АДРЕСНЫЙ РЕЖИМ - ADDRESS: стабилизация интерпретации сленга и mixed RU/EN, расширение intent/mode-роутинга, регресс-тесты и live stress 102/102
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user