Files
waoowaoo/scripts/guards/prompt-json-canary-guard.mjs

251 lines
10 KiB
JavaScript

#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
import process from 'process'
const root = process.cwd()
const CANARY_FILES = {
clips: 'standards/prompt-canary/story_to_script_clips.canary.json',
screenplay: 'standards/prompt-canary/screenplay_conversion.canary.json',
storyboardPanels: 'standards/prompt-canary/storyboard_panels.canary.json',
voiceAnalysis: 'standards/prompt-canary/voice_analysis.canary.json',
}
const TEMPLATE_TOKEN_REQUIREMENTS = {
'novel-promotion/agent_clip': ['start', 'end', 'summary', 'location', 'characters'],
'novel-promotion/screenplay_conversion': [
'clip_id',
'original_text',
'scenes',
'heading',
'content',
'type',
'action',
'dialogue',
'voiceover',
],
'novel-promotion/agent_storyboard_plan': [
'panel_number',
'description',
'characters',
'location',
'scene_type',
'source_text',
],
'novel-promotion/agent_storyboard_detail': [
'panel_number',
'description',
'characters',
'location',
'scene_type',
'source_text',
'shot_type',
'camera_move',
'video_prompt',
],
'novel-promotion/agent_storyboard_insert': [
'panel_number',
'description',
'characters',
'location',
'scene_type',
'source_text',
'shot_type',
'camera_move',
'video_prompt',
],
'novel-promotion/voice_analysis': [
'lineIndex',
'speaker',
'content',
'emotionStrength',
'matchedPanel',
'storyboardId',
'panelIndex',
],
}
function fail(title, details = []) {
console.error(`\n[prompt-json-canary-guard] ${title}`)
for (const line of details) {
console.error(` - ${line}`)
}
process.exit(1)
}
function isRecord(value) {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function isString(value) {
return typeof value === 'string'
}
function isNumber(value) {
return typeof value === 'number' && Number.isFinite(value)
}
function readJson(relativePath) {
const fullPath = path.join(root, relativePath)
if (!fs.existsSync(fullPath)) {
fail('Missing canary fixture', [relativePath])
}
try {
return JSON.parse(fs.readFileSync(fullPath, 'utf8'))
} catch (error) {
fail('Invalid canary fixture JSON', [`${relativePath}: ${error instanceof Error ? error.message : String(error)}`])
}
}
function validateClipCanary(value) {
if (!Array.isArray(value) || value.length === 0) return 'clips fixture must be a non-empty array'
for (let i = 0; i < value.length; i += 1) {
const row = value[i]
if (!isRecord(row)) return `clips[${i}] must be an object`
if (!isString(row.start) || row.start.length < 5) return `clips[${i}].start must be string length >= 5`
if (!isString(row.end) || row.end.length < 5) return `clips[${i}].end must be string length >= 5`
if (!isString(row.summary) || row.summary.length === 0) return `clips[${i}].summary must be non-empty string`
if (!(row.location === null || isString(row.location))) return `clips[${i}].location must be string or null`
if (!Array.isArray(row.characters) || !row.characters.every((item) => isString(item))) {
return `clips[${i}].characters must be string array`
}
}
return null
}
function validateScreenplayCanary(value) {
if (!isRecord(value)) return 'screenplay fixture must be an object'
if (!isString(value.clip_id) || !value.clip_id) return 'screenplay.clip_id must be non-empty string'
if (!isString(value.original_text)) return 'screenplay.original_text must be string'
if (!Array.isArray(value.scenes) || value.scenes.length === 0) return 'screenplay.scenes must be non-empty array'
for (let i = 0; i < value.scenes.length; i += 1) {
const scene = value.scenes[i]
if (!isRecord(scene)) return `screenplay.scenes[${i}] must be object`
if (!isNumber(scene.scene_number)) return `screenplay.scenes[${i}].scene_number must be number`
if (!isRecord(scene.heading)) return `screenplay.scenes[${i}].heading must be object`
if (!isString(scene.heading.int_ext)) return `screenplay.scenes[${i}].heading.int_ext must be string`
if (!isString(scene.heading.location)) return `screenplay.scenes[${i}].heading.location must be string`
if (!isString(scene.heading.time)) return `screenplay.scenes[${i}].heading.time must be string`
if (!isString(scene.description)) return `screenplay.scenes[${i}].description must be string`
if (!Array.isArray(scene.characters) || !scene.characters.every((item) => isString(item))) {
return `screenplay.scenes[${i}].characters must be string array`
}
if (!Array.isArray(scene.content) || scene.content.length === 0) return `screenplay.scenes[${i}].content must be non-empty array`
for (let j = 0; j < scene.content.length; j += 1) {
const segment = scene.content[j]
if (!isRecord(segment)) return `screenplay.scenes[${i}].content[${j}] must be object`
if (!isString(segment.type)) return `screenplay.scenes[${i}].content[${j}].type must be string`
if (segment.type === 'action') {
if (!isString(segment.text)) return `screenplay action[${i}:${j}].text must be string`
} else if (segment.type === 'dialogue') {
if (!isString(segment.character)) return `screenplay dialogue[${i}:${j}].character must be string`
if (!isString(segment.lines)) return `screenplay dialogue[${i}:${j}].lines must be string`
if (segment.parenthetical !== undefined && !isString(segment.parenthetical)) {
return `screenplay dialogue[${i}:${j}].parenthetical must be string when present`
}
} else if (segment.type === 'voiceover') {
if (!isString(segment.text)) return `screenplay voiceover[${i}:${j}].text must be string`
if (segment.character !== undefined && !isString(segment.character)) {
return `screenplay voiceover[${i}:${j}].character must be string when present`
}
} else {
return `screenplay.scenes[${i}].content[${j}].type must be action/dialogue/voiceover`
}
}
}
return null
}
function validateStoryboardPanelsCanary(value) {
if (!Array.isArray(value) || value.length === 0) return 'storyboard panels fixture must be non-empty array'
for (let i = 0; i < value.length; i += 1) {
const panel = value[i]
if (!isRecord(panel)) return `storyboardPanels[${i}] must be object`
if (!isNumber(panel.panel_number)) return `storyboardPanels[${i}].panel_number must be number`
if (!isString(panel.description)) return `storyboardPanels[${i}].description must be string`
if (!isString(panel.location)) return `storyboardPanels[${i}].location must be string`
if (!isString(panel.scene_type)) return `storyboardPanels[${i}].scene_type must be string`
if (!isString(panel.source_text)) return `storyboardPanels[${i}].source_text must be string`
if (!isString(panel.shot_type)) return `storyboardPanels[${i}].shot_type must be string`
if (!isString(panel.camera_move)) return `storyboardPanels[${i}].camera_move must be string`
if (!isString(panel.video_prompt)) return `storyboardPanels[${i}].video_prompt must be string`
if (panel.duration !== undefined && !isNumber(panel.duration)) return `storyboardPanels[${i}].duration must be number when present`
if (!Array.isArray(panel.characters)) return `storyboardPanels[${i}].characters must be array`
for (let j = 0; j < panel.characters.length; j += 1) {
const character = panel.characters[j]
if (!isRecord(character)) return `storyboardPanels[${i}].characters[${j}] must be object`
if (!isString(character.name)) return `storyboardPanels[${i}].characters[${j}].name must be string`
if (character.appearance !== undefined && !isString(character.appearance)) {
return `storyboardPanels[${i}].characters[${j}].appearance must be string when present`
}
}
}
return null
}
function validateVoiceAnalysisCanary(value) {
if (!Array.isArray(value) || value.length === 0) return 'voice analysis fixture must be non-empty array'
for (let i = 0; i < value.length; i += 1) {
const row = value[i]
if (!isRecord(row)) return `voiceAnalysis[${i}] must be object`
if (!isNumber(row.lineIndex)) return `voiceAnalysis[${i}].lineIndex must be number`
if (!isString(row.speaker)) return `voiceAnalysis[${i}].speaker must be string`
if (!isString(row.content)) return `voiceAnalysis[${i}].content must be string`
if (!isNumber(row.emotionStrength)) return `voiceAnalysis[${i}].emotionStrength must be number`
if (row.matchedPanel !== null) {
if (!isRecord(row.matchedPanel)) return `voiceAnalysis[${i}].matchedPanel must be object or null`
if (!isString(row.matchedPanel.storyboardId)) return `voiceAnalysis[${i}].matchedPanel.storyboardId must be string`
if (!isNumber(row.matchedPanel.panelIndex)) return `voiceAnalysis[${i}].matchedPanel.panelIndex must be number`
}
}
return null
}
function checkTemplateTokens(pathStem, requiredTokens) {
const violations = []
for (const locale of ['zh', 'en']) {
const relPath = `lib/prompts/${pathStem}.${locale}.txt`
const fullPath = path.join(root, relPath)
if (!fs.existsSync(fullPath)) {
violations.push(`missing template: ${relPath}`)
continue
}
const content = fs.readFileSync(fullPath, 'utf8')
for (const token of requiredTokens) {
if (!content.includes(token)) {
violations.push(`missing token ${token} in ${relPath}`)
}
}
}
return violations
}
const violations = []
const clipsErr = validateClipCanary(readJson(CANARY_FILES.clips))
if (clipsErr) violations.push(clipsErr)
const screenplayErr = validateScreenplayCanary(readJson(CANARY_FILES.screenplay))
if (screenplayErr) violations.push(screenplayErr)
const panelsErr = validateStoryboardPanelsCanary(readJson(CANARY_FILES.storyboardPanels))
if (panelsErr) violations.push(panelsErr)
const voiceErr = validateVoiceAnalysisCanary(readJson(CANARY_FILES.voiceAnalysis))
if (voiceErr) violations.push(voiceErr)
for (const [pathStem, requiredTokens] of Object.entries(TEMPLATE_TOKEN_REQUIREMENTS)) {
violations.push(...checkTemplateTokens(pathStem, requiredTokens))
}
if (violations.length > 0) {
fail('JSON schema canary check failed', violations)
}
console.log('[prompt-json-canary-guard] OK')