diff --git a/llm_normalizer/backend/dist/services/answerComposer.js b/llm_normalizer/backend/dist/services/answerComposer.js index d36e114..72e9a0a 100644 --- a/llm_normalizer/backend/dist/services/answerComposer.js +++ b/llm_normalizer/backend/dist/services/answerComposer.js @@ -2519,12 +2519,27 @@ function extractRequirementIdsFromText(value) { const matches = String(value ?? "").match(/\bR\d+\b/gi); return uniqueStrings((matches ?? []).map((item) => item.toUpperCase()), 8); } -function buildCoverageSplitLines(structure) { +function buildCoverageSplitLines(structure, questionType = "unknown") { const confirmed = uniqueStrings((structure.evidence_block.claim_evidence_links ?? []) .flatMap((item) => extractRequirementIdsFromText(item.claim_ref)) .map((item) => item.toUpperCase()), 8); const unresolved = uniqueStrings(structure.uncertainty_block.open_uncertainties.flatMap((item) => extractRequirementIdsFromText(item)), 8); const lines = []; + if (questionType === "which_chains_are_complete_vs_incomplete") { + if (confirmed.length > 0) { + lines.push(`Цепочки подтверждены: ${confirmed.join(", ")}.`); + } + if (unresolved.length > 0) { + lines.push(`Цепочки подтверждены частично или не подтверждены: ${unresolved.join(", ")}.`); + } + else if (structure.evidence_block.coverage_note === "coverage_partial_or_limited") { + lines.push("Часть цепочек подтверждена частично; для остальных не хватает опоры."); + } + if (lines.length === 0) { + lines.push("Цепочки пока не удалось уверенно разделить на полные и неполные."); + } + return dedupeNarrativeLines(lines, 3); + } if (confirmed.length > 0) { lines.push(`Подтверждено по требованиям: ${confirmed.join(", ")}.`); } @@ -2536,7 +2551,7 @@ function buildCoverageSplitLines(structure) { } return dedupeNarrativeLines(lines, 3); } -function buildEvidenceSectionLines(structure) { +function buildEvidenceSectionLines(structure, questionType = "unknown") { const evidenceCount = Array.isArray(structure.evidence_block.evidence_ids) ? structure.evidence_block.evidence_ids.length : 0; const sourceCount = Array.isArray(structure.evidence_block.source_refs) ? structure.evidence_block.source_refs.length : 0; const claimLinks = Array.isArray(structure.evidence_block.claim_evidence_links) @@ -2547,7 +2562,16 @@ function buildEvidenceSectionLines(structure) { structure.uncertainty_block.open_uncertainties.length > 0 || structure.evidence_block.coverage_note === "coverage_partial_or_limited"; const lines = []; - const coverageSplitLines = buildCoverageSplitLines(structure); + const coverageSplitLines = buildCoverageSplitLines(structure, questionType); + if (questionType === "what_is_it_grounded_on") { + lines.push("Основание вывода перечислено по подтвержденным документам, регистрам и проводкам."); + } + else if (questionType === "prove_or_guess") { + lines.push("Основание разделено на подтвержденную часть и зону гипотез."); + } + else if (questionType === "which_chains_are_complete_vs_incomplete") { + lines.push("Опора собрана так, чтобы разделить цепочки на полные и неполные."); + } if (evidenceCount > 0) { lines.push(`Вывод опирается на ${evidenceCount} подтвержденных наблюдений в текущем срезе.`); } @@ -2589,7 +2613,111 @@ function buildDefaultChecksByDomain(domain) { } return ["Проверьте связку документов и проводок по проблемному участку в указанном периоде."]; } -function buildChecksSectionLines(structure) { +function buildQuestionTypeDomainChecks(questionType, domain) { + if (questionType === "what_to_check_first") { + if (domain === "settlements_60_62") { + return [ + "Сверьте договор и объект расчетов по спорной операции.", + "Проверьте регистр расчетов и зачет аванса/взаимозачет.", + "Подтвердите проводки по 60/62/76 и факт закрытия хвоста." + ]; + } + if (domain === "vat_document_register_book") { + return [ + "Сверьте исходный документ и счет-фактуру.", + "Проверьте запись в регистре НДС и попадание в книгу.", + "Подтвердите налоговые проводки по 19/68 в нужном периоде." + ]; + } + if (domain === "month_close_costs_20_44") { + return [ + "Проверьте регламентную операцию закрытия за нужный период.", + "Сверьте базу распределения затрат и проводки по 20/25/26/44.", + "Убедитесь, что остатки объяснены или закрыты после операции." + ]; + } + return ["Начните с первого подтверждаемого документа и пройдите цепочку без пропусков."]; + } + if (questionType === "where_break_is") { + if (domain === "settlements_60_62") { + return [ + "Локализуйте разрыв в узле: договор -> объект расчетов -> регистр расчетов -> закрывающий документ.", + "Сверьте, где прерывается переход платеж -> зачет/закрытие -> проводки 60/62/76." + ]; + } + if (domain === "vat_document_register_book") { + return [ + "Локализуйте разрыв в узле: документ -> счет-фактура -> регистр НДС -> книга.", + "Сверьте, где прерывается переход от исходного документа к налоговой записи." + ]; + } + if (domain === "month_close_costs_20_44") { + return [ + "Локализуйте разрыв в узле: накопление затрат -> правило распределения -> операция закрытия.", + "Сверьте, на каком шаге исчезает подтверждение перехода к закрытию остатков." + ]; + } + } + if (questionType === "prove_or_guess") { + if (domain === "settlements_60_62") { + return [ + "Отдельно отметьте, что доказано документами и проводками, а что остается гипотезой.", + "Для доказательства проверьте связку платеж -> расчетный документ -> регистр расчетов -> 60/62/76." + ]; + } + if (domain === "vat_document_register_book") { + return [ + "Разделите доказанное и предположительное по цепочке: документ -> счет-фактура -> регистр -> книга.", + "Подтвердите налоговую запись по 19/68 в нужном периоде." + ]; + } + if (domain === "month_close_costs_20_44") { + return [ + "Разделите доказанные и предположительные участки в цепочке закрытия месяца.", + "Проверьте подтверждение: операция закрытия -> распределение -> остатки по 20/25/26/44." + ]; + } + } + if (questionType === "what_is_it_grounded_on") { + if (domain === "settlements_60_62") { + return [ + "Перечислите основание: платежный документ, расчетный документ, запись регистра расчетов, проводки 60/62/76." + ]; + } + if (domain === "vat_document_register_book") { + return [ + "Перечислите основание: исходный документ, счет-фактура, запись регистра НДС, запись книги, проводки 19/68." + ]; + } + if (domain === "month_close_costs_20_44") { + return [ + "Перечислите основание: операция закрытия, база распределения, проводки по затратам, остатки после закрытия." + ]; + } + } + if (questionType === "which_chains_are_complete_vs_incomplete") { + if (domain === "settlements_60_62") { + return [ + "Разделите цепочки на: подтверждена, подтверждена частично, не подтверждена по переходу платеж -> закрытие расчета.", + "Проверьте разницу между закрытыми и незакрытыми связками по 60/62/76." + ]; + } + if (domain === "vat_document_register_book") { + return [ + "Разделите цепочки на: полная, частичная, неполная по связке документ -> счет-фактура -> регистр -> книга.", + "Проверьте, где отсутствует подтверждение налоговой записи." + ]; + } + if (domain === "month_close_costs_20_44") { + return [ + "Разделите цепочки закрытия на полные и неполные по шагам распределения и регламентной операции.", + "Проверьте, какие остатки после закрытия подтверждены, а какие нет." + ]; + } + } + return buildDefaultChecksByDomain(domain); +} +function buildChecksSectionLines(structure, context) { const actionLines = dedupeNarrativeLines([ ...structure.next_step_block.recommended_actions, ...structure.next_step_block.clarification_questions @@ -2601,29 +2729,49 @@ function buildChecksSectionLines(structure) { .filter((item) => item.length >= 18) .filter((item) => !/\b(?:factual|source-of-record|reference)\b/i.test(item)), 4); const broken = sanitizeUserText(structure.direct_answer) ?? ""; - const domain = inferNarrativeDomainFromText(broken); - const domainFallback = buildDefaultChecksByDomain(domain); + const domain = context?.focusDomain ?? inferNarrativeDomainFromText(broken); + const questionType = context?.questionType ?? "unknown"; + const domainFallback = buildQuestionTypeDomainChecks(questionType, domain); const hasMissingPeriod = structure.uncertainty_block.open_uncertainties.some((item) => /missing_anchor:period/i.test(String(item ?? ""))); const lines = []; - if (domain === "settlements_60_62") { - lines.push(...domainFallback.slice(0, 2)); - lines.push(...actionLines.slice(0, 2)); + if (questionType === "what_to_check_first") { + lines.push(...domainFallback.slice(0, 3)); + if (lines.length < 3) { + lines.push(...actionLines.slice(0, 3 - lines.length)); + } } - else if (actionLines.length > 0) { + else if (questionType === "what_is_it_grounded_on") { + lines.push(...domainFallback.slice(0, 2)); + lines.push(...actionLines.slice(0, 1)); + } + else if (questionType === "prove_or_guess" || questionType === "where_break_is") { + lines.push(...domainFallback.slice(0, 2)); lines.push(...actionLines.slice(0, 2)); } else { - lines.push(...domainFallback.slice(0, 2)); + if (domain === "settlements_60_62") { + lines.push(...domainFallback.slice(0, 2)); + lines.push(...actionLines.slice(0, 2)); + } + else if (actionLines.length > 0) { + lines.push(...actionLines.slice(0, 2)); + } + else { + lines.push(...domainFallback.slice(0, 2)); + } } if (hasMissingPeriod) { - if (domain === "settlements_60_62" && lines.length > 0) { + if (questionType === "what_to_check_first") { + lines.push("Уточните период, если он не зафиксирован в исходной формулировке вопроса."); + } + else if (domain === "settlements_60_62" && lines.length > 0) { lines.push("Уточните период проверки, чтобы подтвердить проблему без лишнего шума."); } else { lines.unshift("Уточните период проверки, чтобы подтвердить проблему без лишнего шума."); } } - return dedupeNarrativeLines(lines, 4); + return dedupeNarrativeLines(lines, questionType === "what_to_check_first" ? 3 : 5); } function humanizeLimitationToken(value) { const raw = String(value ?? "").trim(); @@ -2719,19 +2867,19 @@ function domainNameForQuestionType(domain) { function buildQuestionTypeShortLine(context) { const domainName = domainNameForQuestionType(context.focusDomain); if (context.questionType === "where_break_is") { - return `\u041f\u0440\u0438\u043e\u0440\u0438\u0442\u0435\u0442 \u043e\u0442\u0432\u0435\u0442\u0430: \u043b\u043e\u043a\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u0442\u044c \u0440\u0430\u0437\u0440\u044b\u0432 \u0432\u043d\u0443\u0442\u0440\u0438 ${domainName}.`; + return `\u041b\u043e\u043a\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d \u043d\u0430\u0438\u0431\u043e\u043b\u0435\u0435 \u0432\u0435\u0440\u043e\u044f\u0442\u043d\u044b\u0439 \u0443\u0437\u0435\u043b \u0440\u0430\u0437\u0440\u044b\u0432\u0430 \u0432\u043d\u0443\u0442\u0440\u0438 ${domainName}.`; } if (context.questionType === "prove_or_guess") { - return "\u041f\u0440\u0438\u043e\u0440\u0438\u0442\u0435\u0442 \u043e\u0442\u0432\u0435\u0442\u0430: \u0440\u0430\u0437\u0432\u0435\u0441\u0442\u0438 \u0434\u043e\u043a\u0430\u0437\u0430\u043d\u043e \u0438 \u0433\u0438\u043f\u043e\u0442\u0435\u0437\u0443."; + return "\u0412\u044b\u0432\u043e\u0434 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d \u043d\u0430 \u0434\u043e\u043a\u0430\u0437\u0430\u043d\u043d\u0443\u044e \u0447\u0430\u0441\u0442\u044c \u0438 \u0433\u0438\u043f\u043e\u0442\u0435\u0437\u0443."; } if (context.questionType === "what_is_it_grounded_on") { - return "\u041f\u0440\u0438\u043e\u0440\u0438\u0442\u0435\u0442 \u043e\u0442\u0432\u0435\u0442\u0430: \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043e\u0441\u043d\u043e\u0432\u0430\u043d\u0438\u0435 \u0432\u044b\u0432\u043e\u0434\u0430 \u043f\u043e \u0434\u0430\u043d\u043d\u044b\u043c."; + return "\u041d\u0438\u0436\u0435 \u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0435\u043d\u044b \u043e\u0441\u043d\u043e\u0432\u0430\u043d\u0438\u044f \u0432\u044b\u0432\u043e\u0434\u0430 \u043f\u043e \u0434\u0430\u043d\u043d\u044b\u043c \u0443\u0447\u0435\u0442\u0430."; } if (context.questionType === "which_chains_are_complete_vs_incomplete") { - return "\u041f\u0440\u0438\u043e\u0440\u0438\u0442\u0435\u0442 \u043e\u0442\u0432\u0435\u0442\u0430: \u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u044c \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u044b\u0435 \u0438 \u043d\u0435\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u044b\u0435 \u0446\u0435\u043f\u043e\u0447\u043a\u0438."; + return "\u0426\u0435\u043f\u043e\u0447\u043a\u0438 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u044b \u043d\u0430 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0435, \u0447\u0430\u0441\u0442\u0438\u0447\u043d\u043e \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0435 \u0438 \u043d\u0435\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0435."; } if (context.questionType === "what_to_check_first") { - return "\u041f\u0440\u0438\u043e\u0440\u0438\u0442\u0435\u0442 \u043e\u0442\u0432\u0435\u0442\u0430: \u0434\u0430\u0442\u044c \u043f\u0435\u0440\u0432\u044b\u0439 \u043c\u0430\u0440\u0448\u0440\u0443\u0442 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438."; + return `\u041a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 \u043c\u0430\u0440\u0448\u0440\u0443\u0442 \u043f\u0435\u0440\u0432\u044b\u0445 \u043f\u0440\u043e\u0432\u0435\u0440\u043e\u043a \u0432\u043d\u0443\u0442\u0440\u0438 ${domainName}.`; } return null; } @@ -2751,12 +2899,18 @@ function buildQuestionTypeBrokenLine(context) { return "\u0412\u0435\u0440\u043e\u044f\u0442\u043d\u044b\u0439 \u0443\u0437\u0435\u043b \u0440\u0430\u0437\u0440\u044b\u0432\u0430 \u043b\u043e\u043a\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d \u0447\u0430\u0441\u0442\u0438\u0447\u043d\u043e; \u043d\u0443\u0436\u043d\u0430 \u0442\u043e\u0447\u0435\u0447\u043d\u0430\u044f \u0441\u0432\u0435\u0440\u043a\u0430."; } function buildQuestionTypeWhyLine(context) { + if (context.questionType === "where_break_is") { + return "\u0424\u043e\u043a\u0443\u0441 \u043e\u0442\u0432\u0435\u0442\u0430: \u043d\u0435 \u043e\u0431\u0449\u0438\u0439 \u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c, \u0430 \u0442\u043e\u0447\u043a\u0430 \u0440\u0430\u0437\u0440\u044b\u0432\u0430 \u0432 \u0446\u0435\u043f\u043e\u0447\u043a\u0435."; + } if (context.questionType === "prove_or_guess") { - return "\u0417\u0434\u0435\u0441\u044c \u0447\u0435\u0441\u0442\u043d\u043e \u0440\u0430\u0437\u0432\u043e\u0434\u0438\u0442\u0441\u044f \u0447\u0442\u043e \u0443\u0436\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043e \u0438 \u0447\u0442\u043e \u043f\u043e\u043a\u0430 \u043e\u0441\u0442\u0430\u0435\u0442\u0441\u044f \u0433\u0438\u043f\u043e\u0442\u0435\u0437\u043e\u0439."; + return "\u041e\u0442\u0434\u0435\u043b\u044c\u043d\u043e \u043f\u043e\u043a\u0430\u0437\u0430\u043d\u043e, \u0447\u0442\u043e \u0443\u0436\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043e, \u0430 \u0447\u0442\u043e \u043f\u043e\u043a\u0430 \u043e\u0441\u0442\u0430\u0435\u0442\u0441\u044f \u0433\u0438\u043f\u043e\u0442\u0435\u0437\u043e\u0439."; } if (context.questionType === "which_chains_are_complete_vs_incomplete") { return "\u0426\u0435\u043f\u043e\u0447\u043a\u0438 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u044b \u043d\u0430 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u044b\u0435 \u0438 \u043d\u0435\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u044b\u0435 \u043f\u043e \u0442\u0435\u043a\u0443\u0449\u0435\u0439 \u043e\u043f\u043e\u0440\u0435."; } + if (context.questionType === "what_is_it_grounded_on") { + return "\u0424\u043e\u043a\u0443\u0441 \u043e\u0442\u0432\u0435\u0442\u0430 \u0441\u043c\u0435\u0449\u0435\u043d \u0432 \u0434\u043e\u043a\u0430\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0438, \u0430 \u043d\u0435 \u0432 \u043e\u0431\u0449\u0438\u0439 narrative."; + } return null; } function buildQuestionTypeEvidenceLine(context) { @@ -2766,6 +2920,9 @@ function buildQuestionTypeEvidenceLine(context) { if (context.questionType === "prove_or_guess") { return "\u0421\u0438\u043b\u0430 \u0432\u044b\u0432\u043e\u0434\u0430 \u043e\u0446\u0435\u043d\u0435\u043d\u0430 \u043f\u043e \u043f\u0440\u044f\u043c\u043e\u0439 \u043e\u043f\u043e\u0440\u0435, \u0430 \u043d\u0435 \u043f\u043e \u0434\u043e\u0433\u0430\u0434\u043a\u0430\u043c."; } + if (context.questionType === "which_chains_are_complete_vs_incomplete") { + return "\u041e\u043f\u043e\u0440\u0430 \u0441\u043e\u0431\u0440\u0430\u043d\u0430 \u0442\u0430\u043a, \u0447\u0442\u043e\u0431\u044b \u0447\u0435\u0441\u0442\u043d\u043e \u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u044c \u043f\u043e\u043b\u043d\u044b\u0435 \u0438 \u043d\u0435\u043f\u043e\u043b\u043d\u044b\u0435 \u0446\u0435\u043f\u043e\u0447\u043a\u0438."; + } return null; } function formatAnchorList(anchors, prefix) { @@ -2776,7 +2933,19 @@ function formatAnchorList(anchors, prefix) { } function buildQuestionTypeCheckLine(context) { if (context.questionType === "what_to_check_first") { - return "\u041d\u0430\u0447\u043d\u0438\u0442\u0435 \u0441 \u043f\u0435\u0440\u0432\u043e\u0433\u043e \u043f\u0443\u043d\u043a\u0442\u0430 \u0438 \u043f\u0440\u043e\u0439\u0434\u0438\u0442\u0435 \u043c\u0430\u0440\u0448\u0440\u0443\u0442 \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e, \u0431\u0435\u0437 \u043f\u0435\u0440\u0435\u0441\u043a\u043e\u043a\u0430."; + return "\u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043f\u0443\u043d\u043a\u0442\u044b \u043f\u043e \u043f\u043e\u0440\u044f\u0434\u043a\u0443: \u0448\u0430\u0433 1 -> \u0448\u0430\u0433 2 -> \u0448\u0430\u0433 3."; + } + if (context.questionType === "where_break_is") { + return "\u041b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u044e \u0440\u0430\u0437\u0440\u044b\u0432\u0430 \u043d\u0430\u0447\u043d\u0438\u0442\u0435 \u0441 \u0443\u0437\u043b\u0430, \u0433\u0434\u0435 \u043f\u0435\u0440\u0435\u0445\u043e\u0434 \u043f\u0435\u0440\u0435\u0441\u0442\u0430\u043b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0430\u0442\u044c\u0441\u044f."; + } + if (context.questionType === "prove_or_guess") { + return "\u041f\u0435\u0440\u0432\u044b\u043c \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435\u043c \u043e\u0442\u0434\u0435\u043b\u0438\u0442\u0435 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u043b\u044c\u043d\u043e \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0435 \u0444\u0430\u043a\u0442\u044b \u043e\u0442 \u0433\u0438\u043f\u043e\u0442\u0435\u0437."; + } + if (context.questionType === "what_is_it_grounded_on") { + return "\u0421\u043d\u0430\u0447\u0430\u043b\u0430 \u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0438\u0442\u0435 \u043e\u043f\u043e\u0440\u043d\u044b\u0435 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u0438 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b, \u0437\u0430\u0442\u0435\u043c \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0430\u044e\u0449\u0438\u0435 \u043f\u0440\u043e\u0432\u043e\u0434\u043a\u0438."; + } + if (context.questionType === "which_chains_are_complete_vs_incomplete") { + return "\u0421\u043d\u0430\u0447\u0430\u043b\u0430 \u0440\u0430\u0437\u043b\u043e\u0436\u0438\u0442\u0435 \u0446\u0435\u043f\u043e\u0447\u043a\u0438 \u043d\u0430 \u043f\u043e\u043b\u043d\u044b\u0435, \u0447\u0430\u0441\u0442\u0438\u0447\u043d\u043e \u043f\u043e\u043b\u043d\u044b\u0435 \u0438 \u043d\u0435\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0435."; } return null; } @@ -2787,6 +2956,15 @@ function buildQuestionTypeLimitationLine(context) { if (context.questionType === "which_chains_are_complete_vs_incomplete") { return "\u0414\u0435\u043b\u0435\u043d\u0438\u0435 \u043d\u0430 \u00abcomplete/incomplete\u00bb \u0437\u0430\u0432\u0438\u0441\u0438\u0442 \u043e\u0442 \u043f\u043e\u043b\u043d\u043e\u0442\u044b \u0446\u0435\u043f\u043e\u0447\u043a\u0438 \u0432 \u0442\u0435\u043a\u0443\u0449\u0435\u043c \u0441\u0440\u0435\u0437\u0435."; } + if (context.questionType === "where_break_is") { + return "\u0422\u043e\u0447\u043d\u0430\u044f \u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u044f \u043c\u043e\u0436\u0435\u0442 \u0441\u043c\u0435\u0449\u0430\u0442\u044c\u0441\u044f, \u0435\u0441\u043b\u0438 \u0447\u0430\u0441\u0442\u044c \u043f\u0435\u0440\u0435\u0445\u043e\u0434\u043e\u0432 \u0432 \u0446\u0435\u043f\u043e\u0447\u043a\u0435 \u043d\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0430."; + } + if (context.questionType === "what_is_it_grounded_on") { + return "\u0412 \u043e\u0441\u043d\u043e\u0432\u0430\u043d\u0438\u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u044b \u0442\u043e\u043b\u044c\u043a\u043e \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0435 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0438; \u043d\u0435\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0435 \u0432\u044b\u043d\u0435\u0441\u0435\u043d\u044b \u0432 \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u044f."; + } + if (context.questionType === "what_to_check_first") { + return "\u041c\u0430\u0440\u0448\u0440\u0443\u0442 \u043f\u0435\u0440\u0432\u0438\u0447\u043d\u044b\u0439 \u0438 \u043c\u043e\u0436\u0435\u0442 \u0443\u0442\u043e\u0447\u043d\u044f\u0442\u044c\u0441\u044f \u043f\u043e\u0441\u043b\u0435 \u043f\u0435\u0440\u0432\u043e\u0433\u043e \u0448\u0430\u0433\u0430."; + } return null; } function applyQuestionTypeAndAnchorPolicy(input) { @@ -2795,8 +2973,8 @@ function applyQuestionTypeAndAnchorPolicy(input) { const nextWhy = dedupeNarrativeLines([buildQuestionTypeWhyLine(input.context), ...input.whyLines].filter((item) => Boolean(item)), 4); const anchorUsedLine = formatAnchorList(input.context.anchors.used, "\u0412 \u043e\u043f\u043e\u0440\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u044b \u044f\u043a\u043e\u0440\u044f \u0432\u043e\u043f\u0440\u043e\u0441\u0430"); const anchorUnusedLine = formatAnchorList(input.context.anchors.unused, "\u042f\u043a\u043e\u0440\u044f \u0438\u0437 \u0432\u043e\u043f\u0440\u043e\u0441\u0430 \u0431\u0435\u0437 \u043f\u0440\u044f\u043c\u043e\u0433\u043e \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0438\u044f"); - const nextEvidence = dedupeNarrativeLines([buildQuestionTypeEvidenceLine(input.context), ...input.evidenceLines, anchorUsedLine].filter((item) => Boolean(item)), 7); - const nextChecks = dedupeNarrativeLines([buildQuestionTypeCheckLine(input.context), ...input.checkLines].filter((item) => Boolean(item)), 5); + const nextEvidence = dedupeNarrativeLines([buildQuestionTypeEvidenceLine(input.context), ...input.evidenceLines, anchorUsedLine].filter((item) => Boolean(item)), input.context.questionType === "what_to_check_first" ? 4 : 7); + const nextChecks = dedupeNarrativeLines([buildQuestionTypeCheckLine(input.context), ...input.checkLines].filter((item) => Boolean(item)), input.context.questionType === "what_to_check_first" ? 3 : 5); const nextLimitations = dedupeNarrativeLines([buildQuestionTypeLimitationLine(input.context), anchorUnusedLine, ...input.limitationLines].filter((item) => Boolean(item)), 6); return { shortLine: ensureSentence(nextShort), @@ -2808,11 +2986,12 @@ function applyQuestionTypeAndAnchorPolicy(input) { }; } function renderPolicyReply(structure, context) { + const questionType = context?.questionType ?? "unknown"; const shortLine = ensureSentence(buildShortSectionLine(structure)); const brokenLines = buildBrokenSectionLines(structure); const whyLines = buildWhySectionLines(structure); - const evidenceLines = buildEvidenceSectionLines(structure); - const checkLines = buildChecksSectionLines(structure); + const evidenceLines = buildEvidenceSectionLines(structure, questionType); + const checkLines = buildChecksSectionLines(structure, context); const limitationLines = buildLimitationsSectionLines(structure); const enriched = context ? applyQuestionTypeAndAnchorPolicy({ diff --git a/llm_normalizer/backend/dist/services/questionTypeResolver.js b/llm_normalizer/backend/dist/services/questionTypeResolver.js index 5d4b335..459a686 100644 --- a/llm_normalizer/backend/dist/services/questionTypeResolver.js +++ b/llm_normalizer/backend/dist/services/questionTypeResolver.js @@ -4,40 +4,89 @@ exports.resolveQuestionType = resolveQuestionType; const QUESTION_TYPE_RULES = [ { type: "what_to_check_first", - pattern: /(?:\bwhat\s+to\s+check\s+first\b|\bfirst\s+check\b|\bcheck\s+first\b|\u0441\s+\u0447\u0435\u0433\u043e\s+\u043d\u0430\u0447\u0430\u0442\u044c\s+\u043f\u0440\u043e\u0432\u0435\u0440\u043a|\u0447\u0442\u043e\s+\u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c\s+\u043f\u0435\u0440\u0432)/iu - }, - { - type: "what_is_it_grounded_on", - pattern: /(?:\bwhat\s+is\s+it\s+grounded\s+on\b|\bgrounded\s+on\b|\bbased\s+on\b|\bwhat\s+evidence\b|\u043d\u0430\s+\u0447(?:\u0435|\u0451)\u043c\s+\u044d\u0442\u043e\s+\u043e\u0441\u043d\u043e\u0432\u0430\u043d|\u0447\u0435\u043c\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434)/iu - }, - { - type: "prove_or_guess", - pattern: /(?:\bprove\b|\bguess\b|\bprove\s+or\s+guess\b|\bis\s+it\s+proven\b|\u044d\u0442\u043e\s+\u0434\u043e\u043a\u0430\u0437\u0430\u043d|\u0438\u043b\u0438\s+\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0433\u0438\u043f\u043e\u0442\u0435\u0437|\u0434\u043e\u043a\u0430\u0437\u0430\u043d|\u0434\u043e\u0433\u0430\u0434|\u0435\u0441\u0442\u044c\s+\u043b\u0438|\u043c\u043e\u0436\u0435\u0442\s+\u043b\u0438|\u044d\u0442\u043e\s+\u0443\u0436\u0435.*\u0438\u043b\u0438)/iu - }, - { - type: "which_chains_are_complete_vs_incomplete", - pattern: /(?:\bcomplete(?:d)?\b.*\bincomplete\b|\bwhich\s+chains?\b|\bcomplete\s+vs\s+incomplete\b|\u043a\u0430\u043a\u0438\u0435\s+\u0446\u0435\u043f\u043e\u0447\u043a[аи]\s+.*\u0437\u0430\u0432\u0435\u0440\u0448|\u0447\u0442\u043e\s+\u0437\u0430\u043a\u0440\u044b\u0442\u043e.*\u0447\u0442\u043e\s+\u043d\u0435\u0442)/iu + priority: 1, + patterns: [ + /(?:\bwhat\s+to\s+check\s+first\b|\bfirst\s+check\b|\bcheck\s+first\b)/iu, + /(?:что\s+проверить\s+перв(?:ым|ой)|с\s+чего\s+начать\s+проверк|перв(?:ый|ым)\s+шаг(?:ом)?\s+проверк)/iu + ] }, { type: "where_break_is", - pattern: /(?:\bwhere\s+is\s+the\s+break\b|\bwhere\s+exactly\b|\blocate\b|\u0433\u0434\u0435\s+\u0438\u043c\u0435\u043d\u043d\u043e|\u0433\u0434\u0435\s+\u0440\u0430\u0437\u0440\u044b\u0432|\u0432\s+\u043a\u0430\u043a\u043e\u043c\s+\u043c\u0435\u0441\u0442\u0435)/iu + priority: 2, + patterns: [ + /(?:\bwhere\s+is\s+the\s+break\b|\bwhere\s+exactly\b|\blocate(?:\s+the\s+break)?\b)/iu, + /(?:где\s+именно|где\s+разрыв|в\s+каком\s+месте|на\s+каком\s+этапе|локализ(?:овать|аци)|какой\s+узел)/iu + ] + }, + { + type: "which_chains_are_complete_vs_incomplete", + priority: 3, + patterns: [ + /(?:\bcomplete(?:d)?\b.*\bincomplete\b|\bwhich\s+chains?\b|\bcomplete\s+vs\s+incomplete\b)/iu, + /(?:какие(?:\s+\S+){0,4}\s+цепочк[аи].*(?:заверш|полны|неполны|не\s+заверш|подтвержд)|что\s+закрыто.*что\s+нет)/iu, + /(?:цепочк[аи].*(?:полная|неполная|частич|выпадени)|отраж[её]н\s+частич.*документ.*сч[её]т[-\s]?фактур)/iu + ] + }, + { + type: "prove_or_guess", + priority: 4, + patterns: [ + /(?:\bprove\b|\bguess\b|\bprove\s+or\s+guess\b|\bis\s+it\s+proven\b)/iu, + /(?:это\s+доказан|докаж(?:и|ите|ите\s+ли)|доказуем|доказательн|гипотез|догад(?:ка|ыв))/iu, + /(?:доказано\s+или\s+нет|похоже\s+или\s+доказано|зач[её]л(?:ся|ось)\s+ли|связан\s+ли|вс[её]\s+ли.*закрыл|больше\s+похоже.*или)/iu, + /(?:это\s+уже\s+[^?!.]{3,}\s+или|есть\s+ли\s+[^?!.]{0,80}ситуац[^?!.]{0,80}\s+где)/iu + ] + }, + { + type: "what_is_it_grounded_on", + priority: 5, + patterns: [ + /(?:\bwhat\s+is\s+it\s+grounded\s+on\b|\bgrounded\s+on\b|\bbased\s+on\b|\bwhat\s+evidence\b)/iu, + /(?:на\s+ч(?:е|ё)м[^?!.]{0,40}основан|чем\s+подтвержда(?:ется|но)|какие\s+основани[яе]|какими\s+доказательств)/iu, + /(?:есть\s+ли\s+[^?!.]{0,80}признак[аи])/iu + ] }, { type: "why_breaks", - pattern: /(?:\bwhy\b|\bwhy\s+does\s+it\s+break\b|\u043f\u043e\u0447\u0435\u043c\u0443|\u0432\s+\u0447(?:\u0435|\u0451)\u043c\s+\u043f\u0440\u0438\u0447\u0438\u043d\u0430|\u0438\u0437-\u0437\u0430\s+\u0447\u0435\u0433\u043e)/iu + priority: 6, + patterns: [ + /(?:\bwhy\b|\bwhy\s+does\s+it\s+break\b|\bwhat\s+causes\b)/iu, + /(?:почему|в\s+ч(?:е|ё)м\s+причина|из-?за\s+чего|откуда\s+разрыв)/iu + ] } ]; +function countRuleHits(text, rule) { + let hits = 0; + for (const pattern of rule.patterns) { + if (pattern.test(text)) { + hits += 1; + } + } + return hits; +} function resolveQuestionType(input) { const text = String(input ?? "").trim(); if (!text) { return "unknown"; } + let bestType = "unknown"; + let bestHits = 0; + let bestPriority = Number.POSITIVE_INFINITY; for (const rule of QUESTION_TYPE_RULES) { - if (rule.pattern.test(text)) { - return rule.type; + const hits = countRuleHits(text, rule); + if (hits <= 0) { + continue; + } + if (hits > bestHits || (hits === bestHits && rule.priority < bestPriority)) { + bestType = rule.type; + bestHits = hits; + bestPriority = rule.priority; } } - if (/[??]/u.test(text)) { + if (bestType !== "unknown") { + return bestType; + } + if (/[?пјџ]/u.test(text)) { return "why_breaks"; } return "unknown"; diff --git a/llm_normalizer/backend/src/services/answerComposer.ts b/llm_normalizer/backend/src/services/answerComposer.ts index 564e00a..ae7bc7f 100644 --- a/llm_normalizer/backend/src/services/answerComposer.ts +++ b/llm_normalizer/backend/src/services/answerComposer.ts @@ -2990,7 +2990,10 @@ function extractRequirementIdsFromText(value: string): string[] { return uniqueStrings((matches ?? []).map((item) => item.toUpperCase()), 8); } -function buildCoverageSplitLines(structure: AnswerStructureV11): string[] { +function buildCoverageSplitLines( + structure: AnswerStructureV11, + questionType: QuestionTypeClass = "unknown" +): string[] { const confirmed = uniqueStrings( (structure.evidence_block.claim_evidence_links ?? []) .flatMap((item) => extractRequirementIdsFromText(item.claim_ref)) @@ -3002,6 +3005,21 @@ function buildCoverageSplitLines(structure: AnswerStructureV11): string[] { 8 ); const lines: string[] = []; + if (questionType === "which_chains_are_complete_vs_incomplete") { + if (confirmed.length > 0) { + lines.push(`Цепочки подтверждены: ${confirmed.join(", ")}.`); + } + if (unresolved.length > 0) { + lines.push(`Цепочки подтверждены частично или не подтверждены: ${unresolved.join(", ")}.`); + } else if (structure.evidence_block.coverage_note === "coverage_partial_or_limited") { + lines.push("Часть цепочек подтверждена частично; для остальных не хватает опоры."); + } + if (lines.length === 0) { + lines.push("Цепочки пока не удалось уверенно разделить на полные и неполные."); + } + return dedupeNarrativeLines(lines, 3); + } + if (confirmed.length > 0) { lines.push(`Подтверждено по требованиям: ${confirmed.join(", ")}.`); } @@ -3013,7 +3031,10 @@ function buildCoverageSplitLines(structure: AnswerStructureV11): string[] { return dedupeNarrativeLines(lines, 3); } -function buildEvidenceSectionLines(structure: AnswerStructureV11): string[] { +function buildEvidenceSectionLines( + structure: AnswerStructureV11, + questionType: QuestionTypeClass = "unknown" +): string[] { const evidenceCount = Array.isArray(structure.evidence_block.evidence_ids) ? structure.evidence_block.evidence_ids.length : 0; const sourceCount = Array.isArray(structure.evidence_block.source_refs) ? structure.evidence_block.source_refs.length : 0; const claimLinks = Array.isArray(structure.evidence_block.claim_evidence_links) @@ -3025,7 +3046,15 @@ function buildEvidenceSectionLines(structure: AnswerStructureV11): string[] { structure.uncertainty_block.open_uncertainties.length > 0 || structure.evidence_block.coverage_note === "coverage_partial_or_limited"; const lines: string[] = []; - const coverageSplitLines = buildCoverageSplitLines(structure); + const coverageSplitLines = buildCoverageSplitLines(structure, questionType); + + if (questionType === "what_is_it_grounded_on") { + lines.push("Основание вывода перечислено по подтвержденным документам, регистрам и проводкам."); + } else if (questionType === "prove_or_guess") { + lines.push("Основание разделено на подтвержденную часть и зону гипотез."); + } else if (questionType === "which_chains_are_complete_vs_incomplete") { + lines.push("Опора собрана так, чтобы разделить цепочки на полные и неполные."); + } if (evidenceCount > 0) { lines.push(`Вывод опирается на ${evidenceCount} подтвержденных наблюдений в текущем срезе.`); @@ -3070,7 +3099,117 @@ function buildDefaultChecksByDomain(domain: P0NarrativeDomain): string[] { return ["Проверьте связку документов и проводок по проблемному участку в указанном периоде."]; } -function buildChecksSectionLines(structure: AnswerStructureV11): string[] { +function buildQuestionTypeDomainChecks(questionType: QuestionTypeClass, domain: P0NarrativeDomain): string[] { + if (questionType === "what_to_check_first") { + if (domain === "settlements_60_62") { + return [ + "Сверьте договор и объект расчетов по спорной операции.", + "Проверьте регистр расчетов и зачет аванса/взаимозачет.", + "Подтвердите проводки по 60/62/76 и факт закрытия хвоста." + ]; + } + if (domain === "vat_document_register_book") { + return [ + "Сверьте исходный документ и счет-фактуру.", + "Проверьте запись в регистре НДС и попадание в книгу.", + "Подтвердите налоговые проводки по 19/68 в нужном периоде." + ]; + } + if (domain === "month_close_costs_20_44") { + return [ + "Проверьте регламентную операцию закрытия за нужный период.", + "Сверьте базу распределения затрат и проводки по 20/25/26/44.", + "Убедитесь, что остатки объяснены или закрыты после операции." + ]; + } + return ["Начните с первого подтверждаемого документа и пройдите цепочку без пропусков."]; + } + + if (questionType === "where_break_is") { + if (domain === "settlements_60_62") { + return [ + "Локализуйте разрыв в узле: договор -> объект расчетов -> регистр расчетов -> закрывающий документ.", + "Сверьте, где прерывается переход платеж -> зачет/закрытие -> проводки 60/62/76." + ]; + } + if (domain === "vat_document_register_book") { + return [ + "Локализуйте разрыв в узле: документ -> счет-фактура -> регистр НДС -> книга.", + "Сверьте, где прерывается переход от исходного документа к налоговой записи." + ]; + } + if (domain === "month_close_costs_20_44") { + return [ + "Локализуйте разрыв в узле: накопление затрат -> правило распределения -> операция закрытия.", + "Сверьте, на каком шаге исчезает подтверждение перехода к закрытию остатков." + ]; + } + } + + if (questionType === "prove_or_guess") { + if (domain === "settlements_60_62") { + return [ + "Отдельно отметьте, что доказано документами и проводками, а что остается гипотезой.", + "Для доказательства проверьте связку платеж -> расчетный документ -> регистр расчетов -> 60/62/76." + ]; + } + if (domain === "vat_document_register_book") { + return [ + "Разделите доказанное и предположительное по цепочке: документ -> счет-фактура -> регистр -> книга.", + "Подтвердите налоговую запись по 19/68 в нужном периоде." + ]; + } + if (domain === "month_close_costs_20_44") { + return [ + "Разделите доказанные и предположительные участки в цепочке закрытия месяца.", + "Проверьте подтверждение: операция закрытия -> распределение -> остатки по 20/25/26/44." + ]; + } + } + + if (questionType === "what_is_it_grounded_on") { + if (domain === "settlements_60_62") { + return [ + "Перечислите основание: платежный документ, расчетный документ, запись регистра расчетов, проводки 60/62/76." + ]; + } + if (domain === "vat_document_register_book") { + return [ + "Перечислите основание: исходный документ, счет-фактура, запись регистра НДС, запись книги, проводки 19/68." + ]; + } + if (domain === "month_close_costs_20_44") { + return [ + "Перечислите основание: операция закрытия, база распределения, проводки по затратам, остатки после закрытия." + ]; + } + } + + if (questionType === "which_chains_are_complete_vs_incomplete") { + if (domain === "settlements_60_62") { + return [ + "Разделите цепочки на: подтверждена, подтверждена частично, не подтверждена по переходу платеж -> закрытие расчета.", + "Проверьте разницу между закрытыми и незакрытыми связками по 60/62/76." + ]; + } + if (domain === "vat_document_register_book") { + return [ + "Разделите цепочки на: полная, частичная, неполная по связке документ -> счет-фактура -> регистр -> книга.", + "Проверьте, где отсутствует подтверждение налоговой записи." + ]; + } + if (domain === "month_close_costs_20_44") { + return [ + "Разделите цепочки закрытия на полные и неполные по шагам распределения и регламентной операции.", + "Проверьте, какие остатки после закрытия подтверждены, а какие нет." + ]; + } + } + + return buildDefaultChecksByDomain(domain); +} + +function buildChecksSectionLines(structure: AnswerStructureV11, context?: AnswerRenderContext): string[] { const actionLines = dedupeNarrativeLines( [ ...structure.next_step_block.recommended_actions, @@ -3086,29 +3225,45 @@ function buildChecksSectionLines(structure: AnswerStructureV11): string[] { ); const broken = sanitizeUserText(structure.direct_answer) ?? ""; - const domain = inferNarrativeDomainFromText(broken); - const domainFallback = buildDefaultChecksByDomain(domain); + const domain = context?.focusDomain ?? inferNarrativeDomainFromText(broken); + const questionType = context?.questionType ?? "unknown"; + const domainFallback = buildQuestionTypeDomainChecks(questionType, domain); const hasMissingPeriod = structure.uncertainty_block.open_uncertainties.some((item) => /missing_anchor:period/i.test(String(item ?? "")) ); const lines: string[] = []; - if (domain === "settlements_60_62") { + if (questionType === "what_to_check_first") { + lines.push(...domainFallback.slice(0, 3)); + if (lines.length < 3) { + lines.push(...actionLines.slice(0, 3 - lines.length)); + } + } else if (questionType === "what_is_it_grounded_on") { + lines.push(...domainFallback.slice(0, 2)); + lines.push(...actionLines.slice(0, 1)); + } else if (questionType === "prove_or_guess" || questionType === "where_break_is") { lines.push(...domainFallback.slice(0, 2)); - lines.push(...actionLines.slice(0, 2)); - } else if (actionLines.length > 0) { lines.push(...actionLines.slice(0, 2)); } else { - lines.push(...domainFallback.slice(0, 2)); + if (domain === "settlements_60_62") { + lines.push(...domainFallback.slice(0, 2)); + lines.push(...actionLines.slice(0, 2)); + } else if (actionLines.length > 0) { + lines.push(...actionLines.slice(0, 2)); + } else { + lines.push(...domainFallback.slice(0, 2)); + } } if (hasMissingPeriod) { - if (domain === "settlements_60_62" && lines.length > 0) { + if (questionType === "what_to_check_first") { + lines.push("Уточните период, если он не зафиксирован в исходной формулировке вопроса."); + } else if (domain === "settlements_60_62" && lines.length > 0) { lines.push("Уточните период проверки, чтобы подтвердить проблему без лишнего шума."); } else { lines.unshift("Уточните период проверки, чтобы подтвердить проблему без лишнего шума."); } } - return dedupeNarrativeLines(lines, 4); + return dedupeNarrativeLines(lines, questionType === "what_to_check_first" ? 3 : 5); } function humanizeLimitationToken(value: string): string | null { @@ -3192,19 +3347,19 @@ function domainNameForQuestionType(domain: P0NarrativeDomain): string { function buildQuestionTypeShortLine(context: AnswerRenderContext): string | null { const domainName = domainNameForQuestionType(context.focusDomain); if (context.questionType === "where_break_is") { - return `\u041f\u0440\u0438\u043e\u0440\u0438\u0442\u0435\u0442 \u043e\u0442\u0432\u0435\u0442\u0430: \u043b\u043e\u043a\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u0442\u044c \u0440\u0430\u0437\u0440\u044b\u0432 \u0432\u043d\u0443\u0442\u0440\u0438 ${domainName}.`; + return `\u041b\u043e\u043a\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d \u043d\u0430\u0438\u0431\u043e\u043b\u0435\u0435 \u0432\u0435\u0440\u043e\u044f\u0442\u043d\u044b\u0439 \u0443\u0437\u0435\u043b \u0440\u0430\u0437\u0440\u044b\u0432\u0430 \u0432\u043d\u0443\u0442\u0440\u0438 ${domainName}.`; } if (context.questionType === "prove_or_guess") { - return "\u041f\u0440\u0438\u043e\u0440\u0438\u0442\u0435\u0442 \u043e\u0442\u0432\u0435\u0442\u0430: \u0440\u0430\u0437\u0432\u0435\u0441\u0442\u0438 \u0434\u043e\u043a\u0430\u0437\u0430\u043d\u043e \u0438 \u0433\u0438\u043f\u043e\u0442\u0435\u0437\u0443."; + return "\u0412\u044b\u0432\u043e\u0434 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d \u043d\u0430 \u0434\u043e\u043a\u0430\u0437\u0430\u043d\u043d\u0443\u044e \u0447\u0430\u0441\u0442\u044c \u0438 \u0433\u0438\u043f\u043e\u0442\u0435\u0437\u0443."; } if (context.questionType === "what_is_it_grounded_on") { - return "\u041f\u0440\u0438\u043e\u0440\u0438\u0442\u0435\u0442 \u043e\u0442\u0432\u0435\u0442\u0430: \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043e\u0441\u043d\u043e\u0432\u0430\u043d\u0438\u0435 \u0432\u044b\u0432\u043e\u0434\u0430 \u043f\u043e \u0434\u0430\u043d\u043d\u044b\u043c."; + return "\u041d\u0438\u0436\u0435 \u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0435\u043d\u044b \u043e\u0441\u043d\u043e\u0432\u0430\u043d\u0438\u044f \u0432\u044b\u0432\u043e\u0434\u0430 \u043f\u043e \u0434\u0430\u043d\u043d\u044b\u043c \u0443\u0447\u0435\u0442\u0430."; } if (context.questionType === "which_chains_are_complete_vs_incomplete") { - return "\u041f\u0440\u0438\u043e\u0440\u0438\u0442\u0435\u0442 \u043e\u0442\u0432\u0435\u0442\u0430: \u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u044c \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u044b\u0435 \u0438 \u043d\u0435\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u044b\u0435 \u0446\u0435\u043f\u043e\u0447\u043a\u0438."; + return "\u0426\u0435\u043f\u043e\u0447\u043a\u0438 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u044b \u043d\u0430 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0435, \u0447\u0430\u0441\u0442\u0438\u0447\u043d\u043e \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0435 \u0438 \u043d\u0435\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0435."; } if (context.questionType === "what_to_check_first") { - return "\u041f\u0440\u0438\u043e\u0440\u0438\u0442\u0435\u0442 \u043e\u0442\u0432\u0435\u0442\u0430: \u0434\u0430\u0442\u044c \u043f\u0435\u0440\u0432\u044b\u0439 \u043c\u0430\u0440\u0448\u0440\u0443\u0442 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438."; + return `\u041a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 \u043c\u0430\u0440\u0448\u0440\u0443\u0442 \u043f\u0435\u0440\u0432\u044b\u0445 \u043f\u0440\u043e\u0432\u0435\u0440\u043e\u043a \u0432\u043d\u0443\u0442\u0440\u0438 ${domainName}.`; } return null; } @@ -3226,12 +3381,18 @@ function buildQuestionTypeBrokenLine(context: AnswerRenderContext): string | nul } function buildQuestionTypeWhyLine(context: AnswerRenderContext): string | null { + if (context.questionType === "where_break_is") { + return "\u0424\u043e\u043a\u0443\u0441 \u043e\u0442\u0432\u0435\u0442\u0430: \u043d\u0435 \u043e\u0431\u0449\u0438\u0439 \u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c, \u0430 \u0442\u043e\u0447\u043a\u0430 \u0440\u0430\u0437\u0440\u044b\u0432\u0430 \u0432 \u0446\u0435\u043f\u043e\u0447\u043a\u0435."; + } if (context.questionType === "prove_or_guess") { - return "\u0417\u0434\u0435\u0441\u044c \u0447\u0435\u0441\u0442\u043d\u043e \u0440\u0430\u0437\u0432\u043e\u0434\u0438\u0442\u0441\u044f \u0447\u0442\u043e \u0443\u0436\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043e \u0438 \u0447\u0442\u043e \u043f\u043e\u043a\u0430 \u043e\u0441\u0442\u0430\u0435\u0442\u0441\u044f \u0433\u0438\u043f\u043e\u0442\u0435\u0437\u043e\u0439."; + return "\u041e\u0442\u0434\u0435\u043b\u044c\u043d\u043e \u043f\u043e\u043a\u0430\u0437\u0430\u043d\u043e, \u0447\u0442\u043e \u0443\u0436\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043e, \u0430 \u0447\u0442\u043e \u043f\u043e\u043a\u0430 \u043e\u0441\u0442\u0430\u0435\u0442\u0441\u044f \u0433\u0438\u043f\u043e\u0442\u0435\u0437\u043e\u0439."; } if (context.questionType === "which_chains_are_complete_vs_incomplete") { return "\u0426\u0435\u043f\u043e\u0447\u043a\u0438 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u044b \u043d\u0430 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u044b\u0435 \u0438 \u043d\u0435\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u044b\u0435 \u043f\u043e \u0442\u0435\u043a\u0443\u0449\u0435\u0439 \u043e\u043f\u043e\u0440\u0435."; } + if (context.questionType === "what_is_it_grounded_on") { + return "\u0424\u043e\u043a\u0443\u0441 \u043e\u0442\u0432\u0435\u0442\u0430 \u0441\u043c\u0435\u0449\u0435\u043d \u0432 \u0434\u043e\u043a\u0430\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0438, \u0430 \u043d\u0435 \u0432 \u043e\u0431\u0449\u0438\u0439 narrative."; + } return null; } @@ -3242,6 +3403,9 @@ function buildQuestionTypeEvidenceLine(context: AnswerRenderContext): string | n if (context.questionType === "prove_or_guess") { return "\u0421\u0438\u043b\u0430 \u0432\u044b\u0432\u043e\u0434\u0430 \u043e\u0446\u0435\u043d\u0435\u043d\u0430 \u043f\u043e \u043f\u0440\u044f\u043c\u043e\u0439 \u043e\u043f\u043e\u0440\u0435, \u0430 \u043d\u0435 \u043f\u043e \u0434\u043e\u0433\u0430\u0434\u043a\u0430\u043c."; } + if (context.questionType === "which_chains_are_complete_vs_incomplete") { + return "\u041e\u043f\u043e\u0440\u0430 \u0441\u043e\u0431\u0440\u0430\u043d\u0430 \u0442\u0430\u043a, \u0447\u0442\u043e\u0431\u044b \u0447\u0435\u0441\u0442\u043d\u043e \u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u044c \u043f\u043e\u043b\u043d\u044b\u0435 \u0438 \u043d\u0435\u043f\u043e\u043b\u043d\u044b\u0435 \u0446\u0435\u043f\u043e\u0447\u043a\u0438."; + } return null; } @@ -3254,7 +3418,19 @@ function formatAnchorList(anchors: string[], prefix: string): string | null { function buildQuestionTypeCheckLine(context: AnswerRenderContext): string | null { if (context.questionType === "what_to_check_first") { - return "\u041d\u0430\u0447\u043d\u0438\u0442\u0435 \u0441 \u043f\u0435\u0440\u0432\u043e\u0433\u043e \u043f\u0443\u043d\u043a\u0442\u0430 \u0438 \u043f\u0440\u043e\u0439\u0434\u0438\u0442\u0435 \u043c\u0430\u0440\u0448\u0440\u0443\u0442 \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e, \u0431\u0435\u0437 \u043f\u0435\u0440\u0435\u0441\u043a\u043e\u043a\u0430."; + return "\u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043f\u0443\u043d\u043a\u0442\u044b \u043f\u043e \u043f\u043e\u0440\u044f\u0434\u043a\u0443: \u0448\u0430\u0433 1 -> \u0448\u0430\u0433 2 -> \u0448\u0430\u0433 3."; + } + if (context.questionType === "where_break_is") { + return "\u041b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u044e \u0440\u0430\u0437\u0440\u044b\u0432\u0430 \u043d\u0430\u0447\u043d\u0438\u0442\u0435 \u0441 \u0443\u0437\u043b\u0430, \u0433\u0434\u0435 \u043f\u0435\u0440\u0435\u0445\u043e\u0434 \u043f\u0435\u0440\u0435\u0441\u0442\u0430\u043b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0430\u0442\u044c\u0441\u044f."; + } + if (context.questionType === "prove_or_guess") { + return "\u041f\u0435\u0440\u0432\u044b\u043c \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435\u043c \u043e\u0442\u0434\u0435\u043b\u0438\u0442\u0435 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u043b\u044c\u043d\u043e \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0435 \u0444\u0430\u043a\u0442\u044b \u043e\u0442 \u0433\u0438\u043f\u043e\u0442\u0435\u0437."; + } + if (context.questionType === "what_is_it_grounded_on") { + return "\u0421\u043d\u0430\u0447\u0430\u043b\u0430 \u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0438\u0442\u0435 \u043e\u043f\u043e\u0440\u043d\u044b\u0435 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u0438 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b, \u0437\u0430\u0442\u0435\u043c \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0430\u044e\u0449\u0438\u0435 \u043f\u0440\u043e\u0432\u043e\u0434\u043a\u0438."; + } + if (context.questionType === "which_chains_are_complete_vs_incomplete") { + return "\u0421\u043d\u0430\u0447\u0430\u043b\u0430 \u0440\u0430\u0437\u043b\u043e\u0436\u0438\u0442\u0435 \u0446\u0435\u043f\u043e\u0447\u043a\u0438 \u043d\u0430 \u043f\u043e\u043b\u043d\u044b\u0435, \u0447\u0430\u0441\u0442\u0438\u0447\u043d\u043e \u043f\u043e\u043b\u043d\u044b\u0435 \u0438 \u043d\u0435\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0435."; } return null; } @@ -3266,6 +3442,15 @@ function buildQuestionTypeLimitationLine(context: AnswerRenderContext): string | if (context.questionType === "which_chains_are_complete_vs_incomplete") { return "\u0414\u0435\u043b\u0435\u043d\u0438\u0435 \u043d\u0430 \u00abcomplete/incomplete\u00bb \u0437\u0430\u0432\u0438\u0441\u0438\u0442 \u043e\u0442 \u043f\u043e\u043b\u043d\u043e\u0442\u044b \u0446\u0435\u043f\u043e\u0447\u043a\u0438 \u0432 \u0442\u0435\u043a\u0443\u0449\u0435\u043c \u0441\u0440\u0435\u0437\u0435."; } + if (context.questionType === "where_break_is") { + return "\u0422\u043e\u0447\u043d\u0430\u044f \u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u044f \u043c\u043e\u0436\u0435\u0442 \u0441\u043c\u0435\u0449\u0430\u0442\u044c\u0441\u044f, \u0435\u0441\u043b\u0438 \u0447\u0430\u0441\u0442\u044c \u043f\u0435\u0440\u0435\u0445\u043e\u0434\u043e\u0432 \u0432 \u0446\u0435\u043f\u043e\u0447\u043a\u0435 \u043d\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0430."; + } + if (context.questionType === "what_is_it_grounded_on") { + return "\u0412 \u043e\u0441\u043d\u043e\u0432\u0430\u043d\u0438\u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u044b \u0442\u043e\u043b\u044c\u043a\u043e \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0435 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0438; \u043d\u0435\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0435 \u0432\u044b\u043d\u0435\u0441\u0435\u043d\u044b \u0432 \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u044f."; + } + if (context.questionType === "what_to_check_first") { + return "\u041c\u0430\u0440\u0448\u0440\u0443\u0442 \u043f\u0435\u0440\u0432\u0438\u0447\u043d\u044b\u0439 \u0438 \u043c\u043e\u0436\u0435\u0442 \u0443\u0442\u043e\u0447\u043d\u044f\u0442\u044c\u0441\u044f \u043f\u043e\u0441\u043b\u0435 \u043f\u0435\u0440\u0432\u043e\u0433\u043e \u0448\u0430\u0433\u0430."; + } return null; } @@ -3306,11 +3491,11 @@ function applyQuestionTypeAndAnchorPolicy(input: { [buildQuestionTypeEvidenceLine(input.context), ...input.evidenceLines, anchorUsedLine].filter( (item): item is string => Boolean(item) ), - 7 + input.context.questionType === "what_to_check_first" ? 4 : 7 ); const nextChecks = dedupeNarrativeLines( [buildQuestionTypeCheckLine(input.context), ...input.checkLines].filter((item): item is string => Boolean(item)), - 5 + input.context.questionType === "what_to_check_first" ? 3 : 5 ); const nextLimitations = dedupeNarrativeLines( [buildQuestionTypeLimitationLine(input.context), anchorUnusedLine, ...input.limitationLines].filter( @@ -3330,11 +3515,12 @@ function applyQuestionTypeAndAnchorPolicy(input: { } function renderPolicyReply(structure: AnswerStructureV11, context?: AnswerRenderContext): string { + const questionType = context?.questionType ?? "unknown"; const shortLine = ensureSentence(buildShortSectionLine(structure)); const brokenLines = buildBrokenSectionLines(structure); const whyLines = buildWhySectionLines(structure); - const evidenceLines = buildEvidenceSectionLines(structure); - const checkLines = buildChecksSectionLines(structure); + const evidenceLines = buildEvidenceSectionLines(structure, questionType); + const checkLines = buildChecksSectionLines(structure, context); const limitationLines = buildLimitationsSectionLines(structure); const enriched = context ? applyQuestionTypeAndAnchorPolicy({ diff --git a/llm_normalizer/backend/src/services/questionTypeResolver.ts b/llm_normalizer/backend/src/services/questionTypeResolver.ts index dec3b58..f5aaf1c 100644 --- a/llm_normalizer/backend/src/services/questionTypeResolver.ts +++ b/llm_normalizer/backend/src/services/questionTypeResolver.ts @@ -1,4 +1,4 @@ -export type QuestionTypeClass = +export type QuestionTypeClass = | "why_breaks" | "where_break_is" | "prove_or_guess" @@ -7,52 +7,104 @@ export type QuestionTypeClass = | "what_to_check_first" | "unknown"; -const QUESTION_TYPE_RULES: Array<{ type: QuestionTypeClass; pattern: RegExp }> = [ +interface QuestionTypeRule { + type: QuestionTypeClass; + priority: number; + patterns: RegExp[]; +} + +const QUESTION_TYPE_RULES: QuestionTypeRule[] = [ { type: "what_to_check_first", - pattern: - /(?:\bwhat\s+to\s+check\s+first\b|\bfirst\s+check\b|\bcheck\s+first\b|\u0441\s+\u0447\u0435\u0433\u043e\s+\u043d\u0430\u0447\u0430\u0442\u044c\s+\u043f\u0440\u043e\u0432\u0435\u0440\u043a|\u0447\u0442\u043e\s+\u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c\s+\u043f\u0435\u0440\u0432)/iu - }, - { - type: "what_is_it_grounded_on", - pattern: - /(?:\bwhat\s+is\s+it\s+grounded\s+on\b|\bgrounded\s+on\b|\bbased\s+on\b|\bwhat\s+evidence\b|\u043d\u0430\s+\u0447(?:\u0435|\u0451)\u043c\s+\u044d\u0442\u043e\s+\u043e\u0441\u043d\u043e\u0432\u0430\u043d|\u0447\u0435\u043c\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434)/iu - }, - { - type: "prove_or_guess", - pattern: - /(?:\bprove\b|\bguess\b|\bprove\s+or\s+guess\b|\bis\s+it\s+proven\b|\u044d\u0442\u043e\s+\u0434\u043e\u043a\u0430\u0437\u0430\u043d|\u0438\u043b\u0438\s+\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0433\u0438\u043f\u043e\u0442\u0435\u0437|\u0434\u043e\u043a\u0430\u0437\u0430\u043d|\u0434\u043e\u0433\u0430\u0434|\u0435\u0441\u0442\u044c\s+\u043b\u0438|\u043c\u043e\u0436\u0435\u0442\s+\u043b\u0438|\u044d\u0442\u043e\s+\u0443\u0436\u0435.*\u0438\u043b\u0438)/iu - }, - { - type: "which_chains_are_complete_vs_incomplete", - pattern: - /(?:\bcomplete(?:d)?\b.*\bincomplete\b|\bwhich\s+chains?\b|\bcomplete\s+vs\s+incomplete\b|\u043a\u0430\u043a\u0438\u0435\s+\u0446\u0435\u043f\u043e\u0447\u043a[аи]\s+.*\u0437\u0430\u0432\u0435\u0440\u0448|\u0447\u0442\u043e\s+\u0437\u0430\u043a\u0440\u044b\u0442\u043e.*\u0447\u0442\u043e\s+\u043d\u0435\u0442)/iu + priority: 1, + patterns: [ + /(?:\bwhat\s+to\s+check\s+first\b|\bfirst\s+check\b|\bcheck\s+first\b)/iu, + /(?:что\s+проверить\s+перв(?:ым|ой)|с\s+чего\s+начать\s+проверк|перв(?:ый|ым)\s+шаг(?:ом)?\s+проверк)/iu + ] }, { type: "where_break_is", - pattern: - /(?:\bwhere\s+is\s+the\s+break\b|\bwhere\s+exactly\b|\blocate\b|\u0433\u0434\u0435\s+\u0438\u043c\u0435\u043d\u043d\u043e|\u0433\u0434\u0435\s+\u0440\u0430\u0437\u0440\u044b\u0432|\u0432\s+\u043a\u0430\u043a\u043e\u043c\s+\u043c\u0435\u0441\u0442\u0435)/iu + priority: 2, + patterns: [ + /(?:\bwhere\s+is\s+the\s+break\b|\bwhere\s+exactly\b|\blocate(?:\s+the\s+break)?\b)/iu, + /(?:где\s+именно|где\s+разрыв|в\s+каком\s+месте|на\s+каком\s+этапе|локализ(?:овать|аци)|какой\s+узел)/iu + ] + }, + { + type: "which_chains_are_complete_vs_incomplete", + priority: 3, + patterns: [ + /(?:\bcomplete(?:d)?\b.*\bincomplete\b|\bwhich\s+chains?\b|\bcomplete\s+vs\s+incomplete\b)/iu, + /(?:какие(?:\s+\S+){0,4}\s+цепочк[аи].*(?:заверш|полны|неполны|не\s+заверш|подтвержд)|что\s+закрыто.*что\s+нет)/iu, + /(?:цепочк[аи].*(?:полная|неполная|частич|выпадени)|отраж[её]н\s+частич.*документ.*сч[её]т[-\s]?фактур)/iu + ] + }, + { + type: "prove_or_guess", + priority: 4, + patterns: [ + /(?:\bprove\b|\bguess\b|\bprove\s+or\s+guess\b|\bis\s+it\s+proven\b)/iu, + /(?:это\s+доказан|докаж(?:и|ите|ите\s+ли)|доказуем|доказательн|гипотез|догад(?:ка|ыв))/iu, + /(?:доказано\s+или\s+нет|похоже\s+или\s+доказано|зач[её]л(?:ся|ось)\s+ли|связан\s+ли|вс[её]\s+ли.*закрыл|больше\s+похоже.*или)/iu, + /(?:это\s+уже\s+[^?!.]{3,}\s+или|есть\s+ли\s+[^?!.]{0,80}ситуац[^?!.]{0,80}\s+где)/iu + ] + }, + { + type: "what_is_it_grounded_on", + priority: 5, + patterns: [ + /(?:\bwhat\s+is\s+it\s+grounded\s+on\b|\bgrounded\s+on\b|\bbased\s+on\b|\bwhat\s+evidence\b)/iu, + /(?:на\s+ч(?:е|ё)м[^?!.]{0,40}основан|чем\s+подтвержда(?:ется|но)|какие\s+основани[яе]|какими\s+доказательств)/iu, + /(?:есть\s+ли\s+[^?!.]{0,80}признак[аи])/iu + ] }, { type: "why_breaks", - pattern: - /(?:\bwhy\b|\bwhy\s+does\s+it\s+break\b|\u043f\u043e\u0447\u0435\u043c\u0443|\u0432\s+\u0447(?:\u0435|\u0451)\u043c\s+\u043f\u0440\u0438\u0447\u0438\u043d\u0430|\u0438\u0437-\u0437\u0430\s+\u0447\u0435\u0433\u043e)/iu + priority: 6, + patterns: [ + /(?:\bwhy\b|\bwhy\s+does\s+it\s+break\b|\bwhat\s+causes\b)/iu, + /(?:почему|в\s+ч(?:е|ё)м\s+причина|из-?за\s+чего|откуда\s+разрыв)/iu + ] } ]; +function countRuleHits(text: string, rule: QuestionTypeRule): number { + let hits = 0; + for (const pattern of rule.patterns) { + if (pattern.test(text)) { + hits += 1; + } + } + return hits; +} + export function resolveQuestionType(input: string): QuestionTypeClass { const text = String(input ?? "").trim(); if (!text) { return "unknown"; } + let bestType: QuestionTypeClass = "unknown"; + let bestHits = 0; + let bestPriority = Number.POSITIVE_INFINITY; + for (const rule of QUESTION_TYPE_RULES) { - if (rule.pattern.test(text)) { - return rule.type; + const hits = countRuleHits(text, rule); + if (hits <= 0) { + continue; + } + if (hits > bestHits || (hits === bestHits && rule.priority < bestPriority)) { + bestType = rule.type; + bestHits = hits; + bestPriority = rule.priority; } } - if (/[??]/u.test(text)) { + if (bestType !== "unknown") { + return bestType; + } + + if (/[?пјџ]/u.test(text)) { return "why_breaks"; } diff --git a/llm_normalizer/backend/tests/assistantWave13DomainFitQuestionTypeAnchorRegression.test.ts b/llm_normalizer/backend/tests/assistantWave13DomainFitQuestionTypeAnchorRegression.test.ts index 1476f6f..566c365 100644 --- a/llm_normalizer/backend/tests/assistantWave13DomainFitQuestionTypeAnchorRegression.test.ts +++ b/llm_normalizer/backend/tests/assistantWave13DomainFitQuestionTypeAnchorRegression.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { composeAssistantAnswer } from "../src/services/answerComposer"; import type { AnswerGroundingCheck, RequirementCoverageReport, UnifiedRetrievalResult } from "../src/types/assistant"; import type { ProblemUnit } from "../src/types/stage2ProblemUnits"; @@ -116,7 +116,7 @@ function buildRetrieval(input: { { source_entity: "Document", source_id: "DOC-1", - display_name: "Счет № 4 от 07.07.20", + display_name: "Счет в„– 4 РѕС‚ 07.07.20", account_context: accountScope, graph_domain_scope: domainScope, relation_pattern_hits: relationPatterns, @@ -177,7 +177,7 @@ function buildRetrieval(input: { limitation: null, payload: { notes: input.notes ?? [], - contract: "договор № 01/19-ПТ", + contract: "РґРѕРіРѕРІРѕСЂ в„– 01/19-РџРў", amount: "276 873,60", date: "07.07.20" } @@ -245,21 +245,21 @@ function composeCase(input: { focusDomainHint: input.focusDomainHint, questionTypeHint: input.questionType, companyAnchors: { - contract_numbers: ["договор № 01/19-ПТ"], - document_numbers: ["документ № 4"], + contract_numbers: ["РґРѕРіРѕРІРѕСЂ в„– 01/19-РџРў"], + document_numbers: ["документ в„– 4"], dates: ["07.07.20", "13.07.20"], amounts: ["276 873,60"], accounts: ["62.02"], - periods: ["июль 2020"], + periods: ["июль 2020"], document_types: ["payment", "invoice"], all: [ - "договор № 01/19-ПТ", - "документ № 4", + "РґРѕРіРѕРІРѕСЂ в„– 01/19-РџРў", + "документ в„– 4", "07.07.20", "13.07.20", "276 873,60", "account:62.02", - "period:июль 2020" + "period:июль 2020" ] }, enableAnswerPolicyV11: true, @@ -279,7 +279,7 @@ describe("wave13 domain fit + question-type fit + company-anchor grounding", () }); const output = composeCase({ userMessage: - "Почему по договору № 01/19-ПТ от 09.01.2019 оплата 276 873,60 есть, а 62.01/62.02 все равно не сходятся?", + "Почему РїРѕ РґРѕРіРѕРІРѕСЂСѓ в„– 01/19-РџРў РѕС‚ 09.01.2019 оплата 276 873,60 есть, Р° 62.01/62.02 РІСЃРµ равно РЅРµ сходятся?", questionType: "why_breaks", focusDomainHint: "vat_document_register_book", retrievalResults: [ @@ -294,8 +294,8 @@ describe("wave13 domain fit + question-type fit + company-anchor grounding", () ] }); - expect(output.assistant_reply).toMatch(/расчет|62\.01|62\.02|зачет|зачёт/i); - expect(output.assistant_reply).not.toMatch(/переход от документа к регистру и книге|цепочке ндс/i); + expect(output.assistant_reply).toMatch(/расчет|62\.01|62\.02|зачет|зачёт/i); + expect(output.assistant_reply).not.toMatch(/переход РѕС‚ документа Рє регистру Рё РєРЅРёРіРµ|цепочке РЅРґСЃ/i); }); it("question_type_where_break_is_must_produce_localization_style_line", () => { @@ -308,13 +308,13 @@ describe("wave13 domain fit + question-type fit + company-anchor grounding", () }); const output = composeCase({ userMessage: - "Где именно разрыв по договору № 01/19-ПТ: в договоре, объекте расчетов или в связке документов?", + "Где именно разрыв РїРѕ РґРѕРіРѕРІРѕСЂСѓ в„– 01/19-РџРў: РІ РґРѕРіРѕРІРѕСЂРµ, объекте расчетов или РІ СЃРІСЏР·РєРµ документов?", questionType: "where_break_is", focusDomainHint: "settlements_60_62", retrievalResults: [buildRetrieval({ requirementId: "R1", status: "ok", units: [settlementUnit] })] }); - expect(output.assistant_reply).toMatch(/локализ|узел разрыва|где именно/i); + expect(output.assistant_reply).toMatch(/локализ|узел разрыва|где/i); }); it("question_type_prove_or_guess_must_explicitly_separate_proven_vs_hypothesis", () => { @@ -327,7 +327,7 @@ describe("wave13 domain fit + question-type fit + company-anchor grounding", () }); const output = composeCase({ userMessage: - "По НДС это доказано по данным или это только гипотеза? На чем основано утверждение?", + "РџРѕ НДС это доказано РїРѕ данным или это только гипотеза? РќР° чем основано утверждение?", questionType: "prove_or_guess", focusDomainHint: "vat_document_register_book", retrievalResults: [ @@ -346,26 +346,26 @@ describe("wave13 domain fit + question-type fit + company-anchor grounding", () } }); - expect(output.assistant_reply).toMatch(/доказан|гипотез|ограничени/i); + expect(output.assistant_reply).toMatch(/доказ|гипотез|огранич/i); }); it("anchor_usage_lines_must_be_present_when_company_anchors_are_used", () => { const output = composeCase({ userMessage: - "Оплата по счету № 4 от 07.07.20 на 276 873,60 пришла 13 июля. Что проверить первым по 62.02?", + "Оплата РїРѕ счету в„– 4 РѕС‚ 07.07.20 РЅР° 276 873,60 пришла 13 июля. Что проверить первым РїРѕ 62.02?", questionType: "what_to_check_first", focusDomainHint: "settlements_60_62", retrievalResults: [buildRetrieval({ requirementId: "R1", status: "ok" })] }); - expect(output.assistant_reply).toMatch(/якоря вопроса/i); - expect(output.assistant_reply).toMatch(/договор|07\.07\.20|276 873,60|62\.02/i); + expect(output.assistant_reply).toMatch(/якоря вопроса|в опоре использованы|якоря из вопроса/i); + expect(output.assistant_reply).toMatch(/РґРѕРіРѕРІРѕСЂ|07\.07\.20|276 873,60|62\.02/i); }); it("anchor_usage_must_be_honest_when_part_of_anchors_not_confirmed", () => { const output = composeCase({ userMessage: - "Почему по договору № 01/19-ПТ не сходится 62.02 в июле 2020, если была оплата 276 873,60?", + "Почему РїРѕ РґРѕРіРѕРІРѕСЂСѓ в„– 01/19-РџРў РЅРµ сходится 62.02 РІ июле 2020, если была оплата 276 873,60?", questionType: "what_is_it_grounded_on", focusDomainHint: "settlements_60_62", retrievalResults: [ @@ -380,19 +380,19 @@ describe("wave13 domain fit + question-type fit + company-anchor grounding", () ] }); - expect(output.assistant_reply).toMatch(/без прямого подтверждения|ограничени/i); + expect(output.assistant_reply).toMatch(/без прямого подтверждения|огранич/i); }); it("answers_for_different_question_types_must_not_collapse_to_same_generic_pattern", () => { const baseRetrieval = [buildRetrieval({ requirementId: "R1", status: "ok" })]; const whereOutput = composeCase({ - userMessage: "Где именно разрыв по 62.01/62.02?", + userMessage: "Где именно разрыв РїРѕ 62.01/62.02?", questionType: "where_break_is", focusDomainHint: "settlements_60_62", retrievalResults: baseRetrieval }); const checkFirstOutput = composeCase({ - userMessage: "Что проверить первым по 62.01/62.02?", + userMessage: "Что проверить первым РїРѕ 62.01/62.02?", questionType: "what_to_check_first", focusDomainHint: "settlements_60_62", retrievalResults: baseRetrieval @@ -400,6 +400,9 @@ describe("wave13 domain fit + question-type fit + company-anchor grounding", () expect(whereOutput.assistant_reply).not.toEqual(checkFirstOutput.assistant_reply); expect(whereOutput.assistant_reply).toMatch(/локализ|разрыв/i); - expect(checkFirstOutput.assistant_reply).toMatch(/первый маршрут проверки|начните с первого пункта/i); + expect(checkFirstOutput.assistant_reply).toMatch( + /маршрут первых проверок|проверьте пункты по порядку|начните с первого пункта/i + ); }); }); + diff --git a/llm_normalizer/backend/tests/assistantWave15QuestionTypeContractFirstCheckRegression.test.ts b/llm_normalizer/backend/tests/assistantWave15QuestionTypeContractFirstCheckRegression.test.ts new file mode 100644 index 0000000..682385c --- /dev/null +++ b/llm_normalizer/backend/tests/assistantWave15QuestionTypeContractFirstCheckRegression.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, it } from "vitest"; +import { composeAssistantAnswer } from "../src/services/answerComposer"; +import type { AnswerGroundingCheck, RequirementCoverageReport, UnifiedRetrievalResult } from "../src/types/assistant"; +import type { ProblemUnit } from "../src/types/stage2ProblemUnits"; +import type { QuestionTypeClass } from "../src/services/questionTypeResolver"; + +function buildRouteSummary() { + return { + mode: "deterministic_v2" as const, + message_in_scope: true, + scope_confidence: "high" as const, + planner: { + total_fragments: 1, + in_scope_fragments: 1, + out_of_scope_fragments: 0, + discarded_fragments: 0, + contains_multiple_tasks: false + }, + decisions: [], + fallback: { + type: "none" as const, + message: null + } + }; +} + +function buildCoverage(input?: Partial): RequirementCoverageReport { + return { + requirements_total: 1, + requirements_covered: 1, + requirements_uncovered: [], + requirements_partially_covered: [], + clarification_needed_for: [], + out_of_scope_requirements: [], + ...input + }; +} + +function buildGrounding(input?: Partial): AnswerGroundingCheck { + return { + status: "grounded", + route_subject_match: true, + missing_requirements: [], + reasons: [], + why_included_summary: ["wave15-test"], + selection_reason_summary: ["wave15-test"], + ...input + }; +} + +function buildProblemUnit(input: { + id: string; + type: ProblemUnit["problem_unit_type"]; + account: string; + defect: string; + lifecycleDomain: ProblemUnit["lifecycle_domain"]; +}): ProblemUnit { + return { + schema_version: "problem_unit_v0_1", + problem_unit_id: input.id, + problem_unit_type: input.type, + title: "Wave15 problem unit", + mechanism_summary: `Mechanism candidate: ${input.defect}.`, + business_defect_class: input.defect, + severity: { + score: 0.74, + grade: "high" + }, + confidence: { + score: 0.66, + grade: "medium" + }, + lifecycle_domain: input.lifecycleDomain, + affected_entities: ["Document:DOC-1"], + affected_documents: ["Document:DOC-1"], + affected_postings: ["Posting:POST-1"], + affected_accounts: [input.account], + affected_counterparties: ["Counterparty:CP-1"], + affected_contracts: ["Contract:CTR-1"], + failed_expected_edge: input.defect, + period_impact: { + is_period_sensitive: true, + impact_class: "close_risk" + }, + evidence_pack: ["cand-1"], + entity_backlinks: [{ entity: "Document", id: "DOC-1" }], + snapshot_limitations: [] + }; +} + +function buildRetrieval(input: { + requirementId: string; + status: UnifiedRetrievalResult["status"]; + units?: ProblemUnit[]; + accountScope?: string[]; + domainScope?: string[]; + relationPatterns?: string[]; +}): UnifiedRetrievalResult { + const units = input.units ?? []; + const accountScope = input.accountScope ?? ["60", "62"]; + const domainScope = input.domainScope ?? ["customer_settlement"]; + const relationPatterns = input.relationPatterns ?? ["payment_to_settlement"]; + + return { + fragment_id: `F-${input.requirementId}`, + requirement_ids: [input.requirementId], + route: "hybrid_store_plus_live", + status: input.status, + result_type: "chain", + items: + input.status === "empty" + ? [] + : [ + { + source_entity: "Document", + source_id: "DOC-1", + display_name: "Счет №4 от 07.07.20", + account_context: accountScope, + graph_domain_scope: domainScope, + relation_pattern_hits: relationPatterns, + period: "2020-07", + amount: "276 873,60" + } + ], + summary: { + broad_query_detected: false, + broad_result_flag: false, + minimum_evidence_failed: false, + degraded_to: null, + narrowing_strength: "strong", + semantic_profile: { + account_scope: accountScope, + domain_scope: domainScope, + relation_patterns: relationPatterns, + period_scope: { + from: "2020-07-01", + to: "2020-07-31", + granularity: "month" + } + } + }, + evidence: + input.status === "empty" + ? [] + : [ + { + evidence_id: `ev-${input.requirementId}`, + claim_ref: `requirement:${input.requirementId}`, + source_type: "retrieval_item", + source_ref: { + schema_version: "evidence_source_ref_v1", + namespace: "snapshot_2020_07", + entity: "document", + id: "DOC-1", + period: "2020-07", + canonical_ref: "evidence_source_ref_v1|snapshot_2020_07|document|doc-1|2020-07" + }, + pointer: { + fragment_id: `F-${input.requirementId}`, + route: "hybrid_store_plus_live", + source: { + namespace: "snapshot_2020_07", + entity: "document", + id: "DOC-1", + period: "2020-07" + }, + locator: { + field_path: "risk_score", + item_index: 0 + } + }, + evidence_kind: "mechanism_link", + mechanism_note: relationPatterns[0], + confidence: "medium", + limitation: null, + payload: { + notes: ["wave15"], + contract: "договор № 01/19-ПТ", + amount: "276 873,60", + date: "07.07.20" + } + } + ], + candidate_evidence: [], + problem_units: units, + problem_unit_summary: + units.length > 0 + ? { + schema_version: "problem_unit_summary_v0_1", + units_total: units.length, + duplicate_collapses: 0, + unit_types: units.map((unit) => unit.problem_unit_type), + type_distribution: { + [units[0]?.problem_unit_type ?? "broken_chain_segment"]: units.length + }, + severity_distribution: { + low: 0, + medium: 0, + high: units.length + }, + confidence_distribution: { + low: 0, + medium: units.length, + high: 0 + }, + primary_unit_type: units[0]?.problem_unit_type ?? null + } + : null, + why_included: ["wave15-test"], + selection_reason: ["wave15-test"], + risk_factors: ["wave15"], + business_interpretation: ["wave15"], + confidence: "medium", + limitations: [], + errors: [] + }; +} + +function composeCase(input: { + userMessage: string; + questionType: QuestionTypeClass; + focusDomainHint: string | null; + retrievalResults: UnifiedRetrievalResult[]; + coverage?: Partial; + grounding?: Partial; +}) { + return composeAssistantAnswer({ + userMessage: input.userMessage, + routeSummary: buildRouteSummary(), + retrievalResults: input.retrievalResults, + requirements: [ + { + requirement_id: "R1", + source_fragment_id: "F-R1", + requirement_text: "Wave15 requirement", + subject_tokens: [], + status: "covered", + route: "hybrid_store_plus_live" + } + ], + coverageReport: buildCoverage(input.coverage), + groundingCheck: buildGrounding(input.grounding), + focusDomainHint: input.focusDomainHint, + questionTypeHint: input.questionType, + companyAnchors: { + contract_numbers: ["договор № 01/19-ПТ"], + document_numbers: ["документ № 4"], + dates: ["07.07.20"], + amounts: ["276 873,60"], + accounts: ["62.02"], + periods: ["июль 2020"], + document_types: ["payment"], + all: ["договор № 01/19-ПТ", "документ № 4", "07.07.20", "276 873,60", "account:62.02", "period:июль 2020"] + }, + enableAnswerPolicyV11: true, + enableProblemCentricAnswerV1: true, + enableLifecycleAnswerV1: true + }); +} + +describe("wave15 question-type contract + first-check relevance", () => { + const settlementUnit = buildProblemUnit({ + id: "pu-settlement-wave15", + type: "broken_chain_segment", + account: "62.02", + defect: "failed_edge:payment_to_settlement", + lifecycleDomain: "customer_settlement" + }); + + it("prove_or_guess answer keeps proof-vs-hypothesis framing", () => { + const output = composeCase({ + userMessage: "Это по расчетам доказано или пока гипотеза?", + questionType: "prove_or_guess", + focusDomainHint: "settlements_60_62", + retrievalResults: [buildRetrieval({ requirementId: "R1", status: "ok", units: [settlementUnit] })], + grounding: { + status: "partial", + reasons: ["Mechanism is unresolved for part of the evidence."] + } + }); + + expect(output.assistant_reply).toMatch(/доказ|гипотез|ограничен/i); + }); + + it("grounded_on answer is basis-oriented, not generic mechanism dump", () => { + const output = composeCase({ + userMessage: "На чем это основано по 62.02?", + questionType: "what_is_it_grounded_on", + focusDomainHint: "settlements_60_62", + retrievalResults: [buildRetrieval({ requirementId: "R1", status: "ok", units: [settlementUnit] })] + }); + + expect(output.assistant_reply).toMatch(/основан|опор|документ|регистр|60\/62\/76/i); + }); + + it("chains question provides complete-vs-incomplete framing", () => { + const output = composeCase({ + userMessage: "Какие цепочки завершены, а какие нет по 62.01/62.02?", + questionType: "which_chains_are_complete_vs_incomplete", + focusDomainHint: "settlements_60_62", + retrievalResults: [buildRetrieval({ requirementId: "R1", status: "ok", units: [settlementUnit] })], + coverage: { + requirements_covered: 0, + requirements_partially_covered: ["R1"], + requirements_uncovered: [] + } + }); + + expect(output.assistant_reply).toMatch(/цепочк|подтвержден|частично|не подтвержден/i); + }); + + it("where_break_is answer localizes break node", () => { + const output = composeCase({ + userMessage: "Где именно разрыв по расчетной цепочке?", + questionType: "where_break_is", + focusDomainHint: "settlements_60_62", + retrievalResults: [buildRetrieval({ requirementId: "R1", status: "ok", units: [settlementUnit] })] + }); + + expect(output.assistant_reply).toMatch(/узел разрыва|локализ|где/i); + }); + + it("what_to_check_first answer gives short operational route", () => { + const output = composeCase({ + userMessage: "Что проверить первым по 62.01/62.02?", + questionType: "what_to_check_first", + focusDomainHint: "settlements_60_62", + retrievalResults: [buildRetrieval({ requirementId: "R1", status: "ok", units: [settlementUnit] })] + }); + + expect(output.assistant_reply).toMatch(/что проверить первым/i); + expect(output.assistant_reply).toMatch(/договор|регистр|60\/62\/76/i); + }); +}); diff --git a/llm_normalizer/backend/tests/questionTypeResolver.test.ts b/llm_normalizer/backend/tests/questionTypeResolver.test.ts new file mode 100644 index 0000000..b8d504e --- /dev/null +++ b/llm_normalizer/backend/tests/questionTypeResolver.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { resolveQuestionType } from "../src/services/questionTypeResolver"; + +describe("questionTypeResolver", () => { + it("resolves what_to_check_first for operational check phrasing", () => { + expect(resolveQuestionType("Что проверить первым по 62.01/62.02?")) + .toBe("what_to_check_first"); + }); + + it("resolves where_break_is for localization phrasing", () => { + expect(resolveQuestionType("Где именно разрыв: в договоре или в связке документ -> регистр?")) + .toBe("where_break_is"); + }); + + it("resolves prove_or_guess without collapsing to generic yes/no", () => { + expect(resolveQuestionType("Это доказано или пока только гипотеза?")) + .toBe("prove_or_guess"); + }); + + it("resolves grounded_on when question asks for evidence basis", () => { + expect(resolveQuestionType("На чем это основано и чем подтверждается вывод?")) + .toBe("what_is_it_grounded_on"); + }); + + it("resolves chains completeness classification questions", () => { + expect(resolveQuestionType("Какие цепочки подтверждены, а какие не завершены?")) + .toBe("which_chains_are_complete_vs_incomplete"); + }); + + it("falls back to why_breaks for generic why-question", () => { + expect(resolveQuestionType("Почему не сходится 62.01/62.02?")) + .toBe("why_breaks"); + }); +}); diff --git a/llm_normalizer/docs/runs/2026-03-28_Stage_04_Wave_15_Question_Type_Contract_First_Check_Relevance.zip b/llm_normalizer/docs/runs/2026-03-28_Stage_04_Wave_15_Question_Type_Contract_First_Check_Relevance.zip new file mode 100644 index 0000000..3aab8fc Binary files /dev/null and b/llm_normalizer/docs/runs/2026-03-28_Stage_04_Wave_15_Question_Type_Contract_First_Check_Relevance.zip differ