8000 feat(chat): title conversations after their first answer by adeelahmad · Pull Request #531 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ The backend (`fastapi_backend`) and the frontend (`nextjs-frontend`) are version

## Unreleased

- **Conversations title themselves.** After a conversation's first answer, the model gives it a short title from the
question and the answer, with the model for summaries (`ai.compact_model`) when one is set, in one call counted for
the conversation in the activity ledger; until then, and when there's no model, it's titled with the first
question's words. A title someone gives it is kept. `title_by` says who titled it, `POST /api/v1/chats/<id>/title`
and the MCP tool `rename_chat` (without a title) have Lens title it again, and `rename_chat` with a title renames it.
Settings → AI assistant → Title conversations (on) turns it off ([Titles](docs/assistant.md#titles)).
- **Specs from transcripts.** Turn a voice session or a meeting into a technical spec, saved as a note linked to it:
the model writes the spec, then lists every named decision, every "no" and "not that, this", every constraint and
every gap the speaker left to decide later, each with its quote and a link to the moment it was said; a quote the
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ listed under [In progress](#in-progress), and what comes next under [Planned](#p
of each conversation's own. Regenerating an answer or editing a question makes a new version beside the old one, and
nothing is lost. Each answer keeps a record of what produced it (the model, its tools, how long it took and what it
cost), opened from its header. It reads answers aloud with a speech provider, macOS `say` or StyleTTS2 on this machine
(opt-in), and `/clear` and `/compact` keep long conversations short. Each namespace can have its own assistant with
(opt-in), and `/clear` and `/compact` keep long conversations short. Conversations title themselves after their
first answer, unless you name them. Each namespace can have its own assistant with
its own name, instructions and memory, answering in Telegram and Slack (built in, with voice notes and files), in
Discord, Matrix, WhatsApp and other chat rooms through Matterbridge, and by email at an address of its own that you
email or forward things to. ([Chat](docs/assistant.md), [web search](docs/assistant.md#web-search-and-reading-pages),
Expand Down
7 changes: 6 additions & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1518,6 +1518,7 @@ GET /api/v1/chats/tools
POST /api/v1/chats
GET /api/v1/chats/{cid}
PATCH /api/v1/chats/{cid}
POST /api/v1/chats/{cid}/title
DELETE /api/v1/chats/{cid}
POST /api/v1/chats/{cid}/messages
GET /api/v1/chats/{cid}/messages/{mid}
Expand Down Expand Up @@ -1549,7 +1550,11 @@ step it's on: the stream sends `stopped`, then `done` with the saved message, wh
`content` is what came before (`(stopped)` when nothing had). `{stopping: false}` when nothing was being written.
`POST /chats/{cid}/messages {content, edit}` edits one of your earlier questions (`edit` is its message id): it and
everything after it are replaced by this question and its new answer; 404 when `edit` isn't a question in that
conversation. A conversation titled after the first question is retitled when that question is edited.
conversation. A conversation Lens titled (`title_by` `assistant`) is titled again when that question is edited; one
you named (`title_by` `person`: `POST /chats` or `PATCH /chats/{cid}` with `title`) keeps its name. After a
conversation's first answer the model titles it, and the stream ends with `title`; `POST /chats/{cid}/title` has it
titled now from how it starts, `{title, title_by}` (409 before the first question, 400 without a model)
([Titles](assistant.md#titles)).

`GET /chats/tools` lists the tool groups and tools you have, each tool's `effect` (`read`, `asks` or `out`), why a
group isn't available and the setting that switches it on, the most tool calls a question may ask for
Expand Down
15 changes: 14 additions & 1 deletion docs/assistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ then.
- **Answers:** the model answers only from those excerpts and cites them as [n]. Each citation carries the asset
and timestamp.
- **Streaming:** answers arrive over server-sent events: `passages`, then `token`s, then `meta` (the answer's
record, [below](#what-each-answer-records)) and `done`.
record, [below](#what-each-answer-records)) and `done`; after a conversation's first answer, `title`
([Titles](#titles)).
- **Stopping:** Stop asks the server to end the answer after the piece or tool step it's on (whichever server process
is writing it); what came before is saved, marked stopped. A model call already under way finishes first.
- **Reopening:** a conversation's answers keep the tools the assistant used (and with what), any notice (the model
Expand Down Expand Up @@ -42,6 +43,18 @@ then.
**Model for summaries** (`ai.compact_model`) writes them with a smaller, cheaper model on the same provider; empty,
the chat's model does. Settings › AI assistant › Summarise long conversations (`ai.compact`, on) turns it off; off,
or with no model, only the last six messages are read.
- **Titles:**{#titles} a conversation nobody named is titled with its first question's words as soon as it's asked,
then, after the first answer, the model gives it a short title of a few words from the question and the answer
(streamed as `title` at the end of the answer's stream). It's one small call, written by **Model for summaries**
(`ai.compact_model`) when set, else the chat's model, and counted in the activity ledger for the conversation
(`chat:<id>`), not for the answer. Editing the first question titles it again. A conversation you name, when you
start it (`POST /api/v1/chats` with `title`) or later (rename in the list, `PATCH /api/v1/chats/<id>` with `title`),
keeps its name; `title_by` on a conversation says which it is (`assistant` or `person`). `POST
/api/v1/chats/<id>/title` has Lens title one now from how it starts, even one you named, and the title is Lens's
again; agents do both with the MCP tool `rename_chat` (a `title`, or none for Lens's). Settings › AI assistant ›
Title conversations (`ai.auto_title`, on) turns the model's titles off: a conversation then keeps its first
question's words. Without a model, or when the call fails or the budget's hard limit stops it, the first question's
words stay too. Chat rooms, setup conversations and automations' conversations keep the names Lens gives them.
- **/clear and /compact:** typed as a message, in the app or in a chat room (`Lens /clear`). `/clear` makes the
assistant start afresh: the messages stay to read, but the model no longer sees them or their summary.
`/compact` folds the whole conversation into its summary now, and `/compact keep the dates and owners` tells the
Expand Down
3 changes: 2 additions & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ has the same tools, but sees only that namespace, whatever else the person who s
that take a namespace use this one when none is named, `list_namespaces` and `list_files` show only it, and the tools
about the server itself (`file_storage`, `monitoring`, `sandbox_gateway`, `install_settings` and the ones that change
them) aren't there, even for admins. Nor are the person's own conversations (`list_chats`, `read_chat`,
`export_chat`), which belong to no namespace. An admin counts as an owner of the namespace there.
`rename_chat`, `export_chat`), which belong to no namespace. An admin counts as an owner of the namespace there.

It is off until an owner of the namespace turns it on: **Admin → Namespaces →** the namespace **→ Connect apps**, which
also copies its address, or `PATCH /api/v1/namespaces/<namespace>/integrations` with `{"mcp": true}`, or an agent's
Expand Down Expand Up @@ -96,6 +96,7 @@ For an app that speaks OpenAI's API rather than MCP, the namespace can also answ
| `write_note` | write a note or a thing's page, marked as written by an assistant. Needs a write-scope token and editor access |
| `list_chats` | the person's conversations with Lens's [assistant](assistant.md#chat), newest first, by words in their title, each with its link |
| `read_chat` | one of them: what it draws on, and the version showing (or the one ending at a `message`), each message with its versions, the tools an answer used, its citations with links and its record; `tree` adds every message's parent and role |
| `rename_chat` | name one of them, or have Lens [title](assistant.md#titles) it from how it starts (no `title`). Needs a write-scope token |
| `export_chat` | one of them as Markdown or JSON ([Copy and export](assistant.md#copy-and-export)): the version showing, one `message`, every version (`all_versions`) or OpenAI's `{"messages": [...]}` shape (`as_messages`) |
| `create_podcast` | make a two-host learning [episode](podcasts.md) about assets or lines in them, with an angle, a length and a style; it's made in the background. Needs a write-scope token and editor access where it goes |
| `get_podcast` | an episode: how far it has got, its script with each line's speaker and the sources it cites (links), and what the fact-check changed |
Expand Down
1 change: 1 addition & 0 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ request, to another check that proves the same thing; loosening a requirement or
| Regenerating an answer or editing a question makes a new version beside the old one | [test_chat_versions.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_chat_versions.py) `test_regenerate_edit_switch_and_go_on`; [chat-versions.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/chat-versions.test.tsx) `regenerating an answer`, `the version switch` |
| Each answer keeps a record of what produced it: the model, its tools, how long it took and what it cost | [test_chat_records.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_chat_records.py) `test_an_answer_records_what_produced_it`, `test_a_regenerated_answer_has_its_own_record`; [chat-details.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/chat-details.test.tsx) `says what produced it: model and host, scope, tools, use, tokens, cost, time and how it ended` |
| Answers read aloud by a speech provider, macOS `say` or StyleTTS2 (opt-in) | [test_voice.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_voice.py) `test_answers_are_read_aloud_by_a_speech_model`; [test_speech.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/domain/test_speech.py) `test_macos_say_reads_aloud_on_a_mac`, `test_styletts2_reads_aloud_from_its_own_service`; [styletts2.yml](https://github.com/adeelahmad/lense/blob/main/.github/workflows/styletts2.yml) `Read a sentence aloud` |
| Conversations title themselves after their first answer, and a name you give is kept | [test_chat_titles.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_chat_titles.py) `test_a_first_answer_titles_the_conversation`, `test_a_title_you_give_is_kept`, `test_agents_rename_and_retitle_over_mcp` |
| `/clear` and `/compact` | [test_compaction.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_compaction.py) `test_clear_starts_afresh_and_compact_folds_everything_now`, `test_commands_in_the_app` |
| A namespace's own assistant, with its own name, instructions and memory | [test_ns_assistant.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_ns_assistant.py) `test_off_by_default_and_owners_turn_it_on`, `test_remembers_across_conversations_with_citations`; [ns-assistant.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/ns-assistant.test.tsx) `saving a name and forgetting a memory` |
| Telegram and Slack built in, with voice notes and files | [test_telegram.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_telegram.py) `test_it_answers_private_chats_and_what_is_said_to_it_in_groups`, `test_voice_notes_are_heard`, `test_files_are_held_for_the_assistant_to_import`; [test_slack.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_slack.py) `test_it_answers_direct_messages_mentions_and_the_slash_command`, `test_voice_clips_are_heard_and_files_held` |
Expand Down
29 changes: 27 additions & 2 deletions fastapi_backend/app/api/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1841,8 +1841,8 @@ def write_note(
@tool(
"list_chats",
"List your conversations",
"Your conversations with Lens's assistant, most recent first: each one's id, title, kind, when it was last added "
"to, and its link. Only your own: conversations aren't shared.",
"Your conversations with Lens's assistant, most recent first: each one's id, title (`title_by` person when someone "
"named it), kind, when it was last added to, and its link. Only your own: conversations aren't shared.",
Arg("words", "string", "only those whose title has all these words", max_length=200),
Arg("limit", "integer", "how many", default=20, minimum=1, maximum=200),
personal=True,
Expand All @@ -1857,6 +1857,7 @@ def list_chats(ctx: Context, words: str | None, limit: int) -> dict[str, Any]:
{
"chat_id": c.id,
"title": c.title,
"title_by": c.title_by,
"kind": c.kind if c.kind != "chat" else None,
"updated_at": c.updated_at,
"url": f"{ctx.web}/chat/{c.id}",
Expand Down Expand Up @@ -1918,6 +1919,7 @@ def read_chat(ctx: Context, chat_id: int, message: int | None, tree: bool) -> di
{
"chat_id": c["id"],
"title": c["title"],
"title_by": c.get("title_by"),
"url": f"{ctx.web}/chat/{c['id']}",
"draws_on": chat_export.scope_words(c.get("scope")),
"model": c.get("model"),
Expand All @@ -1928,6 +1930,29 @@ def read_chat(ctx: Context, chat_id: int, message: int | None, tree: bool) -> di
)


@tool(
"rename_chat",
"Rename a conversation",
"Name one of your conversations, or leave `title` out to have Lens title it now from how it starts (one call to the "
"model for summaries, counted in the activity ledger). Lens titles a conversation after its first answer on its "
"own; one someone named keeps its name. Needs a token with the write scope.",
Arg("chat_id", "integer", "the conversation (from list_chats)", required=True),
Arg("title", "string", "its new name; left out, Lens writes one", max_length=120),
writes=True,
personal=True,
)
def rename_chat(ctx: Context, chat_id: int, title: str | None) -> dict[str, Any]:
if not ctx.user.can_write:
raise ToolError("this token is read-only: ask for a token with the write scope to rename conversations")
chat_routes._own_chat(ctx.db, chat_id, ctx.user)
if name := (title or "").strip():
chat.rename(ctx.db, chat_id, name)
named = {"title": name[:120], "title_by": "person"}
else:
named = chat_routes.title_chat(chat_id, ctx.user, ctx.db, ctx.cfg).model_dump()
return {"chat_id": chat_id, **named, "url": f"{ctx.web}/chat/{chat_id}"}


@tool(
"export_chat",
"Export a conversation",
Expand Down
Loading
Loading
0