From d8da11a4ffb81567d4729550c985e7bf3d59087d Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 8 Apr 2026 16:12:17 +0200 Subject: [PATCH] feat(todo): typed quick-add gets the same LLM enrichment as voice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Press Enter on "Steuererklärung morgen 14 Uhr hoch" and the task lands instantly with your exact text as the title — then a background pass through /api/v1/voice/parse-task swaps in dueDate + priority once mana-llm answers. The title only gets rewritten when the LLM actually finds structured info (dueDate or priority); for plain titles like "Mülltonnen rausstellen" the typed text is left alone, since silently "cleaning up" perfectly fine input is more annoying than helpful. Pulled the parse + STT-then-parse plumbing apart so both flows share parseTaskText() and only differ in policy: voice always applies the LLM title (raw transcripts are noisy), typed only when there's structured payoff. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/src/lib/modules/todo/ListView.svelte | 7 +- .../lib/modules/todo/stores/tasks.svelte.ts | 81 +++++++++++++++---- 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/apps/mana/apps/web/src/lib/modules/todo/ListView.svelte b/apps/mana/apps/web/src/lib/modules/todo/ListView.svelte index 42d7e0b3d..a0a0c5e3f 100644 --- a/apps/mana/apps/web/src/lib/modules/todo/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/todo/ListView.svelte @@ -65,8 +65,13 @@ if (!title) return; const data: Record = { title }; if (filter === 'today') data.dueDate = new Date().toISOString(); - await tasksStore.createTask(data as { title: string; dueDate?: string }); + const task = await tasksStore.createTask(data as { title: string; dueDate?: string }); newTitle = ''; + // Background LLM enrichment: if the user typed something like + // "Steuererklärung morgen 14 Uhr hoch", swap in dueDate + priority + // once mana-llm answers. The task is already in the list with + // the user's exact title, so this only ever adds detail. + void tasksStore.enrichTaskFromText(task.id, title); } async function handleVoiceComplete(blob: Blob, durationMs: number) { diff --git a/apps/mana/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts b/apps/mana/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts index a63060ffa..fc918571e 100644 --- a/apps/mana/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts @@ -133,23 +133,11 @@ export const tasksStore = { return; } - // Step 2: structured extraction. parse-task gracefully falls - // back to { title: transcript, dueDate: null, ... } if mana-llm - // is unreachable, so we don't wrap this in another try/catch. - const parseResponse = await fetch('/api/v1/voice/parse-task', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ transcript, language }), - }); - const parsed = parseResponse.ok - ? ((await parseResponse.json()) as { - title: string; - dueDate: string | null; - priority: 'low' | 'medium' | 'high' | null; - labels: string[]; - }) - : { title: transcript, dueDate: null, priority: null as null, labels: [] as string[] }; - + // Step 2: structured extraction. For voice we always apply the + // LLM's title since the raw transcript ("erinnere mich morgen + // daran die steuererklärung zu machen") is much noisier than + // what the user actually wants to see in the list. + const parsed = await this.parseTaskText(transcript, language); const update: Record = { title: parsed.title }; if (parsed.dueDate) update.dueDate = parsed.dueDate; if (parsed.priority) update.priority = parsed.priority; @@ -164,6 +152,65 @@ export const tasksStore = { } }, + /** + * Background enrichment for typed quick-add. Runs the same LLM + * parser the voice flow uses, but with a stricter rule: only update + * the task if the LLM actually found structured info (dueDate or + * priority). For typed input the user already sees their exact text + * as the title — silently rewriting it to a "cleaner" version when + * the LLM didn't find a date/priority would be surprising and + * occasionally wrong, so we leave it alone in that case. + */ + async enrichTaskFromText(taskId: string, text: string, language = 'de'): Promise { + const trimmed = text.trim(); + if (!trimmed) return; + try { + const parsed = await this.parseTaskText(trimmed, language); + if (!parsed.dueDate && !parsed.priority) return; + + const update: Record = {}; + if (parsed.title && parsed.title !== trimmed) update.title = parsed.title; + if (parsed.dueDate) update.dueDate = parsed.dueDate; + if (parsed.priority) update.priority = parsed.priority; + if (Object.keys(update).length === 0) return; + await this.updateTask(taskId, update); + } catch { + // Silent — typed quick-add already gave the user a usable + // task; an LLM failure should never undo that. + } + }, + + /** + * POST a transcript or typed text to the parse-task proxy and + * return the structured result. The proxy already falls back to + * { title: text, dueDate: null, ... } when mana-llm is unreachable + * or returns garbage, so callers can use the result unconditionally. + */ + async parseTaskText( + text: string, + language = 'de' + ): Promise<{ + title: string; + dueDate: string | null; + priority: 'low' | 'medium' | 'high' | null; + labels: string[]; + }> { + const response = await fetch('/api/v1/voice/parse-task', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ transcript: text, language }), + }); + if (!response.ok) { + return { title: text, dueDate: null, priority: null, labels: [] }; + } + return (await response.json()) as { + title: string; + dueDate: string | null; + priority: 'low' | 'medium' | 'high' | null; + labels: string[]; + }; + }, + async updateTask(id: string, data: Record) { const raw = await taskTable.get(id); if (!raw) return;