diff --git a/apps/mana/apps/web/src/lib/llm-tasks/generate-title.ts b/apps/mana/apps/web/src/lib/llm-tasks/generate-title.ts index 7d895a9a6..e5bc8b858 100644 --- a/apps/mana/apps/web/src/lib/llm-tasks/generate-title.ts +++ b/apps/mana/apps/web/src/lib/llm-tasks/generate-title.ts @@ -21,6 +21,29 @@ export interface GenerateTitleInput { export type GenerateTitleOutput = string; +/** Deterministic first-sentence heuristic. Extracted to a module-scope + * function so runLlm can call it as a fallback when the LLM returns + * empty or whitespace-only output (which happens when the model emits + * only a `.` or special tokens that get stripped by skip_special_tokens). */ +function rulesImpl(input: GenerateTitleInput): string { + 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'; +} + export const generateTitleTask: LlmTask = { name: 'common.generateTitle', minTier: 'none', // works on Tier 0 via the first-sentence heuristic @@ -49,29 +72,26 @@ export const generateTitleTask: LlmTask // Defensive: strip surrounding quotes / markdown / trailing dots in // case the model didn't fully respect the system prompt. - return result.content + const cleaned = result.content .trim() .replace(/^["'`*_]+|["'`*_]+$/g, '') .replace(/\.+$/, '') .trim(); + + // LLM produced nothing usable (empty content, only punctuation, + // only special tokens that got stripped, etc.) — fall back to the + // deterministic rules implementation so the user gets *something*. + // Without this fallback the watcher writes "" to memo.title and the + // user sees an empty placeholder forever. + if (!cleaned) { + console.info('[generateTitle] LLM returned empty after cleanup, falling back to rules'); + return rulesImpl(input); + } + + return cleaned; }, async runRules(input): Promise { - 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'; + return rulesImpl(input); }, }; diff --git a/apps/mana/apps/web/src/lib/modules/memoro/llm-watcher.svelte.ts b/apps/mana/apps/web/src/lib/modules/memoro/llm-watcher.svelte.ts index 364c86715..a4fda3a17 100644 --- a/apps/mana/apps/web/src/lib/modules/memoro/llm-watcher.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/memoro/llm-watcher.svelte.ts @@ -132,9 +132,34 @@ async function applyRow(row: QueuedTask): Promise { return; } - console.info(`[memoro-llm-watcher] writing title to memo ${row.refId}: "${row.result}"`); + // Backstop: if the task result somehow came back empty/whitespace + // (LLM emitted only special tokens, runRules got an empty input, + // any other edge case), synthesize a date-based fallback so the + // user always gets *some* title rather than a stuck empty input. + let titleToWrite = row.result.trim(); + if (!titleToWrite) { + const created = (memo as { createdAt?: string }).createdAt; + const dateLabel = created + ? new Date(created).toLocaleDateString('de', { + day: 'numeric', + month: 'long', + year: 'numeric', + }) + : new Date().toLocaleDateString('de'); + titleToWrite = `Memo vom ${dateLabel}`; + console.warn( + `[memoro-llm-watcher] row ${row.id} returned empty title — using date fallback "${titleToWrite}"`, + { source: row.source, attempts: row.attempts, rawResult: JSON.stringify(row.result) } + ); + } else { + console.info(`[memoro-llm-watcher] writing title to memo ${row.refId}: "${titleToWrite}"`, { + source: row.source, + attempts: row.attempts, + }); + } + const diff: Partial = { - title: row.result, + title: titleToWrite, updatedAt: new Date().toISOString(), }; await encryptRecord('memos', diff);