Post-F: закрепить семантическую целостность MCP-цепочек
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AssistantService } from "../src/services/assistantService";
|
||||
import { AssistantSessionStore } from "../src/services/assistantSessionStore";
|
||||
|
||||
@@ -1444,6 +1444,102 @@ describe("assistant address llm pre-decompose candidate preference", () => {
|
||||
expect(response.debug?.fallback_rule_hit).toBe("documents_counterparty_year_rewrite");
|
||||
});
|
||||
|
||||
it("rejects account injection when LLM truncates a numeric counterparty suffix", async () => {
|
||||
const calls: Array<{ message: string }> = [];
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string) => {
|
||||
calls.push({ message });
|
||||
return buildAddressLaneResult(message);
|
||||
})
|
||||
} as any;
|
||||
|
||||
const normalizerService = {
|
||||
normalize: vi.fn(async () => ({
|
||||
trace_id: "norm-predecompose-counterparty-suffix-account-injection",
|
||||
ok: true,
|
||||
normalized: {
|
||||
schema_version: "normalized_query_v2_0_2",
|
||||
user_message_raw: "Покажи документы по Жуковке 51.",
|
||||
message_in_scope: true,
|
||||
scope_confidence: "high",
|
||||
contains_multiple_tasks: false,
|
||||
fragments: [
|
||||
{
|
||||
fragment_id: "F1",
|
||||
raw_fragment_text: "Покажи документы по Жуковке 51.",
|
||||
normalized_fragment_text: "Показать документы, связанные с контрагентом Жуковка по счету 51",
|
||||
domain_relevance: "in_scope",
|
||||
business_scope: "company_specific_accounting",
|
||||
entity_hints: ["Жуковка"],
|
||||
account_hints: ["51"],
|
||||
document_hints: ["документы"],
|
||||
register_hints: [],
|
||||
semantic_hints: {
|
||||
scope_target_kind: "counterparty",
|
||||
scope_target_text: "Жуковка",
|
||||
date_scope_kind: "implicit_current",
|
||||
self_scope_detected: false,
|
||||
selected_object_scope_detected: false
|
||||
},
|
||||
time_scope: { type: "unspecified", value: null, confidence: "low" },
|
||||
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 }
|
||||
},
|
||||
raw_model_output: null,
|
||||
validation: { passed: true, errors: [] },
|
||||
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||||
latency_ms: 10,
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
request_count_for_case: 1
|
||||
}))
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const service = new AssistantService(
|
||||
normalizerService,
|
||||
sessions as any,
|
||||
{} as any,
|
||||
{ persistSession: vi.fn() } as any,
|
||||
addressQueryService
|
||||
);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: `asst-predecompose-counterparty-suffix-${Date.now()}`,
|
||||
user_message: "Покажи документы по Жуковке 51.",
|
||||
llmProvider: "local",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].message).toBe("Покажи документы по Жуковке 51.");
|
||||
expect(response.debug?.llm_decomposition_applied).toBe(false);
|
||||
expect(response.debug?.llm_decomposition_reason).toBe("normalized_fragment_rejected_anchor_injection");
|
||||
expect(response.debug?.llm_predecompose_contract?.entities?.account).toBeNull();
|
||||
expect(response.debug?.llm_predecompose_contract?.entities?.counterparty).toBe("Жуковке 51");
|
||||
});
|
||||
|
||||
it("rewrites payment-style counterparty phrasing to bank operations", async () => {
|
||||
const calls: Array<{ message: string }> = [];
|
||||
const addressQueryService = {
|
||||
|
||||
@@ -341,6 +341,62 @@ describe("assistant MCP discovery answer adapter", () => {
|
||||
expect(draft.next_step_line).toContain("организац");
|
||||
});
|
||||
|
||||
it("renders confirmed ranked value-flow without raw technical evidence lines", async () => {
|
||||
const planner = planAssistantMcpDiscovery({
|
||||
dataNeedGraph: {
|
||||
schema_version: "assistant_data_need_graph_v1",
|
||||
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
|
||||
subject_candidates: [],
|
||||
business_fact_family: "value_flow",
|
||||
action_family: "turnover",
|
||||
aggregation_need: null,
|
||||
time_scope_need: "explicit_period",
|
||||
comparison_need: null,
|
||||
ranking_need: "top_desc",
|
||||
proof_expectation: "coverage_checked_fact",
|
||||
clarification_gaps: [],
|
||||
decomposition_candidates: ["collect_scoped_movements", "aggregate_ranked_axis_values", "probe_coverage"],
|
||||
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
|
||||
reason_codes: ["data_need_graph_built", "data_need_graph_ranking_top_desc"]
|
||||
},
|
||||
turnMeaning: {
|
||||
asked_domain_family: "counterparty_value",
|
||||
asked_action_family: "turnover",
|
||||
explicit_organization_scope: "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441",
|
||||
explicit_date_scope: "2020",
|
||||
unsupported_but_understood_family: "counterparty_value_or_turnover"
|
||||
}
|
||||
});
|
||||
const pilot = await executeAssistantMcpDiscoveryPilot(
|
||||
planner,
|
||||
buildDeps([
|
||||
{
|
||||
Period: "2020-01-15T00:00:00",
|
||||
Amount: 12000,
|
||||
Counterparty: "\u0421\u0411\u0415\u0420\u0411\u0410\u041d\u041a, \u041f\u0410\u041e",
|
||||
Organization: "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"
|
||||
},
|
||||
{
|
||||
Period: "2020-02-20T00:00:00",
|
||||
Amount: 5000,
|
||||
Counterparty: "\u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a",
|
||||
Organization: "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"
|
||||
}
|
||||
])
|
||||
);
|
||||
|
||||
const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot);
|
||||
const userText = [...draft.confirmed_lines, ...draft.inference_lines, ...draft.unknown_lines].join("\n");
|
||||
|
||||
expect(draft.answer_mode).toBe("confirmed_with_bounded_inference");
|
||||
expect(draft.confirmed_lines).toHaveLength(1);
|
||||
expect(userText).toContain("\u0411\u043e\u043b\u044c\u0448\u0435 \u0432\u0441\u0435\u0433\u043e \u0434\u0435\u043d\u0435\u0433 \u043f\u0440\u0438\u043d\u0451\u0441 \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442");
|
||||
expect(userText).toContain("\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441");
|
||||
expect(userText).not.toContain("1C incoming value-flow");
|
||||
expect(userText).not.toContain("Full ranking outside");
|
||||
expect(draft.unknown_lines[0]).toContain("\u041f\u043e\u043b\u043d\u044b\u0439 \u0440\u0435\u0439\u0442\u0438\u043d\u0433");
|
||||
});
|
||||
|
||||
it("asks for both organization and period when an open total still misses both axes", async () => {
|
||||
const planner = planAssistantMcpDiscovery({
|
||||
dataNeedGraph: {
|
||||
|
||||
@@ -1426,6 +1426,45 @@ describe("assistant MCP discovery turn input adapter", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lets an explicit current entity search override stale ranking follow-up scope", () => {
|
||||
const staleEntity = "\u043f\u0440\u0438\u043d\u0451\u0441 \u043d\u0430\u0438\u0431\u043e\u043b\u044c\u0448\u0443\u044e \u0432\u044b\u0440\u0443\u0447\u043a\u0443 \u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u0438 \u0432";
|
||||
const result = buildAssistantMcpDiscoveryTurnInput({
|
||||
userMessage:
|
||||
"\u0422\u0435\u043f\u0435\u0440\u044c \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u0430\u044f \u0442\u0435\u043c\u0430 \u043f\u043e \u043a\u043e\u043d\u043a\u0440\u0435\u0442\u043d\u043e\u043c\u0443 \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0443. \u041d\u0430\u0439\u0434\u0438 \u0432 1\u0421 \u0413\u0440\u0443\u043f\u043f\u0443 \u0421\u0412\u041a.",
|
||||
assistantTurnMeaning: {
|
||||
asked_domain_family: "entity_resolution",
|
||||
asked_action_family: "search_business_entity",
|
||||
explicit_entity_candidates: [staleEntity],
|
||||
explicit_organization_scope: "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441",
|
||||
explicit_date_scope: "2021",
|
||||
unsupported_but_understood_family: "entity_resolution",
|
||||
stale_replay_forbidden: true
|
||||
},
|
||||
followupContext: {
|
||||
previous_discovery_pilot_scope: "counterparty_value_flow_query_movements_v1",
|
||||
previous_discovery_ranking_need: "top_desc",
|
||||
previous_filters: {
|
||||
organization: "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441",
|
||||
counterparty: staleEntity,
|
||||
period_from: "2021-01-01",
|
||||
period_to: "2021-12-31"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.adapter_status).toBe("ready");
|
||||
expect(result.should_run_discovery).toBe(true);
|
||||
expect(result.source_signal).toBe("raw_text");
|
||||
expect(result.turn_meaning_ref?.explicit_entity_candidates).toEqual(["\u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a"]);
|
||||
expect(result.turn_meaning_ref?.metadata_scope_hint).toBeUndefined();
|
||||
expect(result.turn_meaning_ref?.explicit_organization_scope).toBeUndefined();
|
||||
expect(result.turn_meaning_ref?.explicit_date_scope).toBeUndefined();
|
||||
expect(result.turn_meaning_ref?.seeded_ranking_need).toBeUndefined();
|
||||
expect(result.data_need_graph?.subject_candidates).toEqual(["\u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a"]);
|
||||
expect(result.reason_codes).toContain("mcp_discovery_entity_scope_from_raw_entity_search");
|
||||
expect(result.reason_codes).not.toContain("mcp_discovery_counterparty_from_followup_context");
|
||||
});
|
||||
|
||||
it("marks top-value wording as a ranking data need without inventing a missing subject gap", () => {
|
||||
const result = buildAssistantMcpDiscoveryTurnInput({
|
||||
userMessage: "кто больше всего принес денег в 2020"
|
||||
@@ -1444,6 +1483,46 @@ describe("assistant MCP discovery turn input adapter", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps open organization ranking subjectless when assistant meaning invents a predicate-shaped entity", () => {
|
||||
const result = buildAssistantMcpDiscoveryTurnInput({
|
||||
userMessage:
|
||||
"\u0418 \u043a\u0442\u043e \u0431\u043e\u043b\u044c\u0448\u0435 \u0432\u0441\u0435\u0433\u043e \u043f\u0440\u0438\u043d\u0435\u0441 \u0434\u0435\u043d\u0435\u0433 \u044d\u0442\u043e\u0439 \u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u0438 \u0432 2020 \u0433\u043e\u0434\u0443?",
|
||||
assistantTurnMeaning: {
|
||||
asked_domain_family: "counterparty_value",
|
||||
asked_action_family: "turnover",
|
||||
explicit_entity_candidates: [
|
||||
"\u043f\u0440\u0438\u043d\u0451\u0441 \u043d\u0430\u0438\u0431\u043e\u043b\u044c\u0448\u0443\u044e \u0432\u044b\u0440\u0443\u0447\u043a\u0443 \u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u0438 \u0432"
|
||||
],
|
||||
explicit_organization_scope: "\u044d\u0442\u0430 \u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044f",
|
||||
explicit_date_scope: "2020",
|
||||
unsupported_but_understood_family: "counterparty_value_or_turnover",
|
||||
stale_replay_forbidden: true
|
||||
},
|
||||
predecomposeContract: {
|
||||
entities: {
|
||||
organization: "\u044d\u0442\u0430 \u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044f"
|
||||
}
|
||||
},
|
||||
followupContext: {
|
||||
previous_discovery_pilot_scope: "counterparty_value_flow_query_movements_v1",
|
||||
previous_filters: {
|
||||
organization: "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.adapter_status).toBe("ready");
|
||||
expect(result.should_run_discovery).toBe(true);
|
||||
expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined();
|
||||
expect(result.turn_meaning_ref?.explicit_organization_scope).toBe(
|
||||
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"
|
||||
);
|
||||
expect(result.turn_meaning_ref?.explicit_date_scope).toBe("2020");
|
||||
expect(result.data_need_graph?.subject_candidates).toEqual([]);
|
||||
expect(result.data_need_graph?.ranking_need).toBe("top_desc");
|
||||
expect(result.data_need_graph?.decomposition_candidates).toContain("aggregate_ranked_axis_values");
|
||||
});
|
||||
|
||||
it("keeps organization as scope for open bidirectional comparison wording instead of inventing a subject candidate", () => {
|
||||
const result = buildAssistantMcpDiscoveryTurnInput({
|
||||
userMessage: "что больше: входящие или исходящие деньги за 2020 год по ООО Альтернатива Плюс?",
|
||||
|
||||
Reference in New Issue
Block a user