think
16px
820px

Ask-the-Archive v2 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Turn /ask into a durable product surface — server-side conversations (rename/delete/save/incognito), streaming answers, retry, copy, styled PDF export, an Admin → AI tab (chat retention + token budget + usage), and a retention sweep — all behind the existing ai+semantic module gates and per-caller ACL retrieval.

Architecture: The ai context gains its first Postgres store (ai/adapters/pg.go) behind a Repository port on aiapp.Service; conversations/messages/settings/usage live there, owner-scoped. The retrieval pipeline in handlers_ai.go is UNTOUCHED — v2 wraps persistence + streaming + budget/metering around it. Streaming is SSE over the same route via a new ChatProvider.Stream method (real SSE for OpenAI/Anthropic, word-chunks for mock). PDF uses the existing Gotenberg HTMLToPDF. Frontend adds a conversation rail + hand-rolled SSE reader (no new npm deps).

Tech Stack: Go modular monolith (go/internal, go/cmd), Postgres via *db.DB wrapper + goose migrations, chi router with http.Flusher SSE, Gotenberg (Chromium HTML→PDF), React/Carbon SPA (web/) with openapi-fetch + a raw fetch+ReadableStream reader.

Global Constraints

  • NEVER run go test (test DSN → LIVE demo Postgres). Verify Go: cd go && go build ./... && go vet ./...; gofmt -l <touched .go> (fix any output); smart-quote scan grep -nP '[‘’“”]' <touched> must be empty (em-dashes in comments are fine; prefer the Write tool for whole new files).
  • Web: after any api/openapi.yaml change run cd web && npm run gen:api; then npx tsc --noEmit && npx vite build. i18n en+id parity is enforced by tsc — add BOTH.
  • npm install is BROKEN on this box (npm 11 / node 25 arborist crash). Do NOT add npm dependencies. The SSE reader and the existing Markdown renderer stay hand-rolled.
  • Deploy ONLY from repo ROOT: docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura web. After every deploy assert /me enabled_modules == [ai,correspondence,esign,semantic,watermarking] and the demo is intact (dev-login may race startup — retry).
  • Commit per task on main; do NOT push. Never git add go/obscura-server (tracked ELF binary that go build rebuilds). Clean up test artifacts.
  • Demo AI provider = openai_compat / gpt-4.1 (via deploy/mekari.env); streaming must work against real OpenAI SSE, and the mock must keep dev/tests working.
  • Do NOT run module-gate-swap tests (they overwrite deploy/secrets/obscura.license.json; already proven). Retrieval pipeline (CondenseQuestion/EmbedQuery/SearchDocumentChunks/SearchAdvanced hybrid + ACL subjects/bypass) is untouched.
  • Store pattern: s.db.Exec(ctx).Query/QueryRow/Exec(ctx, sql, args...); not-found → &kernel.Error{Kind: kernel.ErrNotFound, Code:..., Message:...}; there is NO kernel.ErrInternal (use kernel.ErrUnknown → 500); kernel.ErrRateLimited → HTTP 429 (budget); kernel.ErrValidation → 400.

File Structure

Phase 1 — conversations store + CRUD
- Create go/migrations/00077_ai_conversations.sql — the four tables + the seeded ai_settings row.
- Create go/internal/ai/app/store.goRepository port + the row structs (Conversation, StoredMessage, Settings, UsageRow).
- Create go/internal/ai/adapters/pg.go — Postgres Store implementing Repository.
- Modify go/internal/ai/app/service.goService gains a repo Repository; NewService(provider, repo); conversation/settings/usage passthrough methods.
- Modify go/cmd/obscura-server/wire.go — build aiadapters.NewStore(database), pass to NewService; seed ai.manage permission.
- Create go/internal/httpapi/handlers_ai_conversations.go — the CRUD handlers.
- Modify go/internal/httpapi/server.go — mount the conversation routes.
- Modify api/openapi.yaml (+ web/src/api/schema.ts via gen:api) — the conversation paths + schemas.

Phase 2 — ask persistence + budget/metering + admin AI + retention
- Modify go/internal/ai/app/ports.goChatResponse gains TokensIn/TokensOut.
- Modify go/internal/ai/adapters/{openai.go,anthropic.go,mock.go} — fill token counts (estimate fallback).
- Modify go/internal/ai/app/service.gometer() helper (budget guard + usage record) wrapping every generation call; AnswerFromSources returns ChatResponse.
- Modify go/internal/httpapi/handlers_ai.go — AskArchive gains conversation_id/incognito/retry (JSON path); persistence.
- Create go/internal/httpapi/handlers_ai_admin.go — settings GET/PUT + usage GET.
- Modify go/internal/httpapi/server.go — admin AI routes (ai.manage).
- Modify go/cmd/obscura-server/wire.go — register ai.chat_retention scheduler job.
- Modify go/cmd/obscura-server/jobs.gorunChatRetention.
- Modify api/openapi.yaml (+gen) — admin AI paths + the ask-archive request additions.

Phase 3 — streaming
- Modify go/internal/ai/app/ports.goChatProvider gains Stream(...).
- Modify go/internal/ai/adapters/{openai.go,anthropic.go,mock.go} — implement Stream; streamWholeAsDelta helper.
- Modify go/internal/ai/app/service.goAnswerFromSourcesStream(...).
- Modify go/internal/httpapi/handlers_ai.go — SSE branch in AskArchive.
- Modify web/src/api/ai.tsaskArchiveStream() fetch+ReadableStream reader.
- Modify web/src/features/ask/AskArchivePage.tsx — consume the stream + Stop.

Phase 4 — UI rail + actions + PDF + Admin tab
- Create go/internal/httpapi/handlers_ai_pdf.go + a Go HTML template — ExportChatPDF.
- Modify server.go/ai/export-pdf route; api/openapi.yaml (+gen).
- Modify web/src/api/ai.ts — conversation hooks + export.
- Modify web/src/features/ask/AskArchivePage.tsx (+ app.css) — rail, Copy/Retry, Download PDF, incognito.
- Create web/src/features/admin/AiTab.tsx; modify AdminPage.tsx, admin/i18n.ts, admin/data.ts, admin/permissionCatalog.ts.

Phase 5 — deploy + e2e + review.


Phase 1 — Conversations store + CRUD (no behavior change to ask)

Task 1.1: migration 00077_ai_conversations.sql

Files: Create go/migrations/00077_ai_conversations.sql

  • [ ] Step 1: Write the migration (goose format; mirrors 00073)
