generate.ts
3,059 bytes
| 1 | import { mkdir, rename, stat, writeFile } from 'node:fs/promises' |
|---|---|
| 2 | import path from 'node:path' |
| 3 | import type { Session } from '../../shared/types' |
| 4 | import { SPEC_PACK_FILES, type InterviewLlm } from '../providers/types' |
| 5 | import { buildGeneratePrompt } from './prompts' |
| 6 | import { validateProvenance } from './provenance' |
| 7 | |
| 8 | const SOURCES_FILE = 'sources.json' |
| 9 | |
| 10 | export interface GenerateOptions { |
| 11 | overwrite?: boolean |
| 12 | } |
| 13 | |
| 14 | export interface GenerateResult { |
| 15 | files: string[] |
| 16 | warnings: string[] |
| 17 | } |
| 18 | |
| 19 | export class GenerateFilesExistError extends Error { |
| 20 | readonly existing: string[] |
| 21 | |
| 22 | constructor(existing: string[]) { |
| 23 | super(`spec pack files already exist: ${existing.join(', ')}`) |
| 24 | this.existing = existing |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | async function pathExists(target: string): Promise<boolean> { |
| 29 | try { |
| 30 | await stat(target) |
| 31 | return true |
| 32 | } catch { |
| 33 | return false |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | async function backupExistingFiles(specDir: string, existing: string[]): Promise<void> { |
| 38 | const timestamp = new Date().toISOString().replace(/[:.]/g, '-') |
| 39 | const backupDir = path.join(specDir, `backup-${timestamp}`) |
| 40 | await mkdir(backupDir, { recursive: true }) |
| 41 | for (const file of existing) { |
| 42 | await rename(path.join(specDir, file), path.join(backupDir, file)) |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | export async function generateSpecPack( |
| 47 | llm: InterviewLlm, |
| 48 | session: Session, |
| 49 | options: GenerateOptions = {}, |
| 50 | ): Promise<GenerateResult> { |
| 51 | const targetStat = await stat(session.targetDir).catch(() => null) |
| 52 | if (!targetStat || !targetStat.isDirectory()) { |
| 53 | throw new Error(`target directory does not exist: ${session.targetDir}`) |
| 54 | } |
| 55 | |
| 56 | const specDir = path.join(session.targetDir, 'spec') |
| 57 | await mkdir(specDir, { recursive: true }) |
| 58 | |
| 59 | const candidateFiles: string[] = [...SPEC_PACK_FILES, SOURCES_FILE] |
| 60 | const existing: string[] = [] |
| 61 | for (const file of candidateFiles) { |
| 62 | if (await pathExists(path.join(specDir, file))) existing.push(file) |
| 63 | } |
| 64 | |
| 65 | if (existing.length > 0) { |
| 66 | if (!options.overwrite) throw new GenerateFilesExistError(existing) |
| 67 | await backupExistingFiles(specDir, existing) |
| 68 | } |
| 69 | |
| 70 | const validSegmentIds = new Set(session.segments.map((segment) => segment.id)) |
| 71 | const warnings: string[] = [] |
| 72 | const writtenFiles: string[] = [] |
| 73 | |
| 74 | for (const file of SPEC_PACK_FILES) { |
| 75 | const prompt = buildGeneratePrompt(file, session.name, session.segments) |
| 76 | const rawContent = await llm.generateFile({ file, prompt, segments: session.segments }) |
| 77 | const { content, invalidMarkerCount } = validateProvenance(rawContent, validSegmentIds) |
| 78 | if (invalidMarkerCount > 0) { |
| 79 | warnings.push(`${file}: removed ${invalidMarkerCount} unverified provenance marker(s)`) |
| 80 | } |
| 81 | await writeFile(path.join(specDir, file), content, 'utf8') |
| 82 | writtenFiles.push(`spec/${file}`) |
| 83 | } |
| 84 | |
| 85 | const sources = Object.fromEntries( |
| 86 | session.segments.map((segment) => [segment.id, { text: segment.text, ts: segment.ts }]), |
| 87 | ) |
| 88 | await writeFile(path.join(specDir, SOURCES_FILE), JSON.stringify(sources, null, 2), 'utf8') |
| 89 | writtenFiles.push(`spec/${SOURCES_FILE}`) |
| 90 | |
| 91 | return { files: writtenFiles, warnings } |
| 92 | } |
| 93 | |