think
16px
820px

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)

  1. 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 is 404.
  • Token counts: providers that report usage (OpenAI/Anthropic responses) fill exact numbers —
    extend ChatResponse with TokensIn/TokensOut; providers that don't report fall back to a
    len(chars)/4 estimate. 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, bumping updated_at.
  • absent + not incognito → server creates the conversation (auto-title) and returns
    conversation_id in 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 (requires conversation_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 — the ai_usage rows 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: trueContent-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: ChatProvider gains Stream(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 emits error).
  • Frontend consumes with fetch + ReadableStream (POST body rules out EventSource), 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 > 0 and today's ai_usage total ≥ budget → problem 429 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')::interval when chat_retention_days > 0. Logged
    count. Idempotent, no boot TriggerNow (no catch-up urgency).

UI

  • /ask gets 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.