Repository navigation
feat: the web app talks to the reception (08b) #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c953216
58e0367
99e703e
304b200
b4ef737
a4f81c6
157d5c1
b80b3e8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,7 @@ | |
| from . import conversations, memory, views | ||
| from .api import ApiError, Principal, Server, principal, require_within, scope, server_of | ||
| from .endpoints import AttachmentIn | ||
| from .pii import raised_class | ||
|
|
||
| INTAKE_KEY = re.compile(r"^[!-~]{1,128}$") | ||
| SSE_POLL_S, SSE_MAX_S = 1.0, 900.0 | ||
|
|
@@ -45,6 +46,12 @@ class TapIn(_Body): | |
| text: str | None = Field(default=None, max_length=20000) | ||
|
|
||
|
|
||
| class MemoryIn(_Body): | ||
| kind: str = Field(pattern=r"^(fact|glossary|preference|todo)$") # a summary is the reception's own | ||
| text: str = Field(min_length=1, max_length=memory.MAX_TEXT) | ||
| pinned: bool = False | ||
|
|
||
|
|
||
| class MemoryEditIn(_Body): | ||
| text: str | None = Field(default=None, min_length=1, max_length=memory.MAX_TEXT) | ||
| pinned: bool | None = None | ||
|
|
@@ -153,6 +160,40 @@ def events(conversation: str, request: Request, after: str | None = None, | |
| return StreamingResponse(_stream(server, conversation, cursor, lambda: scope("read")(principal(request))), | ||
| media_type="text/event-stream") | ||
|
|
||
| @api.get("/projects") | ||
| def list_projects(request: Request, who: Principal = Depends(scope("read"))) -> dict: | ||
| """What a project picker offers (design 08 §9): the projects of ooat.toml within the reader's cap, then the | ||
| projects of the reader's own conversations and tasks that ooat.toml lacks. Names only, never a folder.""" | ||
| server = server_of(request) | ||
| listed = {slug: {"project": slug, "data_class": p.data_class, "write": p.write, "folders": len(p.folders)} | ||
| for slug, p in server.config.projects.items() if views.within(p.data_class, who.max_data_class)} | ||
| for event in server.runtime().ledger.events(types=["TASK_SUBMITTED", "CONVERSATION_MESSAGE"]): | ||
| slug = event["body"].get("project") | ||
| if slug and slug not in listed and slug not in server.config.projects \ | ||
| and event["actor"]["id"] == who.operator and views.within("internal", who.max_data_class): | ||
| listed[slug] = {"project": slug, "data_class": "internal", "write": False, "folders": 0} | ||
| return {"projects": [listed[slug] for slug in sorted(listed)]} | ||
|
|
||
| @api.post("/memory/{scope_name}", status_code=201) | ||
| def add_memory(scope_name: str, body: MemoryIn, request: Request, | ||
| who: Principal = Depends(scope("answer"))) -> dict: | ||
| server = server_of(request) | ||
| if who.channel != "web": | ||
| # An item written by hand steers every later task, as a pinned one: a named operator's session only. | ||
| raise ApiError(403, "SCOPE", "adding memory needs the web app; a token's message may ask the reception " | ||
| "to remember") | ||
| if scope_name == memory.OPERATOR_SCOPE and body.kind != "preference": | ||
| raise ApiError(400, "INVALID", "the operator's memory holds preferences only") | ||
| data_class = "internal" if scope_name == memory.OPERATOR_SCOPE else project_class(server, scope_name) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: any slug passes the scope check here. A slug that isn't in |
||
| # Personal data in the text raises the item's class: refused when that is above the author's cap, so an item | ||
| # is never stored that its author could no longer see. | ||
| require_within(who, raised_class(data_class, body.text)) | ||
| if server.memory is None: | ||
| raise ApiError(404, "NOT_FOUND", "this ledger keeps no memory") | ||
| item = memory.add(server.runtime().ledger, server.memory, scope=scope_name, kind=body.kind, text=body.text, | ||
| data_class=data_class, operator=who.operator, channel=who.channel, pinned=body.pinned) | ||
| return views.memory_view(item) | ||
|
|
||
| @api.get("/memory/{scope_name}") | ||
| def list_memory(scope_name: str, request: Request, kind: str | None = None, done: bool = False, | ||
| who: Principal = Depends(scope("read"))) -> dict: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| // What a conversation with the reception shows (design 08 §4, §9): one turn per event, in order. A decision that a | ||
| // later call replaced (escalated, retried) is not shown, and an event seen twice (a tap's response and the stream) | ||
| // is one turn. Text above the session's data class arrives as the server's stub and is shown as redacted. | ||
|
|
||
| export const REPLACED = ["escalated", "retried"]; | ||
|
|
||
| /** | ||
| * @typedef {object} Turn | ||
| * @property {string} id | ||
| * @property {string} ts | ||
| * @property {"you" | "reception" | "report" | "closed"} who | ||
| * @property {string | null} text what was said, or the tap's label key with its text (`tap`) | ||
| * @property {string | null} tap a tap's kind (yes, differently, …), for its label | ||
| * @property {boolean} redacted | ||
| * @property {string[]} options numbered next steps | ||
| * @property {string[]} buttons the reception's taps: yes, differently, new_thing, make_task, todo, forget:<mem>, … | ||
| * @property {string | null} task a task the decision started, or the report's task | ||
| * @property {string} project | ||
| * @property {string} conversation | ||
| */ | ||
|
|
||
| /** @param {unknown} value */ | ||
| const said = (value) => (typeof value === "string" ? value : null); | ||
|
|
||
| /** | ||
| * @param {any[]} events events of GET /conversations/{id}/events, oldest first | ||
| * @param {Set<string>} [seen] ids already shown; kept up to date, so a later call skips them | ||
| * @returns {Turn[]} | ||
| */ | ||
| export function shown(events, seen = new Set()) { | ||
| /** @type {Turn[]} */ | ||
| const turns = []; | ||
| for (const event of events) { | ||
| if (seen.has(event.id)) continue; | ||
| seen.add(event.id); | ||
| const body = event.body ?? {}; | ||
| const base = {id: event.id, ts: event.ts, project: body.project ?? "", conversation: body.conversation ?? "", | ||
| tap: null, redacted: false, options: [], buttons: [], task: null}; | ||
| if (event.type === "CONVERSATION_MESSAGE") { | ||
| const tap = said(body.tap); | ||
| const text = tap ? said(body.tap_text) : said(body.text); | ||
| const redacted = (body.tap_text !== undefined || !tap) && text === null; | ||
| turns.push({...base, who: "you", tap: tap ? tapKind(tap) : null, text, redacted}); | ||
| } else if (event.type === "RECEPTION_DECIDED" || event.type === "CONVERSATION_REPORTED") { | ||
| if (REPLACED.includes(body.decision)) continue; | ||
| const text = said(body.reply); | ||
| const visible = text !== null; | ||
| turns.push({...base, who: event.type === "RECEPTION_DECIDED" ? "reception" : "report", text, | ||
| redacted: !visible, options: visible && Array.isArray(body.options) ? body.options.filter(said) : [], | ||
| buttons: visible && Array.isArray(body.buttons) ? body.buttons.filter(said) : [], task: body.task ?? null}); | ||
| } else if (event.type === "CONVERSATION_CLOSED") { | ||
| turns.push({...base, who: "closed", text: said(body.reason)}); | ||
| } | ||
| } | ||
| return turns; | ||
| } | ||
|
|
||
| /** The kind of a tap, without its memory id: "forget:mem_…" is "forget". @param {string} tap */ | ||
| export function tapKind(tap) { | ||
| return tap.split(":", 1)[0]; | ||
| } | ||
|
|
||
| /** @type {Record<string, import("./i18n.js").Key>} */ | ||
| const LABELS = {yes: "conv.yes", differently: "conv.differently", new_thing: "conv.newThing", | ||
| make_task: "conv.makeTask", todo: "conv.todo", forget: "conv.forget", done: "conv.done"}; | ||
|
|
||
| /** | ||
| * The i18n key of a button or of a tap message's label; null for a tap this page does not know. | ||
| * @param {string} tap | ||
| * @returns {import("./i18n.js").Key | null} | ||
| */ | ||
| export function tapLabel(tap) { | ||
| return LABELS[tapKind(tap)] ?? null; | ||
| } | ||
|
|
||
| /** | ||
| * The conversation the home page continues: the newest open one of this project on the web. A conversation of a | ||
| * chat bridge is its own channel's, never continued here. | ||
| * @param {any[]} list conversations of GET /conversations, newest first | ||
| * @param {string} project | ||
| * @returns {string | null} | ||
| */ | ||
| export function openOf(list, project) { | ||
| return list.find((c) => c.project === project && c.channel === "web" && !c.closed)?.conversation ?? null; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit (performance): every home page load reads all
TASK_SUBMITTEDandCONVERSATION_MESSAGEevents in the ledger, from every operator. That is fine for F1 with SQLite, but it grows with the ledger. If it becomes a hotspot, filter by actor in the query, or move this to a view (db-dev).