Skip to content

Commit 2c305f6

Browse files
aksOpsclaude
andcommitted
feat: Phase 4+5 UI merge + ops/retrieval quality
Ships the user-visible unification (one SPA for docs + notes) plus backend observability and retrieval improvements. Delivered in parallel by two sub-agents with no cross-conflicts. Phase 4 — UI merge (no new npm deps): - ui/src/components/notes/: FolderTree, NoteView (inline md+wikilinks renderer), NoteEditor, LinkPanel, NotesGraphView - ui/src/components/shared/UnifiedSearchPanel — single query hits both /api/search (docs) and /api/projects/{p}/search (notes); results tagged [doc] / [entity] / [note] - ui/src/hooks/useNotes.ts, useProjects.ts with ?project= URL sync - TopNav: Notes tab + project selector dropdown - App: 3-col notes view (tree | view/editor+graph | links) - GraphView: optional notes-graph overlay toggle - Design tokens: --accent-notes hue for distinguishing note nodes - Rebuilt ui/dist/: 312 kB JS (94 kB gzip) + 10 kB CSS - Backend hook: 20-line GET /api/projects handler Phase 5 — Ops + retrieval (pure-Go, no CGO, no new deps): - internal/api/claims_handlers.go + MCP get_entity_claims tool + store.ClaimsForEntity / ListClaims — claims finally surfaced - internal/api/metrics.go — Prometheus text format, stdlib-only: docsiq_requests_total, request_duration_seconds, projects_total, notes_total, build_info. Public /metrics endpoint. - internal/api/request_id.go — middleware generates crypto/rand ID (or passes through X-Request-ID), sets response header, exposes RequestIDFromContext - cmd --log-format=text|json (DOCSIQ_LOG_FORMAT) for structured logging in production - internal/pipeline incremental re-index: SHA-256 + mtime short- circuit (skips unchanged files), new indexed_mtime column, Prune() for deleted files, docsiq index --prune flag - Per-project LLM override: cfg.LLMOverrides + LLMConfigForProject + ProviderForProject helper (data layer; incremental rollout into search handlers deferred) Test baseline: 362 → 405 subtests (+43). All packages green. No sqlite-vec / CGO introduced — stays pure-Go as planned for Phases 0-5. Known scope limits: - Per-project LLM override plumbing into search is the helper only; every LocalSearch/GlobalSearch callsite still uses the root provider (intentional — broader refactor) - Pure-Go HNSW vector index deferred alongside sqlite-vec to whenever CGO becomes acceptable - FolderTree lacks new-note UX; notes must be created via API/MCP Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b46b17b commit 2c305f6

39 files changed

Lines changed: 3318 additions & 314 deletions

cmd/index.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ var (
2020
indexMaxPages int
2121
indexMaxDepth int
2222
indexSkipSitemap bool
23+
indexPrune bool
2324
)
2425

