From 5002f50c2884ab81c72be78070d29be182d03f8e Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Wed, 11 Mar 2026 16:32:55 +0900 Subject: [PATCH] fix flush sub-threshold transcript and summary artifacts Add comprehensive fallback handling when selected tabs become unavailable. Enhance transcript word counting with error handling and improve type safety in eligibility checks. - Add fallback to raw tab when transcript is no longer available - Fallback to first enhanced note when selected note is missing - Improve error handling in transcript word parsing with try-catch - Replace hardcoded threshold with shared constant - Add comprehensive test coverage for tab computation scenarios - Strengthen type definitions for transcript word store interface --- .../src/services/enhancer/eligibility.ts | 18 ++- .../src/services/enhancer/index.test.ts | 37 +++++- apps/desktop/src/services/enhancer/index.ts | 36 ++++++ .../components/compute-note-tab.test.ts | 28 +++++ .../session/components/compute-note-tab.ts | 15 ++- .../src/session/components/shared.test.ts | 39 +++++-- .../desktop/src/session/components/shared.tsx | 23 +++- .../src/session/hooks/useEnhancedNotes.ts | 54 ++++++++- .../persister/session/save/transcript.test.ts | 105 ++++++++++++++++++ .../persister/session/save/transcript.ts | 57 ++++++++-- apps/desktop/src/stt/thresholds.ts | 1 + 11 files changed, 371 insertions(+), 42 deletions(-) create mode 100644 apps/desktop/src/session/components/compute-note-tab.test.ts create mode 100644 apps/desktop/src/store/tinybase/persister/session/save/transcript.test.ts create mode 100644 apps/desktop/src/stt/thresholds.ts diff --git a/apps/desktop/src/services/enhancer/eligibility.ts b/apps/desktop/src/services/enhancer/eligibility.ts index b79fd48237..509bded7b5 100644 --- a/apps/desktop/src/services/enhancer/eligibility.ts +++ b/apps/desktop/src/services/enhancer/eligibility.ts @@ -1,10 +1,14 @@ -import type * as main from "~/store/tinybase/store/main"; +import { MIN_WORDS_FOR_MEANINGFUL_TRANSCRIPT } from "~/stt/thresholds"; -export const MIN_WORDS_FOR_ENHANCEMENT = 5; +export const MIN_WORDS_FOR_ENHANCEMENT = MIN_WORDS_FOR_MEANINGFUL_TRANSCRIPT; + +type TranscriptWordStore = { + getCell: (tableId: "transcripts", rowId: string, cellId: "words") => unknown; +}; export function countTranscriptWords( transcriptIds: string[], - store: main.Store | undefined, + store: TranscriptWordStore | undefined, ): number { if (!store) return 0; @@ -14,7 +18,11 @@ export function countTranscriptWords( | string | undefined; if (wordsJson) { - totalWordCount += (JSON.parse(wordsJson) as unknown[]).length; + try { + totalWordCount += (JSON.parse(wordsJson) as unknown[]).length; + } catch { + continue; + } } } return totalWordCount; @@ -27,7 +35,7 @@ type EligibilityResult = export function getEligibility( hasTranscript: boolean, transcriptIds: string[], - store: main.Store | undefined, + store: TranscriptWordStore | undefined, ): EligibilityResult { if (!hasTranscript) { return { eligible: false, reason: "No transcript recorded", wordCount: 0 }; diff --git a/apps/desktop/src/services/enhancer/index.test.ts b/apps/desktop/src/services/enhancer/index.test.ts index f3742792c8..ddbc914672 100644 --- a/apps/desktop/src/services/enhancer/index.test.ts +++ b/apps/desktop/src/services/enhancer/index.test.ts @@ -25,7 +25,10 @@ type Tables = Record>>; function createTables(data?: { transcripts?: Record; - enhanced_notes?: Record; + enhanced_notes?: Record< + string, + { session_id: string; template_id?: string; content?: string } + >; sessions?: Record; }): Tables { return { @@ -49,6 +52,10 @@ function createMockStore(tables: Tables) { if (!tables[table]) tables[table] = {}; tables[table][rowId] = row; }), + delRow: vi.fn((table: string, rowId: string) => { + delete tables[table]?.[rowId]; + }), + transaction: vi.fn((callback: () => void) => callback()), setPartialRow: vi.fn(), } as any; } @@ -355,6 +362,34 @@ describe("EnhancerService", () => { }); }); + it("flushes ineligible transcript rows and empty default summaries", () => { + const tables = createTables({ + transcripts: { + "t-1": { + session_id: "session-1", + words: JSON.stringify([{ text: "hi" }, { text: "there" }]), + }, + }, + enhanced_notes: { + "note-1": { + session_id: "session-1", + content: "", + }, + }, + }); + const store = createMockStore(tables); + const deps = createDeps({ + mainStore: store, + indexes: createMockIndexes(tables), + }); + const service = new EnhancerService(deps); + + (service as any).tryAutoEnhance("session-1", 20); + + expect(store.delRow).toHaveBeenCalledWith("transcripts", "t-1"); + expect(store.delRow).toHaveBeenCalledWith("enhanced_notes", "note-1"); + }); + it("clears activeAutoEnhance on skipped after max retries", () => { const tables = createTables(); const deps = createDeps({ diff --git a/apps/desktop/src/services/enhancer/index.ts b/apps/desktop/src/services/enhancer/index.ts index 54834ba9b8..6a7164afde 100644 --- a/apps/desktop/src/services/enhancer/index.ts +++ b/apps/desktop/src/services/enhancer/index.ts @@ -139,6 +139,7 @@ export class EnhancerService { } this.activeAutoEnhance.delete(sessionId); + this.flushIneligibleSession(sessionId); this.emit({ type: "auto-enhance-skipped", sessionId, @@ -231,6 +232,41 @@ export class EnhancerService { ); } + flushIneligibleSession(sessionId: string) { + const eligibility = this.checkEligibility(sessionId); + if (eligibility.eligible) { + return; + } + + const store = this.deps.mainStore; + const transcriptIds = this.getTranscriptIds(sessionId); + const emptyDefaultSummaryIds = this.getEnhancedNoteIds(sessionId).filter( + (id) => { + const templateId = store.getCell("enhanced_notes", id, "template_id"); + const content = store.getCell("enhanced_notes", id, "content"); + + return ( + (typeof templateId !== "string" || !templateId) && + (typeof content !== "string" || !content.trim()) + ); + }, + ); + + if (!transcriptIds.length && !emptyDefaultSummaryIds.length) { + return; + } + + store.transaction(() => { + transcriptIds.forEach((id) => { + store.delRow("transcripts", id); + }); + + emptyDefaultSummaryIds.forEach((id) => { + store.delRow("enhanced_notes", id); + }); + }); + } + ensureNote(sessionId: string, templateId?: string): string { const store = this.deps.mainStore; const normalizedTemplateId = templateId || undefined; diff --git a/apps/desktop/src/session/components/compute-note-tab.test.ts b/apps/desktop/src/session/components/compute-note-tab.test.ts new file mode 100644 index 0000000000..5640f4bfc0 --- /dev/null +++ b/apps/desktop/src/session/components/compute-note-tab.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "vitest"; + +import { computeCurrentNoteTab } from "./compute-note-tab"; + +describe("computeCurrentNoteTab", () => { + test("falls back to raw when transcript view is no longer available", () => { + expect( + computeCurrentNoteTab({ type: "transcript" }, false, false, []), + ).toEqual({ type: "raw" }); + }); + + test("falls back to the first enhanced note when the selected one is gone", () => { + expect( + computeCurrentNoteTab( + { type: "enhanced", id: "missing-note" }, + false, + true, + ["note-1"], + ), + ).toEqual({ type: "enhanced", id: "note-1" }); + }); + + test("keeps transcript view while the listener is active", () => { + expect( + computeCurrentNoteTab({ type: "transcript" }, true, false, []), + ).toEqual({ type: "transcript" }); + }); +}); diff --git a/apps/desktop/src/session/components/compute-note-tab.ts b/apps/desktop/src/session/components/compute-note-tab.ts index 88876e6168..01bb4d0b7f 100644 --- a/apps/desktop/src/session/components/compute-note-tab.ts +++ b/apps/desktop/src/session/components/compute-note-tab.ts @@ -3,7 +3,8 @@ import type { EditorView } from "~/store/zustand/tabs/schema"; export function computeCurrentNoteTab( tabView: EditorView | null, isListenerActive: boolean, - firstEnhancedNoteId: string | undefined, + hasTranscript: boolean, + enhancedNoteIds: string[], ): EditorView { if (isListenerActive) { if (tabView?.type === "raw" || tabView?.type === "transcript") { @@ -13,9 +14,19 @@ export function computeCurrentNoteTab( } if (tabView) { - return tabView; + if (tabView.type === "transcript" && !hasTranscript) { + tabView = null; + } else if ( + tabView.type === "enhanced" && + !enhancedNoteIds.includes(tabView.id) + ) { + tabView = null; + } else { + return tabView; + } } + const firstEnhancedNoteId = enhancedNoteIds[0]; if (firstEnhancedNoteId) { return { type: "enhanced", id: firstEnhancedNoteId }; } diff --git a/apps/desktop/src/session/components/shared.test.ts b/apps/desktop/src/session/components/shared.test.ts index 82b86b0f1b..af031d5087 100644 --- a/apps/desktop/src/session/components/shared.test.ts +++ b/apps/desktop/src/session/components/shared.test.ts @@ -8,27 +8,28 @@ describe("computeCurrentNoteTab", () => { const result = computeCurrentNoteTab( { type: "enhanced", id: "note-1" }, true, - "note-1", + true, + ["note-1"], ); expect(result).toEqual({ type: "raw" }); }); it("preserves raw view", () => { - const result = computeCurrentNoteTab({ type: "raw" }, true, "note-1"); + const result = computeCurrentNoteTab({ type: "raw" }, true, true, [ + "note-1", + ]); expect(result).toEqual({ type: "raw" }); }); it("preserves transcript view", () => { - const result = computeCurrentNoteTab( - { type: "transcript" }, - true, + const result = computeCurrentNoteTab({ type: "transcript" }, true, true, [ "note-1", - ); + ]); expect(result).toEqual({ type: "transcript" }); }); it("returns raw view when no persisted view", () => { - const result = computeCurrentNoteTab(null, true, "note-1"); + const result = computeCurrentNoteTab(null, true, true, ["note-1"]); expect(result).toEqual({ type: "raw" }); }); }); @@ -38,13 +39,16 @@ describe("computeCurrentNoteTab", () => { const result = computeCurrentNoteTab( { type: "enhanced", id: "note-1" }, false, - "note-1", + true, + ["note-1"], ); expect(result).toEqual({ type: "enhanced", id: "note-1" }); }); it("respects persisted raw view", () => { - const result = computeCurrentNoteTab({ type: "raw" }, false, "note-1"); + const result = computeCurrentNoteTab({ type: "raw" }, false, true, [ + "note-1", + ]); expect(result).toEqual({ type: "raw" }); }); @@ -52,18 +56,29 @@ describe("computeCurrentNoteTab", () => { const result = computeCurrentNoteTab( { type: "transcript" }, false, - "note-1", + true, + ["note-1"], ); expect(result).toEqual({ type: "transcript" }); }); it("defaults to enhanced view when available and no persisted view", () => { - const result = computeCurrentNoteTab(null, false, "note-1"); + const result = computeCurrentNoteTab(null, false, true, ["note-1"]); expect(result).toEqual({ type: "enhanced", id: "note-1" }); }); it("defaults to raw when no enhanced notes and no persisted view", () => { - const result = computeCurrentNoteTab(null, false, undefined); + const result = computeCurrentNoteTab(null, false, false, []); + expect(result).toEqual({ type: "raw" }); + }); + + it("falls back to raw when transcript view is no longer available", () => { + const result = computeCurrentNoteTab( + { type: "transcript" }, + false, + false, + [], + ); expect(result).toEqual({ type: "raw" }); }); }); diff --git a/apps/desktop/src/session/components/shared.tsx b/apps/desktop/src/session/components/shared.tsx index cfff13b0cc..79152a580b 100644 --- a/apps/desktop/src/session/components/shared.tsx +++ b/apps/desktop/src/session/components/shared.tsx @@ -6,11 +6,13 @@ import { computeCurrentNoteTab } from "./compute-note-tab"; import { useAITaskTask } from "~/ai/hooks"; import { useNetwork } from "~/contexts/network"; +import { countTranscriptWords } from "~/services/enhancer/eligibility"; import * as main from "~/store/tinybase/store/main"; import { createTaskId } from "~/store/zustand/ai-task/task-configs"; import type { Tab } from "~/store/zustand/tabs/schema"; import { type EditorView } from "~/store/zustand/tabs/schema"; import { useListener } from "~/stt/contexts"; +import { MIN_WORDS_FOR_MEANINGFUL_TRANSCRIPT } from "~/stt/thresholds"; import { useSTTConnection } from "~/stt/useSTTConnection"; export { computeCurrentNoteTab } from "./compute-note-tab"; @@ -21,8 +23,18 @@ export function useHasTranscript(sessionId: string): boolean { sessionId, main.STORE_ID, ); - - return !!transcriptIds && transcriptIds.length > 0; + const store = main.UI.useStore(main.STORE_ID); + + return useMemo(() => { + if (!store || !transcriptIds?.length) { + return false; + } + + return ( + countTranscriptWords(transcriptIds, store) >= + MIN_WORDS_FOR_MEANINGFUL_TRANSCRIPT + ); + }, [store, transcriptIds]); } export function useCurrentNoteTab( @@ -39,22 +51,23 @@ export function useCurrentNoteTab( sessionMode === "active" || sessionMode === "finalizing" || isListenerStarting; + const hasTranscript = useHasTranscript(tab.id); const enhancedNoteIds = main.UI.useSliceRowIds( main.INDEXES.enhancedNotesBySession, tab.id, main.STORE_ID, ); - const firstEnhancedNoteId = enhancedNoteIds?.[0]; return useMemo( () => computeCurrentNoteTab( tab.state.view ?? null, isListenerActive, - firstEnhancedNoteId, + hasTranscript, + enhancedNoteIds ?? [], ), - [tab.state.view, isListenerActive, firstEnhancedNoteId], + [tab.state.view, isListenerActive, hasTranscript, enhancedNoteIds], ); } diff --git a/apps/desktop/src/session/hooks/useEnhancedNotes.ts b/apps/desktop/src/session/hooks/useEnhancedNotes.ts index 9cec624ebf..5608511261 100644 --- a/apps/desktop/src/session/hooks/useEnhancedNotes.ts +++ b/apps/desktop/src/session/hooks/useEnhancedNotes.ts @@ -1,13 +1,13 @@ import { useEffect, useMemo } from "react"; -import { useHasTranscript } from "../components/shared"; - import { useAITask } from "~/ai/contexts"; import { getEnhancerService } from "~/services/enhancer"; +import { countTranscriptWords } from "~/services/enhancer/eligibility"; import * as main from "~/store/tinybase/store/main"; import * as settings from "~/store/tinybase/store/settings"; import { createTaskId } from "~/store/zustand/ai-task/task-configs"; import { useListener } from "~/stt/contexts"; +import { MIN_WORDS_FOR_MEANINGFUL_TRANSCRIPT } from "~/stt/thresholds"; export function useEnhancedNotes(sessionId: string) { return main.UI.useSliceRowIds( @@ -48,7 +48,12 @@ export function useEnhancedNote(enhancedNoteId: string) { export function useEnsureDefaultSummary(sessionId: string) { const sessionMode = useListener((state) => state.getSessionMode(sessionId)); - const hasTranscript = useHasTranscript(sessionId); + const store = main.UI.useStore(main.STORE_ID); + const transcriptIds = main.UI.useSliceRowIds( + main.INDEXES.transcriptBySession, + sessionId, + main.STORE_ID, + ); const enhancedNoteIds = main.UI.useSliceRowIds( main.INDEXES.enhancedNotesBySession, sessionId, @@ -58,10 +63,16 @@ export function useEnsureDefaultSummary(sessionId: string) { "selected_template_id", settings.STORE_ID, ) as string | undefined; + const transcriptWordCount = useMemo( + () => countTranscriptWords(transcriptIds ?? [], store), + [store, transcriptIds], + ); + const hasEligibleTranscript = + transcriptWordCount >= MIN_WORDS_FOR_MEANINGFUL_TRANSCRIPT; useEffect(() => { if ( - !hasTranscript || + !hasEligibleTranscript || sessionMode === "active" || sessionMode === "running_batch" || sessionMode === "finalizing" || @@ -75,12 +86,45 @@ export function useEnsureDefaultSummary(sessionId: string) { selectedTemplateId || undefined, ); }, [ - hasTranscript, + hasEligibleTranscript, sessionMode, sessionId, enhancedNoteIds?.length, selectedTemplateId, ]); + + useEffect(() => { + if ( + !store || + sessionMode === "active" || + sessionMode === "running_batch" || + sessionMode === "finalizing" || + hasEligibleTranscript || + !enhancedNoteIds?.length + ) { + return; + } + + const emptyDefaultSummaryIds = enhancedNoteIds.filter((id) => { + const templateId = store.getCell("enhanced_notes", id, "template_id"); + const content = store.getCell("enhanced_notes", id, "content"); + + return ( + (typeof templateId !== "string" || !templateId) && + (typeof content !== "string" || !content.trim()) + ); + }); + + if (!emptyDefaultSummaryIds.length) { + return; + } + + store.transaction(() => { + emptyDefaultSummaryIds.forEach((id) => { + store.delRow("enhanced_notes", id); + }); + }); + }, [store, sessionMode, hasEligibleTranscript, enhancedNoteIds]); } export function useIsSessionEnhancing(sessionId: string): boolean { diff --git a/apps/desktop/src/store/tinybase/persister/session/save/transcript.test.ts b/apps/desktop/src/store/tinybase/persister/session/save/transcript.test.ts new file mode 100644 index 0000000000..b669dd70cb --- /dev/null +++ b/apps/desktop/src/store/tinybase/persister/session/save/transcript.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "vitest"; + +import { buildTranscriptSaveOps } from "./transcript"; + +describe("buildTranscriptSaveOps", () => { + test("writes transcript.json when the session meets the threshold", () => { + const ops = buildTranscriptSaveOps( + { + sessions: { + "session-1": { + folder_id: "", + }, + }, + transcripts: { + "transcript-1": { + session_id: "session-1", + user_id: "user-1", + created_at: "2026-03-11T00:00:00.000Z", + started_at: 1, + words: JSON.stringify( + Array.from({ length: 5 }, (_, index) => ({ + id: `word-${index}`, + text: `word-${index}`, + })), + ), + speaker_hints: "[]", + memo_md: "", + }, + }, + }, + "/data", + ); + + expect(ops).toEqual([ + { + type: "write-json", + path: "/data/sessions/session-1/transcript.json", + content: { + transcripts: [ + expect.objectContaining({ + id: "transcript-1", + session_id: "session-1", + }), + ], + }, + }, + ]); + }); + + test("deletes transcript.json when the session stays below the threshold", () => { + const ops = buildTranscriptSaveOps( + { + sessions: { + "session-1": { + folder_id: "", + }, + }, + transcripts: { + "transcript-1": { + session_id: "session-1", + user_id: "user-1", + created_at: "2026-03-11T00:00:00.000Z", + started_at: 1, + words: JSON.stringify([ + { id: "word-1", text: "hello" }, + { id: "word-2", text: "world" }, + ]), + speaker_hints: "[]", + memo_md: "", + }, + }, + }, + "/data", + ); + + expect(ops).toEqual([ + { + type: "delete", + paths: ["/data/sessions/session-1/transcript.json"], + }, + ]); + }); + + test("deletes transcript.json for changed sessions with no transcript rows left", () => { + const ops = buildTranscriptSaveOps( + { + sessions: { + "session-1": { + folder_id: "", + }, + }, + transcripts: {}, + }, + "/data", + new Set(["session-1"]), + ); + + expect(ops).toEqual([ + { + type: "delete", + paths: ["/data/sessions/session-1/transcript.json"], + }, + ]); + }); +}); diff --git a/apps/desktop/src/store/tinybase/persister/session/save/transcript.ts b/apps/desktop/src/store/tinybase/persister/session/save/transcript.ts index 3b86186bf1..a40670571d 100644 --- a/apps/desktop/src/store/tinybase/persister/session/save/transcript.ts +++ b/apps/desktop/src/store/tinybase/persister/session/save/transcript.ts @@ -9,6 +9,7 @@ import { type TablesContent, type WriteOperation, } from "~/store/tinybase/persister/shared"; +import { MIN_WORDS_FOR_MEANINGFUL_TRANSCRIPT } from "~/stt/thresholds"; type BuildContext = { tables: TablesContent; @@ -24,12 +25,13 @@ export function buildTranscriptSaveOps( const ctx: BuildContext = { tables, dataDir, changedSessionIds }; const transcriptsBySession = groupTranscriptsBySession(ctx); - const sessionsToProcess = filterByChangedSessions( + const sessionIdsToProcess = getSessionIdsToProcess( + tables, transcriptsBySession, changedSessionIds, ); - return buildOperations(ctx, sessionsToProcess); + return buildOperations(ctx, transcriptsBySession, sessionIdsToProcess); } function groupTranscriptsBySession( @@ -63,34 +65,65 @@ function groupTranscriptsBySession( return grouped; } -function filterByChangedSessions( +function getSessionIdsToProcess( + tables: TablesContent, transcriptsBySession: Map, changedSessionIds?: Set, -): Array<[string, TranscriptWithData[]]> { - const entries = [...transcriptsBySession]; - if (!changedSessionIds) return entries; - return entries.filter(([id]) => changedSessionIds.has(id)); +): string[] { + if (changedSessionIds) { + return [...changedSessionIds]; + } + + return [ + ...new Set([ + ...Object.keys(tables.sessions ?? {}), + ...transcriptsBySession.keys(), + ]), + ]; } function buildOperations( ctx: BuildContext, - sessions: Array<[string, TranscriptWithData[]]>, + transcriptsBySession: Map, + sessionIds: string[], ): WriteOperation[] { const { tables, dataDir } = ctx; + const operations: WriteOperation[] = []; + const deletePaths: string[] = []; - return sessions.map(([sessionId, transcripts]) => { + sessionIds.forEach((sessionId) => { + const transcripts = transcriptsBySession.get(sessionId) ?? []; const session = tables.sessions?.[sessionId]; const sessionDir = buildSessionPath( dataDir, sessionId, session?.folder_id ?? "", ); + const path = [sessionDir, SESSION_TRANSCRIPT_FILE].join(sep()); + const wordCount = transcripts.reduce( + (total, transcript) => total + (transcript.words?.length ?? 0), + 0, + ); + + if (wordCount < MIN_WORDS_FOR_MEANINGFUL_TRANSCRIPT) { + deletePaths.push(path); + return; + } const content: TranscriptJson = { transcripts }; - return { + operations.push({ type: "write-json" as const, - path: [sessionDir, SESSION_TRANSCRIPT_FILE].join(sep()), + path, content, - }; + }); }); + + if (deletePaths.length > 0) { + operations.push({ + type: "delete", + paths: deletePaths, + }); + } + + return operations; } diff --git a/apps/desktop/src/stt/thresholds.ts b/apps/desktop/src/stt/thresholds.ts new file mode 100644 index 0000000000..898e21d0f5 --- /dev/null +++ b/apps/desktop/src/stt/thresholds.ts @@ -0,0 +1 @@ +export const MIN_WORDS_FOR_MEANINGFUL_TRANSCRIPT = 5;