think
16px
820px

Smart Upload Assistant 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: On file drop in the Upload Document modal, auto-analyze the file with AI (summary + classification + tags), let the user confirm/edit before saving, and reuse the extracted text on Save — nothing persisted until Save, cancel leaves zero rows.

Architecture: A stateless POST /api/v1/ai/analyze-upload endpoint extracts text from the raw bytes (extract-sidecar) and runs the existing metered AI calls (Summarize + ClassifyDocument) via a new enrich.Service.Analyze; the result is cached in-process (owner-scoped TTL cache) under an analysis_id. On Save, the existing create→AddVersion sequence carries analysis_id + the (possibly edited) summary; the AddVersion handler redeems the cache entry to seed content_text (skipping the async extract hook to avoid double work) and persists the confirmed enrichment via enrich.Service.RecordConfirmed.

Tech Stack: Go modular monolith (chi, *db.DB store pattern, kernel errors), extract-sidecar (FastAPI/pypdfium2+OCR), OpenAI-compatible chat provider (metered), React + Carbon SPA (openapi-fetch + raw fetch for multipart), Postgres migrations.

Spec: docs/superpowers/specs/2026-07-03-smart-upload-assistant-design.md

Global Constraints (every task)

  • NEVER run go test — the test DSN is the LIVE demo Postgres (:55432 == deploy-postgres-1). Verify with cd go && go build ./... && go vet ./... + curl e2e on the deployed stack.
  • Web verify: cd web && npx tsc --noEmit && npx vite build.
  • After ANY api/openapi.yaml edit: cd web && npm run gen:api (regenerates src/api/schema.ts). npm install is BROKEN (npm11/node25 arborist crash) — never add npm dependencies.
  • Deploy ONLY from repo root: docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura web (a plain docker restart does NOT re-read the env file).
  • After every deploy assert /me enabled_modules == [ai, correspondence, esign, semantic, watermarking] and the demo stays intact.
  • Commit per task locally on main. Do NOT push unless asked. NEVER git add -A (go/obscura-server is a tracked ELF).
  • Analysis must NEVER block or fail an upload — best-effort everywhere; every failure path degrades to today's async extract/enrich pipeline.
  • i18n: en/id parity (enforced by tsc). The Edit tool can smart-quote ' in i18n files — prefer Write for whole i18n files, always verify with tsc.
  • Clean up all e2e artifacts (scratch docs → trash → purge; restore any toggled settings).

File Map

File Role
go/migrations/00078_ai_upload_analyze.sql new column ai_settings.upload_analyze_enabled
go/internal/ai/app/store.go Settings.UploadAnalyzeEnabled
go/internal/ai/adapters/pg.go settings SELECT/UPDATE gain the column
go/internal/httpapi/handlers_ai_admin.go GET/PUT expose the toggle
go/internal/enrich/app/service.go Analysis type, Analyze, RecordConfirmed
go/internal/httpapi/analysis_cache.go (new) owner-scoped TTL cache
go/internal/httpapi/handlers_ai_analyze.go (new) POST /ai/analyze-upload
go/internal/httpapi/server.go Deps.Extractor, Server fields, route
go/internal/dms/app/service.go VersionOpt / WithoutExtractHook on AddVersion
go/internal/httpapi/handlers_dms.go AddVersion confirm branch
go/cmd/obscura-server/wire.go hoist extractor, pass to Deps
api/openapi.yaml analyze-upload path, AddVersion fields, settings fields
web/src/api/ai.ts analyzeUpload()
web/src/api/files.ts uploadVersion extra fields
web/src/features/admin/data.ts + AiTab.tsx + i18n.ts admin toggle
web/src/features/documents/UploadDocumentModal.tsx AI suggestions panel
web/src/features/documents/DocumentsPage.tsx save sequence carries analysis
web/src/i18n/locales/en.ts + id.ts newDoc.ai* strings

Phase 1 — backend analyze path (deployable alone)

Task 1: Migration 00078 + upload_analyze_enabled settings plumbing

Files:
- Create: go/migrations/00078_ai_upload_analyze.sql
- Modify: go/internal/ai/app/store.go:35-38 (Settings struct)
- Modify: go/internal/ai/adapters/pg.go:210-226 (GetSettings/PutSettings)
- Modify: go/internal/httpapi/handlers_ai_admin.go (GET/PUT)
- Modify: api/openapi.yaml:5196-5249 (admin ai settings schema)

Interfaces:
- Produces: aiapp.Settings{ChatRetentionDays int; DailyTokenBudget int64; UploadAnalyzeEnabled bool}; wire fields upload_analyze_enabled on GET/PUT /api/v1/admin/ai/settings. Task 4's handler reads st.UploadAnalyzeEnabled; Task 6's web hooks read/write upload_analyze_enabled.

  • [x] Step 1: Write the migration
-- go/migrations/00078_ai_upload_analyze.sql
-- Smart upload assistant: admin toggle for pre-save AI analysis of uploads.
-- Default ON — the feature is already gated by the ai module license.
ALTER TABLE ai_settings
    ADD COLUMN upload_analyze_enabled BOOLEAN NOT NULL DEFAULT TRUE;
  • [x] Step 2: Extend the Settings struct in go/internal/ai/app/store.go (currently lines 35-38):
