Усилить answer contract и агентный аудит для phase105

This commit is contained in:
2026-05-21 09:00:08 +03:00
parent 9c86407937
commit bbc257fd6c
34 changed files with 1533 additions and 184 deletions
@@ -587,6 +587,7 @@ describe("address compose stage utf8 headers", () => {
expect(reply.text).toContain("не подтвержденная клиентская выручка");
expect(reply.text).toContain("Сводка по направлению");
expect(reply.text).toContain("Основание 1С");
expect(reply.text).toContain("По смыслу это скорее финансовый/банковский контур");
expect(reply.text).toContain("вид операции/назначение платежа/договор");
});
@@ -613,6 +614,8 @@ describe("address compose stage utf8 headers", () => {
expect(reply.text).toContain("Сводка по направлению");
expect(reply.text).toContain("Это не обычный поставщик автоматически");
expect(reply.text).toContain("Примеры строк 1С:");
expect(reply.text).toContain("Следующий шаг: могу отдельно разложить назначения платежа");
expect(reply.text).toContain("Показаны первые 5 из 8");
expect(reply.text).not.toContain("00000000007");
});
@@ -443,4 +443,86 @@ describe("address reply builders regressions", () => {
expect(result.text.split("\n")[0]).toContain("1.000,00");
expect(result.text).not.toContain("встречных остатков");
});
it("avoids mixed stock quantity as a fake management KPI in on-hand snapshot answers", () => {
const result = composeInventoryReply(
"inventory_on_hand_as_of_date",
[
{
amount: 695360,
quantity: 3,
item: "Рабочая станция",
warehouse: "Основной склад",
organization: 'ООО "Альтернатива Плюс"',
period: "2017-06-30",
registrator: "Остатки"
} as any,
{
amount: 295526.51,
quantity: 317000,
item: "Лифлеты",
warehouse: "Основной склад",
organization: 'ООО "Альтернатива Плюс"',
period: "2017-06-30",
registrator: "Остатки"
} as any
],
{
userMessage: "какие остатки на июнь 2017",
asOfDate: "2017-06-30"
},
{
resolvePayablesAsOfDate: () => "2017-06-30",
buildInventoryOnHandAggregate: () => [
{
item: "Рабочая станция",
warehouse: "Основной склад",
organization: 'ООО "Альтернатива Плюс"',
quantity: 3,
amount: 695360,
operations: 1,
firstPeriod: "2017-06-30",
lastPeriod: "2017-06-30",
sourceRefs: []
},
{
item: "Лифлеты",
warehouse: "Основной склад",
organization: 'ООО "Альтернатива Плюс"',
quantity: 317000,
amount: 295526.51,
operations: 1,
firstPeriod: "2017-06-30",
lastPeriod: "2017-06-30",
sourceRefs: []
}
],
uniqueStrings: (values: string[]) => Array.from(new Set(values)),
formatDateRu: (value: string) => value,
formatNumberWithDots: (value: number, fractionDigits = 0) => value.toFixed(fractionDigits),
formatMoneyRub: (value: number) => `${value}`,
isInventoryPurchaseMovement: () => false,
summarizeInventoryTraceRows: () => ({
item: null,
warehouses: [],
organizations: [],
counterparties: [],
documents: [],
firstPeriod: null,
lastPeriod: null,
totalAmount: 0
}),
formatInventoryTraceRows: () => [],
hasInventoryPurchaseDateActionFocus: () => false,
inventoryTraceDateLabel: () => "",
extractInventoryCounterpartyCandidates: () => [],
buildInventoryAgingByItemAggregate: () => [],
formatInventoryAgingRows: () => [],
isInventorySaleMovement: () => false
}
);
expect(result?.text).not.toContain("Суммарное количество");
expect(result?.text).toContain("Общее количество не свожу в один управленческий показатель");
expect(result?.text).toContain("Следующий шаг: могу раскрыть полный список");
});
});
@@ -3342,6 +3342,107 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.reason_codes).not.toContain("mcp_discovery_counterparty_from_followup_context");
});
it("pivots a business-overview follow-up with explicit counterparty net-flow wording back to counterparty value flow", () => {
const orgName =
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
const counterpartyName = "\u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a";
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage:
"\u0410 \u0442\u0435\u043f\u0435\u0440\u044c \u043f\u043e \u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a \u0437\u0430 2020: \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0434\u0435\u043d\u0435\u0433 \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438, \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438 \u0438 \u043a\u0430\u043a\u043e\u0435 \u043d\u0435\u0442\u0442\u043e?",
assistantTurnMeaning: {
asked_domain_family: "unknown",
asked_action_family: "unknown"
},
followupContext: {
previous_discovery_pilot_scope: "business_overview_route_template_v1",
previous_filters: {
organization: orgName,
period_from: "2020-01-01",
period_to: "2020-12-31"
}
}
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.semantic_data_need).toBe("counterparty value-flow evidence");
expect(result.data_need_graph?.business_fact_family).toBe("value_flow");
expect(result.data_need_graph?.subject_candidates).toEqual([counterpartyName]);
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_entity_candidates: [counterpartyName],
explicit_organization_scope: orgName,
explicit_date_scope: "2020",
unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting",
stale_replay_forbidden: true
});
expect(result.reason_codes).toContain(
"mcp_discovery_business_overview_followup_pivoted_to_counterparty_value_flow"
);
expect(result.reason_codes).toContain("mcp_discovery_business_overview_continuation_from_followup_context");
expect(result.reason_codes).toContain("mcp_discovery_counterparty_from_raw_scope");
expect(result.reason_codes).not.toContain("mcp_discovery_broad_business_evaluation_route_candidate");
expect(result.reason_codes).not.toContain("mcp_discovery_business_overview_suppressed_stale_counterparty");
});
it("survives poisoned business-overview turn meaning when predecompose still carries the counterparty", () => {
const orgName =
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
const counterpartyName = "\u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a";
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage:
"\u0410 \u0442\u0435\u043f\u0435\u0440\u044c \u043f\u043e \u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a \u0437\u0430 2020: \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0434\u0435\u043d\u0435\u0433 \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438, \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438 \u0438 \u043a\u0430\u043a\u043e\u0435 \u043d\u0435\u0442\u0442\u043e?",
effectiveMessage:
"\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0441\u0443\u043c\u043c\u0443 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u044b\u0445 \u0441\u0440\u0435\u0434\u0441\u0442\u0432, \u0441\u0443\u043c\u043c\u0443 \u0432\u044b\u043f\u043b\u0430\u0447\u0435\u043d\u043d\u044b\u0445 \u0441\u0440\u0435\u0434\u0441\u0442\u0432 \u0438 \u0447\u0438\u0441\u0442\u0443\u044e \u043f\u0440\u0438\u0431\u044b\u043b\u044c (\u043d\u0435\u0442\u0442\u043e) \u0434\u043b\u044f \u0433\u0440\u0443\u043f\u043f\u044b \u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0439 \u0421\u0412\u041a \u0437\u0430 2020 \u0433\u043e\u0434.",
assistantTurnMeaning: {
asked_domain_family: "business_overview",
asked_action_family: "profit_margin_boundary",
explicit_date_scope: "2020",
unsupported_but_understood_family: "profit_margin_boundary",
stale_replay_forbidden: true
},
predecomposeContract: {
entities: {
counterparty: counterpartyName
},
period: {
scope: "range",
period_from: "2020-01-01",
period_to: "2020-12-31"
}
},
followupContext: {
previous_discovery_pilot_scope: "business_overview_route_template_v1",
previous_filters: {
organization: orgName,
period_from: "2020-01-01",
period_to: "2020-12-31"
}
}
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.semantic_data_need).toBe("counterparty value-flow evidence");
expect(result.data_need_graph?.business_fact_family).toBe("value_flow");
expect(result.data_need_graph?.subject_candidates).toEqual([counterpartyName]);
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_entity_candidates: [counterpartyName],
explicit_organization_scope: orgName,
explicit_date_scope: "2020",
unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting",
stale_replay_forbidden: true
});
expect(result.reason_codes).toContain(
"mcp_discovery_business_overview_followup_pivoted_to_counterparty_value_flow"
);
expect(result.reason_codes).toContain("mcp_discovery_counterparty_from_predecompose");
expect(result.reason_codes).not.toContain("mcp_discovery_broad_business_evaluation_route_candidate");
});
it("lets an explicit VAT follow-up stay on the exact VAT route instead of stale business overview", () => {
const orgName =
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
@@ -57,6 +57,34 @@ describe("assistantTurnMeaningPolicy", () => {
]);
});
it("keeps counterparty net-flow wording in counterparty semantics even if effective text mentions profit", () => {
const policy = buildPolicy({
resolveAddressIntent: () => ({ intent: "unknown", confidence: "low" })
});
const meaning = policy.resolveAssistantTurnMeaning({
rawUserMessage:
"\u0410 \u0442\u0435\u043f\u0435\u0440\u044c \u043f\u043e \u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a \u0437\u0430 2020: \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0434\u0435\u043d\u0435\u0433 \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438, \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438 \u0438 \u043a\u0430\u043a\u043e\u0435 \u043d\u0435\u0442\u0442\u043e?",
effectiveAddressUserMessage:
"\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0441\u0443\u043c\u043c\u0443 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u044b\u0445 \u0441\u0440\u0435\u0434\u0441\u0442\u0432, \u0441\u0443\u043c\u043c\u0443 \u0432\u044b\u043f\u043b\u0430\u0447\u0435\u043d\u043d\u044b\u0445 \u0441\u0440\u0435\u0434\u0441\u0442\u0432 \u0438 \u0447\u0438\u0441\u0442\u0443\u044e \u043f\u0440\u0438\u0431\u044b\u043b\u044c (\u043d\u0435\u0442\u0442\u043e) \u0434\u043b\u044f \u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a \u0437\u0430 2020 \u0433\u043e\u0434."
});
expect(meaning.explicit_intent_candidate).toBeNull();
expect(meaning.asked_domain_family).toBe("counterparty_value");
expect(meaning.asked_action_family).toBe("net_value_flow");
expect(meaning.unsupported_but_understood_family).toBe("counterparty_bidirectional_value_flow_or_netting");
expect(meaning.explicit_entity_candidates).toEqual([
{
type: "counterparty",
value: "\u0433\u0440\u0443\u043f\u043f\u0430 \u0441\u0432\u043a",
source: "current_turn_loose_entity_tail"
}
]);
expect(meaning.reason_codes).toContain("counterparty_bidirectional_value_flow_current_turn_signal");
expect(meaning.reason_codes).not.toContain("broad_business_evaluation_current_turn_signal");
expect(meaning.stale_replay_forbidden).toBe(true);
});
it("ignores temporal tail words in all-time revenue ranking questions", () => {
const policy = buildPolicy({
resolveAddressIntent: (text: string) =>
@@ -109,7 +109,8 @@ describe("counterparty analytics reply builders", () => {
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
expect(reply.text).toContain("Оборот по СВК за доступное время: 3.500,00");
expect(reply.text).toContain("по 2 подтвержденным входящим операциям");
expect(reply.text).toContain("Это денежный поток от клиента, а не чистая прибыль");
expect(reply.text).toContain("Граница ответа: это подтвержденный денежный поток по поступлениям, а не чистая прибыль.");
expect(reply.text).toContain("Следующий шаг: могу разложить поток по месяцам, документам или контрагентам.");
expect(reply.text).not.toContain("Самый доходный клиент");
expect(reply.text).not.toContain("Топ-");
});
@@ -225,4 +225,63 @@ describe("routeHintAdapter", () => {
}
expect(summary.decisions[0]?.route).toBe("store_canonical");
});
it("routes counterparty received-paid-net wording to hybrid instead of canonical fact lookup", () => {
const summary = toRouteHintSummary({
schema_version: "normalized_query_v2_0_2",
user_message_raw: "А теперь по Группа СВК за 2020: сколько денег получили, сколько заплатили и какое нетто?",
message_in_scope: true,
scope_confidence: "high",
contains_multiple_tasks: false,
fragments: [
{
fragment_id: "F1",
raw_fragment_text: "А теперь по Группа СВК за 2020: сколько денег получили, сколько заплатили и какое нетто?",
normalized_fragment_text:
"Определить сумму полученных средств, сумму выплаченных средств и чистый остаток (нетто) для контрагента Группа СВК за период 2020 года.",
domain_relevance: "in_scope",
business_scope: "company_specific_accounting",
entity_hints: ["Группа СВК"],
account_hints: [],
document_hints: [],
register_hints: [],
time_scope: {
type: "explicit",
value: "2020",
confidence: "high"
},
flags: {
has_multi_entity_scope: false,
asks_for_chain_explanation: false,
asks_for_ranking_or_top: false,
asks_for_period_summary: false,
asks_for_rule_check: false,
asks_for_anomaly_scan: false,
asks_for_exact_object_trace: false,
asks_for_evidence: false,
mentions_period_close_context: false
},
candidate_labels: ["simple_factual"],
confidence: "high",
execution_readiness: "executable",
clarification_reason: null,
soft_assumption_used: [],
route_status: "routed",
no_route_reason: null
}
],
discarded_fragments: [],
global_notes: {
needs_clarification: false,
clarification_reason: null
}
});
expect(summary.mode).toBe("deterministic_v2");
if (summary.mode !== "deterministic_v2") {
throw new Error("Expected deterministic_v2 summary");
}
expect(summary.decisions[0]?.route).toBe("hybrid_store_plus_live");
expect(summary.decisions[0]?.reason).toContain("bidirectional_value_flow");
});
});