From 3d764b9672827ca2995caec331e8fa045267c7c5 Mon Sep 17 00:00:00 2001 From: Natasha Ann Date: Wed, 13 May 2026 18:31:16 +0800 Subject: [PATCH 1/7] feat(config): add stampMetadata helper for artifact provenance Centralized helper that stamps schema_version and tool_versions onto any JSON artifact object before it is written to disk. Reads tool versions from .ragtech/project.json if present; falls back to node version only. Keeps stamping logic in one place so pipeline scripts import rather than duplicate the version-detection code. AC: centralize-if-possible --- scripts/config/metadata.js | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 scripts/config/metadata.js 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, + }; +} From 1d19d515e66f1942d5160c0285cdeacaa1595491 Mon Sep 17 00:00:00 2001 From: Natasha Ann Date: Wed, 13 May 2026 18:31:39 +0800 Subject: [PATCH 2/7] test(config): unit tests for stampMetadata helper Covers ARTIFACT_SCHEMA_VERSION constant, buildToolVersions fallback when project.json is absent, stampMetadata field merging, original-field preservation, and array-wrapped artifact shape. --- scripts/config/metadata.test.js | 59 +++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 scripts/config/metadata.test.js 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); + }); + }); +}); From 8c676860416f773680b24748d85ce911f98a8d36 Mon Sep 17 00:00:00 2001 From: Natasha Ann Date: Wed, 13 May 2026 18:33:00 +0800 Subject: [PATCH 3/7] feat(pipeline): stamp artifact metadata on JSON outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each pipeline script that writes a JSON artifact now calls stampMetadata() before fs.writeJson so every output carries schema_version and tool_versions. Covered artifacts: - transcript.raw.json (Transcriber.js, Diarizer.assignSpeakers, align-transcript.js) - diarization.json (Diarizer.js — array wrapped in { turns, schema_version, tool_versions }) - transcript.json (edit-transcript.js) diarization.json reader updated to accept both legacy array format and the new object format so runs that have old artifacts on disk continue to work. AC: functional-metadata, downstream-compat --- scripts/align/align-transcript.js | 3 ++- scripts/diarize/Diarizer.js | 9 ++++++--- scripts/edit-transcript.js | 3 ++- scripts/transcribe/Transcriber.js | 3 ++- 4 files changed, 12 insertions(+), 6 deletions(-) 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/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}`); From 1b67c788b44f6b2bbc6d96820e16e3f57cabc6fa Mon Sep 17 00:00:00 2001 From: Natasha Ann Date: Wed, 13 May 2026 18:33:46 +0800 Subject: [PATCH 4/7] docs(inventory): mark issue #4 complete --- docs/REFACTOR_ISSUE_INVENTORY.md | 1 + 1 file changed, 1 insertion(+) 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. --- From a84174201c8341fbcab6a96f91d4b58936efa51d Mon Sep 17 00:00:00 2001 From: Natasha Ann Date: Wed, 13 May 2026 18:34:00 +0800 Subject: [PATCH 5/7] docs(claude-md): reflect changes from issue #4 --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) 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) | From 56ddd907e9c4fe6b95ddbaa7713cd2ecc3ba23c5 Mon Sep 17 00:00:00 2001 From: Natasha Ann Date: Thu, 14 May 2026 14:44:35 +0800 Subject: [PATCH 6/7] test(diarize): integration tests for runAssignment format compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-push audit identified that the backwards-compat parsing added in feat(pipeline): stamp artifact metadata on JSON outputs was untested. Diarizer.runAssignment now accepts both the legacy bare array format and the new { turns: [...] } object format — tests confirm both paths assign speakers correctly and that the written transcript is stamped. --- .../diarizer-runAssignment.test.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/integration/diarizer-runAssignment.test.ts diff --git a/tests/integration/diarizer-runAssignment.test.ts b/tests/integration/diarizer-runAssignment.test.ts new file mode 100644 index 0000000..a7fb524 --- /dev/null +++ b/tests/integration/diarizer-runAssignment.test.ts @@ -0,0 +1,71 @@ +import os from 'os'; +import path from 'path'; +import fse from 'fs-extra'; +import Diarizer from '../../scripts/diarize/Diarizer.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: '1', 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).toBeDefined(); + expect(typeof written.tool_versions).toBe('object'); + }); +}); From 4e92b93c824c67d4578b03ab179466cb6689f739 Mon Sep 17 00:00:00 2001 From: Natasha Ann Date: Thu, 14 May 2026 15:01:10 +0800 Subject: [PATCH 7/7] test(diarize): strengthen stampMetadata assertions in runAssignment tests W1/W2 from PR review: hardcoded schema_version string and weak toBeDefined() assertions replaced with imported ARTIFACT_SCHEMA_VERSION constant and exact node version check, so tests break if the constant or stamping logic drifts. --- tests/integration/diarizer-runAssignment.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/integration/diarizer-runAssignment.test.ts b/tests/integration/diarizer-runAssignment.test.ts index a7fb524..ff8781e 100644 --- a/tests/integration/diarizer-runAssignment.test.ts +++ b/tests/integration/diarizer-runAssignment.test.ts @@ -2,6 +2,7 @@ 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 }, @@ -46,7 +47,7 @@ describe('Diarizer.runAssignment — diarization.json format compatibility', () it('reads new object format { turns: [...] } and assigns speakers', async () => { await fse.writeJson( diarizationPath, - { schema_version: '1', tool_versions: { node: process.version }, turns: SAMPLE_TURNS }, + { schema_version: ARTIFACT_SCHEMA_VERSION, tool_versions: { node: process.version }, turns: SAMPLE_TURNS }, { spaces: 2 }, ); const result = await makeDiarizer().runAssignment(); @@ -65,7 +66,7 @@ describe('Diarizer.runAssignment — diarization.json format compatibility', () await fse.writeJson(diarizationPath, SAMPLE_TURNS, { spaces: 2 }); await makeDiarizer().runAssignment(); const written = await fse.readJson(transcriptPath); - expect(written.schema_version).toBeDefined(); - expect(typeof written.tool_versions).toBe('object'); + expect(written.schema_version).toBe(ARTIFACT_SCHEMA_VERSION); + expect(written.tool_versions.node).toBe(process.version); }); });