[agents] Add agent-session scrobbling for Gemini and opencode providers
This commit is contained in:
35
PROJECT.org
35
PROJECT.org
@ -185,7 +185,7 @@ events).
|
||||
- Timestamp picker (default to now)
|
||||
- Optional: barcode scanning for food/drinks via CameraX
|
||||
|
||||
* Backlog [0/24] :vrobbler:project:personal:
|
||||
* Backlog [2/26] :vrobbler:project:personal:
|
||||
** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata:
|
||||
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
|
||||
:PROPERTIES:
|
||||
@ -698,6 +698,39 @@ The Edit log form should have from top to bottom:
|
||||
*** Description
|
||||
|
||||
** TODO [#A] Add trends tests for concurrent trends :trends:tests:concurrent:
|
||||
** DONE [#A] Add agent-session scrobbling :scrobbles:agents:
|
||||
:PROPERTIES:
|
||||
:ID: 0114a3d8-b13e-bbb6-b461-c7005e9be680
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
What if we could "scrobble" an agent session? Honestly, this is mostly applicable to
|
||||
web searches or quick questions via Gemini or similar web-based agents.
|
||||
|
||||
The scrobble should be the tool, so using Gemini is an agent_session scrobble, and the
|
||||
associated prompt and any response are stored in the AgentSessionLogData. For this,
|
||||
we'd need a way to start a prompt with an AI agent via python libraries like genai.
|
||||
|
||||
Then we'd ask a question or a prompt request in the top Scrobble something bar. In
|
||||
this case, an entry without a -<letter> prefix would be understood as starting a
|
||||
Gemini chat and would send the prompt to Gemini, get a response and return it
|
||||
to the browser, starting a new scrobble and storing any response in the log data.
|
||||
|
||||
Then the AgentSessionLogData would capture a list of turns: [{"prompt_id": <id>,
|
||||
"prompt": "A request prompt", "response": "An agent respones"},
|
||||
|
||||
**** Tasks
|
||||
- [X] Add agents app with AgentSession model and AgentSessionLogData
|
||||
- [X] Add gemini (httpx) and opencode (CLI) agent provider modules
|
||||
- [X] Wire AgentSession into scrobbles (media type, FK, prefetching)
|
||||
- [X] Route unprefixed manual scrobble input to agent sessions
|
||||
- [X] Add async Celery task to fetch turn responses
|
||||
- [X] Render agent turns on scrobble detail with htmx polling
|
||||
- [X] Add agent session list/detail views and templates
|
||||
- [X] Generate migrations
|
||||
- [X] Write tests
|
||||
|
||||
** DONE [#A] Fix small bug in nature importer if geojson is missing :importers:nature:
|
||||
:PROPERTIES:
|
||||
:ID: 0e0a9089-fda1-5e0d-6974-bebacb571cd3
|
||||
|
||||
0
tests/agents_tests/__init__.py
Normal file
0
tests/agents_tests/__init__.py
Normal file
592
tests/agents_tests/test_agent_sessions.py
Normal file
592
tests/agents_tests/test_agent_sessions.py
Normal file
@ -0,0 +1,592 @@
|
||||
import json
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agents.models import AgentSession, AgentSessionLogData
|
||||
from agents.providers import (
|
||||
_parse_opencode_events,
|
||||
agent_prompt,
|
||||
gemini_agent_prompt,
|
||||
opencode_agent_prompt,
|
||||
)
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.scrobblers import (
|
||||
manual_scrobble_agent_follow_up,
|
||||
manual_scrobble_agent_session,
|
||||
)
|
||||
from scrobbles.tasks import scrobble_agent_session_prompt
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db):
|
||||
return User.objects.create_user(username="agentuser", password="pw")
|
||||
|
||||
|
||||
def _mk_scrobble(user, *, in_progress=True, turns=None, title="First prompt"):
|
||||
agent_session, _ = AgentSession.find_or_create(
|
||||
provider="gemini", model=settings.LLM_MODEL
|
||||
)
|
||||
scrobble = Scrobble.create_or_update(
|
||||
agent_session,
|
||||
user.id,
|
||||
{
|
||||
"user_id": user.id,
|
||||
"timestamp": timezone.now(),
|
||||
"playback_position_seconds": 0,
|
||||
"source": "Vrobbler",
|
||||
},
|
||||
skip_in_progress_check=True,
|
||||
)
|
||||
log = scrobble.log if isinstance(scrobble.log, dict) else {}
|
||||
log["provider"] = "gemini"
|
||||
log["model"] = settings.LLM_MODEL
|
||||
log["title"] = title
|
||||
log["turns"] = turns or []
|
||||
scrobble.log = log
|
||||
scrobble.in_progress = in_progress
|
||||
scrobble.save(update_fields=["log", "in_progress"])
|
||||
return scrobble
|
||||
|
||||
|
||||
# --- providers ---
|
||||
|
||||
|
||||
def test_parse_opencode_events_joins_text_parts():
|
||||
raw = "\n".join(
|
||||
[
|
||||
json.dumps({"type": "step_start"}),
|
||||
json.dumps({"type": "text", "part": {"type": "text", "text": "hello"}}),
|
||||
json.dumps({"type": "text", "part": {"type": "text", "text": " world"}}),
|
||||
json.dumps({"type": "step_finish"}),
|
||||
"not-json",
|
||||
]
|
||||
)
|
||||
assert _parse_opencode_events(raw) == "hello\n world"
|
||||
|
||||
|
||||
def test_opencode_agent_prompt(settings):
|
||||
settings.OPENCODE_CMD = "opencode"
|
||||
events = json.dumps(
|
||||
{"type": "text", "part": {"type": "text", "text": "the answer"}}
|
||||
)
|
||||
with patch(
|
||||
"agents.providers.subprocess.run",
|
||||
return_value=MagicMock(returncode=0, stdout=events, stderr=""),
|
||||
) as mock_run:
|
||||
result = opencode_agent_prompt("what is 2+2?")
|
||||
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args.args[0]
|
||||
assert cmd[:2] == ["opencode", "run"]
|
||||
assert cmd[2:5] == ["--format", "json", "--dir"]
|
||||
assert cmd[5].startswith("/tmp/")
|
||||
assert cmd[6] == "what is 2+2?"
|
||||
assert result == {"text": "the answer", "provider": "opencode", "model": "opencode"}
|
||||
|
||||
|
||||
def test_opencode_agent_prompt_nonzero_exit():
|
||||
with patch(
|
||||
"agents.providers.subprocess.run",
|
||||
return_value=MagicMock(returncode=1, stdout="", stderr="boom"),
|
||||
):
|
||||
with pytest.raises(ValueError, match="opencode exited with 1"):
|
||||
opencode_agent_prompt("hi")
|
||||
|
||||
|
||||
def test_opencode_agent_prompt_timeout():
|
||||
with patch(
|
||||
"agents.providers.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired("cmd", 900),
|
||||
):
|
||||
with pytest.raises(ValueError, match="timed out"):
|
||||
opencode_agent_prompt("hi")
|
||||
|
||||
|
||||
def test_opencode_agent_prompt_no_text_output():
|
||||
with patch(
|
||||
"agents.providers.subprocess.run",
|
||||
return_value=MagicMock(
|
||||
returncode=0, stdout=json.dumps({"type": "step_start"}), stderr=""
|
||||
),
|
||||
):
|
||||
with pytest.raises(ValueError, match="no text output"):
|
||||
opencode_agent_prompt("hi")
|
||||
|
||||
|
||||
def test_gemini_agent_prompt(settings):
|
||||
settings.GOOGLE_API_KEY = "test-key"
|
||||
settings.LLM_MODEL = "gemini-2.5-flash"
|
||||
fake = MagicMock()
|
||||
fake.raise_for_status = MagicMock()
|
||||
fake.json.return_value = {
|
||||
"candidates": [{"content": {"parts": [{"text": " the answer "}]}}]
|
||||
}
|
||||
with patch("agents.providers.httpx.post", return_value=fake) as mock_post:
|
||||
result = gemini_agent_prompt("what is 2+2?")
|
||||
|
||||
mock_post.assert_called_once()
|
||||
kwargs = mock_post.call_args.kwargs
|
||||
assert kwargs["params"] == {"key": "test-key"}
|
||||
assert kwargs["json"] == {"contents": [{"parts": [{"text": "what is 2+2?"}]}]}
|
||||
assert result == {
|
||||
"text": "the answer",
|
||||
"provider": "gemini",
|
||||
"model": "gemini-2.5-flash",
|
||||
}
|
||||
|
||||
|
||||
def test_gemini_agent_prompt_missing_key(settings):
|
||||
settings.GOOGLE_API_KEY = ""
|
||||
with pytest.raises(ValueError, match="VROBBLER_GOOGLE_API_KEY is not set"):
|
||||
gemini_agent_prompt("hi")
|
||||
|
||||
|
||||
def test_gemini_agent_prompt_bad_response(settings):
|
||||
settings.GOOGLE_API_KEY = "test-key"
|
||||
fake = MagicMock()
|
||||
fake.raise_for_status = MagicMock()
|
||||
fake.json.return_value = {"unexpected": True}
|
||||
with patch("agents.providers.httpx.post", return_value=fake):
|
||||
with pytest.raises(ValueError, match="Unexpected Gemini response"):
|
||||
gemini_agent_prompt("hi")
|
||||
|
||||
|
||||
def test_gemini_agent_prompt_with_history(settings):
|
||||
settings.GOOGLE_API_KEY = "test-key"
|
||||
fake = MagicMock()
|
||||
fake.raise_for_status = MagicMock()
|
||||
fake.json.return_value = {"candidates": [{"content": {"parts": [{"text": "12"}]}}]}
|
||||
history = [
|
||||
{"prompt": "what is 2+2?", "response": "4"},
|
||||
{"prompt": "no response yet", "response": None},
|
||||
]
|
||||
with patch("agents.providers.httpx.post", return_value=fake) as mock_post:
|
||||
gemini_agent_prompt("times 3?", history=history)
|
||||
|
||||
contents = mock_post.call_args.kwargs["json"]["contents"]
|
||||
assert contents == [
|
||||
{"parts": [{"text": "what is 2+2?"}]},
|
||||
{"parts": [{"text": "4"}]},
|
||||
{"parts": [{"text": "times 3?"}]},
|
||||
]
|
||||
|
||||
|
||||
def test_opencode_agent_prompt_with_history(settings):
|
||||
settings.OPENCODE_CMD = "opencode"
|
||||
with patch(
|
||||
"agents.providers.subprocess.run",
|
||||
return_value=MagicMock(
|
||||
returncode=0,
|
||||
stdout=json.dumps({"type": "text", "part": {"type": "text", "text": "x"}}),
|
||||
stderr="",
|
||||
),
|
||||
) as mock_run:
|
||||
opencode_agent_prompt(
|
||||
"times 3?", history=[{"prompt": "what is 2+2?", "response": "4"}]
|
||||
)
|
||||
|
||||
prompt = mock_run.call_args.args[0][6]
|
||||
assert "Previous conversation:" in prompt
|
||||
assert "User: what is 2+2?" in prompt
|
||||
assert "Assistant: 4" in prompt
|
||||
assert "times 3?" in prompt
|
||||
|
||||
|
||||
def test_agent_prompt_unknown_provider(settings):
|
||||
settings.AGENT_PROVIDER = "nope"
|
||||
with pytest.raises(ValueError, match="Unknown agent provider"):
|
||||
agent_prompt("hi")
|
||||
|
||||
|
||||
def test_agent_prompt_defaults_to_configured_provider(settings):
|
||||
settings.AGENT_PROVIDER = "gemini"
|
||||
settings.GOOGLE_API_KEY = "test-key"
|
||||
with patch("agents.providers.gemini_agent_prompt", return_value={"text": "x"}) as m:
|
||||
agent_prompt("hi")
|
||||
m.assert_called_once_with("hi", history=None)
|
||||
|
||||
|
||||
# --- scrobbler ---
|
||||
|
||||
|
||||
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
|
||||
def test_manual_scrobble_agent_session_creates_new_session(mock_delay, user):
|
||||
scrobble = manual_scrobble_agent_session("What is the capital of France?", user.id)
|
||||
|
||||
assert Scrobble.objects.filter(id=scrobble.id).exists()
|
||||
assert scrobble.media_type == Scrobble.MediaType.AGENT_SESSION
|
||||
assert scrobble.in_progress is True
|
||||
assert scrobble.agent_session.provider == "gemini"
|
||||
assert scrobble.agent_session.model == settings.LLM_MODEL
|
||||
assert scrobble.agent_session.title == f"gemini {settings.LLM_MODEL}"
|
||||
assert scrobble.log["title"] == "What is the capital of France?"
|
||||
turns = scrobble.log["turns"]
|
||||
assert len(turns) == 1
|
||||
assert turns[0]["prompt"] == "What is the capital of France?"
|
||||
assert turns[0]["response"] is None
|
||||
mock_delay.assert_called_once_with(scrobble.id, turns[0]["prompt_id"])
|
||||
|
||||
|
||||
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
|
||||
def test_manual_scrobble_agent_session_reuses_agent_session(mock_delay, user):
|
||||
agent_session, created = AgentSession.find_or_create(
|
||||
provider="gemini", model=settings.LLM_MODEL
|
||||
)
|
||||
assert created is True
|
||||
|
||||
manual_scrobble_agent_session("First question?", user.id)
|
||||
|
||||
agent_session_again, created = AgentSession.find_or_create(
|
||||
provider="gemini", model=settings.LLM_MODEL
|
||||
)
|
||||
assert created is False
|
||||
assert agent_session_again.id == agent_session.id
|
||||
assert AgentSession.objects.count() == 1
|
||||
|
||||
|
||||
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
|
||||
def test_manual_scrobble_agent_session_appends_to_in_progress(mock_delay, user):
|
||||
_mk_scrobble(
|
||||
user,
|
||||
in_progress=True,
|
||||
turns=[
|
||||
{"prompt_id": "first-1", "prompt": "First prompt", "response": "answer"}
|
||||
],
|
||||
)
|
||||
|
||||
scrobble = manual_scrobble_agent_session("And the second question?", user.id)
|
||||
|
||||
turns = scrobble.log["turns"]
|
||||
assert len(turns) == 2
|
||||
assert turns[0]["prompt"] == "First prompt"
|
||||
assert turns[1]["prompt"] == "And the second question?"
|
||||
assert turns[1]["response"] is None
|
||||
assert scrobble.log["title"] == "First prompt"
|
||||
|
||||
|
||||
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
|
||||
def test_manual_scrobble_agent_session_new_session_after_completed(mock_delay, user):
|
||||
_mk_scrobble(user, in_progress=False)
|
||||
|
||||
scrobble = manual_scrobble_agent_session("A new session?", user.id)
|
||||
|
||||
assert scrobble.media_type == Scrobble.MediaType.AGENT_SESSION
|
||||
assert len(scrobble.log["turns"]) == 1
|
||||
assert Scrobble.objects.filter(user_id=user.id).count() == 2
|
||||
|
||||
|
||||
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
|
||||
def test_manual_scrobble_agent_session_opencode_model_empty(mock_delay, user, settings):
|
||||
settings.AGENT_PROVIDER = "opencode"
|
||||
|
||||
scrobble = manual_scrobble_agent_session("hi", user.id)
|
||||
|
||||
assert scrobble.agent_session.provider == "opencode"
|
||||
assert scrobble.agent_session.model == "opencode"
|
||||
assert scrobble.agent_session.title == "opencode opencode"
|
||||
|
||||
|
||||
# --- celery task ---
|
||||
|
||||
|
||||
@patch("agents.providers.agent_prompt")
|
||||
def test_scrobble_agent_session_prompt_fills_turn(mock_agent_prompt, user):
|
||||
scrobble = _mk_scrobble(
|
||||
user,
|
||||
turns=[
|
||||
{
|
||||
"prompt_id": "abc-123",
|
||||
"prompt": "hello",
|
||||
"response": None,
|
||||
}
|
||||
],
|
||||
)
|
||||
mock_agent_prompt.return_value = {
|
||||
"text": "hi back",
|
||||
"provider": "gemini",
|
||||
"model": "gemini-2.5-flash",
|
||||
}
|
||||
|
||||
scrobble_agent_session_prompt(scrobble.id, "abc-123")
|
||||
|
||||
scrobble.refresh_from_db()
|
||||
mock_agent_prompt.assert_called_once_with("hello", provider="gemini", history=[])
|
||||
assert scrobble.log["turns"][0]["response"] == "hi back"
|
||||
assert scrobble.log["provider"] == "gemini"
|
||||
assert scrobble.in_progress is False
|
||||
assert scrobble.played_to_completion is True
|
||||
|
||||
|
||||
@patch("agents.providers.agent_prompt")
|
||||
def test_scrobble_agent_session_prompt_passes_history(mock_agent_prompt, user):
|
||||
scrobble = _mk_scrobble(
|
||||
user,
|
||||
turns=[
|
||||
{
|
||||
"prompt_id": "first-1",
|
||||
"prompt": "what is 2+2?",
|
||||
"response": "4",
|
||||
},
|
||||
{
|
||||
"prompt_id": "err-1",
|
||||
"prompt": "boom?",
|
||||
"response": "Error: boom",
|
||||
"error": True,
|
||||
},
|
||||
{
|
||||
"prompt_id": "cur-1",
|
||||
"prompt": "and times 3?",
|
||||
"response": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
mock_agent_prompt.return_value = {
|
||||
"text": "12",
|
||||
"provider": "gemini",
|
||||
"model": settings.LLM_MODEL,
|
||||
}
|
||||
scrobble_agent_session_prompt(scrobble.id, "cur-1")
|
||||
|
||||
mock_agent_prompt.assert_called_once_with(
|
||||
"and times 3?",
|
||||
provider="gemini",
|
||||
history=[{"prompt": "what is 2+2?", "response": "4"}],
|
||||
)
|
||||
|
||||
|
||||
@patch("agents.providers.agent_prompt")
|
||||
def test_scrobble_agent_session_prompt_stores_error(mock_agent_prompt, user):
|
||||
scrobble = _mk_scrobble(
|
||||
user,
|
||||
turns=[
|
||||
{
|
||||
"prompt_id": "abc-123",
|
||||
"prompt": "hello",
|
||||
"response": None,
|
||||
}
|
||||
],
|
||||
)
|
||||
mock_agent_prompt.side_effect = ValueError("boom")
|
||||
|
||||
scrobble_agent_session_prompt(scrobble.id, "abc-123")
|
||||
|
||||
scrobble.refresh_from_db()
|
||||
assert scrobble.log["turns"][0]["response"] == "Error: boom"
|
||||
assert scrobble.log["turns"][0]["error"] is True
|
||||
assert scrobble.in_progress is False
|
||||
|
||||
|
||||
# --- views ---
|
||||
|
||||
|
||||
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
|
||||
def test_manual_scrobble_view_routes_plain_text_to_agent(mock_delay, client, user):
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
reverse("scrobbles:lookup-manual-scrobble"),
|
||||
{"item_id": "what is the meaning of life?"},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
scrobble = Scrobble.objects.filter(user_id=user.id).first()
|
||||
assert scrobble is not None
|
||||
assert scrobble.media_type == Scrobble.MediaType.AGENT_SESSION
|
||||
assert response.url.startswith(reverse("scrobbles:detail", args=[scrobble.id]))
|
||||
|
||||
|
||||
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
|
||||
def test_agent_session_partial_pending_polls(mock_delay, client, user):
|
||||
scrobble = _mk_scrobble(
|
||||
user,
|
||||
turns=[{"prompt_id": "abc-123", "prompt": "hello", "response": None}],
|
||||
)
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
reverse("scrobbles:agent-session-partial", args=[scrobble.id])
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b"every 5s" in response.content
|
||||
assert b"Thinking" in response.content
|
||||
|
||||
|
||||
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
|
||||
def test_agent_session_partial_completed_stops_polling(mock_delay, client, user):
|
||||
scrobble = _mk_scrobble(
|
||||
user,
|
||||
in_progress=False,
|
||||
turns=[
|
||||
{
|
||||
"prompt_id": "abc-123",
|
||||
"prompt": "hello",
|
||||
"response": "**hi back**",
|
||||
}
|
||||
],
|
||||
)
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
reverse("scrobbles:agent-session-partial", args=[scrobble.id])
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b"every 5s" not in response.content
|
||||
assert b"hi back" in response.content
|
||||
|
||||
|
||||
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
|
||||
def test_agent_session_list_view(mock_delay, client, user):
|
||||
_mk_scrobble(user, in_progress=True)
|
||||
client.force_login(user)
|
||||
response = client.get(reverse("agents:agent_session_list"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert settings.LLM_MODEL.encode() in response.content
|
||||
|
||||
|
||||
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
|
||||
def test_agent_session_detail_view(mock_delay, client, user):
|
||||
scrobble = _mk_scrobble(user, in_progress=True)
|
||||
client.force_login(user)
|
||||
response = client.get(scrobble.media_obj.get_absolute_url())
|
||||
|
||||
assert response.status_code == 200
|
||||
assert settings.LLM_MODEL.encode() in response.content
|
||||
|
||||
|
||||
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
|
||||
def test_manual_scrobble_agent_follow_up_appends_turn(mock_delay, user):
|
||||
scrobble = _mk_scrobble(
|
||||
user,
|
||||
in_progress=False,
|
||||
turns=[
|
||||
{
|
||||
"prompt_id": "abc-123",
|
||||
"prompt": "hello",
|
||||
"response": "hi back",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
result = manual_scrobble_agent_follow_up("tell me more", scrobble.id, user.id)
|
||||
|
||||
assert result.id == scrobble.id
|
||||
scrobble.refresh_from_db()
|
||||
turns = scrobble.log["turns"]
|
||||
assert len(turns) == 2
|
||||
assert turns[1]["prompt"] == "tell me more"
|
||||
assert turns[1]["response"] is None
|
||||
assert scrobble.in_progress is True
|
||||
mock_delay.assert_called_once_with(scrobble.id, turns[1]["prompt_id"])
|
||||
|
||||
|
||||
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
|
||||
def test_manual_scrobble_agent_follow_up_unknown_scrobble(mock_delay, user):
|
||||
assert manual_scrobble_agent_follow_up("tell me more", 999999, user.id) is None
|
||||
mock_delay.assert_not_called()
|
||||
|
||||
|
||||
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
|
||||
def test_agent_session_followup_post_appends_turn(mock_delay, client, user):
|
||||
scrobble = _mk_scrobble(
|
||||
user,
|
||||
in_progress=False,
|
||||
turns=[
|
||||
{
|
||||
"prompt_id": "abc-123",
|
||||
"prompt": "hello",
|
||||
"response": "hi back",
|
||||
}
|
||||
],
|
||||
)
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
reverse("scrobbles:agent-session-followup", args=[scrobble.id]),
|
||||
{"prompt": "tell me more"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b"tell me more" in response.content
|
||||
assert b"Thinking" in response.content
|
||||
assert b"every 5s" in response.content
|
||||
scrobble.refresh_from_db()
|
||||
assert len(scrobble.log["turns"]) == 2
|
||||
assert scrobble.in_progress is True
|
||||
|
||||
|
||||
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
|
||||
def test_agent_session_followup_post_empty_prompt(mock_delay, client, user):
|
||||
scrobble = _mk_scrobble(user, in_progress=False)
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
reverse("scrobbles:agent-session-followup", args=[scrobble.id]),
|
||||
{"prompt": " "},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
mock_delay.assert_not_called()
|
||||
|
||||
|
||||
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
|
||||
def test_agent_session_followup_form_shown_when_complete(mock_delay, client, user):
|
||||
scrobble = _mk_scrobble(
|
||||
user,
|
||||
in_progress=False,
|
||||
turns=[
|
||||
{
|
||||
"prompt_id": "abc-123",
|
||||
"prompt": "hello",
|
||||
"response": "hi back",
|
||||
}
|
||||
],
|
||||
)
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
reverse("scrobbles:agent-session-partial", args=[scrobble.id])
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b"Ask a follow-up" in response.content
|
||||
assert b"/follow-up/" in response.content
|
||||
assert b"csrfmiddlewaretoken" in response.content
|
||||
|
||||
|
||||
# --- log data ---
|
||||
|
||||
|
||||
def test_agent_session_logdata_pending():
|
||||
log = AgentSessionLogData(
|
||||
provider="gemini",
|
||||
model="gemini-2.5-flash",
|
||||
turns=[{"prompt_id": "1", "prompt": "hi", "response": None}],
|
||||
)
|
||||
assert log.pending is True
|
||||
log.turns[0]["response"] = "hi back"
|
||||
assert log.pending is False
|
||||
assert AgentSessionLogData(provider="gemini").pending is False
|
||||
|
||||
|
||||
def test_agent_session_logdata_as_html():
|
||||
log = AgentSessionLogData(
|
||||
provider="gemini",
|
||||
model="gemini-2.5-flash",
|
||||
turns=[
|
||||
{"prompt_id": "1", "prompt": "**bold?**", "response": "yes"},
|
||||
{"prompt_id": "2", "prompt": "again", "response": None},
|
||||
],
|
||||
)
|
||||
html = log.as_html()
|
||||
|
||||
assert '<div class="agent-turn">' in html
|
||||
assert "Prompt 1" in html
|
||||
assert "<strong>bold?</strong>" in html
|
||||
assert "yes" in html
|
||||
assert "Prompt 2" in html
|
||||
assert "Thinking" in html
|
||||
0
vrobbler/apps/agents/__init__.py
Normal file
0
vrobbler/apps/agents/__init__.py
Normal file
8
vrobbler/apps/agents/admin.py
Normal file
8
vrobbler/apps/agents/admin.py
Normal file
@ -0,0 +1,8 @@
|
||||
from agents.models import AgentSession
|
||||
from django.contrib import admin
|
||||
|
||||
|
||||
@admin.register(AgentSession)
|
||||
class AgentSessionAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "provider", "model", "created")
|
||||
search_fields = ("title",)
|
||||
5
vrobbler/apps/agents/apps.py
Normal file
5
vrobbler/apps/agents/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AgentsConfig(AppConfig):
|
||||
name = "agents"
|
||||
78
vrobbler/apps/agents/migrations/0001_initial.py
Normal file
78
vrobbler/apps/agents/migrations/0001_initial.py
Normal file
@ -0,0 +1,78 @@
|
||||
# Generated by Django 4.2.29 on 2026-07-31 03:27
|
||||
|
||||
from django.db import migrations, models
|
||||
import django_extensions.db.fields
|
||||
import taggit.managers
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
|
||||
("scrobbles", "0104_backfill_null_uuids"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AgentSession",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
("title", models.CharField(blank=True, max_length=255, null=True)),
|
||||
("base_run_time_seconds", models.IntegerField(blank=True, null=True)),
|
||||
("provider", models.CharField(blank=True, max_length=50, null=True)),
|
||||
("model", models.CharField(blank=True, max_length=100, null=True)),
|
||||
(
|
||||
"genre",
|
||||
taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Genre",
|
||||
),
|
||||
),
|
||||
(
|
||||
"tags",
|
||||
taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="taggit.TaggedItem",
|
||||
to="taggit.Tag",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
||||
0
vrobbler/apps/agents/migrations/__init__.py
Normal file
0
vrobbler/apps/agents/migrations/__init__.py
Normal file
138
vrobbler/apps/agents/models.py
Normal file
138
vrobbler/apps/agents/models.py
Normal file
@ -0,0 +1,138 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import bleach
|
||||
import markdown
|
||||
from django import forms
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from scrobbles.dataclasses import BaseLogData
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
AGENT_HTML_ALLOWED_TAGS = [
|
||||
"p",
|
||||
"br",
|
||||
"strong",
|
||||
"em",
|
||||
"a",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"code",
|
||||
"pre",
|
||||
"blockquote",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"hr",
|
||||
"img",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentSessionLogData(BaseLogData):
|
||||
title: Optional[str] = None
|
||||
provider: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
turns: Optional[list] = None
|
||||
|
||||
_excluded_fields = {"turns"}
|
||||
|
||||
@classmethod
|
||||
def override_fields(cls) -> dict:
|
||||
return {
|
||||
"title": forms.CharField(required=False),
|
||||
"provider": forms.CharField(required=False),
|
||||
"model": forms.CharField(required=False),
|
||||
}
|
||||
|
||||
@property
|
||||
def pending(self) -> bool:
|
||||
if not self.turns:
|
||||
return False
|
||||
return any(turn.get("response") is None for turn in self.turns)
|
||||
|
||||
@staticmethod
|
||||
def _md_to_html(text: str) -> str:
|
||||
md = markdown.Markdown(extensions=["extra"])
|
||||
return bleach.clean(md.convert(text), tags=AGENT_HTML_ALLOWED_TAGS, strip=True)
|
||||
|
||||
def as_html(self) -> str:
|
||||
if not self.turns:
|
||||
return ""
|
||||
|
||||
html_parts = []
|
||||
for i, turn in enumerate(self.turns):
|
||||
prompt = turn.get("prompt", "")
|
||||
response = turn.get("response")
|
||||
html_parts.append('<div class="agent-turn">')
|
||||
html_parts.append(
|
||||
f'<div class="agent-prompt"><h5>Prompt {i + 1}</h5>'
|
||||
f"{self._md_to_html(prompt)}</div>"
|
||||
)
|
||||
if response:
|
||||
html_parts.append(
|
||||
f'<div class="agent-response"><h5>Response</h5>'
|
||||
f"{self._md_to_html(response)}</div>"
|
||||
)
|
||||
else:
|
||||
html_parts.append(
|
||||
'<div class="agent-response agent-thinking">Thinking…</div>'
|
||||
)
|
||||
html_parts.append("</div>")
|
||||
return "".join(html_parts)
|
||||
|
||||
|
||||
class AgentSession(ScrobblableMixin):
|
||||
provider = models.CharField(max_length=50, **BNULL)
|
||||
model = models.CharField(max_length=100, **BNULL)
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, provider: str, model: str):
|
||||
agent_session = cls.objects.filter(provider=provider, model=model).first()
|
||||
if agent_session:
|
||||
return agent_session, False
|
||||
title = f"{provider} {model}".strip()
|
||||
agent_session = cls.objects.create(provider=provider, model=model, title=title)
|
||||
return agent_session, True
|
||||
|
||||
def __str__(self):
|
||||
if self.title:
|
||||
return self.title
|
||||
return str(self.uuid)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("agents:agent_session_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@property
|
||||
def subtitle(self) -> str:
|
||||
return " ".join(p for p in [self.provider, self.model] if p)
|
||||
|
||||
@property
|
||||
def strings(self) -> ScrobblableConstants:
|
||||
return ScrobblableConstants(verb="Asking", tags="agents")
|
||||
|
||||
@property
|
||||
def start_url(self) -> str:
|
||||
return ""
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
return AgentSessionLogData
|
||||
|
||||
@property
|
||||
def primary_image_url(self) -> str:
|
||||
return ""
|
||||
129
vrobbler/apps/agents/providers.py
Normal file
129
vrobbler/apps/agents/providers.py
Normal file
@ -0,0 +1,129 @@
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import httpx
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GEMINI_GENERATE_CONTENT_URL = (
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
|
||||
)
|
||||
|
||||
|
||||
def agent_prompt(
|
||||
prompt: str,
|
||||
provider: str | None = None,
|
||||
history: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""Send a prompt to the configured agent provider and return the response.
|
||||
|
||||
``history`` is a list of prior turns as ``{"prompt": str, "response": str}``
|
||||
dicts, used as conversation context for follow-up questions.
|
||||
|
||||
Returns a dict with ``text``, ``provider`` and ``model`` keys.
|
||||
"""
|
||||
provider = provider or settings.AGENT_PROVIDER
|
||||
if provider == "gemini":
|
||||
return gemini_agent_prompt(prompt, history=history)
|
||||
if provider == "opencode":
|
||||
return opencode_agent_prompt(prompt, history=history)
|
||||
raise ValueError(f"Unknown agent provider: {provider}")
|
||||
|
||||
|
||||
def _gemini_contents(prompt: str, history: list[dict] | None) -> list[dict]:
|
||||
contents = []
|
||||
for turn in history or []:
|
||||
if not turn.get("response"):
|
||||
continue
|
||||
contents.append({"parts": [{"text": turn["prompt"]}]})
|
||||
contents.append({"parts": [{"text": turn["response"]}]})
|
||||
contents.append({"parts": [{"text": prompt}]})
|
||||
return contents
|
||||
|
||||
|
||||
def gemini_agent_prompt(prompt: str, history: list[dict] | None = None) -> dict:
|
||||
api_key = settings.GOOGLE_API_KEY
|
||||
if not api_key:
|
||||
raise ValueError("VROBBLER_GOOGLE_API_KEY is not set")
|
||||
model = settings.LLM_MODEL
|
||||
|
||||
response = httpx.post(
|
||||
GEMINI_GENERATE_CONTENT_URL.format(model=model),
|
||||
params={"key": api_key},
|
||||
json={"contents": _gemini_contents(prompt, history)},
|
||||
timeout=120.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
try:
|
||||
text = data["candidates"][0]["content"]["parts"][0]["text"]
|
||||
except (KeyError, IndexError) as e:
|
||||
raise ValueError(f"Unexpected Gemini response: {data}") from e
|
||||
|
||||
return {"text": text.strip(), "provider": "gemini", "model": model}
|
||||
|
||||
|
||||
def _opencode_prompt_with_history(prompt: str, history: list[dict] | None) -> str:
|
||||
if not history:
|
||||
return prompt
|
||||
lines = ["Previous conversation:"]
|
||||
for turn in history:
|
||||
if not turn.get("response"):
|
||||
continue
|
||||
lines.append(f"User: {turn['prompt']}")
|
||||
lines.append(f"Assistant: {turn['response']}")
|
||||
lines.extend(["", "Continue the conversation and answer the latest question:"])
|
||||
return "\n".join(lines) + "\n\n" + prompt
|
||||
|
||||
|
||||
def opencode_agent_prompt(prompt: str, history: list[dict] | None = None) -> dict:
|
||||
with tempfile.TemporaryDirectory(prefix="vrobbler-agent-") as tmp_dir:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
settings.OPENCODE_CMD,
|
||||
"run",
|
||||
"--format",
|
||||
"json",
|
||||
"--dir",
|
||||
tmp_dir,
|
||||
_opencode_prompt_with_history(prompt, history),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=900,
|
||||
)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise ValueError("opencode agent timed out") from e
|
||||
|
||||
if result.returncode != 0:
|
||||
raise ValueError(
|
||||
f"opencode exited with {result.returncode}: {result.stderr[:500]}"
|
||||
)
|
||||
|
||||
text = _parse_opencode_events(result.stdout)
|
||||
if not text:
|
||||
raise ValueError("opencode agent returned no text output")
|
||||
return {"text": text.strip(), "provider": "opencode", "model": "opencode"}
|
||||
|
||||
|
||||
def _parse_opencode_events(raw: str) -> str:
|
||||
parts = []
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if event.get("type") != "text":
|
||||
continue
|
||||
part = event.get("part") or {}
|
||||
if part.get("text"):
|
||||
parts.append(part["text"])
|
||||
return "\n".join(parts)
|
||||
14
vrobbler/apps/agents/urls.py
Normal file
14
vrobbler/apps/agents/urls.py
Normal file
@ -0,0 +1,14 @@
|
||||
from agents import views
|
||||
from django.urls import path
|
||||
|
||||
app_name = "agents"
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("agents/", views.AgentSessionListView.as_view(), name="agent_session_list"),
|
||||
path(
|
||||
"agents/<slug:slug>/",
|
||||
views.AgentSessionDetailView.as_view(),
|
||||
name="agent_session_detail",
|
||||
),
|
||||
]
|
||||
10
vrobbler/apps/agents/views.py
Normal file
10
vrobbler/apps/agents/views.py
Normal file
@ -0,0 +1,10 @@
|
||||
from agents.models import AgentSession
|
||||
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
|
||||
|
||||
|
||||
class AgentSessionListView(ScrobbleableListView):
|
||||
model = AgentSession
|
||||
|
||||
|
||||
class AgentSessionDetailView(ScrobbleableDetailView):
|
||||
model = AgentSession
|
||||
@ -22,7 +22,7 @@ class ScrobbleForm(forms.Form):
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": "form-control form-control-dark w-100",
|
||||
"placeholder": "Scrobble something (ttIMDB, -v Video Game title, -b Book title, -s TheSportsDB ID, -f Food name - calories)",
|
||||
"placeholder": "Scrobble something (ttIMDB, -v Video Game title, -b Book title, -s TheSportsDB ID, -f Food name - calories, or type a question for Gemini)",
|
||||
"aria-label": "Scrobble something",
|
||||
}
|
||||
),
|
||||
|
||||
@ -0,0 +1,96 @@
|
||||
# Generated by Django 4.2.29 on 2026-07-31 03:27
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("agents", "0001_initial"),
|
||||
("scrobbles", "0104_backfill_null_uuids"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="scrobble",
|
||||
name="agent_session",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="agents.agentsession",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="favoritemedia",
|
||||
name="media_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("AgentSession", "Agent session"),
|
||||
("Video", "Video"),
|
||||
("Track", "Track"),
|
||||
("PodcastEpisode", "Podcast episode"),
|
||||
("SportEvent", "Sport event"),
|
||||
("Book", "Book"),
|
||||
("Paper", "Paper"),
|
||||
("VideoGame", "Video game"),
|
||||
("BoardGame", "Board game"),
|
||||
("GeoLocation", "GeoLocation"),
|
||||
("Trail", "Trail"),
|
||||
("Beer", "Beer"),
|
||||
("Wine", "Wine"),
|
||||
("Coffee", "Coffee"),
|
||||
("Drink", "Drink"),
|
||||
("Puzzle", "Puzzle"),
|
||||
("Food", "Food"),
|
||||
("Task", "Task"),
|
||||
("WebPage", "Web Page"),
|
||||
("LifeEvent", "Life event"),
|
||||
("Mood", "Mood"),
|
||||
("BrickSet", "Brick set"),
|
||||
("Channel", "Channel"),
|
||||
("BirdingLocation", "Birding location"),
|
||||
("DiscGolfCourse", "Disc golf"),
|
||||
("SpeciesObservation", "Species observation"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="scrobble",
|
||||
name="media_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("AgentSession", "Agent session"),
|
||||
("Video", "Video"),
|
||||
("Track", "Track"),
|
||||
("PodcastEpisode", "Podcast episode"),
|
||||
("SportEvent", "Sport event"),
|
||||
("Book", "Book"),
|
||||
("Paper", "Paper"),
|
||||
("VideoGame", "Video game"),
|
||||
("BoardGame", "Board game"),
|
||||
("GeoLocation", "GeoLocation"),
|
||||
("Trail", "Trail"),
|
||||
("Beer", "Beer"),
|
||||
("Wine", "Wine"),
|
||||
("Coffee", "Coffee"),
|
||||
("Drink", "Drink"),
|
||||
("Puzzle", "Puzzle"),
|
||||
("Food", "Food"),
|
||||
("Task", "Task"),
|
||||
("WebPage", "Web Page"),
|
||||
("LifeEvent", "Life event"),
|
||||
("Mood", "Mood"),
|
||||
("BrickSet", "Brick set"),
|
||||
("Channel", "Channel"),
|
||||
("BirdingLocation", "Birding location"),
|
||||
("DiscGolfCourse", "Disc golf"),
|
||||
("SpeciesObservation", "Species observation"),
|
||||
],
|
||||
default="Video",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -9,6 +9,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
import pendulum
|
||||
import pytz
|
||||
from agents.models import AgentSession
|
||||
from birds.models import BirdingLocation
|
||||
from boardgames.models import BoardGame
|
||||
from books.koreader import process_koreader_sqlite_file
|
||||
@ -674,6 +675,7 @@ class UDiscCSVImport(BaseFileImportMixin):
|
||||
|
||||
|
||||
TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
|
||||
"AgentSession": ("agent_session",),
|
||||
"Video": ("video",),
|
||||
"Track": ("track", "track__artist_fk"),
|
||||
"PodcastEpisode": ("podcast_episode", "podcast_episode__podcast"),
|
||||
@ -705,6 +707,7 @@ TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
|
||||
class ScrobbleQuerySet(models.QuerySet):
|
||||
def with_related(self):
|
||||
return self.select_related("user").prefetch_related(
|
||||
"agent_session",
|
||||
"video",
|
||||
"track",
|
||||
"track__artist_fk",
|
||||
@ -761,6 +764,7 @@ class Scrobble(TimeStampedModel):
|
||||
class MediaType(models.TextChoices):
|
||||
"""Enum mapping a media model type to a string"""
|
||||
|
||||
AGENT_SESSION = "AgentSession", "Agent session"
|
||||
VIDEO = "Video", "Video"
|
||||
TRACK = "Track", "Track"
|
||||
PODCAST_EPISODE = "PodcastEpisode", "Podcast episode"
|
||||
@ -792,6 +796,9 @@ class Scrobble(TimeStampedModel):
|
||||
return list(map(lambda c: c.value, cls))
|
||||
|
||||
uuid = models.UUIDField(editable=False, **BNULL)
|
||||
agent_session = models.ForeignKey(
|
||||
AgentSession, on_delete=models.DO_NOTHING, **BNULL
|
||||
)
|
||||
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
|
||||
channel = models.ForeignKey("videos.Channel", on_delete=models.DO_NOTHING, **BNULL)
|
||||
track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
|
||||
@ -1361,6 +1368,8 @@ class Scrobble(TimeStampedModel):
|
||||
@property
|
||||
def media_obj(self):
|
||||
media_obj = None
|
||||
if self.agent_session:
|
||||
media_obj = self.agent_session
|
||||
if self.video:
|
||||
media_obj = self.video
|
||||
if self.track:
|
||||
|
||||
@ -2,6 +2,7 @@ import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import pendulum
|
||||
import pytz
|
||||
@ -1568,3 +1569,134 @@ def manual_scrobble_discgolf(
|
||||
)
|
||||
|
||||
return Scrobble.create_or_update(course, user_id, scrobble_dict)
|
||||
|
||||
|
||||
def _append_agent_turn(
|
||||
scrobble, prompt: str, provider: str, model: str, *, set_in_progress: bool = False
|
||||
) -> str:
|
||||
"""Append a pending turn to an agent session scrobble's log data."""
|
||||
log = scrobble.log if isinstance(scrobble.log, dict) else {}
|
||||
prompt_id = str(uuid4())
|
||||
turns = log.get("turns", [])
|
||||
turns.append({"prompt_id": prompt_id, "prompt": prompt, "response": None})
|
||||
log["turns"] = turns
|
||||
log["provider"] = provider
|
||||
log["model"] = model
|
||||
scrobble.log = log
|
||||
|
||||
update_fields = ["log"]
|
||||
if set_in_progress:
|
||||
scrobble.in_progress = True
|
||||
update_fields.append("in_progress")
|
||||
scrobble.save(update_fields=update_fields)
|
||||
return prompt_id
|
||||
|
||||
|
||||
def manual_scrobble_agent_session(
|
||||
prompt: str,
|
||||
user_id: int,
|
||||
source: str = "Vrobbler",
|
||||
):
|
||||
"""Start (or append to) an agent session scrobble for a prompt.
|
||||
|
||||
The scrobbled media object is the agent model itself; the initial prompt is
|
||||
stored as the title in the scrobble's log data. Follow-up prompts append a
|
||||
new turn to the user's most recent in-progress scrobble for that model, or a
|
||||
new session is started if none exists. The provider call itself happens
|
||||
asynchronously in a Celery task.
|
||||
"""
|
||||
from agents.models import AgentSession
|
||||
from django.conf import settings
|
||||
|
||||
provider = settings.AGENT_PROVIDER
|
||||
model = settings.LLM_MODEL if provider == "gemini" else provider
|
||||
|
||||
agent_session, _ = AgentSession.find_or_create(provider=provider, model=model)
|
||||
|
||||
last_scrobble = (
|
||||
Scrobble.objects.filter(
|
||||
user_id=user_id,
|
||||
agent_session_id=agent_session.id,
|
||||
in_progress=True,
|
||||
)
|
||||
.order_by("-timestamp")
|
||||
.first()
|
||||
)
|
||||
|
||||
if last_scrobble:
|
||||
scrobble = last_scrobble
|
||||
else:
|
||||
scrobble_dict = {
|
||||
"user_id": user_id,
|
||||
"timestamp": timezone.now(),
|
||||
"playback_position_seconds": 0,
|
||||
"source": source,
|
||||
}
|
||||
scrobble = Scrobble.create_or_update(
|
||||
agent_session,
|
||||
user_id,
|
||||
scrobble_dict,
|
||||
skip_in_progress_check=True,
|
||||
)
|
||||
|
||||
log = scrobble.log if isinstance(scrobble.log, dict) else {}
|
||||
if "title" not in log:
|
||||
log["title"] = prompt
|
||||
scrobble.log = log
|
||||
scrobble.save(update_fields=["log"])
|
||||
|
||||
prompt_id = _append_agent_turn(scrobble, prompt, provider, model)
|
||||
|
||||
logger.info(
|
||||
"[scrobblers] manual agent session scrobble request received",
|
||||
extra={
|
||||
"scrobble_id": scrobble.id,
|
||||
"agent_session_id": agent_session.id,
|
||||
"user_id": user_id,
|
||||
"prompt_id": prompt_id,
|
||||
"media_type": Scrobble.MediaType.AGENT_SESSION,
|
||||
},
|
||||
)
|
||||
|
||||
from scrobbles.tasks import scrobble_agent_session_prompt
|
||||
|
||||
scrobble_agent_session_prompt.delay(scrobble.id, prompt_id)
|
||||
return scrobble
|
||||
|
||||
|
||||
def manual_scrobble_agent_follow_up(
|
||||
prompt: str,
|
||||
scrobble_id: int,
|
||||
user_id: int,
|
||||
):
|
||||
"""Append a follow-up turn to a specific agent session scrobble."""
|
||||
from django.conf import settings
|
||||
|
||||
scrobble = Scrobble.objects.filter(id=scrobble_id, user_id=user_id).first()
|
||||
if not scrobble:
|
||||
return None
|
||||
|
||||
log = scrobble.log if isinstance(scrobble.log, dict) else {}
|
||||
provider = log.get("provider") or settings.AGENT_PROVIDER
|
||||
model = log.get("model") or (
|
||||
settings.LLM_MODEL if provider == "gemini" else provider
|
||||
)
|
||||
|
||||
prompt_id = _append_agent_turn(
|
||||
scrobble, prompt, provider, model, set_in_progress=True
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[scrobblers] manual agent session follow-up received",
|
||||
extra={
|
||||
"scrobble_id": scrobble.id,
|
||||
"user_id": user_id,
|
||||
"prompt_id": prompt_id,
|
||||
"media_type": Scrobble.MediaType.AGENT_SESSION,
|
||||
},
|
||||
)
|
||||
|
||||
from scrobbles.tasks import scrobble_agent_session_prompt
|
||||
|
||||
scrobble_agent_session_prompt.delay(scrobble.id, prompt_id)
|
||||
return scrobble
|
||||
|
||||
@ -99,6 +99,67 @@ def check_twitch_channels_for_vods():
|
||||
logger.info(f"[twitch_vods] Matched {matched_count} VODs")
|
||||
|
||||
|
||||
@shared_task
|
||||
def scrobble_agent_session_prompt(scrobble_id, prompt_id):
|
||||
"""Ask the configured agent provider for a turn response and store it.
|
||||
|
||||
Looks up the pending turn by ``prompt_id`` on the scrobble's log data,
|
||||
calls the agent provider, then fills in the response and finishes the
|
||||
scrobble so the detail page can stop polling.
|
||||
"""
|
||||
from agents.providers import agent_prompt
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
scrobble = Scrobble.objects.filter(id=scrobble_id).first()
|
||||
if not scrobble:
|
||||
logger.warning(
|
||||
"[scrobble_agent_session_prompt] scrobble not found",
|
||||
extra={"scrobble_id": scrobble_id},
|
||||
)
|
||||
return
|
||||
|
||||
log = scrobble.log if isinstance(scrobble.log, dict) else {}
|
||||
turn = next(
|
||||
(t for t in log.get("turns", []) if t.get("prompt_id") == prompt_id), None
|
||||
)
|
||||
if not turn:
|
||||
logger.warning(
|
||||
"[scrobble_agent_session_prompt] turn not found",
|
||||
extra={"scrobble_id": scrobble_id, "prompt_id": prompt_id},
|
||||
)
|
||||
return
|
||||
|
||||
prompt = turn.get("prompt", "")
|
||||
provider = log.get("provider") or settings.AGENT_PROVIDER
|
||||
|
||||
history = []
|
||||
for prior_turn in log.get("turns", []):
|
||||
if prior_turn.get("prompt_id") == prompt_id:
|
||||
break
|
||||
if prior_turn.get("response") and not prior_turn.get("error"):
|
||||
history.append(
|
||||
{"prompt": prior_turn["prompt"], "response": prior_turn["response"]}
|
||||
)
|
||||
|
||||
try:
|
||||
result = agent_prompt(prompt, provider=provider, history=history)
|
||||
turn["response"] = result["text"]
|
||||
log["provider"] = result["provider"]
|
||||
log["model"] = result["model"]
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"[scrobble_agent_session_prompt] provider error",
|
||||
extra={"scrobble_id": scrobble_id, "prompt_id": prompt_id},
|
||||
)
|
||||
turn["response"] = f"Error: {e}"
|
||||
turn["error"] = True
|
||||
|
||||
scrobble.log = log
|
||||
scrobble.played_to_completion = True
|
||||
scrobble.in_progress = False
|
||||
scrobble.save(update_fields=["log", "played_to_completion", "in_progress"])
|
||||
|
||||
|
||||
@shared_task
|
||||
def process_retroarch_import(import_id):
|
||||
RetroarchImport = apps.get_model("scrobbles", "RetroarchImport")
|
||||
|
||||
@ -44,6 +44,16 @@ urlpatterns = [
|
||||
views.ManualScrobbleView.as_view(),
|
||||
name="lookup-manual-scrobble",
|
||||
),
|
||||
path(
|
||||
"agent-session/<int:pk>/partial/",
|
||||
views.ScrobbleAgentSessionPartialView.as_view(),
|
||||
name="agent-session-partial",
|
||||
),
|
||||
path(
|
||||
"agent-session/<int:pk>/follow-up/",
|
||||
views.AgentSessionFollowUpView.as_view(),
|
||||
name="agent-session-followup",
|
||||
),
|
||||
path(
|
||||
"long-play-finish/<slug:media_uuid>/",
|
||||
views.scrobble_longplay_finish,
|
||||
|
||||
@ -33,6 +33,7 @@ from django.http import (
|
||||
JsonResponse,
|
||||
)
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.dateformat import DateFormat
|
||||
@ -388,6 +389,45 @@ class NowPlayingPartialView(LoginRequiredMixin, TemplateView):
|
||||
return ctx
|
||||
|
||||
|
||||
class ScrobbleAgentSessionPartialView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "scrobbles/_agent_session.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
scrobble = get_object_or_404(
|
||||
Scrobble,
|
||||
pk=self.kwargs["pk"],
|
||||
user=self.request.user,
|
||||
media_type=Scrobble.MediaType.AGENT_SESSION,
|
||||
)
|
||||
ctx["object"] = scrobble
|
||||
return ctx
|
||||
|
||||
|
||||
class AgentSessionFollowUpView(LoginRequiredMixin, View):
|
||||
http_method_names = ["post"]
|
||||
template_name = "scrobbles/_agent_session.html"
|
||||
|
||||
def post(self, request, pk):
|
||||
prompt = request.POST.get("prompt", "").strip()
|
||||
scrobble = get_object_or_404(
|
||||
Scrobble,
|
||||
pk=pk,
|
||||
user=request.user,
|
||||
media_type=Scrobble.MediaType.AGENT_SESSION,
|
||||
)
|
||||
if not prompt:
|
||||
messages.error(request, "Follow-up prompt is empty.")
|
||||
return HttpResponseRedirect(scrobble.get_absolute_url())
|
||||
|
||||
scrobble = manual_scrobble_agent_follow_up(prompt, scrobble.id, request.user.id)
|
||||
return TemplateResponse(
|
||||
request,
|
||||
self.template_name,
|
||||
context={"object": scrobble},
|
||||
)
|
||||
|
||||
|
||||
class ScrobbleListView(LoginRequiredMixin, ListView):
|
||||
model = Scrobble
|
||||
paginate_by = 100
|
||||
@ -643,6 +683,13 @@ class ManualScrobbleView(FormView):
|
||||
if key == "-f" and " - " not in item_id:
|
||||
return HttpResponseRedirect(reverse("foods:food_search") + f"?q={item_id}")
|
||||
|
||||
if key not in MANUAL_SCROBBLE_FNS:
|
||||
scrobble = manual_scrobble_agent_session(item_str, self.request.user.id)
|
||||
if scrobble:
|
||||
return HttpResponseRedirect(scrobble.get_absolute_url())
|
||||
messages.error(self.request, "Could not start agent session.")
|
||||
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER", "/"))
|
||||
|
||||
scrobble_fn = MANUAL_SCROBBLE_FNS[key]
|
||||
scrobble = eval(scrobble_fn)(item_id, self.request.user.id)
|
||||
|
||||
|
||||
@ -82,6 +82,10 @@ TODOIST_CLIENT_SECRET = os.getenv("VROBBLER_TODOIST_CLIENT_SECRET", "")
|
||||
GOOGLE_API_KEY = os.getenv("VROBBLER_GOOGLE_API_KEY", "")
|
||||
LICHESS_API_KEY = os.getenv("VROBBLER_LICHESS_API_KEY", "")
|
||||
|
||||
AGENT_PROVIDER = os.getenv("VROBBLER_AGENT_PROVIDER", "gemini")
|
||||
LLM_MODEL = os.getenv("VROBBLER_LLM_MODEL", "gemini-3.6-flash")
|
||||
OPENCODE_CMD = os.getenv("VROBBLER_OPENCODE_CMD", "opencode")
|
||||
|
||||
AMAZON_PAAPI_ACCESS_KEY = os.getenv("VROBBLER_AMAZON_PAAPI_ACCESS_KEY", "")
|
||||
AMAZON_PAAPI_SECRET_KEY = os.getenv("VROBBLER_AMAZON_PAAPI_SECRET_KEY", "")
|
||||
AMAZON_PAAPI_ASSOCIATE_TAG = os.getenv("VROBBLER_AMAZON_PAAPI_ASSOCIATE_TAG", "")
|
||||
@ -223,6 +227,7 @@ INSTALLED_APPS = [
|
||||
"oauth2_provider",
|
||||
"encrypted_field",
|
||||
"profiles",
|
||||
"agents",
|
||||
"scrobbles",
|
||||
"people",
|
||||
"charts",
|
||||
|
||||
@ -32,8 +32,10 @@
|
||||
{% endif %}
|
||||
{% if request.user.is_authenticated %}
|
||||
<td>{{obj.scrobble_count}}</td>
|
||||
{% if obj.start_url %}
|
||||
<td><a type="button" class="btn btn-sm btn-primary" href="{{obj.start_url}}">Scrobble</a></td>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
35
vrobbler/templates/agents/agentsession_detail.html
Normal file
35
vrobbler/templates/agents/agentsession_detail.html
Normal file
@ -0,0 +1,35 @@
|
||||
{% extends "base_list.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{object.title}}{% endblock %}
|
||||
|
||||
{% block lists %}
|
||||
<div class="row webpage">
|
||||
<div class="webpage-metadata">
|
||||
<h2>{{ object.title }}</h2>
|
||||
<p class="text-muted">{{ object.subtitle }}</p>
|
||||
</div>
|
||||
<hr/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<h3>Last scrobbles</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for scrobble in scrobbles %}
|
||||
<tr>
|
||||
<td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
13
vrobbler/templates/agents/agentsession_list.html
Normal file
13
vrobbler/templates/agents/agentsession_list.html
Normal file
@ -0,0 +1,13 @@
|
||||
{% extends "base_list.html" %}
|
||||
|
||||
{% block title %}Agent sessions{% endblock %}
|
||||
|
||||
{% block lists %}
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<div class="table-responsive">
|
||||
{% include "_scrobblable_list.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
24
vrobbler/templates/scrobbles/_agent_session.html
Normal file
24
vrobbler/templates/scrobbles/_agent_session.html
Normal file
@ -0,0 +1,24 @@
|
||||
{% load static %}
|
||||
<div id="agent-session-content"
|
||||
hx-get="{% url 'scrobbles:agent-session-partial' object.id %}"
|
||||
hx-trigger="{% if object.logdata.pending %}every 5s{% else %}none{% endif %}"
|
||||
hx-swap="outerHTML">
|
||||
{% if object.logdata.as_html %}
|
||||
{{ object.logdata.as_html|safe }}
|
||||
{% else %}
|
||||
<p class="text-muted">No conversation yet.</p>
|
||||
{% endif %}
|
||||
{% if not object.logdata.pending %}
|
||||
<form class="mt-3"
|
||||
hx-post="{% url 'scrobbles:agent-session-followup' object.id %}"
|
||||
hx-target="#agent-session-content"
|
||||
hx-swap="outerHTML">
|
||||
<div class="mb-2">
|
||||
{% csrf_token %}
|
||||
<textarea name="prompt" class="form-control" rows="2"
|
||||
placeholder="Ask a follow-up..."></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-primary">Ask</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
@ -49,13 +49,13 @@
|
||||
<div class="row">
|
||||
|
||||
<h1 class="d-flex align-items-center gap-2">
|
||||
{% if object.media_type == "Video" %}🎬{% elif object.media_type == "Track" %}🎵{% elif object.media_type == "PodcastEpisode" %}🎙️{% elif object.media_type == "SportEvent" %}⚽{% elif object.media_type == "Book" %}📚{% elif object.media_type == "Paper" %}📄{% elif object.media_type == "VideoGame" %}🎮{% elif object.media_type == "BoardGame" %}🎲{% elif object.media_type == "GeoLocation" %}📍{% elif object.media_type == "Trail" %}🥾{% elif object.media_type == "Beer" %}🍺{% elif object.media_type == "Puzzle" %}🧩{% elif object.media_type == "Food" %}🍔{% elif object.media_type == "Task" %}✅{% elif object.media_type == "WebPage" %}🌐{% elif object.media_type == "LifeEvent" %}🎉{% elif object.media_type == "Mood" %}😊{% elif object.media_type == "BrickSet" %}🧱{% elif object.media_type == "Channel" %}📺{% endif %}
|
||||
{% if object.media_type == "Video" %}🎬{% elif object.media_type == "Track" %}🎵{% elif object.media_type == "PodcastEpisode" %}🎙️{% elif object.media_type == "SportEvent" %}⚽{% elif object.media_type == "Book" %}📚{% elif object.media_type == "Paper" %}📄{% elif object.media_type == "VideoGame" %}🎮{% elif object.media_type == "BoardGame" %}🎲{% elif object.media_type == "GeoLocation" %}📍{% elif object.media_type == "Trail" %}🥾{% elif object.media_type == "Beer" %}🍺{% elif object.media_type == "Puzzle" %}🧩{% elif object.media_type == "Food" %}🍔{% elif object.media_type == "Task" %}✅{% elif object.media_type == "WebPage" %}🌐{% elif object.media_type == "LifeEvent" %}🎉{% elif object.media_type == "Mood" %}😊{% elif object.media_type == "BrickSet" %}🧱{% elif object.media_type == "Channel" %}📺{% elif object.media_type == "AgentSession" %}🤖{% endif %}
|
||||
{% if object.media_obj.get_absolute_url %}
|
||||
<a href="{{ object.media_obj.get_absolute_url }}">{% endif %}
|
||||
{{ object.media_obj.title }}
|
||||
{% if object.media_obj.get_absolute_url %}</a>
|
||||
{% endif %}
|
||||
{% if user.is_authenticated and object.media_obj %}
|
||||
{% if user.is_authenticated and object.media_obj and object.media_type != "AgentSession" %}
|
||||
<button id="favorite-btn"
|
||||
data-url="{% url 'scrobbles:toggle-favorite' object.media_type object.media_obj.id %}"
|
||||
data-favorited="{{ is_favorited|yesno:'true,false' }}"
|
||||
@ -77,6 +77,10 @@
|
||||
</p>
|
||||
<h2>{{ object.logdata.title }}</h2>
|
||||
{% endif %}
|
||||
{% if object.media_type == "AgentSession" and object.logdata.title %}
|
||||
</p>
|
||||
<h2>{{ object.logdata.title }}</h2>
|
||||
{% endif %}
|
||||
<h3 class="text-muted">{{ object.local_timestamp }}</h3>
|
||||
|
||||
{% if user.is_authenticated and object.user == user %}
|
||||
@ -275,6 +279,12 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if object.media_type == "AgentSession" %}
|
||||
<div class="mb-3">
|
||||
{% include "scrobbles/_agent_session.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if user.is_authenticated and object.user == user %}
|
||||
<button class="btn btn-secondary mb-3" type="button" data-bs-toggle="collapse" data-bs-target="#editLogForm">
|
||||
Edit Log
|
||||
@ -322,7 +332,7 @@
|
||||
<tr{% if scrobble.id == object.id %} class="table-active fw-bold"{% endif %}>
|
||||
<td>{% if scrobble.id == object.id %}{{ scrobble.timestamp|date:"M d, Y" }}{% else %}<a href="{% url 'scrobbles:detail' scrobble.id %}">{{ scrobble.timestamp|date:"M d, Y" }}</a>{% endif %}</td>
|
||||
<td>
|
||||
{% if scrobble.media_type == "Task" and scrobble.logdata.title %}{{ scrobble.media_obj.title }}: {{ scrobble.logdata.title }}{% else %}{{ scrobble.media_obj.title|default:scrobble.media_obj }}{% endif %}
|
||||
{% if scrobble.media_type == "Task" and scrobble.logdata.title %}{{ scrobble.media_obj.title }}: {{ scrobble.logdata.title }}{% elif scrobble.media_type == "AgentSession" and scrobble.logdata.title %}{{ scrobble.logdata.title }}{% else %}{{ scrobble.media_obj.title|default:scrobble.media_obj }}{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if scrobble.is_long_play and scrobble.long_play_seconds %}
|
||||
|
||||
@ -7,6 +7,7 @@ from oauth2_provider import urls as oauth2_urls
|
||||
from rest_framework import routers
|
||||
|
||||
import vrobbler.apps.scrobbles.views as scrobbles_views
|
||||
from vrobbler.apps.agents import urls as agents_urls
|
||||
from vrobbler.apps.birds import urls as birds_urls
|
||||
from vrobbler.apps.birds.api.views import BirdingLocationViewSet, BirdViewSet
|
||||
from vrobbler.apps.boardgames import urls as boardgame_urls
|
||||
@ -196,6 +197,7 @@ urlpatterns = [
|
||||
path("", include(podcast_urls, namespace="podcasts")),
|
||||
path("", include(lifeevents_urls, namespace="life-events")),
|
||||
path("", include(moods_urls, namespace="moods")),
|
||||
path("", include(agents_urls, namespace="agents")),
|
||||
path("", include(birds_urls, namespace="birds")),
|
||||
path("", include(nature_urls, namespace="nature")),
|
||||
path("", include(scrobble_urls, namespace="scrobbles")),
|
||||
|
||||
Reference in New Issue
Block a user