mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-19 11:06:25 +08:00
feat(server): refactor record schema (#14729)
#### PR Dependency Tree * **PR #14729** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Transcriptions now produce structured meeting summaries (strict JSON) and a normalized, speaker‑tagged, non‑overlapping transcript with legacy projection support. * **API** * Submission accepts richer transcription input; results return source‑audio metadata, slice manifest, quality indicators, normalized segments/transcript, and structured summary JSON. * **Frontend** * Recording flow stores transcription metadata and uploads preprocessed audio slices with slice/quality info; UI-side result normalization applied. * **Tests** * Expanded unit, contract, and e2e coverage for normalization, payload parsing, persistence/retry, and end‑to‑end transcription flows. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -16,6 +16,21 @@ import { getCurrentWorkspace, isAiEnabled } from './utils';
|
||||
const logger = new DebugLogger('electron-renderer:recording');
|
||||
const RECORDING_IMPORT_RETRY_MS = 1000;
|
||||
const NATIVE_RECORDING_MIME_TYPE = 'audio/ogg';
|
||||
const TRANSCRIPTION_BLOCK_FLAVOUR = 'affine:transcription';
|
||||
|
||||
type TranscriptionBlockModel = {
|
||||
props: {
|
||||
transcription: {
|
||||
sourceAudio?: {
|
||||
mimeType?: string;
|
||||
durationMs?: number;
|
||||
sampleRate?: number;
|
||||
channels?: number;
|
||||
};
|
||||
quality?: { degraded?: boolean; overflowCount?: number };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type RecordingImportStatus = {
|
||||
id: number;
|
||||
@@ -115,6 +130,29 @@ function findExistingAttachment(docStore: Store, attachmentName: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function ensureTranscriptionBlock(model: AttachmentBlockModel) {
|
||||
for (const key of model.childMap.value.keys()) {
|
||||
const block = model.store.getBlock$(key);
|
||||
if (block?.flavour === TRANSCRIPTION_BLOCK_FLAVOUR) {
|
||||
return block.model as unknown as TranscriptionBlockModel;
|
||||
}
|
||||
}
|
||||
|
||||
const blockId = model.store.addBlock(
|
||||
TRANSCRIPTION_BLOCK_FLAVOUR,
|
||||
{ transcription: {} },
|
||||
model.id
|
||||
);
|
||||
|
||||
const block = model.store.getBlock(blockId)?.model as
|
||||
| TranscriptionBlockModel
|
||||
| undefined;
|
||||
if (!block) {
|
||||
throw new Error('Failed to create transcription block');
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
async function createRecordingDoc(
|
||||
frameworkProvider: FrameworkProvider,
|
||||
workspace: WorkspaceHandle['workspace'],
|
||||
@@ -187,6 +225,20 @@ async function createRecordingDoc(
|
||||
attachmentCreated = true;
|
||||
}
|
||||
|
||||
const transcriptionBlock = ensureTranscriptionBlock(model);
|
||||
transcriptionBlock.props.transcription = {
|
||||
sourceAudio: {
|
||||
mimeType: model.props.type,
|
||||
durationMs: status.durationMs,
|
||||
sampleRate: status.sampleRate,
|
||||
channels: status.numberOfChannels,
|
||||
},
|
||||
quality: {
|
||||
degraded: status.degraded,
|
||||
overflowCount: status.overflowCount,
|
||||
},
|
||||
};
|
||||
|
||||
workspace.scope.get(WorkbenchService).workbench.openDoc(targetDocId);
|
||||
|
||||
if (!aiEnabled || !attachmentCreated) {
|
||||
|
||||
@@ -124,9 +124,32 @@ function createWorkspaceRef() {
|
||||
const blobSet = vi.fn(async () => 'blob-1');
|
||||
const openDoc = vi.fn();
|
||||
const createdDocs = new Set<string>();
|
||||
type MockBlockRecord = { model: unknown } | null;
|
||||
type MockBlockStore = {
|
||||
addBlock: (
|
||||
flavour: string,
|
||||
props: Record<string, unknown>,
|
||||
parentId?: string
|
||||
) => string;
|
||||
getBlock: (id: string) => MockBlockRecord;
|
||||
// eslint-disable-next-line rxjs/finnish
|
||||
getBlock$: (id: string) => MockBlockRecord;
|
||||
};
|
||||
const attachments: Array<{
|
||||
id: string;
|
||||
props: { name: string; type: string };
|
||||
childMap: { value: Map<string, true> };
|
||||
store: MockBlockStore;
|
||||
}> = [];
|
||||
const transcriptionBlocks: Array<{
|
||||
id: string;
|
||||
flavour: string;
|
||||
props: {
|
||||
transcription: {
|
||||
sourceAudio?: Record<string, unknown>;
|
||||
quality?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
}> = [];
|
||||
|
||||
const blockSuiteDoc = {
|
||||
@@ -143,20 +166,43 @@ function createWorkspaceRef() {
|
||||
return [];
|
||||
}),
|
||||
addBlock: vi.fn(
|
||||
(
|
||||
flavour: string,
|
||||
props: { name?: string; type?: string },
|
||||
_parentId?: string
|
||||
) => {
|
||||
(flavour: string, props: Record<string, unknown>, parentId?: string) => {
|
||||
if (flavour === 'affine:attachment') {
|
||||
const id = `attachment-${attachments.length + 1}`;
|
||||
attachments.push({
|
||||
const attachment = {
|
||||
id,
|
||||
props: {
|
||||
name: props.name ?? '',
|
||||
type: props.type ?? '',
|
||||
name: typeof props.name === 'string' ? props.name : '',
|
||||
type: typeof props.type === 'string' ? props.type : '',
|
||||
},
|
||||
});
|
||||
childMap: { value: new Map<string, true>() },
|
||||
store: {
|
||||
addBlock: (...args: Parameters<typeof blockSuiteDoc.addBlock>) =>
|
||||
blockSuiteDoc.addBlock(...args),
|
||||
getBlock: (blockId: string) => blockSuiteDoc.getBlock(blockId),
|
||||
// eslint-disable-next-line rxjs/finnish
|
||||
getBlock$: (blockId: string) => blockSuiteDoc.getBlock(blockId),
|
||||
},
|
||||
};
|
||||
attachments.push(attachment);
|
||||
return id;
|
||||
}
|
||||
if (flavour === 'affine:transcription') {
|
||||
const id = `transcription-${transcriptionBlocks.length + 1}`;
|
||||
const block = {
|
||||
id,
|
||||
flavour,
|
||||
props: {
|
||||
transcription:
|
||||
(props.transcription as {
|
||||
sourceAudio?: Record<string, unknown>;
|
||||
quality?: Record<string, unknown>;
|
||||
}) ?? {},
|
||||
},
|
||||
};
|
||||
transcriptionBlocks.push(block);
|
||||
const attachment = attachments.find(entry => entry.id === parentId);
|
||||
attachment?.childMap.value.set(id, true);
|
||||
return id;
|
||||
}
|
||||
return `${flavour}-1`;
|
||||
@@ -164,7 +210,13 @@ function createWorkspaceRef() {
|
||||
),
|
||||
getBlock: vi.fn((id: string) => {
|
||||
const attachment = attachments.find(entry => entry.id === id);
|
||||
return attachment ? { model: attachment } : null;
|
||||
if (attachment) {
|
||||
return { model: attachment };
|
||||
}
|
||||
const transcriptionBlock = transcriptionBlocks.find(
|
||||
entry => entry.id === id
|
||||
);
|
||||
return transcriptionBlock ? { model: transcriptionBlock } : null;
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -236,6 +288,7 @@ function createWorkspaceRef() {
|
||||
openDoc,
|
||||
blobSet,
|
||||
attachments,
|
||||
transcriptionBlocks,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -339,6 +392,54 @@ describe('recording effect', () => {
|
||||
expect(completeRecordingImport).toHaveBeenCalledWith(8);
|
||||
});
|
||||
|
||||
test('writes recording metadata into the transcription block', async () => {
|
||||
const workspace = createWorkspaceRef();
|
||||
const pendingImport = withQueueMeta({
|
||||
id: 18,
|
||||
importStatus: 'pending_import',
|
||||
appName: 'Meet',
|
||||
filepath: '/tmp/meeting.opus',
|
||||
startTime: 1000,
|
||||
sampleRate: 48_000,
|
||||
numberOfChannels: 2,
|
||||
durationMs: 120_000,
|
||||
degraded: true,
|
||||
overflowCount: 4,
|
||||
});
|
||||
|
||||
isActiveTab.mockResolvedValue(true);
|
||||
getCurrentWorkspace.mockReturnValue(workspace.ref);
|
||||
claimRecordingImport.mockResolvedValue({
|
||||
...pendingImport,
|
||||
workspaceId: 'workspace-1',
|
||||
docId: 'recording-18',
|
||||
importStatus: 'importing',
|
||||
});
|
||||
getRecordingImportQueue.mockResolvedValue([pendingImport]);
|
||||
|
||||
const { setupRecordingEvents } =
|
||||
await import('../../../electron-renderer/src/app/effects/recording');
|
||||
|
||||
setupRecordingEvents({} as never);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(workspace.transcriptionBlocks).toHaveLength(1);
|
||||
expect(workspace.transcriptionBlocks[0]?.props.transcription).toEqual({
|
||||
sourceAudio: {
|
||||
mimeType: 'audio/ogg',
|
||||
durationMs: 120_000,
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
},
|
||||
quality: {
|
||||
degraded: true,
|
||||
overflowCount: 4,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('marks imports as failed when blob import fails after claim', async () => {
|
||||
const pendingImport = {
|
||||
id: 9,
|
||||
|
||||
Reference in New Issue
Block a user