Skip to content

Commit 27c2e01

Browse files
committed
feat(day-8): F742/F746/F751 activate semantic & hybrid search UI
Keyword/semantic/hybrid mode toggle now fully live in the ⌘⇧F overlay, each calling /search?mode=; degraded-state banner when the embeddings index is still building; per-result 'why?' score breakdown in hybrid mode (explain). RelatedPanel gains a real 'similar by meaning' section from /notes/:id/related/semantic. Embeddings coverage indicator with a 'build index' button that backfills and polls. 8 new tests; 1,518 total; typecheck, lint, build, bundle all green. https://claude.ai/code/session_01C8N8jgTF1MfofAu6t5CU1W
1 parent 36ca352 commit 27c2e01

8 files changed

Lines changed: 677 additions & 45 deletions

File tree

FEATURES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Your notes are the world. Your stories run on a compiler you own.
1717
6. Keep `pnpm test` and `pnpm build` green at every commit. Do not leave the tree broken at end of session.
1818
7. Update the **Status** line below at the end of every session.
1919

20-
**Status:** Day 8 intelligence backend done: embeddings + vector + hybrid search (F721–F750) with graceful pure-JS degradation, no native deps. 1,510 tests green. Remaining Day 8: semantic UI activation (F754 backend done), ingestion/clipper/audio (F761–F790, later wave). Next: wire semantic/hybrid modes in web, then F761.
20+
**Status:** Day 8 search/semantic COMPLETE: keyword+semantic+hybrid search live in UI with degraded-state handling, explain breakdowns, semantic related-notes, embeddings index control. 1,518 tests green. Remaining Day 8: ingestion/clipper/audio (F761–F790, heavy-dep wave). Next: F761.
2121

2222
---
2323

apps/web/src/api/client.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -638,19 +638,29 @@ export const attachmentsApi = {
638638
},
639639
};
640640

641-
/* ===== Search (F711–F720) ===== */
641+
/* ===== Search (F711–F720, F742, F746) ===== */
642642

643643
export interface SearchHighlight {
644644
start: number;
645645
end: number;
646646
}
647647

648+
/** Score breakdown from explain=true or scoreComponents in response (F746). */
649+
export interface ScoreComponents {
650+
fts?: number;
651+
vector?: number;
652+
recency?: number;
653+
links?: number;
654+
[key: string]: number | undefined;
655+
}
656+
648657
export interface SearchResult {
649658
id: string;
650659
title: string;
651660
snippet: string;
652661
highlights: SearchHighlight[];
653662
score: number;
663+
scoreComponents?: ScoreComponents;
654664
}
655665

