Compare commits
14 Commits
931251d1eb
...
9b02083493
| Author | SHA1 | Date |
|---|---|---|
|
|
9b02083493 | |
|
|
dfbfe26501 | |
|
|
f86cb8e886 | |
|
|
48c3b5340b | |
|
|
913745380f | |
|
|
de1aa3d17c | |
|
|
73b9053bab | |
|
|
f628266e44 | |
|
|
37d33bd6e6 | |
|
|
a3378a3d52 | |
|
|
b4f50346cc | |
|
|
fdf70b3d4f | |
|
|
2244c62554 | |
|
|
4089708dfd |
|
|
@ -39,7 +39,7 @@ Use these repo-native capture paths:
|
|||
- import existing technical export: `python scripts/domain_case_loop.py import-export ...`
|
||||
- `run-case` defaults to the repo's live local profile: `local / qwen2.5-14b-instruct-1m / http://127.0.0.1:1234/v1`
|
||||
- override with `--llm-provider`, `--llm-model`, `--llm-base-url`, `--llm-api-key` when needed
|
||||
- `run-pack-loop` defaults to `gpt-5.4` for analyst and `gpt-5.4-mini` for coder; tune with `--analyst-codex-model`, `--coder-codex-model`, `--analyst-reasoning-effort`, `--coder-reasoning-effort`
|
||||
- `run-pack-loop` defaults to `gpt-5.4` for the independent business analyst and `lead-handoff` repair mode; opt into the old autonomous coder loop only with `--repair-mode auto-coder`
|
||||
|
||||
## Workflow
|
||||
|
||||
|
|
@ -77,13 +77,14 @@ In pack mode:
|
|||
|
||||
### Autonomous pack-loop mode
|
||||
|
||||
Use autonomous pack-loop mode when the user wants the system to continue with analyst/coder iterations until the analyst gate is reached or the loop hits a real blocker.
|
||||
Use pack-loop mode when the user wants the system to run live replay, produce a strong business-first analyst verdict, and continue toward repair evidence until the analyst gate is reached or the loop hits a real blocker.
|
||||
|
||||
In autonomous pack-loop mode:
|
||||
- run `python scripts/domain_case_loop.py run-pack-loop --manifest ...`;
|
||||
- keep each iteration under `artifacts/domain_runs/<loop_id>/iterations/<iteration_id>/`;
|
||||
- read `analyst_verdict.json` before any coder patch;
|
||||
- let coder patch only the highest-value domain targets from the current analyst verdict;
|
||||
- by default, stop after the analyst verdict with `business_audit.md` and `lead_coder_handoff.md` so Lead Codex repairs code in the main context;
|
||||
- let an autonomous coder patch only when `--repair-mode auto-coder` is explicitly selected, and only against the highest-value domain targets from the current analyst verdict;
|
||||
- stop only on `accepted`, `blocked`, explicit `requires_user_decision = true`, or `max_iterations`;
|
||||
- do not stop just because the analyst returns `needs_exact_capability` or `partial` if autonomous domain enablement work still remains.
|
||||
- treat `quality score >= 80` as the target gate, not as permission to keep pushing through hard blockers, missing essential observations, or unsafe fixes.
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@
|
|||
- Use an integer `0%` to `100%` scale and keep the estimate architecture-aware, based on implemented runtime wiring, tests, replay coverage, and remaining integration risk.
|
||||
- Do not inflate progress because unit tests are green; semantic replay and real runtime wiring still count as unfinished work when they are pending.
|
||||
|
||||
## next_stage_plan_rule
|
||||
- After applying fixes or completing a development stage, always provide `Дальше по плану:` in the close-out.
|
||||
- The plan must list the next concrete module/stage action, the expected validation path, and whether the next step needs commit/push/manual replay.
|
||||
- If the next plan is unclear or stale, the first next action must be to resync docs, graphify, and current stage artifacts before continuing implementation.
|
||||
|
||||
## graphify
|
||||
|
||||
This project has a graphify knowledge graph at graphify-out/.
|
||||
|
|
|
|||
|
|
@ -84,6 +84,12 @@
|
|||
"expected_requested_result_modes": ["heuristic_candidates", "confirmed_balance"],
|
||||
"expected_result_modes": ["heuristic_candidates", "confirmed_balance"]
|
||||
},
|
||||
{
|
||||
"intent": "open_items_by_counterparty_or_contract",
|
||||
"expected_selected_recipes": ["address_open_items_by_party_or_contract_v1"],
|
||||
"expected_requested_result_modes": ["heuristic_candidates", "confirmed_balance"],
|
||||
"expected_result_modes": ["heuristic_candidates", "confirmed_balance"]
|
||||
},
|
||||
{
|
||||
"intent": "open_contracts_confirmed_as_of_date",
|
||||
"expected_selected_recipes": ["address_open_contracts_confirmed_as_of_date_v1"],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
{
|
||||
"schema_version": "domain_scenario_pack_v1",
|
||||
"pack_id": "agentic_semantic_development_loop_stage_pack",
|
||||
"domain": "agentic_semantic_development_loop_control",
|
||||
"title": "Agentic semantic development loop control pack",
|
||||
"description": "Stage pack for dogfooding the agentic development loop against business overview, VAT, stale scope, counterparty pivots, legacy route canaries, and answer-shape quality.",
|
||||
"analysis_context": {
|
||||
"as_of_date": "2026-05-09",
|
||||
"source": "agentic_semantic_development_loop_stage_pack"
|
||||
},
|
||||
"bindings": {
|
||||
"main_organization": "ООО Альтернатива Плюс",
|
||||
"control_year": "2020",
|
||||
"svk_counterparty": "Группа СВК"
|
||||
},
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario_id": "biz_scope",
|
||||
"title": "Business overview and stale-scope control",
|
||||
"description": "Checks direct business-answer shape, period carryover, all-time reset, VAT boundary, and organization scope hygiene.",
|
||||
"steps": [
|
||||
{
|
||||
"step_id": "s01_biz",
|
||||
"title": "Business overview for explicit period",
|
||||
"node_role": "root",
|
||||
"question": "Дай взрослый бизнес-обзор {{bindings.main_organization}} за {{bindings.control_year}} год по данным 1С: обороты, входящие и исходящие деньги, нетто, НДС, долги, склад, клиенты, поставщики и что пока нельзя утверждать.",
|
||||
"expected_intents": ["business_overview"],
|
||||
"semantic_tags": ["business_overview", "money", "vat", "debt", "inventory", "scope_guard"],
|
||||
"required_answer_shape": "direct_answer_first",
|
||||
"forbidden_answer_patterns": [
|
||||
"(?i)capability_id",
|
||||
"(?i)selected_chain_id",
|
||||
"(?i)runtime_",
|
||||
"(?i)business_overview_route_template_v1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"step_id": "s02_money",
|
||||
"title": "Money follow-up",
|
||||
"question": "Раскрой деньги подробнее: сколько получили, сколько заплатили, какой чистый денежный поток, кто главный клиент и главный поставщик в {{bindings.control_year}}.",
|
||||
"depends_on": ["s01_biz"],
|
||||
"semantic_tags": ["money", "counterparty"],
|
||||
"required_answer_shape": "direct_answer_first"
|
||||
},
|
||||
{
|
||||
"step_id": "s03_best_year",
|
||||
"title": "All-time best operating-flow year",
|
||||
"question": "А если смотреть за все доступное время, какой самый доходный год по подтвержденным оборотам и почему? Не называй это бухгалтерской прибылью, если чистой прибыли нет.",
|
||||
"depends_on": ["s02_money"],
|
||||
"semantic_tags": ["money", "scope_guard"],
|
||||
"required_answer_shape": "direct_answer_first"
|
||||
},
|
||||
{
|
||||
"step_id": "s04_vat",
|
||||
"title": "VAT explicit period",
|
||||
"question": "Что с НДС за {{bindings.control_year}} год по {{bindings.main_organization}}: какая позиция видна, на чем она основана и чего не хватает для налогового вывода?",
|
||||
"depends_on": ["s03_best_year"],
|
||||
"semantic_tags": ["vat", "documents", "scope_guard"],
|
||||
"required_answer_shape": "direct_answer_first"
|
||||
},
|
||||
{
|
||||
"step_id": "s05_all_time",
|
||||
"title": "All-time reset without stale VAT carryover",
|
||||
"question": "Теперь за все доступное время дай обзор компании в целом, но не тащи НДС за {{bindings.control_year}} как подтвержденную общую налоговую позицию.",
|
||||
"depends_on": ["s04_vat"],
|
||||
"semantic_tags": ["business_overview", "vat", "scope_guard"],
|
||||
"required_answer_shape": "direct_answer_first"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"scenario_id": "svk_pivot",
|
||||
"title": "Counterparty pivot and legacy canaries",
|
||||
"description": "Checks explicit counterparty arbitration after organization context and keeps technical/debug details out of the final answer.",
|
||||
"steps": [
|
||||
{
|
||||
"step_id": "s01_svk_money",
|
||||
"title": "Explicit counterparty money flow",
|
||||
"node_role": "root",
|
||||
"question": "Отдельно по контрагенту {{bindings.svk_counterparty}}, без опоры на прошлый диалог: сколько денег прошло, что входящее, что исходящее и есть ли документы или движения, на которых это основано?",
|
||||
"expected_intents": ["value_flow"],
|
||||
"semantic_tags": ["counterparty", "money", "documents", "scope_guard"],
|
||||
"required_answer_shape": "direct_answer_first",
|
||||
"forbidden_answer_patterns": [
|
||||
"(?i)capability_id",
|
||||
"(?i)selected_chain_id",
|
||||
"(?i)runtime_",
|
||||
"(?i)mcp_discovery"
|
||||
]
|
||||
},
|
||||
{
|
||||
"step_id": "s02_svk_docs",
|
||||
"title": "Counterparty documents follow-up",
|
||||
"question": "Покажи документы по этой цепочке и не смешивай {{bindings.svk_counterparty}} с организацией {{bindings.main_organization}}.",
|
||||
"depends_on": ["s01_svk_money"],
|
||||
"semantic_tags": ["counterparty", "documents", "scope_guard"],
|
||||
"required_answer_shape": "direct_answer_first"
|
||||
},
|
||||
{
|
||||
"step_id": "s03_summary",
|
||||
"title": "Final executive summary",
|
||||
"question": "Собери короткий итог: что мы подтвердили по компании, что отдельно по {{bindings.svk_counterparty}}, какие выводы можно делать и какие нельзя.",
|
||||
"depends_on": ["s01_svk_money", "s02_svk_docs"],
|
||||
"semantic_tags": ["business_overview", "counterparty", "scope_guard"],
|
||||
"required_answer_shape": "direct_answer_first",
|
||||
"required_answer_patterns_all": [
|
||||
"СВК",
|
||||
"компан"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"scenario_id": "legacy_canaries",
|
||||
"title": "Legacy route canaries and context interruptions",
|
||||
"description": "Keeps old deterministic routes and conversational interruptions in the stage pack so new agentic loop wiring does not hide regressions.",
|
||||
"steps": [
|
||||
{
|
||||
"step_id": "s01_memory",
|
||||
"title": "Memory checkpoint after prior business context",
|
||||
"node_role": "root",
|
||||
"question": "Сделай короткий стартовый чек контекста: есть ли уже выбранная компания или контрагент в текущем диалоге; если нет, скажи честно и не выдумывай память про {{bindings.svk_counterparty}}.",
|
||||
"semantic_tags": ["memory", "business_overview", "counterparty", "scope_guard"],
|
||||
"required_answer_shape": "direct_answer_first",
|
||||
"forbidden_answer_patterns": [
|
||||
"(?i)capability_id",
|
||||
"(?i)runtime_"
|
||||
]
|
||||
},
|
||||
{
|
||||
"step_id": "s02_acc60",
|
||||
"title": "Account 60 tail legacy canary",
|
||||
"question": "Покажи хвосты по счету 60 на август {{bindings.control_year}} по {{bindings.main_organization}}; если точных данных нет, скажи это прямо и не подменяй ответ общим обзором.",
|
||||
"depends_on": ["s01_memory"],
|
||||
"semantic_tags": ["debt", "documents", "scope_guard"],
|
||||
"required_answer_shape": "direct_answer_first"
|
||||
},
|
||||
{
|
||||
"step_id": "s03_stock",
|
||||
"title": "Inventory route canary",
|
||||
"question": "Что было на складе на март 2021 по доступным данным? Дай прямой ответ и не уводи его в контрагента {{bindings.svk_counterparty}}.",
|
||||
"depends_on": ["s02_acc60"],
|
||||
"semantic_tags": ["inventory", "scope_guard"],
|
||||
"required_answer_shape": "direct_answer_first"
|
||||
},
|
||||
{
|
||||
"step_id": "s04_all_money",
|
||||
"title": "All-money answer without counterparty leakage",
|
||||
"question": "Вернись к {{bindings.main_organization}}: сколько всего денег получили и заплатили по всем подтвержденным данным, но не смешивай это с отдельной цепочкой {{bindings.svk_counterparty}} и не называй оборот чистой прибылью.",
|
||||
"depends_on": ["s03_stock"],
|
||||
"semantic_tags": ["money", "business_overview", "counterparty", "scope_guard"],
|
||||
"required_answer_shape": "direct_answer_first"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ This repository now supports two outer-loop capture modes:
|
|||
- `run-case` for one concrete domain question;
|
||||
- `run-scenario` for a linked multi-step domain chain that should reuse one assistant session.
|
||||
- `run-pack` for a whole domain question pool grouped into several scenarios.
|
||||
- `run-pack-loop` for an autonomous analyst/coder loop over a whole domain pack.
|
||||
- `run-pack-loop` for a strong analyst review loop over a whole domain pack, with Lead Codex repair handoff by default.
|
||||
|
||||
`run-scenario` is the preferred capture mode for domains where the user's next question depends on the previous result set.
|
||||
`run-pack` is the preferred capture mode when the user brings a full domain pool that should be kept in one aggregate backlog.
|
||||
|
|
@ -80,7 +80,7 @@ That path is explicitly marked as unvalidated and must not be treated as semanti
|
|||
|
||||
1. take the current global/local stage manifest;
|
||||
2. run `scripts/domain_case_loop.py run-pack-loop` for that stage pack;
|
||||
3. let the loop iterate through pack replay, business-first analyst verdict, coder patch, and rerun until the objective gate is accepted, blocked, or a real user decision is required;
|
||||
3. let the loop run pack replay and a business-first analyst verdict; if the gate is not accepted, write `business_audit.md` and `lead_coder_handoff.md` instead of launching a weak coder by default;
|
||||
4. if accepted, persist the validated AGENT pack into GUI autoruns through `scripts/save_agent_semantic_run.py --validated-run-dir`;
|
||||
5. write `stage_loop_summary.json` and `stage_loop_handoff.md` for the final human visual confirmation.
|
||||
|
||||
|
|
@ -92,10 +92,37 @@ Canonical commands:
|
|||
```powershell
|
||||
python scripts/stage_agent_loop.py plan --manifest docs/orchestration/<stage_loop>.json
|
||||
python scripts/stage_agent_loop.py run --manifest docs/orchestration/<stage_loop>.json
|
||||
python scripts/stage_agent_loop.py review-questions --manifest docs/orchestration/<stage_loop>.json
|
||||
python scripts/stage_agent_loop.py ingest-gui-run --manifest docs/orchestration/<stage_loop>.json --run-id assistant-stage1-<id>
|
||||
python scripts/stage_agent_loop.py prepare-repair --manifest docs/orchestration/<stage_loop>.json
|
||||
python scripts/stage_agent_loop.py run-repair --manifest docs/orchestration/<stage_loop>.json --dry-run
|
||||
python scripts/stage_agent_loop.py status --manifest docs/orchestration/<stage_loop>.json
|
||||
python scripts/stage_agent_loop.py continue --manifest docs/orchestration/<stage_loop>.json
|
||||
python scripts/stage_agent_loop.py summarize --manifest docs/orchestration/<stage_loop>.json
|
||||
```
|
||||
|
||||
This is the intended path for “implement the stage, generate/check stage questions, analyze business answers, patch code, rerun, then ask the user for final visual confirmation”.
|
||||
This is the intended path for "implement the stage, generate/check stage questions, analyze business answers, patch code, rerun, then ask the user for final visual confirmation".
|
||||
|
||||
The default repair mode is `lead-handoff`. In this mode the expensive replay still runs live and the independent analyst still produces the strict business verdict, but code repair stays with the main Lead Codex context. The loop stops with `next_action = lead_coder_repair_required`, plus:
|
||||
|
||||
- `business_audit.md` for the user-facing semantic/business verdict;
|
||||
- `lead_coder_handoff.md/json` for the concrete repair target, candidate files, and validation path;
|
||||
- `stage_context_capsule.md/json` for the current stage contract, question quality, loop status, and operating model.
|
||||
|
||||
`auto-coder` remains available only as an explicit opt-in experiment:
|
||||
|
||||
```powershell
|
||||
python scripts/stage_agent_loop.py run --manifest docs/orchestration/<stage_loop>.json --repair-mode auto-coder
|
||||
```
|
||||
|
||||
That path must not be treated as the normal high-trust repair mode for this project.
|
||||
|
||||
Before launching an expensive live replay, run `review-questions`. It reads the stage pack, resolves `{{bindings.*}}` placeholders, checks scenario/follow-up density, direct-answer shape declarations, domain coverage, stale-scope canaries, dependency order, duplicates, mojibake in generated Russian questions, and estimated Windows artifact path length. It writes:
|
||||
|
||||
- `question_generation_review.json`;
|
||||
- `question_generation_review.md`.
|
||||
|
||||
A strong question review is not semantic proof that the assistant answers correctly. It is the pre-flight gate that says the generated questions are worth spending a live replay on.
|
||||
|
||||
## GUI run review bridge
|
||||
|
||||
|
|
@ -123,7 +150,48 @@ This bridge is intentionally business-first:
|
|||
- noisy direct answers, missing first-line answers, technical garbage, and over-broad business answers become findings;
|
||||
- generated question packs get a deterministic quality review for follow-up density, direct questions, report-style analysis, domain diversity, duplicates, and weak business anchors.
|
||||
|
||||
Use this bridge when the operator would otherwise say “чекни прогон `assistant-stage1-...`”. The expected next step is no longer manual eyeballing first; it is: review by id, inspect `run_review.md`, map `repair_targets.json` into the current stage loop, patch, and rerun.
|
||||
Use this bridge when the operator would otherwise say "чекни прогон `assistant-stage1-...`". The expected next step is no longer manual eyeballing first; it is: review by id, inspect `run_review.md`, map `repair_targets.json` into the current stage loop, patch, and rerun.
|
||||
|
||||
For stage work, prefer the integrated command:
|
||||
|
||||
```powershell
|
||||
python scripts/stage_agent_loop.py ingest-gui-run --manifest docs/orchestration/<stage_loop>.json --run-id assistant-stage1-<id>
|
||||
```
|
||||
|
||||
It stores the GUI review under `artifacts/domain_runs/stage_agent_loops/<stage_id>/gui_run_reviews/<run_id>/`, updates `stage_loop_summary.json`, and writes the next stage action:
|
||||
- `continue_repair_from_gui_review_p0` when the GUI run exposes business-wrong or missing direct-answer defects;
|
||||
- `continue_repair_from_gui_review_p1` when the run is semantically usable but still noisy, over-broad, or poorly layered;
|
||||
- `manual_gui_confirmation_or_stage_close` when the GUI run is clean enough for final human confirmation.
|
||||
|
||||
`stage_loop_summary.json` also includes `next_step_guidance.command_templates`, so the next operator or agent pass can continue from machine-readable commands instead of re-inferring the workflow from prose.
|
||||
|
||||
Use `python scripts/stage_agent_loop.py status --manifest docs/orchestration/<stage_loop>.json` as the cheap read-only checkpoint before continuing a stage. It prints the current next action, closing gate, latest GUI run, latest repair coder status, latest repair validation status, and cold-start continuation artifacts such as `domain_pack_loop.command.txt` without modifying artifacts.
|
||||
|
||||
Use `python scripts/stage_agent_loop.py continue --manifest docs/orchestration/<stage_loop>.json` as the safe one-command continuation layer. From a cold start it materializes `domain_pack_loop.command.txt` without launching the long live loop; after a GUI review it can prepare a repair iteration and materialize `run-repair --dry-run` automatically; it will not run the real coder pass unless `--execute-repair` is passed, and it waits for a `--run-id assistant-stage1-<id>` when the next required step is post-repair rerun/ingest validation.
|
||||
|
||||
It also writes `stage_repair_handoff.md/json` next to the stage summary. That handoff is the preferred input for the next coder pass: it lists primary repair targets and sample user-facing failures without forcing the coder to reread the entire GUI conversation first.
|
||||
|
||||
For live stage-pack failures, prefer `lead_coder_handoff.md` over immediately preparing a coder pass. The intent is: strong business audit first, Lead Codex code repair second, same replay/GUI validation third.
|
||||
|
||||
To prepare the next repair iteration from that handoff, run:
|
||||
|
||||
```powershell
|
||||
python scripts/stage_agent_loop.py prepare-repair --manifest docs/orchestration/<stage_loop>.json
|
||||
```
|
||||
|
||||
This writes `repair_iterations/<iteration_id>/repair_iteration_plan.json`, `repair_prompt.md`, and `repair_checklist.md`. The plan enriches GUI repair targets with candidate runtime files and rerun instructions, so the next coder pass can start from a bounded business defect instead of a full transcript archaeology dig.
|
||||
|
||||
To materialize or execute the coder command for that repair iteration, run:
|
||||
|
||||
```powershell
|
||||
python scripts/stage_agent_loop.py run-repair --manifest docs/orchestration/<stage_loop>.json --dry-run
|
||||
```
|
||||
|
||||
`--dry-run` writes `repair_coder.command.txt`, records `repair_execution_summary.json`, updates `stage_loop_summary.json`, and prints the exact non-interactive Codex command without changing code. Without `--dry-run`, it executes the coder command with the prepared `repair_prompt.md`, writes `repair_coder_result.json`, captures stdout/stderr, records `repair_execution_summary.json`, and updates the stage next action to rerun/ingest, inspect, or stop for a decision depending on the coder status. After a real coder patch, rerun the same semantic pack or GUI session and ingest the new `assistant-stage1-<id>`.
|
||||
|
||||
When the coder result is `patched`, the next `ingest-gui-run` is treated as post-repair validation for that repair iteration. `stage_loop_summary.json` records `latest_repair_validation` and `repair_validation_history`, including the validation run id, remaining P0/P1 findings, and whether the repair was actually accepted after replay. A patch without this rerun/ingest evidence is not a closed stage.
|
||||
|
||||
The stage closing gate enforces that rule even when the inner pack loop reports `accepted`: `loop_accepted_gate` preserves the raw loop verdict, but stage-level `accepted_gate` stays `false` with `stage_closing_gate.status = blocked_pending_repair_validation` until the latest patched repair has a matching successful validation run.
|
||||
|
||||
## Placeholder contract
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"schema_version": "stage_agent_loop_manifest_v1",
|
||||
"stage_id": "agentic_semantic_development_loop",
|
||||
"module_name": "Agentic Semantic Development Loop",
|
||||
"title": "Agentic semantic development loop dogfood gate",
|
||||
"architecture_phase": "turnaround_11_agentic_semantic_development_loop",
|
||||
"agent_focus": "Automate stage question review, live semantic replay, strong business audit, Lead Codex repair handoff, rerun validation, and final human confirmation.",
|
||||
"current_stage_status": "active_dogfood",
|
||||
"global_plan_refs": [
|
||||
"docs/orchestration/domain_scenario_loop_repo_adapter.md",
|
||||
"docs/ARCH/11 - architecture_turnaround/README.md",
|
||||
"AGENTS.md codex_domain_loop and agent_semantic_runs"
|
||||
],
|
||||
"pack_manifest": "docs/orchestration/agentic_semantic_development_loop_stage_pack.json",
|
||||
"loop_id": "asl",
|
||||
"artifact_path_warning_limit": 240,
|
||||
"target_score": 88,
|
||||
"max_iterations": 6,
|
||||
"repair_mode": "lead-handoff",
|
||||
"acceptance_invariants": [
|
||||
"status command exposes next_action, repair state, validation state, and closing gate",
|
||||
"run-pack-loop defaults to Lead Codex handoff instead of weak autonomous coding",
|
||||
"continue command never runs the real coder pass without --execute-repair",
|
||||
"business_audit.md and lead_coder_handoff.md are produced before code repair when semantic replay is not accepted",
|
||||
"patched repair cannot close the stage without successful rerun/ingest validation",
|
||||
"business answers remain direct, context-aware, and free of internal route/debug ids",
|
||||
"manual GUI confirmation remains required after accepted semantic replay"
|
||||
],
|
||||
"save_autorun_on_accept": true,
|
||||
"manual_confirmation_required_after_accept": true
|
||||
}
|
||||
|
|
@ -356,6 +356,12 @@ function extractMonthPeriod(text) {
|
|||
}
|
||||
return {};
|
||||
}
|
||||
function isExactHistoricalPeriodWindow(filters) {
|
||||
return (typeof filters.period_from === "string" &&
|
||||
filters.period_from.trim().length > 0 &&
|
||||
typeof filters.period_to === "string" &&
|
||||
filters.period_to.trim().length > 0);
|
||||
}
|
||||
function extractPeriodRange(text) {
|
||||
const directMatch = text.match(PERIOD_RANGE_PATTERN_1) ?? text.match(PERIOD_RANGE_PATTERN_2);
|
||||
if (!directMatch) {
|
||||
|
|
@ -710,6 +716,9 @@ function isLowQualityCounterpartyAnchorValue(rawValue) {
|
|||
if (meaningfulNonGenericTokens.length === 0 && (hasTemporalCue || paymentCue)) {
|
||||
return true;
|
||||
}
|
||||
if (meaningfulNonGenericTokens.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token));
|
||||
return meaningfulTokens.length === 0;
|
||||
}
|
||||
|
|
@ -1407,6 +1416,9 @@ function resolveSemanticDateBasisHint(filters, warnings) {
|
|||
const hasAsOfDate = typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0;
|
||||
const hasPeriodFrom = typeof filters.period_from === "string" && filters.period_from.trim().length > 0;
|
||||
const hasPeriodTo = typeof filters.period_to === "string" && filters.period_to.trim().length > 0;
|
||||
if (warnings.includes("as_of_date_derived_from_exact_historical_period") && (hasPeriodFrom || hasPeriodTo)) {
|
||||
return hasPeriodFrom && hasPeriodTo ? "period_range" : "period_end";
|
||||
}
|
||||
if (hasPeriodFrom && hasPeriodTo) {
|
||||
return "period_range";
|
||||
}
|
||||
|
|
@ -1670,6 +1682,14 @@ function extractAddressFilters(userMessage, intent) {
|
|||
warnings.push("period_derived_from_year_phrase");
|
||||
}
|
||||
}
|
||||
if (isExactHistoricalPeriodWindow(filters) && !warnings.includes("exact_historical_period_window_requested")) {
|
||||
const derivedFromHistoricalPhrase = warnings.includes("period_derived_from_month_phrase") ||
|
||||
warnings.includes("period_derived_from_year_range_phrase") ||
|
||||
warnings.includes("period_derived_from_year_phrase");
|
||||
if (derivedFromHistoricalPhrase) {
|
||||
warnings.push("exact_historical_period_window_requested");
|
||||
}
|
||||
}
|
||||
const vatAsOfDate = explicitAsOfDateWithCue ?? explicitAsOfDate;
|
||||
if (intent === "vat_payable_forecast" && vatAsOfDate && !periodRange.period_from && !periodRange.period_to) {
|
||||
const quarterWindow = deriveQuarterWindowForDate(vatAsOfDate);
|
||||
|
|
@ -1685,10 +1705,12 @@ function extractAddressFilters(userMessage, intent) {
|
|||
}
|
||||
}
|
||||
const monthPeriodWasDerived = warnings.includes("period_derived_from_month_phrase");
|
||||
const yearPeriodWasDerived = warnings.includes("period_derived_from_year_phrase") || warnings.includes("period_derived_from_year_range_phrase");
|
||||
if (intent === "vat_liability_confirmed_for_tax_period" &&
|
||||
!periodRange.period_from &&
|
||||
!periodRange.period_to &&
|
||||
!monthPeriodWasDerived) {
|
||||
!monthPeriodWasDerived &&
|
||||
!yearPeriodWasDerived) {
|
||||
const periodToForQuarter = filters.period_to ?? vatAsOfDate ?? null;
|
||||
if (periodToForQuarter) {
|
||||
const quarterWindow = deriveQuarterWindowForDate(periodToForQuarter);
|
||||
|
|
@ -1711,7 +1733,12 @@ function extractAddressFilters(userMessage, intent) {
|
|||
const periodWasDerivedHeuristically = warnings.includes("period_derived_from_month_phrase") ||
|
||||
warnings.includes("period_derived_from_year_range_phrase") ||
|
||||
warnings.includes("period_derived_from_year_phrase");
|
||||
const preserveDerivedPeriodWindow = intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date";
|
||||
const preserveDerivedPeriodWindow = usesAsOfPrimaryWindow(intent) ||
|
||||
intent === "inventory_on_hand_as_of_date" ||
|
||||
intent === "inventory_supplier_stock_overlap_as_of_date";
|
||||
if (periodWasDerivedHeuristically && !warnings.includes("exact_historical_period_window_requested")) {
|
||||
warnings.push("exact_historical_period_window_requested");
|
||||
}
|
||||
if (periodWasDerivedHeuristically && !periodRange.period_from && !periodRange.period_to && !preserveDerivedPeriodWindow) {
|
||||
delete filters.period_from;
|
||||
delete filters.period_to;
|
||||
|
|
@ -1738,6 +1765,14 @@ function extractAddressFilters(userMessage, intent) {
|
|||
if (filters.period_to) {
|
||||
filters.as_of_date = filters.period_to;
|
||||
warnings.push("as_of_date_derived_from_period_to");
|
||||
if (warnings.includes("period_derived_from_month_phrase") ||
|
||||
warnings.includes("period_derived_from_year_range_phrase") ||
|
||||
warnings.includes("period_derived_from_year_phrase")) {
|
||||
warnings.push("as_of_date_derived_from_exact_historical_period");
|
||||
if (!warnings.includes("exact_historical_period_window_requested")) {
|
||||
warnings.push("exact_historical_period_window_requested");
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (shouldDefaultAsOfDateToToday(intent)) {
|
||||
filters.as_of_date = new Date().toISOString().slice(0, 10);
|
||||
|
|
|
|||
|
|
@ -1724,6 +1724,10 @@ function resolveUnicodeAddressIntentBridge(text) {
|
|||
]).has(byAnchorToken);
|
||||
const hasMoneyCue = /(?:деньг|денег|выручк|доход|оборот|заработ|прин[её]с|чек|ликвидн|revenue|turnover|money)/iu.test(normalized);
|
||||
const hasRankingCue = /(?:топ|ранк|сам(?:ый|ая|ое|ые)|больше\s+всего|наибольш|крупн|жирн|max|top|rank)/iu.test(normalized);
|
||||
const hasInventoryPurchaseToSaleDocumentChainCue = /(?:закупк[а-яё]*[\s\S]{0,80}склад[\s\S]{0,80}продаж|путь\s+товар[а-яё]*[\s\S]{0,80}закуп|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|->\s*(?:склад|warehouse|stock)\s*->\s*(?:продаж|sale))/iu.test(normalized) && /(?:товар|позици|номенклатур|sku|item|product)/iu.test(normalized);
|
||||
if (hasInventoryPurchaseToSaleDocumentChainCue) {
|
||||
return unicodeBridgeResolution("inventory_purchase_to_sale_chain", "high", "unicode_inventory_purchase_to_sale_chain_bridge_signal_detected");
|
||||
}
|
||||
const hasSelectedObjectProfitabilityCue = /(?:\u043f\u043e\s+\u0432\u044b\u0431\u0440\u0430\u043d\u043d(?:\u043e\u043c\u0443|\u043e\u0439)\s+(?:\u043e\u0431\u044a\u0435\u043a\u0442\u0443|\u043f\u043e\u0437\u0438\u0446\u0438\u0438)|selected\s+object)/iu.test(normalized) &&
|
||||
(/(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u043f\u0440\u0438\u0431\u044b\u043b|\u043c\u0430\u0440\u0436|profit|margin)/iu.test(normalized) ||
|
||||
(/(?:\u043f\u0440\u043e\u0434\u0430\u0436|sale)/iu.test(normalized) &&
|
||||
|
|
@ -1731,10 +1735,6 @@ function resolveUnicodeAddressIntentBridge(text) {
|
|||
if (hasSelectedObjectProfitabilityCue) {
|
||||
return unicodeBridgeResolution("inventory_profitability_for_item", "high", "unicode_selected_object_profitability_bridge_signal_detected");
|
||||
}
|
||||
const hasInventoryPurchaseToSaleDocumentChainCue = /(?:закупк[а-яё]*[\s\S]{0,80}склад[\s\S]{0,80}продаж|путь\s+товар[а-яё]*[\s\S]{0,80}закуп|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|->\s*(?:склад|warehouse|stock)\s*->\s*(?:продаж|sale))/iu.test(normalized) && /(?:товар|позици|номенклатур|sku|item|product)/iu.test(normalized);
|
||||
if (hasInventoryPurchaseToSaleDocumentChainCue) {
|
||||
return unicodeBridgeResolution("inventory_purchase_to_sale_chain", "high", "unicode_inventory_purchase_to_sale_chain_bridge_signal_detected");
|
||||
}
|
||||
const hasOpenItemsAccountCue = /(?:хвост|долг|незакрыт|вис)/iu.test(normalized) &&
|
||||
/(?:сч(?:е|ё)т(?:а|у|ом|е|ов)?\s*(?:№|#)?\s*(?:60|62|76)(?:[.,]\d{1,2})?|\b(?:60|62|76)(?:[.,]\d{1,2})?\b\s*сч(?:е|ё)т)/iu.test(normalized);
|
||||
if (hasOpenItemsAccountCue) {
|
||||
|
|
|
|||
|
|
@ -1769,7 +1769,7 @@ function enforceStrictAccountScopeForIntent(plan, intent) {
|
|||
account_scope_mode: "strict"
|
||||
};
|
||||
}
|
||||
function resolveExecutionFiltersForConfirmedBalance(filters, analysisDate) {
|
||||
function resolveExecutionFiltersForConfirmedBalance(filters, analysisDate, warnings = []) {
|
||||
const explicitAsOf = normalizeAnalysisDateHint(filters.as_of_date);
|
||||
const periodTo = normalizeAnalysisDateHint(filters.period_to);
|
||||
const derivedAsOf = explicitAsOf ?? periodTo ?? analysisDate ?? null;
|
||||
|
|
@ -1779,8 +1779,10 @@ function resolveExecutionFiltersForConfirmedBalance(filters, analysisDate) {
|
|||
if (derivedAsOf) {
|
||||
executionFilters.as_of_date = derivedAsOf;
|
||||
}
|
||||
delete executionFilters.period_from;
|
||||
delete executionFilters.period_to;
|
||||
if (!warnings.includes("as_of_date_derived_from_exact_historical_period")) {
|
||||
delete executionFilters.period_from;
|
||||
delete executionFilters.period_to;
|
||||
}
|
||||
const limit = typeof executionFilters.limit === "number" && Number.isFinite(executionFilters.limit)
|
||||
? Math.max(1, Math.trunc(executionFilters.limit))
|
||||
: null;
|
||||
|
|
@ -1952,6 +1954,9 @@ function asksForUnresolvedInventorySupplierLink(userMessage) {
|
|||
return /(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test(String(userMessage ?? ""));
|
||||
}
|
||||
function canAutoBroadenPeriodWindow(intent, filters) {
|
||||
if (Array.isArray(filters.warnings) && filters.warnings?.includes("exact_historical_period_window_requested")) {
|
||||
return false;
|
||||
}
|
||||
const hasRecoverableAsOfOnlyWindow = !hasExplicitPeriodWindow(filters) &&
|
||||
typeof filters.as_of_date === "string" &&
|
||||
filters.as_of_date.trim().length > 0 &&
|
||||
|
|
@ -3001,16 +3006,16 @@ class AddressQueryService {
|
|||
const confirmedBalanceVatPayableIntent = intent.intent === "vat_payable_confirmed_as_of_date" && requestedResultMode === "confirmed_balance";
|
||||
const confirmedBalanceInventoryIntent = intent.intent === "inventory_on_hand_as_of_date" && requestedResultMode === "confirmed_balance";
|
||||
const payablesConfirmedExecution = confirmedBalancePayablesIntent
|
||||
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
|
||||
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings)
|
||||
: null;
|
||||
const receivablesConfirmedExecution = confirmedBalanceReceivablesIntent
|
||||
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
|
||||
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings)
|
||||
: null;
|
||||
const vatPayableConfirmedExecution = confirmedBalanceVatPayableIntent
|
||||
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
|
||||
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings)
|
||||
: null;
|
||||
const inventoryConfirmedExecution = confirmedBalanceInventoryIntent
|
||||
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
|
||||
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings)
|
||||
: null;
|
||||
let executionFilters = inventoryConfirmedExecution?.executionFilters ??
|
||||
payablesConfirmedExecution?.executionFilters ??
|
||||
|
|
@ -4219,6 +4224,7 @@ class AddressQueryService {
|
|||
!counterpartyItemFlowQuery &&
|
||||
isDocumentOrBankAnchorIntent(intent.intent) &&
|
||||
!hasExplicitPeriodWindow(filters.extracted_filters) &&
|
||||
!filters.warnings.some((warning) => warning.startsWith("period_derived_from_")) &&
|
||||
(anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract")) {
|
||||
const currentLimit = typeof filters.extracted_filters.limit === "number" && Number.isFinite(filters.extracted_filters.limit)
|
||||
? Math.max(1, Math.trunc(filters.extracted_filters.limit))
|
||||
|
|
|
|||
|
|
@ -119,6 +119,10 @@ function truthGateStatusFrom(input) {
|
|||
return input.truthGateStatusHint;
|
||||
}
|
||||
const missingRequiredFilters = input.missingRequiredFilters ?? [];
|
||||
const reasonCodes = input.reasons ?? [];
|
||||
const heuristicOpenItemsFallback = Boolean(input.intent === "open_items_by_counterparty_or_contract" &&
|
||||
(reasonCodes.includes("confirmed_balance_unavailable_fallback_to_heuristic_candidates") ||
|
||||
reasonCodes.includes("open_items_account_query_override_to_movements")));
|
||||
if (input.routeExpectationStatus === "mismatch") {
|
||||
return "blocked_route_expectation_failure";
|
||||
}
|
||||
|
|
@ -134,6 +138,9 @@ function truthGateStatusFrom(input) {
|
|||
if (input.replyType === "factual" && input.limitedReasonCategory === "empty_match") {
|
||||
return "full_confirmed";
|
||||
}
|
||||
if (heuristicOpenItemsFallback) {
|
||||
return "partial_supported";
|
||||
}
|
||||
if (input.limitedReasonCategory === "empty_match" ||
|
||||
input.limitedReasonCategory === "recipe_visibility_gap" ||
|
||||
input.limitedReasonCategory === "unsupported" ||
|
||||
|
|
|
|||
|
|
@ -3281,11 +3281,22 @@ function composeFactualReplyBody(intent, rows, options = {}) {
|
|||
}
|
||||
if (intent === "open_items_by_counterparty_or_contract") {
|
||||
const counterparties = buildCounterpartyRiskAggregate(rows);
|
||||
const accountLead = typeof options.accountHint === "string" && options.accountHint.trim().length > 0
|
||||
? `Проверил хвосты по счету ${options.accountHint.trim()}.`
|
||||
: "Собраны открытые позиции по взаиморасчетам.";
|
||||
const accountLabel = typeof options.accountHint === "string" && options.accountHint.trim().length > 0
|
||||
? `по счету ${options.accountHint.trim()}`
|
||||
: "по взаиморасчетам";
|
||||
const exactBalanceRequested = options.requestedResultMode === "confirmed_balance";
|
||||
const periodLabel = options.asOfDate
|
||||
? `на ${formatDateRu(options.asOfDate)}`
|
||||
: options.periodFrom || options.periodTo
|
||||
? `за период ${formatDateRu(options.periodFrom ?? "...")}..${formatDateRu(options.periodTo ?? "...")}`
|
||||
: null;
|
||||
const lines = [
|
||||
accountLead,
|
||||
exactBalanceRequested
|
||||
? `Коротко: точный открытый остаток ${accountLabel}${periodLabel ? ` ${periodLabel}` : ""} не подтвержден; ниже только предварительные сигналы по движениям: ${formatNumberWithDots(rows.length)} строк, контрагентов с сигналом: ${formatNumberWithDots(counterparties.length)}.`
|
||||
: `Коротко: ${accountLabel} найдено ${formatNumberWithDots(rows.length)} строк хвостов/открытых расчетов; контрагентов с сигналом: ${formatNumberWithDots(counterparties.length)}.`,
|
||||
exactBalanceRequested
|
||||
? "Это не подтвержденное сальдо и не финальный реестр открытых расчетов: текущий контур видит движения-кандидаты, но не доказывает остаток закрытия."
|
||||
: "Это shortlist для проверки, а не финальный подтвержденный реестр открытых расчетов.",
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
`Контрагентов с сигналом: ${counterparties.length}.`
|
||||
];
|
||||
|
|
@ -3301,7 +3312,12 @@ function composeFactualReplyBody(intent, rows, options = {}) {
|
|||
}
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
text: lines.join("\n"),
|
||||
semantics: {
|
||||
result_mode: "heuristic_candidates",
|
||||
evidence_strength: counterparties.length > 0 || rows.length > 0 ? "medium" : "weak",
|
||||
balance_confirmed: false
|
||||
}
|
||||
};
|
||||
}
|
||||
if (intent === "list_contracts_by_counterparty") {
|
||||
|
|
@ -3366,7 +3382,7 @@ function composeFactualReplyBody(intent, rows, options = {}) {
|
|||
? `Контрагент: ${counterpartyInline}. Найдено документов: ${rows.length}.`
|
||||
: `Найдено документов по контрагенту: ${rows.length}.`);
|
||||
}
|
||||
if (counterpartyLabel) {
|
||||
if (counterpartyLabel && itemFlowQuestion) {
|
||||
lines.push(`Контрагент: ${counterpartyLabel}`);
|
||||
}
|
||||
if (itemFlowQuestion) {
|
||||
|
|
@ -3388,7 +3404,11 @@ function composeFactualReplyBody(intent, rows, options = {}) {
|
|||
}
|
||||
}
|
||||
else {
|
||||
lines.push(...formatTopRows(rows, rows.length));
|
||||
const visibleRows = rows.slice(0, 5);
|
||||
lines.push(...formatTopRows(visibleRows, visibleRows.length));
|
||||
if (rows.length > visibleRows.length) {
|
||||
lines.push(`Показаны первые ${visibleRows.length} из ${rows.length} документов; полный список остается в подтвержденном срезе.`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
|
|
|
|||
|
|
@ -165,11 +165,18 @@ const FOLLOWUP_LOW_QUALITY_COUNTERPARTY_TOKENS = new Set([
|
|||
"сейчас",
|
||||
"этому",
|
||||
"этомуже",
|
||||
"этой",
|
||||
"этойже",
|
||||
"тому",
|
||||
"томуже",
|
||||
"той",
|
||||
"тойже",
|
||||
"нему",
|
||||
"ней",
|
||||
"ним",
|
||||
"цепочка",
|
||||
"цепочке",
|
||||
"цепочку",
|
||||
"неуказанному",
|
||||
"неуказанный",
|
||||
"неуказанная",
|
||||
|
|
|
|||
|
|
@ -93,11 +93,16 @@ function composeInventoryReply(intent, rows, options, deps) {
|
|||
: `На ${deps.formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`;
|
||||
const lines = [directAnswerLine];
|
||||
if (positions.length > 0) {
|
||||
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции:", positions.slice(0, 20).map((item, index) => (0, inventoryReplyPresentation_1.formatInventorySnapshotPositionLine)(item, index, {
|
||||
const visiblePositionsLimit = 6;
|
||||
const visiblePositions = positions.slice(0, visiblePositionsLimit);
|
||||
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции:", visiblePositions.map((item, index) => (0, inventoryReplyPresentation_1.formatInventorySnapshotPositionLine)(item, index, {
|
||||
formatDateRu: deps.formatDateRu,
|
||||
formatNumberWithDots: deps.formatNumberWithDots,
|
||||
formatMoneyRub: deps.formatMoneyRub
|
||||
})));
|
||||
if (positions.length > visiblePositions.length) {
|
||||
lines.push(`Показаны первые ${deps.formatNumberWithDots(visiblePositions.length)} из ${deps.formatNumberWithDots(positions.length)} позиций по сумме; полный список можно раскрыть отдельным запросом.`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции:", [
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@ async function runAssistantLivingChatRuntime(input) {
|
|||
organization: scopedOrganization,
|
||||
addressDebug: lastMemoryAddressDebug,
|
||||
sessionItems: input.sessionItems,
|
||||
userMessage,
|
||||
toNonEmptyString: input.toNonEmptyString
|
||||
});
|
||||
activeOrganization = scopedOrganization ?? activeOrganization;
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ function timeScopeNeedFor(input) {
|
|||
if (input.explicitDateScope) {
|
||||
return "explicit_period";
|
||||
}
|
||||
if (input.allTimeScopeHint &&
|
||||
if ((input.allTimeScopeHint || input.subjectScopedBidirectionalAllTime) &&
|
||||
(input.family === "value_flow" || input.family === "movement_evidence" || input.family === "document_evidence")) {
|
||||
return "all_time_scope";
|
||||
}
|
||||
|
|
@ -396,6 +396,10 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) {
|
|||
const comparisonNeed = comparisonNeedFor(action);
|
||||
const rankingNeed = rankingNeedFromRawUtterance(rawUtterance) ?? seededRankingNeed;
|
||||
const allTimeScopeHint = hasAllTimeScopeHint(rawUtterance);
|
||||
const subjectScopedBidirectionalAllTime = businessFactFamily === "value_flow" &&
|
||||
comparisonNeed === "incoming_vs_outgoing" &&
|
||||
subjectCandidates.length > 0 &&
|
||||
!explicitDateScope;
|
||||
const directBusinessOverviewMoneyAnswerHint = hasBusinessOverviewDirectMoneyAnswerHint({
|
||||
family: businessFactFamily,
|
||||
rawUtterance,
|
||||
|
|
@ -449,7 +453,8 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) {
|
|||
const timeScopeNeed = timeScopeNeedFor({
|
||||
family: businessFactFamily,
|
||||
explicitDateScope,
|
||||
allTimeScopeHint
|
||||
allTimeScopeHint,
|
||||
subjectScopedBidirectionalAllTime
|
||||
});
|
||||
if (timeScopeNeed === "period_required" && !explicitDateScope) {
|
||||
pushUnique(clarificationGaps, "period");
|
||||
|
|
@ -492,6 +497,9 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) {
|
|||
if (allTimeScopeHint) {
|
||||
pushReason(reasonCodes, "data_need_graph_all_time_scope_hint");
|
||||
}
|
||||
if (subjectScopedBidirectionalAllTime) {
|
||||
pushReason(reasonCodes, "data_need_graph_subject_bidirectional_value_flow_defaults_to_all_time_scope");
|
||||
}
|
||||
if (businessFactFamily === "business_overview" && !explicitDateScope) {
|
||||
pushReason(reasonCodes, "data_need_graph_business_overview_defaults_to_all_time_scope");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ function normalizeTurnMeaning(value) {
|
|||
const dateScope = toNonEmptyString(value.explicit_date_scope);
|
||||
const unsupported = toNonEmptyString(value.unsupported_but_understood_family);
|
||||
const entities = toStringList(value.explicit_entity_candidates);
|
||||
const businessOverviewSeparateEntities = toStringList(value.business_overview_separate_entity_candidates);
|
||||
const metadataAmbiguityEntitySets = toStringList(value.metadata_ambiguity_entity_sets);
|
||||
if (domain) {
|
||||
result.asked_domain_family = domain;
|
||||
|
|
@ -96,6 +97,9 @@ function normalizeTurnMeaning(value) {
|
|||
if (entities.length > 0) {
|
||||
result.explicit_entity_candidates = entities;
|
||||
}
|
||||
if (businessOverviewSeparateEntities.length > 0) {
|
||||
result.business_overview_separate_entity_candidates = businessOverviewSeparateEntities;
|
||||
}
|
||||
if (metadataAmbiguityEntitySets.length > 0) {
|
||||
result.metadata_ambiguity_entity_sets = metadataAmbiguityEntitySets;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -365,18 +365,230 @@ function businessOverviewYearRowsLine(overview) {
|
|||
const joined = values.join("; ");
|
||||
return values.length > 0 ? `По годам: ${sentenceAmount(joined) ?? joined}.` : null;
|
||||
}
|
||||
function firstOverviewAxisLabel(rows, amountKey = "total_amount_human_ru") {
|
||||
const first = toRecordObject(Array.isArray(rows) ? rows[0] : null);
|
||||
const label = toNonEmptyString(first?.axis_value);
|
||||
const amount = moneyText(first?.[amountKey]);
|
||||
return label && amount ? `${label} — ${sentenceAmount(amount) ?? amount}` : null;
|
||||
}
|
||||
function businessOverviewTaxLine(overview) {
|
||||
const tax = toRecordObject(overview.tax_position);
|
||||
if (!tax) {
|
||||
return null;
|
||||
}
|
||||
const salesVat = moneyText(tax.sales_vat_amount_human_ru);
|
||||
const purchaseVat = moneyText(tax.purchase_vat_amount_human_ru);
|
||||
const netVat = moneyText(tax.net_vat_amount_human_ru);
|
||||
if (!salesVat && !purchaseVat && !netVat) {
|
||||
return null;
|
||||
}
|
||||
const direction = tax.net_vat_direction === "vat_to_pay"
|
||||
? "НДС к уплате"
|
||||
: tax.net_vat_direction === "vat_to_recover_or_offset"
|
||||
? "НДС к возмещению/зачету"
|
||||
: "чистая НДС-позиция";
|
||||
return `НДС: продажи ${salesVat ?? "0 руб."}, покупки ${purchaseVat ?? "0 руб."}, ${direction} ${sentenceAmount(netVat) ?? netVat ?? "0 руб."}.`;
|
||||
}
|
||||
function businessOverviewDebtLine(overview) {
|
||||
const debt = toRecordObject(overview.debt_position);
|
||||
if (!debt) {
|
||||
return null;
|
||||
}
|
||||
const receivables = moneyText(toRecordObject(debt.receivables)?.total_amount_human_ru);
|
||||
const payables = moneyText(toRecordObject(debt.payables)?.total_amount_human_ru);
|
||||
const net = moneyText(debt.net_debt_position_amount_human_ru);
|
||||
if (!receivables && !payables && !net) {
|
||||
return null;
|
||||
}
|
||||
const direction = debt.net_debt_position_direction === "net_payable" ? "кредиторка больше дебиторки" : "дебиторка больше кредиторки";
|
||||
return `Долги: дебиторка ${receivables ?? "0 руб."}, кредиторка ${payables ?? "0 руб."}, нетто ${sentenceAmount(net) ?? net ?? "0 руб."} (${direction}).`;
|
||||
}
|
||||
function businessOverviewInventoryLine(overview) {
|
||||
const inventory = toRecordObject(overview.inventory_position);
|
||||
if (!inventory) {
|
||||
return null;
|
||||
}
|
||||
const amount = moneyText(inventory.total_amount_human_ru);
|
||||
const rows = Number(inventory.rows_matched);
|
||||
const quantity = Number(inventory.total_quantity);
|
||||
if (!amount && !Number.isFinite(rows)) {
|
||||
return null;
|
||||
}
|
||||
const pieces = [
|
||||
Number.isFinite(rows) ? `${rows} позиций` : null,
|
||||
amount ? `на ${sentenceAmount(amount) ?? amount}` : null,
|
||||
Number.isFinite(quantity) && quantity > 0 ? `количество ${quantity}` : null
|
||||
].filter((item) => Boolean(item));
|
||||
return pieces.length > 0 ? `Склад: ${pieces.join(", ")}.` : null;
|
||||
}
|
||||
function rowCountText(value) {
|
||||
const count = Number(value);
|
||||
return Number.isFinite(count) ? String(count) : null;
|
||||
}
|
||||
function sideRowsText(side) {
|
||||
const rowsWithAmount = rowCountText(side?.rows_with_amount);
|
||||
const rowsMatched = rowCountText(side?.rows_matched);
|
||||
if (rowsWithAmount && rowsMatched) {
|
||||
return `${rowsWithAmount} из ${rowsMatched}`;
|
||||
}
|
||||
return rowsWithAmount ?? rowsMatched;
|
||||
}
|
||||
function sideDateText(side) {
|
||||
const first = toNonEmptyString(side?.first_movement_date);
|
||||
const latest = toNonEmptyString(side?.latest_movement_date);
|
||||
if (first && latest) {
|
||||
return first === latest ? `дата ${first}` : `даты ${first}..${latest}`;
|
||||
}
|
||||
return first ? `первая дата ${first}` : latest ? `последняя дата ${latest}` : null;
|
||||
}
|
||||
function bidirectionalNetLabel(direction) {
|
||||
if (direction === "net_outgoing") {
|
||||
return "нетто в сторону контрагента";
|
||||
}
|
||||
if (direction === "balanced") {
|
||||
return "нетто около нуля";
|
||||
}
|
||||
return "нетто в нашу сторону";
|
||||
}
|
||||
function buildCompactBidirectionalValueFlowReply(entryPoint, draft) {
|
||||
const bridge = toRecordObject(entryPoint.bridge);
|
||||
const pilot = toRecordObject(bridge?.pilot);
|
||||
const flow = toRecordObject(pilot?.derived_bidirectional_value_flow);
|
||||
if (!flow) {
|
||||
return null;
|
||||
}
|
||||
const incoming = toRecordObject(flow.incoming_customer_revenue);
|
||||
const outgoing = toRecordObject(flow.outgoing_supplier_payout);
|
||||
const incomingAmount = moneyText(incoming?.total_amount_human_ru);
|
||||
const outgoingAmount = moneyText(outgoing?.total_amount_human_ru);
|
||||
const netAmount = moneyText(flow.net_amount_human_ru);
|
||||
if (!incomingAmount && !outgoingAmount && !netAmount) {
|
||||
return null;
|
||||
}
|
||||
const counterparty = toNonEmptyString(flow.counterparty) ?? "запрошенному контрагенту";
|
||||
const period = toNonEmptyString(flow.period_scope);
|
||||
const periodText = period ? ` за период ${period}` : " в проверенном окне";
|
||||
const incomingRows = sideRowsText(incoming);
|
||||
const outgoingRows = sideRowsText(outgoing);
|
||||
const incomingDates = sideDateText(incoming);
|
||||
const outgoingDates = sideDateText(outgoing);
|
||||
const netLabel = bidirectionalNetLabel(flow.net_direction);
|
||||
const lines = [
|
||||
`Коротко: по контрагенту ${counterparty}${periodText} по найденным строкам 1С получили ${incomingAmount ?? "0 руб."}, заплатили ${outgoingAmount ?? "0 руб."}; расчетное ${netLabel}: ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.`
|
||||
];
|
||||
const basis = [];
|
||||
if (incomingRows) {
|
||||
basis.push(`входящих строк с суммой ${incomingRows}${incomingDates ? ` (${incomingDates})` : ""}`);
|
||||
}
|
||||
if (outgoingRows) {
|
||||
basis.push(`исходящих строк с суммой ${outgoingRows}${outgoingDates ? ` (${outgoingDates})` : ""}`);
|
||||
}
|
||||
if (basis.length > 0) {
|
||||
lines.push(`Основа: ${basis.join("; ")}.`);
|
||||
}
|
||||
if (flow.coverage_limited_by_probe_limit === true) {
|
||||
lines.push("Важно: часть проверки уперлась в лимит строк, поэтому это проверенный срез найденных движений, а не гарантия полного периода.");
|
||||
}
|
||||
lines.push("Метод: нетто рассчитано как подтвержденные входящие строки 1С минус подтвержденные исходящие строки; это не полное бухгалтерское сальдо вне проверенного окна.");
|
||||
const fallbackNextStep = toNonEmptyString(draft.next_step_line);
|
||||
if (fallbackNextStep) {
|
||||
lines.push(`Следующий шаг: ${localizeLine(fallbackNextStep)}`);
|
||||
}
|
||||
const reply = lines.join("\n").trim();
|
||||
return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null;
|
||||
}
|
||||
function compactComparable(value) {
|
||||
return String(value ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/[«»"']/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
function businessOverviewSeparateSubjectLabel(graph, turnMeaning, organizationScope) {
|
||||
const candidates = uniqueStrings([
|
||||
...toStringList(turnMeaning?.business_overview_separate_entity_candidates),
|
||||
...toStringList(graph?.subject_candidates),
|
||||
...toStringList(turnMeaning?.explicit_entity_candidates)
|
||||
]);
|
||||
const organizationComparable = compactComparable(organizationScope);
|
||||
for (const candidate of candidates) {
|
||||
const text = toNonEmptyString(candidate);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const comparable = compactComparable(text);
|
||||
if (organizationComparable && comparable === organizationComparable) {
|
||||
continue;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function sameBusinessSubject(left, right) {
|
||||
const leftComparable = compactComparable(left);
|
||||
const rightComparable = compactComparable(right);
|
||||
return Boolean(leftComparable && rightComparable && leftComparable === rightComparable);
|
||||
}
|
||||
function previousDocumentSummaryLine(bundle, separateSubject) {
|
||||
if (!bundle || !sameBusinessSubject(toNonEmptyString(bundle.counterparty), separateSubject)) {
|
||||
return null;
|
||||
}
|
||||
const count = Number(bundle.document_count);
|
||||
if (!Number.isFinite(count) || count <= 0) {
|
||||
return null;
|
||||
}
|
||||
return `документы по цепочке: найдено ${count}`;
|
||||
}
|
||||
function buildPreviousCounterpartyValueFlowSummary(flow, separateSubject, documentBundle) {
|
||||
if (!flow || !separateSubject || !sameBusinessSubject(toNonEmptyString(flow.counterparty), separateSubject)) {
|
||||
return null;
|
||||
}
|
||||
const incoming = toRecordObject(flow.incoming_customer_revenue);
|
||||
const outgoing = toRecordObject(flow.outgoing_supplier_payout);
|
||||
const incomingAmount = moneyText(incoming?.total_amount_human_ru);
|
||||
const outgoingAmount = moneyText(outgoing?.total_amount_human_ru);
|
||||
const netAmount = moneyText(flow.net_amount_human_ru);
|
||||
if (!incomingAmount && !outgoingAmount && !netAmount) {
|
||||
return null;
|
||||
}
|
||||
const counterparty = toNonEmptyString(flow.counterparty) ?? separateSubject;
|
||||
const netLabel = bidirectionalNetLabel(flow.net_direction);
|
||||
const lead = `; отдельно по ${counterparty}: получили ${incomingAmount ?? "0 руб."}, заплатили ${outgoingAmount ?? "0 руб."}, ` +
|
||||
`${netLabel} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}`;
|
||||
const basis = [];
|
||||
const incomingRows = sideRowsText(incoming);
|
||||
const outgoingRows = sideRowsText(outgoing);
|
||||
const incomingDates = sideDateText(incoming);
|
||||
const outgoingDates = sideDateText(outgoing);
|
||||
if (incomingRows) {
|
||||
basis.push(`входящие строки ${incomingRows}${incomingDates ? ` (${incomingDates})` : ""}`);
|
||||
}
|
||||
if (outgoingRows) {
|
||||
basis.push(`исходящие строки ${outgoingRows}${outgoingDates ? ` (${outgoingDates})` : ""}`);
|
||||
}
|
||||
const documents = previousDocumentSummaryLine(documentBundle, counterparty);
|
||||
if (documents) {
|
||||
basis.push(documents);
|
||||
}
|
||||
const basisText = basis.length > 0 ? ` Основа: ${basis.join("; ")}.` : "";
|
||||
return {
|
||||
lead,
|
||||
line: `Отдельно по контрагенту ${counterparty}: подтверждено получили ${incomingAmount ?? "0 руб."}, ` +
|
||||
`заплатили ${outgoingAmount ?? "0 руб."}, расчетное ${netLabel} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.` +
|
||||
`${basisText} Это не перенос сумм компании на контрагента, а отдельный ранее подтвержденный контрагентский срез.`
|
||||
};
|
||||
}
|
||||
function buildCompactBusinessOverviewReply(entryPoint, draft) {
|
||||
const turnInput = toRecordObject(entryPoint.turn_input);
|
||||
const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref);
|
||||
const graph = toRecordObject(turnInput?.data_need_graph);
|
||||
const bridge = toRecordObject(entryPoint.bridge);
|
||||
const pilot = toRecordObject(bridge?.pilot);
|
||||
const overview = toRecordObject(pilot?.derived_business_overview);
|
||||
const graphReasons = readStringArray(graph?.reason_codes);
|
||||
const isBusinessOverview = toNonEmptyString(graph?.business_fact_family) === "business_overview" ||
|
||||
toNonEmptyString(pilot?.pilot_scope) === "business_overview_route_template_v1";
|
||||
const rankingNeed = toNonEmptyString(graph?.ranking_need);
|
||||
const directMoneyAnswer = graphReasons.includes("data_need_graph_business_overview_direct_money_answer");
|
||||
if (!isBusinessOverview || !overview || (!rankingNeed && !directMoneyAnswer)) {
|
||||
if (!isBusinessOverview || !overview) {
|
||||
return null;
|
||||
}
|
||||
const incoming = toRecordObject(overview.incoming_customer_revenue);
|
||||
|
|
@ -387,7 +599,38 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
|
|||
const netDirection = overview.net_direction === "net_outgoing" ? "операционное нетто в минус" : "расчетное операционное нетто";
|
||||
const period = businessOverviewPeriodText(overview);
|
||||
const limitLine = businessOverviewCoverageLimitLine(overview);
|
||||
const organizationScope = toNonEmptyString(turnMeaning?.explicit_organization_scope);
|
||||
const separateSubject = businessOverviewSeparateSubjectLabel(graph, turnMeaning, organizationScope);
|
||||
const previousCounterpartySummary = buildPreviousCounterpartyValueFlowSummary(toRecordObject(turnMeaning?.previous_counterparty_value_flow_bundle), separateSubject, toRecordObject(turnMeaning?.previous_counterparty_document_bundle));
|
||||
const organizationPrefix = organizationScope ? `по компании ${organizationScope} ` : "";
|
||||
const separateSubjectLead = separateSubject
|
||||
? previousCounterpartySummary?.lead ??
|
||||
`; по контрагенту ${separateSubject} суммы компании не переношу, это отдельный контур без подтвержденного итога в этой строке`
|
||||
: "";
|
||||
const topCustomer = toRecordObject(Array.isArray(overview.top_customers) ? overview.top_customers[0] : null);
|
||||
const customerName = toNonEmptyString(topCustomer?.axis_value);
|
||||
const customerAmount = moneyText(topCustomer?.total_amount_human_ru);
|
||||
const topCustomerLead = customerName && customerAmount
|
||||
? `; крупнейший источник входящих денег: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}`
|
||||
: "";
|
||||
const topSupplier = firstOverviewAxisLabel(overview.top_suppliers);
|
||||
const topSupplierLead = topSupplier ? `; крупнейший получатель исходящих денег: ${topSupplier}` : "";
|
||||
const roleBoundaryLead = topCustomer || topSupplier ? "; клиент/поставщик как бизнес-роли этим денежным срезом не подтверждены" : "";
|
||||
const graphReasonCodes = toStringList(graph?.reason_codes);
|
||||
const directMoneyAnswer = graphReasonCodes.includes("data_need_graph_business_overview_direct_money_answer");
|
||||
const crossScopeExecutiveSummary = Boolean(separateSubject && previousCounterpartySummary);
|
||||
const lines = [];
|
||||
if (crossScopeExecutiveSummary && separateSubject && previousCounterpartySummary && (incomingAmount || outgoingAmount || netAmount)) {
|
||||
lines.push(`Коротко: по компании ${organizationScope ?? "в выбранном контуре"} ${period} подтвержден денежный срез: получили ${incomingAmount ?? "0 руб."}, исходящие платежи/списания ${outgoingAmount ?? "0 руб."}, ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}${previousCounterpartySummary.lead}; можно утверждать только эти подтвержденные срезы, нельзя называть это чистой прибылью, полным оборотом или доказанной ролью главного клиента/поставщика.`);
|
||||
lines.push(previousCounterpartySummary.line);
|
||||
lines.push(`Можно утверждать: по компании подтвержден operating-flow proxy по найденным строкам 1С; по ${separateSubject} отдельно подтверждены входящие/исходящие строки, расчетное нетто и документы из предыдущего контрагентского среза.`);
|
||||
lines.push(`Нельзя утверждать: это не чистая прибыль, не полный бухгалтерский оборот вне проверенного окна и не доказательство, что ${separateSubject} является главным клиентом или поставщиком как бизнес-роль.`);
|
||||
if (limitLine) {
|
||||
lines.push(limitLine);
|
||||
}
|
||||
const reply = lines.join("\n").trim();
|
||||
return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null;
|
||||
}
|
||||
if (rankingNeed) {
|
||||
const incomingLeader = strongestIncomingYear(overview);
|
||||
const netLeader = strongestNetYear(overview);
|
||||
|
|
@ -397,7 +640,7 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
|
|||
if (!leaderYear || !leaderAmount) {
|
||||
return null;
|
||||
}
|
||||
lines.push(`Коротко: самый доходный год в доступном денежном контуре 1С — ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}.`);
|
||||
lines.push(`Коротко: в доступном проверенном MCP-срезе по входящим денежным строкам лидирует ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}; это не полный бухгалтерский рейтинг доходности.`);
|
||||
const netYear = toNonEmptyString(netLeader?.year_bucket);
|
||||
const netYearAmount = moneyText(netLeader?.net_amount_human_ru);
|
||||
if (netYear && netYearAmount) {
|
||||
|
|
@ -414,18 +657,54 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
|
|||
}
|
||||
}
|
||||
else if (incomingAmount || outgoingAmount || netAmount) {
|
||||
lines.push(`Коротко: ${period} по подтвержденным строкам 1С получили ${incomingAmount ?? "0 руб."}; исходящие платежи/списания ${outgoingAmount ?? "0 руб."}; ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб"}.`);
|
||||
lines.push(`Коротко: ${organizationPrefix}${period} по подтвержденным строкам 1С получили ${incomingAmount ?? "0 руб."}; исходящие платежи/списания ${outgoingAmount ?? "0 руб."}; ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб"}${topCustomerLead}${topSupplierLead}${roleBoundaryLead}${separateSubjectLead}.`);
|
||||
lines.push('Метод: "заработали" здесь считаю как денежный operating-flow proxy по 1С; это не чистая прибыль и не финрезультат.');
|
||||
const topCustomer = toRecordObject(Array.isArray(overview.top_customers) ? overview.top_customers[0] : null);
|
||||
const customerName = toNonEmptyString(topCustomer?.axis_value);
|
||||
const customerAmount = moneyText(topCustomer?.total_amount_human_ru);
|
||||
if (customerName && customerAmount) {
|
||||
if (!directMoneyAnswer && customerName && customerAmount) {
|
||||
lines.push(`Крупнейший подтвержденный источник входящих денег в этом срезе: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}.`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
if (separateSubject) {
|
||||
lines.push(previousCounterpartySummary?.line ??
|
||||
`Отдельно по контрагенту ${separateSubject}: этот итог не переносит суммы компании на контрагента. Можно утверждать только разделение контура; нельзя делать вывод о выручке, долге или прибыльности ${separateSubject} без отдельного контрагентского среза документов и движений.`);
|
||||
}
|
||||
if (!directMoneyAnswer && topSupplier) {
|
||||
lines.push(`Крупнейший подтвержденный получатель исходящих денег: ${topSupplier}.`);
|
||||
}
|
||||
if (!directMoneyAnswer && (topCustomer || topSupplier)) {
|
||||
lines.push("Важно по ролям: текущий денежный срез подтверждает денежные источники и получателей, но не доказывает, что это главный клиент или главный поставщик как бизнес-роль.");
|
||||
}
|
||||
if (!directMoneyAnswer) {
|
||||
lines.push(`Что подтверждено: денежный срез по компании${organizationScope ? ` ${organizationScope}` : ""}${period ? ` ${period}` : ""}${topCustomer ? ", крупнейший источник входящих денег" : ""}${topSupplier ? ", крупнейший получатель исходящих денег" : ""}.`);
|
||||
const taxLine = businessOverviewTaxLine(overview);
|
||||
if (taxLine) {
|
||||
lines.push(taxLine);
|
||||
}
|
||||
const debtLine = businessOverviewDebtLine(overview);
|
||||
if (debtLine) {
|
||||
lines.push(debtLine);
|
||||
}
|
||||
const inventoryLine = businessOverviewInventoryLine(overview);
|
||||
if (inventoryLine) {
|
||||
lines.push(inventoryLine);
|
||||
}
|
||||
const missingOverviewFamilies = [];
|
||||
if (!taxLine) {
|
||||
missingOverviewFamilies.push("общая НДС/налоговая позиция без отдельного точного расчета");
|
||||
}
|
||||
if (!debtLine) {
|
||||
missingOverviewFamilies.push("долги без даты среза");
|
||||
}
|
||||
if (!inventoryLine) {
|
||||
missingOverviewFamilies.push("склад без даты среза");
|
||||
}
|
||||
if (missingOverviewFamilies.length > 0) {
|
||||
lines.push(`Что не подтверждено в этом срезе: ${missingOverviewFamilies.join(", ")}.`);
|
||||
}
|
||||
lines.push("Что нельзя утверждать: чистую прибыль, полноценный финрезультат, юридические бизнес-роли клиентов/поставщиков и общую налоговую позицию без отдельного точного расчета.");
|
||||
}
|
||||
if (limitLine) {
|
||||
lines.push(limitLine);
|
||||
}
|
||||
|
|
@ -476,6 +755,10 @@ function buildReplyText(entryPoint, status) {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
const compactBidirectionalValueFlowReply = buildCompactBidirectionalValueFlowReply(entryPoint, draft);
|
||||
if (compactBidirectionalValueFlowReply) {
|
||||
return compactBidirectionalValueFlowReply;
|
||||
}
|
||||
const compactBusinessOverviewReply = buildCompactBusinessOverviewReply(entryPoint, draft);
|
||||
if (compactBusinessOverviewReply) {
|
||||
return compactBusinessOverviewReply;
|
||||
|
|
|
|||
|
|
@ -233,6 +233,18 @@ function readStateTransitionReasonCodes(input) {
|
|||
.map((item) => toNonEmptyString(item))
|
||||
.filter((item) => Boolean(item));
|
||||
}
|
||||
function hasFullConfirmedTruth(input) {
|
||||
const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status);
|
||||
if (truthGateStatus === "full_confirmed") {
|
||||
return true;
|
||||
}
|
||||
const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1);
|
||||
const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate);
|
||||
const sourceTruthGateStatus = toNonEmptyString(truthGate?.source_truth_gate_status);
|
||||
const coverageStatus = toNonEmptyString(truthGate?.coverage_status);
|
||||
const groundingStatus = toNonEmptyString(truthGate?.grounding_status);
|
||||
return sourceTruthGateStatus === "full_confirmed" || (coverageStatus === "full" && groundingStatus === "grounded");
|
||||
}
|
||||
function readStringArray(value) {
|
||||
return Array.isArray(value)
|
||||
? value.map((item) => toNonEmptyString(item)).filter((item) => Boolean(item))
|
||||
|
|
@ -299,6 +311,12 @@ function hasExactMatchedFactualAddressReply(input, entryPoint) {
|
|||
if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
|
||||
return false;
|
||||
}
|
||||
if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
|
||||
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
|
||||
if (!(isMetadataDiscoveryTurn(entryPoint) && isInventoryExactAddressIntent(detectedIntent))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const mcpCallStatus = toNonEmptyString(input.addressRuntimeMeta?.mcp_call_status);
|
||||
const truthMode = toNonEmptyString(input.addressRuntimeMeta?.truth_mode);
|
||||
const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe);
|
||||
|
|
@ -335,16 +353,7 @@ function hasRuntimeAdjustedExactReply(input, entryPoint) {
|
|||
if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
|
||||
return false;
|
||||
}
|
||||
const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status);
|
||||
const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1);
|
||||
const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate);
|
||||
const sourceTruthGateStatus = toNonEmptyString(truthGate?.source_truth_gate_status);
|
||||
const coverageStatus = toNonEmptyString(truthGate?.coverage_status);
|
||||
const groundingStatus = toNonEmptyString(truthGate?.grounding_status);
|
||||
const hasFullConfirmedTruth = truthGateStatus === "full_confirmed" ||
|
||||
sourceTruthGateStatus === "full_confirmed" ||
|
||||
(coverageStatus === "full" && groundingStatus === "grounded");
|
||||
if (!hasFullConfirmedTruth) {
|
||||
if (!hasFullConfirmedTruth(input)) {
|
||||
return false;
|
||||
}
|
||||
const truthAnswerShape = readTruthAnswerShape(input);
|
||||
|
|
@ -354,6 +363,26 @@ function hasRuntimeAdjustedExactReply(input, entryPoint) {
|
|||
}
|
||||
return readStateTransitionReasonCodes(input).some((reason) => /^intent_adjusted_to_.+_followup_context$/i.test(reason));
|
||||
}
|
||||
function hasRuntimeMatchedExactReply(input, entryPoint) {
|
||||
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
|
||||
return false;
|
||||
}
|
||||
if (!hasEffectivelyFactualAddressReply(input)) {
|
||||
return false;
|
||||
}
|
||||
if (hasMetadataDiscoveryPriority(input, entryPoint)) {
|
||||
return false;
|
||||
}
|
||||
if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
|
||||
return false;
|
||||
}
|
||||
if (!hasFullConfirmedTruth(input)) {
|
||||
return false;
|
||||
}
|
||||
const reasonCodes = readStateTransitionReasonCodes(input);
|
||||
return (reasonCodes.some((reason) => reason === "route_expectation_matched") &&
|
||||
reasonCodes.some((reason) => /(?:confirmed_balance_exact|exact_.+_intent|vat_period_inspection_bridge_signal_detected)/iu.test(reason)));
|
||||
}
|
||||
function hasAlignedFactualAddressReply(input, entryPoint) {
|
||||
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
|
||||
return false;
|
||||
|
|
@ -380,6 +409,9 @@ function hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint) {
|
|||
if (hasRuntimeAdjustedExactReply(input, entryPoint)) {
|
||||
return false;
|
||||
}
|
||||
if (hasRuntimeMatchedExactReply(input, entryPoint)) {
|
||||
return false;
|
||||
}
|
||||
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
|
||||
const turnMeaning = readDiscoveryTurnMeaning(entryPoint);
|
||||
const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family);
|
||||
|
|
@ -453,16 +485,7 @@ function hasFullConfirmedFactualAddressReply(input, entryPoint) {
|
|||
if (hasMetadataDiscoveryPriority(input, entryPoint)) {
|
||||
return false;
|
||||
}
|
||||
const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status);
|
||||
if (truthGateStatus === "full_confirmed") {
|
||||
return true;
|
||||
}
|
||||
const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1);
|
||||
const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate);
|
||||
const sourceTruthGateStatus = toNonEmptyString(truthGate?.source_truth_gate_status);
|
||||
const coverageStatus = toNonEmptyString(truthGate?.coverage_status);
|
||||
const groundingStatus = toNonEmptyString(truthGate?.grounding_status);
|
||||
return sourceTruthGateStatus === "full_confirmed" || (coverageStatus === "full" && groundingStatus === "grounded");
|
||||
return hasFullConfirmedTruth(input);
|
||||
}
|
||||
function applyAssistantMcpDiscoveryResponsePolicy(input) {
|
||||
const currentReply = String(input.currentReply ?? "");
|
||||
|
|
@ -482,6 +505,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
|
|||
const fullConfirmedFactualAddressReply = hasFullConfirmedFactualAddressReply(input, entryPoint);
|
||||
const exactMatchedFactualAddressReply = hasExactMatchedFactualAddressReply(input, entryPoint);
|
||||
const runtimeAdjustedExactReply = hasRuntimeAdjustedExactReply(input, entryPoint);
|
||||
const runtimeMatchedExactReply = hasRuntimeMatchedExactReply(input, entryPoint);
|
||||
const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint);
|
||||
const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint);
|
||||
const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning(input, entryPoint);
|
||||
|
|
@ -534,6 +558,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
|
|||
if (runtimeAdjustedExactReply) {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_runtime_adjusted_exact_reply_over_stale_discovery_turn_meaning");
|
||||
}
|
||||
if (runtimeMatchedExactReply) {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_runtime_matched_exact_reply_over_stale_discovery_turn_meaning");
|
||||
}
|
||||
if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_broad_business_summary_over_clarification_candidate");
|
||||
}
|
||||
|
|
@ -557,6 +584,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
|
|||
!fullConfirmedFactualAddressReply &&
|
||||
!exactMatchedFactualAddressReply &&
|
||||
!runtimeAdjustedExactReply &&
|
||||
!runtimeMatchedExactReply &&
|
||||
!(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") &&
|
||||
ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) &&
|
||||
candidate.eligible_for_future_hot_runtime &&
|
||||
|
|
|
|||
|
|
@ -193,6 +193,9 @@ function pushScopedEntityCandidate(target, value, groundedFollowupEntity) {
|
|||
isValueFlowPredicateEntityCandidate(text)) {
|
||||
return;
|
||||
}
|
||||
if (target.some((existing) => sameScopedName(existing, text))) {
|
||||
return;
|
||||
}
|
||||
pushUnique(target, text);
|
||||
}
|
||||
function canonicalizeEntityResolutionCandidate(value) {
|
||||
|
|
@ -220,6 +223,19 @@ function compactLower(value) {
|
|||
function sameScopedName(left, right) {
|
||||
return Boolean(left && right && compactLower(left) === compactLower(right));
|
||||
}
|
||||
function preferredScopedDisplayName(value, candidates) {
|
||||
const anchor = toNonEmptyString(value);
|
||||
if (!anchor) {
|
||||
return null;
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
const text = candidateValue(candidate);
|
||||
if (sameScopedName(text, anchor)) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return anchor;
|
||||
}
|
||||
function candidateValue(value) {
|
||||
const direct = toNonEmptyString(value);
|
||||
if (direct && direct !== "[object Object]") {
|
||||
|
|
@ -553,7 +569,9 @@ function collectFollowupDiscoverySeed(followupContext) {
|
|||
metadataSelectedSurfaceObjects: collectEntityCandidates(followupContext?.previous_discovery_metadata_selected_surface_objects),
|
||||
metadataRecommendedNextPrimitive: normalizeMetadataRecommendedPrimitive(followupContext?.previous_discovery_metadata_recommended_next_primitive),
|
||||
metadataAmbiguityDetected: followupContext?.previous_discovery_metadata_ambiguity_detected === true,
|
||||
metadataAmbiguityEntitySets: collectEntityCandidates(followupContext?.previous_discovery_metadata_ambiguity_entity_sets)
|
||||
metadataAmbiguityEntitySets: collectEntityCandidates(followupContext?.previous_discovery_metadata_ambiguity_entity_sets),
|
||||
previousBidirectionalValueFlow: toRecordObject(followupContext?.previous_discovery_bidirectional_value_flow),
|
||||
previousDocumentSummary: toRecordObject(followupContext?.previous_discovery_document_summary)
|
||||
};
|
||||
}
|
||||
function buildMetadataSurfaceRef(followupSeed) {
|
||||
|
|
@ -652,8 +670,15 @@ function hasOrganizationLevelSupplierQualityOverviewSignal(text) {
|
|||
const hasCompanyScopeCue = /(?:\u0443\s+\u043d\u0430\u0441|\u043d\u0430\u0448\w*|\u043f\u043e\s+\u043a\u043e\u043c\u043f\u0430\u043d|\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u0431\u0438\u0437\u043d\u0435\u0441|\u0432\s+\u0446\u0435\u043b\u043e\u043c|\u043e\u0431\u0449\w*|\u043a\u0430\u043a\w*|\u043f\u043e\u043a\u0430\u0436|\u0434\u0430\u0439|\u0441\u0440\u0435\u0437|\u0430\u043d\u0430\u043b\u0438\u0437|(?:19|20)\d{2}|company|business|organization|overall|our|we|us|show|give|analysis)/iu.test(text);
|
||||
return hasSupplierScopeCue && hasSupplierQualityCue && hasCompanyScopeCue;
|
||||
}
|
||||
function hasCrossScopeExecutiveSummarySignal(text) {
|
||||
return (/(?:\u0441\u043e\u0431\u0435\u0440\p{L}*\s+(?:\u043a\u043e\u0440\u043e\u0442\u043a\p{L}*\s+)?\u0438\u0442\u043e\u0433|\u044d\u043a\u0437\u0435\u043a\u044c\u044e\u0442\u0438\u0432\p{L}*\s+\u0441\u0430\u043c\u043c\u0430\u0440\u0438|executive\s+summary|final\s+summary)/iu.test(text) &&
|
||||
/(?:\u0447\u0442\u043e\s+(?:\u043c\u044b\s+)?\u043f\u043e\u0434\u0442\u0432\u0435\u0440\p{L}*|\u043f\u043e\s+\u043a\u043e\u043c\u043f\u0430\u043d\p{L}*|\u043f\u043e\s+\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\p{L}*|confirmed|company|organization)/iu.test(text) &&
|
||||
/(?:\u043e\u0442\u0434\u0435\u043b\u044c\u043d\p{L}*\s+\u043f\u043e|\u043f\u043e\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0433\u0440\u0443\u043f\u043f\p{L}*\s+\u0441\u0432\u043a|\u0441\u0432\u043a|counterpart(?:y|ies)?)/iu.test(text) &&
|
||||
/(?:\u0447\u0442\u043e\s+\u043c\u043e\u0436\u043d\p{L}*|\u0447\u0442\u043e\s+\u043d\u0435\u043b\u044c\u0437\p{L}*|\u0432\u044b\u0432\u043e\u0434\p{L}*|allowed|forbidden|cannot|can\s+say)/iu.test(text));
|
||||
}
|
||||
function hasBusinessOverviewSignal(text) {
|
||||
if (hasOrganizationLevelEarningsOverviewSignal(text) ||
|
||||
if (hasCrossScopeExecutiveSummarySignal(text) ||
|
||||
hasOrganizationLevelEarningsOverviewSignal(text) ||
|
||||
hasOrganizationLevelDebtPositionOverviewSignal(text) ||
|
||||
hasOrganizationLevelDebtDueDateOverviewSignal(text) ||
|
||||
hasOrganizationLevelInventoryReserveLiquidationOverviewSignal(text) ||
|
||||
|
|
@ -679,6 +704,34 @@ function hasBusinessOverviewContinuationSignal(text) {
|
|||
hasFinalSummaryCue ||
|
||||
hasMoneyBreakdownCue);
|
||||
}
|
||||
function hasExplicitVatQuestionSignal(text) {
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
return (/(?:\u043d\u0434\u0441|vat)/iu.test(text) &&
|
||||
/(?:\u0437\u0430|\u043d\u0430|\u043f\u0435\u0440\u0438\u043e\u0434|\u043f\u043e\u0437\u0438\u0446|\u043a\s+\u0443\u043f\u043b\u0430\u0442|\u043a\s+\u0432\u043e\u0437\u043c\u0435\u0449|\u043e\u0441\u043d\u043e\u0432\u0430\u043d|\u043d\u0430\u043b\u043e\u0433\u043e\u0432\p{L}*\s+\u0432\u044b\u0432\u043e\u0434|tax\s+period|tax\s+position)/iu.test(text));
|
||||
}
|
||||
function hasBusinessOverviewSeparateCounterpartySignal(text) {
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
return (/(?:\u043e\u0442\u0434\u0435\u043b\u044c\u043d\p{L}*\s+\u043f\u043e|\u043f\u043e\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|counterpart(?:y|ies)?)/iu.test(text) &&
|
||||
/(?:\u043a\u043e\u043c\u043f\u0430\u043d\p{L}*|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\p{L}*|company|organization|\u0438\u0442\u043e\u0433|summary|\u0432\u044b\u0432\u043e\u0434\p{L}*)/iu.test(text));
|
||||
}
|
||||
function businessOverviewSeparateCounterpartyCandidateFromText(text) {
|
||||
const source = (0, addressTextRepair_1.repairAddressMojibakeText)(String(text ?? ""));
|
||||
const patterns = [
|
||||
/(?:\u043e\u0442\u0434\u0435\u043b\u044c\u043d\p{L}*\s+\u043f\u043e|\u043f\u043e\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*)\s+(.+?)(?:[,.;:!?]|\s+\u043a\u0430\u043a\u0438\p{L}*\b|\s+\u0447\u0442\u043e\b|$)/iu,
|
||||
/(?:\u0434\u043b\u044f|for)\s+([\p{L}\d._-]+(?:\s+[\p{L}\d._-]+){0,3})(?:[,.;:!?]|\s+\u043a\u0430\u043a\u0438\p{L}*\b|\s+\u0447\u0442\u043e\b|$)/iu
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const candidate = normalizeFollowupCounterpartyCandidate(source.match(pattern)?.[1]);
|
||||
if (candidate && !isInvalidEntityCandidate(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function hasExplicitTopicSwitchSignal(text) {
|
||||
return /(?:^|\s)(?:\u0442\u0435\u043f\u0435\u0440\u044c|\u0430\s+\u0442\u0435\u043f\u0435\u0440\u044c|\u0434\u0430\u043b\u044c\u0448\u0435|\u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e|\u043f\u0435\u0440\u0435\u0439\u0434[\u0451\u0435]\u043c|\u0441\u043c\u0435\u043d\u0438\u043c\s+\u0442\u0435\u043c\u0443|\u0432\u0435\u0440\u043d[\u0451\u0435]\u043c\u0441\u044f\s+\u043a|now|next|switch\s+to)(?:\s|$)/iu.test(text);
|
||||
}
|
||||
|
|
@ -1047,8 +1100,13 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
const rawEntitySourceText = repairedUserText ?? rawUserText ?? repairedEffectiveText ?? rawEffectiveText ?? rawSignalSourceText;
|
||||
const rawText = compactLower(rawSignalSourceText);
|
||||
const rawReferentialDocumentExclusionSignal = hasReferentialDocumentExclusionFollowupSignal(repairedUserText ?? rawUserText ?? "");
|
||||
const businessOverviewContinuationSignal = hasBusinessOverviewFollowupSeed(followupSeed) && hasBusinessOverviewContinuationSignal(rawText);
|
||||
const rawBusinessOverviewSignal = hasBusinessOverviewSignal(rawText) || businessOverviewContinuationSignal;
|
||||
const rawPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawText);
|
||||
const explicitVatQuestionSignal = hasExplicitVatQuestionSignal(rawText);
|
||||
const explicitVatSuppressesBusinessOverviewContinuation = Boolean(explicitVatQuestionSignal && !rawPrimaryBusinessOverviewSignal);
|
||||
const businessOverviewContinuationSignal = hasBusinessOverviewFollowupSeed(followupSeed) &&
|
||||
hasBusinessOverviewContinuationSignal(rawText) &&
|
||||
!explicitVatSuppressesBusinessOverviewContinuation;
|
||||
const rawBusinessOverviewSignal = rawPrimaryBusinessOverviewSignal || businessOverviewContinuationSignal;
|
||||
const rawLifecycleSignal = !rawBusinessOverviewSignal && hasLifecycleSignal(rawText);
|
||||
const rawBidirectionalValueFlowSignal = !rawBusinessOverviewSignal && !rawLifecycleSignal && hasBidirectionalValueFlowSignal(rawText);
|
||||
const rawValueFlowSignal = !rawBusinessOverviewSignal &&
|
||||
|
|
@ -1094,6 +1152,10 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
rawDomain === "business_summary" ||
|
||||
rawDomain === "business_overview" ||
|
||||
rawAction === "broad_evaluation";
|
||||
const businessOverviewSeparateCounterpartySignal = Boolean(businessOverviewSignal && hasBusinessOverviewSeparateCounterpartySignal(rawText));
|
||||
const businessOverviewSeparateCounterpartyCandidate = businessOverviewSeparateCounterpartySignal
|
||||
? businessOverviewSeparateCounterpartyCandidateFromText(rawText)
|
||||
: null;
|
||||
const explicitIntentCandidate = toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate);
|
||||
const currentTurnDocumentLaneSignal = rawAction === "list_documents";
|
||||
const currentTurnMovementLaneSignal = rawAction === "list_movements";
|
||||
|
|
@ -1125,6 +1187,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
sameScopedName(followupSeed.counterparty, followupSeed.organization) ||
|
||||
sameScopedName(followupSeed.counterparty, currentTurnOrganizationScope)));
|
||||
const businessOverviewSuppressesFollowupCounterparty = Boolean(businessOverviewSignal &&
|
||||
!businessOverviewSeparateCounterpartySignal &&
|
||||
(rawBusinessOverviewSignal ||
|
||||
businessOverviewContinuationSignal ||
|
||||
broadBusinessEvaluationUnsupported ||
|
||||
|
|
@ -1161,7 +1224,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
? null
|
||||
: normalizeFollowupCounterpartyCandidate(predecomposeEntities.counterparty);
|
||||
const predecomposeDateScope = collectDateScope(predecomposeContract);
|
||||
const suppressFollowupBusinessOverviewSeed = Boolean(explicitVatSuppressesBusinessOverviewContinuation && hasBusinessOverviewFollowupSeed(followupSeed));
|
||||
const periodClarificationFollowupApplicable = Boolean(followupSeed.domain &&
|
||||
!suppressFollowupBusinessOverviewSeed &&
|
||||
followupSeed.loopStatus === "awaiting_clarification" &&
|
||||
followupSeed.loopPendingAxes.includes("period") &&
|
||||
!rawLifecycleSignal &&
|
||||
|
|
@ -1172,6 +1237,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
relativeCurrentDateHintDetected ||
|
||||
(predecomposeDateScope && !isImplicitCurrentDateScope(predecomposeDateScope))));
|
||||
const followupDiscoverySeedApplicable = Boolean(followupSeed.domain &&
|
||||
!suppressFollowupBusinessOverviewSeed &&
|
||||
!rawLifecycleSignal &&
|
||||
!rawMetadataSignal &&
|
||||
(periodClarificationFollowupApplicable ||
|
||||
|
|
@ -1499,6 +1565,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
for (const candidate of collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates)) {
|
||||
pushScopedEntityCandidate(entityCandidates, candidate, groundedFollowupEntity);
|
||||
}
|
||||
pushScopedEntityCandidate(entityCandidates, businessOverviewSeparateCounterpartyCandidate, groundedFollowupEntity);
|
||||
pushScopedEntityCandidate(entityCandidates, normalizedPredecomposeCounterparty, groundedFollowupEntity);
|
||||
pushScopedEntityCandidate(entityCandidates, rawScopedEntityCandidate, groundedFollowupEntity);
|
||||
if (!groundedFollowupEntity) {
|
||||
|
|
@ -1511,6 +1578,20 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
}
|
||||
pushScopedEntityCandidate(entityCandidates, rawEntityCandidate, groundedFollowupEntity);
|
||||
}
|
||||
const businessOverviewSeparateCounterpartyDisplayCandidate = businessOverviewSeparateCounterpartySignal
|
||||
? preferredScopedDisplayName(businessOverviewSeparateCounterpartyCandidate, [
|
||||
groundedFollowupEntity,
|
||||
effectiveFollowupCounterparty,
|
||||
followupSeed.discoveryEntity,
|
||||
normalizedPredecomposeCounterparty,
|
||||
rawScopedEntityCandidate,
|
||||
rawEntityCandidate,
|
||||
...entityCandidates
|
||||
])
|
||||
: null;
|
||||
const businessOverviewSeparateEntityCandidates = businessOverviewSeparateCounterpartyDisplayCandidate
|
||||
? [businessOverviewSeparateCounterpartyDisplayCandidate]
|
||||
: [];
|
||||
if ((rawMetadataSignal || metadataFollowupSeedApplicable) &&
|
||||
!groundedFollowupEntity &&
|
||||
!metadataScopedLaneWithoutSubject) {
|
||||
|
|
@ -1579,6 +1660,8 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
(clarificationLoopStillNeedsPeriod ||
|
||||
businessOverviewSignal ||
|
||||
openScopeValueFlowWithoutResolvedCounterparty ||
|
||||
valueFlowGroundedDocumentFollowupApplicable ||
|
||||
valueFlowGroundedMovementFollowupApplicable ||
|
||||
(valueFlowOrganizationStaysScope && (Boolean(followupSeed.rankingNeed) || bidirectionalValueFlowSignal))));
|
||||
const suppressNegatedTaxOnlyDateScope = Boolean(businessOverviewSignal && negatedTaxDateScopeOnlySignal);
|
||||
const topicSwitchSuppressesFollowupScope = Boolean(rawTopicSwitchSignal &&
|
||||
|
|
@ -1604,10 +1687,15 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
(suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(followupSeed.dateScope))
|
||||
? null
|
||||
: followupSeed.dateScope;
|
||||
const businessOverviewRawYearOverridesPredecomposeAsOf = Boolean(businessOverviewSignal &&
|
||||
rawDateScope &&
|
||||
/^\d{4}$/.test(rawDateScope) &&
|
||||
normalizedPredecomposeDateScope &&
|
||||
normalizedPredecomposeDateScope.startsWith(`${rawDateScope}-`));
|
||||
const explicitDateScope = rawAllTimeScopeSignal
|
||||
? null
|
||||
: normalizedAssistantTurnMeaningDateScope ??
|
||||
normalizedPredecomposeDateScope ??
|
||||
(businessOverviewRawYearOverridesPredecomposeAsOf ? rawDateScope : normalizedPredecomposeDateScope) ??
|
||||
rawDateScope ??
|
||||
normalizedFollowupDateScope;
|
||||
const followupDateScopeApplied = Boolean(!rawAllTimeScopeSignal &&
|
||||
|
|
@ -1656,6 +1744,11 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
? followupSeed.rankingNeed
|
||||
: undefined,
|
||||
explicit_entity_candidates: businessOverviewSignal ? [] : entityCandidates,
|
||||
business_overview_separate_entity_candidates: businessOverviewSeparateEntityCandidates,
|
||||
previous_counterparty_value_flow_bundle: businessOverviewSignal && followupSeed.previousBidirectionalValueFlow
|
||||
? followupSeed.previousBidirectionalValueFlow
|
||||
: undefined,
|
||||
previous_counterparty_document_bundle: businessOverviewSignal && followupSeed.previousDocumentSummary ? followupSeed.previousDocumentSummary : undefined,
|
||||
metadata_ambiguity_entity_sets: metadataAmbiguityLaneClarificationApplicable && followupSeed.metadataAmbiguityEntitySets.length > 0
|
||||
? followupSeed.metadataAmbiguityEntitySets
|
||||
: undefined,
|
||||
|
|
@ -1716,6 +1809,15 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
if ((turnMeaning.explicit_entity_candidates?.length ?? 0) > 0) {
|
||||
cleanTurnMeaning.explicit_entity_candidates = turnMeaning.explicit_entity_candidates;
|
||||
}
|
||||
if ((turnMeaning.business_overview_separate_entity_candidates?.length ?? 0) > 0) {
|
||||
cleanTurnMeaning.business_overview_separate_entity_candidates = turnMeaning.business_overview_separate_entity_candidates;
|
||||
}
|
||||
if (toRecordObject(turnMeaning.previous_counterparty_value_flow_bundle)) {
|
||||
cleanTurnMeaning.previous_counterparty_value_flow_bundle = turnMeaning.previous_counterparty_value_flow_bundle;
|
||||
}
|
||||
if (toRecordObject(turnMeaning.previous_counterparty_document_bundle)) {
|
||||
cleanTurnMeaning.previous_counterparty_document_bundle = turnMeaning.previous_counterparty_document_bundle;
|
||||
}
|
||||
if ((turnMeaning.metadata_ambiguity_entity_sets?.length ?? 0) > 0) {
|
||||
cleanTurnMeaning.metadata_ambiguity_entity_sets = turnMeaning.metadata_ambiguity_entity_sets;
|
||||
}
|
||||
|
|
@ -1924,9 +2026,21 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
if (businessOverviewContinuationSignal) {
|
||||
pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_from_followup_context");
|
||||
}
|
||||
if (explicitVatSuppressesBusinessOverviewContinuation) {
|
||||
pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_suppressed_by_explicit_vat_question");
|
||||
}
|
||||
if (businessOverviewSuppressesFollowupCounterparty) {
|
||||
pushReason(reasonCodes, "mcp_discovery_business_overview_suppressed_stale_counterparty");
|
||||
}
|
||||
if (businessOverviewSeparateCounterpartySignal) {
|
||||
pushReason(reasonCodes, "mcp_discovery_business_overview_preserved_explicit_counterparty_summary_scope");
|
||||
}
|
||||
if (businessOverviewSeparateCounterpartyCandidate) {
|
||||
pushReason(reasonCodes, "mcp_discovery_business_overview_counterparty_from_summary_text");
|
||||
}
|
||||
if (businessOverviewRawYearOverridesPredecomposeAsOf) {
|
||||
pushReason(reasonCodes, "mcp_discovery_business_overview_raw_year_overrode_predecompose_as_of_scope");
|
||||
}
|
||||
if (!(valueFlowOrganizationStaysScope && normalizedPredecomposeCounterparty === explicitOrganizationScope) &&
|
||||
normalizedPredecomposeCounterparty) {
|
||||
pushReason(reasonCodes, "mcp_discovery_counterparty_from_predecompose");
|
||||
|
|
@ -1957,11 +2071,17 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
if (runDiscovery && !hasTurnMeaning) {
|
||||
pushReason(reasonCodes, "mcp_discovery_turn_meaning_missing");
|
||||
}
|
||||
const dataNeedGraphTurnMeaning = businessOverviewSeparateCounterpartySignal && cleanTurnMeaning.explicit_entity_candidates
|
||||
? {
|
||||
...cleanTurnMeaning,
|
||||
explicit_entity_candidates: []
|
||||
}
|
||||
: cleanTurnMeaning;
|
||||
const dataNeedGraph = runDiscovery && hasTurnMeaning
|
||||
? (0, assistantMcpDiscoveryDataNeedGraph_1.buildAssistantMcpDiscoveryDataNeedGraph)({
|
||||
semanticDataNeed,
|
||||
rawUtterance: rawSignalSourceText,
|
||||
turnMeaning: cleanTurnMeaning
|
||||
turnMeaning: dataNeedGraphTurnMeaning
|
||||
})
|
||||
: null;
|
||||
if (dataNeedGraph) {
|
||||
|
|
|
|||
|
|
@ -171,6 +171,30 @@ function hasSignalAcrossSamples(samples, detector) {
|
|||
function hasExplicitRecapPromptSignal(samples) {
|
||||
return samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|что\s+уже\s+поняли|напомни\s+что\s+мы|executive\s+summary|финальн\w*\s+собери|итогов\w*\s+(?:резюм|summary|вывод)|по\s+всему\s+диалогу|где\s+ответы\s+были\s+подтвержден|где\s+proxy|где\s+прокси|не\s+хватил\w*\s+доказательств|ручн\w*\s+(?:смотр|провер|контрол))/iu.test(sample));
|
||||
}
|
||||
function normalizeMemoryCheckpointSample(value) {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/ё/g, "е")
|
||||
.replace(/[«»"'`]/g, "")
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
function hasMemoryCheckpointPromptSignal(samples) {
|
||||
return samples.some((sample) => {
|
||||
const text = normalizeMemoryCheckpointSample(sample);
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
if (/(?:стартов\w*\s+чек\s+контекст|чек\s+контекста|context\s+check|memory\s+check)/iu.test(text)) {
|
||||
return true;
|
||||
}
|
||||
const hasSelectedStateCue = /(?:выбранн\w*\s+(?:компан|организац|контрагент|объект)|активн\w*\s+(?:компан|организац|контрагент|объект)|selected\s+(?:company|organization|counterparty|object)|active\s+(?:company|organization|counterparty|object))/iu.test(text);
|
||||
const hasDialogStateCue = /(?:в\s+текущ\w*\s+диалог|в\s+этом\s+диалог|в\s+сессии|контекст(?:е|а)?\s+диалог|current\s+(?:dialog|session|conversation))/iu.test(text);
|
||||
const hasHonestyCue = /(?:не\s+выдумывай\s+памят|не\s+придумывай\s+памят|скажи\s+честно|если\s+нет|no\s+fabricat|do\s+not\s+invent\s+memory)/iu.test(text);
|
||||
const asksCurrentSelection = /(?:есть\s+ли\s+уже|есть\s+ли\s+сейчас|что\s+выбрано|кто\s+выбран|какая\s+компан\w*\s+выбран)/iu.test(text);
|
||||
return (hasSelectedStateCue && hasDialogStateCue) || (hasDialogStateCue && hasHonestyCue) || (asksCurrentSelection && hasHonestyCue);
|
||||
});
|
||||
}
|
||||
function buildInventoryHistoryCapabilityFollowupReply(input) {
|
||||
const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString);
|
||||
const organization = input.organization ?? contextFacts.organization;
|
||||
|
|
@ -545,6 +569,26 @@ function extractBuyerFromSaleTraceAnswer(answerText, itemLabel) {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
function extractRequestedMemorySubject(userMessage) {
|
||||
const text = String(userMessage ?? "").trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const patterns = [
|
||||
/памят[ьи]\s+про\s+([^.;!?]+)/iu,
|
||||
/memory\s+about\s+([^.;!?]+)/iu
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
const subject = match?.[1]
|
||||
? match[1].replace(/[«»"'`]/g, "").replace(/\s+/g, " ").trim()
|
||||
: "";
|
||||
if (subject.length >= 2 && subject.length <= 80) {
|
||||
return subject;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function buildAddressMemoryRecapReply(input) {
|
||||
const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString);
|
||||
const item = contextFacts.item;
|
||||
|
|
@ -604,7 +648,14 @@ function buildAddressMemoryRecapReply(input) {
|
|||
"Могу кратко напомнить контекст или сразу продолжить следующий шаг по этому же сценарию."
|
||||
].join(" ");
|
||||
}
|
||||
return "Да, помню предыдущий адресный контур. Могу кратко напомнить, что мы уже подтвердили, или сразу продолжить следующий шаг.";
|
||||
const requestedMemorySubject = extractRequestedMemorySubject(input.userMessage);
|
||||
const subjectLine = requestedMemorySubject
|
||||
? ` Память про «${requestedMemorySubject}» в этом диалоге не подтверждена.`
|
||||
: " Память про конкретную компанию или контрагента в этом диалоге не подтверждена.";
|
||||
return [
|
||||
`Коротко: в текущем диалоге я не вижу выбранной компании, контрагента или позиции.${subjectLine}`,
|
||||
"Чтобы продолжить без выдуманной памяти, назови компанию, контрагента или объект, и я начну новый проверенный контур."
|
||||
].join(" ");
|
||||
}
|
||||
function buildBroadBusinessEvaluationReply(input) {
|
||||
const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString);
|
||||
|
|
@ -820,6 +871,7 @@ function createAssistantMemoryRecapPolicy(deps) {
|
|||
const historicalCapabilitySignal = hasSignalAcrossSamples(samples, deps.hasHistoricalCapabilityFollowupSignal);
|
||||
const memoryRecapSignal = hasSignalAcrossSamples(samples, deps.hasConversationMemoryRecallFollowupSignal);
|
||||
const explicitRecapPromptSignal = hasExplicitRecapPromptSignal(samples);
|
||||
const memoryCheckpointPromptSignal = hasMemoryCheckpointPromptSignal(samples);
|
||||
return {
|
||||
contextualHistoricalCapabilityFollowupDetected: Boolean(input.capabilityMetaQuery &&
|
||||
!input.dataScopeMetaQuery &&
|
||||
|
|
@ -829,9 +881,10 @@ function createAssistantMemoryRecapPolicy(deps) {
|
|||
contextualMemoryRecapFollowupDetected: Boolean(!input.dataScopeMetaQuery &&
|
||||
!input.capabilityMetaQuery &&
|
||||
!input.aggregateBusinessAnalyticsSignal &&
|
||||
memoryRecapSignal &&
|
||||
(explicitRecapPromptSignal || (!input.dataRetrievalSignal && !input.strongDataSignal)) &&
|
||||
continuity.hasGroundedAddressContext)
|
||||
(memoryCheckpointPromptSignal ||
|
||||
(memoryRecapSignal &&
|
||||
(explicitRecapPromptSignal || (!input.dataRetrievalSignal && !input.strongDataSignal)) &&
|
||||
continuity.hasGroundedAddressContext)))
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -116,6 +116,10 @@ function createAssistantTransitionPolicy(deps) {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
function hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage = null) {
|
||||
const samples = [userMessage, alternateMessage].map((item) => normalizeFollowupText(item)).filter(Boolean);
|
||||
return samples.some((sample) => /(?:итог|summary|резюм|вывод|что\s+(?:мы\s+)?подтверд|что\s+понят|что\s+можно|что\s+нельзя|собери\s+коротк)/iu.test(sample) && /(?:контрагент|группа\s+свк|свк|отдельн)/iu.test(sample));
|
||||
}
|
||||
function parseDmyDateToIso(value) {
|
||||
const match = String(value ?? "").trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||
if (!match) {
|
||||
|
|
@ -244,6 +248,57 @@ function createAssistantTransitionPolicy(deps) {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
function readMcpDiscoveryBidirectionalValueFlow(debug) {
|
||||
const entryPoint = debug?.assistant_mcp_discovery_entry_point_v1;
|
||||
const flow = entryPoint?.bridge?.pilot?.derived_bidirectional_value_flow;
|
||||
if (!flow || typeof flow !== "object" || Array.isArray(flow)) {
|
||||
return null;
|
||||
}
|
||||
return flow;
|
||||
}
|
||||
function readCounterpartyDocumentSummaryFromItem(item) {
|
||||
const text = deps.toNonEmptyString(item?.text);
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const firstLine = text.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "";
|
||||
const match = firstLine.match(/Контрагент:\s*([^.\n]+)\.\s*Найдено документов:\s*(\d+)/iu);
|
||||
if (!match?.[1] || !match?.[2]) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
counterparty: deps.toNonEmptyString(match[1]),
|
||||
document_count: Number(match[2]),
|
||||
direct_answer: firstLine
|
||||
};
|
||||
}
|
||||
function findRecentDiscoveryValueFlowBundle(items) {
|
||||
for (let index = Array.isArray(items) ? items.length - 1 : -1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
const debug = item?.debug;
|
||||
if (!item || item.role !== "assistant" || !debug || typeof debug !== "object") {
|
||||
continue;
|
||||
}
|
||||
const flow = readMcpDiscoveryBidirectionalValueFlow(debug);
|
||||
if (flow) {
|
||||
return flow;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function findRecentCounterpartyDocumentBundle(items) {
|
||||
for (let index = Array.isArray(items) ? items.length - 1 : -1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
|
||||
continue;
|
||||
}
|
||||
const summary = readCounterpartyDocumentSummaryFromItem(item);
|
||||
if (summary) {
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint) {
|
||||
if (sourceIntentHint !== "inventory_purchase_provenance_for_item" &&
|
||||
!hasInventoryItemFocusHint &&
|
||||
|
|
@ -388,7 +443,8 @@ function createAssistantTransitionPolicy(deps) {
|
|||
llmPreDecomposeMeta
|
||||
})
|
||||
: null;
|
||||
if (assistantTurnMeaning?.stale_replay_forbidden === true) {
|
||||
if (assistantTurnMeaning?.stale_replay_forbidden === true &&
|
||||
!hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage)) {
|
||||
return null;
|
||||
}
|
||||
const latestAddressItem = deps.findLastAddressAssistantItem(items);
|
||||
|
|
@ -465,17 +521,20 @@ function createAssistantTransitionPolicy(deps) {
|
|||
const shortValueFlowRetargetAlternate = hasValueFlowCarryoverSourceHint && deps.toNonEmptyString(alternateMessage)
|
||||
? hasShortValueFlowRetargetCue(String(alternateMessage ?? ""))
|
||||
: false;
|
||||
const explicitSummaryBundleReuseSignal = hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage);
|
||||
let hasPrimaryFollowupSignal = deps.hasAddressFollowupContextSignal(userMessage) ||
|
||||
Boolean(debtRoleSwapPrimary) ||
|
||||
shortValueFlowRetargetPrimary ||
|
||||
inventoryShortFollowupPrimary ||
|
||||
inventoryPurchaseDateVatBridge;
|
||||
inventoryPurchaseDateVatBridge ||
|
||||
explicitSummaryBundleReuseSignal;
|
||||
let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage)
|
||||
? deps.hasAddressFollowupContextSignal(alternateMessage) ||
|
||||
Boolean(debtRoleSwapAlternate) ||
|
||||
shortValueFlowRetargetAlternate ||
|
||||
inventoryShortFollowupAlternate ||
|
||||
inventoryPurchaseDateVatBridge
|
||||
inventoryPurchaseDateVatBridge ||
|
||||
explicitSummaryBundleReuseSignal
|
||||
: false;
|
||||
const hasPrimaryIndexReferenceSignal = deps.extractDisplayedEntityIndexMention(userMessage) !== null;
|
||||
const hasAlternateIndexReferenceSignal = deps.toNonEmptyString(alternateMessage)
|
||||
|
|
@ -507,6 +566,7 @@ function createAssistantTransitionPolicy(deps) {
|
|||
hasInventoryRootRestatementPrimary ||
|
||||
hasInventoryRootRestatementAlternate ||
|
||||
inventoryPurchaseDateVatBridge ||
|
||||
explicitSummaryBundleReuseSignal ||
|
||||
Boolean(debtRoleSwapIntent) ||
|
||||
shortValueFlowRetargetPrimary ||
|
||||
shortValueFlowRetargetAlternate ||
|
||||
|
|
@ -526,6 +586,7 @@ function createAssistantTransitionPolicy(deps) {
|
|||
hasInventoryRootRestatementPrimary ||
|
||||
hasInventoryRootRestatementAlternate ||
|
||||
inventoryPurchaseDateVatBridge ||
|
||||
explicitSummaryBundleReuseSignal ||
|
||||
Boolean(debtRoleSwapIntent) ||
|
||||
shortValueFlowRetargetPrimary ||
|
||||
shortValueFlowRetargetAlternate ||
|
||||
|
|
@ -556,7 +617,8 @@ function createAssistantTransitionPolicy(deps) {
|
|||
!hasImplicitContinuationSignal &&
|
||||
!hasSuggestedIntentPivotSignal &&
|
||||
!hasOrganizationClarificationContinuation &&
|
||||
!hasIndexReferenceSignal) {
|
||||
!hasIndexReferenceSignal &&
|
||||
!explicitSummaryBundleReuseSignal) {
|
||||
return null;
|
||||
}
|
||||
if (!hasPrimaryFollowupSignal &&
|
||||
|
|
@ -570,7 +632,8 @@ function createAssistantTransitionPolicy(deps) {
|
|||
!hasImplicitContinuationSignal &&
|
||||
!hasSuggestedIntentPivotSignal &&
|
||||
!hasOrganizationClarificationContinuation &&
|
||||
!hasIndexReferenceSignal) {
|
||||
!hasIndexReferenceSignal &&
|
||||
!explicitSummaryBundleReuseSignal) {
|
||||
return null;
|
||||
}
|
||||
if (!carryoverSourceDebug) {
|
||||
|
|
@ -598,6 +661,8 @@ function createAssistantTransitionPolicy(deps) {
|
|||
const sourceDiscoveryLoopSubjectResolutionOptional = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopSubjectResolutionOptional)(carryoverSourceDebug);
|
||||
const sourceDiscoveryRankingNeed = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryRankingNeed)(carryoverSourceDebug, deps.toNonEmptyString);
|
||||
const sourceDiscoveryEntityAmbiguityCandidates = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryEntityAmbiguityCandidates)(carryoverSourceDebug, deps.toNonEmptyString);
|
||||
const sourceDiscoveryBidirectionalValueFlow = readMcpDiscoveryBidirectionalValueFlow(carryoverSourceDebug) ?? findRecentDiscoveryValueFlowBundle(items);
|
||||
const sourceDiscoveryDocumentSummary = findRecentCounterpartyDocumentBundle(items);
|
||||
const llmExplicitIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
|
||||
const llmSelectedObjectScopeDetected = llmPreDecomposeMeta?.predecomposeContract?.semantics?.selected_object_scope_detected === true;
|
||||
const resolvedPrimaryIntent = deps.resolveAddressIntent(deps.repairAddressMojibake(String(userMessage ?? ""))).intent;
|
||||
|
|
@ -690,6 +755,7 @@ function createAssistantTransitionPolicy(deps) {
|
|||
shortValueFlowRetargetPrimary ||
|
||||
inventoryShortFollowupPrimary ||
|
||||
inventoryPurchaseDateVatBridge ||
|
||||
explicitSummaryBundleReuseSignal ||
|
||||
hasInventoryRootTemporalFollowupPrimary;
|
||||
hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage)
|
||||
? deps.hasAddressFollowupContextSignal(alternateMessage) ||
|
||||
|
|
@ -698,6 +764,7 @@ function createAssistantTransitionPolicy(deps) {
|
|||
shortValueFlowRetargetAlternate ||
|
||||
inventoryShortFollowupAlternate ||
|
||||
inventoryPurchaseDateVatBridge ||
|
||||
explicitSummaryBundleReuseSignal ||
|
||||
hasInventoryRootTemporalFollowupAlternate
|
||||
: false;
|
||||
hasStrongFollowupReference =
|
||||
|
|
@ -711,6 +778,7 @@ function createAssistantTransitionPolicy(deps) {
|
|||
hasInventoryRootTemporalFollowupPrimary ||
|
||||
hasInventoryRootTemporalFollowupAlternate ||
|
||||
inventoryPurchaseDateVatBridge ||
|
||||
explicitSummaryBundleReuseSignal ||
|
||||
Boolean(debtRoleSwapIntent) ||
|
||||
shortValueFlowRetargetPrimary ||
|
||||
shortValueFlowRetargetAlternate ||
|
||||
|
|
@ -859,6 +927,8 @@ function createAssistantTransitionPolicy(deps) {
|
|||
previous_discovery_metadata_recommended_next_primitive: sourceDiscoveryMetadataRecommendedNextPrimitive ?? undefined,
|
||||
previous_discovery_metadata_ambiguity_detected: sourceDiscoveryMetadataAmbiguityDetected || undefined,
|
||||
previous_discovery_metadata_ambiguity_entity_sets: sourceDiscoveryMetadataAmbiguityEntitySets.length > 0 ? sourceDiscoveryMetadataAmbiguityEntitySets : undefined,
|
||||
previous_discovery_bidirectional_value_flow: sourceDiscoveryBidirectionalValueFlow ?? undefined,
|
||||
previous_discovery_document_summary: sourceDiscoveryDocumentSummary ?? undefined,
|
||||
resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined,
|
||||
root_context_only: rootScopedPivot || undefined,
|
||||
root_intent: shouldAttachInventoryRootFrame ? inventoryRootFrame?.intent ?? undefined : undefined,
|
||||
|
|
|
|||
|
|
@ -80,21 +80,24 @@ function groundingStatusFrom(debug, input, truthGateStatus) {
|
|||
}
|
||||
function coverageStatusFrom(debug, input, truthGateStatus, groundingStatus) {
|
||||
const explicitCoverageEvidence = (0, addressCoverageEvidencePolicy_1.toAddressCoverageEvidenceContract)(debug.address_coverage_evidence_v1);
|
||||
if (truthGateStatus === "full_confirmed") {
|
||||
return "full";
|
||||
}
|
||||
if (truthGateStatus === "partial_supported" || truthGateStatus === "limited_temporal_or_contextual") {
|
||||
return "partial";
|
||||
}
|
||||
if (truthGateStatus.startsWith("blocked")) {
|
||||
return "blocked";
|
||||
}
|
||||
if (toStringList(debug.missing_required_filters).length > 0 || groundingStatus === "route_mismatch_blocked" || groundingStatus === "no_grounded_answer") {
|
||||
return "blocked";
|
||||
}
|
||||
if (truthGateStatus === "partial_supported" || truthGateStatus === "limited_temporal_or_contextual") {
|
||||
return "partial";
|
||||
}
|
||||
if (explicitCoverageEvidence) {
|
||||
return explicitCoverageEvidence.coverage_status;
|
||||
}
|
||||
if (debug.balance_confirmed === false || toNonEmptyString(debug.result_mode) === "heuristic_candidates") {
|
||||
return "partial";
|
||||
}
|
||||
if (truthGateStatus === "full_confirmed") {
|
||||
return "full";
|
||||
}
|
||||
const coverageReport = toRecordObject(input.coverageReport) ?? toRecordObject(debug.coverage_report);
|
||||
if (coverageReport) {
|
||||
const total = asNumber(coverageReport.requirements_total);
|
||||
|
|
@ -123,10 +126,16 @@ function truthModeFrom(input) {
|
|||
if (input.truthGateStatus === "blocked_missing_anchor" || toStringList(input.debug.missing_required_filters).length > 0) {
|
||||
return "clarification_required";
|
||||
}
|
||||
if (input.truthGateStatus === "full_confirmed" || (input.coverageStatus === "full" && input.groundingStatus === "grounded")) {
|
||||
if (input.coverageStatus === "partial") {
|
||||
return "limited";
|
||||
}
|
||||
if (input.truthGateStatus === "full_confirmed" && input.coverageStatus === "full") {
|
||||
return "confirmed";
|
||||
}
|
||||
if (input.truthGateStatus === "partial_supported" || input.truthGateStatus === "limited_temporal_or_contextual" || input.coverageStatus === "partial") {
|
||||
if (input.coverageStatus === "full" && input.groundingStatus === "grounded") {
|
||||
return "confirmed";
|
||||
}
|
||||
if (input.truthGateStatus === "partial_supported" || input.truthGateStatus === "limited_temporal_or_contextual") {
|
||||
return "limited";
|
||||
}
|
||||
return "unsupported";
|
||||
|
|
@ -140,6 +149,9 @@ function evidenceGradeFrom(debug, coverageStatus, groundingStatus, truthGateStat
|
|||
if (isEvidenceGrade(explicit)) {
|
||||
return explicit;
|
||||
}
|
||||
if (debug.balance_confirmed === false || toNonEmptyString(debug.result_mode) === "heuristic_candidates") {
|
||||
return coverageStatus === "partial" ? "medium" : "weak";
|
||||
}
|
||||
if (coverageStatus === "blocked") {
|
||||
return "none";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -384,6 +384,15 @@ function extractMonthPeriod(text: string): { period_from?: string; period_to?: s
|
|||
return {};
|
||||
}
|
||||
|
||||
function isExactHistoricalPeriodWindow(filters: AddressFilterSet): boolean {
|
||||
return (
|
||||
typeof filters.period_from === "string" &&
|
||||
filters.period_from.trim().length > 0 &&
|
||||
typeof filters.period_to === "string" &&
|
||||
filters.period_to.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function extractPeriodRange(text: string): { period_from?: string; period_to?: string } {
|
||||
const directMatch = text.match(PERIOD_RANGE_PATTERN_1) ?? text.match(PERIOD_RANGE_PATTERN_2);
|
||||
if (!directMatch) {
|
||||
|
|
@ -810,6 +819,9 @@ function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean {
|
|||
if (meaningfulNonGenericTokens.length === 0 && (hasTemporalCue || paymentCue)) {
|
||||
return true;
|
||||
}
|
||||
if (meaningfulNonGenericTokens.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token));
|
||||
return meaningfulTokens.length === 0;
|
||||
}
|
||||
|
|
@ -1634,6 +1646,9 @@ function resolveSemanticDateBasisHint(filters: AddressFilterSet, warnings: strin
|
|||
const hasAsOfDate = typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0;
|
||||
const hasPeriodFrom = typeof filters.period_from === "string" && filters.period_from.trim().length > 0;
|
||||
const hasPeriodTo = typeof filters.period_to === "string" && filters.period_to.trim().length > 0;
|
||||
if (warnings.includes("as_of_date_derived_from_exact_historical_period") && (hasPeriodFrom || hasPeriodTo)) {
|
||||
return hasPeriodFrom && hasPeriodTo ? "period_range" : "period_end";
|
||||
}
|
||||
if (hasPeriodFrom && hasPeriodTo) {
|
||||
return "period_range";
|
||||
}
|
||||
|
|
@ -1938,6 +1953,16 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
|||
}
|
||||
}
|
||||
|
||||
if (isExactHistoricalPeriodWindow(filters) && !warnings.includes("exact_historical_period_window_requested")) {
|
||||
const derivedFromHistoricalPhrase =
|
||||
warnings.includes("period_derived_from_month_phrase") ||
|
||||
warnings.includes("period_derived_from_year_range_phrase") ||
|
||||
warnings.includes("period_derived_from_year_phrase");
|
||||
if (derivedFromHistoricalPhrase) {
|
||||
warnings.push("exact_historical_period_window_requested");
|
||||
}
|
||||
}
|
||||
|
||||
const vatAsOfDate = explicitAsOfDateWithCue ?? explicitAsOfDate;
|
||||
if (intent === "vat_payable_forecast" && vatAsOfDate && !periodRange.period_from && !periodRange.period_to) {
|
||||
const quarterWindow = deriveQuarterWindowForDate(vatAsOfDate);
|
||||
|
|
@ -1954,11 +1979,14 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
|||
}
|
||||
}
|
||||
const monthPeriodWasDerived = warnings.includes("period_derived_from_month_phrase");
|
||||
const yearPeriodWasDerived =
|
||||
warnings.includes("period_derived_from_year_phrase") || warnings.includes("period_derived_from_year_range_phrase");
|
||||
if (
|
||||
intent === "vat_liability_confirmed_for_tax_period" &&
|
||||
!periodRange.period_from &&
|
||||
!periodRange.period_to &&
|
||||
!monthPeriodWasDerived
|
||||
!monthPeriodWasDerived &&
|
||||
!yearPeriodWasDerived
|
||||
) {
|
||||
const periodToForQuarter = filters.period_to ?? vatAsOfDate ?? null;
|
||||
if (periodToForQuarter) {
|
||||
|
|
@ -1986,7 +2014,12 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
|||
warnings.includes("period_derived_from_year_range_phrase") ||
|
||||
warnings.includes("period_derived_from_year_phrase");
|
||||
const preserveDerivedPeriodWindow =
|
||||
intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date";
|
||||
usesAsOfPrimaryWindow(intent) ||
|
||||
intent === "inventory_on_hand_as_of_date" ||
|
||||
intent === "inventory_supplier_stock_overlap_as_of_date";
|
||||
if (periodWasDerivedHeuristically && !warnings.includes("exact_historical_period_window_requested")) {
|
||||
warnings.push("exact_historical_period_window_requested");
|
||||
}
|
||||
if (periodWasDerivedHeuristically && !periodRange.period_from && !periodRange.period_to && !preserveDerivedPeriodWindow) {
|
||||
delete filters.period_from;
|
||||
delete filters.period_to;
|
||||
|
|
@ -2016,6 +2049,16 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
|||
if (filters.period_to) {
|
||||
filters.as_of_date = filters.period_to;
|
||||
warnings.push("as_of_date_derived_from_period_to");
|
||||
if (
|
||||
warnings.includes("period_derived_from_month_phrase") ||
|
||||
warnings.includes("period_derived_from_year_range_phrase") ||
|
||||
warnings.includes("period_derived_from_year_phrase")
|
||||
) {
|
||||
warnings.push("as_of_date_derived_from_exact_historical_period");
|
||||
if (!warnings.includes("exact_historical_period_window_requested")) {
|
||||
warnings.push("exact_historical_period_window_requested");
|
||||
}
|
||||
}
|
||||
} else if (shouldDefaultAsOfDateToToday(intent)) {
|
||||
filters.as_of_date = new Date().toISOString().slice(0, 10);
|
||||
warnings.push("as_of_date_defaulted_today");
|
||||
|
|
|
|||
|
|
@ -2232,6 +2232,18 @@ function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolutio
|
|||
const hasRankingCue = /(?:топ|ранк|сам(?:ый|ая|ое|ые)|больше\s+всего|наибольш|крупн|жирн|max|top|rank)/iu.test(
|
||||
normalized
|
||||
);
|
||||
const hasInventoryPurchaseToSaleDocumentChainCue =
|
||||
/(?:закупк[а-яё]*[\s\S]{0,80}склад[\s\S]{0,80}продаж|путь\s+товар[а-яё]*[\s\S]{0,80}закуп|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|->\s*(?:склад|warehouse|stock)\s*->\s*(?:продаж|sale))/iu.test(
|
||||
normalized
|
||||
) && /(?:товар|позици|номенклатур|sku|item|product)/iu.test(normalized);
|
||||
if (hasInventoryPurchaseToSaleDocumentChainCue) {
|
||||
return unicodeBridgeResolution(
|
||||
"inventory_purchase_to_sale_chain",
|
||||
"high",
|
||||
"unicode_inventory_purchase_to_sale_chain_bridge_signal_detected"
|
||||
);
|
||||
}
|
||||
|
||||
const hasSelectedObjectProfitabilityCue =
|
||||
/(?:\u043f\u043e\s+\u0432\u044b\u0431\u0440\u0430\u043d\u043d(?:\u043e\u043c\u0443|\u043e\u0439)\s+(?:\u043e\u0431\u044a\u0435\u043a\u0442\u0443|\u043f\u043e\u0437\u0438\u0446\u0438\u0438)|selected\s+object)/iu.test(
|
||||
normalized
|
||||
|
|
@ -2247,18 +2259,6 @@ function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolutio
|
|||
);
|
||||
}
|
||||
|
||||
const hasInventoryPurchaseToSaleDocumentChainCue =
|
||||
/(?:закупк[а-яё]*[\s\S]{0,80}склад[\s\S]{0,80}продаж|путь\s+товар[а-яё]*[\s\S]{0,80}закуп|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|->\s*(?:склад|warehouse|stock)\s*->\s*(?:продаж|sale))/iu.test(
|
||||
normalized
|
||||
) && /(?:товар|позици|номенклатур|sku|item|product)/iu.test(normalized);
|
||||
if (hasInventoryPurchaseToSaleDocumentChainCue) {
|
||||
return unicodeBridgeResolution(
|
||||
"inventory_purchase_to_sale_chain",
|
||||
"high",
|
||||
"unicode_inventory_purchase_to_sale_chain_bridge_signal_detected"
|
||||
);
|
||||
}
|
||||
|
||||
const hasOpenItemsAccountCue =
|
||||
/(?:хвост|долг|незакрыт|вис)/iu.test(normalized) &&
|
||||
/(?:сч(?:е|ё)т(?:а|у|ом|е|ов)?\s*(?:№|#)?\s*(?:60|62|76)(?:[.,]\d{1,2})?|\b(?:60|62|76)(?:[.,]\d{1,2})?\b\s*сч(?:е|ё)т)/iu.test(
|
||||
|
|
|
|||
|
|
@ -2194,7 +2194,8 @@ function enforceStrictAccountScopeForIntent(
|
|||
|
||||
function resolveExecutionFiltersForConfirmedBalance(
|
||||
filters: AddressFilterSet,
|
||||
analysisDate: string | null
|
||||
analysisDate: string | null,
|
||||
warnings: string[] = []
|
||||
): {
|
||||
executionFilters: AddressFilterSet;
|
||||
asOfDerived: string | null;
|
||||
|
|
@ -2208,8 +2209,10 @@ function resolveExecutionFiltersForConfirmedBalance(
|
|||
if (derivedAsOf) {
|
||||
executionFilters.as_of_date = derivedAsOf;
|
||||
}
|
||||
delete executionFilters.period_from;
|
||||
delete executionFilters.period_to;
|
||||
if (!warnings.includes("as_of_date_derived_from_exact_historical_period")) {
|
||||
delete executionFilters.period_from;
|
||||
delete executionFilters.period_to;
|
||||
}
|
||||
const limit =
|
||||
typeof executionFilters.limit === "number" && Number.isFinite(executionFilters.limit)
|
||||
? Math.max(1, Math.trunc(executionFilters.limit))
|
||||
|
|
@ -2415,6 +2418,9 @@ function asksForUnresolvedInventorySupplierLink(userMessage: string | null | und
|
|||
}
|
||||
|
||||
function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilterSet): boolean {
|
||||
if (Array.isArray((filters as { warnings?: unknown }).warnings) && (filters as { warnings?: string[] }).warnings?.includes("exact_historical_period_window_requested")) {
|
||||
return false;
|
||||
}
|
||||
const hasRecoverableAsOfOnlyWindow =
|
||||
!hasExplicitPeriodWindow(filters) &&
|
||||
typeof filters.as_of_date === "string" &&
|
||||
|
|
@ -3713,16 +3719,16 @@ export class AddressQueryService {
|
|||
intent.intent === "inventory_on_hand_as_of_date" && requestedResultMode === "confirmed_balance";
|
||||
const payablesConfirmedExecution =
|
||||
confirmedBalancePayablesIntent
|
||||
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
|
||||
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings)
|
||||
: null;
|
||||
const receivablesConfirmedExecution = confirmedBalanceReceivablesIntent
|
||||
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
|
||||
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings)
|
||||
: null;
|
||||
const vatPayableConfirmedExecution = confirmedBalanceVatPayableIntent
|
||||
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
|
||||
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings)
|
||||
: null;
|
||||
const inventoryConfirmedExecution = confirmedBalanceInventoryIntent
|
||||
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
|
||||
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings)
|
||||
: null;
|
||||
let executionFilters =
|
||||
inventoryConfirmedExecution?.executionFilters ??
|
||||
|
|
@ -5145,6 +5151,7 @@ export class AddressQueryService {
|
|||
!counterpartyItemFlowQuery &&
|
||||
isDocumentOrBankAnchorIntent(intent.intent) &&
|
||||
!hasExplicitPeriodWindow(filters.extracted_filters) &&
|
||||
!filters.warnings.some((warning) => warning.startsWith("period_derived_from_")) &&
|
||||
(anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract")
|
||||
) {
|
||||
const currentLimit =
|
||||
|
|
|
|||
|
|
@ -175,6 +175,12 @@ function truthGateStatusFrom(input: ResolveAddressTruthGateInput): AssistantTrut
|
|||
return input.truthGateStatusHint;
|
||||
}
|
||||
const missingRequiredFilters = input.missingRequiredFilters ?? [];
|
||||
const reasonCodes = input.reasons ?? [];
|
||||
const heuristicOpenItemsFallback = Boolean(
|
||||
input.intent === "open_items_by_counterparty_or_contract" &&
|
||||
(reasonCodes.includes("confirmed_balance_unavailable_fallback_to_heuristic_candidates") ||
|
||||
reasonCodes.includes("open_items_account_query_override_to_movements"))
|
||||
);
|
||||
if (input.routeExpectationStatus === "mismatch") {
|
||||
return "blocked_route_expectation_failure";
|
||||
}
|
||||
|
|
@ -190,6 +196,9 @@ function truthGateStatusFrom(input: ResolveAddressTruthGateInput): AssistantTrut
|
|||
if (input.replyType === "factual" && input.limitedReasonCategory === "empty_match") {
|
||||
return "full_confirmed";
|
||||
}
|
||||
if (heuristicOpenItemsFallback) {
|
||||
return "partial_supported";
|
||||
}
|
||||
if (
|
||||
input.limitedReasonCategory === "empty_match" ||
|
||||
input.limitedReasonCategory === "recipe_visibility_gap" ||
|
||||
|
|
|
|||
|
|
@ -4198,12 +4198,23 @@ function composeFactualReplyBody(
|
|||
|
||||
if (intent === "open_items_by_counterparty_or_contract") {
|
||||
const counterparties = buildCounterpartyRiskAggregate(rows);
|
||||
const accountLead =
|
||||
const accountLabel =
|
||||
typeof options.accountHint === "string" && options.accountHint.trim().length > 0
|
||||
? `Проверил хвосты по счету ${options.accountHint.trim()}.`
|
||||
: "Собраны открытые позиции по взаиморасчетам.";
|
||||
? `по счету ${options.accountHint.trim()}`
|
||||
: "по взаиморасчетам";
|
||||
const exactBalanceRequested = options.requestedResultMode === "confirmed_balance";
|
||||
const periodLabel = options.asOfDate
|
||||
? `на ${formatDateRu(options.asOfDate)}`
|
||||
: options.periodFrom || options.periodTo
|
||||
? `за период ${formatDateRu(options.periodFrom ?? "...")}..${formatDateRu(options.periodTo ?? "...")}`
|
||||
: null;
|
||||
const lines = [
|
||||
accountLead,
|
||||
exactBalanceRequested
|
||||
? `Коротко: точный открытый остаток ${accountLabel}${periodLabel ? ` ${periodLabel}` : ""} не подтвержден; ниже только предварительные сигналы по движениям: ${formatNumberWithDots(rows.length)} строк, контрагентов с сигналом: ${formatNumberWithDots(counterparties.length)}.`
|
||||
: `Коротко: ${accountLabel} найдено ${formatNumberWithDots(rows.length)} строк хвостов/открытых расчетов; контрагентов с сигналом: ${formatNumberWithDots(counterparties.length)}.`,
|
||||
exactBalanceRequested
|
||||
? "Это не подтвержденное сальдо и не финальный реестр открытых расчетов: текущий контур видит движения-кандидаты, но не доказывает остаток закрытия."
|
||||
: "Это shortlist для проверки, а не финальный подтвержденный реестр открытых расчетов.",
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
`Контрагентов с сигналом: ${counterparties.length}.`
|
||||
];
|
||||
|
|
@ -4223,7 +4234,12 @@ function composeFactualReplyBody(
|
|||
}
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
text: lines.join("\n"),
|
||||
semantics: {
|
||||
result_mode: "heuristic_candidates",
|
||||
evidence_strength: counterparties.length > 0 || rows.length > 0 ? "medium" : "weak",
|
||||
balance_confirmed: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -4310,7 +4326,7 @@ function composeFactualReplyBody(
|
|||
: `Найдено документов по контрагенту: ${rows.length}.`
|
||||
);
|
||||
}
|
||||
if (counterpartyLabel) {
|
||||
if (counterpartyLabel && itemFlowQuestion) {
|
||||
lines.push(`Контрагент: ${counterpartyLabel}`);
|
||||
}
|
||||
if (itemFlowQuestion) {
|
||||
|
|
@ -4330,7 +4346,11 @@ function composeFactualReplyBody(
|
|||
lines.push(`Показаны первые 12 из ${rows.length} поставок.`);
|
||||
}
|
||||
} else {
|
||||
lines.push(...formatTopRows(rows, rows.length));
|
||||
const visibleRows = rows.slice(0, 5);
|
||||
lines.push(...formatTopRows(visibleRows, visibleRows.length));
|
||||
if (rows.length > visibleRows.length) {
|
||||
lines.push(`Показаны первые ${visibleRows.length} из ${rows.length} документов; полный список остается в подтвержденном срезе.`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
|
|
|
|||
|
|
@ -259,11 +259,18 @@ const FOLLOWUP_LOW_QUALITY_COUNTERPARTY_TOKENS = new Set([
|
|||
"сейчас",
|
||||
"этому",
|
||||
"этомуже",
|
||||
"этой",
|
||||
"этойже",
|
||||
"тому",
|
||||
"томуже",
|
||||
"той",
|
||||
"тойже",
|
||||
"нему",
|
||||
"ней",
|
||||
"ним",
|
||||
"цепочка",
|
||||
"цепочке",
|
||||
"цепочку",
|
||||
"неуказанному",
|
||||
"неуказанный",
|
||||
"неуказанная",
|
||||
|
|
|
|||
|
|
@ -182,10 +182,12 @@ export function composeInventoryReply(
|
|||
const lines: string[] = [directAnswerLine];
|
||||
|
||||
if (positions.length > 0) {
|
||||
const visiblePositionsLimit = 6;
|
||||
const visiblePositions = positions.slice(0, visiblePositionsLimit);
|
||||
appendInventorySection(
|
||||
lines,
|
||||
"Позиции:",
|
||||
positions.slice(0, 20).map((item, index) =>
|
||||
visiblePositions.map((item, index) =>
|
||||
formatInventorySnapshotPositionLine(item, index, {
|
||||
formatDateRu: deps.formatDateRu,
|
||||
formatNumberWithDots: deps.formatNumberWithDots,
|
||||
|
|
@ -193,6 +195,11 @@ export function composeInventoryReply(
|
|||
})
|
||||
)
|
||||
);
|
||||
if (positions.length > visiblePositions.length) {
|
||||
lines.push(
|
||||
`Показаны первые ${deps.formatNumberWithDots(visiblePositions.length)} из ${deps.formatNumberWithDots(positions.length)} позиций по сумме; полный список можно раскрыть отдельным запросом.`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
appendInventorySection(lines, "Позиции:", [
|
||||
"- На дату среза товары с ненулевым остатком не найдены."
|
||||
|
|
|
|||
|
|
@ -271,6 +271,7 @@ export async function runAssistantLivingChatRuntime(
|
|||
organization: scopedOrganization,
|
||||
addressDebug: lastMemoryAddressDebug,
|
||||
sessionItems: input.sessionItems,
|
||||
userMessage,
|
||||
toNonEmptyString: input.toNonEmptyString
|
||||
});
|
||||
activeOrganization = scopedOrganization ?? activeOrganization;
|
||||
|
|
|
|||
|
|
@ -185,12 +185,13 @@ function timeScopeNeedFor(input: {
|
|||
family: string | null;
|
||||
explicitDateScope: string | null;
|
||||
allTimeScopeHint: boolean;
|
||||
subjectScopedBidirectionalAllTime: boolean;
|
||||
}): string | null {
|
||||
if (input.explicitDateScope) {
|
||||
return "explicit_period";
|
||||
}
|
||||
if (
|
||||
input.allTimeScopeHint &&
|
||||
(input.allTimeScopeHint || input.subjectScopedBidirectionalAllTime) &&
|
||||
(input.family === "value_flow" || input.family === "movement_evidence" || input.family === "document_evidence")
|
||||
) {
|
||||
return "all_time_scope";
|
||||
|
|
@ -515,6 +516,11 @@ export function buildAssistantMcpDiscoveryDataNeedGraph(
|
|||
const comparisonNeed = comparisonNeedFor(action);
|
||||
const rankingNeed = rankingNeedFromRawUtterance(rawUtterance) ?? seededRankingNeed;
|
||||
const allTimeScopeHint = hasAllTimeScopeHint(rawUtterance);
|
||||
const subjectScopedBidirectionalAllTime =
|
||||
businessFactFamily === "value_flow" &&
|
||||
comparisonNeed === "incoming_vs_outgoing" &&
|
||||
subjectCandidates.length > 0 &&
|
||||
!explicitDateScope;
|
||||
const directBusinessOverviewMoneyAnswerHint = hasBusinessOverviewDirectMoneyAnswerHint({
|
||||
family: businessFactFamily,
|
||||
rawUtterance,
|
||||
|
|
@ -576,7 +582,8 @@ export function buildAssistantMcpDiscoveryDataNeedGraph(
|
|||
const timeScopeNeed = timeScopeNeedFor({
|
||||
family: businessFactFamily,
|
||||
explicitDateScope,
|
||||
allTimeScopeHint
|
||||
allTimeScopeHint,
|
||||
subjectScopedBidirectionalAllTime
|
||||
});
|
||||
if (timeScopeNeed === "period_required" && !explicitDateScope) {
|
||||
pushUnique(clarificationGaps, "period");
|
||||
|
|
@ -618,6 +625,9 @@ export function buildAssistantMcpDiscoveryDataNeedGraph(
|
|||
if (allTimeScopeHint) {
|
||||
pushReason(reasonCodes, "data_need_graph_all_time_scope_hint");
|
||||
}
|
||||
if (subjectScopedBidirectionalAllTime) {
|
||||
pushReason(reasonCodes, "data_need_graph_subject_bidirectional_value_flow_defaults_to_all_time_scope");
|
||||
}
|
||||
if (businessFactFamily === "business_overview" && !explicitDateScope) {
|
||||
pushReason(reasonCodes, "data_need_graph_business_overview_defaults_to_all_time_scope");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ export interface AssistantMcpDiscoveryTurnMeaningRef {
|
|||
asked_aggregation_axis?: string | null;
|
||||
seeded_ranking_need?: string | null;
|
||||
explicit_entity_candidates?: string[];
|
||||
business_overview_separate_entity_candidates?: string[];
|
||||
previous_counterparty_value_flow_bundle?: Record<string, unknown> | null;
|
||||
previous_counterparty_document_bundle?: Record<string, unknown> | null;
|
||||
metadata_ambiguity_entity_sets?: string[];
|
||||
metadata_scope_hint?: string | null;
|
||||
explicit_organization_scope?: string | null;
|
||||
|
|
@ -177,6 +180,7 @@ function normalizeTurnMeaning(
|
|||
const dateScope = toNonEmptyString(value.explicit_date_scope);
|
||||
const unsupported = toNonEmptyString(value.unsupported_but_understood_family);
|
||||
const entities = toStringList(value.explicit_entity_candidates);
|
||||
const businessOverviewSeparateEntities = toStringList(value.business_overview_separate_entity_candidates);
|
||||
const metadataAmbiguityEntitySets = toStringList(value.metadata_ambiguity_entity_sets);
|
||||
if (domain) {
|
||||
result.asked_domain_family = domain;
|
||||
|
|
@ -193,6 +197,9 @@ function normalizeTurnMeaning(
|
|||
if (entities.length > 0) {
|
||||
result.explicit_entity_candidates = entities;
|
||||
}
|
||||
if (businessOverviewSeparateEntities.length > 0) {
|
||||
result.business_overview_separate_entity_candidates = businessOverviewSeparateEntities;
|
||||
}
|
||||
if (metadataAmbiguityEntitySets.length > 0) {
|
||||
result.metadata_ambiguity_entity_sets = metadataAmbiguityEntitySets;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -430,22 +430,271 @@ function businessOverviewYearRowsLine(overview: Record<string, unknown>): string
|
|||
return values.length > 0 ? `По годам: ${sentenceAmount(joined) ?? joined}.` : null;
|
||||
}
|
||||
|
||||
function firstOverviewAxisLabel(rows: unknown, amountKey = "total_amount_human_ru"): string | null {
|
||||
const first = toRecordObject(Array.isArray(rows) ? rows[0] : null);
|
||||
const label = toNonEmptyString(first?.axis_value);
|
||||
const amount = moneyText(first?.[amountKey]);
|
||||
return label && amount ? `${label} — ${sentenceAmount(amount) ?? amount}` : null;
|
||||
}
|
||||
|
||||
function businessOverviewTaxLine(overview: Record<string, unknown>): string | null {
|
||||
const tax = toRecordObject(overview.tax_position);
|
||||
if (!tax) {
|
||||
return null;
|
||||
}
|
||||
const salesVat = moneyText(tax.sales_vat_amount_human_ru);
|
||||
const purchaseVat = moneyText(tax.purchase_vat_amount_human_ru);
|
||||
const netVat = moneyText(tax.net_vat_amount_human_ru);
|
||||
if (!salesVat && !purchaseVat && !netVat) {
|
||||
return null;
|
||||
}
|
||||
const direction =
|
||||
tax.net_vat_direction === "vat_to_pay"
|
||||
? "НДС к уплате"
|
||||
: tax.net_vat_direction === "vat_to_recover_or_offset"
|
||||
? "НДС к возмещению/зачету"
|
||||
: "чистая НДС-позиция";
|
||||
return `НДС: продажи ${salesVat ?? "0 руб."}, покупки ${purchaseVat ?? "0 руб."}, ${direction} ${sentenceAmount(netVat) ?? netVat ?? "0 руб."}.`;
|
||||
}
|
||||
|
||||
function businessOverviewDebtLine(overview: Record<string, unknown>): string | null {
|
||||
const debt = toRecordObject(overview.debt_position);
|
||||
if (!debt) {
|
||||
return null;
|
||||
}
|
||||
const receivables = moneyText(toRecordObject(debt.receivables)?.total_amount_human_ru);
|
||||
const payables = moneyText(toRecordObject(debt.payables)?.total_amount_human_ru);
|
||||
const net = moneyText(debt.net_debt_position_amount_human_ru);
|
||||
if (!receivables && !payables && !net) {
|
||||
return null;
|
||||
}
|
||||
const direction =
|
||||
debt.net_debt_position_direction === "net_payable" ? "кредиторка больше дебиторки" : "дебиторка больше кредиторки";
|
||||
return `Долги: дебиторка ${receivables ?? "0 руб."}, кредиторка ${payables ?? "0 руб."}, нетто ${sentenceAmount(net) ?? net ?? "0 руб."} (${direction}).`;
|
||||
}
|
||||
|
||||
function businessOverviewInventoryLine(overview: Record<string, unknown>): string | null {
|
||||
const inventory = toRecordObject(overview.inventory_position);
|
||||
if (!inventory) {
|
||||
return null;
|
||||
}
|
||||
const amount = moneyText(inventory.total_amount_human_ru);
|
||||
const rows = Number(inventory.rows_matched);
|
||||
const quantity = Number(inventory.total_quantity);
|
||||
if (!amount && !Number.isFinite(rows)) {
|
||||
return null;
|
||||
}
|
||||
const pieces = [
|
||||
Number.isFinite(rows) ? `${rows} позиций` : null,
|
||||
amount ? `на ${sentenceAmount(amount) ?? amount}` : null,
|
||||
Number.isFinite(quantity) && quantity > 0 ? `количество ${quantity}` : null
|
||||
].filter((item): item is string => Boolean(item));
|
||||
return pieces.length > 0 ? `Склад: ${pieces.join(", ")}.` : null;
|
||||
}
|
||||
|
||||
function rowCountText(value: unknown): string | null {
|
||||
const count = Number(value);
|
||||
return Number.isFinite(count) ? String(count) : null;
|
||||
}
|
||||
|
||||
function sideRowsText(side: Record<string, unknown> | null): string | null {
|
||||
const rowsWithAmount = rowCountText(side?.rows_with_amount);
|
||||
const rowsMatched = rowCountText(side?.rows_matched);
|
||||
if (rowsWithAmount && rowsMatched) {
|
||||
return `${rowsWithAmount} из ${rowsMatched}`;
|
||||
}
|
||||
return rowsWithAmount ?? rowsMatched;
|
||||
}
|
||||
|
||||
function sideDateText(side: Record<string, unknown> | null): string | null {
|
||||
const first = toNonEmptyString(side?.first_movement_date);
|
||||
const latest = toNonEmptyString(side?.latest_movement_date);
|
||||
if (first && latest) {
|
||||
return first === latest ? `дата ${first}` : `даты ${first}..${latest}`;
|
||||
}
|
||||
return first ? `первая дата ${first}` : latest ? `последняя дата ${latest}` : null;
|
||||
}
|
||||
|
||||
function bidirectionalNetLabel(direction: unknown): string {
|
||||
if (direction === "net_outgoing") {
|
||||
return "нетто в сторону контрагента";
|
||||
}
|
||||
if (direction === "balanced") {
|
||||
return "нетто около нуля";
|
||||
}
|
||||
return "нетто в нашу сторону";
|
||||
}
|
||||
|
||||
function buildCompactBidirectionalValueFlowReply(
|
||||
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract,
|
||||
draft: Record<string, unknown>
|
||||
): string | null {
|
||||
const bridge = toRecordObject(entryPoint.bridge);
|
||||
const pilot = toRecordObject(bridge?.pilot);
|
||||
const flow = toRecordObject(pilot?.derived_bidirectional_value_flow);
|
||||
if (!flow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const incoming = toRecordObject(flow.incoming_customer_revenue);
|
||||
const outgoing = toRecordObject(flow.outgoing_supplier_payout);
|
||||
const incomingAmount = moneyText(incoming?.total_amount_human_ru);
|
||||
const outgoingAmount = moneyText(outgoing?.total_amount_human_ru);
|
||||
const netAmount = moneyText(flow.net_amount_human_ru);
|
||||
if (!incomingAmount && !outgoingAmount && !netAmount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const counterparty = toNonEmptyString(flow.counterparty) ?? "запрошенному контрагенту";
|
||||
const period = toNonEmptyString(flow.period_scope);
|
||||
const periodText = period ? ` за период ${period}` : " в проверенном окне";
|
||||
const incomingRows = sideRowsText(incoming);
|
||||
const outgoingRows = sideRowsText(outgoing);
|
||||
const incomingDates = sideDateText(incoming);
|
||||
const outgoingDates = sideDateText(outgoing);
|
||||
const netLabel = bidirectionalNetLabel(flow.net_direction);
|
||||
const lines = [
|
||||
`Коротко: по контрагенту ${counterparty}${periodText} по найденным строкам 1С получили ${incomingAmount ?? "0 руб."}, заплатили ${outgoingAmount ?? "0 руб."}; расчетное ${netLabel}: ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.`
|
||||
];
|
||||
|
||||
const basis: string[] = [];
|
||||
if (incomingRows) {
|
||||
basis.push(`входящих строк с суммой ${incomingRows}${incomingDates ? ` (${incomingDates})` : ""}`);
|
||||
}
|
||||
if (outgoingRows) {
|
||||
basis.push(`исходящих строк с суммой ${outgoingRows}${outgoingDates ? ` (${outgoingDates})` : ""}`);
|
||||
}
|
||||
if (basis.length > 0) {
|
||||
lines.push(`Основа: ${basis.join("; ")}.`);
|
||||
}
|
||||
if (flow.coverage_limited_by_probe_limit === true) {
|
||||
lines.push("Важно: часть проверки уперлась в лимит строк, поэтому это проверенный срез найденных движений, а не гарантия полного периода.");
|
||||
}
|
||||
lines.push("Метод: нетто рассчитано как подтвержденные входящие строки 1С минус подтвержденные исходящие строки; это не полное бухгалтерское сальдо вне проверенного окна.");
|
||||
|
||||
const fallbackNextStep = toNonEmptyString(draft.next_step_line);
|
||||
if (fallbackNextStep) {
|
||||
lines.push(`Следующий шаг: ${localizeLine(fallbackNextStep)}`);
|
||||
}
|
||||
|
||||
const reply = lines.join("\n").trim();
|
||||
return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null;
|
||||
}
|
||||
|
||||
function compactComparable(value: string | null): string {
|
||||
return String(value ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/[«»"']/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function businessOverviewSeparateSubjectLabel(
|
||||
graph: Record<string, unknown> | null,
|
||||
turnMeaning: Record<string, unknown> | null,
|
||||
organizationScope: string | null
|
||||
): string | null {
|
||||
const candidates = uniqueStrings([
|
||||
...toStringList(turnMeaning?.business_overview_separate_entity_candidates),
|
||||
...toStringList(graph?.subject_candidates),
|
||||
...toStringList(turnMeaning?.explicit_entity_candidates)
|
||||
]);
|
||||
const organizationComparable = compactComparable(organizationScope);
|
||||
for (const candidate of candidates) {
|
||||
const text = toNonEmptyString(candidate);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const comparable = compactComparable(text);
|
||||
if (organizationComparable && comparable === organizationComparable) {
|
||||
continue;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sameBusinessSubject(left: string | null, right: string | null): boolean {
|
||||
const leftComparable = compactComparable(left);
|
||||
const rightComparable = compactComparable(right);
|
||||
return Boolean(leftComparable && rightComparable && leftComparable === rightComparable);
|
||||
}
|
||||
|
||||
function previousDocumentSummaryLine(
|
||||
bundle: Record<string, unknown> | null,
|
||||
separateSubject: string | null
|
||||
): string | null {
|
||||
if (!bundle || !sameBusinessSubject(toNonEmptyString(bundle.counterparty), separateSubject)) {
|
||||
return null;
|
||||
}
|
||||
const count = Number(bundle.document_count);
|
||||
if (!Number.isFinite(count) || count <= 0) {
|
||||
return null;
|
||||
}
|
||||
return `документы по цепочке: найдено ${count}`;
|
||||
}
|
||||
|
||||
function buildPreviousCounterpartyValueFlowSummary(
|
||||
flow: Record<string, unknown> | null,
|
||||
separateSubject: string | null,
|
||||
documentBundle: Record<string, unknown> | null
|
||||
): { lead: string; line: string } | null {
|
||||
if (!flow || !separateSubject || !sameBusinessSubject(toNonEmptyString(flow.counterparty), separateSubject)) {
|
||||
return null;
|
||||
}
|
||||
const incoming = toRecordObject(flow.incoming_customer_revenue);
|
||||
const outgoing = toRecordObject(flow.outgoing_supplier_payout);
|
||||
const incomingAmount = moneyText(incoming?.total_amount_human_ru);
|
||||
const outgoingAmount = moneyText(outgoing?.total_amount_human_ru);
|
||||
const netAmount = moneyText(flow.net_amount_human_ru);
|
||||
if (!incomingAmount && !outgoingAmount && !netAmount) {
|
||||
return null;
|
||||
}
|
||||
const counterparty = toNonEmptyString(flow.counterparty) ?? separateSubject;
|
||||
const netLabel = bidirectionalNetLabel(flow.net_direction);
|
||||
const lead =
|
||||
`; отдельно по ${counterparty}: получили ${incomingAmount ?? "0 руб."}, заплатили ${outgoingAmount ?? "0 руб."}, ` +
|
||||
`${netLabel} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}`;
|
||||
const basis: string[] = [];
|
||||
const incomingRows = sideRowsText(incoming);
|
||||
const outgoingRows = sideRowsText(outgoing);
|
||||
const incomingDates = sideDateText(incoming);
|
||||
const outgoingDates = sideDateText(outgoing);
|
||||
if (incomingRows) {
|
||||
basis.push(`входящие строки ${incomingRows}${incomingDates ? ` (${incomingDates})` : ""}`);
|
||||
}
|
||||
if (outgoingRows) {
|
||||
basis.push(`исходящие строки ${outgoingRows}${outgoingDates ? ` (${outgoingDates})` : ""}`);
|
||||
}
|
||||
const documents = previousDocumentSummaryLine(documentBundle, counterparty);
|
||||
if (documents) {
|
||||
basis.push(documents);
|
||||
}
|
||||
const basisText = basis.length > 0 ? ` Основа: ${basis.join("; ")}.` : "";
|
||||
return {
|
||||
lead,
|
||||
line:
|
||||
`Отдельно по контрагенту ${counterparty}: подтверждено получили ${incomingAmount ?? "0 руб."}, ` +
|
||||
`заплатили ${outgoingAmount ?? "0 руб."}, расчетное ${netLabel} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.` +
|
||||
`${basisText} Это не перенос сумм компании на контрагента, а отдельный ранее подтвержденный контрагентский срез.`
|
||||
};
|
||||
}
|
||||
|
||||
function buildCompactBusinessOverviewReply(
|
||||
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract,
|
||||
draft: Record<string, unknown>
|
||||
): string | null {
|
||||
const turnInput = toRecordObject(entryPoint.turn_input);
|
||||
const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref);
|
||||
const graph = toRecordObject(turnInput?.data_need_graph);
|
||||
const bridge = toRecordObject(entryPoint.bridge);
|
||||
const pilot = toRecordObject(bridge?.pilot);
|
||||
const overview = toRecordObject(pilot?.derived_business_overview);
|
||||
const graphReasons = readStringArray(graph?.reason_codes);
|
||||
const isBusinessOverview =
|
||||
toNonEmptyString(graph?.business_fact_family) === "business_overview" ||
|
||||
toNonEmptyString(pilot?.pilot_scope) === "business_overview_route_template_v1";
|
||||
const rankingNeed = toNonEmptyString(graph?.ranking_need);
|
||||
const directMoneyAnswer = graphReasons.includes("data_need_graph_business_overview_direct_money_answer");
|
||||
if (!isBusinessOverview || !overview || (!rankingNeed && !directMoneyAnswer)) {
|
||||
if (!isBusinessOverview || !overview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -457,8 +706,51 @@ function buildCompactBusinessOverviewReply(
|
|||
const netDirection = overview.net_direction === "net_outgoing" ? "операционное нетто в минус" : "расчетное операционное нетто";
|
||||
const period = businessOverviewPeriodText(overview);
|
||||
const limitLine = businessOverviewCoverageLimitLine(overview);
|
||||
const organizationScope = toNonEmptyString(turnMeaning?.explicit_organization_scope);
|
||||
const separateSubject = businessOverviewSeparateSubjectLabel(graph, turnMeaning, organizationScope);
|
||||
const previousCounterpartySummary = buildPreviousCounterpartyValueFlowSummary(
|
||||
toRecordObject(turnMeaning?.previous_counterparty_value_flow_bundle),
|
||||
separateSubject,
|
||||
toRecordObject(turnMeaning?.previous_counterparty_document_bundle)
|
||||
);
|
||||
const organizationPrefix = organizationScope ? `по компании ${organizationScope} ` : "";
|
||||
const separateSubjectLead = separateSubject
|
||||
? previousCounterpartySummary?.lead ??
|
||||
`; по контрагенту ${separateSubject} суммы компании не переношу, это отдельный контур без подтвержденного итога в этой строке`
|
||||
: "";
|
||||
const topCustomer = toRecordObject(Array.isArray(overview.top_customers) ? overview.top_customers[0] : null);
|
||||
const customerName = toNonEmptyString(topCustomer?.axis_value);
|
||||
const customerAmount = moneyText(topCustomer?.total_amount_human_ru);
|
||||
const topCustomerLead =
|
||||
customerName && customerAmount
|
||||
? `; крупнейший источник входящих денег: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}`
|
||||
: "";
|
||||
const topSupplier = firstOverviewAxisLabel(overview.top_suppliers);
|
||||
const topSupplierLead = topSupplier ? `; крупнейший получатель исходящих денег: ${topSupplier}` : "";
|
||||
const roleBoundaryLead = topCustomer || topSupplier ? "; клиент/поставщик как бизнес-роли этим денежным срезом не подтверждены" : "";
|
||||
const graphReasonCodes = toStringList(graph?.reason_codes);
|
||||
const directMoneyAnswer = graphReasonCodes.includes("data_need_graph_business_overview_direct_money_answer");
|
||||
const crossScopeExecutiveSummary = Boolean(separateSubject && previousCounterpartySummary);
|
||||
const lines: string[] = [];
|
||||
|
||||
if (crossScopeExecutiveSummary && separateSubject && previousCounterpartySummary && (incomingAmount || outgoingAmount || netAmount)) {
|
||||
lines.push(
|
||||
`Коротко: по компании ${organizationScope ?? "в выбранном контуре"} ${period} подтвержден денежный срез: получили ${incomingAmount ?? "0 руб."}, исходящие платежи/списания ${outgoingAmount ?? "0 руб."}, ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}${previousCounterpartySummary.lead}; можно утверждать только эти подтвержденные срезы, нельзя называть это чистой прибылью, полным оборотом или доказанной ролью главного клиента/поставщика.`
|
||||
);
|
||||
lines.push(previousCounterpartySummary.line);
|
||||
lines.push(
|
||||
`Можно утверждать: по компании подтвержден operating-flow proxy по найденным строкам 1С; по ${separateSubject} отдельно подтверждены входящие/исходящие строки, расчетное нетто и документы из предыдущего контрагентского среза.`
|
||||
);
|
||||
lines.push(
|
||||
`Нельзя утверждать: это не чистая прибыль, не полный бухгалтерский оборот вне проверенного окна и не доказательство, что ${separateSubject} является главным клиентом или поставщиком как бизнес-роль.`
|
||||
);
|
||||
if (limitLine) {
|
||||
lines.push(limitLine);
|
||||
}
|
||||
const reply = lines.join("\n").trim();
|
||||
return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null;
|
||||
}
|
||||
|
||||
if (rankingNeed) {
|
||||
const incomingLeader = strongestIncomingYear(overview);
|
||||
const netLeader = strongestNetYear(overview);
|
||||
|
|
@ -469,7 +761,7 @@ function buildCompactBusinessOverviewReply(
|
|||
return null;
|
||||
}
|
||||
lines.push(
|
||||
`Коротко: самый доходный год в доступном денежном контуре 1С — ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}.`
|
||||
`Коротко: в доступном проверенном MCP-срезе по входящим денежным строкам лидирует ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}; это не полный бухгалтерский рейтинг доходности.`
|
||||
);
|
||||
const netYear = toNonEmptyString(netLeader?.year_bucket);
|
||||
const netYearAmount = moneyText(netLeader?.net_amount_human_ru);
|
||||
|
|
@ -487,19 +779,62 @@ function buildCompactBusinessOverviewReply(
|
|||
}
|
||||
} else if (incomingAmount || outgoingAmount || netAmount) {
|
||||
lines.push(
|
||||
`Коротко: ${period} по подтвержденным строкам 1С получили ${incomingAmount ?? "0 руб."}; исходящие платежи/списания ${outgoingAmount ?? "0 руб."}; ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб"}.`
|
||||
`Коротко: ${organizationPrefix}${period} по подтвержденным строкам 1С получили ${incomingAmount ?? "0 руб."}; исходящие платежи/списания ${outgoingAmount ?? "0 руб."}; ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб"}${topCustomerLead}${topSupplierLead}${roleBoundaryLead}${separateSubjectLead}.`
|
||||
);
|
||||
lines.push('Метод: "заработали" здесь считаю как денежный operating-flow proxy по 1С; это не чистая прибыль и не финрезультат.');
|
||||
const topCustomer = toRecordObject(Array.isArray(overview.top_customers) ? overview.top_customers[0] : null);
|
||||
const customerName = toNonEmptyString(topCustomer?.axis_value);
|
||||
const customerAmount = moneyText(topCustomer?.total_amount_human_ru);
|
||||
if (customerName && customerAmount) {
|
||||
if (!directMoneyAnswer && customerName && customerAmount) {
|
||||
lines.push(`Крупнейший подтвержденный источник входящих денег в этом срезе: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}.`);
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (separateSubject) {
|
||||
lines.push(
|
||||
previousCounterpartySummary?.line ??
|
||||
`Отдельно по контрагенту ${separateSubject}: этот итог не переносит суммы компании на контрагента. Можно утверждать только разделение контура; нельзя делать вывод о выручке, долге или прибыльности ${separateSubject} без отдельного контрагентского среза документов и движений.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!directMoneyAnswer && topSupplier) {
|
||||
lines.push(`Крупнейший подтвержденный получатель исходящих денег: ${topSupplier}.`);
|
||||
}
|
||||
if (!directMoneyAnswer && (topCustomer || topSupplier)) {
|
||||
lines.push("Важно по ролям: текущий денежный срез подтверждает денежные источники и получателей, но не доказывает, что это главный клиент или главный поставщик как бизнес-роль.");
|
||||
}
|
||||
if (!directMoneyAnswer) {
|
||||
lines.push(
|
||||
`Что подтверждено: денежный срез по компании${organizationScope ? ` ${organizationScope}` : ""}${period ? ` ${period}` : ""}${topCustomer ? ", крупнейший источник входящих денег" : ""}${topSupplier ? ", крупнейший получатель исходящих денег" : ""}.`
|
||||
);
|
||||
const taxLine = businessOverviewTaxLine(overview);
|
||||
if (taxLine) {
|
||||
lines.push(taxLine);
|
||||
}
|
||||
const debtLine = businessOverviewDebtLine(overview);
|
||||
if (debtLine) {
|
||||
lines.push(debtLine);
|
||||
}
|
||||
const inventoryLine = businessOverviewInventoryLine(overview);
|
||||
if (inventoryLine) {
|
||||
lines.push(inventoryLine);
|
||||
}
|
||||
const missingOverviewFamilies: string[] = [];
|
||||
if (!taxLine) {
|
||||
missingOverviewFamilies.push("общая НДС/налоговая позиция без отдельного точного расчета");
|
||||
}
|
||||
if (!debtLine) {
|
||||
missingOverviewFamilies.push("долги без даты среза");
|
||||
}
|
||||
if (!inventoryLine) {
|
||||
missingOverviewFamilies.push("склад без даты среза");
|
||||
}
|
||||
if (missingOverviewFamilies.length > 0) {
|
||||
lines.push(`Что не подтверждено в этом срезе: ${missingOverviewFamilies.join(", ")}.`);
|
||||
}
|
||||
lines.push(
|
||||
"Что нельзя утверждать: чистую прибыль, полноценный финрезультат, юридические бизнес-роли клиентов/поставщиков и общую налоговую позицию без отдельного точного расчета."
|
||||
);
|
||||
}
|
||||
if (limitLine) {
|
||||
lines.push(limitLine);
|
||||
}
|
||||
|
|
@ -556,6 +891,11 @@ function buildReplyText(entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContra
|
|||
return null;
|
||||
}
|
||||
|
||||
const compactBidirectionalValueFlowReply = buildCompactBidirectionalValueFlowReply(entryPoint, draft);
|
||||
if (compactBidirectionalValueFlowReply) {
|
||||
return compactBidirectionalValueFlowReply;
|
||||
}
|
||||
|
||||
const compactBusinessOverviewReply = buildCompactBusinessOverviewReply(entryPoint, draft);
|
||||
if (compactBusinessOverviewReply) {
|
||||
return compactBusinessOverviewReply;
|
||||
|
|
|
|||
|
|
@ -344,6 +344,19 @@ function readStateTransitionReasonCodes(input: ApplyAssistantMcpDiscoveryRespons
|
|||
.filter((item): item is string => Boolean(item));
|
||||
}
|
||||
|
||||
function hasFullConfirmedTruth(input: ApplyAssistantMcpDiscoveryResponsePolicyInput): boolean {
|
||||
const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status);
|
||||
if (truthGateStatus === "full_confirmed") {
|
||||
return true;
|
||||
}
|
||||
const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1);
|
||||
const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate);
|
||||
const sourceTruthGateStatus = toNonEmptyString(truthGate?.source_truth_gate_status);
|
||||
const coverageStatus = toNonEmptyString(truthGate?.coverage_status);
|
||||
const groundingStatus = toNonEmptyString(truthGate?.grounding_status);
|
||||
return sourceTruthGateStatus === "full_confirmed" || (coverageStatus === "full" && groundingStatus === "grounded");
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.map((item) => toNonEmptyString(item)).filter((item): item is string => Boolean(item))
|
||||
|
|
@ -424,6 +437,12 @@ function hasExactMatchedFactualAddressReply(
|
|||
if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
|
||||
return false;
|
||||
}
|
||||
if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
|
||||
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
|
||||
if (!(isMetadataDiscoveryTurn(entryPoint) && isInventoryExactAddressIntent(detectedIntent))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const mcpCallStatus = toNonEmptyString(input.addressRuntimeMeta?.mcp_call_status);
|
||||
const truthMode = toNonEmptyString(input.addressRuntimeMeta?.truth_mode);
|
||||
const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe);
|
||||
|
|
@ -472,17 +491,7 @@ function hasRuntimeAdjustedExactReply(
|
|||
if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
|
||||
return false;
|
||||
}
|
||||
const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status);
|
||||
const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1);
|
||||
const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate);
|
||||
const sourceTruthGateStatus = toNonEmptyString(truthGate?.source_truth_gate_status);
|
||||
const coverageStatus = toNonEmptyString(truthGate?.coverage_status);
|
||||
const groundingStatus = toNonEmptyString(truthGate?.grounding_status);
|
||||
const hasFullConfirmedTruth =
|
||||
truthGateStatus === "full_confirmed" ||
|
||||
sourceTruthGateStatus === "full_confirmed" ||
|
||||
(coverageStatus === "full" && groundingStatus === "grounded");
|
||||
if (!hasFullConfirmedTruth) {
|
||||
if (!hasFullConfirmedTruth(input)) {
|
||||
return false;
|
||||
}
|
||||
const truthAnswerShape = readTruthAnswerShape(input);
|
||||
|
|
@ -495,6 +504,32 @@ function hasRuntimeAdjustedExactReply(
|
|||
);
|
||||
}
|
||||
|
||||
function hasRuntimeMatchedExactReply(
|
||||
input: ApplyAssistantMcpDiscoveryResponsePolicyInput,
|
||||
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null
|
||||
): boolean {
|
||||
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
|
||||
return false;
|
||||
}
|
||||
if (!hasEffectivelyFactualAddressReply(input)) {
|
||||
return false;
|
||||
}
|
||||
if (hasMetadataDiscoveryPriority(input, entryPoint)) {
|
||||
return false;
|
||||
}
|
||||
if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
|
||||
return false;
|
||||
}
|
||||
if (!hasFullConfirmedTruth(input)) {
|
||||
return false;
|
||||
}
|
||||
const reasonCodes = readStateTransitionReasonCodes(input);
|
||||
return (
|
||||
reasonCodes.some((reason) => reason === "route_expectation_matched") &&
|
||||
reasonCodes.some((reason) => /(?:confirmed_balance_exact|exact_.+_intent|vat_period_inspection_bridge_signal_detected)/iu.test(reason))
|
||||
);
|
||||
}
|
||||
|
||||
function hasAlignedFactualAddressReply(
|
||||
input: ApplyAssistantMcpDiscoveryResponsePolicyInput,
|
||||
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null
|
||||
|
|
@ -528,6 +563,9 @@ function hasSemanticConflictWithDiscoveryTurnMeaning(
|
|||
if (hasRuntimeAdjustedExactReply(input, entryPoint)) {
|
||||
return false;
|
||||
}
|
||||
if (hasRuntimeMatchedExactReply(input, entryPoint)) {
|
||||
return false;
|
||||
}
|
||||
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
|
||||
const turnMeaning = readDiscoveryTurnMeaning(entryPoint);
|
||||
const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family);
|
||||
|
|
@ -619,16 +657,7 @@ function hasFullConfirmedFactualAddressReply(
|
|||
if (hasMetadataDiscoveryPriority(input, entryPoint)) {
|
||||
return false;
|
||||
}
|
||||
const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status);
|
||||
if (truthGateStatus === "full_confirmed") {
|
||||
return true;
|
||||
}
|
||||
const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1);
|
||||
const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate);
|
||||
const sourceTruthGateStatus = toNonEmptyString(truthGate?.source_truth_gate_status);
|
||||
const coverageStatus = toNonEmptyString(truthGate?.coverage_status);
|
||||
const groundingStatus = toNonEmptyString(truthGate?.grounding_status);
|
||||
return sourceTruthGateStatus === "full_confirmed" || (coverageStatus === "full" && groundingStatus === "grounded");
|
||||
return hasFullConfirmedTruth(input);
|
||||
}
|
||||
|
||||
export function applyAssistantMcpDiscoveryResponsePolicy(
|
||||
|
|
@ -652,6 +681,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
|
|||
const fullConfirmedFactualAddressReply = hasFullConfirmedFactualAddressReply(input, entryPoint);
|
||||
const exactMatchedFactualAddressReply = hasExactMatchedFactualAddressReply(input, entryPoint);
|
||||
const runtimeAdjustedExactReply = hasRuntimeAdjustedExactReply(input, entryPoint);
|
||||
const runtimeMatchedExactReply = hasRuntimeMatchedExactReply(input, entryPoint);
|
||||
const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint);
|
||||
const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint);
|
||||
const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning(
|
||||
|
|
@ -714,6 +744,12 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
|
|||
"mcp_discovery_response_policy_keep_runtime_adjusted_exact_reply_over_stale_discovery_turn_meaning"
|
||||
);
|
||||
}
|
||||
if (runtimeMatchedExactReply) {
|
||||
pushReason(
|
||||
reasonCodes,
|
||||
"mcp_discovery_response_policy_keep_runtime_matched_exact_reply_over_stale_discovery_turn_meaning"
|
||||
);
|
||||
}
|
||||
if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") {
|
||||
pushReason(
|
||||
reasonCodes,
|
||||
|
|
@ -742,6 +778,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
|
|||
!fullConfirmedFactualAddressReply &&
|
||||
!exactMatchedFactualAddressReply &&
|
||||
!runtimeAdjustedExactReply &&
|
||||
!runtimeMatchedExactReply &&
|
||||
!(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") &&
|
||||
ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) &&
|
||||
candidate.eligible_for_future_hot_runtime &&
|
||||
|
|
|
|||
|
|
@ -259,6 +259,9 @@ function pushScopedEntityCandidate(
|
|||
) {
|
||||
return;
|
||||
}
|
||||
if (target.some((existing) => sameScopedName(existing, text))) {
|
||||
return;
|
||||
}
|
||||
pushUnique(target, text);
|
||||
}
|
||||
|
||||
|
|
@ -291,6 +294,20 @@ function sameScopedName(left: string | null, right: string | null): boolean {
|
|||
return Boolean(left && right && compactLower(left) === compactLower(right));
|
||||
}
|
||||
|
||||
function preferredScopedDisplayName(value: string | null, candidates: unknown[]): string | null {
|
||||
const anchor = toNonEmptyString(value);
|
||||
if (!anchor) {
|
||||
return null;
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
const text = candidateValue(candidate);
|
||||
if (sameScopedName(text, anchor)) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return anchor;
|
||||
}
|
||||
|
||||
function candidateValue(value: unknown): string | null {
|
||||
const direct = toNonEmptyString(value);
|
||||
if (direct && direct !== "[object Object]") {
|
||||
|
|
@ -612,6 +629,8 @@ function collectFollowupDiscoverySeed(followupContext: Record<string, unknown> |
|
|||
metadataRecommendedNextPrimitive: AssistantMcpDiscoveryMetadataRecommendedPrimitive | null;
|
||||
metadataAmbiguityDetected: boolean;
|
||||
metadataAmbiguityEntitySets: string[];
|
||||
previousBidirectionalValueFlow: Record<string, unknown> | null;
|
||||
previousDocumentSummary: Record<string, unknown> | null;
|
||||
} {
|
||||
const previousFilters = toRecordObject(followupContext?.previous_filters);
|
||||
const rootFilters = toRecordObject(followupContext?.root_filters);
|
||||
|
|
@ -717,7 +736,9 @@ function collectFollowupDiscoverySeed(followupContext: Record<string, unknown> |
|
|||
followupContext?.previous_discovery_metadata_recommended_next_primitive
|
||||
),
|
||||
metadataAmbiguityDetected: followupContext?.previous_discovery_metadata_ambiguity_detected === true,
|
||||
metadataAmbiguityEntitySets: collectEntityCandidates(followupContext?.previous_discovery_metadata_ambiguity_entity_sets)
|
||||
metadataAmbiguityEntitySets: collectEntityCandidates(followupContext?.previous_discovery_metadata_ambiguity_entity_sets),
|
||||
previousBidirectionalValueFlow: toRecordObject(followupContext?.previous_discovery_bidirectional_value_flow),
|
||||
previousDocumentSummary: toRecordObject(followupContext?.previous_discovery_document_summary)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -896,8 +917,26 @@ function hasOrganizationLevelSupplierQualityOverviewSignal(text: string): boolea
|
|||
return hasSupplierScopeCue && hasSupplierQualityCue && hasCompanyScopeCue;
|
||||
}
|
||||
|
||||
function hasCrossScopeExecutiveSummarySignal(text: string): boolean {
|
||||
return (
|
||||
/(?:\u0441\u043e\u0431\u0435\u0440\p{L}*\s+(?:\u043a\u043e\u0440\u043e\u0442\u043a\p{L}*\s+)?\u0438\u0442\u043e\u0433|\u044d\u043a\u0437\u0435\u043a\u044c\u044e\u0442\u0438\u0432\p{L}*\s+\u0441\u0430\u043c\u043c\u0430\u0440\u0438|executive\s+summary|final\s+summary)/iu.test(
|
||||
text
|
||||
) &&
|
||||
/(?:\u0447\u0442\u043e\s+(?:\u043c\u044b\s+)?\u043f\u043e\u0434\u0442\u0432\u0435\u0440\p{L}*|\u043f\u043e\s+\u043a\u043e\u043c\u043f\u0430\u043d\p{L}*|\u043f\u043e\s+\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\p{L}*|confirmed|company|organization)/iu.test(
|
||||
text
|
||||
) &&
|
||||
/(?:\u043e\u0442\u0434\u0435\u043b\u044c\u043d\p{L}*\s+\u043f\u043e|\u043f\u043e\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0433\u0440\u0443\u043f\u043f\p{L}*\s+\u0441\u0432\u043a|\u0441\u0432\u043a|counterpart(?:y|ies)?)/iu.test(
|
||||
text
|
||||
) &&
|
||||
/(?:\u0447\u0442\u043e\s+\u043c\u043e\u0436\u043d\p{L}*|\u0447\u0442\u043e\s+\u043d\u0435\u043b\u044c\u0437\p{L}*|\u0432\u044b\u0432\u043e\u0434\p{L}*|allowed|forbidden|cannot|can\s+say)/iu.test(
|
||||
text
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function hasBusinessOverviewSignal(text: string): boolean {
|
||||
if (
|
||||
hasCrossScopeExecutiveSummarySignal(text) ||
|
||||
hasOrganizationLevelEarningsOverviewSignal(text) ||
|
||||
hasOrganizationLevelDebtPositionOverviewSignal(text) ||
|
||||
hasOrganizationLevelDebtDueDateOverviewSignal(text) ||
|
||||
|
|
@ -948,6 +987,43 @@ function hasBusinessOverviewContinuationSignal(text: string): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function hasExplicitVatQuestionSignal(text: string): boolean {
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
/(?:\u043d\u0434\u0441|vat)/iu.test(text) &&
|
||||
/(?:\u0437\u0430|\u043d\u0430|\u043f\u0435\u0440\u0438\u043e\u0434|\u043f\u043e\u0437\u0438\u0446|\u043a\s+\u0443\u043f\u043b\u0430\u0442|\u043a\s+\u0432\u043e\u0437\u043c\u0435\u0449|\u043e\u0441\u043d\u043e\u0432\u0430\u043d|\u043d\u0430\u043b\u043e\u0433\u043e\u0432\p{L}*\s+\u0432\u044b\u0432\u043e\u0434|tax\s+period|tax\s+position)/iu.test(
|
||||
text
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function hasBusinessOverviewSeparateCounterpartySignal(text: string): boolean {
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
/(?:\u043e\u0442\u0434\u0435\u043b\u044c\u043d\p{L}*\s+\u043f\u043e|\u043f\u043e\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|counterpart(?:y|ies)?)/iu.test(text) &&
|
||||
/(?:\u043a\u043e\u043c\u043f\u0430\u043d\p{L}*|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\p{L}*|company|organization|\u0438\u0442\u043e\u0433|summary|\u0432\u044b\u0432\u043e\u0434\p{L}*)/iu.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
function businessOverviewSeparateCounterpartyCandidateFromText(text: string): string | null {
|
||||
const source = repairAddressMojibakeText(String(text ?? ""));
|
||||
const patterns = [
|
||||
/(?:\u043e\u0442\u0434\u0435\u043b\u044c\u043d\p{L}*\s+\u043f\u043e|\u043f\u043e\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*)\s+(.+?)(?:[,.;:!?]|\s+\u043a\u0430\u043a\u0438\p{L}*\b|\s+\u0447\u0442\u043e\b|$)/iu,
|
||||
/(?:\u0434\u043b\u044f|for)\s+([\p{L}\d._-]+(?:\s+[\p{L}\d._-]+){0,3})(?:[,.;:!?]|\s+\u043a\u0430\u043a\u0438\p{L}*\b|\s+\u0447\u0442\u043e\b|$)/iu
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const candidate = normalizeFollowupCounterpartyCandidate(source.match(pattern)?.[1]);
|
||||
if (candidate && !isInvalidEntityCandidate(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasExplicitTopicSwitchSignal(text: string): boolean {
|
||||
return /(?:^|\s)(?:\u0442\u0435\u043f\u0435\u0440\u044c|\u0430\s+\u0442\u0435\u043f\u0435\u0440\u044c|\u0434\u0430\u043b\u044c\u0448\u0435|\u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e|\u043f\u0435\u0440\u0435\u0439\u0434[\u0451\u0435]\u043c|\u0441\u043c\u0435\u043d\u0438\u043c\s+\u0442\u0435\u043c\u0443|\u0432\u0435\u0440\u043d[\u0451\u0435]\u043c\u0441\u044f\s+\u043a|now|next|switch\s+to)(?:\s|$)/iu.test(
|
||||
text
|
||||
|
|
@ -1456,9 +1532,16 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
const rawReferentialDocumentExclusionSignal = hasReferentialDocumentExclusionFollowupSignal(
|
||||
repairedUserText ?? rawUserText ?? ""
|
||||
);
|
||||
const rawPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawText);
|
||||
const explicitVatQuestionSignal = hasExplicitVatQuestionSignal(rawText);
|
||||
const explicitVatSuppressesBusinessOverviewContinuation = Boolean(
|
||||
explicitVatQuestionSignal && !rawPrimaryBusinessOverviewSignal
|
||||
);
|
||||
const businessOverviewContinuationSignal =
|
||||
hasBusinessOverviewFollowupSeed(followupSeed) && hasBusinessOverviewContinuationSignal(rawText);
|
||||
const rawBusinessOverviewSignal = hasBusinessOverviewSignal(rawText) || businessOverviewContinuationSignal;
|
||||
hasBusinessOverviewFollowupSeed(followupSeed) &&
|
||||
hasBusinessOverviewContinuationSignal(rawText) &&
|
||||
!explicitVatSuppressesBusinessOverviewContinuation;
|
||||
const rawBusinessOverviewSignal = rawPrimaryBusinessOverviewSignal || businessOverviewContinuationSignal;
|
||||
const rawLifecycleSignal = !rawBusinessOverviewSignal && hasLifecycleSignal(rawText);
|
||||
const rawBidirectionalValueFlowSignal =
|
||||
!rawBusinessOverviewSignal && !rawLifecycleSignal && hasBidirectionalValueFlowSignal(rawText);
|
||||
|
|
@ -1517,6 +1600,12 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
rawDomain === "business_summary" ||
|
||||
rawDomain === "business_overview" ||
|
||||
rawAction === "broad_evaluation";
|
||||
const businessOverviewSeparateCounterpartySignal = Boolean(
|
||||
businessOverviewSignal && hasBusinessOverviewSeparateCounterpartySignal(rawText)
|
||||
);
|
||||
const businessOverviewSeparateCounterpartyCandidate = businessOverviewSeparateCounterpartySignal
|
||||
? businessOverviewSeparateCounterpartyCandidateFromText(rawText)
|
||||
: null;
|
||||
const explicitIntentCandidate = toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate);
|
||||
const currentTurnDocumentLaneSignal = rawAction === "list_documents";
|
||||
const currentTurnMovementLaneSignal = rawAction === "list_movements";
|
||||
|
|
@ -1556,6 +1645,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
);
|
||||
const businessOverviewSuppressesFollowupCounterparty = Boolean(
|
||||
businessOverviewSignal &&
|
||||
!businessOverviewSeparateCounterpartySignal &&
|
||||
(rawBusinessOverviewSignal ||
|
||||
businessOverviewContinuationSignal ||
|
||||
broadBusinessEvaluationUnsupported ||
|
||||
|
|
@ -1604,8 +1694,12 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
? null
|
||||
: normalizeFollowupCounterpartyCandidate(predecomposeEntities.counterparty);
|
||||
const predecomposeDateScope = collectDateScope(predecomposeContract);
|
||||
const suppressFollowupBusinessOverviewSeed = Boolean(
|
||||
explicitVatSuppressesBusinessOverviewContinuation && hasBusinessOverviewFollowupSeed(followupSeed)
|
||||
);
|
||||
const periodClarificationFollowupApplicable = Boolean(
|
||||
followupSeed.domain &&
|
||||
!suppressFollowupBusinessOverviewSeed &&
|
||||
followupSeed.loopStatus === "awaiting_clarification" &&
|
||||
followupSeed.loopPendingAxes.includes("period") &&
|
||||
!rawLifecycleSignal &&
|
||||
|
|
@ -1618,6 +1712,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
);
|
||||
const followupDiscoverySeedApplicable = Boolean(
|
||||
followupSeed.domain &&
|
||||
!suppressFollowupBusinessOverviewSeed &&
|
||||
!rawLifecycleSignal &&
|
||||
!rawMetadataSignal &&
|
||||
(periodClarificationFollowupApplicable ||
|
||||
|
|
@ -2005,6 +2100,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
for (const candidate of collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates)) {
|
||||
pushScopedEntityCandidate(entityCandidates, candidate, groundedFollowupEntity);
|
||||
}
|
||||
pushScopedEntityCandidate(entityCandidates, businessOverviewSeparateCounterpartyCandidate, groundedFollowupEntity);
|
||||
pushScopedEntityCandidate(entityCandidates, normalizedPredecomposeCounterparty, groundedFollowupEntity);
|
||||
pushScopedEntityCandidate(entityCandidates, rawScopedEntityCandidate, groundedFollowupEntity);
|
||||
if (!groundedFollowupEntity) {
|
||||
|
|
@ -2017,6 +2113,20 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
}
|
||||
pushScopedEntityCandidate(entityCandidates, rawEntityCandidate, groundedFollowupEntity);
|
||||
}
|
||||
const businessOverviewSeparateCounterpartyDisplayCandidate = businessOverviewSeparateCounterpartySignal
|
||||
? preferredScopedDisplayName(businessOverviewSeparateCounterpartyCandidate, [
|
||||
groundedFollowupEntity,
|
||||
effectiveFollowupCounterparty,
|
||||
followupSeed.discoveryEntity,
|
||||
normalizedPredecomposeCounterparty,
|
||||
rawScopedEntityCandidate,
|
||||
rawEntityCandidate,
|
||||
...entityCandidates
|
||||
])
|
||||
: null;
|
||||
const businessOverviewSeparateEntityCandidates = businessOverviewSeparateCounterpartyDisplayCandidate
|
||||
? [businessOverviewSeparateCounterpartyDisplayCandidate]
|
||||
: [];
|
||||
if (
|
||||
(rawMetadataSignal || metadataFollowupSeedApplicable) &&
|
||||
!groundedFollowupEntity &&
|
||||
|
|
@ -2107,6 +2217,8 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
(clarificationLoopStillNeedsPeriod ||
|
||||
businessOverviewSignal ||
|
||||
openScopeValueFlowWithoutResolvedCounterparty ||
|
||||
valueFlowGroundedDocumentFollowupApplicable ||
|
||||
valueFlowGroundedMovementFollowupApplicable ||
|
||||
(valueFlowOrganizationStaysScope && (Boolean(followupSeed.rankingNeed) || bidirectionalValueFlowSignal)))
|
||||
);
|
||||
const suppressNegatedTaxOnlyDateScope = Boolean(businessOverviewSignal && negatedTaxDateScopeOnlySignal);
|
||||
|
|
@ -2138,11 +2250,18 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
(suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(followupSeed.dateScope))
|
||||
? null
|
||||
: followupSeed.dateScope;
|
||||
const businessOverviewRawYearOverridesPredecomposeAsOf = Boolean(
|
||||
businessOverviewSignal &&
|
||||
rawDateScope &&
|
||||
/^\d{4}$/.test(rawDateScope) &&
|
||||
normalizedPredecomposeDateScope &&
|
||||
normalizedPredecomposeDateScope.startsWith(`${rawDateScope}-`)
|
||||
);
|
||||
const explicitDateScope =
|
||||
rawAllTimeScopeSignal
|
||||
? null
|
||||
: normalizedAssistantTurnMeaningDateScope ??
|
||||
normalizedPredecomposeDateScope ??
|
||||
(businessOverviewRawYearOverridesPredecomposeAsOf ? rawDateScope : normalizedPredecomposeDateScope) ??
|
||||
rawDateScope ??
|
||||
normalizedFollowupDateScope;
|
||||
const followupDateScopeApplied = Boolean(
|
||||
|
|
@ -2198,6 +2317,13 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
? followupSeed.rankingNeed
|
||||
: undefined,
|
||||
explicit_entity_candidates: businessOverviewSignal ? [] : entityCandidates,
|
||||
business_overview_separate_entity_candidates: businessOverviewSeparateEntityCandidates,
|
||||
previous_counterparty_value_flow_bundle:
|
||||
businessOverviewSignal && followupSeed.previousBidirectionalValueFlow
|
||||
? followupSeed.previousBidirectionalValueFlow
|
||||
: undefined,
|
||||
previous_counterparty_document_bundle:
|
||||
businessOverviewSignal && followupSeed.previousDocumentSummary ? followupSeed.previousDocumentSummary : undefined,
|
||||
metadata_ambiguity_entity_sets:
|
||||
metadataAmbiguityLaneClarificationApplicable && followupSeed.metadataAmbiguityEntitySets.length > 0
|
||||
? followupSeed.metadataAmbiguityEntitySets
|
||||
|
|
@ -2263,6 +2389,15 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
if ((turnMeaning.explicit_entity_candidates?.length ?? 0) > 0) {
|
||||
cleanTurnMeaning.explicit_entity_candidates = turnMeaning.explicit_entity_candidates;
|
||||
}
|
||||
if ((turnMeaning.business_overview_separate_entity_candidates?.length ?? 0) > 0) {
|
||||
cleanTurnMeaning.business_overview_separate_entity_candidates = turnMeaning.business_overview_separate_entity_candidates;
|
||||
}
|
||||
if (toRecordObject(turnMeaning.previous_counterparty_value_flow_bundle)) {
|
||||
cleanTurnMeaning.previous_counterparty_value_flow_bundle = turnMeaning.previous_counterparty_value_flow_bundle;
|
||||
}
|
||||
if (toRecordObject(turnMeaning.previous_counterparty_document_bundle)) {
|
||||
cleanTurnMeaning.previous_counterparty_document_bundle = turnMeaning.previous_counterparty_document_bundle;
|
||||
}
|
||||
if ((turnMeaning.metadata_ambiguity_entity_sets?.length ?? 0) > 0) {
|
||||
cleanTurnMeaning.metadata_ambiguity_entity_sets = turnMeaning.metadata_ambiguity_entity_sets;
|
||||
}
|
||||
|
|
@ -2478,9 +2613,21 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
if (businessOverviewContinuationSignal) {
|
||||
pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_from_followup_context");
|
||||
}
|
||||
if (explicitVatSuppressesBusinessOverviewContinuation) {
|
||||
pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_suppressed_by_explicit_vat_question");
|
||||
}
|
||||
if (businessOverviewSuppressesFollowupCounterparty) {
|
||||
pushReason(reasonCodes, "mcp_discovery_business_overview_suppressed_stale_counterparty");
|
||||
}
|
||||
if (businessOverviewSeparateCounterpartySignal) {
|
||||
pushReason(reasonCodes, "mcp_discovery_business_overview_preserved_explicit_counterparty_summary_scope");
|
||||
}
|
||||
if (businessOverviewSeparateCounterpartyCandidate) {
|
||||
pushReason(reasonCodes, "mcp_discovery_business_overview_counterparty_from_summary_text");
|
||||
}
|
||||
if (businessOverviewRawYearOverridesPredecomposeAsOf) {
|
||||
pushReason(reasonCodes, "mcp_discovery_business_overview_raw_year_overrode_predecompose_as_of_scope");
|
||||
}
|
||||
if (
|
||||
!(valueFlowOrganizationStaysScope && normalizedPredecomposeCounterparty === explicitOrganizationScope) &&
|
||||
normalizedPredecomposeCounterparty
|
||||
|
|
@ -2515,12 +2662,19 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
if (runDiscovery && !hasTurnMeaning) {
|
||||
pushReason(reasonCodes, "mcp_discovery_turn_meaning_missing");
|
||||
}
|
||||
const dataNeedGraphTurnMeaning =
|
||||
businessOverviewSeparateCounterpartySignal && cleanTurnMeaning.explicit_entity_candidates
|
||||
? {
|
||||
...cleanTurnMeaning,
|
||||
explicit_entity_candidates: []
|
||||
}
|
||||
: cleanTurnMeaning;
|
||||
const dataNeedGraph =
|
||||
runDiscovery && hasTurnMeaning
|
||||
? buildAssistantMcpDiscoveryDataNeedGraph({
|
||||
semanticDataNeed,
|
||||
rawUtterance: rawSignalSourceText,
|
||||
turnMeaning: cleanTurnMeaning
|
||||
turnMeaning: dataNeedGraphTurnMeaning
|
||||
})
|
||||
: null;
|
||||
if (dataNeedGraph) {
|
||||
|
|
|
|||
|
|
@ -236,6 +236,36 @@ function hasExplicitRecapPromptSignal(samples: string[]): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function normalizeMemoryCheckpointSample(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/ё/g, "е")
|
||||
.replace(/[«»"'`]/g, "")
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function hasMemoryCheckpointPromptSignal(samples: string[]): boolean {
|
||||
return samples.some((sample) => {
|
||||
const text = normalizeMemoryCheckpointSample(sample);
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
if (/(?:стартов\w*\s+чек\s+контекст|чек\s+контекста|context\s+check|memory\s+check)/iu.test(text)) {
|
||||
return true;
|
||||
}
|
||||
const hasSelectedStateCue =
|
||||
/(?:выбранн\w*\s+(?:компан|организац|контрагент|объект)|активн\w*\s+(?:компан|организац|контрагент|объект)|selected\s+(?:company|organization|counterparty|object)|active\s+(?:company|organization|counterparty|object))/iu.test(text);
|
||||
const hasDialogStateCue =
|
||||
/(?:в\s+текущ\w*\s+диалог|в\s+этом\s+диалог|в\s+сессии|контекст(?:е|а)?\s+диалог|current\s+(?:dialog|session|conversation))/iu.test(text);
|
||||
const hasHonestyCue =
|
||||
/(?:не\s+выдумывай\s+памят|не\s+придумывай\s+памят|скажи\s+честно|если\s+нет|no\s+fabricat|do\s+not\s+invent\s+memory)/iu.test(text);
|
||||
const asksCurrentSelection =
|
||||
/(?:есть\s+ли\s+уже|есть\s+ли\s+сейчас|что\s+выбрано|кто\s+выбран|какая\s+компан\w*\s+выбран)/iu.test(text);
|
||||
return (hasSelectedStateCue && hasDialogStateCue) || (hasDialogStateCue && hasHonestyCue) || (asksCurrentSelection && hasHonestyCue);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildInventoryHistoryCapabilityFollowupReply(input: {
|
||||
organization: string | null;
|
||||
addressDebug: Record<string, unknown> | null;
|
||||
|
|
@ -713,10 +743,32 @@ function extractBuyerFromSaleTraceAnswer(
|
|||
return null;
|
||||
}
|
||||
|
||||
function extractRequestedMemorySubject(userMessage: unknown): string | null {
|
||||
const text = String(userMessage ?? "").trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const patterns = [
|
||||
/памят[ьи]\s+про\s+([^.;!?]+)/iu,
|
||||
/memory\s+about\s+([^.;!?]+)/iu
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
const subject = match?.[1]
|
||||
? match[1].replace(/[«»"'`]/g, "").replace(/\s+/g, " ").trim()
|
||||
: "";
|
||||
if (subject.length >= 2 && subject.length <= 80) {
|
||||
return subject;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildAddressMemoryRecapReply(input: {
|
||||
organization: string | null;
|
||||
addressDebug: Record<string, unknown> | null;
|
||||
sessionItems?: unknown[];
|
||||
userMessage?: unknown;
|
||||
toNonEmptyString: (value: unknown) => string | null;
|
||||
}): string {
|
||||
const contextFacts = resolveAddressDebugContextFacts(input.addressDebug, input.toNonEmptyString);
|
||||
|
|
@ -782,7 +834,14 @@ export function buildAddressMemoryRecapReply(input: {
|
|||
].join(" ");
|
||||
}
|
||||
|
||||
return "Да, помню предыдущий адресный контур. Могу кратко напомнить, что мы уже подтвердили, или сразу продолжить следующий шаг.";
|
||||
const requestedMemorySubject = extractRequestedMemorySubject(input.userMessage);
|
||||
const subjectLine = requestedMemorySubject
|
||||
? ` Память про «${requestedMemorySubject}» в этом диалоге не подтверждена.`
|
||||
: " Память про конкретную компанию или контрагента в этом диалоге не подтверждена.";
|
||||
return [
|
||||
`Коротко: в текущем диалоге я не вижу выбранной компании, контрагента или позиции.${subjectLine}`,
|
||||
"Чтобы продолжить без выдуманной памяти, назови компанию, контрагента или объект, и я начну новый проверенный контур."
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function buildBroadBusinessEvaluationReply(input: {
|
||||
|
|
@ -1055,6 +1114,7 @@ export function createAssistantMemoryRecapPolicy(
|
|||
deps.hasConversationMemoryRecallFollowupSignal
|
||||
);
|
||||
const explicitRecapPromptSignal = hasExplicitRecapPromptSignal(samples);
|
||||
const memoryCheckpointPromptSignal = hasMemoryCheckpointPromptSignal(samples);
|
||||
return {
|
||||
contextualHistoricalCapabilityFollowupDetected: Boolean(
|
||||
input.capabilityMetaQuery &&
|
||||
|
|
@ -1067,9 +1127,10 @@ export function createAssistantMemoryRecapPolicy(
|
|||
!input.dataScopeMetaQuery &&
|
||||
!input.capabilityMetaQuery &&
|
||||
!input.aggregateBusinessAnalyticsSignal &&
|
||||
memoryRecapSignal &&
|
||||
(explicitRecapPromptSignal || (!input.dataRetrievalSignal && !input.strongDataSignal)) &&
|
||||
continuity.hasGroundedAddressContext
|
||||
(memoryCheckpointPromptSignal ||
|
||||
(memoryRecapSignal &&
|
||||
(explicitRecapPromptSignal || (!input.dataRetrievalSignal && !input.strongDataSignal)) &&
|
||||
continuity.hasGroundedAddressContext))
|
||||
)
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,6 +198,16 @@ export function createAssistantTransitionPolicy(deps) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage = null) {
|
||||
const samples = [userMessage, alternateMessage].map((item) => normalizeFollowupText(item)).filter(Boolean);
|
||||
return samples.some(
|
||||
(sample) =>
|
||||
/(?:итог|summary|резюм|вывод|что\s+(?:мы\s+)?подтверд|что\s+понят|что\s+можно|что\s+нельзя|собери\s+коротк)/iu.test(
|
||||
sample
|
||||
) && /(?:контрагент|группа\s+свк|свк|отдельн)/iu.test(sample)
|
||||
);
|
||||
}
|
||||
|
||||
function parseDmyDateToIso(value) {
|
||||
const match = String(value ?? "").trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||
if (!match) {
|
||||
|
|
@ -341,6 +351,61 @@ export function createAssistantTransitionPolicy(deps) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function readMcpDiscoveryBidirectionalValueFlow(debug) {
|
||||
const entryPoint = debug?.assistant_mcp_discovery_entry_point_v1;
|
||||
const flow = entryPoint?.bridge?.pilot?.derived_bidirectional_value_flow;
|
||||
if (!flow || typeof flow !== "object" || Array.isArray(flow)) {
|
||||
return null;
|
||||
}
|
||||
return flow;
|
||||
}
|
||||
|
||||
function readCounterpartyDocumentSummaryFromItem(item) {
|
||||
const text = deps.toNonEmptyString(item?.text);
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const firstLine = text.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "";
|
||||
const match = firstLine.match(/Контрагент:\s*([^.\n]+)\.\s*Найдено документов:\s*(\d+)/iu);
|
||||
if (!match?.[1] || !match?.[2]) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
counterparty: deps.toNonEmptyString(match[1]),
|
||||
document_count: Number(match[2]),
|
||||
direct_answer: firstLine
|
||||
};
|
||||
}
|
||||
|
||||
function findRecentDiscoveryValueFlowBundle(items) {
|
||||
for (let index = Array.isArray(items) ? items.length - 1 : -1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
const debug = item?.debug;
|
||||
if (!item || item.role !== "assistant" || !debug || typeof debug !== "object") {
|
||||
continue;
|
||||
}
|
||||
const flow = readMcpDiscoveryBidirectionalValueFlow(debug);
|
||||
if (flow) {
|
||||
return flow;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findRecentCounterpartyDocumentBundle(items) {
|
||||
for (let index = Array.isArray(items) ? items.length - 1 : -1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
|
||||
continue;
|
||||
}
|
||||
const summary = readCounterpartyDocumentSummaryFromItem(item);
|
||||
if (summary) {
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint) {
|
||||
if (
|
||||
sourceIntentHint !== "inventory_purchase_provenance_for_item" &&
|
||||
|
|
@ -530,7 +595,10 @@ export function createAssistantTransitionPolicy(deps) {
|
|||
llmPreDecomposeMeta
|
||||
})
|
||||
: null;
|
||||
if (assistantTurnMeaning?.stale_replay_forbidden === true) {
|
||||
if (
|
||||
assistantTurnMeaning?.stale_replay_forbidden === true &&
|
||||
!hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const latestAddressItem = deps.findLastAddressAssistantItem(items);
|
||||
|
|
@ -638,18 +706,21 @@ export function createAssistantTransitionPolicy(deps) {
|
|||
hasValueFlowCarryoverSourceHint && deps.toNonEmptyString(alternateMessage)
|
||||
? hasShortValueFlowRetargetCue(String(alternateMessage ?? ""))
|
||||
: false;
|
||||
const explicitSummaryBundleReuseSignal = hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage);
|
||||
let hasPrimaryFollowupSignal =
|
||||
deps.hasAddressFollowupContextSignal(userMessage) ||
|
||||
Boolean(debtRoleSwapPrimary) ||
|
||||
shortValueFlowRetargetPrimary ||
|
||||
inventoryShortFollowupPrimary ||
|
||||
inventoryPurchaseDateVatBridge;
|
||||
inventoryPurchaseDateVatBridge ||
|
||||
explicitSummaryBundleReuseSignal;
|
||||
let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage)
|
||||
? deps.hasAddressFollowupContextSignal(alternateMessage) ||
|
||||
Boolean(debtRoleSwapAlternate) ||
|
||||
shortValueFlowRetargetAlternate ||
|
||||
inventoryShortFollowupAlternate ||
|
||||
inventoryPurchaseDateVatBridge
|
||||
inventoryPurchaseDateVatBridge ||
|
||||
explicitSummaryBundleReuseSignal
|
||||
: false;
|
||||
const hasPrimaryIndexReferenceSignal = deps.extractDisplayedEntityIndexMention(userMessage) !== null;
|
||||
const hasAlternateIndexReferenceSignal = deps.toNonEmptyString(alternateMessage)
|
||||
|
|
@ -698,6 +769,7 @@ export function createAssistantTransitionPolicy(deps) {
|
|||
hasInventoryRootRestatementPrimary ||
|
||||
hasInventoryRootRestatementAlternate ||
|
||||
inventoryPurchaseDateVatBridge ||
|
||||
explicitSummaryBundleReuseSignal ||
|
||||
Boolean(debtRoleSwapIntent) ||
|
||||
shortValueFlowRetargetPrimary ||
|
||||
shortValueFlowRetargetAlternate ||
|
||||
|
|
@ -718,6 +790,7 @@ export function createAssistantTransitionPolicy(deps) {
|
|||
hasInventoryRootRestatementPrimary ||
|
||||
hasInventoryRootRestatementAlternate ||
|
||||
inventoryPurchaseDateVatBridge ||
|
||||
explicitSummaryBundleReuseSignal ||
|
||||
Boolean(debtRoleSwapIntent) ||
|
||||
shortValueFlowRetargetPrimary ||
|
||||
shortValueFlowRetargetAlternate ||
|
||||
|
|
@ -753,7 +826,8 @@ export function createAssistantTransitionPolicy(deps) {
|
|||
!hasImplicitContinuationSignal &&
|
||||
!hasSuggestedIntentPivotSignal &&
|
||||
!hasOrganizationClarificationContinuation &&
|
||||
!hasIndexReferenceSignal
|
||||
!hasIndexReferenceSignal &&
|
||||
!explicitSummaryBundleReuseSignal
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -769,7 +843,8 @@ export function createAssistantTransitionPolicy(deps) {
|
|||
!hasImplicitContinuationSignal &&
|
||||
!hasSuggestedIntentPivotSignal &&
|
||||
!hasOrganizationClarificationContinuation &&
|
||||
!hasIndexReferenceSignal
|
||||
!hasIndexReferenceSignal &&
|
||||
!explicitSummaryBundleReuseSignal
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -848,6 +923,9 @@ export function createAssistantTransitionPolicy(deps) {
|
|||
carryoverSourceDebug,
|
||||
deps.toNonEmptyString
|
||||
);
|
||||
const sourceDiscoveryBidirectionalValueFlow =
|
||||
readMcpDiscoveryBidirectionalValueFlow(carryoverSourceDebug) ?? findRecentDiscoveryValueFlowBundle(items);
|
||||
const sourceDiscoveryDocumentSummary = findRecentCounterpartyDocumentBundle(items);
|
||||
const llmExplicitIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
|
||||
const llmSelectedObjectScopeDetected =
|
||||
llmPreDecomposeMeta?.predecomposeContract?.semantics?.selected_object_scope_detected === true;
|
||||
|
|
@ -951,6 +1029,7 @@ export function createAssistantTransitionPolicy(deps) {
|
|||
shortValueFlowRetargetPrimary ||
|
||||
inventoryShortFollowupPrimary ||
|
||||
inventoryPurchaseDateVatBridge ||
|
||||
explicitSummaryBundleReuseSignal ||
|
||||
hasInventoryRootTemporalFollowupPrimary;
|
||||
hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage)
|
||||
? deps.hasAddressFollowupContextSignal(alternateMessage) ||
|
||||
|
|
@ -959,6 +1038,7 @@ export function createAssistantTransitionPolicy(deps) {
|
|||
shortValueFlowRetargetAlternate ||
|
||||
inventoryShortFollowupAlternate ||
|
||||
inventoryPurchaseDateVatBridge ||
|
||||
explicitSummaryBundleReuseSignal ||
|
||||
hasInventoryRootTemporalFollowupAlternate
|
||||
: false;
|
||||
hasStrongFollowupReference =
|
||||
|
|
@ -972,6 +1052,7 @@ export function createAssistantTransitionPolicy(deps) {
|
|||
hasInventoryRootTemporalFollowupPrimary ||
|
||||
hasInventoryRootTemporalFollowupAlternate ||
|
||||
inventoryPurchaseDateVatBridge ||
|
||||
explicitSummaryBundleReuseSignal ||
|
||||
Boolean(debtRoleSwapIntent) ||
|
||||
shortValueFlowRetargetPrimary ||
|
||||
shortValueFlowRetargetAlternate ||
|
||||
|
|
@ -1220,6 +1301,8 @@ export function createAssistantTransitionPolicy(deps) {
|
|||
previous_discovery_metadata_ambiguity_detected: sourceDiscoveryMetadataAmbiguityDetected || undefined,
|
||||
previous_discovery_metadata_ambiguity_entity_sets:
|
||||
sourceDiscoveryMetadataAmbiguityEntitySets.length > 0 ? sourceDiscoveryMetadataAmbiguityEntitySets : undefined,
|
||||
previous_discovery_bidirectional_value_flow: sourceDiscoveryBidirectionalValueFlow ?? undefined,
|
||||
previous_discovery_document_summary: sourceDiscoveryDocumentSummary ?? undefined,
|
||||
resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined,
|
||||
root_context_only: rootScopedPivot || undefined,
|
||||
root_intent: shouldAttachInventoryRootFrame ? inventoryRootFrame?.intent ?? undefined : undefined,
|
||||
|
|
|
|||
|
|
@ -125,21 +125,24 @@ function coverageStatusFrom(
|
|||
groundingStatus: AssistantGroundingStatus
|
||||
): AssistantCoverageStatus {
|
||||
const explicitCoverageEvidence = toAddressCoverageEvidenceContract(debug.address_coverage_evidence_v1);
|
||||
if (truthGateStatus === "full_confirmed") {
|
||||
return "full";
|
||||
}
|
||||
if (truthGateStatus === "partial_supported" || truthGateStatus === "limited_temporal_or_contextual") {
|
||||
return "partial";
|
||||
}
|
||||
if (truthGateStatus.startsWith("blocked")) {
|
||||
return "blocked";
|
||||
}
|
||||
if (toStringList(debug.missing_required_filters).length > 0 || groundingStatus === "route_mismatch_blocked" || groundingStatus === "no_grounded_answer") {
|
||||
return "blocked";
|
||||
}
|
||||
if (truthGateStatus === "partial_supported" || truthGateStatus === "limited_temporal_or_contextual") {
|
||||
return "partial";
|
||||
}
|
||||
if (explicitCoverageEvidence) {
|
||||
return explicitCoverageEvidence.coverage_status;
|
||||
}
|
||||
if (debug.balance_confirmed === false || toNonEmptyString(debug.result_mode) === "heuristic_candidates") {
|
||||
return "partial";
|
||||
}
|
||||
if (truthGateStatus === "full_confirmed") {
|
||||
return "full";
|
||||
}
|
||||
|
||||
const coverageReport = toRecordObject(input.coverageReport) ?? toRecordObject(debug.coverage_report);
|
||||
if (coverageReport) {
|
||||
|
|
@ -176,10 +179,16 @@ function truthModeFrom(input: {
|
|||
if (input.truthGateStatus === "blocked_missing_anchor" || toStringList(input.debug.missing_required_filters).length > 0) {
|
||||
return "clarification_required";
|
||||
}
|
||||
if (input.truthGateStatus === "full_confirmed" || (input.coverageStatus === "full" && input.groundingStatus === "grounded")) {
|
||||
if (input.coverageStatus === "partial") {
|
||||
return "limited";
|
||||
}
|
||||
if (input.truthGateStatus === "full_confirmed" && input.coverageStatus === "full") {
|
||||
return "confirmed";
|
||||
}
|
||||
if (input.truthGateStatus === "partial_supported" || input.truthGateStatus === "limited_temporal_or_contextual" || input.coverageStatus === "partial") {
|
||||
if (input.coverageStatus === "full" && input.groundingStatus === "grounded") {
|
||||
return "confirmed";
|
||||
}
|
||||
if (input.truthGateStatus === "partial_supported" || input.truthGateStatus === "limited_temporal_or_contextual") {
|
||||
return "limited";
|
||||
}
|
||||
return "unsupported";
|
||||
|
|
@ -199,6 +208,9 @@ function evidenceGradeFrom(
|
|||
if (isEvidenceGrade(explicit)) {
|
||||
return explicit;
|
||||
}
|
||||
if (debug.balance_confirmed === false || toNonEmptyString(debug.result_mode) === "heuristic_candidates") {
|
||||
return coverageStatus === "partial" ? "medium" : "weak";
|
||||
}
|
||||
if (coverageStatus === "blocked") {
|
||||
return "none";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -255,6 +255,28 @@ describe("counterparty shipment item flow and open-items routing", () => {
|
|||
expect(reply.text).not.toContain("Контрагент: Группа. Найдено документов");
|
||||
});
|
||||
|
||||
it("keeps document follow-up answer compact for larger counterparty lists", () => {
|
||||
const rows = Array.from({ length: 7 }, (_, index) => ({
|
||||
period: `2021-11-${String(index + 1).padStart(2, "0")}T12:00:00Z`,
|
||||
registrator: `Документ ${index + 1}`,
|
||||
account_dt: "0",
|
||||
account_kt: "0",
|
||||
amount: 1000 + index,
|
||||
analytics: ["Группа СВК", "Договор № 1-ПМ/2020"],
|
||||
organization: "ООО Альтернатива Плюс"
|
||||
}));
|
||||
|
||||
const reply = composeFactualReply("list_documents_by_counterparty", rows, {
|
||||
counterpartyHint: "Группа СВК"
|
||||
});
|
||||
|
||||
expect(reply.text).toContain("Контрагент: Группа СВК. Найдено документов: 7.");
|
||||
expect(reply.text).toContain("Показаны первые 5 из 7 документов");
|
||||
expect(reply.text).toContain("Документ 5");
|
||||
expect(reply.text).not.toContain("Документ 6");
|
||||
expect(reply.text.split("\n").filter((line) => line.startsWith("Контрагент:")).length).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps current resolved counterparty label over stale follow-up anchor during short retarget", async () => {
|
||||
executeAddressMcpQueryMock
|
||||
.mockResolvedValueOnce({
|
||||
|
|
@ -427,6 +449,14 @@ describe("counterparty shipment item flow and open-items routing", () => {
|
|||
expect(result?.response_type).toBe("FACTUAL_LIST");
|
||||
expect(result?.debug.detected_intent).toBe("open_items_by_counterparty_or_contract");
|
||||
expect(String(result?.reply_text ?? "")).toContain("счету 60");
|
||||
expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("Коротко:");
|
||||
expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("точный открытый остаток");
|
||||
expect(String(result?.reply_text ?? "")).toContain("не подтвержден");
|
||||
expect(String(result?.reply_text ?? "")).toContain("предварительные сигналы");
|
||||
expect(result?.debug.address_coverage_evidence_v1?.requested_result_mode).toBe("confirmed_balance");
|
||||
expect(result?.debug.address_coverage_evidence_v1?.result_mode).toBe("heuristic_candidates");
|
||||
expect(result?.debug.address_coverage_evidence_v1?.coverage_status).toBe("partial");
|
||||
expect(result?.debug.address_coverage_evidence_v1?.balance_confirmed).toBe(false);
|
||||
|
||||
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
|
||||
expect(query).toContain("РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто");
|
||||
|
|
|
|||
|
|
@ -14,6 +14,29 @@ describe("address filter extractor regressions", () => {
|
|||
expect(extracted.warnings).toContain("period_derived_from_month_phrase");
|
||||
expect(extracted.warnings).not.toContain("period_derived_from_tax_quarter_for_confirmed_vat_liability");
|
||||
});
|
||||
|
||||
it("keeps explicit year window for confirmed VAT tax-period intent", () => {
|
||||
const extracted = extractAddressFilters(
|
||||
"\u0447\u0442\u043e \u0441 \u043d\u0434\u0441 \u0437\u0430 2020 \u0433\u043e\u0434 \u043f\u043e \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441",
|
||||
"vat_liability_confirmed_for_tax_period"
|
||||
);
|
||||
|
||||
expect(extracted.extracted_filters.period_from).toBe("2020-01-01");
|
||||
expect(extracted.extracted_filters.period_to).toBe("2020-12-31");
|
||||
expect(extracted.warnings).toContain("period_derived_from_year_phrase");
|
||||
expect(extracted.warnings).not.toContain("period_derived_from_tax_quarter_for_confirmed_vat_liability");
|
||||
});
|
||||
|
||||
it("drops pronoun-only counterparty anchors for chain follow-ups", () => {
|
||||
const extracted = extractAddressFilters(
|
||||
"\u043f\u043e\u043a\u0430\u0436\u0438 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u043f\u043e \u044d\u0442\u043e\u0439 \u0446\u0435\u043f\u043e\u0447\u043a\u0435",
|
||||
"list_documents_by_counterparty"
|
||||
);
|
||||
|
||||
expect(extracted.extracted_filters.counterparty).toBeUndefined();
|
||||
expect(extracted.warnings).toContain("counterparty_anchor_dropped_low_quality");
|
||||
});
|
||||
|
||||
it("extracts a compact counterparty tail for customer revenue profile", () => {
|
||||
const extracted = extractAddressFilters(
|
||||
"\u043a\u0430\u043a\u043e\u0439 \u043e\u0431\u043e\u0440\u043e\u0442 \u0431\u044b\u043b \u0441\u0432\u043a",
|
||||
|
|
|
|||
|
|
@ -291,4 +291,27 @@ describe("address follow-up temporal regressions", () => {
|
|||
expect(movements?.filters.extracted_filters.counterparty).toBe("Группа СВК");
|
||||
expect(movements?.baseReasons).toContain("counterparty_from_followup_context");
|
||||
});
|
||||
|
||||
it("replaces pronoun chain anchors from counterparty follow-up context", () => {
|
||||
const followupContext = {
|
||||
previous_intent: "customer_revenue_and_payments" as const,
|
||||
target_intent: "list_documents_by_counterparty" as const,
|
||||
previous_filters: {
|
||||
organization: "ООО Альтернатива Плюс",
|
||||
counterparty: "Группа СВК"
|
||||
},
|
||||
previous_anchor_type: "counterparty" as const,
|
||||
previous_anchor_value: "Группа СВК",
|
||||
resolved_counterparty_from_display: true
|
||||
};
|
||||
|
||||
const documents = runAddressDecomposeStage(
|
||||
"покажи документы по этой цепочке и не смешивай Группа СВК с организацией ООО Альтернатива Плюс",
|
||||
followupContext
|
||||
);
|
||||
|
||||
expect(documents?.intent.intent).toBe("list_documents_by_counterparty");
|
||||
expect(documents?.filters.extracted_filters.counterparty).toBe("Группа СВК");
|
||||
expect(documents?.baseReasons).toContain("counterparty_from_followup_context");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -67,6 +67,17 @@ describe("address route expectations contract", () => {
|
|||
expect(audit.reason).toBe("route_expectation_matched");
|
||||
});
|
||||
|
||||
it("matches open-items route as a supported factual route", () => {
|
||||
const audit = evaluateAddressRouteExpectation({
|
||||
intent: "open_items_by_counterparty_or_contract",
|
||||
selectedRecipe: "address_open_items_by_party_or_contract_v1",
|
||||
requestedResultMode: "confirmed_balance",
|
||||
resultMode: "confirmed_balance"
|
||||
});
|
||||
expect(audit.status).toBe("matched");
|
||||
expect(audit.reason).toBe("route_expectation_matched");
|
||||
});
|
||||
|
||||
it("detects selected recipe mismatch", () => {
|
||||
const audit = evaluateAddressRouteExpectation({
|
||||
intent: "payables_confirmed_as_of_date",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,32 @@ describe("address truth gate policy", () => {
|
|||
expect(gate.reason_codes).toContain("limited_category_empty_match");
|
||||
});
|
||||
|
||||
it("keeps open-items movement fallback partial even when heuristic rows are found", () => {
|
||||
const gate = resolveAddressTruthGate({
|
||||
intent: "open_items_by_counterparty_or_contract",
|
||||
filters: {
|
||||
account: "60",
|
||||
period_from: "2020-08-01",
|
||||
period_to: "2020-08-31",
|
||||
organization: "ООО Альтернатива Плюс"
|
||||
},
|
||||
selectedRecipe: "address_open_items_by_party_or_contract_v1",
|
||||
rowsMatched: 8,
|
||||
runtimeReadiness: "LIVE_QUERYABLE_WITH_LIMITS",
|
||||
reasons: [
|
||||
"confirmed_balance_unavailable_fallback_to_heuristic_candidates",
|
||||
"open_items_account_query_override_to_movements"
|
||||
],
|
||||
routeExpectationStatus: "matched",
|
||||
replyType: "factual"
|
||||
});
|
||||
|
||||
expect(gate.truth_gate_status).toBe("partial_supported");
|
||||
expect(gate.carryover_eligibility).toBe("root_only");
|
||||
expect(gate.blocked_or_limited_explanation).toBe("evidence_or_coverage_is_partial");
|
||||
expect(gate.reason_codes).toContain("confirmed_balance_unavailable_fallback_to_heuristic_candidates");
|
||||
});
|
||||
|
||||
it("keeps selected-item limited answers object-scoped", () => {
|
||||
const gate = resolveAddressTruthGate({
|
||||
intent: "inventory_sale_trace_for_item",
|
||||
|
|
|
|||
|
|
@ -468,6 +468,26 @@ describe("assistant living chat runtime adapter", () => {
|
|||
expect(executeLlmChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("builds honest memory checkpoint reply when there is no selected context", async () => {
|
||||
const executeLlmChat = vi.fn(async () => "raw-llm");
|
||||
const input = buildRuntimeInput({
|
||||
userMessage:
|
||||
"Сделай короткий стартовый чек контекста: есть ли уже выбранная компания или контрагент в текущем диалоге; если нет, скажи честно и не выдумывай память про Группа СВК.",
|
||||
modeDecision: { mode: "chat", reason: "memory_recap_followup_detected" },
|
||||
sessionItems: [],
|
||||
executeLlmChat
|
||||
});
|
||||
|
||||
const output = await runAssistantLivingChatRuntime(input);
|
||||
|
||||
expect(output.handled).toBe(true);
|
||||
expect(output.chatText).toContain("не вижу выбранной компании");
|
||||
expect(output.chatText).toContain("Группа СВК");
|
||||
expect(output.chatText).toContain("не подтверждена");
|
||||
expect(output.debug?.living_chat_response_source).toBe("deterministic_memory_recap_contract");
|
||||
expect(executeLlmChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("builds deterministic memory recap for prior grounded MCP discovery counterparty context", async () => {
|
||||
const executeLlmChat = vi.fn(async () => "raw-llm");
|
||||
const input = buildRuntimeInput({
|
||||
|
|
|
|||
|
|
@ -1007,6 +1007,39 @@ describe("assistant orchestration contract", () => {
|
|||
expect(decision.livingReason).toBe("memory_recap_followup_detected");
|
||||
});
|
||||
|
||||
it("routes startup memory checkpoint without selected context to deterministic chat", () => {
|
||||
const question =
|
||||
"Сделай короткий стартовый чек контекста: есть ли уже выбранная компания или контрагент в текущем диалоге; если нет, скажи честно и не выдумывай память про Группа СВК.";
|
||||
const decision = resolveAssistantOrchestrationDecision({
|
||||
rawUserMessage: question,
|
||||
effectiveAddressUserMessage: question,
|
||||
followupContext: null,
|
||||
llmPreDecomposeMeta: {
|
||||
applied: true,
|
||||
llmCanonicalCandidateDetected: true,
|
||||
predecomposeContract: {
|
||||
mode: "unsupported",
|
||||
mode_confidence: "low",
|
||||
intent: "customer_revenue_and_payments",
|
||||
intent_confidence: "high"
|
||||
},
|
||||
semanticExtractionContract: {
|
||||
valid: true,
|
||||
apply_canonical_recommended: true,
|
||||
reason_codes: ["unsupported_low_confidence_contract"]
|
||||
}
|
||||
} as any,
|
||||
sessionItems: [],
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(decision.runAddressLane).toBe(false);
|
||||
expect(decision.toolGateDecision).toBe("skip_address_lane");
|
||||
expect(decision.toolGateReason).toBe("memory_recap_followup_detected");
|
||||
expect(decision.livingMode).toBe("chat");
|
||||
expect(decision.livingReason).toBe("memory_recap_followup_detected");
|
||||
});
|
||||
|
||||
it("keeps documentary inventory chain verification in address lane for supported exact intent", () => {
|
||||
const question =
|
||||
"Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы";
|
||||
|
|
|
|||
|
|
@ -32,6 +32,34 @@ describe("assistant MCP discovery data need graph", () => {
|
|||
expect(result.forbidden_overclaim_flags).toContain("no_unchecked_fact_totals");
|
||||
});
|
||||
|
||||
it("defaults explicit-counterparty bidirectional value-flow without period to bounded all-time scope", () => {
|
||||
const result = buildAssistantMcpDiscoveryDataNeedGraph({
|
||||
semanticDataNeed: "counterparty value-flow evidence",
|
||||
rawUtterance: "how much money passed with SVK, incoming and outgoing?",
|
||||
turnMeaning: {
|
||||
asked_domain_family: "counterparty_value",
|
||||
asked_action_family: "net_value_flow",
|
||||
explicit_entity_candidates: ["SVK"]
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.business_fact_family).toBe("value_flow");
|
||||
expect(result.comparison_need).toBe("incoming_vs_outgoing");
|
||||
expect(result.time_scope_need).toBe("all_time_scope");
|
||||
expect(result.clarification_gaps).toEqual([]);
|
||||
expect(result.proof_expectation).toBe("coverage_checked_fact");
|
||||
expect(result.decomposition_candidates).toEqual([
|
||||
"resolve_entity_reference",
|
||||
"collect_incoming_movements",
|
||||
"collect_outgoing_movements",
|
||||
"aggregate_checked_amounts",
|
||||
"probe_coverage"
|
||||
]);
|
||||
expect(result.reason_codes).toContain(
|
||||
"data_need_graph_subject_bidirectional_value_flow_defaults_to_all_time_scope"
|
||||
);
|
||||
});
|
||||
|
||||
it("marks metadata lane choice as a clarification-required graph", () => {
|
||||
const result = buildAssistantMcpDiscoveryDataNeedGraph({
|
||||
semanticDataNeed: "metadata lane clarification",
|
||||
|
|
|
|||
|
|
@ -1011,6 +1011,45 @@ describe("assistant MCP discovery planner", () => {
|
|||
expect(result.reason_codes).toContain("planner_instantiated_catalog_chain_template_value_flow_comparison");
|
||||
});
|
||||
|
||||
it("keeps explicit-counterparty bidirectional comparison executable over bounded all-time scope", () => {
|
||||
const result = planAssistantMcpDiscovery({
|
||||
dataNeedGraph: {
|
||||
schema_version: "assistant_data_need_graph_v1",
|
||||
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
|
||||
subject_candidates: ["SVK"],
|
||||
business_fact_family: "value_flow",
|
||||
action_family: "net_value_flow",
|
||||
aggregation_need: null,
|
||||
time_scope_need: "all_time_scope",
|
||||
comparison_need: "incoming_vs_outgoing",
|
||||
ranking_need: null,
|
||||
proof_expectation: "coverage_checked_fact",
|
||||
clarification_gaps: [],
|
||||
decomposition_candidates: ["resolve_entity_reference", "collect_incoming_movements", "collect_outgoing_movements", "probe_coverage"],
|
||||
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
|
||||
reason_codes: [
|
||||
"data_need_graph_built",
|
||||
"data_need_graph_comparison_incoming_vs_outgoing",
|
||||
"data_need_graph_subject_bidirectional_value_flow_defaults_to_all_time_scope"
|
||||
]
|
||||
},
|
||||
turnMeaning: {
|
||||
asked_domain_family: "counterparty_value",
|
||||
asked_action_family: "net_value_flow",
|
||||
explicit_entity_candidates: ["SVK"],
|
||||
unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting"
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.planner_status).toBe("ready_for_execution");
|
||||
expect(result.selected_chain_id).toBe("value_flow_comparison");
|
||||
expect(result.proposed_primitives).toEqual(["resolve_entity_reference", "query_movements", "probe_coverage"]);
|
||||
expect(result.required_axes).toEqual(["counterparty", "all_time_scope", "amount", "coverage_target"]);
|
||||
expect(result.catalog_review.review_status).toBe("catalog_compatible");
|
||||
expect(result.discovery_plan.clarification_gaps).toEqual([]);
|
||||
expect(result.reason_codes).toContain("planner_ready_for_guarded_mcp_execution");
|
||||
});
|
||||
|
||||
it("builds an inference-safe lifecycle plan with evidence explanation", () => {
|
||||
const result = planAssistantMcpDiscovery({
|
||||
turnMeaning: {
|
||||
|
|
|
|||
|
|
@ -114,9 +114,11 @@ describe("assistant MCP discovery response candidate", () => {
|
|||
})
|
||||
);
|
||||
|
||||
expect(candidate.reply_text).toContain("самый доходный год");
|
||||
expect(candidate.reply_text).toContain("в доступном проверенном MCP-срезе");
|
||||
expect(candidate.reply_text).toContain("лидирует 2015");
|
||||
expect(candidate.reply_text).toContain("2015");
|
||||
expect(candidate.reply_text).toContain("136 723 459,73 руб.");
|
||||
expect(candidate.reply_text).toContain("не полный бухгалтерский рейтинг доходности");
|
||||
expect(candidate.reply_text).toContain("не как чистую бухгалтерскую прибыль");
|
||||
expect(candidate.reply_text).toContain("лимит выборки MCP");
|
||||
expect(candidate.reply_text).not.toContain("Что подтверждено:");
|
||||
|
|
@ -162,6 +164,12 @@ describe("assistant MCP discovery response candidate", () => {
|
|||
total_amount_human_ru: "11 536 836,23 руб."
|
||||
}
|
||||
],
|
||||
top_suppliers: [
|
||||
{
|
||||
axis_value: "ООО Поставщик",
|
||||
total_amount_human_ru: "2 200 000 руб."
|
||||
}
|
||||
],
|
||||
yearly_breakdown: []
|
||||
}
|
||||
},
|
||||
|
|
@ -181,12 +189,219 @@ describe("assistant MCP discovery response candidate", () => {
|
|||
expect(candidate.reply_text).toContain("за 2017");
|
||||
expect(candidate.reply_text).toContain("получили 16 932 063,96 руб.");
|
||||
expect(candidate.reply_text).toContain("исходящие платежи/списания 4 458 027,05 руб.");
|
||||
expect(candidate.reply_text).toContain("12 474 036,91 руб.");
|
||||
expect(candidate.reply_text).toContain("12 474 036,91 руб");
|
||||
expect(candidate.reply_text?.split("\n")[0]).toContain("крупнейший источник входящих денег: ГКУ УКРиС");
|
||||
expect(candidate.reply_text?.split("\n")[0]).toContain("крупнейший получатель исходящих денег: ООО Поставщик");
|
||||
expect(candidate.reply_text).toContain("денежный operating-flow proxy");
|
||||
expect(candidate.reply_text).not.toContain("Что можно сказать только как вывод:");
|
||||
expect(candidate.reply_text).not.toContain("Складской срез");
|
||||
});
|
||||
|
||||
it("mentions separate counterparty scope in company plus counterparty business summaries", () => {
|
||||
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
|
||||
entryPoint({
|
||||
turn_input: {
|
||||
adapter_status: "ready",
|
||||
turn_meaning_ref: {
|
||||
asked_domain_family: "business_overview",
|
||||
asked_action_family: "broad_evaluation",
|
||||
explicit_organization_scope: "ООО Альтернатива Плюс",
|
||||
business_overview_separate_entity_candidates: ["Группа СВК"]
|
||||
},
|
||||
data_need_graph: {
|
||||
business_fact_family: "business_overview",
|
||||
subject_candidates: [],
|
||||
ranking_need: null,
|
||||
reason_codes: ["data_need_graph_family_business_overview"]
|
||||
}
|
||||
},
|
||||
bridge: {
|
||||
bridge_status: "answer_draft_ready",
|
||||
user_facing_response_allowed: true,
|
||||
business_fact_answer_allowed: true,
|
||||
requires_user_clarification: false,
|
||||
pilot: {
|
||||
pilot_scope: "business_overview_route_template_v1",
|
||||
derived_business_overview: {
|
||||
period_scope: null,
|
||||
incoming_customer_revenue: {
|
||||
total_amount_human_ru: "157 192 981,43 руб.",
|
||||
coverage_limited_by_probe_limit: true
|
||||
},
|
||||
outgoing_supplier_payout: {
|
||||
total_amount_human_ru: "35 439 044,74 руб.",
|
||||
coverage_limited_by_probe_limit: true
|
||||
},
|
||||
net_amount_human_ru: "121 753 936,69 руб.",
|
||||
net_direction: "net_incoming",
|
||||
top_customers: [],
|
||||
yearly_breakdown: []
|
||||
}
|
||||
},
|
||||
answer_draft: {
|
||||
answer_mode: "confirmed_with_bounded_inference",
|
||||
headline: "Company summary.",
|
||||
confirmed_lines: [],
|
||||
inference_lines: [],
|
||||
unknown_lines: [],
|
||||
limitation_lines: [],
|
||||
next_step_line: null
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
expect(candidate.reply_text).toContain("по компании ООО Альтернатива Плюс");
|
||||
expect(candidate.reply_text).toContain("Группа СВК");
|
||||
expect(candidate.reply_text?.split("\n")[0]).toContain("суммы компании не переношу");
|
||||
expect(candidate.reply_text).toContain("нельзя делать вывод о выручке, долге или прибыльности");
|
||||
expect(candidate.reply_text).toContain("без отдельного контрагентского среза");
|
||||
});
|
||||
|
||||
it("adds missing proof boundaries for broad all-time business overviews", () => {
|
||||
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
|
||||
entryPoint({
|
||||
turn_input: {
|
||||
adapter_status: "ready",
|
||||
turn_meaning_ref: {
|
||||
asked_domain_family: "business_overview",
|
||||
asked_action_family: "broad_evaluation",
|
||||
explicit_organization_scope: "ООО Альтернатива Плюс"
|
||||
},
|
||||
data_need_graph: {
|
||||
business_fact_family: "business_overview",
|
||||
subject_candidates: [],
|
||||
ranking_need: null,
|
||||
reason_codes: ["data_need_graph_family_business_overview"]
|
||||
}
|
||||
},
|
||||
bridge: {
|
||||
bridge_status: "answer_draft_ready",
|
||||
user_facing_response_allowed: true,
|
||||
business_fact_answer_allowed: true,
|
||||
requires_user_clarification: false,
|
||||
pilot: {
|
||||
pilot_scope: "business_overview_route_template_v1",
|
||||
derived_business_overview: {
|
||||
period_scope: null,
|
||||
incoming_customer_revenue: {
|
||||
total_amount_human_ru: "157 192 981,43 руб.",
|
||||
coverage_limited_by_probe_limit: true
|
||||
},
|
||||
outgoing_supplier_payout: {
|
||||
total_amount_human_ru: "35 439 044,74 руб.",
|
||||
coverage_limited_by_probe_limit: true
|
||||
},
|
||||
net_amount_human_ru: "121 753 936,69 руб.",
|
||||
net_direction: "net_incoming",
|
||||
top_customers: [],
|
||||
top_suppliers: [],
|
||||
yearly_breakdown: []
|
||||
}
|
||||
},
|
||||
answer_draft: {
|
||||
answer_mode: "confirmed_with_bounded_inference",
|
||||
headline: "Company summary.",
|
||||
confirmed_lines: [],
|
||||
inference_lines: [],
|
||||
unknown_lines: [],
|
||||
limitation_lines: [],
|
||||
next_step_line: null
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
expect(candidate.reply_text).toContain("Что не подтверждено в этом срезе");
|
||||
expect(candidate.reply_text).toContain("НДС");
|
||||
expect(candidate.reply_text).toContain("долги");
|
||||
expect(candidate.reply_text).toContain("склад");
|
||||
expect(candidate.reply_text).not.toContain("capability_id");
|
||||
});
|
||||
|
||||
it("reuses previous counterparty value-flow bundle in company plus counterparty summaries", () => {
|
||||
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
|
||||
entryPoint({
|
||||
turn_input: {
|
||||
adapter_status: "ready",
|
||||
turn_meaning_ref: {
|
||||
asked_domain_family: "business_overview",
|
||||
asked_action_family: "broad_evaluation",
|
||||
explicit_organization_scope: "ООО Альтернатива Плюс",
|
||||
business_overview_separate_entity_candidates: ["Группа СВК"],
|
||||
previous_counterparty_value_flow_bundle: {
|
||||
counterparty: "Группа СВК",
|
||||
incoming_customer_revenue: {
|
||||
total_amount_human_ru: "20 653 490 руб.",
|
||||
rows_with_amount: 26,
|
||||
rows_matched: 26,
|
||||
first_movement_date: "2020-07-27",
|
||||
latest_movement_date: "2021-11-10"
|
||||
},
|
||||
outgoing_supplier_payout: {
|
||||
total_amount_human_ru: "2 129 651 руб.",
|
||||
rows_with_amount: 1,
|
||||
rows_matched: 1,
|
||||
first_movement_date: "2022-01-20",
|
||||
latest_movement_date: "2022-01-20"
|
||||
},
|
||||
net_amount_human_ru: "18 523 839 руб.",
|
||||
net_direction: "net_incoming"
|
||||
},
|
||||
previous_counterparty_document_bundle: {
|
||||
counterparty: "Группа СВК",
|
||||
document_count: 19
|
||||
}
|
||||
},
|
||||
data_need_graph: {
|
||||
business_fact_family: "business_overview",
|
||||
subject_candidates: [],
|
||||
ranking_need: null,
|
||||
reason_codes: ["data_need_graph_family_business_overview"]
|
||||
}
|
||||
},
|
||||
bridge: {
|
||||
bridge_status: "answer_draft_ready",
|
||||
user_facing_response_allowed: true,
|
||||
business_fact_answer_allowed: true,
|
||||
requires_user_clarification: false,
|
||||
pilot: {
|
||||
pilot_scope: "business_overview_route_template_v1",
|
||||
derived_business_overview: {
|
||||
period_scope: null,
|
||||
incoming_customer_revenue: { total_amount_human_ru: "157 192 981,43 руб." },
|
||||
outgoing_supplier_payout: { total_amount_human_ru: "35 439 044,74 руб." },
|
||||
net_amount_human_ru: "121 753 936,69 руб.",
|
||||
net_direction: "net_incoming",
|
||||
top_customers: [],
|
||||
yearly_breakdown: []
|
||||
}
|
||||
},
|
||||
answer_draft: {
|
||||
answer_mode: "confirmed_with_bounded_inference",
|
||||
headline: "Company summary.",
|
||||
confirmed_lines: [],
|
||||
inference_lines: [],
|
||||
unknown_lines: [],
|
||||
limitation_lines: [],
|
||||
next_step_line: null
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const firstLine = candidate.reply_text?.split("\n")[0] ?? "";
|
||||
expect(firstLine).toContain("отдельно по Группа СВК: получили 20 653 490 руб.");
|
||||
expect(firstLine).toContain("можно утверждать только эти подтвержденные срезы");
|
||||
expect(firstLine).toContain("нельзя называть это чистой прибылью");
|
||||
expect(candidate.reply_text).toContain("Отдельно по контрагенту Группа СВК: подтверждено получили 20 653 490 руб.");
|
||||
expect(candidate.reply_text).toContain("заплатили 2 129 651 руб.");
|
||||
expect(candidate.reply_text).toContain("Можно утверждать:");
|
||||
expect(candidate.reply_text).toContain("Нельзя утверждать:");
|
||||
expect(candidate.reply_text).toContain("документы по цепочке: найдено 19");
|
||||
expect(candidate.reply_text).toContain("ранее подтвержденный контрагентский срез");
|
||||
});
|
||||
|
||||
it("localizes value-flow evidence without leaking pilot mechanics", () => {
|
||||
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
|
||||
entryPoint({
|
||||
|
|
@ -294,6 +509,63 @@ describe("assistant MCP discovery response candidate", () => {
|
|||
expect(candidate.reply_text).not.toContain("query_movements");
|
||||
});
|
||||
|
||||
it("uses a compact direct first line for derived bidirectional value-flow totals", () => {
|
||||
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
|
||||
entryPoint({
|
||||
bridge: {
|
||||
bridge_status: "answer_draft_ready",
|
||||
user_facing_response_allowed: true,
|
||||
business_fact_answer_allowed: true,
|
||||
requires_user_clarification: false,
|
||||
pilot: {
|
||||
pilot_scope: "counterparty_bidirectional_value_flow_query_movements_v1",
|
||||
derived_bidirectional_value_flow: {
|
||||
counterparty: "SVK",
|
||||
period_scope: "2020",
|
||||
net_amount_human_ru: "8 500,50 руб.",
|
||||
net_direction: "net_incoming",
|
||||
coverage_limited_by_probe_limit: false,
|
||||
incoming_customer_revenue: {
|
||||
rows_matched: 2,
|
||||
rows_with_amount: 2,
|
||||
total_amount_human_ru: "12 500,50 руб.",
|
||||
first_movement_date: "2020-01-15",
|
||||
latest_movement_date: "2020-02-20"
|
||||
},
|
||||
outgoing_supplier_payout: {
|
||||
rows_matched: 1,
|
||||
rows_with_amount: 1,
|
||||
total_amount_human_ru: "4 000 руб.",
|
||||
first_movement_date: "2020-03-10",
|
||||
latest_movement_date: "2020-03-10"
|
||||
}
|
||||
}
|
||||
},
|
||||
answer_draft: {
|
||||
answer_mode: "confirmed_with_bounded_inference",
|
||||
headline: "По данным 1С найдены строки входящих и исходящих денежных движений.",
|
||||
confirmed_lines: ["1C bidirectional value-flow rows were checked for counterparty SVK: incoming=found, outgoing=found"],
|
||||
inference_lines: ["Counterparty net value-flow was calculated as incoming confirmed 1C rows minus outgoing confirmed 1C rows"],
|
||||
unknown_lines: [],
|
||||
limitation_lines: [],
|
||||
next_step_line: null
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const firstLine = candidate.reply_text?.split("\n")[0] ?? "";
|
||||
expect(firstLine).toContain("Коротко:");
|
||||
expect(firstLine).toContain("SVK");
|
||||
expect(firstLine).toContain("получили 12 500,50 руб.");
|
||||
expect(firstLine).toContain("заплатили 4 000 руб.");
|
||||
expect(firstLine).toContain("нетто в нашу сторону: 8 500,50 руб.");
|
||||
expect(candidate.reply_text).toContain("Основа:");
|
||||
expect(candidate.reply_text).not.toContain("Что подтверждено");
|
||||
expect(candidate.reply_text).not.toContain("pilot_");
|
||||
expect(candidate.reply_text).not.toContain("query_movements");
|
||||
});
|
||||
|
||||
it("keeps monthly breakdown lines user-facing and localizes monthly inference basis", () => {
|
||||
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
|
||||
entryPoint({
|
||||
|
|
|
|||
|
|
@ -135,6 +135,69 @@ describe("assistant MCP discovery response policy", () => {
|
|||
expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_not_discovery_ready_address_candidate");
|
||||
});
|
||||
|
||||
it("lets a grounded business overview candidate override a semantically wrong exact address recipe", () => {
|
||||
const result = applyAssistantMcpDiscoveryResponsePolicy({
|
||||
currentReply: "Supplier and stock overlap was confirmed for 2020.",
|
||||
currentReplySource: "address_query_runtime_v1",
|
||||
currentReplyType: "factual",
|
||||
addressRuntimeMeta: {
|
||||
detected_intent: "inventory_supplier_stock_overlap_as_of_date",
|
||||
selected_recipe: "address_inventory_supplier_stock_overlap_as_of_date_v1",
|
||||
mcp_call_status: "matched_non_empty",
|
||||
truth_mode: "confirmed",
|
||||
capability_binding_status: "bound",
|
||||
capability_binding_violations: [],
|
||||
answer_shape_contract: {
|
||||
reply_type: "factual",
|
||||
capability_contract_id: "inventory_inventory_supplier_stock_overlap_as_of_date"
|
||||
},
|
||||
assistant_mcp_discovery_entry_point_v1: entryPoint({
|
||||
turn_input: {
|
||||
adapter_status: "ready",
|
||||
should_run_discovery: true,
|
||||
turn_meaning_ref: {
|
||||
asked_domain_family: "business_summary",
|
||||
asked_action_family: "broad_evaluation",
|
||||
unsupported_but_understood_family: "broad_business_evaluation",
|
||||
explicit_organization_scope: "OOO Alternative Plus",
|
||||
explicit_date_scope: "2020"
|
||||
},
|
||||
data_need_graph: {
|
||||
business_fact_family: "business_overview",
|
||||
clarification_gaps: []
|
||||
}
|
||||
},
|
||||
bridge: {
|
||||
bridge_status: "answer_draft_ready",
|
||||
user_facing_response_allowed: true,
|
||||
business_fact_answer_allowed: true,
|
||||
requires_user_clarification: false,
|
||||
answer_draft: {
|
||||
answer_mode: "confirmed_with_bounded_inference",
|
||||
headline: "Business overview was assembled from confirmed 1C evidence.",
|
||||
confirmed_lines: [
|
||||
"Incoming customer money flow: 200000.00 RUB.",
|
||||
"Outgoing supplier payouts: 150000.00 RUB."
|
||||
],
|
||||
inference_lines: ["Net confirmed cash-flow spread is +50000.00 RUB; this is not profit."],
|
||||
unknown_lines: ["Profit and formal margin are not confirmed by this overview."],
|
||||
limitation_lines: ["The overview is limited to checked 1C rows."],
|
||||
next_step_line: "Check profit, VAT, debt quality, and inventory liquidity next."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.applied).toBe(true);
|
||||
expect(result.decision).toBe("apply_candidate");
|
||||
expect(result.reply_source).toBe("mcp_discovery_response_candidate_guarded");
|
||||
expect(result.reply_text).toContain("Business overview");
|
||||
expect(result.reply_text).toContain("Incoming customer money flow");
|
||||
expect(result.reason_codes).toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override");
|
||||
expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_exact_matched_factual_address_reply");
|
||||
});
|
||||
|
||||
it("overrides exact inbound value-flow replies when the discovery turn meaning asks for payouts", () => {
|
||||
const result = applyAssistantMcpDiscoveryResponsePolicy({
|
||||
currentReply: "Incoming turnover by SVK: 12 224 925.00 rub.",
|
||||
|
|
@ -744,6 +807,65 @@ describe("assistant MCP discovery response policy", () => {
|
|||
expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override");
|
||||
});
|
||||
|
||||
it("keeps runtime-matched exact VAT replies over a stale business overview discovery seed", () => {
|
||||
const result = applyAssistantMcpDiscoveryResponsePolicy({
|
||||
currentReply: "Short: confirmed VAT for 2020 is based on checked VAT rows.",
|
||||
currentReplySource: "address_query_runtime_v1",
|
||||
currentReplyType: "factual",
|
||||
addressRuntimeMeta: {
|
||||
detected_intent: "vat_liability_confirmed_for_tax_period",
|
||||
selected_recipe: "address_vat_liability_confirmed_tax_period_v1",
|
||||
mcp_call_status: "matched_non_empty",
|
||||
truth_mode: "confirmed",
|
||||
capability_binding_status: "bound",
|
||||
capability_binding_violations: [],
|
||||
truth_gate_contract_status: "full_confirmed",
|
||||
assistant_truth_answer_policy_v1: {
|
||||
truth_gate: {
|
||||
coverage_status: "full",
|
||||
grounding_status: "grounded",
|
||||
source_truth_gate_status: "full_confirmed"
|
||||
},
|
||||
answer_shape: {
|
||||
reply_type: "factual",
|
||||
capability_contract_id: "confirmed_vat_liability_for_tax_period"
|
||||
}
|
||||
},
|
||||
assistant_state_transition_v1: {
|
||||
reason_codes: [
|
||||
"root_followup_continue_previous",
|
||||
"route_expectation_matched",
|
||||
"vat_period_inspection_bridge_signal_detected",
|
||||
"confirmed_balance_exact_vat_tax_period_intent"
|
||||
]
|
||||
},
|
||||
assistant_mcp_discovery_entry_point_v1: entryPoint({
|
||||
turn_input: {
|
||||
adapter_status: "ready",
|
||||
should_run_discovery: true,
|
||||
turn_meaning_ref: {
|
||||
asked_domain_family: "business_overview",
|
||||
asked_action_family: "broad_evaluation",
|
||||
unsupported_but_understood_family: "broad_business_evaluation"
|
||||
},
|
||||
data_need_graph: {
|
||||
business_fact_family: "business_overview",
|
||||
clarification_gaps: []
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.decision).toBe("keep_current_reply");
|
||||
expect(result.reply_text).toContain("confirmed VAT");
|
||||
expect(result.reason_codes).toContain(
|
||||
"mcp_discovery_response_policy_keep_runtime_matched_exact_reply_over_stale_discovery_turn_meaning"
|
||||
);
|
||||
expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override");
|
||||
});
|
||||
|
||||
it("keeps address lane answers when discovery was not requested for the current turn", () => {
|
||||
const result = applyAssistantMcpDiscoveryResponsePolicy({
|
||||
currentReply: "supported exact route answer",
|
||||
|
|
|
|||
|
|
@ -279,6 +279,66 @@ describe("assistant MCP discovery runtime bridge", () => {
|
|||
expect(result.answer_draft.confirmed_lines.join("\n")).toContain("заплатили");
|
||||
});
|
||||
|
||||
it("executes explicit-counterparty bidirectional comparison without period as bounded all-time scope", async () => {
|
||||
const result = await runAssistantMcpDiscoveryRuntimeBridge({
|
||||
dataNeedGraph: {
|
||||
schema_version: "assistant_data_need_graph_v1",
|
||||
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
|
||||
subject_candidates: ["SVK"],
|
||||
business_fact_family: "value_flow",
|
||||
action_family: "net_value_flow",
|
||||
aggregation_need: null,
|
||||
time_scope_need: "all_time_scope",
|
||||
comparison_need: "incoming_vs_outgoing",
|
||||
ranking_need: null,
|
||||
proof_expectation: "coverage_checked_fact",
|
||||
clarification_gaps: [],
|
||||
decomposition_candidates: [
|
||||
"resolve_entity_reference",
|
||||
"collect_incoming_movements",
|
||||
"collect_outgoing_movements",
|
||||
"probe_coverage"
|
||||
],
|
||||
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
|
||||
reason_codes: [
|
||||
"data_need_graph_built",
|
||||
"data_need_graph_comparison_incoming_vs_outgoing",
|
||||
"data_need_graph_subject_bidirectional_value_flow_defaults_to_all_time_scope"
|
||||
]
|
||||
},
|
||||
turnMeaning: {
|
||||
asked_domain_family: "counterparty_value",
|
||||
asked_action_family: "net_value_flow",
|
||||
explicit_entity_candidates: ["SVK"],
|
||||
unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting"
|
||||
},
|
||||
deps: buildBidirectionalDeps(
|
||||
[
|
||||
{ Period: "2020-01-10T00:00:00", Amount: 3200, Counterparty: "SVK" },
|
||||
{ Period: "2021-04-11T00:00:00", Amount: 1800, Counterparty: "SVK" }
|
||||
],
|
||||
[{ Period: "2022-02-12T00:00:00", Amount: 1400, Counterparty: "SVK" }]
|
||||
)
|
||||
});
|
||||
|
||||
expect(result.bridge_status).toBe("answer_draft_ready");
|
||||
expect(result.requires_user_clarification).toBe(false);
|
||||
expect(result.business_fact_answer_allowed).toBe(true);
|
||||
expect(result.planner.selected_chain_id).toBe("value_flow_comparison");
|
||||
expect(result.planner.required_axes).toContain("all_time_scope");
|
||||
expect(result.pilot.mcp_execution_performed).toBe(true);
|
||||
expect(result.pilot.derived_bidirectional_value_flow).toMatchObject({
|
||||
period_scope: null,
|
||||
incoming_customer_revenue: {
|
||||
total_amount: 5000
|
||||
},
|
||||
outgoing_supplier_payout: {
|
||||
total_amount: 1400
|
||||
}
|
||||
});
|
||||
expect(result.answer_draft.confirmed_lines.join("\n")).toContain("SVK");
|
||||
});
|
||||
|
||||
it("keeps document-ready plans bounded when the pilot finds no confirmed rows", async () => {
|
||||
const result = await runAssistantMcpDiscoveryRuntimeBridge({
|
||||
turnMeaning: {
|
||||
|
|
|
|||
|
|
@ -133,6 +133,39 @@ describe("assistant MCP discovery turn input adapter", () => {
|
|||
expect(result.reason_codes).not.toContain("mcp_discovery_payout_signal_detected");
|
||||
});
|
||||
|
||||
it("keeps explicit counterparty money-flow basis questions executable without requiring a period", () => {
|
||||
const result = buildAssistantMcpDiscoveryTurnInput({
|
||||
userMessage: "How much money passed with SVK, incoming and outgoing, and what documents or movements prove it?",
|
||||
assistantTurnMeaning: {
|
||||
asked_domain_family: "counterparty",
|
||||
asked_action_family: "list_documents",
|
||||
explicit_intent_candidate: "list_documents_by_counterparty",
|
||||
explicit_entity_candidates: [{ value: "SVK" }]
|
||||
},
|
||||
predecomposeContract: {
|
||||
entities: { counterparty: "SVK" },
|
||||
period: { scope: "unspecified", period_from: null, period_to: null }
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.adapter_status).toBe("ready");
|
||||
expect(result.should_run_discovery).toBe(true);
|
||||
expect(result.turn_meaning_ref).toMatchObject({
|
||||
asked_domain_family: "counterparty_value",
|
||||
asked_action_family: "net_value_flow",
|
||||
explicit_entity_candidates: ["SVK"],
|
||||
unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting",
|
||||
stale_replay_forbidden: true
|
||||
});
|
||||
expect(result.turn_meaning_ref?.explicit_date_scope).toBeUndefined();
|
||||
expect(result.data_need_graph?.time_scope_need).toBe("all_time_scope");
|
||||
expect(result.data_need_graph?.clarification_gaps).toEqual([]);
|
||||
expect(result.data_need_graph?.reason_codes).toContain(
|
||||
"data_need_graph_subject_bidirectional_value_flow_defaults_to_all_time_scope"
|
||||
);
|
||||
expect(result.reason_codes).toContain("mcp_discovery_bidirectional_value_flow_signal_detected");
|
||||
});
|
||||
|
||||
it("extracts compact scoped counterparty from net follow-up wording when LLM entities are empty", () => {
|
||||
const orgName = "ООО Альтернатива Плюс";
|
||||
const result = buildAssistantMcpDiscoveryTurnInput({
|
||||
|
|
@ -938,6 +971,39 @@ describe("assistant MCP discovery turn input adapter", () => {
|
|||
expect(result.reason_codes).toContain("mcp_discovery_date_scope_from_followup_context");
|
||||
});
|
||||
|
||||
it("does not leak implicit current date into document follow-up after all-time bidirectional value-flow", () => {
|
||||
const orgName = "ООО Альтернатива Плюс";
|
||||
const counterpartyName = "Группа СВК";
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const result = buildAssistantMcpDiscoveryTurnInput({
|
||||
userMessage: "а покажи документы по этой цепочке",
|
||||
followupContext: {
|
||||
previous_discovery_pilot_scope: "counterparty_bidirectional_value_flow_query_movements_v1",
|
||||
previous_filters: {
|
||||
counterparty: counterpartyName,
|
||||
organization: orgName,
|
||||
as_of_date: today
|
||||
},
|
||||
previous_anchor_type: "counterparty",
|
||||
previous_anchor_value: counterpartyName
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.adapter_status).toBe("ready");
|
||||
expect(result.should_run_discovery).toBe(true);
|
||||
expect(result.turn_meaning_ref).toMatchObject({
|
||||
asked_domain_family: "documents",
|
||||
asked_action_family: "list_documents",
|
||||
explicit_entity_candidates: [counterpartyName],
|
||||
explicit_organization_scope: orgName,
|
||||
unsupported_but_understood_family: "document_evidence",
|
||||
stale_replay_forbidden: true
|
||||
});
|
||||
expect(result.turn_meaning_ref?.explicit_date_scope).toBeUndefined();
|
||||
expect(result.reason_codes).toContain("mcp_discovery_value_flow_grounded_document_followup");
|
||||
expect(result.reason_codes).not.toContain("mcp_discovery_date_scope_from_followup_context");
|
||||
});
|
||||
|
||||
it("seeds short metadata follow-up from prior metadata discovery context", () => {
|
||||
const result = buildAssistantMcpDiscoveryTurnInput({
|
||||
userMessage: "а по регистрам?",
|
||||
|
|
@ -1531,6 +1597,40 @@ describe("assistant MCP discovery turn input adapter", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("keeps a raw business-overview year over a predecompose as-of date derived from that year", () => {
|
||||
const result = buildAssistantMcpDiscoveryTurnInput({
|
||||
userMessage:
|
||||
"\u0414\u0430\u0439 \u0432\u0437\u0440\u043e\u0441\u043b\u044b\u0439 \u0431\u0438\u0437\u043d\u0435\u0441-\u043e\u0431\u0437\u043e\u0440 \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441 \u0437\u0430 2020 \u0433\u043e\u0434: \u0434\u0435\u043d\u044c\u0433\u0438, \u041d\u0414\u0421, \u0434\u043e\u043b\u0433\u0438, \u0441\u043a\u043b\u0430\u0434.",
|
||||
assistantTurnMeaning: {
|
||||
asked_domain_family: "business_summary",
|
||||
asked_action_family: "broad_evaluation",
|
||||
unsupported_but_understood_family: "broad_business_evaluation",
|
||||
stale_replay_forbidden: true
|
||||
},
|
||||
predecomposeContract: {
|
||||
entities: {
|
||||
organization: "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"
|
||||
},
|
||||
period: {
|
||||
scope: "as_of",
|
||||
period_from: "2020-01-01",
|
||||
period_to: "2020-12-31",
|
||||
as_of_date: "2020-12-31",
|
||||
has_explicit_period: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.adapter_status).toBe("ready");
|
||||
expect(result.data_need_graph?.business_fact_family).toBe("business_overview");
|
||||
expect(result.turn_meaning_ref).toMatchObject({
|
||||
asked_domain_family: "business_overview",
|
||||
asked_action_family: "broad_evaluation",
|
||||
explicit_date_scope: "2020"
|
||||
});
|
||||
expect(result.reason_codes).toContain("mcp_discovery_business_overview_raw_year_overrode_predecompose_as_of_scope");
|
||||
});
|
||||
|
||||
it("keeps all-time business overview from reusing a negated VAT period as active scope", () => {
|
||||
const result = buildAssistantMcpDiscoveryTurnInput({
|
||||
userMessage:
|
||||
|
|
@ -2937,7 +3037,7 @@ describe("assistant MCP discovery turn input adapter", () => {
|
|||
expect(result.reason_codes).not.toContain("mcp_discovery_counterparty_from_followup_context");
|
||||
});
|
||||
|
||||
it("keeps VAT-position follow-up inside business overview instead of stale inventory position", () => {
|
||||
it("lets an explicit VAT follow-up stay on the exact VAT route instead of stale business overview", () => {
|
||||
const orgName =
|
||||
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
|
||||
const result = buildAssistantMcpDiscoveryTurnInput({
|
||||
|
|
@ -2957,21 +3057,16 @@ describe("assistant MCP discovery turn input adapter", () => {
|
|||
}
|
||||
});
|
||||
|
||||
expect(result.adapter_status).toBe("ready");
|
||||
expect(result.should_run_discovery).toBe(true);
|
||||
expect(result.semantic_data_need).toBe("business overview evidence with bounded analyst interpretation");
|
||||
expect(result.data_need_graph?.business_fact_family).toBe("business_overview");
|
||||
expect(result.data_need_graph?.subject_candidates).toEqual([]);
|
||||
expect(result.turn_meaning_ref).toMatchObject({
|
||||
asked_domain_family: "business_overview",
|
||||
asked_action_family: "broad_evaluation",
|
||||
explicit_organization_scope: orgName,
|
||||
explicit_date_scope: "2020",
|
||||
unsupported_but_understood_family: "broad_business_evaluation",
|
||||
stale_replay_forbidden: true
|
||||
});
|
||||
expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined();
|
||||
expect(result.reason_codes).toContain("mcp_discovery_business_overview_continuation_from_followup_context");
|
||||
expect(result.adapter_status).toBe("not_applicable");
|
||||
expect(result.should_run_discovery).toBe(false);
|
||||
expect(result.semantic_data_need).toBeNull();
|
||||
expect(result.data_need_graph).toBeNull();
|
||||
expect(result.turn_meaning_ref).toBeNull();
|
||||
expect(result.reason_codes).toContain(
|
||||
"mcp_discovery_business_overview_continuation_suppressed_by_explicit_vat_question"
|
||||
);
|
||||
expect(result.reason_codes).toContain("mcp_discovery_not_applicable_for_supported_exact_turn");
|
||||
expect(result.reason_codes).not.toContain("mcp_discovery_business_overview_continuation_from_followup_context");
|
||||
});
|
||||
|
||||
it("routes business overview final-summary wording to the overview lane without document pseudo subject", () => {
|
||||
|
|
@ -3012,4 +3107,108 @@ describe("assistant MCP discovery turn input adapter", () => {
|
|||
expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined();
|
||||
expect(result.reason_codes).toContain("mcp_discovery_business_overview_continuation_from_followup_context");
|
||||
});
|
||||
|
||||
it("routes cross-scope executive summary over stale document carryover and keeps prior bundles", () => {
|
||||
const orgName =
|
||||
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
|
||||
const counterpartyName = "\u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a";
|
||||
const valueFlowBundle = {
|
||||
counterparty: counterpartyName,
|
||||
incoming_total: 20653490,
|
||||
outgoing_total: 2129651,
|
||||
net_amount: 18523839
|
||||
};
|
||||
const documentBundle = {
|
||||
counterparty: counterpartyName,
|
||||
document_count: 19,
|
||||
direct_answer:
|
||||
"\u041a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442: \u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a. \u041d\u0430\u0439\u0434\u0435\u043d\u043e \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432: 19."
|
||||
};
|
||||
|
||||
const result = buildAssistantMcpDiscoveryTurnInput({
|
||||
userMessage:
|
||||
"\u0421\u043e\u0431\u0435\u0440\u0438 \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 \u0438\u0442\u043e\u0433: \u0447\u0442\u043e \u043c\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u043e \u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438, \u0447\u0442\u043e \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e \u043f\u043e \u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a, \u043a\u0430\u043a\u0438\u0435 \u0432\u044b\u0432\u043e\u0434\u044b \u043c\u043e\u0436\u043d\u043e \u0434\u0435\u043b\u0430\u0442\u044c \u0438 \u043a\u0430\u043a\u0438\u0435 \u043d\u0435\u043b\u044c\u0437\u044f.",
|
||||
assistantTurnMeaning: {
|
||||
asked_domain_family: "counterparty",
|
||||
asked_action_family: "list_documents",
|
||||
explicit_intent_candidate: "list_documents_by_counterparty"
|
||||
},
|
||||
followupContext: {
|
||||
previous_discovery_pilot_scope: "counterparty_document_evidence_query_documents_v1",
|
||||
previous_intent: "list_documents_by_counterparty",
|
||||
target_intent: "list_documents_by_counterparty",
|
||||
previous_filters: {
|
||||
organization: orgName,
|
||||
counterparty: counterpartyName,
|
||||
as_of_date: "2026-05-09"
|
||||
},
|
||||
previous_anchor_type: "counterparty",
|
||||
previous_anchor_value: counterpartyName,
|
||||
previous_discovery_bidirectional_value_flow: valueFlowBundle,
|
||||
previous_discovery_document_summary: documentBundle
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.adapter_status).toBe("ready");
|
||||
expect(result.should_run_discovery).toBe(true);
|
||||
expect(result.semantic_data_need).toBe("business overview evidence with bounded analyst interpretation");
|
||||
expect(result.data_need_graph?.business_fact_family).toBe("business_overview");
|
||||
expect(result.data_need_graph?.subject_candidates).toEqual([]);
|
||||
expect(result.turn_meaning_ref).toMatchObject({
|
||||
asked_domain_family: "business_overview",
|
||||
asked_action_family: "broad_evaluation",
|
||||
business_overview_separate_entity_candidates: [counterpartyName],
|
||||
previous_counterparty_value_flow_bundle: valueFlowBundle,
|
||||
previous_counterparty_document_bundle: documentBundle,
|
||||
explicit_organization_scope: orgName,
|
||||
unsupported_but_understood_family: "broad_business_evaluation",
|
||||
stale_replay_forbidden: true
|
||||
});
|
||||
expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined();
|
||||
expect(result.reason_codes).toContain(
|
||||
"mcp_discovery_business_overview_preserved_explicit_counterparty_summary_scope"
|
||||
);
|
||||
expect(result.reason_codes).not.toContain("mcp_discovery_not_applicable_for_supported_exact_turn");
|
||||
});
|
||||
|
||||
it("preserves explicit counterparty scope for company plus counterparty business summaries", () => {
|
||||
const orgName = "ООО Альтернатива Плюс";
|
||||
const counterpartyName = "Группа СВК";
|
||||
const result = buildAssistantMcpDiscoveryTurnInput({
|
||||
userMessage:
|
||||
"Собери короткий итог: что мы подтвердили по компании, что отдельно по Группа СВК, какие выводы можно делать и какие нельзя.",
|
||||
assistantTurnMeaning: {
|
||||
asked_domain_family: "business_summary",
|
||||
asked_action_family: "broad_evaluation",
|
||||
unsupported_but_understood_family: "broad_business_evaluation",
|
||||
stale_replay_forbidden: true
|
||||
},
|
||||
followupContext: {
|
||||
previous_discovery_pilot_scope: "counterparty_document_evidence_query_documents_v1",
|
||||
previous_filters: {
|
||||
organization: orgName,
|
||||
counterparty: counterpartyName
|
||||
},
|
||||
previous_anchor_type: "counterparty",
|
||||
previous_anchor_value: counterpartyName
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.adapter_status).toBe("ready");
|
||||
expect(result.data_need_graph?.business_fact_family).toBe("business_overview");
|
||||
expect(result.data_need_graph?.subject_candidates).toEqual([]);
|
||||
expect(result.turn_meaning_ref).toMatchObject({
|
||||
asked_domain_family: "business_overview",
|
||||
asked_action_family: "broad_evaluation",
|
||||
business_overview_separate_entity_candidates: [counterpartyName],
|
||||
explicit_organization_scope: orgName,
|
||||
unsupported_but_understood_family: "broad_business_evaluation",
|
||||
stale_replay_forbidden: true
|
||||
});
|
||||
expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined();
|
||||
expect(result.reason_codes).toContain(
|
||||
"mcp_discovery_business_overview_preserved_explicit_counterparty_summary_scope"
|
||||
);
|
||||
expect(result.reason_codes).not.toContain("mcp_discovery_business_overview_suppressed_stale_counterparty");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -69,6 +69,27 @@ describe("assistantMemoryRecapPolicy", () => {
|
|||
expect(signals.contextualMemoryRecapFollowupDetected).toBe(true);
|
||||
});
|
||||
|
||||
it("detects startup memory checkpoint without prior grounded context", () => {
|
||||
const signals = policy.resolveRouteMemorySignals({
|
||||
rawUserMessage:
|
||||
"Сделай короткий стартовый чек контекста: есть ли уже выбранная компания или контрагент в текущем диалоге; если нет, скажи честно и не выдумывай память про Группа СВК.",
|
||||
repairedRawUserMessage: "",
|
||||
effectiveAddressUserMessage: "",
|
||||
repairedEffectiveAddressUserMessage: "",
|
||||
dataScopeMetaQuery: false,
|
||||
capabilityMetaQuery: false,
|
||||
dataRetrievalSignal: false,
|
||||
strongDataSignal: true,
|
||||
aggregateBusinessAnalyticsSignal: false,
|
||||
lastGroundedAddressDebug: null,
|
||||
hasPriorAddressDebug: false,
|
||||
sessionItems: []
|
||||
});
|
||||
|
||||
expect(signals.contextualHistoricalCapabilityFollowupDetected).toBe(false);
|
||||
expect(signals.contextualMemoryRecapFollowupDetected).toBe(true);
|
||||
});
|
||||
|
||||
it("treats explicit recap wording over selected-object phrasing as memory follow-up even when data cues are present", () => {
|
||||
const signals = policy.resolveRouteMemorySignals({
|
||||
rawUserMessage: "а ты помнишь, что мы по этой позиции уже выяснили?",
|
||||
|
|
@ -324,6 +345,25 @@ describe("assistantMemoryRecapPolicy", () => {
|
|||
expect(reply).toContain("подняли документы закупки");
|
||||
});
|
||||
|
||||
it("honestly reports empty memory when startup checkpoint has no selected context", () => {
|
||||
const reply = buildAddressMemoryRecapReply({
|
||||
organization: null,
|
||||
addressDebug: null,
|
||||
sessionItems: [],
|
||||
userMessage:
|
||||
"Сделай короткий стартовый чек контекста: есть ли уже выбранная компания или контрагент в текущем диалоге; если нет, скажи честно и не выдумывай память про Группа СВК.",
|
||||
toNonEmptyString: (value: unknown) => {
|
||||
const text = String(value ?? "").trim();
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
});
|
||||
|
||||
expect(reply).toContain("Коротко: в текущем диалоге я не вижу выбранной компании");
|
||||
expect(reply).toContain("Группа СВК");
|
||||
expect(reply).toContain("не подтверждена");
|
||||
expect(reply).not.toContain("Да, помню предыдущий адресный контур");
|
||||
});
|
||||
|
||||
it("resolves grounded answer inspection from shared memory context", () => {
|
||||
const context = resolveAssistantLivingChatMemoryContext({
|
||||
modeDecisionReason: "answer_inspection_followup_detected",
|
||||
|
|
|
|||
|
|
@ -131,6 +131,85 @@ describe("assistant truth answer policy runtime adapter", () => {
|
|||
expect(policy.answer_shape.may_power_followup).toBe(true);
|
||||
});
|
||||
|
||||
it("downgrades stale full-confirmed truth gates when coverage evidence is only heuristic", () => {
|
||||
const policy = resolveAssistantTruthAnswerPolicyRuntime({
|
||||
addressDebug: {
|
||||
capability_id: "address_open_items_by_counterparty_or_contract",
|
||||
rows_matched: 8,
|
||||
answer_grounding_check: {
|
||||
status: "grounded",
|
||||
reasons: ["confirmed_balance_unavailable_fallback_to_heuristic_candidates"]
|
||||
},
|
||||
address_coverage_evidence_v1: {
|
||||
schema_version: "address_coverage_evidence_v1",
|
||||
policy_owner: "addressCoverageEvidencePolicy",
|
||||
requested_result_mode: "confirmed_balance",
|
||||
result_mode: "heuristic_candidates",
|
||||
evidence_strength: "medium",
|
||||
balance_confirmed: false,
|
||||
as_of_date_basis: "period_range",
|
||||
coverage_status: "partial",
|
||||
evidence_basis: "heuristic_candidates",
|
||||
reason_codes: [
|
||||
"coverage_status_partial",
|
||||
"result_mode_heuristic_candidates",
|
||||
"balance_confirmed_false"
|
||||
]
|
||||
},
|
||||
address_truth_gate_v1: {
|
||||
schema_version: "address_truth_gate_v1",
|
||||
policy_owner: "addressTruthGatePolicy",
|
||||
truth_gate_status: "full_confirmed",
|
||||
carryover_eligibility: "none",
|
||||
limited_reason_category: null,
|
||||
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
|
||||
reason_codes: ["stale_full_confirmed_shadow"],
|
||||
blocked_or_limited_explanation: null
|
||||
}
|
||||
},
|
||||
replyType: "factual"
|
||||
});
|
||||
|
||||
expect(policy.truth_gate.coverage_status).toBe("partial");
|
||||
expect(policy.truth_gate.truth_mode).toBe("limited");
|
||||
expect(policy.truth_gate.evidence_grade).toBe("medium");
|
||||
expect(policy.truth_gate.reason_codes).toContain("confirmed_balance_unavailable_fallback_to_heuristic_candidates");
|
||||
expect(policy.answer_shape.answer_shape).toBe("limited_with_reason");
|
||||
expect(policy.answer_shape.must_include_limitation).toBe(true);
|
||||
});
|
||||
|
||||
it("downgrades stale full-confirmed truth gates from top-level heuristic result metadata", () => {
|
||||
const policy = resolveAssistantTruthAnswerPolicyRuntime({
|
||||
addressDebug: {
|
||||
capability_id: "address_open_items_by_counterparty_or_contract",
|
||||
rows_matched: 8,
|
||||
result_mode: "heuristic_candidates",
|
||||
evidence_strength: "medium",
|
||||
balance_confirmed: false,
|
||||
answer_grounding_check: {
|
||||
status: "grounded",
|
||||
reasons: ["confirmed_balance_unavailable_fallback_to_heuristic_candidates"]
|
||||
},
|
||||
address_truth_gate_v1: {
|
||||
schema_version: "address_truth_gate_v1",
|
||||
policy_owner: "addressTruthGatePolicy",
|
||||
truth_gate_status: "full_confirmed",
|
||||
carryover_eligibility: "none",
|
||||
limited_reason_category: null,
|
||||
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
|
||||
reason_codes: [],
|
||||
blocked_or_limited_explanation: null
|
||||
}
|
||||
},
|
||||
replyType: "factual"
|
||||
});
|
||||
|
||||
expect(policy.truth_gate.coverage_status).toBe("partial");
|
||||
expect(policy.truth_gate.truth_mode).toBe("limited");
|
||||
expect(policy.truth_gate.evidence_grade).toBe("medium");
|
||||
expect(policy.answer_shape.answer_shape).toBe("limited_with_reason");
|
||||
});
|
||||
|
||||
it("keeps explicit temporal-limited factual answers limited in the truth contract", () => {
|
||||
const policy = resolveAssistantTruthAnswerPolicyRuntime({
|
||||
addressDebug: {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,219 @@
|
|||
{
|
||||
"saved_at": "2026-05-09T18:30:50+00:00",
|
||||
"generation_id": "gen-ag05091830-062fc9",
|
||||
"mode": "saved_user_sessions",
|
||||
"title": "AGENT | Agentic semantic development loop dogfood gate",
|
||||
"agent_run": true,
|
||||
"questions": [
|
||||
"Дай взрослый бизнес-обзор ООО Альтернатива Плюс за 2020 год по данным 1С: обороты, входящие и исходящие деньги, нетто, НДС, долги, склад, клиенты, поставщики и что пока нельзя утверждать.",
|
||||
"Раскрой деньги подробнее: сколько получили, сколько заплатили, какой чистый денежный поток, кто главный клиент и главный поставщик в 2020.",
|
||||
"А если смотреть за все доступное время, какой самый доходный год по подтвержденным оборотам и почему? Не называй это бухгалтерской прибылью, если чистой прибыли нет.",
|
||||
"Что с НДС за 2020 год по ООО Альтернатива Плюс: какая позиция видна, на чем она основана и чего не хватает для налогового вывода?",
|
||||
"Теперь за все доступное время дай обзор компании в целом, но не тащи НДС за 2020 как подтвержденную общую налоговую позицию.",
|
||||
"Отдельно по контрагенту Группа СВК, без опоры на прошлый диалог: сколько денег прошло, что входящее, что исходящее и есть ли документы или движения, на которых это основано?",
|
||||
"Покажи документы по этой цепочке и не смешивай Группа СВК с организацией ООО Альтернатива Плюс.",
|
||||
"Собери короткий итог: что мы подтвердили по компании, что отдельно по Группа СВК, какие выводы можно делать и какие нельзя.",
|
||||
"Сделай короткий стартовый чек контекста: есть ли уже выбранная компания или контрагент в текущем диалоге; если нет, скажи честно и не выдумывай память про Группа СВК.",
|
||||
"Покажи хвосты по счету 60 на август 2020 по ООО Альтернатива Плюс; если точных данных нет, скажи это прямо и не подменяй ответ общим обзором.",
|
||||
"Что было на складе на март 2021 по доступным данным? Дай прямой ответ и не уводи его в контрагента Группа СВК.",
|
||||
"Вернись к ООО Альтернатива Плюс: сколько всего денег получили и заплатили по всем подтвержденным данным, но не смешивай это с отдельной цепочкой Группа СВК и не называй оборот чистой прибылью."
|
||||
],
|
||||
"metadata": {
|
||||
"assistant_prompt_version": null,
|
||||
"decomposition_prompt_version": null,
|
||||
"prompt_fingerprint": null,
|
||||
"agent_focus": "Automate stage question review, live semantic replay, strong business audit, Lead Codex repair handoff, rerun validation, and final human confirmation.",
|
||||
"architecture_phase": "turnaround_11_agentic_semantic_development_loop",
|
||||
"source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\agentic_semantic_development_loop_stage_pack.json",
|
||||
"scenario_id": null,
|
||||
"semantic_tags": [
|
||||
"business_overview",
|
||||
"counterparty",
|
||||
"debt",
|
||||
"documents",
|
||||
"inventory",
|
||||
"memory",
|
||||
"money",
|
||||
"scope_guard",
|
||||
"vat"
|
||||
],
|
||||
"validation_status": "accepted_domain_pack_loop",
|
||||
"validated_run_dir": "artifacts\\domain_runs\\stage_agent_loops\\agentic_semantic_development_loop\\domain_loops\\asl",
|
||||
"saved_after_validated_replay": true,
|
||||
"save_gate": {
|
||||
"schema_version": "agent_semantic_save_gate_v1",
|
||||
"validation_status": "accepted_domain_pack_loop",
|
||||
"validated_run_dir": "artifacts\\domain_runs\\stage_agent_loops\\agentic_semantic_development_loop\\domain_loops\\asl",
|
||||
"final_status": "accepted",
|
||||
"loop_id": "asl",
|
||||
"target_score": 88,
|
||||
"iterations_ran": 1,
|
||||
"quality_score": 91,
|
||||
"repair_target_count": 0,
|
||||
"repair_target_severity_counts": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"accepted_gate": true,
|
||||
"saved_after_validated_replay": true
|
||||
}
|
||||
},
|
||||
"source_session_id": null,
|
||||
"session": {
|
||||
"session_id": null,
|
||||
"mode": "agent_semantic_run",
|
||||
"items": [
|
||||
{
|
||||
"message_id": "agent-user-001",
|
||||
"role": "user",
|
||||
"text": "Дай взрослый бизнес-обзор ООО Альтернатива Плюс за 2020 год по данным 1С: обороты, входящие и исходящие деньги, нетто, НДС, долги, склад, клиенты, поставщики и что пока нельзя утверждать.",
|
||||
"created_at": "2026-05-09T18:30:50+00:00",
|
||||
"reply_type": null,
|
||||
"trace_id": null,
|
||||
"debug": null
|
||||
},
|
||||
{
|
||||
"message_id": "agent-user-002",
|
||||
"role": "user",
|
||||
"text": "Раскрой деньги подробнее: сколько получили, сколько заплатили, какой чистый денежный поток, кто главный клиент и главный поставщик в 2020.",
|
||||
"created_at": "2026-05-09T18:30:50+00:00",
|
||||
"reply_type": null,
|
||||
"trace_id": null,
|
||||
"debug": null
|
||||
},
|
||||
{
|
||||
"message_id": "agent-user-003",
|
||||
"role": "user",
|
||||
"text": "А если смотреть за все доступное время, какой самый доходный год по подтвержденным оборотам и почему? Не называй это бухгалтерской прибылью, если чистой прибыли нет.",
|
||||
"created_at": "2026-05-09T18:30:50+00:00",
|
||||
"reply_type": null,
|
||||
"trace_id": null,
|
||||
"debug": null
|
||||
},
|
||||
{
|
||||
"message_id": "agent-user-004",
|
||||
"role": "user",
|
||||
"text": "Что с НДС за 2020 год по ООО Альтернатива Плюс: какая позиция видна, на чем она основана и чего не хватает для налогового вывода?",
|
||||
"created_at": "2026-05-09T18:30:50+00:00",
|
||||
"reply_type": null,
|
||||
"trace_id": null,
|
||||
"debug": null
|
||||
},
|
||||
{
|
||||
"message_id": "agent-user-005",
|
||||
"role": "user",
|
||||
"text": "Теперь за все доступное время дай обзор компании в целом, но не тащи НДС за 2020 как подтвержденную общую налоговую позицию.",
|
||||
"created_at": "2026-05-09T18:30:50+00:00",
|
||||
"reply_type": null,
|
||||
"trace_id": null,
|
||||
"debug": null
|
||||
},
|
||||
{
|
||||
"message_id": "agent-user-006",
|
||||
"role": "user",
|
||||
"text": "Отдельно по контрагенту Группа СВК, без опоры на прошлый диалог: сколько денег прошло, что входящее, что исходящее и есть ли документы или движения, на которых это основано?",
|
||||
"created_at": "2026-05-09T18:30:50+00:00",
|
||||
"reply_type": null,
|
||||
"trace_id": null,
|
||||
"debug": null
|
||||
},
|
||||
{
|
||||
"message_id": "agent-user-007",
|
||||
"role": "user",
|
||||
"text": "Покажи документы по этой цепочке и не смешивай Группа СВК с организацией ООО Альтернатива Плюс.",
|
||||
"created_at": "2026-05-09T18:30:50+00:00",
|
||||
"reply_type": null,
|
||||
"trace_id": null,
|
||||
"debug": null
|
||||
},
|
||||
{
|
||||
"message_id": "agent-user-008",
|
||||
"role": "user",
|
||||
"text": "Собери короткий итог: что мы подтвердили по компании, что отдельно по Группа СВК, какие выводы можно делать и какие нельзя.",
|
||||
"created_at": "2026-05-09T18:30:50+00:00",
|
||||
"reply_type": null,
|
||||
"trace_id": null,
|
||||
"debug": null
|
||||
},
|
||||
{
|
||||
"message_id": "agent-user-009",
|
||||
"role": "user",
|
||||
"text": "Сделай короткий стартовый чек контекста: есть ли уже выбранная компания или контрагент в текущем диалоге; если нет, скажи честно и не выдумывай память про Группа СВК.",
|
||||
"created_at": "2026-05-09T18:30:50+00:00",
|
||||
"reply_type": null,
|
||||
"trace_id": null,
|
||||
"debug": null
|
||||
},
|
||||
{
|
||||
"message_id": "agent-user-010",
|
||||
"role": "user",
|
||||
"text": "Покажи хвосты по счету 60 на август 2020 по ООО Альтернатива Плюс; если точных данных нет, скажи это прямо и не подменяй ответ общим обзором.",
|
||||
"created_at": "2026-05-09T18:30:50+00:00",
|
||||
"reply_type": null,
|
||||
"trace_id": null,
|
||||
"debug": null
|
||||
},
|
||||
{
|
||||
"message_id": "agent-user-011",
|
||||
"role": "user",
|
||||
"text": "Что было на складе на март 2021 по доступным данным? Дай прямой ответ и не уводи его в контрагента Группа СВК.",
|
||||
"created_at": "2026-05-09T18:30:50+00:00",
|
||||
"reply_type": null,
|
||||
"trace_id": null,
|
||||
"debug": null
|
||||
},
|
||||
{
|
||||
"message_id": "agent-user-012",
|
||||
"role": "user",
|
||||
"text": "Вернись к ООО Альтернатива Плюс: сколько всего денег получили и заплатили по всем подтвержденным данным, но не смешивай это с отдельной цепочкой Группа СВК и не называй оборот чистой прибылью.",
|
||||
"created_at": "2026-05-09T18:30:50+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": "Automate stage question review, live semantic replay, strong business audit, Lead Codex repair handoff, rerun validation, and final human confirmation.",
|
||||
"architecture_phase": "turnaround_11_agentic_semantic_development_loop",
|
||||
"source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\agentic_semantic_development_loop_stage_pack.json",
|
||||
"scenario_id": null,
|
||||
"semantic_tags": [
|
||||
"business_overview",
|
||||
"counterparty",
|
||||
"debt",
|
||||
"documents",
|
||||
"inventory",
|
||||
"memory",
|
||||
"money",
|
||||
"scope_guard",
|
||||
"vat"
|
||||
],
|
||||
"validation_status": "accepted_domain_pack_loop",
|
||||
"validated_run_dir": "artifacts\\domain_runs\\stage_agent_loops\\agentic_semantic_development_loop\\domain_loops\\asl",
|
||||
"saved_after_validated_replay": true,
|
||||
"save_gate": {
|
||||
"schema_version": "agent_semantic_save_gate_v1",
|
||||
"validation_status": "accepted_domain_pack_loop",
|
||||
"validated_run_dir": "artifacts\\domain_runs\\stage_agent_loops\\agentic_semantic_development_loop\\domain_loops\\asl",
|
||||
"final_status": "accepted",
|
||||
"loop_id": "asl",
|
||||
"target_score": 88,
|
||||
"iterations_ran": 1,
|
||||
"quality_score": 91,
|
||||
"repair_target_count": 0,
|
||||
"repair_target_severity_counts": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"accepted_gate": true,
|
||||
"saved_after_validated_replay": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"suite_id": "assistant_saved_session_gen-ag05091830-062fc9",
|
||||
"suite_version": "0.1.0",
|
||||
"schema_version": "assistant_saved_session_suite_v0_1",
|
||||
"generated_at": "2026-05-09T18:30:50+00:00",
|
||||
"generation_id": "gen-ag05091830-062fc9",
|
||||
"mode": "saved_user_sessions",
|
||||
"title": "AGENT | Agentic semantic development loop dogfood gate",
|
||||
"domain": "agentic_semantic_development_loop_control",
|
||||
"scenario_count": 1,
|
||||
"case_ids": [
|
||||
"SAVED-001"
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"case_id": "SAVED-001",
|
||||
"scenario_tag": "agent_saved_user_sessions",
|
||||
"title": "AGENT | Agentic semantic development loop dogfood gate",
|
||||
"question_type": "followup",
|
||||
"broadness_level": "medium",
|
||||
"turns": [
|
||||
{
|
||||
"user_message": "Дай взрослый бизнес-обзор ООО Альтернатива Плюс за 2020 год по данным 1С: обороты, входящие и исходящие деньги, нетто, НДС, долги, склад, клиенты, поставщики и что пока нельзя утверждать."
|
||||
},
|
||||
{
|
||||
"user_message": "Раскрой деньги подробнее: сколько получили, сколько заплатили, какой чистый денежный поток, кто главный клиент и главный поставщик в 2020."
|
||||
},
|
||||
{
|
||||
"user_message": "А если смотреть за все доступное время, какой самый доходный год по подтвержденным оборотам и почему? Не называй это бухгалтерской прибылью, если чистой прибыли нет."
|
||||
},
|
||||
{
|
||||
"user_message": "Что с НДС за 2020 год по ООО Альтернатива Плюс: какая позиция видна, на чем она основана и чего не хватает для налогового вывода?"
|
||||
},
|
||||
{
|
||||
"user_message": "Теперь за все доступное время дай обзор компании в целом, но не тащи НДС за 2020 как подтвержденную общую налоговую позицию."
|
||||
},
|
||||
{
|
||||
"user_message": "Отдельно по контрагенту Группа СВК, без опоры на прошлый диалог: сколько денег прошло, что входящее, что исходящее и есть ли документы или движения, на которых это основано?"
|
||||
},
|
||||
{
|
||||
"user_message": "Покажи документы по этой цепочке и не смешивай Группа СВК с организацией ООО Альтернатива Плюс."
|
||||
},
|
||||
{
|
||||
"user_message": "Собери короткий итог: что мы подтвердили по компании, что отдельно по Группа СВК, какие выводы можно делать и какие нельзя."
|
||||
},
|
||||
{
|
||||
"user_message": "Сделай короткий стартовый чек контекста: есть ли уже выбранная компания или контрагент в текущем диалоге; если нет, скажи честно и не выдумывай память про Группа СВК."
|
||||
},
|
||||
{
|
||||
"user_message": "Покажи хвосты по счету 60 на август 2020 по ООО Альтернатива Плюс; если точных данных нет, скажи это прямо и не подменяй ответ общим обзором."
|
||||
},
|
||||
{
|
||||
"user_message": "Что было на складе на март 2021 по доступным данным? Дай прямой ответ и не уводи его в контрагента Группа СВК."
|
||||
},
|
||||
{
|
||||
"user_message": "Вернись к ООО Альтернатива Плюс: сколько всего денег получили и заплатили по всем подтвержденным данным, но не смешивай это с отдельной цепочкой Группа СВК и не называй оборот чистой прибылью."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
{
|
||||
"suite_id": "assistant_saved_session_runtime_job-8LkHvkpEuA",
|
||||
"suite_version": "0.1.0",
|
||||
"schema_version": "assistant_saved_session_runtime_v0_1",
|
||||
"title": "БОЛЬШОЙ ОБЩИЙ Ручная сессия 16.04.2026, 21:26:06",
|
||||
"scenario_count": 1,
|
||||
"case_ids": [
|
||||
"SAVED-001"
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"case_id": "SAVED-001",
|
||||
"scenario_tag": "saved_user_sessions_runtime",
|
||||
"title": "БОЛЬШОЙ ОБЩИЙ Ручная сессия 16.04.2026, 21:26:06",
|
||||
"question_type": "followup",
|
||||
"broadness_level": "medium",
|
||||
"turns": [
|
||||
{
|
||||
"user_message": "приветик - че как там дела"
|
||||
},
|
||||
{
|
||||
"user_message": "расскажи что можешь интересного"
|
||||
},
|
||||
{
|
||||
"user_message": "кайф - что там на складе по остаткам?"
|
||||
},
|
||||
{
|
||||
"user_message": "АЛЬТЕРНАТИВА"
|
||||
},
|
||||
{
|
||||
"user_message": "а исторические остатки на другие даты умеешь?"
|
||||
},
|
||||
{
|
||||
"user_message": "давай на июль 2017"
|
||||
},
|
||||
{
|
||||
"user_message": "март 2016"
|
||||
},
|
||||
{
|
||||
"user_message": "По выбранному объекту \"Рабочая станция универсального специалиста (индивидуальное изготовление)\": где взяли это?"
|
||||
},
|
||||
{
|
||||
"user_message": "а кому продали?"
|
||||
},
|
||||
{
|
||||
"user_message": "у тебя написано кто контрагент: рабочая станция - это ошибка?"
|
||||
},
|
||||
{
|
||||
"user_message": "ндс можешь прикинуть на дату покупки рабочей станции?"
|
||||
},
|
||||
{
|
||||
"user_message": "а какой ндс мы должны сгрузить на март 2020?"
|
||||
},
|
||||
{
|
||||
"user_message": "прикинь какой ндс нам надо заплатить на февраль 2017"
|
||||
},
|
||||
{
|
||||
"user_message": "кто у нас самый доходный клиент за все время"
|
||||
},
|
||||
{
|
||||
"user_message": "кто нам должен денег на май 2017"
|
||||
},
|
||||
{
|
||||
"user_message": "а какой ндс мы должны примерно заплатить за этот период?"
|
||||
},
|
||||
{
|
||||
"user_message": "мы должны комуто денег на сегодня?"
|
||||
},
|
||||
{
|
||||
"user_message": "а нам?"
|
||||
},
|
||||
{
|
||||
"user_message": "какой у нас самый доходный год"
|
||||
},
|
||||
{
|
||||
"user_message": "а за 2017 мы скок заработали?"
|
||||
},
|
||||
{
|
||||
"user_message": "сколько вообще денег мы заработали за все время?"
|
||||
},
|
||||
{
|
||||
"user_message": "ты умеешь считать дельту по договорам?"
|
||||
},
|
||||
{
|
||||
"user_message": "по чепурнову покажи все доки"
|
||||
},
|
||||
{
|
||||
"user_message": "а по свк"
|
||||
},
|
||||
{
|
||||
"user_message": "а сейчас у нас есть что на складе?"
|
||||
},
|
||||
{
|
||||
"user_message": "что нам отгружал чепурнов? какой товар или услугу?"
|
||||
},
|
||||
{
|
||||
"user_message": "какие остатки на складе на сегодня"
|
||||
},
|
||||
{
|
||||
"user_message": "остатки на март 2016"
|
||||
},
|
||||
{
|
||||
"user_message": "хвосты покажи по счету 60 на август 2022"
|
||||
},
|
||||
{
|
||||
"user_message": "Есть ли остатки товара, которые закупались очень давно"
|
||||
},
|
||||
{
|
||||
"user_message": "Какие конкретно номенклатуры формируют остаток по складу на май 2020"
|
||||
},
|
||||
{
|
||||
"user_message": "а по Альтернативе Плюс сколько лет активности в базе 1С?"
|
||||
},
|
||||
{
|
||||
"user_message": "Как ты оценишь деятельность компании?"
|
||||
},
|
||||
{
|
||||
"user_message": "какое нетто по деньгам с Группа СВК за 2020 год: сколько получили и сколько заплатили?"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
{
|
||||
"suite_id": "assistant_saved_session_runtime_job-Q8urvLyjn6",
|
||||
"suite_version": "0.1.0",
|
||||
"schema_version": "assistant_saved_session_runtime_v0_1",
|
||||
"title": "MANUAL QA | Open-World 99 жирный прогон: бизнес-обзор, pivots, legacy canaries",
|
||||
"scenario_count": 1,
|
||||
"case_ids": [
|
||||
"SAVED-001"
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"case_id": "SAVED-001",
|
||||
"scenario_tag": "saved_user_sessions_runtime",
|
||||
"title": "MANUAL QA | Open-World 99 жирный прогон: бизнес-обзор, pivots, legacy canaries",
|
||||
"question_type": "followup",
|
||||
"broadness_level": "medium",
|
||||
"turns": [
|
||||
{
|
||||
"user_message": "привет, ты на связи? перед большим прогоном отвечай живо, но не теряй потом бизнес-контекст"
|
||||
},
|
||||
{
|
||||
"user_message": "Дай взрослый бизнес-обзор ООО Альтернатива Плюс за 2020 год по данным 1С: обороты, входящие и исходящие деньги, нетто, НДС, дебиторка, кредиторка, склад, клиенты, поставщики, договоры, документы, что подтверждено и что пока нельзя утверждать."
|
||||
},
|
||||
{
|
||||
"user_message": "Раскрой деньги подробнее: сколько всего получили, сколько заплатили, какой чистый денежный поток, кто главный клиент и кто главный поставщик в 2020."
|
||||
},
|
||||
{
|
||||
"user_message": "А если смотреть за все доступное время, какой самый доходный год по подтвержденным оборотам и почему? Только не называй это бухгалтерской прибылью, если ее нет."
|
||||
},
|
||||
{
|
||||
"user_message": "Можно ли по этим данным посчитать нормальную прибыль и маржу компании? Если нет, дай proxy-анализ и объясни, каких учетных доказательств не хватает."
|
||||
},
|
||||
{
|
||||
"user_message": "Кто крупнейшие клиенты Альтернативы Плюс и насколько бизнес зависит от одного покупателя?"
|
||||
},
|
||||
{
|
||||
"user_message": "А по поставщикам: кто самый крупный получатель исходящих денег и есть ли риск зависимости от поставщика?"
|
||||
},
|
||||
{
|
||||
"user_message": "Что с НДС за 2020 год по Альтернативе Плюс: какая позиция видна, на чем она основана и чего не хватает для налогового вывода?"
|
||||
},
|
||||
{
|
||||
"user_message": "Теперь за все доступное время дай обзор компании в целом, но не тащи НДС за 2020 как подтвержденную общую налоговую позицию."
|
||||
},
|
||||
{
|
||||
"user_message": "Какая дебиторка и кредиторка у Альтернативы Плюс на 2020-12-31, и где самые крупные открытые расчеты?"
|
||||
},
|
||||
{
|
||||
"user_message": "Это можно считать просрочкой и плохим качеством долга или пока только открытыми расчетами? Объясни аккуратно."
|
||||
},
|
||||
{
|
||||
"user_message": "Теперь снова за все время по компании: дай общий бизнес-обзор, но не тащи долговой срез на 2020-12-31 как текущую или общую долговую позицию."
|
||||
},
|
||||
{
|
||||
"user_message": "Покажи складской срез Альтернативы Плюс на 2026-04-16: что есть в остатках, какие самые заметные позиции, и что это говорит о бизнесе."
|
||||
},
|
||||
{
|
||||
"user_message": "Можно ли из этого сказать, что склад ликвидный или что надо создавать резервы/списывать неликвид? Если нет, что именно подтверждено и чего не хватает?"
|
||||
},
|
||||
{
|
||||
"user_message": "Теперь общий обзор Альтернативы Плюс за все время, но не тащи складской остаток на 2026-04-16 как общий all-time склад."
|
||||
},
|
||||
{
|
||||
"user_message": "Сколько реально активных контрагентов и договоров видно по Альтернативе Плюс, какие роли у контрагентов, и какие договоры используются чаще всего?"
|
||||
},
|
||||
{
|
||||
"user_message": "Какой профиль документов и разделов учета виден по компании: продажи, закупки, банк, склад, НДС? Где активность плотнее всего?"
|
||||
},
|
||||
{
|
||||
"user_message": "Собери это как нормальный бизнес-аудит: сильные стороны, риски, что уже можно сказать уверенно, что только proxy, и что директору проверить руками."
|
||||
},
|
||||
{
|
||||
"user_message": "Теперь резко переключаемся: найди в 1С контрагента СВК."
|
||||
},
|
||||
{
|
||||
"user_message": "Сколько получили по нему за 2020 год?"
|
||||
},
|
||||
{
|
||||
"user_message": "А теперь сколько заплатили?"
|
||||
},
|
||||
{
|
||||
"user_message": "А какое нетто по СВК: сколько получили минус сколько заплатили?"
|
||||
},
|
||||
{
|
||||
"user_message": "А по документам СВК что видно?"
|
||||
},
|
||||
{
|
||||
"user_message": "А по движениям?"
|
||||
},
|
||||
{
|
||||
"user_message": "Теперь по СВК за все доступное время: деньги, документы, движения, и короткий вывод."
|
||||
},
|
||||
{
|
||||
"user_message": "Проверь себя: ты сейчас не смешал Группа СВК как контрагента с ООО Альтернатива Плюс как организацией? Объясни контур человечески."
|
||||
},
|
||||
{
|
||||
"user_message": "СВК закончили. Новая тема: покажи документы по Жуковке 51."
|
||||
},
|
||||
{
|
||||
"user_message": "Хорошо, а теперь платежи по нему тоже покажи."
|
||||
},
|
||||
{
|
||||
"user_message": "А по нему договоры?"
|
||||
},
|
||||
{
|
||||
"user_message": "А по нему документы?"
|
||||
},
|
||||
{
|
||||
"user_message": "А за 2021?"
|
||||
},
|
||||
{
|
||||
"user_message": "С Жуковкой закончили. Теперь нужна другая задача: быстрый денежный срез по одной организации. Сколько вообще входящих денег было за 2020 год?"
|
||||
},
|
||||
{
|
||||
"user_message": "По ООО Альтернатива Плюс."
|
||||
},
|
||||
{
|
||||
"user_message": "Понял, тогда за все время."
|
||||
},
|
||||
{
|
||||
"user_message": "Хорошо. А что по ООО Альтернатива Плюс больше в 2020 году: входящие или исходящие деньги?"
|
||||
},
|
||||
{
|
||||
"user_message": "А что по ООО Альтернатива Плюс больше уже за 2021 год: входящие или исходящие деньги?"
|
||||
},
|
||||
{
|
||||
"user_message": "И кто больше всего принес денег этой организации в 2020 году?"
|
||||
},
|
||||
{
|
||||
"user_message": "А в 2021 году?"
|
||||
},
|
||||
{
|
||||
"user_message": "Какие справочники 1С есть по контрагентам?"
|
||||
},
|
||||
{
|
||||
"user_message": "давай дальше"
|
||||
},
|
||||
{
|
||||
"user_message": "Какие поля и связи стоит смотреть у документов реализации и поступления, если я хочу потом идти в продажи, закупки, оплату и движения?"
|
||||
},
|
||||
{
|
||||
"user_message": "Если я спрашиваю прибыль компании, какой маршрут ты должен выбрать и что обязан честно ограничить в ответе?"
|
||||
},
|
||||
{
|
||||
"user_message": "А чем капибара отличается от утки?"
|
||||
},
|
||||
{
|
||||
"user_message": "Возвращаемся к 1С: прикинь, какой НДС нам надо заплатить за февраль 2017."
|
||||
},
|
||||
{
|
||||
"user_message": "А сколько НДС в налоговую за декабрь 2020?"
|
||||
},
|
||||
{
|
||||
"user_message": "Мне нужно понять, где в 1С по НДС вообще лежат данные. Какие объекты стоит смотреть по НДС?"
|
||||
},
|
||||
{
|
||||
"user_message": "Хорошо, тогда покажи движения по ООО Альтернатива Плюс за 2020 год."
|
||||
},
|
||||
{
|
||||
"user_message": "А теперь по документам?"
|
||||
},
|
||||
{
|
||||
"user_message": "А теперь за 2021 год?"
|
||||
},
|
||||
{
|
||||
"user_message": "А теперь за все время?"
|
||||
},
|
||||
{
|
||||
"user_message": "кайф, что там на складе по остаткам?"
|
||||
},
|
||||
{
|
||||
"user_message": "АЛЬТЕРНАТИВА"
|
||||
},
|
||||
{
|
||||
"user_message": "март 2016"
|
||||
},
|
||||
{
|
||||
"user_message": "По выбранному объекту \"Рабочая станция универсального специалиста (индивидуальное изготовление)\": где взяли это?"
|
||||
},
|
||||
{
|
||||
"user_message": "НДС можешь прикинуть на дату покупки рабочей станции?"
|
||||
},
|
||||
{
|
||||
"user_message": "По выбранному объекту \"Четки Пост (84*117)\": сколько заработали на продаже, какие закупочные и продажные документы это подтверждают?"
|
||||
},
|
||||
{
|
||||
"user_message": "Кто у нас самый доходный клиент за все время?"
|
||||
},
|
||||
{
|
||||
"user_message": "По Чепурнову покажи все доки."
|
||||
},
|
||||
{
|
||||
"user_message": "Что нам отгружал Чепурнов: какой товар или услугу?"
|
||||
},
|
||||
{
|
||||
"user_message": "А сейчас у нас есть что на складе?"
|
||||
},
|
||||
{
|
||||
"user_message": "Финально собери executive summary по всему диалогу: где ответы были подтвержденными, где proxy, где не хватило доказательств, и какие места мне руками смотреть особенно внимательно."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
{
|
||||
"suite_id": "assistant_saved_session_runtime_job-hBmySNO0hH",
|
||||
"suite_version": "0.1.0",
|
||||
"schema_version": "assistant_saved_session_runtime_v0_1",
|
||||
"title": "БОЛЬШОЙ ОБЩИЙ Ручная сессия 16.04.2026, 21:26:06",
|
||||
"scenario_count": 1,
|
||||
"case_ids": [
|
||||
"SAVED-001"
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"case_id": "SAVED-001",
|
||||
"scenario_tag": "saved_user_sessions_runtime",
|
||||
"title": "БОЛЬШОЙ ОБЩИЙ Ручная сессия 16.04.2026, 21:26:06",
|
||||
"question_type": "followup",
|
||||
"broadness_level": "medium",
|
||||
"turns": [
|
||||
{
|
||||
"user_message": "приветик - че как там дела"
|
||||
},
|
||||
{
|
||||
"user_message": "расскажи что можешь интересного"
|
||||
},
|
||||
{
|
||||
"user_message": "кайф - что там на складе по остаткам?"
|
||||
},
|
||||
{
|
||||
"user_message": "АЛЬТЕРНАТИВА"
|
||||
},
|
||||
{
|
||||
"user_message": "а исторические остатки на другие даты умеешь?"
|
||||
},
|
||||
{
|
||||
"user_message": "давай на июль 2017"
|
||||
},
|
||||
{
|
||||
"user_message": "март 2016"
|
||||
},
|
||||
{
|
||||
"user_message": "По выбранному объекту \"Рабочая станция универсального специалиста (индивидуальное изготовление)\": где взяли это?"
|
||||
},
|
||||
{
|
||||
"user_message": "а кому продали?"
|
||||
},
|
||||
{
|
||||
"user_message": "у тебя написано кто контрагент: рабочая станция - это ошибка?"
|
||||
},
|
||||
{
|
||||
"user_message": "ндс можешь прикинуть на дату покупки рабочей станции?"
|
||||
},
|
||||
{
|
||||
"user_message": "а какой ндс мы должны сгрузить на март 2020?"
|
||||
},
|
||||
{
|
||||
"user_message": "прикинь какой ндс нам надо заплатить на февраль 2017"
|
||||
},
|
||||
{
|
||||
"user_message": "кто у нас самый доходный клиент за все время"
|
||||
},
|
||||
{
|
||||
"user_message": "кто нам должен денег на май 2017"
|
||||
},
|
||||
{
|
||||
"user_message": "а какой ндс мы должны примерно заплатить за этот период?"
|
||||
},
|
||||
{
|
||||
"user_message": "мы должны комуто денег на сегодня?"
|
||||
},
|
||||
{
|
||||
"user_message": "а нам?"
|
||||
},
|
||||
{
|
||||
"user_message": "какой у нас самый доходный год"
|
||||
},
|
||||
{
|
||||
"user_message": "а за 2017 мы скок заработали?"
|
||||
},
|
||||
{
|
||||
"user_message": "сколько вообще денег мы заработали за все время?"
|
||||
},
|
||||
{
|
||||
"user_message": "ты умеешь считать дельту по договорам?"
|
||||
},
|
||||
{
|
||||
"user_message": "по чепурнову покажи все доки"
|
||||
},
|
||||
{
|
||||
"user_message": "а по свк"
|
||||
},
|
||||
{
|
||||
"user_message": "а сейчас у нас есть что на складе?"
|
||||
},
|
||||
{
|
||||
"user_message": "что нам отгружал чепурнов? какой товар или услугу?"
|
||||
},
|
||||
{
|
||||
"user_message": "какие остатки на складе на сегодня"
|
||||
},
|
||||
{
|
||||
"user_message": "остатки на март 2016"
|
||||
},
|
||||
{
|
||||
"user_message": "хвосты покажи по счету 60 на август 2022"
|
||||
},
|
||||
{
|
||||
"user_message": "Есть ли остатки товара, которые закупались очень давно"
|
||||
},
|
||||
{
|
||||
"user_message": "Какие конкретно номенклатуры формируют остаток по складу на май 2020"
|
||||
},
|
||||
{
|
||||
"user_message": "а по Альтернативе Плюс сколько лет активности в базе 1С?"
|
||||
},
|
||||
{
|
||||
"user_message": "Как ты оценишь деятельность компании?"
|
||||
},
|
||||
{
|
||||
"user_message": "какое нетто по деньгам с Группа СВК за 2020 год: сколько получили и сколько заплатили?"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
{
|
||||
"suite_id": "assistant_saved_session_runtime_job-kbAR1Zc8hw",
|
||||
"suite_version": "0.1.0",
|
||||
"schema_version": "assistant_saved_session_runtime_v0_1",
|
||||
"title": "БОЛЬШОЙ ОБЩИЙ Ручная сессия 16.04.2026, 21:26:06",
|
||||
"scenario_count": 1,
|
||||
"case_ids": [
|
||||
"SAVED-001"
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"case_id": "SAVED-001",
|
||||
"scenario_tag": "saved_user_sessions_runtime",
|
||||
"title": "БОЛЬШОЙ ОБЩИЙ Ручная сессия 16.04.2026, 21:26:06",
|
||||
"question_type": "followup",
|
||||
"broadness_level": "medium",
|
||||
"turns": [
|
||||
{
|
||||
"user_message": "приветик - че как там дела"
|
||||
},
|
||||
{
|
||||
"user_message": "расскажи что можешь интересного"
|
||||
},
|
||||
{
|
||||
"user_message": "кайф - что там на складе по остаткам?"
|
||||
},
|
||||
{
|
||||
"user_message": "АЛЬТЕРНАТИВА"
|
||||
},
|
||||
{
|
||||
"user_message": "а исторические остатки на другие даты умеешь?"
|
||||
},
|
||||
{
|
||||
"user_message": "давай на июль 2017"
|
||||
},
|
||||
{
|
||||
"user_message": "март 2016"
|
||||
},
|
||||
{
|
||||
"user_message": "По выбранному объекту \"Рабочая станция универсального специалиста (индивидуальное изготовление)\": где взяли это?"
|
||||
},
|
||||
{
|
||||
"user_message": "а кому продали?"
|
||||
},
|
||||
{
|
||||
"user_message": "у тебя написано кто контрагент: рабочая станция - это ошибка?"
|
||||
},
|
||||
{
|
||||
"user_message": "ндс можешь прикинуть на дату покупки рабочей станции?"
|
||||
},
|
||||
{
|
||||
"user_message": "а какой ндс мы должны сгрузить на март 2020?"
|
||||
},
|
||||
{
|
||||
"user_message": "прикинь какой ндс нам надо заплатить на февраль 2017"
|
||||
},
|
||||
{
|
||||
"user_message": "кто у нас самый доходный клиент за все время"
|
||||
},
|
||||
{
|
||||
"user_message": "кто нам должен денег на май 2017"
|
||||
},
|
||||
{
|
||||
"user_message": "а какой ндс мы должны примерно заплатить за этот период?"
|
||||
},
|
||||
{
|
||||
"user_message": "мы должны комуто денег на сегодня?"
|
||||
},
|
||||
{
|
||||
"user_message": "а нам?"
|
||||
},
|
||||
{
|
||||
"user_message": "какой у нас самый доходный год"
|
||||
},
|
||||
{
|
||||
"user_message": "а за 2017 мы скок заработали?"
|
||||
},
|
||||
{
|
||||
"user_message": "сколько вообще денег мы заработали за все время?"
|
||||
},
|
||||
{
|
||||
"user_message": "ты умеешь считать дельту по договорам?"
|
||||
},
|
||||
{
|
||||
"user_message": "по чепурнову покажи все доки"
|
||||
},
|
||||
{
|
||||
"user_message": "а по свк"
|
||||
},
|
||||
{
|
||||
"user_message": "а сейчас у нас есть что на складе?"
|
||||
},
|
||||
{
|
||||
"user_message": "что нам отгружал чепурнов? какой товар или услугу?"
|
||||
},
|
||||
{
|
||||
"user_message": "какие остатки на складе на сегодня"
|
||||
},
|
||||
{
|
||||
"user_message": "остатки на март 2016"
|
||||
},
|
||||
{
|
||||
"user_message": "хвосты покажи по счету 60 на август 2022"
|
||||
},
|
||||
{
|
||||
"user_message": "Есть ли остатки товара, которые закупались очень давно"
|
||||
},
|
||||
{
|
||||
"user_message": "Какие конкретно номенклатуры формируют остаток по складу на май 2020"
|
||||
},
|
||||
{
|
||||
"user_message": "а по Альтернативе Плюс сколько лет активности в базе 1С?"
|
||||
},
|
||||
{
|
||||
"user_message": "Как ты оценишь деятельность компании?"
|
||||
},
|
||||
{
|
||||
"user_message": "какое нетто по деньгам с Группа СВК за 2020 год: сколько получили и сколько заплатили?"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -387,6 +387,10 @@ def evaluate_truth_step(
|
|||
assistant_text = str(step_state.get("assistant_text") or "")
|
||||
direct_answer = str(step_state.get("actual_direct_answer") or "").strip()
|
||||
detected_intent = str(step_state.get("detected_intent") or "").strip()
|
||||
effective_intents = [
|
||||
detected_intent,
|
||||
*dcl.normalize_string_list(step_state.get("mcp_discovery_effective_intents")),
|
||||
]
|
||||
selected_recipe = str(step_state.get("selected_recipe") or "").strip()
|
||||
capability_id = str(step_state.get("capability_id") or "").strip()
|
||||
catalog_alignment_status = str(step_state.get("mcp_discovery_catalog_chain_alignment_status") or "").strip()
|
||||
|
|
@ -508,13 +512,13 @@ def evaluate_truth_step(
|
|||
expected_intents = dcl.normalize_string_list(
|
||||
resolve_nested_placeholders(step.get("expected_intents") or [], step_results, bindings, runtime_bindings)
|
||||
)
|
||||
if expected_intents and not dcl.identifier_in_list(detected_intent, expected_intents):
|
||||
if expected_intents and not any(dcl.identifier_in_list(intent, expected_intents) for intent in effective_intents if intent):
|
||||
append_finding(
|
||||
findings,
|
||||
step,
|
||||
"wrong_intent",
|
||||
"Интент не соответствует ожидаемому бизнес-смыслу шага.",
|
||||
actual=detected_intent or None,
|
||||
actual=effective_intents,
|
||||
expected=expected_intents,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ HISTORY_FILE = REPO_ROOT / "llm_normalizer" / "data" / "autorun_generators" / "h
|
|||
SAVED_SESSIONS_DIR = REPO_ROOT / "llm_normalizer" / "data" / "autorun_generators" / "saved_sessions"
|
||||
EVAL_CASES_DIR = REPO_ROOT / "llm_normalizer" / "data" / "eval_cases"
|
||||
VALIDATED_AGENT_SAVE_SCHEMA_VERSION = "agent_semantic_save_gate_v1"
|
||||
BINDING_TOKEN_RE = re.compile(r"\{\{\s*bindings\.([A-Za-z0-9_-]+)\s*\}\}")
|
||||
|
||||
|
||||
def now_utc() -> datetime:
|
||||
|
|
@ -39,6 +40,36 @@ def sanitize_question(value: Any) -> str:
|
|||
return text
|
||||
|
||||
|
||||
def normalize_bindings(raw_bindings: Any) -> dict[str, str]:
|
||||
if not isinstance(raw_bindings, dict):
|
||||
return {}
|
||||
result: dict[str, str] = {}
|
||||
for key, value in raw_bindings.items():
|
||||
normalized_key = str(key or "").strip()
|
||||
normalized_value = str(value or "").strip()
|
||||
if normalized_key and normalized_value:
|
||||
result[normalized_key] = normalized_value
|
||||
return result
|
||||
|
||||
|
||||
def merge_bindings(*binding_sets: Any) -> dict[str, str]:
|
||||
merged: dict[str, str] = {}
|
||||
for raw_bindings in binding_sets:
|
||||
merged.update(normalize_bindings(raw_bindings))
|
||||
return merged
|
||||
|
||||
|
||||
def render_question_template(value: Any, bindings: dict[str, str]) -> str:
|
||||
question = sanitize_question(value)
|
||||
|
||||
def replace_binding(match: re.Match[str]) -> str:
|
||||
binding_key = match.group(1)
|
||||
replacement = bindings.get(binding_key)
|
||||
return replacement if replacement is not None else match.group(0)
|
||||
|
||||
return sanitize_question(BINDING_TOKEN_RE.sub(replace_binding, question))
|
||||
|
||||
|
||||
def ensure_agent_title(title: str) -> str:
|
||||
normalized = title.strip()
|
||||
if not normalized:
|
||||
|
|
@ -237,11 +268,11 @@ def build_save_gate_metadata(args: argparse.Namespace, spec: dict[str, Any], spe
|
|||
)
|
||||
|
||||
|
||||
def normalize_questions(raw_questions: list[Any]) -> list[str]:
|
||||
def normalize_questions(raw_questions: list[Any], bindings: dict[str, str] | None = None) -> list[str]:
|
||||
result: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in raw_questions:
|
||||
question = sanitize_question(item)
|
||||
question = render_question_template(item, bindings or {})
|
||||
if not question or question in seen:
|
||||
continue
|
||||
seen.add(question)
|
||||
|
|
@ -250,50 +281,84 @@ def normalize_questions(raw_questions: list[Any]) -> list[str]:
|
|||
|
||||
|
||||
def extract_semantic_tags(spec: dict[str, Any]) -> list[str]:
|
||||
steps = spec.get("steps")
|
||||
if not isinstance(steps, list):
|
||||
return []
|
||||
tags: set[str] = set()
|
||||
for step in steps:
|
||||
if not isinstance(step, dict):
|
||||
continue
|
||||
raw_tags = step.get("semantic_tags")
|
||||
if not isinstance(raw_tags, list):
|
||||
continue
|
||||
for raw_tag in raw_tags:
|
||||
tag = str(raw_tag or "").strip()
|
||||
if tag:
|
||||
tags.add(tag)
|
||||
step_groups: list[Any] = []
|
||||
steps = spec.get("steps")
|
||||
if isinstance(steps, list):
|
||||
step_groups.append(steps)
|
||||
scenarios = spec.get("scenarios")
|
||||
if isinstance(scenarios, list):
|
||||
for scenario in scenarios:
|
||||
if isinstance(scenario, dict) and isinstance(scenario.get("steps"), list):
|
||||
step_groups.append(scenario["steps"])
|
||||
for step_group in step_groups:
|
||||
for step in step_group:
|
||||
if not isinstance(step, dict):
|
||||
continue
|
||||
raw_tags = step.get("semantic_tags")
|
||||
if not isinstance(raw_tags, list):
|
||||
continue
|
||||
for raw_tag in raw_tags:
|
||||
tag = str(raw_tag or "").strip()
|
||||
if tag:
|
||||
tags.add(tag)
|
||||
return sorted(tags)
|
||||
|
||||
|
||||
def assert_no_unresolved_bindings(questions: list[str]) -> None:
|
||||
unresolved = [question for question in questions if BINDING_TOKEN_RE.search(question)]
|
||||
if unresolved:
|
||||
sample = unresolved[0]
|
||||
raise RuntimeError(
|
||||
"Refusing to save AGENT autorun with unresolved bindings in questions. "
|
||||
f"First unresolved question: {sample}"
|
||||
)
|
||||
|
||||
|
||||
def extract_questions_from_spec(spec: dict[str, Any]) -> list[str]:
|
||||
global_bindings = normalize_bindings(spec.get("bindings"))
|
||||
if isinstance(spec.get("questions"), list):
|
||||
return normalize_questions(list(spec["questions"]))
|
||||
questions = normalize_questions(list(spec["questions"]), global_bindings)
|
||||
assert_no_unresolved_bindings(questions)
|
||||
return questions
|
||||
|
||||
steps = spec.get("steps")
|
||||
if isinstance(steps, list):
|
||||
return normalize_questions(
|
||||
[
|
||||
step.get("question") or step.get("question_template")
|
||||
for step in steps
|
||||
if isinstance(step, dict) and (step.get("question") or step.get("question_template"))
|
||||
]
|
||||
)
|
||||
raw_questions = [
|
||||
step.get("question") or step.get("question_template")
|
||||
for step in steps
|
||||
if isinstance(step, dict) and (step.get("question") or step.get("question_template"))
|
||||
]
|
||||
questions = normalize_questions(raw_questions, global_bindings)
|
||||
assert_no_unresolved_bindings(questions)
|
||||
return questions
|
||||
|
||||
scenarios = spec.get("scenarios")
|
||||
if isinstance(scenarios, list):
|
||||
raw_questions: list[Any] = []
|
||||
questions: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for scenario in scenarios:
|
||||
if not isinstance(scenario, dict):
|
||||
continue
|
||||
scenario_steps = scenario.get("steps")
|
||||
if not isinstance(scenario_steps, list):
|
||||
continue
|
||||
raw_questions.extend(
|
||||
step.get("question") or step.get("question_template")
|
||||
for step in scenario_steps
|
||||
if isinstance(step, dict) and (step.get("question") or step.get("question_template"))
|
||||
)
|
||||
return normalize_questions(raw_questions)
|
||||
scenario_bindings = merge_bindings(global_bindings, scenario.get("bindings"))
|
||||
for step in scenario_steps:
|
||||
if not isinstance(step, dict):
|
||||
continue
|
||||
raw_question = step.get("question") or step.get("question_template")
|
||||
if not raw_question:
|
||||
continue
|
||||
step_bindings = merge_bindings(scenario_bindings, step.get("bindings"))
|
||||
question = render_question_template(raw_question, step_bindings)
|
||||
if not question or question in seen:
|
||||
continue
|
||||
seen.add(question)
|
||||
questions.append(question)
|
||||
assert_no_unresolved_bindings(questions)
|
||||
return questions
|
||||
|
||||
raise RuntimeError(
|
||||
"Spec must define `questions[]`, `steps[].question`, `steps[].question_template`, "
|
||||
"or `scenarios[].steps[]` questions"
|
||||
|
|
|
|||
|
|
@ -212,6 +212,10 @@ def build_scenario_acceptance_matrix(
|
|||
"mcp_discovery_catalog_chain_alignment_status": step_state.get("mcp_discovery_catalog_chain_alignment_status"),
|
||||
"mcp_discovery_catalog_chain_top_match": step_state.get("mcp_discovery_catalog_chain_top_match"),
|
||||
"mcp_discovery_catalog_chain_selected_matches_top": step_state.get("mcp_discovery_catalog_chain_selected_matches_top"),
|
||||
"mcp_discovery_response_applied": step_state.get("mcp_discovery_response_applied"),
|
||||
"mcp_discovery_selected_chain_id": step_state.get("mcp_discovery_selected_chain_id"),
|
||||
"mcp_discovery_response_candidate_status": step_state.get("mcp_discovery_response_candidate_status"),
|
||||
"mcp_discovery_effective_intents": step_state.get("mcp_discovery_effective_intents"),
|
||||
"selected_object_step": _has_selected_object_signal(step),
|
||||
"meta_context_step": _has_meta_context_signal(step),
|
||||
"highest_unresolved_priority": highest_priority,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,289 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import domain_case_loop as dcl
|
||||
|
||||
|
||||
class DomainCaseLoopLeadHandoffTests(unittest.TestCase):
|
||||
def test_normalize_repair_mode_defaults_to_lead_handoff(self) -> None:
|
||||
self.assertEqual(dcl.normalize_repair_mode(None), "lead-handoff")
|
||||
self.assertEqual(dcl.normalize_repair_mode("lead_codex"), "lead-handoff")
|
||||
self.assertEqual(dcl.normalize_repair_mode("auto_coder"), "auto-coder")
|
||||
|
||||
def test_lead_handoff_captures_business_audit_and_primary_focus(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
pack_dir = root / "pack"
|
||||
iteration_dir = root / "loop" / "iterations" / "iteration_00"
|
||||
loop_dir = root / "loop"
|
||||
business_audit_path = iteration_dir / "business_audit.md"
|
||||
analyst_verdict_path = iteration_dir / "analyst_verdict.json"
|
||||
repair_targets_path = pack_dir / "repair_targets.json"
|
||||
repair_targets = {
|
||||
"target_count": 1,
|
||||
"severity_counts": {"P0": 1},
|
||||
"priority_foci": [
|
||||
{
|
||||
"focus_id": "answer_shape",
|
||||
"severity": "P0",
|
||||
"issue_code": "business_direct_answer_missing",
|
||||
"summary": "Direct answer is buried below service scaffolding.",
|
||||
"candidate_files": [
|
||||
"llm_normalizer/backend/src/services/address_runtime/composeStage.ts"
|
||||
],
|
||||
}
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"severity": "P0",
|
||||
"issue_code": "business_direct_answer_missing",
|
||||
"step_id": "q01",
|
||||
}
|
||||
],
|
||||
}
|
||||
analyst_verdict = {
|
||||
"quality_score": 42,
|
||||
"loop_decision": "partial",
|
||||
"user_intent_summary": "User asked for a direct business answer.",
|
||||
"expected_direct_answer": "Direct first-line answer.",
|
||||
"actual_direct_answer": "Scaffolded long answer.",
|
||||
"root_cause_layers": ["answer_shape_mismatch"],
|
||||
}
|
||||
|
||||
handoff = dcl.build_lead_coder_handoff(
|
||||
loop_state={"loop_id": "demo"},
|
||||
iteration_id="iteration_00",
|
||||
pack_dir=pack_dir,
|
||||
analyst_verdict_path=analyst_verdict_path,
|
||||
repair_targets_path=repair_targets_path,
|
||||
business_audit_path=business_audit_path,
|
||||
analyst_verdict=analyst_verdict,
|
||||
repair_targets=repair_targets,
|
||||
target_score=88,
|
||||
loop_decision="partial",
|
||||
analyst_accepted_gate=False,
|
||||
accepted_gate=False,
|
||||
deterministic_gate_ok=False,
|
||||
deterministic_gate_reason="repair_targets_remaining=P0:1",
|
||||
requires_user_decision=False,
|
||||
user_decision_type="none",
|
||||
user_decision_prompt=None,
|
||||
)
|
||||
paths = dcl.save_lead_coder_handoff(
|
||||
loop_dir=loop_dir,
|
||||
iteration_dir=iteration_dir,
|
||||
handoff=handoff,
|
||||
)
|
||||
|
||||
saved = json.loads((iteration_dir / "lead_coder_handoff.json").read_text(encoding="utf-8"))
|
||||
latest_handoff_exists = Path(paths["latest_lead_coder_handoff_path"]).exists()
|
||||
|
||||
self.assertEqual(saved["repair_mode"], "lead-handoff")
|
||||
self.assertEqual(saved["status"], "lead_coder_repair_required")
|
||||
self.assertEqual(saved["assigned_primary_focus"]["focus_id"], "answer_shape")
|
||||
self.assertIn("business_audit", saved["artifact_refs"])
|
||||
self.assertTrue(latest_handoff_exists)
|
||||
|
||||
def test_analyst_priority_targets_become_lead_repair_targets(self) -> None:
|
||||
repair_targets = {
|
||||
"pack_id": "demo_pack",
|
||||
"domain": "demo",
|
||||
"target_count": 0,
|
||||
"severity_counts": {"P0": 0, "P1": 0, "P2": 0},
|
||||
"priority_foci": [],
|
||||
"targets": [],
|
||||
}
|
||||
analyst_verdict = {
|
||||
"priority_targets": [
|
||||
{
|
||||
"scenario_id": "svk_pivot",
|
||||
"step_id": "s03_summary",
|
||||
"severity": "P0",
|
||||
"problem_type": "bundle_reuse_gap",
|
||||
"fix_goal": "Reuse the confirmed SVK value-flow bundle in the final summary.",
|
||||
},
|
||||
{
|
||||
"scenario_id": "biz_scope",
|
||||
"step_id": "s02_money",
|
||||
"severity": "P1",
|
||||
"problem_type": "field_mapping_gap",
|
||||
"fix_goal": "Separate cash source/recipient labels from client/supplier labels.",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
merged = dcl.merge_analyst_priority_repair_targets(repair_targets, analyst_verdict)
|
||||
handoff = dcl.build_lead_coder_handoff(
|
||||
loop_state={"loop_id": "demo"},
|
||||
iteration_id="iteration_00",
|
||||
pack_dir=Path("pack"),
|
||||
analyst_verdict_path=Path("analyst_verdict.json"),
|
||||
repair_targets_path=Path("semantic_repair_targets.json"),
|
||||
business_audit_path=Path("business_audit.md"),
|
||||
analyst_verdict={"quality_score": 73, "loop_decision": "continue"},
|
||||
repair_targets=merged,
|
||||
target_score=88,
|
||||
loop_decision="continue",
|
||||
analyst_accepted_gate=False,
|
||||
accepted_gate=False,
|
||||
deterministic_gate_ok=True,
|
||||
deterministic_gate_reason="deterministic_gate_passed",
|
||||
requires_user_decision=False,
|
||||
user_decision_type="none",
|
||||
user_decision_prompt=None,
|
||||
)
|
||||
|
||||
self.assertEqual(merged["target_count"], 2)
|
||||
self.assertEqual(merged["severity_counts"]["P0"], 1)
|
||||
self.assertEqual(handoff["assigned_primary_focus"]["problem_type"], "bundle_reuse_gap")
|
||||
self.assertEqual(handoff["top_repair_targets"][0]["target_id"], "svk_pivot:s03_summary")
|
||||
self.assertIn(
|
||||
"llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts",
|
||||
handoff["candidate_files"],
|
||||
)
|
||||
|
||||
def test_stale_analyst_validation_target_is_suppressed_by_step_state(self) -> None:
|
||||
repair_targets = {
|
||||
"pack_id": "demo_pack",
|
||||
"domain": "demo",
|
||||
"target_count": 0,
|
||||
"severity_counts": {"P0": 0, "P1": 0, "P2": 0},
|
||||
"priority_foci": [],
|
||||
"targets": [],
|
||||
"step_validation_index": {
|
||||
"legacy_canaries:s02_acc60": {
|
||||
"acceptance_status": "validated",
|
||||
"violated_invariants": [],
|
||||
"warnings": [],
|
||||
"runtime_factual_answer_validated": False,
|
||||
"guarded_insufficiency_validated": True,
|
||||
}
|
||||
},
|
||||
}
|
||||
analyst_verdict = {
|
||||
"priority_targets": [
|
||||
{
|
||||
"scenario_id": "legacy_canaries",
|
||||
"step_id": "s02_acc60",
|
||||
"severity": "P0",
|
||||
"problem_type": "evidence_gap",
|
||||
"fix_goal": (
|
||||
"partial heuristic answer without runtime_factual_answer_validated "
|
||||
"or guarded_insufficiency_validated must not pass silently"
|
||||
),
|
||||
},
|
||||
{
|
||||
"scenario_id": "biz_scope",
|
||||
"step_id": "s03_best_year",
|
||||
"severity": "P2",
|
||||
"problem_type": "presentation_gap",
|
||||
"fix_goal": "Clarify why this year leads without implying pure profit.",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
merged = dcl.merge_analyst_priority_repair_targets(repair_targets, analyst_verdict)
|
||||
|
||||
self.assertEqual(merged["suppressed_analyst_priority_target_count"], 1)
|
||||
self.assertEqual(merged["target_count"], 1)
|
||||
self.assertEqual(merged["targets"][0]["target_id"], "biz_scope:s03_best_year")
|
||||
self.assertEqual(merged["severity_counts"]["P0"], 0)
|
||||
self.assertEqual(merged["severity_counts"]["P2"], 1)
|
||||
|
||||
def test_bounded_mcp_evidence_gap_target_is_suppressed_by_step_state(self) -> None:
|
||||
repair_targets = {
|
||||
"pack_id": "demo_pack",
|
||||
"domain": "demo",
|
||||
"target_count": 0,
|
||||
"severity_counts": {"P0": 0, "P1": 0, "P2": 0},
|
||||
"priority_foci": [],
|
||||
"targets": [],
|
||||
"step_validation_index": {
|
||||
"biz_scope:s03_best_year": {
|
||||
"acceptance_status": "validated",
|
||||
"violated_invariants": [],
|
||||
"warnings": [],
|
||||
"bounded_mcp_answer_validated": True,
|
||||
"mcp_discovery_response_applied": True,
|
||||
"mcp_discovery_response_candidate_status": "ready_for_guarded_use",
|
||||
"assistant_text_excerpt": (
|
||||
"Коротко: самый доходный год в доступном денежном контуре 1С — 2015. "
|
||||
"Важно: входящие уперлись в лимит выборки MCP; это проверенный срез, "
|
||||
"не чистая бухгалтерская прибыль."
|
||||
),
|
||||
}
|
||||
},
|
||||
}
|
||||
analyst_verdict = {
|
||||
"priority_targets": [
|
||||
{
|
||||
"scenario_id": "biz_scope",
|
||||
"step_id": "s03_best_year",
|
||||
"severity": "P0",
|
||||
"problem_type": "evidence_gap",
|
||||
"fix_goal": (
|
||||
"Убрать asserted winner-year как подтвержденный факт, пока yearly ranking "
|
||||
"не имеет exact validated compute; legacy metadata says unsupported/blocked."
|
||||
),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
merged = dcl.merge_analyst_priority_repair_targets(repair_targets, analyst_verdict)
|
||||
|
||||
self.assertEqual(merged["suppressed_analyst_priority_target_count"], 1)
|
||||
self.assertEqual(merged["target_count"], 0)
|
||||
self.assertEqual(merged["severity_counts"], {"P0": 0, "P1": 0, "P2": 0})
|
||||
|
||||
def test_runtime_exact_followup_target_is_suppressed_when_focus_is_proven(self) -> None:
|
||||
repair_targets = {
|
||||
"pack_id": "demo_pack",
|
||||
"domain": "demo",
|
||||
"target_count": 0,
|
||||
"severity_counts": {"P0": 0, "P1": 0, "P2": 0},
|
||||
"priority_foci": [],
|
||||
"targets": [],
|
||||
"step_validation_index": {
|
||||
"svk_pivot:s02_svk_docs": {
|
||||
"acceptance_status": "validated",
|
||||
"violated_invariants": [],
|
||||
"warnings": [],
|
||||
"runtime_factual_answer_validated": True,
|
||||
"assistant_text_excerpt": "Контрагент: Группа СВК. Найдено документов: 19.",
|
||||
"extracted_filters": {"counterparty": "Группа СВК"},
|
||||
"focus_object": {"label": "Группа СВК"},
|
||||
}
|
||||
},
|
||||
}
|
||||
analyst_verdict = {
|
||||
"priority_targets": [
|
||||
{
|
||||
"scenario_id": "svk_pivot",
|
||||
"step_id": "s02_svk_docs",
|
||||
"severity": "P1",
|
||||
"problem_type": "followup_action_resolution_gap",
|
||||
"fix_goal": (
|
||||
"Добавить pack-level validation на object-centric carryover: docs follow-up "
|
||||
"и bundle reuse должны быть явно проверены через stable counterparty/focus."
|
||||
),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
merged = dcl.merge_analyst_priority_repair_targets(repair_targets, analyst_verdict)
|
||||
|
||||
self.assertEqual(merged["suppressed_analyst_priority_target_count"], 1)
|
||||
self.assertEqual(merged["target_count"], 0)
|
||||
self.assertEqual(merged["severity_counts"], {"P0": 0, "P1": 0, "P2": 0})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -50,6 +50,580 @@ class DomainCaseLoopStepStateTests(unittest.TestCase):
|
|||
self.assertEqual(step_state["mcp_discovery_catalog_chain_top_match"], "value_flow")
|
||||
self.assertTrue(step_state["mcp_discovery_catalog_chain_selected_matches_top"])
|
||||
|
||||
def test_analysis_context_date_is_not_implicit_business_filter(self) -> None:
|
||||
step_state = dcl.build_scenario_step_state(
|
||||
scenario_id="stage_pack_demo",
|
||||
domain="agentic_loop",
|
||||
step={
|
||||
"step_id": "step_01",
|
||||
"title": "All-time summary",
|
||||
"depends_on": [],
|
||||
"question_template": "all-time money summary",
|
||||
},
|
||||
step_index=1,
|
||||
question_resolved="all-time money summary",
|
||||
analysis_context={"as_of_date": "2026-05-09", "source": "stage_pack"},
|
||||
turn_artifact={
|
||||
"assistant_message": {
|
||||
"reply_type": "factual_with_explanation",
|
||||
"text": "Short: all-time confirmed money summary.",
|
||||
"message_id": "msg-1",
|
||||
"trace_id": "trace-1",
|
||||
},
|
||||
"technical_debug_payload": {},
|
||||
"session_summary": {},
|
||||
},
|
||||
entries=[],
|
||||
)
|
||||
|
||||
self.assertNotIn("missing_required_filter", step_state["violated_invariants"])
|
||||
self.assertNotIn("wrong_as_of_date", step_state["violated_invariants"])
|
||||
|
||||
def test_analysis_context_date_is_required_for_explicit_date_carryover(self) -> None:
|
||||
step_state = dcl.build_scenario_step_state(
|
||||
scenario_id="date_carryover_demo",
|
||||
domain="inventory",
|
||||
step={
|
||||
"step_id": "step_01",
|
||||
"title": "Date carryover",
|
||||
"depends_on": [],
|
||||
"question_template": "stock on that date",
|
||||
"required_carryover_invariants": ["date_scope"],
|
||||
},
|
||||
step_index=1,
|
||||
question_resolved="stock on that date",
|
||||
analysis_context={"as_of_date": "2021-03-31"},
|
||||
turn_artifact={
|
||||
"assistant_message": {
|
||||
"reply_type": "factual",
|
||||
"text": "Short: stock confirmed.",
|
||||
"message_id": "msg-1",
|
||||
"trace_id": "trace-1",
|
||||
},
|
||||
"technical_debug_payload": {
|
||||
"detected_mode": "address_query",
|
||||
"detected_intent": "inventory_on_hand_as_of_date",
|
||||
"selected_recipe": "address_inventory_on_hand_as_of_date_v1",
|
||||
"capability_id": "confirmed_inventory_on_hand_as_of_date",
|
||||
"capability_route_mode": "exact",
|
||||
"fallback_type": "none",
|
||||
"extracted_filters": {"as_of_date": "2020-03-31"},
|
||||
},
|
||||
"session_summary": {},
|
||||
},
|
||||
entries=[],
|
||||
)
|
||||
|
||||
self.assertIn("wrong_as_of_date", step_state["violated_invariants"])
|
||||
|
||||
def test_temporal_reset_question_skips_carried_date_scope(self) -> None:
|
||||
self.assertTrue(dcl.question_resets_temporal_scope("show money za all time"))
|
||||
self.assertTrue(dcl.question_resets_temporal_scope("сколько всего денег за все доступное время"))
|
||||
|
||||
carried = dcl.carry_forward_analysis_context(
|
||||
{
|
||||
"semantic_memory": {
|
||||
"date_scope": {
|
||||
"as_of_date": "2020-12-31",
|
||||
"period_from": "2020-10-01",
|
||||
"period_to": "2020-12-31",
|
||||
},
|
||||
"organization_scope": {"label": "ООО Альтернатива Плюс"},
|
||||
}
|
||||
},
|
||||
{},
|
||||
prefer_carryover=True,
|
||||
carry_date_scope=False,
|
||||
)
|
||||
|
||||
self.assertNotIn("as_of_date", carried)
|
||||
self.assertEqual(carried["organization_scope"], {"label": "ООО Альтернатива Плюс"})
|
||||
|
||||
def test_merge_scenario_date_scope_keeps_current_scope_over_stale_previous(self) -> None:
|
||||
merged = dcl.merge_scenario_date_scope(
|
||||
{
|
||||
"as_of_date": "2020-12-31",
|
||||
"period_from": "2020-10-01",
|
||||
"period_to": "2020-12-31",
|
||||
"source": "scenario_state_carryover",
|
||||
},
|
||||
{
|
||||
"as_of_date": "2021-03-31",
|
||||
"period_from": "2021-03-01",
|
||||
"period_to": "2021-03-31",
|
||||
"source": "current_turn",
|
||||
},
|
||||
depends_on=["previous_step"],
|
||||
)
|
||||
|
||||
self.assertEqual(merged["as_of_date"], "2021-03-31")
|
||||
self.assertEqual(merged["source"], "current_turn")
|
||||
|
||||
def test_mcp_business_overview_all_time_scope_overrides_stale_session_date(self) -> None:
|
||||
step_state = dcl.build_scenario_step_state(
|
||||
scenario_id="business_overview_demo",
|
||||
domain="agentic_loop",
|
||||
step={
|
||||
"step_id": "step_01",
|
||||
"title": "All-time money",
|
||||
"depends_on": ["previous_step"],
|
||||
"question_template": "all-time money summary",
|
||||
"expected_intents": ["business_overview"],
|
||||
},
|
||||
step_index=1,
|
||||
question_resolved="all-time money summary",
|
||||
analysis_context={},
|
||||
turn_artifact={
|
||||
"assistant_message": {
|
||||
"reply_type": "partial_coverage",
|
||||
"text": "Short: all-time confirmed money summary.",
|
||||
"message_id": "msg-1",
|
||||
"trace_id": "trace-1",
|
||||
},
|
||||
"technical_debug_payload": {
|
||||
"detected_mode": "address_query",
|
||||
"detected_intent": "inventory_supplier_stock_overlap_as_of_date",
|
||||
"selected_recipe": "address_inventory_supplier_stock_overlap_as_of_date_v1",
|
||||
"capability_id": "inventory_inventory_supplier_stock_overlap_as_of_date",
|
||||
"mcp_discovery_response_applied": True,
|
||||
"mcp_discovery_selected_chain_id": "business_overview",
|
||||
"mcp_discovery_catalog_chain_top_match": "business_overview",
|
||||
"mcp_discovery_response_candidate_v1": {
|
||||
"candidate_status": "ready_for_guarded_use",
|
||||
"reply_type": "partial_coverage",
|
||||
},
|
||||
"assistant_mcp_discovery_entry_point_v1": {
|
||||
"bridge": {
|
||||
"pilot": {
|
||||
"derived_business_overview": {
|
||||
"period_scope": None,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"session_summary": {
|
||||
"address_navigation_state": {
|
||||
"session_context": {
|
||||
"active_result_set_id": "rs-stale",
|
||||
"date_scope": {
|
||||
"as_of_date": "2020-12-31",
|
||||
"period_from": "2020-10-01",
|
||||
"period_to": "2020-12-31",
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
entries=[],
|
||||
)
|
||||
|
||||
self.assertEqual(step_state["date_scope"]["scope"], "all_time")
|
||||
self.assertIsNone(step_state["date_scope"]["as_of_date"])
|
||||
self.assertEqual(step_state["active_result_set_id"], "mcp-discovery-msg-1")
|
||||
self.assertNotIn("wrong_date_scope_state", step_state["violated_invariants"])
|
||||
|
||||
def test_applied_ready_mcp_discovery_chain_satisfies_expected_intent(self) -> None:
|
||||
step_state = dcl.build_scenario_step_state(
|
||||
scenario_id="business_overview_demo",
|
||||
domain="agentic_loop",
|
||||
step={
|
||||
"step_id": "step_01",
|
||||
"title": "Business overview",
|
||||
"depends_on": [],
|
||||
"question_template": "business overview for 2020",
|
||||
"expected_intents": ["business_overview"],
|
||||
},
|
||||
step_index=1,
|
||||
question_resolved="business overview for 2020",
|
||||
analysis_context={},
|
||||
turn_artifact={
|
||||
"assistant_message": {
|
||||
"reply_type": "partial_coverage",
|
||||
"text": "Short: business overview from confirmed 1C rows.",
|
||||
"message_id": "msg-1",
|
||||
"trace_id": "trace-1",
|
||||
},
|
||||
"technical_debug_payload": {
|
||||
"detected_mode": "address_query",
|
||||
"detected_intent": "inventory_supplier_stock_overlap_as_of_date",
|
||||
"selected_recipe": "address_inventory_supplier_stock_overlap_as_of_date_v1",
|
||||
"capability_id": "inventory_inventory_supplier_stock_overlap_as_of_date",
|
||||
"mcp_discovery_response_applied": True,
|
||||
"mcp_discovery_selected_chain_id": "business_overview",
|
||||
"mcp_discovery_catalog_chain_top_match": "business_overview",
|
||||
"mcp_discovery_response_candidate_v1": {
|
||||
"candidate_status": "ready_for_guarded_use",
|
||||
"reply_type": "partial_coverage",
|
||||
},
|
||||
},
|
||||
"session_summary": {},
|
||||
},
|
||||
entries=[],
|
||||
)
|
||||
|
||||
self.assertEqual(step_state["mcp_discovery_effective_intents"], ["business_overview"])
|
||||
self.assertNotIn("wrong_intent", step_state["violated_invariants"])
|
||||
|
||||
def test_ready_bounded_mcp_answer_can_validate_without_exact_route(self) -> None:
|
||||
step_state = dcl.build_scenario_step_state(
|
||||
scenario_id="business_overview_demo",
|
||||
domain="agentic_loop",
|
||||
step={
|
||||
"step_id": "step_01",
|
||||
"title": "Business overview",
|
||||
"depends_on": [],
|
||||
"question_template": "business overview for 2020",
|
||||
"expected_intents": ["business_overview"],
|
||||
"required_answer_shape": "direct_answer_first",
|
||||
},
|
||||
step_index=1,
|
||||
question_resolved="business overview for 2020",
|
||||
analysis_context={},
|
||||
turn_artifact={
|
||||
"assistant_message": {
|
||||
"reply_type": "partial_coverage",
|
||||
"text": "Short: confirmed bounded business overview from 1C rows.",
|
||||
"message_id": "msg-1",
|
||||
"trace_id": "trace-1",
|
||||
},
|
||||
"technical_debug_payload": {
|
||||
"detected_mode": "address_query",
|
||||
"detected_intent": "inventory_supplier_stock_overlap_as_of_date",
|
||||
"selected_recipe": "address_inventory_supplier_stock_overlap_as_of_date_v1",
|
||||
"capability_id": "inventory_inventory_supplier_stock_overlap_as_of_date",
|
||||
"mcp_discovery_response_applied": True,
|
||||
"mcp_discovery_selected_chain_id": "business_overview",
|
||||
"mcp_discovery_catalog_chain_top_match": "business_overview",
|
||||
"mcp_discovery_response_candidate_v1": {
|
||||
"candidate_status": "ready_for_guarded_use",
|
||||
"reply_type": "partial_coverage",
|
||||
},
|
||||
},
|
||||
"session_summary": {},
|
||||
},
|
||||
entries=[],
|
||||
)
|
||||
|
||||
self.assertEqual(step_state["execution_status"], "partial")
|
||||
self.assertTrue(step_state["bounded_mcp_answer_validated"])
|
||||
self.assertEqual(step_state["acceptance_status"], "validated")
|
||||
|
||||
def test_required_answer_patterns_block_generic_bounded_mcp_summary(self) -> None:
|
||||
step_state = dcl.build_scenario_step_state(
|
||||
scenario_id="summary_demo",
|
||||
domain="agentic_loop",
|
||||
step={
|
||||
"step_id": "step_01",
|
||||
"title": "Summary",
|
||||
"depends_on": [],
|
||||
"question_template": "summarize company and SVK separately",
|
||||
"required_answer_shape": "direct_answer_first",
|
||||
"required_answer_patterns_all": ["SVK", "company"],
|
||||
},
|
||||
step_index=1,
|
||||
question_resolved="summarize company and SVK separately",
|
||||
analysis_context={},
|
||||
turn_artifact={
|
||||
"assistant_message": {
|
||||
"reply_type": "partial_coverage",
|
||||
"text": "Short: company money summary only.",
|
||||
"message_id": "msg-1",
|
||||
"trace_id": "trace-1",
|
||||
},
|
||||
"technical_debug_payload": {
|
||||
"mcp_discovery_response_applied": True,
|
||||
"mcp_discovery_selected_chain_id": "business_overview",
|
||||
"mcp_discovery_catalog_chain_top_match": "business_overview",
|
||||
"mcp_discovery_response_candidate_v1": {
|
||||
"candidate_status": "ready_for_guarded_use",
|
||||
"reply_type": "partial_coverage",
|
||||
},
|
||||
},
|
||||
"session_summary": {},
|
||||
},
|
||||
entries=[],
|
||||
)
|
||||
|
||||
self.assertIn("required_answer_patterns_all_missing", step_state["violated_invariants"])
|
||||
self.assertFalse(step_state["bounded_mcp_answer_validated"])
|
||||
self.assertEqual(step_state["acceptance_status"], "rejected")
|
||||
|
||||
def test_memory_checkpoint_can_validate_honest_no_scope_answer(self) -> None:
|
||||
step_state = dcl.build_scenario_step_state(
|
||||
scenario_id="memory_demo",
|
||||
domain="agentic_loop",
|
||||
step={
|
||||
"step_id": "step_01",
|
||||
"title": "Memory checkpoint",
|
||||
"depends_on": [],
|
||||
"question_template": "is any company or counterparty selected in the current dialog?",
|
||||
"semantic_tags": ["memory", "scope_guard"],
|
||||
"required_answer_shape": "direct_answer_first",
|
||||
},
|
||||
step_index=1,
|
||||
question_resolved="is any company or counterparty selected in the current dialog?",
|
||||
analysis_context={},
|
||||
turn_artifact={
|
||||
"assistant_message": {
|
||||
"reply_type": "partial_coverage",
|
||||
"text": "В текущем диалоге не выбрана компания или контрагент; память не выдумываю.",
|
||||
"message_id": "msg-1",
|
||||
"trace_id": "trace-1",
|
||||
},
|
||||
"technical_debug_payload": {
|
||||
"detected_mode": "address_query",
|
||||
"detected_intent": "customer_revenue_and_payments",
|
||||
"fallback_type": "no_rows",
|
||||
},
|
||||
"session_summary": {},
|
||||
},
|
||||
entries=[],
|
||||
)
|
||||
|
||||
self.assertEqual(step_state["execution_status"], "partial")
|
||||
self.assertTrue(step_state["memory_checkpoint_validated"])
|
||||
self.assertEqual(step_state["acceptance_status"], "validated")
|
||||
|
||||
def test_deterministic_chat_memory_checkpoint_validates_without_exact_capability(self) -> None:
|
||||
step_state = dcl.build_scenario_step_state(
|
||||
scenario_id="memory_demo",
|
||||
domain="agentic_loop",
|
||||
step={
|
||||
"step_id": "step_01",
|
||||
"title": "Memory checkpoint",
|
||||
"depends_on": [],
|
||||
"question_template": "current dialog memory checkpoint",
|
||||
"semantic_tags": ["memory", "scope_guard"],
|
||||
"required_answer_shape": "direct_answer_first",
|
||||
},
|
||||
step_index=1,
|
||||
question_resolved="current dialog memory checkpoint",
|
||||
analysis_context={},
|
||||
turn_artifact={
|
||||
"assistant_message": {
|
||||
"reply_type": "factual_with_explanation",
|
||||
"text": (
|
||||
"Коротко: в текущем диалоге я не вижу выбранной компании, контрагента или позиции. "
|
||||
"Память про «Группа СВК» в этом диалоге не подтверждена."
|
||||
),
|
||||
"message_id": "msg-1",
|
||||
"trace_id": "trace-1",
|
||||
},
|
||||
"technical_debug_payload": {
|
||||
"detected_mode": "chat",
|
||||
"fallback_type": "none",
|
||||
"living_router_reason": "memory_recap_followup_detected",
|
||||
"living_chat_response_source": "deterministic_memory_recap_contract",
|
||||
},
|
||||
"session_summary": {},
|
||||
},
|
||||
entries=[],
|
||||
)
|
||||
|
||||
self.assertEqual(step_state["execution_status"], "partial")
|
||||
self.assertTrue(step_state["memory_checkpoint_validated"])
|
||||
self.assertEqual(step_state["acceptance_status"], "validated")
|
||||
|
||||
def test_confirmed_runtime_factual_answer_can_validate_without_exact_route_mode(self) -> None:
|
||||
step_state = dcl.build_scenario_step_state(
|
||||
scenario_id="runtime_factual_demo",
|
||||
domain="agentic_loop",
|
||||
step={
|
||||
"step_id": "step_01",
|
||||
"title": "Account 60 tails",
|
||||
"depends_on": [],
|
||||
"question_template": "show account 60 tails",
|
||||
"required_answer_shape": "direct_answer_first",
|
||||
},
|
||||
step_index=1,
|
||||
question_resolved="show account 60 tails",
|
||||
analysis_context={},
|
||||
turn_artifact={
|
||||
"assistant_message": {
|
||||
"reply_type": "factual",
|
||||
"text": "Коротко: по счету 60 найдено 8 строк хвостов; контрагентов с сигналом: 6.",
|
||||
"message_id": "msg-1",
|
||||
"trace_id": "trace-1",
|
||||
},
|
||||
"technical_debug_payload": {
|
||||
"detected_mode": "address_query",
|
||||
"detected_intent": "open_items_by_counterparty_or_contract",
|
||||
"selected_recipe": "address_open_items_by_party_or_contract_v1",
|
||||
"capability_id": "address_open_items_by_counterparty_or_contract",
|
||||
"capability_route_mode": "heuristic",
|
||||
"fallback_type": "none",
|
||||
"mcp_call_status": "matched_non_empty",
|
||||
"response_type": "FACTUAL_LIST",
|
||||
"result_mode": "confirmed_balance",
|
||||
},
|
||||
"session_summary": {},
|
||||
},
|
||||
entries=[],
|
||||
)
|
||||
|
||||
self.assertEqual(step_state["execution_status"], "partial")
|
||||
self.assertTrue(step_state["runtime_factual_answer_validated"])
|
||||
self.assertEqual(step_state["acceptance_status"], "validated")
|
||||
|
||||
def test_exact_confirmed_document_followup_sets_runtime_factual_validation(self) -> None:
|
||||
step_state = dcl.build_scenario_step_state(
|
||||
scenario_id="svk_pivot",
|
||||
domain="agentic_loop",
|
||||
step={
|
||||
"step_id": "s02_svk_docs",
|
||||
"title": "Counterparty documents follow-up",
|
||||
"depends_on": ["s01_svk_money"],
|
||||
"question_template": "show documents by this chain",
|
||||
"semantic_tags": ["counterparty", "documents", "scope_guard"],
|
||||
"required_answer_shape": "direct_answer_first",
|
||||
},
|
||||
step_index=2,
|
||||
question_resolved="show documents by this chain",
|
||||
analysis_context={"as_of_date": "2026-05-09"},
|
||||
turn_artifact={
|
||||
"assistant_message": {
|
||||
"reply_type": "factual",
|
||||
"text": "Контрагент: Группа СВК. Найдено документов: 19.",
|
||||
"message_id": "msg-1",
|
||||
"trace_id": "trace-1",
|
||||
},
|
||||
"technical_debug_payload": {
|
||||
"detected_mode": "address_query",
|
||||
"detected_intent": "list_documents_by_counterparty",
|
||||
"selected_recipe": "address_documents_by_counterparty_v1",
|
||||
"capability_id": "documents_drilldown",
|
||||
"capability_route_mode": "exact",
|
||||
"fallback_type": "none",
|
||||
"mcp_call_status": "matched_non_empty",
|
||||
"response_type": "FACTUAL_LIST",
|
||||
"truth_mode": "confirmed",
|
||||
"answer_shape": "confirmed_factual",
|
||||
"coverage_status": "full",
|
||||
"evidence_grade": "strong",
|
||||
"extracted_filters": {"counterparty": "Группа СВК", "as_of_date": "2026-05-09"},
|
||||
"focus_object": {
|
||||
"object_type": "counterparty",
|
||||
"object_id": "counterparty:группа свк",
|
||||
"label": "Группа СВК",
|
||||
},
|
||||
},
|
||||
"session_summary": {},
|
||||
},
|
||||
entries=[{"item": "2021-11-10T12:00:07Z"}],
|
||||
)
|
||||
|
||||
self.assertEqual(step_state["execution_status"], "exact")
|
||||
self.assertTrue(step_state["runtime_factual_answer_validated"])
|
||||
self.assertEqual(step_state["acceptance_status"], "validated")
|
||||
|
||||
def test_heuristic_open_items_guarded_insufficiency_validates_separately(self) -> None:
|
||||
answer_text = (
|
||||
"\u041a\u043e\u0440\u043e\u0442\u043a\u043e: \u0442\u043e\u0447\u043d\u044b\u0439 "
|
||||
"\u043e\u0442\u043a\u0440\u044b\u0442\u044b\u0439 \u043e\u0441\u0442\u0430\u0442\u043e\u043a "
|
||||
"\u043f\u043e \u0441\u0447\u0435\u0442\u0443 60 \u043d\u0435 "
|
||||
"\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d; \u043d\u0438\u0436\u0435 "
|
||||
"\u0442\u043e\u043b\u044c\u043a\u043e \u043f\u0440\u0435\u0434\u0432\u0430\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 "
|
||||
"\u0441\u0438\u0433\u043d\u0430\u043b\u044b \u043f\u043e \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f\u043c: 8 "
|
||||
"\u0441\u0442\u0440\u043e\u043a.\n"
|
||||
"\u042d\u0442\u043e \u043d\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u043e\u0435 "
|
||||
"\u0441\u0430\u043b\u044c\u0434\u043e: \u0442\u0435\u043a\u0443\u0449\u0438\u0439 "
|
||||
"\u043a\u043e\u043d\u0442\u0443\u0440 \u0432\u0438\u0434\u0438\u0442 "
|
||||
"\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f-\u043a\u0430\u043d\u0434\u0438\u0434\u0430\u0442\u044b, "
|
||||
"\u043d\u043e \u043d\u0435 \u0434\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0435\u0442 "
|
||||
"\u043e\u0441\u0442\u0430\u0442\u043e\u043a."
|
||||
)
|
||||
step_state = dcl.build_scenario_step_state(
|
||||
scenario_id="runtime_factual_demo",
|
||||
domain="agentic_loop",
|
||||
step={
|
||||
"step_id": "step_01",
|
||||
"title": "Account 60 limited tails",
|
||||
"depends_on": [],
|
||||
"question_template": "show account 60 tails; say if exact data is unavailable",
|
||||
"required_answer_shape": "direct_answer_first",
|
||||
},
|
||||
step_index=1,
|
||||
question_resolved="show account 60 tails; say if exact data is unavailable",
|
||||
analysis_context={},
|
||||
turn_artifact={
|
||||
"assistant_message": {
|
||||
"reply_type": "factual",
|
||||
"text": answer_text,
|
||||
"message_id": "msg-1",
|
||||
"trace_id": "trace-1",
|
||||
},
|
||||
"technical_debug_payload": {
|
||||
"detected_mode": "address_query",
|
||||
"detected_intent": "open_items_by_counterparty_or_contract",
|
||||
"selected_recipe": "address_open_items_by_party_or_contract_v1",
|
||||
"capability_id": "address_open_items_by_counterparty_or_contract",
|
||||
"capability_route_mode": "heuristic",
|
||||
"fallback_type": "none",
|
||||
"mcp_call_status": "matched_non_empty",
|
||||
"response_type": "FACTUAL_LIST",
|
||||
"result_mode": "heuristic_candidates",
|
||||
"balance_confirmed": False,
|
||||
"truth_mode": "limited",
|
||||
"answer_shape": "limited_with_reason",
|
||||
},
|
||||
"session_summary": {},
|
||||
},
|
||||
entries=[],
|
||||
)
|
||||
|
||||
self.assertEqual(step_state["execution_status"], "partial")
|
||||
self.assertEqual(step_state["truth_mode"], "limited")
|
||||
self.assertEqual(step_state["answer_shape"], "limited_with_reason")
|
||||
self.assertFalse(step_state["runtime_factual_answer_validated"])
|
||||
self.assertTrue(step_state["guarded_insufficiency_validated"])
|
||||
self.assertEqual(step_state["acceptance_status"], "validated")
|
||||
|
||||
def test_heuristic_open_items_without_limitation_is_rejected(self) -> None:
|
||||
step_state = dcl.build_scenario_step_state(
|
||||
scenario_id="runtime_factual_demo",
|
||||
domain="agentic_loop",
|
||||
step={
|
||||
"step_id": "step_01",
|
||||
"title": "Account 60 unguarded tails",
|
||||
"depends_on": [],
|
||||
"question_template": "show account 60 tails",
|
||||
"required_answer_shape": "direct_answer_first",
|
||||
},
|
||||
step_index=1,
|
||||
question_resolved="show account 60 tails",
|
||||
analysis_context={},
|
||||
turn_artifact={
|
||||
"assistant_message": {
|
||||
"reply_type": "factual",
|
||||
"text": "Short: account 60 has 8 open-item rows and 6 counterparties.",
|
||||
"message_id": "msg-1",
|
||||
"trace_id": "trace-1",
|
||||
},
|
||||
"technical_debug_payload": {
|
||||
"detected_mode": "address_query",
|
||||
"detected_intent": "open_items_by_counterparty_or_contract",
|
||||
"selected_recipe": "address_open_items_by_party_or_contract_v1",
|
||||
"capability_id": "address_open_items_by_counterparty_or_contract",
|
||||
"capability_route_mode": "heuristic",
|
||||
"fallback_type": "none",
|
||||
"mcp_call_status": "matched_non_empty",
|
||||
"response_type": "FACTUAL_LIST",
|
||||
"result_mode": "heuristic_candidates",
|
||||
"balance_confirmed": False,
|
||||
"truth_mode": "limited",
|
||||
"answer_shape": "limited_with_reason",
|
||||
},
|
||||
"session_summary": {},
|
||||
},
|
||||
entries=[],
|
||||
)
|
||||
|
||||
self.assertEqual(step_state["execution_status"], "partial")
|
||||
self.assertFalse(step_state["runtime_factual_answer_validated"])
|
||||
self.assertFalse(step_state["guarded_insufficiency_validated"])
|
||||
self.assertEqual(step_state["acceptance_status"], "rejected")
|
||||
|
||||
def test_truth_harness_warns_on_catalog_alignment_divergence(self) -> None:
|
||||
reviewed = dth.evaluate_truth_step(
|
||||
step={
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
|
@ -13,228 +10,55 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|||
import save_agent_semantic_run as saver
|
||||
|
||||
|
||||
def write_json(path: Path, payload: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
class SaveAgentSemanticRunTests(unittest.TestCase):
|
||||
def test_extract_questions_accepts_truth_harness_question_template(self) -> None:
|
||||
questions = saver.extract_questions_from_spec(
|
||||
{
|
||||
"steps": [
|
||||
{"step_id": "step_01", "question_template": "first question"},
|
||||
{"step_id": "step_02", "question": "second question"},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(questions, ["first question", "second question"])
|
||||
|
||||
def test_extract_questions_accepts_domain_pack_scenarios(self) -> None:
|
||||
questions = saver.extract_questions_from_spec(
|
||||
{
|
||||
"pack_id": "demo_pack",
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario_id": "scenario_01",
|
||||
"steps": [
|
||||
{"step_id": "step_01", "question_template": "first question"},
|
||||
{"step_id": "step_02", "question": "second question"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"scenario_id": "scenario_02",
|
||||
"steps": [
|
||||
{"step_id": "step_01", "question": "first question"},
|
||||
{"step_id": "step_02", "question": "third question"},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(questions, ["first question", "second question", "third question"])
|
||||
|
||||
def test_validate_accepted_run_dir_accepts_clean_business_review(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
run_dir = Path(tmp)
|
||||
write_json(
|
||||
run_dir / "pack_state.json",
|
||||
def test_extract_questions_resolves_scenario_pack_bindings(self) -> None:
|
||||
spec = {
|
||||
"schema_version": "domain_scenario_pack_v1",
|
||||
"bindings": {
|
||||
"main_organization": "ООО Альтернатива Плюс",
|
||||
"control_year": "2020",
|
||||
"svk_counterparty": "Группа СВК",
|
||||
},
|
||||
"scenarios": [
|
||||
{
|
||||
"final_status": "accepted",
|
||||
"review_overall_status": "pass",
|
||||
"acceptance_gate_passed": True,
|
||||
"no_unresolved_p0": True,
|
||||
"unresolved_p0_count": 0,
|
||||
"steps_total": 1,
|
||||
"steps_passed": 1,
|
||||
"steps_failed": 0,
|
||||
},
|
||||
)
|
||||
write_json(run_dir / "truth_review.json", {"summary": {"overall_status": "pass"}})
|
||||
write_json(
|
||||
run_dir / "business_review.json",
|
||||
{
|
||||
"overall_business_status": "pass",
|
||||
"steps_with_business_failures": 0,
|
||||
"steps_with_business_warnings": 0,
|
||||
},
|
||||
)
|
||||
|
||||
metadata = saver.validate_accepted_run_dir(run_dir)
|
||||
|
||||
self.assertEqual(metadata["validation_status"], "accepted_live_replay")
|
||||
self.assertTrue(metadata["saved_after_validated_replay"])
|
||||
|
||||
def test_validate_accepted_run_dir_rejects_business_review_failures(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
run_dir = Path(tmp)
|
||||
write_json(
|
||||
run_dir / "pack_state.json",
|
||||
{
|
||||
"final_status": "accepted",
|
||||
"review_overall_status": "pass",
|
||||
"acceptance_gate_passed": True,
|
||||
"no_unresolved_p0": True,
|
||||
"unresolved_p0_count": 0,
|
||||
},
|
||||
)
|
||||
write_json(run_dir / "truth_review.json", {"summary": {"overall_status": "pass"}})
|
||||
write_json(
|
||||
run_dir / "business_review.json",
|
||||
{
|
||||
"overall_business_status": "fail",
|
||||
"steps_with_business_failures": 1,
|
||||
},
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "business_review"):
|
||||
saver.validate_accepted_run_dir(run_dir)
|
||||
|
||||
def test_validate_accepted_run_dir_accepts_clean_domain_pack_loop(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
loop_dir = Path(tmp)
|
||||
iteration_dir = loop_dir / "iterations" / "iteration_00"
|
||||
analyst_path = iteration_dir / "analyst_verdict.json"
|
||||
repair_targets_path = iteration_dir / "pack_output" / "pack_run" / "repair_targets.json"
|
||||
write_json(
|
||||
loop_dir / "loop_state.json",
|
||||
{
|
||||
"loop_id": "stage_demo",
|
||||
"target_score": 88,
|
||||
"final_status": "accepted",
|
||||
"iterations": [
|
||||
"scenario_id": "biz",
|
||||
"steps": [
|
||||
{
|
||||
"iteration_id": "iteration_00",
|
||||
"quality_score": 91,
|
||||
"accepted_gate": True,
|
||||
"analyst_accepted_gate": True,
|
||||
"deterministic_gate_ok": True,
|
||||
"repair_target_count": 0,
|
||||
"repair_target_severity_counts": {"P0": 0, "P1": 0, "P2": 0},
|
||||
"analyst_verdict_path": str(analyst_path),
|
||||
"repair_targets_path": str(repair_targets_path),
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
write_json(
|
||||
analyst_path,
|
||||
{
|
||||
"loop_decision": "accepted",
|
||||
"unresolved_p0_count": 0,
|
||||
"regression_detected": False,
|
||||
"direct_answer_ok": True,
|
||||
"business_usefulness_ok": True,
|
||||
"temporal_honesty_ok": True,
|
||||
"field_truth_ok": True,
|
||||
"answer_layering_ok": True,
|
||||
},
|
||||
)
|
||||
write_json(repair_targets_path, {"severity_counts": {"P0": 0, "P1": 0, "P2": 0}})
|
||||
|
||||
metadata = saver.validate_accepted_run_dir(loop_dir)
|
||||
|
||||
self.assertEqual(metadata["validation_status"], "accepted_domain_pack_loop")
|
||||
self.assertEqual(metadata["quality_score"], 91)
|
||||
|
||||
def test_validate_accepted_run_dir_rejects_domain_pack_loop_with_p1_targets(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
loop_dir = Path(tmp)
|
||||
iteration_dir = loop_dir / "iterations" / "iteration_00"
|
||||
analyst_path = iteration_dir / "analyst_verdict.json"
|
||||
repair_targets_path = iteration_dir / "pack_output" / "pack_run" / "repair_targets.json"
|
||||
write_json(
|
||||
loop_dir / "loop_state.json",
|
||||
{
|
||||
"loop_id": "stage_demo",
|
||||
"target_score": 88,
|
||||
"final_status": "accepted",
|
||||
"iterations": [
|
||||
"question": "Дай обзор {{bindings.main_organization}} за {{bindings.control_year}} год.",
|
||||
"semantic_tags": ["business_overview", "money"],
|
||||
},
|
||||
{
|
||||
"quality_score": 91,
|
||||
"accepted_gate": True,
|
||||
"analyst_accepted_gate": True,
|
||||
"deterministic_gate_ok": True,
|
||||
"analyst_verdict_path": str(analyst_path),
|
||||
"repair_targets_path": str(repair_targets_path),
|
||||
}
|
||||
"question": "Отдельно по {{bindings.svk_counterparty}} покажи документы.",
|
||||
"semantic_tags": ["counterparty", "documents"],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
write_json(
|
||||
analyst_path,
|
||||
{
|
||||
"loop_decision": "accepted",
|
||||
"unresolved_p0_count": 0,
|
||||
"regression_detected": False,
|
||||
"direct_answer_ok": True,
|
||||
"business_usefulness_ok": True,
|
||||
"temporal_honesty_ok": True,
|
||||
"field_truth_ok": True,
|
||||
"answer_layering_ok": True,
|
||||
},
|
||||
)
|
||||
write_json(repair_targets_path, {"severity_counts": {"P0": 0, "P1": 1, "P2": 0}})
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "repair_targets"):
|
||||
saver.validate_accepted_run_dir(loop_dir)
|
||||
questions = saver.extract_questions_from_spec(spec)
|
||||
|
||||
def test_save_gate_refuses_real_write_without_validation(self) -> None:
|
||||
args = SimpleNamespace(
|
||||
validated_run_dir=None,
|
||||
dry_run=False,
|
||||
allow_unvalidated=False,
|
||||
unvalidated_reason=None,
|
||||
self.assertEqual(
|
||||
questions,
|
||||
[
|
||||
"Дай обзор ООО Альтернатива Плюс за 2020 год.",
|
||||
"Отдельно по Группа СВК покажи документы.",
|
||||
],
|
||||
)
|
||||
self.assertFalse(any("{{bindings." in question for question in questions))
|
||||
self.assertEqual(
|
||||
saver.extract_semantic_tags(spec),
|
||||
["business_overview", "counterparty", "documents", "money"],
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "Refusing to save AGENT autorun"):
|
||||
saver.build_save_gate_metadata(args, {}, Path("demo.json"))
|
||||
def test_extract_questions_refuses_unresolved_bindings(self) -> None:
|
||||
spec = {
|
||||
"questions": ["Что с НДС за {{bindings.control_year}} год?"],
|
||||
"bindings": {},
|
||||
}
|
||||
|
||||
def test_save_gate_requires_reason_for_unvalidated_draft(self) -> None:
|
||||
args = SimpleNamespace(
|
||||
validated_run_dir=None,
|
||||
dry_run=False,
|
||||
allow_unvalidated=True,
|
||||
unvalidated_reason="",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "--unvalidated-reason"):
|
||||
saver.build_save_gate_metadata(args, {}, Path("demo.json"))
|
||||
|
||||
def test_save_gate_marks_explicit_unvalidated_draft(self) -> None:
|
||||
args = SimpleNamespace(
|
||||
validated_run_dir=None,
|
||||
dry_run=False,
|
||||
allow_unvalidated=True,
|
||||
unvalidated_reason="manual GUI canary before live replay",
|
||||
)
|
||||
|
||||
metadata = saver.build_save_gate_metadata(args, {}, Path("demo.json"))
|
||||
|
||||
self.assertEqual(metadata["validation_status"], "explicitly_unvalidated")
|
||||
self.assertFalse(metadata["saved_after_validated_replay"])
|
||||
with self.assertRaisesRegex(RuntimeError, "unresolved bindings"):
|
||||
saver.extract_questions_from_spec(spec)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue