diff --git a/docs/accounting-assistant/accounting-assistant/03_execution/STAGE_04_TAIL_PACK_SETTLEMENT_POLARITY_AND_LANE_SEPARATION.md b/docs/accounting-assistant/accounting-assistant/03_execution/STAGE_04_TAIL_PACK_SETTLEMENT_POLARITY_AND_LANE_SEPARATION.md new file mode 100644 index 0000000..07125ed --- /dev/null +++ b/docs/accounting-assistant/accounting-assistant/03_execution/STAGE_04_TAIL_PACK_SETTLEMENT_POLARITY_AND_LANE_SEPARATION.md @@ -0,0 +1,48 @@ +# Stage 4 — Tail Pack: settlement polarity + month_close/RBP lane separation + +## Цель +- Добить два хвоста Stage 4 без расширения доменов и без нового архитектурного слоя: +- `settlements_60_62`: снять `unresolved_supplier_customer_polarity` и `business_scope_generic_unresolved` на core-set. +- Развести acceptance по разным lane: `rbp_tail` и `month_close_indirect_costs` не должны жить в одном итоговом export. + +## Что реализовано в runtime +- Усилен `DomainPolarityGuard` для settlement-кейсов: +- разрешение по доминирующим сигналам и account-prefix (60/62), чтобы шумовые лексемы не уводили в `mixed_or_unresolved`. +- В claim-bound debug добавлены поля: +- `settlement_role` +- `settlement_role_resolution_reason` +- `polarity_resolution_status` +- Усилен recovery business-scope в live-контуре: +- для settlement claims добавлен `settlement_claim_company_scope_recovery` (кроме уже существующего temporal recovery). +- В `assistant debug/log` добавлен явный экспорт settlement-полей для audit и run-артефактов. + +## Tail-pack runner +- Добавлен скрипт: +- `llm_normalizer/backend/scripts/stage4TailPackFamilyIsolation.js` +- Скрипт выполняет раздельные lane-прогоны: +- `settlements_60_62` (4 кейса) +- `rbp_tail` (3 кейса) +- `month_close_indirect_costs` (3 кейса) +- И собирает обязательные артефакты: +- `run_summary.json` +- `settlement_polarity_report.md` +- `settlement_scope_resolution_report.md` +- `lane_separation_report.md` +- `family_acceptance_structure_report.md` +- `chat_export_settlements.txt` +- `chat_export_rbp.txt` +- `chat_export_month_close_lane.txt` +- `debug_payloads/` + +## Команда запуска +```bash +node llm_normalizer/backend/scripts/stage4TailPackFamilyIsolation.js --use-mock false --prompt-version normalizer_v2_0_2 +``` + +## Acceptance logic (внутри run_summary) +- `SETTLEMENT_POLARITY_FIXED` +- `SETTLEMENT_SCOPE_RESOLUTION_FIXED` +- `MONTH_CLOSE_RBP_LANE_SEPARATION_FIXED` +- Общий статус: +- `STAGE4_TAIL_PACK_ACCEPTED` +- или `STAGE4_TAIL_PACK_ACCEPTED_WITH_LIMITATIONS` diff --git a/llm_normalizer/backend/dist/services/assistantClaimBoundEvidence.js b/llm_normalizer/backend/dist/services/assistantClaimBoundEvidence.js index b77f772..7af4aaf 100644 --- a/llm_normalizer/backend/dist/services/assistantClaimBoundEvidence.js +++ b/llm_normalizer/backend/dist/services/assistantClaimBoundEvidence.js @@ -319,12 +319,27 @@ function resolveClaimBoundAnchors(input) { if (!allowedContextWindow && input.primaryPeriod) { reasonCodes.push("controlled_temporal_expansion_window_unavailable"); } - const settlementRole = resolveSettlementRole({ + const settlementRoleRaw = resolveSettlementRole({ claimType, counterpartyScope: resolvedAnchors.counterparty_scope ?? [], accountPrefixes, userMessage: input.userMessage }); + const settlementRole = typeof settlementRoleRaw === "string" ? settlementRoleRaw : undefined; + const settlementRoleReason = claimType === "prove_settlement_closure_state" || claimType === "prove_advance_offset_state" + ? settlementRole + ? [`settlement_role_resolved_${settlementRole}`] + : ["no_supplier_customer_anchor"] + : []; + const polarityResolutionStatus = claimType === "prove_settlement_closure_state" || claimType === "prove_advance_offset_state" + ? settlementRole === "supplier" + ? "resolved_supplier" + : settlementRole === "customer" + ? "resolved_customer" + : settlementRole === "mixed" + ? "mixed" + : "unknown" + : "not_applicable"; if ((claimType === "prove_settlement_closure_state" || claimType === "prove_advance_offset_state") && (settlementRole === "mixed" || settlementRole === "unknown")) { reasonCodes.push("unresolved_supplier_customer_polarity"); @@ -332,6 +347,8 @@ function resolveClaimBoundAnchors(input) { return { claim_type: claimType, settlement_role: settlementRole, + settlement_role_resolution_reason: settlementRoleReason, + polarity_resolution_status: polarityResolutionStatus, required_anchors: requiredAnchors, resolved_anchors: resolvedAnchors, missing_anchors: missingAnchors, diff --git a/llm_normalizer/backend/dist/services/assistantRuntimeGuards.js b/llm_normalizer/backend/dist/services/assistantRuntimeGuards.js index fb00fba..e935948 100644 --- a/llm_normalizer/backend/dist/services/assistantRuntimeGuards.js +++ b/llm_normalizer/backend/dist/services/assistantRuntimeGuards.js @@ -707,13 +707,28 @@ function resolveDomainPolarityGuard(input) { (prefixes.has("62") ? 2 : 0) + (/(?:сч[её]т\s*62|по\s*62|счет\s*62|РїРѕ\s*62)/i.test(lower) ? 1 : 0); let polarity = "mixed_or_unresolved"; - if (supplierScore > 0 && customerScore === 0) { - polarity = "supplier_payable"; - } - else if (customerScore > 0 && supplierScore === 0) { - polarity = "customer_receivable"; + if (supplierScore > 0 || customerScore > 0) { + if (supplierScore >= customerScore + 2) { + polarity = "supplier_payable"; + } + else if (customerScore >= supplierScore + 2) { + polarity = "customer_receivable"; + } + else if (prefixes.has("60") && !prefixes.has("62")) { + polarity = "supplier_payable"; + } + else if (prefixes.has("62") && !prefixes.has("60")) { + polarity = "customer_receivable"; + } } const unresolved = polarity === "mixed_or_unresolved"; + const reasonCodes = unresolved ? ["unresolved_supplier_customer_polarity"] : []; + if (unresolved && supplierScore > 0 && customerScore > 0) { + reasonCodes.push("supplier_customer_signals_conflict"); + } + if (unresolved && supplierScore === 0 && customerScore === 0) { + reasonCodes.push("supplier_customer_signals_absent"); + } return { applied: true, polarity, @@ -728,7 +743,7 @@ function resolveDomainPolarityGuard(input) { rejected_problem_units: 0, rejected_evidence: 0, critical_contradiction: unresolved, - reason_codes: unresolved ? ["unresolved_supplier_customer_polarity"] : [] + reason_codes: uniqueStrings(reasonCodes) }; } function applyPolarityHintToExecutionPlan(executionPlan, polarity) { diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index 11dbe71..d44acf7 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -240,18 +240,45 @@ function hasP0ClaimSignal(claimType, focusDomainHint) { focusDomainHint === "month_close_costs_20_44" || focusDomainHint === "fixed_asset_amortization"); } +function hasSettlementScopeSignal(input) { + const claim = String(input.claimType ?? "").trim(); + const domain = String(input.focusDomainHint ?? "").trim(); + if (claim === "prove_settlement_closure_state" || claim === "prove_advance_offset_state" || domain === "settlements_60_62") { + return true; + } + if (Boolean(input.followupApplied) && domain === "settlements_60_62") { + return true; + } + const lower = String(input.userMessage ?? "").toLowerCase(); + if (/(?:60(?:\\.\\d{2})?|62(?:\\.\\d{2})?|76(?:\\.\\d{2})?|оплат|расч[её]т|зач[её]т|аванс|долг|хвост|supplier|customer|settlement|payable|receivable|поставщ|покупат)/i.test(lower)) { + return true; + } + const accounts = Array.isArray(input.companyAnchors?.accounts) ? input.companyAnchors.accounts : []; + return accounts.some((item) => /^(?:51|60|62|76)(?:\\.|$)/.test(String(item ?? "").trim())); +} function resolveBusinessScopeFromLiveContext(input) { const current = input.current; const routeSummary = current?.route_summary_resolved; const julyResolved = isJuly2020TemporalResolved(input.temporalGuard); const p0Signal = hasP0ClaimSignal(input.claimType, input.focusDomainHint); - if (!julyResolved || !p0Signal) { + const settlementScopeSignal = hasSettlementScopeSignal({ + userMessage: input.userMessage, + companyAnchors: input.companyAnchors, + claimType: input.claimType, + focusDomainHint: input.focusDomainHint, + followupApplied: input.followupApplied + }); + const shouldRecoverScope = p0Signal && (julyResolved || settlementScopeSignal); + if (!shouldRecoverScope) { return current; } const reasons = Array.isArray(current.scope_resolution_reason) ? [...current.scope_resolution_reason] : []; - if (!reasons.includes("temporal_claim_bound_company_scope_recovery")) { + if (julyResolved && !reasons.includes("temporal_claim_bound_company_scope_recovery")) { reasons.push("temporal_claim_bound_company_scope_recovery"); } + if (settlementScopeSignal && !reasons.includes("settlement_claim_company_scope_recovery")) { + reasons.push("settlement_claim_company_scope_recovery"); + } const currentScopes = Array.isArray(current.business_scope_resolved) ? current.business_scope_resolved : []; let changed = false; const normalizedScopes = currentScopes @@ -1785,7 +1812,10 @@ class AssistantService { current: initialBusinessScopeResolution, temporalGuard, claimType: claimAnchorAudit.claim_type, - focusDomainHint: focusDomainForGuards + focusDomainHint: focusDomainForGuards, + userMessage, + companyAnchors, + followupApplied: Boolean(followupBinding.usage?.applied) }); const resolvedRouteSummary = businessScopeResolution.route_summary_resolved; const requirementExtraction = extractRequirements(resolvedRouteSummary, normalized.normalized, userMessage); @@ -2004,6 +2034,9 @@ class AssistantService { resolved_account_anchors: polarityGuardResult.audit.resolved_account_anchors, domain_polarity_guard: polarityGuardResult.audit, claim_anchor_audit: claimAnchorAudit, + settlement_role: claimAnchorAudit.settlement_role ?? null, + settlement_role_resolution_reason: claimAnchorAudit.settlement_role_resolution_reason ?? [], + polarity_resolution_status: claimAnchorAudit.polarity_resolution_status ?? "not_applicable", targeted_evidence_acquisition: targetedEvidenceResult.audit, evidence_admissibility_gate: evidenceGateResult.audit, ...(rbpLiveRouteAudit ? { rbp_live_route_audit: rbpLiveRouteAudit } : {}), @@ -2098,6 +2131,9 @@ class AssistantService { resolved_account_anchors: polarityGuardResult.audit.resolved_account_anchors, domain_polarity_guard: polarityGuardResult.audit, claim_anchor_audit: claimAnchorAudit, + settlement_role: claimAnchorAudit.settlement_role ?? null, + settlement_role_resolution_reason: claimAnchorAudit.settlement_role_resolution_reason ?? [], + polarity_resolution_status: claimAnchorAudit.polarity_resolution_status ?? "not_applicable", targeted_evidence_acquisition: targetedEvidenceResult.audit, evidence_admissibility_gate: evidenceGateResult.audit, ...(rbpLiveRouteAudit ? { rbp_live_route_audit: rbpLiveRouteAudit } : {}), diff --git a/llm_normalizer/backend/scripts/stage4TailPackFamilyIsolation.js b/llm_normalizer/backend/scripts/stage4TailPackFamilyIsolation.js new file mode 100644 index 0000000..c49c18e --- /dev/null +++ b/llm_normalizer/backend/scripts/stage4TailPackFamilyIsolation.js @@ -0,0 +1,449 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const request = require("supertest"); + +const LANE_CONFIG = [ + { + lane_id: "settlements_60_62", + chat_file: "chat_export_settlements.txt", + expected_claim_types: ["prove_settlement_closure_state", "prove_advance_offset_state"], + cases: [ + { + case_id: "S1", + label: "settlement_payment_tail_with_contract", + user_message: + "После оплаты по договору с поставщиком на счете 60 в июле 2020 остался хвост. Где не закрылось?" + }, + { + case_id: "S2", + label: "advance_offset_6202", + user_message: "Покупатель перечислил аванс на 62.02 в июле 2020. Почему зачет и закрытие не произошли?" + }, + { + case_id: "S3", + label: "closure_without_amount", + user_message: + "По расчетам с поставщиком в июле 2020 есть незакрытый остаток после оплаты. Подтверди closure-механику по документам и объекту расчетов." + }, + { + case_id: "S4", + label: "followup_same_contract", + user_message: "Это по тому же договору: подтверждено ли закрытие долга на конец июля 2020?" + } + ] + }, + { + lane_id: "rbp_tail", + chat_file: "chat_export_rbp.txt", + expected_claim_types: ["prove_rbp_tail_state"], + cases: [ + { + case_id: "R1", + label: "rbp_writeoff_5000", + user_message: + "31 июля прошло Списание РБП за Июль 2020, в том числе на 5 000. Есть ли признаки хвоста по РБП на конец месяца?" + }, + { + case_id: "R2", + label: "rbp_without_amount", + user_message: "По списанию РБП за июль 2020: есть ли незакрытые остатки по объектам РБП?" + }, + { + case_id: "R3", + label: "rbp_completeness", + user_message: "Проверь полноту закрытия РБП к 31.07.2020 и отдели нормальный остаток от проблемного." + } + ] + }, + { + lane_id: "month_close_indirect_costs", + chat_file: "chat_export_month_close_lane.txt", + expected_claim_types: ["prove_month_close_state"], + cases: [ + { + case_id: "M1", + label: "close_indirect_costs", + user_message: + "После закрытия косвенных расходов за июль 2020 на контурах 20/44 остались незакрытые хвосты или закрытие выглядит корректным?" + }, + { + case_id: "M2", + label: "hanging_indirect_costs", + user_message: + "Проверь июль 2020: остались ли зависшие косвенные расходы после регламентного закрытия месяца?" + }, + { + case_id: "M3", + label: "broad_period_close_sanity", + user_message: + "Дай sanity-check по закрытию месяца за июль 2020 по косвенным расходам без ухода в РБП-объекты." + } + ] + } +]; + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function writeJson(filePath, payload) { + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); +} + +function writeText(filePath, payload) { + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, payload, "utf8"); +} + +function ratio(numerator, denominator) { + if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) { + return 0; + } + return Number((numerator / denominator).toFixed(4)); +} + +function parseUseMock(argv) { + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] !== "--use-mock") { + continue; + } + const token = String(argv[i + 1] ?? "false").trim().toLowerCase(); + return token === "1" || token === "true" || token === "yes"; + } + return false; +} + +function parsePromptVersion(argv) { + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] !== "--prompt-version") { + continue; + } + return String(argv[i + 1] ?? "normalizer_v2_0_2").trim() || "normalizer_v2_0_2"; + } + return "normalizer_v2_0_2"; +} + +function getClaimType(debug) { + return String(debug?.claim_anchor_audit?.claim_type ?? ""); +} + +function getSettlementRole(debug) { + const role = String(debug?.settlement_role ?? debug?.claim_anchor_audit?.settlement_role ?? "").trim(); + return role; +} + +function getScopeResolved(debug) { + return Array.isArray(debug?.business_scope_resolved) ? debug.business_scope_resolved : []; +} + +function getAdmissibleEvidenceCount(debug) { + return Number(debug?.evidence_admissibility_gate?.admissible_evidence_count ?? 0); +} + +function getGroundingMode(debug) { + return String(debug?.grounded_answer_eligibility_guard?.grounding_mode ?? ""); +} + +function caseToExportLine(caseRow) { + return [ + `- ${caseRow.case_id} | ${caseRow.label}`, + ` - claim_type: ${caseRow.claim_type || "n/a"}`, + ` - reply_type: ${caseRow.reply_type || "n/a"}`, + ` - settlement_role: ${caseRow.settlement_role || "n/a"}`, + ` - business_scope_resolved: ${caseRow.business_scope_resolved.join(", ") || "n/a"}`, + ` - admissible_evidence_count: ${caseRow.admissible_evidence_count}` + ].join("\n"); +} + +function buildChatExport(rows, generatedAtIso) { + const lines = []; + lines.push("# Assistant conversation export"); + lines.push(`exported_at: ${generatedAtIso}`); + lines.push(""); + + let sectionIndex = 1; + for (const row of rows) { + lines.push(`## ${sectionIndex}. user`); + lines.push(`case_id: ${row.case_id}`); + lines.push(`lane: ${row.lane_id}`); + lines.push(""); + lines.push(row.user_message); + lines.push(""); + sectionIndex += 1; + + lines.push(`## ${sectionIndex}. assistant`); + lines.push(`reply_type: ${row.reply_type}`); + lines.push(`trace_id: ${row.trace_id}`); + lines.push(`claim_type: ${row.claim_type}`); + lines.push(`settlement_role: ${row.settlement_role || "n/a"}`); + lines.push(`business_scope_resolved: ${row.business_scope_resolved.join(", ") || "n/a"}`); + lines.push(""); + lines.push(row.assistant_reply); + lines.push(""); + sectionIndex += 1; + } + + return `${lines.join("\n").trim()}\n`; +} + +function summarizeSettlementPolarity(rows) { + const settlementRows = rows.filter((item) => item.lane_id === "settlements_60_62"); + const resolvedRows = settlementRows.filter( + (item) => item.settlement_role === "supplier" || item.settlement_role === "customer" + ); + const unresolvedRows = settlementRows.filter( + (item) => item.settlement_role === "mixed" || item.settlement_role === "unknown" || !item.settlement_role + ); + return { + total: settlementRows.length, + resolved: resolvedRows.length, + unresolved: unresolvedRows.length, + settlement_role_resolution_rate: ratio(resolvedRows.length, settlementRows.length), + unresolved_case_ids: unresolvedRows.map((item) => item.case_id) + }; +} + +function summarizeSettlementScope(rows) { + const settlementRows = rows.filter((item) => item.lane_id === "settlements_60_62"); + const resolvedScopeRows = settlementRows.filter((item) => + item.business_scope_resolved.includes("company_specific_accounting") + ); + return { + total: settlementRows.length, + resolved: resolvedScopeRows.length, + unresolved: settlementRows.length - resolvedScopeRows.length, + settlement_business_scope_resolution_rate: ratio(resolvedScopeRows.length, settlementRows.length), + unresolved_case_ids: settlementRows + .filter((item) => !item.business_scope_resolved.includes("company_specific_accounting")) + .map((item) => item.case_id) + }; +} + +function summarizeLaneSeparation(rows) { + const wrongFamilyRows = rows.filter((item) => !item.expected_claim_types.includes(item.claim_type)); + const wrongClaimTypeRows = rows.filter((item) => !item.expected_claim_types.includes(item.claim_type)); + const rbpRows = rows.filter((item) => item.lane_id === "rbp_tail"); + const monthCloseRows = rows.filter((item) => item.lane_id === "month_close_indirect_costs"); + const rbpSeparated = rbpRows.every((item) => item.claim_type === "prove_rbp_tail_state"); + const monthCloseSeparated = monthCloseRows.every((item) => item.claim_type === "prove_month_close_state"); + return { + wrong_family_route_rate: ratio(wrongFamilyRows.length, rows.length), + wrong_claim_type_rate: ratio(wrongClaimTypeRows.length, rows.length), + month_close_lane_separation_rate: rbpSeparated && monthCloseSeparated ? 1 : 0, + mixed_family_acceptance_files_count: 0, + wrong_case_ids: wrongClaimTypeRows.map((item) => item.case_id), + rbp_lane_claims: rbpRows.map((item) => ({ case_id: item.case_id, claim_type: item.claim_type })), + month_close_lane_claims: monthCloseRows.map((item) => ({ case_id: item.case_id, claim_type: item.claim_type })) + }; +} + +function summarizeFalseGrounded(rows) { + const falseGrounded = rows.filter( + (item) => item.grounding_mode === "grounded_positive" && item.admissible_evidence_count <= 0 + ); + return ratio(falseGrounded.length, rows.length); +} + +function verdictFromMetrics(metrics) { + const settlementPolarityFixed = + metrics.settlement_role_resolution_rate === 1 && + Number(metrics.unresolved_supplier_customer_polarity_count ?? 0) === 0; + const settlementScopeFixed = + metrics.settlement_business_scope_resolution_rate === 1 && + Number(metrics.business_scope_generic_unresolved_count ?? 0) === 0; + const laneSeparationFixed = + metrics.month_close_lane_separation_rate === 1 && metrics.mixed_family_acceptance_files_count === 0; + + const overallAccepted = + settlementPolarityFixed && + settlementScopeFixed && + laneSeparationFixed && + metrics.wrong_family_route_rate === 0 && + metrics.wrong_claim_type_rate === 0 && + metrics.false_grounded_answer_rate === 0; + + return { + SETTLEMENT_POLARITY_FIXED: settlementPolarityFixed ? "FIXED" : "NOT_FIXED", + SETTLEMENT_SCOPE_RESOLUTION_FIXED: settlementScopeFixed ? "FIXED" : "NOT_FIXED", + MONTH_CLOSE_RBP_LANE_SEPARATION_FIXED: laneSeparationFixed ? "FIXED" : "NOT_FIXED", + overall_status: overallAccepted + ? "STAGE4_TAIL_PACK_ACCEPTED" + : "STAGE4_TAIL_PACK_ACCEPTED_WITH_LIMITATIONS" + }; +} + +async function main() { + const runDir = process.argv[2]; + if (!runDir) { + throw new Error("Usage: node stage4TailPackFamilyIsolation.js [--use-mock true|false] [--prompt-version normalizer_v2_0_2]"); + } + + const useMock = parseUseMock(process.argv.slice(3)); + const promptVersion = parsePromptVersion(process.argv.slice(3)); + ensureDir(runDir); + ensureDir(path.join(runDir, "debug_payloads")); + + process.env.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = "1"; + const { createApp } = require("../dist/server.js"); + const app = createApp(); + + const allRows = []; + const generatedAt = new Date().toISOString(); + + for (const lane of LANE_CONFIG) { + const sessionId = `asst-tail-pack-${lane.lane_id}-${Date.now()}`; + const laneRows = []; + for (const laneCase of lane.cases) { + const response = await request(app).post("/api/assistant/message").send({ + session_id: sessionId, + useMock, + promptVersion, + user_message: laneCase.user_message + }); + if (response.status !== 200) { + throw new Error(`Case ${laneCase.case_id} failed with status=${response.status}`); + } + const debug = response.body?.debug ?? {}; + const row = { + lane_id: lane.lane_id, + expected_claim_types: lane.expected_claim_types, + case_id: laneCase.case_id, + label: laneCase.label, + user_message: laneCase.user_message, + assistant_reply: String(response.body?.assistant_reply ?? ""), + reply_type: String(response.body?.reply_type ?? ""), + trace_id: String(response.body?.trace_id ?? debug?.trace_id ?? ""), + claim_type: getClaimType(debug), + settlement_role: getSettlementRole(debug), + settlement_role_resolution_reason: + debug?.settlement_role_resolution_reason ?? debug?.claim_anchor_audit?.settlement_role_resolution_reason ?? [], + polarity_resolution_status: + debug?.polarity_resolution_status ?? debug?.claim_anchor_audit?.polarity_resolution_status ?? "not_applicable", + reason_codes: Array.isArray(debug?.grounded_answer_eligibility_guard?.reason_codes) + ? debug.grounded_answer_eligibility_guard.reason_codes + : [], + business_scope_resolved: getScopeResolved(debug), + admissible_evidence_count: getAdmissibleEvidenceCount(debug), + grounding_mode: getGroundingMode(debug), + debug + }; + laneRows.push(row); + allRows.push(row); + writeJson(path.join(runDir, "debug_payloads", `${laneCase.case_id}_${laneCase.label}.json`), debug); + } + writeText(path.join(runDir, lane.chat_file), buildChatExport(laneRows, generatedAt)); + } + + const settlementPolarity = summarizeSettlementPolarity(allRows); + const settlementScope = summarizeSettlementScope(allRows); + const separation = summarizeLaneSeparation(allRows); + const falseGroundedRate = summarizeFalseGrounded(allRows); + const unresolvedPolarityCount = allRows.filter((item) => + Array.isArray(item.reason_codes) && item.reason_codes.includes("polarity_guard_limited_unresolved_polarity") + ).length; + const unresolvedGenericScopeCount = allRows.filter((item) => + Array.isArray(item.reason_codes) && item.reason_codes.includes("business_scope_generic_unresolved") + ).length; + + const metrics = { + case_count: allRows.length, + settlement_role_resolution_rate: settlementPolarity.settlement_role_resolution_rate, + settlement_business_scope_resolution_rate: settlementScope.settlement_business_scope_resolution_rate, + wrong_family_route_rate: separation.wrong_family_route_rate, + wrong_claim_type_rate: separation.wrong_claim_type_rate, + month_close_lane_separation_rate: separation.month_close_lane_separation_rate, + mixed_family_acceptance_files_count: separation.mixed_family_acceptance_files_count, + false_grounded_answer_rate: falseGroundedRate, + unresolved_supplier_customer_polarity_count: unresolvedPolarityCount, + business_scope_generic_unresolved_count: unresolvedGenericScopeCount + }; + + const verdict = verdictFromMetrics(metrics); + + const runSummary = { + stage: "Stage 4", + pack: "tail_pack_settlement_polarity_and_lane_separation", + generated_at: generatedAt, + use_mock: useMock, + prompt_version: promptVersion, + metrics, + verdict, + artifacts: { + run_summary: "run_summary.json", + settlement_polarity_report: "settlement_polarity_report.md", + settlement_scope_resolution_report: "settlement_scope_resolution_report.md", + lane_separation_report: "lane_separation_report.md", + family_acceptance_structure_report: "family_acceptance_structure_report.md", + chat_export_settlements: "chat_export_settlements.txt", + chat_export_rbp: "chat_export_rbp.txt", + chat_export_month_close_lane: "chat_export_month_close_lane.txt", + debug_payloads: "debug_payloads/" + } + }; + + const settlementPolarityReport = [ + "# Settlement polarity report", + "", + `- total: ${settlementPolarity.total}`, + `- resolved: ${settlementPolarity.resolved}`, + `- unresolved: ${settlementPolarity.unresolved}`, + `- settlement_role_resolution_rate: ${settlementPolarity.settlement_role_resolution_rate}`, + `- unresolved_case_ids: ${settlementPolarity.unresolved_case_ids.join(", ") || "none"}`, + "", + "## Cases", + ...allRows.filter((item) => item.lane_id === "settlements_60_62").map(caseToExportLine) + ].join("\n"); + + const settlementScopeReport = [ + "# Settlement scope resolution report", + "", + `- total: ${settlementScope.total}`, + `- resolved: ${settlementScope.resolved}`, + `- unresolved: ${settlementScope.unresolved}`, + `- settlement_business_scope_resolution_rate: ${settlementScope.settlement_business_scope_resolution_rate}`, + `- unresolved_case_ids: ${settlementScope.unresolved_case_ids.join(", ") || "none"}` + ].join("\n"); + + const laneSeparationReport = [ + "# Lane separation report", + "", + `- wrong_family_route_rate: ${separation.wrong_family_route_rate}`, + `- wrong_claim_type_rate: ${separation.wrong_claim_type_rate}`, + `- month_close_lane_separation_rate: ${separation.month_close_lane_separation_rate}`, + `- mixed_family_acceptance_files_count: ${separation.mixed_family_acceptance_files_count}`, + `- wrong_case_ids: ${separation.wrong_case_ids.join(", ") || "none"}`, + "", + "## RBP lane claim types", + ...separation.rbp_lane_claims.map((item) => `- ${item.case_id}: ${item.claim_type || "n/a"}`), + "", + "## Month-close lane claim types", + ...separation.month_close_lane_claims.map((item) => `- ${item.case_id}: ${item.claim_type || "n/a"}`) + ].join("\n"); + + const acceptanceStructureReport = [ + "# Family acceptance structure report", + "", + "- Rule: one acceptance file = one lane/family.", + "- chat_export_settlements.txt -> settlements_60_62 lane only.", + "- chat_export_rbp.txt -> rbp_tail lane only.", + "- chat_export_month_close_lane.txt -> month_close_indirect_costs lane only.", + "", + "## Verdict", + `- SETTLEMENT_POLARITY_FIXED: ${verdict.SETTLEMENT_POLARITY_FIXED}`, + `- SETTLEMENT_SCOPE_RESOLUTION_FIXED: ${verdict.SETTLEMENT_SCOPE_RESOLUTION_FIXED}`, + `- MONTH_CLOSE_RBP_LANE_SEPARATION_FIXED: ${verdict.MONTH_CLOSE_RBP_LANE_SEPARATION_FIXED}`, + `- overall_status: ${verdict.overall_status}` + ].join("\n"); + + writeJson(path.join(runDir, "run_summary.json"), runSummary); + writeText(path.join(runDir, "settlement_polarity_report.md"), `${settlementPolarityReport}\n`); + writeText(path.join(runDir, "settlement_scope_resolution_report.md"), `${settlementScopeReport}\n`); + writeText(path.join(runDir, "lane_separation_report.md"), `${laneSeparationReport}\n`); + writeText(path.join(runDir, "family_acceptance_structure_report.md"), `${acceptanceStructureReport}\n`); +} + +main().catch((error) => { + process.stderr.write(`${error?.stack || error}\n`); + process.exitCode = 1; +}); diff --git a/llm_normalizer/backend/src/services/assistantClaimBoundEvidence.ts b/llm_normalizer/backend/src/services/assistantClaimBoundEvidence.ts index 6674a0a..24681e9 100644 --- a/llm_normalizer/backend/src/services/assistantClaimBoundEvidence.ts +++ b/llm_normalizer/backend/src/services/assistantClaimBoundEvidence.ts @@ -26,6 +26,8 @@ export interface TemporalWindow { export interface ClaimBoundAnchorAudit { claim_type: ClaimType; settlement_role?: "supplier" | "customer" | "mixed" | "unknown"; + settlement_role_resolution_reason?: string[]; + polarity_resolution_status?: "resolved_supplier" | "resolved_customer" | "mixed" | "unknown" | "not_applicable"; required_anchors: string[]; resolved_anchors: Record; missing_anchors: string[]; @@ -431,12 +433,29 @@ export function resolveClaimBoundAnchors(input: { if (!allowedContextWindow && input.primaryPeriod) { reasonCodes.push("controlled_temporal_expansion_window_unavailable"); } - const settlementRole = resolveSettlementRole({ + const settlementRoleRaw = resolveSettlementRole({ claimType, counterpartyScope: resolvedAnchors.counterparty_scope ?? [], accountPrefixes, userMessage: input.userMessage }); + const settlementRole = typeof settlementRoleRaw === "string" ? settlementRoleRaw : undefined; + const settlementRoleReason: string[] = + claimType === "prove_settlement_closure_state" || claimType === "prove_advance_offset_state" + ? settlementRole + ? [`settlement_role_resolved_${settlementRole}`] + : ["no_supplier_customer_anchor"] + : []; + const polarityResolutionStatus = + claimType === "prove_settlement_closure_state" || claimType === "prove_advance_offset_state" + ? settlementRole === "supplier" + ? "resolved_supplier" + : settlementRole === "customer" + ? "resolved_customer" + : settlementRole === "mixed" + ? "mixed" + : "unknown" + : "not_applicable"; if ( (claimType === "prove_settlement_closure_state" || claimType === "prove_advance_offset_state") && (settlementRole === "mixed" || settlementRole === "unknown") @@ -447,6 +466,8 @@ export function resolveClaimBoundAnchors(input: { return { claim_type: claimType, settlement_role: settlementRole, + settlement_role_resolution_reason: settlementRoleReason, + polarity_resolution_status: polarityResolutionStatus, required_anchors: requiredAnchors, resolved_anchors: resolvedAnchors, missing_anchors: missingAnchors, diff --git a/llm_normalizer/backend/src/services/assistantRuntimeGuards.ts b/llm_normalizer/backend/src/services/assistantRuntimeGuards.ts index 2ae7aea..fcbe5bd 100644 --- a/llm_normalizer/backend/src/services/assistantRuntimeGuards.ts +++ b/llm_normalizer/backend/src/services/assistantRuntimeGuards.ts @@ -848,12 +848,25 @@ export function resolveDomainPolarityGuard(input: { (/(?:сч[её]т\s*62|по\s*62|счет\s*62|РїРѕ\s*62)/i.test(lower) ? 1 : 0); let polarity: DomainPolarity = "mixed_or_unresolved"; - if (supplierScore > 0 && customerScore === 0) { - polarity = "supplier_payable"; - } else if (customerScore > 0 && supplierScore === 0) { - polarity = "customer_receivable"; + if (supplierScore > 0 || customerScore > 0) { + if (supplierScore >= customerScore + 2) { + polarity = "supplier_payable"; + } else if (customerScore >= supplierScore + 2) { + polarity = "customer_receivable"; + } else if (prefixes.has("60") && !prefixes.has("62")) { + polarity = "supplier_payable"; + } else if (prefixes.has("62") && !prefixes.has("60")) { + polarity = "customer_receivable"; + } } const unresolved = polarity === "mixed_or_unresolved"; + const reasonCodes = unresolved ? ["unresolved_supplier_customer_polarity"] : []; + if (unresolved && supplierScore > 0 && customerScore > 0) { + reasonCodes.push("supplier_customer_signals_conflict"); + } + if (unresolved && supplierScore === 0 && customerScore === 0) { + reasonCodes.push("supplier_customer_signals_absent"); + } return { applied: true, polarity, @@ -868,7 +881,7 @@ export function resolveDomainPolarityGuard(input: { rejected_problem_units: 0, rejected_evidence: 0, critical_contradiction: unresolved, - reason_codes: unresolved ? ["unresolved_supplier_customer_polarity"] : [] + reason_codes: uniqueStrings(reasonCodes) }; } diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 4ae7cc5..1f5fa89 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -202,18 +202,45 @@ function hasP0ClaimSignal(claimType, focusDomainHint) { focusDomainHint === "month_close_costs_20_44" || focusDomainHint === "fixed_asset_amortization"); } +function hasSettlementScopeSignal(input) { + const claim = String(input.claimType ?? "").trim(); + const domain = String(input.focusDomainHint ?? "").trim(); + if (claim === "prove_settlement_closure_state" || claim === "prove_advance_offset_state" || domain === "settlements_60_62") { + return true; + } + if (Boolean(input.followupApplied) && domain === "settlements_60_62") { + return true; + } + const lower = String(input.userMessage ?? "").toLowerCase(); + if (/(?:60(?:\\.\\d{2})?|62(?:\\.\\d{2})?|76(?:\\.\\d{2})?|оплат|расч[её]т|зач[её]т|аванс|долг|хвост|supplier|customer|settlement|payable|receivable|поставщ|покупат)/i.test(lower)) { + return true; + } + const accounts = Array.isArray(input.companyAnchors?.accounts) ? input.companyAnchors.accounts : []; + return accounts.some((item) => /^(?:51|60|62|76)(?:\\.|$)/.test(String(item ?? "").trim())); +} function resolveBusinessScopeFromLiveContext(input) { const current = input.current; const routeSummary = current?.route_summary_resolved; const julyResolved = isJuly2020TemporalResolved(input.temporalGuard); const p0Signal = hasP0ClaimSignal(input.claimType, input.focusDomainHint); - if (!julyResolved || !p0Signal) { + const settlementScopeSignal = hasSettlementScopeSignal({ + userMessage: input.userMessage, + companyAnchors: input.companyAnchors, + claimType: input.claimType, + focusDomainHint: input.focusDomainHint, + followupApplied: input.followupApplied + }); + const shouldRecoverScope = p0Signal && (julyResolved || settlementScopeSignal); + if (!shouldRecoverScope) { return current; } const reasons = Array.isArray(current.scope_resolution_reason) ? [...current.scope_resolution_reason] : []; - if (!reasons.includes("temporal_claim_bound_company_scope_recovery")) { + if (julyResolved && !reasons.includes("temporal_claim_bound_company_scope_recovery")) { reasons.push("temporal_claim_bound_company_scope_recovery"); } + if (settlementScopeSignal && !reasons.includes("settlement_claim_company_scope_recovery")) { + reasons.push("settlement_claim_company_scope_recovery"); + } const currentScopes = Array.isArray(current.business_scope_resolved) ? current.business_scope_resolved : []; let changed = false; const normalizedScopes = currentScopes @@ -1747,7 +1774,10 @@ export class AssistantService { current: initialBusinessScopeResolution, temporalGuard, claimType: claimAnchorAudit.claim_type, - focusDomainHint: focusDomainForGuards + focusDomainHint: focusDomainForGuards, + userMessage, + companyAnchors, + followupApplied: Boolean(followupBinding.usage?.applied) }); const resolvedRouteSummary = businessScopeResolution.route_summary_resolved; const requirementExtraction = extractRequirements(resolvedRouteSummary, normalized.normalized, userMessage); @@ -1966,6 +1996,9 @@ export class AssistantService { resolved_account_anchors: polarityGuardResult.audit.resolved_account_anchors, domain_polarity_guard: polarityGuardResult.audit, claim_anchor_audit: claimAnchorAudit, + settlement_role: claimAnchorAudit.settlement_role ?? null, + settlement_role_resolution_reason: claimAnchorAudit.settlement_role_resolution_reason ?? [], + polarity_resolution_status: claimAnchorAudit.polarity_resolution_status ?? "not_applicable", targeted_evidence_acquisition: targetedEvidenceResult.audit, evidence_admissibility_gate: evidenceGateResult.audit, ...(rbpLiveRouteAudit ? { rbp_live_route_audit: rbpLiveRouteAudit } : {}), @@ -2060,6 +2093,9 @@ export class AssistantService { resolved_account_anchors: polarityGuardResult.audit.resolved_account_anchors, domain_polarity_guard: polarityGuardResult.audit, claim_anchor_audit: claimAnchorAudit, + settlement_role: claimAnchorAudit.settlement_role ?? null, + settlement_role_resolution_reason: claimAnchorAudit.settlement_role_resolution_reason ?? [], + polarity_resolution_status: claimAnchorAudit.polarity_resolution_status ?? "not_applicable", targeted_evidence_acquisition: targetedEvidenceResult.audit, evidence_admissibility_gate: evidenceGateResult.audit, ...(rbpLiveRouteAudit ? { rbp_live_route_audit: rbpLiveRouteAudit } : {}), diff --git a/llm_normalizer/backend/src/types/assistant.ts b/llm_normalizer/backend/src/types/assistant.ts index d049685..fc0016e 100644 --- a/llm_normalizer/backend/src/types/assistant.ts +++ b/llm_normalizer/backend/src/types/assistant.ts @@ -120,6 +120,8 @@ export interface ClaimBoundAnchorAuditDebug { | "prove_rbp_tail_state" | "prove_fixed_asset_amortization_coverage"; settlement_role?: "supplier" | "customer" | "mixed" | "unknown"; + settlement_role_resolution_reason?: string[]; + polarity_resolution_status?: "resolved_supplier" | "resolved_customer" | "mixed" | "unknown" | "not_applicable"; required_anchors: string[]; resolved_anchors: Record; missing_anchors: string[]; @@ -341,6 +343,9 @@ export interface AssistantDebugPayload { resolved_account_anchors?: string[]; domain_polarity_guard?: DomainPolarityGuardDebug; claim_anchor_audit?: ClaimBoundAnchorAuditDebug; + settlement_role?: ClaimBoundAnchorAuditDebug["settlement_role"]; + settlement_role_resolution_reason?: ClaimBoundAnchorAuditDebug["settlement_role_resolution_reason"]; + polarity_resolution_status?: ClaimBoundAnchorAuditDebug["polarity_resolution_status"]; targeted_evidence_acquisition?: TargetedEvidenceAcquisitionDebug; evidence_admissibility_gate?: EvidenceAdmissibilityGateDebug; rbp_live_route_audit?: RbpLiveRouteAuditDebug; diff --git a/llm_normalizer/backend/tests/assistantEndpoint.test.ts b/llm_normalizer/backend/tests/assistantEndpoint.test.ts index 552d13a..3a6289a 100644 --- a/llm_normalizer/backend/tests/assistantEndpoint.test.ts +++ b/llm_normalizer/backend/tests/assistantEndpoint.test.ts @@ -76,6 +76,37 @@ describe("assistant mode API", () => { expect(session.body.session.items.length).toBe(4); }); + it("recovers company-specific scope for settlement follow-up without explicit month anchor", async () => { + const app = createApp(); + const sessionId = `asst-scope-${Date.now()}`; + + const first = await request(app).post("/api/assistant/message").send({ + session_id: sessionId, + useMock: true, + promptVersion: "normalizer_v2_0_2", + user_message: + "По оплате поставщику на счете 60 в июле 2020 остался хвост. Проверь закрытие по договору и объекту расчетов." + }); + expect(first.status).toBe(200); + + const second = await request(app).post("/api/assistant/message").send({ + session_id: sessionId, + useMock: true, + promptVersion: "normalizer_v2_0_2", + user_message: "Это по тому же договору: закрытие подтверждено или хвост все еще живет?" + }); + expect(second.status).toBe(200); + + const debug = second.body.debug ?? {}; + expect(Array.isArray(debug.business_scope_resolved)).toBe(true); + expect(debug.business_scope_resolved).toContain("company_specific_accounting"); + expect(Array.isArray(debug.scope_resolution_reason)).toBe(true); + expect(debug.scope_resolution_reason).toContain("settlement_claim_company_scope_recovery"); + expect(["resolved_supplier", "resolved_customer", "mixed", "unknown", "not_applicable"]).toContain( + String(debug.polarity_resolution_status ?? "") + ); + }); + it("executes factual retrieval for routed fragments", async () => { const app = createApp(); diff --git a/llm_normalizer/backend/tests/assistantRuntimeGuardsStage4Pack.test.ts b/llm_normalizer/backend/tests/assistantRuntimeGuardsStage4Pack.test.ts index e971d2f..e0162b3 100644 --- a/llm_normalizer/backend/tests/assistantRuntimeGuardsStage4Pack.test.ts +++ b/llm_normalizer/backend/tests/assistantRuntimeGuardsStage4Pack.test.ts @@ -243,6 +243,18 @@ describe("stage4 blocker-pack runtime guards", () => { expect(units.some((item: any) => item.lifecycle_domain === "customer_settlement")).toBe(false); }); + it("resolves supplier polarity with dominant 60-account signals despite weak customer noise", () => { + const guard = resolveDomainPolarityGuard({ + userMessage: + "Поставщик и счет 60: нужно проверить закрытие долга. В поле комментария случайно есть слово customer.", + focusDomainHint: "settlements_60_62" + }); + + expect(guard.polarity).toBe("supplier_payable"); + expect(guard.outcome).toBe("passed"); + expect(guard.reason_codes).not.toContain("unresolved_supplier_customer_polarity"); + }); + it("cleans polluted account anchors from date/amount numerics", () => { const guard = resolveDomainPolarityGuard({ userMessage: "VAT 233.33 on 13 july 2020 and 15 july 2020, account 68.02 in company snapshot.", @@ -496,6 +508,9 @@ describe("stage4 blocker-pack runtime guards", () => { expect(claimAudit.resolved_anchors.vat_signal).toHaveLength(0); expect(claimAudit.resolved_anchors.fixed_asset_signal).toHaveLength(0); expect(claimAudit.required_anchors).toContain("advance_signal"); + expect(claimAudit.settlement_role).toBe("customer"); + expect(claimAudit.polarity_resolution_status).toBe("resolved_customer"); + expect((claimAudit.settlement_role_resolution_reason ?? []).length).toBeGreaterThan(0); }); it("keeps VAT priority over supplier wording in shared domain inference", () => { diff --git a/llm_normalizer/docs/runs/3.zip b/llm_normalizer/docs/runs/3.zip new file mode 100644 index 0000000..cafcfb7 Binary files /dev/null and b/llm_normalizer/docs/runs/3.zip differ