656666
export interface SearchGroup {
@@ -664,6 +674,7 @@ export type SearchMode = 'keyword' | 'semantic' | 'hybrid';
664674
export interface SearchData {
665675
mode: SearchMode;
666676
query: string;
677+
degraded?: boolean;
667678
groups: SearchGroup[];
668679
}
669680

@@ -675,8 +686,10 @@ export interface SearchResponse {
675686
export interface SearchParams {
676687
q: string;
677688
types?: string;
689+
mode?: SearchMode;
678690
limit?: number;
679691
cursor?: string;
692+
explain?: boolean;
680693
}
681694

682695
async function requestSearch(params: SearchParams): Promise<SearchResponse> {
@@ -700,6 +713,57 @@ export const searchApi = {
700713
search: (params: SearchParams) => requestSearch(params),
701714
};
702715

716+
/* ===== Semantic related notes (F751/F754) ===== */
717+
718+
export interface SemanticRelatedResult {
719+
id: string;
720+
title: string;
721+
score: number;
722+
snippet: string;
723+
sourceType: string;
724+
}
725+
726+
export interface SemanticRelatedData {
727+
noteId: string;
728+
degraded: boolean;
729+
results: SemanticRelatedResult[];
730+
}
731+
732+
export const relatedApi = {
733+
semantic: (noteId: string, limit = 8) =>
734+
api.get<SemanticRelatedData>(`/notes/${noteId}/related/semantic${qs({ limit })}`),
735+
};
736+
737+
/* ===== Embeddings status + backfill (F742/embeddings indicator) ===== */
738+
739+
export interface EmbeddingsProvider {
740+
id: string;
741+
dim: number;
742+
available: boolean;
743+
}
744+
745+
export interface EmbeddingsCoverage {
746+
coveragePct: number;
747+
[key: string]: unknown;
748+
}
749+
750+
export interface EmbeddingsQueue {
751+
queueDepth: number;
752+
[key: string]: unknown;
753+
}
754+
755+
export interface EmbeddingsStatus {
756+
provider: EmbeddingsProvider;
757+
coverage: EmbeddingsCoverage;
758+
queue: EmbeddingsQueue;
759+
}
760+
761+
export const embeddingsApi = {
762+
status: () => api.get<EmbeddingsStatus>('/embeddings/status'),
763+
backfill: () =>
764+
request<{ message: string; provider: string }>('/embeddings/backfill', { method: 'POST' }),
765+
};
766+
703767
/* ===== Insights (F791–F800) ===== */
704768

705769
export interface InsightsOverview {

apps/web/src/api/hooks.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
66
import {
77
attachmentsApi,
8+
embeddingsApi,
89
graphApi,
910
importApi,
1011
insightsApi,
1112
linksApi,
1213
notebooksApi,
1314
notesApi,
1415
queryApi,
16+
relatedApi,
1517
revisionsApi,
1618
savedQueriesApi,
1719
searchApi,
@@ -499,3 +501,36 @@ export function useRelatedByLinks(noteId: string | null, enabled = true) {
499501
staleTime: 30_000,
500502
});
501503
}
504+
505+
/** Semantic nearest-neighbor related notes (F751/F754). */
506+
export function useRelatedBySemantic(noteId: string | null, limit = 8, enabled = true) {
507+
return useQuery({
508+
queryKey: ['related', 'semantic', noteId ?? 'none', limit],
509+
queryFn: () => relatedApi.semantic(noteId as string, limit),
510+
enabled: noteId !== null && enabled,
511+
staleTime: 60_000,
512+
});
513+
}
514+
515+
/* ===== Embeddings status (F742 indicator) ===== */
516+
517+
export function useEmbeddingsStatus(enabled = true) {
518+
return useQuery({
519+
queryKey: ['embeddings', 'status'],
520+
queryFn: embeddingsApi.status,
521+
enabled,
522+
staleTime: 10_000,
523+
refetchInterval: (query) => {
524+
const depth = query.state.data?.queue.queueDepth ?? 0;
525+
return depth > 0 ? 3000 : false;
526+
},
527+
});
528+
}
529+
530+
export function useEmbeddingsBackfill() {
531+
const qc = useQueryClient();
532+
return useMutation({
533+
mutationFn: embeddingsApi.backfill,
534+
onSuccess: () => void qc.invalidateQueries({ queryKey: ['embeddings', 'status'] }),
535+
});
536+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// @vitest-environment jsdom
2+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3+
import { describe, expect, it, vi } from 'vitest';
4+
import { createWrapper, mockFetchRoutes } from '../test-utils/wrappers.js';
5+
import { RelatedPanel } from './RelatedPanel.js';
6+
import type { NoteWithTags } from '../api/client.js';
7+
8+
const mockNote: NoteWithTags = {
9+
id: 'note-1',
10+
notebookId: 'nb-1',
11+
title: 'Test Note',
12+
body: 'Some content',
13+
pinned: false,
14+
trashedAt: null,
15+
createdAt: '2025-01-01T00:00:00Z',
16+
updatedAt: '2025-01-01T00:00:00Z',
17+
rev: 1,
18+
tags: [],
19+
};
20+
21+
const mockNoteWithTags: NoteWithTags = {
22+
...mockNote,
23+
tags: [{ id: 't1', name: 'fiction', color: null, createdAt: '2025-01-01T00:00:00Z' }],
24+
};
25+
26+
const onClose = vi.fn();
27+
28+
const emptyGraph = {
29+
data: { nodes: [], edges: [], stats: { nodes: 0, edges: 0, orphans: 0, communities: 0 } },
30+
};
31+
const emptyBacklinks = {
32+
data: { noteId: 'note-1', total: 0, sources: [] },
33+
};
34+
35+
describe('RelatedPanel semantic section (F751/F754)', () => {
36+
it('renders "Similar by meaning" section with semantic results', async () => {
37+
mockFetchRoutes([
38+
{ url: '/api/v1/notes/note-1/graph', body: emptyGraph },
39+
{ url: '/api/v1/notes/note-1/backlinks', body: emptyBacklinks },
40+
{
41+
url: '/api/v1/notes/note-1/related/semantic',
42+
body: {
43+
data: {
44+
noteId: 'note-1',
45+
degraded: false,
46+
results: [
47+
{ id: 'note-2', title: 'Related Story', score: 0.87, snippet: 'About stories', sourceType: 'note' },
48+
{ id: 'note-3', title: 'Another Match', score: 0.72, snippet: 'More content', sourceType: 'note' },
49+
],
50+
},
51+
},
52+
},
53+
]);
54+
55+
render(<RelatedPanel note={mockNote} onClose={onClose} />, { wrapper: createWrapper() });
56+
57+
await waitFor(
58+
() => expect(screen.queryByText('Related Story')).not.toBeNull(),
59+
{ timeout: 3000 },
60+
);
61+
expect(screen.queryByText('Another Match')).not.toBeNull();
62+
// Score rendered as percentage
63+
expect(screen.queryByText('87%')).not.toBeNull();
64+
});
65+
66+
it('shows "building index" badge when degraded=true', async () => {
67+
mockFetchRoutes([
68+
{ url: '/api/v1/notes/note-1/graph', body: emptyGraph },
69+
{ url: '/api/v1/notes/note-1/backlinks', body: emptyBacklinks },
70+
{
71+
url: '/api/v1/notes/note-1/related/semantic',
72+
body: {
73+
data: {
74+
noteId: 'note-1',
75+
degraded: true,
76+
results: [
77+
{ id: 'note-4', title: 'Linked Note', score: 0.5, snippet: 'A linked note', sourceType: 'note' },
78+
],
79+
},
80+
},
81+
},
82+
]);
83+
84+
render(<RelatedPanel note={mockNote} onClose={onClose} />, { wrapper: createWrapper() });
85+
86+
await waitFor(
87+
() => expect(screen.queryByText('building index')).not.toBeNull(),
88+
{ timeout: 3000 },
89+
);
90+
// Shows "linked" instead of percentage when degraded
91+
expect(screen.queryByText('linked')).not.toBeNull();
92+
});
93+
94+
it('shows empty state when no semantic results', async () => {
95+
mockFetchRoutes([
96+
{ url: '/api/v1/notes/note-1/graph', body: emptyGraph },
97+
{ url: '/api/v1/notes/note-1/backlinks', body: emptyBacklinks },
98+
{
99+
url: '/api/v1/notes/note-1/related/semantic',
100+
body: {
101+
data: {
102+
noteId: 'note-1',
103+
degraded: false,
104+
results: [],
105+
},
106+
},
107+
},
108+
]);
109+
110+
render(<RelatedPanel note={mockNote} onClose={onClose} />, { wrapper: createWrapper() });
111+
112+
await waitFor(
113+
() => expect(screen.queryByText('No similar notes found yet.')).not.toBeNull(),
114+
{ timeout: 3000 },
115+
);
116+
});
117+
118+
it('dismissing a semantic result removes it from view', async () => {
119+
mockFetchRoutes([
120+
{ url: '/api/v1/notes/note-1/graph', body: emptyGraph },
121+
{ url: '/api/v1/notes/note-1/backlinks', body: emptyBacklinks },
122+
{
123+
url: '/api/v1/notes/note-1/related/semantic',
124+
body: {
125+
data: {
126+
noteId: 'note-1',
127+
degraded: false,
128+
results: [
129+
{ id: 'note-5', title: 'Dismissable Note', score: 0.9, snippet: 'Content', sourceType: 'note' },
130+
],
131+
},
132+
},
133+
},
134+
]);
135+
136+
render(<RelatedPanel note={mockNoteWithTags} onClose={onClose} />, { wrapper: createWrapper() });
137+
138+
await waitFor(
139+
() => expect(screen.queryByText('Dismissable Note')).not.toBeNull(),
140+
{ timeout: 3000 },
141+
);
142+
143+
const dismissBtn = screen.getByLabelText('Dismiss Dismissable Note');
144+
fireEvent.click(dismissBtn);
145+
146+
await waitFor(
147+
() => expect(screen.queryByText('Dismissable Note')).toBeNull(),
148+
{ timeout: 1000 },
149+
);
150+
});
151+
});

0 commit comments

Comments
 (0)