ARCH: удержать counterparty scope после contracts pivot в payments

This commit is contained in:
dctouch 2026-04-23 20:30:55 +03:00
parent 0528745e39
commit 7e1a2edadb
4 changed files with 206 additions and 6 deletions

View File

@ -0,0 +1,65 @@
{
"schema_version": "domain_truth_harness_spec_v1",
"scenario_id": "address_truth_harness_phase75_contracts_to_payments_year_switch",
"domain": "address_phase75_contracts_to_payments_year_switch",
"title": "Phase 75 contracts to payments year-switch continuity",
"description": "Replay for a human chain where the user opens documents by counterparty, pivots to contracts, then pivots again to payments via pronoun follow-up, and finally switches the year without renaming the counterparty.",
"bindings": {},
"steps": [
{
"step_id": "step_01_documents_by_counterparty",
"title": "Open documents for the counterparty",
"question": "Покажи документы по Жуковке 51.",
"allowed_reply_types": ["factual", "factual_with_explanation", "partial_coverage"],
"required_answer_patterns_all": [
"(?i)жуковк",
"(?i)документ|сч[её]т|акт|накладн|строк"
],
"criticality": "critical",
"semantic_tags": ["documents_by_counterparty", "pivot_seed", "integrity_guard"]
},
{
"step_id": "step_02_contracts_by_pronoun_followup",
"title": "Pivot to contracts",
"question": "А по нему договоры?",
"allowed_reply_types": ["factual", "factual_with_explanation", "partial_coverage"],
"required_answer_patterns_all": [
"(?i)жуковк|контрагент",
"(?i)договор|контракт|соглаш"
],
"criticality": "critical",
"semantic_tags": ["contracts_followup", "counterparty_pronoun_resolution", "integrity_guard"]
},
{
"step_id": "step_03_payments_after_contracts_pivot",
"title": "Pivot again to payments",
"question": "А по нему платежи?",
"allowed_reply_types": ["factual", "factual_with_explanation", "partial_coverage"],
"required_answer_patterns_all": [
"(?i)жуковк|контрагент",
"(?i)платеж|операц|банк|поступлен|списан"
],
"criticality": "critical",
"semantic_tags": ["payments_followup", "second_pivot", "integrity_guard"]
},
{
"step_id": "step_04_year_switch_after_second_pivot",
"title": "Switch the year after the second pivot",
"question": "А за 2021?",
"allowed_reply_types": ["factual", "factual_with_explanation", "partial_coverage"],
"required_answer_patterns_all": [
"(?i)2021",
"(?i)жуковк|контрагент",
"(?i)платеж|операц|банк|поступлен|списан"
],
"forbidden_answer_patterns": [
"(?i)уточните .* контрагент",
"(?i)метадан",
"(?i)схем",
"(?i)объект[а-я]* 1с"
],
"criticality": "critical",
"semantic_tags": ["year_switch_after_second_pivot", "payments_followup", "integrity_guard"]
}
]
}

View File

