Skip to content

Commit 19e7ac2

Browse files
authored
fix(ui): render document content + entity graph; nil-safe embedder (#93)
Three fixes for issues surfaced when running through the indexed-corpus flow end-to-end. Backend - Add `GET /api/documents/{id}/chunks` returning the ordered chunks for a document (id, chunk_index, content, token_count). The store already exposes `ListChunksByDoc`; this is a thin handler around it. - Add `GET /api/graph?project=...&type=...&limit=...` returning the full entity graph as `{nodes, edges}` matching the UI's existing RawGraphResponse shape, so the same GraphCanvas can render it without a parallel transform. - Skip the embedding phase in `pipeline.indexFile` when `p.embedder` is nil (provider=none / graph-only flow). Previously every CLI index run with `DOCSIQ_LLM_PROVIDER=none` panicked with a nil-pointer in `(*Embedder).EmbedTexts`, contradicting the CLAUDE.md guarantee that the embedder is nil-safe in this mode. Chunks are still persisted; downstream extraction works off raw text rather than vectors. Frontend - New `useDocChunks` hook fetching the chunks endpoint above. - `DocumentView` now renders chunk content. For markdown documents we pipe through `markdown-it` (already a dep) for HTML; otherwise we fall back to a `<pre>` of the raw text. The previous view rendered only `title + doc_type · v<n>`, leaving the document body blank — the symptom users were hitting after `docsiq index`. - New `useEntityGraph` hook fetching `/api/graph` and adapting it to the existing `GraphData` interface. - `Graph.tsx` now loads both the entity graph (from the indexing pipeline) and the notes graph (wikilinks between authored notes), defaults to the entity graph when it has nodes, and exposes a small toggle so users can flip between the two. The previous route was hard-wired to `useNotesGraph` only, so an indexed corpus with no hand-authored notes always showed the empty state. Adjacent issues surfaced but **not** fixed here (separate PRs): - `./docsiq index --force` collides on `documents.file_hash UNIQUE` because the supersede path bumps version but doesn't release the old hash. - `/api/graph/neighborhood` requires the entity *name* (case-sensitive via `GetEntityByName`); UUIDs return 404. Worth accepting either.
1 parent 3c6b046 commit 19e7ac2

8 files changed

Lines changed: 260 additions & 34 deletions

File tree

internal/api/handlers.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,88 @@ func (h *handlers) getDocument(w http.ResponseWriter, r *http.Request) {
191191
writeJSON(w, 200, doc)
192192
}
193193

194+
func (h *handlers) getDocumentChunks(w http.ResponseWriter, r *http.Request) {
195+
st, ok := h.resolveStore(w, r)
196+
if !ok {
197+
return
198+
}
199+
id := r.PathValue("id")
200+
doc, err := st.GetDocument(r.Context(), id)
201+
if err != nil {
202+
writeError(w, r, 500, err.Error(), err)
203+
return
204+
}
205+
if doc == nil {
206+
writeError(w, r, 404, "document not found", nil)
207+
return
208+
}
209+
chunks, err := st.ListChunksByDoc(r.Context(), id)
210+
if err != nil {
211+
writeError(w, r, 500, err.Error(), err)
212+
return
213+
}
214+
out := make([]map[string]any, 0, len(chunks))
215+
for _, c := range chunks {
216+
out = append(out, map[string]any{
217+
"id": c.ID,
218+
"chunk_index": c.ChunkIndex,
219+
"content": c.Content,
220+
"token_count": c.TokenCount,
221+
})
222+
}
223+
writeJSON(w, 200, out)
224+
}
225+
226+
func (h *handlers) entityGraph(w http.ResponseWriter, r *http.Request) {
227+
st, ok := h.resolveStore(w, r)
228+
if !ok {
229+
return
230+
}
231+
q := r.URL.Query()
232+
limit := intQuery(q.Get("limit"), 500)
233+
typ := q.Get("type")
234+
235+
entities, err := st.ListEntities(r.Context(), typ, limit, 0)
236+
if err != nil {
237+
writeError(w, r, 500, err.Error(), err)
238+
return
239+
}
240+
rels, err := st.AllRelationships(r.Context())
241+
if err != nil {
242+
writeError(w, r, 500, err.Error(), err)
243+
return
244+
}
245+
246+
nodes := make([]map[string]any, 0, len(entities))
247+
keep := make(map[string]bool, len(entities))
248+
for _, e := range entities {
249+
keep[e.ID] = true
250+
nodes = append(nodes, map[string]any{
251+
"id": e.ID,
252+
"label": e.Name,
253+
"kind": "entity",
254+
"type": e.Type,
255+
"description": e.Description,
256+
"rank": e.Rank,
257+
"community": e.CommunityID,
258+
})
259+
}
260+
edges := make([]map[string]any, 0)
261+
for _, rel := range rels {
262+
if !keep[rel.SourceID] || !keep[rel.TargetID] {
263+
continue
264+
}
265+
edges = append(edges, map[string]any{
266+
"id": rel.ID,
267+
"source": rel.SourceID,
268+
"target": rel.TargetID,
269+
"label": rel.Predicate,
270+
"weight": rel.Weight,
271+
})
272+
}
273+
writeJSON(w, 200, map[string]any{"nodes": nodes, "edges": edges})
274+
}
275+
194276
type searchRequest struct {
195277
Query string `json:"query"`
196278
Mode string `json:"mode"` // local | global

internal/api/router.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,10 @@ func NewRouter(prov llm.Provider, emb *embedder.Embedder, cfg *config.Config, re
169169
mux.HandleFunc("GET /api/stats", h.getStats)
170170
mux.HandleFunc("GET /api/documents", h.listDocuments)
171171
mux.HandleFunc("GET /api/documents/{id}", h.getDocument)
172+
mux.HandleFunc("GET /api/documents/{id}/chunks", h.getDocumentChunks)
172173
mux.HandleFunc("GET /api/documents/{id}/versions", h.getDocumentVersions)
173174
mux.HandleFunc("POST /api/search", h.search)
175+
mux.HandleFunc("GET /api/graph", h.entityGraph)
174176
mux.HandleFunc("GET /api/graph/neighborhood", h.graphNeighborhood)
175177
mux.HandleFunc("GET /api/entities", h.listEntities)
176178
mux.HandleFunc("GET /api/communities", h.listCommunities)

internal/pipeline/pipeline.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -313,15 +313,21 @@ func (p *Pipeline) indexFile(ctx context.Context, path string, opts IndexOptions
313313
return fmt.Errorf("batch insert chunks: %w", err)
314314
}
315315

316-
// Phase 1c: Embed chunks
317-
vecs, err := p.embedder.EmbedTexts(ctx, texts)
318-
if err != nil {
319-
return fmt.Errorf("embed: %w", err)
320-
}
321-
slog.Debug("📊 chunks embedded", "path", path, "chunks", len(vecs))
316+
// Phase 1c: Embed chunks. Skip when the embedder is nil (provider=none /
317+
// graph-only flow); chunks are still persisted, downstream extraction
318+
// uses raw text rather than vectors. CLAUDE.md guarantees this no-op path.
319+
if p.embedder != nil {
320+
vecs, err := p.embedder.EmbedTexts(ctx, texts)
321+
if err != nil {
322+
return fmt.Errorf("embed: %w", err)
323+
}
324+
slog.Debug("📊 chunks embedded", "path", path, "chunks", len(vecs))
322325

323-
if err := p.store.BatchUpsertEmbeddings(ctx, p.provider.ModelID(), chunkIDs, vecs); err != nil {
324-
return fmt.Errorf("batch store embeddings: %w", err)
326+
if err := p.store.BatchUpsertEmbeddings(ctx, p.provider.ModelID(), chunkIDs, vecs); err != nil {
327+
return fmt.Errorf("batch store embeddings: %w", err)
328+
}
329+
} else {
330+
slog.Debug("⏭️ skipping embedding (provider=none)", "path", path, "chunks", len(texts))
325331
}
326332

327333
// Phase 2: Run graph extraction, claims, and structured doc in parallel

ui/src/hooks/api/keys.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export const qk = {
88
notesSearch: (project: string, q: string) => ["notes-search", project, q] as const,
99
docs: (project: string) => ["docs", project] as const,
1010
doc: (project: string, id: string) => ["doc", project, id] as const,
11+
docChunks: (project: string, id: string) => ["doc-chunks", project, id] as const,
12+
entityGraph: (project: string) => ["entity-graph", project] as const,
1113
search: (project: string, q: string, mode: string) => ["search", project, q, mode] as const,
1214
entities: (project: string) => ["entities", project] as const,
1315
communities: (project: string) => ["communities", project] as const,

ui/src/hooks/api/useDocs.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ import { apiFetch } from "@/lib/api-client";
33
import { qk } from "./keys";
44
import type { Document } from "@/types/api";
55

6+
export interface DocChunk {
7+
id: string;
8+
chunk_index: number;
9+
content: string;
10+
token_count: number;
11+
}
12+
613
export function useDocs(project: string) {
714
return useQuery({
815
queryKey: qk.docs(project),
@@ -22,3 +29,16 @@ export function useDoc(project: string, id: string | undefined) {
2229
queryFn: () => apiFetch<Document>(`/api/documents/${encodeURIComponent(id!)}?project=${encodeURIComponent(project)}`),
2330
});
2431
}
32+
33+
export function useDocChunks(project: string, id: string | undefined) {
34+
return useQuery({
35+
queryKey: qk.docChunks(project, id ?? ""),
36+
enabled: !!id,
37+
queryFn: async () => {
38+
const res = await apiFetch<DocChunk[] | null>(
39+
`/api/documents/${encodeURIComponent(id!)}/chunks?project=${encodeURIComponent(project)}`,
40+
);
41+
return Array.isArray(res) ? res : [];
42+
},
43+
});
44+
}

ui/src/hooks/api/useGraph.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,29 @@ export function useNotesGraph(project: string) {
3232
},
3333
});
3434
}
35+
36+
// Entity graph from the indexing pipeline (entities + relationships extracted
37+
// by the LLM). Distinct from useNotesGraph, which surfaces wikilinks between
38+
// hand-authored notes.
39+
export function useEntityGraph(project: string) {
40+
return useQuery({
41+
queryKey: qk.entityGraph(project),
42+
queryFn: async (): Promise<GraphData> => {
43+
const res = await apiFetch<RawGraphResponse | null>(
44+
`/api/graph?project=${encodeURIComponent(project)}`,
45+
);
46+
const rawNodes = res?.nodes ?? [];
47+
const rawEdges = res?.edges ?? [];
48+
const nodes: GraphNode[] = rawNodes.map((n) => ({
49+
id: n.id ?? "",
50+
label: n.label ?? n.title ?? n.id ?? "",
51+
kind: (n.kind as GraphNode["kind"]) ?? "entity",
52+
}));
53+
const ids = new Set(nodes.map((n) => n.id));
54+
const edges: GraphEdge[] = rawEdges
55+
.filter((e) => ids.has(e.source) && ids.has(e.target))
56+
.map((e) => ({ source: e.source, target: e.target }));
57+
return { nodes, edges };
58+
},
59+
});
60+
}

ui/src/routes/Graph.tsx

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,92 @@
1+
import { useState } from "react";
12
import { GraphCanvas } from "@/components/graph/GraphCanvas";
2-
import { useNotesGraph } from "@/hooks/api/useGraph";
3+
import { useEntityGraph, useNotesGraph } from "@/hooks/api/useGraph";
34
import { useProjectStore } from "@/stores/project";
45
import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/empty";
56

7+
type View = "entity" | "notes";
8+
69
export default function Graph() {
710
const project = useProjectStore((s) => s.slug);
8-
const { data, isLoading, error, refetch } = useNotesGraph(project);
9-
const err = error as Error | null | undefined;
11+
const entity = useEntityGraph(project);
12+
const notes = useNotesGraph(project);
13+
14+
// Default view: entity graph if it has nodes, else notes graph. Honour
15+
// an explicit user toggle once made.
16+
const [override, setOverride] = useState<View | null>(null);
17+
const entityHasNodes = (entity.data?.nodes.length ?? 0) > 0;
18+
const view: View = override ?? (entityHasNodes ? "entity" : "notes");
19+
const active = view === "entity" ? entity : notes;
20+
const data = active.data;
21+
const err = active.error as Error | null | undefined;
22+
23+
const Toggle = () => (
24+
<div className="graph-toggle flex gap-2 p-3 border-b text-sm">
25+
<button
26+
type="button"
27+
onClick={() => setOverride("entity")}
28+
aria-pressed={view === "entity"}
29+
className={`px-3 py-1 rounded ${view === "entity" ? "bg-foreground text-background" : "hover:bg-muted"}`}
30+
>
31+
Entity graph
32+
{entity.data && ` · ${entity.data.nodes.length}`}
33+
</button>
34+
<button
35+
type="button"
36+
onClick={() => setOverride("notes")}
37+
aria-pressed={view === "notes"}
38+
className={`px-3 py-1 rounded ${view === "notes" ? "bg-foreground text-background" : "hover:bg-muted"}`}
39+
>
40+
Notes graph
41+
{notes.data && ` · ${notes.data.nodes.length}`}
42+
</button>
43+
</div>
44+
);
1045

11-
if (isLoading) {
46+
if (active.isLoading) {
1247
return (
13-
<div className="graph-page p-8">
14-
<LoadingSkeleton label="Loading graph" rows={4} />
48+
<div className="graph-page">
49+
<Toggle />
50+
<div className="p-8">
51+
<LoadingSkeleton label="Loading graph" rows={4} />
52+
</div>
1553
</div>
1654
);
1755
}
1856
if (err) {
1957
return (
20-
<div className="graph-page p-8">
21-
<ErrorState
22-
title="Graph failed to load"
23-
message={err.message || "Unknown error"}
24-
onRetry={() => refetch()}
25-
/>
58+
<div className="graph-page">
59+
<Toggle />
60+
<div className="p-8">
61+
<ErrorState
62+
title="Graph failed to load"
63+
message={err.message || "Unknown error"}
64+
onRetry={() => active.refetch()}
65+
/>
66+
</div>
2667
</div>
2768
);
2869
}
2970
if (!data || data.nodes.length === 0) {
3071
return (
31-
<div className="graph-page p-8">
32-
<EmptyState
33-
title="No graph data for this project"
34-
description="Ingest or index a document to build the graph."
35-
/>
72+
<div className="graph-page">
73+
<Toggle />
74+
<div className="p-8">
75+
<EmptyState
76+
title={view === "entity" ? "No entity graph yet" : "No notes graph yet"}
77+
description={
78+
view === "entity"
79+
? "Run `docsiq index <path>` followed by `docsiq index --finalize` to extract entities and relationships."
80+
: "Add markdown notes with [[wikilinks]] under this project to build the notes graph."
81+
}
82+
/>
83+
</div>
3684
</div>
3785
);
3886
}
3987
return (
4088
<div className="graph-page">
89+
<Toggle />
4190
<GraphCanvas data={data} />
4291
</div>
4392
);

0 commit comments

Comments
 (0)