2526
var indexCmd = &cobra.Command{
@@ -48,6 +49,21 @@ var indexCmd = &cobra.Command{
4849
return nil
4950
}
5051

52+
if indexPrune {
53+
// Prune does not need an LLM provider. Opening one here would
54+
// force the user to have valid LLM credentials just to remove
55+
// dangling rows — explicitly skip NewProvider.
56+
pl := pipeline.New(st, nil, cfg)
57+
slog.Info("🗑️ pruning documents whose source files are missing")
58+
n, err := pl.Prune(cmd.Context())
59+
if err != nil {
60+
slog.Error("❌ prune failed", "err", err)
61+
return err
62+
}
63+
slog.Info("✅ prune complete", "removed", n)
64+
return nil
65+
}
66+
5167
prov, err := llm.NewProvider(&cfg.LLM)
5268
if err != nil {
5369
return fmt.Errorf("llm provider: %w", err)
@@ -98,5 +114,6 @@ func init() {
98114
indexCmd.Flags().IntVar(&indexMaxPages, "max-pages", 500, "Maximum pages to crawl (0 = unlimited)")
99115
indexCmd.Flags().IntVar(&indexMaxDepth, "max-depth", 0, "Maximum BFS link depth (0 = unlimited)")
100116
indexCmd.Flags().BoolVar(&indexSkipSitemap, "skip-sitemap", false, "Force BFS crawl even if sitemap.xml exists")
117+
indexCmd.Flags().BoolVar(&indexPrune, "prune", false, "Remove documents whose source files no longer exist on disk")
101118
}
102119

cmd/logformat_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"log/slog"
7+
"testing"
8+
)
9+
10+
// TestLogFormatJSON is a compact smoke-test for the JSON handler — the
11+
// real --log-format wiring lives in initConfig which hits os.Stderr +
12+
// viper, neither of which is worth faking here. Exercising the handler
13+
// directly is enough to catch regressions where we accidentally write a
14+
// text handler for format=json.
15+
func TestLogFormatJSON(t *testing.T) {
16+
var buf bytes.Buffer
17+
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
18+
logger.Info("hello", "k", "v")
19+
20+
var decoded map[string]any
21+
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
22+
t.Fatalf("log output is not valid JSON: %v\n%s", err, buf.String())
23+
}
24+
if decoded["msg"] != "hello" {
25+
t.Errorf("msg = %v, want hello", decoded["msg"])
26+
}
27+
if decoded["k"] != "v" {
28+
t.Errorf("k = %v, want v", decoded["k"])
29+
}
30+
}

cmd/root.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@ import (
44
"fmt"
55
"log/slog"
66
"os"
7+
"strings"
78

89
"github.com/RandomCodeSpace/docscontext/internal/config"
910
"github.com/spf13/cobra"
1011
)
1112

1213
var (
13-
cfgFile string
14-
cfg *config.Config
15-
logLevel string
14+
cfgFile string
15+
cfg *config.Config
16+
logLevel string
17+
logFormat string
1618
)
1719

1820
var rootCmd = &cobra.Command{
@@ -35,6 +37,7 @@ func init() {
3537
cobra.OnInitialize(initConfig)
3638
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default ~/.docscontext/config.yaml or ~/.DocsContext/config.yaml)")
3739
rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "Log level: debug, info, warn, error")
40+
rootCmd.PersistentFlags().StringVar(&logFormat, "log-format", "", "Log format: text|json (env DOCSIQ_LOG_FORMAT; default text)")
3841
}
3942

4043
func initConfig() {
@@ -50,7 +53,20 @@ func initConfig() {
5053
default:
5154
level = slog.LevelInfo
5255
}
53-
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})))
56+
// Log format: --log-format wins, else DOCSIQ_LOG_FORMAT, else "text".
57+
format := strings.ToLower(strings.TrimSpace(logFormat))
58+
if format == "" {
59+
format = strings.ToLower(strings.TrimSpace(os.Getenv("DOCSIQ_LOG_FORMAT")))
60+
}
61+
handlerOpts := &slog.HandlerOptions{Level: level}
62+
var handler slog.Handler
63+
switch format {
64+
case "json":
65+
handler = slog.NewJSONHandler(os.Stderr, handlerOpts)
66+
default:
67+
handler = slog.NewTextHandler(os.Stderr, handlerOpts)
68+
}
69+
slog.SetDefault(slog.New(handler))
5470

5571
var err error
5672
cfg, err = config.Load(cfgFile)

