diff --git a/artifacts/tmp_backend_runtime_check.err.log b/artifacts/tmp_backend_runtime_check.err.log new file mode 100644 index 0000000..27bd25a --- /dev/null +++ b/artifacts/tmp_backend_runtime_check.err.log @@ -0,0 +1,26 @@ +node:events:497 + throw er; // Unhandled 'error' event + ^ + +Error: listen EADDRINUSE: address already in use :::8787 + at Server.setupListenHandle [as _listen2] (node:net:1940:16) + at listenInCluster (node:net:1997:12) + at Server.listen (node:net:2102:7) + at Function.listen (X:\1C\NDC_1C\llm_normalizer\backend\node_modules\express\lib\application.js:635:24) + at Object. (X:\1C\NDC_1C\llm_normalizer\backend\dist\server.js:76:9) + at Module._compile (node:internal/modules/cjs/loader:1706:14) + at Object..js (node:internal/modules/cjs/loader:1839:10) + at Module.load (node:internal/modules/cjs/loader:1441:32) + at Function._load (node:internal/modules/cjs/loader:1263:12) + at TracingChannel.traceSync (node:diagnostics_channel:322:14) +Emitted 'error' event on Server instance at: + at emitErrorNT (node:net:1976:8) + at process.processTicksAndRejections (node:internal/process/task_queues:90:21) { + code: 'EADDRINUSE', + errno: -4091, + syscall: 'listen', + address: '::', + port: 8787 +} + +Node.js v22.20.0 diff --git a/artifacts/tmp_backend_runtime_check.log b/artifacts/tmp_backend_runtime_check.log new file mode 100644 index 0000000..37bd38f --- /dev/null +++ b/artifacts/tmp_backend_runtime_check.log @@ -0,0 +1,4 @@ + +> llm-normalizer-backend@0.1.0 start +> node dist/server.js + diff --git a/docs/ARCH/11 - architecture_turnaround/08 - current_status_audit_2026-04-17.md b/docs/ARCH/11 - architecture_turnaround/08 - current_status_audit_2026-04-17.md index 146504a..bcfffdd 100644 --- a/docs/ARCH/11 - architecture_turnaround/08 - current_status_audit_2026-04-17.md +++ b/docs/ARCH/11 - architecture_turnaround/08 - current_status_audit_2026-04-17.md @@ -20,29 +20,33 @@ This snapshot is based on: - current owner modules in `llm_normalizer/backend/src/services/` - current scenario acceptance scripts under `scripts/` - current AGENT semantic source catalog under `docs/orchestration/` +- live replay comparison between: + - `address_truth_harness_phase5_company_selection_and_activity_age_live_20260417_iter6` + - `address_truth_harness_phase7_meta_domain_mix_live_20260417` +- [10 - regression_breakpoint_analysis_2026-04-17.md](./10%20-%20regression_breakpoint_analysis_2026-04-17.md) ## Graph Snapshot Latest graph rebuild: -- `5287 nodes` -- `11407 edges` -- `135 communities` +- `5312 nodes` +- `11408 edges` +- `136 communities` Most relevant current god nodes for turnaround `11`: 1. `resolveAddressIntent()` 2. `ChannelRegistry` 3. `CanonicalStore` -4. `composeFactualReplyBody()` -5. `compactWhitespace()` +4. `compactWhitespace()` +5. `ExecuteQueryParams` The relevant conclusion is not that every god node is part of turnaround `11`. The relevant conclusion is: - `resolveAddressIntent()` remains the main unresolved domain-intent concentration point; -- `composeFactualReplyBody()` still carries answer-shaping pressure, but it is no longer a top-3 god node after the latest extractions; +- `composeFactualReplyBody()` no longer appears in the top god-node slice after the latest compose extractions, which means the central answer-shaping pressure has materially dropped; - `assistantService` still appears as a large coordinator-heavy community rather than a thin shell. ## What Is Already Real In Code @@ -126,7 +130,13 @@ This is enough to build targeted semantic packs that are not single-domain toy s ## Honest Phase Status -Estimated overall turnaround completion: `~92%` +Turnaround implementation progress: `~88%` + +Pre-expansion readiness: `~62%` + +This split is intentional. + +The codebase already contains most of the required architectural objects, but the mixed saved-session runtime is still not stable enough to justify aggressive domain expansion. ### Phase 0. Shared Baseline @@ -148,22 +158,23 @@ Reason: ### Phase 2. State And Transition Contracts -Status: `92%` +Status: `74%` Reason: -- root, selected-object, same-date, and carryover classes are materially operational; +- root, selected-object, same-date, and carryover classes exist as real owner seams; - transition policy is extracted; -- scenario acceptance now evaluates continuity explicitly. +- scenario acceptance evaluates continuity explicitly. Remaining debt: -- legacy transition helpers still physically exist inside `assistantService.ts`; -- not every follow-up family is represented as equally clean first-class transition taxonomy. +- there is still no single authoritative continuity contract for live mixed sessions; +- continuity priority is still split across route policy, transition policy, recap policy, navigation state, and coordinator glue; +- the mixed replay can still lose the active frame and fall into clarification or wrong-family reuse. ### Phase 3. Capability Contracts -Status: `89%` +Status: `91%` Reason: @@ -189,16 +200,18 @@ Reason: - reply-packaging mechanics are now explicitly split into `address_runtime/replyPackaging.ts` instead of staying fully in `composeStage.ts`. - named reply contracts and answer semantics presets now also live in `address_runtime/replyContracts.ts` instead of being rebuilt inline across major factual branches. - inventory answer construction now has an explicit owner in `address_runtime/inventoryReplyBuilders.ts` instead of staying inline inside `composeFactualReplyBody()`. +- counterparty / lifecycle / value / contract-profile factual replies now also have an explicit owner in `address_runtime/counterpartyAnalyticsReplyBuilders.ts` instead of staying inline inside `composeFactualReplyBody()`. +- `composeStage.ts` is now down to about `4305` lines after the counterparty analytics extraction and duplicate-body cleanup. Remaining debt: -- `composeFactualReplyBody()` is still a major concentration point, but its graph pressure is lower than in the previous snapshot; +- `composeFactualReplyBody()` is still not fully decomposed, but it is no longer one of the top graph pressure points in the current snapshot; - humanized blocked/limited semantics are not yet fully separated from answer semantics across all paths; -- `composeStage.ts` still remains too large even after packaging extraction and inventory-family extraction. +- `composeStage.ts` still remains too large even after packaging extraction, inventory-family extraction, and counterparty analytics extraction. ### Phase 5. AssistantService Extraction -Status: `82%` +Status: `78%` Reason: @@ -209,8 +222,8 @@ Reason: Remaining debt: - `assistantService.ts` is still about `5050` lines; -- runtime uses extracted owners, but legacy bodies and fallback branches still live in the coordinator file; -- code review still sometimes requires reading `assistantService` together with extracted owners. +- runtime uses extracted owners, but priority and fallback seams still cross the coordinator boundary; +- code review still requires reading `assistantService` together with extracted owners to understand real behavior. ### Phase 6. Provider / Runtime Axis Hardening @@ -227,7 +240,7 @@ Remaining debt: ### Phase 7. Scenario Acceptance As Primary Gate -Status: `90%` +Status: `68%` Reason: @@ -237,8 +250,25 @@ Reason: Remaining debt: -- semantic replay remains intentionally tool-driven and script-driven rather than promoted to a mandatory UI-first loop; -- coverage breadth should continue to grow as new domain slices are hardened. +- narrow packs and seam tests are much stronger than before, but mixed saved-session runtime is still under-protected; +- the current phase7 mixed replay fails on continuity-critical edges, so acceptance cannot yet be treated as expansion-safe; +- coverage breadth should continue to grow as new domain slices are hardened, but stability of the existing mixed runtime is the immediate gate. + +## Current Breakpoint Evidence + +The strongest current architectural reading is not "a few routes regressed". + +It is: + +- `phase5_company_selection_and_activity_age` is green end-to-end; +- `phase7_meta_domain_mix` still fails on root inventory, selected-object continuity, same-date restore, and cross-domain same-date pivot; +- therefore the core capability families still exist, but mixed-session continuity authority is not stable. + +In practical terms, the active breakpoint is now: + +- owner extraction has progressed far enough to split decisions across multiple layers; +- saved-session mixed runtime still does not have one governing continuity authority; +- clarification and recap can therefore outrank or outrun the real grounded business thread. ## What Has Improved Relative To The Original State @@ -254,29 +284,45 @@ Compared with the pre-turnaround baseline, the system is now materially better i - reply formatting and reply-type classification now have an explicit owner outside `composeStage.ts`; - confirmed-balance and heuristic-candidate reply contracts now have explicit builders instead of repeated inline `semantics` objects in major compose branches; - inventory factual replies are now owned by a dedicated module rather than embedded directly in the central compose body; +- counterparty analytics, lifecycle, customer/supplier value rankings, and contract-value factual replies are now owned by a dedicated module rather than embedded directly in the central compose body; - inventory intent classification now has a dedicated owner instead of being only an inline segment inside the central address intent resolver; - counterparty / contract / documents intent classification now also has a dedicated owner instead of being only an inline segment inside the central address intent resolver; - architecture regressions can now be localized to route, transition, truth gate, coverage/evidence, boundary, or meta/memory layers. ## What Still Remains The Main Architectural Debt -### 1. `assistantService.ts` is still too large +### 1. Mixed-session continuity still has no single authority + +The project now has real route, transition, recap, and boundary owners, but the live session still does not resolve: + +- active root frame +- selected object +- organization scope +- temporal carryover +- clarification state +- recap source of truth + +from one governing contract. + +This is the highest-risk debt before domain expansion. + +### 2. `assistantService.ts` is still too large The extracted owners are real, but the coordinator has not yet been fully reduced to a thin orchestration shell. -### 2. `resolveAddressIntent()` is still too central +### 3. `resolveAddressIntent()` is still too central Intent resolution remains one of the most connected business nodes in the graph. This means capability and contour growth still concentrate pressure there. -### 3. `composeFactualReplyBody()` is still too central +### 4. `composeFactualReplyBody()` is still too central Truth contracts are now explicit, and reply packaging, reply contracts, and the inventory answer family have all started moving into their own owners, but final answer-shaping still retains too much architecture weight. This is the main remaining reason why user-facing humanization and limitation semantics are not completely isolated yet. -### 4. Coverage breadth is stronger, but still selective +### 5. Coverage breadth is stronger, but still selective The project is now strong on: @@ -292,10 +338,12 @@ But not every business family has reached the same contract maturity. The next honest architecture slice should be: -1. continue reducing `assistantService.ts` to a thinner coordinator; -2. continue isolating answer semantics further away from `composeFactualReplyBody()` now that reply packaging and reply contracts have their own owner seams; -3. keep extending AGENT packs with mixed business + meta + interruption patterns instead of single-family smoke tests; -4. keep using scenario acceptance as the main sign-off rather than unit-test green status alone. +1. finish one authoritative continuity contract for live mixed sessions; +2. subordinate clarification to restored business continuity instead of letting it act as a default escape hatch; +3. continue reducing `assistantService.ts` to a thinner coordinator; +4. continue isolating answer semantics further away from `composeFactualReplyBody()` now that reply packaging and reply contracts have their own owner seams; +5. keep extending AGENT packs with mixed business + meta + interruption patterns instead of single-family smoke tests; +6. keep using mixed scenario acceptance as the main sign-off rather than unit-test green status alone. ## Bottom Line diff --git a/docs/ARCH/11 - architecture_turnaround/09 - pre_expansion_cut_2026-04-17.md b/docs/ARCH/11 - architecture_turnaround/09 - pre_expansion_cut_2026-04-17.md index 54c7ded..4cc649e 100644 --- a/docs/ARCH/11 - architecture_turnaround/09 - pre_expansion_cut_2026-04-17.md +++ b/docs/ARCH/11 - architecture_turnaround/09 - pre_expansion_cut_2026-04-17.md @@ -25,12 +25,51 @@ The system already has: The remaining problem is different now: +- the project is in a hybrid extracted state; - quality risk is concentrated in a small number of central pressure points; - these pressure points will amplify regressions once many new domains are added. ## Must Close Before Mass Domain Expansion -### 1. Intent concentration in `resolveAddressIntent()` +### 1. One continuity authority for live mixed sessions + +Why it matters: + +- the same capability family can pass in a narrow scenario and fail in a nearby mixed scenario; +- current runtime continuity is still split across route policy, transition policy, memory recap policy, navigation state, and coordinator glue; +- domain expansion on top of split continuity ownership will multiply regressions faster than route extraction can contain them. + +What "done enough" means: + +- one governing session continuity contract resolves active root frame, selected object, organization scope, date scope, clarification state, and recap source; +- route, recap, and clarification decisions read the same continuity object instead of reconstructing state independently; +- selected-object, same-date, and cross-domain root pivots stop drifting across different owners. + +Current status: + +- the underlying contract vocabulary already exists across runtime contract registries and state-transition adapters; +- owner modules are real, but continuity state is still not yet governed from one place. + +### 2. Clarification precedence must stop outranking continuity + +Why it matters: + +- current regressions often appear as repeated company clarification even after a nearby business answer already succeeded; +- this is not a pure intent failure, it is clarification winning too often when continuity confidence was lost; +- if this precedence remains unstable, new domains will feel scripted even when the route exists. + +What "done enough" means: + +- clarification becomes the last meaningful step, not the default escape hatch; +- restored business continuity outranks template clarification whenever the active frame is sufficient; +- repeated clarification no longer breaks selected-object or same-date follow-ups. + +Current status: + +- company clarification already has explicit owner seams; +- mixed live replay still shows clarification outranking restored business context. + +### 3. Intent concentration in `resolveAddressIntent()` Why it matters: @@ -48,9 +87,9 @@ Current status: - inventory signal-family is now delegated to `addressInventoryIntentSignals.ts`; - counterparty / documents / contracts / open-items signal-family is now also delegated to `addressCounterpartyIntentSignals.ts`; -- this reduces ownership pressure, even though the old inline bodies still remain as cleanup debt. +- the central resolver now delegates these hot families through owner modules instead of carrying them only as inline branches. -### 2. Answer semantics pressure in `composeFactualReplyBody()` +### 4. Answer semantics pressure in `composeFactualReplyBody()` Why it matters: @@ -64,7 +103,13 @@ What "done enough" means: - blocked / limited / humanized fallback semantics are explicit for the most important contours; - user-facing replies no longer expose internal route/capability/debug jargon on critical business paths. -### 3. Business-first quality guard on hot contours +Current status: + +- inventory factual replies already live in `address_runtime/inventoryReplyBuilders.ts`; +- counterparty analytics / lifecycle / value / contract-value factual replies now also live in `address_runtime/counterpartyAnalyticsReplyBuilders.ts`; +- `composeStage.ts` is materially smaller than before, but blocked / limited semantics still need one more hardening pass on the hottest contours. + +### 5. Business-first quality guard on hot contours Why it matters: @@ -109,20 +154,28 @@ Promoting every replay step into a more polished UI loop can happen later. ### Pass 1 -- continue extracting the highest-risk signal families out of `resolveAddressIntent()`; -- keep business behavior stable through focused regression packs; +- finish the first authoritative continuity contract and wire it into route / recap / clarification hot paths; +- prove that mixed replay stops losing active frame authority; - treat this as the main pre-expansion hardening track. ### Pass 2 -- reduce remaining answer-semantics pressure in `composeFactualReplyBody()`; -- harden blocked / limited / humanized response semantics on the hottest business contours; +- subordinate clarification to restored business continuity and remove repeated template escapes; +- harden recap truthfulness so it can only summarize verified session facts; - confirm with AGENT replay that user-facing answers stay business-first. +### Pass 3 + +- continue extracting the highest-risk signal families out of `resolveAddressIntent()`; +- reduce remaining answer-semantics pressure in `composeFactualReplyBody()` and `composeStage.ts`; +- confirm with AGENT replay that technical scaffolding no longer leaks into the user-facing answer. + ## Practical Exit Condition Turnaround `11` can be considered "ready for domain expansion" when: +- one continuity authority governs mixed live sessions; +- clarification no longer wins against valid restored business context; - the main route-collision pressure in `resolveAddressIntent()` is materially reduced; - the hottest user-facing answer families are protected from technical leakage; - AGENT replay confirms stable business usefulness on the core mixed chains; diff --git a/docs/ARCH/11 - architecture_turnaround/10 - regression_breakpoint_analysis_2026-04-17.md b/docs/ARCH/11 - architecture_turnaround/10 - regression_breakpoint_analysis_2026-04-17.md new file mode 100644 index 0000000..e01ff00 --- /dev/null +++ b/docs/ARCH/11 - architecture_turnaround/10 - regression_breakpoint_analysis_2026-04-17.md @@ -0,0 +1,328 @@ +# 10 - Regression Breakpoint Analysis (2026-04-17) + +## Purpose + +This note records the current best architectural reading of where the live assistant started to regress during turnaround `11`. + +It is not a blame note and not a patch log. + +It is the breakpoint analysis that answers: + +- at what stage the system stopped behaving like the older stable mixed-session assistant; +- why the regression looked sudden even though many extractions were individually "correct"; +- what is the real architectural break, as opposed to surface symptoms in specific questions. + +## Short Answer + +The system did not break at one isolated question and not at one isolated line. + +The system started to break at the moment when the old monolithic decision cluster was already partially extracted into owner modules, but the new shared runtime continuity contract was still not consolidated into one governing layer. + +In practice, the breakpoint appears after the late pre-expansion extraction passes, when all of the following became true at the same time: + +- intent recognition started to move out of the old central resolver into separate signal owners; +- transition and carryover logic started to move into dedicated policies; +- reply shaping and recap logic started to move into dedicated builders and policies; +- but saved-session mixed runtime still depended on old assumptions about how these layers stay synchronized. + +That is the point where the project entered a hybrid state: + +- the old monolith no longer fully owned the decisions; +- the new extracted architecture did not yet fully own the decisions; +- continuity priority was therefore split across several places. + +This hybrid state is the real breakpoint. + +## What Worked Before + +Before the regression wave, the assistant was structurally dirtier but behaviorally more coupled. + +That older state had serious architecture debt, but it also had one practical advantage: + +- root intent resolution; +- selected-object continuity; +- same-date carryover; +- company clarification; +- recap behavior; +- and final answer shaping + +were still located closer to each other inside a narrower monolithic runtime path. + +Because these decisions lived close together, they could remain mutually consistent even when the code was oversized and hard to reason about. + +So the older system was not "more correct" architecturally. + +It was more behaviorally coherent because fewer contracts crossed file and ownership boundaries. + +## What Changed During Turnaround `11` + +Turnaround `11` did the right high-level thing: it started separating concerns. + +The project extracted or hardened real owner layers such as: + +- `assistantRoutePolicy.ts` +- `assistantTransitionPolicy.ts` +- `assistantBoundaryPolicy.ts` +- `assistantMemoryRecapPolicy.ts` +- `addressInventoryIntentSignals.ts` +- `addressCounterpartyIntentSignals.ts` +- `address_runtime/inventoryReplyBuilders.ts` +- `address_runtime/counterpartyAnalyticsReplyBuilders.ts` +- `address_runtime/replyPackaging.ts` +- `address_runtime/replyContracts.ts` + +Architecturally, this was necessary. + +The problem is that behavior did not move as a single closed package. + +It moved in slices. + +This created a dangerous intermediate state where: + +- intent ownership was partially new; +- route arbitration was partially new; +- selected-object and same-date carryover were partially new; +- recap logic was partially new; +- final user-facing response shaping was partially new; +- but the full saved-session runtime contract was still not enforced centrally. + +## The Actual Breakpoint + +### Breakpoint Definition + +The true breakpoint is the transition from: + +- `monolithic but behaviorally coupled` + +to: + +- `partially extracted but behaviorally split` + +without a single authoritative continuity/arbitration contract for live mixed sessions. + +### Why It Felt Like "Everything Broke At Once" + +Once the system entered this hybrid state, regressions no longer stayed local. + +A small drift in one layer immediately propagated into others. + +For example: + +1. A root inventory question could lose the intended inventory frame. +2. A later same-date follow-up could then reuse the wrong active family. +3. A selected-object follow-up could then trigger a generic clarification template instead of continuing the object thread. +4. A recap question could then summarize the wrong or incomplete thread. +5. Reply shaping could present this as a confident-looking business answer or a polished template. + +That chain is why the regressions felt broad and sudden. + +The system was no longer failing only at route detection. + +It was failing at cross-layer state coherence. + +## The Best Current Architectural Reading Of "When" + +The best current reading is: + +- the foundations did not collapse during the early baseline and layer-separation work; +- the visible regression wave begins during the late pre-expansion extraction and stabilization passes; +- specifically when high-pressure families were extracted from the old central bodies, but the saved-session runtime path still relied on distributed priority decisions. + +In practical turnaround terms, the break appears after the project had already reached the following state: + +- policy owners are real; +- truth and coverage/evidence contracts are real; +- `assistantService` is partially decomposed; +- `resolveAddressIntent()` is partially extracted; +- `composeStage` is partially extracted; +- AGENT replay exists; +- but continuity and business-first arbitration are still spread across multiple owners and legacy remnants. + +This is why the regression was not visible as "one bad refactor." + +It was the cumulative point where enough ownership moved to break the old implicit coherence, while not enough of the new explicit coherence had been installed yet. + +## The Four Structural Fault Lines + +### 1. Continuity Arbitration Split Across Layers + +The biggest fault line is that continuity is not yet governed from one place. + +It is currently spread across: + +- intent signal owners; +- route policy; +- transition policy; +- decompose/runtime stages; +- recap policy; +- coordinator glue in `assistantService`. + +This means the answer to questions like these is not always decided by one authoritative contract: + +- are we still inside the same root frame? +- are we still working on the selected object? +- do we carry the same date? +- do we preserve organization scope? +- should we recap facts or continue the last action thread? + +When these decisions are distributed, mixed sessions become fragile. + +### 2. Saved-Session Runtime Was Less Protected Than Narrow Packs + +The second fault line is acceptance asymmetry. + +The project got much better at: + +- targeted truth harnesses; +- narrow scenario packs; +- exact-lane semantic acceptance; +- seam tests around extracted owners. + +But the broader saved-user-session runtime path remained less protected. + +That path is exactly where real users mix: + +- smalltalk; +- company scope; +- counterparty questions; +- inventory pivots; +- selected-object follow-ups; +- recap; +- account analytics. + +So the architecture improved in slices faster than the real mixed runtime was being revalidated. + +### 3. Clarification Logic Became Too Template-Strong + +The third fault line is that clarification logic gained too much priority in ambiguous moments. + +This shows up when the assistant does one of these: + +- asks for company selection again even though the active thread should already be sufficient; +- falls into "not supported / clarify company" language even when a business route previously existed; +- prefers a safe template over context continuation. + +This is not the clarification policy being "wrong" in principle. + +It is clarification winning too often because continuity confidence is not stably preserved. + +### 4. Recap And User-Facing Packaging Became More Structured Than Truthful + +The fourth fault line is answer-shape drift. + +During extraction, recap and packaging became more explicit, which is good. + +But in the unstable hybrid phase, explicit packaging sometimes outran explicit truth assembly. + +That produces the most dangerous kind of regression: + +- not a crash; +- not a blank answer; +- but a smooth, template-like answer that sounds coherent while not accurately reflecting what was truly established in the session. + +This is why recap hallucination and canned follow-up language became such an important warning sign. + +## Surface Symptoms That Are Not The Root Cause + +The following things look like the problem, but they are mostly surface symptoms: + +- a specific wording like `какие остатки на складе на март 2021`; +- one particular company like `Альтернатива Плюс`; +- one particular counterparty like `Чепурнов`; +- one particular selected object like `Столешница 600*3050*26 альмандин`; +- one particular recap phrase like `а что мы уже выяснили по этой позиции?` + +These questions exposed the break. + +They did not create it. + +The root cause is not "we forgot a phrase." + +The root cause is that the runtime stopped having one coherent authority over: + +- frame continuity; +- scope continuity; +- selected-object continuity; +- clarification precedence; +- recap truthfulness; +- and business-first answer packaging. + +## Why Full Rollback Is Not The Right Default + +A full rollback would restore some older behavioral coupling, but it would also throw away real architecture gains from turnaround `11`. + +That would hide the breakpoint, not resolve it. + +The correct reading is: + +- extraction itself is not the mistake; +- incomplete extraction with split continuity ownership is the mistake. + +So the right response is not "go back to the old monolith." + +The right response is: + +- finish the continuity contract; +- reduce hybrid priority conflicts; +- prove behavior on real mixed saved-session runs; +- only roll back isolated seams if a specific extraction is shown to be unrecoverable. + +## What Must Be Fixed To Exit This Breakpoint + +The project does not need general beautification to recover. + +It needs a narrow set of structural completions. + +### 1. One Source Of Truth For Runtime Continuity + +The system needs one authoritative contract for: + +- active root frame; +- selected object; +- active company scope; +- temporal carryover; +- same-date restore; +- recap vs continue behavior. + +Until that exists, mixed sessions will remain vulnerable to drift between policies. + +### 2. Clarification Must Become A Last Meaningful Step, Not A Default Escape Hatch + +The system should ask for company clarification when the business frame is genuinely underdetermined. + +It should not do so simply because cross-layer continuity was lost. + +### 3. Recap Must Be Built From Verified Session Facts + +Recap must summarize what was truly established: + +- selected object; +- supplier; +- purchase date; +- documents found; +- organization chosen; +- date boundary actually used. + +If these facts are missing, the answer must say so honestly. + +It must not backfill a neat template. + +### 4. Saved-Session Mixed Runtime Must Become A First-Class Acceptance Gate + +Narrow harnesses remain useful, but they are not enough. + +The mixed runtime path has to become a primary architectural acceptance gate before mass domain expansion. + +## Final Conclusion + +Everything did not break because one feature was removed. + +Everything started to break when the project crossed from old monolithic coherence into hybrid extracted coherence without yet finishing the new runtime contract. + +That is the breakpoint of turnaround `11`. + +In one sentence: + +the project broke at the moment when decision ownership became distributed faster than continuity ownership became explicit. + +That is the main architectural fact to remember before continuing the final stabilization passes. diff --git a/docs/ARCH/11 - architecture_turnaround/11 - continuity_stabilization_plan_2026-04-17.md b/docs/ARCH/11 - architecture_turnaround/11 - continuity_stabilization_plan_2026-04-17.md new file mode 100644 index 0000000..e15638a --- /dev/null +++ b/docs/ARCH/11 - architecture_turnaround/11 - continuity_stabilization_plan_2026-04-17.md @@ -0,0 +1,157 @@ +# 11 - Continuity Stabilization Plan (2026-04-17) + +## Purpose + +This note defines the recovery plan for the current pre-expansion breakpoint. + +The goal is not to patch individual failing prompts. + +The goal is to finish the missing runtime authority that should govern mixed live sessions after the turnaround `11` owner extractions. + +## Current Reading + +The strongest current evidence is: + +- narrow and company-selected scenarios can pass end-to-end; +- mixed saved-session runtime still fails on root inventory, selected-object continuity, same-date restore, and cross-domain same-date pivot; +- therefore the architecture is not missing only routes; +- it is missing one governing continuity authority. + +In one sentence: + +- decision ownership became distributed faster than continuity ownership became explicit. + +## What This Plan Stabilizes + +This plan is specifically about one system object: + +- `assistant_session_continuity_v1` + +That object should become the shared authority for: + +- active root frame +- active selected object +- active organization scope +- active date scope +- active clarification state +- active answer object / reusable bundle +- recap source of truth + +## Target Runtime Rule + +Before any of the following decisions are made: + +- route arbitration +- company clarification +- selected-object follow-up routing +- same-date restore +- recap answer generation + +the runtime must first resolve one continuity snapshot for the active session. + +Those downstream owners may interpret the snapshot differently, but they must not reconstruct competing versions of the session state independently. + +## Immediate Passes + +### Pass A. Install shared continuity snapshot + +Scope: + +- create one shared continuity resolver for session items and grounded address context; +- centralize extraction of active item, organization, date, root frame, and recap-eligible grounded context; +- stop allowing recap and adjacent meta logic to build over ungrounded or clarification-only state. + +Exit condition: + +- recap can only trigger over verified grounded address context; +- selected-object memory cannot be reconstructed from failed clarification turns; +- route and memory layers consume the same continuity snapshot. + +### Pass B. Reduce clarification priority conflicts + +Scope: + +- move clarification behind restored continuity when the business frame is already sufficient; +- stop repeated company clarification from interrupting same-family continuation; +- make clarification state explicit and resumable instead of re-discovered ad hoc. + +Exit condition: + +- repeated clarification no longer appears after adjacent grounded business answers in the same thread; +- selected-object and same-date follow-ups stop falling into generic company templates. + +### Pass C. Re-ground recap and answer packaging + +Scope: + +- recap must summarize verified session facts only; +- answer packaging must not sound more certain than truth assembly; +- technical scaffolding must not leak into the top block of user-facing answers. + +Exit condition: + +- recap cannot claim supplier/date/document facts that were never grounded; +- meta boundary replies no longer expose `MCP`, `read-only`, route ids, capability ids, or debug labels; +- user-facing top blocks remain business-first. + +### Pass D. Lock mixed runtime as the primary gate + +Scope: + +- promote mixed saved-session runtime to the main architecture gate before domain expansion; +- keep narrow harnesses and seam tests, but do not let them overrule mixed replay; +- evaluate critical user paths rather than isolated route green status. + +Exit condition: + +- the core mixed replay is green on direct answer, selected-object continuity, same-date carryover, recap truthfulness, and technical cleanliness; +- no unresolved `P0` remains on the primary user path. + +## Anti-Goals + +This stabilization pass is not: + +- a rollback to the old monolith +- a case-by-case regex patch sweep +- a prompt-only wording cleanup +- a UI-only improvement pass + +## Practical Sequence + +1. Finish the continuity snapshot and wire it into the hot route / recap path. +2. Rework clarification precedence so it becomes a last meaningful step. +3. Harden recap and boundary presentation against ungrounded and technical output. +4. Rerun the mixed AGENT replay until the critical continuity edges are green. +5. Only then continue deeper intent extraction and wider domain expansion. + +## Current Pass Status + +Completed in the current working pass: + +- shared continuity snapshot is already wired into recap and adjacent route memory logic; +- grounded address history can now restore `active organization scope` instead of depending only on explicit company-selection metadata; +- early organization clarification no longer outranks item-focused inventory follow-up paths when the session already carries a strong object frame; +- meta boundary replies were already cleaned from technical `MCP` / `read-only` leakage. +- early `non_domain` arbitration no longer suppresses a positive L0 address-lane decision for colloquial but supported exact routes; +- foreign-accounting pivots over inventory drilldown now preserve root-scoped carryover instead of dropping continuity before root-frame sanitation; +- the wide `assistantAddressFollowupContext` regression pack is green again, including month-only VAT follow-up and inventory -> VAT pivot sanitation. +- counterparty document root wording is now recovered through unicode-safe exact signals instead of depending on mojibake-sensitive legacy phrases; +- declined Russian account wording like `по счёту 60` now restores account scope inside polarity/runtime guards instead of collapsing into `other_numeric`; +- exact address intents can now stay in the address lane even if the semantic guard overflags deep investigation without an actual investigative user request; +- live replay `address_truth_harness_phase7_meta_domain_mix_live_20260417_post_arch_fix_rerun2` is accepted end-to-end with `14/14` steps green, including the previously broken `step_01_counterparty_documents` and `step_04_open_items_account_60`. + +Still open after this pass: + +- mixed continuity is now strong enough for the current phase7 gate, but it still needs broader saved-session proof before domain expansion can be treated as low-risk; +- the next architecture pass should move from one repaired mixed replay to a wider saved-session set and multi-domain acceptance pack; +- remaining work should focus on keeping the unified continuity authority stable under new real user paths, not on wording-only polish or isolated route greens. + +## Ready Signal + +The project can leave the current breakpoint when: + +- mixed live sessions no longer depend on distributed guesswork about active context; +- clarification does not outrank valid restored business continuity; +- recap is grounded and business-useful; +- technical scaffolding is removed from user-facing meta answers; +- the primary mixed replay is green for the real user path, not only for narrow packs. diff --git a/docs/ARCH/11 - architecture_turnaround/README.md b/docs/ARCH/11 - architecture_turnaround/README.md index 82699fa..b3d9c7c 100644 --- a/docs/ARCH/11 - architecture_turnaround/README.md +++ b/docs/ARCH/11 - architecture_turnaround/README.md @@ -26,12 +26,15 @@ This package answers the next question: 6. [06 - phase_acceptance_matrix.md](./06%20-%20phase_acceptance_matrix.md) 7. [07 - external_reference_appendix.md](./07%20-%20external_reference_appendix.md) 8. [08 - current_status_audit_2026-04-17.md](./08%20-%20current_status_audit_2026-04-17.md) +9. [09 - pre_expansion_cut_2026-04-17.md](./09%20-%20pre_expansion_cut_2026-04-17.md) +10. [10 - regression_breakpoint_analysis_2026-04-17.md](./10%20-%20regression_breakpoint_analysis_2026-04-17.md) +11. [11 - continuity_stabilization_plan_2026-04-17.md](./11%20-%20continuity_stabilization_plan_2026-04-17.md) ## Current Status Snapshot (2026-04-17) This package is no longer planning-only. -It now documents a turnaround that is already partially operational in code: +It now documents a turnaround that is already operational in code but still inside a pre-expansion stabilization breakpoint: - route, transition, boundary, meta, memory, and provider policy owners exist as separate modules; - exact-lane truth and coverage/evidence contracts exist as explicit runtime artifacts; @@ -40,16 +43,25 @@ It now documents a turnaround that is already partially operational in code: Current honest status: -- estimated overall completion: `~85%` -- graph snapshot after latest rebuild: `5228 nodes`, `11338 edges`, `133 communities` +- turnaround implementation progress: `~88%` +- pre-expansion readiness: `~62%` +- graph snapshot after latest rebuild: `5312 nodes`, `11408 edges`, `136 communities` +- current breakpoint: + - mixed saved-session runtime still fails on continuity-critical edges; + - clarification can outrank restored business context; + - recap and user-facing packaging can remain smoother than the actual grounded thread. - main remaining architectural pressure: - - `resolveAddressIntent()` - - `composeFactualReply()` + - no single authoritative continuity contract for live mixed sessions - residual coordinator/legacy pressure inside `assistantService.ts` + - central domain-intent pressure inside `resolveAddressIntent()` + - remaining answer-semantics pressure inside `composeStage.ts` For the detailed audit, current percentages, and remaining debt, read: - [08 - current_status_audit_2026-04-17.md](./08%20-%20current_status_audit_2026-04-17.md) +- [09 - pre_expansion_cut_2026-04-17.md](./09%20-%20pre_expansion_cut_2026-04-17.md) +- [10 - regression_breakpoint_analysis_2026-04-17.md](./10%20-%20regression_breakpoint_analysis_2026-04-17.md) +- [11 - continuity_stabilization_plan_2026-04-17.md](./11%20-%20continuity_stabilization_plan_2026-04-17.md) ## Architectural Objects Of Planning @@ -76,6 +88,9 @@ Read in this order: 7. `06 - phase_acceptance_matrix.md` 8. `07 - external_reference_appendix.md` 9. `08 - current_status_audit_2026-04-17.md` +10. `09 - pre_expansion_cut_2026-04-17.md` +11. `10 - regression_breakpoint_analysis_2026-04-17.md` +12. `11 - continuity_stabilization_plan_2026-04-17.md` ## Planning Rules @@ -95,10 +110,12 @@ and start being described as: - "a stateful exact-data assistant with explicit transition contracts and isolated truth gating." -As of `2026-04-17`, the project is already materially closer to the target description, but not fully there yet. +As of `2026-04-17`, the project is already materially closer to the target description, but mixed-session continuity is still not governed by one runtime authority. The biggest remaining blockers are: +- split continuity ownership across route / transition / recap / coordinator glue; +- clarification precedence still too strong in mixed sessions; - residual `assistantService` overload; -- central answer-shaping pressure in `composeFactualReply()`; -- central intent pressure in `resolveAddressIntent()`. +- central intent pressure in `resolveAddressIntent()`; +- remaining answer-semantics pressure in `composeStage.ts`. diff --git a/llm_normalizer/backend/dist/services/addressCounterpartyIntentSignals.js b/llm_normalizer/backend/dist/services/addressCounterpartyIntentSignals.js index b76a0af..14ff153 100644 --- a/llm_normalizer/backend/dist/services/addressCounterpartyIntentSignals.js +++ b/llm_normalizer/backend/dist/services/addressCounterpartyIntentSignals.js @@ -1,7 +1,112 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.resolveCounterpartyAddressIntent = resolveCounterpartyAddressIntent; +function hasUnicodeOpenItemsAccountSignal(text) { + const normalized = String(text ?? "").toLowerCase(); + if (!normalized) { + return false; + } + return (/(?:\u0445\u0432\u043e\u0441\u0442|\u0434\u043e\u043b\u0433|\u043d\u0435\u0437\u0430\u043a\u0440\u044b\u0442|\u0432\u0438\u0441)/iu.test(normalized) && + /(?:\u0441\u0447(?:\u0435|\u0451)\u0442(?:\u0430|\u0443|\u043e\u043c|\u043e\u0432)?\s*(?:\u2116|#)?\s*(?:60|62|76)(?:\.\d{2})?|(?:60|62|76)(?:\.\d{2})?\s*(?:\u0441\u0447(?:\u0435|\u0451)\u0442(?:\u0430|\u0443|\u043e\u043c|\u043e\u0432)?))/iu.test(normalized)); +} +function hasUnicodeCounterpartyShipmentItemFlowSignal(text) { + return /(?:\u043e\u0442\u0433\u0440\u0443\u0436\u0430\u043b)/iu.test(text) && + /(?:\u0442\u043e\u0432\u0430\u0440|\u0443\u0441\u043b\u0443\u0433|\u043f\u043e\u0437\u0438\u0446\u0438|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440)/iu.test(text); +} +function hasUnicodePassiveShipmentByCounterpartySignal(text) { + return (/(?:\u043e\u0442\u0433\u0440\u0443\u0436\u0435\u043d(?:\u044b|\u043e|\u0430)?)/iu.test(text) && + /(?:\u0442\u043e\u0432\u0430\u0440|\u0443\u0441\u043b\u0443\u0433|\u043f\u043e\u0437\u0438\u0446\u0438|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440)/iu.test(text) && + /(?:\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u043e\u043c)/iu.test(text)); +} +function hasUnicodeDocumentSignal(text) { + return /(?:\u0434\u043e\u043a(?:\u0443\u043c\u0435\u043d\u0442(?:\u044b|\u043e\u0432|\u0430\u043c|\u0430\u043c\u0438|\u0430\u0445)?|\u0438)?|\u0434\u043e\u043a\u0438)(?=$|[\s,.;:!?()])/iu.test(text); +} +function hasUnicodeLikelyCounterpartyAfterBy(text) { + const match = text.match(/(?:^|[\s(])\u043f\u043e\s+([\p{L}\d][\p{L}\d._-]{1,})(?=$|[\s,.;:!?()])/iu); + if (!match) { + return false; + } + const token = String(match[1] ?? "").toLowerCase(); + if (!token) { + return false; + } + const stopWords = new Set([ + "\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0443", + "\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430", + "\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u043e\u043c", + "\u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438", + "\u043a\u043e\u043c\u043f\u0430\u043d\u0438\u044e", + "\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u0438", + "\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044e", + "\u0434\u043e\u0433\u043e\u0432\u043e\u0440\u0443", + "\u0434\u043e\u0433\u043e\u0432\u043e\u0440\u043e\u043c", + "\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442\u0443", + "\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442\u043e\u043c", + "\u0441\u0447\u0435\u0442\u0443", + "\u0441\u0447\u0451\u0442\u0443", + "\u0441\u0447\u0435\u0442\u043e\u043c", + "\u0441\u0447\u0451\u0442\u043e\u043c", + "\u0434\u0430\u0442\u0435", + "\u0434\u0430\u0442\u0443", + "\u043f\u0435\u0440\u0438\u043e\u0434\u0443", + "\u043f\u0435\u0440\u0438\u043e\u0434", + "\u0441\u043a\u043b\u0430\u0434\u0443", + "\u0441\u043a\u043b\u0430\u0434\u0435", + "\u0431\u0430\u043d\u043a\u0443", + "\u0431\u0430\u043d\u043a\u0435", + "\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u043c", + "\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b", + "\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0443" + ]); + return !stopWords.has(token); +} function resolveCounterpartyAddressIntent(text, deps) { + if (hasUnicodeOpenItemsAccountSignal(text)) { + return { + intent: "open_items_by_counterparty_or_contract", + confidence: "medium", + reasons: ["open_items_signal_detected"] + }; + } + if (/(?:хвост|долг|незакрыт|вис)/iu.test(text) && + /(?:счету?|сч[её]ту?)\s*60|60\s*(?:счет|сч[её]т)/iu.test(text)) { + return { + intent: "open_items_by_counterparty_or_contract", + confidence: "medium", + reasons: ["open_items_signal_detected"] + }; + } + if (/отгружал/iu.test(text) && + /(?:товар|услуг|позици|номенклатур)/iu.test(text)) { + return { + intent: "list_documents_by_counterparty", + confidence: "medium", + reasons: ["counterparty_item_flow_signal_detected"] + }; + } + if (/отгружен(?:ы|о|а)?/iu.test(text) && + /(?:товар|услуг|позици|номенклатур)/iu.test(text) && + /контрагентом/iu.test(text)) { + return { + intent: "list_documents_by_counterparty", + confidence: "medium", + reasons: ["counterparty_item_flow_signal_detected"] + }; + } + if (hasUnicodeCounterpartyShipmentItemFlowSignal(text)) { + return { + intent: "list_documents_by_counterparty", + confidence: "medium", + reasons: ["counterparty_item_flow_signal_detected"] + }; + } + if (hasUnicodePassiveShipmentByCounterpartySignal(text)) { + return { + intent: "list_documents_by_counterparty", + confidence: "medium", + reasons: ["counterparty_item_flow_signal_detected"] + }; + } if (deps.hasOpenContractsListSignal(text)) { return { intent: "open_contracts_confirmed_as_of_date", @@ -124,6 +229,13 @@ function resolveCounterpartyAddressIntent(text, deps) { reasons: ["bank_ops_by_counterparty_signal_detected"] }; } + if (hasUnicodeDocumentSignal(text) && hasUnicodeLikelyCounterpartyAfterBy(text)) { + return { + intent: "list_documents_by_counterparty", + confidence: "medium", + reasons: ["documents_by_counterparty_signal_detected"] + }; + } if ((deps.hasAny(text, deps.documentsByCounterpartyHints) || deps.hasCounterpartyShipmentItemFlowSignal(text)) && (deps.hasPartyAnchorMention(text) || deps.hasLooseByAnchorMention(text) || diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index 5ea95d7..79f2cd5 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -844,6 +844,17 @@ function extractShipmentCounterpartyValue(text) { } return candidate; } +function extractInstrumentalCounterpartyValue(text) { + const match = String(text ?? "").match(/(?:контрагентом|поставщиком|клиентом|заказчиком)\s+([\p{L}][\p{L}\p{N}._-]{1,})(?=[\s,.;:!?)]|$)/iu); + if (!match) { + return undefined; + } + const candidate = cleanupAnchorValue(String(match[1] ?? "")); + if (!candidate || !isLikelyCounterpartyToken(candidate)) { + return undefined; + } + return candidate; +} function hasExplicitAccountCue(text) { return /(?:сч[её]т|счет|account|acct)/iu.test(String(text ?? "")); } @@ -1508,6 +1519,17 @@ function extractAddressFilters(userMessage, intent) { warnings.push("counterparty_anchor_derived_from_shipment_phrase"); } } + if (!filters.counterparty && + allowGenericCounterpartyAnchor && + (intent === "list_documents_by_counterparty" || + intent === "bank_operations_by_counterparty" || + intent === "list_contracts_by_counterparty")) { + const instrumentalCounterparty = extractInstrumentalCounterpartyValue(text); + if (instrumentalCounterparty) { + filters.counterparty = cleanupAnchorValue(instrumentalCounterparty); + warnings.push("counterparty_anchor_derived_from_instrumental_phrase"); + } + } if (!filters.counterparty && allowGenericCounterpartyAnchor && (intent === "list_documents_by_counterparty" || diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index def6a44..155589c 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -5,46 +5,46 @@ const addressCounterpartyIntentSignals_1 = require("./addressCounterpartyIntentS const addressInventoryIntentSignals_1 = require("./addressInventoryIntentSignals"); const inventoryLifecycleCueHelpers_1 = require("./inventoryLifecycleCueHelpers"); const RECEIVABLES_STRONG = [ - "кто должен нам", - "кто нам должен", - "кто нам должэн", - "нам должны", - "нам должен", - "нам должэны", + "кто должен нам", + "кто нам должен", + "кто нам должэн", + "нам должны", + "нам должен", + "нам должэны", "who owes us", "receivable", "receivables", "debtor", "debtors", - "дебитор", - "дебиторск" + "дебитор", + "дебиторск" ]; const PAYABLES_STRONG = [ - "кому должны мы", - "кому должэны мы", - "кому мы должны", - "кому мы должэны", - "мы должны", - "мы должэны", + "РєРѕРјСѓ должны РјС‹", + "РєРѕРјСѓ должэны РјС‹", + "РєРѕРјСѓ РјС‹ должны", + "РєРѕРјСѓ РјС‹ должэны", + "РјС‹ должны", + "РјС‹ должэны", "who we owe", "payable", "payables", "creditor", "creditors", - "кредитор", - "кредиторск" + "кредитор", + "кредиторск" ]; const ACCOUNT_BALANCE_HINTS = [ "account balance", "balance by account", "saldo", - "баланс", - "остаток по счет", - "сальдо по счет", - "по счету", - "что на счете", - "что на счёте", - "на конец" + "баланс", + "остаток РїРѕ счет", + "сальдо РїРѕ счет", + "РїРѕ счету", + "что РЅР° счете", + "что РЅР° счёте", + "РЅР° конец" ]; const DOCUMENTS_FORMING_BALANCE_HINTS = [ "documents forming balance", @@ -54,30 +54,30 @@ const DOCUMENTS_FORMING_BALANCE_HINTS = [ "balance documents", "documents for balance", "which documents form balance", - "из чего состоит остаток", - "какие документы формируют остаток", - "раскрой остаток по документам", - "документы под остатком" + "РёР· чего состоит остаток", + "какие документы формируют остаток", + "раскрой остаток РїРѕ документам", + "документы РїРѕРґ остатком" ]; const OPEN_CONTRACTS_HINTS = [ "open contracts", "unclosed contracts", - "незакрыт", - "не закрыт", - "открыт", - "договор", - "контракт" + "незакрыт", + "РЅРµ закрыт", + "открыт", + "РґРѕРіРѕРІРѕСЂ", + "контракт" ]; const OPEN_ITEMS_HINTS = [ "open items", "unclosed items", - "хвост", - "висят", - "незакрыт", - "открыт", - "долг", - "задолж", - "позици" + "С…РІРѕСЃС‚", + "РІРёСЃСЏС‚", + "незакрыт", + "открыт", + "долг", + "задолж", + "позици" ]; const DOCUMENTS_BY_COUNTERPARTY_HINTS = [ "documents by counterparty", @@ -89,17 +89,17 @@ const DOCUMENTS_BY_COUNTERPARTY_HINTS = [ "documents by partner", "show documents by counterparty", "list documents by counterparty", - "документы по", - "доступные документы", - "список документов", - "документ", - "доки", - "доки по", - "док по", + "документы РїРѕ", + "доступные документы", + "СЃРїРёСЃРѕРє документов", + "документ", + "РґРѕРєРё", + "РґРѕРєРё РїРѕ", + "РґРѕРє РїРѕ", "doki", "docy", "doci", - "по контрагент" + "РїРѕ контрагент" ]; const BANK_OPERATIONS_BY_COUNTERPARTY_HINTS = [ "bank operations by counterparty", @@ -112,34 +112,34 @@ const BANK_OPERATIONS_BY_COUNTERPARTY_HINTS = [ "bank ops", "bank oper", "transactions by counterparty", - "транзак", - "банк", - "банков", - "по банку", - "опер", - "выписк", - "платеж", - "платёж", - "оплат", - "списан", - "списани", - "поступлен", - "поступлени", - "движени" + "транзак", + "банк", + "банков", + "РїРѕ банку", + "опер", + "выписк", + "платеж", + "платёж", + "оплат", + "списан", + "списани", + "поступлен", + "поступлени", + "движени" ]; const DOCUMENTS_BY_CONTRACT_HINTS = [ "documents by contract", "docs by contract", "show documents by contract", "list documents by contract", - "документы по договору", - "доки по договору", - "док по договору", - "документы договор", - "договор", - "документы по контракту", - "доки по контракту", - "контракт" + "документы РїРѕ РґРѕРіРѕРІРѕСЂСѓ", + "РґРѕРєРё РїРѕ РґРѕРіРѕРІРѕСЂСѓ", + "РґРѕРє РїРѕ РґРѕРіРѕРІРѕСЂСѓ", + "документы РґРѕРіРѕРІРѕСЂ", + "РґРѕРіРѕРІРѕСЂ", + "документы РїРѕ контракту", + "РґРѕРєРё РїРѕ контракту", + "контракт" ]; const BANK_OPERATIONS_BY_CONTRACT_HINTS = [ "bank operations by contract", @@ -147,26 +147,26 @@ const BANK_OPERATIONS_BY_CONTRACT_HINTS = [ "payment orders by contract", "transactions by contract", "bank ops by contract", - "банковские операции по договору", - "платежи по договору", - "выписка по договору", - "банковские операции по контракту", - "платежи по контракту", - "выписка по контракту" + "банковские операции РїРѕ РґРѕРіРѕРІРѕСЂСѓ", + "платежи РїРѕ РґРѕРіРѕРІРѕСЂСѓ", + "выписка РїРѕ РґРѕРіРѕРІРѕСЂСѓ", + "банковские операции РїРѕ контракту", + "платежи РїРѕ контракту", + "выписка РїРѕ контракту" ]; const BANK_OPERATION_CORE_HINTS = [ - "банк", - "банков", - "операц", - "опер", - "выписк", - "платеж", - "платёж", - "оплат", - "списан", - "поступлен", - "движени", - "транзак", + "банк", + "банков", + "операц", + "опер", + "выписк", + "платеж", + "платёж", + "оплат", + "списан", + "поступлен", + "движени", + "транзак", "bank", "payment", "payments", @@ -176,24 +176,24 @@ const BANK_OPERATION_CORE_HINTS = [ "wire" ]; const PERIOD_COVERAGE_PROFILE_HINTS = [ - "за какие годы", - "за какие года", - "в базе есть данные", - "покрытие периодов", - "диапазон лет", - "профиль данных", - "самый активный год", - "самый активный месяц", - "самый пассивный год", - "самый пассивный месяц", - "наименее активный год", - "наименее активный месяц", - "минимум документов по году", - "минимум операций по месяцу", - "год с минимальным количеством документов", - "месяц с минимальным количеством операций", - "активный год по количеству документов", - "активный месяц по количеству операций", + "Р·Р° какие РіРѕРґС‹", + "Р·Р° какие РіРѕРґР°", + "РІ базе есть данные", + "покрытие периодов", + "диапазон лет", + "профиль данных", + "самый активный РіРѕРґ", + "самый активный месяц", + "самый пассивный РіРѕРґ", + "самый пассивный месяц", + "наименее активный РіРѕРґ", + "наименее активный месяц", + "РјРёРЅРёРјСѓРј документов РїРѕ РіРѕРґСѓ", + "РјРёРЅРёРјСѓРј операций РїРѕ месяцу", + "РіРѕРґ СЃ минимальным количеством документов", + "месяц СЃ минимальным количеством операций", + "активный РіРѕРґ РїРѕ количеству документов", + "активный месяц РїРѕ количеству операций", "most active year", "most active month", "least active year", @@ -202,194 +202,194 @@ const PERIOD_COVERAGE_PROFILE_HINTS = [ "data coverage" ]; const DOCUMENT_TYPE_AND_ACCOUNT_SECTION_PROFILE_HINTS = [ - "типы документов", - "типы доков", - "документы чаще всего", - "документы реже всего", - "редкие типы документов", - "наименее используемые типы документов", - "частые типы документов", - "сводка по типам документов", - "доля типов документов", - "разделы учета", - "разделы учёта", - "наиболее заполнены", - "наименее заполнены", - "почти не используются", + "типы документов", + "типы РґРѕРєРѕРІ", + "документы чаще всего", + "документы реже всего", + "редкие типы документов", + "наименее используемые типы документов", + "частые типы документов", + "СЃРІРѕРґРєР° РїРѕ типам документов", + "доля типов документов", + "разделы учета", + "разделы учёта", + "наиболее заполнены", + "наименее заполнены", + "почти РЅРµ используются", "account section", "document types usage", "document type profile" ]; const COUNTERPARTY_POPULATION_AND_ROLES_HINTS = [ - "сколько всего контрагентов", - "сколько уникальных контрагентов", - "сколько контрагентов в базе", - "сколько заказчиков", - "сколько поставщиков", - "сколько клиентов", - "сколько покупателей", - "скока всего контрагентов", - "скока уникальных контрагентов", - "скока контрагентов в базе", - "скока заказчиков", - "скока поставщиков", - "скока клиентов", - "скока покупателей", - "скок контрагентов", - "скок контрагентов в базе", - "скок заказчиков", - "скок поставщиков", - "скок клиентов", - "скок покупателей", - "сколько смешанных контрагентов", - "типы контрагентов", - "разбей контрагентов", - "раздели контрагентов", + "сколько всего контрагентов", + "сколько уникальных контрагентов", + "сколько контрагентов РІ базе", + "сколько заказчиков", + "сколько поставщиков", + "сколько клиентов", + "сколько покупателей", + "СЃРєРѕРєР° всего контрагентов", + "СЃРєРѕРєР° уникальных контрагентов", + "СЃРєРѕРєР° контрагентов РІ базе", + "СЃРєРѕРєР° заказчиков", + "СЃРєРѕРєР° поставщиков", + "СЃРєРѕРєР° клиентов", + "СЃРєРѕРєР° покупателей", + "СЃРєРѕРє контрагентов", + "СЃРєРѕРє контрагентов РІ базе", + "СЃРєРѕРє заказчиков", + "СЃРєРѕРє поставщиков", + "СЃРєРѕРє клиентов", + "СЃРєРѕРє покупателей", + "сколько смешанных контрагентов", + "типы контрагентов", + "разбей контрагентов", + "раздели контрагентов", "counterparty population", "counterparty roles", "customer supplier split" ]; const COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS = [ - "какие заказчики работали", - "какие заказчики активны", - "какие клиенты работали", - "какие клиенты активны", - "какие контрагенты работали", - "какие поставщики работали", - "список заказчиков", - "список клиентов", - "список заказчиков за все время", - "список клиентов за все время", - "список активных заказчиков", - "список активных клиентов", - "новые заказчики", - "новые клиенты", - "новые контрагенты", - "впервые в", - "кто исчез", - "кто ушел", - "кто ушёл", - "только один раз", - "дольше всего", - "дольше всех", - "долгоживущие контрагенты", - "регулярные поставщики", - "эпизодические поставщики", - "давно не использовались поставщики", - "всех заков", - "кто был активен", - "потом отвалился", - "ровно один раз", - "и пропал", - "самые старые по сотрудничеству", - "разбей поставщиков на регуляр и разовые", - "кто новые в этом году", + "какие заказчики работали", + "какие заказчики активны", + "какие клиенты работали", + "какие клиенты активны", + "какие контрагенты работали", + "какие поставщики работали", + "СЃРїРёСЃРѕРє заказчиков", + "СЃРїРёСЃРѕРє клиентов", + "СЃРїРёСЃРѕРє заказчиков Р·Р° РІСЃРµ время", + "СЃРїРёСЃРѕРє клиентов Р·Р° РІСЃРµ время", + "СЃРїРёСЃРѕРє активных заказчиков", + "СЃРїРёСЃРѕРє активных клиентов", + "новые заказчики", + "новые клиенты", + "новые контрагенты", + "впервые РІ", + "кто исчез", + "кто ушел", + "кто ушёл", + "только РѕРґРёРЅ раз", + "дольше всего", + "дольше всех", + "долгоживущие контрагенты", + "регулярные поставщики", + "эпизодические поставщики", + "давно РЅРµ использовались поставщики", + "всех заков", + "кто был активен", + "потом отвалился", + "СЂРѕРІРЅРѕ РѕРґРёРЅ раз", + "Рё пропал", + "самые старые РїРѕ сотрудничеству", + "разбей поставщиков РЅР° регуляр Рё разовые", + "кто новые РІ этом РіРѕРґСѓ", "active customers", - "сколько лет активности в базе", - "сколько лет активности в 1с", - "сколько лет в базе 1с", - "какой первый платеж", - "какое первое поступление", - "когда была первая активность", - "когда была последняя активность", - "первая активность в базе", - "последняя активность в базе", + "сколько лет активности РІ базе", + "сколько лет активности РІ 1СЃ", + "сколько лет РІ базе 1СЃ", + "какой первый платеж", + "какое первое поступление", + "РєРѕРіРґР° была первая активность", + "РєРѕРіРґР° была последняя активность", + "первая активность РІ базе", + "последняя активность РІ базе", "customer activity list", "counterparty lifecycle" ]; const CONTRACT_USAGE_OVERVIEW_HINTS = [ - "сколько всего договоров", - "сколько договоров заведено", - "сколько договоров в базе", - "сколько договоров использовались", - "сколько договоров использовалось", - "договоры total vs used", - "обзор договорной базы", - "договорная база total used", - "неиспользуемые договоры", - "давно не использовались договоры", - "мертвые договоры", - "мёртвые договоры", + "сколько всего РґРѕРіРѕРІРѕСЂРѕРІ", + "сколько РґРѕРіРѕРІРѕСЂРѕРІ заведено", + "сколько РґРѕРіРѕРІРѕСЂРѕРІ РІ базе", + "сколько РґРѕРіРѕРІРѕСЂРѕРІ использовались", + "сколько РґРѕРіРѕРІРѕСЂРѕРІ использовалось", + "РґРѕРіРѕРІРѕСЂС‹ total vs used", + "РѕР±Р·РѕСЂ РґРѕРіРѕРІРѕСЂРЅРѕР№ базы", + "договорная база total used", + "неиспользуемые РґРѕРіРѕРІРѕСЂС‹", + "давно РЅРµ использовались РґРѕРіРѕРІРѕСЂС‹", + "мертвые РґРѕРіРѕРІРѕСЂС‹", + "мёртвые РґРѕРіРѕРІРѕСЂС‹", "stale contracts", "unused contracts", "contracts total used", "contract usage overview" ]; const CUSTOMER_REVENUE_AND_PAYMENTS_HINTS = [ - "самые доходные клиенты", - "самые доходные заказчики", - "самые ликвидные клиенты", - "самые ликвидные заказчики", - "самых ликвидних заказчиков", - "топ клиентов по сумме поступлений", - "топ заказчиков по сумме поступлений", - "кто больше всего принес денег", - "кто больше всего принёс денег", - "кто принес больше всего денег", - "кто принёс больше всего денег", - "кто нам больше денег принес", - "кто нам больше денег принёс", - "кто нам принес больше денег", - "кто нам принёс больше денег", - "кто нам больше всего занес", - "кто нам больше всего занёс", - "кто нам принес больше всего", - "кто нам принёс больше всего", - "кто платит чаще всего", - "средний чек клиентов", - "средний чек заказчиков", - "крупные сделки по поступлениям", - "маленькие сделки по поступлениям", + "самые доходные клиенты", + "самые доходные заказчики", + "самые ликвидные клиенты", + "самые ликвидные заказчики", + "самых ликвидних заказчиков", + "топ клиентов РїРѕ СЃСѓРјРјРµ поступлений", + "топ заказчиков РїРѕ СЃСѓРјРјРµ поступлений", + "кто больше всего принес денег", + "кто больше всего принёс денег", + "кто принес больше всего денег", + "кто принёс больше всего денег", + "кто нам больше денег принес", + "кто нам больше денег принёс", + "кто нам принес больше денег", + "кто нам принёс больше денег", + "кто нам больше всего занес", + "кто нам больше всего занёс", + "кто нам принес больше всего", + "кто нам принёс больше всего", + "кто платит чаще всего", + "средний чек клиентов", + "средний чек заказчиков", + "крупные сделки РїРѕ поступлениям", + "маленькие сделки РїРѕ поступлениям", "smallest deals by inflow", "largest deals by inflow", "top customers by inflow", "top customers by revenue" ]; const SUPPLIER_PAYOUTS_PROFILE_HINTS = [ - "топ поставщиков по сумме выплат", - "кому мы больше всего заплатили", - "кому ушло больше всего денег", - "кому мы больше всего сгрузили денег", - "поставщики по выплатам", - "поставщики по исходящим платежам", - "поставщики с максимальным числом выплат", - "крупные разовые выплаты поставщикам", + "топ поставщиков РїРѕ СЃСѓРјРјРµ выплат", + "РєРѕРјСѓ РјС‹ больше всего заплатили", + "РєРѕРјСѓ ушло больше всего денег", + "РєРѕРјСѓ РјС‹ больше всего сгрузили денег", + "поставщики РїРѕ выплатам", + "поставщики РїРѕ исходящим платежам", + "поставщики СЃ максимальным числом выплат", + "крупные разовые выплаты поставщикам", "top suppliers by payouts", "top suppliers by outgoing payments" ]; const CONTRACT_USAGE_AND_VALUE_HINTS = [ - "договоры по обороту", - "договоры по сумме оборота", - "топ договоров по обороту", - "контракты по обороту", - "контракты по сумме оборота", - "топ контрактов по обороту", - "договоры с минимальным бюджетом", - "договоры с самым маленьким бюджетом", - "контракты с минимальным бюджетом", - "контракты с самым маленьким бюджетом", - "активные договоры по бюджету", - "активные контракты по бюджету", - "контрагенты с несколькими договорами", - "несколько договоров у контрагента", - "мультидоговорные контрагенты", - "какие договоры активны", - "какие контракты активны", - "рабочие договоры", - "рабочие контракты", + "РґРѕРіРѕРІРѕСЂС‹ РїРѕ обороту", + "РґРѕРіРѕРІРѕСЂС‹ РїРѕ СЃСѓРјРјРµ оборота", + "топ РґРѕРіРѕРІРѕСЂРѕРІ РїРѕ обороту", + "контракты РїРѕ обороту", + "контракты РїРѕ СЃСѓРјРјРµ оборота", + "топ контрактов РїРѕ обороту", + "РґРѕРіРѕРІРѕСЂС‹ СЃ минимальным бюджетом", + "РґРѕРіРѕРІРѕСЂС‹ СЃ самым маленьким бюджетом", + "контракты СЃ минимальным бюджетом", + "контракты СЃ самым маленьким бюджетом", + "активные РґРѕРіРѕРІРѕСЂС‹ РїРѕ бюджету", + "активные контракты РїРѕ бюджету", + "контрагенты СЃ несколькими договорами", + "несколько РґРѕРіРѕРІРѕСЂРѕРІ Сѓ контрагента", + "мультидоговорные контрагенты", + "какие РґРѕРіРѕРІРѕСЂС‹ активны", + "какие контракты активны", + "рабочие РґРѕРіРѕРІРѕСЂС‹", + "рабочие контракты", "contracts by turnover", "contracts by budget" ]; const CONTRACT_LIST_BY_COUNTERPARTY_HINTS = [ - "договоры по", - "договора по", - "список договоров по", - "покажи договоры по", - "выведи договоры по", - "контракты по", - "список контрактов по", - "покажи контракты по", - "выведи контракты по", + "РґРѕРіРѕРІРѕСЂС‹ РїРѕ", + "РґРѕРіРѕРІРѕСЂР° РїРѕ", + "СЃРїРёСЃРѕРє РґРѕРіРѕРІРѕСЂРѕРІ РїРѕ", + "покажи РґРѕРіРѕРІРѕСЂС‹ РїРѕ", + "выведи РґРѕРіРѕРІРѕСЂС‹ РїРѕ", + "контракты РїРѕ", + "СЃРїРёСЃРѕРє контрактов РїРѕ", + "покажи контракты РїРѕ", + "выведи контракты РїРѕ", "contracts by counterparty", "list contracts by counterparty", "show contracts by counterparty" @@ -412,21 +412,21 @@ function hasFlexiblePayablesDebtSignal(text) { if (!normalized) { return false; } - return (/(?:кому(?:\s+\S+){0,4}\s+мы(?:\s+\S+){0,4}\s+долж)/iu.test(normalized) || - /(?:мы(?:\s+\S+){0,4}\s+кому(?:\s+\S+){0,4}\s+долж)/iu.test(normalized)); + return (/(?:РєРѕРјСѓ(?:\s+\S+){0,4}\s+РјС‹(?:\s+\S+){0,4}\s+долж)/iu.test(normalized) || + /(?:РјС‹(?:\s+\S+){0,4}\s+РєРѕРјСѓ(?:\s+\S+){0,4}\s+долж)/iu.test(normalized)); } function tokenizeText(text) { return String(text ?? "") .toLowerCase() - .split(/[^a-zа-яё0-9]+/iu) + .split(/[^a-zР°-СЏС‘0-9]+/iu) .map((token) => token.trim()) .filter((token) => token.length > 0); } function trimRussianEnding(token) { - return token.replace(/(?:иями|ями|ами|ого|ему|ому|ыми|ими|ией|ей|ий|ый|ой|ях|ах|ов|ев|ам|ям|ом|ем|ы|и|а|я|у|ю|е|о)$/u, ""); + return token.replace(/(?:РёСЏРјРё|СЏРјРё|ами|РѕРіРѕ|ему|РѕРјСѓ|ыми|РёРјРё|ией|ей|РёР№|ый|РѕР№|СЏС…|ах|РѕРІ|ев|ам|СЏРј|РѕРј|ем|С‹|Рё|Р°|СЏ|Сѓ|СЋ|Рµ|Рѕ)$/u, ""); } function normalizeLexemeToken(rawToken) { - const token = String(rawToken ?? "").toLowerCase().replace(/[^a-zа-яё0-9]+/gu, ""); + const token = String(rawToken ?? "").toLowerCase().replace(/[^a-zР°-СЏС‘0-9]+/gu, ""); if (!token) { return ""; } @@ -492,7 +492,7 @@ function hasFuzzyLexeme(text, lexemeRoots) { return false; } function hasCompactAccountCodeToken(text) { - // Match compact account tokens while reducing false positives on short-year literals like "22 год". + // Match compact account tokens while reducing false positives on short-year literals like "22 РіРѕРґ". const source = String(text ?? ""); if (!source) { return false; @@ -502,11 +502,11 @@ function hasCompactAccountCodeToken(text) { return true; } // Plain two-digit code is accepted only in explicit account context. - if (/(?:сч[её]т|account)\D{0,12}\d{2}(?![\d-])/iu.test(source)) { + if (/(?:СЃС‡[её]С‚|account)\D{0,12}\d{2}(?![\d-])/iu.test(source)) { return true; } - if (/(?:^|\s)по\s+\d{2}(?=$|[\s,.;:!?])/iu.test(source)) { - if (!/(?:^|\s)(?:за|в)\s+\d{2}\s*(?:г(?:од|ода)?|year)\b/iu.test(source)) { + if (/(?:^|\s)РїРѕ\s+\d{2}(?=$|[\s,.;:!?])/iu.test(source)) { + if (!/(?:^|\s)(?:Р·Р°|РІ)\s+\d{2}\s*(?:Рі(?:РѕРґ|РѕРґР°)?|year)\b/iu.test(source)) { return true; } } @@ -517,125 +517,125 @@ function hasDocumentsFormingBalanceSignal(text) { return true; } const hasLooseAccountCodeToken = hasCompactAccountCodeToken(text); - const hasDocLexeme = /(?:документ|док(?:и|ам|ах|ов|а)?)/u.test(text); - const hasFormingLexeme = text.includes("формир"); - const hasBalanceLexeme = text.includes("остат"); - const hasAccountLexeme = text.includes("счет") || text.includes("счёт") || hasAccountNumberAnchor(text) || hasLooseAccountCodeToken; + const hasDocLexeme = /(?:документ|РґРѕРє(?:Рё|ам|ах|РѕРІ|Р°)?)/u.test(text); + const hasFormingLexeme = text.includes("формир"); + const hasBalanceLexeme = text.includes("остат"); + const hasAccountLexeme = text.includes("счет") || text.includes("счёт") || hasAccountNumberAnchor(text) || hasLooseAccountCodeToken; if (hasDocLexeme && hasFormingLexeme && hasBalanceLexeme && hasAccountLexeme) { return true; } if (hasDocLexeme && hasBalanceLexeme && hasAccountLexeme && - (text.includes("раскрой") || text.includes("раскид") || text.includes("под остатк"))) { + (text.includes("раскрой") || text.includes("раскид") || text.includes("РїРѕРґ остатк"))) { return true; } - if (hasBalanceLexeme && hasAccountLexeme && text.includes("из чего состоит")) { + if (hasBalanceLexeme && hasAccountLexeme && text.includes("РёР· чего состоит")) { return true; } - return hasBalanceLexeme && hasAccountLexeme && /из\s+чего\s+остат/u.test(text); + return hasBalanceLexeme && hasAccountLexeme && /РёР·\s+чего\s+остат/u.test(text); } function hasDocumentsFormingBalanceAccountAnchor(text) { - if (hasAccountNumberAnchor(text) || text.includes("счет") || text.includes("счёт")) { + if (hasAccountNumberAnchor(text) || text.includes("счет") || text.includes("счёт")) { return true; } - // Allow compact account mentions like "60.01" in slang prompts without explicit "счет". + // Allow compact account mentions like "60.01" in slang prompts without explicit "счет". return hasCompactAccountCodeToken(text); } function hasAccountBalanceSignal(text) { if (hasAny(text, ACCOUNT_BALANCE_HINTS)) { return true; } - const hasAccountLexeme = hasAccountNumberAnchor(text) || hasCompactAccountCodeToken(text) || /(?:^|\s)по\s+\d{2}(?:[.,]\d{1,2})?(?=$|[\s,.;:!?])/u.test(text); - const hasBalanceLexeme = text.includes("баланс") || - text.includes("остат") || - text.includes("сальд") || + const hasAccountLexeme = hasAccountNumberAnchor(text) || hasCompactAccountCodeToken(text) || /(?:^|\s)РїРѕ\s+\d{2}(?:[.,]\d{1,2})?(?=$|[\s,.;:!?])/u.test(text); + const hasBalanceLexeme = text.includes("баланс") || + text.includes("остат") || + text.includes("сальд") || text.includes("saldo") || text.includes("balance") || - text.includes("скока") || - text.includes("сколько") || - /на\s+конец/u.test(text); + text.includes("СЃРєРѕРєР°") || + text.includes("сколько") || + /РЅР°\s+конец/u.test(text); if (hasAccountLexeme && hasBalanceLexeme) { return true; } const hasAsOfStyleDate = /\b(19|20)\d{2}[./-](0?[1-9]|1[0-2])(?:[./-](0?[1-9]|[12]\d|3[01]))\b/u.test(text) || - /(?:на\s+ту\s+же\s+дат[ауеы]|same\s+date|the\s+same\s+date)/iu.test(text); - const hasFollowupBalanceVerb = /(?:вернись|вернуться|вернуть|back|return)/iu.test(text); + /(?:РЅР°\s+ту\s+Р¶Рµ\s+дат[ауеы]|same\s+date|the\s+same\s+date)/iu.test(text); + const hasFollowupBalanceVerb = /(?:вернись|вернуться|вернуть|back|return)/iu.test(text); return hasAccountLexeme && hasAsOfStyleDate && hasFollowupBalanceVerb; } function hasForecastTaxSignal(text) { - const hasForecastLexeme = /(?:прогноз|forecast|план(?:\s+платежа|\s+оплаты)?|прикин(?:уть|ем|у|ь|ул|ули|усь|усь))/iu.test(text); - const hasTaxLexeme = /(?:ндс|vat|налог)/iu.test(text); + const hasForecastLexeme = /(?:РїСЂРѕРіРЅРѕР·|forecast|план(?:\s+платежа|\s+оплаты)?|РїСЂРёРєРёРЅ(?:уть|ем|Сѓ|СЊ|СѓР»|ули|СѓСЃСЊ|СѓСЃСЊ))/iu.test(text); + const hasTaxLexeme = /(?:РЅРґСЃ|vat|налог)/iu.test(text); return hasForecastLexeme && hasTaxLexeme; } function hasVatLiabilityConfirmedTaxPeriodSignal(text) { - const hasVatLexeme = /(?:ндс|vat)/iu.test(text); + const hasVatLexeme = /(?:РЅРґСЃ|vat)/iu.test(text); if (!hasVatLexeme) { return false; } - const hasPaymentCue = /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы|сгруз(?:ить|им|ишь|ите|ил|ила|или|ка))/iu.test(text); + const hasPaymentCue = /(?:Рє\s+уплате|надо|РЅСѓР¶РЅРѕ|заплатить|уплатить|плат[её]Р¶|платежку|РІ\s+налогов|РІ\s+бюджет|должн[аы]?\s+заплатить|РјС‹\s+должн[аы]?|должн[аы]?\s+РјС‹|СЃРіСЂСѓР·(?:ить|РёРј|ишь|ите|РёР»|ила|или|РєР°))/iu.test(text); if (!hasPaymentCue) { return false; } - const hasAsOfCue = /(?:на\s+дат|по\s+состоянию|на\s+конец|as\s+of)/iu.test(text); + const hasAsOfCue = /(?:РЅР°\s+дат|РїРѕ\s+состоянию|РЅР°\s+конец|as\s+of)/iu.test(text); if (hasAsOfCue) { return false; } - const hasTaxAuthorityCue = /(?:в\s+налогов|в\s+бюджет|декларац|налогов(?:ый|ую)\s+период)/iu.test(text); - const hasQuarterCue = /(?:\b[1-4]\s*(?:квартал|кв\.?)\b|квартал|кв\.?)/iu.test(text); - const hasZaPeriodCue = /(?:за\s+(?:\d{4}|январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр|квартал|кв\.?|месяц|год|период))/iu.test(text); + const hasTaxAuthorityCue = /(?:РІ\s+налогов|РІ\s+бюджет|декларац|налогов(?:ый|СѓСЋ)\s+период)/iu.test(text); + const hasQuarterCue = /(?:\b[1-4]\s*(?:квартал|РєРІ\.?)\b|квартал|РєРІ\.?)/iu.test(text); + const hasZaPeriodCue = /(?:Р·Р°\s+(?:\d{4}|январ|феврал|март|апрел|май|РёСЋРЅ|РёСЋР»|август|сентябр|октябр|РЅРѕСЏР±СЂ|декабр|квартал|РєРІ\.?|месяц|РіРѕРґ|период))/iu.test(text); const hasExplicitDayDate = /\b(?:\d{1,2}[./-]\d{1,2}[./-](?:\d{2}|\d{4})|(?:19|20)\d{2}[./-]\d{1,2}[./-]\d{1,2})\b/u.test(text); - const hasMonthYearNaCue = /(?:на\s+(?:январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)\S*\s+(?:19|20)\d{2})/iu.test(text); - const hasHowMuchCue = /(?:сколько|скока|скок)/iu.test(text); - // "На март 2020" и конкретная дата без налогового контекста чаще означают as-of срез. + const hasMonthYearNaCue = /(?:РЅР°\s+(?:январ|феврал|март|апрел|май|РёСЋРЅ|РёСЋР»|август|сентябр|октябр|РЅРѕСЏР±СЂ|декабр)\S*\s+(?:19|20)\d{2})/iu.test(text); + const hasHowMuchCue = /(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє)/iu.test(text); + // "РќР° март 2020" Рё конкретная дата без налогового контекста чаще означают as-of срез. if (!hasTaxAuthorityCue && !hasZaPeriodCue && !hasQuarterCue && (hasMonthYearNaCue || hasExplicitDayDate)) { return false; } return hasTaxAuthorityCue || hasZaPeriodCue || hasQuarterCue || (hasHowMuchCue && hasTaxAuthorityCue); } function hasVatPayableConfirmedSignal(text) { - const hasVatLexeme = /(?:ндс|vat)/iu.test(text); + const hasVatLexeme = /(?:РЅРґСЃ|vat)/iu.test(text); if (!hasVatLexeme) { return false; } - const hasPaymentCue = /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы|сгруз(?:ить|им|ишь|ите|ил|ила|или|ка))/iu.test(text); + const hasPaymentCue = /(?:Рє\s+уплате|надо|РЅСѓР¶РЅРѕ|заплатить|уплатить|плат[её]Р¶|платежку|РІ\s+налогов|РІ\s+бюджет|должн[аы]?\s+заплатить|РјС‹\s+должн[аы]?|должн[аы]?\s+РјС‹|СЃРіСЂСѓР·(?:ить|РёРј|ишь|ите|РёР»|ила|или|РєР°))/iu.test(text); if (!hasPaymentCue) { return false; } - const hasDateOrPeriodCue = /(?:на\s+дат|по\s+состоянию|на\s+конец|за\s+(?:\d{4}|январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)|на\s+(?:январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)\S*(?:\s+(?:19|20)\d{2})?|квартал|месяц|год|период|\b\d{4}[./-]\d{2}[./-]\d{2}\b)/iu.test(text); - return hasDateOrPeriodCue || /(?:сколько|скока|скок)/iu.test(text); + const hasDateOrPeriodCue = /(?:РЅР°\s+дат|РїРѕ\s+состоянию|РЅР°\s+конец|Р·Р°\s+(?:\d{4}|январ|феврал|март|апрел|май|РёСЋРЅ|РёСЋР»|август|сентябр|октябр|РЅРѕСЏР±СЂ|декабр)|РЅР°\s+(?:январ|феврал|март|апрел|май|РёСЋРЅ|РёСЋР»|август|сентябр|октябр|РЅРѕСЏР±СЂ|декабр)\S*(?:\s+(?:19|20)\d{2})?|квартал|месяц|РіРѕРґ|период|\b\d{4}[./-]\d{2}[./-]\d{2}\b)/iu.test(text); + return hasDateOrPeriodCue || /(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє)/iu.test(text); } function hasPeriodCoverageProfileSignal(text) { if (hasAny(text, PERIOD_COVERAGE_PROFILE_HINTS)) { return true; } - if (/(?:за\s+какие\s+год[а-яё]*).*(?:баз[аы].*жив|период|данн)/iu.test(text)) { + if (/(?:Р·Р°\s+какие\s+РіРѕРґ[Р°-СЏС‘]*).*(?:баз[аы].*Р¶РёРІ|период|данн)/iu.test(text)) { return true; } - if (/(?:какой\s+год[а-яё]*).*(?:по\s+док|докам|документам)/iu.test(text)) { + if (/(?:какой\s+РіРѕРґ[Р°-СЏС‘]*).*(?:РїРѕ\s+РґРѕРє|докам|документам)/iu.test(text)) { return true; } - if (/(?:какой\s+месяц[а-яё]*).*(?:пик|по\s+операц)/iu.test(text)) { + if (/(?:какой\s+месяц[Р°-СЏС‘]*).*(?:РїРёРє|РїРѕ\s+операц)/iu.test(text)) { return true; } - if (/(?:месяц[\s-]*пик).*(?:операц|ops?|operation)/iu.test(text)) { + if (/(?:месяц[\s-]*РїРёРє).*(?:операц|ops?|operation)/iu.test(text)) { return true; } if (/(?:top\s*year|top\s*month|years?\/top\s*year|years?\s*top\s*year)/iu.test(text)) { return true; } - if (/(?:за\s+какие\s+год[а-яё]*\s+в\s+баз[еы]\s+есть\s+данн)/iu.test(text)) { + if (/(?:Р·Р°\s+какие\s+РіРѕРґ[Р°-СЏС‘]*\s+РІ\s+баз[еы]\s+есть\s+данн)/iu.test(text)) { return true; } - if (/(?:какой\s+год[а-яё]*\s+сам(?:ый|ая|ое)\s+(?:актив|пассив)|какой\s+год[а-яё]*\s+наименее\s+актив|год\s+с\s+минимальн)/iu.test(text) && - /(?:документ|doc)/iu.test(text)) { + if (/(?:какой\s+РіРѕРґ[Р°-СЏС‘]*\s+сам(?:ый|ая|РѕРµ)\s+(?:актив|пассив)|какой\s+РіРѕРґ[Р°-СЏС‘]*\s+наименее\s+актив|РіРѕРґ\s+СЃ\s+минимальн)/iu.test(text) && + /(?:документ|doc)/iu.test(text)) { return true; } - if (/(?:какой\s+месяц[а-яё]*\s+сам(?:ый|ая|ое)\s+(?:актив|пассив)|какой\s+месяц[а-яё]*\s+наименее\s+актив|месяц\s+с\s+минимальн)/iu.test(text) && - /(?:операц|operation|ops?)/iu.test(text)) { + if (/(?:какой\s+месяц[Р°-СЏС‘]*\s+сам(?:ый|ая|РѕРµ)\s+(?:актив|пассив)|какой\s+месяц[Р°-СЏС‘]*\s+наименее\s+актив|месяц\s+СЃ\s+минимальн)/iu.test(text) && + /(?:операц|operation|ops?)/iu.test(text)) { return true; } - if (/(?:профил[ья]\s+данн|покрыт(?:ие|ия)\s+период|диапазон\s+лет)/iu.test(text)) { + if (/(?:профил[СЊСЏ]\s+данн|покрыт(?:РёРµ|РёСЏ)\s+период|диапазон\s+лет)/iu.test(text)) { return true; } return false; @@ -644,25 +644,25 @@ function hasDocumentTypeAndAccountSectionProfileSignal(text) { if (hasAny(text, DOCUMENT_TYPE_AND_ACCOUNT_SECTION_PROFILE_HINTS)) { return true; } - if (/(?:каких?\s+док(?:ов|и)?).*(?:больше\s+всего|чаще\s+всего|крут)/iu.test(text)) { + if (/(?:каких?\s+РґРѕРє(?:РѕРІ|Рё)?).*(?:больше\s+всего|чаще\s+всего|РєСЂСѓС‚)/iu.test(text)) { return true; } - if (/(?:сводк[ауи].*тип[а-яё]*\s+док(?:умент|ов|и)?).*(?:дол[ья]|объем|объ[её]м)/iu.test(text)) { + if (/(?:СЃРІРѕРґРє[ауи].*тип[Р°-СЏС‘]*\s+РґРѕРє(?:умент|РѕРІ|Рё)?).*(?:РґРѕР»[СЊСЏ]|объем|РѕР±СЉ[её]Рј)/iu.test(text)) { return true; } - if (/(?:какие\s+тип[аы]\s+док(?:умент|ов|и)?\s+(?:использ|чаще|больш))/iu.test(text)) { + if (/(?:какие\s+тип[аы]\s+РґРѕРє(?:умент|РѕРІ|Рё)?\s+(?:использ|чаще|больш))/iu.test(text)) { return true; } - if (/(?:какие\s+тип[аы]\s+док(?:умент|ов|и)?\s+(?:реже|редк|наименее|миним))/iu.test(text)) { + if (/(?:какие\s+тип[аы]\s+РґРѕРє(?:умент|РѕРІ|Рё)?\s+(?:реже|редк|наименее|РјРёРЅРёРј))/iu.test(text)) { return true; } - if (/(?:типы?\s+док(?:умент|ов|и)?\s+и\s+их\s+дол[ья])/iu.test(text)) { + if (/(?:типы?\s+РґРѕРє(?:умент|РѕРІ|Рё)?\s+Рё\s+РёС…\s+РґРѕР»[СЊСЏ])/iu.test(text)) { return true; } - if (/(?:какие\s+раздел[ыа]\s+уч[её]та\s+(?:наибол|наимен|заполн|почти\s+не))/iu.test(text)) { + if (/(?:какие\s+раздел[ыа]\s+СѓС‡[её]та\s+(?:наибол|наимен|заполн|почти\s+РЅРµ))/iu.test(text)) { return true; } - if (/(?:раздел[ыа]\s+уч[её]та).*(?:жирн|мертв|пуст|использ)/iu.test(text)) { + if (/(?:раздел[ыа]\s+СѓС‡[её]та).*(?:Р¶РёСЂРЅ|мертв|РїСѓСЃС‚|использ)/iu.test(text)) { return true; } return false; @@ -674,36 +674,36 @@ function hasCounterpartyPopulationAndRolesSignal(text) { if (hasAny(text, COUNTERPARTY_POPULATION_AND_ROLES_HINTS)) { return true; } - if (/(?:(?:сколько|скока|скок)\s+(?:всего\s+)?уникальн(?:ых|ые|ого)?\s+контрагент|(?:сколько|скока|скок)\s+(?:всего\s+)?контрагент(?:ов|а)?(?:\s+в\s+баз[еы])?)/iu.test(text)) { + if (/(?:(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє)\s+(?:всего\s+)?уникальн(?:ых|ые|РѕРіРѕ)?\s+контрагент|(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє)\s+(?:всего\s+)?контрагент(?:РѕРІ|Р°)?(?:\s+РІ\s+баз[еы])?)/iu.test(text)) { return true; } - if (/(?:(?:сколько|скока|скок)\s+(?:у\s+нас\s+)?заказчик(?:ов|а)?|(?:сколько|скока|скок)\s+(?:у\s+нас\s+)?поставщик(?:ов|а)?|(?:сколько|скока|скок)\s+(?:у\s+нас\s+)?клиент(?:ов|а)?|(?:сколько|скока|скок)\s+(?:у\s+нас\s+)?покупател(?:ей|я)|(?:сколько|скока|скок)\s+(?:у\s+нас\s+)?смешан(?:ных|ые)\s+контрагент(?:ов|а)?|заказчик(?:и|ов)\s*,?\s*поставщик(?:и|ов))/iu.test(text)) { + if (/(?:(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє)\s+(?:Сѓ\s+нас\s+)?заказчик(?:РѕРІ|Р°)?|(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє)\s+(?:Сѓ\s+нас\s+)?поставщик(?:РѕРІ|Р°)?|(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє)\s+(?:Сѓ\s+нас\s+)?клиент(?:РѕРІ|Р°)?|(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє)\s+(?:Сѓ\s+нас\s+)?покупател(?:ей|СЏ)|(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє)\s+(?:Сѓ\s+нас\s+)?смешан(?:ных|ые)\s+контрагент(?:РѕРІ|Р°)?|заказчик(?:Рё|РѕРІ)\s*,?\s*поставщик(?:Рё|РѕРІ))/iu.test(text)) { return true; } - if (/(?:разбей|раздели|сформируй\s+сводк).*(?:контрагент|заказчик|поставщик|клиент|покупател)/iu.test(text)) { + if (/(?:разбей|раздели|сформируй\s+СЃРІРѕРґРє).*(?:контрагент|заказчик|поставщик|клиент|покупател)/iu.test(text)) { return true; } return false; } function hasLifecycleSegmentationSignal(text) { - return /(?:вперв|нов(?:ые|ых|ые\s+контрагент|ые\s+клиент|ые\s+заказчик)|исчез|ушед|ушл|пропал|отвал|только\s+один\s+раз|ровно\s+один\s+раз|однораз|дольше\s+всех|дольше\s+всего|долгожив|самые\s+старые|старые\s+по\s+сотрудничеству|регуляр|эпизодич|разов(?:ые|ой|ые\s+поставщик)|давно\s+не\s+использ|неиспольз|потом\s+перестал)/iu.test(text); + return /(?:вперв|РЅРѕРІ(?:ые|ых|ые\s+контрагент|ые\s+клиент|ые\s+заказчик)|исчез|ушед|ушл|пропал|отвал|только\s+РѕРґРёРЅ\s+раз|СЂРѕРІРЅРѕ\s+РѕРґРёРЅ\s+раз|однораз|дольше\s+всех|дольше\s+всего|долгожив|самые\s+старые|старые\s+РїРѕ\s+сотрудничеству|регуляр|СЌРїРёР·РѕРґРёС‡|разов(?:ые|РѕР№|ые\s+поставщик)|давно\s+РЅРµ\s+использ|неиспольз|потом\s+перестал)/iu.test(text); } function hasCounterpartyDebtLongevitySignal(text) { - const hasCounterpartyLexeme = /(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?|контрагент(?:ов|а|ы)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|buyer(?:s)?)/iu.test(text); - const hasDebtLexeme = /(?:долг(?:и|ов|а|у)?|задолж(?:енность|енности|енностям|ал|али)?|просроч|хвост)/iu.test(text); - const hasLongevityCue = /(?:долгожив|долгожител|несколько\s+месяц|по\s+годам|дольше|лет|год(?:ам|а|у|ы)?|на\s+этот\s+момент|длительн)/iu.test(text); + const hasCounterpartyLexeme = /(?:заказчик(?:РѕРІ|Р°|Рё)?|клиент(?:РѕРІ|Р°|С‹)?|покупател(?:ей|СЏ|Рё)?|контрагент(?:РѕРІ|Р°|С‹)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|buyer(?:s)?)/iu.test(text); + const hasDebtLexeme = /(?:долг(?:Рё|РѕРІ|Р°|Сѓ)?|задолж(?:енность|енности|енностям|ал|али)?|РїСЂРѕСЃСЂРѕС‡|С…РІРѕСЃС‚)/iu.test(text); + const hasLongevityCue = /(?:долгожив|долгожител|несколько\s+месяц|РїРѕ\s+годам|дольше|лет|РіРѕРґ(?:ам|Р°|Сѓ|С‹)?|РЅР°\s+этот\s+момент|длительн)/iu.test(text); return hasCounterpartyLexeme && hasDebtLexeme && hasLongevityCue; } function hasCounterpartyActivityLifecycleSignal(text) { if (hasCustomerRevenueAndPaymentsSignal(text) || hasSupplierPayoutsProfileSignal(text)) { return false; } - const hasActivityAgeCue = /(?:сколько\s+лет\s+активности|сколько\s+лет\s+в\s+базе|возраст\s+активности|перв(?:ая|ый|ое)\s+(?:активность|платеж|поступление|документ)|последн(?:яя|ий|ее)\s+активность|с\s+какого\s+года\s+актив)/iu.test(text); - const hasActivityAgeAnchor = /(?:компан|контрагент|организац|ооо|ао|зао|ип|по\s+[a-zа-я0-9"«»().,_-]{3,}|в\s+базе\s+1с|в\s+1с\s+базе)/iu.test(text); + const hasActivityAgeCue = /(?:сколько\s+лет\s+активности|сколько\s+лет\s+РІ\s+базе|возраст\s+активности|перв(?:ая|ый|РѕРµ)\s+(?:активность|платеж|поступление|документ)|последн(?:СЏСЏ|РёР№|ее)\s+активность|СЃ\s+какого\s+РіРѕРґР°\s+актив)/iu.test(text); + const hasActivityAgeAnchor = /(?:компан|контрагент|организац|РѕРѕРѕ|ао|зао|РёРї|РїРѕ\s+[a-zР°-СЏ0-9"«»().,_-]{3,}|РІ\s+базе\s+1СЃ|РІ\s+1СЃ\s+базе)/iu.test(text); if (hasActivityAgeCue && hasActivityAgeAnchor) { return true; } - const hasPaymentRiskLexeme = /(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|задерж(?:ива|к)|просроч|задолж|\bдолг(?:и|ов|а|у)?\b)/iu.test(text); + const hasPaymentRiskLexeme = /(?:РЅРµ\s+плат(?:РёС‚|СЏС‚|РёР»|или)|без\s+оплат|оплат(?:С‹|Р°)?\s+нет|нет\s+оплат|задерж(?:РёРІР°|Рє)|РїСЂРѕСЃСЂРѕС‡|задолж|\bдолг(?:Рё|РѕРІ|Р°|Сѓ)?\b)/iu.test(text); if (hasPaymentRiskLexeme) { return false; } @@ -713,15 +713,15 @@ function hasCounterpartyActivityLifecycleSignal(text) { if (hasAny(text, COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS)) { return true; } - if (/(?:сколько|скока|скок)\s+/iu.test(text) && !hasLifecycleSegmentationSignal(text)) { + if (/(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє)\s+/iu.test(text) && !hasLifecycleSegmentationSignal(text)) { return false; } - const hasCounterpartyLexeme = /(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?|контрагент(?:ов|а|ы)?|поставщик(?:ов|а|и)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|supplier(?:s)?|vendor(?:s)?)/iu.test(text); - const hasActivityLexeme = /(?:работал(?:и)?|работа(?:ет|ют)|активн(?:ые|ых|а|о)?|сотрудничал(?:и)?|были\s+в\s+работе|active|использ(?:овал(?:и|ось)?|уются|ован(?:ы|о)?))/iu.test(text); - const hasTimeWindowLexeme = /(?:за\s+вс[её]\s+время|all\s+time|\b(?:19|20)\d{2}\b|(?:^|[^\d])\d{2}\s*(?:г(?:од|ода)?|г)(?:[^\p{L}\p{N}]|$)|в\s+конкретн(?:ом|ый)\s+год|за\s+год|в\s+году)/iu.test(text); - const hasListVerb = /(?:какие|кто|покажи|выведи|список|list|show)/iu.test(text); - const hasRosterQualifier = /(?:у\s+нас|вообще|в\s+баз[еы]|какие\s+есть|кто\s+есть|who\s+are)/iu.test(text); - const hasImplicitCounterpartyQuestion = /(?:кто\s+с\s+нами|кто\s+у\s+нас|всех?\s+зак(?:ов|а|и)?|все\s+заки|кто\s+нов(?:ые|ых|ый)\b|кто\s+был\s+активен|самые\s+старые\s+по\s+сотрудничеству)/iu.test(text); + const hasCounterpartyLexeme = /(?:заказчик(?:РѕРІ|Р°|Рё)?|клиент(?:РѕРІ|Р°|С‹)?|покупател(?:ей|СЏ|Рё)?|контрагент(?:РѕРІ|Р°|С‹)?|поставщик(?:РѕРІ|Р°|Рё)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|supplier(?:s)?|vendor(?:s)?)/iu.test(text); + const hasActivityLexeme = /(?:работал(?:Рё)?|работа(?:ет|СЋС‚)|активн(?:ые|ых|Р°|Рѕ)?|сотрудничал(?:Рё)?|были\s+РІ\s+работе|active|использ(?:овал(?:Рё|РѕСЃСЊ)?|уются|ован(?:С‹|Рѕ)?))/iu.test(text); + const hasTimeWindowLexeme = /(?:Р·Р°\s+РІСЃ[её]\s+время|all\s+time|\b(?:19|20)\d{2}\b|(?:^|[^\d])\d{2}\s*(?:Рі(?:РѕРґ|РѕРґР°)?|Рі)(?:[^\p{L}\p{N}]|$)|РІ\s+конкретн(?:РѕРј|ый)\s+РіРѕРґ|Р·Р°\s+РіРѕРґ|РІ\s+РіРѕРґСѓ)/iu.test(text); + const hasListVerb = /(?:какие|кто|покажи|выведи|СЃРїРёСЃРѕРє|list|show)/iu.test(text); + const hasRosterQualifier = /(?:Сѓ\s+нас|вообще|РІ\s+баз[еы]|какие\s+есть|кто\s+есть|who\s+are)/iu.test(text); + const hasImplicitCounterpartyQuestion = /(?:кто\s+СЃ\s+нами|кто\s+Сѓ\s+нас|всех?\s+зак(?:РѕРІ|Р°|Рё)?|РІСЃРµ\s+заки|кто\s+РЅРѕРІ(?:ые|ых|ый)\b|кто\s+был\s+активен|самые\s+старые\s+РїРѕ\s+сотрудничеству)/iu.test(text); const hasListWithWindow = hasCounterpartyLexeme && hasListVerb && hasTimeWindowLexeme; if (hasListWithWindow) { return true; @@ -735,17 +735,17 @@ function hasCounterpartyActivityLifecycleSignal(text) { if (hasImplicitCounterpartyQuestion && (hasLifecycleSegmentationSignal(text) || hasTimeWindowLexeme || hasActivityLexeme)) { return true; } - if (!hasCounterpartyLexeme && hasListVerb && hasLifecycleSegmentationSignal(text) && /\bкто\b/iu.test(text)) { + if (!hasCounterpartyLexeme && hasListVerb && hasLifecycleSegmentationSignal(text) && /\bкто\b/iu.test(text)) { return true; } return hasCounterpartyLexeme && hasActivityLexeme && (hasTimeWindowLexeme || hasListVerb); } function hasCounterpartyShipmentItemFlowSignal(text) { - const hasSelectedObjectInventoryCue = /(?:по\s+этой\s+позици(?:и|я|ю)|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|selected\s+object|по\s+выбранному\s+объекту)/iu.test(text); + const hasSelectedObjectInventoryCue = /(?:РїРѕ\s+этой\s+позици(?:Рё|СЏ|СЋ)|РїРѕ\s+этому\s+товару|РїРѕ\s+ней|РїРѕ\s+нему|РїРѕ\s+РЅРёРј|selected\s+object|РїРѕ\s+выбранному\s+объекту)/iu.test(text); if (hasSelectedObjectInventoryCue) { return false; } - const hasNamedTailAfterShipmentCue = /(?:отгруж(?:ал|али|ено)|постав(?:лял|ляли|ил|или)|привоз(?:ил|или)|продал)\s+[a-zа-яё][a-zа-яё0-9._-]{2,}/iu.test(text); + const hasNamedTailAfterShipmentCue = /(?:отгруж(?:ал|али|ено)|постав(?:лял|ляли|РёР»|или)|РїСЂРёРІРѕР·(?:РёР»|или)|продал)\s+[a-zР°-СЏС‘][a-zР°-СЏС‘0-9._-]{2,}/iu.test(text); const hasPartySignal = hasPartyAnchorMention(text) || hasLooseByAnchorMention(text) || hasImplicitCounterpartyAnchorAroundDocs(text) || @@ -753,24 +753,24 @@ function hasCounterpartyShipmentItemFlowSignal(text) { if (!hasPartySignal && !hasNamedTailAfterShipmentCue) { return false; } - const hasInboundShipmentCue = /(?:что\s+нам\s+(?:отгруж(?:ал|али|ено)|постав(?:лял|ляли|ил|или)|привоз(?:ил|или)|продал)|кто\s+нам\s+постав(?:лял|ил)|что\s+постав(?:лял|или)\s+нам|что\s+нам\s+поставили)/iu.test(text); - const hasItemOrServiceCue = /(?:како(?:й|е|го|му)\s+товар|каки(?:е|х)\s+товар|какую\s+услуг|какие\s+услуг|товар\s+или\s+услуг|позици(?:ю|и|ях)?)/iu.test(text); + const hasInboundShipmentCue = /(?:что\s+нам\s+(?:отгруж(?:ал|али|ено)|постав(?:лял|ляли|РёР»|или)|РїСЂРёРІРѕР·(?:РёР»|или)|продал)|кто\s+нам\s+постав(?:лял|РёР»)|что\s+постав(?:лял|или)\s+нам|что\s+нам\s+поставили)/iu.test(text); + const hasItemOrServiceCue = /(?:како(?:Р№|Рµ|РіРѕ|РјСѓ)\s+товар|каки(?:Рµ|С…)\s+товар|какую\s+услуг|какие\s+услуг|товар\s+или\s+услуг|позици(?:СЋ|Рё|СЏС…)?)/iu.test(text); return hasInboundShipmentCue || hasItemOrServiceCue; } function hasContractUsageOverviewSignal(text) { if (hasAny(text, CONTRACT_USAGE_OVERVIEW_HINTS)) { return true; } - if (/(?:сколько\s+(?:всего\s+)?(?:договор|контракт)(?:ов|а)?(?:\s+заведен[оы])?|(?:договорн(?:ая|ой)|контрактн(?:ая|ой))\s+баз[аы]).*(?:сколько|used|использ)/iu.test(text)) { + if (/(?:сколько\s+(?:всего\s+)?(?:РґРѕРіРѕРІРѕСЂ|контракт)(?:РѕРІ|Р°)?(?:\s+заведен[РѕС‹])?|(?:РґРѕРіРѕРІРѕСЂРЅ(?:ая|РѕР№)|контрактн(?:ая|РѕР№))\s+баз[аы]).*(?:сколько|used|использ)/iu.test(text)) { return true; } - if (/(?:сколько\s+из\s+(?:договор|контракт)(?:ов|а)?\s+(?:реально\s+)?использ(?:ован[оы]|овал(?:и|ось)?))/iu.test(text)) { + if (/(?:сколько\s+РёР·\s+(?:РґРѕРіРѕРІРѕСЂ|контракт)(?:РѕРІ|Р°)?\s+(?:реально\s+)?использ(?:ован[РѕС‹]|овал(?:Рё|РѕСЃСЊ)?))/iu.test(text)) { return true; } - if (/(?:total\s+vs\s+used|used\s+vs\s+total).*(?:договор|контракт|contract)?/iu.test(text)) { + if (/(?:total\s+vs\s+used|used\s+vs\s+total).*(?:РґРѕРіРѕРІРѕСЂ|контракт|contract)?/iu.test(text)) { return true; } - if (/(?:какие\s+(?:договор|контракт)(?:ы|а)?).*(?:давно\s+не\s+использ|неиспольз|протух|мертв|мёртв|stale|unused)/iu.test(text)) { + if (/(?:какие\s+(?:РґРѕРіРѕРІРѕСЂ|контракт)(?:С‹|Р°)?).*(?:давно\s+РЅРµ\s+использ|неиспольз|протух|мертв|мёртв|stale|unused)/iu.test(text)) { return true; } return false; @@ -782,33 +782,33 @@ function hasCustomerRevenueAndPaymentsSignal(text) { if (hasContractAnchorSignal(text)) { return false; } - const hasFuzzyCustomerLexeme = hasFuzzyLexeme(text, ["клиент", "заказчик", "покупател", "customer", "client"]); - const hasFuzzySupplierLexeme = hasFuzzyLexeme(text, ["поставщик", "supplier", "vendor"]); - const hasCounterpartyLexeme = /(?:контрагент(?:ов|а|ы)?|counterpart(?:y|ies)|компан(?:и|ия|ии|ию)|организац(?:и|ия|ии|ию)|partner(?:s)?)/iu.test(text); + const hasFuzzyCustomerLexeme = hasFuzzyLexeme(text, ["клиент", "заказчик", "покупател", "customer", "client"]); + const hasFuzzySupplierLexeme = hasFuzzyLexeme(text, ["поставщик", "supplier", "vendor"]); + const hasCounterpartyLexeme = /(?:контрагент(?:РѕРІ|Р°|С‹)?|counterpart(?:y|ies)|компан(?:Рё|РёСЏ|РёРё|РёСЋ)|организац(?:Рё|РёСЏ|РёРё|РёСЋ)|partner(?:s)?)/iu.test(text); const hasSpecificCounterpartyAnchor = hasLooseByAnchorMention(text) || hasHeuristicCounterpartyAnchor(text) || - /(?:по\s+(?:клиент(?:у|а)?|заказчик(?:у|а)?|покупател(?:ю|я)|customer|client)\s+[a-zа-яё0-9])/iu.test(text); - const asksWhoPays = /(?:кто\s+(?:нам\s+)?(?:(?:больше|чаще)\s+)?плат(?:ит|ят)?)/iu.test(text); - const asksCustomerGroup = /(?:клиент(?:ов|а|ы)?|заказчик(?:ов|а|и)?|покупател(?:ей|я|и)?|customer(?:s)?|client(?:s)?)/iu.test(text) || + /(?:РїРѕ\s+(?:клиент(?:Сѓ|Р°)?|заказчик(?:Сѓ|Р°)?|покупател(?:СЋ|СЏ)|customer|client)\s+[a-zР°-СЏС‘0-9])/iu.test(text); + const asksWhoPays = /(?:кто\s+(?:нам\s+)?(?:(?:больше|чаще)\s+)?плат(?:РёС‚|СЏС‚)?)/iu.test(text); + const asksCustomerGroup = /(?:клиент(?:РѕРІ|Р°|С‹)?|заказчик(?:РѕРІ|Р°|Рё)?|покупател(?:ей|СЏ|Рё)?|customer(?:s)?|client(?:s)?)/iu.test(text) || hasFuzzyCustomerLexeme || asksWhoPays; - const asksCounterpartySource = /(?:с\s+каких|от\s+каких|от\s+кого|from\s+which|from\s+who)/iu.test(text); - const asksIncomingFlow = /(?:приход|поступлен|входящ|зачислен|inflow|incoming)/iu.test(text); - const asksWhoBringsMostMoney = /(?:кто\s+(?:нам\s+)?(?:больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш(?:ий|ая|ее|ие))\s+(?:прин[её]с|зан[её]с).*(?:деньг|денег))/iu.test(text); - const asksWhoBringsMoneyLoose = /(?:кто\s+(?:нам\s+)?(?:больше|больше\s+всех|больше\s+всего).*(?:деньг|денег|доход|выручк).*(?:прин[её]с|зан[её]с))/iu.test(text) || - /(?:кто\s+(?:нам\s+)?(?:прин[её]с|зан[её]с).*(?:больше|больше\s+всех|больше\s+всего).*(?:деньг|денег|доход|выручк))/iu.test(text); - const asksLiquidityRanking = /(?:ликвидн|liquid)/iu.test(text) && - (asksCustomerGroup || hasCounterpartyLexeme || /(?:клиент|заказчик|контрагент|customer|client|counterpart)/iu.test(text)); - const asksProfitableYears = /(?:доходн|выручк|оборот|прибыл|revenue|turnover).*(?:год|года|годы|year|years)/iu.test(text) && - /(?:сам(?:ый|ая|ое|ые)|топ|луч|max|best|наибольш|больше)/iu.test(text); - const asksDealBudgetRanking = /(?:сделк|deal|бюджет)/iu.test(text) && - /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test(text); - const asksRevenueTotal = /(?:сколько|скока|скок).*(?:денег|выручк|доход|заработ|оборот)/iu.test(text); - const asksOverallTurnover = /(?:общ(?:ий|ие|ая)\s+оборот|общ(?:ая|ий)\s+выручк|total\s+turnover|turnover\s+total)/iu.test(text); - const asksMajorShare = /(?:основн(?:ую|ая|ые|ой)\s+част|больш(?:ую|ая|ие)\s+част|львин(?:ая|ую)\s+дол[яю]|ключев(?:ую|ая)\s+част)/iu.test(text); - const asksValue = /(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|плат(?:еж|ёж|ежн|ежей|ежа|ит|ят)|деньг|денег|заработ|оборот|чек|сделк|бюджет|занес|занёс|принес|принёс|ликвидн|revenue|inflow|deal|turnover|liquid)/iu.test(text); - const asksRankOrTop = /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн)/iu.test(text); - const asksCountOnly = /(?:сколько|скока|скок)\s+/iu.test(text) && !asksValue; + const asksCounterpartySource = /(?:СЃ\s+каких|РѕС‚\s+каких|РѕС‚\s+РєРѕРіРѕ|from\s+which|from\s+who)/iu.test(text); + const asksIncomingFlow = /(?:РїСЂРёС…РѕРґ|поступлен|РІС…РѕРґСЏС‰|зачислен|inflow|incoming)/iu.test(text); + const asksWhoBringsMostMoney = /(?:кто\s+(?:нам\s+)?(?:больше\s+всего|сам(?:ый|ая|РѕРµ|ые)|наибольш(?:РёР№|ая|ее|РёРµ))\s+(?:РїСЂРёРЅ[её]СЃ|зан[её]СЃ).*(?:деньг|денег))/iu.test(text); + const asksWhoBringsMoneyLoose = /(?:кто\s+(?:нам\s+)?(?:больше|больше\s+всех|больше\s+всего).*(?:деньг|денег|РґРѕС…РѕРґ|выручк).*(?:РїСЂРёРЅ[её]СЃ|зан[её]СЃ))/iu.test(text) || + /(?:кто\s+(?:нам\s+)?(?:РїСЂРёРЅ[её]СЃ|зан[её]СЃ).*(?:больше|больше\s+всех|больше\s+всего).*(?:деньг|денег|РґРѕС…РѕРґ|выручк))/iu.test(text); + const asksLiquidityRanking = /(?:ликвидн|liquid)/iu.test(text) && + (asksCustomerGroup || hasCounterpartyLexeme || /(?:клиент|заказчик|контрагент|customer|client|counterpart)/iu.test(text)); + const asksProfitableYears = /(?:РґРѕС…РѕРґРЅ|выручк|РѕР±РѕСЂРѕС‚|прибыл|revenue|turnover).*(?:РіРѕРґ|РіРѕРґР°|РіРѕРґС‹|year|years)/iu.test(text) && + /(?:сам(?:ый|ая|РѕРµ|ые)|топ|луч|max|best|наибольш|больше)/iu.test(text); + const asksDealBudgetRanking = /(?:сделк|deal|бюджет)/iu.test(text) && + /(?:топ|top|сам(?:ый|ая|РѕРµ|ые)|РєСЂСѓРїРЅ|мален|Р¶РёСЂРЅ|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test(text); + const asksRevenueTotal = /(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє).*(?:денег|выручк|РґРѕС…РѕРґ|заработ|РѕР±РѕСЂРѕС‚)/iu.test(text); + const asksOverallTurnover = /(?:РѕР±С‰(?:РёР№|РёРµ|ая)\s+РѕР±РѕСЂРѕС‚|РѕР±С‰(?:ая|РёР№)\s+выручк|total\s+turnover|turnover\s+total)/iu.test(text); + const asksMajorShare = /(?:РѕСЃРЅРѕРІРЅ(?:СѓСЋ|ая|ые|РѕР№)\s+част|больш(?:СѓСЋ|ая|РёРµ)\s+част|львин(?:ая|СѓСЋ)\s+РґРѕР»[СЏСЋ]|ключев(?:СѓСЋ|ая)\s+част)/iu.test(text); + const asksValue = /(?:РґРѕС…РѕРґРЅ|выручк|РїСЂРёС…РѕРґ|поступлен|РІС…РѕРґСЏС‰|зачислен|оплат|плат(?:еж|ёж|ежн|ежей|ежа|РёС‚|СЏС‚)|деньг|денег|заработ|РѕР±РѕСЂРѕС‚|чек|сделк|бюджет|занес|занёс|принес|принёс|ликвидн|revenue|inflow|deal|turnover|liquid)/iu.test(text); + const asksRankOrTop = /(?:топ|top|сам(?:ый|ая|РѕРµ|ые)|РєСЂСѓРїРЅ|мален|Р¶РёСЂРЅ|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн)/iu.test(text); + const asksCountOnly = /(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє)\s+/iu.test(text) && !asksValue; if (asksCountOnly) { return false; } @@ -860,16 +860,16 @@ function hasSupplierPayoutsProfileSignal(text) { if (hasContractAnchorSignal(text)) { return false; } - const hasFuzzySupplierLexeme = hasFuzzyLexeme(text, ["поставщик", "supplier", "vendor"]); + const hasFuzzySupplierLexeme = hasFuzzyLexeme(text, ["поставщик", "supplier", "vendor"]); const hasSpecificCounterpartyAnchor = hasLooseByAnchorMention(text) || hasHeuristicCounterpartyAnchor(text) || - /(?:по\s+(?:поставщик(?:у|а)?|supplier|vendor)\s+[a-zа-яё0-9])/iu.test(text); - const asksSupplierGroup = /(?:поставщик(?:ов|а|и)?|supplier(?:s)?|vendor(?:s)?|к[ао]му\s+мы)/iu.test(text) || + /(?:РїРѕ\s+(?:поставщик(?:Сѓ|Р°)?|supplier|vendor)\s+[a-zР°-СЏС‘0-9])/iu.test(text); + const asksSupplierGroup = /(?:поставщик(?:РѕРІ|Р°|Рё)?|supplier(?:s)?|vendor(?:s)?|Рє[ао]РјСѓ\s+РјС‹)/iu.test(text) || hasFuzzySupplierLexeme || - /(?:кому\s+ушло|кому\s+платили|кому\s+заплатили)/iu.test(text); - const asksPayoutValue = /(?:выплат|исходящ|списан|заплат|ушло|сгрузил|сгрузили|перевел|перевёл|отдали|платеж|платёж|outflow|payout)/iu.test(text); - const asksRankOrTop = /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|больше\s+всего|чаще\s+всего|максимальн|наибольш)/iu.test(text); - const asksCountOnly = /(?:сколько|скока|скок)\s+/iu.test(text) && !asksPayoutValue; + /(?:РєРѕРјСѓ\s+ушло|РєРѕРјСѓ\s+платили|РєРѕРјСѓ\s+заплатили)/iu.test(text); + const asksPayoutValue = /(?:выплат|РёСЃС…РѕРґСЏС‰|списан|заплат|ушло|СЃРіСЂСѓР·РёР»|сгрузили|перевел|перевёл|отдали|платеж|платёж|outflow|payout)/iu.test(text); + const asksRankOrTop = /(?:топ|top|сам(?:ый|ая|РѕРµ|ые)|РєСЂСѓРїРЅ|больше\s+всего|чаще\s+всего|максимальн|наибольш)/iu.test(text); + const asksCountOnly = /(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє)\s+/iu.test(text) && !asksPayoutValue; if (asksCountOnly) { return false; } @@ -882,19 +882,19 @@ function hasContractUsageAndValueSignal(text) { if (hasAny(text, CONTRACT_USAGE_AND_VALUE_HINTS)) { return true; } - if (!/(?:договор(?:ов|а|ы)?|контракт(?:ов|а|ы|у|ом|е)?|contract(?:s)?)/iu.test(text)) { + if (!/(?:РґРѕРіРѕРІРѕСЂ(?:РѕРІ|Р°|С‹)?|контракт(?:РѕРІ|Р°|С‹|Сѓ|РѕРј|Рµ)?|contract(?:s)?)/iu.test(text)) { return false; } if (hasContractUsageOverviewSignal(text)) { return false; } - const asksStructure = /(?:нескольк(?:ими|их|ие|о)?\s+(?:договор|контракт)|мультидоговор|контрагент(?:ов|ы)?.*нескольк(?:ими|их|ие|о)\s+(?:договор|контракт)|какие\s+(?:договор|контракт)(?:ы|а)?\s+активн|рабоч(?:ие|их)\s+(?:договор|контракт))/iu.test(text); - const asksValue = /(?:оборот|бюджет|сумм|стоим|value|turnover|amount|revenue|крупн|мелк|миним|максим)/iu.test(text); - const asksRank = /(?:топ|top|ранк|rank|сам(?:ый|ая|ое|ые))/iu.test(text); + const asksStructure = /(?:нескольк(?:РёРјРё|РёС…|РёРµ|Рѕ)?\s+(?:РґРѕРіРѕРІРѕСЂ|контракт)|мультидоговор|контрагент(?:РѕРІ|С‹)?.*нескольк(?:РёРјРё|РёС…|РёРµ|Рѕ)\s+(?:РґРѕРіРѕРІРѕСЂ|контракт)|какие\s+(?:РґРѕРіРѕРІРѕСЂ|контракт)(?:С‹|Р°)?\s+активн|рабоч(?:РёРµ|РёС…)\s+(?:РґРѕРіРѕРІРѕСЂ|контракт))/iu.test(text); + const asksValue = /(?:РѕР±РѕСЂРѕС‚|бюджет|СЃСѓРјРј|стоим|value|turnover|amount|revenue|РєСЂСѓРїРЅ|мелк|РјРёРЅРёРј|максим)/iu.test(text); + const asksRank = /(?:топ|top|ранк|rank|сам(?:ый|ая|РѕРµ|ые))/iu.test(text); return asksStructure || asksValue || asksRank; } function hasContractListByCounterpartySignal(text) { - const hasContractLexeme = /(?:договор(?:а|у|ом|е|ы)?|контракт(?:а|у|ом|е|ы)?|contracts?|contract)/iu.test(text); + const hasContractLexeme = /(?:РґРѕРіРѕРІРѕСЂ(?:Р°|Сѓ|РѕРј|Рµ|С‹)?|контракт(?:Р°|Сѓ|РѕРј|Рµ|С‹)?|contracts?|contract)/iu.test(text); if (!hasContractLexeme) { return false; } @@ -911,8 +911,8 @@ function hasContractListByCounterpartySignal(text) { if (hasBankOperationSignal(text)) { return false; } - const hasListVerb = /(?:покажи|выведи|список|какие|show|list)/iu.test(text); - const hasAllQualifier = /(?:\ball\b|\bвсе\b|всё)/iu.test(text); + const hasListVerb = /(?:покажи|выведи|СЃРїРёСЃРѕРє|какие|show|list)/iu.test(text); + const hasAllQualifier = /(?:\ball\b|\bРІСЃРµ\b|РІСЃС‘)/iu.test(text); const hasCounterpartyAnchor = hasPartyAnchorMention(text) || hasLooseByAnchorMention(text) || hasHeuristicCounterpartyAnchor(text); @@ -923,14 +923,14 @@ function hasContractListByCounterpartySignal(text) { } function hasDocumentsByAccountDrilldownSignal(text) { const hasAccountLexeme = hasAccountNumberAnchor(text) || hasCompactAccountCodeToken(text); - const hasDocLexeme = /(?:документ|док(?:и|ам|ах|ов|а)?|docs?|documents?|doki|docy|doci)/iu.test(text); - const hasDrilldownVerb = /(?:раскрой|раскры|разлож|разверн|документами|по\s+документ)/iu.test(text); - const hasSameDate = /(?:на\s+ту\s+же\s+дат[ауеы]|same\s+date|the\s+same\s+date)/iu.test(text); + const hasDocLexeme = /(?:документ|РґРѕРє(?:Рё|ам|ах|РѕРІ|Р°)?|docs?|documents?|doki|docy|doci)/iu.test(text); + const hasDrilldownVerb = /(?:раскрой|раскры|разлож|разверн|документами|РїРѕ\s+документ)/iu.test(text); + const hasSameDate = /(?:РЅР°\s+ту\s+Р¶Рµ\s+дат[ауеы]|same\s+date|the\s+same\s+date)/iu.test(text); return hasAccountLexeme && hasDocLexeme && (hasDrilldownVerb || hasSameDate); } function hasOpenContractsListSignal(text) { - const hasContractLexeme = text.includes("договор") || text.includes("контракт") || text.includes("contract") || text.includes("dogovor"); - const hasOpenLexeme = /(?:незакрыт|не\s+закрыт|открыт|open|unclosed)/iu.test(text); + const hasContractLexeme = text.includes("РґРѕРіРѕРІРѕСЂ") || text.includes("контракт") || text.includes("contract") || text.includes("dogovor"); + const hasOpenLexeme = /(?:незакрыт|РЅРµ\s+закрыт|открыт|open|unclosed)/iu.test(text); if (!hasContractLexeme || !hasOpenLexeme) { return false; } @@ -939,56 +939,56 @@ function hasOpenContractsListSignal(text) { return false; } // Debt/tail wording indicates open-items intent, not contract list. - if (/(?:долг|задолж|хвост|позиц|open\s+items|unclosed\s+items|взаиморасчет|взаиморасчёт)/iu.test(text)) { + if (/(?:долг|задолж|С…РІРѕСЃС‚|РїРѕР·РёС†|open\s+items|unclosed\s+items|взаиморасчет|взаиморасчёт)/iu.test(text)) { return false; } return true; } function hasSupplierTailRiskSignal(text) { - const hasSupplier = /(?:поставщик|supplier|vendor)/iu.test(text); - const hasTail = /(?:хвост|висят|незакрыт|не\s+закрыв|задолж|долг|просроч|сч[её]т)/iu.test(text); - const hasRisk = /(?:систематич|регулярн|проблем|тревог|не\s+разов|больше\s+похож)/iu.test(text); - const hasPeriodCue = /(?:на\s+конец\s+(?:месяц|период)|конец\s+месяц|пару\s+месяц|несколько\s+месяц|больше\s+месяц)/iu.test(text); + const hasSupplier = /(?:поставщик|supplier|vendor)/iu.test(text); + const hasTail = /(?:С…РІРѕСЃС‚|РІРёСЃСЏС‚|незакрыт|РЅРµ\s+закрыв|задолж|долг|РїСЂРѕСЃСЂРѕС‡|СЃС‡[её]С‚)/iu.test(text); + const hasRisk = /(?:систематич|регулярн|проблем|тревог|РЅРµ\s+разов|больше\s+РїРѕС…РѕР¶)/iu.test(text); + const hasPeriodCue = /(?:РЅР°\s+конец\s+(?:месяц|период)|конец\s+месяц|пару\s+месяц|несколько\s+месяц|больше\s+месяц)/iu.test(text); return hasSupplier && hasTail && (hasRisk || hasPeriodCue); } function hasPayablesDebtLifecycleSignal(text) { - const hasOweSignal = /(?:кому\s+мы\s+долж(?:ен|ны|эны|эна|эно)?|мы\s+долж(?:ен|ны|эны|эна|эно)?|кому\s+долж(?:ен|ны|эны|эна|эно)?|долж[нэ](?:ы|а|о)?\s+(?:заплат|оплат|перечис)|к\s+оплате|на\s+оплату|who\s+we\s+owe|owe\s+to|payables?|кредитор(?:[а-яё]{0,6})?)/iu.test(text); + const hasOweSignal = /(?:РєРѕРјСѓ\s+РјС‹\s+долж(?:ен|РЅС‹|СЌРЅС‹|СЌРЅР°|СЌРЅРѕ)?|РјС‹\s+долж(?:ен|РЅС‹|СЌРЅС‹|СЌРЅР°|СЌРЅРѕ)?|РєРѕРјСѓ\s+долж(?:ен|РЅС‹|СЌРЅС‹|СЌРЅР°|СЌРЅРѕ)?|долж[РЅСЌ](?:С‹|Р°|Рѕ)?\s+(?:заплат|оплат|перечис)|Рє\s+оплате|РЅР°\s+оплату|who\s+we\s+owe|owe\s+to|payables?|кредитор(?:[Р°-СЏС‘]{0,6})?)/iu.test(text); if (!hasOweSignal) { return false; } - const hasPastPaymentSignal = /(?:заплатил(?:и)?|платил(?:и)?|кому\s+ушло|выплатил(?:и)?|списан|outflow|payout)/iu.test(text); - const hasTopRankingSignal = /(?:топ|top|больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш|максимальн)/iu.test(text); + const hasPastPaymentSignal = /(?:заплатил(?:Рё)?|платил(?:Рё)?|РєРѕРјСѓ\s+ушло|выплатил(?:Рё)?|списан|outflow|payout)/iu.test(text); + const hasTopRankingSignal = /(?:топ|top|больше\s+всего|сам(?:ый|ая|РѕРµ|ые)|наибольш|максимальн)/iu.test(text); if (hasPastPaymentSignal && hasTopRankingSignal) { return false; } return true; } function hasReceivablesDebtLifecycleSignal(text) { - const hasOweUsSignal = /(?:кто\s+нам\s+долж(?:ен|ны|эны|эна|эно)?|кто\s+долж(?:ен|ны|эны|эна|эно)?\s+нам|нам\s+долж(?:ен|ны|эны|эна|эно)?|должник(?:[а-яё]{0,6})?|дебитор(?:[а-яё]{0,6})?|дебиторск(?:[а-яё]{0,6})?|задолж|долг(?:и|ов|а|у)?|к\s+получению|на\s+поступление|к\s+взысканию|who\s+owes\s+us|receivables?)/iu.test(text); + const hasOweUsSignal = /(?:кто\s+нам\s+долж(?:ен|РЅС‹|СЌРЅС‹|СЌРЅР°|СЌРЅРѕ)?|кто\s+долж(?:ен|РЅС‹|СЌРЅС‹|СЌРЅР°|СЌРЅРѕ)?\s+нам|нам\s+долж(?:ен|РЅС‹|СЌРЅС‹|СЌРЅР°|СЌРЅРѕ)?|должник(?:[Р°-СЏС‘]{0,6})?|дебитор(?:[Р°-СЏС‘]{0,6})?|дебиторск(?:[Р°-СЏС‘]{0,6})?|задолж|долг(?:Рё|РѕРІ|Р°|Сѓ)?|Рє\s+получению|РЅР°\s+поступление|Рє\s+взысканию|who\s+owes\s+us|receivables?)/iu.test(text); if (!hasOweUsSignal) { return false; } - const hasPastInflowSignal = /(?:прин[её]с|зан[её]с|поступил|приход|inflow|paid\s+us|already\s+paid)/iu.test(text); - const hasTopRankingSignal = /(?:топ|top|больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш|максимальн)/iu.test(text); + const hasPastInflowSignal = /(?:РїСЂРёРЅ[её]СЃ|зан[её]СЃ|поступил|РїСЂРёС…РѕРґ|inflow|paid\s+us|already\s+paid)/iu.test(text); + const hasTopRankingSignal = /(?:топ|top|больше\s+всего|сам(?:ый|ая|РѕРµ|ые)|наибольш|максимальн)/iu.test(text); if (hasPastInflowSignal && hasTopRankingSignal) { return false; } return true; } function hasReceivablesLatencyRiskSignal(text) { - const hasBuyer = /(?:покупател|клиент|заказчик|customer|buyer)/iu.test(text); - const hasCounterparty = /(?:контрагент|counterparty|partner)/iu.test(text); - const hasPayment = /(?:оплат|платеж|платёж|payment)/iu.test(text); - const hasShipment = /(?:отправк|отгруз|реализ|shipment|delivery)/iu.test(text); - const hasDelay = /(?:длинн|долг|просроч|задерж|висят|тревог|too\s+long|late)/iu.test(text); - const hasOverdueDeadlineCue = /(?:срок(?:и|а)?(?:\s+оплат[ыы]?)?[\s\S]{0,24}(?:прош|выш|истек|истёк)|срок(?:и|а)?\s+давно\s+прошл|давно\s+пора\s+оплат|давно\s+не\s+оплач)/iu.test(text); - const hasNonPayment = /(?:не\s+плат(?:ит|ят|ил|или)|не\s+оплат|не\s+оплач|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|неоплач)/iu.test(text); - const hasPaymentShipmentImbalance = /(?:оплач(?:ено|ен[аоы]?|ивать|ивать)?\s+меньше[\s\S]{0,36}отгруж|недоплат[\s\S]{0,36}отгруж|отгруж[\s\S]{0,36}оплач(?:ено|ено\s+меньше))/iu.test(text); - const hasNegativeSaldoRisk = /(?:сальд[оа]\s+(?:уже\s+)?отрицат|минусов(?:ое|ой)\s+сальдо|сальдо\s+в\s+минус)/iu.test(text); - const hasPeriodOrRiskCue = /(?:за\s+текущ|на\s+конец|тревог|просроч|задерж|долг|длинн|несколько\s+месяц|больше\s+месяц)/iu.test(text) || + const hasBuyer = /(?:покупател|клиент|заказчик|customer|buyer)/iu.test(text); + const hasCounterparty = /(?:контрагент|counterparty|partner)/iu.test(text); + const hasPayment = /(?:оплат|платеж|платёж|payment)/iu.test(text); + const hasShipment = /(?:отправк|отгруз|реализ|shipment|delivery)/iu.test(text); + const hasDelay = /(?:длинн|долг|РїСЂРѕСЃСЂРѕС‡|задерж|РІРёСЃСЏС‚|тревог|too\s+long|late)/iu.test(text); + const hasOverdueDeadlineCue = /(?:СЃСЂРѕРє(?:Рё|Р°)?(?:\s+оплат[ыы]?)?[\s\S]{0,24}(?:РїСЂРѕС€|выш|истек|истёк)|СЃСЂРѕРє(?:Рё|Р°)?\s+давно\s+прошл|давно\s+РїРѕСЂР°\s+оплат|давно\s+РЅРµ\s+оплач)/iu.test(text); + const hasNonPayment = /(?:РЅРµ\s+плат(?:РёС‚|СЏС‚|РёР»|или)|РЅРµ\s+оплат|РЅРµ\s+оплач|без\s+оплат|оплат(?:С‹|Р°)?\s+нет|нет\s+оплат|неоплач)/iu.test(text); + const hasPaymentShipmentImbalance = /(?:оплач(?:ено|ен[аоы]?|ивать|ивать)?\s+меньше[\s\S]{0,36}отгруж|недоплат[\s\S]{0,36}отгруж|отгруж[\s\S]{0,36}оплач(?:ено|ено\s+меньше))/iu.test(text); + const hasNegativeSaldoRisk = /(?:сальд[РѕР°]\s+(?:СѓР¶Рµ\s+)?отрицат|РјРёРЅСѓСЃРѕРІ(?:РѕРµ|РѕР№)\s+сальдо|сальдо\s+РІ\s+РјРёРЅСѓСЃ)/iu.test(text); + const hasPeriodOrRiskCue = /(?:Р·Р°\s+текущ|РЅР°\s+конец|тревог|РїСЂРѕСЃСЂРѕС‡|задерж|долг|длинн|несколько\s+месяц|больше\s+месяц)/iu.test(text) || hasOverdueDeadlineCue || hasNegativeSaldoRisk; - const hasBetweenShipmentAndPayment = /между[\s\S]{0,80}(?:отправк|отгруз|реализ)[\s\S]{0,80}(?:оплат|платеж|платёж|payment)/iu.test(text); + const hasBetweenShipmentAndPayment = /между[\s\S]{0,80}(?:отправк|отгруз|реализ)[\s\S]{0,80}(?:оплат|платеж|платёж|payment)/iu.test(text); if (hasBuyer && hasPayment && ((hasShipment && (hasDelay || hasOverdueDeadlineCue)) || hasBetweenShipmentAndPayment || hasPaymentShipmentImbalance)) { @@ -1000,31 +1000,31 @@ function hasReceivablesLatencyRiskSignal(text) { return (hasBuyer || hasCounterparty) && hasNonPayment && hasPeriodOrRiskCue; } function hasSettlementGapSignal(text) { - const hasPayment = /(?:платеж|платёж|оплат|списани|поступлен|payment)/iu.test(text); - const hasDocument = /(?:док(?:и|умент|ументы|ументов)|docs?|documents?)/iu.test(text); - const hasShipment = /(?:отгруз|реализ|shipment|delivery|товар|услуг)/iu.test(text); - const hasAdvance = /(?:аванс|предоплат)/iu.test(text); - const hasClosureLexeme = /(?:закрыти|взаиморасч|акт|сч[её]т(?:ов|а|ы)?)/iu.test(text); - const hasNoDocumentForClosing = /(?:нет|без)\s+(?:док(?:и|умент|ументы|ументов)|закрывающ)/iu.test(text) && + const hasPayment = /(?:платеж|платёж|оплат|списани|поступлен|payment)/iu.test(text); + const hasDocument = /(?:РґРѕРє(?:Рё|умент|ументы|ументов)|docs?|documents?)/iu.test(text); + const hasShipment = /(?:отгруз|реализ|shipment|delivery|товар|услуг)/iu.test(text); + const hasAdvance = /(?:аванс|предоплат)/iu.test(text); + const hasClosureLexeme = /(?:закрыти|взаиморасч|акт|СЃС‡[её]С‚(?:РѕРІ|Р°|С‹)?)/iu.test(text); + const hasNoDocumentForClosing = /(?:нет|без)\s+(?:РґРѕРє(?:Рё|умент|ументы|ументов)|закрывающ)/iu.test(text) && hasClosureLexeme; - const hasNoDocumentForClosingReversed = /(?:док(?:и|умент|ументы|ументов)|закрывающ)[\s\S]{0,48}(?:нет|без)/iu.test(text) && + const hasNoDocumentForClosingReversed = /(?:РґРѕРє(?:Рё|умент|ументы|ументов)|закрывающ)[\s\S]{0,48}(?:нет|без)/iu.test(text) && hasClosureLexeme; - const hasNoPayments = /(?:нет|без)\s+(?:оплат|платеж|платёж|payment)/iu.test(text) || - /(?:оплат|платеж|платёж|payment)\s+нет/iu.test(text); + const hasNoPayments = /(?:нет|без)\s+(?:оплат|платеж|платёж|payment)/iu.test(text) || + /(?:оплат|платеж|платёж|payment)\s+нет/iu.test(text); const hasDocsWithoutPayments = hasDocument && hasNoPayments; const hasPaymentsWithoutClosingDocs = hasPayment && (hasNoDocumentForClosing || hasNoDocumentForClosingReversed); const hasPaymentsWithoutSettlementClosure = hasPayment && - /(?:без|нет)\s+закрыти(?:я|й)?(?:\s+взаиморасч[её]тов)?/iu.test(text) && + /(?:без|нет)\s+закрыти(?:СЏ|Р№)?(?:\s+взаиморасч[её]тов)?/iu.test(text) && hasClosureLexeme; const hasShipmentWithoutClosingDocs = hasShipment && (hasNoDocumentForClosing || hasNoDocumentForClosingReversed || - /(?:без|нет)\s+док(?:и|умент(?:ов|ы|а)?)\s+(?:для\s+)?(?:их\s+)?закрыти/u.test(text)); + /(?:без|нет)\s+РґРѕРє(?:Рё|умент(?:РѕРІ|С‹|Р°)?)\s+(?:для\s+)?(?:РёС…\s+)?закрыти/u.test(text)); const hasClosingWithoutSupportingDocs = hasClosureLexeme && - /(?:без|нет)\s+подтверждающ(?:их|его|ие)?\s+док(?:и|умент(?:ов|ы|а)?)/iu.test(text); - const hasAdvanceStuckRisk = /(?:зависш(?:ий|ие|ая|ие\s+аванс)|давно\s+пора\s+закрыть|пора\s+закрывать|перепривяз(?:ать|к)|списыв(?:ать|ани|ан)|нереальн)/iu.test(text); + /(?:без|нет)\s+подтверждающ(?:РёС…|его|РёРµ)?\s+РґРѕРє(?:Рё|умент(?:РѕРІ|С‹|Р°)?)/iu.test(text); + const hasAdvanceStuckRisk = /(?:зависш(?:РёР№|РёРµ|ая|РёРµ\s+аванс)|давно\s+РїРѕСЂР°\s+закрыть|РїРѕСЂР°\s+закрывать|перепривяз(?:ать|Рє)|списыв(?:ать|ани|ан)|нереальн)/iu.test(text); const hasUnclosedAdvanceGap = hasAdvance && - (/(?:не\s+закрыт|незакрыт|долго\s+не\s+закрыт|давно\s+не\s+закрыт|давно\s+пора\s+закрыть)/iu.test(text) || + (/(?:РЅРµ\s+закрыт|незакрыт|долго\s+РЅРµ\s+закрыт|давно\s+РЅРµ\s+закрыт|давно\s+РїРѕСЂР°\s+закрыть)/iu.test(text) || hasAdvanceStuckRisk || hasNoDocumentForClosing || hasNoDocumentForClosingReversed); @@ -1036,12 +1036,12 @@ function hasSettlementGapSignal(text) { hasUnclosedAdvanceGap); } function hasReconciliationMismatchSignal(text) { - const hasCounterparty = /(?:контрагент|поставщик|клиент|покупател|customer|supplier|counterparty)/iu.test(text); - const hasReconciliationLexeme = /(?:акт(?:а|ом|ах)?\s+свер(?:к|ок)|свер(?:к|ок))/iu.test(text); - const hasMismatchLexeme = /(?:не\s+совпад|несовпад|расхожд|расход|не\s+сход|несход|разъех|разниц|не\s+бь[её]т)/iu.test(text); - const hasBalanceLexeme = /(?:сальд|остат|баланс|saldo|balance)/iu.test(text); - const hasLookupVerb = /(?:покажи|выведи|найд[иь]|show|list)/iu.test(text); - const hasInterrogativeLookup = /(?:по\s+каким|у\s+кого|какие|какой|кто|где)/iu.test(text); + const hasCounterparty = /(?:контрагент|поставщик|клиент|покупател|customer|supplier|counterparty)/iu.test(text); + const hasReconciliationLexeme = /(?:акт(?:Р°|РѕРј|ах)?\s+свер(?:Рє|РѕРє)|свер(?:Рє|РѕРє))/iu.test(text); + const hasMismatchLexeme = /(?:РЅРµ\s+совпад|несовпад|расхожд|расход|РЅРµ\s+СЃС…РѕРґ|несход|разъех|разниц|РЅРµ\s+Р±СЊ[её]С‚)/iu.test(text); + const hasBalanceLexeme = /(?:сальд|остат|баланс|saldo|balance)/iu.test(text); + const hasLookupVerb = /(?:покажи|выведи|найд[РёСЊ]|show|list)/iu.test(text); + const hasInterrogativeLookup = /(?:РїРѕ\s+каким|Сѓ\s+РєРѕРіРѕ|какие|какой|кто|РіРґРµ)/iu.test(text); return (hasCounterparty && hasReconciliationLexeme && hasMismatchLexeme && @@ -1060,130 +1060,130 @@ function isLikelyCounterpartyToken(rawToken) { return false; } const stopWords = new Set([ - "за", - "с", - "по", - "на", - "и", - "или", - "док", - "доки", - "доки?", - "документ", - "документы", - "документов", - "документами", - "документу", - "документе", - "документа", - "документах", - "докам", - "доками", - "количество", - "количеству", - "количества", - "количеством", - "активный", - "активного", - "активности", - "пассивный", - "пассивного", - "пассивности", - "наименее", - "минимальный", - "минимум", - "реже", - "редкий", - "банк", - "банковские", - "операции", - "платежи", - "платеж", - "платёж", - "контрагент", - "контрагенту", - "контрагента", - "компания", - "компании", - "организация", - "организации", - "год", - "года", - "г", - "плс", + "Р·Р°", + "СЃ", + "РїРѕ", + "РЅР°", + "Рё", + "или", + "РґРѕРє", + "РґРѕРєРё", + "РґРѕРєРё?", + "документ", + "документы", + "документов", + "документами", + "документу", + "документе", + "документа", + "документах", + "докам", + "доками", + "количество", + "количеству", + "количества", + "количеством", + "активный", + "активного", + "активности", + "пассивный", + "пассивного", + "пассивности", + "наименее", + "минимальный", + "РјРёРЅРёРјСѓРј", + "реже", + "редкий", + "банк", + "банковские", + "операции", + "платежи", + "платеж", + "платёж", + "контрагент", + "контрагенту", + "контрагента", + "компания", + "компании", + "организация", + "организации", + "РіРѕРґ", + "РіРѕРґР°", + "Рі", + "плс", "pls", - "пж", - "пжлст", - "пожалуйста", - "есть", - "же", - "сводные", - "сводный", - "сводная", - "сводную", - "сводном", - "сводного", - "сводному", - "неуказанному", - "неуказанный", - "неуказанная", - "неуказанное", - "указанному", - "указанный", - "указанная", - "указанное", - "объекту", - "объект", - "бля", - "блять", - "епт", - "ёпт", - "епта", - "нах", - "нахуй", - "связанным", - "связанные", - "связанных", - "связанному", + "РїР¶", + "пжлст", + "пожалуйста", + "есть", + "Р¶Рµ", + "сводные", + "сводный", + "сводная", + "СЃРІРѕРґРЅСѓСЋ", + "СЃРІРѕРґРЅРѕРј", + "СЃРІРѕРґРЅРѕРіРѕ", + "СЃРІРѕРґРЅРѕРјСѓ", + "неуказанному", + "неуказанный", + "неуказанная", + "неуказанное", + "указанному", + "указанный", + "указанная", + "указанное", + "объекту", + "объект", + "бля", + "блять", + "епт", + "ёпт", + "епта", + "нах", + "нахуй", + "связанным", + "связанные", + "связанных", + "связанному", "related", "linked", - "этомуже", - "томуже" + "этомуже", + "томуже" ]); return !stopWords.has(token); } function hasPartyAnchorMention(text) { - return (text.includes("контраг") || - text.includes("контра") || + return (text.includes("контраг") || + text.includes("контра") || text.includes("counterparty") || - text.includes("компан") || + text.includes("компан") || text.includes("company") || - text.includes("организац") || + text.includes("организац") || text.includes("supplier") || text.includes("vendor") || text.includes("customer") || text.includes("client") || text.includes("partner") || - text.includes("поставщик") || - text.includes("клиент") || - text.includes("покупател") || - text.includes("партнер")); + text.includes("поставщик") || + text.includes("клиент") || + text.includes("покупател") || + text.includes("партнер")); } function hasContractAnchorMention(text) { - return (text.includes("договор") || - text.includes("контракт") || - /\bдог\.?\b/iu.test(text) || - text.includes("дог.") || + return (text.includes("РґРѕРіРѕРІРѕСЂ") || + text.includes("контракт") || + /\bРґРѕРі\.?\b/iu.test(text) || + text.includes("РґРѕРі.") || text.includes("contract") || text.includes("dogovor")); } function hasContractNumberLikeToken(text) { - if (/(?:^|[\s([{])(?:№|#|n)\s*[a-zа-яё0-9][a-zа-яё0-9./_-]{1,}(?=$|[\s,.;:!?)\]}])/iu.test(text)) { + if (/(?:^|[\s([{])(?:в„–|#|n)\s*[a-zР°-СЏС‘0-9][a-zР°-СЏС‘0-9./_-]{1,}(?=$|[\s,.;:!?)\]}])/iu.test(text)) { return true; } const rawTokens = text - .split(/[\s,;:!?()[\]{}"«»]+/u) - .map((token) => token.replace(/^[^\p{L}\p{N}#№]+|[^\p{L}\p{N}./_-]+$/gu, "").trim()) + .split(/[\s,;:!?()[\]{}"«»]+/u) + .map((token) => token.replace(/^[^\p{L}\p{N}#в„–]+|[^\p{L}\p{N}./_-]+$/gu, "").trim()) .filter((token) => token.length > 0); for (const rawToken of rawTokens) { const token = String(rawToken ?? "").trim(); @@ -1231,7 +1231,7 @@ function hasContractAnchorSignal(text) { return hasContractNumberLikeToken(text) && hasDocsOrBankSignal(text); } function hasLooseByAnchorMention(text) { - const match = text.match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu); + const match = text.match(/(?:^|\s)РїРѕ\s+([a-zР°-СЏС‘][a-zР°-СЏС‘0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu); if (!match) { return false; } @@ -1240,62 +1240,62 @@ function hasLooseByAnchorMention(text) { return false; } const stopWords = new Set([ - "контрагенту", - "контрагента", - "контре", - "компании", - "компанию", - "организации", - "организацию", - "поставщику", - "поставщика", - "клиенту", - "клиента", - "покупателю", - "покупателя", - "партнеру", - "партнера", - "договору", - "договора", - "счету", - "счёту", - "дате", - "периоду", - "период", - "документам", - "докам", - "количество", - "количеству", - "количества", - "количеством", - "активности", - "пассивности", - "наименее", - "минимум" + "контрагенту", + "контрагента", + "контре", + "компании", + "компанию", + "организации", + "организацию", + "поставщику", + "поставщика", + "клиенту", + "клиента", + "покупателю", + "покупателя", + "партнеру", + "партнера", + "РґРѕРіРѕРІРѕСЂСѓ", + "РґРѕРіРѕРІРѕСЂР°", + "счету", + "счёту", + "дате", + "периоду", + "период", + "документам", + "докам", + "количество", + "количеству", + "количества", + "количеством", + "активности", + "пассивности", + "наименее", + "РјРёРЅРёРјСѓРј" ]); return !stopWords.has(token); } function hasImplicitCounterpartyAnchorAroundDocs(text) { - const beforeDocsMatch = text.match(/(?:^|\s)([a-zа-яё][a-zа-яё0-9._-]{1,})\s+(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?|doki|docy|doci)(?=[\s,.;:!?)]|$)/iu); + const beforeDocsMatch = text.match(/(?:^|\s)([a-zР°-СЏС‘][a-zР°-СЏС‘0-9._-]{1,})\s+(?:РґРѕРє(?:Рё|СѓРј(?:ент(?:С‹|РѕРІ|ам|Р°)?)?)|docs?|documents?|doki|docy|doci)(?=[\s,.;:!?)]|$)/iu); if (beforeDocsMatch && isLikelyCounterpartyToken(String(beforeDocsMatch[1] ?? ""))) { return true; } - const afterDocsMatch = text.match(/(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?|doki|docy|doci)\s+(?:по\s+)?([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu); + const afterDocsMatch = text.match(/(?:РґРѕРє(?:Рё|СѓРј(?:ент(?:С‹|РѕРІ|ам|Р°)?)?)|docs?|documents?|doki|docy|doci)\s+(?:РїРѕ\s+)?([a-zР°-СЏС‘][a-zР°-СЏС‘0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu); if (afterDocsMatch && isLikelyCounterpartyToken(String(afterDocsMatch[1] ?? ""))) { return true; } return false; } function hasDocsOrBankSignal(text) { - return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|doki|docy|doci|банк|выписк|платеж|платёж|оплат|transactions?|bank\s+ops|bank\s+operations?)/iu.test(text); + return /(?:РґРѕРє(?:Рё|умент|ументы|ументов)|docs?|documents?|doki|docy|doci|банк|выписк|платеж|платёж|оплат|transactions?|bank\s+ops|bank\s+operations?)/iu.test(text); } function hasBankOperationSignal(text) { return hasAny(text, BANK_OPERATION_CORE_HINTS) || hasAny(text, BANK_OPERATIONS_BY_COUNTERPARTY_HINTS) || hasAny(text, BANK_OPERATIONS_BY_CONTRACT_HINTS); } function hasDocumentSignal(text) { - return (text.includes("док") || - text.includes("доки") || - text.includes("документ") || + return (text.includes("РґРѕРє") || + text.includes("РґРѕРєРё") || + text.includes("документ") || text.includes("doki") || text.includes("docy") || text.includes("doci") || @@ -1307,7 +1307,7 @@ function hasHeuristicCounterpartyAnchor(text) { return false; } const tokens = String(text ?? "") - .split(/[^a-zа-яё0-9._-]+/iu) + .split(/[^a-zР°-СЏС‘0-9._-]+/iu) .map((item) => item.trim()) .filter((item) => item.length > 0); for (const token of tokens) { @@ -1318,7 +1318,7 @@ function hasHeuristicCounterpartyAnchor(text) { if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) { continue; } - if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) { + if (/(?:^Р·Р°$|^for$|^from$|^to$|^РїРѕ$|^СЃ$|^РіРѕРґ$|^РіРѕРґР°$|^Рі$|^year$)/iu.test(lowered)) { continue; } return true; @@ -1326,30 +1326,30 @@ function hasHeuristicCounterpartyAnchor(text) { return false; } function hasGenericAddressLookupSignal(text) { - return (/\bесть\b/iu.test(text) || - /\bпокажи\b/iu.test(text) || - /\bвыведи\b/iu.test(text) || - /\bкакие\b/iu.test(text) || - /\bчто(?:-|\s)?то\b/iu.test(text) || - /за\s+любой\s+период/iu.test(text) || - /за\s+вс[её]\s+время/iu.test(text) || + return (/\bесть\b/iu.test(text) || + /\bпокажи\b/iu.test(text) || + /\bвыведи\b/iu.test(text) || + /\bкакие\b/iu.test(text) || + /\bчто(?:-|\s)?то\b/iu.test(text) || + /Р·Р°\s+любой\s+период/iu.test(text) || + /Р·Р°\s+РІСЃ[её]\s+время/iu.test(text) || /for\s+all\s+time/iu.test(text) || /all\s+time/iu.test(text)); } function hasAccountNumberAnchor(text) { - return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text); + return /(?:account|СЃС‡[её]С‚|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text); } function hasInventoryAccount41Anchor(text) { - return /(?:сч[её]т(?:а|е|у)?|счет(?:а|е|у)?)\D{0,12}41(?:[.,]0?1)?/iu.test(text) || /41(?:[.,]0?1)?\D{0,12}(?:сч[её]т(?:а|е|у)?|счет(?:а|е|у)?)/iu.test(text); + return /(?:СЃС‡[её]С‚(?:Р°|Рµ|Сѓ)?|счет(?:Р°|Рµ|Сѓ)?)\D{0,12}41(?:[.,]0?1)?/iu.test(text) || /41(?:[.,]0?1)?\D{0,12}(?:СЃС‡[её]С‚(?:Р°|Рµ|Сѓ)?|счет(?:Р°|Рµ|Сѓ)?)/iu.test(text); } function hasInventoryAsOfCue(text) { - return /(?:сейчас|текущ|на\s+дату|по\s+состоянию|срез|на\s+конец|date|as\s+of|current|now|today)/iu.test(text); + return /(?:сейчас|текущ|РЅР°\s+дату|РїРѕ\s+состоянию|срез|РЅР°\s+конец|date|as\s+of|current|now|today)/iu.test(text); } function hasInventoryOnHandSignal(text) { - const hasColloquialStockSnapshotCue = /(?:что|ч[еёо])\s+(?:у\s+нас\s+)?на\s+склад(?:е|у|ом|ах)(?=$|[\s,.;:!?])/iu.test(text); - const hasStockStateCue = /(?:(?:что|ч[еёо])\s+там\s+на\s+склад(?:е|у|ом|ах)|(?:что|ч[еёо]).*происход(?:ит|ило|ящее).*(?:на\s+)?склад(?:е|у|ом|ах)|происход(?:ит|ило|ящее)\s+на\s+склад(?:е|у|ом|ах)|ситуац(?:ия|ии)\s+на\s+склад(?:е|у|ом|ах)|обстановк(?:а|и)\s+на\s+склад(?:е|у|ом|ах)|what(?:'s| is)?\s+(?:there\s+)?(?:on|in)\s+(?:the\s+)?(?:warehouse|stock)|what(?:'s| is)?\s+happening\s+(?:on|in)\s+(?:the\s+)?(?:warehouse|stock))/iu.test(text); + const hasColloquialStockSnapshotCue = /(?:что|С‡[еёо])\s+(?:Сѓ\s+нас\s+)?РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)(?=$|[\s,.;:!?])/iu.test(text); + const hasStockStateCue = /(?:(?:что|С‡[еёо])\s+там\s+РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)|(?:что|С‡[еёо]).*РїСЂРѕРёСЃС…РѕРґ(?:РёС‚|ило|ящее).*(?:РЅР°\s+)?склад(?:Рµ|Сѓ|РѕРј|ах)|РїСЂРѕРёСЃС…РѕРґ(?:РёС‚|ило|ящее)\s+РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)|ситуац(?:РёСЏ|РёРё)\s+РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)|обстановк(?:Р°|Рё)\s+РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)|what(?:'s| is)?\s+(?:there\s+)?(?:on|in)\s+(?:the\s+)?(?:warehouse|stock)|what(?:'s| is)?\s+happening\s+(?:on|in)\s+(?:the\s+)?(?:warehouse|stock))/iu.test(text); const hasAccount41Anchor = hasInventoryAccount41Anchor(text); - const hasStockLexeme = /(?:склад(?:е|у|ом|ы|ов)?|warehouse|stock(?:room)?|inventory|on[\s-]?hand)/iu.test(text); + const hasStockLexeme = /(?:склад(?:Рµ|Сѓ|РѕРј|С‹|РѕРІ)?|warehouse|stock(?:room)?|inventory|on[\s-]?hand)/iu.test(text); if (!hasStockLexeme && !hasAccount41Anchor) { return false; } @@ -1360,9 +1360,9 @@ function hasInventoryOnHandSignal(text) { hasInventoryPurchaseToSaleChainSignal(text)) { return false; } - const hasGoodsLexeme = /(?:товар(?:ы|ов|ом|а|ные)?|номенклатур|материал(?:ы|ов|а|ам)?|item(?:s)?|sku|product(?:s)?)/iu.test(text); - const hasBalanceLexeme = /(?:леж(?:ит|ат)|есть|числ(?:ит(?:ся|сь)|ятся)|остат(?:ок|ки)|срез|на\s+дат|по\s+состоянию|на\s+конец|происход(?:ит|ило|ящее)|ситуац(?:ия|ии)|обстановк(?:а|и)|today|now|current|as\s+of)/iu.test(text); - const hasRequestCue = /(?:покажи|показать|выведи|дай|какие|что|ч[еёо]|какой|сколько|проверь|проверить|чекни|check|show|list|which|what)/iu.test(text); + const hasGoodsLexeme = /(?:товар(?:С‹|РѕРІ|РѕРј|Р°|ные)?|номенклатур|материал(?:С‹|РѕРІ|Р°|ам)?|item(?:s)?|sku|product(?:s)?)/iu.test(text); + const hasBalanceLexeme = /(?:леж(?:РёС‚|ат)|есть|числ(?:РёС‚(?:СЃСЏ|СЃСЊ)|ятся)|остат(?:РѕРє|РєРё)|срез|РЅР°\s+дат|РїРѕ\s+состоянию|РЅР°\s+конец|РїСЂРѕРёСЃС…РѕРґ(?:РёС‚|ило|ящее)|ситуац(?:РёСЏ|РёРё)|обстановк(?:Р°|Рё)|today|now|current|as\s+of)/iu.test(text); + const hasRequestCue = /(?:покажи|показать|выведи|дай|какие|что|С‡[еёо]|какой|сколько|проверь|проверить|чекни|check|show|list|which|what)/iu.test(text); if (hasAccount41Anchor && (hasGoodsLexeme || hasBalanceLexeme || hasRequestCue || hasInventoryAsOfCue(text))) { return true; } @@ -1370,23 +1370,23 @@ function hasInventoryOnHandSignal(text) { (hasRequestCue || hasBalanceLexeme || hasColloquialStockSnapshotCue || hasStockStateCue); } function hasInventoryProvenanceSignal(text) { - return /(?:поставщик|закупк|РїСЂРѕРёСЃС…РѕР¶Рґ|откуда|РєРѕРіРґР° был куплен|активная закупк|purchase provenance|purchase date|supplier provenance|stock overlap)/iu.test(text); + return /(?:поставщик|закупк|РїСЂРѕРёСЃС…РѕР¶Рґ|откуда|РєРѕРіРґР° был куплен|активная закупк|purchase provenance|purchase date|supplier provenance|stock overlap)/iu.test(text); } function hasInventoryPurchaseDocumentsSignal(text) { - return /(?:РїРѕ каким документам|документы закупки|purchase documents|documents of purchase|through which documents|chain of documents)/iu.test(text); + return /(?:Р С—Р С• каким документам|документы закупки|purchase documents|documents of purchase|through which documents|chain of documents)/iu.test(text); } function hasInventorySaleTraceSignal(text) { - return /(?:продаж|покупател|buyer|sale trace|purchase[\s-]?to[\s-]?sale|purchase -> warehouse -> sale|закупка.*продаж)/iu.test(text); + return /(?:продаж|покупател|buyer|sale trace|purchase[\s-]?to[\s-]?sale|purchase -> warehouse -> sale|закупка.*продаж)/iu.test(text); } function hasSelectedObjectInventoryCue(text) { - return /(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+нему|по\s+ней|по\s+ним|по\s+нему\s+же|по\s+ней\s+же|selected\s+object)/iu.test(String(text ?? "")); + return /(?:РїРѕ\s+выбранному\s+объекту|РїРѕ\s+выбранной\s+позиции|РїРѕ\s+этой\s+позиции|РїРѕ\s+этому\s+товару|РїРѕ\s+нему|РїРѕ\s+ней|РїРѕ\s+РЅРёРј|РїРѕ\s+нему\s+Р¶Рµ|РїРѕ\s+ней\s+Р¶Рµ|selected\s+object)/iu.test(String(text ?? "")); } function hasSelectedObjectInventoryProvenanceSignal(text) { return hasSelectedObjectInventoryCue(text) && (0, inventoryLifecycleCueHelpers_1.hasInventorySupplierCue)(text); } function hasSelectedObjectInventoryPurchaseDocumentsSignal(text) { - const hasPurchaseDocumentsCue = /(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(text) || - /(?:(?:по\s+каким|какими)\s+док[а-яё]*[\s\S]{0,80}(?:купил|куплен)|док(?:и|умент[а-яё]*)[\s\S]{0,80}(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару)|операци)|(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару))[\s\S]{0,80}док(?:и|умент[а-яё]*))/iu.test(text); + const hasPurchaseDocumentsCue = /(?:РїРѕ\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|РїРѕ\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(text) || + /(?:(?:РїРѕ\s+каким|какими)\s+РґРѕРє[Р°-СЏС‘]*[\s\S]{0,80}(?:РєСѓРїРёР»|куплен)|РґРѕРє(?:Рё|умент[Р°-СЏС‘]*)[\s\S]{0,80}(?:РїРѕ\s+(?:РЅРёРј|ней|нему|этой\s+позиции|этому\s+товару)|операци)|(?:РїРѕ\s+(?:РЅРёРј|ней|нему|этой\s+позиции|этому\s+товару))[\s\S]{0,80}РґРѕРє(?:Рё|умент[Р°-СЏС‘]*))/iu.test(text); return (hasSelectedObjectInventoryCue(text) && hasPurchaseDocumentsCue); } @@ -1397,55 +1397,128 @@ function hasSelectedObjectInventoryProfitabilitySignal(text) { return hasSelectedObjectInventoryCue(text) && (0, inventoryLifecycleCueHelpers_1.hasInventoryProfitabilityCue)(text); } function hasInventoryProvenanceSignalV2(text) { - const hasItemCue = /(?:товар|номенклатур|sku|item|product|остат(?:ок|ки)|склад)/iu.test(text); - const hasSupplierCue = (0, inventoryLifecycleCueHelpers_1.hasInventorySupplierCue)(text) || /кем\s+поставлен/iu.test(text); - const hasPurchaseCue = /(?:куплен(?:ы|а|о)?|закупк|происхождени|откуда|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставлен(?:ы|а)?|purchase\s+provenance|purchase\s+date)/iu.test(text) || (0, inventoryLifecycleCueHelpers_1.hasInventoryPurchaseStem)(text); + const hasItemCue = /(?:товар|номенклатур|sku|item|product|остат(?:РѕРє|РєРё)|склад)/iu.test(text); + const hasSupplierCue = (0, inventoryLifecycleCueHelpers_1.hasInventorySupplierCue)(text) || /кем\s+поставлен/iu.test(text); + const hasPurchaseCue = /(?:куплен(?:С‹|Р°|Рѕ)?|закупк|происхождени|откуда|РіРґРµ\s+(?:РјС‹\s+)?купили(?:\s+(?:это|его|товар|позицию))?|РіРґРµ\s+куплено|РєРѕРіРґР°\s+был\s+куплен|РєРѕРіРґР°\s+куплен|дата\s+закупк|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставлен(?:С‹|Р°)?|purchase\s+provenance|purchase\s+date)/iu.test(text) || (0, inventoryLifecycleCueHelpers_1.hasInventoryPurchaseStem)(text); return hasItemCue && hasSupplierCue && hasPurchaseCue; } function hasInventoryPurchaseDateSignal(text) { - const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text) || hasSelectedObjectInventoryCue(text); - const hasPurchaseDateCue = /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|purchase\s+date)/iu.test(text) || - /(?:когда\s+был(?:а|и|о)?\s+закупк\w*|когда\s+закупк\w*)/iu.test(text); + const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text) || hasSelectedObjectInventoryCue(text); + const hasPurchaseDateCue = /(?:РєРѕРіРґР°\s+(?:примерно\s+)?(?:РјС‹\s+)?купили|РєРѕРіРґР°\s+был\s+куплен|РєРѕРіРґР°\s+куплен|дата\s+закупк|purchase\s+date)/iu.test(text) || + /(?:РєРѕРіРґР°\s+был(?:Р°|Рё|Рѕ)?\s+закупк\w*|РєРѕРіРґР°\s+закупк\w*)/iu.test(text); return hasItemCue && hasPurchaseDateCue; } function hasInventoryPurchaseDocumentsSignalV2(text) { - const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); - const hasPurchaseDocCue = /(?:по\s+каким\s+документам\s+был\s+куплен|по\s+каким\s+документам\s+куплен|какими\s+документами\s+был\s+куплен|документ(?:ам|ы)\s+закупк|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(text); + const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); + const hasPurchaseDocCue = /(?:РїРѕ\s+каким\s+документам\s+был\s+куплен|РїРѕ\s+каким\s+документам\s+куплен|какими\s+документами\s+был\s+куплен|документ(?:ам|С‹)\s+закупк|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(text); return hasItemCue && hasPurchaseDocCue; } function hasInventorySaleTraceSignalV2(text) { - const hasItemCue = /(?:товар|номенклатур|sku|item|product|позици(?:я|ю|и)|продукци(?:я|ю|и))/iu.test(text); - const hasTraceCue = /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|куда\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали(?:\s+(?:это|его|товар|позицию))?|куда\s+(?:была\s+)?реализована\s+(?:позиция|номенклатура|продукция)|кто\s+купил|buyer|sale\s+trace|trace\s+of\s+sale|через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*warehouse\s*->\s*sale|purchase\s*->\s*stock\s*->\s*sale)/iu.test(text); + const hasItemCue = /(?:товар|номенклатур|sku|item|product|позици(?:СЏ|СЋ|Рё)|продукци(?:СЏ|СЋ|Рё))/iu.test(text); + const hasTraceCue = /(?:РєРѕРјСѓ\s+(?:РІ\s+итоге\s+)?(?:РјС‹\s+)?продали|РєРѕРјСѓ\s+был\s+продан|РєСѓРґР°\s+(?:РІ\s+итоге\s+)?(?:РјС‹\s+)?продали(?:\s+(?:это|его|товар|позицию))?|РєСѓРґР°\s+(?:была\s+)?реализована\s+(?:позиция|номенклатура|продукция)|кто\s+РєСѓРїРёР»|buyer|sale\s+trace|trace\s+of\s+sale|через\s+какие\s+документы\s+РїСЂРѕС€[её]Р»\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*warehouse\s*->\s*sale|purchase\s*->\s*stock\s*->\s*sale)/iu.test(text); return hasItemCue && hasTraceCue; } function hasInventorySupplierStockOverlapSignal(text) { - const hasDirectSingleItemSupplierQuestion = /(?:от\s+какого\s+поставщика\s+куплен\s+(?:товар|номенклатур(?:а|у|ы)|позици(?:я|ю|и))|от\s+кого\s+куплен\s+(?:товар|номенклатур(?:а|у|ы)|позици(?:я|ю|и)))/iu.test(text); + const hasDirectSingleItemSupplierQuestion = /(?:РѕС‚\s+какого\s+поставщика\s+куплен\s+(?:товар|номенклатур(?:Р°|Сѓ|С‹)|позици(?:СЏ|СЋ|Рё))|РѕС‚\s+РєРѕРіРѕ\s+куплен\s+(?:товар|номенклатур(?:Р°|Сѓ|С‹)|позици(?:СЏ|СЋ|Рё)))/iu.test(text); if (hasDirectSingleItemSupplierQuestion) { return false; } - const hasSupplierCue = /(?:поставщик|supplier|vendor|от\s+поставщика|у\s+поставщика)/iu.test(text); - const hasStockCue = /(?:склад|остат(?:ок|ке|ков)|лежат|лежит|сейчас\s+еще|сейчас\s+ещ[её]|на\s+дату|по\s+состоянию\s+на\s+дату|current\s+stock|stock\s+overlap|что\s+сейчас\s+лежит)/iu.test(text); + const hasSupplierCue = /(?:поставщик|supplier|vendor|РѕС‚\s+поставщика|Сѓ\s+поставщика)/iu.test(text); + const hasStockCue = /(?:склад|остат(?:РѕРє|РєРµ|РєРѕРІ)|лежат|лежит|сейчас\s+еще|сейчас\s+ещ[её]|РЅР°\s+дату|РїРѕ\s+состоянию\s+РЅР°\s+дату|current\s+stock|stock\s+overlap|что\s+сейчас\s+лежит)/iu.test(text); return hasSupplierCue && hasStockCue; } function hasInventoryAgingSignal(text) { - const hasResidueCue = /(?:остат(?:ок|ки)|в\s+остатке|среди\s+текущих\s+остатков|на\s+складе|stock\s+residue|stock\s+balance)/iu.test(text); - const hasAgingCue = /(?:стар(?:ые|ым|ых)\s+закупк|стары(?:м|х)\s+закупк(?:ам|и|ах)|относит(?:ся|ся\s+ли)?\s+.*\s+к\s+старым\s+закупк|закупал(?:ись|ся)\s+очень\s+давно|очень\s+давно|давно\s+куплен|давно\s+приобретен|куплен\s+задолго\s+до(?:\s+даты)?|закуплен(?:ы|а)?\s+давно|приобретен\s+давно|задолго\s+до(?:\s+даты)?|возраст\s+остатк|возраст\s+закупк|aged?\s+stock|old\s+purchase|old\s+purchases|old\s+stock|bought\s+long\s+ago|purchased\s+long\s+ago|aging\s+by\s+purchase\s+date|very\s+old\s+stock|very\s+old\s+purchase|old\s+procurement|older\s+purchases|aged\s+items|old\s+goods)/iu.test(text); - return hasAgingCue || (hasResidueCue && /(?:давно\s+куплен|давно\s+приобретен|задолго\s+до)/iu.test(text)); + const hasResidueCue = /(?:остат(?:РѕРє|РєРё)|РІ\s+остатке|среди\s+текущих\s+остатков|РЅР°\s+складе|stock\s+residue|stock\s+balance)/iu.test(text); + const hasAgingCue = /(?:стар(?:ые|ым|ых)\s+закупк|стары(?:Рј|С…)\s+закупк(?:ам|Рё|ах)|относит(?:СЃСЏ|СЃСЏ\s+ли)?\s+.*\s+Рє\s+старым\s+закупк|закупал(?:РёСЃСЊ|СЃСЏ)\s+очень\s+давно|очень\s+давно|давно\s+куплен|давно\s+приобретен|куплен\s+задолго\s+РґРѕ(?:\s+даты)?|закуплен(?:С‹|Р°)?\s+давно|приобретен\s+давно|задолго\s+РґРѕ(?:\s+даты)?|возраст\s+остатк|возраст\s+закупк|aged?\s+stock|old\s+purchase|old\s+purchases|old\s+stock|bought\s+long\s+ago|purchased\s+long\s+ago|aging\s+by\s+purchase\s+date|very\s+old\s+stock|very\s+old\s+purchase|old\s+procurement|older\s+purchases|aged\s+items|old\s+goods)/iu.test(text); + return hasAgingCue || (hasResidueCue && /(?:давно\s+куплен|давно\s+приобретен|задолго\s+РґРѕ)/iu.test(text)); } function hasInventoryPurchaseToSaleChainSignal(text) { - const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); - const hasChainCue = /(?:закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|закупка\s*->\s*склад\s*->\s*продажа|цепочк[аи]\s+движен|документально\s+подтвержденн\w+\s+цепочк|supplier\s*->\s*item\s*->\s*(?:buyer|customer)|supplier\s+to\s+buyer|supplier\s+to\s+item\s+to\s+buyer)/iu.test(text) || text.includes("->"); + const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); + const hasChainCue = /(?:закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|закупка\s*->\s*склад\s*->\s*продажа|цепочк[аи]\s+движен|документально\s+подтвержденн\w+\s+цепочк|supplier\s*->\s*item\s*->\s*(?:buyer|customer)|supplier\s+to\s+buyer|supplier\s+to\s+item\s+to\s+buyer)/iu.test(text) || text.includes("->"); return hasItemCue && hasChainCue; } function hasInventorySupplierToBuyerChainSignal(text) { - const hasSupplierCue = /(?:поставщик|supplier|vendor)/iu.test(text); - const hasBuyerCue = /(?:покупател|buyer|customer|client)/iu.test(text); - const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); - const hasChainCue = /(?:документально\s+подтвержденн\w+\s+цепочк|supplier\s*->\s*item\s*->\s*buyer|supplier\s*->\s*item\s*->\s*customer|supplier\s*->\s*buyer|supplier\s+to\s+buyer|supplier\s+to\s+buyer\s+chain|supplier\s+to\s+item\s+to\s+buyer|поставщик\s*->\s*товар\s*->\s*покупател|поставщик\s*->\s*товар\s*->\s*клиент|поставщик\s*->\s*товар\s*->\s*покупатель|поставщик\s+к\s+покупател|поставщик\s+к\s+клиент|поставщик\s+к\s+товару\s+и\s+покупателю)/iu.test(text) || text.includes("->"); + const hasSupplierCue = /(?:поставщик|supplier|vendor)/iu.test(text); + const hasBuyerCue = /(?:покупател|buyer|customer|client)/iu.test(text); + const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); + const hasChainCue = /(?:документально\s+подтвержденн\w+\s+цепочк|supplier\s*->\s*item\s*->\s*buyer|supplier\s*->\s*item\s*->\s*customer|supplier\s*->\s*buyer|supplier\s+to\s+buyer|supplier\s+to\s+buyer\s+chain|supplier\s+to\s+item\s+to\s+buyer|поставщик\s*->\s*товар\s*->\s*покупател|поставщик\s*->\s*товар\s*->\s*клиент|поставщик\s*->\s*товар\s*->\s*покупатель|поставщик\s+Рє\s+покупател|поставщик\s+Рє\s+клиент|поставщик\s+Рє\s+товару\s+Рё\s+покупателю)/iu.test(text) || text.includes("->"); return hasSupplierCue && hasBuyerCue && hasItemCue && hasChainCue; } +function hasCustomerRevenueRankingBridgeSignal(text) { + const normalized = String(text ?? "").trim().toLowerCase(); + if (!normalized) { + return false; + } + const hasMoneyCue = /(?:\u0434\u0435\u043d\u044c\u0433|\u0434\u0435\u043d\u0435\u0433|\u0432\u044b\u0440\u0443\u0447|\u0434\u043e\u0445\u043e\u0434|\u043e\u0431\u043e\u0440\u043e\u0442|revenue|turnover|money|inflow)/iu.test(normalized); + if (!hasMoneyCue) { + return false; + } + return /(?:\u043a\u0442\u043e\s+(?:\u043d\u0430\u043c\s+)?(?:\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?\s+\u043f\u0440\u0438\u043d\u0435\u0441(?:\s+\u0434\u0435\u043d\u0435\u0433)?|\u043f\u0440\u0438\u043d\u0435\u0441\s+\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?\s+\u0434\u0435\u043d\u0435\u0433)|who\s+brought\s+(?:us\s+)?(?:the\s+)?most\s+money)/iu.test(normalized); +} +function hasInventoryProvenanceBridgeSignal(text) { + const normalized = String(text ?? "").trim().toLowerCase(); + if (!normalized) { + return false; + } + const hasItemCue = /(?:\u0442\u043e\u0432\u0430\u0440|\u043f\u043e\u0437\u0438\u0446|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440|sku|item|product)/iu.test(normalized); + const hasSupplierCue = /(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043e\u0442\s+\u043a\u0430\u043a\u043e\u0433\u043e|\u043a\u0442\u043e\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0438\u043b|supplier|vendor)/iu.test(normalized); + const hasPurchaseCue = /(?:\u043a\u0443\u043f\u043b\u0435\u043d|\u0437\u0430\u043a\u0443\u043f|\u043a\u043e\u0433\u0434\u0430\s+\u043a\u0443\u043f\u0438\u043b|\u043a\u0443\u043f\u0438\u043b\u0438|purchase)/iu.test(normalized); + return hasItemCue && hasSupplierCue && hasPurchaseCue; +} +function hasInventoryDocumentaryChainBridgeSignal(text) { + const normalized = String(text ?? "").trim().toLowerCase(); + if (!normalized) { + return false; + } + const hasChainCue = /(?:\u0446\u0435\u043f\u043e\u0447|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u043b|\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434|->|\u2192|chain|trace)/iu.test(normalized); + const hasSupplierCue = /(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|supplier|vendor)/iu.test(normalized); + const hasBuyerCue = /(?:\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b|\u043a\u043b\u0438\u0435\u043d\u0442|buyer|customer|client)/iu.test(normalized); + const hasItemCue = /(?:\u0442\u043e\u0432\u0430\u0440|\u043f\u043e\u0437\u0438\u0446|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440|sku|item|product)/iu.test(normalized); + return hasChainCue && hasSupplierCue && hasBuyerCue && hasItemCue; +} +function hasColloquialInventoryOnHandBridgeSignal(text) { + const normalized = String(text ?? "").trim().toLowerCase(); + if (!normalized) { + return false; + } + const tokenCount = normalized.split(/\s+/u).filter(Boolean).length; + const hasWarehouseCue = /(?:\u0441\u043a\u043b\u0430\u0434(?:\u0430\u0445|\u0435|\u0443|\u043e\u043c|\u044b)?|\u043e\u0441\u0442\u0430\u0442|warehouse|stock|inventory)/iu.test(normalized); + if (!hasWarehouseCue) { + return false; + } + const hasQuestionCue = /(?:\u0447\u0442\u043e|\u0447\u0435|\u0447\u0451|\u043a\u0430\u043a\u0438\u0435|\u043f\u043e\u043a\u0430\u0436\u0438|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c|show|list|what)/iu.test(normalized); + return hasQuestionCue && tokenCount <= 8; +} function resolveAddressIntent(userMessage) { const text = String(userMessage ?? "").trim().toLowerCase(); + if (hasCustomerRevenueRankingBridgeSignal(text)) { + return { + intent: "customer_revenue_and_payments", + confidence: "medium", + reasons: ["customer_revenue_ranking_bridge_signal_detected"] + }; + } + if (hasInventoryDocumentaryChainBridgeSignal(text)) { + return { + intent: "inventory_purchase_to_sale_chain", + confidence: "medium", + reasons: ["inventory_documentary_chain_bridge_signal_detected"] + }; + } + if (hasInventoryProvenanceBridgeSignal(text)) { + return { + intent: "inventory_purchase_provenance_for_item", + confidence: "medium", + reasons: ["inventory_provenance_bridge_signal_detected"] + }; + } + if (hasColloquialInventoryOnHandBridgeSignal(text)) { + return { + intent: "inventory_on_hand_as_of_date", + confidence: "medium", + reasons: ["inventory_on_hand_colloquial_bridge_signal_detected"] + }; + } if (hasVatLiabilityConfirmedTaxPeriodSignal(text)) { return { intent: "vat_liability_confirmed_for_tax_period", @@ -1550,136 +1623,6 @@ function resolveAddressIntent(userMessage) { if (inventoryIntent) { return inventoryIntent; } - if (/(?:старым\s+закупк(?:ам|и|ах)|относится\s+ли\s+.*\s+к\s+старым\s+закупк(?:ам|и|ах)|очень\s+давно|давно\s+куплен|давно\s+приобретен|old\s+stock|old\s+purchase|aging\s+by\s+purchase\s+date)/iu.test(text)) { - return { - intent: "inventory_aging_by_purchase_date", - confidence: "high", - reasons: ["inventory_aging_signal_detected_strong"] - }; - } - if (hasInventoryAccount41Anchor(text) && hasInventoryAsOfCue(text)) { - return { - intent: "inventory_on_hand_as_of_date", - confidence: "high", - reasons: ["inventory_account_41_as_of_date_signal_detected"] - }; - } - if (/(?:без\s+понятн(?:ой|ого)\s+привязк(?:и|а)\s+к\s+поставщик|без\s+привязк(?:и|а)\s+к\s+поставщик|unresolved\s+supplier\s+link)/iu.test(text)) { - return { - intent: "inventory_supplier_stock_overlap_as_of_date", - confidence: "medium", - reasons: ["inventory_unresolved_provenance_signal_detected"] - }; - } - if (hasInventorySupplierStockOverlapSignal(text)) { - return { - intent: "inventory_supplier_stock_overlap_as_of_date", - confidence: "medium", - reasons: ["inventory_supplier_stock_overlap_signal_detected"] - }; - } - if (/(?:supplier\s*->\s*buyer|supplier\s+to\s+buyer|supplier\s+to\s+buyer\s+chain|поставщик\s+к\s+покупателю|поставщик\s*->\s*товар\s*->\s*покупател|документально\s+подтвержденн\w+\s+цепочк)/iu.test(text) && - /(?:поставщик|supplier|vendor)/iu.test(text) && - /(?:покупател|buyer|customer|client)/iu.test(text) && - /(?:товар|номенклатур|sku|item|product)/iu.test(text)) { - return { - intent: "inventory_purchase_to_sale_chain", - confidence: "high", - reasons: ["inventory_supplier_to_buyer_chain_signal_detected_strong"] - }; - } - if (hasInventoryPurchaseToSaleChainSignal(text)) { - return { - intent: "inventory_purchase_to_sale_chain", - confidence: "medium", - reasons: ["inventory_purchase_to_sale_chain_signal_detected"] - }; - } - if (hasInventoryAgingSignal(text)) { - return { - intent: "inventory_aging_by_purchase_date", - confidence: "medium", - reasons: ["inventory_aging_signal_detected"] - }; - } - if (hasSelectedObjectInventoryProvenanceSignal(text)) { - return { - intent: "inventory_purchase_provenance_for_item", - confidence: "medium", - reasons: ["inventory_selected_object_provenance_signal_detected"] - }; - } - if (hasInventoryProvenanceSignalV2(text)) { - return { - intent: "inventory_purchase_provenance_for_item", - confidence: "medium", - reasons: ["inventory_provenance_signal_detected"] - }; - } - if (hasInventoryPurchaseDateSignal(text)) { - return { - intent: "inventory_purchase_provenance_for_item", - confidence: "medium", - reasons: ["inventory_purchase_date_signal_detected"] - }; - } - if (hasSelectedObjectInventoryPurchaseDocumentsSignal(text)) { - return { - intent: "inventory_purchase_documents_for_item", - confidence: "medium", - reasons: ["inventory_selected_object_purchase_documents_signal_detected"] - }; - } - if (hasInventoryPurchaseDocumentsSignalV2(text)) { - return { - intent: "inventory_purchase_documents_for_item", - confidence: "medium", - reasons: ["inventory_purchase_documents_signal_detected"] - }; - } - if (hasSelectedObjectInventoryProfitabilitySignal(text)) { - return { - intent: "inventory_profitability_for_item", - confidence: "medium", - reasons: ["inventory_selected_object_profitability_signal_detected"] - }; - } - if (hasSelectedObjectInventorySaleTraceSignal(text)) { - return { - intent: "inventory_sale_trace_for_item", - confidence: "medium", - reasons: ["inventory_selected_object_sale_trace_signal_detected"] - }; - } - if (/(?:кому\s+(?:мы\s+)?впарили(?:\s+(?:это|его|товар|позицию))?|кому\s+в\s+итоге\s+мы\s+впарили)/iu.test(text) && - /(?:товар|номенклатур|sku|item|product|позици(?:я|ю|и)|продукци(?:я|ю|и))/iu.test(text)) { - return { - intent: "inventory_sale_trace_for_item", - confidence: "medium", - reasons: ["inventory_sale_trace_signal_detected"] - }; - } - if (hasInventorySaleTraceSignalV2(text)) { - return { - intent: "inventory_sale_trace_for_item", - confidence: "medium", - reasons: ["inventory_sale_trace_signal_detected"] - }; - } - if (hasInventorySupplierToBuyerChainSignal(text)) { - return { - intent: "inventory_purchase_to_sale_chain", - confidence: "medium", - reasons: ["inventory_supplier_to_buyer_chain_signal_detected"] - }; - } - if (hasInventoryOnHandSignal(text)) { - return { - intent: "inventory_on_hand_as_of_date", - confidence: "high", - reasons: ["inventory_on_hand_signal_detected"] - }; - } const counterpartyIntent = (0, addressCounterpartyIntentSignals_1.resolveCounterpartyAddressIntent)(text, { hasAny, openItemsHints: OPEN_ITEMS_HINTS, @@ -1718,166 +1661,6 @@ function resolveAddressIntent(userMessage) { if (counterpartyIntent) { return counterpartyIntent; } - if (hasOpenContractsListSignal(text)) { - return { - intent: "open_contracts_confirmed_as_of_date", - confidence: "medium", - reasons: ["open_contract_signal_detected"] - }; - } - if (hasAny(text, OPEN_ITEMS_HINTS) && - !hasCounterpartyDebtLongevitySignal(text) && - !hasInventoryAgingSignal(text) && - !hasInventoryProvenanceSignalV2(text) && - !hasInventoryPurchaseDocumentsSignalV2(text) && - !hasInventorySaleTraceSignalV2(text) && - (/(?:контраг|договор|контракт|counterparty|contract|покупател|клиент|заказчик|customer|client|buyer|supplier|поставщик)/iu.test(text) || - hasAccountNumberAnchor(text) || - hasCompactAccountCodeToken(text))) { - return { - intent: "open_items_by_counterparty_or_contract", - confidence: "medium", - reasons: ["open_items_signal_detected"] - }; - } - if (hasPeriodCoverageProfileSignal(text) && - !hasPartyAnchorMention(text) && - !hasContractAnchorSignal(text) && - !hasAccountBalanceSignal(text)) { - return { - intent: "period_coverage_profile", - confidence: "high", - reasons: ["period_coverage_profile_signal_detected"] - }; - } - if (hasDocumentTypeAndAccountSectionProfileSignal(text) && - !hasPartyAnchorMention(text) && - !hasContractAnchorSignal(text) && - !hasAccountBalanceSignal(text)) { - return { - intent: "document_type_and_account_section_profile", - confidence: "high", - reasons: ["document_type_and_account_section_profile_signal_detected"] - }; - } - if (hasCounterpartyPopulationAndRolesSignal(text) && - !hasContractAnchorSignal(text) && - !hasAccountBalanceSignal(text)) { - return { - intent: "counterparty_population_and_roles", - confidence: "high", - reasons: ["counterparty_population_and_roles_signal_detected"] - }; - } - if (hasCounterpartyActivityLifecycleSignal(text) && - !hasContractAnchorSignal(text) && - !hasAccountBalanceSignal(text)) { - return { - intent: "counterparty_activity_lifecycle", - confidence: "high", - reasons: ["counterparty_activity_lifecycle_signal_detected"] - }; - } - if (hasContractUsageOverviewSignal(text) && - !hasAccountBalanceSignal(text) && - !hasOpenContractsListSignal(text)) { - return { - intent: "contract_usage_overview", - confidence: "high", - reasons: ["contract_usage_overview_signal_detected"] - }; - } - if (hasCustomerRevenueAndPaymentsSignal(text) && !hasAccountBalanceSignal(text)) { - return { - intent: "customer_revenue_and_payments", - confidence: "high", - reasons: ["customer_revenue_and_payments_signal_detected"] - }; - } - if (hasSupplierPayoutsProfileSignal(text) && !hasAccountBalanceSignal(text)) { - return { - intent: "supplier_payouts_profile", - confidence: "high", - reasons: ["supplier_payouts_profile_signal_detected"] - }; - } - if (hasContractUsageAndValueSignal(text) && - !hasAccountBalanceSignal(text) && - !hasOpenContractsListSignal(text)) { - return { - intent: "contract_usage_and_value", - confidence: "high", - reasons: ["contract_usage_and_value_signal_detected"] - }; - } - if (hasContractListByCounterpartySignal(text)) { - return { - intent: "list_contracts_by_counterparty", - confidence: "medium", - reasons: ["contracts_by_counterparty_signal_detected"] - }; - } - if (hasContractAnchorSignal(text) && - hasBankOperationSignal(text)) { - return { - intent: "bank_operations_by_contract", - confidence: "medium", - reasons: ["bank_ops_by_contract_signal_detected"] - }; - } - if (hasContractAnchorSignal(text) && - (hasAny(text, DOCUMENTS_BY_CONTRACT_HINTS) || hasDocumentSignal(text))) { - return { - intent: "list_documents_by_contract", - confidence: "medium", - reasons: ["documents_by_contract_signal_detected"] - }; - } - if (hasAny(text, BANK_OPERATIONS_BY_COUNTERPARTY_HINTS) && - (hasPartyAnchorMention(text) || hasLooseByAnchorMention(text) || hasHeuristicCounterpartyAnchor(text))) { - return { - intent: "bank_operations_by_counterparty", - confidence: "medium", - reasons: ["bank_ops_by_counterparty_signal_detected"] - }; - } - if ((hasAny(text, DOCUMENTS_BY_COUNTERPARTY_HINTS) || hasCounterpartyShipmentItemFlowSignal(text)) && - (hasPartyAnchorMention(text) || - hasLooseByAnchorMention(text) || - hasImplicitCounterpartyAnchorAroundDocs(text) || - hasHeuristicCounterpartyAnchor(text) || - hasCounterpartyShipmentItemFlowSignal(text))) { - return { - intent: "list_documents_by_counterparty", - confidence: "medium", - reasons: [ - hasCounterpartyShipmentItemFlowSignal(text) - ? "counterparty_item_flow_signal_detected" - : "documents_by_counterparty_signal_detected" - ] - }; - } - if (hasAccountBalanceSignal(text)) { - return { - intent: "account_balance_snapshot", - confidence: "high", - reasons: ["account_balance_signal_detected"] - }; - } - if (hasLooseByAnchorMention(text) && hasGenericAddressLookupSignal(text)) { - return { - intent: "list_documents_by_counterparty", - confidence: "low", - reasons: ["generic_lookup_with_loose_anchor_fallback"] - }; - } - if (hasAny(text, OPEN_CONTRACTS_HINTS) && (text.includes("договор") || text.includes("контракт") || text.includes("contract"))) { - return { - intent: "open_contracts_confirmed_as_of_date", - confidence: "medium", - reasons: ["open_contract_signal_detected"] - }; - } return { intent: "unknown", confidence: "low", diff --git a/llm_normalizer/backend/dist/services/addressInventoryIntentSignals.js b/llm_normalizer/backend/dist/services/addressInventoryIntentSignals.js index 5f5117e..6e3a320 100644 --- a/llm_normalizer/backend/dist/services/addressInventoryIntentSignals.js +++ b/llm_normalizer/backend/dist/services/addressInventoryIntentSignals.js @@ -8,12 +8,26 @@ function hasInventoryAccount41Anchor(text) { function hasInventoryAsOfCue(text) { return /(?:сейчас|текущ|РЅР°\s+дату|РїРѕ\s+состоянию|срез|РЅР°\s+конец|date|as\s+of|current|now|today)/iu.test(text); } +function hasPlainRussianInventoryOnHandSignal(text) { + const normalized = String(text ?? "").toLowerCase().replace(/ё/g, "е"); + if (!normalized) { + return false; + } + const hasStockCue = /(?:остатк|склад|товар(?:ы|ов)?|номенклатур|позици|на\s+складе|по\s+складу)/iu.test(normalized); + if (!hasStockCue) { + return false; + } + const hasRequestCue = /(?:какие|какой|какая|что|покажи|показать|получить|дай|выведи|чекни|посмотри|есть\s+ли)/iu.test(normalized); + const hasSnapshotCue = /(?:на\s+(?:дату|сегодня|сейчас|март|апрел|май|мая|июн|июл|август|сентябр|октябр|ноябр|декабр|январ|феврал)|\b(?:19|20)\d{2}\b)/iu.test(normalized); + return hasRequestCue && (hasSnapshotCue || /остатк/iu.test(normalized)); +} function hasInventoryOnHandSignal(text) { const hasColloquialStockSnapshotCue = /(?:что|С‡[еёо])\s+(?:Сѓ\s+нас\s+)?РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)(?=$|[\s,.;:!?])/iu.test(text); const hasStockStateCue = /(?:(?:что|С‡[еёо])\s+там\s+РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)|(?:что|С‡[еёо]).*РїСЂРѕРёСЃС…РѕРґ(?:РёС‚|ило|ящее).*(?:РЅР°\s+)?склад(?:Рµ|Сѓ|РѕРј|ах)|РїСЂРѕРёСЃС…РѕРґ(?:РёС‚|ило|ящее)\s+РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)|ситуац(?:РёСЏ|РёРё)\s+РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)|обстановк(?:Р°|Рё)\s+РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)|what(?:'s| is)?\s+(?:there\s+)?(?:on|in)\s+(?:the\s+)?(?:warehouse|stock)|what(?:'s| is)?\s+happening\s+(?:on|in)\s+(?:the\s+)?(?:warehouse|stock))/iu.test(text); const hasAccount41Anchor = hasInventoryAccount41Anchor(text); const hasStockLexeme = /(?:склад(?:Рµ|Сѓ|РѕРј|С‹|РѕРІ)?|warehouse|stock(?:room)?|inventory|on[\s-]?hand)/iu.test(text); - if (!hasStockLexeme && !hasAccount41Anchor) { + const hasPlainRussianRootCue = hasPlainRussianInventoryOnHandSignal(text); + if (!hasStockLexeme && !hasAccount41Anchor && !hasPlainRussianRootCue) { return false; } if (hasInventoryProvenanceSignalV2(text) || @@ -29,6 +43,9 @@ function hasInventoryOnHandSignal(text) { if (hasAccount41Anchor && (hasGoodsLexeme || hasBalanceLexeme || hasRequestCue || hasInventoryAsOfCue(text))) { return true; } + if (hasPlainRussianRootCue) { + return true; + } return (hasGoodsLexeme || hasBalanceLexeme || hasColloquialStockSnapshotCue || hasStockStateCue) && (hasRequestCue || hasBalanceLexeme || hasColloquialStockSnapshotCue || hasStockStateCue); } @@ -171,6 +188,14 @@ function resolveInventoryAddressIntent(text) { reasons: ["inventory_purchase_date_signal_detected"] }; } + if ((text.includes("по этой позиции") || text.includes("по этому товару")) && + (text.includes("когда была закупка") || text.includes("когда купили"))) { + return { + intent: "inventory_purchase_provenance_for_item", + confidence: "medium", + reasons: ["inventory_purchase_date_signal_detected"] + }; + } if (hasSelectedObjectInventoryPurchaseDocumentsSignal(text)) { return { intent: "inventory_purchase_documents_for_item", diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index 50d4a92..0bf6702 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -1461,6 +1461,24 @@ function isOrganizationScopedInventoryIntent(intent) { intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date"); } +function shouldDeferInventoryOrganizationClarification(intent, filters, semanticFrame) { + if (!isOrganizationScopedInventoryIntent(intent)) { + return false; + } + const hasItemScope = Boolean(toNonEmptyFilterValue(filters.item)) || + semanticFrame?.anchor_kind === "item" || + semanticFrame?.anchor_kind === "selected_object" || + semanticFrame?.selected_object_scope_detected === true; + if (hasItemScope) { + return true; + } + return (intent === "inventory_purchase_provenance_for_item" || + intent === "inventory_purchase_documents_for_item" || + intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || + intent === "inventory_purchase_to_sale_chain" || + intent === "inventory_aging_by_purchase_date"); +} function collectOrganizationCandidatesFromRows(rows) { return (0, assistantOrganizationMatcher_1.mergeKnownOrganizations)(rows.map((row) => row.organization).filter((value) => Boolean(value))); } @@ -2584,6 +2602,7 @@ class AddressQueryService { !toNonEmptyFilterValue(filters.extracted_filters.organization) && !activeOrganization && !resolvedOrganizationFromMessage && + !shouldDeferInventoryOrganizationClarification(intent.intent, filters.extracted_filters, semanticFrame) && knownOrganizations.length > 1) { return buildOrganizationClarificationExecutionResult({ mode, diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index 23eda66..74bc2ca 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -3,8 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.contractCandidatesFromRows = contractCandidatesFromRows; exports.composeFactualReply = composeFactualReply; exports.inferReplyType = inferReplyType; -const assistantOrganizationMatcher_1 = require("../assistantOrganizationMatcher"); const replyPackaging_1 = require("./replyPackaging"); +const counterpartyAnalyticsReplyBuilders_1 = require("./counterpartyAnalyticsReplyBuilders"); const inventoryReplyBuilders_1 = require("./inventoryReplyBuilders"); function uniqueStrings(values) { return Array.from(new Set(values @@ -2135,6 +2135,30 @@ function composeFactualReplyBody(intent, rows, options = {}) { if (inventoryReply) { return inventoryReply; } + const counterpartyAnalyticsReply = (0, counterpartyAnalyticsReplyBuilders_1.composeCounterpartyAnalyticsReply)(intent, rows, options, { + formatPercent, + formatDateRu, + formatMoneyRub, + extractYearFromIso, + detectCounterpartyProfileFocus, + detectCounterpartyLifecycleFocus, + hasCounterpartyLifecycleLongevityQuestion, + hasCounterpartyActivityAgeQuestion, + detectRankingLimit, + detectValueRankingFocus, + detectContractValueFocus, + detectMinOpsForAvgCheck, + extractRequestedYearFromQuestion, + extractCounterpartyName, + extractContractName, + counterpartyLookupMatches, + toUtcDayTimestamp, + formatAgeYearsMonthsDays, + normalizeQuestionText + }); + if (counterpartyAnalyticsReply) { + return counterpartyAnalyticsReply; + } if (intent === "document_type_and_account_section_profile") { const rowsByMarker = new Map(); for (const row of rows) { @@ -2366,692 +2390,6 @@ function composeFactualReplyBody(intent, rows, options = {}) { text: lines.join("\n") }; } - if (intent === "counterparty_population_and_roles") { - const rowsByMarker = new Map(); - for (const row of rows) { - const marker = String(row.registrator ?? "").trim().toUpperCase(); - if (!marker) { - continue; - } - if (!rowsByMarker.has(marker)) { - rowsByMarker.set(marker, []); - } - rowsByMarker.get(marker).push(row); - } - const sumMarker = (marker) => (rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0); - const totalCounterparties = sumMarker("CP_TOTAL"); - const customerActive = sumMarker("CP_CUSTOMER_ACTIVE"); - const supplierActive = sumMarker("CP_SUPPLIER_ACTIVE"); - const mixedActive = sumMarker("CP_MIXED_ACTIVE"); - const activeUnion = sumMarker("CP_ACTIVE_UNION"); - const customerOnly = Math.max(0, customerActive - mixedActive); - const supplierOnly = Math.max(0, supplierActive - mixedActive); - const resolvedActive = customerOnly + supplierOnly + mixedActive; - const activeCounterparties = Math.max(activeUnion, resolvedActive); - const otherCounterparties = totalCounterparties > 0 ? Math.max(0, totalCounterparties - resolvedActive) : null; - const focus = detectCounterpartyProfileFocus(options.userMessage); - const includeTotal = focus === "full_profile" || focus === "total_only"; - const includeRoles = focus === "full_profile" || focus === "roles_only"; - const directLead = focus === "suppliers_only" - ? `Поставщиков (только supplier-роль): ${supplierOnly}.` - : focus === "customers_only" - ? `Заказчиков (только customer-роль): ${customerOnly}.` - : focus === "mixed_only" - ? `Смешанных контрагентов (и customer, и supplier): ${mixedActive}.` - : includeTotal && totalCounterparties > 0 - ? `Всего уникальных контрагентов в базе: ${totalCounterparties}.` - : `Активных контрагентов по операциям: ${activeCounterparties}.`; - const lines = [ - directLead, - "Профиль контрагентов собран (catalog + bank-doc activity aggregate).", - `Строк агрегата: ${rows.length}.` - ]; - if (includeTotal) { - if (totalCounterparties > 0) { - lines.push(`Всего уникальных контрагентов в базе: ${totalCounterparties}.`); - } - else if (activeCounterparties > 0) { - lines.push(`Total из справочника не получен, оценка по активности в документах: ${activeCounterparties} контрагентов.`); - } - else { - lines.push("По количеству контрагентов агрегатных строк не найдено."); - } - } - if (includeRoles) { - if (resolvedActive > 0 || activeCounterparties > 0) { - lines.push("Роли контрагентов по активности:"); - lines.push(`1. Заказчики (только customer-роль): ${customerOnly}.`); - lines.push(`2. Поставщики (только supplier-роль): ${supplierOnly}.`); - lines.push(`3. Смешанные (и покупатель, и поставщик): ${mixedActive}.`); - lines.push(`4. Активные контрагенты (union ролей): ${activeCounterparties}.`); - if (otherCounterparties !== null) { - lines.push(`5. Прочие/неактивные в выбранном окне: ${otherCounterparties}.`); - } - } - else { - lines.push("По role-split контрагентов агрегатных строк не найдено."); - } - } - if (focus === "suppliers_only") { - lines.push(`Поставщиков (только supplier-роль): ${supplierOnly}.`); - } - if (focus === "customers_only") { - lines.push(`Заказчиков (только customer-роль): ${customerOnly}.`); - } - if (focus === "mixed_only") { - lines.push(`Смешанных контрагентов (и customer, и supplier): ${mixedActive}.`); - } - return { - responseType: "FACTUAL_SUMMARY", - text: lines.join("\n") - }; - } - if (intent === "counterparty_activity_lifecycle") { - const activityFirstRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_FIRST"); - const activityRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY"); - const activityYearRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_YEAR"); - const byCounterparty = new Map(); - for (const row of activityFirstRows) { - const name = extractCounterpartyName(row); - if (!name) { - continue; - } - const opsCount = Math.max(0, Math.trunc(row.amount ?? 0)); - const year = extractYearFromIso(row.period); - const current = byCounterparty.get(name); - if (!current) { - byCounterparty.set(name, { - name, - opsCount, - lastPeriod: row.period, - firstPeriod: row.period, - firstObservedActivity: row.period, - years: new Set(year !== null ? [year] : []) - }); - continue; - } - if (!current.firstObservedActivity || (row.period ?? "") < current.firstObservedActivity) { - current.firstObservedActivity = row.period; - } - if ((row.period ?? "") < (current.firstPeriod ?? "")) { - current.firstPeriod = row.period; - } - if (year !== null) { - current.years.add(year); - } - } - for (const row of activityYearRows) { - const name = extractCounterpartyName(row); - if (!name) { - continue; - } - const opsCount = Math.max(0, Math.trunc(row.amount ?? 0)); - const year = extractYearFromIso(row.period); - const current = byCounterparty.get(name); - if (!current) { - byCounterparty.set(name, { - name, - opsCount, - lastPeriod: row.period, - firstPeriod: row.period, - firstObservedActivity: null, - years: new Set(year !== null ? [year] : []) - }); - continue; - } - current.opsCount += opsCount; - if ((row.period ?? "") > (current.lastPeriod ?? "")) { - current.lastPeriod = row.period; - } - if ((row.period ?? "") < (current.firstPeriod ?? "")) { - current.firstPeriod = row.period; - } - if (year !== null) { - current.years.add(year); - } - } - for (const row of activityRows) { - const name = extractCounterpartyName(row); - if (!name) { - continue; - } - const opsCount = Math.max(0, Math.trunc(row.amount ?? 0)); - const current = byCounterparty.get(name); - if (!current) { - const year = extractYearFromIso(row.period); - byCounterparty.set(name, { - name, - opsCount, - lastPeriod: row.period, - firstPeriod: row.period, - firstObservedActivity: row.period, - years: new Set(year !== null ? [year] : []) - }); - continue; - } - if (activityYearRows.length === 0 && opsCount > current.opsCount) { - current.opsCount = opsCount; - } - if ((row.period ?? "") > (current.lastPeriod ?? "")) { - current.lastPeriod = row.period; - } - if ((row.period ?? "") < (current.firstPeriod ?? "")) { - current.firstPeriod = row.period; - } - const year = extractYearFromIso(row.period); - if (year !== null) { - current.years.add(year); - } - } - const counterpartiesRaw = Array.from(byCounterparty.values()); - const focus = detectCounterpartyLifecycleFocus(options.userMessage); - const requestedYear = extractRequestedYearFromQuestion(options.userMessage); - const longevityQuestion = hasCounterpartyLifecycleLongevityQuestion(options.userMessage); - const activityAgeQuestion = hasCounterpartyActivityAgeQuestion(options.userMessage); - const rankingLimit = detectRankingLimit(options.userMessage, 10); - const counterparties = counterpartiesRaw.sort((left, right) => { - if (longevityQuestion) { - const yearsDiff = right.years.size - left.years.size; - if (yearsDiff !== 0) { - return yearsDiff; - } - } - if (right.opsCount !== left.opsCount) { - return right.opsCount - left.opsCount; - } - return (right.lastPeriod ?? "").localeCompare(left.lastPeriod ?? ""); - }); - const scopeLabel = focus === "active_customers_all_time" - ? "за все время" - : requestedYear - ? `в ${requestedYear} году` - : "в выбранном периоде"; - if (activityAgeQuestion) { - const focusedCounterparty = counterparties.find((item) => counterpartyLookupMatches(item.name, options.counterpartyHint)) ?? null; - if (focusedCounterparty) { - const firstObservedActivity = focusedCounterparty.firstObservedActivity ?? focusedCounterparty.firstPeriod; - const lastObservedActivity = focusedCounterparty.lastPeriod; - const firstTimestamp = toUtcDayTimestamp(firstObservedActivity); - const lastTimestamp = toUtcDayTimestamp(lastObservedActivity); - const observedDays = firstTimestamp !== null && lastTimestamp !== null && lastTimestamp >= firstTimestamp - ? Math.floor((lastTimestamp - firstTimestamp) / 86_400_000) - : null; - const observedAgeLabel = observedDays !== null - ? formatAgeYearsMonthsDays(observedDays) - : focusedCounterparty.years.size > 0 - ? `${focusedCounterparty.years.size} г.` - : null; - const directLine = observedAgeLabel && firstObservedActivity && lastObservedActivity - ? `По активности в базе 1С контрагент ${focusedCounterparty.name} наблюдается минимум ${observedAgeLabel}.` - : `По активности в базе 1С контрагент ${focusedCounterparty.name} найден в подтвержденных движениях.`; - const lines = [directLine]; - if (firstObservedActivity) { - lines.push(`Первая подтвержденная активность: ${formatDateRu(firstObservedActivity)}.`); - } - if (lastObservedActivity) { - lines.push(`Последняя подтвержденная активность: ${formatDateRu(lastObservedActivity)}.`); - } - lines.push(`Подтвержденных операций в агрегате: ${focusedCounterparty.opsCount}.`); - if (focusedCounterparty.years.size > 0) { - const years = Array.from(focusedCounterparty.years).sort((a, b) => a - b); - lines.push(`Годы с активностью в базе: ${years.join(", ")}.`); - } - lines.push("Это возраст активности в 1С по подтвержденным движениям, а не дата регистрации юрлица."); - return { - responseType: "FACTUAL_SUMMARY", - text: lines.join("\n") - }; - } - const organizationHint = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(options.organizationHint ?? null); - if (organizationHint && counterparties.length > 0) { - const organizationFirstObservedActivity = counterparties.reduce((earliest, item) => { - const candidate = item.firstObservedActivity ?? item.firstPeriod ?? null; - if (!candidate) { - return earliest; - } - if (!earliest || candidate < earliest) { - return candidate; - } - return earliest; - }, null); - const organizationLastObservedActivity = counterparties.reduce((latest, item) => { - const candidate = item.lastPeriod ?? item.firstPeriod ?? item.firstObservedActivity ?? null; - if (!candidate) { - return latest; - } - if (!latest || candidate > latest) { - return candidate; - } - return latest; - }, null); - const organizationYears = new Set(); - let organizationOpsCount = 0; - for (const item of counterparties) { - organizationOpsCount += item.opsCount; - for (const year of item.years) { - organizationYears.add(year); - } - } - const firstTimestamp = toUtcDayTimestamp(organizationFirstObservedActivity); - const lastTimestamp = toUtcDayTimestamp(organizationLastObservedActivity); - const observedDays = firstTimestamp !== null && lastTimestamp !== null && lastTimestamp >= firstTimestamp - ? Math.floor((lastTimestamp - firstTimestamp) / 86_400_000) - : null; - const observedAgeLabel = observedDays !== null - ? formatAgeYearsMonthsDays(observedDays) - : organizationYears.size > 0 - ? `${organizationYears.size} г.` - : null; - const lines = [ - observedAgeLabel && organizationFirstObservedActivity && organizationLastObservedActivity - ? `По активности организации ${organizationHint} в базе 1С наблюдается минимум ${observedAgeLabel}.` - : `По активности организации ${organizationHint} в базе 1С найдены подтвержденные движения.` - ]; - if (organizationFirstObservedActivity) { - lines.push(`Первая подтвержденная активность: ${formatDateRu(organizationFirstObservedActivity)}.`); - } - if (organizationLastObservedActivity) { - lines.push(`Последняя подтвержденная активность: ${formatDateRu(organizationLastObservedActivity)}.`); - } - lines.push(`Подтвержденных операций в агрегате: ${organizationOpsCount}.`); - if (organizationYears.size > 0) { - const years = Array.from(organizationYears).sort((a, b) => a - b); - lines.push(`Годы с активностью в базе: ${years.join(", ")}.`); - } - lines.push("Это возраст активности организации в 1С по подтвержденным движениям, а не дата регистрации юрлица."); - return { - responseType: "FACTUAL_SUMMARY", - text: lines.join("\n") - }; - } - } - const lines = longevityQuestion - ? [ - `Заказчиков с самым длинным горизонтом сотрудничества (по годам): ${counterparties.length}.`, - "Собран lifecycle-профиль заказчиков: ранжирование по числу лет и частоте активности.", - `Строк агрегата: ${rows.length}.` - ] - : [ - `Активные заказчики ${scopeLabel}: ${counterparties.length}.`, - "Собран профиль активности заказчиков (bank-doc activity aggregate).", - `Строк агрегата: ${rows.length}.` - ]; - if (counterparties.length === 0) { - lines.push(longevityQuestion - ? "По доступному окну не удалось выделить заказчиков с подтвержденной длительностью сотрудничества по годам." - : "По выбранному окну активности заказчики не найдены."); - return { - responseType: "FACTUAL_SUMMARY", - text: lines.join("\n") - }; - } - const visible = counterparties.slice(0, longevityQuestion ? rankingLimit : 120); - if (longevityQuestion) { - lines.push(`Топ-${visible.length} заказчиков по охвату лет и частоте операций:`); - } - lines.push(...visible.map((item, index) => { - const years = Array.from(item.years).sort((a, b) => a - b); - const yearsLabel = years.length > 0 ? ` | лет в базе: ${years.length} | годы: ${years.join(", ")}` : ""; - const periodSpan = item.firstPeriod && item.lastPeriod ? ` | период: ${item.firstPeriod}..${item.lastPeriod}` : ""; - if (longevityQuestion) { - return `${index + 1}. ${item.name} | операций: ${item.opsCount}${yearsLabel}${periodSpan}`; - } - const suffix = item.lastPeriod ? ` | последняя активность: ${item.lastPeriod}` : ""; - return `${index + 1}. ${item.name} | операций: ${item.opsCount}${suffix}${years.length > 0 ? ` | лет в базе: ${years.length}` : ""}`; - })); - if (counterparties.length > visible.length) { - lines.push(longevityQuestion - ? `Показаны первые ${visible.length} из ${counterparties.length} заказчиков (полный список можно выгрузить отдельно).` - : `Показаны первые ${visible.length} из ${counterparties.length} заказчиков.`); - } - return { - responseType: "FACTUAL_LIST", - text: lines.join("\n") - }; - } - if (intent === "contract_usage_overview") { - const rowsByMarker = new Map(); - for (const row of rows) { - const marker = String(row.registrator ?? "").trim().toUpperCase(); - if (!marker) { - continue; - } - if (!rowsByMarker.has(marker)) { - rowsByMarker.set(marker, []); - } - rowsByMarker.get(marker).push(row); - } - const sumMarker = (marker) => (rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0); - const totalContracts = sumMarker("CT_TOTAL"); - const usedContracts = sumMarker("CT_USED"); - const unusedContracts = totalContracts > 0 ? Math.max(0, totalContracts - Math.min(usedContracts, totalContracts)) : null; - const usedShare = totalContracts > 0 ? formatPercent(Math.min(usedContracts, totalContracts), totalContracts) : null; - const usageLead = totalContracts > 0 - ? `Использованных договоров: ${usedContracts} из ${totalContracts}${usedShare ? ` (${usedShare})` : ""}.` - : `Использованных договоров (есть factual связь с операциями): ${usedContracts}.`; - const lines = [ - usageLead, - "Профиль договорной базы собран (catalog + usage aggregate).", - `Строк агрегата: ${rows.length}.` - ]; - if (totalContracts > 0) { - lines.push(`Всего договоров в базе: ${totalContracts}.`); - } - else { - lines.push("Общее количество договоров не получено (пустой/недоступный срез справочника)."); - } - lines.push(`Использованных договоров (есть factual связь с операциями): ${usedContracts}.`); - if (unusedContracts !== null) { - lines.push(`Неиспользуемых договоров: ${unusedContracts}.`); - } - if (usedShare) { - lines.push(`Доля используемых договоров: ${usedShare}.`); - } - return { - responseType: "FACTUAL_SUMMARY", - text: lines.join("\n") - }; - } - if (intent === "customer_revenue_and_payments" || intent === "supplier_payouts_profile") { - const isSupplier = intent === "supplier_payouts_profile"; - const focus = detectValueRankingFocus(options.userMessage); - const limit = detectRankingLimit(options.userMessage, 20); - const minOpsForAvgCheck = detectMinOpsForAvgCheck(options.userMessage); - const normalizedQuestion = normalizeQuestionText(options.userMessage); - const byCounterparty = new Map(); - const byYear = new Map(); - const deals = []; - for (const row of rows) { - const counterparty = extractCounterpartyName(row); - const amount = row.amount ?? 0; - if (!counterparty || !Number.isFinite(amount) || amount <= 0) { - continue; - } - const current = byCounterparty.get(counterparty); - if (!current) { - byCounterparty.set(counterparty, { - name: counterparty, - total: amount, - ops: 1, - maxSingle: amount, - minSingle: amount, - lastPeriod: row.period - }); - } - else { - current.total += amount; - current.ops += 1; - current.maxSingle = Math.max(current.maxSingle, amount); - current.minSingle = Math.min(current.minSingle, amount); - if ((row.period ?? "") > (current.lastPeriod ?? "")) { - current.lastPeriod = row.period; - } - } - deals.push({ - period: row.period, - registrator: row.registrator, - counterparty, - amount - }); - const year = extractYearFromIso(row.period); - if (year !== null) { - const yearBucket = byYear.get(year); - if (!yearBucket) { - byYear.set(year, { - year, - total: amount, - ops: 1, - maxSingle: amount, - counterparties: new Set([counterparty]) - }); - } - else { - yearBucket.total += amount; - yearBucket.ops += 1; - yearBucket.maxSingle = Math.max(yearBucket.maxSingle, amount); - yearBucket.counterparties.add(counterparty); - } - } - } - const profileRows = Array.from(byCounterparty.values()); - const yearRows = Array.from(byYear.values()); - const totalFlow = profileRows.reduce((sum, item) => sum + item.total, 0); - const totalOperations = profileRows.reduce((sum, item) => sum + item.ops, 0); - const rankedByTotal = [...profileRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.name.localeCompare(b.name)); - const rankedByYearTotal = [...yearRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.year - b.year); - const rankedByOps = [...profileRows].sort((a, b) => b.ops - a.ops || b.total - a.total || a.name.localeCompare(b.name)); - const rankedByMaxSingle = [...profileRows].sort((a, b) => b.maxSingle - a.maxSingle || b.total - a.total || a.name.localeCompare(b.name)); - const rankedByAvgCheck = [...profileRows] - .filter((item) => item.ops >= minOpsForAvgCheck) - .map((item) => ({ - ...item, - avgCheck: item.total / item.ops - })) - .sort((a, b) => b.avgCheck - a.avgCheck || b.total - a.total || a.name.localeCompare(b.name)); - const rankedDealsTop = [...deals].sort((a, b) => b.amount - a.amount || (b.period ?? "").localeCompare(a.period ?? "")); - const activeOnlyForBottomDeals = /(?:активн|active)/iu.test(normalizedQuestion); - const activeCounterpartiesForBottom = new Set(profileRows.filter((item) => item.ops >= Math.max(3, minOpsForAvgCheck)).map((item) => item.name)); - const rankedDealsBottom = [...deals] - .filter((item) => !activeOnlyForBottomDeals || activeCounterpartiesForBottom.has(item.counterparty)) - .sort((a, b) => a.amount - b.amount || (a.period ?? "").localeCompare(b.period ?? "")); - const lines = [ - isSupplier - ? "Собран профиль выплат поставщикам (bank-doc value aggregate)." - : "Собран профиль поступлений от заказчиков (bank-doc value aggregate).", - `Строк источника: ${rows.length}.`, - `Уникальных контрагентов: ${profileRows.length}.` - ]; - if (profileRows.length === 0) { - lines.push("По выбранному окну данных платежные строки не найдены."); - return { - responseType: "FACTUAL_SUMMARY", - text: lines.join("\n") - }; - } - if (focus === "total_flow") { - const periodLine = options.periodFrom && options.periodTo - ? `За период ${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)} подтверждено ${formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.` - : `За все доступное время подтверждено ${formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`; - const directAnswerLine = isSupplier - ? periodLine - : `${periodLine} Это сумма денег, полученных от клиентов, а не чистая прибыль.`; - const summaryLines = [ - directAnswerLine, - "", - "Подтверждение:", - `- Операций в выборке: ${totalOperations}.`, - `- Контрагентов в выборке: ${profileRows.length}.` - ]; - if (rankedByYearTotal.length > 0) { - summaryLines.push(`- Самый сильный год по поступлениям: ${rankedByYearTotal[0].year} (${formatMoneyRub(rankedByYearTotal[0].total)}).`); - } - if (rankedByTotal.length > 0) { - summaryLines.push(`- Крупнейший контрагент по потоку: ${rankedByTotal[0].name} (${formatMoneyRub(rankedByTotal[0].total)}).`); - } - return { - responseType: "FACTUAL_SUMMARY", - text: summaryLines.join("\n") - }; - } - if (focus === "top_years_by_total") { - const visible = rankedByYearTotal.slice(0, limit); - const heading = isSupplier - ? `Топ-${visible.length} лет по сумме выплат:` - : `Топ-${visible.length} лет по сумме поступлений:`; - lines.unshift(heading); - if (visible.length === 0) { - lines.push("По доступному окну не удалось собрать годовые агрегаты по суммам."); - } - else { - lines.push(...visible.map((item, index) => `${index + 1}. ${item.year} | сумма: ${item.total} | операций: ${item.ops} | контрагентов: ${item.counterparties.size} | макс: ${item.maxSingle}`)); - } - return { - responseType: "FACTUAL_LIST", - text: lines.join("\n") - }; - } - if (focus === "top_by_ops") { - const visible = rankedByOps.slice(0, limit); - const heading = isSupplier - ? `Топ-${visible.length} поставщиков по количеству исходящих платежных операций:` - : `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`; - lines.unshift(heading); - lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | операций: ${item.ops} | сумма: ${item.total} | макс: ${item.maxSingle}`)); - return { - responseType: "FACTUAL_LIST", - text: lines.join("\n") - }; - } - if (focus === "top_by_max_single") { - const visible = rankedByMaxSingle.slice(0, limit); - const heading = isSupplier - ? `Топ-${visible.length} поставщиков по максимальной разовой выплате:` - : `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`; - lines.unshift(heading); - lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | max single: ${item.maxSingle} | сумма: ${item.total} | операций: ${item.ops}`)); - return { - responseType: "FACTUAL_LIST", - text: lines.join("\n") - }; - } - if (focus === "top_by_avg_check_min_ops") { - const visible = rankedByAvgCheck.slice(0, limit); - const heading = isSupplier - ? `Топ-${visible.length} поставщиков по среднему чеку (минимум ${minOpsForAvgCheck} операций):` - : `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`; - lines.unshift(heading); - if (visible.length === 0) { - lines.push(`Контрагентов с минимум ${minOpsForAvgCheck} операций не найдено.`); - } - else { - lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | средний чек: ${item.avgCheck.toFixed(2)} | операций: ${item.ops} | сумма: ${item.total}`)); - } - return { - responseType: "FACTUAL_LIST", - text: lines.join("\n") - }; - } - if (focus === "top_deals") { - const visible = rankedDealsTop.slice(0, limit); - const heading = isSupplier - ? `Топ-${visible.length} самых крупных разовых выплат поставщикам:` - : `Топ-${visible.length} самых крупных разовых сделок по поступлениям:`; - lines.unshift(heading); - lines.push(...visible.map((item, index) => `${index + 1}. ${item.period ?? "n/a"} | ${item.counterparty} | ${item.registrator} | ${item.amount}`)); - return { - responseType: "FACTUAL_LIST", - text: lines.join("\n") - }; - } - if (focus === "bottom_deals") { - const visible = rankedDealsBottom.slice(0, limit); - const heading = isSupplier - ? `Топ-${visible.length} самых маленьких разовых выплат:` - : `Топ-${visible.length} самых маленьких разовых сделок по поступлениям:`; - lines.unshift(heading); - if (activeOnlyForBottomDeals) { - lines.push("Фильтр: только активные контрагенты (минимум 3 операции)."); - } - lines.push(...visible.map((item, index) => `${index + 1}. ${item.period ?? "n/a"} | ${item.counterparty} | ${item.registrator} | ${item.amount}`)); - return { - responseType: "FACTUAL_LIST", - text: lines.join("\n") - }; - } - const visible = rankedByTotal.slice(0, limit); - const heading = isSupplier - ? `Топ-${visible.length} поставщиков по сумме выплат:` - : `Топ-${visible.length} заказчиков по сумме поступлений:`; - lines.unshift(heading); - lines.push(...visible.map((item, index) => { - const avgCheck = item.ops > 0 ? (item.total / item.ops).toFixed(2) : "0"; - return `${index + 1}. ${item.name} | сумма: ${item.total} | операций: ${item.ops} | средний чек: ${avgCheck} | макс: ${item.maxSingle}`; - })); - return { - responseType: "FACTUAL_LIST", - text: lines.join("\n") - }; - } - if (intent === "contract_usage_and_value") { - const focus = detectContractValueFocus(options.userMessage); - const limit = detectRankingLimit(options.userMessage, 20); - const byContract = new Map(); - for (const row of rows) { - const contract = extractContractName(row); - const amount = row.amount ?? 0; - if (!contract || !Number.isFinite(amount) || amount <= 0) { - continue; - } - const counterparty = extractCounterpartyName(row); - const current = byContract.get(contract); - if (!current) { - byContract.set(contract, { - contract, - turnover: amount, - docs: 1, - lastPeriod: row.period, - counterparties: new Set(counterparty ? [counterparty] : []) - }); - } - else { - current.turnover += amount; - current.docs += 1; - if ((row.period ?? "") > (current.lastPeriod ?? "")) { - current.lastPeriod = row.period; - } - if (counterparty) { - current.counterparties.add(counterparty); - } - } - } - const contractRows = Array.from(byContract.values()); - const rankedByTurnover = [...contractRows].sort((a, b) => b.turnover - a.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract)); - const rankedByDocs = [...contractRows].sort((a, b) => b.docs - a.docs || b.turnover - a.turnover || a.contract.localeCompare(b.contract)); - const rankedBottomActive = [...contractRows] - .filter((item) => item.docs > 0 && item.turnover > 0) - .sort((a, b) => a.turnover - b.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract)); - const lines = [ - `Активных договоров: ${contractRows.length}.`, - "Собран профиль договоров по обороту/бюджету (bank-doc contract aggregate).", - `Строк источника: ${rows.length}.`, - `Договорных агрегатов: ${contractRows.length}.` - ]; - if (contractRows.length === 0) { - lines.push("В выбранном окне не найдено операций, связанных с договорами."); - return { - responseType: "FACTUAL_SUMMARY", - text: lines.join("\n") - }; - } - if (focus === "top_by_docs") { - const visible = rankedByDocs.slice(0, limit); - const heading = `Топ-${visible.length} договоров по количеству операций:`; - lines.unshift(heading); - lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | операций: ${item.docs} | оборот: ${item.turnover} | контрагентов: ${item.counterparties.size}`)); - return { - responseType: "FACTUAL_LIST", - text: lines.join("\n") - }; - } - if (focus === "bottom_by_turnover_active") { - const visible = rankedBottomActive.slice(0, limit); - const heading = `Топ-${visible.length} активных договоров с минимальным бюджетом (оборотом):`; - lines.unshift(heading); - lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | оборот: ${item.turnover} | операций: ${item.docs} | последняя активность: ${item.lastPeriod ?? "n/a"}`)); - return { - responseType: "FACTUAL_LIST", - text: lines.join("\n") - }; - } - const visible = rankedByTurnover.slice(0, limit); - const heading = `Топ-${visible.length} договоров по сумме оборота:`; - lines.unshift(heading); - lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | оборот: ${item.turnover} | операций: ${item.docs} | контрагентов: ${item.counterparties.size} | последняя активность: ${item.lastPeriod ?? "n/a"}`)); - return { - responseType: "FACTUAL_LIST", - text: lines.join("\n") - }; - } if (intent === "vat_payable_forecast") { const rowsByMarker = new Map(); for (const row of rows) { diff --git a/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js new file mode 100644 index 0000000..dd1b471 --- /dev/null +++ b/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js @@ -0,0 +1,632 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.composeCounterpartyAnalyticsReply = composeCounterpartyAnalyticsReply; +const assistantOrganizationMatcher_1 = require("../assistantOrganizationMatcher"); +const replyContracts_1 = require("./replyContracts"); +function groupRowsByMarker(rows) { + const rowsByMarker = new Map(); + for (const row of rows) { + const marker = String(row.registrator ?? "").trim().toUpperCase(); + if (!marker) { + continue; + } + if (!rowsByMarker.has(marker)) { + rowsByMarker.set(marker, []); + } + rowsByMarker.get(marker).push(row); + } + return rowsByMarker; +} +function formatOptionalDate(value, formatDateRu) { + return value ? formatDateRu(value) : "дата не указана"; +} +function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { + if (intent === "counterparty_population_and_roles") { + const rowsByMarker = groupRowsByMarker(rows); + const sumMarker = (marker) => (rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0); + const totalCounterparties = sumMarker("CP_TOTAL"); + const customerActive = sumMarker("CP_CUSTOMER_ACTIVE"); + const supplierActive = sumMarker("CP_SUPPLIER_ACTIVE"); + const mixedActive = sumMarker("CP_MIXED_ACTIVE"); + const activeUnion = sumMarker("CP_ACTIVE_UNION"); + const customerOnly = Math.max(0, customerActive - mixedActive); + const supplierOnly = Math.max(0, supplierActive - mixedActive); + const resolvedActive = customerOnly + supplierOnly + mixedActive; + const activeCounterparties = Math.max(activeUnion, resolvedActive); + const otherCounterparties = totalCounterparties > 0 ? Math.max(0, totalCounterparties - resolvedActive) : null; + const focus = deps.detectCounterpartyProfileFocus(options.userMessage); + const includeTotal = focus === "full_profile" || focus === "total_only"; + const includeRoles = focus === "full_profile" || focus === "roles_only"; + const directLead = focus === "suppliers_only" + ? `Контрагентов только в роли поставщика: ${supplierOnly}.` + : focus === "customers_only" + ? `Контрагентов только в роли заказчика: ${customerOnly}.` + : focus === "mixed_only" + ? `Контрагентов со смешанной ролью: ${mixedActive}.` + : includeTotal && totalCounterparties > 0 + ? `Всего уникальных контрагентов в базе: ${totalCounterparties}.` + : `Активных контрагентов по документальной активности: ${activeCounterparties}.`; + const lines = [ + directLead, + "Профиль контрагентов собран по справочнику и документальной активности.", + `Строк агрегата: ${rows.length}.` + ]; + if (includeTotal) { + if (totalCounterparties > 0) { + lines.push(`Всего уникальных контрагентов в базе: ${totalCounterparties}.`); + } + else if (activeCounterparties > 0) { + lines.push(`Полный итог по справочнику не получен, поэтому даю оценку по документальной активности: ${activeCounterparties}.`); + } + else { + lines.push("По количеству контрагентов агрегатных строк не найдено."); + } + } + if (includeRoles) { + if (resolvedActive > 0 || activeCounterparties > 0) { + lines.push("Распределение ролей по активности:"); + lines.push(`1. Только заказчики: ${customerOnly}.`); + lines.push(`2. Только поставщики: ${supplierOnly}.`); + lines.push(`3. И заказчики, и поставщики: ${mixedActive}.`); + lines.push(`4. Всего активных контрагентов: ${activeCounterparties}.`); + if (otherCounterparties !== null) { + lines.push(`5. Прочие или неактивные в выбранном окне: ${otherCounterparties}.`); + } + } + else { + lines.push("По распределению ролей агрегатных строк не найдено."); + } + } + if (focus === "suppliers_only") { + lines.push(`Контрагентов только в роли поставщика: ${supplierOnly}.`); + } + if (focus === "customers_only") { + lines.push(`Контрагентов только в роли заказчика: ${customerOnly}.`); + } + if (focus === "mixed_only") { + lines.push(`Контрагентов со смешанной ролью: ${mixedActive}.`); + } + return (0, replyContracts_1.buildFactualSummaryReply)(lines); + } + if (intent === "counterparty_activity_lifecycle") { + const activityFirstRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_FIRST"); + const activityRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY"); + const activityYearRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_YEAR"); + const byCounterparty = new Map(); + for (const row of activityFirstRows) { + const name = deps.extractCounterpartyName(row); + if (!name) { + continue; + } + const opsCount = Math.max(0, Math.trunc(row.amount ?? 0)); + const year = deps.extractYearFromIso(row.period); + const current = byCounterparty.get(name); + if (!current) { + byCounterparty.set(name, { + name, + opsCount, + lastPeriod: row.period, + firstPeriod: row.period, + firstObservedActivity: row.period, + years: new Set(year !== null ? [year] : []) + }); + continue; + } + if (!current.firstObservedActivity || (row.period ?? "") < current.firstObservedActivity) { + current.firstObservedActivity = row.period; + } + if ((row.period ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = row.period; + } + if (year !== null) { + current.years.add(year); + } + } + for (const row of activityYearRows) { + const name = deps.extractCounterpartyName(row); + if (!name) { + continue; + } + const opsCount = Math.max(0, Math.trunc(row.amount ?? 0)); + const year = deps.extractYearFromIso(row.period); + const current = byCounterparty.get(name); + if (!current) { + byCounterparty.set(name, { + name, + opsCount, + lastPeriod: row.period, + firstPeriod: row.period, + firstObservedActivity: null, + years: new Set(year !== null ? [year] : []) + }); + continue; + } + current.opsCount += opsCount; + if ((row.period ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = row.period; + } + if ((row.period ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = row.period; + } + if (year !== null) { + current.years.add(year); + } + } + for (const row of activityRows) { + const name = deps.extractCounterpartyName(row); + if (!name) { + continue; + } + const opsCount = Math.max(0, Math.trunc(row.amount ?? 0)); + const current = byCounterparty.get(name); + if (!current) { + const year = deps.extractYearFromIso(row.period); + byCounterparty.set(name, { + name, + opsCount, + lastPeriod: row.period, + firstPeriod: row.period, + firstObservedActivity: row.period, + years: new Set(year !== null ? [year] : []) + }); + continue; + } + if (activityYearRows.length === 0 && opsCount > current.opsCount) { + current.opsCount = opsCount; + } + if ((row.period ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = row.period; + } + if ((row.period ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = row.period; + } + const year = deps.extractYearFromIso(row.period); + if (year !== null) { + current.years.add(year); + } + } + const counterpartiesRaw = Array.from(byCounterparty.values()); + const focus = deps.detectCounterpartyLifecycleFocus(options.userMessage); + const requestedYear = deps.extractRequestedYearFromQuestion(options.userMessage); + const longevityQuestion = deps.hasCounterpartyLifecycleLongevityQuestion(options.userMessage); + const activityAgeQuestion = deps.hasCounterpartyActivityAgeQuestion(options.userMessage); + const rankingLimit = deps.detectRankingLimit(options.userMessage, 10); + const counterparties = counterpartiesRaw.sort((left, right) => { + if (longevityQuestion) { + const yearsDiff = right.years.size - left.years.size; + if (yearsDiff !== 0) { + return yearsDiff; + } + } + if (right.opsCount !== left.opsCount) { + return right.opsCount - left.opsCount; + } + return (right.lastPeriod ?? "").localeCompare(left.lastPeriod ?? ""); + }); + const scopeLabel = focus === "active_customers_all_time" + ? "за все время" + : requestedYear + ? `в ${requestedYear} году` + : "в выбранном периоде"; + if (activityAgeQuestion) { + const focusedCounterparty = counterparties.find((item) => deps.counterpartyLookupMatches(item.name, options.counterpartyHint)) ?? null; + if (focusedCounterparty) { + const firstObservedActivity = focusedCounterparty.firstObservedActivity ?? focusedCounterparty.firstPeriod; + const lastObservedActivity = focusedCounterparty.lastPeriod; + const firstTimestamp = deps.toUtcDayTimestamp(firstObservedActivity); + const lastTimestamp = deps.toUtcDayTimestamp(lastObservedActivity); + const observedDays = firstTimestamp !== null && lastTimestamp !== null && lastTimestamp >= firstTimestamp + ? Math.floor((lastTimestamp - firstTimestamp) / 86_400_000) + : null; + const observedAgeLabel = observedDays !== null + ? deps.formatAgeYearsMonthsDays(observedDays) + : focusedCounterparty.years.size > 0 + ? `${focusedCounterparty.years.size} г.` + : null; + const lines = [ + observedAgeLabel && firstObservedActivity && lastObservedActivity + ? `По активности в базе 1С контрагент ${focusedCounterparty.name} наблюдается минимум ${observedAgeLabel}.` + : `По активности в базе 1С контрагент ${focusedCounterparty.name} найден в подтвержденных движениях.` + ]; + if (firstObservedActivity) { + lines.push(`Первая подтвержденная активность: ${deps.formatDateRu(firstObservedActivity)}.`); + } + if (lastObservedActivity) { + lines.push(`Последняя подтвержденная активность: ${deps.formatDateRu(lastObservedActivity)}.`); + } + lines.push(`Подтвержденных операций в агрегате: ${focusedCounterparty.opsCount}.`); + if (focusedCounterparty.years.size > 0) { + const years = Array.from(focusedCounterparty.years).sort((a, b) => a - b); + lines.push(`Годы с активностью в базе: ${years.join(", ")}.`); + } + lines.push("Это возраст активности в 1С по подтвержденным движениям, а не дата регистрации юрлица."); + return (0, replyContracts_1.buildFactualSummaryReply)(lines); + } + const organizationHint = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(options.organizationHint ?? null); + if (organizationHint && counterparties.length > 0) { + const organizationFirstObservedActivity = counterparties.reduce((earliest, item) => { + const candidate = item.firstObservedActivity ?? item.firstPeriod ?? null; + if (!candidate) { + return earliest; + } + if (!earliest || candidate < earliest) { + return candidate; + } + return earliest; + }, null); + const organizationLastObservedActivity = counterparties.reduce((latest, item) => { + const candidate = item.lastPeriod ?? item.firstPeriod ?? item.firstObservedActivity ?? null; + if (!candidate) { + return latest; + } + if (!latest || candidate > latest) { + return candidate; + } + return latest; + }, null); + const organizationYears = new Set(); + let organizationOpsCount = 0; + for (const item of counterparties) { + organizationOpsCount += item.opsCount; + for (const year of item.years) { + organizationYears.add(year); + } + } + const firstTimestamp = deps.toUtcDayTimestamp(organizationFirstObservedActivity); + const lastTimestamp = deps.toUtcDayTimestamp(organizationLastObservedActivity); + const observedDays = firstTimestamp !== null && lastTimestamp !== null && lastTimestamp >= firstTimestamp + ? Math.floor((lastTimestamp - firstTimestamp) / 86_400_000) + : null; + const observedAgeLabel = observedDays !== null + ? deps.formatAgeYearsMonthsDays(observedDays) + : organizationYears.size > 0 + ? `${organizationYears.size} г.` + : null; + const lines = [ + observedAgeLabel && organizationFirstObservedActivity && organizationLastObservedActivity + ? `По активности организации ${organizationHint} в базе 1С наблюдается минимум ${observedAgeLabel}.` + : `По активности организации ${organizationHint} в базе 1С найдены подтвержденные движения.` + ]; + if (organizationFirstObservedActivity) { + lines.push(`Первая подтвержденная активность: ${deps.formatDateRu(organizationFirstObservedActivity)}.`); + } + if (organizationLastObservedActivity) { + lines.push(`Последняя подтвержденная активность: ${deps.formatDateRu(organizationLastObservedActivity)}.`); + } + lines.push(`Подтвержденных операций в агрегате: ${organizationOpsCount}.`); + if (organizationYears.size > 0) { + const years = Array.from(organizationYears).sort((a, b) => a - b); + lines.push(`Годы с активностью в базе: ${years.join(", ")}.`); + } + lines.push("Это возраст активности организации в 1С по подтвержденным движениям, а не дата регистрации юрлица."); + return (0, replyContracts_1.buildFactualSummaryReply)(lines); + } + } + const lines = longevityQuestion + ? [ + `Заказчиков с самым длинным горизонтом сотрудничества: ${counterparties.length}.`, + "Собран профиль длительности сотрудничества по годам и частоте активности.", + `Строк агрегата: ${rows.length}.` + ] + : [ + `Активные заказчики ${scopeLabel}: ${counterparties.length}.`, + "Собран профиль активности заказчиков по платежным документам.", + `Строк агрегата: ${rows.length}.` + ]; + if (counterparties.length === 0) { + lines.push(longevityQuestion + ? "По доступному окну не удалось выделить заказчиков с подтвержденной длительностью сотрудничества." + : "По выбранному окну активные заказчики не найдены."); + return (0, replyContracts_1.buildFactualSummaryReply)(lines); + } + const visible = counterparties.slice(0, longevityQuestion ? rankingLimit : 120); + if (longevityQuestion) { + lines.push(`Топ-${visible.length} заказчиков по охвату лет и частоте операций:`); + } + lines.push(...visible.map((item, index) => { + const years = Array.from(item.years).sort((a, b) => a - b); + const yearsLabel = years.length > 0 ? ` | лет в базе: ${years.length} | годы: ${years.join(", ")}` : ""; + const periodSpan = item.firstPeriod && item.lastPeriod + ? ` | период: ${formatOptionalDate(item.firstPeriod, deps.formatDateRu)}..${formatOptionalDate(item.lastPeriod, deps.formatDateRu)}` + : ""; + if (longevityQuestion) { + return `${index + 1}. ${item.name} | операций: ${item.opsCount}${yearsLabel}${periodSpan}`; + } + const suffix = item.lastPeriod ? ` | последняя активность: ${deps.formatDateRu(item.lastPeriod)}` : ""; + return `${index + 1}. ${item.name} | операций: ${item.opsCount}${suffix}${years.length > 0 ? ` | лет в базе: ${years.length}` : ""}`; + })); + if (counterparties.length > visible.length) { + lines.push(`Показаны первые ${visible.length} из ${counterparties.length} заказчиков.`); + } + return (0, replyContracts_1.buildFactualListReply)(lines); + } + if (intent === "contract_usage_overview") { + const rowsByMarker = groupRowsByMarker(rows); + const sumMarker = (marker) => (rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0); + const totalContracts = sumMarker("CT_TOTAL"); + const usedContracts = sumMarker("CT_USED"); + const unusedContracts = totalContracts > 0 ? Math.max(0, totalContracts - Math.min(usedContracts, totalContracts)) : null; + const usedShare = totalContracts > 0 ? deps.formatPercent(Math.min(usedContracts, totalContracts), totalContracts) : null; + const usageLead = totalContracts > 0 + ? `Использованных договоров: ${usedContracts} из ${totalContracts}${usedShare ? ` (${usedShare})` : ""}.` + : `Использованных договоров с подтвержденной связью с операциями: ${usedContracts}.`; + const lines = [ + usageLead, + "Профиль договорной базы собран по справочнику и подтвержденным операциям.", + `Строк агрегата: ${rows.length}.` + ]; + if (totalContracts > 0) { + lines.push(`Всего договоров в базе: ${totalContracts}.`); + } + else { + lines.push("Общее количество договоров не получено из доступного среза справочника."); + } + lines.push(`Использованных договоров с подтвержденной связью с операциями: ${usedContracts}.`); + if (unusedContracts !== null) { + lines.push(`Неиспользуемых договоров: ${unusedContracts}.`); + } + if (usedShare) { + lines.push(`Доля используемых договоров: ${usedShare}.`); + } + return (0, replyContracts_1.buildFactualSummaryReply)(lines); + } + if (intent === "customer_revenue_and_payments" || intent === "supplier_payouts_profile") { + const isSupplier = intent === "supplier_payouts_profile"; + const focus = deps.detectValueRankingFocus(options.userMessage); + const limit = deps.detectRankingLimit(options.userMessage, 20); + const minOpsForAvgCheck = deps.detectMinOpsForAvgCheck(options.userMessage); + const normalizedQuestion = deps.normalizeQuestionText(options.userMessage); + const byCounterparty = new Map(); + const byYear = new Map(); + const deals = []; + for (const row of rows) { + const counterparty = deps.extractCounterpartyName(row); + const amount = row.amount ?? 0; + if (!counterparty || !Number.isFinite(amount) || amount <= 0) { + continue; + } + const current = byCounterparty.get(counterparty); + if (!current) { + byCounterparty.set(counterparty, { + name: counterparty, + total: amount, + ops: 1, + maxSingle: amount, + minSingle: amount, + lastPeriod: row.period + }); + } + else { + current.total += amount; + current.ops += 1; + current.maxSingle = Math.max(current.maxSingle, amount); + current.minSingle = Math.min(current.minSingle, amount); + if ((row.period ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = row.period; + } + } + deals.push({ + period: row.period, + registrator: row.registrator, + counterparty, + amount + }); + const year = deps.extractYearFromIso(row.period); + if (year !== null) { + const yearBucket = byYear.get(year); + if (!yearBucket) { + byYear.set(year, { + year, + total: amount, + ops: 1, + maxSingle: amount, + counterparties: new Set([counterparty]) + }); + } + else { + yearBucket.total += amount; + yearBucket.ops += 1; + yearBucket.maxSingle = Math.max(yearBucket.maxSingle, amount); + yearBucket.counterparties.add(counterparty); + } + } + } + const profileRows = Array.from(byCounterparty.values()); + const yearRows = Array.from(byYear.values()); + const totalFlow = profileRows.reduce((sum, item) => sum + item.total, 0); + const totalOperations = profileRows.reduce((sum, item) => sum + item.ops, 0); + const rankedByTotal = [...profileRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.name.localeCompare(b.name)); + const rankedByYearTotal = [...yearRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.year - b.year); + const rankedByOps = [...profileRows].sort((a, b) => b.ops - a.ops || b.total - a.total || a.name.localeCompare(b.name)); + const rankedByMaxSingle = [...profileRows].sort((a, b) => b.maxSingle - a.maxSingle || b.total - a.total || a.name.localeCompare(b.name)); + const rankedByAvgCheck = [...profileRows] + .filter((item) => item.ops >= minOpsForAvgCheck) + .map((item) => ({ + ...item, + avgCheck: item.total / item.ops + })) + .sort((a, b) => b.avgCheck - a.avgCheck || b.total - a.total || a.name.localeCompare(b.name)); + const rankedDealsTop = [...deals].sort((a, b) => b.amount - a.amount || (b.period ?? "").localeCompare(a.period ?? "")); + const activeOnlyForBottomDeals = /(?:активн|active)/iu.test(normalizedQuestion); + const activeCounterpartiesForBottom = new Set(profileRows.filter((item) => item.ops >= Math.max(3, minOpsForAvgCheck)).map((item) => item.name)); + const rankedDealsBottom = [...deals] + .filter((item) => !activeOnlyForBottomDeals || activeCounterpartiesForBottom.has(item.counterparty)) + .sort((a, b) => a.amount - b.amount || (a.period ?? "").localeCompare(b.period ?? "")); + const lines = [ + isSupplier + ? "Собран профиль выплат поставщикам по платежным документам." + : "Собран профиль поступлений от заказчиков по платежным документам.", + `Строк источника: ${rows.length}.`, + `Уникальных контрагентов: ${profileRows.length}.` + ]; + if (profileRows.length === 0) { + lines.push("По выбранному окну данных платежные строки не найдены."); + return (0, replyContracts_1.buildFactualSummaryReply)(lines); + } + if (focus === "total_flow") { + const periodLine = options.periodFrom && options.periodTo + ? `За период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)} подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.` + : `За все доступное время подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`; + const directAnswerLine = isSupplier + ? periodLine + : `${periodLine} Это денежный поток от клиентов, а не чистая прибыль.`; + const summaryLines = [ + directAnswerLine, + "", + "Подтверждение:", + `- Операций в выборке: ${totalOperations}.`, + `- Контрагентов в выборке: ${profileRows.length}.` + ]; + if (rankedByYearTotal.length > 0) { + summaryLines.push(`- Самый сильный год по поступлениям: ${rankedByYearTotal[0].year} (${deps.formatMoneyRub(rankedByYearTotal[0].total)}).`); + } + if (rankedByTotal.length > 0) { + summaryLines.push(`- Крупнейший контрагент по потоку: ${rankedByTotal[0].name} (${deps.formatMoneyRub(rankedByTotal[0].total)}).`); + } + return (0, replyContracts_1.buildFactualSummaryReply)(summaryLines); + } + if (focus === "top_years_by_total") { + const visible = rankedByYearTotal.slice(0, limit); + const heading = isSupplier + ? `Топ-${visible.length} лет по сумме выплат:` + : `Топ-${visible.length} лет по сумме поступлений:`; + lines.unshift(heading); + if (visible.length === 0) { + lines.push("По доступному окну не удалось собрать годовые агрегаты по суммам."); + } + else { + lines.push(...visible.map((item, index) => `${index + 1}. ${item.year} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops} | контрагентов: ${item.counterparties.size} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)}`)); + } + return (0, replyContracts_1.buildFactualListReply)(lines); + } + if (focus === "top_by_ops") { + const visible = rankedByOps.slice(0, limit); + const heading = isSupplier + ? `Топ-${visible.length} поставщиков по количеству исходящих платежных операций:` + : `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`; + lines.unshift(heading); + lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | операций: ${item.ops} | сумма: ${deps.formatMoneyRub(item.total)} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)}`)); + return (0, replyContracts_1.buildFactualListReply)(lines); + } + if (focus === "top_by_max_single") { + const visible = rankedByMaxSingle.slice(0, limit); + const heading = isSupplier + ? `Топ-${visible.length} поставщиков по максимальной разовой выплате:` + : `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`; + lines.unshift(heading); + lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops}`)); + return (0, replyContracts_1.buildFactualListReply)(lines); + } + if (focus === "top_by_avg_check_min_ops") { + const visible = rankedByAvgCheck.slice(0, limit); + const heading = isSupplier + ? `Топ-${visible.length} поставщиков по среднему чеку (минимум ${minOpsForAvgCheck} операций):` + : `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`; + lines.unshift(heading); + if (visible.length === 0) { + lines.push(`Контрагентов с минимум ${minOpsForAvgCheck} операций не найдено.`); + } + else { + lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | средний чек: ${deps.formatMoneyRub(item.avgCheck)} | операций: ${item.ops} | сумма: ${deps.formatMoneyRub(item.total)}`)); + } + return (0, replyContracts_1.buildFactualListReply)(lines); + } + if (focus === "top_deals") { + const visible = rankedDealsTop.slice(0, limit); + const heading = isSupplier + ? `Топ-${visible.length} самых крупных разовых выплат поставщикам:` + : `Топ-${visible.length} самых крупных разовых поступлений:`; + lines.unshift(heading); + lines.push(...visible.map((item, index) => `${index + 1}. ${formatOptionalDate(item.period, deps.formatDateRu)} | ${item.counterparty} | ${item.registrator} | ${deps.formatMoneyRub(item.amount)}`)); + return (0, replyContracts_1.buildFactualListReply)(lines); + } + if (focus === "bottom_deals") { + const visible = rankedDealsBottom.slice(0, limit); + const heading = isSupplier + ? `Топ-${visible.length} самых маленьких разовых выплат:` + : `Топ-${visible.length} самых маленьких разовых поступлений:`; + lines.unshift(heading); + if (activeOnlyForBottomDeals) { + lines.push("Фильтр: только активные контрагенты с минимум 3 операциями."); + } + lines.push(...visible.map((item, index) => `${index + 1}. ${formatOptionalDate(item.period, deps.formatDateRu)} | ${item.counterparty} | ${item.registrator} | ${deps.formatMoneyRub(item.amount)}`)); + return (0, replyContracts_1.buildFactualListReply)(lines); + } + const visible = rankedByTotal.slice(0, limit); + const heading = isSupplier + ? `Топ-${visible.length} поставщиков по сумме выплат:` + : `Топ-${visible.length} заказчиков по сумме поступлений:`; + lines.unshift(heading); + lines.push(...visible.map((item, index) => { + const avgCheck = item.ops > 0 ? item.total / item.ops : 0; + return `${index + 1}. ${item.name} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops} | средний чек: ${deps.formatMoneyRub(avgCheck)} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)}`; + })); + return (0, replyContracts_1.buildFactualListReply)(lines); + } + if (intent === "contract_usage_and_value") { + const focus = deps.detectContractValueFocus(options.userMessage); + const limit = deps.detectRankingLimit(options.userMessage, 20); + const byContract = new Map(); + for (const row of rows) { + const contract = deps.extractContractName(row); + const amount = row.amount ?? 0; + if (!contract || !Number.isFinite(amount) || amount <= 0) { + continue; + } + const counterparty = deps.extractCounterpartyName(row); + const current = byContract.get(contract); + if (!current) { + byContract.set(contract, { + contract, + turnover: amount, + docs: 1, + lastPeriod: row.period, + counterparties: new Set(counterparty ? [counterparty] : []) + }); + } + else { + current.turnover += amount; + current.docs += 1; + if ((row.period ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = row.period; + } + if (counterparty) { + current.counterparties.add(counterparty); + } + } + } + const contractRows = Array.from(byContract.values()); + const rankedByTurnover = [...contractRows].sort((a, b) => b.turnover - a.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract)); + const rankedByDocs = [...contractRows].sort((a, b) => b.docs - a.docs || b.turnover - a.turnover || a.contract.localeCompare(b.contract)); + const rankedBottomActive = [...contractRows] + .filter((item) => item.docs > 0 && item.turnover > 0) + .sort((a, b) => a.turnover - b.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract)); + const lines = [ + `Активных договоров: ${contractRows.length}.`, + "Собран профиль договоров по обороту и подтвержденным операциям.", + `Строк источника: ${rows.length}.`, + `Договорных агрегатов: ${contractRows.length}.` + ]; + if (contractRows.length === 0) { + lines.push("В выбранном окне не найдено операций, связанных с договорами."); + return (0, replyContracts_1.buildFactualSummaryReply)(lines); + } + if (focus === "top_by_docs") { + const visible = rankedByDocs.slice(0, limit); + lines.unshift(`Топ-${visible.length} договоров по количеству операций:`); + lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | операций: ${item.docs} | оборот: ${deps.formatMoneyRub(item.turnover)} | контрагентов: ${item.counterparties.size}`)); + return (0, replyContracts_1.buildFactualListReply)(lines); + } + if (focus === "bottom_by_turnover_active") { + const visible = rankedBottomActive.slice(0, limit); + lines.unshift(`Топ-${visible.length} активных договоров с минимальным оборотом:`); + lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | оборот: ${deps.formatMoneyRub(item.turnover)} | операций: ${item.docs} | последняя активность: ${formatOptionalDate(item.lastPeriod, deps.formatDateRu)}`)); + return (0, replyContracts_1.buildFactualListReply)(lines); + } + const visible = rankedByTurnover.slice(0, limit); + lines.unshift(`Топ-${visible.length} договоров по сумме оборота:`); + lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | оборот: ${deps.formatMoneyRub(item.turnover)} | операций: ${item.docs} | контрагентов: ${item.counterparties.size} | последняя активность: ${formatOptionalDate(item.lastPeriod, deps.formatDateRu)}`)); + return (0, replyContracts_1.buildFactualListReply)(lines); + } + return null; +} diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index 26b2230..9535cf5 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -421,12 +421,17 @@ function shouldRestoreInventoryRootFrame(userMessage, intent, extractedFilters, const rootContextOnly = followupContext.root_context_only === true; const comingFromInventoryDrilldown = currentFrameKind === "inventory_drilldown" || isInventoryDrilldownFrameIntent(previousIntent); const normalized = String(userMessage ?? ""); + const hasExplicitInventoryRootSnapshotCue = /(?:склад|остат(?:ок|ки)|товар(?:ы|ов)?|номенклатур)/iu.test(normalized); const hasInventoryRootRestatementCue = /(?:склад|остат(?:ок|ки)|позици(?:я|и|ю)|товар(?:ы|ов)?|номенклатур)/iu.test(normalized) && - /(?:покажи|показать|выведи|раскрой|еще\s+раз|ещ[её]\s+раз|снова|опять|верни|вернись|повтори|тот\s+же|этот\s+же|same|again)/iu.test(normalized); + (/(?:покажи|показать|выведи|раскрой|еще\s+раз|ещ[её]\s+раз|снова|опять|верни|вернись|повтори|тот\s+же|этот\s+же|same|again)/iu.test(normalized) || + hasSameDateHint(normalized) || + hasSamePeriodHint(normalized)); const canReenterInventoryRoot = comingFromInventoryDrilldown || rootContextOnly || (currentFrameKind === "inventory_root" && (hasSamePeriodHint(normalized) || hasInventoryRootRestatementCue)) || - (currentFrameKind === "generic" && hasInventoryRootRestatementCue && hasSamePeriodHint(normalized)); + (currentFrameKind === "generic" && + hasExplicitInventoryRootSnapshotCue && + (hasSameDateHint(normalized) || hasSamePeriodHint(normalized) || hasInventoryRootRestatementCue)); if (!canReenterInventoryRoot) { return false; } @@ -791,15 +796,13 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { (intent === "inventory_aging_by_purchase_date" || isInventoryLifecycleHistoryIntent(intent)) && !hasExplicitPeriodLiteral(userMessage) && !hasExplicitCurrentDateHint(userMessage)) { - if (intent === "inventory_aging_by_purchase_date") { - const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; - const currentAsOfDate = toNonEmptyString(merged.as_of_date); - const todayIso = new Date().toISOString().slice(0, 10); - const currentLooksDefaultedToToday = currentAsOfDate === todayIso; - if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) { - merged.as_of_date = inheritedAsOfDate; - reasons.push("as_of_date_from_followup_context"); - } + const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; + const currentAsOfDate = toNonEmptyString(merged.as_of_date); + const todayIso = new Date().toISOString().slice(0, 10); + const currentLooksDefaultedToToday = currentAsOfDate === todayIso; + if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) { + merged.as_of_date = inheritedAsOfDate; + reasons.push("as_of_date_from_followup_context"); } } if ((Boolean(previousPeriodFrom) || Boolean(previousPeriodTo)) && @@ -1025,6 +1028,8 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo const hasPreviousCounterparty = Boolean(previousCounterparty ?? previousCounterpartyFromAnchor); const hasAnyPartyAnchor = hasPreviousContract || hasPreviousCounterparty; const isVatFollowup = hasVatCue(normalizedMessage); + const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined); + const inventorySelectedObjectFollowup = hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal); if (detectedIntent.intent === "unknown" && isVatFollowup) { const vatIntent = hasVatTaxPaymentCue(normalizedMessage) ? "vat_liability_confirmed_for_tax_period" @@ -1038,7 +1043,10 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo }; } const allowOpenItemsFollowupFallback = detectedIntent.intent === "unknown" && !isVatFollowup; - if (allowOpenItemsFollowupFallback && hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) { + if (allowOpenItemsFollowupFallback && + !inventorySelectedObjectFollowup && + hasOpenItemsHint(normalizedMessage) && + hasAnyPartyAnchor) { return { intent: "open_items_by_counterparty_or_contract", confidence: "low", @@ -1060,8 +1068,6 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo reasons: [...detectedIntent.reasons, "intent_adjusted_to_balance_followup_context"] }; } - const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined); - const inventorySelectedObjectFollowup = hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal); if (inventorySelectedObjectFollowup && hasInventorySupplierFollowupCue(normalizedMessage)) { if (detectedIntent.intent === "unknown" || detectedIntent.intent === "list_documents_by_counterparty" || diff --git a/llm_normalizer/backend/dist/services/assistantBoundaryPolicy.js b/llm_normalizer/backend/dist/services/assistantBoundaryPolicy.js index 61ba36f..8fd729c 100644 --- a/llm_normalizer/backend/dist/services/assistantBoundaryPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantBoundaryPolicy.js @@ -19,9 +19,7 @@ function containsLetterLikeChars(text) { return /[A-Za-z\u0400-\u04FF]/u.test(source); } function createAssistantBoundaryPolicy(deps) { - const defaultChannel = String(deps.activeMcpChannel ?? "default"); function buildAssistantDataScopeContractReply(scopeProbe = null) { - const channel = String(scopeProbe?.channel ?? defaultChannel); const organizations = Array.isArray(scopeProbe?.organizations) ? scopeProbe.organizations .map((item) => String(item ?? "").trim()) @@ -29,27 +27,27 @@ function createAssistantBoundaryPolicy(deps) { : []; if (organizations.length === 1) { return [ - `Сейчас в активном MCP-канале \`${channel}\` доступна организация: ${organizations[0]}.`, - "Работаю в read-only режиме. Могу сразу показать по этой организации документы, операции, договоры или остатки." + `Сейчас доступна организация: ${organizations[0]}.`, + "Могу сразу показать по ней документы, операции, договоры или остатки." ].join(" "); } if (organizations.length > 1) { const preview = organizations.slice(0, 10).join(", "); return [ - `Сейчас в активном MCP-канале \`${channel}\` доступны организации (${organizations.length}): ${preview}.`, - "Работаю в read-only режиме. Скажи, по какой организации смотреть документы/операции." + `Сейчас доступны организации (${organizations.length}): ${preview}.`, + "Скажите, по какой организации смотреть данные." ].join(" "); } if (scopeProbe?.status === "unresolved_with_error" && scopeProbe?.error) { return [ - `Не смог прочитать название организации из live MCP-канала \`${channel}\`: ${scopeProbe.error}.`, - "Работаю в read-only режиме и вижу только данные активного контура. Проверь подключение MCP/1С, после этого сразу назову контур." + "Сейчас не удалось определить список организаций из подключенной базы.", + `Техническая причина: ${scopeProbe.error}.`, + "Проверьте подключение, и я сразу назову доступный контур." ].join(" "); } return [ - `Работаю в read-only режиме и вижу только те данные, которые отдает текущий MCP-канал \`${channel}\`.`, - "Словарь компаний не зашит в код: рабочий контур определяется live-подключением.", - "Если подключено несколько баз, для автосписка нужен MCP-метод метаданных (перечень баз/организаций); без него можно анализировать только активный контур запросов." + "Сейчас вижу только данные текущего подключенного контура.", + "Если в нем несколько организаций, скажите, по какой смотреть данные." ].join(" "); } function buildAssistantDataScopeSelectionReply(organization) { diff --git a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js new file mode 100644 index 0000000..6b2323a --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js @@ -0,0 +1,117 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.formatIsoDateForReply = formatIsoDateForReply; +exports.readAddressDebugFilters = readAddressDebugFilters; +exports.readAddressDebugItem = readAddressDebugItem; +exports.readAddressDebugOrganization = readAddressDebugOrganization; +exports.readAddressDebugScopedDate = readAddressDebugScopedDate; +exports.isGroundedAddressDebug = isGroundedAddressDebug; +exports.resolveAssistantContinuitySnapshot = resolveAssistantContinuitySnapshot; +function fallbackToNonEmptyString(value) { + if (value === null || value === undefined) { + return null; + } + const text = String(value).trim(); + return text.length > 0 ? text : null; +} +function toRecordObject(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value; +} +function formatIsoDateForReply(value) { + const source = String(value ?? "").trim(); + const match = source.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) { + return null; + } + return `${match[3]}.${match[2]}.${match[1]}`; +} +function readAddressDebugFilters(debug) { + return toRecordObject(debug?.extracted_filters); +} +function readAddressDebugItem(debug, toNonEmptyString = fallbackToNonEmptyString) { + const extractedFilters = readAddressDebugFilters(debug); + return (toNonEmptyString(extractedFilters?.item) ?? + (String(debug?.anchor_type ?? "") === "item" + ? toNonEmptyString(debug?.anchor_value_resolved) ?? toNonEmptyString(debug?.anchor_value_raw) + : null)); +} +function readAddressDebugOrganization(debug, toNonEmptyString = fallbackToNonEmptyString) { + const extractedFilters = readAddressDebugFilters(debug); + const rootFrameContext = toRecordObject(debug?.address_root_frame_context); + return toNonEmptyString(extractedFilters?.organization) ?? toNonEmptyString(rootFrameContext?.organization); +} +function readAddressDebugScopedDate(debug) { + const extractedFilters = readAddressDebugFilters(debug); + const rootFrameContext = toRecordObject(debug?.address_root_frame_context); + return (formatIsoDateForReply(extractedFilters?.as_of_date) ?? + formatIsoDateForReply(rootFrameContext?.as_of_date) ?? + formatIsoDateForReply(extractedFilters?.period_to)); +} +function isGroundedAddressDebug(debug, toNonEmptyString = fallbackToNonEmptyString) { + if (!debug || typeof debug !== "object") { + return false; + } + const executionLane = toNonEmptyString(debug.execution_lane); + if (executionLane !== "address_query") { + return false; + } + const answerGroundingCheck = toRecordObject(debug.answer_grounding_check); + const groundingStatus = toNonEmptyString(answerGroundingCheck?.status); + return groundingStatus === "grounded"; +} +function isGroundedInventoryContextDebug(debug, toNonEmptyString) { + if (!isGroundedAddressDebug(debug, toNonEmptyString)) { + return false; + } + const detectedIntent = toNonEmptyString(debug?.detected_intent); + const capabilityId = toNonEmptyString(debug?.capability_id); + const rootFrameContext = toRecordObject(debug?.address_root_frame_context); + const rootIntent = toNonEmptyString(rootFrameContext?.root_intent); + return (detectedIntent === "inventory_on_hand_as_of_date" || + capabilityId === "confirmed_inventory_on_hand_as_of_date" || + rootIntent === "inventory_on_hand_as_of_date"); +} +function resolveAssistantContinuitySnapshot(input) { + const toNonEmptyString = input.toNonEmptyString ?? fallbackToNonEmptyString; + const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : []; + let lastGroundedAddressDebug = null; + let lastGroundedItemAddressDebug = null; + let lastGroundedInventoryAddressDebug = null; + 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; + } + const debug = item.debug; + if (!isGroundedAddressDebug(debug, toNonEmptyString)) { + continue; + } + if (!lastGroundedAddressDebug) { + lastGroundedAddressDebug = debug; + } + if (!lastGroundedItemAddressDebug && readAddressDebugItem(debug, toNonEmptyString)) { + lastGroundedItemAddressDebug = debug; + } + if (!lastGroundedInventoryAddressDebug && isGroundedInventoryContextDebug(debug, toNonEmptyString)) { + lastGroundedInventoryAddressDebug = debug; + } + if (lastGroundedAddressDebug && lastGroundedItemAddressDebug && lastGroundedInventoryAddressDebug) { + break; + } + } + const primaryDebug = lastGroundedItemAddressDebug ?? lastGroundedAddressDebug; + return { + lastGroundedAddressDebug, + lastGroundedItemAddressDebug, + lastGroundedInventoryAddressDebug, + activeItem: readAddressDebugItem(primaryDebug, toNonEmptyString), + activeOrganization: readAddressDebugOrganization(primaryDebug, toNonEmptyString), + activeScopedDate: readAddressDebugScopedDate(primaryDebug), + hasGroundedAddressContext: Boolean(lastGroundedAddressDebug), + hasGroundedItemContext: Boolean(lastGroundedItemAddressDebug), + hasGroundedInventoryContext: Boolean(lastGroundedInventoryAddressDebug) + }; +} diff --git a/llm_normalizer/backend/dist/services/assistantDataScopePolicy.js b/llm_normalizer/backend/dist/services/assistantDataScopePolicy.js index e905491..272153e 100644 --- a/llm_normalizer/backend/dist/services/assistantDataScopePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantDataScopePolicy.js @@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.createAssistantDataScopePolicy = createAssistantDataScopePolicy; // @ts-nocheck const assistantOrganizationMatcher_1 = require("./assistantOrganizationMatcher"); +const assistantContinuityPolicy_1 = require("./assistantContinuityPolicy"); const DATA_SCOPE_CACHE_TTL_MS = 60_000; function normalizeScopeLabel(value) { return String(value ?? "") @@ -235,6 +236,12 @@ function createAssistantDataScopePolicy(deps) { if (selected) { return selected; } + if ((0, assistantContinuityPolicy_1.isGroundedAddressDebug)(debug)) { + const groundedOrganization = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)((0, assistantContinuityPolicy_1.readAddressDebugOrganization)(debug)); + if (groundedOrganization) { + return groundedOrganization; + } + } } return null; } diff --git a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js index cb79c77..e8129b2 100644 --- a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js @@ -226,6 +226,7 @@ async function runAssistantLivingChatRuntime(input) { chatText = (0, assistantMemoryRecapPolicy_1.buildAddressMemoryRecapReply)({ organization: scopedOrganization, addressDebug: lastMemoryAddressDebug, + sessionItems: input.sessionItems, toNonEmptyString: input.toNonEmptyString }); activeOrganization = scopedOrganization ?? activeOrganization; diff --git a/llm_normalizer/backend/dist/services/assistantLivingModePolicy.js b/llm_normalizer/backend/dist/services/assistantLivingModePolicy.js index 9424c2f..f4dde8d 100644 --- a/llm_normalizer/backend/dist/services/assistantLivingModePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantLivingModePolicy.js @@ -108,7 +108,7 @@ function createAssistantLivingModePolicy(deps) { const hasMemoryCue = samples.some((sample) => /(?:помни(?:шь|те|м)?|remember|recall)/iu.test(sample)); const hasDiscussionCue = samples.some((sample) => /(?:обсуждал[аи]?|говорил[аи]?|смотрел[аи]?|разбирал[аи]?|спрашивал[аи]?)/iu.test(sample)); const hasExplicitRecapPrompt = samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|напомни\s+что\s+мы|what\s+we\s+already\s+(?:discussed|figured\s+out))/iu.test(sample)); - if (!hasMemoryCue || !(hasDiscussionCue || hasExplicitRecapPrompt)) { + if (!(hasExplicitRecapPrompt || (hasMemoryCue && hasDiscussionCue))) { return false; } if (hasExplicitRecapPrompt) { diff --git a/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js b/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js index 107f618..4761720 100644 --- a/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js @@ -5,13 +5,13 @@ exports.buildInventoryHistoryCapabilityFollowupReply = buildInventoryHistoryCapa exports.buildAddressMemoryRecapReply = buildAddressMemoryRecapReply; exports.resolveAssistantLivingChatMemoryContext = resolveAssistantLivingChatMemoryContext; exports.createAssistantMemoryRecapPolicy = createAssistantMemoryRecapPolicy; -function formatIsoDateForReply(value) { - const source = String(value ?? "").trim(); - const match = source.match(/^(\d{4})-(\d{2})-(\d{2})$/); - if (!match) { +const assistantContinuityPolicy_1 = require("./assistantContinuityPolicy"); +function toNonEmptyString(value) { + if (value === null || value === undefined) { return null; } - return `${match[3]}.${match[2]}.${match[1]}`; + const text = String(value).trim(); + return text.length > 0 ? text : null; } function collectMessageSamples(input) { const values = [ @@ -30,89 +30,17 @@ function hasSignalAcrossSamples(samples, detector) { function hasExplicitRecapPromptSignal(samples) { return samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|что\s+уже\s+поняли|напомни\s+что\s+мы)/iu.test(sample)); } -function findLastGroundedInventoryAddressDebug(items) { - if (!Array.isArray(items)) { - return null; - } - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index]; - if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { - continue; - } - const debug = item.debug; - const answerGroundingCheck = debug.answer_grounding_check && typeof debug.answer_grounding_check === "object" - ? debug.answer_grounding_check - : null; - const groundingStatus = String(answerGroundingCheck?.status ?? ""); - const detectedIntent = String(debug.detected_intent ?? ""); - const capabilityId = String(debug.capability_id ?? ""); - const rootFrameContext = debug.address_root_frame_context && typeof debug.address_root_frame_context === "object" - ? debug.address_root_frame_context - : null; - const rootIntent = String(rootFrameContext?.root_intent ?? ""); - const isInventoryContext = detectedIntent === "inventory_on_hand_as_of_date" || - capabilityId === "confirmed_inventory_on_hand_as_of_date" || - rootIntent === "inventory_on_hand_as_of_date"; - if (groundingStatus === "grounded" && isInventoryContext) { - return debug; - } - } - return null; -} -function findLastAddressDebugWithItem(items) { - if (!Array.isArray(items)) { - return null; - } - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index]; - if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { - continue; - } - const debug = item.debug; - if (String(debug.execution_lane ?? "") !== "address_query") { - continue; - } - const extractedFilters = debug.extracted_filters && typeof debug.extracted_filters === "object" - ? debug.extracted_filters - : null; - const itemLabel = String(extractedFilters?.item ?? "").trim() || - (String(debug.anchor_type ?? "") === "item" - ? String(debug.anchor_value_resolved ?? debug.anchor_value_raw ?? "").trim() - : ""); - if (itemLabel) { - return debug; - } - } - return null; -} -function findLastAddressDebug(items) { - if (!Array.isArray(items)) { - return null; - } - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index]; - if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { - continue; - } - if (String(item.debug.execution_lane ?? "") === "address_query") { - return item.debug; - } - } - return null; -} function buildInventoryHistoryCapabilityFollowupReply(input) { const rootFrameContext = input.addressDebug?.address_root_frame_context && typeof input.addressDebug.address_root_frame_context === "object" ? input.addressDebug.address_root_frame_context : null; - const extractedFilters = input.addressDebug?.extracted_filters && typeof input.addressDebug.extracted_filters === "object" - ? input.addressDebug.extracted_filters - : null; + const extractedFilters = (0, assistantContinuityPolicy_1.readAddressDebugFilters)(input.addressDebug); const organization = input.organization ?? input.toNonEmptyString(rootFrameContext?.organization) ?? input.toNonEmptyString(extractedFilters?.organization); - const lastAsOfDate = formatIsoDateForReply(rootFrameContext?.as_of_date) ?? - formatIsoDateForReply(extractedFilters?.as_of_date); + const lastAsOfDate = (0, assistantContinuityPolicy_1.formatIsoDateForReply)(rootFrameContext?.as_of_date) ?? + (0, assistantContinuityPolicy_1.formatIsoDateForReply)(extractedFilters?.as_of_date); const organizationPart = organization ? ` по компании «${organization}»` : ""; const referenceLine = lastAsOfDate ? `Да, могу. Сейчас мы уже смотрели складской срез${organizationPart} на ${lastAsOfDate}.` @@ -128,6 +56,93 @@ function buildInventoryHistoryCapabilityFollowupReply(input) { "Если хочешь, сразу покажу нужный исторический период." ].join("\n"); } +function normalizeRecapIdentity(value) { + return String(value ?? "") + .trim() + .toLowerCase() + .replace(/[«»"'`]/g, "") + .replace(/\s+/g, " "); +} +function buildRecapFactLine(input) { + const detectedIntent = String(input.debug?.detected_intent ?? ""); + const scopedDate = (0, assistantContinuityPolicy_1.readAddressDebugScopedDate)(input.debug); + const itemPart = input.item ? `по позиции «${input.item}»` : null; + const organizationPart = input.organization ? `по компании «${input.organization}»` : null; + const datePart = scopedDate ? ` на ${scopedDate}` : ""; + if (detectedIntent === "inventory_on_hand_as_of_date") { + return `смотрели остатки${organizationPart ? ` ${organizationPart}` : ""}${datePart}`.trim(); + } + if (detectedIntent === "inventory_purchase_provenance_for_item" && itemPart) { + return `разобрали, кто поставлял ${itemPart}${datePart}`.trim(); + } + if (detectedIntent === "inventory_purchase_documents_for_item" && itemPart) { + return `подняли документы закупки ${itemPart}${datePart}`.trim(); + } + if (detectedIntent === "inventory_sale_trace_for_item" && itemPart) { + return `разобрали, кому продавали ${itemPart}${datePart}`.trim(); + } + if (detectedIntent === "inventory_purchase_to_sale_chain" && itemPart) { + return `проследили цепочку от закупки до продажи ${itemPart}${datePart}`.trim(); + } + if (detectedIntent === "inventory_profitability_for_item" && itemPart) { + return `смотрели рентабельность ${itemPart}${datePart}`.trim(); + } + if (detectedIntent === "inventory_aging_by_purchase_date" && itemPart) { + return `смотрели возраст остатков ${itemPart}${datePart}`.trim(); + } + if (detectedIntent === "counterparty_activity_lifecycle" && organizationPart) { + return `смотрели активность в базе 1С ${organizationPart}`.trim(); + } + if (detectedIntent === "list_documents_by_counterparty" && organizationPart) { + return `поднимали документы ${organizationPart}${datePart}`.trim(); + } + return null; +} +function collectRecentRecapFacts(input) { + const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : []; + if (sessionItems.length === 0) { + return []; + } + const currentItemKey = normalizeRecapIdentity(input.item); + const currentOrganizationKey = normalizeRecapIdentity(input.organization); + const facts = []; + const seen = new Set(); + 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 debugItem = (0, assistantContinuityPolicy_1.readAddressDebugItem)(item.debug, input.toNonEmptyString); + const debugOrganization = (0, assistantContinuityPolicy_1.readAddressDebugOrganization)(item.debug, input.toNonEmptyString); + const itemMatches = currentItemKey ? normalizeRecapIdentity(debugItem) === currentItemKey : false; + const organizationMatches = currentOrganizationKey + ? normalizeRecapIdentity(debugOrganization) === currentOrganizationKey + : false; + if (currentItemKey && !itemMatches) { + continue; + } + if (!currentItemKey && currentOrganizationKey && !organizationMatches) { + continue; + } + const fact = buildRecapFactLine({ + debug: item.debug, + item: debugItem, + organization: debugOrganization + }); + if (!fact || seen.has(fact)) { + continue; + } + seen.add(fact); + facts.push(fact); + if (facts.length >= 3) { + break; + } + } + return facts.reverse(); +} function buildAddressMemoryRecapReply(input) { const extractedFilters = input.addressDebug?.extracted_filters && typeof input.addressDebug.extracted_filters === "object" ? input.addressDebug.extracted_filters @@ -136,18 +151,27 @@ function buildAddressMemoryRecapReply(input) { typeof input.addressDebug.address_root_frame_context === "object" ? input.addressDebug.address_root_frame_context : null; - const item = input.toNonEmptyString(extractedFilters?.item) ?? - (String(input.addressDebug?.anchor_type ?? "") === "item" - ? input.toNonEmptyString(input.addressDebug?.anchor_value_resolved) ?? - input.toNonEmptyString(input.addressDebug?.anchor_value_raw) - : null); + const item = (0, assistantContinuityPolicy_1.readAddressDebugItem)(input.addressDebug, input.toNonEmptyString); const organization = input.organization ?? input.toNonEmptyString(extractedFilters?.organization) ?? input.toNonEmptyString(rootFrameContext?.organization); - const scopedDate = formatIsoDateForReply(extractedFilters?.as_of_date) ?? - formatIsoDateForReply(rootFrameContext?.as_of_date) ?? - formatIsoDateForReply(extractedFilters?.period_to); + const scopedDate = (0, assistantContinuityPolicy_1.readAddressDebugScopedDate)(input.addressDebug); + const recapFacts = collectRecentRecapFacts({ + sessionItems: input.sessionItems, + item, + organization, + toNonEmptyString: input.toNonEmptyString + }); if (item) { + if (recapFacts.length > 0) { + const datePart = scopedDate ? ` в срезе на ${scopedDate}` : ""; + const organizationPart = organization ? ` по компании «${organization}»` : ""; + return [ + `Да, помню. По позиции «${item}»${organizationPart}${datePart} мы уже выяснили:`, + ...recapFacts.map((fact) => `- ${fact}.`), + "Могу сразу продолжить по ней: поставщик, закупка, документы или продажа." + ].join("\n"); + } const datePart = scopedDate ? ` в срезе на ${scopedDate}` : ""; const organizationPart = organization ? ` по компании «${organization}»` : ""; return [ @@ -168,21 +192,29 @@ function buildAddressMemoryRecapReply(input) { function resolveAssistantLivingChatMemoryContext(input) { const contextualInventoryHistoryCapabilityFollowup = String(input.modeDecisionReason ?? "") === "inventory_history_capability_followup_detected"; const contextualMemoryRecapFollowup = String(input.modeDecisionReason ?? "") === "memory_recap_followup_detected"; - const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : []; + const continuity = (0, assistantContinuityPolicy_1.resolveAssistantContinuitySnapshot)({ + sessionItems: input.sessionItems, + toNonEmptyString + }); return { contextualInventoryHistoryCapabilityFollowup, contextualMemoryRecapFollowup, lastGroundedInventoryAddressDebug: contextualInventoryHistoryCapabilityFollowup - ? findLastGroundedInventoryAddressDebug(sessionItems) + ? continuity.lastGroundedInventoryAddressDebug : null, lastMemoryAddressDebug: contextualMemoryRecapFollowup - ? findLastAddressDebugWithItem(sessionItems) ?? findLastAddressDebug(sessionItems) + ? continuity.lastGroundedItemAddressDebug ?? continuity.lastGroundedAddressDebug : null }; } function createAssistantMemoryRecapPolicy(deps) { function resolveRouteMemorySignals(input) { const samples = collectMessageSamples(input); + const continuity = (0, assistantContinuityPolicy_1.resolveAssistantContinuitySnapshot)({ + sessionItems: input.sessionItems, + toNonEmptyString + }); + const groundedInventoryContext = continuity.lastGroundedInventoryAddressDebug ?? input.lastGroundedAddressDebug; const historicalCapabilitySignal = hasSignalAcrossSamples(samples, deps.hasHistoricalCapabilityFollowupSignal); const memoryRecapSignal = hasSignalAcrossSamples(samples, deps.hasConversationMemoryRecallFollowupSignal); const explicitRecapPromptSignal = hasExplicitRecapPromptSignal(samples); @@ -191,13 +223,13 @@ function createAssistantMemoryRecapPolicy(deps) { !input.dataScopeMetaQuery && !input.dataRetrievalSignal && historicalCapabilitySignal && - deps.isGroundedInventoryContextDebug(input.lastGroundedAddressDebug)), + deps.isGroundedInventoryContextDebug(groundedInventoryContext)), contextualMemoryRecapFollowupDetected: Boolean(!input.dataScopeMetaQuery && !input.capabilityMetaQuery && !input.aggregateBusinessAnalyticsSignal && memoryRecapSignal && (explicitRecapPromptSignal || (!input.dataRetrievalSignal && !input.strongDataSignal)) && - input.hasPriorAddressDebug) + continuity.hasGroundedAddressContext) }; } return { diff --git a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js index 40099a6..41db71d 100644 --- a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js @@ -102,10 +102,16 @@ function createAssistantRoutePolicy(deps) { hasDataRetrievalRequestSignal(repairedRawUserMessage) || hasDataRetrievalRequestSignal(effectiveAddressUserMessage) || hasDataRetrievalRequestSignal(repairedEffectiveAddressUserMessage); + const analyticsSample = compactWhitespace(`${repairedRawUserMessage} ${repairedEffectiveAddressUserMessage}`.toLowerCase()); + const colloquialCustomerValueSignal = /(?:\u043a\u0442\u043e|\u043a\u043e\u043c\u0443|customer|client|counterparty)/iu.test(analyticsSample) && + /(?:(?:\u043f\u0440\u0438\u043d\u0435\u0441|\u043f\u0440\u0438\u043d\u0451\u0441|\u0437\u0430\u043d\u0435\u0441|\u0437\u0430\u043d\u0451\u0441).*(?:\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u044c\u0433)|(?:\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u044c\u0433).*(?:\u043f\u0440\u0438\u043d\u0435\u0441|\u043f\u0440\u0438\u043d\u0451\u0441|\u0437\u0430\u043d\u0435\u0441|\u0437\u0430\u043d\u0451\u0441)|(?:\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?).*(?:\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u044c\u0433))/iu.test(analyticsSample); + const turnoverAggregateSignal = /(?:\u043e\u0431\u043e\u0440\u043e\u0442(?:\u044b)?|\u0432\u044b\u0440\u0443\u0447\u043a|\u0434\u043e\u0445\u043e\u0434|turnover|revenue).*(?:\u0437\u0430\s+\d{4}\s+\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u0433\u043e\u0434|year|month|quarter)/iu.test(analyticsSample); + const aggregateBusinessAnalyticsBridgeDetected = colloquialCustomerValueSignal || turnoverAggregateSignal; const aggregateBusinessAnalyticsSignal = hasAggregateBusinessAnalyticsSignal(rawUserMessage) || hasAggregateBusinessAnalyticsSignal(repairedRawUserMessage) || hasAggregateBusinessAnalyticsSignal(effectiveAddressUserMessage) || - hasAggregateBusinessAnalyticsSignal(repairedEffectiveAddressUserMessage); + hasAggregateBusinessAnalyticsSignal(repairedEffectiveAddressUserMessage) || + aggregateBusinessAnalyticsBridgeDetected; const standaloneAddressTopicSignal = hasStandaloneAddressTopicSignal(rawUserMessage) || hasStandaloneAddressTopicSignal(repairedRawUserMessage) || hasStandaloneAddressTopicSignal(effectiveAddressUserMessage) || @@ -195,15 +201,20 @@ function createAssistantRoutePolicy(deps) { !capabilityMetaQuery && !dataRetrievalSignal); const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal; + const baseToolGate = resolveAddressToolGateDecision(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage); const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery && !capabilityMetaQuery && !dataRetrievalSignal && + !aggregateBusinessAnalyticsSignal && !effectiveAddressFollowupSignal && resolvedModeDetection.mode === "unsupported" && resolvedIntentResolution.intent === "unknown"); + const baseToolGatePreservesAddressLane = Boolean(baseToolGate?.runAddressLane && + ["address_intent_resolver_detected", "address_mode_classifier_detected", "address_signal_detected", "llm_canonical_data_signal_detected"].includes(String(baseToolGate?.reason ?? ""))); const nonDomainQueryIndexed = Boolean(!llmFirstAddressCandidate && deterministicNonDomainGuard && (llmFirstUnsupportedCandidate || llmContractMode === null) && + !baseToolGatePreservesAddressLane && !protectedInventoryShortFollowup && !organizationClarificationContinuationDetected); const lastAddressAssistantDebug = sessionItems @@ -220,7 +231,8 @@ function createAssistantRoutePolicy(deps) { strongDataSignal, aggregateBusinessAnalyticsSignal, lastGroundedAddressDebug, - hasPriorAddressDebug: Boolean(lastGroundedAddressDebug || lastAddressAssistantDebug) + hasPriorAddressDebug: Boolean(lastGroundedAddressDebug || lastAddressAssistantDebug), + sessionItems }); const contextualHistoricalCapabilityFollowupDetected = memorySignals.contextualHistoricalCapabilityFollowupDetected; const contextualMemoryRecapFollowupDetected = memorySignals.contextualMemoryRecapFollowupDetected; @@ -392,6 +404,143 @@ function createAssistantRoutePolicy(deps) { } }; } + const supportedExactInvestigativeAddressBypass = Boolean(llmContractMode === "deep_analysis" && + semanticApplyCanonicalRecommended && + strictDeepInvestigationBypassAllowed && + llmContractIntent && + ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)); + if (supportedExactInvestigativeAddressBypass) { + return { + runAddressLane: true, + toolGateDecision: "run_address_lane", + toolGateReason: "address_signal_detected", + livingMode: "address_data", + livingReason: "address_lane_triggered", + orchestrationContract: { + schema_version: "assistant_orchestration_contract_v1", + hard_meta_mode: null, + provider_execution: providerExecution, + address_mode: resolvedModeDetection.mode, + address_mode_confidence: resolvedModeDetection.confidence, + address_intent: resolvedIntentResolution.intent, + address_intent_confidence: resolvedIntentResolution.confidence, + strong_data_signal_detected: strongDataSignal, + data_retrieval_signal_detected: dataRetrievalSignal, + semantic_contract_valid: semanticContractValid, + semantic_apply_canonical_recommended: semanticApplyCanonicalRecommended, + semantic_reason_codes: semanticReasonCodes, + semantic_route_arbitration: { + supported_address_intent_detected: true, + strict_deep_investigation_bypass_allowed: true, + semantic_deep_investigation_hint_detected: semanticExtractionContract?.guard_hints?.deep_investigation_signal_detected === true, + semantic_aggregate_shape_detected: false, + followup_semantic_override_to_deep_allowed: false + }, + followup_context_detected: Boolean(followupContext), + unsupported_address_intent_fallback_to_deep: false, + deep_analysis_signal_fallback_to_deep: false, + aggregate_analytics_signal_fallback_to_deep: false, + deep_session_continuation_fallback_to_deep: false, + final_decision: { + run_address_lane: true, + tool_gate_decision: "run_address_lane", + tool_gate_reason: "address_signal_detected", + living_mode: "address_data", + living_reason: "address_lane_triggered" + } + } + }; + } + if (colloquialCustomerValueSignal) { + return { + runAddressLane: true, + toolGateDecision: "run_address_lane", + toolGateReason: "address_signal_detected", + livingMode: "address_data", + livingReason: "address_lane_triggered", + orchestrationContract: { + schema_version: "assistant_orchestration_contract_v1", + hard_meta_mode: null, + provider_execution: providerExecution, + address_mode: resolvedModeDetection.mode, + address_mode_confidence: resolvedModeDetection.confidence, + address_intent: resolvedIntentResolution.intent, + address_intent_confidence: resolvedIntentResolution.confidence, + strong_data_signal_detected: true, + data_retrieval_signal_detected: dataRetrievalSignal, + semantic_contract_valid: semanticContractValid, + semantic_apply_canonical_recommended: semanticApplyCanonicalRecommended, + semantic_reason_codes: semanticReasonCodes, + semantic_route_arbitration: { + supported_address_intent_detected: true, + strict_deep_investigation_bypass_allowed: false, + semantic_deep_investigation_hint_detected: false, + semantic_aggregate_shape_detected: true, + followup_semantic_override_to_deep_allowed: false + }, + followup_context_detected: Boolean(followupContext), + unsupported_address_intent_fallback_to_deep: false, + deep_analysis_signal_fallback_to_deep: false, + aggregate_analytics_signal_fallback_to_deep: false, + deep_session_continuation_fallback_to_deep: false, + final_decision: { + run_address_lane: true, + tool_gate_decision: "run_address_lane", + tool_gate_reason: "address_signal_detected", + living_mode: "address_data", + living_reason: "address_lane_triggered" + } + } + }; + } + const standaloneAggregateAnalyticsFollowupFallback = Boolean(followupContext && + aggregateBusinessAnalyticsBridgeDetected && + llmContractMode === null && + !((resolvedIntentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(resolvedIntentResolution.intent)) || + (llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) || + openContractsAddressSignal)); + if (standaloneAggregateAnalyticsFollowupFallback) { + return { + runAddressLane: false, + toolGateDecision: "skip_address_lane", + toolGateReason: "aggregate_analytics_signal_fallback_to_deep", + livingMode: "deep_analysis", + livingReason: "aggregate_analytics_signal_fallback_to_deep", + orchestrationContract: { + schema_version: "assistant_orchestration_contract_v1", + hard_meta_mode: null, + provider_execution: providerExecution, + address_mode: resolvedModeDetection.mode, + address_mode_confidence: resolvedModeDetection.confidence, + address_intent: resolvedIntentResolution.intent, + address_intent_confidence: resolvedIntentResolution.confidence, + strong_data_signal_detected: strongDataSignal, + data_retrieval_signal_detected: dataRetrievalSignal, + semantic_contract_valid: semanticContractValid, + semantic_apply_canonical_recommended: semanticApplyCanonicalRecommended, + semantic_reason_codes: semanticReasonCodes, + semantic_route_arbitration: { + supported_address_intent_detected: false, + strict_deep_investigation_bypass_allowed: false, + semantic_deep_investigation_hint_detected: false, + semantic_aggregate_shape_detected: true, + followup_semantic_override_to_deep_allowed: true + }, + followup_context_detected: true, + unsupported_address_intent_fallback_to_deep: false, + deep_analysis_signal_fallback_to_deep: false, + aggregate_analytics_signal_fallback_to_deep: true, + deep_session_continuation_fallback_to_deep: false, + final_decision: { + run_address_lane: false, + tool_gate_decision: "skip_address_lane", + tool_gate_reason: "aggregate_analytics_signal_fallback_to_deep", + living_mode: "deep_analysis", + living_reason: "aggregate_analytics_signal_fallback_to_deep" + } + } + }; + } if (nonDomainQueryIndexed) { return { runAddressLane: false, @@ -422,7 +571,6 @@ function createAssistantRoutePolicy(deps) { }; } const metaAnswerFollowupSignal = metaSignals.metaAnswerFollowupSignal; - const baseToolGate = resolveAddressToolGateDecision(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage); const preserveAddressLaneSignal = Boolean((llmPreDecomposeMeta?.llmCanonicalCandidateDetected && llmPreDecomposeMeta?.applied && llmContractMode === "address_query") || @@ -447,6 +595,12 @@ function createAssistantRoutePolicy(deps) { Boolean((resolvedIntentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(resolvedIntentResolution.intent)) || (llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) || openContractsAddressSignal); + const supportedAddressRouteCandidateDetected = Boolean(supportedAddressIntentDetected || + keepAddressLaneByIntent || + (baseToolGate?.runAddressLane && + llmContractMode !== "deep_analysis" && + !strictDeepInvestigationCueDetected && + ["address_intent_resolver_detected", "address_mode_classifier_detected", "address_signal_detected", "llm_canonical_data_signal_detected"].includes(String(baseToolGate?.reason ?? "")))); const semanticGuardHints = semanticExtractionContract?.guard_hints && typeof semanticExtractionContract.guard_hints === "object" ? semanticExtractionContract.guard_hints @@ -481,8 +635,7 @@ function createAssistantRoutePolicy(deps) { semanticDeepInvestigationHintDetected || aggregateBusinessAnalyticsSignal) && !preserveAddressLaneSignal && - !keepAddressLaneByIntent && - !supportedAddressIntentDetected && + !supportedAddressRouteCandidateDetected && (!followupContext || followupSemanticOverrideToDeepAllowed)); const deepAnalysisPreferenceDetected = Boolean(hasDeepAnalysisPreferenceSignal(rawUserMessage) || hasDeepAnalysisPreferenceSignal(repairedRawUserMessage) || @@ -492,6 +645,19 @@ function createAssistantRoutePolicy(deps) { hasDirectDeepAnalysisSignal(repairedRawUserMessage) || hasDirectDeepAnalysisSignal(effectiveAddressUserMessage) || hasDirectDeepAnalysisSignal(repairedEffectiveAddressUserMessage)); + const exactAddressIntentProtectedFromSemanticDeepHint = Boolean(supportedAddressRouteCandidateDetected && + llmContractMode === "address_query" && + llmContractIntent && + ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent) && + semanticApplyCanonicalRecommended && + !deepAnalysisPreferenceDetected && + !strictDeepInvestigationCueDetected && + !semanticAggregateShapeDetected); + const protectAddressLaneFromFallback = Boolean(supportedAddressRouteCandidateDetected && + !deepAnalysisPreferenceDetected && + (exactAddressIntentProtectedFromSemanticDeepHint || + !semanticDeepInvestigationHintDetected || + strictDeepInvestigationBypassAllowed)); const vatExplainFollowupSignal = Boolean(followupContext && toNonEmptyString(followupContext.previous_intent) === "vat_payable_forecast" && /(?:\u043f\u043e\u0447\u0435\u043c\u0443|why).*(?:\u043f\u0440\u043e\u0433\u043d\u043e\u0437|forecast).*(?:\u0443\u043f\u043b\u0430\u0442|payable|\b0\b)/iu.test(compactWhitespace(`${repairedRawUserMessage} ${repairedEffectiveAddressUserMessage}`))); @@ -501,17 +667,16 @@ function createAssistantRoutePolicy(deps) { const deepAnalysisSignalFallbackToDeep = Boolean(baseToolGate?.runAddressLane && !llmRuntimeUnavailableDetected && (deepAnalysisPreferenceDetected || semanticDeepInvestigationHintDetected) && - !keepAddressLaneByIntent && - !supportedAddressIntentDetected && + !protectAddressLaneFromFallback && !vatExplainFollowupSignal && (!followupContext || !dataRetrievalSignal || followupSemanticOverrideToDeepAllowed)); const aggregateAnalyticsFallbackToDeep = Boolean(baseToolGate?.runAddressLane && !llmRuntimeUnavailableDetected && aggregateBusinessAnalyticsSignal && - !keepAddressLaneByIntent && - !supportedAddressIntentDetected && + !protectAddressLaneFromFallback && (!followupContext || llmContractMode === "unsupported" || + llmContractMode === null || semanticAggregateShapeDetected || !semanticApplyCanonicalRecommended || standaloneAddressTopicSignal)); @@ -631,10 +796,11 @@ function createAssistantRoutePolicy(deps) { semantic_apply_canonical_recommended: semanticApplyCanonicalRecommended, semantic_reason_codes: semanticReasonCodes, semantic_route_arbitration: { - supported_address_intent_detected: supportedAddressIntentDetected, + supported_address_intent_detected: supportedAddressRouteCandidateDetected, strict_deep_investigation_bypass_allowed: strictDeepInvestigationBypassAllowed, semantic_deep_investigation_hint_detected: semanticDeepInvestigationHintDetected, semantic_aggregate_shape_detected: semanticAggregateShapeDetected, + exact_address_intent_protected_from_semantic_deep_hint: exactAddressIntentProtectedFromSemanticDeepHint, followup_semantic_override_to_deep_allowed: followupSemanticOverrideToDeepAllowed }, followup_context_detected: Boolean(followupContext), diff --git a/llm_normalizer/backend/dist/services/assistantRuntimeGuards.js b/llm_normalizer/backend/dist/services/assistantRuntimeGuards.js index 2b830e4..ad6e387 100644 --- a/llm_normalizer/backend/dist/services/assistantRuntimeGuards.js +++ b/llm_normalizer/backend/dist/services/assistantRuntimeGuards.js @@ -174,7 +174,7 @@ function intersectsSpan(start, end, spans) { function hasAccountContextAround(text, start, end) { const left = text.slice(Math.max(0, start - 28), start); const right = text.slice(end, Math.min(text.length, end + 28)); - return /(?:счет|сч\.?|account|schet|оплат|расч[её]т|расчет|аванс|зач[её]т|долг|постав|покуп|supplier|customer|settlement|payment|ндс|vat|проводк|posting)/iu.test(`${left} ${right}`); + return /(?:сч(?:е|ё)т(?:а|у|ом|ов)?|сч\.?|account|schet|оплат|расч[её]т|расчет|аванс|зач[её]т|долг|постав|покуп|supplier|customer|settlement|payment|ндс|vat|проводк|posting)/iu.test(`${left} ${right}`); } function extractAccountsFromTextDetailed(text, options) { const lower = String(text ?? "").toLowerCase(); @@ -184,8 +184,8 @@ function extractAccountsFromTextDetailed(text, options) { const percentSpans = collectPercentLikeSpans(lower); const contractSpans = collectContractLikeSpans(lower); const blockedSpans = [...dateSpans, ...amountSpans, ...percentSpans, ...contractSpans]; - const hasAccountingLexeme = /(?:\bсчет(?:а|у|ом|ов)?\b|\bсч\.?\b|\baccount(?:s)?\b|\bschet(?:a|u|om|ov)?\b|оплат|расч[её]т|расчет|аванс|долг|settlement|payment|supplier|customer|постав|покуп)/iu.test(lower); - const contextualPattern = /(?:\b(?:счет(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?)\b\s*(?:№|#|:)?\s*)(\d{2}(?:\.\d{2})?)/giu; + const hasAccountingLexeme = /(?:сч(?:е|ё)т(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?|оплат|расч[её]т|расчет|аванс|долг|settlement|payment|supplier|customer|постав|покуп)/iu.test(lower); + const contextualPattern = /(?:^|[^\p{L}\d])(?:сч(?:е|ё)т(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?)[\s#:№]*((?:\d{2})(?:\.\d{2})?)/giu; let contextualMatch = null; while ((contextualMatch = contextualPattern.exec(lower)) !== null) { const token = String(contextualMatch[1] ?? "").trim(); @@ -811,7 +811,15 @@ function resolveDomainPolarityGuard(input) { const repairedMessage = repairRuntimeGuardsMojibake(String(input.userMessage ?? "")); const lower = repairedMessage.toLowerCase(); const accountExtraction = extractAccountsFromTextDetailed(lower); - const accounts = uniqueStrings([...(input.companyAnchors?.accounts ?? []), ...accountExtraction.resolved_account_anchors]); + let accounts = uniqueStrings([...(input.companyAnchors?.accounts ?? []), ...accountExtraction.resolved_account_anchors]); + if (accounts.length === 0) { + const directAccountMentions = Array.from(repairedMessage.matchAll(/(?:^|[^\p{L}\d])(?:сч(?:е|ё)т(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?)[\s#:№]*((?:60|62|76)(?:\.\d{2})?)/giu)) + .map((match) => String(match[1] ?? "").trim()) + .filter((token) => token.length > 0); + if (directAccountMentions.length > 0) { + accounts = uniqueStrings([...accounts, ...directAccountMentions]); + } + } const prefixes = new Set(accounts.map((item) => accountPrefix(item)).filter((item) => Boolean(item))); const settlementSignal = input.focusDomainHint === "settlements_60_62" || prefixes.has("60") || diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index 8526efc..927a174 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -3701,6 +3701,9 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll ? llmPreDecomposeMeta.semanticExtractionContract : null; const semanticCanonicalRecommended = semanticExtractionContract?.apply_canonical_recommended !== false; + const llmSupportedDeepAddressIntentSignal = llmContractMode === "deep_analysis" && + /^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_profitability_for_item|inventory_purchase_to_sale_chain)$/u.test(llmContractIntent ?? "") && + semanticCanonicalRecommended; const llmCanonicalEntitySignal = /(?:\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043a\u043e\u043c\u043f\u0430\u043d|customer|supplier|counterparty|company|vendor|client)/iu.test(compactWhitespace(repairedInputMessage.toLowerCase())); const llmCanonicalAppliedSignal = Boolean(llmPreDecomposeMeta?.applied) && llmContractMode !== "deep_analysis"; const hasLlmCanonicalSignal = semanticCanonicalRecommended && @@ -3713,6 +3716,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll Boolean(llmPreDecomposeMeta?.applied) && (llmContractMode === "address_query" || llmContractMode === "unsupported" || llmContractMode === null) && hasStrongDataIntentSignal(repairedInputMessage); + const hasBusinessRankingAddressSignal = /(?:\u043a\u0442\u043e\s+(?:\u043d\u0430\u043c\s+)?(?:\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?\s+\u043f\u0440\u0438\u043d\u0435\u0441|\u043f\u0440\u0438\u043d\u0435\u0441\s+\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?).*(?:\u0434\u0435\u043d\u0435\u0433)?|who\s+brought\s+(?:us\s+)?(?:the\s+)?most\s+money)/iu.test(compactWhitespace(repairedInputMessage.toLowerCase())); const sameDateAccountFollowupSignal = hasSameDateAccountFollowupSignalForPredecompose(rawMessageForGate) || hasSameDateAccountFollowupSignalForPredecompose(repairedInputMessage); const hasLexicalAddressSignal = isAddressLlmPreDecomposeCandidate(addressInputMessage) || @@ -3721,11 +3725,12 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll hasAccountingSignal(repairedInputMessage) || hasShortDebtMirrorFollowupSignal(rawMessageForGate) || hasShortDebtMirrorFollowupSignal(repairedInputMessage) || + hasBusinessRankingAddressSignal || sameDateAccountFollowupSignal; const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" && (llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") && llmContractIntent === "unknown"; - const hasAnyAddressSignal = hasClassifierSignal || hasIntentSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal; + const hasAnyAddressSignal = hasClassifierSignal || hasIntentSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal || llmSupportedDeepAddressIntentSignal; const strongDataSignalFromRawMessage = hasStrongDataIntentSignal(rawMessageForGate) || hasDataRetrievalRequestSignal(rawMessageForGate) || hasAccountingSignal(rawMessageForGate) || @@ -3739,6 +3744,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll !hasClassifierSignal && !hasIntentSignal && !hasLexicalAddressSignal && + !llmSupportedDeepAddressIntentSignal && !strongDataSignalFromRawMessage && !strongDataSignalFromEffectiveMessage) { return { @@ -3749,6 +3755,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll } if (hasUnsupportedLowConfidencePredecomposeSignal && !followupContext && !hasAnyAddressSignal && + !llmSupportedDeepAddressIntentSignal && !strongDataSignalFromRawMessage && !strongDataSignalFromEffectiveMessage) { return { @@ -3770,7 +3777,9 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll ? "llm_canonical_candidate_detected" : hasLlmCanonicalDataSignal ? "llm_canonical_data_signal_detected" - : "address_signal_detected" + : llmSupportedDeepAddressIntentSignal + ? "address_signal_detected" + : "address_signal_detected" }; } if (followupContext) { @@ -3886,6 +3895,11 @@ function hasAggregateBusinessAnalyticsSignal(text) { if (!normalized) { return false; } + const hasTurnoverByEntityBridgeSignal = /(?:\u043e\u0431\u043e\u0440\u043e\u0442(?:\u044b)?|\u0432\u044b\u0440\u0443\u0447\u043a|\u0434\u043e\u0445\u043e\u0434|turnover|revenue).*(?:\u0437\u0430\s+\d{4}\s+\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u0433\u043e\u0434|year|month|quarter)/iu.test(normalized) && + /(?:\b\u043f\u043e\s+[\p{L}\d._-]{3,}|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|customer|supplier|counterparty|company)/iu.test(normalized); + if (hasTurnoverByEntityBridgeSignal) { + return true; + } const hasMetricCue = /(?:\u043e\u0431\u043e\u0440\u043e\u0442|\u0432\u044b\u0440\u0443\u0447|\u0434\u043e\u0445\u043e\u0434|\u043f\u0440\u0438\u0431\u044b\u043b|\u043c\u0430\u0440\u0436|\u0440\u0435\u043d\u0442\u0430\u0431\u0435\u043b|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u0435\u043b|turnover|revenue|profit|margin)/iu.test(normalized); if (!hasMetricCue) { return false; diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index 66f7329..d8bf49b 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -3,6 +3,38 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.createAssistantTransitionPolicy = createAssistantTransitionPolicy; function createAssistantTransitionPolicy(deps) { + function hasInventoryRootRestatementLikeSignal(userMessage, sourceIntentHint, hasInventoryRootFrame) { + if (!hasInventoryRootFrame) { + return false; + } + const normalized = deps + .compactWhitespace(deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase()) + .replace(/ё/g, "е"); + if (!normalized) { + return false; + } + if (deps.countTokens(normalized) > 10) { + return false; + } + const hasInventoryLexeme = /(?:остат|склад|товар|номенклатур|позиц)/iu.test(normalized); + const hasRestatementCue = /(?:еще\s+раз|снова|повтори|повтори\s+еще\s+раз|верни|покажи)/iu.test(normalized) && + /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|на\s+эту\s+дат[ауеы]|эту\s+дат[ауеы]|та\s+же\s+дата|тот\s+же\s+период|этот\s+же\s+период)/iu.test(normalized); + const hasBareSnapshotSameDateCue = hasInventoryLexeme && + /(?:РЅР°\s+ту\s+Р¶Рµ\s+дат[ауеы]|РЅР°\s+эту\s+Р¶Рµ\s+дат[ауеы]|РЅР°\s+эту\s+дат[ауеы]|эту\s+дат[ауеы]|та\s+Р¶Рµ\s+дата|тот\s+Р¶Рµ\s+период|этот\s+Р¶Рµ\s+период)/iu.test(normalized); + const bareSnapshotSameDatePhrases = [ + "\u043d\u0430 \u0442\u0443 \u0436\u0435 \u0434\u0430\u0442", + "\u043d\u0430 \u044d\u0442\u0443 \u0436\u0435 \u0434\u0430\u0442", + "\u043d\u0430 \u044d\u0442\u0443 \u0434\u0430\u0442", + "\u044d\u0442\u0443 \u0434\u0430\u0442", + "\u0442\u0430 \u0436\u0435 \u0434\u0430\u0442\u0430", + "\u0442\u043e\u0442 \u0436\u0435 \u043f\u0435\u0440\u0438\u043e\u0434", + "\u044d\u0442\u043e\u0442 \u0436\u0435 \u043f\u0435\u0440\u0438\u043e\u0434" + ]; + const hasBareSnapshotSameDatePhraseCue = hasInventoryLexeme && bareSnapshotSameDatePhrases.some((phrase) => normalized.includes(phrase)); + return (hasInventoryLexeme && + (hasRestatementCue || hasBareSnapshotSameDateCue || hasBareSnapshotSameDatePhraseCue) && + !deps.hasForeignAccountingPivotOverInventoryMessage(normalized)); + } function shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent) { const normalized = deps.compactWhitespace(deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase()); if (!normalized || deps.countTokens(normalized) > 4) { @@ -16,6 +48,62 @@ function createAssistantTransitionPolicy(deps) { } return /^(?:Р°|Рё|РЅСѓ)?\s*РїРѕ\s+[a-zР°-СЏС‘0-9._-]{2,}(?:\s+[a-zР°-СЏС‘0-9._-]{2,})?$/iu.test(normalized); } + function shouldKeepPreviousIntentForShortCounterpartyRetargetV2(userMessage, sourceIntent) { + const normalized = deps.compactWhitespace(deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase()); + if (!normalized || deps.countTokens(normalized) > 4) { + return false; + } + if (sourceIntent !== "list_documents_by_counterparty" && sourceIntent !== "list_documents_by_contract") { + return false; + } + if (/(?:bank|payment|wire|statement)/iu.test(normalized)) { + return false; + } + return /^(?:а|и|ну)?\s*(?:покажи\s+)?по\s+[a-zа-яё0-9._-]{2,}(?:\s+[a-zа-яё0-9._-]{2,})?$/iu.test(normalized); + } + function inferStandaloneAddressTopicFamily(userMessage) { + const normalized = deps.compactWhitespace(deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase()); + if (!normalized) { + return null; + } + if (/(?:ндс|vat)/iu.test(normalized)) { + return "vat"; + } + if (/(?:остат|склад|товар|номенклатур|позици)/iu.test(normalized)) { + return "inventory"; + } + return null; + } + function resolveDisplayedEntityRetargetIntent(userMessage, entityType) { + const normalized = deps.compactWhitespace(deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase()); + if (!normalized) { + return null; + } + if (entityType === "counterparty") { + if (/(?:договор|контракт)/iu.test(normalized)) { + return "list_contracts_by_counterparty"; + } + if (/(?:банк|выписк|плат[её]ж|оплат|statement|payment|wire)/iu.test(normalized)) { + return "bank_operations_by_counterparty"; + } + if (/(?:документ|накладн|счет|сч[её]т|акт|реализац|поступл)/iu.test(normalized)) { + return "list_documents_by_counterparty"; + } + if (/(?:сколько\s+денег|сколько\s+принес|выручк|сумм[аы]?|оплатил|продаж)/iu.test(normalized)) { + return "customer_revenue_and_payments"; + } + return null; + } + if (entityType === "contract") { + if (/(?:банк|выписк|плат[её]ж|оплат|statement|payment|wire)/iu.test(normalized)) { + return "bank_operations_by_contract"; + } + if (/(?:документ|накладн|счет|сч[её]т|акт|реализац|поступл)/iu.test(normalized)) { + return "list_documents_by_contract"; + } + } + return null; + } function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null, addressNavigationState = null) { const previousAddressItem = deps.findLastAddressAssistantItem(items); const previousAddressDebug = previousAddressItem?.debug ?? null; @@ -78,6 +166,10 @@ function createAssistantTransitionPolicy(deps) { const hasInventoryRootTemporalFollowupAlternate = deps.toNonEmptyString(alternateMessage) ? deps.hasInventoryRootTemporalFollowupSignal(String(alternateMessage ?? ""), sourceIntentHint, Boolean(recentInventoryRootFrame)) : false; + const hasInventoryRootRestatementPrimary = hasInventoryRootRestatementLikeSignal(userMessage, sourceIntentHint, Boolean(recentInventoryRootFrame)); + const hasInventoryRootRestatementAlternate = deps.toNonEmptyString(alternateMessage) + ? hasInventoryRootRestatementLikeSignal(String(alternateMessage ?? ""), sourceIntentHint, Boolean(recentInventoryRootFrame)) + : false; let hasStrongFollowupReference = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal || hasOrganizationClarificationContinuation || @@ -86,6 +178,8 @@ function createAssistantTransitionPolicy(deps) { inventoryShortFollowupAlternate || hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate || + hasInventoryRootRestatementPrimary || + hasInventoryRootRestatementAlternate || Boolean(debtRoleSwapIntent) || deps.hasFollowupMarker(userMessage) || deps.hasReferentialPointer(userMessage) || @@ -100,6 +194,8 @@ function createAssistantTransitionPolicy(deps) { !hasAlternateFollowupSignal && !hasInventoryRootTemporalFollowupPrimary && !hasInventoryRootTemporalFollowupAlternate && + !hasInventoryRootRestatementPrimary && + !hasInventoryRootRestatementAlternate && !hasImplicitContinuationSignal && !hasOrganizationClarificationContinuation && !hasIndexReferenceSignal) { @@ -109,6 +205,8 @@ function createAssistantTransitionPolicy(deps) { !hasAlternateFollowupSignal && !hasInventoryRootTemporalFollowupPrimary && !hasInventoryRootTemporalFollowupAlternate && + !hasInventoryRootRestatementPrimary && + !hasInventoryRootRestatementAlternate && !hasImplicitContinuationSignal && !hasOrganizationClarificationContinuation && !hasIndexReferenceSignal) { @@ -131,8 +229,38 @@ function createAssistantTransitionPolicy(deps) { ? resolvedAlternateIntent : null; const sourceIntentFamily = deps.resolveAddressIntentFamily(sourceIntent); - const explicitIntentFamily = deps.resolveAddressIntentFamily(explicitIntent); - if (sourceIntentFamily && explicitIntentFamily && sourceIntentFamily !== explicitIntentFamily && !hasStrongFollowupReference) { + const explicitIntentFamily = deps.resolveAddressIntentFamily(explicitIntent) ?? + inferStandaloneAddressTopicFamily(userMessage) ?? + (deps.toNonEmptyString(alternateMessage) ? inferStandaloneAddressTopicFamily(String(alternateMessage ?? "")) : null); + const foreignAccountingPivotOverInventory = deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage); + const familyMismatchStandaloneTopic = Boolean(hasStandaloneAddressTopic && + sourceIntentFamily && + explicitIntentFamily && + sourceIntentFamily !== explicitIntentFamily && + !hasOrganizationClarificationContinuation && + !hasImplicitContinuationSignal && + !hasIndexReferenceSignal && + !hasInventoryRootTemporalFollowupPrimary && + !hasInventoryRootTemporalFollowupAlternate && + !hasInventoryRootRestatementPrimary && + !hasInventoryRootRestatementAlternate && + !inventoryShortFollowupPrimary && + !inventoryShortFollowupAlternate && + !foreignAccountingPivotOverInventory && + !deps.hasFollowupMarker(userMessage) && + !deps.hasReferentialPointer(userMessage) && + (!deps.toNonEmptyString(alternateMessage) + ? true + : !deps.hasFollowupMarker(String(alternateMessage ?? "")) && + !deps.hasReferentialPointer(String(alternateMessage ?? "")))); + if (familyMismatchStandaloneTopic) { + return null; + } + if (sourceIntentFamily && + explicitIntentFamily && + sourceIntentFamily !== explicitIntentFamily && + !hasStrongFollowupReference && + !foreignAccountingPivotOverInventory) { return null; } let previousIntent = sourceIntent; @@ -144,7 +272,7 @@ function createAssistantTransitionPolicy(deps) { const suggestedIntent = Array.isArray(followupOffer?.suggested_intents) ? deps.toNonEmptyString(followupOffer.suggested_intents[0]) : null; - const keepPreviousIntent = shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent); + const keepPreviousIntent = shouldKeepPreviousIntentForShortCounterpartyRetargetV2(userMessage, sourceIntent); if (suggestedIntent && !keepPreviousIntent) { previousIntent = suggestedIntent; followupSelectionMode = "switch_to_suggested_intent"; @@ -248,6 +376,7 @@ function createAssistantTransitionPolicy(deps) { : "generic" : null; let resolvedCounterpartyFromDisplay = false; + let displayedEntityTargetIntent = null; const previousFiltersRaw = previousAddressDebug.extracted_filters; let previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object" ? { ...previousFiltersRaw } : {}; const shouldBackfillHistoricalPartyAnchors = sourceIntentHint === "list_contracts_by_counterparty" || @@ -314,7 +443,15 @@ function createAssistantTransitionPolicy(deps) { currentFrameKind === "inventory_root") && (hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate) && !deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage)); - const rootScopedPivot = rootContextOnlyPivot || inventoryRootTemporalPivot; + const inventoryRootRestatementPivot = Boolean(inventoryRootFrame && + (deps.isInventorySelectedObjectIntent(sourceIntentHint) || + deps.isInventoryRootFrameIntent(sourceIntentHint) || + currentFrameKind === "inventory_drilldown" || + currentFrameKind === "inventory_root" || + currentFrameKind === "generic") && + (hasInventoryRootRestatementPrimary || hasInventoryRootRestatementAlternate) && + !deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage)); + const rootScopedPivot = rootContextOnlyPivot || inventoryRootTemporalPivot || inventoryRootRestatementPivot; if (rootScopedPivot) { previousIntent = null; previousAnchorType = null; @@ -330,6 +467,7 @@ function createAssistantTransitionPolicy(deps) { ? deps.resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities) : null); if (resolvedEntityFromFollowup && !rootScopedPivot) { + displayedEntityTargetIntent = resolveDisplayedEntityRetargetIntent(userMessage, resolvedEntityFromFollowup.entityType); if (resolvedEntityFromFollowup.entityType === "counterparty") { previousFilters.counterparty = resolvedEntityFromFollowup.value; previousAnchorType = "counterparty"; @@ -396,11 +534,13 @@ function createAssistantTransitionPolicy(deps) { inventoryShortFollowupAlternate || hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate || + hasInventoryRootRestatementPrimary || + hasInventoryRootRestatementAlternate || hasSelectedObjectInventorySignalPrimary || hasSelectedObjectInventorySignalAlternate)); const carryoverTargetIntent = followupSelectionMode === "carry_root_context" - ? inventoryRootFrame?.intent ?? explicitIntent ?? previousIntent ?? undefined - : explicitIntent ?? previousIntent ?? undefined; + ? inventoryRootFrame?.intent ?? displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined + : displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined; return { followupContext: { previous_intent: previousIntent ?? undefined, @@ -430,14 +570,15 @@ function createAssistantTransitionPolicy(deps) { const previousIntent = deps.toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null; const selectionMode = deps.toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? null; const rootContextOnly = selectionMode === "carry_root_context"; + const carryoverTargetIntent = deps.toNonEmptyString(carryoverMeta?.followupContext?.target_intent) ?? null; const explicitIntentRaw = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); const explicitIntent = explicitIntentRaw === "unknown" ? null : explicitIntentRaw; const rootIntent = deps.toNonEmptyString(carryoverMeta?.followupContext?.root_intent) ?? null; const targetIntent = selectionMode === "switch_to_suggested_intent" ? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null : rootContextOnly - ? rootIntent ?? explicitIntent ?? null - : explicitIntent ?? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null; + ? carryoverTargetIntent ?? rootIntent ?? explicitIntent ?? null + : carryoverTargetIntent ?? explicitIntent ?? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null; const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal); const rewrittenByPredecompose = deps.compactWhitespace(sourceMessage.toLowerCase()) !== deps.compactWhitespace(canonicalMessage.toLowerCase()); const hasExplicitIntent = Boolean(explicitIntent); diff --git a/llm_normalizer/backend/src/services/addressCounterpartyIntentSignals.ts b/llm_normalizer/backend/src/services/addressCounterpartyIntentSignals.ts index d0f98b9..15771c5 100644 --- a/llm_normalizer/backend/src/services/addressCounterpartyIntentSignals.ts +++ b/llm_normalizer/backend/src/services/addressCounterpartyIntentSignals.ts @@ -38,10 +38,138 @@ type CounterpartyIntentDeps = { hasGenericAddressLookupSignal: (text: string) => boolean; }; +function hasUnicodeOpenItemsAccountSignal(text: string): boolean { + const normalized = String(text ?? "").toLowerCase(); + if (!normalized) { + return false; + } + return ( + /(?:\u0445\u0432\u043e\u0441\u0442|\u0434\u043e\u043b\u0433|\u043d\u0435\u0437\u0430\u043a\u0440\u044b\u0442|\u0432\u0438\u0441)/iu.test(normalized) && + /(?:\u0441\u0447(?:\u0435|\u0451)\u0442(?:\u0430|\u0443|\u043e\u043c|\u043e\u0432)?\s*(?:\u2116|#)?\s*(?:60|62|76)(?:\.\d{2})?|(?:60|62|76)(?:\.\d{2})?\s*(?:\u0441\u0447(?:\u0435|\u0451)\u0442(?:\u0430|\u0443|\u043e\u043c|\u043e\u0432)?))/iu.test( + normalized + ) + ); +} + +function hasUnicodeCounterpartyShipmentItemFlowSignal(text: string): boolean { + return /(?:\u043e\u0442\u0433\u0440\u0443\u0436\u0430\u043b)/iu.test(text) && + /(?:\u0442\u043e\u0432\u0430\u0440|\u0443\u0441\u043b\u0443\u0433|\u043f\u043e\u0437\u0438\u0446\u0438|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440)/iu.test(text); +} + +function hasUnicodePassiveShipmentByCounterpartySignal(text: string): boolean { + return ( + /(?:\u043e\u0442\u0433\u0440\u0443\u0436\u0435\u043d(?:\u044b|\u043e|\u0430)?)/iu.test(text) && + /(?:\u0442\u043e\u0432\u0430\u0440|\u0443\u0441\u043b\u0443\u0433|\u043f\u043e\u0437\u0438\u0446\u0438|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440)/iu.test(text) && + /(?:\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u043e\u043c)/iu.test(text) + ); +} + +function hasUnicodeDocumentSignal(text: string): boolean { + return /(?:\u0434\u043e\u043a(?:\u0443\u043c\u0435\u043d\u0442(?:\u044b|\u043e\u0432|\u0430\u043c|\u0430\u043c\u0438|\u0430\u0445)?|\u0438)?|\u0434\u043e\u043a\u0438)(?=$|[\s,.;:!?()])/iu.test(text); +} + +function hasUnicodeLikelyCounterpartyAfterBy(text: string): boolean { + const match = text.match(/(?:^|[\s(])\u043f\u043e\s+([\p{L}\d][\p{L}\d._-]{1,})(?=$|[\s,.;:!?()])/iu); + if (!match) { + return false; + } + const token = String(match[1] ?? "").toLowerCase(); + if (!token) { + return false; + } + const stopWords = new Set([ + "\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0443", + "\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430", + "\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u043e\u043c", + "\u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438", + "\u043a\u043e\u043c\u043f\u0430\u043d\u0438\u044e", + "\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u0438", + "\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044e", + "\u0434\u043e\u0433\u043e\u0432\u043e\u0440\u0443", + "\u0434\u043e\u0433\u043e\u0432\u043e\u0440\u043e\u043c", + "\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442\u0443", + "\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442\u043e\u043c", + "\u0441\u0447\u0435\u0442\u0443", + "\u0441\u0447\u0451\u0442\u0443", + "\u0441\u0447\u0435\u0442\u043e\u043c", + "\u0441\u0447\u0451\u0442\u043e\u043c", + "\u0434\u0430\u0442\u0435", + "\u0434\u0430\u0442\u0443", + "\u043f\u0435\u0440\u0438\u043e\u0434\u0443", + "\u043f\u0435\u0440\u0438\u043e\u0434", + "\u0441\u043a\u043b\u0430\u0434\u0443", + "\u0441\u043a\u043b\u0430\u0434\u0435", + "\u0431\u0430\u043d\u043a\u0443", + "\u0431\u0430\u043d\u043a\u0435", + "\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u043c", + "\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b", + "\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0443" + ]); + return !stopWords.has(token); +} + export function resolveCounterpartyAddressIntent( text: string, deps: CounterpartyIntentDeps ): AddressIntentResolution | null { + if (hasUnicodeOpenItemsAccountSignal(text)) { + return { + intent: "open_items_by_counterparty_or_contract", + confidence: "medium", + reasons: ["open_items_signal_detected"] + }; + } + + if ( + /(?:хвост|долг|незакрыт|вис)/iu.test(text) && + /(?:счету?|сч[её]ту?)\s*60|60\s*(?:счет|сч[её]т)/iu.test(text) + ) { + return { + intent: "open_items_by_counterparty_or_contract", + confidence: "medium", + reasons: ["open_items_signal_detected"] + }; + } + + if ( + /отгружал/iu.test(text) && + /(?:товар|услуг|позици|номенклатур)/iu.test(text) + ) { + return { + intent: "list_documents_by_counterparty", + confidence: "medium", + reasons: ["counterparty_item_flow_signal_detected"] + }; + } + + if ( + /отгружен(?:ы|о|а)?/iu.test(text) && + /(?:товар|услуг|позици|номенклатур)/iu.test(text) && + /контрагентом/iu.test(text) + ) { + return { + intent: "list_documents_by_counterparty", + confidence: "medium", + reasons: ["counterparty_item_flow_signal_detected"] + }; + } + + if (hasUnicodeCounterpartyShipmentItemFlowSignal(text)) { + return { + intent: "list_documents_by_counterparty", + confidence: "medium", + reasons: ["counterparty_item_flow_signal_detected"] + }; + } + + if (hasUnicodePassiveShipmentByCounterpartySignal(text)) { + return { + intent: "list_documents_by_counterparty", + confidence: "medium", + reasons: ["counterparty_item_flow_signal_detected"] + }; + } + if (deps.hasOpenContractsListSignal(text)) { return { intent: "open_contracts_confirmed_as_of_date", @@ -200,6 +328,14 @@ export function resolveCounterpartyAddressIntent( }; } + if (hasUnicodeDocumentSignal(text) && hasUnicodeLikelyCounterpartyAfterBy(text)) { + return { + intent: "list_documents_by_counterparty", + confidence: "medium", + reasons: ["documents_by_counterparty_signal_detected"] + }; + } + if ( (deps.hasAny(text, deps.documentsByCounterpartyHints) || deps.hasCounterpartyShipmentItemFlowSignal(text)) && (deps.hasPartyAnchorMention(text) || diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index f809f93..1ab34d1 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -957,6 +957,20 @@ function extractShipmentCounterpartyValue(text: string): string | undefined { return candidate; } +function extractInstrumentalCounterpartyValue(text: string): string | undefined { + const match = String(text ?? "").match( + /(?:контрагентом|поставщиком|клиентом|заказчиком)\s+([\p{L}][\p{L}\p{N}._-]{1,})(?=[\s,.;:!?)]|$)/iu + ); + if (!match) { + return undefined; + } + const candidate = cleanupAnchorValue(String(match[1] ?? "")); + if (!candidate || !isLikelyCounterpartyToken(candidate)) { + return undefined; + } + return candidate; +} + function hasExplicitAccountCue(text: string): boolean { return /(?:сч[её]т|счет|account|acct)/iu.test(String(text ?? "")); } @@ -1744,6 +1758,19 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent warnings.push("counterparty_anchor_derived_from_shipment_phrase"); } } + if ( + !filters.counterparty && + allowGenericCounterpartyAnchor && + (intent === "list_documents_by_counterparty" || + intent === "bank_operations_by_counterparty" || + intent === "list_contracts_by_counterparty") + ) { + const instrumentalCounterparty = extractInstrumentalCounterpartyValue(text); + if (instrumentalCounterparty) { + filters.counterparty = cleanupAnchorValue(instrumentalCounterparty); + warnings.push("counterparty_anchor_derived_from_instrumental_phrase"); + } + } if ( !filters.counterparty && allowGenericCounterpartyAnchor && diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index c667114..6655f24 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -9,48 +9,48 @@ import { } from "./inventoryLifecycleCueHelpers"; const RECEIVABLES_STRONG = [ - "кто должен нам", - "кто нам должен", - "кто нам должэн", - "нам должны", - "нам должен", - "нам должэны", + "кто должен нам", + "кто нам должен", + "кто нам должэн", + "нам должны", + "нам должен", + "нам должэны", "who owes us", "receivable", "receivables", "debtor", "debtors", - "дебитор", - "дебиторск" + "дебитор", + "дебиторск" ]; const PAYABLES_STRONG = [ - "кому должны мы", - "кому должэны мы", - "кому мы должны", - "кому мы должэны", - "мы должны", - "мы должэны", + "РєРѕРјСѓ должны РјС‹", + "РєРѕРјСѓ должэны РјС‹", + "РєРѕРјСѓ РјС‹ должны", + "РєРѕРјСѓ РјС‹ должэны", + "РјС‹ должны", + "РјС‹ должэны", "who we owe", "payable", "payables", "creditor", "creditors", - "кредитор", - "кредиторск" + "кредитор", + "кредиторск" ]; const ACCOUNT_BALANCE_HINTS = [ "account balance", "balance by account", "saldo", - "баланс", - "остаток по счет", - "сальдо по счет", - "по счету", - "что на счете", - "что на счёте", - "на конец" + "баланс", + "остаток РїРѕ счет", + "сальдо РїРѕ счет", + "РїРѕ счету", + "что РЅР° счете", + "что РЅР° счёте", + "РЅР° конец" ]; const DOCUMENTS_FORMING_BALANCE_HINTS = [ @@ -61,32 +61,32 @@ const DOCUMENTS_FORMING_BALANCE_HINTS = [ "balance documents", "documents for balance", "which documents form balance", - "из чего состоит остаток", - "какие документы формируют остаток", - "раскрой остаток по документам", - "документы под остатком" + "РёР· чего состоит остаток", + "какие документы формируют остаток", + "раскрой остаток РїРѕ документам", + "документы РїРѕРґ остатком" ]; const OPEN_CONTRACTS_HINTS = [ "open contracts", "unclosed contracts", - "незакрыт", - "не закрыт", - "открыт", - "договор", - "контракт" + "незакрыт", + "РЅРµ закрыт", + "открыт", + "РґРѕРіРѕРІРѕСЂ", + "контракт" ]; const OPEN_ITEMS_HINTS = [ "open items", "unclosed items", - "хвост", - "висят", - "незакрыт", - "открыт", - "долг", - "задолж", - "позици" + "С…РІРѕСЃС‚", + "РІРёСЃСЏС‚", + "незакрыт", + "открыт", + "долг", + "задолж", + "позици" ]; const DOCUMENTS_BY_COUNTERPARTY_HINTS = [ @@ -99,17 +99,17 @@ const DOCUMENTS_BY_COUNTERPARTY_HINTS = [ "documents by partner", "show documents by counterparty", "list documents by counterparty", - "документы по", - "доступные документы", - "список документов", - "документ", - "доки", - "доки по", - "док по", + "документы РїРѕ", + "доступные документы", + "СЃРїРёСЃРѕРє документов", + "документ", + "РґРѕРєРё", + "РґРѕРєРё РїРѕ", + "РґРѕРє РїРѕ", "doki", "docy", "doci", - "по контрагент" + "РїРѕ контрагент" ]; const BANK_OPERATIONS_BY_COUNTERPARTY_HINTS = [ @@ -123,34 +123,34 @@ const BANK_OPERATIONS_BY_COUNTERPARTY_HINTS = [ "bank ops", "bank oper", "transactions by counterparty", - "транзак", - "банк", - "банков", - "по банку", - "опер", - "выписк", - "платеж", - "платёж", - "оплат", - "списан", - "списани", - "поступлен", - "поступлени", - "движени" + "транзак", + "банк", + "банков", + "РїРѕ банку", + "опер", + "выписк", + "платеж", + "платёж", + "оплат", + "списан", + "списани", + "поступлен", + "поступлени", + "движени" ]; const DOCUMENTS_BY_CONTRACT_HINTS = [ "documents by contract", "docs by contract", "show documents by contract", "list documents by contract", - "документы по договору", - "доки по договору", - "док по договору", - "документы договор", - "договор", - "документы по контракту", - "доки по контракту", - "контракт" + "документы РїРѕ РґРѕРіРѕРІРѕСЂСѓ", + "РґРѕРєРё РїРѕ РґРѕРіРѕРІРѕСЂСѓ", + "РґРѕРє РїРѕ РґРѕРіРѕРІРѕСЂСѓ", + "документы РґРѕРіРѕРІРѕСЂ", + "РґРѕРіРѕРІРѕСЂ", + "документы РїРѕ контракту", + "РґРѕРєРё РїРѕ контракту", + "контракт" ]; const BANK_OPERATIONS_BY_CONTRACT_HINTS = [ "bank operations by contract", @@ -158,27 +158,27 @@ const BANK_OPERATIONS_BY_CONTRACT_HINTS = [ "payment orders by contract", "transactions by contract", "bank ops by contract", - "банковские операции по договору", - "платежи по договору", - "выписка по договору", - "банковские операции по контракту", - "платежи по контракту", - "выписка по контракту" + "банковские операции РїРѕ РґРѕРіРѕРІРѕСЂСѓ", + "платежи РїРѕ РґРѕРіРѕРІРѕСЂСѓ", + "выписка РїРѕ РґРѕРіРѕРІРѕСЂСѓ", + "банковские операции РїРѕ контракту", + "платежи РїРѕ контракту", + "выписка РїРѕ контракту" ]; const BANK_OPERATION_CORE_HINTS = [ - "банк", - "банков", - "операц", - "опер", - "выписк", - "платеж", - "платёж", - "оплат", - "списан", - "поступлен", - "движени", - "транзак", + "банк", + "банков", + "операц", + "опер", + "выписк", + "платеж", + "платёж", + "оплат", + "списан", + "поступлен", + "движени", + "транзак", "bank", "payment", "payments", @@ -189,24 +189,24 @@ const BANK_OPERATION_CORE_HINTS = [ ]; const PERIOD_COVERAGE_PROFILE_HINTS = [ - "за какие годы", - "за какие года", - "в базе есть данные", - "покрытие периодов", - "диапазон лет", - "профиль данных", - "самый активный год", - "самый активный месяц", - "самый пассивный год", - "самый пассивный месяц", - "наименее активный год", - "наименее активный месяц", - "минимум документов по году", - "минимум операций по месяцу", - "год с минимальным количеством документов", - "месяц с минимальным количеством операций", - "активный год по количеству документов", - "активный месяц по количеству операций", + "Р·Р° какие РіРѕРґС‹", + "Р·Р° какие РіРѕРґР°", + "РІ базе есть данные", + "покрытие периодов", + "диапазон лет", + "профиль данных", + "самый активный РіРѕРґ", + "самый активный месяц", + "самый пассивный РіРѕРґ", + "самый пассивный месяц", + "наименее активный РіРѕРґ", + "наименее активный месяц", + "РјРёРЅРёРјСѓРј документов РїРѕ РіРѕРґСѓ", + "РјРёРЅРёРјСѓРј операций РїРѕ месяцу", + "РіРѕРґ СЃ минимальным количеством документов", + "месяц СЃ минимальным количеством операций", + "активный РіРѕРґ РїРѕ количеству документов", + "активный месяц РїРѕ количеству операций", "most active year", "most active month", "least active year", @@ -216,117 +216,117 @@ const PERIOD_COVERAGE_PROFILE_HINTS = [ ]; const DOCUMENT_TYPE_AND_ACCOUNT_SECTION_PROFILE_HINTS = [ - "типы документов", - "типы доков", - "документы чаще всего", - "документы реже всего", - "редкие типы документов", - "наименее используемые типы документов", - "частые типы документов", - "сводка по типам документов", - "доля типов документов", - "разделы учета", - "разделы учёта", - "наиболее заполнены", - "наименее заполнены", - "почти не используются", + "типы документов", + "типы РґРѕРєРѕРІ", + "документы чаще всего", + "документы реже всего", + "редкие типы документов", + "наименее используемые типы документов", + "частые типы документов", + "СЃРІРѕРґРєР° РїРѕ типам документов", + "доля типов документов", + "разделы учета", + "разделы учёта", + "наиболее заполнены", + "наименее заполнены", + "почти РЅРµ используются", "account section", "document types usage", "document type profile" ]; const COUNTERPARTY_POPULATION_AND_ROLES_HINTS = [ - "сколько всего контрагентов", - "сколько уникальных контрагентов", - "сколько контрагентов в базе", - "сколько заказчиков", - "сколько поставщиков", - "сколько клиентов", - "сколько покупателей", - "скока всего контрагентов", - "скока уникальных контрагентов", - "скока контрагентов в базе", - "скока заказчиков", - "скока поставщиков", - "скока клиентов", - "скока покупателей", - "скок контрагентов", - "скок контрагентов в базе", - "скок заказчиков", - "скок поставщиков", - "скок клиентов", - "скок покупателей", - "сколько смешанных контрагентов", - "типы контрагентов", - "разбей контрагентов", - "раздели контрагентов", + "сколько всего контрагентов", + "сколько уникальных контрагентов", + "сколько контрагентов РІ базе", + "сколько заказчиков", + "сколько поставщиков", + "сколько клиентов", + "сколько покупателей", + "СЃРєРѕРєР° всего контрагентов", + "СЃРєРѕРєР° уникальных контрагентов", + "СЃРєРѕРєР° контрагентов РІ базе", + "СЃРєРѕРєР° заказчиков", + "СЃРєРѕРєР° поставщиков", + "СЃРєРѕРєР° клиентов", + "СЃРєРѕРєР° покупателей", + "СЃРєРѕРє контрагентов", + "СЃРєРѕРє контрагентов РІ базе", + "СЃРєРѕРє заказчиков", + "СЃРєРѕРє поставщиков", + "СЃРєРѕРє клиентов", + "СЃРєРѕРє покупателей", + "сколько смешанных контрагентов", + "типы контрагентов", + "разбей контрагентов", + "раздели контрагентов", "counterparty population", "counterparty roles", "customer supplier split" ]; const COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS = [ - "какие заказчики работали", - "какие заказчики активны", - "какие клиенты работали", - "какие клиенты активны", - "какие контрагенты работали", - "какие поставщики работали", - "список заказчиков", - "список клиентов", - "список заказчиков за все время", - "список клиентов за все время", - "список активных заказчиков", - "список активных клиентов", - "новые заказчики", - "новые клиенты", - "новые контрагенты", - "впервые в", - "кто исчез", - "кто ушел", - "кто ушёл", - "только один раз", - "дольше всего", - "дольше всех", - "долгоживущие контрагенты", - "регулярные поставщики", - "эпизодические поставщики", - "давно не использовались поставщики", - "всех заков", - "кто был активен", - "потом отвалился", - "ровно один раз", - "и пропал", - "самые старые по сотрудничеству", - "разбей поставщиков на регуляр и разовые", - "кто новые в этом году", + "какие заказчики работали", + "какие заказчики активны", + "какие клиенты работали", + "какие клиенты активны", + "какие контрагенты работали", + "какие поставщики работали", + "СЃРїРёСЃРѕРє заказчиков", + "СЃРїРёСЃРѕРє клиентов", + "СЃРїРёСЃРѕРє заказчиков Р·Р° РІСЃРµ время", + "СЃРїРёСЃРѕРє клиентов Р·Р° РІСЃРµ время", + "СЃРїРёСЃРѕРє активных заказчиков", + "СЃРїРёСЃРѕРє активных клиентов", + "новые заказчики", + "новые клиенты", + "новые контрагенты", + "впервые РІ", + "кто исчез", + "кто ушел", + "кто ушёл", + "только РѕРґРёРЅ раз", + "дольше всего", + "дольше всех", + "долгоживущие контрагенты", + "регулярные поставщики", + "эпизодические поставщики", + "давно РЅРµ использовались поставщики", + "всех заков", + "кто был активен", + "потом отвалился", + "СЂРѕРІРЅРѕ РѕРґРёРЅ раз", + "Рё пропал", + "самые старые РїРѕ сотрудничеству", + "разбей поставщиков РЅР° регуляр Рё разовые", + "кто новые РІ этом РіРѕРґСѓ", "active customers", - "сколько лет активности в базе", - "сколько лет активности в 1с", - "сколько лет в базе 1с", - "какой первый платеж", - "какое первое поступление", - "когда была первая активность", - "когда была последняя активность", - "первая активность в базе", - "последняя активность в базе", + "сколько лет активности РІ базе", + "сколько лет активности РІ 1СЃ", + "сколько лет РІ базе 1СЃ", + "какой первый платеж", + "какое первое поступление", + "РєРѕРіРґР° была первая активность", + "РєРѕРіРґР° была последняя активность", + "первая активность РІ базе", + "последняя активность РІ базе", "customer activity list", "counterparty lifecycle" ]; const CONTRACT_USAGE_OVERVIEW_HINTS = [ - "сколько всего договоров", - "сколько договоров заведено", - "сколько договоров в базе", - "сколько договоров использовались", - "сколько договоров использовалось", - "договоры total vs used", - "обзор договорной базы", - "договорная база total used", - "неиспользуемые договоры", - "давно не использовались договоры", - "мертвые договоры", - "мёртвые договоры", + "сколько всего РґРѕРіРѕРІРѕСЂРѕРІ", + "сколько РґРѕРіРѕРІРѕСЂРѕРІ заведено", + "сколько РґРѕРіРѕРІРѕСЂРѕРІ РІ базе", + "сколько РґРѕРіРѕРІРѕСЂРѕРІ использовались", + "сколько РґРѕРіРѕРІРѕСЂРѕРІ использовалось", + "РґРѕРіРѕРІРѕСЂС‹ total vs used", + "РѕР±Р·РѕСЂ РґРѕРіРѕРІРѕСЂРЅРѕР№ базы", + "договорная база total used", + "неиспользуемые РґРѕРіРѕРІРѕСЂС‹", + "давно РЅРµ использовались РґРѕРіРѕРІРѕСЂС‹", + "мертвые РґРѕРіРѕРІРѕСЂС‹", + "мёртвые РґРѕРіРѕРІРѕСЂС‹", "stale contracts", "unused contracts", "contracts total used", @@ -334,30 +334,30 @@ const CONTRACT_USAGE_OVERVIEW_HINTS = [ ]; const CUSTOMER_REVENUE_AND_PAYMENTS_HINTS = [ - "самые доходные клиенты", - "самые доходные заказчики", - "самые ликвидные клиенты", - "самые ликвидные заказчики", - "самых ликвидних заказчиков", - "топ клиентов по сумме поступлений", - "топ заказчиков по сумме поступлений", - "кто больше всего принес денег", - "кто больше всего принёс денег", - "кто принес больше всего денег", - "кто принёс больше всего денег", - "кто нам больше денег принес", - "кто нам больше денег принёс", - "кто нам принес больше денег", - "кто нам принёс больше денег", - "кто нам больше всего занес", - "кто нам больше всего занёс", - "кто нам принес больше всего", - "кто нам принёс больше всего", - "кто платит чаще всего", - "средний чек клиентов", - "средний чек заказчиков", - "крупные сделки по поступлениям", - "маленькие сделки по поступлениям", + "самые доходные клиенты", + "самые доходные заказчики", + "самые ликвидные клиенты", + "самые ликвидные заказчики", + "самых ликвидних заказчиков", + "топ клиентов РїРѕ СЃСѓРјРјРµ поступлений", + "топ заказчиков РїРѕ СЃСѓРјРјРµ поступлений", + "кто больше всего принес денег", + "кто больше всего принёс денег", + "кто принес больше всего денег", + "кто принёс больше всего денег", + "кто нам больше денег принес", + "кто нам больше денег принёс", + "кто нам принес больше денег", + "кто нам принёс больше денег", + "кто нам больше всего занес", + "кто нам больше всего занёс", + "кто нам принес больше всего", + "кто нам принёс больше всего", + "кто платит чаще всего", + "средний чек клиентов", + "средний чек заказчиков", + "крупные сделки РїРѕ поступлениям", + "маленькие сделки РїРѕ поступлениям", "smallest deals by inflow", "largest deals by inflow", "top customers by inflow", @@ -365,52 +365,52 @@ const CUSTOMER_REVENUE_AND_PAYMENTS_HINTS = [ ]; const SUPPLIER_PAYOUTS_PROFILE_HINTS = [ - "топ поставщиков по сумме выплат", - "кому мы больше всего заплатили", - "кому ушло больше всего денег", - "кому мы больше всего сгрузили денег", - "поставщики по выплатам", - "поставщики по исходящим платежам", - "поставщики с максимальным числом выплат", - "крупные разовые выплаты поставщикам", + "топ поставщиков РїРѕ СЃСѓРјРјРµ выплат", + "РєРѕРјСѓ РјС‹ больше всего заплатили", + "РєРѕРјСѓ ушло больше всего денег", + "РєРѕРјСѓ РјС‹ больше всего сгрузили денег", + "поставщики РїРѕ выплатам", + "поставщики РїРѕ исходящим платежам", + "поставщики СЃ максимальным числом выплат", + "крупные разовые выплаты поставщикам", "top suppliers by payouts", "top suppliers by outgoing payments" ]; const CONTRACT_USAGE_AND_VALUE_HINTS = [ - "договоры по обороту", - "договоры по сумме оборота", - "топ договоров по обороту", - "контракты по обороту", - "контракты по сумме оборота", - "топ контрактов по обороту", - "договоры с минимальным бюджетом", - "договоры с самым маленьким бюджетом", - "контракты с минимальным бюджетом", - "контракты с самым маленьким бюджетом", - "активные договоры по бюджету", - "активные контракты по бюджету", - "контрагенты с несколькими договорами", - "несколько договоров у контрагента", - "мультидоговорные контрагенты", - "какие договоры активны", - "какие контракты активны", - "рабочие договоры", - "рабочие контракты", + "РґРѕРіРѕРІРѕСЂС‹ РїРѕ обороту", + "РґРѕРіРѕРІРѕСЂС‹ РїРѕ СЃСѓРјРјРµ оборота", + "топ РґРѕРіРѕРІРѕСЂРѕРІ РїРѕ обороту", + "контракты РїРѕ обороту", + "контракты РїРѕ СЃСѓРјРјРµ оборота", + "топ контрактов РїРѕ обороту", + "РґРѕРіРѕРІРѕСЂС‹ СЃ минимальным бюджетом", + "РґРѕРіРѕРІРѕСЂС‹ СЃ самым маленьким бюджетом", + "контракты СЃ минимальным бюджетом", + "контракты СЃ самым маленьким бюджетом", + "активные РґРѕРіРѕРІРѕСЂС‹ РїРѕ бюджету", + "активные контракты РїРѕ бюджету", + "контрагенты СЃ несколькими договорами", + "несколько РґРѕРіРѕРІРѕСЂРѕРІ Сѓ контрагента", + "мультидоговорные контрагенты", + "какие РґРѕРіРѕРІРѕСЂС‹ активны", + "какие контракты активны", + "рабочие РґРѕРіРѕРІРѕСЂС‹", + "рабочие контракты", "contracts by turnover", "contracts by budget" ]; const CONTRACT_LIST_BY_COUNTERPARTY_HINTS = [ - "договоры по", - "договора по", - "список договоров по", - "покажи договоры по", - "выведи договоры по", - "контракты по", - "список контрактов по", - "покажи контракты по", - "выведи контракты по", + "РґРѕРіРѕРІРѕСЂС‹ РїРѕ", + "РґРѕРіРѕРІРѕСЂР° РїРѕ", + "СЃРїРёСЃРѕРє РґРѕРіРѕРІРѕСЂРѕРІ РїРѕ", + "покажи РґРѕРіРѕРІРѕСЂС‹ РїРѕ", + "выведи РґРѕРіРѕРІРѕСЂС‹ РїРѕ", + "контракты РїРѕ", + "СЃРїРёСЃРѕРє контрактов РїРѕ", + "покажи контракты РїРѕ", + "выведи контракты РїРѕ", "contracts by counterparty", "list contracts by counterparty", "show contracts by counterparty" @@ -441,28 +441,28 @@ function hasFlexiblePayablesDebtSignal(text: string): boolean { return false; } return ( - /(?:кому(?:\s+\S+){0,4}\s+мы(?:\s+\S+){0,4}\s+долж)/iu.test(normalized) || - /(?:мы(?:\s+\S+){0,4}\s+кому(?:\s+\S+){0,4}\s+долж)/iu.test(normalized) + /(?:РєРѕРјСѓ(?:\s+\S+){0,4}\s+РјС‹(?:\s+\S+){0,4}\s+долж)/iu.test(normalized) || + /(?:РјС‹(?:\s+\S+){0,4}\s+РєРѕРјСѓ(?:\s+\S+){0,4}\s+долж)/iu.test(normalized) ); } function tokenizeText(text: string): string[] { return String(text ?? "") .toLowerCase() - .split(/[^a-zа-яё0-9]+/iu) + .split(/[^a-zР°-СЏС‘0-9]+/iu) .map((token) => token.trim()) .filter((token) => token.length > 0); } function trimRussianEnding(token: string): string { return token.replace( - /(?:иями|ями|ами|ого|ему|ому|ыми|ими|ией|ей|ий|ый|ой|ях|ах|ов|ев|ам|ям|ом|ем|ы|и|а|я|у|ю|е|о)$/u, + /(?:РёСЏРјРё|СЏРјРё|ами|РѕРіРѕ|ему|РѕРјСѓ|ыми|РёРјРё|ией|ей|РёР№|ый|РѕР№|СЏС…|ах|РѕРІ|ев|ам|СЏРј|РѕРј|ем|С‹|Рё|Р°|СЏ|Сѓ|СЋ|Рµ|Рѕ)$/u, "" ); } function normalizeLexemeToken(rawToken: string): string { - const token = String(rawToken ?? "").toLowerCase().replace(/[^a-zа-яё0-9]+/gu, ""); + const token = String(rawToken ?? "").toLowerCase().replace(/[^a-zР°-СЏС‘0-9]+/gu, ""); if (!token) { return ""; } @@ -535,7 +535,7 @@ function hasFuzzyLexeme(text: string, lexemeRoots: string[]): boolean { } function hasCompactAccountCodeToken(text: string): boolean { - // Match compact account tokens while reducing false positives on short-year literals like "22 год". + // Match compact account tokens while reducing false positives on short-year literals like "22 РіРѕРґ". const source = String(text ?? ""); if (!source) { return false; @@ -545,11 +545,11 @@ function hasCompactAccountCodeToken(text: string): boolean { return true; } // Plain two-digit code is accepted only in explicit account context. - if (/(?:сч[её]т|account)\D{0,12}\d{2}(?![\d-])/iu.test(source)) { + if (/(?:СЃС‡[её]С‚|account)\D{0,12}\d{2}(?![\d-])/iu.test(source)) { return true; } - if (/(?:^|\s)по\s+\d{2}(?=$|[\s,.;:!?])/iu.test(source)) { - if (!/(?:^|\s)(?:за|в)\s+\d{2}\s*(?:г(?:од|ода)?|year)\b/iu.test(source)) { + if (/(?:^|\s)РїРѕ\s+\d{2}(?=$|[\s,.;:!?])/iu.test(source)) { + if (!/(?:^|\s)(?:Р·Р°|РІ)\s+\d{2}\s*(?:Рі(?:РѕРґ|РѕРґР°)?|year)\b/iu.test(source)) { return true; } } @@ -561,11 +561,11 @@ function hasDocumentsFormingBalanceSignal(text: string): boolean { return true; } const hasLooseAccountCodeToken = hasCompactAccountCodeToken(text); - const hasDocLexeme = /(?:документ|док(?:и|ам|ах|ов|а)?)/u.test(text); - const hasFormingLexeme = text.includes("формир"); - const hasBalanceLexeme = text.includes("остат"); + const hasDocLexeme = /(?:документ|РґРѕРє(?:Рё|ам|ах|РѕРІ|Р°)?)/u.test(text); + const hasFormingLexeme = text.includes("формир"); + const hasBalanceLexeme = text.includes("остат"); const hasAccountLexeme = - text.includes("счет") || text.includes("счёт") || hasAccountNumberAnchor(text) || hasLooseAccountCodeToken; + text.includes("счет") || text.includes("счёт") || hasAccountNumberAnchor(text) || hasLooseAccountCodeToken; if (hasDocLexeme && hasFormingLexeme && hasBalanceLexeme && hasAccountLexeme) { return true; } @@ -573,21 +573,21 @@ function hasDocumentsFormingBalanceSignal(text: string): boolean { hasDocLexeme && hasBalanceLexeme && hasAccountLexeme && - (text.includes("раскрой") || text.includes("раскид") || text.includes("под остатк")) + (text.includes("раскрой") || text.includes("раскид") || text.includes("РїРѕРґ остатк")) ) { return true; } - if (hasBalanceLexeme && hasAccountLexeme && text.includes("из чего состоит")) { + if (hasBalanceLexeme && hasAccountLexeme && text.includes("РёР· чего состоит")) { return true; } - return hasBalanceLexeme && hasAccountLexeme && /из\s+чего\s+остат/u.test(text); + return hasBalanceLexeme && hasAccountLexeme && /РёР·\s+чего\s+остат/u.test(text); } function hasDocumentsFormingBalanceAccountAnchor(text: string): boolean { - if (hasAccountNumberAnchor(text) || text.includes("счет") || text.includes("счёт")) { + if (hasAccountNumberAnchor(text) || text.includes("счет") || text.includes("счёт")) { return true; } - // Allow compact account mentions like "60.01" in slang prompts without explicit "счет". + // Allow compact account mentions like "60.01" in slang prompts without explicit "счет". return hasCompactAccountCodeToken(text); } @@ -596,64 +596,64 @@ function hasAccountBalanceSignal(text: string): boolean { return true; } const hasAccountLexeme = - hasAccountNumberAnchor(text) || hasCompactAccountCodeToken(text) || /(?:^|\s)по\s+\d{2}(?:[.,]\d{1,2})?(?=$|[\s,.;:!?])/u.test(text); + hasAccountNumberAnchor(text) || hasCompactAccountCodeToken(text) || /(?:^|\s)РїРѕ\s+\d{2}(?:[.,]\d{1,2})?(?=$|[\s,.;:!?])/u.test(text); const hasBalanceLexeme = - text.includes("баланс") || - text.includes("остат") || - text.includes("сальд") || + text.includes("баланс") || + text.includes("остат") || + text.includes("сальд") || text.includes("saldo") || text.includes("balance") || - text.includes("скока") || - text.includes("сколько") || - /на\s+конец/u.test(text); + text.includes("СЃРєРѕРєР°") || + text.includes("сколько") || + /РЅР°\s+конец/u.test(text); if (hasAccountLexeme && hasBalanceLexeme) { return true; } const hasAsOfStyleDate = /\b(19|20)\d{2}[./-](0?[1-9]|1[0-2])(?:[./-](0?[1-9]|[12]\d|3[01]))\b/u.test(text) || - /(?:на\s+ту\s+же\s+дат[ауеы]|same\s+date|the\s+same\s+date)/iu.test(text); - const hasFollowupBalanceVerb = /(?:вернись|вернуться|вернуть|back|return)/iu.test(text); + /(?:РЅР°\s+ту\s+Р¶Рµ\s+дат[ауеы]|same\s+date|the\s+same\s+date)/iu.test(text); + const hasFollowupBalanceVerb = /(?:вернись|вернуться|вернуть|back|return)/iu.test(text); return hasAccountLexeme && hasAsOfStyleDate && hasFollowupBalanceVerb; } function hasForecastTaxSignal(text: string): boolean { const hasForecastLexeme = - /(?:прогноз|forecast|план(?:\s+платежа|\s+оплаты)?|прикин(?:уть|ем|у|ь|ул|ули|усь|усь))/iu.test(text); - const hasTaxLexeme = /(?:ндс|vat|налог)/iu.test(text); + /(?:РїСЂРѕРіРЅРѕР·|forecast|план(?:\s+платежа|\s+оплаты)?|РїСЂРёРєРёРЅ(?:уть|ем|Сѓ|СЊ|СѓР»|ули|СѓСЃСЊ|СѓСЃСЊ))/iu.test(text); + const hasTaxLexeme = /(?:РЅРґСЃ|vat|налог)/iu.test(text); return hasForecastLexeme && hasTaxLexeme; } function hasVatLiabilityConfirmedTaxPeriodSignal(text: string): boolean { - const hasVatLexeme = /(?:ндс|vat)/iu.test(text); + const hasVatLexeme = /(?:РЅРґСЃ|vat)/iu.test(text); if (!hasVatLexeme) { return false; } const hasPaymentCue = - /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы|сгруз(?:ить|им|ишь|ите|ил|ила|или|ка))/iu.test( + /(?:Рє\s+уплате|надо|РЅСѓР¶РЅРѕ|заплатить|уплатить|плат[её]Р¶|платежку|РІ\s+налогов|РІ\s+бюджет|должн[аы]?\s+заплатить|РјС‹\s+должн[аы]?|должн[аы]?\s+РјС‹|СЃРіСЂСѓР·(?:ить|РёРј|ишь|ите|РёР»|ила|или|РєР°))/iu.test( text ); if (!hasPaymentCue) { return false; } - const hasAsOfCue = /(?:на\s+дат|по\s+состоянию|на\s+конец|as\s+of)/iu.test(text); + const hasAsOfCue = /(?:РЅР°\s+дат|РїРѕ\s+состоянию|РЅР°\s+конец|as\s+of)/iu.test(text); if (hasAsOfCue) { return false; } - const hasTaxAuthorityCue = /(?:в\s+налогов|в\s+бюджет|декларац|налогов(?:ый|ую)\s+период)/iu.test(text); - const hasQuarterCue = /(?:\b[1-4]\s*(?:квартал|кв\.?)\b|квартал|кв\.?)/iu.test(text); + const hasTaxAuthorityCue = /(?:РІ\s+налогов|РІ\s+бюджет|декларац|налогов(?:ый|СѓСЋ)\s+период)/iu.test(text); + const hasQuarterCue = /(?:\b[1-4]\s*(?:квартал|РєРІ\.?)\b|квартал|РєРІ\.?)/iu.test(text); const hasZaPeriodCue = - /(?:за\s+(?:\d{4}|январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр|квартал|кв\.?|месяц|год|период))/iu.test( + /(?:Р·Р°\s+(?:\d{4}|январ|феврал|март|апрел|май|РёСЋРЅ|РёСЋР»|август|сентябр|октябр|РЅРѕСЏР±СЂ|декабр|квартал|РєРІ\.?|месяц|РіРѕРґ|период))/iu.test( text ); const hasExplicitDayDate = /\b(?:\d{1,2}[./-]\d{1,2}[./-](?:\d{2}|\d{4})|(?:19|20)\d{2}[./-]\d{1,2}[./-]\d{1,2})\b/u.test(text); const hasMonthYearNaCue = - /(?:на\s+(?:январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)\S*\s+(?:19|20)\d{2})/iu.test( + /(?:РЅР°\s+(?:январ|феврал|март|апрел|май|РёСЋРЅ|РёСЋР»|август|сентябр|октябр|РЅРѕСЏР±СЂ|декабр)\S*\s+(?:19|20)\d{2})/iu.test( text ); - const hasHowMuchCue = /(?:сколько|скока|скок)/iu.test(text); + const hasHowMuchCue = /(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє)/iu.test(text); - // "На март 2020" и конкретная дата без налогового контекста чаще означают as-of срез. + // "РќР° март 2020" Рё конкретная дата без налогового контекста чаще означают as-of срез. if (!hasTaxAuthorityCue && !hasZaPeriodCue && !hasQuarterCue && (hasMonthYearNaCue || hasExplicitDayDate)) { return false; } @@ -662,59 +662,59 @@ function hasVatLiabilityConfirmedTaxPeriodSignal(text: string): boolean { } function hasVatPayableConfirmedSignal(text: string): boolean { - const hasVatLexeme = /(?:ндс|vat)/iu.test(text); + const hasVatLexeme = /(?:РЅРґСЃ|vat)/iu.test(text); if (!hasVatLexeme) { return false; } const hasPaymentCue = - /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы|сгруз(?:ить|им|ишь|ите|ил|ила|или|ка))/iu.test( + /(?:Рє\s+уплате|надо|РЅСѓР¶РЅРѕ|заплатить|уплатить|плат[её]Р¶|платежку|РІ\s+налогов|РІ\s+бюджет|должн[аы]?\s+заплатить|РјС‹\s+должн[аы]?|должн[аы]?\s+РјС‹|СЃРіСЂСѓР·(?:ить|РёРј|ишь|ите|РёР»|ила|или|РєР°))/iu.test( text ); if (!hasPaymentCue) { return false; } const hasDateOrPeriodCue = - /(?:на\s+дат|по\s+состоянию|на\s+конец|за\s+(?:\d{4}|январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)|на\s+(?:январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)\S*(?:\s+(?:19|20)\d{2})?|квартал|месяц|год|период|\b\d{4}[./-]\d{2}[./-]\d{2}\b)/iu.test( + /(?:РЅР°\s+дат|РїРѕ\s+состоянию|РЅР°\s+конец|Р·Р°\s+(?:\d{4}|январ|феврал|март|апрел|май|РёСЋРЅ|РёСЋР»|август|сентябр|октябр|РЅРѕСЏР±СЂ|декабр)|РЅР°\s+(?:январ|феврал|март|апрел|май|РёСЋРЅ|РёСЋР»|август|сентябр|октябр|РЅРѕСЏР±СЂ|декабр)\S*(?:\s+(?:19|20)\d{2})?|квартал|месяц|РіРѕРґ|период|\b\d{4}[./-]\d{2}[./-]\d{2}\b)/iu.test( text ); - return hasDateOrPeriodCue || /(?:сколько|скока|скок)/iu.test(text); + return hasDateOrPeriodCue || /(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє)/iu.test(text); } function hasPeriodCoverageProfileSignal(text: string): boolean { if (hasAny(text, PERIOD_COVERAGE_PROFILE_HINTS)) { return true; } - if (/(?:за\s+какие\s+год[а-яё]*).*(?:баз[аы].*жив|период|данн)/iu.test(text)) { + if (/(?:Р·Р°\s+какие\s+РіРѕРґ[Р°-СЏС‘]*).*(?:баз[аы].*Р¶РёРІ|период|данн)/iu.test(text)) { return true; } - if (/(?:какой\s+год[а-яё]*).*(?:по\s+док|докам|документам)/iu.test(text)) { + if (/(?:какой\s+РіРѕРґ[Р°-СЏС‘]*).*(?:РїРѕ\s+РґРѕРє|докам|документам)/iu.test(text)) { return true; } - if (/(?:какой\s+месяц[а-яё]*).*(?:пик|по\s+операц)/iu.test(text)) { + if (/(?:какой\s+месяц[Р°-СЏС‘]*).*(?:РїРёРє|РїРѕ\s+операц)/iu.test(text)) { return true; } - if (/(?:месяц[\s-]*пик).*(?:операц|ops?|operation)/iu.test(text)) { + if (/(?:месяц[\s-]*РїРёРє).*(?:операц|ops?|operation)/iu.test(text)) { return true; } if (/(?:top\s*year|top\s*month|years?\/top\s*year|years?\s*top\s*year)/iu.test(text)) { return true; } - if (/(?:за\s+какие\s+год[а-яё]*\s+в\s+баз[еы]\s+есть\s+данн)/iu.test(text)) { + if (/(?:Р·Р°\s+какие\s+РіРѕРґ[Р°-СЏС‘]*\s+РІ\s+баз[еы]\s+есть\s+данн)/iu.test(text)) { return true; } if ( - /(?:какой\s+год[а-яё]*\s+сам(?:ый|ая|ое)\s+(?:актив|пассив)|какой\s+год[а-яё]*\s+наименее\s+актив|год\s+с\s+минимальн)/iu.test(text) && - /(?:документ|doc)/iu.test(text) + /(?:какой\s+РіРѕРґ[Р°-СЏС‘]*\s+сам(?:ый|ая|РѕРµ)\s+(?:актив|пассив)|какой\s+РіРѕРґ[Р°-СЏС‘]*\s+наименее\s+актив|РіРѕРґ\s+СЃ\s+минимальн)/iu.test(text) && + /(?:документ|doc)/iu.test(text) ) { return true; } if ( - /(?:какой\s+месяц[а-яё]*\s+сам(?:ый|ая|ое)\s+(?:актив|пассив)|какой\s+месяц[а-яё]*\s+наименее\s+актив|месяц\s+с\s+минимальн)/iu.test(text) && - /(?:операц|operation|ops?)/iu.test(text) + /(?:какой\s+месяц[Р°-СЏС‘]*\s+сам(?:ый|ая|РѕРµ)\s+(?:актив|пассив)|какой\s+месяц[Р°-СЏС‘]*\s+наименее\s+актив|месяц\s+СЃ\s+минимальн)/iu.test(text) && + /(?:операц|operation|ops?)/iu.test(text) ) { return true; } - if (/(?:профил[ья]\s+данн|покрыт(?:ие|ия)\s+период|диапазон\s+лет)/iu.test(text)) { + if (/(?:профил[СЊСЏ]\s+данн|покрыт(?:РёРµ|РёСЏ)\s+период|диапазон\s+лет)/iu.test(text)) { return true; } return false; @@ -724,25 +724,25 @@ function hasDocumentTypeAndAccountSectionProfileSignal(text: string): boolean { if (hasAny(text, DOCUMENT_TYPE_AND_ACCOUNT_SECTION_PROFILE_HINTS)) { return true; } - if (/(?:каких?\s+док(?:ов|и)?).*(?:больше\s+всего|чаще\s+всего|крут)/iu.test(text)) { + if (/(?:каких?\s+РґРѕРє(?:РѕРІ|Рё)?).*(?:больше\s+всего|чаще\s+всего|РєСЂСѓС‚)/iu.test(text)) { return true; } - if (/(?:сводк[ауи].*тип[а-яё]*\s+док(?:умент|ов|и)?).*(?:дол[ья]|объем|объ[её]м)/iu.test(text)) { + if (/(?:СЃРІРѕРґРє[ауи].*тип[Р°-СЏС‘]*\s+РґРѕРє(?:умент|РѕРІ|Рё)?).*(?:РґРѕР»[СЊСЏ]|объем|РѕР±СЉ[её]Рј)/iu.test(text)) { return true; } - if (/(?:какие\s+тип[аы]\s+док(?:умент|ов|и)?\s+(?:использ|чаще|больш))/iu.test(text)) { + if (/(?:какие\s+тип[аы]\s+РґРѕРє(?:умент|РѕРІ|Рё)?\s+(?:использ|чаще|больш))/iu.test(text)) { return true; } - if (/(?:какие\s+тип[аы]\s+док(?:умент|ов|и)?\s+(?:реже|редк|наименее|миним))/iu.test(text)) { + if (/(?:какие\s+тип[аы]\s+РґРѕРє(?:умент|РѕРІ|Рё)?\s+(?:реже|редк|наименее|РјРёРЅРёРј))/iu.test(text)) { return true; } - if (/(?:типы?\s+док(?:умент|ов|и)?\s+и\s+их\s+дол[ья])/iu.test(text)) { + if (/(?:типы?\s+РґРѕРє(?:умент|РѕРІ|Рё)?\s+Рё\s+РёС…\s+РґРѕР»[СЊСЏ])/iu.test(text)) { return true; } - if (/(?:какие\s+раздел[ыа]\s+уч[её]та\s+(?:наибол|наимен|заполн|почти\s+не))/iu.test(text)) { + if (/(?:какие\s+раздел[ыа]\s+СѓС‡[её]та\s+(?:наибол|наимен|заполн|почти\s+РЅРµ))/iu.test(text)) { return true; } - if (/(?:раздел[ыа]\s+уч[её]та).*(?:жирн|мертв|пуст|использ)/iu.test(text)) { + if (/(?:раздел[ыа]\s+СѓС‡[её]та).*(?:Р¶РёСЂРЅ|мертв|РїСѓСЃС‚|использ)/iu.test(text)) { return true; } return false; @@ -756,39 +756,39 @@ function hasCounterpartyPopulationAndRolesSignal(text: string): boolean { return true; } if ( - /(?:(?:сколько|скока|скок)\s+(?:всего\s+)?уникальн(?:ых|ые|ого)?\s+контрагент|(?:сколько|скока|скок)\s+(?:всего\s+)?контрагент(?:ов|а)?(?:\s+в\s+баз[еы])?)/iu.test( + /(?:(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє)\s+(?:всего\s+)?уникальн(?:ых|ые|РѕРіРѕ)?\s+контрагент|(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє)\s+(?:всего\s+)?контрагент(?:РѕРІ|Р°)?(?:\s+РІ\s+баз[еы])?)/iu.test( text ) ) { return true; } if ( - /(?:(?:сколько|скока|скок)\s+(?:у\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+(?:Сѓ\s+нас\s+)?клиент(?:РѕРІ|Р°)?|(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє)\s+(?:Сѓ\s+нас\s+)?покупател(?:ей|СЏ)|(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє)\s+(?:Сѓ\s+нас\s+)?смешан(?:ных|ые)\s+контрагент(?:РѕРІ|Р°)?|заказчик(?:Рё|РѕРІ)\s*,?\s*поставщик(?:Рё|РѕРІ))/iu.test( text ) ) { return true; } - if (/(?:разбей|раздели|сформируй\s+сводк).*(?:контрагент|заказчик|поставщик|клиент|покупател)/iu.test(text)) { + if (/(?:разбей|раздели|сформируй\s+СЃРІРѕРґРє).*(?:контрагент|заказчик|поставщик|клиент|покупател)/iu.test(text)) { return true; } return false; } function hasLifecycleSegmentationSignal(text: string): boolean { - return /(?:вперв|нов(?:ые|ых|ые\s+контрагент|ые\s+клиент|ые\s+заказчик)|исчез|ушед|ушл|пропал|отвал|только\s+один\s+раз|ровно\s+один\s+раз|однораз|дольше\s+всех|дольше\s+всего|долгожив|самые\s+старые|старые\s+по\s+сотрудничеству|регуляр|эпизодич|разов(?:ые|ой|ые\s+поставщик)|давно\s+не\s+использ|неиспольз|потом\s+перестал)/iu.test( + return /(?:вперв|РЅРѕРІ(?:ые|ых|ые\s+контрагент|ые\s+клиент|ые\s+заказчик)|исчез|ушед|ушл|пропал|отвал|только\s+РѕРґРёРЅ\s+раз|СЂРѕРІРЅРѕ\s+РѕРґРёРЅ\s+раз|однораз|дольше\s+всех|дольше\s+всего|долгожив|самые\s+старые|старые\s+РїРѕ\s+сотрудничеству|регуляр|СЌРїРёР·РѕРґРёС‡|разов(?:ые|РѕР№|ые\s+поставщик)|давно\s+РЅРµ\s+использ|неиспольз|потом\s+перестал)/iu.test( text ); } function hasCounterpartyDebtLongevitySignal(text: string): boolean { const hasCounterpartyLexeme = - /(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?|контрагент(?:ов|а|ы)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|buyer(?:s)?)/iu.test( + /(?:заказчик(?:РѕРІ|Р°|Рё)?|клиент(?:РѕРІ|Р°|С‹)?|покупател(?:ей|СЏ|Рё)?|контрагент(?:РѕРІ|Р°|С‹)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|buyer(?:s)?)/iu.test( text ); - const hasDebtLexeme = /(?:долг(?:и|ов|а|у)?|задолж(?:енность|енности|енностям|ал|али)?|просроч|хвост)/iu.test(text); + const hasDebtLexeme = /(?:долг(?:Рё|РѕРІ|Р°|Сѓ)?|задолж(?:енность|енности|енностям|ал|али)?|РїСЂРѕСЃСЂРѕС‡|С…РІРѕСЃС‚)/iu.test(text); const hasLongevityCue = - /(?:долгожив|долгожител|несколько\s+месяц|по\s+годам|дольше|лет|год(?:ам|а|у|ы)?|на\s+этот\s+момент|длительн)/iu.test( + /(?:долгожив|долгожител|несколько\s+месяц|РїРѕ\s+годам|дольше|лет|РіРѕРґ(?:ам|Р°|Сѓ|С‹)?|РЅР°\s+этот\s+момент|длительн)/iu.test( text ); return hasCounterpartyLexeme && hasDebtLexeme && hasLongevityCue; @@ -799,18 +799,18 @@ function hasCounterpartyActivityLifecycleSignal(text: string): boolean { return false; } const hasActivityAgeCue = - /(?:сколько\s+лет\s+активности|сколько\s+лет\s+в\s+базе|возраст\s+активности|перв(?:ая|ый|ое)\s+(?:активность|платеж|поступление|документ)|последн(?:яя|ий|ее)\s+активность|с\s+какого\s+года\s+актив)/iu.test( + /(?:сколько\s+лет\s+активности|сколько\s+лет\s+РІ\s+базе|возраст\s+активности|перв(?:ая|ый|РѕРµ)\s+(?:активность|платеж|поступление|документ)|последн(?:СЏСЏ|РёР№|ее)\s+активность|СЃ\s+какого\s+РіРѕРґР°\s+актив)/iu.test( text ); const hasActivityAgeAnchor = - /(?:компан|контрагент|организац|ооо|ао|зао|ип|по\s+[a-zа-я0-9"«»().,_-]{3,}|в\s+базе\s+1с|в\s+1с\s+базе)/iu.test( + /(?:компан|контрагент|организац|РѕРѕРѕ|ао|зао|РёРї|РїРѕ\s+[a-zР°-СЏ0-9"«»().,_-]{3,}|РІ\s+базе\s+1СЃ|РІ\s+1СЃ\s+базе)/iu.test( text ); if (hasActivityAgeCue && hasActivityAgeAnchor) { return true; } const hasPaymentRiskLexeme = - /(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|задерж(?:ива|к)|просроч|задолж|\bдолг(?:и|ов|а|у)?\b)/iu.test( + /(?:РЅРµ\s+плат(?:РёС‚|СЏС‚|РёР»|или)|без\s+оплат|оплат(?:С‹|Р°)?\s+нет|нет\s+оплат|задерж(?:РёРІР°|Рє)|РїСЂРѕСЃСЂРѕС‡|задолж|\bдолг(?:Рё|РѕРІ|Р°|Сѓ)?\b)/iu.test( text ); if (hasPaymentRiskLexeme) { @@ -822,24 +822,24 @@ function hasCounterpartyActivityLifecycleSignal(text: string): boolean { if (hasAny(text, COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS)) { return true; } - if (/(?:сколько|скока|скок)\s+/iu.test(text) && !hasLifecycleSegmentationSignal(text)) { + if (/(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє)\s+/iu.test(text) && !hasLifecycleSegmentationSignal(text)) { return false; } - const hasCounterpartyLexeme = /(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?|контрагент(?:ов|а|ы)?|поставщик(?:ов|а|и)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|supplier(?:s)?|vendor(?:s)?)/iu.test( + const hasCounterpartyLexeme = /(?:заказчик(?:РѕРІ|Р°|Рё)?|клиент(?:РѕРІ|Р°|С‹)?|покупател(?:ей|СЏ|Рё)?|контрагент(?:РѕРІ|Р°|С‹)?|поставщик(?:РѕРІ|Р°|Рё)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|supplier(?:s)?|vendor(?:s)?)/iu.test( text ); const hasActivityLexeme = - /(?:работал(?:и)?|работа(?:ет|ют)|активн(?:ые|ых|а|о)?|сотрудничал(?:и)?|были\s+в\s+работе|active|использ(?:овал(?:и|ось)?|уются|ован(?:ы|о)?))/iu.test( + /(?:работал(?:Рё)?|работа(?:ет|СЋС‚)|активн(?:ые|ых|Р°|Рѕ)?|сотрудничал(?:Рё)?|были\s+РІ\s+работе|active|использ(?:овал(?:Рё|РѕСЃСЊ)?|уются|ован(?:С‹|Рѕ)?))/iu.test( text ); const hasTimeWindowLexeme = - /(?:за\s+вс[её]\s+время|all\s+time|\b(?:19|20)\d{2}\b|(?:^|[^\d])\d{2}\s*(?:г(?:од|ода)?|г)(?:[^\p{L}\p{N}]|$)|в\s+конкретн(?:ом|ый)\s+год|за\s+год|в\s+году)/iu.test( + /(?:Р·Р°\s+РІСЃ[её]\s+время|all\s+time|\b(?:19|20)\d{2}\b|(?:^|[^\d])\d{2}\s*(?:Рі(?:РѕРґ|РѕРґР°)?|Рі)(?:[^\p{L}\p{N}]|$)|РІ\s+конкретн(?:РѕРј|ый)\s+РіРѕРґ|Р·Р°\s+РіРѕРґ|РІ\s+РіРѕРґСѓ)/iu.test( text ); - const hasListVerb = /(?:какие|кто|покажи|выведи|список|list|show)/iu.test(text); - const hasRosterQualifier = /(?:у\s+нас|вообще|в\s+баз[еы]|какие\s+есть|кто\s+есть|who\s+are)/iu.test(text); + const hasListVerb = /(?:какие|кто|покажи|выведи|СЃРїРёСЃРѕРє|list|show)/iu.test(text); + const hasRosterQualifier = /(?:Сѓ\s+нас|вообще|РІ\s+баз[еы]|какие\s+есть|кто\s+есть|who\s+are)/iu.test(text); const hasImplicitCounterpartyQuestion = - /(?:кто\s+с\s+нами|кто\s+у\s+нас|всех?\s+зак(?:ов|а|и)?|все\s+заки|кто\s+нов(?:ые|ых|ый)\b|кто\s+был\s+активен|самые\s+старые\s+по\s+сотрудничеству)/iu.test( + /(?:кто\s+СЃ\s+нами|кто\s+Сѓ\s+нас|всех?\s+зак(?:РѕРІ|Р°|Рё)?|РІСЃРµ\s+заки|кто\s+РЅРѕРІ(?:ые|ых|ый)\b|кто\s+был\s+активен|самые\s+старые\s+РїРѕ\s+сотрудничеству)/iu.test( text ); const hasListWithWindow = hasCounterpartyLexeme && hasListVerb && hasTimeWindowLexeme; @@ -855,7 +855,7 @@ function hasCounterpartyActivityLifecycleSignal(text: string): boolean { if (hasImplicitCounterpartyQuestion && (hasLifecycleSegmentationSignal(text) || hasTimeWindowLexeme || hasActivityLexeme)) { return true; } - if (!hasCounterpartyLexeme && hasListVerb && hasLifecycleSegmentationSignal(text) && /\bкто\b/iu.test(text)) { + if (!hasCounterpartyLexeme && hasListVerb && hasLifecycleSegmentationSignal(text) && /\bкто\b/iu.test(text)) { return true; } @@ -863,14 +863,14 @@ function hasCounterpartyActivityLifecycleSignal(text: string): boolean { } function hasCounterpartyShipmentItemFlowSignal(text: string): boolean { - const hasSelectedObjectInventoryCue = /(?:по\s+этой\s+позици(?:и|я|ю)|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|selected\s+object|по\s+выбранному\s+объекту)/iu.test( + const hasSelectedObjectInventoryCue = /(?:РїРѕ\s+этой\s+позици(?:Рё|СЏ|СЋ)|РїРѕ\s+этому\s+товару|РїРѕ\s+ней|РїРѕ\s+нему|РїРѕ\s+РЅРёРј|selected\s+object|РїРѕ\s+выбранному\s+объекту)/iu.test( text ); if (hasSelectedObjectInventoryCue) { return false; } const hasNamedTailAfterShipmentCue = - /(?:отгруж(?:ал|али|ено)|постав(?:лял|ляли|ил|или)|привоз(?:ил|или)|продал)\s+[a-zа-яё][a-zа-яё0-9._-]{2,}/iu.test( + /(?:отгруж(?:ал|али|ено)|постав(?:лял|ляли|РёР»|или)|РїСЂРёРІРѕР·(?:РёР»|или)|продал)\s+[a-zР°-СЏС‘][a-zР°-СЏС‘0-9._-]{2,}/iu.test( text ); const hasPartySignal = @@ -882,11 +882,11 @@ function hasCounterpartyShipmentItemFlowSignal(text: string): boolean { return false; } const hasInboundShipmentCue = - /(?:что\s+нам\s+(?:отгруж(?:ал|али|ено)|постав(?:лял|ляли|ил|или)|привоз(?:ил|или)|продал)|кто\s+нам\s+постав(?:лял|ил)|что\s+постав(?:лял|или)\s+нам|что\s+нам\s+поставили)/iu.test( + /(?:что\s+нам\s+(?:отгруж(?:ал|али|ено)|постав(?:лял|ляли|РёР»|или)|РїСЂРёРІРѕР·(?:РёР»|или)|продал)|кто\s+нам\s+постав(?:лял|РёР»)|что\s+постав(?:лял|или)\s+нам|что\s+нам\s+поставили)/iu.test( text ); const hasItemOrServiceCue = - /(?:како(?:й|е|го|му)\s+товар|каки(?:е|х)\s+товар|какую\s+услуг|какие\s+услуг|товар\s+или\s+услуг|позици(?:ю|и|ях)?)/iu.test( + /(?:како(?:Р№|Рµ|РіРѕ|РјСѓ)\s+товар|каки(?:Рµ|С…)\s+товар|какую\s+услуг|какие\s+услуг|товар\s+или\s+услуг|позици(?:СЋ|Рё|СЏС…)?)/iu.test( text ); return hasInboundShipmentCue || hasItemOrServiceCue; @@ -897,21 +897,21 @@ function hasContractUsageOverviewSignal(text: string): boolean { return true; } if ( - /(?:сколько\s+(?:всего\s+)?(?:договор|контракт)(?:ов|а)?(?:\s+заведен[оы])?|(?:договорн(?:ая|ой)|контрактн(?:ая|ой))\s+баз[аы]).*(?:сколько|used|использ)/iu.test( + /(?:сколько\s+(?:всего\s+)?(?:РґРѕРіРѕРІРѕСЂ|контракт)(?:РѕРІ|Р°)?(?:\s+заведен[РѕС‹])?|(?:РґРѕРіРѕРІРѕСЂРЅ(?:ая|РѕР№)|контрактн(?:ая|РѕР№))\s+баз[аы]).*(?:сколько|used|использ)/iu.test( text ) ) { return true; } if ( - /(?:сколько\s+из\s+(?:договор|контракт)(?:ов|а)?\s+(?:реально\s+)?использ(?:ован[оы]|овал(?:и|ось)?))/iu.test(text) + /(?:сколько\s+РёР·\s+(?:РґРѕРіРѕРІРѕСЂ|контракт)(?:РѕРІ|Р°)?\s+(?:реально\s+)?использ(?:ован[РѕС‹]|овал(?:Рё|РѕСЃСЊ)?))/iu.test(text) ) { return true; } - if (/(?:total\s+vs\s+used|used\s+vs\s+total).*(?:договор|контракт|contract)?/iu.test(text)) { + if (/(?:total\s+vs\s+used|used\s+vs\s+total).*(?:РґРѕРіРѕРІРѕСЂ|контракт|contract)?/iu.test(text)) { return true; } - if (/(?:какие\s+(?:договор|контракт)(?:ы|а)?).*(?:давно\s+не\s+использ|неиспольз|протух|мертв|мёртв|stale|unused)/iu.test(text)) { + if (/(?:какие\s+(?:РґРѕРіРѕРІРѕСЂ|контракт)(?:С‹|Р°)?).*(?:давно\s+РЅРµ\s+использ|неиспольз|протух|мертв|мёртв|stale|unused)/iu.test(text)) { return true; } return false; @@ -924,58 +924,58 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean { if (hasContractAnchorSignal(text)) { return false; } - const hasFuzzyCustomerLexeme = hasFuzzyLexeme(text, ["клиент", "заказчик", "покупател", "customer", "client"]); - const hasFuzzySupplierLexeme = hasFuzzyLexeme(text, ["поставщик", "supplier", "vendor"]); - const hasCounterpartyLexeme = /(?:контрагент(?:ов|а|ы)?|counterpart(?:y|ies)|компан(?:и|ия|ии|ию)|организац(?:и|ия|ии|ию)|partner(?:s)?)/iu.test( + const hasFuzzyCustomerLexeme = hasFuzzyLexeme(text, ["клиент", "заказчик", "покупател", "customer", "client"]); + const hasFuzzySupplierLexeme = hasFuzzyLexeme(text, ["поставщик", "supplier", "vendor"]); + const hasCounterpartyLexeme = /(?:контрагент(?:РѕРІ|Р°|С‹)?|counterpart(?:y|ies)|компан(?:Рё|РёСЏ|РёРё|РёСЋ)|организац(?:Рё|РёСЏ|РёРё|РёСЋ)|partner(?:s)?)/iu.test( text ); const hasSpecificCounterpartyAnchor = hasLooseByAnchorMention(text) || hasHeuristicCounterpartyAnchor(text) || - /(?:по\s+(?:клиент(?:у|а)?|заказчик(?:у|а)?|покупател(?:ю|я)|customer|client)\s+[a-zа-яё0-9])/iu.test(text); - const asksWhoPays = /(?:кто\s+(?:нам\s+)?(?:(?:больше|чаще)\s+)?плат(?:ит|ят)?)/iu.test(text); + /(?:РїРѕ\s+(?:клиент(?:Сѓ|Р°)?|заказчик(?:Сѓ|Р°)?|покупател(?:СЋ|СЏ)|customer|client)\s+[a-zР°-СЏС‘0-9])/iu.test(text); + const asksWhoPays = /(?:кто\s+(?:нам\s+)?(?:(?:больше|чаще)\s+)?плат(?:РёС‚|СЏС‚)?)/iu.test(text); const asksCustomerGroup = - /(?:клиент(?:ов|а|ы)?|заказчик(?:ов|а|и)?|покупател(?:ей|я|и)?|customer(?:s)?|client(?:s)?)/iu.test(text) || + /(?:клиент(?:РѕРІ|Р°|С‹)?|заказчик(?:РѕРІ|Р°|Рё)?|покупател(?:ей|СЏ|Рё)?|customer(?:s)?|client(?:s)?)/iu.test(text) || hasFuzzyCustomerLexeme || asksWhoPays; - const asksCounterpartySource = /(?:с\s+каких|от\s+каких|от\s+кого|from\s+which|from\s+who)/iu.test(text); - const asksIncomingFlow = /(?:приход|поступлен|входящ|зачислен|inflow|incoming)/iu.test(text); + const asksCounterpartySource = /(?:СЃ\s+каких|РѕС‚\s+каких|РѕС‚\s+РєРѕРіРѕ|from\s+which|from\s+who)/iu.test(text); + const asksIncomingFlow = /(?:РїСЂРёС…РѕРґ|поступлен|РІС…РѕРґСЏС‰|зачислен|inflow|incoming)/iu.test(text); const asksWhoBringsMostMoney = - /(?:кто\s+(?:нам\s+)?(?:больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш(?:ий|ая|ее|ие))\s+(?:прин[её]с|зан[её]с).*(?:деньг|денег))/iu.test( + /(?:кто\s+(?:нам\s+)?(?:больше\s+всего|сам(?:ый|ая|РѕРµ|ые)|наибольш(?:РёР№|ая|ее|РёРµ))\s+(?:РїСЂРёРЅ[её]СЃ|зан[её]СЃ).*(?:деньг|денег))/iu.test( text ); const asksWhoBringsMoneyLoose = - /(?:кто\s+(?:нам\s+)?(?:больше|больше\s+всех|больше\s+всего).*(?:деньг|денег|доход|выручк).*(?:прин[её]с|зан[её]с))/iu.test( + /(?:кто\s+(?:нам\s+)?(?:больше|больше\s+всех|больше\s+всего).*(?:деньг|денег|РґРѕС…РѕРґ|выручк).*(?:РїСЂРёРЅ[её]СЃ|зан[её]СЃ))/iu.test( text ) || - /(?:кто\s+(?:нам\s+)?(?:прин[её]с|зан[её]с).*(?:больше|больше\s+всех|больше\s+всего).*(?:деньг|денег|доход|выручк))/iu.test( + /(?:кто\s+(?:нам\s+)?(?:РїСЂРёРЅ[её]СЃ|зан[её]СЃ).*(?:больше|больше\s+всех|больше\s+всего).*(?:деньг|денег|РґРѕС…РѕРґ|выручк))/iu.test( text ); const asksLiquidityRanking = - /(?:ликвидн|liquid)/iu.test(text) && - (asksCustomerGroup || hasCounterpartyLexeme || /(?:клиент|заказчик|контрагент|customer|client|counterpart)/iu.test(text)); + /(?:ликвидн|liquid)/iu.test(text) && + (asksCustomerGroup || hasCounterpartyLexeme || /(?:клиент|заказчик|контрагент|customer|client|counterpart)/iu.test(text)); const asksProfitableYears = - /(?:доходн|выручк|оборот|прибыл|revenue|turnover).*(?:год|года|годы|year|years)/iu.test(text) && - /(?:сам(?:ый|ая|ое|ые)|топ|луч|max|best|наибольш|больше)/iu.test(text); + /(?:РґРѕС…РѕРґРЅ|выручк|РѕР±РѕСЂРѕС‚|прибыл|revenue|turnover).*(?:РіРѕРґ|РіРѕРґР°|РіРѕРґС‹|year|years)/iu.test(text) && + /(?:сам(?:ый|ая|РѕРµ|ые)|топ|луч|max|best|наибольш|больше)/iu.test(text); const asksDealBudgetRanking = - /(?:сделк|deal|бюджет)/iu.test(text) && - /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test( + /(?:сделк|deal|бюджет)/iu.test(text) && + /(?:топ|top|сам(?:ый|ая|РѕРµ|ые)|РєСЂСѓРїРЅ|мален|Р¶РёСЂРЅ|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test( text ); - const asksRevenueTotal = /(?:сколько|скока|скок).*(?:денег|выручк|доход|заработ|оборот)/iu.test(text); - const asksOverallTurnover = /(?:общ(?:ий|ие|ая)\s+оборот|общ(?:ая|ий)\s+выручк|total\s+turnover|turnover\s+total)/iu.test(text); + const asksRevenueTotal = /(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє).*(?:денег|выручк|РґРѕС…РѕРґ|заработ|РѕР±РѕСЂРѕС‚)/iu.test(text); + const asksOverallTurnover = /(?:РѕР±С‰(?:РёР№|РёРµ|ая)\s+РѕР±РѕСЂРѕС‚|РѕР±С‰(?:ая|РёР№)\s+выручк|total\s+turnover|turnover\s+total)/iu.test(text); const asksMajorShare = - /(?:основн(?:ую|ая|ые|ой)\s+част|больш(?:ую|ая|ие)\s+част|львин(?:ая|ую)\s+дол[яю]|ключев(?:ую|ая)\s+част)/iu.test( + /(?:РѕСЃРЅРѕРІРЅ(?:СѓСЋ|ая|ые|РѕР№)\s+част|больш(?:СѓСЋ|ая|РёРµ)\s+част|львин(?:ая|СѓСЋ)\s+РґРѕР»[СЏСЋ]|ключев(?:СѓСЋ|ая)\s+част)/iu.test( text ); const asksValue = - /(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|плат(?:еж|ёж|ежн|ежей|ежа|ит|ят)|деньг|денег|заработ|оборот|чек|сделк|бюджет|занес|занёс|принес|принёс|ликвидн|revenue|inflow|deal|turnover|liquid)/iu.test( + /(?:РґРѕС…РѕРґРЅ|выручк|РїСЂРёС…РѕРґ|поступлен|РІС…РѕРґСЏС‰|зачислен|оплат|плат(?:еж|ёж|ежн|ежей|ежа|РёС‚|СЏС‚)|деньг|денег|заработ|РѕР±РѕСЂРѕС‚|чек|сделк|бюджет|занес|занёс|принес|принёс|ликвидн|revenue|inflow|deal|turnover|liquid)/iu.test( text ); - const asksRankOrTop = /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн)/iu.test( + const asksRankOrTop = /(?:топ|top|сам(?:ый|ая|РѕРµ|ые)|РєСЂСѓРїРЅ|мален|Р¶РёСЂРЅ|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн)/iu.test( text ); - const asksCountOnly = /(?:сколько|скока|скок)\s+/iu.test(text) && !asksValue; + const asksCountOnly = /(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє)\s+/iu.test(text) && !asksValue; if (asksCountOnly) { return false; } @@ -1028,23 +1028,23 @@ function hasSupplierPayoutsProfileSignal(text: string): boolean { if (hasContractAnchorSignal(text)) { return false; } - const hasFuzzySupplierLexeme = hasFuzzyLexeme(text, ["поставщик", "supplier", "vendor"]); + const hasFuzzySupplierLexeme = hasFuzzyLexeme(text, ["поставщик", "supplier", "vendor"]); const hasSpecificCounterpartyAnchor = hasLooseByAnchorMention(text) || hasHeuristicCounterpartyAnchor(text) || - /(?:по\s+(?:поставщик(?:у|а)?|supplier|vendor)\s+[a-zа-яё0-9])/iu.test(text); + /(?:РїРѕ\s+(?:поставщик(?:Сѓ|Р°)?|supplier|vendor)\s+[a-zР°-СЏС‘0-9])/iu.test(text); const asksSupplierGroup = - /(?:поставщик(?:ов|а|и)?|supplier(?:s)?|vendor(?:s)?|к[ао]му\s+мы)/iu.test(text) || + /(?:поставщик(?:РѕРІ|Р°|Рё)?|supplier(?:s)?|vendor(?:s)?|Рє[ао]РјСѓ\s+РјС‹)/iu.test(text) || hasFuzzySupplierLexeme || - /(?:кому\s+ушло|кому\s+платили|кому\s+заплатили)/iu.test(text); + /(?:РєРѕРјСѓ\s+ушло|РєРѕРјСѓ\s+платили|РєРѕРјСѓ\s+заплатили)/iu.test(text); const asksPayoutValue = - /(?:выплат|исходящ|списан|заплат|ушло|сгрузил|сгрузили|перевел|перевёл|отдали|платеж|платёж|outflow|payout)/iu.test( + /(?:выплат|РёСЃС…РѕРґСЏС‰|списан|заплат|ушло|СЃРіСЂСѓР·РёР»|сгрузили|перевел|перевёл|отдали|платеж|платёж|outflow|payout)/iu.test( text ); - const asksRankOrTop = /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|больше\s+всего|чаще\s+всего|максимальн|наибольш)/iu.test( + const asksRankOrTop = /(?:топ|top|сам(?:ый|ая|РѕРµ|ые)|РєСЂСѓРїРЅ|больше\s+всего|чаще\s+всего|максимальн|наибольш)/iu.test( text ); - const asksCountOnly = /(?:сколько|скока|скок)\s+/iu.test(text) && !asksPayoutValue; + const asksCountOnly = /(?:сколько|СЃРєРѕРєР°|СЃРєРѕРє)\s+/iu.test(text) && !asksPayoutValue; if (asksCountOnly) { return false; } @@ -1058,23 +1058,23 @@ function hasContractUsageAndValueSignal(text: string): boolean { if (hasAny(text, CONTRACT_USAGE_AND_VALUE_HINTS)) { return true; } - if (!/(?:договор(?:ов|а|ы)?|контракт(?:ов|а|ы|у|ом|е)?|contract(?:s)?)/iu.test(text)) { + if (!/(?:РґРѕРіРѕРІРѕСЂ(?:РѕРІ|Р°|С‹)?|контракт(?:РѕРІ|Р°|С‹|Сѓ|РѕРј|Рµ)?|contract(?:s)?)/iu.test(text)) { return false; } if (hasContractUsageOverviewSignal(text)) { return false; } - const asksStructure = /(?:нескольк(?:ими|их|ие|о)?\s+(?:договор|контракт)|мультидоговор|контрагент(?:ов|ы)?.*нескольк(?:ими|их|ие|о)\s+(?:договор|контракт)|какие\s+(?:договор|контракт)(?:ы|а)?\s+активн|рабоч(?:ие|их)\s+(?:договор|контракт))/iu.test( + const asksStructure = /(?:нескольк(?:РёРјРё|РёС…|РёРµ|Рѕ)?\s+(?:РґРѕРіРѕРІРѕСЂ|контракт)|мультидоговор|контрагент(?:РѕРІ|С‹)?.*нескольк(?:РёРјРё|РёС…|РёРµ|Рѕ)\s+(?:РґРѕРіРѕРІРѕСЂ|контракт)|какие\s+(?:РґРѕРіРѕРІРѕСЂ|контракт)(?:С‹|Р°)?\s+активн|рабоч(?:РёРµ|РёС…)\s+(?:РґРѕРіРѕРІРѕСЂ|контракт))/iu.test( text ); const asksValue = - /(?:оборот|бюджет|сумм|стоим|value|turnover|amount|revenue|крупн|мелк|миним|максим)/iu.test(text); - const asksRank = /(?:топ|top|ранк|rank|сам(?:ый|ая|ое|ые))/iu.test(text); + /(?:РѕР±РѕСЂРѕС‚|бюджет|СЃСѓРјРј|стоим|value|turnover|amount|revenue|РєСЂСѓРїРЅ|мелк|РјРёРЅРёРј|максим)/iu.test(text); + const asksRank = /(?:топ|top|ранк|rank|сам(?:ый|ая|РѕРµ|ые))/iu.test(text); return asksStructure || asksValue || asksRank; } function hasContractListByCounterpartySignal(text: string): boolean { - const hasContractLexeme = /(?:договор(?:а|у|ом|е|ы)?|контракт(?:а|у|ом|е|ы)?|contracts?|contract)/iu.test(text); + const hasContractLexeme = /(?:РґРѕРіРѕРІРѕСЂ(?:Р°|Сѓ|РѕРј|Рµ|С‹)?|контракт(?:Р°|Сѓ|РѕРј|Рµ|С‹)?|contracts?|contract)/iu.test(text); if (!hasContractLexeme) { return false; } @@ -1096,8 +1096,8 @@ function hasContractListByCounterpartySignal(text: string): boolean { return false; } - const hasListVerb = /(?:покажи|выведи|список|какие|show|list)/iu.test(text); - const hasAllQualifier = /(?:\ball\b|\bвсе\b|всё)/iu.test(text); + const hasListVerb = /(?:покажи|выведи|СЃРїРёСЃРѕРє|какие|show|list)/iu.test(text); + const hasAllQualifier = /(?:\ball\b|\bРІСЃРµ\b|РІСЃС‘)/iu.test(text); const hasCounterpartyAnchor = hasPartyAnchorMention(text) || hasLooseByAnchorMention(text) || @@ -1112,15 +1112,15 @@ function hasContractListByCounterpartySignal(text: string): boolean { function hasDocumentsByAccountDrilldownSignal(text: string): boolean { const hasAccountLexeme = hasAccountNumberAnchor(text) || hasCompactAccountCodeToken(text); - const hasDocLexeme = /(?:документ|док(?:и|ам|ах|ов|а)?|docs?|documents?|doki|docy|doci)/iu.test(text); - const hasDrilldownVerb = /(?:раскрой|раскры|разлож|разверн|документами|по\s+документ)/iu.test(text); - const hasSameDate = /(?:на\s+ту\s+же\s+дат[ауеы]|same\s+date|the\s+same\s+date)/iu.test(text); + const hasDocLexeme = /(?:документ|РґРѕРє(?:Рё|ам|ах|РѕРІ|Р°)?|docs?|documents?|doki|docy|doci)/iu.test(text); + const hasDrilldownVerb = /(?:раскрой|раскры|разлож|разверн|документами|РїРѕ\s+документ)/iu.test(text); + const hasSameDate = /(?:РЅР°\s+ту\s+Р¶Рµ\s+дат[ауеы]|same\s+date|the\s+same\s+date)/iu.test(text); return hasAccountLexeme && hasDocLexeme && (hasDrilldownVerb || hasSameDate); } function hasOpenContractsListSignal(text: string): boolean { - const hasContractLexeme = text.includes("договор") || text.includes("контракт") || text.includes("contract") || text.includes("dogovor"); - const hasOpenLexeme = /(?:незакрыт|не\s+закрыт|открыт|open|unclosed)/iu.test(text); + const hasContractLexeme = text.includes("РґРѕРіРѕРІРѕСЂ") || text.includes("контракт") || text.includes("contract") || text.includes("dogovor"); + const hasOpenLexeme = /(?:незакрыт|РЅРµ\s+закрыт|открыт|open|unclosed)/iu.test(text); if (!hasContractLexeme || !hasOpenLexeme) { return false; } @@ -1129,30 +1129,30 @@ function hasOpenContractsListSignal(text: string): boolean { return false; } // Debt/tail wording indicates open-items intent, not contract list. - if (/(?:долг|задолж|хвост|позиц|open\s+items|unclosed\s+items|взаиморасчет|взаиморасчёт)/iu.test(text)) { + if (/(?:долг|задолж|С…РІРѕСЃС‚|РїРѕР·РёС†|open\s+items|unclosed\s+items|взаиморасчет|взаиморасчёт)/iu.test(text)) { return false; } return true; } function hasSupplierTailRiskSignal(text: string): boolean { - const hasSupplier = /(?:поставщик|supplier|vendor)/iu.test(text); - const hasTail = /(?:хвост|висят|незакрыт|не\s+закрыв|задолж|долг|просроч|сч[её]т)/iu.test(text); - const hasRisk = /(?:систематич|регулярн|проблем|тревог|не\s+разов|больше\s+похож)/iu.test(text); - const hasPeriodCue = /(?:на\s+конец\s+(?:месяц|период)|конец\s+месяц|пару\s+месяц|несколько\s+месяц|больше\s+месяц)/iu.test(text); + const hasSupplier = /(?:поставщик|supplier|vendor)/iu.test(text); + const hasTail = /(?:С…РІРѕСЃС‚|РІРёСЃСЏС‚|незакрыт|РЅРµ\s+закрыв|задолж|долг|РїСЂРѕСЃСЂРѕС‡|СЃС‡[её]С‚)/iu.test(text); + const hasRisk = /(?:систематич|регулярн|проблем|тревог|РЅРµ\s+разов|больше\s+РїРѕС…РѕР¶)/iu.test(text); + const hasPeriodCue = /(?:РЅР°\s+конец\s+(?:месяц|период)|конец\s+месяц|пару\s+месяц|несколько\s+месяц|больше\s+месяц)/iu.test(text); return hasSupplier && hasTail && (hasRisk || hasPeriodCue); } function hasPayablesDebtLifecycleSignal(text: string): boolean { const hasOweSignal = - /(?:кому\s+мы\s+долж(?:ен|ны|эны|эна|эно)?|мы\s+долж(?:ен|ны|эны|эна|эно)?|кому\s+долж(?:ен|ны|эны|эна|эно)?|долж[нэ](?:ы|а|о)?\s+(?:заплат|оплат|перечис)|к\s+оплате|на\s+оплату|who\s+we\s+owe|owe\s+to|payables?|кредитор(?:[а-яё]{0,6})?)/iu.test( + /(?:РєРѕРјСѓ\s+РјС‹\s+долж(?:ен|РЅС‹|СЌРЅС‹|СЌРЅР°|СЌРЅРѕ)?|РјС‹\s+долж(?:ен|РЅС‹|СЌРЅС‹|СЌРЅР°|СЌРЅРѕ)?|РєРѕРјСѓ\s+долж(?:ен|РЅС‹|СЌРЅС‹|СЌРЅР°|СЌРЅРѕ)?|долж[РЅСЌ](?:С‹|Р°|Рѕ)?\s+(?:заплат|оплат|перечис)|Рє\s+оплате|РЅР°\s+оплату|who\s+we\s+owe|owe\s+to|payables?|кредитор(?:[Р°-СЏС‘]{0,6})?)/iu.test( text ); if (!hasOweSignal) { return false; } - const hasPastPaymentSignal = /(?:заплатил(?:и)?|платил(?:и)?|кому\s+ушло|выплатил(?:и)?|списан|outflow|payout)/iu.test(text); - const hasTopRankingSignal = /(?:топ|top|больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш|максимальн)/iu.test(text); + const hasPastPaymentSignal = /(?:заплатил(?:Рё)?|платил(?:Рё)?|РєРѕРјСѓ\s+ушло|выплатил(?:Рё)?|списан|outflow|payout)/iu.test(text); + const hasTopRankingSignal = /(?:топ|top|больше\s+всего|сам(?:ый|ая|РѕРµ|ые)|наибольш|максимальн)/iu.test(text); if (hasPastPaymentSignal && hasTopRankingSignal) { return false; } @@ -1161,14 +1161,14 @@ function hasPayablesDebtLifecycleSignal(text: string): boolean { function hasReceivablesDebtLifecycleSignal(text: string): boolean { const hasOweUsSignal = - /(?:кто\s+нам\s+долж(?:ен|ны|эны|эна|эно)?|кто\s+долж(?:ен|ны|эны|эна|эно)?\s+нам|нам\s+долж(?:ен|ны|эны|эна|эно)?|должник(?:[а-яё]{0,6})?|дебитор(?:[а-яё]{0,6})?|дебиторск(?:[а-яё]{0,6})?|задолж|долг(?:и|ов|а|у)?|к\s+получению|на\s+поступление|к\s+взысканию|who\s+owes\s+us|receivables?)/iu.test( + /(?:кто\s+нам\s+долж(?:ен|РЅС‹|СЌРЅС‹|СЌРЅР°|СЌРЅРѕ)?|кто\s+долж(?:ен|РЅС‹|СЌРЅС‹|СЌРЅР°|СЌРЅРѕ)?\s+нам|нам\s+долж(?:ен|РЅС‹|СЌРЅС‹|СЌРЅР°|СЌРЅРѕ)?|должник(?:[Р°-СЏС‘]{0,6})?|дебитор(?:[Р°-СЏС‘]{0,6})?|дебиторск(?:[Р°-СЏС‘]{0,6})?|задолж|долг(?:Рё|РѕРІ|Р°|Сѓ)?|Рє\s+получению|РЅР°\s+поступление|Рє\s+взысканию|who\s+owes\s+us|receivables?)/iu.test( text ); if (!hasOweUsSignal) { return false; } - const hasPastInflowSignal = /(?:прин[её]с|зан[её]с|поступил|приход|inflow|paid\s+us|already\s+paid)/iu.test(text); - const hasTopRankingSignal = /(?:топ|top|больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш|максимальн)/iu.test(text); + const hasPastInflowSignal = /(?:РїСЂРёРЅ[её]СЃ|зан[её]СЃ|поступил|РїСЂРёС…РѕРґ|inflow|paid\s+us|already\s+paid)/iu.test(text); + const hasTopRankingSignal = /(?:топ|top|больше\s+всего|сам(?:ый|ая|РѕРµ|ые)|наибольш|максимальн)/iu.test(text); if (hasPastInflowSignal && hasTopRankingSignal) { return false; } @@ -1176,27 +1176,27 @@ function hasReceivablesDebtLifecycleSignal(text: string): boolean { } function hasReceivablesLatencyRiskSignal(text: string): boolean { - const hasBuyer = /(?:покупател|клиент|заказчик|customer|buyer)/iu.test(text); - const hasCounterparty = /(?:контрагент|counterparty|partner)/iu.test(text); - const hasPayment = /(?:оплат|платеж|платёж|payment)/iu.test(text); - const hasShipment = /(?:отправк|отгруз|реализ|shipment|delivery)/iu.test(text); - const hasDelay = /(?:длинн|долг|просроч|задерж|висят|тревог|too\s+long|late)/iu.test(text); + const hasBuyer = /(?:покупател|клиент|заказчик|customer|buyer)/iu.test(text); + const hasCounterparty = /(?:контрагент|counterparty|partner)/iu.test(text); + const hasPayment = /(?:оплат|платеж|платёж|payment)/iu.test(text); + const hasShipment = /(?:отправк|отгруз|реализ|shipment|delivery)/iu.test(text); + const hasDelay = /(?:длинн|долг|РїСЂРѕСЃСЂРѕС‡|задерж|РІРёСЃСЏС‚|тревог|too\s+long|late)/iu.test(text); const hasOverdueDeadlineCue = - /(?:срок(?:и|а)?(?:\s+оплат[ыы]?)?[\s\S]{0,24}(?:прош|выш|истек|истёк)|срок(?:и|а)?\s+давно\s+прошл|давно\s+пора\s+оплат|давно\s+не\s+оплач)/iu.test( + /(?:СЃСЂРѕРє(?:Рё|Р°)?(?:\s+оплат[ыы]?)?[\s\S]{0,24}(?:РїСЂРѕС€|выш|истек|истёк)|СЃСЂРѕРє(?:Рё|Р°)?\s+давно\s+прошл|давно\s+РїРѕСЂР°\s+оплат|давно\s+РЅРµ\s+оплач)/iu.test( text ); - const hasNonPayment = /(?:не\s+плат(?:ит|ят|ил|или)|не\s+оплат|не\s+оплач|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|неоплач)/iu.test(text); + const hasNonPayment = /(?:РЅРµ\s+плат(?:РёС‚|СЏС‚|РёР»|или)|РЅРµ\s+оплат|РЅРµ\s+оплач|без\s+оплат|оплат(?:С‹|Р°)?\s+нет|нет\s+оплат|неоплач)/iu.test(text); const hasPaymentShipmentImbalance = - /(?:оплач(?:ено|ен[аоы]?|ивать|ивать)?\s+меньше[\s\S]{0,36}отгруж|недоплат[\s\S]{0,36}отгруж|отгруж[\s\S]{0,36}оплач(?:ено|ено\s+меньше))/iu.test( + /(?:оплач(?:ено|ен[аоы]?|ивать|ивать)?\s+меньше[\s\S]{0,36}отгруж|недоплат[\s\S]{0,36}отгруж|отгруж[\s\S]{0,36}оплач(?:ено|ено\s+меньше))/iu.test( text ); - const hasNegativeSaldoRisk = /(?:сальд[оа]\s+(?:уже\s+)?отрицат|минусов(?:ое|ой)\s+сальдо|сальдо\s+в\s+минус)/iu.test(text); + const hasNegativeSaldoRisk = /(?:сальд[РѕР°]\s+(?:СѓР¶Рµ\s+)?отрицат|РјРёРЅСѓСЃРѕРІ(?:РѕРµ|РѕР№)\s+сальдо|сальдо\s+РІ\s+РјРёРЅСѓСЃ)/iu.test(text); const hasPeriodOrRiskCue = - /(?:за\s+текущ|на\s+конец|тревог|просроч|задерж|долг|длинн|несколько\s+месяц|больше\s+месяц)/iu.test(text) || + /(?:Р·Р°\s+текущ|РЅР°\s+конец|тревог|РїСЂРѕСЃСЂРѕС‡|задерж|долг|длинн|несколько\s+месяц|больше\s+месяц)/iu.test(text) || hasOverdueDeadlineCue || hasNegativeSaldoRisk; const hasBetweenShipmentAndPayment = - /между[\s\S]{0,80}(?:отправк|отгруз|реализ)[\s\S]{0,80}(?:оплат|платеж|платёж|payment)/iu.test(text); + /между[\s\S]{0,80}(?:отправк|отгруз|реализ)[\s\S]{0,80}(?:оплат|платеж|платёж|payment)/iu.test(text); if ( hasBuyer && hasPayment && @@ -1211,41 +1211,41 @@ function hasReceivablesLatencyRiskSignal(text: string): boolean { } function hasSettlementGapSignal(text: string): boolean { - const hasPayment = /(?:платеж|платёж|оплат|списани|поступлен|payment)/iu.test(text); - const hasDocument = /(?:док(?:и|умент|ументы|ументов)|docs?|documents?)/iu.test(text); - const hasShipment = /(?:отгруз|реализ|shipment|delivery|товар|услуг)/iu.test(text); - const hasAdvance = /(?:аванс|предоплат)/iu.test(text); - const hasClosureLexeme = /(?:закрыти|взаиморасч|акт|сч[её]т(?:ов|а|ы)?)/iu.test(text); + const hasPayment = /(?:платеж|платёж|оплат|списани|поступлен|payment)/iu.test(text); + const hasDocument = /(?:РґРѕРє(?:Рё|умент|ументы|ументов)|docs?|documents?)/iu.test(text); + const hasShipment = /(?:отгруз|реализ|shipment|delivery|товар|услуг)/iu.test(text); + const hasAdvance = /(?:аванс|предоплат)/iu.test(text); + const hasClosureLexeme = /(?:закрыти|взаиморасч|акт|СЃС‡[её]С‚(?:РѕРІ|Р°|С‹)?)/iu.test(text); const hasNoDocumentForClosing = - /(?:нет|без)\s+(?:док(?:и|умент|ументы|ументов)|закрывающ)/iu.test(text) && + /(?:нет|без)\s+(?:РґРѕРє(?:Рё|умент|ументы|ументов)|закрывающ)/iu.test(text) && hasClosureLexeme; const hasNoDocumentForClosingReversed = - /(?:док(?:и|умент|ументы|ументов)|закрывающ)[\s\S]{0,48}(?:нет|без)/iu.test(text) && + /(?:РґРѕРє(?:Рё|умент|ументы|ументов)|закрывающ)[\s\S]{0,48}(?:нет|без)/iu.test(text) && hasClosureLexeme; const hasNoPayments = - /(?:нет|без)\s+(?:оплат|платеж|платёж|payment)/iu.test(text) || - /(?:оплат|платеж|платёж|payment)\s+нет/iu.test(text); + /(?:нет|без)\s+(?:оплат|платеж|платёж|payment)/iu.test(text) || + /(?:оплат|платеж|платёж|payment)\s+нет/iu.test(text); const hasDocsWithoutPayments = hasDocument && hasNoPayments; const hasPaymentsWithoutClosingDocs = hasPayment && (hasNoDocumentForClosing || hasNoDocumentForClosingReversed); const hasPaymentsWithoutSettlementClosure = hasPayment && - /(?:без|нет)\s+закрыти(?:я|й)?(?:\s+взаиморасч[её]тов)?/iu.test(text) && + /(?:без|нет)\s+закрыти(?:СЏ|Р№)?(?:\s+взаиморасч[её]тов)?/iu.test(text) && hasClosureLexeme; const hasShipmentWithoutClosingDocs = hasShipment && (hasNoDocumentForClosing || hasNoDocumentForClosingReversed || - /(?:без|нет)\s+док(?:и|умент(?:ов|ы|а)?)\s+(?:для\s+)?(?:их\s+)?закрыти/u.test(text)); + /(?:без|нет)\s+РґРѕРє(?:Рё|умент(?:РѕРІ|С‹|Р°)?)\s+(?:для\s+)?(?:РёС…\s+)?закрыти/u.test(text)); const hasClosingWithoutSupportingDocs = hasClosureLexeme && - /(?:без|нет)\s+подтверждающ(?:их|его|ие)?\s+док(?:и|умент(?:ов|ы|а)?)/iu.test(text); + /(?:без|нет)\s+подтверждающ(?:РёС…|его|РёРµ)?\s+РґРѕРє(?:Рё|умент(?:РѕРІ|С‹|Р°)?)/iu.test(text); const hasAdvanceStuckRisk = - /(?:зависш(?:ий|ие|ая|ие\s+аванс)|давно\s+пора\s+закрыть|пора\s+закрывать|перепривяз(?:ать|к)|списыв(?:ать|ани|ан)|нереальн)/iu.test( + /(?:зависш(?:РёР№|РёРµ|ая|РёРµ\s+аванс)|давно\s+РїРѕСЂР°\s+закрыть|РїРѕСЂР°\s+закрывать|перепривяз(?:ать|Рє)|списыв(?:ать|ани|ан)|нереальн)/iu.test( text ); const hasUnclosedAdvanceGap = hasAdvance && - (/(?:не\s+закрыт|незакрыт|долго\s+не\s+закрыт|давно\s+не\s+закрыт|давно\s+пора\s+закрыть)/iu.test(text) || + (/(?:РЅРµ\s+закрыт|незакрыт|долго\s+РЅРµ\s+закрыт|давно\s+РЅРµ\s+закрыт|давно\s+РїРѕСЂР°\s+закрыть)/iu.test(text) || hasAdvanceStuckRisk || hasNoDocumentForClosing || hasNoDocumentForClosingReversed); @@ -1261,13 +1261,13 @@ function hasSettlementGapSignal(text: string): boolean { function hasReconciliationMismatchSignal(text: string): boolean { const hasCounterparty = - /(?:контрагент|поставщик|клиент|покупател|customer|supplier|counterparty)/iu.test(text); - const hasReconciliationLexeme = /(?:акт(?:а|ом|ах)?\s+свер(?:к|ок)|свер(?:к|ок))/iu.test(text); + /(?:контрагент|поставщик|клиент|покупател|customer|supplier|counterparty)/iu.test(text); + const hasReconciliationLexeme = /(?:акт(?:Р°|РѕРј|ах)?\s+свер(?:Рє|РѕРє)|свер(?:Рє|РѕРє))/iu.test(text); const hasMismatchLexeme = - /(?:не\s+совпад|несовпад|расхожд|расход|не\s+сход|несход|разъех|разниц|не\s+бь[её]т)/iu.test(text); - const hasBalanceLexeme = /(?:сальд|остат|баланс|saldo|balance)/iu.test(text); - const hasLookupVerb = /(?:покажи|выведи|найд[иь]|show|list)/iu.test(text); - const hasInterrogativeLookup = /(?:по\s+каким|у\s+кого|какие|какой|кто|где)/iu.test(text); + /(?:РЅРµ\s+совпад|несовпад|расхожд|расход|РЅРµ\s+СЃС…РѕРґ|несход|разъех|разниц|РЅРµ\s+Р±СЊ[её]С‚)/iu.test(text); + const hasBalanceLexeme = /(?:сальд|остат|баланс|saldo|balance)/iu.test(text); + const hasLookupVerb = /(?:покажи|выведи|найд[РёСЊ]|show|list)/iu.test(text); + const hasInterrogativeLookup = /(?:РїРѕ\s+каким|Сѓ\s+РєРѕРіРѕ|какие|какой|кто|РіРґРµ)/iu.test(text); return ( hasCounterparty && hasReconciliationLexeme && @@ -1289,138 +1289,138 @@ function isLikelyCounterpartyToken(rawToken: string): boolean { return false; } const stopWords = new Set([ - "за", - "с", - "по", - "на", - "и", - "или", - "док", - "доки", - "доки?", - "документ", - "документы", - "документов", - "документами", - "документу", - "документе", - "документа", - "документах", - "докам", - "доками", - "количество", - "количеству", - "количества", - "количеством", - "активный", - "активного", - "активности", - "пассивный", - "пассивного", - "пассивности", - "наименее", - "минимальный", - "минимум", - "реже", - "редкий", - "банк", - "банковские", - "операции", - "платежи", - "платеж", - "платёж", - "контрагент", - "контрагенту", - "контрагента", - "компания", - "компании", - "организация", - "организации", - "год", - "года", - "г", - "плс", + "Р·Р°", + "СЃ", + "РїРѕ", + "РЅР°", + "Рё", + "или", + "РґРѕРє", + "РґРѕРєРё", + "РґРѕРєРё?", + "документ", + "документы", + "документов", + "документами", + "документу", + "документе", + "документа", + "документах", + "докам", + "доками", + "количество", + "количеству", + "количества", + "количеством", + "активный", + "активного", + "активности", + "пассивный", + "пассивного", + "пассивности", + "наименее", + "минимальный", + "РјРёРЅРёРјСѓРј", + "реже", + "редкий", + "банк", + "банковские", + "операции", + "платежи", + "платеж", + "платёж", + "контрагент", + "контрагенту", + "контрагента", + "компания", + "компании", + "организация", + "организации", + "РіРѕРґ", + "РіРѕРґР°", + "Рі", + "плс", "pls", - "пж", - "пжлст", - "пожалуйста", - "есть", - "же", - "сводные", - "сводный", - "сводная", - "сводную", - "сводном", - "сводного", - "сводному", - "неуказанному", - "неуказанный", - "неуказанная", - "неуказанное", - "указанному", - "указанный", - "указанная", - "указанное", - "объекту", - "объект", - "бля", - "блять", - "епт", - "ёпт", - "епта", - "нах", - "нахуй", - "связанным", - "связанные", - "связанных", - "связанному", + "РїР¶", + "пжлст", + "пожалуйста", + "есть", + "Р¶Рµ", + "сводные", + "сводный", + "сводная", + "СЃРІРѕРґРЅСѓСЋ", + "СЃРІРѕРґРЅРѕРј", + "СЃРІРѕРґРЅРѕРіРѕ", + "СЃРІРѕРґРЅРѕРјСѓ", + "неуказанному", + "неуказанный", + "неуказанная", + "неуказанное", + "указанному", + "указанный", + "указанная", + "указанное", + "объекту", + "объект", + "бля", + "блять", + "епт", + "ёпт", + "епта", + "нах", + "нахуй", + "связанным", + "связанные", + "связанных", + "связанному", "related", "linked", - "этомуже", - "томуже" + "этомуже", + "томуже" ]); return !stopWords.has(token); } function hasPartyAnchorMention(text: string): boolean { return ( - text.includes("контраг") || - text.includes("контра") || + text.includes("контраг") || + text.includes("контра") || text.includes("counterparty") || - text.includes("компан") || + text.includes("компан") || text.includes("company") || - text.includes("организац") || + text.includes("организац") || text.includes("supplier") || text.includes("vendor") || text.includes("customer") || text.includes("client") || text.includes("partner") || - text.includes("поставщик") || - text.includes("клиент") || - text.includes("покупател") || - text.includes("партнер") + text.includes("поставщик") || + text.includes("клиент") || + text.includes("покупател") || + text.includes("партнер") ); } function hasContractAnchorMention(text: string): boolean { return ( - text.includes("договор") || - text.includes("контракт") || - /\bдог\.?\b/iu.test(text) || - text.includes("дог.") || + text.includes("РґРѕРіРѕРІРѕСЂ") || + text.includes("контракт") || + /\bРґРѕРі\.?\b/iu.test(text) || + text.includes("РґРѕРі.") || text.includes("contract") || text.includes("dogovor") ); } function hasContractNumberLikeToken(text: string): boolean { - if (/(?:^|[\s([{])(?:№|#|n)\s*[a-zа-яё0-9][a-zа-яё0-9./_-]{1,}(?=$|[\s,.;:!?)\]}])/iu.test(text)) { + if (/(?:^|[\s([{])(?:в„–|#|n)\s*[a-zР°-СЏС‘0-9][a-zР°-СЏС‘0-9./_-]{1,}(?=$|[\s,.;:!?)\]}])/iu.test(text)) { return true; } const rawTokens = text - .split(/[\s,;:!?()[\]{}"«»]+/u) - .map((token) => token.replace(/^[^\p{L}\p{N}#№]+|[^\p{L}\p{N}./_-]+$/gu, "").trim()) + .split(/[\s,;:!?()[\]{}"«»]+/u) + .map((token) => token.replace(/^[^\p{L}\p{N}#в„–]+|[^\p{L}\p{N}./_-]+$/gu, "").trim()) .filter((token) => token.length > 0); for (const rawToken of rawTokens) { @@ -1475,7 +1475,7 @@ function hasContractAnchorSignal(text: string): boolean { } function hasLooseByAnchorMention(text: string): boolean { - const match = text.match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu); + const match = text.match(/(?:^|\s)РїРѕ\s+([a-zР°-СЏС‘][a-zР°-СЏС‘0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu); if (!match) { return false; } @@ -1484,52 +1484,52 @@ function hasLooseByAnchorMention(text: string): boolean { return false; } const stopWords = new Set([ - "контрагенту", - "контрагента", - "контре", - "компании", - "компанию", - "организации", - "организацию", - "поставщику", - "поставщика", - "клиенту", - "клиента", - "покупателю", - "покупателя", - "партнеру", - "партнера", - "договору", - "договора", - "счету", - "счёту", - "дате", - "периоду", - "период", - "документам", - "докам", - "количество", - "количеству", - "количества", - "количеством", - "активности", - "пассивности", - "наименее", - "минимум" + "контрагенту", + "контрагента", + "контре", + "компании", + "компанию", + "организации", + "организацию", + "поставщику", + "поставщика", + "клиенту", + "клиента", + "покупателю", + "покупателя", + "партнеру", + "партнера", + "РґРѕРіРѕРІРѕСЂСѓ", + "РґРѕРіРѕРІРѕСЂР°", + "счету", + "счёту", + "дате", + "периоду", + "период", + "документам", + "докам", + "количество", + "количеству", + "количества", + "количеством", + "активности", + "пассивности", + "наименее", + "РјРёРЅРёРјСѓРј" ]); return !stopWords.has(token); } function hasImplicitCounterpartyAnchorAroundDocs(text: string): boolean { const beforeDocsMatch = text.match( - /(?:^|\s)([a-zа-яё][a-zа-яё0-9._-]{1,})\s+(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?|doki|docy|doci)(?=[\s,.;:!?)]|$)/iu + /(?:^|\s)([a-zР°-СЏС‘][a-zР°-СЏС‘0-9._-]{1,})\s+(?:РґРѕРє(?:Рё|СѓРј(?:ент(?:С‹|РѕРІ|ам|Р°)?)?)|docs?|documents?|doki|docy|doci)(?=[\s,.;:!?)]|$)/iu ); if (beforeDocsMatch && isLikelyCounterpartyToken(String(beforeDocsMatch[1] ?? ""))) { return true; } const afterDocsMatch = text.match( - /(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?|doki|docy|doci)\s+(?:по\s+)?([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu + /(?:РґРѕРє(?:Рё|СѓРј(?:ент(?:С‹|РѕРІ|ам|Р°)?)?)|docs?|documents?|doki|docy|doci)\s+(?:РїРѕ\s+)?([a-zР°-СЏС‘][a-zР°-СЏС‘0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu ); if (afterDocsMatch && isLikelyCounterpartyToken(String(afterDocsMatch[1] ?? ""))) { return true; @@ -1539,7 +1539,7 @@ function hasImplicitCounterpartyAnchorAroundDocs(text: string): boolean { } function hasDocsOrBankSignal(text: string): boolean { - return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|doki|docy|doci|банк|выписк|платеж|платёж|оплат|transactions?|bank\s+ops|bank\s+operations?)/iu.test( + return /(?:РґРѕРє(?:Рё|умент|ументы|ументов)|docs?|documents?|doki|docy|doci|банк|выписк|платеж|платёж|оплат|transactions?|bank\s+ops|bank\s+operations?)/iu.test( text ); } @@ -1550,9 +1550,9 @@ function hasBankOperationSignal(text: string): boolean { function hasDocumentSignal(text: string): boolean { return ( - text.includes("док") || - text.includes("доки") || - text.includes("документ") || + text.includes("РґРѕРє") || + text.includes("РґРѕРєРё") || + text.includes("документ") || text.includes("doki") || text.includes("docy") || text.includes("doci") || @@ -1566,7 +1566,7 @@ function hasHeuristicCounterpartyAnchor(text: string): boolean { return false; } const tokens = String(text ?? "") - .split(/[^a-zа-яё0-9._-]+/iu) + .split(/[^a-zР°-СЏС‘0-9._-]+/iu) .map((item) => item.trim()) .filter((item) => item.length > 0); for (const token of tokens) { @@ -1577,7 +1577,7 @@ function hasHeuristicCounterpartyAnchor(text: string): boolean { if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) { continue; } - if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) { + if (/(?:^Р·Р°$|^for$|^from$|^to$|^РїРѕ$|^СЃ$|^РіРѕРґ$|^РіРѕРґР°$|^Рі$|^year$)/iu.test(lowered)) { continue; } return true; @@ -1587,42 +1587,42 @@ function hasHeuristicCounterpartyAnchor(text: string): boolean { function hasGenericAddressLookupSignal(text: string): boolean { return ( - /\bесть\b/iu.test(text) || - /\bпокажи\b/iu.test(text) || - /\bвыведи\b/iu.test(text) || - /\bкакие\b/iu.test(text) || - /\bчто(?:-|\s)?то\b/iu.test(text) || - /за\s+любой\s+период/iu.test(text) || - /за\s+вс[её]\s+время/iu.test(text) || + /\bесть\b/iu.test(text) || + /\bпокажи\b/iu.test(text) || + /\bвыведи\b/iu.test(text) || + /\bкакие\b/iu.test(text) || + /\bчто(?:-|\s)?то\b/iu.test(text) || + /Р·Р°\s+любой\s+период/iu.test(text) || + /Р·Р°\s+РІСЃ[её]\s+время/iu.test(text) || /for\s+all\s+time/iu.test(text) || /all\s+time/iu.test(text) ); } function hasAccountNumberAnchor(text: string): boolean { - return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text); + return /(?:account|СЃС‡[её]С‚|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text); } function hasInventoryAccount41Anchor(text: string): boolean { - return /(?:сч[её]т(?:а|е|у)?|счет(?:а|е|у)?)\D{0,12}41(?:[.,]0?1)?/iu.test(text) || /41(?:[.,]0?1)?\D{0,12}(?:сч[её]т(?:а|е|у)?|счет(?:а|е|у)?)/iu.test(text); + return /(?:СЃС‡[её]С‚(?:Р°|Рµ|Сѓ)?|счет(?:Р°|Рµ|Сѓ)?)\D{0,12}41(?:[.,]0?1)?/iu.test(text) || /41(?:[.,]0?1)?\D{0,12}(?:СЃС‡[её]С‚(?:Р°|Рµ|Сѓ)?|счет(?:Р°|Рµ|Сѓ)?)/iu.test(text); } function hasInventoryAsOfCue(text: string): boolean { - return /(?:сейчас|текущ|на\s+дату|по\s+состоянию|срез|на\s+конец|date|as\s+of|current|now|today)/iu.test( + return /(?:сейчас|текущ|РЅР°\s+дату|РїРѕ\s+состоянию|срез|РЅР°\s+конец|date|as\s+of|current|now|today)/iu.test( text ); } function hasInventoryOnHandSignal(text: string): boolean { - const hasColloquialStockSnapshotCue = /(?:что|ч[еёо])\s+(?:у\s+нас\s+)?на\s+склад(?:е|у|ом|ах)(?=$|[\s,.;:!?])/iu.test( + const hasColloquialStockSnapshotCue = /(?:что|С‡[еёо])\s+(?:Сѓ\s+нас\s+)?РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)(?=$|[\s,.;:!?])/iu.test( text ); - const hasStockStateCue = /(?:(?:что|ч[еёо])\s+там\s+на\s+склад(?:е|у|ом|ах)|(?:что|ч[еёо]).*происход(?:ит|ило|ящее).*(?:на\s+)?склад(?:е|у|ом|ах)|происход(?:ит|ило|ящее)\s+на\s+склад(?:е|у|ом|ах)|ситуац(?:ия|ии)\s+на\s+склад(?:е|у|ом|ах)|обстановк(?:а|и)\s+на\s+склад(?:е|у|ом|ах)|what(?:'s| is)?\s+(?:there\s+)?(?:on|in)\s+(?:the\s+)?(?:warehouse|stock)|what(?:'s| is)?\s+happening\s+(?:on|in)\s+(?:the\s+)?(?:warehouse|stock))/iu.test( + const hasStockStateCue = /(?:(?:что|С‡[еёо])\s+там\s+РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)|(?:что|С‡[еёо]).*РїСЂРѕРёСЃС…РѕРґ(?:РёС‚|ило|ящее).*(?:РЅР°\s+)?склад(?:Рµ|Сѓ|РѕРј|ах)|РїСЂРѕРёСЃС…РѕРґ(?:РёС‚|ило|ящее)\s+РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)|ситуац(?:РёСЏ|РёРё)\s+РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)|обстановк(?:Р°|Рё)\s+РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)|what(?:'s| is)?\s+(?:there\s+)?(?:on|in)\s+(?:the\s+)?(?:warehouse|stock)|what(?:'s| is)?\s+happening\s+(?:on|in)\s+(?:the\s+)?(?:warehouse|stock))/iu.test( text ); const hasAccount41Anchor = hasInventoryAccount41Anchor(text); const hasStockLexeme = - /(?:склад(?:е|у|ом|ы|ов)?|warehouse|stock(?:room)?|inventory|on[\s-]?hand)/iu.test(text); + /(?:склад(?:Рµ|Сѓ|РѕРј|С‹|РѕРІ)?|warehouse|stock(?:room)?|inventory|on[\s-]?hand)/iu.test(text); if (!hasStockLexeme && !hasAccount41Anchor) { return false; } @@ -1636,13 +1636,13 @@ function hasInventoryOnHandSignal(text: string): boolean { return false; } const hasGoodsLexeme = - /(?:товар(?:ы|ов|ом|а|ные)?|номенклатур|материал(?:ы|ов|а|ам)?|item(?:s)?|sku|product(?:s)?)/iu.test(text); + /(?:товар(?:С‹|РѕРІ|РѕРј|Р°|ные)?|номенклатур|материал(?:С‹|РѕРІ|Р°|ам)?|item(?:s)?|sku|product(?:s)?)/iu.test(text); const hasBalanceLexeme = - /(?:леж(?:ит|ат)|есть|числ(?:ит(?:ся|сь)|ятся)|остат(?:ок|ки)|срез|на\s+дат|по\s+состоянию|на\s+конец|происход(?:ит|ило|ящее)|ситуац(?:ия|ии)|обстановк(?:а|и)|today|now|current|as\s+of)/iu.test( + /(?:леж(?:РёС‚|ат)|есть|числ(?:РёС‚(?:СЃСЏ|СЃСЊ)|ятся)|остат(?:РѕРє|РєРё)|срез|РЅР°\s+дат|РїРѕ\s+состоянию|РЅР°\s+конец|РїСЂРѕРёСЃС…РѕРґ(?:РёС‚|ило|ящее)|ситуац(?:РёСЏ|РёРё)|обстановк(?:Р°|Рё)|today|now|current|as\s+of)/iu.test( text ); const hasRequestCue = - /(?:покажи|показать|выведи|дай|какие|что|ч[еёо]|какой|сколько|проверь|проверить|чекни|check|show|list|which|what)/iu.test( + /(?:покажи|показать|выведи|дай|какие|что|С‡[еёо]|какой|сколько|проверь|проверить|чекни|check|show|list|which|what)/iu.test( text ); if (hasAccount41Anchor && (hasGoodsLexeme || hasBalanceLexeme || hasRequestCue || hasInventoryAsOfCue(text))) { @@ -1653,25 +1653,25 @@ function hasInventoryOnHandSignal(text: string): boolean { } function hasInventoryProvenanceSignal(text: string): boolean { - return /(?:поставщик|закупк|РїСЂРѕРёСЃС…РѕР¶Рґ|откуда|РєРѕРіРґР° был куплен|активная закупк|purchase provenance|purchase date|supplier provenance|stock overlap)/iu.test( + return /(?:поставщик|закупк|РїСЂРѕРёСЃС…РѕР¶Рґ|откуда|РєРѕРіРґР° был куплен|активная закупк|purchase provenance|purchase date|supplier provenance|stock overlap)/iu.test( text ); } function hasInventoryPurchaseDocumentsSignal(text: string): boolean { - return /(?:РїРѕ каким документам|документы закупки|purchase documents|documents of purchase|through which documents|chain of documents)/iu.test( + return /(?:Р С—Р С• каким документам|документы закупки|purchase documents|documents of purchase|through which documents|chain of documents)/iu.test( text ); } function hasInventorySaleTraceSignal(text: string): boolean { - return /(?:продаж|покупател|buyer|sale trace|purchase[\s-]?to[\s-]?sale|purchase -> warehouse -> sale|закупка.*продаж)/iu.test( + return /(?:продаж|покупател|buyer|sale trace|purchase[\s-]?to[\s-]?sale|purchase -> warehouse -> sale|закупка.*продаж)/iu.test( text ); } function hasSelectedObjectInventoryCue(text: string): boolean { - return /(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+нему|по\s+ней|по\s+ним|по\s+нему\s+же|по\s+ней\s+же|selected\s+object)/iu.test( + return /(?:РїРѕ\s+выбранному\s+объекту|РїРѕ\s+выбранной\s+позиции|РїРѕ\s+этой\s+позиции|РїРѕ\s+этому\s+товару|РїРѕ\s+нему|РїРѕ\s+ней|РїРѕ\s+РЅРёРј|РїРѕ\s+нему\s+Р¶Рµ|РїРѕ\s+ней\s+Р¶Рµ|selected\s+object)/iu.test( String(text ?? "") ); } @@ -1682,10 +1682,10 @@ function hasSelectedObjectInventoryProvenanceSignal(text: string): boolean { function hasSelectedObjectInventoryPurchaseDocumentsSignal(text: string): boolean { const hasPurchaseDocumentsCue = - /(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test( + /(?:РїРѕ\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|РїРѕ\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test( text ) || - /(?:(?:по\s+каким|какими)\s+док[а-яё]*[\s\S]{0,80}(?:купил|куплен)|док(?:и|умент[а-яё]*)[\s\S]{0,80}(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару)|операци)|(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару))[\s\S]{0,80}док(?:и|умент[а-яё]*))/iu.test( + /(?:(?:РїРѕ\s+каким|какими)\s+РґРѕРє[Р°-СЏС‘]*[\s\S]{0,80}(?:РєСѓРїРёР»|куплен)|РґРѕРє(?:Рё|умент[Р°-СЏС‘]*)[\s\S]{0,80}(?:РїРѕ\s+(?:РЅРёРј|ней|нему|этой\s+позиции|этому\s+товару)|операци)|(?:РїРѕ\s+(?:РЅРёРј|ней|нему|этой\s+позиции|этому\s+товару))[\s\S]{0,80}РґРѕРє(?:Рё|умент[Р°-СЏС‘]*))/iu.test( text ); return ( @@ -1703,10 +1703,10 @@ function hasSelectedObjectInventoryProfitabilitySignal(text: string): boolean { } function hasInventoryProvenanceSignalV2(text: string): boolean { - const hasItemCue = /(?:товар|номенклатур|sku|item|product|остат(?:ок|ки)|склад)/iu.test(text); - const hasSupplierCue = hasInventorySupplierCue(text) || /кем\s+поставлен/iu.test(text); + const hasItemCue = /(?:товар|номенклатур|sku|item|product|остат(?:РѕРє|РєРё)|склад)/iu.test(text); + const hasSupplierCue = hasInventorySupplierCue(text) || /кем\s+поставлен/iu.test(text); const hasPurchaseCue = - /(?:куплен(?:ы|а|о)?|закупк|происхождени|откуда|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставлен(?:ы|а)?|purchase\s+provenance|purchase\s+date)/iu.test( + /(?:куплен(?:С‹|Р°|Рѕ)?|закупк|происхождени|откуда|РіРґРµ\s+(?:РјС‹\s+)?купили(?:\s+(?:это|его|товар|позицию))?|РіРґРµ\s+куплено|РєРѕРіРґР°\s+был\s+куплен|РєРѕРіРґР°\s+куплен|дата\s+закупк|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставлен(?:С‹|Р°)?|purchase\s+provenance|purchase\s+date)/iu.test( text ) || hasInventoryPurchaseStem(text); return hasItemCue && hasSupplierCue && hasPurchaseCue; @@ -1714,27 +1714,27 @@ function hasInventoryProvenanceSignalV2(text: string): boolean { function hasInventoryPurchaseDateSignal(text: string): boolean { const hasItemCue = - /(?:товар|номенклатур|sku|item|product)/iu.test(text) || hasSelectedObjectInventoryCue(text); + /(?:товар|номенклатур|sku|item|product)/iu.test(text) || hasSelectedObjectInventoryCue(text); const hasPurchaseDateCue = - /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|purchase\s+date)/iu.test( + /(?:РєРѕРіРґР°\s+(?:примерно\s+)?(?:РјС‹\s+)?купили|РєРѕРіРґР°\s+был\s+куплен|РєРѕРіРґР°\s+куплен|дата\s+закупк|purchase\s+date)/iu.test( text ) || - /(?:когда\s+был(?:а|и|о)?\s+закупк\w*|когда\s+закупк\w*)/iu.test(text); + /(?:РєРѕРіРґР°\s+был(?:Р°|Рё|Рѕ)?\s+закупк\w*|РєРѕРіРґР°\s+закупк\w*)/iu.test(text); return hasItemCue && hasPurchaseDateCue; } function hasInventoryPurchaseDocumentsSignalV2(text: string): boolean { - const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); - const hasPurchaseDocCue = /(?:по\s+каким\s+документам\s+был\s+куплен|по\s+каким\s+документам\s+куплен|какими\s+документами\s+был\s+куплен|документ(?:ам|ы)\s+закупк|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test( + const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); + const hasPurchaseDocCue = /(?:РїРѕ\s+каким\s+документам\s+был\s+куплен|РїРѕ\s+каким\s+документам\s+куплен|какими\s+документами\s+был\s+куплен|документ(?:ам|С‹)\s+закупк|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test( text ); return hasItemCue && hasPurchaseDocCue; } function hasInventorySaleTraceSignalV2(text: string): boolean { - const hasItemCue = /(?:товар|номенклатур|sku|item|product|позици(?:я|ю|и)|продукци(?:я|ю|и))/iu.test(text); + const hasItemCue = /(?:товар|номенклатур|sku|item|product|позици(?:СЏ|СЋ|Рё)|продукци(?:СЏ|СЋ|Рё))/iu.test(text); const hasTraceCue = - /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|куда\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали(?:\s+(?:это|его|товар|позицию))?|куда\s+(?:была\s+)?реализована\s+(?:позиция|номенклатура|продукция)|кто\s+купил|buyer|sale\s+trace|trace\s+of\s+sale|через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*warehouse\s*->\s*sale|purchase\s*->\s*stock\s*->\s*sale)/iu.test( + /(?:РєРѕРјСѓ\s+(?:РІ\s+итоге\s+)?(?:РјС‹\s+)?продали|РєРѕРјСѓ\s+был\s+продан|РєСѓРґР°\s+(?:РІ\s+итоге\s+)?(?:РјС‹\s+)?продали(?:\s+(?:это|его|товар|позицию))?|РєСѓРґР°\s+(?:была\s+)?реализована\s+(?:позиция|номенклатура|продукция)|кто\s+РєСѓРїРёР»|buyer|sale\s+trace|trace\s+of\s+sale|через\s+какие\s+документы\s+РїСЂРѕС€[её]Р»\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*warehouse\s*->\s*sale|purchase\s*->\s*stock\s*->\s*sale)/iu.test( text ); return hasItemCue && hasTraceCue; @@ -1742,14 +1742,14 @@ function hasInventorySaleTraceSignalV2(text: string): boolean { function hasInventorySupplierStockOverlapSignal(text: string): boolean { const hasDirectSingleItemSupplierQuestion = - /(?:от\s+какого\s+поставщика\s+куплен\s+(?:товар|номенклатур(?:а|у|ы)|позици(?:я|ю|и))|от\s+кого\s+куплен\s+(?:товар|номенклатур(?:а|у|ы)|позици(?:я|ю|и)))/iu.test( + /(?:РѕС‚\s+какого\s+поставщика\s+куплен\s+(?:товар|номенклатур(?:Р°|Сѓ|С‹)|позици(?:СЏ|СЋ|Рё))|РѕС‚\s+РєРѕРіРѕ\s+куплен\s+(?:товар|номенклатур(?:Р°|Сѓ|С‹)|позици(?:СЏ|СЋ|Рё)))/iu.test( text ); if (hasDirectSingleItemSupplierQuestion) { return false; } - const hasSupplierCue = /(?:поставщик|supplier|vendor|от\s+поставщика|у\s+поставщика)/iu.test(text); - const hasStockCue = /(?:склад|остат(?:ок|ке|ков)|лежат|лежит|сейчас\s+еще|сейчас\s+ещ[её]|на\s+дату|по\s+состоянию\s+на\s+дату|current\s+stock|stock\s+overlap|что\s+сейчас\s+лежит)/iu.test( + const hasSupplierCue = /(?:поставщик|supplier|vendor|РѕС‚\s+поставщика|Сѓ\s+поставщика)/iu.test(text); + const hasStockCue = /(?:склад|остат(?:РѕРє|РєРµ|РєРѕРІ)|лежат|лежит|сейчас\s+еще|сейчас\s+ещ[её]|РЅР°\s+дату|РїРѕ\s+состоянию\s+РЅР°\s+дату|current\s+stock|stock\s+overlap|что\s+сейчас\s+лежит)/iu.test( text ); return hasSupplierCue && hasStockCue; @@ -1757,37 +1757,144 @@ function hasInventorySupplierStockOverlapSignal(text: string): boolean { function hasInventoryAgingSignal(text: string): boolean { const hasResidueCue = - /(?:остат(?:ок|ки)|в\s+остатке|среди\s+текущих\s+остатков|на\s+складе|stock\s+residue|stock\s+balance)/iu.test(text); + /(?:остат(?:РѕРє|РєРё)|РІ\s+остатке|среди\s+текущих\s+остатков|РЅР°\s+складе|stock\s+residue|stock\s+balance)/iu.test(text); const hasAgingCue = - /(?:стар(?:ые|ым|ых)\s+закупк|стары(?:м|х)\s+закупк(?:ам|и|ах)|относит(?:ся|ся\s+ли)?\s+.*\s+к\s+старым\s+закупк|закупал(?:ись|ся)\s+очень\s+давно|очень\s+давно|давно\s+куплен|давно\s+приобретен|куплен\s+задолго\s+до(?:\s+даты)?|закуплен(?:ы|а)?\s+давно|приобретен\s+давно|задолго\s+до(?:\s+даты)?|возраст\s+остатк|возраст\s+закупк|aged?\s+stock|old\s+purchase|old\s+purchases|old\s+stock|bought\s+long\s+ago|purchased\s+long\s+ago|aging\s+by\s+purchase\s+date|very\s+old\s+stock|very\s+old\s+purchase|old\s+procurement|older\s+purchases|aged\s+items|old\s+goods)/iu.test( + /(?:стар(?:ые|ым|ых)\s+закупк|стары(?:Рј|С…)\s+закупк(?:ам|Рё|ах)|относит(?:СЃСЏ|СЃСЏ\s+ли)?\s+.*\s+Рє\s+старым\s+закупк|закупал(?:РёСЃСЊ|СЃСЏ)\s+очень\s+давно|очень\s+давно|давно\s+куплен|давно\s+приобретен|куплен\s+задолго\s+РґРѕ(?:\s+даты)?|закуплен(?:С‹|Р°)?\s+давно|приобретен\s+давно|задолго\s+РґРѕ(?:\s+даты)?|возраст\s+остатк|возраст\s+закупк|aged?\s+stock|old\s+purchase|old\s+purchases|old\s+stock|bought\s+long\s+ago|purchased\s+long\s+ago|aging\s+by\s+purchase\s+date|very\s+old\s+stock|very\s+old\s+purchase|old\s+procurement|older\s+purchases|aged\s+items|old\s+goods)/iu.test( text ); - return hasAgingCue || (hasResidueCue && /(?:давно\s+куплен|давно\s+приобретен|задолго\s+до)/iu.test(text)); + return hasAgingCue || (hasResidueCue && /(?:давно\s+куплен|давно\s+приобретен|задолго\s+РґРѕ)/iu.test(text)); } function hasInventoryPurchaseToSaleChainSignal(text: string): boolean { - const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); + const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); const hasChainCue = - /(?:закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|закупка\s*->\s*склад\s*->\s*продажа|цепочк[аи]\s+движен|документально\s+подтвержденн\w+\s+цепочк|supplier\s*->\s*item\s*->\s*(?:buyer|customer)|supplier\s+to\s+buyer|supplier\s+to\s+item\s+to\s+buyer)/iu.test( + /(?:закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|закупка\s*->\s*склад\s*->\s*продажа|цепочк[аи]\s+движен|документально\s+подтвержденн\w+\s+цепочк|supplier\s*->\s*item\s*->\s*(?:buyer|customer)|supplier\s+to\s+buyer|supplier\s+to\s+item\s+to\s+buyer)/iu.test( text ) || text.includes("->"); return hasItemCue && hasChainCue; } function hasInventorySupplierToBuyerChainSignal(text: string): boolean { - const hasSupplierCue = /(?:поставщик|supplier|vendor)/iu.test(text); - const hasBuyerCue = /(?:покупател|buyer|customer|client)/iu.test(text); - const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); + const hasSupplierCue = /(?:поставщик|supplier|vendor)/iu.test(text); + const hasBuyerCue = /(?:покупател|buyer|customer|client)/iu.test(text); + const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); const hasChainCue = - /(?:документально\s+подтвержденн\w+\s+цепочк|supplier\s*->\s*item\s*->\s*buyer|supplier\s*->\s*item\s*->\s*customer|supplier\s*->\s*buyer|supplier\s+to\s+buyer|supplier\s+to\s+buyer\s+chain|supplier\s+to\s+item\s+to\s+buyer|поставщик\s*->\s*товар\s*->\s*покупател|поставщик\s*->\s*товар\s*->\s*клиент|поставщик\s*->\s*товар\s*->\s*покупатель|поставщик\s+к\s+покупател|поставщик\s+к\s+клиент|поставщик\s+к\s+товару\s+и\s+покупателю)/iu.test( + /(?:документально\s+подтвержденн\w+\s+цепочк|supplier\s*->\s*item\s*->\s*buyer|supplier\s*->\s*item\s*->\s*customer|supplier\s*->\s*buyer|supplier\s+to\s+buyer|supplier\s+to\s+buyer\s+chain|supplier\s+to\s+item\s+to\s+buyer|поставщик\s*->\s*товар\s*->\s*покупател|поставщик\s*->\s*товар\s*->\s*клиент|поставщик\s*->\s*товар\s*->\s*покупатель|поставщик\s+Рє\s+покупател|поставщик\s+Рє\s+клиент|поставщик\s+Рє\s+товару\s+Рё\s+покупателю)/iu.test( text ) || text.includes("->"); return hasSupplierCue && hasBuyerCue && hasItemCue && hasChainCue; } +function hasCustomerRevenueRankingBridgeSignal(text: string): boolean { + const normalized = String(text ?? "").trim().toLowerCase(); + if (!normalized) { + return false; + } + const hasMoneyCue = + /(?:\u0434\u0435\u043d\u044c\u0433|\u0434\u0435\u043d\u0435\u0433|\u0432\u044b\u0440\u0443\u0447|\u0434\u043e\u0445\u043e\u0434|\u043e\u0431\u043e\u0440\u043e\u0442|revenue|turnover|money|inflow)/iu.test( + normalized + ); + if (!hasMoneyCue) { + return false; + } + return /(?:\u043a\u0442\u043e\s+(?:\u043d\u0430\u043c\s+)?(?:\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?\s+\u043f\u0440\u0438\u043d\u0435\u0441(?:\s+\u0434\u0435\u043d\u0435\u0433)?|\u043f\u0440\u0438\u043d\u0435\u0441\s+\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?\s+\u0434\u0435\u043d\u0435\u0433)|who\s+brought\s+(?:us\s+)?(?:the\s+)?most\s+money)/iu.test( + normalized + ); +} + +function hasInventoryProvenanceBridgeSignal(text: string): boolean { + const normalized = String(text ?? "").trim().toLowerCase(); + if (!normalized) { + return false; + } + const hasItemCue = + /(?:\u0442\u043e\u0432\u0430\u0440|\u043f\u043e\u0437\u0438\u0446|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440|sku|item|product)/iu.test( + normalized + ); + const hasSupplierCue = + /(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043e\u0442\s+\u043a\u0430\u043a\u043e\u0433\u043e|\u043a\u0442\u043e\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0438\u043b|supplier|vendor)/iu.test( + normalized + ); + const hasPurchaseCue = + /(?:\u043a\u0443\u043f\u043b\u0435\u043d|\u0437\u0430\u043a\u0443\u043f|\u043a\u043e\u0433\u0434\u0430\s+\u043a\u0443\u043f\u0438\u043b|\u043a\u0443\u043f\u0438\u043b\u0438|purchase)/iu.test( + normalized + ); + return hasItemCue && hasSupplierCue && hasPurchaseCue; +} + +function hasInventoryDocumentaryChainBridgeSignal(text: string): boolean { + const normalized = String(text ?? "").trim().toLowerCase(); + if (!normalized) { + return false; + } + const hasChainCue = + /(?:\u0446\u0435\u043f\u043e\u0447|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u043b|\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434|->|\u2192|chain|trace)/iu.test( + normalized + ); + const hasSupplierCue = /(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|supplier|vendor)/iu.test(normalized); + const hasBuyerCue = /(?:\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b|\u043a\u043b\u0438\u0435\u043d\u0442|buyer|customer|client)/iu.test(normalized); + const hasItemCue = + /(?:\u0442\u043e\u0432\u0430\u0440|\u043f\u043e\u0437\u0438\u0446|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440|sku|item|product)/iu.test( + normalized + ); + return hasChainCue && hasSupplierCue && hasBuyerCue && hasItemCue; +} + +function hasColloquialInventoryOnHandBridgeSignal(text: string): boolean { + const normalized = String(text ?? "").trim().toLowerCase(); + if (!normalized) { + return false; + } + const tokenCount = normalized.split(/\s+/u).filter(Boolean).length; + const hasWarehouseCue = + /(?:\u0441\u043a\u043b\u0430\u0434(?:\u0430\u0445|\u0435|\u0443|\u043e\u043c|\u044b)?|\u043e\u0441\u0442\u0430\u0442|warehouse|stock|inventory)/iu.test( + normalized + ); + if (!hasWarehouseCue) { + return false; + } + const hasQuestionCue = + /(?:\u0447\u0442\u043e|\u0447\u0435|\u0447\u0451|\u043a\u0430\u043a\u0438\u0435|\u043f\u043e\u043a\u0430\u0436\u0438|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c|show|list|what)/iu.test( + normalized + ); + return hasQuestionCue && tokenCount <= 8; +} + export function resolveAddressIntent(userMessage: string): AddressIntentResolution { const text = String(userMessage ?? "").trim().toLowerCase(); + if (hasCustomerRevenueRankingBridgeSignal(text)) { + return { + intent: "customer_revenue_and_payments", + confidence: "medium", + reasons: ["customer_revenue_ranking_bridge_signal_detected"] + }; + } + + if (hasInventoryDocumentaryChainBridgeSignal(text)) { + return { + intent: "inventory_purchase_to_sale_chain", + confidence: "medium", + reasons: ["inventory_documentary_chain_bridge_signal_detected"] + }; + } + + if (hasInventoryProvenanceBridgeSignal(text)) { + return { + intent: "inventory_purchase_provenance_for_item", + confidence: "medium", + reasons: ["inventory_provenance_bridge_signal_detected"] + }; + } + + if (hasColloquialInventoryOnHandBridgeSignal(text)) { + return { + intent: "inventory_on_hand_as_of_date", + confidence: "medium", + reasons: ["inventory_on_hand_colloquial_bridge_signal_detected"] + }; + } + if (hasVatLiabilityConfirmedTaxPeriodSignal(text)) { return { intent: "vat_liability_confirmed_for_tax_period", @@ -1907,167 +2014,6 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti return inventoryIntent; } - if ( - /(?:старым\s+закупк(?:ам|и|ах)|относится\s+ли\s+.*\s+к\s+старым\s+закупк(?:ам|и|ах)|очень\s+давно|давно\s+куплен|давно\s+приобретен|old\s+stock|old\s+purchase|aging\s+by\s+purchase\s+date)/iu.test( - text - ) - ) { - return { - intent: "inventory_aging_by_purchase_date", - confidence: "high", - reasons: ["inventory_aging_signal_detected_strong"] - }; - } - - if (hasInventoryAccount41Anchor(text) && hasInventoryAsOfCue(text)) { - return { - intent: "inventory_on_hand_as_of_date", - confidence: "high", - reasons: ["inventory_account_41_as_of_date_signal_detected"] - }; - } - - if ( - /(?:без\s+понятн(?:ой|ого)\s+привязк(?:и|а)\s+к\s+поставщик|без\s+привязк(?:и|а)\s+к\s+поставщик|unresolved\s+supplier\s+link)/iu.test( - text - ) - ) { - return { - intent: "inventory_supplier_stock_overlap_as_of_date", - confidence: "medium", - reasons: ["inventory_unresolved_provenance_signal_detected"] - }; - } - - if (hasInventorySupplierStockOverlapSignal(text)) { - return { - intent: "inventory_supplier_stock_overlap_as_of_date", - confidence: "medium", - reasons: ["inventory_supplier_stock_overlap_signal_detected"] - }; - } - - if ( - /(?:supplier\s*->\s*buyer|supplier\s+to\s+buyer|supplier\s+to\s+buyer\s+chain|поставщик\s+к\s+покупателю|поставщик\s*->\s*товар\s*->\s*покупател|документально\s+подтвержденн\w+\s+цепочк)/iu.test( - text - ) && - /(?:поставщик|supplier|vendor)/iu.test(text) && - /(?:покупател|buyer|customer|client)/iu.test(text) && - /(?:товар|номенклатур|sku|item|product)/iu.test(text) - ) { - return { - intent: "inventory_purchase_to_sale_chain", - confidence: "high", - reasons: ["inventory_supplier_to_buyer_chain_signal_detected_strong"] - }; - } - - if (hasInventoryPurchaseToSaleChainSignal(text)) { - return { - intent: "inventory_purchase_to_sale_chain", - confidence: "medium", - reasons: ["inventory_purchase_to_sale_chain_signal_detected"] - }; - } - - if (hasInventoryAgingSignal(text)) { - return { - intent: "inventory_aging_by_purchase_date", - confidence: "medium", - reasons: ["inventory_aging_signal_detected"] - }; - } - - if (hasSelectedObjectInventoryProvenanceSignal(text)) { - return { - intent: "inventory_purchase_provenance_for_item", - confidence: "medium", - reasons: ["inventory_selected_object_provenance_signal_detected"] - }; - } - - if (hasInventoryProvenanceSignalV2(text)) { - return { - intent: "inventory_purchase_provenance_for_item", - confidence: "medium", - reasons: ["inventory_provenance_signal_detected"] - }; - } - - if (hasInventoryPurchaseDateSignal(text)) { - return { - intent: "inventory_purchase_provenance_for_item", - confidence: "medium", - reasons: ["inventory_purchase_date_signal_detected"] - }; - } - - if (hasSelectedObjectInventoryPurchaseDocumentsSignal(text)) { - return { - intent: "inventory_purchase_documents_for_item", - confidence: "medium", - reasons: ["inventory_selected_object_purchase_documents_signal_detected"] - }; - } - - if (hasInventoryPurchaseDocumentsSignalV2(text)) { - return { - intent: "inventory_purchase_documents_for_item", - confidence: "medium", - reasons: ["inventory_purchase_documents_signal_detected"] - }; - } - - if (hasSelectedObjectInventoryProfitabilitySignal(text)) { - return { - intent: "inventory_profitability_for_item", - confidence: "medium", - reasons: ["inventory_selected_object_profitability_signal_detected"] - }; - } - - if (hasSelectedObjectInventorySaleTraceSignal(text)) { - return { - intent: "inventory_sale_trace_for_item", - confidence: "medium", - reasons: ["inventory_selected_object_sale_trace_signal_detected"] - }; - } - - if ( - /(?:кому\s+(?:мы\s+)?впарили(?:\s+(?:это|его|товар|позицию))?|кому\s+в\s+итоге\s+мы\s+впарили)/iu.test(text) && - /(?:товар|номенклатур|sku|item|product|позици(?:я|ю|и)|продукци(?:я|ю|и))/iu.test(text) - ) { - return { - intent: "inventory_sale_trace_for_item", - confidence: "medium", - reasons: ["inventory_sale_trace_signal_detected"] - }; - } - - if (hasInventorySaleTraceSignalV2(text)) { - return { - intent: "inventory_sale_trace_for_item", - confidence: "medium", - reasons: ["inventory_sale_trace_signal_detected"] - }; - } - - if (hasInventorySupplierToBuyerChainSignal(text)) { - return { - intent: "inventory_purchase_to_sale_chain", - confidence: "medium", - reasons: ["inventory_supplier_to_buyer_chain_signal_detected"] - }; - } - - if (hasInventoryOnHandSignal(text)) { - return { - intent: "inventory_on_hand_as_of_date", - confidence: "high", - reasons: ["inventory_on_hand_signal_detected"] - }; - } const counterpartyIntent = resolveCounterpartyAddressIntent(text, { hasAny, @@ -2107,211 +2053,6 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti if (counterpartyIntent) { return counterpartyIntent; } - - if (hasOpenContractsListSignal(text)) { - return { - intent: "open_contracts_confirmed_as_of_date", - confidence: "medium", - reasons: ["open_contract_signal_detected"] - }; - } - - if ( - hasAny(text, OPEN_ITEMS_HINTS) && - !hasCounterpartyDebtLongevitySignal(text) && - !hasInventoryAgingSignal(text) && - !hasInventoryProvenanceSignalV2(text) && - !hasInventoryPurchaseDocumentsSignalV2(text) && - !hasInventorySaleTraceSignalV2(text) && - ( - /(?:контраг|договор|контракт|counterparty|contract|покупател|клиент|заказчик|customer|client|buyer|supplier|поставщик)/iu.test( - text - ) || - hasAccountNumberAnchor(text) || - hasCompactAccountCodeToken(text) - ) - ) { - return { - intent: "open_items_by_counterparty_or_contract", - confidence: "medium", - reasons: ["open_items_signal_detected"] - }; - } - - if ( - hasPeriodCoverageProfileSignal(text) && - !hasPartyAnchorMention(text) && - !hasContractAnchorSignal(text) && - !hasAccountBalanceSignal(text) - ) { - return { - intent: "period_coverage_profile", - confidence: "high", - reasons: ["period_coverage_profile_signal_detected"] - }; - } - - if ( - hasDocumentTypeAndAccountSectionProfileSignal(text) && - !hasPartyAnchorMention(text) && - !hasContractAnchorSignal(text) && - !hasAccountBalanceSignal(text) - ) { - return { - intent: "document_type_and_account_section_profile", - confidence: "high", - reasons: ["document_type_and_account_section_profile_signal_detected"] - }; - } - - if ( - hasCounterpartyPopulationAndRolesSignal(text) && - !hasContractAnchorSignal(text) && - !hasAccountBalanceSignal(text) - ) { - return { - intent: "counterparty_population_and_roles", - confidence: "high", - reasons: ["counterparty_population_and_roles_signal_detected"] - }; - } - - if ( - hasCounterpartyActivityLifecycleSignal(text) && - !hasContractAnchorSignal(text) && - !hasAccountBalanceSignal(text) - ) { - return { - intent: "counterparty_activity_lifecycle", - confidence: "high", - reasons: ["counterparty_activity_lifecycle_signal_detected"] - }; - } - - if ( - hasContractUsageOverviewSignal(text) && - !hasAccountBalanceSignal(text) && - !hasOpenContractsListSignal(text) - ) { - return { - intent: "contract_usage_overview", - confidence: "high", - reasons: ["contract_usage_overview_signal_detected"] - }; - } - - if (hasCustomerRevenueAndPaymentsSignal(text) && !hasAccountBalanceSignal(text)) { - return { - intent: "customer_revenue_and_payments", - confidence: "high", - reasons: ["customer_revenue_and_payments_signal_detected"] - }; - } - - if (hasSupplierPayoutsProfileSignal(text) && !hasAccountBalanceSignal(text)) { - return { - intent: "supplier_payouts_profile", - confidence: "high", - reasons: ["supplier_payouts_profile_signal_detected"] - }; - } - - if ( - hasContractUsageAndValueSignal(text) && - !hasAccountBalanceSignal(text) && - !hasOpenContractsListSignal(text) - ) { - return { - intent: "contract_usage_and_value", - confidence: "high", - reasons: ["contract_usage_and_value_signal_detected"] - }; - } - - if (hasContractListByCounterpartySignal(text)) { - return { - intent: "list_contracts_by_counterparty", - confidence: "medium", - reasons: ["contracts_by_counterparty_signal_detected"] - }; - } - - if ( - hasContractAnchorSignal(text) && - hasBankOperationSignal(text) - ) { - return { - intent: "bank_operations_by_contract", - confidence: "medium", - reasons: ["bank_ops_by_contract_signal_detected"] - }; - } - - if ( - hasContractAnchorSignal(text) && - (hasAny(text, DOCUMENTS_BY_CONTRACT_HINTS) || hasDocumentSignal(text)) - ) { - return { - intent: "list_documents_by_contract", - confidence: "medium", - reasons: ["documents_by_contract_signal_detected"] - }; - } - - if ( - hasAny(text, BANK_OPERATIONS_BY_COUNTERPARTY_HINTS) && - (hasPartyAnchorMention(text) || hasLooseByAnchorMention(text) || hasHeuristicCounterpartyAnchor(text)) - ) { - return { - intent: "bank_operations_by_counterparty", - confidence: "medium", - reasons: ["bank_ops_by_counterparty_signal_detected"] - }; - } - - if ( - (hasAny(text, DOCUMENTS_BY_COUNTERPARTY_HINTS) || hasCounterpartyShipmentItemFlowSignal(text)) && - (hasPartyAnchorMention(text) || - hasLooseByAnchorMention(text) || - hasImplicitCounterpartyAnchorAroundDocs(text) || - hasHeuristicCounterpartyAnchor(text) || - hasCounterpartyShipmentItemFlowSignal(text)) - ) { - return { - intent: "list_documents_by_counterparty", - confidence: "medium", - reasons: [ - hasCounterpartyShipmentItemFlowSignal(text) - ? "counterparty_item_flow_signal_detected" - : "documents_by_counterparty_signal_detected" - ] - }; - } - - if (hasAccountBalanceSignal(text)) { - return { - intent: "account_balance_snapshot", - confidence: "high", - reasons: ["account_balance_signal_detected"] - }; - } - - if (hasLooseByAnchorMention(text) && hasGenericAddressLookupSignal(text)) { - return { - intent: "list_documents_by_counterparty", - confidence: "low", - reasons: ["generic_lookup_with_loose_anchor_fallback"] - }; - } - - if (hasAny(text, OPEN_CONTRACTS_HINTS) && (text.includes("договор") || text.includes("контракт") || text.includes("contract"))) { - return { - intent: "open_contracts_confirmed_as_of_date", - confidence: "medium", - reasons: ["open_contract_signal_detected"] - }; - } - return { intent: "unknown", confidence: "low", diff --git a/llm_normalizer/backend/src/services/addressInventoryIntentSignals.ts b/llm_normalizer/backend/src/services/addressInventoryIntentSignals.ts index dad9275..b6e04e6 100644 --- a/llm_normalizer/backend/src/services/addressInventoryIntentSignals.ts +++ b/llm_normalizer/backend/src/services/addressInventoryIntentSignals.ts @@ -16,6 +16,25 @@ function hasInventoryAsOfCue(text: string): boolean { ); } +function hasPlainRussianInventoryOnHandSignal(text: string): boolean { + const normalized = String(text ?? "").toLowerCase().replace(/ё/g, "е"); + if (!normalized) { + return false; + } + const hasStockCue = + /(?:остатк|склад|товар(?:ы|ов)?|номенклатур|позици|на\s+складе|по\s+складу)/iu.test(normalized); + if (!hasStockCue) { + return false; + } + const hasRequestCue = + /(?:какие|какой|какая|что|покажи|показать|получить|дай|выведи|чекни|посмотри|есть\s+ли)/iu.test(normalized); + const hasSnapshotCue = + /(?:на\s+(?:дату|сегодня|сейчас|март|апрел|май|мая|июн|июл|август|сентябр|октябр|ноябр|декабр|январ|феврал)|\b(?:19|20)\d{2}\b)/iu.test( + normalized + ); + return hasRequestCue && (hasSnapshotCue || /остатк/iu.test(normalized)); +} + function hasInventoryOnHandSignal(text: string): boolean { const hasColloquialStockSnapshotCue = /(?:что|С‡[еёо])\s+(?:Сѓ\s+нас\s+)?РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)(?=$|[\s,.;:!?])/iu.test( text @@ -26,7 +45,8 @@ function hasInventoryOnHandSignal(text: string): boolean { const hasAccount41Anchor = hasInventoryAccount41Anchor(text); const hasStockLexeme = /(?:склад(?:Рµ|Сѓ|РѕРј|С‹|РѕРІ)?|warehouse|stock(?:room)?|inventory|on[\s-]?hand)/iu.test(text); - if (!hasStockLexeme && !hasAccount41Anchor) { + const hasPlainRussianRootCue = hasPlainRussianInventoryOnHandSignal(text); + if (!hasStockLexeme && !hasAccount41Anchor && !hasPlainRussianRootCue) { return false; } if ( @@ -51,6 +71,9 @@ function hasInventoryOnHandSignal(text: string): boolean { if (hasAccount41Anchor && (hasGoodsLexeme || hasBalanceLexeme || hasRequestCue || hasInventoryAsOfCue(text))) { return true; } + if (hasPlainRussianRootCue) { + return true; + } return (hasGoodsLexeme || hasBalanceLexeme || hasColloquialStockSnapshotCue || hasStockStateCue) && (hasRequestCue || hasBalanceLexeme || hasColloquialStockSnapshotCue || hasStockStateCue); } @@ -263,6 +286,17 @@ export function resolveInventoryAddressIntent(text: string): AddressIntentResolu }; } + if ( + (text.includes("по этой позиции") || text.includes("по этому товару")) && + (text.includes("когда была закупка") || text.includes("когда купили")) + ) { + return { + intent: "inventory_purchase_provenance_for_item", + confidence: "medium", + reasons: ["inventory_purchase_date_signal_detected"] + }; + } + if (hasSelectedObjectInventoryPurchaseDocumentsSignal(text)) { return { intent: "inventory_purchase_documents_for_item", diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index 4b89cd2..adefd33 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -1816,6 +1816,32 @@ function isOrganizationScopedInventoryIntent(intent: AddressIntent): boolean { ); } +function shouldDeferInventoryOrganizationClarification( + intent: AddressIntent, + filters: AddressFilterSet, + semanticFrame: AddressSemanticFrame | null | undefined +): boolean { + if (!isOrganizationScopedInventoryIntent(intent)) { + return false; + } + const hasItemScope = + Boolean(toNonEmptyFilterValue(filters.item)) || + semanticFrame?.anchor_kind === "item" || + semanticFrame?.anchor_kind === "selected_object" || + semanticFrame?.selected_object_scope_detected === true; + if (hasItemScope) { + return true; + } + return ( + intent === "inventory_purchase_provenance_for_item" || + intent === "inventory_purchase_documents_for_item" || + intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || + intent === "inventory_purchase_to_sale_chain" || + intent === "inventory_aging_by_purchase_date" + ); +} + function collectOrganizationCandidatesFromRows(rows: NormalizedAddressRow[]): string[] { return mergeKnownOrganizations(rows.map((row) => row.organization).filter((value): value is string => Boolean(value))); } @@ -3224,6 +3250,7 @@ export class AddressQueryService { !toNonEmptyFilterValue(filters.extracted_filters.organization) && !activeOrganization && !resolvedOrganizationFromMessage && + !shouldDeferInventoryOrganizationClarification(intent.intent, filters.extracted_filters, semanticFrame) && knownOrganizations.length > 1 ) { return buildOrganizationClarificationExecutionResult({ diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index 1b6df56..556855f 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -13,6 +13,7 @@ import { type ComposeReplyResult, type ComposeReplySemantics } from "./replyPackaging"; +import { composeCounterpartyAnalyticsReply } from "./counterpartyAnalyticsReplyBuilders"; import { composeInventoryReply } from "./inventoryReplyBuilders"; export type { ComposeFactualReplyOptions, ComposeReplySemantics } from "./replyPackaging"; @@ -2751,6 +2752,31 @@ function composeFactualReplyBody( return inventoryReply; } + const counterpartyAnalyticsReply = composeCounterpartyAnalyticsReply(intent, rows, options, { + formatPercent, + formatDateRu, + formatMoneyRub, + extractYearFromIso, + detectCounterpartyProfileFocus, + detectCounterpartyLifecycleFocus, + hasCounterpartyLifecycleLongevityQuestion, + hasCounterpartyActivityAgeQuestion, + detectRankingLimit, + detectValueRankingFocus, + detectContractValueFocus, + detectMinOpsForAvgCheck, + extractRequestedYearFromQuestion, + extractCounterpartyName, + extractContractName, + counterpartyLookupMatches, + toUtcDayTimestamp, + formatAgeYearsMonthsDays, + normalizeQuestionText + }); + if (counterpartyAnalyticsReply) { + return counterpartyAnalyticsReply; + } + if (intent === "document_type_and_account_section_profile") { const rowsByMarker = new Map(); for (const row of rows) { @@ -3035,854 +3061,6 @@ function composeFactualReplyBody( }; } - if (intent === "counterparty_population_and_roles") { - const rowsByMarker = new Map(); - for (const row of rows) { - const marker = String(row.registrator ?? "").trim().toUpperCase(); - if (!marker) { - continue; - } - if (!rowsByMarker.has(marker)) { - rowsByMarker.set(marker, []); - } - rowsByMarker.get(marker)!.push(row); - } - - const sumMarker = (marker: string): number => - (rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0); - - const totalCounterparties = sumMarker("CP_TOTAL"); - const customerActive = sumMarker("CP_CUSTOMER_ACTIVE"); - const supplierActive = sumMarker("CP_SUPPLIER_ACTIVE"); - const mixedActive = sumMarker("CP_MIXED_ACTIVE"); - const activeUnion = sumMarker("CP_ACTIVE_UNION"); - - const customerOnly = Math.max(0, customerActive - mixedActive); - const supplierOnly = Math.max(0, supplierActive - mixedActive); - const resolvedActive = customerOnly + supplierOnly + mixedActive; - const activeCounterparties = Math.max(activeUnion, resolvedActive); - const otherCounterparties = totalCounterparties > 0 ? Math.max(0, totalCounterparties - resolvedActive) : null; - - const focus = detectCounterpartyProfileFocus(options.userMessage); - const includeTotal = focus === "full_profile" || focus === "total_only"; - const includeRoles = focus === "full_profile" || focus === "roles_only"; - const directLead = - focus === "suppliers_only" - ? `Поставщиков (только supplier-роль): ${supplierOnly}.` - : focus === "customers_only" - ? `Заказчиков (только customer-роль): ${customerOnly}.` - : focus === "mixed_only" - ? `Смешанных контрагентов (и customer, и supplier): ${mixedActive}.` - : includeTotal && totalCounterparties > 0 - ? `Всего уникальных контрагентов в базе: ${totalCounterparties}.` - : `Активных контрагентов по операциям: ${activeCounterparties}.`; - - const lines: string[] = [ - directLead, - "Профиль контрагентов собран (catalog + bank-doc activity aggregate).", - `Строк агрегата: ${rows.length}.` - ]; - - if (includeTotal) { - if (totalCounterparties > 0) { - lines.push(`Всего уникальных контрагентов в базе: ${totalCounterparties}.`); - } else if (activeCounterparties > 0) { - lines.push( - `Total из справочника не получен, оценка по активности в документах: ${activeCounterparties} контрагентов.` - ); - } else { - lines.push("По количеству контрагентов агрегатных строк не найдено."); - } - } - - if (includeRoles) { - if (resolvedActive > 0 || activeCounterparties > 0) { - lines.push("Роли контрагентов по активности:"); - lines.push(`1. Заказчики (только customer-роль): ${customerOnly}.`); - lines.push(`2. Поставщики (только supplier-роль): ${supplierOnly}.`); - lines.push(`3. Смешанные (и покупатель, и поставщик): ${mixedActive}.`); - lines.push(`4. Активные контрагенты (union ролей): ${activeCounterparties}.`); - if (otherCounterparties !== null) { - lines.push(`5. Прочие/неактивные в выбранном окне: ${otherCounterparties}.`); - } - } else { - lines.push("По role-split контрагентов агрегатных строк не найдено."); - } - } - - if (focus === "suppliers_only") { - lines.push(`Поставщиков (только supplier-роль): ${supplierOnly}.`); - } - if (focus === "customers_only") { - lines.push(`Заказчиков (только customer-роль): ${customerOnly}.`); - } - if (focus === "mixed_only") { - lines.push(`Смешанных контрагентов (и customer, и supplier): ${mixedActive}.`); - } - - return { - responseType: "FACTUAL_SUMMARY", - text: lines.join("\n") - }; - } - - if (intent === "counterparty_activity_lifecycle") { - const activityFirstRows = rows.filter( - (row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_FIRST" - ); - const activityRows = rows.filter( - (row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY" - ); - const activityYearRows = rows.filter( - (row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_YEAR" - ); - const byCounterparty = new Map< - string, - { - name: string; - opsCount: number; - lastPeriod: string | null; - firstPeriod: string | null; - firstObservedActivity: string | null; - years: Set; - } - >(); - - for (const row of activityFirstRows) { - const name = extractCounterpartyName(row); - if (!name) { - continue; - } - const opsCount = Math.max(0, Math.trunc(row.amount ?? 0)); - const year = extractYearFromIso(row.period); - const current = byCounterparty.get(name); - if (!current) { - byCounterparty.set(name, { - name, - opsCount, - lastPeriod: row.period, - firstPeriod: row.period, - firstObservedActivity: row.period, - years: new Set(year !== null ? [year] : []) - }); - continue; - } - if (!current.firstObservedActivity || (row.period ?? "") < current.firstObservedActivity) { - current.firstObservedActivity = row.period; - } - if ((row.period ?? "") < (current.firstPeriod ?? "")) { - current.firstPeriod = row.period; - } - if (year !== null) { - current.years.add(year); - } - } - - for (const row of activityYearRows) { - const name = extractCounterpartyName(row); - if (!name) { - continue; - } - const opsCount = Math.max(0, Math.trunc(row.amount ?? 0)); - const year = extractYearFromIso(row.period); - const current = byCounterparty.get(name); - if (!current) { - byCounterparty.set(name, { - name, - opsCount, - lastPeriod: row.period, - firstPeriod: row.period, - firstObservedActivity: null, - years: new Set(year !== null ? [year] : []) - }); - continue; - } - current.opsCount += opsCount; - if ((row.period ?? "") > (current.lastPeriod ?? "")) { - current.lastPeriod = row.period; - } - if ((row.period ?? "") < (current.firstPeriod ?? "")) { - current.firstPeriod = row.period; - } - if (year !== null) { - current.years.add(year); - } - } - - for (const row of activityRows) { - const name = extractCounterpartyName(row); - if (!name) { - continue; - } - const opsCount = Math.max(0, Math.trunc(row.amount ?? 0)); - const current = byCounterparty.get(name); - if (!current) { - const year = extractYearFromIso(row.period); - byCounterparty.set(name, { - name, - opsCount, - lastPeriod: row.period, - firstPeriod: row.period, - firstObservedActivity: row.period, - years: new Set(year !== null ? [year] : []) - }); - continue; - } - if (activityYearRows.length === 0 && opsCount > current.opsCount) { - current.opsCount = opsCount; - } - if ((row.period ?? "") > (current.lastPeriod ?? "")) { - current.lastPeriod = row.period; - } - if ((row.period ?? "") < (current.firstPeriod ?? "")) { - current.firstPeriod = row.period; - } - const year = extractYearFromIso(row.period); - if (year !== null) { - current.years.add(year); - } - } - - const counterpartiesRaw = Array.from(byCounterparty.values()); - const focus = detectCounterpartyLifecycleFocus(options.userMessage); - const requestedYear = extractRequestedYearFromQuestion(options.userMessage); - const longevityQuestion = hasCounterpartyLifecycleLongevityQuestion(options.userMessage); - const activityAgeQuestion = hasCounterpartyActivityAgeQuestion(options.userMessage); - const rankingLimit = detectRankingLimit(options.userMessage, 10); - const counterparties = counterpartiesRaw.sort((left, right) => { - if (longevityQuestion) { - const yearsDiff = right.years.size - left.years.size; - if (yearsDiff !== 0) { - return yearsDiff; - } - } - if (right.opsCount !== left.opsCount) { - return right.opsCount - left.opsCount; - } - return (right.lastPeriod ?? "").localeCompare(left.lastPeriod ?? ""); - }); - const scopeLabel = - focus === "active_customers_all_time" - ? "за все время" - : requestedYear - ? `в ${requestedYear} году` - : "в выбранном периоде"; - - if (activityAgeQuestion) { - const focusedCounterparty = - counterparties.find((item) => counterpartyLookupMatches(item.name, options.counterpartyHint)) ?? null; - if (focusedCounterparty) { - const firstObservedActivity = focusedCounterparty.firstObservedActivity ?? focusedCounterparty.firstPeriod; - const lastObservedActivity = focusedCounterparty.lastPeriod; - const firstTimestamp = toUtcDayTimestamp(firstObservedActivity); - const lastTimestamp = toUtcDayTimestamp(lastObservedActivity); - const observedDays = - firstTimestamp !== null && lastTimestamp !== null && lastTimestamp >= firstTimestamp - ? Math.floor((lastTimestamp - firstTimestamp) / 86_400_000) - : null; - const observedAgeLabel = - observedDays !== null - ? formatAgeYearsMonthsDays(observedDays) - : focusedCounterparty.years.size > 0 - ? `${focusedCounterparty.years.size} г.` - : null; - const directLine = - observedAgeLabel && firstObservedActivity && lastObservedActivity - ? `По активности в базе 1С контрагент ${focusedCounterparty.name} наблюдается минимум ${observedAgeLabel}.` - : `По активности в базе 1С контрагент ${focusedCounterparty.name} найден в подтвержденных движениях.`; - const lines: string[] = [directLine]; - if (firstObservedActivity) { - lines.push(`Первая подтвержденная активность: ${formatDateRu(firstObservedActivity)}.`); - } - if (lastObservedActivity) { - lines.push(`Последняя подтвержденная активность: ${formatDateRu(lastObservedActivity)}.`); - } - lines.push(`Подтвержденных операций в агрегате: ${focusedCounterparty.opsCount}.`); - if (focusedCounterparty.years.size > 0) { - const years = Array.from(focusedCounterparty.years).sort((a, b) => a - b); - lines.push(`Годы с активностью в базе: ${years.join(", ")}.`); - } - lines.push("Это возраст активности в 1С по подтвержденным движениям, а не дата регистрации юрлица."); - return { - responseType: "FACTUAL_SUMMARY", - text: lines.join("\n") - }; - } - const organizationHint = normalizeOrganizationScopeValue(options.organizationHint ?? null); - if (organizationHint && counterparties.length > 0) { - const organizationFirstObservedActivity = counterparties.reduce((earliest, item) => { - const candidate = item.firstObservedActivity ?? item.firstPeriod ?? null; - if (!candidate) { - return earliest; - } - if (!earliest || candidate < earliest) { - return candidate; - } - return earliest; - }, null); - const organizationLastObservedActivity = counterparties.reduce((latest, item) => { - const candidate = item.lastPeriod ?? item.firstPeriod ?? item.firstObservedActivity ?? null; - if (!candidate) { - return latest; - } - if (!latest || candidate > latest) { - return candidate; - } - return latest; - }, null); - const organizationYears = new Set(); - let organizationOpsCount = 0; - for (const item of counterparties) { - organizationOpsCount += item.opsCount; - for (const year of item.years) { - organizationYears.add(year); - } - } - const firstTimestamp = toUtcDayTimestamp(organizationFirstObservedActivity); - const lastTimestamp = toUtcDayTimestamp(organizationLastObservedActivity); - const observedDays = - firstTimestamp !== null && lastTimestamp !== null && lastTimestamp >= firstTimestamp - ? Math.floor((lastTimestamp - firstTimestamp) / 86_400_000) - : null; - const observedAgeLabel = - observedDays !== null - ? formatAgeYearsMonthsDays(observedDays) - : organizationYears.size > 0 - ? `${organizationYears.size} г.` - : null; - const lines: string[] = [ - observedAgeLabel && organizationFirstObservedActivity && organizationLastObservedActivity - ? `По активности организации ${organizationHint} в базе 1С наблюдается минимум ${observedAgeLabel}.` - : `По активности организации ${organizationHint} в базе 1С найдены подтвержденные движения.` - ]; - if (organizationFirstObservedActivity) { - lines.push(`Первая подтвержденная активность: ${formatDateRu(organizationFirstObservedActivity)}.`); - } - if (organizationLastObservedActivity) { - lines.push(`Последняя подтвержденная активность: ${formatDateRu(organizationLastObservedActivity)}.`); - } - lines.push(`Подтвержденных операций в агрегате: ${organizationOpsCount}.`); - if (organizationYears.size > 0) { - const years = Array.from(organizationYears).sort((a, b) => a - b); - lines.push(`Годы с активностью в базе: ${years.join(", ")}.`); - } - lines.push("Это возраст активности организации в 1С по подтвержденным движениям, а не дата регистрации юрлица."); - return { - responseType: "FACTUAL_SUMMARY", - text: lines.join("\n") - }; - } - } - - const lines: string[] = longevityQuestion - ? [ - `Заказчиков с самым длинным горизонтом сотрудничества (по годам): ${counterparties.length}.`, - "Собран lifecycle-профиль заказчиков: ранжирование по числу лет и частоте активности.", - `Строк агрегата: ${rows.length}.` - ] - : [ - `Активные заказчики ${scopeLabel}: ${counterparties.length}.`, - "Собран профиль активности заказчиков (bank-doc activity aggregate).", - `Строк агрегата: ${rows.length}.` - ]; - - if (counterparties.length === 0) { - lines.push( - longevityQuestion - ? "По доступному окну не удалось выделить заказчиков с подтвержденной длительностью сотрудничества по годам." - : "По выбранному окну активности заказчики не найдены." - ); - return { - responseType: "FACTUAL_SUMMARY", - text: lines.join("\n") - }; - } - - const visible = counterparties.slice(0, longevityQuestion ? rankingLimit : 120); - if (longevityQuestion) { - lines.push(`Топ-${visible.length} заказчиков по охвату лет и частоте операций:`); - } - lines.push( - ...visible.map((item, index) => { - const years = Array.from(item.years).sort((a, b) => a - b); - const yearsLabel = years.length > 0 ? ` | лет в базе: ${years.length} | годы: ${years.join(", ")}` : ""; - const periodSpan = - item.firstPeriod && item.lastPeriod ? ` | период: ${item.firstPeriod}..${item.lastPeriod}` : ""; - if (longevityQuestion) { - return `${index + 1}. ${item.name} | операций: ${item.opsCount}${yearsLabel}${periodSpan}`; - } - const suffix = item.lastPeriod ? ` | последняя активность: ${item.lastPeriod}` : ""; - return `${index + 1}. ${item.name} | операций: ${item.opsCount}${suffix}${years.length > 0 ? ` | лет в базе: ${years.length}` : ""}`; - }) - ); - if (counterparties.length > visible.length) { - lines.push( - longevityQuestion - ? `Показаны первые ${visible.length} из ${counterparties.length} заказчиков (полный список можно выгрузить отдельно).` - : `Показаны первые ${visible.length} из ${counterparties.length} заказчиков.` - ); - } - - return { - responseType: "FACTUAL_LIST", - text: lines.join("\n") - }; - } - - if (intent === "contract_usage_overview") { - const rowsByMarker = new Map(); - for (const row of rows) { - const marker = String(row.registrator ?? "").trim().toUpperCase(); - if (!marker) { - continue; - } - if (!rowsByMarker.has(marker)) { - rowsByMarker.set(marker, []); - } - rowsByMarker.get(marker)!.push(row); - } - - const sumMarker = (marker: string): number => - (rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0); - - const totalContracts = sumMarker("CT_TOTAL"); - const usedContracts = sumMarker("CT_USED"); - const unusedContracts = - totalContracts > 0 ? Math.max(0, totalContracts - Math.min(usedContracts, totalContracts)) : null; - const usedShare = totalContracts > 0 ? formatPercent(Math.min(usedContracts, totalContracts), totalContracts) : null; - - const usageLead = - totalContracts > 0 - ? `Использованных договоров: ${usedContracts} из ${totalContracts}${usedShare ? ` (${usedShare})` : ""}.` - : `Использованных договоров (есть factual связь с операциями): ${usedContracts}.`; - - const lines: string[] = [ - usageLead, - "Профиль договорной базы собран (catalog + usage aggregate).", - `Строк агрегата: ${rows.length}.` - ]; - - if (totalContracts > 0) { - lines.push(`Всего договоров в базе: ${totalContracts}.`); - } else { - lines.push("Общее количество договоров не получено (пустой/недоступный срез справочника)."); - } - - lines.push(`Использованных договоров (есть factual связь с операциями): ${usedContracts}.`); - - if (unusedContracts !== null) { - lines.push(`Неиспользуемых договоров: ${unusedContracts}.`); - } - if (usedShare) { - lines.push(`Доля используемых договоров: ${usedShare}.`); - } - - return { - responseType: "FACTUAL_SUMMARY", - text: lines.join("\n") - }; - } - - if (intent === "customer_revenue_and_payments" || intent === "supplier_payouts_profile") { - const isSupplier = intent === "supplier_payouts_profile"; - const focus = detectValueRankingFocus(options.userMessage); - const limit = detectRankingLimit(options.userMessage, 20); - const minOpsForAvgCheck = detectMinOpsForAvgCheck(options.userMessage); - const normalizedQuestion = normalizeQuestionText(options.userMessage); - - const byCounterparty = new Map< - string, - { - name: string; - total: number; - ops: number; - maxSingle: number; - minSingle: number; - lastPeriod: string | null; - } - >(); - const byYear = new Map< - number, - { - year: number; - total: number; - ops: number; - maxSingle: number; - counterparties: Set; - } - >(); - const deals: Array<{ period: string | null; registrator: string; counterparty: string; amount: number }> = []; - - for (const row of rows) { - const counterparty = extractCounterpartyName(row); - const amount = row.amount ?? 0; - if (!counterparty || !Number.isFinite(amount) || amount <= 0) { - continue; - } - - const current = byCounterparty.get(counterparty); - if (!current) { - byCounterparty.set(counterparty, { - name: counterparty, - total: amount, - ops: 1, - maxSingle: amount, - minSingle: amount, - lastPeriod: row.period - }); - } else { - current.total += amount; - current.ops += 1; - current.maxSingle = Math.max(current.maxSingle, amount); - current.minSingle = Math.min(current.minSingle, amount); - if ((row.period ?? "") > (current.lastPeriod ?? "")) { - current.lastPeriod = row.period; - } - } - deals.push({ - period: row.period, - registrator: row.registrator, - counterparty, - amount - }); - - const year = extractYearFromIso(row.period); - if (year !== null) { - const yearBucket = byYear.get(year); - if (!yearBucket) { - byYear.set(year, { - year, - total: amount, - ops: 1, - maxSingle: amount, - counterparties: new Set([counterparty]) - }); - } else { - yearBucket.total += amount; - yearBucket.ops += 1; - yearBucket.maxSingle = Math.max(yearBucket.maxSingle, amount); - yearBucket.counterparties.add(counterparty); - } - } - } - - const profileRows = Array.from(byCounterparty.values()); - const yearRows = Array.from(byYear.values()); - const totalFlow = profileRows.reduce((sum, item) => sum + item.total, 0); - const totalOperations = profileRows.reduce((sum, item) => sum + item.ops, 0); - const rankedByTotal = [...profileRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.name.localeCompare(b.name)); - const rankedByYearTotal = [...yearRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.year - b.year); - const rankedByOps = [...profileRows].sort((a, b) => b.ops - a.ops || b.total - a.total || a.name.localeCompare(b.name)); - const rankedByMaxSingle = [...profileRows].sort( - (a, b) => b.maxSingle - a.maxSingle || b.total - a.total || a.name.localeCompare(b.name) - ); - const rankedByAvgCheck = [...profileRows] - .filter((item) => item.ops >= minOpsForAvgCheck) - .map((item) => ({ - ...item, - avgCheck: item.total / item.ops - })) - .sort((a, b) => b.avgCheck - a.avgCheck || b.total - a.total || a.name.localeCompare(b.name)); - - const rankedDealsTop = [...deals].sort( - (a, b) => b.amount - a.amount || (b.period ?? "").localeCompare(a.period ?? "") - ); - const activeOnlyForBottomDeals = /(?:активн|active)/iu.test(normalizedQuestion); - const activeCounterpartiesForBottom = new Set( - profileRows.filter((item) => item.ops >= Math.max(3, minOpsForAvgCheck)).map((item) => item.name) - ); - const rankedDealsBottom = [...deals] - .filter((item) => !activeOnlyForBottomDeals || activeCounterpartiesForBottom.has(item.counterparty)) - .sort((a, b) => a.amount - b.amount || (a.period ?? "").localeCompare(b.period ?? "")); - - const lines: string[] = [ - isSupplier - ? "Собран профиль выплат поставщикам (bank-doc value aggregate)." - : "Собран профиль поступлений от заказчиков (bank-doc value aggregate).", - `Строк источника: ${rows.length}.`, - `Уникальных контрагентов: ${profileRows.length}.` - ]; - - if (profileRows.length === 0) { - lines.push("По выбранному окну данных платежные строки не найдены."); - return { - responseType: "FACTUAL_SUMMARY", - text: lines.join("\n") - }; - } - - if (focus === "total_flow") { - const periodLine = - options.periodFrom && options.periodTo - ? `За период ${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)} подтверждено ${formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.` - : `За все доступное время подтверждено ${formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`; - const directAnswerLine = isSupplier - ? periodLine - : `${periodLine} Это сумма денег, полученных от клиентов, а не чистая прибыль.`; - const summaryLines = [ - directAnswerLine, - "", - "Подтверждение:", - `- Операций в выборке: ${totalOperations}.`, - `- Контрагентов в выборке: ${profileRows.length}.` - ]; - if (rankedByYearTotal.length > 0) { - summaryLines.push(`- Самый сильный год по поступлениям: ${rankedByYearTotal[0].year} (${formatMoneyRub(rankedByYearTotal[0].total)}).`); - } - if (rankedByTotal.length > 0) { - summaryLines.push(`- Крупнейший контрагент по потоку: ${rankedByTotal[0].name} (${formatMoneyRub(rankedByTotal[0].total)}).`); - } - return { - responseType: "FACTUAL_SUMMARY", - text: summaryLines.join("\n") - }; - } - - if (focus === "top_years_by_total") { - const visible = rankedByYearTotal.slice(0, limit); - const heading = isSupplier - ? `Топ-${visible.length} лет по сумме выплат:` - : `Топ-${visible.length} лет по сумме поступлений:`; - lines.unshift(heading); - if (visible.length === 0) { - lines.push("По доступному окну не удалось собрать годовые агрегаты по суммам."); - } else { - lines.push( - ...visible.map( - (item, index) => - `${index + 1}. ${item.year} | сумма: ${item.total} | операций: ${item.ops} | контрагентов: ${item.counterparties.size} | макс: ${item.maxSingle}` - ) - ); - } - return { - responseType: "FACTUAL_LIST", - text: lines.join("\n") - }; - } - - if (focus === "top_by_ops") { - const visible = rankedByOps.slice(0, limit); - const heading = isSupplier - ? `Топ-${visible.length} поставщиков по количеству исходящих платежных операций:` - : `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`; - lines.unshift(heading); - lines.push( - ...visible.map( - (item, index) => `${index + 1}. ${item.name} | операций: ${item.ops} | сумма: ${item.total} | макс: ${item.maxSingle}` - ) - ); - return { - responseType: "FACTUAL_LIST", - text: lines.join("\n") - }; - } - - if (focus === "top_by_max_single") { - const visible = rankedByMaxSingle.slice(0, limit); - const heading = isSupplier - ? `Топ-${visible.length} поставщиков по максимальной разовой выплате:` - : `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`; - lines.unshift(heading); - lines.push( - ...visible.map((item, index) => `${index + 1}. ${item.name} | max single: ${item.maxSingle} | сумма: ${item.total} | операций: ${item.ops}`) - ); - return { - responseType: "FACTUAL_LIST", - text: lines.join("\n") - }; - } - - if (focus === "top_by_avg_check_min_ops") { - const visible = rankedByAvgCheck.slice(0, limit); - const heading = isSupplier - ? `Топ-${visible.length} поставщиков по среднему чеку (минимум ${minOpsForAvgCheck} операций):` - : `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`; - lines.unshift(heading); - if (visible.length === 0) { - lines.push(`Контрагентов с минимум ${minOpsForAvgCheck} операций не найдено.`); - } else { - lines.push( - ...visible.map( - (item, index) => - `${index + 1}. ${item.name} | средний чек: ${item.avgCheck.toFixed(2)} | операций: ${item.ops} | сумма: ${item.total}` - ) - ); - } - return { - responseType: "FACTUAL_LIST", - text: lines.join("\n") - }; - } - - if (focus === "top_deals") { - const visible = rankedDealsTop.slice(0, limit); - const heading = isSupplier - ? `Топ-${visible.length} самых крупных разовых выплат поставщикам:` - : `Топ-${visible.length} самых крупных разовых сделок по поступлениям:`; - lines.unshift(heading); - lines.push( - ...visible.map( - (item, index) => `${index + 1}. ${item.period ?? "n/a"} | ${item.counterparty} | ${item.registrator} | ${item.amount}` - ) - ); - return { - responseType: "FACTUAL_LIST", - text: lines.join("\n") - }; - } - - if (focus === "bottom_deals") { - const visible = rankedDealsBottom.slice(0, limit); - const heading = isSupplier - ? `Топ-${visible.length} самых маленьких разовых выплат:` - : `Топ-${visible.length} самых маленьких разовых сделок по поступлениям:`; - lines.unshift(heading); - if (activeOnlyForBottomDeals) { - lines.push("Фильтр: только активные контрагенты (минимум 3 операции)."); - } - lines.push( - ...visible.map( - (item, index) => `${index + 1}. ${item.period ?? "n/a"} | ${item.counterparty} | ${item.registrator} | ${item.amount}` - ) - ); - return { - responseType: "FACTUAL_LIST", - text: lines.join("\n") - }; - } - - const visible = rankedByTotal.slice(0, limit); - const heading = isSupplier - ? `Топ-${visible.length} поставщиков по сумме выплат:` - : `Топ-${visible.length} заказчиков по сумме поступлений:`; - lines.unshift(heading); - lines.push( - ...visible.map((item, index) => { - const avgCheck = item.ops > 0 ? (item.total / item.ops).toFixed(2) : "0"; - return `${index + 1}. ${item.name} | сумма: ${item.total} | операций: ${item.ops} | средний чек: ${avgCheck} | макс: ${item.maxSingle}`; - }) - ); - return { - responseType: "FACTUAL_LIST", - text: lines.join("\n") - }; - } - - if (intent === "contract_usage_and_value") { - const focus = detectContractValueFocus(options.userMessage); - const limit = detectRankingLimit(options.userMessage, 20); - const byContract = new Map< - string, - { - contract: string; - turnover: number; - docs: number; - lastPeriod: string | null; - counterparties: Set; - } - >(); - - for (const row of rows) { - const contract = extractContractName(row); - const amount = row.amount ?? 0; - if (!contract || !Number.isFinite(amount) || amount <= 0) { - continue; - } - const counterparty = extractCounterpartyName(row); - const current = byContract.get(contract); - if (!current) { - byContract.set(contract, { - contract, - turnover: amount, - docs: 1, - lastPeriod: row.period, - counterparties: new Set(counterparty ? [counterparty] : []) - }); - } else { - current.turnover += amount; - current.docs += 1; - if ((row.period ?? "") > (current.lastPeriod ?? "")) { - current.lastPeriod = row.period; - } - if (counterparty) { - current.counterparties.add(counterparty); - } - } - } - - const contractRows = Array.from(byContract.values()); - const rankedByTurnover = [...contractRows].sort( - (a, b) => b.turnover - a.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract) - ); - const rankedByDocs = [...contractRows].sort( - (a, b) => b.docs - a.docs || b.turnover - a.turnover || a.contract.localeCompare(b.contract) - ); - const rankedBottomActive = [...contractRows] - .filter((item) => item.docs > 0 && item.turnover > 0) - .sort((a, b) => a.turnover - b.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract)); - - const lines: string[] = [ - `Активных договоров: ${contractRows.length}.`, - "Собран профиль договоров по обороту/бюджету (bank-doc contract aggregate).", - `Строк источника: ${rows.length}.`, - `Договорных агрегатов: ${contractRows.length}.` - ]; - - if (contractRows.length === 0) { - lines.push("В выбранном окне не найдено операций, связанных с договорами."); - return { - responseType: "FACTUAL_SUMMARY", - text: lines.join("\n") - }; - } - - if (focus === "top_by_docs") { - const visible = rankedByDocs.slice(0, limit); - const heading = `Топ-${visible.length} договоров по количеству операций:`; - lines.unshift(heading); - lines.push( - ...visible.map( - (item, index) => - `${index + 1}. ${item.contract} | операций: ${item.docs} | оборот: ${item.turnover} | контрагентов: ${item.counterparties.size}` - ) - ); - return { - responseType: "FACTUAL_LIST", - text: lines.join("\n") - }; - } - - if (focus === "bottom_by_turnover_active") { - const visible = rankedBottomActive.slice(0, limit); - const heading = `Топ-${visible.length} активных договоров с минимальным бюджетом (оборотом):`; - lines.unshift(heading); - lines.push( - ...visible.map( - (item, index) => - `${index + 1}. ${item.contract} | оборот: ${item.turnover} | операций: ${item.docs} | последняя активность: ${item.lastPeriod ?? "n/a"}` - ) - ); - return { - responseType: "FACTUAL_LIST", - text: lines.join("\n") - }; - } - - const visible = rankedByTurnover.slice(0, limit); - const heading = `Топ-${visible.length} договоров по сумме оборота:`; - lines.unshift(heading); - lines.push( - ...visible.map( - (item, index) => - `${index + 1}. ${item.contract} | оборот: ${item.turnover} | операций: ${item.docs} | контрагентов: ${item.counterparties.size} | последняя активность: ${item.lastPeriod ?? "n/a"}` - ) - ); - return { - responseType: "FACTUAL_LIST", - text: lines.join("\n") - }; - } - if (intent === "vat_payable_forecast") { const rowsByMarker = new Map(); for (const row of rows) { diff --git a/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts new file mode 100644 index 0000000..cc35e6f --- /dev/null +++ b/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts @@ -0,0 +1,847 @@ +import { normalizeOrganizationScopeValue } from "../assistantOrganizationMatcher"; +import type { AddressIntent } from "../../types/addressQuery"; +import { buildFactualListReply, buildFactualSummaryReply } from "./replyContracts"; +import type { ComposeFactualReplyOptions, ComposeReplyResult } from "./replyPackaging"; +import type { ComposeStageRow } from "./composeStage"; + +type CounterpartyProfileFocus = + | "full_profile" + | "total_only" + | "roles_only" + | "suppliers_only" + | "customers_only" + | "mixed_only"; +type CounterpartyLifecycleFocus = "active_customers_period" | "active_customers_all_time"; +type ValueRankingFocus = + | "top_by_total" + | "total_flow" + | "top_years_by_total" + | "top_by_ops" + | "top_by_max_single" + | "top_by_avg_check_min_ops" + | "top_deals" + | "bottom_deals"; +type ContractValueFocus = "top_by_turnover" | "bottom_by_turnover_active" | "top_by_docs"; + +interface CounterpartyActivityPoint { + name: string; + opsCount: number; + lastPeriod: string | null; + firstPeriod: string | null; + firstObservedActivity: string | null; + years: Set; +} + +interface CounterpartyValuePoint { + name: string; + total: number; + ops: number; + maxSingle: number; + minSingle: number; + lastPeriod: string | null; +} + +interface CounterpartyYearPoint { + year: number; + total: number; + ops: number; + maxSingle: number; + counterparties: Set; +} + +interface CounterpartyDealPoint { + period: string | null; + registrator: string; + counterparty: string; + amount: number; +} + +interface ContractValuePoint { + contract: string; + turnover: number; + docs: number; + lastPeriod: string | null; + counterparties: Set; +} + +interface CounterpartyAnalyticsReplyDeps { + formatPercent: (value: number, total: number) => string | null; + formatDateRu: (isoDate: string) => string; + formatMoneyRub: (value: number) => string; + extractYearFromIso: (value: string | null) => number | null; + detectCounterpartyProfileFocus: (userMessage: string | null | undefined) => CounterpartyProfileFocus; + detectCounterpartyLifecycleFocus: (userMessage: string | null | undefined) => CounterpartyLifecycleFocus; + hasCounterpartyLifecycleLongevityQuestion: (userMessage: string | null | undefined) => boolean; + hasCounterpartyActivityAgeQuestion: (userMessage: string | null | undefined) => boolean; + detectRankingLimit: (userMessage: string | null | undefined, defaultLimit?: number) => number; + detectValueRankingFocus: (userMessage: string | null | undefined) => ValueRankingFocus; + detectContractValueFocus: (userMessage: string | null | undefined) => ContractValueFocus; + detectMinOpsForAvgCheck: (userMessage: string | null | undefined) => number; + extractRequestedYearFromQuestion: (userMessage: string | null | undefined) => number | null; + extractCounterpartyName: (row: ComposeStageRow) => string | null; + extractContractName: (row: ComposeStageRow) => string | null; + counterpartyLookupMatches: (candidate: string | null | undefined, hint: string | null | undefined) => boolean; + toUtcDayTimestamp: (isoDate: string | null | undefined) => number | null; + formatAgeYearsMonthsDays: (daysRaw: number) => string; + normalizeQuestionText: (value: string | null | undefined) => string; +} + +function groupRowsByMarker(rows: ComposeStageRow[]): Map { + const rowsByMarker = new Map(); + for (const row of rows) { + const marker = String(row.registrator ?? "").trim().toUpperCase(); + if (!marker) { + continue; + } + if (!rowsByMarker.has(marker)) { + rowsByMarker.set(marker, []); + } + rowsByMarker.get(marker)!.push(row); + } + return rowsByMarker; +} + +function formatOptionalDate(value: string | null, formatDateRu: (isoDate: string) => string): string { + return value ? formatDateRu(value) : "дата не указана"; +} + +export function composeCounterpartyAnalyticsReply( + intent: AddressIntent, + rows: ComposeStageRow[], + options: ComposeFactualReplyOptions = {}, + deps: CounterpartyAnalyticsReplyDeps +): ComposeReplyResult | null { + if (intent === "counterparty_population_and_roles") { + const rowsByMarker = groupRowsByMarker(rows); + const sumMarker = (marker: string): number => + (rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0); + + const totalCounterparties = sumMarker("CP_TOTAL"); + const customerActive = sumMarker("CP_CUSTOMER_ACTIVE"); + const supplierActive = sumMarker("CP_SUPPLIER_ACTIVE"); + const mixedActive = sumMarker("CP_MIXED_ACTIVE"); + const activeUnion = sumMarker("CP_ACTIVE_UNION"); + + const customerOnly = Math.max(0, customerActive - mixedActive); + const supplierOnly = Math.max(0, supplierActive - mixedActive); + const resolvedActive = customerOnly + supplierOnly + mixedActive; + const activeCounterparties = Math.max(activeUnion, resolvedActive); + const otherCounterparties = totalCounterparties > 0 ? Math.max(0, totalCounterparties - resolvedActive) : null; + + const focus = deps.detectCounterpartyProfileFocus(options.userMessage); + const includeTotal = focus === "full_profile" || focus === "total_only"; + const includeRoles = focus === "full_profile" || focus === "roles_only"; + const directLead = + focus === "suppliers_only" + ? `Контрагентов только в роли поставщика: ${supplierOnly}.` + : focus === "customers_only" + ? `Контрагентов только в роли заказчика: ${customerOnly}.` + : focus === "mixed_only" + ? `Контрагентов со смешанной ролью: ${mixedActive}.` + : includeTotal && totalCounterparties > 0 + ? `Всего уникальных контрагентов в базе: ${totalCounterparties}.` + : `Активных контрагентов по документальной активности: ${activeCounterparties}.`; + + const lines: string[] = [ + directLead, + "Профиль контрагентов собран по справочнику и документальной активности.", + `Строк агрегата: ${rows.length}.` + ]; + + if (includeTotal) { + if (totalCounterparties > 0) { + lines.push(`Всего уникальных контрагентов в базе: ${totalCounterparties}.`); + } else if (activeCounterparties > 0) { + lines.push(`Полный итог по справочнику не получен, поэтому даю оценку по документальной активности: ${activeCounterparties}.`); + } else { + lines.push("По количеству контрагентов агрегатных строк не найдено."); + } + } + + if (includeRoles) { + if (resolvedActive > 0 || activeCounterparties > 0) { + lines.push("Распределение ролей по активности:"); + lines.push(`1. Только заказчики: ${customerOnly}.`); + lines.push(`2. Только поставщики: ${supplierOnly}.`); + lines.push(`3. И заказчики, и поставщики: ${mixedActive}.`); + lines.push(`4. Всего активных контрагентов: ${activeCounterparties}.`); + if (otherCounterparties !== null) { + lines.push(`5. Прочие или неактивные в выбранном окне: ${otherCounterparties}.`); + } + } else { + lines.push("По распределению ролей агрегатных строк не найдено."); + } + } + + if (focus === "suppliers_only") { + lines.push(`Контрагентов только в роли поставщика: ${supplierOnly}.`); + } + if (focus === "customers_only") { + lines.push(`Контрагентов только в роли заказчика: ${customerOnly}.`); + } + if (focus === "mixed_only") { + lines.push(`Контрагентов со смешанной ролью: ${mixedActive}.`); + } + + return buildFactualSummaryReply(lines); + } + + if (intent === "counterparty_activity_lifecycle") { + const activityFirstRows = rows.filter( + (row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_FIRST" + ); + const activityRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY"); + const activityYearRows = rows.filter( + (row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_YEAR" + ); + const byCounterparty = new Map(); + + for (const row of activityFirstRows) { + const name = deps.extractCounterpartyName(row); + if (!name) { + continue; + } + const opsCount = Math.max(0, Math.trunc(row.amount ?? 0)); + const year = deps.extractYearFromIso(row.period); + const current = byCounterparty.get(name); + if (!current) { + byCounterparty.set(name, { + name, + opsCount, + lastPeriod: row.period, + firstPeriod: row.period, + firstObservedActivity: row.period, + years: new Set(year !== null ? [year] : []) + }); + continue; + } + if (!current.firstObservedActivity || (row.period ?? "") < current.firstObservedActivity) { + current.firstObservedActivity = row.period; + } + if ((row.period ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = row.period; + } + if (year !== null) { + current.years.add(year); + } + } + + for (const row of activityYearRows) { + const name = deps.extractCounterpartyName(row); + if (!name) { + continue; + } + const opsCount = Math.max(0, Math.trunc(row.amount ?? 0)); + const year = deps.extractYearFromIso(row.period); + const current = byCounterparty.get(name); + if (!current) { + byCounterparty.set(name, { + name, + opsCount, + lastPeriod: row.period, + firstPeriod: row.period, + firstObservedActivity: null, + years: new Set(year !== null ? [year] : []) + }); + continue; + } + current.opsCount += opsCount; + if ((row.period ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = row.period; + } + if ((row.period ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = row.period; + } + if (year !== null) { + current.years.add(year); + } + } + + for (const row of activityRows) { + const name = deps.extractCounterpartyName(row); + if (!name) { + continue; + } + const opsCount = Math.max(0, Math.trunc(row.amount ?? 0)); + const current = byCounterparty.get(name); + if (!current) { + const year = deps.extractYearFromIso(row.period); + byCounterparty.set(name, { + name, + opsCount, + lastPeriod: row.period, + firstPeriod: row.period, + firstObservedActivity: row.period, + years: new Set(year !== null ? [year] : []) + }); + continue; + } + if (activityYearRows.length === 0 && opsCount > current.opsCount) { + current.opsCount = opsCount; + } + if ((row.period ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = row.period; + } + if ((row.period ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = row.period; + } + const year = deps.extractYearFromIso(row.period); + if (year !== null) { + current.years.add(year); + } + } + + const counterpartiesRaw = Array.from(byCounterparty.values()); + const focus = deps.detectCounterpartyLifecycleFocus(options.userMessage); + const requestedYear = deps.extractRequestedYearFromQuestion(options.userMessage); + const longevityQuestion = deps.hasCounterpartyLifecycleLongevityQuestion(options.userMessage); + const activityAgeQuestion = deps.hasCounterpartyActivityAgeQuestion(options.userMessage); + const rankingLimit = deps.detectRankingLimit(options.userMessage, 10); + const counterparties = counterpartiesRaw.sort((left, right) => { + if (longevityQuestion) { + const yearsDiff = right.years.size - left.years.size; + if (yearsDiff !== 0) { + return yearsDiff; + } + } + if (right.opsCount !== left.opsCount) { + return right.opsCount - left.opsCount; + } + return (right.lastPeriod ?? "").localeCompare(left.lastPeriod ?? ""); + }); + const scopeLabel = + focus === "active_customers_all_time" + ? "за все время" + : requestedYear + ? `в ${requestedYear} году` + : "в выбранном периоде"; + + if (activityAgeQuestion) { + const focusedCounterparty = + counterparties.find((item) => deps.counterpartyLookupMatches(item.name, options.counterpartyHint)) ?? null; + if (focusedCounterparty) { + const firstObservedActivity = focusedCounterparty.firstObservedActivity ?? focusedCounterparty.firstPeriod; + const lastObservedActivity = focusedCounterparty.lastPeriod; + const firstTimestamp = deps.toUtcDayTimestamp(firstObservedActivity); + const lastTimestamp = deps.toUtcDayTimestamp(lastObservedActivity); + const observedDays = + firstTimestamp !== null && lastTimestamp !== null && lastTimestamp >= firstTimestamp + ? Math.floor((lastTimestamp - firstTimestamp) / 86_400_000) + : null; + const observedAgeLabel = + observedDays !== null + ? deps.formatAgeYearsMonthsDays(observedDays) + : focusedCounterparty.years.size > 0 + ? `${focusedCounterparty.years.size} г.` + : null; + const lines: string[] = [ + observedAgeLabel && firstObservedActivity && lastObservedActivity + ? `По активности в базе 1С контрагент ${focusedCounterparty.name} наблюдается минимум ${observedAgeLabel}.` + : `По активности в базе 1С контрагент ${focusedCounterparty.name} найден в подтвержденных движениях.` + ]; + if (firstObservedActivity) { + lines.push(`Первая подтвержденная активность: ${deps.formatDateRu(firstObservedActivity)}.`); + } + if (lastObservedActivity) { + lines.push(`Последняя подтвержденная активность: ${deps.formatDateRu(lastObservedActivity)}.`); + } + lines.push(`Подтвержденных операций в агрегате: ${focusedCounterparty.opsCount}.`); + if (focusedCounterparty.years.size > 0) { + const years = Array.from(focusedCounterparty.years).sort((a, b) => a - b); + lines.push(`Годы с активностью в базе: ${years.join(", ")}.`); + } + lines.push("Это возраст активности в 1С по подтвержденным движениям, а не дата регистрации юрлица."); + return buildFactualSummaryReply(lines); + } + + const organizationHint = normalizeOrganizationScopeValue(options.organizationHint ?? null); + if (organizationHint && counterparties.length > 0) { + const organizationFirstObservedActivity = counterparties.reduce((earliest, item) => { + const candidate = item.firstObservedActivity ?? item.firstPeriod ?? null; + if (!candidate) { + return earliest; + } + if (!earliest || candidate < earliest) { + return candidate; + } + return earliest; + }, null); + const organizationLastObservedActivity = counterparties.reduce((latest, item) => { + const candidate = item.lastPeriod ?? item.firstPeriod ?? item.firstObservedActivity ?? null; + if (!candidate) { + return latest; + } + if (!latest || candidate > latest) { + return candidate; + } + return latest; + }, null); + const organizationYears = new Set(); + let organizationOpsCount = 0; + for (const item of counterparties) { + organizationOpsCount += item.opsCount; + for (const year of item.years) { + organizationYears.add(year); + } + } + const firstTimestamp = deps.toUtcDayTimestamp(organizationFirstObservedActivity); + const lastTimestamp = deps.toUtcDayTimestamp(organizationLastObservedActivity); + const observedDays = + firstTimestamp !== null && lastTimestamp !== null && lastTimestamp >= firstTimestamp + ? Math.floor((lastTimestamp - firstTimestamp) / 86_400_000) + : null; + const observedAgeLabel = + observedDays !== null + ? deps.formatAgeYearsMonthsDays(observedDays) + : organizationYears.size > 0 + ? `${organizationYears.size} г.` + : null; + const lines: string[] = [ + observedAgeLabel && organizationFirstObservedActivity && organizationLastObservedActivity + ? `По активности организации ${organizationHint} в базе 1С наблюдается минимум ${observedAgeLabel}.` + : `По активности организации ${organizationHint} в базе 1С найдены подтвержденные движения.` + ]; + if (organizationFirstObservedActivity) { + lines.push(`Первая подтвержденная активность: ${deps.formatDateRu(organizationFirstObservedActivity)}.`); + } + if (organizationLastObservedActivity) { + lines.push(`Последняя подтвержденная активность: ${deps.formatDateRu(organizationLastObservedActivity)}.`); + } + lines.push(`Подтвержденных операций в агрегате: ${organizationOpsCount}.`); + if (organizationYears.size > 0) { + const years = Array.from(organizationYears).sort((a, b) => a - b); + lines.push(`Годы с активностью в базе: ${years.join(", ")}.`); + } + lines.push("Это возраст активности организации в 1С по подтвержденным движениям, а не дата регистрации юрлица."); + return buildFactualSummaryReply(lines); + } + } + + const lines: string[] = longevityQuestion + ? [ + `Заказчиков с самым длинным горизонтом сотрудничества: ${counterparties.length}.`, + "Собран профиль длительности сотрудничества по годам и частоте активности.", + `Строк агрегата: ${rows.length}.` + ] + : [ + `Активные заказчики ${scopeLabel}: ${counterparties.length}.`, + "Собран профиль активности заказчиков по платежным документам.", + `Строк агрегата: ${rows.length}.` + ]; + + if (counterparties.length === 0) { + lines.push( + longevityQuestion + ? "По доступному окну не удалось выделить заказчиков с подтвержденной длительностью сотрудничества." + : "По выбранному окну активные заказчики не найдены." + ); + return buildFactualSummaryReply(lines); + } + + const visible = counterparties.slice(0, longevityQuestion ? rankingLimit : 120); + if (longevityQuestion) { + lines.push(`Топ-${visible.length} заказчиков по охвату лет и частоте операций:`); + } + lines.push( + ...visible.map((item, index) => { + const years = Array.from(item.years).sort((a, b) => a - b); + const yearsLabel = years.length > 0 ? ` | лет в базе: ${years.length} | годы: ${years.join(", ")}` : ""; + const periodSpan = + item.firstPeriod && item.lastPeriod + ? ` | период: ${formatOptionalDate(item.firstPeriod, deps.formatDateRu)}..${formatOptionalDate(item.lastPeriod, deps.formatDateRu)}` + : ""; + if (longevityQuestion) { + return `${index + 1}. ${item.name} | операций: ${item.opsCount}${yearsLabel}${periodSpan}`; + } + const suffix = item.lastPeriod ? ` | последняя активность: ${deps.formatDateRu(item.lastPeriod)}` : ""; + return `${index + 1}. ${item.name} | операций: ${item.opsCount}${suffix}${years.length > 0 ? ` | лет в базе: ${years.length}` : ""}`; + }) + ); + if (counterparties.length > visible.length) { + lines.push(`Показаны первые ${visible.length} из ${counterparties.length} заказчиков.`); + } + + return buildFactualListReply(lines); + } + + if (intent === "contract_usage_overview") { + const rowsByMarker = groupRowsByMarker(rows); + const sumMarker = (marker: string): number => + (rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0); + + const totalContracts = sumMarker("CT_TOTAL"); + const usedContracts = sumMarker("CT_USED"); + const unusedContracts = + totalContracts > 0 ? Math.max(0, totalContracts - Math.min(usedContracts, totalContracts)) : null; + const usedShare = totalContracts > 0 ? deps.formatPercent(Math.min(usedContracts, totalContracts), totalContracts) : null; + + const usageLead = + totalContracts > 0 + ? `Использованных договоров: ${usedContracts} из ${totalContracts}${usedShare ? ` (${usedShare})` : ""}.` + : `Использованных договоров с подтвержденной связью с операциями: ${usedContracts}.`; + + const lines: string[] = [ + usageLead, + "Профиль договорной базы собран по справочнику и подтвержденным операциям.", + `Строк агрегата: ${rows.length}.` + ]; + + if (totalContracts > 0) { + lines.push(`Всего договоров в базе: ${totalContracts}.`); + } else { + lines.push("Общее количество договоров не получено из доступного среза справочника."); + } + + lines.push(`Использованных договоров с подтвержденной связью с операциями: ${usedContracts}.`); + if (unusedContracts !== null) { + lines.push(`Неиспользуемых договоров: ${unusedContracts}.`); + } + if (usedShare) { + lines.push(`Доля используемых договоров: ${usedShare}.`); + } + + return buildFactualSummaryReply(lines); + } + + if (intent === "customer_revenue_and_payments" || intent === "supplier_payouts_profile") { + const isSupplier = intent === "supplier_payouts_profile"; + const focus = deps.detectValueRankingFocus(options.userMessage); + const limit = deps.detectRankingLimit(options.userMessage, 20); + const minOpsForAvgCheck = deps.detectMinOpsForAvgCheck(options.userMessage); + const normalizedQuestion = deps.normalizeQuestionText(options.userMessage); + + const byCounterparty = new Map(); + const byYear = new Map(); + const deals: CounterpartyDealPoint[] = []; + + for (const row of rows) { + const counterparty = deps.extractCounterpartyName(row); + const amount = row.amount ?? 0; + if (!counterparty || !Number.isFinite(amount) || amount <= 0) { + continue; + } + + const current = byCounterparty.get(counterparty); + if (!current) { + byCounterparty.set(counterparty, { + name: counterparty, + total: amount, + ops: 1, + maxSingle: amount, + minSingle: amount, + lastPeriod: row.period + }); + } else { + current.total += amount; + current.ops += 1; + current.maxSingle = Math.max(current.maxSingle, amount); + current.minSingle = Math.min(current.minSingle, amount); + if ((row.period ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = row.period; + } + } + + deals.push({ + period: row.period, + registrator: row.registrator, + counterparty, + amount + }); + + const year = deps.extractYearFromIso(row.period); + if (year !== null) { + const yearBucket = byYear.get(year); + if (!yearBucket) { + byYear.set(year, { + year, + total: amount, + ops: 1, + maxSingle: amount, + counterparties: new Set([counterparty]) + }); + } else { + yearBucket.total += amount; + yearBucket.ops += 1; + yearBucket.maxSingle = Math.max(yearBucket.maxSingle, amount); + yearBucket.counterparties.add(counterparty); + } + } + } + + const profileRows = Array.from(byCounterparty.values()); + const yearRows = Array.from(byYear.values()); + const totalFlow = profileRows.reduce((sum, item) => sum + item.total, 0); + const totalOperations = profileRows.reduce((sum, item) => sum + item.ops, 0); + const rankedByTotal = [...profileRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.name.localeCompare(b.name)); + const rankedByYearTotal = [...yearRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.year - b.year); + const rankedByOps = [...profileRows].sort((a, b) => b.ops - a.ops || b.total - a.total || a.name.localeCompare(b.name)); + const rankedByMaxSingle = [...profileRows].sort( + (a, b) => b.maxSingle - a.maxSingle || b.total - a.total || a.name.localeCompare(b.name) + ); + const rankedByAvgCheck = [...profileRows] + .filter((item) => item.ops >= minOpsForAvgCheck) + .map((item) => ({ + ...item, + avgCheck: item.total / item.ops + })) + .sort((a, b) => b.avgCheck - a.avgCheck || b.total - a.total || a.name.localeCompare(b.name)); + const rankedDealsTop = [...deals].sort( + (a, b) => b.amount - a.amount || (b.period ?? "").localeCompare(a.period ?? "") + ); + const activeOnlyForBottomDeals = /(?:активн|active)/iu.test(normalizedQuestion); + const activeCounterpartiesForBottom = new Set( + profileRows.filter((item) => item.ops >= Math.max(3, minOpsForAvgCheck)).map((item) => item.name) + ); + const rankedDealsBottom = [...deals] + .filter((item) => !activeOnlyForBottomDeals || activeCounterpartiesForBottom.has(item.counterparty)) + .sort((a, b) => a.amount - b.amount || (a.period ?? "").localeCompare(b.period ?? "")); + + const lines: string[] = [ + isSupplier + ? "Собран профиль выплат поставщикам по платежным документам." + : "Собран профиль поступлений от заказчиков по платежным документам.", + `Строк источника: ${rows.length}.`, + `Уникальных контрагентов: ${profileRows.length}.` + ]; + + if (profileRows.length === 0) { + lines.push("По выбранному окну данных платежные строки не найдены."); + return buildFactualSummaryReply(lines); + } + + if (focus === "total_flow") { + const periodLine = + options.periodFrom && options.periodTo + ? `За период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)} подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.` + : `За все доступное время подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`; + const directAnswerLine = isSupplier + ? periodLine + : `${periodLine} Это денежный поток от клиентов, а не чистая прибыль.`; + const summaryLines = [ + directAnswerLine, + "", + "Подтверждение:", + `- Операций в выборке: ${totalOperations}.`, + `- Контрагентов в выборке: ${profileRows.length}.` + ]; + if (rankedByYearTotal.length > 0) { + summaryLines.push( + `- Самый сильный год по поступлениям: ${rankedByYearTotal[0].year} (${deps.formatMoneyRub(rankedByYearTotal[0].total)}).` + ); + } + if (rankedByTotal.length > 0) { + summaryLines.push( + `- Крупнейший контрагент по потоку: ${rankedByTotal[0].name} (${deps.formatMoneyRub(rankedByTotal[0].total)}).` + ); + } + return buildFactualSummaryReply(summaryLines); + } + + if (focus === "top_years_by_total") { + const visible = rankedByYearTotal.slice(0, limit); + const heading = isSupplier + ? `Топ-${visible.length} лет по сумме выплат:` + : `Топ-${visible.length} лет по сумме поступлений:`; + lines.unshift(heading); + if (visible.length === 0) { + lines.push("По доступному окну не удалось собрать годовые агрегаты по суммам."); + } else { + lines.push( + ...visible.map( + (item, index) => + `${index + 1}. ${item.year} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops} | контрагентов: ${item.counterparties.size} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)}` + ) + ); + } + return buildFactualListReply(lines); + } + + if (focus === "top_by_ops") { + const visible = rankedByOps.slice(0, limit); + const heading = isSupplier + ? `Топ-${visible.length} поставщиков по количеству исходящих платежных операций:` + : `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`; + lines.unshift(heading); + lines.push( + ...visible.map( + (item, index) => + `${index + 1}. ${item.name} | операций: ${item.ops} | сумма: ${deps.formatMoneyRub(item.total)} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)}` + ) + ); + return buildFactualListReply(lines); + } + + if (focus === "top_by_max_single") { + const visible = rankedByMaxSingle.slice(0, limit); + const heading = isSupplier + ? `Топ-${visible.length} поставщиков по максимальной разовой выплате:` + : `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`; + lines.unshift(heading); + lines.push( + ...visible.map( + (item, index) => + `${index + 1}. ${item.name} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops}` + ) + ); + return buildFactualListReply(lines); + } + + if (focus === "top_by_avg_check_min_ops") { + const visible = rankedByAvgCheck.slice(0, limit); + const heading = isSupplier + ? `Топ-${visible.length} поставщиков по среднему чеку (минимум ${minOpsForAvgCheck} операций):` + : `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`; + lines.unshift(heading); + if (visible.length === 0) { + lines.push(`Контрагентов с минимум ${minOpsForAvgCheck} операций не найдено.`); + } else { + lines.push( + ...visible.map( + (item, index) => + `${index + 1}. ${item.name} | средний чек: ${deps.formatMoneyRub(item.avgCheck)} | операций: ${item.ops} | сумма: ${deps.formatMoneyRub(item.total)}` + ) + ); + } + return buildFactualListReply(lines); + } + + if (focus === "top_deals") { + const visible = rankedDealsTop.slice(0, limit); + const heading = isSupplier + ? `Топ-${visible.length} самых крупных разовых выплат поставщикам:` + : `Топ-${visible.length} самых крупных разовых поступлений:`; + lines.unshift(heading); + lines.push( + ...visible.map( + (item, index) => + `${index + 1}. ${formatOptionalDate(item.period, deps.formatDateRu)} | ${item.counterparty} | ${item.registrator} | ${deps.formatMoneyRub(item.amount)}` + ) + ); + return buildFactualListReply(lines); + } + + if (focus === "bottom_deals") { + const visible = rankedDealsBottom.slice(0, limit); + const heading = isSupplier + ? `Топ-${visible.length} самых маленьких разовых выплат:` + : `Топ-${visible.length} самых маленьких разовых поступлений:`; + lines.unshift(heading); + if (activeOnlyForBottomDeals) { + lines.push("Фильтр: только активные контрагенты с минимум 3 операциями."); + } + lines.push( + ...visible.map( + (item, index) => + `${index + 1}. ${formatOptionalDate(item.period, deps.formatDateRu)} | ${item.counterparty} | ${item.registrator} | ${deps.formatMoneyRub(item.amount)}` + ) + ); + return buildFactualListReply(lines); + } + + const visible = rankedByTotal.slice(0, limit); + const heading = isSupplier + ? `Топ-${visible.length} поставщиков по сумме выплат:` + : `Топ-${visible.length} заказчиков по сумме поступлений:`; + lines.unshift(heading); + lines.push( + ...visible.map((item, index) => { + const avgCheck = item.ops > 0 ? item.total / item.ops : 0; + return `${index + 1}. ${item.name} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops} | средний чек: ${deps.formatMoneyRub(avgCheck)} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)}`; + }) + ); + return buildFactualListReply(lines); + } + + if (intent === "contract_usage_and_value") { + const focus = deps.detectContractValueFocus(options.userMessage); + const limit = deps.detectRankingLimit(options.userMessage, 20); + const byContract = new Map(); + + for (const row of rows) { + const contract = deps.extractContractName(row); + const amount = row.amount ?? 0; + if (!contract || !Number.isFinite(amount) || amount <= 0) { + continue; + } + const counterparty = deps.extractCounterpartyName(row); + const current = byContract.get(contract); + if (!current) { + byContract.set(contract, { + contract, + turnover: amount, + docs: 1, + lastPeriod: row.period, + counterparties: new Set(counterparty ? [counterparty] : []) + }); + } else { + current.turnover += amount; + current.docs += 1; + if ((row.period ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = row.period; + } + if (counterparty) { + current.counterparties.add(counterparty); + } + } + } + + const contractRows = Array.from(byContract.values()); + const rankedByTurnover = [...contractRows].sort( + (a, b) => b.turnover - a.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract) + ); + const rankedByDocs = [...contractRows].sort( + (a, b) => b.docs - a.docs || b.turnover - a.turnover || a.contract.localeCompare(b.contract) + ); + const rankedBottomActive = [...contractRows] + .filter((item) => item.docs > 0 && item.turnover > 0) + .sort((a, b) => a.turnover - b.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract)); + + const lines: string[] = [ + `Активных договоров: ${contractRows.length}.`, + "Собран профиль договоров по обороту и подтвержденным операциям.", + `Строк источника: ${rows.length}.`, + `Договорных агрегатов: ${contractRows.length}.` + ]; + + if (contractRows.length === 0) { + lines.push("В выбранном окне не найдено операций, связанных с договорами."); + return buildFactualSummaryReply(lines); + } + + if (focus === "top_by_docs") { + const visible = rankedByDocs.slice(0, limit); + lines.unshift(`Топ-${visible.length} договоров по количеству операций:`); + lines.push( + ...visible.map( + (item, index) => + `${index + 1}. ${item.contract} | операций: ${item.docs} | оборот: ${deps.formatMoneyRub(item.turnover)} | контрагентов: ${item.counterparties.size}` + ) + ); + return buildFactualListReply(lines); + } + + if (focus === "bottom_by_turnover_active") { + const visible = rankedBottomActive.slice(0, limit); + lines.unshift(`Топ-${visible.length} активных договоров с минимальным оборотом:`); + lines.push( + ...visible.map( + (item, index) => + `${index + 1}. ${item.contract} | оборот: ${deps.formatMoneyRub(item.turnover)} | операций: ${item.docs} | последняя активность: ${formatOptionalDate(item.lastPeriod, deps.formatDateRu)}` + ) + ); + return buildFactualListReply(lines); + } + + const visible = rankedByTurnover.slice(0, limit); + lines.unshift(`Топ-${visible.length} договоров по сумме оборота:`); + lines.push( + ...visible.map( + (item, index) => + `${index + 1}. ${item.contract} | оборот: ${deps.formatMoneyRub(item.turnover)} | операций: ${item.docs} | контрагентов: ${item.counterparties.size} | последняя активность: ${formatOptionalDate(item.lastPeriod, deps.formatDateRu)}` + ) + ); + return buildFactualListReply(lines); + } + + return null; +} diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index 16d37c1..5d81f3a 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -539,16 +539,21 @@ function shouldRestoreInventoryRootFrame( const comingFromInventoryDrilldown = currentFrameKind === "inventory_drilldown" || isInventoryDrilldownFrameIntent(previousIntent); const normalized = String(userMessage ?? ""); + const hasExplicitInventoryRootSnapshotCue = /(?:склад|остат(?:ок|ки)|товар(?:ы|ов)?|номенклатур)/iu.test(normalized); const hasInventoryRootRestatementCue = /(?:склад|остат(?:ок|ки)|позици(?:я|и|ю)|товар(?:ы|ов)?|номенклатур)/iu.test(normalized) && - /(?:покажи|показать|выведи|раскрой|еще\s+раз|ещ[её]\s+раз|снова|опять|верни|вернись|повтори|тот\s+же|этот\s+же|same|again)/iu.test( + (/(?:покажи|показать|выведи|раскрой|еще\s+раз|ещ[её]\s+раз|снова|опять|верни|вернись|повтори|тот\s+же|этот\s+же|same|again)/iu.test( normalized - ); + ) || + hasSameDateHint(normalized) || + hasSamePeriodHint(normalized)); const canReenterInventoryRoot = comingFromInventoryDrilldown || rootContextOnly || (currentFrameKind === "inventory_root" && (hasSamePeriodHint(normalized) || hasInventoryRootRestatementCue)) || - (currentFrameKind === "generic" && hasInventoryRootRestatementCue && hasSamePeriodHint(normalized)); + (currentFrameKind === "generic" && + hasExplicitInventoryRootSnapshotCue && + (hasSameDateHint(normalized) || hasSamePeriodHint(normalized) || hasInventoryRootRestatementCue)); if (!canReenterInventoryRoot) { return false; } @@ -998,23 +1003,21 @@ function mergeFollowupFilters( reasons.push("as_of_date_from_followup_context"); } } - if ( - !sameDateRequested && - (intent === "inventory_aging_by_purchase_date" || isInventoryLifecycleHistoryIntent(intent)) && - !hasExplicitPeriodLiteral(userMessage) && - !hasExplicitCurrentDateHint(userMessage) - ) { - if (intent === "inventory_aging_by_purchase_date") { - const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; - const currentAsOfDate = toNonEmptyString(merged.as_of_date); - const todayIso = new Date().toISOString().slice(0, 10); - const currentLooksDefaultedToToday = currentAsOfDate === todayIso; - if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) { - merged.as_of_date = inheritedAsOfDate; - reasons.push("as_of_date_from_followup_context"); - } + if ( + !sameDateRequested && + (intent === "inventory_aging_by_purchase_date" || isInventoryLifecycleHistoryIntent(intent)) && + !hasExplicitPeriodLiteral(userMessage) && + !hasExplicitCurrentDateHint(userMessage) + ) { + const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; + const currentAsOfDate = toNonEmptyString(merged.as_of_date); + const todayIso = new Date().toISOString().slice(0, 10); + const currentLooksDefaultedToToday = currentAsOfDate === todayIso; + if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) { + merged.as_of_date = inheritedAsOfDate; + reasons.push("as_of_date_from_followup_context"); } - } + } if ( (Boolean(previousPeriodFrom) || Boolean(previousPeriodTo)) && hasSelectedObjectInventorySignal(userMessage) && @@ -1280,6 +1283,9 @@ function deriveIntentWithFollowupContext( const hasPreviousCounterparty = Boolean(previousCounterparty ?? previousCounterpartyFromAnchor); const hasAnyPartyAnchor = hasPreviousContract || hasPreviousCounterparty; const isVatFollowup = hasVatCue(normalizedMessage); + const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined); + const inventorySelectedObjectFollowup = + hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal); if (detectedIntent.intent === "unknown" && isVatFollowup) { const vatIntent: AddressIntent = hasVatTaxPaymentCue(normalizedMessage) @@ -1295,7 +1301,12 @@ function deriveIntentWithFollowupContext( } const allowOpenItemsFollowupFallback = detectedIntent.intent === "unknown" && !isVatFollowup; - if (allowOpenItemsFollowupFallback && hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) { + if ( + allowOpenItemsFollowupFallback && + !inventorySelectedObjectFollowup && + hasOpenItemsHint(normalizedMessage) && + hasAnyPartyAnchor + ) { return { intent: "open_items_by_counterparty_or_contract", confidence: "low", @@ -1323,9 +1334,6 @@ function deriveIntentWithFollowupContext( }; } - const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined); - const inventorySelectedObjectFollowup = - hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal); if (inventorySelectedObjectFollowup && hasInventorySupplierFollowupCue(normalizedMessage)) { if ( detectedIntent.intent === "unknown" || diff --git a/llm_normalizer/backend/src/services/assistantBoundaryPolicy.ts b/llm_normalizer/backend/src/services/assistantBoundaryPolicy.ts index 21b786d..02b78d5 100644 --- a/llm_normalizer/backend/src/services/assistantBoundaryPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantBoundaryPolicy.ts @@ -53,10 +53,7 @@ function containsLetterLikeChars(text: unknown): boolean { } export function createAssistantBoundaryPolicy(deps: AssistantBoundaryPolicyDeps): AssistantBoundaryPolicy { - const defaultChannel = String(deps.activeMcpChannel ?? "default"); - function buildAssistantDataScopeContractReply(scopeProbe: Record | null = null): string { - const channel = String(scopeProbe?.channel ?? defaultChannel); const organizations = Array.isArray(scopeProbe?.organizations) ? scopeProbe.organizations .map((item) => String(item ?? "").trim()) @@ -65,30 +62,30 @@ export function createAssistantBoundaryPolicy(deps: AssistantBoundaryPolicyDeps) if (organizations.length === 1) { return [ - `Сейчас в активном MCP-канале \`${channel}\` доступна организация: ${organizations[0]}.`, - "Работаю в read-only режиме. Могу сразу показать по этой организации документы, операции, договоры или остатки." + `Сейчас доступна организация: ${organizations[0]}.`, + "Могу сразу показать по ней документы, операции, договоры или остатки." ].join(" "); } if (organizations.length > 1) { const preview = organizations.slice(0, 10).join(", "); return [ - `Сейчас в активном MCP-канале \`${channel}\` доступны организации (${organizations.length}): ${preview}.`, - "Работаю в read-only режиме. Скажи, по какой организации смотреть документы/операции." + `Сейчас доступны организации (${organizations.length}): ${preview}.`, + "Скажите, по какой организации смотреть данные." ].join(" "); } if (scopeProbe?.status === "unresolved_with_error" && scopeProbe?.error) { return [ - `Не смог прочитать название организации из live MCP-канала \`${channel}\`: ${scopeProbe.error}.`, - "Работаю в read-only режиме и вижу только данные активного контура. Проверь подключение MCP/1С, после этого сразу назову контур." + "Сейчас не удалось определить список организаций из подключенной базы.", + `Техническая причина: ${scopeProbe.error}.`, + "Проверьте подключение, и я сразу назову доступный контур." ].join(" "); } return [ - `Работаю в read-only режиме и вижу только те данные, которые отдает текущий MCP-канал \`${channel}\`.`, - "Словарь компаний не зашит в код: рабочий контур определяется live-подключением.", - "Если подключено несколько баз, для автосписка нужен MCP-метод метаданных (перечень баз/организаций); без него можно анализировать только активный контур запросов." + "Сейчас вижу только данные текущего подключенного контура.", + "Если в нем несколько организаций, скажите, по какой смотреть данные." ].join(" "); } diff --git a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts new file mode 100644 index 0000000..383ad35 --- /dev/null +++ b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts @@ -0,0 +1,156 @@ +export interface AssistantContinuitySnapshotInput { + sessionItems?: unknown[]; + toNonEmptyString?: (value: unknown) => string | null; +} + +export interface AssistantContinuitySnapshot { + lastGroundedAddressDebug: Record | null; + lastGroundedItemAddressDebug: Record | null; + lastGroundedInventoryAddressDebug: Record | null; + activeItem: string | null; + activeOrganization: string | null; + activeScopedDate: string | null; + hasGroundedAddressContext: boolean; + hasGroundedItemContext: boolean; + hasGroundedInventoryContext: boolean; +} + +function fallbackToNonEmptyString(value: unknown): string | null { + if (value === null || value === undefined) { + return null; + } + const text = String(value).trim(); + return text.length > 0 ? text : null; +} + +function toRecordObject(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +export function formatIsoDateForReply(value: unknown): string | null { + const source = String(value ?? "").trim(); + const match = source.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) { + return null; + } + return `${match[3]}.${match[2]}.${match[1]}`; +} + +export function readAddressDebugFilters(debug: Record | null): Record | null { + return toRecordObject(debug?.extracted_filters); +} + +export function readAddressDebugItem( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): string | null { + const extractedFilters = readAddressDebugFilters(debug); + return ( + toNonEmptyString(extractedFilters?.item) ?? + (String(debug?.anchor_type ?? "") === "item" + ? toNonEmptyString(debug?.anchor_value_resolved) ?? toNonEmptyString(debug?.anchor_value_raw) + : null) + ); +} + +export function readAddressDebugOrganization( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): string | null { + const extractedFilters = readAddressDebugFilters(debug); + const rootFrameContext = toRecordObject(debug?.address_root_frame_context); + return toNonEmptyString(extractedFilters?.organization) ?? toNonEmptyString(rootFrameContext?.organization); +} + +export function readAddressDebugScopedDate(debug: Record | null): string | null { + const extractedFilters = readAddressDebugFilters(debug); + const rootFrameContext = toRecordObject(debug?.address_root_frame_context); + return ( + formatIsoDateForReply(extractedFilters?.as_of_date) ?? + formatIsoDateForReply(rootFrameContext?.as_of_date) ?? + formatIsoDateForReply(extractedFilters?.period_to) + ); +} + +export function isGroundedAddressDebug( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): boolean { + if (!debug || typeof debug !== "object") { + return false; + } + const executionLane = toNonEmptyString(debug.execution_lane); + if (executionLane !== "address_query") { + return false; + } + const answerGroundingCheck = toRecordObject(debug.answer_grounding_check); + const groundingStatus = toNonEmptyString(answerGroundingCheck?.status); + return groundingStatus === "grounded"; +} + +function isGroundedInventoryContextDebug( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null +): boolean { + if (!isGroundedAddressDebug(debug, toNonEmptyString)) { + return false; + } + const detectedIntent = toNonEmptyString(debug?.detected_intent); + const capabilityId = toNonEmptyString(debug?.capability_id); + const rootFrameContext = toRecordObject(debug?.address_root_frame_context); + const rootIntent = toNonEmptyString(rootFrameContext?.root_intent); + return ( + detectedIntent === "inventory_on_hand_as_of_date" || + capabilityId === "confirmed_inventory_on_hand_as_of_date" || + rootIntent === "inventory_on_hand_as_of_date" + ); +} + +export function resolveAssistantContinuitySnapshot( + input: AssistantContinuitySnapshotInput +): AssistantContinuitySnapshot { + const toNonEmptyString = input.toNonEmptyString ?? fallbackToNonEmptyString; + const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : []; + let lastGroundedAddressDebug: Record | null = null; + let lastGroundedItemAddressDebug: Record | null = null; + let lastGroundedInventoryAddressDebug: Record | null = null; + + 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; + } + const debug = item.debug; + if (!isGroundedAddressDebug(debug, toNonEmptyString)) { + continue; + } + if (!lastGroundedAddressDebug) { + lastGroundedAddressDebug = debug; + } + if (!lastGroundedItemAddressDebug && readAddressDebugItem(debug, toNonEmptyString)) { + lastGroundedItemAddressDebug = debug; + } + if (!lastGroundedInventoryAddressDebug && isGroundedInventoryContextDebug(debug, toNonEmptyString)) { + lastGroundedInventoryAddressDebug = debug; + } + if (lastGroundedAddressDebug && lastGroundedItemAddressDebug && lastGroundedInventoryAddressDebug) { + break; + } + } + + const primaryDebug = lastGroundedItemAddressDebug ?? lastGroundedAddressDebug; + return { + lastGroundedAddressDebug, + lastGroundedItemAddressDebug, + lastGroundedInventoryAddressDebug, + activeItem: readAddressDebugItem(primaryDebug, toNonEmptyString), + activeOrganization: readAddressDebugOrganization(primaryDebug, toNonEmptyString), + activeScopedDate: readAddressDebugScopedDate(primaryDebug), + hasGroundedAddressContext: Boolean(lastGroundedAddressDebug), + hasGroundedItemContext: Boolean(lastGroundedItemAddressDebug), + hasGroundedInventoryContext: Boolean(lastGroundedInventoryAddressDebug) + }; +} diff --git a/llm_normalizer/backend/src/services/assistantDataScopePolicy.ts b/llm_normalizer/backend/src/services/assistantDataScopePolicy.ts index 42375d4..c78b370 100644 --- a/llm_normalizer/backend/src/services/assistantDataScopePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantDataScopePolicy.ts @@ -3,6 +3,10 @@ import { mergeKnownOrganizations, normalizeOrganizationScopeValue } from "./assistantOrganizationMatcher"; +import { + isGroundedAddressDebug, + readAddressDebugOrganization +} from "./assistantContinuityPolicy"; const DATA_SCOPE_CACHE_TTL_MS = 60_000; @@ -311,6 +315,12 @@ export function createAssistantDataScopePolicy(deps: AssistantDataScopePolicyDep if (selected) { return selected; } + if (isGroundedAddressDebug(debug)) { + const groundedOrganization = normalizeOrganizationScopeValue(readAddressDebugOrganization(debug)); + if (groundedOrganization) { + return groundedOrganization; + } + } } return null; diff --git a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts index aa26ac8..aab8f53 100644 --- a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts @@ -320,6 +320,7 @@ export async function runAssistantLivingChatRuntime( chatText = buildAddressMemoryRecapReplyFromPolicy({ organization: scopedOrganization, addressDebug: lastMemoryAddressDebug, + sessionItems: input.sessionItems, toNonEmptyString: input.toNonEmptyString }); activeOrganization = scopedOrganization ?? activeOrganization; diff --git a/llm_normalizer/backend/src/services/assistantLivingModePolicy.ts b/llm_normalizer/backend/src/services/assistantLivingModePolicy.ts index d005d87..7b281bf 100644 --- a/llm_normalizer/backend/src/services/assistantLivingModePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantLivingModePolicy.ts @@ -172,7 +172,7 @@ export function createAssistantLivingModePolicy(deps: AssistantLivingModePolicyD const hasMemoryCue = samples.some((sample) => /(?:помни(?:шь|те|м)?|remember|recall)/iu.test(sample)); const hasDiscussionCue = samples.some((sample) => /(?:обсуждал[аи]?|говорил[аи]?|смотрел[аи]?|разбирал[аи]?|спрашивал[аи]?)/iu.test(sample)); const hasExplicitRecapPrompt = samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|напомни\s+что\s+мы|what\s+we\s+already\s+(?:discussed|figured\s+out))/iu.test(sample)); - if (!hasMemoryCue || !(hasDiscussionCue || hasExplicitRecapPrompt)) { + if (!(hasExplicitRecapPrompt || (hasMemoryCue && hasDiscussionCue))) { return false; } if (hasExplicitRecapPrompt) { diff --git a/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts b/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts index a40b652..87ba6ed 100644 --- a/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts @@ -1,5 +1,15 @@ // @ts-nocheck +import { + formatIsoDateForReply, + isGroundedAddressDebug, + readAddressDebugFilters, + readAddressDebugItem, + readAddressDebugOrganization, + readAddressDebugScopedDate, + resolveAssistantContinuitySnapshot +} from "./assistantContinuityPolicy"; + export interface ResolveAssistantRouteMemorySignalsInput { rawUserMessage?: unknown; repairedRawUserMessage?: unknown; @@ -12,6 +22,7 @@ export interface ResolveAssistantRouteMemorySignalsInput { aggregateBusinessAnalyticsSignal?: boolean; lastGroundedAddressDebug?: unknown; hasPriorAddressDebug?: boolean; + sessionItems?: unknown[]; } export interface AssistantRouteMemorySignals { @@ -37,13 +48,12 @@ export interface AssistantMemoryRecapPolicyDeps { isGroundedInventoryContextDebug: (debug: unknown) => boolean; } -function formatIsoDateForReply(value: unknown): string | null { - const source = String(value ?? "").trim(); - const match = source.match(/^(\d{4})-(\d{2})-(\d{2})$/); - if (!match) { +function toNonEmptyString(value: unknown): string | null { + if (value === null || value === undefined) { return null; } - return `${match[3]}.${match[2]}.${match[1]}`; + const text = String(value).trim(); + return text.length > 0 ? text : null; } function collectMessageSamples(input: ResolveAssistantRouteMemorySignalsInput): string[] { @@ -77,84 +87,6 @@ function hasExplicitRecapPromptSignal(samples: string[]): boolean { ); } -function findLastGroundedInventoryAddressDebug(items: unknown[]): Record | null { - if (!Array.isArray(items)) { - return null; - } - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index] as { role?: string; debug?: Record } | null; - if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { - continue; - } - const debug = item.debug; - const answerGroundingCheck = - debug.answer_grounding_check && typeof debug.answer_grounding_check === "object" - ? (debug.answer_grounding_check as Record) - : null; - const groundingStatus = String(answerGroundingCheck?.status ?? ""); - const detectedIntent = String(debug.detected_intent ?? ""); - const capabilityId = String(debug.capability_id ?? ""); - const rootFrameContext = - debug.address_root_frame_context && typeof debug.address_root_frame_context === "object" - ? (debug.address_root_frame_context as Record) - : null; - const rootIntent = String(rootFrameContext?.root_intent ?? ""); - const isInventoryContext = - detectedIntent === "inventory_on_hand_as_of_date" || - capabilityId === "confirmed_inventory_on_hand_as_of_date" || - rootIntent === "inventory_on_hand_as_of_date"; - if (groundingStatus === "grounded" && isInventoryContext) { - return debug; - } - } - return null; -} - -function findLastAddressDebugWithItem(items: unknown[]): Record | null { - if (!Array.isArray(items)) { - return null; - } - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index] as { role?: string; debug?: Record } | null; - if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { - continue; - } - const debug = item.debug; - if (String(debug.execution_lane ?? "") !== "address_query") { - continue; - } - const extractedFilters = - debug.extracted_filters && typeof debug.extracted_filters === "object" - ? (debug.extracted_filters as Record) - : null; - const itemLabel = - String(extractedFilters?.item ?? "").trim() || - (String(debug.anchor_type ?? "") === "item" - ? String(debug.anchor_value_resolved ?? debug.anchor_value_raw ?? "").trim() - : ""); - if (itemLabel) { - return debug; - } - } - return null; -} - -function findLastAddressDebug(items: unknown[]): Record | null { - if (!Array.isArray(items)) { - return null; - } - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index] as { role?: string; debug?: Record } | null; - if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { - continue; - } - if (String(item.debug.execution_lane ?? "") === "address_query") { - return item.debug; - } - } - return null; -} - export function buildInventoryHistoryCapabilityFollowupReply(input: { organization: string | null; addressDebug: Record | null; @@ -165,10 +97,7 @@ export function buildInventoryHistoryCapabilityFollowupReply(input: { typeof input.addressDebug.address_root_frame_context === "object" ? (input.addressDebug.address_root_frame_context as Record) : null; - const extractedFilters = - input.addressDebug?.extracted_filters && typeof input.addressDebug.extracted_filters === "object" - ? (input.addressDebug.extracted_filters as Record) - : null; + const extractedFilters = readAddressDebugFilters(input.addressDebug); const organization = input.organization ?? input.toNonEmptyString(rootFrameContext?.organization) ?? @@ -192,9 +121,111 @@ export function buildInventoryHistoryCapabilityFollowupReply(input: { ].join("\n"); } +function normalizeRecapIdentity(value: unknown): string { + return String(value ?? "") + .trim() + .toLowerCase() + .replace(/[«»"'`]/g, "") + .replace(/\s+/g, " "); +} + +function buildRecapFactLine(input: { + debug: Record | null; + item: string | null; + organization: string | null; +}): string | null { + const detectedIntent = String(input.debug?.detected_intent ?? ""); + const scopedDate = readAddressDebugScopedDate(input.debug); + const itemPart = input.item ? `по позиции «${input.item}»` : null; + const organizationPart = input.organization ? `по компании «${input.organization}»` : null; + const datePart = scopedDate ? ` на ${scopedDate}` : ""; + if (detectedIntent === "inventory_on_hand_as_of_date") { + return `смотрели остатки${organizationPart ? ` ${organizationPart}` : ""}${datePart}`.trim(); + } + if (detectedIntent === "inventory_purchase_provenance_for_item" && itemPart) { + return `разобрали, кто поставлял ${itemPart}${datePart}`.trim(); + } + if (detectedIntent === "inventory_purchase_documents_for_item" && itemPart) { + return `подняли документы закупки ${itemPart}${datePart}`.trim(); + } + if (detectedIntent === "inventory_sale_trace_for_item" && itemPart) { + return `разобрали, кому продавали ${itemPart}${datePart}`.trim(); + } + if (detectedIntent === "inventory_purchase_to_sale_chain" && itemPart) { + return `проследили цепочку от закупки до продажи ${itemPart}${datePart}`.trim(); + } + if (detectedIntent === "inventory_profitability_for_item" && itemPart) { + return `смотрели рентабельность ${itemPart}${datePart}`.trim(); + } + if (detectedIntent === "inventory_aging_by_purchase_date" && itemPart) { + return `смотрели возраст остатков ${itemPart}${datePart}`.trim(); + } + if (detectedIntent === "counterparty_activity_lifecycle" && organizationPart) { + return `смотрели активность в базе 1С ${organizationPart}`.trim(); + } + if (detectedIntent === "list_documents_by_counterparty" && organizationPart) { + return `поднимали документы ${organizationPart}${datePart}`.trim(); + } + return null; +} + +function collectRecentRecapFacts(input: { + sessionItems?: unknown[]; + item: string | null; + organization: string | null; + toNonEmptyString: (value: unknown) => string | null; +}): string[] { + const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : []; + if (sessionItems.length === 0) { + return []; + } + const currentItemKey = normalizeRecapIdentity(input.item); + const currentOrganizationKey = normalizeRecapIdentity(input.organization); + const facts: string[] = []; + const seen = new Set(); + + 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 debugItem = readAddressDebugItem(item.debug, input.toNonEmptyString); + const debugOrganization = readAddressDebugOrganization(item.debug, input.toNonEmptyString); + const itemMatches = currentItemKey ? normalizeRecapIdentity(debugItem) === currentItemKey : false; + const organizationMatches = currentOrganizationKey + ? normalizeRecapIdentity(debugOrganization) === currentOrganizationKey + : false; + if (currentItemKey && !itemMatches) { + continue; + } + if (!currentItemKey && currentOrganizationKey && !organizationMatches) { + continue; + } + const fact = buildRecapFactLine({ + debug: item.debug, + item: debugItem, + organization: debugOrganization + }); + if (!fact || seen.has(fact)) { + continue; + } + seen.add(fact); + facts.push(fact); + if (facts.length >= 3) { + break; + } + } + + return facts.reverse(); +} + export function buildAddressMemoryRecapReply(input: { organization: string | null; addressDebug: Record | null; + sessionItems?: unknown[]; toNonEmptyString: (value: unknown) => string | null; }): string { const extractedFilters = @@ -206,22 +237,29 @@ export function buildAddressMemoryRecapReply(input: { typeof input.addressDebug.address_root_frame_context === "object" ? (input.addressDebug.address_root_frame_context as Record) : null; - const item = - input.toNonEmptyString(extractedFilters?.item) ?? - (String(input.addressDebug?.anchor_type ?? "") === "item" - ? input.toNonEmptyString(input.addressDebug?.anchor_value_resolved) ?? - input.toNonEmptyString(input.addressDebug?.anchor_value_raw) - : null); + const item = readAddressDebugItem(input.addressDebug, input.toNonEmptyString); const organization = input.organization ?? input.toNonEmptyString(extractedFilters?.organization) ?? input.toNonEmptyString(rootFrameContext?.organization); - const scopedDate = - formatIsoDateForReply(extractedFilters?.as_of_date) ?? - formatIsoDateForReply(rootFrameContext?.as_of_date) ?? - formatIsoDateForReply(extractedFilters?.period_to); + const scopedDate = readAddressDebugScopedDate(input.addressDebug); + const recapFacts = collectRecentRecapFacts({ + sessionItems: input.sessionItems, + item, + organization, + toNonEmptyString: input.toNonEmptyString + }); if (item) { + if (recapFacts.length > 0) { + const datePart = scopedDate ? ` в срезе на ${scopedDate}` : ""; + const organizationPart = organization ? ` по компании «${organization}»` : ""; + return [ + `Да, помню. По позиции «${item}»${organizationPart}${datePart} мы уже выяснили:`, + ...recapFacts.map((fact) => `- ${fact}.`), + "Могу сразу продолжить по ней: поставщик, закупка, документы или продажа." + ].join("\n"); + } const datePart = scopedDate ? ` в срезе на ${scopedDate}` : ""; const organizationPart = organization ? ` по компании «${organization}»` : ""; return [ @@ -249,15 +287,18 @@ export function resolveAssistantLivingChatMemoryContext( String(input.modeDecisionReason ?? "") === "inventory_history_capability_followup_detected"; const contextualMemoryRecapFollowup = String(input.modeDecisionReason ?? "") === "memory_recap_followup_detected"; - const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : []; + const continuity = resolveAssistantContinuitySnapshot({ + sessionItems: input.sessionItems, + toNonEmptyString + }); return { contextualInventoryHistoryCapabilityFollowup, contextualMemoryRecapFollowup, lastGroundedInventoryAddressDebug: contextualInventoryHistoryCapabilityFollowup - ? findLastGroundedInventoryAddressDebug(sessionItems) + ? continuity.lastGroundedInventoryAddressDebug : null, lastMemoryAddressDebug: contextualMemoryRecapFollowup - ? findLastAddressDebugWithItem(sessionItems) ?? findLastAddressDebug(sessionItems) + ? continuity.lastGroundedItemAddressDebug ?? continuity.lastGroundedAddressDebug : null }; } @@ -269,6 +310,11 @@ export function createAssistantMemoryRecapPolicy( input: ResolveAssistantRouteMemorySignalsInput ): AssistantRouteMemorySignals { const samples = collectMessageSamples(input); + const continuity = resolveAssistantContinuitySnapshot({ + sessionItems: input.sessionItems, + toNonEmptyString + }); + const groundedInventoryContext = continuity.lastGroundedInventoryAddressDebug ?? input.lastGroundedAddressDebug; const historicalCapabilitySignal = hasSignalAcrossSamples( samples, deps.hasHistoricalCapabilityFollowupSignal @@ -284,7 +330,7 @@ export function createAssistantMemoryRecapPolicy( !input.dataScopeMetaQuery && !input.dataRetrievalSignal && historicalCapabilitySignal && - deps.isGroundedInventoryContextDebug(input.lastGroundedAddressDebug) + deps.isGroundedInventoryContextDebug(groundedInventoryContext) ), contextualMemoryRecapFollowupDetected: Boolean( !input.dataScopeMetaQuery && @@ -292,7 +338,7 @@ export function createAssistantMemoryRecapPolicy( !input.aggregateBusinessAnalyticsSignal && memoryRecapSignal && (explicitRecapPromptSignal || (!input.dataRetrievalSignal && !input.strongDataSignal)) && - input.hasPriorAddressDebug + continuity.hasGroundedAddressContext ) }; } diff --git a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts index eaa0892..92a1433 100644 --- a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts @@ -138,10 +138,16 @@ export function createAssistantRoutePolicy(deps) { hasDataRetrievalRequestSignal(repairedRawUserMessage) || hasDataRetrievalRequestSignal(effectiveAddressUserMessage) || hasDataRetrievalRequestSignal(repairedEffectiveAddressUserMessage); + const analyticsSample = compactWhitespace(`${repairedRawUserMessage} ${repairedEffectiveAddressUserMessage}`.toLowerCase()); + const colloquialCustomerValueSignal = /(?:\u043a\u0442\u043e|\u043a\u043e\u043c\u0443|customer|client|counterparty)/iu.test(analyticsSample) && + /(?:(?:\u043f\u0440\u0438\u043d\u0435\u0441|\u043f\u0440\u0438\u043d\u0451\u0441|\u0437\u0430\u043d\u0435\u0441|\u0437\u0430\u043d\u0451\u0441).*(?:\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u044c\u0433)|(?:\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u044c\u0433).*(?:\u043f\u0440\u0438\u043d\u0435\u0441|\u043f\u0440\u0438\u043d\u0451\u0441|\u0437\u0430\u043d\u0435\u0441|\u0437\u0430\u043d\u0451\u0441)|(?:\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?).*(?:\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u044c\u0433))/iu.test(analyticsSample); + const turnoverAggregateSignal = /(?:\u043e\u0431\u043e\u0440\u043e\u0442(?:\u044b)?|\u0432\u044b\u0440\u0443\u0447\u043a|\u0434\u043e\u0445\u043e\u0434|turnover|revenue).*(?:\u0437\u0430\s+\d{4}\s+\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u0433\u043e\u0434|year|month|quarter)/iu.test(analyticsSample); + const aggregateBusinessAnalyticsBridgeDetected = colloquialCustomerValueSignal || turnoverAggregateSignal; const aggregateBusinessAnalyticsSignal = hasAggregateBusinessAnalyticsSignal(rawUserMessage) || hasAggregateBusinessAnalyticsSignal(repairedRawUserMessage) || hasAggregateBusinessAnalyticsSignal(effectiveAddressUserMessage) || - hasAggregateBusinessAnalyticsSignal(repairedEffectiveAddressUserMessage); + hasAggregateBusinessAnalyticsSignal(repairedEffectiveAddressUserMessage) || + aggregateBusinessAnalyticsBridgeDetected; const standaloneAddressTopicSignal = hasStandaloneAddressTopicSignal(rawUserMessage) || hasStandaloneAddressTopicSignal(repairedRawUserMessage) || hasStandaloneAddressTopicSignal(effectiveAddressUserMessage) || @@ -231,33 +237,39 @@ export function createAssistantRoutePolicy(deps) { !capabilityMetaQuery && !dataRetrievalSignal); const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal; + const baseToolGate = resolveAddressToolGateDecision(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage); const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery && !capabilityMetaQuery && !dataRetrievalSignal && + !aggregateBusinessAnalyticsSignal && !effectiveAddressFollowupSignal && resolvedModeDetection.mode === "unsupported" && resolvedIntentResolution.intent === "unknown"); + const baseToolGatePreservesAddressLane = Boolean(baseToolGate?.runAddressLane && + ["address_intent_resolver_detected", "address_mode_classifier_detected", "address_signal_detected", "llm_canonical_data_signal_detected"].includes(String(baseToolGate?.reason ?? ""))); const nonDomainQueryIndexed = Boolean(!llmFirstAddressCandidate && deterministicNonDomainGuard && (llmFirstUnsupportedCandidate || llmContractMode === null) && + !baseToolGatePreservesAddressLane && !protectedInventoryShortFollowup && !organizationClarificationContinuationDetected); const lastAddressAssistantDebug = sessionItems ? findLastAddressAssistantItem(sessionItems)?.debug ?? null : null; - const memorySignals = resolveRouteMemorySignals({ - rawUserMessage, - repairedRawUserMessage, - effectiveAddressUserMessage, - repairedEffectiveAddressUserMessage, + const memorySignals = resolveRouteMemorySignals({ + rawUserMessage, + repairedRawUserMessage, + effectiveAddressUserMessage, + repairedEffectiveAddressUserMessage, dataScopeMetaQuery, capabilityMetaQuery, dataRetrievalSignal, - strongDataSignal, - aggregateBusinessAnalyticsSignal, - lastGroundedAddressDebug, - hasPriorAddressDebug: Boolean(lastGroundedAddressDebug || lastAddressAssistantDebug) - }); + strongDataSignal, + aggregateBusinessAnalyticsSignal, + lastGroundedAddressDebug, + hasPriorAddressDebug: Boolean(lastGroundedAddressDebug || lastAddressAssistantDebug), + sessionItems + }); const contextualHistoricalCapabilityFollowupDetected = memorySignals.contextualHistoricalCapabilityFollowupDetected; const contextualMemoryRecapFollowupDetected = memorySignals.contextualMemoryRecapFollowupDetected; const organizationFactLookupDetected = hasOrganizationFactLookupSignal(rawUserMessage) || @@ -428,6 +440,143 @@ export function createAssistantRoutePolicy(deps) { } }; } + const supportedExactInvestigativeAddressBypass = Boolean(llmContractMode === "deep_analysis" && + semanticApplyCanonicalRecommended && + strictDeepInvestigationBypassAllowed && + llmContractIntent && + ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)); + if (supportedExactInvestigativeAddressBypass) { + return { + runAddressLane: true, + toolGateDecision: "run_address_lane", + toolGateReason: "address_signal_detected", + livingMode: "address_data", + livingReason: "address_lane_triggered", + orchestrationContract: { + schema_version: "assistant_orchestration_contract_v1", + hard_meta_mode: null, + provider_execution: providerExecution, + address_mode: resolvedModeDetection.mode, + address_mode_confidence: resolvedModeDetection.confidence, + address_intent: resolvedIntentResolution.intent, + address_intent_confidence: resolvedIntentResolution.confidence, + strong_data_signal_detected: strongDataSignal, + data_retrieval_signal_detected: dataRetrievalSignal, + semantic_contract_valid: semanticContractValid, + semantic_apply_canonical_recommended: semanticApplyCanonicalRecommended, + semantic_reason_codes: semanticReasonCodes, + semantic_route_arbitration: { + supported_address_intent_detected: true, + strict_deep_investigation_bypass_allowed: true, + semantic_deep_investigation_hint_detected: semanticExtractionContract?.guard_hints?.deep_investigation_signal_detected === true, + semantic_aggregate_shape_detected: false, + followup_semantic_override_to_deep_allowed: false + }, + followup_context_detected: Boolean(followupContext), + unsupported_address_intent_fallback_to_deep: false, + deep_analysis_signal_fallback_to_deep: false, + aggregate_analytics_signal_fallback_to_deep: false, + deep_session_continuation_fallback_to_deep: false, + final_decision: { + run_address_lane: true, + tool_gate_decision: "run_address_lane", + tool_gate_reason: "address_signal_detected", + living_mode: "address_data", + living_reason: "address_lane_triggered" + } + } + }; + } + if (colloquialCustomerValueSignal) { + return { + runAddressLane: true, + toolGateDecision: "run_address_lane", + toolGateReason: "address_signal_detected", + livingMode: "address_data", + livingReason: "address_lane_triggered", + orchestrationContract: { + schema_version: "assistant_orchestration_contract_v1", + hard_meta_mode: null, + provider_execution: providerExecution, + address_mode: resolvedModeDetection.mode, + address_mode_confidence: resolvedModeDetection.confidence, + address_intent: resolvedIntentResolution.intent, + address_intent_confidence: resolvedIntentResolution.confidence, + strong_data_signal_detected: true, + data_retrieval_signal_detected: dataRetrievalSignal, + semantic_contract_valid: semanticContractValid, + semantic_apply_canonical_recommended: semanticApplyCanonicalRecommended, + semantic_reason_codes: semanticReasonCodes, + semantic_route_arbitration: { + supported_address_intent_detected: true, + strict_deep_investigation_bypass_allowed: false, + semantic_deep_investigation_hint_detected: false, + semantic_aggregate_shape_detected: true, + followup_semantic_override_to_deep_allowed: false + }, + followup_context_detected: Boolean(followupContext), + unsupported_address_intent_fallback_to_deep: false, + deep_analysis_signal_fallback_to_deep: false, + aggregate_analytics_signal_fallback_to_deep: false, + deep_session_continuation_fallback_to_deep: false, + final_decision: { + run_address_lane: true, + tool_gate_decision: "run_address_lane", + tool_gate_reason: "address_signal_detected", + living_mode: "address_data", + living_reason: "address_lane_triggered" + } + } + }; + } + const standaloneAggregateAnalyticsFollowupFallback = Boolean(followupContext && + aggregateBusinessAnalyticsBridgeDetected && + llmContractMode === null && + !((resolvedIntentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(resolvedIntentResolution.intent)) || + (llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) || + openContractsAddressSignal)); + if (standaloneAggregateAnalyticsFollowupFallback) { + return { + runAddressLane: false, + toolGateDecision: "skip_address_lane", + toolGateReason: "aggregate_analytics_signal_fallback_to_deep", + livingMode: "deep_analysis", + livingReason: "aggregate_analytics_signal_fallback_to_deep", + orchestrationContract: { + schema_version: "assistant_orchestration_contract_v1", + hard_meta_mode: null, + provider_execution: providerExecution, + address_mode: resolvedModeDetection.mode, + address_mode_confidence: resolvedModeDetection.confidence, + address_intent: resolvedIntentResolution.intent, + address_intent_confidence: resolvedIntentResolution.confidence, + strong_data_signal_detected: strongDataSignal, + data_retrieval_signal_detected: dataRetrievalSignal, + semantic_contract_valid: semanticContractValid, + semantic_apply_canonical_recommended: semanticApplyCanonicalRecommended, + semantic_reason_codes: semanticReasonCodes, + semantic_route_arbitration: { + supported_address_intent_detected: false, + strict_deep_investigation_bypass_allowed: false, + semantic_deep_investigation_hint_detected: false, + semantic_aggregate_shape_detected: true, + followup_semantic_override_to_deep_allowed: true + }, + followup_context_detected: true, + unsupported_address_intent_fallback_to_deep: false, + deep_analysis_signal_fallback_to_deep: false, + aggregate_analytics_signal_fallback_to_deep: true, + deep_session_continuation_fallback_to_deep: false, + final_decision: { + run_address_lane: false, + tool_gate_decision: "skip_address_lane", + tool_gate_reason: "aggregate_analytics_signal_fallback_to_deep", + living_mode: "deep_analysis", + living_reason: "aggregate_analytics_signal_fallback_to_deep" + } + } + }; + } if (nonDomainQueryIndexed) { return { runAddressLane: false, @@ -458,7 +607,6 @@ export function createAssistantRoutePolicy(deps) { }; } const metaAnswerFollowupSignal = metaSignals.metaAnswerFollowupSignal; - const baseToolGate = resolveAddressToolGateDecision(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage); const preserveAddressLaneSignal = Boolean((llmPreDecomposeMeta?.llmCanonicalCandidateDetected && llmPreDecomposeMeta?.applied && llmContractMode === "address_query") || @@ -483,6 +631,12 @@ export function createAssistantRoutePolicy(deps) { Boolean((resolvedIntentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(resolvedIntentResolution.intent)) || (llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) || openContractsAddressSignal); + const supportedAddressRouteCandidateDetected = Boolean(supportedAddressIntentDetected || + keepAddressLaneByIntent || + (baseToolGate?.runAddressLane && + llmContractMode !== "deep_analysis" && + !strictDeepInvestigationCueDetected && + ["address_intent_resolver_detected", "address_mode_classifier_detected", "address_signal_detected", "llm_canonical_data_signal_detected"].includes(String(baseToolGate?.reason ?? "")))); const semanticGuardHints = semanticExtractionContract?.guard_hints && typeof semanticExtractionContract.guard_hints === "object" ? semanticExtractionContract.guard_hints @@ -517,8 +671,7 @@ export function createAssistantRoutePolicy(deps) { semanticDeepInvestigationHintDetected || aggregateBusinessAnalyticsSignal) && !preserveAddressLaneSignal && - !keepAddressLaneByIntent && - !supportedAddressIntentDetected && + !supportedAddressRouteCandidateDetected && (!followupContext || followupSemanticOverrideToDeepAllowed)); const deepAnalysisPreferenceDetected = Boolean(hasDeepAnalysisPreferenceSignal(rawUserMessage) || hasDeepAnalysisPreferenceSignal(repairedRawUserMessage) || @@ -528,6 +681,19 @@ export function createAssistantRoutePolicy(deps) { hasDirectDeepAnalysisSignal(repairedRawUserMessage) || hasDirectDeepAnalysisSignal(effectiveAddressUserMessage) || hasDirectDeepAnalysisSignal(repairedEffectiveAddressUserMessage)); + const exactAddressIntentProtectedFromSemanticDeepHint = Boolean(supportedAddressRouteCandidateDetected && + llmContractMode === "address_query" && + llmContractIntent && + ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent) && + semanticApplyCanonicalRecommended && + !deepAnalysisPreferenceDetected && + !strictDeepInvestigationCueDetected && + !semanticAggregateShapeDetected); + const protectAddressLaneFromFallback = Boolean(supportedAddressRouteCandidateDetected && + !deepAnalysisPreferenceDetected && + (exactAddressIntentProtectedFromSemanticDeepHint || + !semanticDeepInvestigationHintDetected || + strictDeepInvestigationBypassAllowed)); const vatExplainFollowupSignal = Boolean(followupContext && toNonEmptyString(followupContext.previous_intent) === "vat_payable_forecast" && /(?:\u043f\u043e\u0447\u0435\u043c\u0443|why).*(?:\u043f\u0440\u043e\u0433\u043d\u043e\u0437|forecast).*(?:\u0443\u043f\u043b\u0430\u0442|payable|\b0\b)/iu.test(compactWhitespace(`${repairedRawUserMessage} ${repairedEffectiveAddressUserMessage}`))); @@ -537,17 +703,16 @@ export function createAssistantRoutePolicy(deps) { const deepAnalysisSignalFallbackToDeep = Boolean(baseToolGate?.runAddressLane && !llmRuntimeUnavailableDetected && (deepAnalysisPreferenceDetected || semanticDeepInvestigationHintDetected) && - !keepAddressLaneByIntent && - !supportedAddressIntentDetected && + !protectAddressLaneFromFallback && !vatExplainFollowupSignal && (!followupContext || !dataRetrievalSignal || followupSemanticOverrideToDeepAllowed)); const aggregateAnalyticsFallbackToDeep = Boolean(baseToolGate?.runAddressLane && !llmRuntimeUnavailableDetected && aggregateBusinessAnalyticsSignal && - !keepAddressLaneByIntent && - !supportedAddressIntentDetected && + !protectAddressLaneFromFallback && (!followupContext || llmContractMode === "unsupported" || + llmContractMode === null || semanticAggregateShapeDetected || !semanticApplyCanonicalRecommended || standaloneAddressTopicSignal)); @@ -667,10 +832,11 @@ export function createAssistantRoutePolicy(deps) { semantic_apply_canonical_recommended: semanticApplyCanonicalRecommended, semantic_reason_codes: semanticReasonCodes, semantic_route_arbitration: { - supported_address_intent_detected: supportedAddressIntentDetected, + supported_address_intent_detected: supportedAddressRouteCandidateDetected, strict_deep_investigation_bypass_allowed: strictDeepInvestigationBypassAllowed, semantic_deep_investigation_hint_detected: semanticDeepInvestigationHintDetected, semantic_aggregate_shape_detected: semanticAggregateShapeDetected, + exact_address_intent_protected_from_semantic_deep_hint: exactAddressIntentProtectedFromSemanticDeepHint, followup_semantic_override_to_deep_allowed: followupSemanticOverrideToDeepAllowed }, followup_context_detected: Boolean(followupContext), diff --git a/llm_normalizer/backend/src/services/assistantRuntimeGuards.ts b/llm_normalizer/backend/src/services/assistantRuntimeGuards.ts index cff2716..9a99086 100644 --- a/llm_normalizer/backend/src/services/assistantRuntimeGuards.ts +++ b/llm_normalizer/backend/src/services/assistantRuntimeGuards.ts @@ -193,7 +193,7 @@ function intersectsSpan(start: number, end: number, spans: Array<{ start: number function hasAccountContextAround(text: string, start: number, end: number): boolean { const left = text.slice(Math.max(0, start - 28), start); const right = text.slice(end, Math.min(text.length, end + 28)); - return /(?:счет|сч\.?|account|schet|оплат|расч[её]т|расчет|аванс|зач[её]т|долг|постав|покуп|supplier|customer|settlement|payment|ндс|vat|проводк|posting)/iu.test( + return /(?:сч(?:е|ё)т(?:а|у|ом|ов)?|сч\.?|account|schet|оплат|расч[её]т|расчет|аванс|зач[её]т|долг|постав|покуп|supplier|customer|settlement|payment|ндс|vat|проводк|posting)/iu.test( `${left} ${right}` ); } @@ -217,11 +217,11 @@ function extractAccountsFromTextDetailed(text: string, options?: { forceAccountC const contractSpans = collectContractLikeSpans(lower); const blockedSpans = [...dateSpans, ...amountSpans, ...percentSpans, ...contractSpans]; const hasAccountingLexeme = - /(?:\bсчет(?:а|у|ом|ов)?\b|\bсч\.?\b|\baccount(?:s)?\b|\bschet(?:a|u|om|ov)?\b|оплат|расч[её]т|расчет|аванс|долг|settlement|payment|supplier|customer|постав|покуп)/iu.test( + /(?:сч(?:е|ё)т(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?|оплат|расч[её]т|расчет|аванс|долг|settlement|payment|supplier|customer|постав|покуп)/iu.test( lower ); const contextualPattern = - /(?:\b(?:счет(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?)\b\s*(?:№|#|:)?\s*)(\d{2}(?:\.\d{2})?)/giu; + /(?:^|[^\p{L}\d])(?:сч(?:е|ё)т(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?)[\s#:№]*((?:\d{2})(?:\.\d{2})?)/giu; let contextualMatch: RegExpExecArray | null = null; while ((contextualMatch = contextualPattern.exec(lower)) !== null) { const token = String(contextualMatch[1] ?? "").trim(); @@ -961,7 +961,19 @@ export function resolveDomainPolarityGuard(input: { const repairedMessage = repairRuntimeGuardsMojibake(String(input.userMessage ?? "")); const lower = repairedMessage.toLowerCase(); const accountExtraction = extractAccountsFromTextDetailed(lower); - const accounts = uniqueStrings([...(input.companyAnchors?.accounts ?? []), ...accountExtraction.resolved_account_anchors]); + let accounts = uniqueStrings([...(input.companyAnchors?.accounts ?? []), ...accountExtraction.resolved_account_anchors]); + if (accounts.length === 0) { + const directAccountMentions = Array.from( + repairedMessage.matchAll( + /(?:^|[^\p{L}\d])(?:сч(?:е|ё)т(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?)[\s#:№]*((?:60|62|76)(?:\.\d{2})?)/giu + ) + ) + .map((match) => String(match[1] ?? "").trim()) + .filter((token) => token.length > 0); + if (directAccountMentions.length > 0) { + accounts = uniqueStrings([...accounts, ...directAccountMentions]); + } + } const prefixes = new Set(accounts.map((item) => accountPrefix(item)).filter((item): item is string => Boolean(item))); const settlementSignal = input.focusDomainHint === "settlements_60_62" || diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index df6923c..4511847 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -3657,6 +3657,9 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll ? llmPreDecomposeMeta.semanticExtractionContract : null; const semanticCanonicalRecommended = semanticExtractionContract?.apply_canonical_recommended !== false; + const llmSupportedDeepAddressIntentSignal = llmContractMode === "deep_analysis" && + /^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_profitability_for_item|inventory_purchase_to_sale_chain)$/u.test(llmContractIntent ?? "") && + semanticCanonicalRecommended; const llmCanonicalEntitySignal = /(?:\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043a\u043e\u043c\u043f\u0430\u043d|customer|supplier|counterparty|company|vendor|client)/iu.test(compactWhitespace(repairedInputMessage.toLowerCase())); const llmCanonicalAppliedSignal = Boolean(llmPreDecomposeMeta?.applied) && llmContractMode !== "deep_analysis"; const hasLlmCanonicalSignal = semanticCanonicalRecommended && @@ -3669,6 +3672,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll Boolean(llmPreDecomposeMeta?.applied) && (llmContractMode === "address_query" || llmContractMode === "unsupported" || llmContractMode === null) && hasStrongDataIntentSignal(repairedInputMessage); + const hasBusinessRankingAddressSignal = /(?:\u043a\u0442\u043e\s+(?:\u043d\u0430\u043c\s+)?(?:\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?\s+\u043f\u0440\u0438\u043d\u0435\u0441|\u043f\u0440\u0438\u043d\u0435\u0441\s+\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?).*(?:\u0434\u0435\u043d\u0435\u0433)?|who\s+brought\s+(?:us\s+)?(?:the\s+)?most\s+money)/iu.test(compactWhitespace(repairedInputMessage.toLowerCase())); const sameDateAccountFollowupSignal = hasSameDateAccountFollowupSignalForPredecompose(rawMessageForGate) || hasSameDateAccountFollowupSignalForPredecompose(repairedInputMessage); const hasLexicalAddressSignal = isAddressLlmPreDecomposeCandidate(addressInputMessage) || @@ -3677,12 +3681,13 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll hasAccountingSignal(repairedInputMessage) || hasShortDebtMirrorFollowupSignal(rawMessageForGate) || hasShortDebtMirrorFollowupSignal(repairedInputMessage) || + hasBusinessRankingAddressSignal || sameDateAccountFollowupSignal; const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" && (llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") && llmContractIntent === "unknown"; const hasAnyAddressSignal = - hasClassifierSignal || hasIntentSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal; + hasClassifierSignal || hasIntentSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal || llmSupportedDeepAddressIntentSignal; const strongDataSignalFromRawMessage = hasStrongDataIntentSignal(rawMessageForGate) || hasDataRetrievalRequestSignal(rawMessageForGate) || hasAccountingSignal(rawMessageForGate) || @@ -3696,6 +3701,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll !hasClassifierSignal && !hasIntentSignal && !hasLexicalAddressSignal && + !llmSupportedDeepAddressIntentSignal && !strongDataSignalFromRawMessage && !strongDataSignalFromEffectiveMessage) { return { @@ -3706,6 +3712,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll } if (hasUnsupportedLowConfidencePredecomposeSignal && !followupContext && !hasAnyAddressSignal && + !llmSupportedDeepAddressIntentSignal && !strongDataSignalFromRawMessage && !strongDataSignalFromEffectiveMessage) { return { @@ -3725,8 +3732,10 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll ? "address_intent_resolver_detected" : hasLlmCanonicalSignal ? "llm_canonical_candidate_detected" - : hasLlmCanonicalDataSignal + : hasLlmCanonicalDataSignal ? "llm_canonical_data_signal_detected" + : llmSupportedDeepAddressIntentSignal + ? "address_signal_detected" : "address_signal_detected" }; } @@ -3843,6 +3852,11 @@ function hasAggregateBusinessAnalyticsSignal(text) { if (!normalized) { return false; } + const hasTurnoverByEntityBridgeSignal = /(?:\u043e\u0431\u043e\u0440\u043e\u0442(?:\u044b)?|\u0432\u044b\u0440\u0443\u0447\u043a|\u0434\u043e\u0445\u043e\u0434|turnover|revenue).*(?:\u0437\u0430\s+\d{4}\s+\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u0433\u043e\u0434|year|month|quarter)/iu.test(normalized) && + /(?:\b\u043f\u043e\s+[\p{L}\d._-]{3,}|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|customer|supplier|counterparty|company)/iu.test(normalized); + if (hasTurnoverByEntityBridgeSignal) { + return true; + } const hasMetricCue = /(?:\u043e\u0431\u043e\u0440\u043e\u0442|\u0432\u044b\u0440\u0443\u0447|\u0434\u043e\u0445\u043e\u0434|\u043f\u0440\u0438\u0431\u044b\u043b|\u043c\u0430\u0440\u0436|\u0440\u0435\u043d\u0442\u0430\u0431\u0435\u043b|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u0435\u043b|turnover|revenue|profit|margin)/iu.test(normalized); if (!hasMetricCue) { return false; diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index ea32af7..67db28d 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -1,6 +1,48 @@ // @ts-nocheck export function createAssistantTransitionPolicy(deps) { + function hasInventoryRootRestatementLikeSignal(userMessage, sourceIntentHint, hasInventoryRootFrame) { + if (!hasInventoryRootFrame) { + return false; + } + const normalized = deps + .compactWhitespace(deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase()) + .replace(/ё/g, "е"); + if (!normalized) { + return false; + } + if (deps.countTokens(normalized) > 10) { + return false; + } + const hasInventoryLexeme = /(?:остат|склад|товар|номенклатур|позиц)/iu.test(normalized); + const hasRestatementCue = + /(?:еще\s+раз|снова|повтори|повтори\s+еще\s+раз|верни|покажи)/iu.test(normalized) && + /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|на\s+эту\s+дат[ауеы]|эту\s+дат[ауеы]|та\s+же\s+дата|тот\s+же\s+период|этот\s+же\s+период)/iu.test( + normalized + ); + const hasBareSnapshotSameDateCue = + hasInventoryLexeme && + /(?:РЅР°\s+ту\s+Р¶Рµ\s+дат[ауеы]|РЅР°\s+эту\s+Р¶Рµ\s+дат[ауеы]|РЅР°\s+эту\s+дат[ауеы]|эту\s+дат[ауеы]|та\s+Р¶Рµ\s+дата|тот\s+Р¶Рµ\s+период|этот\s+Р¶Рµ\s+период)/iu.test( + normalized + ); + const bareSnapshotSameDatePhrases = [ + "\u043d\u0430 \u0442\u0443 \u0436\u0435 \u0434\u0430\u0442", + "\u043d\u0430 \u044d\u0442\u0443 \u0436\u0435 \u0434\u0430\u0442", + "\u043d\u0430 \u044d\u0442\u0443 \u0434\u0430\u0442", + "\u044d\u0442\u0443 \u0434\u0430\u0442", + "\u0442\u0430 \u0436\u0435 \u0434\u0430\u0442\u0430", + "\u0442\u043e\u0442 \u0436\u0435 \u043f\u0435\u0440\u0438\u043e\u0434", + "\u044d\u0442\u043e\u0442 \u0436\u0435 \u043f\u0435\u0440\u0438\u043e\u0434" + ]; + const hasBareSnapshotSameDatePhraseCue = + hasInventoryLexeme && bareSnapshotSameDatePhrases.some((phrase) => normalized.includes(phrase)); + return ( + hasInventoryLexeme && + (hasRestatementCue || hasBareSnapshotSameDateCue || hasBareSnapshotSameDatePhraseCue) && + !deps.hasForeignAccountingPivotOverInventoryMessage(normalized) + ); + } + function shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent) { const normalized = deps.compactWhitespace( deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase() @@ -21,6 +63,71 @@ export function createAssistantTransitionPolicy(deps) { return /^(?:Р°|Рё|РЅСѓ)?\s*РїРѕ\s+[a-zР°-СЏС‘0-9._-]{2,}(?:\s+[a-zР°-СЏС‘0-9._-]{2,})?$/iu.test(normalized); } + function shouldKeepPreviousIntentForShortCounterpartyRetargetV2(userMessage, sourceIntent) { + const normalized = deps.compactWhitespace( + deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase() + ); + if (!normalized || deps.countTokens(normalized) > 4) { + return false; + } + if (sourceIntent !== "list_documents_by_counterparty" && sourceIntent !== "list_documents_by_contract") { + return false; + } + if (/(?:bank|payment|wire|statement)/iu.test(normalized)) { + return false; + } + return /^(?:а|и|ну)?\s*(?:покажи\s+)?по\s+[a-zа-яё0-9._-]{2,}(?:\s+[a-zа-яё0-9._-]{2,})?$/iu.test(normalized); + } + + function inferStandaloneAddressTopicFamily(userMessage) { + const normalized = deps.compactWhitespace( + deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase() + ); + if (!normalized) { + return null; + } + if (/(?:ндс|vat)/iu.test(normalized)) { + return "vat"; + } + if (/(?:остат|склад|товар|номенклатур|позици)/iu.test(normalized)) { + return "inventory"; + } + return null; + } + + function resolveDisplayedEntityRetargetIntent(userMessage, entityType) { + const normalized = deps.compactWhitespace( + deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase() + ); + if (!normalized) { + return null; + } + if (entityType === "counterparty") { + if (/(?:договор|контракт)/iu.test(normalized)) { + return "list_contracts_by_counterparty"; + } + if (/(?:банк|выписк|плат[её]ж|оплат|statement|payment|wire)/iu.test(normalized)) { + return "bank_operations_by_counterparty"; + } + if (/(?:документ|накладн|счет|сч[её]т|акт|реализац|поступл)/iu.test(normalized)) { + return "list_documents_by_counterparty"; + } + if (/(?:сколько\s+денег|сколько\s+принес|выручк|сумм[аы]?|оплатил|продаж)/iu.test(normalized)) { + return "customer_revenue_and_payments"; + } + return null; + } + if (entityType === "contract") { + if (/(?:банк|выписк|плат[её]ж|оплат|statement|payment|wire)/iu.test(normalized)) { + return "bank_operations_by_contract"; + } + if (/(?:документ|накладн|счет|сч[её]т|акт|реализац|поступл)/iu.test(normalized)) { + return "list_documents_by_contract"; + } + } + return null; + } + function resolveAddressFollowupCarryoverContext( userMessage, items, @@ -108,6 +215,18 @@ export function createAssistantTransitionPolicy(deps) { Boolean(recentInventoryRootFrame) ) : false; + const hasInventoryRootRestatementPrimary = hasInventoryRootRestatementLikeSignal( + userMessage, + sourceIntentHint, + Boolean(recentInventoryRootFrame) + ); + const hasInventoryRootRestatementAlternate = deps.toNonEmptyString(alternateMessage) + ? hasInventoryRootRestatementLikeSignal( + String(alternateMessage ?? ""), + sourceIntentHint, + Boolean(recentInventoryRootFrame) + ) + : false; let hasStrongFollowupReference = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal || @@ -117,6 +236,8 @@ export function createAssistantTransitionPolicy(deps) { inventoryShortFollowupAlternate || hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate || + hasInventoryRootRestatementPrimary || + hasInventoryRootRestatementAlternate || Boolean(debtRoleSwapIntent) || deps.hasFollowupMarker(userMessage) || deps.hasReferentialPointer(userMessage) || @@ -133,6 +254,8 @@ export function createAssistantTransitionPolicy(deps) { !hasAlternateFollowupSignal && !hasInventoryRootTemporalFollowupPrimary && !hasInventoryRootTemporalFollowupAlternate && + !hasInventoryRootRestatementPrimary && + !hasInventoryRootRestatementAlternate && !hasImplicitContinuationSignal && !hasOrganizationClarificationContinuation && !hasIndexReferenceSignal @@ -144,6 +267,8 @@ export function createAssistantTransitionPolicy(deps) { !hasAlternateFollowupSignal && !hasInventoryRootTemporalFollowupPrimary && !hasInventoryRootTemporalFollowupAlternate && + !hasInventoryRootRestatementPrimary && + !hasInventoryRootRestatementAlternate && !hasImplicitContinuationSignal && !hasOrganizationClarificationContinuation && !hasIndexReferenceSignal @@ -168,8 +293,46 @@ export function createAssistantTransitionPolicy(deps) { ? resolvedAlternateIntent : null; const sourceIntentFamily = deps.resolveAddressIntentFamily(sourceIntent); - const explicitIntentFamily = deps.resolveAddressIntentFamily(explicitIntent); - if (sourceIntentFamily && explicitIntentFamily && sourceIntentFamily !== explicitIntentFamily && !hasStrongFollowupReference) { + const explicitIntentFamily = + deps.resolveAddressIntentFamily(explicitIntent) ?? + inferStandaloneAddressTopicFamily(userMessage) ?? + (deps.toNonEmptyString(alternateMessage) ? inferStandaloneAddressTopicFamily(String(alternateMessage ?? "")) : null); + const foreignAccountingPivotOverInventory = deps.hasForeignAccountingPivotOverInventoryMessage( + userMessage, + alternateMessage + ); + const familyMismatchStandaloneTopic = Boolean( + hasStandaloneAddressTopic && + sourceIntentFamily && + explicitIntentFamily && + sourceIntentFamily !== explicitIntentFamily && + !hasOrganizationClarificationContinuation && + !hasImplicitContinuationSignal && + !hasIndexReferenceSignal && + !hasInventoryRootTemporalFollowupPrimary && + !hasInventoryRootTemporalFollowupAlternate && + !hasInventoryRootRestatementPrimary && + !hasInventoryRootRestatementAlternate && + !inventoryShortFollowupPrimary && + !inventoryShortFollowupAlternate && + !foreignAccountingPivotOverInventory && + !deps.hasFollowupMarker(userMessage) && + !deps.hasReferentialPointer(userMessage) && + (!deps.toNonEmptyString(alternateMessage) + ? true + : !deps.hasFollowupMarker(String(alternateMessage ?? "")) && + !deps.hasReferentialPointer(String(alternateMessage ?? ""))) + ); + if (familyMismatchStandaloneTopic) { + return null; + } + if ( + sourceIntentFamily && + explicitIntentFamily && + sourceIntentFamily !== explicitIntentFamily && + !hasStrongFollowupReference && + !foreignAccountingPivotOverInventory + ) { return null; } let previousIntent = sourceIntent; @@ -181,7 +344,7 @@ export function createAssistantTransitionPolicy(deps) { const suggestedIntent = Array.isArray(followupOffer?.suggested_intents) ? deps.toNonEmptyString(followupOffer.suggested_intents[0]) : null; - const keepPreviousIntent = shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent); + const keepPreviousIntent = shouldKeepPreviousIntentForShortCounterpartyRetargetV2(userMessage, sourceIntent); if (suggestedIntent && !keepPreviousIntent) { previousIntent = suggestedIntent; followupSelectionMode = "switch_to_suggested_intent"; @@ -297,6 +460,7 @@ export function createAssistantTransitionPolicy(deps) { : "generic" : null; let resolvedCounterpartyFromDisplay = false; + let displayedEntityTargetIntent = null; const previousFiltersRaw = previousAddressDebug.extracted_filters; let previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object" ? { ...previousFiltersRaw } : {}; @@ -376,7 +540,17 @@ export function createAssistantTransitionPolicy(deps) { (hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate) && !deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage) ); - const rootScopedPivot = rootContextOnlyPivot || inventoryRootTemporalPivot; + const inventoryRootRestatementPivot = Boolean( + inventoryRootFrame && + (deps.isInventorySelectedObjectIntent(sourceIntentHint) || + deps.isInventoryRootFrameIntent(sourceIntentHint) || + currentFrameKind === "inventory_drilldown" || + currentFrameKind === "inventory_root" || + currentFrameKind === "generic") && + (hasInventoryRootRestatementPrimary || hasInventoryRootRestatementAlternate) && + !deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage) + ); + const rootScopedPivot = rootContextOnlyPivot || inventoryRootTemporalPivot || inventoryRootRestatementPivot; if (rootScopedPivot) { previousIntent = null; previousAnchorType = null; @@ -396,6 +570,10 @@ export function createAssistantTransitionPolicy(deps) { ? deps.resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities) : null); if (resolvedEntityFromFollowup && !rootScopedPivot) { + displayedEntityTargetIntent = resolveDisplayedEntityRetargetIntent( + userMessage, + resolvedEntityFromFollowup.entityType + ); if (resolvedEntityFromFollowup.entityType === "counterparty") { previousFilters.counterparty = resolvedEntityFromFollowup.value; previousAnchorType = "counterparty"; @@ -465,13 +643,15 @@ export function createAssistantTransitionPolicy(deps) { inventoryShortFollowupAlternate || hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate || + hasInventoryRootRestatementPrimary || + hasInventoryRootRestatementAlternate || hasSelectedObjectInventorySignalPrimary || hasSelectedObjectInventorySignalAlternate) ); const carryoverTargetIntent = followupSelectionMode === "carry_root_context" - ? inventoryRootFrame?.intent ?? explicitIntent ?? previousIntent ?? undefined - : explicitIntent ?? previousIntent ?? undefined; + ? inventoryRootFrame?.intent ?? displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined + : displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined; return { followupContext: { previous_intent: previousIntent ?? undefined, @@ -507,6 +687,7 @@ export function createAssistantTransitionPolicy(deps) { const previousIntent = deps.toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null; const selectionMode = deps.toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? null; const rootContextOnly = selectionMode === "carry_root_context"; + const carryoverTargetIntent = deps.toNonEmptyString(carryoverMeta?.followupContext?.target_intent) ?? null; const explicitIntentRaw = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); const explicitIntent = explicitIntentRaw === "unknown" ? null : explicitIntentRaw; const rootIntent = deps.toNonEmptyString(carryoverMeta?.followupContext?.root_intent) ?? null; @@ -514,8 +695,8 @@ export function createAssistantTransitionPolicy(deps) { selectionMode === "switch_to_suggested_intent" ? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null : rootContextOnly - ? rootIntent ?? explicitIntent ?? null - : explicitIntent ?? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null; + ? carryoverTargetIntent ?? rootIntent ?? explicitIntent ?? null + : carryoverTargetIntent ?? explicitIntent ?? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null; const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal); const rewrittenByPredecompose = deps.compactWhitespace(sourceMessage.toLowerCase()) !== deps.compactWhitespace(canonicalMessage.toLowerCase()); diff --git a/llm_normalizer/backend/tests/addressCounterpartyIntentSignals.test.ts b/llm_normalizer/backend/tests/addressCounterpartyIntentSignals.test.ts index f334561..9c92e50 100644 --- a/llm_normalizer/backend/tests/addressCounterpartyIntentSignals.test.ts +++ b/llm_normalizer/backend/tests/addressCounterpartyIntentSignals.test.ts @@ -54,6 +54,13 @@ describe("addressCounterpartyIntentSignals", () => { expect(result?.reasons).toContain("counterparty_item_flow_signal_detected"); }); + it("classifies passive shipment wording with instrumental counterparty phrasing", () => { + const result = resolveAddressIntent("какие товары или услуги были отгружены нашей компании контрагентом чапурновым?"); + + expect(result.intent).toBe("list_documents_by_counterparty"); + expect(result.reasons).toContain("counterparty_item_flow_signal_detected"); + }); + it("keeps the main resolver behavior stable through counterparty-owner delegation", () => { const result = resolveAddressIntent("хвосты покажи по счету 60 на август 2022"); diff --git a/llm_normalizer/backend/tests/addressCounterpartyUtf8Regression.test.ts b/llm_normalizer/backend/tests/addressCounterpartyUtf8Regression.test.ts new file mode 100644 index 0000000..2d6bfaf --- /dev/null +++ b/llm_normalizer/backend/tests/addressCounterpartyUtf8Regression.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { resolveCounterpartyAddressIntent } from "../src/services/addressCounterpartyIntentSignals"; +import { resolveAddressIntent } from "../src/services/addressIntentResolver"; + +const utf8Deps = { + hasAny: (text: string, hints: readonly string[]) => hints.some((hint) => text.includes(hint)), + openItemsHints: ["хвост", "долг", "open items"], + openContractsHints: ["договор", "контракт", "open contracts"], + documentsByCounterpartyHints: ["документы по", "documents by counterparty"], + bankOperationsByCounterpartyHints: ["банк", "выписка", "bank operations by counterparty"], + documentsByContractHints: ["документы по договору", "documents by contract"], + hasCounterpartyDebtLongevitySignal: () => false, + hasInventoryAgingSignal: () => false, + hasInventoryProvenanceSignalV2: () => false, + hasInventoryPurchaseDocumentsSignalV2: () => false, + hasInventorySaleTraceSignalV2: () => false, + hasAccountNumberAnchor: (text: string) => /(?:60|62|76)/.test(text), + hasCompactAccountCodeToken: () => false, + hasPeriodCoverageProfileSignal: () => false, + hasPartyAnchorMention: () => false, + hasContractAnchorSignal: (text: string) => text.includes("договор"), + hasAccountBalanceSignal: () => false, + hasDocumentTypeAndAccountSectionProfileSignal: () => false, + hasCounterpartyPopulationAndRolesSignal: () => false, + hasCounterpartyActivityLifecycleSignal: () => false, + hasContractUsageOverviewSignal: () => false, + hasOpenContractsListSignal: () => false, + hasCustomerRevenueAndPaymentsSignal: () => false, + hasSupplierPayoutsProfileSignal: () => false, + hasContractUsageAndValueSignal: () => false, + hasContractListByCounterpartySignal: () => false, + hasBankOperationSignal: (text: string) => text.includes("банк"), + hasDocumentSignal: (text: string) => text.includes("док"), + hasLooseByAnchorMention: () => false, + hasHeuristicCounterpartyAnchor: () => false, + hasCounterpartyShipmentItemFlowSignal: (text: string) => text.includes("отгружал"), + hasImplicitCounterpartyAnchorAroundDocs: () => false, + hasGenericAddressLookupSignal: (text: string) => text.includes("покажи") +}; + +describe("address counterparty utf8 regression", () => { + it("classifies direct documents-by-counterparty wording with a loose human anchor", () => { + const result = resolveCounterpartyAddressIntent("покажи все документы по чепурнову", utf8Deps); + + expect(result?.intent).toBe("list_documents_by_counterparty"); + expect(result?.reasons).toContain("documents_by_counterparty_signal_detected"); + }); + + it("keeps the main resolver in the supported contour for direct documents-by-counterparty wording", () => { + const result = resolveAddressIntent("покажи все документы по чепурнову"); + + expect(result.intent).toBe("list_documents_by_counterparty"); + }); +}); diff --git a/llm_normalizer/backend/tests/addressInventoryIntentSignals.test.ts b/llm_normalizer/backend/tests/addressInventoryIntentSignals.test.ts index 0bcc3ac..4b72c28 100644 --- a/llm_normalizer/backend/tests/addressInventoryIntentSignals.test.ts +++ b/llm_normalizer/backend/tests/addressInventoryIntentSignals.test.ts @@ -4,6 +4,13 @@ import { resolveInventoryAddressIntent } from "../src/services/addressInventoryI import { resolveAddressIntent } from "../src/services/addressIntentResolver"; describe("addressInventoryIntentSignals", () => { + it("classifies plain Russian stock wording from the agent replay as inventory on hand", () => { + const result = resolveAddressIntent("какие остатки на складе на март 2021"); + + expect(result.intent).toBe("inventory_on_hand_as_of_date"); + expect(result.reasons).toContain("inventory_on_hand_signal_detected"); + }); + it("classifies warehouse snapshot wording through the extracted inventory owner", () => { const result = resolveInventoryAddressIntent("show inventory on hand as of 2020-03-15"); diff --git a/llm_normalizer/backend/tests/addressInventoryOrganizationScope.test.ts b/llm_normalizer/backend/tests/addressInventoryOrganizationScope.test.ts index 2c7698c..7c027c0 100644 --- a/llm_normalizer/backend/tests/addressInventoryOrganizationScope.test.ts +++ b/llm_normalizer/backend/tests/addressInventoryOrganizationScope.test.ts @@ -12,6 +12,48 @@ vi.mock("../src/services/addressMcpClient", async () => { ...actual, executeAddressMcpQuery: executeAddressMcpQueryMock }; + it("keeps plain March stock wording in the inventory contour and asks to choose the company", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 2, + matched_rows: 2, + raw_rows: [ + { + Period: "2021-03-31T23:59:59Z", + Registrator: "Остатки товаров РЅР° складах", + AccountDt: "41.01", + AccountKt: "00.00", + Amount: 6490, + Quantity: 1, + SubcontoDt1: "РџСѓС„ арий", + Warehouse: "РћСЃРЅРѕРІРЅРѕР№ склад", + Organization: "РћРћРћ Альтернатива Плюс" + }, + { + Period: "2021-03-31T23:59:59Z", + Registrator: "Остатки товаров РЅР° складах", + AccountDt: "41.01", + AccountKt: "00.00", + Amount: 34490, + Quantity: 1, + SubcontoDt1: "Диван трехместный", + Warehouse: "РћСЃРЅРѕРІРЅРѕР№ склад", + Organization: "РћРћРћ Лайсвуд" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle("какие остатки на складе на март 2021"); + + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("inventory_on_hand_as_of_date"); + expect(result?.response_type).toBe("LIMITED_WITH_REASON"); + expect(result?.debug.limited_reason_category).toBe("missing_anchor"); + expect(result?.debug.organization_candidates).toEqual(["РћРћРћ Альтернатива Плюс", "РћРћРћ Лайсвуд"]); + expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1); + }); }); import { AddressQueryService } from "../src/services/addressQueryService"; @@ -143,4 +185,52 @@ describe("inventory organization scope grounding", () => { expect(String(result?.reply_text ?? "")).toContain("ООО Лайсвуд"); expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1); }); + + it("defers company clarification for item-focused inventory queries and grounds the company from observed rows", async () => { + const item = "Столешница 600*3050*26 дуб ниагара"; + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 1, + matched_rows: 1, + raw_rows: [ + { + Period: "2019-02-11T00:00:00Z", + Registrator: "Поступление товаров и услуг 00000000077 от 11.02.2019 0:00:00", + AccountDt: "41.01", + AccountKt: "60.01", + Amount: 3724.17, + Quantity: 1, + SubcontoDt1: item, + SubcontoDt3: "Основной склад", + SubcontoKt1: "Торговый дом \\Союз МСК\\", + SubcontoKt2: "Договор поставки № 12 от 01.02.2019", + Organization: "ООО \\Альтернатива Плюс\\" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle("покажи документы по этой позиции", { + knownOrganizations: ["ООО Альтернатива Плюс", "ООО Лайсвуд"], + followupContext: { + previous_intent: "inventory_purchase_provenance_for_item", + previous_filters: { + item, + as_of_date: "2021-03-31", + period_from: "2021-03-01", + period_to: "2021-03-31" + }, + previous_anchor_type: "unknown", + previous_anchor_value: null + } + }); + + expect(result?.handled).toBe(true); + expect(result?.response_type).toBe("FACTUAL_LIST"); + expect(result?.debug.detected_intent).toBe("inventory_purchase_documents_for_item"); + expect(result?.debug.extracted_filters?.organization).toBe("ООО \\Альтернатива Плюс\\"); + expect(result?.debug.reasons).toContain("organization_grounded_from_observed_rows"); + expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts b/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts index 9591b16..5e0e2a5 100644 --- a/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts +++ b/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts @@ -549,6 +549,61 @@ describe("inventory selected-object follow-up", () => { expect(String(result?.reply_text ?? "")).toContain("Поступление товаров и услуг 00000000077"); }); + it("does not let carried counterparty scope steal selected-item document follow-up into open items", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 1, + matched_rows: 1, + raw_rows: [ + { + Period: "2019-02-12T00:00:00Z", + Registrator: "Поступление товаров и услуг 00000000003 от 12.02.2019 0:00:00", + AccountDt: "41.01", + AccountKt: "60.01", + Amount: 3690, + SubcontoDt1: "Столешница 600*3050*26 альмандин", + SubcontoDt3: "Основной склад", + SubcontoKt1: "Торговый дом \\Союз", + SubcontoKt2: "Договор поставки № 12 от 01.02.2019", + Organization: "ООО \\Альтернатива Плюс\\" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle("покажи документы по этой позиции", { + followupContext: { + previous_intent: "inventory_purchase_provenance_for_item", + previous_filters: { + item: "Столешница 600*3050*26 альмандин", + organization: "ООО \\Альтернатива Плюс\\", + counterparty: "Альтернатива Плюс, ООО", + as_of_date: "2021-03-31", + period_from: "2021-03-01", + period_to: "2021-03-31" + }, + previous_anchor_type: "counterparty", + previous_anchor_value: "ООО \\Альтернатива Плюс\\", + root_intent: "inventory_on_hand_as_of_date", + root_filters: { + organization: "ООО \\Альтернатива Плюс\\", + as_of_date: "2021-03-31", + period_from: "2021-03-01", + period_to: "2021-03-31" + }, + current_frame_kind: "inventory_drilldown" + } + }); + + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("inventory_purchase_documents_for_item"); + expect(result?.debug.selected_recipe).toBe("address_inventory_purchase_documents_for_item_v1"); + expect(result?.debug.capability_id).toBe("inventory_inventory_purchase_documents_for_item"); + expect(result?.debug.reasons).not.toContain("open_items_from_followup_context"); + expect(String(result?.reply_text ?? "")).toContain("Поступление товаров и услуг 00000000003"); + }); + it("routes buyer follow-up over the same selected item into sale trace instead of replaying provenance", async () => { executeAddressMcpQueryMock.mockResolvedValueOnce({ fetched_rows: 1, diff --git a/llm_normalizer/backend/tests/assistantAgentSemanticRunInventoryRegression.test.ts b/llm_normalizer/backend/tests/assistantAgentSemanticRunInventoryRegression.test.ts new file mode 100644 index 0000000..7ef7c81 --- /dev/null +++ b/llm_normalizer/backend/tests/assistantAgentSemanticRunInventoryRegression.test.ts @@ -0,0 +1,609 @@ +import { describe, expect, it, vi } from "vitest"; +import { AssistantService } from "../src/services/assistantService"; +import { AssistantSessionStore } from "../src/services/assistantSessionStore"; + +function buildAddressLaneResult(overrides?: Record): any { + return { + handled: true, + reply_text: "Подтвержден адресный ответ.", + reply_type: "factual", + response_type: "FACTUAL_SUMMARY", + debug: { + detected_mode: "address_query", + detected_intent: "inventory_on_hand_as_of_date", + extracted_filters: {}, + selected_recipe: "address_inventory_on_hand_as_of_date_v1", + reasons: ["address_action_detected", "address_entity_detected"], + answer_grounding_check: { + status: "grounded" + } + }, + ...(overrides ?? {}) + }; +} + +function buildAddressLimitedLaneResult(overrides?: Record): any { + return { + handled: true, + reply_text: "Нужно уточнить организацию.", + reply_type: "partial_coverage", + response_type: "LIMITED_WITH_REASON", + debug: { + detected_mode: "address_query", + detected_intent: "inventory_on_hand_as_of_date", + extracted_filters: { + period_from: "2021-03-01", + period_to: "2021-03-31", + as_of_date: "2021-03-31" + }, + selected_recipe: null, + limited_reason_category: "missing_anchor", + organization_candidates: ['ООО "Альтернатива Плюс"', 'ООО "Лайсвуд"'], + reasons: ["organization_clarification_required", "multiple_known_organizations_detected"] + }, + ...(overrides ?? {}) + }; +} + +describe("agent semantic inventory regressions", () => { + it("continues the stock query after exact clarification phrase 'давай по Альтернативе Плюс'", async () => { + const calls: Array<{ message: string; options?: any }> = []; + const firstMessage = "какие остатки на складе на март 2021"; + const secondMessage = "давай по Альтернативе Плюс"; + const organization = 'ООО "Альтернатива Плюс"'; + + const addressQueryService = { + tryHandle: vi.fn(async (message: string, options?: any) => { + calls.push({ message, options }); + if (message === firstMessage) { + return buildAddressLimitedLaneResult(); + } + if ( + message === secondMessage && + options?.activeOrganization === organization && + options?.followupContext?.previous_intent === "inventory_on_hand_as_of_date" + ) { + return buildAddressLaneResult({ + reply_text: "На 31.03.2021 по ООО \"Альтернатива Плюс\" подтвержден складской срез.", + debug: { + ...buildAddressLaneResult().debug, + extracted_filters: { + organization, + period_from: "2021-03-01", + period_to: "2021-03-31", + as_of_date: "2021-03-31" + }, + reasons: ["address_followup_context_applied", "organization_grounded_from_scope_candidates"] + } + }); + } + return null; + }) + } as any; + + const normalizerService = { + normalize: vi.fn(async () => ({ + assistant_reply: "normalizer_fallback_should_not_be_used", + reply_type: "partial_coverage", + debug: {} + })) + } as any; + + const sessions = new AssistantSessionStore(); + const service = new AssistantService( + normalizerService, + sessions as any, + {} as any, + { persistSession: vi.fn() } as any, + addressQueryService + ); + + const sessionId = `agent-semantic-org-${Date.now()}`; + const first = await service.handleMessage({ + session_id: sessionId, + user_message: firstMessage, + useMock: true + } as any); + expect(first.ok).toBe(true); + expect(first.reply_type).toBe("partial_coverage"); + + const second = await service.handleMessage({ + session_id: sessionId, + user_message: secondMessage, + useMock: true + } as any); + + expect(second.ok).toBe(true); + expect(second.reply_type).toBe("factual"); + expect(calls).toHaveLength(2); + expect(calls[1].options?.activeOrganization).toBe(organization); + expect(calls[1].options?.followupContext?.previous_filters?.organization).toBe(organization); + expect(calls[1].options?.followupContext?.root_filters?.organization).toBe(organization); + expect(normalizerService.normalize).not.toHaveBeenCalled(); + }); + + it("restores root stock slice after selected-object drilldown and does not rerun address lane for memory recap", async () => { + const calls: Array<{ message: string; options?: any }> = []; + const restatementMessage = "покажи еще раз остатки на эту же дату"; + const recapMessage = "а что мы уже выяснили по этой позиции?"; + const item = "Столешница 600*3050*26 альмандин"; + const organization = 'ООО "Альтернатива Плюс"'; + + const addressQueryService = { + tryHandle: vi.fn(async (message: string, options?: any) => { + calls.push({ message, options }); + if ( + message === restatementMessage && + options?.followupContext?.root_intent === "inventory_on_hand_as_of_date" && + options?.followupContext?.root_filters?.as_of_date === "2021-03-31" && + options?.followupContext?.root_filters?.period_from === "2021-03-01" && + options?.followupContext?.root_filters?.period_to === "2021-03-31" + ) { + return buildAddressLaneResult({ + reply_text: "На 31.03.2021 по ООО \"Альтернатива Плюс\" подтвержден складской остаток.", + debug: { + ...buildAddressLaneResult().debug, + extracted_filters: { + organization, + period_from: "2021-03-01", + period_to: "2021-03-31", + as_of_date: "2021-03-31" + }, + reasons: ["address_followup_context_applied", "intent_restored_to_inventory_root_frame"] + } + }); + } + return null; + }) + } as any; + + const normalizerService = { + normalize: vi.fn(async () => ({ + assistant_reply: "normalizer_fallback_should_not_be_used", + reply_type: "partial_coverage", + debug: {} + })) + } as any; + + const sessions = new AssistantSessionStore(); + const service = new AssistantService( + normalizerService, + sessions as any, + {} as any, + { persistSession: vi.fn() } as any, + addressQueryService + ); + + const sessionId = `agent-semantic-inventory-${Date.now()}`; + sessions.appendItem(sessionId, { + message_id: "msg-root", + session_id: sessionId, + role: "assistant", + text: "На 31.03.2021 по ООО \"Альтернатива Плюс\" подтвержден складской срез.", + reply_type: "factual", + created_at: "2026-04-17T16:37:39.000Z", + trace_id: "address-root-seed", + debug: { + execution_lane: "address_query", + detected_mode: "address_query", + detected_intent: "inventory_on_hand_as_of_date", + extracted_filters: { + organization, + period_from: "2021-03-01", + period_to: "2021-03-31", + as_of_date: "2021-03-31" + }, + selected_recipe: "address_inventory_on_hand_as_of_date_v1", + answer_grounding_check: { + status: "grounded" + } + } + } as any); + sessions.appendItem(sessionId, { + message_id: "msg-provenance", + session_id: sessionId, + role: "assistant", + text: `По позиции ${item} подтвержден поставщик: Торговый дом "Союз".`, + reply_type: "factual", + created_at: "2026-04-17T16:37:52.000Z", + trace_id: "address-provenance-seed", + debug: { + execution_lane: "address_query", + detected_mode: "address_query", + detected_intent: "inventory_purchase_provenance_for_item", + extracted_filters: { + item, + organization, + as_of_date: "2021-03-31" + }, + anchor_type: "item", + anchor_value_raw: item, + anchor_value_resolved: item, + selected_recipe: "address_inventory_purchase_provenance_for_item_v1", + answer_grounding_check: { + status: "grounded" + } + } + } as any); + sessions.appendItem(sessionId, { + message_id: "msg-docs", + session_id: sessionId, + role: "assistant", + text: `По позиции ${item} найден документ закупки.`, + reply_type: "factual", + created_at: "2026-04-17T16:38:16.000Z", + trace_id: "address-docs-seed", + debug: { + execution_lane: "address_query", + detected_mode: "address_query", + detected_intent: "inventory_purchase_documents_for_item", + extracted_filters: { + item, + organization, + as_of_date: "2021-03-31" + }, + anchor_type: "item", + anchor_value_raw: item, + anchor_value_resolved: item, + selected_recipe: "address_inventory_purchase_documents_for_item_v1", + answer_grounding_check: { + status: "grounded" + } + } + } as any); + + const restatement = await service.handleMessage({ + session_id: sessionId, + user_message: restatementMessage, + useMock: true + } as any); + + expect(restatement.ok).toBe(true); + expect(restatement.reply_type).toBe("factual"); + expect(calls).toHaveLength(1); + expect(calls[0].options?.followupContext?.root_filters?.organization).toBe(organization); + expect(calls[0].options?.followupContext?.root_filters?.period_from).toBe("2021-03-01"); + expect(calls[0].options?.followupContext?.root_filters?.period_to).toBe("2021-03-31"); + expect(calls[0].options?.followupContext?.root_filters?.as_of_date).toBe("2021-03-31"); + expect(calls[0].options?.followupContext?.previous_filters?.item).toBeUndefined(); + + const recap = await service.handleMessage({ + session_id: sessionId, + user_message: recapMessage, + useMock: true + } as any); + + expect(recap.ok).toBe(true); + expect(recap.reply_type).toBe("factual_with_explanation"); + expect(recap.debug?.tool_gate_reason).toBe("memory_recap_followup_detected"); + expect(recap.debug?.living_chat_response_source).toBe("deterministic_memory_recap_contract"); + expect(String(recap.assistant_reply ?? "")).toContain(item); + expect(calls).toHaveLength(1); + expect(normalizerService.normalize).not.toHaveBeenCalled(); + }); + + it("restores inventory root for bare 'остатки по складу на эту же дату' after receivables drift", async () => { + const calls: Array<{ message: string; options?: any }> = []; + const message = "остатки по складу на эту же дату"; + const organization = 'ООО "Альтернатива Плюс"'; + + const addressQueryService = { + tryHandle: vi.fn(async (requestMessage: string, options?: any) => { + calls.push({ message: requestMessage, options }); + if ( + requestMessage === message && + options?.followupContext?.root_intent === "inventory_on_hand_as_of_date" && + options?.followupContext?.root_filters?.as_of_date === "2020-03-31" + ) { + return buildAddressLaneResult({ + reply_text: "На 31.03.2020 по ООО \"Альтернатива Плюс\" подтвержден складской срез.", + debug: { + ...buildAddressLaneResult().debug, + extracted_filters: { + organization, + period_from: "2020-03-01", + period_to: "2020-03-31", + as_of_date: "2020-03-31" + }, + reasons: ["address_followup_context_applied", "intent_restored_to_inventory_root_frame"] + } + }); + } + return null; + }) + } as any; + + const normalizerService = { + normalize: vi.fn(async () => ({ + assistant_reply: "normalizer_fallback_should_not_be_used", + reply_type: "partial_coverage", + debug: {} + })) + } as any; + + const sessions = new AssistantSessionStore(); + const service = new AssistantService( + normalizerService, + sessions as any, + {} as any, + { persistSession: vi.fn() } as any, + addressQueryService + ); + + const sessionId = `agent-semantic-same-date-${Date.now()}`; + sessions.appendItem(sessionId, { + message_id: "msg-root-stock", + session_id: sessionId, + role: "assistant", + text: "На 31.03.2020 по ООО \"Альтернатива Плюс\" подтвержден складской срез.", + reply_type: "factual", + created_at: "2026-04-17T17:20:00.000Z", + trace_id: "address-root-stock-seed", + debug: { + execution_lane: "address_query", + detected_mode: "address_query", + detected_intent: "inventory_on_hand_as_of_date", + extracted_filters: { + organization, + period_from: "2020-03-01", + period_to: "2020-03-31", + as_of_date: "2020-03-31" + }, + selected_recipe: "address_inventory_on_hand_as_of_date_v1", + answer_grounding_check: { + status: "grounded" + } + } + } as any); + sessions.appendItem(sessionId, { + message_id: "msg-receivables", + session_id: sessionId, + role: "assistant", + text: "Итого подтвержденная дебиторская задолженность на 31.03.2020: 15 404 897,08 ₽.", + reply_type: "factual", + created_at: "2026-04-17T17:25:00.000Z", + trace_id: "address-receivables-seed", + debug: { + execution_lane: "address_query", + detected_mode: "address_query", + detected_intent: "receivables_confirmed_as_of_date", + extracted_filters: { + organization, + as_of_date: "2020-03-31", + period_from: "2020-03-01", + period_to: "2020-03-31" + }, + selected_recipe: "address_receivables_confirmed_as_of_date_v1", + answer_grounding_check: { + status: "grounded" + } + } + } as any); + + const response = await service.handleMessage({ + session_id: sessionId, + user_message: message, + useMock: true + } as any); + + expect(response.ok).toBe(true); + expect(calls).toHaveLength(1); + expect(calls[0].options?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date"); + expect(calls[0].options?.followupContext?.root_filters?.organization).toBe(organization); + expect(calls[0].options?.followupContext?.root_context_only).toBe(true); + expect(response.reply_type).toBe("factual"); + expect(normalizerService.normalize).not.toHaveBeenCalled(); + }); + + it("keeps selected-object document follow-up in inventory contour after an intermediate capability chat turn", async () => { + const calls: Array<{ message: string; options?: any }> = []; + const item = "Столешница 600*3050*26 альмандин"; + const organization = 'ООО "Альтернатива Плюс"'; + const message = `По выбранному объекту "${item}": покажи документы по этой позиции`; + + const addressQueryService = { + tryHandle: vi.fn(async (requestMessage: string, options?: any) => { + calls.push({ message: requestMessage, options }); + if ( + requestMessage === message && + options?.followupContext?.previous_intent === "inventory_purchase_provenance_for_item" && + options?.followupContext?.previous_filters?.item === item && + options?.followupContext?.previous_filters?.organization === organization + ) { + return buildAddressLaneResult({ + reply_text: `По позиции ${item} найден подтвержденный документ закупки.`, + debug: { + ...buildAddressLaneResult().debug, + detected_intent: "inventory_purchase_documents_for_item", + extracted_filters: { + item, + organization, + as_of_date: "2021-03-31" + }, + reasons: ["address_followup_context_applied", "intent_adjusted_to_inventory_followup_context"] + } + }); + } + return null; + }) + } as any; + + const normalizerService = { + normalize: vi.fn(async () => ({ + assistant_reply: "normalizer_fallback_should_not_be_used", + reply_type: "partial_coverage", + debug: {} + })) + } as any; + + const sessions = new AssistantSessionStore(); + const service = new AssistantService( + normalizerService, + sessions as any, + {} as any, + { persistSession: vi.fn() } as any, + addressQueryService + ); + + const sessionId = `agent-semantic-meta-docs-${Date.now()}`; + sessions.appendItem(sessionId, { + message_id: "msg-root-inventory", + session_id: sessionId, + role: "assistant", + text: "На 31.03.2021 по ООО \"Альтернатива Плюс\" подтвержден складской срез.", + reply_type: "factual", + created_at: "2026-04-17T17:29:00.000Z", + trace_id: "address-root-seed-2", + debug: { + execution_lane: "address_query", + detected_mode: "address_query", + detected_intent: "inventory_on_hand_as_of_date", + extracted_filters: { + organization, + period_from: "2021-03-01", + period_to: "2021-03-31", + as_of_date: "2021-03-31" + }, + selected_recipe: "address_inventory_on_hand_as_of_date_v1", + answer_grounding_check: { + status: "grounded" + } + } + } as any); + sessions.appendItem(sessionId, { + message_id: "msg-item-provenance", + session_id: sessionId, + role: "assistant", + text: `По позиции ${item} подтвержден поставщик: Торговый дом "Союз".`, + reply_type: "factual", + created_at: "2026-04-17T17:30:00.000Z", + trace_id: "address-provenance-seed-2", + debug: { + execution_lane: "address_query", + detected_mode: "address_query", + detected_intent: "inventory_purchase_provenance_for_item", + extracted_filters: { + item, + organization, + as_of_date: "2021-03-31" + }, + anchor_type: "item", + anchor_value_raw: item, + anchor_value_resolved: item, + selected_recipe: "address_inventory_purchase_provenance_for_item_v1", + answer_grounding_check: { + status: "grounded" + } + } + } as any); + sessions.appendItem(sessionId, { + message_id: "msg-capability-chat", + session_id: sessionId, + role: "assistant", + text: "Могу работать с остатками, документами и взаиморасчетами.", + reply_type: "factual_with_explanation", + created_at: "2026-04-17T17:31:00.000Z", + trace_id: "chat-capability-seed", + debug: { + execution_lane: "living_chat", + living_chat_response_source: "deterministic_capability_contract" + } + } as any); + + const response = await service.handleMessage({ + session_id: sessionId, + user_message: message, + useMock: true + } as any); + + expect(response.ok).toBe(true); + expect(response.reply_type).toBe("factual"); + expect(calls).toHaveLength(1); + expect(calls[0].options?.followupContext?.previous_filters?.item).toBe(item); + expect(calls[0].options?.followupContext?.previous_filters?.organization).toBe(organization); + expect(normalizerService.normalize).not.toHaveBeenCalled(); + }); + + it("reuses grounded organization from the last factual address answer instead of re-asking for company", async () => { + const calls: Array<{ message: string; options?: any }> = []; + const organization = 'ООО "Альтернатива Плюс"'; + const message = "какие остатки на складе на март 2021"; + + const addressQueryService = { + tryHandle: vi.fn(async (requestMessage: string, options?: any) => { + calls.push({ message: requestMessage, options }); + if (requestMessage === message && options?.activeOrganization === organization) { + return buildAddressLaneResult({ + reply_text: "На 31.03.2021 по ООО \"Альтернатива Плюс\" подтвержден складской срез.", + debug: { + ...buildAddressLaneResult().debug, + extracted_filters: { + organization, + period_from: "2021-03-01", + period_to: "2021-03-31", + as_of_date: "2021-03-31" + }, + reasons: ["organization_scope_restored_from_grounded_address_history"] + } + }); + } + return buildAddressLimitedLaneResult(); + }) + } as any; + + const normalizerService = { + normalize: vi.fn(async () => ({ + assistant_reply: "normalizer_fallback_should_not_be_used", + reply_type: "partial_coverage", + debug: {} + })) + } as any; + + const sessions = new AssistantSessionStore(); + const service = new AssistantService( + normalizerService, + sessions as any, + {} as any, + { persistSession: vi.fn() } as any, + addressQueryService + ); + + const sessionId = `agent-semantic-grounded-org-${Date.now()}`; + sessions.appendItem(sessionId, { + message_id: "msg-counterparty-seed", + session_id: sessionId, + role: "assistant", + text: "По Чапурнову подтверждены документы по ООО \"Альтернатива Плюс\".", + reply_type: "factual", + created_at: "2026-04-17T18:05:00.000Z", + trace_id: "address-counterparty-seed", + debug: { + execution_lane: "address_query", + detected_mode: "address_query", + detected_intent: "list_documents_by_counterparty", + extracted_filters: { + organization, + counterparty: "Чапурнов", + period_from: "2021-03-01", + period_to: "2021-03-31" + }, + selected_recipe: "address_list_documents_by_counterparty_v1", + answer_grounding_check: { + status: "grounded" + } + } + } as any); + + const response = await service.handleMessage({ + session_id: sessionId, + user_message: message, + useMock: true + } as any); + + expect(response.ok).toBe(true); + expect(response.reply_type).toBe("factual"); + expect(calls).toHaveLength(1); + expect(calls[0].options?.activeOrganization).toBe(organization); + expect(normalizerService.normalize).not.toHaveBeenCalled(); + }); +}); diff --git a/llm_normalizer/backend/tests/assistantBoundaryPolicy.test.ts b/llm_normalizer/backend/tests/assistantBoundaryPolicy.test.ts index 7eeb295..a70df50 100644 --- a/llm_normalizer/backend/tests/assistantBoundaryPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantBoundaryPolicy.test.ts @@ -32,9 +32,10 @@ describe("assistantBoundaryPolicy", () => { organizations: ["ООО Альтернатива Плюс"] }); - expect(reply).toContain("MCP-канале `finance`"); + expect(reply).toContain("Сейчас доступна организация"); expect(reply).toContain("ООО Альтернатива Плюс"); - expect(reply.toLowerCase()).toContain("read-only"); + expect(reply).not.toContain("MCP"); + expect(reply.toLowerCase()).not.toContain("read-only"); }); it("strips unexpected CJK fragments from live chat reply", () => { diff --git a/llm_normalizer/backend/tests/assistantLivingChatMode.test.ts b/llm_normalizer/backend/tests/assistantLivingChatMode.test.ts index 6fd08a3..887e737 100644 --- a/llm_normalizer/backend/tests/assistantLivingChatMode.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingChatMode.test.ts @@ -7,7 +7,7 @@ describe("assistant living chat mode", () => { const items = [ { role: "assistant", - text: "Сейчас в активном MCP-канале `default` доступны организации (3): ООО Альтернатива Плюс, ООО Лайсвуд, РАЙМ.", + text: "Сейчас доступны организации (3): ООО Альтернатива Плюс, ООО Лайсвуд, РАЙМ. Скажите, по какой организации смотреть данные.", debug: { trace_id: "chat-org-scope", living_chat_data_scope_probe_status: "resolved", @@ -91,7 +91,7 @@ describe("assistant living chat mode", () => { message_id: "msg-seed-org-scope", session_id: sessionId, role: "assistant", - text: "Сейчас в активном MCP-канале `default` доступны организации (3): ООО Альтернатива Плюс, ООО Лайсвуд, РАЙМ.", + text: "Сейчас доступны организации (3): ООО Альтернатива Плюс, ООО Лайсвуд, РАЙМ. Скажите, по какой организации смотреть данные.", reply_type: "factual_with_explanation", created_at: new Date().toISOString(), trace_id: "chat-seed-org-scope", @@ -780,7 +780,8 @@ describe("assistant living chat mode", () => { expect(response.ok).toBe(true); expect(response.reply_type).toBe("factual_with_explanation"); - expect(String(response.assistant_reply).toLowerCase()).toContain("mcp-канал"); + expect(String(response.assistant_reply).toLowerCase()).toContain("организаций"); + expect(String(response.assistant_reply).toLowerCase()).not.toContain("mcp"); expect(response.debug?.tool_gate_reason).toBe("assistant_data_scope_query_detected"); expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract"); expect(chatClient.chat).toHaveBeenCalledTimes(0); @@ -827,7 +828,8 @@ describe("assistant living chat mode", () => { expect(response.ok).toBe(true); expect(response.reply_type).toBe("factual_with_explanation"); - expect(String(response.assistant_reply).toLowerCase()).toContain("read-only"); + expect(String(response.assistant_reply).toLowerCase()).toContain("по какой смотреть данные"); + expect(String(response.assistant_reply).toLowerCase()).not.toContain("read-only"); expect(response.debug?.tool_gate_reason).toBe("assistant_data_scope_query_detected"); expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract"); expect(chatClient.chat).toHaveBeenCalledTimes(0); diff --git a/llm_normalizer/backend/tests/assistantLivingModePolicy.test.ts b/llm_normalizer/backend/tests/assistantLivingModePolicy.test.ts index 00d4466..c6150d1 100644 --- a/llm_normalizer/backend/tests/assistantLivingModePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingModePolicy.test.ts @@ -42,6 +42,12 @@ describe("assistantLivingModePolicy", () => { ).toBe(true); }); + it("detects bare recap wording without 'помнишь' as memory signal", () => { + const policy = buildPolicy(); + + expect(policy.hasConversationMemoryRecallFollowupSignal("а что мы уже выяснили по этой позиции?")).toBe(true); + }); + it("routes casual small-talk to chat mode", () => { const policy = buildPolicy(); diff --git a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts index ea38fbe..bcddf89 100644 --- a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts @@ -1169,6 +1169,55 @@ describe("assistant orchestration contract", () => { expect(decision.livingReason).toBe("address_lane_triggered"); }); + it("keeps exact open-items lookup in address lane even when semantic guard overflags deep investigation", () => { + const rawUserMessage = "хвосты покажи по счету 60 на август 2022"; + const effectiveAddressUserMessage = "хвосты по счету 60 на август 2022"; + const predecomposeContract = buildAddressLlmPredecomposeContractV1({ + sourceMessage: rawUserMessage, + canonicalMessage: effectiveAddressUserMessage, + semanticHints: { + exact_data_request_detected: true, + account_scope_kind: "explicit", + account_scope_text: "60", + date_scope_kind: "explicit_period", + date_scope_text: "август 2022" + } + }); + const semanticExtractionContract = buildAddressSemanticExtractionContractV1({ + sourceMessage: rawUserMessage, + canonicalMessage: effectiveAddressUserMessage, + predecomposeContract + }); + + const decision = resolveAssistantOrchestrationDecision({ + rawUserMessage, + effectiveAddressUserMessage, + followupContext: null, + llmPreDecomposeMeta: { + applied: true, + llmCanonicalCandidateDetected: true, + predecomposeContract, + semanticExtractionContract: { + ...semanticExtractionContract, + guard_hints: { + ...(semanticExtractionContract.guard_hints ?? {}), + deep_investigation_signal_detected: true + } + } + } as any, + useMock: false + }); + + expect(decision.runAddressLane).toBe(true); + expect(decision.toolGateDecision).toBe("run_address_lane"); + expect(decision.livingMode).toBe("address_data"); + expect(decision.livingReason).toBe("address_lane_triggered"); + expect(decision.orchestrationContract?.deep_analysis_signal_fallback_to_deep).toBe(false); + expect( + decision.orchestrationContract?.semantic_route_arbitration?.exact_address_intent_protected_from_semantic_deep_hint + ).toBe(true); + }); + it("keeps open-contracts request in address lane even with stale deep followup context when LLM contract is absent", () => { const decision = resolveAssistantOrchestrationDecision({ rawUserMessage: "Покажи незакрытые договоры на 2020-12-31", diff --git a/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts b/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts index d12ced6..d4ea23a 100644 --- a/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts @@ -48,7 +48,19 @@ describe("assistantMemoryRecapPolicy", () => { strongDataSignal: false, aggregateBusinessAnalyticsSignal: false, lastGroundedAddressDebug: null, - hasPriorAddressDebug: true + hasPriorAddressDebug: true, + sessionItems: [ + { + role: "assistant", + debug: { + execution_lane: "address_query", + answer_grounding_check: { + status: "grounded" + }, + detected_intent: "list_documents_by_counterparty" + } + } + ] }); expect(signals.contextualHistoricalCapabilityFollowupDetected).toBe(false); @@ -67,14 +79,60 @@ describe("assistantMemoryRecapPolicy", () => { strongDataSignal: true, aggregateBusinessAnalyticsSignal: false, lastGroundedAddressDebug: null, - hasPriorAddressDebug: true + hasPriorAddressDebug: true, + sessionItems: [ + { + role: "assistant", + debug: { + execution_lane: "address_query", + answer_grounding_check: { + status: "grounded" + }, + detected_intent: "inventory_purchase_provenance_for_item", + extracted_filters: { + item: "Рабочая станция", + as_of_date: "2022-02-28" + } + } + } + ] }); expect(signals.contextualHistoricalCapabilityFollowupDetected).toBe(false); expect(signals.contextualMemoryRecapFollowupDetected).toBe(true); }); - it("builds deterministic recap from prior selected object context", () => { + it("does not trigger recap from ungrounded address history", () => { + const signals = policy.resolveRouteMemorySignals({ + rawUserMessage: "а ты помнишь что мы обсуждали?", + repairedRawUserMessage: "", + effectiveAddressUserMessage: "", + repairedEffectiveAddressUserMessage: "", + dataScopeMetaQuery: false, + capabilityMetaQuery: false, + dataRetrievalSignal: false, + strongDataSignal: false, + aggregateBusinessAnalyticsSignal: false, + lastGroundedAddressDebug: null, + hasPriorAddressDebug: true, + sessionItems: [ + { + role: "assistant", + debug: { + execution_lane: "address_query", + detected_intent: "inventory_purchase_documents_for_item", + extracted_filters: { + item: "Рабочая станция" + } + } + } + ] + }); + + expect(signals.contextualMemoryRecapFollowupDetected).toBe(false); + }); + + it("builds deterministic recap summary from recent selected-object facts", () => { const context = resolveAssistantLivingChatMemoryContext({ modeDecisionReason: "memory_recap_followup_detected", sessionItems: [ @@ -82,6 +140,9 @@ describe("assistantMemoryRecapPolicy", () => { role: "assistant", debug: { execution_lane: "address_query", + answer_grounding_check: { + status: "grounded" + }, anchor_type: "item", anchor_value_resolved: "Рабочая станция", extracted_filters: { @@ -89,6 +150,34 @@ describe("assistantMemoryRecapPolicy", () => { as_of_date: "2022-02-28" } } + }, + { + role: "assistant", + debug: { + execution_lane: "address_query", + answer_grounding_check: { + status: "grounded" + }, + detected_intent: "inventory_purchase_provenance_for_item", + extracted_filters: { + item: "Рабочая станция", + as_of_date: "2022-02-28" + } + } + }, + { + role: "assistant", + debug: { + execution_lane: "address_query", + answer_grounding_check: { + status: "grounded" + }, + detected_intent: "inventory_purchase_documents_for_item", + extracted_filters: { + item: "Рабочая станция", + as_of_date: "2022-02-28" + } + } } ] }); @@ -96,6 +185,50 @@ describe("assistantMemoryRecapPolicy", () => { const reply = buildAddressMemoryRecapReply({ organization: null, addressDebug: context.lastMemoryAddressDebug, + sessionItems: [ + { + role: "assistant", + debug: { + execution_lane: "address_query", + answer_grounding_check: { + status: "grounded" + }, + detected_intent: "inventory_on_hand_as_of_date", + extracted_filters: { + organization: "ООО Альтернатива Плюс", + as_of_date: "2022-02-28" + } + } + }, + { + role: "assistant", + debug: { + execution_lane: "address_query", + answer_grounding_check: { + status: "grounded" + }, + detected_intent: "inventory_purchase_provenance_for_item", + extracted_filters: { + item: "Рабочая станция", + as_of_date: "2022-02-28" + } + } + }, + { + role: "assistant", + debug: { + execution_lane: "address_query", + answer_grounding_check: { + status: "grounded" + }, + detected_intent: "inventory_purchase_documents_for_item", + extracted_filters: { + item: "Рабочая станция", + as_of_date: "2022-02-28" + } + } + } + ], toNonEmptyString: (value: unknown) => { const text = String(value ?? "").trim(); return text.length > 0 ? text : null; @@ -104,6 +237,8 @@ describe("assistantMemoryRecapPolicy", () => { expect(context.contextualMemoryRecapFollowup).toBe(true); expect(reply).toContain("Рабочая станция"); - expect(reply).toContain("28.02.2022"); + expect(reply).toContain("мы уже выяснили"); + expect(reply).toContain("разобрали, кто поставлял"); + expect(reply).toContain("подняли документы закупки"); }); }); diff --git a/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts b/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts index 450030c..9316df1 100644 --- a/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts @@ -294,4 +294,27 @@ describe("assistantRoutePolicy", () => { expect(decision.orchestrationContract?.unsupported_address_intent_fallback_to_deep).toBe(false); expect(decision.orchestrationContract?.provider_execution?.llm_runtime_unavailable_detected).toBe(true); }); + + it("does not classify colloquial VAT root query as non-domain when L0 address gate is positive", () => { + const policy = buildPolicy({ + hasStrongDataIntentSignal: () => true, + resolveAddressToolGateDecision: () => ({ + runAddressLane: true, + decision: "run_address_lane", + reason: "address_signal_detected" + }) + }); + + const decision = policy.resolveAssistantOrchestrationDecision({ + rawUserMessage: "скок ндс надо заплатить в налоговую на февраль 2017", + effectiveAddressUserMessage: "скок ндс надо заплатить в налоговую на февраль 2017", + followupContext: null, + llmPreDecomposeMeta: null, + useMock: true + }); + + expect(decision.runAddressLane).toBe(true); + expect(decision.toolGateReason).toBe("address_signal_detected"); + expect(decision.livingMode).toBe("address_data"); + }); }); diff --git a/llm_normalizer/backend/tests/assistantRuntimeGuardsUtf8.test.ts b/llm_normalizer/backend/tests/assistantRuntimeGuardsUtf8.test.ts new file mode 100644 index 0000000..150abc6 --- /dev/null +++ b/llm_normalizer/backend/tests/assistantRuntimeGuardsUtf8.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { resolveDomainPolarityGuard } from "../src/services/assistantRuntimeGuards"; + +describe("assistant runtime guards utf8 regression", () => { + it("resolves supplier polarity for declined russian account wording", () => { + const guard = resolveDomainPolarityGuard({ + userMessage: "хвосты покажи по счёту 60 на август 2022", + focusDomainHint: "settlements_60_62" + }); + + expect(guard.outcome).toBe("passed"); + expect(guard.polarity).toBe("supplier_payable"); + expect(guard.account_scope).toContain("60"); + expect(guard.resolved_account_anchors).toContain("60"); + }); +}); diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts index 21cae14..a17d0f9 100644 --- a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -115,6 +115,29 @@ describe("assistantTransitionPolicy", () => { }); }); + it("promotes same-date inventory restatement after drilldown into root-scoped carryover", () => { + const policy = buildPolicy({ + hasInventoryRootTemporalFollowupSignal: () => false + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext( + "покажи еще раз остатки на эту же дату", + [], + null, + null, + null + ); + + expect(carryover?.followupSelectionMode).toBe("carry_root_context"); + expect(carryover?.followupContext?.root_context_only).toBe(true); + expect(carryover?.followupContext?.previous_intent).toBeUndefined(); + expect(carryover?.followupContext?.previous_filters).toEqual({ + as_of_date: "2020-03-31", + organization: 'ООО "Альтернатива Плюс"' + }); + expect(carryover?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date"); + }); + it("builds continuation contract from extracted root carryover", () => { const policy = buildPolicy(); @@ -146,4 +169,143 @@ describe("assistantTransitionPolicy", () => { expect(contract.anchor_type).toBe("item"); expect(contract.anchor_value).toBe("Рабочая станция"); }); + it("prefers carryover target intent over llm contract drift in continuation contract", () => { + const policy = buildPolicy(); + + const contract = policy.buildAddressDialogContinuationContractV2( + "покажи договор по гамме", + "покажи договор по гамме", + { + followupContext: { + previous_intent: "customer_revenue_and_payments", + target_intent: "list_contracts_by_counterparty", + previous_anchor_type: "counterparty", + previous_anchor_value: "Гамма-мебель, ООО" + }, + previousSourceIntent: "customer_revenue_and_payments", + previousAddressIntent: "customer_revenue_and_payments", + followupSelectionMode: "carry_referenced_entity", + hasImplicitContinuationSignal: false + }, + { + predecomposeContract: { + intent: "unknown" + } + } + ); + + expect(contract.target_intent).toBe("list_contracts_by_counterparty"); + expect(contract.decision).toBe("continue_previous"); + }); + + it("drops stale carryover for a fresh standalone topic from another intent family", () => { + const policy = buildPolicy({ + findLastAddressAssistantItem: () => ({ + text: "Прогноз НДС на март 2020 собран.", + debug: { + detected_intent: "vat_payable_forecast", + extracted_filters: { + period_from: "2020-03-01", + period_to: "2020-03-31" + } + } + }), + hasAddressFollowupContextSignal: () => true, + hasStandaloneAddressTopicSignal: () => true, + resolveAddressIntent: () => ({ intent: "inventory_on_hand_as_of_date" }), + resolveAddressIntentFamily: (intent: unknown) => { + if (String(intent ?? "").startsWith("vat_")) return "vat"; + if (String(intent ?? "").startsWith("inventory_")) return "inventory"; + return null; + } + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext( + "остаток на складе за май 2020", + [], + null, + null, + null + ); + + expect(carryover).toBeNull(); + }); + + it("keeps document intent for short counterparty retarget wording with action verb", () => { + const policy = buildPolicy({ + findLastAddressAssistantItem: () => ({ + text: "Собран список документов по контрагенту Чапурнов.", + debug: { + detected_intent: "list_documents_by_counterparty", + extracted_filters: { + counterparty: "Чапурнов" + }, + anchor_type: "counterparty", + anchor_value_resolved: "Чапурнов" + } + }), + buildAddressFollowupOffer: () => ({ + enabled: true, + source_intent: "list_documents_by_counterparty", + suggested_intents: ["bank_operations_by_counterparty"] + }), + isImplicitAddressContinuationByLlm: () => true + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext("покажи по свк", [], null, null, null); + + expect(carryover?.followupContext?.previous_intent).toBe("list_documents_by_counterparty"); + expect(carryover?.followupSelectionMode).toBe("carry_previous_intent"); + }); + it("keeps root-scoped carryover for foreign accounting pivot over inventory drilldown", () => { + const policy = buildPolicy({ + findLastAddressAssistantItem: () => ({ + text: "Собран sale trace по позиции.", + debug: { + detected_intent: "inventory_sale_trace_for_item", + extracted_filters: { + item: "Кромка с клеем 33 дуб ниагара 137 м", + organization: 'ООО "Альтернатива Плюс"', + as_of_date: "2021-03-31" + }, + anchor_type: "item", + anchor_value_resolved: "Кромка с клеем 33 дуб ниагара 137 м" + } + }), + hasAddressFollowupContextSignal: () => true, + resolveAddressIntent: () => ({ intent: "vat_payable_confirmed_as_of_date" }), + resolveAddressIntentFamily: (intent: unknown) => { + if (String(intent ?? "").startsWith("vat_")) return "vat"; + if (String(intent ?? "").startsWith("inventory_")) return "inventory"; + return null; + }, + hasForeignAccountingPivotOverInventoryMessage: () => true, + findRecentInventoryRootFrame: () => ({ + intent: "inventory_on_hand_as_of_date", + filters: { + organization: 'ООО "Альтернатива Плюс"', + warehouse: "Основной склад", + as_of_date: "2021-03-31", + period_from: "2021-03-01", + period_to: "2021-03-31" + }, + anchorType: "organization", + anchorValue: 'ООО "Альтернатива Плюс"' + }) + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext("а ндс?", [], null, null, null); + + expect(carryover?.followupSelectionMode).toBe("carry_root_context"); + expect(carryover?.followupContext?.root_context_only).toBe(true); + expect(carryover?.followupContext?.previous_intent).toBeUndefined(); + expect(carryover?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date"); + expect(carryover?.followupContext?.previous_filters).toEqual({ + organization: 'ООО "Альтернатива Плюс"', + warehouse: "Основной склад", + as_of_date: "2021-03-31", + period_from: "2021-03-01", + period_to: "2021-03-31" + }); + }); }); diff --git a/llm_normalizer/backend/tests/counterpartyAnalyticsReplyBuilders.test.ts b/llm_normalizer/backend/tests/counterpartyAnalyticsReplyBuilders.test.ts new file mode 100644 index 0000000..b56e9d6 --- /dev/null +++ b/llm_normalizer/backend/tests/counterpartyAnalyticsReplyBuilders.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; + +import { composeFactualReply } from "../src/services/address_runtime/composeStage"; + +describe("counterparty analytics reply builders", () => { + it("keeps counterparty role split answers business-first", () => { + const reply = composeFactualReply( + "counterparty_population_and_roles", + [ + { + period: "2000-01-01T00:00:00Z", + registrator: "CP_TOTAL", + account_dt: "", + account_kt: "", + amount: 30, + analytics: [] + }, + { + period: "2000-01-01T00:00:00Z", + registrator: "CP_CUSTOMER_ACTIVE", + account_dt: "", + account_kt: "", + amount: 12, + analytics: [] + }, + { + period: "2000-01-01T00:00:00Z", + registrator: "CP_SUPPLIER_ACTIVE", + account_dt: "", + account_kt: "", + amount: 9, + analytics: [] + }, + { + period: "2000-01-01T00:00:00Z", + registrator: "CP_MIXED_ACTIVE", + account_dt: "", + account_kt: "", + amount: 4, + analytics: [] + } + ], + { userMessage: "покажи роли контрагентов" } + ); + + expect(reply.responseType).toBe("FACTUAL_SUMMARY"); + expect(reply.text).toContain("Распределение ролей по активности:"); + expect(reply.text).not.toContain("supplier-роль"); + expect(reply.text).not.toContain("customer-роль"); + }); + + it("formats value rankings without technical 'max single' label", () => { + const reply = composeFactualReply( + "customer_revenue_and_payments", + [ + { + period: "2020-03-01T00:00:00Z", + registrator: "Поступление 1", + account_dt: "", + account_kt: "", + amount: 500, + analytics: ["Клиент А", "Договор А-1"] + }, + { + period: "2020-03-02T00:00:00Z", + registrator: "Поступление 2", + account_dt: "", + account_kt: "", + amount: 1200, + analytics: ["Клиент Б", "Договор Б-1"] + } + ], + { userMessage: "с каких клиенктов самый высокий чек" } + ); + + expect(reply.responseType).toBe("FACTUAL_LIST"); + expect(reply.text).toContain("максимальной сумме одной входящей операции"); + expect(reply.text).toContain("максимальная разовая сумма"); + expect(reply.text).not.toContain("max single"); + }); + + it("explains organization activity age as 1C activity rather than legal age", () => { + const reply = composeFactualReply( + "counterparty_activity_lifecycle", + [ + { + period: "2020-01-15T00:00:00Z", + registrator: "CP_CUSTOMER_ACTIVITY", + account_dt: "62.01", + account_kt: "90.01", + amount: 12, + analytics: ['ООО "Ромашка"'] + }, + { + period: "2024-03-10T00:00:00Z", + registrator: "CP_CUSTOMER_ACTIVITY", + account_dt: "62.01", + account_kt: "90.01", + amount: 4, + analytics: ['ООО "Ландыш"'] + } + ], + { + userMessage: "сколько лет активности в базе 1с у нашей компании", + organizationHint: 'ООО "Альтернатива Плюс"' + } + ); + + expect(reply.responseType).toBe("FACTUAL_SUMMARY"); + expect(reply.text).toContain('По активности организации ООО "Альтернатива Плюс" в базе 1С'); + expect(reply.text).toContain("Это возраст активности организации в 1С"); + }); + + it("renders contract usage overview with explicit confirmed wording", () => { + const reply = composeFactualReply("contract_usage_overview", [ + { + period: "2000-01-01T00:00:00Z", + registrator: "CT_TOTAL", + account_dt: "", + account_kt: "", + amount: 520, + analytics: [] + }, + { + period: "2000-01-01T00:00:00Z", + registrator: "CT_USED", + account_dt: "", + account_kt: "", + amount: 148, + analytics: [] + } + ]); + + expect(reply.responseType).toBe("FACTUAL_SUMMARY"); + expect(reply.text).toContain("Профиль договорной базы собран по справочнику и подтвержденным операциям."); + expect(reply.text).toContain("Использованных договоров с подтвержденной связью с операциями: 148."); + }); +}); diff --git a/llm_normalizer/data/autorun_annotations/annotations.json b/llm_normalizer/data/autorun_annotations/annotations.json index ba0b2be..986cfaa 100644 --- a/llm_normalizer/data/autorun_annotations/annotations.json +++ b/llm_normalizer/data/autorun_annotations/annotations.json @@ -889,5 +889,32 @@ "question_text": "Покажи контрагентов, чьи заказы на отгрузку еще не оплачены, но сальдо уже отрицательное - это явный признак того, что нужно вмешаться.", "answer_text": "Сейчас этот тип вопроса вне поддерживаемого контура адресного режима.\nКоротко: этот сценарий пока не поддержан в текущем адресном контуре.\nЧто можно сделать дальше: могу проверить близкие сценарии: документы/платежи по контрагенту, договоры или остаток по счету." } + }, + { + "annotation_id": "ann-mo39hhmv-l57x4zz", + "run_id": "assistant-stage1-mddpEDjfFS", + "case_id": "SAVED-001", + "session_id": "assistant-stage1-mddpEDjfFS-SAVED-001", + "message_index": 17, + "rating": 3, + "comment": "кейс был отработан - сейчас не работает", + "manual_case_decision": "needs_dialog_policy_fix", + "annotation_author": "manual_reviewer", + "resolved": false, + "resolved_at": null, + "resolved_by": null, + "created_at": "2026-04-17T18:48:37.254Z", + "updated_at": "2026-04-17T18:48:37.254Z", + "context": { + "message_id": "msg-Qg69owOb5S", + "trace_id": "address-J5BLsXfr3e", + "reply_type": "partial_coverage", + "eval_target": "assistant_stage1", + "prompt_version": "address_query_runtime_v1", + "domain": null, + "query_class": null, + "question_text": "а по Альтернативе Плюс сколько лет активности в базе 1С?", + "answer_text": "На данный момент не удается точно определить, сколько лет Альтернатива Плюс активна в базе 1С — данные по истории деятельности контрагента в текущем сценарии недоступны.\n\nПроверено: документы, платежи и договоры по ООО «Альтернатива Плюс» за последние периоды. Результатов, подтверждающих начало активности, не обнаружено.\n\nМогу уточнить остаток по счёту или проверить наличие договоров/платежей за конкретные годы — сообщите, какой период вас интересует." + } } ] \ No newline at end of file diff --git a/llm_normalizer/data/autorun_generators/history.json b/llm_normalizer/data/autorun_generators/history.json index 001dc8b..c43587d 100644 --- a/llm_normalizer/data/autorun_generators/history.json +++ b/llm_normalizer/data/autorun_generators/history.json @@ -104,61 +104,6 @@ "latest_acceptance": null } }, - { - "generation_id": "gen-ag04171208-438c43", - "created_at": "2026-04-17T12:08:33+00:00", - "mode": "saved_user_sessions", - "title": "AGENT | AssistantService boundary and transition delegation replay over mixed contextual chains", - "count": 11, - "domain": "address_phase5_assistantservice_boundary_transition_mix", - "questions": [ - "привет, как дела?", - "по какой компании мы сейчас работаем?", - "какие остатки на складе на март 2021", - "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?", - "По выбранному объекту \"Столешница 600*3050*26 альмандин\": покажи документы по этой позиции", - "покажи еще раз остатки на эту же дату", - "а какой возраст у Альтернативы Плюс?", - "что ты умеешь?", - "а ты помнишь, что мы по этой позиции уже выяснили?", - "кто нам должен на март 2020", - "остатки по складу на эту же дату" - ], - "generated_by": "codex_agent", - "saved_case_set_file": "assistant_autogen_saved_user_sessions_20260417120833_gen-ag04171208-438c43.json", - "context": { - "llm_provider": null, - "model": null, - "assistant_prompt_version": null, - "decomposition_prompt_version": null, - "prompt_fingerprint": null, - "autogen_personality_id": null, - "autogen_personality_prompt": null, - "source_session_id": null, - "saved_session_file": "assistant_saved_session_20260417120833_gen-ag04171208-438c43.json", - "saved_case_set_kind": "agent_semantic_scenario", - "agent_run": true, - "agent_focus": "assistantService boundary+transition delegation", - "architecture_phase": "turnaround_11", - "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase5_assistantservice_boundary_transition_mix.json", - "scenario_id": "address_truth_harness_phase5_assistantservice_boundary_transition_mix", - "semantic_tags": [ - "inventory_root", - "meta_capability", - "meta_memory", - "meta_scope", - "meta_smalltalk", - "organization_fact_boundary", - "same_date_pivot", - "same_date_restore", - "selected_object", - "selected_object_documents", - "selected_object_supplier", - "settlements_receivables" - ], - "latest_acceptance": null - } - }, { "generation_id": "gen-ag04170941-87680e", "created_at": "2026-04-17T09:41:32+00:00", diff --git a/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260417120833_gen-ag04171208-438c43.json b/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260417120833_gen-ag04171208-438c43.json deleted file mode 100644 index c4d5850..0000000 --- a/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260417120833_gen-ag04171208-438c43.json +++ /dev/null @@ -1,173 +0,0 @@ -{ - "saved_at": "2026-04-17T12:08:33+00:00", - "generation_id": "gen-ag04171208-438c43", - "mode": "saved_user_sessions", - "title": "AGENT | AssistantService boundary and transition delegation replay over mixed contextual chains", - "agent_run": true, - "questions": [ - "привет, как дела?", - "по какой компании мы сейчас работаем?", - "какие остатки на складе на март 2021", - "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?", - "По выбранному объекту \"Столешница 600*3050*26 альмандин\": покажи документы по этой позиции", - "покажи еще раз остатки на эту же дату", - "а какой возраст у Альтернативы Плюс?", - "что ты умеешь?", - "а ты помнишь, что мы по этой позиции уже выяснили?", - "кто нам должен на март 2020", - "остатки по складу на эту же дату" - ], - "metadata": { - "assistant_prompt_version": null, - "decomposition_prompt_version": null, - "prompt_fingerprint": null, - "agent_focus": "assistantService boundary+transition delegation", - "architecture_phase": "turnaround_11", - "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase5_assistantservice_boundary_transition_mix.json", - "scenario_id": "address_truth_harness_phase5_assistantservice_boundary_transition_mix", - "semantic_tags": [ - "inventory_root", - "meta_capability", - "meta_memory", - "meta_scope", - "meta_smalltalk", - "organization_fact_boundary", - "same_date_pivot", - "same_date_restore", - "selected_object", - "selected_object_documents", - "selected_object_supplier", - "settlements_receivables" - ] - }, - "source_session_id": null, - "session": { - "session_id": null, - "mode": "agent_semantic_run", - "items": [ - { - "message_id": "agent-user-001", - "role": "user", - "text": "привет, как дела?", - "created_at": "2026-04-17T12:08:33+00:00", - "reply_type": null, - "trace_id": null, - "debug": null - }, - { - "message_id": "agent-user-002", - "role": "user", - "text": "по какой компании мы сейчас работаем?", - "created_at": "2026-04-17T12:08:33+00:00", - "reply_type": null, - "trace_id": null, - "debug": null - }, - { - "message_id": "agent-user-003", - "role": "user", - "text": "какие остатки на складе на март 2021", - "created_at": "2026-04-17T12:08:33+00:00", - "reply_type": null, - "trace_id": null, - "debug": null - }, - { - "message_id": "agent-user-004", - "role": "user", - "text": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?", - "created_at": "2026-04-17T12:08:33+00:00", - "reply_type": null, - "trace_id": null, - "debug": null - }, - { - "message_id": "agent-user-005", - "role": "user", - "text": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": покажи документы по этой позиции", - "created_at": "2026-04-17T12:08:33+00:00", - "reply_type": null, - "trace_id": null, - "debug": null - }, - { - "message_id": "agent-user-006", - "role": "user", - "text": "покажи еще раз остатки на эту же дату", - "created_at": "2026-04-17T12:08:33+00:00", - "reply_type": null, - "trace_id": null, - "debug": null - }, - { - "message_id": "agent-user-007", - "role": "user", - "text": "а какой возраст у Альтернативы Плюс?", - "created_at": "2026-04-17T12:08:33+00:00", - "reply_type": null, - "trace_id": null, - "debug": null - }, - { - "message_id": "agent-user-008", - "role": "user", - "text": "что ты умеешь?", - "created_at": "2026-04-17T12:08:33+00:00", - "reply_type": null, - "trace_id": null, - "debug": null - }, - { - "message_id": "agent-user-009", - "role": "user", - "text": "а ты помнишь, что мы по этой позиции уже выяснили?", - "created_at": "2026-04-17T12:08:33+00:00", - "reply_type": null, - "trace_id": null, - "debug": null - }, - { - "message_id": "agent-user-010", - "role": "user", - "text": "кто нам должен на март 2020", - "created_at": "2026-04-17T12:08:33+00:00", - "reply_type": null, - "trace_id": null, - "debug": null - }, - { - "message_id": "agent-user-011", - "role": "user", - "text": "остатки по складу на эту же дату", - "created_at": "2026-04-17T12:08:33+00:00", - "reply_type": null, - "trace_id": null, - "debug": null - } - ], - "agent_run": true, - "metadata": { - "assistant_prompt_version": null, - "decomposition_prompt_version": null, - "prompt_fingerprint": null, - "agent_focus": "assistantService boundary+transition delegation", - "architecture_phase": "turnaround_11", - "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase5_assistantservice_boundary_transition_mix.json", - "scenario_id": "address_truth_harness_phase5_assistantservice_boundary_transition_mix", - "semantic_tags": [ - "inventory_root", - "meta_capability", - "meta_memory", - "meta_scope", - "meta_smalltalk", - "organization_fact_boundary", - "same_date_pivot", - "same_date_restore", - "selected_object", - "selected_object_documents", - "selected_object_supplier", - "settlements_receivables" - ] - } - } -} diff --git a/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-9q-mFr32_l.json b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-9q-mFr32_l.json new file mode 100644 index 0000000..6105c3d --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-9q-mFr32_l.json @@ -0,0 +1,45 @@ +{ + "suite_id": "assistant_saved_session_runtime_job-9q-mFr32_l", + "suite_version": "0.1.0", + "schema_version": "assistant_saved_session_runtime_v0_1", + "title": "AGENT replay for inventory clarification continuity and answer-shape cleanliness", + "scenario_count": 1, + "case_ids": [ + "SAVED-001" + ], + "cases": [ + { + "case_id": "SAVED-001", + "scenario_tag": "saved_user_sessions_runtime", + "title": "AGENT replay for inventory clarification continuity and answer-shape cleanliness", + "question_type": "followup", + "broadness_level": "medium", + "turns": [ + { + "user_message": "какие остатки на складе на март 2021" + }, + { + "user_message": "давай по Альтернативе Плюс" + }, + { + "user_message": "тогда покажи остатки на март 2021" + }, + { + "user_message": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?" + }, + { + "user_message": "а по этой позиции когда была закупка?" + }, + { + "user_message": "покажи документы по этой позиции" + }, + { + "user_message": "покажи еще раз остатки на эту же дату" + }, + { + "user_message": "а что мы уже выяснили по этой позиции?" + } + ] + } + ] +} \ No newline at end of file diff --git a/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-AeqlSvQhbj.json b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-AeqlSvQhbj.json new file mode 100644 index 0000000..008345f --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-AeqlSvQhbj.json @@ -0,0 +1,45 @@ +{ + "suite_id": "assistant_saved_session_runtime_job-AeqlSvQhbj", + "suite_version": "0.1.0", + "schema_version": "assistant_saved_session_runtime_v0_1", + "title": "AGENT replay for inventory clarification continuity and answer-shape cleanliness", + "scenario_count": 1, + "case_ids": [ + "SAVED-001" + ], + "cases": [ + { + "case_id": "SAVED-001", + "scenario_tag": "saved_user_sessions_runtime", + "title": "AGENT replay for inventory clarification continuity and answer-shape cleanliness", + "question_type": "followup", + "broadness_level": "medium", + "turns": [ + { + "user_message": "какие остатки на складе на март 2021" + }, + { + "user_message": "давай по Альтернативе Плюс" + }, + { + "user_message": "тогда покажи остатки на март 2021" + }, + { + "user_message": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?" + }, + { + "user_message": "а по этой позиции когда была закупка?" + }, + { + "user_message": "покажи документы по этой позиции" + }, + { + "user_message": "покажи еще раз остатки на эту же дату" + }, + { + "user_message": "а что мы уже выяснили по этой позиции?" + } + ] + } + ] +} \ No newline at end of file diff --git a/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-OQF8AjY_Dj.json b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-OQF8AjY_Dj.json new file mode 100644 index 0000000..748f5ba --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-OQF8AjY_Dj.json @@ -0,0 +1,45 @@ +{ + "suite_id": "assistant_saved_session_runtime_job-OQF8AjY_Dj", + "suite_version": "0.1.0", + "schema_version": "assistant_saved_session_runtime_v0_1", + "title": "AGENT replay for inventory clarification continuity and answer-shape cleanliness", + "scenario_count": 1, + "case_ids": [ + "SAVED-001" + ], + "cases": [ + { + "case_id": "SAVED-001", + "scenario_tag": "saved_user_sessions_runtime", + "title": "AGENT replay for inventory clarification continuity and answer-shape cleanliness", + "question_type": "followup", + "broadness_level": "medium", + "turns": [ + { + "user_message": "какие остатки на складе на март 2021" + }, + { + "user_message": "давай по Альтернативе Плюс" + }, + { + "user_message": "тогда покажи остатки на март 2021" + }, + { + "user_message": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?" + }, + { + "user_message": "а по этой позиции когда была закупка?" + }, + { + "user_message": "покажи документы по этой позиции" + }, + { + "user_message": "покажи еще раз остатки на эту же дату" + }, + { + "user_message": "а что мы уже выяснили по этой позиции?" + } + ] + } + ] +} \ No newline at end of file diff --git a/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-T_gjp0aldl.json b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-T_gjp0aldl.json new file mode 100644 index 0000000..57fa898 --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-T_gjp0aldl.json @@ -0,0 +1,63 @@ +{ + "suite_id": "assistant_saved_session_runtime_job-T_gjp0aldl", + "suite_version": "0.1.0", + "schema_version": "assistant_saved_session_runtime_v0_1", + "title": "AGENT | Phase 7 mixed replay for documents, selected-object continuity, meta context, and cross-domain pivots", + "scenario_count": 1, + "case_ids": [ + "SAVED-001" + ], + "cases": [ + { + "case_id": "SAVED-001", + "scenario_tag": "saved_user_sessions_runtime", + "title": "AGENT | Phase 7 mixed replay for documents, selected-object continuity, meta context, and cross-domain pivots", + "question_type": "followup", + "broadness_level": "medium", + "turns": [ + { + "user_message": "привет, как дела?" + }, + { + "user_message": "по какой компании мы сейчас работаем?" + }, + { + "user_message": "покажи все документы по чепурнову" + }, + { + "user_message": "что нам отгружал чепурнов, какой товар или услугу?" + }, + { + "user_message": "какие остатки на складе на март 2021" + }, + { + "user_message": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?" + }, + { + "user_message": "что ты умеешь?" + }, + { + "user_message": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": покажи документы по этой позиции" + }, + { + "user_message": "а ты помнишь, что мы по этой позиции уже выяснили?" + }, + { + "user_message": "покажи еще раз остатки на эту же дату" + }, + { + "user_message": "кто нам должен на март 2020" + }, + { + "user_message": "остатки по складу на эту же дату" + }, + { + "user_message": "а исторические остатки тоже можешь?" + }, + { + "user_message": "хвосты покажи по счету 60 на август 2022" + } + ] + } + ] +} \ No newline at end of file diff --git a/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-U2sK02tw52.json b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-U2sK02tw52.json new file mode 100644 index 0000000..71d4236 --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-U2sK02tw52.json @@ -0,0 +1,45 @@ +{ + "suite_id": "assistant_saved_session_runtime_job-U2sK02tw52", + "suite_version": "0.1.0", + "schema_version": "assistant_saved_session_runtime_v0_1", + "title": "AGENT replay for inventory clarification continuity and answer-shape cleanliness", + "scenario_count": 1, + "case_ids": [ + "SAVED-001" + ], + "cases": [ + { + "case_id": "SAVED-001", + "scenario_tag": "saved_user_sessions_runtime", + "title": "AGENT replay for inventory clarification continuity and answer-shape cleanliness", + "question_type": "followup", + "broadness_level": "medium", + "turns": [ + { + "user_message": "какие остатки на складе на март 2021" + }, + { + "user_message": "давай по Альтернативе Плюс" + }, + { + "user_message": "тогда покажи остатки на март 2021" + }, + { + "user_message": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?" + }, + { + "user_message": "а по этой позиции когда была закупка?" + }, + { + "user_message": "покажи документы по этой позиции" + }, + { + "user_message": "покажи еще раз остатки на эту же дату" + }, + { + "user_message": "а что мы уже выяснили по этой позиции?" + } + ] + } + ] +} \ No newline at end of file diff --git a/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-UAA_a19eKd.json b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-UAA_a19eKd.json new file mode 100644 index 0000000..4ff21d5 --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-UAA_a19eKd.json @@ -0,0 +1,63 @@ +{ + "suite_id": "assistant_saved_session_runtime_job-UAA_a19eKd", + "suite_version": "0.1.0", + "schema_version": "assistant_saved_session_runtime_v0_1", + "title": "AGENT | Phase 7 mixed replay for documents, selected-object continuity, meta context, and cross-domain pivots", + "scenario_count": 1, + "case_ids": [ + "SAVED-001" + ], + "cases": [ + { + "case_id": "SAVED-001", + "scenario_tag": "saved_user_sessions_runtime", + "title": "AGENT | Phase 7 mixed replay for documents, selected-object continuity, meta context, and cross-domain pivots", + "question_type": "followup", + "broadness_level": "medium", + "turns": [ + { + "user_message": "привет, как дела?" + }, + { + "user_message": "по какой компании мы сейчас работаем?" + }, + { + "user_message": "покажи все документы по чепурнову" + }, + { + "user_message": "что нам отгружал чепурнов, какой товар или услугу?" + }, + { + "user_message": "какие остатки на складе на март 2021" + }, + { + "user_message": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?" + }, + { + "user_message": "что ты умеешь?" + }, + { + "user_message": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": покажи документы по этой позиции" + }, + { + "user_message": "а ты помнишь, что мы по этой позиции уже выяснили?" + }, + { + "user_message": "покажи еще раз остатки на эту же дату" + }, + { + "user_message": "кто нам должен на март 2020" + }, + { + "user_message": "остатки по складу на эту же дату" + }, + { + "user_message": "а исторические остатки тоже можешь?" + }, + { + "user_message": "хвосты покажи по счету 60 на август 2022" + } + ] + } + ] +} \ No newline at end of file diff --git a/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-ciAaI60jFN.json b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-ciAaI60jFN.json new file mode 100644 index 0000000..9045eee --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-ciAaI60jFN.json @@ -0,0 +1,63 @@ +{ + "suite_id": "assistant_saved_session_runtime_job-ciAaI60jFN", + "suite_version": "0.1.0", + "schema_version": "assistant_saved_session_runtime_v0_1", + "title": "AGENT | Phase 7 mixed replay for documents, selected-object continuity, meta context, and cross-domain pivots", + "scenario_count": 1, + "case_ids": [ + "SAVED-001" + ], + "cases": [ + { + "case_id": "SAVED-001", + "scenario_tag": "saved_user_sessions_runtime", + "title": "AGENT | Phase 7 mixed replay for documents, selected-object continuity, meta context, and cross-domain pivots", + "question_type": "followup", + "broadness_level": "medium", + "turns": [ + { + "user_message": "привет, как дела?" + }, + { + "user_message": "по какой компании мы сейчас работаем?" + }, + { + "user_message": "покажи все документы по чепурнову" + }, + { + "user_message": "что нам отгружал чепурнов, какой товар или услугу?" + }, + { + "user_message": "какие остатки на складе на март 2021" + }, + { + "user_message": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?" + }, + { + "user_message": "что ты умеешь?" + }, + { + "user_message": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": покажи документы по этой позиции" + }, + { + "user_message": "а ты помнишь, что мы по этой позиции уже выяснили?" + }, + { + "user_message": "покажи еще раз остатки на эту же дату" + }, + { + "user_message": "кто нам должен на март 2020" + }, + { + "user_message": "остатки по складу на эту же дату" + }, + { + "user_message": "а исторические остатки тоже можешь?" + }, + { + "user_message": "хвосты покажи по счету 60 на август 2022" + } + ] + } + ] +} \ No newline at end of file diff --git a/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260417120833_gen-ag04171208-438c43.json b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-ejhSFoBbRu.json similarity index 68% rename from llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260417120833_gen-ag04171208-438c43.json rename to llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-ejhSFoBbRu.json index 54ec011..edef10a 100644 --- a/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260417120833_gen-ag04171208-438c43.json +++ b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-ejhSFoBbRu.json @@ -1,12 +1,8 @@ { - "suite_id": "assistant_saved_session_gen-ag04171208-438c43", + "suite_id": "assistant_saved_session_runtime_job-ejhSFoBbRu", "suite_version": "0.1.0", - "schema_version": "assistant_saved_session_suite_v0_1", - "generated_at": "2026-04-17T12:08:33+00:00", - "generation_id": "gen-ag04171208-438c43", - "mode": "saved_user_sessions", - "title": "AGENT | AssistantService boundary and transition delegation replay over mixed contextual chains", - "domain": "address_phase5_assistantservice_boundary_transition_mix", + "schema_version": "assistant_saved_session_runtime_v0_1", + "title": "AGENT replay for company selection continuity and organization activity age", "scenario_count": 1, "case_ids": [ "SAVED-001" @@ -14,8 +10,8 @@ "cases": [ { "case_id": "SAVED-001", - "scenario_tag": "agent_saved_user_sessions", - "title": "AGENT | AssistantService boundary and transition delegation replay over mixed contextual chains", + "scenario_tag": "saved_user_sessions_runtime", + "title": "AGENT replay for company selection continuity and organization activity age", "question_type": "followup", "broadness_level": "medium", "turns": [ @@ -28,6 +24,12 @@ { "user_message": "какие остатки на складе на март 2021" }, + { + "user_message": "давай по Альтернативе Плюс" + }, + { + "user_message": "тогда покажи остатки на март 2021" + }, { "user_message": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?" }, @@ -38,7 +40,7 @@ "user_message": "покажи еще раз остатки на эту же дату" }, { - "user_message": "а какой возраст у Альтернативы Плюс?" + "user_message": "а по Альтернативе Плюс сколько лет активности в базе 1С?" }, { "user_message": "что ты умеешь?" @@ -55,4 +57,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-pPnB2zHtwo.json b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-pPnB2zHtwo.json new file mode 100644 index 0000000..37f6585 --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-pPnB2zHtwo.json @@ -0,0 +1,60 @@ +{ + "suite_id": "assistant_saved_session_runtime_job-pPnB2zHtwo", + "suite_version": "0.1.0", + "schema_version": "assistant_saved_session_runtime_v0_1", + "title": "AGENT replay for company selection continuity and organization activity age", + "scenario_count": 1, + "case_ids": [ + "SAVED-001" + ], + "cases": [ + { + "case_id": "SAVED-001", + "scenario_tag": "saved_user_sessions_runtime", + "title": "AGENT replay for company selection continuity and organization activity age", + "question_type": "followup", + "broadness_level": "medium", + "turns": [ + { + "user_message": "привет, как дела?" + }, + { + "user_message": "по какой компании мы сейчас работаем?" + }, + { + "user_message": "какие остатки на складе на март 2021" + }, + { + "user_message": "давай по Альтернативе Плюс" + }, + { + "user_message": "тогда покажи остатки на март 2021" + }, + { + "user_message": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?" + }, + { + "user_message": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": покажи документы по этой позиции" + }, + { + "user_message": "покажи еще раз остатки на эту же дату" + }, + { + "user_message": "а по Альтернативе Плюс сколько лет активности в базе 1С?" + }, + { + "user_message": "что ты умеешь?" + }, + { + "user_message": "а ты помнишь, что мы по этой позиции уже выяснили?" + }, + { + "user_message": "кто нам должен на март 2020" + }, + { + "user_message": "остатки по складу на эту же дату" + } + ] + } + ] +} \ No newline at end of file diff --git a/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-sGN4Z9zVS_.json b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-sGN4Z9zVS_.json new file mode 100644 index 0000000..b56dde5 --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-sGN4Z9zVS_.json @@ -0,0 +1,60 @@ +{ + "suite_id": "assistant_saved_session_runtime_job-sGN4Z9zVS_", + "suite_version": "0.1.0", + "schema_version": "assistant_saved_session_runtime_v0_1", + "title": "AGENT replay for company selection continuity and organization activity age", + "scenario_count": 1, + "case_ids": [ + "SAVED-001" + ], + "cases": [ + { + "case_id": "SAVED-001", + "scenario_tag": "saved_user_sessions_runtime", + "title": "AGENT replay for company selection continuity and organization activity age", + "question_type": "followup", + "broadness_level": "medium", + "turns": [ + { + "user_message": "привет, как дела?" + }, + { + "user_message": "по какой компании мы сейчас работаем?" + }, + { + "user_message": "какие остатки на складе на март 2021" + }, + { + "user_message": "давай по Альтернативе Плюс" + }, + { + "user_message": "тогда покажи остатки на март 2021" + }, + { + "user_message": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?" + }, + { + "user_message": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": покажи документы по этой позиции" + }, + { + "user_message": "покажи еще раз остатки на эту же дату" + }, + { + "user_message": "а по Альтернативе Плюс сколько лет активности в базе 1С?" + }, + { + "user_message": "что ты умеешь?" + }, + { + "user_message": "а ты помнишь, что мы по этой позиции уже выяснили?" + }, + { + "user_message": "кто нам должен на март 2020" + }, + { + "user_message": "остатки по складу на эту же дату" + } + ] + } + ] +} \ No newline at end of file diff --git a/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-tJRWJ4xOQ-.json b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-tJRWJ4xOQ-.json new file mode 100644 index 0000000..276484b --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-tJRWJ4xOQ-.json @@ -0,0 +1,45 @@ +{ + "suite_id": "assistant_saved_session_runtime_job-tJRWJ4xOQ-", + "suite_version": "0.1.0", + "schema_version": "assistant_saved_session_runtime_v0_1", + "title": "AGENT replay for inventory clarification continuity and answer-shape cleanliness", + "scenario_count": 1, + "case_ids": [ + "SAVED-001" + ], + "cases": [ + { + "case_id": "SAVED-001", + "scenario_tag": "saved_user_sessions_runtime", + "title": "AGENT replay for inventory clarification continuity and answer-shape cleanliness", + "question_type": "followup", + "broadness_level": "medium", + "turns": [ + { + "user_message": "какие остатки на складе на март 2021" + }, + { + "user_message": "давай по Альтернативе Плюс" + }, + { + "user_message": "тогда покажи остатки на март 2021" + }, + { + "user_message": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?" + }, + { + "user_message": "а по этой позиции когда была закупка?" + }, + { + "user_message": "покажи документы по этой позиции" + }, + { + "user_message": "покажи еще раз остатки на эту же дату" + }, + { + "user_message": "а что мы уже выяснили по этой позиции?" + } + ] + } + ] +} \ No newline at end of file