diff --git a/docs/meeting-block.md b/docs/meeting-block.md index 4e52a824..ae12a26f 100644 --- a/docs/meeting-block.md +++ b/docs/meeting-block.md @@ -16,6 +16,7 @@ Unknown top-level props remain allowed for forward compatibility. | `status` | `'idle' \| 'recording' \| 'processing' \| 'done'`. Validation checks the enum, not state transitions. | | `audioChunks` | Ordered array of `{assetId: string, durationMs: number, startedAtMs?: number}`. `assetId` is a nonempty asset reference (max 512 characters), never inline audio. `durationMs` is the chunk duration; optional `startedAtMs` is an offset from meeting start, **not** Unix time. Array order is capture/playback order. | | `transcript` | Ordered array of `{startMs: number, endMs: number, text: string}`. Offsets are relative to meeting start; `endMs >= startMs`. Text is plain text. Array order is display order; overlapping segments are allowed. | +| `transcriptionCompleted` | Optional `string[]` of successfully transcribed chunk keys, each `startedAtMs:assetId` (a nonnegative number, a colon, then a nonempty asset ID). The recorder’s `chunkKey` uses `0` when `startedAtMs` is absent. Used to skip already transcribed chunks on retry, including chunks with empty transcription results. Missing means an empty list; `null` removes the prop. Existing keys are preserved unchanged; duplicates and non-UUID asset IDs are allowed. Updates replace the whole array. | | `summary` | Plain string; no rich-text runs or Markdown interpretation is required. | | `startedAt` | Unix epoch milliseconds, a finite nonnegative number. | | `title` | Optional plain string. | diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 9d0002ff..fb426316 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -93,7 +93,7 @@ Call `list_block_types` for the machine-readable source of truth: every entry ha | `tooltipcard` | `term:string`, `tip:string` | | `dbview` | `pageId:string` | | `dbform` | `databaseId:string`, `viewId:string` | -| `meeting` | `status:"idle"/"recording"/"processing"/"done"`, `audioChunks:[{assetId:string,durationMs:number,startedAtMs?:number}]`, `transcript:[{startMs:number,endMs:number,text:string}]`, `summary:string`, `startedAt:number`, `title:string`; container children are manual notes; [representation contract](../../docs/meeting-block.md) | +| `meeting` | `status:"idle"/"recording"/"processing"/"done"`, `audioChunks:[{assetId:string,durationMs:number,startedAtMs?:number}]`, `transcript:[{startMs:number,endMs:number,text:string}]`, `transcriptionCompleted?:string[]` (completed chunk keys: a nonnegative number, a colon, then a nonempty asset ID; absent means empty, `null` removes), `summary:string`, `startedAt:number`, `title:string`; container children are manual notes; [representation contract](../../docs/meeting-block.md) | | `form` | `formId:string`, `submissionKey:string`, `enabled:boolean`, `databaseId:string`, `schema:object`, `label:string`, `description:string` | Shared input/frame props are `name`, `label`, and `description` strings plus `compact` and `interactive` booleans. A structured option's `value` defaults to a slug of its `label`; `selected` contains those string values. diff --git a/packages/sdk/src/blockCatalogue.test.ts b/packages/sdk/src/blockCatalogue.test.ts index 4310c0e1..99499a0b 100644 --- a/packages/sdk/src/blockCatalogue.test.ts +++ b/packages/sdk/src/blockCatalogue.test.ts @@ -253,10 +253,11 @@ describe('meeting representation contract (MEET-4)', () => { expect(invalidBlockProps('meeting', {status, startedAt: 1234, title: 'Review', summary: 'Agreed.', audioChunks: [{assetId: 'audio-1', durationMs: 500, startedAtMs: 0}, {assetId: 'audio-2', durationMs: 250}], transcript: [{startMs: 0, endMs: 500, text: 'Hello'}], + transcriptionCompleted: ['0:audio-1', '500:audio-2'], })).toBeNull(); } expect(invalidBlockProps('meeting', {})).toBeNull(); - expect(invalidBlockProps('meeting', {status: null, audioChunks: null, transcript: null, summary: null, startedAt: null, title: null})).toBeNull(); + expect(invalidBlockProps('meeting', {status: null, audioChunks: null, transcript: null, transcriptionCompleted: null, summary: null, startedAt: null, title: null})).toBeNull(); }); it.each([ @@ -270,9 +271,27 @@ describe('meeting representation contract (MEET-4)', () => { expect(invalidBlockProps('meeting', props)).toContain('Invalid prop'); }); + it.each([[], ['0:legacy-asset'], ['1.5:asset'], ['1e-7:asset', '1e+21:asset', '1.5e21:asset'], ['001:asset:with:colons', '001:asset:with:colons']].map((keys) => ({keys})))( + 'accepts optional completion keys without rewriting legacy values: %j', ({keys}) => { + expect(invalidBlockProps('meeting', {transcriptionCompleted: keys})).toBeNull(); + }, + ); + + it.each(['0:asset', {}, [1], [null], [''], ['asset'], [':asset'], ['-1:asset'], ['1:'], ['x:asset']].map((value) => ({value})))( + 'rejects malformed completion keys: %j', ({value}) => { + expect(invalidBlockProps('meeting', {transcriptionCompleted: value})).toContain('Invalid prop "transcriptionCompleted"'); + }, + ); + it('publishes nested item schemas and required fields to agents', () => { const {blocks} = JSON.parse(blockCatalogueText([], ['meeting'])); expect(blocks).toHaveLength(1); + expect(blockTypeInfo('meeting')?.props?.transcriptionCompleted).toBe('array'); + expect(blocks[0].description).toContain('transcriptionCompleted'); + expect(blocks[0].propsSchema.properties.transcriptionCompleted).toMatchObject({ + type: 'array', nullable: true, items: {type: 'string', pattern: '^\\d+(\\.\\d+)?(e[+-]?\\d+)?:.+$'}, + }); + expect(blocks[0].propsSchema.required).toBeUndefined(); expect(blocks[0].propsSchema.properties.status.enum).toEqual(['idle', 'recording', 'processing', 'done']); expect(blocks[0].propsSchema.properties.audioChunks.items.required).toEqual(['assetId', 'durationMs']); expect(blocks[0].propsSchema.properties.transcript.items.required).toEqual(['startMs', 'endMs', 'text']); diff --git a/packages/sdk/src/blockCatalogue.ts b/packages/sdk/src/blockCatalogue.ts index 2c86bf60..3f07190d 100644 --- a/packages/sdk/src/blockCatalogue.ts +++ b/packages/sdk/src/blockCatalogue.ts @@ -109,7 +109,7 @@ const CATALOGUE_LITERAL = [ {type: 'tooltipcard', category: 'kit', nature: 'void', props: {term: 'string', tip: 'string'}, hint: '{term,tip}'}, {type: 'dbview', category: 'kit', nature: 'void', props: {pageId: 'string'}, hint: 'embedded live database view {pageId} — the page hosting the database'}, {type: 'dbform', category: 'kit', nature: 'void', props: {databaseId: 'string', viewId: 'string'}, hint: 'embedded database form {databaseId,viewId} — a live reference, never a copied schema or capability'}, - {type: 'meeting', category: 'kit', nature: 'container', kitValue: false, props: {status: 'string', audioChunks: 'array', transcript: 'array', summary: 'string', startedAt: 'number', title: 'string'}, hint: 'meeting recording {status?,audioChunks?:[{assetId,durationMs,startedAtMs?}],transcript?:[{startMs,endMs,text}],summary?,startedAt?,title?}; times in ms, startedAt is Unix epoch; children hold manual notes; see docs/meeting-block.md'}, + {type: 'meeting', category: 'kit', nature: 'container', kitValue: false, props: {status: 'string', audioChunks: 'array', transcript: 'array', transcriptionCompleted: 'array', summary: 'string', startedAt: 'number', title: 'string'}, hint: 'meeting recording {status?,audioChunks?:[{assetId,durationMs,startedAtMs?}],transcript?:[{startMs,endMs,text}],transcriptionCompleted?:["startedAtMs:assetId"],summary?,startedAt?,title?}; times in ms, startedAt is Unix epoch; children hold manual notes; see docs/meeting-block.md'}, {type: 'form', category: 'kit', nature: 'void', kitValue: false, props: {formId: 'string', submissionKey: 'string', enabled: 'boolean', databaseId: 'string', schema: 'object', label: 'string', description: 'string'}, hint: 'public form definition {formId,submissionKey,enabled,databaseId?,schema}'}, ] as const satisfies readonly BlockTypeInfo[]; diff --git a/packages/sdk/src/blockPropSchemas.ts b/packages/sdk/src/blockPropSchemas.ts index 56e07684..0d31f0a0 100644 --- a/packages/sdk/src/blockPropSchemas.ts +++ b/packages/sdk/src/blockPropSchemas.ts @@ -50,6 +50,10 @@ const audioChunk = object({ durationMs: number('Chunk duration in milliseconds.', 0), startedAtMs: number('Offset from the meeting start in milliseconds.', 0), }, 'One audio asset in capture order.', ['assetId', 'durationMs']); +const transcriptionChunkKey: Field = { + schema: z.string().regex(/^\d+(\.\d+)?(e[+-]?\d+)?:.+$/, 'must be a startedAtMs:assetId chunk key'), + json: {type: 'string', pattern: '^\\d+(\\.\\d+)?(e[+-]?\\d+)?:.+$', description: 'Chunk offset in milliseconds followed by a colon and a nonempty asset ID.'}, +}; const transcriptSegment = object({ startMs: number('Inclusive offset from the meeting start in milliseconds.', 0), endMs: number('Exclusive offset from the meeting start in milliseconds; must be >= startMs.', 0), @@ -91,6 +95,7 @@ const fields = { status: enumeration(['idle', 'recording', 'processing', 'done'], 'Absent means idle.'), audioChunks: array(audioChunk, 'Audio chunks in capture order; replace the entire array when updating.'), transcript: array(transcriptSegment, 'Plain-text segments in display order; replace the entire array when updating.'), + transcriptionCompleted: array(transcriptionChunkKey, 'Successfully transcribed chunk keys (startedAtMs:assetId); absent means empty. Replace the entire array when updating.'), summary: string('Plain-text summary.'), startedAt: number('Meeting start as Unix epoch milliseconds.', 0), title: text, diff --git a/packages/ui/src/blockeditor/__tests__/meetingRecorder.test.tsx b/packages/ui/src/blockeditor/__tests__/meetingRecorder.test.tsx index 34daad4e..85e8448d 100644 --- a/packages/ui/src/blockeditor/__tests__/meetingRecorder.test.tsx +++ b/packages/ui/src/blockeditor/__tests__/meetingRecorder.test.tsx @@ -2,7 +2,7 @@ import * as Y from 'yjs'; import {setLocale, t} from '@/i18n'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {act, cleanup, fireEvent, render, screen} from '@testing-library/react'; -import type {AiTranscriptionResult} from '@book.dev/sdk'; +import {invalidBlockProps, type AiTranscriptionResult} from '@book.dev/sdk'; import {setAssetBridge} from '@/lib/assetBridge'; import {registerBlockEditorDoc} from '@/lib/aiBridge'; import {AssetBridgeHost} from '@/components/AssetBridgeHost'; @@ -84,10 +84,21 @@ describe('meeting recording and recovery', () => { expect(blockProp(block, 'transcript')).toEqual([ {startMs: 250, endMs: 1500, text: 'hello'}, {startMs: 45250, endMs: 46500, text: 'hello'}, {startMs: 47250, endMs: 48500, text: 'hello'}, ]); + expect(blockProp(block, 'transcriptionCompleted')).toEqual(['0:asset-2', '45000:asset-2', '47000:asset-3']); + expect(invalidBlockProps('meeting', {transcriptionCompleted: blockProp(block, 'transcriptionCompleted')})).toBeNull(); expect(docToJSON(decodeSnapshot(encodeSnapshot(doc)))).toEqual(docToJSON(doc)); expect(track.stop).toHaveBeenCalled(); }); + it.each([1.5, 1e-7, 1e21])('persists schema-valid completion keys when retrying a chunk starting at %s ms', async (startedAtMs) => { + const audio = {assetId: 'saved-audio', durationMs: 1000, startedAtMs}; + const {session, block} = harness({status: 'done', audioChunks: [audio]}); + session.retry(audio); await flush(); + const completed = blockProp(block, 'transcriptionCompleted'); + expect(completed).toEqual([chunkKey(audio)]); + expect(invalidBlockProps('meeting', {transcriptionCompleted: completed})).toBeNull(); + }); + it('keeps audio on upload failure, retries with exponential backoff, then permits manual recovery', async () => { putAsset.mockRejectedValue(new Error('offline')); const {session, block} = harness();