/** * Independent, deterministic reference implementation. Synthetic inputs only. * No network, model API, storage, randomness, or current-clock dependency. * Citation validation checks exact structured facts, not natural-language quality. */ export type Region = "us" | "cn"; export type FactKey = "price" | "connection" | "mounting"; export type Intent = "fact" | "compare" | "explain"; export type Route = "deterministic-answer" | "model-request" | "safe-fallback"; export type Product = { id: string; name: string; aliases: readonly string[] }; export type Source = { id: string; productId: string; region: Region; published: boolean; disabled?: boolean; validFrom: string; expiresAt?: string | null; facts: Partial>; }; export type Claim = { productId: string; field: FactKey; value: string; citationId: string }; export type SimulatedResponse = { claims: readonly Claim[] }; export type Input = { message: string; intent: Intent; field: FactKey; region: Region; now: string; previousProductIds: readonly string[]; providerAvailable: boolean; simulatedResponse?: SimulatedResponse; }; export type SourceReview = { source: Source; accepted: boolean; reasons: string[] }; export type CitationReview = { valid: boolean; reasons: string[] }; export type Decision = { route: Route; reason: string; products: Product[]; contextUsed: boolean; reviews: SourceReview[]; facts: Claim[]; citations: CitationReview | null; provider: "not-needed" | "not-called" | "unavailable" | "simulated-valid" | "simulated-rejected"; request: { productIds: string[]; field: FactKey; evidence: { sourceId: string; productId: string; value: string }[] } | null; }; const normalized = (value: string) => value.normalize("NFKC").toLocaleLowerCase("en-US").replace(/[^\p{L}\p{N}]+/gu, " ").trim(); /** Longest matching product aliases win; a shared family alias stays ambiguous. */ export function selectProducts(message: string, catalog: readonly Product[], previousProductIds: readonly string[], compareAcrossTurns = false) { const text = ` ${normalized(message)} `; const hits: { product: Product; start: number; end: number }[] = []; for (const product of catalog) { for (const alias of Array.from(new Set([product.name, ...product.aliases]))) { const term = ` ${normalized(alias)} `; if (term.trim().length === 0) continue; let start = text.indexOf(term); while (start !== -1) { hits.push({ product, start, end: start + term.length }); start = text.indexOf(term, start + 1); } } } const specific = hits.filter(hit => !hits.some(other => other.start <= hit.start && other.end >= hit.end && other.end - other.start > hit.end - hit.start)); const ids = Array.from(new Set(specific.map(hit => hit.product.id))); const refersBack = /\b(it|this|that|they|them|these|those|both|previous|other)\b/.test(normalized(message)); const previous = Array.from(new Set(previousProductIds)).filter(id => catalog.some(product => product.id === id)); // A new explicit selection replaces stale context. One remembered product can // supply the other side only when the user explicitly compares back to it. const addsComparisonPartner = compareAcrossTurns && previous.length === 1 && ids.length === 1 && /\b(compare|compared|comparing|with|against|versus|vs|than)\b/.test(normalized(message)); const contextUsed = refersBack && previous.length > 0 && (ids.length === 0 || addsComparisonPartner); const selectedIds = contextUsed ? Array.from(new Set([...previous, ...ids])) : ids; // A single alias mapping to multiple products must never choose the first one. const ambiguous = specific.some(hit => specific.some(other => other.start === hit.start && other.end === hit.end && other.product.id !== hit.product.id)); return { products: selectedIds.map(id => catalog.find(product => product.id === id)!), ambiguous, contextUsed }; } /** Require a real UTC calendar instant; Date.parse alone normalizes invalid dates. */ function utcInstant(value: string) { const parts = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?Z$/.exec(value); if (!parts) return NaN; const time = Date.parse(value); const date = new Date(time); const actual = [date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(), date.getUTCMilliseconds()]; const expected = parts.slice(1, 7).map(part => Number(part ?? 0)); expected.push(Number((parts[7] ?? "").padEnd(3, "0"))); return actual.every((part, index) => part === expected[index]) ? time : NaN; } /** Expiry is exclusive: a source expires exactly at expiresAt. */ export function reviewSources(sources: readonly Source[], productIds: readonly string[], input: Pick): SourceReview[] { const now = utcInstant(input.now); const counts = new Map(); for (const source of sources) counts.set(source.id, (counts.get(source.id) ?? 0) + 1); return sources.map(source => { const reasons: string[] = []; const from = utcInstant(source.validFrom); const until = source.expiresAt == null ? Infinity : utcInstant(source.expiresAt); if (!productIds.includes(source.productId)) reasons.push("different-product"); if (source.region !== input.region) reasons.push("wrong-region"); if (source.published !== true) reasons.push("unpublished"); if (source.disabled !== undefined && source.disabled !== false) reasons.push("disabled"); if (!Number.isFinite(now) || !Number.isFinite(from) || (until !== Infinity && !Number.isFinite(until)) || from >= until) reasons.push("invalid-time-window"); else { if (now < from) reasons.push("not-yet-valid"); if (now >= until) reasons.push("expired"); } if ((counts.get(source.id) ?? 0) !== 1) reasons.push("duplicate-source-id"); const value = source.facts[input.field]; if (typeof value !== "string" || !value.trim()) reasons.push("missing-requested-fact"); return { source, accepted: reasons.length === 0, reasons: reasons.length ? reasons : ["eligible"] }; }); } /** Every cited claim must match an allowed product, requested field and exact fact. */ export function validateCitations(response: SimulatedResponse, accepted: readonly Source[], productIds: readonly string[], field: FactKey): CitationReview { const reasons: string[] = []; if (!Array.isArray(response.claims) || response.claims.length === 0) return { valid: false, reasons: ["empty-claims"] }; if (response.claims.length > 32) return { valid: false, reasons: ["too-many-claims"] }; for (const claim of response.claims as readonly Claim[]) { if (!claim || typeof claim !== "object" || ![claim.productId, claim.field, claim.value, claim.citationId].every(value => typeof value === "string" && value.length > 0)) { reasons.push("malformed-claim"); continue; } const source = accepted.find(candidate => candidate.id === claim.citationId); if (!source) reasons.push("unknown-or-ineligible-citation"); if (!productIds.includes(claim.productId)) reasons.push("unselected-product"); if (claim.field !== field) reasons.push("unrequested-field"); if (source && source.productId !== claim.productId) reasons.push("citation-product-mismatch"); if (source && source.facts[claim.field] !== claim.value) reasons.push("unsupported-fact"); } for (const productId of productIds) { if (!response.claims.some(claim => claim && claim.productId === productId && claim.field === field)) reasons.push("missing-product-coverage"); } return { valid: reasons.length === 0, reasons: Array.from(new Set(reasons)) }; } /** Explanations produce an inspectable request boundary, never a live model call. */ export function evaluateGroundedAnswer(input: Input, catalog: readonly Product[], sources: readonly Source[]): Decision { const selection = selectProducts(input.message, catalog, input.previousProductIds, input.intent === "compare"); const products = selection.products; const productIds = products.map(product => product.id); const reviews = reviewSources(sources, productIds, input); const result: Decision = { route: "safe-fallback", reason: "unknown-product", products, contextUsed: selection.contextUsed, reviews, facts: [], citations: null, provider: "not-called", request: null }; if (!input.message.trim()) return { ...result, reason: "empty-question" }; if (selection.ambiguous) return { ...result, reason: "ambiguous-product-name" }; if (products.length === 0) return result; if (products.length > 2 || (input.intent !== "compare" && products.length !== 1)) return { ...result, reason: "choose-one-product" }; if (input.intent === "compare" && products.length !== 2) return { ...result, reason: "comparison-needs-two-products" }; const accepted = reviews.filter(review => review.accepted).map(review => review.source); if (products.some(product => !accepted.some(source => source.productId === product.id))) return { ...result, reason: "no-eligible-evidence" }; for (const product of products) { const values = new Set(accepted.filter(source => source.productId === product.id).map(source => source.facts[input.field])); if (values.size !== 1) return { ...result, reason: "conflicting-evidence" }; } const facts = products.map(product => { const source = accepted.filter(candidate => candidate.productId === product.id).sort((a, b) => a.id.localeCompare(b.id))[0]; return { productId: product.id, field: input.field, value: source.facts[input.field]!, citationId: source.id }; }); if (input.intent !== "explain") return { ...result, route: "deterministic-answer", reason: input.intent === "compare" ? "verified-comparison" : "verified-fact", facts, provider: "not-needed" }; if (input.providerAvailable !== true) return { ...result, reason: "provider-unavailable", provider: "unavailable" }; const request = { productIds, field: input.field, evidence: accepted.map(source => ({ sourceId: source.id, productId: source.productId, value: source.facts[input.field]! })) }; if (!input.simulatedResponse) return { ...result, route: "model-request", reason: "eligible-model-request", request }; const citations = validateCitations(input.simulatedResponse, accepted, productIds, input.field); if (!citations.valid) return { ...result, reason: "invalid-simulated-citations", provider: "simulated-rejected", request, citations }; return { ...result, route: "model-request", reason: "validated-simulated-claims", provider: "simulated-valid", request, facts: [...input.simulatedResponse.claims], citations }; }