mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:41:08 +02:00
fix(voice/parse-task): extract helpers to coerce.ts so prod build passes
SvelteKit's production build forbids non-handler exports from a +server.ts file — dev runs them fine but `pnpm build` errored with "Invalid export 'coerce' in /api/v1/voice/parse-task" when trying to deploy mana-web with the new unit tests. Move ParseResult, fallback, DATE_TRIGGER_PATTERNS, PRIORITY_TRIGGER_PATTERNS, transcriptMentions, coerce, and extractJson into a sibling coerce.ts module. The +server.ts file imports from there and only exports POST, which is the prod build's hard rule. Tests now import from ./coerce instead of from the route handler, which also drops the $env/dynamic/private resolution dance from the test fast path — coerce.test.ts now runs in ~130ms instead of ~400ms because it pulls in zero SvelteKit runtime. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4f6609a595
commit
3b41b39a32
3 changed files with 181 additions and 157 deletions
|
|
@ -20,13 +20,7 @@
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
import { coerce, extractJson, fallback } from './coerce';
|
||||||
interface ParseResult {
|
|
||||||
title: string;
|
|
||||||
dueDate: string | null; // ISO date (YYYY-MM-DD) or full ISO timestamp
|
|
||||||
priority: 'low' | 'medium' | 'high' | null;
|
|
||||||
labels: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_TRANSCRIPT_CHARS = 1000;
|
const MAX_TRANSCRIPT_CHARS = 1000;
|
||||||
const LLM_TIMEOUT_MS = 8000;
|
const LLM_TIMEOUT_MS = 8000;
|
||||||
|
|
@ -40,10 +34,6 @@ const LLM_TIMEOUT_MS = 8000;
|
||||||
// safety net in case the GPU box swaps in a weaker model.
|
// safety net in case the GPU box swaps in a weaker model.
|
||||||
const DEFAULT_MODEL = 'ollama/gemma3:12b';
|
const DEFAULT_MODEL = 'ollama/gemma3:12b';
|
||||||
|
|
||||||
function fallback(transcript: string): ParseResult {
|
|
||||||
return { title: transcript.trim() || 'Sprachaufgabe', dueDate: null, priority: null, labels: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPrompt(transcript: string, language: string): string {
|
function buildPrompt(transcript: string, language: string): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const today = now.toISOString().slice(0, 10);
|
const today = now.toISOString().slice(0, 10);
|
||||||
|
|
@ -85,149 +75,6 @@ function buildPrompt(transcript: string, language: string): string {
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Words that signal "the user actually mentioned a time-anchor".
|
|
||||||
* We use this to override the LLM when it hallucinates a dueDate from
|
|
||||||
* a transcript that has no date words at all — gemma3:4b is stubborn
|
|
||||||
* about defaulting to today even when the prompt explicitly says null.
|
|
||||||
*
|
|
||||||
* Match is substring-based on the lowercased transcript, so we catch
|
|
||||||
* "morgens" via "morgen", "heutzutage" via "heut", etc. False positives
|
|
||||||
* are preferable to false negatives here: if we let through a
|
|
||||||
* transcript with no real date, the user gets an unwanted dueDate;
|
|
||||||
* if we suppress the LLM on a transcript that DOES have a date, the
|
|
||||||
* user just sees no date and can fix it in two clicks.
|
|
||||||
*/
|
|
||||||
const DATE_TRIGGER_PATTERNS = [
|
|
||||||
// German
|
|
||||||
'heut',
|
|
||||||
'morgen',
|
|
||||||
'übermorgen',
|
|
||||||
'gestern',
|
|
||||||
'montag',
|
|
||||||
'dienstag',
|
|
||||||
'mittwoch',
|
|
||||||
'donnerstag',
|
|
||||||
'freitag',
|
|
||||||
'samstag',
|
|
||||||
'sonntag',
|
|
||||||
'wochenende',
|
|
||||||
'nächste',
|
|
||||||
'nachste',
|
|
||||||
'kommende',
|
|
||||||
'in einer woche',
|
|
||||||
'in zwei',
|
|
||||||
'in drei',
|
|
||||||
'in einem monat',
|
|
||||||
'um ',
|
|
||||||
'uhr',
|
|
||||||
' am ',
|
|
||||||
// English
|
|
||||||
'today',
|
|
||||||
'tomorrow',
|
|
||||||
'yesterday',
|
|
||||||
'monday',
|
|
||||||
'tuesday',
|
|
||||||
'wednesday',
|
|
||||||
'thursday',
|
|
||||||
'friday',
|
|
||||||
'saturday',
|
|
||||||
'sunday',
|
|
||||||
'weekend',
|
|
||||||
'next ',
|
|
||||||
'in a week',
|
|
||||||
'in two ',
|
|
||||||
'in three ',
|
|
||||||
'at ',
|
|
||||||
'pm',
|
|
||||||
'am ',
|
|
||||||
'tonight',
|
|
||||||
];
|
|
||||||
|
|
||||||
const PRIORITY_TRIGGER_PATTERNS = [
|
|
||||||
// German
|
|
||||||
'dringend',
|
|
||||||
'wichtig',
|
|
||||||
'unbedingt',
|
|
||||||
'sofort',
|
|
||||||
'asap',
|
|
||||||
'kann warten',
|
|
||||||
'eilt',
|
|
||||||
'priorität',
|
|
||||||
// English
|
|
||||||
'urgent',
|
|
||||||
'important',
|
|
||||||
'immediately',
|
|
||||||
'critical',
|
|
||||||
'low priority',
|
|
||||||
'high priority',
|
|
||||||
'whenever',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Exported for unit tests. */
|
|
||||||
export function transcriptMentions(transcript: string, patterns: string[]): boolean {
|
|
||||||
const lower = transcript.toLowerCase();
|
|
||||||
return patterns.some((p) => lower.includes(p));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Exported for unit tests. */
|
|
||||||
export const __test = { DATE_TRIGGER_PATTERNS, PRIORITY_TRIGGER_PATTERNS };
|
|
||||||
|
|
||||||
/** Exported for unit tests. */
|
|
||||||
export function coerce(raw: unknown, transcript: string): ParseResult {
|
|
||||||
if (!raw || typeof raw !== 'object') return fallback(transcript);
|
|
||||||
const r = raw as Record<string, unknown>;
|
|
||||||
const title = typeof r.title === 'string' && r.title.trim() ? r.title.trim() : transcript.trim();
|
|
||||||
|
|
||||||
// Strict YYYY-MM-DD only — strip any time component the model adds
|
|
||||||
// ("2026-04-09T14:00:00" → "2026-04-09"). Reject anything that
|
|
||||||
// doesn't start with a 10-char ISO date.
|
|
||||||
let dueDate: string | null = null;
|
|
||||||
if (typeof r.dueDate === 'string') {
|
|
||||||
const m = r.dueDate.match(/^(\d{4}-\d{2}-\d{2})/);
|
|
||||||
if (m) dueDate = m[1];
|
|
||||||
}
|
|
||||||
// Override: if the transcript has zero date trigger words, the
|
|
||||||
// LLM hallucinated a dueDate. Drop it. This guard exists because
|
|
||||||
// gemma3:4b consistently emits today's date for plain tasks like
|
|
||||||
// "Mülltonnen rausstellen" no matter how loudly the prompt says
|
|
||||||
// "null when no date is mentioned".
|
|
||||||
if (dueDate && !transcriptMentions(transcript, DATE_TRIGGER_PATTERNS)) {
|
|
||||||
dueDate = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let priority: 'low' | 'medium' | 'high' | null = null;
|
|
||||||
if (r.priority === 'low' || r.priority === 'medium' || r.priority === 'high') {
|
|
||||||
priority = r.priority;
|
|
||||||
}
|
|
||||||
// Same hallucination guard for priority — neutral transcripts
|
|
||||||
// shouldn't end up "high" because the model thinks taxes are
|
|
||||||
// inherently urgent.
|
|
||||||
if (priority && !transcriptMentions(transcript, PRIORITY_TRIGGER_PATTERNS)) {
|
|
||||||
priority = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const labels = Array.isArray(r.labels)
|
|
||||||
? r.labels.filter((l): l is string => typeof l === 'string').slice(0, 3)
|
|
||||||
: [];
|
|
||||||
return { title, dueDate, priority, labels };
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractJson(text: string): unknown {
|
|
||||||
// Models sometimes wrap JSON in ```json ... ``` even when told not to;
|
|
||||||
// strip a fenced block if present, then take the first {...} run.
|
|
||||||
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
||||||
const body = fenced ? fenced[1] : text;
|
|
||||||
const start = body.indexOf('{');
|
|
||||||
const end = body.lastIndexOf('}');
|
|
||||||
if (start === -1 || end === -1 || end < start) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(body.slice(start, end + 1));
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
let body: { transcript?: string; language?: string };
|
let body: { transcript?: string; language?: string };
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,12 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { coerce, transcriptMentions, __test } from './+server';
|
import {
|
||||||
|
coerce,
|
||||||
const { DATE_TRIGGER_PATTERNS, PRIORITY_TRIGGER_PATTERNS } = __test;
|
transcriptMentions,
|
||||||
|
DATE_TRIGGER_PATTERNS,
|
||||||
|
PRIORITY_TRIGGER_PATTERNS,
|
||||||
|
} from './coerce';
|
||||||
|
|
||||||
describe('transcriptMentions', () => {
|
describe('transcriptMentions', () => {
|
||||||
it('returns true on an exact substring hit', () => {
|
it('returns true on an exact substring hit', () => {
|
||||||
|
|
|
||||||
174
apps/mana/apps/web/src/routes/api/v1/voice/parse-task/coerce.ts
Normal file
174
apps/mana/apps/web/src/routes/api/v1/voice/parse-task/coerce.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
/**
|
||||||
|
* Pure helpers for /api/v1/voice/parse-task.
|
||||||
|
*
|
||||||
|
* Lives next to +server.ts (rather than inside it) because SvelteKit's
|
||||||
|
* production build forbids non-handler exports from a +server file —
|
||||||
|
* dev runs them fine, but `pnpm build` errors out with
|
||||||
|
* "Invalid export 'coerce' in /api/v1/voice/parse-task"
|
||||||
|
* for anything that isn't a request method or starts with `_`. Putting
|
||||||
|
* the helpers here keeps both the route file clean and the unit tests
|
||||||
|
* importing-from-a-real-module instead of from a route handler.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ParseResult {
|
||||||
|
title: string;
|
||||||
|
dueDate: string | null; // ISO date (YYYY-MM-DD) or null
|
||||||
|
priority: 'low' | 'medium' | 'high' | null;
|
||||||
|
labels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fallback(transcript: string): ParseResult {
|
||||||
|
return {
|
||||||
|
title: transcript.trim() || 'Sprachaufgabe',
|
||||||
|
dueDate: null,
|
||||||
|
priority: null,
|
||||||
|
labels: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Words that signal "the user actually mentioned a time-anchor".
|
||||||
|
* We use this to override the LLM when it hallucinates a dueDate from
|
||||||
|
* a transcript that has no date words at all — gemma3:4b is stubborn
|
||||||
|
* about defaulting to today even when the prompt explicitly says null.
|
||||||
|
*
|
||||||
|
* Match is substring-based on the lowercased transcript, so we catch
|
||||||
|
* "morgens" via "morgen", "heutzutage" via "heut", etc. False positives
|
||||||
|
* are preferable to false negatives here: if we let through a
|
||||||
|
* transcript with no real date, the user gets an unwanted dueDate;
|
||||||
|
* if we suppress the LLM on a transcript that DOES have a date, the
|
||||||
|
* user just sees no date and can fix it in two clicks.
|
||||||
|
*/
|
||||||
|
export const DATE_TRIGGER_PATTERNS = [
|
||||||
|
// German
|
||||||
|
'heut',
|
||||||
|
'morgen',
|
||||||
|
'übermorgen',
|
||||||
|
'gestern',
|
||||||
|
'montag',
|
||||||
|
'dienstag',
|
||||||
|
'mittwoch',
|
||||||
|
'donnerstag',
|
||||||
|
'freitag',
|
||||||
|
'samstag',
|
||||||
|
'sonntag',
|
||||||
|
'wochenende',
|
||||||
|
'nächste',
|
||||||
|
'nachste',
|
||||||
|
'kommende',
|
||||||
|
'in einer woche',
|
||||||
|
'in zwei',
|
||||||
|
'in drei',
|
||||||
|
'in einem monat',
|
||||||
|
'um ',
|
||||||
|
'uhr',
|
||||||
|
' am ',
|
||||||
|
// English
|
||||||
|
'today',
|
||||||
|
'tomorrow',
|
||||||
|
'yesterday',
|
||||||
|
'monday',
|
||||||
|
'tuesday',
|
||||||
|
'wednesday',
|
||||||
|
'thursday',
|
||||||
|
'friday',
|
||||||
|
'saturday',
|
||||||
|
'sunday',
|
||||||
|
'weekend',
|
||||||
|
'next ',
|
||||||
|
'in a week',
|
||||||
|
'in two ',
|
||||||
|
'in three ',
|
||||||
|
'at ',
|
||||||
|
'pm',
|
||||||
|
'am ',
|
||||||
|
'tonight',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PRIORITY_TRIGGER_PATTERNS = [
|
||||||
|
// German
|
||||||
|
'dringend',
|
||||||
|
'wichtig',
|
||||||
|
'unbedingt',
|
||||||
|
'sofort',
|
||||||
|
'asap',
|
||||||
|
'kann warten',
|
||||||
|
'eilt',
|
||||||
|
'priorität',
|
||||||
|
// English
|
||||||
|
'urgent',
|
||||||
|
'important',
|
||||||
|
'immediately',
|
||||||
|
'critical',
|
||||||
|
'low priority',
|
||||||
|
'high priority',
|
||||||
|
'whenever',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function transcriptMentions(transcript: string, patterns: string[]): boolean {
|
||||||
|
const lower = transcript.toLowerCase();
|
||||||
|
return patterns.some((p) => lower.includes(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce an LLM response into a ParseResult, applying the
|
||||||
|
* deterministic guards that catch the gemma3 hallucination failure
|
||||||
|
* modes (today's-date stamping on bare tasks, fake priorities,
|
||||||
|
* malformed date strings, non-array label payloads).
|
||||||
|
*/
|
||||||
|
export function coerce(raw: unknown, transcript: string): ParseResult {
|
||||||
|
if (!raw || typeof raw !== 'object') return fallback(transcript);
|
||||||
|
const r = raw as Record<string, unknown>;
|
||||||
|
const title = typeof r.title === 'string' && r.title.trim() ? r.title.trim() : transcript.trim();
|
||||||
|
|
||||||
|
// Strict YYYY-MM-DD only — strip any time component the model adds
|
||||||
|
// ("2026-04-09T14:00:00" → "2026-04-09"). Reject anything that
|
||||||
|
// doesn't start with a 10-char ISO date.
|
||||||
|
let dueDate: string | null = null;
|
||||||
|
if (typeof r.dueDate === 'string') {
|
||||||
|
const m = r.dueDate.match(/^(\d{4}-\d{2}-\d{2})/);
|
||||||
|
if (m) dueDate = m[1];
|
||||||
|
}
|
||||||
|
// Override: if the transcript has zero date trigger words, the
|
||||||
|
// LLM hallucinated a dueDate. Drop it. This guard exists because
|
||||||
|
// gemma3:4b consistently emits today's date for plain tasks like
|
||||||
|
// "Mülltonnen rausstellen" no matter how loudly the prompt says
|
||||||
|
// "null when no date is mentioned".
|
||||||
|
if (dueDate && !transcriptMentions(transcript, DATE_TRIGGER_PATTERNS)) {
|
||||||
|
dueDate = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let priority: 'low' | 'medium' | 'high' | null = null;
|
||||||
|
if (r.priority === 'low' || r.priority === 'medium' || r.priority === 'high') {
|
||||||
|
priority = r.priority;
|
||||||
|
}
|
||||||
|
// Same hallucination guard for priority — neutral transcripts
|
||||||
|
// shouldn't end up "high" because the model thinks taxes are
|
||||||
|
// inherently urgent.
|
||||||
|
if (priority && !transcriptMentions(transcript, PRIORITY_TRIGGER_PATTERNS)) {
|
||||||
|
priority = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = Array.isArray(r.labels)
|
||||||
|
? r.labels.filter((l): l is string => typeof l === 'string').slice(0, 3)
|
||||||
|
: [];
|
||||||
|
return { title, dueDate, priority, labels };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a JSON object from a model response. gemma3 sometimes wraps
|
||||||
|
* its output in ```json ... ``` fences even when told not to; we strip
|
||||||
|
* those, then take the first {...} run we find.
|
||||||
|
*/
|
||||||
|
export function extractJson(text: string): unknown {
|
||||||
|
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||||
|
const body = fenced ? fenced[1] : text;
|
||||||
|
const start = body.indexOf('{');
|
||||||
|
const end = body.lastIndexOf('}');
|
||||||
|
if (start === -1 || end === -1 || end < start) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(body.slice(start, end + 1));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue