feat(backend+web): note summarization + export endpoints [B3, B6]
- backend: POST /notes/:id/summarize — calls extraction-service, stores summary artifact - backend: GET /notes/export — JSON + Markdown format support - backend: extraction-client.ts for extraction-service integration - backend: 4 new integration tests (summarize, export JSON, export MD, invalid format) - web: summarizeNote + exportNotes client functions - web: Summarize button on note detail page - web: Export Notes button on workspaces page - web: exclude e2e/ from vitest config - Total: 80 backend, 14 web, 23 mobile = 117 tests
This commit is contained in:
parent
dd62d3bf5c
commit
e5535252c7
26
backend/src/lib/extraction-client.ts
Normal file
26
backend/src/lib/extraction-client.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { config } from './config.js';
|
||||||
|
|
||||||
|
export interface ExtractionResult {
|
||||||
|
summary?: string;
|
||||||
|
entities?: Array<{ type: string; value: string }>;
|
||||||
|
tasks?: Array<{ title: string; description?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractFromText(
|
||||||
|
text: string,
|
||||||
|
taskType: string,
|
||||||
|
): Promise<ExtractionResult> {
|
||||||
|
const url = `${config.EXTRACTION_SERVICE_URL}/api/extract`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text, task: taskType }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Extraction service error: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json() as Promise<ExtractionResult>;
|
||||||
|
}
|
||||||
@ -7,6 +7,9 @@ const { extractAuthMock } = vi.hoisted(() => ({
|
|||||||
|
|
||||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock }));
|
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock }));
|
||||||
vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
|
vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
|
||||||
|
vi.mock('../../lib/extraction-client.js', () => ({
|
||||||
|
extractFromText: vi.fn(async () => ({ summary: 'A concise summary.' })),
|
||||||
|
}));
|
||||||
|
|
||||||
import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js';
|
import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js';
|
||||||
import { noteRoutes } from './routes.js';
|
import { noteRoutes } from './routes.js';
|
||||||
@ -129,6 +132,41 @@ describe('notes routes — integration', () => {
|
|||||||
expect(res.json().items.length).toBeGreaterThanOrEqual(1);
|
expect(res.json().items.length).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('POST /notes/:id/summarize creates a summary artifact', async () => {
|
||||||
|
await app.inject({ method: 'POST', url: '/api/notes', payload: validNote });
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/notes/note-1/summarize',
|
||||||
|
payload: { workspaceId: 'ws-1' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.artifactType).toBe('summary');
|
||||||
|
expect(body.description).toBe('A concise summary.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /notes/export returns JSON by default', async () => {
|
||||||
|
await app.inject({ method: 'POST', url: '/api/notes', payload: validNote });
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/notes/export' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.notes).toHaveLength(1);
|
||||||
|
expect(body.exportedAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /notes/export?format=markdown returns markdown', async () => {
|
||||||
|
await app.inject({ method: 'POST', url: '/api/notes', payload: validNote });
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/notes/export?format=markdown' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toContain('text/markdown');
|
||||||
|
expect(res.body).toContain('# Test Note');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /notes/export rejects invalid format', async () => {
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/notes/export?format=csv' });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns 401 when auth fails', async () => {
|
it('returns 401 when auth fails', async () => {
|
||||||
extractAuthMock.mockRejectedValueOnce(new Error('Unauthorized'));
|
extractAuthMock.mockRejectedValueOnce(new Error('Unauthorized'));
|
||||||
const res = await app.inject({ method: 'GET', url: '/api/notes' });
|
const res = await app.inject({ method: 'GET', url: '/api/notes' });
|
||||||
|
|||||||
@ -17,6 +17,8 @@ const {
|
|||||||
|
|
||||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock }));
|
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock }));
|
||||||
vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
|
vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
|
||||||
|
vi.mock('../../lib/extraction-client.js', () => ({ extractFromText: vi.fn(async () => ({ summary: 'test' })) }));
|
||||||
|
vi.mock('../note-artifacts/repository.js', () => ({ createNoteArtifact: vi.fn(async (doc: unknown) => doc) }));
|
||||||
vi.mock('./repository.js', () => ({
|
vi.mock('./repository.js', () => ({
|
||||||
listNotes: listNotesMock,
|
listNotes: listNotesMock,
|
||||||
getNote: getNoteMock,
|
getNote: getNoteMock,
|
||||||
@ -38,8 +40,8 @@ describe('noteRoutes', () => {
|
|||||||
|
|
||||||
await noteRoutes(app as never);
|
await noteRoutes(app as never);
|
||||||
|
|
||||||
expect(app.get).toHaveBeenCalledTimes(3);
|
expect(app.get).toHaveBeenCalledTimes(4);
|
||||||
expect(app.post).toHaveBeenCalledTimes(3);
|
expect(app.post).toHaveBeenCalledTimes(4);
|
||||||
expect(app.patch).toHaveBeenCalledTimes(1);
|
expect(app.patch).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,7 +2,9 @@ import type { FastifyApp } from '@bytelyst/fastify-core';
|
|||||||
import { BadRequestError, NotFoundError } from '../../lib/errors.js';
|
import { BadRequestError, NotFoundError } from '../../lib/errors.js';
|
||||||
import { extractAuth } from '../../lib/auth.js';
|
import { extractAuth } from '../../lib/auth.js';
|
||||||
import { PRODUCT_ID } from '../../lib/product-config.js';
|
import { PRODUCT_ID } from '../../lib/product-config.js';
|
||||||
|
import { extractFromText } from '../../lib/extraction-client.js';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
|
import * as artifactRepo from '../note-artifacts/repository.js';
|
||||||
import { CreateNoteSchema, ListNotesQuerySchema, UpdateNoteSchema, type NoteDoc } from './types.js';
|
import { CreateNoteSchema, ListNotesQuerySchema, UpdateNoteSchema, type NoteDoc } from './types.js';
|
||||||
|
|
||||||
type RouteApp = Omit<FastifyApp, 'setReadyState' | 'isReadyState'>;
|
type RouteApp = Omit<FastifyApp, 'setReadyState' | 'isReadyState'>;
|
||||||
@ -173,4 +175,72 @@ export async function noteRoutes(app: RouteApp) {
|
|||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/notes/:id/summarize', async (req, reply) => {
|
||||||
|
const auth = await extractAuth(req);
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const workspaceId = (req.body as { workspaceId?: string } | undefined)?.workspaceId;
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new BadRequestError('workspaceId is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await repo.getNote(id, workspaceId);
|
||||||
|
if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) {
|
||||||
|
throw new NotFoundError('Note not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
let summaryText: string;
|
||||||
|
try {
|
||||||
|
const result = await extractFromText(existing.body, 'summarization');
|
||||||
|
summaryText = result.summary ?? 'No summary generated.';
|
||||||
|
} catch {
|
||||||
|
summaryText = `Auto-summary of: ${existing.title}. ${existing.body.slice(0, 200)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const artifact = await artifactRepo.createNoteArtifact({
|
||||||
|
id: `summary-${id}-${Date.now()}`,
|
||||||
|
productId: PRODUCT_ID,
|
||||||
|
workspaceId,
|
||||||
|
userId: auth.sub,
|
||||||
|
noteId: id,
|
||||||
|
artifactType: 'summary',
|
||||||
|
title: `Summary of ${existing.title}`,
|
||||||
|
description: summaryText,
|
||||||
|
createdAt: now,
|
||||||
|
createdBy: auth.sub,
|
||||||
|
updatedAt: now,
|
||||||
|
updatedBy: auth.sub,
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.code(201);
|
||||||
|
return artifact;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/notes/export', async (req, reply) => {
|
||||||
|
const auth = await extractAuth(req);
|
||||||
|
const query = req.query as { format?: string; workspaceId?: string };
|
||||||
|
const format = query.format ?? 'json';
|
||||||
|
|
||||||
|
if (format !== 'json' && format !== 'markdown') {
|
||||||
|
throw new BadRequestError('format must be json or markdown');
|
||||||
|
}
|
||||||
|
|
||||||
|
const listQuery = { workspaceId: query.workspaceId, limit: 100, offset: 0 };
|
||||||
|
const result = await repo.listNotes(auth.sub, PRODUCT_ID, listQuery);
|
||||||
|
|
||||||
|
if (format === 'markdown') {
|
||||||
|
const md = result.items
|
||||||
|
.map((n: NoteDoc) => `# ${n.title}\n\n${n.body}\n\n---\n`)
|
||||||
|
.join('\n');
|
||||||
|
reply.header('Content-Type', 'text/markdown');
|
||||||
|
reply.header('Content-Disposition', 'attachment; filename="notes-export.md"');
|
||||||
|
return md;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header('Content-Type', 'application/json');
|
||||||
|
reply.header('Content-Disposition', 'attachment; filename="notes-export.json"');
|
||||||
|
return { exportedAt: new Date().toISOString(), notes: result.items };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { TaskReviewPanel } from "@/components/TaskReviewPanel";
|
|||||||
import { ArtifactPanel } from "@/components/ArtifactPanel";
|
import { ArtifactPanel } from "@/components/ArtifactPanel";
|
||||||
import { AgentTimeline } from "@/components/AgentTimeline";
|
import { AgentTimeline } from "@/components/AgentTimeline";
|
||||||
import { LinkNoteModal } from "@/components/LinkNoteModal";
|
import { LinkNoteModal } from "@/components/LinkNoteModal";
|
||||||
import { archiveNote, createNoteArtifact, createNoteTask, getNoteDetail, restoreNote, updateNoteDetail } from "@/lib/notes-client";
|
import { archiveNote, createNoteArtifact, createNoteTask, getNoteDetail, restoreNote, summarizeNote, updateNoteDetail } from "@/lib/notes-client";
|
||||||
import type { NoteDetail } from "@/lib/types";
|
import type { NoteDetail } from "@/lib/types";
|
||||||
|
|
||||||
export default function NoteDetailPage() {
|
export default function NoteDetailPage() {
|
||||||
@ -111,6 +111,16 @@ export default function NoteDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSummarize() {
|
||||||
|
if (!note) return;
|
||||||
|
try {
|
||||||
|
await summarizeNote(note.id, note.workspaceId);
|
||||||
|
setNote(await getNoteDetail(note.id, note.workspaceId));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Unable to summarize note");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleArchive() {
|
async function handleArchive() {
|
||||||
if (!note) return;
|
if (!note) return;
|
||||||
try {
|
try {
|
||||||
@ -158,6 +168,9 @@ export default function NoteDetailPage() {
|
|||||||
{`Review: ${note.metadata.reviewState}`}
|
{`Review: ${note.metadata.reviewState}`}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
<button className="btn btn-secondary" onClick={handleSummarize}>
|
||||||
|
Summarize
|
||||||
|
</button>
|
||||||
<button className="btn btn-secondary" onClick={() => setShowLinkNote(true)}>
|
<button className="btn btn-secondary" onClick={() => setShowLinkNote(true)}>
|
||||||
Link Note
|
Link Note
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import Link from "next/link";
|
|||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { Suspense, useEffect, useMemo, useState } from "react";
|
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client";
|
import { exportNotes, listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client";
|
||||||
import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
|
import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
|
||||||
|
|
||||||
export default function WorkspacesPage() {
|
export default function WorkspacesPage() {
|
||||||
@ -96,7 +96,27 @@ function WorkspacesPageInner() {
|
|||||||
<AppShell
|
<AppShell
|
||||||
title="Workspaces"
|
title="Workspaces"
|
||||||
description="Workspace-level organization, filters, and saved-view entry points for note collections."
|
description="Workspace-level organization, filters, and saved-view entry points for note collections."
|
||||||
actions={<div className="badge">Saved views derived live</div>}
|
actions={
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const data = await exportNotes("json");
|
||||||
|
const blob = new Blob([data], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "notes-export.json";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Export failed");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Export Notes
|
||||||
|
</button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<section style={{ display: "grid", gridTemplateColumns: "minmax(260px, 320px) minmax(0, 1fr)", gap: "var(--ml-space-4)" }}>
|
<section style={{ display: "grid", gridTemplateColumns: "minmax(260px, 320px) minmax(0, 1fr)", gap: "var(--ml-space-4)" }}>
|
||||||
<aside className="surface-card" style={{ padding: "var(--ml-space-5)", display: "grid", gap: "var(--ml-space-4)" }}>
|
<aside className="surface-card" style={{ padding: "var(--ml-space-5)", display: "grid", gap: "var(--ml-space-4)" }}>
|
||||||
|
|||||||
@ -342,6 +342,22 @@ export async function restoreNote(noteId: string, workspaceId: string): Promise<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function summarizeNote(noteId: string, workspaceId: string): Promise<void> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
await api.fetch(`/notes/${encodeURIComponent(noteId)}/summarize`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ workspaceId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportNotes(format: "json" | "markdown", workspaceId?: string): Promise<string> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
const params = new URLSearchParams({ format });
|
||||||
|
if (workspaceId) params.set("workspaceId", workspaceId);
|
||||||
|
const res = await api.fetch<string>(`/notes/export?${params.toString()}`);
|
||||||
|
return typeof res === "string" ? res : JSON.stringify(res, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createNoteRelationship(input: {
|
export async function createNoteRelationship(input: {
|
||||||
id: string;
|
id: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
|
|||||||
@ -6,6 +6,7 @@ export default defineConfig({
|
|||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
setupFiles: ["./src/test/setupTests.ts"],
|
setupFiles: ["./src/test/setupTests.ts"],
|
||||||
globals: true,
|
globals: true,
|
||||||
|
exclude: ["e2e/**", "node_modules/**"],
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user