ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Систематизировать ответы по поставщику без поставок через анализ оплат и возвратов
This commit is contained in:
@@ -471,6 +471,13 @@ function extractYearRangePeriod(text: string): { period_from?: string; period_to
|
||||
}
|
||||
|
||||
function cleanupAnchorValue(value: string): string {
|
||||
const stripLeadingEntityRole = (text: string): string =>
|
||||
String(text ?? "")
|
||||
.replace(
|
||||
/^(?:(?:по\s+)?(?:контрагент(?:ом|у|а|ы|ов)?|поставщик(?:ом|у|а|и|ов)?|клиент(?:ом|у|а|ы|ов)?|покупател(?:ем|ю|я|и|ей)|продав(?:цом|цу|ца|цы|цов)|заказчик(?:ом|у|а|и|ов)?|исполнител(?:ем|ю|я|и|ей)|подрядчик(?:ом|у|а|и|ов)?))\s+/iu,
|
||||
""
|
||||
)
|
||||
.trim();
|
||||
const stripOuterQuotes = (text: string): string =>
|
||||
String(text ?? "")
|
||||
.replace(/^['"«»“”„`’‘]+|['"«»“”„`’‘]+$/gu, "")
|
||||
@@ -480,6 +487,10 @@ function cleanupAnchorValue(value: string): string {
|
||||
if (!cleaned) {
|
||||
return "";
|
||||
}
|
||||
cleaned = stripOuterQuotes(stripLeadingEntityRole(cleaned.replace(/[?!]+$/u, "").trim()));
|
||||
if (!cleaned) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Remove trailing as-of qualifiers often captured by broad contract/counterparty regexes:
|
||||
// "<anchor> на 2020-07-31", "<anchor> на дату 31.07.2020", "<anchor> as of 2020-07-31".
|
||||
@@ -525,7 +536,7 @@ function cleanupAnchorValue(value: string): string {
|
||||
.replace(/\s+(?:с|по|за)(?:\s+|$)[\s\S]*$/iu, "")
|
||||
.trim();
|
||||
|
||||
return stripOuterQuotes(cleaned);
|
||||
return stripOuterQuotes(stripLeadingEntityRole(cleaned));
|
||||
}
|
||||
|
||||
function cleanupContractAnchorValue(value: string): string {
|
||||
@@ -638,6 +649,9 @@ function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean {
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
if (/(?:за\s+вс[её]\s+время|за\s+всю\s+истори(?:ю|и)|all\s+time|entire\s+period|full\s+history)/iu.test(value)) {
|
||||
return true;
|
||||
}
|
||||
const tokens = value
|
||||
.split(/[^a-zа-я0-9]+/iu)
|
||||
.map((token) => token.trim())
|
||||
@@ -681,6 +695,16 @@ function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean {
|
||||
"дата",
|
||||
"конец",
|
||||
"период",
|
||||
"весь",
|
||||
"все",
|
||||
"всё",
|
||||
"всю",
|
||||
"всех",
|
||||
"всего",
|
||||
"время",
|
||||
"история",
|
||||
"истории",
|
||||
"срок",
|
||||
"месяц",
|
||||
"году",
|
||||
"год",
|
||||
@@ -906,6 +930,33 @@ function extractLeadingCounterpartyTokenHeuristic(text: string): string | undefi
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractShipmentCounterpartyValue(text: string): string | undefined {
|
||||
const hasShipmentCounterpartyTokenShape = (token: string): boolean => {
|
||||
const source = String(token ?? "").trim();
|
||||
if (!source) {
|
||||
return false;
|
||||
}
|
||||
if (hasStrongCounterpartyTokenShape(source)) {
|
||||
return true;
|
||||
}
|
||||
return /^[\p{L}]{5,}$/u.test(source);
|
||||
};
|
||||
const match = String(text ?? "").match(
|
||||
/(?:что\s+нам\s+)?(?:отгруж(?:ал|али|ено)|постав(?:лял|ляли|ил|или)|привоз(?:ил|или)|продал)\s+([\p{L}][\p{L}\p{N}._-]{1,})(?=[\s,.;:!?)]|$)/iu
|
||||
);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = String(match[1] ?? "").trim();
|
||||
if (!candidate || !hasShipmentCounterpartyTokenShape(candidate)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isLikelyCounterpartyToken(candidate)) {
|
||||
return undefined;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function hasExplicitAccountCue(text: string): boolean {
|
||||
return /(?:сч[её]т|счет|account|acct)/iu.test(String(text ?? ""));
|
||||
}
|
||||
@@ -1666,6 +1717,19 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
||||
warnings.push("counterparty_anchor_derived_from_implicit_phrase");
|
||||
}
|
||||
}
|
||||
if (
|
||||
!filters.counterparty &&
|
||||
allowGenericCounterpartyAnchor &&
|
||||
(intent === "list_documents_by_counterparty" ||
|
||||
intent === "bank_operations_by_counterparty" ||
|
||||
intent === "list_contracts_by_counterparty")
|
||||
) {
|
||||
const shipmentCounterparty = extractShipmentCounterpartyValue(text);
|
||||
if (shipmentCounterparty) {
|
||||
filters.counterparty = cleanupAnchorValue(shipmentCounterparty);
|
||||
warnings.push("counterparty_anchor_derived_from_shipment_phrase");
|
||||
}
|
||||
}
|
||||
if (
|
||||
!filters.counterparty &&
|
||||
allowGenericCounterpartyAnchor &&
|
||||
|
||||
@@ -618,7 +618,7 @@ function hasVatLiabilityConfirmedTaxPeriodSignal(text: string): boolean {
|
||||
return false;
|
||||
}
|
||||
const hasPaymentCue =
|
||||
/(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы)/iu.test(
|
||||
/(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы|сгруз(?:ить|им|ишь|ите|ил|ила|или|ка))/iu.test(
|
||||
text
|
||||
);
|
||||
if (!hasPaymentCue) {
|
||||
@@ -656,14 +656,14 @@ function hasVatPayableConfirmedSignal(text: string): boolean {
|
||||
return false;
|
||||
}
|
||||
const hasPaymentCue =
|
||||
/(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы)/iu.test(
|
||||
/(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы|сгруз(?:ить|им|ишь|ите|ил|ила|или|ка))/iu.test(
|
||||
text
|
||||
);
|
||||
if (!hasPaymentCue) {
|
||||
return false;
|
||||
}
|
||||
const hasDateOrPeriodCue =
|
||||
/(?:на\s+дат|по\s+состоянию|на\s+конец|за\s+(?:\d{4}|январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)|квартал|месяц|год|период|\b\d{4}[./-]\d{2}[./-]\d{2}\b)/iu.test(
|
||||
/(?:на\s+дат|по\s+состоянию|на\s+конец|за\s+(?:\d{4}|январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)|на\s+(?:январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)\S*(?:\s+(?:19|20)\d{2})?|квартал|месяц|год|период|\b\d{4}[./-]\d{2}[./-]\d{2}\b)/iu.test(
|
||||
text
|
||||
);
|
||||
return hasDateOrPeriodCue || /(?:сколько|скока|скок)/iu.test(text);
|
||||
@@ -784,6 +784,9 @@ function hasCounterpartyDebtLongevitySignal(text: string): boolean {
|
||||
}
|
||||
|
||||
function hasCounterpartyActivityLifecycleSignal(text: string): boolean {
|
||||
if (hasCustomerRevenueAndPaymentsSignal(text) || hasSupplierPayoutsProfileSignal(text)) {
|
||||
return false;
|
||||
}
|
||||
const hasPaymentRiskLexeme =
|
||||
/(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|задерж(?:ива|к)|просроч|задолж|\bдолг(?:и|ов|а|у)?\b)/iu.test(
|
||||
text
|
||||
@@ -837,6 +840,30 @@ function hasCounterpartyActivityLifecycleSignal(text: string): boolean {
|
||||
return hasCounterpartyLexeme && hasActivityLexeme && (hasTimeWindowLexeme || hasListVerb);
|
||||
}
|
||||
|
||||
function hasCounterpartyShipmentItemFlowSignal(text: string): boolean {
|
||||
const hasNamedTailAfterShipmentCue =
|
||||
/(?:отгруж(?:ал|али|ено)|постав(?:лял|ляли|ил|или)|привоз(?:ил|или)|продал)\s+[a-zа-яё][a-zа-яё0-9._-]{2,}/iu.test(
|
||||
text
|
||||
);
|
||||
const hasPartySignal =
|
||||
hasPartyAnchorMention(text) ||
|
||||
hasLooseByAnchorMention(text) ||
|
||||
hasImplicitCounterpartyAnchorAroundDocs(text) ||
|
||||
hasHeuristicCounterpartyAnchor(text);
|
||||
if (!hasPartySignal && !hasNamedTailAfterShipmentCue) {
|
||||
return false;
|
||||
}
|
||||
const hasInboundShipmentCue =
|
||||
/(?:что\s+нам\s+(?:отгруж(?:ал|али|ено)|постав(?:лял|ляли|ил|или)|привоз(?:ил|или)|продал)|кто\s+нам\s+постав(?:лял|ил)|что\s+постав(?:лял|или)\s+нам|что\s+нам\s+поставили)/iu.test(
|
||||
text
|
||||
);
|
||||
const hasItemOrServiceCue =
|
||||
/(?:како(?:й|е|го|му)\s+товар|каки(?:е|х)\s+товар|какую\s+услуг|какие\s+услуг|товар\s+или\s+услуг|позици(?:ю|и|ях)?)/iu.test(
|
||||
text
|
||||
);
|
||||
return hasInboundShipmentCue || hasItemOrServiceCue;
|
||||
}
|
||||
|
||||
function hasContractUsageOverviewSignal(text: string): boolean {
|
||||
if (hasAny(text, CONTRACT_USAGE_OVERVIEW_HINTS)) {
|
||||
return true;
|
||||
@@ -2021,8 +2048,12 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||
!hasInventoryProvenanceSignalV2(text) &&
|
||||
!hasInventoryPurchaseDocumentsSignalV2(text) &&
|
||||
!hasInventorySaleTraceSignalV2(text) &&
|
||||
/(?:контраг|договор|контракт|counterparty|contract|покупател|клиент|заказчик|customer|client|buyer|supplier|поставщик)/iu.test(
|
||||
text
|
||||
(
|
||||
/(?:контраг|договор|контракт|counterparty|contract|покупател|клиент|заказчик|customer|client|buyer|supplier|поставщик)/iu.test(
|
||||
text
|
||||
) ||
|
||||
hasAccountNumberAnchor(text) ||
|
||||
hasCompactAccountCodeToken(text)
|
||||
)
|
||||
) {
|
||||
return {
|
||||
@@ -2164,16 +2195,21 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||
}
|
||||
|
||||
if (
|
||||
hasAny(text, DOCUMENTS_BY_COUNTERPARTY_HINTS) &&
|
||||
(hasAny(text, DOCUMENTS_BY_COUNTERPARTY_HINTS) || hasCounterpartyShipmentItemFlowSignal(text)) &&
|
||||
(hasPartyAnchorMention(text) ||
|
||||
hasLooseByAnchorMention(text) ||
|
||||
hasImplicitCounterpartyAnchorAroundDocs(text) ||
|
||||
hasHeuristicCounterpartyAnchor(text))
|
||||
hasHeuristicCounterpartyAnchor(text) ||
|
||||
hasCounterpartyShipmentItemFlowSignal(text))
|
||||
) {
|
||||
return {
|
||||
intent: "list_documents_by_counterparty",
|
||||
confidence: "medium",
|
||||
reasons: ["documents_by_counterparty_signal_detected"]
|
||||
reasons: [
|
||||
hasCounterpartyShipmentItemFlowSignal(text)
|
||||
? "counterparty_item_flow_signal_detected"
|
||||
: "documents_by_counterparty_signal_detected"
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -499,20 +499,34 @@ export function evolveAddressNavigationStateWithAssistantItem(
|
||||
)
|
||||
);
|
||||
const nextEvents = capNavigationEvents([...state.navigation_history, navigationEvent]);
|
||||
const nextSessionContext =
|
||||
action === "open"
|
||||
? {
|
||||
active_result_set_id: resultSetId,
|
||||
active_focus_object: focusObject ?? null,
|
||||
last_confirmed_route: routeId ?? null,
|
||||
date_scope: {
|
||||
as_of_date: normalizedDateScope.as_of_date,
|
||||
period_from: normalizedDateScope.period_from,
|
||||
period_to: normalizedDateScope.period_to
|
||||
},
|
||||
organization_scope: organizationScope ?? state.session_context.organization_scope
|
||||
}
|
||||
: {
|
||||
active_result_set_id: resultSetId,
|
||||
active_focus_object: focusObject ?? state.session_context.active_focus_object,
|
||||
last_confirmed_route: routeId ?? state.session_context.last_confirmed_route,
|
||||
date_scope: {
|
||||
as_of_date: normalizedDateScope.as_of_date ?? state.session_context.date_scope.as_of_date,
|
||||
period_from: normalizedDateScope.period_from ?? state.session_context.date_scope.period_from,
|
||||
period_to: normalizedDateScope.period_to ?? state.session_context.date_scope.period_to
|
||||
},
|
||||
organization_scope: organizationScope ?? state.session_context.organization_scope
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
updated_at: createdAt,
|
||||
session_context: {
|
||||
active_result_set_id: resultSetId,
|
||||
active_focus_object: focusObject ?? state.session_context.active_focus_object,
|
||||
last_confirmed_route: routeId ?? state.session_context.last_confirmed_route,
|
||||
date_scope: {
|
||||
as_of_date: normalizedDateScope.as_of_date ?? state.session_context.date_scope.as_of_date,
|
||||
period_from: normalizedDateScope.period_from ?? state.session_context.date_scope.period_from,
|
||||
period_to: normalizedDateScope.period_to ?? state.session_context.date_scope.period_to
|
||||
},
|
||||
organization_scope: organizationScope ?? state.session_context.organization_scope
|
||||
},
|
||||
session_context: nextSessionContext,
|
||||
result_sets: nextResultSets,
|
||||
navigation_history: nextEvents
|
||||
};
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import {
|
||||
DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_OPENAI_BASE_URL,
|
||||
FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1,
|
||||
FEATURE_ASSISTANT_ROUTE_EXPECTATION_AUDIT_V1,
|
||||
FEATURE_ASSISTANT_ROUTE_EXPECTATION_HARD_GUARD_V1,
|
||||
FEATURE_ASSISTANT_ADDRESS_QUERY_V1,
|
||||
FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1
|
||||
FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1,
|
||||
SHARED_LLM_CONNECTION_FILE
|
||||
} from "../config";
|
||||
import type {
|
||||
AddressCapabilityLayer,
|
||||
@@ -28,6 +32,8 @@ import type {
|
||||
} from "../types/addressQuery";
|
||||
import {
|
||||
buildAddressRecipePlan,
|
||||
buildCounterpartyPurchaseDocumentQuery,
|
||||
buildOpenItemsMovementQuery,
|
||||
selectAddressRecipe,
|
||||
type AddressRecipeExecutionPlan
|
||||
} from "./addressRecipeCatalog";
|
||||
@@ -57,6 +63,8 @@ import {
|
||||
normalizeOrganizationScopeValue,
|
||||
resolveOrganizationSelectionFromMessage
|
||||
} from "./assistantOrganizationMatcher";
|
||||
import { OpenAIResponsesClient, type OpenAIRequestConfig } from "./openaiResponsesClient";
|
||||
import { readJsonFile } from "../utils/files";
|
||||
|
||||
interface NormalizedAddressRow {
|
||||
period: string | null;
|
||||
@@ -219,7 +227,20 @@ interface CounterpartyCatalogResolution {
|
||||
ambiguityCount: number;
|
||||
}
|
||||
|
||||
interface SharedLlmConnectionRecord {
|
||||
schema_version: "shared_llm_connection_v1";
|
||||
updated_at: string;
|
||||
connection: {
|
||||
llmProvider: "openai" | "local";
|
||||
model: string;
|
||||
baseUrl: string;
|
||||
temperature: number;
|
||||
maxOutputTokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
let counterpartyCatalogCache: { names: string[]; loadedAt: number } | null = null;
|
||||
const limitedReplyLlmClient = new OpenAIResponsesClient();
|
||||
|
||||
function parseFiniteNumber(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
@@ -836,9 +857,43 @@ function anchorTokenVariants(token: string): string[] {
|
||||
return Array.from(variants);
|
||||
}
|
||||
|
||||
function normalizePartyTokenSkeleton(value: string): string {
|
||||
return normalizeSearchText(value).replace(/\s+/g, "").replace(/[аеёиоуыэюяaeiouy]+/giu, "");
|
||||
}
|
||||
|
||||
function fuzzyPartyTokenMatches(candidate: string, token: string): boolean {
|
||||
const normalizedCandidate = normalizeSearchText(candidate);
|
||||
const normalizedToken = normalizeSearchText(token);
|
||||
if (!normalizedCandidate || !normalizedToken) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedCandidate === normalizedToken) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
normalizedCandidate.length < 4 ||
|
||||
normalizedToken.length < 4 ||
|
||||
/\d/u.test(normalizedCandidate) ||
|
||||
/\d/u.test(normalizedToken)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const candidateSkeleton = normalizePartyTokenSkeleton(normalizedCandidate);
|
||||
const tokenSkeleton = normalizePartyTokenSkeleton(normalizedToken);
|
||||
if (candidateSkeleton.length < 3 || tokenSkeleton.length < 3) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
candidateSkeleton === tokenSkeleton ||
|
||||
candidateSkeleton.startsWith(tokenSkeleton) ||
|
||||
tokenSkeleton.startsWith(candidateSkeleton)
|
||||
);
|
||||
}
|
||||
|
||||
function matchesAnchorText(searchable: string, anchor: string): boolean {
|
||||
const searchableNormalized = normalizeSearchText(searchable);
|
||||
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
|
||||
const searchableTokens = tokenizeSearchableText(searchable);
|
||||
const tokens = tokenizeAnchor(anchor);
|
||||
if (tokens.length === 0) {
|
||||
const direct = normalizeSearchText(anchor);
|
||||
@@ -851,7 +906,11 @@ function matchesAnchorText(searchable: string, anchor: string): boolean {
|
||||
const variants = anchorTokenVariants(token);
|
||||
return variants.some((variant) => {
|
||||
const tokenLatin = transliterateCyrillicToLatin(variant);
|
||||
return searchableNormalized.includes(variant) || searchableLatin.includes(tokenLatin);
|
||||
return (
|
||||
searchableNormalized.includes(variant) ||
|
||||
searchableLatin.includes(tokenLatin) ||
|
||||
searchableTokens.some((candidate) => fuzzyPartyTokenMatches(candidate, variant))
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -989,6 +1048,16 @@ function normalizeCounterpartyName(value: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function hasCounterpartyShipmentItemFlowSignal(userMessage: string): boolean {
|
||||
const text = normalizeSearchText(String(userMessage ?? ""));
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
return /(?:что\s+нам\s+(?:отгруж|постав|привоз|прод)|кто\s+нам\s+постав|како(?:й|е|го|му)\s+товар|какую\s+услуг|какие\s+товар|какие\s+услуг|товар\s+или\s+услуг|позици(?:ю|и|ях)?)/iu.test(
|
||||
text
|
||||
);
|
||||
}
|
||||
|
||||
function extractCounterpartyCatalogNames(rows: Array<Record<string, unknown>>): string[] {
|
||||
return uniqueStrings(
|
||||
rows
|
||||
@@ -1009,6 +1078,7 @@ function scoreCounterpartyCandidate(name: string, anchor: string): number | null
|
||||
}
|
||||
const normalizedName = normalizeCounterpartyName(name);
|
||||
const normalizedAnchor = normalizeCounterpartyName(anchor);
|
||||
const nameTokens = tokenizeSearchableText(name);
|
||||
if (!normalizedName || !normalizedAnchor) {
|
||||
return null;
|
||||
}
|
||||
@@ -1018,6 +1088,8 @@ function scoreCounterpartyCandidate(name: string, anchor: string): number | null
|
||||
score += 10_000;
|
||||
} else if (normalizedName.includes(normalizedAnchor)) {
|
||||
score += 5_000;
|
||||
} else if (fuzzyPartyTokenMatches(normalizedName, normalizedAnchor)) {
|
||||
score += 3_500;
|
||||
} else if (normalizedAnchor.includes(normalizedName) && normalizedName.length >= 4) {
|
||||
score += 2_000;
|
||||
}
|
||||
@@ -1029,6 +1101,8 @@ function scoreCounterpartyCandidate(name: string, anchor: string): number | null
|
||||
for (const variant of variants) {
|
||||
if (normalizedName.includes(variant)) {
|
||||
tokenScore = Math.max(tokenScore, Math.max(2, variant.length) * 20);
|
||||
} else if (nameTokens.some((candidate) => fuzzyPartyTokenMatches(candidate, variant))) {
|
||||
tokenScore = Math.max(tokenScore, Math.max(2, variant.length) * 12);
|
||||
}
|
||||
}
|
||||
if (tokenScore === 0) {
|
||||
@@ -1241,24 +1315,7 @@ function toNormalizedRows(rows: Array<Record<string, unknown>>): NormalizedAddre
|
||||
const accountKt = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim() || null;
|
||||
const amount = parseFiniteNumber(row.Сумма ?? row.amount ?? row.Amount);
|
||||
const quantity = firstFiniteNumber(row.Количество, row.quantity, row.Quantity);
|
||||
const item = firstNonEmptyString(
|
||||
row.Номенклатура,
|
||||
row.Item,
|
||||
row.item,
|
||||
row.НоменклатураПредставление,
|
||||
row.SubcontoDt1,
|
||||
row.SubcontoDt2,
|
||||
row.SubcontoDt3,
|
||||
row.SubcontoKt1,
|
||||
row.SubcontoKt2,
|
||||
row.SubcontoKt3,
|
||||
row.СубконтоДт1,
|
||||
row.СубконтоДт2,
|
||||
row.СубконтоДт3,
|
||||
row.СубконтоКт1,
|
||||
row.СубконтоКт2,
|
||||
row.СубконтоКт3
|
||||
);
|
||||
const item = resolveInventoryItemFromRawRow(row, accountDt, accountKt);
|
||||
const warehouse = firstNonEmptyString(row.Склад, row.Warehouse, row.warehouse, row.СкладПредставление);
|
||||
const organization = firstNonEmptyString(
|
||||
row.Организация,
|
||||
@@ -1285,6 +1342,191 @@ function toNormalizedRows(rows: Array<Record<string, unknown>>): NormalizedAddre
|
||||
.filter((item) => Boolean(item.period || item.registrator));
|
||||
}
|
||||
|
||||
function hasInventoryAccountPrefix(account: string | null | undefined): boolean {
|
||||
const normalized = String(account ?? "").trim();
|
||||
return /^41(?:\.01)?(?:$|[^\d])/u.test(normalized);
|
||||
}
|
||||
|
||||
function resolveInventoryItemFromRawRow(
|
||||
row: Record<string, unknown>,
|
||||
accountDt: string | null,
|
||||
accountKt: string | null
|
||||
): string | null {
|
||||
const debitCandidates = [
|
||||
row.SubcontoDt1,
|
||||
row.SubcontoDt2,
|
||||
row.SubcontoDt3,
|
||||
row.СубконтоДт1,
|
||||
row.СубконтоДт2,
|
||||
row.СубконтоДт3
|
||||
];
|
||||
const creditCandidates = [
|
||||
row.SubcontoKt1,
|
||||
row.SubcontoKt2,
|
||||
row.SubcontoKt3,
|
||||
row.СубконтоКт1,
|
||||
row.СубконтоКт2,
|
||||
row.СубконтоКт3
|
||||
];
|
||||
|
||||
if (hasInventoryAccountPrefix(accountDt) && !hasInventoryAccountPrefix(accountKt)) {
|
||||
const debitSideItem = firstNonEmptyString(...debitCandidates, ...creditCandidates);
|
||||
if (debitSideItem) {
|
||||
return debitSideItem;
|
||||
}
|
||||
}
|
||||
if (hasInventoryAccountPrefix(accountKt) && !hasInventoryAccountPrefix(accountDt)) {
|
||||
const creditSideItem = firstNonEmptyString(...creditCandidates, ...debitCandidates);
|
||||
if (creditSideItem) {
|
||||
return creditSideItem;
|
||||
}
|
||||
}
|
||||
|
||||
const explicitItem = firstNonEmptyString(
|
||||
row.Номенклатура,
|
||||
row.Nomenclature,
|
||||
row.nomenclature,
|
||||
row.Item,
|
||||
row.item,
|
||||
row.НоменклатураПредставление
|
||||
);
|
||||
if (explicitItem) {
|
||||
return explicitItem;
|
||||
}
|
||||
return firstNonEmptyString(...debitCandidates, ...creditCandidates);
|
||||
}
|
||||
|
||||
function formatMoneyRubForReply(value: number): string {
|
||||
return `${new Intl.NumberFormat("ru-RU", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(value)} ₽`;
|
||||
}
|
||||
|
||||
function extractContractNameFromNormalizedRow(row: NormalizedAddressRow): string | null {
|
||||
for (const token of row.analytics) {
|
||||
const normalized = String(token ?? "").trim();
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
if (/^(?:0|<пусто>|пустая ссылка)$/iu.test(normalized)) {
|
||||
continue;
|
||||
}
|
||||
if (/(?:договор|contract|дог\.)/iu.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasBankAccountPrefix(account: string | null | undefined): boolean {
|
||||
const normalized = String(account ?? "").trim();
|
||||
return /^5[12](?:$|[^\d])/u.test(normalized);
|
||||
}
|
||||
|
||||
function hasPayablesAccountPrefix(account: string | null | undefined): boolean {
|
||||
const normalized = String(account ?? "").trim();
|
||||
return /^60(?:$|[^\d])/u.test(normalized);
|
||||
}
|
||||
|
||||
function isOutgoingSupplierPaymentRow(row: NormalizedAddressRow): boolean {
|
||||
const registrator = normalizeSearchText(row.registrator);
|
||||
return (
|
||||
(hasPayablesAccountPrefix(row.account_dt) && hasBankAccountPrefix(row.account_kt)) ||
|
||||
registrator.includes("списание с расчетного счета")
|
||||
);
|
||||
}
|
||||
|
||||
function isIncomingSupplierReturnRow(row: NormalizedAddressRow): boolean {
|
||||
const registrator = normalizeSearchText(row.registrator);
|
||||
return (
|
||||
(hasBankAccountPrefix(row.account_dt) && hasPayablesAccountPrefix(row.account_kt)) ||
|
||||
registrator.includes("поступление на расчетный счет")
|
||||
);
|
||||
}
|
||||
|
||||
function formatCounterpartyBankActivityRows(rows: NormalizedAddressRow[], limit = 5): string[] {
|
||||
return rows.slice(0, limit).map((row, index) => {
|
||||
const parts = [`${index + 1}. ${row.registrator}`, `дата: ${row.period ?? "не указана"}`];
|
||||
if (typeof row.amount === "number" && Number.isFinite(row.amount)) {
|
||||
parts.push(`сумма: ${formatMoneyRubForReply(row.amount)}`);
|
||||
}
|
||||
const contract = extractContractNameFromNormalizedRow(row);
|
||||
if (contract) {
|
||||
parts.push(`договор: ${contract}`);
|
||||
}
|
||||
return parts.join(" | ");
|
||||
});
|
||||
}
|
||||
|
||||
function buildCounterpartyItemFlowBankFallbackReply(
|
||||
counterpartyLabel: string,
|
||||
bankRows: NormalizedAddressRow[]
|
||||
): string | null {
|
||||
if (bankRows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const outgoingPayments = bankRows.filter((row) => isOutgoingSupplierPaymentRow(row));
|
||||
const incomingReturns = bankRows.filter((row) => isIncomingSupplierReturnRow(row));
|
||||
const classifiedKeys = new Set(
|
||||
[...outgoingPayments, ...incomingReturns].map((row) => `${row.period ?? ""}|${row.registrator}|${row.amount ?? ""}`)
|
||||
);
|
||||
const otherBankRows = bankRows.filter(
|
||||
(row) => !classifiedKeys.has(`${row.period ?? ""}|${row.registrator}|${row.amount ?? ""}`)
|
||||
);
|
||||
const outgoingTotal = outgoingPayments.reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
const incomingTotal = incomingReturns.reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
const otherTotal = otherBankRows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
const contracts = uniqueStrings(
|
||||
bankRows
|
||||
.map((row) => extractContractNameFromNormalizedRow(row))
|
||||
.filter((item): item is string => Boolean(item))
|
||||
);
|
||||
const summaryParts: string[] = [];
|
||||
if (outgoingPayments.length > 0) {
|
||||
summaryParts.push(
|
||||
`исходящих оплат поставщику: ${outgoingPayments.length} на ${formatMoneyRubForReply(outgoingTotal)}`
|
||||
);
|
||||
}
|
||||
if (incomingReturns.length > 0) {
|
||||
summaryParts.push(
|
||||
`возвратов от поставщика: ${incomingReturns.length} на ${formatMoneyRubForReply(incomingTotal)}`
|
||||
);
|
||||
}
|
||||
if (otherBankRows.length > 0) {
|
||||
summaryParts.push(`прочих банковских документов: ${otherBankRows.length} на ${formatMoneyRubForReply(otherTotal)}`);
|
||||
}
|
||||
if (summaryParts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = [`Контрагент: ${counterpartyLabel}. Подтвержденных поставок товаров или услуг не найдено.`];
|
||||
lines.push("Позиции: подтвержденных поставок товаров или услуг не найдено.");
|
||||
lines.push(`По связанным расчетам с контрагентом найдено ${summaryParts.join("; ")}.`);
|
||||
if (outgoingPayments.length > 0 && incomingReturns.length > 0 && Math.abs(outgoingTotal - incomingTotal) < 0.005) {
|
||||
lines.push("Сумма возврата совпадает с суммой исходящих оплат.");
|
||||
}
|
||||
if (contracts.length === 1) {
|
||||
lines.push(`Договор: ${contracts[0]}.`);
|
||||
} else if (contracts.length > 1) {
|
||||
lines.push(`Договоры: ${contracts.slice(0, 3).join("; ")}.`);
|
||||
}
|
||||
if (outgoingPayments.length > 0) {
|
||||
lines.push("Оплаты поставщику:");
|
||||
lines.push(...formatCounterpartyBankActivityRows(outgoingPayments));
|
||||
}
|
||||
if (incomingReturns.length > 0) {
|
||||
lines.push("Возвраты от поставщика:");
|
||||
lines.push(...formatCounterpartyBankActivityRows(incomingReturns));
|
||||
}
|
||||
if (otherBankRows.length > 0) {
|
||||
lines.push("Прочие банковские документы:");
|
||||
lines.push(...formatCounterpartyBankActivityRows(otherBankRows));
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function rowSearchableText(row: NormalizedAddressRow): string {
|
||||
return [
|
||||
row.registrator,
|
||||
@@ -2630,6 +2872,104 @@ function hasAggregateLimitedSignal(input: {
|
||||
);
|
||||
}
|
||||
|
||||
function shouldUseLlmLimitedReply(category: AddressLimitedReasonCategory): boolean {
|
||||
return (
|
||||
category === "unsupported" ||
|
||||
category === "recipe_visibility_gap" ||
|
||||
category === "execution_error" ||
|
||||
category === "missing_anchor" ||
|
||||
category === "empty_match"
|
||||
);
|
||||
}
|
||||
|
||||
function loadSharedLlmRequestConfig(): OpenAIRequestConfig | null {
|
||||
const record = readJsonFile<SharedLlmConnectionRecord | null>(SHARED_LLM_CONNECTION_FILE, null);
|
||||
const connection = record?.connection;
|
||||
if (!connection || typeof connection !== "object") {
|
||||
return null;
|
||||
}
|
||||
const llmProvider = connection.llmProvider === "local" ? "local" : "openai";
|
||||
const model = String(connection.model ?? "").trim() || DEFAULT_MODEL;
|
||||
const baseUrl = String(connection.baseUrl ?? "").trim() || DEFAULT_OPENAI_BASE_URL;
|
||||
const temperature =
|
||||
typeof connection.temperature === "number" && Number.isFinite(connection.temperature)
|
||||
? connection.temperature
|
||||
: 0.2;
|
||||
const maxOutputTokens =
|
||||
typeof connection.maxOutputTokens === "number" && Number.isFinite(connection.maxOutputTokens)
|
||||
? Math.max(64, Math.trunc(connection.maxOutputTokens))
|
||||
: DEFAULT_MAX_OUTPUT_TOKENS;
|
||||
const apiKey = String(process.env.OPENAI_API_KEY ?? "").trim();
|
||||
if (llmProvider === "openai" && apiKey.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
llmProvider,
|
||||
apiKey,
|
||||
model,
|
||||
baseUrl,
|
||||
temperature,
|
||||
maxOutputTokens
|
||||
};
|
||||
}
|
||||
|
||||
async function tryComposeLlmLimitedReply(input: {
|
||||
userMessage: string;
|
||||
category: AddressLimitedReasonCategory;
|
||||
intent: AddressIntent;
|
||||
reasonText: string;
|
||||
nextStep?: string;
|
||||
filters: AddressFilterSet;
|
||||
anchor?: AnchorResolutionDebug;
|
||||
}): Promise<string | null> {
|
||||
if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") {
|
||||
return null;
|
||||
}
|
||||
if (!shouldUseLlmLimitedReply(input.category)) {
|
||||
return null;
|
||||
}
|
||||
const question = String(input.userMessage ?? "").trim();
|
||||
if (!question) {
|
||||
return null;
|
||||
}
|
||||
const config = loadSharedLlmRequestConfig();
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
const contextPayload = {
|
||||
question,
|
||||
intent: input.intent,
|
||||
category: input.category,
|
||||
reason: normalizeLimitedReason(input.reasonText),
|
||||
next_step: normalizeLimitedNextStep(input.nextStep ?? ""),
|
||||
filters: input.filters,
|
||||
anchor: input.anchor
|
||||
? {
|
||||
type: input.anchor.anchor_type,
|
||||
raw: input.anchor.anchor_value_raw,
|
||||
resolved: input.anchor.anchor_value_resolved
|
||||
}
|
||||
: null
|
||||
};
|
||||
try {
|
||||
const response = await limitedReplyLlmClient.chat(config, {
|
||||
systemPrompt:
|
||||
"Ты формулируешь мягкий, но предметный отказ для бухгалтерского ассистента 1С. Нельзя выдумывать факты и нельзя утверждать, что данные найдены, если это не подтверждено.",
|
||||
developerPrompt:
|
||||
"Ответь по-русски, коротко и по делу. Дай 2-4 коротких абзаца. Первая фраза должна прямо сказать, что именно сейчас не удается надежно подтвердить по вопросу пользователя. Если есть контрагент, счет, договор, организация или период, упомяни их естественно. Коротко скажи, что уже проверено, и предложи 1-2 ближайших шага. Не используй техжаргон вроде address lane, partial_coverage, recipe, capability, unsupported.",
|
||||
userMessage: `Контекст отказа:\n${JSON.stringify(contextPayload, null, 2)}`,
|
||||
maxOutputTokens: Math.min(280, Math.max(160, config.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS)),
|
||||
temperature: 0.2
|
||||
});
|
||||
const text = String(response.outputText ?? "")
|
||||
.replace(/\r\n/g, "\n")
|
||||
.trim();
|
||||
return text.length > 0 ? text : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function composeLimitedReply(input: {
|
||||
category: AddressLimitedReasonCategory;
|
||||
reason: string;
|
||||
@@ -3132,6 +3472,7 @@ export class AddressQueryService {
|
||||
requestedResultMode,
|
||||
filters: executionFilters
|
||||
});
|
||||
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
|
||||
if (isCapabilityRouteBlocked(capabilityDecision)) {
|
||||
return buildLimitedExecutionResult({
|
||||
mode,
|
||||
@@ -3162,9 +3503,24 @@ export class AddressQueryService {
|
||||
vatDirectSourceProbe?: VatDirectSourceProbeSummary | null;
|
||||
emphasizeNumbers?: boolean;
|
||||
useRubCurrency?: boolean;
|
||||
counterpartyHint?: string;
|
||||
accountHint?: string;
|
||||
} = {}
|
||||
) => ({
|
||||
userMessage,
|
||||
itemHint: typeof filterSet.item === "string" ? filterSet.item : undefined,
|
||||
counterpartyHint:
|
||||
typeof options.counterpartyHint === "string"
|
||||
? options.counterpartyHint
|
||||
: typeof filterSet.counterparty === "string"
|
||||
? filterSet.counterparty
|
||||
: undefined,
|
||||
accountHint:
|
||||
typeof options.accountHint === "string"
|
||||
? options.accountHint
|
||||
: typeof filterSet.account === "string"
|
||||
? filterSet.account
|
||||
: undefined,
|
||||
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
|
||||
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined,
|
||||
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined,
|
||||
@@ -3173,8 +3529,53 @@ export class AddressQueryService {
|
||||
emphasizeNumbers: options.emphasizeNumbers ?? undefined,
|
||||
useRubCurrency: options.useRubCurrency ?? undefined
|
||||
});
|
||||
const finalizeLimitedResult = async (
|
||||
input: Parameters<typeof buildLimitedExecutionResult>[0]
|
||||
): Promise<AddressExecutionResult> => {
|
||||
const result = buildLimitedExecutionResult(input);
|
||||
const llmReply = await tryComposeLlmLimitedReply({
|
||||
userMessage,
|
||||
category: input.category,
|
||||
intent: input.intent.intent,
|
||||
reasonText: input.reasonText,
|
||||
nextStep: input.nextStep,
|
||||
filters: input.filters,
|
||||
anchor: input.anchor
|
||||
});
|
||||
if (!llmReply) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
reply_text: llmReply
|
||||
};
|
||||
};
|
||||
const composeRuntimeOptions = (
|
||||
filterSet: AddressFilterSet,
|
||||
options: {
|
||||
vatDirectSourceProbe?: VatDirectSourceProbeSummary | null;
|
||||
emphasizeNumbers?: boolean;
|
||||
useRubCurrency?: boolean;
|
||||
counterpartyHint?: string;
|
||||
accountHint?: string;
|
||||
} = {}
|
||||
) =>
|
||||
composeOptionsFromFilters(filterSet, {
|
||||
...options,
|
||||
counterpartyHint:
|
||||
typeof options.counterpartyHint === "string"
|
||||
? options.counterpartyHint
|
||||
: anchor?.anchor_type === "counterparty"
|
||||
? anchor.anchor_value_resolved ?? anchor.anchor_value_raw ?? undefined
|
||||
: undefined,
|
||||
accountHint:
|
||||
typeof options.accountHint === "string"
|
||||
? options.accountHint
|
||||
: typeof filterSet.account === "string"
|
||||
? filterSet.account
|
||||
: undefined
|
||||
});
|
||||
const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, executionFilters);
|
||||
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
|
||||
const debtLifecycleReceivablesScenario =
|
||||
intent.intent === "list_receivables_counterparties" &&
|
||||
Array.isArray(intent.reasons) &&
|
||||
@@ -3238,7 +3639,7 @@ export class AddressQueryService {
|
||||
}
|
||||
|
||||
if (intent.intent === "unknown") {
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
@@ -3261,7 +3662,7 @@ export class AddressQueryService {
|
||||
}
|
||||
|
||||
if (recipeSelection.selected_recipe === null) {
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
@@ -3284,7 +3685,7 @@ export class AddressQueryService {
|
||||
}
|
||||
|
||||
if (recipeSelection.missing_required_filters.length > 0) {
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
@@ -3307,7 +3708,7 @@ export class AddressQueryService {
|
||||
}
|
||||
|
||||
if (!FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1) {
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
@@ -3334,6 +3735,14 @@ export class AddressQueryService {
|
||||
if (shouldAttemptCounterpartyCatalogResolution(intent.intent, filters.extracted_filters)) {
|
||||
const catalogResolution = await resolveCounterpartyViaCatalog(rawCounterpartyAnchor);
|
||||
if (catalogResolution.resolvedValue) {
|
||||
filters.extracted_filters = {
|
||||
...filters.extracted_filters,
|
||||
counterparty: catalogResolution.resolvedValue
|
||||
};
|
||||
executionFilters = {
|
||||
...executionFilters,
|
||||
counterparty: catalogResolution.resolvedValue
|
||||
};
|
||||
if (normalizeCounterpartyName(rawCounterpartyAnchor) !== normalizeCounterpartyName(catalogResolution.resolvedValue)) {
|
||||
filters.warnings.push("counterparty_anchor_resolved_via_catalog_lookup");
|
||||
}
|
||||
@@ -3375,6 +3784,45 @@ export class AddressQueryService {
|
||||
buildAddressRecipePlan(recipeSelection.selected_recipe, executionFilters),
|
||||
intent.intent
|
||||
);
|
||||
const counterpartyItemFlowQuery =
|
||||
intent.intent === "list_documents_by_counterparty" &&
|
||||
hasCounterpartyShipmentItemFlowSignal(userMessage) &&
|
||||
typeof executionFilters.counterparty === "string" &&
|
||||
executionFilters.counterparty.trim().length > 0;
|
||||
const counterpartyItemFlowFilters =
|
||||
counterpartyItemFlowQuery && anchor.anchor_type === "counterparty" && anchor.anchor_value_resolved
|
||||
? {
|
||||
...executionFilters,
|
||||
counterparty: anchor.anchor_value_resolved
|
||||
}
|
||||
: executionFilters;
|
||||
if (counterpartyItemFlowQuery) {
|
||||
plan = {
|
||||
...plan,
|
||||
query: buildCounterpartyPurchaseDocumentQuery(counterpartyItemFlowFilters, plan.limit),
|
||||
account_scope: [],
|
||||
account_scope_mode: "preferred"
|
||||
};
|
||||
if (!baseReasons.includes("counterparty_item_flow_query_override_to_purchase_documents")) {
|
||||
baseReasons.push("counterparty_item_flow_query_override_to_purchase_documents");
|
||||
}
|
||||
}
|
||||
const accountOnlyOpenItemsQuery =
|
||||
intent.intent === "open_items_by_counterparty_or_contract" &&
|
||||
typeof executionFilters.account === "string" &&
|
||||
executionFilters.account.trim().length > 0 &&
|
||||
!(typeof executionFilters.counterparty === "string" && executionFilters.counterparty.trim().length > 0) &&
|
||||
!(typeof executionFilters.contract === "string" && executionFilters.contract.trim().length > 0);
|
||||
if (accountOnlyOpenItemsQuery) {
|
||||
plan = {
|
||||
...plan,
|
||||
query: buildOpenItemsMovementQuery(executionFilters, plan.limit),
|
||||
account_scope_mode: "strict"
|
||||
};
|
||||
if (!baseReasons.includes("open_items_account_query_override_to_movements")) {
|
||||
baseReasons.push("open_items_account_query_override_to_movements");
|
||||
}
|
||||
}
|
||||
let mcp = await executeAddressMcpQuery({
|
||||
query: plan.query,
|
||||
limit: plan.limit
|
||||
@@ -3479,7 +3927,7 @@ export class AddressQueryService {
|
||||
|
||||
if (mcp.error) {
|
||||
const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters);
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
@@ -3623,6 +4071,90 @@ export class AddressQueryService {
|
||||
: matchFailureStage === "materialized_but_filtered_out_by_recipe"
|
||||
? "rows_filtered_out_by_intent_recipe_after_anchor_match"
|
||||
: null;
|
||||
const buildFactualNoRowsResult = (
|
||||
replyText: string,
|
||||
noRowsReason: string,
|
||||
extraLimitations: string[] = []
|
||||
): AddressExecutionResult => {
|
||||
const responseType: AddressResponseType = "FACTUAL_SUMMARY";
|
||||
const semantics = mergeAddressResultSemantics(
|
||||
deriveAddressResultSemantics({
|
||||
intent: intent.intent,
|
||||
selectedRecipe: effectiveRecipeId,
|
||||
filters: filters.extracted_filters,
|
||||
semanticFrame,
|
||||
responseType,
|
||||
rowsMatched: 0
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
return {
|
||||
handled: true,
|
||||
reply_text: replyText,
|
||||
reply_type: inferReplyType(responseType),
|
||||
response_type: 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: effectiveRecipeId,
|
||||
mcp_call_status_legacy: toLegacyMcpStatus(stageStatus),
|
||||
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: stageStatus,
|
||||
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: 0,
|
||||
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: responseType,
|
||||
...semantics,
|
||||
limitations: [...filters.warnings, ...extraLimitations],
|
||||
reasons: withConfirmedBalanceFallbackReason(
|
||||
[...baseReasons, noRowsReason],
|
||||
requestedResultMode,
|
||||
undefined,
|
||||
semantics.result_mode
|
||||
),
|
||||
...(capabilityAudit
|
||||
? {
|
||||
capability_id: capabilityAudit.capabilityId,
|
||||
capability_layer: capabilityAudit.layer,
|
||||
capability_route_mode: capabilityAudit.routeMode,
|
||||
capability_route_enabled: capabilityAudit.enabled,
|
||||
capability_route_reason: capabilityAudit.reason
|
||||
}
|
||||
: {}),
|
||||
...(shadowRouteAudit
|
||||
? {
|
||||
shadow_route_status: shadowRouteAudit.status
|
||||
}
|
||||
: {})
|
||||
}
|
||||
};
|
||||
};
|
||||
if (organizationWarehouseRecoveryApplied) {
|
||||
if (!baseReasons.includes("organization_scope_live_grounding_recovered_rows")) {
|
||||
baseReasons.push("organization_scope_live_grounding_recovered_rows");
|
||||
@@ -3669,7 +4201,7 @@ export class AddressQueryService {
|
||||
const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors);
|
||||
const recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors;
|
||||
if (recoveredRows.length > 0) {
|
||||
const factual = composeFactualReply(intent.intent, recoveredRows, composeOptionsFromFilters(executionFilters));
|
||||
const factual = composeFactualReply(intent.intent, recoveredRows, composeRuntimeOptions(executionFilters));
|
||||
const recoveryReason =
|
||||
recoveredBankRows.length > 0
|
||||
? "contract_docs_recovered_via_bank_fallback"
|
||||
@@ -3822,7 +4354,7 @@ export class AddressQueryService {
|
||||
const expandedFactual = composeFactualReply(
|
||||
intent.intent,
|
||||
expandedFilteredRows,
|
||||
composeOptionsFromFilters(expandedLimitFilters)
|
||||
composeRuntimeOptions(expandedLimitFilters)
|
||||
);
|
||||
const expandedPrefix = `Период сохранен. Глубина live-выборки автоматически расширена до ${expandedPlan.limit} строк.`;
|
||||
const expandedLimitations = [...filters.warnings, "query_limit_auto_expanded_for_anchor_recovery"];
|
||||
@@ -3977,7 +4509,7 @@ export class AddressQueryService {
|
||||
const broadenedFactual = composeFactualReply(
|
||||
intent.intent,
|
||||
broadenedFilteredRows,
|
||||
composeOptionsFromFilters(autoBroadenedFilters)
|
||||
composeRuntimeOptions(autoBroadenedFilters)
|
||||
);
|
||||
const broadenedLimitations = [
|
||||
...filters.warnings,
|
||||
@@ -4075,6 +4607,7 @@ export class AddressQueryService {
|
||||
|
||||
if (
|
||||
filteredRows.length === 0 &&
|
||||
!counterpartyItemFlowQuery &&
|
||||
isDocumentOrBankAnchorIntent(intent.intent) &&
|
||||
!hasExplicitPeriodWindow(filters.extracted_filters) &&
|
||||
(anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract")
|
||||
@@ -4151,7 +4684,7 @@ export class AddressQueryService {
|
||||
const historicalFactual = composeFactualReply(
|
||||
intent.intent,
|
||||
historicalFilteredRows,
|
||||
composeOptionsFromFilters(historicalFilters)
|
||||
composeRuntimeOptions(historicalFilters)
|
||||
);
|
||||
const historicalPrefix = "Найдены данные в историческом срезе базы по вашему запросу.";
|
||||
const historicalSuggestion =
|
||||
@@ -4238,7 +4771,7 @@ export class AddressQueryService {
|
||||
const fallbackFactual = composeFactualReply(
|
||||
intent.intent,
|
||||
documentBankFallbackRows,
|
||||
composeOptionsFromFilters(executionFilters)
|
||||
composeRuntimeOptions(executionFilters)
|
||||
);
|
||||
const fallbackPrefix = "По вашему запросу показываю найденные документы и операции в доступном срезе базы.";
|
||||
const fallbackSuggestion =
|
||||
@@ -4322,6 +4855,65 @@ export class AddressQueryService {
|
||||
!toNonEmptyFilterValue(filters.extracted_filters.contract) &&
|
||||
!toNonEmptyFilterValue(filters.extracted_filters.document_ref);
|
||||
if (filteredRows.length === 0 && !allowConfirmedAsOfZeroSnapshot) {
|
||||
if (stageStatus === "no_raw_rows" && counterpartyItemFlowQuery && anchor.anchor_type === "counterparty") {
|
||||
const counterpartyLabel = String(anchor.anchor_value_resolved ?? anchor.anchor_value_raw ?? "указанный контрагент")
|
||||
.trim()
|
||||
.replace(/[.]+$/u, "");
|
||||
const bankFallbackSelection = selectAddressRecipe("bank_operations_by_counterparty", counterpartyItemFlowFilters);
|
||||
if (bankFallbackSelection.selected_recipe) {
|
||||
const bankFallbackPlan = enforceStrictAccountScopeForIntent(
|
||||
buildAddressRecipePlan(bankFallbackSelection.selected_recipe, counterpartyItemFlowFilters),
|
||||
"bank_operations_by_counterparty"
|
||||
);
|
||||
const bankFallbackMcp = await executeAddressMcpQuery({
|
||||
query: bankFallbackPlan.query,
|
||||
limit: bankFallbackPlan.limit
|
||||
});
|
||||
if (!bankFallbackMcp.error) {
|
||||
const bankFallbackNormalizedRows = applyAccountScopeFilter(
|
||||
toNormalizedRows(bankFallbackMcp.raw_rows),
|
||||
bankFallbackPlan.account_scope
|
||||
);
|
||||
const bankFallbackRows = applyIntentSpecificFilter(
|
||||
"bank_operations_by_counterparty",
|
||||
applyAddressFilters(bankFallbackNormalizedRows, counterpartyItemFlowFilters).rows
|
||||
);
|
||||
const bankFallbackReply = buildCounterpartyItemFlowBankFallbackReply(counterpartyLabel, bankFallbackRows);
|
||||
if (bankFallbackReply) {
|
||||
return buildFactualNoRowsResult(
|
||||
bankFallbackReply,
|
||||
"counterparty_item_flow_no_supply_but_bank_activity_explained",
|
||||
["counterparty_item_flow_bank_activity_fallback_applied"]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return buildFactualNoRowsResult(
|
||||
`Контрагент: ${counterpartyLabel}. Позиции: подтвержденных поступлений товаров или услуг не найдено.\nПроверил документы поступления и связанные движения по этому контрагенту: в доступном срезе базы строк с товарами или услугами не нашлось.`,
|
||||
"counterparty_item_flow_exact_negative_response"
|
||||
);
|
||||
}
|
||||
if (stageStatus === "no_raw_rows" && accountOnlyOpenItemsQuery) {
|
||||
const accountLabel =
|
||||
typeof executionFilters.account === "string" && executionFilters.account.trim().length > 0
|
||||
? executionFilters.account.trim()
|
||||
: "указанному счету";
|
||||
const periodLabel =
|
||||
typeof executionFilters.period_from === "string" && typeof executionFilters.period_to === "string"
|
||||
? `за период ${executionFilters.period_from}..${executionFilters.period_to}`
|
||||
: typeof executionFilters.as_of_date === "string"
|
||||
? `на дату ${executionFilters.as_of_date}`
|
||||
: "";
|
||||
const organizationLabel =
|
||||
typeof executionFilters.organization === "string" && executionFilters.organization.trim().length > 0
|
||||
? executionFilters.organization.trim()
|
||||
: "";
|
||||
return buildFactualNoRowsResult(
|
||||
`По счету ${accountLabel}${periodLabel ? ` ${periodLabel}` : ""} открытых остатков не найдено.` +
|
||||
(organizationLabel ? `\nОрганизация: ${organizationLabel}.` : ""),
|
||||
"open_items_account_exact_negative_response"
|
||||
);
|
||||
}
|
||||
const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0;
|
||||
const hadAnchorMatchedRows = filterByAnchors.length > 0;
|
||||
const isVisibilityGapCandidate =
|
||||
@@ -4405,7 +4997,7 @@ export class AddressQueryService {
|
||||
? "document_or_bank_visibility_gap_after_base_filter"
|
||||
: "no_rows_after_recipe_and_scope_filter"
|
||||
];
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
@@ -4454,7 +5046,7 @@ export class AddressQueryService {
|
||||
const factual = composeFactualReply(
|
||||
composeIntent,
|
||||
filteredRows,
|
||||
composeOptionsFromFilters(executionFilters, {
|
||||
composeRuntimeOptions(executionFilters, {
|
||||
vatDirectSourceProbe,
|
||||
emphasizeNumbers: shouldEmphasizeNumbers,
|
||||
useRubCurrency: shouldUseRubCurrency
|
||||
@@ -4489,7 +5081,7 @@ export class AddressQueryService {
|
||||
resultMode: factualResultSemantics.result_mode
|
||||
});
|
||||
if (finalRouteExpectationAudit.status === "mismatch" && FEATURE_ASSISTANT_ROUTE_EXPECTATION_HARD_GUARD_V1) {
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
@@ -4537,7 +5129,7 @@ export class AddressQueryService {
|
||||
: intent.intent === "vat_liability_confirmed_for_tax_period"
|
||||
? "vat_tax_period"
|
||||
: "vat_payable";
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
|
||||
@@ -85,6 +85,40 @@ __WHERE_CLAUSE__
|
||||
Товары.Ссылка.Дата __ORDER_DIRECTION__
|
||||
`;
|
||||
|
||||
const COUNTERPARTY_PURCHASE_GOODS_QUERY_TEMPLATE = `
|
||||
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
|
||||
Товары.Ссылка.Дата КАК Период,
|
||||
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка) КАК Регистратор,
|
||||
"41.01" КАК СчетДт,
|
||||
"" КАК СчетКт,
|
||||
Товары.Сумма КАК Сумма,
|
||||
ПРЕДСТАВЛЕНИЕ(Товары.Номенклатура) КАК Номенклатура,
|
||||
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент,
|
||||
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.ДоговорКонтрагента) КАК Договор,
|
||||
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация,
|
||||
Товары.Количество КАК Количество
|
||||
ИЗ
|
||||
Документ.ПоступлениеТоваровУслуг.Товары КАК Товары
|
||||
__WHERE_CLAUSE__
|
||||
`;
|
||||
|
||||
const COUNTERPARTY_PURCHASE_SERVICES_QUERY_TEMPLATE = `
|
||||
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
|
||||
Услуги.Ссылка.Дата КАК Период,
|
||||
ПРЕДСТАВЛЕНИЕ(Услуги.Ссылка) КАК Регистратор,
|
||||
"" КАК СчетДт,
|
||||
"" КАК СчетКт,
|
||||
Услуги.Сумма КАК Сумма,
|
||||
ПРЕДСТАВЛЕНИЕ(Услуги.Номенклатура) КАК Номенклатура,
|
||||
ПРЕДСТАВЛЕНИЕ(Услуги.Ссылка.Контрагент) КАК Контрагент,
|
||||
ПРЕДСТАВЛЕНИЕ(Услуги.Ссылка.ДоговорКонтрагента) КАК Договор,
|
||||
ПРЕДСТАВЛЕНИЕ(Услуги.Ссылка.Организация) КАК Организация,
|
||||
0 КАК Количество
|
||||
ИЗ
|
||||
Документ.ПоступлениеТоваровУслуг.Услуги КАК Услуги
|
||||
__WHERE_CLAUSE__
|
||||
`;
|
||||
|
||||
const PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
|
||||
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
|
||||
__AS_OF_EXPR__ КАК Период,
|
||||
@@ -1227,6 +1261,36 @@ function buildInventoryItemReferenceCondition(filters: AddressFilterSet, fieldPa
|
||||
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
|
||||
}
|
||||
|
||||
function buildCounterpartyReferenceCondition(filters: AddressFilterSet, fieldPaths: string[]): string | null {
|
||||
const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : "";
|
||||
if (!counterparty) {
|
||||
return null;
|
||||
}
|
||||
const counterpartyTokens = Array.from(
|
||||
new Set(
|
||||
counterparty
|
||||
.split(/[^A-Za-zА-Яа-яЁё0-9]+/u)
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length >= 3)
|
||||
)
|
||||
);
|
||||
const tokens = counterpartyTokens.length > 0 ? counterpartyTokens : [counterparty];
|
||||
const clauses = fieldPaths
|
||||
.map((fieldPath) => String(fieldPath ?? "").trim())
|
||||
.filter((fieldPath) => fieldPath.length > 0)
|
||||
.map((fieldPath) => {
|
||||
const tokenConditions = tokens.map((token) => {
|
||||
const escapedToken = toQueryStringLiteral(token);
|
||||
return `${fieldPath}.Наименование ПОДОБНО "%${escapedToken}%"`;
|
||||
});
|
||||
return tokenConditions.length === 1 ? tokenConditions[0] : `(${tokenConditions.join(" И ")})`;
|
||||
});
|
||||
if (clauses.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
|
||||
}
|
||||
|
||||
function buildInventorySaleDocumentQuery(filters: AddressFilterSet, resolvedLimit: number): string {
|
||||
const itemCondition = buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]);
|
||||
return INVENTORY_SALE_DOCUMENTS_QUERY_TEMPLATE
|
||||
@@ -1257,6 +1321,44 @@ function buildInventoryPurchaseDocumentQuery(filters: AddressFilterSet, resolved
|
||||
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
||||
}
|
||||
|
||||
export function buildCounterpartyPurchaseDocumentQuery(filters: AddressFilterSet, resolvedLimit: number): string {
|
||||
const goodsCounterpartyCondition = buildCounterpartyReferenceCondition(filters, ["Товары.Ссылка.Контрагент"]);
|
||||
const servicesCounterpartyCondition = buildCounterpartyReferenceCondition(filters, ["Услуги.Ссылка.Контрагент"]);
|
||||
const goodsQuery = COUNTERPARTY_PURCHASE_GOODS_QUERY_TEMPLATE.replace("__LIMIT__", String(resolvedLimit)).replace(
|
||||
"__WHERE_CLAUSE__",
|
||||
buildWhereClause(
|
||||
filters,
|
||||
"Товары.Ссылка.Дата",
|
||||
['Товары.Ссылка.Проведен = ИСТИНА', goodsCounterpartyCondition].filter((item): item is string => Boolean(item))
|
||||
)
|
||||
);
|
||||
const servicesQuery = COUNTERPARTY_PURCHASE_SERVICES_QUERY_TEMPLATE.replace("__LIMIT__", String(resolvedLimit)).replace(
|
||||
"__WHERE_CLAUSE__",
|
||||
buildWhereClause(
|
||||
filters,
|
||||
"Услуги.Ссылка.Дата",
|
||||
['Услуги.Ссылка.Проведен = ИСТИНА', servicesCounterpartyCondition].filter((item): item is string => Boolean(item))
|
||||
)
|
||||
);
|
||||
return `${goodsQuery}
|
||||
ОБЪЕДИНИТЬ ВСЕ
|
||||
${servicesQuery}
|
||||
УПОРЯДОЧИТЬ ПО
|
||||
Период ${resolveOrderDirection(filters.sort)}`;
|
||||
}
|
||||
|
||||
export function buildOpenItemsMovementQuery(filters: AddressFilterSet, resolvedLimit: number): string {
|
||||
const extraConditions: string[] = [];
|
||||
const accountCondition = buildMovementAccountCondition(filters);
|
||||
if (accountCondition) {
|
||||
extraConditions.push(accountCondition);
|
||||
}
|
||||
return MOVEMENTS_QUERY_TEMPLATE
|
||||
.replace("__LIMIT__", String(resolvedLimit))
|
||||
.replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Движения.Период", extraConditions))
|
||||
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
||||
}
|
||||
|
||||
function shouldBoostLimitForAllTimeCounterparty(filters: AddressFilterSet): boolean {
|
||||
const hasAnchor =
|
||||
(typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0) ||
|
||||
|
||||
@@ -40,6 +40,9 @@ export interface VatDirectSourceProbeSummary {
|
||||
|
||||
interface ComposeFactualReplyOptions {
|
||||
userMessage?: string;
|
||||
itemHint?: string;
|
||||
counterpartyHint?: string;
|
||||
accountHint?: string;
|
||||
periodFrom?: string;
|
||||
periodTo?: string;
|
||||
asOfDate?: string;
|
||||
@@ -78,6 +81,7 @@ type CounterpartyProfileFocus =
|
||||
type CounterpartyLifecycleFocus = "active_customers_period" | "active_customers_all_time";
|
||||
type ValueRankingFocus =
|
||||
| "top_by_total"
|
||||
| "total_flow"
|
||||
| "top_years_by_total"
|
||||
| "top_by_ops"
|
||||
| "top_by_max_single"
|
||||
@@ -662,6 +666,13 @@ function detectValueRankingFocus(userMessage: string | null | undefined): ValueR
|
||||
if (!text) {
|
||||
return "top_by_total";
|
||||
}
|
||||
const asksTotalMoneyEarned =
|
||||
/(?:сколько|скока|скок).*(?:денег|выручк|доход|заработ|оборот)/iu.test(text) &&
|
||||
!/(?:клиент|заказчик|покупател|контрагент|customer|client|counterpart)/iu.test(text) &&
|
||||
!/(?:топ|top|сам(?:ый|ая|ое|ые)|наибольш|больше\s+всего|максимальн)/iu.test(text);
|
||||
if (asksTotalMoneyEarned) {
|
||||
return "total_flow";
|
||||
}
|
||||
const asksYearlyRevenueRanking =
|
||||
/(?:доходн|выручк|оборот|прибыл|деньг|денег|revenue|turnover|income)/iu.test(text) &&
|
||||
/(?:год|года|годы|year|years|по\s+годам)/iu.test(text) &&
|
||||
@@ -767,6 +778,16 @@ function extractCounterpartyName(row: ComposeStageRow): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasCounterpartyItemFlowQuestion(userMessage: string | undefined): boolean {
|
||||
const text = String(userMessage ?? "").trim().toLowerCase();
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
return /(?:что\s+нам\s+(?:отгруж|постав|привоз|прод)|како(?:й|е|го|му)\s+товар|какую\s+услуг|какие\s+товар|какие\s+услуг|товар\s+или\s+услуг|позици(?:ю|и|ях)?)/iu.test(
|
||||
text
|
||||
);
|
||||
}
|
||||
|
||||
function extractInventoryItemName(row: ComposeStageRow): string | null {
|
||||
const direct = String(row.item ?? "").trim();
|
||||
if (direct) {
|
||||
@@ -988,10 +1009,13 @@ function looksLikeInventoryPartyToken(value: string): boolean {
|
||||
return normalized === normalized.toUpperCase() && normalized.length >= 4;
|
||||
}
|
||||
|
||||
function extractInventoryCounterpartyCandidates(row: ComposeStageRow): string[] {
|
||||
function extractInventoryCounterpartyCandidates(row: ComposeStageRow, excludedTokens: string[] = []): string[] {
|
||||
const itemToken = normalizeEntityToken(extractInventoryItemName(row));
|
||||
const warehouseToken = normalizeEntityToken(extractInventoryWarehouseName(row));
|
||||
const organizationToken = normalizeEntityToken(extractInventoryOrganizationName(row));
|
||||
const excludedComparableTokens = excludedTokens
|
||||
.map((token) => normalizeEntityToken(token))
|
||||
.filter((token): token is string => Boolean(token));
|
||||
const candidates: string[] = [];
|
||||
for (const token of row.analytics) {
|
||||
const normalized = String(token ?? "").trim();
|
||||
@@ -999,7 +1023,13 @@ function extractInventoryCounterpartyCandidates(row: ComposeStageRow): string[]
|
||||
continue;
|
||||
}
|
||||
const comparable = normalizeEntityToken(normalized);
|
||||
if (!comparable || comparable === itemToken || comparable === warehouseToken || comparable === organizationToken) {
|
||||
if (
|
||||
!comparable ||
|
||||
comparable === itemToken ||
|
||||
comparable === warehouseToken ||
|
||||
comparable === organizationToken ||
|
||||
excludedComparableTokens.includes(comparable)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
candidates.push(normalized);
|
||||
@@ -1018,7 +1048,7 @@ interface InventoryTraceSummary {
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
function summarizeInventoryTraceRows(rows: ComposeStageRow[]): InventoryTraceSummary {
|
||||
function summarizeInventoryTraceRows(rows: ComposeStageRow[], excludedCounterpartyTokens: string[] = []): InventoryTraceSummary {
|
||||
const items = uniqueStrings(
|
||||
rows
|
||||
.map((row) => extractInventoryItemName(row))
|
||||
@@ -1034,7 +1064,9 @@ function summarizeInventoryTraceRows(rows: ComposeStageRow[]): InventoryTraceSum
|
||||
.map((row) => extractInventoryOrganizationName(row))
|
||||
.filter((item): item is string => Boolean(item))
|
||||
);
|
||||
const counterparties = uniqueStrings(rows.flatMap((row) => extractInventoryCounterpartyCandidates(row)));
|
||||
const counterparties = uniqueStrings(
|
||||
rows.flatMap((row) => extractInventoryCounterpartyCandidates(row, excludedCounterpartyTokens))
|
||||
);
|
||||
const documents = uniqueStrings(
|
||||
rows
|
||||
.map((row) => String(row.registrator ?? "").trim())
|
||||
@@ -1057,9 +1089,9 @@ function summarizeInventoryTraceRows(rows: ComposeStageRow[]): InventoryTraceSum
|
||||
};
|
||||
}
|
||||
|
||||
function formatInventoryTraceRows(rows: ComposeStageRow[], limit = 10): string[] {
|
||||
function formatInventoryTraceRows(rows: ComposeStageRow[], limit = 10, excludedCounterpartyTokens: string[] = []): string[] {
|
||||
return rows.slice(0, limit).map((row, index) => {
|
||||
const parties = extractInventoryCounterpartyCandidates(row);
|
||||
const parties = extractInventoryCounterpartyCandidates(row, excludedCounterpartyTokens);
|
||||
const warehouse = extractInventoryWarehouseName(row);
|
||||
const organization = extractInventoryOrganizationName(row);
|
||||
const amount =
|
||||
@@ -1082,6 +1114,35 @@ function formatInventoryTraceRows(rows: ComposeStageRow[], limit = 10): string[]
|
||||
});
|
||||
}
|
||||
|
||||
function formatCounterpartyItemFlowRows(rows: ComposeStageRow[], limit = 12): string[] {
|
||||
return rows.slice(0, limit).map((row, index) => {
|
||||
const item = extractInventoryItemName(row) ?? "позиция не указана";
|
||||
const contract = extractContractName(row);
|
||||
const warehouse = extractInventoryWarehouseName(row);
|
||||
const organization = extractInventoryOrganizationName(row);
|
||||
const quantity = extractInventoryQuantity(row);
|
||||
const amount =
|
||||
typeof row.amount === "number" && Number.isFinite(row.amount) ? formatMoneyRub(row.amount) : "сумма не указана";
|
||||
const parts = [
|
||||
`${index + 1}. ${item}`,
|
||||
`договор: ${contract ?? "не указан"}`,
|
||||
`документ: ${row.registrator}`,
|
||||
`дата: ${inventoryTraceDateLabel(row.period)}`,
|
||||
`сумма: ${amount}`
|
||||
];
|
||||
if (quantity !== null && quantity > 0) {
|
||||
parts.push(`количество: ${formatNumberWithDots(quantity, 3)}`);
|
||||
}
|
||||
if (warehouse) {
|
||||
parts.push(`склад: ${warehouse}`);
|
||||
}
|
||||
if (organization) {
|
||||
parts.push(`организация: ${organization}`);
|
||||
}
|
||||
return parts.join(" | ");
|
||||
});
|
||||
}
|
||||
|
||||
interface InventoryAgingByItemAggregate {
|
||||
item: string;
|
||||
warehouse: string | null;
|
||||
@@ -3296,6 +3357,8 @@ export function composeFactualReply(
|
||||
|
||||
const profileRows = Array.from(byCounterparty.values());
|
||||
const yearRows = Array.from(byYear.values());
|
||||
const totalFlow = profileRows.reduce((sum, item) => sum + item.total, 0);
|
||||
const totalOperations = profileRows.reduce((sum, item) => sum + item.ops, 0);
|
||||
const rankedByTotal = [...profileRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.name.localeCompare(b.name));
|
||||
const rankedByYearTotal = [...yearRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.year - b.year);
|
||||
const rankedByOps = [...profileRows].sort((a, b) => b.ops - a.ops || b.total - a.total || a.name.localeCompare(b.name));
|
||||
@@ -3337,6 +3400,33 @@ export function composeFactualReply(
|
||||
};
|
||||
}
|
||||
|
||||
if (focus === "total_flow") {
|
||||
const periodLine =
|
||||
options.periodFrom && options.periodTo
|
||||
? `За период ${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)} подтверждено ${formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`
|
||||
: `За все доступное время подтверждено ${formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`;
|
||||
const directAnswerLine = isSupplier
|
||||
? periodLine
|
||||
: `${periodLine} Это сумма денег, полученных от клиентов, а не чистая прибыль.`;
|
||||
const summaryLines = [
|
||||
directAnswerLine,
|
||||
"",
|
||||
"Подтверждение:",
|
||||
`- Операций в выборке: ${totalOperations}.`,
|
||||
`- Контрагентов в выборке: ${profileRows.length}.`
|
||||
];
|
||||
if (rankedByYearTotal.length > 0) {
|
||||
summaryLines.push(`- Самый сильный год по поступлениям: ${rankedByYearTotal[0].year} (${formatMoneyRub(rankedByYearTotal[0].total)}).`);
|
||||
}
|
||||
if (rankedByTotal.length > 0) {
|
||||
summaryLines.push(`- Крупнейший контрагент по потоку: ${rankedByTotal[0].name} (${formatMoneyRub(rankedByTotal[0].total)}).`);
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: summaryLines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (focus === "top_years_by_total") {
|
||||
const visible = rankedByYearTotal.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
@@ -4274,8 +4364,11 @@ export function composeFactualReply(
|
||||
if (intent === "inventory_sale_trace_for_item") {
|
||||
const asOfDate = resolvePayablesAsOfDate(options);
|
||||
const saleRows = rows.filter((row) => isInventorySaleMovement(row));
|
||||
const summary = summarizeInventoryTraceRows(saleRows);
|
||||
const itemLabel = summary.item ?? "товар не определен";
|
||||
const requestedItemHint = String(options.itemHint ?? "").trim();
|
||||
const provisionalExcludedTokens = requestedItemHint ? [requestedItemHint] : [];
|
||||
const summary = summarizeInventoryTraceRows(saleRows, provisionalExcludedTokens);
|
||||
const itemLabel = requestedItemHint || (summary.item ?? "товар не определен");
|
||||
const excludedCounterpartyTokens = [itemLabel];
|
||||
const directAnswerLine =
|
||||
summary.counterparties.length === 1
|
||||
? `По товару ${itemLabel} покупатель определен: ${summary.counterparties[0]}.`
|
||||
@@ -4296,7 +4389,7 @@ export function composeFactualReply(
|
||||
}
|
||||
lines.push("", "Документы выбытия:");
|
||||
if (saleRows.length > 0) {
|
||||
lines.push(...formatInventoryTraceRows(saleRows, 12));
|
||||
lines.push(...formatInventoryTraceRows(saleRows, 12, excludedCounterpartyTokens));
|
||||
} else {
|
||||
lines.push("- По выбранному товару не найдено проводок выбытия со счета 41.01 в доступном контуре.");
|
||||
}
|
||||
@@ -5023,8 +5116,12 @@ export function composeFactualReply(
|
||||
|
||||
if (intent === "open_items_by_counterparty_or_contract") {
|
||||
const counterparties = buildCounterpartyRiskAggregate(rows);
|
||||
const accountLead =
|
||||
typeof options.accountHint === "string" && options.accountHint.trim().length > 0
|
||||
? `Проверил хвосты по счету ${options.accountHint.trim()}.`
|
||||
: "Собраны открытые позиции по взаиморасчетам.";
|
||||
const lines = [
|
||||
"Собраны открытые позиции по взаиморасчетам.",
|
||||
accountLead,
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
`Контрагентов с сигналом: ${counterparties.length}.`
|
||||
];
|
||||
@@ -5090,10 +5187,73 @@ export function composeFactualReply(
|
||||
}
|
||||
|
||||
if (intent === "list_documents_by_counterparty") {
|
||||
const lines = [
|
||||
`Найдено документов по контрагенту: ${rows.length}.`,
|
||||
...formatTopRows(rows, rows.length)
|
||||
];
|
||||
const resolvedCounterparty =
|
||||
(typeof options.counterpartyHint === "string" && options.counterpartyHint.trim().length > 0
|
||||
? options.counterpartyHint.trim()
|
||||
: null) ??
|
||||
(() => {
|
||||
const counterparties = uniqueStrings(
|
||||
rows
|
||||
.map((row) => extractCounterpartyName(row))
|
||||
.filter((item): item is string => Boolean(item))
|
||||
);
|
||||
return counterparties.length === 1 ? counterparties[0] : null;
|
||||
})();
|
||||
const counterpartyLabel =
|
||||
typeof resolvedCounterparty === "string" && resolvedCounterparty.endsWith(".")
|
||||
? resolvedCounterparty
|
||||
: resolvedCounterparty
|
||||
? `${resolvedCounterparty}.`
|
||||
: null;
|
||||
const counterpartyInline =
|
||||
typeof counterpartyLabel === "string" ? counterpartyLabel.replace(/[.]+$/u, "") : resolvedCounterparty;
|
||||
const itemFlowQuestion = hasCounterpartyItemFlowQuestion(options.userMessage);
|
||||
const items = uniqueStrings(
|
||||
rows
|
||||
.map((row) => extractInventoryItemName(row))
|
||||
.filter((item): item is string => Boolean(item))
|
||||
);
|
||||
const contracts = uniqueStrings(
|
||||
rows
|
||||
.map((row) => extractContractName(row))
|
||||
.filter((item): item is string => Boolean(item))
|
||||
);
|
||||
const lines: string[] = [];
|
||||
if (itemFlowQuestion) {
|
||||
lines.push(
|
||||
counterpartyInline
|
||||
? `Контрагент: ${counterpartyInline}. Подтвержденных поставок товаров или услуг: ${rows.length}.`
|
||||
: `Подтвержденных поставок товаров или услуг по запрошенному контрагенту: ${rows.length}.`
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
counterpartyInline
|
||||
? `Контрагент: ${counterpartyInline}. Найдено документов: ${rows.length}.`
|
||||
: `Найдено документов по контрагенту: ${rows.length}.`
|
||||
);
|
||||
}
|
||||
if (counterpartyLabel) {
|
||||
lines.push(`Контрагент: ${counterpartyLabel}`);
|
||||
}
|
||||
if (itemFlowQuestion) {
|
||||
if (items.length > 0) {
|
||||
lines.push(`Позиции: ${items.slice(0, 8).join("; ")}.`);
|
||||
if (items.length > 8) {
|
||||
lines.push(`Показаны первые 8 из ${items.length} позиций.`);
|
||||
}
|
||||
}
|
||||
if (contracts.length === 1) {
|
||||
lines.push(`Договор: ${contracts[0]}.`);
|
||||
} else if (contracts.length > 1) {
|
||||
lines.push(`Договоры в выборке: ${contracts.slice(0, 3).join("; ")}.`);
|
||||
}
|
||||
lines.push(...formatCounterpartyItemFlowRows(rows));
|
||||
if (rows.length > 12) {
|
||||
lines.push(`Показаны первые 12 из ${rows.length} поставок.`);
|
||||
}
|
||||
} else {
|
||||
lines.push(...formatTopRows(rows, rows.length));
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { AddressLlmSemanticHints } from "../../types/addressQuery";
|
||||
|
||||
export interface AddressFollowupContext {
|
||||
previous_intent?: AddressIntent;
|
||||
target_intent?: AddressIntent;
|
||||
previous_filters?: AddressFilterSet;
|
||||
previous_anchor_type?:
|
||||
| "account"
|
||||
@@ -98,7 +99,7 @@ function hasSameDateHint(text: string): boolean {
|
||||
}
|
||||
|
||||
function hasSamePeriodHint(text: string): boolean {
|
||||
return /(?:на\s+тот\s+же\s+период|за\s+тот\s+же\s+период|тот\s+же\s+период(?:\s+рассмотрения)?|на\s+этот\s+же\s+период|за\s+этот\s+же\s+период|аналогичн\w+\s+текущ\w+\s+период\w+|same\s+period|same\s+range|same\s+window)/iu.test(
|
||||
return /(?:на\s+тот\s+же\s+период|за\s+тот\s+же\s+период|тот\s+же\s+период(?:\s+рассмотрения)?|на\s+этот\s+же\s+период|за\s+этот\s+же\s+период|за\s+этот\s+период|на\s+этот\s+период|за\s+тот\s+период|на\s+тот\s+период|этот\s+период|тот\s+период|аналогичн\w+\s+текущ\w+\s+период\w+|same\s+period|same\s+range|same\s+window)/iu.test(
|
||||
String(text ?? "")
|
||||
);
|
||||
}
|
||||
@@ -672,6 +673,9 @@ export function hasAddressFollowupContextSignal(text: string): boolean {
|
||||
if (hasSameDateHint(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (hasSamePeriodHint(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const tokenCount = normalized.split(/\s+/).filter(Boolean).length;
|
||||
if (
|
||||
@@ -853,7 +857,9 @@ function mergeFollowupFilters(
|
||||
intent === "inventory_aging_by_purchase_date" ||
|
||||
intent === "payables_confirmed_as_of_date" ||
|
||||
intent === "receivables_confirmed_as_of_date" ||
|
||||
intent === "vat_payable_confirmed_as_of_date"
|
||||
intent === "vat_payable_confirmed_as_of_date" ||
|
||||
intent === "vat_payable_forecast" ||
|
||||
intent === "vat_liability_confirmed_for_tax_period"
|
||||
) {
|
||||
const hasFollowupSignalForConfirmed = hasAddressFollowupContextSignal(userMessage);
|
||||
const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
|
||||
@@ -935,6 +941,34 @@ function mergeFollowupFilters(
|
||||
reasons.push("as_of_date_from_followup_context");
|
||||
}
|
||||
}
|
||||
if (
|
||||
samePeriodRequested &&
|
||||
(intent === "vat_payable_confirmed_as_of_date" ||
|
||||
intent === "vat_payable_forecast" ||
|
||||
intent === "vat_liability_confirmed_for_tax_period" ||
|
||||
intent === "payables_confirmed_as_of_date" ||
|
||||
intent === "receivables_confirmed_as_of_date")
|
||||
) {
|
||||
if (previousPeriodFrom && merged.period_from !== previousPeriodFrom) {
|
||||
merged.period_from = previousPeriodFrom;
|
||||
reasons.push("period_from_from_followup_context");
|
||||
}
|
||||
if (previousPeriodTo && merged.period_to !== previousPeriodTo) {
|
||||
merged.period_to = previousPeriodTo;
|
||||
reasons.push("period_to_from_followup_context");
|
||||
}
|
||||
if (
|
||||
intent === "vat_payable_confirmed_as_of_date" ||
|
||||
intent === "payables_confirmed_as_of_date" ||
|
||||
intent === "receivables_confirmed_as_of_date"
|
||||
) {
|
||||
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
|
||||
if (inheritedAsOfDate && merged.as_of_date !== inheritedAsOfDate) {
|
||||
merged.as_of_date = inheritedAsOfDate;
|
||||
reasons.push("as_of_date_from_followup_context");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
samePeriodRequested &&
|
||||
(intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date")
|
||||
@@ -1146,6 +1180,17 @@ function mergeFollowupFilters(
|
||||
merged.period_to = previousPeriodTo;
|
||||
}
|
||||
reasons.push("period_from_followup_context");
|
||||
if (
|
||||
intent === "vat_payable_confirmed_as_of_date" ||
|
||||
intent === "payables_confirmed_as_of_date" ||
|
||||
intent === "receivables_confirmed_as_of_date"
|
||||
) {
|
||||
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
|
||||
if (inheritedAsOfDate) {
|
||||
merged.as_of_date = inheritedAsOfDate;
|
||||
reasons.push("as_of_date_from_followup_context");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -1199,7 +1244,7 @@ function deriveIntentWithFollowupContext(
|
||||
userMessage: string,
|
||||
followupContext: AddressFollowupContext | null
|
||||
): AddressIntentResolution {
|
||||
if (!followupContext || !followupContext.previous_intent) {
|
||||
if (!followupContext || (!followupContext.previous_intent && !followupContext.target_intent)) {
|
||||
return detectedIntent;
|
||||
}
|
||||
|
||||
@@ -1208,7 +1253,11 @@ function deriveIntentWithFollowupContext(
|
||||
if (!hasFollowupSignal) {
|
||||
return detectedIntent;
|
||||
}
|
||||
const previousIntent = followupContext.previous_intent;
|
||||
const sourceIntent = followupContext.previous_intent ?? null;
|
||||
const fallbackIntent = followupContext.target_intent ?? sourceIntent;
|
||||
if (!sourceIntent && !fallbackIntent) {
|
||||
return detectedIntent;
|
||||
}
|
||||
const previousFilters = followupContext.previous_filters ?? {};
|
||||
const previousContract = toNonEmptyString(previousFilters.contract);
|
||||
const previousCounterparty = toNonEmptyString(previousFilters.counterparty);
|
||||
@@ -1243,7 +1292,8 @@ function deriveIntentWithFollowupContext(
|
||||
};
|
||||
}
|
||||
|
||||
const previousIsBalanceFamily = previousIntent === "account_balance_snapshot" || previousIntent === "documents_forming_balance";
|
||||
const previousIsBalanceFamily =
|
||||
sourceIntent === "account_balance_snapshot" || sourceIntent === "documents_forming_balance";
|
||||
if (
|
||||
previousIsBalanceFamily &&
|
||||
hasAccountSignal(normalizedMessage) &&
|
||||
@@ -1262,7 +1312,7 @@ function deriveIntentWithFollowupContext(
|
||||
};
|
||||
}
|
||||
|
||||
const previousIsInventoryFamily = isInventoryIntent(previousIntent);
|
||||
const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
|
||||
const inventorySelectedObjectFollowup =
|
||||
hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal);
|
||||
if (inventorySelectedObjectFollowup && hasInventorySupplierFollowupCue(normalizedMessage)) {
|
||||
@@ -1273,7 +1323,7 @@ function deriveIntentWithFollowupContext(
|
||||
detectedIntent.intent === "bank_operations_by_counterparty" ||
|
||||
detectedIntent.intent === "bank_operations_by_contract" ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
|
||||
detectedIntent.intent === previousIntent
|
||||
detectedIntent.intent === sourceIntent
|
||||
) {
|
||||
return {
|
||||
intent: "inventory_purchase_provenance_for_item",
|
||||
@@ -1289,7 +1339,7 @@ function deriveIntentWithFollowupContext(
|
||||
detectedIntent.intent === "list_documents_by_counterparty" ||
|
||||
detectedIntent.intent === "list_documents_by_contract" ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
|
||||
detectedIntent.intent === previousIntent
|
||||
detectedIntent.intent === sourceIntent
|
||||
) {
|
||||
return {
|
||||
intent: "inventory_purchase_documents_for_item",
|
||||
@@ -1309,7 +1359,7 @@ function deriveIntentWithFollowupContext(
|
||||
detectedIntent.intent === "bank_operations_by_contract" ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
|
||||
detectedIntent.intent === "inventory_sale_trace_for_item" ||
|
||||
detectedIntent.intent === previousIntent
|
||||
detectedIntent.intent === sourceIntent
|
||||
) {
|
||||
return {
|
||||
intent: "inventory_profitability_for_item",
|
||||
@@ -1323,7 +1373,7 @@ function deriveIntentWithFollowupContext(
|
||||
if (
|
||||
detectedIntent.intent === "unknown" ||
|
||||
detectedIntent.intent === "inventory_purchase_provenance_for_item" ||
|
||||
detectedIntent.intent === previousIntent ||
|
||||
detectedIntent.intent === sourceIntent ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date"
|
||||
) {
|
||||
return {
|
||||
@@ -1339,7 +1389,7 @@ function deriveIntentWithFollowupContext(
|
||||
detectedIntent.intent === "unknown" ||
|
||||
detectedIntent.intent === "inventory_purchase_provenance_for_item" ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
|
||||
detectedIntent.intent === previousIntent
|
||||
detectedIntent.intent === sourceIntent
|
||||
) {
|
||||
return {
|
||||
intent: "inventory_sale_trace_for_item",
|
||||
@@ -1354,7 +1404,7 @@ function deriveIntentWithFollowupContext(
|
||||
detectedIntent.intent === "unknown" ||
|
||||
detectedIntent.intent === "inventory_sale_trace_for_item" ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
|
||||
detectedIntent.intent === previousIntent
|
||||
detectedIntent.intent === sourceIntent
|
||||
) {
|
||||
return {
|
||||
intent: "inventory_purchase_to_sale_chain",
|
||||
@@ -1368,7 +1418,7 @@ function deriveIntentWithFollowupContext(
|
||||
previousIsInventoryFamily &&
|
||||
hasFollowupSignal &&
|
||||
hasBareInventoryPurchaseDateFollowupCue(normalizedMessage) &&
|
||||
(detectedIntent.intent === "unknown" || detectedIntent.intent === previousIntent)
|
||||
(detectedIntent.intent === "unknown" || detectedIntent.intent === sourceIntent)
|
||||
) {
|
||||
return {
|
||||
intent: "inventory_purchase_provenance_for_item",
|
||||
@@ -1431,7 +1481,7 @@ function deriveIntentWithFollowupContext(
|
||||
}
|
||||
|
||||
return {
|
||||
intent: previousIntent,
|
||||
intent: fallbackIntent ?? "unknown",
|
||||
confidence: "low",
|
||||
reasons: [...detectedIntent.reasons, "intent_from_followup_context"]
|
||||
};
|
||||
|
||||
@@ -101,6 +101,13 @@ function tokenizeAnchor(value: string): string[] {
|
||||
.filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token));
|
||||
}
|
||||
|
||||
function tokenizeSearchableText(value: string): string[] {
|
||||
return normalizeSearchText(value)
|
||||
.split(" ")
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function anchorTokenVariants(token: string): string[] {
|
||||
const source = String(token ?? "").trim().toLowerCase();
|
||||
if (!source) {
|
||||
@@ -123,9 +130,43 @@ function anchorTokenVariants(token: string): string[] {
|
||||
return Array.from(variants);
|
||||
}
|
||||
|
||||
function normalizePartyTokenSkeleton(value: string): string {
|
||||
return normalizeSearchText(value).replace(/\s+/g, "").replace(/[аеёиоуыэюяaeiouy]+/giu, "");
|
||||
}
|
||||
|
||||
function fuzzyPartyTokenMatches(candidate: string, token: string): boolean {
|
||||
const normalizedCandidate = normalizeSearchText(candidate);
|
||||
const normalizedToken = normalizeSearchText(token);
|
||||
if (!normalizedCandidate || !normalizedToken) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedCandidate === normalizedToken) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
normalizedCandidate.length < 4 ||
|
||||
normalizedToken.length < 4 ||
|
||||
/\d/u.test(normalizedCandidate) ||
|
||||
/\d/u.test(normalizedToken)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const candidateSkeleton = normalizePartyTokenSkeleton(normalizedCandidate);
|
||||
const tokenSkeleton = normalizePartyTokenSkeleton(normalizedToken);
|
||||
if (candidateSkeleton.length < 3 || tokenSkeleton.length < 3) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
candidateSkeleton === tokenSkeleton ||
|
||||
candidateSkeleton.startsWith(tokenSkeleton) ||
|
||||
tokenSkeleton.startsWith(candidateSkeleton)
|
||||
);
|
||||
}
|
||||
|
||||
function matchesAnchorText(searchable: string, anchor: string): boolean {
|
||||
const searchableNormalized = normalizeSearchText(searchable);
|
||||
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
|
||||
const searchableTokens = tokenizeSearchableText(searchable);
|
||||
const tokens = tokenizeAnchor(anchor);
|
||||
if (tokens.length === 0) {
|
||||
const direct = normalizeSearchText(anchor);
|
||||
@@ -138,7 +179,11 @@ function matchesAnchorText(searchable: string, anchor: string): boolean {
|
||||
const variants = anchorTokenVariants(token);
|
||||
return variants.some((variant) => {
|
||||
const tokenLatin = transliterateCyrillicToLatin(variant);
|
||||
return searchableNormalized.includes(variant) || searchableLatin.includes(tokenLatin);
|
||||
return (
|
||||
searchableNormalized.includes(variant) ||
|
||||
searchableLatin.includes(tokenLatin) ||
|
||||
searchableTokens.some((candidate) => fuzzyPartyTokenMatches(candidate, variant))
|
||||
);
|
||||
});
|
||||
});
|
||||
if (fullMatch) {
|
||||
|
||||
@@ -2440,6 +2440,12 @@ function findRecentAddressFilterValue(items, key) {
|
||||
if (!isAddressLaneDebugPayload(debug)) {
|
||||
continue;
|
||||
}
|
||||
const replyType = toNonEmptyString(item.reply_type);
|
||||
const limitedReasonCategory = toNonEmptyString(debug.limited_reason_category);
|
||||
if ((replyType && replyType !== "factual" && replyType !== "factual_with_explanation") ||
|
||||
limitedReasonCategory) {
|
||||
continue;
|
||||
}
|
||||
const directFilterValue = readAddressFilterString(debug, key);
|
||||
if (directFilterValue) {
|
||||
return directFilterValue;
|
||||
@@ -2735,8 +2741,10 @@ function hasShortDebtMirrorFollowupSignal(userMessage) {
|
||||
return false;
|
||||
}
|
||||
return samples.some((sample) => /^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu.test(sample) ||
|
||||
/^(?:а|a|и|i)\s+нам(?=$|[\s,.;:!?])/iu.test(sample) ||
|
||||
/^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu.test(sample) ||
|
||||
/^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєс‚рѕ(?=$|[\s,.;:!?])/iu.test(sample) ||
|
||||
/^(?:р°|a|рё|i)\s+рЅр°рј(?=$|[\s,.;:!?])/iu.test(sample) ||
|
||||
/^(?:р°|a|рё|i)\s+(?:рјс‹\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(sample));
|
||||
}
|
||||
function isInventorySelectedObjectIntent(intent) {
|
||||
@@ -2854,6 +2862,7 @@ function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) {
|
||||
}
|
||||
const hasReceivablesCue = /(?:нам\s+кто\s+долж|кто\s+нам\s+долж|кто\s+долж[а-яё]*\s+нам|дебитор|к\s+получению|к\s+взысканию|receivable)/iu.test(normalized) ||
|
||||
/^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu.test(normalized) ||
|
||||
/^(?:а|a|и|i)\s+нам(?=$|[\s,.;:!?])/iu.test(normalized) ||
|
||||
/^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєс‚рѕ(?=$|[\s,.;:!?])/iu.test(normalized);
|
||||
const hasPayablesCue = /(?:мы\s+кому\s+долж|кому\s+мы\s+долж|кому\s+долж[а-яё]*\s+мы|кредитор|к\s+уплате|payable)/iu.test(normalized) ||
|
||||
/^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu.test(normalized) ||
|
||||
@@ -2868,6 +2877,49 @@ function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function hasExplicitOrganizationScopeCue(text) {
|
||||
return /(?:(?:^|[\s"'«»„“()\\\/])(?:ооо|ао|пао|зао|оао|ип|гку|муп|гуп|llc|ltd|inc|corp)(?=$|[\s"'«»„“()\\\/.,;:]))|организац|компан|контор|фирм/u.test(String(text ?? "").toLowerCase());
|
||||
}
|
||||
function looksLikeBareCounterpartyScopeTarget(text) {
|
||||
const normalized = compactWhitespace(String(text ?? "").toLowerCase());
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (hasExplicitOrganizationScopeCue(normalized)) {
|
||||
return false;
|
||||
}
|
||||
if (/[0-9]/u.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
const tokens = normalized.split(/\s+/u).filter(Boolean);
|
||||
if (tokens.length === 0 || tokens.length > 4) {
|
||||
return false;
|
||||
}
|
||||
return tokens.every((token) => /^[a-zа-яё._-]{2,}$/iu.test(token));
|
||||
}
|
||||
function shouldDowngradeOrganizationSemanticHint(scopeTargetText, fragmentText) {
|
||||
const target = toNonEmptyString(scopeTargetText);
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
if (hasExplicitOrganizationScopeCue(target) || hasExplicitOrganizationScopeCue(fragmentText)) {
|
||||
return false;
|
||||
}
|
||||
return looksLikeBareCounterpartyScopeTarget(target);
|
||||
}
|
||||
function shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent) {
|
||||
const normalized = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
|
||||
if (!normalized || countTokens(normalized) > 4) {
|
||||
return false;
|
||||
}
|
||||
if (sourceIntent !== "list_documents_by_counterparty" && sourceIntent !== "list_documents_by_contract") {
|
||||
return false;
|
||||
}
|
||||
if (/(?:банк|выписк|плат[её]ж|оплат|списан|поступлен|bank|payment|wire|statement)/iu.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return /^(?:а|и|ну)?\s*по\s+[a-zа-яё0-9._-]{2,}(?:\s+[a-zа-яё0-9._-]{2,})?$/iu.test(normalized);
|
||||
}
|
||||
function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null, addressNavigationState = null) {
|
||||
const previousAddressItem = findLastAddressAssistantItem(items);
|
||||
const previousAddressDebug = previousAddressItem?.debug ?? null;
|
||||
@@ -2988,7 +3040,8 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||
const suggestedIntent = Array.isArray(followupOffer?.suggested_intents)
|
||||
? toNonEmptyString(followupOffer.suggested_intents[0])
|
||||
: null;
|
||||
if (suggestedIntent) {
|
||||
const keepPreviousIntent = shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent);
|
||||
if (suggestedIntent && !keepPreviousIntent) {
|
||||
previousIntent = suggestedIntent;
|
||||
followupSelectionMode = "switch_to_suggested_intent";
|
||||
}
|
||||
@@ -3086,13 +3139,20 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||
let previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
|
||||
? { ...previousFiltersRaw }
|
||||
: {};
|
||||
if (!toNonEmptyString(previousFilters.contract)) {
|
||||
const shouldBackfillHistoricalPartyAnchors =
|
||||
sourceIntentHint === "list_contracts_by_counterparty" ||
|
||||
sourceIntentHint === "list_documents_by_counterparty" ||
|
||||
sourceIntentHint === "bank_operations_by_counterparty" ||
|
||||
sourceIntentHint === "list_documents_by_contract" ||
|
||||
sourceIntentHint === "bank_operations_by_contract" ||
|
||||
sourceIntentHint === "open_items_by_counterparty_or_contract";
|
||||
if (shouldBackfillHistoricalPartyAnchors && !toNonEmptyString(previousFilters.contract)) {
|
||||
const historicalContract = findRecentAddressFilterValue(items, "contract");
|
||||
if (historicalContract) {
|
||||
previousFilters.contract = historicalContract;
|
||||
}
|
||||
}
|
||||
if (!toNonEmptyString(previousFilters.counterparty)) {
|
||||
if (shouldBackfillHistoricalPartyAnchors && !toNonEmptyString(previousFilters.counterparty)) {
|
||||
const historicalCounterparty = findRecentAddressFilterValue(items, "counterparty");
|
||||
if (historicalCounterparty) {
|
||||
previousFilters.counterparty = historicalCounterparty;
|
||||
@@ -3138,7 +3198,10 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||
const rootContextOnlyPivot = Boolean((isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") &&
|
||||
hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage));
|
||||
const inventoryRootTemporalPivot = Boolean(inventoryRootFrame &&
|
||||
(isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") &&
|
||||
(isInventorySelectedObjectIntent(sourceIntentHint) ||
|
||||
isInventoryRootFrameIntent(sourceIntentHint) ||
|
||||
currentFrameKind === "inventory_drilldown" ||
|
||||
currentFrameKind === "inventory_root") &&
|
||||
(hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate) &&
|
||||
!hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage));
|
||||
const rootScopedPivot = rootContextOnlyPivot || inventoryRootTemporalPivot;
|
||||
@@ -3212,19 +3275,34 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
|
||||
return null;
|
||||
}
|
||||
const shouldAttachInventoryRootFrame = Boolean(inventoryRootFrame &&
|
||||
(rootScopedPivot ||
|
||||
isInventoryRootFrameIntent(sourceIntentHint) ||
|
||||
isInventorySelectedObjectIntent(sourceIntentHint) ||
|
||||
hasNavigationInventoryItemFocusHint ||
|
||||
inventoryShortFollowupPrimary ||
|
||||
inventoryShortFollowupAlternate ||
|
||||
hasInventoryRootTemporalFollowupPrimary ||
|
||||
hasInventoryRootTemporalFollowupAlternate ||
|
||||
hasSelectedObjectInventorySignalPrimary ||
|
||||
hasSelectedObjectInventorySignalAlternate));
|
||||
const carryoverTargetIntent = followupSelectionMode === "carry_root_context"
|
||||
? inventoryRootFrame?.intent ?? explicitIntent ?? previousIntent ?? undefined
|
||||
: explicitIntent ?? previousIntent ?? undefined;
|
||||
return {
|
||||
followupContext: {
|
||||
previous_intent: previousIntent ?? undefined,
|
||||
target_intent: carryoverTargetIntent,
|
||||
previous_filters: previousFilters,
|
||||
previous_anchor_type: previousAnchorType ?? undefined,
|
||||
previous_anchor_value: previousAnchor,
|
||||
resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined,
|
||||
root_context_only: rootScopedPivot || undefined,
|
||||
root_intent: inventoryRootFrame?.intent ?? undefined,
|
||||
root_filters: inventoryRootFrame?.filters ?? undefined,
|
||||
root_anchor_type: inventoryRootFrame?.anchorType ?? undefined,
|
||||
root_anchor_value: inventoryRootFrame?.anchorValue ?? undefined,
|
||||
current_frame_kind: currentFrameKind ?? undefined
|
||||
root_intent: shouldAttachInventoryRootFrame ? inventoryRootFrame?.intent ?? undefined : undefined,
|
||||
root_filters: shouldAttachInventoryRootFrame ? inventoryRootFrame?.filters ?? undefined : undefined,
|
||||
root_anchor_type: shouldAttachInventoryRootFrame ? inventoryRootFrame?.anchorType ?? undefined : undefined,
|
||||
root_anchor_value: shouldAttachInventoryRootFrame ? inventoryRootFrame?.anchorValue ?? undefined : undefined,
|
||||
current_frame_kind: shouldAttachInventoryRootFrame ? currentFrameKind ?? undefined : undefined
|
||||
},
|
||||
previousAddressIntent: previousIntent,
|
||||
previousAddressAnchor: previousAnchor,
|
||||
@@ -3298,7 +3376,7 @@ function isRetryableAddressLimitedResult(addressLane) {
|
||||
return false;
|
||||
}
|
||||
const category = String(addressLane?.debug?.limited_reason_category ?? "").trim().toLowerCase();
|
||||
return category === "missing_anchor" || category === "empty_match";
|
||||
return category === "missing_anchor" || category === "empty_match" || category === "unsupported";
|
||||
}
|
||||
function isAddressLlmPreDecomposeCandidate(userMessage) {
|
||||
const repaired = repairAddressMojibake(String(userMessage ?? ""));
|
||||
@@ -3317,13 +3395,19 @@ function normalizeAddressSemanticHintsFromFragment(fragment) {
|
||||
return null;
|
||||
}
|
||||
const scopeTargetKind = toNonEmptyString(hints.scope_target_kind);
|
||||
const scopeTargetText = toNonEmptyString(hints.scope_target_text);
|
||||
const fragmentText = toNonEmptyString(fragment.raw_fragment_text) ?? toNonEmptyString(fragment.normalized_fragment_text) ?? "";
|
||||
const normalizedScopeTargetKind = scopeTargetKind === "organization" &&
|
||||
shouldDowngradeOrganizationSemanticHint(scopeTargetText, fragmentText)
|
||||
? "counterparty"
|
||||
: scopeTargetKind;
|
||||
const dateScopeKind = toNonEmptyString(hints.date_scope_kind);
|
||||
return {
|
||||
scope_target_kind: scopeTargetKind ?? "none",
|
||||
scope_target_text: toNonEmptyString(hints.scope_target_text),
|
||||
scope_target_kind: normalizedScopeTargetKind ?? "none",
|
||||
scope_target_text: scopeTargetText,
|
||||
date_scope_kind: dateScopeKind ?? "missing",
|
||||
self_scope_detected: hints.self_scope_detected === true || scopeTargetKind === "self_scope",
|
||||
selected_object_scope_detected: hints.selected_object_scope_detected === true || scopeTargetKind === "selected_object"
|
||||
self_scope_detected: hints.self_scope_detected === true || normalizedScopeTargetKind === "self_scope",
|
||||
selected_object_scope_detected: hints.selected_object_scope_detected === true || normalizedScopeTargetKind === "selected_object"
|
||||
};
|
||||
}
|
||||
function extractAddressPredecomposeCandidateFromFragments(fragments) {
|
||||
|
||||
Reference in New Issue
Block a user