NODEDC_1C/llm_normalizer/backend/dist/services/assistantCoverageGrounding.js

345 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.extractRequirementsForRoute = extractRequirementsForRoute;
exports.evaluateCoverageForRequirements = evaluateCoverageForRequirements;
exports.checkGroundingForRequirements = checkGroundingForRequirements;
function summarizeUnique(values, limit = 6) {
return Array.from(new Set(values.map((item) => String(item ?? "").trim()).filter(Boolean))).slice(0, limit);
}
const SUBJECT_TOKEN_RULES = {
nds: {
critical: true,
patterns: [
"vat",
"accumulationregister",
"ндс",
"книгипокупок",
"книгипродаж",
"налогнадобавленнуюстоимость"
]
},
os: {
critical: true,
patterns: ["fixed_asset", "fixedasset", "основн", "амортиз"]
},
saldo: {
critical: true,
patterns: ["balance", "saldo", "сальдо", "остат"]
},
counterparty: {
critical: false,
patterns: [
"counterparty",
"supplier",
"buyer",
"counterparty_id",
"journal_counterparty",
"document_has_counterparty",
"контрагент",
"поставщик",
"покупател"
],
routes: ["hybrid_store_plus_live", "store_feature_risk", "store_canonical"]
},
document: {
critical: false,
patterns: [
"document",
"recorder",
"journal",
"document_refs_count",
"recorded_by_document",
"journal_refers_to_document",
"документ"
],
routes: ["hybrid_store_plus_live", "store_feature_risk", "store_canonical", "live_mcp_drilldown"]
},
anomaly: {
critical: false,
patterns: [
"risk",
"risk_score",
"unknown_link_count",
"zero_guid",
"navigation_links",
"missing_counterparty_link",
"аномал",
"риск"
],
routes: ["store_feature_risk", "batch_refresh_then_store"]
},
chain: {
critical: false,
patterns: ["chain", "cross_entity_chain", "relation_types", "operations_count", "matched_counterparties", "цепоч"],
routes: ["hybrid_store_plus_live"]
}
};
function hasRegexMatch(corpus, pattern) {
try {
return pattern.test(corpus);
}
catch {
return false;
}
}
function evaluateSubjectTokenMatch(token, corpus, executedRoutes) {
if (token.startsWith("account_")) {
const account = token.slice("account_".length).trim();
if (!account) {
return { matched: false, critical: true };
}
const escaped = account.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const accountPattern = new RegExp(`(^|[^0-9])${escaped}([^0-9]|$)`, "i");
return { matched: hasRegexMatch(corpus, accountPattern), critical: true };
}
const rule = SUBJECT_TOKEN_RULES[token];
if (rule) {
const byPattern = rule.patterns.some((pattern) => corpus.includes(pattern));
const byRoute = Array.isArray(rule.routes) ? rule.routes.some((route) => executedRoutes.has(route)) : false;
return { matched: byPattern || byRoute, critical: rule.critical };
}
return { matched: corpus.includes(token), critical: false };
}
function evidenceCountForRequirement(requirementId, result) {
const evidence = Array.isArray(result.evidence) ? result.evidence : [];
if (evidence.length === 0) {
return 0;
}
const tagged = evidence.filter((item) => {
const claimRef = typeof item?.claim_ref === "string" ? item.claim_ref : "";
return claimRef.toLowerCase() === `requirement:${String(requirementId).toLowerCase()}`;
}).length;
if (tagged > 0) {
return tagged;
}
if (Array.isArray(result.requirement_ids) &&
result.requirement_ids.length === 1 &&
result.requirement_ids[0] === requirementId) {
return evidence.length;
}
return 0;
}
function hasSubstantiveCoverageForRequirement(requirementId, result) {
const evidenceCount = evidenceCountForRequirement(requirementId, result);
if (evidenceCount > 0) {
return true;
}
const problemUnitsCount = Array.isArray(result.problem_units) ? result.problem_units.length : 0;
const candidateEvidenceCount = Array.isArray(result.candidate_evidence) ? result.candidate_evidence.length : 0;
if (problemUnitsCount > 0 || candidateEvidenceCount > 0) {
if (Array.isArray(result.requirement_ids) &&
result.requirement_ids.length === 1 &&
result.requirement_ids[0] === requirementId) {
return true;
}
}
return false;
}
function extractRequirementsForRoute(input) {
const byFragment = new Map();
const requirements = [];
const pushRequirement = (item) => {
const subjectTokens = input.extractSubjectTokens(item.requirement_text);
requirements.push({
requirement_id: item.requirement_id,
source_fragment_id: item.source_fragment_id,
requirement_text: item.requirement_text,
subject_tokens: subjectTokens,
status: item.status,
route: item.route
});
if (item.source_fragment_id) {
const current = byFragment.get(item.source_fragment_id) ?? [];
current.push(item.requirement_id);
byFragment.set(item.source_fragment_id, current);
}
};
if (!input.routeSummary) {
pushRequirement({
requirement_id: "R1",
source_fragment_id: null,
requirement_text: input.userMessage,
status: "clarification_needed",
route: null
});
return { requirements, byFragment };
}
if (input.routeSummary.mode === "legacy_v1") {
pushRequirement({
requirement_id: "R1",
source_fragment_id: "F1",
requirement_text: input.userMessage,
status: "covered",
route: input.routeSummary.route_hint
});
return { requirements, byFragment };
}
input.routeSummary.decisions.forEach((decision, index) => {
const requirementId = `R${index + 1}`;
const text = input.fragmentTextById.get(decision.fragment_id) ?? input.userMessage;
let status = "covered";
if (decision.route === "no_route") {
if (decision.no_route_reason === "out_of_scope") {
status = "out_of_scope";
}
else if (decision.no_route_reason === "insufficient_specificity") {
status = "clarification_needed";
}
else {
status = "uncovered";
}
}
pushRequirement({
requirement_id: requirementId,
source_fragment_id: decision.fragment_id,
requirement_text: text,
status,
route: decision.route === "no_route" ? null : decision.route
});
});
return { requirements, byFragment };
}
function evaluateCoverageForRequirements(requirements, retrievalResults) {
const statusByRequirement = new Map();
for (const result of retrievalResults) {
for (const requirementId of result.requirement_ids) {
const list = statusByRequirement.get(requirementId) ?? [];
list.push({
status: result.status,
substantive: hasSubstantiveCoverageForRequirement(requirementId, result)
});
statusByRequirement.set(requirementId, list);
}
}
const resolvedRequirements = requirements.map((requirement) => {
if (requirement.status === "out_of_scope" || requirement.status === "clarification_needed") {
return requirement;
}
const states = statusByRequirement.get(requirement.requirement_id) ?? [];
if (states.length === 0) {
return { ...requirement, status: "uncovered" };
}
const hasAnySubstantive = states.some((item) => item.substantive);
if (!hasAnySubstantive) {
return { ...requirement, status: "uncovered" };
}
const hasOk = states.some((item) => item.status === "ok");
const hasPartial = states.some((item) => item.status === "partial");
const hasEmpty = states.some((item) => item.status === "empty");
const hasError = states.some((item) => item.status === "error");
const hasWeakOk = states.some((item) => item.status === "ok" && !item.substantive);
const hasSubstantiveOk = states.some((item) => item.status === "ok" && item.substantive);
const hasSubstantivePartial = states.some((item) => item.status === "partial" && item.substantive);
if (hasSubstantiveOk && !hasSubstantivePartial && !hasWeakOk && !hasEmpty && !hasError) {
return { ...requirement, status: "covered" };
}
if (hasSubstantiveOk || hasSubstantivePartial || hasOk || hasPartial) {
return { ...requirement, status: "partially_covered" };
}
return { ...requirement, status: "uncovered" };
});
const requirementsCovered = resolvedRequirements.filter((item) => item.status === "covered").length;
const requirementsUncovered = resolvedRequirements
.filter((item) => item.status === "uncovered")
.map((item) => item.requirement_id);
const requirementsPartiallyCovered = resolvedRequirements
.filter((item) => item.status === "partially_covered")
.map((item) => item.requirement_id);
const clarificationNeededFor = resolvedRequirements
.filter((item) => item.status === "clarification_needed")
.map((item) => item.requirement_id);
const outOfScopeRequirements = resolvedRequirements
.filter((item) => item.status === "out_of_scope")
.map((item) => item.requirement_id);
return {
requirements: resolvedRequirements,
coverage: {
requirements_total: resolvedRequirements.length,
requirements_covered: requirementsCovered,
requirements_uncovered: requirementsUncovered,
requirements_partially_covered: requirementsPartiallyCovered,
clarification_needed_for: clarificationNeededFor,
out_of_scope_requirements: outOfScopeRequirements
}
};
}
function checkGroundingForRequirements(input) {
const whyIncludedSummary = summarizeUnique(input.retrievalResults.flatMap((item) => item.why_included));
const selectionReasonSummary = summarizeUnique(input.retrievalResults.flatMap((item) => item.selection_reason));
const hasMaterialResults = input.retrievalResults.some((item) => item.status === "ok" || item.status === "partial");
const subjectTokens = input.extractSubjectTokens(input.userMessage);
const executedRoutes = new Set(input.retrievalResults
.filter((item) => item.status !== "error")
.map((item) => item.route)
.filter(Boolean));
const retrievalCorpus = JSON.stringify(input.retrievalResults.map((item) => ({
route: item.route,
result_type: item.result_type,
summary: item.summary,
items: item.items,
evidence: item.evidence,
why_included: item.why_included,
selection_reason: item.selection_reason,
risk_factors: item.risk_factors,
business_interpretation: item.business_interpretation
}))).toLowerCase();
const missingSubjectTokens = [];
const missingCriticalTokens = [];
for (const token of subjectTokens) {
const match = evaluateSubjectTokenMatch(token, retrievalCorpus, executedRoutes);
if (!match.matched) {
missingSubjectTokens.push(token);
if (match.critical) {
missingCriticalTokens.push(token);
}
}
}
const onlyAccountCriticalMissing = missingCriticalTokens.length > 0 && missingCriticalTokens.every((token) => token.startsWith("account_"));
const accountOnlyMismatchRecoverable = hasMaterialResults &&
input.coverage.requirements_covered > 0 &&
onlyAccountCriticalMissing &&
(whyIncludedSummary.length > 0 || selectionReasonSummary.length > 0);
const routeSubjectMatch = !hasMaterialResults || missingCriticalTokens.length === 0 || accountOnlyMismatchRecoverable;
let status = "grounded";
const reasons = [];
if (!routeSubjectMatch) {
status = "route_mismatch_blocked";
reasons.push(`Ключевые ориентиры вопроса не подтверждены в найденных данных: ${missingCriticalTokens.join(", ")}`);
}
else if (accountOnlyMismatchRecoverable) {
status = "partial";
reasons.push(`Часть счетных ориентиров не подтвердилась напрямую (${missingCriticalTokens.join(", ")}), но есть опора для ограниченного вывода.`);
}
else if (input.coverage.requirements_covered === 0) {
status = "no_grounded_answer";
reasons.push("Ни одно требование не получило подтвержденного покрытия.");
}
else if (input.coverage.requirements_uncovered.length > 0 ||
input.coverage.requirements_partially_covered.length > 0 ||
input.coverage.clarification_needed_for.length > 0 ||
input.coverage.out_of_scope_requirements.length > 0) {
status = "partial";
reasons.push("Вопрос покрыт частично: есть непокрытые или требующие уточнения требования.");
}
if (whyIncludedSummary.length === 0) {
reasons.push("В текущей выборке не хватает явных подтверждений, почему записи попали в ответ.");
}
if (missingSubjectTokens.length > 0 && missingCriticalTokens.length === 0) {
reasons.push(`Часть контекста вопроса не подтверждена напрямую в найденных данных: ${missingSubjectTokens.join(", ")}`);
}
const missingRequirements = [
...input.coverage.requirements_uncovered,
...input.coverage.requirements_partially_covered,
...input.coverage.clarification_needed_for,
...input.coverage.out_of_scope_requirements
];
return {
status,
route_subject_match: routeSubjectMatch,
missing_requirements: missingRequirements,
reasons,
why_included_summary: whyIncludedSummary,
selection_reason_summary: selectionReasonSummary
};
}