АДРЕСНЫЙ РЕЖИМ - ADDRESS: стабилизация интерпретации сленга и mixed RU/EN, расширение intent/mode-роутинга, регресс-тесты и live stress 102/102

This commit is contained in:
2026-04-02 11:01:28 +03:00
parent 7dd6607ded
commit da8a4eb872
68 changed files with 29777 additions and 3220 deletions
@@ -34,6 +34,17 @@ describe("address query shape classifier", () => {
const result = detectAddressQuestionMode("за любой период есть что-то по свк?");
expect(result.mode).toBe("address_query");
});
it("keeps slang transaction phrasing in address lane", () => {
const result = detectAddressQuestionMode("транзакции по свк за 2020");
expect(result.mode).toBe("address_query");
});
it("keeps short balance slang with compact account token in address lane", () => {
const result = detectAddressQuestionMode("скока по 60.02 на конец 2020-12");
expect(result.mode).toBe("address_query");
});
});
describe("address compose stage utf8 headers", () => {
@@ -87,6 +98,16 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("documents_forming_balance");
});
it("resolves documents forming balance for slang phrase with compact account token", () => {
const result = resolveAddressIntent("раскрой остаток 60.01 по документам на конец июля 2020");
expect(result.intent).toBe("documents_forming_balance");
});
it("resolves documents forming balance for 'доки под остатком' slang phrase", () => {
const result = resolveAddressIntent("доки под остатком 60.01 на 2020-07-31");
expect(result.intent).toBe("documents_forming_balance");
});
it("resolves documents by company phrase as counterparty intent", () => {
const result = resolveAddressIntent("Какие документы доступны по компании СВК за 2021 год?");
expect(result.intent).toBe("list_documents_by_counterparty");
@@ -107,6 +128,16 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("bank_operations_by_contract");
});
it("resolves shorthand bank-by-contract slang intent", () => {
const result = resolveAddressIntent("покажи банк опер по дог 15/24 пж");
expect(result.intent).toBe("bank_operations_by_contract");
});
it("resolves debt-by-contract query to open items intent", () => {
const result = resolveAddressIntent("Есть ли долг по договору 15/24 на 2020-07-31");
expect(result.intent).toBe("open_items_by_counterparty_or_contract");
});
it("resolves bank operations by contract for normalized phrase with linked contract wording", () => {
const result = resolveAddressIntent(
"Показать банковские операции (счета 51, 60, 62) связанные с договором 15/24."
@@ -139,6 +170,36 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("list_documents_by_counterparty");
});
it("resolves slang transactions phrase by counterparty", () => {
const result = resolveAddressIntent("транзакции по свк за 2020");
expect(result.intent).toBe("bank_operations_by_counterparty");
});
it("resolves short balance slang with compact account token", () => {
const result = resolveAddressIntent("скока по 60.02 на конец 2020-12");
expect(result.intent).toBe("account_balance_snapshot");
});
it("resolves colloquial 'что на счете' phrasing as account balance snapshot", () => {
const result = resolveAddressIntent("что на счете 60 на 2020.05");
expect(result.intent).toBe("account_balance_snapshot");
});
it("resolves mixed ru/en balance phrasing with account token", () => {
const result = resolveAddressIntent("баланс account 60.01 as of 2020-07-31");
expect(result.intent).toBe("account_balance_snapshot");
});
it("resolves 'по докам' slang as documents forming balance", () => {
const result = resolveAddressIntent("раскидай остаток 62.01 по докам на 2020-12-31");
expect(result.intent).toBe("documents_forming_balance");
});
it("resolves english compact docs-forming phrasing", () => {
const result = resolveAddressIntent("docs forming balance 60.01 as of 2020-07-31");
expect(result.intent).toBe("documents_forming_balance");
});
it("resolves loose by-anchor follow-up as documents by counterparty fallback", () => {
const result = resolveAddressIntent("за любой период есть что-то по свк?");
expect(result.intent).toBe("list_documents_by_counterparty");
@@ -174,6 +235,17 @@ describe("address filter extraction for balance drilldown", () => {
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
});
it("keeps all-time period by default for counterparty docs query without explicit window", () => {
const result = extractAddressFilters(
"Покажи документы по контрагенту тестовый",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("тестовый");
expect(result.extracted_filters.period_from).toBeUndefined();
expect(result.extracted_filters.period_to).toBeUndefined();
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
});
it("extracts counterparty from company phrase and derives year period", () => {
const result = extractAddressFilters(
"Какие документы доступны по компании СВК за 2021 год?",
@@ -62,9 +62,6 @@ describe("assistant address follow-up carryover", () => {
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === "какие есть доки по свк с 2020 по 2025 год") {
return buildAddressLaneResult();
}
if (message === "а за все время?" && !options?.followupContext) {
return null;
}
@@ -77,7 +74,7 @@ describe("assistant address follow-up carryover", () => {
}
});
}
return null;
return buildAddressLaneResult();
})
} as any;
@@ -101,7 +98,7 @@ describe("assistant address follow-up carryover", () => {
const sessionId = `asst-address-followup-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: "какие есть доки по свк с 2020 по 2025 год",
user_message: "покажи документы по свк за 2020",
useMock: true
} as any);
expect(first.ok).toBe(true);
@@ -121,7 +118,7 @@ describe("assistant address follow-up carryover", () => {
expect(second.debug?.answer_grounding_check?.reasons).toContain("address_followup_context_applied");
expect(calls).toHaveLength(2);
expect(calls[0].message).toBe("какие есть доки по свк с 2020 по 2025 год");
expect(calls[0].message.toLowerCase()).toContain("свк");
expect(calls[1].message).toBe("а за все время?");
expect(calls[1].options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
@@ -494,6 +494,118 @@ describe("assistant address llm pre-decompose candidate preference", () => {
expect(response.debug?.fallback_rule_hit).toBe("bank_operations_counterparty_year_rewrite");
});
it("rewrites shorthand bank/contract slang phrase to bank operations by contract", 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-bank-contract-slang",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "покажи банк опер по дог 15/24 пж",
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
fragments: []
},
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-bank-contract-slang-${Date.now()}`,
user_message: "покажи банк опер по дог 15/24 пж",
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("банковские операции по договору 15/24");
expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm");
expect(response.debug?.fallback_rule_hit).toBe("bank_operations_contract_rewrite");
});
it("keeps loose all-time colloquial lookup in address lane without forcing rewrite", 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-loose-all-time",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "по свк за весь период че есть",
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
fragments: []
},
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-loose-all-time-${Date.now()}`,
user_message: "по свк за весь период че есть",
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("по свк за весь период че есть");
expect(response.debug?.llm_decomposition_applied).toBe(false);
expect(response.debug?.llm_decomposition_reason).toBe("not_address_like");
expect(response.debug?.fallback_rule_hit).toBeNull();
expect(response.debug?.tool_gate_decision).toBe("run_address_lane");
});
it("normalizes short ordinal year like '20й' in noisy docs phrasing", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
@@ -0,0 +1,125 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { OpenAIResponsesClient } from "../src/services/openaiResponsesClient";
describe("openai local responses fallback", () => {
const originalFetch = global.fetch;
afterEach(() => {
vi.restoreAllMocks();
global.fetch = originalFetch;
});
it("falls back to /chat/completions when /responses payload is parseable JSON but has no output_text", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ id: "resp-1", output: [], usage: { prompt_tokens: 4, completion_tokens: 3 } }), {
status: 200,
headers: { "content-type": "application/json" }
})
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
id: "chat-1",
choices: [{ message: { content: "{\"schema_version\":\"normalized_query_v2_0_2\"}" } }],
usage: { prompt_tokens: 7, completion_tokens: 5, total_tokens: 12 }
}),
{
status: 200,
headers: { "content-type": "application/json" }
}
)
);
global.fetch = fetchMock as unknown as typeof fetch;
const client = new OpenAIResponsesClient();
const response = await client.normalize(
{
llmProvider: "local",
apiKey: "",
model: "qwen2.5-14b-instruct-1m",
baseUrl: "http://127.0.0.1:1234",
temperature: 0
},
{
systemPrompt: "system",
developerPrompt: "developer",
domainPrompt: "domain",
userQuestion: "question",
schemaVersion: "v2_0_2"
}
);
expect(response.outputText).toContain("\"schema_version\":\"normalized_query_v2_0_2\"");
expect(response.usage.total_tokens).toBe(12);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(String(fetchMock.mock.calls[0]?.[0] ?? "")).toContain("/responses");
expect(String(fetchMock.mock.calls[1]?.[0] ?? "")).toContain("/chat/completions");
});
it("retries alternative local base when endpoint mismatch payload is returned for chat completions", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ error: { message: "Unexpected endpoint or method. (POST /responses)" } }), {
status: 404,
headers: { "content-type": "application/json" }
})
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ error: { message: "Unexpected endpoint or method. (POST /responses)" } }), {
status: 404,
headers: { "content-type": "application/json" }
})
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ error: "Unexpected endpoint or method. (POST /chat/completions)" }), {
status: 200,
headers: { "content-type": "application/json" }
})
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
id: "chat-v1",
choices: [{ message: { content: "{\"schema_version\":\"normalized_query_v2_0_2\"}" } }],
usage: { prompt_tokens: 10, completion_tokens: 7, total_tokens: 17 }
}),
{
status: 200,
headers: { "content-type": "application/json" }
}
)
);
global.fetch = fetchMock as unknown as typeof fetch;
const client = new OpenAIResponsesClient();
const response = await client.normalize(
{
llmProvider: "local",
apiKey: "",
model: "qwen2.5-14b-instruct-1m",
baseUrl: "http://127.0.0.1:1234",
temperature: 0
},
{
systemPrompt: "system",
developerPrompt: "developer",
domainPrompt: "domain",
userQuestion: "question",
schemaVersion: "v2_0_2"
}
);
expect(response.outputText).toContain("\"schema_version\":\"normalized_query_v2_0_2\"");
expect(response.usage.total_tokens).toBe(17);
expect(fetchMock).toHaveBeenCalledTimes(4);
expect(String(fetchMock.mock.calls[0]?.[0] ?? "")).toBe("http://127.0.0.1:1234/responses");
expect(String(fetchMock.mock.calls[1]?.[0] ?? "")).toBe("http://127.0.0.1:1234/v1/responses");
expect(String(fetchMock.mock.calls[2]?.[0] ?? "")).toBe("http://127.0.0.1:1234/chat/completions");
expect(String(fetchMock.mock.calls[3]?.[0] ?? "")).toBe("http://127.0.0.1:1234/v1/chat/completions");
});
});