diff --git a/core/src/ooat_core/api.py b/core/src/ooat_core/api.py index b2f6101..2161395 100644 --- a/core/src/ooat_core/api.py +++ b/core/src/ooat_core/api.py @@ -368,13 +368,21 @@ def app_routes() -> APIRouter: def page() -> Response: return Response(shell, media_type="text/html; charset=utf-8") - for path in ("/", "/usage", "/connectors", "/setup"): + for path in ("/", "/usage", "/connectors", "/setup", "/memory"): router.add_api_route(path, page, methods=["GET"]) @router.get("/tasks/{task}") def task_page(task: str) -> Response: # the page reads the id from its address return page() + @router.get("/conversations/{conversation}") + def conversation_page(conversation: str) -> Response: + return page() + + @router.get("/memory/{scope_name}") + def memory_page(scope_name: str) -> Response: + return page() + @router.get("/app/{path:path}") def app_file(path: str) -> Response: found = app_files.get(path) diff --git a/core/src/ooat_core/conversation_endpoints.py b/core/src/ooat_core/conversation_endpoints.py index 963b76e..845156f 100644 --- a/core/src/ooat_core/conversation_endpoints.py +++ b/core/src/ooat_core/conversation_endpoints.py @@ -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) + # 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: diff --git a/core/src/ooat_core/memory.py b/core/src/ooat_core/memory.py index 2c0d520..3b768dd 100644 --- a/core/src/ooat_core/memory.py +++ b/core/src/ooat_core/memory.py @@ -135,10 +135,12 @@ def find(events: list[dict], mem_id: str) -> dict | None: return noted -def note(ledger: Ledger, store: MemoryStore, *, scope: str, kind: str, text: str, data_class: str, source: str, - actor: dict, conversation: str | None = None, pinned: bool = False, channel: str | None = None) -> MemoryItem: +def note(ledger: Ledger, store: MemoryStore, *, scope: str, kind: str, text: str, data_class: str, + source: str | None, actor: dict, conversation: str | None = None, pinned: bool = False, + channel: str | None = None) -> MemoryItem: """Store a new item: its text in the store, then MEMORY_NOTED. A text the ledger refuses to record is deleted - again, so no text is kept without its event.""" + again, so no text is kept without its event. An item written by hand (`source` None) has its own MEMORY_NOTED + as its source: the operator's act is where it came from.""" if kind not in KINDS: raise ValueError(f"a memory kind is one of {', '.join(KINDS)}") if not _SCOPE.match(scope): @@ -158,12 +160,22 @@ def note(ledger: Ledger, store: MemoryStore, *, scope: str, kind: str, text: str body["pinned"] = True if channel is not None: body["channel"] = channel + event = new_event("MEMORY_NOTED", task=None, actor=actor, body=body) + body["source"] = source or event["id"] try: - event = ledger.append(new_event("MEMORY_NOTED", task=None, actor=actor, body=body)) + event = ledger.append(event) except BaseException: store.delete(mem_id) raise - return MemoryItem(mem_id, scope, kind, data_class, source, text, pinned, False, event["ts"], conversation) + return MemoryItem(mem_id, scope, kind, data_class, body["source"], text, pinned, False, event["ts"], + conversation) + + +def add(ledger: Ledger, store: MemoryStore, *, scope: str, kind: str, text: str, data_class: str, operator: str, + channel: str, pinned: bool = False) -> MemoryItem: + """The operator writes an item by hand (the web's Memory page); its source is its own MEMORY_NOTED.""" + return note(ledger, store, scope=scope, kind=kind, text=text, data_class=data_class, source=None, + actor={"kind": "hil", "id": checked_operator(operator)}, pinned=pinned, channel=channel) def edit(ledger: Ledger, store: MemoryStore, mem_id: str, *, operator: str, channel: str, text: str | None = None, diff --git a/core/src/ooat_core/web/app/api.js b/core/src/ooat_core/web/app/api.js index af82b5f..fb4459a 100644 --- a/core/src/ooat_core/web/app/api.js +++ b/core/src/ooat_core/web/app/api.js @@ -83,20 +83,44 @@ export function createApi(env) { } /** - * Follows a task's timeline until its TASK_CLOSED, or until `signal` aborts. The server ends a stream after - * 15 minutes, and a network can drop one; the next starts after the last event seen, so nothing is shown twice - * or missed. A dropped connection is tried again after 1 s, then twice as long per further drop, at most 30 s. + * Follows a task's timeline until its TASK_CLOSED, or until `signal` aborts. * @param {string} task * @param {string | null} after the last event already shown * @param {(event: any) => void} onEvent * @param {AbortSignal} signal */ - async function follow(task, after, onEvent, signal) { + function follow(task, after, onEvent, signal) { + return stream(`/tasks/${encodeURIComponent(task)}/events`, "TASK_CLOSED", after, onEvent, signal); + } + + /** + * Follows a conversation with the reception until its CONVERSATION_CLOSED, or until `signal` aborts. + * @param {string} conversation + * @param {string | null} after the last event already shown + * @param {(event: any) => void} onEvent + * @param {AbortSignal} signal + */ + function followConversation(conversation, after, onEvent, signal) { + return stream(`/conversations/${encodeURIComponent(conversation)}/events`, "CONVERSATION_CLOSED", after, onEvent, + signal); + } + + /** + * The events of a stream until the event `end`. The server ends a stream after 15 minutes, and a network can drop + * one; the next starts after the last event seen, so nothing is shown twice or missed. A dropped connection is + * tried again after 1 s, then twice as long per further drop, at most 30 s. + * @param {string} path below /api/v1 + * @param {string} end the event type that ends it + * @param {string | null} after + * @param {(event: any) => void} onEvent + * @param {AbortSignal} signal + */ + async function stream(path, end, after, onEvent, signal) { let cursor = after; let wait = RECONNECT_MS; while (!signal.aborted) { try { - const response = await env.fetch(`/api/v1/tasks/${encodeURIComponent(task)}/events`, { + const response = await env.fetch(`/api/v1${path}`, { headers: headers({Accept: "text/event-stream", ...(cursor ? {"Last-Event-ID": cursor} : {})}), signal, }); if (!response.ok || !response.body) throw await failure(response); @@ -116,7 +140,7 @@ export function createApi(env) { cursor = event.id; wait = RECONNECT_MS; onEvent(event); - if (event.type === "TASK_CLOSED") return; + if (event.type === end) return; } } } catch (error) { @@ -130,7 +154,7 @@ export function createApi(env) { } } - return {json, raw, follow}; + return {json, raw, follow, followConversation}; } /** @typedef {ReturnType} Api */ diff --git a/core/src/ooat_core/web/app/app.css b/core/src/ooat_core/web/app/app.css index 77bc4ce..1537371 100644 --- a/core/src/ooat_core/web/app/app.css +++ b/core/src/ooat_core/web/app/app.css @@ -9,6 +9,7 @@ --accent: #7aa2ff; --ok: #5cc27a; --fail: #ff8a80; --ask: #e0b25c; } } * { box-sizing: border-box; } +[hidden] { display: none !important; } body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, sans-serif; } #top { display: flex; flex-wrap: wrap; gap: 12px 24px; align-items: center; justify-content: space-between; padding: 10px 16px; border-bottom: 1px solid var(--line); } @@ -52,4 +53,15 @@ pre { margin: 0; white-space: pre-wrap; word-break: break-word; font: 13px/1.45 .diff .add { color: var(--ok); } .diff .del { color: var(--fail); } .diff .hunk { color: var(--muted); } -@media (max-width: 720px) { .columns { grid-template-columns: 1fr; } } +.box textarea { resize: vertical; margin-bottom: 4px; } +details > summary { cursor: pointer; color: var(--muted); margin: 16px 0 4px; } +.thread { display: flex; flex-direction: column; gap: 8px; margin: 12px 0; } +.turn { max-width: min(85%, 760px); border: 1px solid var(--line); border-radius: 10px; padding: 8px 12px; + background: var(--card); } +.turn.you { align-self: flex-end; background: var(--bg); } +.turn.report { border-color: var(--ok); } +.turn time { display: block; font-size: 0.8rem; color: var(--muted); } +.turn p, .turn ol { margin: 4px 0; } +.memory-item { display: flex; flex-wrap: wrap; gap: 4px 12px; align-items: baseline; } +.memory-item .text { flex: 1 1 20em; white-space: pre-wrap; } +@media (max-width: 720px) { .columns { grid-template-columns: 1fr; } .turn { max-width: 100%; } } diff --git a/core/src/ooat_core/web/app/app.js b/core/src/ooat_core/web/app/app.js index 461325d..98030d7 100644 --- a/core/src/ooat_core/web/app/app.js +++ b/core/src/ooat_core/web/app/app.js @@ -4,7 +4,9 @@ import { createApi, KEY_STORAGE } from "./api.js"; import { h, report } from "./dom.js"; import { money } from "./format.js"; import { currentLanguage, setLanguage, t } from "./i18n.js"; -import { inbox } from "./views/inbox.js"; +import { home, storedProject } from "./views/home.js"; +import { conversationPage } from "./views/conversation.js"; +import { memoryPage } from "./views/memory.js"; import { taskPage } from "./views/task.js"; import { connectors } from "./views/connectors.js"; import { usage } from "./views/usage.js"; @@ -18,8 +20,15 @@ const top = /** @type {HTMLElement} */ (document.getElementById("top")); /** @typedef {(root: HTMLElement, ctx: import("./api.js").Context, match: RegExpMatchArray) => Promise} Screen */ /** @type {[RegExp, Screen][]} */ const ROUTES = [ - [/^\/$/, (root, ctx) => inbox(root, ctx)], + [/^\/$/, (root, ctx) => home(root, ctx)], + [/^\/conversations\/(cnv_[A-Za-z0-9]+)$/, (root, ctx, match) => conversationPage(root, ctx, match[1])], [/^\/tasks\/([A-Za-z0-9_]+)$/, (root, ctx, match) => taskPage(root, ctx, match[1])], + [/^\/memory$/, (root, ctx) => { // the header's Memory: the project last shown on the home page + const scope = storedProject() ?? "operator"; + history.replaceState(null, "", `/memory/${scope}`); + return memoryPage(root, ctx, scope); + }], + [/^\/memory\/([a-z0-9][a-z0-9_-]*)$/, (root, ctx, match) => memoryPage(root, ctx, match[1])], [/^\/usage$/, (root, ctx) => usage(root, ctx)], [/^\/connectors$/, (root, ctx) => connectors(root, ctx)], [/^\/setup$/, (root, ctx) => setup(root, ctx)], @@ -27,6 +36,7 @@ const ROUTES = [ /** @type {[string, () => string][]} */ const NAV = [ ["/", () => t("nav.inbox")], + ["/memory", () => t("nav.memory")], ["/usage", () => t("nav.usage")], ["/connectors", () => t("nav.connectors")], ["/setup", () => t("nav.setup")], diff --git a/core/src/ooat_core/web/app/conversation.js b/core/src/ooat_core/web/app/conversation.js new file mode 100644 index 0000000..f6e6608 --- /dev/null +++ b/core/src/ooat_core/web/app/conversation.js @@ -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:, … + * @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} [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} */ +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; +} diff --git a/core/src/ooat_core/web/app/i18n/cs.js b/core/src/ooat_core/web/app/i18n/cs.js index 4410d32..be28845 100644 --- a/core/src/ooat_core/web/app/i18n/cs.js +++ b/core/src/ooat_core/web/app/i18n/cs.js @@ -156,4 +156,62 @@ export default { "setup.sample": "Otevřít přehled s ukázkovou úlohou", "sample.goal": "Shrň do pěti odrážek, co OOAT dělá s úlohou od zadání po hodnocení.", "sample.acceptance": "Nejvýše pět odrážek\nKaždá odrážka nejvýše 25 slov", + "nav.memory": "Paměť", + "home.placeholder": "Zeptej se nebo zadej cokoli… (Enter odešle, Shift+Enter nový řádek)", + "home.project": "Projekt", + "home.send": "Odeslat", + "home.attach": "Přiložit soubory", + "home.received": "Přijato. Recepce odpoví, jakmile chvíli nepíšeš.", + "home.noProjects": "Zatím žádný projekt: přidej ho do [projects] v ooat.toml.", + "home.empty": "Nová konverzace začne tvou zprávou.", + "home.earlier": "Dřívější konverzace", + "home.exactTask": "Úkol s přesnými kritérii", + "conv.title": "Konverzace · {project}", + "conv.started": "{channel}, {time}", + "conv.yes": "Ano", + "conv.differently": "Jinak", + "conv.newThing": "Nová věc", + "conv.makeTask": "Udělat z toho úkol", + "conv.todo": "Nechat jako otevřený bod", + "conv.forget": "Zapomenout", + "conv.forgetN": "Zapomenout {n}", + "conv.done": "Hotovo ✓", + "conv.forgetConfirm": "Zapomenout tuto položku? Její text se smaže.", + "conv.forgotten": "Zapomenuto.", + "conv.markedDone": "Odškrtnuto.", + "conv.correction": "Jak to myslíš?", + "conv.openTask": "Otevřít úkol", + "conv.closed": "Konverzace skončila ({reason}).", + "conv.readOnly": "Tato konverzace je tu jen ke čtení: skončila, nebo patří jinému kanálu.", + "conv.you": "Ty", + "conv.reception": "Recepce", + "conv.report": "Zpráva o úkolu", + "memory.title": "Paměť · {project}", + "memory.preferences": "Preference operátora", + "memory.toPreferences": "Preference operátora (všechny projekty)", + "memory.toProject": "Paměť projektu {project}", + "memory.kind.pinned": "📌 Připnuté", + "memory.kind.fact": "Fakta", + "memory.kind.glossary": "Slovník", + "memory.kind.preference": "Preference", + "memory.kind.todo": "Otevřené body", + "memory.kind.summary": "Shrnutí konverzací", + "memory.edit": "Upravit", + "memory.save": "Uložit", + "memory.cancel": "Zrušit", + "memory.pin": "Připnout", + "memory.unpin": "Odepnout", + "memory.done": "Hotovo ✓", + "memory.isDone": "hotovo", + "memory.forget": "Zapomenout", + "memory.forgetConfirm": "Zapomenout tuto položku? Její text se smaže natrvalo.", + "memory.showDone": "Ukázat hotové otevřené body", + "memory.empty": "Zatím nic zapamatováno.", + "memory.source": "z konverzace", + "memory.byHand": "přidáno ručně", + "memory.forgetNote": "Zapomenutí smaže text položky. Zpráva, ze které vznikla, v konverzaci zůstává.", + "memory.add": "Přidat", + "memory.addText": "Co si má OOAT pamatovat?", + "memory.addPinned": "Připnout (dostane ho každý úkol)", + "memory.kind": "Druh", }; diff --git a/core/src/ooat_core/web/app/i18n/en.js b/core/src/ooat_core/web/app/i18n/en.js index 7ca72f6..f5e0cc3 100644 --- a/core/src/ooat_core/web/app/i18n/en.js +++ b/core/src/ooat_core/web/app/i18n/en.js @@ -155,4 +155,62 @@ export default { "setup.sample": "Open the inbox with a sample task", "sample.goal": "Summarise in five bullet points what OOAT does with a task, from submission to rating.", "sample.acceptance": "At most five bullet points\nEach point at most 25 words", + "nav.memory": "Memory", + "home.placeholder": "Ask or tell OOAT anything… (Enter sends, Shift+Enter is a new line)", + "home.project": "Project", + "home.send": "Send", + "home.attach": "Attach files", + "home.received": "Received. The reception replies once you stop writing for a moment.", + "home.noProjects": "No project yet: add one under [projects] in ooat.toml.", + "home.empty": "A new conversation starts with your message.", + "home.earlier": "Earlier conversations", + "home.exactTask": "A task with exact criteria", + "conv.title": "Conversation · {project}", + "conv.started": "{channel}, {time}", + "conv.yes": "Yes", + "conv.differently": "Differently", + "conv.newThing": "New thing", + "conv.makeTask": "Make it a task", + "conv.todo": "Keep as an open point", + "conv.forget": "Forget", + "conv.forgetN": "Forget {n}", + "conv.done": "Done ✓", + "conv.forgetConfirm": "Forget this memory item? Its text is deleted.", + "conv.forgotten": "Forgotten.", + "conv.markedDone": "Marked done.", + "conv.correction": "How do you mean it?", + "conv.openTask": "Open the task", + "conv.closed": "The conversation closed ({reason}).", + "conv.readOnly": "This conversation is read only here: it is closed, or it belongs to another channel.", + "conv.you": "You", + "conv.reception": "Reception", + "conv.report": "Report", + "memory.title": "Memory · {project}", + "memory.preferences": "Operator preferences", + "memory.toPreferences": "Operator preferences (all projects)", + "memory.toProject": "Memory of {project}", + "memory.kind.pinned": "📌 Pinned", + "memory.kind.fact": "Facts", + "memory.kind.glossary": "Glossary", + "memory.kind.preference": "Preferences", + "memory.kind.todo": "Open points", + "memory.kind.summary": "Summaries of conversations", + "memory.edit": "Edit", + "memory.save": "Save", + "memory.cancel": "Cancel", + "memory.pin": "Pin", + "memory.unpin": "Unpin", + "memory.done": "Done ✓", + "memory.isDone": "done", + "memory.forget": "Forget", + "memory.forgetConfirm": "Forget this item? Its text is deleted for good.", + "memory.showDone": "Show done open points", + "memory.empty": "Nothing remembered yet.", + "memory.source": "from the conversation", + "memory.byHand": "added by hand", + "memory.forgetNote": "Forgetting deletes the item's text. The message it came from stays in its conversation.", + "memory.add": "Add", + "memory.addText": "What should OOAT remember?", + "memory.addPinned": "Pinned (given to every task)", + "memory.kind": "Kind", }; diff --git a/core/src/ooat_core/web/app/views/conversation.js b/core/src/ooat_core/web/app/views/conversation.js new file mode 100644 index 0000000..6799542 --- /dev/null +++ b/core/src/ooat_core/web/app/views/conversation.js @@ -0,0 +1,163 @@ +// A conversation with the reception (design 08 §4, §9): its turns, the reception's buttons while it is open on the +// web, and one conversation's own page. The redaction notice of the server links to that page. +import { shown, tapKind, tapLabel } from "../conversation.js"; +import { h, report } from "../dom.js"; +import { ago, clock } from "../format.js"; +import { t } from "../i18n.js"; +import { showMarkdown } from "../markdown.js"; + +/** @typedef {import("../conversation.js").Turn} Turn */ + +/** + * The turns of one conversation, appended as they come; never redrawn, so a half-typed correction stays. + * @param {import("../api.js").Context} ctx + * @param {{live: boolean, onTurn?: (turn: Turn) => void}} options live: the buttons work (an open web conversation) + */ +export function thread(ctx, options) { + const element = h("div", {class: "thread", "aria-live": "polite"}); + /** @type {Set} */ + const seen = new Set(); + /** @type {() => void} turns off the last reply's taps: a tap always answers the newest decision */ + let retire = () => {}; + + /** @param {any[]} events */ + function add(events) { + for (const turn of shown(events, seen)) { + if (turn.who === "reception" || turn.who === "report") retire(); + element.append(turnElement(turn)); + options.onTurn?.(turn); + } + } + + /** @param {Turn} turn */ + function turnElement(turn) { + if (turn.who === "closed") return h("p", {class: "muted"}, t("conv.closed", {reason: turn.text ?? ""})); + if (turn.who === "you") { + const label = turn.tap ? tapLabel(turn.tap) : null; + const said = turn.redacted ? t("common.redacted") : [label ? t(label) : turn.tap, turn.text].filter(Boolean) + .join(": "); + return h("div", {class: "turn you"}, h("time", {datetime: turn.ts}, clock(turn.ts)), h("p", {}, said)); + } + const body = h("div", {class: "said"}); + if (turn.redacted) { + body.append(h("p", {class: "muted"}, t("common.redacted"), " ", h("a", + {href: `/conversations/${turn.conversation}`, "data-route": ""}, t("conv.title", {project: turn.project})))); + } else { + showMarkdown(body, turn.text ?? ""); + } + const status = h("p", {class: "status", role: "status"}); + return h("div", {class: `turn ${turn.who}`}, + h("time", {datetime: turn.ts}, + `${t(turn.who === "report" ? "conv.report" : "conv.reception")} · ${clock(turn.ts)}`), + body, + turn.options.length ? h("ol", {}, ...turn.options.map((option) => h("li", {}, option))) : null, + turn.task ? h("p", {}, h("a", {href: `/tasks/${turn.task}`, "data-route": ""}, t("conv.openTask"))) : null, + options.live && turn.buttons.length ? buttons(turn, status) : null, status); + } + + /** @param {Turn} turn @param {HTMLElement} status */ + function buttons(turn, status) { + const forgets = turn.buttons.filter((tap) => tapKind(tap) === "forget").length; + let forget = 0; + const correction = h("span", {class: "row", hidden: true}); + /** @type {HTMLButtonElement[]} the conversation's taps (Yes, Differently, …); Forget and Done stay on */ + const taps = []; + retire = () => { + for (const button of taps) button.disabled = true; + correction.hidden = true; + }; + + /** @param {string} tap @param {HTMLButtonElement} pressed @param {string} [text] */ + async function press(tap, pressed, text) { + const kind = tapKind(tap); + const mem = tap.split(":", 2)[1]; + if (kind === "forget" && !confirm(t("conv.forgetConfirm"))) return; + const group = kind === "forget" || kind === "done" ? [pressed] : taps; + for (const button of group) button.disabled = true; + status.textContent = ""; + try { + if (kind === "forget") { + await ctx.api.json(`/memory/${encodeURIComponent(turn.project)}/${encodeURIComponent(mem)}`, + {method: "DELETE"}); + status.textContent = t("conv.forgotten"); + } else if (kind === "done") { + await ctx.api.json(`/memory/${encodeURIComponent(turn.project)}/${encodeURIComponent(mem)}`, + {method: "PATCH", body: {done: true}}); + status.textContent = t("conv.markedDone"); + } else { + const tapped = await ctx.api.json(`/conversations/${encodeURIComponent(turn.conversation)}/taps`, + {method: "POST", body: {tap, ...(text ? {text} : {})}}); + add(tapped.decision ? [tapped.decision] : []); // the stream brings it too: shown once + if (!tapped.decision) status.textContent = t("home.received"); + } + correction.hidden = true; + } catch (error) { + report(status, error); + for (const button of group) button.disabled = false; + } + } + + /** @type {HTMLButtonElement[]} */ + const all = []; + for (const tap of turn.buttons) { + const kind = tapKind(tap); + const key = tapLabel(tap); + if (key === null) continue; + if (kind === "forget") forget += 1; + const label = kind === "forget" && forgets > 1 ? t("conv.forgetN", {n: forget}) : t(key); + const button = h("button", {type: "button", class: kind === "yes" ? "recommended" : undefined, onclick: () => { + if (kind !== "differently") { + press(tap, button); + return; + } + correction.hidden = false; // the correction goes with the tap; an empty one lets the reception ask + correction.querySelector("input")?.focus(); + }}, label); + all.push(button); + if (kind !== "forget" && kind !== "done") taps.push(button); + } + if (turn.buttons.includes("differently")) { + const typed = h("input", {type: "text", maxlength: "20000", placeholder: t("conv.correction"), + "aria-label": t("conv.correction")}); + const send = h("button", {type: "button", onclick: () => press("differently", send, typed.value.trim())}, + t("home.send")); + taps.push(send); + correction.append(typed, send); + } + return h("div", {}, h("div", {class: "actions"}, ...all), correction); + } + + return {element, add}; +} + +/** + * Follows a conversation from `after`, adding each event to `shown`; ends at its close or when `signal` aborts. + * @param {import("../api.js").Context} ctx @param {string} conversation @param {string | null} after + * @param {{add: (events: any[]) => void}} into @param {AbortSignal} signal @param {Element} errors + * @param {() => void} [onClosed] + */ +export function follow(ctx, conversation, after, into, signal, errors, onClosed) { + ctx.api.followConversation(conversation, after, (event) => { + into.add([event]); + if (event.type === "CONVERSATION_CLOSED") onClosed?.(); + }, signal).catch((error) => report(errors, error)); +} + +/** + * One conversation by its id, read only unless it is open on the web. + * @param {HTMLElement} root @param {import("../api.js").Context} ctx @param {string} id + */ +export async function conversationPage(root, ctx, id) { + const found = await ctx.api.json(`/conversations/${encodeURIComponent(id)}`, {signal: ctx.signal}); + const live = !found.closed && found.channel === "web"; + const turns = thread(ctx, {live}); + const errors = h("p", {class: "status", role: "status"}); + root.replaceChildren(h("h1", {}, t("conv.title", {project: found.project})), + h("p", {class: "muted"}, t("conv.started", {channel: found.channel, time: ago(found.started, new Date())})), + ...(live ? [] : [h("p", {class: "muted"}, t("conv.readOnly"))]), turns.element, errors); + turns.add(found.events); + if (live) { + const last = found.events.length ? found.events[found.events.length - 1].id : null; + follow(ctx, id, last, turns, ctx.signal, errors); + } +} diff --git a/core/src/ooat_core/web/app/views/home.js b/core/src/ooat_core/web/app/views/home.js new file mode 100644 index 0000000..8f04ed7 --- /dev/null +++ b/core/src/ooat_core/web/app/views/home.js @@ -0,0 +1,155 @@ +// Home (design 08 §9, plan 08b): one box like a search field with the project next to it, the conversation with the +// reception below it, the project's earlier conversations, then the inbox's lists and the folded exact-task form. +import { openOf } from "../conversation.js"; +import { h, report } from "../dom.js"; +import { ago } from "../format.js"; +import { t } from "../i18n.js"; +import { follow, thread } from "./conversation.js"; +import { MAX_ATTACHMENT_BYTES, base64, taskForm, taskLists } from "./inbox.js"; + +export const PROJECT_STORAGE = "ooat.project"; +const EARLIER = 5; + +/** The project last chosen, if the browser keeps it. */ +export function storedProject() { + try { + return localStorage.getItem(PROJECT_STORAGE); + } catch { + return null; + } +} + +/** @param {string} project */ +function storeProject(project) { + try { + localStorage.setItem(PROJECT_STORAGE, project); + } catch { + // a private window: the picker falls back to the first project next time + } +} + +/** @param {HTMLElement} root @param {import("../api.js").Context} ctx */ +export async function home(root, ctx) { + const {projects} = await ctx.api.json("/projects", {signal: ctx.signal}); + const names = projects.map((/** @type {any} */ p) => p.project); + const picker = h("select", {"aria-label": t("home.project")}, ...names.map((/** @type {string} */ name) => + h("option", {value: name}, name))); + const stored = storedProject(); + picker.value = stored && names.includes(stored) ? stored : names[0] ?? ""; + const text = h("textarea", {rows: "2", required: true, maxlength: "20000", placeholder: t("home.placeholder"), + "aria-label": t("home.placeholder")}); + const files = h("input", {type: "file", multiple: true, "aria-label": t("home.attach")}); + const send = h("button", {type: "submit", class: "recommended"}, t("home.send")); + const status = h("p", {class: "status", role: "status"}); + const talk = h("section"); + const earlier = h("section"); + const form = taskForm(ctx); + const box = h("form", {class: "card box", onsubmit: submit}, text, + h("div", {class: "row"}, picker, files, send), status); + root.replaceChildren(h("h1", {}, t("inbox.title")), box, talk, earlier, form.element); + if (!names.length) { + for (const control of [text, picker, files, send]) control.disabled = true; + status.textContent = t("home.noProjects"); + } + + /** The conversation shown: its id (null until the next message starts one), its turns, its stream. */ + let current = {project: picker.value, conversation: /** @type {string | null} */ (null), + turns: thread(ctx, {live: true}), stop: new AbortController()}; + ctx.signal.addEventListener("abort", () => current.stop.abort()); // leaving the screen ends the stream shown + let key = crypto.randomUUID(); // one Idempotency-Key per typed message: a double Enter or a retry is one message + + /** @param {string} project */ + async function open(project) { + current.stop.abort(); // the old project's replies never show under the new one + const stop = new AbortController(); + const turns = thread(ctx, {live: true, onTurn: (turn) => { + talk.querySelector(".empty")?.remove(); + if (turn.who !== "you") status.textContent = ""; + }}); + current = {project, conversation: null, turns, stop}; + if (project) storeProject(project); // the header's Memory opens the project shown here + talk.replaceChildren(turns.element); + earlier.replaceChildren(); + if (!project) return; + const query = `/conversations?project=${encodeURIComponent(project)}&limit=20`; + const {conversations} = await ctx.api.json(query, {signal: stop.signal}); + if (current.stop !== stop) return; + const id = openOf(conversations, project); + const before = conversations.filter((/** @type {any} */ c) => c.conversation !== id).slice(0, EARLIER); + if (before.length) { + const now = new Date(); + earlier.append(h("h2", {}, t("home.earlier")), h("ul", {}, ...before.map((/** @type {any} */ c) => h("li", {}, + h("a", {href: `/conversations/${c.conversation}`, "data-route": ""}, + t("conv.started", {channel: c.channel, time: ago(c.last_message, now)})))))); + } + if (id === null) { + talk.append(h("p", {class: "muted empty"}, t("home.empty"))); + return; + } + const first = await ctx.api.json(`/conversations/${encodeURIComponent(id)}/events`, {signal: stop.signal}); + if (current.stop !== stop) return; + current.conversation = id; + turns.add(first.events); + listen(id, first.next); + } + + /** @param {string} id @param {string | null} after */ + function listen(id, after) { + const mine = current; + follow(ctx, id, after, mine.turns, mine.stop.signal, status, () => { + if (current === mine) current.conversation = null; // closed: the next message starts a new conversation + }); + } + + /** @param {Event} event */ + async function submit(event) { + event.preventDefault(); + const said = text.value.trim(); + if (!said || send.disabled) return; + const chosen = [...(files.files ?? [])]; + if (chosen.reduce((sum, file) => sum + file.size, 0) > MAX_ATTACHMENT_BYTES) { + status.textContent = t("new.tooLarge"); + return; + } + send.disabled = true; + status.textContent = ""; + const mine = current; + try { + const body = {project: mine.project, text: said, + ...(mine.conversation ? {conversation: mine.conversation} : {}), + attachments: await Promise.all(chosen.map(async (file) => + ({name: file.name, content_base64: await base64(file)})))}; + const sent = await ctx.api.json("/conversations/messages", {method: "POST", body, + headers: {"Idempotency-Key": key}}); + key = crypto.randomUUID(); + text.value = ""; + files.value = ""; + if (current !== mine) return; + // Shown at once; its id is then known, so the stream's copy of it is not shown again. + mine.turns.add([{id: sent.message, ts: new Date().toISOString(), type: "CONVERSATION_MESSAGE", + body: {conversation: sent.conversation, project: mine.project, text: said}}]); + status.textContent = t("home.received"); + if (mine.conversation !== sent.conversation) { + mine.conversation = sent.conversation; + listen(sent.conversation, sent.message); + } + } catch (error) { + report(status, error); + } finally { + send.disabled = false; + } + } + + text.addEventListener("keydown", (event) => { // Enter sends, Shift+Enter is a new line + if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { + event.preventDefault(); + box.requestSubmit(); + } + }); + picker.addEventListener("change", () => { + open(picker.value).catch((error) => report(talk, error)); + }); + + await open(picker.value); + root.append(await taskLists(ctx, form)); +} diff --git a/core/src/ooat_core/web/app/views/inbox.js b/core/src/ooat_core/web/app/views/inbox.js index 3f8c620..37e038a 100644 --- a/core/src/ooat_core/web/app/views/inbox.js +++ b/core/src/ooat_core/web/app/views/inbox.js @@ -1,18 +1,21 @@ -// Home (design 05 §4): what waits for me, what is stuck, the tasks, and the box for a new task. +// The lists of the home page (design 05 §4): what waits for me, what is stuck, the tasks; and the form for a task +// with exact criteria, folded under the reception's box (design 08 §9, plan 08b). import { questionCard, ratingCard } from "../cards.js"; import { h, report } from "../dom.js"; import { ago, clock, money, title } from "../format.js"; import { t } from "../i18n.js"; const REFRESH_MS = 15_000; -const MAX_ATTACHMENT_BYTES = 14 * 1024 * 1024; // a request carries at most 20 MB; base64 adds a third +export const MAX_ATTACHMENT_BYTES = 14 * 1024 * 1024; // a request carries at most 20 MB; base64 adds a third const DATA_CLASSES = ["public", "internal", "client_confidential", "personal"]; -/** @param {HTMLElement} root @param {import("../api.js").Context} ctx */ -export async function inbox(root, ctx) { +/** + * Waiting for you, stuck, and the tasks, refreshed every 15 s while the focus is outside them. + * @param {import("../api.js").Context} ctx + * @param {{known: (tasks: any[]) => void}} form told the listed tasks, for its project suggestions + */ +export async function taskLists(ctx, form) { const lists = h("div"); - const form = newTask(ctx); - root.replaceChildren(h("h1", {}, t("inbox.title")), form.element, lists); async function draw() { const [tasks, questions, ratings] = await Promise.all([ @@ -36,6 +39,7 @@ export async function inbox(root, ctx) { if (!lists.contains(document.activeElement)) draw().catch((error) => report(lists, error)); // keep typed text }, REFRESH_MS); ctx.signal.addEventListener("abort", () => clearInterval(timer)); + return lists; } /** @param {any} task */ @@ -53,8 +57,11 @@ function table(tasks) { h("td", {}, ago(task.updated, now)))))); } -/** @param {import("../api.js").Context} ctx */ -function newTask(ctx) { +/** + * A task with exact criteria, data class and budget, in a closed
. + * @param {import("../api.js").Context} ctx + */ +export function taskForm(ctx) { const goal = h("textarea", {rows: "3", required: true, maxlength: "20000", placeholder: t("new.goal"), "aria-label": t("new.goal")}); const acceptance = h("textarea", {rows: "2", placeholder: t("new.acceptance"), "aria-label": t("new.acceptance")}); @@ -70,7 +77,8 @@ function newTask(ctx) { const submit = h("button", {type: "submit"}, t("new.submit")); const status = h("p", {class: "status", role: "status"}); let key = crypto.randomUUID(); // one Idempotency-Key per task typed in: a double click or a retry makes one task - if (new URLSearchParams(location.search).has("sample")) { // Setup's last step: filled in, sent only on Submit + const sample = new URLSearchParams(location.search).has("sample"); + if (sample) { // Setup's last step: filled in, sent only on Submit goal.value = t("sample.goal"); acceptance.value = t("sample.acceptance"); project.value = "sample"; @@ -105,8 +113,9 @@ function newTask(ctx) { } } - const element = h("form", {class: "card new-task", onsubmit: send}, goal, acceptance, - h("div", {class: "row"}, project, projects, dataClass, budget, attachments, submit), status); + const element = h("details", {open: sample}, h("summary", {}, t("home.exactTask")), + h("form", {class: "card new-task", onsubmit: send}, goal, acceptance, + h("div", {class: "row"}, project, projects, dataClass, budget, attachments, submit), status)); return { element, /** The projects of the listed tasks, offered as you type; the newest is the default. @param {any[]} tasks */ @@ -119,7 +128,7 @@ function newTask(ctx) { } /** @param {File} file @returns {Promise} */ -function base64(file) { +export function base64(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result).split(",", 2)[1] ?? ""); diff --git a/core/src/ooat_core/web/app/views/memory.js b/core/src/ooat_core/web/app/views/memory.js new file mode 100644 index 0000000..c8c7253 --- /dev/null +++ b/core/src/ooat_core/web/app/views/memory.js @@ -0,0 +1,114 @@ +// Memory (design 08 §6, plan 08b): a project's items, or the operator's preferences (the scope `operator`), with +// add, edit, pin, done and forget. Texts live outside the ledger; forgetting deletes one for good (ADR 0018). +import { h, report } from "../dom.js"; +import { ago } from "../format.js"; +import { t } from "../i18n.js"; +import { storedProject } from "./home.js"; + +const OPERATOR = "operator"; +/** @type {[string, import("../i18n.js").Key][]} */ +const GROUPS = [["fact", "memory.kind.fact"], ["glossary", "memory.kind.glossary"], + ["preference", "memory.kind.preference"], ["todo", "memory.kind.todo"], ["summary", "memory.kind.summary"]]; +const ADDABLE = ["fact", "glossary", "todo", "preference"]; + +/** @param {HTMLElement} root @param {import("../api.js").Context} ctx @param {string} scope */ +export async function memoryPage(root, ctx, scope) { + const preferences = scope === OPERATOR; + const path = `/memory/${encodeURIComponent(scope)}`; + const showDone = h("input", {type: "checkbox"}); + const list = h("div"); + const project = storedProject(); + const other = preferences + ? project && h("a", {href: `/memory/${project}`, "data-route": ""}, t("memory.toProject", {project})) + : h("a", {href: `/memory/${OPERATOR}`, "data-route": ""}, t("memory.toPreferences")); + root.replaceChildren(h("h1", {}, preferences ? t("memory.preferences") : t("memory.title", {project: scope})), + h("p", {}, other || null), addForm(), h("p", {}, h("label", {}, showDone, " ", t("memory.showDone"))), list, + h("p", {class: "muted"}, t("memory.forgetNote"))); + + async function draw() { + const {items} = await ctx.api.json(`${path}${showDone.checked ? "?done=true" : ""}`, {signal: ctx.signal}); + if (!items.length) { + list.replaceChildren(h("p", {class: "muted"}, t("memory.empty"))); + return; + } + const pinned = items.filter((/** @type {any} */ i) => i.pinned); + /** @type {[import("../i18n.js").Key, any[]][]} */ + const groups = [["memory.kind.pinned", pinned], ...GROUPS.map(([kind, key]) => /** @type {[any, any[]]} */ ( + [key, items.filter((/** @type {any} */ i) => !i.pinned && i.kind === kind)]))]; + list.replaceChildren(...groups.filter(([, shown]) => shown.length).map(([key, shown]) => + h("section", {}, h("h2", {}, t(key)), ...shown.map(itemElement)))); + } + + const again = () => { draw().catch((error) => report(list, error)); }; + + function addForm() { + const kind = h("select", {"aria-label": t("memory.kind")}, ...(preferences ? ["preference"] : ADDABLE) + .map((name) => h("option", {value: name}, t(/** @type {import("../i18n.js").Key} */ (`memory.kind.${name}`))))); + const text = h("textarea", {rows: "2", required: true, maxlength: "2000", placeholder: t("memory.addText"), + "aria-label": t("memory.addText")}); + const pin = h("input", {type: "checkbox"}); + const status = h("p", {class: "status", role: "status"}); + const add = h("button", {type: "submit"}, t("memory.add")); + return h("form", {class: "card", onsubmit: async (/** @type {Event} */ event) => { + event.preventDefault(); + add.disabled = true; + status.textContent = ""; + try { + await ctx.api.json(path, {method: "POST", body: {kind: kind.value, text: text.value, pinned: pin.checked}}); + text.value = ""; // kept on a refusal, so the operator can change it + pin.checked = false; + again(); + } catch (error) { + report(status, error); + } finally { + add.disabled = false; + } + }}, text, h("div", {class: "row"}, kind, h("label", {}, pin, " ", t("memory.addPinned")), add), status); + } + + /** @param {any} item */ + function itemElement(item) { + const status = h("span", {class: "status", role: "status"}); + const text = h("span", {class: "text"}, item.text); + const source = item.conversation + ? h("a", {href: `/conversations/${item.conversation}`, "data-route": ""}, t("memory.source")) + : t("memory.byHand"); + const meta = h("span", {class: "muted"}, `${item.data_class} · ${ago(item.noted, new Date())} · `, source, + item.done ? ` · ${t("memory.isDone")}` : ""); + + /** @param {string} method @param {object} [body] */ + async function change(method, body) { + status.textContent = ""; + try { + await ctx.api.json(`${path}/${encodeURIComponent(item.memory)}`, {method, body}); + again(); + } catch (error) { + report(status, error); + } + } + + function edit() { + const area = h("textarea", {rows: "3", maxlength: "2000", "aria-label": t("memory.edit")}); + area.value = item.text; + const save = h("button", {type: "button", onclick: () => change("PATCH", {text: area.value})}, + t("memory.save")); + const cancel = h("button", {type: "button", onclick: again}, t("memory.cancel")); + text.replaceChildren(area, h("span", {class: "row"}, save, cancel)); + area.focus(); + } + + const actions = h("span", {class: "actions"}, + h("button", {type: "button", onclick: edit}, t("memory.edit")), + h("button", {type: "button", onclick: () => change("PATCH", {pinned: !item.pinned})}, + item.pinned ? t("memory.unpin") : t("memory.pin")), + item.kind === "todo" && !item.done + ? h("button", {type: "button", onclick: () => change("PATCH", {done: true})}, t("memory.done")) : null, + h("button", {type: "button", class: "danger", onclick: () => { + if (confirm(t("memory.forgetConfirm"))) change("DELETE"); + }}, t("memory.forget"))); + return h("div", {class: "card memory-item"}, text, meta, actions, status); + } + + showDone.addEventListener("change", again); + await draw(); +} diff --git a/core/tests/test_api_conversations.py b/core/tests/test_api_conversations.py index e3e6a5a..58b33a2 100644 --- a/core/tests/test_api_conversations.py +++ b/core/tests/test_api_conversations.py @@ -221,3 +221,62 @@ def test_a_delete_may_go_without_a_length_but_never_with_a_streamed_body(tmp_pat response = s.client.request("DELETE", url, headers={**s.token(), "Transfer-Encoding": "chunked"}, content=b"") assert response.status_code == 411 and s.server.memory.read(item.id) is not None assert s.client.delete(url, headers=s.token()).status_code == 200 + + +def test_projects_lists_the_configured_ones_within_the_cap_and_those_seen(tmp_path): + for name in ("Docs", "Hr"): + (tmp_path / name).mkdir() + config = parse_config({"reception": {"batch_seconds": 0}, "projects": { + "docs": {"folders": [str(tmp_path / "Docs")]}, + "hr": {"folders": [str(tmp_path / "Hr")], "data_class": "personal"}}}) + s = Served(tmp_path, model=ReceptionModel(), config=config) + bot = s.token() + s.client.post("/api/v1/conversations/messages", headers=bot, json={"project": "eshop", "text": "Ahoj."}) + s.client.post("/api/v1/conversations/messages", headers=s.token(), json={"project": "docs", "text": "Ahoj."}) + listed = s.client.get("/api/v1/projects", headers=bot) + assert listed.json() == {"projects": [ + {"project": "docs", "data_class": "internal", "write": False, "folders": 1}, + {"project": "eshop", "data_class": "internal", "write": False, "folders": 0}]} + assert str(tmp_path.name) not in listed.text # names only, never a folder + other = s.client.get("/api/v1/projects", headers=s.login(operator="someone else")).json()["projects"] + assert [p["project"] for p in other] == ["docs", "hr"] # another operator's projects stay theirs + + +def test_an_item_by_hand_needs_the_web_and_passes_the_secret_scan(tmp_path): + s = served(tmp_path) + body = {"kind": "fact", "text": "Ceny jsou s DPH.", "pinned": True} + refused = s.client.post("/api/v1/memory/docs", headers=s.token(), json=body) + assert refused.status_code == 403 and refused.json()["error"]["code"] == "SCOPE" + web = s.login() + made = s.client.post("/api/v1/memory/docs", headers=web, json=body) + assert made.status_code == 201, made.text + item = made.json() + assert item | {"memory": None, "noted": None, "source": None} == { + "memory": None, "scope": "docs", "kind": "fact", "data_class": "internal", "text": "Ceny jsou s DPH.", + "pinned": True, "done": False, "noted": None, "source": None, "conversation": None} + noted_event = next(e for e in s.server.runtime().ledger.events(types=["MEMORY_NOTED"])) + assert item["source"] == noted_event["id"] and noted_event["actor"] == {"kind": "hil", "id": "operator"} + assert [i["text"] for i in s.client.get("/api/v1/memory/docs", headers=web).json()["items"]] == [ + "Ceny jsou s DPH."] + secret = s.client.post("/api/v1/memory/docs", headers=web, json={"kind": "fact", "text": "password=hunter22"}) + assert secret.status_code == 400 and secret.json()["error"]["code"] == "SECRET_REFUSED" + assert "hunter22" not in secret.text + + +def test_an_item_by_hand_keeps_to_its_kinds(tmp_path): + s = served(tmp_path) + web = s.login() + for scope_name, kind in (("docs", "summary"), ("operator", "fact"), ("Docs!", "fact")): + response = s.client.post(f"/api/v1/memory/{scope_name}", headers=web, json={"kind": kind, "text": "Krátce."}) + assert response.status_code == 400, (scope_name, kind, response.text) + made = s.client.post("/api/v1/memory/operator", headers=web, + json={"kind": "preference", "text": "Odpovídej česky."}) + assert made.status_code == 201 and made.json()["scope"] == "operator" + + +def test_an_item_by_hand_above_the_sessions_cap_is_refused_not_orphaned(tmp_path): + s = served(tmp_path, host="192.168.1.10", hosts=("ooat.lan",), tls=True) # beyond loopback: capped at internal + response = s.client.post("/api/v1/memory/docs", headers=s.login(), + json={"kind": "fact", "text": "Jan Novák, jan.novak@example.cz, platí fakturou."}) + assert response.status_code == 403 and response.json()["error"]["code"] == "DATA_CLASS_ABOVE_TOKEN" + assert s.client.get("/api/v1/memory/docs", headers=s.login()).json()["items"] == [] diff --git a/core/tests/test_web.py b/core/tests/test_web.py index 6002ced..5122394 100644 --- a/core/tests/test_web.py +++ b/core/tests/test_web.py @@ -36,7 +36,8 @@ def test_html_files_have_no_inline_script_handler_or_style(): def test_the_app_shell_is_served_for_each_screen_without_a_session(tmp_path): served = Served(tmp_path) - for path in ("/", "/tasks/tsk_01ABC", "/usage", "/connectors", "/setup"): + for path in ("/", "/tasks/tsk_01ABC", "/usage", "/connectors", "/setup", "/conversations/cnv_01ABC", + "/memory", "/memory/docs", "/memory/operator"): response = served.client.get(path) assert response.status_code == 200 and response.headers["content-type"].startswith("text/html"), path assert '