Этап 4 добивка двух хвостов: settlement polarity и чистое разделение month_close / RBP в live acceptance

This commit is contained in:
dctouch 2026-03-29 16:30:10 +03:00
parent f74e7b697a
commit c82ebd70b7
12 changed files with 705 additions and 19 deletions

View File

@ -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 <run-dir> --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`

View File

@ -319,12 +319,27 @@ function resolveClaimBoundAnchors(input) {
if (!allowedContextWindow && input.primaryPeriod) { if (!allowedContextWindow && input.primaryPeriod) {
reasonCodes.push("controlled_temporal_expansion_window_unavailable"); reasonCodes.push("controlled_temporal_expansion_window_unavailable");
} }
const settlementRole = resolveSettlementRole({ const settlementRoleRaw = resolveSettlementRole({
claimType, claimType,
counterpartyScope: resolvedAnchors.counterparty_scope ?? [], counterpartyScope: resolvedAnchors.counterparty_scope ?? [],
accountPrefixes, accountPrefixes,
userMessage: input.userMessage 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") && if ((claimType === "prove_settlement_closure_state" || claimType === "prove_advance_offset_state") &&
(settlementRole === "mixed" || settlementRole === "unknown")) { (settlementRole === "mixed" || settlementRole === "unknown")) {
reasonCodes.push("unresolved_supplier_customer_polarity"); reasonCodes.push("unresolved_supplier_customer_polarity");
@ -332,6 +347,8 @@ function resolveClaimBoundAnchors(input) {
return { return {
claim_type: claimType, claim_type: claimType,
settlement_role: settlementRole, settlement_role: settlementRole,
settlement_role_resolution_reason: settlementRoleReason,
polarity_resolution_status: polarityResolutionStatus,
required_anchors: requiredAnchors, required_anchors: requiredAnchors,
resolved_anchors: resolvedAnchors, resolved_anchors: resolvedAnchors,
missing_anchors: missingAnchors, missing_anchors: missingAnchors,

View File

@ -707,13 +707,28 @@ function resolveDomainPolarityGuard(input) {
(prefixes.has("62") ? 2 : 0) + (prefixes.has("62") ? 2 : 0) +
(/(?:сч[её]т\s*62|по\s*62|счет\s*62|РїРѕ\s*62)/i.test(lower) ? 1 : 0); (/(?:сч[её]т\s*62|по\s*62|счет\s*62|РїРѕ\s*62)/i.test(lower) ? 1 : 0);
let polarity = "mixed_or_unresolved"; let polarity = "mixed_or_unresolved";
if (supplierScore > 0 && customerScore === 0) { if (supplierScore > 0 || customerScore > 0) {
if (supplierScore >= customerScore + 2) {
polarity = "supplier_payable"; polarity = "supplier_payable";
} }
else if (customerScore > 0 && supplierScore === 0) { else if (customerScore >= supplierScore + 2) {
polarity = "customer_receivable"; 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 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 { return {
applied: true, applied: true,
polarity, polarity,
@ -728,7 +743,7 @@ function resolveDomainPolarityGuard(input) {
rejected_problem_units: 0, rejected_problem_units: 0,
rejected_evidence: 0, rejected_evidence: 0,
critical_contradiction: unresolved, critical_contradiction: unresolved,
reason_codes: unresolved ? ["unresolved_supplier_customer_polarity"] : [] reason_codes: uniqueStrings(reasonCodes)
}; };
} }
function applyPolarityHintToExecutionPlan(executionPlan, polarity) { function applyPolarityHintToExecutionPlan(executionPlan, polarity) {

View File

@ -240,18 +240,45 @@ function hasP0ClaimSignal(claimType, focusDomainHint) {
focusDomainHint === "month_close_costs_20_44" || focusDomainHint === "month_close_costs_20_44" ||
focusDomainHint === "fixed_asset_amortization"); 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) { function resolveBusinessScopeFromLiveContext(input) {
const current = input.current; const current = input.current;
const routeSummary = current?.route_summary_resolved; const routeSummary = current?.route_summary_resolved;
const julyResolved = isJuly2020TemporalResolved(input.temporalGuard); const julyResolved = isJuly2020TemporalResolved(input.temporalGuard);
const p0Signal = hasP0ClaimSignal(input.claimType, input.focusDomainHint); 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; return current;
} }
const reasons = Array.isArray(current.scope_resolution_reason) ? [...current.scope_resolution_reason] : []; 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"); 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 : []; const currentScopes = Array.isArray(current.business_scope_resolved) ? current.business_scope_resolved : [];
let changed = false; let changed = false;
const normalizedScopes = currentScopes const normalizedScopes = currentScopes
@ -1785,7 +1812,10 @@ class AssistantService {
current: initialBusinessScopeResolution, current: initialBusinessScopeResolution,
temporalGuard, temporalGuard,
claimType: claimAnchorAudit.claim_type, claimType: claimAnchorAudit.claim_type,
focusDomainHint: focusDomainForGuards focusDomainHint: focusDomainForGuards,
userMessage,
companyAnchors,
followupApplied: Boolean(followupBinding.usage?.applied)
}); });
const resolvedRouteSummary = businessScopeResolution.route_summary_resolved; const resolvedRouteSummary = businessScopeResolution.route_summary_resolved;
const requirementExtraction = extractRequirements(resolvedRouteSummary, normalized.normalized, userMessage); const requirementExtraction = extractRequirements(resolvedRouteSummary, normalized.normalized, userMessage);
@ -2004,6 +2034,9 @@ class AssistantService {
resolved_account_anchors: polarityGuardResult.audit.resolved_account_anchors, resolved_account_anchors: polarityGuardResult.audit.resolved_account_anchors,
domain_polarity_guard: polarityGuardResult.audit, domain_polarity_guard: polarityGuardResult.audit,
claim_anchor_audit: claimAnchorAudit, 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, targeted_evidence_acquisition: targetedEvidenceResult.audit,
evidence_admissibility_gate: evidenceGateResult.audit, evidence_admissibility_gate: evidenceGateResult.audit,
...(rbpLiveRouteAudit ? { rbp_live_route_audit: rbpLiveRouteAudit } : {}), ...(rbpLiveRouteAudit ? { rbp_live_route_audit: rbpLiveRouteAudit } : {}),
@ -2098,6 +2131,9 @@ class AssistantService {
resolved_account_anchors: polarityGuardResult.audit.resolved_account_anchors, resolved_account_anchors: polarityGuardResult.audit.resolved_account_anchors,
domain_polarity_guard: polarityGuardResult.audit, domain_polarity_guard: polarityGuardResult.audit,
claim_anchor_audit: claimAnchorAudit, 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, targeted_evidence_acquisition: targetedEvidenceResult.audit,
evidence_admissibility_gate: evidenceGateResult.audit, evidence_admissibility_gate: evidenceGateResult.audit,
...(rbpLiveRouteAudit ? { rbp_live_route_audit: rbpLiveRouteAudit } : {}), ...(rbpLiveRouteAudit ? { rbp_live_route_audit: rbpLiveRouteAudit } : {}),

View File

@ -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 <run-dir> [--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;
});

View File

@ -26,6 +26,8 @@ export interface TemporalWindow {
export interface ClaimBoundAnchorAudit { export interface ClaimBoundAnchorAudit {
claim_type: ClaimType; claim_type: ClaimType;
settlement_role?: "supplier" | "customer" | "mixed" | "unknown"; 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[]; required_anchors: string[];
resolved_anchors: Record<string, string[]>; resolved_anchors: Record<string, string[]>;
missing_anchors: string[]; missing_anchors: string[];
@ -431,12 +433,29 @@ export function resolveClaimBoundAnchors(input: {
if (!allowedContextWindow && input.primaryPeriod) { if (!allowedContextWindow && input.primaryPeriod) {
reasonCodes.push("controlled_temporal_expansion_window_unavailable"); reasonCodes.push("controlled_temporal_expansion_window_unavailable");
} }
const settlementRole = resolveSettlementRole({ const settlementRoleRaw = resolveSettlementRole({
claimType, claimType,
counterpartyScope: resolvedAnchors.counterparty_scope ?? [], counterpartyScope: resolvedAnchors.counterparty_scope ?? [],
accountPrefixes, accountPrefixes,
userMessage: input.userMessage 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 ( if (
(claimType === "prove_settlement_closure_state" || claimType === "prove_advance_offset_state") && (claimType === "prove_settlement_closure_state" || claimType === "prove_advance_offset_state") &&
(settlementRole === "mixed" || settlementRole === "unknown") (settlementRole === "mixed" || settlementRole === "unknown")
@ -447,6 +466,8 @@ export function resolveClaimBoundAnchors(input: {
return { return {
claim_type: claimType, claim_type: claimType,
settlement_role: settlementRole, settlement_role: settlementRole,
settlement_role_resolution_reason: settlementRoleReason,
polarity_resolution_status: polarityResolutionStatus,
required_anchors: requiredAnchors, required_anchors: requiredAnchors,
resolved_anchors: resolvedAnchors, resolved_anchors: resolvedAnchors,
missing_anchors: missingAnchors, missing_anchors: missingAnchors,

View File

@ -848,12 +848,25 @@ export function resolveDomainPolarityGuard(input: {
(/(?:сч[её]т\s*62|по\s*62|счет\s*62|РїРѕ\s*62)/i.test(lower) ? 1 : 0); (/(?:сч[её]т\s*62|по\s*62|счет\s*62|РїРѕ\s*62)/i.test(lower) ? 1 : 0);
let polarity: DomainPolarity = "mixed_or_unresolved"; let polarity: DomainPolarity = "mixed_or_unresolved";
if (supplierScore > 0 && customerScore === 0) { if (supplierScore > 0 || customerScore > 0) {
if (supplierScore >= customerScore + 2) {
polarity = "supplier_payable"; polarity = "supplier_payable";
} else if (customerScore > 0 && supplierScore === 0) { } 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"; polarity = "customer_receivable";
} }
}
const unresolved = polarity === "mixed_or_unresolved"; 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 { return {
applied: true, applied: true,
polarity, polarity,
@ -868,7 +881,7 @@ export function resolveDomainPolarityGuard(input: {
rejected_problem_units: 0, rejected_problem_units: 0,
rejected_evidence: 0, rejected_evidence: 0,
critical_contradiction: unresolved, critical_contradiction: unresolved,
reason_codes: unresolved ? ["unresolved_supplier_customer_polarity"] : [] reason_codes: uniqueStrings(reasonCodes)
}; };
} }

View File

@ -202,18 +202,45 @@ function hasP0ClaimSignal(claimType, focusDomainHint) {
focusDomainHint === "month_close_costs_20_44" || focusDomainHint === "month_close_costs_20_44" ||
focusDomainHint === "fixed_asset_amortization"); 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) { function resolveBusinessScopeFromLiveContext(input) {
const current = input.current; const current = input.current;
const routeSummary = current?.route_summary_resolved; const routeSummary = current?.route_summary_resolved;
const julyResolved = isJuly2020TemporalResolved(input.temporalGuard); const julyResolved = isJuly2020TemporalResolved(input.temporalGuard);
const p0Signal = hasP0ClaimSignal(input.claimType, input.focusDomainHint); 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; return current;
} }
const reasons = Array.isArray(current.scope_resolution_reason) ? [...current.scope_resolution_reason] : []; 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"); 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 : []; const currentScopes = Array.isArray(current.business_scope_resolved) ? current.business_scope_resolved : [];
let changed = false; let changed = false;
const normalizedScopes = currentScopes const normalizedScopes = currentScopes
@ -1747,7 +1774,10 @@ export class AssistantService {
current: initialBusinessScopeResolution, current: initialBusinessScopeResolution,
temporalGuard, temporalGuard,
claimType: claimAnchorAudit.claim_type, claimType: claimAnchorAudit.claim_type,
focusDomainHint: focusDomainForGuards focusDomainHint: focusDomainForGuards,
userMessage,
companyAnchors,
followupApplied: Boolean(followupBinding.usage?.applied)
}); });
const resolvedRouteSummary = businessScopeResolution.route_summary_resolved; const resolvedRouteSummary = businessScopeResolution.route_summary_resolved;
const requirementExtraction = extractRequirements(resolvedRouteSummary, normalized.normalized, userMessage); const requirementExtraction = extractRequirements(resolvedRouteSummary, normalized.normalized, userMessage);
@ -1966,6 +1996,9 @@ export class AssistantService {
resolved_account_anchors: polarityGuardResult.audit.resolved_account_anchors, resolved_account_anchors: polarityGuardResult.audit.resolved_account_anchors,
domain_polarity_guard: polarityGuardResult.audit, domain_polarity_guard: polarityGuardResult.audit,
claim_anchor_audit: claimAnchorAudit, 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, targeted_evidence_acquisition: targetedEvidenceResult.audit,
evidence_admissibility_gate: evidenceGateResult.audit, evidence_admissibility_gate: evidenceGateResult.audit,
...(rbpLiveRouteAudit ? { rbp_live_route_audit: rbpLiveRouteAudit } : {}), ...(rbpLiveRouteAudit ? { rbp_live_route_audit: rbpLiveRouteAudit } : {}),
@ -2060,6 +2093,9 @@ export class AssistantService {
resolved_account_anchors: polarityGuardResult.audit.resolved_account_anchors, resolved_account_anchors: polarityGuardResult.audit.resolved_account_anchors,
domain_polarity_guard: polarityGuardResult.audit, domain_polarity_guard: polarityGuardResult.audit,
claim_anchor_audit: claimAnchorAudit, 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, targeted_evidence_acquisition: targetedEvidenceResult.audit,
evidence_admissibility_gate: evidenceGateResult.audit, evidence_admissibility_gate: evidenceGateResult.audit,
...(rbpLiveRouteAudit ? { rbp_live_route_audit: rbpLiveRouteAudit } : {}), ...(rbpLiveRouteAudit ? { rbp_live_route_audit: rbpLiveRouteAudit } : {}),

View File

@ -120,6 +120,8 @@ export interface ClaimBoundAnchorAuditDebug {
| "prove_rbp_tail_state" | "prove_rbp_tail_state"
| "prove_fixed_asset_amortization_coverage"; | "prove_fixed_asset_amortization_coverage";
settlement_role?: "supplier" | "customer" | "mixed" | "unknown"; 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[]; required_anchors: string[];
resolved_anchors: Record<string, string[]>; resolved_anchors: Record<string, string[]>;
missing_anchors: string[]; missing_anchors: string[];
@ -341,6 +343,9 @@ export interface AssistantDebugPayload {
resolved_account_anchors?: string[]; resolved_account_anchors?: string[];
domain_polarity_guard?: DomainPolarityGuardDebug; domain_polarity_guard?: DomainPolarityGuardDebug;
claim_anchor_audit?: ClaimBoundAnchorAuditDebug; 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; targeted_evidence_acquisition?: TargetedEvidenceAcquisitionDebug;
evidence_admissibility_gate?: EvidenceAdmissibilityGateDebug; evidence_admissibility_gate?: EvidenceAdmissibilityGateDebug;
rbp_live_route_audit?: RbpLiveRouteAuditDebug; rbp_live_route_audit?: RbpLiveRouteAuditDebug;

View File

@ -76,6 +76,37 @@ describe("assistant mode API", () => {
expect(session.body.session.items.length).toBe(4); 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 () => { it("executes factual retrieval for routed fragments", async () => {
const app = createApp(); const app = createApp();

View File

@ -243,6 +243,18 @@ describe("stage4 blocker-pack runtime guards", () => {
expect(units.some((item: any) => item.lifecycle_domain === "customer_settlement")).toBe(false); 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", () => { it("cleans polluted account anchors from date/amount numerics", () => {
const guard = resolveDomainPolarityGuard({ const guard = resolveDomainPolarityGuard({
userMessage: "VAT 233.33 on 13 july 2020 and 15 july 2020, account 68.02 in company snapshot.", 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.vat_signal).toHaveLength(0);
expect(claimAudit.resolved_anchors.fixed_asset_signal).toHaveLength(0); expect(claimAudit.resolved_anchors.fixed_asset_signal).toHaveLength(0);
expect(claimAudit.required_anchors).toContain("advance_signal"); 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", () => { it("keeps VAT priority over supplier wording in shared domain inference", () => {

Binary file not shown.