Stage 3: улучшена логика жизненного цикла и очищены ответы ассистента

This commit is contained in:
2026-03-26 20:21:51 +03:00
parent d0b842adb0
commit 914843a8ba
81 changed files with 18051 additions and 654 deletions
@@ -1,4 +1,4 @@
import type {
import type {
AssistantFallbackType,
AssistantReplyType,
AnswerGroundingCheck,
@@ -50,21 +50,96 @@ const UUID_PATTERN = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]
const LONG_HEX_PATTERN = /\b[0-9a-f]{24,}\b/gi;
const RAW_REF_BLOB_PATTERN = /\bevidence_source_ref_v1\|[^\s,;]+/gi;
const RAW_REF_TOKEN_PATTERN = /\b(?:source_ref|canonical_ref|entity_id|fragment_id|guid|uuid)\b/gi;
const SYNTHETIC_PLACEHOLDER_PATTERN = /\bunknown_entity(?::[^\s,;]+)?\b/gi;
const SYNTHETIC_FALLBACK_MARKER_PATTERN = /\b(?:unknown_source|unknown_record)\b/gi;
const SYNTHETIC_ROUTE_TOKEN_PATTERN = /\bbatch_refresh_then_store:[^\s,;]+/gi;
const CYRILLIC_MOJIBAKE_FRAGMENT_PATTERN = /(?:[\u0420\u0421][\u0080-\u04FF\u2000-\u20CF]){2,}/u;
const LATIN_MOJIBAKE_FRAGMENT_PATTERN = /(?:[\u00D0\u00D1][\u0080-\u00FF]){2,}/u;
const SHORT_CYRILLIC_MOJIBAKE_TOKEN_PATTERN = /^[\u0420\u0421][\u0080-\u04FF\u2000-\u20CF]{1,2}$/u;
const PREFIXED_SHORT_CYRILLIC_MOJIBAKE_TOKEN_PATTERN = /^[\p{L}\p{N}_-]+[\u0420\u0421][\u0080-\u04FF\u2000-\u20CF]{1,2}$/u;
const MOJIBAKE_SINGLE_MARKER_PATTERN = /^[\u0420\u0421\u00D0\u00D1]$/u;
const MOJIBAKE_MARKER_CHAR_PATTERN = /[\u0402\u0403\u040A\u040C\u040E\u040F\u0452\u0453\u0459\u045A\u045C\u045E\u045F\u201A\u201E\u2020\u2021\u2026\u2030\u20AC\u2122]/u;
const CYRILLIC_MOJIBAKE_FRAGMENT_GLOBAL_PATTERN = /(?:[\u0420\u0421][\u0080-\u04FF\u2000-\u20CF]){2,}/gu;
const LATIN_MOJIBAKE_FRAGMENT_GLOBAL_PATTERN = /(?:[\u00D0\u00D1][\u0080-\u00FF]){2,}/g;
const MOJIBAKE_MARKER_CHAR_GLOBAL_PATTERN = /[\u0402\u0403\u040A\u040C\u040E\u040F\u0452\u0453\u0459\u045A\u045C\u045E\u045F\u201A\u201E\u2020\u2021\u2026\u2030\u20AC\u2122]/gu;
function normalizeToken(value: string): string {
return value.replace(/^[^\p{L}\p{N}_-]+|[^\p{L}\p{N}_-]+$/gu, "");
}
function isLikelyMojibakeToken(value: string): boolean {
const token = normalizeToken(String(value ?? ""));
if (!token) {
return false;
}
if (MOJIBAKE_SINGLE_MARKER_PATTERN.test(token)) {
return true;
}
if (SHORT_CYRILLIC_MOJIBAKE_TOKEN_PATTERN.test(token)) {
return true;
}
if (token.length <= 8 && PREFIXED_SHORT_CYRILLIC_MOJIBAKE_TOKEN_PATTERN.test(token)) {
return true;
}
return CYRILLIC_MOJIBAKE_FRAGMENT_PATTERN.test(token) || LATIN_MOJIBAKE_FRAGMENT_PATTERN.test(token);
}
function countMojibakeTokens(value: string): number {
return String(value ?? "")
.split(/[\s,.;:!?()[\]{}"']+/g)
.filter((token) => token.length > 0)
.filter((token) => isLikelyMojibakeToken(token)).length;
}
function countMojibakeSingleMarkers(value: string): number {
return String(value ?? "")
.split(/[\s,.;:!?()[\]{}"']+/g)
.filter((token) => token.length > 0)
.map((token) => normalizeToken(token))
.filter((token) => MOJIBAKE_SINGLE_MARKER_PATTERN.test(token)).length;
}
function stripMojibakeFragments(value: string): string {
const removedByToken = String(value ?? "")
.split(/(\s+)/g)
.map((part) => {
if (/^\s+$/u.test(part)) {
return part;
}
return isLikelyMojibakeToken(part) ? "" : part;
})
.join("");
return removedByToken
.replace(CYRILLIC_MOJIBAKE_FRAGMENT_GLOBAL_PATTERN, "")
.replace(LATIN_MOJIBAKE_FRAGMENT_GLOBAL_PATTERN, "")
.replace(MOJIBAKE_MARKER_CHAR_GLOBAL_PATTERN, "")
.replace(/\s+([,.;:!?])/g, "$1")
.replace(/\s{2,}/g, " ")
.trim();
}
function looksLikeMojibake(value: string): boolean {
const text = String(value ?? "");
if (!text.trim()) {
return false;
}
if (/(?:Р.|С.){5,}/u.test(text)) {
const tokenHits = countMojibakeTokens(text);
const singleMarkers = countMojibakeSingleMarkers(text);
if (tokenHits >= 2 || (tokenHits >= 1 && singleMarkers >= 1) || singleMarkers >= 3) {
return true;
}
if (/[ЃѓЂђЌќЎў]/u.test(text)) {
if (MOJIBAKE_MARKER_CHAR_PATTERN.test(text)) {
return true;
}
if (CYRILLIC_MOJIBAKE_FRAGMENT_PATTERN.test(text) || LATIN_MOJIBAKE_FRAGMENT_PATTERN.test(text)) {
return true;
}
if (/\uFFFD/u.test(text)) {
return true;
}
return false;
}
function looksLikeTechnicalIdentifier(value: string): boolean {
const text = String(value ?? "").trim();
if (!text) {
@@ -99,15 +174,33 @@ function scrubRawTechnicalRefs(value: string): string {
.trim();
}
function sanitizeUserFacingReply(value: string): string {
return scrubRawTechnicalRefs(value)
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
function stripSyntheticPlaceholders(value: string): string {
return String(value ?? "")
.replace(SYNTHETIC_PLACEHOLDER_PATTERN, "")
.replace(SYNTHETIC_FALLBACK_MARKER_PATTERN, "")
.replace(SYNTHETIC_ROUTE_TOKEN_PATTERN, "")
.replace(/[;,:]\s*[;,:]+/g, "; ")
.replace(/\s{2,}/g, " ")
.trim();
}
function sanitizeUserFacingReply(value: string): string {
const normalized = scrubRawTechnicalRefs(value).replace(/[ \t]+\n/g, "\n");
const cleanedLines = normalized
.split(/\r?\n/g)
.map((line) => stripSyntheticPlaceholders(line))
.map((line) => stripMojibakeFragments(line))
.map((line) => line.trim())
.filter((line) => line.length > 0)
.filter((line) => !looksLikeMojibake(line));
const cleaned = cleanedLines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
return cleaned || "Available data requires clarification for a reliable user-facing answer.";
}
function sanitizeUserText(value: string): string | null {
const normalized = scrubRawTechnicalRefs(String(value ?? "").replace(/\s+/g, " ").trim());
const normalized = stripMojibakeFragments(
stripSyntheticPlaceholders(scrubRawTechnicalRefs(String(value ?? "").replace(/\s+/g, " ").trim()))
);
if (!normalized) {
return null;
}
@@ -238,13 +331,13 @@ function buildFallbackWhyIncluded(results: UnifiedRetrievalResult[]): string[] {
const checkedRecords = summaryNumber(result, "checked_records");
if (routeFocus) {
lines.push(`Проверка выполнена по профилю ${routeFocus}.`);
lines.push(`Проверка выполнена по профилю ${routeFocus}.`);
}
if (sourceRecords !== null && filteredRecords !== null && filteredRecords < sourceRecords) {
lines.push(`Применено сужение выборки: ${filteredRecords} из ${sourceRecords} записей.`);
lines.push(`Применено сужение выборки: ${filteredRecords} из ${sourceRecords} записей.`);
}
if (checkedRecords !== null) {
lines.push(`Проверено записей в текущем проходе: ${checkedRecords}.`);
lines.push(`Проверено записей в текущем проходе: ${checkedRecords}.`);
}
}
@@ -255,19 +348,19 @@ function buildFallbackSelectionReasons(results: UnifiedRetrievalResult[]): strin
const lines: string[] = [];
for (const result of results.slice(0, 2)) {
if (summaryBoolean(result, "semantic_narrowing_applied")) {
lines.push("Отбор выполнен по семантическому сужению предметной области.");
lines.push("Отбор выполнен по семантическому сужению предметной области.");
}
const rankingBasis = summaryStringArray(result, "ranking_basis");
if (rankingBasis.length > 0) {
lines.push(`Ранжирование основано на: ${rankingBasis.join(", ")}.`);
lines.push(`Ранжирование основано на: ${rankingBasis.join(", ")}.`);
}
if (summaryBoolean(result, "broad_guard_applied")) {
lines.push("Применен broad-query guard для контроля ложной точности.");
lines.push("Применен broad-query guard для контроля ложной точности.");
}
}
if (lines.length === 0) {
lines.push("Отбор выполнен по совпадению предметных сигналов и доступной evidence-опоры.");
lines.push("Отбор выполнен по совпадению предметных сигналов и доступной evidence-опоры.");
}
return sanitizeUserLines(lines, 4);
@@ -276,16 +369,16 @@ function buildFallbackSelectionReasons(results: UnifiedRetrievalResult[]): strin
function suggestNextStep(requirements: AssistantRequirement[], coverage: RequirementCoverageReport): string[] {
const next: string[] = [];
if (coverage.clarification_needed_for.length > 0) {
next.push("Уточните период, счет, документ или контрагента для требований: " + coverage.clarification_needed_for.join(", ") + ".");
next.push("Уточните период, счет, документ или контрагента для требований: " + coverage.clarification_needed_for.join(", ") + ".");
}
if (coverage.requirements_uncovered.length > 0) {
next.push("Проверьте непокрытые требования: " + coverage.requirements_uncovered.join(", ") + ".");
next.push("Проверьте непокрытые требования: " + coverage.requirements_uncovered.join(", ") + ".");
}
if (coverage.out_of_scope_requirements.length > 0) {
next.push("Часть запроса вне текущего учетного контура: " + coverage.out_of_scope_requirements.join(", ") + ".");
next.push("Часть запроса вне текущего учетного контура: " + coverage.out_of_scope_requirements.join(", ") + ".");
}
if (next.length === 0 && requirements.length > 0) {
next.push("Следующим шагом можно открыть технический разбор и углубить проверку по выбранным объектам.");
next.push("Следующим шагом можно открыть технический разбор и углубить проверку по выбранным объектам.");
}
return next;
}
@@ -364,21 +457,25 @@ function selectProblemUnitSummary(results: UnifiedRetrievalResult[]): ProblemUni
}
function formatAffectedScope(unit: ProblemUnit): string {
const accountScope = sanitizeUserLines(unit.affected_accounts, 2);
const counterpartyScope = sanitizeUserLines(unit.affected_counterparties, 2);
const documentScope = sanitizeUserLines(unit.affected_documents, 2);
const entityScope = sanitizeUserLines(unit.affected_entities, 2);
const scopeParts: string[] = [];
if (unit.affected_accounts.length > 0) {
scopeParts.push(`счета: ${unit.affected_accounts.slice(0, 2).join(", ")}`);
if (accountScope.length > 0) {
scopeParts.push(`accounts: ${accountScope.join(", ")}`);
}
if (unit.affected_counterparties.length > 0) {
scopeParts.push(`контрагенты: ${unit.affected_counterparties.slice(0, 2).join(", ")}`);
if (counterpartyScope.length > 0) {
scopeParts.push(`counterparties: ${counterpartyScope.join(", ")}`);
}
if (unit.affected_documents.length > 0) {
scopeParts.push(`документы: ${unit.affected_documents.slice(0, 2).join(", ")}`);
if (documentScope.length > 0) {
scopeParts.push(`documents: ${documentScope.join(", ")}`);
}
if (scopeParts.length === 0 && unit.affected_entities.length > 0) {
scopeParts.push(`объекты: ${unit.affected_entities.slice(0, 2).join(", ")}`);
if (scopeParts.length === 0 && entityScope.length > 0) {
scopeParts.push(`entities: ${entityScope.join(", ")}`);
}
if (scopeParts.length === 0) {
return "затронутый контур требует уточнения";
return "affected scope requires clarification";
}
return scopeParts.join("; ");
}
@@ -448,49 +545,49 @@ function buildProblemCentricActions(input: {
const unitTypes = new Set(input.units.map((item) => item.problem_unit_type));
if (unitTypes.has("broken_chain_segment")) {
actions.push("Проверьте связку выписка -> документ -> проводка по проблемным участкам цепочки.");
actions.push("Проверьте связку выписка -> документ -> проводка по проблемным участкам цепочки.");
}
if (unitTypes.has("unresolved_settlement_cluster")) {
actions.push("Сверьте хвосты по расчетам: закрылся ли документ оплаты корректным закрывающим документом.");
actions.push("Сверьте хвосты по расчетам: закрылся ли документ оплаты корректным закрывающим документом.");
}
if (unitTypes.has("period_risk_cluster")) {
actions.push("Оцените влияние дефекта на закрытие периода и корректность регламентных операций.");
actions.push("Оцените влияние дефекта на закрытие периода и корректность регламентных операций.");
}
if (unitTypes.has("cross_branch_inconsistency_cluster")) {
actions.push("Сверьте противоречия между документами, проводками и регистрами по НДС/межконтурным связям.");
actions.push("Сверьте противоречия между документами, проводками и регистрами по НДС/межконтурным связям.");
}
if (unitTypes.has("lifecycle_anomaly_node")) {
actions.push("Проверьте lifecycle объекта: ожидаемый этап не должен оставаться в partially_linked состоянии.");
actions.push("Проверьте lifecycle объекта: ожидаемый этап не должен оставаться в partially_linked состоянии.");
}
for (const unit of input.units) {
if (unit.lifecycle_defect_type === "stale_active_state") {
actions.push("Проверьте, почему объект завис: ожидаемый переход не должен оставаться в активной стадии.");
actions.push("Проверьте, почему объект завис: ожидаемый переход не должен оставаться в активной стадии.");
}
if (unit.lifecycle_defect_type === "misclosed_state") {
actions.push("Проверьте закрывающий документ и проводки: закрытие может быть формальным, но некорректным по пути.");
actions.push("Проверьте закрывающий документ и проводки: закрытие может быть формальным, но некорректным по пути.");
}
if (unit.lifecycle_defect_type === "cross_branch_state_conflict") {
actions.push("Сверьте бухгалтерскую и смежную ветки (например, НДС/расчеты): обнаружен межконтурный конфликт состояния.");
actions.push("Сверьте бухгалтерскую и смежную ветки (например, НДС/расчеты): обнаружен межконтурный конфликт состояния.");
}
}
if (input.mode === "clarification_required") {
if (input.missingAnchors.period) {
actions.push("Уточните период проверки, чтобы зафиксировать границы проблемного контура.");
actions.push("Уточните период проверки, чтобы зафиксировать границы проблемного контура.");
}
if (input.missingAnchors.account) {
actions.push("Уточните счет или группу счетов для предметной локализации дефекта.");
actions.push("Уточните счет или группу счетов для предметной локализации дефекта.");
}
if (input.missingAnchors.documentOrObject) {
actions.push("Укажите конкретный документ или объект трассировки для проверки механизма отклонения.");
actions.push("Укажите конкретный документ или объект трассировки для проверки механизма отклонения.");
}
if (input.missingAnchors.counterparty) {
actions.push("Укажите контрагента/договор, чтобы проверить хвосты и разрывы на конкретной связке.");
actions.push("Укажите контрагента/договор, чтобы проверить хвосты и разрывы на конкретной связке.");
}
}
if (input.coverageReport.requirements_uncovered.length > 0) {
actions.push(`Закройте непокрытые требования: ${input.coverageReport.requirements_uncovered.join(", ")}.`);
actions.push(`Закройте непокрытые требования: ${input.coverageReport.requirements_uncovered.join(", ")}.`);
}
return uniqueStrings(actions, 6);
@@ -510,28 +607,28 @@ function buildProblemCentricClarifications(input: {
const unitTypes = new Set(input.units.map((item) => item.problem_unit_type));
if (input.missingAnchors.period) {
questions.push("Уточните период (например, 2020-06), в котором нужно проверить проблемный кластер.");
questions.push("Уточните период (например, 2020-06), в котором нужно проверить проблемный кластер.");
}
if (input.missingAnchors.account) {
questions.push("Уточните счет или связку счетов (например, 51/60), где вы ожидаете дефект.");
questions.push("Уточните счет или связку счетов (например, 51/60), где вы ожидаете дефект.");
}
if (input.missingAnchors.documentOrObject) {
questions.push("Укажите документ/объект, от которого нужно строить проверку цепочки.");
questions.push("Укажите документ/объект, от которого нужно строить проверку цепочки.");
}
if (input.missingAnchors.counterparty) {
questions.push("Укажите контрагента или договор, по которому проверить незакрытую экспозицию.");
questions.push("Укажите контрагента или договор, по которому проверить незакрытую экспозицию.");
}
if (unitTypes.has("broken_chain_segment")) {
questions.push("Уточните участок цепочки: выписка, платежный документ или проводка.");
questions.push("Уточните участок цепочки: выписка, платежный документ или проводка.");
}
if (unitTypes.has("period_risk_cluster")) {
questions.push("Уточните, какой этап закрытия периода критичен: начисление, закрытие счетов или НДС-блок.");
questions.push("Уточните, какой этап закрытия периода критичен: начисление, закрытие счетов или НДС-блок.");
}
if (unitTypes.has("unresolved_settlement_cluster")) {
questions.push("Уточните, интересуют хвосты поставщиков, покупателей или оба направления.");
questions.push("Уточните, интересуют хвосты поставщиков, покупателей или оба направления.");
}
if (input.coverageReport.clarification_needed_for.length > 0) {
questions.push(`Закройте уточнения для требований: ${input.coverageReport.clarification_needed_for.join(", ")}.`);
questions.push(`Закройте уточнения для требований: ${input.coverageReport.clarification_needed_for.join(", ")}.`);
}
return uniqueStrings(questions, 6);
@@ -644,10 +741,10 @@ function limitationReasonToText(code: EvidenceLimitationReasonCode): string {
function detectMissingAnchors(userMessage: string): MissingAnchors {
const lower = String(userMessage ?? "").toLowerCase();
const hasPeriod = /\b20\d{2}(?:[-./](?:0[1-9]|1[0-2]))?\b/.test(lower);
const hasAccount = /(?:\bсчет\b|\baccount\b|\bschet\b|\b\d{2}(?:\.\d{2})?\b)/i.test(lower);
const hasDocumentOrObject = /(?:документ|invoice|guid|object|obj|#\d+|\bid\b|\bref\b|dokument|doc)/i.test(lower);
const hasCounterparty = /(?:контрагент|supplier|buyer|customer|kontragent|postavsh|pokupatel)/i.test(lower);
const hasAnomalyType = /(?:аномал|risk|отклон|разрыв|mismatch|duplicate|tail|цепочк|anomali|hvost)/i.test(lower);
const hasAccount = /(?:\bсчет\b|\baccount\b|\bschet\b|\b\d{2}(?:\.\d{2})?\b)/i.test(lower);
const hasDocumentOrObject = /(?:документ|invoice|guid|object|obj|#\d+|\bid\b|\bref\b|dokument|doc)/i.test(lower);
const hasCounterparty = /(?:контрагент|supplier|buyer|customer|kontragent|postavsh|pokupatel)/i.test(lower);
const hasAnomalyType = /(?:аномал|risk|отклон|разрыв|mismatch|duplicate|tail|цепочк|anomali|hvost)/i.test(lower);
return {
period: !hasPeriod,
@@ -671,22 +768,22 @@ function buildClarificationQuestions(input: {
}
if (input.missingAnchors.period) {
questions.push("Уточните период проверки (например, 2020-06).");
questions.push("Уточните период проверки (например, 2020-06).");
}
if (input.missingAnchors.account) {
questions.push("Уточните счет или группу счетов (например, 19, 60, 62).");
questions.push("Уточните счет или группу счетов (например, 19, 60, 62).");
}
if (input.missingAnchors.documentOrObject) {
questions.push("Укажите документ/GUID/конкретный объект для трассировки.");
questions.push("Укажите документ/GUID/конкретный объект для трассировки.");
}
if (input.missingAnchors.counterparty) {
questions.push("Укажите контрагента или группу контрагентов.");
questions.push("Укажите контрагента или группу контрагентов.");
}
if (input.policySignals.broad_query_detected && input.missingAnchors.anomalyType) {
questions.push("Уточните тип отклонения: разрыв цепочки, неверный документ или аномальный риск.");
questions.push("Уточните тип отклонения: разрыв цепочки, неверный документ или аномальный риск.");
}
if (input.coverageReport.clarification_needed_for.length > 0) {
questions.push(`Закройте уточнения для требований: ${input.coverageReport.clarification_needed_for.join(", ")}.`);
questions.push(`Закройте уточнения для требований: ${input.coverageReport.clarification_needed_for.join(", ")}.`);
}
return uniqueStrings(questions, 6);
@@ -701,31 +798,31 @@ function buildRecommendedActions(input: {
}): string[] {
const actions: string[] = [];
if (input.mode === "focused_grounded") {
actions.push("Проверьте 1-2 ключевые записи в учетной базе и зафиксируйте итог в рабочем файле проверки.");
actions.push("Проверьте 1-2 ключевые записи в учетной базе и зафиксируйте итог в рабочем файле проверки.");
}
if (input.mode === "broad_partial") {
actions.push("Сузьте запрос до периода + счета или периода + документа и повторите проверку.");
actions.push("Сузьте запрос до периода + счета или периода + документа и повторите проверку.");
}
if (input.mode === "clarification_required") {
actions.push("Дайте недостающие якоря (период/счет/объект), иначе сильный factual вывод невозможен.");
actions.push("Дайте недостающие якоря (период/счет/объект), иначе сильный factual вывод невозможен.");
}
if (input.coverageReport.requirements_uncovered.length > 0) {
actions.push(`Закройте непокрытые требования: ${input.coverageReport.requirements_uncovered.join(", ")}.`);
actions.push(`Закройте непокрытые требования: ${input.coverageReport.requirements_uncovered.join(", ")}.`);
}
if (input.coverageReport.requirements_partially_covered.length > 0) {
actions.push(`Доуточните частично покрытые требования: ${input.coverageReport.requirements_partially_covered.join(", ")}.`);
actions.push(`Доуточните частично покрытые требования: ${input.coverageReport.requirements_partially_covered.join(", ")}.`);
}
if (input.policySignals.broad_query_detected && input.policySignals.narrowing_strength !== "strong") {
actions.push("Добавьте более узкий контекст: тип отклонения, группу документов и бизнес-участок.");
actions.push("Добавьте более узкий контекст: тип отклонения, группу документов и бизнес-участок.");
}
if (input.limitationReasonCodes.includes("snapshot_only")) {
actions.push("Сверьте критичные выводы с live source-of-record в 1C.");
actions.push("Сверьте критичные выводы с live source-of-record в 1C.");
}
if (input.limitationReasonCodes.includes("weak_source_mapping")) {
actions.push("Проверьте source mapping для связей document/register по указанным ref.");
actions.push("Проверьте source mapping для связей document/register по указанным ref.");
}
if (input.sourceRefs.length > 0) {
actions.push(`Начните проверку с ${input.sourceRefs.length} подтвержденных записей и сверьте их с первичными документами.`);
actions.push(`Начните проверку с ${input.sourceRefs.length} подтвержденных записей и сверьте их с первичными документами.`);
}
return uniqueStrings(actions, 6);
@@ -842,14 +939,14 @@ function buildPolicyDecision(input: {
}
function buildAnswerSummary(mode: PolicyMode): string {
if (mode === "focused_grounded") return "Сформирован прямой ответ на основе подтвержденной опоры.";
if (mode === "broad_partial") return "Вывод ограничен: есть частичная опора, но не полный coverage.";
if (mode === "clarification_required") return "Нужны уточнения: без сужения strong factual вывод ненадежен.";
if (mode === "out_of_scope") return "Запрос вне доступного учетного контура.";
if (mode === "route_mismatch") return "Результат маршрута не совпал с предметом вопроса.";
if (mode === "empty") return "В текущем срезе данных релевантные записи не обнаружены.";
if (mode === "no_grounded") return "Недостаточно опоры для обоснованного ответа.";
return "Не удалось собрать обоснованный ответ по текущему запросу.";
if (mode === "focused_grounded") return "Сформирован прямой ответ на основе подтвержденной опоры.";
if (mode === "broad_partial") return "Вывод ограничен: есть частичная опора, но не полный coverage.";
if (mode === "clarification_required") return "Нужны уточнения: без сужения strong factual вывод ненадежен.";
if (mode === "out_of_scope") return "Запрос вне доступного учетного контура.";
if (mode === "route_mismatch") return "Результат маршрута не совпал с предметом вопроса.";
if (mode === "empty") return "В текущем срезе данных релевантные записи не обнаружены.";
if (mode === "no_grounded") return "Недостаточно опоры для обоснованного ответа.";
return "Не удалось собрать обоснованный ответ по текущему запросу.";
}
function buildDirectAnswer(input: {
@@ -859,33 +956,33 @@ function buildDirectAnswer(input: {
}): string {
const topFact = firstMeaningfulFact(input.retrievalResults);
if (input.mode === "focused_grounded") {
return topFact ?? "Подтвержденный результат получен; можно продолжать предметную проверку без деградации.";
return topFact ?? "Подтвержденный результат получен; можно продолжать предметную проверку без деградации.";
}
if (input.mode === "broad_partial") {
if (topFact) {
return `Доступен ограниченный подтвержденный фрагмент: ${topFact}`;
return `Доступен ограниченный подтвержденный фрагмент: ${topFact}`;
}
return "Есть только ограниченная опора; вывод дан в частичном режиме без ложной точности.";
return "Есть только ограниченная опора; вывод дан в частичном режиме без ложной точности.";
}
if (input.mode === "clarification_required") {
return "Текущий запрос слишком широкий или недоопределен; надежный factual вывод пока невозможен.";
return "Текущий запрос слишком широкий или недоопределен; надежный factual вывод пока невозможен.";
}
if (input.mode === "out_of_scope") {
return "Могу отвечать только в пределах данных доступного учетного контура.";
return "Могу отвечать только в пределах данных доступного учетного контура.";
}
if (input.mode === "route_mismatch") {
return "Предмет результата не совпал с предметом вопроса; требуется уточнение фокуса.";
return "Предмет результата не совпал с предметом вопроса; требуется уточнение фокуса.";
}
if (input.mode === "empty") {
return "В текущем срезе данных проблемные записи по заданному условию не найдены.";
return "В текущем срезе данных проблемные записи по заданному условию не найдены.";
}
if (input.mode === "no_grounded") {
return "Недостаточно подтвержденной опоры для ответа в требуемой точности.";
return "Недостаточно подтвержденной опоры для ответа в требуемой точности.";
}
if (input.policySignals.minimum_evidence_failed) {
return "Маршрут отработал, но минимальная evidence-опора не пройдена.";
return "Маршрут отработал, но минимальная evidence-опора не пройдена.";
}
return "Не удалось сформировать обоснованный ответ; нужно уточнение запроса.";
return "Не удалось сформировать обоснованный ответ; нужно уточнение запроса.";
}
function buildProblemCentricAnswerSummary(input: {
@@ -896,20 +993,20 @@ function buildProblemCentricAnswerSummary(input: {
}): string {
if (input.lifecycleEnriched && input.summary?.lifecycle_enriched_units && input.summary.lifecycle_enriched_units > 0) {
if (input.mode === "clarification_required") {
return "Выявлены lifecycle-дефекты, но для надежного вывода требуется уточнение предметных якорей.";
return "Выявлены lifecycle-дефекты, но для надежного вывода требуется уточнение предметных якорей.";
}
return `Сформирован lifecycle-aware problem срез: выделено ${input.summary.lifecycle_enriched_units} lifecycle-узлов с приоритетом по дефектам перехода.`;
return `Сформирован lifecycle-aware problem срез: выделено ${input.summary.lifecycle_enriched_units} lifecycle-узлов с приоритетом по дефектам перехода.`;
}
if (input.mode === "clarification_required") {
return "Выявлены проблемные кластеры, но для надежного вывода требуется предметное уточнение фокуса.";
return "Выявлены проблемные кластеры, но для надежного вывода требуется предметное уточнение фокуса.";
}
if (input.weakUnits) {
return "Сформирован problem-centric срез с ограниченной опорой; вывод предварительный и требует до-проверки.";
return "Сформирован problem-centric срез с ограниченной опорой; вывод предварительный и требует до-проверки.";
}
if (input.summary?.units_total && input.summary.units_total > 1) {
return `Сформирован problem-centric срез: выделено ${input.summary.units_total} проблемных кластера с приоритетами.`;
return `Сформирован problem-centric срез: выделено ${input.summary.units_total} проблемных кластера с приоритетами.`;
}
return "Сформирован problem-centric срез: выделен ключевой проблемный кластер и затронутый контур.";
return "Сформирован problem-centric срез: выделен ключевой проблемный кластер и затронутый контур.";
}
function buildProblemCentricDirectAnswer(input: {
@@ -920,19 +1017,24 @@ function buildProblemCentricDirectAnswer(input: {
}): string {
const lead =
input.mode === "clarification_required"
? "Обнаружены проблемные зоны, но без уточнения якорей сильный factual-вывод преждевременен."
? "Обнаружены проблемные зоны, но без уточнения якорей сильный factual-вывод преждевременен."
: input.weakUnits
? "Выделены проблемные зоны с ограниченной надежностью; вывод дан в ограниченном режиме."
? "Выделены проблемные зоны с ограниченной надежностью; вывод дан в ограниченном режиме."
: input.lifecycleAnswerEnabled && hasLifecycleResolution(input.units)
? "Выделены lifecycle-проблемы: определены текущие/ожидаемые стадии и тип нарушения перехода."
: "Выделены ключевые проблемные зоны и их влияние на учетный контур.";
? "Выделены lifecycle-проблемы: определены текущие/ожидаемые стадии и тип нарушения перехода."
: "Выделены ключевые проблемные зоны и их влияние на учетный контур.";
const unitLines = input.units.map((unit) => {
const scope = formatAffectedScope(unit);
const lifecycleScope = input.lifecycleAnswerEnabled ? formatLifecycleScope(unit) : null;
const lifecycleInterpretation = input.lifecycleAnswerEnabled ? unit.business_lifecycle_interpretation : null;
const lifecycleInterpretation =
input.lifecycleAnswerEnabled && unit.business_lifecycle_interpretation
? sanitizeUserText(unit.business_lifecycle_interpretation)
: null;
const title = sanitizeUserText(unit.title) ?? "Problem cluster detected";
const defect = sanitizeUserText(unit.business_defect_class) ?? "detected_issue";
const segments = [
`${unit.title}: ${unit.business_defect_class}`,
`${title}: ${defect}`,
scope,
lifecycleScope,
lifecycleInterpretation,
@@ -944,10 +1046,10 @@ function buildProblemCentricDirectAnswer(input: {
});
if (unitLines.length === 0) {
return `${lead}\nПроблемные кластеры не удалось детализировать в текущем срезе.`;
return `${lead}\nПроблемные кластеры не удалось детализировать в текущем срезе.`;
}
return [lead, "Проблемные кластеры:", ...unitLines].join("\n");
return [lead, "Проблемные кластеры:", ...unitLines].join("\n");
}
function buildProblemCentricAnswerStructure(input: {
@@ -1358,21 +1460,23 @@ function composeExplainableAnswer(input: ComposeAnswerInput, scopeLabel: "full"
const lead =
scopeLabel === "full"
? "Итог: запрос обработан по предмету, найденные объекты подтверждены данными контура."
: "Итог: запрос обработан частично, ниже подтвержденная часть и ограничения.";
? "Итог: запрос обработан по предмету, найденные объекты подтверждены данными контура."
: "Итог: запрос обработан частично, ниже подтвержденная часть и ограничения.";
return [
lead,
facts.length > 0 ? "Подтвержденные результаты:\n" + formatList(facts) : "",
whyIncluded.length > 0 ? "Почему это попало в ответ:\n" + formatList(whyIncluded) : "",
selectionReasons.length > 0 ? "Основание отбора:\n" + formatList(selectionReasons) : "",
riskFactors.length > 0 ? "Подтверждающие признаки:\n" + formatList(riskFactors) : "",
interpretation.length > 0 ? "Практический смысл:\n" + formatList(interpretation) : "",
limitations.length > 0 ? "Ограничения:\n" + formatList(limitations) : "",
nextSteps.length > 0 ? "Что проверить дальше:\n" + formatList(nextSteps) : ""
]
.filter(Boolean)
.join("\n\n");
return sanitizeUserFacingReply(
[
lead,
facts.length > 0 ? "Подтвержденные результаты:\n" + formatList(facts) : "",
whyIncluded.length > 0 ? "Почему это попало в ответ:\n" + formatList(whyIncluded) : "",
selectionReasons.length > 0 ? "Основание отбора:\n" + formatList(selectionReasons) : "",
riskFactors.length > 0 ? "Подтверждающие признаки:\n" + formatList(riskFactors) : "",
interpretation.length > 0 ? "Практический смысл:\n" + formatList(interpretation) : "",
limitations.length > 0 ? "Ограничения:\n" + formatList(limitations) : "",
nextSteps.length > 0 ? "Что проверить дальше:\n" + formatList(nextSteps) : ""
]
.filter(Boolean)
.join("\n\n")
);
}
export function composeAssistantAnswer(input: ComposeAnswerInput): ComposeAnswerOutput {
@@ -1385,6 +1489,8 @@ export function composeAssistantAnswer(input: ComposeAnswerInput): ComposeAnswer
const partialResults = input.retrievalResults.filter((item) => item.status === "partial");
const emptyResults = input.retrievalResults.filter((item) => item.status === "empty");
const errorResults = input.retrievalResults.filter((item) => item.status === "error");
const legacyEvidenceItems = flattenEvidence(input.retrievalResults);
const legacyLimitationReasonCodes = collectLimitationReasonCodes(legacyEvidenceItems);
const hasBroadMinimumEvidenceSignal = input.retrievalResults.some(
(item) => summaryBoolean(item, "broad_guard_applied") && summaryBoolean(item, "minimum_evidence_failed")
);
@@ -1398,7 +1504,7 @@ export function composeAssistantAnswer(input: ComposeAnswerInput): ComposeAnswer
if (fallbackType === "out_of_scope" && input.coverageReport.requirements_covered === 0) {
return {
assistant_reply:
"Я могу отвечать только по данным вашей учетной базы. Этот запрос выходит за рамки доступного контура.",
"Я могу отвечать только по данным вашей учетной базы. Этот запрос выходит за рамки доступного контура.",
fallback_type: "out_of_scope",
reply_type: "out_of_scope"
};
@@ -1407,8 +1513,8 @@ export function composeAssistantAnswer(input: ComposeAnswerInput): ComposeAnswer
if (input.groundingCheck.status === "route_mismatch_blocked") {
return {
assistant_reply: [
"Не отправляю финальный ответ, потому что предмет результата не совпал с предметом вопроса.",
"Уточните формулировку (например, нужный счет/участок учета), и я выполню повторный проход."
"Не отправляю финальный ответ, потому что предмет результата не совпал с предметом вопроса.",
"Уточните формулировку (например, нужный счет/участок учета), и я выполню повторный проход."
].join("\n\n"),
fallback_type: "partial",
reply_type: "route_mismatch_blocked"
@@ -1418,7 +1524,7 @@ export function composeAssistantAnswer(input: ComposeAnswerInput): ComposeAnswer
if (input.groundingCheck.status === "no_grounded_answer" && okResults.length === 0 && !hasBroadMinimumEvidenceSignal) {
return {
assistant_reply:
"Пока не удалось собрать предметно подтвержденный ответ по вашему вопросу. Нужны дополнительные уточнения по периоду или объекту проверки.",
"Пока не удалось собрать предметно подтвержденный ответ по вашему вопросу. Нужны дополнительные уточнения по периоду или объекту проверки.",
fallback_type: fallbackType,
reply_type: "no_grounded_answer"
};
@@ -1427,7 +1533,7 @@ export function composeAssistantAnswer(input: ComposeAnswerInput): ComposeAnswer
if (hasBroadClarificationSignal && okResults.length === 0 && partialResults.length === 0) {
return {
assistant_reply:
"Запрос слишком широкий для надежного вывода по текущей опоре. Уточните период, участок учета или объект проверки, после чего я дам предметный результат.",
"Запрос слишком широкий для надежного вывода по текущей опоре. Уточните период, участок учета или объект проверки, после чего я дам предметный результат.",
fallback_type: "clarification",
reply_type: "clarification_required"
};
@@ -1435,7 +1541,7 @@ export function composeAssistantAnswer(input: ComposeAnswerInput): ComposeAnswer
if (fallbackType === "clarification" && okResults.length === 0 && partialResults.length === 0) {
return {
assistant_reply: "Уточните, пожалуйста, период, счет, документ или контрагента, чтобы закрыть все части вопроса корректно.",
assistant_reply: "Уточните, пожалуйста, период, счет, документ или контрагента, чтобы закрыть все части вопроса корректно.",
fallback_type: "clarification",
reply_type: "clarification_required"
};
@@ -1443,7 +1549,7 @@ export function composeAssistantAnswer(input: ComposeAnswerInput): ComposeAnswer
if (errorResults.length > 0 && okResults.length === 0 && partialResults.length === 0) {
return {
assistant_reply: "Не удалось получить данные из контура. Попробуйте повторить запрос или уточнить формулировку.",
assistant_reply: "Не удалось получить данные из контура. Попробуйте повторить запрос или уточнить формулировку.",
fallback_type: fallbackType,
reply_type: "backend_error"
};
@@ -1459,7 +1565,7 @@ export function composeAssistantAnswer(input: ComposeAnswerInput): ComposeAnswer
if (okResults.length === 0 && partialResults.length === 0 && emptyResults.length > 0) {
return {
assistant_reply: "По заданному условию в текущем срезе данных явных проблемных записей не найдено.",
assistant_reply: "По заданному условию в текущем срезе данных явных проблемных записей не найдено.",
fallback_type: fallbackType,
reply_type: "empty_but_valid"
};
@@ -1471,7 +1577,9 @@ export function composeAssistantAnswer(input: ComposeAnswerInput): ComposeAnswer
input.coverageReport.clarification_needed_for.length > 0 ||
input.coverageReport.out_of_scope_requirements.length > 0 ||
input.groundingCheck.status === "partial" ||
errorResults.length > 0;
errorResults.length > 0 ||
legacyLimitationReasonCodes.includes("weak_source_mapping") ||
legacyLimitationReasonCodes.includes("missing_mechanism");
if (okResults.length > 0 && hasPartialCoverage) {
return {
@@ -1490,9 +1598,10 @@ export function composeAssistantAnswer(input: ComposeAnswerInput): ComposeAnswer
}
return {
assistant_reply: "По текущему запросу не удалось построить обоснованный ответ. Уточните формулировку и попробуйте снова.",
assistant_reply: "По текущему запросу не удалось построить обоснованный ответ. Уточните формулировку и попробуйте снова.",
fallback_type: "unknown",
reply_type: "backend_error"
};
}
@@ -1125,9 +1125,9 @@ export class AssistantDataLayer {
} else if (route === "store_feature_risk") {
result = this.executeRisk(fragmentText, data);
} else if (route === "batch_refresh_then_store") {
result = this.executeBatch(data);
result = this.executeBatch(fragmentText, data);
} else if (route === "store_canonical") {
result = this.executeCanonical(data);
result = this.executeCanonical(fragmentText, data);
} else if (route === "live_mcp_drilldown") {
result = this.executeDrilldown(fragmentText, data);
}
@@ -1437,7 +1437,9 @@ export class AssistantDataLayer {
};
}
private executeRisk(_fragmentText: string, data: DatasetBundle): RawRetrievalResult {
private executeRisk(fragmentText: string, data: DatasetBundle): RawRetrievalResult {
const semanticProfile = buildSemanticRetrievalProfile(fragmentText);
const profileRiskFactors = semanticProfile.anomaly_patterns;
const records = [...data.problemCases, ...data.ndsRegisters];
const scored = records
.map((record) => {
@@ -1491,12 +1493,15 @@ export class AssistantDataLayer {
items: [],
summary: {
checked_records: records.length,
risky_records: 0
risky_records: 0,
query_subject: semanticProfile.query_subject,
semantic_profile: semanticProfile,
ranking_basis: semanticProfile.ranking_basis
},
evidence: [],
why_included: [],
selection_reason: ["Риск-оценка выполнялась по техническим признакам, но записи выше порога не найдены."],
risk_factors: [],
risk_factors: profileRiskFactors,
business_interpretation: ["По текущему срезу явные риск-признаки не обнаружены."],
confidence: "medium",
limitations: ["Оценка основана на snapshot-данных и эвристическом risk score."],
@@ -1505,6 +1510,13 @@ export class AssistantDataLayer {
}
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",
@@ -1512,7 +1524,10 @@ export class AssistantDataLayer {
summary: {
checked_records: records.length,
risky_records: items.length,
average_risk_score: Number(averageScore.toFixed(2))
average_risk_score: Number(averageScore.toFixed(2)),
query_subject: semanticProfile.query_subject,
semantic_profile: semanticProfile,
ranking_basis: semanticProfile.ranking_basis
},
evidence: items.slice(0, 10).map((item) => ({
source_entity: item.source_entity,
@@ -1521,14 +1536,10 @@ export class AssistantDataLayer {
})),
why_included: ["В ответ включены записи с risk_score >= 2."],
selection_reason: [
"score растет при unknown links, zero GUID, навигационных ссылках и отсутствии явного контрагента."
],
risk_factors: [
"unknown_link_count",
"zero_guid_values",
"navigation_links",
"missing_counterparty_link"
"score растет при unknown links, zero GUID, навигационных ссылках и отсутствии явного контрагента.",
`Semantic profile subject: ${semanticProfile.query_subject}.`
],
risk_factors: normalizedRiskFactors,
business_interpretation: ["Эти записи требуют первичной бухгалтерской проверки как потенциальные аномалии."],
confidence: "high",
limitations: ["Риск-факторы определяются эвристикой, а не полным набором бизнес-правил 1С."],
@@ -1536,7 +1547,8 @@ export class AssistantDataLayer {
};
}
private executeBatch(data: DatasetBundle): RawRetrievalResult {
private executeBatch(fragmentText: string, data: DatasetBundle): RawRetrievalResult {
const semanticProfile = buildSemanticRetrievalProfile(fragmentText);
const source = [...data.problemCases, ...data.keyFields, ...data.docs];
const byEntity = new Map<string, number>();
for (const record of source) {
@@ -1558,7 +1570,10 @@ export class AssistantDataLayer {
items,
summary: {
checked_records: source.length,
ranked_entities: items.length
ranked_entities: items.length,
query_subject: semanticProfile.query_subject,
semantic_profile: semanticProfile,
ranking_basis: semanticProfile.ranking_basis
},
evidence: items.slice(0, 5).map((item) => ({
entity: item.entity,
@@ -1566,9 +1581,9 @@ export class AssistantDataLayer {
})),
why_included: items.length > 0 ? ["Показаны сущности с максимальным количеством записей."] : [],
selection_reason: ["Ранжирование выполнено по records_count по убыванию."],
risk_factors: ["Высокий объем записей по сущности повышает приоритет проверки."],
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: ["Ранжирование по объему не всегда эквивалентно бизнес-риску."],
@@ -1576,8 +1591,11 @@ export class AssistantDataLayer {
};
}
private executeCanonical(data: DatasetBundle): RawRetrievalResult {
const items = data.docs
private executeCanonical(fragmentText: string, data: DatasetBundle): RawRetrievalResult {
const semanticProfile = buildSemanticRetrievalProfile(fragmentText);
const useVatSource = semanticProfile.domain_scope.includes("vat") || semanticProfile.domain_scope.includes("taxes");
const sourceRecords = useVatSource ? [...data.ndsRegisters, ...data.keyFields] : data.docs;
const items = sourceRecords
.map((record) => {
const period = extractDate(record);
return {
@@ -1599,8 +1617,11 @@ export class AssistantDataLayer {
result_type: "list",
items,
summary: {
checked_records: data.docs.length,
returned_records: items.length
checked_records: sourceRecords.length,
returned_records: items.length,
query_subject: semanticProfile.query_subject,
semantic_profile: semanticProfile,
ranking_basis: semanticProfile.ranking_basis
},
evidence: items.slice(0, 6).map((item) => ({
source_entity: item.source_entity,
@@ -1608,8 +1629,11 @@ export class AssistantDataLayer {
period: item.period
})),
why_included: items.length > 0 ? ["Показаны последние по дате записи канонического документного слоя."] : [],
selection_reason: ["Отбор по максимальной дате документа в пределах snapshot."],
risk_factors: [],
selection_reason: [
"Отбор по максимальной дате документа в пределах snapshot.",
`Semantic profile subject: ${semanticProfile.query_subject}.`
],
risk_factors: semanticProfile.anomaly_patterns,
business_interpretation: ["Слой отражает базовый factual-срез документов для оперативной сверки."],
confidence: "high",
limitations: ["Это read-only snapshot, а не онлайн-состояние 1С."],
@@ -1,4 +1,4 @@
import type { CandidateEvidenceItem, ProblemConfidence, ProblemUnit, ProblemUnitType } from "../types/stage2ProblemUnits";
import type { CandidateEvidenceItem, ProblemConfidence, ProblemUnit, ProblemUnitType } from "../types/stage2ProblemUnits";
import {
LIFECYCLE_MODEL_SCHEMA_VERSION,
STAGE3_LIFECYCLE_DOMAINS,
@@ -47,13 +47,99 @@ function hasToken(values: string[], pattern: RegExp): boolean {
return values.some((value) => pattern.test(value));
}
function defaultExpectedState(domain: LifecycleDomain): string {
if (domain === "bank_settlement") return "settlement_closed";
if (domain === "customer_settlement") return "receivable_closed";
if (domain === "deferred_expense") return "fully_written_off";
if (domain === "fixed_asset") return "depreciation_active";
if (domain === "vat_flow") return "vat_deducted";
return "close_completed";
function normalizeStateToken(value: string): string {
return value.trim().toLowerCase();
}
function resolveStateCode(model: LifecycleDomainModel, stateCode: string | null | undefined): string | null {
if (!stateCode || typeof stateCode !== "string") {
return null;
}
const normalized = normalizeStateToken(stateCode);
const matched = model.states.find((state) => normalizeStateToken(state.state_code) === normalized);
return matched?.state_code ?? null;
}
function defaultInitialState(model: LifecycleDomainModel): string {
const initial = model.states.find((state) => state.state_class === "initial");
if (initial) {
return initial.state_code;
}
return model.states[0]?.state_code ?? "unknown_state";
}
function defaultExpectedState(model: LifecycleDomainModel): string {
const terminal = model.states.find((state) => state.is_terminal || state.state_class === "terminal");
if (terminal) {
return terminal.state_code;
}
const active = model.states.find((state) => state.state_class === "active");
if (active) {
return active.state_code;
}
return defaultInitialState(model);
}
function expectedTransitionAdjacency(model: LifecycleDomainModel): Map<string, string[]> {
const graph = new Map<string, string[]>();
for (const transition of model.transitions) {
if (transition.transition_type !== "expected") {
continue;
}
const from = transition.from_state;
const to = transition.to_state;
const current = graph.get(from) ?? [];
if (!current.includes(to)) {
current.push(to);
}
graph.set(from, current);
}
return graph;
}
function shortestExpectedPath(model: LifecycleDomainModel, fromState: string, toState: string): string[] | null {
if (fromState === toState) {
return [fromState];
}
const graph = expectedTransitionAdjacency(model);
const queue: string[][] = [[fromState]];
const visited = new Set<string>([fromState]);
while (queue.length > 0) {
const path = queue.shift();
if (!path) {
continue;
}
const tail = path[path.length - 1];
const nextStates = graph.get(tail) ?? [];
for (const nextState of nextStates) {
if (visited.has(nextState)) {
continue;
}
const nextPath = [...path, nextState];
if (nextState === toState) {
return nextPath;
}
visited.add(nextState);
queue.push(nextPath);
}
}
return null;
}
function transitionEdgeLabel(fromState: string, toState: string): string {
return `${fromState}->${toState}`;
}
function resolvePreviousStates(model: LifecycleDomainModel, currentState: string): string[] {
const initialState = defaultInitialState(model);
if (initialState === currentState) {
return [];
}
const path = shortestExpectedPath(model, initialState, currentState);
if (!path || path.length <= 1) {
return [];
}
return path.slice(0, -1);
}
const LIFECYCLE_DOMAIN_MODELS: Record<LifecycleDomain, LifecycleDomainModel> = {
@@ -64,53 +150,53 @@ const LIFECYCLE_DOMAIN_MODELS: Record<LifecycleDomain, LifecycleDomainModel> = {
states: [
{
state_code: "initiated_payment",
state_label: "Платеж инициирован",
state_label: "Платеж инициирован",
state_class: "initial",
entry_conditions: ["payment_order_created"],
exit_conditions: ["bank_recorded"],
is_terminal: false,
is_problematic: false,
business_meaning: "Есть инициирование платежа."
business_meaning: "Есть инициирование платежа."
},
{
state_code: "bank_recorded",
state_label: "Платеж отражен банком",
state_label: "Платеж отражен банком",
state_class: "active",
entry_conditions: ["bank_statement_recorded"],
exit_conditions: ["settlement_linked"],
is_terminal: false,
is_problematic: false,
business_meaning: "Движение денег зафиксировано, ожидается расчетное закрытие."
business_meaning: "Движение денег зафиксировано, ожидается расчетное закрытие."
},
{
state_code: "settlement_closed",
state_label: "Расчет закрыт",
state_label: "Расчет закрыт",
state_class: "terminal",
entry_conditions: ["payment_to_settlement_linked"],
exit_conditions: [],
is_terminal: true,
is_problematic: false,
business_meaning: "Платеж доведен до расчетного результата."
business_meaning: "Платеж доведен до расчетного результата."
},
{
state_code: "stale_unlinked_payment",
state_label: "Платеж завис без закрытия",
state_label: "Платеж завис без закрытия",
state_class: "problematic",
entry_conditions: ["bank_recorded", "missing_link"],
exit_conditions: ["settlement_closed"],
is_terminal: false,
is_problematic: true,
business_meaning: "Платеж отражен, но ожидаемая связь по расчету не завершена."
business_meaning: "Платеж отражен, но ожидаемая связь по расчету не завершена."
},
{
state_code: "misclosed_payment",
state_label: "Платеж закрыт некорректно",
state_label: "Платеж закрыт некорректно",
state_class: "problematic",
entry_conditions: ["wrong_document_type_or_posting_mismatch"],
exit_conditions: ["settlement_closed"],
is_terminal: false,
is_problematic: true,
business_meaning: "Формальное закрытие есть, но путь закрытия неверный."
business_meaning: "Формальное закрытие есть, но путь закрытия неверный."
}
],
transitions: [
@@ -121,7 +207,7 @@ const LIFECYCLE_DOMAIN_MODELS: Record<LifecycleDomain, LifecycleDomainModel> = {
required_evidence: ["bank_statement_recorded"],
optional_evidence: ["payment_order"],
forbidden_conditions: [],
business_meaning: "Платеж должен появиться во выписке."
business_meaning: "Платеж должен появиться во выписке."
},
{
from_state: "bank_recorded",
@@ -130,7 +216,7 @@ const LIFECYCLE_DOMAIN_MODELS: Record<LifecycleDomain, LifecycleDomainModel> = {
required_evidence: ["payment_to_settlement_link"],
optional_evidence: ["document_to_posting"],
forbidden_conditions: ["wrong_document_type"],
business_meaning: "После выписки должен закрываться расчет."
business_meaning: "После выписки должен закрываться расчет."
}
],
defects: []
@@ -142,43 +228,43 @@ const LIFECYCLE_DOMAIN_MODELS: Record<LifecycleDomain, LifecycleDomainModel> = {
states: [
{
state_code: "invoice_issued",
state_label: "Реализация отражена",
state_label: "Реализация отражена",
state_class: "initial",
entry_conditions: ["realization_document_exists"],
exit_conditions: ["payment_recorded"],
is_terminal: false,
is_problematic: false,
business_meaning: "Возникла дебиторская позиция."
business_meaning: "Возникла дебиторская позиция."
},
{
state_code: "payment_recorded",
state_label: "Оплата отражена",
state_label: "Оплата отражена",
state_class: "active",
entry_conditions: ["payment_document_exists"],
exit_conditions: ["receivable_closed"],
is_terminal: false,
is_problematic: false,
business_meaning: "Оплата есть, ожидается корректное закрытие."
business_meaning: "Оплата есть, ожидается корректное закрытие."
},
{
state_code: "receivable_closed",
state_label: "Дебиторка закрыта",
state_label: "Дебиторка закрыта",
state_class: "terminal",
entry_conditions: ["closing_document_linked"],
exit_conditions: [],
is_terminal: true,
is_problematic: false,
business_meaning: "Дебиторская позиция закрыта корректно."
business_meaning: "Дебиторская позиция закрыта корректно."
},
{
state_code: "stale_receivable",
state_label: "Дебиторка зависла",
state_label: "Дебиторка зависла",
state_class: "problematic",
entry_conditions: ["unresolved_settlement"],
exit_conditions: ["receivable_closed"],
is_terminal: false,
is_problematic: true,
business_meaning: "Позиция остается незавершенной дольше ожидаемого."
business_meaning: "Позиция остается незавершенной дольше ожидаемого."
}
],
transitions: [
@@ -189,7 +275,7 @@ const LIFECYCLE_DOMAIN_MODELS: Record<LifecycleDomain, LifecycleDomainModel> = {
required_evidence: ["payment_document_exists"],
optional_evidence: [],
forbidden_conditions: [],
business_meaning: "После реализации ожидается оплата/зачет."
business_meaning: "После реализации ожидается оплата/зачет."
},
{
from_state: "payment_recorded",
@@ -198,7 +284,7 @@ const LIFECYCLE_DOMAIN_MODELS: Record<LifecycleDomain, LifecycleDomainModel> = {
required_evidence: ["closing_document_linked"],
optional_evidence: ["register_movement_exists"],
forbidden_conditions: ["cross_branch_inconsistency"],
business_meaning: "Оплата должна завершаться корректным закрытием расчета."
business_meaning: "Оплата должна завершаться корректным закрытием расчета."
}
],
defects: []
@@ -210,43 +296,43 @@ const LIFECYCLE_DOMAIN_MODELS: Record<LifecycleDomain, LifecycleDomainModel> = {
states: [
{
state_code: "recognized",
state_label: "РБП признан",
state_label: "РБП признан",
state_class: "initial",
entry_conditions: ["deferred_expense_created"],
exit_conditions: ["writeoff_started"],
is_terminal: false,
is_problematic: false,
business_meaning: "РБП поставлен на учет."
business_meaning: "РБП поставлен на учет."
},
{
state_code: "partially_written_off",
state_label: "Частичное списание",
state_label: "Частичное списание",
state_class: "active",
entry_conditions: ["partial_writeoff_exists"],
exit_conditions: ["fully_written_off"],
is_terminal: false,
is_problematic: false,
business_meaning: "Списание идет по графику."
business_meaning: "Списание идет по графику."
},
{
state_code: "fully_written_off",
state_label: "РБП полностью списан",
state_label: "РБП полностью списан",
state_class: "terminal",
entry_conditions: ["full_writeoff_exists"],
exit_conditions: [],
is_terminal: true,
is_problematic: false,
business_meaning: "РБП завершил lifecycle."
business_meaning: "РБП завершил lifecycle."
},
{
state_code: "overdue_writeoff",
state_label: "Просроченное списание",
state_label: "Просроченное списание",
state_class: "problematic",
entry_conditions: ["period_boundary", "missing_link"],
exit_conditions: ["fully_written_off"],
is_terminal: false,
is_problematic: true,
business_meaning: "РБП живет дольше допустимого окна."
business_meaning: "РБП живет дольше допустимого окна."
}
],
transitions: [],
@@ -259,53 +345,53 @@ const LIFECYCLE_DOMAIN_MODELS: Record<LifecycleDomain, LifecycleDomainModel> = {
states: [
{
state_code: "capitalized",
state_label: "Капвложения отражены",
state_label: "Капвложения отражены",
state_class: "initial",
entry_conditions: ["capitalization_document_exists"],
exit_conditions: ["accepted_for_accounting"],
is_terminal: false,
is_problematic: false,
business_meaning: "Объект зафиксирован как вложение."
business_meaning: "Объект зафиксирован как вложение."
},
{
state_code: "accepted_for_accounting",
state_label: "Принят к учету",
state_label: "Принят к учету",
state_class: "active",
entry_conditions: ["acceptance_document_exists"],
exit_conditions: ["depreciation_active"],
is_terminal: false,
is_problematic: false,
business_meaning: "Объект переведен в основной контур учета."
business_meaning: "Объект переведен в основной контур учета."
},
{
state_code: "depreciation_active",
state_label: "Амортизация активна",
state_label: "Амортизация активна",
state_class: "active",
entry_conditions: ["depreciation_register_movement"],
exit_conditions: ["disposed"],
is_terminal: false,
is_problematic: false,
business_meaning: "Жизненный цикл ОС идет штатно."
business_meaning: "Жизненный цикл ОС идет штатно."
},
{
state_code: "contradictory_asset_state",
state_label: "Противоречивый статус ОС",
state_label: "Противоречивый статус ОС",
state_class: "problematic",
entry_conditions: ["posting_mismatch_or_wrong_path"],
exit_conditions: ["depreciation_active"],
is_terminal: false,
is_problematic: true,
business_meaning: "Статус ОС формально есть, но смыслово противоречив."
business_meaning: "Статус ОС формально есть, но смыслово противоречив."
},
{
state_code: "disposed",
state_label: "Выбыл",
state_label: "Выбыл",
state_class: "terminal",
entry_conditions: ["disposal_document_exists"],
exit_conditions: [],
is_terminal: true,
is_problematic: false,
business_meaning: "Жизненный цикл ОС завершен."
business_meaning: "Жизненный цикл ОС завершен."
}
],
transitions: [],
@@ -318,43 +404,43 @@ const LIFECYCLE_DOMAIN_MODELS: Record<LifecycleDomain, LifecycleDomainModel> = {
states: [
{
state_code: "vat_registered",
state_label: "НДС отражен документно",
state_label: "НДС отражен документно",
state_class: "initial",
entry_conditions: ["invoice_registered"],
exit_conditions: ["vat_reflected"],
is_terminal: false,
is_problematic: false,
business_meaning: "Сформирован первичный документный слой НДС."
business_meaning: "Сформирован первичный документный слой НДС."
},
{
state_code: "vat_reflected",
state_label: "НДС отражен в учете",
state_label: "НДС отражен в учете",
state_class: "active",
entry_conditions: ["vat_register_movement"],
exit_conditions: ["vat_deducted"],
is_terminal: false,
is_problematic: false,
business_meaning: "НДС проходит штатную стадию отражения."
business_meaning: "НДС проходит штатную стадию отражения."
},
{
state_code: "vat_deducted",
state_label: "НДС принят к вычету",
state_label: "НДС принят к вычету",
state_class: "terminal",
entry_conditions: ["deduction_confirmed"],
exit_conditions: [],
is_terminal: true,
is_problematic: false,
business_meaning: "НДС-цепочка завершена корректно."
business_meaning: "НДС-цепочка завершена корректно."
},
{
state_code: "vat_conflict",
state_label: "Конфликт НДС-цепочки",
state_label: "Конфликт НДС-цепочки",
state_class: "problematic",
entry_conditions: ["cross_branch_inconsistency"],
exit_conditions: ["vat_reflected"],
is_terminal: false,
is_problematic: true,
business_meaning: "Бухгалтерская и налоговая ветки расходятся."
business_meaning: "Бухгалтерская и налоговая ветки расходятся."
}
],
transitions: [],
@@ -367,53 +453,53 @@ const LIFECYCLE_DOMAIN_MODELS: Record<LifecycleDomain, LifecycleDomainModel> = {
states: [
{
state_code: "preclose_checks",
state_label: "Предзакрытие",
state_label: "Предзакрытие",
state_class: "active",
entry_conditions: ["period_scope_detected"],
exit_conditions: ["close_ready"],
is_terminal: false,
is_problematic: false,
business_meaning: "Идет проверка готовности периода."
business_meaning: "Идет проверка готовности периода."
},
{
state_code: "close_ready",
state_label: "Готов к закрытию",
state_label: "Готов к закрытию",
state_class: "active",
entry_conditions: ["no_blockers_detected"],
exit_conditions: ["close_completed"],
is_terminal: false,
is_problematic: false,
business_meaning: "Период может быть закрыт."
business_meaning: "Период может быть закрыт."
},
{
state_code: "close_completed",
state_label: "Закрытие завершено",
state_label: "Закрытие завершено",
state_class: "terminal",
entry_conditions: ["close_operation_done"],
exit_conditions: [],
is_terminal: true,
is_problematic: false,
business_meaning: "Период закрыт."
business_meaning: "Период закрыт."
},
{
state_code: "close_blocked",
state_label: "Закрытие заблокировано",
state_label: "Закрытие заблокировано",
state_class: "problematic",
entry_conditions: ["period_close_risk_or_stale_state"],
exit_conditions: ["close_ready"],
is_terminal: false,
is_problematic: true,
business_meaning: "Есть lifecycle-дефекты, влияющие на закрытие."
business_meaning: "Есть lifecycle-дефекты, влияющие на закрытие."
},
{
state_code: "close_contradicted",
state_label: "Закрыт формально, но с противоречием",
state_label: "Закрыт формально, но с противоречием",
state_class: "problematic",
entry_conditions: ["misclosed_or_cross_branch_conflict"],
exit_conditions: ["close_completed"],
is_terminal: false,
is_problematic: true,
business_meaning: "Формальное закрытие не согласовано с фактическими ветками."
business_meaning: "Формальное закрытие не согласовано с фактическими ветками."
}
],
transitions: [],
@@ -426,7 +512,7 @@ const SHARED_DEFECTS: LifecycleDefectDefinition[] = [
defect_code: "missing_expected_transition",
defect_class: "path",
severity_hint: "medium",
business_meaning: "Ожидаемый переход не произошел.",
business_meaning: "Ожидаемый переход не произошел.",
evidence_requirements: ["expected_state", "missing_transition_signal"],
period_impact_potential: "indirect"
},
@@ -434,7 +520,7 @@ const SHARED_DEFECTS: LifecycleDefectDefinition[] = [
defect_code: "invalid_transition",
defect_class: "path",
severity_hint: "high",
business_meaning: "Переход произошел по некорректному пути.",
business_meaning: "Переход произошел по некорректному пути.",
evidence_requirements: ["invalid_transition_signal"],
period_impact_potential: "indirect"
},
@@ -442,7 +528,7 @@ const SHARED_DEFECTS: LifecycleDefectDefinition[] = [
defect_code: "stale_active_state",
defect_class: "timing",
severity_hint: "high",
business_meaning: "Объект завис в активном состоянии.",
business_meaning: "Объект завис в активном состоянии.",
evidence_requirements: ["stale_marker", "missing_transition_signal"],
period_impact_potential: "direct"
},
@@ -450,7 +536,7 @@ const SHARED_DEFECTS: LifecycleDefectDefinition[] = [
defect_code: "contradictory_state",
defect_class: "consistency",
severity_hint: "high",
business_meaning: "Статусы объекта противоречат друг другу.",
business_meaning: "Статусы объекта противоречат друг другу.",
evidence_requirements: ["contradiction_signal"],
period_impact_potential: "direct"
},
@@ -458,7 +544,7 @@ const SHARED_DEFECTS: LifecycleDefectDefinition[] = [
defect_code: "premature_terminal_state",
defect_class: "closure",
severity_hint: "medium",
business_meaning: "Терминальное состояние наступило преждевременно.",
business_meaning: "Терминальное состояние наступило преждевременно.",
evidence_requirements: ["terminal_state", "missing_required_previous_state"],
period_impact_potential: "indirect"
},
@@ -466,7 +552,7 @@ const SHARED_DEFECTS: LifecycleDefectDefinition[] = [
defect_code: "misclosed_state",
defect_class: "closure",
severity_hint: "high",
business_meaning: "Контур формально закрыт, но закрыт неверно.",
business_meaning: "Контур формально закрыт, но закрыт неверно.",
evidence_requirements: ["wrong_closure_path"],
period_impact_potential: "direct"
},
@@ -474,7 +560,7 @@ const SHARED_DEFECTS: LifecycleDefectDefinition[] = [
defect_code: "orphan_intermediate_state",
defect_class: "path",
severity_hint: "medium",
business_meaning: "Промежуточная стадия осталась без корректного продолжения.",
business_meaning: "Промежуточная стадия осталась без корректного продолжения.",
evidence_requirements: ["intermediate_state_without_next"],
period_impact_potential: "indirect"
},
@@ -482,7 +568,7 @@ const SHARED_DEFECTS: LifecycleDefectDefinition[] = [
defect_code: "cross_branch_state_conflict",
defect_class: "consistency",
severity_hint: "high",
business_meaning: "Состояния соседних веток учета противоречат друг другу.",
business_meaning: "Состояния соседних веток учета противоречат друг другу.",
evidence_requirements: ["cross_branch_conflict_signal"],
period_impact_potential: "direct"
}
@@ -502,6 +588,23 @@ class LifecycleRegistryImpl {
public getDomain(domain: LifecycleDomain): LifecycleDomainModel {
return this.models[domain];
}
public hasState(domain: LifecycleDomain, stateCode: string | null | undefined): boolean {
const model = this.getDomain(domain);
return Boolean(resolveStateCode(model, stateCode));
}
public resolveDefaultExpectedState(domain: LifecycleDomain): string {
return defaultExpectedState(this.getDomain(domain));
}
public resolveInitialState(domain: LifecycleDomain): string {
return defaultInitialState(this.getDomain(domain));
}
public findExpectedPath(domain: LifecycleDomain, fromState: string, toState: string): string[] | null {
return shortestExpectedPath(this.getDomain(domain), fromState, toState);
}
}
export const LifecycleRegistry = new LifecycleRegistryImpl(LIFECYCLE_DOMAIN_MODELS);
@@ -524,30 +627,88 @@ function inferLifecycleDomain(input: LifecycleResolverInput): LifecycleDomain {
.join(" ")
.toLowerCase();
if (includesAny(unitTokens, [/\bnds\b/, /\bvat\b/, /\btax\b/, /cross[_\s-]?branch/, /\b19\b/, /\b68\b/])) {
return "vat_flow";
}
if (includesAny(unitTokens, [/\bperiod\b/, /\bclose\b/, /закрыт/, /reporting/]) || input.unit.problem_unit_type === "period_risk_cluster") {
return "period_close";
}
if (includesAny(unitTokens, [/deferred/, /writeoff/, /рбп/, /\b97\b/])) {
const hasVatMarkers = includesAny(unitTokens, [
/domain_hint:vat_flow/,
/\binvoice_to_vat\b/,
/\bvat_chain_conflict\b/,
/(^|[^a-z0-9])nds([^a-z0-9]|$)/,
/(^|[^a-z0-9])vat([^a-z0-9]|$)/,
/(^|[^a-z0-9])tax(?:es)?([^a-z0-9]|$)/,
/\baccount[_:\s-]?(19|68)\b/
]);
const hasDeferredMarkers = includesAny(unitTokens, [
/domain_hint:deferred_expense/,
/\bdeferred(?:_expense)?\b/,
/\bdeferred_expense_to_writeoff\b/,
/\bwriteoff\b/,
/\bpartially_written_off\b/,
/\bfully_written_off\b/,
/\baccount[_:\s-]?97\b/
]);
const hasFixedAssetMarkers = includesAny(unitTokens, [
/domain_hint:fixed_asset/,
/\bfixed[_\s-]?asset(?:s)?\b/,
/\basset_card_to_depreciation\b/,
/\bdepreciation(?:_active)?\b/,
/\baccepted_for_accounting\b/,
/\bcapitalized\b/,
/\baccount[_:\s-]?(01|02|08)\b/
]);
const hasPeriodCloseMarkers = includesAny(unitTokens, [
/domain_hint:period_close/,
/\bperiod[_\s-]?close\b/,
/\bperiod_close_risk\b/,
/\bclose[_\s-]?risk\b/,
/\bclosure[_\s-]?risk\b/,
/\bpreclose\b/,
/\bmonth[_\s-]?close\b/,
/\bperiod_risk\b/
]);
if (hasDeferredMarkers) {
return "deferred_expense";
}
if (includesAny(unitTokens, [/fixed[_\s-]?asset/, /амортиз/, /ос\b/, /\b01\b/, /\b02\b/, /\b08\b/])) {
if (hasFixedAssetMarkers) {
return "fixed_asset";
}
if (includesAny(unitTokens, [/buyer/, /customer/, /дебитор/, /\b62\b/])) {
if (hasVatMarkers) {
return "vat_flow";
}
if (
hasPeriodCloseMarkers ||
input.unit.problem_unit_type === "period_risk_cluster" ||
input.unit.period_impact?.impact_class === "close_risk"
) {
return "period_close";
}
if (includesAny(unitTokens, [/buyer/, /customer/, /\b62\b/])) {
return "customer_settlement";
}
if (
includesAny(unitTokens, [
/domain_hint:bank_settlement/,
/\bpayment_to_settlement\b/,
/\bstatement_to_document\b/,
/\bbank_recorded\b/,
/\binitiated_payment\b/,
/\bsettlement(?:_closed)?\b/
]) ||
input.unit.problem_unit_type === "unresolved_settlement_cluster" ||
input.unit.problem_unit_type === "broken_chain_segment"
) {
return "bank_settlement";
}
if (input.unit.problem_unit_type === "cross_branch_inconsistency_cluster") {
return "vat_flow";
}
if (input.unit.problem_unit_type === "lifecycle_anomaly_node") {
return "deferred_expense";
}
return "bank_settlement";
}
function inferCurrentState(domain: LifecycleDomain, input: LifecycleResolverInput): string {
const explicitActual = input.unit.actual_state?.trim();
if (explicitActual) {
return explicitActual;
}
const anomalies = input.candidates.flatMap((item) => item.anomaly_patterns).map((item) => item.toLowerCase());
const relations = input.candidates.flatMap((item) => item.relation_pattern_hits).map((item) => item.toLowerCase());
@@ -573,7 +734,7 @@ function inferCurrentState(domain: LifecycleDomain, input: LifecycleResolverInpu
if (domain === "fixed_asset") {
if (hasInvalid) return "contradictory_asset_state";
if (hasToken(relations, /depreciation|amort/)) return "depreciation_active";
if (hasToken(relations, /accept|учет/)) return "accepted_for_accounting";
if (hasToken(relations, /accept|account/)) return "accepted_for_accounting";
return "capitalized";
}
if (domain === "vat_flow") {
@@ -587,27 +748,51 @@ function inferCurrentState(domain: LifecycleDomain, input: LifecycleResolverInpu
return "preclose_checks";
}
function inferExpectedState(domain: LifecycleDomain, input: LifecycleResolverInput): string {
function inferExpectedState(domain: LifecycleDomain, input: LifecycleResolverInput, model: LifecycleDomainModel): string {
const explicitExpected = input.unit.expected_state?.trim();
if (explicitExpected) {
return explicitExpected;
}
return defaultExpectedState(domain);
return defaultExpectedState(model);
}
function inferMissingTransition(input: LifecycleResolverInput): string | null {
function inferMissingTransition(
input: LifecycleResolverInput,
model: LifecycleDomainModel,
currentState: string,
expectedState: string
): string | null {
if (typeof input.unit.failed_expected_edge === "string" && input.unit.failed_expected_edge.trim().length > 0) {
return input.unit.failed_expected_edge.trim();
}
const anomalies = input.candidates.flatMap((item) => item.anomaly_patterns).join(" ").toLowerCase();
if (/(missing_link|no_continuation|broken_lifecycle|tail|unresolved)/.test(anomalies)) {
return "expected_transition_not_observed";
if (!/(missing_link|no_continuation|broken_lifecycle|tail|unresolved)/.test(anomalies)) {
return null;
}
return null;
if (currentState !== expectedState) {
const path = shortestExpectedPath(model, currentState, expectedState);
if (path && path.length >= 2) {
return transitionEdgeLabel(path[0], path[1]);
}
}
const directExpected = model.transitions.find(
(transition) => transition.transition_type === "expected" && transition.from_state === currentState
);
if (directExpected) {
return transitionEdgeLabel(directExpected.from_state, directExpected.to_state);
}
return "expected_transition_not_observed";
}
function inferInvalidTransition(input: LifecycleResolverInput): string | null {
function inferInvalidTransition(input: LifecycleResolverInput, model: LifecycleDomainModel): string | null {
const anomalies = input.candidates.flatMap((item) => item.anomaly_patterns).join(" ").toLowerCase();
for (const transition of model.transitions) {
for (const forbiddenCondition of transition.forbidden_conditions) {
if (anomalies.includes(forbiddenCondition.toLowerCase())) {
return `${transitionEdgeLabel(transition.from_state, transition.to_state)}:forbidden:${forbiddenCondition}`;
}
}
}
if (/(cross_branch|cross_domain_inconsistency)/.test(anomalies)) {
return "cross_branch_conflict_transition";
}
@@ -653,6 +838,14 @@ export function classifyLifecycleDefect(input: {
return null;
}
function registryBackedDefect(domain: LifecycleDomain, defect: LifecycleDefectType | null): LifecycleDefectType | null {
if (!defect) {
return null;
}
const model = LifecycleRegistry.getDomain(domain);
return model.defects.some((definition) => definition.defect_code === defect) ? defect : null;
}
function resolutionConfidence(unitConfidence: ProblemConfidence, input: {
hasExplicitStates: boolean;
hasDefectSignal: boolean;
@@ -690,32 +883,47 @@ function lifecycleInterpretation(input: {
missingTransition: string | null;
invalidTransition: string | null;
}): string {
const base = `Текущая стадия: ${input.currentState}; ожидаемая стадия: ${input.expectedState}.`;
const base = `Текущая стадия: ${input.currentState}; ожидаемая стадия: ${input.expectedState}.`;
if (input.defect === "stale_active_state") {
return `${base} Объект завис во времени и не дошел до ожидаемого перехода.`;
return `${base} Объект завис во времени и не дошел до ожидаемого перехода.`;
}
if (input.defect === "misclosed_state") {
return `${base} Контур закрыт формально, но путь закрытия противоречит бухгалтерской логике.`;
return `${base} Контур закрыт формально, но путь закрытия противоречит бухгалтерской логике.`;
}
if (input.defect === "cross_branch_state_conflict") {
return `${base} Между ветками домена ${input.domain} обнаружено противоречие состояний.`;
return `${base} Между ветками домена ${input.domain} обнаружено противоречие состояний.`;
}
if (input.defect === "missing_expected_transition") {
return `${base} Не зафиксирован ожидаемый переход (${input.missingTransition ?? "unknown_transition"}).`;
return `${base} Не зафиксирован ожидаемый переход (${input.missingTransition ?? "unknown_transition"}).`;
}
if (input.defect === "invalid_transition") {
return `${base} Зафиксирован некорректный переход (${input.invalidTransition ?? "invalid_transition"}).`;
return `${base} Зафиксирован некорректный переход (${input.invalidTransition ?? "invalid_transition"}).`;
}
return `${base} Lifecycle-разрешение не выявило критичный дефект, но состояние требует наблюдения.`;
return `${base} Lifecycle-разрешение не выявило критичный дефект, но состояние требует наблюдения.`;
}
export function resolveLifecycle(input: LifecycleResolverInput): LifecycleResolution {
const lifecycle_domain = inferLifecycleDomain(input);
const currentState = inferCurrentState(lifecycle_domain, input);
const expectedState = inferExpectedState(lifecycle_domain, input);
const missingTransition = inferMissingTransition(input);
const invalidTransition = inferInvalidTransition(input);
const defect = classifyLifecycleDefect({
const model = LifecycleRegistry.getDomain(lifecycle_domain);
const inferredCurrentState = inferCurrentState(lifecycle_domain, input);
const inferredExpectedState = inferExpectedState(lifecycle_domain, input, model);
const explicitActualState = input.unit.actual_state?.trim() ?? null;
const explicitExpectedState = input.unit.expected_state?.trim() ?? null;
const explicitCurrentState = resolveStateCode(model, explicitActualState);
const explicitExpectedResolved = resolveStateCode(model, explicitExpectedState);
const inferredCurrentResolved = resolveStateCode(model, inferredCurrentState);
const inferredExpectedResolved = resolveStateCode(model, inferredExpectedState);
const currentState = explicitCurrentState ?? inferredCurrentResolved ?? defaultInitialState(model);
const expectedState = explicitExpectedResolved ?? inferredExpectedResolved ?? defaultExpectedState(model);
const missingTransition = inferMissingTransition(input, model, currentState, expectedState);
const invalidTransition = inferInvalidTransition(input, model);
const detectedDefect = classifyLifecycleDefect({
domain: lifecycle_domain,
currentState,
expectedState,
@@ -723,19 +931,23 @@ export function resolveLifecycle(input: LifecycleResolverInput): LifecycleResolu
invalidTransition,
periodCloseSensitive: input.unit.period_impact?.impact_class === "close_risk"
});
const defect = registryBackedDefect(lifecycle_domain, detectedDefect);
const evidenceIds = uniqueStrings(input.unit.evidence_pack, 8);
const previousStates = resolvePreviousStates(model, currentState);
const limitations = uniqueStrings(
[
...input.unit.snapshot_limitations,
...(input.candidates.some((item) => item.confidence_hint === "low") ? ["low_confidence_candidates_present"] : []),
...(input.unit.actual_state ? [] : ["actual_state_inferred"]),
...(input.unit.expected_state ? [] : ["expected_state_inferred"])
...(explicitActualState && !explicitCurrentState ? ["actual_state_not_in_registry_normalized"] : []),
...(explicitExpectedState && !explicitExpectedResolved ? ["expected_state_not_in_registry_normalized"] : []),
...(explicitCurrentState ? [] : ["actual_state_inferred"]),
...(explicitExpectedResolved ? [] : ["expected_state_inferred"])
],
8
);
const confidence = resolutionConfidence(input.unit.confidence, {
hasExplicitStates: Boolean(input.unit.actual_state || input.unit.expected_state),
hasExplicitStates: Boolean(explicitCurrentState || explicitExpectedResolved),
hasDefectSignal: Boolean(defect || missingTransition || invalidTransition),
candidateCount: input.candidates.length,
hasSnapshotLimitations: limitations.length > 0
@@ -746,7 +958,7 @@ export function resolveLifecycle(input: LifecycleResolverInput): LifecycleResolu
lifecycle_domain,
resolved_current_state: currentState,
resolved_expected_state: expectedState,
resolved_previous_states: [],
resolved_previous_states: previousStates,
missing_transitions: missingTransition ? [missingTransition] : [],
invalid_transitions: invalidTransition ? [invalidTransition] : [],
detected_defects: defect ? [defect] : [],
@@ -100,7 +100,7 @@ function extractAccounts(text: string): string[] {
const lower = String(text ?? "").toLowerCase();
const explicitAccounts = new Set<string>();
const contextualPattern =
/(?:\bсчет(?:а|у|ом|ов)?\b|\bсч\.?\b|\baccount(?:s)?\b|\bschet(?:a|u|om|ov)?\b)\s*(?:|#|:)?\s*(\d{2}(?:\.\d{2})?)/giu;
/(?:\bсч(?:е|ё)т(?:а|у|ом|ов)?\b|\bсч\.?\b|\baccount(?:s)?\b|\bschet(?:a|u|om|ov)?\b)\s*(?:|#|:)?\s*(\d{2}(?:\.\d{2})?)/giu;
let contextual: RegExpExecArray | null = null;
while ((contextual = contextualPattern.exec(lower)) !== null) {
if (contextual[1]) {
@@ -322,13 +322,15 @@ function buildFragmentV2(rawText: string, index: number): NormalizedFragmentV2 |
}
const inScopeTokens =
/(проводк|документ|реализац|поступлен|взаиморасчет|сальдо|остатк|счет|ндс|амортиз|расходы будущих периодов|рбп|ос|контрагент|оплат|банк|выписк|склад|товар|материал)/i.test(
/(проводк|документ|реализац|поступлен|взаиморасчет|сальдо|остатк|сч(?:е|ё)т|ндс|амортиз|расходы будущих периодов|рбп|ос|контрагент|оплат|банк|выписк|склад|товар|материал|списани|жизненн|цикл|переход|lifecycle|writeoff|deferred)/i.test(
lower
);
const translitInScopeTokens =
/\b(?:schet|scheta|schetu|schetom|postavsh|kontragent|dokument|doc|oplata|oplati|platezh|vypisk|provodk|realiz|postuplen|nds|os|saldo|hvost|tail|anomali|risk|zakryt)\b/i.test(
/\b(?:schet|scheta|schetu|schetom|postavsh|kontragent|dokument|doc|oplata|oplati|platezh|vypisk|provodk|realiz|postuplen|nds|os|saldo|hvost|tail|anomali|risk|zakryt|lifecycle|state|transition|writeoff|deferred|periodclose)\b/i.test(
lower
);
const lifecycleInScopeTokens =
/(lifecycle|жизненн(?:ого|ый)?\s+цикл|стади|переход|списани|writeoff|deferred|period\s*close)/i.test(lower);
const genericAccountingTokens = /(фсбу|налогов(ый|ого)|нк рф|закон|форма отчетности|как правильно в бухгалтерии)/i.test(lower);
const offTopicTokens = /(погода|анекдот|музык|фильм|игр[аы]|рецепт|курс валют в мире)/i.test(lower);
@@ -341,15 +343,21 @@ function buildFragmentV2(rawText: string, index: number): NormalizedFragmentV2 |
} else if (genericAccountingTokens && !inScopeTokens && !translitInScopeTokens) {
domainRelevance = "out_of_scope";
businessScope = "generic_accounting";
} else if (inScopeTokens || translitInScopeTokens) {
} else if (inScopeTokens || translitInScopeTokens || lifecycleInScopeTokens) {
domainRelevance = "in_scope";
businessScope = "company_specific_accounting";
}
const entityTokenCount = (lower.match(/(документ|оплат|проводк|контрагент|договор|реализац|поступлен|выписк|закрыт|взаиморасчет|склад|товар|материал)/g) ?? [])
const entityTokenCount = (
lower.match(
/(документ|оплат|проводк|контрагент|договор|реализац|поступлен|выписк|закрыт|взаиморасчет|склад|товар|материал|поставщ|покупат|списани|жизненн|цикл)/g
) ?? []
)
.length;
const translitEntityTokenCount = (
lower.match(/\b(?:dokument|oplata|platezh|provodk|kontragent|realiz|postuplen|vypisk|zakryt|schet|sklad|tovar|material)\b/g) ?? []
lower.match(
/\b(?:dokument|oplata|platezh|provodk|kontragent|postavsh|pokupat|realiz|postuplen|vypisk|zakryt|schet|sklad|tovar|material)\b/g
) ?? []
).length;
const entityTokenCountTotal = entityTokenCount + translitEntityTokenCount;
@@ -237,6 +237,7 @@ export function simulateDeterministicRouting(normalized: V2Family): RouteHintSum
const decisions = normalized.fragments.map((fragment) => decideRouteForFragment(fragment));
const inScopeCount = decisions.filter((item) => item.domain_relevance === "in_scope").length;
const outOfScopeCount = decisions.filter((item) => item.domain_relevance === "out_of_scope").length;
const unclearCount = decisions.filter((item) => item.domain_relevance === "unclear").length;
const routedInScopeCount = decisions.filter((item) => item.domain_relevance === "in_scope" && item.route !== "no_route").length;
const clarificationInScopeCount = decisions.filter(
(item) => item.domain_relevance === "in_scope" && item.execution_readiness === "needs_clarification"
@@ -245,7 +246,7 @@ export function simulateDeterministicRouting(normalized: V2Family): RouteHintSum
let fallbackType: RouteHintSummaryV2["fallback"]["type"] = "none";
if (!normalized.message_in_scope || inScopeCount === 0) {
fallbackType = "out_of_scope";
fallbackType = outOfScopeCount > 0 && unclearCount === 0 ? "out_of_scope" : "clarification";
} else if (routedInScopeCount === 0 && clarificationInScopeCount > 0) {
fallbackType = "clarification";
} else if (routedInScopeCount === 0 && noRouteInScopeCount > 0) {