@ -2483,11 +2483,19 @@ function findRecentInventoryRootFrame(items) {
const ADDRESS_FOLLOWUP_OFFER_BY_INTENT = {
list_documents_by_counterparty: ["bank_operations_by_counterparty", "list_contracts_by_counterparty"],
bank_operations_by_counterparty: ["list_documents_by_counterparty", "list_contracts_by_counterparty"],
list_contracts_by_counterparty: ["list_documents_by_contract", "bank_operations_by_contract"],
list_documents_by_contract: ["bank_operations_by_contract"],
bank_operations_by_contract: ["list_documents_by_contract"],
open_items_by_counterparty_or_contract: ["list_documents_by_counterparty", "bank_operations_by_counterparty"]
};
function resolveAddressFollowupSuggestedIntents(intent, anchorType) {
if (intent === "list_contracts_by_counterparty") {
if (anchorType === "contract") {
return ["list_documents_by_contract", "bank_operations_by_contract"];
}
return ["list_documents_by_counterparty", "bank_operations_by_counterparty"];
}
return ADDRESS_FOLLOWUP_OFFER_BY_INTENT[intent] ?? null;
}
function buildAddressFollowupOffer(addressDebug) {
if (!isAddressLaneDebugPayload(addressDebug)) {
return null;
@ -2496,11 +2504,11 @@ function buildAddressFollowupOffer(addressDebug) {
if (!intent) {
return null;
}
const suggestedIntents = ADDRESS_FOLLOWUP_OFFER_BY_INTENT[intent];
const anchorContext = (0, assistantContinuityPolicy_1.resolveAddressDebugAnchorContext)(addressDebug, toNonEmptyString);
const suggestedIntents = resolveAddressFollowupSuggestedIntents(intent, anchorContext.anchorType);
if (!Array.isArray(suggestedIntents) || suggestedIntents.length === 0) {
return null;
}
const anchorContext = (0, assistantContinuityPolicy_1.resolveAddressDebugAnchorContext)(addressDebug, toNonEmptyString);
return {
enabled: true,
source_intent: intent,

View File

@ -2439,11 +2439,19 @@ function findRecentInventoryRootFrame(items) {
const ADDRESS_FOLLOWUP_OFFER_BY_INTENT = {
list_documents_by_counterparty: ["bank_operations_by_counterparty", "list_contracts_by_counterparty"],
bank_operations_by_counterparty: ["list_documents_by_counterparty", "list_contracts_by_counterparty"],
list_contracts_by_counterparty: ["list_documents_by_contract", "bank_operations_by_contract"],
list_documents_by_contract: ["bank_operations_by_contract"],
bank_operations_by_contract: ["list_documents_by_contract"],
open_items_by_counterparty_or_contract: ["list_documents_by_counterparty", "bank_operations_by_counterparty"]
} as Record<string, string[]>;
function resolveAddressFollowupSuggestedIntents(intent, anchorType) {
if (intent === "list_contracts_by_counterparty") {
if (anchorType === "contract") {
return ["list_documents_by_contract", "bank_operations_by_contract"];
}
return ["list_documents_by_counterparty", "bank_operations_by_counterparty"];
}
return ADDRESS_FOLLOWUP_OFFER_BY_INTENT[intent] ?? null;
}
function buildAddressFollowupOffer(addressDebug) {
if (!isAddressLaneDebugPayload(addressDebug)) {
return null;
@ -2452,11 +2460,11 @@ function buildAddressFollowupOffer(addressDebug) {
if (!intent) {
return null;
}
const suggestedIntents = ADDRESS_FOLLOWUP_OFFER_BY_INTENT[intent];
const anchorContext = (0, assistantContinuityPolicy_1.resolveAddressDebugAnchorContext)(addressDebug, toNonEmptyString);
const suggestedIntents = resolveAddressFollowupSuggestedIntents(intent, anchorContext.anchorType);
if (!Array.isArray(suggestedIntents) || suggestedIntents.length === 0) {
return null;
}
const anchorContext = (0, assistantContinuityPolicy_1.resolveAddressDebugAnchorContext)(addressDebug, toNonEmptyString);
return {
enabled: true,
source_intent: intent,

View File

@ -1496,6 +1496,125 @@ describe("assistant address follow-up carryover", () => {
expect(String(calls[0].message).toLowerCase()).toContain("свк");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
});
it("keeps counterparty scope when pivoting from contracts list to payments by pronoun", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "Покажи документы по Жуковке 51.";
const secondMessage = "А по нему договоры?";
const thirdMessage = "А по нему платежи?";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
const normalizedMessage = String(message ?? "").toLowerCase();
if (normalizedMessage.includes("документ") && normalizedMessage.includes("жуков")) {
return buildAddressLaneResult({
reply_text: "Собран список документов по контрагенту ТСЖ \\Жуковка 51\\.",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "list_documents_by_counterparty",
selected_recipe: "address_documents_by_counterparty_v1",
extracted_filters: {
counterparty: "ТСЖ \\Жуковка 51\\"
},
anchor_type: "counterparty",
anchor_value_raw: "Жуковке 51",
anchor_value_resolved: "ТСЖ \\Жуковка 51\\",
reasons: ["documents_by_counterparty_signal_detected"]
}
});
}
if (normalizedMessage.includes("договор")) {
return buildAddressLaneResult({
reply_text: [
"Коротко: найдено 1 договоров по контрагенту.",
"Контрагент: ТСЖ \\Жуковка 51\\.",
"1. Счет № 5 от 05.04.2017"
].join("\n"),
debug: {
...buildAddressLaneResult().debug,
detected_intent: "list_contracts_by_counterparty",
selected_recipe: "address_contracts_by_counterparty_v1",
extracted_filters: {
counterparty: "ТСЖ \\Жуковка 51\\"
},
anchor_type: "counterparty",
anchor_value_raw: "нему",
anchor_value_resolved: "ТСЖ \\Жуковка 51\\",
reasons: ["contracts_by_counterparty_signal_detected", "address_followup_context_applied"]
}
});
}
if (normalizedMessage.includes("плат") || normalizedMessage.includes("банк")) {
return buildAddressLaneResult({
reply_text: "Собран список банковских операций по контрагенту ТСЖ \\Жуковка 51\\.",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "bank_operations_by_counterparty",
selected_recipe: "address_bank_operations_by_counterparty_v1",
extracted_filters: {
counterparty: "ТСЖ \\Жуковка 51\\"
},
anchor_type: "counterparty",
anchor_value_raw: "нему",
anchor_value_resolved: "ТСЖ \\Жуковка 51\\",
reasons: ["bank_operations_by_counterparty_signal_detected", "address_followup_context_applied"]
}
});
}
return null;
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-contracts-payments-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: secondMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(second.debug?.detected_intent).toBe("list_contracts_by_counterparty");
const third = await service.handleMessage({
session_id: sessionId,
user_message: thirdMessage,
useMock: true
} as any);
expect(third.ok).toBe(true);
expect(third.reply_type).toBe("factual");
expect(third.debug?.detected_intent).toBe("bank_operations_by_counterparty");
expect(third.debug?.dialog_continuation_contract_v2?.target_intent).toBe("bank_operations_by_counterparty");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("keeps debt lifecycle follow-up context for 'а нам кто должен?.' after payables as-of answer", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage =