diff --git a/navi/api/routes/sessions.py b/navi/api/routes/sessions.py index 84c9f0e..20b5925 100644 --- a/navi/api/routes/sessions.py +++ b/navi/api/routes/sessions.py @@ -579,8 +579,19 @@ if not user_messages: return {"name": None} + # Short first user messages often carry no topic (greetings, one-liners) — + # that's why a share of sessions stayed unnamed. The agent's final replies + # are where the actual subject lands; fold them into the naming context. + assistant_messages = [ + m.content for m in session.messages + if m.role == "assistant" and m.content + ][-2:] + backend = backends.get("ollama") - name = await generate_session_name(user_messages, backend, settings.ollama_default_model) + name = await generate_session_name( + user_messages, backend, settings.ollama_default_model, + assistant_messages=assistant_messages, + ) if name: await store.set_name(session_id, name) diff --git a/navi/core/name_generator.py b/navi/core/name_generator.py index 584452c..7963a50 100644 --- a/navi/core/name_generator.py +++ b/navi/core/name_generator.py @@ -1,27 +1,44 @@ -"""Generate a short session name from user messages via LLM.""" +"""Generate a short session name from the conversation via LLM.""" from navi.llm.base import LLMBackend, Message _SYSTEM = ( "You are a session title generator. " - "Given the user's messages from a conversation, produce ONE short title (3–6 words, no punctuation at the end). " + "Given the user's messages (and, possibly, the assistant's replies) from a conversation, " + "produce ONE short title (3–6 words, no punctuation at the end). " "The title must reflect the actual topic. " "Respond in the same language as the user's messages. " - "If the messages contain no clear topic yet (e.g. only greetings, very short or ambiguous text), " + "If there's still no clear topic (e.g. only greetings, very short or ambiguous text), " "reply with exactly: NO_TITLE" ) +# Assistant replies can be huge; a tail slice is enough context for a title. +_ASSISTANT_MSG_CHARS = 600 +_ASSISTANT_MSG_COUNT = 2 + async def generate_session_name( user_messages: list[str], backend: LLMBackend, model: str, + assistant_messages: list[str] | None = None, ) -> str | None: """Return a short title or None if content isn't substantial enough.""" if not user_messages: return None combined = "\n".join(f"- {m}" for m in user_messages[:10]) + + # Naming runs right after the first exchange, when user messages are often + # greetings or one-liners with no topic. The agent's final response is + # where the actual subject shows up — include a trimmed tail of it. + if assistant_messages: + combined += "\n\nAssistant replies:\n" + "\n---\n".join( + m[-_ASSISTANT_MSG_CHARS:] + for m in assistant_messages[-_ASSISTANT_MSG_COUNT:] + if m + ) + messages = [ Message(role="system", content=_SYSTEM), Message(role="user", content=f"User messages:\n{combined}"), diff --git a/tests/unit/core/test_name_generator.py b/tests/unit/core/test_name_generator.py new file mode 100644 index 0000000..2c01f87 --- /dev/null +++ b/tests/unit/core/test_name_generator.py @@ -0,0 +1,55 @@ +"""Unit tests for the session name generator.""" + +import pytest + +from navi.core.name_generator import generate_session_name +from navi.llm.base import LLMBackend, Message + + +class _FakeBackend(LLMBackend): + def __init__(self, reply: str): + self.reply = reply + self.last_prompt = None + + async def complete(self, messages, tools=None, model=None, **kwargs): + self.last_prompt = messages[-1].content + return type("R", (), {"content": self.reply})() + + async def stream_complete(self, messages, tools=None, model=None, **kwargs): + yield {"type": "text", "text": self.reply} + + +@pytest.mark.asyncio +async def test_folds_assistant_replies_into_context(): + backend = _FakeBackend("Setup of TLS on the proxy") + name = await generate_session_name( + ["привет", "ну так продолжай"], + backend, + "test-model", + assistant_messages=["Сделаю шаги: 1) сгенерирую ключи..." + ("x" * 1000)], + ) + assert name == "Setup of TLS on the proxy" + # The assistant reply is trimmed to its 600-char tail, not passed whole + assert "xxxxx" not in backend.last_prompt.replace("x" * 600, "") + tail_len = len("Assistant replies:\n") + 600 + assert len(backend.last_prompt) - len(backend.last_prompt.split("Assistant replies:\n")[0]) == tail_len + + +@pytest.mark.asyncio +async def test_no_topic_still_yields_none(): + backend = _FakeBackend("NO_TITLE") + name = await generate_session_name( + ["привет", "как дела?"], + backend, + "test-model", + assistant_messages=["Тоже привет!"], + ) + assert name is None + + +@pytest.mark.asyncio +async def test_without_assistant_replies_unchanged(): + backend = _FakeBackend("Debugging the deploy") + name = await generate_session_name(["fix the deploy"], backend, "test-model") + assert name == "Debugging the deploy" + assert "Assistant replies:" not in backend.last_prompt \ No newline at end of file