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 scangrep -nP '[‘’“”]' <touched>must be empty (em-dashes—in comments are fine; prefer the Write tool for whole new files). - Web: after any
api/openapi.yamlchange runcd web && npm run gen:api; thennpx 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. Nevergit add go/obscura-server(tracked ELF binary thatgo buildrebuilds). Clean up test artifacts. - Demo AI provider =
openai_compat/gpt-4.1(viadeploy/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 NOkernel.ErrInternal(usekernel.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.go — Repository 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.go — Service 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.go — ChatResponse 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.go — meter() 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.go — runChatRetention.
- Modify api/openapi.yaml (+gen) — admin AI paths + the ask-archive request additions.
Phase 3 — streaming
- Modify go/internal/ai/app/ports.go — ChatProvider gains Stream(...).
- Modify go/internal/ai/adapters/{openai.go,anthropic.go,mock.go} — implement Stream; streamWholeAsDelta helper.
- Modify go/internal/ai/app/service.go — AnswerFromSourcesStream(...).
- Modify go/internal/httpapi/handlers_ai.go — SSE branch in AskArchive.
- Modify web/src/api/ai.ts — askArchiveStream() 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: Verify —
ls go/migrations/00077_ai_conversations.sql. (Applied on next boot; no build impact.) - [ ] Step 3: Commit —
git 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: Verify —
cd go && go build ./internal/ai/... && gofmt -l internal/ai/app/store.go. (Unused interface compiles;Servicewires it in 1.4.) - [ ] Step 3: Commit —
git 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.DBtransaction helper is nameds.db.Do(ctx, func(ctx) error)by grepping an existing store (e.g.internal/dms/appuow usage) — if the wrapper exposes transactions differently (e.g. via akernel.UnitOfWorkpassed to the store), thread auowintoNewStoreand useuow.DoinAppendMessagesinstead.AppendMessagesMUST be atomic (owner-check + inserts + bump). If*db.DBhas no in-wrapper tx, add auow kernel.UnitOfWorkfield (mirroresignStore's constructor) andNewStore(d, uow).
- [ ] Step 2: Verify —
cd go && go build ./internal/ai/... && go vet ./internal/ai/... && gofmt -l internal/ai/adapters/pg.go. - [ ] Step 3: Commit —
git 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 isNewService(chat ChatProvider); the struct holdschat ChatProvider. Addrepo 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.goimportscontext(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 theletterhead.manage/license.adminseeds (~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: Verify —
cd 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: Commit —
git 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 ifaiappends up referenced/unreferenced — run gofmt/vet and delete unused imports. ConfirmPrincipalFromandp.UserIDfield 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 (samerequireModule("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) undertags: [ai], mirroring the existing/api/v1/ai/summarizestyle. 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}]wheresourcesistype: array, items: {type: object}), patch body{title?: string, saved?: boolean}. Each with401/403/404$ref: '#/components/responses/Problem'. - [ ] Step 4: Regen + verify —
cd 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: Commit —
git 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.goComplete, 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.goComplete, 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 tomock.goComplete here.) - [ ] Step 5:
meterin service.go — add the budget-guard + usage-record wrapper and aMessagechar 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.gomust 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. ForAnswerFromSources(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 callers —
handlers_ai.goAskArchive final block currently doesanswer, err := s.ai.AnswerFromSources(...)then writesanswer. Change toresp, err := s.ai.AnswerFromSources(...)and useresp.Content. Any otherAnswerFromSourcescallers (grep) updated likewise. - [ ] Step 8: Verify —
cd go && go build ./... && go vet ./... && gofmt -l internal/ai/.... A429is now reachable (tested live in Phase 5). - [ ] Step 9: Commit —
git 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.goneedstime,strings,kernelimports (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
historyis built frombody.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, (andretrieval_only, which never persists). Before theno_resultswriteJSONand before theansweredwriteJSON, when!body.Incognito, create-or-use the conversation and persist the turn, and addconversation_idto 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).jsonis 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, streamand the response with optionalconversation_id. Regen. - [ ] Step 6: Verify —
cd go && go build ./... && go vet ./...;cd web && npm run gen:api && npx tsc --noEmit && npx vite build. - [ ] Step 7: Commit —
git 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.cfgexposes the embed provider/model (grepEmbedProvider/EmbedConfigon the Servercfg/Config). If the embed config isn't onServer.cfg, thread the two strings throughDeps(addEmbedProvider/EmbedModel stringtoDeps.Configand set them in wire.go fromcfg.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: Commit —
git 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
aiappis imported in jobs.go (addaiapp "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
EnsureRegisteredat ~464, with NOTriggerNow:
schedulerSvc.Register("ai.chat_retention", 24*time.Hour, func(ctx context.Context) error {
return runChatRetention(ctx, aiSvc, logger)
})
- [ ] Step 3: Verify —
cd go && go build ./... && go vet ./... && gofmt -l cmd/obscura-server/jobs.go cmd/obscura-server/wire.go. - [ ] Step 4: Commit —
git 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
ChatProviderinterface:
// 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/completionswith"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_deltaevents; 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: Verify —
cd go && go build ./... && go vet ./... && gofmt -l internal/ai/adapters/*.go internal/ai/app/ports.go. - [ ] Step 7: Commit —
git 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
AnswerFromSourcesbut 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 +
persistclosure, BEFORE the JSONansweredwrite, branch onbody.Stream(and notRetrievalOnly). 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
persistclosure's create-if-empty must be skipped here — this branch handles its own persistence. Keep the JSONansweredpath (Task 2.2) forstream=false. When the client aborts (context canceled),Streamreturns a ctx error →errorevent, 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: Verify —
cd go && go build ./... && go vet ./... && gofmt -l internal/ai/app/service.go internal/httpapi/handlers_ai.go. - [ ] Step 4: Commit —
git 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 reusemapSource.getToken()is a sync export fromsession.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 anAbortController(stored in a ref), callaskArchiveStream, and:onSources→ set that turn's sources + attachconversationId(also select it in the rail once Phase 4 lands);onDelta→ append text to the streaming turn'scontent(state update);onError→ mark the turn errored;onDone→ finalize (set model, clear the pending controller). The Stop button (replaces Send whilepending) callscontroller.abort(). Keep the non-streamuseAskArchiveforretrieval_onlyand as a fallback. Preserve the existing markdown rendering (deltas accumulate intocontent, re-rendered each tick).
Progressive-render note: the existing
AnswerTextre-parses the wholecontenteach render — fine at chat length. Ensure the thread auto-scroll effect still fires on content growth (it keys onturns; make the streaming update produce a newturnsarray so the effect runs).
- [ ] Step 3: Verify —
cd web && npx tsc --noEmit && npx vite build. - [ ] Step 4: Commit —
git 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/templateauto-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). Confirms.officeis therender.OfficeConverterfield name onServer(grep; letterhead Finalize uses it). Replacestring(p.UserID)forAskerwith the caller's display name if a helper exists (grepdisplayName); otherwise the user id is acceptable for v1.
- [ ] Step 2: Route —
r.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 a200application/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: Commit —
git 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/DELETEfor the typed JSON endpoints; a rawfetchfor 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: Verify —
cd web && npx tsc --noEmit. - [ ] Step 3: Commit —
git 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 noconversation_idis sent), the conversation list fromuseConversations(title + relative time; active highlight; per-item CarbonOverflowMenuwith Rename [inlineTextInputswap], Save/Unsave [bookmark], Delete), and a Clear all footer + a retention footnote (static copy). Selecting a conversation loads it viauseConversation(id)and rebuildsturns(map stored messages →ChatTurn, includingsources). TrackactiveConversationIdstate; a fresh answer'sonSources.conversationIdsets it + invalidates['ai-conversations']. - [ ] Step 2: Bubble actions — in
AssistantTurn, add a small action row under the answer: Copy (writesturn.contentraw markdown to clipboard — reuse thenavigator.clipboard+execCommandfallback pattern fromDocumentDetailView) and Retry (only on the last assistant turn — re-sends the previous user question withretry:true+ the activeconversation_id). Thread header: Download PDF (callsdownloadChatPDF(title, turns.map(...))). - [ ] Step 3: CSS — add
.ask__rail,.ask__rail-item,.ask__rail-item--active,.ask__incognito-banner,.ask__bubble-actionsetc. under the existing Ask block inapp.css; the page becomes a two-column grid inside the existing.page.askflex (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 BOTHenandid. - [ ] Step 5: Verify —
cd web && npx tsc --noEmit && npx vite build. - [ ] Step 6: Commit —
git 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 hooks —
useAiSettings(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 (
NumberInputretention days +NumberInputdaily token budget + Save button; success/error inline notification), Usage (CarbonTableof 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 onme.isAdmin(the tab list already renders for admins; the API isai.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: i18n —
admin.tabs.ai+admin.ai.*keys (title/lead, status labels, retentionDays, tokenBudget, save, saved, usage table headers, totals) in en + id. - [ ] Step 6: Verify —
cd web && npx tsc --noEmit && npx vite build. - [ ] Step 7: Commit —
git 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
00077applied (version: 77);ai provider openai_compat gpt-4.1;ai.chat_retentionregistered; no panic. - [ ]
/me enabled_modules == [ai,correspondence,esign,semantic,watermarking]; web200.
Task 5.2: conversations + persistence e2e (dev-login token; API base :38080)
- [ ] Ask (no id, not incognito) → response carries
conversation_id;GET /ai/conversationslists it;GET /ai/conversations/{id}returns the 2 messages withsources. - [ ] 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_atbumps. - [ ]
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 /conversationsclears all. - [ ] incognito:true ask → answer returns,
conversation_idempty, andSELECT count(*)onai_conversationsfor 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) → observeevent: sourcesthen multipleevent: deltathenevent: done; the stored assistant message content equals the concatenated deltas. - [ ] Set
daily_token_budgetto a tiny value viaPUT /admin/ai/settings, exhaust it with one ask, next ask → 429 ai.budget_exhausted; restore budget=0;GET /admin/ai/usageshows 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— pokescheduled_tasks.next_run_ator restart) → unsaved gone, asaved=truebackdated one survives; restore retention=30.
Task 5.4: PDF + admin UI + cleanup
- [ ]
POST /ai/export-pdfwith 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/settings403. - [ ] 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:
Repositorymethods (1.2) match theStoreimpl (1.3) and theServicepassthroughs (1.4/2.2).ChatResponse{Content,TokensIn,TokensOut}(2.1) is returned byComplete/Stream(2.1/3.1) and consumed bymeter/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/streamrequest fields are parsed in 2.2 and sent in 3.3. - Known verify-when-implementing flags (called out inline, not silent): the
*db.DBtransaction API forAppendMessages(1.3) — use the codebase's actual tx helper (s.db.Dovs a threadeduow); the embed provider/model source onServer.cfg(2.3);s.officefield 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.gorequiringgo testis introduced.