managarten/apps/api/src/lib/responses.ts
Till JS e82851985b feat(questions): deep-research module — mana-search + mana-llm pipeline
End-to-end deep-research feature for the questions module: a fire-and-
forget orchestrator in apps/api that plans sub-queries with mana-llm,
retrieves sources via mana-search (with optional Readability extraction),
and streams a structured synthesis back to the web app over SSE.

Backend (apps/api/src/modules/research):
- schema.ts: pgSchema('research') with research_results + sources
- orchestrator.ts: three-phase pipeline (plan / retrieve / synthesise)
  with depth-aware config (quick=1×, standard=3×, deep=6× sub-queries)
- pubsub.ts: in-process event bus, single-node, swappable for Redis
- routes.ts: POST /start (202, fire-and-forget), GET /:id/stream (SSE),
  POST /start-sync (test only), GET /:id, GET /:id/sources
- Credit gating via @mana/shared-hono/credits — validate up-front,
  consume best-effort on `done`. Failed runs cost nothing.

Helpers (apps/api/src/lib):
- llm.ts: llmJson() + llmStream() over mana-llm OpenAI-compat API
- search.ts: webSearch() + bulkExtract() over mana-search Go service
- responses.ts: shared errorResponse / listResponse / validationError

Schema deployment:
- drizzle.config.ts (research-scoped) + drizzle/research/0000_init.sql
  hand-authored migration, deployable via psql -f or drizzle-kit push.
- drizzle-kit added as devDep with db:generate / db:push scripts.

Web client (apps/mana/apps/web/src/lib/api/research.ts):
- Typed start() / get() / listSources() / streamProgress(). The stream
  uses fetch + ReadableStream (not EventSource) so we can attach the
  JWT via Authorization header. Special-cases 402 for friendly toast.
- New PUBLIC_MANA_API_URL plumbing in hooks.server.ts + config.ts.

Module store (modules/questions/stores/answers.svelte.ts):
- New write-side store with createManual / startResearch / accept /
  softDelete. startResearch creates an optimistic empty answer, opens
  the SSE stream, debounces token deltas in 100ms batches into the
  encrypted local row, and on `done` replaces the streamed text with
  the parsed { summary, keyPoints, followUps } payload + citations
  resolved against research.sources.id.

Citation rendering (modules/questions/components/AnswerCitations.svelte):
- Tokenises [n] markers in the answer body into clickable pills with
  hover popovers showing title / host / snippet / external link.
- Lazy-loaded via a session-scoped source cache (stores/sources.svelte.ts)
  that deduplicates concurrent fetches.

UI (routes/(app)/questions/[id]/+page.svelte):
- Recherche card with three-state button (start / cancel / re-run),
  animated phase indicator, source counter.
- Confirmation dialog warning about web/LLM transmission since the
  question itself is locally encrypted.
- Toasts for success / error / cancel via @mana/shared-ui/toast.
- Re-run flow soft-deletes prior research-driven answers but keeps
  manual ones intact.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:15:35 +02:00

118 lines
4 KiB
TypeScript

/**
* Standard response helpers for mana-api modules.
*
* Background: A pre-launch audit (April 2026, see
* `docs/REFACTORING_AUDIT_2026_04.md` item #5) flagged that error and
* list responses were inconsistent across the 15+ modules. The actual
* inconsistency turned out to be smaller than reported — every module
* already returns errors as `{ error: 'message' }` — but using these
* helpers gives us:
*
* 1. **Type-safe status codes** — TS catches stray `c.json(..., 999)`
* 2. **One place to enrich the envelope** — when we add `code`,
* `requestId`, or `details` later, we change one file instead of
* grepping 79 callsites.
* 3. **Consistent list shape** — `{ items, count }` regardless of
* what the items are. Frontend `apps/mana/apps/web` doesn't have
* to special-case `events` vs `contacts` vs `occurrences`.
*
* The shape is wire-compatible with the existing inline `c.json(...)`
* calls, so adoption can be incremental: new code uses these helpers,
* old code keeps working until someone touches the file.
*
* @example
* ```ts
* import { errorResponse, listResponse, validationError } from '../../lib/responses';
*
* routes.get('/things', async (c) => {
* const things = await db.select().from(thingsTable);
* return listResponse(c, things);
* });
*
* routes.post('/things', async (c) => {
* const parsed = thingSchema.safeParse(await c.req.json());
* if (!parsed.success) return validationError(c, parsed.error.issues);
* // ...
* });
* ```
*/
import type { Context } from 'hono';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
/**
* Standard error response envelope.
*
* Wire-compatible with the inline `c.json({ error: '...' }, status)`
* pattern that already dominates the codebase. Future fields like
* `code` (machine-readable error code) and `details` (validation issues,
* etc.) can be added without touching callsites.
*/
export type ErrorBody = {
error: string;
code?: string;
details?: unknown;
};
/**
* Standard list response envelope.
*
* Always uses `items` as the field name, regardless of what's inside.
* The frontend hits a stable shape: `{ items: T[], count: number }`.
*/
export type ListBody<T> = {
items: T[];
count: number;
};
/**
* Return a structured error response.
*
* @param c Hono context
* @param error Human-readable message (also used as fallback for code)
* @param status HTTP status (default 500)
* @param extra Optional extra fields — `code` for machine-readable
* identification, `details` for validation issues, etc.
*/
export function errorResponse(
c: Context,
error: string,
status: ContentfulStatusCode = 500,
extra?: { code?: string; details?: unknown }
) {
const body: ErrorBody = { error };
if (extra?.code) body.code = extra.code;
if (extra?.details !== undefined) body.details = extra.details;
return c.json(body, status);
}
/**
* Return a validation error response (400) with structured issues.
*
* Convenience over `errorResponse` for the common Zod case — extracts
* the first error message as the human string and attaches the full
* issue list under `details`.
*/
export function validationError(c: Context, issues: unknown[], status: ContentfulStatusCode = 400) {
const firstMessage =
Array.isArray(issues) &&
issues.length > 0 &&
typeof issues[0] === 'object' &&
issues[0] !== null &&
'message' in issues[0]
? String((issues[0] as { message: unknown }).message)
: 'Invalid input';
return errorResponse(c, firstMessage, status, { code: 'VALIDATION', details: issues });
}
/**
* Return a standard list response. Always wraps in `{ items, count }`,
* regardless of what `items` are. This is the *opposite* of the current
* convention where each module names its own field
* (`{ events, count }`, `{ contacts, count }`) — frontends benefit
* from a single uniform unwrap step.
*/
export function listResponse<T>(c: Context, items: T[], status: ContentfulStatusCode = 200) {
const body: ListBody<T> = { items, count: items.length };
return c.json(body, status);
}