АДРЕСНЫЙ РЕЖИМ -

This commit is contained in:
2026-04-02 12:02:30 +03:00
parent da8a4eb872
commit 4dff069ae3
43 changed files with 7289 additions and 138 deletions
@@ -323,6 +323,11 @@ function cleanupAnchorValue(value: string): string {
if (asOfTruncatedTailPattern.test(normalized)) {
return normalized.replace(asOfTruncatedTailPattern, "").trim();
}
const periodEndTailPattern =
/\s+на\s+конец(?:\s+период[а-яё]*)?\s+(?:\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?|\d{4}|[a-zа-яё]+\s+\d{4})(?:\s+|$)[\s\S]*$/iu;
if (periodEndTailPattern.test(normalized)) {
return normalized.replace(periodEndTailPattern, "").trim();
}
// Remove trailing period qualifiers that can be swallowed by broad anchor regexes:
// "<counterparty> с 2020-07-01 по 2020-07-31", "<counterparty> from 2020-07-01 to 2020-07-31"
@@ -618,6 +623,10 @@ function requiredFiltersByIntent(intent: AddressIntent): Array<keyof AddressFilt
return [];
}
function usesAsOfPrimaryWindow(intent: AddressIntent): boolean {
return intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts";
}
export function extractAddressFilters(userMessage: string, intent: AddressIntent): AddressFilterExtraction {
const rawText = String(userMessage ?? "").trim();
const text = normalizeMojibakeString(rawText);
@@ -713,8 +722,22 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
}
}
const explicitAsOfDate = extractAsOfDate(text);
if (usesAsOfPrimaryWindow(intent) && explicitAsOfDate) {
filters.as_of_date = explicitAsOfDate;
const periodWasDerivedHeuristically =
warnings.includes("period_derived_from_month_phrase") ||
warnings.includes("period_derived_from_year_range_phrase") ||
warnings.includes("period_derived_from_year_phrase");
if (periodWasDerivedHeuristically && !periodRange.period_from && !periodRange.period_to) {
delete filters.period_from;
delete filters.period_to;
warnings.push("period_window_cleared_for_as_of_intent");
}
}
// If explicit period window exists, do not infer as_of_date from one of its boundary dates.
if (!filters.period_from && !filters.period_to) {
if (!filters.period_from && !filters.period_to && !filters.as_of_date) {
const asOfDate = extractAsOfDate(text);
if (asOfDate) {
filters.as_of_date = asOfDate;
@@ -224,6 +224,23 @@ function hasAccountBalanceSignal(text: string): boolean {
return hasAccountLexeme && hasBalanceLexeme;
}
function hasOpenContractsListSignal(text: string): boolean {
const hasContractLexeme = text.includes("договор") || text.includes("contract") || text.includes("dogovor");
const hasOpenLexeme = /(?:незакрыт|не\s+закрыт|открыт|open|unclosed)/iu.test(text);
if (!hasContractLexeme || !hasOpenLexeme) {
return false;
}
// Query about a specific contract should stay in open-items lane.
if (hasContractNumberLikeToken(text)) {
return false;
}
// Debt/tail wording indicates open-items intent, not contract list.
if (/(?:долг|задолж|хвост|позиц|open\s+items|unclosed\s+items|взаиморасчет|взаиморасчёт)/iu.test(text)) {
return false;
}
return true;
}
function isLikelyCounterpartyToken(rawToken: string): boolean {
const token = String(rawToken ?? "").trim().toLowerCase();
if (!token || token.length < 2) {
@@ -517,6 +534,14 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
};
}
if (hasOpenContractsListSignal(text)) {
return {
intent: "list_open_contracts",
confidence: "medium",
reasons: ["open_contract_signal_detected"]
};
}
if (
hasAny(text, OPEN_ITEMS_HINTS) &&
(text.includes("контраг") || text.includes("договор") || text.includes("counterparty") || text.includes("contract"))
@@ -17,7 +17,7 @@ import { buildAddressRecipePlan, selectAddressRecipe } from "./addressRecipeCata
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";
import { composeFactualReply, inferReplyType } from "./address_runtime/composeStage";
interface NormalizedAddressRow {
period: string | null;
@@ -464,12 +464,14 @@ function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilte
);
}
function isDocumentOrBankIntent(intent: AddressIntent): boolean {
function isAnchorRecoveryIntent(intent: AddressIntent): boolean {
return (
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_documents_by_contract" ||
intent === "bank_operations_by_contract"
intent === "bank_operations_by_contract" ||
intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts"
);
}
@@ -1058,40 +1060,9 @@ export class AddressQueryService {
}
}
if (intent.intent === "list_open_contracts" && filteredRows.length > 0 && contractCandidatesFromRows(filteredRows).length === 0) {
return buildLimitedExecutionResult({
mode,
shape,
intent,
filters: filters.extracted_filters,
missingRequiredFilters: [],
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
accountScopeMode: plan.account_scope_mode,
accountScopeFallbackApplied,
accountScopeAudit,
anchor,
matchFailureStage,
matchFailureReason,
mcpCallStatus: stageStatus,
rowsFetched: mcp.fetched_rows,
rawRowsReceived: mcp.raw_rows.length,
rowsAfterAccountScope: normalizedRows.length,
rowsAfterRecipeFilter: filterByAnchors.length,
rowsMaterialized: normalizedRows.length,
rowsMatched: filteredRows.length,
rawRowKeysSample: rowDiagnostics.rawRowKeysSample,
materializationDropReason: rowDiagnostics.materializationDropReason,
category: "recipe_visibility_gap",
reasonText: "в live строках нет договорных якорей для уверенного списка незакрытых договоров",
nextStep: "сузьте запрос по контрагенту или добавьте номер договора",
limitations: ["no_contract_anchors_in_live_rows"],
reasons: baseReasons
});
}
if (
filteredRows.length === 0 &&
isDocumentOrBankIntent(intent.intent) &&
isAnchorRecoveryIntent(intent.intent) &&
(stageStatus === "materialized_but_not_anchor_matched" || stageStatus === "materialized_but_filtered_out_by_recipe")
) {
const currentLimit =
@@ -1201,6 +1172,82 @@ export class AddressQueryService {
}
};
}
if (
intent.intent === "open_items_by_counterparty_or_contract" &&
expandedFilteredRows.length === 0 &&
expandedRowsByAnchor.length === 0 &&
expandedNormalizedRows.length > 0
) {
const expandedFallbackRows = applyIntentSpecificFilter(intent.intent, expandedNormalizedRows);
if (expandedFallbackRows.length > 0) {
const expandedRowDiagnostics = deriveRowStageDiagnostics(
expandedMcp.raw_rows,
expandedNormalizedRows.length,
expandedNormalizedRows.length
);
const expandedFactual = composeFactualReply(intent.intent, expandedFallbackRows);
const expandedLimitations = [
...filters.warnings,
"query_limit_auto_expanded_for_anchor_recovery",
"open_items_anchor_not_matched_fallback_rows"
];
const expandedReasons = [
...baseReasons,
"query_limit_auto_expanded_for_anchor_recovery",
"open_items_anchor_not_matched_fallback_rows"
];
return {
handled: true,
reply_text:
`Точный якорь не подтвердился в live-строках даже после расширения до ${expandedPlan.limit}; ` +
"показаны ближайшие доступные позиции по счетам 60/62/76.\n" +
expandedFactual.text,
reply_type: inferReplyType(expandedFactual.responseType),
response_type: expandedFactual.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: expandedSelection.selected_recipe.recipe_id,
mcp_call_status_legacy: "matched_non_empty",
account_scope_mode: expandedPlan.account_scope_mode,
account_scope_fallback_applied: expandedAccountScopeFallbackApplied,
anchor_type: expandedAnchor.anchor_type,
anchor_value_raw: expandedAnchor.anchor_value_raw,
anchor_value_resolved: expandedAnchor.anchor_value_resolved,
resolver_confidence: expandedAnchor.resolver_confidence,
ambiguity_count: expandedAnchor.ambiguity_count,
match_failure_stage: "materialized_but_not_anchor_matched",
match_failure_reason:
expandedAnchorFilter.mismatchReason ?? "anchor_not_matched_after_query_limit_expansion",
mcp_call_status: "matched_non_empty",
rows_fetched: expandedMcp.fetched_rows,
raw_rows_received: expandedMcp.raw_rows.length,
rows_after_account_scope: expandedNormalizedRows.length,
rows_after_recipe_filter: expandedRowsByAnchor.length,
rows_materialized: expandedNormalizedRows.length,
rows_matched: expandedFallbackRows.length,
raw_row_keys_sample: expandedRowDiagnostics.rawRowKeysSample,
materialization_drop_reason: expandedRowDiagnostics.materializationDropReason,
account_token_raw: expandedAccountScopeAudit.accountTokenRaw,
account_token_normalized: expandedAccountScopeAudit.accountTokenNormalized,
account_scope_fields_checked: expandedAccountScopeAudit.accountScopeFieldsChecked,
account_scope_match_strategy: expandedAccountScopeAudit.accountScopeMatchStrategy,
account_scope_drop_reason: expandedAccountScopeAudit.accountScopeDropReason,
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: expandedFactual.responseType,
limitations: expandedLimitations,
reasons: expandedReasons
}
};
}
}
}
}
}
@@ -1313,6 +1360,67 @@ export class AddressQueryService {
}
}
if (
filteredRows.length === 0 &&
intent.intent === "open_items_by_counterparty_or_contract" &&
normalizedRows.length > 0
) {
const openItemsFallbackRows = applyIntentSpecificFilter(intent.intent, normalizedRows);
if (openItemsFallbackRows.length > 0) {
const fallbackFactual = composeFactualReply(intent.intent, openItemsFallbackRows);
const fallbackLimitations = [...filters.warnings, "open_items_anchor_not_matched_fallback_rows"];
const fallbackReasons = [...baseReasons, "open_items_anchor_not_matched_fallback_rows"];
return {
handled: true,
reply_text:
"Точный якорь не подтвердился в текущем окне live-данных; показаны ближайшие доступные позиции по счетам 60/62/76.\n" +
fallbackFactual.text,
reply_type: inferReplyType(fallbackFactual.responseType),
response_type: fallbackFactual.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: recipeSelection.selected_recipe.recipe_id,
mcp_call_status_legacy: "matched_non_empty",
account_scope_mode: plan.account_scope_mode,
account_scope_fallback_applied: accountScopeFallbackApplied,
anchor_type: anchor.anchor_type,
anchor_value_raw: anchor.anchor_value_raw,
anchor_value_resolved: anchor.anchor_value_resolved,
resolver_confidence: anchor.resolver_confidence,
ambiguity_count: anchor.ambiguity_count,
match_failure_stage: matchFailureStage,
match_failure_reason: matchFailureReason,
mcp_call_status: "matched_non_empty",
rows_fetched: mcp.fetched_rows,
raw_rows_received: mcp.raw_rows.length,
rows_after_account_scope: normalizedRows.length,
rows_after_recipe_filter: filterByAnchors.length,
rows_materialized: normalizedRows.length,
rows_matched: openItemsFallbackRows.length,
raw_row_keys_sample: rowDiagnostics.rawRowKeysSample,
materialization_drop_reason: rowDiagnostics.materializationDropReason,
account_token_raw: accountScopeAudit.accountTokenRaw,
account_token_normalized: accountScopeAudit.accountTokenNormalized,
account_scope_fields_checked: accountScopeAudit.accountScopeFieldsChecked,
account_scope_match_strategy: accountScopeAudit.accountScopeMatchStrategy,
account_scope_drop_reason: accountScopeAudit.accountScopeDropReason,
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: fallbackFactual.responseType,
limitations: fallbackLimitations,
reasons: fallbackReasons
}
};
}
}
if (filteredRows.length === 0) {
const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0;
const hadAnchorMatchedRows = filterByAnchors.length > 0;
@@ -280,7 +280,9 @@ function maxLimitForIntent(intent: AddressIntent): number {
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_documents_by_contract" ||
intent === "bank_operations_by_contract"
intent === "bank_operations_by_contract" ||
intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts"
) {
return ADDRESS_MAX_LIMIT_EXTENDED;
}
@@ -85,7 +85,12 @@ export function composeFactualReply(
`Строк движения: ${rows.length}.`,
`Договорных кандидатов: ${contracts.length}.`
];
lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`));
if (contracts.length > 0) {
lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`));
} else {
lines.push("Договорные якоря в live-строках не выделены; показаны связанные движения как fallback.");
lines.push(...formatTopRows(rows, 6));
}
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")