Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions internal/api/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,88 @@ func (h *handlers) getDocument(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, doc)
}

func (h *handlers) getDocumentChunks(w http.ResponseWriter, r *http.Request) {
st, ok := h.resolveStore(w, r)
if !ok {
return
}
id := r.PathValue("id")
doc, err := st.GetDocument(r.Context(), id)
if err != nil {
writeError(w, r, 500, err.Error(), err)
return
}
if doc == nil {
writeError(w, r, 404, "document not found", nil)
return
}
chunks, err := st.ListChunksByDoc(r.Context(), id)
if err != nil {
writeError(w, r, 500, err.Error(), err)
return
}
out := make([]map[string]any, 0, len(chunks))
for _, c := range chunks {
out = append(out, map[string]any{
"id": c.ID,
"chunk_index": c.ChunkIndex,
"content": c.Content,
"token_count": c.TokenCount,
})
}
writeJSON(w, 200, out)
}

func (h *handlers) entityGraph(w http.ResponseWriter, r *http.Request) {
st, ok := h.resolveStore(w, r)
if !ok {
return
}
q := r.URL.Query()
limit := intQuery(q.Get("limit"), 500)
typ := q.Get("type")

entities, err := st.ListEntities(r.Context(), typ, limit, 0)
if err != nil {
writeError(w, r, 500, err.Error(), err)
return
}
rels, err := st.AllRelationships(r.Context())
if err != nil {
writeError(w, r, 500, err.Error(), err)
return
}

nodes := make([]map[string]any, 0, len(entities))
keep := make(map[string]bool, len(entities))
for _, e := range entities {
keep[e.ID] = true
nodes = append(nodes, map[string]any{
"id": e.ID,
"label": e.Name,
"kind": "entity",
"type": e.Type,
"description": e.Description,
"rank": e.Rank,
"community": e.CommunityID,
})
}
edges := make([]map[string]any, 0)
for _, rel := range rels {
if !keep[rel.SourceID] || !keep[rel.TargetID] {
continue
}
edges = append(edges, map[string]any{
"id": rel.ID,
"source": rel.SourceID,
"target": rel.TargetID,
"label": rel.Predicate,
"weight": rel.Weight,
})
}
writeJSON(w, 200, map[string]any{"nodes": nodes, "edges": edges})
}