internal/api/claims_handlers.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
)
6+
7+
// claimsForEntity returns all claims attached to the requested entity ID.
8+
// Unknown entity → 200 with empty array; the client can treat that the
9+
// same way whether the entity truly has no claims or was never indexed.
10+
func (h *handlers) claimsForEntity(w http.ResponseWriter, r *http.Request) {
11+
id := r.PathValue("id")
12+
if id == "" {
13+
writeError(w, r, 400, "id required", nil)
14+
return
15+
}
16+
claims, err := h.store.ClaimsForEntity(r.Context(), id)
17+
if err != nil {
18+
writeError(w, r, 500, err.Error(), err)
19+
return
20+
}
21+
writeJSON(w, 200, claims)
22+
}
23+
24+
// listClaims returns claims with optional ?status= and ?limit= filters.
25+
func (h *handlers) listClaims(w http.ResponseWriter, r *http.Request) {
26+
q := r.URL.Query()
27+
status := q.Get("status")
28+
limit := intQuery(q.Get("limit"), 100)
29+
claims, err := h.store.ListClaims(r.Context(), status, limit)
30+
if err != nil {
31+
writeError(w, r, 500, err.Error(), err)
32+
return
33+
}
34+
writeJSON(w, 200, claims)
35+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/RandomCodeSpace/docscontext/internal/config"
12+
"github.com/RandomCodeSpace/docscontext/internal/store"
13+
)
14+
15+
// newClaimsRouter builds a router with a seeded store that has entities
16+
// and claims, so the REST layer can be exercised end-to-end.
17+
func newClaimsRouter(t *testing.T) http.Handler {
18+
t.Helper()
19+
dir := t.TempDir()
20+
st, err := store.Open(filepath.Join(dir, "claims_router.db"))
21+
if err != nil {
22+
t.Fatalf("store.Open: %v", err)
23+
}
24+
t.Cleanup(func() { _ = st.Close() })
25+
26+
ctx := context.Background()
27+
if err := st.UpsertDocument(ctx, &store.Document{
28+
ID: "d1", Path: "/tmp/d1.md", DocType: "md", FileHash: "d1h", IsLatest: true,
29+
}); err != nil {
30+
t.Fatalf("UpsertDocument: %v", err)
31+
}
32+
if err := st.UpsertEntity(ctx, &store.Entity{ID: "e1", Name: "Alpha"}); err != nil {
33+
t.Fatalf("UpsertEntity: %v", err)
34+
}
35+
claims := []*store.Claim{
36+
{ID: "c1", EntityID: "e1", Claim: "fact-1", Status: "verified", DocID: "d1"},
37+
{ID: "c2", EntityID: "e1", Claim: "fact-2", Status: "pending", DocID: "d1"},
38+
}
39+
if err := st.BatchInsertClaims(ctx, claims); err != nil {
40+
t.Fatalf("BatchInsertClaims: %v", err)
41+
}
42+
43+
cfg := &config.Config{}
44+
cfg.DataDir = dir
45+
return NewRouter(st, nil, nil, cfg, nil)
46+
}
47+
48+
func TestClaimsHandlers(t *testing.T) {
49+
t.Run("claims_for_entity_happy_path", func(t *testing.T) {
50+
h := newClaimsRouter(t)
51+
req := httptest.NewRequest(http.MethodGet, "/api/entities/e1/claims", nil)
52+
rec := httptest.NewRecorder()
53+
h.ServeHTTP(rec, req)
54+
if rec.Code != http.StatusOK {
55+
t.Fatalf("status = %d, want 200", rec.Code)
56+
}
57+
var claims []store.Claim
58+
if err := json.NewDecoder(rec.Body).Decode(&claims); err != nil {
59+
t.Fatalf("decode: %v", err)
60+
}
61+
if len(claims) != 2 {
62+
t.Errorf("len = %d, want 2", len(claims))
63+
}
64+
})
65+
66+
t.Run("claims_for_unknown_entity_returns_empty_array", func(t *testing.T) {
67+
h := newClaimsRouter(t)
68+
req := httptest.NewRequest(http.MethodGet, "/api/entities/nope/claims", nil)
69+
rec := httptest.NewRecorder()
70+
h.ServeHTTP(rec, req)
71+
if rec.Code != http.StatusOK {
72+
t.Fatalf("status = %d, want 200", rec.Code)
73+
}
74+
body := rec.Body.String()
75+
if body != "[]\n" {
76+
t.Errorf("body = %q, want %q", body, "[]\n")
77+
}
78+
})
79+
80+
t.Run("list_claims_status_filter", func(t *testing.T) {
81+
h := newClaimsRouter(t)
82+
req := httptest.NewRequest(http.MethodGet, "/api/claims?status=verified", nil)
83+
rec := httptest.NewRecorder()
84+
h.ServeHTTP(rec, req)
85+
if rec.Code != http.StatusOK {
86+
t.Fatalf("status = %d, want 200", rec.Code)
87+
}
88+
var claims []store.Claim
89+
if err := json.NewDecoder(rec.Body).Decode(&claims); err != nil {
90+
t.Fatalf("decode: %v", err)
91+
}
92+
if len(claims) != 1 {
93+
t.Errorf("len = %d, want 1", len(claims))
94+
}
95+
if len(claims) > 0 && claims[0].Status != "verified" {
96+
t.Errorf("status = %q, want verified", claims[0].Status)
97+
}
98+
})
99+
100+
t.Run("list_claims_limit_bound", func(t *testing.T) {
101+
h := newClaimsRouter(t)
102+
req := httptest.NewRequest(http.MethodGet, "/api/claims?limit=1", nil)
103+
rec := httptest.NewRecorder()
104+
h.ServeHTTP(rec, req)
105+
if rec.Code != http.StatusOK {
106+
t.Fatalf("status = %d, want 200", rec.Code)
107+
}
108+
var claims []store.Claim
109+
if err := json.NewDecoder(rec.Body).Decode(&claims); err != nil {
110+
t.Fatalf("decode: %v", err)
111+
}
112+
if len(claims) != 1 {
113+
t.Errorf("len = %d, want 1", len(claims))
114+
}
115+
})
116+
}

