diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js index 15d54d3..efe50c3 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js @@ -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, diff --git a/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js b/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js index 23ad8c2..b0f21f1 100644 --- a/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js @@ -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 [ diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts index 4d7e560..40db490 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts @@ -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, diff --git a/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts b/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts index 4b99efa..2f6cd3f 100644 --- a/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts @@ -103,6 +103,7 @@ function readDiscoveryMetadataScope(debug: Record | null): stri function buildDiscoveryRecapFactLine(input: { debug: Record | 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"); } diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts index c63e4d3..94f20fc 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts @@ -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: Документ, РегистрНакопления" ); diff --git a/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts b/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts index f9814fb..676b36a 100644 --- a/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts @@ -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"); });