Productize decision assistant archive and context cockpit batch
This commit is contained in:
+36
-5
@@ -1,8 +1,8 @@
|
||||
# ai-exchange Session
|
||||
# ai-exchange Session
|
||||
|
||||
## Current Goal
|
||||
|
||||
Automation execution-layer PXX remains closed and its known scope stays frozen. Under explicit user direction, the active product phase is a dense `Decision Assistant` operator workspace with grouped recall, broader replay baselines, note templates, quick-save shortcuts, read-only market context, and a continuous operator cockpit. The current run closed the remaining productization gaps from `docs/AI_ICT_实盘交易分析决策助手未完成需求清单_2026-05-14_v1.md`: workflow archive bodies/query/compare, digest archive query/compare, server-persisted focus workspace, and richer digest preference/profile management are now implemented together and aligned with the codebase.
|
||||
Automation execution-layer PXX remains closed and its known scope stays frozen. Under explicit user direction, the active product phase is a dense `Decision Assistant` operator workspace with grouped recall, broader replay baselines, note templates, quick-save shortcuts, read-only market context, and a continuous operator cockpit. The previous `鏈畬鎴愰渶姹傛竻鍗昤 has been archived, and the current documentation baseline now uses `docs/AI_ICT_闇€姹傛枃妗e墿浣欏姛鑳戒笌寤舵湡椤规竻鍗昣2026-05-14_v1.md` to distinguish true remaining enhancement work from explicitly deferred real-trading scope.
|
||||
|
||||
## Current Branch
|
||||
|
||||
@@ -140,6 +140,21 @@ Automation execution-layer PXX remains closed and its known scope stays frozen.
|
||||
- latest_decision_assistant_archive_productization_slice: workflow_digest_archive_query_compare
|
||||
- latest_decision_assistant_focus_persistence_slice: server_persisted_focus_candidate
|
||||
- latest_decision_assistant_profile_management_slice: digest_profiles_and_editable_importance_templates
|
||||
- latest_decision_assistant_enhancement_plan_path: docs/AI_ICT_鍒嗘瀽鍔╂墜涓荤嚎澧炲己寮€鍙戣鍒抇2026-05-14_v1.md
|
||||
- latest_decision_assistant_preferences_surface_slice: independent_preferences_section
|
||||
- latest_decision_assistant_profile_lifecycle_slice: digest_preference_profile_create_rename_delete
|
||||
- latest_decision_assistant_preferences_draft_slice: explicit_dirty_save_revert_reset
|
||||
- latest_decision_assistant_archive_filter_slice: archive_filter_toolbar_and_query_context
|
||||
- latest_decision_assistant_archive_selection_slice: arbitrary_archive_snapshot_compare
|
||||
- latest_decision_assistant_archive_pagination_slice: archive_load_more_and_offset_state
|
||||
- latest_decision_assistant_focus_evidence_slice: focus_candidate_evidence_chain
|
||||
- latest_decision_assistant_focus_digest_slice: focus_related_reminder_recompute
|
||||
- latest_decision_assistant_focus_replay_slice: candidate_scoped_replay_rollup
|
||||
- latest_decision_assistant_focus_note_slice: structured_focus_note_prefill
|
||||
- latest_decision_assistant_macro_provider_slice: macro_news_provider_inventory
|
||||
- latest_decision_assistant_chart_registry_slice: multi_provider_chart_reference_registry
|
||||
- latest_decision_assistant_exchange_surface_slice: exchange_context_surface_inventory
|
||||
- latest_decision_assistant_visual_reference_slice: external_context_visual_reference_cards
|
||||
- latest_decision_assistant_summary_status: implemented
|
||||
- latest_decision_assistant_change_summary_status: implemented
|
||||
- latest_decision_assistant_evidence_status: implemented
|
||||
@@ -271,7 +286,7 @@ Automation execution-layer PXX remains closed and its known scope stays frozen.
|
||||
- latest_p7_selector_choice: continue_periodic_analysis_decision_monitoring_with_maturity_rollup
|
||||
- latest_p14_slice: webhook_transport_retry
|
||||
- latest_p15_pxx_tasklist_status: ready
|
||||
- latest_p15_pxx_tasklist_path: docs/AI_ICT_自动化执行层_P15-PXX闭环任务清单_v1.md
|
||||
- latest_p15_pxx_tasklist_path: docs/AI_ICT_鑷姩鍖栨墽琛屽眰_P15-PXX闂幆浠诲姟娓呭崟_v1.md
|
||||
- latest_p15_slice: alert_noise_policy_matrix
|
||||
- latest_alert_noise_policy_status: implemented
|
||||
- latest_alert_noise_policy_route_status: visible_existing_quality_summary
|
||||
@@ -525,7 +540,7 @@ Automation execution-layer PXX remains closed and its known scope stays frozen.
|
||||
- Added bounded webhook transport retry inside `AlertDispatcherService._send` so transient outbound failures can be retried before the alert is marked failed.
|
||||
- Added transport-retry observability in dispatcher payloads, delivery summary fields, and runtime `alert_dispatch` details.
|
||||
- Added focused unit tests for the service, script, API routing, and runtime task.
|
||||
- Added `docs/AI_ICT_自动化执行层_P15-PXX闭环任务清单_v1.md` to define a finite continuation queue from P15 through PXX.
|
||||
- Added `docs/AI_ICT_鑷姩鍖栨墽琛屽眰_P15-PXX闂幆浠诲姟娓呭崟_v1.md` to define a finite continuation queue from P15 through PXX.
|
||||
- Updated the state anchors so future continuation starts at P15 instead of inventing new unbounded phases.
|
||||
- Added alert noise policy matrix settings for external minimum severity, workbench-only alert types, suppressed duty modes, and suppressed operator modes.
|
||||
- Extended `DeliveryPolicyService` with `evaluate_alert_delivery_policy()` so alert status, silent window, alert type, severity, channel, duty mode, operator mode, and cooldown return a structured policy decision.
|
||||
@@ -688,11 +703,21 @@ Automation execution-layer PXX remains closed and its known scope stays frozen.
|
||||
- Latest E8 validation passed: focused exchange/provider tests, 71 related unit/API/static tests, 717 full unit tests, 73 script static checks, deploy completed, remote ops smoke passed 9 checks, and remote field smoke confirmed `exchange_status/announcements = idle`, `cache_state = empty_fallback`, and `chart_stale_source_count = 0`.
|
||||
- Latest E9/B1 validation passed: 72 related unit/API/static tests, 720 full unit tests, 73 script static checks, `node --check src/web/app.js`, remote deploy completed, remote ops smoke passed 9 checks, and remote field smoke confirmed `chart_url`, `symbol=BTC-USDT`, `interval=1m`, adapter mode `live_reference/empty_fallback`, and `unattended_harness = passed`.
|
||||
- Latest W6/F27/F28/H29 validation passed: 70 focused route/operator/static tests, 722 full unit tests, 73 script static checks, `node --check src/web/app.js`, deploy smoke passed 11 endpoints, remote ops smoke passed 9 checks, and remote field smoke confirmed digest/workflow archive query+compare routes, profile route visibility, and non-blocking assistant focus route.
|
||||
- Latest A1/A2/A3/A4 validation passed: 71 focused API/operator/static tests, 723 full unit tests, 73 script static checks, `node --check src/web/app.js`, deploy smoke passed 11 endpoints, remote ops smoke passed 9 checks, and remote Phase A smoke confirmed profile create/save/review-packet profile switch/rename/delete closure.
|
||||
- Latest B1/B2/B3/B4 validation passed: 71 focused API/operator/static tests, 723 full unit tests, 73 script static checks, `node --check src/web/app.js`, deploy smoke passed 11 endpoints, remote ops smoke passed 9 checks, and remote Phase B smoke confirmed archive filters, arbitrary digest/workflow snapshot compare, and load-more compatible query state.
|
||||
- Latest C1/C2/C3/C4 validation passed: 71 focused API/operator/static tests, 723 full unit tests, 73 script static checks, `node --check src/web/app.js`, deploy smoke passed 11 endpoints, remote ops smoke passed 9 checks, and remote Phase C smoke confirmed persisted focus drives evidence, reminders, replay scope, and structured note prefill.
|
||||
- Latest D1/D2/D3/D4 validation passed: 23 focused provider/operator/static tests, 723 full unit tests, 73 script static checks, `node --check src/web/app.js`, deploy smoke passed 11 endpoints, remote ops smoke passed 9 checks, and remote Phase D smoke confirmed macro provider inventory, chart provider registry, exchange surface inventory, and visual reference cards on the live host.
|
||||
- Latest E1 validation passed: 74 focused workflow/operator/static tests, 726 full unit tests, 73 script static checks, `node --check src/web/app.js`, deploy smoke passed 11 endpoints, remote ops smoke passed 9 checks, and remote Phase E smoke confirmed workflow archive operations summary, day/session groups, multi-day drift review, and deployed `workflow operations` / `workflow drift` / `Day Group` / `Session Group` UI strings on the live host.
|
||||
- Latest B5 validation passed: 72 focused API/static/operator tests, 726 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote B5 smoke confirmed digest/workflow archive `query_text` search, digest/workflow export bundles, and deployed `Search Text` / `Export Archive Results` / `Export Workflow Results` UI strings on the live host.
|
||||
- Latest W7 validation passed: 74 focused workflow/API/static/operator tests, 726 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote W7 smoke confirmed workflow archive bridge summary, handoff export bundle summary, and deployed `Replay Bridge` / `Note Bridge` / `Handoff Bridge` / `Prefill Replay Note` UI strings on the live host.
|
||||
- Latest E10 validation passed: 16 focused external-context/static tests, 726 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote E10 smoke confirmed provider health summary/cards, visual evidence bridge summary, and deployed `Open Visual Bridge` / `Open Target` / `Open Embed` UI strings on the live host.
|
||||
- Latest O1 validation passed: 19 focused archive/static/operator tests, 729 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote O1 smoke confirmed digest/workflow long-range trend summaries, baseline pin starter fields, and deployed `archive trend` / `archive baseline` / `Pin Baseline` / `Compare To Baseline` UI strings on the live host.
|
||||
- Refreshed `docs/AI_ICT_??????????????????????2026-05-14_v1.md` after closing Phases A-E; the active remaining mainline backlog is now archive deep retrieval follow-up, external context deep visual evidence, workflow archive deep bridge extensions, and archive long-range trend deepening.
|
||||
- Real-live order submission remains intentionally disabled because the OKX order adapter is still not a real-live submitter.
|
||||
|
||||
## Exact Next Step
|
||||
|
||||
The `未完成需求清单` alignment batch is now the active closure target: deploy the current Decision Assistant archive/focus/profile productization work, smoke the new archive/focus routes, and then treat any further work as non-blocking enhancement depth rather than unfinished baseline scope. PXX remains closed and you still must not create P25/P26 automatically or enable real trading.
|
||||
Use `docs/AI_ICT_???????????????????2026-05-14_v1.md` as the completed enhancement record for Phases A-E. The active backlog source is now `docs/AI_ICT_??????????????????????2026-05-14_v1.md`. `B5 archive keyword search / export starter slice`, `W7 workflow archive replay / handoff / export bridge starter slice`, `E10 external context visual evidence / provider health starter slice`, and `O1 archive long-range trend / baseline pin starter slice` are now closed. Start from `B6 archive deep retrieval / structured export follow-up starter slice`, keep work on the analysis-assistant mainline only, and continue treating real-trading scope as deferred. PXX remains closed and you still must not create P25/P26 automatically or enable real trading.
|
||||
|
||||
## Validation Commands Used Recently
|
||||
|
||||
@@ -711,6 +736,12 @@ D:\ai-exchange\.venv\Scripts\python.exe -m unittest discover -s tests/unit
|
||||
node --check src/web/app.js
|
||||
D:\ai-exchange\.venv\Scripts\python.exe scripts\deploy_with_password.py --host 192.168.3.90 --user root --port 22 --password <provided>
|
||||
D:\ai-exchange\.venv\Scripts\python.exe scripts\run_remote_ops_smoke.py --base-url http://192.168.3.90:8000 --attempts 2 --timeout-seconds 20
|
||||
D:\ai-exchange\.venv\Scripts\python.exe -m unittest tests.unit.test_workflow_archive_projection tests.unit.test_operator_workspace_service tests.unit.test_api_static_server tests.unit.test_api_server
|
||||
D:\ai-exchange\.venv\Scripts\python.exe scripts\run_script_static_checks.py
|
||||
D:\ai-exchange\.venv\Scripts\python.exe -m unittest discover -s tests/unit
|
||||
node --check src/web/app.js
|
||||
D:\ai-exchange\.venv\Scripts\python.exe scripts\deploy_with_password.py --host 192.168.3.90 --user root --port 22 --password <provided>
|
||||
D:\ai-exchange\.venv\Scripts\python.exe scripts\run_remote_ops_smoke.py --base-url http://192.168.3.90:8000 --attempts 2 --timeout-seconds 20
|
||||
D:\ai-exchange\.venv\Scripts\python.exe -m unittest tests.unit.test_visual_review_service tests.unit.test_run_visual_review tests.unit.test_trading_runtime_service tests.unit.test_api_and_main tests.unit.test_api_server
|
||||
D:\ai-exchange\.venv\Scripts\python.exe scripts\run_script_static_checks.py
|
||||
D:\ai-exchange\.venv\Scripts\python.exe -m unittest tests.unit.test_run_until_blocked tests.unit.test_monitoring_alert_rollout tests.unit.test_runtime_script_json
|
||||
|
||||
@@ -103,6 +103,29 @@
|
||||
|
||||
## Next Up
|
||||
|
||||
- [x] B5 archive keyword search / export starter slice
|
||||
- [x] W7 workflow archive replay / handoff / export bridge starter slice
|
||||
- [x] E10 external context visual evidence / provider health starter slice
|
||||
- [x] O1 archive long-range trend / baseline pin starter slice
|
||||
- [ ] B6 archive deep retrieval / structured export follow-up starter slice
|
||||
|
||||
- [x] A1 independent preferences surface / dedicated settings section
|
||||
- [x] A2 preference profile create / duplicate / rename / delete
|
||||
- [x] A3 global and template threshold editor / reset defaults
|
||||
- [x] A4 dirty draft / explicit save / revert
|
||||
- [x] B1 archive deep retrieval / arbitrary snapshot compare
|
||||
- [x] B2 archive filter toolbar / query context / date-range retrieval
|
||||
- [x] B3 archive load-more / offset-state browser
|
||||
- [x] B4 digest and workflow compare drill-down
|
||||
- [x] C1 focus-aware evidence chain
|
||||
- [x] C2 focus-aware digest reminder recompute
|
||||
- [x] C3 candidate-scoped replay learning rollup
|
||||
- [x] C4 structured focus note prefill / submit context
|
||||
- [x] D1 multi-provider chart reference registry
|
||||
- [x] D2 macro/news provider inventory enrichment
|
||||
- [x] D3 exchange context surface inventory
|
||||
- [x] D4 external context visual reference cards
|
||||
- [x] E1 workflow archive operations view / multi-day drift review
|
||||
- [x] W6 workflow archive bodies / query / compare productization slice complete
|
||||
- [x] F27 digest archive query / compare productization slice complete
|
||||
- [x] F28 digest profile management / editable importance template slice complete
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# AI ICT 全自动开发执行流程 v1
|
||||
# AI ICT 全自动开发执行流程 v1
|
||||
|
||||
## 1. 目标
|
||||
|
||||
@@ -98,15 +98,15 @@
|
||||
|
||||
- `docs/archive/AI_ICT_下一阶段开发计划_v1.md`
|
||||
- `docs/archive/AI_ICT_下一阶段开发任务清单_v1.md`
|
||||
- `docs/AI_ICT_TradingAgents_P0自动任务清单_v1.md`
|
||||
- `docs/archive/AI_ICT_TradingAgents_P0自动任务清单_v1.md`
|
||||
- `docs/AI_ICT_TradingAgents_P1自动任务清单_v1.md`
|
||||
- `docs/AI_ICT_TradingAgents_P2自动任务清单_v1.md`
|
||||
- `docs/AI_ICT_TradingAgents_P3自动任务清单_v1.md`
|
||||
- `docs/AI_ICT_TradingAgents_P4自动任务清单_v1.md`
|
||||
- `docs/AI_ICT_TradingAgents_P5自动任务清单_v1.md`
|
||||
- `docs/AI_ICT_TradingAgents_P6自动任务清单_v1.md`
|
||||
- `docs/AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md`
|
||||
- `docs/AI_ICT_research_council脚手架与数据表草案_v1.md`
|
||||
- `docs/archive/AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md`
|
||||
- `docs/archive/AI_ICT_research_council脚手架与数据表草案_v1.md`
|
||||
|
||||
## 4.3 真源原则
|
||||
|
||||
@@ -364,7 +364,7 @@ npm run visual:snapshots
|
||||
|
||||
必要时同步更新:
|
||||
|
||||
- `docs/AI_ICT_TradingAgents融合实施方案_最终版.md`
|
||||
- `docs/archive/AI_ICT_TradingAgents融合实施方案_最终版.md`
|
||||
- 当前阶段相关任务清单 / 技术设计文档
|
||||
|
||||
必须写回的内容:
|
||||
@@ -688,7 +688,7 @@ no_op_healthy 时停止
|
||||
|
||||
正式进入自动化执行层开发的条件:
|
||||
|
||||
1. `docs/AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md` 已存在
|
||||
1. `docs/archive/AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md` 已存在
|
||||
2. `SESSION.md` / `TASKS.md` 已写明 `automation_flow_p0_p6_review_closed`
|
||||
3. P6 governance tick 最近一次结果为 `no_op_healthy`
|
||||
4. 文档一致性测试通过
|
||||
@@ -740,7 +740,7 @@ P7 必须继续遵守:
|
||||
P7 任务清单以以下文档为准:
|
||||
|
||||
```text
|
||||
docs/AI_ICT_自动化执行层_P7监控成熟度与晋级路由_v1.md
|
||||
docs/archive/AI_ICT_自动化执行层_P7监控成熟度与晋级路由_v1.md
|
||||
```
|
||||
|
||||
P14 之后的自动化执行层连续开发,以以下有限清单为准:
|
||||
@@ -827,3 +827,4 @@ docs/AI_ICT_自动化执行层_P15-PXX闭环任务清单_v1.md
|
||||
换句话说:
|
||||
|
||||
> **以后该项目默认全程采用自动化流程推进,除非遇到真实外部阻塞或必须用户拍板的产品决策。**
|
||||
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
# AI_ICT 分析助手主线增强开发计划 2026-05-14 v1
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本计划只围绕当前“分析助手”主线推进,不展开真实交易、OKX live submit、默认自动下单、live adapter 开闸。
|
||||
|
||||
本轮增强的目标是把当前已经可用的 `Decision Assistant`,从“有基础闭环的工作台”进一步推进为:
|
||||
|
||||
- 可配置
|
||||
- 可检索
|
||||
- 可对比
|
||||
- 可聚焦
|
||||
- 可连续日常使用
|
||||
|
||||
重点只覆盖以下 5 个方向:
|
||||
|
||||
1. 独立偏好设置面
|
||||
2. archive 深度检索与任意对比
|
||||
3. candidate-scoped 连续工作区重算
|
||||
4. 外部上下文 provider 与视觉化增强
|
||||
5. workflow archive 运营化能力
|
||||
|
||||
## 2. 边界
|
||||
|
||||
本计划明确不做:
|
||||
|
||||
- 真实交易执行
|
||||
- OKX live submit
|
||||
- 默认自动下单
|
||||
- risk governor / trust bucket / operator confirmation protocol 逻辑变更
|
||||
- execution_allowed / real_live_ready 开闸
|
||||
|
||||
本计划只做:
|
||||
|
||||
- 分析助手体验增强
|
||||
- 只读数据组织增强
|
||||
- 历史检索与比较增强
|
||||
- 工作台聚焦与连贯性增强
|
||||
|
||||
## 3. 当前基线
|
||||
|
||||
当前代码已具备的基础:
|
||||
|
||||
- digest preferences 持久化
|
||||
- digest history archive / workflow history archive
|
||||
- latest-two compare 基础能力
|
||||
- focus candidate 持久化基础版
|
||||
- continuous workspace / loop queue / watchlist / replay / notes 入口
|
||||
- 外部上下文基础卡片
|
||||
- external chart read-only provider 基础版
|
||||
|
||||
因此本轮开发不是从零搭建,而是做“产品层增强”。
|
||||
|
||||
## 4. 总体推进顺序
|
||||
|
||||
建议按以下顺序推进:
|
||||
|
||||
### Phase A
|
||||
|
||||
- 独立偏好设置面
|
||||
|
||||
### Phase B
|
||||
|
||||
- archive 深度检索与任意对比
|
||||
|
||||
### Phase C
|
||||
|
||||
- candidate-scoped 连续工作区重算
|
||||
|
||||
### Phase D
|
||||
|
||||
- 外部上下文 provider 与视觉化增强
|
||||
|
||||
### Phase E
|
||||
|
||||
- workflow archive 运营化能力
|
||||
|
||||
原因:
|
||||
|
||||
- A 和 B 能最快提升日常可用性
|
||||
- C 能把现有 cockpit 从“看板”推进成“工作区”
|
||||
- D 和 E 更偏增强深度,适合在前 3 项稳定后推进
|
||||
|
||||
## 5. Phase A: 独立偏好设置面
|
||||
|
||||
### 5.1 目标状态
|
||||
|
||||
从当前“通过内联按钮和 action bar 保存偏好”,升级到独立的 `Preferences` 工作区。
|
||||
|
||||
用户应能:
|
||||
|
||||
- 查看当前 profile
|
||||
- 新建 / 复制 / 重命名 / 删除 profile
|
||||
- 调整 digest mode
|
||||
- 调整 preferred history kind
|
||||
- 调整 global importance threshold
|
||||
- 单独编辑 importance template threshold
|
||||
- 配置 history window
|
||||
- 明确保存 / 重置 / 撤销
|
||||
|
||||
### 5.2 交付范围
|
||||
|
||||
后端:
|
||||
|
||||
- profile 管理 API
|
||||
- profile 新建 / 复制 / 删除
|
||||
- payload schema 稳定化
|
||||
- profile label 唯一性与回退规则
|
||||
|
||||
前端:
|
||||
|
||||
- 新增独立 `Preferences` section
|
||||
- profile selector
|
||||
- profile actions
|
||||
- threshold editor
|
||||
- save / reset / duplicate / delete controls
|
||||
- dirty state 提示
|
||||
|
||||
数据层:
|
||||
|
||||
- 继续复用 `decision_assistant_preferences`
|
||||
- 增补 payload 规范:
|
||||
- `profile_label`
|
||||
- `history_window_hours`
|
||||
- `importance_templates`
|
||||
- `focus_candidate_id`
|
||||
- 后续可拓展字段占位
|
||||
|
||||
### 5.3 建议切片
|
||||
|
||||
#### A1 profile list + selector
|
||||
|
||||
- `GET /trading/alerts/digest-preferences/profiles`
|
||||
- 前端独立展示当前 profile
|
||||
- UI 不再只靠 action bar
|
||||
|
||||
#### A2 profile create / duplicate / rename / delete
|
||||
|
||||
- 后端加管理 route
|
||||
- 前端支持 profile 级管理动作
|
||||
|
||||
#### A3 threshold editor
|
||||
|
||||
- global threshold
|
||||
- template threshold
|
||||
- reset to default
|
||||
|
||||
#### A4 dirty state + explicit save/revert
|
||||
|
||||
- 未保存提示
|
||||
- 手动保存
|
||||
- 取消修改
|
||||
|
||||
### 5.4 验收标准
|
||||
|
||||
- 不进入 digest 主卡片,也能完整管理偏好
|
||||
- 新 profile 能创建并立即生效
|
||||
- 修改 template threshold 后刷新仍保留
|
||||
- 删除 profile 不影响默认 `local_operator`
|
||||
|
||||
### 5.5 focused validation
|
||||
|
||||
- repository / API unit tests
|
||||
- `test_api_server`
|
||||
- `test_operator_workspace_service`
|
||||
- `test_api_static_server`
|
||||
- `node --check src/web/app.js`
|
||||
|
||||
## 6. Phase B: archive 深度检索与任意对比
|
||||
|
||||
### 6.1 目标状态
|
||||
|
||||
从当前“latest-two compare + 基础 limit/offset”,升级到真正可用的 archive 工作流。
|
||||
|
||||
用户应能:
|
||||
|
||||
- 按 profile 检索
|
||||
- 按日期范围检索
|
||||
- 按 digest mode / history kind / workflow phase 检索
|
||||
- 从列表中任意选择两条 snapshot 做 compare
|
||||
- 查看 compare 差异摘要
|
||||
|
||||
### 6.2 交付范围
|
||||
|
||||
后端:
|
||||
|
||||
- digest archive query 扩展
|
||||
- workflow archive query 扩展
|
||||
- compare route 支持任意 snapshot id 对
|
||||
- 支持时间过滤与 profile 过滤
|
||||
|
||||
前端:
|
||||
|
||||
- archive filter toolbar
|
||||
- archive result list
|
||||
- dual-selection compare UI
|
||||
- clear filter / clear compare
|
||||
- compare summary card
|
||||
|
||||
### 6.3 建议切片
|
||||
|
||||
#### B1 query 扩展
|
||||
|
||||
- created_after / created_before
|
||||
- profile_key
|
||||
- workflow phase / artifact kind
|
||||
|
||||
#### B2 archive result browser
|
||||
|
||||
- 筛选器
|
||||
- 分页或“加载更多”
|
||||
- 当前检索上下文可见
|
||||
|
||||
#### B3 arbitrary compare
|
||||
|
||||
- 勾选两条 snapshot
|
||||
- compare route 取差异
|
||||
- compare summary / changed fields / count delta
|
||||
|
||||
#### B4 compare drill-down
|
||||
|
||||
- digest:title/body/reminder delta
|
||||
- workflow:phase brief/body delta
|
||||
|
||||
### 6.4 验收标准
|
||||
|
||||
- 可以不依赖“最新两条”,手动选任意两条 archive compare
|
||||
- 日期过滤可稳定返回结果
|
||||
- digest 和 workflow 两条线都能检索和对比
|
||||
|
||||
### 6.5 focused validation
|
||||
|
||||
- repository query tests
|
||||
- archive compare tests
|
||||
- API route tests
|
||||
- dashboard JS syntax
|
||||
|
||||
## 7. Phase C: candidate-scoped 连续工作区重算
|
||||
|
||||
### 7.1 目标状态
|
||||
|
||||
从当前“focus persistence + 局部联动”,升级到真正围绕一个 candidate 重算工作区。
|
||||
|
||||
当用户选中一个 focus candidate 后:
|
||||
|
||||
- Evidence 只展示该 candidate 相关证据链
|
||||
- Digest 只展示与该 candidate 强相关提醒
|
||||
- Replay 优先展示该 candidate 对应 setup/session 学习面
|
||||
- Notes 默认带入该 candidate 的结构化上下文
|
||||
|
||||
### 7.2 交付范围
|
||||
|
||||
后端:
|
||||
|
||||
- candidate-scoped evidence builder
|
||||
- candidate-scoped digest reminder builder
|
||||
- candidate-scoped replay learning rollup
|
||||
- focus-aware review entry defaults
|
||||
|
||||
前端:
|
||||
|
||||
- focus candidate 明确可见
|
||||
- clear focus / switch focus
|
||||
- 焦点切换后 section 结果一致刷新
|
||||
|
||||
### 7.3 建议切片
|
||||
|
||||
#### C1 focus-aware evidence chain
|
||||
|
||||
- evidence 过滤与重算
|
||||
- evidence source path 继续保持可解释
|
||||
|
||||
#### C2 focus-aware digest
|
||||
|
||||
- reminders 以当前 focus candidate 为第一优先级
|
||||
- 当前 focus 不相关提醒降级
|
||||
|
||||
#### C3 focus-aware replay
|
||||
|
||||
- setup / session / market state 统计优先围绕当前 focus
|
||||
|
||||
#### C4 focus-aware notes
|
||||
|
||||
- 新 note 默认带入:
|
||||
- candidate_id
|
||||
- session_code
|
||||
- model_code
|
||||
- focus title
|
||||
|
||||
### 7.4 验收标准
|
||||
|
||||
- 切换 focus candidate 后,Evidence / Digest / Replay / Notes 明显随之变化
|
||||
- focus 状态刷新页面后仍保留
|
||||
- “清除 focus” 后退回默认全局视角
|
||||
|
||||
### 7.5 focused validation
|
||||
|
||||
- `test_operator_workspace_service`
|
||||
- `test_api_server`
|
||||
- front-end interaction smoke
|
||||
- remote field smoke for focus-aware sections
|
||||
|
||||
## 8. Phase D: 外部上下文 provider 与视觉化增强
|
||||
|
||||
### 8.1 目标状态
|
||||
|
||||
从当前“有基础 provider 和 reference 卡片”,升级到更丰富的外部上下文层。
|
||||
|
||||
用户应能:
|
||||
|
||||
- 查看多个外部上下文源
|
||||
- 知道当前 source freshness / status / degrade state
|
||||
- 在需要时看到可视化的外部 context 证据
|
||||
|
||||
### 8.2 交付范围
|
||||
|
||||
后端:
|
||||
|
||||
- 多 provider external chart source 抽象
|
||||
- 宏观/新闻 provider 扩展
|
||||
- 交易所状态页 / 异常页 source 扩展
|
||||
- 外部 context visual evidence 元数据
|
||||
|
||||
前端:
|
||||
|
||||
- provider list
|
||||
- source state / freshness / fallback tag
|
||||
- chart reference card 增强
|
||||
- 可选 screenshot / embed 入口
|
||||
|
||||
### 8.3 建议切片
|
||||
|
||||
#### D1 multi-provider registry
|
||||
|
||||
- `binance_public_chart_reference` 之外增加 provider 抽象
|
||||
- provider selection / fallback order
|
||||
|
||||
#### D2 macro/news provider enrichment
|
||||
|
||||
- 丰富事件类型
|
||||
- provider freshness 统一摘要
|
||||
|
||||
#### D3 exchange anomaly surface enrichment
|
||||
|
||||
- 多交易所只读状态页
|
||||
- source cards 中统一展示
|
||||
|
||||
#### D4 visual evidence extension
|
||||
|
||||
- 外部 chart/image 仅作为只读可视证据
|
||||
- 不改变主分析链路
|
||||
|
||||
### 8.4 验收标准
|
||||
|
||||
- external context 至少支持多 provider registry
|
||||
- UI 能显示 provider 状态差异
|
||||
- screenshot / embed 失败不会阻塞主分析
|
||||
|
||||
### 8.5 focused validation
|
||||
|
||||
- provider tests
|
||||
- exchange status tests
|
||||
- workbench static smoke
|
||||
- remote route field smoke
|
||||
|
||||
## 9. Phase E: workflow archive 运营化能力
|
||||
|
||||
### 9.1 目标状态
|
||||
|
||||
从当前“能看 archive 条目”,升级到“可用于盘前/盘中/盘后运营回看”的 archive 视图。
|
||||
|
||||
用户应能:
|
||||
|
||||
- 按日历日回看 workflow
|
||||
- 按 session 回看 workflow
|
||||
- 查看某天完整 pre/intraday/post 流转
|
||||
- 对比多天 workflow drift
|
||||
|
||||
### 9.2 交付范围
|
||||
|
||||
后端:
|
||||
|
||||
- day/session grouped workflow archive query
|
||||
- workflow daily summary builder
|
||||
- workflow drift compare summary
|
||||
|
||||
前端:
|
||||
|
||||
- 按天浏览
|
||||
- 按 session 浏览
|
||||
- workflow timeline summary
|
||||
- multi-day compare card
|
||||
|
||||
### 9.3 建议切片
|
||||
|
||||
#### E1 grouped archive query
|
||||
|
||||
- day bucket
|
||||
- session bucket
|
||||
- item counts / status counts
|
||||
|
||||
#### E2 daily summary view
|
||||
|
||||
- 某天 pre/intraday/post 摘要串联
|
||||
|
||||
#### E3 drift compare
|
||||
|
||||
- 多日 artifact count / pending/update drift
|
||||
- 多日 body 变化摘要
|
||||
|
||||
#### E4 export / replay handoff
|
||||
|
||||
- 为后续复盘导出预留结构
|
||||
|
||||
### 9.4 验收标准
|
||||
|
||||
- 用户能按天看 workflow
|
||||
- 用户能看出多天之间 workflow 差异
|
||||
- archive 不再只是条目列表,而是运营回看面
|
||||
|
||||
### 9.5 focused validation
|
||||
|
||||
- workflow history service tests
|
||||
- API tests
|
||||
- front-end render tests
|
||||
- remote smoke for grouped workflow archive
|
||||
|
||||
## 10. 推荐排期
|
||||
|
||||
如果按最稳的开发节奏推进,建议分 5 轮:
|
||||
|
||||
### Round 1
|
||||
|
||||
- Phase A1-A4
|
||||
|
||||
### Round 2
|
||||
|
||||
- Phase B1-B4
|
||||
|
||||
### Round 3
|
||||
|
||||
- Phase C1-C4
|
||||
|
||||
### Round 4
|
||||
|
||||
- Phase D1-D4
|
||||
|
||||
### Round 5
|
||||
|
||||
- Phase E1-E4
|
||||
|
||||
## 11. 每轮统一闭环要求
|
||||
|
||||
每一轮都必须完成:
|
||||
|
||||
1. 锚点读取
|
||||
2. 最小闭环切片实现
|
||||
3. focused validation
|
||||
4. 必要质量门
|
||||
5. 必要时远端 deploy/smoke
|
||||
6. 回写 `SESSION.md / TASKS.md`
|
||||
|
||||
## 12. 最终结论
|
||||
|
||||
这 5 个方向不是新的大重构,而是围绕当前分析助手主线的连续增强计划。
|
||||
|
||||
推荐默认从 `独立偏好设置面` 开始,原因是:
|
||||
|
||||
- 对日常使用提升最大
|
||||
- 风险最低
|
||||
- 与后续 archive / focus 深化天然衔接
|
||||
- 不触碰延期项与实盘权限边界
|
||||
@@ -0,0 +1,173 @@
|
||||
# AI_ICT 需求文档剩余功能与延期项清单 2026-05-14 v1
|
||||
|
||||
## 1. 对照范围
|
||||
|
||||
本清单基于以下仍然有效的需求文档与当前代码状态整理:
|
||||
|
||||
1. `docs/AI_ICT_实盘交易分析决策助手下一阶段开发方案_v1.md`
|
||||
2. `docs/AI_ICT_实盘辅助决策系统开发方案_v2.md`
|
||||
3. `docs/AI_ICT_实盘交易分析系统开发计划_v1.md`
|
||||
4. `docs/ICT交易全书_出版终校版.md`
|
||||
5. `SESSION.md`
|
||||
6. `TASKS.md`
|
||||
|
||||
说明:
|
||||
- 本文只列“截至当前代码仍未完全实现”的内容。
|
||||
- 已完成并已闭环的能力不再重复列为待办。
|
||||
- 与真实交易、OKX live submit、默认自动下单、real live readiness 开闸相关内容,继续单列为延期项,不纳入当前分析助手主线 backlog。
|
||||
|
||||
## 2. 已完成且已闭环的主线能力
|
||||
|
||||
以下能力已经完成到可用闭环,不再视为“当前缺口”:
|
||||
|
||||
- Decision Assistant dense cockpit 主界面
|
||||
- unified watchlist / evidence / digest / replay / notes 连续工作区基础能力
|
||||
- digest 偏好持久化、profile 管理、独立 preferences section
|
||||
- digest archive / workflow archive 查询、load-more、任意双选 compare
|
||||
- pre-market / intraday / post-trade workflow rhythm 与 artifact persistence
|
||||
- candidate-scoped focus workspace 基础重算
|
||||
- replay learning / weekly trend / attribution rollup
|
||||
- external context provider inventory、exchange surface inventory、visual reference cards
|
||||
- workflow archive operations view、day/session grouping、multi-day drift review
|
||||
|
||||
结论:
|
||||
- 之前 Phase A-E 对应的 5 个增强方向已经全部闭环完成。
|
||||
- 这份文档现在需要承担的是“下一批 backlog 刷新”,而不是继续重复 A-E。
|
||||
|
||||
## 3. 当前仍未完全实现的功能
|
||||
|
||||
### 3.1 archive 全文检索与导出能力
|
||||
|
||||
当前状态:
|
||||
|
||||
- digest archive / workflow archive 已支持 profile、kind、mode、phase、date range、limit、offset。
|
||||
- 已支持任意两条 snapshot compare。
|
||||
- 已支持 UI 浏览、load-more、compare drill-down。
|
||||
- 已支持 `query_text` 关键词检索。
|
||||
- 已支持当前过滤结果与当前 compare 结果的 JSON export starter bundle。
|
||||
|
||||
仍缺:
|
||||
|
||||
- 跨 profile archive 检索
|
||||
- 更细颗粒度的结构化导出格式
|
||||
- 常用 archive 查询预设保存
|
||||
|
||||
结论:
|
||||
|
||||
- `archive 查询、compare、关键词检索与基础导出已完成`
|
||||
- `archive 深化检索与运营化导出能力未完成`
|
||||
|
||||
### 3.2 external context 视觉证据增强层
|
||||
|
||||
当前状态:
|
||||
|
||||
- 已有 macro/news provider inventory、chart provider registry、exchange context source inventory。
|
||||
- 已有 visual reference cards 和只读外部 chart reference。
|
||||
- 已有 provider health cards、provider health summary、visual evidence bridge summary。
|
||||
- cockpit 已支持 `Open Visual Bridge` / `Open Target` / `Open Embed` 只读打开动作。
|
||||
|
||||
仍缺:
|
||||
|
||||
- 外部 chart embed / screenshot capture / visual evidence 化
|
||||
- 更丰富的宏观 / 新闻 / 交易所异常 provider 扩展
|
||||
- external context 与 evidence chain 的更深联动
|
||||
|
||||
结论:
|
||||
|
||||
- `external context inventory、provider health、visual bridge starter 已完成`
|
||||
- `external context 的深层视觉证据增强层未完成`
|
||||
|
||||
### 3.3 workflow archive 回放与导出桥接
|
||||
|
||||
当前状态:
|
||||
|
||||
- workflow archive 已能持久化正文、浏览、compare、按 day/session group 汇总。
|
||||
- 已有 operations summary 和 multi-day drift review。
|
||||
- 已有 archive snapshot 到 `Setup Replay / Review Entry / Workflow` 的 bridge action。
|
||||
- workflow export bundle 已带基础 `bridge_summary / handoff_bundle_summary`。
|
||||
|
||||
仍缺:
|
||||
|
||||
- day/session 级 artifact bundle 导出
|
||||
- workflow archive 到 operator handoff packet 的快速生成
|
||||
- archive 对比结果回写为结构化复盘记录
|
||||
|
||||
结论:
|
||||
|
||||
- `workflow archive 运营化浏览层与 starter bridge 已完成`
|
||||
- `workflow archive 到 replay / handoff / export 的深层桥接未完成`
|
||||
|
||||
### 3.4 archive 运营化长期视图
|
||||
|
||||
当前状态:
|
||||
|
||||
- 当前 archive 视图已经覆盖近期 snapshot 浏览、day/session grouping、latest drift compare。
|
||||
- 已有 7d / 30d long-range trend starter summary。
|
||||
- 已有 baseline pin starter fields 与 compare prompt。
|
||||
|
||||
仍缺:
|
||||
|
||||
- baseline pin / compare baseline 的持久化保存
|
||||
- digest / workflow 的长期趋势联动视图深化
|
||||
- 面向运营回看的筛选、趋势、导出统一入口
|
||||
|
||||
结论:
|
||||
|
||||
- `短周期 archive 运维视图与长期趋势 starter 已完成`
|
||||
- `长期趋势深化与运营统一入口未完成`
|
||||
|
||||
## 4. 明确延期项
|
||||
|
||||
以下内容仍然属于延期项,不纳入当前分析助手主线:
|
||||
|
||||
### 4.1 真实交易执行
|
||||
|
||||
- OKX live submit
|
||||
- 默认真实自动下单
|
||||
- live order routing / bracket / OCO 正式投产
|
||||
- execution_allowed / real_live_ready 开闸
|
||||
|
||||
### 4.2 真实账户与实盘同步深化
|
||||
|
||||
- 更深的 broker read-only sync 扩展
|
||||
- 围绕真实 fill 的 reconciliation 运营闭环
|
||||
- 真实仓位驱动的半自动执行协同
|
||||
|
||||
### 4.3 live adapter 最终放量阶段
|
||||
|
||||
- real-live readiness 最终验收
|
||||
- operator confirmation protocol 的真实下单阶段验收
|
||||
|
||||
说明:
|
||||
- 这些不是“忘了做”,而是当前阶段明确延后。
|
||||
|
||||
## 5. 建议的下一批 backlog 顺序
|
||||
|
||||
如果继续围绕“分析助手”主线推进,建议顺序如下:
|
||||
|
||||
1. `archive 深化检索能力`
|
||||
2. `external context 深层视觉证据增强`
|
||||
3. `workflow archive 深层桥接`
|
||||
4. `archive 长期趋势深化`
|
||||
|
||||
## 6. 建议的下一刀最小闭环切片
|
||||
|
||||
推荐下一轮直接从这一刀开始:
|
||||
|
||||
`B6 archive deep retrieval / structured export follow-up starter slice`
|
||||
|
||||
建议范围:
|
||||
|
||||
- 为 digest / workflow archive 增加跨 profile 检索 follow-up
|
||||
- 补结构化 export format 与 compare/export schema 深化
|
||||
- 增加 archive 查询 preset starter fields
|
||||
- 保持只读,不改变交易权限与主分析决策边界
|
||||
- 完成 focused validation + 必要 deploy/smoke + 回写 `SESSION.md / TASKS.md`
|
||||
|
||||
## 7. 最终结论
|
||||
|
||||
截至当前代码状态:
|
||||
|
||||
- A-E 计划已经全部完成,不该再作为主 backlog 重复推进。
|
||||
- 当前真正未完成的,已经从“基础产品化”切换到“archive 深化、external evidence 强化、运营长期视图”这一级。
|
||||
- 真实交易、live adapter、下单权限相关内容继续保持延期,不应混回当前分析助手主线。
|
||||
@@ -256,9 +256,9 @@ PHASES = [
|
||||
"N4-2 仅做代码预留点",
|
||||
],
|
||||
"files": [
|
||||
"docs/AI_ICT_盯盘告警架构图_v1.md",
|
||||
"docs/AI_ICT_盯盘告警技术设计稿_v1.md",
|
||||
"docs/AI_ICT_盯盘告警开发任务清单_v1.md",
|
||||
"docs/archive/AI_ICT_盯盘告警架构图_v1.md",
|
||||
"docs/archive/AI_ICT_盯盘告警技术设计稿_v1.md",
|
||||
"docs/archive/AI_ICT_盯盘告警开发任务清单_v1.md",
|
||||
"src/services/decision/decision_packet_service.py",
|
||||
"src/services/runtime/trading_runtime_service.py",
|
||||
],
|
||||
@@ -1329,3 +1329,4 @@ def main(env=None) -> int:
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
|
||||
@@ -3366,7 +3366,7 @@ def render_session(completed_stages: list[str], current_stage: Optional[str], la
|
||||
" - `TASKS.md`",
|
||||
"- 当前执行协议说明保留在:",
|
||||
" - `docs/AI_ICT_全自动开发执行流程_v1.md`",
|
||||
"- `docs/AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md` 仅作为已完成冻结决策的历史治理参考,不再是每次启动必读锚点。",
|
||||
"- `docs/archive/AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md` 仅作为已完成冻结决策的历史治理参考,不再是每次启动必读锚点。",
|
||||
"- 默认不再等待用户中途确认",
|
||||
"- 每阶段必须完成:",
|
||||
" - 实现",
|
||||
@@ -3469,3 +3469,4 @@ def main() -> int:
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
|
||||
+307
-5
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
|
||||
from src.repositories.alert_delivery_repository import AlertDeliveryRepository
|
||||
from src.repositories.decision_assistant_repository import DecisionAssistantRepository
|
||||
from src.repositories.trade_plan_candidate_repository import TradePlanCandidateRepository
|
||||
@@ -9,6 +12,13 @@ from src.services.alerts import (
|
||||
AlertSilentWindowService,
|
||||
OperatorDigestService,
|
||||
)
|
||||
from src.services.operator.archive_long_range_projection import (
|
||||
build_digest_archive_baseline_summary,
|
||||
build_digest_archive_long_range_summary,
|
||||
build_workflow_archive_baseline_summary,
|
||||
build_workflow_archive_long_range_summary,
|
||||
)
|
||||
from src.services.operator.workflow_archive_projection import build_workflow_archive_operations_view
|
||||
|
||||
|
||||
def dispatch_pending_alerts(limit: int = 50) -> dict:
|
||||
@@ -79,11 +89,11 @@ def list_operator_digest_preference_profiles(instrument_id: int) -> dict:
|
||||
"updated_ts": row.get("updated_ts"),
|
||||
}
|
||||
)
|
||||
if not items:
|
||||
default = _default_preference_payload(
|
||||
instrument_id=instrument_id,
|
||||
profile_key=DecisionAssistantRepository.DEFAULT_PROFILE_KEY,
|
||||
)
|
||||
default = _default_preference_payload(
|
||||
instrument_id=instrument_id,
|
||||
profile_key=DecisionAssistantRepository.DEFAULT_PROFILE_KEY,
|
||||
)
|
||||
if not any(item.get("profile_key") == DecisionAssistantRepository.DEFAULT_PROFILE_KEY for item in items):
|
||||
items.append(
|
||||
{
|
||||
"profile_key": default.get("profile_key"),
|
||||
@@ -103,6 +113,141 @@ def list_operator_digest_preference_profiles(instrument_id: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def create_operator_digest_preference_profile(
|
||||
instrument_id: int,
|
||||
*,
|
||||
profile_key: str | None = None,
|
||||
profile_label: str | None = None,
|
||||
source_profile_key: str | None = None,
|
||||
digest_mode: str | None = None,
|
||||
preferred_history_kind: str | None = None,
|
||||
importance_threshold: int | None = None,
|
||||
payload: dict | None = None,
|
||||
) -> dict:
|
||||
repository = DecisionAssistantRepository()
|
||||
normalized_profile_key = _normalize_profile_key(profile_key=profile_key, profile_label=profile_label)
|
||||
if not normalized_profile_key:
|
||||
return {
|
||||
"status": "ignored",
|
||||
"reason": "invalid_profile_key",
|
||||
"instrument_id": instrument_id,
|
||||
"profile_key": profile_key,
|
||||
}
|
||||
if repository.fetch_preference(instrument_id=instrument_id, profile_key=normalized_profile_key):
|
||||
return {
|
||||
"status": "ignored",
|
||||
"reason": "profile_exists",
|
||||
"instrument_id": instrument_id,
|
||||
"profile_key": normalized_profile_key,
|
||||
}
|
||||
base = repository.fetch_preference(
|
||||
instrument_id=instrument_id,
|
||||
profile_key=source_profile_key or DecisionAssistantRepository.DEFAULT_PROFILE_KEY,
|
||||
) or _default_preference_payload(
|
||||
instrument_id=instrument_id,
|
||||
profile_key=source_profile_key or DecisionAssistantRepository.DEFAULT_PROFILE_KEY,
|
||||
)
|
||||
merged_payload = _normalize_preference_payload((base or {}).get("payload"))
|
||||
merged_payload.update(dict(payload or {}))
|
||||
merged_payload["profile_label"] = profile_label or merged_payload.get("profile_label") or normalized_profile_key
|
||||
saved = repository.upsert_preference(
|
||||
instrument_id=instrument_id,
|
||||
profile_key=normalized_profile_key,
|
||||
digest_mode=digest_mode or base.get("digest_mode") or "all_reminders",
|
||||
preferred_history_kind=preferred_history_kind if preferred_history_kind is not None else base.get("preferred_history_kind"),
|
||||
importance_threshold=int(importance_threshold or base.get("importance_threshold") or 70),
|
||||
payload=merged_payload,
|
||||
)
|
||||
saved["status"] = "created"
|
||||
saved["payload"] = _normalize_preference_payload(saved.get("payload"))
|
||||
return saved
|
||||
|
||||
|
||||
def rename_operator_digest_preference_profile(
|
||||
instrument_id: int,
|
||||
*,
|
||||
profile_key: str,
|
||||
new_profile_key: str | None = None,
|
||||
profile_label: str | None = None,
|
||||
) -> dict:
|
||||
repository = DecisionAssistantRepository()
|
||||
source = repository.fetch_preference(instrument_id=instrument_id, profile_key=profile_key)
|
||||
if not source:
|
||||
return {
|
||||
"status": "ignored",
|
||||
"reason": "profile_missing",
|
||||
"instrument_id": instrument_id,
|
||||
"profile_key": profile_key,
|
||||
}
|
||||
normalized_target_key = _normalize_profile_key(
|
||||
profile_key=new_profile_key or profile_key,
|
||||
profile_label=profile_label,
|
||||
)
|
||||
if not normalized_target_key:
|
||||
return {
|
||||
"status": "ignored",
|
||||
"reason": "invalid_profile_key",
|
||||
"instrument_id": instrument_id,
|
||||
"profile_key": profile_key,
|
||||
}
|
||||
if (
|
||||
profile_key == DecisionAssistantRepository.DEFAULT_PROFILE_KEY
|
||||
and normalized_target_key != DecisionAssistantRepository.DEFAULT_PROFILE_KEY
|
||||
):
|
||||
return {
|
||||
"status": "ignored",
|
||||
"reason": "default_profile_protected",
|
||||
"instrument_id": instrument_id,
|
||||
"profile_key": profile_key,
|
||||
}
|
||||
if normalized_target_key != profile_key and repository.fetch_preference(instrument_id=instrument_id, profile_key=normalized_target_key):
|
||||
return {
|
||||
"status": "ignored",
|
||||
"reason": "profile_exists",
|
||||
"instrument_id": instrument_id,
|
||||
"profile_key": normalized_target_key,
|
||||
}
|
||||
merged_payload = _normalize_preference_payload(source.get("payload"))
|
||||
if profile_label:
|
||||
merged_payload["profile_label"] = profile_label
|
||||
saved = repository.upsert_preference(
|
||||
instrument_id=instrument_id,
|
||||
profile_key=normalized_target_key,
|
||||
digest_mode=source.get("digest_mode") or "all_reminders",
|
||||
preferred_history_kind=source.get("preferred_history_kind"),
|
||||
importance_threshold=int(source.get("importance_threshold") or 70),
|
||||
payload=merged_payload,
|
||||
)
|
||||
if normalized_target_key != profile_key:
|
||||
repository.delete_preference(instrument_id=instrument_id, profile_key=profile_key)
|
||||
saved["status"] = "renamed"
|
||||
saved["previous_profile_key"] = profile_key
|
||||
saved["payload"] = _normalize_preference_payload(saved.get("payload"))
|
||||
return saved
|
||||
|
||||
|
||||
def delete_operator_digest_preference_profile(
|
||||
instrument_id: int,
|
||||
*,
|
||||
profile_key: str,
|
||||
) -> dict:
|
||||
if profile_key == DecisionAssistantRepository.DEFAULT_PROFILE_KEY:
|
||||
return {
|
||||
"status": "ignored",
|
||||
"reason": "default_profile_protected",
|
||||
"instrument_id": instrument_id,
|
||||
"profile_key": profile_key,
|
||||
}
|
||||
deleted = DecisionAssistantRepository().delete_preference(instrument_id=instrument_id, profile_key=profile_key)
|
||||
return {
|
||||
"status": "deleted" if deleted else "ignored",
|
||||
"reason": None if deleted else "profile_missing",
|
||||
"instrument_id": instrument_id,
|
||||
"profile_key": profile_key,
|
||||
"deleted_count": deleted,
|
||||
}
|
||||
|
||||
|
||||
def save_operator_digest_preferences(
|
||||
instrument_id: int,
|
||||
*,
|
||||
@@ -212,6 +357,7 @@ def get_operator_digest_history_archive(
|
||||
offset: int = 0,
|
||||
preferred_history_kind: str | None = None,
|
||||
digest_mode: str | None = None,
|
||||
query_text: str | None = None,
|
||||
created_after=None,
|
||||
created_before=None,
|
||||
) -> dict:
|
||||
@@ -223,10 +369,13 @@ def get_operator_digest_history_archive(
|
||||
offset=offset,
|
||||
preferred_history_kind=preferred_history_kind,
|
||||
digest_mode=digest_mode,
|
||||
query_text=query_text,
|
||||
created_after=created_after,
|
||||
created_before=created_before,
|
||||
)
|
||||
items = [_normalize_digest_history_row(row) for row in rows]
|
||||
long_range_summary = build_digest_archive_long_range_summary(items)
|
||||
baseline_summary = build_digest_archive_baseline_summary(items)
|
||||
return {
|
||||
"status": "active" if items else "idle",
|
||||
"instrument_id": instrument_id,
|
||||
@@ -239,9 +388,12 @@ def get_operator_digest_history_archive(
|
||||
"filters": {
|
||||
"preferred_history_kind": preferred_history_kind,
|
||||
"digest_mode": digest_mode,
|
||||
"query_text": query_text,
|
||||
"created_after": created_after,
|
||||
"created_before": created_before,
|
||||
},
|
||||
"long_range_trend_summary": long_range_summary,
|
||||
"baseline_pin_summary": baseline_summary,
|
||||
}
|
||||
|
||||
|
||||
@@ -253,6 +405,7 @@ def compare_operator_digest_history_archive(
|
||||
right_id: int | None = None,
|
||||
preferred_history_kind: str | None = None,
|
||||
digest_mode: str | None = None,
|
||||
query_text: str | None = None,
|
||||
) -> dict:
|
||||
repository = DecisionAssistantRepository()
|
||||
if left_id and right_id:
|
||||
@@ -268,11 +421,65 @@ def compare_operator_digest_history_archive(
|
||||
limit=2,
|
||||
preferred_history_kind=preferred_history_kind,
|
||||
digest_mode=digest_mode,
|
||||
query_text=query_text,
|
||||
)
|
||||
items = [_normalize_digest_history_row(row) for row in rows]
|
||||
return _build_digest_archive_compare(items)
|
||||
|
||||
|
||||
def export_operator_digest_history_archive(
|
||||
instrument_id: int,
|
||||
*,
|
||||
profile_key: str = DecisionAssistantRepository.DEFAULT_PROFILE_KEY,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
preferred_history_kind: str | None = None,
|
||||
digest_mode: str | None = None,
|
||||
query_text: str | None = None,
|
||||
created_after=None,
|
||||
created_before=None,
|
||||
left_id: int | None = None,
|
||||
right_id: int | None = None,
|
||||
) -> dict:
|
||||
archive = get_operator_digest_history_archive(
|
||||
instrument_id=instrument_id,
|
||||
profile_key=profile_key,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
preferred_history_kind=preferred_history_kind,
|
||||
digest_mode=digest_mode,
|
||||
query_text=query_text,
|
||||
created_after=created_after,
|
||||
created_before=created_before,
|
||||
)
|
||||
compare = None
|
||||
if left_id and right_id:
|
||||
compare = compare_operator_digest_history_archive(
|
||||
instrument_id=instrument_id,
|
||||
profile_key=profile_key,
|
||||
left_id=left_id,
|
||||
right_id=right_id,
|
||||
preferred_history_kind=preferred_history_kind,
|
||||
digest_mode=digest_mode,
|
||||
query_text=query_text,
|
||||
)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
return {
|
||||
"status": archive.get("status") or "idle",
|
||||
"export_kind": "digest_archive_bundle",
|
||||
"generated_ts": datetime.now(timezone.utc).isoformat(),
|
||||
"export_name": f"digest_archive_{profile_key}_{timestamp}.json",
|
||||
"instrument_id": instrument_id,
|
||||
"profile_key": profile_key,
|
||||
"filters": dict(archive.get("filters") or {}),
|
||||
"item_count": archive.get("item_count") or 0,
|
||||
"items": list(archive.get("items") or []),
|
||||
"long_range_trend_summary": dict(archive.get("long_range_trend_summary") or {}),
|
||||
"baseline_pin_summary": dict(archive.get("baseline_pin_summary") or {}),
|
||||
"compare": compare,
|
||||
}
|
||||
|
||||
|
||||
def get_operator_workflow_archive(
|
||||
instrument_id: int,
|
||||
*,
|
||||
@@ -282,6 +489,7 @@ def get_operator_workflow_archive(
|
||||
current_session: str | None = None,
|
||||
next_phase: str | None = None,
|
||||
next_artifact_kind: str | None = None,
|
||||
query_text: str | None = None,
|
||||
created_after=None,
|
||||
created_before=None,
|
||||
) -> dict:
|
||||
@@ -294,10 +502,14 @@ def get_operator_workflow_archive(
|
||||
current_session=current_session,
|
||||
next_phase=next_phase,
|
||||
next_artifact_kind=next_artifact_kind,
|
||||
query_text=query_text,
|
||||
created_after=created_after,
|
||||
created_before=created_before,
|
||||
)
|
||||
items = [_normalize_workflow_history_row(row) for row in rows]
|
||||
operations_view = build_workflow_archive_operations_view(items)
|
||||
long_range_summary = build_workflow_archive_long_range_summary(items)
|
||||
baseline_summary = build_workflow_archive_baseline_summary(items)
|
||||
return {
|
||||
"status": "active" if items else "idle",
|
||||
"instrument_id": instrument_id,
|
||||
@@ -311,9 +523,13 @@ def get_operator_workflow_archive(
|
||||
"current_session": current_session,
|
||||
"next_phase": next_phase,
|
||||
"next_artifact_kind": next_artifact_kind,
|
||||
"query_text": query_text,
|
||||
"created_after": created_after,
|
||||
"created_before": created_before,
|
||||
},
|
||||
"long_range_trend_summary": long_range_summary,
|
||||
"baseline_pin_summary": baseline_summary,
|
||||
**operations_view,
|
||||
}
|
||||
|
||||
|
||||
@@ -325,6 +541,7 @@ def compare_operator_workflow_archive(
|
||||
right_id: int | None = None,
|
||||
current_session: str | None = None,
|
||||
next_phase: str | None = None,
|
||||
query_text: str | None = None,
|
||||
) -> dict:
|
||||
repository = DecisionAssistantRepository()
|
||||
if left_id and right_id:
|
||||
@@ -340,11 +557,69 @@ def compare_operator_workflow_archive(
|
||||
limit=2,
|
||||
current_session=current_session,
|
||||
next_phase=next_phase,
|
||||
query_text=query_text,
|
||||
)
|
||||
items = [_normalize_workflow_history_row(row) for row in rows]
|
||||
return _build_workflow_archive_compare(items)
|
||||
|
||||
|
||||
def export_operator_workflow_archive(
|
||||
instrument_id: int,
|
||||
*,
|
||||
profile_key: str = DecisionAssistantRepository.DEFAULT_PROFILE_KEY,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
current_session: str | None = None,
|
||||
next_phase: str | None = None,
|
||||
next_artifact_kind: str | None = None,
|
||||
query_text: str | None = None,
|
||||
created_after=None,
|
||||
created_before=None,
|
||||
left_id: int | None = None,
|
||||
right_id: int | None = None,
|
||||
) -> dict:
|
||||
archive = get_operator_workflow_archive(
|
||||
instrument_id=instrument_id,
|
||||
profile_key=profile_key,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
current_session=current_session,
|
||||
next_phase=next_phase,
|
||||
next_artifact_kind=next_artifact_kind,
|
||||
query_text=query_text,
|
||||
created_after=created_after,
|
||||
created_before=created_before,
|
||||
)
|
||||
compare = None
|
||||
if left_id and right_id:
|
||||
compare = compare_operator_workflow_archive(
|
||||
instrument_id=instrument_id,
|
||||
profile_key=profile_key,
|
||||
left_id=left_id,
|
||||
right_id=right_id,
|
||||
current_session=current_session,
|
||||
next_phase=next_phase,
|
||||
query_text=query_text,
|
||||
)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
return {
|
||||
"status": archive.get("status") or "idle",
|
||||
"export_kind": "workflow_archive_bundle",
|
||||
"generated_ts": datetime.now(timezone.utc).isoformat(),
|
||||
"export_name": f"workflow_archive_{profile_key}_{timestamp}.json",
|
||||
"instrument_id": instrument_id,
|
||||
"profile_key": profile_key,
|
||||
"filters": dict(archive.get("filters") or {}),
|
||||
"item_count": archive.get("item_count") or 0,
|
||||
"items": list(archive.get("items") or []),
|
||||
"long_range_trend_summary": dict(archive.get("long_range_trend_summary") or {}),
|
||||
"baseline_pin_summary": dict(archive.get("baseline_pin_summary") or {}),
|
||||
"bridge_summary": dict(archive.get("bridge_summary") or {}),
|
||||
"handoff_bundle_summary": _build_workflow_archive_handoff_bundle_summary(archive, compare),
|
||||
"compare": compare,
|
||||
}
|
||||
|
||||
|
||||
def get_alert_silent_window_status() -> dict:
|
||||
return AlertSilentWindowService().build_status()
|
||||
|
||||
@@ -372,6 +647,14 @@ def _normalize_preference_payload(payload) -> dict:
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_profile_key(*, profile_key: str | None, profile_label: str | None = None) -> str | None:
|
||||
seed = str(profile_key or profile_label or "").strip().lower()
|
||||
normalized = re.sub(r"[^a-z0-9]+", "_", seed).strip("_")
|
||||
if not normalized:
|
||||
return None
|
||||
return normalized[:64]
|
||||
|
||||
|
||||
def _default_preference_payload(*, instrument_id: int, profile_key: str) -> dict:
|
||||
return {
|
||||
"profile_key": profile_key,
|
||||
@@ -506,3 +789,22 @@ def _build_workflow_archive_compare(items: list[dict]) -> dict:
|
||||
"count_delta": count_delta,
|
||||
"summary": f"Workflow archive compare shows {changed_count} changed phase brief(s) across the latest two snapshots.",
|
||||
}
|
||||
|
||||
|
||||
def _build_workflow_archive_handoff_bundle_summary(archive: dict, compare: dict | None) -> dict:
|
||||
bridge_summary = dict(archive.get("bridge_summary") or {})
|
||||
items = list(archive.get("items") or [])
|
||||
latest = dict(items[0] or {})
|
||||
compare_status = dict(compare or {}).get("status")
|
||||
return {
|
||||
"status": "active" if items else "idle",
|
||||
"latest_snapshot_id": latest.get("snapshot_id"),
|
||||
"latest_session": latest.get("current_session"),
|
||||
"latest_next_phase": latest.get("next_phase"),
|
||||
"latest_artifact_kind": latest.get("next_artifact_kind"),
|
||||
"compare_status": compare_status or "idle",
|
||||
"bridge_target_count": bridge_summary.get("bridge_target_count") or 0,
|
||||
"next_handoff_prompt": bridge_summary.get("next_handoff_prompt"),
|
||||
"next_replay_prompt": bridge_summary.get("next_replay_prompt"),
|
||||
"next_note_prompt": bridge_summary.get("next_note_prompt"),
|
||||
}
|
||||
|
||||
+85
-1
@@ -13,8 +13,12 @@ from src.api.harness import run_unattended_harness
|
||||
from src.api.live_operation_audits import get_recent_live_operation_audits
|
||||
from src.api.maintenance import get_maintenance_summary
|
||||
from src.api.alerts import (
|
||||
create_operator_digest_preference_profile,
|
||||
compare_operator_digest_history_archive,
|
||||
compare_operator_workflow_archive,
|
||||
delete_operator_digest_preference_profile,
|
||||
export_operator_digest_history_archive,
|
||||
export_operator_workflow_archive,
|
||||
get_alert_deliveries,
|
||||
get_operator_digest_history_archive,
|
||||
get_alert_quality_summary,
|
||||
@@ -24,6 +28,7 @@ from src.api.alerts import (
|
||||
get_operator_focus_state,
|
||||
get_operator_workflow_archive,
|
||||
list_operator_digest_preference_profiles,
|
||||
rename_operator_digest_preference_profile,
|
||||
save_operator_focus_state,
|
||||
save_operator_digest_preferences,
|
||||
)
|
||||
@@ -135,11 +140,16 @@ def build_api_dependencies() -> dict:
|
||||
"trading_alert_operator_digest": get_operator_digest,
|
||||
"trading_alert_operator_digest_preferences": get_operator_digest_preferences,
|
||||
"trading_alert_operator_digest_profiles": list_operator_digest_preference_profiles,
|
||||
"trading_alert_operator_digest_profiles_create": create_operator_digest_preference_profile,
|
||||
"trading_alert_operator_digest_profiles_rename": rename_operator_digest_preference_profile,
|
||||
"trading_alert_operator_digest_profiles_delete": delete_operator_digest_preference_profile,
|
||||
"trading_alert_operator_digest_preferences_save": save_operator_digest_preferences,
|
||||
"trading_alert_operator_digest_history_archive": get_operator_digest_history_archive,
|
||||
"trading_alert_operator_digest_history_compare": compare_operator_digest_history_archive,
|
||||
"trading_alert_operator_digest_history_export": export_operator_digest_history_archive,
|
||||
"trading_workflow_archive": get_operator_workflow_archive,
|
||||
"trading_workflow_archive_compare": compare_operator_workflow_archive,
|
||||
"trading_workflow_archive_export": export_operator_workflow_archive,
|
||||
"trading_assistant_focus": get_operator_focus_state,
|
||||
"trading_assistant_focus_save": save_operator_focus_state,
|
||||
"trading_alert_silent_window_status": get_alert_silent_window_status,
|
||||
@@ -334,6 +344,42 @@ def route_request(
|
||||
if method == "GET" and path == "/trading/alerts/digest-preferences/profiles":
|
||||
instrument_id = _require_int(query, "instrument_id")
|
||||
return 200, deps["trading_alert_operator_digest_profiles"](instrument_id=instrument_id)
|
||||
if method == "POST" and path == "/trading/alerts/digest-preferences/profiles":
|
||||
instrument_id = _require_json_int(body or {}, "instrument_id")
|
||||
return 200, deps["trading_alert_operator_digest_profiles_create"](
|
||||
instrument_id=instrument_id,
|
||||
profile_key=_get_body_value(body, "profile_key"),
|
||||
profile_label=_get_body_value(body, "profile_label"),
|
||||
source_profile_key=_get_body_value(body, "source_profile_key"),
|
||||
digest_mode=_get_body_value(body, "digest_mode"),
|
||||
preferred_history_kind=_get_body_value(body, "preferred_history_kind"),
|
||||
importance_threshold=_get_body_optional_int(body, "importance_threshold"),
|
||||
payload=body.get("payload") if isinstance(body, dict) else None,
|
||||
)
|
||||
if (
|
||||
method == "POST"
|
||||
and len(path_parts) == 6
|
||||
and path_parts[:4] == ["trading", "alerts", "digest-preferences", "profiles"]
|
||||
and path_parts[5] == "rename"
|
||||
):
|
||||
instrument_id = _require_json_int(body or {}, "instrument_id")
|
||||
return 200, deps["trading_alert_operator_digest_profiles_rename"](
|
||||
instrument_id=instrument_id,
|
||||
profile_key=path_parts[4],
|
||||
new_profile_key=_get_body_value(body, "new_profile_key"),
|
||||
profile_label=_get_body_value(body, "profile_label"),
|
||||
)
|
||||
if (
|
||||
method == "POST"
|
||||
and len(path_parts) == 6
|
||||
and path_parts[:4] == ["trading", "alerts", "digest-preferences", "profiles"]
|
||||
and path_parts[5] == "delete"
|
||||
):
|
||||
instrument_id = _require_json_int(body or {}, "instrument_id")
|
||||
return 200, deps["trading_alert_operator_digest_profiles_delete"](
|
||||
instrument_id=instrument_id,
|
||||
profile_key=path_parts[4],
|
||||
)
|
||||
if method == "POST" and path == "/trading/alerts/digest-preferences":
|
||||
instrument_id = _require_json_int(body or {}, "instrument_id")
|
||||
return 200, deps["trading_alert_operator_digest_preferences_save"](
|
||||
@@ -353,9 +399,25 @@ def route_request(
|
||||
offset=_get_int(query, "offset", 0),
|
||||
preferred_history_kind=_get_single(query, "preferred_history_kind"),
|
||||
digest_mode=_get_single(query, "digest_mode"),
|
||||
query_text=_get_single(query, "query_text"),
|
||||
created_after=_get_single(query, "created_after"),
|
||||
created_before=_get_single(query, "created_before"),
|
||||
)
|
||||
if method == "GET" and path == "/trading/alerts/digest-history-archive/export":
|
||||
instrument_id = _require_int(query, "instrument_id")
|
||||
return 200, deps["trading_alert_operator_digest_history_export"](
|
||||
instrument_id=instrument_id,
|
||||
profile_key=_get_single(query, "profile_key", "local_operator"),
|
||||
limit=_get_int(query, "limit", 20),
|
||||
offset=_get_int(query, "offset", 0),
|
||||
preferred_history_kind=_get_single(query, "preferred_history_kind"),
|
||||
digest_mode=_get_single(query, "digest_mode"),
|
||||
query_text=_get_single(query, "query_text"),
|
||||
created_after=_get_single(query, "created_after"),
|
||||
created_before=_get_single(query, "created_before"),
|
||||
left_id=_get_optional_int(query, "left_id"),
|
||||
right_id=_get_optional_int(query, "right_id"),
|
||||
)
|
||||
if method == "GET" and path == "/trading/alerts/digest-history-archive/compare":
|
||||
instrument_id = _require_int(query, "instrument_id")
|
||||
return 200, deps["trading_alert_operator_digest_history_compare"](
|
||||
@@ -365,6 +427,7 @@ def route_request(
|
||||
right_id=_get_optional_int(query, "right_id"),
|
||||
preferred_history_kind=_get_single(query, "preferred_history_kind"),
|
||||
digest_mode=_get_single(query, "digest_mode"),
|
||||
query_text=_get_single(query, "query_text"),
|
||||
)
|
||||
if method == "GET" and path == "/trading/workflow-archive":
|
||||
instrument_id = _require_int(query, "instrument_id")
|
||||
@@ -376,9 +439,26 @@ def route_request(
|
||||
current_session=_get_single(query, "current_session"),
|
||||
next_phase=_get_single(query, "next_phase"),
|
||||
next_artifact_kind=_get_single(query, "next_artifact_kind"),
|
||||
query_text=_get_single(query, "query_text"),
|
||||
created_after=_get_single(query, "created_after"),
|
||||
created_before=_get_single(query, "created_before"),
|
||||
)
|
||||
if method == "GET" and path == "/trading/workflow-archive/export":
|
||||
instrument_id = _require_int(query, "instrument_id")
|
||||
return 200, deps["trading_workflow_archive_export"](
|
||||
instrument_id=instrument_id,
|
||||
profile_key=_get_single(query, "profile_key", "local_operator"),
|
||||
limit=_get_int(query, "limit", 20),
|
||||
offset=_get_int(query, "offset", 0),
|
||||
current_session=_get_single(query, "current_session"),
|
||||
next_phase=_get_single(query, "next_phase"),
|
||||
next_artifact_kind=_get_single(query, "next_artifact_kind"),
|
||||
query_text=_get_single(query, "query_text"),
|
||||
created_after=_get_single(query, "created_after"),
|
||||
created_before=_get_single(query, "created_before"),
|
||||
left_id=_get_optional_int(query, "left_id"),
|
||||
right_id=_get_optional_int(query, "right_id"),
|
||||
)
|
||||
if method == "GET" and path == "/trading/workflow-archive/compare":
|
||||
instrument_id = _require_int(query, "instrument_id")
|
||||
return 200, deps["trading_workflow_archive_compare"](
|
||||
@@ -388,6 +468,7 @@ def route_request(
|
||||
right_id=_get_optional_int(query, "right_id"),
|
||||
current_session=_get_single(query, "current_session"),
|
||||
next_phase=_get_single(query, "next_phase"),
|
||||
query_text=_get_single(query, "query_text"),
|
||||
)
|
||||
if method == "GET" and path == "/trading/assistant-focus":
|
||||
instrument_id = _require_int(query, "instrument_id")
|
||||
@@ -453,7 +534,10 @@ def route_request(
|
||||
)
|
||||
if method == "GET" and path == "/trading/review-packet":
|
||||
instrument_id = _require_int(query, "instrument_id")
|
||||
return 200, deps["trading_review_packet"](instrument_id=instrument_id)
|
||||
return 200, deps["trading_review_packet"](
|
||||
instrument_id=instrument_id,
|
||||
profile_key=_get_single(query, "profile_key", "local_operator"),
|
||||
)
|
||||
if method == "GET" and path == "/trading/research-council/latest":
|
||||
instrument_id = _require_int(query, "instrument_id")
|
||||
payload = deps["trading_research_council_latest"](instrument_id=instrument_id)
|
||||
|
||||
@@ -456,8 +456,12 @@ def create_trading_operator_note(
|
||||
)
|
||||
|
||||
|
||||
def get_trading_review_packet(instrument_id: int) -> dict:
|
||||
return ReviewPacketService().build_packet(instrument_id=instrument_id)
|
||||
def get_trading_review_packet(
|
||||
instrument_id: int,
|
||||
*,
|
||||
profile_key: str = "local_operator",
|
||||
) -> dict:
|
||||
return ReviewPacketService().build_packet(instrument_id=instrument_id, profile_key=profile_key)
|
||||
|
||||
|
||||
def build_live_broker_service() -> LiveBrokerService:
|
||||
|
||||
@@ -100,6 +100,24 @@ class DecisionAssistantRepository:
|
||||
).mappings().one()
|
||||
return dict(row)
|
||||
|
||||
def delete_preference(self, *, instrument_id: int, profile_key: str = DEFAULT_PROFILE_KEY) -> int:
|
||||
sql = text(
|
||||
"""
|
||||
delete from decision_assistant_preferences
|
||||
where instrument_id = :instrument_id
|
||||
and profile_key = :profile_key
|
||||
"""
|
||||
)
|
||||
with SessionLocal.begin() as session:
|
||||
result = session.execute(
|
||||
sql,
|
||||
{
|
||||
"instrument_id": instrument_id,
|
||||
"profile_key": profile_key,
|
||||
},
|
||||
)
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
def fetch_recent_digest_history(
|
||||
self,
|
||||
*,
|
||||
@@ -109,6 +127,7 @@ class DecisionAssistantRepository:
|
||||
offset: int = 0,
|
||||
digest_mode: str | None = None,
|
||||
preferred_history_kind: str | None = None,
|
||||
query_text: str | None = None,
|
||||
created_after=None,
|
||||
created_before=None,
|
||||
) -> list[dict]:
|
||||
@@ -128,6 +147,16 @@ class DecisionAssistantRepository:
|
||||
if preferred_history_kind:
|
||||
filters.append("preferred_history_kind = :preferred_history_kind")
|
||||
params["preferred_history_kind"] = preferred_history_kind
|
||||
if query_text:
|
||||
filters.append(
|
||||
"""
|
||||
(
|
||||
coalesce(top_summary, '') ilike :query_text_like
|
||||
or cast(payload as text) ilike :query_text_like
|
||||
)
|
||||
"""
|
||||
)
|
||||
params["query_text_like"] = f"%{query_text.strip()}%"
|
||||
if created_after is not None:
|
||||
filters.append("created_ts >= :created_after")
|
||||
params["created_after"] = created_after
|
||||
@@ -244,6 +273,7 @@ class DecisionAssistantRepository:
|
||||
current_session: str | None = None,
|
||||
next_phase: str | None = None,
|
||||
next_artifact_kind: str | None = None,
|
||||
query_text: str | None = None,
|
||||
created_after=None,
|
||||
created_before=None,
|
||||
) -> list[dict]:
|
||||
@@ -266,6 +296,18 @@ class DecisionAssistantRepository:
|
||||
if next_artifact_kind:
|
||||
filters.append("next_artifact_kind = :next_artifact_kind")
|
||||
params["next_artifact_kind"] = next_artifact_kind
|
||||
if query_text:
|
||||
filters.append(
|
||||
"""
|
||||
(
|
||||
coalesce(current_session, '') ilike :query_text_like
|
||||
or coalesce(next_phase, '') ilike :query_text_like
|
||||
or coalesce(next_artifact_kind, '') ilike :query_text_like
|
||||
or cast(payload as text) ilike :query_text_like
|
||||
)
|
||||
"""
|
||||
)
|
||||
params["query_text_like"] = f"%{query_text.strip()}%"
|
||||
if created_after is not None:
|
||||
filters.append("created_ts >= :created_after")
|
||||
params["created_after"] = created_after
|
||||
|
||||
@@ -264,7 +264,7 @@ def _normalize_announcement_item(row: dict) -> dict:
|
||||
|
||||
|
||||
def _fetch_okx_json(*, url: str, timeout: int, proxy_url: str, headers: dict, payload_error: str) -> dict:
|
||||
attempt_timeout = max(1, min(int(timeout or 1), 4))
|
||||
attempt_timeout = max(1, min(int(timeout or 1), 2))
|
||||
attempts = []
|
||||
if proxy_url:
|
||||
attempts.append({"timeout": attempt_timeout, "follow_redirects": True, "proxy": proxy_url, "trust_env": False})
|
||||
|
||||
@@ -100,7 +100,7 @@ class BinanceChartReferenceProvider:
|
||||
return str((instrument or {}).get("symbol") or "").strip() or None
|
||||
|
||||
def _fetch_payload(self, *, api_symbol: str, interval: str) -> list:
|
||||
attempt_timeout = max(1, min(int(self.timeout or 1), 4))
|
||||
attempt_timeout = max(1, min(int(self.timeout or 1), 2))
|
||||
attempts = []
|
||||
if self.proxy_url:
|
||||
attempts.append({"timeout": attempt_timeout, "follow_redirects": True, "proxy": self.proxy_url, "trust_env": False})
|
||||
|
||||
@@ -155,14 +155,17 @@ class MarketStateService:
|
||||
max_candle_age_seconds: int = DEFAULT_MAX_CANDLE_AGE_SECONDS,
|
||||
news_windows: Optional[list[dict]] = None,
|
||||
opening_volatility_minutes: int = 0,
|
||||
cached_news_only: bool = False,
|
||||
) -> MarketState:
|
||||
observed_ts = _ensure_aware(now or datetime.now(timezone.utc))
|
||||
runtime_state = None
|
||||
if self.market_truth_supervisor is not None:
|
||||
runtime_state = self.market_truth_supervisor.current_state(
|
||||
runtime_state = _current_runtime_state(
|
||||
supervisor=self.market_truth_supervisor,
|
||||
instrument_id=instrument_id,
|
||||
timeframe=timeframe,
|
||||
now=observed_ts,
|
||||
cached_news_only=cached_news_only,
|
||||
)
|
||||
|
||||
latest_candle = self.repository.fetch_latest_candle(instrument_id=instrument_id, timeframe=timeframe)
|
||||
@@ -441,3 +444,19 @@ def _timeframe_to_seconds(timeframe: str) -> int:
|
||||
if text.endswith("d"):
|
||||
return int(text[:-1]) * 24 * 60 * 60
|
||||
raise ValueError(f"unsupported_timeframe:{timeframe}")
|
||||
|
||||
|
||||
def _current_runtime_state(*, supervisor, instrument_id: int, timeframe: str, now: datetime, cached_news_only: bool):
|
||||
try:
|
||||
return supervisor.current_state(
|
||||
instrument_id=instrument_id,
|
||||
timeframe=timeframe,
|
||||
now=now,
|
||||
cached_news_only=cached_news_only,
|
||||
)
|
||||
except TypeError:
|
||||
return supervisor.current_state(
|
||||
instrument_id=instrument_id,
|
||||
timeframe=timeframe,
|
||||
now=now,
|
||||
)
|
||||
|
||||
@@ -108,7 +108,14 @@ class MarketTruthSupervisor:
|
||||
if self.enabled:
|
||||
self.start()
|
||||
|
||||
def current_state(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState:
|
||||
def current_state(
|
||||
self,
|
||||
*,
|
||||
instrument_id: int,
|
||||
timeframe: str,
|
||||
now: Optional[datetime] = None,
|
||||
cached_news_only: bool = False,
|
||||
) -> MarketTruthRuntimeState:
|
||||
now = _ensure_aware(now or datetime.now(timezone.utc))
|
||||
self.ensure_subscription(instrument_id=instrument_id, timeframe=timeframe)
|
||||
latest_candle = self.repository.fetch_latest_candle(instrument_id=instrument_id, timeframe=timeframe)
|
||||
@@ -126,7 +133,11 @@ class MarketTruthSupervisor:
|
||||
now=now,
|
||||
gap_multiplier=self.gap_multiplier,
|
||||
)
|
||||
news_snapshot = self._get_news_snapshot(now=now, force_refresh=True)
|
||||
news_snapshot = self._get_news_snapshot(
|
||||
now=now,
|
||||
force_refresh=not cached_news_only,
|
||||
cached_only=cached_news_only,
|
||||
)
|
||||
if self._should_fallback(stream_state=stream_state, data_gap_state=data_gap_state) and self._fallback_allowed(
|
||||
instrument_id=instrument_id,
|
||||
timeframe=timeframe,
|
||||
@@ -526,9 +537,21 @@ class MarketTruthSupervisor:
|
||||
return True
|
||||
return (now - last_attempt).total_seconds() >= max(self.fallback_cooldown_seconds, 1)
|
||||
|
||||
def _get_news_snapshot(self, *, now: datetime, force_refresh: bool = False) -> NewsProviderSnapshot:
|
||||
def _get_news_snapshot(
|
||||
self,
|
||||
*,
|
||||
now: datetime,
|
||||
force_refresh: bool = False,
|
||||
cached_only: bool = False,
|
||||
) -> NewsProviderSnapshot:
|
||||
if not settings.news_provider_enabled:
|
||||
return self.news_provider.evaluate_cached_snapshot(snapshot=None, now=now)
|
||||
if cached_only:
|
||||
snapshot = self._news_snapshot
|
||||
if snapshot is None:
|
||||
snapshot = build_snapshot_from_row(self.repository.fetch_latest_news_provider_snapshot())
|
||||
self._news_snapshot = snapshot
|
||||
return self.news_provider.evaluate_cached_snapshot(snapshot=snapshot, now=now)
|
||||
refresh_interval = max(settings.news_provider_refresh_seconds, 1)
|
||||
should_refresh = force_refresh or self._news_snapshot is None or self._last_news_refresh_ts is None
|
||||
if not should_refresh and self._last_news_refresh_ts is not None:
|
||||
|
||||
@@ -118,7 +118,8 @@ class BaseNewsProvider:
|
||||
return window_start <= now <= window_end
|
||||
|
||||
def _client_kwargs(self) -> dict:
|
||||
kwargs = {"timeout": self.timeout, "follow_redirects": True}
|
||||
attempt_timeout = max(1, min(int(self.timeout or 1), 2))
|
||||
kwargs = {"timeout": attempt_timeout, "follow_redirects": True}
|
||||
if self.proxy_url:
|
||||
kwargs["proxy"] = self.proxy_url
|
||||
kwargs["trust_env"] = False
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def build_digest_archive_long_range_summary(items: list[dict]) -> dict:
|
||||
if not items:
|
||||
return {
|
||||
"status": "idle",
|
||||
"window_basis": "query_result_window",
|
||||
"snapshot_count": 0,
|
||||
"recent_7d_count": 0,
|
||||
"recent_30d_count": 0,
|
||||
"covered_day_span": 0,
|
||||
"trend_status": "no_history",
|
||||
"next_review_prompt": None,
|
||||
}
|
||||
latest_dt = _safe_dt(items[0].get("created_ts"))
|
||||
oldest_dt = _safe_dt(items[-1].get("created_ts"))
|
||||
recent_7d = [item for item in items if _within_days(item.get("created_ts"), latest_dt, 7)]
|
||||
recent_30d = [item for item in items if _within_days(item.get("created_ts"), latest_dt, 30)]
|
||||
high_value_7d = sum(int(item.get("high_value_reminder_count") or 0) for item in recent_7d)
|
||||
high_value_30d = sum(int(item.get("high_value_reminder_count") or 0) for item in recent_30d)
|
||||
covered_day_span = _covered_day_span(latest_dt, oldest_dt)
|
||||
trend_status = _archive_trend_status(
|
||||
snapshot_count=len(items),
|
||||
recent_7d_count=len(recent_7d),
|
||||
recent_30d_count=len(recent_30d),
|
||||
covered_day_span=covered_day_span,
|
||||
)
|
||||
return {
|
||||
"status": "active",
|
||||
"window_basis": "query_result_window",
|
||||
"snapshot_count": len(items),
|
||||
"latest_created_ts": items[0].get("created_ts"),
|
||||
"oldest_created_ts": items[-1].get("created_ts"),
|
||||
"covered_day_span": covered_day_span,
|
||||
"recent_7d_count": len(recent_7d),
|
||||
"recent_30d_count": len(recent_30d),
|
||||
"recent_7d_high_value_count": high_value_7d,
|
||||
"recent_30d_high_value_count": high_value_30d,
|
||||
"trend_status": trend_status,
|
||||
"next_review_prompt": f"Review 7d vs 30d digest trend before rotating the next archive baseline.",
|
||||
}
|
||||
|
||||
|
||||
def build_digest_archive_baseline_summary(items: list[dict]) -> dict:
|
||||
if not items:
|
||||
return {
|
||||
"status": "idle",
|
||||
"baseline_snapshot_id": None,
|
||||
"latest_snapshot_id": None,
|
||||
"compare_ready": False,
|
||||
"suggested_compare_prompt": None,
|
||||
}
|
||||
latest = dict(items[0] or {})
|
||||
baseline = dict(items[-1] or {})
|
||||
return {
|
||||
"status": "active" if len(items) >= 2 else "insufficient_history",
|
||||
"latest_snapshot_id": latest.get("snapshot_id"),
|
||||
"latest_created_ts": latest.get("created_ts"),
|
||||
"latest_summary": latest.get("top_summary"),
|
||||
"baseline_snapshot_id": baseline.get("snapshot_id"),
|
||||
"baseline_created_ts": baseline.get("created_ts"),
|
||||
"baseline_summary": baseline.get("top_summary"),
|
||||
"compare_ready": len(items) >= 2,
|
||||
"suggested_compare_prompt": (
|
||||
f"Pin digest snapshot {baseline.get('snapshot_id')} as the baseline and compare it against {latest.get('snapshot_id')}."
|
||||
if len(items) >= 2
|
||||
else "Need at least two digest archive snapshots before pinning a baseline."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def build_workflow_archive_long_range_summary(items: list[dict]) -> dict:
|
||||
if not items:
|
||||
return {
|
||||
"status": "idle",
|
||||
"window_basis": "query_result_window",
|
||||
"snapshot_count": 0,
|
||||
"recent_7d_count": 0,
|
||||
"recent_30d_count": 0,
|
||||
"covered_day_span": 0,
|
||||
"trend_status": "no_history",
|
||||
"next_review_prompt": None,
|
||||
}
|
||||
latest_dt = _safe_dt(items[0].get("created_ts"))
|
||||
oldest_dt = _safe_dt(items[-1].get("created_ts"))
|
||||
recent_7d = [item for item in items if _within_days(item.get("created_ts"), latest_dt, 7)]
|
||||
recent_30d = [item for item in items if _within_days(item.get("created_ts"), latest_dt, 30)]
|
||||
covered_day_span = _covered_day_span(latest_dt, oldest_dt)
|
||||
distinct_sessions = len({str(item.get("current_session") or "unknown") for item in items})
|
||||
trend_status = _archive_trend_status(
|
||||
snapshot_count=len(items),
|
||||
recent_7d_count=len(recent_7d),
|
||||
recent_30d_count=len(recent_30d),
|
||||
covered_day_span=covered_day_span,
|
||||
)
|
||||
return {
|
||||
"status": "active",
|
||||
"window_basis": "query_result_window",
|
||||
"snapshot_count": len(items),
|
||||
"latest_created_ts": items[0].get("created_ts"),
|
||||
"oldest_created_ts": items[-1].get("created_ts"),
|
||||
"covered_day_span": covered_day_span,
|
||||
"recent_7d_count": len(recent_7d),
|
||||
"recent_30d_count": len(recent_30d),
|
||||
"recent_7d_ready_count": sum(int(item.get("ready_for_handoff_count") or 0) for item in recent_7d),
|
||||
"recent_30d_ready_count": sum(int(item.get("ready_for_handoff_count") or 0) for item in recent_30d),
|
||||
"distinct_session_count": distinct_sessions,
|
||||
"trend_status": trend_status,
|
||||
"next_review_prompt": "Review 7d vs 30d workflow archive trend before pinning the next baseline.",
|
||||
}
|
||||
|
||||
|
||||
def build_workflow_archive_baseline_summary(items: list[dict]) -> dict:
|
||||
if not items:
|
||||
return {
|
||||
"status": "idle",
|
||||
"baseline_snapshot_id": None,
|
||||
"latest_snapshot_id": None,
|
||||
"compare_ready": False,
|
||||
"suggested_compare_prompt": None,
|
||||
}
|
||||
latest = dict(items[0] or {})
|
||||
baseline = dict(items[-1] or {})
|
||||
return {
|
||||
"status": "active" if len(items) >= 2 else "insufficient_history",
|
||||
"latest_snapshot_id": latest.get("snapshot_id"),
|
||||
"latest_created_ts": latest.get("created_ts"),
|
||||
"latest_next_phase": latest.get("next_phase"),
|
||||
"latest_session": latest.get("current_session"),
|
||||
"baseline_snapshot_id": baseline.get("snapshot_id"),
|
||||
"baseline_created_ts": baseline.get("created_ts"),
|
||||
"baseline_next_phase": baseline.get("next_phase"),
|
||||
"baseline_session": baseline.get("current_session"),
|
||||
"compare_ready": len(items) >= 2,
|
||||
"suggested_compare_prompt": (
|
||||
f"Pin workflow snapshot {baseline.get('snapshot_id')} as the baseline and compare it against {latest.get('snapshot_id')}."
|
||||
if len(items) >= 2
|
||||
else "Need at least two workflow archive snapshots before pinning a baseline."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _safe_dt(value):
|
||||
if not value:
|
||||
return None
|
||||
text = str(value)
|
||||
try:
|
||||
return datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _within_days(value, latest_dt, days: int) -> bool:
|
||||
current_dt = _safe_dt(value)
|
||||
if not latest_dt or not current_dt:
|
||||
return False
|
||||
delta_days = (latest_dt - current_dt).total_seconds() / 86400
|
||||
return delta_days <= float(days)
|
||||
|
||||
|
||||
def _covered_day_span(latest_dt, oldest_dt) -> int:
|
||||
if not latest_dt or not oldest_dt:
|
||||
return 0
|
||||
return max(0, int(round((latest_dt - oldest_dt).total_seconds() / 86400)))
|
||||
|
||||
|
||||
def _archive_trend_status(*, snapshot_count: int, recent_7d_count: int, recent_30d_count: int, covered_day_span: int) -> str:
|
||||
if snapshot_count <= 1:
|
||||
return "thin_history"
|
||||
if covered_day_span >= 30 and recent_30d_count >= 6:
|
||||
return "long_range_ready"
|
||||
if covered_day_span >= 7 and recent_7d_count >= 3:
|
||||
return "weekly_ready"
|
||||
if recent_30d_count > 0:
|
||||
return "recent_window_only"
|
||||
return "thin_history"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,276 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def build_workflow_archive_operations_view(items: list[dict]) -> dict:
|
||||
daily_groups = _build_workflow_archive_daily_groups(items)
|
||||
session_groups = _build_workflow_archive_session_groups(items)
|
||||
operations_summary = _build_workflow_archive_operations_summary(
|
||||
items=items,
|
||||
daily_groups=daily_groups,
|
||||
session_groups=session_groups,
|
||||
)
|
||||
multi_day_drift_review = _build_workflow_archive_multi_day_drift_review(daily_groups)
|
||||
bridge_summary = _build_workflow_archive_bridge_summary(items)
|
||||
return {
|
||||
"daily_groups": daily_groups,
|
||||
"daily_group_count": len(daily_groups),
|
||||
"session_groups": session_groups,
|
||||
"session_group_count": len(session_groups),
|
||||
"operations_summary": operations_summary,
|
||||
"multi_day_drift_review": multi_day_drift_review,
|
||||
"bridge_summary": bridge_summary,
|
||||
}
|
||||
|
||||
|
||||
def _build_workflow_archive_daily_groups(items: list[dict]) -> list[dict]:
|
||||
buckets: dict[str, list[dict]] = {}
|
||||
for item in items:
|
||||
group_date = _workflow_archive_group_date(item.get("created_ts"))
|
||||
buckets.setdefault(group_date, []).append(dict(item))
|
||||
groups = []
|
||||
for group_date, rows in buckets.items():
|
||||
ordered = sorted(rows, key=lambda row: str(row.get("created_ts") or ""), reverse=True)
|
||||
representative = dict(ordered[0] or {})
|
||||
sessions = sorted({str(row.get("current_session") or "unknown") for row in ordered})
|
||||
next_phase_counts = _count_values([row.get("next_phase") for row in ordered])
|
||||
phase_names = _workflow_archive_phase_names(ordered)
|
||||
groups.append(
|
||||
{
|
||||
"group_date": group_date,
|
||||
"status": "active",
|
||||
"snapshot_count": len(ordered),
|
||||
"latest_created_ts": representative.get("created_ts"),
|
||||
"current_session": representative.get("current_session"),
|
||||
"sessions": sessions,
|
||||
"session_count": len(sessions),
|
||||
"next_phase": representative.get("next_phase"),
|
||||
"next_phase_counts": next_phase_counts,
|
||||
"next_artifact_kind": representative.get("next_artifact_kind"),
|
||||
"artifact_count": int(representative.get("artifact_count") or 0),
|
||||
"ready_for_handoff_count": int(representative.get("ready_for_handoff_count") or 0),
|
||||
"needs_update_count": int(representative.get("needs_update_count") or 0),
|
||||
"pending_capture_count": int(representative.get("pending_capture_count") or 0),
|
||||
"phase_names": phase_names,
|
||||
"phase_name_count": len(phase_names),
|
||||
"workflow_body": representative.get("workflow_body"),
|
||||
"next_artifact_prompt": representative.get("next_artifact_prompt"),
|
||||
"representative_snapshot_id": representative.get("snapshot_id"),
|
||||
"representative_phase_briefs": list(representative.get("phase_briefs") or []),
|
||||
}
|
||||
)
|
||||
groups.sort(key=lambda row: str(row.get("group_date") or ""), reverse=True)
|
||||
return groups[:14]
|
||||
|
||||
|
||||
def _build_workflow_archive_session_groups(items: list[dict]) -> list[dict]:
|
||||
buckets: dict[str, list[dict]] = {}
|
||||
for item in items:
|
||||
session_code = str(item.get("current_session") or "unknown")
|
||||
buckets.setdefault(session_code, []).append(dict(item))
|
||||
groups = []
|
||||
for session_code, rows in buckets.items():
|
||||
ordered = sorted(rows, key=lambda row: str(row.get("created_ts") or ""), reverse=True)
|
||||
representative = dict(ordered[0] or {})
|
||||
day_count = len({str(_workflow_archive_group_date(row.get("created_ts"))) for row in ordered})
|
||||
phase_names = _workflow_archive_phase_names(ordered)
|
||||
groups.append(
|
||||
{
|
||||
"session_code": session_code,
|
||||
"status": "active",
|
||||
"snapshot_count": len(ordered),
|
||||
"day_count": day_count,
|
||||
"latest_created_ts": representative.get("created_ts"),
|
||||
"latest_group_date": _workflow_archive_group_date(representative.get("created_ts")),
|
||||
"latest_next_phase": representative.get("next_phase"),
|
||||
"artifact_count": int(representative.get("artifact_count") or 0),
|
||||
"ready_for_handoff_count": int(representative.get("ready_for_handoff_count") or 0),
|
||||
"needs_update_count": int(representative.get("needs_update_count") or 0),
|
||||
"pending_capture_count": int(representative.get("pending_capture_count") or 0),
|
||||
"phase_names": phase_names,
|
||||
"phase_name_count": len(phase_names),
|
||||
}
|
||||
)
|
||||
groups.sort(
|
||||
key=lambda row: (
|
||||
int(row.get("snapshot_count") or 0),
|
||||
str(row.get("latest_created_ts") or ""),
|
||||
str(row.get("session_code") or ""),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
return groups[:10]
|
||||
|
||||
|
||||
def _build_workflow_archive_operations_summary(*, items: list[dict], daily_groups: list[dict], session_groups: list[dict]) -> dict:
|
||||
if not items:
|
||||
return {
|
||||
"status": "idle",
|
||||
"snapshot_count": 0,
|
||||
"day_group_count": 0,
|
||||
"session_group_count": 0,
|
||||
"latest_group_date": None,
|
||||
"most_active_day": None,
|
||||
"most_active_session": None,
|
||||
"next_operations_prompt": None,
|
||||
}
|
||||
latest_day = dict(daily_groups[0] or {})
|
||||
top_session = dict(session_groups[0] or {})
|
||||
return {
|
||||
"status": "active",
|
||||
"snapshot_count": len(items),
|
||||
"day_group_count": len(daily_groups),
|
||||
"session_group_count": len(session_groups),
|
||||
"latest_group_date": latest_day.get("group_date"),
|
||||
"latest_snapshot_count": latest_day.get("snapshot_count"),
|
||||
"most_active_day": latest_day.get("group_date"),
|
||||
"most_active_day_session_count": latest_day.get("session_count"),
|
||||
"most_active_session": top_session.get("session_code"),
|
||||
"most_active_session_snapshot_count": top_session.get("snapshot_count"),
|
||||
"phase_name_count": len(_workflow_archive_phase_names(items)),
|
||||
"next_operations_prompt": (
|
||||
f"Start with {latest_day.get('group_date')} archive group, then compare it against the previous day for workflow drift."
|
||||
if latest_day.get("group_date")
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _build_workflow_archive_multi_day_drift_review(daily_groups: list[dict]) -> dict:
|
||||
if len(daily_groups) < 2:
|
||||
return {
|
||||
"status": "insufficient_history",
|
||||
"left_group_date": None,
|
||||
"right_group_date": None,
|
||||
"phase_changes": [],
|
||||
"summary": "Need at least two workflow day groups to review multi-day drift.",
|
||||
}
|
||||
left = dict(daily_groups[0] or {})
|
||||
right = dict(daily_groups[1] or {})
|
||||
left_map = {
|
||||
str(item.get("phase") or "workflow"): str(item.get("archive_body") or item.get("detail") or "")
|
||||
for item in list(left.get("representative_phase_briefs") or [])
|
||||
}
|
||||
right_map = {
|
||||
str(item.get("phase") or "workflow"): str(item.get("archive_body") or item.get("detail") or "")
|
||||
for item in list(right.get("representative_phase_briefs") or [])
|
||||
}
|
||||
phase_changes = []
|
||||
for phase in sorted(set(left_map) | set(right_map)):
|
||||
phase_changes.append(
|
||||
{
|
||||
"phase": phase,
|
||||
"changed": str(left_map.get(phase) or "") != str(right_map.get(phase) or ""),
|
||||
"left_body": left_map.get(phase),
|
||||
"right_body": right_map.get(phase),
|
||||
}
|
||||
)
|
||||
changed_phase_count = sum(1 for item in phase_changes if item.get("changed"))
|
||||
session_changed = str(left.get("current_session") or "") != str(right.get("current_session") or "")
|
||||
next_phase_changed = str(left.get("next_phase") or "") != str(right.get("next_phase") or "")
|
||||
drift_status = "drift_detected" if changed_phase_count or session_changed or next_phase_changed else "stable_repeat"
|
||||
return {
|
||||
"status": "active",
|
||||
"drift_status": drift_status,
|
||||
"left_group_date": left.get("group_date"),
|
||||
"right_group_date": right.get("group_date"),
|
||||
"changed_phase_count": changed_phase_count,
|
||||
"session_changed": session_changed,
|
||||
"next_phase_changed": next_phase_changed,
|
||||
"count_delta": {
|
||||
"artifact_count": int(left.get("artifact_count") or 0) - int(right.get("artifact_count") or 0),
|
||||
"ready_for_handoff_count": int(left.get("ready_for_handoff_count") or 0) - int(right.get("ready_for_handoff_count") or 0),
|
||||
"needs_update_count": int(left.get("needs_update_count") or 0) - int(right.get("needs_update_count") or 0),
|
||||
"pending_capture_count": int(left.get("pending_capture_count") or 0) - int(right.get("pending_capture_count") or 0),
|
||||
"snapshot_count": int(left.get("snapshot_count") or 0) - int(right.get("snapshot_count") or 0),
|
||||
},
|
||||
"phase_changes": phase_changes,
|
||||
"summary": (
|
||||
f"Workflow drift review compares {left.get('group_date')} vs {right.get('group_date')}: "
|
||||
f"{changed_phase_count} changed phase brief(s), session_changed={str(session_changed).lower()}, "
|
||||
f"next_phase_changed={str(next_phase_changed).lower()}."
|
||||
),
|
||||
"next_drift_prompt": (
|
||||
f"Review {left.get('group_date')} against {right.get('group_date')} before promoting the next workflow cadence."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _build_workflow_archive_bridge_summary(items: list[dict]) -> dict:
|
||||
if not items:
|
||||
return {
|
||||
"status": "idle",
|
||||
"bridge_target_count": 0,
|
||||
"replay_ready_count": 0,
|
||||
"note_ready_count": 0,
|
||||
"handoff_ready_count": 0,
|
||||
"latest_snapshot_id": None,
|
||||
"latest_session": None,
|
||||
"latest_next_phase": None,
|
||||
"next_replay_prompt": None,
|
||||
"next_note_prompt": None,
|
||||
"next_handoff_prompt": None,
|
||||
}
|
||||
latest = dict(items[0] or {})
|
||||
replay_ready_count = sum(1 for item in items if item.get("workflow_body") or item.get("phase_briefs"))
|
||||
note_ready_count = sum(1 for item in items if item.get("workflow_body") or item.get("next_artifact_prompt"))
|
||||
handoff_ready_count = sum(1 for item in items if item.get("next_phase") or item.get("next_artifact_kind"))
|
||||
return {
|
||||
"status": "active",
|
||||
"bridge_target_count": 3,
|
||||
"replay_ready_count": replay_ready_count,
|
||||
"note_ready_count": note_ready_count,
|
||||
"handoff_ready_count": handoff_ready_count,
|
||||
"latest_snapshot_id": latest.get("snapshot_id"),
|
||||
"latest_session": latest.get("current_session"),
|
||||
"latest_next_phase": latest.get("next_phase"),
|
||||
"latest_artifact_kind": latest.get("next_artifact_kind"),
|
||||
"next_replay_prompt": (
|
||||
f"Open replay with workflow snapshot {latest.get('snapshot_id')} from {latest.get('current_session') or 'unknown session'}."
|
||||
if latest.get("snapshot_id")
|
||||
else None
|
||||
),
|
||||
"next_note_prompt": (
|
||||
f"Prefill a review note from workflow snapshot {latest.get('snapshot_id')} before handoff."
|
||||
if latest.get("snapshot_id")
|
||||
else None
|
||||
),
|
||||
"next_handoff_prompt": (
|
||||
f"Bridge workflow snapshot {latest.get('snapshot_id')} into the next {latest.get('next_phase') or 'workflow'} handoff."
|
||||
if latest.get("snapshot_id")
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _workflow_archive_group_date(value) -> str:
|
||||
if not value:
|
||||
return "unknown_date"
|
||||
text = str(value)
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
return parsed.date().isoformat()
|
||||
except Exception:
|
||||
return text[:10]
|
||||
|
||||
|
||||
def _workflow_archive_phase_names(items: list[dict]) -> list[str]:
|
||||
phase_names = []
|
||||
seen = set()
|
||||
for item in items:
|
||||
for phase in list(item.get("phase_briefs") or []):
|
||||
name = str(phase.get("phase") or "")
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
phase_names.append(name)
|
||||
return phase_names
|
||||
|
||||
|
||||
def _count_values(values: list[object]) -> dict:
|
||||
counts: dict[str, int] = {}
|
||||
for value in values:
|
||||
key = str(value or "unknown")
|
||||
counts[key] = int(counts.get(key) or 0) + 1
|
||||
return counts
|
||||
@@ -40,9 +40,19 @@ class LaunchReadinessService:
|
||||
self.trust_service = trust_service or ModelTrustService()
|
||||
self.live_readiness_service = live_readiness_service or LiveReadinessService()
|
||||
|
||||
def build_summary(self, *, instrument_id: int, timeframe: str = "1m") -> dict:
|
||||
def build_summary(
|
||||
self,
|
||||
*,
|
||||
instrument_id: int,
|
||||
timeframe: str = "1m",
|
||||
cached_news_only: bool = False,
|
||||
) -> dict:
|
||||
now = datetime.now(timezone.utc)
|
||||
market_state = self.market_state_service.build_market_state(instrument_id=instrument_id, timeframe=timeframe)
|
||||
market_state = self.market_state_service.build_market_state(
|
||||
instrument_id=instrument_id,
|
||||
timeframe=timeframe,
|
||||
cached_news_only=cached_news_only,
|
||||
)
|
||||
broker_state = self.broker_read_model_service.fetch_snapshot(
|
||||
instrument_id=instrument_id,
|
||||
now=now,
|
||||
|
||||
+1560
-231
File diff suppressed because it is too large
Load Diff
@@ -246,6 +246,20 @@ th { color: #9db0d3; }
|
||||
border-color: #4f7cff;
|
||||
box-shadow: inset 0 0 0 1px rgba(79, 124, 255, 0.25);
|
||||
}
|
||||
.assistant-archive-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.assistant-archive-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.assistant-compare-selected {
|
||||
border-color: #fbbf24;
|
||||
box-shadow: inset 0 0 0 1px rgba(251, 191, 36, 0.28);
|
||||
}
|
||||
.assistant-dense .card {
|
||||
padding: 11px;
|
||||
}
|
||||
@@ -272,6 +286,33 @@ th { color: #9db0d3; }
|
||||
border-color: #4f7cff;
|
||||
box-shadow: inset 0 0 0 1px rgba(79, 124, 255, 0.3);
|
||||
}
|
||||
.assistant-preference-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.assistant-preference-fields,
|
||||
.assistant-preference-template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.assistant-preference-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.assistant-preference-input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #31436c;
|
||||
border-radius: 8px;
|
||||
background: #0d1430;
|
||||
color: #fff;
|
||||
}
|
||||
.assistant-preference-dirty {
|
||||
color: #fcd34d;
|
||||
font-size: 12px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
@@ -173,46 +173,75 @@ class ApiServerRoutingTests(unittest.TestCase):
|
||||
)
|
||||
digest_archive_status, digest_archive_payload = route_request(
|
||||
"/trading/alerts/digest-history-archive",
|
||||
{"instrument_id": ["1"], "limit": ["5"], "offset": ["0"], "preferred_history_kind": ["reminder"]},
|
||||
{"instrument_id": ["1"], "limit": ["5"], "offset": ["0"], "preferred_history_kind": ["reminder"], "query_text": ["candidate"]},
|
||||
dependencies={
|
||||
"trading_alert_operator_digest_history_archive": lambda **kwargs: {
|
||||
"status": "active",
|
||||
"instrument_id": kwargs["instrument_id"],
|
||||
"limit": kwargs["limit"],
|
||||
"filters": {"preferred_history_kind": kwargs["preferred_history_kind"]},
|
||||
"filters": {"preferred_history_kind": kwargs["preferred_history_kind"], "query_text": kwargs["query_text"]},
|
||||
}
|
||||
},
|
||||
)
|
||||
digest_export_status, digest_export_payload = route_request(
|
||||
"/trading/alerts/digest-history-archive/export",
|
||||
{"instrument_id": ["1"], "query_text": ["candidate"], "left_id": ["4"], "right_id": ["3"]},
|
||||
dependencies={
|
||||
"trading_alert_operator_digest_history_export": lambda **kwargs: {
|
||||
"status": "active",
|
||||
"export_kind": "digest_archive_bundle",
|
||||
"query_text": kwargs["query_text"],
|
||||
"left_id": kwargs["left_id"],
|
||||
"right_id": kwargs["right_id"],
|
||||
}
|
||||
},
|
||||
)
|
||||
digest_compare_status, digest_compare_payload = route_request(
|
||||
"/trading/alerts/digest-history-archive/compare",
|
||||
{"instrument_id": ["1"], "left_id": ["4"], "right_id": ["3"]},
|
||||
{"instrument_id": ["1"], "left_id": ["4"], "right_id": ["3"], "query_text": ["candidate"]},
|
||||
dependencies={
|
||||
"trading_alert_operator_digest_history_compare": lambda **kwargs: {
|
||||
"status": "active",
|
||||
"left_id": kwargs["left_id"],
|
||||
"right_id": kwargs["right_id"],
|
||||
"query_text": kwargs["query_text"],
|
||||
}
|
||||
},
|
||||
)
|
||||
workflow_archive_status, workflow_archive_payload = route_request(
|
||||
"/trading/workflow-archive",
|
||||
{"instrument_id": ["1"], "current_session": ["LONDON"], "next_phase": ["intraday"]},
|
||||
{"instrument_id": ["1"], "current_session": ["LONDON"], "next_phase": ["intraday"], "query_text": ["handoff"]},
|
||||
dependencies={
|
||||
"trading_workflow_archive": lambda **kwargs: {
|
||||
"status": "active",
|
||||
"instrument_id": kwargs["instrument_id"],
|
||||
"filters": {"current_session": kwargs["current_session"], "next_phase": kwargs["next_phase"]},
|
||||
"filters": {"current_session": kwargs["current_session"], "next_phase": kwargs["next_phase"], "query_text": kwargs["query_text"]},
|
||||
}
|
||||
},
|
||||
)
|
||||
workflow_export_status, workflow_export_payload = route_request(
|
||||
"/trading/workflow-archive/export",
|
||||
{"instrument_id": ["1"], "query_text": ["handoff"], "left_id": ["9"], "right_id": ["8"]},
|
||||
dependencies={
|
||||
"trading_workflow_archive_export": lambda **kwargs: {
|
||||
"status": "active",
|
||||
"export_kind": "workflow_archive_bundle",
|
||||
"query_text": kwargs["query_text"],
|
||||
"left_id": kwargs["left_id"],
|
||||
"right_id": kwargs["right_id"],
|
||||
"handoff_bundle_summary": {"status": "active"},
|
||||
}
|
||||
},
|
||||
)
|
||||
workflow_compare_status, workflow_compare_payload = route_request(
|
||||
"/trading/workflow-archive/compare",
|
||||
{"instrument_id": ["1"], "left_id": ["9"], "right_id": ["8"]},
|
||||
{"instrument_id": ["1"], "left_id": ["9"], "right_id": ["8"], "query_text": ["handoff"]},
|
||||
dependencies={
|
||||
"trading_workflow_archive_compare": lambda **kwargs: {
|
||||
"status": "active",
|
||||
"left_id": kwargs["left_id"],
|
||||
"right_id": kwargs["right_id"],
|
||||
"query_text": kwargs["query_text"],
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -248,17 +277,87 @@ class ApiServerRoutingTests(unittest.TestCase):
|
||||
self.assertEqual(digest_archive_status, 200)
|
||||
self.assertEqual(digest_archive_payload["limit"], 5)
|
||||
self.assertEqual(digest_archive_payload["filters"]["preferred_history_kind"], "reminder")
|
||||
self.assertEqual(digest_archive_payload["filters"]["query_text"], "candidate")
|
||||
self.assertEqual(digest_export_status, 200)
|
||||
self.assertEqual(digest_export_payload["export_kind"], "digest_archive_bundle")
|
||||
self.assertEqual(digest_export_payload["left_id"], 4)
|
||||
self.assertEqual(digest_compare_status, 200)
|
||||
self.assertEqual(digest_compare_payload["left_id"], 4)
|
||||
self.assertEqual(digest_compare_payload["query_text"], "candidate")
|
||||
self.assertEqual(workflow_archive_status, 200)
|
||||
self.assertEqual(workflow_archive_payload["filters"]["next_phase"], "intraday")
|
||||
self.assertEqual(workflow_archive_payload["filters"]["query_text"], "handoff")
|
||||
self.assertEqual(workflow_export_status, 200)
|
||||
self.assertEqual(workflow_export_payload["export_kind"], "workflow_archive_bundle")
|
||||
self.assertEqual(workflow_export_payload["handoff_bundle_summary"]["status"], "active")
|
||||
self.assertEqual(workflow_compare_status, 200)
|
||||
self.assertEqual(workflow_compare_payload["right_id"], 8)
|
||||
self.assertEqual(workflow_compare_payload["query_text"], "handoff")
|
||||
self.assertEqual(focus_get_status, 200)
|
||||
self.assertEqual(focus_get_payload["focus_candidate_id"], 18)
|
||||
self.assertEqual(focus_post_status, 200)
|
||||
self.assertEqual(focus_post_payload["profile_key"], "desk_a")
|
||||
|
||||
def test_routes_decision_assistant_profile_management(self) -> None:
|
||||
create_status, create_payload = route_request(
|
||||
"/trading/alerts/digest-preferences/profiles",
|
||||
{},
|
||||
method="POST",
|
||||
body={
|
||||
"instrument_id": 1,
|
||||
"profile_key": "desk_b",
|
||||
"profile_label": "Desk B",
|
||||
"digest_mode": "high_value_only",
|
||||
},
|
||||
dependencies={
|
||||
"trading_alert_operator_digest_profiles_create": lambda **kwargs: {
|
||||
"status": "created",
|
||||
"instrument_id": kwargs["instrument_id"],
|
||||
"profile_key": kwargs["profile_key"],
|
||||
"profile_label": kwargs["profile_label"],
|
||||
}
|
||||
},
|
||||
)
|
||||
rename_status, rename_payload = route_request(
|
||||
"/trading/alerts/digest-preferences/profiles/desk_b/rename",
|
||||
{},
|
||||
method="POST",
|
||||
body={
|
||||
"instrument_id": 1,
|
||||
"profile_label": "Desk Bravo",
|
||||
},
|
||||
dependencies={
|
||||
"trading_alert_operator_digest_profiles_rename": lambda **kwargs: {
|
||||
"status": "renamed",
|
||||
"instrument_id": kwargs["instrument_id"],
|
||||
"profile_key": kwargs["profile_key"],
|
||||
"profile_label": kwargs["profile_label"],
|
||||
}
|
||||
},
|
||||
)
|
||||
delete_status, delete_payload = route_request(
|
||||
"/trading/alerts/digest-preferences/profiles/desk_b/delete",
|
||||
{},
|
||||
method="POST",
|
||||
body={"instrument_id": 1},
|
||||
dependencies={
|
||||
"trading_alert_operator_digest_profiles_delete": lambda **kwargs: {
|
||||
"status": "deleted",
|
||||
"instrument_id": kwargs["instrument_id"],
|
||||
"profile_key": kwargs["profile_key"],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(create_status, 200)
|
||||
self.assertEqual(create_payload["status"], "created")
|
||||
self.assertEqual(create_payload["profile_key"], "desk_b")
|
||||
self.assertEqual(rename_status, 200)
|
||||
self.assertEqual(rename_payload["profile_key"], "desk_b")
|
||||
self.assertEqual(rename_payload["profile_label"], "Desk Bravo")
|
||||
self.assertEqual(delete_status, 200)
|
||||
self.assertEqual(delete_payload["status"], "deleted")
|
||||
|
||||
def test_routes_maintenance_summary(self) -> None:
|
||||
status, payload = route_request(
|
||||
"/trading/maintenance-summary",
|
||||
|
||||
@@ -104,8 +104,11 @@ class ApiStaticServerTests(unittest.TestCase):
|
||||
"/trading/alerts/quality-summary",
|
||||
"/trading/alerts/operator-digest",
|
||||
"/trading/alerts/digest-preferences",
|
||||
"/trading/alerts/digest-preferences/profiles",
|
||||
"/trading/alerts/digest-history-archive",
|
||||
"/trading/alerts/digest-history-archive/export",
|
||||
"/trading/workflow-archive",
|
||||
"/trading/workflow-archive/export",
|
||||
"/trading/assistant-focus",
|
||||
"/trading/runtime-incidents",
|
||||
"/trading/maintenance-summary",
|
||||
@@ -130,6 +133,40 @@ class ApiStaticServerTests(unittest.TestCase):
|
||||
):
|
||||
self.assertNotIn(forbidden, app_js)
|
||||
|
||||
def test_dashboard_exposes_workflow_archive_operations_surface(self) -> None:
|
||||
app_file = _resolve_static_file("/static/app.js")
|
||||
self.assertIsNotNone(app_file)
|
||||
app_js = app_file.read_text(encoding="utf-8")
|
||||
|
||||
for expected in (
|
||||
"workflow operations",
|
||||
"workflow drift",
|
||||
"Day Group:",
|
||||
"Session Group:",
|
||||
"operation snapshots",
|
||||
"drift next prompt",
|
||||
"Search Text",
|
||||
"Export Archive Results",
|
||||
"Export Archive Compare",
|
||||
"Export Workflow Results",
|
||||
"Export Workflow Compare",
|
||||
"Replay Bridge",
|
||||
"Note Bridge",
|
||||
"Handoff Bridge",
|
||||
"Clear Workflow Bridge",
|
||||
"provider health cards",
|
||||
"Open Visual Bridge",
|
||||
"Open Target",
|
||||
"Open Embed",
|
||||
"archive trend",
|
||||
"archive baseline",
|
||||
"Pin Baseline",
|
||||
"Compare To Baseline",
|
||||
"workflow long-range",
|
||||
"workflow baseline",
|
||||
):
|
||||
self.assertIn(expected, app_js)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import unittest
|
||||
|
||||
from src.services.operator.archive_long_range_projection import (
|
||||
build_digest_archive_baseline_summary,
|
||||
build_digest_archive_long_range_summary,
|
||||
build_workflow_archive_baseline_summary,
|
||||
build_workflow_archive_long_range_summary,
|
||||
)
|
||||
|
||||
|
||||
class ArchiveLongRangeProjectionTests(unittest.TestCase):
|
||||
def test_digest_long_range_and_baseline_summary(self) -> None:
|
||||
items = [
|
||||
{
|
||||
"snapshot_id": 5,
|
||||
"created_ts": "2026-05-14T09:00:00+00:00",
|
||||
"top_summary": "Latest digest",
|
||||
"high_value_reminder_count": 2,
|
||||
},
|
||||
{
|
||||
"snapshot_id": 4,
|
||||
"created_ts": "2026-05-10T09:00:00+00:00",
|
||||
"top_summary": "Weekly digest",
|
||||
"high_value_reminder_count": 1,
|
||||
},
|
||||
{
|
||||
"snapshot_id": 1,
|
||||
"created_ts": "2026-04-20T09:00:00+00:00",
|
||||
"top_summary": "Older digest",
|
||||
"high_value_reminder_count": 0,
|
||||
},
|
||||
]
|
||||
|
||||
trend = build_digest_archive_long_range_summary(items)
|
||||
baseline = build_digest_archive_baseline_summary(items)
|
||||
|
||||
self.assertEqual(trend["status"], "active")
|
||||
self.assertEqual(trend["recent_7d_count"], 2)
|
||||
self.assertEqual(trend["recent_30d_count"], 3)
|
||||
self.assertGreaterEqual(trend["covered_day_span"], 20)
|
||||
self.assertTrue(trend["next_review_prompt"])
|
||||
self.assertEqual(baseline["status"], "active")
|
||||
self.assertEqual(baseline["latest_snapshot_id"], 5)
|
||||
self.assertEqual(baseline["baseline_snapshot_id"], 1)
|
||||
self.assertTrue(baseline["compare_ready"])
|
||||
|
||||
def test_workflow_long_range_and_baseline_summary(self) -> None:
|
||||
items = [
|
||||
{
|
||||
"snapshot_id": 9,
|
||||
"created_ts": "2026-05-14T09:00:00+00:00",
|
||||
"current_session": "LONDON",
|
||||
"next_phase": "intraday",
|
||||
"ready_for_handoff_count": 2,
|
||||
},
|
||||
{
|
||||
"snapshot_id": 8,
|
||||
"created_ts": "2026-05-08T09:00:00+00:00",
|
||||
"current_session": "NY_AM",
|
||||
"next_phase": "post_trade",
|
||||
"ready_for_handoff_count": 1,
|
||||
},
|
||||
{
|
||||
"snapshot_id": 3,
|
||||
"created_ts": "2026-04-18T09:00:00+00:00",
|
||||
"current_session": "ASIA_PM",
|
||||
"next_phase": "pre_market",
|
||||
"ready_for_handoff_count": 0,
|
||||
},
|
||||
]
|
||||
|
||||
trend = build_workflow_archive_long_range_summary(items)
|
||||
baseline = build_workflow_archive_baseline_summary(items)
|
||||
|
||||
self.assertEqual(trend["status"], "active")
|
||||
self.assertEqual(trend["recent_7d_count"], 2)
|
||||
self.assertEqual(trend["recent_30d_count"], 3)
|
||||
self.assertEqual(trend["distinct_session_count"], 3)
|
||||
self.assertEqual(baseline["status"], "active")
|
||||
self.assertEqual(baseline["latest_snapshot_id"], 9)
|
||||
self.assertEqual(baseline["baseline_snapshot_id"], 3)
|
||||
self.assertTrue(baseline["suggested_compare_prompt"])
|
||||
|
||||
def test_empty_items_return_idle(self) -> None:
|
||||
self.assertEqual(build_digest_archive_long_range_summary([])["status"], "idle")
|
||||
self.assertEqual(build_digest_archive_baseline_summary([])["status"], "idle")
|
||||
self.assertEqual(build_workflow_archive_long_range_summary([])["status"], "idle")
|
||||
self.assertEqual(build_workflow_archive_baseline_summary([])["status"], "idle")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -8,8 +8,6 @@ from scripts import check_local_demo_readiness, check_trading_delivery_readiness
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
README = ROOT / "README.md"
|
||||
RUNBOOK = ROOT / "RUNBOOK.md"
|
||||
TRADING_PLAN_DOC = ROOT / "docs" / "AI_ICT_实盘交易分析系统开发计划_v1.md"
|
||||
ICT_BOOK_DOC = ROOT / "docs" / "ICT交易全书_出版终校版.md"
|
||||
TRAINING_PUBLIC_DOC = ROOT / "docs" / "training_console_public_surface_v1.md"
|
||||
TRAINING_PACKAGE_SMOKE_DOC = ROOT / "docs" / "examples" / "training_case_package_smoke_m6.md"
|
||||
TRADING_INDEX_HTML = ROOT / "src" / "web" / "index.html"
|
||||
@@ -18,15 +16,20 @@ TRAINING_HTML = ROOT / "src" / "web" / "training.html"
|
||||
TRAINING_JS = ROOT / "src" / "web" / "training.js"
|
||||
OLD_TRAINING_DOC = ROOT / "docs" / "training_case_engine_m1_m6_product_readme.md"
|
||||
NONEXISTENT_VISUAL_PLAN_NAME = "AI_ICT_" + "视觉盯盘与主动告警增强方案_v1.md"
|
||||
ARCHIVED_DORMANT_DOCS = {
|
||||
"AI_ICT_下一阶段开发计划_v1.md",
|
||||
"AI_ICT_下一阶段开发任务清单_v1.md",
|
||||
"AI_ICT_TradingAgents融合实施方案_v1.md",
|
||||
"AI_ICT_方案一系统边界图与模块融合图_v1.md",
|
||||
|
||||
ACTIVE_TOP_LEVEL_DOCS = {
|
||||
"AI_ICT_分析助手主线增强开发计划_2026-05-14_v1.md",
|
||||
"AI_ICT_全自动开发执行流程_v1.md",
|
||||
"AI_ICT_实盘交易分析决策助手下一阶段开发方案_v1.md",
|
||||
"AI_ICT_实盘交易分析系统开发计划_v1.md",
|
||||
"AI_ICT_实盘辅助决策系统开发方案_v2.md",
|
||||
"AI_ICT_自动化执行层_P15-PXX闭环任务清单_v1.md",
|
||||
"AI_ICT_需求文档剩余功能与延期项清单_2026-05-14_v1.md",
|
||||
"ICT交易全书_出版终校版.md",
|
||||
"training_console_public_surface_v1.md",
|
||||
}
|
||||
OPTIONAL_DORMANT_DOCS = {
|
||||
"AI_ICT_repair_bridge_protocol_v1.md",
|
||||
"AI_ICT_research_council脚手架与数据表草案_v1.md",
|
||||
|
||||
ARCHIVED_DOCS = {
|
||||
"AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md",
|
||||
"AI_ICT_TradingAgents_P0自动任务清单_v1.md",
|
||||
"AI_ICT_TradingAgents_P1自动任务清单_v1.md",
|
||||
@@ -35,44 +38,38 @@ OPTIONAL_DORMANT_DOCS = {
|
||||
"AI_ICT_TradingAgents_P4自动任务清单_v1.md",
|
||||
"AI_ICT_TradingAgents_P5自动任务清单_v1.md",
|
||||
"AI_ICT_TradingAgents_P6自动任务清单_v1.md",
|
||||
"AI_ICT_TradingAgents融合实施方案_v1.md",
|
||||
"AI_ICT_TradingAgents融合实施方案_最终版.md",
|
||||
"AI_ICT_下一阶段开发任务清单_v1.md",
|
||||
"AI_ICT_下一阶段开发计划_v1.md",
|
||||
"AI_ICT_repair_bridge_protocol_v1.md",
|
||||
"AI_ICT_research_council脚手架与数据表草案_v1.md",
|
||||
"AI_ICT_外部repair_agent接入说明_v1.md",
|
||||
"AI_ICT_实盘交易分析决策助手未完成需求清单_2026-05-14_v1.md",
|
||||
"AI_ICT_方案一系统边界图与模块融合图_v1.md",
|
||||
"AI_ICT_盯盘告警开发任务清单_v1.md",
|
||||
"AI_ICT_盯盘告警技术设计稿_v1.md",
|
||||
"AI_ICT_盯盘告警架构图_v1.md",
|
||||
"AI_ICT_自动化执行层_P15-PXX闭环任务清单_v1.md",
|
||||
"AI_ICT_自动化执行层_P7监控成熟度与晋级路由_v1.md",
|
||||
"AI_ICT_自动化流程使用教程_v1.md",
|
||||
}
|
||||
|
||||
|
||||
class DocumentationConsistencyTests(unittest.TestCase):
|
||||
def test_docs_inventory_is_current_and_old_training_product_doc_is_gone(self) -> None:
|
||||
def test_docs_inventory_is_current_and_archived_docs_are_not_top_level(self) -> None:
|
||||
top_level_docs = {
|
||||
path.name
|
||||
for path in (ROOT / "docs").iterdir()
|
||||
if path.is_file()
|
||||
}
|
||||
self.assertEqual(
|
||||
top_level_docs,
|
||||
{
|
||||
"AI_ICT_全自动开发执行流程_v1.md",
|
||||
"AI_ICT_实盘交易分析决策助手下一阶段开发方案_v1.md",
|
||||
"AI_ICT_实盘交易分析决策助手未完成需求清单_2026-05-14_v1.md",
|
||||
"AI_ICT_实盘交易分析系统开发计划_v1.md",
|
||||
"AI_ICT_实盘辅助决策系统开发方案_v2.md",
|
||||
"ICT交易全书_出版终校版.md",
|
||||
"training_console_public_surface_v1.md",
|
||||
}
|
||||
| OPTIONAL_DORMANT_DOCS,
|
||||
)
|
||||
self.assertTrue(TRAINING_PACKAGE_SMOKE_DOC.is_file())
|
||||
self.assertEqual(top_level_docs, ACTIVE_TOP_LEVEL_DOCS)
|
||||
|
||||
archive_docs = {
|
||||
path.name
|
||||
for path in (ROOT / "docs" / "archive").iterdir()
|
||||
if path.is_file()
|
||||
}
|
||||
self.assertTrue(ARCHIVED_DORMANT_DOCS <= archive_docs)
|
||||
self.assertTrue(ARCHIVED_DOCS <= archive_docs)
|
||||
self.assertFalse(OLD_TRAINING_DOC.exists())
|
||||
|
||||
def test_readme_and_runbook_use_current_training_entry_and_not_old_doc(self) -> None:
|
||||
|
||||
@@ -191,7 +191,23 @@ class OperatorWorkspaceServiceTests(unittest.TestCase):
|
||||
self.assertIn("workflow_history_archive", summary["workflow_artifact_summary"])
|
||||
self.assertIn("workflow_history_archive_count", summary["workflow_artifact_summary"])
|
||||
self.assertIn("workflow_archive_compare", summary["workflow_artifact_summary"])
|
||||
self.assertIn("workflow_archive_operations_summary", summary["workflow_artifact_summary"])
|
||||
self.assertIn("workflow_archive_multi_day_drift_review", summary["workflow_artifact_summary"])
|
||||
self.assertIn("workflow_archive_bridge_summary", summary["workflow_artifact_summary"])
|
||||
self.assertIn("workflow_persistence_status", summary["workflow_artifact_summary"])
|
||||
self.assertEqual(summary["workflow_artifact_summary"]["workflow_archive_operations_summary"]["status"], "active")
|
||||
self.assertEqual(
|
||||
summary["workflow_artifact_summary"]["workflow_archive_multi_day_drift_review"]["status"],
|
||||
"insufficient_history",
|
||||
)
|
||||
self.assertEqual(summary["workflow_artifact_summary"]["workflow_archive_bridge_summary"]["status"], "active")
|
||||
self.assertIn("long_range_trend_summary", summary["workflow_artifact_summary"]["workflow_history_archive"])
|
||||
self.assertIn("baseline_pin_summary", summary["workflow_artifact_summary"]["workflow_history_archive"])
|
||||
self.assertEqual(summary["workflow_artifact_summary"]["workflow_history_archive"]["long_range_trend_summary"]["status"], "active")
|
||||
self.assertEqual(summary["workflow_artifact_summary"]["workflow_history_archive"]["baseline_pin_summary"]["status"], "insufficient_history")
|
||||
self.assertIn("daily_groups", summary["workflow_artifact_summary"]["workflow_history_archive"])
|
||||
self.assertIn("session_groups", summary["workflow_artifact_summary"]["workflow_history_archive"])
|
||||
self.assertIn("bridge_summary", summary["workflow_artifact_summary"]["workflow_history_archive"])
|
||||
self.assertIn("assistant_loop_queue", summary)
|
||||
self.assertIn("assistant_loop_summary", summary)
|
||||
self.assertTrue(summary["assistant_loop_queue"])
|
||||
@@ -223,6 +239,10 @@ class OperatorWorkspaceServiceTests(unittest.TestCase):
|
||||
self.assertEqual(summary["decision_timeline"][0]["title"], "Current Packet")
|
||||
self.assertEqual(summary["evidence_chain"][0]["title"], "Decision Gate")
|
||||
self.assertEqual(summary["evidence_chain"][1]["title"], "Launch Mode")
|
||||
self.assertTrue(any(item["title"] == "Focus Candidate" for item in summary["evidence_chain"]))
|
||||
focus_evidence = next(item for item in summary["evidence_chain"] if item["title"] == "Focus Candidate")
|
||||
self.assertTrue(focus_evidence["focus_related"])
|
||||
self.assertEqual(focus_evidence["focus_candidate_id"], 18)
|
||||
self.assertEqual(summary["explanation_drilldown"][0]["title"], "Decision Logic")
|
||||
self.assertEqual(summary["reason_citations"][0]["source_path"], "decision_packet.why_allowed[0]")
|
||||
self.assertEqual(summary["learning_rollup"]["status"], "active")
|
||||
@@ -230,6 +250,9 @@ class OperatorWorkspaceServiceTests(unittest.TestCase):
|
||||
self.assertEqual(summary["review_entrypoints"]["status"], "active")
|
||||
self.assertEqual(summary["review_entrypoints"]["focus_candidate_id"], 18)
|
||||
self.assertGreaterEqual(summary["review_entrypoints"]["focus_note_count"], 1)
|
||||
self.assertIn("Candidate #18", summary["review_entrypoints"]["focus_note_prefill"])
|
||||
self.assertIn("focus-candidate", summary["review_entrypoints"]["focus_note_tags"])
|
||||
self.assertIn("focus_note_prompt", summary["review_entrypoints"])
|
||||
self.assertEqual(summary["review_entrypoints"]["actions"][0]["action_id"], "open_operator_notes")
|
||||
self.assertTrue(summary["review_entrypoints"]["note_templates"])
|
||||
self.assertIn("review_entry_action_queue", summary["review_entrypoints"])
|
||||
@@ -368,8 +391,15 @@ class OperatorWorkspaceServiceTests(unittest.TestCase):
|
||||
self.assertIn("digest_history_archive", summary["digest_surface"])
|
||||
self.assertIn("digest_history_archive_count", summary["digest_surface"])
|
||||
self.assertIn("digest_archive_compare", summary["digest_surface"])
|
||||
self.assertIn("focus_related_reminders", summary["digest_surface"])
|
||||
self.assertGreaterEqual(summary["digest_surface"]["focus_related_reminder_count"], 1)
|
||||
self.assertIsNotNone(summary["digest_surface"]["next_focus_related_reminder"])
|
||||
self.assertIn("digest_persistence_status", summary["digest_surface"])
|
||||
self.assertIn("next_digest_history_archive_review", summary["digest_surface"])
|
||||
self.assertIn("long_range_trend_summary", summary["digest_surface"]["digest_history_archive"])
|
||||
self.assertIn("baseline_pin_summary", summary["digest_surface"]["digest_history_archive"])
|
||||
self.assertEqual(summary["digest_surface"]["digest_history_archive"]["long_range_trend_summary"]["status"], "active")
|
||||
self.assertEqual(summary["digest_surface"]["digest_history_archive"]["baseline_pin_summary"]["status"], "insufficient_history")
|
||||
self.assertIn("next_high_value_change", summary["digest_surface"])
|
||||
self.assertIn("digest_review_back_actions", summary["digest_surface"])
|
||||
self.assertIn("digest_review_back_action_count", summary["digest_surface"])
|
||||
@@ -427,6 +457,7 @@ class OperatorWorkspaceServiceTests(unittest.TestCase):
|
||||
self.assertTrue(summary["digest_history_preview"])
|
||||
self.assertEqual(summary["digest_history_preview"][0]["kind"], "reminder")
|
||||
self.assertEqual(summary["personalized_reminders"][0]["severity"], "high")
|
||||
self.assertTrue(any(item.get("focus_related") for item in summary["personalized_reminders"]))
|
||||
self.assertIn("importance_score", summary["personalized_reminders"][0])
|
||||
self.assertIn("importance_template", summary["personalized_reminders"][0])
|
||||
self.assertIn("high_value", summary["personalized_reminders"][0])
|
||||
@@ -434,6 +465,9 @@ class OperatorWorkspaceServiceTests(unittest.TestCase):
|
||||
self.assertEqual(summary["setup_learning_rollup"]["top_lessons"][0]["lesson_type"], "journal_pattern")
|
||||
self.assertEqual(summary["setup_learning_rollup"]["session_code"], "LONDON")
|
||||
self.assertEqual(summary["setup_learning_rollup"]["session_code_source"], "focus_candidate")
|
||||
self.assertEqual(summary["setup_learning_rollup"]["focus_candidate_id"], 18)
|
||||
self.assertEqual(summary["setup_learning_rollup"]["focus_replay_scope"], "candidate_scoped")
|
||||
self.assertIn("focus_replay_prompt", summary["setup_learning_rollup"])
|
||||
self.assertIn("replay_statistics", summary["setup_learning_rollup"])
|
||||
self.assertIn("learning_attribution_summary", summary["setup_learning_rollup"])
|
||||
self.assertIn("failure_reason_attribution", summary["setup_learning_rollup"])
|
||||
@@ -591,6 +625,8 @@ class OperatorWorkspaceServiceTests(unittest.TestCase):
|
||||
self.assertTrue(summary["external_context_summary"]["announcement_titles"])
|
||||
self.assertIn("macro_news_calendar", summary["external_context_summary"])
|
||||
self.assertGreaterEqual(summary["external_context_summary"]["macro_news_calendar_count"], 3)
|
||||
self.assertIn("macro_news_provider_inventory", summary["external_context_summary"])
|
||||
self.assertGreaterEqual(summary["external_context_summary"]["macro_news_provider_count"], 1)
|
||||
self.assertIn("event_window_summary", summary["external_context_summary"])
|
||||
self.assertIn("event_window_status", summary["external_context_summary"])
|
||||
self.assertIn("event_window_prompt", summary["external_context_summary"])
|
||||
@@ -598,11 +634,24 @@ class OperatorWorkspaceServiceTests(unittest.TestCase):
|
||||
self.assertTrue(summary["external_context_summary"]["exchange_anomaly_window"])
|
||||
self.assertIn("window_type", summary["external_context_summary"]["macro_news_calendar"][0])
|
||||
self.assertIn("chart_readonly_sources", summary["external_context_summary"])
|
||||
self.assertIn("chart_provider_registry", summary["external_context_summary"])
|
||||
self.assertGreaterEqual(summary["external_context_summary"]["chart_provider_registry_count"], 3)
|
||||
self.assertIn("chart_stale_source_evidence", summary["external_context_summary"])
|
||||
self.assertGreaterEqual(summary["external_context_summary"]["chart_readonly_source_count"], 1)
|
||||
self.assertEqual(summary["external_context_summary"]["chart_stale_source_count"], 0)
|
||||
self.assertEqual(summary["external_context_summary"]["chart_adapter_mode"], "live_reference")
|
||||
self.assertEqual(summary["external_context_summary"]["chart_primary_mode"], "internal_review_packet_html")
|
||||
self.assertIn("exchange_context_sources", summary["external_context_summary"])
|
||||
self.assertGreaterEqual(summary["external_context_summary"]["exchange_context_source_count"], 3)
|
||||
self.assertIn("provider_health_cards", summary["external_context_summary"])
|
||||
self.assertGreaterEqual(summary["external_context_summary"]["provider_health_card_count"], 3)
|
||||
self.assertIn("provider_health_summary", summary["external_context_summary"])
|
||||
self.assertEqual(summary["external_context_summary"]["provider_health_summary"]["status"], "active")
|
||||
self.assertIn("visual_evidence_bridge_summary", summary["external_context_summary"])
|
||||
self.assertEqual(summary["external_context_summary"]["visual_evidence_bridge_summary"]["status"], "active")
|
||||
self.assertIn("visual_reference_cards", summary["external_context_summary"])
|
||||
self.assertGreaterEqual(summary["external_context_summary"]["visual_reference_card_count"], 3)
|
||||
self.assertIsNotNone(summary["external_context_summary"]["visual_reference_next_prompt"])
|
||||
self.assertIn("live", summary["external_context_summary"]["chart_context_prompt"].lower())
|
||||
self.assertIn("CPI", summary["external_context_summary"]["headline"])
|
||||
self.assertTrue(summary["no_trade_reason_items"])
|
||||
|
||||
@@ -140,7 +140,7 @@ class RunUntilBlockedTests(unittest.TestCase):
|
||||
changed_files=[
|
||||
"scripts/run_until_blocked.py",
|
||||
"tests/unit/test_run_until_blocked.py",
|
||||
"docs/AI_ICT_自动化流程使用教程_v1.md",
|
||||
"docs/archive/AI_ICT_自动化流程使用教程_v1.md",
|
||||
"SESSION.md",
|
||||
"TASKS.md",
|
||||
],
|
||||
@@ -1455,3 +1455,4 @@ class RunUntilBlockedTests(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import unittest
|
||||
|
||||
from src.services.operator.workflow_archive_projection import build_workflow_archive_operations_view
|
||||
|
||||
|
||||
class WorkflowArchiveProjectionTests(unittest.TestCase):
|
||||
def test_builds_daily_session_and_drift_views(self) -> None:
|
||||
items = [
|
||||
{
|
||||
"snapshot_id": 3,
|
||||
"created_ts": "2026-05-14T09:30:00+00:00",
|
||||
"current_session": "LONDON",
|
||||
"next_phase": "intraday",
|
||||
"next_artifact_kind": "intraday_digest",
|
||||
"artifact_count": 4,
|
||||
"ready_for_handoff_count": 2,
|
||||
"needs_update_count": 1,
|
||||
"pending_capture_count": 1,
|
||||
"workflow_body": "London plan ready.",
|
||||
"next_artifact_prompt": "Check intraday follow-through.",
|
||||
"phase_briefs": [
|
||||
{"phase": "pre_market", "archive_body": "Macro and session prep done."},
|
||||
{"phase": "intraday", "archive_body": "Watch the first liquidity sweep."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"snapshot_id": 2,
|
||||
"created_ts": "2026-05-13T15:45:00+00:00",
|
||||
"current_session": "NY_AM",
|
||||
"next_phase": "post_trade",
|
||||
"next_artifact_kind": "post_trade_recap",
|
||||
"artifact_count": 2,
|
||||
"ready_for_handoff_count": 1,
|
||||
"needs_update_count": 1,
|
||||
"pending_capture_count": 0,
|
||||
"workflow_body": "NY recap pending.",
|
||||
"next_artifact_prompt": "Write post-trade recap.",
|
||||
"phase_briefs": [
|
||||
{"phase": "pre_market", "archive_body": "Prep was lighter."},
|
||||
{"phase": "intraday", "archive_body": "NY sweep already happened."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"snapshot_id": 1,
|
||||
"created_ts": "2026-05-13T12:15:00+00:00",
|
||||
"current_session": "LONDON",
|
||||
"next_phase": "intraday",
|
||||
"next_artifact_kind": "intraday_digest",
|
||||
"artifact_count": 3,
|
||||
"ready_for_handoff_count": 1,
|
||||
"needs_update_count": 1,
|
||||
"pending_capture_count": 1,
|
||||
"workflow_body": "Earlier London handoff.",
|
||||
"next_artifact_prompt": "Re-check the London setup.",
|
||||
"phase_briefs": [
|
||||
{"phase": "pre_market", "archive_body": "Prep baseline."},
|
||||
{"phase": "intraday", "archive_body": "Initial sweep watch."},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
result = build_workflow_archive_operations_view(items)
|
||||
|
||||
self.assertEqual(result["daily_group_count"], 2)
|
||||
self.assertEqual(result["session_group_count"], 2)
|
||||
self.assertEqual(result["operations_summary"]["status"], "active")
|
||||
self.assertEqual(result["operations_summary"]["latest_group_date"], "2026-05-14")
|
||||
self.assertEqual(result["operations_summary"]["most_active_session"], "LONDON")
|
||||
self.assertEqual(result["multi_day_drift_review"]["status"], "active")
|
||||
self.assertEqual(result["multi_day_drift_review"]["drift_status"], "drift_detected")
|
||||
self.assertEqual(result["bridge_summary"]["status"], "active")
|
||||
self.assertEqual(result["bridge_summary"]["latest_snapshot_id"], 3)
|
||||
self.assertGreaterEqual(result["multi_day_drift_review"]["changed_phase_count"], 1)
|
||||
self.assertTrue(result["daily_groups"][0]["phase_names"])
|
||||
self.assertEqual(result["session_groups"][0]["session_code"], "LONDON")
|
||||
|
||||
def test_handles_empty_archive(self) -> None:
|
||||
result = build_workflow_archive_operations_view([])
|
||||
|
||||
self.assertEqual(result["daily_group_count"], 0)
|
||||
self.assertEqual(result["session_group_count"], 0)
|
||||
self.assertEqual(result["operations_summary"]["status"], "idle")
|
||||
self.assertEqual(result["multi_day_drift_review"]["status"], "insufficient_history")
|
||||
self.assertEqual(result["bridge_summary"]["status"], "idle")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user