type searchRequest struct {
Query string `json:"query"`
Mode string `json:"mode"` // local | global
Expand Down
2 changes: 2 additions & 0 deletions internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,10 @@ func NewRouter(prov llm.Provider, emb *embedder.Embedder, cfg *config.Config, re
mux.HandleFunc("GET /api/stats", h.getStats)
mux.HandleFunc("GET /api/documents", h.listDocuments)
mux.HandleFunc("GET /api/documents/{id}", h.getDocument)
mux.HandleFunc("GET /api/documents/{id}/chunks", h.getDocumentChunks)
mux.HandleFunc("GET /api/documents/{id}/versions", h.getDocumentVersions)
mux.HandleFunc("POST /api/search", h.search)
mux.HandleFunc("GET /api/graph", h.entityGraph)
mux.HandleFunc("GET /api/graph/neighborhood", h.graphNeighborhood)
mux.HandleFunc("GET /api/entities", h.listEntities)
mux.HandleFunc("GET /api/communities", h.listCommunities)
Expand Down
22 changes: 14 additions & 8 deletions internal/pipeline/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,15 +313,21 @@ func (p *Pipeline) indexFile(ctx context.Context, path string, opts IndexOptions
return fmt.Errorf("batch insert chunks: %w", err)
}

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

if err := p.store.BatchUpsertEmbeddings(ctx, p.provider.ModelID(), chunkIDs, vecs); err != nil {
return fmt.Errorf("batch store embeddings: %w", err)
if err := p.store.BatchUpsertEmbeddings(ctx, p.provider.ModelID(), chunkIDs, vecs); err != nil {
return fmt.Errorf("batch store embeddings: %w", err)
}
} else {
slog.Debug("⏭️ skipping embedding (provider=none)", "path", path, "chunks", len(texts))
}

// Phase 2: Run graph extraction, claims, and structured doc in parallel
Expand Down
2 changes: 2 additions & 0 deletions ui/src/hooks/api/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export const qk = {
notesSearch: (project: string, q: string) => ["notes-search", project, q] as const,
docs: (project: string) => ["docs", project] as const,
doc: (project: string, id: string) => ["doc", project, id] as const,
docChunks: (project: string, id: string) => ["doc-chunks", project, id] as const,
entityGraph: (project: string) => ["entity-graph", project] as const,
search: (project: string, q: string, mode: string) => ["search", project, q, mode] as const,
entities: (project: string) => ["entities", project] as const,
communities: (project: string) => ["communities", project] as const,
Expand Down
20 changes: 20 additions & 0 deletions ui/src/hooks/api/useDocs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import { apiFetch } from "@/lib/api-client";
import { qk } from "./keys";
import type { Document } from "@/types/api";

export interface DocChunk {
id: string;
chunk_index: number;
content: string;
token_count: number;
}

export function useDocs(project: string) {
return useQuery({
queryKey: qk.docs(project),
Expand All @@ -22,3 +29,16 @@ export function useDoc(project: string, id: string | undefined) {
queryFn: () => apiFetch<Document>(`/api/documents/${encodeURIComponent(id!)}?project=${encodeURIComponent(project)}`),
});
}

export function useDocChunks(project: string, id: string | undefined) {
return useQuery({
queryKey: qk.docChunks(project, id ?? ""),
enabled: !!id,
queryFn: async () => {
const res = await apiFetch<DocChunk[] | null>(
`/api/documents/${encodeURIComponent(id!)}/chunks?project=${encodeURIComponent(project)}`,
);
return Array.isArray(res) ? res : [];
},
});
}
26 changes: 26 additions & 0 deletions ui/src/hooks/api/useGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,29 @@ export function useNotesGraph(project: string) {
},
});
}

// Entity graph from the indexing pipeline (entities + relationships extracted
// by the LLM). Distinct from useNotesGraph, which surfaces wikilinks between
// hand-authored notes.
export function useEntityGraph(project: string) {
return useQuery({
queryKey: qk.entityGraph(project),
queryFn: async (): Promise<GraphData> => {
const res = await apiFetch<RawGraphResponse | null>(
`/api/graph?project=${encodeURIComponent(project)}`,
);
const rawNodes = res?.nodes ?? [];
const rawEdges = res?.edges ?? [];
const nodes: GraphNode[] = rawNodes.map((n) => ({
id: n.id ?? "",
label: n.label ?? n.title ?? n.id ?? "",
kind: (n.kind as GraphNode["kind"]) ?? "entity",
}));
const ids = new Set(nodes.map((n) => n.id));
const edges: GraphEdge[] = rawEdges
.filter((e) => ids.has(e.source) && ids.has(e.target))
.map((e) => ({ source: e.source, target: e.target }));
return { nodes, edges };
},
});
}
83 changes: 66 additions & 17 deletions ui/src/routes/Graph.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,92 @@
import { useState } from "react";
import { GraphCanvas } from "@/components/graph/GraphCanvas";
import { useNotesGraph } from "@/hooks/api/useGraph";
import { useEntityGraph, useNotesGraph } from "@/hooks/api/useGraph";
import { useProjectStore } from "@/stores/project";
import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/empty";

type View = "entity" | "notes";

export default function Graph() {
const project = useProjectStore((s) => s.slug);
const { data, isLoading, error, refetch } = useNotesGraph(project);
const err = error as Error | null | undefined;
const entity = useEntityGraph(project);
const notes = useNotesGraph(project);

// Default view: entity graph if it has nodes, else notes graph. Honour
// an explicit user toggle once made.
const [override, setOverride] = useState<View | null>(null);
const entityHasNodes = (entity.data?.nodes.length ?? 0) > 0;
const view: View = override ?? (entityHasNodes ? "entity" : "notes");
const active = view === "entity" ? entity : notes;
const data = active.data;
const err = active.error as Error | null | undefined;

const Toggle = () => (
<div className="graph-toggle flex gap-2 p-3 border-b text-sm">
<button
type="button"
onClick={() => setOverride("entity")}
aria-pressed={view === "entity"}
className={`px-3 py-1 rounded ${view === "entity" ? "bg-foreground text-background" : "hover:bg-muted"}`}
>
Entity graph
{entity.data && ` · ${entity.data.nodes.length}`}
</button>
<button
type="button"
onClick={() => setOverride("notes")}
aria-pressed={view === "notes"}
className={`px-3 py-1 rounded ${view === "notes" ? "bg-foreground text-background" : "hover:bg-muted"}`}
>
Notes graph
{notes.data && ` · ${notes.data.nodes.length}`}
</button>
</div>
);

if (isLoading) {
if (active.isLoading) {
return (
<div className="graph-page p-8">
<LoadingSkeleton label="Loading graph" rows={4} />
<div className="graph-page">
<Toggle />
<div className="p-8">
<LoadingSkeleton label="Loading graph" rows={4} />
</div>
</div>
);
}
if (err) {
return (
<div className="graph-page p-8">
<ErrorState
title="Graph failed to load"
message={err.message || "Unknown error"}
onRetry={() => refetch()}
/>
<div className="graph-page">
<Toggle />
<div className="p-8">
<ErrorState
title="Graph failed to load"
message={err.message || "Unknown error"}
onRetry={() => active.refetch()}
/>
</div>
</div>
);
}
if (!data || data.nodes.length === 0) {
return (
<div className="graph-page p-8">
<EmptyState
title="No graph data for this project"
description="Ingest or index a document to build the graph."
/>
<div className="graph-page">
<Toggle />
<div className="p-8">
<EmptyState
title={view === "entity" ? "No entity graph yet" : "No notes graph yet"}
description={
view === "entity"
? "Run `docsiq index <path>` followed by `docsiq index --finalize` to extract entities and relationships."
: "Add markdown notes with [[wikilinks]] under this project to build the notes graph."
}
/>
</div>
</div>
);
}
return (
<div className="graph-page">
<Toggle />
<GraphCanvas data={data} />
</div>
);
Expand Down
Loading
Loading