diff --git a/migrations/0003_unified_optimization_cases.sql b/migrations/0003_unified_optimization_cases.sql new file mode 100644 index 0000000..1d7f432 --- /dev/null +++ b/migrations/0003_unified_optimization_cases.sql @@ -0,0 +1,106 @@ +CREATE TABLE IF NOT EXISTS optimization_cases ( + id TEXT PRIMARY KEY, + case_type TEXT NOT NULL, + title TEXT NOT NULL, + summary TEXT NOT NULL, + status TEXT NOT NULL, + customer_name TEXT NOT NULL DEFAULT '', + brand_name TEXT NOT NULL DEFAULT '', + project_tags TEXT NOT NULL DEFAULT '[]', + notes TEXT NOT NULL DEFAULT '', + publish_target TEXT NOT NULL DEFAULT '', + source_excerpt TEXT NOT NULL DEFAULT '', + result_excerpt TEXT NOT NULL DEFAULT '', + latest_result_version_id TEXT, + latest_version_number INTEGER, + last_error_stage TEXT, + last_error_summary TEXT, + archived_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS case_inputs ( + case_id TEXT PRIMARY KEY, + case_type TEXT NOT NULL, + article_job_id TEXT, + payload TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (case_id) REFERENCES optimization_cases(id) ON DELETE CASCADE, + FOREIGN KEY (article_job_id) REFERENCES article_jobs(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS optimization_result_versions ( + id TEXT PRIMARY KEY, + case_id TEXT NOT NULL, + case_type TEXT NOT NULL, + version INTEGER NOT NULL, + status TEXT NOT NULL, + article_job_id TEXT, + article_revision INTEGER, + result_summary TEXT NOT NULL, + payload TEXT NOT NULL, + process_summary TEXT NOT NULL, + llm_audit_summary TEXT NOT NULL, + error_stage TEXT, + error_summary TEXT, + created_at TEXT NOT NULL, + UNIQUE (case_id, version), + FOREIGN KEY (case_id) REFERENCES optimization_cases(id) ON DELETE CASCADE, + FOREIGN KEY (article_job_id) REFERENCES article_jobs(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_optimization_cases_updated_at + ON optimization_cases(updated_at); + +CREATE INDEX IF NOT EXISTS idx_optimization_cases_case_type + ON optimization_cases(case_type); + +CREATE INDEX IF NOT EXISTS idx_optimization_cases_status + ON optimization_cases(status); + +CREATE INDEX IF NOT EXISTS idx_optimization_result_versions_case + ON optimization_result_versions(case_id, version DESC); + +ALTER TABLE article_jobs ADD COLUMN case_id TEXT; + +CREATE TABLE IF NOT EXISTS publication_records_next ( + id TEXT PRIMARY KEY, + result_version_id TEXT, + job_id TEXT, + revision INTEGER, + publish_target TEXT NOT NULL, + url TEXT NOT NULL, + published_at TEXT NOT NULL, + status TEXT NOT NULL, + notes TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (result_version_id) + REFERENCES optimization_result_versions(id) ON DELETE CASCADE +); + +INSERT INTO publication_records_next ( + id, result_version_id, job_id, revision, publish_target, url, + published_at, status, notes, created_at, updated_at +) +SELECT + id, NULL, job_id, revision, platform, url, + published_at, status, notes, created_at, updated_at +FROM publication_records; + +DROP TABLE publication_records; +ALTER TABLE publication_records_next RENAME TO publication_records; + +CREATE INDEX IF NOT EXISTS idx_publication_records_result_version + ON publication_records(result_version_id); + +CREATE INDEX IF NOT EXISTS idx_publication_records_job_revision + ON publication_records(job_id, revision); + +ALTER TABLE scoring_runs ADD COLUMN result_version_id TEXT; +ALTER TABLE scoring_runs ADD COLUMN case_type TEXT NOT NULL DEFAULT 'article'; + +CREATE INDEX IF NOT EXISTS idx_scoring_runs_result_version + ON scoring_runs(result_version_id); diff --git a/src/lib/calibration/types.ts b/src/lib/calibration/types.ts index 62db10f..9339454 100644 --- a/src/lib/calibration/types.ts +++ b/src/lib/calibration/types.ts @@ -1,4 +1,5 @@ import type { PublishPlatform, QaReport } from "../domain/types"; +import type { OptimizationCaseType } from "../cases/types"; export type CalibrationDirection = | "better_than_expected" @@ -27,8 +28,10 @@ export interface RubricVersion { export interface ScoringRun { id: string; - job_id: string; - revision: number; + result_version_id?: string | null; + case_type?: OptimizationCaseType; + job_id: string | null; + revision: number | null; rubric_version_id: string; dimension_scores: Record; composite_score: number; @@ -38,9 +41,11 @@ export interface ScoringRun { export interface PublicationRecord { id: string; - job_id: string; - revision: number; - platform: PublishPlatform; + result_version_id: string | null; + job_id: string | null; + revision: number | null; + publish_target: string; + platform?: PublishPlatform; url: string; published_at: string; status: "draft" | "published" | "archived"; diff --git a/src/lib/calibration/validation.ts b/src/lib/calibration/validation.ts index c948def..277a1fe 100644 --- a/src/lib/calibration/validation.ts +++ b/src/lib/calibration/validation.ts @@ -44,11 +44,18 @@ export const performanceMetricsSchema = z ); export const publicationInputSchema = z.object({ - platform: publishPlatformSchema, + platform: publishPlatformSchema.optional(), + publish_target: z.string().trim().min(1).optional(), url: z.string().trim().url(), published_at: z.string().datetime(), notes: optionalTextSchema.default(""), -}); +}).transform((input) => ({ + platform: input.platform, + publish_target: input.publish_target ?? input.platform ?? "未指定", + url: input.url, + published_at: input.published_at, + notes: input.notes, +})); export const manualPerformanceInputSchema = z .object({ @@ -100,8 +107,10 @@ export const rubricVersionSchema = z.object({ export const scoringRunSchema = z.object({ id: z.string().trim().min(1), - job_id: z.string().trim().min(1), - revision: z.number().int().positive(), + result_version_id: z.string().trim().min(1).nullable().optional(), + case_type: z.enum(["article", "human_copy"]).optional(), + job_id: z.string().trim().min(1).nullable(), + revision: z.number().int().positive().nullable(), rubric_version_id: z.string().trim().min(1), dimension_scores: z.record(z.string(), z.number().min(0).max(5)), composite_score: z.number().min(0).max(10), @@ -111,9 +120,11 @@ export const scoringRunSchema = z.object({ export const publicationRecordSchema = z.object({ id: z.string().trim().min(1), - job_id: z.string().trim().min(1), - revision: z.number().int().positive(), - platform: publishPlatformSchema, + result_version_id: z.string().trim().min(1).nullable(), + job_id: z.string().trim().min(1).nullable(), + revision: z.number().int().positive().nullable(), + publish_target: z.string().trim().min(1), + platform: publishPlatformSchema.optional(), url: z.string().trim().url(), published_at: z.string().datetime(), status: z.enum(["draft", "published", "archived"]), diff --git a/src/lib/db/__tests__/d1-repository.test.ts b/src/lib/db/__tests__/d1-repository.test.ts index 41c0a63..3cc9c6a 100644 --- a/src/lib/db/__tests__/d1-repository.test.ts +++ b/src/lib/db/__tests__/d1-repository.test.ts @@ -24,6 +24,7 @@ describe("createD1Repository", () => { expect(bind).toHaveBeenCalledWith( job.id, null, + null, "Title", "Body", "[]", @@ -40,6 +41,7 @@ describe("createD1Repository", () => { const first = vi.fn().mockResolvedValue({ id: "job_123", brand_template_id: null, + case_id: null, source_title: "Title", source_body: "Body", image_inputs: "[]", diff --git a/src/lib/db/__tests__/repositories.test.ts b/src/lib/db/__tests__/repositories.test.ts index 8c0d6b4..02c34bc 100644 --- a/src/lib/db/__tests__/repositories.test.ts +++ b/src/lib/db/__tests__/repositories.test.ts @@ -50,7 +50,10 @@ describe("sqlite repositories", () => { "article_jobs", "brand_templates", "calibration_events", + "case_inputs", "fact_cards", + "optimization_cases", + "optimization_result_versions", "optimized_articles", "performance_snapshots", "publication_records", @@ -60,6 +63,23 @@ describe("sqlite repositories", () => { ]); }); + it("adds case references to article jobs and result-version publications", () => { + const db = createDatabase(dbPath); + const articleJobColumns = db + .prepare("pragma table_info(article_jobs)") + .all() + .map((row) => (row as { name: string }).name); + const publicationColumns = db + .prepare("pragma table_info(publication_records)") + .all() + .map((row) => (row as { name: string }).name); + db.close(); + + expect(articleJobColumns).toContain("case_id"); + expect(publicationColumns).toContain("result_version_id"); + expect(publicationColumns).toContain("publish_target"); + }); + it("inserts and fetches a brand template", () => { const created = createBrandTemplate(dbPath, { brand_name: "Example", diff --git a/src/lib/db/__tests__/repository.test.ts b/src/lib/db/__tests__/repository.test.ts index de25532..3a03dc8 100644 --- a/src/lib/db/__tests__/repository.test.ts +++ b/src/lib/db/__tests__/repository.test.ts @@ -111,4 +111,122 @@ describe("createSqliteRepository", () => { ]); expect(event.observations).toEqual(["表现高于预期"]); }); + + test("creates, lists, updates, archives, and restores optimization cases", async () => { + const repository = createSqliteRepository(dbPath); + + const created = await repository.createOptimizationCase({ + case_type: "human_copy", + title: "人味文案优化:朋友圈", + summary: "原文摘要", + publish_target: "朋友圈", + source_excerpt: "原文摘要", + }); + + await repository.saveCaseInput({ + case_id: created.id, + case_type: "human_copy", + article_job_id: null, + payload: { + source_text: "原文摘要", + goal: "自然一点", + intensity: "light", + user_instructions: "", + publish_target: "朋友圈", + }, + }); + + await expect( + repository.listOptimizationCases({ include_archived: false }), + ).resolves.toEqual([expect.objectContaining({ id: created.id })]); + + await expect( + repository.updateOptimizationCaseMetadata(created.id, { + customer_name: "客户A", + brand_name: "品牌B", + project_tags: ["朋友圈"], + notes: "保留口语。", + }), + ).resolves.toMatchObject({ + customer_name: "客户A", + project_tags: ["朋友圈"], + }); + + await repository.archiveOptimizationCase(created.id); + await expect( + repository.listOptimizationCases({ include_archived: false }), + ).resolves.toHaveLength(0); + + await repository.restoreOptimizationCase(created.id); + await expect(repository.getOptimizationCaseDetail(created.id)).resolves.toMatchObject({ + case: { id: created.id, status: "running" }, + input: expect.objectContaining({ case_id: created.id }), + }); + }); + + test("creates multiple result versions and binds publication to a version", async () => { + const repository = createSqliteRepository(dbPath); + const optimizationCase = await repository.createOptimizationCase({ + case_type: "human_copy", + title: "人味文案优化:私域", + summary: "原文", + publish_target: "私域", + source_excerpt: "原文", + }); + + const first = await repository.createOptimizationResultVersion({ + case_id: optimizationCase.id, + case_type: "human_copy", + status: "optimized", + article_job_id: null, + article_revision: null, + result_summary: "第一版", + payload: { + optimized_text: "第一版文案", + change_notes: [], + ai_taste_checks: [], + warnings: [], + }, + process_summary: [], + llm_audit_summary: [], + error_stage: null, + error_summary: null, + }); + const second = await repository.createOptimizationResultVersion({ + case_id: optimizationCase.id, + case_type: "human_copy", + status: "optimized", + article_job_id: null, + article_revision: null, + result_summary: "第二版", + payload: { + optimized_text: "第二版文案", + change_notes: [], + ai_taste_checks: [], + warnings: [], + }, + process_summary: [], + llm_audit_summary: [], + error_stage: null, + error_summary: null, + }); + + expect(first.version).toBe(1); + expect(second.version).toBe(2); + + const publication = await repository.createPublicationRecord({ + result_version_id: second.id, + job_id: null, + revision: null, + publish_target: "私域", + url: "https://example.com/private", + published_at: "2026-07-08T12:00:00.000Z", + status: "published", + notes: "客户私域发布", + }); + + await expect( + repository.listPublicationRecordsForResultVersion(second.id), + ).resolves.toEqual([expect.objectContaining({ id: publication.id })]); + }); }); diff --git a/src/lib/db/d1-repository.ts b/src/lib/db/d1-repository.ts index 928565d..7651ed5 100644 --- a/src/lib/db/d1-repository.ts +++ b/src/lib/db/d1-repository.ts @@ -5,6 +5,16 @@ import type { PublicationRecord, ScoringRun, } from "../calibration/types"; +import type { + CaseInput, + CaseListFilters, + CaseMetadataPatch, + OptimizationCase, + OptimizationCaseStatus, + OptimizationCaseType, + OptimizationResultVersion, + ResultVersionStatus, +} from "../cases/types"; import type { OptimizationFactCard, ImageInput, @@ -35,6 +45,7 @@ interface BrandTemplateRow { interface ArticleJobRow { id: string; brand_template_id: string | null; + case_id: string | null; source_title: string; source_body: string; image_inputs: string; @@ -65,8 +76,10 @@ interface QaReportRow { interface ScoringRunRow { id: string; - job_id: string; - revision: number; + result_version_id: string | null; + case_type: OptimizationCaseType; + job_id: string | null; + revision: number | null; rubric_version_id: string; dimension_scores: string; composite_score: number; @@ -76,9 +89,10 @@ interface ScoringRunRow { interface PublicationRecordRow { id: string; - job_id: string; - revision: number; - platform: PublishPlatform; + result_version_id: string | null; + job_id: string | null; + revision: number | null; + publish_target: string; url: string; published_at: string; status: "draft" | "published" | "archived"; @@ -98,6 +112,54 @@ interface PerformanceSnapshotRow { snapshot_at: string; } +interface OptimizationCaseRow { + id: string; + case_type: OptimizationCaseType; + title: string; + summary: string; + status: OptimizationCaseStatus; + customer_name: string; + brand_name: string; + project_tags: string; + notes: string; + publish_target: string; + source_excerpt: string; + result_excerpt: string; + latest_result_version_id: string | null; + latest_version_number: number | null; + last_error_stage: string | null; + last_error_summary: string | null; + archived_at: string | null; + created_at: string; + updated_at: string; +} + +interface CaseInputRow { + case_id: string; + case_type: OptimizationCaseType; + article_job_id: string | null; + payload: string; + created_at: string; + updated_at: string; +} + +interface OptimizationResultVersionRow { + id: string; + case_id: string; + case_type: OptimizationCaseType; + version: number; + status: ResultVersionStatus; + article_job_id: string | null; + article_revision: number | null; + result_summary: string; + payload: string; + process_summary: string; + llm_audit_summary: string; + error_stage: string | null; + error_summary: string | null; + created_at: string; +} + function nowIso() { return new Date().toISOString(); } @@ -138,6 +200,15 @@ function toScoringRun(row: ScoringRunRow): ScoringRun { }; } +function toPublicationRecord(row: PublicationRecordRow): PublicationRecord { + return { + ...row, + platform: isPublishPlatform(row.publish_target) + ? row.publish_target + : undefined, + }; +} + function toPerformanceSnapshot(row: PerformanceSnapshotRow): PerformanceSnapshot { return { ...row, @@ -146,6 +217,86 @@ function toPerformanceSnapshot(row: PerformanceSnapshotRow): PerformanceSnapshot }; } +function toOptimizationCase(row: OptimizationCaseRow): OptimizationCase { + return { + ...row, + project_tags: parseJson(row.project_tags), + }; +} + +function toCaseInput(row: CaseInputRow): CaseInput { + return { + ...row, + payload: parseJson(row.payload), + }; +} + +function toOptimizationResultVersion( + row: OptimizationResultVersionRow, +): OptimizationResultVersion { + return { + ...row, + payload: row.payload + ? parseJson(row.payload) + : null, + process_summary: parseJson( + row.process_summary, + ), + llm_audit_summary: parseJson( + row.llm_audit_summary, + ), + }; +} + +function isPublishPlatform(value: string): value is PublishPlatform { + return [ + "official_site", + "media_article", + "comparison_review", + "recommendation_list", + ].includes(value); +} + +async function updateD1CaseArchiveState( + db: D1Database, + caseId: string, + archived: boolean, +) { + const existing = await db + .prepare("select * from optimization_cases where id = ?") + .bind(caseId) + .first(); + if (!existing) return null; + + const timestamp = nowIso(); + const restoredStatus = existing.latest_result_version_id + ? "optimized" + : existing.last_error_summary + ? "failed" + : "running"; + await db + .prepare( + `update optimization_cases set + status = ?, + archived_at = ?, + updated_at = ? + where id = ?`, + ) + .bind( + archived ? "archived" : restoredStatus, + archived ? timestamp : null, + timestamp, + caseId, + ) + .run(); + + const row = await db + .prepare("select * from optimization_cases where id = ?") + .bind(caseId) + .first(); + return row ? toOptimizationCase(row) : null; +} + export function createD1Repository(db: D1Database): AppRepository { return { async createBrandTemplate(input) { @@ -201,6 +352,7 @@ export function createD1Repository(db: D1Database): AppRepository { const job: ArticleJob = { id: `job_${nanoid(10)}`, brand_template_id: input.brand_template_id ?? null, + case_id: input.case_id ?? null, source_title: input.source_title, source_body: input.source_body, image_inputs: input.image_inputs, @@ -215,13 +367,14 @@ export function createD1Repository(db: D1Database): AppRepository { await db .prepare( `insert into article_jobs ( - id, brand_template_id, source_title, source_body, image_inputs, + id, brand_template_id, case_id, source_title, source_body, image_inputs, publish_platform, user_instructions, status, export_paths, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( job.id, job.brand_template_id, + job.case_id, job.source_title, job.source_body, serialize(job.image_inputs), @@ -356,6 +509,302 @@ export function createD1Repository(db: D1Database): AppRepository { .first(); return row ? parseJson(row.report) : null; }, + async createOptimizationCase(input) { + const timestamp = nowIso(); + const optimizationCase: OptimizationCase = { + id: `case_${nanoid(10)}`, + case_type: input.case_type, + title: input.title, + summary: input.summary, + status: "running", + customer_name: "", + brand_name: "", + project_tags: [], + notes: "", + publish_target: input.publish_target, + source_excerpt: input.source_excerpt, + result_excerpt: "", + latest_result_version_id: null, + latest_version_number: null, + last_error_stage: null, + last_error_summary: null, + archived_at: null, + created_at: timestamp, + updated_at: timestamp, + }; + await db + .prepare( + `insert into optimization_cases ( + id, case_type, title, summary, status, customer_name, brand_name, + project_tags, notes, publish_target, source_excerpt, result_excerpt, + latest_result_version_id, latest_version_number, last_error_stage, + last_error_summary, archived_at, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + optimizationCase.id, + optimizationCase.case_type, + optimizationCase.title, + optimizationCase.summary, + optimizationCase.status, + optimizationCase.customer_name, + optimizationCase.brand_name, + serialize(optimizationCase.project_tags), + optimizationCase.notes, + optimizationCase.publish_target, + optimizationCase.source_excerpt, + optimizationCase.result_excerpt, + optimizationCase.latest_result_version_id, + optimizationCase.latest_version_number, + optimizationCase.last_error_stage, + optimizationCase.last_error_summary, + optimizationCase.archived_at, + optimizationCase.created_at, + optimizationCase.updated_at, + ) + .run(); + return optimizationCase; + }, + async saveCaseInput(input) { + const timestamp = nowIso(); + const saved: CaseInput = { + ...input, + created_at: timestamp, + updated_at: timestamp, + }; + await db + .prepare( + `insert into case_inputs ( + case_id, case_type, article_job_id, payload, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?) + on conflict(case_id) do update set + case_type = excluded.case_type, + article_job_id = excluded.article_job_id, + payload = excluded.payload, + updated_at = excluded.updated_at`, + ) + .bind( + saved.case_id, + saved.case_type, + saved.article_job_id, + serialize(saved.payload), + saved.created_at, + saved.updated_at, + ) + .run(); + return saved; + }, + async listOptimizationCases(filters) { + const where: string[] = []; + const values: unknown[] = []; + if (!filters.include_archived) where.push("archived_at is null"); + if (filters.case_type) { + where.push("case_type = ?"); + values.push(filters.case_type); + } + if (filters.status) { + where.push("status = ?"); + values.push(filters.status); + } + if (filters.publish_target) { + where.push("publish_target = ?"); + values.push(filters.publish_target); + } + if (filters.project_tag) { + where.push("project_tags like ?"); + values.push(`%"${filters.project_tag}"%`); + } + if (filters.created_from) { + where.push("created_at >= ?"); + values.push(filters.created_from); + } + if (filters.created_to) { + where.push("created_at <= ?"); + values.push(filters.created_to); + } + if (filters.q) { + where.push( + "(title like ? or summary like ? or source_excerpt like ? or result_excerpt like ? or customer_name like ? or brand_name like ? or notes like ?)", + ); + const keyword = `%${filters.q}%`; + values.push(keyword, keyword, keyword, keyword, keyword, keyword, keyword); + } + const clause = where.length > 0 ? `where ${where.join(" and ")}` : ""; + const prepared = db.prepare( + `select * from optimization_cases ${clause} order by updated_at desc`, + ); + const result = + values.length > 0 + ? await prepared.bind(...values).all() + : await prepared.all(); + return result.results.map(toOptimizationCase); + }, + async getOptimizationCaseDetail(caseId) { + const caseRow = await db + .prepare("select * from optimization_cases where id = ?") + .bind(caseId) + .first(); + if (!caseRow) return null; + const inputRow = await db + .prepare("select * from case_inputs where case_id = ?") + .bind(caseId) + .first(); + const versionRows = await db + .prepare( + `select * from optimization_result_versions + where case_id = ? + order by version desc`, + ) + .bind(caseId) + .all(); + return { + case: toOptimizationCase(caseRow), + input: inputRow ? toCaseInput(inputRow) : null, + versions: versionRows.results.map(toOptimizationResultVersion), + }; + }, + async updateOptimizationCaseMetadata(caseId, changes) { + const existing = await db + .prepare("select * from optimization_cases where id = ?") + .bind(caseId) + .first(); + if (!existing) return null; + await db + .prepare( + `update optimization_cases set + title = ?, + customer_name = ?, + brand_name = ?, + project_tags = ?, + notes = ?, + updated_at = ? + where id = ?`, + ) + .bind( + changes.title ?? existing.title, + changes.customer_name ?? existing.customer_name, + changes.brand_name ?? existing.brand_name, + changes.project_tags === undefined + ? existing.project_tags + : serialize(changes.project_tags), + changes.notes ?? existing.notes, + nowIso(), + caseId, + ) + .run(); + const row = await db + .prepare("select * from optimization_cases where id = ?") + .bind(caseId) + .first(); + return row ? toOptimizationCase(row) : null; + }, + async archiveOptimizationCase(caseId) { + return updateD1CaseArchiveState(db, caseId, true); + }, + async restoreOptimizationCase(caseId) { + return updateD1CaseArchiveState(db, caseId, false); + }, + async markOptimizationCaseFailed(caseId, input) { + await db + .prepare( + `update optimization_cases set + status = 'failed', + last_error_stage = ?, + last_error_summary = ?, + updated_at = ? + where id = ?`, + ) + .bind(input.error_stage, input.error_summary, nowIso(), caseId) + .run(); + const row = await db + .prepare("select * from optimization_cases where id = ?") + .bind(caseId) + .first(); + return row ? toOptimizationCase(row) : null; + }, + async createOptimizationResultVersion(input) { + const latest = await db + .prepare( + "select max(version) as version from optimization_result_versions where case_id = ?", + ) + .bind(input.case_id) + .first<{ version: number | null }>(); + const createdAt = nowIso(); + const resultVersion: OptimizationResultVersion = { + id: `ver_${nanoid(10)}`, + ...input, + version: (latest?.version ?? 0) + 1, + created_at: createdAt, + }; + await db + .prepare( + `insert into optimization_result_versions ( + id, case_id, case_type, version, status, article_job_id, article_revision, + result_summary, payload, process_summary, llm_audit_summary, + error_stage, error_summary, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + resultVersion.id, + resultVersion.case_id, + resultVersion.case_type, + resultVersion.version, + resultVersion.status, + resultVersion.article_job_id, + resultVersion.article_revision, + resultVersion.result_summary, + resultVersion.payload ? serialize(resultVersion.payload) : "", + serialize(resultVersion.process_summary), + serialize(resultVersion.llm_audit_summary), + resultVersion.error_stage, + resultVersion.error_summary, + resultVersion.created_at, + ) + .run(); + await db + .prepare( + `update optimization_cases set + status = ?, + result_excerpt = ?, + latest_result_version_id = ?, + latest_version_number = ?, + last_error_stage = ?, + last_error_summary = ?, + updated_at = ? + where id = ?`, + ) + .bind( + input.status === "optimized" ? "optimized" : "failed", + input.result_summary, + resultVersion.id, + resultVersion.version, + input.error_stage, + input.error_summary, + createdAt, + input.case_id, + ) + .run(); + return resultVersion; + }, + async getOptimizationResultVersion(versionId) { + const row = await db + .prepare("select * from optimization_result_versions where id = ?") + .bind(versionId) + .first(); + return row ? toOptimizationResultVersion(row) : null; + }, + async findResultVersionForArticleRevision(jobId, revision) { + const row = await db + .prepare( + `select * from optimization_result_versions + where article_job_id = ? and article_revision = ? + order by version desc + limit 1`, + ) + .bind(jobId, revision) + .first(); + return row ? toOptimizationResultVersion(row) : null; + }, async saveRubricVersion(rubric) { await db .prepare( @@ -385,12 +834,14 @@ export function createD1Repository(db: D1Database): AppRepository { await db .prepare( `insert into scoring_runs ( - id, job_id, revision, rubric_version_id, dimension_scores, + id, result_version_id, case_type, job_id, revision, rubric_version_id, dimension_scores, composite_score, rationale, created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?)`, + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( run.id, + run.result_version_id ?? null, + run.case_type ?? "article", run.job_id, run.revision, run.rubric_version_id, @@ -414,26 +865,43 @@ export function createD1Repository(db: D1Database): AppRepository { .first(); return row ? toScoringRun(row) : null; }, + async getLatestScoringRunForResultVersion(resultVersionId) { + const row = await db + .prepare( + `select * from scoring_runs + where result_version_id = ? + order by created_at desc + limit 1`, + ) + .bind(resultVersionId) + .first(); + return row ? toScoringRun(row) : null; + }, async createPublicationRecord(input) { const timestamp = nowIso(); + const publishTarget = input.publish_target ?? input.platform ?? "未指定"; const record: PublicationRecord = { id: `pub_${nanoid(10)}`, ...input, + result_version_id: input.result_version_id ?? null, + publish_target: publishTarget, + platform: isPublishPlatform(publishTarget) ? publishTarget : input.platform, created_at: timestamp, updated_at: timestamp, }; await db .prepare( `insert into publication_records ( - id, job_id, revision, platform, url, published_at, status, + id, result_version_id, job_id, revision, publish_target, url, published_at, status, notes, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( record.id, + record.result_version_id, record.job_id, record.revision, - record.platform, + record.publish_target, record.url, record.published_at, record.status, @@ -451,13 +919,23 @@ export function createD1Repository(db: D1Database): AppRepository { ) .bind(jobId) .all(); - return result.results; + return result.results.map(toPublicationRecord); + }, + async listPublicationRecordsForResultVersion(resultVersionId) { + const result = await db + .prepare( + "select * from publication_records where result_version_id = ? order by published_at desc", + ) + .bind(resultVersionId) + .all(); + return result.results.map(toPublicationRecord); }, async getPublicationRecord(id) { - return db + const row = await db .prepare("select * from publication_records where id = ?") .bind(id) .first(); + return row ? toPublicationRecord(row) : null; }, async savePerformanceSnapshot(snapshot) { await db diff --git a/src/lib/db/repositories.ts b/src/lib/db/repositories.ts index 6a1c769..7fa5cb7 100644 --- a/src/lib/db/repositories.ts +++ b/src/lib/db/repositories.ts @@ -7,6 +7,16 @@ import type { RubricVersion, ScoringRun, } from "../calibration/types"; +import type { + CaseInput, + CaseListFilters, + CaseMetadataPatch, + OptimizationCase, + OptimizationCaseStatus, + OptimizationCaseType, + OptimizationResultVersion, + ResultVersionStatus, +} from "../cases/types"; import type { OptimizationFactCard, ImageInput, @@ -43,6 +53,7 @@ export type NewBrandTemplate = Omit< export interface ArticleJob { id: string; brand_template_id: string | null; + case_id: string | null; source_title: string; source_body: string; image_inputs: ImageInput[]; @@ -56,6 +67,7 @@ export interface ArticleJob { export interface NewArticleJob { brand_template_id?: string | null; + case_id?: string | null; source_title: string; source_body: string; image_inputs: ImageInput[]; @@ -81,6 +93,7 @@ interface BrandTemplateRow { interface ArticleJobRow { id: string; brand_template_id: string | null; + case_id: string | null; source_title: string; source_body: string; image_inputs: string; @@ -111,8 +124,10 @@ interface QaReportRow { interface ScoringRunRow { id: string; - job_id: string; - revision: number; + result_version_id: string | null; + case_type: OptimizationCaseType; + job_id: string | null; + revision: number | null; rubric_version_id: string; dimension_scores: string; composite_score: number; @@ -122,9 +137,10 @@ interface ScoringRunRow { interface PublicationRecordRow { id: string; - job_id: string; - revision: number; - platform: PublishPlatform; + result_version_id: string | null; + job_id: string | null; + revision: number | null; + publish_target: string; url: string; published_at: string; status: "draft" | "published" | "archived"; @@ -144,6 +160,54 @@ interface PerformanceSnapshotRow { snapshot_at: string; } +interface OptimizationCaseRow { + id: string; + case_type: OptimizationCaseType; + title: string; + summary: string; + status: OptimizationCaseStatus; + customer_name: string; + brand_name: string; + project_tags: string; + notes: string; + publish_target: string; + source_excerpt: string; + result_excerpt: string; + latest_result_version_id: string | null; + latest_version_number: number | null; + last_error_stage: string | null; + last_error_summary: string | null; + archived_at: string | null; + created_at: string; + updated_at: string; +} + +interface CaseInputRow { + case_id: string; + case_type: OptimizationCaseType; + article_job_id: string | null; + payload: string; + created_at: string; + updated_at: string; +} + +interface OptimizationResultVersionRow { + id: string; + case_id: string; + case_type: OptimizationCaseType; + version: number; + status: ResultVersionStatus; + article_job_id: string | null; + article_revision: number | null; + result_summary: string; + payload: string; + process_summary: string; + llm_audit_summary: string; + error_stage: string | null; + error_summary: string | null; + created_at: string; +} + function nowIso() { return new Date().toISOString(); } @@ -194,6 +258,15 @@ function toScoringRun(row: ScoringRunRow): ScoringRun { }; } +function toPublicationRecord(row: PublicationRecordRow): PublicationRecord { + return { + ...row, + platform: isPublishPlatform(row.publish_target) + ? row.publish_target + : undefined, + }; +} + function toPerformanceSnapshot(row: PerformanceSnapshotRow): PerformanceSnapshot { return { ...row, @@ -202,6 +275,46 @@ function toPerformanceSnapshot(row: PerformanceSnapshotRow): PerformanceSnapshot }; } +function toOptimizationCase(row: OptimizationCaseRow): OptimizationCase { + return { + ...row, + project_tags: parseJson(row.project_tags), + }; +} + +function toCaseInput(row: CaseInputRow): CaseInput { + return { + ...row, + payload: parseJson(row.payload), + }; +} + +function toOptimizationResultVersion( + row: OptimizationResultVersionRow, +): OptimizationResultVersion { + return { + ...row, + payload: row.payload + ? parseJson(row.payload) + : null, + process_summary: parseJson( + row.process_summary, + ), + llm_audit_summary: parseJson( + row.llm_audit_summary, + ), + }; +} + +function isPublishPlatform(value: string): value is PublishPlatform { + return [ + "official_site", + "media_article", + "comparison_review", + "recommendation_list", + ].includes(value); +} + export function createBrandTemplate( dbPath: string | undefined, input: NewBrandTemplate, @@ -264,6 +377,7 @@ export function createArticleJob(dbPath: string | undefined, input: NewArticleJo const job: ArticleJob = { id: `job_${nanoid(10)}`, brand_template_id: input.brand_template_id ?? null, + case_id: input.case_id ?? null, source_title: input.source_title, source_body: input.source_body, image_inputs: input.image_inputs, @@ -277,10 +391,10 @@ export function createArticleJob(dbPath: string | undefined, input: NewArticleJo db.prepare( `insert into article_jobs ( - id, brand_template_id, source_title, source_body, image_inputs, + id, brand_template_id, case_id, source_title, source_body, image_inputs, publish_platform, user_instructions, status, export_paths, created_at, updated_at ) values ( - @id, @brand_template_id, @source_title, @source_body, @image_inputs, + @id, @brand_template_id, @case_id, @source_title, @source_body, @image_inputs, @publish_platform, @user_instructions, @status, @export_paths, @created_at, @updated_at )`, ).run({ @@ -440,6 +554,379 @@ export function getLatestQaReport(dbPath: string | undefined, jobId: string) { }); } +export function createOptimizationCase( + dbPath: string | undefined, + input: { + case_type: OptimizationCaseType; + title: string; + summary: string; + publish_target: string; + source_excerpt: string; + }, +) { + return withDb(dbPath, (db) => { + const timestamp = nowIso(); + const optimizationCase: OptimizationCase = { + id: `case_${nanoid(10)}`, + case_type: input.case_type, + title: input.title, + summary: input.summary, + status: "running", + customer_name: "", + brand_name: "", + project_tags: [], + notes: "", + publish_target: input.publish_target, + source_excerpt: input.source_excerpt, + result_excerpt: "", + latest_result_version_id: null, + latest_version_number: null, + last_error_stage: null, + last_error_summary: null, + archived_at: null, + created_at: timestamp, + updated_at: timestamp, + }; + + db.prepare( + `insert into optimization_cases ( + id, case_type, title, summary, status, customer_name, brand_name, + project_tags, notes, publish_target, source_excerpt, result_excerpt, + latest_result_version_id, latest_version_number, last_error_stage, + last_error_summary, archived_at, created_at, updated_at + ) values ( + @id, @case_type, @title, @summary, @status, @customer_name, @brand_name, + @project_tags, @notes, @publish_target, @source_excerpt, @result_excerpt, + @latest_result_version_id, @latest_version_number, @last_error_stage, + @last_error_summary, @archived_at, @created_at, @updated_at + )`, + ).run({ + ...optimizationCase, + project_tags: serialize(optimizationCase.project_tags), + }); + + return optimizationCase; + }); +} + +export function saveCaseInput( + dbPath: string | undefined, + input: Omit, +) { + return withDb(dbPath, (db) => { + const timestamp = nowIso(); + const saved: CaseInput = { + ...input, + created_at: timestamp, + updated_at: timestamp, + }; + + db.prepare( + `insert into case_inputs ( + case_id, case_type, article_job_id, payload, created_at, updated_at + ) values ( + @case_id, @case_type, @article_job_id, @payload, @created_at, @updated_at + ) + on conflict(case_id) do update set + case_type = excluded.case_type, + article_job_id = excluded.article_job_id, + payload = excluded.payload, + updated_at = excluded.updated_at`, + ).run({ + ...saved, + payload: serialize(saved.payload), + }); + + return saved; + }); +} + +export function listOptimizationCases( + dbPath: string | undefined, + filters: CaseListFilters, +) { + return withDb(dbPath, (db) => { + const where: string[] = []; + const values: unknown[] = []; + + if (!filters.include_archived) { + where.push("archived_at is null"); + } + if (filters.case_type) { + where.push("case_type = ?"); + values.push(filters.case_type); + } + if (filters.status) { + where.push("status = ?"); + values.push(filters.status); + } + if (filters.publish_target) { + where.push("publish_target = ?"); + values.push(filters.publish_target); + } + if (filters.project_tag) { + where.push("project_tags like ?"); + values.push(`%"${filters.project_tag}"%`); + } + if (filters.created_from) { + where.push("created_at >= ?"); + values.push(filters.created_from); + } + if (filters.created_to) { + where.push("created_at <= ?"); + values.push(filters.created_to); + } + if (filters.q) { + where.push( + "(title like ? or summary like ? or source_excerpt like ? or result_excerpt like ? or customer_name like ? or brand_name like ? or notes like ?)", + ); + const keyword = `%${filters.q}%`; + values.push(keyword, keyword, keyword, keyword, keyword, keyword, keyword); + } + + const clause = where.length > 0 ? `where ${where.join(" and ")}` : ""; + return db + .prepare(`select * from optimization_cases ${clause} order by updated_at desc`) + .all(...values) + .map((row) => toOptimizationCase(row as OptimizationCaseRow)); + }); +} + +export function getOptimizationCaseDetail( + dbPath: string | undefined, + caseId: string, +) { + return withDb(dbPath, (db) => { + const caseRow = db + .prepare("select * from optimization_cases where id = ?") + .get(caseId) as OptimizationCaseRow | undefined; + if (!caseRow) return null; + + const inputRow = db + .prepare("select * from case_inputs where case_id = ?") + .get(caseId) as CaseInputRow | undefined; + const versionRows = db + .prepare( + `select * from optimization_result_versions + where case_id = ? + order by version desc`, + ) + .all(caseId) as OptimizationResultVersionRow[]; + + return { + case: toOptimizationCase(caseRow), + input: inputRow ? toCaseInput(inputRow) : null, + versions: versionRows.map(toOptimizationResultVersion), + }; + }); +} + +export function updateOptimizationCaseMetadata( + dbPath: string | undefined, + caseId: string, + changes: CaseMetadataPatch, +) { + return withDb(dbPath, (db) => { + const existing = db + .prepare("select * from optimization_cases where id = ?") + .get(caseId) as OptimizationCaseRow | undefined; + if (!existing) return null; + + const updated = { + title: changes.title ?? existing.title, + customer_name: changes.customer_name ?? existing.customer_name, + brand_name: changes.brand_name ?? existing.brand_name, + project_tags: + changes.project_tags === undefined + ? existing.project_tags + : serialize(changes.project_tags), + notes: changes.notes ?? existing.notes, + updated_at: nowIso(), + }; + + db.prepare( + `update optimization_cases set + title = @title, + customer_name = @customer_name, + brand_name = @brand_name, + project_tags = @project_tags, + notes = @notes, + updated_at = @updated_at + where id = @id`, + ).run({ id: caseId, ...updated }); + + const row = db + .prepare("select * from optimization_cases where id = ?") + .get(caseId) as OptimizationCaseRow; + return toOptimizationCase(row); + }); +} + +export function archiveOptimizationCase( + dbPath: string | undefined, + caseId: string, +) { + return updateCaseArchiveState(dbPath, caseId, true); +} + +export function restoreOptimizationCase( + dbPath: string | undefined, + caseId: string, +) { + return updateCaseArchiveState(dbPath, caseId, false); +} + +function updateCaseArchiveState( + dbPath: string | undefined, + caseId: string, + archived: boolean, +) { + return withDb(dbPath, (db) => { + const existing = db + .prepare("select * from optimization_cases where id = ?") + .get(caseId) as OptimizationCaseRow | undefined; + if (!existing) return null; + + const timestamp = nowIso(); + const restoredStatus = existing.latest_result_version_id + ? "optimized" + : existing.last_error_summary + ? "failed" + : "running"; + db.prepare( + `update optimization_cases set + status = ?, + archived_at = ?, + updated_at = ? + where id = ?`, + ).run( + archived ? "archived" : restoredStatus, + archived ? timestamp : null, + timestamp, + caseId, + ); + + const row = db + .prepare("select * from optimization_cases where id = ?") + .get(caseId) as OptimizationCaseRow; + return toOptimizationCase(row); + }); +} + +export function markOptimizationCaseFailed( + dbPath: string | undefined, + caseId: string, + input: { error_stage: string; error_summary: string }, +) { + return withDb(dbPath, (db) => { + const timestamp = nowIso(); + db.prepare( + `update optimization_cases set + status = 'failed', + last_error_stage = ?, + last_error_summary = ?, + updated_at = ? + where id = ?`, + ).run(input.error_stage, input.error_summary, timestamp, caseId); + + const row = db + .prepare("select * from optimization_cases where id = ?") + .get(caseId) as OptimizationCaseRow | undefined; + return row ? toOptimizationCase(row) : null; + }); +} + +export function createOptimizationResultVersion( + dbPath: string | undefined, + input: Omit, +) { + return withDb(dbPath, (db) => { + const nextVersion = + ((db + .prepare( + "select max(version) as version from optimization_result_versions where case_id = ?", + ) + .get(input.case_id) as { version: number | null }).version ?? 0) + 1; + const createdAt = nowIso(); + const resultVersion: OptimizationResultVersion = { + id: `ver_${nanoid(10)}`, + ...input, + version: nextVersion, + created_at: createdAt, + }; + + db.prepare( + `insert into optimization_result_versions ( + id, case_id, case_type, version, status, article_job_id, article_revision, + result_summary, payload, process_summary, llm_audit_summary, + error_stage, error_summary, created_at + ) values ( + @id, @case_id, @case_type, @version, @status, @article_job_id, @article_revision, + @result_summary, @payload, @process_summary, @llm_audit_summary, + @error_stage, @error_summary, @created_at + )`, + ).run({ + ...resultVersion, + payload: resultVersion.payload ? serialize(resultVersion.payload) : "", + process_summary: serialize(resultVersion.process_summary), + llm_audit_summary: serialize(resultVersion.llm_audit_summary), + }); + + db.prepare( + `update optimization_cases set + status = @status, + result_excerpt = @result_excerpt, + latest_result_version_id = @latest_result_version_id, + latest_version_number = @latest_version_number, + last_error_stage = @last_error_stage, + last_error_summary = @last_error_summary, + updated_at = @updated_at + where id = @case_id`, + ).run({ + case_id: input.case_id, + status: input.status === "optimized" ? "optimized" : "failed", + result_excerpt: input.result_summary, + latest_result_version_id: resultVersion.id, + latest_version_number: resultVersion.version, + last_error_stage: input.error_stage, + last_error_summary: input.error_summary, + updated_at: createdAt, + }); + + return resultVersion; + }); +} + +export function getOptimizationResultVersion( + dbPath: string | undefined, + versionId: string, +) { + return withDb(dbPath, (db) => { + const row = db + .prepare("select * from optimization_result_versions where id = ?") + .get(versionId) as OptimizationResultVersionRow | undefined; + return row ? toOptimizationResultVersion(row) : null; + }); +} + +export function findResultVersionForArticleRevision( + dbPath: string | undefined, + jobId: string, + revision: number, +) { + return withDb(dbPath, (db) => { + const row = db + .prepare( + `select * from optimization_result_versions + where article_job_id = ? and article_revision = ? + order by version desc + limit 1`, + ) + .get(jobId, revision) as OptimizationResultVersionRow | undefined; + return row ? toOptimizationResultVersion(row) : null; + }); +} + export function saveRubricVersion( dbPath: string | undefined, rubric: RubricVersion, @@ -472,11 +959,13 @@ export function saveScoringRun(dbPath: string | undefined, run: ScoringRun) { return withDb(dbPath, (db) => { db.prepare( `insert into scoring_runs ( - id, job_id, revision, rubric_version_id, dimension_scores, + id, result_version_id, case_type, job_id, revision, rubric_version_id, dimension_scores, composite_score, rationale, created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?)`, + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( run.id, + run.result_version_id ?? null, + run.case_type ?? "article", run.job_id, run.revision, run.rubric_version_id, @@ -507,28 +996,56 @@ export function getLatestScoringRun( }); } +export function getLatestScoringRunForResultVersion( + dbPath: string | undefined, + resultVersionId: string, +) { + return withDb(dbPath, (db) => { + const row = db + .prepare( + `select * from scoring_runs + where result_version_id = ? + order by created_at desc + limit 1`, + ) + .get(resultVersionId) as ScoringRunRow | undefined; + return row ? toScoringRun(row) : null; + }); +} + export function createPublicationRecord( dbPath: string | undefined, - input: Omit, + input: Omit< + PublicationRecord, + "id" | "created_at" | "updated_at" | "result_version_id" | "publish_target" + > & { + result_version_id?: string | null; + publish_target?: string; + }, ) { return withDb(dbPath, (db) => { const timestamp = nowIso(); + const publishTarget = input.publish_target ?? input.platform ?? "未指定"; const record: PublicationRecord = { id: `pub_${nanoid(10)}`, ...input, + result_version_id: input.result_version_id ?? null, + publish_target: publishTarget, + platform: isPublishPlatform(publishTarget) ? publishTarget : input.platform, created_at: timestamp, updated_at: timestamp, }; db.prepare( `insert into publication_records ( - id, job_id, revision, platform, url, published_at, status, + id, result_version_id, job_id, revision, publish_target, url, published_at, status, notes, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( record.id, + record.result_version_id, record.job_id, record.revision, - record.platform, + record.publish_target, record.url, record.published_at, record.status, @@ -545,7 +1062,21 @@ export function listPublicationRecords(dbPath: string | undefined, jobId: string db .prepare("select * from publication_records where job_id = ? order by published_at desc") .all(jobId) - .map((row) => row as PublicationRecordRow), + .map((row) => toPublicationRecord(row as PublicationRecordRow)), + ); +} + +export function listPublicationRecordsForResultVersion( + dbPath: string | undefined, + resultVersionId: string, +) { + return withDb(dbPath, (db) => + db + .prepare( + "select * from publication_records where result_version_id = ? order by published_at desc", + ) + .all(resultVersionId) + .map((row) => toPublicationRecord(row as PublicationRecordRow)), ); } @@ -554,7 +1085,7 @@ export function getPublicationRecord(dbPath: string | undefined, id: string) { const row = db .prepare("select * from publication_records where id = ?") .get(id) as PublicationRecordRow | undefined; - return row ?? null; + return row ? toPublicationRecord(row) : null; }); } diff --git a/src/lib/db/repository.ts b/src/lib/db/repository.ts index e87222e..efa0cf8 100644 --- a/src/lib/db/repository.ts +++ b/src/lib/db/repository.ts @@ -5,6 +5,15 @@ import type { RubricVersion, ScoringRun, } from "../calibration/types"; +import type { + CaseInput, + CaseListFilters, + CaseMetadataPatch, + OptimizationCase, + OptimizationCaseDetail, + OptimizationCaseType, + OptimizationResultVersion, +} from "../cases/types"; import type { OptimizationFactCard, OptimizedArticle, QaReport } from "../domain/types"; import type { ArticleJob, @@ -38,16 +47,55 @@ export interface AppRepository { saveRubricVersion(rubric: RubricVersion): Promise; saveScoringRun(run: ScoringRun): Promise; getLatestScoringRun(jobId: string, revision: number): Promise; + getLatestScoringRunForResultVersion(resultVersionId: string): Promise; createPublicationRecord( - input: Omit, + input: NewPublicationRecord, ): Promise; listPublicationRecords(jobId: string): Promise; + listPublicationRecordsForResultVersion(resultVersionId: string): Promise; getPublicationRecord(id: string): Promise; savePerformanceSnapshot(snapshot: PerformanceSnapshot): Promise; listPerformanceSnapshots(publicationId: string): Promise; saveCalibrationEvent(event: CalibrationEvent): Promise; + createOptimizationCase(input: { + case_type: OptimizationCaseType; + title: string; + summary: string; + publish_target: string; + source_excerpt: string; + }): Promise; + saveCaseInput(input: Omit): Promise; + listOptimizationCases(filters: CaseListFilters): Promise; + getOptimizationCaseDetail(caseId: string): Promise; + updateOptimizationCaseMetadata( + caseId: string, + changes: CaseMetadataPatch, + ): Promise; + archiveOptimizationCase(caseId: string): Promise; + restoreOptimizationCase(caseId: string): Promise; + markOptimizationCaseFailed( + caseId: string, + input: { error_stage: string; error_summary: string }, + ): Promise; + createOptimizationResultVersion( + input: Omit, + ): Promise; + getOptimizationResultVersion(versionId: string): Promise; + findResultVersionForArticleRevision( + jobId: string, + revision: number, + ): Promise; } +export type NewPublicationRecord = Omit< + PublicationRecord, + "id" | "created_at" | "updated_at" | "result_version_id" | "publish_target" | "platform" +> & { + result_version_id?: string | null; + publish_target?: string; + platform?: PublicationRecord["platform"]; +}; + interface RuntimeRepositoryOptions { appRuntime?: string; dbPath?: string; diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index f39f3cc..47b4d9f 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -20,6 +20,7 @@ export function initializeSchema(db: Database.Database) { create table if not exists article_jobs ( id text primary key, brand_template_id text, + case_id text, source_title text not null, source_body text not null, image_inputs text not null, @@ -32,6 +33,39 @@ export function initializeSchema(db: Database.Database) { foreign key (brand_template_id) references brand_templates(id) ); + create table if not exists optimization_cases ( + id text primary key, + case_type text not null, + title text not null, + summary text not null, + status text not null, + customer_name text not null default '', + brand_name text not null default '', + project_tags text not null default '[]', + notes text not null default '', + publish_target text not null default '', + source_excerpt text not null default '', + result_excerpt text not null default '', + latest_result_version_id text, + latest_version_number integer, + last_error_stage text, + last_error_summary text, + archived_at text, + created_at text not null, + updated_at text not null + ); + + create table if not exists case_inputs ( + case_id text primary key, + case_type text not null, + article_job_id text, + payload text not null, + created_at text not null, + updated_at text not null, + foreign key (case_id) references optimization_cases(id) on delete cascade, + foreign key (article_job_id) references article_jobs(id) on delete set null + ); + create table if not exists fact_cards ( job_id text primary key, source text not null, @@ -61,6 +95,26 @@ export function initializeSchema(db: Database.Database) { references optimized_articles(job_id, revision) on delete cascade ); + create table if not exists optimization_result_versions ( + id text primary key, + case_id text not null, + case_type text not null, + version integer not null, + status text not null, + article_job_id text, + article_revision integer, + result_summary text not null, + payload text not null, + process_summary text not null, + llm_audit_summary text not null, + error_stage text, + error_summary text, + created_at text not null, + unique (case_id, version), + foreign key (case_id) references optimization_cases(id) on delete cascade, + foreign key (article_job_id) references article_jobs(id) on delete set null + ); + create table if not exists rubric_versions ( id text primary key, version text not null, @@ -73,31 +127,34 @@ export function initializeSchema(db: Database.Database) { create table if not exists scoring_runs ( id text primary key, - job_id text not null, - revision integer not null, + result_version_id text, + case_type text not null default 'article', + job_id text, + revision integer, rubric_version_id text not null, dimension_scores text not null, composite_score real not null, rationale text not null, created_at text not null, - foreign key (job_id, revision) - references optimized_articles(job_id, revision) on delete cascade, + foreign key (result_version_id) + references optimization_result_versions(id) on delete cascade, foreign key (rubric_version_id) references rubric_versions(id) ); create table if not exists publication_records ( id text primary key, - job_id text not null, - revision integer not null, - platform text not null, + result_version_id text, + job_id text, + revision integer, + publish_target text not null, url text not null, published_at text not null, status text not null, notes text not null, created_at text not null, updated_at text not null, - foreign key (job_id, revision) - references optimized_articles(job_id, revision) on delete cascade + foreign key (result_version_id) + references optimization_result_versions(id) on delete cascade ); create table if not exists performance_snapshots ( @@ -130,9 +187,27 @@ export function initializeSchema(db: Database.Database) { create index if not exists idx_scoring_runs_job_revision on scoring_runs(job_id, revision); + create index if not exists idx_scoring_runs_result_version + on scoring_runs(result_version_id); + + create index if not exists idx_optimization_cases_updated_at + on optimization_cases(updated_at); + + create index if not exists idx_optimization_cases_case_type + on optimization_cases(case_type); + + create index if not exists idx_optimization_cases_status + on optimization_cases(status); + + create index if not exists idx_optimization_result_versions_case + on optimization_result_versions(case_id, version desc); + create index if not exists idx_publication_records_job_revision on publication_records(job_id, revision); + create index if not exists idx_publication_records_result_version + on publication_records(result_version_id); + create index if not exists idx_performance_snapshots_publication on performance_snapshots(publication_id); `); diff --git a/src/lib/db/sqlite-repository.ts b/src/lib/db/sqlite-repository.ts index 7b45cbe..4ad70c8 100644 --- a/src/lib/db/sqlite-repository.ts +++ b/src/lib/db/sqlite-repository.ts @@ -1,8 +1,11 @@ import type { OptimizationFactCard, OptimizedArticle, QaReport } from "../domain/types"; import type { AppRepository } from "./repository"; import { + archiveOptimizationCase, createArticleJob, createBrandTemplate, + createOptimizationCase, + createOptimizationResultVersion, createPublicationRecord, getArticleJob, getBrandTemplate, @@ -10,10 +13,19 @@ import { getLatestOptimizedArticle, getLatestQaReport, getLatestScoringRun, + getLatestScoringRunForResultVersion, + getOptimizationCaseDetail, + getOptimizationResultVersion, getPublicationRecord, + findResultVersionForArticleRevision, listBrandTemplates, + listOptimizationCases, listPerformanceSnapshots, listPublicationRecords, + listPublicationRecordsForResultVersion, + markOptimizationCaseFailed, + restoreOptimizationCase, + saveCaseInput, saveCalibrationEvent, saveFactCard, saveOptimizedArticle, @@ -21,6 +33,7 @@ import { saveQaReport, saveRubricVersion, saveScoringRun, + updateOptimizationCaseMetadata, updateArticleJob, type ArticleJob, type NewArticleJob, @@ -77,12 +90,18 @@ export function createSqliteRepository(dbPath?: string): AppRepository { getLatestScoringRun(jobId, revision) { return Promise.resolve(getLatestScoringRun(dbPath, jobId, revision)); }, + getLatestScoringRunForResultVersion(resultVersionId) { + return Promise.resolve(getLatestScoringRunForResultVersion(dbPath, resultVersionId)); + }, createPublicationRecord(input) { return Promise.resolve(createPublicationRecord(dbPath, input)); }, listPublicationRecords(jobId) { return Promise.resolve(listPublicationRecords(dbPath, jobId)); }, + listPublicationRecordsForResultVersion(resultVersionId) { + return Promise.resolve(listPublicationRecordsForResultVersion(dbPath, resultVersionId)); + }, getPublicationRecord(id) { return Promise.resolve(getPublicationRecord(dbPath, id)); }, @@ -95,5 +114,38 @@ export function createSqliteRepository(dbPath?: string): AppRepository { saveCalibrationEvent(event) { return Promise.resolve(saveCalibrationEvent(dbPath, event)); }, + createOptimizationCase(input) { + return Promise.resolve(createOptimizationCase(dbPath, input)); + }, + saveCaseInput(input) { + return Promise.resolve(saveCaseInput(dbPath, input)); + }, + listOptimizationCases(filters) { + return Promise.resolve(listOptimizationCases(dbPath, filters)); + }, + getOptimizationCaseDetail(caseId) { + return Promise.resolve(getOptimizationCaseDetail(dbPath, caseId)); + }, + updateOptimizationCaseMetadata(caseId, changes) { + return Promise.resolve(updateOptimizationCaseMetadata(dbPath, caseId, changes)); + }, + archiveOptimizationCase(caseId) { + return Promise.resolve(archiveOptimizationCase(dbPath, caseId)); + }, + restoreOptimizationCase(caseId) { + return Promise.resolve(restoreOptimizationCase(dbPath, caseId)); + }, + markOptimizationCaseFailed(caseId, input) { + return Promise.resolve(markOptimizationCaseFailed(dbPath, caseId, input)); + }, + createOptimizationResultVersion(input) { + return Promise.resolve(createOptimizationResultVersion(dbPath, input)); + }, + getOptimizationResultVersion(versionId) { + return Promise.resolve(getOptimizationResultVersion(dbPath, versionId)); + }, + findResultVersionForArticleRevision(jobId, revision) { + return Promise.resolve(findResultVersionForArticleRevision(dbPath, jobId, revision)); + }, }; }