-- +goose Up
-- Server-side Ask-the-Archive: per-user conversations + their messages, plus admin AI
-- settings (retention/budget) and daily usage metering. All owner-scoped in the app.
CREATE TABLE ai_conversations (
    id         uuid PRIMARY KEY,
    user_id    uuid NOT NULL,
    title      text NOT NULL,
    saved      boolean NOT NULL DEFAULT false,
    created_at timestamptz NOT NULL DEFAULT now(),
    updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ai_conversations_user ON ai_conversations (user_id, updated_at DESC);

CREATE TABLE ai_messages (
    id              uuid PRIMARY KEY,
    conversation_id uuid NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE,
    role            text NOT NULL,
    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 '[]',
    created_at      timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ai_messages_conv ON ai_messages (conversation_id, created_at ASC);

-- Single-row admin settings (id is pinned to 1). Seeded here so GetSettings always finds it.
CREATE TABLE ai_settings (
    id                  int PRIMARY KEY CHECK (id = 1),
    chat_retention_days int NOT NULL DEFAULT 30,
    daily_token_budget  bigint NOT NULL DEFAULT 0,
    updated_at          timestamptz NOT NULL DEFAULT now()
);
INSERT INTO ai_settings (id) VALUES (1);

-- Per-day, per-feature usage metering (upserted after each generation call).
CREATE TABLE ai_usage (
    day        date NOT NULL,
    feature    text NOT NULL,
    requests   int NOT NULL DEFAULT 0,
    tokens_in  bigint NOT NULL DEFAULT 0,
    tokens_out bigint NOT NULL DEFAULT 0,
    PRIMARY KEY (day, feature)
);

-- +goose Down
DROP TABLE IF EXISTS ai_usage;
DROP TABLE IF EXISTS ai_settings;
DROP TABLE IF EXISTS ai_messages;
DROP TABLE IF EXISTS ai_conversations;
  • [ ] Step 2: Verifyls go/migrations/00077_ai_conversations.sql. (Applied on next boot; no build impact.)
  • [ ] Step 3: Commitgit add go/migrations/00077_ai_conversations.sql && git commit -m "feat(ai): migration 00077 — conversations, messages, settings, usage"

Task 1.2: Repository port + row structs

Files: Create go/internal/ai/app/store.go

Interfaces — Produces (used by every later task):

package app

import (
    "context"
    "encoding/json"
    "time"
)

// Conversation is one stored chat (owner-scoped). MessageCount is populated only by List.
type Conversation struct {
    ID           string
    UserID       string
    Title        string
    Saved        bool
    CreatedAt    time.Time
    UpdatedAt    time.Time
    MessageCount int
}

// StoredMessage is one persisted turn. Sources is the opaque citation-cards JSON
// ([{ref,doc_id,title,score,snippet}]) as stored in the jsonb column.
type StoredMessage struct {
    ID             string
    ConversationID string
    Role           Role
    Content        string
    NoResults      bool
    ModelProvider  string
    ModelName      string
    Sources        json.RawMessage
    CreatedAt      time.Time
}

// Settings are the admin-managed AI knobs (single row).
type Settings struct {
    ChatRetentionDays int
    DailyTokenBudget  int64
}

// UsageRow is one (day, feature) metering bucket.
type UsageRow struct {
    Day        string // YYYY-MM-DD
    Feature    string
    Requests   int
    TokensIn   int64
    TokensOut  int64
}

// Repository is the ai context's Postgres port: conversations, messages, settings, usage.
// Every conversation/message method is OWNER-SCOPED — a userID that does not own the row
// yields kernel.ErrNotFound (no existence disclosure).
type Repository interface {
    CreateConversation(ctx context.Context, c Conversation) error
    ListConversations(ctx context.Context, userID string, limit int) ([]Conversation, error)
    GetConversation(ctx context.Context, userID, id string) (Conversation, error)
    ConversationMessages(ctx context.Context, userID, id string) ([]StoredMessage, error)
    RenameConversation(ctx context.Context, userID, id, title string) error
    SetConversationSaved(ctx context.Context, userID, id string, saved bool) error
    DeleteConversation(ctx context.Context, userID, id string) error
    DeleteAllConversations(ctx context.Context, userID string) error
    // AppendMessages inserts msgs (owner-checked against convID) and bumps updated_at, atomically.
    AppendMessages(ctx context.Context, userID, convID string, msgs []StoredMessage) error
    // DeleteLastAssistant removes the newest assistant message of convID (owner-checked); no-op if none.
    DeleteLastAssistant(ctx context.Context, userID, convID string) error
    // LastTurns returns the last n messages of convID (owner-checked), oldest-first.
    LastTurns(ctx context.Context, userID, convID string, n int) ([]StoredMessage, error)
    // PurgeStaleConversations deletes NOT saved conversations older than retentionDays; returns count.
    PurgeStaleConversations(ctx context.Context, retentionDays int) (int, error)

    GetSettings(ctx context.Context) (Settings, error)
    PutSettings(ctx context.Context, s Settings) error
    // UsageToday returns today's total input+output tokens across all features.
    UsageToday(ctx context.Context) (int64, error)
    // RecordUsage upserts one (today, feature) bucket, adding requests=1 + the token deltas.
    RecordUsage(ctx context.Context, feature string, tokensIn, tokensOut int64) error
    UsageWindow(ctx context.Context, days int) ([]UsageRow, error)
}
  • [ ] Step 1: Write the file (exactly the block above).
  • [ ] Step 2: Verifycd go && go build ./internal/ai/... && gofmt -l internal/ai/app/store.go. (Unused interface compiles; Service wires it in 1.4.)
  • [ ] Step 3: Commitgit add go/internal/ai/app/store.go && git commit -m "feat(ai): Repository port + conversation/settings/usage row types"

Task 1.3: Postgres Store (ai/adapters/pg.go)

Files: Create go/internal/ai/adapters/pg.go
Interfaces — Consumes: app.Repository (Task 1.2). Produces: func NewStore(*db.DB) *Store, var _ app.Repository = (*Store)(nil).

  • [ ] Step 1: Write the store (owner-scoping is in the SQL WHERE user_id = $ / EXISTS checks)
// Package adapters implements the ai context's ports: the ChatProvider backends and
// (here) the Postgres Repository for conversations, messages, settings and usage.
package adapters

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "time"

    "github.com/jackc/pgx/v5"

    "github.com/Virtue-Digital-Indonesia/obscura/internal/ai/app"
    "github.com/Virtue-Digital-Indonesia/obscura/internal/kernel"
    "github.com/Virtue-Digital-Indonesia/obscura/internal/platform/db"
)

// Store implements app.Repository over Postgres (house pattern: one *db.DB, every
// statement runs through s.db.Exec(ctx) so it joins an ambient transaction when open).
type Store struct{ db *db.DB }

// NewStore constructs the ai repository.
func NewStore(d *db.DB) *Store { return &Store{db: d} }

func notFound() error {
    return &kernel.Error{Kind: kernel.ErrNotFound, Code: "ai.conversation.not_found", Message: "conversation not found"}
}

func (s *Store) CreateConversation(ctx context.Context, c app.Conversation) error {
    _, err := s.db.Exec(ctx).Exec(ctx,
        `INSERT INTO ai_conversations (id, user_id, title, saved, created_at, updated_at)
         VALUES ($1,$2,$3,$4,$5,$5)`, c.ID, c.UserID, c.Title, c.Saved, c.CreatedAt)
    if err != nil {
        return fmt.Errorf("ai create conversation: %w", err)
    }
    return nil
}

func (s *Store) ListConversations(ctx context.Context, userID string, limit int) ([]app.Conversation, error) {
    rows, err := s.db.Exec(ctx).Query(ctx,
        `SELECT c.id, c.user_id, c.title, c.saved, c.created_at, c.updated_at,
                (SELECT count(*) FROM ai_messages m WHERE m.conversation_id = c.id)
           FROM ai_conversations c WHERE c.user_id = $1 ORDER BY c.updated_at DESC LIMIT $2`, userID, limit)
    if err != nil {
        return nil, fmt.Errorf("ai list conversations: %w", err)
    }
    defer rows.Close()
    var out []app.Conversation
    for rows.Next() {
        var c app.Conversation
        if err := rows.Scan(&c.ID, &c.UserID, &c.Title, &c.Saved, &c.CreatedAt, &c.UpdatedAt, &c.MessageCount); err != nil {
            return nil, err
        }
        out = append(out, c)
    }
    return out, rows.Err()
}

func (s *Store) GetConversation(ctx context.Context, userID, id string) (app.Conversation, error) {
    var c app.Conversation
    err := s.db.Exec(ctx).QueryRow(ctx,
        `SELECT id, user_id, title, saved, created_at, updated_at FROM ai_conversations
           WHERE id = $1 AND user_id = $2`, id, userID).
        Scan(&c.ID, &c.UserID, &c.Title, &c.Saved, &c.CreatedAt, &c.UpdatedAt)
    if errors.Is(err, pgx.ErrNoRows) {
        return app.Conversation{}, notFound()
    }
    if err != nil {
        return app.Conversation{}, fmt.Errorf("ai get conversation: %w", err)
    }
    return c, nil
}

func (s *Store) ConversationMessages(ctx context.Context, userID, id string) ([]app.StoredMessage, error) {
    if _, err := s.GetConversation(ctx, userID, id); err != nil {
        return nil, err // owner check → 404 for a foreign/unknown id
    }
    rows, err := s.db.Exec(ctx).Query(ctx,
        `SELECT id, conversation_id, role, content, no_results, model_provider, model_name, sources, created_at
           FROM ai_messages WHERE conversation_id = $1 ORDER BY created_at ASC`, id)
    if err != nil {
        return nil, fmt.Errorf("ai conversation messages: %w", err)
    }
    defer rows.Close()
    return scanMessages(rows)
}

func scanMessages(rows pgx.Rows) ([]app.StoredMessage, error) {
    var out []app.StoredMessage
    for rows.Next() {
        var m app.StoredMessage
        var role string
        var src []byte
        if err := rows.Scan(&m.ID, &m.ConversationID, &role, &m.Content, &m.NoResults,
            &m.ModelProvider, &m.ModelName, &src, &m.CreatedAt); err != nil {
            return nil, err
        }
        m.Role = app.Role(role)
        m.Sources = json.RawMessage(src)
        out = append(out, m)
    }
    return out, rows.Err()
}

func (s *Store) RenameConversation(ctx context.Context, userID, id, title string) error {
    tag, err := s.db.Exec(ctx).Exec(ctx,
        `UPDATE ai_conversations SET title = $3, updated_at = now() WHERE id = $1 AND user_id = $2`, id, userID, title)
    if err != nil {
        return fmt.Errorf("ai rename conversation: %w", err)
    }
    if tag.RowsAffected() == 0 {
        return notFound()
    }
    return nil
}

func (s *Store) SetConversationSaved(ctx context.Context, userID, id string, saved bool) error {
    tag, err := s.db.Exec(ctx).Exec(ctx,
        `UPDATE ai_conversations SET saved = $3, updated_at = now() WHERE id = $1 AND user_id = $2`, id, userID, saved)
    if err != nil {
        return fmt.Errorf("ai set saved: %w", err)
    }
    if tag.RowsAffected() == 0 {
        return notFound()
    }
    return nil
}

func (s *Store) DeleteConversation(ctx context.Context, userID, id string) error {
    // Idempotent: a foreign/absent id simply affects no rows (cascade removes messages).
    if _, err := s.db.Exec(ctx).Exec(ctx,
        `DELETE FROM ai_conversations WHERE id = $1 AND user_id = $2`, id, userID); err != nil {
        return fmt.Errorf("ai delete conversation: %w", err)
    }
    return nil
}

func (s *Store) DeleteAllConversations(ctx context.Context, userID string) error {
    if _, err := s.db.Exec(ctx).Exec(ctx, `DELETE FROM ai_conversations WHERE user_id = $1`, userID); err != nil {
        return fmt.Errorf("ai delete all conversations: %w", err)
    }
    return nil
}

func (s *Store) AppendMessages(ctx context.Context, userID, convID string, msgs []app.StoredMessage) error {
    return s.db.Do(ctx, func(ctx context.Context) error {
        if _, err := s.GetConversation(ctx, userID, convID); err != nil {
            return err // owner check inside the tx
        }
        for _, m := range msgs {
            src := m.Sources
            if len(src) == 0 {
                src = json.RawMessage("[]")
            }
            if _, err := s.db.Exec(ctx).Exec(ctx,
                `INSERT INTO ai_messages (id, conversation_id, role, content, no_results, model_provider, model_name, sources, created_at)
                 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
                m.ID, convID, string(m.Role), m.Content, m.NoResults, m.ModelProvider, m.ModelName, []byte(src), m.CreatedAt); err != nil {
                return fmt.Errorf("ai append message: %w", err)
            }
        }
        if _, err := s.db.Exec(ctx).Exec(ctx, `UPDATE ai_conversations SET updated_at = now() WHERE id = $1`, convID); err != nil {
            return fmt.Errorf("ai bump conversation: %w", err)
        }
        return nil
    })
}

func (s *Store) DeleteLastAssistant(ctx context.Context, userID, convID string) error {
    if _, err := s.GetConversation(ctx, userID, convID); err != nil {
        return err
    }
    _, err := s.db.Exec(ctx).Exec(ctx,
        `DELETE FROM ai_messages WHERE id = (
            SELECT id FROM ai_messages WHERE conversation_id = $1 AND role = 'assistant'
            ORDER BY created_at DESC LIMIT 1)`, convID)
    if err != nil {
        return fmt.Errorf("ai delete last assistant: %w", err)
    }
    return nil
}

func (s *Store) LastTurns(ctx context.Context, userID, convID string, n int) ([]app.StoredMessage, error) {
    if _, err := s.GetConversation(ctx, userID, convID); err != nil {
        return nil, err
    }
    rows, err := s.db.Exec(ctx).Query(ctx,
        `SELECT id, conversation_id, role, content, no_results, model_provider, model_name, sources, created_at FROM (
            SELECT * FROM ai_messages WHERE conversation_id = $1 ORDER BY created_at DESC LIMIT $2
         ) t ORDER BY created_at ASC`, convID, n)
    if err != nil {
        return nil, fmt.Errorf("ai last turns: %w", err)
    }
    defer rows.Close()
    return scanMessages(rows)
}

func (s *Store) PurgeStaleConversations(ctx context.Context, retentionDays int) (int, error) {
    if retentionDays <= 0 {
        return 0, nil
    }
    tag, err := s.db.Exec(ctx).Exec(ctx,
        `DELETE FROM ai_conversations WHERE NOT saved AND updated_at < now() - make_interval(days => $1)`, retentionDays)
    if err != nil {
        return 0, fmt.Errorf("ai purge stale: %w", err)
    }
    return int(tag.RowsAffected()), nil
}

func (s *Store) GetSettings(ctx context.Context) (app.Settings, error) {
    var st app.Settings
    err := s.db.Exec(ctx).QueryRow(ctx,
        `SELECT chat_retention_days, daily_token_budget FROM ai_settings WHERE id = 1`).
        Scan(&st.ChatRetentionDays, &st.DailyTokenBudget)
    if err != nil {
        return app.Settings{}, fmt.Errorf("ai get settings: %w", err)
    }
    return st, nil
}

func (s *Store) PutSettings(ctx context.Context, st app.Settings) error {
    _, err := s.db.Exec(ctx).Exec(ctx,
        `UPDATE ai_settings SET chat_retention_days = $1, daily_token_budget = $2, updated_at = now() WHERE id = 1`,
        st.ChatRetentionDays, st.DailyTokenBudget)
    if err != nil {
        return fmt.Errorf("ai put settings: %w", err)
    }
    return nil
}

func (s *Store) UsageToday(ctx context.Context) (int64, error) {
    var total int64
    err := s.db.Exec(ctx).QueryRow(ctx,
        `SELECT coalesce(sum(tokens_in + tokens_out), 0) FROM ai_usage WHERE day = current_date`).Scan(&total)
    if err != nil {
        return 0, fmt.Errorf("ai usage today: %w", err)
    }
    return total, nil
}

func (s *Store) RecordUsage(ctx context.Context, feature string, tokensIn, tokensOut int64) error {
    _, err := s.db.Exec(ctx).Exec(ctx,
        `INSERT INTO ai_usage (day, feature, requests, tokens_in, tokens_out)
         VALUES (current_date, $1, 1, $2, $3)
         ON CONFLICT (day, feature) DO UPDATE
           SET requests = ai_usage.requests + 1,
               tokens_in = ai_usage.tokens_in + EXCLUDED.tokens_in,
               tokens_out = ai_usage.tokens_out + EXCLUDED.tokens_out`, feature, tokensIn, tokensOut)
    if err != nil {
        return fmt.Errorf("ai record usage: %w", err)
    }
    return nil
}

func (s *Store) UsageWindow(ctx context.Context, days int) ([]app.UsageRow, error) {
    rows, err := s.db.Exec(ctx).Query(ctx,
        `SELECT to_char(day, 'YYYY-MM-DD'), feature, requests, tokens_in, tokens_out FROM ai_usage
           WHERE day >= current_date - make_interval(days => $1) ORDER BY day DESC, feature ASC`, days)
    if err != nil {
        return nil, fmt.Errorf("ai usage window: %w", err)
    }
    defer rows.Close()
    var out []app.UsageRow
    for rows.Next() {
        var u app.UsageRow
        if err := rows.Scan(&u.Day, &u.Feature, &u.Requests, &u.TokensIn, &u.TokensOut); err != nil {
            return nil, err
        }
        out = append(out, u)
    }
    return out, rows.Err()
}

var _ app.Repository = (*Store)(nil)

VERIFY WHEN IMPLEMENTING: confirm the *db.DB transaction helper is named s.db.Do(ctx, func(ctx) error) by grepping an existing store (e.g. internal/dms/app uow usage) — if the wrapper exposes transactions differently (e.g. via a kernel.UnitOfWork passed to the store), thread a uow into NewStore and use uow.Do in AppendMessages instead. AppendMessages MUST be atomic (owner-check + inserts + bump). If *db.DB has no in-wrapper tx, add a uow kernel.UnitOfWork field (mirror esign Store's constructor) and NewStore(d, uow).

  • [ ] Step 2: Verifycd go && go build ./internal/ai/... && go vet ./internal/ai/... && gofmt -l internal/ai/adapters/pg.go.
  • [ ] Step 3: Commitgit add go/internal/ai/adapters/pg.go && git commit -m "feat(ai): Postgres Store for conversations/messages/settings/usage (owner-scoped)"

Task 1.4: wire the store into Service + wire.go + seed ai.manage

Files: Modify go/internal/ai/app/service.go (Service struct + NewService), go/cmd/obscura-server/wire.go (line ~361 + ~217).
Interfaces — Produces: func NewService(provider ChatProvider, repo Repository) *Service; passthrough methods Service.ListConversations/GetConversation/ConversationMessages/RenameConversation/SetConversationSaved/DeleteConversation/DeleteAllConversations (all (ctx, userID, …)), Service.GetSettings/PutSettings/UsageWindow.

  • [ ] Step 1: Service gains the repo — in service.go, change the struct + constructor. Current constructor is NewService(chat ChatProvider); the struct holds chat ChatProvider. Add repo Repository:
// (struct) add field:  repo Repository
func NewService(chat ChatProvider, repo Repository) *Service {
    return &Service{chat: chat, repo: repo}
}
  • [ ] Step 2: Add conversation/settings/usage passthroughs to service.go (owner-scoping lives in the store):
// --- conversations (owner-scoped; store enforces ownership → kernel.ErrNotFound) ---
func (s *Service) ListConversations(ctx context.Context, userID string, limit int) ([]Conversation, error) {
    return s.repo.ListConversations(ctx, userID, limit)
}
func (s *Service) ConversationMessages(ctx context.Context, userID, id string) (Conversation, []StoredMessage, error) {
    c, err := s.repo.GetConversation(ctx, userID, id)
    if err != nil {
        return Conversation{}, nil, err
    }
    msgs, err := s.repo.ConversationMessages(ctx, userID, id)
    return c, msgs, err
}
func (s *Service) RenameConversation(ctx context.Context, userID, id, title string) error {
    return s.repo.RenameConversation(ctx, userID, id, title)
}
func (s *Service) SetConversationSaved(ctx context.Context, userID, id string, saved bool) error {
    return s.repo.SetConversationSaved(ctx, userID, id, saved)
}
func (s *Service) DeleteConversation(ctx context.Context, userID, id string) error {
    return s.repo.DeleteConversation(ctx, userID, id)
}
func (s *Service) DeleteAllConversations(ctx context.Context, userID string) error {
    return s.repo.DeleteAllConversations(ctx, userID)
}
// --- admin settings + usage ---
func (s *Service) GetSettings(ctx context.Context) (Settings, error) { return s.repo.GetSettings(ctx) }
func (s *Service) PutSettings(ctx context.Context, st Settings) error { return s.repo.PutSettings(ctx, st) }
func (s *Service) UsageWindow(ctx context.Context, days int) ([]UsageRow, error) { return s.repo.UsageWindow(ctx, days) }
// --- retention (called by the scheduler job) ---
func (s *Service) PurgeStaleChats(ctx context.Context) (int, error) {
    st, err := s.repo.GetSettings(ctx)
    if err != nil {
        return 0, err
    }
    return s.repo.PurgeStaleConversations(ctx, st.ChatRetentionDays)
}

Confirm service.go imports context (it does). No other imports needed here.

  • [ ] Step 3: wire.go — change the ai construction (line ~361) from aiapp.NewService(aiadapters.SelectProvider(...)) to:
    aiSvc := aiapp.NewService(
        aiadapters.SelectProvider(cfg.AI.Provider, cfg.AI.BaseURL, cfg.AI.APIKey, cfg.AI.Model),
        aiadapters.NewStore(database),
    )
  • [ ] Step 4: seed ai.manage — in wire.go next to the letterhead.manage / license.admin seeds (~217-224) add:
    if err := rbacStore.CreatePermission(ctx, "ai.manage", "manage AI settings (chat retention, token budget) and view usage"); err != nil {
        logger.Warn("seed ai permission", "err", err)
    }
  • [ ] Step 5: Verifycd go && go build ./... && go vet ./... (whole module; NewService now needs 2 args — this is the only caller). gofmt -l internal/ai/app/service.go cmd/obscura-server/wire.go.
  • [ ] Step 6: Commitgit add go/internal/ai/app/service.go go/cmd/obscura-server/wire.go && git commit -m "feat(ai): wire store into Service + conversation/settings passthroughs + seed ai.manage"

Task 1.5: conversation CRUD handlers + routes + OpenAPI

Files: Create go/internal/httpapi/handlers_ai_conversations.go; modify go/internal/httpapi/server.go (near ai routes ~302-308), api/openapi.yaml.
Interfaces — Consumes: s.ai.ListConversations/ConversationMessages/RenameConversation/SetConversationSaved/DeleteConversation/DeleteAllConversations; PrincipalFrom(r.Context()), writeJSON, writeProblem, chi.URLParam.

  • [ ] Step 1: Handlers (owner id from the session principal — never a client-supplied user id):
package httpapi

import (
    "encoding/json"
    "net/http"
    "strings"

    "github.com/go-chi/chi/v5"

    aiapp "github.com/Virtue-Digital-Indonesia/obscura/internal/ai/app"
    "github.com/Virtue-Digital-Indonesia/obscura/internal/kernel"
)

type convDTO struct {
    ID           string `json:"id"`
    Title        string `json:"title"`
    Saved        bool   `json:"saved"`
    UpdatedAt    string `json:"updated_at"`
    MessageCount int    `json:"message_count"`
}

// ListConversations returns the caller's conversations, newest first.
func (s *Server) ListConversations(w http.ResponseWriter, r *http.Request) {
    p, _ := PrincipalFrom(r.Context())
    cs, err := s.ai.ListConversations(r.Context(), string(p.UserID), 100)
    if err != nil {
        writeProblem(w, err)
        return
    }
    out := make([]convDTO, 0, len(cs))
    for _, c := range cs {
        out = append(out, convDTO{ID: c.ID, Title: c.Title, Saved: c.Saved, UpdatedAt: c.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z07:00"), MessageCount: c.MessageCount})
    }
    writeJSON(w, http.StatusOK, map[string]any{"conversations": out})
}

// GetConversation returns one conversation's messages (with stored sources), owner-scoped.
func (s *Server) GetConversation(w http.ResponseWriter, r *http.Request) {
    p, _ := PrincipalFrom(r.Context())
    c, msgs, err := s.ai.ConversationMessages(r.Context(), string(p.UserID), chi.URLParam(r, "id"))
    if err != nil {
        writeProblem(w, err)
        return
    }
    type msgDTO struct {
        Role          string          `json:"role"`
        Content       string          `json:"content"`
        NoResults     bool            `json:"no_results"`
        ModelProvider string          `json:"model_provider"`
        ModelName     string          `json:"model_name"`
        Sources       json.RawMessage `json:"sources"`
    }
    out := make([]msgDTO, 0, len(msgs))
    for _, m := range msgs {
        out = append(out, msgDTO{Role: string(m.Role), Content: m.Content, NoResults: m.NoResults, ModelProvider: m.ModelProvider, ModelName: m.ModelName, Sources: m.Sources})
    }
    writeJSON(w, http.StatusOK, map[string]any{
        "id": c.ID, "title": c.Title, "saved": c.Saved, "messages": out,
    })
}

// PatchConversation renames (title) and/or toggles saved.
func (s *Server) PatchConversation(w http.ResponseWriter, r *http.Request) {
    p, _ := PrincipalFrom(r.Context())
    id := chi.URLParam(r, "id")
    var body struct {
        Title *string `json:"title"`
        Saved *bool   `json:"saved"`
    }
    if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "request.invalid_json", Message: "invalid request body"})
        return
    }
    if body.Title != nil {
        title := strings.TrimSpace(*body.Title)
        if title == "" || len([]rune(title)) > 120 {
            writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "ai.conversation.bad_title", Message: "title must be 1..120 characters"})
            return
        }
        if err := s.ai.RenameConversation(r.Context(), string(p.UserID), id, title); err != nil {
            writeProblem(w, err)
            return
        }
    }
    if body.Saved != nil {
        if err := s.ai.SetConversationSaved(r.Context(), string(p.UserID), id, *body.Saved); err != nil {
            writeProblem(w, err)
            return
        }
    }
    w.WriteHeader(http.StatusNoContent)
}

// DeleteConversation removes one conversation (idempotent → 204).
func (s *Server) DeleteConversation(w http.ResponseWriter, r *http.Request) {
    p, _ := PrincipalFrom(r.Context())
    if err := s.ai.DeleteConversation(r.Context(), string(p.UserID), chi.URLParam(r, "id")); err != nil {
        writeProblem(w, err)
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

// DeleteAllConversations clears the caller's history.
func (s *Server) DeleteAllConversations(w http.ResponseWriter, r *http.Request) {
    p, _ := PrincipalFrom(r.Context())
    if err := s.ai.DeleteAllConversations(r.Context(), string(p.UserID)); err != nil {
        writeProblem(w, err)
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

var _ = aiapp.Conversation{} // keep the aiapp import if unused after edits (remove if gofmt/vet flags)

Remove the trailing var _ = guard line if aiapp ends up referenced/unreferenced — run gofmt/vet and delete unused imports. Confirm PrincipalFrom and p.UserID field names by grepping an existing handler (used across handlers_dms.go).

  • [ ] Step 2: Routes — in server.go, right after the ask-archive route (.Post("/ai/ask-archive", s.AskArchive) at ~308), add (same requireModule("ai") + requireModule("semantic") gate):
            r.With(s.requireModule("ai"), s.requireModule("semantic")).Get("/ai/conversations", s.ListConversations)
            r.With(s.requireModule("ai"), s.requireModule("semantic")).Delete("/ai/conversations", s.DeleteAllConversations)
            r.With(s.requireModule("ai"), s.requireModule("semantic")).Get("/ai/conversations/{id}", s.GetConversation)
            r.With(s.requireModule("ai"), s.requireModule("semantic")).Patch("/ai/conversations/{id}", s.PatchConversation)
            r.With(s.requireModule("ai"), s.requireModule("semantic")).Delete("/ai/conversations/{id}", s.DeleteConversation)
  • [ ] Step 3: OpenAPI — add paths /api/v1/ai/conversations (get list, delete-all) and /api/v1/ai/conversations/{id} (get, patch, delete) under tags: [ai], mirroring the existing /api/v1/ai/summarize style. Response schemas: ConversationList (conversations: [{id,title,saved,updated_at,message_count}]), ConversationDetail (id,title,saved,messages:[{role,content,no_results,model_provider,model_name,sources}] where sources is type: array, items: {type: object}), patch body {title?: string, saved?: boolean}. Each with 401/403/404 $ref: '#/components/responses/Problem'.
  • [ ] Step 4: Regen + verifycd web && npm run gen:api && npx tsc --noEmit && npx vite build; cd ../go && go build ./... && go vet ./... && gofmt -l internal/httpapi/handlers_ai_conversations.go internal/httpapi/server.go.
  • [ ] Step 5: Commitgit add go/internal/httpapi/handlers_ai_conversations.go go/internal/httpapi/server.go api/openapi.yaml web/src/api/schema.ts && git commit -m "feat(ai): conversation CRUD endpoints (owner-scoped) + openapi"

Phase 2 — Ask persistence + budget/metering + admin AI + retention

Task 2.1: token counts + meter() (budget guard + usage)

Files: Modify go/internal/ai/app/ports.go, go/internal/ai/adapters/{openai.go,anthropic.go,mock.go}, go/internal/ai/app/service.go.
Interfaces — Produces: ChatResponse{Content string; TokensIn, TokensOut int}; func (s *Service) meter(ctx, feature string, call func() (ChatResponse, error)) (ChatResponse, error).

  • [ ] Step 1: ChatResponse gains tokens — in ports.go:
// ChatResponse is the model's reply plus best-effort token accounting (exact when the
// provider reports usage; a chars/4 estimate otherwise).
type ChatResponse struct {
    Content   string
    TokensIn  int
    TokensOut int
}
  • [ ] Step 2: OpenAI usage — in openai.go Complete, extend the decode struct + return:
    var out struct {
        Choices []struct {
            Message struct {
                Content string `json:"content"`
            } `json:"message"`
        } `json:"choices"`
        Usage struct {
            PromptTokens     int `json:"prompt_tokens"`
            CompletionTokens int `json:"completion_tokens"`
        } `json:"usage"`
    }
    // ... existing unmarshal + no-choices guard ...
    return app.ChatResponse{
        Content:   out.Choices[0].Message.Content,
        TokensIn:  out.Usage.PromptTokens,
        TokensOut: out.Usage.CompletionTokens,
    }, nil
  • [ ] Step 3: Anthropic usage — in anthropic.go Complete, extend + return:
    var out struct {
        Content []struct {
            Type string `json:"type"`
            Text string `json:"text"`
        } `json:"content"`
        Usage struct {
            InputTokens  int `json:"input_tokens"`
            OutputTokens int `json:"output_tokens"`
        } `json:"usage"`
    }
    // ... existing loop building sb ...
    return app.ChatResponse{Content: sb.String(), TokensIn: out.Usage.InputTokens, TokensOut: out.Usage.OutputTokens}, nil
  • [ ] Step 4: Mock estimate — mock leaves tokens 0; the estimate fallback lives in meter (Step 5), so all providers converge. (No change to mock.go Complete here.)
  • [ ] Step 5: meter in service.go — add the budget-guard + usage-record wrapper and a Message char estimator:
// budgetExhausted is the 429 returned when the admin daily token budget is spent.
func budgetExhausted() error {
    return &kernel.Error{Kind: kernel.ErrRateLimited, Code: "ai.budget_exhausted",
        Message: "the daily AI budget has been reached — try again tomorrow or raise it in Admin → AI"}
}

// meter guards a generation call against the daily token budget, runs it, and records
// usage (best-effort; metering never fails a successful call). estIn is a fallback input
// estimate used only when the provider reports zero tokens.
func (s *Service) meter(ctx context.Context, feature string, estIn int, call func() (ChatResponse, error)) (ChatResponse, error) {
    if st, err := s.repo.GetSettings(ctx); err == nil && st.DailyTokenBudget > 0 {
        if used, uerr := s.repo.UsageToday(ctx); uerr == nil && used >= st.DailyTokenBudget {
            return ChatResponse{}, budgetExhausted()
        }
    }
    resp, err := call()
    if err != nil {
        return resp, err
    }
    in, out := resp.TokensIn, resp.TokensOut
    if in == 0 {
        in = estIn
    }
    if out == 0 {
        out = len(resp.Content) / 4
    }
    _ = s.repo.RecordUsage(ctx, feature, int64(in), int64(out)) // best-effort
    return resp, nil
}

// estTokens is the chars/4 fallback for the prompt size.
func estTokens(system string, msgs []Message) int {
    total := len(system)
    for _, m := range msgs {
        total += len(m.Content)
    }
    return total / 4
}

service.go must import "github.com/Virtue-Digital-Indonesia/obscura/internal/kernel" — add it.

  • [ ] Step 6: Route every generation call through meter — update the Service methods that call the provider. For AnswerFromSources (currently returns (string, error)), change to return (ChatResponse, error) and wrap:
func (s *Service) AnswerFromSources(ctx context.Context, question string, history []Message, sources []Source) (ChatResponse, error) {
    // ... existing prompt build (b, msgs, System) ...
    req := ChatRequest{System: /*…the existing system string…*/, Messages: msgs, MaxTokens: 1024}
    return s.meter(ctx, "ask", estTokens(req.System, msgs), func() (ChatResponse, error) {
        return s.chat.Complete(ctx, req)
    })
}

Also wrap Summarize (feature "summarize"), ClassifyDocument ("classify"), CondenseQuestion ("condense" — but keep its "return question on any failure" contract: on a meter error, return the original question), and any enrich call in the enrich service if it goes through this Service (grep — if enrich calls aiSvc.Complete-style, meter there; otherwise leave enrich unmetered and note it). Each wrap is the same shape: resp, err := s.meter(ctx, "<feature>", estTokens(system, msgs), func(){ return s.chat.Complete(ctx, req) }) then use resp.Content.

  • [ ] Step 7: Update callershandlers_ai.go AskArchive final block currently does answer, err := s.ai.AnswerFromSources(...) then writes answer. Change to resp, err := s.ai.AnswerFromSources(...) and use resp.Content. Any other AnswerFromSources callers (grep) updated likewise.
  • [ ] Step 8: Verifycd go && go build ./... && go vet ./... && gofmt -l internal/ai/.... A 429 is now reachable (tested live in Phase 5).
  • [ ] Step 9: Commitgit add go/internal/ai/ go/internal/httpapi/handlers_ai.go && git commit -m "feat(ai): token accounting + daily-budget guard (429) + per-feature usage metering"

Task 2.2: AskArchive persistence (conversation_id / incognito / retry — JSON path)

Files: Modify go/internal/httpapi/handlers_ai.go (AskArchive).
Interfaces — Consumes: s.ai.* conversation methods; s.ai.repo is NOT exposed — add thin Service methods used here: CreateConversationForAsk, LoadHistory, AppendTurns, DropLastAssistant (below). Produces: the enriched response carrying conversation_id.

  • [ ] Step 1: Add ask-persistence Service methods to service.go (so the handler never touches the repo directly; titles + ids minted here):
// StartConversation creates a conversation titled from the first question (clipped) and
// returns its id. now is the caller's clock (kernel time).
func (s *Service) StartConversation(ctx context.Context, userID, firstQuestion string, now time.Time) (string, error) {
    title := strings.TrimSpace(firstQuestion)
    if r := []rune(title); len(r) > 60 {
        title = strings.TrimSpace(string(r[:60])) + "…"
    }
    if title == "" {
        title = "New conversation"
    }
    id := kernel.NewID()
    if err := s.repo.CreateConversation(ctx, Conversation{ID: id, UserID: userID, Title: title, CreatedAt: now}); err != nil {
        return "", err
    }
    return id, nil
}

// HistoryForAsk returns the last n turns of a conversation as chat Messages (owner-checked).
func (s *Service) HistoryForAsk(ctx context.Context, userID, convID string, n int) ([]Message, error) {
    stored, err := s.repo.LastTurns(ctx, userID, convID, n)
    if err != nil {
        return nil, err
    }
    out := make([]Message, 0, len(stored))
    for _, m := range stored {
        out = append(out, Message{Role: m.Role, Content: m.Content})
    }
    return out, nil
}

// PersistTurn appends the user question + the assistant answer (with its sources jsonb)
// to a conversation, owner-checked and atomic. sourcesJSON is the exact [{ref,…}] array.
func (s *Service) PersistTurn(ctx context.Context, userID, convID, question, answer string, noResults bool, model ModelInfo, sourcesJSON []byte, now time.Time) error {
    return s.repo.AppendMessages(ctx, userID, convID, []StoredMessage{
        {ID: kernel.NewID(), Role: RoleUser, Content: question, CreatedAt: now},
        {ID: kernel.NewID(), Role: RoleAssistant, Content: answer, NoResults: noResults,
            ModelProvider: model.Provider, ModelName: model.Model, Sources: sourcesJSON, CreatedAt: now.Add(time.Millisecond)},
    })
}

// DropLastAssistant removes a conversation's most recent assistant message (for retry).
func (s *Service) DropLastAssistant(ctx context.Context, userID, convID string) error {
    return s.repo.DeleteLastAssistant(ctx, userID, convID)
}

service.go needs time, strings, kernel imports (kernel added in 2.1). kernel.NewID() is the id minter used across the codebase (grep to confirm).

  • [ ] Step 2: AskArchive body fields — extend the request struct with ConversationID string json:"conversation_id", Incognito bool json:"incognito", Retry bool json:"retry", Stream bool json:"stream" (Stream is consumed in Phase 3; parse it now, ignore).
  • [ ] Step 3: Validate + resolve conversation — right after the question == "" guard:
    if body.Retry && body.Incognito {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "ai.retry_incognito", Message: "retry is not available in incognito"})
        return
    }
    convID := strings.TrimSpace(body.ConversationID)
    // Server-authoritative history for a stored conversation; client history only for incognito.
    if !body.Incognito && convID != "" {
        if body.Retry {
            if derr := s.ai.DropLastAssistant(r.Context(), string(p.UserID), convID); derr != nil {
                writeProblem(w, derr)
                return
            }
        }
        hist, herr := s.ai.HistoryForAsk(r.Context(), string(p.UserID), convID, askArchiveMaxTurns)
        if herr != nil {
            writeProblem(w, herr) // foreign/unknown id → 404
            return
        }
        history = hist // REPLACES the client-provided history block for stored chats
    }

Place this AFTER the existing history is built from body.History (so incognito keeps using the client history and stored chats override it). Read the current history-building block (~176-190) and insert after it.

  • [ ] Step 4: Persist on the JSON answer paths — the handler has three terminal writes: no_results, answered, (and retrieval_only, which never persists). Before the no_results writeJSON and before the answered writeJSON, when !body.Incognito, create-or-use the conversation and persist the turn, and add conversation_id to the response. Concretely, introduce a helper closure near the top of the handler:
    now := time.Now().UTC()
    // persist writes the turn (creating the conversation on first ask) and returns the id
    // to echo to the client. No-op (returns "") in incognito or retrieval-only.
    persist := func(answer string, noResults bool, sourcesJSON []byte) (string, error) {
        if body.Incognito || body.RetrievalOnly {
            return "", nil
        }
        id := convID
        if id == "" {
            nid, err := s.ai.StartConversation(r.Context(), string(p.UserID), question, now)
            if err != nil {
                return "", err
            }
            id = nid
        }
        if err := s.ai.PersistTurn(r.Context(), string(p.UserID), id, question, answer, noResults, s.ai.Info(), sourcesJSON, now); err != nil {
            return "", err
        }
        return id, nil
    }

Then at the no_results branch:

    if len(srcs) == 0 {
        logAsk("no_results")
        cid, perr := persist("", true, []byte("[]"))
        if perr != nil { writeProblem(w, perr); return }
        writeJSON(w, http.StatusOK, map[string]any{"answer": "", "sources": []sourceOut{}, "no_results": true, "conversation_id": cid})
        return
    }

At the answered branch (after resp, err := s.ai.AnswerFromSources(...) from Task 2.1 Step 7):

    logAsk("answered")
    srcJSON, _ := json.Marshal(out) // out is []sourceOut — the exact citation cards
    cid, perr := persist(resp.Content, false, srcJSON)
    if perr != nil { writeProblem(w, perr); return }
    writeJSON(w, http.StatusOK, map[string]any{"answer": resp.Content, "sources": out, "no_results": false, "model": s.ai.Info(), "conversation_id": cid})

s.ai.Info() exists (used already). json is imported. out ([]sourceOut) is the citation array — persist it verbatim so a reopened conversation re-renders identical source cards.

  • [ ] Step 5: OpenAPI — extend the ask-archive request schema with optional conversation_id, incognito, retry, stream and the response with optional conversation_id. Regen.
  • [ ] Step 6: Verifycd go && go build ./... && go vet ./...; cd web && npm run gen:api && npx tsc --noEmit && npx vite build.
  • [ ] Step 7: Commitgit add go/internal/ai/app/service.go go/internal/httpapi/handlers_ai.go api/openapi.yaml web/src/api/schema.ts && git commit -m "feat(ai): ask-archive persists conversations (create/load/append/retry/incognito)"

Task 2.3: admin AI settings + usage API + ai.manage

Files: Create go/internal/httpapi/handlers_ai_admin.go; modify server.go, api/openapi.yaml.
Interfaces — Consumes: s.ai.GetSettings/PutSettings/UsageWindow, s.cfg (provider/model), embed config. Produces: GET/PUT /api/v1/admin/ai/settings, GET /api/v1/admin/ai/usage.

  • [ ] Step 1: Handlers
package httpapi

import (
    "encoding/json"
    "net/http"
    "strconv"

    aiapp "github.com/Virtue-Digital-Indonesia/obscura/internal/ai/app"
    "github.com/Virtue-Digital-Indonesia/obscura/internal/kernel"
)

// GetAISettings returns the AI knobs plus read-only provider/model status.
func (s *Server) GetAISettings(w http.ResponseWriter, r *http.Request) {
    st, err := s.ai.GetSettings(r.Context())
    if err != nil {
        writeProblem(w, err)
        return
    }
    info := s.ai.Info()
    writeJSON(w, http.StatusOK, map[string]any{
        "chat_retention_days": st.ChatRetentionDays,
        "daily_token_budget":  st.DailyTokenBudget,
        "chat_provider":       info.Provider,
        "chat_model":          info.Model,
        "embed_provider":      s.cfg.EmbedProvider,
        "embed_model":         s.cfg.EmbedModel,
    })
}

// PutAISettings validates + saves the AI knobs.
func (s *Server) PutAISettings(w http.ResponseWriter, r *http.Request) {
    var body struct {
        ChatRetentionDays int   `json:"chat_retention_days"`
        DailyTokenBudget  int64 `json:"daily_token_budget"`
    }
    if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "request.invalid_json", Message: "invalid request body"})
        return
    }
    if body.ChatRetentionDays < 0 || body.ChatRetentionDays > 3650 || body.DailyTokenBudget < 0 {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "ai.settings.invalid", Message: "retention must be 0..3650 days and budget >= 0"})
        return
    }
    if err := s.ai.PutSettings(r.Context(), aiapp.Settings{ChatRetentionDays: body.ChatRetentionDays, DailyTokenBudget: body.DailyTokenBudget}); err != nil {
        writeProblem(w, err)
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

// GetAIUsage returns per-day/feature usage for the last ?days (default 30, max 90).
func (s *Server) GetAIUsage(w http.ResponseWriter, r *http.Request) {
    days := 30
    if q := r.URL.Query().Get("days"); q != "" {
        if n, err := strconv.Atoi(q); err == nil && n > 0 && n <= 90 {
            days = n
        }
    }
    rows, err := s.ai.UsageWindow(r.Context(), days)
    if err != nil {
        writeProblem(w, err)
        return
    }
    type usageDTO struct {
        Day       string `json:"day"`
        Feature   string `json:"feature"`
        Requests  int    `json:"requests"`
        TokensIn  int64  `json:"tokens_in"`
        TokensOut int64  `json:"tokens_out"`
    }
    out := make([]usageDTO, 0, len(rows))
    for _, u := range rows {
        out = append(out, usageDTO{Day: u.Day, Feature: u.Feature, Requests: u.Requests, TokensIn: u.TokensIn, TokensOut: u.TokensOut})
    }
    writeJSON(w, http.StatusOK, map[string]any{"usage": out})
}

Confirm s.cfg exposes the embed provider/model (grep EmbedProvider/EmbedConfig on the Server cfg/Config). If the embed config isn't on Server.cfg, thread the two strings through Deps (add EmbedProvider/EmbedModel string to Deps.Config and set them in wire.go from cfg.Embed). Adjust the two lines accordingly.

  • [ ] Step 2: Routes — in server.go admin region (~616, next to license.admin):
            r.With(s.requirePerm("ai.manage")).Get("/admin/ai/settings", s.GetAISettings)
            r.With(s.requirePerm("ai.manage")).Put("/admin/ai/settings", s.PutAISettings)
            r.With(s.requirePerm("ai.manage")).Get("/admin/ai/usage", s.GetAIUsage)
  • [ ] Step 3: OpenAPI (+gen) — the three admin AI paths under tags: [admin].
  • [ ] Step 4: Verify — go build/vet/gofmt; web gen:api/tsc/build.
  • [ ] Step 5: Commitgit add go/internal/httpapi/handlers_ai_admin.go go/internal/httpapi/server.go api/openapi.yaml web/src/api/schema.ts && git commit -m "feat(ai): admin AI settings + usage endpoints (ai.manage)"

Task 2.4: retention sweep job

Files: Modify go/cmd/obscura-server/jobs.go, go/cmd/obscura-server/wire.go.
Interfaces — Consumes: s.ai.PurgeStaleChats(ctx) (Task 1.4). Produces: func runChatRetention(ctx, ai *aiapp.Service, logger *slog.Logger) error.

  • [ ] Step 1: jobs.go — add (mirror runExtractBackfill):
// runChatRetention deletes unsaved Ask-the-Archive conversations older than the admin's
// retention window (no-op when retention is 0). Saved conversations are exempt.
func runChatRetention(ctx context.Context, ai *aiapp.Service, logger *slog.Logger) error {
    n, err := ai.PurgeStaleChats(ctx)
    if err != nil {
        return err
    }
    if n > 0 {
        logger.Info("ai.chat_retention swept", "deleted", n)
    }
    return nil
}

Ensure aiapp is imported in jobs.go (add aiapp "github.com/Virtue-Digital-Indonesia/obscura/internal/ai/app").

  • [ ] Step 2: wire.go — register next to the semantic/extract backfills (~447-456), BEFORE the shared EnsureRegistered at ~464, with NO TriggerNow:
    schedulerSvc.Register("ai.chat_retention", 24*time.Hour, func(ctx context.Context) error {
        return runChatRetention(ctx, aiSvc, logger)
    })
  • [ ] Step 3: Verifycd go && go build ./... && go vet ./... && gofmt -l cmd/obscura-server/jobs.go cmd/obscura-server/wire.go.
  • [ ] Step 4: Commitgit add go/cmd/obscura-server/jobs.go go/cmd/obscura-server/wire.go && git commit -m "feat(ai): daily ai.chat_retention sweep (unsaved chats, admin window)"

Phase 3 — Streaming

Task 3.1: ChatProvider.Stream on all three adapters

Files: Modify go/internal/ai/app/ports.go, go/internal/ai/adapters/{openai.go,anthropic.go,mock.go}.
Interfaces — Produces: ChatProvider.Stream(ctx, req ChatRequest, onDelta func(string)) (ChatResponse, error); helper streamWholeAsDelta.

  • [ ] Step 1: Port — add to the ChatProvider interface:
    // Stream runs a completion, invoking onDelta for each text chunk as it arrives, and
    // returns the full accumulated response (+ token counts when the provider reports them).
    Stream(ctx context.Context, req ChatRequest, onDelta func(string)) (ChatResponse, error)
  • [ ] Step 2: shared fallback — in anthropic.go (shared-helpers section) add:
// streamWholeAsDelta adapts a non-streaming Complete into the Stream shape by emitting the
// whole answer as one delta. Providers without native SSE (or the mock's simple path) reuse it.
func streamWholeAsDelta(ctx context.Context, complete func(context.Context, app.ChatRequest) (app.ChatResponse, error), req app.ChatRequest, onDelta func(string)) (app.ChatResponse, error) {
    resp, err := complete(ctx, req)
    if err != nil {
        return app.ChatResponse{}, err
    }
    if resp.Content != "" {
        onDelta(resp.Content)
    }
    return resp, nil
}
  • [ ] Step 3: Mock stream — word-chunks so dev/tests see streaming:
// Stream emits the mock response in word chunks (so the streaming UI is exercised in dev).
func (m *MockProvider) Stream(ctx context.Context, _ app.ChatRequest, onDelta func(string)) (app.ChatResponse, error) {
    if m.Err != nil {
        return app.ChatResponse{}, m.Err
    }
    for i, word := range strings.Fields(m.Response) {
        if ctx.Err() != nil {
            return app.ChatResponse{}, ctx.Err()
        }
        if i > 0 {
            onDelta(" ")
        }
        onDelta(word)
    }
    return app.ChatResponse{Content: m.Response, TokensOut: len(m.Response) / 4}, nil
}

Add "strings" + "context" imports to mock.go.

  • [ ] Step 4: OpenAI SSE — parse data: lines from /v1/chat/completions with "stream": true:
// Stream sends a streaming chat-completions request, emitting each content delta.
func (p *OpenAIProvider) Stream(ctx context.Context, req app.ChatRequest, onDelta func(string)) (app.ChatResponse, error) {
    msgs := make([]map[string]string, 0, len(req.Messages)+1)
    if req.System != "" {
        msgs = append(msgs, map[string]string{"role": "system", "content": req.System})
    }
    for _, m := range req.Messages {
        msgs = append(msgs, map[string]string{"role": string(m.Role), "content": m.Content})
    }
    raw, _ := json.Marshal(map[string]any{
        "model": p.model, "max_tokens": maxTokensOr(req.MaxTokens, 1024), "messages": msgs,
        "stream": true, "stream_options": map[string]any{"include_usage": true},
    })
    httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/v1/chat/completions", bytes.NewReader(raw))
    if err != nil {
        return app.ChatResponse{}, err
    }
    httpReq.Header.Set("authorization", "Bearer "+p.apiKey)
    httpReq.Header.Set("content-type", "application/json")
    resp, err := p.http.Do(httpReq)
    if err != nil {
        return app.ChatResponse{}, fmt.Errorf("openai stream: %w", err)
    }
    defer resp.Body.Close()
    if resp.StatusCode/100 != 2 {
        rb, _ := io.ReadAll(resp.Body)
        return app.ChatResponse{}, fmt.Errorf("openai %d: %s", resp.StatusCode, truncate(string(rb), 300))
    }
    var full strings.Builder
    var usageIn, usageOut int
    sc := bufio.NewScanner(resp.Body)
    sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
    for sc.Scan() {
        line := strings.TrimSpace(sc.Text())
        if !strings.HasPrefix(line, "data:") {
            continue
        }
        payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
        if payload == "[DONE]" {
            break
        }
        var ev struct {
            Choices []struct {
                Delta struct {
                    Content string `json:"content"`
                } `json:"delta"`
            } `json:"choices"`
            Usage *struct {
                PromptTokens     int `json:"prompt_tokens"`
                CompletionTokens int `json:"completion_tokens"`
            } `json:"usage"`
        }
        if err := json.Unmarshal([]byte(payload), &ev); err != nil {
            continue // skip keep-alives / partial frames
        }
        if ev.Usage != nil {
            usageIn, usageOut = ev.Usage.PromptTokens, ev.Usage.CompletionTokens
        }
        for _, c := range ev.Choices {
            if c.Delta.Content != "" {
                full.WriteString(c.Delta.Content)
                onDelta(c.Delta.Content)
            }
        }
    }
    if err := sc.Err(); err != nil {
        return app.ChatResponse{}, fmt.Errorf("openai stream read: %w", err)
    }
    return app.ChatResponse{Content: full.String(), TokensIn: usageIn, TokensOut: usageOut}, nil
}

Add "bufio" import to openai.go.

  • [ ] Step 5: Anthropic SSE — the Messages API streams content_block_delta events; simplest robust path for v1 is the fallback (correctness over token-by-token for the secondary provider):
// Stream — Anthropic's Messages SSE. v1 uses the whole-answer fallback (correct + simple);
// swap to content_block_delta parsing later if token-by-token Claude streaming is needed.
func (p *AnthropicProvider) Stream(ctx context.Context, req app.ChatRequest, onDelta func(string)) (app.ChatResponse, error) {
    return streamWholeAsDelta(ctx, p.Complete, req, onDelta)
}
  • [ ] Step 6: Verifycd go && go build ./... && go vet ./... && gofmt -l internal/ai/adapters/*.go internal/ai/app/ports.go.
  • [ ] Step 7: Commitgit add go/internal/ai/ && git commit -m "feat(ai): ChatProvider.Stream (OpenAI SSE, mock chunks, anthropic fallback)"

Task 3.2: streaming Service method + SSE handler branch

Files: Modify go/internal/ai/app/service.go, go/internal/httpapi/handlers_ai.go.
Interfaces — Consumes: s.chat.Stream, s.meter. Produces: Service.AnswerFromSourcesStream(ctx, question, history, sources, onDelta) (ChatResponse, error).

  • [ ] Step 1: Streaming Service method — mirror AnswerFromSources but stream + meter:
func (s *Service) AnswerFromSourcesStream(ctx context.Context, question string, history []Message, sources []Source, onDelta func(string)) (ChatResponse, error) {
    // ... build the SAME System + msgs as AnswerFromSources (extract the shared prompt build
    // into a private buildAnswerRequest(question, history, sources) ChatRequest to avoid drift) ...
    req := s.buildAnswerRequest(question, history, sources)
    return s.meter(ctx, "ask", estTokens(req.System, req.Messages), func() (ChatResponse, error) {
        return s.chat.Stream(ctx, req, onDelta)
    })
}

Refactor: extract buildAnswerRequest used by BOTH AnswerFromSources and AnswerFromSourcesStream (single source of truth for the prompt).

  • [ ] Step 2: SSE branch in AskArchive — after retrieval + persist closure, BEFORE the JSON answered write, branch on body.Stream (and not RetrievalOnly). Extract the response-writing into two paths. The SSE path:
    if body.Stream && !body.RetrievalOnly && len(srcs) > 0 {
        flusher, ok := w.(http.Flusher)
        if !ok {
            writeProblem(w, &kernel.Error{Kind: kernel.ErrUnknown, Code: "ai.stream_unsupported", Message: "streaming not supported"})
            return
        }
        w.Header().Set("Content-Type", "text/event-stream")
        w.Header().Set("Cache-Control", "no-cache")
        w.Header().Set("X-Accel-Buffering", "no") // ask nginx not to buffer the SSE
        w.WriteHeader(http.StatusOK)
        sendEvent := func(event string, v any) {
            b, _ := json.Marshal(v)
            fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b)
            flusher.Flush()
        }
        // We don't yet have the conversation id (creation happens at persist). Create it now
        // for a stored chat so the client can attach the id before deltas flow.
        cid := convID
        if !body.Incognito && cid == "" {
            nid, cerr := s.ai.StartConversation(r.Context(), string(p.UserID), question, now)
            if cerr == nil {
                cid = nid
            }
        }
        sendEvent("sources", map[string]any{"conversation_id": cid, "no_results": false, "sources": out})
        resp, aerr := s.ai.AnswerFromSourcesStream(r.Context(), question, history, srcs, func(delta string) {
            sendEvent("delta", map[string]any{"text": delta})
        })
        if aerr != nil {
            sendEvent("error", map[string]any{"code": "ai.answer_failed", "message": aerr.Error()})
            return // nothing persisted on a failed/aborted stream
        }
        if !body.Incognito && cid != "" {
            srcJSON, _ := json.Marshal(out)
            // Persist against the already-created conversation; PersistTurn appends both turns.
            _ = s.ai.PersistTurn(r.Context(), string(p.UserID), cid, question, resp.Content, false, s.ai.Info(), srcJSON, now)
        }
        logAsk("answered_stream")
        sendEvent("done", map[string]any{"model": s.ai.Info()})
        return
    }

Because a streamed stored chat pre-creates the conversation, the persist closure's create-if-empty must be skipped here — this branch handles its own persistence. Keep the JSON answered path (Task 2.2) for stream=false. When the client aborts (context canceled), Stream returns a ctx error → error event, nothing persisted, but the empty conversation created above remains with only… nothing (no turns) — acceptable (a New-chat shell); OR delete it on abort: if aerr != nil && cid != "" && cid != convID { _ = s.ai.DeleteConversation(...) }. Include that cleanup.

  • [ ] Step 3: Verifycd go && go build ./... && go vet ./... && gofmt -l internal/ai/app/service.go internal/httpapi/handlers_ai.go.
  • [ ] Step 4: Commitgit add go/internal/ai/app/service.go go/internal/httpapi/handlers_ai.go && git commit -m "feat(ai): SSE streaming path for ask-archive (sources→delta→done)"

Task 3.3: frontend stream consumption + Stop

Files: Modify web/src/api/ai.ts, web/src/features/ask/AskArchivePage.tsx.
Interfaces — Produces: askArchiveStream(input, handlers, signal) in ai.ts.

  • [ ] Step 1: SSE reader in ai.ts (raw fetch + ReadableStream; bearer from getToken()):
import { getToken } from './session'

export interface StreamHandlers {
  onSources: (p: { conversationId: string; sources: ArchiveSource[] }) => void
  onDelta: (text: string) => void
  onDone: (model?: AiModel) => void
  onError: (message: string, code?: string) => void
}

// askArchiveStream POSTs an ask with stream:true and dispatches SSE events. Returns when the
// stream ends (done/error) or the signal aborts. No new deps — hand-rolled SSE frame parser.
export async function askArchiveStream(
  body: { question: string; conversation_id?: string; incognito?: boolean; retry?: boolean },
  h: StreamHandlers,
  signal: AbortSignal,
): Promise<void> {
  const token = getToken()
  const res = await fetch('/api/v1/ai/ask-archive', {
    method: 'POST',
    headers: { 'content-type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
    body: JSON.stringify({ ...body, stream: true }),
    signal,
  })
  if (!res.ok || !res.body) {
    let msg = 'AI request failed'
    let code: string | undefined
    try { const p = await res.json(); msg = p.detail || p.title || msg; code = p.code } catch { /* non-json */ }
    h.onError(msg, code)
    return
  }
  const reader = res.body.getReader()
  const dec = new TextDecoder()
  let buf = ''
  for (;;) {
    const { value, done } = await reader.read()
    if (done) break
    buf += dec.decode(value, { stream: true })
    let idx: number
    while ((idx = buf.indexOf('\n\n')) !== -1) {
      const frame = buf.slice(0, idx)
      buf = buf.slice(idx + 2)
      let event = 'message'
      let data = ''
      for (const line of frame.split('\n')) {
        if (line.startsWith('event:')) event = line.slice(6).trim()
        else if (line.startsWith('data:')) data += line.slice(5).trim()
      }
      if (!data) continue
      const payload = JSON.parse(data)
      if (event === 'sources') h.onSources({ conversationId: payload.conversation_id ?? '', sources: (payload.sources ?? []).map(mapSource) })
      else if (event === 'delta') h.onDelta(payload.text ?? '')
      else if (event === 'done') { h.onDone(payload.model); return }
      else if (event === 'error') { h.onError(payload.message ?? 'AI error', payload.code); return }
    }
  }
}

// mapSource: wire → ArchiveSource (shared with useAskArchive's mapping).
function mapSource(s: { ref: number; doc_id: string; title: string; score: number; snippet: string }): ArchiveSource {
  return { ref: s.ref, docId: s.doc_id, title: s.title, score: s.score, snippet: s.snippet }
}

Refactor useAskArchive's inline source mapping to reuse mapSource. getToken() is a sync export from session.ts.

  • [ ] Step 2: AskArchivePage consumes the stream — replace the ask.mutate(...) send path with a streaming send: on submit, push the user turn + an empty assistant turn, create an AbortController (stored in a ref), call askArchiveStream, and: onSources → set that turn's sources + attach conversationId (also select it in the rail once Phase 4 lands); onDelta → append text to the streaming turn's content (state update); onError → mark the turn errored; onDone → finalize (set model, clear the pending controller). The Stop button (replaces Send while pending) calls controller.abort(). Keep the non-stream useAskArchive for retrieval_only and as a fallback. Preserve the existing markdown rendering (deltas accumulate into content, re-rendered each tick).

Progressive-render note: the existing AnswerText re-parses the whole content each render — fine at chat length. Ensure the thread auto-scroll effect still fires on content growth (it keys on turns; make the streaming update produce a new turns array so the effect runs).

  • [ ] Step 3: Verifycd web && npx tsc --noEmit && npx vite build.
  • [ ] Step 4: Commitgit add web/src/api/ai.ts web/src/features/ask/AskArchivePage.tsx && git commit -m "feat(web): stream ask-archive answers (SSE reader) + Stop"

Phase 4 — UI rail + actions + PDF + Admin tab

Task 4.1: PDF export (backend template + Gotenberg + route)

Files: Create go/internal/httpapi/handlers_ai_pdf.go; modify server.go, api/openapi.yaml.
Interfaces — Consumes: s.office.HTMLToPDF(ctx, []byte) ([]byte, error) (render.OfficeConverter, wired as Deps.Office), PrincipalFrom, s.directory/me display name (grep how a handler gets the caller's display name; else use the email/user id). Produces: POST /api/v1/ai/export-pdf.

  • [ ] Step 1: Handler + template (Go html/template → Gotenberg Chromium):
package httpapi

import (
    "bytes"
    "encoding/json"
    "html/template"
    "net/http"
    "time"

    "github.com/Virtue-Digital-Indonesia/obscura/internal/kernel"
)

type pdfTurn struct {
    Role    string `json:"role"`
    Content string `json:"content"`
    Sources []struct {
        Ref   int     `json:"ref"`
        Title string  `json:"title"`
        Score float64 `json:"score"`
    } `json:"sources"`
}

var chatPDFTmpl = template.Must(template.New("chat").Parse(`<!doctype html><html><head><meta charset="utf-8">
<style>
 @page { margin: 22mm 18mm; }
 body { font: 12px/1.6 -apple-system, 'Segoe UI', Roboto, sans-serif; color: #161616; }
 h1 { font-size: 20px; margin: 0 0 2px; }
 .meta { color: #6f6f6f; font-size: 11px; margin-bottom: 18px; }
 .turn { margin: 0 0 14px; page-break-inside: avoid; }
 .role { font-weight: 700; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: #4589ff; margin-bottom: 3px; }
 .role.user { color: #6f6f6f; }
 .content { white-space: pre-wrap; }
 .sources { margin-top: 6px; border-top: 1px solid #e0e0e0; padding-top: 6px; font-size: 11px; color: #525252; }
 .sources b { color: #161616; }
</style></head><body>
 <h1>{{.Title}}</h1>
 <div class="meta">{{.Asker}} · {{.Date}}</div>
 {{range .Turns}}
  <div class="turn"><div class="role {{.Role}}">{{if eq .Role "user"}}Question{{else}}Answer{{end}}</div>
   <div class="content">{{.Content}}</div>
   {{if .Sources}}<div class="sources"><b>Sources:</b> {{range .Sources}}[{{.Ref}}] {{.Title}} · {{end}}</div>{{end}}
  </div>
 {{end}}
</body></html>`))

// ExportChatPDF renders a transcript payload to a styled PDF via Gotenberg. Works for both
// stored and incognito chats (the client sends the transcript). Bounded ≤60 turns / ≤512 KiB.
func (s *Server) ExportChatPDF(w http.ResponseWriter, r *http.Request) {
    p, _ := PrincipalFrom(r.Context())
    var body struct {
        Title string    `json:"title"`
        Turns []pdfTurn `json:"turns"`
    }
    dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 512<<10))
    if err := dec.Decode(&body); err != nil {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "ai.pdf.invalid", Message: "invalid or oversized transcript"})
        return
    }
    if len(body.Turns) == 0 || len(body.Turns) > 60 {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "ai.pdf.bad_turns", Message: "transcript must have 1..60 turns"})
        return
    }
    title := body.Title
    if title == "" {
        title = "Ask the Archive"
    }
    var buf bytes.Buffer
    _ = chatPDFTmpl.Execute(&buf, map[string]any{
        "Title": title, "Asker": string(p.UserID), "Date": time.Now().UTC().Format("2 Jan 2006 15:04 UTC"), "Turns": body.Turns,
    })
    pdf, err := s.office.HTMLToPDF(r.Context(), buf.Bytes())
    if err != nil {
        writeProblem(w, err)
        return
    }
    w.Header().Set("Content-Type", "application/pdf")
    w.Header().Set("Content-Disposition", `attachment; filename="ask-the-archive.pdf"`)
    w.WriteHeader(http.StatusOK)
    _, _ = w.Write(pdf)
}

html/template auto-escapes .Content/.Title (the transcript is model+user text) — the citations [n] render as plain text, which is fine for the PDF (no clickable jumps needed). Confirm s.office is the render.OfficeConverter field name on Server (grep; letterhead Finalize uses it). Replace string(p.UserID) for Asker with the caller's display name if a helper exists (grep displayName); otherwise the user id is acceptable for v1.

  • [ ] Step 2: Router.With(s.requireModule("ai"), s.requireModule("semantic")).Post("/ai/export-pdf", s.ExportChatPDF) next to the conversation routes.
  • [ ] Step 3: OpenAPI — the path with request body {title, turns:[{role,content,sources?}]} and a 200 application/pdf (type: string, format: binary). (This one the SPA calls via raw fetch for the blob, so the typed client isn't required — but spec it for consistency.)
  • [ ] Step 4: Verify — go build/vet/gofmt; web gen:api/tsc/build.
  • [ ] Step 5: Commitgit add go/internal/httpapi/handlers_ai_pdf.go go/internal/httpapi/server.go api/openapi.yaml web/src/api/schema.ts && git commit -m "feat(ai): POST /ai/export-pdf — styled chat PDF via Gotenberg"

Task 4.2: conversation hooks + PDF download in ai.ts

Files: Modify web/src/api/ai.ts.
Interfaces — Produces: useConversations, useConversation, useRenameConversation, useSaveConversation, useDeleteConversation, useClearConversations, downloadChatPDF(title, turns).

  • [ ] Step 1: Add the hooks (openapi-fetch api.GET/PATCH/DELETE for the typed JSON endpoints; a raw fetch for the PDF blob):
export interface ConversationSummary { id: string; title: string; saved: boolean; updatedAt: string; messageCount: number }

export function useConversations() {
  return useQuery<ConversationSummary[]>({
    queryKey: ['ai-conversations'],
    queryFn: async () => {
      const res = await api.GET('/api/v1/ai/conversations')
      return (ok(res).conversations ?? []).map((c) => ({ id: c.id, title: c.title, saved: c.saved, updatedAt: c.updated_at, messageCount: c.message_count }))
    },
  })
}
// useConversation(id): GET messages → the same ChatTurn shape AskArchivePage uses (map sources).
// useRenameConversation / useSaveConversation: PATCH {title} / {saved} → invalidate ['ai-conversations'].
// useDeleteConversation(id) / useClearConversations: DELETE → invalidate.
// downloadChatPDF: POST /api/v1/ai/export-pdf (raw fetch + getToken), res.blob(), trigger a download link.

Write each fully following the neighboring hook patterns in this file (useQuery/useMutation + useQueryClient().invalidateQueries). For downloadChatPDF:

export async function downloadChatPDF(title: string, turns: { role: string; content: string; sources?: unknown[] }[]): Promise<void> {
  const token = getToken()
  const res = await fetch('/api/v1/ai/export-pdf', {
    method: 'POST',
    headers: { 'content-type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
    body: JSON.stringify({ title, turns }),
  })
  if (!res.ok) throw new Error('PDF export failed')
  const blob = await res.blob()
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = 'ask-the-archive.pdf'
  a.click()
  URL.revokeObjectURL(url)
}
  • [ ] Step 2: Verifycd web && npx tsc --noEmit.
  • [ ] Step 3: Commitgit add web/src/api/ai.ts && git commit -m "feat(web): conversation hooks + PDF download client"

Task 4.3: AskArchivePage rail + bubble actions

Files: Modify web/src/features/ask/AskArchivePage.tsx, web/src/styles/app.css, web/src/i18n/locales/{en,id}.ts.

  • [ ] Step 1: Rail + state — add a collapsible left rail: New chat button, an Incognito toggle (Carbon Toggle; when on, a banner renders on the thread and no conversation_id is sent), the conversation list from useConversations (title + relative time; active highlight; per-item Carbon OverflowMenu with Rename [inline TextInput swap], Save/Unsave [bookmark], Delete), and a Clear all footer + a retention footnote (static copy). Selecting a conversation loads it via useConversation(id) and rebuilds turns (map stored messages → ChatTurn, including sources). Track activeConversationId state; a fresh answer's onSources.conversationId sets it + invalidates ['ai-conversations'].
  • [ ] Step 2: Bubble actions — in AssistantTurn, add a small action row under the answer: Copy (writes turn.content raw markdown to clipboard — reuse the navigator.clipboard + execCommand fallback pattern from DocumentDetailView) and Retry (only on the last assistant turn — re-sends the previous user question with retry:true + the active conversation_id). Thread header: Download PDF (calls downloadChatPDF(title, turns.map(...))).
  • [ ] Step 3: CSS — add .ask__rail, .ask__rail-item, .ask__rail-item--active, .ask__incognito-banner, .ask__bubble-actions etc. under the existing Ask block in app.css; the page becomes a two-column grid inside the existing .page.ask flex (rail fixed width, thread flexes). Keep the pinned-composer behavior (the thread column keeps the flex/scroll model).
  • [ ] Step 4: i18n — add askArchive.* keys (newChat, incognito, incognitoBanner, rename, save, unsave, delete, clearAll, retentionNote, copy, copied, retry, downloadPdf, conversations, emptyHistory) to BOTH en and id.
  • [ ] Step 5: Verifycd web && npx tsc --noEmit && npx vite build.
  • [ ] Step 6: Commitgit add web/src/features/ask/AskArchivePage.tsx web/src/styles/app.css web/src/i18n/locales/en.ts web/src/i18n/locales/id.ts && git commit -m "feat(web): /ask conversation rail + Copy/Retry/Download-PDF + incognito"

Task 4.4: Admin → AI tab

Files: Create web/src/features/admin/AiTab.tsx; modify web/src/features/admin/AdminPage.tsx, admin/i18n.ts, admin/data.ts, admin/permissionCatalog.ts.

  • [ ] Step 1: data.ts hooksuseAiSettings (GET /admin/ai/settings), useSaveAiSettings (PUT), useAiUsage(days) (GET /admin/ai/usage), following the file's existing query/mutation patterns.
  • [ ] Step 2: AiTab.tsx — three cards (reuse LicensingTab card styling): Status (chat provider+model, embed provider+model — read-only), Settings (NumberInput retention days + NumberInput daily token budget + Save button; success/error inline notification), Usage (Carbon Table of the last-30-days rows: day, feature, requests, tokens in/out; a totals row).
  • [ ] Step 3: AdminPage wiring — add <Tab>{t('admin.tabs.ai')}</Tab> to the TabList and a matching <TabPanel><AiTab/></TabPanel> (keep list/panel order aligned). Gate on me.isAdmin (the tab list already renders for admins; the API is ai.manage-gated).
  • [ ] Step 4: permissionCatalog.ts — add { key: 'ai.manage', label: 'Manage AI', desc: 'Configure AI chat retention and token budget, and view usage.' } to a suitable group (e.g. an "AI" group or the admin/platform group).
  • [ ] Step 5: i18nadmin.tabs.ai + admin.ai.* keys (title/lead, status labels, retentionDays, tokenBudget, save, saved, usage table headers, totals) in en + id.
  • [ ] Step 6: Verifycd web && npx tsc --noEmit && npx vite build.
  • [ ] Step 7: Commitgit add web/src/features/admin/ && git commit -m "feat(web): Admin → AI tab (status, settings, usage)"

Phase 5 — Deploy + e2e + review

Task 5.1: deploy + boot assertions

  • [ ] Deploy: docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura web.
  • [ ] Boot log: migration 00077 applied (version: 77); ai provider openai_compat gpt-4.1; ai.chat_retention registered; no panic.
  • [ ] /me enabled_modules == [ai,correspondence,esign,semantic,watermarking]; web 200.

Task 5.2: conversations + persistence e2e (dev-login token; API base :38080)

  • [ ] Ask (no id, not incognito) → response carries conversation_id; GET /ai/conversations lists it; GET /ai/conversations/{id} returns the 2 messages with sources.
  • [ ] Second ask with the conversation_id → server-loaded history (send NO client history; confirm a follow-up "what about the second one?" resolves) → 3rd/4th messages appended; updated_at bumps.
  • [ ] retry:true → last assistant message replaced (message count stays; content changes).
  • [ ] PATCH {title} renames; PATCH {saved:true} sets saved; DELETE /{id} → 204 and gone (DB-checked); DELETE /conversations clears all.
  • [ ] incognito:true ask → answer returns, conversation_id empty, and SELECT count(*) on ai_conversations for that user is unchanged (zero new rows).
  • [ ] Owner isolation: user B GET /ai/conversations/{A's id}404; PATCH/DELETE → 404. (Register a second user via /auth/register.)

Task 5.3: streaming + budget + retention

  • [ ] Stream ask via curl -N (Accept SSE) → observe event: sources then multiple event: delta then event: done; the stored assistant message content equals the concatenated deltas.
  • [ ] Set daily_token_budget to a tiny value via PUT /admin/ai/settings, exhaust it with one ask, next ask → 429 ai.budget_exhausted; restore budget=0; GET /admin/ai/usage shows rows with token counts.
  • [ ] Retention: PUT {chat_retention_days:1}; backdate an unsaved conversation (UPDATE ai_conversations SET updated_at = now() - interval '2 days'); trigger the sweep (schedulerSvc — poke scheduled_tasks.next_run_at or restart) → unsaved gone, a saved=true backdated one survives; restore retention=30.

Task 5.4: PDF + admin UI + cleanup

  • [ ] POST /ai/export-pdf with a 2-turn transcript → application/pdf, opens, shows title/date/Q-A/sources.
  • [ ] Admin AI tab loads (settings + usage render); non-admin has no ai.manage/admin/ai/settings 403.
  • [ ] Delete all test conversations + any test users' chats; re-assert 5 modules + demo intact.

Task 5.5: final review

  • [ ] Dispatch a whole-feature adversarial reviewer over the range: focus owner-scoping (no cross-user read/write/delete; server never trusts a client user id), incognito leaves zero rows, budget guard actually blocks (429) and meters, streaming persists only complete answers (abort/error → nothing stored; no orphan empty conversations), SSE isn't buffered by middleware/nginx, PDF template escapes user/model text (no injection), retention never touches saved chats, and no regression to the untouched retrieval pipeline or the 5-module gate.
  • [ ] Address blockers/highs; re-verify build/vet/tsc + redeploy; report commit list + e2e results. Do NOT push unless asked.

Self-Review notes (author)

  • Spec coverage: conversations model+CRUD → 1.1–1.5; ask persistence/retry/incognito → 2.2; token metering+budget → 2.1; admin perm+settings+usage → 2.3 + 4.4; retention sweep → 2.4; streaming → 3.1–3.3; PDF → 4.1–4.2; UI rail/actions → 4.3. All spec sections mapped.
  • Type consistency: Repository methods (1.2) match the Store impl (1.3) and the Service passthroughs (1.4/2.2). ChatResponse{Content,TokensIn,TokensOut} (2.1) is returned by Complete/Stream (2.1/3.1) and consumed by meter/handlers. SSE event names (sources/delta/done/error) match between the Go handler (3.2) and the TS reader (3.3). conversation_id/incognito/retry/stream request fields are parsed in 2.2 and sent in 3.3.
  • Known verify-when-implementing flags (called out inline, not silent): the *db.DB transaction API for AppendMessages (1.3) — use the codebase's actual tx helper (s.db.Do vs a threaded uow); the embed provider/model source on Server.cfg (2.3); s.office field name + a display-name helper (4.1); kernel.NewID() (2.2). Each says exactly what to grep.
  • No-go-test adaptation: every task's "verify" is go build/go vet/gofmt (compile-time contract) or a curl/DB check deferred to Phase 5 — no _test.go requiring go test is introduced.