diff --git a/CLAUDE.md b/CLAUDE.md index 03c57f3..477cfc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -198,6 +198,7 @@ ty = (0.5 - vp.cy) × 100% | `remotion/types/camera.ts` | `CameraProfiles`, `CameraShot`, `CropViewport` | Will import from `scripts/types/` (Phase 6) | | `remotion/types/brand.ts` | Brand design tokens + extended identity/hosts/mascot/audio; `id: string` field required by registry | Move overlays + parameterize (Phase 0.5 Steps 3, 6–7) | | `scripts/config/project.ts` | `ProjectFile` type, `readProject`/`writeProject`, `ProjectNotFoundError`; typed `PipelineParams` (`timestamp_offset`, `diarization_seed`, `num_speakers`, `sync_window_seconds`) | Sprint 1 Issues #1, #3 | +| `scripts/config/metadata.js` | `stampMetadata(artifact, cwd?)` — prepends `schema_version` + `tool_versions` to any JSON artifact; reads tool versions from `.ragtech/project.json` if present | Sprint 1 Issue #4 | | `scripts/edit-transcript.js` | Sentence merging, `deriveCuts`, doc generation | Migrate to .ts (Phase 3) | | `scripts/sync/AudioSyncer.js` | FFT sync, `syncMultiple` | Add FFT tie-breaking (Phase 0) | | `scripts/wizard.js` | Interactive pipeline runner (60KB) | Replace with DAG runner (Phase 2) | diff --git a/docs/REFACTOR_ISSUE_INVENTORY.md b/docs/REFACTOR_ISSUE_INVENTORY.md index 04b1e32..6e18921 100644 --- a/docs/REFACTOR_ISSUE_INVENTORY.md +++ b/docs/REFACTOR_ISSUE_INVENTORY.md @@ -14,6 +14,7 @@ Create the artifact storage system that writes outputs into `.ragtech/artifacts/ Move runtime parameters such as diarization seed, timestamp offset, and number of speakers out of CLI flags and into the project file so pipeline runs are reproducible. ### 4. Add deterministic metadata to generated artifacts +✅ Done — `refactor/s2-artifact-metadata` — `stampMetadata()` helper in `scripts/config/metadata.js`; stamps `schema_version` + `tool_versions` on `transcript.raw.json`, `diarization.json`, `transcript.json` Ensure every JSON artifact includes schema version and tool version metadata so outputs can be traced to exact execution conditions. --- diff --git a/scripts/align/align-transcript.js b/scripts/align/align-transcript.js index a5cc196..28d0efd 100644 --- a/scripts/align/align-transcript.js +++ b/scripts/align/align-transcript.js @@ -5,6 +5,7 @@ import fs from 'fs-extra'; import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; +import { stampMetadata } from '../config/metadata.js'; const ALIGN_SCRIPT_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), 'run_whisperx_align.py'); @@ -272,7 +273,7 @@ async function main() { const { updated, appliedSegments } = applyAlignment(raw, aligned); - await fs.writeJson(rawPath, updated, { spaces: 2 }); + await fs.writeJson(rawPath, stampMetadata(updated), { spaces: 2 }); console.log(`\n ✓ Alignment applied to ${appliedSegments} segment(s).`); console.log(` ✓ Updated transcript: ${rawPath}`); diff --git a/scripts/config/metadata.js b/scripts/config/metadata.js new file mode 100644 index 0000000..23fb647 --- /dev/null +++ b/scripts/config/metadata.js @@ -0,0 +1,31 @@ +import fs from 'fs'; +import path from 'path'; + +export const ARTIFACT_SCHEMA_VERSION = '1'; + +function readProjectTools(cwd) { + const projectPath = path.join(cwd, '.ragtech', 'project.json'); + try { + const raw = fs.readFileSync(projectPath, 'utf-8'); + return JSON.parse(raw)?.tools ?? {}; + } catch { + return {}; + } +} + +export function buildToolVersions(cwd = process.cwd()) { + const projectTools = readProjectTools(cwd); + return { node: process.version, ...projectTools }; +} + +/** + * Returns artifact with schema_version and tool_versions prepended. + * Additive — existing fields are preserved and take precedence over metadata keys. + */ +export function stampMetadata(artifact, cwd = process.cwd()) { + return { + schema_version: ARTIFACT_SCHEMA_VERSION, + tool_versions: buildToolVersions(cwd), + ...artifact, + }; +} diff --git a/scripts/config/metadata.test.js b/scripts/config/metadata.test.js new file mode 100644 index 0000000..88cae96 --- /dev/null +++ b/scripts/config/metadata.test.js @@ -0,0 +1,59 @@ +import { ARTIFACT_SCHEMA_VERSION, buildToolVersions, stampMetadata } from './metadata.js'; + +describe('metadata helper', () => { + describe('ARTIFACT_SCHEMA_VERSION', () => { + it('is a non-empty string', () => { + expect(typeof ARTIFACT_SCHEMA_VERSION).toBe('string'); + expect(ARTIFACT_SCHEMA_VERSION.length).toBeGreaterThan(0); + }); + }); + + describe('buildToolVersions', () => { + it('always includes node version', () => { + const versions = buildToolVersions('/nonexistent-cwd-for-test'); + expect(versions.node).toBe(process.version); + }); + + it('returns plain object even when project.json is absent', () => { + const versions = buildToolVersions('/nonexistent-cwd-for-test'); + expect(typeof versions).toBe('object'); + expect(versions).not.toBeNull(); + }); + }); + + describe('stampMetadata', () => { + it('adds schema_version and tool_versions to an artifact', () => { + const artifact = { meta: { fps: 60 }, segments: [] }; + const stamped = stampMetadata(artifact, '/nonexistent-cwd-for-test'); + + expect(stamped.schema_version).toBe(ARTIFACT_SCHEMA_VERSION); + expect(typeof stamped.tool_versions).toBe('object'); + expect(stamped.tool_versions.node).toBe(process.version); + }); + + it('preserves all original artifact fields', () => { + const artifact = { meta: { fps: 60 }, segments: [{ id: 1 }], extra: 'keep' }; + const stamped = stampMetadata(artifact, '/nonexistent-cwd-for-test'); + + expect(stamped.meta).toEqual({ fps: 60 }); + expect(stamped.segments).toEqual([{ id: 1 }]); + expect(stamped.extra).toBe('keep'); + }); + + it('artifact fields take precedence over metadata keys', () => { + // If an artifact already has schema_version, the spread preserves it + const artifact = { schema_version: '99', segments: [] }; + const stamped = stampMetadata(artifact, '/nonexistent-cwd-for-test'); + + expect(stamped.schema_version).toBe('99'); + }); + + it('works with object-wrapped array artifacts', () => { + const artifact = { turns: [{ speaker: 'A', start: 0, end: 1 }] }; + const stamped = stampMetadata(artifact, '/nonexistent-cwd-for-test'); + + expect(stamped.schema_version).toBe(ARTIFACT_SCHEMA_VERSION); + expect(stamped.turns).toEqual(artifact.turns); + }); + }); +}); diff --git a/scripts/diarize/Diarizer.js b/scripts/diarize/Diarizer.js index 21c3bac..b95474c 100644 --- a/scripts/diarize/Diarizer.js +++ b/scripts/diarize/Diarizer.js @@ -3,6 +3,7 @@ import path from 'path'; import os from 'os'; import fs from 'fs-extra'; import { fileURLToPath } from 'url'; +import { stampMetadata } from '../config/metadata.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -123,7 +124,7 @@ class Diarizer { const speakerCount = new Set(turns.map((t) => t.speaker)).size; console.log(` ${speakerCount} speaker(s), ${turns.length} turns.`); - await fs.writeJson(this.diarizationJsonPath, turns, { spaces: 2 }); + await fs.writeJson(this.diarizationJsonPath, stampMetadata({ turns }), { spaces: 2 }); console.log(` Saved: ${this.diarizationJsonPath}`); return turns; @@ -162,7 +163,9 @@ class Diarizer { async runAssignment() { console.log(`Reading diarization output: ${this.diarizationJsonPath}`); - const turns = await fs.readJson(this.diarizationJsonPath); + const raw = await fs.readJson(this.diarizationJsonPath); + // Support both legacy array format and current object format { turns: [...] } + const turns = Array.isArray(raw) ? raw : (raw.turns ?? []); console.log(` ${new Set(turns.map((t) => t.speaker)).size} speaker(s), ${turns.length} turns.`); console.log(`Reading transcript: ${this.rawJsonPath}`); @@ -170,7 +173,7 @@ class Diarizer { console.log('Assigning speaker labels...'); const updated = this.assignSpeakers(transcript, turns); - await fs.writeJson(this.rawJsonPath, updated, { spaces: 2 }); + await fs.writeJson(this.rawJsonPath, stampMetadata(updated), { spaces: 2 }); const speakers = [...new Set(updated.segments.map((s) => s.speaker).filter(Boolean))]; console.log(` Labels assigned: ${speakers.join(', ') || '(none — check diarization.json timestamps overlap with transcript)'}`); diff --git a/scripts/edit-transcript.js b/scripts/edit-transcript.js index d1c1e01..cdb399e 100644 --- a/scripts/edit-transcript.js +++ b/scripts/edit-transcript.js @@ -3,6 +3,7 @@ import fs from 'fs-extra'; import path from 'path'; import { convertVttToSrt } from './shared/vtt-to-srt.js'; +import { stampMetadata } from './config/metadata.js'; function parseArgs() { const args = process.argv.slice(2); @@ -2011,7 +2012,7 @@ async function main() { const chaptersPath = outputPath.replace(/\.json$/, '.chapters.txt'); await fs.ensureDir(path.dirname(outputPath)); - await fs.writeJson(outputPath, transcript, { spaces: 2 }); + await fs.writeJson(outputPath, stampMetadata(transcript), { spaces: 2 }); await fs.writeFile(sentencesSrtPath, buildSentencesSrt(transcript.segments, transcript.meta), 'utf8'); await fs.writeFile(docPath, buildDoc(transcript), 'utf8'); await fs.writeFile(youtubeSrtPath, buildYouTubeSubtitles(transcript), 'utf8'); diff --git a/scripts/transcribe/Transcriber.js b/scripts/transcribe/Transcriber.js index 9f1eb96..e98f4f2 100644 --- a/scripts/transcribe/Transcriber.js +++ b/scripts/transcribe/Transcriber.js @@ -4,6 +4,7 @@ import path from 'path'; import fs from 'fs-extra'; import { open as fsOpen } from 'node:fs/promises'; import { installWhisperCpp, downloadWhisperModel, transcribe } from '@remotion/install-whisper-cpp'; +import { stampMetadata } from '../config/metadata.js'; const WHISPER_VERSION = '1.5.5'; const DEFAULT_MODEL = 'medium.en'; @@ -248,7 +249,7 @@ class Transcriber { const jsonPath = path.join(this.outputDir, 'transcript.raw.json'); await fs.writeFile(vttPath, this.buildVtt(transcription), 'utf8'); - await fs.writeJson(jsonPath, this.buildJson(transcription), { spaces: 2 }); + await fs.writeJson(jsonPath, stampMetadata(this.buildJson(transcription)), { spaces: 2 }); console.log(` VTT: ${vttPath}`); console.log(` JSON: ${jsonPath}`); diff --git a/tests/integration/diarizer-runAssignment.test.ts b/tests/integration/diarizer-runAssignment.test.ts new file mode 100644 index 0000000..ff8781e --- /dev/null +++ b/tests/integration/diarizer-runAssignment.test.ts @@ -0,0 +1,72 @@ +import os from 'os'; +import path from 'path'; +import fse from 'fs-extra'; +import Diarizer from '../../scripts/diarize/Diarizer.js'; +import { ARTIFACT_SCHEMA_VERSION } from '../../scripts/config/metadata.js'; + +const SAMPLE_TRANSCRIPT = { + meta: { fps: 60 }, + segments: [ + { id: 's1', start: 0.0, end: 2.0, text: 'Hello world', speaker: '', tokens: [], cuts: [] }, + { id: 's2', start: 3.0, end: 5.0, text: 'How are you', speaker: '', tokens: [], cuts: [] }, + ], +}; + +const SAMPLE_TURNS = [ + { speaker: 'Natasha', start: 0.0, end: 2.5 }, + { speaker: 'Saloni', start: 2.8, end: 5.5 }, +]; + +describe('Diarizer.runAssignment — diarization.json format compatibility', () => { + let tmpDir: string; + let diarizationPath: string; + let transcriptPath: string; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'deckcreate-diarizer-test-')); + diarizationPath = path.join(tmpDir, 'diarization.json'); + transcriptPath = path.join(tmpDir, 'transcript.raw.json'); + await fse.writeJson(transcriptPath, SAMPLE_TRANSCRIPT, { spaces: 2 }); + }); + + afterEach(async () => { + await fse.remove(tmpDir); + }); + + function makeDiarizer() { + return new Diarizer({ diarizationJsonPath: diarizationPath, rawJsonPath: transcriptPath }); + } + + it('reads legacy array format and assigns speakers to segments', async () => { + await fse.writeJson(diarizationPath, SAMPLE_TURNS, { spaces: 2 }); + const result = await makeDiarizer().runAssignment(); + const speakers = result.segments.map((s: { speaker: string }) => s.speaker); + expect(speakers).toEqual(['Natasha', 'Saloni']); + }); + + it('reads new object format { turns: [...] } and assigns speakers', async () => { + await fse.writeJson( + diarizationPath, + { schema_version: ARTIFACT_SCHEMA_VERSION, tool_versions: { node: process.version }, turns: SAMPLE_TURNS }, + { spaces: 2 }, + ); + const result = await makeDiarizer().runAssignment(); + const speakers = result.segments.map((s: { speaker: string }) => s.speaker); + expect(speakers).toEqual(['Natasha', 'Saloni']); + }); + + it('handles empty turns array gracefully (no speakers assigned)', async () => { + await fse.writeJson(diarizationPath, { turns: [] }, { spaces: 2 }); + const result = await makeDiarizer().runAssignment(); + const speakers = result.segments.map((s: { speaker: string }) => s.speaker); + expect(speakers.every((sp: string) => sp === '')).toBe(true); + }); + + it('stamps schema_version and tool_versions on the written transcript', async () => { + await fse.writeJson(diarizationPath, SAMPLE_TURNS, { spaces: 2 }); + await makeDiarizer().runAssignment(); + const written = await fse.readJson(transcriptPath); + expect(written.schema_version).toBe(ARTIFACT_SCHEMA_VERSION); + expect(written.tool_versions.node).toBe(process.version); + }); +});