Ask-the-Archive v2 — conversations, streaming, admin AI settings — Design
Date: 2026-07-03
Status: Approved (design); pending implementation plan.
Goal
Grow the /ask chatbot from a per-session toy into a durable product surface: server-side
conversation history (rename / delete / save / incognito), streaming answers, retry,
copy, styled PDF export, an admin AI tab (chat retention, token budget, usage
visibility), and a retention sweep that auto-deletes stale unsaved chats. All of it stays
behind the existing ai + semantic module gates and the per-caller ACL-filtered retrieval
(both live-verified 2026-07-03).
Decisions (locked with the user)
- History lives server-side (Postgres, per-user) — survives browsers/devices; incognito =
never written. 2. PDF via Gotenberg (already in the stack) — a styled report, not a print
hack. 3. Streaming answers included. 4. Conversation rename included. 5. Auto-delete
unsaved chats older than N days (default 30, admin-configurable; 0 = never), saved chats
exempt — with a new Admin → AI tab. 6. Extra admin settings chosen for genuine utility:
daily token budget (spend guard) + usage metering/visibility + read-only provider
status. 7. Explicit non-goals listed at the end.
Data model (migration 00074, ai context — new ai/adapters/pg.go store + Repository port)
ai_conversations (
id uuid PRIMARY KEY,
user_id uuid NOT NULL, -- owner; every query filters on it (owner-scoped)
title text NOT NULL, -- auto: first question clipped ~60 chars; renameable
saved boolean NOT NULL DEFAULT false, -- saved chats are EXEMPT from retention
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL -- bumped per message; retention keys off this
);
CREATE INDEX ai_conversations_user ON ai_conversations (user_id, updated_at DESC);
ai_messages (
id uuid PRIMARY KEY,
conversation_id uuid NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE,
role text NOT NULL, -- 'user' | 'assistant'
content text NOT NULL,
no_results boolean NOT NULL DEFAULT false,
model_provider text NOT NULL DEFAULT '',
model_name text NOT NULL DEFAULT '',
sources jsonb NOT NULL DEFAULT '[]', -- [{ref,doc_id,title,score,snippet}]
created_at timestamptz NOT NULL
);
CREATE INDEX ai_messages_conv ON ai_messages (conversation_id, created_at ASC);
ai_settings ( -- single row (id=1): admin-managed knobs
id int PRIMARY KEY CHECK (id = 1),
chat_retention_days int NOT NULL DEFAULT 30, -- 0 = keep forever
daily_token_budget bigint NOT NULL DEFAULT 0, -- 0 = unlimited; input+output tokens/day
updated_at timestamptz NOT NULL
);
ai_usage ( -- per-day, per-feature metering (upserted after each model call)
day date NOT NULL,
feature text NOT NULL, -- 'ask' | 'summarize' | 'classify' | 'enrich' | ...
requests int NOT NULL DEFAULT 0,
tokens_in bigint NOT NULL DEFAULT 0,
tokens_out bigint NOT NULL DEFAULT 0,
PRIMARY KEY (day, feature)
);
- ACL nuance (documented): a stored conversation keeps the snippets the user could read at
answer time (equivalent to copying them). Later ACL revocation does not retro-scrub history.
Conversations themselves are strictly owner-scoped; a foreign id is404. - Token counts: providers that report usage (OpenAI/Anthropic responses) fill exact numbers —
extendChatResponsewithTokensIn/TokensOut; providers that don't report fall back to a
len(chars)/4estimate. Metering is best-effort and never fails a request.
API (all under requireModule("ai") + requireModule("semantic") unless noted)
GET /api/v1/ai/conversations— owner's list, newest first (id, title, saved, updated_at,
message_count), limit 100.GET /api/v1/ai/conversations/{id}— the messages (owner check → 404 otherwise).PATCH /api/v1/ai/conversations/{id}—{title?}(rename, 1..120 chars) and/or{saved?}
(save/unsave toggle).DELETE /api/v1/ai/conversations/{id}— hard delete (cascade). Idempotent.DELETE /api/v1/ai/conversations— clear ALL of the caller's conversations.POST /api/v1/ai/ask-archive— gains fields:conversation_id?— when present, the server loads the last 8 turns from the DB as history
(the client stops replaying history for stored chats) and appends the new Q + A
transactionally, bumpingupdated_at.- absent + not incognito → server creates the conversation (auto-title) and returns
conversation_idin the response/stream. incognito: true— exactly today's behavior: client-held history in the request, nothing
ever written server-side (not even transiently).retry: true(requiresconversation_id) — the server deletes the conversation's last
assistant message and regenerates from the stored history (fresh retrieval + generation).stream: true— SSE response (below). Without it, the current JSON response (kept for
retrieval_only, tests, and as fallback).POST /api/v1/ai/export-pdf— body = transcript payload{title, turns:[{role, content, sources?}]}(works for stored AND incognito chats). Server renders a styled HTML report — cover
(title, asker display name, date), Q/A sections with inline[n]citations, a sources appendix
(ref, doc title, relevance) — through the existing Gotenberg adapter →application/pdf
attachment. Bounded: ≤ 60 turns, ≤ 512 KiB payload.- Admin (new perm
ai.manage, seeded + added to the permission catalog, admin role granted): GET/PUT /api/v1/admin/ai/settings—{chat_retention_days, daily_token_budget}(validated:
0..3650 days; budget ≥ 0).GET /api/v1/admin/ai/usage?days=30— theai_usagerows for the window plus provider/model
status (provider, chat model, embed provider/model — read-only, from live config).
Streaming protocol (SSE over the existing route)
POST /ai/ask-archive with stream: true → Content-Type: text/event-stream:
event: sources data: {"conversation_id":"…","no_results":false,"sources":[…]} (once, post-retrieval)
event: delta data: {"text":"…"} (repeated)
event: done data: {"model":{"provider":"…","model":"…"}} (once, then close)
event: error data: {"code":"…","message":"…"} (terminal, instead of done)
- Port change:
ChatProvidergainsStream(ctx, ChatRequest, onDelta func(string)) (ChatResponse, error). OpenAI + Anthropic adapters parse their SSE; mock streams the canned answer in
word chunks; a shared fallback helper lets any non-streaming provider emit one big delta — the
handler code stays uniform. - The server accumulates the full text and persists the assistant message after
done
(nothing partial is stored; a mid-stream failure stores nothing and emitserror). - Frontend consumes with
fetch+ReadableStream(POST body rules outEventSource), rendering
deltas progressively through the existing markdown renderer; the typing bubble becomes the
growing answer. Stop button =AbortController(aborting a non-incognito ask means the
answer is not persisted — the user question stays).
Budget guard + retention sweep
- Budget: before any model generation call (ask, summarize, classify, enrich…), if
daily_token_budget > 0and today'sai_usagetotal ≥ budget → problem429 ai.budget_exhausted("daily AI budget reached — try again tomorrow or raise it in Admin → AI").
Retrieval-only asks and embeddings (local sidecar) are free and unaffected. Check is one cheap
SUM per call; usage is recorded after each call (even failed streams record what was consumed). - Retention sweep: scheduler job
ai.chat_retention(daily +EnsureRegistered, following
the existing jobs.go pattern):DELETE FROM ai_conversations WHERE NOT saved AND updated_at < now() - (chat_retention_days || ' days')::intervalwhenchat_retention_days > 0. Logged
count. Idempotent, no boot TriggerNow (no catch-up urgency).
UI
/askgets a collapsible left rail (ChatGPT-style): New chat, Incognito toggle
(banner on thread: "Incognito — this conversation won't be saved"), conversation list (title +
relative time; active highlight; overflow menu per item: Rename (inline input), Save/
Unsave (bookmark icon shown on saved items; saved = exempt from auto-delete), Delete),
and a Clear all footer action. When retention is on, a subtle rail footnote: "Unsaved chats
auto-delete after N days."- Assistant bubble actions: Copy (raw markdown text to clipboard, with the existing
clipboard fallback pattern) and Retry (last answer only). Thread header: Download PDF +
the incognito banner. Stop replaces Send while streaming. - Opening a conversation loads and re-renders it fully (markdown, citation jump-buttons, source
cards from the stored jsonb). - Admin → AI tab (new tab in AdminPage, perm-gated
ai.manage): Status card (chat provider +
model, embed provider + model — read-only), Settings card (retention days, daily token budget,
save button), Usage card (last-30-days table: day, feature, requests, tokens; simple totals
row). en + id i18n throughout.
Error handling
- Ownership: foreign/unknown conversation id → 404
ai.conversation.not_found(no existence
disclosure). Rename/save validation → 400. Delete idempotent → 204. - Retry without a stored assistant turn behaves as a normal ask.
retry+incognito→ 400. - Stream disconnects: client marks the turn failed (error bubble + Retry); server persists nothing.
- PDF: Gotenberg failure → problem JSON; oversized transcript → 413-style validation error.
- Sweep and metering failures are logged, never fatal, never block requests.
Out of scope (documented futures)
👍/👎 answer feedback · conversation sharing/export-to-document · org-wide chat audit/eDiscovery
view · per-user budgets · streaming for the per-document /ai/ask panel (same seam, later) ·
AI-as-search-engine on /search (assistant layer over Smart search — roadmap).
Verification (repo discipline: never go test)
cd go && go build ./... && go vet ./... + cd web && npx tsc --noEmit && npx vite build; deploy
from repo root; then live e2e: 2-turn conversation persists across reload; rename + save + delete
(DB-checked); incognito leaves zero rows; retry regenerates (old answer replaced); streaming
delivers sources→deltas→done and the stored message matches the streamed text; PDF downloads and
opens; user B gets 404 on user A's conversation; budget set to a tiny value → 429 then
restored; retention set to 0/N verified against a backdated row (updated_at poked in SQL);
/me 5 modules + demo intact; clean up test artifacts.