feat: define optimizer domain model
This commit is contained in:
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
articleInputSchema,
|
||||||
|
confirmedFactCardSchema,
|
||||||
|
candidateFactCardSchema,
|
||||||
|
qaReportSchema,
|
||||||
|
} from "../validation";
|
||||||
|
|
||||||
|
describe("domain validation", () => {
|
||||||
|
it("accepts article input with title, body, images, platform, and instructions", () => {
|
||||||
|
const parsed = articleInputSchema.parse({
|
||||||
|
title: "How GEO Optimization Improves Brand Visibility",
|
||||||
|
body: "A practical overview of GEO optimization for marketing teams.",
|
||||||
|
images: [
|
||||||
|
{ type: "description", content: "Dashboard screenshot" },
|
||||||
|
{ type: "link", content: "https://example.com/image.png" },
|
||||||
|
],
|
||||||
|
platform: "official_site",
|
||||||
|
user_instructions: "Keep the article factual and concise.",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.images).toHaveLength(2);
|
||||||
|
expect(parsed.platform).toBe("official_site");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a fact card with unresolved uncertain items as not ready for optimization", () => {
|
||||||
|
const parsed = candidateFactCardSchema.parse({
|
||||||
|
company_full_name: "Example Technology Co., Ltd.",
|
||||||
|
company_short_names: ["Example Tech"],
|
||||||
|
brand_names: ["Example"],
|
||||||
|
product_names: ["Example GEO"],
|
||||||
|
target_industry: "GEO optimization",
|
||||||
|
target_audience: "Marketing teams",
|
||||||
|
experience_years: 8,
|
||||||
|
core_claims: ["Eight years of GEO optimization experience"],
|
||||||
|
forbidden_claims: [],
|
||||||
|
image_topics: ["Product dashboard"],
|
||||||
|
uncertain_items: ["Conflicting product names found"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.is_ready_for_optimization).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a confirmed fact card with an empty company full name", () => {
|
||||||
|
expect(() =>
|
||||||
|
confirmedFactCardSchema.parse({
|
||||||
|
company_full_name: "",
|
||||||
|
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 GEO optimization experience"],
|
||||||
|
forbidden_claims: [],
|
||||||
|
image_topics: [],
|
||||||
|
uncertain_items: [],
|
||||||
|
confirmed_by_user: true,
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts QA reports only with pass, warn, or fail statuses", () => {
|
||||||
|
const valid = qaReportSchema.parse({
|
||||||
|
job_id: "job_123",
|
||||||
|
revision: 1,
|
||||||
|
overall_status: "warn",
|
||||||
|
checks: [
|
||||||
|
{
|
||||||
|
rule_id: "title_quality",
|
||||||
|
status: "pass",
|
||||||
|
evidence: "Title reads naturally.",
|
||||||
|
reason: "No grammar issue detected.",
|
||||||
|
suggested_fix: "",
|
||||||
|
target_agent: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(valid.checks[0]?.status).toBe("pass");
|
||||||
|
expect(() =>
|
||||||
|
qaReportSchema.parse({
|
||||||
|
job_id: "job_123",
|
||||||
|
revision: 1,
|
||||||
|
overall_status: "blocked",
|
||||||
|
checks: [],
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
export type PublishPlatform =
|
||||||
|
| "official_site"
|
||||||
|
| "media_article"
|
||||||
|
| "comparison_review"
|
||||||
|
| "recommendation_list";
|
||||||
|
|
||||||
|
export type CheckStatus = "pass" | "warn" | "fail";
|
||||||
|
|
||||||
|
export type QualityRuleId =
|
||||||
|
| "industry_alignment"
|
||||||
|
| "image_text_match"
|
||||||
|
| "voice_consistency"
|
||||||
|
| "platform_fit"
|
||||||
|
| "company_name_integrity"
|
||||||
|
| "title_quality"
|
||||||
|
| "body_quality"
|
||||||
|
| "hallucination_risk"
|
||||||
|
| "claim_consistency"
|
||||||
|
| "context_sensitive_terms";
|
||||||
|
|
||||||
|
export interface ImageInput {
|
||||||
|
type: "description" | "link";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArticleInput {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
images: ImageInput[];
|
||||||
|
platform: PublishPlatform;
|
||||||
|
user_instructions: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CandidateFactCard {
|
||||||
|
company_full_name: string;
|
||||||
|
company_short_names: string[];
|
||||||
|
brand_names: string[];
|
||||||
|
product_names: string[];
|
||||||
|
target_industry: string;
|
||||||
|
target_audience: string;
|
||||||
|
experience_years: number | null;
|
||||||
|
core_claims: string[];
|
||||||
|
forbidden_claims: string[];
|
||||||
|
image_topics: string[];
|
||||||
|
uncertain_items: string[];
|
||||||
|
is_ready_for_optimization: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmedFactCard extends CandidateFactCard {
|
||||||
|
confirmed_by_user: true;
|
||||||
|
is_ready_for_optimization: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageSuggestion {
|
||||||
|
source: string;
|
||||||
|
suggestion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OptimizedArticle {
|
||||||
|
job_id?: string;
|
||||||
|
revision?: number;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
body_markdown: string;
|
||||||
|
image_suggestions: ImageSuggestion[];
|
||||||
|
changed_sections: string[];
|
||||||
|
requires_user_confirmation: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QaCheck {
|
||||||
|
rule_id: QualityRuleId;
|
||||||
|
status: CheckStatus;
|
||||||
|
evidence: string;
|
||||||
|
reason: string;
|
||||||
|
suggested_fix: string;
|
||||||
|
target_agent: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QaReport {
|
||||||
|
job_id?: string;
|
||||||
|
revision?: number;
|
||||||
|
overall_status: CheckStatus;
|
||||||
|
checks: QaCheck[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ArticleInput,
|
||||||
|
CandidateFactCard,
|
||||||
|
CheckStatus,
|
||||||
|
ConfirmedFactCard,
|
||||||
|
ImageInput,
|
||||||
|
OptimizedArticle,
|
||||||
|
PublishPlatform,
|
||||||
|
QaCheck,
|
||||||
|
QaReport,
|
||||||
|
QualityRuleId,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export const publishPlatformSchema = z.enum([
|
||||||
|
"official_site",
|
||||||
|
"media_article",
|
||||||
|
"comparison_review",
|
||||||
|
"recommendation_list",
|
||||||
|
]) satisfies z.ZodType<PublishPlatform>;
|
||||||
|
|
||||||
|
export const checkStatusSchema = z.enum([
|
||||||
|
"pass",
|
||||||
|
"warn",
|
||||||
|
"fail",
|
||||||
|
]) satisfies z.ZodType<CheckStatus>;
|
||||||
|
|
||||||
|
export const qualityRuleIdSchema = z.enum([
|
||||||
|
"industry_alignment",
|
||||||
|
"image_text_match",
|
||||||
|
"voice_consistency",
|
||||||
|
"platform_fit",
|
||||||
|
"company_name_integrity",
|
||||||
|
"title_quality",
|
||||||
|
"body_quality",
|
||||||
|
"hallucination_risk",
|
||||||
|
"claim_consistency",
|
||||||
|
"context_sensitive_terms",
|
||||||
|
]) satisfies z.ZodType<QualityRuleId>;
|
||||||
|
|
||||||
|
export const imageInputSchema = z.object({
|
||||||
|
type: z.enum(["description", "link"]),
|
||||||
|
content: z.string().trim().min(1),
|
||||||
|
}) satisfies z.ZodType<ImageInput>;
|
||||||
|
|
||||||
|
export const articleInputSchema = z.object({
|
||||||
|
title: z.string().trim().min(1),
|
||||||
|
body: z.string().trim().min(1),
|
||||||
|
images: z.array(imageInputSchema).default([]),
|
||||||
|
platform: publishPlatformSchema,
|
||||||
|
user_instructions: z.string().trim().default(""),
|
||||||
|
}) satisfies z.ZodType<ArticleInput>;
|
||||||
|
|
||||||
|
const factCardBaseSchema = z.object({
|
||||||
|
company_full_name: z.string().trim(),
|
||||||
|
company_short_names: z.array(z.string().trim().min(1)).default([]),
|
||||||
|
brand_names: z.array(z.string().trim().min(1)).default([]),
|
||||||
|
product_names: z.array(z.string().trim().min(1)).default([]),
|
||||||
|
target_industry: z.string().trim().min(1),
|
||||||
|
target_audience: z.string().trim().min(1),
|
||||||
|
experience_years: z.number().int().nonnegative().nullable().default(null),
|
||||||
|
core_claims: z.array(z.string().trim().min(1)).default([]),
|
||||||
|
forbidden_claims: z.array(z.string().trim().min(1)).default([]),
|
||||||
|
image_topics: z.array(z.string().trim().min(1)).default([]),
|
||||||
|
uncertain_items: z.array(z.string().trim().min(1)).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const candidateFactCardSchema = factCardBaseSchema
|
||||||
|
.extend({
|
||||||
|
is_ready_for_optimization: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.transform((card) => ({
|
||||||
|
...card,
|
||||||
|
is_ready_for_optimization: card.uncertain_items.length === 0,
|
||||||
|
})) satisfies z.ZodType<CandidateFactCard>;
|
||||||
|
|
||||||
|
export const confirmedFactCardSchema = candidateFactCardSchema
|
||||||
|
.pipe(
|
||||||
|
z.object({
|
||||||
|
company_full_name: z.string().trim().min(1),
|
||||||
|
company_short_names: z.array(z.string().trim().min(1)).default([]),
|
||||||
|
brand_names: z.array(z.string().trim().min(1)).default([]),
|
||||||
|
product_names: z.array(z.string().trim().min(1)).default([]),
|
||||||
|
target_industry: z.string().trim().min(1),
|
||||||
|
target_audience: z.string().trim().min(1),
|
||||||
|
experience_years: z.number().int().nonnegative().nullable().default(null),
|
||||||
|
core_claims: z.array(z.string().trim().min(1)).default([]),
|
||||||
|
forbidden_claims: z.array(z.string().trim().min(1)).default([]),
|
||||||
|
image_topics: z.array(z.string().trim().min(1)).default([]),
|
||||||
|
uncertain_items: z.array(z.never()).length(0),
|
||||||
|
is_ready_for_optimization: z.literal(true),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.and(z.object({ confirmed_by_user: z.literal(true) })) satisfies z.ZodType<ConfirmedFactCard>;
|
||||||
|
|
||||||
|
export const imageSuggestionSchema = z.object({
|
||||||
|
source: z.string().trim().min(1),
|
||||||
|
suggestion: z.string().trim().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const optimizedArticleSchema = z.object({
|
||||||
|
job_id: z.string().trim().min(1).optional(),
|
||||||
|
revision: z.number().int().positive().optional(),
|
||||||
|
title: z.string().trim().min(1),
|
||||||
|
summary: z.string().trim().min(1),
|
||||||
|
body_markdown: z.string().trim().min(1),
|
||||||
|
image_suggestions: z.array(imageSuggestionSchema).default([]),
|
||||||
|
changed_sections: z.array(z.string().trim().min(1)).default([]),
|
||||||
|
requires_user_confirmation: z.array(z.string().trim().min(1)).default([]),
|
||||||
|
}) satisfies z.ZodType<OptimizedArticle>;
|
||||||
|
|
||||||
|
export const qaCheckSchema = z.object({
|
||||||
|
rule_id: qualityRuleIdSchema,
|
||||||
|
status: checkStatusSchema,
|
||||||
|
evidence: z.string().trim().min(1),
|
||||||
|
reason: z.string().trim().min(1),
|
||||||
|
suggested_fix: z.string().trim().default(""),
|
||||||
|
target_agent: z.string().trim().min(1).nullable().default(null),
|
||||||
|
}) satisfies z.ZodType<QaCheck>;
|
||||||
|
|
||||||
|
export const qaReportSchema = z.object({
|
||||||
|
job_id: z.string().trim().min(1).optional(),
|
||||||
|
revision: z.number().int().positive().optional(),
|
||||||
|
overall_status: checkStatusSchema,
|
||||||
|
checks: z.array(qaCheckSchema).default([]),
|
||||||
|
}) satisfies z.ZodType<QaReport>;
|
||||||
Reference in New Issue
Block a user