Semantic Folder Suggestion (AI smart-filing) 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: When a user files a document, rank the caller's write-accessible folders by semantic similarity of the file's content to each folder's existing contents (centroid) and an optional admin description, and surface the top match as a one-click chip — all gated end-to-end by a new, separately-sellable semantic license module.
Architecture: A swappable Embedder port (mock / fastembed sidecar / OpenAI) feeds a pgvector index (document_embeddings). Documents embed best-effort/async when their extracted text lands (SetContentText hook) with a scheduled backfill safety-net. A new semantic application context computes folder centroids on the fly (avg(embedding) over document_embeddings ⋈ documents, restricted to the caller's write-accessible folders) and ranks them against the query embedding by cosine. The whole pipeline — embed-on-upload, backfill, the suggest endpoint, the UI chip, and the admin folder description — no-ops / hides when semantic is unlicensed (fail-closed).
Tech Stack: Go modular monolith (go/internal/, go/cmd/, chi, pgx v5, goose), pgvector (pgvector/pgvector:pg17, already the postgres image), FastAPI + fastembed (ONNX intfloat/multilingual-e5-small, 384-dim, CPU-only, air-gapped), React/Carbon SPA (web/src/), docker-compose.
Working discipline (applies to EVERY task — do not skip)
These override the writing-plans skill's default go test / TDD-failing-test cadence. The repo's test DSN points at the live demo Postgres, so:
- NEVER run
go test ./...(it writes the live demo DB). Verify Go viacd go && go build ./... && go vet ./.... - Verify the frontend via
cd web && npx tsc --noEmit && npx vite build. Runnpm run gen:apiafter any OpenAPI edit (it regeneratesweb/src/api/schema.ts; i18n en/id parity is enforced bytsc). - Deploy ONLY from the repo root, exactly:
bash docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build
(The--env-file deploy/mekari.envis mandatory — the esign callback fail-closed guard refuses to bootESIGN_PROVIDER=mekariwithoutMEKARI_CALLBACK_KEY; that key lives only in the gitignoreddeploy/mekari.env.) This now also buildsembed-sidecar. - After every deploy, assert the module set and that the demo is intact:
bash TOKEN=$(curl -s -XPOST localhost:38080/api/v1/auth/dev-login -H 'content-type: application/json' -d '{"email":"admin@obscura.local"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])') curl -s localhost:38080/api/v1/me -H "authorization: Bearer $TOKEN" | python3 -c 'import sys,json;print(sorted(json.load(sys.stdin)["enabled_modules"]))'
Expected after Phase 1 Task 1.2 onward:['ai', 'correspondence', 'esign', 'semantic', 'watermarking'](the four originals plussemantic). (dev-login can race startup right afterup -d; retry for ~30s.) - Fail-closed licensing is non-negotiable: with no
semanticlicense, no embeddings are written, the suggest endpoint 403s (module.not_licensed), the UI chip is hidden, and no sidecar is required to boot. - Commit per task locally on
main. Do NOT push unless the user asks. Clean up any test artifacts (documents/folders) you create during e2e. - pgvector dimension is fixed at 384 (e5-small). Switching to a different-dimension model later requires a re-embed + a dim migration — call this out, never silently change the
vector(384)column. - CLAUDE.md: any
.mdfile you create/modify this session must be uploaded:curl -F "file=@<path>.md" https://x056.think.val.id/upload, then give the user the returned URL.
File Structure
Phase 1 — module + seam (no behavior):
- Modify go/internal/platform/config/config.go — add "semantic" to KnownModules; add EmbedConfig + Embed field + Validate switch.
- Modify go/internal/httpapi/licensing_test.go — add "semantic" to the expected-disabled list (edit only, never run).
- Modify web/src/features/admin/LicensingTab.tsx — add 'semantic' to KNOWN_MODULES.
- Create go/internal/ai/app/embed.go — Embedder port, EmbedKind, EmbedInfo.
- Create go/internal/ai/adapters/embed_mock.go — deterministic MockEmbedder + hashVector.
- Create go/internal/ai/adapters/embed_select.go — SelectEmbedder factory (default mock).
- Regenerate deploy/secrets/obscura.license.json (demo license, +semantic).
- Modify LICENSING.md — add semantic to the module list prose.
Phase 2 — sidecar + pipeline:
- Create deploy/embed-sidecar/app.py, deploy/embed-sidecar/requirements.txt, deploy/Dockerfile.embed.
- Modify deploy/docker-compose.yml — embed-sidecar service + EMBED_* env + depends_on.
- Create go/internal/ai/adapters/embed_sidecar.go, go/internal/ai/adapters/embed_openai.go; extend embed_select.go.
- Create go/migrations/00071_pgvector_embeddings.sql.
- Create go/internal/semantic/app/service.go, go/internal/semantic/app/ports.go, go/internal/semantic/adapters/pg.go, go/internal/semantic/adapters/vector.go.
- Modify go/internal/dms/app/service.go + go/internal/dms/app/acl.go — WritableFolderIDs, DocumentEmbedSource, the embedHook field + SetEmbedHook + the SetContentText call.
- Modify go/internal/dms/adapters/acl_pg.go — WritableFolderIDs SQL.
- Modify go/cmd/obscura-server/wire.go — build embedder + semantic store/service, set enabled func, set dms hook, register semantic.embed_backfill.
- Modify go/cmd/obscura-server/jobs.go — runEmbedBackfill.
Phase 3 — suggest + chip:
- Modify go/internal/semantic/adapters/pg.go — FolderCentroids.
- Modify go/internal/semantic/app/service.go — SuggestFolders + SuggestQuery/Suggestion.
- Create go/internal/httpapi/handlers_semantic.go; modify go/internal/httpapi/server.go (Deps/Server/NewServer/route).
- Modify api/openapi.yaml — POST /api/v1/semantic/suggest-folder; then npm run gen:api.
- Create web/src/api/semantic.ts, web/src/features/semantic/i18n.ts; modify web/src/features/documents/NewDocumentModal.tsx, web/src/features/documents/DocumentsPage.tsx, web/src/i18n/locales/en.ts, web/src/i18n/locales/id.ts.
Phase 4 — admin folder descriptions:
- Create go/migrations/00072_folder_description.sql.
- Modify dms (Folder.Description, SetFolderDescription, repo/Store) + semantic (EmbedFolderDescription, FolderDescEmbeddings, blend in SuggestFolders).
- Modify api/openapi.yaml (PUT /folders/{id}/description) + regen; folder-edit UI + i18n.
Phase 1 — The semantic module + Embedder seam + mock + config
Goal: semantic is a real licensed module end-to-end and the Embedder port exists with a mock — no embedding behavior yet.
Task 1.1: Register semantic as a known module (backend + UI + test fixture)
Files:
- Modify: go/internal/platform/config/config.go:60
- Modify: go/internal/httpapi/licensing_test.go:77 (edit only — do NOT run go test)
- Modify: web/src/features/admin/LicensingTab.tsx:28
- Modify: LICENSING.md
- [ ] Step 1: Add
semanticto the canonical module set
In go/internal/platform/config/config.go, change line 60:
var KnownModules = []string{"correspondence", "watermarking", "ai", "esign", "semantic"}
This single change flows through ModuleList(), /me enabled_modules, requireModule, and licensegen's unknownModules() automatically (all iterate config.KnownModules).
- [ ] Step 2: Update the licensing test fixture (edit, never run)
In go/internal/httpapi/licensing_test.go find the expected-disabled module list (around line 77; it currently reads []string{"ai", "esign", "watermarking"} or similar — a core-only license disables every known premium module). Add "semantic" so the list stays in sync with KnownModules. For example if it reads:
want := []string{"ai", "esign", "watermarking"}
change it to (keep alphabetical to match the test's sort, if it sorts):
want := []string{"ai", "esign", "semantic", "watermarking"}
Read the surrounding assertion first and match its exact slice contents/order. Do not run go test — this edit only keeps the fixture honest for whenever tests run in CI.
- [ ] Step 3: Add
semanticto the UI's known-modules list
In web/src/features/admin/LicensingTab.tsx line 28:
const KNOWN_MODULES = ['correspondence', 'watermarking', 'ai', 'esign', 'semantic']
- [ ] Step 4: Document the module in
LICENSING.md
Find the prose/table that enumerates the premium modules (correspondence / watermarking / ai / esign) and add a row/line for semantic — "Semantic folder suggestion (AI smart-filing): pgvector embedding index over documents; suggests the best folder at upload time." Keep the format identical to the existing entries.
- [ ] Step 5: Verify build/typecheck
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...
cd /home/efran/remote-development/obscura/web && npx tsc --noEmit
Expected: both clean. (No deploy yet — the demo license still lacks semantic, so /me won't show it until Task 1.2.)
- [ ] Step 6: Upload the changed
.mdand commit
curl -F "file=@/home/efran/remote-development/obscura/LICENSING.md" https://x056.think.val.id/upload
cd /home/efran/remote-development/obscura
git add go/internal/platform/config/config.go go/internal/httpapi/licensing_test.go web/src/features/admin/LicensingTab.tsx LICENSING.md
git commit -m "feat(licensing): register the semantic premium module"
Give the user the upload URL.
Task 1.2: Regenerate the demo license to include semantic + verify on deploy
Files:
- Regenerate: deploy/secrets/obscura.license.json (gitignored — not committed)
Depends on Task 1.1 Step 1:
licensegenrejects unknown modules, sosemanticmust already be inKnownModulesor this fatals with "unknown module(s) [semantic]".
- [ ] Step 1: Regenerate the signed demo license with the dev key
The current demo payload is {"customer":"Obscura Demo (dev)","modules":["correspondence","watermarking","ai","esign"],"seats":100,"expiry":"2099-12-31T23:59:59Z"}. Reproduce it with semantic added (the license is canonical — regenerate, never hand-edit the base64):
cd /home/efran/remote-development/obscura/go && go run ./cmd/licensegen \
-private-key "$(cat ../deploy/secrets/license_dev_ed25519.key)" \
-customer "Obscura Demo (dev)" \
-modules correspondence,watermarking,ai,esign,semantic \
-seats 100 \
-expiry 2099-12-31 \
-out ../deploy/secrets/obscura.license.json
Expected stderr: licensegen: signing with public key 3tVBsf3z3mdBKcdPSoV85w4BlZccBORIN8P89Y+9XWo= (the dev key). The file is written world-readable (0644) so the distroless nonroot UID 65532 can read it.
- [ ] Step 2: Deploy and assert the module set
cd /home/efran/remote-development/obscura
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build
Then run the post-deploy assertion from the discipline block. Expected: ['ai', 'correspondence', 'esign', 'semantic', 'watermarking']. Confirm the demo is otherwise intact (log in to http://localhost:8091, browse a folder).
- [ ] Step 3: Commit (no license file — it is gitignored)
Nothing to commit here (the only artifact, deploy/secrets/obscura.license.json, is gitignored). Record in the task notes that the demo license was regenerated. If git status shows it as untracked-but-ignored, leave it.
Task 1.3: Define the Embedder port
Files:
- Create: go/internal/ai/app/embed.go
- [ ] Step 1: Write the port
package app
import "context"
// EmbedKind selects the text role for asymmetric embedding models (the e5 family),
// which prepend "query:" or "passage:" before encoding. Symmetric models and the mock
// ignore it.
type EmbedKind string
const (
EmbedQuery EmbedKind = "query"
EmbedPassage EmbedKind = "passage"
)
// EmbedInfo identifies the active embedding backend and its output dimension. Dim MUST
// equal the pgvector column dimension the embeddings are stored in — a mismatch is a
// runtime insert error, not a compile error.
type EmbedInfo struct {
Provider string
Model string
Dim int
}
// Embedder is the swappable embedding backend, mirroring ChatProvider. Implemented by the
// fastembed sidecar and OpenAI-compatible adapters, and a deterministic mock for tests/dev.
// Embed encodes a batch and returns exactly one vector per input text, each of length
// Info().Dim.
type Embedder interface {
Embed(ctx context.Context, kind EmbedKind, texts []string) ([][]float32, error)
Info() EmbedInfo
}
- [ ] Step 2: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...
Expected: clean.
- [ ] Step 3: Commit
git add go/internal/ai/app/embed.go
git commit -m "feat(ai): add Embedder port mirroring ChatProvider"
Task 1.4: Mock embedder + SelectEmbedder factory
Files:
- Create: go/internal/ai/adapters/embed_mock.go
- Create: go/internal/ai/adapters/embed_select.go
- [ ] Step 1: Write the deterministic mock
package adapters
import (
"context"
"crypto/sha256"
"math"
"github.com/Virtue-Digital-Indonesia/obscura/internal/ai/app"
)
// MockEmbedder produces deterministic, L2-normalized pseudo-embeddings from a hash of each
// input. Stable (so tests/dev are reproducible) but non-semantic — it is NOT meaningful for
// real ranking, so the demo runs the fastembed sidecar instead.
type MockEmbedder struct {
Dim int
}
// NewMockEmbedder builds a mock of the given dimension (falls back to 384).
func NewMockEmbedder(dim int) *MockEmbedder {
if dim <= 0 {
dim = 384
}
return &MockEmbedder{Dim: dim}
}
// Embed returns one deterministic unit vector per text. kind is folded into the hash so the
// query and passage forms of the same text differ slightly, mirroring an asymmetric model.
func (m *MockEmbedder) Embed(_ context.Context, kind app.EmbedKind, texts []string) ([][]float32, error) {
out := make([][]float32, len(texts))
for i, t := range texts {
out[i] = hashVector(string(kind)+"\x00"+t, m.Dim)
}
return out, nil
}
// Info identifies the mock backend.
func (m *MockEmbedder) Info() app.EmbedInfo {
return app.EmbedInfo{Provider: "mock", Model: "mock", Dim: m.Dim}
}
// hashVector expands a seed string into a deterministic L2-normalized vector of width dim by
// hashing (seed||counter) blocks and mapping bytes to [-1,1).
func hashVector(seed string, dim int) []float32 {
v := make([]float32, dim)
var counter byte
idx := 0
for idx < dim {
h := sha256.Sum256(append([]byte(seed), counter))
for _, b := range h {
if idx >= dim {
break
}
v[idx] = (float32(b) / 127.5) - 1.0
idx++
}
counter++
}
var norm float64
for _, x := range v {
norm += float64(x) * float64(x)
}
norm = math.Sqrt(norm)
if norm == 0 {
return v
}
for i := range v {
v[i] = float32(float64(v[i]) / norm)
}
return v
}
var _ app.Embedder = (*MockEmbedder)(nil)
- [ ] Step 2: Write the factory (Phase-1 form: only mock exists)
package adapters
import "github.com/Virtue-Digital-Indonesia/obscura/internal/ai/app"
// SelectEmbedder returns an Embedder for the given config, mirroring SelectProvider. Unknown
// or unset providers fall back to the deterministic mock so the seam compiles and runs with
// no sidecar. The "sidecar" and "openai" cases are added in Phase 2.
func SelectEmbedder(provider, sidecarURL, openAIBaseURL, openAIKey, model string, dim int) app.Embedder {
switch provider {
default:
return NewMockEmbedder(dim)
}
}
Note: a
switchwith only adefaultis intentional — Phase 2 Task 2.3 addscase "sidecar"/case "openai".go vetdoes not flag this.
- [ ] Step 3: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...
Expected: clean.
- [ ] Step 4: Commit
git add go/internal/ai/adapters/embed_mock.go go/internal/ai/adapters/embed_select.go
git commit -m "feat(ai): add deterministic mock embedder + SelectEmbedder factory"
Task 1.5: EmbedConfig
Files:
- Modify: go/internal/platform/config/config.go (struct field at line 34 region; new struct near the AIConfig at line 170; Validate at line 234 region)
- [ ] Step 1: Add the
Embedfield toConfig
In the Config struct, after AI AIConfig (line 34) add:
AI AIConfig
Embed EmbedConfig
- [ ] Step 2: Add the
EmbedConfigstruct
Immediately after the AIConfig struct (after line 175) add:
// EmbedConfig selects the embedding backend for the semantic module (pgvector index).
// Provider "mock" (the default) needs no service and is non-semantic; "sidecar" calls the
// local FastAPI embed-sidecar (air-gapped, model baked into the image); "openai" calls an
// OpenAI-compatible /v1/embeddings endpoint. Dim MUST equal the vector(N) column dimension
// in the embeddings migration (384 for multilingual-e5-small) — switching to a model of a
// different dimension requires a re-embed + a dim migration, NOT just an env change.
// SidecarURL defaults to the host-mapped port for `go run` outside compose (mirroring how
// GotenbergURL defaults to localhost:33000); compose overrides it to the in-network address.
type EmbedConfig struct {
Provider string `env:"EMBED_PROVIDER" envDefault:"mock"`
SidecarURL string `env:"EMBED_SIDECAR_URL" envDefault:"http://localhost:38000"`
Model string `env:"EMBED_MODEL" envDefault:"intfloat/multilingual-e5-small"`
Dim int `env:"EMBED_DIM" envDefault:"384"`
OpenAIBaseURL string `env:"EMBED_OPENAI_BASE_URL"`
OpenAIKey string `env:"EMBED_OPENAI_API_KEY"`
}
- [ ] Step 3: Validate the provider whitelist (fail-closed for openai)
In Validate(), after the ESignProvider switch / Mekari guard and before if c.DatabaseURL == "" (around line 245), add:
switch c.Embed.Provider {
case "mock", "sidecar", "openai":
default:
return fmt.Errorf("config: invalid EMBED_PROVIDER %q (want mock|sidecar|openai)", c.Embed.Provider)
}
if c.Embed.Provider == "openai" && c.Embed.OpenAIKey == "" {
return fmt.Errorf("config: EMBED_PROVIDER=openai requires EMBED_OPENAI_API_KEY")
}
if c.Embed.Dim <= 0 {
return fmt.Errorf("config: EMBED_DIM must be positive")
}
- [ ] Step 4: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...
Expected: clean. (Embed is parsed automatically by env.ParseAs[Config](); no manual registration.)
- [ ] Step 5: Commit
git add go/internal/platform/config/config.go
git commit -m "feat(config): add EmbedConfig (EMBED_PROVIDER/URL/MODEL/DIM) with validation"
Phase 1 done: semantic is a licensed module (demo shows it), the Embedder seam exists with a mock + factory + config. No embeddings are produced yet.
Phase 2 — Sidecar + adapters + pgvector migration + embed pipeline + backfill
Goal: real embeddings get written for documents (best-effort on text-land + a backfill sweep), gated on semantic. Still no suggest endpoint.
Task 2.1: The embed-sidecar FastAPI service (fastembed, e5-small, air-gapped)
Files:
- Create: deploy/embed-sidecar/app.py
- Create: deploy/embed-sidecar/requirements.txt
- Create: deploy/Dockerfile.embed
- [ ] Step 1: Write
requirements.txt
deploy/embed-sidecar/requirements.txt:
fastembed>=0.3,<1.0
fastapi>=0.110,<1.0
uvicorn[standard]>=0.29,<1.0
- [ ] Step 2: Write the FastAPI app
deploy/embed-sidecar/app.py:
import os
from fastapi import FastAPI
from fastembed import TextEmbedding
from pydantic import BaseModel
MODEL_NAME = os.environ.get("EMBED_MODEL", "intfloat/multilingual-e5-small")
CACHE_DIR = os.environ.get("EMBED_CACHE_DIR", "/app/.fastembed_cache")
app = FastAPI()
# The model is baked into the image at build time (see Dockerfile.embed), so this loads
# from the local cache with no network access — air-gap safe.
_model = TextEmbedding(model_name=MODEL_NAME, cache_dir=CACHE_DIR)
# Probe the output dimension once so /healthz and /embed can report it.
_DIM = len(next(iter(_model.embed(["dimension probe"]))))
class EmbedRequest(BaseModel):
texts: list[str]
kind: str = "passage" # "query" | "passage"
@app.get("/healthz")
def healthz():
return {"status": "ok", "model": MODEL_NAME, "dim": _DIM}
@app.post("/embed")
def embed(req: EmbedRequest):
# e5 retrieval convention: prefix queries with "query: " and documents with "passage: ".
prefix = "query: " if req.kind == "query" else "passage: "
prefixed = [prefix + (t or "") for t in req.texts]
vectors = [v.tolist() for v in _model.embed(prefixed)]
return {"vectors": vectors, "model": MODEL_NAME, "dim": _DIM}
- [ ] Step 3: Write
Dockerfile.embed(bakes the model for air-gap)
deploy/Dockerfile.embed:
# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY deploy/embed-sidecar/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Bake the ONNX model into the image at build time so the runtime never reaches the internet
# (on-prem / air-gapped first). The cache dir matches EMBED_CACHE_DIR used by app.py.
ARG EMBED_MODEL=intfloat/multilingual-e5-small
ENV EMBED_MODEL=${EMBED_MODEL} EMBED_CACHE_DIR=/app/.fastembed_cache
RUN python -c "from fastembed import TextEmbedding; TextEmbedding(model_name='${EMBED_MODEL}', cache_dir='/app/.fastembed_cache')"
COPY deploy/embed-sidecar/ ./
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
The compose build context is the repo root (
context: ..), so COPY paths are repo-root-relative (deploy/embed-sidecar/...), exactly likeDockerfile.web.
- [ ] Step 4: Build the image in isolation to confirm the model resolves
cd /home/efran/remote-development/obscura
docker build -f deploy/Dockerfile.embed -t obscura-embed-sidecar:plancheck .
Expected: build succeeds; the RUN python -c ... line downloads + caches the model. If intfloat/multilingual-e5-small is NOT in this fastembed version's catalogue (the bake step errors with an unsupported-model message), fall back to another 384-dim model fastembed ships — try BAAI/bge-small-en-v1.5 (English-only, 384) — by setting --build-arg EMBED_MODEL=... AND updating the EMBED_MODEL default in compose (Task 2.2) + config (Task 1.5) to match; keep EMBED_DIM=384. Record the chosen model in the commit message.
- [ ] Step 5: Smoke the container locally (optional but recommended)
docker run --rm -p 38000:8000 --name embed-plancheck -d obscura-embed-sidecar:plancheck
sleep 8
curl -s localhost:38000/healthz
curl -s -XPOST localhost:38000/embed -H 'content-type: application/json' \
-d '{"texts":["kontrak kerja sama vendor"],"kind":"passage"}' | python3 -c 'import sys,json;d=json.load(sys.stdin);print("dim",d["dim"],"n",len(d["vectors"]),"len0",len(d["vectors"][0]))'
docker stop embed-plancheck
Expected: /healthz → {"status":"ok","model":"intfloat/multilingual-e5-small","dim":384}; embed → dim 384 n 1 len0 384.
- [ ] Step 6: Commit
git add deploy/embed-sidecar/ deploy/Dockerfile.embed
git commit -m "feat(embed-sidecar): fastembed e5-small FastAPI sidecar (air-gapped, 384-dim)"
Task 2.2: Wire the sidecar into compose + EMBED_* env
Files:
- Modify: deploy/docker-compose.yml
- [ ] Step 1: Add the
embed-sidecarservice
After the gotenberg service block (after line 35) and before the obscura service, add:
# CPU-only embedding sidecar (FastAPI + fastembed ONNX multilingual-e5-small, 384-dim).
# The model is baked into the image (air-gapped: no boot-time download). Reached by the
# backend over the compose network as http://embed-sidecar:8000 (container port), mirroring
# gotenberg. Only used when the `semantic` module is licensed.
embed-sidecar:
build:
context: ..
dockerfile: deploy/Dockerfile.embed
healthcheck:
# slim python has no curl — use a urllib one-liner.
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')"]
interval: 10s
timeout: 5s
retries: 12
ports: ["38000:8000"]
- [ ] Step 2: Add
EMBED_*to theobscuraenvironment block
In the obscura service environment: block, right after the GOTENBERG_URL line (line 69), add:
# Semantic module embedding backend. Defaults to the local sidecar so the DEMO shows
# real (non-mock) suggestions; override EMBED_PROVIDER=mock for an unlicensed/air-gapped
# build with no sidecar. EMBED_DIM MUST match the vector(384) migration + the model.
EMBED_PROVIDER: "${EMBED_PROVIDER:-sidecar}"
EMBED_SIDECAR_URL: "http://embed-sidecar:8000"
EMBED_MODEL: "${EMBED_MODEL:-intfloat/multilingual-e5-small}"
EMBED_DIM: "${EMBED_DIM:-384}"
EMBED_OPENAI_BASE_URL: "${EMBED_OPENAI_BASE_URL:-}"
EMBED_OPENAI_API_KEY: "${EMBED_OPENAI_API_KEY:-}"
- [ ] Step 3: Add a (soft) dependency so the sidecar starts with the stack
In the obscura service depends_on: block (lines 43-47), add:
embed-sidecar:
condition: service_started
Use
service_started, NOTservice_healthy: embed calls are best-effort (the embed-on-upload hook and the backfill sweep both tolerate a sidecar that is still loading), and the demo must boot even if the model is mid-load. The healthcheck still exists for operator visibility.
- [ ] Step 4: Deploy the full stack + assert intact
cd /home/efran/remote-development/obscura
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build
docker compose -f deploy/docker-compose.yml ps embed-sidecar
curl -s localhost:38000/healthz
Then the post-deploy /me enabled_modules assertion (expect the 5 modules) + log in to confirm the demo is intact.
- [ ] Step 5: Commit
git add deploy/docker-compose.yml
git commit -m "feat(deploy): run embed-sidecar + EMBED_* config in the compose stack"
Task 2.3: Sidecar + OpenAI embedder adapters + extend the factory
Files:
- Create: go/internal/ai/adapters/embed_sidecar.go
- Create: go/internal/ai/adapters/embed_openai.go
- Modify: go/internal/ai/adapters/embed_select.go
- [ ] Step 1: Write the sidecar adapter (
truncatealready exists in this package, used byopenai.go)
go/internal/ai/adapters/embed_sidecar.go:
package adapters
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/Virtue-Digital-Indonesia/obscura/internal/ai/app"
)
// SidecarEmbedder calls the local embed-sidecar (FastAPI + fastembed) over the compose
// network. The sidecar bakes the model into its image (air-gapped) and applies the e5
// query:/passage: prefixes based on kind.
type SidecarEmbedder struct {
baseURL string
model string
dim int
http *http.Client
}
// NewSidecarEmbedder constructs the sidecar client.
func NewSidecarEmbedder(baseURL, model string, dim int) *SidecarEmbedder {
return &SidecarEmbedder{
baseURL: strings.TrimRight(baseURL, "/"),
model: model,
dim: dim,
http: &http.Client{Timeout: 60 * time.Second},
}
}
// Embed POSTs the batch to the sidecar and returns one vector per text.
func (e *SidecarEmbedder) Embed(ctx context.Context, kind app.EmbedKind, texts []string) ([][]float32, error) {
if len(texts) == 0 {
return nil, nil
}
raw, _ := json.Marshal(map[string]any{"texts": texts, "kind": string(kind)})
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, e.baseURL+"/embed", bytes.NewReader(raw))
if err != nil {
return nil, err
}
httpReq.Header.Set("content-type", "application/json")
resp, err := e.http.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("embed sidecar request: %w", err)
}
defer resp.Body.Close()
rb, _ := io.ReadAll(resp.Body)
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("embed sidecar %d: %s", resp.StatusCode, truncate(string(rb), 300))
}
var out struct {
Vectors [][]float32 `json:"vectors"`
Model string `json:"model"`
Dim int `json:"dim"`
}
if err := json.Unmarshal(rb, &out); err != nil {
return nil, fmt.Errorf("embed sidecar decode: %w", err)
}
if len(out.Vectors) != len(texts) {
return nil, fmt.Errorf("embed sidecar: expected %d vectors, got %d", len(texts), len(out.Vectors))
}
return out.Vectors, nil
}
// Info identifies the sidecar backend.
func (e *SidecarEmbedder) Info() app.EmbedInfo {
return app.EmbedInfo{Provider: "sidecar", Model: e.model, Dim: e.dim}
}
var _ app.Embedder = (*SidecarEmbedder)(nil)
- [ ] Step 2: Write the OpenAI embedder adapter
go/internal/ai/adapters/embed_openai.go:
package adapters
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/Virtue-Digital-Indonesia/obscura/internal/ai/app"
)
// OpenAIEmbedder calls an OpenAI-compatible /v1/embeddings endpoint (OpenAI itself or a
// self-hosted gateway) — the connected/SaaS path. OpenAI embedding models are symmetric, so
// kind is ignored.
type OpenAIEmbedder struct {
baseURL string
apiKey string
model string
dim int
http *http.Client
}
// NewOpenAIEmbedder constructs the embeddings client. An empty baseURL defaults to the public
// API; an empty model defaults to text-embedding-3-small.
func NewOpenAIEmbedder(baseURL, apiKey, model string, dim int) *OpenAIEmbedder {
if baseURL == "" {
baseURL = "https://api.openai.com"
}
if model == "" {
model = "text-embedding-3-small"
}
return &OpenAIEmbedder{
baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey,
model: model,
dim: dim,
http: &http.Client{Timeout: 60 * time.Second},
}
}
// Embed POSTs the batch and returns one vector per text, index-ordered.
func (e *OpenAIEmbedder) Embed(ctx context.Context, _ app.EmbedKind, texts []string) ([][]float32, error) {
if len(texts) == 0 {
return nil, nil
}
raw, _ := json.Marshal(map[string]any{"model": e.model, "input": texts})
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, e.baseURL+"/v1/embeddings", bytes.NewReader(raw))
if err != nil {
return nil, err
}
httpReq.Header.Set("authorization", "Bearer "+e.apiKey)
httpReq.Header.Set("content-type", "application/json")
resp, err := e.http.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("openai embeddings request: %w", err)
}
defer resp.Body.Close()
rb, _ := io.ReadAll(resp.Body)
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("openai embeddings %d: %s", resp.StatusCode, truncate(string(rb), 300))
}
var out struct {
Data []struct {
Embedding []float32 `json:"embedding"`
Index int `json:"index"`
} `json:"data"`
}
if err := json.Unmarshal(rb, &out); err != nil {
return nil, fmt.Errorf("openai embeddings decode: %w", err)
}
if len(out.Data) != len(texts) {
return nil, fmt.Errorf("openai embeddings: expected %d vectors, got %d", len(texts), len(out.Data))
}
vecs := make([][]float32, len(texts))
for _, d := range out.Data {
if d.Index >= 0 && d.Index < len(vecs) {
vecs[d.Index] = d.Embedding
}
}
return vecs, nil
}
// Info identifies the OpenAI embeddings backend.
func (e *OpenAIEmbedder) Info() app.EmbedInfo {
return app.EmbedInfo{Provider: "openai", Model: e.model, Dim: e.dim}
}
var _ app.Embedder = (*OpenAIEmbedder)(nil)
- [ ] Step 3: Extend
SelectEmbedder
Replace the body of SelectEmbedder in go/internal/ai/adapters/embed_select.go:
func SelectEmbedder(provider, sidecarURL, openAIBaseURL, openAIKey, model string, dim int) app.Embedder {
switch provider {
case "sidecar":
return NewSidecarEmbedder(sidecarURL, model, dim)
case "openai":
return NewOpenAIEmbedder(openAIBaseURL, openAIKey, model, dim)
default:
return NewMockEmbedder(dim)
}
}
- [ ] Step 4: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...
Expected: clean.
- [ ] Step 5: Commit
git add go/internal/ai/adapters/embed_sidecar.go go/internal/ai/adapters/embed_openai.go go/internal/ai/adapters/embed_select.go
git commit -m "feat(ai): sidecar + openai embedder adapters; wire into SelectEmbedder"
Task 2.4: pgvector migration — document_embeddings
Files:
- Create: go/migrations/00071_pgvector_embeddings.sql
- [ ] Step 1: Write the migration (
documents.idisuuid;ON DELETE CASCADEso purges clean up automatically)
go/migrations/00071_pgvector_embeddings.sql:
-- +goose Up
-- Semantic module: a pgvector embedding per document for folder-suggestion (and later
-- semantic search). The vector(384) dimension matches multilingual-e5-small / EMBED_DIM;
-- switching to a model of a different dimension requires a re-embed + a dim migration.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE document_embeddings (
document_id uuid PRIMARY KEY REFERENCES documents(id) ON DELETE CASCADE,
version int NOT NULL,
model text NOT NULL,
embedding vector(384) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- HNSW index for cosine-distance ANN search over document embeddings.
CREATE INDEX document_embeddings_ann ON document_embeddings USING hnsw (embedding vector_cosine_ops);
-- +goose Down
DROP TABLE IF EXISTS document_embeddings;
-- Leave the `vector` extension installed; later objects may depend on it.
- [ ] Step 2: Apply by deploying (migrations run on boot via
MIGRATE_ON_BOOT=true)
cd /home/efran/remote-development/obscura
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura
docker compose -f deploy/docker-compose.yml logs --tail=40 obscura | grep -i "migrat\|error"
Then confirm the table exists:
docker compose -f deploy/docker-compose.yml exec -T postgres psql -U obscura -d obscura -c "\d document_embeddings"
Expected: the table prints with an embedding | vector(384) column and the document_embeddings_ann hnsw index. Run the /me module assertion + demo-intact check.
- [ ] Step 3: Commit
git add go/migrations/00071_pgvector_embeddings.sql
git commit -m "feat(db): pgvector extension + document_embeddings table (00071)"
Task 2.5: The semantic application context (store + service: embed pipeline)
Files:
- Create: go/internal/semantic/app/ports.go
- Create: go/internal/semantic/app/service.go
- Create: go/internal/semantic/adapters/vector.go
- Create: go/internal/semantic/adapters/pg.go
- [ ] Step 1: Write the ports
go/internal/semantic/app/ports.go:
// Package app is the semantic context: a pgvector embedding index over documents that powers
// folder suggestion (and, later, semantic search). It embeds documents via an ai/app.Embedder,
// stores vectors through a Store, and reads document text + the caller's write-accessible
// folders through a DocSource (satisfied by the dms Service). Everything no-ops when the
// `semantic` module is unlicensed (the Enabled predicate fails closed).
package app
import (
"context"
"github.com/Virtue-Digital-Indonesia/obscura/internal/kernel"
)
// Store is the pgvector persistence port (document_embeddings, and in Phase 4
// folder_description_embeddings).
type Store interface {
// UpsertDocEmbedding inserts or replaces a document's embedding (keyed by document_id).
UpsertDocEmbedding(ctx context.Context, docID string, version int, model string, vec []float32) error
// DocIDsNeedingEmbedding returns live documents (current_version > 0) whose embedding is
// missing or stale (different model or older version than current), newest first, capped
// at limit — the backfill working set.
DocIDsNeedingEmbedding(ctx context.Context, model string, limit int) ([]string, error)
}
// DocSource is the narrow slice of the dms Service the semantic context needs (declared here
// so semantic never imports the dms domain).
type DocSource interface {
// DocumentEmbedSource returns the title, extracted content_text, and current version for a
// document. kernel.ErrNotFound if absent.
DocumentEmbedSource(ctx context.Context, id string) (title, text string, version int, err error)
// WritableFolderIDs returns the ids of folders the subjects can write to (access_mode >= 2),
// or all live folders when bypass is true.
WritableFolderIDs(ctx context.Context, subjects []kernel.Subject, bypass bool) ([]string, error)
}
- [ ] Step 2: Write the vector text codec (dependency-light: encode/parse pgvector's
[a,b,c]text form; no new module)
go/internal/semantic/adapters/vector.go:
package adapters
import (
"fmt"
"strconv"
"strings"
)
// formatVector renders a []float32 as pgvector's text input form, e.g. "[0.1,0.2,0.3]". Bound
// as a text param and cast ::vector in SQL — avoids pulling in a pgvector codec dependency.
func formatVector(v []float32) string {
var b strings.Builder
b.Grow(len(v)*8 + 2)
b.WriteByte('[')
for i, x := range v {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(strconv.FormatFloat(float64(x), 'f', -1, 32))
}
b.WriteByte(']')
return b.String()
}
// parseVector parses pgvector's text output form ("[0.1,0.2,...]") into a []float32.
func parseVector(s string) ([]float32, error) {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "[")
s = strings.TrimSuffix(s, "]")
if s == "" {
return nil, nil
}
parts := strings.Split(s, ",")
v := make([]float32, len(parts))
for i, p := range parts {
f, err := strconv.ParseFloat(strings.TrimSpace(p), 32)
if err != nil {
return nil, fmt.Errorf("parse vector element %d: %w", i, err)
}
v[i] = float32(f)
}
return v, nil
}
- [ ] Step 3: Write the pgvector Store (mirror the existing pgx pool usage; pass
docIDstrings directly intouuidcolumns exactly likedms/adapters/content_pg.godoes)
go/internal/semantic/adapters/pg.go:
package adapters
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Virtue-Digital-Indonesia/obscura/internal/semantic/app"
)
// Store is the pgvector-backed persistence adapter for the semantic context.
type Store struct {
db *pgxpool.Pool
}
// NewStore builds the Store over the shared pgx pool.
func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} }
// UpsertDocEmbedding inserts or replaces a document's embedding row.
func (s *Store) UpsertDocEmbedding(ctx context.Context, docID string, version int, model string, vec []float32) error {
const q = `
INSERT INTO document_embeddings (document_id, version, model, embedding, updated_at)
VALUES ($1, $2, $3, $4::vector, now())
ON CONFLICT (document_id) DO UPDATE
SET version = EXCLUDED.version, model = EXCLUDED.model, embedding = EXCLUDED.embedding, updated_at = now()`
if _, err := s.db.Exec(ctx, q, docID, version, model, formatVector(vec)); err != nil {
return fmt.Errorf("upsert doc embedding: %w", err)
}
return nil
}
// DocIDsNeedingEmbedding returns live documents whose embedding is missing or stale.
func (s *Store) DocIDsNeedingEmbedding(ctx context.Context, model string, limit int) ([]string, error) {
const q = `
SELECT d.id::text
FROM documents d
LEFT JOIN document_embeddings e ON e.document_id = d.id
WHERE d.deleted_at IS NULL
AND d.current_version > 0
AND (e.document_id IS NULL OR e.model <> $1 OR e.version <> d.current_version)
ORDER BY d.created_at DESC
LIMIT $2`
rows, err := s.db.Query(ctx, q, model, limit)
if err != nil {
return nil, fmt.Errorf("list docs needing embedding: %w", err)
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
var _ app.Store = (*Store)(nil)
documents.deleted_atis added by a later migration than 00006; it is referenced by existing queries (AllFolderIDs,ListExpired), so the column exists.
- [ ] Step 4: Write the Service (embed pipeline; suggest is added in Phase 3)
go/internal/semantic/app/service.go:
package app
import (
"context"
"errors"
"log/slog"
"sync"
"time"
aiapp "github.com/Virtue-Digital-Indonesia/obscura/internal/ai/app"
"github.com/Virtue-Digital-Indonesia/obscura/internal/kernel"
)
// embedCharLimit bounds the text sent to the embedder per document. multilingual-e5-small has
// a ~512-token window (the sidecar truncates too); this caps the request payload.
const embedCharLimit = 2000
// Service owns the embedding pipeline + folder suggestion. enabled fails closed: until the
// composition root wires it to the live license, it reports false (no embeddings written).
type Service struct {
store Store
embedder aiapp.Embedder
docs DocSource
model string
dim int
logger *slog.Logger
mu sync.RWMutex
enabled func() bool
}
// NewService builds the semantic Service. The enabled predicate defaults to "always false"
// (fail-closed) until SetEnabled is called with the live license check.
func NewService(store Store, embedder aiapp.Embedder, docs DocSource, model string, dim int, logger *slog.Logger) *Service {
if logger == nil {
logger = slog.Default()
}
return &Service{
store: store,
embedder: embedder,
docs: docs,
model: model,
dim: dim,
logger: logger,
enabled: func() bool { return false },
}
}
// SetEnabled installs the live "is the semantic module licensed" predicate (read from the
// hot-swappable license). Called once by the composition root after the HTTP server exists.
func (s *Service) SetEnabled(fn func() bool) {
s.mu.Lock()
defer s.mu.Unlock()
if fn != nil {
s.enabled = fn
}
}
// Enabled reports whether the semantic module is currently licensed.
func (s *Service) Enabled() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.enabled()
}
// OnContentText is the embed-on-upload hook the dms Service calls (best-effort) after a
// document's content_text lands. It detaches from the request and embeds asynchronously so it
// never blocks the upload; a no-op when the module is unlicensed.
func (s *Service) OnContentText(docID string) {
if !s.Enabled() {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := s.EmbedDocument(ctx, docID); err != nil {
s.logger.Warn("semantic: embed-on-upload failed", "doc", docID, "err", err)
}
}()
}
// EmbedDocument embeds one document's (title + extracted text) and upserts the vector. Returns
// nil (skips) when the document has no embeddable text yet.
func (s *Service) EmbedDocument(ctx context.Context, docID string) error {
title, text, version, err := s.docs.DocumentEmbedSource(ctx, docID)
if err != nil {
if errors.Is(err, kernel.ErrNotFound) {
return nil
}
return err
}
body := buildEmbedText(title, text, "")
if body == "" {
return nil
}
vecs, err := s.embedder.Embed(ctx, aiapp.EmbedPassage, []string{body})
if err != nil {
return err
}
if len(vecs) != 1 || len(vecs[0]) != s.dim {
s.logger.Warn("semantic: unexpected embedding shape; skipping", "doc", docID, "want_dim", s.dim)
return nil
}
return s.store.UpsertDocEmbedding(ctx, docID, version, s.model, vecs[0])
}
// RunBackfill embeds up to limit documents whose embedding is missing or stale. Each document
// is best-effort (a per-doc error is logged, not fatal). Returns the number of candidates it
// processed. A no-op (0, nil) when unlicensed.
func (s *Service) RunBackfill(ctx context.Context, limit int) (int, error) {
if !s.Enabled() {
return 0, nil
}
ids, err := s.store.DocIDsNeedingEmbedding(ctx, s.model, limit)
if err != nil {
return 0, err
}
for _, id := range ids {
if err := s.EmbedDocument(ctx, id); err != nil {
s.logger.Warn("semantic: backfill embed failed", "doc", id, "err", err)
}
}
return len(ids), nil
}
// buildEmbedText joins the signals into the text to embed, clipped to embedCharLimit.
func buildEmbedText(title, text, filename string) string {
parts := make([]byte, 0, embedCharLimit+64)
add := func(s string) {
if s == "" {
return
}
if len(parts) > 0 {
parts = append(parts, '\n')
}
parts = append(parts, s...)
}
add(title)
add(filename)
add(text)
if len(parts) > embedCharLimit {
parts = parts[:embedCharLimit]
}
return string(parts)
}
- [ ] Step 5: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...
Expected: clean. (The Service compiles standalone; it is wired in Task 2.7.)
- [ ] Step 6: Commit
git add go/internal/semantic/
git commit -m "feat(semantic): pgvector store + embed pipeline service (embed-on-upload + backfill)"
Task 2.6: dms additions — writable folders, embed source, and the embed hook
Files:
- Modify: go/internal/dms/app/service.go (Repository port near lines 182-185; SetContentText at 1572; new Service methods)
- Modify: go/internal/dms/app/acl.go (Service wrapper near EffectiveDocumentAccess at 385)
- Modify: go/internal/dms/adapters/acl_pg.go (new WritableFolderIDs SQL near EffectiveReadAccess at 304)
- [ ] Step 1: Add
WritableFolderIDsto the Repository port
In go/internal/dms/app/service.go, in the Repository interface near the folder-ACL methods (after EffectiveReadAccess, line 185), add:
// WritableFolderIDs returns the ids of folders any of subjects can write to (folder_acl_read
// access_mode >= AccessReadWrite), excluding soft-deleted folders. bypass=true returns every
// live folder id; empty subjects returns nil.
WritableFolderIDs(ctx context.Context, subjects []kernel.Subject, bypass bool) ([]string, error)
- [ ] Step 2: Implement the SQL in the Store
In go/internal/dms/adapters/acl_pg.go, after EffectiveReadAccess (line 304), add. (Mirror the existing unnest($1::text[],$2::text[]) subject-join idiom; the folder_acl_read projection already stores the per-subject tier as access_mode.)
// WritableFolderIDs returns the live folders any of subjects can write to (access_mode >= 2).
// bypass returns every live folder id (delegates to AllFolderIDs); empty subjects returns nil.
func (s *Store) WritableFolderIDs(ctx context.Context, subjects []kernel.Subject, bypass bool) ([]string, error) {
if bypass {
return s.AllFolderIDs(ctx)
}
if len(subjects) == 0 {
return nil, nil
}
kinds := make([]string, len(subjects))
ids := make([]string, len(subjects))
for i, sub := range subjects {
kinds[i] = string(sub.Kind)
ids[i] = sub.ID
}
const q = `
SELECT DISTINCT r.folder_id::text
FROM folder_acl_read r
JOIN unnest($1::text[], $2::text[]) AS subj(kind, id)
ON r.subject_kind = subj.kind AND r.subject_id = subj.id
JOIN folders f ON f.id = r.folder_id AND f.deleted_at IS NULL
WHERE r.access_mode >= 2`
rows, err := s.db.Query(ctx, q, kinds, ids)
if err != nil {
return nil, fmt.Errorf("writable folder ids: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
Check the exact pool field name +
string(sub.Kind)usage against the existingEffectiveReadAccessimpl in this file and match it (e.g. if subjects are passed askinds/idsslices built differently, mirror that).fmtis already imported in this file (it builds errors); if not, add it.
- [ ] Step 3: Add the Service wrappers (
WritableFolderIDs+DocumentEmbedSource)
In go/internal/dms/app/acl.go, next to EffectiveDocumentAccess (line 385), add:
// WritableFolderIDs returns the ids of folders the subjects can write to (Contributor+), or
// all live folders when bypass is true. Used by the semantic suggest flow to bound candidate
// folders to where the caller may actually file.
func (s *Service) WritableFolderIDs(ctx context.Context, subjects []kernel.Subject, bypass bool) ([]string, error) {
return s.repo.WritableFolderIDs(ctx, subjects, bypass)
}
In go/internal/dms/app/service.go, near DocumentText (line 748), add:
// DocumentEmbedSource returns the inputs the semantic embedding pipeline needs for a document
// — its title, extracted content_text, and current version — without exposing the dms domain
// type. kernel.ErrNotFound if the document is absent.
func (s *Service) DocumentEmbedSource(ctx context.Context, id string) (title, text string, version int, err error) {
doc, err := s.repo.GetDocument(ctx, id)
if err != nil {
return "", "", 0, err
}
text, err = s.repo.GetContentText(ctx, id)
if err != nil {
return "", "", 0, err
}
return doc.Title, text, doc.CurrentVersion, nil
}
Confirm
domain.DocumentexposesCurrentVersion(it mapsdocuments.current_version); if the field is named differently, use that name.
- [ ] Step 4: Add the embed hook field + setter + fire it in
SetContentText
In go/internal/dms/app/service.go, find the Service struct definition (near the top, after the package docs). Add a field:
// embedHook, when set, is invoked (best-effort, non-fatal) after content_text is
// (re)written, so an out-of-context indexer (the semantic embedding pipeline) can react to
// freshly-extracted text. nil = no hook (core build / unlicensed). The hook owns its own
// goroutine + context.
embedHook func(docID string)
Add a setter near the constructor:
// SetEmbedHook installs the post-content-text hook (the semantic embedding trigger). Idempotent;
// nil clears it.
func (s *Service) SetEmbedHook(fn func(docID string)) { s.embedHook = fn }
In SetContentText (line 1572), after the successful s.repo.SetContentText(...) call, fire the hook. Change:
return s.repo.SetContentText(ctx, docID, text)
to:
if err := s.repo.SetContentText(ctx, docID, text); err != nil {
return err
}
if s.embedHook != nil {
s.embedHook(docID)
}
return nil
- [ ] Step 5: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...
Expected: clean.
- [ ] Step 6: Commit
git add go/internal/dms/app/service.go go/internal/dms/app/acl.go go/internal/dms/adapters/acl_pg.go
git commit -m "feat(dms): WritableFolderIDs, DocumentEmbedSource, and content-text embed hook"
Task 2.7: Wire the semantic service + backfill job in the composition root
Files:
- Modify: go/cmd/obscura-server/wire.go (AI wiring at 331; Deps at 341; after api is built at 379; scheduler registration at 264-327)
- Modify: go/cmd/obscura-server/jobs.go (new runEmbedBackfill)
- [ ] Step 1: Add the backfill job body
In go/cmd/obscura-server/jobs.go, add (mirrors the disposition sweep's page-cap-logging shape):
// embedBackfillPageSize bounds how many documents one backfill sweep embeds; logged when hit so
// older un-embedded documents are never silently starved.
const embedBackfillPageSize = 200
// runEmbedBackfill embeds documents whose semantic embedding is missing or stale (the safety net
// behind the best-effort embed-on-upload hook, and the catch-up when `semantic` is first
// licensed). A no-op when the module is unlicensed. Idempotent; safe at every boot.
func runEmbedBackfill(ctx context.Context, semantic *semanticapp.Service, logger *slog.Logger) error {
n, err := semantic.RunBackfill(ctx, embedBackfillPageSize)
if err != nil {
return err
}
if n == embedBackfillPageSize {
logger.Warn("semantic embed backfill hit its page cap; older documents may be deferred this run", "cap", embedBackfillPageSize)
}
return nil
}
Add the import to jobs.go:
semanticapp "github.com/Virtue-Digital-Indonesia/obscura/internal/semantic/app"
- [ ] Step 2: Build the embedder + semantic store/service near the AI wiring
In go/cmd/obscura-server/wire.go, right after the aiSvc := ... line (331-332), add:
embedder := aiadapters.SelectEmbedder(cfg.Embed.Provider, cfg.Embed.SidecarURL, cfg.Embed.OpenAIBaseURL, cfg.Embed.OpenAIKey, cfg.Embed.Model, cfg.Embed.Dim)
logger.Info("embed provider", "provider", embedder.Info().Provider, "model", embedder.Info().Model, "dim", embedder.Info().Dim)
semanticSvc := semanticapp.NewService(
semanticadapters.NewStore(database),
embedder,
dmsSvc, // satisfies semanticapp.DocSource (DocumentEmbedSource + WritableFolderIDs)
cfg.Embed.Model,
cfg.Embed.Dim,
logger,
)
Add the imports near the other aliased context imports (top of wire.go, by aiadapters/aiapp):
semanticadapters "github.com/Virtue-Digital-Indonesia/obscura/internal/semantic/adapters"
semanticapp "github.com/Virtue-Digital-Indonesia/obscura/internal/semantic/app"
databaseis the*pgxpool.Poolalready used for the other stores (e.g.aiSvc/correspondence wiring); confirm its variable name in this file and reuse it.
- [ ] Step 3: Inject
Semanticinto the HTTPDeps
In the httpapi.Deps{...} literal (line 341), after Ai: aiSvc, add:
Semantic: semanticSvc,
(The Deps.Semantic field + Server.semantic field + NewServer copy are added in Phase 3 Task 3.2; until then this line will not compile, so either do Task 3.2's Deps/Server edits together with this step, or temporarily omit this single line and add it in 3.2. Recommended: add the Deps.Semantic field now as part of this step — edit server.go Deps struct (after Ai *aiapp.Service, line 74) to add Semantic *semanticapp.Service, the Server struct (after ai, line 106) to add semantic *semanticapp.Service, and NewServer (after ai: d.Ai,, line 149) to add semantic: d.Semantic,, plus the semanticapp import in server.go. Then this injection compiles.)
- [ ] Step 4: Wire the live license predicate + the dms hook, after
apiis built
After the api := httpapi.NewServer(...) call completes (after line 379), add:
// The semantic pipeline reads the LIVE (hot-swappable) license so a license up/downgrade
// takes effect without a restart, mirroring esign's ModuleEnabled use. Fail-closed until set.
semanticSvc.SetEnabled(func() bool { return api.ModuleEnabled("semantic") })
// Embed each document when its extracted text lands (best-effort/async; no-op when unlicensed).
dmsSvc.SetEmbedHook(semanticSvc.OnContentText)
- [ ] Step 5: Register + trigger the backfill sweep
The scheduler is registered + EnsureRegistered at lines 264-321, before api exists. Register the embed backfill AFTER api/semanticSvc.SetEnabled (Step 4), then re-run EnsureRegistered (idempotent upsert) and trigger it once:
schedulerSvc.Register("semantic.embed_backfill", 1*time.Hour, func(ctx context.Context) error {
return runEmbedBackfill(ctx, semanticSvc, logger)
})
if err := schedulerSvc.EnsureRegistered(ctx); err != nil {
return fmt.Errorf("scheduler register (semantic): %w", err)
}
// Catch up immediately on a fresh deploy / first-time licensing (no-op when unlicensed).
if err := schedulerSvc.TriggerNow(ctx, "semantic.embed_backfill"); err != nil {
logger.Warn("could not trigger initial embed backfill", "err", err)
}
- [ ] Step 6: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...
Expected: clean.
- [ ] Step 7: Commit
git add go/cmd/obscura-server/wire.go go/cmd/obscura-server/jobs.go go/internal/httpapi/server.go
git commit -m "feat(semantic): wire embedder + service, license-gated embed hook + backfill sweep"
Task 2.8: Deploy + e2e — embeddings actually get written (and only when licensed)
Files: none (verification only)
- [ ] Step 1: Deploy + module assertion
cd /home/efran/remote-development/obscura
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build
Run the /me enabled_modules assertion (expect the 5 modules) + confirm demo intact + curl -s localhost:38000/healthz → ok.
- [ ] Step 2: Create a doc, set its content text, confirm an embedding row appears
TOKEN=$(curl -s -XPOST localhost:38080/api/v1/auth/dev-login -H 'content-type: application/json' -d '{"email":"admin@obscura.local"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
# create a document (root folder)
DOC=$(curl -s -XPOST localhost:38080/api/v1/documents -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"title":"PLAN-EMBED-TEST kontrak vendor"}' | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))')
echo "doc=$DOC"
# land extracted text (fires the embed hook)
curl -s -XPUT "localhost:38080/api/v1/documents/$DOC/content-text" -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"text":"Perjanjian kerja sama pengadaan barang dan jasa antara perusahaan dan vendor pihak ketiga."}' -o /dev/null -w "%{http_code}\n"
sleep 4
docker compose -f deploy/docker-compose.yml exec -T postgres psql -U obscura -d obscura -c "SELECT document_id, version, model, vector_dims(embedding) FROM document_embeddings WHERE document_id='$DOC';"
Expected: one row, model = intfloat/multilingual-e5-small, vector_dims = 384. (Adjust the create-document body to whatever POST /documents requires — check CreateDocument/useCreateDocument; classification may default. If the create needs folder_id/classification, add them.)
- [ ] Step 3: Backfill sweep covers a pre-existing doc
Confirm the boot TriggerNow ran (or trigger again): within ~35s of boot the semantic.embed_backfill task runs (30s RunDue tick). Re-query document_embeddings count vs documents count (live, current_version>0) to confirm pre-existing demo docs that have content_text got embedded:
docker compose -f deploy/docker-compose.yml exec -T postgres psql -U obscura -d obscura -c "SELECT (SELECT count(*) FROM document_embeddings) AS embedded, (SELECT count(*) FROM documents WHERE deleted_at IS NULL AND current_version>0 AND content_text<>'') AS have_text;"
Expected: embedded grows toward have_text over subsequent sweeps. (Docs with empty content_text are intentionally not embedded in v1.)
- [ ] Step 4: Fail-closed check — unlicensed ⇒ no embeddings
EMBED_PROVIDER=mock docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build embed-sidecar obscura >/dev/null 2>&1 || true
# Temporarily install a core-only license to prove the gate (regenerate WITHOUT semantic),
# create a doc + set content-text, and assert NO new embedding row appears, then restore the
# 5-module demo license and redeploy.
Do this carefully and restore the demo license afterward (Task 1.2 Step 1 command, with semantic back in). The point: with semantic absent from the license, semanticSvc.Enabled() is false, so OnContentText and RunBackfill no-op — no document_embeddings rows for the new doc. Verify, then restore + redeploy + re-assert the 5 modules. (If a full license-swap is heavy, it is acceptable to instead reason from code that Enabled() gates both paths and skip the live downgrade — but note that in the task log.)
- [ ] Step 5: Clean up test docs
Delete the PLAN-EMBED-TEST document(s) you created (move to trash + purge, or via the UI). Confirm the demo is back to its baseline.
- [ ] Step 6: Commit (if any verification scratch files were created, remove them; otherwise nothing to commit)
No code changes in this task; record the e2e results in the task notes.
Phase 2 done: documents embed best-effort on text-land + via the backfill sweep, written to pgvector, strictly gated on semantic.
Phase 3 — Folder centroids + suggest endpoint + the upload chip
Goal: POST /api/v1/semantic/suggest-folder ranks the caller's writable folders; NewDocumentModal shows a one-click suggested-folder chip. The demo shows real suggestions after this phase.
Task 3.1: Folder centroids + SuggestFolders ranking
Files:
- Modify: go/internal/semantic/adapters/pg.go (add FolderCentroids)
- Modify: go/internal/semantic/app/ports.go (add FolderCentroids to Store)
- Modify: go/internal/semantic/app/service.go (add SuggestQuery, Suggestion, SuggestFolders, cosine)
- [ ] Step 1: Add the centroid type + Store method to the port
In go/internal/semantic/app/ports.go, add the type and extend Store:
// FolderCentroid is a folder's id, materialized path, and the centroid (mean) of its member
// documents' embeddings.
type FolderCentroid struct {
FolderID string
Path string
Centroid []float32
}
Add to the Store interface:
// FolderCentroids returns avg(embedding) per folder over live member documents, restricted
// to folderIDs. Folders in folderIDs with no embedded members are omitted.
FolderCentroids(ctx context.Context, folderIDs []string) ([]FolderCentroid, error)
- [ ] Step 2: Implement
FolderCentroids(pgvector ships theavg(vector)aggregate; cast the centroid to text and parse it — same codec as the upsert)
In go/internal/semantic/adapters/pg.go:
// FolderCentroids returns the mean embedding per folder over its live member documents,
// restricted to folderIDs. Computed on the fly (v1); materializing is the documented scale path.
func (s *Store) FolderCentroids(ctx context.Context, folderIDs []string) ([]app.FolderCentroid, error) {
if len(folderIDs) == 0 {
return nil, nil
}
const q = `
SELECT f.id::text, f.path, avg(e.embedding)::text
FROM document_embeddings e
JOIN documents d ON d.id = e.document_id AND d.deleted_at IS NULL
JOIN folders f ON f.id = d.folder_id AND f.deleted_at IS NULL
WHERE d.folder_id::text = ANY($1::text[])
GROUP BY f.id, f.path`
rows, err := s.db.Query(ctx, q, folderIDs)
if err != nil {
return nil, fmt.Errorf("folder centroids: %w", err)
}
defer rows.Close()
var out []app.FolderCentroid
for rows.Next() {
var id, path, vecText string
if err := rows.Scan(&id, &path, &vecText); err != nil {
return nil, err
}
vec, perr := parseVector(vecText)
if perr != nil {
return nil, perr
}
out = append(out, app.FolderCentroid{FolderID: id, Path: path, Centroid: vec})
}
return out, rows.Err()
}
- [ ] Step 3: Add the suggest types + ranking to the Service
In go/internal/semantic/app/service.go, add (also add "math" and "sort" to the imports):
// SuggestQuery is the input to a folder suggestion: any of title/text/filename (at least one).
type SuggestQuery struct {
Title string
Text string
Filename string
}
// Suggestion is one ranked folder. Score is cosine similarity in [-1,1] (clamped to >= 0 for
// display); higher is better.
type Suggestion struct {
FolderID string
Path string
Score float64
}
// SuggestFolders ranks the caller's write-accessible folders by cosine similarity of the query
// to each folder's member-document centroid, returning the top k (default 3). An empty index
// or no writable folders yields an empty slice (the UI then shows nothing). The route gates on
// requireModule("semantic"); this method assumes the caller is already authorized.
func (s *Service) SuggestFolders(ctx context.Context, subjects []kernel.Subject, bypass bool, q SuggestQuery, k int) ([]Suggestion, error) {
if k <= 0 {
k = 3
}
folderIDs, err := s.docs.WritableFolderIDs(ctx, subjects, bypass)
if err != nil {
return nil, err
}
if len(folderIDs) == 0 {
return nil, nil
}
body := buildEmbedText(q.Title, q.Text, q.Filename)
if body == "" {
return nil, nil
}
vecs, err := s.embedder.Embed(ctx, aiapp.EmbedQuery, []string{body})
if err != nil {
return nil, err
}
if len(vecs) != 1 || len(vecs[0]) != s.dim {
return nil, nil
}
query := vecs[0]
centroids, err := s.store.FolderCentroids(ctx, folderIDs)
if err != nil {
return nil, err
}
out := make([]Suggestion, 0, len(centroids))
for _, c := range centroids {
score := cosine(query, c.Centroid)
if score < 0 {
score = 0
}
out = append(out, Suggestion{FolderID: c.FolderID, Path: c.Path, Score: score})
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Score > out[j].Score })
if len(out) > k {
out = out[:k]
}
return out, nil
}
// cosine returns the cosine similarity of two equal-length vectors (0 if degenerate).
func cosine(a, b []float32) float64 {
if len(a) != len(b) || len(a) == 0 {
return 0
}
var dot, na, nb float64
for i := range a {
dot += float64(a[i]) * float64(b[i])
na += float64(a[i]) * float64(a[i])
nb += float64(b[i]) * float64(b[i])
}
if na == 0 || nb == 0 {
return 0
}
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
- [ ] Step 4: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...
Expected: clean.
- [ ] Step 5: Commit
git add go/internal/semantic/
git commit -m "feat(semantic): folder centroids + cosine SuggestFolders ranking"
Task 3.2: Suggest HTTP endpoint + route (and finish the Deps/Server wiring)
Files:
- Create: go/internal/httpapi/handlers_semantic.go
- Modify: go/internal/httpapi/server.go (Deps field 74; Server field 106; NewServer 149; route at 283; semanticapp import) — if not already done in Task 2.7 Step 3, do it here.
- [ ] Step 1: Ensure
Deps.Semantic/Server.semantic/NewServerare wired (from Task 2.7 Step 3; if you deferred it, add now)
server.go Deps struct — after Ai *aiapp.Service (line 74):
Semantic *semanticapp.Service
server.go Server struct — after ai *aiapp.Service (line 106):
semantic *semanticapp.Service
NewServer — after ai: d.Ai, (line 149):
semantic: d.Semantic,
Add the import to server.go:
semanticapp "github.com/Virtue-Digital-Indonesia/obscura/internal/semantic/app"
- [ ] Step 2: Write the handler (mirror
handlers_ai.go:PrincipalFrom,s.isContentAdmin,json.NewDecoder,writeJSON,writeProblem)
go/internal/httpapi/handlers_semantic.go:
package httpapi
import (
"encoding/json"
"net/http"
"strings"
"github.com/Virtue-Digital-Indonesia/obscura/internal/kernel"
semanticapp "github.com/Virtue-Digital-Indonesia/obscura/internal/semantic/app"
)
// SemanticSuggestFolder ranks the caller's write-accessible folders by semantic similarity of
// the supplied {title, text, filename} to each folder's contents. Gated by
// requireModule("semantic"). Returns the top-K (default 3) as {folder_id, path, score}; an
// empty index or no writable folders yields an empty list.
func (s *Server) SemanticSuggestFolder(w http.ResponseWriter, r *http.Request) {
p, _ := PrincipalFrom(r.Context())
var body struct {
Title string `json:"title"`
Text string `json:"text"`
Filename string `json:"filename"`
K int `json:"k"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "request.invalid_json", Message: "invalid request body"})
return
}
if strings.TrimSpace(body.Title) == "" && strings.TrimSpace(body.Text) == "" && strings.TrimSpace(body.Filename) == "" {
writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "semantic.query_required", Message: "a title, text, or filename is required"})
return
}
sugs, err := s.semantic.SuggestFolders(
r.Context(),
p.Subjects(),
s.isContentAdmin(r.Context(), p),
semanticapp.SuggestQuery{Title: body.Title, Text: body.Text, Filename: body.Filename},
body.K,
)
if err != nil {
writeProblem(w, err)
return
}
out := make([]map[string]any, 0, len(sugs))
for _, sg := range sugs {
out = append(out, map[string]any{"folder_id": sg.FolderID, "path": sg.Path, "score": sg.Score})
}
writeJSON(w, http.StatusOK, map[string]any{"suggestions": out})
}
Confirm the helper names against
handlers_ai.go:PrincipalFrom(used by nav handlers),writeJSON,writeProblem. IfPrincipalFromhas a different exact name in this codebase, mirror whathandlers_dms_nav.go:ListFoldersuses.
- [ ] Step 3: Register the route (top-level protected group, next to the non-doc AI routes at
server.go:283)
After line 283 (r.With(s.requireModule("ai")).Post("/ai/classify", s.Classify)), add:
// Semantic folder suggestion (premium "semantic" module): rank the caller's
// write-accessible folders for an about-to-be-filed document. 403
// module.not_licensed when unlicensed; hidden in the UI likewise.
r.With(s.requireModule("semantic")).Post("/semantic/suggest-folder", s.SemanticSuggestFolder)
- [ ] Step 4: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...
Expected: clean.
- [ ] Step 5: Commit
git add go/internal/httpapi/handlers_semantic.go go/internal/httpapi/server.go
git commit -m "feat(httpapi): POST /semantic/suggest-folder gated by requireModule(semantic)"
Task 3.3: OpenAPI + frontend chip
Files:
- Modify: api/openapi.yaml (add the path + schemas)
- Regenerate: web/src/api/schema.ts (npm run gen:api)
- Create: web/src/api/semantic.ts
- Create: web/src/features/semantic/i18n.ts
- Modify: web/src/i18n/locales/en.ts, web/src/i18n/locales/id.ts
- Modify: web/src/features/documents/NewDocumentModal.tsx
- Modify: web/src/features/documents/DocumentsPage.tsx
- [ ] Step 1: Add the OpenAPI path
In api/openapi.yaml, add under paths: (match the file's existing style; find a sibling like /ai/classify for the exact request/response shape conventions used):
/semantic/suggest-folder:
post:
tags: [semantic]
summary: Suggest folders for an about-to-be-filed document (premium "semantic" module)
operationId: suggestFolder
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title: { type: string }
text: { type: string }
filename: { type: string }
k: { type: integer }
responses:
'200':
description: Ranked folder suggestions (possibly empty)
content:
application/json:
schema:
type: object
properties:
suggestions:
type: array
items:
type: object
properties:
folder_id: { type: string }
path: { type: string }
score: { type: number }
'403':
description: The semantic module is not licensed in this deployment
- [ ] Step 2: Regenerate the typed client
cd /home/efran/remote-development/obscura/web && npm run gen:api
Confirm web/src/api/schema.ts now contains /semantic/suggest-folder.
- [ ] Step 3: Write the data hook (mirror
useDocAiClassifyinweb/src/api/ai.ts:45-56, incl. itstoErrorhelper)
web/src/api/semantic.ts:
import { useMutation } from '@tanstack/react-query'
import { api } from './client'
function toError(e: unknown): Error {
const p = e as { detail?: string; title?: string } | undefined
return new Error(p?.detail || p?.title || 'Request failed')
}
export interface FolderSuggestion {
folderId: string
path: string
score: number
}
export interface SuggestFolderInput {
title?: string
text?: string
filename?: string
}
export function useSuggestFolder() {
return useMutation<FolderSuggestion[], Error, SuggestFolderInput>({
mutationFn: async (input) => {
const { data, error } = await api.POST('/api/v1/semantic/suggest-folder', {
body: { title: input.title, text: input.text, filename: input.filename },
})
if (error) throw toError(error)
return (data?.suggestions ?? []).map((s) => ({
folderId: s.folder_id ?? '',
path: s.path ?? '',
score: s.score ?? 0,
}))
},
})
}
If the generated path constant differs (e.g. no
/api/v1prefix inpaths), match exactly what the otherapi.POST(...)calls inweb/src/api/ai.tsuse.
- [ ] Step 4: Add the i18n slice
web/src/features/semantic/i18n.ts:
export const en = {
semantic: {
suggestTitle: 'Suggested folder',
suggest: 'Suggest a folder',
suggesting: 'Finding the best folder…',
useFolder: 'Use this folder',
match: '{{pct}}% match',
none: 'No suggestion available',
error: 'Could not suggest a folder',
},
}
export const id: typeof en = {
semantic: {
suggestTitle: 'Folder yang disarankan',
suggest: 'Sarankan folder',
suggesting: 'Mencari folder terbaik…',
useFolder: 'Gunakan folder ini',
match: '{{pct}}% cocok',
none: 'Tidak ada saran',
error: 'Tidak dapat menyarankan folder',
},
}
In web/src/i18n/locales/en.ts: add import { en as semanticEn } from '@/features/semantic/i18n' near the aiEn import (line 13), and spread ...semanticEn next to ...aiEn (line 717). Do the mirror in id.ts (semanticId, ...semanticId). The id: typeof en constraint keeps shapes identical (tsc enforces it).
- [ ] Step 5: Add the chip to
NewDocumentModal(gate onmoduleEnabled(useMe().data?.enabledModules, 'semantic'); route the accepted folder out viaNewDocumentInput.folderId)
In web/src/features/documents/NewDocumentModal.tsx:
- Imports:
import { useMe } from '@/api/me'
import { moduleEnabled } from '@/lib/nav'
import { useSuggestFolder } from '@/api/semantic'
import { Tag, InlineLoading } from '@carbon/react'
import { useTranslation } from 'react-i18next'
- Extend
NewDocumentInput(line 16) with the optional accepted folder:
folderId?: string | null
- In the component (near line 58-60):
const me = useMe()
const semanticEnabled = moduleEnabled(me.data?.enabledModules, 'semantic')
const suggest = useSuggestFolder()
const { t } = useTranslation()
const [pickedFolderId, setPickedFolderId] = useState<string | null>(null)
const [pickedPath, setPickedPath] = useState<string>('')
- Reset in the open-effect (lines 73-82): add
setPickedFolderId(null); setPickedPath(''); suggest.reset(). - Render the chip block — only when
semanticEnabledand a file/title exists — as a new<div className="newdoc__field">(matching the existing fields at lines 136/147/156), placed after the file/title inputs:
{semanticEnabled && (
<div className="newdoc__field">
<Button
kind="ghost"
size="sm"
disabled={suggest.isPending}
onClick={() =>
suggest.mutate(
// No extracted text exists at create time (extraction is async, post-upload), so the
// query is driven by the title + filename. See the "documented limitations" note.
{ title, filename: file?.name },
{
onSuccess: (rows) => {
if (rows.length > 0) {
setPickedFolderId(rows[0].folderId)
setPickedPath(rows[0].path)
}
},
},
)
}
>
{t('semantic.suggest')}
</Button>
{suggest.isPending && <InlineLoading description={t('semantic.suggesting')} />}
{suggest.isError && <div className="newdoc__hint">{t('semantic.error')}</div>}
{pickedFolderId && (
<Tag type="blue" filter onClose={() => { setPickedFolderId(null); setPickedPath('') }}>
{t('semantic.suggestTitle')}: {pickedPath}
</Tag>
)}
</div>
)}
- Include the accepted folder in the
onSubmitpayload (line ~108): addfolderId: pickedFolderIdto theNewDocumentInputobject passed toonSubmit.
The
title/file/modevariables already exist in the modal state (NewDocumentInputfields). TheButtonis already imported (it renders the submit/cancel actions). The chip uses a Carbon<Tag>withfilter+onClose(the dismissible-tag pattern used elsewhere, e.g.RequestSignatureModal).
- [ ] Step 6: Honor the accepted folder in
DocumentsPage
In web/src/features/documents/DocumentsPage.tsx, submitNewDoc (lines 90-116): destructure folderId from the input and prefer it over currentFolderId:
const submitNewDoc = async ({ title, classification, docType, mode, formatId, customVars, file, folderId }: NewDocumentInput) => {
const targetFolderId = folderId ?? currentFolderId
// ... pass targetFolderId into createDocument.mutateAsync(...) at line 94 in place of currentFolderId
}
- [ ] Step 7: Verify
cd /home/efran/remote-development/obscura/web && npx tsc --noEmit && npx vite build
Expected: clean (i18n en/id parity enforced by tsc).
- [ ] Step 8: Commit
cd /home/efran/remote-development/obscura
git add api/openapi.yaml web/src/api/schema.ts web/src/api/semantic.ts web/src/features/semantic/ web/src/i18n/locales/en.ts web/src/i18n/locales/id.ts web/src/features/documents/NewDocumentModal.tsx web/src/features/documents/DocumentsPage.tsx
git commit -m "feat(web): suggested-folder chip in NewDocumentModal (semantic-gated) + OpenAPI"
Task 3.4: Deploy + e2e — suggestion ranks the right folder; hidden when unlicensed
Files: none (verification only)
-
[ ] Step 1: Deploy + module assertion (full stack; expect 5 modules, demo intact, sidecar healthy).
-
[ ] Step 2: Seed two distinct folders, embed their contents, and suggest
TOKEN=$(curl -s -XPOST localhost:38080/api/v1/auth/dev-login -H 'content-type: application/json' -d '{"email":"admin@obscura.local"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
# create two folders (match the POST /folders body the app uses)
FA=$(curl -s -XPOST localhost:38080/api/v1/folders -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"name":"PLAN-Kontrak"}' | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))')
FB=$(curl -s -XPOST localhost:38080/api/v1/folders -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"name":"PLAN-SDM"}' | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))')
# file a contract-y doc in FA and an HR-y doc in FB, each with content-text (fires embedding)
DA=$(curl -s -XPOST localhost:38080/api/v1/documents -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d "{\"title\":\"PLAN kontrak A\",\"folder_id\":\"$FA\"}" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))')
curl -s -XPUT "localhost:38080/api/v1/documents/$DA/content-text" -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"text":"Perjanjian kontrak pengadaan barang jasa vendor pemasok harga termin pembayaran."}' -o /dev/null
DB=$(curl -s -XPOST localhost:38080/api/v1/documents -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d "{\"title\":\"PLAN SDM B\",\"folder_id\":\"$FB\"}" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))')
curl -s -XPUT "localhost:38080/api/v1/documents/$DB/content-text" -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"text":"Surat keputusan pengangkatan karyawan cuti tahunan penggajian sumber daya manusia."}' -o /dev/null
sleep 5
# suggest a NEW contract-like upload — expect PLAN-Kontrak (FA) as the top folder
curl -s -XPOST localhost:38080/api/v1/semantic/suggest-folder -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"title":"kontrak baru","text":"perjanjian pengadaan vendor termin pembayaran"}' | python3 -m json.tool
Expected: suggestions[0].path is the PLAN-Kontrak folder, with score clearly above the SDM folder's. (Real semantic ordering depends on the sidecar; with EMBED_PROVIDER=mock the ordering is meaningless — this e2e requires the real sidecar, which the demo runs.)
- [ ] Step 3: Unlicensed ⇒ 403 / hidden
Reason from code (route is requireModule("semantic")) or do a temporary core-only license swap (as in Task 2.8 Step 4) and confirm:
curl -s -o /dev/null -w "%{http_code}\n" -XPOST localhost:38080/api/v1/semantic/suggest-folder -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"title":"x"}'
Expected with semantic unlicensed: 403. Restore the 5-module demo license + redeploy + re-assert afterward.
- [ ] Step 4: UI smoke
Open http://localhost:8091, start a new document (upload), confirm the "Suggest a folder" affordance renders and accepting the chip pre-selects the folder for creation. Then create with the suggestion and confirm the doc lands in that folder.
- [ ] Step 5: Clean up all
PLAN-*test folders/docs; confirm the demo is back to baseline.
Phase 3 done: end-to-end semantic folder suggestion, gated, demonstrable in the demo.
Phase 4 — Optional admin folder descriptions (blended into the centroid)
Goal: admins can set a folder "purpose" description; when semantic is licensed it is embedded and blended into the folder's profile, sharpening suggestions for new/empty folders.
Task 4.1: Migration — folders.description + folder_description_embeddings
Files:
- Create: go/migrations/00072_folder_description.sql
- [ ] Step 1: Write the migration (
folders.idisuuid)
go/migrations/00072_folder_description.sql:
-- +goose Up
-- Optional admin-set folder purpose ("what this folder is for"). Harmless core metadata; its
-- USE (embedding it to sharpen semantic folder suggestions) is gated on the `semantic` module.
ALTER TABLE folders ADD COLUMN description text NOT NULL DEFAULT '';
-- The embedding of a folder's description (semantic module). Its own table so a description
-- change re-embeds without touching member-document embeddings. vector(384) matches EMBED_DIM.
CREATE TABLE folder_description_embeddings (
folder_id uuid PRIMARY KEY REFERENCES folders(id) ON DELETE CASCADE,
embedding vector(384) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE IF EXISTS folder_description_embeddings;
ALTER TABLE folders DROP COLUMN IF EXISTS description;
- [ ] Step 2: Apply (deploy obscura) + confirm
cd /home/efran/remote-development/obscura
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura
docker compose -f deploy/docker-compose.yml exec -T postgres psql -U obscura -d obscura -c "\d folder_description_embeddings" -c "\d folders"
Expected: the new table + folders.description column. Module assertion + demo intact.
- [ ] Step 3: Commit
git add go/migrations/00072_folder_description.sql
git commit -m "feat(db): folders.description + folder_description_embeddings (00072)"
Task 4.2: dms — carry + set the folder description
Files:
- Modify: go/internal/dms/domain/document.go (Folder.Description)
- Modify: go/internal/dms/app/service.go (Repository SetFolderDescription; Service SetFolderDescription)
- Modify: go/internal/dms/adapters/pg.go (folderColumns includes description; SetFolderDescription SQL; GetFolder/ListFolders scan the new column)
- [ ] Step 1: Add the domain field
In go/internal/dms/domain/document.go, add Description string to the Folder struct (line 29 region).
- [ ] Step 2: Repository + Service method
Add to the Repository interface (folders region, near RenameFolderTree):
// SetFolderDescription sets folders.description. kernel.ErrNotFound if absent.
SetFolderDescription(ctx context.Context, folderID, description string) error
Add a Service method (near other folder mutators):
// SetFolderDescription sets a folder's admin purpose description. Returns a hook signal so the
// caller (composition root) can re-embed it when the semantic module is licensed.
func (s *Service) SetFolderDescription(ctx context.Context, folderID, description string) error {
if folderID == "" {
return &kernel.Error{Kind: kernel.ErrValidation, Code: "dms.folder.id_required", Message: "folder id is required"}
}
if err := s.repo.SetFolderDescription(ctx, folderID, description); err != nil {
return err
}
if s.folderDescHook != nil {
s.folderDescHook(folderID, description)
}
return nil
}
Add a folderDescHook func(folderID, description string) field to the Service struct + a SetFolderDescHook setter (mirror the embedHook pattern from Task 2.6 Step 4).
- [ ] Step 3: Adapter SQL + scans
In go/internal/dms/adapters/pg.go:
- Add description to folderColumns (line 56 region) and to every Folder row-scan (GetFolder, ListFolders) — append &f.Description to the scan targets in the same order as the column list. (Find all places that scan a domain.Folder and update them, or the scan will misalign — this is the main risk; grep for folderColumns usage.)
- Add:
// SetFolderDescription sets folders.description.
func (s *Store) SetFolderDescription(ctx context.Context, folderID, description string) error {
ct, err := s.db.Exec(ctx, `UPDATE folders SET description = $2 WHERE id = $1 AND deleted_at IS NULL`, folderID, description)
if err != nil {
return fmt.Errorf("set folder description: %w", err)
}
if ct.RowsAffected() == 0 {
return kernel.ErrNotFound
}
return nil
}
Confirm
kernel.ErrNotFoundis the sentinel used in this file (or the&kernel.Error{...}form) and match the existing convention.
- [ ] Step 4: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...
Expected: clean.
- [ ] Step 5: Commit
git add go/internal/dms/
git commit -m "feat(dms): folder description column, setter, and change hook"
Task 4.3: semantic — embed the description + blend into the centroid
Files:
- Modify: go/internal/semantic/app/ports.go (Store: upsert/delete/get description embeddings)
- Modify: go/internal/semantic/adapters/pg.go (the three SQL methods)
- Modify: go/internal/semantic/app/service.go (EmbedFolderDescription; blend in SuggestFolders)
- Modify: go/cmd/obscura-server/wire.go (set the dms folder-desc hook)
- [ ] Step 1: Extend the Store port
Add to Store:
// UpsertFolderDescEmbedding inserts/replaces a folder description's embedding.
UpsertFolderDescEmbedding(ctx context.Context, folderID string, vec []float32) error
// DeleteFolderDescEmbedding removes a folder description embedding (empty description).
DeleteFolderDescEmbedding(ctx context.Context, folderID string) error
// FolderDescEmbeddings returns the description embedding for each of folderIDs that has one.
FolderDescEmbeddings(ctx context.Context, folderIDs []string) (map[string][]float32, error)
- [ ] Step 2: Implement them
In go/internal/semantic/adapters/pg.go:
func (s *Store) UpsertFolderDescEmbedding(ctx context.Context, folderID string, vec []float32) error {
const q = `
INSERT INTO folder_description_embeddings (folder_id, embedding, updated_at)
VALUES ($1, $2::vector, now())
ON CONFLICT (folder_id) DO UPDATE SET embedding = EXCLUDED.embedding, updated_at = now()`
if _, err := s.db.Exec(ctx, q, folderID, formatVector(vec)); err != nil {
return fmt.Errorf("upsert folder desc embedding: %w", err)
}
return nil
}
func (s *Store) DeleteFolderDescEmbedding(ctx context.Context, folderID string) error {
if _, err := s.db.Exec(ctx, `DELETE FROM folder_description_embeddings WHERE folder_id = $1`, folderID); err != nil {
return fmt.Errorf("delete folder desc embedding: %w", err)
}
return nil
}
func (s *Store) FolderDescEmbeddings(ctx context.Context, folderIDs []string) (map[string][]float32, error) {
if len(folderIDs) == 0 {
return map[string][]float32{}, nil
}
rows, err := s.db.Query(ctx, `SELECT folder_id::text, embedding::text FROM folder_description_embeddings WHERE folder_id::text = ANY($1::text[])`, folderIDs)
if err != nil {
return nil, fmt.Errorf("folder desc embeddings: %w", err)
}
defer rows.Close()
out := make(map[string][]float32)
for rows.Next() {
var id, vecText string
if err := rows.Scan(&id, &vecText); err != nil {
return nil, err
}
v, perr := parseVector(vecText)
if perr != nil {
return nil, perr
}
out[id] = v
}
return out, rows.Err()
}
- [ ] Step 3:
EmbedFolderDescriptionon the Service
// EmbedFolderDescription (re)embeds a folder's admin description, or removes the embedding when
// the description is blank. A no-op when unlicensed. The composition root wires this to the dms
// folder-description change hook.
func (s *Service) EmbedFolderDescription(folderID, description string) {
if !s.Enabled() {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if strings.TrimSpace(description) == "" {
if err := s.store.DeleteFolderDescEmbedding(ctx, folderID); err != nil {
s.logger.Warn("semantic: delete folder desc embedding failed", "folder", folderID, "err", err)
}
return
}
vecs, err := s.embedder.Embed(ctx, aiapp.EmbedPassage, []string{clipEmbed(description)})
if err != nil || len(vecs) != 1 || len(vecs[0]) != s.dim {
s.logger.Warn("semantic: embed folder desc failed", "folder", folderID, "err", err)
return
}
if err := s.store.UpsertFolderDescEmbedding(ctx, folderID, vecs[0]); err != nil {
s.logger.Warn("semantic: upsert folder desc embedding failed", "folder", folderID, "err", err)
}
}()
}
// clipEmbed clips text to embedCharLimit for embedding.
func clipEmbed(t string) string {
if len(t) > embedCharLimit {
return t[:embedCharLimit]
}
return t
}
Add "strings" to the service imports.
- [ ] Step 4: Blend the description embedding into the ranking
In SuggestFolders, after fetching centroids, also fetch description embeddings and blend (average member-centroid + description embedding when both exist; otherwise use whichever exists). Replace the centroid loop:
descs, err := s.store.FolderDescEmbeddings(ctx, folderIDs)
if err != nil {
return nil, err
}
// A folder can be ranked on its member centroid, its description embedding, or the blend.
type prof struct {
path string
vec []float32
}
profiles := make(map[string]prof, len(centroids))
for _, c := range centroids {
profiles[c.FolderID] = prof{path: c.Path, vec: c.Centroid}
}
for fid, dvec := range descs {
if p, ok := profiles[fid]; ok {
profiles[fid] = prof{path: p.path, vec: averageVectors(p.vec, dvec)}
}
// description-only folders (no embedded members) need their path; fetched lazily below.
}
out := make([]Suggestion, 0, len(profiles))
for fid, p := range profiles {
score := cosine(query, p.vec)
if score < 0 {
score = 0
}
out = append(out, Suggestion{FolderID: fid, Path: p.path, Score: score})
}
Add the blend helper:
// averageVectors returns the element-wise mean of two equal-length vectors (or the non-empty
// one if the other is empty / mismatched).
func averageVectors(a, b []float32) []float32 {
if len(a) == 0 {
return b
}
if len(b) == 0 || len(a) != len(b) {
return a
}
out := make([]float32, len(a))
for i := range a {
out[i] = (a[i] + b[i]) / 2
}
return out
}
Scope note (YAGNI): a description-only folder with no embedded members won't appear in
centroids, so it has nopathhere. Ranking such empty folders requires also returning their path from a description-embedding query. For v1 keep the blend limited to folders that already have member documents (the common case), and leave "rank empty folders by description alone" as a documented follow-up — OR extendFolderDescEmbeddingsto also return the folder path and seedprofilesfor description-only folders. Pick one and state it in the commit; the simplest correct v1 is members-only blend.
- [ ] Step 5: Wire the dms folder-desc hook in
wire.go
After dmsSvc.SetEmbedHook(...) (Task 2.7 Step 4), add:
dmsSvc.SetFolderDescHook(semanticSvc.EmbedFolderDescription)
- [ ] Step 6: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...
Expected: clean.
- [ ] Step 7: Commit
git add go/internal/semantic/ go/cmd/obscura-server/wire.go
git commit -m "feat(semantic): embed folder descriptions and blend into suggestion ranking"
Task 4.4: Folder description — endpoint + admin/edit UI
Files:
- Modify: go/internal/httpapi/server.go (route PUT /folders/{id}/description, gated folder.write) + a handler (in handlers_dms_nav.go or a small new handler)
- Modify: api/openapi.yaml (+ npm run gen:api)
- Modify: the folder edit/admin UI (the existing rename-folder affordance) + i18n
- [ ] Step 1: Backend handler + route
Add a handler (mirror RenameFolder in handlers_dms_nav.go): decode {description}, call s.dms.SetFolderDescription(ctx, chi.URLParam(r,"id"), body.Description), writeJSON 200. Register near the folder routes (server.go:293 region):
r.With(s.requirePerm("folder.write")).Put("/folders/{id}/description", s.SetFolderDescription)
The description field itself is harmless core metadata (settable regardless of semantic); only its embedding use is gated (the hook no-ops when unlicensed). So the route is NOT requireModule("semantic").
- [ ] Step 2: OpenAPI + regen
Add PUT /folders/{id}/description (request {description: string}, 200) to api/openapi.yaml; cd web && npm run gen:api.
-
[ ] Step 3: UI — in the folder edit affordance (the rename modal / folder settings), add a "Folder purpose" textarea bound to a
useSetFolderDescriptionmutation (mirror the rename hook inweb/src/api/documents.ts). Show a hint that the description improves AI suggestions ONLY whenmoduleEnabled(me.data?.enabledModules, 'semantic'); the field itself always shows for folder admins. Add i18n keys toweb/src/features/semantic/i18n.ts(and theidmirror):descLabel,descHint,descSave. -
[ ] Step 4: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...
cd /home/efran/remote-development/obscura/web && npx tsc --noEmit && npx vite build
Expected: both clean.
- [ ] Step 5: Commit
cd /home/efran/remote-development/obscura
git add go/internal/httpapi/ api/openapi.yaml web/src/
git commit -m "feat(folders): admin folder-purpose description (UI + endpoint)"
Task 4.5: Deploy + e2e — a description sharpens suggestions
Files: none (verification only)
-
[ ] Step 1: Deploy + module assertion (5 modules, demo intact).
-
[ ] Step 2: Set a description on an empty/sparse folder, confirm it is embedded + influences ranking
TOKEN=$(curl -s -XPOST localhost:38080/api/v1/auth/dev-login -H 'content-type: application/json' -d '{"email":"admin@obscura.local"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
FC=$(curl -s -XPOST localhost:38080/api/v1/folders -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"name":"PLAN-Hukum"}' | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))')
# give it a purpose + at least one member doc so it appears in centroids
curl -s -XPUT "localhost:38080/api/v1/folders/$FC/description" -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"description":"Dokumen hukum, perjanjian, dan kepatuhan regulasi perusahaan."}' -o /dev/null -w "%{http_code}\n"
sleep 4
docker compose -f deploy/docker-compose.yml exec -T postgres psql -U obscura -d obscura -c "SELECT folder_id, vector_dims(embedding) FROM folder_description_embeddings WHERE folder_id='$FC';"
Expected: a folder_description_embeddings row with vector_dims = 384. Then run a suggest-folder query whose text matches the description and confirm the folder ranks appropriately (needs ≥1 embedded member doc in the folder per the v1 members-only blend scope note).
- [ ] Step 3: Clear the description ⇒ embedding row removed
curl -s -XPUT "localhost:38080/api/v1/folders/$FC/description" -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"description":""}' -o /dev/null
sleep 3
docker compose -f deploy/docker-compose.yml exec -T postgres psql -U obscura -d obscura -c "SELECT count(*) FROM folder_description_embeddings WHERE folder_id='$FC';"
Expected: 0.
- [ ] Step 4: Clean up all
PLAN-*test folders/docs; confirm the demo is back to baseline.
Phase 4 done — feature complete.
Final review (after all phases)
- [ ] Dispatch a final whole-implementation code review (subagent) over the full diff: confirm fail-closed gating on every path (embed hook, backfill, suggest endpoint, description embedding); confirm the demo license carries all 5 modules and
/mereflects them; confirm nogo testwas run; confirmgo build ./... && go vet ./...andcd web && npx tsc --noEmit && npx vite buildare clean; confirm allPLAN-*test artifacts are cleaned up. - [ ] Use superpowers:finishing-a-development-branch (the work is on
mainper repo discipline; do NOT push unless the user asks). - [ ] Upload every
.mdcreated/modified this session tohttps://x056.think.val.id/uploadand give the user the URLs.
Notes / documented limitations (carry into commits where relevant)
- No in-process raw-bytes→text extractor exists (extraction is deferred to an external Python sidecar that PUTs
/content-text). So the suggest endpoint embeds the supplied{title, text, filename}— for a brand-new upload with no client-side text, the suggestion is driven by title+filename. Server-side ad-hoc extraction of uploaded bytes is a documented follow-up (would require an extract endpoint on the extractor sidecar). - Centroids are computed on the fly (SQL
avg()per request). Materializing them (afolder_embeddingstable refreshed on a sweep) is the documented scale path; not built (YAGNI for the single-node on-prem target). - pgvector dimension is fixed at 384. Switching embedding models to a different dimension requires a re-embed + a
vector(N)dim migration — not a runtime toggle. - Mock embedder is non-semantic. Deployments running
EMBED_PROVIDER=mockget gated, stored, but meaningless vectors; the demo runs the real sidecar. - Phase 4 v1 blends descriptions for folders that already have member documents. Ranking a description-only (no members) empty folder is a documented follow-up.