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 withcd 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.yamledit:cd web && npm run gen:api(regeneratessrc/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 plaindocker restartdoes NOT re-read the env file). - After every deploy assert
/meenabled_modules == [ai, correspondence, esign, semantic, watermarking]and the demo stays intact. - Commit per task locally on
main. Do NOT push unless asked. NEVERgit 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. InGetAISettingsadd 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.yamlunder/api/v1/admin/ai/settings: add to the GET 200 schemaproperties:
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
stringsandkerneltogo/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, beforeclip):
// 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". Depsstruct (afterEnrich *enrichapp.Service, line ~81): addExtractor extractapp.Extractor.Serverstruct (nearenrich, line ~115): addextractor extractapp.Extractorandanalyses *analysisCache.- Constructor assignments (near
enrich: d.Enrich,line ~160): addextractor: d.Extractor,andanalyses: newAnalysisCache(),. - Route, in the authed
/aigroup (after line 306Post("/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 importscontext,log/slog, anddmsapp "github.com/Virtue-Digital-Indonesia/obscura/internal/dms/app"(check existing import block; add only what's missing). ReworkAddVersion(the section from themime :=lines through the finalwriteJSON):
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-dataschema (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 adduploadAnalyzeEnabled: d.upload_analyze_enabled,(and add the field to the localAiSettingsinterface — find it above line 515). InuseSaveAiSettings:
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
Togglefrom@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.tsxadd 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.
submitbecomes:
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').submitUploadbecomes:
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 existingnewDockey 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 whatanalysisCachestores (T3), whatAnalyzeUploadputs (T4), and whatRecordConfirmedreceives viaentry.analysis(T5).UploadAnalysis(T7) is what the modal consumes (T8).Settings.UploadAnalyzeEnabled(T1) is read in T4 and written by T6. - Known judgment call:
RecordConfirmedDOES runExtractFields(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.