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

This commit is contained in:
dctouch 2026-04-11 17:22:25 +03:00
parent c2fa360eb9
commit a0d0f95dde
5 changed files with 597 additions and 50 deletions

View File

@ -2382,6 +2382,25 @@ Implemented in current pass (Stage 3.2 semantic route arbitration):
- Stage 3 focused suite: `10` files / `76` tests passed.
- Type build: `npm --prefix llm_normalizer/backend run build` passed.
Implemented in current pass (Stage 3.3 soft-refusal + anti-template limited replies):
1. Reworked address-lane limited reply composer from fixed template to contextual soft-refusal:
- Added deterministic phrasing variants (stable, non-random) to reduce repeated boilerplate.
- Kept concise "Коротко" structure while replacing hard repeated wording.
2. Added context-aware explanation and recovery offers for limited responses:
- Response now injects request scope hints when available (organization, as-of date, period window).
- Added "Что могу сделать сейчас" with nearest supported scenarios (documents/payments by anchor, open contracts/tails, account-balance drilldown).
- Added weak-anchor suppression for user-facing hints to avoid low-value auto-substitutions in suggestions.
3. Improved unsupported aggregate handling UX:
- For aggregate/ranking style queries, reply now proposes evidence-first path (collect factual base -> continue in extended analysis) instead of hard static fallback phrase.
4. Improved missing-anchor UX:
- Missing anchor tokens are normalized to user-facing terms (e.g., `counterparty_or_contract` -> "контрагент или договор").
- Added concrete reformulation hint for underspecified anchor cases.
5. Regression updates:
- Updated `addressQueryRuntimeM23.test.ts` soft out-of-scope assertion to validate contextual soft-refusal structure.
6. Validation snapshot:
- Extended regression pack: `11` files / `344` tests passed.
- Type build: `npm --prefix llm_normalizer/backend run build` passed.
Acceptance (Stage 3):
1. LLM outputs strictly validated schema for extraction/decomposition (no free-form).
2. Deterministic guards can block or downgrade answers when evidence insufficient.

View File

@ -835,29 +835,173 @@ function toLegacyMcpStatus(status) {
}
return status;
}
function composeLimitedReply(category, reason, nextStep) {
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
];
function pickDeterministicVariant(seed, variants) {
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) {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : null;
}
function isWeakOfferAnchorValue(value) {
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) {
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) {
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 = [];
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) {
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 = [];
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) {
lines.push(`Что можно сделать дальше: ${normalizeLimitedNextStep(nextStep)}.`);
offers.push(nextStep);
}
return Array.from(new Set(offers)).slice(0, 3);
}
function composeLimitedReply(input) {
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");
}
@ -865,7 +1009,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: {

View File

@ -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: {

View File

@ -2352,7 +2352,8 @@ describe("address query limited taxonomy and stage diagnostics", () => {
expect(result?.debug.detected_intent).toBe("unknown");
expect(result?.debug.limited_reason_category).toBe("unsupported");
const reply = String(result?.reply_text ?? "");
expect(reply.toLowerCase()).toContain("вне поддерживаемого контура");
expect(reply.toLowerCase()).toContain("адресн");
expect(reply).toContain("Что могу сделать сейчас:");
expect(reply).not.toMatch(/address_query|V1|lookup|materialized|якор/iu);
});

View File

@ -0,0 +1,190 @@
{
"suite_id": "assistant_autogen_runtime_job-0tckNFdleX",
"suite_version": "0.1.0",
"schema_version": "assistant_autogen_runtime_v0_1",
"scenario_count": 15,
"case_ids": [
"AUTO-001",
"AUTO-002",
"AUTO-003",
"AUTO-004",
"AUTO-005",
"AUTO-006",
"AUTO-007",
"AUTO-008",
"AUTO-009",
"AUTO-010",
"AUTO-011",
"AUTO-012",
"AUTO-013",
"AUTO-014",
"AUTO-015"
],
"cases": [
{
"case_id": "AUTO-001",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Кому из контрагентов мы уже месяц отдаем товары, но на счетах все еще красуется минусовое сальдо - это реально зеленый свет для ручного вмешательства?"
}
]
},
{
"case_id": "AUTO-002",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Где у нас накопились авансы к отгрузкам, которые уже давно пора закрыть или хотя бы перепроверить, чтобы не подозревать худшее?"
}
]
},
{
"case_id": "AUTO-003",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Покажи контрагентов, по которым сальдо у нас выглядит так, будто оно врет - ну точно не совпадает с тем, что они нам прислали. Это уже критично для сверки."
}
]
},
{
"case_id": "AUTO-004",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Сколько заказчиков у нас на этот момент могут считаться долгожителями по своим задолженностям?"
}
]
},
{
"case_id": "AUTO-005",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "В каких случаях мы видим ситуацию, когда документы есть, а денег - нет и пока не предвидится?"
}
]
},
{
"case_id": "AUTO-006",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Какие контрагенты висят с закрытыми отгрузками, но с открытыми документами оплаты, что явно выглядит как кейс для ручной проверки?"
}
]
},
{
"case_id": "AUTO-007",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Покажи контрагентов, у которых есть неоплаченные задолженности по договорам на конец месяца - это уже красный свет для бухгалтера."
}
]
},
{
"case_id": "AUTO-008",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "По каким заказчикам мы можем выделить непростую картину: сальдо нулевое, а история платежей явно говорит о том, что все не так просто?"
}
]
},
{
"case_id": "AUTO-009",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Какие контрагенты у нас на этом моменте могут быть причислены к тем, кто вообще не платит уже несколько месяцев?"
}
]
},
{
"case_id": "AUTO-010",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "В каких случаях мы видим зависшие отгрузки, которые уже давно пора закрыть - это грозит проблемами в отчетности."
}
]
},
{
"case_id": "AUTO-011",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Покажи контрагентов, по которым на конец месяца сальдо выглядит так, будто документы собраны криво и их нужно перепроверить."
}
]
},
{
"case_id": "AUTO-012",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Какие у нас зависшие авансы или предоплаты уже давно пора либо закрыть, либо хотя бы проверить - это уже не просто вопрос времени?"
}
]
},
{
"case_id": "AUTO-013",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "По каким контрагентам мы можем заметить такую картину: оплачено меньше, чем отгружено, и это явно требует вмешательства бухгалтера."
}
]
},
{
"case_id": "AUTO-014",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Какие незакрытые документы по договорам у нас уже давно пора проверить - это грозит серьезными проблемами?"
}
]
},
{
"case_id": "AUTO-015",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Покажи контрагентов, чьи заказы на отгрузку еще не оплачены, но сальдо уже отрицательное - это явный признак того, что нужно вмешаться."
}
]
}
]
}