Очеловечить MCP ответы и broad-eval recap

This commit is contained in:
dctouch 2026-05-01 22:38:23 +03:00
parent 4c00d8c854
commit da3a148918
6 changed files with 295 additions and 73 deletions

View File

@ -53,7 +53,11 @@ function isInternalMechanicsLine(value) {
text.includes("catalog_") ||
text.includes("scope is not implemented yet") ||
text.includes("needs more scope before execution") ||
text.includes("mcp_execution_performed"));
text.includes("mcp_execution_performed")
|| text.includes("confirmed 1c metadata surface")
|| text.includes("metadata surface family scores")
|| text.includes("available metadata object sets")
|| text.includes("selected metadata"));
}
function isMcpTransportFailureLine(value) {
const text = value.toLowerCase();
@ -344,16 +348,16 @@ function headlineFor(mode, pilot) {
return `По движениям${documentOrMovementScopeRu(pilot)} в 1С найдены подтвержденные строки; ответ ограничен проверенным окном и найденными строками.`;
}
if (isCatalogDrilldownPilot(pilot) && mode === "confirmed_with_bounded_inference") {
return "По метаданным 1С удалось углубиться в контур справочников и связанных объектов; это уже не общий обзор схемы, а следующий безопасный catalog drilldown.";
return "По схеме 1С удалось углубиться в контур справочников и связанных объектов; это следующий безопасный шаг по проверенной схеме, а не бизнес-обороты.";
}
if (pilot.derived_metadata_surface && mode === "confirmed_with_bounded_inference") {
if (pilot.derived_metadata_surface.ambiguity_detected) {
return "По метаданным 1С найдены конкурирующие schema-поверхности; перед следующим шагом нужно удержать неоднозначность явно.";
return "По схеме 1С найдены несколько конкурирующих контуров; перед следующим шагом нужно явно выбрать нужный тип данных.";
}
if (pilot.derived_metadata_surface.downstream_route_family) {
return "По метаданным 1С найдена схема и заземлена вероятная поверхность для следующего безопасного шага.";
return "По схеме 1С найдены подходящие объекты; можно безопасно выбрать следующий контур проверки.";
}
return "По метаданным 1С найдена доступная схема для дальнейшего безопасного поиска.";
return "По схеме 1С найдены доступные объекты для дальнейшего безопасного поиска.";
}
if (askedMonthlyBreakdown && pilot.derived_bidirectional_value_flow && mode === "confirmed_with_bounded_inference") {
return "По данным 1С найдены строки входящих и исходящих денежных движений; нетто и помесячная раскладка могут называться только как расчет по найденным строкам и проверенному периоду.";
@ -392,7 +396,7 @@ function headlineFor(mode, pilot) {
return "Точный факт не подтвержден, но есть ограниченная оценка по найденной активности в 1С.";
}
if (mode === "needs_clarification" && isMetadataLaneChoiceClarification(pilot)) {
return "По подтвержденной metadata-поверхности видно несколько конкурирующих data-lane, и без явного выбора дальше идти нельзя.";
return "По проверенной схеме 1С видно несколько возможных контуров, и без явного выбора дальше идти нельзя.";
}
if (mode === "needs_clarification" && isMovementLaneClarification(pilot)) {
const need = clarificationNeedRu(pilot);
@ -468,11 +472,11 @@ function nextStepFor(mode, pilot) {
if (mode === "confirmed_with_bounded_inference" && pilot.derived_metadata_surface) {
const surface = pilot.derived_metadata_surface;
if (surface.ambiguity_detected && surface.ambiguity_entity_sets.length > 0) {
return `Следующим шагом лучше сузить surface до одного семейства: ${surface.ambiguity_entity_sets.join(", ")}.`;
return `Следующим шагом лучше выбрать один контур схемы: ${surface.ambiguity_entity_sets.join(", ")}.`;
}
const routeLabel = metadataRouteFamilyLabelRu(surface.downstream_route_family);
if (surface.selected_entity_set && routeLabel) {
return `Следующим шагом могу пойти в ${routeLabel} по surface «${surface.selected_entity_set}» и уже искать подтвержденные данные, а не только схему.`;
return `Следующим шагом могу пойти в ${routeLabel} по типу «${surface.selected_entity_set}» и уже искать подтвержденные данные, а не только схему.`;
}
}
if (mode === "checked_sources_only" && pilot.query_limitations.length > 0) {
@ -580,19 +584,18 @@ function derivedMetadataConfirmedLine(pilot) {
}
const scope = surface.metadata_scope ? ` по области "${surface.metadata_scope}"` : "";
const entitySets = surface.available_entity_sets.length > 0
? ` Типы объектов: ${surface.available_entity_sets.join(", ")}.`
? ` Тип объектов: ${surface.available_entity_sets.join(", ")}.`
: "";
const objects = surface.matched_objects.length > 0
? ` Найденные объекты: ${surface.matched_objects.slice(0, 8).join(", ")}.`
: "";
const selectedEntitySet = surface.selected_entity_set ? ` Выбранное family: ${surface.selected_entity_set}.` : "";
const selectedObjects = surface.selected_surface_objects.length > 0
? ` Выбранные surface-объекты: ${surface.selected_surface_objects.slice(0, 6).join(", ")}.`
? ` Для следующего шага подходят: ${surface.selected_surface_objects.slice(0, 6).join(", ")}.`
: "";
const fields = surface.available_fields.length > 0
? ` Доступные поля/секции: ${surface.available_fields.slice(0, 12).join(", ")}.`
: "";
return `Подтвержденная metadata-поверхность 1С${scope}: ${surface.matched_rows} строк metadata-ответа.${entitySets}${objects}${selectedEntitySet}${selectedObjects}${fields}`.replace(/\s+/g, " ").trim();
return `В схеме 1С${scope} найдены подтвержденные объекты: ${surface.matched_rows}.${entitySets}${objects}${selectedObjects}${fields}`.replace(/\s+/g, " ").trim();
}
function derivedMetadataInferenceLine(pilot) {
const surface = pilot.derived_metadata_surface;
@ -600,13 +603,13 @@ function derivedMetadataInferenceLine(pilot) {
return null;
}
if (surface.ambiguity_detected && surface.ambiguity_entity_sets.length > 0) {
return `По подтвержденной metadata-поверхности видно несколько конкурирующих family: ${surface.ambiguity_entity_sets.join(", ")}. Следующий data-lane пока нельзя выбрать без явного сужения.`;
return `По проверенной схеме видно несколько возможных контуров: ${surface.ambiguity_entity_sets.join(", ")}. Следующий шаг пока нельзя выбрать без явного сужения.`;
}
const routeLabel = metadataRouteFamilyLabelRu(surface.downstream_route_family);
if (!surface.selected_entity_set || !routeLabel) {
return null;
}
return `По подтвержденной metadata-поверхности следующий проверяемый шаг можно ограниченно оценить как ${routeLabel} через family «${surface.selected_entity_set}». Это еще не выполненный data-fetch, а только grounded выбор следующего контура.`;
return `Следующий проверяемый шаг можно вести в ${routeLabel} через тип «${surface.selected_entity_set}». Это пока выбор контура по схеме 1С, а не уже полученные бизнес-строки.`;
}
function derivedEntityResolutionConfirmedLine(pilot) {
const resolution = pilot.derived_entity_resolution;
@ -651,6 +654,15 @@ function derivedRankedValueFlowConfirmedLine(pilot) {
const leader = ranking.ranked_values[0];
const organization = ranking.organization_scope ? ` по организации ${ranking.organization_scope}` : "";
const period = ranking.period_scope ? ` за период ${ranking.period_scope}` : " в проверенном окне";
if (ranking.ranked_values.length === 1) {
const singleLead = ranking.value_flow_direction === "outgoing_supplier_payout"
? "В проверенных исходящих платежах найден один контрагент"
: "В проверенных входящих поступлениях найден один контрагент";
const limitCaveat = ranking.coverage_limited_by_probe_limit
? " Лимит строк проверки достигнут; сравнение с другими контрагентами может быть неполным."
: " Других контрагентов в этом проверенном срезе не найдено, поэтому это не полноценный сравнительный рейтинг.";
return `${singleLead} ${leader.axis_value}${organization}${period}: ${leader.total_amount_human_ru} по ${leader.rows_with_amount} строкам с суммой.${limitCaveat}`;
}
const directionLead = ranking.ranking_need === "bottom_asc"
? ranking.value_flow_direction === "outgoing_supplier_payout"
? "Меньше всего заплатили контрагенту"
@ -783,8 +795,13 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) {
: derivedEntityResolutionLine
? [...pilot.evidence.confirmed_facts, derivedEntityResolutionLine]
: derivedMetadataLine
? [...pilot.evidence.confirmed_facts, derivedMetadataLine]
? [derivedMetadataLine]
: pilot.evidence.confirmed_facts;
const unknownLines = pilot.derived_metadata_surface
? pilot.derived_metadata_surface.available_fields.length > 0
? userFacingUnknowns(pilot.evidence.unknown_facts)
: ["Детальный список полей этих объектов этим шагом не получен."]
: rankedValueFlowUnknownLines(pilot);
return {
schema_version: exports.ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryAnswerAdapter",
@ -792,7 +809,7 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) {
headline: headlineFor(mode, pilot),
confirmed_lines: uniqueStrings(confirmedLines),
inference_lines: uniqueStrings(inferenceLines),
unknown_lines: rankedValueFlowUnknownLines(pilot),
unknown_lines: unknownLines,
limitation_lines: userFacingLimitations([...pilot.query_limitations, ...pilot.evidence.query_limitations]),
next_step_line: nextStepFor(mode, pilot),
internal_mechanics_allowed: false,

View File

@ -88,31 +88,55 @@ function buildDiscoveryRecapFactLine(input) {
const objectsPart = objects.length > 0 ? `, нашли объекты ${objects.slice(0, 4).join(", ")}` : "";
const entitySetsPart = entitySets.length > 0 ? `, видны типы ${entitySets.slice(0, 4).join(", ")}` : "";
const fieldsPart = fields.length > 0 ? `, доступны поля/секции ${fields.slice(0, 5).join(", ")}` : "";
return `смотрели metadata-поверхность 1С${scopePart}${periodPart}: ${rows} подтвержденных строк${objectsPart}${entitySetsPart}${fieldsPart}`.trim();
return `смотрели схему 1С${scopePart}${periodPart}: ${rows} подтвержденных строк${objectsPart}${entitySetsPart}${fieldsPart}`.trim();
}
if (!input.counterparty) {
const rankedFlow = toRecordObject(pilot?.derived_ranked_value_flow);
if (rankedFlow) {
const rankedValues = Array.isArray(rankedFlow.ranked_values) ? rankedFlow.ranked_values : [];
const leader = toRecordObject(rankedValues[0]);
const leaderName = toNonEmptyString(leader?.axis_value);
const leaderAmount = toNonEmptyString(leader?.total_amount_human_ru);
const leaderRows = toNonEmptyString(leader?.rows_with_amount);
const organization = toNonEmptyString(rankedFlow.organization_scope) ?? input.organization;
const period = toNonEmptyString(rankedFlow.period_scope) ?? input.scopedDate;
const organizationPart = organization ? ` по компании «${organization}»` : "";
const periodPartForRanking = period ? ` за период ${period}` : periodPart;
if (leaderName && leaderAmount) {
const rowsPart = leaderRows ? ` по ${leaderRows} строкам` : "";
const rankingKind = rankedValues.length > 1 ? "строили рейтинг клиентов" : "видели единственного клиента в проверенном срезе";
return `${rankingKind}${organizationPart}${periodPartForRanking}: ${leaderName}${leaderAmount}${rowsPart}`.trim();
}
}
const subjectPart = input.counterparty
? `контрагенту «${input.counterparty}»`
: input.organization
? `компании «${input.organization}»`
: null;
if (!subjectPart) {
return null;
}
if (pilotScope === "counterparty_lifecycle_query_documents_v1") {
const activityPeriod = toRecordObject(pilot?.derived_activity_period);
const duration = toNonEmptyString(activityPeriod?.duration_human_ru);
return duration
? `смотрели подтвержденную активность по контрагенту «${input.counterparty}»${periodPart} и оценили период взаимодействия примерно как ${duration}`
: `смотрели подтвержденную активность по контрагенту «${input.counterparty}»${periodPart}`;
? `смотрели подтвержденную активность по ${subjectPart}${periodPart} и оценили период взаимодействия примерно как ${duration}`
: `смотрели подтвержденную активность по ${subjectPart}${periodPart}`;
}
if (pilotScope === "counterparty_supplier_payout_query_movements_v1") {
const flow = toRecordObject(pilot?.derived_value_flow);
const amount = toNonEmptyString(flow?.total_amount_human_ru);
const flowPeriodPart = periodPartForRecap(toNonEmptyString(flow?.period_scope) ?? input.scopedDate);
return amount
? `считали исходящие платежи/списания по контрагенту «${input.counterparty}»${periodPart}: ${amount}`
: `считали исходящие платежи/списания по контрагенту «${input.counterparty}»${periodPart}`;
? `считали исходящие платежи/списания по ${subjectPart}${flowPeriodPart}: ${amount}`
: `считали исходящие платежи/списания по ${subjectPart}${flowPeriodPart}`;
}
if (pilotScope === "counterparty_value_flow_query_movements_v1") {
const flow = toRecordObject(pilot?.derived_value_flow);
const amount = toNonEmptyString(flow?.total_amount_human_ru);
const flowPeriodPart = periodPartForRecap(toNonEmptyString(flow?.period_scope) ?? input.scopedDate);
return amount
? `смотрели денежный поток по контрагенту «${input.counterparty}»${periodPart}: ${amount}`
: `смотрели денежный поток по контрагенту «${input.counterparty}»${periodPart}`;
? `смотрели денежный поток по ${subjectPart}${flowPeriodPart}: ${amount}`
: `смотрели денежный поток по ${subjectPart}${flowPeriodPart}`;
}
if (pilotScope === "counterparty_bidirectional_value_flow_query_movements_v1") {
const flow = toRecordObject(pilot?.derived_bidirectional_value_flow);
@ -121,10 +145,11 @@ function buildDiscoveryRecapFactLine(input) {
const incomingAmount = toNonEmptyString(incoming?.total_amount_human_ru);
const outgoingAmount = toNonEmptyString(outgoing?.total_amount_human_ru);
const netAmount = toNonEmptyString(flow?.net_amount_human_ru);
const flowPeriodPart = periodPartForRecap(toNonEmptyString(flow?.period_scope) ?? input.scopedDate);
if (incomingAmount && outgoingAmount && netAmount) {
return `считали нетто по деньгам с контрагентом «${input.counterparty}»${periodPart}: получили ${incomingAmount}, заплатили ${outgoingAmount}, расчетное нетто ${netAmount}`;
return `считали нетто по деньгам по ${subjectPart}${flowPeriodPart}: получили ${incomingAmount}, заплатили ${outgoingAmount}, расчетное нетто ${netAmount}`;
}
return `считали нетто по деньгам с контрагентом «${input.counterparty}»${periodPart}`;
return `считали нетто по деньгам по ${subjectPart}${flowPeriodPart}`;
}
return null;
}
@ -177,6 +202,7 @@ function buildRecapFactLine(input) {
const discoveryFact = buildDiscoveryRecapFactLine({
debug: input.debug,
counterparty: input.counterparty,
organization: input.organization,
scopedDate
});
if (discoveryFact) {
@ -259,7 +285,7 @@ function collectRecentRecapFacts(input) {
}
seen.add(fact);
facts.push(fact);
if (facts.length >= 3) {
if (facts.length >= (input.limit ?? 3)) {
break;
}
}
@ -309,6 +335,13 @@ function buildAddressMemoryRecapReply(input) {
"Могу продолжить по нему без переписывания контекста: поступления, платежи, нетто, документы или пояснение границ ответа."
].join(" ");
}
if (recapFacts.length > 0) {
return [
"Да, помню. В предыдущем проверенном контуре мы уже выяснили:",
...recapFacts.map((fact) => `- ${fact}.`),
"Могу продолжить от этого места: углубиться в данные, документы, движения или границы подтверждения."
].join("\n");
}
if (organization || scopedDate) {
const organizationPart = organization ? ` по компании «${organization}»` : "";
const datePart = scopedDate ? ` на ${scopedDate}` : "";
@ -326,16 +359,32 @@ function buildBroadBusinessEvaluationReply(input) {
sessionItems: input.sessionItems,
item: null,
organization,
toNonEmptyString: input.toNonEmptyString
toNonEmptyString: input.toNonEmptyString,
limit: 5
});
const organizationPart = organization ? ` по компании «${organization}»` : "";
if (recapFacts.length > 0) {
const moneyFactCount = recapFacts.filter((fact) => /(?:денежн|нетто|поступлен|платеж|рейтинг|клиент|выруч|оборот|заплатили|получили)/iu.test(fact)).length;
const hasRankingFact = recapFacts.some((fact) => /(?:рейтинг|клиент|единственного клиента)/iu.test(fact));
const hasNetFact = recapFacts.some((fact) => /нетто/iu.test(fact));
const auditLines = [
moneyFactCount > 0
? "- Денежный контур уже выглядит операционно значимым: есть подтвержденные поступления, платежи или клиентские срезы."
: "- Операционная активность подтверждена, но денежный контур пока раскрыт слабо.",
hasRankingFact
? "- По клиентской базе уже есть точечные лидеры, но это еще не полноценная управленческая сегментация всей базы."
: "- Ключевых клиентов и концентрацию выручки стоит добрать отдельным рейтингом.",
hasNetFact
? "- По нетто можно обсуждать направление денежного потока, но прибыль и маржу этим не доказываем."
: "- Прибыль, маржа и качество операционки пока не доказаны: нужны расходы, себестоимость и задолженность."
];
return [
`Коротко: по тому, что мы уже подтвердили в 1С${organizationPart}, компания выглядит операционно живой, но это пока только частичная оценка бизнеса.`,
"Сейчас я опираюсь на такие подтвержденные факты:",
`Коротко: по уже подтвержденным срезам 1С${organizationPart} компания выглядит операционно живой; это предварительная оценка бизнеса, а для взрослого вывода еще нужны прибыль, маржа и долги.`,
"Что уже видно:",
...recapFacts.map((fact) => `- ${ensureSentence(fact)}`),
"Это еще не полная диагностика всего бизнеса и не вывод о прибыли: я честно суммирую только те контуры, которые мы уже проверили в диалоге.",
"Если хочешь, следующим шагом могу сузить оценку до денежного потока, долгов, НДС или ключевых контрагентов."
"Предварительный LLM-аудит:",
...auditLines,
"Что добрать для полной оценки: обороты по годам, топ клиентов, входящие/исходящие деньги, дебиторку/кредиторку, НДС и признаки маржинальности."
].join("\n");
}
return [

View File

@ -81,6 +81,10 @@ function isInternalMechanicsLine(value: string): boolean {
text.includes("scope is not implemented yet") ||
text.includes("needs more scope before execution") ||
text.includes("mcp_execution_performed")
|| text.includes("confirmed 1c metadata surface")
|| text.includes("metadata surface family scores")
|| text.includes("available metadata object sets")
|| text.includes("selected metadata")
);
}
@ -444,16 +448,16 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD
return `По движениям${documentOrMovementScopeRu(pilot)} в 1С найдены подтвержденные строки; ответ ограничен проверенным окном и найденными строками.`;
}
if (isCatalogDrilldownPilot(pilot) && mode === "confirmed_with_bounded_inference") {
return "По метаданным 1С удалось углубиться в контур справочников и связанных объектов; это уже не общий обзор схемы, а следующий безопасный catalog drilldown.";
return "По схеме 1С удалось углубиться в контур справочников и связанных объектов; это следующий безопасный шаг по проверенной схеме, а не бизнес-обороты.";
}
if (pilot.derived_metadata_surface && mode === "confirmed_with_bounded_inference") {
if (pilot.derived_metadata_surface.ambiguity_detected) {
return "По метаданным 1С найдены конкурирующие schema-поверхности; перед следующим шагом нужно удержать неоднозначность явно.";
return "По схеме 1С найдены несколько конкурирующих контуров; перед следующим шагом нужно явно выбрать нужный тип данных.";
}
if (pilot.derived_metadata_surface.downstream_route_family) {
return "По метаданным 1С найдена схема и заземлена вероятная поверхность для следующего безопасного шага.";
return "По схеме 1С найдены подходящие объекты; можно безопасно выбрать следующий контур проверки.";
}
return "По метаданным 1С найдена доступная схема для дальнейшего безопасного поиска.";
return "По схеме 1С найдены доступные объекты для дальнейшего безопасного поиска.";
}
if (askedMonthlyBreakdown && pilot.derived_bidirectional_value_flow && mode === "confirmed_with_bounded_inference") {
return "По данным 1С найдены строки входящих и исходящих денежных движений; нетто и помесячная раскладка могут называться только как расчет по найденным строкам и проверенному периоду.";
@ -492,7 +496,7 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD
return "Точный факт не подтвержден, но есть ограниченная оценка по найденной активности в 1С.";
}
if (mode === "needs_clarification" && isMetadataLaneChoiceClarification(pilot)) {
return "По подтвержденной metadata-поверхности видно несколько конкурирующих data-lane, и без явного выбора дальше идти нельзя.";
return "По проверенной схеме 1С видно несколько возможных контуров, и без явного выбора дальше идти нельзя.";
}
if (mode === "needs_clarification" && isMovementLaneClarification(pilot)) {
const need = clarificationNeedRu(pilot);
@ -573,11 +577,11 @@ function nextStepFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD
if (mode === "confirmed_with_bounded_inference" && pilot.derived_metadata_surface) {
const surface = pilot.derived_metadata_surface;
if (surface.ambiguity_detected && surface.ambiguity_entity_sets.length > 0) {
return `Следующим шагом лучше сузить surface до одного семейства: ${surface.ambiguity_entity_sets.join(", ")}.`;
return `Следующим шагом лучше выбрать один контур схемы: ${surface.ambiguity_entity_sets.join(", ")}.`;
}
const routeLabel = metadataRouteFamilyLabelRu(surface.downstream_route_family);
if (surface.selected_entity_set && routeLabel) {
return `Следующим шагом могу пойти в ${routeLabel} по surface «${surface.selected_entity_set}» и уже искать подтвержденные данные, а не только схему.`;
return `Следующим шагом могу пойти в ${routeLabel} по типу «${surface.selected_entity_set}» и уже искать подтвержденные данные, а не только схему.`;
}
}
if (mode === "checked_sources_only" && pilot.query_limitations.length > 0) {
@ -692,22 +696,21 @@ function derivedMetadataConfirmedLine(pilot: AssistantMcpDiscoveryPilotExecution
const scope = surface.metadata_scope ? ` по области "${surface.metadata_scope}"` : "";
const entitySets =
surface.available_entity_sets.length > 0
? ` Типы объектов: ${surface.available_entity_sets.join(", ")}.`
? ` Тип объектов: ${surface.available_entity_sets.join(", ")}.`
: "";
const objects =
surface.matched_objects.length > 0
? ` Найденные объекты: ${surface.matched_objects.slice(0, 8).join(", ")}.`
: "";
const selectedEntitySet = surface.selected_entity_set ? ` Выбранное family: ${surface.selected_entity_set}.` : "";
const selectedObjects =
surface.selected_surface_objects.length > 0
? ` Выбранные surface-объекты: ${surface.selected_surface_objects.slice(0, 6).join(", ")}.`
? ` Для следующего шага подходят: ${surface.selected_surface_objects.slice(0, 6).join(", ")}.`
: "";
const fields =
surface.available_fields.length > 0
? ` Доступные поля/секции: ${surface.available_fields.slice(0, 12).join(", ")}.`
: "";
return `Подтвержденная metadata-поверхность 1С${scope}: ${surface.matched_rows} строк metadata-ответа.${entitySets}${objects}${selectedEntitySet}${selectedObjects}${fields}`.replace(/\s+/g, " ").trim();
return `В схеме 1С${scope} найдены подтвержденные объекты: ${surface.matched_rows}.${entitySets}${objects}${selectedObjects}${fields}`.replace(/\s+/g, " ").trim();
}
function derivedMetadataInferenceLine(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null {
@ -716,13 +719,13 @@ function derivedMetadataInferenceLine(pilot: AssistantMcpDiscoveryPilotExecution
return null;
}
if (surface.ambiguity_detected && surface.ambiguity_entity_sets.length > 0) {
return `По подтвержденной metadata-поверхности видно несколько конкурирующих family: ${surface.ambiguity_entity_sets.join(", ")}. Следующий data-lane пока нельзя выбрать без явного сужения.`;
return `По проверенной схеме видно несколько возможных контуров: ${surface.ambiguity_entity_sets.join(", ")}. Следующий шаг пока нельзя выбрать без явного сужения.`;
}
const routeLabel = metadataRouteFamilyLabelRu(surface.downstream_route_family);
if (!surface.selected_entity_set || !routeLabel) {
return null;
}
return `По подтвержденной metadata-поверхности следующий проверяемый шаг можно ограниченно оценить как ${routeLabel} через family «${surface.selected_entity_set}». Это еще не выполненный data-fetch, а только grounded выбор следующего контура.`;
return `Следующий проверяемый шаг можно вести в ${routeLabel} через тип «${surface.selected_entity_set}». Это пока выбор контура по схеме 1С, а не уже полученные бизнес-строки.`;
}
function derivedEntityResolutionConfirmedLine(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null {
@ -774,6 +777,16 @@ function derivedRankedValueFlowConfirmedLine(pilot: AssistantMcpDiscoveryPilotEx
const leader = ranking.ranked_values[0];
const organization = ranking.organization_scope ? ` по организации ${ranking.organization_scope}` : "";
const period = ranking.period_scope ? ` за период ${ranking.period_scope}` : " в проверенном окне";
if (ranking.ranked_values.length === 1) {
const singleLead =
ranking.value_flow_direction === "outgoing_supplier_payout"
? "В проверенных исходящих платежах найден один контрагент"
: "В проверенных входящих поступлениях найден один контрагент";
const limitCaveat = ranking.coverage_limited_by_probe_limit
? " Лимит строк проверки достигнут; сравнение с другими контрагентами может быть неполным."
: " Других контрагентов в этом проверенном срезе не найдено, поэтому это не полноценный сравнительный рейтинг.";
return `${singleLead} ${leader.axis_value}${organization}${period}: ${leader.total_amount_human_ru} по ${leader.rows_with_amount} строкам с суммой.${limitCaveat}`;
}
const directionLead =
ranking.ranking_need === "bottom_asc"
? ranking.value_flow_direction === "outgoing_supplier_payout"
@ -926,8 +939,13 @@ export function buildAssistantMcpDiscoveryAnswerDraft(
: derivedEntityResolutionLine
? [...pilot.evidence.confirmed_facts, derivedEntityResolutionLine]
: derivedMetadataLine
? [...pilot.evidence.confirmed_facts, derivedMetadataLine]
? [derivedMetadataLine]
: pilot.evidence.confirmed_facts;
const unknownLines = pilot.derived_metadata_surface
? pilot.derived_metadata_surface.available_fields.length > 0
? userFacingUnknowns(pilot.evidence.unknown_facts)
: ["Детальный список полей этих объектов этим шагом не получен."]
: rankedValueFlowUnknownLines(pilot);
return {
schema_version: ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION,
@ -936,7 +954,7 @@ export function buildAssistantMcpDiscoveryAnswerDraft(
headline: headlineFor(mode, pilot),
confirmed_lines: uniqueStrings(confirmedLines),
inference_lines: uniqueStrings(inferenceLines),
unknown_lines: rankedValueFlowUnknownLines(pilot),
unknown_lines: unknownLines,
limitation_lines: userFacingLimitations([...pilot.query_limitations, ...pilot.evidence.query_limitations]),
next_step_line: nextStepFor(mode, pilot),
internal_mechanics_allowed: false,

View File

@ -103,6 +103,7 @@ function readDiscoveryMetadataScope(debug: Record<string, unknown> | null): stri
function buildDiscoveryRecapFactLine(input: {
debug: Record<string, unknown> | null;
counterparty: string | null;
organization: string | null;
scopedDate: string | null;
}): string | null {
if (!input.debug) {
@ -138,31 +139,55 @@ function buildDiscoveryRecapFactLine(input: {
entitySets.length > 0 ? `, видны типы ${entitySets.slice(0, 4).join(", ")}` : "";
const fieldsPart =
fields.length > 0 ? `, доступны поля/секции ${fields.slice(0, 5).join(", ")}` : "";
return `смотрели metadata-поверхность 1С${scopePart}${periodPart}: ${rows} подтвержденных строк${objectsPart}${entitySetsPart}${fieldsPart}`.trim();
return `смотрели схему 1С${scopePart}${periodPart}: ${rows} подтвержденных строк${objectsPart}${entitySetsPart}${fieldsPart}`.trim();
}
if (!input.counterparty) {
const rankedFlow = toRecordObject(pilot?.derived_ranked_value_flow);
if (rankedFlow) {
const rankedValues = Array.isArray(rankedFlow.ranked_values) ? rankedFlow.ranked_values : [];
const leader = toRecordObject(rankedValues[0]);
const leaderName = toNonEmptyString(leader?.axis_value);
const leaderAmount = toNonEmptyString(leader?.total_amount_human_ru);
const leaderRows = toNonEmptyString(leader?.rows_with_amount);
const organization = toNonEmptyString(rankedFlow.organization_scope) ?? input.organization;
const period = toNonEmptyString(rankedFlow.period_scope) ?? input.scopedDate;
const organizationPart = organization ? ` по компании «${organization}»` : "";
const periodPartForRanking = period ? ` за период ${period}` : periodPart;
if (leaderName && leaderAmount) {
const rowsPart = leaderRows ? ` по ${leaderRows} строкам` : "";
const rankingKind = rankedValues.length > 1 ? "строили рейтинг клиентов" : "видели единственного клиента в проверенном срезе";
return `${rankingKind}${organizationPart}${periodPartForRanking}: ${leaderName}${leaderAmount}${rowsPart}`.trim();
}
}
const subjectPart = input.counterparty
? `контрагенту «${input.counterparty}»`
: input.organization
? `компании «${input.organization}»`
: null;
if (!subjectPart) {
return null;
}
if (pilotScope === "counterparty_lifecycle_query_documents_v1") {
const activityPeriod = toRecordObject(pilot?.derived_activity_period);
const duration = toNonEmptyString(activityPeriod?.duration_human_ru);
return duration
? `смотрели подтвержденную активность по контрагенту «${input.counterparty}»${periodPart} и оценили период взаимодействия примерно как ${duration}`
: `смотрели подтвержденную активность по контрагенту «${input.counterparty}»${periodPart}`;
? `смотрели подтвержденную активность по ${subjectPart}${periodPart} и оценили период взаимодействия примерно как ${duration}`
: `смотрели подтвержденную активность по ${subjectPart}${periodPart}`;
}
if (pilotScope === "counterparty_supplier_payout_query_movements_v1") {
const flow = toRecordObject(pilot?.derived_value_flow);
const amount = toNonEmptyString(flow?.total_amount_human_ru);
const flowPeriodPart = periodPartForRecap(toNonEmptyString(flow?.period_scope) ?? input.scopedDate);
return amount
? `считали исходящие платежи/списания по контрагенту «${input.counterparty}»${periodPart}: ${amount}`
: `считали исходящие платежи/списания по контрагенту «${input.counterparty}»${periodPart}`;
? `считали исходящие платежи/списания по ${subjectPart}${flowPeriodPart}: ${amount}`
: `считали исходящие платежи/списания по ${subjectPart}${flowPeriodPart}`;
}
if (pilotScope === "counterparty_value_flow_query_movements_v1") {
const flow = toRecordObject(pilot?.derived_value_flow);
const amount = toNonEmptyString(flow?.total_amount_human_ru);
const flowPeriodPart = periodPartForRecap(toNonEmptyString(flow?.period_scope) ?? input.scopedDate);
return amount
? `смотрели денежный поток по контрагенту «${input.counterparty}»${periodPart}: ${amount}`
: `смотрели денежный поток по контрагенту «${input.counterparty}»${periodPart}`;
? `смотрели денежный поток по ${subjectPart}${flowPeriodPart}: ${amount}`
: `смотрели денежный поток по ${subjectPart}${flowPeriodPart}`;
}
if (pilotScope === "counterparty_bidirectional_value_flow_query_movements_v1") {
const flow = toRecordObject(pilot?.derived_bidirectional_value_flow);
@ -171,10 +196,11 @@ function buildDiscoveryRecapFactLine(input: {
const incomingAmount = toNonEmptyString(incoming?.total_amount_human_ru);
const outgoingAmount = toNonEmptyString(outgoing?.total_amount_human_ru);
const netAmount = toNonEmptyString(flow?.net_amount_human_ru);
const flowPeriodPart = periodPartForRecap(toNonEmptyString(flow?.period_scope) ?? input.scopedDate);
if (incomingAmount && outgoingAmount && netAmount) {
return `считали нетто по деньгам с контрагентом «${input.counterparty}»${periodPart}: получили ${incomingAmount}, заплатили ${outgoingAmount}, расчетное нетто ${netAmount}`;
return `считали нетто по деньгам по ${subjectPart}${flowPeriodPart}: получили ${incomingAmount}, заплатили ${outgoingAmount}, расчетное нетто ${netAmount}`;
}
return `считали нетто по деньгам с контрагентом «${input.counterparty}»${periodPart}`;
return `считали нетто по деньгам по ${subjectPart}${flowPeriodPart}`;
}
return null;
}
@ -253,6 +279,7 @@ function buildRecapFactLine(input: {
const discoveryFact = buildDiscoveryRecapFactLine({
debug: input.debug,
counterparty: input.counterparty,
organization: input.organization,
scopedDate
});
if (discoveryFact) {
@ -300,6 +327,7 @@ function collectRecentRecapFacts(input: {
item: string | null;
organization: string | null;
toNonEmptyString: (value: unknown) => string | null;
limit?: number;
}): string[] {
const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : [];
if (sessionItems.length === 0) {
@ -342,7 +370,7 @@ function collectRecentRecapFacts(input: {
}
seen.add(fact);
facts.push(fact);
if (facts.length >= 3) {
if (facts.length >= (input.limit ?? 3)) {
break;
}
}
@ -402,6 +430,14 @@ export function buildAddressMemoryRecapReply(input: {
].join(" ");
}
if (recapFacts.length > 0) {
return [
"Да, помню. В предыдущем проверенном контуре мы уже выяснили:",
...recapFacts.map((fact) => `- ${fact}.`),
"Могу продолжить от этого места: углубиться в данные, документы, движения или границы подтверждения."
].join("\n");
}
if (organization || scopedDate) {
const organizationPart = organization ? ` по компании «${organization}»` : "";
const datePart = scopedDate ? ` на ${scopedDate}` : "";
@ -426,17 +462,35 @@ export function buildBroadBusinessEvaluationReply(input: {
sessionItems: input.sessionItems,
item: null,
organization,
toNonEmptyString: input.toNonEmptyString
toNonEmptyString: input.toNonEmptyString,
limit: 5
});
const organizationPart = organization ? ` по компании «${organization}»` : "";
if (recapFacts.length > 0) {
const moneyFactCount = recapFacts.filter((fact) =>
/(?:денежн|нетто|поступлен|платеж|рейтинг|клиент|выруч|оборот|заплатили|получили)/iu.test(fact)
).length;
const hasRankingFact = recapFacts.some((fact) => /(?:рейтинг|клиент|единственного клиента)/iu.test(fact));
const hasNetFact = recapFacts.some((fact) => /нетто/iu.test(fact));
const auditLines = [
moneyFactCount > 0
? "- Денежный контур уже выглядит операционно значимым: есть подтвержденные поступления, платежи или клиентские срезы."
: "- Операционная активность подтверждена, но денежный контур пока раскрыт слабо.",
hasRankingFact
? "- По клиентской базе уже есть точечные лидеры, но это еще не полноценная управленческая сегментация всей базы."
: "- Ключевых клиентов и концентрацию выручки стоит добрать отдельным рейтингом.",
hasNetFact
? "- По нетто можно обсуждать направление денежного потока, но прибыль и маржу этим не доказываем."
: "- Прибыль, маржа и качество операционки пока не доказаны: нужны расходы, себестоимость и задолженность."
];
return [
`Коротко: по тому, что мы уже подтвердили в 1С${organizationPart}, компания выглядит операционно живой, но это пока только частичная оценка бизнеса.`,
"Сейчас я опираюсь на такие подтвержденные факты:",
`Коротко: по уже подтвержденным срезам 1С${organizationPart} компания выглядит операционно живой; это предварительная оценка бизнеса, а для взрослого вывода еще нужны прибыль, маржа и долги.`,
"Что уже видно:",
...recapFacts.map((fact) => `- ${ensureSentence(fact)}`),
"Это еще не полная диагностика всего бизнеса и не вывод о прибыли: я честно суммирую только те контуры, которые мы уже проверили в диалоге.",
"Если хочешь, следующим шагом могу сузить оценку до денежного потока, долгов, НДС или ключевых контрагентов."
"Предварительный LLM-аудит:",
...auditLines,
"Что добрать для полной оценки: обороты по годам, топ клиентов, входящие/исходящие деньги, дебиторку/кредиторку, НДС и признаки маржинальности."
].join("\n");
}

View File

@ -407,6 +407,53 @@ describe("assistant MCP discovery answer adapter", () => {
expect(draft.unknown_lines[0]).toContain("\u041f\u043e\u043b\u043d\u044b\u0439 \u0440\u0435\u0439\u0442\u0438\u043d\u0433");
});
it("does not overclaim a comparative ranking when only one counterparty is present", async () => {
const planner = planAssistantMcpDiscovery({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
business_fact_family: "value_flow",
action_family: "turnover",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: null,
ranking_need: "top_desc",
proof_expectation: "coverage_checked_fact",
clarification_gaps: [],
decomposition_candidates: ["collect_scoped_movements", "aggregate_ranked_axis_values", "probe_coverage"],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
reason_codes: ["data_need_graph_built", "data_need_graph_ranking_top_desc"]
},
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "turnover",
explicit_organization_scope: "ООО Альтернатива Плюс",
explicit_date_scope: "2021",
seeded_ranking_need: "top_desc"
}
});
const pilot = await executeAssistantMcpDiscoveryPilot(
planner,
buildDeps([
{
Period: "2021-01-15T00:00:00",
Amount: 8560025,
Counterparty: "Группа СВК",
Organization: "ООО Альтернатива Плюс"
}
])
);
const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot);
const text = draft.confirmed_lines.join("\n");
expect(text).toContain("найден один контрагент");
expect(text).toContain("Группа СВК");
expect(text).toContain("не полноценный сравнительный рейтинг");
expect(text).not.toContain("Больше всего денег принёс");
});
it("asks for both organization and period when an open total still misses both axes", async () => {
const planner = planAssistantMcpDiscovery({
dataNeedGraph: {
@ -501,7 +548,7 @@ describe("assistant MCP discovery answer adapter", () => {
const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot);
expect(draft.answer_mode).toBe("needs_clarification");
expect(draft.headline).toContain("data-lane");
expect(draft.headline).toContain("контуров");
expect(draft.next_step_line).toContain("по документам");
expect(draft.next_step_line).toContain("по движениям/регистрам");
expect(draft.must_not_claim).toContain("Do not claim rows were checked when mcp_execution_performed=false.");
@ -537,7 +584,7 @@ describe("assistant MCP discovery answer adapter", () => {
const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot);
expect(draft.answer_mode).toBe("needs_clarification");
expect(draft.headline).toContain("data-lane");
expect(draft.headline).toContain("контуров");
expect(draft.next_step_line).toContain("по документам");
expect(draft.next_step_line).toContain("по движениям/регистрам");
expect(draft.next_step_line).not.toContain("Уточните контрагента");
@ -671,13 +718,15 @@ describe("assistant MCP discovery answer adapter", () => {
const confirmedText = draft.confirmed_lines.join("\n");
expect(draft.answer_mode).toBe("confirmed_with_bounded_inference");
expect(draft.headline).toContain("заземлена вероятная поверхность");
expect(confirmedText).toContain("Подтвержденная metadata-поверхность 1С");
expect(draft.headline).toContain("схеме 1С");
expect(confirmedText).toContain("В схеме 1С");
expect(confirmedText).toContain("Документ.СчетФактураВыданный");
expect(confirmedText).toContain("Выбранное family: Документ");
expect(confirmedText).toContain("Для следующего шага подходят");
expect(confirmedText).toContain("Дата");
expect(draft.inference_lines.join("\n")).toContain("контур документов");
expect(draft.next_step_line).toContain("surface «Документ»");
expect(draft.next_step_line).toContain("типу «Документ»");
expect(confirmedText).not.toContain("Confirmed 1C metadata surface");
expect(confirmedText).not.toContain("Metadata surface family scores");
expect(draft.must_not_claim).toContain("Do not present metadata surface as confirmed business data rows.");
expect(draft.must_not_claim).toContain("Do not present the inferred next checked lane as already executed data retrieval.");
});
@ -715,7 +764,42 @@ describe("assistant MCP discovery answer adapter", () => {
expect(draft.answer_mode).toBe("confirmed_with_bounded_inference");
expect(draft.headline).toContain("углубиться");
expect(draft.headline).toContain("справочников");
expect(draft.headline).toContain("catalog drilldown");
expect(draft.headline).not.toContain("catalog drilldown");
});
it("renders catalog metadata without leaking internal surface scoring", async () => {
const planner = planAssistantMcpDiscovery({
turnMeaning: {
asked_domain_family: "metadata",
asked_action_family: "inspect_catalog",
explicit_entity_candidates: ["контрагент"],
unsupported_but_understood_family: "1c_metadata_surface"
}
});
const pilot = await executeAssistantMcpDiscoveryPilot(
planner,
buildMetadataDeps([
{ FullName: "Справочник.ДоговорыКонтрагентов", MetaType: "Справочник" },
{ FullName: "Справочник.Контрагенты", MetaType: "Справочник" }
])
);
const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot);
const userText = [
draft.headline,
...draft.confirmed_lines,
...draft.inference_lines,
...draft.unknown_lines,
draft.next_step_line ?? ""
].join("\n");
expect(userText).toContain("Справочник.ДоговорыКонтрагентов");
expect(userText).toContain("Справочник.Контрагенты");
expect(userText).toContain("Детальный список полей");
expect(userText).not.toContain("Confirmed 1C metadata surface");
expect(userText).not.toContain("Metadata surface family scores");
expect(userText).not.toContain("surface «");
expect(userText).not.toContain("catalog drilldown");
});
it("keeps metadata answer honest when schema surface stays ambiguous", async () => {
@ -744,8 +828,8 @@ describe("assistant MCP discovery answer adapter", () => {
const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot);
expect(draft.headline).toContain("конкурирующие schema-поверхности");
expect(draft.inference_lines.join("\n")).toContain("несколько конкурирующих family");
expect(draft.headline).toContain("конкурирующих контуров");
expect(draft.inference_lines.join("\n")).toContain("несколько возможных контуров");
expect(draft.unknown_lines).toContain(
"Exact downstream metadata surface remains ambiguous across: Документ, РегистрНакопления"
);

View File

@ -439,7 +439,7 @@ describe("assistantMemoryRecapPolicy", () => {
expect(context.contextualMemoryRecapFollowup).toBe(true);
expect(reply).toContain("НДС");
expect(reply).toContain("metadata-поверхность 1С");
expect(reply).toContain("схему 1С");
expect(reply).toContain("amount");
expect(reply).toContain("accumulation_register");
});