3554 lines
175 KiB
JavaScript
3554 lines
175 KiB
JavaScript
"use strict";
|
||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||
};
|
||
Object.defineProperty(exports, "__esModule", { value: true });
|
||
exports.AssistantDataLayer = void 0;
|
||
const fs_1 = __importDefault(require("fs"));
|
||
const path_1 = __importDefault(require("path"));
|
||
const config_1 = require("../config");
|
||
const BROAD_GENERIC_MARKERS = new RegExp([
|
||
"\\boverall\\b",
|
||
"\\bgeneral\\b",
|
||
"\\bgeneric\\b",
|
||
"\\bsummary\\b",
|
||
"\\btop\\b",
|
||
"\\ball\\s+risks?\\b",
|
||
"\\bshow\\s+all\\b",
|
||
"\\bwhat\\s+is\\s+wrong\\b",
|
||
"\\u0432\\s*\\u0446\\u0435\\u043b\\u043e\\u043c",
|
||
"\\u043e\\u0431\\u0449(?:\\u0430\\u044f|\\u0443\\u044e)?\\s+\\u043a\\u0430\\u0440\\u0442\\u0438\\u043d\\u0443?",
|
||
"\\u043e\\u0431\\u0437\\u043e\\u0440",
|
||
"\\u043f\\u043e\\u043a\\u0430\\u0436\\u0438\\s+\\u0432\\u0441\\u0435",
|
||
"\\u0432\\u0441\\u0435\\s+\\u0440\\u0438\\u0441\\u043a\\u0438",
|
||
"\\u043e\\u0431\\u0449\\u0438\\u0435\\s+\\u0440\\u0438\\u0441\\u043a\\u0438",
|
||
"\\u0442\\u043e\\u043f\\s+\\u0440\\u0438\\u0441\\u043a\\u043e\\u0432",
|
||
"\\u0433\\u0434\\u0435\\s+\\u043f\\u0440\\u043e\\u0431\\u043b\\u0435\\u043c\\u044b",
|
||
"\\u0447\\u0442\\u043e\\s+\\u043d\\u0435\\s+\\u0442\\u0430\\u043a"
|
||
].join("|"), "iu");
|
||
const ACCOUNT_SPECIFIC_MARKERS = /(?:\u0441\u0447\u0435\u0442(?:\u0430|\u0443|\u043e\u043c)?|account)\s*[:#]?\s*\d{2}(?:\.\d{2})?/iu;
|
||
const PERIOD_MARKERS = /\b20\d{2}(?:[-./](?:0[1-9]|1[0-2]))?\b/;
|
||
const ENTITY_SPECIFIC_MARKERS = /(?:\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|supplier|buyer|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|invoice|posting|register|guid|id[:=\s])/iu;
|
||
const EXACT_OBJECT_MARKERS = /(?:\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\s*(?:#|\u2116)|\bref\b|\bid\b|trx-\d+|inv-\d+)/iu;
|
||
const CONTRACT_MARKERS = /(?:\u0434\u043e\u0433\u043e\u0432\u043e\u0440(?:\u0430|\u0443|\u043e\u043c|\u0435)?\s*(?:№|#|n)\s*[a-z\u0430-\u044f0-9./_-]+)/iu;
|
||
const DOCUMENT_NUMBER_MARKERS = /(?:(?:\u0441\u0447(?:\u0435|\u0451)\u0442(?:-\u0444\u0430\u043a\u0442\u0443\u0440(?:\u0430|\u044b))?|\u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446(?:\u0438\u044f|\u0438\u0438)|\u0430\u043a\u0442)\s*(?:№|#|n)\s*[a-z\u0430-\u044f0-9./_-]+)/iu;
|
||
const AMOUNT_MARKERS = /\b(?:\d{1,3}(?:[ \u00A0]\d{3})+(?:[.,]\d{2})?|\d+[.,]\d{2})\b/u;
|
||
const ROUTE_MIN_EVIDENCE_GATE = {
|
||
hybrid_store_plus_live: {
|
||
min_evidence_items: 3,
|
||
min_result_items: 2
|
||
},
|
||
store_feature_risk: {
|
||
min_evidence_items: 2,
|
||
min_result_items: 2
|
||
},
|
||
batch_refresh_then_store: {
|
||
min_evidence_items: 6,
|
||
min_result_items: 8
|
||
},
|
||
store_canonical: {
|
||
min_evidence_items: 2,
|
||
min_result_items: 3
|
||
},
|
||
live_mcp_drilldown: {
|
||
min_evidence_items: 1,
|
||
min_result_items: 1
|
||
}
|
||
};
|
||
const MCP_LIVE_MOVEMENTS_QUERY_TEMPLATE = `
|
||
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
|
||
Движения.Период КАК Период,
|
||
ПРЕДСТАВЛЕНИЕ(Движения.Регистратор) КАК Регистратор,
|
||
ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) КАК СчетДт,
|
||
ПРЕДСТАВЛЕНИЕ(Движения.СчетКт) КАК СчетКт,
|
||
Движения.Сумма КАК Сумма
|
||
ИЗ
|
||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
||
УПОРЯДОЧИТЬ ПО
|
||
Движения.Период УБЫВ
|
||
`;
|
||
const MCP_LIVE_MOVEMENTS_BY_PERIOD_QUERY_TEMPLATE = `
|
||
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
|
||
Движения.Период КАК Период,
|
||
ПРЕДСТАВЛЕНИЕ(Движения.Регистратор) КАК Регистратор,
|
||
ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) КАК СчетДт,
|
||
ПРЕДСТАВЛЕНИЕ(Движения.СчетКт) КАК СчетКт,
|
||
Движения.Сумма КАК Сумма
|
||
ИЗ
|
||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
||
ГДЕ
|
||
Движения.Период МЕЖДУ __FROM_DATETIME__ И __TO_DATETIME__
|
||
УПОРЯДОЧИТЬ ПО
|
||
Движения.Период УБЫВ
|
||
`;
|
||
const RBP_REQUIRED_LIVE_CALLS = [
|
||
"find_rbp_writeoff_documents_in_period",
|
||
"find_rbp_object_movements_account_97",
|
||
"find_month_close_entries_linked_to_rbp",
|
||
"compute_end_period_residual_by_rbp_object"
|
||
];
|
||
function pushUniqueLine(target, line) {
|
||
if (!target.includes(line)) {
|
||
target.push(line);
|
||
}
|
||
}
|
||
function escapeRegExp(value) {
|
||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
}
|
||
function valueAsString(value) {
|
||
if (value === null || value === undefined) {
|
||
return "";
|
||
}
|
||
return String(value);
|
||
}
|
||
function parseFiniteNumber(value) {
|
||
if (typeof value === "number" && Number.isFinite(value)) {
|
||
return value;
|
||
}
|
||
if (typeof value === "string") {
|
||
const normalized = value.replace(",", ".").trim();
|
||
const parsed = Number(normalized);
|
||
if (Number.isFinite(parsed)) {
|
||
return parsed;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
function formatIsoDateUtc(date) {
|
||
const year = date.getUTCFullYear();
|
||
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||
const day = String(date.getUTCDate()).padStart(2, "0");
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
function monthEndFromIso(isoDate) {
|
||
const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||
if (!match) {
|
||
return null;
|
||
}
|
||
const year = Number(match[1]);
|
||
const month = Number(match[2]);
|
||
if (!Number.isFinite(year) || !Number.isFinite(month)) {
|
||
return null;
|
||
}
|
||
const end = new Date(Date.UTC(year, month, 0));
|
||
return formatIsoDateUtc(end);
|
||
}
|
||
function shiftIsoDate(isoDate, deltaDays) {
|
||
const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||
if (!match) {
|
||
return null;
|
||
}
|
||
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
|
||
if (Number.isNaN(date.getTime())) {
|
||
return null;
|
||
}
|
||
date.setUTCDate(date.getUTCDate() + deltaDays);
|
||
return formatIsoDateUtc(date);
|
||
}
|
||
function toDateTimeExpr(isoDate, endOfDay) {
|
||
const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||
if (!match) {
|
||
return null;
|
||
}
|
||
const year = Number(match[1]);
|
||
const month = Number(match[2]);
|
||
const day = Number(match[3]);
|
||
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
|
||
return null;
|
||
}
|
||
const hour = endOfDay ? 23 : 0;
|
||
const minute = endOfDay ? 59 : 0;
|
||
const second = endOfDay ? 59 : 0;
|
||
return `ДАТАВРЕМЯ(${year}, ${month}, ${day}, ${hour}, ${minute}, ${second})`;
|
||
}
|
||
function buildLiveRangeQuery(fromIso, toIso, limit) {
|
||
const fromExpr = toDateTimeExpr(fromIso, false);
|
||
const toExpr = toDateTimeExpr(toIso, true);
|
||
if (!fromExpr || !toExpr) {
|
||
return MCP_LIVE_MOVEMENTS_QUERY_TEMPLATE.replace("__LIMIT__", String(limit));
|
||
}
|
||
return MCP_LIVE_MOVEMENTS_BY_PERIOD_QUERY_TEMPLATE.replace("__LIMIT__", String(limit))
|
||
.replace("__FROM_DATETIME__", fromExpr)
|
||
.replace("__TO_DATETIME__", toExpr);
|
||
}
|
||
function hasRbpSignal(text) {
|
||
return /(?:\brbp\b|рбп|расходы\s+будущих\s+периодов|deferred|writeoff|списани[ея]\s+рбп|account\s*97|счет\s*97)/i.test(String(text ?? "").toLowerCase());
|
||
}
|
||
function buildLiveMcpCallPlan(route, fragmentText) {
|
||
const semanticProfile = buildSemanticRetrievalProfile(fragmentText);
|
||
const rbpClaim = hasRbpSignal(fragmentText) ||
|
||
semanticProfile.query_subject === "deferred_expense_lifecycle_anomaly" ||
|
||
semanticProfile.domain_scope.includes("deferred_expense");
|
||
if (!rbpClaim) {
|
||
return {
|
||
claim_type: null,
|
||
query_subject: semanticProfile.query_subject,
|
||
required_live_calls: [],
|
||
calls: [
|
||
{
|
||
call_id: "generic_accounting_register_probe",
|
||
purpose: "live_overlay_probe",
|
||
query: MCP_LIVE_MOVEMENTS_QUERY_TEMPLATE.replace("__LIMIT__", String(config_1.ASSISTANT_MCP_LIVE_LIMIT)),
|
||
required_for_claim: false
|
||
}
|
||
],
|
||
route_gap_reason: null
|
||
};
|
||
}
|
||
const periodScope = inferPeriodScope(fragmentText);
|
||
const primaryFrom = periodScope.from ?? "2020-07-01";
|
||
const primaryTo = periodScope.to ?? monthEndFromIso(primaryFrom) ?? "2020-07-31";
|
||
const carryFrom = shiftIsoDate(primaryFrom, -31) ?? primaryFrom;
|
||
const carryTo = shiftIsoDate(primaryTo, 31) ?? primaryTo;
|
||
return {
|
||
claim_type: "prove_rbp_tail_state",
|
||
query_subject: "deferred_expense_lifecycle_anomaly",
|
||
required_live_calls: [...RBP_REQUIRED_LIVE_CALLS],
|
||
calls: [
|
||
{
|
||
call_id: "find_rbp_writeoff_documents_in_period",
|
||
purpose: "seed_writeoff_documents",
|
||
query: buildLiveRangeQuery(primaryFrom, primaryTo, config_1.ASSISTANT_MCP_LIVE_LIMIT),
|
||
required_for_claim: true,
|
||
account_scope_override: ["97", "20", "25", "26", "44"]
|
||
},
|
||
{
|
||
call_id: "find_rbp_object_movements_account_97",
|
||
purpose: "collect_rbp_object_movements",
|
||
query: buildLiveRangeQuery(primaryFrom, primaryTo, config_1.ASSISTANT_MCP_LIVE_LIMIT),
|
||
required_for_claim: true,
|
||
account_scope_override: ["97"]
|
||
},
|
||
{
|
||
call_id: "find_month_close_entries_linked_to_rbp",
|
||
purpose: "link_month_close_to_rbp",
|
||
query: buildLiveRangeQuery(primaryFrom, primaryTo, config_1.ASSISTANT_MCP_LIVE_LIMIT),
|
||
required_for_claim: true,
|
||
account_scope_override: ["97", "20", "25", "26", "44"]
|
||
},
|
||
{
|
||
call_id: "compute_end_period_residual_by_rbp_object",
|
||
purpose: "collect_residual_tail_signals",
|
||
query: buildLiveRangeQuery(carryFrom, carryTo, config_1.ASSISTANT_MCP_LIVE_LIMIT),
|
||
required_for_claim: true,
|
||
account_scope_override: ["97", "20", "25", "26", "44"]
|
||
}
|
||
],
|
||
route_gap_reason: null
|
||
};
|
||
}
|
||
function detectBroadQuery(fragmentText, route) {
|
||
const text = String(fragmentText ?? "").trim();
|
||
const lower = text.toLowerCase();
|
||
const tokenCount = lower.split(/\s+/).filter(Boolean).length;
|
||
const hasGenericMarker = BROAD_GENERIC_MARKERS.test(lower);
|
||
const hasAccountAnchor = ACCOUNT_SPECIFIC_MARKERS.test(lower);
|
||
const hasPeriodAnchor = PERIOD_MARKERS.test(lower);
|
||
const hasEntityAnchor = ENTITY_SPECIFIC_MARKERS.test(lower);
|
||
const hasExactObjectAnchor = EXACT_OBJECT_MARKERS.test(lower);
|
||
const hasGuidAnchor = extractGuids(lower).length > 0;
|
||
const hasContractAnchor = CONTRACT_MARKERS.test(lower);
|
||
const hasDocumentNumberAnchor = DOCUMENT_NUMBER_MARKERS.test(lower);
|
||
const hasAmountAnchor = AMOUNT_MARKERS.test(lower);
|
||
let anchorScore = 0;
|
||
if (hasGuidAnchor)
|
||
anchorScore += 3;
|
||
if (hasAccountAnchor)
|
||
anchorScore += 2;
|
||
if (hasPeriodAnchor)
|
||
anchorScore += 1;
|
||
if (hasEntityAnchor)
|
||
anchorScore += 1;
|
||
if (hasExactObjectAnchor)
|
||
anchorScore += 1;
|
||
if (hasContractAnchor)
|
||
anchorScore += 2;
|
||
if (hasDocumentNumberAnchor)
|
||
anchorScore += 2;
|
||
if (hasAmountAnchor)
|
||
anchorScore += 1;
|
||
const weakAnchors = anchorScore <= 1;
|
||
const strongFocus = hasGuidAnchor ||
|
||
(hasAccountAnchor && hasPeriodAnchor) ||
|
||
(hasContractAnchor && hasDocumentNumberAnchor) ||
|
||
anchorScore >= 4;
|
||
const routeSensitiveBroad = route === "batch_refresh_then_store" || route === "hybrid_store_plus_live";
|
||
let broadnessLevel = "low";
|
||
if (hasGenericMarker && !strongFocus && (weakAnchors || routeSensitiveBroad)) {
|
||
broadnessLevel = "high";
|
||
}
|
||
else if (hasGenericMarker && !strongFocus) {
|
||
broadnessLevel = "medium";
|
||
}
|
||
return {
|
||
broad_query_detected: broadnessLevel !== "low",
|
||
broadness_level: broadnessLevel,
|
||
scope_confidence_hint: broadnessLevel === "high" ? "low" : broadnessLevel === "medium" ? "medium" : "high",
|
||
narrowing_strength: anchorScore >= 3 ? "strong" : anchorScore === 2 ? "medium" : "weak"
|
||
};
|
||
}
|
||
function enforceBroadQueryGuards(route, fragmentText, raw) {
|
||
if (!config_1.FEATURE_ASSISTANT_BROAD_GUARD_V1) {
|
||
return raw;
|
||
}
|
||
const assessed = detectBroadQuery(fragmentText, route);
|
||
const summary = {
|
||
...(raw.summary ?? {})
|
||
};
|
||
let antiGenericGuardApplied = false;
|
||
let minimumEvidenceFailed = false;
|
||
if (config_1.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 && assessed.broad_query_detected && route === "batch_refresh_then_store") {
|
||
antiGenericGuardApplied = true;
|
||
raw.items = raw.items.slice(0, 5);
|
||
pushUniqueLine(raw.selection_reason, "Anti-generic ranking guard applied for broad batch request.");
|
||
pushUniqueLine(raw.limitations, "Broad ranking output was tightened to avoid generic over-precision.");
|
||
if (raw.confidence === "high") {
|
||
raw.confidence = "medium";
|
||
}
|
||
}
|
||
if (config_1.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 && assessed.broad_query_detected && raw.status !== "error") {
|
||
const gate = ROUTE_MIN_EVIDENCE_GATE[route];
|
||
if (gate) {
|
||
const broadPenalty = assessed.broadness_level === "high" ? 1 : 0;
|
||
const requiredEvidence = gate.min_evidence_items + broadPenalty;
|
||
const requiredItems = gate.min_result_items + broadPenalty;
|
||
const evidenceCount = raw.evidence.length;
|
||
const resultItemsCount = raw.items.length;
|
||
const hasLimitedSupport = evidenceCount > 0 || resultItemsCount > 0;
|
||
minimumEvidenceFailed = evidenceCount < requiredEvidence || resultItemsCount < requiredItems;
|
||
if (minimumEvidenceFailed) {
|
||
if (hasLimitedSupport) {
|
||
raw.status = "partial";
|
||
pushUniqueLine(raw.limitations, "Broad query support is limited; output degraded to partial confidence.");
|
||
pushUniqueLine(raw.selection_reason, "Route-aware minimum evidence gate downgraded broad output to partial.");
|
||
summary.degraded_to = "partial";
|
||
if (raw.confidence === "high") {
|
||
raw.confidence = "medium";
|
||
}
|
||
}
|
||
else {
|
||
raw.status = "empty";
|
||
pushUniqueLine(raw.limitations, "Broad query lacks enough support for even a limited factual output.");
|
||
pushUniqueLine(raw.selection_reason, "Route-aware minimum evidence gate requires clarification before retrieval output.");
|
||
raw.confidence = "low";
|
||
summary.degraded_to = "clarification";
|
||
}
|
||
}
|
||
}
|
||
}
|
||
summary.broad_query_detected = assessed.broad_query_detected;
|
||
summary.broadness_level = assessed.broadness_level;
|
||
summary.scope_confidence_hint = assessed.scope_confidence_hint;
|
||
summary.narrowing_strength = assessed.narrowing_strength;
|
||
summary.broad_guard_applied = assessed.broad_query_detected;
|
||
summary.minimum_evidence_failed = minimumEvidenceFailed;
|
||
summary.anti_generic_guard_applied = antiGenericGuardApplied;
|
||
summary.broad_result_flag = assessed.broad_query_detected;
|
||
raw.summary = summary;
|
||
return raw;
|
||
}
|
||
const CLOSE_COST_ACCOUNTS = ["20", "21", "23", "25", "26", "28", "29", "44"];
|
||
const P0_DOMAIN_CARDS = [
|
||
{
|
||
id: "settlements_60_62",
|
||
title: "Settlements and bank flow (60-62)",
|
||
account_scope: ["51", "60", "62"],
|
||
domain_scope: ["bank", "settlements", "suppliers", "customers", "supplier_payments"],
|
||
allowed_entities: ["document", "counterparty", "contract", "posting"],
|
||
allowed_evidence_sources: {
|
||
risk: ["problemCases", "keyFields", "docs"],
|
||
canonical: ["docs", "keyFields"]
|
||
},
|
||
expected_edges: ["payment_to_settlement", "statement_to_document", "contract_to_documents", "document_to_posting"],
|
||
forbidden_cross_domain_leakage: ["vat", "taxes", "deferred_expense", "fixed_assets", "period_close"],
|
||
symptom_markers: [
|
||
/payment/i,
|
||
/settlement/i,
|
||
/\b60\b/,
|
||
/\b62\b/,
|
||
/\b51\b/,
|
||
/\u043e\u043f\u043b\u0430\u0442/i,
|
||
/\u0440\u0430\u0441\u0447\u0435\u0442/i,
|
||
/\u043d\u0435\s+\u0437\u0430\u043a\u0440/i,
|
||
/\u0445\u0432\u043e\u0441\u0442/i
|
||
]
|
||
},
|
||
{
|
||
id: "vat_document_register_book",
|
||
title: "VAT flow document -> register -> book",
|
||
account_scope: ["19", "68"],
|
||
domain_scope: ["vat", "taxes"],
|
||
allowed_entities: ["document", "tax_entry", "posting", "counterparty"],
|
||
allowed_evidence_sources: {
|
||
risk: ["ndsRegisters", "keyFields", "problemCases"],
|
||
canonical: ["ndsRegisters", "keyFields", "docs"]
|
||
},
|
||
expected_edges: ["invoice_to_vat", "document_to_posting", "contract_to_documents"],
|
||
forbidden_cross_domain_leakage: ["bank", "settlements", "suppliers", "customers", "deferred_expense", "fixed_assets", "period_close"],
|
||
symptom_markers: [
|
||
/\bvat\b/i,
|
||
/\u043d\u0434\u0441/i,
|
||
/\u0441\u0447[её]т(?:а|у|ом|е)?.?фактур/i,
|
||
/\u043a\u043d\u0438\u0433[аи]\s+\u043f\u043e\u043a\u0443\u043f/i,
|
||
/\u043a\u043d\u0438\u0433[аи]\s+\u043f\u0440\u043e\u0434\u0430\u0436/i,
|
||
/\u0432\u044b\u0447\u0435\u0442/i,
|
||
/\u043d\u0430\u043b\u043e\u0433\u043e\u0432(?:\u044b\u0439|\u043e\u0433\u043e)?\s+\u044d\u0444\u0444\u0435\u043a\u0442/i
|
||
]
|
||
},
|
||
{
|
||
id: "month_close_costs_20_44",
|
||
title: "Month close and costs flow (20-44)",
|
||
account_scope: CLOSE_COST_ACCOUNTS,
|
||
domain_scope: ["period_close", "deferred_expense"],
|
||
allowed_entities: ["document", "posting", "contract"],
|
||
allowed_evidence_sources: {
|
||
risk: ["problemCases", "docs", "journals", "keyFields"],
|
||
canonical: ["docs", "journals", "keyFields"]
|
||
},
|
||
expected_edges: ["document_to_posting", "deferred_expense_to_writeoff", "contract_to_documents"],
|
||
forbidden_cross_domain_leakage: ["vat", "taxes", "bank", "settlements", "suppliers", "customers", "fixed_assets"],
|
||
symptom_markers: [
|
||
/period\s*close/i,
|
||
/month\s*close/i,
|
||
/close\s+period/i,
|
||
/закрыт[а-яё]*\s+период/i,
|
||
/close\s+operation/i,
|
||
/allocation/i,
|
||
/закр/i,
|
||
/перио/i,
|
||
/\u0437\u0430\u043a\u0440\u044b\u0442(?:\u0438|\u0438\u0435|\u044b|)\s*(?:\u043c\u0435\u0441\u044f\u0446|\u0441\u0447\u0435\u0442)/i,
|
||
/\u0440\u0435\u0433\u043b\u0430\u043c\u0435\u043d\u0442/i,
|
||
/\u0437\u0430\u0442\u0440\u0430\u0442/i,
|
||
/\u0440\u0430\u0441\u043f\u0440\u0435\u0434\u0435\u043b/i,
|
||
/\u0440\u0431\u043f/i,
|
||
/\u0430\u043c\u043e\u0440\u0442\u0438\u0437/i
|
||
]
|
||
}
|
||
];
|
||
function parseDateCandidate(value) {
|
||
if (typeof value !== "string") {
|
||
return null;
|
||
}
|
||
const time = Date.parse(value);
|
||
if (Number.isNaN(time)) {
|
||
return null;
|
||
}
|
||
return time;
|
||
}
|
||
function extractDate(record) {
|
||
const attrs = record.attributes ?? {};
|
||
const directKeys = ["Period", "Date", "Дата", "Период", "ДатаСобытия"];
|
||
for (const key of directKeys) {
|
||
if (attrs[key] !== undefined && attrs[key] !== null) {
|
||
return String(attrs[key]);
|
||
}
|
||
}
|
||
for (const [key, value] of Object.entries(attrs)) {
|
||
if (/period|date|дата|период/i.test(key) && typeof value === "string" && value.trim()) {
|
||
return value;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
function countZeroGuidValues(record) {
|
||
const attrs = record.attributes ?? {};
|
||
let count = 0;
|
||
for (const value of Object.values(attrs)) {
|
||
if (typeof value === "string" && value.trim() === "00000000-0000-0000-0000-000000000000") {
|
||
count += 1;
|
||
}
|
||
}
|
||
return count;
|
||
}
|
||
function countNavigationLinks(record) {
|
||
const attrs = record.attributes ?? {};
|
||
let count = 0;
|
||
for (const key of Object.keys(attrs)) {
|
||
if (key.includes("@navigationLinkUrl")) {
|
||
count += 1;
|
||
}
|
||
}
|
||
return count;
|
||
}
|
||
function findCounterpartyLinks(record) {
|
||
return record.links.filter((link) => link.target_entity === "Counterparty" || /supplier|buyer|counterparty/i.test(link.relation) || /постав|покуп/i.test(link.source_field));
|
||
}
|
||
function extractGuids(text) {
|
||
const matches = text.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi) ?? [];
|
||
return Array.from(new Set(matches.map((item) => item.toLowerCase())));
|
||
}
|
||
function hasGuidMatch(record, guid) {
|
||
const source = record.source_id.toLowerCase();
|
||
if (source.includes(guid)) {
|
||
return true;
|
||
}
|
||
for (const link of record.links) {
|
||
if (String(link.target_id ?? "").toLowerCase() === guid) {
|
||
return true;
|
||
}
|
||
}
|
||
for (const value of Object.values(record.attributes ?? {})) {
|
||
if (typeof value === "string" && value.toLowerCase().includes(guid)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
function normalizeBusinessDateToken(value) {
|
||
const raw = String(value ?? "").trim();
|
||
if (!raw) {
|
||
return null;
|
||
}
|
||
const dayMonthYear = raw.match(/^(\d{1,2})[./-](\d{1,2})[./-](\d{2}|\d{4})$/);
|
||
if (!dayMonthYear) {
|
||
return null;
|
||
}
|
||
const day = dayMonthYear[1].padStart(2, "0");
|
||
const month = dayMonthYear[2].padStart(2, "0");
|
||
const yearRaw = dayMonthYear[3];
|
||
const year = yearRaw.length === 2 ? `20${yearRaw}` : yearRaw;
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
function extractBusinessAnchorsFromText(fragmentText) {
|
||
const text = String(fragmentText ?? "");
|
||
const lower = text.toLowerCase();
|
||
const documentNumbers = Array.from(new Set((text.match(/(?:№|#)\s*([a-zа-я0-9][a-zа-я0-9\-/.]{0,31})/giu) ?? [])
|
||
.map((item) => item.replace(/^(?:№|#)\s*/u, "").trim().toLowerCase())
|
||
.filter((item) => item.length > 0))).slice(0, 6);
|
||
const dateTokens = Array.from(new Set((text.match(/\b(?:0?[1-9]|[12]\d|3[01])[./-](?:0?[1-9]|1[0-2])[./-](?:\d{2}|\d{4})\b/g) ?? []).map((item) => item.trim()))).slice(0, 8);
|
||
const dateIso = Array.from(new Set(dateTokens.map((item) => normalizeBusinessDateToken(item)).filter((item) => Boolean(item))));
|
||
const amountValues = Array.from(new Set((text.match(/\b\d{1,3}(?:[ \u00A0]\d{3})*(?:[,.]\d{2})\b/g) ?? [])
|
||
.map((item) => parseFiniteNumber(item.replace(/\s|\u00A0/g, "")))
|
||
.filter((item) => item !== null))).slice(0, 6);
|
||
const accountScope = extractAccountScopeFromText(lower);
|
||
const periodScope = inferPeriodScope(lower);
|
||
const periodKeys = uniqueStrings([
|
||
...(periodScope.from ? [String(periodScope.from).slice(0, 7)] : []),
|
||
...(periodScope.to ? [String(periodScope.to).slice(0, 7)] : []),
|
||
...dateIso.map((item) => item.slice(0, 7))
|
||
].filter((item) => /^\d{4}-\d{2}$/.test(item))).slice(0, 6);
|
||
let score = 0;
|
||
if (documentNumbers.length > 0)
|
||
score += 2;
|
||
if (dateTokens.length > 0 || periodKeys.length > 0)
|
||
score += 1;
|
||
if (amountValues.length > 0)
|
||
score += 1;
|
||
if (accountScope.length > 0)
|
||
score += 1;
|
||
return {
|
||
document_numbers: documentNumbers,
|
||
date_tokens: dateTokens,
|
||
date_iso: dateIso,
|
||
amount_values: amountValues,
|
||
account_scope: accountScope,
|
||
period_keys: periodKeys,
|
||
sufficient: score >= 3 && (documentNumbers.length > 0 || (dateTokens.length > 0 && amountValues.length > 0))
|
||
};
|
||
}
|
||
function extractAmountSignalsFromRecord(record) {
|
||
const values = [];
|
||
for (const [key, rawValue] of Object.entries(record.attributes ?? {})) {
|
||
if (!/sum|amount|итого|сумм|оплат|долг/i.test(key)) {
|
||
continue;
|
||
}
|
||
const parsed = parseFiniteNumber(rawValue);
|
||
if (parsed !== null) {
|
||
values.push(parsed);
|
||
}
|
||
}
|
||
return uniqueStrings(values.map((item) => item.toFixed(2))).map((item) => Number(item));
|
||
}
|
||
function matchesAmountAnchor(recordAmounts, anchors) {
|
||
if (recordAmounts.length === 0 || anchors.length === 0) {
|
||
return false;
|
||
}
|
||
return anchors.some((anchor) => recordAmounts.some((value) => Math.abs(value - anchor) <= 0.05));
|
||
}
|
||
function scoreRecordForBusinessAnchorTrace(record, anchors) {
|
||
const matchedCategories = [];
|
||
const corpus = collectTextFromRecord(record).toLowerCase();
|
||
const recordPeriod = extractDate(record);
|
||
const recordPeriodIso = recordPeriod ? String(recordPeriod).slice(0, 10) : "";
|
||
const recordPeriodMonth = recordPeriod ? String(recordPeriod).slice(0, 7) : "";
|
||
let score = 0;
|
||
if (anchors.document_numbers.length > 0) {
|
||
const docMatch = anchors.document_numbers.some((item) => new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRegExp(item)}($|[^\\p{L}\\p{N}])`, "iu").test(corpus));
|
||
if (docMatch) {
|
||
matchedCategories.push("document_number");
|
||
score += 3;
|
||
}
|
||
}
|
||
if (anchors.date_tokens.length > 0 || anchors.date_iso.length > 0) {
|
||
const rawDateHit = anchors.date_tokens.some((item) => corpus.includes(item.toLowerCase()));
|
||
const isoDateHit = anchors.date_iso.some((item) => recordPeriodIso.startsWith(item));
|
||
const monthHit = anchors.period_keys.some((item) => recordPeriodMonth.startsWith(item));
|
||
if (rawDateHit || isoDateHit || monthHit) {
|
||
matchedCategories.push("date_or_period");
|
||
score += 2;
|
||
}
|
||
}
|
||
if (anchors.amount_values.length > 0) {
|
||
const amountHit = matchesAmountAnchor(extractAmountSignalsFromRecord(record), anchors.amount_values);
|
||
if (amountHit) {
|
||
matchedCategories.push("amount");
|
||
score += 3;
|
||
}
|
||
}
|
||
if (anchors.account_scope.length > 0) {
|
||
const accounts = inferAccountsFromRecord(record, corpus);
|
||
if (intersects(anchors.account_scope, accounts)) {
|
||
matchedCategories.push("account_scope");
|
||
score += 2;
|
||
}
|
||
}
|
||
if (anchors.period_keys.length > 0 && !matchedCategories.includes("date_or_period")) {
|
||
if (anchors.period_keys.some((item) => recordPeriodMonth.startsWith(item))) {
|
||
matchedCategories.push("period");
|
||
score += 1;
|
||
}
|
||
}
|
||
return {
|
||
score,
|
||
matched_categories: matchedCategories
|
||
};
|
||
}
|
||
const ACCOUNT_PRESETS = {
|
||
"51": {
|
||
domains: ["bank", "settlements", "supplier_payments"],
|
||
documents: ["bank_statement", "payment_order", "settlement_document"],
|
||
entities: ["counterparty", "contract", "document", "posting"],
|
||
relations: ["payment_to_settlement", "statement_to_document", "document_to_posting"],
|
||
anomalies: ["missing_link", "posting_mismatch", "closure_risk"]
|
||
},
|
||
"60": {
|
||
domains: ["suppliers", "settlements", "supplier_payments"],
|
||
documents: ["supplier_receipt", "payment_order", "settlement_document"],
|
||
entities: ["counterparty", "contract", "document", "posting"],
|
||
relations: ["payment_to_settlement", "contract_to_documents", "document_to_posting"],
|
||
anomalies: ["missing_link", "broken_lifecycle", "closure_risk"]
|
||
},
|
||
"62": {
|
||
domains: ["customers", "settlements"],
|
||
documents: ["sales_document", "payment_order", "settlement_document"],
|
||
entities: ["counterparty", "contract", "document", "posting"],
|
||
relations: ["payment_to_settlement", "contract_to_documents", "document_to_posting"],
|
||
anomalies: ["missing_link", "broken_lifecycle", "closure_risk"]
|
||
},
|
||
"76": {
|
||
domains: ["other_settlements", "settlements"],
|
||
documents: ["manual_operation", "settlement_document"],
|
||
entities: ["counterparty", "contract", "document", "posting"],
|
||
relations: ["contract_to_documents", "document_to_posting"],
|
||
anomalies: ["silent_orphan", "manual_intervention_suspicion", "closure_risk"]
|
||
},
|
||
"97": {
|
||
domains: ["deferred_expense", "period_close"],
|
||
documents: ["deferred_expense_document", "manual_operation", "period_close_document"],
|
||
entities: ["document", "posting"],
|
||
relations: ["deferred_expense_to_writeoff", "document_to_posting"],
|
||
anomalies: ["broken_lifecycle", "missing_link", "closure_risk"]
|
||
},
|
||
"01": {
|
||
domains: ["fixed_assets"],
|
||
documents: ["fixed_asset_card", "fixed_asset_acceptance", "depreciation_document"],
|
||
entities: ["fixed_asset", "document", "posting"],
|
||
relations: ["asset_card_to_depreciation", "document_to_posting"],
|
||
anomalies: ["broken_lifecycle", "missing_link", "posting_mismatch"]
|
||
},
|
||
"02": {
|
||
domains: ["fixed_assets"],
|
||
documents: ["depreciation_document", "fixed_asset_card"],
|
||
entities: ["fixed_asset", "document", "posting"],
|
||
relations: ["asset_card_to_depreciation", "document_to_posting"],
|
||
anomalies: ["broken_lifecycle", "posting_mismatch"]
|
||
},
|
||
"08": {
|
||
domains: ["fixed_assets"],
|
||
documents: ["fixed_asset_acceptance", "fixed_asset_card", "manual_operation"],
|
||
entities: ["fixed_asset", "document", "posting"],
|
||
relations: ["asset_card_to_depreciation", "document_to_posting"],
|
||
anomalies: ["missing_link", "broken_lifecycle", "posting_mismatch"]
|
||
},
|
||
"20": {
|
||
domains: ["period_close", "deferred_expense"],
|
||
documents: ["period_close_document", "deferred_expense_document"],
|
||
entities: ["document", "posting", "contract"],
|
||
relations: ["document_to_posting", "deferred_expense_to_writeoff"],
|
||
anomalies: ["closure_risk", "broken_lifecycle", "missing_link"]
|
||
},
|
||
"21": {
|
||
domains: ["period_close", "deferred_expense"],
|
||
documents: ["period_close_document", "deferred_expense_document"],
|
||
entities: ["document", "posting", "contract"],
|
||
relations: ["document_to_posting", "deferred_expense_to_writeoff"],
|
||
anomalies: ["closure_risk", "broken_lifecycle", "missing_link"]
|
||
},
|
||
"23": {
|
||
domains: ["period_close", "deferred_expense"],
|
||
documents: ["period_close_document", "deferred_expense_document"],
|
||
entities: ["document", "posting", "contract"],
|
||
relations: ["document_to_posting", "deferred_expense_to_writeoff"],
|
||
anomalies: ["closure_risk", "broken_lifecycle", "missing_link"]
|
||
},
|
||
"25": {
|
||
domains: ["period_close", "deferred_expense"],
|
||
documents: ["period_close_document", "deferred_expense_document"],
|
||
entities: ["document", "posting", "contract"],
|
||
relations: ["document_to_posting", "deferred_expense_to_writeoff"],
|
||
anomalies: ["closure_risk", "broken_lifecycle", "missing_link"]
|
||
},
|
||
"26": {
|
||
domains: ["period_close", "deferred_expense"],
|
||
documents: ["period_close_document", "deferred_expense_document"],
|
||
entities: ["document", "posting", "contract"],
|
||
relations: ["document_to_posting", "deferred_expense_to_writeoff"],
|
||
anomalies: ["closure_risk", "broken_lifecycle", "missing_link"]
|
||
},
|
||
"28": {
|
||
domains: ["period_close", "deferred_expense"],
|
||
documents: ["period_close_document", "deferred_expense_document"],
|
||
entities: ["document", "posting", "contract"],
|
||
relations: ["document_to_posting", "deferred_expense_to_writeoff"],
|
||
anomalies: ["closure_risk", "broken_lifecycle", "missing_link"]
|
||
},
|
||
"29": {
|
||
domains: ["period_close", "deferred_expense"],
|
||
documents: ["period_close_document", "deferred_expense_document"],
|
||
entities: ["document", "posting", "contract"],
|
||
relations: ["document_to_posting", "deferred_expense_to_writeoff"],
|
||
anomalies: ["closure_risk", "broken_lifecycle", "missing_link"]
|
||
},
|
||
"44": {
|
||
domains: ["period_close", "deferred_expense"],
|
||
documents: ["period_close_document", "deferred_expense_document"],
|
||
entities: ["document", "posting", "contract"],
|
||
relations: ["document_to_posting", "deferred_expense_to_writeoff"],
|
||
anomalies: ["closure_risk", "broken_lifecycle", "missing_link"]
|
||
},
|
||
"19": {
|
||
domains: ["vat"],
|
||
documents: ["invoice", "vat_document", "supplier_receipt"],
|
||
entities: ["document", "tax_entry", "counterparty"],
|
||
relations: ["invoice_to_vat", "document_to_posting"],
|
||
anomalies: ["missing_link", "cross_domain_inconsistency", "closure_risk"]
|
||
},
|
||
"68": {
|
||
domains: ["vat", "taxes"],
|
||
documents: ["invoice", "vat_document", "period_close_document"],
|
||
entities: ["document", "tax_entry", "posting"],
|
||
relations: ["invoice_to_vat", "document_to_posting"],
|
||
anomalies: ["missing_link", "cross_domain_inconsistency", "closure_risk"]
|
||
}
|
||
};
|
||
const GRAPH_DOMAIN_PRESETS = {
|
||
bank_settlement: {
|
||
relations: ["payment_to_settlement", "statement_to_document", "document_to_posting", "contract_to_documents"],
|
||
signals: ["missing_link", "broken_lifecycle", "posting_mismatch", "wrong_document_type", "closure_risk"],
|
||
lifecycle_markers: ["closed", "reconciled", "partially_linked", "no_continuation", "period_boundary"]
|
||
},
|
||
customer_settlement: {
|
||
relations: ["contract_to_documents", "payment_to_settlement", "document_to_posting"],
|
||
signals: ["missing_link", "broken_lifecycle", "closure_risk"],
|
||
lifecycle_markers: ["closed", "reconciled", "partially_linked", "no_continuation", "period_boundary"]
|
||
},
|
||
deferred_expense: {
|
||
relations: ["deferred_expense_to_writeoff", "document_to_posting"],
|
||
signals: ["missing_link", "broken_lifecycle", "closure_risk", "amount_independent_risk"],
|
||
lifecycle_markers: ["closed", "partially_linked", "period_boundary", "no_continuation", "reconciled"]
|
||
},
|
||
fixed_asset: {
|
||
relations: ["asset_card_to_depreciation", "document_to_posting"],
|
||
signals: ["missing_link", "broken_lifecycle", "posting_mismatch", "closure_risk"],
|
||
lifecycle_markers: ["closed", "partially_linked", "period_boundary", "no_continuation", "reconciled"]
|
||
},
|
||
vat_flow: {
|
||
relations: ["invoice_to_vat", "document_to_posting", "contract_to_documents"],
|
||
signals: ["missing_link", "cross_domain_inconsistency", "posting_mismatch", "closure_risk"],
|
||
lifecycle_markers: ["closed", "partially_linked", "period_boundary", "reconciled", "no_continuation"]
|
||
},
|
||
period_close: {
|
||
relations: ["document_to_posting", "deferred_expense_to_writeoff", "invoice_to_vat", "asset_card_to_depreciation"],
|
||
signals: ["closure_risk", "broken_lifecycle", "cross_domain_inconsistency", "missing_link"],
|
||
lifecycle_markers: ["posted", "closed", "period_boundary", "partially_linked", "no_continuation"]
|
||
}
|
||
};
|
||
const ACCOUNT_GRAPH_DOMAIN_MAP = {
|
||
"51": "bank_settlement",
|
||
"60": "bank_settlement",
|
||
"62": "customer_settlement",
|
||
"76": "bank_settlement",
|
||
"97": "deferred_expense",
|
||
"01": "fixed_asset",
|
||
"02": "fixed_asset",
|
||
"08": "fixed_asset",
|
||
"19": "vat_flow",
|
||
"68": "vat_flow"
|
||
};
|
||
const GRAPH_INTENT_MARKERS = /(?:lifecycle|transition|state|expected|actual|terminal|closing|chain|break|conflict|cross[-_\s]?branch|\u0436\u0438\u0437\u043d\u0435\u043d\u043d|\u043f\u0435\u0440\u0435\u0445\u043e\u0434|\u043e\u0436\u0438\u0434\u0430\u0435\u043c|\u0444\u0430\u043a\u0442\u0438\u0447|\u0440\u0430\u0437\u0440\u044b\u0432|\u043a\u043e\u043d\u0444\u043b\u0438\u043a\u0442|\u0432\u0435\u0442\u043a)/iu;
|
||
function uniqueStrings(items) {
|
||
return Array.from(new Set(items.map((item) => item.trim()).filter(Boolean)));
|
||
}
|
||
function pushMany(target, values) {
|
||
for (const value of values) {
|
||
target.push(value);
|
||
}
|
||
}
|
||
function toGraphDomain(domain) {
|
||
if (domain === "deferred_expense") {
|
||
return "deferred_expense";
|
||
}
|
||
if (domain === "fixed_assets") {
|
||
return "fixed_asset";
|
||
}
|
||
if (domain === "vat" || domain === "taxes") {
|
||
return "vat_flow";
|
||
}
|
||
if (domain === "period_close") {
|
||
return "period_close";
|
||
}
|
||
if (domain === "customers") {
|
||
return "customer_settlement";
|
||
}
|
||
if (domain === "bank" ||
|
||
domain === "settlements" ||
|
||
domain === "suppliers" ||
|
||
domain === "supplier_payments" ||
|
||
domain === "other_settlements") {
|
||
return "bank_settlement";
|
||
}
|
||
return null;
|
||
}
|
||
function toGraphDomainScope(domains, accounts) {
|
||
const resolved = [];
|
||
for (const domain of domains) {
|
||
const mapped = toGraphDomain(domain);
|
||
if (mapped) {
|
||
resolved.push(mapped);
|
||
}
|
||
}
|
||
for (const account of accounts) {
|
||
const mapped = ACCOUNT_GRAPH_DOMAIN_MAP[account];
|
||
if (mapped) {
|
||
resolved.push(mapped);
|
||
if (mapped === "deferred_expense") {
|
||
resolved.push("period_close");
|
||
}
|
||
}
|
||
}
|
||
return uniqueStrings(resolved);
|
||
}
|
||
function buildGraphTraversalProfile(input) {
|
||
const targetDomains = toGraphDomainScope(input.domainScope, input.accountScope);
|
||
const targetRelations = [...input.relationPatterns];
|
||
const targetSignals = [...input.anomalyPatterns];
|
||
const targetLifecycleMarkers = ["closed", "reconciled", "partially_linked", "period_boundary", "no_continuation"];
|
||
for (const domain of targetDomains) {
|
||
const preset = GRAPH_DOMAIN_PRESETS[domain];
|
||
if (!preset) {
|
||
continue;
|
||
}
|
||
pushMany(targetRelations, preset.relations);
|
||
pushMany(targetSignals, preset.signals);
|
||
pushMany(targetLifecycleMarkers, preset.lifecycle_markers);
|
||
}
|
||
const graphIntent = GRAPH_INTENT_MARKERS.test(input.text) ||
|
||
input.anomalyPatterns.some((item) => item === "broken_lifecycle" || item === "closure_risk" || item === "cross_domain_inconsistency");
|
||
const eligible = graphIntent || targetDomains.length > 0;
|
||
return {
|
||
runtime_enabled: config_1.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1,
|
||
eligible,
|
||
planner_mode: config_1.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 && eligible ? "typed_domain_path" : "semantic_only",
|
||
target_domains: targetDomains,
|
||
target_relations: uniqueStrings(targetRelations),
|
||
target_signals: uniqueStrings(targetSignals),
|
||
target_lifecycle_markers: uniqueStrings(targetLifecycleMarkers)
|
||
};
|
||
}
|
||
function inferGraphRuntimeSignals(signals) {
|
||
const runtimeSignals = [];
|
||
if (signals.anomaly_patterns.includes("missing_link") ||
|
||
signals.lifecycle_markers.includes("partially_linked") ||
|
||
signals.lifecycle_markers.includes("no_continuation")) {
|
||
runtimeSignals.push("missing_transition");
|
||
}
|
||
if (signals.anomaly_patterns.includes("posting_mismatch") || signals.anomaly_patterns.includes("cross_domain_inconsistency")) {
|
||
runtimeSignals.push("conflicting_transition");
|
||
}
|
||
if (signals.anomaly_patterns.includes("closure_risk") || signals.lifecycle_markers.includes("period_boundary")) {
|
||
runtimeSignals.push("terminal_state_gap");
|
||
}
|
||
if (signals.anomaly_patterns.includes("wrong_document_type")) {
|
||
runtimeSignals.push("wrong_closing_document_type");
|
||
}
|
||
return uniqueStrings(runtimeSignals);
|
||
}
|
||
function summarizeGraphTraversalRuntime(candidates, profile) {
|
||
const domainHits = new Map();
|
||
const signalCounts = new Map();
|
||
let matchedCandidates = 0;
|
||
let neighborBranchLiftedCandidates = 0;
|
||
let crossBranchConflictCandidates = 0;
|
||
let terminalGapCandidates = 0;
|
||
let multiHopCandidates = 0;
|
||
let maxRelationHops = 0;
|
||
const rankingShiftSignals = new Set();
|
||
for (const candidate of candidates) {
|
||
if (candidate.evaluation.graph_traversal_score > 0) {
|
||
matchedCandidates += 1;
|
||
}
|
||
const relationHopCount = candidate.evaluation.signals.relation_patterns.length;
|
||
maxRelationHops = Math.max(maxRelationHops, relationHopCount);
|
||
if (relationHopCount >= 2 && candidate.evaluation.graph_traversal_score > 0) {
|
||
multiHopCandidates += 1;
|
||
}
|
||
const hasTargetDomain = candidate.evaluation.graph_domain_scope.some((domain) => profile.graph_traversal.target_domains.includes(domain));
|
||
const hasNeighborDomain = candidate.evaluation.graph_domain_scope.some((domain) => !profile.graph_traversal.target_domains.includes(domain));
|
||
if (hasTargetDomain && hasNeighborDomain) {
|
||
neighborBranchLiftedCandidates += 1;
|
||
rankingShiftSignals.add("neighbor_branch_lifting");
|
||
}
|
||
for (const domain of candidate.evaluation.graph_domain_scope) {
|
||
if (!profile.graph_traversal.target_domains.includes(domain)) {
|
||
continue;
|
||
}
|
||
domainHits.set(domain, (domainHits.get(domain) ?? 0) + 1);
|
||
}
|
||
for (const signal of candidate.evaluation.graph_runtime_signals) {
|
||
signalCounts.set(signal, (signalCounts.get(signal) ?? 0) + 1);
|
||
}
|
||
if (candidate.evaluation.graph_runtime_signals.includes("conflicting_transition")) {
|
||
crossBranchConflictCandidates += 1;
|
||
rankingShiftSignals.add("cross_branch_conflict");
|
||
}
|
||
if (candidate.evaluation.graph_runtime_signals.includes("terminal_state_gap")) {
|
||
terminalGapCandidates += 1;
|
||
rankingShiftSignals.add("terminal_gap");
|
||
}
|
||
}
|
||
if (multiHopCandidates > 0) {
|
||
rankingShiftSignals.add("multi_hop_chain");
|
||
}
|
||
if (matchedCandidates > 0 && matchedCandidates < candidates.length) {
|
||
rankingShiftSignals.add("graph_selective_scoring");
|
||
}
|
||
return {
|
||
runtime_enabled: profile.graph_traversal.runtime_enabled,
|
||
graph_eligible: profile.graph_traversal.eligible,
|
||
planner_mode: profile.graph_traversal.planner_mode,
|
||
traversal_applied: profile.graph_traversal.runtime_enabled && profile.graph_traversal.eligible && candidates.length > 0,
|
||
target_domains: profile.graph_traversal.target_domains,
|
||
target_relations: profile.graph_traversal.target_relations,
|
||
target_signals: profile.graph_traversal.target_signals,
|
||
target_lifecycle_markers: profile.graph_traversal.target_lifecycle_markers,
|
||
evaluated_candidates: candidates.length,
|
||
matched_candidates: matchedCandidates,
|
||
domain_hits: Object.fromEntries(Array.from(domainHits.entries())),
|
||
signal_counts: Object.fromEntries(Array.from(signalCounts.entries())),
|
||
neighbor_branch_lifted_candidates: neighborBranchLiftedCandidates,
|
||
cross_branch_conflict_candidates: crossBranchConflictCandidates,
|
||
terminal_gap_candidates: terminalGapCandidates,
|
||
multi_hop_candidates: multiHopCandidates,
|
||
max_relation_hops: maxRelationHops,
|
||
ranking_shift_signals: Array.from(rankingShiftSignals)
|
||
};
|
||
}
|
||
function collectTextFromRecord(record) {
|
||
const parts = [record.source_entity, record.display_name, record.source_id];
|
||
for (const link of record.links) {
|
||
parts.push(link.relation, link.target_entity, link.target_id, link.source_field);
|
||
}
|
||
for (const [key, value] of Object.entries(record.attributes ?? {})) {
|
||
parts.push(key, String(value));
|
||
}
|
||
return parts.join(" ").toLowerCase();
|
||
}
|
||
const KNOWN_ACCOUNT_CODES = new Set([
|
||
"01",
|
||
"02",
|
||
"08",
|
||
"19",
|
||
"20",
|
||
"21",
|
||
"23",
|
||
"25",
|
||
"26",
|
||
"28",
|
||
"29",
|
||
"44",
|
||
"51",
|
||
"60",
|
||
"62",
|
||
"68",
|
||
"76",
|
||
"97"
|
||
]);
|
||
const ACCOUNT_CONTEXT_AROUND_MARKERS = /(?:счет|сч\.?|account|schet|оплат|расчет|расч[её]т|аванс|зачет|зач[её]т|ндс|период|закрыт|провод|постав|покуп|settlement|payment|vat|close|supplier|customer)/iu;
|
||
function collectDateLikeSpans(text) {
|
||
const spans = [];
|
||
const patterns = [
|
||
/\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b/g,
|
||
/\b20\d{2}(?:[./-](?:0[1-9]|1[0-2]))(?:[./-](?:0[1-9]|[12]\d|3[01]))?\b/g,
|
||
/\b(?:0?[1-9]|[12]\d|3[01])\s+(?:январ[ьяе]|феврал[ьяе]|март[ае]?|апрел[ьяе]|ма[йея]|июн[ьяе]?|июл[ьяе]?|август[ае]?|сентябр[ьяе]?|октябр[ьяе]?|ноябр[ьяе]?|декабр[ьяе]?|january|february|march|april|may|june|july|august|september|october|november|december)(?:\s+20\d{2})?\b/giu,
|
||
/\b(?:январ[ьяе]|феврал[ьяе]|март[ае]?|апрел[ьяе]|ма[йея]|июн[ьяе]?|июл[ьяе]?|август[ае]?|сентябр[ьяе]?|октябр[ьяе]?|ноябр[ьяе]?|декабр[ьяе]?|january|february|march|april|may|june|july|august|september|october|november|december)\s+20\d{2}\b/giu
|
||
];
|
||
for (const pattern of patterns) {
|
||
let match = null;
|
||
while ((match = pattern.exec(text)) !== null) {
|
||
spans.push({
|
||
start: match.index,
|
||
end: match.index + match[0].length
|
||
});
|
||
}
|
||
}
|
||
return spans;
|
||
}
|
||
function collectAmountLikeSpans(text) {
|
||
const spans = [];
|
||
const patterns = [
|
||
/\b\d{1,3}(?:[ \u00A0]\d{3})+(?:[.,]\d{2})?\b/g,
|
||
/\b\d+[.,]\d{2}\b/g
|
||
];
|
||
for (const pattern of patterns) {
|
||
let match = null;
|
||
while ((match = pattern.exec(text)) !== null) {
|
||
spans.push({
|
||
start: match.index,
|
||
end: match.index + match[0].length
|
||
});
|
||
}
|
||
}
|
||
return spans;
|
||
}
|
||
function collectPercentLikeSpans(text) {
|
||
const spans = [];
|
||
const pattern = /\b\d{1,3}(?:[.,]\d+)?\s*%/g;
|
||
let match = null;
|
||
while ((match = pattern.exec(text)) !== null) {
|
||
spans.push({
|
||
start: match.index,
|
||
end: match.index + match[0].length
|
||
});
|
||
}
|
||
return spans;
|
||
}
|
||
function intersectsSpan(start, end, spans) {
|
||
return spans.some((span) => start < span.end && end > span.start);
|
||
}
|
||
function hasAccountContextAround(text, start, end) {
|
||
const left = text.slice(Math.max(0, start - 28), start);
|
||
const right = text.slice(end, Math.min(text.length, end + 28));
|
||
return ACCOUNT_CONTEXT_AROUND_MARKERS.test(`${left} ${right}`);
|
||
}
|
||
function extractAccountScopeFromText(text) {
|
||
const lower = String(text ?? "").toLowerCase();
|
||
const blockedSpans = [
|
||
...collectDateLikeSpans(lower),
|
||
...collectAmountLikeSpans(lower),
|
||
...collectPercentLikeSpans(lower)
|
||
];
|
||
const accounts = [];
|
||
const pushAccount = (raw) => {
|
||
const prefix = String(raw ?? "").trim().match(/^(\d{2})/)?.[1];
|
||
if (!prefix) {
|
||
return;
|
||
}
|
||
if (!KNOWN_ACCOUNT_CODES.has(prefix)) {
|
||
return;
|
||
}
|
||
accounts.push(prefix);
|
||
};
|
||
const contextualPattern = /(?:\b(?:счет(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?)\b)\s*(?:№|#|:)?\s*([0-9./,\sиand]{2,96})/giu;
|
||
let contextualMatch = null;
|
||
while ((contextualMatch = contextualPattern.exec(lower)) !== null) {
|
||
const rawChunk = String(contextualMatch[1] ?? "");
|
||
const chunkAccounts = rawChunk.match(/\b\d{2}(?:\.\d{1,2})?\b/g) ?? [];
|
||
for (const account of chunkAccounts) {
|
||
pushAccount(account);
|
||
}
|
||
}
|
||
const settlementPairPattern = /\b(?:60|62)\.\d{1,2}\s*\/\s*(?:60|62)\.\d{1,2}\b/g;
|
||
let settlementPairMatch = null;
|
||
while ((settlementPairMatch = settlementPairPattern.exec(lower)) !== null) {
|
||
const pair = settlementPairMatch[0];
|
||
const pairAccounts = pair.match(/\b\d{2}(?:\.\d{1,2})?\b/g) ?? [];
|
||
for (const account of pairAccounts) {
|
||
pushAccount(account);
|
||
}
|
||
}
|
||
const closePairPattern = /\b(?:20|21|23|25|26|28|29|44)\s*[-/]\s*(?:20|21|23|25|26|28|29|44)\b/g;
|
||
let closePairMatch = null;
|
||
while ((closePairMatch = closePairPattern.exec(lower)) !== null) {
|
||
const pair = closePairMatch[0];
|
||
const pairAccounts = pair.match(/\b\d{2}(?:\.\d{1,2})?\b/g) ?? [];
|
||
for (const account of pairAccounts) {
|
||
pushAccount(account);
|
||
}
|
||
}
|
||
const suffixAnchorPattern = /\b(?:51|60|62|68|76|97)(?:\.\d{1,2})?(?:-(?:му|й|го|м|х))?\b/giu;
|
||
let suffixAnchorMatch = null;
|
||
while ((suffixAnchorMatch = suffixAnchorPattern.exec(lower)) !== null) {
|
||
const token = suffixAnchorMatch[0];
|
||
const start = suffixAnchorMatch.index;
|
||
const end = start + token.length;
|
||
if (intersectsSpan(start, end, blockedSpans)) {
|
||
continue;
|
||
}
|
||
pushAccount(token);
|
||
}
|
||
const explicitPattern = /\b\d{2}(?:\.\d{1,2})?\b/g;
|
||
let explicitMatch = null;
|
||
const settlementLexicalAnchor = /(оплат|расчет|расч[её]т|аванс|долг|постав|покуп|settlement|payment|supplier|customer)/i.test(lower);
|
||
while ((explicitMatch = explicitPattern.exec(lower)) !== null) {
|
||
const token = explicitMatch[0];
|
||
const start = explicitMatch.index;
|
||
const end = start + token.length;
|
||
if (intersectsSpan(start, end, blockedSpans)) {
|
||
continue;
|
||
}
|
||
const prefix = token.match(/^(\d{2})/)?.[1];
|
||
if (!prefix || !KNOWN_ACCOUNT_CODES.has(prefix)) {
|
||
continue;
|
||
}
|
||
if (prefix === "60" || prefix === "62" || prefix === "51" || prefix === "76") {
|
||
if (settlementLexicalAnchor || hasAccountContextAround(lower, start, end)) {
|
||
accounts.push(prefix);
|
||
}
|
||
continue;
|
||
}
|
||
if (hasAccountContextAround(lower, start, end)) {
|
||
accounts.push(prefix);
|
||
}
|
||
}
|
||
return uniqueStrings(accounts);
|
||
}
|
||
function inferPeriodScope(fragmentText) {
|
||
const dayMonthYear = fragmentText.match(/\b(0?[1-9]|[12]\d|3[01])[./-](0?[1-9]|1[0-2])[./-](\d{2}|\d{4})\b/);
|
||
if (dayMonthYear) {
|
||
const yearRaw = dayMonthYear[3];
|
||
const year = yearRaw.length === 2 ? `20${yearRaw}` : yearRaw;
|
||
const month = dayMonthYear[2].padStart(2, "0");
|
||
const day = dayMonthYear[1].padStart(2, "0");
|
||
return {
|
||
from: `${year}-${month}-${day}`,
|
||
to: null,
|
||
granularity: "day"
|
||
};
|
||
}
|
||
const month = fragmentText.match(/\b(20\d{2})[-./](0[1-9]|1[0-2])\b/);
|
||
if (month) {
|
||
return {
|
||
from: `${month[1]}-${month[2]}-01`,
|
||
to: null,
|
||
granularity: "month"
|
||
};
|
||
}
|
||
const monthNameToNumber = [
|
||
{ pattern: /январ|january/i, month: "01" },
|
||
{ pattern: /феврал|february/i, month: "02" },
|
||
{ pattern: /март|march/i, month: "03" },
|
||
{ pattern: /апрел|april/i, month: "04" },
|
||
{ pattern: /\bмай\b|\bмая\b|may/i, month: "05" },
|
||
{ pattern: /июн|june/i, month: "06" },
|
||
{ pattern: /июл|july/i, month: "07" },
|
||
{ pattern: /август|august/i, month: "08" },
|
||
{ pattern: /сентябр|september/i, month: "09" },
|
||
{ pattern: /октябр|october/i, month: "10" },
|
||
{ pattern: /ноябр|november/i, month: "11" },
|
||
{ pattern: /декабр|december/i, month: "12" }
|
||
];
|
||
const yearForMonthName = fragmentText.match(/\b(20\d{2})\b/)?.[1] ?? null;
|
||
if (yearForMonthName) {
|
||
const monthByName = monthNameToNumber.find((item) => item.pattern.test(fragmentText));
|
||
if (monthByName) {
|
||
return {
|
||
from: `${yearForMonthName}-${monthByName.month}-01`,
|
||
to: null,
|
||
granularity: "month"
|
||
};
|
||
}
|
||
}
|
||
if (monthNameToNumber.some((item) => item.pattern.test(fragmentText))) {
|
||
return {
|
||
from: null,
|
||
to: null,
|
||
granularity: "month"
|
||
};
|
||
}
|
||
const year = fragmentText.match(/\b(20\d{2})\b/);
|
||
if (year) {
|
||
return {
|
||
from: `${year[1]}-01-01`,
|
||
to: `${year[1]}-12-31`,
|
||
granularity: "year"
|
||
};
|
||
}
|
||
if (/квартал|quarter/i.test(fragmentText)) {
|
||
return {
|
||
from: null,
|
||
to: null,
|
||
granularity: "quarter"
|
||
};
|
||
}
|
||
if (/месяц|month|период/i.test(fragmentText)) {
|
||
return {
|
||
from: null,
|
||
to: null,
|
||
granularity: "month"
|
||
};
|
||
}
|
||
return {
|
||
from: null,
|
||
to: null,
|
||
granularity: "unknown"
|
||
};
|
||
}
|
||
const WRONG_DOCUMENT_MARKERS = /(?:\u043d\u0435\s+\u0442\u0435\u043c\s+\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442(?:\u043e\u043c)?|\u043d\u0435\s+\u0432\s+\u0442\u043e\u0442|wrong\s+document|wrong_document_type)/iu;
|
||
const REPEATED_ANOMALY_MARKERS = /(?:\u043f\u043e\u0432\u0442\u043e\u0440\u044f\u044e\u0449|\u0441\u0435\u0440\u0438\u0439\u043d|\u043f\u0430\u0442\u0442\u0435\u0440\u043d|repeat(?:ed|ability)?)/iu;
|
||
function inferQuerySubject(text, domains, anomalies) {
|
||
const lower = text.toLowerCase();
|
||
if ((domains.includes("bank") || domains.includes("settlements")) && WRONG_DOCUMENT_MARKERS.test(lower)) {
|
||
return "bank_settlement_mismatch";
|
||
}
|
||
if (domains.includes("suppliers")) {
|
||
return "supplier_tail_analysis";
|
||
}
|
||
if (domains.includes("customers")) {
|
||
return "customer_closure_gap";
|
||
}
|
||
if (domains.includes("deferred_expense")) {
|
||
return "deferred_expense_lifecycle_anomaly";
|
||
}
|
||
if (domains.includes("fixed_assets")) {
|
||
return "fixed_asset_card_mismatch";
|
||
}
|
||
if (domains.includes("vat")) {
|
||
return "vat_chain_conflict";
|
||
}
|
||
if (domains.includes("period_close")) {
|
||
return "period_closure_risk";
|
||
}
|
||
if (anomalies.includes("posting_mismatch")) {
|
||
return "document_posting_conflict";
|
||
}
|
||
return "cross_entity_breakage";
|
||
}
|
||
function buildSemanticRetrievalProfile(fragmentText) {
|
||
const lower = fragmentText.toLowerCase();
|
||
const accountScope = extractAccountScopeFromText(lower);
|
||
const domainScope = [];
|
||
const documentTypes = [];
|
||
const entityTypes = [];
|
||
const relationPatterns = [];
|
||
const anomalyPatterns = [];
|
||
const excludedInterpretations = [];
|
||
const rankingBasis = ["closure_risk", "repeatability", "financial_impact"];
|
||
const explanationFocus = ["why_selected", "where_chain_breaks", "what_business_risk"];
|
||
if (/банк|выписк|расчетн|платеж|банк|выписк|расчетн|платеж|bank|payment|statement|platezh|vypisk/i.test(lower)) {
|
||
pushMany(domainScope, ["bank", "settlements"]);
|
||
pushMany(documentTypes, ["bank_statement", "payment_order", "settlement_document"]);
|
||
pushMany(entityTypes, ["counterparty", "contract", "document", "posting"]);
|
||
pushMany(relationPatterns, ["payment_to_settlement", "statement_to_document", "document_to_posting"]);
|
||
}
|
||
const hasSettlementAccountScope = accountScope.some((item) => item === "51" || item === "60" || item === "62" || item === "76");
|
||
const hasVatAccountScope = accountScope.some((item) => item === "19" || item === "68");
|
||
const hasFixedAssetAccountScope = accountScope.some((item) => item === "01" || item === "02" || item === "08");
|
||
const hasDeferredExpenseAccountScope = accountScope.some((item) => item === "97");
|
||
const hasMonthCloseCostsAccountScope = accountScope.some((item) => CLOSE_COST_ACCOUNTS.includes(item));
|
||
const hasExplicitMonthCloseLexicalMarker = /(?:закрыти[ея]\s+месяц|закрыт[а-яё]*\s+период|закрытие\s+счетов|регламентн|косвенн|затрат|распределени|рбп|амортиз|финансовых\s+результат|month\s*close|period\s*close|close\s+period|close\s+operation)/i.test(lower) ||
|
||
(/закр/i.test(lower) && /перио/i.test(lower));
|
||
if (/постав|постав|supplier|vendor/i.test(lower) || hasSettlementAccountScope) {
|
||
pushMany(domainScope, ["suppliers", "settlements"]);
|
||
pushMany(documentTypes, ["supplier_receipt", "settlement_document"]);
|
||
pushMany(entityTypes, ["counterparty", "contract", "document", "posting"]);
|
||
pushMany(relationPatterns, ["payment_to_settlement", "contract_to_documents"]);
|
||
}
|
||
if (/покупат|покупат|customer|buyer/i.test(lower) || hasSettlementAccountScope) {
|
||
pushMany(domainScope, ["customers", "settlements"]);
|
||
pushMany(documentTypes, ["sales_document", "settlement_document"]);
|
||
pushMany(entityTypes, ["counterparty", "contract", "document", "posting"]);
|
||
pushMany(relationPatterns, ["payment_to_settlement", "contract_to_documents"]);
|
||
}
|
||
if (/РЅРґСЃ|ндс|vat|РєРЅРёРіР° РїРѕРєСѓРїРѕРє|РєРЅРёРіР° продаж|счет.?фактур|книг[аи]\s+покуп|книг[аи]\s+продаж|сч[её]т(?:а|у|ом|е)?[-\s]?фактур(?:а|ы|е|у|ой)?|вычет|налогов(?:ый|ого)?\s+эффект/i.test(lower) ||
|
||
hasVatAccountScope) {
|
||
pushMany(domainScope, ["vat", "taxes"]);
|
||
pushMany(documentTypes, ["invoice", "vat_document"]);
|
||
pushMany(entityTypes, ["document", "tax_entry", "posting"]);
|
||
pushMany(relationPatterns, ["invoice_to_vat", "document_to_posting"]);
|
||
}
|
||
if (/РѕСЃ|РѕСЃРЅРѕРІРЅ(ые|ых)\s+сред|(?:^|[^a-zа-яё])ос(?:$|[^a-zа-яё])|основн(ые|ых|ым)?\s+средств|fixed asset|amort|амортиз|амортиз/i.test(lower) ||
|
||
hasFixedAssetAccountScope) {
|
||
pushMany(domainScope, ["fixed_assets"]);
|
||
pushMany(documentTypes, ["fixed_asset_card", "fixed_asset_acceptance", "depreciation_document"]);
|
||
pushMany(entityTypes, ["fixed_asset", "document", "posting"]);
|
||
pushMany(relationPatterns, ["asset_card_to_depreciation", "document_to_posting"]);
|
||
}
|
||
if (/СЂР±Рї|расходы будущих периодов|рбп|расходы\s+будущих\s+периодов|deferred|writeoff/i.test(lower) ||
|
||
hasDeferredExpenseAccountScope) {
|
||
pushMany(domainScope, ["deferred_expense", "period_close"]);
|
||
pushMany(documentTypes, ["deferred_expense_document", "period_close_document"]);
|
||
pushMany(entityTypes, ["document", "posting"]);
|
||
pushMany(relationPatterns, ["deferred_expense_to_writeoff", "document_to_posting"]);
|
||
}
|
||
if (/цепоч|разрыв|СЃРІСЏР·|документ.*РїСЂРѕРІРѕРґ|РіРґРµ рвет|Р¶РёРІСѓС‚ отдельно|цепоч|разрыв|связ|документ.*провод|chain|break/i.test(lower)) {
|
||
pushMany(relationPatterns, ["document_to_posting", "contract_to_documents"]);
|
||
pushMany(explanationFocus, ["what_conflicts_with_what", "why_not_closed"]);
|
||
}
|
||
if (/аномал|СЂРёСЃРє|С…РІРѕСЃС‚|РїРѕРґРѕР·СЂ|искаж|аномал|риск|хвост|подозр|искаж|suspic|risk/i.test(lower)) {
|
||
pushMany(anomalyPatterns, ["missing_link", "broken_lifecycle", "amount_independent_risk"]);
|
||
}
|
||
if (WRONG_DOCUMENT_MARKERS.test(lower)) {
|
||
pushMany(anomalyPatterns, ["wrong_document_type", "posting_mismatch", "broken_lifecycle"]);
|
||
}
|
||
if (/Р¶РёРІСѓС‚ отдельно|РЅРµ СЃРІСЏР·|без СЃРІСЏР·Рё|живут\s+отдельно|не\s+связ|без\s+связи|missing link/i.test(lower)) {
|
||
pushMany(anomalyPatterns, ["missing_link", "cross_domain_inconsistency"]);
|
||
}
|
||
if (REPEATED_ANOMALY_MARKERS.test(lower)) {
|
||
pushMany(anomalyPatterns, ["repeated_anomaly"]);
|
||
pushMany(rankingBasis, ["repeatability"]);
|
||
}
|
||
if (hasExplicitMonthCloseLexicalMarker || hasMonthCloseCostsAccountScope || hasDeferredExpenseAccountScope) {
|
||
pushMany(domainScope, ["period_close"]);
|
||
pushMany(anomalyPatterns, ["closure_risk", "broken_lifecycle"]);
|
||
pushMany(documentTypes, ["period_close_document"]);
|
||
}
|
||
if (/РЅРµ РІ платеже|не\s+в\s+платеже|not payment/i.test(lower)) {
|
||
pushMany(excludedInterpretations, ["simple_payment_delay"]);
|
||
}
|
||
if (/РЅРµ РїРѕ СЃСѓРјРј|РЅРµ СЃСѓРјРјР°|не\s+по\s+сумм|не\s+сумм|not by amount/i.test(lower)) {
|
||
pushMany(excludedInterpretations, ["amount_only_anomaly"]);
|
||
pushMany(rankingBasis, ["amount_independent_risk"]);
|
||
}
|
||
for (const account of accountScope) {
|
||
const preset = ACCOUNT_PRESETS[account];
|
||
if (!preset) {
|
||
continue;
|
||
}
|
||
pushMany(domainScope, preset.domains);
|
||
pushMany(documentTypes, preset.documents);
|
||
pushMany(entityTypes, preset.entities);
|
||
pushMany(relationPatterns, preset.relations);
|
||
pushMany(anomalyPatterns, preset.anomalies);
|
||
}
|
||
if (relationPatterns.length === 0) {
|
||
pushMany(relationPatterns, ["document_to_posting", "contract_to_documents"]);
|
||
}
|
||
if (anomalyPatterns.length === 0) {
|
||
pushMany(anomalyPatterns, ["missing_link", "broken_lifecycle"]);
|
||
}
|
||
if (domainScope.length === 0) {
|
||
pushMany(domainScope, ["settlements"]);
|
||
}
|
||
if (documentTypes.length === 0) {
|
||
pushMany(documentTypes, ["settlement_document"]);
|
||
}
|
||
if (entityTypes.length === 0) {
|
||
pushMany(entityTypes, ["counterparty", "document", "posting"]);
|
||
}
|
||
const dedupedDomains = uniqueStrings(domainScope);
|
||
const dedupedAnomalies = uniqueStrings(anomalyPatterns);
|
||
const dedupedAccounts = uniqueStrings(accountScope);
|
||
const dedupedRelations = uniqueStrings(relationPatterns);
|
||
const graphTraversal = buildGraphTraversalProfile({
|
||
text: lower,
|
||
accountScope: dedupedAccounts,
|
||
domainScope: dedupedDomains,
|
||
relationPatterns: dedupedRelations,
|
||
anomalyPatterns: dedupedAnomalies
|
||
});
|
||
return {
|
||
query_subject: inferQuerySubject(lower, dedupedDomains, dedupedAnomalies),
|
||
account_scope: dedupedAccounts,
|
||
subaccount_scope: [],
|
||
domain_scope: dedupedDomains,
|
||
document_types: uniqueStrings(documentTypes),
|
||
entity_types: uniqueStrings(entityTypes),
|
||
period_scope: inferPeriodScope(lower),
|
||
relation_patterns: dedupedRelations,
|
||
lifecycle_stage_filters: ["created", "posted", "closed", "reconciled"],
|
||
anomaly_patterns: dedupedAnomalies,
|
||
ranking_basis: uniqueStrings(rankingBasis),
|
||
excluded_interpretations: uniqueStrings(excludedInterpretations),
|
||
explanation_focus: uniqueStrings(explanationFocus),
|
||
graph_traversal: graphTraversal
|
||
};
|
||
}
|
||
function hasSymptomMarker(fragmentText, card) {
|
||
return card.symptom_markers.some((marker) => marker.test(fragmentText));
|
||
}
|
||
function cardResolutionScore(card, fragmentText, profile) {
|
||
const accountMatches = profile.account_scope.filter((account) => card.account_scope.includes(account));
|
||
const domainMatches = profile.domain_scope.filter((domain) => card.domain_scope.includes(domain));
|
||
const markerHit = hasSymptomMarker(fragmentText, card);
|
||
const hasExplicitAccountScope = profile.account_scope.length > 0;
|
||
// If the user explicitly asked with account hints and this card does not intersect,
|
||
// the card must not activate (prevents false P0 gating on non-P0 accounts like 97).
|
||
if (hasExplicitAccountScope && accountMatches.length === 0) {
|
||
return 0;
|
||
}
|
||
const hasVatSoftAnchor = card.id === "vat_document_register_book" && hasStrongVatDomainSignal(fragmentText, profile);
|
||
const hasMonthCloseSignal = card.id === "month_close_costs_20_44" && hasStrongMonthCloseSignal(fragmentText, profile);
|
||
const fixedAssetOnlySignal = card.id === "month_close_costs_20_44" && hasFixedAssetSignal(fragmentText, profile) && !hasMonthCloseSignal && accountMatches.length === 0;
|
||
if (fixedAssetOnlySignal) {
|
||
return 0;
|
||
}
|
||
const markerWeight = card.id === "month_close_costs_20_44" ? hasMonthCloseSignal : markerHit;
|
||
const hasHardAnchor = accountMatches.length > 0 || markerWeight || hasVatSoftAnchor;
|
||
if (!hasHardAnchor) {
|
||
return 0;
|
||
}
|
||
return accountMatches.length * 4 + domainMatches.length * 3 + (markerWeight ? 2 : 0);
|
||
}
|
||
function hasStrongVatDomainSignal(fragmentText, profile) {
|
||
const text = String(fragmentText ?? "");
|
||
const hasVatLexicalAnchor = /(?:ндс|vat|сч[её]т(?:а|у|ом|е)?[-\s]?фактур(?:а|ы|е|у|ой)?|книг[аи]\s+(?:покуп|продаж)|вычет|налогов(?:ый|ого)?\s+эффект)/iu.test(text);
|
||
return (hasVatLexicalAnchor ||
|
||
profile.account_scope.some((account) => account === "19" || account === "68") ||
|
||
profile.domain_scope.some((domain) => domain === "vat" || domain === "taxes") ||
|
||
profile.relation_patterns.some((pattern) => ["invoice_to_vat", "register_to_book", "book_entry_generated", "deduction_posted"].includes(pattern)));
|
||
}
|
||
function hasStrongMonthCloseSignal(fragmentText, profile) {
|
||
const text = String(fragmentText ?? "");
|
||
const hasMonthCloseLexicalAnchor = /(?:закрыти[ея]\s+месяц|закрыт[а-яё]*\s+период|закрытие\s+счетов|регламентн|косвенн|затрат|распределени|рбп|month\s*close|period\s*close|close\s+operation)/iu.test(text);
|
||
return (hasMonthCloseLexicalAnchor ||
|
||
profile.account_scope.some((account) => CLOSE_COST_ACCOUNTS.includes(account)) ||
|
||
profile.domain_scope.some((domain) => domain === "period_close" || domain === "deferred_expense") ||
|
||
profile.relation_patterns.some((pattern) => ["deferred_expense_to_writeoff", "close_operation", "allocation_rules_resolved", "residuals_zero_or_explained"].includes(pattern)));
|
||
}
|
||
function hasFixedAssetSignal(fragmentText, profile) {
|
||
const text = String(fragmentText ?? "");
|
||
return (/(?:основн(ые|ых|ым)?\s+средств|(?:^|[^a-zа-яё])ос(?:$|[^a-zа-яё])|амортиз|depreciat|fixed\s*asset)/iu.test(text) ||
|
||
profile.account_scope.some((account) => account === "01" || account === "02"));
|
||
}
|
||
function hasStrongSettlementAccountSignal(profile) {
|
||
return profile.account_scope.some((account) => account === "51" || account === "60" || account === "62" || account === "76");
|
||
}
|
||
function resolveP0DomainCard(fragmentText, profile) {
|
||
const resolved = P0_DOMAIN_CARDS.map((card) => ({
|
||
card,
|
||
score: cardResolutionScore(card, fragmentText, profile)
|
||
}))
|
||
.filter((item) => item.score > 0)
|
||
.sort((left, right) => right.score - left.score);
|
||
if (resolved.length === 0) {
|
||
return null;
|
||
}
|
||
const [first, second] = resolved;
|
||
if (second && second.score === first.score) {
|
||
const pair = new Set([first.card.id, second.card.id]);
|
||
const hasVatSettlementTie = pair.has("vat_document_register_book") && pair.has("settlements_60_62");
|
||
if (hasVatSettlementTie && hasStrongVatDomainSignal(fragmentText, profile) && !hasStrongSettlementAccountSignal(profile)) {
|
||
return resolved.find((item) => item.card.id === "vat_document_register_book") ?? null;
|
||
}
|
||
return null;
|
||
}
|
||
return first;
|
||
}
|
||
function isSettlementSymptomQuery(fragmentText, profile) {
|
||
const lower = String(fragmentText ?? "").toLowerCase();
|
||
const hasSettlementAccount = profile.account_scope.some((account) => account === "60" || account === "62");
|
||
const hasSettlementRelation = profile.relation_patterns.includes("payment_to_settlement") ||
|
||
profile.relation_patterns.includes("statement_to_document") ||
|
||
profile.relation_patterns.includes("contract_to_documents");
|
||
const hasSettlementSymptom = /(?:\b60(?:\.\d{2})?\b|\b62(?:\.\d{2})?\b|\b51(?:\.\d{2})?\b|не\s+сход|деньг[аи].*долг|аванс.*не\s+зач|не\s+закры|settlement|payment_to_settlement|\u0440\u0430\u0441\u0447\u0435\u0442|\u0437\u0430\u0447\u0435\u0442)/iu.test(lower);
|
||
return hasSettlementAccount && (hasSettlementRelation || hasSettlementSymptom);
|
||
}
|
||
function shouldUseStrictForbiddenDomainGate(card, profile, fragmentText) {
|
||
if (card.id !== "settlements_60_62") {
|
||
return false;
|
||
}
|
||
return isSettlementSymptomQuery(fragmentText, profile);
|
||
}
|
||
function hasSettlementRecoverySignal(signals) {
|
||
const hasSettlementAccount = signals.account_context.some((item) => ["51", "60", "62", "76"].includes(item));
|
||
const hasSettlementDomain = signals.domain_scope.some((item) => ["bank", "settlements", "suppliers", "customers", "supplier_payments", "other_settlements"].includes(item));
|
||
const hasSettlementRelation = signals.relation_patterns.some((item) => ["payment_to_settlement", "statement_to_document", "contract_to_documents"].includes(item));
|
||
const hasSettlementDocument = signals.document_types.some((item) => ["bank_statement", "payment_order", "settlement_document", "supplier_receipt", "sales_document"].includes(item));
|
||
return hasSettlementAccount || hasSettlementDomain || hasSettlementRelation || hasSettlementDocument;
|
||
}
|
||
function isVatAllowedAccountContext(account) {
|
||
const normalized = String(account ?? "").trim();
|
||
return normalized === "19" || normalized === "68";
|
||
}
|
||
function isVatAllowedDocumentContext(documentType) {
|
||
return /(?:invoice|vat_document|purchase_book|sales_book|tax_entry|supplier_receipt|sales_document|register)/i.test(String(documentType ?? ""));
|
||
}
|
||
function isVatAllowedRelationPattern(pattern) {
|
||
return /(?:invoice_to_vat|register_to_book|book_entry_generated|deduction_posted|document_to_posting|contract_to_documents|source_doc_present|invoice_linked)/i.test(String(pattern ?? ""));
|
||
}
|
||
function isVatAllowedGraphDomain(domain) {
|
||
return /(?:vat_flow)/i.test(String(domain ?? ""));
|
||
}
|
||
function collectSourceRecords(data, sources) {
|
||
const items = [];
|
||
for (const source of sources) {
|
||
const records = data[source] ?? [];
|
||
for (const record of records) {
|
||
items.push({
|
||
source,
|
||
record
|
||
});
|
||
}
|
||
}
|
||
return items;
|
||
}
|
||
function evaluateDomainPurity(card, profile, signals, options) {
|
||
const strictForbidden = options?.strict_forbidden === true;
|
||
const accountScopeActive = profile.account_scope.length > 0;
|
||
const domainScopeActive = profile.domain_scope.length > 0;
|
||
const accountMatch = !accountScopeActive || intersects(profile.account_scope, signals.account_context);
|
||
const domainMatch = !domainScopeActive || intersects(profile.domain_scope, signals.domain_scope);
|
||
const entityMatch = intersects(card.allowed_entities, signals.entity_types);
|
||
const edgeMatch = intersects(card.expected_edges, signals.relation_patterns);
|
||
const cardAccountMatch = intersects(card.account_scope, signals.account_context);
|
||
const cardDomainMatch = intersects(card.domain_scope, signals.domain_scope);
|
||
const crossDomainOverlap = signals.domain_scope.filter((domain) => !card.domain_scope.includes(domain));
|
||
const hardForbiddenDomains = uniqueStrings(card.forbidden_cross_domain_leakage.filter((domain) => signals.domain_scope.includes(domain)));
|
||
const hasStrongTargetAnchor = cardAccountMatch || cardDomainMatch || edgeMatch;
|
||
const forbiddenDomains = strictForbidden ? hardForbiddenDomains : hasStrongTargetAnchor ? [] : hardForbiddenDomains;
|
||
const anchorMatch = cardAccountMatch || cardDomainMatch || edgeMatch;
|
||
const allowed = forbiddenDomains.length === 0 && accountMatch && domainMatch && anchorMatch && (entityMatch || edgeMatch || cardDomainMatch);
|
||
return {
|
||
allowed,
|
||
account_match: accountMatch && cardAccountMatch,
|
||
domain_match: domainMatch && cardDomainMatch,
|
||
entity_match: entityMatch,
|
||
edge_match: edgeMatch,
|
||
forbidden_domains: forbiddenDomains,
|
||
cross_domain_overlap: crossDomainOverlap
|
||
};
|
||
}
|
||
function applyDomainPuritySourceGate(input, card, profile, options) {
|
||
const accepted = [];
|
||
let rejectedTotal = 0;
|
||
let rejectedForbidden = 0;
|
||
for (const item of input) {
|
||
const signals = inferRecordSignals(item.record);
|
||
const purity = evaluateDomainPurity(card, profile, signals, options);
|
||
if (!purity.allowed) {
|
||
rejectedTotal += 1;
|
||
if (purity.forbidden_domains.length > 0) {
|
||
rejectedForbidden += 1;
|
||
}
|
||
continue;
|
||
}
|
||
accepted.push({
|
||
...item,
|
||
signals,
|
||
purity
|
||
});
|
||
}
|
||
return {
|
||
accepted,
|
||
rejected_total: rejectedTotal,
|
||
rejected_forbidden: rejectedForbidden
|
||
};
|
||
}
|
||
function enforceDomainPurityRanking(input, card, profile, options) {
|
||
const accepted = [];
|
||
let rejectedTotal = 0;
|
||
for (const item of input) {
|
||
const purity = evaluateDomainPurity(card, profile, item.signals, options);
|
||
if (!purity.allowed) {
|
||
rejectedTotal += 1;
|
||
continue;
|
||
}
|
||
accepted.push({
|
||
...item,
|
||
purity
|
||
});
|
||
}
|
||
return {
|
||
accepted,
|
||
rejected_total: rejectedTotal
|
||
};
|
||
}
|
||
function topThreePurityHolds(items) {
|
||
const topThree = items.slice(0, 3);
|
||
return topThree.every((item) => item.purity.allowed && item.purity.forbidden_domains.length === 0);
|
||
}
|
||
function topOnePurityHolds(items) {
|
||
const topOne = items[0];
|
||
if (!topOne) {
|
||
return true;
|
||
}
|
||
return topOne.purity.allowed && topOne.purity.forbidden_domains.length === 0;
|
||
}
|
||
function enforceDomainPurityPromotion(input, card, profile, options) {
|
||
const accepted = [];
|
||
let rejectedTotal = 0;
|
||
for (const item of input) {
|
||
const purity = evaluateDomainPurity(card, profile, item.signals, options);
|
||
if (!purity.allowed) {
|
||
rejectedTotal += 1;
|
||
continue;
|
||
}
|
||
accepted.push({
|
||
...item,
|
||
purity
|
||
});
|
||
}
|
||
return {
|
||
accepted,
|
||
rejected_total: rejectedTotal
|
||
};
|
||
}
|
||
function inferAccountsFromRecord(record, corpus) {
|
||
const accounts = [];
|
||
const accountTokens = corpus.match(/\b(?:01|02|08|19|20|21|23|25|26|28|29|44|51|60|62|68|76|97)\b/g) ?? [];
|
||
for (const token of accountTokens) {
|
||
accounts.push(token.split(".")[0]);
|
||
}
|
||
for (const key of Object.keys(record.attributes ?? {})) {
|
||
if (/счетбанк|расчетн.*счет|bank account|банковскийсчет/i.test(key)) {
|
||
accounts.push("51");
|
||
}
|
||
if (/счетучетарасчетовсконтрагентом/i.test(key)) {
|
||
accounts.push("60");
|
||
}
|
||
if (/счетучетандс/i.test(key)) {
|
||
accounts.push("19");
|
||
}
|
||
if (/субконтодт/i.test(key)) {
|
||
accounts.push("60");
|
||
}
|
||
}
|
||
return uniqueStrings(accounts);
|
||
}
|
||
function inferDocumentTypesFromRecord(record, corpus) {
|
||
const items = [];
|
||
if (/банковскиевыписки|выписк|расчетногосчета|spisaniesraschetnogoscheta|bank/i.test(corpus)) {
|
||
pushMany(items, ["bank_statement", "payment_order"]);
|
||
}
|
||
if (/поступлениетоваровуслуг|поступлен/i.test(corpus)) {
|
||
items.push("supplier_receipt");
|
||
}
|
||
if (/реализациятоваровуслуг|реализац/i.test(corpus)) {
|
||
items.push("sales_document");
|
||
}
|
||
if (/ндс|счетфактур|книгипокупок|книгипродаж|vat|invoice/i.test(corpus)) {
|
||
pushMany(items, ["invoice", "vat_document"]);
|
||
}
|
||
if (/корректировк|ручн|manual/i.test(corpus)) {
|
||
items.push("manual_operation");
|
||
}
|
||
if (/закрытие|регламент/i.test(corpus)) {
|
||
items.push("period_close_document");
|
||
}
|
||
if (/основн|амортиз|fixed_asset/i.test(corpus)) {
|
||
pushMany(items, ["fixed_asset_card", "depreciation_document"]);
|
||
}
|
||
if (/расходыбудущихпериодов|deferred|97/.test(corpus)) {
|
||
items.push("deferred_expense_document");
|
||
}
|
||
if (record.source_entity.startsWith("Document") || record.source_entity.startsWith("DocumentJournal")) {
|
||
items.push("settlement_document");
|
||
}
|
||
return uniqueStrings(items);
|
||
}
|
||
function inferDomainsFromRecord(corpus, documentTypes, record) {
|
||
const domains = [];
|
||
if (documentTypes.some((item) => item === "bank_statement" || item === "payment_order")) {
|
||
pushMany(domains, ["bank", "settlements"]);
|
||
}
|
||
if (documentTypes.some((item) => item === "supplier_receipt")) {
|
||
pushMany(domains, ["suppliers", "settlements"]);
|
||
}
|
||
if (documentTypes.some((item) => item === "sales_document")) {
|
||
pushMany(domains, ["customers", "settlements"]);
|
||
}
|
||
if (documentTypes.some((item) => item === "invoice" || item === "vat_document")) {
|
||
pushMany(domains, ["vat"]);
|
||
}
|
||
if (documentTypes.some((item) => item === "fixed_asset_card" || item === "depreciation_document")) {
|
||
pushMany(domains, ["fixed_assets"]);
|
||
}
|
||
if (documentTypes.some((item) => item === "deferred_expense_document")) {
|
||
pushMany(domains, ["deferred_expense", "period_close"]);
|
||
}
|
||
if (/закрытие|регламент|period close/i.test(corpus)) {
|
||
domains.push("period_close");
|
||
}
|
||
const hasSettlementLexicalAnchor = /(?:settlement|payment|bank|statement|supplier|customer|buyer|vendor|60\b|62\b|51\b|оплат|банк|выписк|расчет|постав|покуп)/i.test(corpus);
|
||
const hasSettlementDocAnchor = documentTypes.some((item) => item === "bank_statement" || item === "payment_order" || item === "supplier_receipt" || item === "sales_document");
|
||
const hasSettlementDomainAnchor = domains.includes("bank") || domains.includes("suppliers") || domains.includes("customers") || domains.includes("supplier_payments");
|
||
if (findCounterpartyLinks(record).length > 0 && (hasSettlementLexicalAnchor || hasSettlementDocAnchor || hasSettlementDomainAnchor)) {
|
||
domains.push("settlements");
|
||
}
|
||
return uniqueStrings(domains);
|
||
}
|
||
function inferEntityTypes(record) {
|
||
const entities = [];
|
||
if (record.source_entity.startsWith("Document") || record.source_entity.startsWith("DocumentJournal")) {
|
||
entities.push("document");
|
||
}
|
||
if (record.source_entity.startsWith("AccumulationRegister_")) {
|
||
entities.push("posting");
|
||
}
|
||
if (findCounterpartyLinks(record).length > 0) {
|
||
entities.push("counterparty");
|
||
}
|
||
const corpus = collectTextFromRecord(record);
|
||
if (/РґРѕРіРѕРІРѕСЂ|contract/i.test(corpus)) {
|
||
entities.push("contract");
|
||
}
|
||
if (/основн|fixed_asset|инвентар/i.test(corpus)) {
|
||
entities.push("fixed_asset");
|
||
}
|
||
if (/ндс|книгипокупок|книгипродаж|vat|invoice/i.test(corpus)) {
|
||
entities.push("tax_entry");
|
||
}
|
||
return uniqueStrings(entities);
|
||
}
|
||
function inferRelationPatterns(record, corpus) {
|
||
const patterns = [];
|
||
const hasDocLinks = record.links.some((item) => item.target_entity === "Document");
|
||
const hasCounterparty = findCounterpartyLinks(record).length > 0;
|
||
if (hasDocLinks) {
|
||
patterns.push("document_to_posting");
|
||
}
|
||
if (hasCounterparty && hasDocLinks && /платеж|bank|settlement|расчет/i.test(corpus)) {
|
||
patterns.push("payment_to_settlement");
|
||
}
|
||
if (/банковскиевыписки|выписк|statement/i.test(corpus) && hasDocLinks) {
|
||
patterns.push("statement_to_document");
|
||
}
|
||
if (/основн|fixed_asset|амортиз/i.test(corpus)) {
|
||
patterns.push("asset_card_to_depreciation");
|
||
}
|
||
if (/расходыбудущихпериодов|97|deferred/i.test(corpus)) {
|
||
patterns.push("deferred_expense_to_writeoff");
|
||
}
|
||
if (/ндс|счетфактур|книгипокупок|книгипродаж|vat|invoice/i.test(corpus)) {
|
||
patterns.push("invoice_to_vat");
|
||
}
|
||
if (/РґРѕРіРѕРІРѕСЂ|contract/i.test(corpus) && hasDocLinks) {
|
||
patterns.push("contract_to_documents");
|
||
}
|
||
if (/склад|товар|материал|receipt/i.test(corpus)) {
|
||
patterns.push("receipt_to_stock_movement");
|
||
}
|
||
return uniqueStrings(patterns);
|
||
}
|
||
function inferLifecycleMarkers(record) {
|
||
const markers = ["created"];
|
||
if (record.attributes.Recorder && String(record.attributes.Recorder).trim()) {
|
||
markers.push("posted");
|
||
}
|
||
const unknownLinks = Number(record.unknown_link_count ?? 0);
|
||
const zeroGuidValues = countZeroGuidValues(record);
|
||
if (unknownLinks > 0 || zeroGuidValues > 0) {
|
||
markers.push("partially_linked");
|
||
}
|
||
const period = extractDate(record);
|
||
if (period && /-30T|-31T/.test(period)) {
|
||
markers.push("period_boundary");
|
||
}
|
||
if (record.links.length === 0) {
|
||
markers.push("no_continuation");
|
||
}
|
||
return uniqueStrings(markers);
|
||
}
|
||
function inferAnomalyPatterns(record, corpus, relationPatterns, lifecycleMarkers) {
|
||
const anomalies = [];
|
||
const hasDocLinks = record.links.some((item) => item.target_entity === "Document");
|
||
const hasCounterparty = findCounterpartyLinks(record).length > 0;
|
||
const unknownLinks = Number(record.unknown_link_count ?? 0);
|
||
const zeroGuidValues = countZeroGuidValues(record);
|
||
if (!hasCounterparty || !hasDocLinks || unknownLinks > 0) {
|
||
anomalies.push("missing_link");
|
||
}
|
||
if (lifecycleMarkers.includes("partially_linked") || lifecycleMarkers.includes("no_continuation")) {
|
||
anomalies.push("broken_lifecycle");
|
||
}
|
||
if (relationPatterns.includes("document_to_posting") && !record.attributes.Recorder) {
|
||
anomalies.push("posting_mismatch");
|
||
}
|
||
if (/ручн|manual|корректировк/.test(corpus)) {
|
||
anomalies.push("manual_intervention_suspicion");
|
||
}
|
||
if (lifecycleMarkers.includes("period_boundary") && (unknownLinks > 0 || zeroGuidValues > 0)) {
|
||
anomalies.push("closure_risk");
|
||
}
|
||
if (countNavigationLinks(record) >= 3 && (unknownLinks > 0 || zeroGuidValues > 0)) {
|
||
anomalies.push("repeated_anomaly");
|
||
}
|
||
if (!hasCounterparty && !hasDocLinks && zeroGuidValues > 0) {
|
||
anomalies.push("silent_orphan");
|
||
}
|
||
const hasAmountSignal = Object.keys(record.attributes ?? {}).some((key) => /sum|сумм|итого|amount/i.test(key));
|
||
if (!hasAmountSignal && anomalies.length > 0) {
|
||
anomalies.push("amount_independent_risk");
|
||
}
|
||
const domains = inferDomainsFromRecord(corpus, inferDocumentTypesFromRecord(record, corpus), record);
|
||
if (domains.includes("bank") && domains.includes("vat")) {
|
||
anomalies.push("cross_domain_inconsistency");
|
||
}
|
||
return uniqueStrings(anomalies);
|
||
}
|
||
function inferRecordSignals(record) {
|
||
const corpus = collectTextFromRecord(record);
|
||
const accountContext = inferAccountsFromRecord(record, corpus);
|
||
const documentTypes = inferDocumentTypesFromRecord(record, corpus);
|
||
const entityTypes = inferEntityTypes(record);
|
||
const relationPatterns = inferRelationPatterns(record, corpus);
|
||
const lifecycleMarkers = inferLifecycleMarkers(record);
|
||
const anomalyPatterns = inferAnomalyPatterns(record, corpus, relationPatterns, lifecycleMarkers);
|
||
const domains = inferDomainsFromRecord(corpus, documentTypes, record);
|
||
return {
|
||
account_context: accountContext,
|
||
domain_scope: domains,
|
||
document_types: documentTypes,
|
||
entity_types: entityTypes,
|
||
relation_patterns: relationPatterns,
|
||
anomaly_patterns: anomalyPatterns,
|
||
lifecycle_markers: lifecycleMarkers
|
||
};
|
||
}
|
||
function intersects(left, right) {
|
||
if (left.length === 0) {
|
||
return true;
|
||
}
|
||
const rightSet = new Set(right);
|
||
return left.some((item) => rightSet.has(item));
|
||
}
|
||
function evaluateExcludedInterpretations(profile, signals, record) {
|
||
const reasons = [];
|
||
const interpretationSet = new Set(profile.excluded_interpretations);
|
||
if (interpretationSet.has("simple_payment_delay")) {
|
||
const structural = ["missing_link", "broken_lifecycle", "posting_mismatch", "wrong_document_type", "closure_risk"];
|
||
const hasStructural = signals.anomaly_patterns.some((item) => structural.includes(item));
|
||
if (!hasStructural) {
|
||
reasons.push("Рсключено как simple_payment_delay без структурного дефекта.");
|
||
}
|
||
}
|
||
if (interpretationSet.has("amount_only_anomaly")) {
|
||
const hasAmountSignal = Object.keys(record.attributes ?? {}).some((key) => /sum|сумм|итого|amount/i.test(key));
|
||
const structural = ["missing_link", "broken_lifecycle", "posting_mismatch", "cross_domain_inconsistency", "silent_orphan"];
|
||
const hasStructural = signals.anomaly_patterns.some((item) => structural.includes(item));
|
||
if (hasAmountSignal && !hasStructural) {
|
||
reasons.push("Рсключено как amount-only аномалия без структурных признаков.");
|
||
}
|
||
}
|
||
return {
|
||
excluded: reasons.length > 0,
|
||
reasons
|
||
};
|
||
}
|
||
function evaluateGraphTraversalForRecord(profile, signals) {
|
||
const graphDomains = toGraphDomainScope(signals.domain_scope, signals.account_context);
|
||
const runtimeSignals = inferGraphRuntimeSignals(signals);
|
||
if (!profile.graph_traversal.runtime_enabled || !profile.graph_traversal.eligible) {
|
||
return {
|
||
domain_match: false,
|
||
relation_match: false,
|
||
signal_match: false,
|
||
lifecycle_match: false,
|
||
runtime_signals: runtimeSignals,
|
||
graph_domains: graphDomains,
|
||
score: 0
|
||
};
|
||
}
|
||
const domainMatch = intersects(profile.graph_traversal.target_domains, graphDomains);
|
||
const relationMatch = intersects(profile.graph_traversal.target_relations, signals.relation_patterns);
|
||
const signalMatch = intersects(profile.graph_traversal.target_signals, [...signals.anomaly_patterns, ...runtimeSignals]);
|
||
const lifecycleMatch = intersects(profile.graph_traversal.target_lifecycle_markers, signals.lifecycle_markers);
|
||
const score = (domainMatch ? 3 : 0) +
|
||
(relationMatch ? 3 : 0) +
|
||
(signalMatch ? 2 : 0) +
|
||
(lifecycleMatch ? 1 : 0) +
|
||
Math.min(2, runtimeSignals.length);
|
||
return {
|
||
domain_match: domainMatch,
|
||
relation_match: relationMatch,
|
||
signal_match: signalMatch,
|
||
lifecycle_match: lifecycleMatch,
|
||
runtime_signals: runtimeSignals,
|
||
graph_domains: graphDomains,
|
||
score
|
||
};
|
||
}
|
||
function evaluateRecordAgainstProfile(record, profile) {
|
||
const signals = inferRecordSignals(record);
|
||
const accountMatch = intersects(profile.account_scope, signals.account_context);
|
||
const domainMatch = intersects(profile.domain_scope, signals.domain_scope);
|
||
const documentMatch = intersects(profile.document_types, signals.document_types);
|
||
const entityMatch = intersects(profile.entity_types, signals.entity_types);
|
||
const relationMatch = intersects(profile.relation_patterns, signals.relation_patterns);
|
||
const anomalyMatch = intersects(profile.anomaly_patterns, signals.anomaly_patterns);
|
||
const lifecycleMatch = intersects(profile.lifecycle_stage_filters, signals.lifecycle_markers);
|
||
const excluded = evaluateExcludedInterpretations(profile, signals, record);
|
||
const graphTraversal = evaluateGraphTraversalForRecord(profile, signals);
|
||
const matchReasons = [];
|
||
if (accountMatch && profile.account_scope.length > 0) {
|
||
matchReasons.push("Совпал account_scope.");
|
||
}
|
||
if (domainMatch && profile.domain_scope.length > 0) {
|
||
matchReasons.push("Совпал domain_scope.");
|
||
}
|
||
if (documentMatch && profile.document_types.length > 0) {
|
||
matchReasons.push("Совпал document_types.");
|
||
}
|
||
if (relationMatch && profile.relation_patterns.length > 0) {
|
||
matchReasons.push("Совпали relation_patterns.");
|
||
}
|
||
if (anomalyMatch && profile.anomaly_patterns.length > 0) {
|
||
matchReasons.push("Совпали anomaly_patterns.");
|
||
}
|
||
if (lifecycleMatch && profile.lifecycle_stage_filters.length > 0) {
|
||
matchReasons.push("Совпал lifecycle_stage_filters.");
|
||
}
|
||
if (graphTraversal.domain_match) {
|
||
matchReasons.push("Graph traversal domain matched.");
|
||
}
|
||
if (graphTraversal.relation_match) {
|
||
matchReasons.push("Graph traversal relation matched.");
|
||
}
|
||
if (graphTraversal.signal_match) {
|
||
matchReasons.push("Graph traversal signal matched.");
|
||
}
|
||
if (graphTraversal.runtime_signals.length > 0) {
|
||
matchReasons.push(`Graph runtime signals: ${graphTraversal.runtime_signals.join(", ")}.`);
|
||
}
|
||
const matchScore = (accountMatch ? 3 : 0) +
|
||
(domainMatch ? 3 : 0) +
|
||
(documentMatch ? 2 : 0) +
|
||
(entityMatch ? 1 : 0) +
|
||
(relationMatch ? 3 : 0) +
|
||
(anomalyMatch ? 2 : 0) +
|
||
(lifecycleMatch ? 1 : 0) +
|
||
Math.min(2, signals.anomaly_patterns.length) +
|
||
graphTraversal.score;
|
||
return {
|
||
signals,
|
||
account_match: accountMatch,
|
||
domain_match: domainMatch,
|
||
document_match: documentMatch,
|
||
entity_match: entityMatch,
|
||
relation_match: relationMatch,
|
||
anomaly_match: anomalyMatch,
|
||
lifecycle_match: lifecycleMatch,
|
||
graph_domain_match: graphTraversal.domain_match,
|
||
graph_relation_match: graphTraversal.relation_match,
|
||
graph_signal_match: graphTraversal.signal_match,
|
||
graph_lifecycle_match: graphTraversal.lifecycle_match,
|
||
graph_runtime_signals: graphTraversal.runtime_signals,
|
||
graph_domain_scope: graphTraversal.graph_domains,
|
||
graph_traversal_score: graphTraversal.score,
|
||
excluded_by_interpretation: excluded.excluded,
|
||
match_score: matchScore,
|
||
match_reasons: matchReasons,
|
||
excluded_reasons: excluded.reasons
|
||
};
|
||
}
|
||
function shouldIncludeSemanticCandidate(candidate, profile) {
|
||
if (candidate.excluded_by_interpretation) {
|
||
return false;
|
||
}
|
||
if (!candidate.account_match && profile.account_scope.length > 0) {
|
||
return false;
|
||
}
|
||
if (!candidate.domain_match && profile.domain_scope.length > 0) {
|
||
return false;
|
||
}
|
||
const softAxes = [
|
||
profile.document_types.length > 0,
|
||
profile.entity_types.length > 0,
|
||
profile.relation_patterns.length > 0,
|
||
profile.anomaly_patterns.length > 0
|
||
].filter(Boolean).length;
|
||
const softHits = [candidate.document_match, candidate.entity_match, candidate.relation_match, candidate.anomaly_match].filter(Boolean).length;
|
||
const requiredSoftHits = softAxes > 0 ? 1 : 0;
|
||
const baseIncluded = softHits >= requiredSoftHits;
|
||
if (!baseIncluded) {
|
||
return false;
|
||
}
|
||
if (profile.graph_traversal.runtime_enabled && profile.graph_traversal.eligible) {
|
||
const graphHits = [
|
||
candidate.graph_domain_match,
|
||
candidate.graph_relation_match,
|
||
candidate.graph_signal_match,
|
||
candidate.graph_lifecycle_match
|
||
].filter(Boolean).length;
|
||
if (graphHits === 0 && candidate.graph_runtime_signals.length === 0 && candidate.match_score < 8) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
function semanticNarrowCandidates(records, profile) {
|
||
const evaluated = records.map((record) => ({
|
||
record,
|
||
evaluation: evaluateRecordAgainstProfile(record, profile)
|
||
}));
|
||
let narrowed = evaluated.filter((item) => shouldIncludeSemanticCandidate(item.evaluation, profile));
|
||
if (narrowed.length > 0) {
|
||
if (narrowed.length === records.length && records.length > 1) {
|
||
const keepCount = Math.max(2, Math.floor(records.length * 0.85));
|
||
return narrowed
|
||
.sort((left, right) => right.evaluation.match_score - left.evaluation.match_score)
|
||
.slice(0, keepCount);
|
||
}
|
||
return narrowed;
|
||
}
|
||
narrowed = evaluated
|
||
.filter((item) => !item.evaluation.excluded_by_interpretation &&
|
||
(item.evaluation.account_match || item.evaluation.domain_match || item.evaluation.relation_match))
|
||
.sort((left, right) => right.evaluation.match_score - left.evaluation.match_score)
|
||
.slice(0, Math.max(10, Math.floor(records.length * 0.35)));
|
||
if (narrowed.length > 0) {
|
||
return narrowed;
|
||
}
|
||
return evaluated
|
||
.filter((item) => !item.evaluation.excluded_by_interpretation)
|
||
.sort((left, right) => right.evaluation.match_score - left.evaluation.match_score)
|
||
.slice(0, Math.max(8, Math.floor(records.length * 0.2)));
|
||
}
|
||
function toSnapshotRecords(payload) {
|
||
if (!payload || typeof payload !== "object") {
|
||
return [];
|
||
}
|
||
const data = payload;
|
||
if (!Array.isArray(data.records)) {
|
||
return [];
|
||
}
|
||
const records = [];
|
||
for (const item of data.records) {
|
||
if (!item || typeof item !== "object") {
|
||
continue;
|
||
}
|
||
const source = item;
|
||
if (typeof source.source_entity !== "string" || typeof source.source_id !== "string") {
|
||
continue;
|
||
}
|
||
const links = Array.isArray(source.links)
|
||
? source.links
|
||
.map((link) => {
|
||
if (!link || typeof link !== "object")
|
||
return null;
|
||
const value = link;
|
||
return {
|
||
relation: String(value.relation ?? ""),
|
||
target_entity: String(value.target_entity ?? ""),
|
||
target_id: String(value.target_id ?? ""),
|
||
source_field: String(value.source_field ?? "")
|
||
};
|
||
})
|
||
.filter((link) => link !== null)
|
||
: [];
|
||
records.push({
|
||
problem_flags: Array.isArray(source.problem_flags) ? source.problem_flags.map((item) => String(item)) : [],
|
||
unknown_link_count: typeof source.unknown_link_count === "number" ? source.unknown_link_count : 0,
|
||
source_entity: source.source_entity,
|
||
source_id: source.source_id,
|
||
display_name: typeof source.display_name === "string" ? source.display_name : source.source_id,
|
||
attributes: source.attributes && typeof source.attributes === "object" && !Array.isArray(source.attributes)
|
||
? source.attributes
|
||
: {},
|
||
links
|
||
});
|
||
}
|
||
return records;
|
||
}
|
||
class AssistantDataLayer {
|
||
rootDir;
|
||
cache = null;
|
||
constructor(rootDir = config_1.ARCH_EXPORT_2020_DIR) {
|
||
this.rootDir = rootDir;
|
||
}
|
||
executeRoute(route, fragmentText) {
|
||
const data = this.ensureData();
|
||
if (!data) {
|
||
return {
|
||
status: "error",
|
||
result_type: "summary",
|
||
items: [],
|
||
summary: {},
|
||
evidence: [],
|
||
why_included: [],
|
||
selection_reason: [],
|
||
risk_factors: [],
|
||
business_interpretation: [],
|
||
confidence: "low",
|
||
limitations: ["Snapshot data files could not be loaded."],
|
||
errors: ["Слой данных недоступен: не удалось загрузить snapshot-файлы."]
|
||
};
|
||
}
|
||
let result = null;
|
||
if (route === "hybrid_store_plus_live") {
|
||
result = this.executeHybrid(fragmentText, data);
|
||
}
|
||
else if (route === "store_feature_risk") {
|
||
result = this.executeRisk(fragmentText, data);
|
||
}
|
||
else if (route === "batch_refresh_then_store") {
|
||
result = this.executeBatch(fragmentText, data);
|
||
}
|
||
else if (route === "store_canonical") {
|
||
result = this.executeCanonical(fragmentText, data);
|
||
}
|
||
else if (route === "live_mcp_drilldown") {
|
||
result = this.executeDrilldown(fragmentText, data);
|
||
}
|
||
if (!result) {
|
||
return {
|
||
status: "error",
|
||
result_type: "summary",
|
||
items: [],
|
||
summary: {},
|
||
evidence: [],
|
||
why_included: [],
|
||
selection_reason: [],
|
||
risk_factors: [],
|
||
business_interpretation: [],
|
||
confidence: "low",
|
||
limitations: ["Route is not implemented in current data executor."],
|
||
errors: [`Маршрут ${route} не поддержан в текущем исполнителе.`]
|
||
};
|
||
}
|
||
return enforceBroadQueryGuards(route, fragmentText, result);
|
||
}
|
||
async executeRouteRuntime(route, fragmentText) {
|
||
const base = this.executeRoute(route, fragmentText);
|
||
if (!config_1.FEATURE_ASSISTANT_MCP_RUNTIME_V1) {
|
||
return base;
|
||
}
|
||
if (route !== "hybrid_store_plus_live" && route !== "live_mcp_drilldown") {
|
||
return base;
|
||
}
|
||
const liveOverlay = await this.fetchLiveMcpOverlay(route, fragmentText);
|
||
return this.mergeWithLiveOverlay(base, liveOverlay);
|
||
}
|
||
cloneRawResult(base) {
|
||
return {
|
||
...base,
|
||
items: [...base.items],
|
||
summary: { ...(base.summary ?? {}) },
|
||
evidence: [...base.evidence],
|
||
why_included: [...base.why_included],
|
||
selection_reason: [...base.selection_reason],
|
||
risk_factors: [...base.risk_factors],
|
||
business_interpretation: [...base.business_interpretation],
|
||
limitations: [...base.limitations],
|
||
errors: [...base.errors]
|
||
};
|
||
}
|
||
mergeWithLiveOverlay(base, liveOverlay) {
|
||
const merged = this.cloneRawResult(base);
|
||
merged.summary = {
|
||
...(merged.summary ?? {}),
|
||
live_mcp: liveOverlay.summary
|
||
};
|
||
for (const line of liveOverlay.selection_reason) {
|
||
pushUniqueLine(merged.selection_reason, line);
|
||
}
|
||
for (const line of liveOverlay.limitations) {
|
||
pushUniqueLine(merged.limitations, line);
|
||
}
|
||
for (const error of liveOverlay.errors) {
|
||
pushUniqueLine(merged.errors, error);
|
||
}
|
||
if (liveOverlay.status === "ok" && liveOverlay.items.length > 0) {
|
||
if (merged.items.length === 0 || merged.status === "empty") {
|
||
merged.items = [...liveOverlay.items];
|
||
merged.status = "ok";
|
||
merged.result_type = "list";
|
||
pushUniqueLine(merged.why_included, "Добавлены live-сигналы из 1С через MCP.");
|
||
pushUniqueLine(merged.business_interpretation, "Snapshot не дал опорные записи, поэтому добавлены живые строки движений 1С для первичной проверки.");
|
||
if (merged.confidence === "low") {
|
||
merged.confidence = "medium";
|
||
}
|
||
}
|
||
else {
|
||
pushUniqueLine(merged.why_included, "Live MCP использован как дополнительное доказательство к snapshot-выдаче.");
|
||
}
|
||
merged.evidence = [...merged.evidence, ...liveOverlay.evidence].slice(0, 16);
|
||
}
|
||
return merged;
|
||
}
|
||
async fetchLiveMcpOverlay(route, fragmentText) {
|
||
const endpoint = this.buildMcpUrl("/api/execute_query");
|
||
const livePlan = buildLiveMcpCallPlan(route, fragmentText);
|
||
const explicitAccountScope = extractAccountScopeFromText(fragmentText);
|
||
const accountScope = explicitAccountScope.length > 0
|
||
? explicitAccountScope
|
||
: livePlan.claim_type === "prove_rbp_tail_state"
|
||
? ["97", "20", "25", "26", "44"]
|
||
: [];
|
||
const callExecutions = [];
|
||
const collectedRows = [];
|
||
const errors = [];
|
||
let fetchedRowsTotal = 0;
|
||
let matchedRowsTotal = 0;
|
||
for (const call of livePlan.calls) {
|
||
const callAccountScope = Array.isArray(call.account_scope_override) && call.account_scope_override.length > 0
|
||
? call.account_scope_override
|
||
: accountScope;
|
||
try {
|
||
const payload = await this.fetchJsonWithTimeout(endpoint, {
|
||
query: call.query,
|
||
limit: config_1.ASSISTANT_MCP_LIVE_LIMIT
|
||
});
|
||
const parsed = this.parseExecuteQueryPayload(payload);
|
||
if (parsed.error) {
|
||
errors.push(parsed.error);
|
||
callExecutions.push({
|
||
call_id: call.call_id,
|
||
purpose: call.purpose,
|
||
required_for_claim: call.required_for_claim,
|
||
status: "error",
|
||
fetched_rows: 0,
|
||
matched_rows: 0,
|
||
returned_rows: 0,
|
||
error: parsed.error
|
||
});
|
||
continue;
|
||
}
|
||
const matchedRows = this.filterLiveRowsByAccountScope(parsed.rows, callAccountScope);
|
||
const rowsForAnswer = callAccountScope.length > 0 ? matchedRows : parsed.rows;
|
||
fetchedRowsTotal += parsed.rows.length;
|
||
matchedRowsTotal += matchedRows.length;
|
||
for (const row of rowsForAnswer) {
|
||
collectedRows.push({
|
||
...row,
|
||
__live_call_id: call.call_id,
|
||
__live_call_purpose: call.purpose,
|
||
__claim_type: livePlan.claim_type,
|
||
__query_subject: livePlan.query_subject,
|
||
__account_scope_applied: callAccountScope
|
||
});
|
||
}
|
||
callExecutions.push({
|
||
call_id: call.call_id,
|
||
purpose: call.purpose,
|
||
required_for_claim: call.required_for_claim,
|
||
status: rowsForAnswer.length > 0 ? "ok" : "empty",
|
||
fetched_rows: parsed.rows.length,
|
||
matched_rows: matchedRows.length,
|
||
returned_rows: rowsForAnswer.length,
|
||
error: null
|
||
});
|
||
}
|
||
catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||
errors.push(errorMessage);
|
||
callExecutions.push({
|
||
call_id: call.call_id,
|
||
purpose: call.purpose,
|
||
required_for_claim: call.required_for_claim,
|
||
status: "error",
|
||
fetched_rows: 0,
|
||
matched_rows: 0,
|
||
returned_rows: 0,
|
||
error: errorMessage
|
||
});
|
||
}
|
||
}
|
||
const items = this.toLiveOverlayItems(collectedRows.slice(0, 16), route);
|
||
const evidence = items.slice(0, 12).map((item) => ({
|
||
source_entity: item.source_entity,
|
||
source_id: item.source_id,
|
||
source_namespace: "assistant_derived",
|
||
period: item.period,
|
||
account_debit: item.account_debit,
|
||
account_credit: item.account_credit,
|
||
source_layer: item.source_layer,
|
||
document_context: item.document_context,
|
||
relation_pattern_hits: item.relation_pattern_hits,
|
||
lifecycle_markers: item.lifecycle_markers,
|
||
live_call_id: item.live_call_id,
|
||
live_call_purpose: item.live_call_purpose,
|
||
claim_type: item.claim_type
|
||
}));
|
||
const executedRequiredCalls = callExecutions
|
||
.filter((item) => item.required_for_claim && item.status !== "error")
|
||
.map((item) => item.call_id);
|
||
const missingLiveCalls = livePlan.required_live_calls.filter((callId) => !executedRequiredCalls.includes(callId));
|
||
const liveRouteExecutionRate = livePlan.required_live_calls.length > 0
|
||
? Number((executedRequiredCalls.length / livePlan.required_live_calls.length).toFixed(4))
|
||
: 1;
|
||
const routeGapReason = missingLiveCalls.length > 0
|
||
? "required_live_calls_not_executed"
|
||
: livePlan.claim_type && matchedRowsTotal <= 0
|
||
? "claim_live_calls_executed_but_zero_matches"
|
||
: livePlan.route_gap_reason;
|
||
const selectionReason = [
|
||
livePlan.claim_type
|
||
? `Claim-bound live path selected for ${livePlan.claim_type}.`
|
||
: `Live MCP probe: ${fetchedRowsTotal} rows fetched from 1C register.`,
|
||
accountScope.length > 0
|
||
? `Account scope filter (${accountScope.join(", ")}) matched ${matchedRowsTotal} rows.`
|
||
: "Account scope filter was not applied."
|
||
];
|
||
const limitations = [
|
||
"Live probe использует ограниченный выборочный read-only запрос к 1С."
|
||
];
|
||
if (missingLiveCalls.length > 0) {
|
||
limitations.push(`Required live calls were not executed: ${missingLiveCalls.join(", ")}.`);
|
||
}
|
||
if (items.length === 0) {
|
||
limitations.push("Live probe не вернул строк, релевантных текущему запросу.");
|
||
}
|
||
if (errors.length > 0) {
|
||
limitations.push("Часть live вызовов завершилась ошибкой; включен ограниченный fallback.");
|
||
}
|
||
const status = items.length > 0 ? "ok" : callExecutions.every((item) => item.status === "error") ? "error" : "empty";
|
||
return {
|
||
status,
|
||
items,
|
||
evidence,
|
||
summary: {
|
||
enabled: true,
|
||
status,
|
||
route,
|
||
channel: config_1.ASSISTANT_MCP_CHANNEL,
|
||
proxy: config_1.ASSISTANT_MCP_PROXY_URL,
|
||
source_profile: livePlan.claim_type ? "claim_bound_rbp_live_path" : "generic_live_probe",
|
||
claim_type: livePlan.claim_type,
|
||
query_subject: livePlan.query_subject,
|
||
account_scope: accountScope,
|
||
fetched_rows: fetchedRowsTotal,
|
||
matched_rows: matchedRowsTotal,
|
||
returned_rows: items.length,
|
||
required_live_calls: livePlan.required_live_calls,
|
||
executed_live_calls: callExecutions,
|
||
missing_live_calls: missingLiveCalls,
|
||
live_route_execution_rate: liveRouteExecutionRate,
|
||
route_gap_reason: routeGapReason
|
||
},
|
||
selection_reason: selectionReason,
|
||
limitations,
|
||
errors
|
||
};
|
||
}
|
||
async fetchJsonWithTimeout(url, body) {
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(() => controller.abort(), Math.max(200, config_1.ASSISTANT_MCP_TIMEOUT_MS));
|
||
try {
|
||
const response = await fetch(url, {
|
||
method: "POST",
|
||
headers: {
|
||
"content-type": "application/json; charset=utf-8"
|
||
},
|
||
body: JSON.stringify(body),
|
||
signal: controller.signal
|
||
});
|
||
const responseText = await response.text();
|
||
if (!response.ok) {
|
||
throw new Error(`MCP HTTP ${response.status}: ${responseText.slice(0, 300)}`);
|
||
}
|
||
if (!responseText.trim()) {
|
||
return {};
|
||
}
|
||
return JSON.parse(responseText);
|
||
}
|
||
catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
throw new Error(`MCP fetch failed: ${message}`);
|
||
}
|
||
finally {
|
||
clearTimeout(timeout);
|
||
}
|
||
}
|
||
parseExecuteQueryPayload(payload) {
|
||
if (!payload || typeof payload !== "object") {
|
||
return {
|
||
rows: [],
|
||
error: "MCP payload is empty or malformed."
|
||
};
|
||
}
|
||
const source = payload;
|
||
if (source.success !== true) {
|
||
const errorMessage = valueAsString(source.error).trim();
|
||
return {
|
||
rows: [],
|
||
error: errorMessage || "MCP execute_query returned success=false."
|
||
};
|
||
}
|
||
const data = source.data;
|
||
if (Array.isArray(data)) {
|
||
return {
|
||
rows: data
|
||
.map((item) => (item && typeof item === "object" ? item : null))
|
||
.filter((item) => item !== null),
|
||
error: null
|
||
};
|
||
}
|
||
if (typeof data === "string") {
|
||
return {
|
||
rows: this.parseRowsFromTextTable(data),
|
||
error: null
|
||
};
|
||
}
|
||
if (data && typeof data === "object" && Array.isArray(data.rows)) {
|
||
const rows = (data.rows ?? [])
|
||
.map((item) => (item && typeof item === "object" ? item : null))
|
||
.filter((item) => item !== null);
|
||
return {
|
||
rows,
|
||
error: null
|
||
};
|
||
}
|
||
return {
|
||
rows: [],
|
||
error: null
|
||
};
|
||
}
|
||
parseRowsFromTextTable(source) {
|
||
const normalized = String(source ?? "").replace(/\r/g, "").trim();
|
||
if (!normalized) {
|
||
return [];
|
||
}
|
||
const headerMatch = normalized.match(/\{([^}]*)\}:/);
|
||
if (!headerMatch) {
|
||
return [];
|
||
}
|
||
const header = headerMatch[1] ?? "";
|
||
const columns = header
|
||
.split(",")
|
||
.map((item) => item.replace(/^"+|"+$/g, "").trim())
|
||
.filter((item) => item.length > 0);
|
||
const body = normalized.slice((headerMatch.index ?? 0) + headerMatch[0].length).trim();
|
||
if (!body) {
|
||
return [];
|
||
}
|
||
const rows = [];
|
||
const lines = body
|
||
.split("\n")
|
||
.map((line) => line.trim())
|
||
.filter((line) => line.length > 0);
|
||
for (const line of lines) {
|
||
const values = this.parseTextRowValues(line);
|
||
if (values.length === 0) {
|
||
continue;
|
||
}
|
||
const row = {};
|
||
for (let index = 0; index < columns.length; index += 1) {
|
||
const key = columns[index] ?? `column_${index + 1}`;
|
||
const raw = values[index] ?? "";
|
||
const numeric = parseFiniteNumber(raw);
|
||
const looksNumeric = /^-?\d+(?:[.,]\d+)?$/.test(raw);
|
||
row[key] = numeric !== null && looksNumeric ? numeric : raw;
|
||
}
|
||
if (values[0])
|
||
row.Period = values[0];
|
||
if (values[1])
|
||
row.Registrator = values[1];
|
||
if (values[2])
|
||
row.AccountDt = values[2];
|
||
if (values[3])
|
||
row.AccountKt = values[3];
|
||
if (values[4])
|
||
row.Amount = parseFiniteNumber(values[4]) ?? values[4];
|
||
rows.push(row);
|
||
}
|
||
return rows;
|
||
}
|
||
parseTextRowValues(line) {
|
||
const values = [];
|
||
const matcher = /"([^"]*)"|([^,]+)/g;
|
||
let match = null;
|
||
while ((match = matcher.exec(line)) !== null) {
|
||
const raw = match[1] !== undefined ? match[1] : match[2];
|
||
const value = String(raw ?? "").trim();
|
||
if (value.length > 0) {
|
||
values.push(value);
|
||
}
|
||
}
|
||
return values;
|
||
}
|
||
filterLiveRowsByAccountScope(rows, accountScope) {
|
||
if (accountScope.length === 0) {
|
||
return rows;
|
||
}
|
||
const matchers = accountScope.map((account) => new RegExp(`\\b${escapeRegExp(account)}(?:\\.\\d{2})?\\b`, "i"));
|
||
return rows.filter((row) => {
|
||
const searchable = Object.values(row).map(valueAsString).join(" ");
|
||
return matchers.some((matcher) => matcher.test(searchable));
|
||
});
|
||
}
|
||
toLiveOverlayItems(rows, route) {
|
||
return rows.map((row, index) => {
|
||
const periodRaw = valueAsString(row.Период ?? row.period ?? row.Period).trim();
|
||
const registrator = valueAsString(row.Регистратор ?? row.registrator ?? row.Registrator).trim();
|
||
const debit = valueAsString(row.СчетДт ?? row.account_dt ?? row.AccountDt).trim();
|
||
const credit = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim();
|
||
const amount = parseFiniteNumber(row.Сумма ?? row.amount ?? row.Amount);
|
||
const baseId = `${route}-mcp-${index + 1}`;
|
||
const sourceId = periodRaw ? `${baseId}-${periodRaw}` : baseId;
|
||
const displayName = registrator || `Live movement row #${index + 1}`;
|
||
const accountContext = uniqueStrings([debit, credit].filter((item) => item.length > 0));
|
||
const callId = valueAsString(row.__live_call_id ?? "").trim();
|
||
const callPurpose = valueAsString(row.__live_call_purpose ?? "").trim();
|
||
const claimType = valueAsString(row.__claim_type ?? "").trim() || null;
|
||
const querySubject = valueAsString(row.__query_subject ?? "").trim() || null;
|
||
const registratorLower = registrator.toLowerCase();
|
||
const hasRbpByDocument = /(?:рбп|deferred|списани[ея]\s+рбп)/i.test(registratorLower);
|
||
const hasAccount97 = accountContext.some((item) => /^97(?:\.|$)/.test(item));
|
||
const hasCloseDoc = /(?:закрыти[ея]\s+месяц|period\s*close|month\s*close|close\s+operation)/i.test(registratorLower) ||
|
||
callId.includes("month_close");
|
||
const relationPatternHits = uniqueStrings([
|
||
"document_to_posting",
|
||
hasRbpByDocument || hasAccount97 ? "deferred_expense_to_writeoff" : "",
|
||
hasCloseDoc ? "close_operation" : "",
|
||
callId.includes("residual") ? "residuals_zero_or_explained" : ""
|
||
]);
|
||
const documentContext = uniqueStrings([
|
||
hasRbpByDocument || hasAccount97 ? "deferred_expense_document" : "",
|
||
hasCloseDoc ? "period_close_document" : "",
|
||
"posting"
|
||
]);
|
||
const graphDomainScope = uniqueStrings([
|
||
hasRbpByDocument || hasAccount97 ? "deferred_expense" : "",
|
||
hasCloseDoc ? "period_close" : ""
|
||
]);
|
||
const lifecycleMarkers = uniqueStrings([
|
||
callId.includes("residual") ? "period_boundary" : "",
|
||
callId.includes("residual") ? "tail_state_observed" : "",
|
||
hasCloseDoc ? "close_operation" : ""
|
||
]);
|
||
return {
|
||
source_entity: "MCPLiveMovement",
|
||
source_id: sourceId,
|
||
display_name: displayName,
|
||
period: periodRaw || null,
|
||
account_debit: debit || null,
|
||
account_credit: credit || null,
|
||
account_context: accountContext,
|
||
document_context: documentContext,
|
||
relation_pattern_hits: relationPatternHits,
|
||
graph_domain_scope: graphDomainScope,
|
||
lifecycle_markers: lifecycleMarkers,
|
||
source_namespace: "assistant_derived",
|
||
live_call_id: callId || null,
|
||
live_call_purpose: callPurpose || null,
|
||
claim_type: claimType,
|
||
query_subject: querySubject,
|
||
amount,
|
||
source_layer: "mcp_live_probe",
|
||
route
|
||
};
|
||
});
|
||
}
|
||
buildMcpUrl(endpoint) {
|
||
const normalizedEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
||
const separator = normalizedEndpoint.includes("?") ? "&" : "?";
|
||
return `${config_1.ASSISTANT_MCP_PROXY_URL}${normalizedEndpoint}${separator}channel=${encodeURIComponent(config_1.ASSISTANT_MCP_CHANNEL)}`;
|
||
}
|
||
ensureData() {
|
||
if (this.cache) {
|
||
return this.cache;
|
||
}
|
||
try {
|
||
const keyFields = this.readRecords("09_samples_key_fields_Recorder_Ref_Supplier_Buyer_Responsible.json");
|
||
const problemCases = this.readRecords("03_snapshot_fragment_problem_cases.json");
|
||
const journals = this.readRecords("07_samples_DocumentJournals.json");
|
||
const ndsRegisters = this.readRecords("08_samples_NDS_registers.json");
|
||
const docs = [
|
||
...this.readRecords("04_samples_SpisanieSRaschetnogoScheta.json"),
|
||
...this.readRecords("05_samples_RealizaciyaTovarovUslug.json"),
|
||
...this.readRecords("06_samples_PostuplenieTovarovUslug.json")
|
||
];
|
||
this.cache = {
|
||
keyFields,
|
||
problemCases,
|
||
journals,
|
||
ndsRegisters,
|
||
docs
|
||
};
|
||
return this.cache;
|
||
}
|
||
catch {
|
||
return null;
|
||
}
|
||
}
|
||
readRecords(fileName) {
|
||
const fullPath = path_1.default.resolve(this.rootDir, fileName);
|
||
if (!fs_1.default.existsSync(fullPath)) {
|
||
return [];
|
||
}
|
||
const raw = fs_1.default.readFileSync(fullPath, "utf-8");
|
||
const parsed = JSON.parse(raw);
|
||
return toSnapshotRecords(parsed);
|
||
}
|
||
executeHybrid(fragmentText, data) {
|
||
const guidFilter = extractGuids(fragmentText);
|
||
const semanticProfile = buildSemanticRetrievalProfile(fragmentText);
|
||
const resolvedDomain = resolveP0DomainCard(fragmentText, semanticProfile);
|
||
const domainCard = resolvedDomain?.card ?? null;
|
||
const fallbackSources = ["keyFields", "journals", "docs"];
|
||
const sourceScope = domainCard
|
||
? uniqueStrings([...domainCard.allowed_evidence_sources.risk, ...domainCard.allowed_evidence_sources.canonical])
|
||
: fallbackSources;
|
||
const sourcePool = collectSourceRecords(data, sourceScope);
|
||
const strictForbidden = domainCard ? shouldUseStrictForbiddenDomainGate(domainCard, semanticProfile, fragmentText) : false;
|
||
let sourceGate = domainCard
|
||
? applyDomainPuritySourceGate(sourcePool, domainCard, semanticProfile, { strict_forbidden: strictForbidden })
|
||
: {
|
||
accepted: sourcePool.map((item) => ({
|
||
...item,
|
||
signals: inferRecordSignals(item.record),
|
||
purity: {
|
||
allowed: true,
|
||
account_match: true,
|
||
domain_match: true,
|
||
entity_match: true,
|
||
edge_match: true,
|
||
forbidden_domains: [],
|
||
cross_domain_overlap: []
|
||
}
|
||
})),
|
||
rejected_total: 0,
|
||
rejected_forbidden: 0
|
||
};
|
||
let strictForbiddenFallbackUsed = false;
|
||
if (domainCard && strictForbidden && sourceGate.accepted.length === 0 && sourcePool.length > 0) {
|
||
sourceGate = applyDomainPuritySourceGate(sourcePool, domainCard, semanticProfile, { strict_forbidden: false });
|
||
strictForbiddenFallbackUsed = true;
|
||
}
|
||
let settlementSourceRecoveryUsed = false;
|
||
if (domainCard?.id === "settlements_60_62" && sourceGate.accepted.length === 0 && sourcePool.length > 0) {
|
||
const recovered = sourcePool
|
||
.map((item) => ({
|
||
...item,
|
||
signals: inferRecordSignals(item.record),
|
||
purity: {
|
||
allowed: true,
|
||
account_match: true,
|
||
domain_match: true,
|
||
entity_match: true,
|
||
edge_match: true,
|
||
forbidden_domains: [],
|
||
cross_domain_overlap: []
|
||
}
|
||
}))
|
||
.filter((item) => hasSettlementRecoverySignal(item.signals));
|
||
if (recovered.length > 0) {
|
||
sourceGate = {
|
||
accepted: recovered,
|
||
rejected_total: Math.max(0, sourcePool.length - recovered.length),
|
||
rejected_forbidden: sourceGate.rejected_forbidden
|
||
};
|
||
settlementSourceRecoveryUsed = true;
|
||
}
|
||
}
|
||
const sourceRecords = sourceGate.accepted.map((item) => item.record);
|
||
let narrowedCandidates = guidFilter.length > 0
|
||
? sourceRecords
|
||
.filter((record) => guidFilter.some((guid) => hasGuidMatch(record, guid)))
|
||
.map((record) => ({
|
||
record,
|
||
evaluation: evaluateRecordAgainstProfile(record, semanticProfile)
|
||
}))
|
||
: semanticNarrowCandidates(sourceRecords, semanticProfile);
|
||
let settlementNarrowingRecoveryUsed = false;
|
||
if (domainCard?.id === "settlements_60_62" &&
|
||
guidFilter.length === 0 &&
|
||
narrowedCandidates.length === 0 &&
|
||
sourceRecords.length > 0 &&
|
||
isSettlementSymptomQuery(fragmentText, semanticProfile)) {
|
||
const recovered = sourceRecords
|
||
.map((record) => ({
|
||
record,
|
||
evaluation: evaluateRecordAgainstProfile(record, semanticProfile)
|
||
}))
|
||
.filter((item) => hasSettlementRecoverySignal(item.evaluation.signals));
|
||
if (recovered.length > 0) {
|
||
narrowedCandidates = recovered;
|
||
settlementNarrowingRecoveryUsed = true;
|
||
}
|
||
}
|
||
const filtered = narrowedCandidates.map((item) => item.record);
|
||
const semanticNarrowingApplied = guidFilter.length === 0;
|
||
const graphTraversalRuntime = summarizeGraphTraversalRuntime(narrowedCandidates, semanticProfile);
|
||
const puritySummary = {
|
||
enabled: Boolean(domainCard),
|
||
domain_card_id: domainCard?.id ?? null,
|
||
domain_card_title: domainCard?.title ?? null,
|
||
source_scope: sourceScope,
|
||
source_pool_records: sourcePool.length,
|
||
source_selection_allowed: sourceGate.accepted.length,
|
||
source_selection_rejected: sourceGate.rejected_total,
|
||
source_selection_rejected_forbidden: sourceGate.rejected_forbidden,
|
||
ranking_checked: narrowedCandidates.length,
|
||
ranking_allowed: narrowedCandidates.length,
|
||
ranking_rejected: 0,
|
||
promotion_checked: 0,
|
||
promotion_allowed: 0,
|
||
promotion_rejected: 0,
|
||
top1_pure: true,
|
||
top3_pure: true,
|
||
strict_forbidden_mode: strictForbidden,
|
||
strict_forbidden_fallback: strictForbiddenFallbackUsed,
|
||
settlement_source_recovery: settlementSourceRecoveryUsed,
|
||
settlement_narrowing_recovery: settlementNarrowingRecoveryUsed
|
||
};
|
||
const groups = new Map();
|
||
for (const candidate of narrowedCandidates) {
|
||
const { record, evaluation } = candidate;
|
||
const cpLinks = findCounterpartyLinks(record);
|
||
if (cpLinks.length === 0) {
|
||
continue;
|
||
}
|
||
const docLinks = record.links.filter((link) => link.target_entity === "Document");
|
||
for (const link of cpLinks) {
|
||
const key = link.target_id || `unknown:${record.source_id}`;
|
||
let group = groups.get(key);
|
||
if (!group) {
|
||
group = {
|
||
counterparty_id: key,
|
||
operations_count: 0,
|
||
document_ids: new Set(),
|
||
relations: new Map(),
|
||
samples: [],
|
||
account_context: new Set(),
|
||
document_context: new Set(),
|
||
relation_pattern_hits: new Set(),
|
||
risk_factors: new Set(),
|
||
lifecycle_gaps: new Set(),
|
||
graph_runtime_signals: new Set(),
|
||
graph_domain_scope: new Set(),
|
||
selection_reasons: new Set(),
|
||
total_match_score: 0,
|
||
total_graph_traversal_score: 0,
|
||
graph_match_hits: 0
|
||
};
|
||
groups.set(key, group);
|
||
}
|
||
group.operations_count += 1;
|
||
group.total_match_score += evaluation.match_score;
|
||
group.total_graph_traversal_score += evaluation.graph_traversal_score;
|
||
if (evaluation.graph_traversal_score > 0) {
|
||
group.graph_match_hits += 1;
|
||
}
|
||
for (const relation of [link.relation, ...docLinks.map((item) => item.relation)]) {
|
||
group.relations.set(relation, (group.relations.get(relation) ?? 0) + 1);
|
||
}
|
||
for (const account of evaluation.signals.account_context) {
|
||
if (domainCard?.id === "vat_document_register_book" && !isVatAllowedAccountContext(account)) {
|
||
continue;
|
||
}
|
||
if (semanticProfile.account_scope.length === 0 || semanticProfile.account_scope.includes(account)) {
|
||
group.account_context.add(account);
|
||
}
|
||
}
|
||
for (const item of evaluation.signals.document_types) {
|
||
if (domainCard?.id === "settlements_60_62" &&
|
||
!["bank_statement", "payment_order", "settlement_document", "supplier_receipt", "sales_document", "manual_operation"].includes(item)) {
|
||
continue;
|
||
}
|
||
if (domainCard?.id === "vat_document_register_book" && !isVatAllowedDocumentContext(item)) {
|
||
continue;
|
||
}
|
||
group.document_context.add(item);
|
||
}
|
||
for (const item of evaluation.signals.relation_patterns) {
|
||
if (domainCard?.id === "settlements_60_62" &&
|
||
!["payment_to_settlement", "statement_to_document", "contract_to_documents", "document_to_posting"].includes(item)) {
|
||
continue;
|
||
}
|
||
if (domainCard?.id === "vat_document_register_book" && !isVatAllowedRelationPattern(item)) {
|
||
continue;
|
||
}
|
||
group.relation_pattern_hits.add(item);
|
||
}
|
||
for (const item of evaluation.signals.anomaly_patterns) {
|
||
group.risk_factors.add(item);
|
||
}
|
||
for (const item of evaluation.signals.lifecycle_markers) {
|
||
if (item === "partially_linked" || item === "no_continuation" || item === "period_boundary") {
|
||
group.lifecycle_gaps.add(item);
|
||
}
|
||
}
|
||
for (const item of evaluation.graph_runtime_signals) {
|
||
group.graph_runtime_signals.add(item);
|
||
}
|
||
for (const domain of evaluation.graph_domain_scope) {
|
||
if (domainCard?.id === "settlements_60_62" &&
|
||
!["bank_settlement", "customer_settlement"].includes(domain)) {
|
||
continue;
|
||
}
|
||
if (domainCard?.id === "vat_document_register_book" && !isVatAllowedGraphDomain(domain)) {
|
||
continue;
|
||
}
|
||
group.graph_domain_scope.add(domain);
|
||
}
|
||
for (const reason of evaluation.match_reasons.slice(0, 4)) {
|
||
group.selection_reasons.add(reason);
|
||
}
|
||
for (const docLink of docLinks) {
|
||
if (docLink.target_id) {
|
||
group.document_ids.add(docLink.target_id);
|
||
}
|
||
}
|
||
if (group.samples.length < 3) {
|
||
const unknownLinks = Number(record.unknown_link_count ?? 0);
|
||
const sampleAccountContext = domainCard?.id === "settlements_60_62"
|
||
? evaluation.signals.account_context.filter((item) => ["51", "60", "62", "76"].includes(item))
|
||
: domainCard?.id === "vat_document_register_book"
|
||
? evaluation.signals.account_context.filter((item) => isVatAllowedAccountContext(item))
|
||
: evaluation.signals.account_context;
|
||
const sampleDocumentContext = domainCard?.id === "settlements_60_62"
|
||
? evaluation.signals.document_types.filter((item) => ["bank_statement", "payment_order", "settlement_document", "supplier_receipt", "sales_document", "manual_operation"].includes(item))
|
||
: domainCard?.id === "vat_document_register_book"
|
||
? evaluation.signals.document_types.filter((item) => isVatAllowedDocumentContext(item))
|
||
: evaluation.signals.document_types;
|
||
const sampleRelationPatterns = domainCard?.id === "settlements_60_62"
|
||
? evaluation.signals.relation_patterns.filter((item) => ["payment_to_settlement", "statement_to_document", "contract_to_documents", "document_to_posting"].includes(item))
|
||
: domainCard?.id === "vat_document_register_book"
|
||
? evaluation.signals.relation_patterns.filter((item) => isVatAllowedRelationPattern(item))
|
||
: evaluation.signals.relation_patterns;
|
||
const sampleGraphDomainScope = domainCard?.id === "settlements_60_62"
|
||
? evaluation.graph_domain_scope.filter((item) => ["bank_settlement", "customer_settlement"].includes(item))
|
||
: domainCard?.id === "vat_document_register_book"
|
||
? evaluation.graph_domain_scope.filter((item) => isVatAllowedGraphDomain(item))
|
||
: evaluation.graph_domain_scope;
|
||
group.samples.push({
|
||
source_entity: record.source_entity,
|
||
source_id: record.source_id,
|
||
period: extractDate(record),
|
||
recorder: record.attributes.Recorder ?? null,
|
||
account_context: sampleAccountContext,
|
||
document_context: sampleDocumentContext,
|
||
relation_patterns: sampleRelationPatterns,
|
||
anomaly_patterns: evaluation.signals.anomaly_patterns,
|
||
lifecycle_markers: evaluation.signals.lifecycle_markers,
|
||
graph_runtime_signals: evaluation.graph_runtime_signals,
|
||
graph_domain_scope: sampleGraphDomainScope,
|
||
graph_traversal_score: evaluation.graph_traversal_score,
|
||
missing_links: unknownLinks
|
||
});
|
||
}
|
||
}
|
||
}
|
||
const items = Array.from(groups.values())
|
||
.map((group) => ({
|
||
entity_type: "counterparty",
|
||
entity_id: group.counterparty_id,
|
||
label: group.counterparty_id,
|
||
counterparty_id: group.counterparty_id,
|
||
operations_count: group.operations_count,
|
||
document_refs_count: group.document_ids.size,
|
||
account_context: Array.from(group.account_context),
|
||
document_context: Array.from(group.document_context),
|
||
relation_pattern_hits: Array.from(group.relation_pattern_hits),
|
||
risk_factors: Array.from(group.risk_factors),
|
||
lifecycle_gaps: Array.from(group.lifecycle_gaps),
|
||
graph_runtime_signals: Array.from(group.graph_runtime_signals),
|
||
graph_domain_scope: Array.from(group.graph_domain_scope),
|
||
graph_match_hits: group.graph_match_hits,
|
||
graph_traversal_score: Number((group.total_graph_traversal_score / Math.max(1, group.operations_count)).toFixed(2)),
|
||
selection_reason: Array.from(group.selection_reasons).slice(0, 6),
|
||
ranking_score: Number((group.operations_count +
|
||
group.risk_factors.size * 2 +
|
||
group.relation_pattern_hits.size * 1.5 +
|
||
group.lifecycle_gaps.size * 1.25 +
|
||
group.total_match_score / Math.max(1, group.operations_count) +
|
||
(semanticProfile.graph_traversal.runtime_enabled && semanticProfile.graph_traversal.eligible
|
||
? group.graph_runtime_signals.size * 1.75 +
|
||
group.graph_match_hits * 0.5 +
|
||
group.total_graph_traversal_score / Math.max(1, group.operations_count)
|
||
: 0)).toFixed(2)),
|
||
confidence: group.risk_factors.size >= 2 || group.relation_pattern_hits.size >= 2
|
||
? "high"
|
||
: group.risk_factors.size >= 1
|
||
? "medium"
|
||
: "low",
|
||
business_interpretation: group.risk_factors.size > 0
|
||
? "Есть признаки разрыва расчетной цепочки: часть связей/этапов lifecycle подтверждена неполно."
|
||
: "Есть связанная операционная цепочка, но явные риск-паттерны выражены слабо.",
|
||
relation_types: Array.from(group.relations.entries())
|
||
.sort((left, right) => right[1] - left[1])
|
||
.map((item) => item[0]),
|
||
samples: group.samples,
|
||
evidence_pack: group.samples.slice(0, 2)
|
||
}))
|
||
.sort((left, right) => {
|
||
const scoreDiff = Number(right.ranking_score) - Number(left.ranking_score);
|
||
if (scoreDiff !== 0) {
|
||
return scoreDiff;
|
||
}
|
||
return Number(right.operations_count) - Number(left.operations_count);
|
||
})
|
||
.slice(0, 8)
|
||
.map((item, index) => ({
|
||
...item,
|
||
rank: index + 1
|
||
}));
|
||
puritySummary.promotion_checked = items.length;
|
||
puritySummary.promotion_allowed = items.length;
|
||
puritySummary.promotion_rejected = 0;
|
||
puritySummary.top1_pure = true;
|
||
puritySummary.top3_pure = true;
|
||
if (items.length === 0) {
|
||
return {
|
||
status: "empty",
|
||
result_type: "chain",
|
||
items: [],
|
||
summary: {
|
||
source_records: sourceRecords.length,
|
||
filtered_records_after_narrowing: filtered.length,
|
||
checked_records: filtered.length,
|
||
matched_counterparties: 0,
|
||
semantic_narrowing_applied: semanticNarrowingApplied,
|
||
guid_mode: guidFilter.length > 0,
|
||
query_subject: semanticProfile.query_subject,
|
||
semantic_profile: semanticProfile,
|
||
ranking_basis: semanticProfile.ranking_basis,
|
||
domain_purity_guard: puritySummary,
|
||
graph_runtime_enabled: semanticProfile.graph_traversal.runtime_enabled,
|
||
graph_eligible: semanticProfile.graph_traversal.eligible,
|
||
graph_traversal_applied: graphTraversalRuntime.traversal_applied,
|
||
graph_traversal: graphTraversalRuntime
|
||
},
|
||
evidence: [],
|
||
why_included: [],
|
||
selection_reason: [
|
||
"Поиск строился по semantic retrieval profile, но подходящие контрагенты не найдены.",
|
||
"Фильтрация использовала пересечение account/domain/document/relation/anomaly ограничений.",
|
||
domainCard ? `P0 domain purity enforced for ${domainCard.id}.` : "P0 domain purity was not enforced.",
|
||
guidFilter.length > 0
|
||
? "GUID-фильтрация включена."
|
||
: `GUID отсутствовал, выполнено semantic narrowing (${filtered.length}/${sourceRecords.length}).`,
|
||
`Graph planner mode=${graphTraversalRuntime.planner_mode}, eligible=${graphTraversalRuntime.graph_eligible}, applied=${graphTraversalRuntime.traversal_applied}.`,
|
||
`Graph ranking signals: ${graphTraversalRuntime.ranking_shift_signals.join(",") || "none"}.`
|
||
],
|
||
risk_factors: semanticProfile.anomaly_patterns,
|
||
business_interpretation: [
|
||
"По текущему профилю запроса устойчивых разрывов цепочки не обнаружено.",
|
||
"Для точечного drilldown добавьте GUID или уточните период/контрагента."
|
||
],
|
||
confidence: "medium",
|
||
limitations: [
|
||
guidFilter.length > 0 ? "Поиск ограничен переданными GUID." : "Поиск выполнен по semantic narrowing без GUID.",
|
||
"Рсточник данных — snapshot 2020 (read-only), Р° РЅРµ live состояние базы 1РЎ.",
|
||
domainCard ? "Domain purity guardrail может исключить cross-domain записи на этапе source selection." : "Domain purity guardrail не применялся."
|
||
],
|
||
errors: []
|
||
};
|
||
}
|
||
const evidence = items.flatMap((item) => item.samples.slice(0, 1).map((sample) => ({
|
||
counterparty_id: item.counterparty_id,
|
||
source_id: sample.source_id,
|
||
source_entity: sample.source_entity,
|
||
period: sample.period,
|
||
account_context: item.account_context,
|
||
relation_pattern_hits: item.relation_pattern_hits,
|
||
risk_factors: item.risk_factors,
|
||
graph_runtime_signals: item.graph_runtime_signals,
|
||
graph_domain_scope: item.graph_domain_scope
|
||
})));
|
||
const aggregatedRiskFactors = uniqueStrings(items.flatMap((item) => (Array.isArray(item.risk_factors) ? item.risk_factors : [])));
|
||
return {
|
||
status: "ok",
|
||
result_type: "chain",
|
||
items,
|
||
summary: {
|
||
source_records: sourceRecords.length,
|
||
filtered_records_after_narrowing: filtered.length,
|
||
checked_records: filtered.length,
|
||
matched_counterparties: items.length,
|
||
route_focus: "cross_entity_chain",
|
||
semantic_narrowing_applied: semanticNarrowingApplied,
|
||
guid_mode: guidFilter.length > 0,
|
||
query_subject: semanticProfile.query_subject,
|
||
semantic_profile: semanticProfile,
|
||
ranking_basis: semanticProfile.ranking_basis,
|
||
domain_purity_guard: puritySummary,
|
||
graph_runtime_enabled: semanticProfile.graph_traversal.runtime_enabled,
|
||
graph_eligible: semanticProfile.graph_traversal.eligible,
|
||
graph_traversal_applied: graphTraversalRuntime.traversal_applied,
|
||
graph_traversal: graphTraversalRuntime
|
||
},
|
||
evidence: evidence.slice(0, 12),
|
||
why_included: [
|
||
`Семантическое сужение выполнено по профилю ${semanticProfile.query_subject}.`,
|
||
domainCard ? `P0 domain purity enforced for ${domainCard.id}.` : "P0 domain purity was not enforced.",
|
||
semanticProfile.account_scope.length > 0
|
||
? `Учитывались счета: ${semanticProfile.account_scope.join(", ")}.`
|
||
: "Счета не были заданы явно, использованы domain/document/relation ограничения.",
|
||
`После narrowing осталось ${filtered.length} из ${sourceRecords.length} записей.`,
|
||
`Graph traversal mode=${graphTraversalRuntime.planner_mode}, matched=${graphTraversalRuntime.matched_candidates}/${graphTraversalRuntime.evaluated_candidates}.`
|
||
],
|
||
selection_reason: [
|
||
"Отбор основан на пересечении account_scope + domain_scope + document_types + relation_patterns + anomaly_patterns.",
|
||
"GUID-mode отключен: full scan без ограничителей не использовался.",
|
||
`Ранжирование выполнено по basis: ${semanticProfile.ranking_basis.join(", ")}.`,
|
||
domainCard ? `Domain gate source scope: ${sourceScope.join(", ")}.` : "Domain gate source scope not applied.",
|
||
`Graph signal counts: ${JSON.stringify(graphTraversalRuntime.signal_counts)}.`,
|
||
`Graph ranking signals: ${graphTraversalRuntime.ranking_shift_signals.join(",") || "none"}.`
|
||
],
|
||
risk_factors: aggregatedRiskFactors.length > 0
|
||
? aggregatedRiskFactors
|
||
: ["Высокая плотность операций по контрагенту может указывать на незакрытые цепочки."],
|
||
business_interpretation: [
|
||
"Результат отражает не просто объем операций, а структурные признаки разрыва цепочки и lifecycle-конфликта.",
|
||
"Контрагенты в топе приоритетны для проверки на неверный тип закрывающего документа и незавершенные связи."
|
||
],
|
||
confidence: "high",
|
||
limitations: [
|
||
guidFilter.length > 0 ? "Выборка ограничена GUID из запроса." : "Выборка ограничена semantic retrieval profile.",
|
||
"Рсточник данных — snapshot 2020 (read-only), РЅРµ live контур 1РЎ.",
|
||
domainCard ? "Domain purity guardrail может исключить cross-domain элементы на этапе source selection." : "Domain purity guardrail не применялся."
|
||
],
|
||
errors: []
|
||
};
|
||
}
|
||
executeRisk(fragmentText, data) {
|
||
const semanticProfile = buildSemanticRetrievalProfile(fragmentText);
|
||
const profileRiskFactors = semanticProfile.anomaly_patterns;
|
||
const resolvedDomain = resolveP0DomainCard(fragmentText, semanticProfile);
|
||
const domainCard = resolvedDomain?.card ?? null;
|
||
const fallbackSources = ["problemCases", "ndsRegisters"];
|
||
const sourceScope = domainCard ? domainCard.allowed_evidence_sources.risk : fallbackSources;
|
||
const sourcePool = collectSourceRecords(data, sourceScope);
|
||
const strictForbidden = Boolean(domainCard);
|
||
let sourceGate = domainCard
|
||
? applyDomainPuritySourceGate(sourcePool, domainCard, semanticProfile, { strict_forbidden: strictForbidden })
|
||
: {
|
||
accepted: sourcePool.map((item) => ({
|
||
...item,
|
||
signals: inferRecordSignals(item.record),
|
||
purity: {
|
||
allowed: true,
|
||
account_match: true,
|
||
domain_match: true,
|
||
entity_match: true,
|
||
edge_match: true,
|
||
forbidden_domains: [],
|
||
cross_domain_overlap: []
|
||
}
|
||
})),
|
||
rejected_total: 0,
|
||
rejected_forbidden: 0
|
||
};
|
||
let sourceStrictFallbackUsed = false;
|
||
if (domainCard && strictForbidden && sourceGate.accepted.length === 0 && sourcePool.length > 0) {
|
||
sourceGate = applyDomainPuritySourceGate(sourcePool, domainCard, semanticProfile, { strict_forbidden: false });
|
||
sourceStrictFallbackUsed = true;
|
||
}
|
||
const scored = sourceGate.accepted
|
||
.map((candidate) => {
|
||
const record = candidate.record;
|
||
const reasons = [];
|
||
let score = 0;
|
||
const unknownLinks = Number(record.unknown_link_count ?? 0);
|
||
const zeroGuidValues = countZeroGuidValues(record);
|
||
const navigationLinks = countNavigationLinks(record);
|
||
const cpLinks = findCounterpartyLinks(record).length;
|
||
if (unknownLinks > 0) {
|
||
score += 3;
|
||
reasons.push(`Есть связи с неопределенной сущностью (${unknownLinks}).`);
|
||
}
|
||
if (zeroGuidValues > 0) {
|
||
score += Math.min(3, 1 + zeroGuidValues);
|
||
reasons.push(`Найдены нулевые GUID в ключевых полях (${zeroGuidValues}).`);
|
||
}
|
||
if (navigationLinks > 0) {
|
||
score += 1;
|
||
reasons.push("Есть навигационные ссылки, требующие сверки связей.");
|
||
}
|
||
if (cpLinks === 0) {
|
||
score += 1;
|
||
reasons.push("Нет явной связи с контрагентом.");
|
||
}
|
||
const flags = Array.isArray(record.problem_flags) ? record.problem_flags : [];
|
||
if (flags.length > 0) {
|
||
score += 1;
|
||
}
|
||
return {
|
||
candidate,
|
||
source_entity: record.source_entity,
|
||
source_id: record.source_id,
|
||
period: extractDate(record),
|
||
risk_score: score,
|
||
reasons,
|
||
unknown_link_count: unknownLinks,
|
||
zero_guid_values: zeroGuidValues,
|
||
navigation_links: navigationLinks
|
||
};
|
||
})
|
||
.filter((item) => item.risk_score >= 2)
|
||
.sort((left, right) => right.risk_score - left.risk_score);
|
||
let rankingGate = domainCard
|
||
? enforceDomainPurityRanking(scored.map((item) => item.candidate), domainCard, semanticProfile, { strict_forbidden: strictForbidden })
|
||
: {
|
||
accepted: scored.map((item) => item.candidate),
|
||
rejected_total: 0
|
||
};
|
||
let rankingStrictFallbackUsed = false;
|
||
if (domainCard && strictForbidden && rankingGate.accepted.length === 0 && scored.length > 0) {
|
||
rankingGate = enforceDomainPurityRanking(scored.map((item) => item.candidate), domainCard, semanticProfile, { strict_forbidden: false });
|
||
rankingStrictFallbackUsed = true;
|
||
}
|
||
const rankingAcceptedIds = new Set(rankingGate.accepted.map((item) => `${item.record.source_entity}:${item.record.source_id}`));
|
||
const rankedFiltered = scored.filter((item) => rankingAcceptedIds.has(`${item.source_entity}:${item.source_id}`));
|
||
const topRanked = rankedFiltered.slice(0, 15);
|
||
let promotionGate = domainCard
|
||
? enforceDomainPurityPromotion(topRanked.map((item) => item.candidate), domainCard, semanticProfile, { strict_forbidden: strictForbidden })
|
||
: {
|
||
accepted: topRanked.map((item) => item.candidate),
|
||
rejected_total: 0
|
||
};
|
||
let promotionStrictFallbackUsed = false;
|
||
if (domainCard && strictForbidden && promotionGate.accepted.length === 0 && topRanked.length > 0) {
|
||
promotionGate = enforceDomainPurityPromotion(topRanked.map((item) => item.candidate), domainCard, semanticProfile, { strict_forbidden: false });
|
||
promotionStrictFallbackUsed = true;
|
||
}
|
||
const promotionAcceptedIds = new Set(promotionGate.accepted.map((item) => `${item.record.source_entity}:${item.record.source_id}`));
|
||
const promotedRanked = topRanked.filter((item) => promotionAcceptedIds.has(`${item.source_entity}:${item.source_id}`));
|
||
const items = promotedRanked.map(({ candidate: _candidate, ...payload }) => payload);
|
||
const puritySummary = {
|
||
enabled: Boolean(domainCard),
|
||
domain_card_id: domainCard?.id ?? null,
|
||
domain_card_title: domainCard?.title ?? null,
|
||
source_scope: sourceScope,
|
||
source_pool_records: sourcePool.length,
|
||
source_selection_allowed: sourceGate.accepted.length,
|
||
source_selection_rejected: sourceGate.rejected_total,
|
||
source_selection_rejected_forbidden: sourceGate.rejected_forbidden,
|
||
ranking_checked: scored.length,
|
||
ranking_allowed: rankedFiltered.length,
|
||
ranking_rejected: rankingGate.rejected_total,
|
||
promotion_checked: topRanked.length,
|
||
promotion_allowed: promotedRanked.length,
|
||
promotion_rejected: promotionGate.rejected_total,
|
||
top1_pure: domainCard ? topOnePurityHolds(promotedRanked.map((item) => item.candidate)) : true,
|
||
top3_pure: domainCard ? topThreePurityHolds(promotedRanked.map((item) => item.candidate)) : true,
|
||
strict_forbidden_mode: strictForbidden,
|
||
strict_forbidden_fallback_source: sourceStrictFallbackUsed,
|
||
strict_forbidden_fallback_ranking: rankingStrictFallbackUsed,
|
||
strict_forbidden_fallback_promotion: promotionStrictFallbackUsed
|
||
};
|
||
if (items.length === 0) {
|
||
return {
|
||
status: "empty",
|
||
result_type: "list",
|
||
items: [],
|
||
summary: {
|
||
checked_records: sourcePool.length,
|
||
risky_records: 0,
|
||
query_subject: semanticProfile.query_subject,
|
||
semantic_profile: semanticProfile,
|
||
ranking_basis: semanticProfile.ranking_basis,
|
||
domain_purity_guard: puritySummary
|
||
},
|
||
evidence: [],
|
||
why_included: [],
|
||
selection_reason: [
|
||
"Risk scoring executed with technical anomaly heuristics.",
|
||
domainCard ? `P0 domain guardrail applied: ${domainCard.id}.` : "P0 domain guardrail was not activated."
|
||
],
|
||
risk_factors: profileRiskFactors,
|
||
business_interpretation: ["По текущему срезу явные риск-признаки не обнаружены."],
|
||
confidence: "medium",
|
||
limitations: [
|
||
"Оценка основана на snapshot-данных и эвристическом risk score.",
|
||
domainCard ? "Domain purity guardrail может отфильтровать записи вне целевого домена." : "Domain purity guardrail не активирован."
|
||
],
|
||
errors: []
|
||
};
|
||
}
|
||
const averageScore = items.reduce((acc, item) => acc + item.risk_score, 0) / items.length;
|
||
const normalizedRiskFactors = uniqueStrings([
|
||
...profileRiskFactors,
|
||
"unknown_link_count",
|
||
"zero_guid_values",
|
||
"navigation_links",
|
||
"missing_counterparty_link"
|
||
]);
|
||
return {
|
||
status: "ok",
|
||
result_type: "list",
|
||
items,
|
||
summary: {
|
||
checked_records: sourcePool.length,
|
||
risky_records: items.length,
|
||
average_risk_score: Number(averageScore.toFixed(2)),
|
||
query_subject: semanticProfile.query_subject,
|
||
semantic_profile: semanticProfile,
|
||
ranking_basis: semanticProfile.ranking_basis,
|
||
domain_purity_guard: puritySummary
|
||
},
|
||
evidence: items.slice(0, 10).map((item) => ({
|
||
source_entity: item.source_entity,
|
||
source_id: item.source_id,
|
||
risk_score: item.risk_score
|
||
})),
|
||
why_included: [
|
||
"В ответ включены записи с risk_score >= 2.",
|
||
domainCard ? `P0 domain purity enforced for ${domainCard.id}.` : "P0 domain purity was not enforced."
|
||
],
|
||
selection_reason: [
|
||
"score растет при unknown links, zero GUID, навигационных ссылках и отсутствии явного контрагента.",
|
||
`Semantic profile subject: ${semanticProfile.query_subject}.`,
|
||
domainCard ? `Domain gate source scope: ${sourceScope.join(", ")}.` : "Domain gate source scope not applied."
|
||
],
|
||
risk_factors: normalizedRiskFactors,
|
||
business_interpretation: ["Эти записи требуют первичной бухгалтерской проверки как потенциальные аномалии."],
|
||
confidence: "high",
|
||
limitations: [
|
||
"Риск-факторы определяются эвристикой, а не полным набором бизнес-правил 1С.",
|
||
domainCard ? "Часть нерелевантных записей исключена domain purity фильтром." : "Domain purity guardrail не применялся."
|
||
],
|
||
errors: []
|
||
};
|
||
}
|
||
executeBatch(fragmentText, data) {
|
||
const semanticProfile = buildSemanticRetrievalProfile(fragmentText);
|
||
const resolvedDomain = resolveP0DomainCard(fragmentText, semanticProfile);
|
||
const domainCard = resolvedDomain?.card ?? null;
|
||
const fallbackSources = ["problemCases", "keyFields", "docs"];
|
||
const sourceScope = domainCard
|
||
? uniqueStrings([...domainCard.allowed_evidence_sources.risk, ...domainCard.allowed_evidence_sources.canonical])
|
||
: fallbackSources;
|
||
const sourcePool = collectSourceRecords(data, sourceScope);
|
||
const strictForbidden = Boolean(domainCard);
|
||
let sourceGate = domainCard
|
||
? applyDomainPuritySourceGate(sourcePool, domainCard, semanticProfile, { strict_forbidden: strictForbidden })
|
||
: {
|
||
accepted: sourcePool.map((item) => ({
|
||
...item,
|
||
signals: inferRecordSignals(item.record),
|
||
purity: {
|
||
allowed: true,
|
||
account_match: true,
|
||
domain_match: true,
|
||
entity_match: true,
|
||
edge_match: true,
|
||
forbidden_domains: [],
|
||
cross_domain_overlap: []
|
||
}
|
||
})),
|
||
rejected_total: 0,
|
||
rejected_forbidden: 0
|
||
};
|
||
let sourceStrictFallbackUsed = false;
|
||
if (domainCard && strictForbidden && sourceGate.accepted.length === 0 && sourcePool.length > 0) {
|
||
sourceGate = applyDomainPuritySourceGate(sourcePool, domainCard, semanticProfile, { strict_forbidden: false });
|
||
sourceStrictFallbackUsed = true;
|
||
}
|
||
const source = sourceGate.accepted.map((item) => item.record);
|
||
const byEntity = new Map();
|
||
for (const record of source) {
|
||
byEntity.set(record.source_entity, (byEntity.get(record.source_entity) ?? 0) + 1);
|
||
}
|
||
const items = Array.from(byEntity.entries())
|
||
.sort((left, right) => right[1] - left[1])
|
||
.slice(0, 10)
|
||
.map(([entity, count], index) => ({
|
||
rank: index + 1,
|
||
entity,
|
||
records_count: count
|
||
}));
|
||
const puritySummary = {
|
||
enabled: Boolean(domainCard),
|
||
domain_card_id: domainCard?.id ?? null,
|
||
domain_card_title: domainCard?.title ?? null,
|
||
source_scope: sourceScope,
|
||
source_pool_records: sourcePool.length,
|
||
source_selection_allowed: sourceGate.accepted.length,
|
||
source_selection_rejected: sourceGate.rejected_total,
|
||
source_selection_rejected_forbidden: sourceGate.rejected_forbidden,
|
||
top1_pure: domainCard ? topOnePurityHolds(sourceGate.accepted) : true,
|
||
top3_pure: domainCard ? topThreePurityHolds(sourceGate.accepted) : true,
|
||
strict_forbidden_mode: strictForbidden,
|
||
strict_forbidden_fallback_source: sourceStrictFallbackUsed
|
||
};
|
||
return {
|
||
status: items.length > 0 ? "ok" : "empty",
|
||
result_type: "ranking",
|
||
items,
|
||
summary: {
|
||
checked_records: sourcePool.length,
|
||
ranked_entities: items.length,
|
||
query_subject: semanticProfile.query_subject,
|
||
semantic_profile: semanticProfile,
|
||
ranking_basis: semanticProfile.ranking_basis,
|
||
domain_purity_guard: puritySummary
|
||
},
|
||
evidence: items.slice(0, 5).map((item) => ({
|
||
entity: item.entity,
|
||
records_count: item.records_count
|
||
})),
|
||
why_included: items.length > 0
|
||
? [
|
||
"Показаны сущности с максимальным количеством записей.",
|
||
domainCard ? `P0 domain purity enforced for ${domainCard.id}.` : "P0 domain purity was not enforced."
|
||
]
|
||
: [],
|
||
selection_reason: [
|
||
"Ранжирование выполнено по records_count по убыванию.",
|
||
domainCard ? `Domain gate source scope: ${sourceScope.join(", ")}.` : "Domain gate source scope not applied."
|
||
],
|
||
risk_factors: uniqueStrings(["entity_volume_spike", ...semanticProfile.anomaly_patterns]),
|
||
business_interpretation: [
|
||
"Top entities by volume highlight where lifecycle-focused review should start first."
|
||
],
|
||
confidence: "medium",
|
||
limitations: [
|
||
"Ранжирование по объему не всегда эквивалентно бизнес-риску.",
|
||
domainCard ? "Domain purity guardrail может исключить cross-domain записи на batch-слое." : "Domain purity guardrail не применялся."
|
||
],
|
||
errors: []
|
||
};
|
||
}
|
||
executeCanonical(fragmentText, data) {
|
||
const semanticProfile = buildSemanticRetrievalProfile(fragmentText);
|
||
const resolvedDomain = resolveP0DomainCard(fragmentText, semanticProfile);
|
||
const domainCard = resolvedDomain?.card ?? null;
|
||
const fallbackSources = semanticProfile.domain_scope.includes("vat") || semanticProfile.domain_scope.includes("taxes")
|
||
? ["ndsRegisters", "keyFields"]
|
||
: ["docs"];
|
||
const sourceScope = domainCard ? domainCard.allowed_evidence_sources.canonical : fallbackSources;
|
||
const sourcePool = collectSourceRecords(data, sourceScope);
|
||
const strictForbidden = Boolean(domainCard);
|
||
let sourceGate = domainCard
|
||
? applyDomainPuritySourceGate(sourcePool, domainCard, semanticProfile, { strict_forbidden: strictForbidden })
|
||
: {
|
||
accepted: sourcePool.map((item) => ({
|
||
...item,
|
||
signals: inferRecordSignals(item.record),
|
||
purity: {
|
||
allowed: true,
|
||
account_match: true,
|
||
domain_match: true,
|
||
entity_match: true,
|
||
edge_match: true,
|
||
forbidden_domains: [],
|
||
cross_domain_overlap: []
|
||
}
|
||
})),
|
||
rejected_total: 0,
|
||
rejected_forbidden: 0
|
||
};
|
||
let sourceStrictFallbackUsed = false;
|
||
if (domainCard && strictForbidden && sourceGate.accepted.length === 0 && sourcePool.length > 0) {
|
||
sourceGate = applyDomainPuritySourceGate(sourcePool, domainCard, semanticProfile, { strict_forbidden: false });
|
||
sourceStrictFallbackUsed = true;
|
||
}
|
||
const ranked = sourceGate.accepted
|
||
.map((candidate) => {
|
||
const record = candidate.record;
|
||
const period = extractDate(record);
|
||
return {
|
||
candidate,
|
||
source_entity: record.source_entity,
|
||
source_id: record.source_id,
|
||
display_name: record.display_name,
|
||
period,
|
||
counterparty_id: findCounterpartyLinks(record)[0]?.target_id ?? null,
|
||
recorder: record.attributes.Recorder ?? null,
|
||
sort_key: parseDateCandidate(period) ?? 0
|
||
};
|
||
})
|
||
.sort((left, right) => right.sort_key - left.sort_key);
|
||
let rankingGate = domainCard
|
||
? enforceDomainPurityRanking(ranked.map((item) => item.candidate), domainCard, semanticProfile, { strict_forbidden: strictForbidden })
|
||
: {
|
||
accepted: ranked.map((item) => item.candidate),
|
||
rejected_total: 0
|
||
};
|
||
let rankingStrictFallbackUsed = false;
|
||
if (domainCard && strictForbidden && rankingGate.accepted.length === 0 && ranked.length > 0) {
|
||
rankingGate = enforceDomainPurityRanking(ranked.map((item) => item.candidate), domainCard, semanticProfile, { strict_forbidden: false });
|
||
rankingStrictFallbackUsed = true;
|
||
}
|
||
const rankingAcceptedIds = new Set(rankingGate.accepted.map((item) => `${item.record.source_entity}:${item.record.source_id}`));
|
||
const rankedFiltered = ranked.filter((item) => rankingAcceptedIds.has(`${item.source_entity}:${item.source_id}`));
|
||
const topRanked = rankedFiltered.slice(0, 12);
|
||
let promotionGate = domainCard
|
||
? enforceDomainPurityPromotion(topRanked.map((item) => item.candidate), domainCard, semanticProfile, { strict_forbidden: strictForbidden })
|
||
: {
|
||
accepted: topRanked.map((item) => item.candidate),
|
||
rejected_total: 0
|
||
};
|
||
let promotionStrictFallbackUsed = false;
|
||
if (domainCard && strictForbidden && promotionGate.accepted.length === 0 && topRanked.length > 0) {
|
||
promotionGate = enforceDomainPurityPromotion(topRanked.map((item) => item.candidate), domainCard, semanticProfile, { strict_forbidden: false });
|
||
promotionStrictFallbackUsed = true;
|
||
}
|
||
const promotionAcceptedIds = new Set(promotionGate.accepted.map((item) => `${item.record.source_entity}:${item.record.source_id}`));
|
||
const promotedRanked = topRanked.filter((item) => promotionAcceptedIds.has(`${item.source_entity}:${item.source_id}`));
|
||
const items = promotedRanked.map(({ candidate: _candidate, sort_key: _ignored, ...record }) => record);
|
||
const puritySummary = {
|
||
enabled: Boolean(domainCard),
|
||
domain_card_id: domainCard?.id ?? null,
|
||
domain_card_title: domainCard?.title ?? null,
|
||
source_scope: sourceScope,
|
||
source_pool_records: sourcePool.length,
|
||
source_selection_allowed: sourceGate.accepted.length,
|
||
source_selection_rejected: sourceGate.rejected_total,
|
||
source_selection_rejected_forbidden: sourceGate.rejected_forbidden,
|
||
ranking_checked: ranked.length,
|
||
ranking_allowed: rankedFiltered.length,
|
||
ranking_rejected: rankingGate.rejected_total,
|
||
promotion_checked: topRanked.length,
|
||
promotion_allowed: promotedRanked.length,
|
||
promotion_rejected: promotionGate.rejected_total,
|
||
top1_pure: domainCard ? topOnePurityHolds(promotedRanked.map((item) => item.candidate)) : true,
|
||
top3_pure: domainCard ? topThreePurityHolds(promotedRanked.map((item) => item.candidate)) : true,
|
||
strict_forbidden_mode: strictForbidden,
|
||
strict_forbidden_fallback_source: sourceStrictFallbackUsed,
|
||
strict_forbidden_fallback_ranking: rankingStrictFallbackUsed,
|
||
strict_forbidden_fallback_promotion: promotionStrictFallbackUsed
|
||
};
|
||
return {
|
||
status: items.length > 0 ? "ok" : "empty",
|
||
result_type: "list",
|
||
items,
|
||
summary: {
|
||
checked_records: sourcePool.length,
|
||
returned_records: items.length,
|
||
query_subject: semanticProfile.query_subject,
|
||
semantic_profile: semanticProfile,
|
||
ranking_basis: semanticProfile.ranking_basis,
|
||
domain_purity_guard: puritySummary
|
||
},
|
||
evidence: items.slice(0, 6).map((item) => ({
|
||
source_entity: item.source_entity,
|
||
source_id: item.source_id,
|
||
period: item.period
|
||
})),
|
||
why_included: items.length > 0
|
||
? [
|
||
"Показаны последние по дате записи канонического документного слоя.",
|
||
domainCard ? `P0 domain purity enforced for ${domainCard.id}.` : "P0 domain purity was not enforced."
|
||
]
|
||
: [],
|
||
selection_reason: [
|
||
"Отбор по максимальной дате документа в пределах snapshot.",
|
||
`Semantic profile subject: ${semanticProfile.query_subject}.`,
|
||
domainCard ? `Domain gate source scope: ${sourceScope.join(", ")}.` : "Domain gate source scope not applied."
|
||
],
|
||
risk_factors: semanticProfile.anomaly_patterns,
|
||
business_interpretation: ["Слой отражает базовый factual-срез документов для оперативной сверки."],
|
||
confidence: "high",
|
||
limitations: [
|
||
"Это read-only snapshot, а не онлайн-состояние 1С.",
|
||
domainCard ? "Canonical output ограничен доменным runtime-контрактом." : "Domain purity guardrail не применялся."
|
||
],
|
||
errors: []
|
||
};
|
||
}
|
||
executeDrilldown(fragmentText, data) {
|
||
const guidFilter = extractGuids(fragmentText);
|
||
const all = [...data.keyFields, ...data.problemCases, ...data.docs, ...data.journals, ...data.ndsRegisters];
|
||
const anchors = extractBusinessAnchorsFromText(fragmentText);
|
||
if (guidFilter.length === 0 && !anchors.sufficient) {
|
||
return {
|
||
status: "empty",
|
||
result_type: "object",
|
||
items: [],
|
||
summary: {
|
||
reason: "guid_not_provided",
|
||
business_anchor_trace_available: false,
|
||
anchor_score: {
|
||
document_numbers: anchors.document_numbers.length,
|
||
date_tokens: anchors.date_tokens.length,
|
||
amount_values: anchors.amount_values.length,
|
||
account_scope: anchors.account_scope.length
|
||
}
|
||
},
|
||
evidence: [],
|
||
why_included: [],
|
||
selection_reason: ["Для drilldown требуется GUID или достаточные business anchors (номер/дата/сумма/счет)."],
|
||
risk_factors: [],
|
||
business_interpretation: ["Без GUID или business anchors точечный drilldown невозможен."],
|
||
confidence: "low",
|
||
limitations: ["Добавьте GUID или якоря: номер документа, дату, сумму, счет."],
|
||
errors: []
|
||
};
|
||
}
|
||
if (guidFilter.length === 0) {
|
||
const matches = all
|
||
.map((record) => {
|
||
const scored = scoreRecordForBusinessAnchorTrace(record, anchors);
|
||
return {
|
||
record,
|
||
...scored
|
||
};
|
||
})
|
||
.filter((item) => item.score >= 4 && item.matched_categories.length >= 2)
|
||
.sort((left, right) => {
|
||
const scoreDiff = right.score - left.score;
|
||
if (scoreDiff !== 0) {
|
||
return scoreDiff;
|
||
}
|
||
return String(right.record.source_id).localeCompare(String(left.record.source_id));
|
||
})
|
||
.slice(0, 20)
|
||
.map((item) => ({
|
||
source_entity: item.record.source_entity,
|
||
source_id: item.record.source_id,
|
||
display_name: item.record.display_name,
|
||
period: extractDate(item.record),
|
||
links_count: item.record.links.length,
|
||
trace_score: item.score,
|
||
matched_categories: item.matched_categories
|
||
}));
|
||
return {
|
||
status: matches.length > 0 ? "ok" : "empty",
|
||
result_type: "object",
|
||
items: matches,
|
||
summary: {
|
||
reason: "business_anchor_trace",
|
||
matched_records: matches.length,
|
||
anchor_trace: {
|
||
document_numbers: anchors.document_numbers,
|
||
date_tokens: anchors.date_tokens,
|
||
amount_values: anchors.amount_values,
|
||
account_scope: anchors.account_scope,
|
||
period_keys: anchors.period_keys
|
||
}
|
||
},
|
||
evidence: matches.slice(0, 10),
|
||
why_included: matches.length > 0
|
||
? ["Включены source-of-record записи, совпавшие по business anchors (номер/дата/сумма/счет)."]
|
||
: [],
|
||
selection_reason: [
|
||
"GUID отсутствует, использован business-anchor trace по атрибутам документа и расчетов."
|
||
],
|
||
risk_factors: [],
|
||
business_interpretation: [
|
||
"Drilldown опирается на business anchors, поэтому вывод требует первичной проверки в source-of-record."
|
||
],
|
||
confidence: matches.length > 0 ? "medium" : "low",
|
||
limitations: [
|
||
"Поиск ограничен локальным snapshot-пакетом.",
|
||
"Без GUID совпадение построено по business anchors и может требовать ручной проверки."
|
||
],
|
||
errors: []
|
||
};
|
||
}
|
||
const matches = all
|
||
.filter((record) => guidFilter.some((guid) => hasGuidMatch(record, guid)))
|
||
.slice(0, 20)
|
||
.map((record) => ({
|
||
source_entity: record.source_entity,
|
||
source_id: record.source_id,
|
||
display_name: record.display_name,
|
||
period: extractDate(record),
|
||
links_count: record.links.length
|
||
}));
|
||
return {
|
||
status: matches.length > 0 ? "ok" : "empty",
|
||
result_type: "object",
|
||
items: matches,
|
||
summary: {
|
||
query_guids: guidFilter,
|
||
matched_records: matches.length
|
||
},
|
||
evidence: matches.slice(0, 10),
|
||
why_included: matches.length > 0 ? ["Включены записи, содержащие GUID из запроса."] : [],
|
||
selection_reason: ["Поиск по source_id, linked target_id и строковым атрибутам."],
|
||
risk_factors: [],
|
||
business_interpretation: ["Результат показывает source-of-record объекты по переданным идентификаторам."],
|
||
confidence: matches.length > 0 ? "high" : "medium",
|
||
limitations: ["Поиск ограничен локальным snapshot-пакетом."],
|
||
errors: []
|
||
};
|
||
}
|
||
}
|
||
exports.AssistantDataLayer = AssistantDataLayer;
|