ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Рефакторинг этапов Stage 3.3: убран шаблонный LIMITED_WITH_REASON и переведен в контекстный мягкий отказ с полезными next-step.

This commit is contained in:
2026-04-11 17:22:25 +03:00
parent c2fa360eb9
commit a0d0f95dde
5 changed files with 597 additions and 50 deletions
@@ -1025,32 +1025,211 @@ function toLegacyMcpStatus(
return status;
}
function composeLimitedReply(category: AddressLimitedReasonCategory, reason: string, nextStep?: string): string {
const heading =
category === "empty_match"
? "По текущим условиям в доступном срезе данных совпадений не нашлось."
: category === "missing_anchor"
? "Чтобы ответить надежно, нужен более точный ориентир в запросе."
: category === "recipe_visibility_gap"
? "Запрос понятен, но текущий режим не дает нужной детализации."
: category === "unsupported"
? "Сейчас этот тип вопроса вне поддерживаемого контура адресного режима."
: "Не удалось завершить проверку в адресном режиме.";
const reasonLine =
category === "unsupported"
? "Коротко: этот сценарий пока не поддержан в текущем адресном контуре."
: category === "missing_anchor"
? "Коротко: в запросе не хватает конкретного ориентира (контрагент, договор или период)."
: category === "recipe_visibility_gap"
? "Коротко: для уверенного ответа нужен более специализированный сценарий выборки."
: `Коротко: ${normalizeLimitedReason(reason)}.`;
const lines = [
heading,
reasonLine
];
if (nextStep) {
lines.push(`Что можно сделать дальше: ${normalizeLimitedNextStep(nextStep)}.`);
function pickDeterministicVariant(seed: string, variants: string[]): string {
if (variants.length === 0) {
return "";
}
let score = 0;
for (const char of String(seed ?? "")) {
score = (score + char.charCodeAt(0)) % 104_729;
}
return variants[score % variants.length];
}
function toNonEmptyFilterValue(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : null;
}
function isWeakOfferAnchorValue(value: string): boolean {
const normalized = String(value ?? "")
.toLowerCase()
.replace(/\s+/gu, " ")
.trim();
if (!normalized) {
return true;
}
if (normalized.length < 3) {
return true;
}
if (/^\d+$/u.test(normalized)) {
return true;
}
return /^(?:контрагент(?:ы|а|у|ом)?|договор(?:ы|а|у|ом)?|контракт(?:ы|а|у|ом)?|документ(?:ы|а|у|ом)?|оплат(?:а|ы|у|ой|ам)?|плат[её]ж(?:и|а|у|ом)?|операц(?:ия|ии|ий|ию|иями)?|период|данные|база|компания|организация)$/iu.test(
normalized
);
}
function normalizeMissingAnchorLabel(anchor: string): string {
if (anchor === "counterparty_or_contract") {
return "контрагент или договор";
}
if (anchor === "counterparty") {
return "контрагент";
}
if (anchor === "contract") {
return "договор";
}
if (anchor === "account") {
return "счет";
}
if (anchor === "document_ref") {
return "документ";
}
if (anchor === "organization") {
return "организация";
}
if (anchor === "period" || anchor === "period_from" || anchor === "period_to" || anchor === "as_of_date") {
return "период/дата";
}
return anchor.replace(/_/gu, " ");
}
function buildLimitedScopeLine(filters: AddressFilterSet): string | null {
const organization = toNonEmptyFilterValue(filters.organization);
const asOfDate = toNonEmptyFilterValue(filters.as_of_date);
const periodFrom = toNonEmptyFilterValue(filters.period_from);
const periodTo = toNonEmptyFilterValue(filters.period_to);
const scopeParts: string[] = [];
if (organization) {
scopeParts.push(`организация ${organization}`);
}
if (asOfDate) {
scopeParts.push(`срез на ${asOfDate}`);
} else if (periodFrom || periodTo) {
scopeParts.push(`период ${periodFrom ?? "..."}..${periodTo ?? "..."}`);
}
if (scopeParts.length === 0) {
return null;
}
return `Контекст запроса: ${scopeParts.join(", ")}.`;
}
function buildLimitedOffers(input: {
category: AddressLimitedReasonCategory;
shape: AddressQueryShapeDetection;
filters: AddressFilterSet;
missingRequiredFilters: string[];
reason: string;
nextStep?: string;
}): string[] {
const counterpartyRaw = toNonEmptyFilterValue(input.filters.counterparty);
const contractRaw = toNonEmptyFilterValue(input.filters.contract);
const counterparty = counterpartyRaw && !isWeakOfferAnchorValue(counterpartyRaw) ? counterpartyRaw : null;
const contract = contractRaw && !isWeakOfferAnchorValue(contractRaw) ? contractRaw : null;
const account = toNonEmptyFilterValue(input.filters.account);
const offers: string[] = [];
if (input.category === "missing_anchor") {
const missingAnchors = Array.from(
new Set(
(Array.isArray(input.missingRequiredFilters) ? input.missingRequiredFilters : [])
.map((item) => normalizeMissingAnchorLabel(String(item ?? "").trim()))
.filter((item) => item.length > 0)
)
);
if (missingAnchors.length > 0) {
offers.push(`уточнить ориентир: ${missingAnchors.join(", ")}`);
}
if (missingAnchors.includes("контрагент или договор")) {
offers.push("пример: «покажи документы по договору <номер> за 2020 год»");
}
}
if (counterparty) {
offers.push(`показать документы и платежи по контрагенту ${counterparty}`);
} else if (contract) {
offers.push(`показать документы и платежи по договору ${contract}`);
} else {
offers.push("показать документы/платежи по контрагенту или договору");
}
if (account) {
offers.push(`проверить остаток и документы, формирующие остаток по счету ${account}`);
} else {
offers.push("показать незакрытые договоры или хвосты на дату");
}
const aggregateIntentSignal =
input.shape.shape === "AGGREGATE_LOOKUP" ||
/(?:оборот|выруч|доход|прибыл|марж|рентабел|тренд|динам|самый|топ|ranking|revenue|profit|margin)/iu.test(
String(input.reason ?? "")
);
if (input.category === "unsupported" && aggregateIntentSignal) {
offers.unshift("собрать фактическую базу по периоду, после чего посчитать метрику в расширенном анализе");
}
const nextStep = normalizeLimitedNextStep(input.nextStep ?? "");
if (nextStep) {
offers.push(nextStep);
}
return Array.from(new Set(offers)).slice(0, 3);
}
function composeLimitedReply(input: {
category: AddressLimitedReasonCategory;
reason: string;
nextStep?: string;
shape: AddressQueryShapeDetection;
filters: AddressFilterSet;
missingRequiredFilters: string[];
}): string {
const reason = normalizeLimitedReason(input.reason);
const headingSeed = `${input.category}|${input.shape.shape}|${reason}`;
const heading =
input.category === "empty_match"
? pickDeterministicVariant(headingSeed, [
"По текущим условиям в доступном срезе данных совпадений не нашлось.",
"В текущем срезе данных по этому запросу совпадения не найдены."
])
: input.category === "missing_anchor"
? pickDeterministicVariant(headingSeed, [
"Чтобы ответ был точным, нужно чуть сильнее заякорить запрос.",
"Запрос понятен, но для надежного ответа не хватает опорного ориентира."
])
: input.category === "recipe_visibility_gap"
? pickDeterministicVariant(headingSeed, [
"Запрос понятен, но текущий сценарий выборки не дает нужной детализации.",
"Смысл запроса ясен, но в этом контуре не хватает глубины выборки."
])
: input.category === "unsupported"
? pickDeterministicVariant(headingSeed, [
"По этому вопросу в текущем адресном контуре пока нет надежного маршрута ответа.",
"Сейчас в адресном режиме такой сценарий не закрыт без риска ошибочного вывода."
])
: "Не удалось завершить проверку в адресном режиме.";
const reasonLine =
input.category === "unsupported"
? "Коротко: сценарий пока не покрыт текущими адресными маршрутами."
: input.category === "missing_anchor"
? "Коротко: не хватает конкретного ориентира (контрагент, договор, счет или период)."
: input.category === "recipe_visibility_gap"
? "Коротко: для уверенного ответа нужен более специализированный сценарий выборки."
: `Коротко: ${reason}.`;
const lines = [heading, reasonLine];
const scopeLine = buildLimitedScopeLine(input.filters);
if (scopeLine) {
lines.push(scopeLine);
}
const offers = buildLimitedOffers({
category: input.category,
shape: input.shape,
filters: input.filters,
missingRequiredFilters: input.missingRequiredFilters,
reason: input.reason,
nextStep: input.nextStep
});
if (offers.length > 0) {
lines.push(`Что могу сделать сейчас: ${offers.join("; ")}.`);
}
return lines.join("\n");
}
@@ -1091,7 +1270,14 @@ function buildLimitedExecutionResult(input: {
const accountScopeAudit = input.accountScopeAudit ?? buildDefaultAccountScopeAudit(input.filters);
return {
handled: true,
reply_text: composeLimitedReply(input.category, input.reasonText, input.nextStep),
reply_text: composeLimitedReply({
category: input.category,
reason: input.reasonText,
nextStep: input.nextStep,
shape: input.shape,
filters: input.filters,
missingRequiredFilters: input.missingRequiredFilters
}),
reply_type: "partial_coverage",
response_type: "LIMITED_WITH_REASON",
debug: {