From 88a7da4f0af08291aaf870cb5e8c5398a8c63708 Mon Sep 17 00:00:00 2001 From: dctouch Date: Sun, 12 Apr 2026 01:17:56 +0300 Subject: [PATCH] =?UTF-8?q?=D0=93=D0=9B=D0=9E=D0=91=D0=90=D0=9B=D0=AC?= =?UTF-8?q?=D0=9D=D0=AB=D0=99=20=D0=A0=D0=95=D0=A4=D0=90=D0=9A=D0=A2=D0=9E?= =?UTF-8?q?=D0=A0=D0=98=D0=9D=D0=93=20=D0=90=D0=A0=D0=A5=D0=98=D0=A2=D0=95?= =?UTF-8?q?=D0=9A=D0=A2=D0=A3=D0=A0=D0=AB=20-=20=D0=AD=D1=82=D0=B0=D0=BF?= =?UTF-8?q?=204.3:=20=D1=83=D0=BD=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=BE=D0=B2=20=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=B0=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B?= =?UTF-8?q?=20Stage=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/TECH/1CLLMARCH-FACT.md | 14 +- .../backend/dist/services/answerComposer.js | 125 +++++----- .../backend/src/services/answerComposer.ts | 211 +++++++++-------- ...assistantStage4AnswerContractShape.test.ts | 220 ++++++++++++++++++ 4 files changed, 403 insertions(+), 167 deletions(-) create mode 100644 llm_normalizer/backend/tests/assistantStage4AnswerContractShape.test.ts diff --git a/docs/TECH/1CLLMARCH-FACT.md b/docs/TECH/1CLLMARCH-FACT.md index 5b49063..65b8aa4 100644 --- a/docs/TECH/1CLLMARCH-FACT.md +++ b/docs/TECH/1CLLMARCH-FACT.md @@ -2667,7 +2667,19 @@ Implemented in current pass (Stage 4.2 boundary fallback contract alignment, 202 - focused Stage 4 pack passed: `4 files / 16 tests` (`assistantBoundaryFallbackReply`, `assistantSoftPolicyReply`, `assistantAnswerPolicyV11`, `assistantWave17RunRegression20260411`); - `npm --prefix llm_normalizer/backend run build` passed. -Status: In progress (Stage 4.1-4.2 completed; continue with quality loop on real runs/manual comments) +Implemented in current pass (Stage 4.3 contract consistency hardening, 2026-04-12): +1. Added unified Stage 4 reply renderer in answer policy layer to avoid section-shape drift across: + - focused grounded replies; + - weak/soft policy replies; + - boundary fallback replies. +2. Added explicit contract regression pack: + - `assistantStage4AnswerContractShape.test.ts` + - validates required Stage 4 blocks and guards against legacy section rollback (`Что сломано`, `Почему это похоже на проблему`, `На чем это основано`, `Ограничения`). +3. Validation snapshot: + - focused Stage 4 contract pack passed: `4 files / 14 tests` (`assistantStage4AnswerContractShape`, `assistantBoundaryFallbackReply`, `assistantSoftPolicyReply`, `assistantAnswerPolicyV11`); + - `npm --prefix llm_normalizer/backend run build` passed. + +Status: In progress (Stage 4.1-4.3 completed; continue with quality loop on real runs/manual comments) ## Stage 5 (P3): Quality Loop Driven By GUI Markup diff --git a/llm_normalizer/backend/dist/services/answerComposer.js b/llm_normalizer/backend/dist/services/answerComposer.js index edb8caf..2ad1a13 100644 --- a/llm_normalizer/backend/dist/services/answerComposer.js +++ b/llm_normalizer/backend/dist/services/answerComposer.js @@ -628,6 +628,30 @@ function formatList(items) { } return items.map((item) => `- ${item}`).join("\n"); } +function renderStage4ContractReply(input) { + const shortLine = ensureSentence(input.shortLine); + const checkedLines = dedupeNarrativeLines(input.checkedLines, 6); + const foundLines = dedupeNarrativeLines(input.foundLines, 6); + const unresolvedLines = dedupeNarrativeLines(input.unresolvedLines, 6); + const nextStepLines = dedupeNarrativeLines(input.nextStepLines, 5); + const contextLead = ensureSentence(String(input.contextLead ?? "")); + return sanitizeUserFacingReply([ + `Коротко: ${shortLine}`, + contextLead || "", + `Что именно проверено:\n${formatList(checkedLines.length > 0 + ? checkedLines + : ["Подтвержденная опора собрана частично; для полного вывода нужен дополнительный проход."])}`, + `Что найдено:\n${formatList(foundLines.length > 0 ? foundLines : ["Явные отклонения по текущей опоре не подтверждены."])}`, + `Что пока не доказано:\n${formatList(unresolvedLines.length > 0 + ? unresolvedLines + : ["Существенных ограничений в текущем срезе не выявлено."])}`, + `${input.nextStepTitle}:\n${formatList(nextStepLines.length > 0 + ? nextStepLines + : ["Уточните период, объект или контрагента, чтобы продолжить проверку по 1С."])}` + ] + .filter(Boolean) + .join("\n\n")); +} function formatSafeItemLine(entity, sourceId, riskScore) { const entityLabel = sanitizeUserText(String(entity ?? "")) ?? "Record"; const idRaw = String(sourceId ?? "").trim(); @@ -1699,24 +1723,21 @@ function buildBoundaryFallbackReply(input) { "По этому запросу у меня нет надежного доменного покрытия, поэтому даю мягкий отказ вместо технического шаблона.", "Сейчас у меня нет надежного доменного маршрута по этому запросу, поэтому даю мягкий отказ вместо шаблонной технички." ]); - return sanitizeUserFacingReply([ - `Коротко: ${ensureSentence(heading)}`, - `Что именно проверено:\n${formatList([ + return renderStage4ContractReply({ + shortLine: heading, + checkedLines: [ "Проверен доступный контур 1С и возможность маршрутизации запроса.", "Надежный доменный путь для текущей формулировки не подтвержден." - ])}`, - `Что найдено:\n${formatList(nearbyCapabilities.length > 0 + ], + foundLines: nearbyCapabilities.length > 0 ? nearbyCapabilities - : ["Близких поддерживаемых сценариев по текущей формулировке не найдено."])}`, - `Что пока не доказано:\n${formatList([ - "Запрос не подтвержден в поддерживаемом контуре как надежный бухгалтерский сценарий." - ])}`, - `Что могу сделать сейчас:\n${formatList(quickActionItems.length > 0 + : ["Близких поддерживаемых сценариев по текущей формулировке не найдено."], + unresolvedLines: ["Запрос не подтвержден в поддерживаемом контуре как надежный бухгалтерский сценарий."], + nextStepLines: quickActionItems.length > 0 ? quickActionItems - : [quickActionLine ?? "Переформулируйте вопрос через период, счет или объект, и я сразу продолжу проверку."])}` - ] - .filter(Boolean) - .join("\n\n")); + : [quickActionLine ?? "Переформулируйте вопрос через период, счет или объект, и я сразу продолжу проверку."], + nextStepTitle: "Что могу сделать сейчас" + }); } const clarificationHints = buildNaturalClarificationHints({ missingAnchors: input.missingAnchors, @@ -1726,24 +1747,23 @@ function buildBoundaryFallbackReply(input) { `Сейчас не могу надежно ответить по сценарию ${formatNarrativeDomainLabel(input.focusDomain)}: не хватает опоры.`, `По сценарию ${formatNarrativeDomainLabel(input.focusDomain)} пока не хватает подтвержденной опоры для надежного вывода.` ]); - return sanitizeUserFacingReply([ - `Коротко: ${ensureSentence(domainHeading)}`, - `Что именно проверено:\n${formatList([ + return renderStage4ContractReply({ + shortLine: domainHeading, + checkedLines: [ `Проверен сценарий ${formatNarrativeDomainLabel(input.focusDomain)} в текущем контуре 1С.`, "Оценена достаточность подтвержденной опоры для прямого вывода." - ])}`, - `Что найдено:\n${formatList(nearbyCapabilities.length > 0 + ], + foundLines: nearbyCapabilities.length > 0 ? nearbyCapabilities.slice(0, 2) - : ["Подтвержденных близких сценариев в текущем проходе не найдено."])}`, - `Что пока не доказано:\n${formatList(clarificationHints.length > 0 + : ["Подтвержденных близких сценариев в текущем проходе не найдено."], + unresolvedLines: clarificationHints.length > 0 ? clarificationHints - : ["Без дополнительного ориентира (период, объект или контрагент) вывод останется частичным."])}`, - `Что могу сделать сейчас:\n${formatList(quickActionItems.length > 0 + : ["Без дополнительного ориентира (период, объект или контрагент) вывод останется частичным."], + nextStepLines: quickActionItems.length > 0 ? quickActionItems - : [quickActionLine ?? "Добавьте один уточняющий ориентир, и я продолжу проверку без смены контура."])}` - ] - .filter(Boolean) - .join("\n\n")); + : [quickActionLine ?? "Добавьте один уточняющий ориентир, и я продолжу проверку без смены контура."], + nextStepTitle: "Что могу сделать сейчас" + }); } function ensureSentence(value) { const sanitized = sanitizeUserText(value) ?? String(value ?? "").trim(); @@ -3677,25 +3697,14 @@ function renderPolicyReply(structure, context) { limitationLines }; const enriched = enforceDomainWordingIsolation(enrichedBase, structure, context); - const checkedLines = dedupeNarrativeLines(enriched.evidenceLines, 6); - const foundLines = dedupeNarrativeLines([...enriched.brokenLines, ...enriched.whyLines], 6); - const unresolvedLines = dedupeNarrativeLines(enriched.limitationLines, 6); - const nextStepLines = dedupeNarrativeLines(enriched.checkLines, 5); - return sanitizeUserFacingReply([ - `Коротко: ${enriched.shortLine}`, - `Что именно проверено:\n${formatList(checkedLines.length > 0 - ? checkedLines - : ["Подтвержденная опора собрана частично; для полного вывода нужен дополнительный проход."])}`, - `Что найдено:\n${formatList(foundLines.length > 0 ? foundLines : ["Явные отклонения по текущей опоре не подтверждены."])}`, - `Что пока не доказано:\n${formatList(unresolvedLines.length > 0 - ? unresolvedLines - : ["Существенных ограничений в текущем срезе не выявлено."])}`, - `Что проверить первым:\n${formatList(nextStepLines.length > 0 - ? nextStepLines - : ["Уточните период, объект или контрагента, чтобы продолжить проверку по 1С."])}` - ] - .filter(Boolean) - .join("\n\n")); + return renderStage4ContractReply({ + shortLine: enriched.shortLine, + checkedLines: enriched.evidenceLines, + foundLines: [...enriched.brokenLines, ...enriched.whyLines], + unresolvedLines: enriched.limitationLines, + nextStepLines: enriched.checkLines, + nextStepTitle: "Что проверить первым" + }); } function shouldUseSoftPolicyReply(input) { if (input.mode === "focused_grounded" || input.mode === "route_mismatch" || input.mode === "backend_error" || input.mode === "out_of_scope") { @@ -3735,22 +3744,18 @@ function renderSoftPolicyReply(input) { : input.mode === "no_grounded" || input.mode === "empty" ? "Сейчас подтвержденной опоры недостаточно для прямого вывода." : "Есть рабочие сигналы, но часть вывода пока ограничена."; - return sanitizeUserFacingReply([ - `Коротко: ${shortLine}`, - modeLine, - `Что именно проверено:\n${formatList(evidenceLines.length > 0 + return renderStage4ContractReply({ + shortLine, + checkedLines: evidenceLines.length > 0 ? evidenceLines - : ["Подтвержденная опора собрана частично; для полного вывода нужна дополнительная проверка."])}`, - `Что найдено:\n${formatList(foundLines.length > 0 ? foundLines : ["Пока подтверждена только часть сигнала, без финальной фиксации причины."])}`, - `Что пока не доказано:\n${formatList(limitationLines.length > 0 - ? limitationLines - : ["Для полного вывода не хватает деталей по части требований."])}`, - `Что могу сделать сейчас:\n${formatList(actionLines.length > 0 + : ["Подтвержденная опора собрана частично; для полного вывода нужна дополнительная проверка."], + foundLines: foundLines.length > 0 ? foundLines : ["Пока подтверждена только часть сигнала, без финальной фиксации причины."], + unresolvedLines: limitationLines.length > 0 ? [modeLine, ...limitationLines] : [modeLine, "Для полного вывода не хватает деталей по части требований."], + nextStepLines: actionLines.length > 0 ? actionLines - : ["Уточните период, объект или контрагента, чтобы завершить проверку в следующем ходе."])}` - ] - .filter(Boolean) - .join("\n\n")); + : ["Уточните период, объект или контрагента, чтобы завершить проверку в следующем ходе."], + nextStepTitle: "Что могу сделать сейчас" + }); } function composeAssistantAnswerV11(input) { const fallbackType = fallbackFromSummary(input.routeSummary); diff --git a/llm_normalizer/backend/src/services/answerComposer.ts b/llm_normalizer/backend/src/services/answerComposer.ts index ffe94d6..fc6b01b 100644 --- a/llm_normalizer/backend/src/services/answerComposer.ts +++ b/llm_normalizer/backend/src/services/answerComposer.ts @@ -733,6 +733,50 @@ function formatList(items: string[]): string { return items.map((item) => `- ${item}`).join("\n"); } +function renderStage4ContractReply(input: { + shortLine: string; + checkedLines: string[]; + foundLines: string[]; + unresolvedLines: string[]; + nextStepLines: string[]; + nextStepTitle: "Что проверить первым" | "Что могу сделать сейчас"; + contextLead?: string | null; +}): string { + const shortLine = ensureSentence(input.shortLine); + const checkedLines = dedupeNarrativeLines(input.checkedLines, 6); + const foundLines = dedupeNarrativeLines(input.foundLines, 6); + const unresolvedLines = dedupeNarrativeLines(input.unresolvedLines, 6); + const nextStepLines = dedupeNarrativeLines(input.nextStepLines, 5); + const contextLead = ensureSentence(String(input.contextLead ?? "")); + + return sanitizeUserFacingReply( + [ + `Коротко: ${shortLine}`, + contextLead || "", + `Что именно проверено:\n${formatList( + checkedLines.length > 0 + ? checkedLines + : ["Подтвержденная опора собрана частично; для полного вывода нужен дополнительный проход."] + )}`, + `Что найдено:\n${formatList( + foundLines.length > 0 ? foundLines : ["Явные отклонения по текущей опоре не подтверждены."] + )}`, + `Что пока не доказано:\n${formatList( + unresolvedLines.length > 0 + ? unresolvedLines + : ["Существенных ограничений в текущем срезе не выявлено."] + )}`, + `${input.nextStepTitle}:\n${formatList( + nextStepLines.length > 0 + ? nextStepLines + : ["Уточните период, объект или контрагента, чтобы продолжить проверку по 1С."] + )}` + ] + .filter(Boolean) + .join("\n\n") + ); +} + function formatSafeItemLine(entity: unknown, sourceId: unknown, riskScore?: unknown): string { const entityLabel = sanitizeUserText(String(entity ?? "")) ?? "Record"; const idRaw = String(sourceId ?? "").trim(); @@ -2023,30 +2067,23 @@ function buildBoundaryFallbackReply(input: { "Сейчас у меня нет надежного доменного маршрута по этому запросу, поэтому даю мягкий отказ вместо шаблонной технички." ] ); - return sanitizeUserFacingReply( - [ - `Коротко: ${ensureSentence(heading)}`, - `Что именно проверено:\n${formatList([ - "Проверен доступный контур 1С и возможность маршрутизации запроса.", - "Надежный доменный путь для текущей формулировки не подтвержден." - ])}`, - `Что найдено:\n${formatList( - nearbyCapabilities.length > 0 - ? nearbyCapabilities - : ["Близких поддерживаемых сценариев по текущей формулировке не найдено."] - )}`, - `Что пока не доказано:\n${formatList([ - "Запрос не подтвержден в поддерживаемом контуре как надежный бухгалтерский сценарий." - ])}`, - `Что могу сделать сейчас:\n${formatList( - quickActionItems.length > 0 - ? quickActionItems - : [quickActionLine ?? "Переформулируйте вопрос через период, счет или объект, и я сразу продолжу проверку."] - )}` - ] - .filter(Boolean) - .join("\n\n") - ); + return renderStage4ContractReply({ + shortLine: heading, + checkedLines: [ + "Проверен доступный контур 1С и возможность маршрутизации запроса.", + "Надежный доменный путь для текущей формулировки не подтвержден." + ], + foundLines: + nearbyCapabilities.length > 0 + ? nearbyCapabilities + : ["Близких поддерживаемых сценариев по текущей формулировке не найдено."], + unresolvedLines: ["Запрос не подтвержден в поддерживаемом контуре как надежный бухгалтерский сценарий."], + nextStepLines: + quickActionItems.length > 0 + ? quickActionItems + : [quickActionLine ?? "Переформулируйте вопрос через период, счет или объект, и я сразу продолжу проверку."], + nextStepTitle: "Что могу сделать сейчас" + }); } const clarificationHints = buildNaturalClarificationHints({ @@ -2060,32 +2097,26 @@ function buildBoundaryFallbackReply(input: { `По сценарию ${formatNarrativeDomainLabel(input.focusDomain)} пока не хватает подтвержденной опоры для надежного вывода.` ] ); - return sanitizeUserFacingReply( - [ - `Коротко: ${ensureSentence(domainHeading)}`, - `Что именно проверено:\n${formatList([ - `Проверен сценарий ${formatNarrativeDomainLabel(input.focusDomain)} в текущем контуре 1С.`, - "Оценена достаточность подтвержденной опоры для прямого вывода." - ])}`, - `Что найдено:\n${formatList( - nearbyCapabilities.length > 0 - ? nearbyCapabilities.slice(0, 2) - : ["Подтвержденных близких сценариев в текущем проходе не найдено."] - )}`, - `Что пока не доказано:\n${formatList( - clarificationHints.length > 0 - ? clarificationHints - : ["Без дополнительного ориентира (период, объект или контрагент) вывод останется частичным."] - )}`, - `Что могу сделать сейчас:\n${formatList( - quickActionItems.length > 0 - ? quickActionItems - : [quickActionLine ?? "Добавьте один уточняющий ориентир, и я продолжу проверку без смены контура."] - )}` - ] - .filter(Boolean) - .join("\n\n") - ); + return renderStage4ContractReply({ + shortLine: domainHeading, + checkedLines: [ + `Проверен сценарий ${formatNarrativeDomainLabel(input.focusDomain)} в текущем контуре 1С.`, + "Оценена достаточность подтвержденной опоры для прямого вывода." + ], + foundLines: + nearbyCapabilities.length > 0 + ? nearbyCapabilities.slice(0, 2) + : ["Подтвержденных близких сценариев в текущем проходе не найдено."], + unresolvedLines: + clarificationHints.length > 0 + ? clarificationHints + : ["Без дополнительного ориентира (период, объект или контрагент) вывод останется частичным."], + nextStepLines: + quickActionItems.length > 0 + ? quickActionItems + : [quickActionLine ?? "Добавьте один уточняющий ориентир, и я продолжу проверку без смены контура."], + nextStepTitle: "Что могу сделать сейчас" + }); } function ensureSentence(value: string): string { @@ -4377,36 +4408,14 @@ function renderPolicyReply(structure: AnswerStructureV11, context?: AnswerRender limitationLines }; const enriched = enforceDomainWordingIsolation(enrichedBase, structure, context); - const checkedLines = dedupeNarrativeLines(enriched.evidenceLines, 6); - const foundLines = dedupeNarrativeLines([...enriched.brokenLines, ...enriched.whyLines], 6); - const unresolvedLines = dedupeNarrativeLines(enriched.limitationLines, 6); - const nextStepLines = dedupeNarrativeLines(enriched.checkLines, 5); - - return sanitizeUserFacingReply( - [ - `Коротко: ${enriched.shortLine}`, - `Что именно проверено:\n${formatList( - checkedLines.length > 0 - ? checkedLines - : ["Подтвержденная опора собрана частично; для полного вывода нужен дополнительный проход."] - )}`, - `Что найдено:\n${formatList( - foundLines.length > 0 ? foundLines : ["Явные отклонения по текущей опоре не подтверждены."] - )}`, - `Что пока не доказано:\n${formatList( - unresolvedLines.length > 0 - ? unresolvedLines - : ["Существенных ограничений в текущем срезе не выявлено."] - )}`, - `Что проверить первым:\n${formatList( - nextStepLines.length > 0 - ? nextStepLines - : ["Уточните период, объект или контрагента, чтобы продолжить проверку по 1С."] - )}` - ] - .filter(Boolean) - .join("\n\n") - ); + return renderStage4ContractReply({ + shortLine: enriched.shortLine, + checkedLines: enriched.evidenceLines, + foundLines: [...enriched.brokenLines, ...enriched.whyLines], + unresolvedLines: enriched.limitationLines, + nextStepLines: enriched.checkLines, + nextStepTitle: "Что проверить первым" + }); } function shouldUseSoftPolicyReply(input: { @@ -4468,32 +4477,22 @@ function renderSoftPolicyReply(input: { : input.mode === "no_grounded" || input.mode === "empty" ? "Сейчас подтвержденной опоры недостаточно для прямого вывода." : "Есть рабочие сигналы, но часть вывода пока ограничена."; - return sanitizeUserFacingReply( - [ - `Коротко: ${shortLine}`, - modeLine, - `Что именно проверено:\n${formatList( - evidenceLines.length > 0 - ? evidenceLines - : ["Подтвержденная опора собрана частично; для полного вывода нужна дополнительная проверка."] - )}`, - `Что найдено:\n${formatList( - foundLines.length > 0 ? foundLines : ["Пока подтверждена только часть сигнала, без финальной фиксации причины."] - )}`, - `Что пока не доказано:\n${formatList( - limitationLines.length > 0 - ? limitationLines - : ["Для полного вывода не хватает деталей по части требований."] - )}`, - `Что могу сделать сейчас:\n${formatList( - actionLines.length > 0 - ? actionLines - : ["Уточните период, объект или контрагента, чтобы завершить проверку в следующем ходе."] - )}` - ] - .filter(Boolean) - .join("\n\n") - ); + return renderStage4ContractReply({ + shortLine, + checkedLines: + evidenceLines.length > 0 + ? evidenceLines + : ["Подтвержденная опора собрана частично; для полного вывода нужна дополнительная проверка."], + foundLines: + foundLines.length > 0 ? foundLines : ["Пока подтверждена только часть сигнала, без финальной фиксации причины."], + unresolvedLines: + limitationLines.length > 0 ? [modeLine, ...limitationLines] : [modeLine, "Для полного вывода не хватает деталей по части требований."], + nextStepLines: + actionLines.length > 0 + ? actionLines + : ["Уточните период, объект или контрагента, чтобы завершить проверку в следующем ходе."], + nextStepTitle: "Что могу сделать сейчас" + }); } function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutput { diff --git a/llm_normalizer/backend/tests/assistantStage4AnswerContractShape.test.ts b/llm_normalizer/backend/tests/assistantStage4AnswerContractShape.test.ts new file mode 100644 index 0000000..14103ed --- /dev/null +++ b/llm_normalizer/backend/tests/assistantStage4AnswerContractShape.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from "vitest"; +import { composeAssistantAnswer } from "../src/services/answerComposer"; +import type { UnifiedRetrievalResult } from "../src/types/assistant"; + +function routeSummary() { + 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 expectStage4Blocks(reply: string) { + expect(reply).toContain("Коротко:"); + expect(reply).toContain("Что именно проверено:"); + expect(reply).toContain("Что найдено:"); + expect(reply).toContain("Что пока не доказано:"); + expect(reply).not.toContain("Что сломано:"); + expect(reply).not.toContain("Почему это похоже на проблему:"); + expect(reply).not.toContain("На чем это основано:"); + expect(reply).not.toContain("Ограничения:"); +} + +describe("assistant stage4 answer contract shape", () => { + it("uses Stage 4 blocks for focused grounded policy reply", () => { + const retrieval: UnifiedRetrievalResult = { + fragment_id: "F1", + requirement_ids: ["R1"], + route: "store_feature_risk", + status: "ok", + result_type: "summary", + items: [{ doc: "A-1" }], + summary: { + broad_query_detected: false, + broad_result_flag: false, + minimum_evidence_failed: false, + narrowing_strength: "strong" + }, + evidence: [ + { + evidence_id: "ev-1", + claim_ref: "requirement:R1", + source_type: "retrieval_item", + source_ref: { + schema_version: "evidence_source_ref_v1", + namespace: "snapshot_2020", + entity: "document", + id: "A-1", + period: "2020-06", + canonical_ref: "evidence_source_ref_v1|snapshot_2020|document|A-1|2020-06" + }, + pointer: { + fragment_id: "F1", + route: "store_feature_risk", + source: { + namespace: "snapshot_2020", + entity: "document", + id: "A-1", + period: "2020-06" + } + }, + evidence_kind: "mechanism_link", + mechanism_note: "Переход подтвержден документом и проводкой.", + confidence: "high", + payload: { + amount: 100 + } + } + ], + why_included: ["релевантная запись"], + selection_reason: ["сильное совпадение"], + risk_factors: [], + business_interpretation: [], + confidence: "high", + limitations: [], + errors: [] + }; + + const output = composeAssistantAnswer({ + userMessage: "Проверь документ A-1 за июнь 2020.", + routeSummary: routeSummary(), + retrievalResults: [retrieval], + requirements: [ + { + requirement_id: "R1", + source_fragment_id: "F1", + requirement_text: "проверить документ", + subject_tokens: [], + status: "covered", + route: "store_feature_risk" + } + ], + coverageReport: { + requirements_total: 1, + requirements_covered: 1, + requirements_uncovered: [], + requirements_partially_covered: [], + clarification_needed_for: [], + out_of_scope_requirements: [] + }, + groundingCheck: { + status: "grounded", + route_subject_match: true, + missing_requirements: [], + reasons: [], + why_included_summary: ["релевантная запись"], + selection_reason_summary: ["сильное совпадение"] + }, + enableAnswerPolicyV11: true + }); + + expect(output.reply_type).toBe("factual_with_explanation"); + expect(output.assistant_reply).toContain("Что проверить первым:"); + expectStage4Blocks(output.assistant_reply); + }); + + it("uses Stage 4 blocks for weak broad-partial soft reply", () => { + const retrieval: UnifiedRetrievalResult = { + fragment_id: "F1", + requirement_ids: ["R1"], + route: "store_feature_risk", + status: "partial", + result_type: "summary", + items: [{ note: "weak candidate" }], + summary: { + broad_query_detected: true, + broad_result_flag: true, + minimum_evidence_failed: true, + narrowing_strength: "weak" + }, + evidence: [], + why_included: [], + selection_reason: [], + risk_factors: [], + business_interpretation: [], + confidence: "low", + limitations: ["Weak evidence envelope"], + errors: [] + }; + + const output = composeAssistantAnswer({ + userMessage: "Покажи общую картину рисков и аномалий по документам.", + routeSummary: routeSummary(), + retrievalResults: [retrieval], + requirements: [ + { + requirement_id: "R1", + source_fragment_id: "F1", + requirement_text: "общая картина рисков", + subject_tokens: [], + status: "partially_covered", + route: "store_feature_risk" + } + ], + coverageReport: { + requirements_total: 1, + requirements_covered: 0, + requirements_uncovered: [], + requirements_partially_covered: ["R1"], + clarification_needed_for: [], + out_of_scope_requirements: [] + }, + groundingCheck: { + status: "partial", + route_subject_match: true, + missing_requirements: ["R1"], + reasons: ["insufficient_detail"], + why_included_summary: [], + selection_reason_summary: [] + }, + enableAnswerPolicyV11: true + }); + + expect(output.reply_type).toBe("partial_coverage"); + expect(output.assistant_reply).toContain("Что могу сделать сейчас:"); + expectStage4Blocks(output.assistant_reply); + }); + + it("uses Stage 4 blocks for boundary fallback reply", () => { + const output = composeAssistantAnswer({ + userMessage: "Скажи курс доллара на завтра и дай прогноз инфляции.", + routeSummary: routeSummary(), + retrievalResults: [], + requirements: [], + coverageReport: { + requirements_total: 0, + requirements_covered: 0, + requirements_uncovered: [], + requirements_partially_covered: [], + clarification_needed_for: [], + out_of_scope_requirements: [] + }, + groundingCheck: { + status: "no_grounded_answer", + route_subject_match: false, + missing_requirements: [], + reasons: ["no grounded support"], + why_included_summary: [], + selection_reason_summary: [] + }, + enableAnswerPolicyV11: true + }); + + expect(output.reply_type).toBe("clarification_required"); + expect(output.assistant_reply).toContain("Что могу сделать сейчас:"); + expectStage4Blocks(output.assistant_reply); + }); +});