mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(memoro): auto-generate voice memo titles via the LLM task queue
First real-world consumer of the @mana/shared-llm tier framework.
After STT transcription completes for a voice memo, the memos store
fire-and-forgets a generateTitleTask into the persistent task queue
with refType:'memo' + refId:memoId. A module-side watcher subscribed
via Dexie liveQuery to completed task rows writes the result back
into memo.title and deletes the queue row to mark it consumed.
What this commit ships:
apps/mana/apps/web/src/lib/llm-tasks/generate-title.ts
- generateTitleTask: minTier='none', contentClass='personal'
- runLlm: sends a German system prompt asking for a 3-7 word
title, defensive cleanup of any quotes/markdown the model
might leak through despite the prompt
- runRules: takes the first sentence (split on .!?\n), caps
at maxWords/60-chars, returns a non-empty fallback string.
Predictable and free, works on every device including the
ones where the user has opted out of all LLM tiers.
apps/mana/apps/web/src/lib/llm-task-registry.ts
- Register generateTitleTask alongside extractDate + summarize
so the queue processor can resolve the name back to the
task object after a row is pulled from the persistent table.
apps/mana/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts
- After transcribeMemo successfully writes the transcript +
processingStatus:'completed', enqueue a generateTitleTask
tagged with refType:'memo' + refId + priority:1. Skips the
enqueue if the memo already has a non-empty title (so
manually-titled memos aren't overwritten on re-transcription)
or if the transcript came back empty.
- Wrapped in try/catch — queue failures must NEVER break the
transcription happy path.
apps/mana/apps/web/src/lib/modules/memoro/llm-watcher.svelte.ts
- startMemoroLlmWatcher() / stopMemoroLlmWatcher()
- Subscribes via Dexie liveQuery to llmQueueDb.tasks rows
where state='done', taskName='common.generateTitle',
refType='memo'. For each row:
1. Skip + delete row if result isn't a string (defensive)
2. Skip + delete row if memo no longer exists (deleted
between enqueue and result)
3. Skip + delete row if memo already has a manual title
(user typed one during the LLM round-trip)
4. Otherwise: encryptRecord + memoTable.update with
{ title: result, updatedAt: now }, then delete the
queue row to mark it consumed.
- Module-scope subscription handle, idempotent start/stop.
apps/mana/apps/web/src/routes/(app)/+layout.svelte
- startMemoroLlmWatcher() in handleAuthReady's Phase A right
after startLlmQueue(). The watcher needs to run regardless
of whether the user is currently on /memoro — a memo
transcribing in the background should auto-title even
while the user is doing something else.
- stopMemoroLlmWatcher() in onDestroy alongside stopLlmQueue().
End-to-end flow with a Tier 0 user (no AI enabled):
1. User records a memo via voice capture
2. memos.svelte.ts createWithTranscription() inserts the memo
with processingStatus:'processing'
3. transcribeMemo POSTs the audio to mana-stt, awaits the
transcript
4. Successful transcript → memos.svelte.ts writes
{ transcript, processingStatus:'completed' } to memoTable
5. Same function enqueues generateTitleTask with the transcript
6. LlmTaskQueue processor picks it up (the queue is running in
the background since layout init), calls
orchestrator.run(generateTitleTask, { text: transcript })
7. Orchestrator: Tier 0 user → no LLM tier → falls through to
runRules() which returns the first-sentence heuristic
8. Queue marks the row done with the rules-tier title string
9. Memoro watcher's liveQuery fires with the new completed row
10. Watcher writes title + deletes the queue row
11. ListView's existing useLiveQuery on memoTable picks up the
title change automatically
End-to-end flow with a Browser-tier user:
Steps 1-6 identical, then:
7. Orchestrator: browser tier ready → calls
generateTitleTask.runLlm with the BrowserBackend
8. Web Worker (Phase 3) runs Gemma 4 E2B against a 32-token
budget, returns a 3-7 word German title
9-11. Same as Tier 0 — the title lands in memo.title without
the user clicking anything
This is the validation the entire 4-phase architecture was built
for: a module-side auto-feature that's completely tier-agnostic,
fire-and-forget, persistent across reloads, and that gracefully
degrades from Gemma 4 down to a regex when the user has opted out.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7a0959e519
commit
b4dd646fd7
5 changed files with 202 additions and 0 deletions
|
|
@ -19,9 +19,11 @@
|
|||
|
||||
import type { TaskRegistry } from '@mana/shared-llm';
|
||||
import { extractDateTask } from './llm-tasks/extract-date';
|
||||
import { generateTitleTask } from './llm-tasks/generate-title';
|
||||
import { summarizeTextTask } from './llm-tasks/summarize';
|
||||
|
||||
export const taskRegistry: TaskRegistry = {
|
||||
[extractDateTask.name]: extractDateTask,
|
||||
[generateTitleTask.name]: generateTitleTask,
|
||||
[summarizeTextTask.name]: summarizeTextTask,
|
||||
};
|
||||
|
|
|
|||
77
apps/mana/apps/web/src/lib/llm-tasks/generate-title.ts
Normal file
77
apps/mana/apps/web/src/lib/llm-tasks/generate-title.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* generateTitleTask — produces a short title (3–7 words) for a longer
|
||||
* piece of text. Used by memoro to auto-name voice memos after STT
|
||||
* finishes, by notes for untitled drafts, by chat for thread names.
|
||||
*
|
||||
* Has a runRules() fallback so it works even on Tier 0: the fallback
|
||||
* takes the first sentence (or first ~60 chars), strips trailing
|
||||
* punctuation, and uses that as the title. It's not as nice as an
|
||||
* LLM-generated title but it's predictable, free, and never empty.
|
||||
*/
|
||||
|
||||
import type { LlmBackend, LlmTask } from '@mana/shared-llm';
|
||||
|
||||
export interface GenerateTitleInput {
|
||||
text: string;
|
||||
/** Optional max title length in words. Default 7. */
|
||||
maxWords?: number;
|
||||
/** Optional language hint for the system prompt. Default 'de'. */
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export type GenerateTitleOutput = string;
|
||||
|
||||
export const generateTitleTask: LlmTask<GenerateTitleInput, GenerateTitleOutput> = {
|
||||
name: 'common.generateTitle',
|
||||
minTier: 'none', // works on Tier 0 via the first-sentence heuristic
|
||||
contentClass: 'personal',
|
||||
displayLabel: 'Titel automatisch erzeugen',
|
||||
|
||||
async runLlm(input, backend: LlmBackend): Promise<GenerateTitleOutput> {
|
||||
const maxWords = input.maxWords ?? 7;
|
||||
const language = input.language ?? 'de';
|
||||
const result = await backend.generate({
|
||||
taskName: generateTitleTask.name,
|
||||
contentClass: generateTitleTask.contentClass,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `Du erstellst kurze, aussagekräftige Titel (max. ${maxWords} Wörter) für Texte. Sprache: ${language}. Antworte AUSSCHLIESSLICH mit dem Titel — kein Markdown, keine Anführungszeichen, keine Vorrede, kein Punkt am Ende.`,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: input.text.slice(0, 4000), // cap context for speed
|
||||
},
|
||||
],
|
||||
temperature: 0.5,
|
||||
maxTokens: 32,
|
||||
});
|
||||
|
||||
// Defensive: strip surrounding quotes / markdown / trailing dots in
|
||||
// case the model didn't fully respect the system prompt.
|
||||
return result.content
|
||||
.trim()
|
||||
.replace(/^["'`*_]+|["'`*_]+$/g, '')
|
||||
.replace(/\.+$/, '')
|
||||
.trim();
|
||||
},
|
||||
|
||||
async runRules(input): Promise<GenerateTitleOutput> {
|
||||
const text = input.text.trim();
|
||||
if (!text) return 'Ohne Titel';
|
||||
|
||||
// Take the first sentence — split on .!? or newline.
|
||||
const firstSentence = text.split(/[.!?\n]/)[0]?.trim() ?? text;
|
||||
|
||||
// Cap at ~60 chars / maxWords words, whichever comes first.
|
||||
const maxWords = input.maxWords ?? 7;
|
||||
const words = firstSentence.split(/\s+/).slice(0, maxWords);
|
||||
let candidate = words.join(' ');
|
||||
|
||||
if (candidate.length > 60) {
|
||||
candidate = candidate.slice(0, 57).trimEnd() + '…';
|
||||
}
|
||||
|
||||
return candidate || 'Ohne Titel';
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* Memoro LLM result watcher.
|
||||
*
|
||||
* The persistent task queue stores LlmTask results in its own Dexie
|
||||
* table — but for module-side data (like a memo's title), we want
|
||||
* those results to land in the module's own collection so existing
|
||||
* queries / UI keep working without per-component subscriptions.
|
||||
*
|
||||
* This file owns the bridge for memoro: it subscribes via Dexie
|
||||
* liveQuery to completed `common.generateTitle` tasks tagged
|
||||
* with refType: 'memo', and for each one writes the generated title
|
||||
* back into the memo row, then deletes the queue entry to mark it
|
||||
* consumed. Once consumed, the queue stays empty for that memo.
|
||||
*
|
||||
* The watcher is started exactly once per page session — see
|
||||
* startMemoroLlmWatcher() below for the idempotent guard. The
|
||||
* memoro module config calls it from its initialize() hook, but
|
||||
* even if a future refactor calls it twice, the duplicate call is
|
||||
* a no-op.
|
||||
*
|
||||
* Cleanup: the subscription handle is stored module-scope; the page
|
||||
* teardown is implicit (page reload kills the dev server too). For
|
||||
* a long-lived SPA we'd want stop() — punt that to a follow-up.
|
||||
*/
|
||||
|
||||
import { liveQuery, type Subscription } from 'dexie';
|
||||
import { llmQueueDb } from '$lib/llm-queue';
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import { memoTable } from './collections';
|
||||
import type { LocalMemo } from './types';
|
||||
|
||||
let subscription: Subscription | null = null;
|
||||
|
||||
export function startMemoroLlmWatcher(): void {
|
||||
if (subscription) return; // already running
|
||||
if (typeof window === 'undefined') return; // SSR-safe no-op
|
||||
|
||||
const observable = liveQuery(async () =>
|
||||
llmQueueDb.tasks
|
||||
.where('state')
|
||||
.equals('done')
|
||||
.and((t) => t.taskName === 'common.generateTitle' && t.refType === 'memo')
|
||||
.toArray()
|
||||
);
|
||||
|
||||
subscription = observable.subscribe({
|
||||
next: async (rows) => {
|
||||
for (const row of rows) {
|
||||
if (!row.refId || typeof row.result !== 'string') {
|
||||
// Result shape didn't match — drop the queue row so we
|
||||
// don't keep retrying it.
|
||||
await llmQueueDb.tasks.delete(row.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const memo = await memoTable.get(row.refId);
|
||||
if (!memo) {
|
||||
// Memo was deleted before the task finished — discard.
|
||||
await llmQueueDb.tasks.delete(row.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Don't overwrite a manual title that the user typed
|
||||
// between enqueue time and result time.
|
||||
if (memo.title?.trim()) {
|
||||
await llmQueueDb.tasks.delete(row.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const diff: Partial<LocalMemo> = {
|
||||
title: row.result,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('memos', diff);
|
||||
await memoTable.update(row.refId, diff);
|
||||
|
||||
// Mark consumed
|
||||
await llmQueueDb.tasks.delete(row.id);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.warn('[memoro-llm-watcher] subscription error:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function stopMemoroLlmWatcher(): void {
|
||||
subscription?.unsubscribe();
|
||||
subscription = null;
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ import { toMemo } from '../queries';
|
|||
import { createArchiveOps } from '@mana/shared-stores';
|
||||
import { MemoroEvents } from '@mana/shared-utils/analytics';
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import { llmTaskQueue } from '$lib/llm-queue';
|
||||
import { generateTitleTask } from '$lib/llm-tasks/generate-title';
|
||||
import type { LocalMemo } from '../types';
|
||||
|
||||
/** Archive/soft-delete ops for memos. */
|
||||
|
|
@ -106,6 +108,26 @@ export const memosStore = {
|
|||
};
|
||||
await encryptRecord('memos', diff);
|
||||
await memoTable.update(memoId, diff);
|
||||
|
||||
// Auto-title: if the user didn't already give the memo a title,
|
||||
// queue a background task to generate one from the transcript.
|
||||
// The task is fire-and-forget — the memoro LLM watcher
|
||||
// (./llm-watcher.svelte.ts) picks up the result reactively and
|
||||
// writes it back to memo.title. Works on every tier including
|
||||
// none (regex-based first-sentence fallback).
|
||||
if (!existing.title?.trim() && transcript.length > 0) {
|
||||
try {
|
||||
await llmTaskQueue.enqueue(
|
||||
generateTitleTask,
|
||||
{ text: transcript, language: existing.language ?? result.language ?? 'de' },
|
||||
{ refType: 'memo', refId: memoId, priority: 1 }
|
||||
);
|
||||
} catch {
|
||||
// Don't let queue failures break the transcription path.
|
||||
// Worst case the memo stays untitled — the user can still
|
||||
// rename it manually.
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
await memoTable.update(memoId, {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@
|
|||
import { linkLocalStore, linkMutations } from '@mana/shared-links';
|
||||
import { manaStore } from '$lib/data/local-store';
|
||||
import { startLlmQueue, stopLlmQueue } from '$lib/llm-queue';
|
||||
import {
|
||||
startMemoroLlmWatcher,
|
||||
stopMemoroLlmWatcher,
|
||||
} from '$lib/modules/memoro/llm-watcher.svelte';
|
||||
import { createUnifiedSync } from '$lib/data/sync';
|
||||
import { networkStore } from '$lib/stores/network.svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
|
|
@ -312,6 +316,12 @@
|
|||
// from a crashed session) before going idle. See $lib/llm-queue.ts.
|
||||
startLlmQueue();
|
||||
|
||||
// Module-side LLM result watchers. Each subscribes via Dexie
|
||||
// liveQuery to completed task rows tagged for its module and
|
||||
// writes the results back to the module's own collection
|
||||
// (e.g. memoro auto-titles → memo.title). Idempotent.
|
||||
startMemoroLlmWatcher();
|
||||
|
||||
// Restore nav collapsed state
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const savedCollapsed = localStorage.getItem(STORAGE_KEYS.NAV_COLLAPSED);
|
||||
|
|
@ -384,6 +394,7 @@
|
|||
// will finish in the background and the next page session will
|
||||
// pick up where we left off.
|
||||
void stopLlmQueue();
|
||||
stopMemoroLlmWatcher();
|
||||
});
|
||||
|
||||
// ── Search / Spotlight ───────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue