/** * V9 evidence model contracts: event kinds, domains, date precision and the * append-only revision/status machine. IDs are always generated by the server. */ export const EVIDENCE_KINDS = [ "education_start", "education_completion", "education_interruption", "education_change", "education_milestone", "career_entry", "career_change", "promotion", "career_pressure", "career_exit", "business_start", "relationship_start", "relationship_commitment", "relationship_separation", "relationship_end", "relationship_change", "relocation", "foreign_move", "return", "home_change", "finance_gain", "finance_loss", "income_change", "asset_change", "finance_change", "self_health_event", "pressure_period", "family_event", "appearance_note", "birthmark_or_scar", "other", ] as const; export type EvidenceKind = (typeof EVIDENCE_KINDS)[number]; export const EVIDENCE_DOMAINS = [ "education", "career", "relationship", "relocation", "finance", "health", "health_pressure", "family", "appearance", "marks", "other", ] as const; export type EvidenceDomain = (typeof EVIDENCE_DOMAINS)[number]; export const DATE_PRECISIONS = [ "year", "month", "quarter", "day", "range", "unknown", ] as const; export type DatePrecision = (typeof DATE_PRECISIONS)[number]; export const EVIDENCE_STATUSES = [ "draft", "pending_confirmation", "confirmed", "superseded", "rejected", ] as const; export type EvidenceStatus = (typeof EVIDENCE_STATUSES)[number]; const KIND_SET = new Set(EVIDENCE_KINDS); const DOMAIN_SET = new Set(EVIDENCE_DOMAINS); const PRECISION_SET = new Set(DATE_PRECISIONS); const STATUS_SET = new Set(EVIDENCE_STATUSES); export function isEvidenceKind(value: unknown): value is EvidenceKind { return typeof value === "string" && KIND_SET.has(value); } export function isEvidenceDomain(value: unknown): value is EvidenceDomain { return typeof value === "string" && DOMAIN_SET.has(value); } export function isDatePrecision(value: unknown): value is DatePrecision { return typeof value === "string" && PRECISION_SET.has(value); } export function isEvidenceStatus(value: unknown): value is EvidenceStatus { return typeof value === "string" && STATUS_SET.has(value); } /** * Kinds that are semantically distinct and must never be folded together. * The scoring path keys on event_kind, so collapsing these would corrupt * both the ledger and the candidate contrast. */ export const DISTINCT_KIND_GROUPS: readonly (readonly EvidenceKind[])[] = [ ["career_entry", "career_pressure", "career_exit"], ["relationship_start", "relationship_commitment", "relationship_separation"], ]; /** * Legal evidence status transitions. Only the server confirmation path may * produce `confirmed`; a grounded draft may use that server path in the same run. */ export const EVIDENCE_STATUS_TRANSITIONS: Readonly< Record > = { draft: ["pending_confirmation", "confirmed", "rejected", "superseded"], pending_confirmation: ["confirmed", "rejected", "superseded"], confirmed: ["superseded"], superseded: [], rejected: [], }; export function canTransitEvidenceStatus( from: EvidenceStatus, to: EvidenceStatus, ): boolean { return EVIDENCE_STATUS_TRANSITIONS[from].includes(to); } /** * Normalized quote grounding contract: a user_quote is accepted only when it * is a substring match after whitespace/punctuation normalization of the * source turn's user message. */ const QUOTE_PUNCTUATION = /[\s\u3000,。!?、;:“”‘’()《》·—…,!.;:?]/g; export function normalizeQuote(value: string): string { return value.replace(QUOTE_PUNCTUATION, "").toLowerCase(); } const PRECISION_RANK: Readonly> = { day: 4, month: 3, quarter: 2, range: 2, year: 1, unknown: 0, }; export function datePrecisionRank(precision: string): number { return PRECISION_RANK[precision] ?? -1; } /** * Agent-facing date label. Day precision must never collapse to a year. */ export function displayDateLabel( precision: string, occurredFrom: string | null, occurredTo: string | null, ): string { const from = occurredFrom?.slice(0, 10) ?? ""; const to = occurredTo?.slice(0, 10) ?? ""; if (precision === "day" && from) return from; if (precision === "month" && from) return from.slice(0, 7); if (precision === "year" && from) return `${from.slice(0, 4)}年`; if (precision === "quarter" && from) return from.slice(0, 7); if (precision === "range") { if (from && to) return `${from}–${to}`; return from || to || "日期范围"; } if (precision === "unknown") return "日期不明"; return from || "日期不明"; } export function quoteIsGroundedInMessage( userMessage: string, quote: string, ): boolean { const normalizedMessage = normalizeQuote(userMessage); const normalizedQuote = normalizeQuote(quote); return ( normalizedQuote.length > 0 && normalizedMessage.includes(normalizedQuote) ); } /** Background kinds that never advance scoring coverage counts. */ export const BACKGROUND_ONLY_KINDS: ReadonlySet = new Set([ "other", ]); export function isBackgroundEvidenceKind(kind: EvidenceKind): boolean { return BACKGROUND_ONLY_KINDS.has(kind); } /** Ledger/engine subject follows the domain. Family events must not stay on the tool default `self`. */ export function evidenceSubjectForDomain( domain: string, _subject?: string | null, ): "self" | "family" | "other" { if (domain === "family") return "family"; if (domain === "other") return "other"; return "self"; }