From 1fa5890939835da0d5a67e6954de8a6f64bb9397 Mon Sep 17 00:00:00 2001 From: Gurden Batra Date: Mon, 22 Jun 2026 15:13:59 +0200 Subject: [PATCH 01/11] docs: semantic edge resolution design (Phase 3, slice 3) --- ...6-06-22-semantic-edge-resolution-design.md | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-22-semantic-edge-resolution-design.md diff --git a/docs/superpowers/specs/2026-06-22-semantic-edge-resolution-design.md b/docs/superpowers/specs/2026-06-22-semantic-edge-resolution-design.md new file mode 100644 index 0000000..f4d713c --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-semantic-edge-resolution-design.md @@ -0,0 +1,163 @@ +# Semantic Edge Resolution — Design + +**Date:** 2026-06-22 +**Phase:** 3, slice 3 +**Status:** Approved (brainstorming) + +## Problem + +`resolveConnections` (`src/lib/agents/connectionResolver.ts`) turns the LLM's +`suggested_connections` (`{ target_title, edge_type, rationale }`) into graph +edges by matching `target_title` against existing node titles with a +case-insensitive **exact** `ilike`. Any suggestion whose title does not match a +real node exactly is **silently dropped**. The LLM paraphrases titles +constantly, so most suggested edges are lost and the graph is under-connected. + +## Goal + +Recover dropped suggestions by matching them **semantically** against the +existing vetted graph, reusing the slice-1 embedding spine (`embedText` + +`match_nodes`). Preserve capture-time speed and the "never block the request +path / embeddings are async + non-fatal" principle. + +## Decisions (user-approved) + +- **Tiered handling** of semantic matches: auto-create above a high similarity, + surface a middle band in the Review inbox for confirm/dismiss, drop below. +- **Forward-only** — applies to nodes processed from now on. No backfill pass. + (Suggestions remain stored in each node's `llm_extraction`, so a backfill + endpoint could be added later.) +- **Exact-first, async semantic fallback** — the exact `ilike` path is unchanged + and stays inline; only the misses go through the async semantic step. + +## Architecture & flow + +``` +capture/process (inline — unchanged speed) + └─ resolveConnections(source, suggestions) -> { created, unresolved } + ├─ exact ilike match → insert edge now (as today) + └─ no exact match → collect into `unresolved`, return it + │ + └─ after(() => resolveSemantically(unresolved)) [async, non-fatal] + for each unresolved suggestion: + embedText(target_title, 'query') → match_nodes(vec, 5) + top non-self vetted hit, similarity s: + s ≥ EDGE_AUTO_THRESHOLD → insert edge (weight = s) + EDGE_REVIEW ≤ s < EDGE_AUTO → insert edge_suggestions (open) + s < EDGE_REVIEW → drop +``` + +- The exact path is untouched: same behavior, same speed, still inline. +- The semantic path runs in `after()`, is wrapped in try/catch, and no-ops when + `VOYAGE_API_KEY` is unset — a failure never breaks capture. +- `match_nodes` searches only **promoted / human_reviewed** nodes (the ones with + embeddings) — the right bar for an auto/suggested edge. +- The existing duplicate-edge check plus `UNIQUE(source_id, target_id, edge_type)` + prevent double edges across the exact and semantic paths. + +## Schema + +New table (mirrors `duplicate_candidates`). Pending edges deliberately do **not** +go in `edges`, so the graph never renders unconfirmed connections. + +```sql +-- supabase/v1.3-edge-suggestions.sql (run in the Supabase SQL editor) +create table if not exists edge_suggestions ( + id uuid primary key default gen_random_uuid(), + source_id uuid not null references nodes(id) on delete cascade, + target_id uuid not null references nodes(id) on delete cascade, -- semantically matched node + edge_type text not null references edge_types(id), + rationale text, -- LLM's reason, shown in review + similarity float not null, + status text not null default 'open', -- open | accepted | dismissed + created_at timestamptz not null default now(), + unique (source_id, target_id, edge_type) +); +create index if not exists edge_suggestions_status on edge_suggestions(status); +alter table edge_suggestions enable row level security; + +drop policy if exists "auth read edge suggestions" on edge_suggestions; +create policy "auth read edge suggestions" on edge_suggestions + for select to authenticated using (true); + +drop policy if exists "auth update edge suggestions" on edge_suggestions; +create policy "auth update edge suggestions" on edge_suggestions + for update to authenticated using (true) with check (true); +-- Inserts (detection) happen via the service-role key, which bypasses RLS. +``` + +## Thresholds + +Constants in `src/lib/agents/semanticEdges.ts`, tunable: + +- `EDGE_AUTO_THRESHOLD = 0.80` — create the edge automatically. +- `EDGE_REVIEW_THRESHOLD = 0.65` — between this and auto → review; below → drop. + +These are **starting values**. The match is asymmetric — a bare `target_title` +(query) against `title + description` (document) — so real matches score lower +than the symmetric doc-to-doc dedup case (0.88), and the band is intentionally +looser to catch paraphrased titles with a conservative auto bar. A debug log of +`(target_title, matched title, similarity, tier)` is emitted so the first real +captures can be eyeballed and the thresholds tuned. Auto-created semantic edges +use `weight = similarity` (exact edges keep `weight = 1`) so the graph reflects +confidence. + +## Components + +- **`connectionResolver.ts` (modified)** — return `{ created, unresolved }` + instead of `number`; collect misses into `unresolved` rather than dropping. +- **`semanticEdges.ts` (new)** — `resolveSemantically(sourceId, suggestions, + supabase, userId)`: embed each `target_title`, `match_nodes`, tier the top hit + into an `edges` insert (auto) or `edge_suggestions` insert (review); skip if an + edge already exists; fully non-fatal; no-op without embeddings. +- **`PATCH /api/edge-suggestions/[id]` (new)** — `withAuth` + `ok/fail`. + `{ action: 'accept' }` inserts the real `edges` row then marks the suggestion + `accepted`; `{ action: 'dismiss' }` marks it `dismissed` (no edge). Invalid + action → `fail`. +- **`SuggestedConnectionItem.tsx` (new)** — renders + `{source} —[edge_type]→ {target}`, the rationale, `{pct}% match`, and + **Add connection** / **Dismiss** buttons (optimistic, like `DuplicateItem`). +- **`SystemHealthClient.tsx` (modified)** — new "Suggested connections" section + rendering the items; optimistic accept/dismiss calling the new endpoint. +- **`review/page.tsx` (modified)** — 5th parallel query for `open` + `edge_suggestions`; fold the source/target node ids into the existing title + lookup; pass resolved suggestions to the client. +- **Caller wiring** — both `resolveConnections` call sites in + `capture/process/route.ts` consume `unresolved` and schedule + `resolveSemantically` via `after()`. + +## Error handling + +- Semantic step is wrapped in try/catch and runs in `after()` — never blocks or + fails the capture response. +- `embedText` returns null without `VOYAGE_API_KEY`; `resolveSemantically` + no-ops on null embeddings. +- `match_nodes` rpc errors are logged and swallowed. +- Accept/dismiss endpoint validates the action and returns the standard envelope. + +## Testing + +- `semanticEdges` — tiering (auto / review / drop), self-filter, existing-edge + skip, non-fatal on rpc error, no-op without embedding. +- `connectionResolver` — returns unresolved misses; still auto-creates exact + matches; respects edge-type validation and existing-edge skip. +- `edge-suggestions` route — accept inserts edge + marks accepted; dismiss marks + dismissed; invalid action → fail. +- `SuggestedConnectionItem` — renders pair + rationale + percent; fires + add/dismiss callbacks. +- `ReviewPage` — stays green with the new 5th query. +- Gate: clean `tsc --noEmit` (0), `eslint .` (0), `vitest run` (green). + +## Rollout + +1. Run `supabase/v1.3-edge-suggestions.sql` in the Supabase SQL editor. +2. `VOYAGE_API_KEY` already set (slice 1). Depends on `match_nodes` + (`v1.1-embeddings.sql`) and the embedding backfill being in place. + +## Out of scope + +- Backfill of historically-dropped suggestions (forward-only for v1). +- Content-merge or edge re-typing in review (accept/dismiss only). +- Changing the exact-match path's behavior or status filter. +- Graph-view affordances for suggested (pending) edges — they live only in the + review inbox until accepted. From 6d19efd4003595042871004e1698d089a5ea6561 Mon Sep 17 00:00:00 2001 From: Gurden Batra Date: Mon, 22 Jun 2026 18:04:37 +0200 Subject: [PATCH 02/11] docs: semantic edge resolution implementation plan --- .../2026-06-22-semantic-edge-resolution.md | 1107 +++++++++++++++++ 1 file changed, 1107 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-22-semantic-edge-resolution.md diff --git a/docs/superpowers/plans/2026-06-22-semantic-edge-resolution.md b/docs/superpowers/plans/2026-06-22-semantic-edge-resolution.md new file mode 100644 index 0000000..1134dce --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-semantic-edge-resolution.md @@ -0,0 +1,1107 @@ +# Semantic Edge Resolution 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:** Recover LLM-suggested connections that fail exact-title matching by matching them semantically against the vetted graph, auto-creating high-confidence edges and surfacing mid-confidence ones in the Review inbox. + +**Architecture:** The exact-title path in `resolveConnections` is unchanged and stays inline; it now *returns* the suggestions it could not place. The two capture/process call sites schedule an async `after()` step, `resolveSemantically`, which embeds each unplaced `target_title`, finds the nearest vetted node via the `match_nodes` RPC, and tiers the top hit into an auto-created `edges` row, an `open` `edge_suggestions` row (review), or a drop. A new review-inbox section and a `PATCH /api/edge-suggestions/[id]` endpoint let a human accept (insert the real edge) or dismiss. + +**Tech Stack:** Next.js 16 App Router, TypeScript, Supabase (pgvector + `match_nodes` SECURITY DEFINER RPC), Voyage `voyage-3.5` embeddings (`embedText`), Vitest + Testing Library. + +**Spec:** `docs/superpowers/specs/2026-06-22-semantic-edge-resolution-design.md` + +**Worktree/branch:** `.claude/worktrees/phase-1`, branch `phase-3-semantic-edges` (off `main`, which has slice 1+2 embedding infra). + +**Verification binaries** (avoid `npx`/rtk; clear `.tsbuildinfo` before tsc): +- tsc: `node ./node_modules/typescript/bin/tsc --noEmit` +- vitest: `node ./node_modules/vitest/vitest.mjs run --reporter=dot` +- eslint: `node ./node_modules/eslint/bin/eslint.js . -f json -o /tmp/lint.json` + +--- + +## File Structure + +- **Create** `supabase/v1.3-edge-suggestions.sql` — `edge_suggestions` table + RLS (run by hand in SQL editor). +- **Create** `src/lib/agents/semanticEdges.ts` — `resolveSemantically` + thresholds. One responsibility: turn unplaced suggestions into edges/review rows. +- **Create** `src/lib/agents/__tests__/semanticEdges.test.ts` — unit tests for the tiering. +- **Modify** `src/lib/agents/connectionResolver.ts` — return `{ created, unresolved }`; collect misses. +- **Modify** `src/lib/agents/__tests__/connectionResolver.test.ts` — adapt to the new return shape; add an unresolved test. +- **Modify** `src/app/api/capture/process/route.ts` — both call sites consume `unresolved` and schedule `resolveSemantically` via `after()`. +- **Create** `src/app/api/edge-suggestions/[id]/route.ts` — `PATCH` accept/dismiss. +- **Create** `src/app/api/edge-suggestions/__tests__/route.test.ts` — route tests. +- **Create** `src/components/review/SuggestedConnectionItem.tsx` — review-row component + `ReviewEdgeSuggestion` type. +- **Create** `src/components/review/__tests__/SuggestedConnectionItem.test.tsx` — component tests. +- **Modify** `src/app/review/SystemHealthClient.tsx` — "Suggested connections" section + accept/dismiss handlers. +- **Modify** `src/app/review/page.tsx` — 5th parallel query + title lookup + pass prop. + +--- + +## Task 1: Database migration + +**Files:** +- Create: `supabase/v1.3-edge-suggestions.sql` + +- [ ] **Step 1: Write the migration file** + +```sql +-- supabase/v1.3-edge-suggestions.sql +-- Phase 3, slice 3: semantic edge resolution. Stores mid-confidence semantic +-- connection matches for human confirm/dismiss in the Review inbox. Inserted by +-- src/lib/agents/semanticEdges.ts (service-role). Run in the Supabase SQL editor. +-- Depends on v1.1-embeddings.sql (match_nodes RPC). + +create table if not exists edge_suggestions ( + id uuid primary key default gen_random_uuid(), + source_id uuid not null references nodes(id) on delete cascade, + target_id uuid not null references nodes(id) on delete cascade, -- semantically matched node + edge_type text not null references edge_types(id), + rationale text, -- LLM's reason, shown in review + similarity float not null, + status text not null default 'open', -- open | accepted | dismissed + created_at timestamptz not null default now(), + unique (source_id, target_id, edge_type) +); + +create index if not exists edge_suggestions_status on edge_suggestions(status); + +alter table edge_suggestions enable row level security; + +drop policy if exists "auth read edge suggestions" on edge_suggestions; +create policy "auth read edge suggestions" on edge_suggestions + for select to authenticated using (true); + +drop policy if exists "auth update edge suggestions" on edge_suggestions; +create policy "auth update edge suggestions" on edge_suggestions + for update to authenticated using (true) with check (true); + +-- Inserts (detection) happen via the service-role key, which bypasses RLS. +``` + +- [ ] **Step 2: Commit** + +```bash +cd /Users/gurden/Documents/code/cof-learning-system/.claude/worktrees/phase-1 +git add supabase/v1.3-edge-suggestions.sql +git commit -m "feat(edges): edge_suggestions table for semantic edge review" +``` + +--- + +## Task 2: `resolveSemantically` core module + +**Files:** +- Create: `src/lib/agents/semanticEdges.ts` +- Test: `src/lib/agents/__tests__/semanticEdges.test.ts` + +- [ ] **Step 1: Write the failing test** + +Mirrors `src/lib/llm/__tests__/dedup.test.ts`. `embedText` is mocked; the supabase mock returns match rows via `.rpc`, reports no existing edge, and records `.insert`/`.upsert` calls. + +```ts +// src/lib/agents/__tests__/semanticEdges.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockEmbedText = vi.fn(); +vi.mock('@/lib/llm/embeddings', () => ({ + embedText: (...args: unknown[]) => mockEmbedText(...args), +})); + +import { + resolveSemantically, + EDGE_AUTO_THRESHOLD, + EDGE_REVIEW_THRESHOLD, +} from '../semanticEdges'; +import type { SuggestedConnection } from '../connectionResolver'; + +type Match = { id: string; similarity: number }; + +function makeSupabase(matches: Match[], opts: { existingEdge?: boolean; rpcError?: { message: string } } = {}) { + const edgeInsert = vi.fn().mockResolvedValue({ error: null }); + const suggestionUpsert = vi.fn().mockResolvedValue({ error: null }); + const maybeSingle = vi.fn().mockResolvedValue({ data: opts.existingEdge ? { id: 'e1' } : null }); + const supabase = { + rpc: vi.fn().mockResolvedValue({ data: matches, error: opts.rpcError ?? null }), + from: vi.fn((table: string) => { + if (table === 'edges') { + return { + select: vi.fn(() => ({ eq: vi.fn(() => ({ eq: vi.fn(() => ({ eq: vi.fn(() => ({ maybeSingle })) })) })) })), + insert: edgeInsert, + }; + } + return { upsert: suggestionUpsert }; + }), + _edgeInsert: edgeInsert, + _suggestionUpsert: suggestionUpsert, + }; + return supabase; +} + +const SUGGESTION: SuggestedConnection = { + target_title: 'Formation capital strategy', + edge_type: 'supports', + rationale: 'Directly supports', +}; + +describe('resolveSemantically', () => { + beforeEach(() => { vi.clearAllMocks(); mockEmbedText.mockResolvedValue([0.1, 0.2, 0.3]); }); + + it('auto-creates an edge when the top match is at/above the auto threshold', async () => { + const supabase = makeSupabase([{ id: 'target', similarity: 0.9 }]); + await resolveSemantically('src', [SUGGESTION], supabase as never, 'user-1'); + expect(supabase._edgeInsert).toHaveBeenCalledWith( + expect.objectContaining({ source_id: 'src', target_id: 'target', edge_type: 'supports', weight: 0.9 }), + ); + expect(supabase._suggestionUpsert).not.toHaveBeenCalled(); + }); + + it('records a review suggestion when the top match is in the review band', async () => { + const supabase = makeSupabase([{ id: 'target', similarity: 0.72 }]); + await resolveSemantically('src', [SUGGESTION], supabase as never, 'user-1'); + expect(supabase._suggestionUpsert).toHaveBeenCalledWith( + expect.objectContaining({ source_id: 'src', target_id: 'target', edge_type: 'supports', status: 'open' }), + expect.objectContaining({ onConflict: 'source_id,target_id,edge_type' }), + ); + expect(supabase._edgeInsert).not.toHaveBeenCalled(); + }); + + it('drops the suggestion when the top match is below the review threshold', async () => { + const supabase = makeSupabase([{ id: 'target', similarity: 0.4 }]); + await resolveSemantically('src', [SUGGESTION], supabase as never, 'user-1'); + expect(supabase._edgeInsert).not.toHaveBeenCalled(); + expect(supabase._suggestionUpsert).not.toHaveBeenCalled(); + }); + + it('ignores the source node itself in the matches', async () => { + const supabase = makeSupabase([{ id: 'src', similarity: 0.99 }]); + await resolveSemantically('src', [SUGGESTION], supabase as never, 'user-1'); + expect(supabase._edgeInsert).not.toHaveBeenCalled(); + expect(supabase._suggestionUpsert).not.toHaveBeenCalled(); + }); + + it('skips when an edge for the pair+type already exists', async () => { + const supabase = makeSupabase([{ id: 'target', similarity: 0.9 }], { existingEdge: true }); + await resolveSemantically('src', [SUGGESTION], supabase as never, 'user-1'); + expect(supabase._edgeInsert).not.toHaveBeenCalled(); + }); + + it('no-ops (no rpc) when embeddings are unavailable', async () => { + mockEmbedText.mockResolvedValue(null); + const supabase = makeSupabase([{ id: 'target', similarity: 0.9 }]); + await resolveSemantically('src', [SUGGESTION], supabase as never, 'user-1'); + expect(supabase.rpc).not.toHaveBeenCalled(); + }); + + it('is non-fatal on an rpc error', async () => { + const supabase = makeSupabase([], { rpcError: { message: 'boom' } }); + await expect(resolveSemantically('src', [SUGGESTION], supabase as never, 'user-1')).resolves.toBeUndefined(); + expect(supabase._edgeInsert).not.toHaveBeenCalled(); + }); + + it('orders thresholds sanely', () => { + expect(EDGE_REVIEW_THRESHOLD).toBeGreaterThan(0); + expect(EDGE_REVIEW_THRESHOLD).toBeLessThan(EDGE_AUTO_THRESHOLD); + expect(EDGE_AUTO_THRESHOLD).toBeLessThan(1); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `node ./node_modules/vitest/vitest.mjs run src/lib/agents/__tests__/semanticEdges.test.ts --reporter=dot` +Expected: FAIL — cannot resolve `../semanticEdges`. + +- [ ] **Step 3: Write the implementation** + +```ts +// src/lib/agents/semanticEdges.ts +import type { SupabaseClient } from '@supabase/supabase-js'; +import { embedText } from '@/lib/llm/embeddings'; +import type { SuggestedConnection } from './connectionResolver'; + +/** Auto-create an edge at/above this cosine similarity. */ +export const EDGE_AUTO_THRESHOLD = 0.80; +/** Between this and the auto threshold → surface for review; below → drop. */ +export const EDGE_REVIEW_THRESHOLD = 0.65; + +interface MatchRow { + readonly id: string; + readonly similarity: number; +} + +/** + * For each connection suggestion the exact-title resolver could not place, + * embed the target title and find its nearest vetted node via match_nodes. + * Tier the top hit: auto-create an edge (>= AUTO), record an open + * edge_suggestions row (REVIEW..AUTO), or drop (< REVIEW). Intended to run + * async via after(); non-fatal — never throws, no-ops when embeddings are + * unavailable. + */ +export async function resolveSemantically( + sourceId: string, + suggestions: ReadonlyArray, + supabase: SupabaseClient, + userId: string, +): Promise { + for (const suggestion of suggestions) { + try { + const title = suggestion.target_title?.trim(); + if (!title) continue; + + const embedding = await embedText(title, 'query'); + if (!embedding) continue; + + const { data, error } = await supabase.rpc('match_nodes', { + query_embedding: embedding, + match_count: 5, + }); + if (error) { + console.error('[semanticEdges] match_nodes failed:', error.message); + continue; + } + + const top = ((data ?? []) as MatchRow[]) + .filter(m => m.id !== sourceId) + .sort((a, b) => b.similarity - a.similarity)[0]; + if (!top || top.similarity < EDGE_REVIEW_THRESHOLD) continue; + + const { data: existing } = await supabase + .from('edges') + .select('id') + .eq('source_id', sourceId) + .eq('target_id', top.id) + .eq('edge_type', suggestion.edge_type) + .maybeSingle(); + if (existing) continue; + + const tier = top.similarity >= EDGE_AUTO_THRESHOLD ? 'auto' : 'review'; + console.error( + `[semanticEdges] "${title}" -> ${top.id} sim=${top.similarity.toFixed(3)} tier=${tier}`, + ); + + if (tier === 'auto') { + await supabase.from('edges').insert({ + source_id: sourceId, + target_id: top.id, + edge_type: suggestion.edge_type, + weight: top.similarity, + author_id: userId, + }); + } else { + await supabase.from('edge_suggestions').upsert( + { + source_id: sourceId, + target_id: top.id, + edge_type: suggestion.edge_type, + rationale: suggestion.rationale ?? null, + similarity: top.similarity, + status: 'open', + }, + { onConflict: 'source_id,target_id,edge_type' }, + ); + } + } catch (err) { + console.error('[semanticEdges] failed for', suggestion.target_title, err); + } + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `node ./node_modules/vitest/vitest.mjs run src/lib/agents/__tests__/semanticEdges.test.ts --reporter=dot` +Expected: PASS (8 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/agents/semanticEdges.ts src/lib/agents/__tests__/semanticEdges.test.ts +git commit -m "feat(edges): resolveSemantically — tiered semantic match for unplaced connections" +``` + +--- + +## Task 3: `resolveConnections` returns `{ created, unresolved }` + +**Files:** +- Modify: `src/lib/agents/connectionResolver.ts` +- Test: `src/lib/agents/__tests__/connectionResolver.test.ts` + +- [ ] **Step 1: Update the tests to the new return shape (failing)** + +Replace the entire `describe('resolveConnections', ...)` block (and only that block) with the version below. Changes: destructure `{ created }`; add an `unresolved` test. + +```ts +describe('resolveConnections', () => { + it('returns created 0 and no unresolved when suggestions is empty', async () => { + const supabase = makeSupabase(null); + const { created, unresolved } = await resolveConnections('src-id', [], supabase as never, 'user-1'); + expect(created).toBe(0); + expect(unresolved).toEqual([]); + }); + + it('creates an edge when a matching node is found', async () => { + const supabase = makeSupabase({ id: 'matched-id' }); + const { created } = await resolveConnections('src-id', [SUGGESTIONS[0]], supabase as never, 'user-1'); + expect(created).toBe(1); + expect(supabase._mockInsert).toHaveBeenCalledWith( + expect.objectContaining({ source_id: 'src-id', target_id: 'matched-id', edge_type: 'supports' }), + ); + }); + + it('returns unmatched valid suggestions in unresolved instead of creating', async () => { + const supabase = makeSupabase(null); + const { created, unresolved } = await resolveConnections('src-id', [SUGGESTIONS[1]], supabase as never, 'user-1'); + expect(created).toBe(0); + expect(unresolved).toEqual([SUGGESTIONS[1]]); + expect(supabase._mockInsert).not.toHaveBeenCalled(); + }); + + it('skips when edge already exists', async () => { + const supabase = makeSupabase({ id: 'matched-id' }, { id: 'existing-edge' }); + const { created } = await resolveConnections('src-id', [SUGGESTIONS[0]], supabase as never, 'user-1'); + expect(created).toBe(0); + expect(supabase._mockInsert).not.toHaveBeenCalled(); + }); + + it('processes multiple suggestions independently', async () => { + const supabase = makeSupabase({ id: 'matched-id' }); + const { created } = await resolveConnections('src-id', SUGGESTIONS, supabase as never, 'user-1'); + expect(created).toBeGreaterThan(0); + }); + + it('skips suggestions with empty target_title', async () => { + const supabase = makeSupabase({ id: 'matched-id' }); + const empty: SuggestedConnection = { target_title: ' ', edge_type: 'supports', rationale: '' }; + const { created, unresolved } = await resolveConnections('src-id', [empty], supabase as never, 'user-1'); + expect(created).toBe(0); + expect(unresolved).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `node ./node_modules/vitest/vitest.mjs run src/lib/agents/__tests__/connectionResolver.test.ts --reporter=dot` +Expected: FAIL — `resolveConnections` returns a number; `created`/`unresolved` are undefined. + +- [ ] **Step 3: Update the implementation** + +In `src/lib/agents/connectionResolver.ts`, add the result interface after `SuggestedConnection`: + +```ts +export interface ResolveResult { + readonly created: number; + readonly unresolved: SuggestedConnection[]; +} +``` + +Change the signature return type from `Promise` to `Promise`, and update the body. Replace lines from `if (!suggestions.length) return 0;` through the final `return created;` with: + +```ts + const unresolved: SuggestedConnection[] = []; + if (!suggestions.length) return { created: 0, unresolved }; + + let created = 0; + + for (const suggestion of suggestions) { + if (!suggestion.target_title?.trim()) continue; + if (!VALID_EDGE_TYPES.has(suggestion.edge_type)) continue; + + const { data: match } = await supabase + .from('nodes') + .select('id') + .ilike('title', suggestion.target_title.trim()) + .neq('id', sourceNodeId) + .in('status', ['promoted', 'human_reviewed', 'llm_reviewed']) + .limit(1) + .maybeSingle(); + + if (!match) { + unresolved.push(suggestion); + continue; + } + + const { data: existing } = await supabase + .from('edges') + .select('id') + .eq('source_id', sourceNodeId) + .eq('target_id', match.id) + .maybeSingle(); + + if (existing) continue; + + const { error } = await supabase.from('edges').insert({ + source_id: sourceNodeId, + target_id: match.id, + edge_type: suggestion.edge_type, + weight: 1, + author_id: userId, + }); + + if (error) { + process.stderr.write(`[connectionResolver] Edge insert failed (${sourceNodeId} -> ${match.id}, type: ${suggestion.edge_type}): ${error.message}\n`); + } else { + created++; + } + } + + return { created, unresolved }; +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `node ./node_modules/vitest/vitest.mjs run src/lib/agents/__tests__/connectionResolver.test.ts --reporter=dot` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/agents/connectionResolver.ts src/lib/agents/__tests__/connectionResolver.test.ts +git commit -m "refactor(edges): resolveConnections returns created + unresolved misses" +``` + +--- + +## Task 4: Wire the async semantic step into capture/process + +**Files:** +- Modify: `src/app/api/capture/process/route.ts` (document path ~line 246; single-node path ~line 321) + +`after` (from `next/server`) and `createAdminClient` (from `@/lib/supabase/admin`) are already imported in this file — no new imports needed. + +- [ ] **Step 1: Update the document-extraction call site** + +Replace this block: + +```ts + if (suggestions && suggestions.length > 0) { + await resolveConnections(child.id as string, suggestions, supabase, user.id); + } +``` + +with: + +```ts + if (suggestions && suggestions.length > 0) { + const childId = child.id as string; + const { unresolved } = await resolveConnections(childId, suggestions, supabase, user.id); + if (unresolved.length > 0) { + after(() => + import('@/lib/agents/semanticEdges').then(m => + m.resolveSemantically(childId, unresolved, createAdminClient(), user.id), + ), + ); + } + } +``` + +- [ ] **Step 2: Update the single-node call site** + +Replace this block: + +```ts + const { resolveConnections } = await import('@/lib/agents/connectionResolver'); + await resolveConnections( + node_id, + extraction.suggested_connections, + supabase, + user.id, + ); +``` + +with: + +```ts + const { resolveConnections } = await import('@/lib/agents/connectionResolver'); + const { unresolved } = await resolveConnections( + node_id, + extraction.suggested_connections, + supabase, + user.id, + ); + if (unresolved.length > 0) { + after(() => + import('@/lib/agents/semanticEdges').then(m => + m.resolveSemantically(node_id, unresolved, createAdminClient(), user.id), + ), + ); + } +``` + +- [ ] **Step 3: Verify types compile** + +Run: `node ./node_modules/typescript/bin/tsc --noEmit` +Expected: 0 errors (clear `.tsbuildinfo` first if present). + +- [ ] **Step 4: Commit** + +```bash +git add src/app/api/capture/process/route.ts +git commit -m "feat(edges): schedule async semantic resolution for unplaced connections" +``` + +--- + +## Task 5: `PATCH /api/edge-suggestions/[id]` accept/dismiss + +**Files:** +- Create: `src/app/api/edge-suggestions/[id]/route.ts` +- Test: `src/app/api/edge-suggestions/__tests__/route.test.ts` + +- [ ] **Step 1: Write the failing test** + +Mirrors `src/app/api/newsletters/__tests__/route.test.ts`. The mock returns the suggestion row on read, records the edge insert on accept, and records the status update. + +```ts +// src/app/api/edge-suggestions/__tests__/route.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockFrom = vi.fn(); +const mockSupabase = { + auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }) }, + from: mockFrom, +}; +vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn().mockResolvedValue(mockSupabase) })); + +const SUGGESTION_ROW = { id: 'sug-1', source_id: 's1', target_id: 't1', edge_type: 'supports' }; + +function makeParams(id: string) { + return Promise.resolve({ id }); +} + +describe('PATCH /api/edge-suggestions/[id]', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('returns 400 for an invalid action', async () => { + const { PATCH } = await import('../[id]/route'); + const req = new Request('http://test/api/edge-suggestions/sug-1', { + method: 'PATCH', body: JSON.stringify({ action: 'nope' }), + headers: { 'Content-Type': 'application/json' }, + }); + const res = await PATCH(req, { params: makeParams('sug-1') }); + expect(res.status).toBe(400); + }); + + it('dismiss marks the suggestion dismissed and creates no edge', async () => { + const update = vi.fn(() => ({ eq: vi.fn().mockResolvedValue({ error: null }) })); + const edgeInsert = vi.fn(); + mockFrom.mockImplementation((table: string) => { + if (table === 'edge_suggestions') return { update }; + if (table === 'edges') return { insert: edgeInsert }; + return {}; + }); + const { PATCH } = await import('../[id]/route'); + const req = new Request('http://test/api/edge-suggestions/sug-1', { + method: 'PATCH', body: JSON.stringify({ action: 'dismiss' }), + headers: { 'Content-Type': 'application/json' }, + }); + const res = await PATCH(req, { params: makeParams('sug-1') }); + expect(res.status).toBe(200); + expect(edgeInsert).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledWith(expect.objectContaining({ status: 'dismissed' })); + }); + + it('accept inserts the edge and marks the suggestion accepted', async () => { + const update = vi.fn(() => ({ eq: vi.fn().mockResolvedValue({ error: null }) })); + const edgeInsert = vi.fn().mockResolvedValue({ error: null }); + const sugSelect = vi.fn(() => ({ eq: vi.fn(() => ({ maybeSingle: vi.fn().mockResolvedValue({ data: SUGGESTION_ROW }) })) })); + mockFrom.mockImplementation((table: string) => { + if (table === 'edge_suggestions') return { select: sugSelect, update }; + if (table === 'edges') return { insert: edgeInsert }; + return {}; + }); + const { PATCH } = await import('../[id]/route'); + const req = new Request('http://test/api/edge-suggestions/sug-1', { + method: 'PATCH', body: JSON.stringify({ action: 'accept' }), + headers: { 'Content-Type': 'application/json' }, + }); + const res = await PATCH(req, { params: makeParams('sug-1') }); + expect(res.status).toBe(200); + expect(edgeInsert).toHaveBeenCalledWith(expect.objectContaining({ + source_id: 's1', target_id: 't1', edge_type: 'supports', + })); + expect(update).toHaveBeenCalledWith(expect.objectContaining({ status: 'accepted' })); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `node ./node_modules/vitest/vitest.mjs run src/app/api/edge-suggestions/__tests__/route.test.ts --reporter=dot` +Expected: FAIL — cannot resolve `../[id]/route`. + +- [ ] **Step 3: Write the implementation** + +```ts +// src/app/api/edge-suggestions/[id]/route.ts +import { withAuth, ok, fail } from '@/lib/api/withAuth'; + +// Resolve an edge suggestion: 'accept' creates the real edge then marks the +// suggestion accepted; 'dismiss' marks it dismissed without creating an edge. +export const PATCH = withAuth<{ id: string }>(async ({ request, supabase, user, params }) => { + const { id } = await params; + + let body: { action?: unknown }; + try { + body = await request.json(); + } catch { + return fail('Invalid JSON body'); + } + if (body.action !== 'accept' && body.action !== 'dismiss') { + return fail('action must be "accept" or "dismiss"'); + } + + if (body.action === 'dismiss') { + const { error } = await supabase + .from('edge_suggestions') + .update({ status: 'dismissed' }) + .eq('id', id); + if (error) return fail(error.message, 500); + return ok({ id, status: 'dismissed' }); + } + + // accept + const { data: suggestion, error: readErr } = await supabase + .from('edge_suggestions') + .select('source_id, target_id, edge_type') + .eq('id', id) + .maybeSingle(); + if (readErr) return fail(readErr.message, 500); + if (!suggestion) return fail('Suggestion not found', 404); + + const { error: edgeErr } = await supabase.from('edges').insert({ + source_id: suggestion.source_id, + target_id: suggestion.target_id, + edge_type: suggestion.edge_type, + weight: 1, + author_id: user.id, + }); + if (edgeErr) return fail(edgeErr.message, 500); + + const { error: updateErr } = await supabase + .from('edge_suggestions') + .update({ status: 'accepted' }) + .eq('id', id); + if (updateErr) return fail(updateErr.message, 500); + + return ok({ id, status: 'accepted' }); +}); +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `node ./node_modules/vitest/vitest.mjs run src/app/api/edge-suggestions/__tests__/route.test.ts --reporter=dot` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/app/api/edge-suggestions +git commit -m "feat(edges): PATCH /api/edge-suggestions/[id] accept/dismiss" +``` + +--- + +## Task 6: `SuggestedConnectionItem` component + +**Files:** +- Create: `src/components/review/SuggestedConnectionItem.tsx` +- Test: `src/components/review/__tests__/SuggestedConnectionItem.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Mirrors `src/components/review/__tests__/DuplicateItem.test.tsx`. + +```tsx +// src/components/review/__tests__/SuggestedConnectionItem.test.tsx +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; + +vi.mock('next/link', () => ({ + default: ({ href, children, className }: { href: string; children: React.ReactNode; className?: string }) => + React.createElement('a', { href, className }, children), +})); + +import { SuggestedConnectionItem, type ReviewEdgeSuggestion } from '../SuggestedConnectionItem'; + +const suggestion: ReviewEdgeSuggestion = { + id: 'sug-1', + similarity: 0.74, + edgeType: 'supports', + rationale: 'It builds on the earlier finding', + source: { id: 's1', title: 'New debt-relief hunch' }, + target: { id: 't1', title: 'Patient debt program' }, +}; + +describe('SuggestedConnectionItem', () => { + it('renders both node titles, edge type, rationale, and percent', () => { + render(); + expect(screen.getByText('New debt-relief hunch')).toBeTruthy(); + expect(screen.getByText('Patient debt program')).toBeTruthy(); + expect(screen.getByText(/supports/)).toBeTruthy(); + expect(screen.getByText('It builds on the earlier finding')).toBeTruthy(); + expect(screen.getByText('74% match')).toBeTruthy(); + }); + + it('links the target node to its capture page', () => { + render(); + expect(screen.getByText('Patient debt program').closest('a')?.getAttribute('href')).toBe('/capture/t1'); + }); + + it('fires onAccept with the suggestion', () => { + const onAccept = vi.fn(); + render(); + fireEvent.click(screen.getByText('Add connection')); + expect(onAccept).toHaveBeenCalledWith(suggestion); + }); + + it('fires onDismiss with the suggestion id', () => { + const onDismiss = vi.fn(); + render(); + fireEvent.click(screen.getByText('Dismiss')); + expect(onDismiss).toHaveBeenCalledWith('sug-1'); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `node ./node_modules/vitest/vitest.mjs run src/components/review/__tests__/SuggestedConnectionItem.test.tsx --reporter=dot` +Expected: FAIL — cannot resolve `../SuggestedConnectionItem`. + +- [ ] **Step 3: Write the implementation** + +```tsx +// src/components/review/SuggestedConnectionItem.tsx +'use client'; + +import Link from 'next/link'; + +export interface ReviewEdgeSuggestion { + readonly id: string; // edge_suggestions row id + readonly similarity: number; + readonly edgeType: string; + readonly rationale: string | null; + readonly source: { readonly id: string; readonly title: string }; + readonly target: { readonly id: string; readonly title: string }; +} + +interface SuggestedConnectionItemProps { + readonly suggestion: ReviewEdgeSuggestion; + readonly onAccept: (suggestion: ReviewEdgeSuggestion) => void; + readonly onDismiss: (suggestionId: string) => void; +} + +export function SuggestedConnectionItem({ suggestion, onAccept, onDismiss }: SuggestedConnectionItemProps) { + const pct = Math.round(suggestion.similarity * 100); + const edgeLabel = suggestion.edgeType.replace(/_/g, ' '); + return ( +
+
+ Suggested connection + {pct}% match +
+
+
+
This entry
+
{suggestion.source.title}
+
+
+ {edgeLabel} + +
+ +
Connects to
+
{suggestion.target.title}
+ +
+ {suggestion.rationale && ( +

{suggestion.rationale}

+ )} +
+ + +
+
+ ); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `node ./node_modules/vitest/vitest.mjs run src/components/review/__tests__/SuggestedConnectionItem.test.tsx --reporter=dot` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/components/review/SuggestedConnectionItem.tsx src/components/review/__tests__/SuggestedConnectionItem.test.tsx +git commit -m "feat(edges): SuggestedConnectionItem review-inbox row" +``` + +--- + +## Task 7: "Suggested connections" section in SystemHealthClient + +**Files:** +- Modify: `src/app/review/SystemHealthClient.tsx` + +- [ ] **Step 1: Add the import** + +After the existing `DuplicateItem` import line: + +```ts +import { DuplicateItem, type ReviewDuplicate } from '@/components/review/DuplicateItem'; +``` + +add: + +```ts +import { SuggestedConnectionItem, type ReviewEdgeSuggestion } from '@/components/review/SuggestedConnectionItem'; +``` + +- [ ] **Step 2: Add the prop to the interface** + +In `SystemHealthClientProps`, after `readonly duplicates: readonly ReviewDuplicate[];` add: + +```ts + readonly edgeSuggestions: readonly ReviewEdgeSuggestion[]; +``` + +- [ ] **Step 3: Destructure the prop and add state** + +In the component params, after `duplicates: initialDuplicates,` add `edgeSuggestions: initialEdgeSuggestions,`. +After the `duplicates` `useState` line add: + +```ts + const [edgeSuggestions, setEdgeSuggestions] = useState(initialEdgeSuggestions); +``` + +- [ ] **Step 4: Add the accept/dismiss handlers** + +After `handleArchiveDuplicate` add: + +```ts + const resolveEdgeSuggestion = useCallback(async (id: string, action: 'accept' | 'dismiss') => { + await fetch(`/api/edge-suggestions/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action }), + }).catch(() => {}); + }, []); + + const handleAcceptSuggestion = useCallback((suggestion: ReviewEdgeSuggestion) => { + setEdgeSuggestions(prev => prev.filter(s => s.id !== suggestion.id)); + void resolveEdgeSuggestion(suggestion.id, 'accept'); + }, [resolveEdgeSuggestion]); + + const handleDismissSuggestion = useCallback((id: string) => { + setEdgeSuggestions(prev => prev.filter(s => s.id !== id)); + void resolveEdgeSuggestion(id, 'dismiss'); + }, [resolveEdgeSuggestion]); +``` + +- [ ] **Step 5: Render the section** + +Immediately after the closing `)}` of the `{duplicates.length > 0 && ( ... )}` section, add: + +```tsx + {edgeSuggestions.length > 0 && ( +
+

+ Suggested connections · {edgeSuggestions.length} +

+
+ {edgeSuggestions.map(suggestion => ( + + ))} +
+
+ )} +``` + +- [ ] **Step 6: Verify types compile** + +Run: `node ./node_modules/typescript/bin/tsc --noEmit` +Expected: 0 errors. (`page.tsx` will be updated in Task 8 to pass the new required prop; if tsc is run now it will flag the missing prop at the `` call — that is expected and fixed in Task 8. Proceed.) + +- [ ] **Step 7: Commit** + +```bash +git add src/app/review/SystemHealthClient.tsx +git commit -m "feat(edges): Suggested connections section in the review inbox" +``` + +--- + +## Task 8: Fetch open edge suggestions in review/page.tsx + +**Files:** +- Modify: `src/app/review/page.tsx` + +- [ ] **Step 1: Add the import and row type** + +After `import type { ReviewDuplicate } from '@/components/review/DuplicateItem';` add: + +```ts +import type { ReviewEdgeSuggestion } from '@/components/review/SuggestedConnectionItem'; +``` + +After the `DuplicateRow` interface add: + +```ts +interface EdgeSuggestionRow { + id: string; + similarity: number; + edge_type: string; + rationale: string | null; + source_id: string; + target_id: string; +} +``` + +- [ ] **Step 2: Add the 5th parallel query** + +In the `Promise.all([...])` destructuring, add `edgeSugRes` after `dupesRes`: + +```ts + const [flaggedRes, tensionsRes, awaitingRes, dupesRes, edgeSugRes] = await Promise.all([ +``` + +and append this query as the last array element (after the `duplicate_candidates` query, keeping flagged/awaiting at indices 0 and 2 so `ReviewPage.test.tsx` stays green): + +```ts + supabase + .from('edge_suggestions') + .select('id, similarity, edge_type, rationale, source_id, target_id') + .eq('status', 'open') + .order('created_at', { ascending: false }), +``` + +- [ ] **Step 3: Fold suggestion node ids into the title lookup** + +After `const dupeRows = (dupesRes.data ?? []) as DuplicateRow[];` add: + +```ts + const edgeSugRows = (edgeSugRes.data ?? []) as EdgeSuggestionRow[]; +``` + +In the `titleIds` set, add the suggestion source/target ids. Replace the `titleIds` definition with: + +```ts + const titleIds = Array.from(new Set([ + ...queue.map(e => e.node.parent_node_id).filter((id): id is string => Boolean(id)), + ...dupeRows.flatMap(d => [d.node_id, d.similar_node_id]), + ...edgeSugRows.flatMap(s => [s.source_id, s.target_id]), + ])); +``` + +- [ ] **Step 4: Build the resolved suggestions and pass the prop** + +After the `duplicates` array is built, add: + +```ts + const edgeSuggestions: ReviewEdgeSuggestion[] = edgeSugRows + .filter(s => titles[s.source_id] && titles[s.target_id]) + .map(s => ({ + id: s.id, + similarity: s.similarity, + edgeType: s.edge_type, + rationale: s.rationale, + source: { id: s.source_id, title: titles[s.source_id] }, + target: { id: s.target_id, title: titles[s.target_id] }, + })); +``` + +In the `` JSX, add the prop after `duplicates={duplicates}`: + +```tsx + edgeSuggestions={edgeSuggestions} +``` + +- [ ] **Step 5: Verify types compile** + +Run: `node ./node_modules/typescript/bin/tsc --noEmit` +Expected: 0 errors. + +- [ ] **Step 6: Run the ReviewPage test to confirm it stays green** + +Run: `node ./node_modules/vitest/vitest.mjs run src/app/review/__tests__/ReviewPage.test.tsx --reporter=dot` +Expected: PASS (3 tests). The mock already supplies 6 datasets and the new query supports `.select().eq().order()`. + +- [ ] **Step 7: Commit** + +```bash +git add src/app/review/page.tsx +git commit -m "feat(edges): load open edge suggestions into the review inbox" +``` + +--- + +## Task 9: Full verification gauntlet + +**Files:** none (verification only) + +- [ ] **Step 1: Clear tsbuildinfo and run tsc** + +```bash +cd /Users/gurden/Documents/code/cof-learning-system/.claude/worktrees/phase-1 +rm -f *.tsbuildinfo +node ./node_modules/typescript/bin/tsc --noEmit +``` +Expected: 0 errors. + +- [ ] **Step 2: Run lint** + +```bash +node ./node_modules/eslint/bin/eslint.js . -f json -o /tmp/lint.json +node -e "const r=require('/tmp/lint.json');console.log('errors:',r.flatMap(f=>f.messages.filter(m=>m.severity===2)).length)" +``` +Expected: `errors: 0`. + +- [ ] **Step 3: Run the full test suite** + +```bash +node ./node_modules/vitest/vitest.mjs run --reporter=dot +``` +Expected: all files pass (existing suites + the new semanticEdges, connectionResolver, edge-suggestions route, SuggestedConnectionItem; ReviewPage still green). + +- [ ] **Step 4: Push and open the PR** + +```bash +git push -u origin phase-3-semantic-edges +``` +Then open a PR (base `main`) summarizing: exact-first inline matching unchanged; async tiered semantic fallback; `edge_suggestions` table + review section + accept/dismiss endpoint; forward-only. Note the rollout step below. + +--- + +## Rollout (user, after merge) + +1. Run `supabase/v1.3-edge-suggestions.sql` in the Supabase SQL editor. +2. `VOYAGE_API_KEY` is already set; depends on `match_nodes` (`v1.1-embeddings.sql`) and embeddings being backfilled. + +--- + +## Self-Review (completed) + +- **Spec coverage:** architecture/flow → Tasks 3,4,2; schema → Task 1; thresholds → Task 2; `connectionResolver` change → Task 3; `semanticEdges` → Task 2; endpoint → Task 5; `SuggestedConnectionItem` → Task 6; `SystemHealthClient` → Task 7; `review/page.tsx` → Task 8; error handling (non-fatal/no-op/rpc) → Task 2 tests; testing list → Tasks 2,3,5,6,8; rollout/out-of-scope → captured. No gaps. +- **Type consistency:** `ResolveResult { created, unresolved }` (Task 3) consumed in Task 4; `resolveSemantically(sourceId, suggestions, supabase, userId)` (Task 2) called in Task 4; `ReviewEdgeSuggestion` (Task 6) used in Tasks 7,8; `edgeSuggestions` prop name consistent across Tasks 7,8; endpoint action `'accept' | 'dismiss'` consistent across Tasks 5,7. +- **Placeholder scan:** none — every code step contains full content. From 4847f9154ae091f3407c8a0e7f02dfd14d95ac3d Mon Sep 17 00:00:00 2001 From: Gurden Batra Date: Mon, 22 Jun 2026 19:01:02 +0200 Subject: [PATCH 03/11] feat(edges): edge_suggestions table for semantic edge review --- supabase/v1.3-edge-suggestions.sql | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 supabase/v1.3-edge-suggestions.sql diff --git a/supabase/v1.3-edge-suggestions.sql b/supabase/v1.3-edge-suggestions.sql new file mode 100644 index 0000000..7f0e1a8 --- /dev/null +++ b/supabase/v1.3-edge-suggestions.sql @@ -0,0 +1,31 @@ +-- supabase/v1.3-edge-suggestions.sql +-- Phase 3, slice 3: semantic edge resolution. Stores mid-confidence semantic +-- connection matches for human confirm/dismiss in the Review inbox. Inserted by +-- src/lib/agents/semanticEdges.ts (service-role). Run in the Supabase SQL editor. +-- Depends on v1.1-embeddings.sql (match_nodes RPC). + +create table if not exists edge_suggestions ( + id uuid primary key default gen_random_uuid(), + source_id uuid not null references nodes(id) on delete cascade, + target_id uuid not null references nodes(id) on delete cascade, -- semantically matched node + edge_type text not null references edge_types(id), + rationale text, -- LLM's reason, shown in review + similarity float not null, + status text not null default 'open', -- open | accepted | dismissed + created_at timestamptz not null default now(), + unique (source_id, target_id, edge_type) +); + +create index if not exists edge_suggestions_status on edge_suggestions(status); + +alter table edge_suggestions enable row level security; + +drop policy if exists "auth read edge suggestions" on edge_suggestions; +create policy "auth read edge suggestions" on edge_suggestions + for select to authenticated using (true); + +drop policy if exists "auth update edge suggestions" on edge_suggestions; +create policy "auth update edge suggestions" on edge_suggestions + for update to authenticated using (true) with check (true); + +-- Inserts (detection) happen via the service-role key, which bypasses RLS. From 16b5ef893297b03d947ca3d827f7ca87befce20e Mon Sep 17 00:00:00 2001 From: Gurden Batra Date: Tue, 23 Jun 2026 09:33:47 +0200 Subject: [PATCH 04/11] =?UTF-8?q?feat(edges):=20resolveSemantically=20?= =?UTF-8?q?=E2=80=94=20tiered=20semantic=20match=20for=20unplaced=20connec?= =?UTF-8?q?tions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agents/__tests__/semanticEdges.test.ts | 104 ++++++++++++++++++ src/lib/agents/semanticEdges.ts | 90 +++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 src/lib/agents/__tests__/semanticEdges.test.ts create mode 100644 src/lib/agents/semanticEdges.ts diff --git a/src/lib/agents/__tests__/semanticEdges.test.ts b/src/lib/agents/__tests__/semanticEdges.test.ts new file mode 100644 index 0000000..adc1b77 --- /dev/null +++ b/src/lib/agents/__tests__/semanticEdges.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockEmbedText = vi.fn(); +vi.mock('@/lib/llm/embeddings', () => ({ + embedText: (...args: unknown[]) => mockEmbedText(...args), +})); + +import { + resolveSemantically, + EDGE_AUTO_THRESHOLD, + EDGE_REVIEW_THRESHOLD, +} from '../semanticEdges'; +import type { SuggestedConnection } from '../connectionResolver'; + +type Match = { id: string; similarity: number }; + +function makeSupabase(matches: Match[], opts: { existingEdge?: boolean; rpcError?: { message: string } } = {}) { + const edgeInsert = vi.fn().mockResolvedValue({ error: null }); + const suggestionUpsert = vi.fn().mockResolvedValue({ error: null }); + const maybeSingle = vi.fn().mockResolvedValue({ data: opts.existingEdge ? { id: 'e1' } : null }); + const supabase = { + rpc: vi.fn().mockResolvedValue({ data: matches, error: opts.rpcError ?? null }), + from: vi.fn((table: string) => { + if (table === 'edges') { + return { + select: vi.fn(() => ({ eq: vi.fn(() => ({ eq: vi.fn(() => ({ eq: vi.fn(() => ({ maybeSingle })) })) })) })), + insert: edgeInsert, + }; + } + return { upsert: suggestionUpsert }; + }), + _edgeInsert: edgeInsert, + _suggestionUpsert: suggestionUpsert, + }; + return supabase; +} + +const SUGGESTION: SuggestedConnection = { + target_title: 'Formation capital strategy', + edge_type: 'supports', + rationale: 'Directly supports', +}; + +describe('resolveSemantically', () => { + beforeEach(() => { vi.clearAllMocks(); mockEmbedText.mockResolvedValue([0.1, 0.2, 0.3]); }); + + it('auto-creates an edge when the top match is at/above the auto threshold', async () => { + const supabase = makeSupabase([{ id: 'target', similarity: 0.9 }]); + await resolveSemantically('src', [SUGGESTION], supabase as never, 'user-1'); + expect(supabase._edgeInsert).toHaveBeenCalledWith( + expect.objectContaining({ source_id: 'src', target_id: 'target', edge_type: 'supports', weight: 0.9 }), + ); + expect(supabase._suggestionUpsert).not.toHaveBeenCalled(); + }); + + it('records a review suggestion when the top match is in the review band', async () => { + const supabase = makeSupabase([{ id: 'target', similarity: 0.72 }]); + await resolveSemantically('src', [SUGGESTION], supabase as never, 'user-1'); + expect(supabase._suggestionUpsert).toHaveBeenCalledWith( + expect.objectContaining({ source_id: 'src', target_id: 'target', edge_type: 'supports', status: 'open' }), + expect.objectContaining({ onConflict: 'source_id,target_id,edge_type' }), + ); + expect(supabase._edgeInsert).not.toHaveBeenCalled(); + }); + + it('drops the suggestion when the top match is below the review threshold', async () => { + const supabase = makeSupabase([{ id: 'target', similarity: 0.4 }]); + await resolveSemantically('src', [SUGGESTION], supabase as never, 'user-1'); + expect(supabase._edgeInsert).not.toHaveBeenCalled(); + expect(supabase._suggestionUpsert).not.toHaveBeenCalled(); + }); + + it('ignores the source node itself in the matches', async () => { + const supabase = makeSupabase([{ id: 'src', similarity: 0.99 }]); + await resolveSemantically('src', [SUGGESTION], supabase as never, 'user-1'); + expect(supabase._edgeInsert).not.toHaveBeenCalled(); + expect(supabase._suggestionUpsert).not.toHaveBeenCalled(); + }); + + it('skips when an edge for the pair+type already exists', async () => { + const supabase = makeSupabase([{ id: 'target', similarity: 0.9 }], { existingEdge: true }); + await resolveSemantically('src', [SUGGESTION], supabase as never, 'user-1'); + expect(supabase._edgeInsert).not.toHaveBeenCalled(); + }); + + it('no-ops (no rpc) when embeddings are unavailable', async () => { + mockEmbedText.mockResolvedValue(null); + const supabase = makeSupabase([{ id: 'target', similarity: 0.9 }]); + await resolveSemantically('src', [SUGGESTION], supabase as never, 'user-1'); + expect(supabase.rpc).not.toHaveBeenCalled(); + }); + + it('is non-fatal on an rpc error', async () => { + const supabase = makeSupabase([], { rpcError: { message: 'boom' } }); + await expect(resolveSemantically('src', [SUGGESTION], supabase as never, 'user-1')).resolves.toBeUndefined(); + expect(supabase._edgeInsert).not.toHaveBeenCalled(); + }); + + it('orders thresholds sanely', () => { + expect(EDGE_REVIEW_THRESHOLD).toBeGreaterThan(0); + expect(EDGE_REVIEW_THRESHOLD).toBeLessThan(EDGE_AUTO_THRESHOLD); + expect(EDGE_AUTO_THRESHOLD).toBeLessThan(1); + }); +}); diff --git a/src/lib/agents/semanticEdges.ts b/src/lib/agents/semanticEdges.ts new file mode 100644 index 0000000..78b7f97 --- /dev/null +++ b/src/lib/agents/semanticEdges.ts @@ -0,0 +1,90 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import { embedText } from '@/lib/llm/embeddings'; +import type { SuggestedConnection } from './connectionResolver'; + +/** Auto-create an edge at/above this cosine similarity. */ +export const EDGE_AUTO_THRESHOLD = 0.80; +/** Between this and the auto threshold → surface for review; below → drop. */ +export const EDGE_REVIEW_THRESHOLD = 0.65; + +interface MatchRow { + readonly id: string; + readonly similarity: number; +} + +/** + * For each connection suggestion the exact-title resolver could not place, + * embed the target title and find its nearest vetted node via match_nodes. + * Tier the top hit: auto-create an edge (>= AUTO), record an open + * edge_suggestions row (REVIEW..AUTO), or drop (< REVIEW). Intended to run + * async via after(); non-fatal — never throws, no-ops when embeddings are + * unavailable. + */ +export async function resolveSemantically( + sourceId: string, + suggestions: ReadonlyArray, + supabase: SupabaseClient, + userId: string, +): Promise { + for (const suggestion of suggestions) { + try { + const title = suggestion.target_title?.trim(); + if (!title) continue; + + const embedding = await embedText(title, 'query'); + if (!embedding) continue; + + const { data, error } = await supabase.rpc('match_nodes', { + query_embedding: embedding, + match_count: 5, + }); + if (error) { + console.error('[semanticEdges] match_nodes failed:', error.message); + continue; + } + + const top = ((data ?? []) as MatchRow[]) + .filter(m => m.id !== sourceId) + .sort((a, b) => b.similarity - a.similarity)[0]; + if (!top || top.similarity < EDGE_REVIEW_THRESHOLD) continue; + + const { data: existing } = await supabase + .from('edges') + .select('id') + .eq('source_id', sourceId) + .eq('target_id', top.id) + .eq('edge_type', suggestion.edge_type) + .maybeSingle(); + if (existing) continue; + + const tier = top.similarity >= EDGE_AUTO_THRESHOLD ? 'auto' : 'review'; + console.error( + `[semanticEdges] "${title}" -> ${top.id} sim=${top.similarity.toFixed(3)} tier=${tier}`, + ); + + if (tier === 'auto') { + await supabase.from('edges').insert({ + source_id: sourceId, + target_id: top.id, + edge_type: suggestion.edge_type, + weight: top.similarity, + author_id: userId, + }); + } else { + await supabase.from('edge_suggestions').upsert( + { + source_id: sourceId, + target_id: top.id, + edge_type: suggestion.edge_type, + rationale: suggestion.rationale ?? null, + similarity: top.similarity, + status: 'open', + }, + { onConflict: 'source_id,target_id,edge_type' }, + ); + } + } catch (err) { + console.error('[semanticEdges] failed for', suggestion.target_title, err); + } + } +} From 958105fd0d92e2440a797ffc9c9b64a744bd4690 Mon Sep 17 00:00:00 2001 From: Gurden Batra Date: Tue, 23 Jun 2026 09:54:59 +0200 Subject: [PATCH 05/11] feat(edges): schedule async semantic resolution for unplaced connections --- src/app/api/capture/process/route.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/app/api/capture/process/route.ts b/src/app/api/capture/process/route.ts index 972e8e9..b04b89c 100644 --- a/src/app/api/capture/process/route.ts +++ b/src/app/api/capture/process/route.ts @@ -244,7 +244,15 @@ export const POST = withAuth(async ({ request, user, supabase }) => { const child = insertedChildren[i]; const suggestions = documentExtraction.extracted_nodes[i]?.suggested_connections; if (suggestions && suggestions.length > 0) { - await resolveConnections(child.id as string, suggestions, supabase, user.id); + const childId = child.id as string; + const { unresolved } = await resolveConnections(childId, suggestions, supabase, user.id); + if (unresolved.length > 0) { + after(() => + import('@/lib/agents/semanticEdges').then(m => + m.resolveSemantically(childId, unresolved, createAdminClient(), user.id), + ), + ); + } } } } @@ -319,12 +327,19 @@ export const POST = withAuth(async ({ request, user, supabase }) => { } const { resolveConnections } = await import('@/lib/agents/connectionResolver'); - await resolveConnections( + const { unresolved } = await resolveConnections( node_id, extraction.suggested_connections, supabase, user.id, ); + if (unresolved.length > 0) { + after(() => + import('@/lib/agents/semanticEdges').then(m => + m.resolveSemantically(node_id, unresolved, createAdminClient(), user.id), + ), + ); + } // Log activity await supabase.from('activity_log').insert({ From c606ecded111fbd9e252f33870000ca2d82cf12d Mon Sep 17 00:00:00 2001 From: Gurden Batra Date: Tue, 23 Jun 2026 09:39:06 +0200 Subject: [PATCH 06/11] refactor(edges): resolveConnections returns created + unresolved misses --- .../__tests__/connectionResolver.test.ts | 37 +++++++++---------- src/lib/agents/connectionResolver.ts | 19 +++++++--- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/src/lib/agents/__tests__/connectionResolver.test.ts b/src/lib/agents/__tests__/connectionResolver.test.ts index 550a016..934393b 100644 --- a/src/lib/agents/__tests__/connectionResolver.test.ts +++ b/src/lib/agents/__tests__/connectionResolver.test.ts @@ -42,49 +42,48 @@ const SUGGESTIONS: SuggestedConnection[] = [ ]; describe('resolveConnections', () => { - it('returns 0 when suggestions is empty', async () => { + it('returns created 0 and no unresolved when suggestions is empty', async () => { const supabase = makeSupabase(null); - const count = await resolveConnections('src-id', [], supabase as never, 'user-1'); - expect(count).toBe(0); + const { created, unresolved } = await resolveConnections('src-id', [], supabase as never, 'user-1'); + expect(created).toBe(0); + expect(unresolved).toEqual([]); }); it('creates an edge when a matching node is found', async () => { const supabase = makeSupabase({ id: 'matched-id' }); - const count = await resolveConnections('src-id', [SUGGESTIONS[0]], supabase as never, 'user-1'); - expect(count).toBe(1); + const { created } = await resolveConnections('src-id', [SUGGESTIONS[0]], supabase as never, 'user-1'); + expect(created).toBe(1); expect(supabase._mockInsert).toHaveBeenCalledWith( - expect.objectContaining({ - source_id: 'src-id', - target_id: 'matched-id', - edge_type: 'supports', - }) + expect.objectContaining({ source_id: 'src-id', target_id: 'matched-id', edge_type: 'supports' }), ); }); - it('skips when no node matches the title', async () => { + it('returns unmatched valid suggestions in unresolved instead of creating', async () => { const supabase = makeSupabase(null); - const count = await resolveConnections('src-id', [SUGGESTIONS[1]], supabase as never, 'user-1'); - expect(count).toBe(0); + const { created, unresolved } = await resolveConnections('src-id', [SUGGESTIONS[1]], supabase as never, 'user-1'); + expect(created).toBe(0); + expect(unresolved).toEqual([SUGGESTIONS[1]]); expect(supabase._mockInsert).not.toHaveBeenCalled(); }); it('skips when edge already exists', async () => { const supabase = makeSupabase({ id: 'matched-id' }, { id: 'existing-edge' }); - const count = await resolveConnections('src-id', [SUGGESTIONS[0]], supabase as never, 'user-1'); - expect(count).toBe(0); + const { created } = await resolveConnections('src-id', [SUGGESTIONS[0]], supabase as never, 'user-1'); + expect(created).toBe(0); expect(supabase._mockInsert).not.toHaveBeenCalled(); }); it('processes multiple suggestions independently', async () => { const supabase = makeSupabase({ id: 'matched-id' }); - const count = await resolveConnections('src-id', SUGGESTIONS, supabase as never, 'user-1'); - expect(count).toBeGreaterThan(0); + const { created } = await resolveConnections('src-id', SUGGESTIONS, supabase as never, 'user-1'); + expect(created).toBeGreaterThan(0); }); it('skips suggestions with empty target_title', async () => { const supabase = makeSupabase({ id: 'matched-id' }); const empty: SuggestedConnection = { target_title: ' ', edge_type: 'supports', rationale: '' }; - const count = await resolveConnections('src-id', [empty], supabase as never, 'user-1'); - expect(count).toBe(0); + const { created, unresolved } = await resolveConnections('src-id', [empty], supabase as never, 'user-1'); + expect(created).toBe(0); + expect(unresolved).toEqual([]); }); }); diff --git a/src/lib/agents/connectionResolver.ts b/src/lib/agents/connectionResolver.ts index c29d8c1..d7655d3 100644 --- a/src/lib/agents/connectionResolver.ts +++ b/src/lib/agents/connectionResolver.ts @@ -6,6 +6,11 @@ export interface SuggestedConnection { readonly rationale: string; } +export interface ResolveResult { + readonly created: number; + readonly unresolved: SuggestedConnection[]; +} + const VALID_EDGE_TYPES = new Set([ 'supports', 'contradicts', 'requires', 'evolved_from', 'tested_by', 'produced', 'connected_to', 'works_at', 'authored_by', 'challenges', @@ -18,8 +23,9 @@ export async function resolveConnections( suggestions: ReadonlyArray, supabase: SupabaseClient, userId: string, -): Promise { - if (!suggestions.length) return 0; +): Promise { + const unresolved: SuggestedConnection[] = []; + if (!suggestions.length) return { created: 0, unresolved }; let created = 0; @@ -36,7 +42,10 @@ export async function resolveConnections( .limit(1) .maybeSingle(); - if (!match) continue; + if (!match) { + unresolved.push(suggestion); + continue; + } const { data: existing } = await supabase .from('edges') @@ -56,11 +65,11 @@ export async function resolveConnections( }); if (error) { - process.stderr.write(`[connectionResolver] Edge insert failed (${sourceNodeId} → ${match.id}, type: ${suggestion.edge_type}): ${error.message}\n`); + process.stderr.write(`[connectionResolver] Edge insert failed (${sourceNodeId} -> ${match.id}, type: ${suggestion.edge_type}): ${error.message}\n`); } else { created++; } } - return created; + return { created, unresolved }; } From 50a578ea086c977d42594cd563f22c983265ea0c Mon Sep 17 00:00:00 2001 From: Gurden Batra Date: Tue, 23 Jun 2026 10:11:17 +0200 Subject: [PATCH 07/11] feat(edges): PATCH /api/edge-suggestions/[id] accept/dismiss --- src/app/api/edge-suggestions/[id]/route.ts | 52 ++++++++++++++ .../edge-suggestions/__tests__/route.test.ts | 69 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 src/app/api/edge-suggestions/[id]/route.ts create mode 100644 src/app/api/edge-suggestions/__tests__/route.test.ts diff --git a/src/app/api/edge-suggestions/[id]/route.ts b/src/app/api/edge-suggestions/[id]/route.ts new file mode 100644 index 0000000..58eb5f1 --- /dev/null +++ b/src/app/api/edge-suggestions/[id]/route.ts @@ -0,0 +1,52 @@ +import { withAuth, ok, fail } from '@/lib/api/withAuth'; + +// Resolve an edge suggestion: 'accept' creates the real edge then marks the +// suggestion accepted; 'dismiss' marks it dismissed without creating an edge. +export const PATCH = withAuth<{ id: string }>(async ({ request, supabase, user, params }) => { + const { id } = await params; + + let body: { action?: unknown }; + try { + body = await request.json(); + } catch { + return fail('Invalid JSON body'); + } + if (body.action !== 'accept' && body.action !== 'dismiss') { + return fail('action must be "accept" or "dismiss"'); + } + + if (body.action === 'dismiss') { + const { error } = await supabase + .from('edge_suggestions') + .update({ status: 'dismissed' }) + .eq('id', id); + if (error) return fail(error.message, 500); + return ok({ id, status: 'dismissed' }); + } + + // accept + const { data: suggestion, error: readErr } = await supabase + .from('edge_suggestions') + .select('source_id, target_id, edge_type') + .eq('id', id) + .maybeSingle(); + if (readErr) return fail(readErr.message, 500); + if (!suggestion) return fail('Suggestion not found', 404); + + const { error: edgeErr } = await supabase.from('edges').insert({ + source_id: suggestion.source_id, + target_id: suggestion.target_id, + edge_type: suggestion.edge_type, + weight: 1, + author_id: user.id, + }); + if (edgeErr) return fail(edgeErr.message, 500); + + const { error: updateErr } = await supabase + .from('edge_suggestions') + .update({ status: 'accepted' }) + .eq('id', id); + if (updateErr) return fail(updateErr.message, 500); + + return ok({ id, status: 'accepted' }); +}); diff --git a/src/app/api/edge-suggestions/__tests__/route.test.ts b/src/app/api/edge-suggestions/__tests__/route.test.ts new file mode 100644 index 0000000..bc206cc --- /dev/null +++ b/src/app/api/edge-suggestions/__tests__/route.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockFrom = vi.fn(); +const mockSupabase = { + auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }) }, + from: mockFrom, +}; +vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn().mockResolvedValue(mockSupabase) })); + +const SUGGESTION_ROW = { id: 'sug-1', source_id: 's1', target_id: 't1', edge_type: 'supports' }; + +function makeParams(id: string) { + return Promise.resolve({ id }); +} + +describe('PATCH /api/edge-suggestions/[id]', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('returns 400 for an invalid action', async () => { + const { PATCH } = await import('../[id]/route'); + const req = new Request('http://test/api/edge-suggestions/sug-1', { + method: 'PATCH', body: JSON.stringify({ action: 'nope' }), + headers: { 'Content-Type': 'application/json' }, + }); + const res = await PATCH(req, { params: makeParams('sug-1') }); + expect(res.status).toBe(400); + }); + + it('dismiss marks the suggestion dismissed and creates no edge', async () => { + const update = vi.fn(() => ({ eq: vi.fn().mockResolvedValue({ error: null }) })); + const edgeInsert = vi.fn(); + mockFrom.mockImplementation((table: string) => { + if (table === 'edge_suggestions') return { update }; + if (table === 'edges') return { insert: edgeInsert }; + return {}; + }); + const { PATCH } = await import('../[id]/route'); + const req = new Request('http://test/api/edge-suggestions/sug-1', { + method: 'PATCH', body: JSON.stringify({ action: 'dismiss' }), + headers: { 'Content-Type': 'application/json' }, + }); + const res = await PATCH(req, { params: makeParams('sug-1') }); + expect(res.status).toBe(200); + expect(edgeInsert).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledWith(expect.objectContaining({ status: 'dismissed' })); + }); + + it('accept inserts the edge and marks the suggestion accepted', async () => { + const update = vi.fn(() => ({ eq: vi.fn().mockResolvedValue({ error: null }) })); + const edgeInsert = vi.fn().mockResolvedValue({ error: null }); + const sugSelect = vi.fn(() => ({ eq: vi.fn(() => ({ maybeSingle: vi.fn().mockResolvedValue({ data: SUGGESTION_ROW }) })) })); + mockFrom.mockImplementation((table: string) => { + if (table === 'edge_suggestions') return { select: sugSelect, update }; + if (table === 'edges') return { insert: edgeInsert }; + return {}; + }); + const { PATCH } = await import('../[id]/route'); + const req = new Request('http://test/api/edge-suggestions/sug-1', { + method: 'PATCH', body: JSON.stringify({ action: 'accept' }), + headers: { 'Content-Type': 'application/json' }, + }); + const res = await PATCH(req, { params: makeParams('sug-1') }); + expect(res.status).toBe(200); + expect(edgeInsert).toHaveBeenCalledWith(expect.objectContaining({ + source_id: 's1', target_id: 't1', edge_type: 'supports', + })); + expect(update).toHaveBeenCalledWith(expect.objectContaining({ status: 'accepted' })); + }); +}); From 152f4441c5cea85c6c914de14a719c85db550505 Mon Sep 17 00:00:00 2001 From: Gurden Batra Date: Tue, 23 Jun 2026 10:15:45 +0200 Subject: [PATCH 08/11] feat(edges): SuggestedConnectionItem review-inbox row --- .../review/SuggestedConnectionItem.tsx | 64 +++++++++++++++++++ .../SuggestedConnectionItem.test.tsx | 49 ++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 src/components/review/SuggestedConnectionItem.tsx create mode 100644 src/components/review/__tests__/SuggestedConnectionItem.test.tsx diff --git a/src/components/review/SuggestedConnectionItem.tsx b/src/components/review/SuggestedConnectionItem.tsx new file mode 100644 index 0000000..ec92e32 --- /dev/null +++ b/src/components/review/SuggestedConnectionItem.tsx @@ -0,0 +1,64 @@ +'use client'; + +import Link from 'next/link'; + +export interface ReviewEdgeSuggestion { + readonly id: string; // edge_suggestions row id + readonly similarity: number; + readonly edgeType: string; + readonly rationale: string | null; + readonly source: { readonly id: string; readonly title: string }; + readonly target: { readonly id: string; readonly title: string }; +} + +interface SuggestedConnectionItemProps { + readonly suggestion: ReviewEdgeSuggestion; + readonly onAccept: (suggestion: ReviewEdgeSuggestion) => void; + readonly onDismiss: (suggestionId: string) => void; +} + +export function SuggestedConnectionItem({ suggestion, onAccept, onDismiss }: SuggestedConnectionItemProps) { + const pct = Math.round(suggestion.similarity * 100); + const edgeLabel = suggestion.edgeType.replace(/_/g, ' '); + return ( +
+
+ Suggested connection + {pct}% match +
+
+
+
This entry
+
{suggestion.source.title}
+
+
+ {edgeLabel} + +
+ +
Connects to
+
{suggestion.target.title}
+ +
+ {suggestion.rationale && ( +

{suggestion.rationale}

+ )} +
+ + +
+
+ ); +} diff --git a/src/components/review/__tests__/SuggestedConnectionItem.test.tsx b/src/components/review/__tests__/SuggestedConnectionItem.test.tsx new file mode 100644 index 0000000..6bfb182 --- /dev/null +++ b/src/components/review/__tests__/SuggestedConnectionItem.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; + +vi.mock('next/link', () => ({ + default: ({ href, children, className }: { href: string; children: React.ReactNode; className?: string }) => + React.createElement('a', { href, className }, children), +})); + +import { SuggestedConnectionItem, type ReviewEdgeSuggestion } from '../SuggestedConnectionItem'; + +const suggestion: ReviewEdgeSuggestion = { + id: 'sug-1', + similarity: 0.74, + edgeType: 'supports', + rationale: 'It builds on the earlier finding', + source: { id: 's1', title: 'New debt-relief hunch' }, + target: { id: 't1', title: 'Patient debt program' }, +}; + +describe('SuggestedConnectionItem', () => { + it('renders both node titles, edge type, rationale, and percent', () => { + render(); + expect(screen.getByText('New debt-relief hunch')).toBeTruthy(); + expect(screen.getByText('Patient debt program')).toBeTruthy(); + expect(screen.getByText(/supports/)).toBeTruthy(); + expect(screen.getByText('It builds on the earlier finding')).toBeTruthy(); + expect(screen.getByText('74% match')).toBeTruthy(); + }); + + it('links the target node to its capture page', () => { + render(); + expect(screen.getByText('Patient debt program').closest('a')?.getAttribute('href')).toBe('/capture/t1'); + }); + + it('fires onAccept with the suggestion', () => { + const onAccept = vi.fn(); + render(); + fireEvent.click(screen.getByText('Add connection')); + expect(onAccept).toHaveBeenCalledWith(suggestion); + }); + + it('fires onDismiss with the suggestion id', () => { + const onDismiss = vi.fn(); + render(); + fireEvent.click(screen.getByText('Dismiss')); + expect(onDismiss).toHaveBeenCalledWith('sug-1'); + }); +}); From fba94e794d397d6d798f675fb89049d262c9fc8e Mon Sep 17 00:00:00 2001 From: Gurden Batra Date: Tue, 23 Jun 2026 10:17:53 +0200 Subject: [PATCH 09/11] feat(edges): Suggested connections section in the review inbox --- src/app/review/SystemHealthClient.tsx | 40 +++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/app/review/SystemHealthClient.tsx b/src/app/review/SystemHealthClient.tsx index e3f48c3..3fe1f4e 100644 --- a/src/app/review/SystemHealthClient.tsx +++ b/src/app/review/SystemHealthClient.tsx @@ -5,6 +5,7 @@ import type { Node } from '@/lib/types/nodes'; import type { TensionAlert } from '@/lib/types/tension'; import { ReviewItem, type ReviewKind } from '@/components/review/ReviewItem'; import { DuplicateItem, type ReviewDuplicate } from '@/components/review/DuplicateItem'; +import { SuggestedConnectionItem, type ReviewEdgeSuggestion } from '@/components/review/SuggestedConnectionItem'; import { Markdown } from '@/components/ui/Markdown'; export interface ReviewQueueEntry { @@ -17,6 +18,7 @@ interface SystemHealthClientProps { readonly tensions: readonly TensionAlert[]; readonly sourceTitles: Readonly>; readonly duplicates: readonly ReviewDuplicate[]; + readonly edgeSuggestions: readonly ReviewEdgeSuggestion[]; } const SEVERITY_COLORS: Record = { @@ -30,9 +32,11 @@ export function SystemHealthClient({ tensions, sourceTitles, duplicates: initialDuplicates, + edgeSuggestions: initialEdgeSuggestions, }: SystemHealthClientProps) { const [queue, setQueue] = useState(initialQueue); const [duplicates, setDuplicates] = useState(initialDuplicates); + const [edgeSuggestions, setEdgeSuggestions] = useState(initialEdgeSuggestions); const [itemErrors, setItemErrors] = useState>({}); const resolveDuplicate = useCallback(async (candidateId: string, status: 'dismissed' | 'resolved') => { @@ -59,6 +63,24 @@ export function SystemHealthClient({ void resolveDuplicate(dup.id, 'resolved'); }, [resolveDuplicate]); + const resolveEdgeSuggestion = useCallback(async (id: string, action: 'accept' | 'dismiss') => { + await fetch(`/api/edge-suggestions/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action }), + }).catch(() => {}); + }, []); + + const handleAcceptSuggestion = useCallback((suggestion: ReviewEdgeSuggestion) => { + setEdgeSuggestions(prev => prev.filter(s => s.id !== suggestion.id)); + void resolveEdgeSuggestion(suggestion.id, 'accept'); + }, [resolveEdgeSuggestion]); + + const handleDismissSuggestion = useCallback((id: string) => { + setEdgeSuggestions(prev => prev.filter(s => s.id !== id)); + void resolveEdgeSuggestion(id, 'dismiss'); + }, [resolveEdgeSuggestion]); + const mutate = useCallback(async (id: string, status: 'promoted' | 'archived', verb: string) => { try { const res = await fetch(`/api/nodes/${id}`, { @@ -99,6 +121,24 @@ export function SystemHealthClient({ )} + {edgeSuggestions.length > 0 && ( +
+

+ Suggested connections · {edgeSuggestions.length} +

+
+ {edgeSuggestions.map(suggestion => ( + + ))} +
+
+ )} +

Review queue {queue.length > 0 && · {queue.length}} From 7807e9793d07653e1c29045e00fc4a4cd283dbde Mon Sep 17 00:00:00 2001 From: Gurden Batra Date: Tue, 23 Jun 2026 10:19:46 +0200 Subject: [PATCH 10/11] feat(edges): load open edge suggestions into the review inbox --- src/app/review/page.tsx | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/app/review/page.tsx b/src/app/review/page.tsx index 437cbca..b627f56 100644 --- a/src/app/review/page.tsx +++ b/src/app/review/page.tsx @@ -3,6 +3,7 @@ import { getKnowledgeReviewTypes } from '@/lib/config/captureTypes'; import { redirect } from 'next/navigation'; import { SystemHealthClient, type ReviewQueueEntry } from './SystemHealthClient'; import type { ReviewDuplicate } from '@/components/review/DuplicateItem'; +import type { ReviewEdgeSuggestion } from '@/components/review/SuggestedConnectionItem'; import type { Node } from '@/lib/types/nodes'; import type { TensionAlert } from '@/lib/types/tension'; @@ -13,6 +14,15 @@ interface DuplicateRow { similar_node_id: string; } +interface EdgeSuggestionRow { + id: string; + similarity: number; + edge_type: string; + rationale: string | null; + source_id: string; + target_id: string; +} + export const dynamic = 'force-dynamic'; export default async function SystemHealthPage() { @@ -20,7 +30,7 @@ export default async function SystemHealthPage() { const { data: { user }, error: authError } = await supabase.auth.getUser(); if (authError || !user) redirect('/login'); - const [flaggedRes, tensionsRes, awaitingRes, dupesRes] = await Promise.all([ + const [flaggedRes, tensionsRes, awaitingRes, dupesRes, edgeSugRes] = await Promise.all([ supabase .from('nodes') .select('*') @@ -42,6 +52,11 @@ export default async function SystemHealthPage() { .select('id, similarity, node_id, similar_node_id') .eq('status', 'open') .order('created_at', { ascending: false }), + supabase + .from('edge_suggestions') + .select('id, similarity, edge_type, rationale, source_id, target_id') + .eq('status', 'open') + .order('created_at', { ascending: false }), ]); const flagged = (flaggedRes.data ?? []) as unknown as Node[]; @@ -53,12 +68,14 @@ export default async function SystemHealthPage() { ]; const dupeRows = (dupesRes.data ?? []) as DuplicateRow[]; + const edgeSugRows = (edgeSugRes.data ?? []) as EdgeSuggestionRow[]; // One title lookup covering both the "from " tags on extracted // children and the node pairs in the duplicates section. const titleIds = Array.from(new Set([ ...queue.map(e => e.node.parent_node_id).filter((id): id is string => Boolean(id)), ...dupeRows.flatMap(d => [d.node_id, d.similar_node_id]), + ...edgeSugRows.flatMap(s => [s.source_id, s.target_id]), ])); let titles: Record = {}; if (titleIds.length > 0) { @@ -76,6 +93,17 @@ export default async function SystemHealthPage() { similarTo: { id: d.similar_node_id, title: titles[d.similar_node_id] }, })); + const edgeSuggestions: ReviewEdgeSuggestion[] = edgeSugRows + .filter(s => titles[s.source_id] && titles[s.target_id]) + .map(s => ({ + id: s.id, + similarity: s.similarity, + edgeType: s.edge_type, + rationale: s.rationale, + source: { id: s.source_id, title: titles[s.source_id] }, + target: { id: s.target_id, title: titles[s.target_id] }, + })); + return (
@@ -85,6 +113,7 @@ export default async function SystemHealthPage() { tensions={(tensionsRes.data ?? []) as unknown as TensionAlert[]} sourceTitles={titles} duplicates={duplicates} + edgeSuggestions={edgeSuggestions} />
From 371fda604d28a34b9abcf84680e98af50e4b0a4b Mon Sep 17 00:00:00 2001 From: Gurden Batra Date: Tue, 23 Jun 2026 11:56:56 +0200 Subject: [PATCH 11/11] fix(edges): make edge-suggestion accept idempotent (only act on open) --- src/app/api/edge-suggestions/[id]/route.ts | 3 ++- .../edge-suggestions/__tests__/route.test.ts | 21 ++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/app/api/edge-suggestions/[id]/route.ts b/src/app/api/edge-suggestions/[id]/route.ts index 58eb5f1..1969d75 100644 --- a/src/app/api/edge-suggestions/[id]/route.ts +++ b/src/app/api/edge-suggestions/[id]/route.ts @@ -29,9 +29,10 @@ export const PATCH = withAuth<{ id: string }>(async ({ request, supabase, user, .from('edge_suggestions') .select('source_id, target_id, edge_type') .eq('id', id) + .eq('status', 'open') .maybeSingle(); if (readErr) return fail(readErr.message, 500); - if (!suggestion) return fail('Suggestion not found', 404); + if (!suggestion) return fail('Suggestion not found or already resolved', 409); const { error: edgeErr } = await supabase.from('edges').insert({ source_id: suggestion.source_id, diff --git a/src/app/api/edge-suggestions/__tests__/route.test.ts b/src/app/api/edge-suggestions/__tests__/route.test.ts index bc206cc..bc052ff 100644 --- a/src/app/api/edge-suggestions/__tests__/route.test.ts +++ b/src/app/api/edge-suggestions/__tests__/route.test.ts @@ -48,7 +48,7 @@ describe('PATCH /api/edge-suggestions/[id]', () => { it('accept inserts the edge and marks the suggestion accepted', async () => { const update = vi.fn(() => ({ eq: vi.fn().mockResolvedValue({ error: null }) })); const edgeInsert = vi.fn().mockResolvedValue({ error: null }); - const sugSelect = vi.fn(() => ({ eq: vi.fn(() => ({ maybeSingle: vi.fn().mockResolvedValue({ data: SUGGESTION_ROW }) })) })); + const sugSelect = vi.fn(() => ({ eq: vi.fn(() => ({ eq: vi.fn(() => ({ maybeSingle: vi.fn().mockResolvedValue({ data: SUGGESTION_ROW }) })) })) })); mockFrom.mockImplementation((table: string) => { if (table === 'edge_suggestions') return { select: sugSelect, update }; if (table === 'edges') return { insert: edgeInsert }; @@ -66,4 +66,23 @@ describe('PATCH /api/edge-suggestions/[id]', () => { })); expect(update).toHaveBeenCalledWith(expect.objectContaining({ status: 'accepted' })); }); + + it('accept on a missing/already-resolved suggestion returns 409 and creates no edge', async () => { + const update = vi.fn(() => ({ eq: vi.fn().mockResolvedValue({ error: null }) })); + const edgeInsert = vi.fn(); + const sugSelect = vi.fn(() => ({ eq: vi.fn(() => ({ eq: vi.fn(() => ({ maybeSingle: vi.fn().mockResolvedValue({ data: null }) })) })) })); + mockFrom.mockImplementation((table: string) => { + if (table === 'edge_suggestions') return { select: sugSelect, update }; + if (table === 'edges') return { insert: edgeInsert }; + return {}; + }); + const { PATCH } = await import('../[id]/route'); + const req = new Request('http://test/api/edge-suggestions/sug-1', { + method: 'PATCH', body: JSON.stringify({ action: 'accept' }), + headers: { 'Content-Type': 'application/json' }, + }); + const res = await PATCH(req, { params: makeParams('sug-1') }); + expect(res.status).toBe(409); + expect(edgeInsert).not.toHaveBeenCalled(); + }); });