type Settings struct {
    ChatRetentionDays    int
    DailyTokenBudget     int64
    UploadAnalyzeEnabled bool
}
  • [x] Step 3: Extend the store SQL in go/internal/ai/adapters/pg.go. GetSettings (line ~210) becomes:
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, upload_analyze_enabled FROM ai_settings WHERE id = 1`).
        Scan(&st.ChatRetentionDays, &st.DailyTokenBudget, &st.UploadAnalyzeEnabled)
    ...
}

(keep the existing error handling lines unchanged — only the SELECT list and Scan args grow). PutSettings (line ~221):

    _, err := s.db.Exec(ctx).Exec(ctx,
        `UPDATE ai_settings SET chat_retention_days = $1, daily_token_budget = $2, upload_analyze_enabled = $3, updated_at = now() WHERE id = 1`,
        st.ChatRetentionDays, st.DailyTokenBudget, st.UploadAnalyzeEnabled)
  • [x] Step 4: Expose on the admin handlers in go/internal/httpapi/handlers_ai_admin.go. In GetAISettings add to the response map:
        "upload_analyze_enabled": st.UploadAnalyzeEnabled,

In PutAISettings, the body struct and the save call become:

    var body struct {
        ChatRetentionDays    int   `json:"chat_retention_days"`
        DailyTokenBudget     int64 `json:"daily_token_budget"`
        UploadAnalyzeEnabled bool  `json:"upload_analyze_enabled"`
    }
    ...
    if err := s.ai.PutSettings(r.Context(), aiapp.Settings{
        ChatRetentionDays:    body.ChatRetentionDays,
        DailyTokenBudget:     body.DailyTokenBudget,
        UploadAnalyzeEnabled: body.UploadAnalyzeEnabled,
    }); err != nil {

(validation lines unchanged — a bool needs none).

  • [x] Step 5: OpenAPI. In api/openapi.yaml under /api/v1/admin/ai/settings: add to the GET 200 schema properties:
                  upload_analyze_enabled:
                    type: boolean
                    description: Auto-analyze uploads with AI before save (Smart Upload Assistant).

and append upload_analyze_enabled to that schema's required list (line ~5225). In the PUT requestBody schema add the same property and append it to its required list (line ~5249).

  • [x] Step 6: Verify + regen
cd go && go build ./... && go vet ./...
cd ../web && npm run gen:api && npx tsc --noEmit

Expected: clean build; tsc clean (nothing consumes the new field yet).

  • [x] Step 7: Commit
git add go/migrations/00078_ai_upload_analyze.sql go/internal/ai/app/store.go go/internal/ai/adapters/pg.go go/internal/httpapi/handlers_ai_admin.go api/openapi.yaml web/src/api/schema.ts
git commit -m "feat(ai): upload_analyze_enabled admin setting (migration 00078)"

Task 2: enrich.Analyze + RecordConfirmed

Files:
- Modify: go/internal/enrich/app/service.go

Interfaces:
- Consumes: existing s.ai.Summarize/ClassifyDocument/ExtractFields/Info, s.store.Upsert, clip(), summarySampleChars/classifySampleChars, s.enabled.
- Produces: type Analysis struct{ Summary, Classification string; Tags []string; Model string }; Analyze(ctx, title, text string) (Analysis, error); RecordConfirmed(ctx, docID string, version int, a Analysis, summaryOverride, text string) error. Task 4 calls Analyze; Task 5 calls RecordConfirmed (detached goroutine).

  • [x] Step 1: Add imports strings and kernel to go/internal/enrich/app/service.go:
import (
    "context"
    "fmt"
    "log/slog"
    "strings"

    aiapp "github.com/Virtue-Digital-Indonesia/obscura/internal/ai/app"
    "github.com/Virtue-Digital-Indonesia/obscura/internal/kernel"
)
  • [x] Step 2: Add the Analysis type + methods (append after RunBackfill/enrichOne, before clip):
// Analysis is an on-demand, pre-save enrichment result (the smart-upload path).
// Nothing is persisted — the HTTP layer caches it until the user confirms the upload.
type Analysis struct {
    Summary        string
    Classification string
    Tags           []string
    Model          string
}

// Analyze runs the summary + classification model calls over not-yet-saved text.
// Field extraction is deliberately skipped here (one less model call while the user
// waits); RecordConfirmed completes it off the response path.
func (s *Service) Analyze(ctx context.Context, title, text string) (Analysis, error) {
    if s.enabled != nil && !s.enabled() {
        return Analysis{}, &kernel.Error{Kind: kernel.ErrPermissionDenied, Code: "ai.module_disabled", Message: "the AI module is not enabled"}
    }
    summary, err := s.ai.Summarize(ctx, clip(text, summarySampleChars))
    if err != nil {
        return Analysis{}, err
    }
    cls, err := s.ai.ClassifyDocument(ctx, title, clip(text, classifySampleChars))
    if err != nil {
        return Analysis{}, err
    }
    info := s.ai.Info()
    return Analysis{
        Summary:        summary,
        Classification: cls.Sensitivity,
        Tags:           cls.Tags,
        Model:          info.Provider + "/" + info.Model,
    }, nil
}

// RecordConfirmed persists a user-confirmed upload analysis as the document's
// enrichment — the user-edited summary wins over the AI's. Fields are extracted here
// (callers run this detached from the upload response) so the stored row is complete
// and the hourly sweep — which would overwrite the user's edit with fresh AI output —
// skips this version. Field-extraction failure degrades to an empty list.
func (s *Service) RecordConfirmed(ctx context.Context, docID string, version int, a Analysis, summaryOverride, text string) error {
    sum := a.Summary
    if strings.TrimSpace(summaryOverride) != "" {
        sum = summaryOverride
    }
    fields := []aiapp.Field{}
    if s.enabled == nil || s.enabled() {
        if f, err := s.ai.ExtractFields(ctx, clip(text, summarySampleChars)); err == nil {
            fields = f
        } else {
            s.logger.Warn("upload analysis: field extraction failed", "doc", docID, "err", err)
        }
    }
    return s.store.Upsert(ctx, Enrichment{
        DocumentID:              docID,
        Version:                 version,
        Summary:                 sum,
        SuggestedClassification: a.Classification,
        SuggestedTags:           a.Tags,
        Fields:                  fields,
        Model:                   a.Model,
    })
}
  • [x] Step 3: Verify
cd go && go build ./... && go vet ./...
  • [x] Step 4: Commit
git add go/internal/enrich/app/service.go
git commit -m "feat(enrich): pre-save Analyze + user-confirmed RecordConfirmed"

Task 3: The analysis cache

Files:
- Create: go/internal/httpapi/analysis_cache.go

Interfaces:
- Produces: newAnalysisCache() *analysisCache; Put(owner, text string, a enrichapp.Analysis) string; Consume(id, owner string) (analysisEntry, bool); analysisEntry{owner, text string; analysis enrichapp.Analysis; expires time.Time}. Task 4 calls Put; Task 5 calls Consume.

  • [x] Step 1: Write the file (complete content):
package httpapi

import (
    "crypto/rand"
    "encoding/hex"
    "sync"
    "time"

    enrichapp "github.com/Virtue-Digital-Indonesia/obscura/internal/enrich/app"
)

// The pre-save upload-analysis cache: entries are consumed on save, expire after the
// TTL, and the oldest is evicted when full. Losing an entry (restart, TTL, eviction)
// silently degrades to the async extract/enrich pipeline — never an error.
const (
    analysisTTL      = 30 * time.Minute
    analysisCacheMax = 64
    // analysisMaxTextBytes mirrors dms's content_text bound (tsvector-safe 800 KiB) so
    // a cached text is always accepted by SetContentText on the confirm path.
    analysisMaxTextBytes = 800 << 10
)

// analysisEntry is one pre-save analysis: the extracted text plus the AI suggestions,
// owned by the principal who uploaded the bytes.
type analysisEntry struct {
    owner    string
    text     string
    analysis enrichapp.Analysis
    expires  time.Time
}

// analysisCache is an in-process, owner-scoped TTL map. Single-instance by design
// (Obscura is a single-tenant modular monolith).
type analysisCache struct {
    mu      sync.Mutex
    entries map[string]analysisEntry
}

func newAnalysisCache() *analysisCache {
    return &analysisCache{entries: make(map[string]analysisEntry)}
}

// Put stores an analysis (clipping text to the content_text bound) and returns its id.
func (c *analysisCache) Put(owner, text string, a enrichapp.Analysis) string {
    if len(text) > analysisMaxTextBytes {
        text = text[:analysisMaxTextBytes]
    }
    id := newAnalysisID()
    c.mu.Lock()
    defer c.mu.Unlock()
    now := time.Now()
    for k, e := range c.entries {
        if now.After(e.expires) {
            delete(c.entries, k)
        }
    }
    if len(c.entries) >= analysisCacheMax {
        oldestK, oldest := "", time.Time{}
        for k, e := range c.entries {
            if oldestK == "" || e.expires.Before(oldest) {
                oldestK, oldest = k, e.expires
            }
        }
        delete(c.entries, oldestK)
    }
    c.entries[id] = analysisEntry{owner: owner, text: text, analysis: a, expires: now.Add(analysisTTL)}
    return id
}

// Consume returns and removes the entry — only for its owner. Unknown, expired, or
// foreign ids report !ok without distinguishing why (nothing to enumerate or leak).
func (c *analysisCache) Consume(id, owner string) (analysisEntry, bool) {
    c.mu.Lock()
    defer c.mu.Unlock()
    e, ok := c.entries[id]
    if !ok {
        return analysisEntry{}, false
    }
    delete(c.entries, id)
    if e.owner != owner || time.Now().After(e.expires) {
        return analysisEntry{}, false
    }
    return e, true
}

// newAnalysisID returns a 128-bit random hex id (no dependency on a uuid package).
func newAnalysisID() string {
    b := make([]byte, 16)
    _, _ = rand.Read(b)
    return hex.EncodeToString(b)
}
  • [x] Step 2: Verify
cd go && go build ./... && go vet ./...
  • [x] Step 3: Commit
git add go/internal/httpapi/analysis_cache.go
git commit -m "feat(httpapi): owner-scoped TTL cache for pre-save upload analyses"

Task 4: POST /ai/analyze-upload handler + wiring + OpenAPI + deploy

Files:
- Create: go/internal/httpapi/handlers_ai_analyze.go
- Modify: go/internal/httpapi/server.go (Deps ~line 71-81, Server ~105-120, ctor ~160, route ~306)
- Modify: go/cmd/obscura-server/wire.go:385-386 + Deps block ~399-409
- Modify: api/openapi.yaml (new path near /api/v1/ai/summarize, line ~798)

Interfaces:
- Consumes: s.ai.GetSettings (Task 1), s.enrich.Analyze (Task 2), s.analyses.Put (Task 3), existing maxUploadMemory/allowedUploadExt from handlers_dms.go, extractapp.Extractor.
- Produces: route POST /api/v1/ai/analyze-upload returning {analysis_id, summary, classification, tags, text_chars} or {analysis_id:"", no_text:true}; Server fields s.extractor extractapp.Extractor + s.analyses *analysisCache (Task 5 reuses s.analyses).

  • [x] Step 1: Write the handler (complete file):
package httpapi

import (
    "io"
    "net/http"
    "path/filepath"
    "strings"

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

// maxAnalyzeBytes caps how much of an upload the pre-save analyzer reads into memory
// (the sidecar client needs the full bytes). Oversized files answer no_text rather
// than erroring — analysis is optional decoration on the upload, never a gate.
const maxAnalyzeBytes = 64 << 20

// AnalyzeUpload extracts text from a not-yet-saved upload and returns AI suggestions
// (summary/classification/tags) plus an analysis_id the Save path redeems to reuse the
// extracted text. Nothing is persisted here; cancelling the modal leaves zero rows.
func (s *Server) AnalyzeUpload(w http.ResponseWriter, r *http.Request) {
    p, _ := PrincipalFrom(r.Context())

    st, err := s.ai.GetSettings(r.Context())
    if err != nil {
        writeProblem(w, err)
        return
    }
    if !st.UploadAnalyzeEnabled {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrConflict, Code: "ai.upload_analyze_disabled", Message: "upload analysis is disabled by the administrator"})
        return
    }

    if err := r.ParseMultipartForm(maxUploadMemory); err != nil {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "request.invalid_multipart", Message: "invalid multipart form"})
        return
    }
    file, header, err := r.FormFile("file")
    if err != nil {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "request.file_required", Message: "file field is required"})
        return
    }
    defer file.Close()
    if ext := strings.ToLower(filepath.Ext(header.Filename)); !allowedUploadExt[ext] {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "dms.version.filetype_not_allowed", Message: "this file type is not allowed"})
        return
    }

    noText := func() { writeJSON(w, http.StatusOK, map[string]any{"analysis_id": "", "no_text": true}) }

    // No extractor (EXTRACT_PROVIDER=none) or an oversized file: analysis simply
    // isn't available — same UX as an unreadable scan.
    if s.extractor == nil || header.Size > maxAnalyzeBytes {
        noText()
        return
    }
    data, err := io.ReadAll(io.LimitReader(file, maxAnalyzeBytes+1))
    if err != nil || len(data) > maxAnalyzeBytes {
        noText()
        return
    }
    mime := header.Header.Get("Content-Type")
    if mime == "" {
        mime = "application/octet-stream"
    }
    text, err := s.extractor.Extract(r.Context(), data, mime, header.Filename)
    if err != nil || strings.TrimSpace(text) == "" {
        noText()
        return
    }

    analysis, err := s.enrich.Analyze(r.Context(), r.FormValue("title"), text)
    if err != nil {
        writeProblem(w, err) // budget 429 / provider failures surface as problems (soft-fail client-side)
        return
    }
    id := s.analyses.Put(string(p.UserID), text, analysis)
    writeJSON(w, http.StatusOK, map[string]any{
        "analysis_id":    id,
        "summary":        analysis.Summary,
        "classification": analysis.Classification,
        "tags":           analysis.Tags,
        "text_chars":     len(text),
    })
}
  • [x] Step 2: Server plumbing in go/internal/httpapi/server.go:
  • Import: extractapp "github.com/Virtue-Digital-Indonesia/obscura/internal/extract/app".
  • Deps struct (after Enrich *enrichapp.Service, line ~81): add Extractor extractapp.Extractor.
  • Server struct (near enrich, line ~115): add extractor extractapp.Extractor and analyses *analysisCache.
  • Constructor assignments (near enrich: d.Enrich, line ~160): add extractor: d.Extractor, and analyses: newAnalysisCache(),.
  • Route, in the authed /ai group (after line 306 Post("/ai/summarize", ...)):
            r.With(s.requireModule("ai")).Post("/ai/analyze-upload", s.AnalyzeUpload)
  • [x] Step 3: wire.go — hoist the extractor (currently built inline at line 385-386) and pass it to Deps:
    extractor := extractadapters.SelectExtractor(cfg.Extract.Provider, cfg.Extract.SidecarURL)
    extractSvc := extractapp.NewService(
        extractor,
        ...

and in the httpapi.NewServer(httpapi.Deps{...}) literal (line ~399, next to Enrich: enrichSvc,): add Extractor: extractor,.
Check SelectExtractor's none-provider return: if it returns a typed-nil concrete pointer instead of a nil interface, the handler's s.extractor == nil check fails — read go/internal/extract/adapters/ SelectExtractor and, if needed, keep var extractor extractapp.Extractor nil when cfg.Extract.Provider != "sidecar".

  • [x] Step 4: OpenAPI path — add near /api/v1/ai/summarize (line ~798):
  /api/v1/ai/analyze-upload:
    post:
      operationId: analyzeUpload
      summary: Analyze a not-yet-saved upload
      description: >-
        Extracts text from the uploaded file and returns AI suggestions (summary,
        classification, tags) plus an analysis_id the Save path can redeem to reuse
        the extracted text. Nothing is persisted. Requires the `ai` module. 409 when
        the admin toggle is off; 429 when the daily token budget is exhausted.
      tags: [ai]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                title:
                  type: string
                  description: Working title (improves classification).
      responses:
        '200':
          description: Suggestions, or no_text when the file has no extractable text.
          content:
            application/json:
              schema:
                type: object
                required: [analysis_id]
                properties:
                  analysis_id: {type: string}
                  no_text: {type: boolean}
                  summary: {type: string}
                  classification: {type: string}
                  tags:
                    type: array
                    items: {type: string}
                  text_chars: {type: integer}
        '400':
          $ref: '#/components/responses/Problem'
        '401':
          $ref: '#/components/responses/Problem'
        '403':
          $ref: '#/components/responses/Problem'
        '409':
          $ref: '#/components/responses/Problem'
        '429':
          $ref: '#/components/responses/Problem'
  • [x] Step 5: Verify + regen
cd go && go build ./... && go vet ./...
cd ../web && npm run gen:api && npx tsc --noEmit && npx vite build
  • [x] Step 6: Deploy Phase 1 + smoke
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura
# modules intact:
TOKEN=$(curl -s -X POST http://localhost:38080/api/v1/auth/dev-login -H 'Content-Type: application/json' -d '{"email":"director@obscura.local"}' | python3 -c 'import json,sys;print(json.load(sys.stdin)["token"])')
curl -s http://localhost:38080/api/v1/me -H "Authorization: Bearer $TOKEN" | python3 -c 'import json,sys;print(sorted(json.load(sys.stdin)["enabled_modules"]))'
# expect: ['ai', 'correspondence', 'esign', 'semantic', 'watermarking']
# smoke the endpoint with a scratch text file:
printf 'Perjanjian kerja sama antara PT Alpha dan PT Beta tahun 2026.' > /tmp/claude-analyze-smoke.txt
curl -s -X POST http://localhost:38080/api/v1/ai/analyze-upload -H "Authorization: Bearer $TOKEN" -F file=@/tmp/claude-analyze-smoke.txt -F title=Smoke | python3 -m json.tool
# expect: analysis_id non-empty, summary/classification/tags populated (live model)
rm /tmp/claude-analyze-smoke.txt
  • [x] Step 7: Commit
git add go/internal/httpapi/handlers_ai_analyze.go go/internal/httpapi/server.go go/cmd/obscura-server/wire.go api/openapi.yaml web/src/api/schema.ts
git commit -m "feat(ai): POST /ai/analyze-upload — pre-save extract + AI suggestions"

Phase 2 — confirm path + admin toggle + frontend

Task 5: AddVersion confirm branch (seed text + confirmed enrichment)

Files:
- Modify: go/internal/dms/app/service.go:773-832 (AddVersion opts)
- Modify: go/internal/httpapi/handlers_dms.go:75-112 (AddVersion handler)
- Modify: api/openapi.yaml:1190-1198 (AddVersion multipart fields)

Interfaces:
- Consumes: s.analyses.Consume(id, owner) (Task 3), s.enrich.RecordConfirmed(ctx, docID, version, analysis, summaryOverride, text) (Task 2), existing s.dms.SetContentText(ctx, docID, text) + s.dms.MarkExtracted(ctx, docID, version).
- Produces: dmsapp.VersionOpt + dmsapp.WithoutExtractHook(); AddVersion multipart accepts optional analysis_id + summary form fields. Task 7's uploadVersion sends them.

  • [x] Step 1: Variadic opts on dms.AddVersion. In go/internal/dms/app/service.go, above the AddVersion method (line ~770):
// VersionOpt tunes AddVersion. WithoutExtractHook suppresses the async extract hook
// for callers that seed content_text themselves (the smart-upload confirm path) —
// otherwise the sidecar would re-extract, and the embed hook would fire twice.
type VersionOpt func(*versionOpts)

type versionOpts struct{ skipExtractHook bool }

// WithoutExtractHook suppresses the post-commit extract hook for this version.
func WithoutExtractHook() VersionOpt { return func(o *versionOpts) { o.skipExtractHook = true } }

Change the signature (line 773) — variadic keeps all 6 existing call sites compiling unchanged:

func (s *Service) AddVersion(ctx context.Context, p kernel.Principal, docID string, content io.Reader, mime string, opts ...VersionOpt) (int, error) {

and the hook fire site (lines 829-831) becomes:

    var vo versionOpts
    for _, o := range opts {
        o(&vo)
    }
    if s.extractHook != nil && !vo.skipExtractHook {
        s.extractHook(docID, newVersion)
    }
  • [x] Step 2: The handler confirm branch. In go/internal/httpapi/handlers_dms.go, add imports context, log/slog, and dmsapp "github.com/Virtue-Digital-Indonesia/obscura/internal/dms/app" (check existing import block; add only what's missing). Rework AddVersion (the section from the mime := lines through the final writeJSON):
    mime := header.Header.Get("Content-Type")
    if mime == "" {
        mime = "application/octet-stream"
    }

    // Smart-upload confirm path: a valid analysis_id redeems the pre-save analysis.
    // Consume BEFORE AddVersion so we know whether to suppress the async extract hook
    // (the seed below replaces it). Foreign/expired/unknown ids silently fall back to
    // the normal async pipeline — best-effort everywhere, the upload never fails
    // because of analysis.
    var analysis *analysisEntry
    if aid := r.FormValue("analysis_id"); aid != "" {
        if e, ok := s.analyses.Consume(aid, string(p.UserID)); ok {
            analysis = &e
        }
    }
    var vOpts []dmsapp.VersionOpt
    if analysis != nil {
        vOpts = append(vOpts, dmsapp.WithoutExtractHook())
    }

    v, err := s.dms.AddVersion(r.Context(), p, docID, file, mime, vOpts...)
    if err != nil {
        writeProblem(w, err)
        return
    }
    // Uploading new content supersedes any published state: a fresh version returns the
    // document to draft so it must be re-published (and re-approved) before it counts as
    // the official record. Best-effort — a failure here doesn't undo the version.
    _ = s.dms.SetDocumentStatus(r.Context(), docID, dmsdomain.StatusDraft)

    if analysis != nil {
        // Seed the already-extracted text (fires the embed hook → immediate semantic
        // indexing) and mark the version extracted so the backfill skips it. If the
        // seed fails the hourly backfill still covers the doc — only log.
        if err := s.dms.SetContentText(r.Context(), docID, analysis.text); err != nil {
            slog.Default().Warn("upload analysis: content seed failed", "doc", docID, "err", err)
        } else {
            _ = s.dms.MarkExtracted(r.Context(), docID, v)
        }
        // The confirmed enrichment includes a field-extraction model call — run it
        // detached so the upload response doesn't wait on it.
        summaryOverride := r.FormValue("summary")
        bg := context.WithoutCancel(r.Context())
        entry := *analysis
        go func() {
            defer func() {
                if rec := recover(); rec != nil {
                    slog.Default().Error("upload analysis: record panic", "doc", docID, "panic", rec)
                }
            }()
            if err := s.enrich.RecordConfirmed(bg, docID, v, entry.analysis, summaryOverride, entry.text); err != nil {
                slog.Default().Warn("upload analysis: record failed", "doc", docID, "err", err)
            }
        }()
    }

    writeJSON(w, http.StatusCreated, map[string]any{"version": v})
  • [x] Step 3: OpenAPI — in the AddVersion multipart/form-data schema (line ~1192) add:
                analysis_id:
                  type: string
                  description: Redeems a pre-save AI analysis (Smart Upload Assistant); unknown/expired ids are ignored.
                summary:
                  type: string
                  description: User-confirmed summary override, stored with the enrichment.
  • [x] Step 4: Verify + regen
cd go && go build ./... && go vet ./...
cd ../web && npm run gen:api && npx tsc --noEmit
  • [x] Step 5: Commit
git add go/internal/dms/app/service.go go/internal/httpapi/handlers_dms.go api/openapi.yaml web/src/api/schema.ts
git commit -m "feat(dms): AddVersion redeems pre-save analysis (seed text, confirmed enrichment, skip extract hook)"

Task 6: Admin toggle in the web UI

Files:
- Modify: web/src/features/admin/data.ts:515-543 (useAiSettings/useSaveAiSettings)
- Modify: web/src/features/admin/AiTab.tsx
- Modify: web/src/features/admin/i18n.ts (keys admin.ai.settings.uploadAnalyze*, en + id)

Interfaces:
- Consumes: wire field upload_analyze_enabled (Task 1).
- Produces: AiSettings.uploadAnalyzeEnabled: boolean; save mutation sends all three fields.

  • [x] Step 1: data.ts. In useAiSettings's mapping add uploadAnalyzeEnabled: d.upload_analyze_enabled, (and add the field to the local AiSettings interface — find it above line 515). In useSaveAiSettings:
    mutationFn: (v: { chatRetentionDays: number; dailyTokenBudget: number; uploadAnalyzeEnabled: boolean }) =>
      api
        .PUT('/api/v1/admin/ai/settings', {
          body: {
            chat_retention_days: v.chatRetentionDays,
            daily_token_budget: v.dailyTokenBudget,
            upload_analyze_enabled: v.uploadAnalyzeEnabled,
          },
        })
        .then(ok),
  • [x] Step 2: AiTab.tsx. Import Toggle from @carbon/react. Add state seeded like the others:
  const [uploadAnalyze, setUploadAnalyze] = useState(true)

in the seed effect: setUploadAnalyze(settings.data.uploadAnalyzeEnabled). In onSave pass uploadAnalyzeEnabled: uploadAnalyze. In the Settings card (next to retention/budget inputs):

        <Toggle
          id="ai-upload-analyze"
          labelText={t('admin.ai.settings.uploadAnalyze')}
          labelA={t('admin.ai.settings.uploadAnalyzeOff')}
          labelB={t('admin.ai.settings.uploadAnalyzeOn')}
          toggled={uploadAnalyze}
          onToggle={setUploadAnalyze}
        />
  • [x] Step 3: i18n (web/src/features/admin/i18n.ts, both languages — mind the smart-quote gotcha, verify with tsc):
// en
'admin.ai.settings.uploadAnalyze': 'Analyze uploads with AI',
'admin.ai.settings.uploadAnalyzeOn': 'On',
'admin.ai.settings.uploadAnalyzeOff': 'Off',
// id
'admin.ai.settings.uploadAnalyze': 'Analisis unggahan dengan AI',
'admin.ai.settings.uploadAnalyzeOn': 'Aktif',
'admin.ai.settings.uploadAnalyzeOff': 'Nonaktif',
  • [x] Step 4: Verify
cd web && npx tsc --noEmit && npx vite build
  • [x] Step 5: Commit
git add web/src/features/admin/data.ts web/src/features/admin/AiTab.tsx web/src/features/admin/i18n.ts
git commit -m "feat(web): Admin → AI toggle for upload analysis"

Task 7: Web API plumbing (analyzeUpload, uploadVersion extras)

Files:
- Modify: web/src/api/ai.ts (append near the other document-AI helpers)
- Modify: web/src/api/files.ts:139-150 (uploadVersion)

Interfaces:
- Consumes: routes from Tasks 4-5; existing getToken(), toError(), Problem in ai.ts.
- Produces: analyzeUpload(file: File, title?: string, signal?: AbortSignal): Promise<UploadAnalysis> with UploadAnalysis{disabled?, noText?, analysisId, summary, classification, tags}; uploadVersion(docID, file, extra?: {analysisId?: string; summary?: string}). Task 8 consumes both.

  • [x] Step 1: ai.ts — append:
// --- Smart upload assistant (pre-save analysis) ---

export interface UploadAnalysis {
  disabled?: boolean
  noText?: boolean
  analysisId: string
  summary: string
  classification: string
  tags: string[]
}

const EMPTY_ANALYSIS: UploadAnalysis = { analysisId: '', summary: '', classification: '', tags: [] }

// The admin can turn upload analysis off (409). Remember it for the session so later
// uploads skip the round-trip; a reload re-checks.
let uploadAnalyzeDisabled = false

// Analyze a not-yet-saved file: extracted text + AI suggestions. Raw fetch because
// openapi-fetch would JSON-serialise FormData. Soft-failure contract: callers treat a
// thrown error as "no suggestions", never as an upload blocker.
export async function analyzeUpload(file: File, title?: string, signal?: AbortSignal): Promise<UploadAnalysis> {
  if (uploadAnalyzeDisabled) return { ...EMPTY_ANALYSIS, disabled: true }
  const token = await getToken()
  const fd = new FormData()
  fd.append('file', file)
  if (title) fd.append('title', title)
  const res = await fetch('/api/v1/ai/analyze-upload', {
    method: 'POST',
    headers: token ? { Authorization: `Bearer ${token}` } : {},
    body: fd,
    signal,
  })
  if (res.status === 409) {
    uploadAnalyzeDisabled = true
    return { ...EMPTY_ANALYSIS, disabled: true }
  }
  if (!res.ok) {
    const p = (await res.json().catch(() => null)) as Problem | null
    throw toError(p ?? `HTTP ${res.status}`)
  }
  const d = (await res.json()) as {
    analysis_id?: string
    no_text?: boolean
    summary?: string
    classification?: string
    tags?: string[]
  }
  return {
    noText: !!d.no_text,
    analysisId: d.analysis_id ?? '',
    summary: d.summary ?? '',
    classification: d.classification ?? '',
    tags: d.tags ?? [],
  }
}
  • [x] Step 2: files.ts — extend uploadVersion:
export async function uploadVersion(
  docID: string,
  file: File,
  extra?: { analysisId?: string; summary?: string },
): Promise<void> {
  const token = await getToken()
  const fd = new FormData()
  fd.append('file', file)
  if (extra?.analysisId) {
    fd.append('analysis_id', extra.analysisId)
    if (extra.summary) fd.append('summary', extra.summary)
  }
  const res = await fetch(`/api/v1/documents/${docID}/versions`, {
    method: 'POST',
    headers: token ? { Authorization: `Bearer ${token}` } : {},
    body: fd,
  })
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
}
  • [x] Step 3: Verify
cd web && npx tsc --noEmit && npx vite build
  • [x] Step 4: Commit
git add web/src/api/ai.ts web/src/api/files.ts
git commit -m "feat(web): analyzeUpload API + uploadVersion analysis fields"

Task 8: Upload modal AI panel + save sequence + i18n

Files:
- Modify: web/src/features/documents/UploadDocumentModal.tsx
- Modify: web/src/features/documents/DocumentsPage.tsx:102-116 (submitUpload)
- Modify: web/src/i18n/locales/en.ts + web/src/i18n/locales/id.ts (newDoc.ai*)
- Modify: web/src/styles/app.css (panel styles)

Interfaces:
- Consumes: analyzeUpload/UploadAnalysis + uploadVersion extras (Task 7), useMe+moduleEnabled (existing: web/src/api/me.ts, web/src/lib/nav.ts:57), useActiveClassifications (already in the modal), tags endpoint PUT /api/v1/documents/{docID}/tags.
- Produces: UploadDocumentInput gains analysisId?: string; summary?: string; tags?: string[].

  • [x] Step 1: Modal state + analyze lifecycle. In UploadDocumentModal.tsx add imports:
import { useEffect, useRef, useState } from 'react'
import { Dropdown, InlineLoading, InlineNotification, Modal, Tag, TextArea, TextInput, FileUploaderDropContainer } from '@carbon/react'
import { useMe } from '@/api/me'
import { moduleEnabled } from '@/lib/nav'
import { analyzeUpload } from '@/api/ai'

Extend the input type:

export interface UploadDocumentInput {
  title: string
  classification: Classification
  docType: string
  formatId: string
  customVars: Record<string, string>
  file: File
  folderId: string | null
  analysisId?: string
  summary?: string
  tags?: string[]
}

Inside the component add:

  const me = useMe()
  const aiOn = moduleEnabled(me.data?.enabledModules, 'ai')

  type AiState =
    | { status: 'idle' }
    | { status: 'analyzing' }
    | { status: 'none' } // no_text
    | { status: 'failed' }
    | { status: 'done'; analysisId: string; summary: string; tags: { name: string; selected: boolean }[]; classSuggested: boolean }
  const [ai, setAi] = useState<AiState>({ status: 'idle' })
  const aiAbort = useRef<AbortController | null>(null)

  const runAnalysis = (f: File, workingTitle: string) => {
    aiAbort.current?.abort()
    const ctrl = new AbortController()
    aiAbort.current = ctrl
    setAi({ status: 'analyzing' })
    analyzeUpload(f, workingTitle, ctrl.signal)
      .then((res) => {
        if (ctrl.signal.aborted) return
        if (res.disabled) { setAi({ status: 'idle' }); return }
        if (res.noText || !res.analysisId) { setAi({ status: 'none' }); return }
        // Apply the suggested classification only if it names a real registry entry
        // and the user hasn't already picked one.
        let classSuggested = false
        if (res.classification && CLASS_ITEMS.some((c) => c.id === res.classification)) {
          setClassification((prev) => {
            if (prev === 'none') { classSuggested = true; return res.classification as Classification }
            return prev
          })
        }
        setAi({
          status: 'done',
          analysisId: res.analysisId,
          summary: res.summary,
          tags: res.tags.map((name) => ({ name, selected: true })),
          classSuggested,
        })
      })
      .catch(() => { if (!ctrl.signal.aborted) setAi({ status: 'failed' }) })
  }

Hook it into file selection and cleanup — onFile becomes:

  const onFile = (f: File | null) => {
    setFile(f)
    if (f && !title.trim()) setTitle(stripExt(f.name))
    if (!f) { aiAbort.current?.abort(); setAi({ status: 'idle' }); return }
    if (aiOn) runAnalysis(f, title.trim() || stripExt(f.name))
  }

and the modal-open reset effect additionally does aiAbort.current?.abort(); setAi({ status: 'idle' }).

  • [x] Step 2: Submit carries the confirmation. submit becomes:
  const submit = () => {
    if (!canSubmit || !file) return
    const vars: Record<string, string> = {}
    for (const n of customNames) vars[n] = (customVars[n] ?? '').trim()
    const aiDone = ai.status === 'done' ? ai : null
    onSubmit({
      title: trimmed,
      classification,
      docType: docType.trim(),
      formatId,
      customVars: vars,
      file,
      folderId: folder.folderId,
      analysisId: aiDone?.analysisId,
      summary: aiDone?.summary.trim() || undefined,
      tags: aiDone ? aiDone.tags.filter((t) => t.selected).map((t) => t.name) : undefined,
    })
  }
  • [x] Step 3: The panel UI. Insert after the classification <div className="newdoc__field"> block (the panel renders only when the ai module is on and something happened):
            {aiOn && ai.status !== 'idle' && (
              <div className="newdoc__field upload__ai">
                <div className="upload__ai-head">
                  <span className="upload__ai-title">{t('newDoc.aiTitle')}</span>
                  {ai.status === 'done' && <Tag type="purple" size="sm">{t('newDoc.aiBadge')}</Tag>}
                </div>
                {ai.status === 'analyzing' && <InlineLoading description={t('newDoc.aiAnalyzing')} />}
                {ai.status === 'none' && <p className="upload__ai-note muted">{t('newDoc.aiNoText')}</p>}
                {ai.status === 'failed' && <p className="upload__ai-note muted">{t('newDoc.aiFailed')}</p>}
                {ai.status === 'done' && (
                  <>
                    <TextArea
                      id="upload-ai-summary"
                      labelText={t('newDoc.aiSummary')}
                      rows={3}
                      value={ai.summary}
                      onChange={(e) => setAi({ ...ai, summary: e.target.value })}
                    />
                    {ai.classSuggested && <p className="upload__ai-note muted">{t('newDoc.aiClassApplied')}</p>}
                    {ai.tags.length > 0 && (
                      <div className="upload__ai-tags">
                        <span className="upload__ai-tags-label">{t('newDoc.aiTags')}</span>
                        {ai.tags.map((tag, i) => (
                          <Tag
                            key={tag.name}
                            type={tag.selected ? 'blue' : 'gray'}
                            size="sm"
                            onClick={() => {
                              const tags = ai.tags.slice()
                              tags[i] = { ...tag, selected: !tag.selected }
                              setAi({ ...ai, tags })
                            }}
                          >
                            {tag.name}
                          </Tag>
                        ))}
                      </div>
                    )}
                  </>
                )}
              </div>
            )}
  • [x] Step 4: DocumentsPage save sequence. Import api (import { api } from '@/api/client'). submitUpload becomes:
  const submitUpload = async ({ title, classification, docType, formatId, customVars, file, folderId, analysisId, summary, tags }: UploadDocumentInput) => {
    setSavingDoc(true)
    setUploadError(null)
    try {
      const id = await createDocument.mutateAsync({ title, folderId, classification, docType, formatId, customVars })
      if (!id) return
      setUploadOpen(false)
      try {
        await uploadVersion(id, file, analysisId ? { analysisId, summary } : undefined)
      } catch { /* stub created; retry from detail */ }
      if (tags && tags.length > 0) {
        // Confirmed AI tags — best-effort, never blocks navigation.
        try {
          await api.PUT('/api/v1/documents/{docID}/tags', { params: { path: { docID: id } }, body: { tags } })
        } catch { /* tags applied later from detail if needed */ }
      }
      navigate(`/documents/d/${id}?folder=${folderId ?? 'root'}`)
    } catch (e) {
      setUploadError(errMsg(e))
    } finally {
      setSavingDoc(false)
    }
  }
  • [x] Step 5: i18n — add to web/src/i18n/locales/en.ts (inside the existing newDoc key group; match the file's existing key style — flat 'newDoc.x' or nested — copy neighbors):
'newDoc.aiTitle': 'AI suggestions',
'newDoc.aiBadge': 'AI',
'newDoc.aiAnalyzing': 'Analyzing document…',
'newDoc.aiSummary': 'Summary',
'newDoc.aiTags': 'Suggested tags',
'newDoc.aiNoText': 'Couldn’t read any text from this file — fill in the details manually.',
'newDoc.aiFailed': 'AI suggestions unavailable right now.',
'newDoc.aiClassApplied': 'Classification pre-set from the AI suggestion — you can change it.',

and id.ts:

'newDoc.aiTitle': 'Saran AI',
'newDoc.aiBadge': 'AI',
'newDoc.aiAnalyzing': 'Menganalisis dokumen…',
'newDoc.aiSummary': 'Ringkasan',
'newDoc.aiTags': 'Saran tag',
'newDoc.aiNoText': 'Tidak dapat membaca teks dari berkas ini — isi detail secara manual.',
'newDoc.aiFailed': 'Saran AI sedang tidak tersedia.',
'newDoc.aiClassApplied': 'Klasifikasi diisi dari saran AI — Anda dapat mengubahnya.',
  • [x] Step 6: Styles — append to web/src/styles/app.css:
/* Smart upload assistant panel (upload modal) */
.upload__ai { border: 1px solid var(--cds-border-subtle, #e0e0e0); border-radius: 6px; padding: 0.75rem; }
.upload__ai-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
.upload__ai-title { font-weight: 600; font-size: 0.875rem; }
.upload__ai-note { font-size: 0.75rem; margin: 0.25rem 0 0; }
.upload__ai-tags { display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; margin-top: 0.5rem; }
.upload__ai-tags .cds--tag { cursor: pointer; }
.upload__ai-tags-label { font-size: 0.75rem; margin-right: 0.25rem; }
  • [x] Step 7: Verify
cd web && npx tsc --noEmit && npx vite build
  • [x] Step 8: Commit
git add web/src/features/documents/UploadDocumentModal.tsx web/src/features/documents/DocumentsPage.tsx web/src/i18n/locales/en.ts web/src/i18n/locales/id.ts web/src/styles/app.css
git commit -m "feat(web): AI suggestions panel in the upload modal (confirm-before-save)"

Phase 3 — deploy + e2e

Task 9: Deploy, full curl e2e, cleanup

Files: none (verification only; fixes found here are committed with their own messages)

  • [x] Step 1: Deploy both images
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura web
  • [x] Step 2: Modules + demo intact
TOKEN=$(curl -s -X POST http://localhost:38080/api/v1/auth/dev-login -H 'Content-Type: application/json' -d '{"email":"director@obscura.local"}' | python3 -c 'import json,sys;print(json.load(sys.stdin)["token"])')
curl -s http://localhost:38080/api/v1/me -H "Authorization: Bearer $TOKEN" | python3 -c 'import json,sys;print(sorted(json.load(sys.stdin)["enabled_modules"]))'
# expect: ['ai', 'correspondence', 'esign', 'semantic', 'watermarking']
  • [x] Step 3: Analyze e2e + usage metering
S=/tmp/claude-1001/-home-efran-remote-development-obscura/1e19bbce-4349-4592-8b5b-ce3bd72141ab/scratchpad
printf 'PERJANJIAN KERJA SAMA\nantara PT Alpha Nusantara dan PT Beta Sejahtera.\nNilai kontrak Rp 250.000.000, berlaku 2026-2027.\nDokumen ini bersifat rahasia untuk kalangan internal.' > $S/e2e-analyze.txt
curl -s -X POST http://localhost:38080/api/v1/ai/analyze-upload -H "Authorization: Bearer $TOKEN" -F file=@$S/e2e-analyze.txt -F 'title=Perjanjian Alpha-Beta' | tee $S/analyze.json | python3 -m json.tool
AID=$(python3 -c "import json;print(json.load(open('$S/analyze.json'))['analysis_id'])")
# assert: analysis_id non-empty, summary/classification/tags plausible
docker exec deploy-postgres-1 psql -U obscura -d obscura -tc "SELECT feature, requests FROM ai_usage WHERE day = CURRENT_DATE AND feature IN ('summarize','classify') ORDER BY feature"
# assert: both rows present with requests >= 1
  • [x] Step 4: Save with analysis + EDITED summary → verify seeding
DOCID=$(curl -s -X POST http://localhost:38080/api/v1/documents -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"title":"E2E Smart Upload","classification":"internal"}' | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
curl -s -X POST "http://localhost:38080/api/v1/documents/$DOCID/versions" -H "Authorization: Bearer $TOKEN" -F file=@$S/e2e-analyze.txt -F "analysis_id=$AID" -F 'summary=EDITED BY USER: kontrak kerja sama Alpha-Beta.' | python3 -m json.tool
sleep 5   # detached RecordConfirmed runs a field-extraction model call
docker exec deploy-postgres-1 psql -U obscura -d obscura -tc "SELECT length(content_text) > 0, content_extracted_version = current_version FROM documents WHERE id = '$DOCID'"
# assert: t | t   (content seeded + marked — the async extractor was skipped)
docker exec deploy-postgres-1 psql -U obscura -d obscura -tc "SELECT summary FROM document_enrichment WHERE document_id = '$DOCID'"
# assert: 'EDITED BY USER: ...' (the user's edit won, not the AI summary)
curl -s "http://localhost:38080/api/v1/documents/$DOCID/ai/enrichment" -H "Authorization: Bearer $TOKEN" | python3 -m json.tool
# assert: exists=true, the edited summary + tags visible (AI Insights card source)
  • [x] Step 5: Foreign analysis_id → silent normal path
DOC2=$(curl -s -X POST http://localhost:38080/api/v1/documents -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"title":"E2E Foreign AID","classification":"none"}' | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
curl -s -o /dev/null -w '%{http_code}\n' -X POST "http://localhost:38080/api/v1/documents/$DOC2/versions" -H "Authorization: Bearer $TOKEN" -F file=@$S/e2e-analyze.txt -F 'analysis_id=deadbeefdeadbeefdeadbeefdeadbeef'
# assert: 201 (upload unaffected; async pipeline covers extraction)
  • [x] Step 6: Toggle off → 409; restore
curl -s -X PUT http://localhost:38080/api/v1/admin/ai/settings -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"chat_retention_days":30,"daily_token_budget":0,"upload_analyze_enabled":false}' -o /dev/null -w '%{http_code}\n'
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:38080/api/v1/ai/analyze-upload -H "Authorization: Bearer $TOKEN" -F file=@$S/e2e-analyze.txt
# assert: 409
curl -s -X PUT http://localhost:38080/api/v1/admin/ai/settings -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"chat_retention_days":30,"daily_token_budget":0,"upload_analyze_enabled":true}' -o /dev/null -w '%{http_code}\n'
# NOTE: read the CURRENT retention/budget from GET first and echo those values back —
# do not blind-write 30/0 if the live settings differ.
  • [x] Step 7: Cancel-leaves-nothing check — analyze once more (new analysis_id), then do NOT save; confirm no new rows appear:
curl -s -X POST http://localhost:38080/api/v1/ai/analyze-upload -H "Authorization: Bearer $TOKEN" -F file=@$S/e2e-analyze.txt > /dev/null
docker exec deploy-postgres-1 psql -U obscura -d obscura -tc "SELECT count(*) FROM documents WHERE title LIKE 'E2E%'"
# assert: exactly the 2 e2e docs from steps 4-5 — analyzing alone persisted nothing
  • [x] Step 8: UI click-through (backend paths verified via curl; browser click-through pending user, as with prior features) (user-facing states): open the SPA → New → Upload Document → drop a PDF → watch Analyzing → suggestions panel (summary editable, tags clickable, classification pre-set with note) → Save → doc detail shows the edited summary in AI Insights. Also verify the panel is absent when logged in as a user without the ai module (or after toggling off).

  • [x] Step 9: Cleanup

# trash + purge both e2e docs (existing endpoints; purge from trash for zero residue)
for D in $DOCID $DOC2; do
  curl -s -X POST "http://localhost:38080/api/v1/documents/$D/trash" -H "Authorization: Bearer $TOKEN" -o /dev/null
done
# then purge via the trash purge endpoint (check /trash routes) and re-assert:
docker exec deploy-postgres-1 psql -U obscura -d obscura -tc "SELECT count(*) FROM documents WHERE title LIKE 'E2E%'"
# assert: 0
rm -f $S/e2e-analyze.txt $S/analyze.json
  • [x] Step 10: Final modules assert + update ROADMAP if it lists this feature
curl -s http://localhost:38080/api/v1/me -H "Authorization: Bearer $TOKEN" | python3 -c 'import json,sys;print(sorted(json.load(sys.stdin)["enabled_modules"]))'

Self-review notes (done at plan time)

  • Spec coverage: trigger/toggle (T1, T4, T6), analyze endpoint + cache + no_text/409/429 (T3, T4), enrich.Analyze skipping fields (T2), confirm path with seed + MarkExtracted + RecordConfirmed + hook-skip (T5), registry-validated classification + tags chips + editable summary + soft-fail states (T8), save sequence + tags apply (T8), e2e incl. edited-summary-wins + cancel-zero-rows + foreign-id (T9). Out-of-scope items honored (no entities UI, upload only, single-instance cache, no re-analyze button).
  • Type consistency: enrichapp.Analysis (T2) is what analysisCache stores (T3), what AnalyzeUpload puts (T4), and what RecordConfirmed receives via entry.analysis (T5). UploadAnalysis (T7) is what the modal consumes (T8). Settings.UploadAnalyzeEnabled (T1) is read in T4 and written by T6.
  • Known judgment call: RecordConfirmed DOES run ExtractFields (detached, best-effort) so the stored enrichment is complete and the sweep — which would overwrite the user's edited summary — can safely skip the version. This is the one deviation from "skip the fields call" and it is off the response path.