Stage 4 / Wave 18 закрыт blocker-pack по временному якорю, полярности домена и допустимости evidence
This commit is contained in:
@@ -0,0 +1,673 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import type { UnifiedRetrievalResult } from "../types/assistant";
|
||||
import type { EvidenceItem, EvidenceSourceNamespace } from "../types/stage1Contracts";
|
||||
import type { CompanyAnchorSet } from "./companyAnchorResolver";
|
||||
|
||||
export type ClaimType =
|
||||
| "prove_settlement_closure_state"
|
||||
| "prove_advance_offset_state"
|
||||
| "prove_vat_chain_completeness"
|
||||
| "prove_month_close_state"
|
||||
| "prove_rbp_tail_state";
|
||||
|
||||
export type ContextExpansionReason =
|
||||
| "prehistory"
|
||||
| "carryover"
|
||||
| "post_period_closure"
|
||||
| "long_running_contract_context";
|
||||
|
||||
export interface TemporalWindow {
|
||||
from: string;
|
||||
to: string;
|
||||
granularity: "day" | "month";
|
||||
}
|
||||
|
||||
export interface ClaimBoundAnchorAudit {
|
||||
claim_type: ClaimType;
|
||||
required_anchors: string[];
|
||||
resolved_anchors: Record<string, string[]>;
|
||||
missing_anchors: string[];
|
||||
claim_anchor_resolution_rate: number;
|
||||
primary_period: TemporalWindow | null;
|
||||
allowed_context_window: TemporalWindow | null;
|
||||
context_expansion_reasons_allowed: ContextExpansionReason[];
|
||||
reason_codes: string[];
|
||||
}
|
||||
|
||||
export interface TargetedEvidenceAcquisitionAudit {
|
||||
claim_type: ClaimType;
|
||||
required_checks: string[];
|
||||
check_status: Record<string, "found" | "not_found">;
|
||||
targeted_item_hits: number;
|
||||
targeted_evidence_hits: number;
|
||||
targeted_evidence_hit_rate: number;
|
||||
targeted_evidence_source_refs: string[];
|
||||
reason_codes: string[];
|
||||
}
|
||||
|
||||
interface ContextExpansionDecision {
|
||||
allowed: boolean;
|
||||
reason: ContextExpansionReason | null;
|
||||
inside_primary_period: boolean;
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
return Array.from(new Set(values.map((item) => String(item ?? "").trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function toObject(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeTwoDigits(value: string): string {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
function normalizeDateIso(value: string): string | null {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const isoDay = raw.match(/\b(20\d{2})[-/.](0?[1-9]|1[0-2])[-/.](0?[1-9]|[12]\d|3[01])\b/);
|
||||
if (isoDay) {
|
||||
return `${isoDay[1]}-${normalizeTwoDigits(isoDay[2])}-${normalizeTwoDigits(isoDay[3])}`;
|
||||
}
|
||||
const isoMonth = raw.match(/\b(20\d{2})[-/.](0?[1-9]|1[0-2])\b/);
|
||||
if (isoMonth) {
|
||||
return `${isoMonth[1]}-${normalizeTwoDigits(isoMonth[2])}-01`;
|
||||
}
|
||||
const localDay = raw.match(/\b(0?[1-9]|[12]\d|3[01])[./-](0?[1-9]|1[0-2])[./-](\d{2}|\d{4})\b/);
|
||||
if (localDay) {
|
||||
const year = localDay[3].length === 2 ? `20${localDay[3]}` : localDay[3];
|
||||
return `${year}-${normalizeTwoDigits(localDay[2])}-${normalizeTwoDigits(localDay[1])}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isoToDate(value: string): Date | null {
|
||||
const normalized = normalizeDateIso(value);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(`${normalized}T00:00:00Z`);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
const year = date.getUTCFullYear();
|
||||
const month = normalizeTwoDigits(String(date.getUTCMonth() + 1));
|
||||
const day = normalizeTwoDigits(String(date.getUTCDate()));
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function shiftDays(iso: string, deltaDays: number): string | null {
|
||||
const date = isoToDate(iso);
|
||||
if (!date) {
|
||||
return null;
|
||||
}
|
||||
date.setUTCDate(date.getUTCDate() + deltaDays);
|
||||
return formatDate(date);
|
||||
}
|
||||
|
||||
function inferClaimType(input: { userMessage: string; focusDomainHint?: string | null }): ClaimType {
|
||||
const lower = String(input.userMessage ?? "").toLowerCase();
|
||||
const isVat =
|
||||
input.focusDomainHint === "vat_document_register_book" ||
|
||||
/(?:\bvat\b|ндс|invoice|счет[- ]фактур|register|книга покупок|книга продаж)/i.test(lower);
|
||||
if (isVat) {
|
||||
return "prove_vat_chain_completeness";
|
||||
}
|
||||
const isRbp = /(?:\brbp\b|рбп|account\s*97|счет\s*97|deferred expense|writeoff)/i.test(lower);
|
||||
if (isRbp) {
|
||||
return "prove_rbp_tail_state";
|
||||
}
|
||||
const isMonthClose =
|
||||
input.focusDomainHint === "month_close_costs_20_44" ||
|
||||
/(?:month[- ]?close|закрыт|косвен|account\s*20|account\s*44|счет\s*20|счет\s*44)/i.test(lower);
|
||||
if (isMonthClose) {
|
||||
return "prove_month_close_state";
|
||||
}
|
||||
const isAdvance = /(?:advance|аванс|offset|зачет|62\.02|60\.02)/i.test(lower);
|
||||
if (isAdvance) {
|
||||
return "prove_advance_offset_state";
|
||||
}
|
||||
return "prove_settlement_closure_state";
|
||||
}
|
||||
|
||||
function inferCounterpartyScope(message: string): string[] {
|
||||
const lower = message.toLowerCase();
|
||||
const out: string[] = [];
|
||||
if (/(?:supplier|vendor|поставщик)/i.test(lower)) out.push("supplier");
|
||||
if (/(?:customer|buyer|покупатель|дебитор)/i.test(lower)) out.push("customer");
|
||||
return uniqueStrings(out);
|
||||
}
|
||||
|
||||
function detectSignals(message: string): Record<string, boolean> {
|
||||
const lower = message.toLowerCase();
|
||||
return {
|
||||
hasAdvance: /(?:advance|аванс|offset|зачет|62\.02|60\.02)/i.test(lower),
|
||||
hasClosure: /(?:close|closure|закрыт|хвост|tail|reconcile|зачет)/i.test(lower),
|
||||
hasVat: /(?:\bvat\b|ндс|счет[- ]фактур|invoice|книга покупок|книга продаж|register)/i.test(lower),
|
||||
hasMonthClose: /(?:month[- ]?close|закрытие месяца|косвен|20\/44|account 20|account 44|счет 20|счет 44)/i.test(lower),
|
||||
hasRbp: /(?:\brbp\b|рбп|account 97|счет 97|writeoff|списани)/i.test(lower)
|
||||
};
|
||||
}
|
||||
|
||||
function mergeAnchors(anchors: CompanyAnchorSet | null | undefined, key: keyof CompanyAnchorSet): string[] {
|
||||
return uniqueStrings(Array.isArray(anchors?.[key]) ? (anchors?.[key] as string[]) : []);
|
||||
}
|
||||
|
||||
function buildAllowedContextWindow(primaryPeriod: TemporalWindow | null): TemporalWindow | null {
|
||||
if (!primaryPeriod) {
|
||||
return null;
|
||||
}
|
||||
const from = shiftDays(primaryPeriod.from, -365);
|
||||
const to = shiftDays(primaryPeriod.to, 365);
|
||||
if (!from || !to) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
granularity: "month"
|
||||
};
|
||||
}
|
||||
|
||||
function missingFromRequired(required: string[], resolved: Record<string, string[]>): string[] {
|
||||
const missing: string[] = [];
|
||||
for (const anchor of required) {
|
||||
if (anchor === "counterparty_scope_or_contract") {
|
||||
if ((resolved.counterparty_scope?.length ?? 0) <= 0 && (resolved.contract?.length ?? 0) <= 0) {
|
||||
missing.push(anchor);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (anchor === "settlement_object") {
|
||||
if ((resolved.contract?.length ?? 0) <= 0 && (resolved.document_numbers?.length ?? 0) <= 0) {
|
||||
missing.push(anchor);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ((resolved[anchor]?.length ?? 0) <= 0) {
|
||||
missing.push(anchor);
|
||||
}
|
||||
}
|
||||
return uniqueStrings(missing);
|
||||
}
|
||||
|
||||
export function resolveClaimBoundAnchors(input: {
|
||||
userMessage: string;
|
||||
companyAnchors?: CompanyAnchorSet | null;
|
||||
focusDomainHint?: string | null;
|
||||
primaryPeriod?: TemporalWindow | null;
|
||||
}): ClaimBoundAnchorAudit {
|
||||
const claimType = inferClaimType({
|
||||
userMessage: input.userMessage,
|
||||
focusDomainHint: input.focusDomainHint
|
||||
});
|
||||
const signals = detectSignals(input.userMessage);
|
||||
const resolvedAnchors: Record<string, string[]> = {
|
||||
period: uniqueStrings([...mergeAnchors(input.companyAnchors, "periods"), ...mergeAnchors(input.companyAnchors, "dates")]),
|
||||
account_scope: mergeAnchors(input.companyAnchors, "accounts"),
|
||||
amounts: mergeAnchors(input.companyAnchors, "amounts"),
|
||||
contract: mergeAnchors(input.companyAnchors, "contract_numbers"),
|
||||
document_numbers: mergeAnchors(input.companyAnchors, "document_numbers"),
|
||||
document_types: mergeAnchors(input.companyAnchors, "document_types"),
|
||||
counterparty_scope: inferCounterpartyScope(input.userMessage),
|
||||
advance_signal: signals.hasAdvance ? ["advance"] : [],
|
||||
closure_signal: signals.hasClosure ? ["closure"] : [],
|
||||
vat_signal: signals.hasVat ? ["vat"] : [],
|
||||
chain_signal: signals.hasVat ? ["chain"] : [],
|
||||
close_signal: signals.hasMonthClose ? ["month_close"] : [],
|
||||
cost_scope: [],
|
||||
rbp_signal: signals.hasRbp ? ["rbp"] : [],
|
||||
writeoff_signal: signals.hasRbp ? ["writeoff"] : []
|
||||
};
|
||||
if (/(?:^|[^\d])(20|44)(?:[^\d]|$)/.test((resolvedAnchors.account_scope ?? []).join(" ")) || signals.hasMonthClose) {
|
||||
resolvedAnchors.cost_scope = ["20_44"];
|
||||
}
|
||||
if (input.primaryPeriod) {
|
||||
resolvedAnchors.period = uniqueStrings([...(resolvedAnchors.period ?? []), input.primaryPeriod.from, input.primaryPeriod.to]);
|
||||
}
|
||||
|
||||
const requiredByClaim: Record<ClaimType, string[]> = {
|
||||
prove_settlement_closure_state: ["period", "account_scope", "counterparty_scope_or_contract", "closure_signal"],
|
||||
prove_advance_offset_state: ["period", "account_scope", "advance_signal", "settlement_object"],
|
||||
prove_vat_chain_completeness: ["period", "document_types", "vat_signal", "chain_signal"],
|
||||
prove_month_close_state: ["period", "close_signal", "cost_scope"],
|
||||
prove_rbp_tail_state: ["period", "rbp_signal", "writeoff_signal"]
|
||||
};
|
||||
|
||||
const requiredAnchors = requiredByClaim[claimType];
|
||||
const missingAnchors = missingFromRequired(requiredAnchors, resolvedAnchors);
|
||||
const resolutionRate =
|
||||
requiredAnchors.length > 0
|
||||
? Number(((requiredAnchors.length - missingAnchors.length) / requiredAnchors.length).toFixed(4))
|
||||
: 1;
|
||||
const allowedContextWindow = buildAllowedContextWindow(input.primaryPeriod ?? null);
|
||||
const reasonCodes: string[] = [];
|
||||
if (missingAnchors.length > 0) {
|
||||
reasonCodes.push("claim_missing_required_anchors");
|
||||
}
|
||||
if (resolutionRate < 0.8) {
|
||||
reasonCodes.push("claim_anchor_resolution_low");
|
||||
}
|
||||
if (!allowedContextWindow && input.primaryPeriod) {
|
||||
reasonCodes.push("controlled_temporal_expansion_window_unavailable");
|
||||
}
|
||||
|
||||
return {
|
||||
claim_type: claimType,
|
||||
required_anchors: requiredAnchors,
|
||||
resolved_anchors: resolvedAnchors,
|
||||
missing_anchors: missingAnchors,
|
||||
claim_anchor_resolution_rate: resolutionRate,
|
||||
primary_period: input.primaryPeriod ?? null,
|
||||
allowed_context_window: allowedContextWindow,
|
||||
context_expansion_reasons_allowed: [
|
||||
"prehistory",
|
||||
"carryover",
|
||||
"post_period_closure",
|
||||
"long_running_contract_context"
|
||||
],
|
||||
reason_codes: uniqueStrings(reasonCodes)
|
||||
};
|
||||
}
|
||||
|
||||
function buildCorpusFromItem(item: Record<string, unknown>): string {
|
||||
return JSON.stringify({
|
||||
source_entity: item.source_entity,
|
||||
source_id: item.source_id,
|
||||
period: item.period ?? item.Period,
|
||||
account_context: item.account_context,
|
||||
account_debit: item.account_debit,
|
||||
account_credit: item.account_credit,
|
||||
document_context: item.document_context,
|
||||
relation_pattern_hits: item.relation_pattern_hits,
|
||||
graph_domain_scope: item.graph_domain_scope,
|
||||
lifecycle_markers: item.lifecycle_markers
|
||||
}).toLowerCase();
|
||||
}
|
||||
|
||||
function buildCorpusFromEvidence(evidence: EvidenceItem): string {
|
||||
return JSON.stringify({
|
||||
source_ref: evidence.source_ref,
|
||||
pointer: evidence.pointer,
|
||||
payload: evidence.payload,
|
||||
mechanism_note: evidence.mechanism_note,
|
||||
limitation: evidence.limitation
|
||||
}).toLowerCase();
|
||||
}
|
||||
|
||||
function requiredChecksByClaim(claimType: ClaimType): string[] {
|
||||
if (claimType === "prove_settlement_closure_state") {
|
||||
return [
|
||||
"payment_document_found",
|
||||
"contract_matched",
|
||||
"settlement_object_matched",
|
||||
"closing_document_found",
|
||||
"register_closure_entry_found",
|
||||
"posting_link_found"
|
||||
];
|
||||
}
|
||||
if (claimType === "prove_advance_offset_state") {
|
||||
return [
|
||||
"payment_document_found",
|
||||
"advance_marker_found",
|
||||
"settlement_object_matched",
|
||||
"closing_document_found",
|
||||
"register_closure_entry_found",
|
||||
"posting_link_found"
|
||||
];
|
||||
}
|
||||
if (claimType === "prove_vat_chain_completeness") {
|
||||
return ["source_document_found", "invoice_found", "tax_register_entry_found", "book_entry_found", "chain_linkage_status"];
|
||||
}
|
||||
if (claimType === "prove_month_close_state") {
|
||||
return ["close_operation_found", "distribution_step_found", "residual_tail_found"];
|
||||
}
|
||||
return ["rbp_writeoff_lifecycle_confirmed", "residual_tail_found", "close_contradiction_or_normal_residual"];
|
||||
}
|
||||
|
||||
function detectChecksForCorpus(corpus: string, claimType: ClaimType, anchors: Record<string, string[]>): string[] {
|
||||
const checks = new Set<string>();
|
||||
const hasContractAnchor =
|
||||
(anchors.contract ?? []).some((token) => token.length >= 3 && corpus.includes(String(token).toLowerCase())) ||
|
||||
/(?:contract|договор)/i.test(corpus);
|
||||
const hasSettlementAccount = /(?:\b60(?:\.\d{2})?\b|\b62(?:\.\d{2})?\b|payable|receivable|settlement)/i.test(corpus);
|
||||
const hasPosting = /(?:document_to_posting|posting|проводк)/i.test(corpus);
|
||||
const hasRegister = /(?:register|accumulationregister|accountingregister|регистр)/i.test(corpus);
|
||||
const hasClose = /(?:close|closure|закрыт|reconcile|зачет|tail|хвост)/i.test(corpus);
|
||||
const hasPayment = /(?:payment|оплат|списаниесрасчетногосчета|payment_order|bank_statement)/i.test(corpus);
|
||||
const hasAdvance = /(?:advance|аванс|offset|зачет|62\.02|60\.02)/i.test(corpus);
|
||||
const hasVat = /(?:\bvat\b|ндс|invoice_to_vat|счет[- ]фактур|invoice)/i.test(corpus);
|
||||
const hasBook = /(?:книгипокупок|книгипродаж|book)/i.test(corpus);
|
||||
const hasChain = /(?:chain|link|document_to_posting|invoice_to_vat|связ)/i.test(corpus);
|
||||
const hasMonthClose = /(?:month[- ]?close|period_close|закрытие месяца|косвен|20|44)/i.test(corpus);
|
||||
const hasDistribution = /(?:distribution|распредел|writeoff|deferred_expense_to_writeoff)/i.test(corpus);
|
||||
const hasRbp = /(?:\brbp\b|рбп|account\s*97|счет\s*97|deferred)/i.test(corpus);
|
||||
const hasResidual = /(?:tail|остат|незакры|overdue|period_boundary|terminal_state_gap)/i.test(corpus);
|
||||
const hasContradiction = /(?:contradiction|invalid_transition|normal residual|нормальн)/i.test(corpus);
|
||||
|
||||
if (claimType === "prove_settlement_closure_state") {
|
||||
if (hasPayment) checks.add("payment_document_found");
|
||||
if (hasContractAnchor) checks.add("contract_matched");
|
||||
if (hasSettlementAccount) checks.add("settlement_object_matched");
|
||||
if (hasClose) checks.add("closing_document_found");
|
||||
if (hasRegister) checks.add("register_closure_entry_found");
|
||||
if (hasPosting) checks.add("posting_link_found");
|
||||
} else if (claimType === "prove_advance_offset_state") {
|
||||
if (hasPayment) checks.add("payment_document_found");
|
||||
if (hasAdvance) checks.add("advance_marker_found");
|
||||
if (hasSettlementAccount) checks.add("settlement_object_matched");
|
||||
if (hasClose) checks.add("closing_document_found");
|
||||
if (hasRegister) checks.add("register_closure_entry_found");
|
||||
if (hasPosting) checks.add("posting_link_found");
|
||||
} else if (claimType === "prove_vat_chain_completeness") {
|
||||
if (/(?:document|receipt|realization|поступлен|реализац)/i.test(corpus)) checks.add("source_document_found");
|
||||
if (/(?:invoice|счет[- ]фактур)/i.test(corpus)) checks.add("invoice_found");
|
||||
if (hasRegister || hasVat) checks.add("tax_register_entry_found");
|
||||
if (hasBook) checks.add("book_entry_found");
|
||||
if (hasChain) checks.add("chain_linkage_status");
|
||||
} else if (claimType === "prove_month_close_state") {
|
||||
if (hasMonthClose || hasClose) checks.add("close_operation_found");
|
||||
if (hasDistribution) checks.add("distribution_step_found");
|
||||
if (hasResidual) checks.add("residual_tail_found");
|
||||
} else {
|
||||
if (hasRbp && hasDistribution) checks.add("rbp_writeoff_lifecycle_confirmed");
|
||||
if (hasResidual) checks.add("residual_tail_found");
|
||||
if (hasContradiction || hasClose) checks.add("close_contradiction_or_normal_residual");
|
||||
}
|
||||
|
||||
return Array.from(checks);
|
||||
}
|
||||
|
||||
function hasAnchorLink(corpus: string, claimAudit: ClaimBoundAnchorAudit): boolean {
|
||||
const values = Object.values(claimAudit.resolved_anchors).flat();
|
||||
return values.some((token) => {
|
||||
const value = String(token ?? "").toLowerCase().trim();
|
||||
if (value.length < 2) return false;
|
||||
return corpus.includes(value);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveContextExpansionDecision(input: {
|
||||
period: string | null;
|
||||
claimAudit: ClaimBoundAnchorAudit;
|
||||
corpus: string;
|
||||
matchedChecks: string[];
|
||||
}): ContextExpansionDecision {
|
||||
if (!input.period || !input.claimAudit.primary_period) {
|
||||
return { allowed: true, reason: null, inside_primary_period: true };
|
||||
}
|
||||
const normalized = normalizeDateIso(input.period);
|
||||
if (!normalized) {
|
||||
return { allowed: false, reason: null, inside_primary_period: false };
|
||||
}
|
||||
const primaryFrom = normalizeDateIso(input.claimAudit.primary_period.from);
|
||||
const primaryTo = normalizeDateIso(input.claimAudit.primary_period.to);
|
||||
if (!primaryFrom || !primaryTo) {
|
||||
return { allowed: true, reason: null, inside_primary_period: true };
|
||||
}
|
||||
if (normalized >= primaryFrom && normalized <= primaryTo) {
|
||||
return { allowed: true, reason: null, inside_primary_period: true };
|
||||
}
|
||||
const allowedFrom = normalizeDateIso(input.claimAudit.allowed_context_window?.from ?? "");
|
||||
const allowedTo = normalizeDateIso(input.claimAudit.allowed_context_window?.to ?? "");
|
||||
if (allowedFrom && normalized < allowedFrom) {
|
||||
return { allowed: false, reason: null, inside_primary_period: false };
|
||||
}
|
||||
if (allowedTo && normalized > allowedTo) {
|
||||
return { allowed: false, reason: null, inside_primary_period: false };
|
||||
}
|
||||
|
||||
const linked = hasAnchorLink(input.corpus, input.claimAudit) || input.matchedChecks.length > 0;
|
||||
const fromDate = isoToDate(primaryFrom);
|
||||
const toDate = isoToDate(primaryTo);
|
||||
const curDate = isoToDate(normalized);
|
||||
const hasContractAnchor = (input.claimAudit.resolved_anchors.contract?.length ?? 0) > 0;
|
||||
if (!fromDate || !toDate || !curDate) {
|
||||
return { allowed: linked, reason: linked ? "carryover" : null, inside_primary_period: false };
|
||||
}
|
||||
const diffBefore = Math.floor((fromDate.getTime() - curDate.getTime()) / (24 * 3600 * 1000));
|
||||
const diffAfter = Math.floor((curDate.getTime() - toDate.getTime()) / (24 * 3600 * 1000));
|
||||
if (curDate < fromDate) {
|
||||
if (linked && hasContractAnchor && diffBefore > 31) {
|
||||
return { allowed: true, reason: "long_running_contract_context", inside_primary_period: false };
|
||||
}
|
||||
if (linked) {
|
||||
return { allowed: true, reason: "prehistory", inside_primary_period: false };
|
||||
}
|
||||
if (diffBefore <= 31) {
|
||||
return { allowed: true, reason: "carryover", inside_primary_period: false };
|
||||
}
|
||||
return { allowed: false, reason: null, inside_primary_period: false };
|
||||
}
|
||||
if (curDate > toDate) {
|
||||
if (diffAfter <= 31) {
|
||||
return { allowed: true, reason: "carryover", inside_primary_period: false };
|
||||
}
|
||||
if (linked && hasContractAnchor) {
|
||||
return { allowed: true, reason: "long_running_contract_context", inside_primary_period: false };
|
||||
}
|
||||
if (linked) {
|
||||
return { allowed: true, reason: "post_period_closure", inside_primary_period: false };
|
||||
}
|
||||
return { allowed: false, reason: null, inside_primary_period: false };
|
||||
}
|
||||
return { allowed: true, reason: null, inside_primary_period: true };
|
||||
}
|
||||
|
||||
function evidenceSourceNamespaceFromItem(item: Record<string, unknown>): EvidenceSourceNamespace {
|
||||
const sourceLayer = String(item.source_layer ?? "").toLowerCase();
|
||||
if (sourceLayer.includes("snapshot")) {
|
||||
return "snapshot_2020";
|
||||
}
|
||||
return "assistant_derived";
|
||||
}
|
||||
|
||||
function buildDerivedEvidenceFromItem(input: {
|
||||
result: UnifiedRetrievalResult;
|
||||
item: Record<string, unknown>;
|
||||
claimType: ClaimType;
|
||||
matchedChecks: string[];
|
||||
expansion: ContextExpansionDecision;
|
||||
}): EvidenceItem {
|
||||
const sourceEntity = String(input.item.source_entity ?? "unknown");
|
||||
const sourceId = String(input.item.source_id ?? `derived-${nanoid(8)}`);
|
||||
const period = String(input.item.period ?? input.item.Period ?? "").trim() || null;
|
||||
const namespace = evidenceSourceNamespaceFromItem(input.item);
|
||||
const canonical = `evidence_source_ref_v1|${namespace}|${sourceEntity.toLowerCase()}|${sourceId.toLowerCase()}|${String(period ?? "").toLowerCase()}`;
|
||||
const confidence = input.matchedChecks.length >= 2 ? "high" : "medium";
|
||||
return {
|
||||
evidence_id: `claim-ev-${nanoid(10)}`,
|
||||
claim_ref: `claim:${input.claimType}`,
|
||||
source_type: "derived",
|
||||
source_ref: {
|
||||
schema_version: "evidence_source_ref_v1",
|
||||
namespace,
|
||||
entity: sourceEntity,
|
||||
id: sourceId,
|
||||
period,
|
||||
canonical_ref: canonical
|
||||
},
|
||||
pointer: {
|
||||
fragment_id: input.result.fragment_id,
|
||||
route: input.result.route,
|
||||
source: {
|
||||
namespace,
|
||||
entity: sourceEntity,
|
||||
id: sourceId,
|
||||
period
|
||||
},
|
||||
locator: {
|
||||
field_path: null,
|
||||
item_index: null
|
||||
}
|
||||
},
|
||||
evidence_kind: "mechanism_link",
|
||||
mechanism_note: input.matchedChecks[0] ?? null,
|
||||
confidence,
|
||||
limitation: null,
|
||||
payload: {
|
||||
from_targeted_item: true,
|
||||
claim_type: input.claimType,
|
||||
claim_target_checks: input.matchedChecks,
|
||||
context_expansion_allowed: input.expansion.allowed,
|
||||
context_expansion_reason: input.expansion.reason,
|
||||
period,
|
||||
source_entity: sourceEntity,
|
||||
source_id: sourceId,
|
||||
account_context: Array.isArray(input.item.account_context) ? input.item.account_context : [],
|
||||
account_debit: input.item.account_debit ?? null,
|
||||
account_credit: input.item.account_credit ?? null,
|
||||
relation_pattern_hits: Array.isArray(input.item.relation_pattern_hits) ? input.item.relation_pattern_hits : []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildClaimStatusTemplate(requiredChecks: string[]): Record<string, "found" | "not_found"> {
|
||||
const out: Record<string, "found" | "not_found"> = {};
|
||||
for (const check of requiredChecks) {
|
||||
out[check] = "not_found";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function applyTargetedEvidenceAcquisition(input: {
|
||||
retrievalResults: UnifiedRetrievalResult[];
|
||||
claimAudit: ClaimBoundAnchorAudit;
|
||||
}): {
|
||||
retrievalResults: UnifiedRetrievalResult[];
|
||||
audit: TargetedEvidenceAcquisitionAudit;
|
||||
} {
|
||||
const requiredChecks = requiredChecksByClaim(input.claimAudit.claim_type);
|
||||
const checkStatus = buildClaimStatusTemplate(requiredChecks);
|
||||
let targetedItemHits = 0;
|
||||
let targetedEvidenceHits = 0;
|
||||
const sourceRefs = new Set<string>();
|
||||
const adjustedResults = input.retrievalResults.map((result) => {
|
||||
const items = Array.isArray(result.items) ? result.items : [];
|
||||
const targetedItems: Array<Record<string, unknown>> = [];
|
||||
const derivedEvidence: EvidenceItem[] = [];
|
||||
for (const item of items) {
|
||||
const corpus = buildCorpusFromItem(item);
|
||||
const matchedChecks = detectChecksForCorpus(corpus, input.claimAudit.claim_type, input.claimAudit.resolved_anchors);
|
||||
for (const check of matchedChecks) {
|
||||
if (check in checkStatus) checkStatus[check] = "found";
|
||||
}
|
||||
if (matchedChecks.length <= 0) {
|
||||
continue;
|
||||
}
|
||||
targetedItemHits += 1;
|
||||
const expansion = resolveContextExpansionDecision({
|
||||
period: String(item.period ?? item.Period ?? "").trim() || null,
|
||||
claimAudit: input.claimAudit,
|
||||
corpus,
|
||||
matchedChecks
|
||||
});
|
||||
const enrichedItem = {
|
||||
...item,
|
||||
claim_target_checks: matchedChecks,
|
||||
context_expansion_allowed: expansion.allowed,
|
||||
context_expansion_reason: expansion.reason
|
||||
};
|
||||
targetedItems.push(enrichedItem);
|
||||
if (derivedEvidence.length < 8) {
|
||||
const evidence = buildDerivedEvidenceFromItem({
|
||||
result,
|
||||
item: enrichedItem,
|
||||
claimType: input.claimAudit.claim_type,
|
||||
matchedChecks,
|
||||
expansion
|
||||
});
|
||||
derivedEvidence.push(evidence);
|
||||
sourceRefs.add(evidence.source_ref.canonical_ref);
|
||||
}
|
||||
}
|
||||
|
||||
const evidence = Array.isArray(result.evidence) ? result.evidence : [];
|
||||
const targetedEvidence: EvidenceItem[] = [];
|
||||
for (const evidenceItem of evidence) {
|
||||
const corpus = buildCorpusFromEvidence(evidenceItem);
|
||||
const matchedChecks = detectChecksForCorpus(corpus, input.claimAudit.claim_type, input.claimAudit.resolved_anchors);
|
||||
for (const check of matchedChecks) {
|
||||
if (check in checkStatus) checkStatus[check] = "found";
|
||||
}
|
||||
if (matchedChecks.length <= 0) {
|
||||
continue;
|
||||
}
|
||||
const payload = toObject(evidenceItem.payload) ?? {};
|
||||
const expansion = resolveContextExpansionDecision({
|
||||
period:
|
||||
String(evidenceItem.source_ref?.period ?? "").trim() ||
|
||||
String(evidenceItem.pointer?.source?.period ?? "").trim() ||
|
||||
String(payload.period ?? "").trim() ||
|
||||
null,
|
||||
claimAudit: input.claimAudit,
|
||||
corpus,
|
||||
matchedChecks
|
||||
});
|
||||
targetedEvidence.push({
|
||||
...evidenceItem,
|
||||
payload: {
|
||||
...payload,
|
||||
claim_type: input.claimAudit.claim_type,
|
||||
claim_target_checks: matchedChecks,
|
||||
context_expansion_allowed: expansion.allowed,
|
||||
context_expansion_reason: expansion.reason
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const mergedEvidence = [...targetedEvidence, ...derivedEvidence];
|
||||
targetedEvidenceHits += mergedEvidence.length;
|
||||
for (const item of mergedEvidence) {
|
||||
sourceRefs.add(item.source_ref.canonical_ref);
|
||||
}
|
||||
const summary = {
|
||||
...(toObject(result.summary) ?? {}),
|
||||
claim_bound_targeting: {
|
||||
claim_type: input.claimAudit.claim_type,
|
||||
required_checks: requiredChecks,
|
||||
targeted_items: targetedItems.length,
|
||||
targeted_evidence: mergedEvidence.length,
|
||||
derived_evidence_added: derivedEvidence.length
|
||||
}
|
||||
};
|
||||
return {
|
||||
...result,
|
||||
items: targetedItems.length > 0 ? targetedItems : items,
|
||||
evidence: mergedEvidence.length > 0 ? mergedEvidence : evidence,
|
||||
summary
|
||||
};
|
||||
});
|
||||
|
||||
const foundChecks = Object.values(checkStatus).filter((status) => status === "found").length;
|
||||
const targetedEvidenceHitRate =
|
||||
requiredChecks.length > 0 ? Number((foundChecks / requiredChecks.length).toFixed(4)) : 0;
|
||||
const reasonCodes: string[] = [];
|
||||
if (targetedEvidenceHits <= 0) {
|
||||
reasonCodes.push("targeted_evidence_not_found");
|
||||
}
|
||||
if (targetedEvidenceHitRate < 0.8) {
|
||||
reasonCodes.push("targeted_evidence_hit_rate_low");
|
||||
}
|
||||
|
||||
return {
|
||||
retrievalResults: adjustedResults,
|
||||
audit: {
|
||||
claim_type: input.claimAudit.claim_type,
|
||||
required_checks: requiredChecks,
|
||||
check_status: checkStatus,
|
||||
targeted_item_hits: targetedItemHits,
|
||||
targeted_evidence_hits: targetedEvidenceHits,
|
||||
targeted_evidence_hit_rate: targetedEvidenceHitRate,
|
||||
targeted_evidence_source_refs: Array.from(sourceRefs).slice(0, 24),
|
||||
reason_codes: uniqueStrings(reasonCodes)
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user