Skip to content

Commit 78eefb2

Browse files
kahboomclaude
andcommitted
feat: add data-driven rule engine for field detection and claim scanning (F030)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent be2234c commit 78eefb2

3 files changed

Lines changed: 373 additions & 1 deletion

File tree

PRD.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,8 @@
165165
"id": "F030",
166166
"phase": 1,
167167
"name": "Rule Engine v1",
168-
"description": "Data-driven JSON rules"
168+
"description": "Data-driven JSON rules",
169+
"status": "passes"
169170
},
170171
{
171172
"id": "F040",

src/rules/engine.test.ts

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { describe, it, expect } from "vitest";
2+
import type { PageSnapshot } from "../content/snapshot.js";
3+
import {
4+
detectField,
5+
evaluateFields,
6+
detectClaims,
7+
runRules,
8+
} from "./engine.js";
9+
10+
function makeSnapshot(overrides: Partial<PageSnapshot> = {}): PageSnapshot {
11+
return {
12+
url: "https://example.com/product",
13+
title: "Test Product",
14+
timestamp: new Date().toISOString(),
15+
meta: {},
16+
textContent: "",
17+
skuHints: [],
18+
...overrides,
19+
};
20+
}
21+
22+
describe("detectField", () => {
23+
it("finds a field via meta tag", () => {
24+
const result = detectField("product_name", "", { "og:title": "Widget X" });
25+
expect(result.status).toBe("found");
26+
expect(result.value).toBe("Widget X");
27+
expect(result.confidence).toBe(0.9);
28+
});
29+
30+
it("finds a field via text pattern", () => {
31+
const result = detectField(
32+
"country_of_origin",
33+
"This product is Made in USA with care",
34+
{},
35+
);
36+
expect(result.status).toBe("found");
37+
expect(result.value).toBeDefined();
38+
expect(result.confidence).toBe(0.7);
39+
});
40+
41+
it("returns missing when field not found", () => {
42+
const result = detectField("manufacturer_address", "Nothing here", {});
43+
expect(result.status).toBe("missing");
44+
expect(result.confidence).toBe(1.0);
45+
expect(result.value).toBeUndefined();
46+
});
47+
48+
it("prefers meta over text when both present", () => {
49+
const result = detectField("brand", "Brand: TextBrand", {
50+
"og:brand": "MetaBrand",
51+
});
52+
expect(result.value).toBe("MetaBrand");
53+
expect(result.confidence).toBe(0.9);
54+
});
55+
56+
it("detects email in contact field", () => {
57+
const result = detectField(
58+
"contact_email_or_url",
59+
"Reach us at help@example.com for support",
60+
{},
61+
);
62+
expect(result.status).toBe("found");
63+
});
64+
65+
it("detects warnings", () => {
66+
const result = detectField(
67+
"warnings",
68+
"WARNING: This product contains chemicals known to cause harm",
69+
{},
70+
);
71+
expect(result.status).toBe("found");
72+
});
73+
74+
it("detects materials", () => {
75+
const result = detectField(
76+
"materials",
77+
"Materials: 100% organic cotton",
78+
{},
79+
);
80+
expect(result.status).toBe("found");
81+
expect(result.value).toContain("cotton");
82+
});
83+
84+
it("detects care instructions", () => {
85+
const result = detectField(
86+
"care_instructions",
87+
"Care instructions: Machine wash cold, tumble dry low",
88+
{},
89+
);
90+
expect(result.status).toBe("found");
91+
});
92+
93+
it("detects certifications", () => {
94+
const result = detectField(
95+
"certifications",
96+
"Certified by OEKO-TEX Standard 100",
97+
{},
98+
);
99+
expect(result.status).toBe("found");
100+
});
101+
});
102+
103+
describe("evaluateFields", () => {
104+
it("returns results for all 12 defined fields", () => {
105+
const snapshot = makeSnapshot();
106+
const results = evaluateFields(snapshot, "general");
107+
expect(results).toHaveLength(12);
108+
});
109+
110+
it("marks fields as found when text matches", () => {
111+
const snapshot = makeSnapshot({
112+
textContent:
113+
"Brand: Acme Corp. Materials: 100% cotton. Made in Portugal. Warning: Keep away from fire.",
114+
meta: { "og:title": "Acme T-Shirt" },
115+
});
116+
const results = evaluateFields(snapshot, "textiles");
117+
const found = results.filter((r) => r.status === "found");
118+
expect(found.length).toBeGreaterThanOrEqual(4);
119+
120+
const productName = results.find((r) => r.key === "product_name");
121+
expect(productName?.status).toBe("found");
122+
123+
const brand = results.find((r) => r.key === "brand");
124+
expect(brand?.status).toBe("found");
125+
});
126+
127+
it("marks all fields missing for empty snapshot", () => {
128+
const snapshot = makeSnapshot();
129+
const results = evaluateFields(snapshot, "general");
130+
const missing = results.filter((r) => r.status === "missing");
131+
expect(missing).toHaveLength(12);
132+
});
133+
134+
it("preserves group and required from field definitions", () => {
135+
const snapshot = makeSnapshot();
136+
const results = evaluateFields(snapshot, "general");
137+
138+
const productName = results.find((r) => r.key === "product_name");
139+
expect(productName?.group).toBe("Identity & Contacts");
140+
expect(productName?.required).toBe(true);
141+
142+
const materials = results.find((r) => r.key === "materials");
143+
expect(materials?.group).toBe("Composition & Origin");
144+
expect(materials?.required).toBe(false);
145+
});
146+
});
147+
148+
describe("detectClaims", () => {
149+
it("flags eco-friendly as high risk", () => {
150+
const snapshot = makeSnapshot({
151+
textContent: "Our eco-friendly product is made with care",
152+
});
153+
const claims = detectClaims(snapshot);
154+
expect(claims).toHaveLength(1);
155+
expect(claims[0].claim).toBe("eco-friendly");
156+
expect(claims[0].riskLevel).toBe("high");
157+
expect(claims[0].source).toBeTruthy();
158+
});
159+
160+
it("flags multiple claims", () => {
161+
const snapshot = makeSnapshot({
162+
textContent:
163+
"This sustainable, biodegradable, and organic product is vegan",
164+
});
165+
const claims = detectClaims(snapshot);
166+
expect(claims.length).toBeGreaterThanOrEqual(4);
167+
const claimNames = claims.map((c) => c.claim);
168+
expect(claimNames).toContain("sustainable");
169+
expect(claimNames).toContain("biodegradable");
170+
expect(claimNames).toContain("organic");
171+
expect(claimNames).toContain("vegan");
172+
});
173+
174+
it("returns empty for text without risky claims", () => {
175+
const snapshot = makeSnapshot({
176+
textContent: "A regular product description with no special claims",
177+
});
178+
expect(detectClaims(snapshot)).toEqual([]);
179+
});
180+
181+
it("is case-insensitive", () => {
182+
const snapshot = makeSnapshot({
183+
textContent: "ECO-FRIENDLY and SUSTAINABLE materials",
184+
});
185+
const claims = detectClaims(snapshot);
186+
expect(claims.length).toBeGreaterThanOrEqual(2);
187+
});
188+
189+
it("includes surrounding context as source", () => {
190+
const snapshot = makeSnapshot({
191+
textContent: "We are proud to offer a non-toxic cleaning solution",
192+
});
193+
const claims = detectClaims(snapshot);
194+
const nonToxic = claims.find((c) => c.claim === "non-toxic");
195+
expect(nonToxic?.source).toContain("non-toxic");
196+
expect(nonToxic?.source?.length).toBeGreaterThan(10);
197+
});
198+
});
199+
200+
describe("runRules", () => {
201+
it("returns both fields and claims", () => {
202+
const snapshot = makeSnapshot({
203+
textContent:
204+
"Brand: TestCo. Materials: recycled plastic. This eco-friendly product is non-toxic.",
205+
meta: { "og:title": "TestCo Green Widget" },
206+
});
207+
const result = runRules(snapshot, "general");
208+
209+
expect(result.fields).toHaveLength(12);
210+
expect(result.claims.length).toBeGreaterThanOrEqual(2);
211+
212+
const brand = result.fields.find((f) => f.key === "brand");
213+
expect(brand?.status).toBe("found");
214+
});
215+
216+
it("works with empty snapshot", () => {
217+
const snapshot = makeSnapshot();
218+
const result = runRules(snapshot, "general");
219+
expect(result.fields).toHaveLength(12);
220+
expect(result.claims).toHaveLength(0);
221+
});
222+
});

src/rules/engine.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import type { PageSnapshot } from "../content/snapshot.js";
2+
import type {
3+
ProductCategory,
4+
FieldResult,
5+
FieldStatus,
6+
ClaimFlag,
7+
} from "../types/scan.js";
8+
import { FIELD_GROUPS } from "./field-groups.js";
9+
import { CLAIM_KEYWORDS } from "./claim-keywords.js";
10+
11+
const FIELD_SEARCH_PATTERNS: Record<string, RegExp[]> = {
12+
product_name: [
13+
/(?:product\s*name|item\s*name)[:\s]+(.+?)(?:\n|$)/i,
14+
/(?:og:title|twitter:title)/i,
15+
],
16+
brand: [
17+
/(?:brand|manufacturer)[:\s]+(.+?)(?:\n|$)/i,
18+
/(?:og:brand|product:brand)/i,
19+
],
20+
manufacturer_name: [
21+
/(?:manufacturer|made\s*by|produced\s*by)[:\s]+(.+?)(?:\n|$)/i,
22+
],
23+
manufacturer_address: [
24+
/(?:manufacturer\s*address|company\s*address|business\s*address)[:\s]+(.+?)(?:\n|$)/i,
25+
],
26+
contact_email_or_url: [
27+
/(?:contact\s*us|customer\s*service|support|email)[:\s]+(.+?)(?:\n|$)/i,
28+
/[\w.-]+@[\w.-]+\.\w{2,}/i,
29+
],
30+
materials: [
31+
/(?:materials?|composition|made\s*(?:from|of|with)|fabric|ingredients?)[:\s]+(.+?)(?:\n|$)/i,
32+
],
33+
country_of_origin: [
34+
/(?:country\s*of\s*origin|made\s*in|manufactured\s*in|origin|product\s*of)[:\s]+(.+?)(?:\n|$)/i,
35+
],
36+
warnings: [
37+
/(?:warning|caution|danger|hazard|prop\s*65|)[:\s]+(.+?)(?:\n|$)/i,
38+
],
39+
instructions: [
40+
/(?:instructions?|directions?|how\s*to\s*use|usage)[:\s]+(.+?)(?:\n|$)/i,
41+
],
42+
care_instructions: [
43+
/(?:care\s*instructions?|wash|cleaning|maintenance)[:\s]+(.+?)(?:\n|$)/i,
44+
],
45+
marketing_claims: [/(?:features?|benefits?|highlights?)[:\s]+(.+?)(?:\n|$)/i],
46+
certifications: [
47+
/(?:certif(?:ied|ication)|certified\s*by|compliant|approved\s*by|tested\s*by)[:\s]+(.+?)(?:\n|$)/i,
48+
],
49+
};
50+
51+
const META_KEY_MAP: Record<string, string[]> = {
52+
product_name: ["og:title", "twitter:title", "product:name", "name"],
53+
brand: ["og:brand", "product:brand", "brand"],
54+
materials: ["product:material"],
55+
country_of_origin: ["product:origin", "og:country-name"],
56+
};
57+
58+
export function detectField(
59+
key: string,
60+
text: string,
61+
meta: Record<string, string>,
62+
): { status: FieldStatus; value?: string; confidence: number } {
63+
// Check meta tags first
64+
const metaKeys = META_KEY_MAP[key] || [];
65+
for (const mk of metaKeys) {
66+
if (meta[mk]) {
67+
return { status: "found", value: meta[mk], confidence: 0.9 };
68+
}
69+
}
70+
71+
// Check text content with patterns
72+
const patterns = FIELD_SEARCH_PATTERNS[key] || [];
73+
for (const pattern of patterns) {
74+
const match = text.match(pattern);
75+
if (match) {
76+
const value = match[1]?.trim() || match[0].trim();
77+
return { status: "found", value, confidence: 0.7 };
78+
}
79+
}
80+
81+
return { status: "missing", confidence: 1.0 };
82+
}
83+
84+
export function evaluateFields(
85+
snapshot: PageSnapshot,
86+
_category: ProductCategory,
87+
): FieldResult[] {
88+
const results: FieldResult[] = [];
89+
90+
for (const group of FIELD_GROUPS) {
91+
for (const field of group.fields) {
92+
const detection = detectField(
93+
field.key,
94+
snapshot.textContent,
95+
snapshot.meta,
96+
);
97+
results.push({
98+
key: field.key,
99+
group: group.group,
100+
required: field.required,
101+
status: detection.status,
102+
value: detection.value,
103+
confidence: detection.confidence,
104+
});
105+
}
106+
}
107+
108+
return results;
109+
}
110+
111+
export function detectClaims(snapshot: PageSnapshot): ClaimFlag[] {
112+
const flags: ClaimFlag[] = [];
113+
const lowerText = snapshot.textContent.toLowerCase();
114+
115+
for (const kw of CLAIM_KEYWORDS) {
116+
const pattern = new RegExp(`\\b${escapeRegex(kw.pattern)}\\b`, "gi");
117+
const match = lowerText.match(pattern);
118+
if (match) {
119+
// Extract surrounding context as source
120+
const idx = lowerText.indexOf(kw.pattern.toLowerCase());
121+
const start = Math.max(0, idx - 40);
122+
const end = Math.min(lowerText.length, idx + kw.pattern.length + 40);
123+
const source = snapshot.textContent.slice(start, end).trim();
124+
125+
flags.push({
126+
claim: kw.pattern,
127+
riskLevel: kw.riskLevel,
128+
evidenceRequired: kw.evidenceRequired,
129+
source,
130+
});
131+
}
132+
}
133+
134+
return flags;
135+
}
136+
137+
function escapeRegex(str: string): string {
138+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
139+
}
140+
141+
export function runRules(
142+
snapshot: PageSnapshot,
143+
category: ProductCategory,
144+
): { fields: FieldResult[]; claims: ClaimFlag[] } {
145+
return {
146+
fields: evaluateFields(snapshot, category),
147+
claims: detectClaims(snapshot),
148+
};
149+
}

0 commit comments

Comments
 (0)