Productize decision assistant archive and context cockpit batch

This commit is contained in:
Codex
2026-05-14 23:21:21 +08:00
parent 89f285ace3
commit 78ee9b3e54
47 changed files with 4400 additions and 317 deletions
+36 -5
View File
@@ -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
+23
View File
@@ -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、下单权限相关内容继续保持延期,不应混回当前分析助手主线。
+4 -3
View File
@@ -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())
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+6 -2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -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;
+105 -6
View File
@@ -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",
+37
View File
@@ -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()
+24 -27
View File
@@ -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"])
+2 -1
View File
@@ -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()