b1b4f5fac9
Dated family evidence now moves candidates via D12 and kin houses, career receipts expose both D1-10 and D10, and appearance/marks may be asked as auxiliary first-house scores. New cases bind Skill 10.0.4. Co-authored-by: Cursor <cursoragent@cursor.com>
207 lines
5.6 KiB
TypeScript
207 lines
5.6 KiB
TypeScript
/**
|
||
* 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<string>(EVIDENCE_KINDS);
|
||
const DOMAIN_SET = new Set<string>(EVIDENCE_DOMAINS);
|
||
const PRECISION_SET = new Set<string>(DATE_PRECISIONS);
|
||
const STATUS_SET = new Set<string>(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<EvidenceStatus, readonly EvidenceStatus[]>
|
||
> = {
|
||
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<Record<string, number>> = {
|
||
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<EvidenceKind> = 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";
|
||
}
|