internal/api/handlers.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/RandomCodeSpace/docscontext/internal/embedder"
2020
"github.com/RandomCodeSpace/docscontext/internal/llm"
2121
"github.com/RandomCodeSpace/docscontext/internal/pipeline"
22+
"github.com/RandomCodeSpace/docscontext/internal/project"
2223
"github.com/RandomCodeSpace/docscontext/internal/search"
2324
"github.com/RandomCodeSpace/docscontext/internal/store"
2425
)
@@ -55,6 +56,35 @@ func (h *handlers) health(w http.ResponseWriter, r *http.Request) {
5556
writeJSON(w, 200, map[string]string{"status": "ok"})
5657
}
5758

59+
// projectsHandler is a thin read-only JSON shim around registry.List()
60+
// so the Phase-4 UI can populate its project-selector dropdown.
61+
type projectsHandler struct {
62+
registry *project.Registry
63+
}
64+
65+
// listProjects returns registered projects as a JSON array. Falls back
66+
// to [{"slug":"_default","name":"_default"}] when the registry is nil
67+
// or empty so the UI always has a usable default selection.
68+
func (p *projectsHandler) listProjects(w http.ResponseWriter, r *http.Request) {
69+
type projInfo struct {
70+
Slug string `json:"slug"`
71+
Name string `json:"name"`
72+
}
73+
out := []projInfo{}
74+
if p.registry != nil {
75+
projs, err := p.registry.List()
76+
if err == nil {
77+
for _, pr := range projs {
78+
out = append(out, projInfo{Slug: pr.Slug, Name: pr.Name})
79+
}
80+
}
81+
}
82+
if len(out) == 0 {
83+
out = append(out, projInfo{Slug: "_default", Name: "_default"})
84+
}
85+
writeJSON(w, 200, out)
86+
}
87+
5888
func (h *handlers) getStats(w http.ResponseWriter, r *http.Request) {
5989
stats, err := h.store.GetStats(r.Context())
6090
if err != nil {

0 commit comments

Comments
 (0)