From 284b2019122eb0e8ebcba378aa81534db347a332 Mon Sep 17 00:00:00 2001 From: dctouch Date: Sat, 2 May 2026 00:09:28 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=BF=D1=83=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20Open-World=20Breadth=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=B1=D0=B8=D0=B7=D0=BD=D0=B5=D1=81-=D0=BE=D0=B1?= =?UTF-8?q?=D0=B7=D0=BE=D1=80=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../21 - current_status_canon_2026-05-01.md | 12 +- ...rld_bounded_autonomy_breadth_2026-05-01.md | 101 +++++++++ .../11 - architecture_turnaround/README.md | 9 +- .../services/assistantMemoryRecapPolicy.js | 149 ++++++++++++- .../services/assistantTurnMeaningPolicy.js | 2 +- .../services/assistantMemoryRecapPolicy.ts | 200 +++++++++++++++++- .../services/assistantTurnMeaningPolicy.ts | 2 +- .../assistantLivingChatRuntimeAdapter.test.ts | 3 + .../tests/assistantTurnMeaningPolicy.test.ts | 16 ++ 9 files changed, 477 insertions(+), 17 deletions(-) create mode 100644 docs/ARCH/11 - architecture_turnaround/22 - open_world_bounded_autonomy_breadth_2026-05-01.md diff --git a/docs/ARCH/11 - architecture_turnaround/21 - current_status_canon_2026-05-01.md b/docs/ARCH/11 - architecture_turnaround/21 - current_status_canon_2026-05-01.md index c28aa63..de04350 100644 --- a/docs/ARCH/11 - architecture_turnaround/21 - current_status_canon_2026-05-01.md +++ b/docs/ARCH/11 - architecture_turnaround/21 - current_status_canon_2026-05-01.md @@ -13,7 +13,8 @@ If another document says `78%`, `87%`, `92%`, or `85%` for a module that is now - `Post-F Semantic Integrity Hardening`: `99%`, operationally closed as a hardening slice and now used as a regression gate. - `Inventory Stock Open-World Breadth Proof`: `100%` for the declared inventory-stock scenario pack, not for arbitrary inventory questions. - `Planner Autonomy Consolidation`: `100%` for the declared phase83 planner-brain slice, including catalog alignment, live-readiness gating, checked-source sanitation, and accepted mixed replay. -- Active next pressure: broader `Open-World Bounded Autonomy Breadth` over unfamiliar 1C asks, while keeping Post-F and phase83 as regression gates. +- Active next module: broader `Open-World Bounded Autonomy Breadth` over unfamiliar 1C asks, while keeping Post-F and phase83 as regression gates. +- First active slice: `Business Overview Evidence Fusion`, tracked in `22 - open_world_bounded_autonomy_breadth_2026-05-01.md`. ## Reporting Rule @@ -70,9 +71,10 @@ For current planning, read: 1. `README.md` 2. this document -3. `20 - planner_autonomy_consolidation_2026-05-01.md` -4. `19 - inventory_stock_open_world_breadth_proof_2026-05-01.md` -5. `17 - post_f_semantic_integrity_hardening_2026-04-23.md` -6. `16 - data_need_graph_and_open_world_mcp_plan_2026-04-22.md` +3. `22 - open_world_bounded_autonomy_breadth_2026-05-01.md` +4. `20 - planner_autonomy_consolidation_2026-05-01.md` +5. `19 - inventory_stock_open_world_breadth_proof_2026-05-01.md` +6. `17 - post_f_semantic_integrity_hardening_2026-04-23.md` +7. `16 - data_need_graph_and_open_world_mcp_plan_2026-04-22.md` Documents `01` through `15` remain valuable, but mostly as the historical architecture trail. diff --git a/docs/ARCH/11 - architecture_turnaround/22 - open_world_bounded_autonomy_breadth_2026-05-01.md b/docs/ARCH/11 - architecture_turnaround/22 - open_world_bounded_autonomy_breadth_2026-05-01.md new file mode 100644 index 0000000..3aa8935 --- /dev/null +++ b/docs/ARCH/11 - architecture_turnaround/22 - open_world_bounded_autonomy_breadth_2026-05-01.md @@ -0,0 +1,101 @@ +# 22 - Open-World Bounded Autonomy Breadth (2026-05-01) + +## Purpose + +This note opens the next active module after: + +- Post-F Semantic Integrity Hardening operational closure; +- inventory-stock bounded breadth proof; +- Planner Autonomy Consolidation phase83 closure. + +The goal is not to reopen those slices. + +The goal is to grow the assistant from reviewed route families toward broader bounded 1C autonomy: + +`user business ask -> data-need graph -> catalog route fabric -> reviewed MCP evidence -> truth gate -> useful analyst answer` + +## Entry Baseline + +Already closed and kept as regression gates: + +- Post-F semantic integrity: explicit current-turn meaning beats stale scope, stale focus object, and wrong post-pivot arbitration. +- Planner phase83: selected chain must align with reviewed catalog templates and replay artifacts must expose that alignment. +- Inventory breadth proof: a domain scenario tree can be accepted only through user-facing business correctness, not route labels alone. + +The current active pressure is: + +- unfamiliar or broad human 1C asks still need more bounded breadth; +- broad business questions must become evidence-guided analyst answers, not generic chat summaries; +- new capabilities must grow through reusable data-need and catalog surfaces, not one-off prompt patches. + +## Slice 1 - Business Overview Evidence Fusion + +User-facing trigger examples: + +- `Как ты оценишь деятельность компании?` +- `Дай полный анализ компании` +- `Сделай LLM-аудит бизнеса` +- `Что думаешь о компании в целом?` + +### Problem + +Before this slice, broad business evaluation was protected from stale lifecycle replay, but it could still be too thin: + +- it summarized context, but did not consistently surface confirmed metrics; +- it did not clearly separate cash-flow signals from profit/margin claims; +- it could under-answer when the user expected a mature analyst-style company overview. + +### Current Implementation Boundary + +The first implementation step is intentionally bounded: + +- it does not invent fresh facts; +- it fuses already-confirmed MCP/session evidence from recent grounded answers; +- it recognizes broader company-analysis wording as `broad_business_evaluation`; +- it extracts useful analyst signals from confirmed value-flow, bidirectional net-flow, ranking, lifecycle, and inventory evidence; +- it states that profit, margin, debt quality, and full company health remain unproved unless corresponding evidence exists. + +This is not yet the final automatic multi-probe company-analysis chain. + +It is the safe first slice: better user-facing business overview without bypassing evidence gates. + +### Runtime Contract + +When this bridge answers, it must: + +- start with a direct business summary; +- list confirmed metrics separately from interpretation; +- call out cash-flow direction and net spread only as cash evidence, not as profit; +- mention top counterparty/customer only when a ranked value-flow evidence slice exists; +- keep profit/margin/debt/VAT as explicit missing evidence when not checked; +- avoid raw MCP, planner, catalog, route, primitive, or debug wording. + +### Next Slice + +Promote this bridge into a real planner route: + +- add a reviewed `business_overview` catalog chain template; +- let the data-need graph model broad evaluation as a composed evidence need; +- run bounded fresh probes for year turnover, top customers, incoming/outgoing/net flow, debt, VAT, and inventory context where available; +- return a layered analyst answer with exact evidence, bounded inference, unknowns, and recommended next probes. + +## Acceptance Signals + +The slice is healthy when: + +- broad analysis wording lands on the business-overview contour; +- the answer is materially more informative than a generic recap; +- confirmed metrics are visibly separated from LLM-style interpretation; +- profit/margin are not claimed without supporting evidence; +- Post-F stale-scope and phase83 catalog-alignment canaries remain green. + +## Validation + +Initial local validation: + +- `npm.cmd test -- assistantTurnMeaningPolicy.test.ts assistantLivingChatRuntimeAdapter.test.ts`: passed `20/20`. +- `npm.cmd test -- assistantTurnMeaningPolicy.test.ts assistantLivingChatRuntimeAdapter.test.ts assistantRoutePolicy.test.ts assistantMcpDiscoveryResponsePolicy.test.ts`: passed `56/56`. +- `npm.cmd run build`: passed. +- graphify rebuild: `5977 nodes`, `12983 edges`, `137 communities`. + +Graphify must be rebuilt after this code/doc slice before commit. diff --git a/docs/ARCH/11 - architecture_turnaround/README.md b/docs/ARCH/11 - architecture_turnaround/README.md index 9337de1..d72d942 100644 --- a/docs/ARCH/11 - architecture_turnaround/README.md +++ b/docs/ARCH/11 - architecture_turnaround/README.md @@ -39,6 +39,7 @@ This package answers the next question: 19. [19 - inventory_stock_open_world_breadth_proof_2026-05-01.md](./19%20-%20inventory_stock_open_world_breadth_proof_2026-05-01.md) 20. [20 - planner_autonomy_consolidation_2026-05-01.md](./20%20-%20planner_autonomy_consolidation_2026-05-01.md) 21. [21 - current_status_canon_2026-05-01.md](./21%20-%20current_status_canon_2026-05-01.md) +22. [22 - open_world_bounded_autonomy_breadth_2026-05-01.md](./22%20-%20open_world_bounded_autonomy_breadth_2026-05-01.md) ## Current Status Snapshot (2026-05-01) @@ -48,7 +49,8 @@ Status canon for planning: - Post-F Semantic Integrity Hardening is operationally closed at `99%` and should now be used as a regression gate, not as the active module denominator. - Planner Autonomy Consolidation is closed at `100%` for the declared phase83 planner-brain slice. -- The active next pressure is broader `Open-World Bounded Autonomy Breadth` over unfamiliar 1C asks, with Post-F and phase83 retained as semantic canaries. +- The active next module is now `Open-World Bounded Autonomy Breadth` over unfamiliar 1C asks, with Post-F and phase83 retained as semantic canaries. +- The first active slice is `Business Overview Evidence Fusion`: broad company-analysis wording now produces a richer evidence-grounded business overview from confirmed MCP/session facts instead of a thin generic summary. - The short source of truth for status wording is [21 - current_status_canon_2026-05-01.md](./21%20-%20current_status_canon_2026-05-01.md). It now documents a turnaround that is already operational in code, already materially past the acute regression breakpoint, and already moved through bounded MCP autonomy, Post-F hardening, inventory breadth proof, and the declared Planner Autonomy slice: @@ -113,10 +115,11 @@ Current honest status: - pre-multidomain readiness: `~90%` - bounded-autonomy foundation readiness: `~89%` - open-world bounded-autonomy readiness: `~85%` +- active Open-World Bounded Autonomy Breadth progress: `~12%`, with the first business-overview evidence-fusion slice started and locally tested; fresh multi-probe planner route is still pending - Post-F semantic integrity module progress: `~99%` operationally closed, with remaining risk now treated as next-slice discovery rather than an open blocker inside the closed slice - active inventory-stock breadth slice progress: `100%` for the declared scenario pack, not for arbitrary inventory questions - Planner Autonomy Consolidation progress: `100%` for the declared module, with catalog-fabric, value-flow arbitration, lifecycle bounded inference, broad-evaluation bridge, inventory catalog templates, inventory runtime-boundary honesty, exact inventory recipe bridging, unambiguous metadata-surface lane inference, catalog chain-template scoring, structured chain-match contract exposure, runtime/debug propagation, subject-aware bidirectional comparison arbitration, structured catalog-alignment verdicts, representative alignment regression guard, catalog-alignment reason-code telemetry, explicit `alignment_status` propagation, truth-harness/acceptance-matrix surfacing, soft divergence warning, `catalog_alignment_ok` acceptance invariant, step-level expected catalog-alignment assertions, phase66 and phase32 spec alignment expectations, AGENT source-catalog surfacing, generated phase83 mixed planner-brain replay spec, checked-source user-facing error sanitation, surface-grounded catalog promotion, and guarded live phase83 acceptance validated. Broader unfamiliar 1C asks are now next-module breadth work rather than an open blocker inside this declared slice -- graph snapshot after latest rebuild: `5974 nodes`, `12974 edges`, `138 communities` +- graph snapshot after latest rebuild: `5977 nodes`, `12983 edges`, `137 communities` - current regression-gate breakpoint: - the validated hot paths are no longer structurally broken; - flagship continuity collapse is no longer the primary risk; @@ -205,6 +208,7 @@ For the detailed audit, current percentages, and remaining debt, read: - [19 - inventory_stock_open_world_breadth_proof_2026-05-01.md](./19%20-%20inventory_stock_open_world_breadth_proof_2026-05-01.md) - [20 - planner_autonomy_consolidation_2026-05-01.md](./20%20-%20planner_autonomy_consolidation_2026-05-01.md) - [21 - current_status_canon_2026-05-01.md](./21%20-%20current_status_canon_2026-05-01.md) +- [22 - open_world_bounded_autonomy_breadth_2026-05-01.md](./22%20-%20open_world_bounded_autonomy_breadth_2026-05-01.md) ## Architectural Objects Of Planning @@ -244,6 +248,7 @@ Read in this order: 20. `19 - inventory_stock_open_world_breadth_proof_2026-05-01.md` 21. `20 - planner_autonomy_consolidation_2026-05-01.md` 22. `21 - current_status_canon_2026-05-01.md` +23. `22 - open_world_bounded_autonomy_breadth_2026-05-01.md` ## Planning Rules diff --git a/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js b/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js index b0f21f1..eff45a9 100644 --- a/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js @@ -291,6 +291,136 @@ function collectRecentRecapFacts(input) { } return facts.reverse(); } +function parseHumanMoney(value) { + const text = String(value ?? "") + .replace(/[^\d,.\-]/g, "") + .replace(/\s+/g, "") + .replace(",", "."); + if (!text) { + return null; + } + const parsed = Number(text); + return Number.isFinite(parsed) ? parsed : null; +} +function pushBusinessLine(target, value) { + const text = String(value ?? "").trim(); + if (text && !target.includes(text)) { + target.push(text); + } +} +function collectBusinessEvaluationEvidence(input) { + const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : []; + const currentOrganizationKey = normalizeRecapIdentity(input.organization); + const confirmedLines = []; + const interpretationLines = []; + let hasRanking = false; + let hasNet = false; + let moneySignalCount = 0; + for (let index = sessionItems.length - 1; index >= 0; index -= 1) { + const item = sessionItems[index]; + if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { + continue; + } + if (!(0, assistantContinuityPolicy_1.isGroundedAddressDebug)(item.debug, input.toNonEmptyString)) { + continue; + } + const debugContext = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(item.debug, input.toNonEmptyString); + const debugOrganizationKey = normalizeRecapIdentity(debugContext.organization); + if (currentOrganizationKey && debugOrganizationKey && debugOrganizationKey !== currentOrganizationKey) { + continue; + } + const discoveryEntry = toRecordObject(item.debug.assistant_mcp_discovery_entry_point_v1); + const bridge = toRecordObject(discoveryEntry?.bridge); + const pilot = toRecordObject(bridge?.pilot); + const pilotScope = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryPilotScope)(item.debug, input.toNonEmptyString); + if (!pilot) { + continue; + } + 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 = input.toNonEmptyString(leader?.axis_value); + const leaderAmount = input.toNonEmptyString(leader?.total_amount_human_ru); + const period = input.toNonEmptyString(rankedFlow.period_scope); + const periodPart = period ? ` за ${period}` : ""; + if (leaderName && leaderAmount) { + pushBusinessLine(confirmedLines, `Топ-контрагент по подтвержденному денежному срезу${periodPart}: ${leaderName} - ${leaderAmount}.`); + hasRanking = true; + moneySignalCount += 1; + } + } + const bidirectionalFlow = toRecordObject(pilot.derived_bidirectional_value_flow); + if (bidirectionalFlow) { + const incoming = toRecordObject(bidirectionalFlow.incoming_customer_revenue); + const outgoing = toRecordObject(bidirectionalFlow.outgoing_supplier_payout); + const incomingAmount = input.toNonEmptyString(incoming?.total_amount_human_ru); + const outgoingAmount = input.toNonEmptyString(outgoing?.total_amount_human_ru); + const netAmount = input.toNonEmptyString(bidirectionalFlow.net_amount_human_ru); + const period = input.toNonEmptyString(bidirectionalFlow.period_scope); + const periodPart = period ? ` за ${period}` : ""; + if (incomingAmount && outgoingAmount && netAmount) { + pushBusinessLine(confirmedLines, `Денежный поток${periodPart}: получили ${incomingAmount}, заплатили ${outgoingAmount}, расчетное нетто ${netAmount}.`); + const incomingNumber = parseHumanMoney(incomingAmount); + const outgoingNumber = parseHumanMoney(outgoingAmount); + const netNumber = parseHumanMoney(netAmount); + if (incomingNumber && outgoingNumber !== null && netNumber !== null) { + const spreadPercent = Math.abs(netNumber) / Math.max(Math.abs(incomingNumber), 1); + const spreadLabel = `${Math.round(spreadPercent * 100)}%`; + if (spreadPercent < 0.1) { + pushBusinessLine(interpretationLines, `Обороты есть, но денежный спред узкий: нетто около ${spreadLabel} от входящего потока. Это не прибыль, но сигнал, что маржу надо проверять отдельно.`); + } + else if (netNumber > 0) { + pushBusinessLine(interpretationLines, `Денежный поток в проверенном срезе положительный: нетто около ${spreadLabel} от входящего потока. Это хороший cash-flow сигнал, но не доказанная прибыль.`); + } + else { + pushBusinessLine(interpretationLines, `Денежный поток в проверенном срезе отрицательный: исходящие платежи выше входящих примерно на ${spreadLabel} от входящего потока. Нужна проверка причин и структуры расходов.`); + } + } + hasNet = true; + moneySignalCount += 1; + } + } + const valueFlow = toRecordObject(pilot.derived_value_flow); + if (valueFlow) { + const amount = input.toNonEmptyString(valueFlow.total_amount_human_ru); + const period = input.toNonEmptyString(valueFlow.period_scope); + const direction = String(valueFlow.value_flow_direction ?? ""); + const periodPart = period ? ` за ${period}` : ""; + if (amount) { + pushBusinessLine(confirmedLines, direction === "outgoing_supplier_payout" + ? `Исходящий денежный поток${periodPart}: ${amount}.` + : `Входящий денежный поток${periodPart}: ${amount}.`); + moneySignalCount += 1; + } + } + const activityPeriod = toRecordObject(pilot.derived_activity_period); + if (pilotScope === "counterparty_lifecycle_query_documents_v1" && activityPeriod) { + const duration = input.toNonEmptyString(activityPeriod.duration_human_ru); + const first = input.toNonEmptyString(activityPeriod.first_activity_date); + const latest = input.toNonEmptyString(activityPeriod.latest_activity_date); + if (duration) { + pushBusinessLine(confirmedLines, `Подтвержденная активность в 1С: примерно ${duration}${first && latest ? ` (${first} - ${latest})` : ""}.`); + } + } + if (pilotScope === "inventory_route_template_v1") { + pushBusinessLine(confirmedLines, "Есть проверенный складской/товарный срез; его можно использовать как операционный контекст, но не как финансовую прибыль."); + } + } + if (hasRanking) { + pushBusinessLine(interpretationLines, "По клиентской базе уже виден лидер, но концентрацию выручки надо проверять отдельным рейтингом и долями, а не одной строкой."); + } + if (moneySignalCount > 0 && !hasNet) { + pushBusinessLine(interpretationLines, "Денежный контур частично подтвержден, но без входящие-вс-исходящие нельзя честно говорить о чистом денежном эффекте."); + } + return { + confirmedLines: confirmedLines.slice(0, 6), + interpretationLines: interpretationLines.slice(0, 5), + hasRanking, + hasNet, + moneySignalCount + }; +} function buildAddressMemoryRecapReply(input) { const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString); const item = contextFacts.item; @@ -363,10 +493,17 @@ function buildBroadBusinessEvaluationReply(input) { 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 businessEvidence = collectBusinessEvaluationEvidence({ + sessionItems: input.sessionItems, + organization, + toNonEmptyString: input.toNonEmptyString + }); + const hasBusinessEvidence = businessEvidence.confirmedLines.length > 0; + if (recapFacts.length > 0 || hasBusinessEvidence) { + const moneyFactCount = recapFacts.filter((fact) => /(?:денежн|нетто|поступлен|платеж|рейтинг|клиент|выруч|оборот|заплатили|получили)/iu.test(fact)).length + businessEvidence.moneySignalCount; + const hasRankingFact = businessEvidence.hasRanking || + recapFacts.some((fact) => /(?:рейтинг|клиент|единственного клиента)/iu.test(fact)); + const hasNetFact = businessEvidence.hasNet || recapFacts.some((fact) => /нетто/iu.test(fact)); const auditLines = [ moneyFactCount > 0 ? "- Денежный контур уже выглядит операционно значимым: есть подтвержденные поступления, платежи или клиентские срезы." @@ -382,7 +519,11 @@ function buildBroadBusinessEvaluationReply(input) { `Коротко: по уже подтвержденным срезам 1С${organizationPart} компания выглядит операционно живой; это предварительная оценка бизнеса, а для взрослого вывода еще нужны прибыль, маржа и долги.`, "Что уже видно:", ...recapFacts.map((fact) => `- ${ensureSentence(fact)}`), + ...(businessEvidence.confirmedLines.length > 0 + ? ["Подтвержденные метрики:", ...businessEvidence.confirmedLines.map((fact) => `- ${ensureSentence(fact)}`)] + : []), "Предварительный LLM-аудит:", + ...businessEvidence.interpretationLines.map((fact) => `- ${ensureSentence(fact)}`), ...auditLines, "Что добрать для полной оценки: обороты по годам, топ клиентов, входящие/исходящие деньги, дебиторку/кредиторку, НДС и признаки маржинальности." ].join("\n"); diff --git a/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js b/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js index a03ba27..0633719 100644 --- a/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js @@ -117,7 +117,7 @@ function detectBroadBusinessEvaluation(text) { if (!normalized) { return null; } - if (/(?:как\s+ты\s+оценишь\s+деятельност[ьи]\s+компан|оценк[аи]?\s+деятельност[ьи]\s+компан|что\s+у\s+нас\s+вообще\s+происход|где\s+главн(?:ые|ый)\s+риски|как\s+у\s+нас\s+дела\s+по\s+компан)/iu.test(normalized)) { + if (/(?:как\s+ты\s+оценишь\s+деятельност[ьи]\s+компан|оценк[аи]?\s+деятельност[ьи]\s+компан|оцени\s+(?:компан|бизнес|деятельност)|(?:полный|сводный|нормальн\w*|взросл\w*)\s+анализ\s+(?:компан|бизнес|деятельност)|проанализируй\s+(?:компан|бизнес|деятельност)|(?:что\s+думаешь|какое\s+мнение)\s+(?:о|по)\s+(?:компан|бизнес)|(?:llm[-\s]?)?аудит\s+(?:компан|бизнес)|что\s+у\s+нас\s+вообще\s+происход|где\s+главн(?:ые|ый)\s+риски|как\s+у\s+нас\s+дела\s+по\s+компан)/iu.test(normalized)) { return { family: "broad_business_evaluation" }; diff --git a/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts b/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts index 2f6cd3f..53c594d 100644 --- a/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts @@ -378,6 +378,186 @@ function collectRecentRecapFacts(input: { return facts.reverse(); } +function parseHumanMoney(value: unknown): number | null { + const text = String(value ?? "") + .replace(/[^\d,.\-]/g, "") + .replace(/\s+/g, "") + .replace(",", "."); + if (!text) { + return null; + } + const parsed = Number(text); + return Number.isFinite(parsed) ? parsed : null; +} + +function pushBusinessLine(target: string[], value: string | null): void { + const text = String(value ?? "").trim(); + if (text && !target.includes(text)) { + target.push(text); + } +} + +function collectBusinessEvaluationEvidence(input: { + sessionItems?: unknown[]; + organization: string | null; + toNonEmptyString: (value: unknown) => string | null; +}): { + confirmedLines: string[]; + interpretationLines: string[]; + hasRanking: boolean; + hasNet: boolean; + moneySignalCount: number; +} { + const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : []; + const currentOrganizationKey = normalizeRecapIdentity(input.organization); + const confirmedLines: string[] = []; + const interpretationLines: string[] = []; + let hasRanking = false; + let hasNet = false; + let moneySignalCount = 0; + + for (let index = sessionItems.length - 1; index >= 0; index -= 1) { + const item = sessionItems[index] as { role?: string; debug?: Record } | null; + if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { + continue; + } + if (!isGroundedAddressDebug(item.debug, input.toNonEmptyString)) { + continue; + } + const debugContext = resolveAddressDebugContextFacts(item.debug, input.toNonEmptyString); + const debugOrganizationKey = normalizeRecapIdentity(debugContext.organization); + if (currentOrganizationKey && debugOrganizationKey && debugOrganizationKey !== currentOrganizationKey) { + continue; + } + + const discoveryEntry = toRecordObject(item.debug.assistant_mcp_discovery_entry_point_v1); + const bridge = toRecordObject(discoveryEntry?.bridge); + const pilot = toRecordObject(bridge?.pilot); + const pilotScope = readAssistantMcpDiscoveryPilotScope(item.debug, input.toNonEmptyString); + if (!pilot) { + continue; + } + + 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 = input.toNonEmptyString(leader?.axis_value); + const leaderAmount = input.toNonEmptyString(leader?.total_amount_human_ru); + const period = input.toNonEmptyString(rankedFlow.period_scope); + const periodPart = period ? ` за ${period}` : ""; + if (leaderName && leaderAmount) { + pushBusinessLine( + confirmedLines, + `Топ-контрагент по подтвержденному денежному срезу${periodPart}: ${leaderName} - ${leaderAmount}.` + ); + hasRanking = true; + moneySignalCount += 1; + } + } + + const bidirectionalFlow = toRecordObject(pilot.derived_bidirectional_value_flow); + if (bidirectionalFlow) { + const incoming = toRecordObject(bidirectionalFlow.incoming_customer_revenue); + const outgoing = toRecordObject(bidirectionalFlow.outgoing_supplier_payout); + const incomingAmount = input.toNonEmptyString(incoming?.total_amount_human_ru); + const outgoingAmount = input.toNonEmptyString(outgoing?.total_amount_human_ru); + const netAmount = input.toNonEmptyString(bidirectionalFlow.net_amount_human_ru); + const period = input.toNonEmptyString(bidirectionalFlow.period_scope); + const periodPart = period ? ` за ${period}` : ""; + if (incomingAmount && outgoingAmount && netAmount) { + pushBusinessLine( + confirmedLines, + `Денежный поток${periodPart}: получили ${incomingAmount}, заплатили ${outgoingAmount}, расчетное нетто ${netAmount}.` + ); + const incomingNumber = parseHumanMoney(incomingAmount); + const outgoingNumber = parseHumanMoney(outgoingAmount); + const netNumber = parseHumanMoney(netAmount); + if (incomingNumber && outgoingNumber !== null && netNumber !== null) { + const spreadPercent = Math.abs(netNumber) / Math.max(Math.abs(incomingNumber), 1); + const spreadLabel = `${Math.round(spreadPercent * 100)}%`; + if (spreadPercent < 0.1) { + pushBusinessLine( + interpretationLines, + `Обороты есть, но денежный спред узкий: нетто около ${spreadLabel} от входящего потока. Это не прибыль, но сигнал, что маржу надо проверять отдельно.` + ); + } else if (netNumber > 0) { + pushBusinessLine( + interpretationLines, + `Денежный поток в проверенном срезе положительный: нетто около ${spreadLabel} от входящего потока. Это хороший cash-flow сигнал, но не доказанная прибыль.` + ); + } else { + pushBusinessLine( + interpretationLines, + `Денежный поток в проверенном срезе отрицательный: исходящие платежи выше входящих примерно на ${spreadLabel} от входящего потока. Нужна проверка причин и структуры расходов.` + ); + } + } + hasNet = true; + moneySignalCount += 1; + } + } + + const valueFlow = toRecordObject(pilot.derived_value_flow); + if (valueFlow) { + const amount = input.toNonEmptyString(valueFlow.total_amount_human_ru); + const period = input.toNonEmptyString(valueFlow.period_scope); + const direction = String(valueFlow.value_flow_direction ?? ""); + const periodPart = period ? ` за ${period}` : ""; + if (amount) { + pushBusinessLine( + confirmedLines, + direction === "outgoing_supplier_payout" + ? `Исходящий денежный поток${periodPart}: ${amount}.` + : `Входящий денежный поток${periodPart}: ${amount}.` + ); + moneySignalCount += 1; + } + } + + const activityPeriod = toRecordObject(pilot.derived_activity_period); + if (pilotScope === "counterparty_lifecycle_query_documents_v1" && activityPeriod) { + const duration = input.toNonEmptyString(activityPeriod.duration_human_ru); + const first = input.toNonEmptyString(activityPeriod.first_activity_date); + const latest = input.toNonEmptyString(activityPeriod.latest_activity_date); + if (duration) { + pushBusinessLine( + confirmedLines, + `Подтвержденная активность в 1С: примерно ${duration}${first && latest ? ` (${first} - ${latest})` : ""}.` + ); + } + } + + if (pilotScope === "inventory_route_template_v1") { + pushBusinessLine( + confirmedLines, + "Есть проверенный складской/товарный срез; его можно использовать как операционный контекст, но не как финансовую прибыль." + ); + } + } + + if (hasRanking) { + pushBusinessLine( + interpretationLines, + "По клиентской базе уже виден лидер, но концентрацию выручки надо проверять отдельным рейтингом и долями, а не одной строкой." + ); + } + if (moneySignalCount > 0 && !hasNet) { + pushBusinessLine( + interpretationLines, + "Денежный контур частично подтвержден, но без входящие-вс-исходящие нельзя честно говорить о чистом денежном эффекте." + ); + } + + return { + confirmedLines: confirmedLines.slice(0, 6), + interpretationLines: interpretationLines.slice(0, 5), + hasRanking, + hasNet, + moneySignalCount + }; +} + export function buildAddressMemoryRecapReply(input: { organization: string | null; addressDebug: Record | null; @@ -466,13 +646,21 @@ export function buildBroadBusinessEvaluationReply(input: { limit: 5 }); const organizationPart = organization ? ` по компании «${organization}»` : ""; + const businessEvidence = collectBusinessEvaluationEvidence({ + sessionItems: input.sessionItems, + organization, + toNonEmptyString: input.toNonEmptyString + }); + const hasBusinessEvidence = businessEvidence.confirmedLines.length > 0; - if (recapFacts.length > 0) { + if (recapFacts.length > 0 || hasBusinessEvidence) { 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)); + ).length + businessEvidence.moneySignalCount; + const hasRankingFact = + businessEvidence.hasRanking || + recapFacts.some((fact) => /(?:рейтинг|клиент|единственного клиента)/iu.test(fact)); + const hasNetFact = businessEvidence.hasNet || recapFacts.some((fact) => /нетто/iu.test(fact)); const auditLines = [ moneyFactCount > 0 ? "- Денежный контур уже выглядит операционно значимым: есть подтвержденные поступления, платежи или клиентские срезы." @@ -488,7 +676,11 @@ export function buildBroadBusinessEvaluationReply(input: { `Коротко: по уже подтвержденным срезам 1С${organizationPart} компания выглядит операционно живой; это предварительная оценка бизнеса, а для взрослого вывода еще нужны прибыль, маржа и долги.`, "Что уже видно:", ...recapFacts.map((fact) => `- ${ensureSentence(fact)}`), + ...(businessEvidence.confirmedLines.length > 0 + ? ["Подтвержденные метрики:", ...businessEvidence.confirmedLines.map((fact) => `- ${ensureSentence(fact)}`)] + : []), "Предварительный LLM-аудит:", + ...businessEvidence.interpretationLines.map((fact) => `- ${ensureSentence(fact)}`), ...auditLines, "Что добрать для полной оценки: обороты по годам, топ клиентов, входящие/исходящие деньги, дебиторку/кредиторку, НДС и признаки маржинальности." ].join("\n"); diff --git a/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts b/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts index 0cd110d..7d30040 100644 --- a/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts @@ -123,7 +123,7 @@ function detectBroadBusinessEvaluation(text) { return null; } if ( - /(?:как\s+ты\s+оценишь\s+деятельност[ьи]\s+компан|оценк[аи]?\s+деятельност[ьи]\s+компан|что\s+у\s+нас\s+вообще\s+происход|где\s+главн(?:ые|ый)\s+риски|как\s+у\s+нас\s+дела\s+по\s+компан)/iu.test( + /(?:как\s+ты\s+оценишь\s+деятельност[ьи]\s+компан|оценк[аи]?\s+деятельност[ьи]\s+компан|оцени\s+(?:компан|бизнес|деятельност)|(?:полный|сводный|нормальн\w*|взросл\w*)\s+анализ\s+(?:компан|бизнес|деятельност)|проанализируй\s+(?:компан|бизнес|деятельност)|(?:что\s+думаешь|какое\s+мнение)\s+(?:о|по)\s+(?:компан|бизнес)|(?:llm[-\s]?)?аудит\s+(?:компан|бизнес)|что\s+у\s+нас\s+вообще\s+происход|где\s+главн(?:ые|ый)\s+риски|как\s+у\s+нас\s+дела\s+по\s+компан)/iu.test( normalized ) ) { diff --git a/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts index b7fe628..d12cb9e 100644 --- a/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts @@ -214,6 +214,9 @@ describe("assistant living chat runtime adapter", () => { expect(output.chatText).toContain("ООО Альтернатива Плюс"); expect(output.chatText).toContain("Группа СВК"); expect(output.chatText).toContain("нетто"); + expect(output.chatText).toContain("Подтвержденные метрики"); + expect(output.chatText).toContain("Денежный поток"); + expect(output.chatText).toContain("маржу"); expect(output.debug?.living_chat_response_source).toBe("deterministic_broad_business_evaluation_contract"); expect(executeLlmChat).not.toHaveBeenCalled(); }); diff --git a/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts b/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts index fb02d7b..462654b 100644 --- a/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts @@ -110,4 +110,20 @@ describe("assistantTurnMeaningPolicy", () => { expect(meaning.stale_replay_forbidden).toBe(true); expect(meaning.reason_codes).toContain("broad_business_evaluation_current_turn_signal"); }); + + it("recognizes broad company analysis wording as the same bounded business overview ask", () => { + const policy = buildPolicy({ + resolveAddressIntent: () => ({ intent: "unknown", confidence: "low" }) + }); + + const meaning = policy.resolveAssistantTurnMeaning({ + rawUserMessage: "Дай полный анализ компании и LLM-аудит бизнеса" + }); + + expect(meaning.explicit_intent_candidate).toBeNull(); + expect(meaning.asked_domain_family).toBe("business_summary"); + expect(meaning.asked_action_family).toBe("broad_evaluation"); + expect(meaning.unsupported_but_understood_family).toBe("broad_business_evaluation"); + expect(meaning.reason_codes).toContain("broad_business_evaluation_current_turn_signal"); + }); });