АДРЕСНЫЙ РЕЖИМ -ADDRESS:Шаг 1 - ЛЛМ ФЕРСТ + feat(address): стабилизация wave1 dynamic resolver контрагентов, follow-up carryover и актуализация docs/tests

This commit is contained in:
2026-04-02 23:11:49 +03:00
parent 53716e548e
commit 88094c09f8
243 changed files with 94296 additions and 181 deletions
File diff suppressed because it is too large Load Diff
@@ -126,4 +126,70 @@ describe("assistant address follow-up carryover", () => {
expect(calls[1].options?.followupContext?.previous_filters?.counterparty).toBe("свк");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("treats short 'по <anchor> также' phrase as follow-up for address lane", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "\u043f\u043e\u043a\u0430\u0436\u0438 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u043f\u043e \u0441\u0432\u043a \u0437\u0430 2020";
const followupMessage = "\u043f\u043e \u0441\u0432\u043a \u0442\u0430\u043a\u0436\u0435 \u043f\u043b\u0438\u0437";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage && !options?.followupContext) {
return null;
}
if (message === followupMessage && options?.followupContext) {
return buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
});
}
return buildAddressLaneResult();
})
} 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-short-${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: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(calls).toHaveLength(2);
expect(calls[1].message).toBe(followupMessage);
expect(calls[1].options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(typeof calls[1].options?.followupContext?.previous_anchor_value).toBe("string");
expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
});
@@ -155,6 +155,8 @@ describe("assistant address llm pre-decompose candidate preference", () => {
expect(response.debug?.fallback_rule_hit).toBeNull();
expect(response.debug?.sanitized_user_message).toBeTypeOf("string");
expect(response.debug?.tool_gate_decision).toBe("run_address_lane");
expect(response.debug?.llm_predecompose_contract?.schema_version).toBe("address_llm_predecompose_contract_v1");
expect(["unknown", "list_documents_by_counterparty"]).toContain(response.debug?.llm_predecompose_contract?.intent);
});
it("applies deterministic fallback rule when llm fragment is unusable", async () => {
@@ -214,6 +216,7 @@ describe("assistant address llm pre-decompose candidate preference", () => {
expect(response.debug?.fallback_rule_hit).toBe("documents_counterparty_year_rewrite");
expect(response.debug?.sanitized_user_message).toContain("свк");
expect(response.debug?.tool_gate_decision).toBe("run_address_lane");
expect(response.debug?.llm_predecompose_contract?.period?.scope).toBe("year");
});
it("keeps contract anchor in deterministic fallback when llm output is unusable", async () => {
@@ -600,12 +603,81 @@ describe("assistant address llm pre-decompose candidate preference", () => {
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe("по свк за весь период че есть");
expect(response.debug?.llm_decomposition_attempted).toBe(true);
expect(response.debug?.llm_decomposition_applied).toBe(false);
expect(response.debug?.llm_decomposition_reason).toBe("not_address_like");
expect(response.debug?.llm_decomposition_reason).toBe("no_usable_fragment");
expect(response.debug?.fallback_rule_hit).toBeNull();
expect(response.debug?.tool_gate_decision).toBe("run_address_lane");
});
it("uses llm canonical candidate as gate signal when regex path has no address markers", 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-llm-gate-signal",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "svk gib list",
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
fragments: [
{
fragment_id: "F1",
raw_fragment_text: "svk gib list",
normalized_fragment_text: "заказчики компании svk",
domain_relevance: "in_scope",
confidence: "medium",
execution_readiness: "no_route",
route_status: "no_route"
}
]
},
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-llm-gate-signal-${Date.now()}`,
user_message: "svk gib list",
llmProvider: "local",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe("заказчики компании svk");
expect(response.debug?.llm_decomposition_attempted).toBe(true);
expect(response.debug?.llm_decomposition_applied).toBe(true);
expect(response.debug?.llm_canonical_candidate_detected).toBe(true);
expect(response.debug?.tool_gate_decision).toBe("run_address_lane");
expect(["llm_canonical_candidate_detected", "address_mode_classifier_detected"]).toContain(response.debug?.tool_gate_reason);
});
it("normalizes short ordinal year like '20й' in noisy docs phrasing", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {