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

1112 lines
52 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.AssistantService = void 0;
const nanoid_1 = require("nanoid");
const stage1Contracts_1 = require("../types/stage1Contracts");
const config_1 = require("../config");
const log_1 = require("../utils/log");
const answerComposer_1 = require("./answerComposer");
const assistantDataLayer_1 = require("./assistantDataLayer");
const assistantSessionLogger_1 = require("./assistantSessionLogger");
const investigationState_1 = require("./investigationState");
const retrievalResultNormalizer_1 = require("./retrievalResultNormalizer");
function retrievalSummaryForRoute(route) {
if (route === "store_canonical")
return "Canonical accounting data path selected.";
if (route === "store_feature_risk")
return "Risk/control profile path selected.";
if (route === "hybrid_store_plus_live")
return "Hybrid chain analysis path selected.";
if (route === "live_mcp_drilldown")
return "Live drilldown path selected.";
if (route === "batch_refresh_then_store")
return "Heavy analytical batch path selected.";
return "Route selected.";
}
function mapNoRouteReason(reason) {
if (reason === "out_of_scope")
return "Fragment out of scope.";
if (reason === "insufficient_specificity")
return "Needs clarification.";
if (reason === "missing_mapping")
return "Route mapping is missing.";
if (reason === "unsupported_fragment_type")
return "Fragment type unsupported.";
return "No-route decision.";
}
function extractFragments(normalized) {
if (!normalized || typeof normalized !== "object") {
return [];
}
const source = normalized;
return Array.isArray(source.fragments) ? source.fragments : [];
}
function extractExecutionState(normalized) {
const fragments = extractFragments(normalized);
return fragments.map((item) => {
if (!item || typeof item !== "object") {
return {};
}
const fragment = item;
return {
fragment_id: fragment.fragment_id ?? null,
execution_readiness: fragment.execution_readiness ?? null,
route_status: fragment.route_status ?? null,
no_route_reason: fragment.no_route_reason ?? null
};
});
}
function fragmentTextById(normalized) {
const result = new Map();
for (const item of extractFragments(normalized)) {
if (!item || typeof item !== "object") {
continue;
}
const fragment = item;
const fragmentId = typeof fragment.fragment_id === "string" ? fragment.fragment_id : "";
if (!fragmentId) {
continue;
}
const text = (typeof fragment.raw_fragment_text === "string" && fragment.raw_fragment_text.trim()) ||
(typeof fragment.normalized_fragment_text === "string" && fragment.normalized_fragment_text.trim()) ||
"";
result.set(fragmentId, text);
}
return result;
}
function extractDiscardedIntentSegments(normalized) {
if (!normalized || typeof normalized !== "object") {
return [];
}
const source = normalized;
if (!Array.isArray(source.discarded_fragments)) {
return [];
}
return source.discarded_fragments
.map((item) => {
if (!item || typeof item !== "object") {
return null;
}
const value = item;
const text = typeof value.raw_fragment_text === "string" ? value.raw_fragment_text.trim() : "";
return text || null;
})
.filter((item) => Boolean(item));
}
function collectDateSpans(text) {
const spans = [];
const datePattern = /\b20\d{2}[-/.](?:0[1-9]|1[0-2])(?:[-/.](?:0[1-9]|[12]\d|3[01]))?\b/g;
let match = null;
while ((match = datePattern.exec(text)) !== null) {
spans.push({
start: match.index,
end: match.index + match[0].length
});
}
return spans;
}
function intersectsAnySpan(start, end, spans) {
return spans.some((span) => start < span.end && end > span.start);
}
function extractAccountTokens(text) {
const lower = String(text ?? "").toLowerCase();
const explicitAccounts = new Set();
const contextualPattern = /(?:\bсчет(?:а|у|ом|ов)?\b|\bсч\.?\b|\baccount(?:s)?\b|\bschet(?:a|u|om|ov)?\b)\s*(?:№|#|:)?\s*(\d{2}(?:\.\d{2})?)/giu;
let contextual = null;
while ((contextual = contextualPattern.exec(lower)) !== null) {
if (contextual[1]) {
explicitAccounts.add(contextual[1]);
}
}
if (explicitAccounts.size > 0) {
return Array.from(explicitAccounts);
}
const spans = collectDateSpans(lower);
const accountList = [];
const genericPattern = /\b\d{2}(?:\.\d{2})?\b/g;
let generic = null;
while ((generic = genericPattern.exec(lower)) !== null) {
const value = generic[0];
const start = generic.index;
const end = start + value.length;
if (intersectsAnySpan(start, end, spans)) {
continue;
}
accountList.push(value);
}
return Array.from(new Set(accountList));
}
function extractSubjectTokens(text) {
const lower = text.toLowerCase();
const tokens = [];
const push = (token, match) => {
if (match)
tokens.push(token);
};
push("nds", /\b(?:\u043d\u0434\u0441|vat)\b/iu.test(lower));
push("os", /(?:\b\u043e\u0441\b|\u043e\u0441\u043d\u043e\u0432\u043d(?:\u044b\u0435|\u044b\u0445)\s+\u0441\u0440\u0435\u0434|osnovn(?:ye|yh)\s+sred)/iu.test(lower));
push("counterparty", /(?:\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b|supplier|buyer|counterparty|kontragent|postavshch|pokupatel)/iu.test(lower));
push("document", /(?:\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d|\u0432\u044b\u043f\u0438\u0441\u043a|\u043f\u043b\u0430\u0442\u0435\u0436|document|invoice|posting|dokument|realiz|postuplen|vypisk|platezh)/iu.test(lower));
push("saldo", /(?:\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u0441\u0442\u0430\u0442\u043a|saldo|balance)/iu.test(lower));
push("anomaly", /(?:\u0430\u043d\u043e\u043c\u0430\u043b|\u0440\u0438\u0441\u043a|\u043f\u043e\u0434\u043e\u0437\u0440|\u0445\u0432\u043e\u0441\u0442|anomaly|risk|anomali|hvost|tail)/iu.test(lower));
push("chain", /(?:\u0446\u0435\u043f\u043e\u0447|\u0440\u0430\u0437\u043b\u043e\u0436|\u0441\u0432\u044f\u0437\u043a|\u0440\u0430\u0437\u0440\u044b\u0432|chain|razlozh|svyaz|razryv)/iu.test(lower));
const accountMatches = extractAccountTokens(lower);
for (const account of accountMatches) {
tokens.push(`account_${account}`);
}
return Array.from(new Set(tokens));
}
function extractRequirements(routeSummary, normalized, userMessage) {
const fragmentText = fragmentTextById(normalized);
const byFragment = new Map();
const requirements = [];
const pushRequirement = (input) => {
const subjectTokens = extractSubjectTokens(input.requirement_text);
requirements.push({
requirement_id: input.requirement_id,
source_fragment_id: input.source_fragment_id,
requirement_text: input.requirement_text,
subject_tokens: subjectTokens,
status: input.status,
route: input.route
});
if (input.source_fragment_id) {
const current = byFragment.get(input.source_fragment_id) ?? [];
current.push(input.requirement_id);
byFragment.set(input.source_fragment_id, current);
}
};
if (!routeSummary) {
pushRequirement({
requirement_id: "R1",
source_fragment_id: null,
requirement_text: userMessage,
status: "clarification_needed",
route: null
});
return { requirements, byFragment };
}
if (routeSummary.mode === "legacy_v1") {
pushRequirement({
requirement_id: "R1",
source_fragment_id: "F1",
requirement_text: userMessage,
status: "covered",
route: routeSummary.route_hint
});
return { requirements, byFragment };
}
routeSummary.decisions.forEach((decision, index) => {
const requirementId = `R${index + 1}`;
const text = fragmentText.get(decision.fragment_id) ?? 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 toExecutionPlan(routeSummary, normalized, userMessage, requirementByFragment) {
if (!routeSummary) {
return [];
}
const fragmentText = fragmentTextById(normalized);
if (routeSummary.mode === "legacy_v1") {
return [
{
fragment_id: "F1",
requirement_ids: requirementByFragment.get("F1") ?? ["R1"],
route: routeSummary.route_hint,
should_execute: true,
fragment_text: userMessage,
no_route_reason: null,
clarification_reason: null
}
];
}
return routeSummary.decisions.map((decision) => {
const text = fragmentText.get(decision.fragment_id) ?? userMessage;
if (decision.route === "no_route") {
return {
fragment_id: decision.fragment_id,
requirement_ids: requirementByFragment.get(decision.fragment_id) ?? [],
route: "no_route",
should_execute: false,
fragment_text: text,
no_route_reason: decision.no_route_reason ?? null,
clarification_reason: decision.clarification_reason ?? null
};
}
return {
fragment_id: decision.fragment_id,
requirement_ids: requirementByFragment.get(decision.fragment_id) ?? [],
route: decision.route,
should_execute: true,
fragment_text: text,
no_route_reason: null,
clarification_reason: decision.clarification_reason ?? null
};
});
}
function toDebugRoutes(routeSummary) {
if (!routeSummary) {
return [];
}
if (routeSummary.mode === "legacy_v1") {
return [
{
fragment_id: "F1",
route: routeSummary.route_hint,
reason: retrievalSummaryForRoute(routeSummary.route_hint),
confidence: routeSummary.confidence,
intent_class: routeSummary.intent_class
}
];
}
return routeSummary.decisions.map((decision) => ({
fragment_id: decision.fragment_id,
route: decision.route,
reason: decision.reason,
route_status: decision.route_status ?? null,
no_route_reason: decision.no_route_reason ?? null,
clarification_reason: decision.clarification_reason ?? null,
execution_readiness: decision.execution_readiness ?? null
}));
}
function buildSkippedResult(item) {
return (0, retrievalResultNormalizer_1.normalizeRetrievalResult)(item.fragment_id, item.requirement_ids, "no_route", {
status: "empty",
result_type: "summary",
items: [],
summary: {
skipped: true,
reason: mapNoRouteReason(item.no_route_reason),
no_route_reason: item.no_route_reason,
clarification_reason: item.clarification_reason
},
evidence: [],
why_included: [],
selection_reason: [mapNoRouteReason(item.no_route_reason)],
risk_factors: [],
business_interpretation: ["Данный фрагмент не был выполнен из-за no-route решения."],
confidence: "low",
limitations: ["Фрагмент требует уточнения или отсутствует поддерживаемый маршрут."],
errors: []
});
}
function summarizeUnique(values, limit = 6) {
return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean))).slice(0, limit);
}
const SUBJECT_TOKEN_RULES = {
nds: {
critical: true,
patterns: [
"vat",
"accumulationregister",
"\u043d\u0434\u0441",
"\u043a\u043d\u0438\u0433\u0438\u043f\u043e\u043a\u0443\u043f\u043e\u043a",
"\u043a\u043d\u0438\u0433\u0438\u043f\u0440\u043e\u0434\u0430\u0436",
"\u043d\u0430\u043b\u043e\u0433\u043d\u0430\u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043d\u0443\u044e\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u044c"
]
},
os: {
critical: true,
patterns: ["fixed_asset", "fixedasset", "\u043e\u0441\u043d\u043e\u0432\u043d", "\u0430\u043c\u043e\u0440\u0442\u0438\u0437"]
},
saldo: {
critical: true,
patterns: ["balance", "saldo", "\u0441\u0430\u043b\u044c\u0434\u043e", "\u043e\u0441\u0442\u0430\u0442"]
},
counterparty: {
critical: false,
patterns: [
"counterparty",
"supplier",
"buyer",
"counterparty_id",
"journal_counterparty",
"document_has_counterparty",
"\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442",
"\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a",
"\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b"
],
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",
"\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442"
],
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",
"\u0430\u043d\u043e\u043c\u0430\u043b",
"\u0440\u0438\u0441\u043a"
],
routes: ["store_feature_risk", "batch_refresh_then_store"]
},
chain: {
critical: false,
patterns: ["chain", "cross_entity_chain", "relation_types", "operations_count", "matched_counterparties", "\u0446\u0435\u043f\u043e\u0447"],
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 evaluateCoverage(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(result.status);
statusByRequirement.set(requirementId, list);
}
}
const resolvedRequirements = requirements.map((requirement) => {
if (requirement.status === "out_of_scope" || requirement.status === "clarification_needed") {
return requirement;
}
const statuses = statusByRequirement.get(requirement.requirement_id) ?? [];
if (statuses.length === 0) {
return { ...requirement, status: "uncovered" };
}
if (statuses.includes("ok")) {
return { ...requirement, status: "covered" };
}
if (statuses.includes("partial")) {
return { ...requirement, status: "partially_covered" };
}
if (statuses.includes("empty") && !statuses.includes("error")) {
return { ...requirement, status: "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 checkGrounding(userMessage, requirements, coverage, retrievalResults) {
const whyIncludedSummary = summarizeUnique(retrievalResults.flatMap((item) => item.why_included));
const selectionReasonSummary = summarizeUnique(retrievalResults.flatMap((item) => item.selection_reason));
const hasMaterialResults = retrievalResults.some((item) => item.status === "ok" || item.status === "partial");
const subjectTokens = extractSubjectTokens(userMessage);
const executedRoutes = new Set(retrievalResults
.filter((item) => item.status !== "error")
.map((item) => item.route)
.filter(Boolean));
const retrievalCorpus = JSON.stringify(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 &&
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 (coverage.requirements_covered === 0) {
status = "no_grounded_answer";
reasons.push("Ни одно требование не получило подтвержденного покрытия.");
}
else if (coverage.requirements_uncovered.length > 0 ||
coverage.requirements_partially_covered.length > 0 ||
coverage.clarification_needed_for.length > 0 ||
coverage.out_of_scope_requirements.length > 0) {
status = "partial";
reasons.push("Вопрос покрыт частично: есть непокрытые или требующие уточнения требования.");
}
if (whyIncludedSummary.length === 0) {
reasons.push("Нет explainable-сигналов why_included в результатах выборки.");
}
if (missingSubjectTokens.length > 0 && missingCriticalTokens.length === 0) {
reasons.push(`Часть контекстных токенов не подтверждена напрямую: ${missingSubjectTokens.join(", ")}`);
}
const missingRequirements = [
...coverage.requirements_uncovered,
...coverage.requirements_partially_covered,
...coverage.clarification_needed_for,
...coverage.out_of_scope_requirements
];
return {
status,
route_subject_match: routeSubjectMatch,
missing_requirements: missingRequirements,
reasons,
why_included_summary: whyIncludedSummary,
selection_reason_summary: selectionReasonSummary
};
}
function firstNonEmptyLine(text) {
const line = text
.split("\n")
.map((item) => item.trim())
.find((item) => item.length > 0);
return (line ?? text).slice(0, 220);
}
function buildClaimEvidenceLinks(retrievalResults) {
const byClaim = new Map();
for (const result of retrievalResults) {
for (const evidence of result.evidence) {
const claimRef = String(evidence.claim_ref ?? "").trim();
if (!claimRef) {
continue;
}
const evidenceId = String(evidence.evidence_id ?? "").trim();
if (!evidenceId) {
continue;
}
const current = byClaim.get(claimRef) ?? [];
current.push(evidenceId);
byClaim.set(claimRef, current);
}
}
return Array.from(byClaim.entries())
.slice(0, 10)
.map(([claimRef, evidenceIds]) => ({
claim_ref: claimRef,
evidence_ids: summarizeUnique(evidenceIds, 10)
}));
}
function buildAnswerStructureV11(input) {
const evidenceIds = summarizeUnique(input.retrievalResults.flatMap((item) => item.evidence.map((evidence) => evidence.evidence_id)), 10);
const mechanismNotes = summarizeUnique(input.retrievalResults.flatMap((item) => item.evidence
.map((evidence) => evidence.mechanism_note)
.filter((note) => typeof note === "string" && note.trim().length > 0)), 6);
const sourceRefs = summarizeUnique(input.retrievalResults.flatMap((item) => item.evidence
.map((evidence) => evidence.source_ref?.canonical_ref)
.filter((value) => typeof value === "string" && value.trim().length > 0)), 8);
const limitationReasonCodes = summarizeUnique(input.retrievalResults.flatMap((item) => item.evidence
.flatMap((evidence) => {
const code = evidence.limitation?.reason_code;
return typeof code === "string" && code.trim().length > 0 ? [code] : [];
})), 8);
const claimEvidenceLinks = buildClaimEvidenceLinks(input.retrievalResults);
const limitations = summarizeUnique([...input.retrievalResults.flatMap((item) => item.limitations), ...input.groundingCheck.reasons], 8);
const clarificationQuestions = input.coverageReport.clarification_needed_for.map((item) => `Уточните требование ${item}.`);
const recommendedActions = summarizeUnique([
...input.coverageReport.requirements_uncovered.map((item) => `Проверить непокрытое требование ${item}.`),
...input.coverageReport.requirements_partially_covered.map((item) => `Доуточнить частично покрытое требование ${item}.`)
], 6);
const mechanismStatus = mechanismNotes.length === 0
? "unresolved"
: limitationReasonCodes.includes("missing_mechanism") || limitationReasonCodes.includes("heuristic_inference")
? "limited"
: "grounded";
return {
schema_version: stage1Contracts_1.ANSWER_STRUCTURE_SCHEMA_VERSION,
answer_summary: firstNonEmptyLine(input.assistantReply),
direct_answer: input.assistantReply,
mechanism_block: {
status: mechanismStatus,
mechanism_notes: mechanismNotes,
limitation_reason_codes: limitationReasonCodes
},
evidence_block: {
evidence_ids: evidenceIds,
source_refs: sourceRefs,
mechanism_notes: mechanismNotes,
coverage_note: input.coverageReport.requirements_total === input.coverageReport.requirements_covered
? "coverage_full_or_near_full"
: "coverage_partial_or_limited",
...(config_1.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 && claimEvidenceLinks.length > 0
? {
claim_evidence_links: claimEvidenceLinks
}
: {})
},
uncertainty_block: {
open_uncertainties: input.groundingCheck.missing_requirements,
limitations
},
next_step_block: {
recommended_actions: recommendedActions,
clarification_questions: clarificationQuestions
}
};
}
const FOLLOWUP_ROUTE_HINTS = new Set(["store_canonical", "store_feature_risk", "hybrid_store_plus_live", "live_mcp_drilldown", "batch_refresh_then_store"]);
const FOLLOWUP_BUSINESS_CONTEXT_MAX = 320;
const FOLLOWUP_SUBJECT_MAX = 160;
const FOLLOWUP_QUESTION_APPEND_MAX = 260;
function compactWhitespace(value) {
return value.replace(/\s+/g, " ").trim();
}
function hasAccountingSignal(text) {
const lower = text.toLowerCase();
if (/(?:^|[\s,;:])\d{2}(?:\.\d{2})?(?=$|[\s,.;:])/i.test(lower)) {
return true;
}
return /(РїСЂРѕРІРѕРґРє|документ|реализац|поступлен|взаиморасчет|сальдо|остатк|счет|РЅРґСЃ|амортиз|СЂР±Рї|РѕСЃ|контрагент|поставщик|покупател|оплат|банк|выписк|склад|товар|материал|проводк|документ|реализац|поступлен|взаиморасчет|сальдо|остатк|счет|счёт|ндс|амортиз|рбп|контрагент|поставщик|покупател|оплат|банк|выписк|склад|товар|материал|закрыти|период|postavshchik|kontragent|schet|schetu|period|counterparty|supplier|invoice|posting|ledger|account|anomaly|risk)/i.test(lower);
}
function hasFollowupMarker(text) {
const compact = compactWhitespace(text.toLowerCase());
return /^(Рё|Р° еще|Р° ещё|еще|ещё|добав|уточн|продолж|также|и|а если|а еще|а ещё|еще|ещё|добав|уточн|продолж|также|plus|also|dobav|utochn|prodolzh)/i.test(compact);
}
function hasReferentialPointer(text) {
return /(РїРѕ этому|РїРѕ тому|это Р¶Рµ|этой|этим|тому|по этому|по тому|это же|этой|этим|этому|из этого|в этом|тот же|same thing|that one|po etomu|po tomu)/i.test(text.toLowerCase());
}
function hasSmallTalkSignal(text) {
return /(привет|как дела|спасибо|привет|как дела|спасибо|благодарю|thanks|thank you|hello|hi)\b/i.test(text.toLowerCase());
}
function countTokens(text) {
return compactWhitespace(text)
.split(" ")
.filter(Boolean).length;
}
function hasPeriodLiteral(text) {
return /\b(20\d{2}(?:[-/.](?:0[1-9]|1[0-2]))?)\b/.test(text);
}
function extractNormalizedPeriodLiteral(text) {
const monthly = text.match(/\b(20\d{2})[-/.](0[1-9]|1[0-2])\b/);
if (monthly) {
return `${monthly[1]}-${monthly[2]}`;
}
const yearly = text.match(/\b(20\d{2})\b/);
if (yearly) {
return yearly[1];
}
return null;
}
function hasStrongFollowupAnchors(userMessage, state) {
const explicitPeriod = extractNormalizedPeriodLiteral(userMessage);
if (explicitPeriod && state.focus.period && explicitPeriod !== state.focus.period) {
const periodLooksLikeFollowupRefinement = hasFollowupMarker(userMessage) || hasReferentialPointer(userMessage);
if (!periodLooksLikeFollowupRefinement) {
return true;
}
}
const explicitAccounts = extractAccountTokens(userMessage);
if (explicitAccounts.length > 0) {
const knownAccounts = new Set(state.focus.primary_accounts.map((item) => item.trim()));
if (knownAccounts.size === 0) {
return true;
}
if (explicitAccounts.some((item) => !knownAccounts.has(item))) {
return true;
}
}
return false;
}
function routeFromInvestigationState(state) {
const rawDomain = compactWhitespace(state.focus.domain ?? "");
if (!rawDomain) {
return null;
}
if (FOLLOWUP_ROUTE_HINTS.has(rawDomain)) {
return rawDomain;
}
for (const candidate of rawDomain.split(",").map((item) => compactWhitespace(item))) {
if (FOLLOWUP_ROUTE_HINTS.has(candidate)) {
return candidate;
}
}
return null;
}
function withCappedLength(value, max) {
return value.length <= max ? value : value.slice(0, max);
}
function mergeBusinessContext(existing, patchParts) {
const existingText = compactWhitespace(existing ?? "");
const patchText = compactWhitespace(patchParts.filter(Boolean).join("; "));
if (!existingText && !patchText) {
return undefined;
}
const merged = existingText && patchText ? `${existingText}; ${patchText}` : existingText || patchText;
return withCappedLength(merged, FOLLOWUP_BUSINESS_CONTEXT_MAX);
}
function buildFollowupStateBinding(input) {
const userMessage = String(input.userMessage ?? "").trim();
if (!userMessage || input.investigationState.status !== "active" || input.investigationState.turn_index <= 0) {
return {
normalizedQuestion: userMessage,
mergedContext: input.payloadContext,
usage: null
};
}
const strongSignal = hasAccountingSignal(userMessage);
const followupMarker = hasFollowupMarker(userMessage);
const referentialPointer = hasReferentialPointer(userMessage);
const shortPrompt = countTokens(userMessage) <= 10;
const smallTalkSignal = hasSmallTalkSignal(userMessage);
const problemState = input.investigationState.problem_unit_state;
const problemContinuityAvailable = config_1.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 &&
Boolean(problemState) &&
((problemState?.active_problem_units.length ?? 0) > 0 || (problemState?.focus_problem_types.length ?? 0) > 0);
const strongNewAnchorDetected = hasStrongFollowupAnchors(userMessage, input.investigationState);
const periodRefinementFollowup = hasPeriodLiteral(userMessage) && problemContinuityAvailable;
const shouldBind = !smallTalkSignal &&
!strongNewAnchorDetected &&
(followupMarker || referentialPointer || periodRefinementFollowup || (!strongSignal && shortPrompt));
if (!shouldBind) {
return {
normalizedQuestion: userMessage,
mergedContext: input.payloadContext,
usage: null
};
}
const context = {
...(input.payloadContext ?? {})
};
const hasExplicitExpectedRoute = Boolean(input.payloadContext?.expected_route);
const expectedRouteFromState = !context?.expected_route ? routeFromInvestigationState(input.investigationState) : null;
const periodHintFromState = !context?.period_hint ? input.investigationState.focus.period : null;
if (expectedRouteFromState) {
context.expected_route = expectedRouteFromState;
}
if (periodHintFromState) {
context.period_hint = periodHintFromState;
}
const subject = withCappedLength(compactWhitespace(input.investigationState.focus.active_query_subject ?? ""), FOLLOWUP_SUBJECT_MAX);
const businessContextPatch = ["followup_state_binding_v1"];
let problemContinuityApplied = false;
let problemContinuitySkippedReason = null;
if (input.investigationState.focus.period) {
businessContextPatch.push("active_period");
}
if (subject) {
businessContextPatch.push(`focus_subject:${subject}`);
}
if (input.investigationState.focus.primary_accounts.length > 0) {
businessContextPatch.push(`focus_accounts:${input.investigationState.focus.primary_accounts.join(",")}`);
}
if (problemContinuityAvailable) {
if (hasExplicitExpectedRoute) {
problemContinuitySkippedReason = "explicit_expected_route";
}
else {
const focusTypes = (problemState?.focus_problem_types ?? []).slice(0, 3);
const activeCount = problemState?.active_problem_units.length ?? 0;
businessContextPatch.push("problem_unit_continuity_v1");
if (focusTypes.length > 0) {
businessContextPatch.push(`problem_focus_types:${focusTypes.join(",")}`);
}
businessContextPatch.push(`problem_active_count:${activeCount}`);
problemContinuityApplied = true;
}
}
const mergedBusinessContext = mergeBusinessContext(context?.business_context, businessContextPatch);
if (mergedBusinessContext) {
context.business_context = mergedBusinessContext;
}
const shouldAugmentQuestion = Boolean(subject) && (followupMarker || referentialPointer || !strongSignal);
let normalizedQuestion = userMessage;
if (shouldAugmentQuestion) {
const appendParts = [`Фокус текущего разбора: ${subject}`];
if (input.investigationState.focus.primary_accounts.length > 0 && !/\b\d{2}(?:\.\d{2})?\b/.test(userMessage)) {
appendParts.push(`Счета фокуса: ${input.investigationState.focus.primary_accounts.join(", ")}`);
}
if (periodHintFromState && !hasPeriodLiteral(userMessage)) {
appendParts.push(`Период фокуса: ${periodHintFromState}`);
}
if (problemContinuityApplied && (problemState?.focus_problem_types.length ?? 0) > 0) {
appendParts.push(`Problem focus types: ${(problemState?.focus_problem_types ?? []).slice(0, 3).join(", ")}`);
}
const appendBlock = withCappedLength(compactWhitespace(appendParts.join("; ")), FOLLOWUP_QUESTION_APPEND_MAX);
normalizedQuestion = `${userMessage}\n${appendBlock}`.trim();
}
const reason = followupMarker
? "followup_marker"
: referentialPointer
? "referential_pointer"
: "underspecified_short_followup";
return {
normalizedQuestion,
mergedContext: context,
usage: {
applied: true,
reason,
state_turn_index: input.investigationState.turn_index,
context_patch: {
period_hint_from_state: Boolean(periodHintFromState),
expected_route_from_state: Boolean(expectedRouteFromState),
business_context_from_state: Boolean(mergedBusinessContext),
question_augmented: shouldAugmentQuestion,
problem_continuity_available: problemContinuityAvailable,
problem_continuity_applied: problemContinuityApplied,
problem_continuity_skipped_reason: problemContinuityApplied ? null : problemContinuitySkippedReason,
strong_new_anchor_detected: strongNewAnchorDetected
}
}
};
}
function cloneItems(items) {
return items.map((item) => ({
...item,
debug: item.debug ? { ...item.debug } : null
}));
}
class AssistantService {
normalizerService;
sessions;
dataLayer;
sessionLogger;
constructor(normalizerService, sessions, dataLayer = new assistantDataLayer_1.AssistantDataLayer(), sessionLogger = new assistantSessionLogger_1.AssistantSessionLogger()) {
this.normalizerService = normalizerService;
this.sessions = sessions;
this.dataLayer = dataLayer;
this.sessionLogger = sessionLogger;
}
getSession(sessionId) {
return this.sessions.getSession(sessionId);
}
async handleMessage(payload) {
const session = this.sessions.ensureSession(payload.session_id);
const sessionId = session.session_id;
const userMessage = String(payload.user_message ?? payload.message ?? "").trim();
const userItem = {
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
session_id: sessionId,
role: "user",
text: userMessage,
reply_type: null,
created_at: new Date().toISOString(),
trace_id: null,
debug: null
};
this.sessions.appendItem(sessionId, userItem);
const followupBinding = config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 &&
config_1.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 &&
session.investigation_state
? buildFollowupStateBinding({
userMessage,
payloadContext: payload.context,
investigationState: session.investigation_state
})
: {
normalizedQuestion: userMessage,
mergedContext: payload.context,
usage: null
};
const normalizePayload = {
apiKey: payload.apiKey,
model: payload.model,
baseUrl: payload.baseUrl,
temperature: payload.temperature,
maxOutputTokens: payload.maxOutputTokens,
promptVersion: payload.promptVersion ?? "normalizer_v2_0_2",
systemPrompt: payload.systemPrompt,
developerPrompt: payload.developerPrompt,
domainPrompt: payload.domainPrompt,
fewShotExamples: payload.fewShotExamples,
userQuestion: followupBinding.normalizedQuestion,
context: followupBinding.mergedContext,
useMock: Boolean(payload.useMock)
};
const normalized = await this.normalizerService.normalize(normalizePayload);
const requirementExtraction = extractRequirements(normalized.route_hint_summary, normalized.normalized, userMessage);
const executionPlan = toExecutionPlan(normalized.route_hint_summary, normalized.normalized, userMessage, requirementExtraction.byFragment);
const retrievalCalls = [];
const retrievalResultsRaw = [];
const retrievalResults = [];
for (const planItem of executionPlan) {
if (!planItem.should_execute) {
retrievalCalls.push({
fragment_id: planItem.fragment_id,
requirement_ids: planItem.requirement_ids,
route: planItem.route,
status: "skipped",
query_text: planItem.fragment_text,
reason: mapNoRouteReason(planItem.no_route_reason)
});
retrievalResults.push(buildSkippedResult(planItem));
continue;
}
retrievalCalls.push({
fragment_id: planItem.fragment_id,
requirement_ids: planItem.requirement_ids,
route: planItem.route,
status: "executed",
query_text: planItem.fragment_text,
reason: null
});
try {
const raw = this.dataLayer.executeRoute(planItem.route, planItem.fragment_text);
retrievalResultsRaw.push({
fragment_id: planItem.fragment_id,
route: planItem.route,
raw_result: raw
});
retrievalResults.push((0, retrievalResultNormalizer_1.normalizeRetrievalResult)(planItem.fragment_id, planItem.requirement_ids, planItem.route, raw));
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
retrievalCalls[retrievalCalls.length - 1].status = "failed";
retrievalCalls[retrievalCalls.length - 1].reason = message;
const rawError = {
status: "error",
result_type: "summary",
items: [],
summary: {
route: planItem.route
},
evidence: [],
why_included: [],
selection_reason: [],
risk_factors: [],
business_interpretation: [],
confidence: "low",
limitations: ["Route executor failed."],
errors: [message]
};
retrievalResultsRaw.push({
fragment_id: planItem.fragment_id,
route: planItem.route,
raw_result: rawError
});
retrievalResults.push((0, retrievalResultNormalizer_1.normalizeRetrievalResult)(planItem.fragment_id, planItem.requirement_ids, planItem.route, rawError));
}
}
const coverageEvaluation = evaluateCoverage(requirementExtraction.requirements, retrievalResults);
const groundingCheck = checkGrounding(userMessage, coverageEvaluation.requirements, coverageEvaluation.coverage, retrievalResults);
const composition = (0, answerComposer_1.composeAssistantAnswer)({
userMessage,
routeSummary: normalized.route_hint_summary,
retrievalResults,
requirements: coverageEvaluation.requirements,
coverageReport: coverageEvaluation.coverage,
groundingCheck,
enableAnswerPolicyV11: config_1.FEATURE_ASSISTANT_ANSWER_POLICY_V11,
enableProblemCentricAnswerV1: config_1.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1,
enableLifecycleAnswerV1: config_1.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1
});
const answerStructureV11 = config_1.FEATURE_ASSISTANT_CONTRACTS_V11
? config_1.FEATURE_ASSISTANT_ANSWER_POLICY_V11 && composition.answer_structure_v11
? composition.answer_structure_v11
: buildAnswerStructureV11({
assistantReply: composition.assistant_reply,
coverageReport: coverageEvaluation.coverage,
groundingCheck,
retrievalResults
})
: null;
const investigationStateSnapshot = config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 && session.investigation_state
? (0, investigationState_1.updateInvestigationState)({
previous: session.investigation_state,
timestamp: new Date().toISOString(),
questionId: userItem.message_id,
userMessage,
routeSummary: normalized.route_hint_summary,
requirements: coverageEvaluation.requirements,
coverageReport: coverageEvaluation.coverage,
retrievalResults,
replyType: composition.reply_type
})
: null;
if (config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 && investigationStateSnapshot) {
this.sessions.setInvestigationState(sessionId, investigationStateSnapshot);
}
const debug = {
trace_id: normalized.trace_id,
prompt_version: normalized.prompt_version,
schema_version: normalized.schema_version,
fallback_type: composition.fallback_type,
route_summary: normalized.route_hint_summary,
fragments: extractFragments(normalized.normalized),
requirements_extracted: coverageEvaluation.requirements,
coverage_report: coverageEvaluation.coverage,
routes: toDebugRoutes(normalized.route_hint_summary),
retrieval_status: retrievalResults.map((item) => ({
fragment_id: item.fragment_id,
requirement_ids: item.requirement_ids,
route: item.route,
status: item.status,
result_type: item.result_type
})),
retrieval_results: retrievalResults,
answer_grounding_check: groundingCheck,
dropped_intent_segments: extractDiscardedIntentSegments(normalized.normalized),
...(followupBinding.usage ? { followup_state_usage: followupBinding.usage } : {}),
problem_centric_answer_applied: composition.problem_centric_answer_applied ?? false,
problem_units_used_count: composition.problem_units_used_count ?? 0,
problem_answer_mode: composition.problem_answer_mode ?? "stage1_policy_v11",
...(Array.isArray(composition.problem_unit_ids_used) && composition.problem_unit_ids_used.length > 0
? {
problem_unit_ids_used: composition.problem_unit_ids_used
}
: {}),
answer_structure_v11: answerStructureV11,
investigation_state_snapshot: investigationStateSnapshot,
normalized: normalized.normalized
};
const assistantItem = {
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
session_id: sessionId,
role: "assistant",
text: composition.assistant_reply,
reply_type: composition.reply_type,
created_at: new Date().toISOString(),
trace_id: normalized.trace_id,
debug
};
this.sessions.appendItem(sessionId, assistantItem);
const current = this.sessions.getSession(sessionId);
if (current) {
this.sessionLogger.persistSession(current);
}
const conversation = cloneItems(current?.items ?? []);
(0, log_1.logJson)({
timestamp: new Date().toISOString(),
level: "info",
service: "assistant_loop",
message: "assistant_message_processed",
sessionId,
eventType: "assistant_message",
details: {
session_id: sessionId,
message_id: assistantItem.message_id,
user_message: userMessage,
normalizer_output: normalized.normalized,
execution_plan: executionPlan,
resolved_execution_state: extractExecutionState(normalized.normalized),
routes: toDebugRoutes(normalized.route_hint_summary),
retrieval_calls: retrievalCalls,
retrieval_results_raw: retrievalResultsRaw,
retrieval_results_normalized: retrievalResults,
requirements_extracted: coverageEvaluation.requirements,
requirements_total: coverageEvaluation.coverage.requirements_total,
requirements_covered: coverageEvaluation.coverage.requirements_covered,
requirements_uncovered: coverageEvaluation.coverage.requirements_uncovered,
coverage_status: coverageEvaluation.coverage.requirements_total === coverageEvaluation.coverage.requirements_covered &&
coverageEvaluation.coverage.requirements_uncovered.length === 0 &&
coverageEvaluation.coverage.requirements_partially_covered.length === 0
? "full"
: "partial_or_limited",
answer_grounding_status: groundingCheck.status,
reply_semantic_type: composition.reply_type,
why_included_summary: groundingCheck.why_included_summary,
selection_reason_summary: groundingCheck.selection_reason_summary,
route_subject_match: groundingCheck.route_subject_match,
clarification_target: coverageEvaluation.coverage.clarification_needed_for,
dropped_intent_segments: extractDiscardedIntentSegments(normalized.normalized),
...(followupBinding.usage ? { followup_state_usage: followupBinding.usage } : {}),
problem_centric_answer_applied: composition.problem_centric_answer_applied ?? false,
problem_units_used_count: composition.problem_units_used_count ?? 0,
problem_answer_mode: composition.problem_answer_mode ?? "stage1_policy_v11",
...(Array.isArray(composition.problem_unit_ids_used) && composition.problem_unit_ids_used.length > 0
? {
problem_unit_ids_used: composition.problem_unit_ids_used
}
: {}),
answer_structure_v11: answerStructureV11,
investigation_state_snapshot: investigationStateSnapshot,
fallback_type: composition.fallback_type,
assistant_reply: composition.assistant_reply,
reply_type: composition.reply_type,
trace_id: normalized.trace_id
}
});
return {
ok: true,
session_id: sessionId,
assistant_reply: composition.assistant_reply,
reply_type: composition.reply_type,
conversation_item: assistantItem,
debug,
conversation
};
}
}
exports.AssistantService = AssistantService;