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/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 { noteRoutes } from './routes.js';
|
||||
@ -129,6 +132,41 @@ describe('notes routes — integration', () => {
|
||||
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 () => {
|
||||
extractAuthMock.mockRejectedValueOnce(new Error('Unauthorized'));
|
||||
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/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', () => ({
|
||||
listNotes: listNotesMock,
|
||||
getNote: getNoteMock,
|
||||
@ -38,8 +40,8 @@ describe('noteRoutes', () => {
|
||||
|
||||
await noteRoutes(app as never);
|
||||
|
||||
expect(app.get).toHaveBeenCalledTimes(3);
|
||||
expect(app.post).toHaveBeenCalledTimes(3);
|
||||
expect(app.get).toHaveBeenCalledTimes(4);
|
||||
expect(app.post).toHaveBeenCalledTimes(4);
|
||||
expect(app.patch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,7 +2,9 @@ import type { FastifyApp } from '@bytelyst/fastify-core';
|
||||
import { BadRequestError, NotFoundError } from '../../lib/errors.js';
|
||||
import { extractAuth } from '../../lib/auth.js';
|
||||
import { PRODUCT_ID } from '../../lib/product-config.js';
|
||||
import { extractFromText } from '../../lib/extraction-client.js';
|
||||
import * as repo from './repository.js';
|
||||
import * as artifactRepo from '../note-artifacts/repository.js';
|
||||
import { CreateNoteSchema, ListNotesQuerySchema, UpdateNoteSchema, type NoteDoc } from './types.js';
|
||||
|
||||
type RouteApp = Omit<FastifyApp, 'setReadyState' | 'isReadyState'>;
|
||||
@ -173,4 +175,72 @@ export async function noteRoutes(app: RouteApp) {
|
||||
|
||||
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 { AgentTimeline } from "@/components/AgentTimeline";
|
||||
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";
|
||||
|
||||
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() {
|
||||
if (!note) return;
|
||||
try {
|
||||
@ -158,6 +168,9 @@ export default function NoteDetailPage() {
|
||||
{`Review: ${note.metadata.reviewState}`}
|
||||
</Link>
|
||||
)}
|
||||
<button className="btn btn-secondary" onClick={handleSummarize}>
|
||||
Summarize
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => setShowLinkNote(true)}>
|
||||
Link Note
|
||||
</button>
|
||||
|
||||
@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
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";
|
||||
|
||||
export default function WorkspacesPage() {
|
||||
@ -96,7 +96,27 @@ function WorkspacesPageInner() {
|
||||
<AppShell
|
||||
title="Workspaces"
|
||||
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)" }}>
|
||||
<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: {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
|
||||
@ -6,6 +6,7 @@ export default defineConfig({
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./src/test/setupTests.ts"],
|
||||
globals: true,
|
||||
exclude: ["e2e/**", "node_modules/**"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user