513 lines
14 KiB
TypeScript
513 lines
14 KiB
TypeScript
import { nanoid } from "nanoid";
|
|
|
|
import type {
|
|
PerformanceSnapshot,
|
|
PublicationRecord,
|
|
ScoringRun,
|
|
} from "../calibration/types";
|
|
import type {
|
|
ConfirmedFactCard,
|
|
ImageInput,
|
|
OptimizedArticle,
|
|
PublishPlatform,
|
|
QaReport,
|
|
} from "../domain/types";
|
|
import type { AppRepository } from "./repository";
|
|
import type { ArticleJob, BrandTemplate } from "./repositories";
|
|
|
|
type JsonObject = Record<string, unknown>;
|
|
|
|
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;
|
|
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;
|
|
job_id: string;
|
|
revision: number;
|
|
rubric_version_id: string;
|
|
dimension_scores: string;
|
|
composite_score: number;
|
|
rationale: string;
|
|
created_at: string;
|
|
}
|
|
|
|
interface PublicationRecordRow {
|
|
id: string;
|
|
job_id: string;
|
|
revision: number;
|
|
platform: PublishPlatform;
|
|
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;
|
|
}
|
|
|
|
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 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 toPerformanceSnapshot(row: PerformanceSnapshotRow): PerformanceSnapshot {
|
|
return {
|
|
...row,
|
|
raw_reference: row.raw_reference ?? undefined,
|
|
metrics: parseJson<PerformanceSnapshot["metrics"]>(row.metrics),
|
|
};
|
|
}
|
|
|
|
export function createD1Repository(db: D1Database): AppRepository {
|
|
return {
|
|
async createBrandTemplate(input) {
|
|
const createdAt = nowIso();
|
|
const template: BrandTemplate = {
|
|
id: `brand_${nanoid(10)}`,
|
|
...input,
|
|
created_at: createdAt,
|
|
updated_at: createdAt,
|
|
};
|
|
|
|
await 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
)
|
|
.bind(
|
|
template.id,
|
|
template.brand_name,
|
|
template.company_full_name,
|
|
serialize(template.company_short_names),
|
|
serialize(template.product_names),
|
|
serialize(template.target_industries),
|
|
serialize(template.target_audience),
|
|
serialize(template.verified_claims),
|
|
serialize(template.forbidden_claims),
|
|
serialize(template.tone_rules),
|
|
template.created_at,
|
|
template.updated_at,
|
|
)
|
|
.run();
|
|
|
|
return template;
|
|
},
|
|
async listBrandTemplates() {
|
|
const result = await db
|
|
.prepare("select * from brand_templates order by updated_at desc")
|
|
.all<BrandTemplateRow>();
|
|
return result.results.map(toBrandTemplate);
|
|
},
|
|
async getBrandTemplate(id) {
|
|
const row = await db
|
|
.prepare("select * from brand_templates where id = ?")
|
|
.bind(id)
|
|
.first<BrandTemplateRow>();
|
|
return row ? toBrandTemplate(row) : null;
|
|
},
|
|
async createArticleJob(input) {
|
|
const createdAt = nowIso();
|
|
const job: ArticleJob = {
|
|
id: `job_${nanoid(10)}`,
|
|
brand_template_id: input.brand_template_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,
|
|
};
|
|
|
|
await db
|
|
.prepare(
|
|
`insert into article_jobs (
|
|
id, brand_template_id, source_title, source_body, image_inputs,
|
|
publish_platform, user_instructions, status, export_paths, created_at, updated_at
|
|
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
)
|
|
.bind(
|
|
job.id,
|
|
job.brand_template_id,
|
|
job.source_title,
|
|
job.source_body,
|
|
serialize(job.image_inputs),
|
|
job.publish_platform,
|
|
job.user_instructions,
|
|
job.status,
|
|
serialize(job.export_paths),
|
|
job.created_at,
|
|
job.updated_at,
|
|
)
|
|
.run();
|
|
|
|
return job;
|
|
},
|
|
async getArticleJob(id) {
|
|
const row = await db
|
|
.prepare("select * from article_jobs where id = ?")
|
|
.bind(id)
|
|
.first<ArticleJobRow>();
|
|
return row ? toArticleJob(row) : null;
|
|
},
|
|
async updateArticleJob(id, changes) {
|
|
const existing = await this.getArticleJob(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(),
|
|
};
|
|
await db
|
|
.prepare(
|
|
`update article_jobs set
|
|
brand_template_id = ?,
|
|
status = ?,
|
|
export_paths = ?,
|
|
updated_at = ?
|
|
where id = ?`,
|
|
)
|
|
.bind(
|
|
updated.brand_template_id,
|
|
updated.status,
|
|
serialize(updated.export_paths),
|
|
updated.updated_at,
|
|
id,
|
|
)
|
|
.run();
|
|
return this.getArticleJob(id);
|
|
},
|
|
async saveFactCard(jobId, factCard) {
|
|
const timestamp = nowIso();
|
|
await db
|
|
.prepare(
|
|
`insert into fact_cards (
|
|
job_id, source, fact_card, confirmed_by_user, created_at, updated_at
|
|
) values (?, ?, ?, ?, ?, ?)
|
|
on conflict(job_id) do update set
|
|
fact_card = excluded.fact_card,
|
|
confirmed_by_user = excluded.confirmed_by_user,
|
|
updated_at = excluded.updated_at`,
|
|
)
|
|
.bind(
|
|
jobId,
|
|
"auto_extract_then_user_confirmed",
|
|
serialize(factCard),
|
|
factCard.confirmed_by_user ? 1 : 0,
|
|
timestamp,
|
|
timestamp,
|
|
)
|
|
.run();
|
|
return { job_id: jobId, ...factCard };
|
|
},
|
|
async getFactCard(jobId) {
|
|
const row = await db
|
|
.prepare("select job_id, fact_card from fact_cards where job_id = ?")
|
|
.bind(jobId)
|
|
.first<FactCardRow>();
|
|
return row
|
|
? { job_id: row.job_id, ...parseJson<ConfirmedFactCard>(row.fact_card) }
|
|
: null;
|
|
},
|
|
async saveOptimizedArticle(jobId, article) {
|
|
const latest = await this.getLatestOptimizedArticle(jobId);
|
|
const revision = (latest?.revision ?? 0) + 1;
|
|
const saved = { ...article, job_id: jobId, revision };
|
|
await db
|
|
.prepare(
|
|
`insert into optimized_articles (job_id, revision, article, created_at)
|
|
values (?, ?, ?, ?)`,
|
|
)
|
|
.bind(jobId, revision, serialize(saved), nowIso())
|
|
.run();
|
|
return saved;
|
|
},
|
|
async getLatestOptimizedArticle(jobId) {
|
|
const row = await db
|
|
.prepare(
|
|
`select job_id, revision, article
|
|
from optimized_articles
|
|
where job_id = ?
|
|
order by revision desc
|
|
limit 1`,
|
|
)
|
|
.bind(jobId)
|
|
.first<OptimizedArticleRow>();
|
|
return row ? parseJson<OptimizedArticle>(row.article) : null;
|
|
},
|
|
async saveQaReport(jobId, revision, report) {
|
|
const saved = { ...report, job_id: jobId, revision };
|
|
await db
|
|
.prepare(
|
|
`insert into qa_reports (job_id, revision, report, created_at)
|
|
values (?, ?, ?, ?)
|
|
on conflict(job_id, revision) do update set report = excluded.report`,
|
|
)
|
|
.bind(jobId, revision, serialize(saved), nowIso())
|
|
.run();
|
|
return saved;
|
|
},
|
|
async getLatestQaReport(jobId) {
|
|
const row = await db
|
|
.prepare(
|
|
`select job_id, revision, report
|
|
from qa_reports
|
|
where job_id = ?
|
|
order by revision desc
|
|
limit 1`,
|
|
)
|
|
.bind(jobId)
|
|
.first<QaReportRow>();
|
|
return row ? parseJson<QaReport>(row.report) : null;
|
|
},
|
|
async saveRubricVersion(rubric) {
|
|
await 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`,
|
|
)
|
|
.bind(
|
|
rubric.id,
|
|
rubric.version,
|
|
rubric.name,
|
|
serialize(rubric.dimensions),
|
|
rubric.formula,
|
|
rubric.is_active ? 1 : 0,
|
|
rubric.created_at,
|
|
)
|
|
.run();
|
|
return rubric;
|
|
},
|
|
async saveScoringRun(run) {
|
|
await db
|
|
.prepare(
|
|
`insert into scoring_runs (
|
|
id, job_id, revision, rubric_version_id, dimension_scores,
|
|
composite_score, rationale, created_at
|
|
) values (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
)
|
|
.bind(
|
|
run.id,
|
|
run.job_id,
|
|
run.revision,
|
|
run.rubric_version_id,
|
|
serialize(run.dimension_scores),
|
|
run.composite_score,
|
|
run.rationale,
|
|
run.created_at,
|
|
)
|
|
.run();
|
|
return run;
|
|
},
|
|
async getLatestScoringRun(jobId, revision) {
|
|
const row = await db
|
|
.prepare(
|
|
`select * from scoring_runs
|
|
where job_id = ? and revision = ?
|
|
order by created_at desc
|
|
limit 1`,
|
|
)
|
|
.bind(jobId, revision)
|
|
.first<ScoringRunRow>();
|
|
return row ? toScoringRun(row) : null;
|
|
},
|
|
async createPublicationRecord(input) {
|
|
const timestamp = nowIso();
|
|
const record: PublicationRecord = {
|
|
id: `pub_${nanoid(10)}`,
|
|
...input,
|
|
created_at: timestamp,
|
|
updated_at: timestamp,
|
|
};
|
|
await db
|
|
.prepare(
|
|
`insert into publication_records (
|
|
id, job_id, revision, platform, url, published_at, status,
|
|
notes, created_at, updated_at
|
|
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
)
|
|
.bind(
|
|
record.id,
|
|
record.job_id,
|
|
record.revision,
|
|
record.platform,
|
|
record.url,
|
|
record.published_at,
|
|
record.status,
|
|
record.notes,
|
|
record.created_at,
|
|
record.updated_at,
|
|
)
|
|
.run();
|
|
return record;
|
|
},
|
|
async listPublicationRecords(jobId) {
|
|
const result = await db
|
|
.prepare(
|
|
"select * from publication_records where job_id = ? order by published_at desc",
|
|
)
|
|
.bind(jobId)
|
|
.all<PublicationRecordRow>();
|
|
return result.results;
|
|
},
|
|
async getPublicationRecord(id) {
|
|
return db
|
|
.prepare("select * from publication_records where id = ?")
|
|
.bind(id)
|
|
.first<PublicationRecordRow>();
|
|
},
|
|
async savePerformanceSnapshot(snapshot) {
|
|
await db
|
|
.prepare(
|
|
`insert into performance_snapshots (
|
|
id, publication_id, source, window_label, metrics,
|
|
feedback_summary, raw_reference, snapshot_at
|
|
) values (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
)
|
|
.bind(
|
|
snapshot.id,
|
|
snapshot.publication_id,
|
|
snapshot.source,
|
|
snapshot.window_label,
|
|
serialize(snapshot.metrics),
|
|
snapshot.feedback_summary,
|
|
snapshot.raw_reference ?? null,
|
|
snapshot.snapshot_at,
|
|
)
|
|
.run();
|
|
return snapshot;
|
|
},
|
|
async listPerformanceSnapshots(publicationId) {
|
|
const result = await db
|
|
.prepare(
|
|
"select * from performance_snapshots where publication_id = ? order by snapshot_at desc",
|
|
)
|
|
.bind(publicationId)
|
|
.all<PerformanceSnapshotRow>();
|
|
return result.results.map(toPerformanceSnapshot);
|
|
},
|
|
async saveCalibrationEvent(event) {
|
|
await db
|
|
.prepare(
|
|
`insert into calibration_events (
|
|
id, publication_id, scoring_run_id, performance_snapshot_id,
|
|
direction, observations, recommended_action, created_at
|
|
) values (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
)
|
|
.bind(
|
|
event.id,
|
|
event.publication_id,
|
|
event.scoring_run_id,
|
|
event.performance_snapshot_id,
|
|
event.direction,
|
|
serialize(event.observations),
|
|
event.recommended_action,
|
|
event.created_at,
|
|
)
|
|
.run();
|
|
return event;
|
|
},
|
|
};
|
|
}
|