diff --git a/SESSION.md b/SESSION.md index 555272f..8b5b7b7 100644 --- a/SESSION.md +++ b/SESSION.md @@ -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 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 +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 diff --git a/TASKS.md b/TASKS.md index 6e574db..4432aaf 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 diff --git a/docs/AI_ICT_全自动开发执行流程_v1.md b/docs/AI_ICT_全自动开发执行流程_v1.md index b06b554..f07ddc7 100644 --- a/docs/AI_ICT_全自动开发执行流程_v1.md +++ b/docs/AI_ICT_全自动开发执行流程_v1.md @@ -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 换句话说: > **以后该项目默认全程采用自动化流程推进,除非遇到真实外部阻塞或必须用户拍板的产品决策。** + diff --git a/docs/AI_ICT_分析助手主线增强开发计划_2026-05-14_v1.md b/docs/AI_ICT_分析助手主线增强开发计划_2026-05-14_v1.md new file mode 100644 index 0000000..244f9e2 --- /dev/null +++ b/docs/AI_ICT_分析助手主线增强开发计划_2026-05-14_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 深化天然衔接 +- 不触碰延期项与实盘权限边界 diff --git a/docs/AI_ICT_需求文档剩余功能与延期项清单_2026-05-14_v1.md b/docs/AI_ICT_需求文档剩余功能与延期项清单_2026-05-14_v1.md new file mode 100644 index 0000000..71e8afc --- /dev/null +++ b/docs/AI_ICT_需求文档剩余功能与延期项清单_2026-05-14_v1.md @@ -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、下单权限相关内容继续保持延期,不应混回当前分析助手主线。 diff --git a/docs/AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md b/docs/archive/AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md similarity index 100% rename from docs/AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md rename to docs/archive/AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md diff --git a/docs/AI_ICT_TradingAgents_P0自动任务清单_v1.md b/docs/archive/AI_ICT_TradingAgents_P0自动任务清单_v1.md similarity index 100% rename from docs/AI_ICT_TradingAgents_P0自动任务清单_v1.md rename to docs/archive/AI_ICT_TradingAgents_P0自动任务清单_v1.md diff --git a/docs/AI_ICT_TradingAgents_P1自动任务清单_v1.md b/docs/archive/AI_ICT_TradingAgents_P1自动任务清单_v1.md similarity index 100% rename from docs/AI_ICT_TradingAgents_P1自动任务清单_v1.md rename to docs/archive/AI_ICT_TradingAgents_P1自动任务清单_v1.md diff --git a/docs/AI_ICT_TradingAgents_P2自动任务清单_v1.md b/docs/archive/AI_ICT_TradingAgents_P2自动任务清单_v1.md similarity index 100% rename from docs/AI_ICT_TradingAgents_P2自动任务清单_v1.md rename to docs/archive/AI_ICT_TradingAgents_P2自动任务清单_v1.md diff --git a/docs/AI_ICT_TradingAgents_P3自动任务清单_v1.md b/docs/archive/AI_ICT_TradingAgents_P3自动任务清单_v1.md similarity index 100% rename from docs/AI_ICT_TradingAgents_P3自动任务清单_v1.md rename to docs/archive/AI_ICT_TradingAgents_P3自动任务清单_v1.md diff --git a/docs/AI_ICT_TradingAgents_P4自动任务清单_v1.md b/docs/archive/AI_ICT_TradingAgents_P4自动任务清单_v1.md similarity index 100% rename from docs/AI_ICT_TradingAgents_P4自动任务清单_v1.md rename to docs/archive/AI_ICT_TradingAgents_P4自动任务清单_v1.md diff --git a/docs/AI_ICT_TradingAgents_P5自动任务清单_v1.md b/docs/archive/AI_ICT_TradingAgents_P5自动任务清单_v1.md similarity index 100% rename from docs/AI_ICT_TradingAgents_P5自动任务清单_v1.md rename to docs/archive/AI_ICT_TradingAgents_P5自动任务清单_v1.md diff --git a/docs/AI_ICT_TradingAgents_P6自动任务清单_v1.md b/docs/archive/AI_ICT_TradingAgents_P6自动任务清单_v1.md similarity index 100% rename from docs/AI_ICT_TradingAgents_P6自动任务清单_v1.md rename to docs/archive/AI_ICT_TradingAgents_P6自动任务清单_v1.md diff --git a/docs/AI_ICT_TradingAgents融合实施方案_最终版.md b/docs/archive/AI_ICT_TradingAgents融合实施方案_最终版.md similarity index 100% rename from docs/AI_ICT_TradingAgents融合实施方案_最终版.md rename to docs/archive/AI_ICT_TradingAgents融合实施方案_最终版.md diff --git a/docs/AI_ICT_repair_bridge_protocol_v1.md b/docs/archive/AI_ICT_repair_bridge_protocol_v1.md similarity index 100% rename from docs/AI_ICT_repair_bridge_protocol_v1.md rename to docs/archive/AI_ICT_repair_bridge_protocol_v1.md diff --git a/docs/AI_ICT_research_council脚手架与数据表草案_v1.md b/docs/archive/AI_ICT_research_council脚手架与数据表草案_v1.md similarity index 100% rename from docs/AI_ICT_research_council脚手架与数据表草案_v1.md rename to docs/archive/AI_ICT_research_council脚手架与数据表草案_v1.md diff --git a/docs/AI_ICT_外部repair_agent接入说明_v1.md b/docs/archive/AI_ICT_外部repair_agent接入说明_v1.md similarity index 100% rename from docs/AI_ICT_外部repair_agent接入说明_v1.md rename to docs/archive/AI_ICT_外部repair_agent接入说明_v1.md diff --git a/docs/AI_ICT_实盘交易分析决策助手未完成需求清单_2026-05-14_v1.md b/docs/archive/AI_ICT_实盘交易分析决策助手未完成需求清单_2026-05-14_v1.md similarity index 100% rename from docs/AI_ICT_实盘交易分析决策助手未完成需求清单_2026-05-14_v1.md rename to docs/archive/AI_ICT_实盘交易分析决策助手未完成需求清单_2026-05-14_v1.md diff --git a/docs/AI_ICT_盯盘告警开发任务清单_v1.md b/docs/archive/AI_ICT_盯盘告警开发任务清单_v1.md similarity index 100% rename from docs/AI_ICT_盯盘告警开发任务清单_v1.md rename to docs/archive/AI_ICT_盯盘告警开发任务清单_v1.md diff --git a/docs/AI_ICT_盯盘告警技术设计稿_v1.md b/docs/archive/AI_ICT_盯盘告警技术设计稿_v1.md similarity index 100% rename from docs/AI_ICT_盯盘告警技术设计稿_v1.md rename to docs/archive/AI_ICT_盯盘告警技术设计稿_v1.md diff --git a/docs/AI_ICT_盯盘告警架构图_v1.md b/docs/archive/AI_ICT_盯盘告警架构图_v1.md similarity index 100% rename from docs/AI_ICT_盯盘告警架构图_v1.md rename to docs/archive/AI_ICT_盯盘告警架构图_v1.md diff --git a/docs/AI_ICT_自动化执行层_P7监控成熟度与晋级路由_v1.md b/docs/archive/AI_ICT_自动化执行层_P7监控成熟度与晋级路由_v1.md similarity index 100% rename from docs/AI_ICT_自动化执行层_P7监控成熟度与晋级路由_v1.md rename to docs/archive/AI_ICT_自动化执行层_P7监控成熟度与晋级路由_v1.md diff --git a/docs/AI_ICT_自动化流程使用教程_v1.md b/docs/archive/AI_ICT_自动化流程使用教程_v1.md similarity index 100% rename from docs/AI_ICT_自动化流程使用教程_v1.md rename to docs/archive/AI_ICT_自动化流程使用教程_v1.md diff --git a/scripts/run_monitoring_alert_rollout.py b/scripts/run_monitoring_alert_rollout.py index 9deb845..144602b 100644 --- a/scripts/run_monitoring_alert_rollout.py +++ b/scripts/run_monitoring_alert_rollout.py @@ -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()) + diff --git a/scripts/run_until_blocked.py b/scripts/run_until_blocked.py index 225da02..7f71fc3 100644 --- a/scripts/run_until_blocked.py +++ b/scripts/run_until_blocked.py @@ -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()) + diff --git a/src/api/alerts.py b/src/api/alerts.py index 6a1ea3a..abaf141 100644 --- a/src/api/alerts.py +++ b/src/api/alerts.py @@ -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"), + } diff --git a/src/api/server.py b/src/api/server.py index ad7d895..de2021b 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -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) diff --git a/src/api/trading_runtime.py b/src/api/trading_runtime.py index 8efc782..dbbcb19 100644 --- a/src/api/trading_runtime.py +++ b/src/api/trading_runtime.py @@ -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: diff --git a/src/repositories/decision_assistant_repository.py b/src/repositories/decision_assistant_repository.py index a509304..ac13db3 100644 --- a/src/repositories/decision_assistant_repository.py +++ b/src/repositories/decision_assistant_repository.py @@ -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 diff --git a/src/services/market_state/exchange_status_provider.py b/src/services/market_state/exchange_status_provider.py index bfb0d0d..3bb1f50 100644 --- a/src/services/market_state/exchange_status_provider.py +++ b/src/services/market_state/exchange_status_provider.py @@ -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}) diff --git a/src/services/market_state/external_chart_provider.py b/src/services/market_state/external_chart_provider.py index 4ca452e..28053a6 100644 --- a/src/services/market_state/external_chart_provider.py +++ b/src/services/market_state/external_chart_provider.py @@ -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}) diff --git a/src/services/market_state/market_state_service.py b/src/services/market_state/market_state_service.py index c73ae38..2fd0200 100644 --- a/src/services/market_state/market_state_service.py +++ b/src/services/market_state/market_state_service.py @@ -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, + ) diff --git a/src/services/market_state/market_truth_supervisor.py b/src/services/market_state/market_truth_supervisor.py index 8798f96..2040cfb 100644 --- a/src/services/market_state/market_truth_supervisor.py +++ b/src/services/market_state/market_truth_supervisor.py @@ -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: diff --git a/src/services/market_state/news_event_provider.py b/src/services/market_state/news_event_provider.py index 1454651..ced9594 100644 --- a/src/services/market_state/news_event_provider.py +++ b/src/services/market_state/news_event_provider.py @@ -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 diff --git a/src/services/operator/archive_long_range_projection.py b/src/services/operator/archive_long_range_projection.py new file mode 100644 index 0000000..35e5645 --- /dev/null +++ b/src/services/operator/archive_long_range_projection.py @@ -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" diff --git a/src/services/operator/operator_workspace_service.py b/src/services/operator/operator_workspace_service.py index 3846a1a..3be1626 100644 --- a/src/services/operator/operator_workspace_service.py +++ b/src/services/operator/operator_workspace_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone import hashlib import json @@ -15,7 +16,20 @@ from src.services.execution import ExecutionService from src.services.journal import WeeklyReviewService from src.services.alerts import AlertQualitySummaryService, OperatorDigestService from src.services.market_state.external_chart_provider import BinanceChartReferenceProvider -from src.services.market_state.exchange_status_provider import OkxAnnouncementsProvider, OkxSystemStatusProvider +from src.services.market_state.exchange_status_provider import ( + DEFAULT_OKX_ANNOUNCEMENTS_URL, + DEFAULT_OKX_SYSTEM_STATUS_URL, + OkxAnnouncementsProvider, + OkxSystemStatusProvider, +) +from src.services.market_state.news_event_provider import build_default_news_providers +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 from src.services.readiness import LaunchReadinessService from src.services.research_council import CouncilOptimizationReviewService, ResearchCouncilService from src.services.trust import ModelTrustService @@ -150,19 +164,48 @@ class DecisionAssistantSummaryService: *, instrument_id: int, timeframe: str = "1m", + profile_key: str = DecisionAssistantRepository.DEFAULT_PROFILE_KEY, decision_packet: Optional[dict] = None, weekly_review: Optional[dict] = None, recent_alerts: Optional[list[dict]] = None, recent_notes: Optional[list[dict]] = None, recent_executions: Optional[list[dict]] = None, ) -> dict: - launch_readiness = self.launch_readiness_service.build_summary(instrument_id=instrument_id, timeframe=timeframe) - session_focus = self.session_focus_service.build_summary(instrument_id=instrument_id, limit=6) - operator_digest = self.operator_digest_service.build_digest(instrument_id=instrument_id, top_limit=5) - previous_packet = self.decision_repository.fetch_latest_decision_packet(instrument_id=instrument_id) - recent_candidates = self.candidate_repository.fetch_recent_candidates(instrument_id=instrument_id, limit=20) - exchange_status = self.exchange_status_provider.fetch_snapshot() - announcements_snapshot = self.announcements_provider.fetch_snapshot() + with ThreadPoolExecutor(max_workers=6) as executor: + launch_readiness_future = executor.submit( + _build_launch_readiness_summary, + self.launch_readiness_service, + instrument_id, + timeframe, + ) + session_focus_future = executor.submit( + self.session_focus_service.build_summary, + instrument_id=instrument_id, + limit=6, + ) + operator_digest_future = executor.submit( + self.operator_digest_service.build_digest, + instrument_id=instrument_id, + top_limit=5, + ) + previous_packet_future = executor.submit( + self.decision_repository.fetch_latest_decision_packet, + instrument_id=instrument_id, + ) + recent_candidates_future = executor.submit( + self.candidate_repository.fetch_recent_candidates, + instrument_id=instrument_id, + limit=20, + ) + exchange_status_future = executor.submit(self.exchange_status_provider.fetch_snapshot) + announcements_future = executor.submit(self.announcements_provider.fetch_snapshot) + launch_readiness = launch_readiness_future.result() + session_focus = session_focus_future.result() + operator_digest = operator_digest_future.result() + previous_packet = previous_packet_future.result() + recent_candidates = recent_candidates_future.result() + exchange_status = exchange_status_future.result() + announcements_snapshot = announcements_future.result() symbol_hint = _decision_assistant_symbol_hint( decision_packet=dict(decision_packet or {}), previous_packet=dict(previous_packet or {}), @@ -176,7 +219,19 @@ class DecisionAssistantSummaryService: persisted_digest_preference = _safe_fetch_digest_preference( repository=self.decision_assistant_repository, instrument_id=instrument_id, + profile_key=profile_key, ) + if not persisted_digest_preference: + persisted_digest_preference = { + "profile_key": profile_key, + "instrument_id": instrument_id, + "digest_mode": "all_reminders", + "preferred_history_kind": None, + "importance_threshold": 70, + "payload": {}, + "updated_ts": None, + "status": "default", + } persisted_digest_preference_profiles = _safe_fetch_digest_preference_profiles( repository=self.decision_assistant_repository, instrument_id=instrument_id, @@ -184,10 +239,12 @@ class DecisionAssistantSummaryService: persisted_digest_history = _safe_fetch_digest_history( repository=self.decision_assistant_repository, instrument_id=instrument_id, + profile_key=profile_key, ) persisted_workflow_history = _safe_fetch_workflow_history( repository=self.decision_assistant_repository, instrument_id=instrument_id, + profile_key=profile_key, ) persisted_focus_candidate = _safe_resolve_focus_candidate( candidate_repository=self.candidate_repository, @@ -223,6 +280,7 @@ class DecisionAssistantSummaryService: recent_alerts=list(recent_alerts or []), recent_notes=list(recent_notes or []), recent_executions=list(recent_executions or []), + profile_key=profile_key, persisted_digest_preference=dict(persisted_digest_preference or {}), persisted_digest_preference_profiles=list(persisted_digest_preference_profiles or []), persisted_digest_history=list(persisted_digest_history or []), @@ -232,20 +290,24 @@ class DecisionAssistantSummaryService: persistence_result = _safe_persist_digest_history_snapshot( repository=self.decision_assistant_repository, instrument_id=instrument_id, + profile_key=profile_key, summary=summary, ) workflow_persistence_result = _safe_persist_workflow_history_snapshot( repository=self.decision_assistant_repository, instrument_id=instrument_id, + profile_key=profile_key, summary=summary, ) refreshed_digest_history = _safe_fetch_digest_history( repository=self.decision_assistant_repository, instrument_id=instrument_id, + profile_key=profile_key, ) refreshed_workflow_history = _safe_fetch_workflow_history( repository=self.decision_assistant_repository, instrument_id=instrument_id, + profile_key=profile_key, ) digest_history_archive = _build_decision_assistant_digest_history_archive(refreshed_digest_history) workflow_history_archive = _build_decision_assistant_workflow_history_archive(refreshed_workflow_history) @@ -256,6 +318,9 @@ class DecisionAssistantSummaryService: summary["digest_persistence_status"] = persistence_result.get("status") summary["workflow_history_archive"] = workflow_history_archive summary["workflow_archive_compare"] = workflow_archive_compare + summary["workflow_archive_operations_summary"] = workflow_history_archive.get("operations_summary") + summary["workflow_archive_multi_day_drift_review"] = workflow_history_archive.get("multi_day_drift_review") + summary["workflow_archive_bridge_summary"] = workflow_history_archive.get("bridge_summary") summary["workflow_persistence_status"] = workflow_persistence_result.get("status") digest_surface = dict(summary.get("digest_surface") or {}) digest_surface["digest_history_archive"] = digest_history_archive @@ -269,6 +334,9 @@ class DecisionAssistantSummaryService: workflow_artifact_summary["workflow_history_archive_count"] = workflow_history_archive.get("snapshot_count") or 0 workflow_artifact_summary["next_workflow_history_review"] = workflow_history_archive.get("next_review_prompt") workflow_artifact_summary["workflow_archive_compare"] = workflow_archive_compare + workflow_artifact_summary["workflow_archive_operations_summary"] = workflow_history_archive.get("operations_summary") + workflow_artifact_summary["workflow_archive_multi_day_drift_review"] = workflow_history_archive.get("multi_day_drift_review") + workflow_artifact_summary["workflow_archive_bridge_summary"] = workflow_history_archive.get("bridge_summary") workflow_artifact_summary["workflow_persistence_status"] = workflow_persistence_result.get("status") summary["workflow_artifact_summary"] = workflow_artifact_summary return summary @@ -304,7 +372,12 @@ class ReviewPacketService: research_council_service=self.research_council_service, ) - def build_packet(self, *, instrument_id: int) -> dict: + def build_packet( + self, + *, + instrument_id: int, + profile_key: str = DecisionAssistantRepository.DEFAULT_PROFILE_KEY, + ) -> dict: decision_packet = self.decision_service.build_preview(instrument_id=instrument_id) weekly_review = self.weekly_review_service.build_summary(instrument_id=instrument_id) recent_alerts = self.decision_repository.fetch_recent_decision_alerts(instrument_id=instrument_id, limit=10) @@ -328,6 +401,7 @@ class ReviewPacketService: "decision_assistant_summary": self.decision_assistant_service.build_summary( instrument_id=instrument_id, timeframe="1m", + profile_key=profile_key, decision_packet=decision_packet, weekly_review=weekly_review, recent_alerts=recent_alerts, @@ -337,9 +411,25 @@ class ReviewPacketService: } -def _safe_fetch_digest_preference(*, repository: DecisionAssistantRepository, instrument_id: int) -> dict: +def _build_launch_readiness_summary(service, instrument_id: int, timeframe: str) -> dict: try: - return dict(repository.fetch_preference(instrument_id=instrument_id) or {}) + return service.build_summary( + instrument_id=instrument_id, + timeframe=timeframe, + cached_news_only=True, + ) + except TypeError: + return service.build_summary(instrument_id=instrument_id, timeframe=timeframe) + + +def _safe_fetch_digest_preference( + *, + repository: DecisionAssistantRepository, + instrument_id: int, + profile_key: str = DecisionAssistantRepository.DEFAULT_PROFILE_KEY, +) -> dict: + try: + return dict(repository.fetch_preference(instrument_id=instrument_id, profile_key=profile_key) or {}) except Exception: return {} @@ -351,16 +441,40 @@ def _safe_fetch_digest_preference_profiles(*, repository: DecisionAssistantRepos return [] -def _safe_fetch_digest_history(*, repository: DecisionAssistantRepository, instrument_id: int, limit: int = 20) -> list[dict]: +def _safe_fetch_digest_history( + *, + repository: DecisionAssistantRepository, + instrument_id: int, + profile_key: str = DecisionAssistantRepository.DEFAULT_PROFILE_KEY, + limit: int = 20, +) -> list[dict]: try: - return list(repository.fetch_recent_digest_history(instrument_id=instrument_id, limit=limit) or []) + return list( + repository.fetch_recent_digest_history( + instrument_id=instrument_id, + profile_key=profile_key, + limit=limit, + ) or [] + ) except Exception: return [] -def _safe_fetch_workflow_history(*, repository: DecisionAssistantRepository, instrument_id: int, limit: int = 20) -> list[dict]: +def _safe_fetch_workflow_history( + *, + repository: DecisionAssistantRepository, + instrument_id: int, + profile_key: str = DecisionAssistantRepository.DEFAULT_PROFILE_KEY, + limit: int = 20, +) -> list[dict]: try: - return list(repository.fetch_recent_workflow_history(instrument_id=instrument_id, limit=limit) or []) + return list( + repository.fetch_recent_workflow_history( + instrument_id=instrument_id, + profile_key=profile_key, + limit=limit, + ) or [] + ) except Exception: return [] @@ -392,6 +506,7 @@ def _safe_persist_digest_history_snapshot( *, repository: DecisionAssistantRepository, instrument_id: int, + profile_key: str = DecisionAssistantRepository.DEFAULT_PROFILE_KEY, summary: dict, ) -> dict: digest_surface = dict(summary.get("digest_surface") or {}) @@ -415,11 +530,12 @@ def _safe_persist_digest_history_snapshot( } signature = hashlib.sha1(json.dumps(snapshot_payload, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")).hexdigest() try: - latest = repository.fetch_latest_digest_history(instrument_id=instrument_id) + latest = repository.fetch_latest_digest_history(instrument_id=instrument_id, profile_key=profile_key) if latest and str(latest.get("snapshot_signature") or "") == signature: return {"status": "duplicate_skipped", "snapshot_signature": signature} repository.insert_digest_history( instrument_id=instrument_id, + profile_key=profile_key, digest_mode=str(preference_profile.get("default_mode") or "all_reminders"), preferred_history_kind=preference_profile.get("preferred_history_kind"), top_summary=str(digest_surface.get("summary") or ""), @@ -435,6 +551,7 @@ def _safe_persist_workflow_history_snapshot( *, repository: DecisionAssistantRepository, instrument_id: int, + profile_key: str = DecisionAssistantRepository.DEFAULT_PROFILE_KEY, summary: dict, ) -> dict: workflow_artifact_summary = dict(summary.get("workflow_artifact_summary") or {}) @@ -478,11 +595,12 @@ def _safe_persist_workflow_history_snapshot( } signature = hashlib.sha1(json.dumps(snapshot_payload, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")).hexdigest() try: - latest = repository.fetch_latest_workflow_history(instrument_id=instrument_id) + latest = repository.fetch_latest_workflow_history(instrument_id=instrument_id, profile_key=profile_key) if latest and str(latest.get("snapshot_signature") or "") == signature: return {"status": "duplicate_skipped", "snapshot_signature": signature} repository.insert_workflow_history( instrument_id=instrument_id, + profile_key=profile_key, current_session=str(summary.get("current_session") or ""), next_phase=str(workflow_artifact_summary.get("next_artifact_phase") or ""), next_artifact_kind=str(workflow_artifact_summary.get("next_artifact_kind") or ""), @@ -559,6 +677,7 @@ def build_decision_assistant_summary( *, instrument_id: int, timeframe: str, + profile_key: str, decision_packet: dict, weekly_review: dict, launch_readiness: dict, @@ -681,6 +800,7 @@ def build_decision_assistant_summary( operator_digest=operator_digest, weekly_review=weekly_review, current_session=current_session, + focus_candidate=persisted_focus_candidate, ) explanation_drilldown = _build_decision_assistant_explanation_drilldown( decision_packet=decision_packet, @@ -2088,6 +2208,7 @@ def _build_decision_assistant_external_context_summary( exchange_events=exchange_events, announcements=announcements, ) + macro_news_provider_inventory = _build_external_context_macro_news_provider_inventory(news_window=news_window) event_window_summary = _build_external_context_event_window_summary( calendar_events=macro_news_calendar, news_window=news_window, @@ -2098,7 +2219,28 @@ def _build_decision_assistant_external_context_summary( announcements_snapshot=announcements_snapshot, external_chart_snapshot=external_chart_snapshot, ) + chart_provider_registry = _build_external_context_chart_provider_registry(external_chart_snapshot=external_chart_snapshot) chart_stale_source_evidence = _build_external_context_chart_stale_source_evidence(chart_readonly_sources) + exchange_context_sources = _build_external_context_exchange_surface_inventory( + exchange_status=exchange_status, + announcements_snapshot=announcements_snapshot, + ) + visual_reference_cards = _build_external_context_visual_reference_cards( + macro_news_provider_inventory=macro_news_provider_inventory, + exchange_context_sources=exchange_context_sources, + chart_provider_registry=chart_provider_registry, + macro_news_calendar=macro_news_calendar, + ) + provider_health_cards = _build_external_context_provider_health_cards( + macro_news_provider_inventory=macro_news_provider_inventory, + chart_provider_registry=chart_provider_registry, + exchange_context_sources=exchange_context_sources, + ) + provider_health_summary = _build_external_context_provider_health_summary(provider_health_cards) + visual_evidence_bridge_summary = _build_external_context_visual_evidence_bridge_summary( + visual_reference_cards=visual_reference_cards, + provider_health_cards=provider_health_cards, + ) chart_adapter_mode = _external_context_chart_adapter_mode(chart_readonly_sources) return { "status": "active" if active_events or external_waiting_items or council_watch_items or operator_questions or next_confirmation_targets or exchange_events or announcements else "idle", @@ -2117,6 +2259,9 @@ def _build_decision_assistant_external_context_summary( "announcement_titles": [str(item.get("title") or "announcement") for item in announcements[:4]], "macro_news_calendar": macro_news_calendar[:8], "macro_news_calendar_count": len(macro_news_calendar), + "macro_news_provider_inventory": macro_news_provider_inventory, + "macro_news_provider_count": len(macro_news_provider_inventory), + "macro_news_provider_status_counts": _external_context_inventory_status_counts(macro_news_provider_inventory), "event_window_summary": event_window_summary, "event_window_status": event_window_summary.get("status"), "event_window_prompt": event_window_summary.get("prompt"), @@ -2125,11 +2270,25 @@ def _build_decision_assistant_external_context_summary( "exchange_anomaly_window": bool(event_window_summary.get("exchange_anomaly_window")), "chart_readonly_sources": chart_readonly_sources, "chart_readonly_source_count": len(chart_readonly_sources), + "chart_provider_registry": chart_provider_registry, + "chart_provider_registry_count": len(chart_provider_registry), + "chart_provider_registry_status_counts": _external_context_inventory_status_counts(chart_provider_registry), "chart_stale_source_evidence": chart_stale_source_evidence, "chart_stale_source_count": len(chart_stale_source_evidence), "chart_adapter_mode": chart_adapter_mode, "chart_primary_mode": "internal_review_packet_html", "chart_context_prompt": _external_context_chart_context_prompt(chart_readonly_sources), + "exchange_context_sources": exchange_context_sources, + "exchange_context_source_count": len(exchange_context_sources), + "exchange_context_status_counts": _external_context_inventory_status_counts(exchange_context_sources), + "provider_health_cards": provider_health_cards, + "provider_health_card_count": len(provider_health_cards), + "provider_health_status_counts": _external_context_inventory_status_counts(provider_health_cards), + "provider_health_summary": provider_health_summary, + "visual_reference_cards": visual_reference_cards, + "visual_reference_card_count": len(visual_reference_cards), + "visual_reference_next_prompt": _external_context_visual_reference_prompt(visual_reference_cards), + "visual_evidence_bridge_summary": visual_evidence_bridge_summary, "source_cards": [ { "source_type": "news_provider", @@ -2151,11 +2310,26 @@ def _build_decision_assistant_external_context_summary( "title": "macro_news_calendar", "detail": event_window_summary.get("status") or "unknown", }, + { + "source_type": "news_provider_inventory", + "title": "macro_news_providers", + "detail": f"providers={len(macro_news_provider_inventory)} active={news_window.get('provider_name') or 'unknown'}", + }, { "source_type": "chart_readonly_inventory", "title": "chart_readonly_sources", "detail": f"sources={len(chart_readonly_sources)} stale={len(chart_stale_source_evidence)}", }, + { + "source_type": "chart_provider_registry", + "title": "chart_provider_registry", + "detail": f"providers={len(chart_provider_registry)} visual_cards={len(visual_reference_cards)}", + }, + { + "source_type": "exchange_surface_inventory", + "title": "exchange_context_sources", + "detail": f"sources={len(exchange_context_sources)} anomaly={event_window_summary.get('status') or 'unknown'}", + }, *[ { "source_type": "external_waiting_item", @@ -2219,6 +2393,20 @@ def _build_decision_assistant_review_entrypoints( ] latest_focus_note = dict(focus_notes[0] or {}) if focus_notes else {} next_actions = list(weekly_review.get("next_week_actions") or []) + focus_note_prefill = ( + f"[focus-candidate] Candidate #{focus_candidate.get('id')} | " + f"session={focus_candidate.get('session_code') or 'OFF_HOURS'} | " + f"model={focus_candidate.get('model_code') or 'unknown'} | " + f"{focus_candidate.get('ai_explanation') or focus_candidate.get('narrative') or 'capture next focus evidence'}" + if focus_candidate + else None + ) + focus_note_tags = [ + "focus-candidate", + f"candidate:{focus_candidate.get('id')}", + f"session:{focus_candidate.get('session_code') or 'OFF_HOURS'}", + f"model:{focus_candidate.get('model_code') or 'unknown'}", + ] if focus_candidate else [] note_templates = _build_review_entry_note_templates( focus_mode=focus_mode, top_opportunities=top_opportunities, @@ -2298,6 +2486,14 @@ def _build_decision_assistant_review_entrypoints( "focus_note_count": len(focus_notes), "focus_latest_note_author": latest_focus_note.get("author"), "focus_latest_note_ts": latest_focus_note.get("created_ts"), + "focus_note_prefill": focus_note_prefill, + "focus_note_scope": "workbench" if focus_candidate else None, + "focus_note_tags": focus_note_tags, + "focus_note_prompt": ( + f"Capture a structured note for Candidate #{focus_candidate.get('id')} before switching context." + if focus_candidate + else None + ), "note_templates": note_templates, "recommended_template_id": recommended_template_id, "template_summary": _build_review_entry_template_summary(note_templates, recommended_template_id=recommended_template_id), @@ -3462,6 +3658,407 @@ def _build_external_context_macro_news_calendar( return calendar +def _build_external_context_macro_news_provider_inventory(*, news_window: dict) -> list[dict]: + providers = [] + active_provider_name = str(news_window.get("provider_name") or "") + active_freshness = str(news_window.get("freshness_code") or "unknown") + active_error = news_window.get("error") + configured_names = [str(provider.provider_name) for provider in build_default_news_providers()] + for provider_name in configured_names: + status = "active_provider" if provider_name == active_provider_name else "enabled_backup" + providers.append( + { + "provider_id": provider_name, + "provider_name": provider_name, + "status": status, + "freshness_status": active_freshness if provider_name == active_provider_name else "configured_backup", + "error": active_error if provider_name == active_provider_name else None, + "surface_url": _external_context_news_provider_surface_url(provider_name), + "visual_prompt": ( + f"Review {provider_name} first for active macro context." + if provider_name == active_provider_name + else f"Keep {provider_name} as a backup macro/news provider reference." + ), + } + ) + if not providers and active_provider_name: + providers.append( + { + "provider_id": active_provider_name, + "provider_name": active_provider_name, + "status": "active_provider", + "freshness_status": active_freshness, + "error": active_error, + "surface_url": _external_context_news_provider_surface_url(active_provider_name), + "visual_prompt": f"Review {active_provider_name} for active macro context.", + } + ) + return providers + + +def _external_context_news_provider_surface_url(provider_name: str) -> str | None: + if provider_name == "tradingeconomics_snapshot": + return "https://api.tradingeconomics.com/calendar" + if provider_name == "forex_factory_public_feed": + return "https://nfs.faireconomy.media/ff_calendar_thisweek.json" + return None + + +def _external_context_chart_symbol_key(symbol: str) -> str: + return str(symbol or "BTC-USDT").replace("-", "").replace("/", "").upper() + + +def _external_context_chart_interval_code(interval: str) -> str: + mapping = { + "1m": "1", + "5m": "5", + "15m": "15", + "1h": "60", + "4h": "240", + "1d": "D", + } + return mapping.get(str(interval or "1h").lower(), "60") + + +def _build_external_context_chart_provider_registry(*, external_chart_snapshot: dict) -> list[dict]: + snapshot = dict(external_chart_snapshot or {}) + symbol = str(snapshot.get("symbol") or "BTC-USDT") + interval = str(snapshot.get("interval") or "1h") + api_symbol = _external_context_chart_symbol_key(symbol) + interval_code = _external_context_chart_interval_code(interval) + binance_url = str(snapshot.get("chart_url") or f"https://www.binance.com/en/trade/{api_symbol.replace('USDT', '_USDT')}?type=spot") + okx_symbol = symbol.lower().replace("-", "-") + tradingview_symbol = f"BINANCE:{api_symbol}" + registry = [ + { + "provider_id": "binance_public_chart_reference", + "provider_name": "binance_public_chart_reference", + "status": snapshot.get("status") or "idle", + "freshness_status": snapshot.get("freshness_code") or "provider_fallback_empty", + "cache_state": snapshot.get("cache_state"), + "symbol": symbol, + "interval": interval, + "target_url": binance_url, + "embed_url": None, + "capture_supported": True, + "visual_type": "external_chart_link", + }, + { + "provider_id": "okx_public_chart_reference", + "provider_name": "okx_public_chart_reference", + "status": "reference_link", + "freshness_status": "reference_only", + "cache_state": None, + "symbol": symbol, + "interval": interval, + "target_url": f"https://www.okx.com/trade-spot/{okx_symbol}", + "embed_url": None, + "capture_supported": True, + "visual_type": "external_chart_link", + }, + { + "provider_id": "tradingview_public_chart_reference", + "provider_name": "tradingview_public_chart_reference", + "status": "reference_link", + "freshness_status": "reference_only", + "cache_state": None, + "symbol": symbol, + "interval": interval, + "target_url": f"https://www.tradingview.com/chart/?symbol={tradingview_symbol}", + "embed_url": ( + f"https://s.tradingview.com/widgetembed/?symbol={tradingview_symbol}" + f"&interval={interval_code}&theme=dark&style=1&timezone=Etc%2FUTC" + ), + "capture_supported": True, + "visual_type": "external_chart_embed", + }, + ] + for item in registry: + item["visual_prompt"] = ( + f"Open {item.get('provider_name')} for {item.get('symbol')} / {item.get('interval')} read-only chart context." + ) + return registry + + +def _build_external_context_exchange_surface_inventory(*, exchange_status: dict, announcements_snapshot: dict) -> list[dict]: + exchange_status = dict(exchange_status or {}) + announcements_snapshot = dict(announcements_snapshot or {}) + return [ + { + "surface_id": "okx_system_status_api", + "surface_type": "exchange_status_api", + "provider_name": exchange_status.get("provider_name") or "okx_system_status", + "status": exchange_status.get("status") or "unknown", + "freshness_status": exchange_status.get("freshness_code") or "unknown", + "cache_state": exchange_status.get("cache_state"), + "target_url": DEFAULT_OKX_SYSTEM_STATUS_URL, + "capture_supported": True, + "visual_prompt": "Review OKX system status before trusting exchange anomaly windows.", + }, + { + "surface_id": "okx_announcements_api", + "surface_type": "announcement_api", + "provider_name": announcements_snapshot.get("provider_name") or "okx_announcements", + "status": announcements_snapshot.get("status") or "unknown", + "freshness_status": announcements_snapshot.get("freshness_code") or "unknown", + "cache_state": announcements_snapshot.get("cache_state"), + "target_url": DEFAULT_OKX_ANNOUNCEMENTS_URL, + "capture_supported": True, + "visual_prompt": "Review OKX announcements before weighing listing or maintenance headlines.", + }, + { + "surface_id": "okx_help_announcements_page", + "surface_type": "announcement_page", + "provider_name": "okx_help_announcements_page", + "status": "reference_link", + "freshness_status": "reference_only", + "cache_state": None, + "target_url": "https://www.okx.com/help/section/announcements-new", + "capture_supported": True, + "visual_prompt": "Open the human-facing OKX announcements page when the API headline needs manual review.", + }, + ] + + +def _external_context_inventory_status_counts(items: list[dict]) -> dict: + counts: dict[str, int] = {} + for item in items: + status = str(item.get("status") or "unknown") + counts[status] = int(counts.get(status) or 0) + 1 + return counts + + +def _external_context_provider_health_status(*, status: str, freshness_status: str, cache_state: str | None, target_url: str | None) -> str: + status = str(status or "unknown") + freshness_status = str(freshness_status or "unknown") + cache_state = str(cache_state or "") + if freshness_status in {"source_degraded", "missing_source"} or status in {"error"}: + return "degraded" + if cache_state in {"cached_fallback", "empty_fallback"} or freshness_status in {"provider_fallback_empty"}: + return "fallback" + if freshness_status in {"reference_only", "configured_backup"} or status in {"reference_link", "enabled_backup"}: + return "reference_ready" if target_url else "reference_only" + if status in {"active_provider", "active", "available"} or freshness_status in {"source_current", "provider_fresh", "fresh"}: + return "healthy_live" + return "monitor" + + +def _build_external_context_provider_health_cards( + *, + macro_news_provider_inventory: list[dict], + chart_provider_registry: list[dict], + exchange_context_sources: list[dict], +) -> list[dict]: + cards = [] + for provider in macro_news_provider_inventory: + cards.append( + { + "health_id": provider.get("provider_id"), + "title": provider.get("provider_name"), + "provider_name": provider.get("provider_name"), + "surface_group": "macro_news", + "status": _external_context_provider_health_status( + status=str(provider.get("status") or ""), + freshness_status=str(provider.get("freshness_status") or ""), + cache_state=None, + target_url=provider.get("surface_url"), + ), + "source_status": provider.get("status"), + "freshness_status": provider.get("freshness_status"), + "cache_state": None, + "target_url": provider.get("surface_url"), + "embed_url": None, + "capture_supported": False, + "health_reason": provider.get("error") or provider.get("visual_prompt"), + "next_visual_prompt": provider.get("visual_prompt"), + } + ) + for source in chart_provider_registry: + cards.append( + { + "health_id": source.get("provider_id"), + "title": source.get("provider_name"), + "provider_name": source.get("provider_name"), + "surface_group": "chart_reference", + "status": _external_context_provider_health_status( + status=str(source.get("status") or ""), + freshness_status=str(source.get("freshness_status") or ""), + cache_state=source.get("cache_state"), + target_url=source.get("target_url"), + ), + "source_status": source.get("status"), + "freshness_status": source.get("freshness_status"), + "cache_state": source.get("cache_state"), + "target_url": source.get("target_url"), + "embed_url": source.get("embed_url"), + "capture_supported": bool(source.get("capture_supported")), + "health_reason": source.get("visual_prompt"), + "next_visual_prompt": source.get("visual_prompt"), + } + ) + for source in exchange_context_sources: + cards.append( + { + "health_id": source.get("surface_id"), + "title": source.get("provider_name"), + "provider_name": source.get("provider_name"), + "surface_group": "exchange_surface", + "status": _external_context_provider_health_status( + status=str(source.get("status") or ""), + freshness_status=str(source.get("freshness_status") or ""), + cache_state=source.get("cache_state"), + target_url=source.get("target_url"), + ), + "source_status": source.get("status"), + "freshness_status": source.get("freshness_status"), + "cache_state": source.get("cache_state"), + "target_url": source.get("target_url"), + "embed_url": None, + "capture_supported": bool(source.get("capture_supported")), + "health_reason": source.get("visual_prompt"), + "next_visual_prompt": source.get("visual_prompt"), + } + ) + order = {"degraded": 0, "fallback": 1, "healthy_live": 2, "reference_ready": 3, "reference_only": 4, "monitor": 5} + cards.sort(key=lambda item: (order.get(str(item.get("status") or "monitor"), 9), str(item.get("title") or ""))) + return cards[:10] + + +def _build_external_context_provider_health_summary(cards: list[dict]) -> dict: + if not cards: + return { + "status": "idle", + "provider_count": 0, + "capture_ready_count": 0, + "embed_ready_count": 0, + "next_health_target": None, + "next_health_prompt": None, + } + capture_ready_count = sum(1 for item in cards if item.get("capture_supported")) + embed_ready_count = sum(1 for item in cards if item.get("embed_url")) + degraded = [item for item in cards if str(item.get("status") or "") == "degraded"] + fallback = [item for item in cards if str(item.get("status") or "") == "fallback"] + next_target = dict((degraded or fallback or cards)[0] or {}) + return { + "status": "active", + "provider_count": len(cards), + "capture_ready_count": capture_ready_count, + "embed_ready_count": embed_ready_count, + "degraded_count": len(degraded), + "fallback_count": len(fallback), + "next_health_target": next_target.get("title"), + "next_health_status": next_target.get("status"), + "next_health_prompt": next_target.get("next_visual_prompt"), + } + + +def _build_external_context_visual_evidence_bridge_summary( + *, + visual_reference_cards: list[dict], + provider_health_cards: list[dict], +) -> dict: + if not visual_reference_cards and not provider_health_cards: + return { + "status": "idle", + "bridge_card_count": 0, + "capture_ready_count": 0, + "embed_ready_count": 0, + "next_bridge_target": None, + "next_bridge_prompt": None, + } + capture_ready_count = sum(1 for item in visual_reference_cards if item.get("capture_supported")) + embed_ready_count = sum(1 for item in visual_reference_cards if item.get("embed_url")) + next_card = dict((visual_reference_cards or provider_health_cards or [{}])[0] or {}) + return { + "status": "active", + "bridge_card_count": len(visual_reference_cards), + "capture_ready_count": capture_ready_count, + "embed_ready_count": embed_ready_count, + "next_bridge_target": next_card.get("title") or next_card.get("provider_name"), + "next_bridge_prompt": next_card.get("visual_prompt") or next_card.get("next_visual_prompt"), + "next_bridge_url": next_card.get("target_url") or next_card.get("embed_url"), + } + + +def _build_external_context_visual_reference_cards( + *, + macro_news_provider_inventory: list[dict], + exchange_context_sources: list[dict], + chart_provider_registry: list[dict], + macro_news_calendar: list[dict], +) -> list[dict]: + cards = [] + for source in chart_provider_registry[:3]: + cards.append( + { + "visual_id": source.get("provider_id"), + "title": source.get("provider_name"), + "visual_type": source.get("visual_type") or "external_chart_link", + "provider_name": source.get("provider_name"), + "status": source.get("status"), + "target_url": source.get("target_url"), + "embed_url": source.get("embed_url"), + "capture_supported": bool(source.get("capture_supported")), + "summary": f"{source.get('symbol') or 'n/a'} / {source.get('interval') or 'n/a'}", + "visual_prompt": source.get("visual_prompt"), + } + ) + for source in exchange_context_sources[:3]: + cards.append( + { + "visual_id": source.get("surface_id"), + "title": source.get("provider_name"), + "visual_type": "exchange_surface_link", + "provider_name": source.get("provider_name"), + "status": source.get("status"), + "target_url": source.get("target_url"), + "embed_url": None, + "capture_supported": bool(source.get("capture_supported")), + "summary": f"{source.get('surface_type')} / {source.get('freshness_status') or 'unknown'}", + "visual_prompt": source.get("visual_prompt"), + } + ) + for provider in macro_news_provider_inventory[:2]: + cards.append( + { + "visual_id": provider.get("provider_id"), + "title": provider.get("provider_name"), + "visual_type": "macro_provider_link", + "provider_name": provider.get("provider_name"), + "status": provider.get("status"), + "target_url": provider.get("surface_url"), + "embed_url": None, + "capture_supported": False, + "summary": provider.get("freshness_status") or "unknown", + "visual_prompt": provider.get("visual_prompt"), + } + ) + if macro_news_calendar: + first_event = dict(macro_news_calendar[0] or {}) + cards.append( + { + "visual_id": "macro_news_window_summary", + "title": first_event.get("title") or "macro_news_window", + "visual_type": "macro_event_focus", + "provider_name": first_event.get("source_type"), + "status": first_event.get("window_type"), + "target_url": None, + "embed_url": None, + "capture_supported": False, + "summary": first_event.get("detail") or first_event.get("impact") or "event_window", + "visual_prompt": f"Keep {first_event.get('title') or 'macro event'} visible while evaluating high-volatility windows.", + } + ) + return cards[:8] + + +def _external_context_visual_reference_prompt(cards: list[dict]) -> str | None: + if not cards: + return None + top = dict(cards[0] or {}) + return top.get("visual_prompt") or f"Open {top.get('title') or top.get('provider_name') or 'external context'} for a read-only visual cross-check." + + def _external_context_event_impact(*, item: dict, title: str) -> str: explicit = str(item.get("impact") or item.get("severity") or item.get("level") or "").lower() if explicit in {"critical", "high"}: @@ -3603,6 +4200,8 @@ def _build_external_context_chart_stale_source_evidence(chart_readonly_sources: and str(source.get("status") or "") == "optional_reference" ): continue + if str(source.get("freshness_status") or "") == "reference_only": + continue freshness = str(source.get("freshness_status") or "") status = str(source.get("status") or "") if freshness in {"missing_source", "source_degraded"} or status in {"error"}: @@ -3665,14 +4264,46 @@ def _build_decision_assistant_personalized_reminders( reminders: list[dict] = [] focus_candidate = dict(focus_candidate or {}) if focus_candidate: + focus_missing = [str(item) for item in list(focus_candidate.get("missing_confirmations") or []) if item] reminders.append( { "severity": "high", "title": f"Stay on Candidate #{focus_candidate.get('id')}", "message": focus_candidate.get("ai_explanation") or focus_candidate.get("narrative") or "persisted_focus_candidate", - "source": "decision_packet", + "source": "focus_candidate", + "focus_candidate_id": focus_candidate.get("id"), + "focus_related": True, + "session_code": focus_candidate.get("session_code"), + "model_code": focus_candidate.get("model_code"), } ) + if focus_missing: + reminders.append( + { + "severity": "high", + "title": f"Resolve Candidate #{focus_candidate.get('id')} confirmations", + "message": ", ".join(focus_missing[:3]), + "source": "focus_candidate", + "focus_candidate_id": focus_candidate.get("id"), + "focus_related": True, + "session_code": focus_candidate.get("session_code"), + "model_code": focus_candidate.get("model_code"), + } + ) + focus_evidence = sorted(str(key) for key in dict(focus_candidate.get("evidence") or {}).keys()) + if focus_evidence: + reminders.append( + { + "severity": "medium", + "title": f"Review Candidate #{focus_candidate.get('id')} evidence", + "message": ", ".join(focus_evidence[:4]), + "source": "focus_candidate", + "focus_candidate_id": focus_candidate.get("id"), + "focus_related": True, + "session_code": focus_candidate.get("session_code"), + "model_code": focus_candidate.get("model_code"), + } + ) if top_opportunities: item = top_opportunities[0] reminders.append( @@ -3681,6 +4312,11 @@ def _build_decision_assistant_personalized_reminders( "title": f"Review {item.get('title') or 'top opportunity'}", "message": item.get("detail") or item.get("reason") or "top_opportunity_ready", "source": item.get("kind") or "decision_assistant", + "focus_related": bool( + focus_candidate + and str(item.get("title") or "").startswith(f"Candidate #{focus_candidate.get('id')}") + ), + "focus_candidate_id": focus_candidate.get("id") if focus_candidate else None, } ) high_blockers = [item for item in no_trade_reason_items if item.get("priority") == "high"] @@ -3731,6 +4367,7 @@ def _build_decision_assistant_personalized_reminders( ] annotated.sort( key=lambda row: ( + 1 if bool(row.get("focus_related")) else 0, int(row.get("importance_score") or 0), {"high": 3, "medium": 2, "low": 1}.get(str(row.get("severity") or "low"), 1), ), @@ -3758,6 +4395,11 @@ def _build_decision_assistant_digest_surface( for item in personalized_reminders if bool(item.get("high_value")) ] + focus_related_reminders = [ + dict(item) + for item in personalized_reminders + if bool(item.get("focus_related")) + ] digest_preference_profile = _build_digest_preference_profile( personalized_reminders=personalized_reminders, digest_history_summary=digest_history_summary, @@ -3787,6 +4429,9 @@ def _build_decision_assistant_digest_surface( "high_priority_count": sum(1 for item in personalized_reminders if str(item.get("severity") or "") == "high"), "high_value_reminders": high_value_reminders[:5], "high_value_reminder_count": len(high_value_reminders), + "focus_related_reminders": focus_related_reminders[:5], + "focus_related_reminder_count": len(focus_related_reminders), + "next_focus_related_reminder": focus_related_reminders[0] if focus_related_reminders else None, "important_change_filter": _digest_important_change_filter(personalized_reminders, threshold=importance_threshold), "importance_templates": _digest_importance_templates( threshold_overrides={ @@ -3831,6 +4476,8 @@ def _annotate_personalized_reminder_importance( base = {"high": 80, "medium": 55, "low": 30}.get(severity, 30) if source in {"decision_packet", "no_trade_reason_items"}: base += 10 + if source == "focus_candidate": + base += 12 if source == "external_context_summary": base += 5 score = max(0, min(100, base)) @@ -3850,6 +4497,8 @@ def _annotate_personalized_reminder_importance( def _reminder_importance_template(reminder: dict) -> str: + if str(reminder.get("source") or "") == "focus_candidate": + return "opportunity_change" if str(reminder.get("source") or "") == "no_trade_reason_items": return "risk_first_change" if str(reminder.get("source") or "") == "decision_packet": @@ -3948,6 +4597,15 @@ def _build_digest_preference_profile( "digest_mode": row.get("digest_mode"), } ) + if not any(item.get("profile_key") == DecisionAssistantRepository.DEFAULT_PROFILE_KEY for item in available_profiles): + available_profiles.append( + { + "profile_key": DecisionAssistantRepository.DEFAULT_PROFILE_KEY, + "profile_label": "Local operator", + "updated_ts": None, + "digest_mode": "all_reminders", + } + ) return { "status": "active" if personalized_reminders else "idle", "default_mode": default_mode, @@ -4174,6 +4832,8 @@ def _build_decision_assistant_digest_history_archive(rows: list[dict]) -> dict: } ) latest = dict(items[0] or {}) + long_range_summary = build_digest_archive_long_range_summary(items) + baseline_summary = build_digest_archive_baseline_summary(items) return { "status": "active", "snapshot_count": len(rows), @@ -4185,6 +4845,8 @@ def _build_decision_assistant_digest_history_archive(rows: list[dict]) -> dict: else None ), "items": items, + "long_range_trend_summary": long_range_summary, + "baseline_pin_summary": baseline_summary, } @@ -4219,6 +4881,9 @@ def _build_decision_assistant_workflow_history_archive(rows: list[dict]) -> dict } ) latest = dict(items[0] or {}) + 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", "snapshot_count": len(rows), @@ -4230,6 +4895,9 @@ def _build_decision_assistant_workflow_history_archive(rows: list[dict]) -> dict else None ), "items": items, + "long_range_trend_summary": long_range_summary, + "baseline_pin_summary": baseline_summary, + **operations_view, } @@ -5469,6 +6137,17 @@ def _build_decision_assistant_setup_learning_rollup( "setup_type": model_code or "unknown", "session_code": session_code or "OFF_HOURS", "session_code_source": session_code_source, + "focus_candidate_id": focus_candidate.get("id"), + "focus_model_code": focus_candidate.get("model_code") or model_code or "unknown", + "focus_session_code": focus_candidate.get("session_code") or session_code or "OFF_HOURS", + "focus_replay_scope": "candidate_scoped" if focus_candidate else "session_scoped", + "focus_replay_prompt": ( + f"Recompute replay learning around Candidate #{focus_candidate.get('id')} / " + f"{focus_candidate.get('model_code') or model_code or 'unknown'} / " + f"{focus_candidate.get('session_code') or session_code or 'OFF_HOURS'}." + if focus_candidate + else None + ), "lesson_count": len(lessons), "top_lessons": [ { @@ -8265,12 +8944,14 @@ def _build_decision_assistant_evidence_chain( operator_digest: dict, weekly_review: dict, current_session: str, + focus_candidate: dict, ) -> list[dict]: blockers = [str(item) for item in list(launch_readiness.get("blocking_reasons") or []) if item] warnings = [str(item) for item in list(launch_readiness.get("warning_reasons") or []) if item] quality_flags = [str(item) for item in list(operator_digest.get("quality_flags") or []) if item] digest_counts = dict(operator_digest.get("summary_counts") or {}) - return [ + focus_candidate = dict(focus_candidate or {}) + chain = [ { "title": "Decision Gate", "status": decision_packet.get("action_recommendation") or decision_packet.get("status") or "monitor", @@ -8302,6 +8983,33 @@ def _build_decision_assistant_evidence_chain( "reason": f"framework_rate={weekly_review.get('framework_execution_rate') if weekly_review.get('framework_execution_rate') is not None else 'n/a'}", }, ] + if focus_candidate: + focus_evidence = dict(focus_candidate.get("evidence") or {}) + focus_missing = [str(item) for item in list(focus_candidate.get("missing_confirmations") or []) if item] + focus_confirmation = dict(focus_candidate.get("confirmation_state") or {}) + chain.insert( + 3, + { + "title": "Focus Candidate", + "status": focus_candidate.get("status") or focus_candidate.get("candidate_state") or "candidate", + "detail": ( + f"Candidate #{focus_candidate.get('id')} | " + f"{focus_candidate.get('model_code') or 'model'} | " + f"{focus_candidate.get('session_code') or current_session or 'OFF_HOURS'}" + ), + "reason": ( + ", ".join(focus_missing[:3]) + or ", ".join([str(item) for item in list(focus_confirmation.get('reason_codes') or [])[:3]]) + or "focus_candidate_context" + ), + "focus_candidate_id": focus_candidate.get("id"), + "focus_related": True, + "evidence_keys": sorted(str(key) for key in focus_evidence.keys())[:8], + "missing_confirmation_count": len(focus_missing), + "missing_confirmations": focus_missing[:6], + }, + ) + return chain def _build_no_trade_reason_items(*, why_blocked: list[str], launch_blockers: list[str], launch_warnings: list[str]) -> list[dict]: diff --git a/src/services/operator/workflow_archive_projection.py b/src/services/operator/workflow_archive_projection.py new file mode 100644 index 0000000..4186620 --- /dev/null +++ b/src/services/operator/workflow_archive_projection.py @@ -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 diff --git a/src/services/readiness/launch_readiness_service.py b/src/services/readiness/launch_readiness_service.py index ea720d0..70a0b14 100644 --- a/src/services/readiness/launch_readiness_service.py +++ b/src/services/readiness/launch_readiness_service.py @@ -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, diff --git a/src/web/app.js b/src/web/app.js index 2778efb..a81d469 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -37,6 +37,41 @@ let assistantDigestPreferenceMode = loadAssistantDigestPreferenceMode(); let assistantDigestProfileKey = loadAssistantDigestProfileKey(); let assistantScanHandoffMarker = loadAssistantScanHandoffMarker(); let assistantFocusCandidateId = loadAssistantFocusCandidateId(); +let assistantPreferenceDraft = null; +let assistantPreferenceDraftDirty = false; +let assistantPreferenceDraftVersion = ''; +let assistantOperatorNoteDraft = { + body: '', + context: null, +}; +let assistantDigestArchiveState = { + profile_key: '', + preferred_history_kind: '', + digest_mode: '', + query_text: '', + created_after: '', + created_before: '', + limit: 12, + next_offset: 0, + selected_left_id: null, + selected_right_id: null, + pinned_baseline_id: null, +}; +let assistantWorkflowArchiveState = { + profile_key: '', + current_session: '', + next_phase: '', + next_artifact_kind: '', + query_text: '', + created_after: '', + created_before: '', + limit: 12, + next_offset: 0, + selected_left_id: null, + selected_right_id: null, + pinned_baseline_id: null, +}; +let assistantWorkflowArchiveBridgeContext = null; async function fetchJson(url, options = {}) { const fetchOptions = { ...options }; @@ -63,6 +98,18 @@ async function postJson(url, body = {}) { }); } +function downloadJsonFile(filename, payload) { + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' }); + const url = window.URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename || 'archive-export.json'; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + window.URL.revokeObjectURL(url); +} + function setText(element, value) { if (!element) return; element.textContent = value == null ? '' : String(value); @@ -135,7 +182,7 @@ function persistAssistantDigestProfileKey(value) { async function persistAssistantDigestPreferenceServer({ profileKey, digestMode, preferredHistoryKind, importanceThreshold = 70, payload = null }) { try { - await postJson('/trading/alerts/digest-preferences', { + const response = await postJson('/trading/alerts/digest-preferences', { instrument_id: Number(instrumentIdInput.value || 1), profile_key: profileKey || assistantDigestProfileKey || 'local_operator', digest_mode: digestMode || 'all_reminders', @@ -147,11 +194,44 @@ async function persistAssistantDigestPreferenceServer({ profileKey, digestMode, }, }); persistAssistantDigestProfileKey(profileKey || assistantDigestProfileKey || 'local_operator'); + return response; } catch { // no-op + return null; } } +async function createAssistantPreferenceProfile({ profileKey, profileLabel, sourceProfileKey = null, draft = null }) { + return postJson('/trading/alerts/digest-preferences/profiles', { + instrument_id: Number(instrumentIdInput.value || 1), + profile_key: profileKey || null, + profile_label: profileLabel || null, + source_profile_key: sourceProfileKey || null, + digest_mode: draft?.digest_mode || null, + preferred_history_kind: draft?.preferred_history_kind === 'all' ? null : (draft?.preferred_history_kind || null), + importance_threshold: draft?.importance_threshold || null, + payload: draft ? { + profile_label: draft.profile_label || profileLabel || null, + history_window_hours: draft.history_window_hours || 24, + importance_templates: { ...(draft.importance_templates || {}) }, + } : null, + }); +} + +async function renameAssistantPreferenceProfile({ profileKey, newProfileKey = null, profileLabel = null }) { + return postJson(`/trading/alerts/digest-preferences/profiles/${encodeURIComponent(profileKey)}/rename`, { + instrument_id: Number(instrumentIdInput.value || 1), + new_profile_key: newProfileKey || null, + profile_label: profileLabel || null, + }); +} + +async function deleteAssistantPreferenceProfile(profileKey) { + return postJson(`/trading/alerts/digest-preferences/profiles/${encodeURIComponent(profileKey)}/delete`, { + instrument_id: Number(instrumentIdInput.value || 1), + }); +} + function loadAssistantScanHandoffMarker() { try { const raw = window.localStorage?.getItem('assistantScanHandoffMarker'); @@ -195,27 +275,102 @@ async function persistAssistantFocusCandidateServer(value) { } async function fetchAssistantDigestArchive(overrides = {}) { - const params = { - instrument_id: Number(instrumentIdInput.value || 1), - profile_key: assistantDigestProfileKey || 'local_operator', - limit: 12, + const append = Boolean(overrides.append); + const nextState = { + ...assistantDigestArchiveState, + profile_key: assistantDigestArchiveState.profile_key || assistantDigestProfileKey || 'local_operator', ...overrides, }; + delete nextState.append; + const params = { + instrument_id: Number(instrumentIdInput.value || 1), + profile_key: nextState.profile_key || assistantDigestProfileKey || 'local_operator', + preferred_history_kind: nextState.preferred_history_kind || null, + digest_mode: nextState.digest_mode || null, + query_text: nextState.query_text || null, + created_after: nextState.created_after || null, + created_before: nextState.created_before || null, + limit: Number(nextState.limit || 12), + offset: append ? Number(assistantDigestArchiveState.next_offset || 0) : 0, + }; + assistantDigestArchiveState = { + ...nextState, + limit: params.limit, + profile_key: params.profile_key, + }; window.__assistantDigestArchiveQuery = params; - window.__assistantDigestArchiveResponse = await fetchJson(`/trading/alerts/digest-history-archive?${queryString(params)}`); - window.__assistantDigestArchiveCompare = await fetchJson(`/trading/alerts/digest-history-archive/compare?${queryString(params)}`); + const response = await fetchJson(`/trading/alerts/digest-history-archive?${queryString(params)}`); + if (append && window.__assistantDigestArchiveResponse?.items?.length) { + response.items = [...(window.__assistantDigestArchiveResponse.items || []), ...(response.items || [])]; + response.item_count = response.items.length; + } + assistantDigestArchiveState.next_offset = Number(response.next_offset || 0); + window.__assistantDigestArchiveResponse = response; + if (assistantDigestArchiveState.selected_left_id && assistantDigestArchiveState.selected_right_id) { + window.__assistantDigestArchiveCompare = await fetchJson(`/trading/alerts/digest-history-archive/compare?${queryString({ + instrument_id: Number(instrumentIdInput.value || 1), + profile_key: assistantDigestArchiveState.profile_key || assistantDigestProfileKey || 'local_operator', + left_id: assistantDigestArchiveState.selected_left_id, + right_id: assistantDigestArchiveState.selected_right_id, + })}`); + } else { + window.__assistantDigestArchiveCompare = await fetchJson(`/trading/alerts/digest-history-archive/compare?${queryString(params)}`); + } } async function fetchAssistantWorkflowArchive(overrides = {}) { - const params = { - instrument_id: Number(instrumentIdInput.value || 1), - profile_key: assistantDigestProfileKey || 'local_operator', - limit: 12, + const append = Boolean(overrides.append); + const nextState = { + ...assistantWorkflowArchiveState, + profile_key: assistantWorkflowArchiveState.profile_key || assistantDigestProfileKey || 'local_operator', ...overrides, }; + delete nextState.append; + const params = { + instrument_id: Number(instrumentIdInput.value || 1), + profile_key: nextState.profile_key || assistantDigestProfileKey || 'local_operator', + current_session: nextState.current_session || null, + next_phase: nextState.next_phase || null, + next_artifact_kind: nextState.next_artifact_kind || null, + query_text: nextState.query_text || null, + created_after: nextState.created_after || null, + created_before: nextState.created_before || null, + limit: Number(nextState.limit || 12), + offset: append ? Number(assistantWorkflowArchiveState.next_offset || 0) : 0, + }; + assistantWorkflowArchiveState = { + ...nextState, + limit: params.limit, + profile_key: params.profile_key, + }; window.__assistantWorkflowArchiveQuery = params; - window.__assistantWorkflowArchiveResponse = await fetchJson(`/trading/workflow-archive?${queryString(params)}`); - window.__assistantWorkflowArchiveCompare = await fetchJson(`/trading/workflow-archive/compare?${queryString(params)}`); + const response = await fetchJson(`/trading/workflow-archive?${queryString(params)}`); + if (append && window.__assistantWorkflowArchiveResponse?.items?.length) { + response.items = [...(window.__assistantWorkflowArchiveResponse.items || []), ...(response.items || [])]; + response.item_count = response.items.length; + } + assistantWorkflowArchiveState.next_offset = Number(response.next_offset || 0); + window.__assistantWorkflowArchiveResponse = response; + if (assistantWorkflowArchiveState.selected_left_id && assistantWorkflowArchiveState.selected_right_id) { + window.__assistantWorkflowArchiveCompare = await fetchJson(`/trading/workflow-archive/compare?${queryString({ + instrument_id: Number(instrumentIdInput.value || 1), + profile_key: assistantWorkflowArchiveState.profile_key || assistantDigestProfileKey || 'local_operator', + left_id: assistantWorkflowArchiveState.selected_left_id, + right_id: assistantWorkflowArchiveState.selected_right_id, + })}`); + } else { + window.__assistantWorkflowArchiveCompare = await fetchJson(`/trading/workflow-archive/compare?${queryString(params)}`); + } +} + +function selectAssistantDigestArchiveSnapshot(side, snapshotId) { + if (side === 'left') assistantDigestArchiveState.selected_left_id = snapshotId; + if (side === 'right') assistantDigestArchiveState.selected_right_id = snapshotId; +} + +function selectAssistantWorkflowArchiveSnapshot(side, snapshotId) { + if (side === 'left') assistantWorkflowArchiveState.selected_left_id = snapshotId; + if (side === 'right') assistantWorkflowArchiveState.selected_right_id = snapshotId; } function persistAssistantScanHandoffMarker(marker) { @@ -231,6 +386,84 @@ function persistAssistantScanHandoffMarker(marker) { } } +function buildAssistantPreferenceDraft(summary) { + const digestSurface = summary?.digest_surface || {}; + const profile = digestSurface.digest_preference_profile || {}; + const templates = {}; + (profile.importance_templates || []).forEach((item) => { + if (item?.template) templates[item.template] = Number(item.threshold || item.default_threshold || 0); + }); + return { + profile_key: profile.selected_profile_key || assistantDigestProfileKey || 'local_operator', + profile_label: profile.profile_label || 'Local operator', + digest_mode: profile.persisted_digest_mode || profile.default_mode || 'all_reminders', + preferred_history_kind: profile.preferred_history_kind || 'all', + importance_threshold: Number(profile.importance_threshold || 70), + history_window_hours: Number(profile.history_window_hours || 24), + importance_templates: templates, + focus_candidate_id: profile.focus_candidate_id ?? summary?.focus_workspace_context?.focus_candidate_id ?? null, + }; +} + +function buildAssistantPreferenceDraftVersion(summary) { + const profile = summary?.digest_surface?.digest_preference_profile || {}; + return `${profile.selected_profile_key || 'local_operator'}:${profile.persisted_updated_ts || 'none'}`; +} + +function ensureAssistantPreferenceDraft(summary) { + const nextDraft = buildAssistantPreferenceDraft(summary); + const nextVersion = buildAssistantPreferenceDraftVersion(summary); + if (!assistantPreferenceDraft || assistantPreferenceDraft.profile_key !== nextDraft.profile_key) { + assistantPreferenceDraft = nextDraft; + assistantPreferenceDraftDirty = false; + assistantPreferenceDraftVersion = nextVersion; + return; + } + if (!assistantPreferenceDraftDirty && assistantPreferenceDraftVersion !== nextVersion) { + assistantPreferenceDraft = nextDraft; + assistantPreferenceDraftVersion = nextVersion; + } +} + +function updateAssistantPreferenceDraft(patch = {}) { + assistantPreferenceDraft = { + ...(assistantPreferenceDraft || {}), + ...(patch || {}), + }; + assistantPreferenceDraftDirty = true; +} + +function updateAssistantPreferenceTemplateDraft(template, threshold) { + const nextTemplates = { + ...((assistantPreferenceDraft && assistantPreferenceDraft.importance_templates) || {}), + }; + nextTemplates[template] = Number(threshold || 0); + updateAssistantPreferenceDraft({ importance_templates: nextTemplates }); +} + +function resetAssistantPreferenceDraftToServer(summary) { + assistantPreferenceDraft = buildAssistantPreferenceDraft(summary); + assistantPreferenceDraftDirty = false; + assistantPreferenceDraftVersion = buildAssistantPreferenceDraftVersion(summary); +} + +function resetAssistantPreferenceDraftToDefaults(summary) { + const profile = summary?.digest_surface?.digest_preference_profile || {}; + const templates = {}; + (profile.importance_templates || []).forEach((item) => { + if (item?.template) templates[item.template] = Number(item.default_threshold || item.threshold || 0); + }); + assistantPreferenceDraft = { + ...(assistantPreferenceDraft || buildAssistantPreferenceDraft(summary)), + digest_mode: 'all_reminders', + preferred_history_kind: 'all', + importance_threshold: 70, + history_window_hours: 24, + importance_templates: templates, + }; + assistantPreferenceDraftDirty = true; +} + function snoozeAssistantScanHandoffMarker(seconds, reason = 'operator_snooze') { if (!assistantScanHandoffMarker) return; const duration = Math.max(60, Number(seconds || 1800)); @@ -287,7 +520,40 @@ function filterAssistantReminders(reminders, mode) { return reminders; } -function prefillOperatorNote(body) { +function getAssistantFocusContext(summary = window.__latestDecisionAssistantSummary || null) { + const assistantSummary = summary || {}; + const focusWorkspace = assistantSummary.focus_workspace_context || {}; + const tiers = assistantSummary.candidate_watchlist_tiers || {}; + const allWatchlistItems = [ + ...(tiers.review_now || []), + ...(tiers.strong_watch || []), + ...(tiers.soft_watch || []), + ...(tiers.archive_only || []), + ]; + const focusCandidate = allWatchlistItems.find((item) => String(item.candidate_id || '') === String(focusWorkspace.focus_candidate_id || '')); + return { + candidate_id: focusCandidate?.candidate_id ?? focusWorkspace.focus_candidate_id ?? null, + session_code: focusCandidate?.session_code ?? focusWorkspace.focus_session_code ?? null, + model_code: focusCandidate?.model_code ?? focusWorkspace.focus_model_code ?? null, + title: focusCandidate?.title ?? focusWorkspace.focus_title ?? null, + detail: focusCandidate?.detail ?? focusWorkspace.focus_detail ?? null, + }; +} + +function buildOperatorNoteTags(baseTags = [], context = null) { + const tags = [...(Array.isArray(baseTags) ? baseTags : [])]; + if (context?.candidate_id != null) tags.push(`candidate:${context.candidate_id}`); + if (context?.session_code) tags.push(`session:${context.session_code}`); + if (context?.model_code) tags.push(`model:${context.model_code}`); + return Array.from(new Set(tags.filter(Boolean))); +} + +function prefillOperatorNote(body, context = null) { + const resolvedContext = context || getAssistantFocusContext(); + assistantOperatorNoteDraft = { + body: body || '', + context: resolvedContext, + }; revealPanel(operatorConsolePanel); const textarea = operatorConsolePanel.querySelector('textarea'); if (textarea) { @@ -296,6 +562,38 @@ function prefillOperatorNote(body) { } } +function buildWorkflowArchiveBridgeContext(item, bridgeMode = 'note') { + if (!item) return null; + return { + bridge_mode: bridgeMode, + snapshot_id: item.snapshot_id ?? null, + session_code: item.current_session || null, + model_code: null, + next_phase: item.next_phase || null, + next_artifact_kind: item.next_artifact_kind || null, + workflow_body: item.workflow_body || '', + next_artifact_prompt: item.next_artifact_prompt || '', + phase_briefs: Array.isArray(item.phase_briefs) ? item.phase_briefs : [], + created_ts: item.created_ts || null, + }; +} + +function setWorkflowArchiveBridgeContext(context) { + assistantWorkflowArchiveBridgeContext = context || null; +} + +function buildWorkflowArchiveBridgeNote(item) { + const phaseLines = (item.phase_briefs || []) + .map((row) => `- ${row.phase || 'phase'}: ${row.archive_body || row.detail || 'n/a'}`) + .join('\n'); + return [ + `[workflow-archive] Snapshot #${item.snapshot_id ?? 'n/a'} | ${item.current_session || 'n/a'} -> ${item.next_phase || 'workflow'}`, + item.workflow_body || '', + item.next_artifact_prompt ? `Next prompt: ${item.next_artifact_prompt}` : '', + phaseLines ? `Phase briefs:\n${phaseLines}` : '', + ].filter(Boolean).join('\n\n'); +} + function appendSectionTitle(container, title) { const element = createElement('div', 'assistant-section-title', title); element.dataset.assistantSection = normalizeAssistantSectionId(title); @@ -517,15 +815,22 @@ async function runAction(url, body = {}) { } } +function openExternalContextLink(url) { + if (!url) return; + window.open(url, '_blank', 'noopener,noreferrer'); +} + async function saveOperatorTemplateNote(template) { + const focusContext = getAssistantFocusContext(); try { await postJson('/trading/operator-notes', { instrument_id: Number(instrumentIdInput.value || 1), note_scope: template.note_scope || 'workbench', author: 'assistant_template', body: template.body || '', - session_code: sessionFilterInput.value === 'all' ? null : sessionFilterInput.value, - tags: Array.isArray(template.tags) ? template.tags : ['assistant-template'], + session_code: focusContext.session_code || (sessionFilterInput.value === 'all' ? null : sessionFilterInput.value), + candidate_id: focusContext.candidate_id, + tags: buildOperatorNoteTags(Array.isArray(template.tags) ? template.tags : ['assistant-template'], focusContext), }); await refreshDashboard(); } catch (error) { @@ -537,14 +842,16 @@ async function saveOperatorTemplateFeedback(template, outcome) { const templateId = String(template?.template_id || 'template'); const normalizedOutcome = String(outcome || 'useful').toLowerCase().replace(/[^a-z0-9_-]+/g, '_'); const templateTag = templateId.replace(/_/g, '-'); + const focusContext = getAssistantFocusContext(); try { await postJson('/trading/operator-notes', { instrument_id: Number(instrumentIdInput.value || 1), note_scope: template?.note_scope || 'workbench', author: 'assistant_template', body: `[template-feedback] ${templateId} | outcome:${normalizedOutcome} | ${template?.label || 'template'}`, - session_code: sessionFilterInput.value === 'all' ? null : sessionFilterInput.value, - tags: ['assistant-template', 'template-feedback', templateTag, `template:${templateId}`, `outcome:${normalizedOutcome}`], + session_code: focusContext.session_code || (sessionFilterInput.value === 'all' ? null : sessionFilterInput.value), + candidate_id: focusContext.candidate_id, + tags: buildOperatorNoteTags(['assistant-template', 'template-feedback', templateTag, `template:${templateId}`, `outcome:${normalizedOutcome}`], focusContext), }); await refreshDashboard(); } catch (error) { @@ -613,7 +920,9 @@ function renderDecisionAssistant(summary) { const digestSurface = summary.digest_surface || null; const digestHistoryBrowser = summary.digest_history_browser || digestSurface?.history_browser || null; const digestPreferenceProfile = digestSurface?.digest_preference_profile || null; + ensureAssistantPreferenceDraft(summary); const selectedProfileKey = assistantDigestProfileKey + || assistantPreferenceDraft?.profile_key || digestPreferenceProfile?.selected_profile_key || 'local_operator'; const digestArchiveData = window.__assistantDigestArchiveResponse?.instrument_id === Number(instrumentIdInput.value || 1) @@ -622,12 +931,31 @@ function renderDecisionAssistant(summary) { const digestArchiveCompare = window.__assistantDigestArchiveCompare?.status ? window.__assistantDigestArchiveCompare : (digestSurface?.digest_archive_compare || summary.digest_archive_compare || null); + const digestArchiveLongRangeSummary = digestArchiveData?.long_range_trend_summary || digestSurface?.digest_history_archive?.long_range_trend_summary || null; + const digestArchiveBaselineSummary = digestArchiveData?.baseline_pin_summary || digestSurface?.digest_history_archive?.baseline_pin_summary || null; const workflowArchiveData = window.__assistantWorkflowArchiveResponse?.instrument_id === Number(instrumentIdInput.value || 1) ? window.__assistantWorkflowArchiveResponse : summary.workflow_artifact_summary?.workflow_history_archive; const workflowArchiveCompare = window.__assistantWorkflowArchiveCompare?.status ? window.__assistantWorkflowArchiveCompare : (summary.workflow_artifact_summary?.workflow_archive_compare || summary.workflow_archive_compare || null); + const workflowArchiveLongRangeSummary = workflowArchiveData?.long_range_trend_summary || summary.workflow_artifact_summary?.workflow_history_archive?.long_range_trend_summary || null; + const workflowArchiveBaselineSummary = workflowArchiveData?.baseline_pin_summary || summary.workflow_artifact_summary?.workflow_history_archive?.baseline_pin_summary || null; + const workflowArchiveOperationsSummary = workflowArchiveData?.operations_summary + || summary.workflow_artifact_summary?.workflow_archive_operations_summary + || summary.workflow_archive_operations_summary + || null; + const workflowArchiveDriftReview = workflowArchiveData?.multi_day_drift_review + || summary.workflow_artifact_summary?.workflow_archive_multi_day_drift_review + || summary.workflow_archive_multi_day_drift_review + || null; + const workflowArchiveBridgeSummary = workflowArchiveData?.bridge_summary + || summary.workflow_artifact_summary?.workflow_archive_bridge_summary + || summary.workflow_archive_bridge_summary + || null; + const workflowArchiveDailyGroups = workflowArchiveData?.daily_groups || []; + const workflowArchiveSessionGroups = workflowArchiveData?.session_groups || []; + const workflowArchiveBridgeContext = assistantWorkflowArchiveBridgeContext; const setupLearningRollup = summary.setup_learning_rollup || null; const focusWorkspaceContext = summary.focus_workspace_context || null; if (!assistantDigestProfileKey && digestPreferenceProfile?.selected_profile_key) { @@ -636,11 +964,13 @@ function renderDecisionAssistant(summary) { const continuousWorkspaceRail = summary.continuous_workspace_rail || []; const continuousWorkspaceSummary = summary.continuous_workspace_summary || null; const workspaceContinuityHandoff = summary.workspace_continuity_handoff || null; - const effectiveDigestPreferenceMode = assistantDigestPreferenceMode + const effectiveDigestPreferenceMode = assistantPreferenceDraft?.digest_mode + || assistantDigestPreferenceMode || digestPreferenceProfile?.persisted_digest_mode || digestPreferenceProfile?.default_mode || 'all_reminders'; - const activeHistoryFilter = assistantHistoryKindFilter + const activeHistoryFilter = assistantPreferenceDraft?.preferred_history_kind + || assistantHistoryKindFilter || digestPreferenceProfile?.preferred_history_kind || 'all'; const visibleReminders = filterAssistantReminders(personalizedReminders, effectiveDigestPreferenceMode); @@ -662,6 +992,7 @@ function renderDecisionAssistant(summary) { assistantLoopQueue.length ? { title: 'Loop Queue' } : null, continuousWorkspaceRail.length ? { title: 'Workspace Rail' } : null, workflowDigest.length ? { title: 'Workflow' } : null, + digestPreferenceProfile ? { title: 'Preferences' } : null, digestSurface ? { title: 'Digest' } : null, reviewEntrypoints ? { title: 'Review Entry' } : null, opportunities.length ? { title: 'Opportunities' } : null, @@ -1031,6 +1362,265 @@ function renderDecisionAssistant(summary) { ) ); } + if (digestPreferenceProfile) { + appendSectionTitle(decisionAssistantPanel, 'Preferences'); + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['selected profile', selectedProfileKey], + ['profile label', assistantPreferenceDraft?.profile_label || digestPreferenceProfile.profile_label || 'n/a'], + ['draft status', assistantPreferenceDraftDirty ? 'unsaved_changes' : 'synced'], + ['digest mode', assistantPreferenceDraft?.digest_mode || 'all_reminders'], + ['preferred history kind', assistantPreferenceDraft?.preferred_history_kind || 'all'], + ['importance threshold', String(assistantPreferenceDraft?.importance_threshold ?? 70)], + ['history window hours', String(assistantPreferenceDraft?.history_window_hours ?? 24)], + ['focus candidate', String(assistantPreferenceDraft?.focus_candidate_id ?? 'n/a')], + ]) + ); + if (digestPreferenceProfile.available_profiles?.length) { + decisionAssistantPanel.appendChild( + buildActionBar( + digestPreferenceProfile.available_profiles.map((profile) => ({ + label: profile.profile_key === selectedProfileKey + ? `${profile.profile_label || profile.profile_key}` + : (profile.profile_label || profile.profile_key), + onClick: async () => { + if (assistantPreferenceDraftDirty && !window.confirm('Switch profile and discard unsaved changes?')) return; + persistAssistantDigestProfileKey(profile.profile_key || 'local_operator'); + assistantPreferenceDraft = null; + assistantPreferenceDraftDirty = false; + await refreshDashboard(); + }, + })) + ) + ); + } + decisionAssistantPanel.appendChild( + buildActionBar([ + { + label: 'New Profile', + onClick: async () => { + const profileKey = window.prompt('New profile key', ''); + if (!profileKey) return; + const profileLabel = window.prompt('New profile label', assistantPreferenceDraft?.profile_label || profileKey); + const created = await createAssistantPreferenceProfile({ + profileKey, + profileLabel, + sourceProfileKey: null, + draft: assistantPreferenceDraft, + }).catch((error) => ({ status: 'error', reason: String(error) })); + if (created?.status !== 'created') { + showErrorPanel(created?.reason || created?.status || 'profile_create_failed'); + return; + } + persistAssistantDigestProfileKey(created.profile_key || profileKey); + assistantPreferenceDraft = null; + assistantPreferenceDraftDirty = false; + await refreshDashboard(); + }, + }, + { + label: 'Duplicate', + onClick: async () => { + const profileKey = window.prompt('Duplicate to profile key', `${selectedProfileKey}_copy`); + if (!profileKey) return; + const profileLabel = window.prompt('Duplicate profile label', `${assistantPreferenceDraft?.profile_label || selectedProfileKey} Copy`); + const created = await createAssistantPreferenceProfile({ + profileKey, + profileLabel, + sourceProfileKey: selectedProfileKey, + draft: assistantPreferenceDraft, + }).catch((error) => ({ status: 'error', reason: String(error) })); + if (created?.status !== 'created') { + showErrorPanel(created?.reason || created?.status || 'profile_duplicate_failed'); + return; + } + persistAssistantDigestProfileKey(created.profile_key || profileKey); + assistantPreferenceDraft = null; + assistantPreferenceDraftDirty = false; + await refreshDashboard(); + }, + }, + { + label: 'Rename Label', + onClick: async () => { + const nextLabel = window.prompt('Profile label', assistantPreferenceDraft?.profile_label || selectedProfileKey); + if (!nextLabel) return; + const renamed = await renameAssistantPreferenceProfile({ + profileKey: selectedProfileKey, + profileLabel: nextLabel, + }).catch((error) => ({ status: 'error', reason: String(error) })); + if (renamed?.status !== 'renamed') { + showErrorPanel(renamed?.reason || renamed?.status || 'profile_rename_failed'); + return; + } + assistantPreferenceDraft = null; + assistantPreferenceDraftDirty = false; + await refreshDashboard(); + }, + }, + { + label: 'Delete Profile', + onClick: async () => { + if (selectedProfileKey === 'local_operator') { + showErrorPanel('default_profile_protected'); + return; + } + if (!window.confirm(`Delete profile ${selectedProfileKey}?`)) return; + const removed = await deleteAssistantPreferenceProfile(selectedProfileKey).catch((error) => ({ status: 'error', reason: String(error) })); + if (removed?.status !== 'deleted') { + showErrorPanel(removed?.reason || removed?.status || 'profile_delete_failed'); + return; + } + persistAssistantDigestProfileKey('local_operator'); + assistantPreferenceDraft = null; + assistantPreferenceDraftDirty = false; + await refreshDashboard(); + }, + }, + ]) + ); + const preferencePanel = createElement('div', 'assistant-preference-panel'); + const fieldGrid = createElement('div', 'assistant-preference-fields'); + const labelField = createElement('label', 'assistant-preference-field'); + labelField.appendChild(createElement('span', 'metric', 'Profile Label')); + const labelInput = createElement('input', 'assistant-preference-input'); + labelInput.type = 'text'; + labelInput.value = assistantPreferenceDraft?.profile_label || ''; + labelInput.addEventListener('input', (event) => { + updateAssistantPreferenceDraft({ profile_label: event.target.value }); + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }); + labelField.appendChild(labelInput); + fieldGrid.appendChild(labelField); + + const modeField = createElement('label', 'assistant-preference-field'); + modeField.appendChild(createElement('span', 'metric', 'Digest Mode')); + const modeSelect = createElement('select', 'assistant-preference-input'); + (digestPreferenceProfile.available_modes || []).forEach((mode) => { + const option = createElement('option', '', mode); + option.value = mode; + option.selected = mode === (assistantPreferenceDraft?.digest_mode || 'all_reminders'); + modeSelect.appendChild(option); + }); + modeSelect.addEventListener('change', (event) => { + updateAssistantPreferenceDraft({ digest_mode: event.target.value || 'all_reminders' }); + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }); + modeField.appendChild(modeSelect); + fieldGrid.appendChild(modeField); + + const kindField = createElement('label', 'assistant-preference-field'); + kindField.appendChild(createElement('span', 'metric', 'Preferred History Kind')); + const kindSelect = createElement('select', 'assistant-preference-input'); + [{ kind: 'all', label: 'all' }, ...(digestHistoryGroups || []).map((item) => ({ kind: item.kind, label: item.kind }))].forEach((item) => { + const option = createElement('option', '', item.label || item.kind); + option.value = item.kind || 'all'; + option.selected = (item.kind || 'all') === (assistantPreferenceDraft?.preferred_history_kind || 'all'); + kindSelect.appendChild(option); + }); + kindSelect.addEventListener('change', (event) => { + updateAssistantPreferenceDraft({ preferred_history_kind: event.target.value || 'all' }); + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }); + kindField.appendChild(kindSelect); + fieldGrid.appendChild(kindField); + + const thresholdField = createElement('label', 'assistant-preference-field'); + thresholdField.appendChild(createElement('span', 'metric', 'Importance Threshold')); + const thresholdInput = createElement('input', 'assistant-preference-input'); + thresholdInput.type = 'number'; + thresholdInput.min = '0'; + thresholdInput.max = '100'; + thresholdInput.value = String(assistantPreferenceDraft?.importance_threshold ?? 70); + thresholdInput.addEventListener('input', (event) => { + updateAssistantPreferenceDraft({ importance_threshold: Number(event.target.value || 70) }); + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }); + thresholdField.appendChild(thresholdInput); + fieldGrid.appendChild(thresholdField); + + const historyWindowField = createElement('label', 'assistant-preference-field'); + historyWindowField.appendChild(createElement('span', 'metric', 'History Window Hours')); + const historyWindowInput = createElement('input', 'assistant-preference-input'); + historyWindowInput.type = 'number'; + historyWindowInput.min = '1'; + historyWindowInput.max = '240'; + historyWindowInput.value = String(assistantPreferenceDraft?.history_window_hours ?? 24); + historyWindowInput.addEventListener('input', (event) => { + updateAssistantPreferenceDraft({ history_window_hours: Number(event.target.value || 24) }); + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }); + historyWindowField.appendChild(historyWindowInput); + fieldGrid.appendChild(historyWindowField); + preferencePanel.appendChild(fieldGrid); + + if (digestPreferenceProfile.importance_templates?.length) { + const templateGrid = createElement('div', 'assistant-preference-template-grid'); + (digestPreferenceProfile.importance_templates || []).forEach((item) => { + const templateField = createElement('label', 'assistant-preference-field'); + templateField.appendChild(createElement('span', 'metric', item.template || 'template')); + const templateInput = createElement('input', 'assistant-preference-input'); + templateInput.type = 'number'; + templateInput.min = '0'; + templateInput.max = '100'; + templateInput.value = String(assistantPreferenceDraft?.importance_templates?.[item.template] ?? item.threshold ?? item.default_threshold ?? 0); + templateInput.addEventListener('input', (event) => { + updateAssistantPreferenceTemplateDraft(item.template, Number(event.target.value || 0)); + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }); + templateField.appendChild(templateInput); + templateField.appendChild(createElement('div', 'metric', item.description || '')); + templateGrid.appendChild(templateField); + }); + preferencePanel.appendChild(templateGrid); + } + preferencePanel.appendChild( + buildActionBar([ + { + label: 'Save Preferences', + onClick: async () => { + const saved = await persistAssistantDigestPreferenceServer({ + profileKey: selectedProfileKey, + digestMode: assistantPreferenceDraft?.digest_mode || 'all_reminders', + preferredHistoryKind: assistantPreferenceDraft?.preferred_history_kind === 'all' ? null : (assistantPreferenceDraft?.preferred_history_kind || null), + importanceThreshold: assistantPreferenceDraft?.importance_threshold || 70, + payload: { + profile_label: assistantPreferenceDraft?.profile_label || selectedProfileKey, + history_window_hours: assistantPreferenceDraft?.history_window_hours || 24, + importance_templates: { ...((assistantPreferenceDraft && assistantPreferenceDraft.importance_templates) || {}) }, + }, + }); + if (!saved) { + showErrorPanel('preference_save_failed'); + return; + } + persistAssistantDigestPreferenceMode(assistantPreferenceDraft?.digest_mode || 'all_reminders'); + persistAssistantHistoryKindFilter(assistantPreferenceDraft?.preferred_history_kind || 'all'); + assistantPreferenceDraftDirty = false; + await refreshDashboard(); + }, + }, + { + label: 'Revert Draft', + onClick: () => { + resetAssistantPreferenceDraftToServer(summary); + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }, + }, + { + label: 'Reset Defaults', + onClick: () => { + resetAssistantPreferenceDraftToDefaults(summary); + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }, + }, + ]) + ); + if (assistantPreferenceDraftDirty) { + preferencePanel.appendChild(createElement('div', 'assistant-preference-dirty', 'Unsaved preference changes are staged locally. Save or revert before switching workflow.')); + } + decisionAssistantPanel.appendChild(preferencePanel); + } if (digestSurface) { appendSectionTitle(decisionAssistantPanel, 'Digest'); decisionAssistantPanel.appendChild( @@ -1052,6 +1642,8 @@ function renderDecisionAssistant(summary) { ['preferred template', digestPreferenceProfile?.preferred_importance_template || 'n/a'], ['preference reason', digestPreferenceProfile?.profile_reason || 'n/a'], ['focus candidate', String(digestPreferenceProfile?.focus_candidate_id ?? digestSurface.focus_candidate_id ?? 'n/a')], + ['focus-related reminders', String(digestSurface.focus_related_reminder_count ?? 0)], + ['next focus reminder', digestSurface.next_focus_related_reminder?.title || 'n/a'], ['visible reminders', String(reminderCards.length)], ['important change filter', `${digestSurface.important_change_filter?.mode || 'n/a'} / ${digestSurface.important_change_filter?.threshold ?? 'n/a'}`], ['importance templates', formatJsonInline(digestSurface.important_change_filter?.template_counts || {})], @@ -1143,189 +1735,298 @@ function renderDecisionAssistant(summary) { label: `${item.label || item.kind}${item.recommended ? ' *' : ''}`, onClick: () => { persistAssistantHistoryKindFilter(item.kind || 'all'); - persistAssistantDigestPreferenceServer({ - profileKey: selectedProfileKey, - digestMode: effectiveDigestPreferenceMode, - preferredHistoryKind: item.kind || 'all', - importanceThreshold: digestPreferenceProfile?.importance_threshold || 70, - }); + updateAssistantPreferenceDraft({ preferred_history_kind: item.kind || 'all' }); renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); }, })) ); decisionAssistantPanel.appendChild(browserBar); } - if (digestPreferenceProfile?.available_modes?.length) { - const preferenceBar = buildActionBar( - digestPreferenceProfile.available_modes.map((mode) => ({ - label: mode === 'all_reminders' ? 'All Reminders' : (mode === 'high_value_only' ? 'High Value' : 'Risk First'), - onClick: () => { - persistAssistantDigestPreferenceMode(mode); - persistAssistantDigestPreferenceServer({ - profileKey: selectedProfileKey, - digestMode: mode, - preferredHistoryKind: activeHistoryFilter === 'all' ? null : activeHistoryFilter, - importanceThreshold: digestPreferenceProfile?.importance_threshold || 70, - }); - renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); - }, - })) - ); - decisionAssistantPanel.appendChild(preferenceBar); - } - if (digestPreferenceProfile?.available_profiles?.length) { - const profileBar = buildActionBar( - digestPreferenceProfile.available_profiles.map((profile) => ({ - label: profile.profile_key === selectedProfileKey - ? `${profile.profile_label || profile.profile_key}` - : (profile.profile_label || profile.profile_key), - onClick: async () => { - persistAssistantDigestProfileKey(profile.profile_key || 'local_operator'); - window.__assistantDigestArchiveResponse = null; - window.__assistantDigestArchiveCompare = null; - window.__assistantWorkflowArchiveResponse = null; - window.__assistantWorkflowArchiveCompare = null; - await refreshDashboard(); - }, - })) - ); - decisionAssistantPanel.appendChild(profileBar); - } - if (digestPreferenceProfile?.importance_templates?.length) { - const templateGrid = createElement('div', 'cards assistant-tight-grid'); - (digestPreferenceProfile.importance_templates || []).forEach((item) => { - templateGrid.appendChild( - buildItemCard( - item.template || 'template', - `threshold ${item.threshold ?? 'n/a'}`, - [ - ['default', String(item.default_threshold ?? 'n/a')], - ['description', item.description || 'n/a'], - ], - [ - { - label: '-5', - onClick: async () => { - const nextThreshold = Math.max(0, Number(item.threshold || 0) - 5); - const nextTemplates = Object.fromEntries((digestPreferenceProfile.importance_templates || []).map((row) => [row.template, row.threshold])); - nextTemplates[item.template] = nextThreshold; - await persistAssistantDigestPreferenceServer({ - profileKey: selectedProfileKey, - digestMode: effectiveDigestPreferenceMode, - preferredHistoryKind: activeHistoryFilter === 'all' ? null : activeHistoryFilter, - importanceThreshold: digestPreferenceProfile?.importance_threshold || 70, - payload: { importance_templates: nextTemplates }, - }); - await refreshDashboard(); - }, - }, - { - label: '+5', - onClick: async () => { - const nextThreshold = Math.min(100, Number(item.threshold || 0) + 5); - const nextTemplates = Object.fromEntries((digestPreferenceProfile.importance_templates || []).map((row) => [row.template, row.threshold])); - nextTemplates[item.template] = nextThreshold; - await persistAssistantDigestPreferenceServer({ - profileKey: selectedProfileKey, - digestMode: effectiveDigestPreferenceMode, - preferredHistoryKind: activeHistoryFilter === 'all' ? null : activeHistoryFilter, - importanceThreshold: digestPreferenceProfile?.importance_threshold || 70, - payload: { importance_templates: nextTemplates }, - }); - await refreshDashboard(); - }, - }, - ] - ) - ); - }); - decisionAssistantPanel.appendChild(templateGrid); - } + const digestArchivePanel = createElement('div', 'assistant-archive-panel'); + const digestArchiveControls = createElement('div', 'assistant-archive-controls'); + const digestArchiveProfileField = createElement('label', 'assistant-preference-field'); + digestArchiveProfileField.appendChild(createElement('span', 'metric', 'Archive Profile')); + const digestArchiveProfileInput = createElement('input', 'assistant-preference-input'); + digestArchiveProfileInput.type = 'text'; + digestArchiveProfileInput.value = assistantDigestArchiveState.profile_key || selectedProfileKey; + digestArchiveProfileField.appendChild(digestArchiveProfileInput); + digestArchiveControls.appendChild(digestArchiveProfileField); + + const digestArchiveModeField = createElement('label', 'assistant-preference-field'); + digestArchiveModeField.appendChild(createElement('span', 'metric', 'Archive Mode')); + const digestArchiveModeSelect = createElement('select', 'assistant-preference-input'); + [{ value: '', label: 'all' }, ...(digestPreferenceProfile?.available_modes || []).map((mode) => ({ value: mode, label: mode }))].forEach((item) => { + const option = createElement('option', '', item.label); + option.value = item.value; + option.selected = item.value === (assistantDigestArchiveState.digest_mode || ''); + digestArchiveModeSelect.appendChild(option); + }); + digestArchiveModeField.appendChild(digestArchiveModeSelect); + digestArchiveControls.appendChild(digestArchiveModeField); + + const digestArchiveKindField = createElement('label', 'assistant-preference-field'); + digestArchiveKindField.appendChild(createElement('span', 'metric', 'Archive Kind')); + const digestArchiveKindSelect = createElement('select', 'assistant-preference-input'); + [{ value: '', label: 'all' }, ...(digestHistoryGroups || []).map((item) => ({ value: item.kind, label: item.kind }))].forEach((item) => { + const option = createElement('option', '', item.label); + option.value = item.value; + option.selected = item.value === (assistantDigestArchiveState.preferred_history_kind || ''); + digestArchiveKindSelect.appendChild(option); + }); + digestArchiveKindField.appendChild(digestArchiveKindSelect); + digestArchiveControls.appendChild(digestArchiveKindField); + + const digestArchiveSearchField = createElement('label', 'assistant-preference-field'); + digestArchiveSearchField.appendChild(createElement('span', 'metric', 'Search Text')); + const digestArchiveSearchInput = createElement('input', 'assistant-preference-input'); + digestArchiveSearchInput.type = 'text'; + digestArchiveSearchInput.value = assistantDigestArchiveState.query_text || ''; + digestArchiveSearchInput.placeholder = 'summary, reminder, candidate...'; + digestArchiveSearchField.appendChild(digestArchiveSearchInput); + digestArchiveControls.appendChild(digestArchiveSearchField); + + const digestArchiveAfterField = createElement('label', 'assistant-preference-field'); + digestArchiveAfterField.appendChild(createElement('span', 'metric', 'Created After')); + const digestArchiveAfterInput = createElement('input', 'assistant-preference-input'); + digestArchiveAfterInput.type = 'datetime-local'; + digestArchiveAfterInput.value = assistantDigestArchiveState.created_after || ''; + digestArchiveAfterField.appendChild(digestArchiveAfterInput); + digestArchiveControls.appendChild(digestArchiveAfterField); + + const digestArchiveBeforeField = createElement('label', 'assistant-preference-field'); + digestArchiveBeforeField.appendChild(createElement('span', 'metric', 'Created Before')); + const digestArchiveBeforeInput = createElement('input', 'assistant-preference-input'); + digestArchiveBeforeInput.type = 'datetime-local'; + digestArchiveBeforeInput.value = assistantDigestArchiveState.created_before || ''; + digestArchiveBeforeField.appendChild(digestArchiveBeforeInput); + digestArchiveControls.appendChild(digestArchiveBeforeField); + + const digestArchiveLimitField = createElement('label', 'assistant-preference-field'); + digestArchiveLimitField.appendChild(createElement('span', 'metric', 'Archive Limit')); + const digestArchiveLimitInput = createElement('input', 'assistant-preference-input'); + digestArchiveLimitInput.type = 'number'; + digestArchiveLimitInput.min = '1'; + digestArchiveLimitInput.max = '50'; + digestArchiveLimitInput.value = String(assistantDigestArchiveState.limit || 12); + digestArchiveLimitField.appendChild(digestArchiveLimitInput); + digestArchiveControls.appendChild(digestArchiveLimitField); + digestArchivePanel.appendChild(digestArchiveControls); decisionAssistantPanel.appendChild( buildActionBar([ { - label: 'Threshold 60', + label: 'Apply Archive Filters', onClick: async () => { - await persistAssistantDigestPreferenceServer({ - profileKey: selectedProfileKey, - digestMode: effectiveDigestPreferenceMode, - preferredHistoryKind: activeHistoryFilter === 'all' ? null : activeHistoryFilter, - importanceThreshold: 60, + await fetchAssistantDigestArchive({ + profile_key: digestArchiveProfileInput.value || selectedProfileKey, + digest_mode: digestArchiveModeSelect.value || '', + preferred_history_kind: digestArchiveKindSelect.value || '', + query_text: digestArchiveSearchInput.value || '', + created_after: digestArchiveAfterInput.value || '', + created_before: digestArchiveBeforeInput.value || '', + limit: Number(digestArchiveLimitInput.value || 12), + selected_left_id: assistantDigestArchiveState.selected_left_id, + selected_right_id: assistantDigestArchiveState.selected_right_id, }); - await refreshDashboard(); - }, - }, - { - label: 'Threshold 70', - onClick: async () => { - await persistAssistantDigestPreferenceServer({ - profileKey: selectedProfileKey, - digestMode: effectiveDigestPreferenceMode, - preferredHistoryKind: activeHistoryFilter === 'all' ? null : activeHistoryFilter, - importanceThreshold: 70, - }); - await refreshDashboard(); - }, - }, - { - label: 'Threshold 85', - onClick: async () => { - await persistAssistantDigestPreferenceServer({ - profileKey: selectedProfileKey, - digestMode: effectiveDigestPreferenceMode, - preferredHistoryKind: activeHistoryFilter === 'all' ? null : activeHistoryFilter, - importanceThreshold: 85, - }); - await refreshDashboard(); - }, - }, - { - label: 'Load Archive', - onClick: async () => { - await fetchAssistantDigestArchive({ preferred_history_kind: activeHistoryFilter === 'all' ? null : activeHistoryFilter }); renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); }, }, { - label: 'Compare Latest Digests', + label: 'Load More Archive', onClick: async () => { - await fetchAssistantDigestArchive({ preferred_history_kind: activeHistoryFilter === 'all' ? null : activeHistoryFilter }); + await fetchAssistantDigestArchive({ + append: true, + profile_key: assistantDigestArchiveState.profile_key || selectedProfileKey, + digest_mode: assistantDigestArchiveState.digest_mode || '', + preferred_history_kind: assistantDigestArchiveState.preferred_history_kind || '', + query_text: assistantDigestArchiveState.query_text || '', + created_after: assistantDigestArchiveState.created_after || '', + created_before: assistantDigestArchiveState.created_before || '', + limit: Number(assistantDigestArchiveState.limit || 12), + selected_left_id: assistantDigestArchiveState.selected_left_id, + selected_right_id: assistantDigestArchiveState.selected_right_id, + }); + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }, + }, + { + label: 'Compare Selected Digests', + onClick: async () => { + if (!assistantDigestArchiveState.selected_left_id || !assistantDigestArchiveState.selected_right_id) { + showErrorPanel('select_two_digest_snapshots'); + return; + } + await fetchAssistantDigestArchive({ + profile_key: assistantDigestArchiveState.profile_key || selectedProfileKey, + digest_mode: assistantDigestArchiveState.digest_mode || '', + preferred_history_kind: assistantDigestArchiveState.preferred_history_kind || '', + query_text: assistantDigestArchiveState.query_text || '', + created_after: assistantDigestArchiveState.created_after || '', + created_before: assistantDigestArchiveState.created_before || '', + limit: Number(assistantDigestArchiveState.limit || 12), + }); + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }, + }, + { + label: 'Export Archive Results', + onClick: async () => { + const exportLimit = Math.max(Number(assistantDigestArchiveState.limit || 12), Number(digestArchiveData?.items?.length || 0)); + const exportPayload = await fetchJson(`/trading/alerts/digest-history-archive/export?${queryString({ + instrument_id: Number(instrumentIdInput.value || 1), + profile_key: assistantDigestArchiveState.profile_key || selectedProfileKey, + digest_mode: assistantDigestArchiveState.digest_mode || '', + preferred_history_kind: assistantDigestArchiveState.preferred_history_kind || '', + query_text: assistantDigestArchiveState.query_text || '', + created_after: assistantDigestArchiveState.created_after || '', + created_before: assistantDigestArchiveState.created_before || '', + limit: exportLimit, + offset: 0, + })}`); + downloadJsonFile(exportPayload.export_name || 'digest-archive-export.json', exportPayload); + }, + }, + { + label: 'Export Archive Compare', + onClick: async () => { + if (!assistantDigestArchiveState.selected_left_id || !assistantDigestArchiveState.selected_right_id) { + showErrorPanel('select_two_digest_snapshots'); + return; + } + const exportLimit = Math.max(Number(assistantDigestArchiveState.limit || 12), Number(digestArchiveData?.items?.length || 0)); + const exportPayload = await fetchJson(`/trading/alerts/digest-history-archive/export?${queryString({ + instrument_id: Number(instrumentIdInput.value || 1), + profile_key: assistantDigestArchiveState.profile_key || selectedProfileKey, + digest_mode: assistantDigestArchiveState.digest_mode || '', + preferred_history_kind: assistantDigestArchiveState.preferred_history_kind || '', + query_text: assistantDigestArchiveState.query_text || '', + created_after: assistantDigestArchiveState.created_after || '', + created_before: assistantDigestArchiveState.created_before || '', + limit: exportLimit, + offset: 0, + left_id: assistantDigestArchiveState.selected_left_id, + right_id: assistantDigestArchiveState.selected_right_id, + })}`); + downloadJsonFile(exportPayload.export_name || 'digest-archive-compare-export.json', exportPayload); + }, + }, + { + label: 'Clear Archive Filters', + onClick: () => { + assistantDigestArchiveState = { + profile_key: selectedProfileKey, + preferred_history_kind: '', + digest_mode: '', + query_text: '', + created_after: '', + created_before: '', + limit: 12, + next_offset: 0, + selected_left_id: null, + selected_right_id: null, + pinned_baseline_id: null, + }; + window.__assistantDigestArchiveResponse = null; + window.__assistantDigestArchiveCompare = null; renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); }, }, ]) ); + digestArchivePanel.appendChild( + buildInfoGrid([ + ['archive query profile', assistantDigestArchiveState.profile_key || selectedProfileKey], + ['archive query mode', assistantDigestArchiveState.digest_mode || 'all'], + ['archive query kind', assistantDigestArchiveState.preferred_history_kind || 'all'], + ['archive query text', assistantDigestArchiveState.query_text || 'n/a'], + ['archive created after', assistantDigestArchiveState.created_after || 'n/a'], + ['archive created before', assistantDigestArchiveState.created_before || 'n/a'], + ['archive trend', digestArchiveLongRangeSummary?.trend_status || 'n/a'], + ['archive 7d / 30d', `${digestArchiveLongRangeSummary?.recent_7d_count ?? 0} / ${digestArchiveLongRangeSummary?.recent_30d_count ?? 0}`], + ['archive span days', String(digestArchiveLongRangeSummary?.covered_day_span ?? 0)], + ['archive baseline', String(assistantDigestArchiveState.pinned_baseline_id ?? digestArchiveBaselineSummary?.baseline_snapshot_id ?? 'n/a')], + ['archive baseline prompt', digestArchiveBaselineSummary?.suggested_compare_prompt || 'n/a'], + ['archive next offset', String(digestArchiveData?.next_offset ?? assistantDigestArchiveState.next_offset ?? 0)], + ['compare left snapshot', String(assistantDigestArchiveState.selected_left_id ?? 'n/a')], + ['compare right snapshot', String(assistantDigestArchiveState.selected_right_id ?? 'n/a')], + ]) + ); + decisionAssistantPanel.appendChild(digestArchivePanel); if (digestArchiveCompare?.status === 'active') { + const digestCompareLeft = digestArchiveCompare.left || {}; + const digestCompareRight = digestArchiveCompare.right || {}; decisionAssistantPanel.appendChild( buildInfoGrid([ - ['compare left', formatTimestamp(digestArchiveCompare.left_created_ts)], - ['compare right', formatTimestamp(digestArchiveCompare.right_created_ts)], + ['compare left', formatTimestamp(digestArchiveCompare.left_created_ts || digestCompareLeft.created_ts)], + ['compare right', formatTimestamp(digestArchiveCompare.right_created_ts || digestCompareRight.created_ts)], ['changed fields', formatList(digestArchiveCompare.changed_fields || [])], ['added titles', formatList(digestArchiveCompare.added_titles || [])], ['removed titles', formatList(digestArchiveCompare.removed_titles || [])], ['reminder delta', String(digestArchiveCompare.count_delta?.reminder_count ?? 0)], ['high value delta', String(digestArchiveCompare.count_delta?.high_value_reminder_count ?? 0)], + ['left body', digestCompareLeft.summary_body || 'n/a'], + ['right body', digestCompareRight.summary_body || 'n/a'], ]) ); } if (digestArchiveData?.items?.length) { const archiveGrid = createElement('div', 'cards assistant-tight-grid'); (digestArchiveData.items || []).forEach((item) => { + const isLeft = String(item.snapshot_id || '') === String(assistantDigestArchiveState.selected_left_id || ''); + const isRight = String(item.snapshot_id || '') === String(assistantDigestArchiveState.selected_right_id || ''); + const baselineId = assistantDigestArchiveState.pinned_baseline_id || digestArchiveBaselineSummary?.baseline_snapshot_id; + const isBaseline = String(item.snapshot_id || '') === String(baselineId || ''); + const compareLabel = isBaseline ? 'baseline_pinned' : (isLeft ? 'left_selected' : (isRight ? 'right_selected' : (item.digest_mode || 'digest'))); + const actions = [ + { + label: isLeft ? 'Left Selected' : 'Select Left', + onClick: async () => { + selectAssistantDigestArchiveSnapshot('left', item.snapshot_id); + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }, + }, + { + label: isRight ? 'Right Selected' : 'Select Right', + onClick: async () => { + selectAssistantDigestArchiveSnapshot('right', item.snapshot_id); + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }, + }, + { + label: isBaseline ? 'Baseline Pinned' : 'Pin Baseline', + onClick: async () => { + assistantDigestArchiveState.pinned_baseline_id = item.snapshot_id; + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }, + }, + { + label: 'Compare To Baseline', + onClick: async () => { + const pinnedId = assistantDigestArchiveState.pinned_baseline_id || digestArchiveBaselineSummary?.baseline_snapshot_id; + if (!pinnedId || String(pinnedId) === String(item.snapshot_id || '')) return; + assistantDigestArchiveState.selected_left_id = item.snapshot_id; + assistantDigestArchiveState.selected_right_id = pinnedId; + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }, + }, + ]; + const card = buildItemCard( + item.top_summary || item.top_opportunity_title || 'digest snapshot', + compareLabel, + [ + ['snapshot', String(item.snapshot_id ?? 'n/a')], + ['time', formatTimestamp(item.created_ts)], + ['history kind', item.preferred_history_kind || 'n/a'], + ['summary body', item.summary_body || 'n/a'], + ['next action', item.next_digest_action_label || 'n/a'], + ['high value reminders', String(item.high_value_reminder_count ?? 0)], + ['reminders', String(item.reminder_count ?? 0)], + ['titles', formatList(item.reminder_titles || [])], + ], + actions + ); + if (isLeft || isRight || isBaseline) card.classList.add('assistant-compare-selected'); archiveGrid.appendChild( - buildItemCard( - item.top_summary || item.top_opportunity_title || 'digest snapshot', - item.digest_mode || 'digest', - [ - ['snapshot', String(item.snapshot_id ?? 'n/a')], - ['time', formatTimestamp(item.created_ts)], - ['history kind', item.preferred_history_kind || 'n/a'], - ['summary body', item.summary_body || 'n/a'], - ['next action', item.next_digest_action_label || 'n/a'], - ['high value reminders', String(item.high_value_reminder_count ?? 0)], - ['reminders', String(item.reminder_count ?? 0)], - ['titles', formatList(item.reminder_titles || [])], - ] - ) + card ); }); decisionAssistantPanel.appendChild(archiveGrid); @@ -1344,6 +2045,8 @@ function renderDecisionAssistant(summary) { ['template', item.importance_template || 'n/a'], ['high value', String(Boolean(item.high_value))], ['high value reason', item.high_value_reason || 'n/a'], + ['focus related', String(Boolean(item.focus_related))], + ['focus candidate', String(item.focus_candidate_id ?? 'n/a')], ] ) ); @@ -1361,12 +2064,7 @@ function renderDecisionAssistant(summary) { label: 'All History', onClick: () => { persistAssistantHistoryKindFilter('all'); - persistAssistantDigestPreferenceServer({ - profileKey: selectedProfileKey, - digestMode: effectiveDigestPreferenceMode, - preferredHistoryKind: null, - importanceThreshold: digestPreferenceProfile?.importance_threshold || 70, - }); + updateAssistantPreferenceDraft({ preferred_history_kind: 'all' }); renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); }, }, @@ -1375,12 +2073,7 @@ function renderDecisionAssistant(summary) { label: `Recommended ${recommendedKind}`, onClick: () => { persistAssistantHistoryKindFilter(recommendedKind); - persistAssistantDigestPreferenceServer({ - profileKey: selectedProfileKey, - digestMode: effectiveDigestPreferenceMode, - preferredHistoryKind: recommendedKind, - importanceThreshold: digestPreferenceProfile?.importance_threshold || 70, - }); + updateAssistantPreferenceDraft({ preferred_history_kind: recommendedKind }); renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); }, }] @@ -1389,12 +2082,7 @@ function renderDecisionAssistant(summary) { label: `${item.kind} (${item.count})${item.recommended ? ' *' : ''}`, onClick: () => { persistAssistantHistoryKindFilter(item.kind || 'all'); - persistAssistantDigestPreferenceServer({ - profileKey: selectedProfileKey, - digestMode: effectiveDigestPreferenceMode, - preferredHistoryKind: item.kind || null, - importanceThreshold: digestPreferenceProfile?.importance_threshold || 70, - }); + updateAssistantPreferenceDraft({ preferred_history_kind: item.kind || 'all' }); renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); }, })), @@ -1486,6 +2174,7 @@ function renderDecisionAssistant(summary) { ['latest note author', reviewEntrypoints.latest_note_author || 'n/a'], ['latest note time', formatTimestamp(reviewEntrypoints.latest_note_ts)], ['focus latest note', formatTimestamp(reviewEntrypoints.focus_latest_note_ts)], + ['focus note prompt', reviewEntrypoints.focus_note_prompt || 'n/a'], ['journal entries', String(reviewEntrypoints.journal_entry_count ?? 0)], ['review queue', String(reviewEntrypoints.review_queue_count ?? 0)], ['next review action', reviewEntrypoints.next_review_action || 'n/a'], @@ -1545,12 +2234,35 @@ function renderDecisionAssistant(summary) { ['review handoff prompt', reviewEntrypoints.review_entry_handoff_summary?.next_handoff_prompt || 'n/a'], ]) ); + if (workflowArchiveBridgeContext) { + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['archive bridge', workflowArchiveBridgeContext.bridge_mode || 'n/a'], + ['archive snapshot', String(workflowArchiveBridgeContext.snapshot_id ?? 'n/a')], + ['archive session', workflowArchiveBridgeContext.session_code || 'n/a'], + ['archive next phase', workflowArchiveBridgeContext.next_phase || 'n/a'], + ['archive artifact', workflowArchiveBridgeContext.next_artifact_kind || 'n/a'], + ['archive prompt', workflowArchiveBridgeContext.next_artifact_prompt || 'n/a'], + ]) + ); + } decisionAssistantPanel.appendChild( buildActionBar([ { label: 'Open Notes', onClick: () => prefillOperatorNote(''), }, + { + label: 'Prefill Focus Note', + onClick: () => prefillOperatorNote( + reviewEntrypoints.focus_note_prefill || '', + { + candidate_id: reviewEntrypoints.focus_candidate_id, + session_code: reviewEntrypoints.focus_session_code, + model_code: reviewEntrypoints.focus_model_code, + }, + ), + }, { label: 'Open Review', onClick: () => revealPanel(postTradeReviewPanel), @@ -1559,6 +2271,13 @@ function renderDecisionAssistant(summary) { label: 'Generate Journal', onClick: () => runAction('/trading/journal/generate', {}), }, + ...(workflowArchiveBridgeContext ? [{ + label: 'Clear Archive Bridge', + onClick: () => { + setWorkflowArchiveBridgeContext(null); + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }, + }] : []), ]) ); if (reviewEntrypoints.review_entry_action_queue?.length) { @@ -1717,70 +2436,404 @@ function renderDecisionAssistant(summary) { buildInfoGrid([ ['workflow artifacts', summary.workflow_artifact_summary.status || 'idle'], ['artifact count', String(summary.workflow_artifact_summary.artifact_count ?? 0)], - ['artifacts ready', String(summary.workflow_artifact_summary.ready_for_handoff_count ?? 0)], - ['artifacts updating', String(summary.workflow_artifact_summary.needs_update_count ?? 0)], - ['artifacts pending', String(summary.workflow_artifact_summary.pending_capture_count ?? 0)], - ['next artifact', summary.workflow_artifact_summary.next_artifact_kind || 'n/a'], - ['next artifact prompt', summary.workflow_artifact_summary.next_artifact_prompt || 'n/a'], - ['workflow archive', String(summary.workflow_artifact_summary.workflow_history_archive_count ?? 0)], - ['workflow archive review', summary.workflow_artifact_summary.next_workflow_history_review || 'n/a'], - ['workflow compare', workflowArchiveCompare?.status || 'idle'], - ['workflow compare summary', workflowArchiveCompare?.summary || 'n/a'], - ['workflow persistence', summary.workflow_artifact_summary.workflow_persistence_status || 'n/a'], - ]) - ); + ['artifacts ready', String(summary.workflow_artifact_summary.ready_for_handoff_count ?? 0)], + ['artifacts updating', String(summary.workflow_artifact_summary.needs_update_count ?? 0)], + ['artifacts pending', String(summary.workflow_artifact_summary.pending_capture_count ?? 0)], + ['next artifact', summary.workflow_artifact_summary.next_artifact_kind || 'n/a'], + ['next artifact prompt', summary.workflow_artifact_summary.next_artifact_prompt || 'n/a'], + ['workflow archive', String(summary.workflow_artifact_summary.workflow_history_archive_count ?? 0)], + ['workflow archive review', summary.workflow_artifact_summary.next_workflow_history_review || 'n/a'], + ['workflow day groups', String(workflowArchiveData?.daily_group_count ?? workflowArchiveDailyGroups.length ?? 0)], + ['workflow session groups', String(workflowArchiveData?.session_group_count ?? workflowArchiveSessionGroups.length ?? 0)], + ['workflow compare', workflowArchiveCompare?.status || 'idle'], + ['workflow compare summary', workflowArchiveCompare?.summary || 'n/a'], + ['workflow persistence', summary.workflow_artifact_summary.workflow_persistence_status || 'n/a'], + ]) + ); } + if (workflowArchiveBridgeSummary) { + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['workflow bridge', workflowArchiveBridgeSummary.status || 'idle'], + ['bridge targets', String(workflowArchiveBridgeSummary.bridge_target_count ?? 0)], + ['replay ready', String(workflowArchiveBridgeSummary.replay_ready_count ?? 0)], + ['note ready', String(workflowArchiveBridgeSummary.note_ready_count ?? 0)], + ['handoff ready', String(workflowArchiveBridgeSummary.handoff_ready_count ?? 0)], + ['bridge snapshot', String(workflowArchiveBridgeSummary.latest_snapshot_id ?? 'n/a')], + ['bridge session', workflowArchiveBridgeSummary.latest_session || 'n/a'], + ['bridge next phase', workflowArchiveBridgeSummary.latest_next_phase || 'n/a'], + ['bridge replay prompt', workflowArchiveBridgeSummary.next_replay_prompt || 'n/a'], + ['bridge handoff prompt', workflowArchiveBridgeSummary.next_handoff_prompt || 'n/a'], + ]) + ); + } + if (workflowArchiveBridgeContext) { + decisionAssistantPanel.appendChild( + buildActionBar([ + { + label: 'Open Bridged Replay', + onClick: () => revealAssistantSection('Setup Replay'), + }, + { + label: 'Prefill Bridged Note', + onClick: () => prefillOperatorNote(buildWorkflowArchiveBridgeNote(workflowArchiveBridgeContext), workflowArchiveBridgeContext), + }, + { + label: 'Clear Workflow Bridge', + onClick: () => { + setWorkflowArchiveBridgeContext(null); + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }, + }, + ]) + ); + } + const workflowArchivePanel = createElement('div', 'assistant-archive-panel'); + const workflowArchiveControls = createElement('div', 'assistant-archive-controls'); + const workflowProfileField = createElement('label', 'assistant-preference-field'); + workflowProfileField.appendChild(createElement('span', 'metric', 'Workflow Profile')); + const workflowProfileInput = createElement('input', 'assistant-preference-input'); + workflowProfileInput.type = 'text'; + workflowProfileInput.value = assistantWorkflowArchiveState.profile_key || selectedProfileKey; + workflowProfileField.appendChild(workflowProfileInput); + workflowArchiveControls.appendChild(workflowProfileField); + + const workflowSessionField = createElement('label', 'assistant-preference-field'); + workflowSessionField.appendChild(createElement('span', 'metric', 'Current Session')); + const workflowSessionInput = createElement('input', 'assistant-preference-input'); + workflowSessionInput.type = 'text'; + workflowSessionInput.value = assistantWorkflowArchiveState.current_session || ''; + workflowSessionField.appendChild(workflowSessionInput); + workflowArchiveControls.appendChild(workflowSessionField); + + const workflowPhaseField = createElement('label', 'assistant-preference-field'); + workflowPhaseField.appendChild(createElement('span', 'metric', 'Next Phase')); + const workflowPhaseSelect = createElement('select', 'assistant-preference-input'); + ['', 'pre_market', 'intraday', 'post_trade'].forEach((phase) => { + const option = createElement('option', '', phase || 'all'); + option.value = phase; + option.selected = phase === (assistantWorkflowArchiveState.next_phase || ''); + workflowPhaseSelect.appendChild(option); + }); + workflowPhaseField.appendChild(workflowPhaseSelect); + workflowArchiveControls.appendChild(workflowPhaseField); + + const workflowArtifactField = createElement('label', 'assistant-preference-field'); + workflowArtifactField.appendChild(createElement('span', 'metric', 'Artifact Kind')); + const workflowArtifactInput = createElement('input', 'assistant-preference-input'); + workflowArtifactInput.type = 'text'; + workflowArtifactInput.value = assistantWorkflowArchiveState.next_artifact_kind || ''; + workflowArtifactField.appendChild(workflowArtifactInput); + workflowArchiveControls.appendChild(workflowArtifactField); + + const workflowSearchField = createElement('label', 'assistant-preference-field'); + workflowSearchField.appendChild(createElement('span', 'metric', 'Search Text')); + const workflowSearchInput = createElement('input', 'assistant-preference-input'); + workflowSearchInput.type = 'text'; + workflowSearchInput.value = assistantWorkflowArchiveState.query_text || ''; + workflowSearchInput.placeholder = 'phase, prompt, body...'; + workflowSearchField.appendChild(workflowSearchInput); + workflowArchiveControls.appendChild(workflowSearchField); + + const workflowAfterField = createElement('label', 'assistant-preference-field'); + workflowAfterField.appendChild(createElement('span', 'metric', 'Created After')); + const workflowAfterInput = createElement('input', 'assistant-preference-input'); + workflowAfterInput.type = 'datetime-local'; + workflowAfterInput.value = assistantWorkflowArchiveState.created_after || ''; + workflowAfterField.appendChild(workflowAfterInput); + workflowArchiveControls.appendChild(workflowAfterField); + + const workflowBeforeField = createElement('label', 'assistant-preference-field'); + workflowBeforeField.appendChild(createElement('span', 'metric', 'Created Before')); + const workflowBeforeInput = createElement('input', 'assistant-preference-input'); + workflowBeforeInput.type = 'datetime-local'; + workflowBeforeInput.value = assistantWorkflowArchiveState.created_before || ''; + workflowBeforeField.appendChild(workflowBeforeInput); + workflowArchiveControls.appendChild(workflowBeforeField); + + const workflowLimitField = createElement('label', 'assistant-preference-field'); + workflowLimitField.appendChild(createElement('span', 'metric', 'Workflow Limit')); + const workflowLimitInput = createElement('input', 'assistant-preference-input'); + workflowLimitInput.type = 'number'; + workflowLimitInput.min = '1'; + workflowLimitInput.max = '50'; + workflowLimitInput.value = String(assistantWorkflowArchiveState.limit || 12); + workflowLimitField.appendChild(workflowLimitInput); + workflowArchiveControls.appendChild(workflowLimitField); + workflowArchivePanel.appendChild(workflowArchiveControls); decisionAssistantPanel.appendChild( buildActionBar([ { - label: 'All Workflow', + label: 'Apply Workflow Filters', onClick: async () => { - await fetchAssistantWorkflowArchive({}); + await fetchAssistantWorkflowArchive({ + profile_key: workflowProfileInput.value || selectedProfileKey, + current_session: workflowSessionInput.value || '', + next_phase: workflowPhaseSelect.value || '', + next_artifact_kind: workflowArtifactInput.value || '', + query_text: workflowSearchInput.value || '', + created_after: workflowAfterInput.value || '', + created_before: workflowBeforeInput.value || '', + limit: Number(workflowLimitInput.value || 12), + selected_left_id: assistantWorkflowArchiveState.selected_left_id, + selected_right_id: assistantWorkflowArchiveState.selected_right_id, + }); renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); }, }, { - label: 'Pre-Market', + label: 'Load More Workflow', onClick: async () => { - await fetchAssistantWorkflowArchive({ next_phase: 'pre_market' }); + await fetchAssistantWorkflowArchive({ + append: true, + profile_key: assistantWorkflowArchiveState.profile_key || selectedProfileKey, + current_session: assistantWorkflowArchiveState.current_session || '', + next_phase: assistantWorkflowArchiveState.next_phase || '', + next_artifact_kind: assistantWorkflowArchiveState.next_artifact_kind || '', + query_text: assistantWorkflowArchiveState.query_text || '', + created_after: assistantWorkflowArchiveState.created_after || '', + created_before: assistantWorkflowArchiveState.created_before || '', + limit: Number(assistantWorkflowArchiveState.limit || 12), + selected_left_id: assistantWorkflowArchiveState.selected_left_id, + selected_right_id: assistantWorkflowArchiveState.selected_right_id, + }); renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); }, }, { - label: 'Intraday', + label: 'Compare Selected Workflow', onClick: async () => { - await fetchAssistantWorkflowArchive({ next_phase: 'intraday' }); + if (!assistantWorkflowArchiveState.selected_left_id || !assistantWorkflowArchiveState.selected_right_id) { + showErrorPanel('select_two_workflow_snapshots'); + return; + } + await fetchAssistantWorkflowArchive({ + profile_key: assistantWorkflowArchiveState.profile_key || selectedProfileKey, + current_session: assistantWorkflowArchiveState.current_session || '', + next_phase: assistantWorkflowArchiveState.next_phase || '', + next_artifact_kind: assistantWorkflowArchiveState.next_artifact_kind || '', + query_text: assistantWorkflowArchiveState.query_text || '', + created_after: assistantWorkflowArchiveState.created_after || '', + created_before: assistantWorkflowArchiveState.created_before || '', + limit: Number(assistantWorkflowArchiveState.limit || 12), + }); renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); }, }, { - label: 'Post-Trade', - onClick: async () => { - await fetchAssistantWorkflowArchive({ next_phase: 'post_trade' }); - renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + label: 'Export Workflow Results', + onClick: async () => { + const exportLimit = Math.max(Number(assistantWorkflowArchiveState.limit || 12), Number(workflowArchiveData?.items?.length || 0)); + const exportPayload = await fetchJson(`/trading/workflow-archive/export?${queryString({ + instrument_id: Number(instrumentIdInput.value || 1), + profile_key: assistantWorkflowArchiveState.profile_key || selectedProfileKey, + current_session: assistantWorkflowArchiveState.current_session || '', + next_phase: assistantWorkflowArchiveState.next_phase || '', + next_artifact_kind: assistantWorkflowArchiveState.next_artifact_kind || '', + query_text: assistantWorkflowArchiveState.query_text || '', + created_after: assistantWorkflowArchiveState.created_after || '', + created_before: assistantWorkflowArchiveState.created_before || '', + limit: exportLimit, + offset: 0, + })}`); + downloadJsonFile(exportPayload.export_name || 'workflow-archive-export.json', exportPayload); }, }, { - label: 'Compare Latest Workflow', - onClick: async () => { - await fetchAssistantWorkflowArchive({}); + label: 'Export Workflow Compare', + onClick: async () => { + if (!assistantWorkflowArchiveState.selected_left_id || !assistantWorkflowArchiveState.selected_right_id) { + showErrorPanel('select_two_workflow_snapshots'); + return; + } + const exportLimit = Math.max(Number(assistantWorkflowArchiveState.limit || 12), Number(workflowArchiveData?.items?.length || 0)); + const exportPayload = await fetchJson(`/trading/workflow-archive/export?${queryString({ + instrument_id: Number(instrumentIdInput.value || 1), + profile_key: assistantWorkflowArchiveState.profile_key || selectedProfileKey, + current_session: assistantWorkflowArchiveState.current_session || '', + next_phase: assistantWorkflowArchiveState.next_phase || '', + next_artifact_kind: assistantWorkflowArchiveState.next_artifact_kind || '', + query_text: assistantWorkflowArchiveState.query_text || '', + created_after: assistantWorkflowArchiveState.created_after || '', + created_before: assistantWorkflowArchiveState.created_before || '', + limit: exportLimit, + offset: 0, + left_id: assistantWorkflowArchiveState.selected_left_id, + right_id: assistantWorkflowArchiveState.selected_right_id, + })}`); + downloadJsonFile(exportPayload.export_name || 'workflow-archive-compare-export.json', exportPayload); + }, + }, + { + label: 'Clear Workflow Filters', + onClick: () => { + assistantWorkflowArchiveState = { + profile_key: selectedProfileKey, + current_session: '', + next_phase: '', + next_artifact_kind: '', + query_text: '', + created_after: '', + created_before: '', + limit: 12, + next_offset: 0, + selected_left_id: null, + selected_right_id: null, + pinned_baseline_id: null, + }; + window.__assistantWorkflowArchiveResponse = null; + window.__assistantWorkflowArchiveCompare = null; renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); }, }, ]) ); - if (workflowArchiveCompare?.status === 'active') { + workflowArchivePanel.appendChild( + buildInfoGrid([ + ['workflow query profile', assistantWorkflowArchiveState.profile_key || selectedProfileKey], + ['workflow query session', assistantWorkflowArchiveState.current_session || 'all'], + ['workflow query phase', assistantWorkflowArchiveState.next_phase || 'all'], + ['workflow query artifact', assistantWorkflowArchiveState.next_artifact_kind || 'all'], + ['workflow query text', assistantWorkflowArchiveState.query_text || 'n/a'], + ['workflow created after', assistantWorkflowArchiveState.created_after || 'n/a'], + ['workflow created before', assistantWorkflowArchiveState.created_before || 'n/a'], + ['workflow trend', workflowArchiveLongRangeSummary?.trend_status || 'n/a'], + ['workflow 7d / 30d', `${workflowArchiveLongRangeSummary?.recent_7d_count ?? 0} / ${workflowArchiveLongRangeSummary?.recent_30d_count ?? 0}`], + ['workflow span days', String(workflowArchiveLongRangeSummary?.covered_day_span ?? 0)], + ['workflow baseline', String(assistantWorkflowArchiveState.pinned_baseline_id ?? workflowArchiveBaselineSummary?.baseline_snapshot_id ?? 'n/a')], + ['workflow baseline prompt', workflowArchiveBaselineSummary?.suggested_compare_prompt || 'n/a'], + ['workflow next offset', String(workflowArchiveData?.next_offset ?? assistantWorkflowArchiveState.next_offset ?? 0)], + ['workflow compare left', String(assistantWorkflowArchiveState.selected_left_id ?? 'n/a')], + ['workflow compare right', String(assistantWorkflowArchiveState.selected_right_id ?? 'n/a')], + ]) + ); + decisionAssistantPanel.appendChild(workflowArchivePanel); + if (workflowArchiveOperationsSummary) { decisionAssistantPanel.appendChild( buildInfoGrid([ - ['workflow compare left', formatTimestamp(workflowArchiveCompare.left_created_ts)], - ['workflow compare right', formatTimestamp(workflowArchiveCompare.right_created_ts)], + ['workflow operations', workflowArchiveOperationsSummary.status || 'idle'], + ['operation snapshots', String(workflowArchiveOperationsSummary.snapshot_count ?? 0)], + ['day groups', String(workflowArchiveOperationsSummary.day_group_count ?? 0)], + ['session groups', String(workflowArchiveOperationsSummary.session_group_count ?? 0)], + ['latest day group', workflowArchiveOperationsSummary.latest_group_date || 'n/a'], + ['most active session', workflowArchiveOperationsSummary.most_active_session || 'n/a'], + ['phase names', String(workflowArchiveOperationsSummary.phase_name_count ?? 0)], + ['operations prompt', workflowArchiveOperationsSummary.next_operations_prompt || 'n/a'], + ]) + ); + } + if (workflowArchiveLongRangeSummary) { + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['workflow long-range', workflowArchiveLongRangeSummary.status || 'idle'], + ['workflow trend status', workflowArchiveLongRangeSummary.trend_status || 'n/a'], + ['workflow 7d snapshots', String(workflowArchiveLongRangeSummary.recent_7d_count ?? 0)], + ['workflow 30d snapshots', String(workflowArchiveLongRangeSummary.recent_30d_count ?? 0)], + ['workflow 7d ready', String(workflowArchiveLongRangeSummary.recent_7d_ready_count ?? 0)], + ['workflow 30d ready', String(workflowArchiveLongRangeSummary.recent_30d_ready_count ?? 0)], + ['workflow sessions', String(workflowArchiveLongRangeSummary.distinct_session_count ?? 0)], + ['workflow next long-range review', workflowArchiveLongRangeSummary.next_review_prompt || 'n/a'], + ]) + ); + } + if (workflowArchiveBaselineSummary) { + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['workflow baseline status', workflowArchiveBaselineSummary.status || 'idle'], + ['workflow baseline latest', String(workflowArchiveBaselineSummary.latest_snapshot_id ?? 'n/a')], + ['workflow baseline pinned', String(assistantWorkflowArchiveState.pinned_baseline_id ?? workflowArchiveBaselineSummary.baseline_snapshot_id ?? 'n/a')], + ['workflow baseline session', workflowArchiveBaselineSummary.baseline_session || 'n/a'], + ['workflow baseline phase', workflowArchiveBaselineSummary.baseline_next_phase || 'n/a'], + ['workflow baseline prompt', workflowArchiveBaselineSummary.suggested_compare_prompt || 'n/a'], + ]) + ); + } + if (workflowArchiveDriftReview) { + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['workflow drift', workflowArchiveDriftReview.status || 'idle'], + ['drift status', workflowArchiveDriftReview.drift_status || 'n/a'], + ['drift left day', workflowArchiveDriftReview.left_group_date || 'n/a'], + ['drift right day', workflowArchiveDriftReview.right_group_date || 'n/a'], + ['changed phases', String(workflowArchiveDriftReview.changed_phase_count ?? 0)], + ['session changed', workflowArchiveDriftReview.session_changed ? 'yes' : 'no'], + ['next phase changed', workflowArchiveDriftReview.next_phase_changed ? 'yes' : 'no'], + ['drift summary', workflowArchiveDriftReview.summary || 'n/a'], + ['drift next prompt', workflowArchiveDriftReview.next_drift_prompt || 'n/a'], + ]) + ); + } + if (workflowArchiveDailyGroups.length) { + const workflowDayGroupGrid = createElement('div', 'cards assistant-tight-grid'); + workflowArchiveDailyGroups.forEach((item) => { + workflowDayGroupGrid.appendChild( + buildItemCard( + `Day Group: ${item.group_date || 'workflow'}`, + item.status || 'active', + [ + ['snapshots', String(item.snapshot_count ?? 0)], + ['latest time', formatTimestamp(item.latest_created_ts)], + ['sessions', formatList(item.sessions || [])], + ['next phase', item.next_phase || 'n/a'], + ['artifact counts', `${item.artifact_count ?? 0} / ${item.ready_for_handoff_count ?? 0} / ${item.needs_update_count ?? 0} / ${item.pending_capture_count ?? 0}`], + ['phase names', formatList(item.phase_names || [])], + ['workflow body', item.workflow_body || 'n/a'], + ['next prompt', item.next_artifact_prompt || 'n/a'], + ] + ) + ); + }); + decisionAssistantPanel.appendChild(workflowDayGroupGrid); + } + if (workflowArchiveSessionGroups.length) { + const workflowSessionGroupGrid = createElement('div', 'cards assistant-tight-grid'); + workflowArchiveSessionGroups.forEach((item) => { + workflowSessionGroupGrid.appendChild( + buildItemCard( + `Session Group: ${item.session_code || 'workflow'}`, + item.status || 'active', + [ + ['snapshots', String(item.snapshot_count ?? 0)], + ['day count', String(item.day_count ?? 0)], + ['latest time', formatTimestamp(item.latest_created_ts)], + ['latest day group', item.latest_group_date || 'n/a'], + ['latest next phase', item.latest_next_phase || 'n/a'], + ['artifact counts', `${item.artifact_count ?? 0} / ${item.ready_for_handoff_count ?? 0} / ${item.needs_update_count ?? 0} / ${item.pending_capture_count ?? 0}`], + ['phase names', formatList(item.phase_names || [])], + ] + ) + ); + }); + decisionAssistantPanel.appendChild(workflowSessionGroupGrid); + } + if (workflowArchiveCompare?.status === 'active') { + const workflowPhaseDrilldown = (workflowArchiveCompare.phase_changes || []).map((item) => `${item.phase}: ${item.changed ? 'changed' : 'same'}`).join(', '); + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['workflow compare left', formatTimestamp(workflowArchiveCompare.left_created_ts || workflowArchiveCompare.left?.created_ts)], + ['workflow compare right', formatTimestamp(workflowArchiveCompare.right_created_ts || workflowArchiveCompare.right?.created_ts)], ['phase changes', String((workflowArchiveCompare.phase_changes || []).filter((item) => item.changed).length)], ['artifact delta', String(workflowArchiveCompare.count_delta?.artifact_count ?? 0)], ['ready delta', String(workflowArchiveCompare.count_delta?.ready_for_handoff_count ?? 0)], ['update delta', String(workflowArchiveCompare.count_delta?.needs_update_count ?? 0)], ['pending delta', String(workflowArchiveCompare.count_delta?.pending_capture_count ?? 0)], + ['phase drilldown', workflowPhaseDrilldown || 'n/a'], ]) ); + const workflowCompareGrid = createElement('div', 'cards assistant-tight-grid'); + (workflowArchiveCompare.phase_changes || []).forEach((item) => { + workflowCompareGrid.appendChild( + buildItemCard( + item.phase || 'phase', + item.changed ? 'changed' : 'same', + [ + ['left body', item.left_body || 'n/a'], + ['right body', item.right_body || 'n/a'], + ] + ) + ); + }); + decisionAssistantPanel.appendChild(workflowCompareGrid); } const workflowGrid = createElement('div', 'cards'); workflowDigest.forEach((item) => { @@ -1825,20 +2878,81 @@ function renderDecisionAssistant(summary) { if (workflowArchiveData?.items?.length) { const workflowArchiveGrid = createElement('div', 'cards assistant-tight-grid'); (workflowArchiveData.items || []).forEach((item) => { + const isLeft = String(item.snapshot_id || '') === String(assistantWorkflowArchiveState.selected_left_id || ''); + const isRight = String(item.snapshot_id || '') === String(assistantWorkflowArchiveState.selected_right_id || ''); + const baselineId = assistantWorkflowArchiveState.pinned_baseline_id || workflowArchiveBaselineSummary?.baseline_snapshot_id; + const isBaseline = String(item.snapshot_id || '') === String(baselineId || ''); + const card = buildItemCard( + item.next_artifact_kind || item.next_phase || 'workflow archive', + isBaseline ? 'baseline_pinned' : (isLeft ? 'left_selected' : (isRight ? 'right_selected' : (item.current_session || 'session'))), + [ + ['snapshot', String(item.snapshot_id ?? 'n/a')], + ['time', formatTimestamp(item.created_ts)], + ['focus mode', item.focus_mode || 'n/a'], + ['artifacts', String(item.artifact_count ?? 0)], + ['ready/update/pending', `${item.ready_for_handoff_count ?? 0} / ${item.needs_update_count ?? 0} / ${item.pending_capture_count ?? 0}`], + ['workflow body', item.workflow_body || 'n/a'], + ['phase briefs', formatList((item.phase_briefs || []).map((row) => `${row.phase || 'phase'}: ${row.archive_body || row.detail || 'n/a'}`))], + ], + [ + { + label: isLeft ? 'Left Selected' : 'Select Left', + onClick: () => { + selectAssistantWorkflowArchiveSnapshot('left', item.snapshot_id); + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }, + }, + { + label: isRight ? 'Right Selected' : 'Select Right', + onClick: () => { + selectAssistantWorkflowArchiveSnapshot('right', item.snapshot_id); + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }, + }, + { + label: 'Replay Bridge', + onClick: () => { + setWorkflowArchiveBridgeContext(buildWorkflowArchiveBridgeContext(item, 'replay')); + revealAssistantSection('Setup Replay'); + }, + }, + { + label: 'Note Bridge', + onClick: () => { + const context = buildWorkflowArchiveBridgeContext(item, 'note'); + setWorkflowArchiveBridgeContext(context); + prefillOperatorNote(buildWorkflowArchiveBridgeNote(item), context); + }, + }, + { + label: 'Handoff Bridge', + onClick: () => { + setWorkflowArchiveBridgeContext(buildWorkflowArchiveBridgeContext(item, 'handoff')); + revealAssistantSection('Workflow'); + }, + }, + { + label: isBaseline ? 'Baseline Pinned' : 'Pin Baseline', + onClick: () => { + assistantWorkflowArchiveState.pinned_baseline_id = item.snapshot_id; + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }, + }, + { + label: 'Compare To Baseline', + onClick: () => { + const pinnedId = assistantWorkflowArchiveState.pinned_baseline_id || workflowArchiveBaselineSummary?.baseline_snapshot_id; + if (!pinnedId || String(pinnedId) === String(item.snapshot_id || '')) return; + assistantWorkflowArchiveState.selected_left_id = item.snapshot_id; + assistantWorkflowArchiveState.selected_right_id = pinnedId; + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }, + }, + ] + ); + if (isLeft || isRight || isBaseline) card.classList.add('assistant-compare-selected'); workflowArchiveGrid.appendChild( - buildItemCard( - item.next_artifact_kind || item.next_phase || 'workflow archive', - item.current_session || 'session', - [ - ['snapshot', String(item.snapshot_id ?? 'n/a')], - ['time', formatTimestamp(item.created_ts)], - ['focus mode', item.focus_mode || 'n/a'], - ['artifacts', String(item.artifact_count ?? 0)], - ['ready/update/pending', `${item.ready_for_handoff_count ?? 0} / ${item.needs_update_count ?? 0} / ${item.pending_capture_count ?? 0}`], - ['workflow body', item.workflow_body || 'n/a'], - ['phase briefs', formatList((item.phase_briefs || []).map((row) => `${row.phase || 'phase'}: ${row.archive_body || row.detail || 'n/a'}`))], - ] - ) + card ); }); decisionAssistantPanel.appendChild(workflowArchiveGrid); @@ -2013,16 +3127,31 @@ function renderDecisionAssistant(summary) { ['news window', externalContext.news_window_code || 'unknown'], ['active events', formatList(externalContext.active_event_titles || [])], ['macro/news calendar', String(externalContext.macro_news_calendar_count ?? 0)], + ['macro/news providers', String(externalContext.macro_news_provider_count ?? 0)], + ['provider states', formatJsonInline(externalContext.macro_news_provider_status_counts || {})], ['event window', externalContext.event_window_status || 'n/a'], ['event window prompt', externalContext.event_window_prompt || 'n/a'], ['high volatility window', String(Boolean(externalContext.high_volatility_window))], ['low liquidity window', String(Boolean(externalContext.low_liquidity_window))], ['exchange anomaly window', String(Boolean(externalContext.exchange_anomaly_window))], ['chart sources', String(externalContext.chart_readonly_source_count ?? 0)], + ['chart provider registry', String(externalContext.chart_provider_registry_count ?? 0)], + ['chart provider states', formatJsonInline(externalContext.chart_provider_registry_status_counts || {})], ['stale chart sources', String(externalContext.chart_stale_source_count ?? 0)], ['chart adapter mode', externalContext.chart_adapter_mode || 'n/a'], ['chart primary mode', externalContext.chart_primary_mode || 'n/a'], ['chart context prompt', externalContext.chart_context_prompt || 'n/a'], + ['exchange surfaces', String(externalContext.exchange_context_source_count ?? 0)], + ['exchange surface states', formatJsonInline(externalContext.exchange_context_status_counts || {})], + ['provider health cards', String(externalContext.provider_health_card_count ?? 0)], + ['provider health states', formatJsonInline(externalContext.provider_health_status_counts || {})], + ['provider health next', externalContext.provider_health_summary?.next_health_target || 'n/a'], + ['provider health prompt', externalContext.provider_health_summary?.next_health_prompt || 'n/a'], + ['visual references', String(externalContext.visual_reference_card_count ?? 0)], + ['visual prompt', externalContext.visual_reference_next_prompt || 'n/a'], + ['visual evidence bridge', externalContext.visual_evidence_bridge_summary?.status || 'n/a'], + ['visual bridge target', externalContext.visual_evidence_bridge_summary?.next_bridge_target || 'n/a'], + ['visual bridge prompt', externalContext.visual_evidence_bridge_summary?.next_bridge_prompt || 'n/a'], ['chart stale evidence', formatList((externalContext.chart_stale_source_evidence || []).map((item) => `${item.source_id}: ${item.evidence}`))], ['exchange status', `${externalContext.exchange_status?.provider_name || 'unknown'} / ${externalContext.exchange_status?.status || 'unknown'}`], ['exchange events', formatList(externalContext.exchange_status_titles || [])], @@ -2034,6 +3163,16 @@ function renderDecisionAssistant(summary) { ['next confirmation targets', formatList(externalContext.next_confirmation_targets || [])], ]) ); + if (externalContext.visual_evidence_bridge_summary?.next_bridge_url) { + decisionAssistantPanel.appendChild( + buildActionBar([ + { + label: 'Open Visual Bridge', + onClick: () => openExternalContextLink(externalContext.visual_evidence_bridge_summary?.next_bridge_url), + }, + ]) + ); + } const sourceCards = externalContext.source_cards || []; if (sourceCards.length) { const sourceGrid = createElement('div', 'cards'); @@ -2050,6 +3189,61 @@ function renderDecisionAssistant(summary) { }); decisionAssistantPanel.appendChild(sourceGrid); } + const providerInventory = externalContext.macro_news_provider_inventory || []; + if (providerInventory.length) { + const providerGrid = createElement('div', 'cards assistant-tight-grid'); + providerInventory.forEach((item) => { + providerGrid.appendChild( + buildItemCard( + item.provider_name || 'macro provider', + item.status || 'unknown', + [ + ['freshness', item.freshness_status || 'n/a'], + ['surface', item.surface_url || 'n/a'], + ['prompt', item.visual_prompt || 'n/a'], + ] + ) + ); + }); + decisionAssistantPanel.appendChild(providerGrid); + } + const providerHealthCards = externalContext.provider_health_cards || []; + if (providerHealthCards.length) { + const healthGrid = createElement('div', 'cards assistant-tight-grid'); + providerHealthCards.forEach((item) => { + const actions = []; + if (item.target_url) { + actions.push({ + label: 'Open Target', + onClick: () => openExternalContextLink(item.target_url), + }); + } + if (item.embed_url) { + actions.push({ + label: 'Open Embed', + onClick: () => openExternalContextLink(item.embed_url), + }); + } + healthGrid.appendChild( + buildItemCard( + item.title || item.provider_name || 'provider health', + item.status || 'monitor', + [ + ['group', item.surface_group || 'n/a'], + ['source status', item.source_status || 'n/a'], + ['freshness', item.freshness_status || 'n/a'], + ['cache', item.cache_state || 'n/a'], + ['capture', String(Boolean(item.capture_supported))], + ['embed', item.embed_url ? 'available' : 'n/a'], + ['reason', item.health_reason || 'n/a'], + ['prompt', item.next_visual_prompt || 'n/a'], + ], + actions + ) + ); + }); + decisionAssistantPanel.appendChild(healthGrid); + } const calendarEvents = externalContext.macro_news_calendar || []; if (calendarEvents.length) { const calendarGrid = createElement('div', 'cards assistant-tight-grid'); @@ -2089,15 +3283,111 @@ function renderDecisionAssistant(summary) { }); decisionAssistantPanel.appendChild(chartGrid); } + const chartProviderRegistry = externalContext.chart_provider_registry || []; + if (chartProviderRegistry.length) { + const chartProviderGrid = createElement('div', 'cards assistant-tight-grid'); + chartProviderRegistry.forEach((item) => { + chartProviderGrid.appendChild( + buildItemCard( + item.provider_name || 'chart provider', + item.status || 'unknown', + [ + ['symbol', item.symbol || 'n/a'], + ['interval', item.interval || 'n/a'], + ['freshness', item.freshness_status || 'n/a'], + ['target', item.target_url || 'n/a'], + ['embed', item.embed_url || 'n/a'], + ['capture', String(Boolean(item.capture_supported))], + ] + ) + ); + }); + decisionAssistantPanel.appendChild(chartProviderGrid); + } + const exchangeSources = externalContext.exchange_context_sources || []; + if (exchangeSources.length) { + const exchangeGrid = createElement('div', 'cards assistant-tight-grid'); + exchangeSources.forEach((item) => { + exchangeGrid.appendChild( + buildItemCard( + item.provider_name || item.surface_type || 'exchange surface', + item.status || 'unknown', + [ + ['surface', item.surface_type || 'n/a'], + ['freshness', item.freshness_status || 'n/a'], + ['cache', item.cache_state || 'n/a'], + ['target', item.target_url || 'n/a'], + ['capture', String(Boolean(item.capture_supported))], + ['prompt', item.visual_prompt || 'n/a'], + ] + ) + ); + }); + decisionAssistantPanel.appendChild(exchangeGrid); + } + const visualReferences = externalContext.visual_reference_cards || []; + if (visualReferences.length) { + const visualGrid = createElement('div', 'cards assistant-tight-grid'); + visualReferences.forEach((item) => { + const actions = []; + if (item.target_url) { + actions.push({ + label: 'Open Target', + onClick: () => openExternalContextLink(item.target_url), + }); + } + if (item.embed_url) { + actions.push({ + label: 'Open Embed', + onClick: () => openExternalContextLink(item.embed_url), + }); + } + visualGrid.appendChild( + buildItemCard( + item.title || item.provider_name || 'visual reference', + item.visual_type || item.status || 'visual', + [ + ['provider', item.provider_name || 'n/a'], + ['status', item.status || 'n/a'], + ['summary', item.summary || 'n/a'], + ['target', item.target_url || 'n/a'], + ['embed', item.embed_url || 'n/a'], + ['capture', String(Boolean(item.capture_supported))], + ['prompt', item.visual_prompt || 'n/a'], + ], + actions + ) + ); + }); + decisionAssistantPanel.appendChild(visualGrid); + } } if (setupLearningRollup) { appendSectionTitle(decisionAssistantPanel, 'Setup Replay'); + if (workflowArchiveBridgeContext) { + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['archive replay bridge', workflowArchiveBridgeContext.bridge_mode || 'n/a'], + ['archive snapshot', String(workflowArchiveBridgeContext.snapshot_id ?? 'n/a')], + ['archive session', workflowArchiveBridgeContext.session_code || 'n/a'], + ['archive next phase', workflowArchiveBridgeContext.next_phase || 'n/a'], + ['archive artifact', workflowArchiveBridgeContext.next_artifact_kind || 'n/a'], + ['archive prompt', workflowArchiveBridgeContext.next_artifact_prompt || 'n/a'], + ['archive workflow body', workflowArchiveBridgeContext.workflow_body || 'n/a'], + ]) + ); + } decisionAssistantPanel.appendChild( buildInfoGrid([ ['setup learning', setupLearningRollup.status || 'idle'], ['setup type', setupLearningRollup.setup_type || 'unknown'], ['setup session', setupLearningRollup.session_code || 'n/a'], ['session source', setupLearningRollup.session_code_source || 'n/a'], + ['focus replay scope', setupLearningRollup.focus_replay_scope || 'n/a'], + ['focus replay candidate', String(setupLearningRollup.focus_candidate_id ?? 'n/a')], + ['focus replay session', setupLearningRollup.focus_session_code || 'n/a'], + ['focus replay model', setupLearningRollup.focus_model_code || 'n/a'], + ['focus replay prompt', setupLearningRollup.focus_replay_prompt || 'n/a'], ['lesson count', String(setupLearningRollup.lesson_count ?? 0)], ['failure attributions', String(setupLearningRollup.learning_attribution_summary?.failure_reason_count ?? 0)], ['missed opportunities', String(setupLearningRollup.learning_attribution_summary?.missed_opportunity_count ?? 0)], @@ -2195,6 +3485,20 @@ function renderDecisionAssistant(summary) { ['low-quality session', setupLearningRollup.low_quality_session?.session_code || 'n/a'], ]) ); + if (workflowArchiveBridgeContext) { + decisionAssistantPanel.appendChild( + buildActionBar([ + { + label: 'Prefill Replay Note', + onClick: () => prefillOperatorNote(buildWorkflowArchiveBridgeNote(workflowArchiveBridgeContext), workflowArchiveBridgeContext), + }, + { + label: 'Jump to Review Entry', + onClick: () => revealAssistantSection('Review Entry'), + }, + ]) + ); + } const broadTopSessions = setupLearningRollup.replay_statistics?.broader_baseline_summary?.top_sessions || []; const broadTopRSessions = setupLearningRollup.replay_statistics?.broader_baseline_summary?.top_r_sessions || []; const broadTopSafe = setupLearningRollup.replay_statistics?.broader_baseline_summary?.top_safe_sessions || []; @@ -2377,6 +3681,10 @@ function renderDecisionAssistant(summary) { [ ['detail', item.detail || 'n/a'], ['reason', item.reason || 'n/a'], + ['focus related', String(Boolean(item.focus_related))], + ['focus candidate', String(item.focus_candidate_id ?? 'n/a')], + ['evidence keys', formatList(item.evidence_keys || [])], + ['missing confirmations', formatList(item.missing_confirmations || [])], ] ) ); @@ -2960,6 +4268,9 @@ function renderVisualReviews(reviewRows, decisionPacketId) { function renderOperatorConsole(noteRows, reviewPacket) { clearNode(operatorConsolePanel); + const assistantSummary = reviewPacket?.decision_assistant_summary || window.__latestDecisionAssistantSummary || null; + const focusContext = assistantOperatorNoteDraft.context || getAssistantFocusContext(assistantSummary); + const reviewEntrypoints = assistantSummary?.review_entrypoints || {}; const authorInput = createElement('input'); authorInput.type = 'text'; authorInput.placeholder = 'operator'; @@ -2968,20 +4279,24 @@ function renderOperatorConsole(noteRows, reviewPacket) { bodyInput.placeholder = 'Add a session, execution, or decision note.'; bodyInput.rows = 4; bodyInput.className = 'operator-note-textarea'; + bodyInput.value = assistantOperatorNoteDraft.body || reviewEntrypoints.focus_note_prefill || ''; const controls = createElement('div', 'actions'); const saveButton = createElement('button', '', 'Save Note'); saveButton.type = 'button'; saveButton.addEventListener('click', async () => { try { + const noteContext = assistantOperatorNoteDraft.context || focusContext; await postJson('/trading/operator-notes', { instrument_id: Number(instrumentIdInput.value || 1), - note_scope: 'workbench', + note_scope: reviewEntrypoints.focus_note_scope || 'workbench', author: authorInput.value || 'local_operator', body: bodyInput.value || '', - session_code: sessionFilterInput.value === 'all' ? null : sessionFilterInput.value, - tags: ['workbench'], + session_code: noteContext?.session_code || (sessionFilterInput.value === 'all' ? null : sessionFilterInput.value), + candidate_id: noteContext?.candidate_id ?? null, + tags: buildOperatorNoteTags(['workbench', ...(reviewEntrypoints.focus_note_tags || [])], noteContext), }); bodyInput.value = ''; + assistantOperatorNoteDraft = { body: '', context: null }; await refreshDashboard(); } catch (error) { showErrorPanel(String(error)); @@ -2991,19 +4306,32 @@ function renderOperatorConsole(noteRows, reviewPacket) { packetButton.type = 'button'; packetButton.addEventListener('click', async () => { try { - await fetchJson(`/trading/review-packet?${queryString({ instrument_id: instrumentIdInput.value || '1' })}`); + await fetchJson(`/trading/review-packet?${queryString({ instrument_id: instrumentIdInput.value || '1', profile_key: assistantDigestProfileKey || 'local_operator' })}`); await refreshDashboard(); } catch (error) { showErrorPanel(String(error)); } }); + const prefillFocusButton = createElement('button', '', 'Prefill Focus Note'); + prefillFocusButton.type = 'button'; + prefillFocusButton.addEventListener('click', () => { + prefillOperatorNote( + reviewEntrypoints.focus_note_prefill || '', + focusContext, + ); + }); controls.appendChild(saveButton); controls.appendChild(packetButton); + controls.appendChild(prefillFocusButton); operatorConsolePanel.appendChild(authorInput); operatorConsolePanel.appendChild(bodyInput); operatorConsolePanel.appendChild(controls); operatorConsolePanel.appendChild( buildInfoGrid([ + ['focus candidate', String(focusContext?.candidate_id ?? 'n/a')], + ['focus session', focusContext?.session_code || 'n/a'], + ['focus model', focusContext?.model_code || 'n/a'], + ['focus note prompt', reviewEntrypoints.focus_note_prompt || 'n/a'], ['review packet', formatTimestamp(reviewPacket?.generated_ts)], ['packet executions', String((reviewPacket?.recent_executions || []).length)], ['packet alerts', String((reviewPacket?.recent_alerts || []).length)], @@ -3518,6 +4846,7 @@ async function refreshDashboard() { const instrumentId = instrumentIdInput.value || '1'; const timeframe = timeframeInput.value || '1m'; const limit = limitInput.value || '20'; + const profileKey = assistantDigestProfileKey || assistantPreferenceDraft?.profile_key || 'local_operator'; try { const [health, candles, tradingPlan, marketState, runtimeStatus, runtimeIncidents, maintenanceSummary, decisionPacket, decisionAlerts, alertQualitySummary, operatorDigest, trustScore, liveReadiness, launchReadiness, sessionView, tradeCandidates, executionTickets, tradingExecutions, journalSummary, operatorNotes, reviewPacket, journalItems, weeklyReview] = await Promise.all([ @@ -3541,7 +4870,7 @@ async function refreshDashboard() { fetchJson(`/trading/executions?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })), fetchJson(`/trading/journal/summary?${queryString({ instrument_id: instrumentId })}`).catch(() => ({ entry_count: 0, next_week_actions: [] })), fetchJson(`/trading/operator-notes?${queryString({ instrument_id: instrumentId, limit: 10 })}`).catch(() => ({ items: [] })), - fetchJson(`/trading/review-packet?${queryString({ instrument_id: instrumentId })}`).catch(() => ({ generated_ts: null, recent_executions: [], recent_alerts: [], recent_notes: [] })), + fetchJson(`/trading/review-packet?${queryString({ instrument_id: instrumentId, profile_key: profileKey })}`).catch(() => ({ generated_ts: null, recent_executions: [], recent_alerts: [], recent_notes: [] })), fetchJson(`/trading/journal?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })), fetchJson(`/trading/research/weekly-review?${queryString({ instrument_id: instrumentId })}`).catch(() => ({ entry_count: 0, next_week_actions: [] })), ]); diff --git a/src/web/styles.css b/src/web/styles.css index b091a37..0350d95 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -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; diff --git a/tests/unit/test_api_server.py b/tests/unit/test_api_server.py index d6a8bd8..2be26f7 100644 --- a/tests/unit/test_api_server.py +++ b/tests/unit/test_api_server.py @@ -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", diff --git a/tests/unit/test_api_static_server.py b/tests/unit/test_api_static_server.py index 61e663c..5b9e8f7 100644 --- a/tests/unit/test_api_static_server.py +++ b/tests/unit/test_api_static_server.py @@ -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() diff --git a/tests/unit/test_archive_long_range_projection.py b/tests/unit/test_archive_long_range_projection.py new file mode 100644 index 0000000..1e65968 --- /dev/null +++ b/tests/unit/test_archive_long_range_projection.py @@ -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() diff --git a/tests/unit/test_documentation_consistency.py b/tests/unit/test_documentation_consistency.py index ad0208d..392f9d7 100644 --- a/tests/unit/test_documentation_consistency.py +++ b/tests/unit/test_documentation_consistency.py @@ -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: diff --git a/tests/unit/test_operator_workspace_service.py b/tests/unit/test_operator_workspace_service.py index 9de0118..2fb164a 100644 --- a/tests/unit/test_operator_workspace_service.py +++ b/tests/unit/test_operator_workspace_service.py @@ -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"]) diff --git a/tests/unit/test_run_until_blocked.py b/tests/unit/test_run_until_blocked.py index 35e19d4..e05e518 100644 --- a/tests/unit/test_run_until_blocked.py +++ b/tests/unit/test_run_until_blocked.py @@ -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() + diff --git a/tests/unit/test_workflow_archive_projection.py b/tests/unit/test_workflow_archive_projection.py new file mode 100644 index 0000000..7de1899 --- /dev/null +++ b/tests/unit/test_workflow_archive_projection.py @@ -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()