АДРЕСНЫЙ РЕЖИМ - локальная подель на декомпозе
This commit is contained in:
@@ -13,12 +13,11 @@ import type {
|
||||
AddressResponseType,
|
||||
AddressRuntimeReadiness
|
||||
} from "../types/addressQuery";
|
||||
import { detectAddressQuestionMode } from "./addressQueryClassifier";
|
||||
import { classifyAddressQueryShape } from "./addressQueryShapeClassifier";
|
||||
import { resolveAddressIntent } from "./addressIntentResolver";
|
||||
import { extractAddressFilters } from "./addressFilterExtractor";
|
||||
import { buildAddressRecipePlan, selectAddressRecipe } from "./addressRecipeCatalog";
|
||||
import { executeAddressMcpQuery } from "./addressMcpClient";
|
||||
import { runAddressDecomposeStage, type AddressFollowupContext } from "./address_runtime/decomposeStage";
|
||||
import { resolvePrimaryAnchor, refineAnchorFromRows, type AnchorResolutionDebug } from "./address_runtime/resolveStage";
|
||||
import { composeFactualReply, contractCandidatesFromRows, inferReplyType } from "./address_runtime/composeStage";
|
||||
|
||||
interface NormalizedAddressRow {
|
||||
period: string | null;
|
||||
@@ -29,6 +28,10 @@ interface NormalizedAddressRow {
|
||||
analytics: string[];
|
||||
}
|
||||
|
||||
interface AddressTryHandleOptions {
|
||||
followupContext?: AddressFollowupContext | null;
|
||||
}
|
||||
|
||||
const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"] as const;
|
||||
const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1" as const;
|
||||
const PARTY_ANCHOR_STOPWORDS = new Set([
|
||||
@@ -388,21 +391,65 @@ function applyIntentSpecificFilter(intent: AddressIntent, rows: NormalizedAddres
|
||||
return rows;
|
||||
}
|
||||
|
||||
function formatTopRows(rows: NormalizedAddressRow[], limit = 6): string[] {
|
||||
return rows.slice(0, limit).map((row, index) => {
|
||||
const period = row.period ?? "дата не указана";
|
||||
const amount = row.amount !== null ? `${row.amount}` : "сумма не указана";
|
||||
const accounts = [row.account_dt ?? "-", row.account_kt ?? "-"].join(" / ");
|
||||
const analytics = row.analytics.length > 0 ? ` | аналитика: ${row.analytics.slice(0, 2).join("; ")}` : "";
|
||||
return `${index + 1}. ${period} | ${row.registrator} | ${accounts} | ${amount}${analytics}`;
|
||||
});
|
||||
function hasExplicitPeriodWindow(filters: AddressFilterSet): boolean {
|
||||
return (
|
||||
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0) ||
|
||||
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function inferReplyType(responseType: AddressResponseType): "factual" | "partial_coverage" {
|
||||
if (responseType === "FACTUAL_LIST" || responseType === "FACTUAL_SUMMARY") {
|
||||
return "factual";
|
||||
function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilterSet): boolean {
|
||||
if (!hasExplicitPeriodWindow(filters)) {
|
||||
return false;
|
||||
}
|
||||
return "partial_coverage";
|
||||
return intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty";
|
||||
}
|
||||
|
||||
function toIsoDatePrefix(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const normalized = String(value).trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const match = normalized.match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveObservedPeriodWindow(rows: NormalizedAddressRow[]): { period_from: string | null; period_to: string | null } {
|
||||
const dates = rows
|
||||
.map((row) => toIsoDatePrefix(row.period))
|
||||
.filter((item): item is string => Boolean(item))
|
||||
.sort();
|
||||
if (dates.length === 0) {
|
||||
return {
|
||||
period_from: null,
|
||||
period_to: null
|
||||
};
|
||||
}
|
||||
return {
|
||||
period_from: dates[0],
|
||||
period_to: dates[dates.length - 1]
|
||||
};
|
||||
}
|
||||
|
||||
function composeAutoBroadenedPeriodPrefix(
|
||||
requested: AddressFilterSet,
|
||||
observed: { period_from: string | null; period_to: string | null }
|
||||
): string {
|
||||
const requestedFrom = typeof requested.period_from === "string" ? requested.period_from : null;
|
||||
const requestedTo = typeof requested.period_to === "string" ? requested.period_to : null;
|
||||
if (requestedFrom && requestedTo && observed.period_from && observed.period_to) {
|
||||
return `По окну ${requestedFrom}..${requestedTo} строк не найдено; показаны ближайшие доступные данные ${observed.period_from}..${observed.period_to}.`;
|
||||
}
|
||||
if (requestedFrom && requestedTo) {
|
||||
return `По окну ${requestedFrom}..${requestedTo} строк не найдено; показаны ближайшие доступные данные по этому якорю.`;
|
||||
}
|
||||
return "По заданному периоду строк не найдено; показаны ближайшие доступные данные по этому якорю.";
|
||||
}
|
||||
|
||||
function runtimeReadinessForLimitedCategory(category: AddressLimitedReasonCategory): AddressRuntimeReadiness {
|
||||
@@ -418,14 +465,6 @@ function runtimeReadinessForLimitedCategory(category: AddressLimitedReasonCatego
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
interface AnchorResolutionDebug {
|
||||
anchor_type: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null;
|
||||
anchor_value_raw: string | null;
|
||||
anchor_value_resolved: string | null;
|
||||
resolver_confidence: "high" | "medium" | "low" | null;
|
||||
ambiguity_count: number;
|
||||
}
|
||||
|
||||
interface RowStageDiagnostics {
|
||||
rawRowKeysSample: string[];
|
||||
materializationDropReason:
|
||||
@@ -580,99 +619,6 @@ function toLegacyMcpStatus(
|
||||
return status;
|
||||
}
|
||||
|
||||
function resolvePrimaryAnchor(intent: AddressIntent, filters: AddressFilterSet): AnchorResolutionDebug {
|
||||
const account = typeof filters.account === "string" ? filters.account.trim() : "";
|
||||
const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : "";
|
||||
const contract = typeof filters.contract === "string" ? filters.contract.trim() : "";
|
||||
const documentRef = typeof filters.document_ref === "string" ? filters.document_ref.trim() : "";
|
||||
|
||||
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
|
||||
if (account) {
|
||||
return {
|
||||
anchor_type: "account",
|
||||
anchor_value_raw: account,
|
||||
anchor_value_resolved: account,
|
||||
resolver_confidence: "high",
|
||||
ambiguity_count: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (counterparty) {
|
||||
return {
|
||||
anchor_type: "counterparty",
|
||||
anchor_value_raw: counterparty,
|
||||
anchor_value_resolved: counterparty,
|
||||
resolver_confidence: "medium",
|
||||
ambiguity_count: 0
|
||||
};
|
||||
}
|
||||
|
||||
if (contract) {
|
||||
return {
|
||||
anchor_type: "contract",
|
||||
anchor_value_raw: contract,
|
||||
anchor_value_resolved: contract,
|
||||
resolver_confidence: "medium",
|
||||
ambiguity_count: 0
|
||||
};
|
||||
}
|
||||
|
||||
if (documentRef) {
|
||||
return {
|
||||
anchor_type: "document_ref",
|
||||
anchor_value_raw: documentRef,
|
||||
anchor_value_resolved: documentRef,
|
||||
resolver_confidence: "medium",
|
||||
ambiguity_count: 0
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
anchor_type: "unknown",
|
||||
anchor_value_raw: null,
|
||||
anchor_value_resolved: null,
|
||||
resolver_confidence: "low",
|
||||
ambiguity_count: 0
|
||||
};
|
||||
}
|
||||
|
||||
function refineAnchorFromRows(anchor: AnchorResolutionDebug, rows: NormalizedAddressRow[]): AnchorResolutionDebug {
|
||||
if (rows.length === 0) {
|
||||
return anchor;
|
||||
}
|
||||
if (anchor.anchor_type !== "counterparty" && anchor.anchor_type !== "contract") {
|
||||
return anchor;
|
||||
}
|
||||
const needleRaw = String(anchor.anchor_value_raw ?? "").trim();
|
||||
if (!needleRaw) {
|
||||
return anchor;
|
||||
}
|
||||
const candidates = uniqueStrings(
|
||||
rows
|
||||
.flatMap((row) => row.analytics)
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length >= 2 && matchesAnchorText(value, needleRaw))
|
||||
);
|
||||
if (candidates.length === 0) {
|
||||
return anchor;
|
||||
}
|
||||
if (candidates.length === 1) {
|
||||
return {
|
||||
...anchor,
|
||||
anchor_value_resolved: candidates[0],
|
||||
resolver_confidence: anchor.resolver_confidence === "high" ? "high" : "medium",
|
||||
ambiguity_count: 0
|
||||
};
|
||||
}
|
||||
return {
|
||||
...anchor,
|
||||
anchor_value_resolved: candidates[0],
|
||||
resolver_confidence: "low",
|
||||
ambiguity_count: candidates.length - 1
|
||||
};
|
||||
}
|
||||
|
||||
function composeLimitedReply(category: AddressLimitedReasonCategory, reason: string, nextStep?: string): string {
|
||||
const heading =
|
||||
category === "empty_match"
|
||||
@@ -777,137 +723,20 @@ function buildLimitedExecutionResult(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function contractCandidatesFromRows(rows: NormalizedAddressRow[]): string[] {
|
||||
const candidates: string[] = [];
|
||||
for (const row of rows) {
|
||||
for (const token of [row.registrator, ...row.analytics]) {
|
||||
const normalized = token.trim();
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
if (/договор|contract|дог\./i.test(normalized)) {
|
||||
candidates.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
return uniqueStrings(candidates);
|
||||
}
|
||||
|
||||
function composeFactualReply(intent: AddressIntent, rows: NormalizedAddressRow[]): { responseType: AddressResponseType; text: string } {
|
||||
if (intent === "account_balance_snapshot") {
|
||||
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
const lines = [
|
||||
"Адресный срез по счету собран (по движениям live MCP).",
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
`Сумма по отобранным движениям: ${movementSum}.`,
|
||||
...formatTopRows(rows, 4)
|
||||
];
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "documents_forming_balance") {
|
||||
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
const lines = [
|
||||
"Собран drilldown документов, формирующих остаток по счету на указанную дату.",
|
||||
`Документных строк отобрано: ${rows.length}.`,
|
||||
`Сумма по отобранным движениям: ${movementSum}.`,
|
||||
...formatTopRows(rows, 8),
|
||||
"Можно уточнить выборку по контрагенту, договору или периоду."
|
||||
];
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "list_open_contracts") {
|
||||
const contracts = contractCandidatesFromRows(rows);
|
||||
const lines = [
|
||||
"Собраны кандидаты по незакрытым договорным позициям (по live движениям 60/62/76).",
|
||||
`Строк движения: ${rows.length}.`,
|
||||
`Договорных кандидатов: ${contracts.length}.`
|
||||
];
|
||||
lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`));
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "open_items_by_counterparty_or_contract") {
|
||||
const lines = [
|
||||
"Собраны открытые позиции по указанному фильтру (контрагент/договор).",
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
...formatTopRows(rows, 6)
|
||||
];
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "list_documents_by_counterparty") {
|
||||
const lines = [
|
||||
"Собран список документов по контрагенту (live address lane).",
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
...formatTopRows(rows, 8)
|
||||
];
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "bank_operations_by_counterparty") {
|
||||
const lines = [
|
||||
"Собран список банковских операций по контрагенту (live address lane).",
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
...formatTopRows(rows, 8)
|
||||
];
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
const title =
|
||||
intent === "list_payables_counterparties"
|
||||
? "Срез обязательств (payables) собран по движениям с account scope 60/76."
|
||||
: intent === "list_receivables_counterparties"
|
||||
? "Срез требований (receivables) собран по движениям с account scope 62/76."
|
||||
: "Срез адресного запроса собран.";
|
||||
|
||||
const lines = [title, `Строк отобрано: ${rows.length}.`, ...formatTopRows(rows, 6)];
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
export class AddressQueryService {
|
||||
public async tryHandle(userMessage: string): Promise<AddressExecutionResult | null> {
|
||||
public async tryHandle(userMessage: string, options: AddressTryHandleOptions = {}): Promise<AddressExecutionResult | null> {
|
||||
if (!FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mode = detectAddressQuestionMode(userMessage);
|
||||
if (mode.mode !== "address_query") {
|
||||
const followupContext = options.followupContext ?? null;
|
||||
const decompose = runAddressDecomposeStage(userMessage, followupContext);
|
||||
if (!decompose) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shape = classifyAddressQueryShape(userMessage);
|
||||
if (shape.shape === "EXPLAIN_OR_REASON") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const intent = resolveAddressIntent(userMessage);
|
||||
const filters = extractAddressFilters(userMessage, intent.intent);
|
||||
const { mode, shape, intent, filters, baseReasons } = decompose;
|
||||
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
|
||||
const recipeSelection = selectAddressRecipe(intent.intent, filters.extracted_filters);
|
||||
const baseReasons = [...mode.reasons, ...shape.reasons, ...intent.reasons];
|
||||
|
||||
if (intent.intent === "unknown") {
|
||||
return buildLimitedExecutionResult({
|
||||
@@ -1130,6 +959,112 @@ export class AddressQueryService {
|
||||
});
|
||||
}
|
||||
|
||||
if (filteredRows.length === 0 && canAutoBroadenPeriodWindow(intent.intent, filters.extracted_filters)) {
|
||||
const autoBroadenedFilters: AddressFilterSet = { ...filters.extracted_filters };
|
||||
delete autoBroadenedFilters.period_from;
|
||||
delete autoBroadenedFilters.period_to;
|
||||
const broadenedSelection = selectAddressRecipe(intent.intent, autoBroadenedFilters);
|
||||
if (broadenedSelection.selected_recipe && broadenedSelection.missing_required_filters.length === 0) {
|
||||
const broadenedPlan = buildAddressRecipePlan(broadenedSelection.selected_recipe, autoBroadenedFilters);
|
||||
const broadenedMcp = await executeAddressMcpQuery({
|
||||
query: broadenedPlan.query,
|
||||
limit: broadenedPlan.limit
|
||||
});
|
||||
if (!broadenedMcp.error) {
|
||||
const broadenedRawRows = toNormalizedRows(broadenedMcp.raw_rows);
|
||||
const broadenedScopedRows = applyAccountScopeFilter(broadenedRawRows, broadenedPlan.account_scope);
|
||||
const broadenedAccountScopeFallbackApplied =
|
||||
broadenedPlan.account_scope_mode === "preferred" &&
|
||||
broadenedPlan.account_scope.length > 0 &&
|
||||
broadenedRawRows.length > 0 &&
|
||||
broadenedScopedRows.length === 0;
|
||||
const broadenedNormalizedRows = broadenedAccountScopeFallbackApplied ? broadenedRawRows : broadenedScopedRows;
|
||||
let broadenedAnchor = resolvePrimaryAnchor(intent.intent, autoBroadenedFilters);
|
||||
broadenedAnchor = refineAnchorFromRows(broadenedAnchor, broadenedNormalizedRows);
|
||||
const broadenedFiltersForMatching: AddressFilterSet =
|
||||
broadenedAnchor.anchor_type === "counterparty" && broadenedAnchor.anchor_value_resolved
|
||||
? { ...autoBroadenedFilters, counterparty: broadenedAnchor.anchor_value_resolved }
|
||||
: broadenedAnchor.anchor_type === "contract" && broadenedAnchor.anchor_value_resolved
|
||||
? { ...autoBroadenedFilters, contract: broadenedAnchor.anchor_value_resolved }
|
||||
: autoBroadenedFilters;
|
||||
const broadenedAccountScopeAudit = buildAccountScopeAudit({
|
||||
intent: intent.intent,
|
||||
filters: broadenedFiltersForMatching,
|
||||
accountScope: broadenedPlan.account_scope,
|
||||
rowsBeforeScope: broadenedRawRows.length,
|
||||
rowsAfterScope: broadenedNormalizedRows.length
|
||||
});
|
||||
const broadenedAnchorFilter = applyAddressFilters(broadenedNormalizedRows, broadenedFiltersForMatching);
|
||||
const broadenedRowsByAnchor = broadenedAnchorFilter.rows;
|
||||
const broadenedFilteredRows = applyIntentSpecificFilter(intent.intent, broadenedRowsByAnchor);
|
||||
if (broadenedFilteredRows.length > 0) {
|
||||
const broadenedRowDiagnostics = deriveRowStageDiagnostics(
|
||||
broadenedMcp.raw_rows,
|
||||
broadenedNormalizedRows.length,
|
||||
broadenedNormalizedRows.length
|
||||
);
|
||||
const broadenedStageStatus = deriveMcpStageStatus({
|
||||
rawRowsReceived: broadenedMcp.raw_rows.length,
|
||||
rowsMaterialized: broadenedNormalizedRows.length,
|
||||
rowsAnchorMatched: broadenedRowsByAnchor.length,
|
||||
rowsMatched: broadenedFilteredRows.length
|
||||
});
|
||||
const observedWindow = deriveObservedPeriodWindow(broadenedFilteredRows);
|
||||
const broadenedPrefix = composeAutoBroadenedPeriodPrefix(filters.extracted_filters, observedWindow);
|
||||
const broadenedFactual = composeFactualReply(intent.intent, broadenedFilteredRows);
|
||||
const broadenedLimitations = [...filters.warnings, "period_window_auto_broadened_to_available_data"];
|
||||
const broadenedReasons = [...baseReasons, "period_window_auto_broadened_to_available_data"];
|
||||
return {
|
||||
handled: true,
|
||||
reply_text: `${broadenedPrefix}\n${broadenedFactual.text}`,
|
||||
reply_type: inferReplyType(broadenedFactual.responseType),
|
||||
response_type: broadenedFactual.responseType,
|
||||
debug: {
|
||||
detected_mode: mode.mode,
|
||||
detected_mode_confidence: mode.confidence,
|
||||
query_shape: shape.shape,
|
||||
query_shape_confidence: shape.confidence,
|
||||
detected_intent: intent.intent,
|
||||
detected_intent_confidence: intent.confidence,
|
||||
extracted_filters: filters.extracted_filters,
|
||||
missing_required_filters: [],
|
||||
selected_recipe: broadenedSelection.selected_recipe.recipe_id,
|
||||
mcp_call_status_legacy: toLegacyMcpStatus(broadenedStageStatus),
|
||||
account_scope_mode: broadenedPlan.account_scope_mode,
|
||||
account_scope_fallback_applied: broadenedAccountScopeFallbackApplied,
|
||||
anchor_type: broadenedAnchor.anchor_type,
|
||||
anchor_value_raw: broadenedAnchor.anchor_value_raw,
|
||||
anchor_value_resolved: broadenedAnchor.anchor_value_resolved,
|
||||
resolver_confidence: broadenedAnchor.resolver_confidence,
|
||||
ambiguity_count: broadenedAnchor.ambiguity_count,
|
||||
match_failure_stage: "none",
|
||||
match_failure_reason: null,
|
||||
mcp_call_status: broadenedStageStatus,
|
||||
rows_fetched: broadenedMcp.fetched_rows,
|
||||
raw_rows_received: broadenedMcp.raw_rows.length,
|
||||
rows_after_account_scope: broadenedNormalizedRows.length,
|
||||
rows_after_recipe_filter: broadenedRowsByAnchor.length,
|
||||
rows_materialized: broadenedNormalizedRows.length,
|
||||
rows_matched: broadenedFilteredRows.length,
|
||||
raw_row_keys_sample: broadenedRowDiagnostics.rawRowKeysSample,
|
||||
materialization_drop_reason: broadenedRowDiagnostics.materializationDropReason,
|
||||
account_token_raw: broadenedAccountScopeAudit.accountTokenRaw,
|
||||
account_token_normalized: broadenedAccountScopeAudit.accountTokenNormalized,
|
||||
account_scope_fields_checked: broadenedAccountScopeAudit.accountScopeFieldsChecked,
|
||||
account_scope_match_strategy: broadenedAccountScopeAudit.accountScopeMatchStrategy,
|
||||
account_scope_drop_reason: broadenedAccountScopeAudit.accountScopeDropReason,
|
||||
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
|
||||
limited_reason_category: null,
|
||||
response_type: broadenedFactual.responseType,
|
||||
limitations: broadenedLimitations,
|
||||
reasons: broadenedReasons
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredRows.length === 0) {
|
||||
const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0;
|
||||
const hadAnchorMatchedRows = filterByAnchors.length > 0;
|
||||
|
||||
Reference in New Issue
Block a user