АРЧ АП11 - Архитектура после ге :
This commit is contained in:
parent
8f9364e7c9
commit
a5ea9adf53
|
|
@ -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.<anonymous> (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
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
|
||||||
|
> llm-normalizer-backend@0.1.0 start
|
||||||
|
> node dist/server.js
|
||||||
|
|
||||||
|
|
@ -20,29 +20,33 @@ This snapshot is based on:
|
||||||
- current owner modules in `llm_normalizer/backend/src/services/`
|
- current owner modules in `llm_normalizer/backend/src/services/`
|
||||||
- current scenario acceptance scripts under `scripts/`
|
- current scenario acceptance scripts under `scripts/`
|
||||||
- current AGENT semantic source catalog under `docs/orchestration/`
|
- 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
|
## Graph Snapshot
|
||||||
|
|
||||||
Latest graph rebuild:
|
Latest graph rebuild:
|
||||||
|
|
||||||
- `5287 nodes`
|
- `5312 nodes`
|
||||||
- `11407 edges`
|
- `11408 edges`
|
||||||
- `135 communities`
|
- `136 communities`
|
||||||
|
|
||||||
Most relevant current god nodes for turnaround `11`:
|
Most relevant current god nodes for turnaround `11`:
|
||||||
|
|
||||||
1. `resolveAddressIntent()`
|
1. `resolveAddressIntent()`
|
||||||
2. `ChannelRegistry`
|
2. `ChannelRegistry`
|
||||||
3. `CanonicalStore`
|
3. `CanonicalStore`
|
||||||
4. `composeFactualReplyBody()`
|
4. `compactWhitespace()`
|
||||||
5. `compactWhitespace()`
|
5. `ExecuteQueryParams`
|
||||||
|
|
||||||
The relevant conclusion is not that every god node is part of turnaround `11`.
|
The relevant conclusion is not that every god node is part of turnaround `11`.
|
||||||
|
|
||||||
The relevant conclusion is:
|
The relevant conclusion is:
|
||||||
|
|
||||||
- `resolveAddressIntent()` remains the main unresolved domain-intent concentration point;
|
- `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.
|
- `assistantService` still appears as a large coordinator-heavy community rather than a thin shell.
|
||||||
|
|
||||||
## What Is Already Real In Code
|
## 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
|
## 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
|
### Phase 0. Shared Baseline
|
||||||
|
|
||||||
|
|
@ -148,22 +158,23 @@ Reason:
|
||||||
|
|
||||||
### Phase 2. State And Transition Contracts
|
### Phase 2. State And Transition Contracts
|
||||||
|
|
||||||
Status: `92%`
|
Status: `74%`
|
||||||
|
|
||||||
Reason:
|
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;
|
- transition policy is extracted;
|
||||||
- scenario acceptance now evaluates continuity explicitly.
|
- scenario acceptance evaluates continuity explicitly.
|
||||||
|
|
||||||
Remaining debt:
|
Remaining debt:
|
||||||
|
|
||||||
- legacy transition helpers still physically exist inside `assistantService.ts`;
|
- there is still no single authoritative continuity contract for live mixed sessions;
|
||||||
- not every follow-up family is represented as equally clean first-class transition taxonomy.
|
- 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
|
### Phase 3. Capability Contracts
|
||||||
|
|
||||||
Status: `89%`
|
Status: `91%`
|
||||||
|
|
||||||
Reason:
|
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`.
|
- 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.
|
- 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()`.
|
- 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:
|
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;
|
- 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
|
### Phase 5. AssistantService Extraction
|
||||||
|
|
||||||
Status: `82%`
|
Status: `78%`
|
||||||
|
|
||||||
Reason:
|
Reason:
|
||||||
|
|
||||||
|
|
@ -209,8 +222,8 @@ Reason:
|
||||||
Remaining debt:
|
Remaining debt:
|
||||||
|
|
||||||
- `assistantService.ts` is still about `5050` lines;
|
- `assistantService.ts` is still about `5050` lines;
|
||||||
- runtime uses extracted owners, but legacy bodies and fallback branches still live in the coordinator file;
|
- runtime uses extracted owners, but priority and fallback seams still cross the coordinator boundary;
|
||||||
- code review still sometimes requires reading `assistantService` together with extracted owners.
|
- code review still requires reading `assistantService` together with extracted owners to understand real behavior.
|
||||||
|
|
||||||
### Phase 6. Provider / Runtime Axis Hardening
|
### Phase 6. Provider / Runtime Axis Hardening
|
||||||
|
|
||||||
|
|
@ -227,7 +240,7 @@ Remaining debt:
|
||||||
|
|
||||||
### Phase 7. Scenario Acceptance As Primary Gate
|
### Phase 7. Scenario Acceptance As Primary Gate
|
||||||
|
|
||||||
Status: `90%`
|
Status: `68%`
|
||||||
|
|
||||||
Reason:
|
Reason:
|
||||||
|
|
||||||
|
|
@ -237,8 +250,25 @@ Reason:
|
||||||
|
|
||||||
Remaining debt:
|
Remaining debt:
|
||||||
|
|
||||||
- semantic replay remains intentionally tool-driven and script-driven rather than promoted to a mandatory UI-first loop;
|
- narrow packs and seam tests are much stronger than before, but mixed saved-session runtime is still under-protected;
|
||||||
- coverage breadth should continue to grow as new domain slices are hardened.
|
- 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
|
## 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`;
|
- 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;
|
- 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;
|
- 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;
|
- 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;
|
- 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.
|
- 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
|
## 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.
|
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.
|
Intent resolution remains one of the most connected business nodes in the graph.
|
||||||
|
|
||||||
This means capability and contour growth still concentrate pressure there.
|
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.
|
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.
|
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:
|
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:
|
The next honest architecture slice should be:
|
||||||
|
|
||||||
1. continue reducing `assistantService.ts` to a thinner coordinator;
|
1. finish one authoritative continuity contract for live mixed sessions;
|
||||||
2. continue isolating answer semantics further away from `composeFactualReplyBody()` now that reply packaging and reply contracts have their own owner seams;
|
2. subordinate clarification to restored business continuity instead of letting it act as a default escape hatch;
|
||||||
3. keep extending AGENT packs with mixed business + meta + interruption patterns instead of single-family smoke tests;
|
3. continue reducing `assistantService.ts` to a thinner coordinator;
|
||||||
4. keep using scenario acceptance as the main sign-off rather than unit-test green status alone.
|
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
|
## Bottom Line
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,51 @@ The system already has:
|
||||||
|
|
||||||
The remaining problem is different now:
|
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;
|
- quality risk is concentrated in a small number of central pressure points;
|
||||||
- these pressure points will amplify regressions once many new domains are added.
|
- these pressure points will amplify regressions once many new domains are added.
|
||||||
|
|
||||||
## Must Close Before Mass Domain Expansion
|
## 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:
|
Why it matters:
|
||||||
|
|
||||||
|
|
@ -48,9 +87,9 @@ Current status:
|
||||||
|
|
||||||
- inventory signal-family is now delegated to `addressInventoryIntentSignals.ts`;
|
- inventory signal-family is now delegated to `addressInventoryIntentSignals.ts`;
|
||||||
- counterparty / documents / contracts / open-items signal-family is now also delegated to `addressCounterpartyIntentSignals.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:
|
Why it matters:
|
||||||
|
|
||||||
|
|
@ -64,7 +103,13 @@ What "done enough" means:
|
||||||
- blocked / limited / humanized fallback semantics are explicit for the most important contours;
|
- 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.
|
- 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:
|
Why it matters:
|
||||||
|
|
||||||
|
|
@ -109,20 +154,28 @@ Promoting every replay step into a more polished UI loop can happen later.
|
||||||
|
|
||||||
### Pass 1
|
### Pass 1
|
||||||
|
|
||||||
- continue extracting the highest-risk signal families out of `resolveAddressIntent()`;
|
- finish the first authoritative continuity contract and wire it into route / recap / clarification hot paths;
|
||||||
- keep business behavior stable through focused regression packs;
|
- prove that mixed replay stops losing active frame authority;
|
||||||
- treat this as the main pre-expansion hardening track.
|
- treat this as the main pre-expansion hardening track.
|
||||||
|
|
||||||
### Pass 2
|
### Pass 2
|
||||||
|
|
||||||
- reduce remaining answer-semantics pressure in `composeFactualReplyBody()`;
|
- subordinate clarification to restored business continuity and remove repeated template escapes;
|
||||||
- harden blocked / limited / humanized response semantics on the hottest business contours;
|
- harden recap truthfulness so it can only summarize verified session facts;
|
||||||
- confirm with AGENT replay that user-facing answers stay business-first.
|
- 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
|
## Practical Exit Condition
|
||||||
|
|
||||||
Turnaround `11` can be considered "ready for domain expansion" when:
|
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 main route-collision pressure in `resolveAddressIntent()` is materially reduced;
|
||||||
- the hottest user-facing answer families are protected from technical leakage;
|
- the hottest user-facing answer families are protected from technical leakage;
|
||||||
- AGENT replay confirms stable business usefulness on the core mixed chains;
|
- AGENT replay confirms stable business usefulness on the core mixed chains;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -26,12 +26,15 @@ This package answers the next question:
|
||||||
6. [06 - phase_acceptance_matrix.md](./06%20-%20phase_acceptance_matrix.md)
|
6. [06 - phase_acceptance_matrix.md](./06%20-%20phase_acceptance_matrix.md)
|
||||||
7. [07 - external_reference_appendix.md](./07%20-%20external_reference_appendix.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)
|
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)
|
## Current Status Snapshot (2026-04-17)
|
||||||
|
|
||||||
This package is no longer planning-only.
|
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;
|
- 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;
|
- 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:
|
Current honest status:
|
||||||
|
|
||||||
- estimated overall completion: `~85%`
|
- turnaround implementation progress: `~88%`
|
||||||
- graph snapshot after latest rebuild: `5228 nodes`, `11338 edges`, `133 communities`
|
- 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:
|
- main remaining architectural pressure:
|
||||||
- `resolveAddressIntent()`
|
- no single authoritative continuity contract for live mixed sessions
|
||||||
- `composeFactualReply()`
|
|
||||||
- residual coordinator/legacy pressure inside `assistantService.ts`
|
- 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:
|
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)
|
- [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
|
## Architectural Objects Of Planning
|
||||||
|
|
||||||
|
|
@ -76,6 +88,9 @@ Read in this order:
|
||||||
7. `06 - phase_acceptance_matrix.md`
|
7. `06 - phase_acceptance_matrix.md`
|
||||||
8. `07 - external_reference_appendix.md`
|
8. `07 - external_reference_appendix.md`
|
||||||
9. `08 - current_status_audit_2026-04-17.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
|
## Planning Rules
|
||||||
|
|
||||||
|
|
@ -95,10 +110,12 @@ and start being described as:
|
||||||
|
|
||||||
- "a stateful exact-data assistant with explicit transition contracts and isolated truth gating."
|
- "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:
|
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;
|
- 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`.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,112 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.resolveCounterpartyAddressIntent = resolveCounterpartyAddressIntent;
|
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) {
|
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)) {
|
if (deps.hasOpenContractsListSignal(text)) {
|
||||||
return {
|
return {
|
||||||
intent: "open_contracts_confirmed_as_of_date",
|
intent: "open_contracts_confirmed_as_of_date",
|
||||||
|
|
@ -124,6 +229,13 @@ function resolveCounterpartyAddressIntent(text, deps) {
|
||||||
reasons: ["bank_ops_by_counterparty_signal_detected"]
|
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)) &&
|
if ((deps.hasAny(text, deps.documentsByCounterpartyHints) || deps.hasCounterpartyShipmentItemFlowSignal(text)) &&
|
||||||
(deps.hasPartyAnchorMention(text) ||
|
(deps.hasPartyAnchorMention(text) ||
|
||||||
deps.hasLooseByAnchorMention(text) ||
|
deps.hasLooseByAnchorMention(text) ||
|
||||||
|
|
|
||||||
|
|
@ -844,6 +844,17 @@ function extractShipmentCounterpartyValue(text) {
|
||||||
}
|
}
|
||||||
return candidate;
|
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) {
|
function hasExplicitAccountCue(text) {
|
||||||
return /(?:сч[её]т|счет|account|acct)/iu.test(String(text ?? ""));
|
return /(?:сч[её]т|счет|account|acct)/iu.test(String(text ?? ""));
|
||||||
}
|
}
|
||||||
|
|
@ -1508,6 +1519,17 @@ function extractAddressFilters(userMessage, intent) {
|
||||||
warnings.push("counterparty_anchor_derived_from_shipment_phrase");
|
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 &&
|
if (!filters.counterparty &&
|
||||||
allowGenericCounterpartyAnchor &&
|
allowGenericCounterpartyAnchor &&
|
||||||
(intent === "list_documents_by_counterparty" ||
|
(intent === "list_documents_by_counterparty" ||
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -8,12 +8,26 @@ function hasInventoryAccount41Anchor(text) {
|
||||||
function hasInventoryAsOfCue(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 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) {
|
function hasInventoryOnHandSignal(text) {
|
||||||
const hasColloquialStockSnapshotCue = /(?:что|ч[еёо])\s+(?:у\s+нас\s+)?на\s+склад(?:е|у|ом|ах)(?=$|[\s,.;:!?])/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 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 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) {
|
const hasPlainRussianRootCue = hasPlainRussianInventoryOnHandSignal(text);
|
||||||
|
if (!hasStockLexeme && !hasAccount41Anchor && !hasPlainRussianRootCue) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (hasInventoryProvenanceSignalV2(text) ||
|
if (hasInventoryProvenanceSignalV2(text) ||
|
||||||
|
|
@ -29,6 +43,9 @@ function hasInventoryOnHandSignal(text) {
|
||||||
if (hasAccount41Anchor && (hasGoodsLexeme || hasBalanceLexeme || hasRequestCue || hasInventoryAsOfCue(text))) {
|
if (hasAccount41Anchor && (hasGoodsLexeme || hasBalanceLexeme || hasRequestCue || hasInventoryAsOfCue(text))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (hasPlainRussianRootCue) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return (hasGoodsLexeme || hasBalanceLexeme || hasColloquialStockSnapshotCue || hasStockStateCue) &&
|
return (hasGoodsLexeme || hasBalanceLexeme || hasColloquialStockSnapshotCue || hasStockStateCue) &&
|
||||||
(hasRequestCue || hasBalanceLexeme || hasColloquialStockSnapshotCue || hasStockStateCue);
|
(hasRequestCue || hasBalanceLexeme || hasColloquialStockSnapshotCue || hasStockStateCue);
|
||||||
}
|
}
|
||||||
|
|
@ -171,6 +188,14 @@ function resolveInventoryAddressIntent(text) {
|
||||||
reasons: ["inventory_purchase_date_signal_detected"]
|
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)) {
|
if (hasSelectedObjectInventoryPurchaseDocumentsSignal(text)) {
|
||||||
return {
|
return {
|
||||||
intent: "inventory_purchase_documents_for_item",
|
intent: "inventory_purchase_documents_for_item",
|
||||||
|
|
|
||||||
|
|
@ -1461,6 +1461,24 @@ function isOrganizationScopedInventoryIntent(intent) {
|
||||||
intent === "inventory_purchase_to_sale_chain" ||
|
intent === "inventory_purchase_to_sale_chain" ||
|
||||||
intent === "inventory_aging_by_purchase_date");
|
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) {
|
function collectOrganizationCandidatesFromRows(rows) {
|
||||||
return (0, assistantOrganizationMatcher_1.mergeKnownOrganizations)(rows.map((row) => row.organization).filter((value) => Boolean(value)));
|
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) &&
|
!toNonEmptyFilterValue(filters.extracted_filters.organization) &&
|
||||||
!activeOrganization &&
|
!activeOrganization &&
|
||||||
!resolvedOrganizationFromMessage &&
|
!resolvedOrganizationFromMessage &&
|
||||||
|
!shouldDeferInventoryOrganizationClarification(intent.intent, filters.extracted_filters, semanticFrame) &&
|
||||||
knownOrganizations.length > 1) {
|
knownOrganizations.length > 1) {
|
||||||
return buildOrganizationClarificationExecutionResult({
|
return buildOrganizationClarificationExecutionResult({
|
||||||
mode,
|
mode,
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.contractCandidatesFromRows = contractCandidatesFromRows;
|
exports.contractCandidatesFromRows = contractCandidatesFromRows;
|
||||||
exports.composeFactualReply = composeFactualReply;
|
exports.composeFactualReply = composeFactualReply;
|
||||||
exports.inferReplyType = inferReplyType;
|
exports.inferReplyType = inferReplyType;
|
||||||
const assistantOrganizationMatcher_1 = require("../assistantOrganizationMatcher");
|
|
||||||
const replyPackaging_1 = require("./replyPackaging");
|
const replyPackaging_1 = require("./replyPackaging");
|
||||||
|
const counterpartyAnalyticsReplyBuilders_1 = require("./counterpartyAnalyticsReplyBuilders");
|
||||||
const inventoryReplyBuilders_1 = require("./inventoryReplyBuilders");
|
const inventoryReplyBuilders_1 = require("./inventoryReplyBuilders");
|
||||||
function uniqueStrings(values) {
|
function uniqueStrings(values) {
|
||||||
return Array.from(new Set(values
|
return Array.from(new Set(values
|
||||||
|
|
@ -2135,6 +2135,30 @@ function composeFactualReplyBody(intent, rows, options = {}) {
|
||||||
if (inventoryReply) {
|
if (inventoryReply) {
|
||||||
return 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") {
|
if (intent === "document_type_and_account_section_profile") {
|
||||||
const rowsByMarker = new Map();
|
const rowsByMarker = new Map();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
|
@ -2366,692 +2390,6 @@ function composeFactualReplyBody(intent, rows, options = {}) {
|
||||||
text: lines.join("\n")
|
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") {
|
if (intent === "vat_payable_forecast") {
|
||||||
const rowsByMarker = new Map();
|
const rowsByMarker = new Map();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
|
|
||||||
632
llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js
vendored
Normal file
632
llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js
vendored
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -421,12 +421,17 @@ function shouldRestoreInventoryRootFrame(userMessage, intent, extractedFilters,
|
||||||
const rootContextOnly = followupContext.root_context_only === true;
|
const rootContextOnly = followupContext.root_context_only === true;
|
||||||
const comingFromInventoryDrilldown = currentFrameKind === "inventory_drilldown" || isInventoryDrilldownFrameIntent(previousIntent);
|
const comingFromInventoryDrilldown = currentFrameKind === "inventory_drilldown" || isInventoryDrilldownFrameIntent(previousIntent);
|
||||||
const normalized = String(userMessage ?? "");
|
const normalized = String(userMessage ?? "");
|
||||||
|
const hasExplicitInventoryRootSnapshotCue = /(?:склад|остат(?:ок|ки)|товар(?:ы|ов)?|номенклатур)/iu.test(normalized);
|
||||||
const hasInventoryRootRestatementCue = /(?:склад|остат(?:ок|ки)|позици(?:я|и|ю)|товар(?:ы|ов)?|номенклатур)/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 ||
|
const canReenterInventoryRoot = comingFromInventoryDrilldown ||
|
||||||
rootContextOnly ||
|
rootContextOnly ||
|
||||||
(currentFrameKind === "inventory_root" && (hasSamePeriodHint(normalized) || hasInventoryRootRestatementCue)) ||
|
(currentFrameKind === "inventory_root" && (hasSamePeriodHint(normalized) || hasInventoryRootRestatementCue)) ||
|
||||||
(currentFrameKind === "generic" && hasInventoryRootRestatementCue && hasSamePeriodHint(normalized));
|
(currentFrameKind === "generic" &&
|
||||||
|
hasExplicitInventoryRootSnapshotCue &&
|
||||||
|
(hasSameDateHint(normalized) || hasSamePeriodHint(normalized) || hasInventoryRootRestatementCue));
|
||||||
if (!canReenterInventoryRoot) {
|
if (!canReenterInventoryRoot) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -791,15 +796,13 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
||||||
(intent === "inventory_aging_by_purchase_date" || isInventoryLifecycleHistoryIntent(intent)) &&
|
(intent === "inventory_aging_by_purchase_date" || isInventoryLifecycleHistoryIntent(intent)) &&
|
||||||
!hasExplicitPeriodLiteral(userMessage) &&
|
!hasExplicitPeriodLiteral(userMessage) &&
|
||||||
!hasExplicitCurrentDateHint(userMessage)) {
|
!hasExplicitCurrentDateHint(userMessage)) {
|
||||||
if (intent === "inventory_aging_by_purchase_date") {
|
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
|
||||||
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
|
const currentAsOfDate = toNonEmptyString(merged.as_of_date);
|
||||||
const currentAsOfDate = toNonEmptyString(merged.as_of_date);
|
const todayIso = new Date().toISOString().slice(0, 10);
|
||||||
const todayIso = new Date().toISOString().slice(0, 10);
|
const currentLooksDefaultedToToday = currentAsOfDate === todayIso;
|
||||||
const currentLooksDefaultedToToday = currentAsOfDate === todayIso;
|
if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) {
|
||||||
if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) {
|
merged.as_of_date = inheritedAsOfDate;
|
||||||
merged.as_of_date = inheritedAsOfDate;
|
reasons.push("as_of_date_from_followup_context");
|
||||||
reasons.push("as_of_date_from_followup_context");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ((Boolean(previousPeriodFrom) || Boolean(previousPeriodTo)) &&
|
if ((Boolean(previousPeriodFrom) || Boolean(previousPeriodTo)) &&
|
||||||
|
|
@ -1025,6 +1028,8 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
||||||
const hasPreviousCounterparty = Boolean(previousCounterparty ?? previousCounterpartyFromAnchor);
|
const hasPreviousCounterparty = Boolean(previousCounterparty ?? previousCounterpartyFromAnchor);
|
||||||
const hasAnyPartyAnchor = hasPreviousContract || hasPreviousCounterparty;
|
const hasAnyPartyAnchor = hasPreviousContract || hasPreviousCounterparty;
|
||||||
const isVatFollowup = hasVatCue(normalizedMessage);
|
const isVatFollowup = hasVatCue(normalizedMessage);
|
||||||
|
const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
|
||||||
|
const inventorySelectedObjectFollowup = hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal);
|
||||||
if (detectedIntent.intent === "unknown" && isVatFollowup) {
|
if (detectedIntent.intent === "unknown" && isVatFollowup) {
|
||||||
const vatIntent = hasVatTaxPaymentCue(normalizedMessage)
|
const vatIntent = hasVatTaxPaymentCue(normalizedMessage)
|
||||||
? "vat_liability_confirmed_for_tax_period"
|
? "vat_liability_confirmed_for_tax_period"
|
||||||
|
|
@ -1038,7 +1043,10 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const allowOpenItemsFollowupFallback = detectedIntent.intent === "unknown" && !isVatFollowup;
|
const allowOpenItemsFollowupFallback = detectedIntent.intent === "unknown" && !isVatFollowup;
|
||||||
if (allowOpenItemsFollowupFallback && hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) {
|
if (allowOpenItemsFollowupFallback &&
|
||||||
|
!inventorySelectedObjectFollowup &&
|
||||||
|
hasOpenItemsHint(normalizedMessage) &&
|
||||||
|
hasAnyPartyAnchor) {
|
||||||
return {
|
return {
|
||||||
intent: "open_items_by_counterparty_or_contract",
|
intent: "open_items_by_counterparty_or_contract",
|
||||||
confidence: "low",
|
confidence: "low",
|
||||||
|
|
@ -1060,8 +1068,6 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
||||||
reasons: [...detectedIntent.reasons, "intent_adjusted_to_balance_followup_context"]
|
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 (inventorySelectedObjectFollowup && hasInventorySupplierFollowupCue(normalizedMessage)) {
|
||||||
if (detectedIntent.intent === "unknown" ||
|
if (detectedIntent.intent === "unknown" ||
|
||||||
detectedIntent.intent === "list_documents_by_counterparty" ||
|
detectedIntent.intent === "list_documents_by_counterparty" ||
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,7 @@ function containsLetterLikeChars(text) {
|
||||||
return /[A-Za-z\u0400-\u04FF]/u.test(source);
|
return /[A-Za-z\u0400-\u04FF]/u.test(source);
|
||||||
}
|
}
|
||||||
function createAssistantBoundaryPolicy(deps) {
|
function createAssistantBoundaryPolicy(deps) {
|
||||||
const defaultChannel = String(deps.activeMcpChannel ?? "default");
|
|
||||||
function buildAssistantDataScopeContractReply(scopeProbe = null) {
|
function buildAssistantDataScopeContractReply(scopeProbe = null) {
|
||||||
const channel = String(scopeProbe?.channel ?? defaultChannel);
|
|
||||||
const organizations = Array.isArray(scopeProbe?.organizations)
|
const organizations = Array.isArray(scopeProbe?.organizations)
|
||||||
? scopeProbe.organizations
|
? scopeProbe.organizations
|
||||||
.map((item) => String(item ?? "").trim())
|
.map((item) => String(item ?? "").trim())
|
||||||
|
|
@ -29,27 +27,27 @@ function createAssistantBoundaryPolicy(deps) {
|
||||||
: [];
|
: [];
|
||||||
if (organizations.length === 1) {
|
if (organizations.length === 1) {
|
||||||
return [
|
return [
|
||||||
`Сейчас в активном MCP-канале \`${channel}\` доступна организация: ${organizations[0]}.`,
|
`Сейчас доступна организация: ${organizations[0]}.`,
|
||||||
"Работаю в read-only режиме. Могу сразу показать по этой организации документы, операции, договоры или остатки."
|
"Могу сразу показать по ней документы, операции, договоры или остатки."
|
||||||
].join(" ");
|
].join(" ");
|
||||||
}
|
}
|
||||||
if (organizations.length > 1) {
|
if (organizations.length > 1) {
|
||||||
const preview = organizations.slice(0, 10).join(", ");
|
const preview = organizations.slice(0, 10).join(", ");
|
||||||
return [
|
return [
|
||||||
`Сейчас в активном MCP-канале \`${channel}\` доступны организации (${organizations.length}): ${preview}.`,
|
`Сейчас доступны организации (${organizations.length}): ${preview}.`,
|
||||||
"Работаю в read-only режиме. Скажи, по какой организации смотреть документы/операции."
|
"Скажите, по какой организации смотреть данные."
|
||||||
].join(" ");
|
].join(" ");
|
||||||
}
|
}
|
||||||
if (scopeProbe?.status === "unresolved_with_error" && scopeProbe?.error) {
|
if (scopeProbe?.status === "unresolved_with_error" && scopeProbe?.error) {
|
||||||
return [
|
return [
|
||||||
`Не смог прочитать название организации из live MCP-канала \`${channel}\`: ${scopeProbe.error}.`,
|
"Сейчас не удалось определить список организаций из подключенной базы.",
|
||||||
"Работаю в read-only режиме и вижу только данные активного контура. Проверь подключение MCP/1С, после этого сразу назову контур."
|
`Техническая причина: ${scopeProbe.error}.`,
|
||||||
|
"Проверьте подключение, и я сразу назову доступный контур."
|
||||||
].join(" ");
|
].join(" ");
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
`Работаю в read-only режиме и вижу только те данные, которые отдает текущий MCP-канал \`${channel}\`.`,
|
"Сейчас вижу только данные текущего подключенного контура.",
|
||||||
"Словарь компаний не зашит в код: рабочий контур определяется live-подключением.",
|
"Если в нем несколько организаций, скажите, по какой смотреть данные."
|
||||||
"Если подключено несколько баз, для автосписка нужен MCP-метод метаданных (перечень баз/организаций); без него можно анализировать только активный контур запросов."
|
|
||||||
].join(" ");
|
].join(" ");
|
||||||
}
|
}
|
||||||
function buildAssistantDataScopeSelectionReply(organization) {
|
function buildAssistantDataScopeSelectionReply(organization) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.createAssistantDataScopePolicy = createAssistantDataScopePolicy;
|
exports.createAssistantDataScopePolicy = createAssistantDataScopePolicy;
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
const assistantOrganizationMatcher_1 = require("./assistantOrganizationMatcher");
|
const assistantOrganizationMatcher_1 = require("./assistantOrganizationMatcher");
|
||||||
|
const assistantContinuityPolicy_1 = require("./assistantContinuityPolicy");
|
||||||
const DATA_SCOPE_CACHE_TTL_MS = 60_000;
|
const DATA_SCOPE_CACHE_TTL_MS = 60_000;
|
||||||
function normalizeScopeLabel(value) {
|
function normalizeScopeLabel(value) {
|
||||||
return String(value ?? "")
|
return String(value ?? "")
|
||||||
|
|
@ -235,6 +236,12 @@ function createAssistantDataScopePolicy(deps) {
|
||||||
if (selected) {
|
if (selected) {
|
||||||
return 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,7 @@ async function runAssistantLivingChatRuntime(input) {
|
||||||
chatText = (0, assistantMemoryRecapPolicy_1.buildAddressMemoryRecapReply)({
|
chatText = (0, assistantMemoryRecapPolicy_1.buildAddressMemoryRecapReply)({
|
||||||
organization: scopedOrganization,
|
organization: scopedOrganization,
|
||||||
addressDebug: lastMemoryAddressDebug,
|
addressDebug: lastMemoryAddressDebug,
|
||||||
|
sessionItems: input.sessionItems,
|
||||||
toNonEmptyString: input.toNonEmptyString
|
toNonEmptyString: input.toNonEmptyString
|
||||||
});
|
});
|
||||||
activeOrganization = scopedOrganization ?? activeOrganization;
|
activeOrganization = scopedOrganization ?? activeOrganization;
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ function createAssistantLivingModePolicy(deps) {
|
||||||
const hasMemoryCue = samples.some((sample) => /(?:помни(?:шь|те|м)?|remember|recall)/iu.test(sample));
|
const hasMemoryCue = samples.some((sample) => /(?:помни(?:шь|те|м)?|remember|recall)/iu.test(sample));
|
||||||
const hasDiscussionCue = samples.some((sample) => /(?:обсуждал[аи]?|говорил[аи]?|смотрел[аи]?|разбирал[аи]?|спрашивал[аи]?)/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));
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
if (hasExplicitRecapPrompt) {
|
if (hasExplicitRecapPrompt) {
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,13 @@ exports.buildInventoryHistoryCapabilityFollowupReply = buildInventoryHistoryCapa
|
||||||
exports.buildAddressMemoryRecapReply = buildAddressMemoryRecapReply;
|
exports.buildAddressMemoryRecapReply = buildAddressMemoryRecapReply;
|
||||||
exports.resolveAssistantLivingChatMemoryContext = resolveAssistantLivingChatMemoryContext;
|
exports.resolveAssistantLivingChatMemoryContext = resolveAssistantLivingChatMemoryContext;
|
||||||
exports.createAssistantMemoryRecapPolicy = createAssistantMemoryRecapPolicy;
|
exports.createAssistantMemoryRecapPolicy = createAssistantMemoryRecapPolicy;
|
||||||
function formatIsoDateForReply(value) {
|
const assistantContinuityPolicy_1 = require("./assistantContinuityPolicy");
|
||||||
const source = String(value ?? "").trim();
|
function toNonEmptyString(value) {
|
||||||
const match = source.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
if (value === null || value === undefined) {
|
||||||
if (!match) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return `${match[3]}.${match[2]}.${match[1]}`;
|
const text = String(value).trim();
|
||||||
|
return text.length > 0 ? text : null;
|
||||||
}
|
}
|
||||||
function collectMessageSamples(input) {
|
function collectMessageSamples(input) {
|
||||||
const values = [
|
const values = [
|
||||||
|
|
@ -30,89 +30,17 @@ function hasSignalAcrossSamples(samples, detector) {
|
||||||
function hasExplicitRecapPromptSignal(samples) {
|
function hasExplicitRecapPromptSignal(samples) {
|
||||||
return samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|что\s+уже\s+поняли|напомни\s+что\s+мы)/iu.test(sample));
|
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) {
|
function buildInventoryHistoryCapabilityFollowupReply(input) {
|
||||||
const rootFrameContext = input.addressDebug?.address_root_frame_context &&
|
const rootFrameContext = input.addressDebug?.address_root_frame_context &&
|
||||||
typeof input.addressDebug.address_root_frame_context === "object"
|
typeof input.addressDebug.address_root_frame_context === "object"
|
||||||
? input.addressDebug.address_root_frame_context
|
? input.addressDebug.address_root_frame_context
|
||||||
: null;
|
: null;
|
||||||
const extractedFilters = input.addressDebug?.extracted_filters && typeof input.addressDebug.extracted_filters === "object"
|
const extractedFilters = (0, assistantContinuityPolicy_1.readAddressDebugFilters)(input.addressDebug);
|
||||||
? input.addressDebug.extracted_filters
|
|
||||||
: null;
|
|
||||||
const organization = input.organization ??
|
const organization = input.organization ??
|
||||||
input.toNonEmptyString(rootFrameContext?.organization) ??
|
input.toNonEmptyString(rootFrameContext?.organization) ??
|
||||||
input.toNonEmptyString(extractedFilters?.organization);
|
input.toNonEmptyString(extractedFilters?.organization);
|
||||||
const lastAsOfDate = formatIsoDateForReply(rootFrameContext?.as_of_date) ??
|
const lastAsOfDate = (0, assistantContinuityPolicy_1.formatIsoDateForReply)(rootFrameContext?.as_of_date) ??
|
||||||
formatIsoDateForReply(extractedFilters?.as_of_date);
|
(0, assistantContinuityPolicy_1.formatIsoDateForReply)(extractedFilters?.as_of_date);
|
||||||
const organizationPart = organization ? ` по компании «${organization}»` : "";
|
const organizationPart = organization ? ` по компании «${organization}»` : "";
|
||||||
const referenceLine = lastAsOfDate
|
const referenceLine = lastAsOfDate
|
||||||
? `Да, могу. Сейчас мы уже смотрели складской срез${organizationPart} на ${lastAsOfDate}.`
|
? `Да, могу. Сейчас мы уже смотрели складской срез${organizationPart} на ${lastAsOfDate}.`
|
||||||
|
|
@ -128,6 +56,93 @@ function buildInventoryHistoryCapabilityFollowupReply(input) {
|
||||||
"Если хочешь, сразу покажу нужный исторический период."
|
"Если хочешь, сразу покажу нужный исторический период."
|
||||||
].join("\n");
|
].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) {
|
function buildAddressMemoryRecapReply(input) {
|
||||||
const extractedFilters = input.addressDebug?.extracted_filters && typeof input.addressDebug.extracted_filters === "object"
|
const extractedFilters = input.addressDebug?.extracted_filters && typeof input.addressDebug.extracted_filters === "object"
|
||||||
? input.addressDebug.extracted_filters
|
? input.addressDebug.extracted_filters
|
||||||
|
|
@ -136,18 +151,27 @@ function buildAddressMemoryRecapReply(input) {
|
||||||
typeof input.addressDebug.address_root_frame_context === "object"
|
typeof input.addressDebug.address_root_frame_context === "object"
|
||||||
? input.addressDebug.address_root_frame_context
|
? input.addressDebug.address_root_frame_context
|
||||||
: null;
|
: null;
|
||||||
const item = input.toNonEmptyString(extractedFilters?.item) ??
|
const item = (0, assistantContinuityPolicy_1.readAddressDebugItem)(input.addressDebug, input.toNonEmptyString);
|
||||||
(String(input.addressDebug?.anchor_type ?? "") === "item"
|
|
||||||
? input.toNonEmptyString(input.addressDebug?.anchor_value_resolved) ??
|
|
||||||
input.toNonEmptyString(input.addressDebug?.anchor_value_raw)
|
|
||||||
: null);
|
|
||||||
const organization = input.organization ??
|
const organization = input.organization ??
|
||||||
input.toNonEmptyString(extractedFilters?.organization) ??
|
input.toNonEmptyString(extractedFilters?.organization) ??
|
||||||
input.toNonEmptyString(rootFrameContext?.organization);
|
input.toNonEmptyString(rootFrameContext?.organization);
|
||||||
const scopedDate = formatIsoDateForReply(extractedFilters?.as_of_date) ??
|
const scopedDate = (0, assistantContinuityPolicy_1.readAddressDebugScopedDate)(input.addressDebug);
|
||||||
formatIsoDateForReply(rootFrameContext?.as_of_date) ??
|
const recapFacts = collectRecentRecapFacts({
|
||||||
formatIsoDateForReply(extractedFilters?.period_to);
|
sessionItems: input.sessionItems,
|
||||||
|
item,
|
||||||
|
organization,
|
||||||
|
toNonEmptyString: input.toNonEmptyString
|
||||||
|
});
|
||||||
if (item) {
|
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 datePart = scopedDate ? ` в срезе на ${scopedDate}` : "";
|
||||||
const organizationPart = organization ? ` по компании «${organization}»` : "";
|
const organizationPart = organization ? ` по компании «${organization}»` : "";
|
||||||
return [
|
return [
|
||||||
|
|
@ -168,21 +192,29 @@ function buildAddressMemoryRecapReply(input) {
|
||||||
function resolveAssistantLivingChatMemoryContext(input) {
|
function resolveAssistantLivingChatMemoryContext(input) {
|
||||||
const contextualInventoryHistoryCapabilityFollowup = String(input.modeDecisionReason ?? "") === "inventory_history_capability_followup_detected";
|
const contextualInventoryHistoryCapabilityFollowup = String(input.modeDecisionReason ?? "") === "inventory_history_capability_followup_detected";
|
||||||
const contextualMemoryRecapFollowup = String(input.modeDecisionReason ?? "") === "memory_recap_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 {
|
return {
|
||||||
contextualInventoryHistoryCapabilityFollowup,
|
contextualInventoryHistoryCapabilityFollowup,
|
||||||
contextualMemoryRecapFollowup,
|
contextualMemoryRecapFollowup,
|
||||||
lastGroundedInventoryAddressDebug: contextualInventoryHistoryCapabilityFollowup
|
lastGroundedInventoryAddressDebug: contextualInventoryHistoryCapabilityFollowup
|
||||||
? findLastGroundedInventoryAddressDebug(sessionItems)
|
? continuity.lastGroundedInventoryAddressDebug
|
||||||
: null,
|
: null,
|
||||||
lastMemoryAddressDebug: contextualMemoryRecapFollowup
|
lastMemoryAddressDebug: contextualMemoryRecapFollowup
|
||||||
? findLastAddressDebugWithItem(sessionItems) ?? findLastAddressDebug(sessionItems)
|
? continuity.lastGroundedItemAddressDebug ?? continuity.lastGroundedAddressDebug
|
||||||
: null
|
: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function createAssistantMemoryRecapPolicy(deps) {
|
function createAssistantMemoryRecapPolicy(deps) {
|
||||||
function resolveRouteMemorySignals(input) {
|
function resolveRouteMemorySignals(input) {
|
||||||
const samples = collectMessageSamples(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 historicalCapabilitySignal = hasSignalAcrossSamples(samples, deps.hasHistoricalCapabilityFollowupSignal);
|
||||||
const memoryRecapSignal = hasSignalAcrossSamples(samples, deps.hasConversationMemoryRecallFollowupSignal);
|
const memoryRecapSignal = hasSignalAcrossSamples(samples, deps.hasConversationMemoryRecallFollowupSignal);
|
||||||
const explicitRecapPromptSignal = hasExplicitRecapPromptSignal(samples);
|
const explicitRecapPromptSignal = hasExplicitRecapPromptSignal(samples);
|
||||||
|
|
@ -191,13 +223,13 @@ function createAssistantMemoryRecapPolicy(deps) {
|
||||||
!input.dataScopeMetaQuery &&
|
!input.dataScopeMetaQuery &&
|
||||||
!input.dataRetrievalSignal &&
|
!input.dataRetrievalSignal &&
|
||||||
historicalCapabilitySignal &&
|
historicalCapabilitySignal &&
|
||||||
deps.isGroundedInventoryContextDebug(input.lastGroundedAddressDebug)),
|
deps.isGroundedInventoryContextDebug(groundedInventoryContext)),
|
||||||
contextualMemoryRecapFollowupDetected: Boolean(!input.dataScopeMetaQuery &&
|
contextualMemoryRecapFollowupDetected: Boolean(!input.dataScopeMetaQuery &&
|
||||||
!input.capabilityMetaQuery &&
|
!input.capabilityMetaQuery &&
|
||||||
!input.aggregateBusinessAnalyticsSignal &&
|
!input.aggregateBusinessAnalyticsSignal &&
|
||||||
memoryRecapSignal &&
|
memoryRecapSignal &&
|
||||||
(explicitRecapPromptSignal || (!input.dataRetrievalSignal && !input.strongDataSignal)) &&
|
(explicitRecapPromptSignal || (!input.dataRetrievalSignal && !input.strongDataSignal)) &&
|
||||||
input.hasPriorAddressDebug)
|
continuity.hasGroundedAddressContext)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -102,10 +102,16 @@ function createAssistantRoutePolicy(deps) {
|
||||||
hasDataRetrievalRequestSignal(repairedRawUserMessage) ||
|
hasDataRetrievalRequestSignal(repairedRawUserMessage) ||
|
||||||
hasDataRetrievalRequestSignal(effectiveAddressUserMessage) ||
|
hasDataRetrievalRequestSignal(effectiveAddressUserMessage) ||
|
||||||
hasDataRetrievalRequestSignal(repairedEffectiveAddressUserMessage);
|
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) ||
|
const aggregateBusinessAnalyticsSignal = hasAggregateBusinessAnalyticsSignal(rawUserMessage) ||
|
||||||
hasAggregateBusinessAnalyticsSignal(repairedRawUserMessage) ||
|
hasAggregateBusinessAnalyticsSignal(repairedRawUserMessage) ||
|
||||||
hasAggregateBusinessAnalyticsSignal(effectiveAddressUserMessage) ||
|
hasAggregateBusinessAnalyticsSignal(effectiveAddressUserMessage) ||
|
||||||
hasAggregateBusinessAnalyticsSignal(repairedEffectiveAddressUserMessage);
|
hasAggregateBusinessAnalyticsSignal(repairedEffectiveAddressUserMessage) ||
|
||||||
|
aggregateBusinessAnalyticsBridgeDetected;
|
||||||
const standaloneAddressTopicSignal = hasStandaloneAddressTopicSignal(rawUserMessage) ||
|
const standaloneAddressTopicSignal = hasStandaloneAddressTopicSignal(rawUserMessage) ||
|
||||||
hasStandaloneAddressTopicSignal(repairedRawUserMessage) ||
|
hasStandaloneAddressTopicSignal(repairedRawUserMessage) ||
|
||||||
hasStandaloneAddressTopicSignal(effectiveAddressUserMessage) ||
|
hasStandaloneAddressTopicSignal(effectiveAddressUserMessage) ||
|
||||||
|
|
@ -195,15 +201,20 @@ function createAssistantRoutePolicy(deps) {
|
||||||
!capabilityMetaQuery &&
|
!capabilityMetaQuery &&
|
||||||
!dataRetrievalSignal);
|
!dataRetrievalSignal);
|
||||||
const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal;
|
const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal;
|
||||||
|
const baseToolGate = resolveAddressToolGateDecision(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage);
|
||||||
const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery &&
|
const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery &&
|
||||||
!capabilityMetaQuery &&
|
!capabilityMetaQuery &&
|
||||||
!dataRetrievalSignal &&
|
!dataRetrievalSignal &&
|
||||||
|
!aggregateBusinessAnalyticsSignal &&
|
||||||
!effectiveAddressFollowupSignal &&
|
!effectiveAddressFollowupSignal &&
|
||||||
resolvedModeDetection.mode === "unsupported" &&
|
resolvedModeDetection.mode === "unsupported" &&
|
||||||
resolvedIntentResolution.intent === "unknown");
|
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 &&
|
const nonDomainQueryIndexed = Boolean(!llmFirstAddressCandidate &&
|
||||||
deterministicNonDomainGuard &&
|
deterministicNonDomainGuard &&
|
||||||
(llmFirstUnsupportedCandidate || llmContractMode === null) &&
|
(llmFirstUnsupportedCandidate || llmContractMode === null) &&
|
||||||
|
!baseToolGatePreservesAddressLane &&
|
||||||
!protectedInventoryShortFollowup &&
|
!protectedInventoryShortFollowup &&
|
||||||
!organizationClarificationContinuationDetected);
|
!organizationClarificationContinuationDetected);
|
||||||
const lastAddressAssistantDebug = sessionItems
|
const lastAddressAssistantDebug = sessionItems
|
||||||
|
|
@ -220,7 +231,8 @@ function createAssistantRoutePolicy(deps) {
|
||||||
strongDataSignal,
|
strongDataSignal,
|
||||||
aggregateBusinessAnalyticsSignal,
|
aggregateBusinessAnalyticsSignal,
|
||||||
lastGroundedAddressDebug,
|
lastGroundedAddressDebug,
|
||||||
hasPriorAddressDebug: Boolean(lastGroundedAddressDebug || lastAddressAssistantDebug)
|
hasPriorAddressDebug: Boolean(lastGroundedAddressDebug || lastAddressAssistantDebug),
|
||||||
|
sessionItems
|
||||||
});
|
});
|
||||||
const contextualHistoricalCapabilityFollowupDetected = memorySignals.contextualHistoricalCapabilityFollowupDetected;
|
const contextualHistoricalCapabilityFollowupDetected = memorySignals.contextualHistoricalCapabilityFollowupDetected;
|
||||||
const contextualMemoryRecapFollowupDetected = memorySignals.contextualMemoryRecapFollowupDetected;
|
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) {
|
if (nonDomainQueryIndexed) {
|
||||||
return {
|
return {
|
||||||
runAddressLane: false,
|
runAddressLane: false,
|
||||||
|
|
@ -422,7 +571,6 @@ function createAssistantRoutePolicy(deps) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const metaAnswerFollowupSignal = metaSignals.metaAnswerFollowupSignal;
|
const metaAnswerFollowupSignal = metaSignals.metaAnswerFollowupSignal;
|
||||||
const baseToolGate = resolveAddressToolGateDecision(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage);
|
|
||||||
const preserveAddressLaneSignal = Boolean((llmPreDecomposeMeta?.llmCanonicalCandidateDetected &&
|
const preserveAddressLaneSignal = Boolean((llmPreDecomposeMeta?.llmCanonicalCandidateDetected &&
|
||||||
llmPreDecomposeMeta?.applied &&
|
llmPreDecomposeMeta?.applied &&
|
||||||
llmContractMode === "address_query") ||
|
llmContractMode === "address_query") ||
|
||||||
|
|
@ -447,6 +595,12 @@ function createAssistantRoutePolicy(deps) {
|
||||||
Boolean((resolvedIntentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(resolvedIntentResolution.intent)) ||
|
Boolean((resolvedIntentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(resolvedIntentResolution.intent)) ||
|
||||||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
|
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
|
||||||
openContractsAddressSignal);
|
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 &&
|
const semanticGuardHints = semanticExtractionContract?.guard_hints &&
|
||||||
typeof semanticExtractionContract.guard_hints === "object"
|
typeof semanticExtractionContract.guard_hints === "object"
|
||||||
? semanticExtractionContract.guard_hints
|
? semanticExtractionContract.guard_hints
|
||||||
|
|
@ -481,8 +635,7 @@ function createAssistantRoutePolicy(deps) {
|
||||||
semanticDeepInvestigationHintDetected ||
|
semanticDeepInvestigationHintDetected ||
|
||||||
aggregateBusinessAnalyticsSignal) &&
|
aggregateBusinessAnalyticsSignal) &&
|
||||||
!preserveAddressLaneSignal &&
|
!preserveAddressLaneSignal &&
|
||||||
!keepAddressLaneByIntent &&
|
!supportedAddressRouteCandidateDetected &&
|
||||||
!supportedAddressIntentDetected &&
|
|
||||||
(!followupContext || followupSemanticOverrideToDeepAllowed));
|
(!followupContext || followupSemanticOverrideToDeepAllowed));
|
||||||
const deepAnalysisPreferenceDetected = Boolean(hasDeepAnalysisPreferenceSignal(rawUserMessage) ||
|
const deepAnalysisPreferenceDetected = Boolean(hasDeepAnalysisPreferenceSignal(rawUserMessage) ||
|
||||||
hasDeepAnalysisPreferenceSignal(repairedRawUserMessage) ||
|
hasDeepAnalysisPreferenceSignal(repairedRawUserMessage) ||
|
||||||
|
|
@ -492,6 +645,19 @@ function createAssistantRoutePolicy(deps) {
|
||||||
hasDirectDeepAnalysisSignal(repairedRawUserMessage) ||
|
hasDirectDeepAnalysisSignal(repairedRawUserMessage) ||
|
||||||
hasDirectDeepAnalysisSignal(effectiveAddressUserMessage) ||
|
hasDirectDeepAnalysisSignal(effectiveAddressUserMessage) ||
|
||||||
hasDirectDeepAnalysisSignal(repairedEffectiveAddressUserMessage));
|
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 &&
|
const vatExplainFollowupSignal = Boolean(followupContext &&
|
||||||
toNonEmptyString(followupContext.previous_intent) === "vat_payable_forecast" &&
|
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}`)));
|
/(?:\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 &&
|
const deepAnalysisSignalFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
|
||||||
!llmRuntimeUnavailableDetected &&
|
!llmRuntimeUnavailableDetected &&
|
||||||
(deepAnalysisPreferenceDetected || semanticDeepInvestigationHintDetected) &&
|
(deepAnalysisPreferenceDetected || semanticDeepInvestigationHintDetected) &&
|
||||||
!keepAddressLaneByIntent &&
|
!protectAddressLaneFromFallback &&
|
||||||
!supportedAddressIntentDetected &&
|
|
||||||
!vatExplainFollowupSignal &&
|
!vatExplainFollowupSignal &&
|
||||||
(!followupContext || !dataRetrievalSignal || followupSemanticOverrideToDeepAllowed));
|
(!followupContext || !dataRetrievalSignal || followupSemanticOverrideToDeepAllowed));
|
||||||
const aggregateAnalyticsFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
|
const aggregateAnalyticsFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
|
||||||
!llmRuntimeUnavailableDetected &&
|
!llmRuntimeUnavailableDetected &&
|
||||||
aggregateBusinessAnalyticsSignal &&
|
aggregateBusinessAnalyticsSignal &&
|
||||||
!keepAddressLaneByIntent &&
|
!protectAddressLaneFromFallback &&
|
||||||
!supportedAddressIntentDetected &&
|
|
||||||
(!followupContext ||
|
(!followupContext ||
|
||||||
llmContractMode === "unsupported" ||
|
llmContractMode === "unsupported" ||
|
||||||
|
llmContractMode === null ||
|
||||||
semanticAggregateShapeDetected ||
|
semanticAggregateShapeDetected ||
|
||||||
!semanticApplyCanonicalRecommended ||
|
!semanticApplyCanonicalRecommended ||
|
||||||
standaloneAddressTopicSignal));
|
standaloneAddressTopicSignal));
|
||||||
|
|
@ -631,10 +796,11 @@ function createAssistantRoutePolicy(deps) {
|
||||||
semantic_apply_canonical_recommended: semanticApplyCanonicalRecommended,
|
semantic_apply_canonical_recommended: semanticApplyCanonicalRecommended,
|
||||||
semantic_reason_codes: semanticReasonCodes,
|
semantic_reason_codes: semanticReasonCodes,
|
||||||
semantic_route_arbitration: {
|
semantic_route_arbitration: {
|
||||||
supported_address_intent_detected: supportedAddressIntentDetected,
|
supported_address_intent_detected: supportedAddressRouteCandidateDetected,
|
||||||
strict_deep_investigation_bypass_allowed: strictDeepInvestigationBypassAllowed,
|
strict_deep_investigation_bypass_allowed: strictDeepInvestigationBypassAllowed,
|
||||||
semantic_deep_investigation_hint_detected: semanticDeepInvestigationHintDetected,
|
semantic_deep_investigation_hint_detected: semanticDeepInvestigationHintDetected,
|
||||||
semantic_aggregate_shape_detected: semanticAggregateShapeDetected,
|
semantic_aggregate_shape_detected: semanticAggregateShapeDetected,
|
||||||
|
exact_address_intent_protected_from_semantic_deep_hint: exactAddressIntentProtectedFromSemanticDeepHint,
|
||||||
followup_semantic_override_to_deep_allowed: followupSemanticOverrideToDeepAllowed
|
followup_semantic_override_to_deep_allowed: followupSemanticOverrideToDeepAllowed
|
||||||
},
|
},
|
||||||
followup_context_detected: Boolean(followupContext),
|
followup_context_detected: Boolean(followupContext),
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,7 @@ function intersectsSpan(start, end, spans) {
|
||||||
function hasAccountContextAround(text, start, end) {
|
function hasAccountContextAround(text, start, end) {
|
||||||
const left = text.slice(Math.max(0, start - 28), start);
|
const left = text.slice(Math.max(0, start - 28), start);
|
||||||
const right = text.slice(end, Math.min(text.length, end + 28));
|
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) {
|
function extractAccountsFromTextDetailed(text, options) {
|
||||||
const lower = String(text ?? "").toLowerCase();
|
const lower = String(text ?? "").toLowerCase();
|
||||||
|
|
@ -184,8 +184,8 @@ function extractAccountsFromTextDetailed(text, options) {
|
||||||
const percentSpans = collectPercentLikeSpans(lower);
|
const percentSpans = collectPercentLikeSpans(lower);
|
||||||
const contractSpans = collectContractLikeSpans(lower);
|
const contractSpans = collectContractLikeSpans(lower);
|
||||||
const blockedSpans = [...dateSpans, ...amountSpans, ...percentSpans, ...contractSpans];
|
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 hasAccountingLexeme = /(?:сч(?:е|ё)т(?:а|у|ом|ов)?|сч\.?|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;
|
const contextualPattern = /(?:^|[^\p{L}\d])(?:сч(?:е|ё)т(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?)[\s#:№]*((?:\d{2})(?:\.\d{2})?)/giu;
|
||||||
let contextualMatch = null;
|
let contextualMatch = null;
|
||||||
while ((contextualMatch = contextualPattern.exec(lower)) !== null) {
|
while ((contextualMatch = contextualPattern.exec(lower)) !== null) {
|
||||||
const token = String(contextualMatch[1] ?? "").trim();
|
const token = String(contextualMatch[1] ?? "").trim();
|
||||||
|
|
@ -811,7 +811,15 @@ function resolveDomainPolarityGuard(input) {
|
||||||
const repairedMessage = repairRuntimeGuardsMojibake(String(input.userMessage ?? ""));
|
const repairedMessage = repairRuntimeGuardsMojibake(String(input.userMessage ?? ""));
|
||||||
const lower = repairedMessage.toLowerCase();
|
const lower = repairedMessage.toLowerCase();
|
||||||
const accountExtraction = extractAccountsFromTextDetailed(lower);
|
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 prefixes = new Set(accounts.map((item) => accountPrefix(item)).filter((item) => Boolean(item)));
|
||||||
const settlementSignal = input.focusDomainHint === "settlements_60_62" ||
|
const settlementSignal = input.focusDomainHint === "settlements_60_62" ||
|
||||||
prefixes.has("60") ||
|
prefixes.has("60") ||
|
||||||
|
|
|
||||||
|
|
@ -3701,6 +3701,9 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||||
? llmPreDecomposeMeta.semanticExtractionContract
|
? llmPreDecomposeMeta.semanticExtractionContract
|
||||||
: null;
|
: null;
|
||||||
const semanticCanonicalRecommended = semanticExtractionContract?.apply_canonical_recommended !== false;
|
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 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 llmCanonicalAppliedSignal = Boolean(llmPreDecomposeMeta?.applied) && llmContractMode !== "deep_analysis";
|
||||||
const hasLlmCanonicalSignal = semanticCanonicalRecommended &&
|
const hasLlmCanonicalSignal = semanticCanonicalRecommended &&
|
||||||
|
|
@ -3713,6 +3716,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||||
Boolean(llmPreDecomposeMeta?.applied) &&
|
Boolean(llmPreDecomposeMeta?.applied) &&
|
||||||
(llmContractMode === "address_query" || llmContractMode === "unsupported" || llmContractMode === null) &&
|
(llmContractMode === "address_query" || llmContractMode === "unsupported" || llmContractMode === null) &&
|
||||||
hasStrongDataIntentSignal(repairedInputMessage);
|
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) ||
|
const sameDateAccountFollowupSignal = hasSameDateAccountFollowupSignalForPredecompose(rawMessageForGate) ||
|
||||||
hasSameDateAccountFollowupSignalForPredecompose(repairedInputMessage);
|
hasSameDateAccountFollowupSignalForPredecompose(repairedInputMessage);
|
||||||
const hasLexicalAddressSignal = isAddressLlmPreDecomposeCandidate(addressInputMessage) ||
|
const hasLexicalAddressSignal = isAddressLlmPreDecomposeCandidate(addressInputMessage) ||
|
||||||
|
|
@ -3721,11 +3725,12 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||||
hasAccountingSignal(repairedInputMessage) ||
|
hasAccountingSignal(repairedInputMessage) ||
|
||||||
hasShortDebtMirrorFollowupSignal(rawMessageForGate) ||
|
hasShortDebtMirrorFollowupSignal(rawMessageForGate) ||
|
||||||
hasShortDebtMirrorFollowupSignal(repairedInputMessage) ||
|
hasShortDebtMirrorFollowupSignal(repairedInputMessage) ||
|
||||||
|
hasBusinessRankingAddressSignal ||
|
||||||
sameDateAccountFollowupSignal;
|
sameDateAccountFollowupSignal;
|
||||||
const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" &&
|
const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" &&
|
||||||
(llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") &&
|
(llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") &&
|
||||||
llmContractIntent === "unknown";
|
llmContractIntent === "unknown";
|
||||||
const hasAnyAddressSignal = hasClassifierSignal || hasIntentSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal;
|
const hasAnyAddressSignal = hasClassifierSignal || hasIntentSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal || llmSupportedDeepAddressIntentSignal;
|
||||||
const strongDataSignalFromRawMessage = hasStrongDataIntentSignal(rawMessageForGate) ||
|
const strongDataSignalFromRawMessage = hasStrongDataIntentSignal(rawMessageForGate) ||
|
||||||
hasDataRetrievalRequestSignal(rawMessageForGate) ||
|
hasDataRetrievalRequestSignal(rawMessageForGate) ||
|
||||||
hasAccountingSignal(rawMessageForGate) ||
|
hasAccountingSignal(rawMessageForGate) ||
|
||||||
|
|
@ -3739,6 +3744,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||||
!hasClassifierSignal &&
|
!hasClassifierSignal &&
|
||||||
!hasIntentSignal &&
|
!hasIntentSignal &&
|
||||||
!hasLexicalAddressSignal &&
|
!hasLexicalAddressSignal &&
|
||||||
|
!llmSupportedDeepAddressIntentSignal &&
|
||||||
!strongDataSignalFromRawMessage &&
|
!strongDataSignalFromRawMessage &&
|
||||||
!strongDataSignalFromEffectiveMessage) {
|
!strongDataSignalFromEffectiveMessage) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -3749,6 +3755,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||||
}
|
}
|
||||||
if (hasUnsupportedLowConfidencePredecomposeSignal && !followupContext &&
|
if (hasUnsupportedLowConfidencePredecomposeSignal && !followupContext &&
|
||||||
!hasAnyAddressSignal &&
|
!hasAnyAddressSignal &&
|
||||||
|
!llmSupportedDeepAddressIntentSignal &&
|
||||||
!strongDataSignalFromRawMessage &&
|
!strongDataSignalFromRawMessage &&
|
||||||
!strongDataSignalFromEffectiveMessage) {
|
!strongDataSignalFromEffectiveMessage) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -3770,7 +3777,9 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||||
? "llm_canonical_candidate_detected"
|
? "llm_canonical_candidate_detected"
|
||||||
: hasLlmCanonicalDataSignal
|
: hasLlmCanonicalDataSignal
|
||||||
? "llm_canonical_data_signal_detected"
|
? "llm_canonical_data_signal_detected"
|
||||||
: "address_signal_detected"
|
: llmSupportedDeepAddressIntentSignal
|
||||||
|
? "address_signal_detected"
|
||||||
|
: "address_signal_detected"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (followupContext) {
|
if (followupContext) {
|
||||||
|
|
@ -3886,6 +3895,11 @@ function hasAggregateBusinessAnalyticsSignal(text) {
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return false;
|
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);
|
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) {
|
if (!hasMetricCue) {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,38 @@
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.createAssistantTransitionPolicy = createAssistantTransitionPolicy;
|
exports.createAssistantTransitionPolicy = createAssistantTransitionPolicy;
|
||||||
function createAssistantTransitionPolicy(deps) {
|
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) {
|
function shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent) {
|
||||||
const normalized = deps.compactWhitespace(deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
|
const normalized = deps.compactWhitespace(deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
|
||||||
if (!normalized || deps.countTokens(normalized) > 4) {
|
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);
|
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) {
|
function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null, addressNavigationState = null) {
|
||||||
const previousAddressItem = deps.findLastAddressAssistantItem(items);
|
const previousAddressItem = deps.findLastAddressAssistantItem(items);
|
||||||
const previousAddressDebug = previousAddressItem?.debug ?? null;
|
const previousAddressDebug = previousAddressItem?.debug ?? null;
|
||||||
|
|
@ -78,6 +166,10 @@ function createAssistantTransitionPolicy(deps) {
|
||||||
const hasInventoryRootTemporalFollowupAlternate = deps.toNonEmptyString(alternateMessage)
|
const hasInventoryRootTemporalFollowupAlternate = deps.toNonEmptyString(alternateMessage)
|
||||||
? deps.hasInventoryRootTemporalFollowupSignal(String(alternateMessage ?? ""), sourceIntentHint, Boolean(recentInventoryRootFrame))
|
? deps.hasInventoryRootTemporalFollowupSignal(String(alternateMessage ?? ""), sourceIntentHint, Boolean(recentInventoryRootFrame))
|
||||||
: false;
|
: false;
|
||||||
|
const hasInventoryRootRestatementPrimary = hasInventoryRootRestatementLikeSignal(userMessage, sourceIntentHint, Boolean(recentInventoryRootFrame));
|
||||||
|
const hasInventoryRootRestatementAlternate = deps.toNonEmptyString(alternateMessage)
|
||||||
|
? hasInventoryRootRestatementLikeSignal(String(alternateMessage ?? ""), sourceIntentHint, Boolean(recentInventoryRootFrame))
|
||||||
|
: false;
|
||||||
let hasStrongFollowupReference = hasPrimaryIndexReferenceSignal ||
|
let hasStrongFollowupReference = hasPrimaryIndexReferenceSignal ||
|
||||||
hasAlternateIndexReferenceSignal ||
|
hasAlternateIndexReferenceSignal ||
|
||||||
hasOrganizationClarificationContinuation ||
|
hasOrganizationClarificationContinuation ||
|
||||||
|
|
@ -86,6 +178,8 @@ function createAssistantTransitionPolicy(deps) {
|
||||||
inventoryShortFollowupAlternate ||
|
inventoryShortFollowupAlternate ||
|
||||||
hasInventoryRootTemporalFollowupPrimary ||
|
hasInventoryRootTemporalFollowupPrimary ||
|
||||||
hasInventoryRootTemporalFollowupAlternate ||
|
hasInventoryRootTemporalFollowupAlternate ||
|
||||||
|
hasInventoryRootRestatementPrimary ||
|
||||||
|
hasInventoryRootRestatementAlternate ||
|
||||||
Boolean(debtRoleSwapIntent) ||
|
Boolean(debtRoleSwapIntent) ||
|
||||||
deps.hasFollowupMarker(userMessage) ||
|
deps.hasFollowupMarker(userMessage) ||
|
||||||
deps.hasReferentialPointer(userMessage) ||
|
deps.hasReferentialPointer(userMessage) ||
|
||||||
|
|
@ -100,6 +194,8 @@ function createAssistantTransitionPolicy(deps) {
|
||||||
!hasAlternateFollowupSignal &&
|
!hasAlternateFollowupSignal &&
|
||||||
!hasInventoryRootTemporalFollowupPrimary &&
|
!hasInventoryRootTemporalFollowupPrimary &&
|
||||||
!hasInventoryRootTemporalFollowupAlternate &&
|
!hasInventoryRootTemporalFollowupAlternate &&
|
||||||
|
!hasInventoryRootRestatementPrimary &&
|
||||||
|
!hasInventoryRootRestatementAlternate &&
|
||||||
!hasImplicitContinuationSignal &&
|
!hasImplicitContinuationSignal &&
|
||||||
!hasOrganizationClarificationContinuation &&
|
!hasOrganizationClarificationContinuation &&
|
||||||
!hasIndexReferenceSignal) {
|
!hasIndexReferenceSignal) {
|
||||||
|
|
@ -109,6 +205,8 @@ function createAssistantTransitionPolicy(deps) {
|
||||||
!hasAlternateFollowupSignal &&
|
!hasAlternateFollowupSignal &&
|
||||||
!hasInventoryRootTemporalFollowupPrimary &&
|
!hasInventoryRootTemporalFollowupPrimary &&
|
||||||
!hasInventoryRootTemporalFollowupAlternate &&
|
!hasInventoryRootTemporalFollowupAlternate &&
|
||||||
|
!hasInventoryRootRestatementPrimary &&
|
||||||
|
!hasInventoryRootRestatementAlternate &&
|
||||||
!hasImplicitContinuationSignal &&
|
!hasImplicitContinuationSignal &&
|
||||||
!hasOrganizationClarificationContinuation &&
|
!hasOrganizationClarificationContinuation &&
|
||||||
!hasIndexReferenceSignal) {
|
!hasIndexReferenceSignal) {
|
||||||
|
|
@ -131,8 +229,38 @@ function createAssistantTransitionPolicy(deps) {
|
||||||
? resolvedAlternateIntent
|
? resolvedAlternateIntent
|
||||||
: null;
|
: null;
|
||||||
const sourceIntentFamily = deps.resolveAddressIntentFamily(sourceIntent);
|
const sourceIntentFamily = deps.resolveAddressIntentFamily(sourceIntent);
|
||||||
const explicitIntentFamily = deps.resolveAddressIntentFamily(explicitIntent);
|
const explicitIntentFamily = deps.resolveAddressIntentFamily(explicitIntent) ??
|
||||||
if (sourceIntentFamily && explicitIntentFamily && sourceIntentFamily !== explicitIntentFamily && !hasStrongFollowupReference) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
let previousIntent = sourceIntent;
|
let previousIntent = sourceIntent;
|
||||||
|
|
@ -144,7 +272,7 @@ function createAssistantTransitionPolicy(deps) {
|
||||||
const suggestedIntent = Array.isArray(followupOffer?.suggested_intents)
|
const suggestedIntent = Array.isArray(followupOffer?.suggested_intents)
|
||||||
? deps.toNonEmptyString(followupOffer.suggested_intents[0])
|
? deps.toNonEmptyString(followupOffer.suggested_intents[0])
|
||||||
: null;
|
: null;
|
||||||
const keepPreviousIntent = shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent);
|
const keepPreviousIntent = shouldKeepPreviousIntentForShortCounterpartyRetargetV2(userMessage, sourceIntent);
|
||||||
if (suggestedIntent && !keepPreviousIntent) {
|
if (suggestedIntent && !keepPreviousIntent) {
|
||||||
previousIntent = suggestedIntent;
|
previousIntent = suggestedIntent;
|
||||||
followupSelectionMode = "switch_to_suggested_intent";
|
followupSelectionMode = "switch_to_suggested_intent";
|
||||||
|
|
@ -248,6 +376,7 @@ function createAssistantTransitionPolicy(deps) {
|
||||||
: "generic"
|
: "generic"
|
||||||
: null;
|
: null;
|
||||||
let resolvedCounterpartyFromDisplay = false;
|
let resolvedCounterpartyFromDisplay = false;
|
||||||
|
let displayedEntityTargetIntent = null;
|
||||||
const previousFiltersRaw = previousAddressDebug.extracted_filters;
|
const previousFiltersRaw = previousAddressDebug.extracted_filters;
|
||||||
let previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object" ? { ...previousFiltersRaw } : {};
|
let previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object" ? { ...previousFiltersRaw } : {};
|
||||||
const shouldBackfillHistoricalPartyAnchors = sourceIntentHint === "list_contracts_by_counterparty" ||
|
const shouldBackfillHistoricalPartyAnchors = sourceIntentHint === "list_contracts_by_counterparty" ||
|
||||||
|
|
@ -314,7 +443,15 @@ function createAssistantTransitionPolicy(deps) {
|
||||||
currentFrameKind === "inventory_root") &&
|
currentFrameKind === "inventory_root") &&
|
||||||
(hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate) &&
|
(hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate) &&
|
||||||
!deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage));
|
!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) {
|
if (rootScopedPivot) {
|
||||||
previousIntent = null;
|
previousIntent = null;
|
||||||
previousAnchorType = null;
|
previousAnchorType = null;
|
||||||
|
|
@ -330,6 +467,7 @@ function createAssistantTransitionPolicy(deps) {
|
||||||
? deps.resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities)
|
? deps.resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities)
|
||||||
: null);
|
: null);
|
||||||
if (resolvedEntityFromFollowup && !rootScopedPivot) {
|
if (resolvedEntityFromFollowup && !rootScopedPivot) {
|
||||||
|
displayedEntityTargetIntent = resolveDisplayedEntityRetargetIntent(userMessage, resolvedEntityFromFollowup.entityType);
|
||||||
if (resolvedEntityFromFollowup.entityType === "counterparty") {
|
if (resolvedEntityFromFollowup.entityType === "counterparty") {
|
||||||
previousFilters.counterparty = resolvedEntityFromFollowup.value;
|
previousFilters.counterparty = resolvedEntityFromFollowup.value;
|
||||||
previousAnchorType = "counterparty";
|
previousAnchorType = "counterparty";
|
||||||
|
|
@ -396,11 +534,13 @@ function createAssistantTransitionPolicy(deps) {
|
||||||
inventoryShortFollowupAlternate ||
|
inventoryShortFollowupAlternate ||
|
||||||
hasInventoryRootTemporalFollowupPrimary ||
|
hasInventoryRootTemporalFollowupPrimary ||
|
||||||
hasInventoryRootTemporalFollowupAlternate ||
|
hasInventoryRootTemporalFollowupAlternate ||
|
||||||
|
hasInventoryRootRestatementPrimary ||
|
||||||
|
hasInventoryRootRestatementAlternate ||
|
||||||
hasSelectedObjectInventorySignalPrimary ||
|
hasSelectedObjectInventorySignalPrimary ||
|
||||||
hasSelectedObjectInventorySignalAlternate));
|
hasSelectedObjectInventorySignalAlternate));
|
||||||
const carryoverTargetIntent = followupSelectionMode === "carry_root_context"
|
const carryoverTargetIntent = followupSelectionMode === "carry_root_context"
|
||||||
? inventoryRootFrame?.intent ?? explicitIntent ?? previousIntent ?? undefined
|
? inventoryRootFrame?.intent ?? displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined
|
||||||
: explicitIntent ?? previousIntent ?? undefined;
|
: displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined;
|
||||||
return {
|
return {
|
||||||
followupContext: {
|
followupContext: {
|
||||||
previous_intent: previousIntent ?? undefined,
|
previous_intent: previousIntent ?? undefined,
|
||||||
|
|
@ -430,14 +570,15 @@ function createAssistantTransitionPolicy(deps) {
|
||||||
const previousIntent = deps.toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null;
|
const previousIntent = deps.toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null;
|
||||||
const selectionMode = deps.toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? null;
|
const selectionMode = deps.toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? null;
|
||||||
const rootContextOnly = selectionMode === "carry_root_context";
|
const rootContextOnly = selectionMode === "carry_root_context";
|
||||||
|
const carryoverTargetIntent = deps.toNonEmptyString(carryoverMeta?.followupContext?.target_intent) ?? null;
|
||||||
const explicitIntentRaw = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
|
const explicitIntentRaw = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
|
||||||
const explicitIntent = explicitIntentRaw === "unknown" ? null : explicitIntentRaw;
|
const explicitIntent = explicitIntentRaw === "unknown" ? null : explicitIntentRaw;
|
||||||
const rootIntent = deps.toNonEmptyString(carryoverMeta?.followupContext?.root_intent) ?? null;
|
const rootIntent = deps.toNonEmptyString(carryoverMeta?.followupContext?.root_intent) ?? null;
|
||||||
const targetIntent = selectionMode === "switch_to_suggested_intent"
|
const targetIntent = selectionMode === "switch_to_suggested_intent"
|
||||||
? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null
|
? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null
|
||||||
: rootContextOnly
|
: rootContextOnly
|
||||||
? rootIntent ?? explicitIntent ?? null
|
? carryoverTargetIntent ?? rootIntent ?? explicitIntent ?? null
|
||||||
: explicitIntent ?? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null;
|
: carryoverTargetIntent ?? explicitIntent ?? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null;
|
||||||
const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal);
|
const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal);
|
||||||
const rewrittenByPredecompose = deps.compactWhitespace(sourceMessage.toLowerCase()) !== deps.compactWhitespace(canonicalMessage.toLowerCase());
|
const rewrittenByPredecompose = deps.compactWhitespace(sourceMessage.toLowerCase()) !== deps.compactWhitespace(canonicalMessage.toLowerCase());
|
||||||
const hasExplicitIntent = Boolean(explicitIntent);
|
const hasExplicitIntent = Boolean(explicitIntent);
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,138 @@ type CounterpartyIntentDeps = {
|
||||||
hasGenericAddressLookupSignal: (text: string) => boolean;
|
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(
|
export function resolveCounterpartyAddressIntent(
|
||||||
text: string,
|
text: string,
|
||||||
deps: CounterpartyIntentDeps
|
deps: CounterpartyIntentDeps
|
||||||
): AddressIntentResolution | null {
|
): 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)) {
|
if (deps.hasOpenContractsListSignal(text)) {
|
||||||
return {
|
return {
|
||||||
intent: "open_contracts_confirmed_as_of_date",
|
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 (
|
if (
|
||||||
(deps.hasAny(text, deps.documentsByCounterpartyHints) || deps.hasCounterpartyShipmentItemFlowSignal(text)) &&
|
(deps.hasAny(text, deps.documentsByCounterpartyHints) || deps.hasCounterpartyShipmentItemFlowSignal(text)) &&
|
||||||
(deps.hasPartyAnchorMention(text) ||
|
(deps.hasPartyAnchorMention(text) ||
|
||||||
|
|
|
||||||
|
|
@ -957,6 +957,20 @@ function extractShipmentCounterpartyValue(text: string): string | undefined {
|
||||||
return candidate;
|
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 {
|
function hasExplicitAccountCue(text: string): boolean {
|
||||||
return /(?:сч[её]т|счет|account|acct)/iu.test(String(text ?? ""));
|
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");
|
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 (
|
if (
|
||||||
!filters.counterparty &&
|
!filters.counterparty &&
|
||||||
allowGenericCounterpartyAnchor &&
|
allowGenericCounterpartyAnchor &&
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 {
|
function hasInventoryOnHandSignal(text: string): boolean {
|
||||||
const hasColloquialStockSnapshotCue = /(?:что|ч[еёо])\s+(?:у\s+нас\s+)?на\s+склад(?:е|у|ом|ах)(?=$|[\s,.;:!?])/iu.test(
|
const hasColloquialStockSnapshotCue = /(?:что|ч[еёо])\s+(?:у\s+нас\s+)?на\s+склад(?:е|у|ом|ах)(?=$|[\s,.;:!?])/iu.test(
|
||||||
text
|
text
|
||||||
|
|
@ -26,7 +45,8 @@ function hasInventoryOnHandSignal(text: string): boolean {
|
||||||
const hasAccount41Anchor = hasInventoryAccount41Anchor(text);
|
const hasAccount41Anchor = hasInventoryAccount41Anchor(text);
|
||||||
const hasStockLexeme =
|
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) {
|
const hasPlainRussianRootCue = hasPlainRussianInventoryOnHandSignal(text);
|
||||||
|
if (!hasStockLexeme && !hasAccount41Anchor && !hasPlainRussianRootCue) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|
@ -51,6 +71,9 @@ function hasInventoryOnHandSignal(text: string): boolean {
|
||||||
if (hasAccount41Anchor && (hasGoodsLexeme || hasBalanceLexeme || hasRequestCue || hasInventoryAsOfCue(text))) {
|
if (hasAccount41Anchor && (hasGoodsLexeme || hasBalanceLexeme || hasRequestCue || hasInventoryAsOfCue(text))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (hasPlainRussianRootCue) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return (hasGoodsLexeme || hasBalanceLexeme || hasColloquialStockSnapshotCue || hasStockStateCue) &&
|
return (hasGoodsLexeme || hasBalanceLexeme || hasColloquialStockSnapshotCue || hasStockStateCue) &&
|
||||||
(hasRequestCue || 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)) {
|
if (hasSelectedObjectInventoryPurchaseDocumentsSignal(text)) {
|
||||||
return {
|
return {
|
||||||
intent: "inventory_purchase_documents_for_item",
|
intent: "inventory_purchase_documents_for_item",
|
||||||
|
|
|
||||||
|
|
@ -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[] {
|
function collectOrganizationCandidatesFromRows(rows: NormalizedAddressRow[]): string[] {
|
||||||
return mergeKnownOrganizations(rows.map((row) => row.organization).filter((value): value is string => Boolean(value)));
|
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) &&
|
!toNonEmptyFilterValue(filters.extracted_filters.organization) &&
|
||||||
!activeOrganization &&
|
!activeOrganization &&
|
||||||
!resolvedOrganizationFromMessage &&
|
!resolvedOrganizationFromMessage &&
|
||||||
|
!shouldDeferInventoryOrganizationClarification(intent.intent, filters.extracted_filters, semanticFrame) &&
|
||||||
knownOrganizations.length > 1
|
knownOrganizations.length > 1
|
||||||
) {
|
) {
|
||||||
return buildOrganizationClarificationExecutionResult({
|
return buildOrganizationClarificationExecutionResult({
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
type ComposeReplyResult,
|
type ComposeReplyResult,
|
||||||
type ComposeReplySemantics
|
type ComposeReplySemantics
|
||||||
} from "./replyPackaging";
|
} from "./replyPackaging";
|
||||||
|
import { composeCounterpartyAnalyticsReply } from "./counterpartyAnalyticsReplyBuilders";
|
||||||
import { composeInventoryReply } from "./inventoryReplyBuilders";
|
import { composeInventoryReply } from "./inventoryReplyBuilders";
|
||||||
|
|
||||||
export type { ComposeFactualReplyOptions, ComposeReplySemantics } from "./replyPackaging";
|
export type { ComposeFactualReplyOptions, ComposeReplySemantics } from "./replyPackaging";
|
||||||
|
|
@ -2751,6 +2752,31 @@ function composeFactualReplyBody(
|
||||||
return inventoryReply;
|
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") {
|
if (intent === "document_type_and_account_section_profile") {
|
||||||
const rowsByMarker = new Map<string, ComposeStageRow[]>();
|
const rowsByMarker = new Map<string, ComposeStageRow[]>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
|
@ -3035,854 +3061,6 @@ function composeFactualReplyBody(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent === "counterparty_population_and_roles") {
|
|
||||||
const rowsByMarker = new Map<string, ComposeStageRow[]>();
|
|
||||||
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<number>;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
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<number>(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<number>(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<number>(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<string | null>((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<string | null>((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<number>();
|
|
||||||
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<string, ComposeStageRow[]>();
|
|
||||||
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<string>;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
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<string>([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<string>;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
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") {
|
if (intent === "vat_payable_forecast") {
|
||||||
const rowsByMarker = new Map<string, number>();
|
const rowsByMarker = new Map<string, number>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
|
|
||||||
|
|
@ -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<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CounterpartyDealPoint {
|
||||||
|
period: string | null;
|
||||||
|
registrator: string;
|
||||||
|
counterparty: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContractValuePoint {
|
||||||
|
contract: string;
|
||||||
|
turnover: number;
|
||||||
|
docs: number;
|
||||||
|
lastPeriod: string | null;
|
||||||
|
counterparties: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, ComposeStageRow[]> {
|
||||||
|
const rowsByMarker = new Map<string, ComposeStageRow[]>();
|
||||||
|
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<string, CounterpartyActivityPoint>();
|
||||||
|
|
||||||
|
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<number>(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<number>(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<number>(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<string | null>((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<string | null>((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<number>();
|
||||||
|
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<string, CounterpartyValuePoint>();
|
||||||
|
const byYear = new Map<number, CounterpartyYearPoint>();
|
||||||
|
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<string>([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<string, ContractValuePoint>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -539,16 +539,21 @@ function shouldRestoreInventoryRootFrame(
|
||||||
const comingFromInventoryDrilldown =
|
const comingFromInventoryDrilldown =
|
||||||
currentFrameKind === "inventory_drilldown" || isInventoryDrilldownFrameIntent(previousIntent);
|
currentFrameKind === "inventory_drilldown" || isInventoryDrilldownFrameIntent(previousIntent);
|
||||||
const normalized = String(userMessage ?? "");
|
const normalized = String(userMessage ?? "");
|
||||||
|
const hasExplicitInventoryRootSnapshotCue = /(?:склад|остат(?:ок|ки)|товар(?:ы|ов)?|номенклатур)/iu.test(normalized);
|
||||||
const hasInventoryRootRestatementCue =
|
const hasInventoryRootRestatementCue =
|
||||||
/(?:склад|остат(?:ок|ки)|позици(?:я|и|ю)|товар(?:ы|ов)?|номенклатур)/iu.test(normalized) &&
|
/(?:склад|остат(?:ок|ки)|позици(?:я|и|ю)|товар(?:ы|ов)?|номенклатур)/iu.test(normalized) &&
|
||||||
/(?:покажи|показать|выведи|раскрой|еще\s+раз|ещ[её]\s+раз|снова|опять|верни|вернись|повтори|тот\s+же|этот\s+же|same|again)/iu.test(
|
(/(?:покажи|показать|выведи|раскрой|еще\s+раз|ещ[её]\s+раз|снова|опять|верни|вернись|повтори|тот\s+же|этот\s+же|same|again)/iu.test(
|
||||||
normalized
|
normalized
|
||||||
);
|
) ||
|
||||||
|
hasSameDateHint(normalized) ||
|
||||||
|
hasSamePeriodHint(normalized));
|
||||||
const canReenterInventoryRoot =
|
const canReenterInventoryRoot =
|
||||||
comingFromInventoryDrilldown ||
|
comingFromInventoryDrilldown ||
|
||||||
rootContextOnly ||
|
rootContextOnly ||
|
||||||
(currentFrameKind === "inventory_root" && (hasSamePeriodHint(normalized) || hasInventoryRootRestatementCue)) ||
|
(currentFrameKind === "inventory_root" && (hasSamePeriodHint(normalized) || hasInventoryRootRestatementCue)) ||
|
||||||
(currentFrameKind === "generic" && hasInventoryRootRestatementCue && hasSamePeriodHint(normalized));
|
(currentFrameKind === "generic" &&
|
||||||
|
hasExplicitInventoryRootSnapshotCue &&
|
||||||
|
(hasSameDateHint(normalized) || hasSamePeriodHint(normalized) || hasInventoryRootRestatementCue));
|
||||||
if (!canReenterInventoryRoot) {
|
if (!canReenterInventoryRoot) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -998,23 +1003,21 @@ function mergeFollowupFilters(
|
||||||
reasons.push("as_of_date_from_followup_context");
|
reasons.push("as_of_date_from_followup_context");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!sameDateRequested &&
|
!sameDateRequested &&
|
||||||
(intent === "inventory_aging_by_purchase_date" || isInventoryLifecycleHistoryIntent(intent)) &&
|
(intent === "inventory_aging_by_purchase_date" || isInventoryLifecycleHistoryIntent(intent)) &&
|
||||||
!hasExplicitPeriodLiteral(userMessage) &&
|
!hasExplicitPeriodLiteral(userMessage) &&
|
||||||
!hasExplicitCurrentDateHint(userMessage)
|
!hasExplicitCurrentDateHint(userMessage)
|
||||||
) {
|
) {
|
||||||
if (intent === "inventory_aging_by_purchase_date") {
|
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
|
||||||
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
|
const currentAsOfDate = toNonEmptyString(merged.as_of_date);
|
||||||
const currentAsOfDate = toNonEmptyString(merged.as_of_date);
|
const todayIso = new Date().toISOString().slice(0, 10);
|
||||||
const todayIso = new Date().toISOString().slice(0, 10);
|
const currentLooksDefaultedToToday = currentAsOfDate === todayIso;
|
||||||
const currentLooksDefaultedToToday = currentAsOfDate === todayIso;
|
if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) {
|
||||||
if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) {
|
merged.as_of_date = inheritedAsOfDate;
|
||||||
merged.as_of_date = inheritedAsOfDate;
|
reasons.push("as_of_date_from_followup_context");
|
||||||
reasons.push("as_of_date_from_followup_context");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
(Boolean(previousPeriodFrom) || Boolean(previousPeriodTo)) &&
|
(Boolean(previousPeriodFrom) || Boolean(previousPeriodTo)) &&
|
||||||
hasSelectedObjectInventorySignal(userMessage) &&
|
hasSelectedObjectInventorySignal(userMessage) &&
|
||||||
|
|
@ -1280,6 +1283,9 @@ function deriveIntentWithFollowupContext(
|
||||||
const hasPreviousCounterparty = Boolean(previousCounterparty ?? previousCounterpartyFromAnchor);
|
const hasPreviousCounterparty = Boolean(previousCounterparty ?? previousCounterpartyFromAnchor);
|
||||||
const hasAnyPartyAnchor = hasPreviousContract || hasPreviousCounterparty;
|
const hasAnyPartyAnchor = hasPreviousContract || hasPreviousCounterparty;
|
||||||
const isVatFollowup = hasVatCue(normalizedMessage);
|
const isVatFollowup = hasVatCue(normalizedMessage);
|
||||||
|
const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
|
||||||
|
const inventorySelectedObjectFollowup =
|
||||||
|
hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal);
|
||||||
|
|
||||||
if (detectedIntent.intent === "unknown" && isVatFollowup) {
|
if (detectedIntent.intent === "unknown" && isVatFollowup) {
|
||||||
const vatIntent: AddressIntent = hasVatTaxPaymentCue(normalizedMessage)
|
const vatIntent: AddressIntent = hasVatTaxPaymentCue(normalizedMessage)
|
||||||
|
|
@ -1295,7 +1301,12 @@ function deriveIntentWithFollowupContext(
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowOpenItemsFollowupFallback = detectedIntent.intent === "unknown" && !isVatFollowup;
|
const allowOpenItemsFollowupFallback = detectedIntent.intent === "unknown" && !isVatFollowup;
|
||||||
if (allowOpenItemsFollowupFallback && hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) {
|
if (
|
||||||
|
allowOpenItemsFollowupFallback &&
|
||||||
|
!inventorySelectedObjectFollowup &&
|
||||||
|
hasOpenItemsHint(normalizedMessage) &&
|
||||||
|
hasAnyPartyAnchor
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
intent: "open_items_by_counterparty_or_contract",
|
intent: "open_items_by_counterparty_or_contract",
|
||||||
confidence: "low",
|
confidence: "low",
|
||||||
|
|
@ -1323,9 +1334,6 @@ function deriveIntentWithFollowupContext(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
|
|
||||||
const inventorySelectedObjectFollowup =
|
|
||||||
hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal);
|
|
||||||
if (inventorySelectedObjectFollowup && hasInventorySupplierFollowupCue(normalizedMessage)) {
|
if (inventorySelectedObjectFollowup && hasInventorySupplierFollowupCue(normalizedMessage)) {
|
||||||
if (
|
if (
|
||||||
detectedIntent.intent === "unknown" ||
|
detectedIntent.intent === "unknown" ||
|
||||||
|
|
|
||||||
|
|
@ -53,10 +53,7 @@ function containsLetterLikeChars(text: unknown): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAssistantBoundaryPolicy(deps: AssistantBoundaryPolicyDeps): AssistantBoundaryPolicy {
|
export function createAssistantBoundaryPolicy(deps: AssistantBoundaryPolicyDeps): AssistantBoundaryPolicy {
|
||||||
const defaultChannel = String(deps.activeMcpChannel ?? "default");
|
|
||||||
|
|
||||||
function buildAssistantDataScopeContractReply(scopeProbe: Record<string, unknown> | null = null): string {
|
function buildAssistantDataScopeContractReply(scopeProbe: Record<string, unknown> | null = null): string {
|
||||||
const channel = String(scopeProbe?.channel ?? defaultChannel);
|
|
||||||
const organizations = Array.isArray(scopeProbe?.organizations)
|
const organizations = Array.isArray(scopeProbe?.organizations)
|
||||||
? scopeProbe.organizations
|
? scopeProbe.organizations
|
||||||
.map((item) => String(item ?? "").trim())
|
.map((item) => String(item ?? "").trim())
|
||||||
|
|
@ -65,30 +62,30 @@ export function createAssistantBoundaryPolicy(deps: AssistantBoundaryPolicyDeps)
|
||||||
|
|
||||||
if (organizations.length === 1) {
|
if (organizations.length === 1) {
|
||||||
return [
|
return [
|
||||||
`Сейчас в активном MCP-канале \`${channel}\` доступна организация: ${organizations[0]}.`,
|
`Сейчас доступна организация: ${organizations[0]}.`,
|
||||||
"Работаю в read-only режиме. Могу сразу показать по этой организации документы, операции, договоры или остатки."
|
"Могу сразу показать по ней документы, операции, договоры или остатки."
|
||||||
].join(" ");
|
].join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (organizations.length > 1) {
|
if (organizations.length > 1) {
|
||||||
const preview = organizations.slice(0, 10).join(", ");
|
const preview = organizations.slice(0, 10).join(", ");
|
||||||
return [
|
return [
|
||||||
`Сейчас в активном MCP-канале \`${channel}\` доступны организации (${organizations.length}): ${preview}.`,
|
`Сейчас доступны организации (${organizations.length}): ${preview}.`,
|
||||||
"Работаю в read-only режиме. Скажи, по какой организации смотреть документы/операции."
|
"Скажите, по какой организации смотреть данные."
|
||||||
].join(" ");
|
].join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scopeProbe?.status === "unresolved_with_error" && scopeProbe?.error) {
|
if (scopeProbe?.status === "unresolved_with_error" && scopeProbe?.error) {
|
||||||
return [
|
return [
|
||||||
`Не смог прочитать название организации из live MCP-канала \`${channel}\`: ${scopeProbe.error}.`,
|
"Сейчас не удалось определить список организаций из подключенной базы.",
|
||||||
"Работаю в read-only режиме и вижу только данные активного контура. Проверь подключение MCP/1С, после этого сразу назову контур."
|
`Техническая причина: ${scopeProbe.error}.`,
|
||||||
|
"Проверьте подключение, и я сразу назову доступный контур."
|
||||||
].join(" ");
|
].join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
`Работаю в read-only режиме и вижу только те данные, которые отдает текущий MCP-канал \`${channel}\`.`,
|
"Сейчас вижу только данные текущего подключенного контура.",
|
||||||
"Словарь компаний не зашит в код: рабочий контур определяется live-подключением.",
|
"Если в нем несколько организаций, скажите, по какой смотреть данные."
|
||||||
"Если подключено несколько баз, для автосписка нужен MCP-метод метаданных (перечень баз/организаций); без него можно анализировать только активный контур запросов."
|
|
||||||
].join(" ");
|
].join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
export interface AssistantContinuitySnapshotInput {
|
||||||
|
sessionItems?: unknown[];
|
||||||
|
toNonEmptyString?: (value: unknown) => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantContinuitySnapshot {
|
||||||
|
lastGroundedAddressDebug: Record<string, unknown> | null;
|
||||||
|
lastGroundedItemAddressDebug: Record<string, unknown> | null;
|
||||||
|
lastGroundedInventoryAddressDebug: Record<string, unknown> | 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<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown> | null): Record<string, unknown> | null {
|
||||||
|
return toRecordObject(debug?.extracted_filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readAddressDebugItem(
|
||||||
|
debug: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | null = null;
|
||||||
|
let lastGroundedItemAddressDebug: Record<string, unknown> | null = null;
|
||||||
|
let lastGroundedInventoryAddressDebug: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
for (let index = sessionItems.length - 1; index >= 0; index -= 1) {
|
||||||
|
const item = sessionItems[index] as { role?: string; debug?: Record<string, unknown> } | 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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,10 @@ import {
|
||||||
mergeKnownOrganizations,
|
mergeKnownOrganizations,
|
||||||
normalizeOrganizationScopeValue
|
normalizeOrganizationScopeValue
|
||||||
} from "./assistantOrganizationMatcher";
|
} from "./assistantOrganizationMatcher";
|
||||||
|
import {
|
||||||
|
isGroundedAddressDebug,
|
||||||
|
readAddressDebugOrganization
|
||||||
|
} from "./assistantContinuityPolicy";
|
||||||
|
|
||||||
const DATA_SCOPE_CACHE_TTL_MS = 60_000;
|
const DATA_SCOPE_CACHE_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
|
@ -311,6 +315,12 @@ export function createAssistantDataScopePolicy(deps: AssistantDataScopePolicyDep
|
||||||
if (selected) {
|
if (selected) {
|
||||||
return selected;
|
return selected;
|
||||||
}
|
}
|
||||||
|
if (isGroundedAddressDebug(debug)) {
|
||||||
|
const groundedOrganization = normalizeOrganizationScopeValue(readAddressDebugOrganization(debug));
|
||||||
|
if (groundedOrganization) {
|
||||||
|
return groundedOrganization;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -320,6 +320,7 @@ export async function runAssistantLivingChatRuntime(
|
||||||
chatText = buildAddressMemoryRecapReplyFromPolicy({
|
chatText = buildAddressMemoryRecapReplyFromPolicy({
|
||||||
organization: scopedOrganization,
|
organization: scopedOrganization,
|
||||||
addressDebug: lastMemoryAddressDebug,
|
addressDebug: lastMemoryAddressDebug,
|
||||||
|
sessionItems: input.sessionItems,
|
||||||
toNonEmptyString: input.toNonEmptyString
|
toNonEmptyString: input.toNonEmptyString
|
||||||
});
|
});
|
||||||
activeOrganization = scopedOrganization ?? activeOrganization;
|
activeOrganization = scopedOrganization ?? activeOrganization;
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ export function createAssistantLivingModePolicy(deps: AssistantLivingModePolicyD
|
||||||
const hasMemoryCue = samples.some((sample) => /(?:помни(?:шь|те|м)?|remember|recall)/iu.test(sample));
|
const hasMemoryCue = samples.some((sample) => /(?:помни(?:шь|те|м)?|remember|recall)/iu.test(sample));
|
||||||
const hasDiscussionCue = samples.some((sample) => /(?:обсуждал[аи]?|говорил[аи]?|смотрел[аи]?|разбирал[аи]?|спрашивал[аи]?)/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));
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
if (hasExplicitRecapPrompt) {
|
if (hasExplicitRecapPrompt) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,15 @@
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatIsoDateForReply,
|
||||||
|
isGroundedAddressDebug,
|
||||||
|
readAddressDebugFilters,
|
||||||
|
readAddressDebugItem,
|
||||||
|
readAddressDebugOrganization,
|
||||||
|
readAddressDebugScopedDate,
|
||||||
|
resolveAssistantContinuitySnapshot
|
||||||
|
} from "./assistantContinuityPolicy";
|
||||||
|
|
||||||
export interface ResolveAssistantRouteMemorySignalsInput {
|
export interface ResolveAssistantRouteMemorySignalsInput {
|
||||||
rawUserMessage?: unknown;
|
rawUserMessage?: unknown;
|
||||||
repairedRawUserMessage?: unknown;
|
repairedRawUserMessage?: unknown;
|
||||||
|
|
@ -12,6 +22,7 @@ export interface ResolveAssistantRouteMemorySignalsInput {
|
||||||
aggregateBusinessAnalyticsSignal?: boolean;
|
aggregateBusinessAnalyticsSignal?: boolean;
|
||||||
lastGroundedAddressDebug?: unknown;
|
lastGroundedAddressDebug?: unknown;
|
||||||
hasPriorAddressDebug?: boolean;
|
hasPriorAddressDebug?: boolean;
|
||||||
|
sessionItems?: unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssistantRouteMemorySignals {
|
export interface AssistantRouteMemorySignals {
|
||||||
|
|
@ -37,13 +48,12 @@ export interface AssistantMemoryRecapPolicyDeps {
|
||||||
isGroundedInventoryContextDebug: (debug: unknown) => boolean;
|
isGroundedInventoryContextDebug: (debug: unknown) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatIsoDateForReply(value: unknown): string | null {
|
function toNonEmptyString(value: unknown): string | null {
|
||||||
const source = String(value ?? "").trim();
|
if (value === null || value === undefined) {
|
||||||
const match = source.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
||||||
if (!match) {
|
|
||||||
return null;
|
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[] {
|
function collectMessageSamples(input: ResolveAssistantRouteMemorySignalsInput): string[] {
|
||||||
|
|
@ -77,84 +87,6 @@ function hasExplicitRecapPromptSignal(samples: string[]): boolean {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findLastGroundedInventoryAddressDebug(items: unknown[]): Record<string, unknown> | 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<string, unknown> } | 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<string, unknown>)
|
|
||||||
: 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<string, unknown>)
|
|
||||||
: 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<string, unknown> | 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<string, unknown> } | 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<string, unknown>)
|
|
||||||
: 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<string, unknown> | 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<string, unknown> } | 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: {
|
export function buildInventoryHistoryCapabilityFollowupReply(input: {
|
||||||
organization: string | null;
|
organization: string | null;
|
||||||
addressDebug: Record<string, unknown> | null;
|
addressDebug: Record<string, unknown> | null;
|
||||||
|
|
@ -165,10 +97,7 @@ export function buildInventoryHistoryCapabilityFollowupReply(input: {
|
||||||
typeof input.addressDebug.address_root_frame_context === "object"
|
typeof input.addressDebug.address_root_frame_context === "object"
|
||||||
? (input.addressDebug.address_root_frame_context as Record<string, unknown>)
|
? (input.addressDebug.address_root_frame_context as Record<string, unknown>)
|
||||||
: null;
|
: null;
|
||||||
const extractedFilters =
|
const extractedFilters = readAddressDebugFilters(input.addressDebug);
|
||||||
input.addressDebug?.extracted_filters && typeof input.addressDebug.extracted_filters === "object"
|
|
||||||
? (input.addressDebug.extracted_filters as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
const organization =
|
const organization =
|
||||||
input.organization ??
|
input.organization ??
|
||||||
input.toNonEmptyString(rootFrameContext?.organization) ??
|
input.toNonEmptyString(rootFrameContext?.organization) ??
|
||||||
|
|
@ -192,9 +121,111 @@ export function buildInventoryHistoryCapabilityFollowupReply(input: {
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRecapIdentity(value: unknown): string {
|
||||||
|
return String(value ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[«»"'`]/g, "")
|
||||||
|
.replace(/\s+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRecapFactLine(input: {
|
||||||
|
debug: Record<string, unknown> | 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<string>();
|
||||||
|
|
||||||
|
for (let index = sessionItems.length - 1; index >= 0; index -= 1) {
|
||||||
|
const item = sessionItems[index] as { role?: string; debug?: Record<string, unknown> } | 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: {
|
export function buildAddressMemoryRecapReply(input: {
|
||||||
organization: string | null;
|
organization: string | null;
|
||||||
addressDebug: Record<string, unknown> | null;
|
addressDebug: Record<string, unknown> | null;
|
||||||
|
sessionItems?: unknown[];
|
||||||
toNonEmptyString: (value: unknown) => string | null;
|
toNonEmptyString: (value: unknown) => string | null;
|
||||||
}): string {
|
}): string {
|
||||||
const extractedFilters =
|
const extractedFilters =
|
||||||
|
|
@ -206,22 +237,29 @@ export function buildAddressMemoryRecapReply(input: {
|
||||||
typeof input.addressDebug.address_root_frame_context === "object"
|
typeof input.addressDebug.address_root_frame_context === "object"
|
||||||
? (input.addressDebug.address_root_frame_context as Record<string, unknown>)
|
? (input.addressDebug.address_root_frame_context as Record<string, unknown>)
|
||||||
: null;
|
: null;
|
||||||
const item =
|
const item = readAddressDebugItem(input.addressDebug, input.toNonEmptyString);
|
||||||
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 organization =
|
const organization =
|
||||||
input.organization ??
|
input.organization ??
|
||||||
input.toNonEmptyString(extractedFilters?.organization) ??
|
input.toNonEmptyString(extractedFilters?.organization) ??
|
||||||
input.toNonEmptyString(rootFrameContext?.organization);
|
input.toNonEmptyString(rootFrameContext?.organization);
|
||||||
const scopedDate =
|
const scopedDate = readAddressDebugScopedDate(input.addressDebug);
|
||||||
formatIsoDateForReply(extractedFilters?.as_of_date) ??
|
const recapFacts = collectRecentRecapFacts({
|
||||||
formatIsoDateForReply(rootFrameContext?.as_of_date) ??
|
sessionItems: input.sessionItems,
|
||||||
formatIsoDateForReply(extractedFilters?.period_to);
|
item,
|
||||||
|
organization,
|
||||||
|
toNonEmptyString: input.toNonEmptyString
|
||||||
|
});
|
||||||
|
|
||||||
if (item) {
|
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 datePart = scopedDate ? ` в срезе на ${scopedDate}` : "";
|
||||||
const organizationPart = organization ? ` по компании «${organization}»` : "";
|
const organizationPart = organization ? ` по компании «${organization}»` : "";
|
||||||
return [
|
return [
|
||||||
|
|
@ -249,15 +287,18 @@ export function resolveAssistantLivingChatMemoryContext(
|
||||||
String(input.modeDecisionReason ?? "") === "inventory_history_capability_followup_detected";
|
String(input.modeDecisionReason ?? "") === "inventory_history_capability_followup_detected";
|
||||||
const contextualMemoryRecapFollowup =
|
const contextualMemoryRecapFollowup =
|
||||||
String(input.modeDecisionReason ?? "") === "memory_recap_followup_detected";
|
String(input.modeDecisionReason ?? "") === "memory_recap_followup_detected";
|
||||||
const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : [];
|
const continuity = resolveAssistantContinuitySnapshot({
|
||||||
|
sessionItems: input.sessionItems,
|
||||||
|
toNonEmptyString
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
contextualInventoryHistoryCapabilityFollowup,
|
contextualInventoryHistoryCapabilityFollowup,
|
||||||
contextualMemoryRecapFollowup,
|
contextualMemoryRecapFollowup,
|
||||||
lastGroundedInventoryAddressDebug: contextualInventoryHistoryCapabilityFollowup
|
lastGroundedInventoryAddressDebug: contextualInventoryHistoryCapabilityFollowup
|
||||||
? findLastGroundedInventoryAddressDebug(sessionItems)
|
? continuity.lastGroundedInventoryAddressDebug
|
||||||
: null,
|
: null,
|
||||||
lastMemoryAddressDebug: contextualMemoryRecapFollowup
|
lastMemoryAddressDebug: contextualMemoryRecapFollowup
|
||||||
? findLastAddressDebugWithItem(sessionItems) ?? findLastAddressDebug(sessionItems)
|
? continuity.lastGroundedItemAddressDebug ?? continuity.lastGroundedAddressDebug
|
||||||
: null
|
: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -269,6 +310,11 @@ export function createAssistantMemoryRecapPolicy(
|
||||||
input: ResolveAssistantRouteMemorySignalsInput
|
input: ResolveAssistantRouteMemorySignalsInput
|
||||||
): AssistantRouteMemorySignals {
|
): AssistantRouteMemorySignals {
|
||||||
const samples = collectMessageSamples(input);
|
const samples = collectMessageSamples(input);
|
||||||
|
const continuity = resolveAssistantContinuitySnapshot({
|
||||||
|
sessionItems: input.sessionItems,
|
||||||
|
toNonEmptyString
|
||||||
|
});
|
||||||
|
const groundedInventoryContext = continuity.lastGroundedInventoryAddressDebug ?? input.lastGroundedAddressDebug;
|
||||||
const historicalCapabilitySignal = hasSignalAcrossSamples(
|
const historicalCapabilitySignal = hasSignalAcrossSamples(
|
||||||
samples,
|
samples,
|
||||||
deps.hasHistoricalCapabilityFollowupSignal
|
deps.hasHistoricalCapabilityFollowupSignal
|
||||||
|
|
@ -284,7 +330,7 @@ export function createAssistantMemoryRecapPolicy(
|
||||||
!input.dataScopeMetaQuery &&
|
!input.dataScopeMetaQuery &&
|
||||||
!input.dataRetrievalSignal &&
|
!input.dataRetrievalSignal &&
|
||||||
historicalCapabilitySignal &&
|
historicalCapabilitySignal &&
|
||||||
deps.isGroundedInventoryContextDebug(input.lastGroundedAddressDebug)
|
deps.isGroundedInventoryContextDebug(groundedInventoryContext)
|
||||||
),
|
),
|
||||||
contextualMemoryRecapFollowupDetected: Boolean(
|
contextualMemoryRecapFollowupDetected: Boolean(
|
||||||
!input.dataScopeMetaQuery &&
|
!input.dataScopeMetaQuery &&
|
||||||
|
|
@ -292,7 +338,7 @@ export function createAssistantMemoryRecapPolicy(
|
||||||
!input.aggregateBusinessAnalyticsSignal &&
|
!input.aggregateBusinessAnalyticsSignal &&
|
||||||
memoryRecapSignal &&
|
memoryRecapSignal &&
|
||||||
(explicitRecapPromptSignal || (!input.dataRetrievalSignal && !input.strongDataSignal)) &&
|
(explicitRecapPromptSignal || (!input.dataRetrievalSignal && !input.strongDataSignal)) &&
|
||||||
input.hasPriorAddressDebug
|
continuity.hasGroundedAddressContext
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,10 +138,16 @@ export function createAssistantRoutePolicy(deps) {
|
||||||
hasDataRetrievalRequestSignal(repairedRawUserMessage) ||
|
hasDataRetrievalRequestSignal(repairedRawUserMessage) ||
|
||||||
hasDataRetrievalRequestSignal(effectiveAddressUserMessage) ||
|
hasDataRetrievalRequestSignal(effectiveAddressUserMessage) ||
|
||||||
hasDataRetrievalRequestSignal(repairedEffectiveAddressUserMessage);
|
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) ||
|
const aggregateBusinessAnalyticsSignal = hasAggregateBusinessAnalyticsSignal(rawUserMessage) ||
|
||||||
hasAggregateBusinessAnalyticsSignal(repairedRawUserMessage) ||
|
hasAggregateBusinessAnalyticsSignal(repairedRawUserMessage) ||
|
||||||
hasAggregateBusinessAnalyticsSignal(effectiveAddressUserMessage) ||
|
hasAggregateBusinessAnalyticsSignal(effectiveAddressUserMessage) ||
|
||||||
hasAggregateBusinessAnalyticsSignal(repairedEffectiveAddressUserMessage);
|
hasAggregateBusinessAnalyticsSignal(repairedEffectiveAddressUserMessage) ||
|
||||||
|
aggregateBusinessAnalyticsBridgeDetected;
|
||||||
const standaloneAddressTopicSignal = hasStandaloneAddressTopicSignal(rawUserMessage) ||
|
const standaloneAddressTopicSignal = hasStandaloneAddressTopicSignal(rawUserMessage) ||
|
||||||
hasStandaloneAddressTopicSignal(repairedRawUserMessage) ||
|
hasStandaloneAddressTopicSignal(repairedRawUserMessage) ||
|
||||||
hasStandaloneAddressTopicSignal(effectiveAddressUserMessage) ||
|
hasStandaloneAddressTopicSignal(effectiveAddressUserMessage) ||
|
||||||
|
|
@ -231,33 +237,39 @@ export function createAssistantRoutePolicy(deps) {
|
||||||
!capabilityMetaQuery &&
|
!capabilityMetaQuery &&
|
||||||
!dataRetrievalSignal);
|
!dataRetrievalSignal);
|
||||||
const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal;
|
const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal;
|
||||||
|
const baseToolGate = resolveAddressToolGateDecision(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage);
|
||||||
const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery &&
|
const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery &&
|
||||||
!capabilityMetaQuery &&
|
!capabilityMetaQuery &&
|
||||||
!dataRetrievalSignal &&
|
!dataRetrievalSignal &&
|
||||||
|
!aggregateBusinessAnalyticsSignal &&
|
||||||
!effectiveAddressFollowupSignal &&
|
!effectiveAddressFollowupSignal &&
|
||||||
resolvedModeDetection.mode === "unsupported" &&
|
resolvedModeDetection.mode === "unsupported" &&
|
||||||
resolvedIntentResolution.intent === "unknown");
|
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 &&
|
const nonDomainQueryIndexed = Boolean(!llmFirstAddressCandidate &&
|
||||||
deterministicNonDomainGuard &&
|
deterministicNonDomainGuard &&
|
||||||
(llmFirstUnsupportedCandidate || llmContractMode === null) &&
|
(llmFirstUnsupportedCandidate || llmContractMode === null) &&
|
||||||
|
!baseToolGatePreservesAddressLane &&
|
||||||
!protectedInventoryShortFollowup &&
|
!protectedInventoryShortFollowup &&
|
||||||
!organizationClarificationContinuationDetected);
|
!organizationClarificationContinuationDetected);
|
||||||
const lastAddressAssistantDebug = sessionItems
|
const lastAddressAssistantDebug = sessionItems
|
||||||
? findLastAddressAssistantItem(sessionItems)?.debug ?? null
|
? findLastAddressAssistantItem(sessionItems)?.debug ?? null
|
||||||
: null;
|
: null;
|
||||||
const memorySignals = resolveRouteMemorySignals({
|
const memorySignals = resolveRouteMemorySignals({
|
||||||
rawUserMessage,
|
rawUserMessage,
|
||||||
repairedRawUserMessage,
|
repairedRawUserMessage,
|
||||||
effectiveAddressUserMessage,
|
effectiveAddressUserMessage,
|
||||||
repairedEffectiveAddressUserMessage,
|
repairedEffectiveAddressUserMessage,
|
||||||
dataScopeMetaQuery,
|
dataScopeMetaQuery,
|
||||||
capabilityMetaQuery,
|
capabilityMetaQuery,
|
||||||
dataRetrievalSignal,
|
dataRetrievalSignal,
|
||||||
strongDataSignal,
|
strongDataSignal,
|
||||||
aggregateBusinessAnalyticsSignal,
|
aggregateBusinessAnalyticsSignal,
|
||||||
lastGroundedAddressDebug,
|
lastGroundedAddressDebug,
|
||||||
hasPriorAddressDebug: Boolean(lastGroundedAddressDebug || lastAddressAssistantDebug)
|
hasPriorAddressDebug: Boolean(lastGroundedAddressDebug || lastAddressAssistantDebug),
|
||||||
});
|
sessionItems
|
||||||
|
});
|
||||||
const contextualHistoricalCapabilityFollowupDetected = memorySignals.contextualHistoricalCapabilityFollowupDetected;
|
const contextualHistoricalCapabilityFollowupDetected = memorySignals.contextualHistoricalCapabilityFollowupDetected;
|
||||||
const contextualMemoryRecapFollowupDetected = memorySignals.contextualMemoryRecapFollowupDetected;
|
const contextualMemoryRecapFollowupDetected = memorySignals.contextualMemoryRecapFollowupDetected;
|
||||||
const organizationFactLookupDetected = hasOrganizationFactLookupSignal(rawUserMessage) ||
|
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) {
|
if (nonDomainQueryIndexed) {
|
||||||
return {
|
return {
|
||||||
runAddressLane: false,
|
runAddressLane: false,
|
||||||
|
|
@ -458,7 +607,6 @@ export function createAssistantRoutePolicy(deps) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const metaAnswerFollowupSignal = metaSignals.metaAnswerFollowupSignal;
|
const metaAnswerFollowupSignal = metaSignals.metaAnswerFollowupSignal;
|
||||||
const baseToolGate = resolveAddressToolGateDecision(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage);
|
|
||||||
const preserveAddressLaneSignal = Boolean((llmPreDecomposeMeta?.llmCanonicalCandidateDetected &&
|
const preserveAddressLaneSignal = Boolean((llmPreDecomposeMeta?.llmCanonicalCandidateDetected &&
|
||||||
llmPreDecomposeMeta?.applied &&
|
llmPreDecomposeMeta?.applied &&
|
||||||
llmContractMode === "address_query") ||
|
llmContractMode === "address_query") ||
|
||||||
|
|
@ -483,6 +631,12 @@ export function createAssistantRoutePolicy(deps) {
|
||||||
Boolean((resolvedIntentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(resolvedIntentResolution.intent)) ||
|
Boolean((resolvedIntentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(resolvedIntentResolution.intent)) ||
|
||||||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
|
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
|
||||||
openContractsAddressSignal);
|
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 &&
|
const semanticGuardHints = semanticExtractionContract?.guard_hints &&
|
||||||
typeof semanticExtractionContract.guard_hints === "object"
|
typeof semanticExtractionContract.guard_hints === "object"
|
||||||
? semanticExtractionContract.guard_hints
|
? semanticExtractionContract.guard_hints
|
||||||
|
|
@ -517,8 +671,7 @@ export function createAssistantRoutePolicy(deps) {
|
||||||
semanticDeepInvestigationHintDetected ||
|
semanticDeepInvestigationHintDetected ||
|
||||||
aggregateBusinessAnalyticsSignal) &&
|
aggregateBusinessAnalyticsSignal) &&
|
||||||
!preserveAddressLaneSignal &&
|
!preserveAddressLaneSignal &&
|
||||||
!keepAddressLaneByIntent &&
|
!supportedAddressRouteCandidateDetected &&
|
||||||
!supportedAddressIntentDetected &&
|
|
||||||
(!followupContext || followupSemanticOverrideToDeepAllowed));
|
(!followupContext || followupSemanticOverrideToDeepAllowed));
|
||||||
const deepAnalysisPreferenceDetected = Boolean(hasDeepAnalysisPreferenceSignal(rawUserMessage) ||
|
const deepAnalysisPreferenceDetected = Boolean(hasDeepAnalysisPreferenceSignal(rawUserMessage) ||
|
||||||
hasDeepAnalysisPreferenceSignal(repairedRawUserMessage) ||
|
hasDeepAnalysisPreferenceSignal(repairedRawUserMessage) ||
|
||||||
|
|
@ -528,6 +681,19 @@ export function createAssistantRoutePolicy(deps) {
|
||||||
hasDirectDeepAnalysisSignal(repairedRawUserMessage) ||
|
hasDirectDeepAnalysisSignal(repairedRawUserMessage) ||
|
||||||
hasDirectDeepAnalysisSignal(effectiveAddressUserMessage) ||
|
hasDirectDeepAnalysisSignal(effectiveAddressUserMessage) ||
|
||||||
hasDirectDeepAnalysisSignal(repairedEffectiveAddressUserMessage));
|
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 &&
|
const vatExplainFollowupSignal = Boolean(followupContext &&
|
||||||
toNonEmptyString(followupContext.previous_intent) === "vat_payable_forecast" &&
|
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}`)));
|
/(?:\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 &&
|
const deepAnalysisSignalFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
|
||||||
!llmRuntimeUnavailableDetected &&
|
!llmRuntimeUnavailableDetected &&
|
||||||
(deepAnalysisPreferenceDetected || semanticDeepInvestigationHintDetected) &&
|
(deepAnalysisPreferenceDetected || semanticDeepInvestigationHintDetected) &&
|
||||||
!keepAddressLaneByIntent &&
|
!protectAddressLaneFromFallback &&
|
||||||
!supportedAddressIntentDetected &&
|
|
||||||
!vatExplainFollowupSignal &&
|
!vatExplainFollowupSignal &&
|
||||||
(!followupContext || !dataRetrievalSignal || followupSemanticOverrideToDeepAllowed));
|
(!followupContext || !dataRetrievalSignal || followupSemanticOverrideToDeepAllowed));
|
||||||
const aggregateAnalyticsFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
|
const aggregateAnalyticsFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
|
||||||
!llmRuntimeUnavailableDetected &&
|
!llmRuntimeUnavailableDetected &&
|
||||||
aggregateBusinessAnalyticsSignal &&
|
aggregateBusinessAnalyticsSignal &&
|
||||||
!keepAddressLaneByIntent &&
|
!protectAddressLaneFromFallback &&
|
||||||
!supportedAddressIntentDetected &&
|
|
||||||
(!followupContext ||
|
(!followupContext ||
|
||||||
llmContractMode === "unsupported" ||
|
llmContractMode === "unsupported" ||
|
||||||
|
llmContractMode === null ||
|
||||||
semanticAggregateShapeDetected ||
|
semanticAggregateShapeDetected ||
|
||||||
!semanticApplyCanonicalRecommended ||
|
!semanticApplyCanonicalRecommended ||
|
||||||
standaloneAddressTopicSignal));
|
standaloneAddressTopicSignal));
|
||||||
|
|
@ -667,10 +832,11 @@ export function createAssistantRoutePolicy(deps) {
|
||||||
semantic_apply_canonical_recommended: semanticApplyCanonicalRecommended,
|
semantic_apply_canonical_recommended: semanticApplyCanonicalRecommended,
|
||||||
semantic_reason_codes: semanticReasonCodes,
|
semantic_reason_codes: semanticReasonCodes,
|
||||||
semantic_route_arbitration: {
|
semantic_route_arbitration: {
|
||||||
supported_address_intent_detected: supportedAddressIntentDetected,
|
supported_address_intent_detected: supportedAddressRouteCandidateDetected,
|
||||||
strict_deep_investigation_bypass_allowed: strictDeepInvestigationBypassAllowed,
|
strict_deep_investigation_bypass_allowed: strictDeepInvestigationBypassAllowed,
|
||||||
semantic_deep_investigation_hint_detected: semanticDeepInvestigationHintDetected,
|
semantic_deep_investigation_hint_detected: semanticDeepInvestigationHintDetected,
|
||||||
semantic_aggregate_shape_detected: semanticAggregateShapeDetected,
|
semantic_aggregate_shape_detected: semanticAggregateShapeDetected,
|
||||||
|
exact_address_intent_protected_from_semantic_deep_hint: exactAddressIntentProtectedFromSemanticDeepHint,
|
||||||
followup_semantic_override_to_deep_allowed: followupSemanticOverrideToDeepAllowed
|
followup_semantic_override_to_deep_allowed: followupSemanticOverrideToDeepAllowed
|
||||||
},
|
},
|
||||||
followup_context_detected: Boolean(followupContext),
|
followup_context_detected: Boolean(followupContext),
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,7 @@ function intersectsSpan(start: number, end: number, spans: Array<{ start: number
|
||||||
function hasAccountContextAround(text: string, start: number, end: number): boolean {
|
function hasAccountContextAround(text: string, start: number, end: number): boolean {
|
||||||
const left = text.slice(Math.max(0, start - 28), start);
|
const left = text.slice(Math.max(0, start - 28), start);
|
||||||
const right = text.slice(end, Math.min(text.length, end + 28));
|
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}`
|
`${left} ${right}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -217,11 +217,11 @@ function extractAccountsFromTextDetailed(text: string, options?: { forceAccountC
|
||||||
const contractSpans = collectContractLikeSpans(lower);
|
const contractSpans = collectContractLikeSpans(lower);
|
||||||
const blockedSpans = [...dateSpans, ...amountSpans, ...percentSpans, ...contractSpans];
|
const blockedSpans = [...dateSpans, ...amountSpans, ...percentSpans, ...contractSpans];
|
||||||
const hasAccountingLexeme =
|
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
|
lower
|
||||||
);
|
);
|
||||||
const contextualPattern =
|
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;
|
let contextualMatch: RegExpExecArray | null = null;
|
||||||
while ((contextualMatch = contextualPattern.exec(lower)) !== null) {
|
while ((contextualMatch = contextualPattern.exec(lower)) !== null) {
|
||||||
const token = String(contextualMatch[1] ?? "").trim();
|
const token = String(contextualMatch[1] ?? "").trim();
|
||||||
|
|
@ -961,7 +961,19 @@ export function resolveDomainPolarityGuard(input: {
|
||||||
const repairedMessage = repairRuntimeGuardsMojibake(String(input.userMessage ?? ""));
|
const repairedMessage = repairRuntimeGuardsMojibake(String(input.userMessage ?? ""));
|
||||||
const lower = repairedMessage.toLowerCase();
|
const lower = repairedMessage.toLowerCase();
|
||||||
const accountExtraction = extractAccountsFromTextDetailed(lower);
|
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 prefixes = new Set(accounts.map((item) => accountPrefix(item)).filter((item): item is string => Boolean(item)));
|
||||||
const settlementSignal =
|
const settlementSignal =
|
||||||
input.focusDomainHint === "settlements_60_62" ||
|
input.focusDomainHint === "settlements_60_62" ||
|
||||||
|
|
|
||||||
|
|
@ -3657,6 +3657,9 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||||
? llmPreDecomposeMeta.semanticExtractionContract
|
? llmPreDecomposeMeta.semanticExtractionContract
|
||||||
: null;
|
: null;
|
||||||
const semanticCanonicalRecommended = semanticExtractionContract?.apply_canonical_recommended !== false;
|
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 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 llmCanonicalAppliedSignal = Boolean(llmPreDecomposeMeta?.applied) && llmContractMode !== "deep_analysis";
|
||||||
const hasLlmCanonicalSignal = semanticCanonicalRecommended &&
|
const hasLlmCanonicalSignal = semanticCanonicalRecommended &&
|
||||||
|
|
@ -3669,6 +3672,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||||
Boolean(llmPreDecomposeMeta?.applied) &&
|
Boolean(llmPreDecomposeMeta?.applied) &&
|
||||||
(llmContractMode === "address_query" || llmContractMode === "unsupported" || llmContractMode === null) &&
|
(llmContractMode === "address_query" || llmContractMode === "unsupported" || llmContractMode === null) &&
|
||||||
hasStrongDataIntentSignal(repairedInputMessage);
|
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) ||
|
const sameDateAccountFollowupSignal = hasSameDateAccountFollowupSignalForPredecompose(rawMessageForGate) ||
|
||||||
hasSameDateAccountFollowupSignalForPredecompose(repairedInputMessage);
|
hasSameDateAccountFollowupSignalForPredecompose(repairedInputMessage);
|
||||||
const hasLexicalAddressSignal = isAddressLlmPreDecomposeCandidate(addressInputMessage) ||
|
const hasLexicalAddressSignal = isAddressLlmPreDecomposeCandidate(addressInputMessage) ||
|
||||||
|
|
@ -3677,12 +3681,13 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||||
hasAccountingSignal(repairedInputMessage) ||
|
hasAccountingSignal(repairedInputMessage) ||
|
||||||
hasShortDebtMirrorFollowupSignal(rawMessageForGate) ||
|
hasShortDebtMirrorFollowupSignal(rawMessageForGate) ||
|
||||||
hasShortDebtMirrorFollowupSignal(repairedInputMessage) ||
|
hasShortDebtMirrorFollowupSignal(repairedInputMessage) ||
|
||||||
|
hasBusinessRankingAddressSignal ||
|
||||||
sameDateAccountFollowupSignal;
|
sameDateAccountFollowupSignal;
|
||||||
const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" &&
|
const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" &&
|
||||||
(llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") &&
|
(llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") &&
|
||||||
llmContractIntent === "unknown";
|
llmContractIntent === "unknown";
|
||||||
const hasAnyAddressSignal =
|
const hasAnyAddressSignal =
|
||||||
hasClassifierSignal || hasIntentSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal;
|
hasClassifierSignal || hasIntentSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal || llmSupportedDeepAddressIntentSignal;
|
||||||
const strongDataSignalFromRawMessage = hasStrongDataIntentSignal(rawMessageForGate) ||
|
const strongDataSignalFromRawMessage = hasStrongDataIntentSignal(rawMessageForGate) ||
|
||||||
hasDataRetrievalRequestSignal(rawMessageForGate) ||
|
hasDataRetrievalRequestSignal(rawMessageForGate) ||
|
||||||
hasAccountingSignal(rawMessageForGate) ||
|
hasAccountingSignal(rawMessageForGate) ||
|
||||||
|
|
@ -3696,6 +3701,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||||
!hasClassifierSignal &&
|
!hasClassifierSignal &&
|
||||||
!hasIntentSignal &&
|
!hasIntentSignal &&
|
||||||
!hasLexicalAddressSignal &&
|
!hasLexicalAddressSignal &&
|
||||||
|
!llmSupportedDeepAddressIntentSignal &&
|
||||||
!strongDataSignalFromRawMessage &&
|
!strongDataSignalFromRawMessage &&
|
||||||
!strongDataSignalFromEffectiveMessage) {
|
!strongDataSignalFromEffectiveMessage) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -3706,6 +3712,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||||
}
|
}
|
||||||
if (hasUnsupportedLowConfidencePredecomposeSignal && !followupContext &&
|
if (hasUnsupportedLowConfidencePredecomposeSignal && !followupContext &&
|
||||||
!hasAnyAddressSignal &&
|
!hasAnyAddressSignal &&
|
||||||
|
!llmSupportedDeepAddressIntentSignal &&
|
||||||
!strongDataSignalFromRawMessage &&
|
!strongDataSignalFromRawMessage &&
|
||||||
!strongDataSignalFromEffectiveMessage) {
|
!strongDataSignalFromEffectiveMessage) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -3725,8 +3732,10 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||||
? "address_intent_resolver_detected"
|
? "address_intent_resolver_detected"
|
||||||
: hasLlmCanonicalSignal
|
: hasLlmCanonicalSignal
|
||||||
? "llm_canonical_candidate_detected"
|
? "llm_canonical_candidate_detected"
|
||||||
: hasLlmCanonicalDataSignal
|
: hasLlmCanonicalDataSignal
|
||||||
? "llm_canonical_data_signal_detected"
|
? "llm_canonical_data_signal_detected"
|
||||||
|
: llmSupportedDeepAddressIntentSignal
|
||||||
|
? "address_signal_detected"
|
||||||
: "address_signal_detected"
|
: "address_signal_detected"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -3843,6 +3852,11 @@ function hasAggregateBusinessAnalyticsSignal(text) {
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return false;
|
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);
|
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) {
|
if (!hasMetricCue) {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,48 @@
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
|
||||||
export function createAssistantTransitionPolicy(deps) {
|
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) {
|
function shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent) {
|
||||||
const normalized = deps.compactWhitespace(
|
const normalized = deps.compactWhitespace(
|
||||||
deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase()
|
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);
|
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(
|
function resolveAddressFollowupCarryoverContext(
|
||||||
userMessage,
|
userMessage,
|
||||||
items,
|
items,
|
||||||
|
|
@ -108,6 +215,18 @@ export function createAssistantTransitionPolicy(deps) {
|
||||||
Boolean(recentInventoryRootFrame)
|
Boolean(recentInventoryRootFrame)
|
||||||
)
|
)
|
||||||
: false;
|
: false;
|
||||||
|
const hasInventoryRootRestatementPrimary = hasInventoryRootRestatementLikeSignal(
|
||||||
|
userMessage,
|
||||||
|
sourceIntentHint,
|
||||||
|
Boolean(recentInventoryRootFrame)
|
||||||
|
);
|
||||||
|
const hasInventoryRootRestatementAlternate = deps.toNonEmptyString(alternateMessage)
|
||||||
|
? hasInventoryRootRestatementLikeSignal(
|
||||||
|
String(alternateMessage ?? ""),
|
||||||
|
sourceIntentHint,
|
||||||
|
Boolean(recentInventoryRootFrame)
|
||||||
|
)
|
||||||
|
: false;
|
||||||
let hasStrongFollowupReference =
|
let hasStrongFollowupReference =
|
||||||
hasPrimaryIndexReferenceSignal ||
|
hasPrimaryIndexReferenceSignal ||
|
||||||
hasAlternateIndexReferenceSignal ||
|
hasAlternateIndexReferenceSignal ||
|
||||||
|
|
@ -117,6 +236,8 @@ export function createAssistantTransitionPolicy(deps) {
|
||||||
inventoryShortFollowupAlternate ||
|
inventoryShortFollowupAlternate ||
|
||||||
hasInventoryRootTemporalFollowupPrimary ||
|
hasInventoryRootTemporalFollowupPrimary ||
|
||||||
hasInventoryRootTemporalFollowupAlternate ||
|
hasInventoryRootTemporalFollowupAlternate ||
|
||||||
|
hasInventoryRootRestatementPrimary ||
|
||||||
|
hasInventoryRootRestatementAlternate ||
|
||||||
Boolean(debtRoleSwapIntent) ||
|
Boolean(debtRoleSwapIntent) ||
|
||||||
deps.hasFollowupMarker(userMessage) ||
|
deps.hasFollowupMarker(userMessage) ||
|
||||||
deps.hasReferentialPointer(userMessage) ||
|
deps.hasReferentialPointer(userMessage) ||
|
||||||
|
|
@ -133,6 +254,8 @@ export function createAssistantTransitionPolicy(deps) {
|
||||||
!hasAlternateFollowupSignal &&
|
!hasAlternateFollowupSignal &&
|
||||||
!hasInventoryRootTemporalFollowupPrimary &&
|
!hasInventoryRootTemporalFollowupPrimary &&
|
||||||
!hasInventoryRootTemporalFollowupAlternate &&
|
!hasInventoryRootTemporalFollowupAlternate &&
|
||||||
|
!hasInventoryRootRestatementPrimary &&
|
||||||
|
!hasInventoryRootRestatementAlternate &&
|
||||||
!hasImplicitContinuationSignal &&
|
!hasImplicitContinuationSignal &&
|
||||||
!hasOrganizationClarificationContinuation &&
|
!hasOrganizationClarificationContinuation &&
|
||||||
!hasIndexReferenceSignal
|
!hasIndexReferenceSignal
|
||||||
|
|
@ -144,6 +267,8 @@ export function createAssistantTransitionPolicy(deps) {
|
||||||
!hasAlternateFollowupSignal &&
|
!hasAlternateFollowupSignal &&
|
||||||
!hasInventoryRootTemporalFollowupPrimary &&
|
!hasInventoryRootTemporalFollowupPrimary &&
|
||||||
!hasInventoryRootTemporalFollowupAlternate &&
|
!hasInventoryRootTemporalFollowupAlternate &&
|
||||||
|
!hasInventoryRootRestatementPrimary &&
|
||||||
|
!hasInventoryRootRestatementAlternate &&
|
||||||
!hasImplicitContinuationSignal &&
|
!hasImplicitContinuationSignal &&
|
||||||
!hasOrganizationClarificationContinuation &&
|
!hasOrganizationClarificationContinuation &&
|
||||||
!hasIndexReferenceSignal
|
!hasIndexReferenceSignal
|
||||||
|
|
@ -168,8 +293,46 @@ export function createAssistantTransitionPolicy(deps) {
|
||||||
? resolvedAlternateIntent
|
? resolvedAlternateIntent
|
||||||
: null;
|
: null;
|
||||||
const sourceIntentFamily = deps.resolveAddressIntentFamily(sourceIntent);
|
const sourceIntentFamily = deps.resolveAddressIntentFamily(sourceIntent);
|
||||||
const explicitIntentFamily = deps.resolveAddressIntentFamily(explicitIntent);
|
const explicitIntentFamily =
|
||||||
if (sourceIntentFamily && explicitIntentFamily && sourceIntentFamily !== explicitIntentFamily && !hasStrongFollowupReference) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
let previousIntent = sourceIntent;
|
let previousIntent = sourceIntent;
|
||||||
|
|
@ -181,7 +344,7 @@ export function createAssistantTransitionPolicy(deps) {
|
||||||
const suggestedIntent = Array.isArray(followupOffer?.suggested_intents)
|
const suggestedIntent = Array.isArray(followupOffer?.suggested_intents)
|
||||||
? deps.toNonEmptyString(followupOffer.suggested_intents[0])
|
? deps.toNonEmptyString(followupOffer.suggested_intents[0])
|
||||||
: null;
|
: null;
|
||||||
const keepPreviousIntent = shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent);
|
const keepPreviousIntent = shouldKeepPreviousIntentForShortCounterpartyRetargetV2(userMessage, sourceIntent);
|
||||||
if (suggestedIntent && !keepPreviousIntent) {
|
if (suggestedIntent && !keepPreviousIntent) {
|
||||||
previousIntent = suggestedIntent;
|
previousIntent = suggestedIntent;
|
||||||
followupSelectionMode = "switch_to_suggested_intent";
|
followupSelectionMode = "switch_to_suggested_intent";
|
||||||
|
|
@ -297,6 +460,7 @@ export function createAssistantTransitionPolicy(deps) {
|
||||||
: "generic"
|
: "generic"
|
||||||
: null;
|
: null;
|
||||||
let resolvedCounterpartyFromDisplay = false;
|
let resolvedCounterpartyFromDisplay = false;
|
||||||
|
let displayedEntityTargetIntent = null;
|
||||||
const previousFiltersRaw = previousAddressDebug.extracted_filters;
|
const previousFiltersRaw = previousAddressDebug.extracted_filters;
|
||||||
let previousFilters =
|
let previousFilters =
|
||||||
previousFiltersRaw && typeof previousFiltersRaw === "object" ? { ...previousFiltersRaw } : {};
|
previousFiltersRaw && typeof previousFiltersRaw === "object" ? { ...previousFiltersRaw } : {};
|
||||||
|
|
@ -376,7 +540,17 @@ export function createAssistantTransitionPolicy(deps) {
|
||||||
(hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate) &&
|
(hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate) &&
|
||||||
!deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage)
|
!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) {
|
if (rootScopedPivot) {
|
||||||
previousIntent = null;
|
previousIntent = null;
|
||||||
previousAnchorType = null;
|
previousAnchorType = null;
|
||||||
|
|
@ -396,6 +570,10 @@ export function createAssistantTransitionPolicy(deps) {
|
||||||
? deps.resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities)
|
? deps.resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities)
|
||||||
: null);
|
: null);
|
||||||
if (resolvedEntityFromFollowup && !rootScopedPivot) {
|
if (resolvedEntityFromFollowup && !rootScopedPivot) {
|
||||||
|
displayedEntityTargetIntent = resolveDisplayedEntityRetargetIntent(
|
||||||
|
userMessage,
|
||||||
|
resolvedEntityFromFollowup.entityType
|
||||||
|
);
|
||||||
if (resolvedEntityFromFollowup.entityType === "counterparty") {
|
if (resolvedEntityFromFollowup.entityType === "counterparty") {
|
||||||
previousFilters.counterparty = resolvedEntityFromFollowup.value;
|
previousFilters.counterparty = resolvedEntityFromFollowup.value;
|
||||||
previousAnchorType = "counterparty";
|
previousAnchorType = "counterparty";
|
||||||
|
|
@ -465,13 +643,15 @@ export function createAssistantTransitionPolicy(deps) {
|
||||||
inventoryShortFollowupAlternate ||
|
inventoryShortFollowupAlternate ||
|
||||||
hasInventoryRootTemporalFollowupPrimary ||
|
hasInventoryRootTemporalFollowupPrimary ||
|
||||||
hasInventoryRootTemporalFollowupAlternate ||
|
hasInventoryRootTemporalFollowupAlternate ||
|
||||||
|
hasInventoryRootRestatementPrimary ||
|
||||||
|
hasInventoryRootRestatementAlternate ||
|
||||||
hasSelectedObjectInventorySignalPrimary ||
|
hasSelectedObjectInventorySignalPrimary ||
|
||||||
hasSelectedObjectInventorySignalAlternate)
|
hasSelectedObjectInventorySignalAlternate)
|
||||||
);
|
);
|
||||||
const carryoverTargetIntent =
|
const carryoverTargetIntent =
|
||||||
followupSelectionMode === "carry_root_context"
|
followupSelectionMode === "carry_root_context"
|
||||||
? inventoryRootFrame?.intent ?? explicitIntent ?? previousIntent ?? undefined
|
? inventoryRootFrame?.intent ?? displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined
|
||||||
: explicitIntent ?? previousIntent ?? undefined;
|
: displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined;
|
||||||
return {
|
return {
|
||||||
followupContext: {
|
followupContext: {
|
||||||
previous_intent: previousIntent ?? undefined,
|
previous_intent: previousIntent ?? undefined,
|
||||||
|
|
@ -507,6 +687,7 @@ export function createAssistantTransitionPolicy(deps) {
|
||||||
const previousIntent = deps.toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null;
|
const previousIntent = deps.toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null;
|
||||||
const selectionMode = deps.toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? null;
|
const selectionMode = deps.toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? null;
|
||||||
const rootContextOnly = selectionMode === "carry_root_context";
|
const rootContextOnly = selectionMode === "carry_root_context";
|
||||||
|
const carryoverTargetIntent = deps.toNonEmptyString(carryoverMeta?.followupContext?.target_intent) ?? null;
|
||||||
const explicitIntentRaw = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
|
const explicitIntentRaw = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
|
||||||
const explicitIntent = explicitIntentRaw === "unknown" ? null : explicitIntentRaw;
|
const explicitIntent = explicitIntentRaw === "unknown" ? null : explicitIntentRaw;
|
||||||
const rootIntent = deps.toNonEmptyString(carryoverMeta?.followupContext?.root_intent) ?? null;
|
const rootIntent = deps.toNonEmptyString(carryoverMeta?.followupContext?.root_intent) ?? null;
|
||||||
|
|
@ -514,8 +695,8 @@ export function createAssistantTransitionPolicy(deps) {
|
||||||
selectionMode === "switch_to_suggested_intent"
|
selectionMode === "switch_to_suggested_intent"
|
||||||
? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null
|
? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null
|
||||||
: rootContextOnly
|
: rootContextOnly
|
||||||
? rootIntent ?? explicitIntent ?? null
|
? carryoverTargetIntent ?? rootIntent ?? explicitIntent ?? null
|
||||||
: explicitIntent ?? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null;
|
: carryoverTargetIntent ?? explicitIntent ?? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null;
|
||||||
const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal);
|
const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal);
|
||||||
const rewrittenByPredecompose =
|
const rewrittenByPredecompose =
|
||||||
deps.compactWhitespace(sourceMessage.toLowerCase()) !== deps.compactWhitespace(canonicalMessage.toLowerCase());
|
deps.compactWhitespace(sourceMessage.toLowerCase()) !== deps.compactWhitespace(canonicalMessage.toLowerCase());
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,13 @@ describe("addressCounterpartyIntentSignals", () => {
|
||||||
expect(result?.reasons).toContain("counterparty_item_flow_signal_detected");
|
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", () => {
|
it("keeps the main resolver behavior stable through counterparty-owner delegation", () => {
|
||||||
const result = resolveAddressIntent("хвосты покажи по счету 60 на август 2022");
|
const result = resolveAddressIntent("хвосты покажи по счету 60 на август 2022");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,6 +4,13 @@ import { resolveInventoryAddressIntent } from "../src/services/addressInventoryI
|
||||||
import { resolveAddressIntent } from "../src/services/addressIntentResolver";
|
import { resolveAddressIntent } from "../src/services/addressIntentResolver";
|
||||||
|
|
||||||
describe("addressInventoryIntentSignals", () => {
|
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", () => {
|
it("classifies warehouse snapshot wording through the extracted inventory owner", () => {
|
||||||
const result = resolveInventoryAddressIntent("show inventory on hand as of 2020-03-15");
|
const result = resolveInventoryAddressIntent("show inventory on hand as of 2020-03-15");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,48 @@ vi.mock("../src/services/addressMcpClient", async () => {
|
||||||
...actual,
|
...actual,
|
||||||
executeAddressMcpQuery: executeAddressMcpQueryMock
|
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";
|
import { AddressQueryService } from "../src/services/addressQueryService";
|
||||||
|
|
@ -143,4 +185,52 @@ describe("inventory organization scope grounding", () => {
|
||||||
expect(String(result?.reply_text ?? "")).toContain("ООО Лайсвуд");
|
expect(String(result?.reply_text ?? "")).toContain("ООО Лайсвуд");
|
||||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -549,6 +549,61 @@ describe("inventory selected-object follow-up", () => {
|
||||||
expect(String(result?.reply_text ?? "")).toContain("Поступление товаров и услуг 00000000077");
|
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 () => {
|
it("routes buyer follow-up over the same selected item into sale trace instead of replaying provenance", async () => {
|
||||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||||
fetched_rows: 1,
|
fetched_rows: 1,
|
||||||
|
|
|
||||||
|
|
@ -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<string, unknown>): 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<string, unknown>): 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -32,9 +32,10 @@ describe("assistantBoundaryPolicy", () => {
|
||||||
organizations: ["ООО Альтернатива Плюс"]
|
organizations: ["ООО Альтернатива Плюс"]
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(reply).toContain("MCP-канале `finance`");
|
expect(reply).toContain("Сейчас доступна организация");
|
||||||
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", () => {
|
it("strips unexpected CJK fragments from live chat reply", () => {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ describe("assistant living chat mode", () => {
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
text: "Сейчас в активном MCP-канале `default` доступны организации (3): ООО Альтернатива Плюс, ООО Лайсвуд, РАЙМ.",
|
text: "Сейчас доступны организации (3): ООО Альтернатива Плюс, ООО Лайсвуд, РАЙМ. Скажите, по какой организации смотреть данные.",
|
||||||
debug: {
|
debug: {
|
||||||
trace_id: "chat-org-scope",
|
trace_id: "chat-org-scope",
|
||||||
living_chat_data_scope_probe_status: "resolved",
|
living_chat_data_scope_probe_status: "resolved",
|
||||||
|
|
@ -91,7 +91,7 @@ describe("assistant living chat mode", () => {
|
||||||
message_id: "msg-seed-org-scope",
|
message_id: "msg-seed-org-scope",
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
text: "Сейчас в активном MCP-канале `default` доступны организации (3): ООО Альтернатива Плюс, ООО Лайсвуд, РАЙМ.",
|
text: "Сейчас доступны организации (3): ООО Альтернатива Плюс, ООО Лайсвуд, РАЙМ. Скажите, по какой организации смотреть данные.",
|
||||||
reply_type: "factual_with_explanation",
|
reply_type: "factual_with_explanation",
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
trace_id: "chat-seed-org-scope",
|
trace_id: "chat-seed-org-scope",
|
||||||
|
|
@ -780,7 +780,8 @@ describe("assistant living chat mode", () => {
|
||||||
|
|
||||||
expect(response.ok).toBe(true);
|
expect(response.ok).toBe(true);
|
||||||
expect(response.reply_type).toBe("factual_with_explanation");
|
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?.tool_gate_reason).toBe("assistant_data_scope_query_detected");
|
||||||
expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
|
expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
|
||||||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||||||
|
|
@ -827,7 +828,8 @@ describe("assistant living chat mode", () => {
|
||||||
|
|
||||||
expect(response.ok).toBe(true);
|
expect(response.ok).toBe(true);
|
||||||
expect(response.reply_type).toBe("factual_with_explanation");
|
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?.tool_gate_reason).toBe("assistant_data_scope_query_detected");
|
||||||
expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
|
expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
|
||||||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,12 @@ describe("assistantLivingModePolicy", () => {
|
||||||
).toBe(true);
|
).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", () => {
|
it("routes casual small-talk to chat mode", () => {
|
||||||
const policy = buildPolicy();
|
const policy = buildPolicy();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1169,6 +1169,55 @@ describe("assistant orchestration contract", () => {
|
||||||
expect(decision.livingReason).toBe("address_lane_triggered");
|
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", () => {
|
it("keeps open-contracts request in address lane even with stale deep followup context when LLM contract is absent", () => {
|
||||||
const decision = resolveAssistantOrchestrationDecision({
|
const decision = resolveAssistantOrchestrationDecision({
|
||||||
rawUserMessage: "Покажи незакрытые договоры на 2020-12-31",
|
rawUserMessage: "Покажи незакрытые договоры на 2020-12-31",
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,19 @@ describe("assistantMemoryRecapPolicy", () => {
|
||||||
strongDataSignal: false,
|
strongDataSignal: false,
|
||||||
aggregateBusinessAnalyticsSignal: false,
|
aggregateBusinessAnalyticsSignal: false,
|
||||||
lastGroundedAddressDebug: null,
|
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);
|
expect(signals.contextualHistoricalCapabilityFollowupDetected).toBe(false);
|
||||||
|
|
@ -67,14 +79,60 @@ describe("assistantMemoryRecapPolicy", () => {
|
||||||
strongDataSignal: true,
|
strongDataSignal: true,
|
||||||
aggregateBusinessAnalyticsSignal: false,
|
aggregateBusinessAnalyticsSignal: false,
|
||||||
lastGroundedAddressDebug: null,
|
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.contextualHistoricalCapabilityFollowupDetected).toBe(false);
|
||||||
expect(signals.contextualMemoryRecapFollowupDetected).toBe(true);
|
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({
|
const context = resolveAssistantLivingChatMemoryContext({
|
||||||
modeDecisionReason: "memory_recap_followup_detected",
|
modeDecisionReason: "memory_recap_followup_detected",
|
||||||
sessionItems: [
|
sessionItems: [
|
||||||
|
|
@ -82,6 +140,9 @@ describe("assistantMemoryRecapPolicy", () => {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
debug: {
|
debug: {
|
||||||
execution_lane: "address_query",
|
execution_lane: "address_query",
|
||||||
|
answer_grounding_check: {
|
||||||
|
status: "grounded"
|
||||||
|
},
|
||||||
anchor_type: "item",
|
anchor_type: "item",
|
||||||
anchor_value_resolved: "Рабочая станция",
|
anchor_value_resolved: "Рабочая станция",
|
||||||
extracted_filters: {
|
extracted_filters: {
|
||||||
|
|
@ -89,6 +150,34 @@ describe("assistantMemoryRecapPolicy", () => {
|
||||||
as_of_date: "2022-02-28"
|
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({
|
const reply = buildAddressMemoryRecapReply({
|
||||||
organization: null,
|
organization: null,
|
||||||
addressDebug: context.lastMemoryAddressDebug,
|
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) => {
|
toNonEmptyString: (value: unknown) => {
|
||||||
const text = String(value ?? "").trim();
|
const text = String(value ?? "").trim();
|
||||||
return text.length > 0 ? text : null;
|
return text.length > 0 ? text : null;
|
||||||
|
|
@ -104,6 +237,8 @@ describe("assistantMemoryRecapPolicy", () => {
|
||||||
|
|
||||||
expect(context.contextualMemoryRecapFollowup).toBe(true);
|
expect(context.contextualMemoryRecapFollowup).toBe(true);
|
||||||
expect(reply).toContain("Рабочая станция");
|
expect(reply).toContain("Рабочая станция");
|
||||||
expect(reply).toContain("28.02.2022");
|
expect(reply).toContain("мы уже выяснили");
|
||||||
|
expect(reply).toContain("разобрали, кто поставлял");
|
||||||
|
expect(reply).toContain("подняли документы закупки");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -294,4 +294,27 @@ describe("assistantRoutePolicy", () => {
|
||||||
expect(decision.orchestrationContract?.unsupported_address_intent_fallback_to_deep).toBe(false);
|
expect(decision.orchestrationContract?.unsupported_address_intent_fallback_to_deep).toBe(false);
|
||||||
expect(decision.orchestrationContract?.provider_execution?.llm_runtime_unavailable_detected).toBe(true);
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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", () => {
|
it("builds continuation contract from extracted root carryover", () => {
|
||||||
const policy = buildPolicy();
|
const policy = buildPolicy();
|
||||||
|
|
||||||
|
|
@ -146,4 +169,143 @@ describe("assistantTransitionPolicy", () => {
|
||||||
expect(contract.anchor_type).toBe("item");
|
expect(contract.anchor_type).toBe("item");
|
||||||
expect(contract.anchor_value).toBe("Рабочая станция");
|
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"
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -889,5 +889,32 @@
|
||||||
"question_text": "Покажи контрагентов, чьи заказы на отгрузку еще не оплачены, но сальдо уже отрицательное - это явный признак того, что нужно вмешаться.",
|
"question_text": "Покажи контрагентов, чьи заказы на отгрузку еще не оплачены, но сальдо уже отрицательное - это явный признак того, что нужно вмешаться.",
|
||||||
"answer_text": "Сейчас этот тип вопроса вне поддерживаемого контура адресного режима.\nКоротко: этот сценарий пока не поддержан в текущем адресном контуре.\nЧто можно сделать дальше: могу проверить близкие сценарии: документы/платежи по контрагенту, договоры или остаток по счету."
|
"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Могу уточнить остаток по счёту или проверить наличие договоров/платежей за конкретные годы — сообщите, какой период вас интересует."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -104,61 +104,6 @@
|
||||||
"latest_acceptance": null
|
"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",
|
"generation_id": "gen-ag04170941-87680e",
|
||||||
"created_at": "2026-04-17T09:41:32+00:00",
|
"created_at": "2026-04-17T09:41:32+00:00",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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": "а что мы уже выяснили по этой позиции?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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": "а что мы уже выяснили по этой позиции?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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": "а что мы уже выяснили по этой позиции?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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": "а что мы уже выяснили по этой позиции?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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",
|
"suite_version": "0.1.0",
|
||||||
"schema_version": "assistant_saved_session_suite_v0_1",
|
"schema_version": "assistant_saved_session_runtime_v0_1",
|
||||||
"generated_at": "2026-04-17T12:08:33+00:00",
|
"title": "AGENT replay for company selection continuity and organization activity age",
|
||||||
"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",
|
|
||||||
"scenario_count": 1,
|
"scenario_count": 1,
|
||||||
"case_ids": [
|
"case_ids": [
|
||||||
"SAVED-001"
|
"SAVED-001"
|
||||||
|
|
@ -14,8 +10,8 @@
|
||||||
"cases": [
|
"cases": [
|
||||||
{
|
{
|
||||||
"case_id": "SAVED-001",
|
"case_id": "SAVED-001",
|
||||||
"scenario_tag": "agent_saved_user_sessions",
|
"scenario_tag": "saved_user_sessions_runtime",
|
||||||
"title": "AGENT | AssistantService boundary and transition delegation replay over mixed contextual chains",
|
"title": "AGENT replay for company selection continuity and organization activity age",
|
||||||
"question_type": "followup",
|
"question_type": "followup",
|
||||||
"broadness_level": "medium",
|
"broadness_level": "medium",
|
||||||
"turns": [
|
"turns": [
|
||||||
|
|
@ -28,6 +24,12 @@
|
||||||
{
|
{
|
||||||
"user_message": "какие остатки на складе на март 2021"
|
"user_message": "какие остатки на складе на март 2021"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"user_message": "давай по Альтернативе Плюс"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"user_message": "тогда покажи остатки на март 2021"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"user_message": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?"
|
"user_message": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?"
|
||||||
},
|
},
|
||||||
|
|
@ -38,7 +40,7 @@
|
||||||
"user_message": "покажи еще раз остатки на эту же дату"
|
"user_message": "покажи еще раз остатки на эту же дату"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"user_message": "а какой возраст у Альтернативы Плюс?"
|
"user_message": "а по Альтернативе Плюс сколько лет активности в базе 1С?"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"user_message": "что ты умеешь?"
|
"user_message": "что ты умеешь?"
|
||||||
|
|
@ -55,4 +57,4 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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": "остатки по складу на эту же дату"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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": "остатки по складу на эту же дату"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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": "а что мы уже выяснили по этой позиции?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue