546 lines
21 KiB
Markdown
546 lines
21 KiB
Markdown
Ниже даю ТЗ прямо текстом, без файлов.
|
||
|
||
# ТЗ: Router / Orchestration Fix для бухгалтерского ассистента
|
||
|
||
## 1. Контекст этапа
|
||
|
||
Ontology/mapping слой после semantic-v2 ремонта вытащен из красной зоны: `semantic_coverage_pct` вырос с `61.1917` до `94.9279`, `unknown_links` снизились с `1016` до `102`, `relation_types_total` вырос с `1` до `25`, `covered_entity_classes` — с `33` до `42`, `source_id_unknown` ушёл в `0`. Regression tests: `17 passed`.
|
||
|
||
При этом orchestration/router слой остаётся узким местом. В benchmark по 35 вопросам:
|
||
|
||
* `route_mismatch_count = 7`
|
||
* `degraded_answers_count = 0`
|
||
* `batch_route_count = 0`
|
||
* `store_feature_risk` используется 19 раз
|
||
* `live_mcp_drilldown` — 4 раза
|
||
* `hybrid_store_plus_live` — 4 раза
|
||
* `store_canonical` — 8 раз.
|
||
|
||
Остаточные промахи распределены так:
|
||
|
||
* `heavy_analytical = 4`
|
||
* `cross_entity = 2`
|
||
* `drilldown_explain = 1`.
|
||
Основная confusion matrix:
|
||
* `batch_refresh_then_store -> store_feature_risk: 4`
|
||
* `hybrid_store_plus_live -> store_canonical: 2`
|
||
* `live_mcp_drilldown -> hybrid_store_plus_live: 1`.
|
||
|
||
Сама policy уже описывает правильную целевую логику:
|
||
|
||
* exact object trace → `live_mcp_drilldown`
|
||
* simple factual in loaded slice → `store_canonical`
|
||
* trend/anomaly/risk → `store_feature_risk`
|
||
* heavy whole-slice with freshness gap → `batch_refresh_then_store`
|
||
* low confidence fallback → `hybrid_store_plus_live`.
|
||
|
||
Проблема текущего этапа: policy на бумаге правильная, но runtime-router выбирает route слишком грубо и слишком рано downcast’ит сложные запросы в store-only ветки.
|
||
|
||
---
|
||
|
||
## 2. Цель этапа
|
||
|
||
Довести router/orchestration слой до состояния, в котором:
|
||
|
||
1. heavy whole-slice запросы реально уходят в `batch_refresh_then_store`, а не в `store_feature_risk`;
|
||
2. cross-entity causal/join запросы не downcast’ятся в `store_canonical`;
|
||
3. точечный drilldown по конкретному объекту стабильно уходит в `live_mcp_drilldown`;
|
||
4. появляется прозрачный decision log по каждому route-решению;
|
||
5. `batch_refresh_then_store` перестаёт быть декларацией и начинает реально исполняться в runtime.
|
||
|
||
---
|
||
|
||
## 3. Scope работ
|
||
|
||
В scope этапа входят:
|
||
|
||
* refactor query classifier / route selector;
|
||
* внедрение decision flags;
|
||
* внедрение store sufficiency checker;
|
||
* внедрение explicit route guards;
|
||
* реализация runtime handoff для `batch_refresh_then_store`;
|
||
* внедрение route explanation logging;
|
||
* повторный benchmark-run на тех же 35 вопросах.
|
||
|
||
В scope не входят:
|
||
|
||
* новый overhaul ontology/mapping слоя;
|
||
* новый redesign canonical model;
|
||
* расширение snapshot domain beyond June-2020;
|
||
* тяжёлая оптимизация latency сверх baseline этого этапа.
|
||
Ontology уже починен до уровня, достаточного для данного этапа; оставшиеся unknown-поля точечные и не являются основным блокером для router-фазы.
|
||
|
||
---
|
||
|
||
## 4. Проблемы, которые надо исправить
|
||
|
||
### 4.1 Heavy analytical downcast
|
||
|
||
Сейчас 4 heavy-вопроса, которые должны идти в `batch_refresh_then_store`, реально уходят в `store_feature_risk`. Это касается whole-slice heavy analytical запросов, включая:
|
||
|
||
* `Полный риск-срез за июнь`
|
||
* `Рейтинг риск-счетов`
|
||
* `Рейтинг риск-контрагентов`
|
||
* `Company anomaly summary`.
|
||
|
||
Это противоречит policy, где для `heavy_analytical` задан приоритет `batch_refresh_then_store -> feature_store -> risk_store`.
|
||
|
||
### 4.2 Cross-entity downcast
|
||
|
||
Два cross-entity вопроса ожидают `hybrid_store_plus_live`, но реально уходят в `store_canonical`:
|
||
|
||
* `Свяжи документы покупателей и проводки`
|
||
* `Свяжи контрагентов, договоры и проводки`.
|
||
|
||
Это означает, что router недостаточно хорошо распознаёт chain-join / causal cross-entity shape и преждевременно считает, что canonical store уже достаточен.
|
||
|
||
### 4.3 Drilldown boundary bug
|
||
|
||
Один drilldown вопрос ожидает `live_mcp_drilldown`, но реально идёт в `hybrid_store_plus_live`. Это означает, что граница между exact object trace и mixed live/store explain сейчас определена недостаточно жёстко.
|
||
|
||
### 4.4 Отсутствие реально работающего batch runtime
|
||
|
||
`batch_route_count = 0`, хотя benchmark содержит heavy-вопросы, ожидающие `batch_refresh_then_store`. Это прямо подтверждает, что batch-маршрут не активируется как реальный runtime-path.
|
||
|
||
---
|
||
|
||
## 5. Требуемая архитектура решения
|
||
|
||
### 5.1 Query classifier v2
|
||
|
||
Нужно внедрить отдельный слой decision classification перед выбором маршрута.
|
||
|
||
Требуемый интерфейс:
|
||
|
||
```python
|
||
def classify_query_for_route(
|
||
question_text: str,
|
||
parsed_intent: dict,
|
||
store_metadata: dict,
|
||
) -> RouteDecisionFlags:
|
||
...
|
||
```
|
||
|
||
Classifier обязан вычислять минимум такие flags:
|
||
|
||
```python
|
||
@dataclass
|
||
class RouteDecisionFlags:
|
||
needs_exact_object_trace: bool
|
||
needs_causal_chain: bool
|
||
needs_cross_entity_join: bool
|
||
needs_full_period_aggregation: bool
|
||
needs_ranking: bool
|
||
needs_anomaly_summary: bool
|
||
needs_runtime_truth: bool
|
||
freshness_sensitive: bool
|
||
ambiguous_object_scope: bool
|
||
store_sufficiency_confident: bool
|
||
precomputed_aggregate_available: bool
|
||
```
|
||
|
||
Назначение: отделить semantic classification вопроса от конечного route selection. Сейчас они, по сути, схлопнуты, из-за чего router переоценивает store sufficiency и недооценивает heavy/cross-entity требования. Это видно по текущим mismatch’ам.
|
||
|
||
---
|
||
|
||
## 6. Правила классификации
|
||
|
||
### 6.1 `live_mcp_drilldown`
|
||
|
||
Запрос должен классифицироваться в `live_mcp_drilldown`, если одновременно выполняется:
|
||
|
||
* запрос относится к exact object trace, posting chain, source-of-record или object-level why/explain;
|
||
* объект явно указан или легко нормализуется до конкретного документа/проводки/строки/регистра/субконто;
|
||
* ответ требует runtime evidence, а не только store summary.
|
||
Это согласуется с policy: “exact object trace or posting chain -> live_mcp_drilldown”.
|
||
|
||
Триггеры:
|
||
|
||
* “почему именно эта проводка”
|
||
* “источник этой строки”
|
||
* “цепочка документ -> проводки -> субконто”
|
||
* “почему выбрано это субконто”
|
||
* “документ по номеру и его ссылка”.
|
||
|
||
### 6.2 `hybrid_store_plus_live`
|
||
|
||
Запрос должен классифицироваться в `hybrid_store_plus_live`, если:
|
||
|
||
* требуется cross-entity join между 3+ сущностями;
|
||
* требуется causal stitching по нескольким источникам;
|
||
* store сам по себе не гарантирует достаточную explainability;
|
||
* но запрос не является exact object trace в узком смысле.
|
||
|
||
Триггеры:
|
||
|
||
* документ ↔ проводки
|
||
* контрагент ↔ договор ↔ проводки
|
||
* регистр ↔ первичный документ
|
||
* explain через движения без указания одного точечного source-of-record.
|
||
|
||
### 6.3 `store_canonical`
|
||
|
||
Разрешён только для:
|
||
|
||
* simple factual within loaded slice;
|
||
* материализованных и однозначных canonical facts;
|
||
* cross-entity вопросов без causal stitching и без runtime evidence need.
|
||
|
||
Запрещён для:
|
||
|
||
* chain joins;
|
||
* “свяжи X и Y через Z”;
|
||
* source-of-record explanations;
|
||
* heavy full-period aggregations.
|
||
|
||
### 6.4 `store_feature_risk`
|
||
|
||
Разрешён только для:
|
||
|
||
* trend / anomaly / risk вопросов;
|
||
* ambiguous fuzzy risk/tax questions;
|
||
* heavy-вопросов только при явном наличии готового и свежего precomputed aggregate.
|
||
|
||
Не должен быть default-веткой для heavy_analytical whole-slice вопросов.
|
||
|
||
### 6.5 `batch_refresh_then_store`
|
||
|
||
Обязателен для heavy whole-slice вопросов, если:
|
||
|
||
* нужен full-period aggregation;
|
||
* нужен ranking;
|
||
* нужен company-wide anomaly/risk summary;
|
||
* нужна свежесть выше текущего store snapshot;
|
||
* нет подтверждённого precomputed aggregate нужного уровня.
|
||
|
||
---
|
||
|
||
## 7. Store sufficiency checker
|
||
|
||
Нужно реализовать отдельный модуль проверки достаточности store перед выбором маршрута.
|
||
|
||
Требуемый интерфейс:
|
||
|
||
```python
|
||
def check_store_sufficiency(
|
||
question_shape: RouteDecisionFlags,
|
||
store_metadata: dict,
|
||
) -> StoreSufficiencyResult:
|
||
...
|
||
```
|
||
|
||
Минимальная модель результата:
|
||
|
||
```python
|
||
@dataclass
|
||
class StoreSufficiencyResult:
|
||
canonical_sufficient: bool
|
||
feature_sufficient: bool
|
||
risk_sufficient: bool
|
||
freshness_ok: bool
|
||
aggregate_level_ok: bool
|
||
ranking_ready: bool
|
||
explanation_ready: bool
|
||
reason_codes: list[str]
|
||
```
|
||
|
||
Checker обязан учитывать:
|
||
|
||
* покрытие периода;
|
||
* наличие нужной группировки;
|
||
* наличие precomputed ranking aggregate;
|
||
* наличие risk/anomaly aggregate нужного уровня;
|
||
* freshness snapshot;
|
||
* достаточность store для explainability.
|
||
|
||
Ключевая цель — убрать ложные решения вида:
|
||
|
||
* “store_feature_risk и так хватит”
|
||
* “store_canonical и так хватит”
|
||
в тех кейсах, где store на самом деле не покрывает shape запроса. Именно это сейчас ломает heavy и cross-entity маршруты.
|
||
|
||
---
|
||
|
||
## 8. Explicit route guards
|
||
|
||
После classifier и sufficiency-checker route должен выбираться не scoring’ом “в среднем”, а guard-правилами.
|
||
|
||
Требуемая логика:
|
||
|
||
```python
|
||
def choose_route(flags: RouteDecisionFlags, suff: StoreSufficiencyResult) -> str:
|
||
if flags.needs_exact_object_trace:
|
||
return "live_mcp_drilldown"
|
||
|
||
if flags.needs_full_period_aggregation or flags.needs_ranking or flags.needs_anomaly_summary:
|
||
if not (flags.precomputed_aggregate_available and suff.freshness_ok and suff.aggregate_level_ok):
|
||
return "batch_refresh_then_store"
|
||
|
||
if flags.needs_cross_entity_join and flags.needs_causal_chain:
|
||
if not suff.explanation_ready:
|
||
return "hybrid_store_plus_live"
|
||
|
||
if suff.feature_sufficient and not flags.needs_runtime_truth and (
|
||
flags.needs_anomaly_summary or parsed_as_trend_or_risk
|
||
):
|
||
return "store_feature_risk"
|
||
|
||
if suff.canonical_sufficient and not flags.needs_causal_chain:
|
||
return "store_canonical"
|
||
|
||
return "hybrid_store_plus_live"
|
||
```
|
||
|
||
Смысл:
|
||
|
||
* drilldown имеет жёсткий приоритет;
|
||
* heavy whole-slice с ranking/summary и недостаточным aggregate обязан уйти в batch;
|
||
* causal cross-entity не имеет права downcast’иться в `store_canonical`;
|
||
* fallback остаётся `hybrid_store_plus_live`.
|
||
|
||
---
|
||
|
||
## 9. Runtime batch handoff
|
||
|
||
Нужно реализовать реальный handoff для `batch_refresh_then_store`.
|
||
|
||
Требуемый поток:
|
||
|
||
1. Router выбирает `batch_refresh_then_store`.
|
||
2. Создаётся orchestration job с типом `refresh_and_answer`.
|
||
3. Job:
|
||
|
||
* проверяет актуальность snapshot/feature/risk stores;
|
||
* при необходимости пересчитывает refresh/features/risk;
|
||
* фиксирует run ids;
|
||
* передаёт управление answer synthesis.
|
||
4. Answer synthesis читает уже обновлённые stores и формирует ответ.
|
||
5. Route/log сохраняют факт batch execution.
|
||
|
||
Минимальный payload job:
|
||
|
||
```json
|
||
{
|
||
"job_type": "refresh_and_answer",
|
||
"question_id": "Q28",
|
||
"slice_window": "2020-06",
|
||
"requested_outputs": ["feature_store", "risk_store"],
|
||
"reason": ["needs_full_period_aggregation", "needs_ranking", "aggregate_not_sufficient"]
|
||
}
|
||
```
|
||
|
||
Обязательное требование: `batch_refresh_then_store` должен стать исполняемым runtime-path, а не декларацией в policy. Сейчас это не так, что видно по `batch_route_count = 0`.
|
||
|
||
---
|
||
|
||
## 10. Route explanation logging
|
||
|
||
Нужно добавить прозрачный лог принятия маршрута.
|
||
|
||
Требуемый формат:
|
||
|
||
```python
|
||
@dataclass
|
||
class RouteDecisionLog:
|
||
question_id: str
|
||
question_text: str
|
||
parsed_class: str
|
||
decision_flags: dict
|
||
sufficiency_snapshot: dict
|
||
candidate_routes: list[str]
|
||
rejected_routes: dict[str, str]
|
||
chosen_route: str
|
||
execution_mode: str
|
||
batch_job_id: str | None
|
||
```
|
||
|
||
Пример лог-записи:
|
||
|
||
```json
|
||
{
|
||
"question_id": "Q30",
|
||
"parsed_class": "heavy_analytical",
|
||
"decision_flags": {
|
||
"needs_full_period_aggregation": true,
|
||
"needs_ranking": false,
|
||
"needs_anomaly_summary": true,
|
||
"needs_runtime_truth": false
|
||
},
|
||
"sufficiency_snapshot": {
|
||
"feature_sufficient": false,
|
||
"risk_sufficient": false,
|
||
"freshness_ok": false,
|
||
"aggregate_level_ok": false
|
||
},
|
||
"rejected_routes": {
|
||
"store_feature_risk": "aggregate_not_sufficient",
|
||
"store_canonical": "wrong_query_shape"
|
||
},
|
||
"chosen_route": "batch_refresh_then_store"
|
||
}
|
||
```
|
||
|
||
Это нужно для последующего дебага benchmark mismatch’ов. Иначе команда и дальше будет видеть только конечный маршрут, но не причину выбора.
|
||
|
||
---
|
||
|
||
## 11. Конкретные ошибки benchmark, которые должны быть закрыты
|
||
|
||
### 11.1 Heavy
|
||
|
||
Нужно закрыть как минимум:
|
||
|
||
* Q26 `Полный риск-срез за июнь`
|
||
* Q27 `Рейтинг риск-счетов`
|
||
* Q28 `Рейтинг риск-контрагентов`
|
||
* Q30 `Company anomaly summary`
|
||
Ожидаемый результат: эти вопросы должны идти в `batch_refresh_then_store`, а не в `store_feature_risk`.
|
||
|
||
### 11.2 Cross-entity
|
||
|
||
Нужно закрыть:
|
||
|
||
* Q11 `Свяжи документы покупателей и проводки`
|
||
* Q12 `Свяжи контрагентов, договоры и проводки`
|
||
Ожидаемый результат: `hybrid_store_plus_live`, а не `store_canonical`.
|
||
|
||
### 11.3 Drilldown
|
||
|
||
Нужно закрыть 1 mismatch по границе `live_mcp_drilldown` / `hybrid_store_plus_live`.
|
||
Ожидаемый результат: точечный object-trace вопрос не должен уходить в hybrid, если вопрос явно указывает на source-of-record / posting chain.
|
||
|
||
---
|
||
|
||
## 12. Предлагаемая структура модулей
|
||
|
||
### Вариант минимальной структуры
|
||
|
||
`router/query_classifier.py`
|
||
|
||
* `classify_query_for_route()`
|
||
* правила выделения decision flags
|
||
|
||
`router/store_sufficiency.py`
|
||
|
||
* `check_store_sufficiency()`
|
||
* оценка достаточности canonical / feature / risk stores
|
||
|
||
`router/route_selector.py`
|
||
|
||
* `choose_route()`
|
||
* explicit route guards
|
||
|
||
`orchestration/batch_runtime.py`
|
||
|
||
* `enqueue_refresh_and_answer_job()`
|
||
* `run_refresh_and_answer_job()`
|
||
|
||
`router/decision_log.py`
|
||
|
||
* `build_route_decision_log()`
|
||
* сериализация route decision trace
|
||
|
||
`tests/test_router_decision_flags.py`
|
||
|
||
* флаги для benchmark-вопросов
|
||
|
||
`tests/test_store_sufficiency.py`
|
||
|
||
* sufficiency cases
|
||
|
||
`tests/test_route_guards.py`
|
||
|
||
* guard logic
|
||
|
||
`tests/test_batch_runtime_handoff.py`
|
||
|
||
* batch execution path
|
||
|
||
---
|
||
|
||
## 13. Требования к тестам
|
||
|
||
Нужно добавить unit + integration тесты.
|
||
|
||
### Unit tests
|
||
|
||
Покрыть:
|
||
|
||
* correct flag extraction for heavy queries;
|
||
* correct flag extraction for drilldown queries;
|
||
* correct flag extraction for cross-entity causal queries;
|
||
* store sufficiency false/true cases;
|
||
* route guards for each route;
|
||
* batch handoff payload building.
|
||
|
||
### Integration tests
|
||
|
||
Покрыть:
|
||
|
||
* heavy query without ready aggregate → `batch_refresh_then_store`
|
||
* heavy query with ready fresh aggregate → `store_feature_risk`
|
||
* cross-entity causal query → `hybrid_store_plus_live`
|
||
* exact object trace → `live_mcp_drilldown`
|
||
* simple factual in loaded slice → `store_canonical`
|
||
|
||
Отдельно нужно прогнать benchmark subset по вопросам:
|
||
|
||
* Q06–Q12
|
||
* Q26–Q30
|
||
потому что именно там сейчас сосредоточены остаточные mismatch’и.
|
||
|
||
---
|
||
|
||
## 14. Acceptance criteria
|
||
|
||
Этап считается принятым, если после повторного benchmark-run достигнуты условия:
|
||
|
||
* `route_mismatch_count <= 2` вместо текущих `7`
|
||
* `heavy_analytical mismatches = 0`
|
||
* `cross_entity mismatches = 0`
|
||
* `drilldown_explain mismatches <= 1`, целевое — `0`
|
||
* `batch_route_count > 0`
|
||
* `degraded_answers_count = 0`
|
||
* `adopt_with_improvements` сохраняется минимум, желательно с качественным улучшением рекомендаций
|
||
* route decision logs сохраняются для всех 35 benchmark-вопросов.
|
||
|
||
Латентность допускается немного выше baseline по heavy-вопросам, так как batch route по профилю дороже: у него baseline retrieval около `1240 ms` против `190 ms` у `store_feature_risk`. Но рост должен быть локализован только на heavy-path и не должен ломать обычные store-first сценарии.
|
||
|
||
---
|
||
|
||
## 15. Нефункциональные требования
|
||
|
||
Нужно сохранить текущие плюсы системы:
|
||
|
||
* `store-first retrieval policy`;
|
||
* bounded context;
|
||
* отсутствие деградированных ответов;
|
||
* отсутствие uncapped heavy live scans.
|
||
|
||
Запрещается:
|
||
|
||
* заменять batch на тяжёлый live-scan;
|
||
* насильно переводить все cross-entity вопросы в live;
|
||
* ломать простые store-only factual ответы;
|
||
* убирать fallback `hybrid_store_plus_live`.
|
||
|
||
---
|
||
|
||
## 16. Порядок реализации
|
||
|
||
Сначала сделать classifier v2 и decision flags. Потом — store sufficiency checker. После этого — route guards. Затем — runtime handoff для batch. Потом — decision logging. И только после этого — повторный benchmark-run и точечная подстройка порогов по остаточным кейсам. Такой порядок минимизирует расползание архитектуры и даёт сразу диагностируемую систему.
|
||
|
||
---
|
||
|
||
## 17. Ожидаемый результат этапа
|
||
|
||
После выполнения этого ТЗ система должна перейти из состояния:
|
||
|
||
* “ontology уже хорошая, но router маршрутизирует местами по упрощённой логике”
|
||
|
||
в состояние:
|
||
|
||
* “ontology + router + orchestration согласованы между собой, а route choice воспроизводим, объясним и исполняется реально, включая batch”.
|
||
|
||
|