feat: add sqlite persistence
This commit is contained in:
@@ -0,0 +1,197 @@
|
|||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createDatabase } from "../connection";
|
||||||
|
import { initializeSchema } from "../schema";
|
||||||
|
import {
|
||||||
|
createArticleJob,
|
||||||
|
createBrandTemplate,
|
||||||
|
getArticleJob,
|
||||||
|
getBrandTemplate,
|
||||||
|
getFactCard,
|
||||||
|
getLatestOptimizedArticle,
|
||||||
|
getLatestQaReport,
|
||||||
|
listBrandTemplates,
|
||||||
|
saveFactCard,
|
||||||
|
saveOptimizedArticle,
|
||||||
|
saveQaReport,
|
||||||
|
} from "../repositories";
|
||||||
|
|
||||||
|
describe("sqlite repositories", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
let dbPath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = mkdtempSync(join(tmpdir(), "geo-agent-db-"));
|
||||||
|
dbPath = join(tempDir, "test.db");
|
||||||
|
const db = createDatabase(dbPath);
|
||||||
|
initializeSchema(db);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializes all required tables", () => {
|
||||||
|
const db = createDatabase(dbPath);
|
||||||
|
const tables = db
|
||||||
|
.prepare(
|
||||||
|
"select name from sqlite_master where type = 'table' order by name",
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
.map((row) => (row as { name: string }).name);
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
expect(tables).toEqual([
|
||||||
|
"article_jobs",
|
||||||
|
"brand_templates",
|
||||||
|
"fact_cards",
|
||||||
|
"optimized_articles",
|
||||||
|
"qa_reports",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts and fetches a brand template", () => {
|
||||||
|
const created = createBrandTemplate(dbPath, {
|
||||||
|
brand_name: "Example",
|
||||||
|
company_full_name: "Example Technology Co., Ltd.",
|
||||||
|
company_short_names: ["Example Tech"],
|
||||||
|
product_names: ["Example GEO"],
|
||||||
|
target_industries: ["GEO optimization"],
|
||||||
|
target_audience: ["Marketing teams"],
|
||||||
|
verified_claims: ["Eight years of experience"],
|
||||||
|
forbidden_claims: ["Industry first"],
|
||||||
|
tone_rules: { official_site: "official voice" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getBrandTemplate(dbPath, created.id)).toMatchObject({
|
||||||
|
id: created.id,
|
||||||
|
brand_name: "Example",
|
||||||
|
company_short_names: ["Example Tech"],
|
||||||
|
});
|
||||||
|
expect(listBrandTemplates(dbPath)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts and fetches an article job", () => {
|
||||||
|
const job = createArticleJob(dbPath, {
|
||||||
|
source_title: "Original Title",
|
||||||
|
source_body: "Original body",
|
||||||
|
image_inputs: [{ type: "description", content: "Dashboard" }],
|
||||||
|
publish_platform: "official_site",
|
||||||
|
user_instructions: "Stay factual",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getArticleJob(dbPath, job.id)).toMatchObject({
|
||||||
|
id: job.id,
|
||||||
|
source_title: "Original Title",
|
||||||
|
image_inputs: [{ type: "description", content: "Dashboard" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a confirmed fact card for a job", () => {
|
||||||
|
const job = createArticleJob(dbPath, {
|
||||||
|
source_title: "Original Title",
|
||||||
|
source_body: "Original body",
|
||||||
|
image_inputs: [],
|
||||||
|
publish_platform: "media_article",
|
||||||
|
user_instructions: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
saveFactCard(dbPath, job.id, {
|
||||||
|
company_full_name: "Example Technology Co., Ltd.",
|
||||||
|
company_short_names: ["Example"],
|
||||||
|
brand_names: ["Example"],
|
||||||
|
product_names: ["Example GEO"],
|
||||||
|
target_industry: "GEO optimization",
|
||||||
|
target_audience: "Marketing teams",
|
||||||
|
experience_years: 8,
|
||||||
|
core_claims: ["Eight years of experience"],
|
||||||
|
forbidden_claims: [],
|
||||||
|
image_topics: [],
|
||||||
|
uncertain_items: [],
|
||||||
|
is_ready_for_optimization: true,
|
||||||
|
confirmed_by_user: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getFactCard(dbPath, job.id)).toMatchObject({
|
||||||
|
job_id: job.id,
|
||||||
|
company_full_name: "Example Technology Co., Ltd.",
|
||||||
|
confirmed_by_user: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments optimized article revisions", () => {
|
||||||
|
const job = createArticleJob(dbPath, {
|
||||||
|
source_title: "Original Title",
|
||||||
|
source_body: "Original body",
|
||||||
|
image_inputs: [],
|
||||||
|
publish_platform: "comparison_review",
|
||||||
|
user_instructions: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = saveOptimizedArticle(dbPath, job.id, {
|
||||||
|
title: "Optimized v1",
|
||||||
|
summary: "Summary",
|
||||||
|
body_markdown: "Body",
|
||||||
|
image_suggestions: [],
|
||||||
|
changed_sections: ["title"],
|
||||||
|
requires_user_confirmation: [],
|
||||||
|
});
|
||||||
|
const second = saveOptimizedArticle(dbPath, job.id, {
|
||||||
|
title: "Optimized v2",
|
||||||
|
summary: "Summary",
|
||||||
|
body_markdown: "Body",
|
||||||
|
image_suggestions: [],
|
||||||
|
changed_sections: ["title"],
|
||||||
|
requires_user_confirmation: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first.revision).toBe(1);
|
||||||
|
expect(second.revision).toBe(2);
|
||||||
|
expect(getLatestOptimizedArticle(dbPath, job.id)?.title).toBe(
|
||||||
|
"Optimized v2",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves and fetches the latest QA report by job and revision", () => {
|
||||||
|
const job = createArticleJob(dbPath, {
|
||||||
|
source_title: "Original Title",
|
||||||
|
source_body: "Original body",
|
||||||
|
image_inputs: [],
|
||||||
|
publish_platform: "recommendation_list",
|
||||||
|
user_instructions: "",
|
||||||
|
});
|
||||||
|
saveOptimizedArticle(dbPath, job.id, {
|
||||||
|
title: "Optimized",
|
||||||
|
summary: "Summary",
|
||||||
|
body_markdown: "Body",
|
||||||
|
image_suggestions: [],
|
||||||
|
changed_sections: [],
|
||||||
|
requires_user_confirmation: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
saveQaReport(dbPath, job.id, 1, {
|
||||||
|
overall_status: "warn",
|
||||||
|
checks: [
|
||||||
|
{
|
||||||
|
rule_id: "image_text_match",
|
||||||
|
status: "warn",
|
||||||
|
evidence: "Image confidence is low.",
|
||||||
|
reason: "Image descriptions are sparse.",
|
||||||
|
suggested_fix: "Review image placement.",
|
||||||
|
target_agent: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getLatestQaReport(dbPath, job.id)).toMatchObject({
|
||||||
|
job_id: job.id,
|
||||||
|
revision: 1,
|
||||||
|
overall_status: "warn",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
|
export function getAppDataDir() {
|
||||||
|
return process.env.APP_DATA_DIR ?? "./data";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultDatabasePath() {
|
||||||
|
return join(getAppDataDir(), "app.db");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDatabase(dbPath = getDefaultDatabasePath()) {
|
||||||
|
mkdirSync(dirname(dbPath), { recursive: true });
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
db.pragma("journal_mode = WAL");
|
||||||
|
db.pragma("foreign_keys = ON");
|
||||||
|
return db;
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ConfirmedFactCard,
|
||||||
|
ImageInput,
|
||||||
|
OptimizedArticle,
|
||||||
|
PublishPlatform,
|
||||||
|
QaReport,
|
||||||
|
} from "@/lib/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;
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
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, 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,
|
||||||
|
@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 saveFactCard(
|
||||||
|
dbPath: string | undefined,
|
||||||
|
jobId: string,
|
||||||
|
factCard: ConfirmedFactCard,
|
||||||
|
) {
|
||||||
|
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: "auto_extract_then_user_confirmed",
|
||||||
|
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<ConfirmedFactCard>(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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type Database from "better-sqlite3";
|
||||||
|
|
||||||
|
export function initializeSchema(db: Database.Database) {
|
||||||
|
db.exec(`
|
||||||
|
create table if not exists brand_templates (
|
||||||
|
id text primary key,
|
||||||
|
brand_name text not null,
|
||||||
|
company_full_name text not null,
|
||||||
|
company_short_names text not null,
|
||||||
|
product_names text not null,
|
||||||
|
target_industries text not null,
|
||||||
|
target_audience text not null,
|
||||||
|
verified_claims text not null,
|
||||||
|
forbidden_claims text not null,
|
||||||
|
tone_rules text not null,
|
||||||
|
created_at text not null,
|
||||||
|
updated_at text not null
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists article_jobs (
|
||||||
|
id text primary key,
|
||||||
|
brand_template_id text,
|
||||||
|
source_title text not null,
|
||||||
|
source_body text not null,
|
||||||
|
image_inputs text not null,
|
||||||
|
publish_platform text not null,
|
||||||
|
user_instructions text not null,
|
||||||
|
status text not null,
|
||||||
|
export_paths text not null,
|
||||||
|
created_at text not null,
|
||||||
|
updated_at text not null,
|
||||||
|
foreign key (brand_template_id) references brand_templates(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists fact_cards (
|
||||||
|
job_id text primary key,
|
||||||
|
source text not null,
|
||||||
|
fact_card text not null,
|
||||||
|
confirmed_by_user integer not null,
|
||||||
|
created_at text not null,
|
||||||
|
updated_at text not null,
|
||||||
|
foreign key (job_id) references article_jobs(id) on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists optimized_articles (
|
||||||
|
job_id text not null,
|
||||||
|
revision integer not null,
|
||||||
|
article text not null,
|
||||||
|
created_at text not null,
|
||||||
|
primary key (job_id, revision),
|
||||||
|
foreign key (job_id) references article_jobs(id) on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists qa_reports (
|
||||||
|
job_id text not null,
|
||||||
|
revision integer not null,
|
||||||
|
report text not null,
|
||||||
|
created_at text not null,
|
||||||
|
primary key (job_id, revision),
|
||||||
|
foreign key (job_id, revision)
|
||||||
|
references optimized_articles(job_id, revision) on delete cascade
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user