1153 lines
32 KiB
TypeScript
1153 lines
32 KiB
TypeScript
import { nanoid } from "nanoid";
|
|
|
|
import type {
|
|
CalibrationEvent,
|
|
PerformanceSnapshot,
|
|
PublicationRecord,
|
|
RubricVersion,
|
|
ScoringRun,
|
|
} from "../calibration/types";
|
|
import type {
|
|
CaseInput,
|
|
CaseListFilters,
|
|
CaseMetadataPatch,
|
|
OptimizationCase,
|
|
OptimizationCaseStatus,
|
|
OptimizationCaseType,
|
|
OptimizationResultVersion,
|
|
ResultVersionStatus,
|
|
} from "../cases/types";
|
|
import type {
|
|
OptimizationFactCard,
|
|
ImageInput,
|
|
OptimizedArticle,
|
|
PublishPlatform,
|
|
QaReport,
|
|
} from "../domain/types";
|
|
|
|
import { createDatabase, getDefaultDatabasePath } from "./connection";
|
|
import { initializeSchema } from "./schema";
|
|
|
|
type JsonObject = Record<string, unknown>;
|
|
|
|
export interface BrandTemplate {
|
|
id: string;
|
|
brand_name: string;
|
|
company_full_name: string;
|
|
company_short_names: string[];
|
|
product_names: string[];
|
|
target_industries: string[];
|
|
target_audience: string[];
|
|
verified_claims: string[];
|
|
forbidden_claims: string[];
|
|
tone_rules: JsonObject;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export type NewBrandTemplate = Omit<
|
|
BrandTemplate,
|
|
"id" | "created_at" | "updated_at"
|
|
>;
|
|
|
|
export interface ArticleJob {
|
|
id: string;
|
|
brand_template_id: string | null;
|
|
case_id: string | null;
|
|
source_title: string;
|
|
source_body: string;
|
|
image_inputs: ImageInput[];
|
|
publish_platform: PublishPlatform;
|
|
user_instructions: string;
|
|
status: string;
|
|
export_paths: Record<string, string>;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface NewArticleJob {
|
|
brand_template_id?: string | null;
|
|
case_id?: string | null;
|
|
source_title: string;
|
|
source_body: string;
|
|
image_inputs: ImageInput[];
|
|
publish_platform: PublishPlatform;
|
|
user_instructions: string;
|
|
}
|
|
|
|
interface BrandTemplateRow {
|
|
id: string;
|
|
brand_name: string;
|
|
company_full_name: string;
|
|
company_short_names: string;
|
|
product_names: string;
|
|
target_industries: string;
|
|
target_audience: string;
|
|
verified_claims: string;
|
|
forbidden_claims: string;
|
|
tone_rules: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface ArticleJobRow {
|
|
id: string;
|
|
brand_template_id: string | null;
|
|
case_id: string | null;
|
|
source_title: string;
|
|
source_body: string;
|
|
image_inputs: string;
|
|
publish_platform: PublishPlatform;
|
|
user_instructions: string;
|
|
status: string;
|
|
export_paths: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface FactCardRow {
|
|
job_id: string;
|
|
fact_card: string;
|
|
}
|
|
|
|
interface OptimizedArticleRow {
|
|
job_id: string;
|
|
revision: number;
|
|
article: string;
|
|
}
|
|
|
|
interface QaReportRow {
|
|
job_id: string;
|
|
revision: number;
|
|
report: string;
|
|
}
|
|
|
|
interface ScoringRunRow {
|
|
id: string;
|
|
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;
|
|
rationale: string;
|
|
created_at: string;
|
|
}
|
|
|
|
interface PublicationRecordRow {
|
|
id: string;
|
|
result_version_id: string | null;
|
|
job_id: string | null;
|
|
revision: number | null;
|
|
publish_target: string;
|
|
url: string;
|
|
published_at: string;
|
|
status: "draft" | "published" | "archived";
|
|
notes: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface PerformanceSnapshotRow {
|
|
id: string;
|
|
publication_id: string;
|
|
source: "manual" | `adapter:${string}`;
|
|
window_label: string;
|
|
metrics: string;
|
|
feedback_summary: string;
|
|
raw_reference: string | null;
|
|
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();
|
|
}
|
|
|
|
function serialize(value: unknown) {
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function parseJson<T>(value: string): T {
|
|
return JSON.parse(value) as T;
|
|
}
|
|
|
|
function withDb<T>(dbPath: string | undefined, action: (db: ReturnType<typeof createDatabase>) => T) {
|
|
const db = createDatabase(dbPath ?? getDefaultDatabasePath());
|
|
initializeSchema(db);
|
|
try {
|
|
return action(db);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
function toBrandTemplate(row: BrandTemplateRow): BrandTemplate {
|
|
return {
|
|
...row,
|
|
company_short_names: parseJson<string[]>(row.company_short_names),
|
|
product_names: parseJson<string[]>(row.product_names),
|
|
target_industries: parseJson<string[]>(row.target_industries),
|
|
target_audience: parseJson<string[]>(row.target_audience),
|
|
verified_claims: parseJson<string[]>(row.verified_claims),
|
|
forbidden_claims: parseJson<string[]>(row.forbidden_claims),
|
|
tone_rules: parseJson<JsonObject>(row.tone_rules),
|
|
};
|
|
}
|
|
|
|
function toArticleJob(row: ArticleJobRow): ArticleJob {
|
|
return {
|
|
...row,
|
|
image_inputs: parseJson<ImageInput[]>(row.image_inputs),
|
|
export_paths: parseJson<Record<string, string>>(row.export_paths),
|
|
};
|
|
}
|
|
|
|
function toScoringRun(row: ScoringRunRow): ScoringRun {
|
|
return {
|
|
...row,
|
|
dimension_scores: parseJson<Record<string, number>>(row.dimension_scores),
|
|
};
|
|
}
|
|
|
|
function toPublicationRecord(row: PublicationRecordRow): PublicationRecord {
|
|
return {
|
|
...row,
|
|
platform: isPublishPlatform(row.publish_target)
|
|
? row.publish_target
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
function toPerformanceSnapshot(row: PerformanceSnapshotRow): PerformanceSnapshot {
|
|
return {
|
|
...row,
|
|
raw_reference: row.raw_reference ?? undefined,
|
|
metrics: parseJson<PerformanceSnapshot["metrics"]>(row.metrics),
|
|
};
|
|
}
|
|
|
|
function toOptimizationCase(row: OptimizationCaseRow): OptimizationCase {
|
|
return {
|
|
...row,
|
|
project_tags: parseJson<string[]>(row.project_tags),
|
|
};
|
|
}
|
|
|
|
function toCaseInput(row: CaseInputRow): CaseInput {
|
|
return {
|
|
...row,
|
|
payload: parseJson<CaseInput["payload"]>(row.payload),
|
|
};
|
|
}
|
|
|
|
function toOptimizationResultVersion(
|
|
row: OptimizationResultVersionRow,
|
|
): OptimizationResultVersion {
|
|
return {
|
|
...row,
|
|
payload: row.payload
|
|
? parseJson<OptimizationResultVersion["payload"]>(row.payload)
|
|
: null,
|
|
process_summary: parseJson<OptimizationResultVersion["process_summary"]>(
|
|
row.process_summary,
|
|
),
|
|
llm_audit_summary: parseJson<OptimizationResultVersion["llm_audit_summary"]>(
|
|
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,
|
|
) {
|
|
return withDb(dbPath, (db) => {
|
|
const createdAt = nowIso();
|
|
const template: BrandTemplate = {
|
|
id: `brand_${nanoid(10)}`,
|
|
...input,
|
|
created_at: createdAt,
|
|
updated_at: createdAt,
|
|
};
|
|
|
|
db.prepare(
|
|
`insert into brand_templates (
|
|
id, brand_name, company_full_name, company_short_names, product_names,
|
|
target_industries, target_audience, verified_claims, forbidden_claims,
|
|
tone_rules, created_at, updated_at
|
|
) values (
|
|
@id, @brand_name, @company_full_name, @company_short_names, @product_names,
|
|
@target_industries, @target_audience, @verified_claims, @forbidden_claims,
|
|
@tone_rules, @created_at, @updated_at
|
|
)`,
|
|
).run({
|
|
...template,
|
|
company_short_names: serialize(template.company_short_names),
|
|
product_names: serialize(template.product_names),
|
|
target_industries: serialize(template.target_industries),
|
|
target_audience: serialize(template.target_audience),
|
|
verified_claims: serialize(template.verified_claims),
|
|
forbidden_claims: serialize(template.forbidden_claims),
|
|
tone_rules: serialize(template.tone_rules),
|
|
});
|
|
|
|
return template;
|
|
});
|
|
}
|
|
|
|
export function listBrandTemplates(dbPath?: string) {
|
|
return withDb(dbPath, (db) =>
|
|
db
|
|
.prepare("select * from brand_templates order by updated_at desc")
|
|
.all()
|
|
.map((row) => toBrandTemplate(row as BrandTemplateRow)),
|
|
);
|
|
}
|
|
|
|
export function getBrandTemplate(dbPath: string | undefined, id: string) {
|
|
return withDb(dbPath, (db) => {
|
|
const row = db
|
|
.prepare("select * from brand_templates where id = ?")
|
|
.get(id) as BrandTemplateRow | undefined;
|
|
return row ? toBrandTemplate(row) : null;
|
|
});
|
|
}
|
|
|
|
export function createArticleJob(dbPath: string | undefined, input: NewArticleJob) {
|
|
return withDb(dbPath, (db) => {
|
|
const createdAt = nowIso();
|
|
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,
|
|
publish_platform: input.publish_platform,
|
|
user_instructions: input.user_instructions,
|
|
status: "draft",
|
|
export_paths: {},
|
|
created_at: createdAt,
|
|
updated_at: createdAt,
|
|
};
|
|
|
|
db.prepare(
|
|
`insert into article_jobs (
|
|
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, @case_id, @source_title, @source_body, @image_inputs,
|
|
@publish_platform, @user_instructions, @status, @export_paths, @created_at, @updated_at
|
|
)`,
|
|
).run({
|
|
...job,
|
|
image_inputs: serialize(job.image_inputs),
|
|
export_paths: serialize(job.export_paths),
|
|
});
|
|
|
|
return job;
|
|
});
|
|
}
|
|
|
|
export function getArticleJob(dbPath: string | undefined, id: string) {
|
|
return withDb(dbPath, (db) => {
|
|
const row = db
|
|
.prepare("select * from article_jobs where id = ?")
|
|
.get(id) as ArticleJobRow | undefined;
|
|
return row ? toArticleJob(row) : null;
|
|
});
|
|
}
|
|
|
|
export function updateArticleJob(
|
|
dbPath: string | undefined,
|
|
id: string,
|
|
changes: Partial<Pick<ArticleJob, "brand_template_id" | "status" | "export_paths">>,
|
|
) {
|
|
return withDb(dbPath, (db) => {
|
|
const existing = getArticleJob(dbPath, id);
|
|
if (!existing) return null;
|
|
const updated = {
|
|
brand_template_id: changes.brand_template_id ?? existing.brand_template_id,
|
|
status: changes.status ?? existing.status,
|
|
export_paths: changes.export_paths ?? existing.export_paths,
|
|
updated_at: nowIso(),
|
|
};
|
|
db.prepare(
|
|
`update article_jobs set
|
|
brand_template_id = @brand_template_id,
|
|
status = @status,
|
|
export_paths = @export_paths,
|
|
updated_at = @updated_at
|
|
where id = @id`,
|
|
).run({
|
|
id,
|
|
...updated,
|
|
export_paths: serialize(updated.export_paths),
|
|
});
|
|
return getArticleJob(dbPath, id);
|
|
});
|
|
}
|
|
|
|
export function saveFactCard(
|
|
dbPath: string | undefined,
|
|
jobId: string,
|
|
factCard: OptimizationFactCard,
|
|
) {
|
|
return withDb(dbPath, (db) => {
|
|
const timestamp = nowIso();
|
|
db.prepare(
|
|
`insert into fact_cards (
|
|
job_id, source, fact_card, confirmed_by_user, created_at, updated_at
|
|
) values (
|
|
@job_id, @source, @fact_card, @confirmed_by_user, @created_at, @updated_at
|
|
)
|
|
on conflict(job_id) do update set
|
|
fact_card = excluded.fact_card,
|
|
confirmed_by_user = excluded.confirmed_by_user,
|
|
updated_at = excluded.updated_at`,
|
|
).run({
|
|
job_id: jobId,
|
|
source: factCard.confirmed_by_user
|
|
? "auto_extract_then_user_confirmed"
|
|
: "auto_extract_for_optimization",
|
|
fact_card: serialize(factCard),
|
|
confirmed_by_user: factCard.confirmed_by_user ? 1 : 0,
|
|
created_at: timestamp,
|
|
updated_at: timestamp,
|
|
});
|
|
|
|
return { job_id: jobId, ...factCard };
|
|
});
|
|
}
|
|
|
|
export function getFactCard(dbPath: string | undefined, jobId: string) {
|
|
return withDb(dbPath, (db) => {
|
|
const row = db
|
|
.prepare("select job_id, fact_card from fact_cards where job_id = ?")
|
|
.get(jobId) as FactCardRow | undefined;
|
|
return row
|
|
? { job_id: row.job_id, ...parseJson<OptimizationFactCard>(row.fact_card) }
|
|
: null;
|
|
});
|
|
}
|
|
|
|
export function saveOptimizedArticle(
|
|
dbPath: string | undefined,
|
|
jobId: string,
|
|
article: OptimizedArticle,
|
|
) {
|
|
return withDb(dbPath, (db) => {
|
|
const nextRevision =
|
|
((db
|
|
.prepare(
|
|
"select max(revision) as revision from optimized_articles where job_id = ?",
|
|
)
|
|
.get(jobId) as { revision: number | null }).revision ?? 0) + 1;
|
|
const saved = { ...article, job_id: jobId, revision: nextRevision };
|
|
|
|
db.prepare(
|
|
`insert into optimized_articles (job_id, revision, article, created_at)
|
|
values (?, ?, ?, ?)`,
|
|
).run(jobId, nextRevision, serialize(saved), nowIso());
|
|
|
|
return saved;
|
|
});
|
|
}
|
|
|
|
export function getLatestOptimizedArticle(dbPath: string | undefined, jobId: string) {
|
|
return withDb(dbPath, (db) => {
|
|
const row = db
|
|
.prepare(
|
|
`select job_id, revision, article from optimized_articles
|
|
where job_id = ? order by revision desc limit 1`,
|
|
)
|
|
.get(jobId) as OptimizedArticleRow | undefined;
|
|
return row ? parseJson<OptimizedArticle>(row.article) : null;
|
|
});
|
|
}
|
|
|
|
export function saveQaReport(
|
|
dbPath: string | undefined,
|
|
jobId: string,
|
|
revision: number,
|
|
report: QaReport,
|
|
) {
|
|
return withDb(dbPath, (db) => {
|
|
const saved = { ...report, job_id: jobId, revision };
|
|
db.prepare(
|
|
`insert into qa_reports (job_id, revision, report, created_at)
|
|
values (?, ?, ?, ?)
|
|
on conflict(job_id, revision) do update set
|
|
report = excluded.report`,
|
|
).run(jobId, revision, serialize(saved), nowIso());
|
|
return saved;
|
|
});
|
|
}
|
|
|
|
export function getLatestQaReport(dbPath: string | undefined, jobId: string) {
|
|
return withDb(dbPath, (db) => {
|
|
const row = db
|
|
.prepare(
|
|
`select job_id, revision, report from qa_reports
|
|
where job_id = ? order by revision desc limit 1`,
|
|
)
|
|
.get(jobId) as QaReportRow | undefined;
|
|
return row ? parseJson<QaReport>(row.report) : null;
|
|
});
|
|
}
|
|
|
|
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<CaseInput, "created_at" | "updated_at">,
|
|
) {
|
|
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<OptimizationResultVersion, "id" | "version" | "created_at">,
|
|
) {
|
|
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,
|
|
) {
|
|
return withDb(dbPath, (db) => {
|
|
db.prepare(
|
|
`insert into rubric_versions (
|
|
id, version, name, dimensions, formula, is_active, created_at
|
|
) values (?, ?, ?, ?, ?, ?, ?)
|
|
on conflict(id) do update set
|
|
version = excluded.version,
|
|
name = excluded.name,
|
|
dimensions = excluded.dimensions,
|
|
formula = excluded.formula,
|
|
is_active = excluded.is_active`,
|
|
).run(
|
|
rubric.id,
|
|
rubric.version,
|
|
rubric.name,
|
|
serialize(rubric.dimensions),
|
|
rubric.formula,
|
|
rubric.is_active ? 1 : 0,
|
|
rubric.created_at,
|
|
);
|
|
return rubric;
|
|
});
|
|
}
|
|
|
|
export function saveScoringRun(dbPath: string | undefined, run: ScoringRun) {
|
|
return withDb(dbPath, (db) => {
|
|
db.prepare(
|
|
`insert into scoring_runs (
|
|
id, result_version_id, case_type, job_id, revision, rubric_version_id, dimension_scores,
|
|
composite_score, rationale, created_at
|
|
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
).run(
|
|
run.id,
|
|
run.result_version_id ?? null,
|
|
run.case_type ?? "article",
|
|
run.job_id,
|
|
run.revision,
|
|
run.rubric_version_id,
|
|
serialize(run.dimension_scores),
|
|
run.composite_score,
|
|
run.rationale,
|
|
run.created_at,
|
|
);
|
|
return run;
|
|
});
|
|
}
|
|
|
|
export function getLatestScoringRun(
|
|
dbPath: string | undefined,
|
|
jobId: string,
|
|
revision: number,
|
|
) {
|
|
return withDb(dbPath, (db) => {
|
|
const row = db
|
|
.prepare(
|
|
`select * from scoring_runs
|
|
where job_id = ? and revision = ?
|
|
order by created_at desc
|
|
limit 1`,
|
|
)
|
|
.get(jobId, revision) as ScoringRunRow | undefined;
|
|
return row ? toScoringRun(row) : null;
|
|
});
|
|
}
|
|
|
|
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<
|
|
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, result_version_id, job_id, revision, publish_target, url, published_at, status,
|
|
notes, created_at, updated_at
|
|
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
).run(
|
|
record.id,
|
|
record.result_version_id,
|
|
record.job_id,
|
|
record.revision,
|
|
record.publish_target,
|
|
record.url,
|
|
record.published_at,
|
|
record.status,
|
|
record.notes,
|
|
record.created_at,
|
|
record.updated_at,
|
|
);
|
|
return record;
|
|
});
|
|
}
|
|
|
|
export function listPublicationRecords(dbPath: string | undefined, jobId: string) {
|
|
return withDb(dbPath, (db) =>
|
|
db
|
|
.prepare("select * from publication_records where job_id = ? order by published_at desc")
|
|
.all(jobId)
|
|
.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)),
|
|
);
|
|
}
|
|
|
|
export function getPublicationRecord(dbPath: string | undefined, id: string) {
|
|
return withDb(dbPath, (db) => {
|
|
const row = db
|
|
.prepare("select * from publication_records where id = ?")
|
|
.get(id) as PublicationRecordRow | undefined;
|
|
return row ? toPublicationRecord(row) : null;
|
|
});
|
|
}
|
|
|
|
export function savePerformanceSnapshot(
|
|
dbPath: string | undefined,
|
|
snapshot: PerformanceSnapshot,
|
|
) {
|
|
return withDb(dbPath, (db) => {
|
|
db.prepare(
|
|
`insert into performance_snapshots (
|
|
id, publication_id, source, window_label, metrics,
|
|
feedback_summary, raw_reference, snapshot_at
|
|
) values (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
).run(
|
|
snapshot.id,
|
|
snapshot.publication_id,
|
|
snapshot.source,
|
|
snapshot.window_label,
|
|
serialize(snapshot.metrics),
|
|
snapshot.feedback_summary,
|
|
snapshot.raw_reference ?? null,
|
|
snapshot.snapshot_at,
|
|
);
|
|
return snapshot;
|
|
});
|
|
}
|
|
|
|
export function listPerformanceSnapshots(
|
|
dbPath: string | undefined,
|
|
publicationId: string,
|
|
) {
|
|
return withDb(dbPath, (db) =>
|
|
db
|
|
.prepare(
|
|
"select * from performance_snapshots where publication_id = ? order by snapshot_at desc",
|
|
)
|
|
.all(publicationId)
|
|
.map((row) => toPerformanceSnapshot(row as PerformanceSnapshotRow)),
|
|
);
|
|
}
|
|
|
|
export function saveCalibrationEvent(
|
|
dbPath: string | undefined,
|
|
event: CalibrationEvent,
|
|
) {
|
|
return withDb(dbPath, (db) => {
|
|
db.prepare(
|
|
`insert into calibration_events (
|
|
id, publication_id, scoring_run_id, performance_snapshot_id,
|
|
direction, observations, recommended_action, created_at
|
|
) values (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
).run(
|
|
event.id,
|
|
event.publication_id,
|
|
event.scoring_run_id,
|
|
event.performance_snapshot_id,
|
|
event.direction,
|
|
serialize(event.observations),
|
|
event.recommended_action,
|
|
event.created_at,
|
|
);
|
|
return event;
|
|
});
|
|
}
|