feat: add sqlite persistence

This commit is contained in:
Codex
2026-06-21 23:42:37 +08:00
parent 8c24949bfa
commit cb56ff9ee7
4 changed files with 633 additions and 0 deletions
+197
View File
@@ -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",
});
});
});
+20
View File
@@ -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;
}
+352
View File
@@ -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;
});
}
+64
View File
@@ -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
);
`);
}