mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18: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 { env } from '$env/dynamic/private';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
interface ParseResult {
|
||||
title: string;
|
||||
dueDate: string | null; // ISO date (YYYY-MM-DD) or full ISO timestamp
|
||||
priority: 'low' | 'medium' | 'high' | null;
|
||||
labels: string[];
|
||||
}
|
||||
import { coerce, extractJson, fallback } from './coerce';
|
||||
|
||||
const MAX_TRANSCRIPT_CHARS = 1000;
|
||||
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.
|
||||
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 {
|
||||
const now = new Date();
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
|
|
@ -85,149 +75,6 @@ function buildPrompt(transcript: string, language: string): string {
|
|||
].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 }) => {
|
||||
let body: { transcript?: string; language?: string };
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -13,9 +13,12 @@
|
|||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { coerce, transcriptMentions, __test } from './+server';
|
||||
|
||||
const { DATE_TRIGGER_PATTERNS, PRIORITY_TRIGGER_PATTERNS } = __test;
|
||||
import {
|
||||
coerce,
|
||||
transcriptMentions,
|
||||
DATE_TRIGGER_PATTERNS,
|
||||
PRIORITY_TRIGGER_PATTERNS,
|
||||
} from './coerce';
|
||||
|
||||
describe('transcriptMentions', () => {
|
||||
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