АДРЕСНЫЙ РЕЖИМ - ллм декомпоз
This commit is contained in:
@@ -5,6 +5,7 @@ import { classifyAddressQueryShape } from "../src/services/addressQueryShapeClas
|
||||
import { extractAddressFilters } from "../src/services/addressFilterExtractor";
|
||||
import { AddressQueryService } from "../src/services/addressQueryService";
|
||||
import { buildAddressRecipePlan, selectAddressRecipe } from "../src/services/addressRecipeCatalog";
|
||||
import { runAddressDecomposeStage } from "../src/services/address_runtime/decomposeStage";
|
||||
|
||||
describe("address query shape classifier", () => {
|
||||
it("classifies explain question as deep-shape", () => {
|
||||
@@ -225,6 +226,56 @@ describe("address filter extraction for balance drilldown", () => {
|
||||
expect(result.extracted_filters.counterparty).not.toBe("плс");
|
||||
});
|
||||
|
||||
it("extracts short ordinal year period from noisy docs phrase", () => {
|
||||
const result = extractAddressFilters(
|
||||
"бля епт покажи доки по свк за 20-й",
|
||||
"list_documents_by_counterparty"
|
||||
);
|
||||
expect(result.extracted_filters.counterparty).toBe("свк");
|
||||
expect(result.extracted_filters.period_from).toBe("2020-01-01");
|
||||
expect(result.extracted_filters.period_to).toBe("2020-12-31");
|
||||
expect(result.warnings).toContain("period_derived_from_year_phrase");
|
||||
});
|
||||
|
||||
it("does not use action verb as counterparty when phrase is 'Показать документы <counterparty>'", () => {
|
||||
const result = extractAddressFilters(
|
||||
"Показать документы СВК за 2020 год.",
|
||||
"list_documents_by_counterparty"
|
||||
);
|
||||
expect(result.extracted_filters.counterparty).toBe("СВК");
|
||||
expect(result.extracted_filters.counterparty).not.toBe("Показать");
|
||||
expect(result.extracted_filters.period_from).toBe("2020-01-01");
|
||||
expect(result.extracted_filters.period_to).toBe("2020-12-31");
|
||||
});
|
||||
|
||||
it("extracts counterparty and short year from transliterated noisy phrase", () => {
|
||||
const result = extractAddressFilters(
|
||||
"svk doki za 20 god pokezh",
|
||||
"list_documents_by_counterparty"
|
||||
);
|
||||
expect(result.extracted_filters.counterparty).toBe("svk");
|
||||
expect(result.extracted_filters.period_from).toBe("2020-01-01");
|
||||
expect(result.extracted_filters.period_to).toBe("2020-12-31");
|
||||
expect(
|
||||
result.warnings.some(
|
||||
(warning) =>
|
||||
warning === "counterparty_anchor_derived_from_free_text_heuristic" ||
|
||||
warning === "counterparty_anchor_derived_from_implicit_phrase"
|
||||
)
|
||||
).toBe(true);
|
||||
expect(result.warnings).toContain("period_derived_from_year_phrase");
|
||||
});
|
||||
|
||||
it("repairs mojibake phrase before extracting counterparty filters", () => {
|
||||
const result = extractAddressFilters(
|
||||
"Показать документы СВК за 2020 год.",
|
||||
"list_documents_by_counterparty"
|
||||
);
|
||||
expect(result.extracted_filters.counterparty).toBe("СВК");
|
||||
expect(result.extracted_filters.period_from).toBe("2020-01-01");
|
||||
expect(result.extracted_filters.period_to).toBe("2020-12-31");
|
||||
});
|
||||
|
||||
it("extracts explicit year range period from phrase", () => {
|
||||
const result = extractAddressFilters(
|
||||
"Какие документы по СВК за 2000 - 2025 год?",
|
||||
@@ -372,6 +423,46 @@ describe("address query limited taxonomy and stage diagnostics", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("address decompose stage follow-up carryover", () => {
|
||||
it("keeps short period follow-up in address lane and preserves previous counterparty anchor", () => {
|
||||
const result = runAddressDecomposeStage("а теперь только за май 2020", {
|
||||
previous_intent: "list_documents_by_counterparty",
|
||||
previous_filters: {
|
||||
counterparty: "свк",
|
||||
period_from: "2020-01-01",
|
||||
period_to: "2020-12-31"
|
||||
},
|
||||
previous_anchor_type: "counterparty",
|
||||
previous_anchor_value: "Группа СВК"
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.intent.intent).toBe("list_documents_by_counterparty");
|
||||
expect(result?.filters.extracted_filters.counterparty).toBe("свк");
|
||||
expect(result?.filters.extracted_filters.period_from).toBe("2020-05-01");
|
||||
expect(result?.filters.extracted_filters.period_to).toBe("2020-05-31");
|
||||
expect(result?.baseReasons).toContain("address_followup_context_applied");
|
||||
});
|
||||
|
||||
it("inherits as_of_date from previous period for same-date balance follow-up", () => {
|
||||
const result = runAddressDecomposeStage("а по счету 60.01 на ту же дату", {
|
||||
previous_intent: "list_documents_by_counterparty",
|
||||
previous_filters: {
|
||||
counterparty: "свк",
|
||||
period_from: "2020-05-01",
|
||||
period_to: "2020-05-31"
|
||||
},
|
||||
previous_anchor_type: "counterparty",
|
||||
previous_anchor_value: "Группа СВК"
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.intent.intent).toBe("account_balance_snapshot");
|
||||
expect(result?.filters.extracted_filters.account).toBe("60.01");
|
||||
expect(result?.filters.extracted_filters.as_of_date).toBe("2020-05-31");
|
||||
expect(result?.baseReasons).toContain("as_of_date_from_followup_context");
|
||||
expect(result?.baseReasons).toContain("address_followup_context_applied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("address recipe catalog counterparty filtering", () => {
|
||||
it("boosts limit for all-time counterparty queries", () => {
|
||||
const filters = extractAddressFilters(
|
||||
|
||||
@@ -120,15 +120,13 @@ describe("assistant address follow-up carryover", () => {
|
||||
expect(second.debug?.extracted_filters?.counterparty).toBe("свк");
|
||||
expect(second.debug?.answer_grounding_check?.reasons).toContain("address_followup_context_applied");
|
||||
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0].message).toBe("какие есть доки по свк с 2020 по 2025 год");
|
||||
expect(calls[1].message).toBe("а за все время?");
|
||||
expect(calls[1].options?.followupContext).toBeUndefined();
|
||||
expect(calls[2].message).toBe("а за все время?");
|
||||
expect(calls[2].options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
|
||||
expect(calls[2].options?.followupContext?.previous_anchor_type).toBe("counterparty");
|
||||
expect(calls[2].options?.followupContext?.previous_anchor_value).toBe("Группа СВК");
|
||||
expect(calls[2].options?.followupContext?.previous_filters?.counterparty).toBe("свк");
|
||||
expect(calls[1].options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
|
||||
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
|
||||
expect(calls[1].options?.followupContext?.previous_anchor_value).toBe("Группа СВК");
|
||||
expect(calls[1].options?.followupContext?.previous_filters?.counterparty).toBe("свк");
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AssistantService } from "../src/services/assistantService";
|
||||
import { AssistantSessionStore } from "../src/services/assistantSessionStore";
|
||||
|
||||
function buildAddressLaneResult(message: string): any {
|
||||
return {
|
||||
handled: true,
|
||||
reply_text: `handled: ${message}`,
|
||||
reply_type: "factual",
|
||||
response_type: "FACTUAL_LIST",
|
||||
debug: {
|
||||
detected_mode: "address_query",
|
||||
detected_mode_confidence: "high",
|
||||
query_shape: "DOCUMENT_LIST",
|
||||
query_shape_confidence: "medium",
|
||||
detected_intent: "list_documents_by_counterparty",
|
||||
detected_intent_confidence: "medium",
|
||||
extracted_filters: {
|
||||
sort: "period_desc",
|
||||
limit: 20,
|
||||
counterparty: "svk",
|
||||
period_from: "2020-01-01",
|
||||
period_to: "2020-12-31"
|
||||
},
|
||||
missing_required_filters: [],
|
||||
selected_recipe: "address_documents_by_counterparty_v1",
|
||||
mcp_call_status_legacy: "matched_non_empty",
|
||||
account_scope_mode: "preferred",
|
||||
account_scope_fallback_applied: false,
|
||||
anchor_type: "counterparty",
|
||||
anchor_value_raw: "svk",
|
||||
anchor_value_resolved: "Группа СВК",
|
||||
resolver_confidence: "medium",
|
||||
ambiguity_count: 0,
|
||||
match_failure_stage: "none",
|
||||
match_failure_reason: null,
|
||||
mcp_call_status: "matched_non_empty",
|
||||
rows_fetched: 20,
|
||||
raw_rows_received: 20,
|
||||
rows_after_account_scope: 5,
|
||||
rows_after_recipe_filter: 3,
|
||||
rows_materialized: 5,
|
||||
rows_matched: 3,
|
||||
raw_row_keys_sample: [],
|
||||
materialization_drop_reason: "none",
|
||||
account_token_raw: null,
|
||||
account_token_normalized: null,
|
||||
account_scope_fields_checked: ["account_dt", "account_kt", "registrator", "analytics"],
|
||||
account_scope_match_strategy: "account_code_regex_plus_alias_map_v1",
|
||||
account_scope_drop_reason: "not_applicable",
|
||||
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
|
||||
limited_reason_category: null,
|
||||
response_type: "FACTUAL_LIST",
|
||||
limitations: [],
|
||||
reasons: ["address_action_detected", "address_entity_detected", "document_list_signal_detected"]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("assistant address llm pre-decompose candidate preference", () => {
|
||||
it("prefers raw fragment when normalized fragment loses counterparty anchor", 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-1",
|
||||
ok: true,
|
||||
normalized: {
|
||||
schema_version: "normalized_query_v2_0_2",
|
||||
user_message_raw: "бля svk doki za 20 god pokezh pls",
|
||||
message_in_scope: true,
|
||||
scope_confidence: "medium",
|
||||
contains_multiple_tasks: false,
|
||||
fragments: [
|
||||
{
|
||||
fragment_id: "F1",
|
||||
raw_fragment_text: "svk doki za 20 god pokezh",
|
||||
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: "medium",
|
||||
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-${Date.now()}`,
|
||||
user_message: "бля svk doki za 20 god pokezh pls",
|
||||
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 doki za 20 god pokezh");
|
||||
expect(response.debug?.llm_decomposition_attempted).toBe(true);
|
||||
expect(response.debug?.llm_decomposition_applied).toBe(true);
|
||||
expect(response.debug?.llm_decomposition_effective_message).toBe("svk doki za 20 god pokezh");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { NormalizerService } from "../src/services/normalizerService";
|
||||
import type { OpenAIResponsesClient } from "../src/services/openaiResponsesClient";
|
||||
import type { NormalizedQueryV2_0_2 } from "../src/types/normalizer";
|
||||
|
||||
function makeService(outputText: string) {
|
||||
const normalizeMock = vi.fn().mockResolvedValue({
|
||||
raw: { mode: "stubbed" },
|
||||
outputText,
|
||||
usage: {
|
||||
input_tokens: 11,
|
||||
output_tokens: 22,
|
||||
total_tokens: 33
|
||||
}
|
||||
});
|
||||
const openaiClient = {
|
||||
normalize: normalizeMock
|
||||
} as unknown as OpenAIResponsesClient;
|
||||
return {
|
||||
service: new NormalizerService(openaiClient),
|
||||
normalizeMock
|
||||
};
|
||||
}
|
||||
|
||||
describe("NormalizerService coercion layer", () => {
|
||||
it("coerces malformed v2.0.2 local payload into a usable routed fragment", async () => {
|
||||
const malformed = {
|
||||
schema_version: "normalized_query_v2_0_2",
|
||||
global_notes: {
|
||||
needs_clarification: true,
|
||||
clarification_reason: "insufficient_specificity"
|
||||
},
|
||||
fragments: [
|
||||
{
|
||||
fragment_id: 1,
|
||||
raw_fragment_text: "свк доки за 20год покеж",
|
||||
normalized_fragment_text: "показать документы за 20 год",
|
||||
domain_relevance: true,
|
||||
business_scope: "document_review",
|
||||
entity_hints: [],
|
||||
account_hints: [],
|
||||
document_hints: ["документы"],
|
||||
register_hints: [],
|
||||
time_scope: {
|
||||
period_type: "year",
|
||||
year: 2020,
|
||||
month: null
|
||||
},
|
||||
flags: {},
|
||||
candidate_labels: ["show_documents"],
|
||||
confidence: 0.85,
|
||||
execution_readiness: "needs_clarification",
|
||||
clarification_reason: "insufficient_specificity",
|
||||
soft_assumption_used: [],
|
||||
route_status: "no_route",
|
||||
no_route_reason: "insufficient_specificity"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const { service, normalizeMock } = makeService(JSON.stringify(malformed));
|
||||
|
||||
const response = await service.normalize({
|
||||
llmProvider: "local",
|
||||
model: "qwen2.5-14b-instruct-1m",
|
||||
baseUrl: "http://127.0.0.1:1234/v1",
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
userQuestion: "свк доки за 20год покеж",
|
||||
retryPolicy: "single-pass-strict"
|
||||
});
|
||||
|
||||
expect(normalizeMock).toHaveBeenCalledTimes(1);
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.normalized?.schema_version).toBe("normalized_query_v2_0_2");
|
||||
|
||||
const normalized = response.normalized as NormalizedQueryV2_0_2;
|
||||
expect(normalized.fragments).toHaveLength(1);
|
||||
expect(normalized.fragments[0]).toMatchObject({
|
||||
fragment_id: "F1",
|
||||
domain_relevance: "in_scope",
|
||||
business_scope: "company_specific_accounting",
|
||||
confidence: "high",
|
||||
route_status: "routed",
|
||||
no_route_reason: null
|
||||
});
|
||||
expect(normalized.fragments[0].candidate_labels).toContain("simple_factual");
|
||||
expect(normalized.fragments[0].time_scope).toMatchObject({
|
||||
type: "explicit",
|
||||
value: "2020"
|
||||
});
|
||||
});
|
||||
|
||||
it("coerces period_type month payload into explicit YYYY-MM time scope", async () => {
|
||||
const malformed = {
|
||||
schema_version: "normalized_query_v2_0_2",
|
||||
fragments: [
|
||||
{
|
||||
fragment_id: "frag_main",
|
||||
raw_fragment_text: "Какой остаток по счету 60 на 2020 май",
|
||||
normalized_fragment_text: "остаток по счету 60 на май 2020",
|
||||
domain_relevance: "in_scope",
|
||||
business_scope: "company_specific_accounting",
|
||||
entity_hints: ["счет"],
|
||||
account_hints: ["60"],
|
||||
document_hints: [],
|
||||
register_hints: ["остаток"],
|
||||
time_scope: {
|
||||
period_type: "month",
|
||||
year: 2020,
|
||||
month: 5
|
||||
},
|
||||
flags: {
|
||||
asks_for_period_summary: false
|
||||
},
|
||||
candidate_labels: ["simple_factual"],
|
||||
confidence: 0.67
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const { service } = makeService(JSON.stringify(malformed));
|
||||
const response = await service.normalize({
|
||||
llmProvider: "local",
|
||||
model: "qwen2.5-14b-instruct-1m",
|
||||
baseUrl: "http://127.0.0.1:1234/v1",
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
userQuestion: "Какой остаток по счету 60 на 2020 май",
|
||||
retryPolicy: "single-pass-strict"
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const normalized = response.normalized as NormalizedQueryV2_0_2;
|
||||
expect(normalized.fragments).toHaveLength(1);
|
||||
expect(normalized.fragments[0].time_scope).toMatchObject({
|
||||
type: "explicit",
|
||||
value: "2020-05"
|
||||
});
|
||||
expect(normalized.fragments[0].route_status).toBe("routed");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user