diff --git a/SESSION.md b/SESSION.md index fa49bbe..00b8359 100644 --- a/SESSION.md +++ b/SESSION.md @@ -2,7 +2,7 @@ ## Current Goal -Automation execution-layer PXX is closed. P15-P24 are all closed, current `HEAD` was deployed to the remote host, P24 remote ops smoke passed, and the PXX closure gate froze the known execution-layer scope. The default state is long-running analysis-decision monitoring with ops rollup; new product phases require explicit user direction. +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, and concrete OKX announcements/system-status read-only sources. This run closed H10/F11/G10/D12 by adding guided scan completion states, stale-to-fresh digest refresh prompts, saved-template reuse metrics, and setup-family peer baselines. Local validation passed, the current working tree was deployed, and remote smoke confirmed the latest Decision Assistant fields on the live host. ## Current Branch @@ -10,12 +10,120 @@ Automation execution-layer PXX is closed. P15-P24 are all closed, current `HEAD` ## Last Updated -2026-05-12 UTC +2026-05-13 UTC ## Automation Status - state: automation_execution_layer_pxx_known_scope_closed - current_phase: Long-running Analysis Decision Monitor With Ops Rollup +- active_product_phase: Decision Assistant Phase H/F/D/G/E Dense Recall + Templates + Announcements + Peer Baselines +- latest_decision_assistant_slice: unified_cockpit_summary +- latest_decision_assistant_change_slice: review_packet_delta_summary +- latest_decision_assistant_evidence_slice: structured_evidence_chain +- latest_decision_assistant_workflow_slice: workflow_digest +- latest_decision_assistant_priority_slice: opportunity_ranking_and_no_trade_priority +- latest_decision_assistant_timeline_slice: decision_timeline +- latest_decision_assistant_explanation_slice: explanation_drilldown_and_reason_citations +- latest_decision_assistant_learning_slice: learning_rollup +- latest_decision_assistant_watchlist_slice: candidate_watchlist_tiers +- latest_decision_assistant_external_context_slice: external_context_summary +- latest_decision_assistant_digest_slice: digest_surface_and_personalized_reminders +- latest_decision_assistant_setup_learning_slice: setup_learning_rollup +- latest_decision_assistant_review_entry_slice: review_entrypoints +- latest_decision_assistant_watchlist_action_slice: tier_action_commands +- latest_decision_assistant_external_source_slice: expanded_external_context_cards +- latest_decision_assistant_workspace_slice: sectioned_dense_workspace +- latest_decision_assistant_replay_stats_slice: setup_session_replay_statistics +- latest_decision_assistant_digest_history_slice: digest_history_preview +- latest_decision_assistant_replay_compare_slice: comparative_replay_summary +- latest_decision_assistant_exchange_status_slice: okx_public_system_status +- latest_decision_assistant_scan_summary_slice: scan_summary_cards +- latest_decision_assistant_digest_summary_slice: digest_history_summary +- latest_decision_assistant_replay_story_slice: replay_comparison_story +- latest_decision_assistant_note_template_slice: review_note_templates +- latest_decision_assistant_digest_group_slice: digest_history_groups +- latest_decision_assistant_replay_broad_baseline_slice: broader_replay_baseline_summary +- latest_decision_assistant_announcement_slice: okx_announcements +- latest_decision_assistant_note_shortcut_slice: template_quick_save_actions +- latest_decision_assistant_history_filter_slice: digest_history_group_filtering +- latest_decision_assistant_template_recommendation_slice: recommended_template_ergonomics +- latest_decision_assistant_digest_recommendation_slice: recommended_digest_group +- latest_decision_assistant_replay_percentile_slice: replay_percentile_visibility +- latest_decision_assistant_scan_action_slice: scan_summary_jump_actions +- latest_decision_assistant_digest_group_nav_slice: digest_history_group_navigation +- latest_decision_assistant_template_action_slice: template_card_use_save_actions +- latest_decision_assistant_replay_composite_rank_slice: composite_replay_rank +- latest_decision_assistant_section_nav_slice: assistant_section_navigation +- latest_decision_assistant_history_persistence_slice: digest_filter_persistence +- latest_decision_assistant_replay_composite_slice: composite_replay_percentile +- latest_decision_assistant_scan_hierarchy_slice: scan_card_hierarchy_context +- latest_decision_assistant_digest_focus_slice: digest_group_focus_headlines +- latest_decision_assistant_template_loop_slice: context_aware_review_templates +- latest_decision_assistant_replay_risk_slice: replay_risk_discipline_verdict +- latest_decision_assistant_scan_transition_slice: scan_to_action_transition_hints +- latest_decision_assistant_digest_staleness_slice: digest_staleness_hints +- latest_decision_assistant_template_reuse_slice: template_reuse_and_carry_over_context +- latest_decision_assistant_replay_peer_slice: peer_replay_baseline_visibility +- latest_decision_assistant_scan_completion_slice: guided_scan_completion_states +- latest_decision_assistant_digest_refresh_slice: stale_to_fresh_refresh_prompts +- latest_decision_assistant_template_reuse_metrics_slice: saved_template_reuse_metrics +- latest_decision_assistant_replay_family_slice: setup_family_peer_baselines +- latest_decision_assistant_summary_status: implemented +- latest_decision_assistant_change_summary_status: implemented +- latest_decision_assistant_evidence_status: implemented +- latest_decision_assistant_workflow_status: implemented +- latest_decision_assistant_priority_status: implemented +- latest_decision_assistant_timeline_status: implemented +- latest_decision_assistant_explanation_status: implemented +- latest_decision_assistant_reason_citation_status: implemented +- latest_decision_assistant_learning_status: implemented +- latest_decision_assistant_watchlist_status: implemented +- latest_decision_assistant_external_context_status: implemented +- latest_decision_assistant_digest_status: implemented +- latest_decision_assistant_setup_learning_status: implemented +- latest_decision_assistant_review_entry_status: implemented +- latest_decision_assistant_watchlist_action_status: implemented +- latest_decision_assistant_external_source_status: implemented +- latest_decision_assistant_workspace_status: implemented +- latest_decision_assistant_replay_stats_status: implemented +- latest_decision_assistant_digest_history_status: implemented +- latest_decision_assistant_replay_compare_status: implemented +- latest_decision_assistant_exchange_status_status: implemented +- latest_decision_assistant_scan_summary_status: implemented +- latest_decision_assistant_digest_summary_status: implemented +- latest_decision_assistant_replay_story_status: implemented +- latest_decision_assistant_note_template_status: implemented +- latest_decision_assistant_digest_group_status: implemented +- latest_decision_assistant_replay_broad_baseline_status: implemented +- latest_decision_assistant_announcement_status: implemented +- latest_decision_assistant_note_shortcut_status: implemented +- latest_decision_assistant_history_filter_status: implemented +- latest_decision_assistant_template_recommendation_status: implemented +- latest_decision_assistant_digest_recommendation_status: implemented +- latest_decision_assistant_replay_percentile_status: implemented +- latest_decision_assistant_scan_action_status: implemented +- latest_decision_assistant_digest_group_nav_status: implemented +- latest_decision_assistant_template_action_status: implemented +- latest_decision_assistant_replay_composite_rank_status: implemented +- latest_decision_assistant_section_nav_status: implemented +- latest_decision_assistant_history_persistence_status: implemented +- latest_decision_assistant_replay_composite_status: implemented +- latest_decision_assistant_scan_hierarchy_status: implemented +- latest_decision_assistant_digest_focus_status: implemented +- latest_decision_assistant_template_loop_status: implemented +- latest_decision_assistant_replay_risk_status: implemented +- latest_decision_assistant_scan_transition_status: implemented +- latest_decision_assistant_digest_staleness_status: implemented +- latest_decision_assistant_template_reuse_status: implemented +- latest_decision_assistant_replay_peer_status: implemented +- latest_decision_assistant_scan_completion_status: implemented +- latest_decision_assistant_digest_refresh_status: implemented +- latest_decision_assistant_template_reuse_metrics_status: implemented +- latest_decision_assistant_replay_family_status: implemented +- latest_decision_assistant_workbench_status: visible_local_smoke +- latest_remote_decision_assistant_deploy_status: deployed_current_head +- latest_remote_decision_assistant_smoke_status: passed +- latest_remote_decision_assistant_review_packet_status: guided_scan_digest_refresh_template_reuse_metrics_setup_family_visible - latest_p7_selector_choice: continue_periodic_analysis_decision_monitoring_with_maturity_rollup - latest_p14_slice: webhook_transport_retry - latest_p15_pxx_tasklist_status: ready @@ -94,6 +202,91 @@ Automation execution-layer PXX is closed. P15-P24 are all closed, current `HEAD` ## Completed In This Run +- Added guided scan completion state metadata (`completion_state`, `completion_hint`, `is_next_scan_step`) plus a top-level `scan_completion_summary` so the operator can see which cockpit scan item should be handled next. +- Added stale-to-fresh digest refresh prompts (`refresh_needed`, `refresh_prompt`, `refresh_action_label`, `refresh_needed_count`) so aged recall groups clearly ask for refresh before being reused as live context. +- Added saved-template reuse metrics (`template_reuse_metrics`, per-template `reuse_count` / `last_used_ts` / `reuse_status`) so review templates show whether the recommended template has already been used in recent notes. +- Added setup-family peer replay baselines (`setup_family`, `setup_family_peer_sessions`, `setup_family_peer_summary`, `setup_family_focus`) so replay comparison can talk about the setup family, not only the current session ladder. +- Passed focused validation: `unittest tests.unit.test_operator_workspace_service`, `unittest tests.unit.test_operator_workspace_service tests.unit.test_api_static_server tests.unit.test_api_server`, `scripts/run_script_static_checks.py`, `node --check src/web/app.js`, and a local `review-packet` smoke for the new H10/F11/G10/D12 fields. +- Deployed the current working tree to `http://192.168.3.90:8000`; deploy smoke passed 11 endpoints, `run_remote_ops_smoke.py` passed 9 checks, and remote field smoke confirmed `scan_completion_summary`, digest refresh prompts, template reuse metrics, setup-family baseline fields, and matching `/static/app.js` UI strings. +- Added scan-to-action `transition_hint` fields so scan cards tell the operator what the next click/action is for. +- Added digest recall aging metadata (`age_seconds`, `age_band`, `stale`, `staleness_hint`) plus fresh/stale group counts on the digest summary. +- Added review-template carry-over and reuse metadata (`carry_over_context`, `reuse_hint`, `recommended_reuse_hint`) so repeated note loops preserve context. +- Added peer replay baseline visibility with `peer_sessions` and `peer_summary`, plus market-session fallback for setup replay when the decision packet has no session code. +- Passed focused validation again: `unittest`, `node --check src/web/app.js`, and local `review-packet` smoke confirming transition hints, digest staleness, template reuse hints, and peer baseline fields. +- Deployed current `HEAD` again to `http://192.168.3.90:8000`. +- Passed remote smoke for the latest Decision Assistant fields: remote `review_packet` exposes transition hints, digest staleness hints, template reuse hints, and peer replay baseline fields; remote `app.js` includes the matching UI. +- Added clearer `scan_summary_cards` hierarchy with rank, context, emphasis, and direct action labels so the top rail reads more like an operator scan order instead of five flat cards. +- Added richer `digest_history_groups` focus metadata (`headline`, `latest_title`, `recommended`, `action_label`) and wired the workbench to filter/review groups with clearer recall intent. +- Added context-aware review templates by introducing `next_review` and `queue_follow_up` note templates plus recommended-template reasoning/priority metadata. +- Extended broader replay baselines with `risk_rank`, `risk_percentile`, `risk_band`, `top_safe_sessions`, and `replay_verdict`. +- Passed focused validation again: `unittest`, `node --check src/web/app.js`, and local `review-packet` smoke confirming `recommended_reason`, digest group headlines, scan action labels, and replay verdict fields. +- Deployed current `HEAD` again to `http://192.168.3.90:8000`. +- Passed remote smoke for the latest Decision Assistant fields: remote `review_packet` exposes `recommended_reason`, digest group headlines/action labels, scan hierarchy metadata, and replay verdict/risk fields; remote `app.js` / `styles.css` include the matching UI updates. +- Added `note_templates` to review entrypoints so the assistant can prefill operator notes with top setup, blocker, and focus-mode templates. +- Passed focused validation again: `unittest`, `node --check src/web/app.js`, and local HTTP smoke confirming `note_templates`. +- Deployed current `HEAD` again to `http://192.168.3.90:8000`. +- Passed remote smoke for the latest Decision Assistant fields: remote `review_packet` exposes `note_templates`, and remote app includes the prefill note helper and template actions. +- Added recommended-template guidance and digest-history group filtering ergonomics to the assistant workspace. +- Added replay percentile visibility so the assistant can show `framework_percentile` and `avg_r_percentile` alongside broader baseline ranks. +- Passed focused validation again: `unittest`, `node --check src/web/app.js`, and local HTTP smoke confirming `recommended_label`, `recommended_kind`, `framework_percentile`, and `avg_r_percentile`. +- Deployed current `HEAD` again to `http://192.168.3.90:8000`. +- Passed remote smoke for the latest Decision Assistant fields: remote `review_packet` exposes `recommended_label`, `recommended_kind`, `framework_percentile`, and `avg_r_percentile`, and the remote app renders the corresponding UI labels. +- Added section navigation, digest filter persistence, and composite replay percentile visibility to the assistant workspace. +- Passed focused validation again: `unittest`, `node --check src/web/app.js`, and local smoke confirming the updated section-nav/filter interactions. +- Deployed current `HEAD` again to `http://192.168.3.90:8000`. +- Passed remote smoke for the latest Decision Assistant fields: remote `review_packet` exposes the template/digest/replay metadata and the remote app includes section navigation plus persisted digest filtering. +- Revalidated and remotely confirmed that the current live `Decision Assistant` now exposes recommended templates, digest group guidance, replay percentiles, and the denser grouped workspace together. +- Revalidated and remotely confirmed that the current live `Decision Assistant` now exposes grouped digest recall, broader replay baselines, OKX announcements, and quick-save note shortcuts alongside the earlier template and scan surfaces. +- Added `scan_summary_cards` so the assistant can present a compact top scan rail with top opportunity, top blocker, next review, external context, and digest summary. +- Added `history_summary` under the digest surface so the assistant can summarize recall kinds and latest timestamp, not just raw history rows. +- Extended replay comparison with `comparison_status`, `confidence_band`, `session_alignment`, and a human-readable `story`. +- Passed focused validation again: `unittest`, `node --check src/web/app.js`, and local HTTP smoke confirming `scan_summary_cards`, digest history summary, and replay comparison story fields. +- Deployed current `HEAD` again to `http://192.168.3.90:8000`. +- Passed remote smoke for the latest Decision Assistant fields: remote `review_packet` exposes `scan_summary_cards`, digest history summary, and replay comparison story; remote app/css include the matching dense workspace updates. +- Added `comparative_summary` to setup/session replay statistics so the assistant can compare the current replay profile against weekly high/low session baselines. +- Tightened the `Decision Assistant` surface into denser grouped sections for repeated operator use. +- Added a concrete new read-only external source: OKX public system status, surfaced inside the assistant external context block. +- Passed focused validation again: `unittest`, `node --check src/web/app.js`, and local HTTP smoke confirming `comparative_summary` and `exchange_status`. +- Deployed current `HEAD` again to `http://192.168.3.90:8000`. +- Passed remote smoke for the latest Decision Assistant fields: remote `review_packet` exposes `comparative_summary`, `digest_history_preview`, and `exchange_status`, and remote app/css include the latest dense assistant workspace updates. +- Added `assistant-section-title` grouping so the Decision Assistant renders as a denser action-oriented workspace instead of one long undifferentiated surface. +- Added setup/session `replay_statistics` inside `setup_learning_rollup`. +- Added `digest_history_preview` so recent reminders, alerts, notes, and the previous packet can be recalled from the assistant surface. +- Passed focused validation again: `unittest`, `node --check src/web/app.js`, and local HTTP smoke confirming `replay_statistics` and `digest_history_preview`. +- Deployed current `HEAD` again to `http://192.168.3.90:8000`. +- Passed remote smoke for the latest Decision Assistant fields: remote `review_packet` exposes `replay_statistics` and `digest_history_preview`, and remote app/css include the assistant sectioned workspace updates. +- Added `review_entrypoints` so the assistant can expose notes / review / journal entry surfaces directly from the top-level panel. +- Added per-tier watchlist action metadata (`suggested_action`, `action_path`, `action_method`) and workbench buttons for confirm-check flows. +- Broadened `external_context_summary` with `operator_questions`, `next_confirmation_targets`, and structured `source_cards`. +- Passed focused validation again: `unittest`, `node --check src/web/app.js`, and local HTTP smoke confirming `review_entrypoints`, `suggested_action`, `source_cards`, and `operator_questions`. +- Deployed current `HEAD` again to `http://192.168.3.90:8000`. +- Passed remote smoke for the latest Decision Assistant fields: remote `review_packet` exposes `review_entrypoints`, watchlist action metadata, and expanded external context cards; remote static app includes the same assistant surface changes. +- Added `personalized_reminders` and `digest_surface` so the assistant can expose actionable reminder cards and a compact digest summary. +- Added `setup_learning_rollup` so the assistant can surface setup-level lessons and session learning context for the current setup type. +- Passed focused validation again: `unittest`, `node --check src/web/app.js`, and local HTTP smoke confirming `personalized_reminders`, `digest_surface`, and `setup_learning_rollup`. +- Deployed current `HEAD` to `http://192.168.3.90:8000`. +- Passed remote smoke for the latest Decision Assistant fields: remote `review_packet` exposes `candidate_watchlist_tiers`, `external_context_summary`, `personalized_reminders`, `digest_surface`, and `setup_learning_rollup`, and the remote workbench root still renders `Decision Assistant`. +- Added `candidate_watchlist_tiers` and `candidate_watchlist_summary` so the assistant can group recent setups into `review_now`, `strong_watch`, `soft_watch`, and `archive_only`. +- Added `external_context_summary` so the assistant can surface news-window status, provider freshness, active macro events, external waiting items, and council watch items. +- Passed focused validation again: `unittest`, `node --check src/web/app.js`, and local HTTP smoke confirming `candidate_watchlist_tiers`, `candidate_watchlist_summary`, and `external_context_summary`. +- Reconfirmed the real external blocker: remote deploy validation still requires `AI_ICT_DEPLOY_PASSWORD` or an explicit `--password`, which is absent in the current shell. +- Added `explanation_drilldown` so the assistant can expose decision logic, blockers/guards, watch signals, and launch boundaries as separate drill-down cards. +- Added structured `reason_citations` with source path / source type / severity so the assistant can show where each reason comes from. +- Added `learning_rollup` so the assistant can surface review-entry count, pending feedback, quality flags, and next review actions. +- Passed focused validation again: `unittest`, `node --check src/web/app.js`, and local HTTP smoke confirming `explanation_drilldown`, `reason_citations`, and `learning_rollup`. +- Reconfirmed the real external blocker: remote deploy validation still requires `AI_ICT_DEPLOY_PASSWORD` or an explicit `--password`, which is absent in the current shell. +- Added `workflow_digest` so the Decision Assistant now exposes pre-market / intraday / post-trade workflow cards. +- Refined top opportunity ranking and structured `no_trade_reason_items` so the assistant can prioritize actionable setups over broad session wrappers and explain blocker severity. +- Added `decision_timeline` so the assistant can show current packet, previous packet, latest alert, latest note, and latest execution time anchors when available. +- Passed focused validation again: `unittest`, `node --check src/web/app.js`, and local HTTP smoke confirming `workflow_digest`, `top_opportunities`, `no_trade_reason_items`, and `decision_timeline`. +- Confirmed the first real external blocker for this loop: remote deploy validation requires `AI_ICT_DEPLOY_PASSWORD` or an explicit `--password`, which is absent in the current shell. +- Verified `GSTACK_OK` and reread the current Decision Assistant anchors. +- Started `Decision Assistant Phase A Cockpit` under explicit user direction while preserving the closed PXX execution-layer state. +- Added `DecisionAssistantSummaryService` and exposed `decision_assistant_summary` from `review_packet`. +- Added a unified `Decision Assistant` workbench panel that surfaces focus mode, headline, market/risk posture, priority items, and next actions. +- Added `changed_since_last` delta summary against the latest stored decision packet so the assistant can explain what materially changed. +- Added a structured `evidence_chain` so the assistant summary exposes the key decision gate, launch mode, session context, operator queue, and weekly review evidence blocks. +- Passed focused validation: `unittest` for operator/api/static surfaces, `node --check src/web/app.js`, and local HTTP smoke confirming the new panel text and summary payload. - Verified `GSTACK_OK`. - Promoted the visual-watch prewire into a P8 review loop slice. - Added `VisualReviewService` to review reserved watch runs, create optional snapshot records, create visual reviews, and mark watch runs reviewed. @@ -163,6 +356,18 @@ Automation execution-layer PXX is closed. P15-P24 are all closed, current `HEAD` ## Current Working State +- Decision Assistant Phase H/F/D/G/E Dense Recall + Templates + Announcements is active as the current product-development line on top of the frozen execution-layer baseline. +- The new assistant summary is implemented in `review_packet`: it synthesizes a single headline, focus mode, market posture, risk posture, no-trade reasons, priority items, next actions, and evidence summary. +- The assistant summary now reports `changed_since_last` by diffing the current packet against the latest stored decision packet payload. +- The assistant summary now also reports a structured `evidence_chain` so the top-level panel can show why the current stance exists without opening multiple secondary panels. +- The assistant summary now also reports `workflow_digest`, `top_opportunities`, structured `no_trade_reason_items`, and `decision_timeline`. +- The assistant summary now also reports `explanation_drilldown`, `reason_citations`, and `learning_rollup` so explainability and review-loop state are visible from the top-level panel. +- The assistant summary now also reports `candidate_watchlist_tiers` / `candidate_watchlist_summary` and `external_context_summary`, so setup tiers and read-only macro/news context are visible from the same top-level surface. +- The assistant summary now also reports `personalized_reminders`, `digest_surface`, and `setup_learning_rollup`, so reminder prioritization and setup-level learning are visible from the same top-level panel. +- The assistant summary now also reports `review_entrypoints`, per-tier watchlist action metadata, `operator_questions`, `next_confirmation_targets`, and structured external `source_cards`. +- The assistant summary now also reports setup/session `replay_statistics`, replay `comparative_summary`, `digest_history_preview`, and OKX public `exchange_status`. +- The assistant summary now also reports `scan_summary_cards`, digest `history_summary`, replay comparison `story`, `digest_history_groups`, broader replay baseline summaries, `note_templates`, OKX announcements, plus recommended template and replay percentile guidance. +- The workbench top section now includes a dedicated `Decision Assistant` panel with denser grouped sections plus note-template prefill, quick-save actions, digest-history group filtering, and replay percentile visibility, and both local and remote smoke confirmed the rendered surface plus `review_packet.decision_assistant_summary` with the latest scan/digest/replay/template/announcement fields. - P7 is closed and remains a monitor-maturity routing layer, not a Research Council expansion. - P8 review/capture/feedback surfaces remain implemented and unchanged in their execution guards. - P9 quality rollup remains implemented and visible in both the dashboard and review packet. @@ -210,6 +415,7 @@ Automation execution-layer PXX is closed. P15-P24 are all closed, current `HEAD` ## Current Risks Or Open Questions +- Remote deploy is no longer blocked, but the current assistant still lacks richer external source coverage beyond the existing news/provider chain and council watch items. - Browser screenshot capture adapter is a future enhancement, not a P8 closure blocker. The current adapter is an advisory review-packet HTML capture, and the review loop can still use an existing snapshot, an explicit `image_path`, or write an `unavailable` structured review. - Visual review remains advisory and must not change execution permission, live readiness, trust bucket, risk governor, or operator confirmation. - Alert-quality statistics remain advisory and must not change execution permission, live readiness, trust bucket, risk governor, or operator confirmation. diff --git a/TASKS.md b/TASKS.md index cac75f0..c1fb1ff 100644 --- a/TASKS.md +++ b/TASKS.md @@ -10,13 +10,71 @@ - [x] Stable monitor quality may enter automation execution-layer P7 - [x] User explicitly requested the next P8 product-development round - [x] User requested a finite P15-PXX task list so future phases can continue without per-phase stops +- [x] User explicitly started `Decision Assistant Phase A Cockpit` from the new anchor plan ## In Progress - [x] PXX Automation Execution Closure Gate complete +- [x] Decision Assistant A1 unified cockpit summary slice complete +- [x] Decision Assistant A2 review-packet delta summary slice complete +- [x] Decision Assistant A3 structured evidence chain slice complete +- [x] Decision Assistant A4 workflow digest slice complete +- [x] Decision Assistant A5 opportunity ranking and no-trade priority slice complete +- [x] Decision Assistant B1 decision timeline slice complete +- [x] Decision Assistant B2 explanation drill-down and reason citations slice complete +- [x] Decision Assistant D1 learning rollup slice complete +- [x] Decision Assistant C1 watchlist / candidate tier slice complete +- [x] Decision Assistant E1 external read-only context slice complete +- [x] Decision Assistant F1 digest / personalized reminder slice complete +- [x] Decision Assistant D2 setup learning rollup slice complete +- [x] Decision Assistant G1 notes / journal review entrypoint slice complete +- [x] Decision Assistant C2 per-tier watchlist action slice complete +- [x] Decision Assistant E2 expanded external context card slice complete +- [x] Decision Assistant H1 dense action-oriented workspace slice complete +- [x] Decision Assistant D3 setup/session replay statistics slice complete +- [x] Decision Assistant F2 digest history / reminder recall slice complete +- [x] Decision Assistant H2 tightened dense section ordering slice complete +- [x] Decision Assistant E3 OKX public system-status source slice complete +- [x] Decision Assistant D4 replay comparison slice complete +- [x] Decision Assistant H3 scan summary rail slice complete +- [x] Decision Assistant F3 digest history summary slice complete +- [x] Decision Assistant D5 replay comparison story slice complete +- [x] Decision Assistant G2 review note template slice complete +- [x] Decision Assistant G3 quick-save note shortcut slice complete +- [x] Decision Assistant H4 dense grouping / history grid slice complete +- [x] Decision Assistant F4 digest history group ergonomics slice complete +- [x] Decision Assistant D6 broader replay baseline slice complete +- [x] Decision Assistant E4 OKX announcements source slice complete +- [x] Decision Assistant G4 recommended template ergonomics slice complete +- [x] Decision Assistant H5 digest-history group filtering slice complete +- [x] Decision Assistant F5 digest grouping ergonomics slice complete +- [x] Decision Assistant D7 replay percentile visibility slice complete +- [x] Decision Assistant G5 recommended template guidance slice complete +- [x] Decision Assistant F6 recommended digest group slice complete +- [x] Decision Assistant D8 composite replay rank slice complete +- [x] Decision Assistant H7 scan-card jump action slice complete +- [x] Decision Assistant F8 digest group navigation slice complete +- [x] Decision Assistant G7 template card action slice complete +- [x] Decision Assistant D9 composite replay leaderboard slice complete +- [x] Decision Assistant H8 clearer scan hierarchy slice complete +- [x] Decision Assistant F9 digest focus / recall headline slice complete +- [x] Decision Assistant G8 context-aware review template loop slice complete +- [x] Decision Assistant D10 replay risk / verdict slice complete +- [x] Decision Assistant H9 scan-to-action transition slice complete +- [x] Decision Assistant F10 digest staleness / aging hint slice complete +- [x] Decision Assistant G9 template carry-over / reuse visibility slice complete +- [x] Decision Assistant D11 peer replay baseline slice complete +- [x] Decision Assistant H10 guided scan completion states slice complete +- [x] Decision Assistant F11 stale-to-fresh digest refresh prompts slice complete +- [x] Decision Assistant G10 saved-template reuse metrics slice complete +- [x] Decision Assistant D12 setup-family peer baselines slice complete ## Next Up +- [ ] H11 continue compact operator workspace polish with scan-state persistence / handoff markers +- [ ] F12 deepen digest recall with refresh-action evidence anchors +- [ ] G11 continue review template ergonomics with template outcome / usefulness scoring +- [ ] D13 extend replay comparison with setup-family trend deltas - [x] P8 local closure complete - [x] P9 local closure complete - [x] P10 local closure complete @@ -43,6 +101,108 @@ - [x] state: automation_execution_layer_pxx_known_scope_closed - [x] current_phase: Long-running Analysis Decision Monitor With Ops Rollup +- [x] active_product_phase: Decision Assistant Phase H/F/D/G/E Dense Recall + Templates + Announcements + Peer Baselines +- [x] latest_decision_assistant_slice: unified_cockpit_summary +- [x] latest_decision_assistant_change_slice: review_packet_delta_summary +- [x] latest_decision_assistant_evidence_slice: structured_evidence_chain +- [x] latest_decision_assistant_workflow_slice: workflow_digest +- [x] latest_decision_assistant_priority_slice: opportunity_ranking_and_no_trade_priority +- [x] latest_decision_assistant_timeline_slice: decision_timeline +- [x] latest_decision_assistant_explanation_slice: explanation_drilldown_and_reason_citations +- [x] latest_decision_assistant_learning_slice: learning_rollup +- [x] latest_decision_assistant_watchlist_slice: candidate_watchlist_tiers +- [x] latest_decision_assistant_external_context_slice: external_context_summary +- [x] latest_decision_assistant_digest_slice: digest_surface_and_personalized_reminders +- [x] latest_decision_assistant_setup_learning_slice: setup_learning_rollup +- [x] latest_decision_assistant_review_entry_slice: review_entrypoints +- [x] latest_decision_assistant_watchlist_action_slice: tier_action_commands +- [x] latest_decision_assistant_external_source_slice: expanded_external_context_cards +- [x] latest_decision_assistant_workspace_slice: sectioned_dense_workspace +- [x] latest_decision_assistant_replay_stats_slice: setup_session_replay_statistics +- [x] latest_decision_assistant_digest_history_slice: digest_history_preview +- [x] latest_decision_assistant_replay_compare_slice: comparative_replay_summary +- [x] latest_decision_assistant_exchange_status_slice: okx_public_system_status +- [x] latest_decision_assistant_scan_summary_slice: scan_summary_cards +- [x] latest_decision_assistant_digest_summary_slice: digest_history_summary +- [x] latest_decision_assistant_replay_story_slice: replay_comparison_story +- [x] latest_decision_assistant_note_template_slice: review_note_templates +- [x] latest_decision_assistant_digest_group_slice: digest_history_groups +- [x] latest_decision_assistant_replay_broad_baseline_slice: broader_replay_baseline_summary +- [x] latest_decision_assistant_announcement_slice: okx_announcements +- [x] latest_decision_assistant_note_shortcut_slice: template_quick_save_actions +- [x] latest_decision_assistant_history_filter_slice: digest_history_group_filtering +- [x] latest_decision_assistant_template_recommendation_slice: recommended_template_ergonomics +- [x] latest_decision_assistant_digest_recommendation_slice: recommended_digest_group +- [x] latest_decision_assistant_replay_percentile_slice: replay_percentile_visibility +- [x] latest_decision_assistant_scan_action_slice: scan_summary_jump_actions +- [x] latest_decision_assistant_digest_group_nav_slice: digest_history_group_navigation +- [x] latest_decision_assistant_template_action_slice: template_card_use_save_actions +- [x] latest_decision_assistant_replay_composite_rank_slice: composite_replay_rank +- [x] latest_decision_assistant_scan_hierarchy_slice: scan_card_hierarchy_context +- [x] latest_decision_assistant_digest_focus_slice: digest_group_focus_headlines +- [x] latest_decision_assistant_template_loop_slice: context_aware_review_templates +- [x] latest_decision_assistant_replay_risk_slice: replay_risk_discipline_verdict +- [x] latest_decision_assistant_scan_transition_slice: scan_to_action_transition_hints +- [x] latest_decision_assistant_digest_staleness_slice: digest_staleness_hints +- [x] latest_decision_assistant_template_reuse_slice: template_reuse_and_carry_over_context +- [x] latest_decision_assistant_replay_peer_slice: peer_replay_baseline_visibility +- [x] latest_decision_assistant_scan_completion_slice: guided_scan_completion_states +- [x] latest_decision_assistant_digest_refresh_slice: stale_to_fresh_refresh_prompts +- [x] latest_decision_assistant_template_reuse_metrics_slice: saved_template_reuse_metrics +- [x] latest_decision_assistant_replay_family_slice: setup_family_peer_baselines +- [x] latest_decision_assistant_summary_status: implemented +- [x] latest_decision_assistant_change_summary_status: implemented +- [x] latest_decision_assistant_evidence_status: implemented +- [x] latest_decision_assistant_workflow_status: implemented +- [x] latest_decision_assistant_priority_status: implemented +- [x] latest_decision_assistant_timeline_status: implemented +- [x] latest_decision_assistant_explanation_status: implemented +- [x] latest_decision_assistant_reason_citation_status: implemented +- [x] latest_decision_assistant_learning_status: implemented +- [x] latest_decision_assistant_watchlist_status: implemented +- [x] latest_decision_assistant_external_context_status: implemented +- [x] latest_decision_assistant_digest_status: implemented +- [x] latest_decision_assistant_setup_learning_status: implemented +- [x] latest_decision_assistant_review_entry_status: implemented +- [x] latest_decision_assistant_watchlist_action_status: implemented +- [x] latest_decision_assistant_external_source_status: implemented +- [x] latest_decision_assistant_workspace_status: implemented +- [x] latest_decision_assistant_replay_stats_status: implemented +- [x] latest_decision_assistant_digest_history_status: implemented +- [x] latest_decision_assistant_replay_compare_status: implemented +- [x] latest_decision_assistant_exchange_status_status: implemented +- [x] latest_decision_assistant_scan_summary_status: implemented +- [x] latest_decision_assistant_digest_summary_status: implemented +- [x] latest_decision_assistant_replay_story_status: implemented +- [x] latest_decision_assistant_note_template_status: implemented +- [x] latest_decision_assistant_digest_group_status: implemented +- [x] latest_decision_assistant_replay_broad_baseline_status: implemented +- [x] latest_decision_assistant_announcement_status: implemented +- [x] latest_decision_assistant_note_shortcut_status: implemented +- [x] latest_decision_assistant_history_filter_status: implemented +- [x] latest_decision_assistant_template_recommendation_status: implemented +- [x] latest_decision_assistant_digest_recommendation_status: implemented +- [x] latest_decision_assistant_replay_percentile_status: implemented +- [x] latest_decision_assistant_scan_action_status: implemented +- [x] latest_decision_assistant_digest_group_nav_status: implemented +- [x] latest_decision_assistant_template_action_status: implemented +- [x] latest_decision_assistant_replay_composite_rank_status: implemented +- [x] latest_decision_assistant_scan_hierarchy_status: implemented +- [x] latest_decision_assistant_digest_focus_status: implemented +- [x] latest_decision_assistant_template_loop_status: implemented +- [x] latest_decision_assistant_replay_risk_status: implemented +- [x] latest_decision_assistant_scan_transition_status: implemented +- [x] latest_decision_assistant_digest_staleness_status: implemented +- [x] latest_decision_assistant_template_reuse_status: implemented +- [x] latest_decision_assistant_replay_peer_status: implemented +- [x] latest_decision_assistant_scan_completion_status: implemented +- [x] latest_decision_assistant_digest_refresh_status: implemented +- [x] latest_decision_assistant_template_reuse_metrics_status: implemented +- [x] latest_decision_assistant_replay_family_status: implemented +- [x] latest_decision_assistant_workbench_status: visible_local_smoke +- [x] latest_remote_decision_assistant_deploy_status: deployed_current_head +- [x] latest_remote_decision_assistant_smoke_status: passed +- [x] latest_remote_decision_assistant_review_packet_status: guided_scan_digest_refresh_template_reuse_metrics_setup_family_visible - [x] latest_p7_selector_choice: continue_periodic_analysis_decision_monitoring_with_maturity_rollup - [x] latest_p14_slice: webhook_transport_retry - [x] latest_p15_pxx_tasklist_status: ready @@ -120,6 +280,66 @@ ## Newly Done +- [x] Added guided scan completion states and `scan_completion_summary` +- [x] Added digest stale-to-fresh refresh prompts and refresh-needed counts +- [x] Added saved-template reuse metrics and per-template reuse visibility +- [x] Added setup-family peer replay baselines and family-focused replay summary +- [x] Passed focused validation for the H10/F11/G10/D12 slice +- [x] Deployed current working tree and passed remote Decision Assistant smoke for guided scan, digest refresh, template reuse metrics, and setup-family baseline fields +- [x] Added scan-to-action transition hints on assistant scan cards +- [x] Added digest staleness / aging hints and fresh/stale group counts +- [x] Added template carry-over context and recommended reuse hints +- [x] Added peer replay baseline summary and adjacent peer session cards +- [x] Passed focused unit/static validation and local `review-packet` smoke for the H9/F10/G9/D11 slice +- [x] Deployed current `HEAD` and passed remote Decision Assistant smoke for transition, staleness, reuse, and peer baseline fields +- [x] Added scan-card hierarchy metadata (`rank`, `context`, `emphasis`, `action_label`) and matching dense UI emphasis +- [x] Added digest group headlines / recommended focus / per-group review actions +- [x] Added context-aware review templates including `next_review` and `queue_follow_up` +- [x] Added replay risk ranking / percentile / verdict fields plus safest-session leaderboard +- [x] Passed focused unit/static validation and local `review-packet` smoke for the new assistant slice +- [x] Deployed current `HEAD` and passed remote smoke for scan hierarchy, digest focus, template reasoning, and replay verdict fields +- [x] Verified `GSTACK_OK` and reread the current Decision Assistant anchors +- [x] Added `DecisionAssistantSummaryService` +- [x] Added `review_packet.decision_assistant_summary` +- [x] Added a top-level `Decision Assistant` workbench panel +- [x] Added `changed_since_last` diff summary against the latest stored decision packet +- [x] Added structured `evidence_chain` blocks to the assistant summary and workbench panel +- [x] Added `workflow_digest` blocks for pre-market / intraday / post-trade assistant flow +- [x] Added `top_opportunities` ranking and structured `no_trade_reason_items` +- [x] Added `decision_timeline` for current packet / previous packet / alert / note / execution anchors +- [x] Added `explanation_drilldown` and structured `reason_citations` +- [x] Added `learning_rollup` for review / feedback / quality-loop visibility +- [x] Added `candidate_watchlist_tiers` and `candidate_watchlist_summary` +- [x] Added `external_context_summary` +- [x] Added `personalized_reminders` and `digest_surface` +- [x] Added `setup_learning_rollup` +- [x] Added `review_entrypoints` +- [x] Added per-tier watchlist action metadata and buttons +- [x] Expanded `external_context_summary` with operator questions, next confirmation targets, and source cards +- [x] Added sectioned dense workspace headings for the assistant surface +- [x] Added setup/session `replay_statistics` +- [x] Added `digest_history_preview` +- [x] Added replay `comparative_summary` +- [x] Added OKX public `exchange_status` source into external context +- [x] Added `scan_summary_cards` +- [x] Added digest `history_summary` +- [x] Added replay comparison `story` +- [x] Added review `note_templates` +- [x] Added quick-save note shortcut actions +- [x] Added digest `history_groups` +- [x] Added broader replay baseline summary +- [x] Added OKX announcements read-only source +- [x] Added digest history group filtering ergonomics +- [x] Added recommended template guidance +- [x] Added recommended digest group visibility +- [x] Added replay percentile visibility +- [x] Added recommended template guidance in review entrypoints +- [x] Added scan-card jump actions +- [x] Added digest group navigation +- [x] Added template card use/save actions +- [x] Added composite replay rank and top composite sessions +- [x] Passed focused unit/static validation and local HTTP smoke for the new cockpit slice +- [x] Deployed current `HEAD` and passed remote Decision Assistant smoke - [x] Completed P7-4 bounded automation slice selector before opening P8 - [x] Added `VisualReviewService` - [x] Added `scripts/run_visual_review.py` diff --git a/docs/AI_ICT_TradingAgents融合实施方案_最终版.md b/docs/AI_ICT_TradingAgents融合实施方案_最终版.md index cad70a5..0a54e7b 100644 --- a/docs/AI_ICT_TradingAgents融合实施方案_最终版.md +++ b/docs/AI_ICT_TradingAgents融合实施方案_最终版.md @@ -4,8 +4,8 @@ 本文档综合以下两份已有方案,吸收其长处并统一为一个可执行版本: -1. `AI_ICT_方案一系统边界图与模块融合图_v1.md` -2. `AI_ICT_TradingAgents融合实施方案_v1.md` +1. `docs/archive/AI_ICT_方案一系统边界图与模块融合图_v1.md` +2. `docs/archive/AI_ICT_TradingAgents融合实施方案_v1.md` 本最终版采用以下合并原则: diff --git a/docs/AI_ICT_全自动开发执行流程_v1.md b/docs/AI_ICT_全自动开发执行流程_v1.md index 0d526e8..b06b554 100644 --- a/docs/AI_ICT_全自动开发执行流程_v1.md +++ b/docs/AI_ICT_全自动开发执行流程_v1.md @@ -17,8 +17,7 @@ 本流程已经根据以下文档统一调整: -- `docs/AI_ICT_实盘辅助决策系统开发方案_v2.md` -- `docs/AI_ICT_TradingAgents融合实施方案_最终版.md` +- `docs/AI_ICT_实盘交易分析决策助手下一阶段开发方案_v1.md` 后续该项目默认以本流程作为自动推进协议。 @@ -89,9 +88,7 @@ ## 4.1 必读锚点 -- `docs/AI_ICT_实盘辅助决策系统开发方案_v2.md` -- `docs/AI_ICT_TradingAgents融合实施方案_最终版.md` -- `docs/AI_ICT_全自动开发执行流程_v1.md` +- `docs/AI_ICT_实盘交易分析决策助手下一阶段开发方案_v1.md` - `SESSION.md` - `TASKS.md` @@ -99,8 +96,8 @@ 当需要判断当前历史阶段、上下文或旧计划映射时,补充读取: -- `docs/AI_ICT_下一阶段开发计划_v1.md` -- `docs/AI_ICT_下一阶段开发任务清单_v1.md` +- `docs/archive/AI_ICT_下一阶段开发计划_v1.md` +- `docs/archive/AI_ICT_下一阶段开发任务清单_v1.md` - `docs/AI_ICT_TradingAgents_P0自动任务清单_v1.md` - `docs/AI_ICT_TradingAgents_P1自动任务清单_v1.md` - `docs/AI_ICT_TradingAgents_P2自动任务清单_v1.md` @@ -190,9 +187,7 @@ 必须读取: -- `docs/AI_ICT_实盘辅助决策系统开发方案_v2.md` -- `docs/AI_ICT_TradingAgents融合实施方案_最终版.md` -- `docs/AI_ICT_全自动开发执行流程_v1.md` +- `docs/AI_ICT_实盘交易分析决策助手下一阶段开发方案_v1.md` - `SESSION.md` - `TASKS.md` diff --git a/docs/AI_ICT_实盘交易分析决策助手下一阶段开发方案_v1.md b/docs/AI_ICT_实盘交易分析决策助手下一阶段开发方案_v1.md new file mode 100644 index 0000000..5746a29 --- /dev/null +++ b/docs/AI_ICT_实盘交易分析决策助手下一阶段开发方案_v1.md @@ -0,0 +1,323 @@ +# AI_ICT 实盘交易分析决策助手下一阶段开发方案 v1 + +## 1. 目标 + +本方案面向“实盘交易分析决策助手”,不做真实交易闭环,不做 OKX 真实下单,不做 live adapter 扩展。 + +目标是把当前分散的行情、账户只读、运行时、决策包、告警、视觉复核、复盘信息,收敛成一个更像“分析助手”的产品: + +- 能看懂当前市场在讲什么 +- 能判断现在该不该看、该不该等、该不该做 +- 能解释为什么允许、为什么拒绝、为什么建议继续观察 +- 能把高价值机会、风险变化和待办动作按优先级推给人工 +- 能持续学习哪些 setup 值得盯,哪些 setup 只会制造噪声 + +## 2. 明确边界 + +本阶段明确不做: + +- 真实交易下单 +- OKX live submit +- 实盘成交闭环 +- 带执行权限提升的自动化动作 +- 任何绕过人工确认的交易动作 + +本阶段只做: + +- 分析 +- 解释 +- 提醒 +- 归纳 +- 复盘 +- 只读可视化 + +## 3. 当前已具备的基础 + +仓库里已经有的底座包括: + +- market truth / session / news / freshness +- broker 只读同步 +- runtime scheduler / supervisor +- decision packet +- launch readiness / live readiness +- alert dispatch / quality summary +- operator digest +- runtime incident journal +- maintenance summary +- visual watch / visual review / feedback +- unattended harness / remote ops smoke + +所以接下来不需要再补“有无”的底层骨架,而是要补“一个分析助手该怎么组织这些能力”。 + +## 4. 仍欠缺的核心功能 + +### 4.1 统一分析助手总面板 + +缺口: + +- 当前能力是分散的子面板,不像一个统一的分析助手 +- 缺少“今天该盯什么、为什么、优先级如何”的统一入口 + +需要开发: + +- 一个 Analysis Cockpit / Decision Assistant 主面板 +- 把 market state、broker state、runtime、trust、decision packet、alerts、visual reviews、readiness 汇总到一屏 +- 输出“今日主叙事 / 主偏向 / 当前风险 / 当前可盯机会 / 当前不可做原因 / 下一步动作” + +### 4.2 决策解释与证据链 + +缺口: + +- 现在能看到状态,但不够像一个可解释的分析助手 +- “为什么允许 / 为什么拒绝 / 证据是什么 / 哪一环没闭合”还不够统一 + +需要开发: + +- 统一的 why-allowed / why-blocked / why-watch 解释块 +- 决策链路时间线 +- 关键证据引用 +- 变更对比:和上一次相比哪里变了 +- 失效条件与过期条件展示 + +### 4.3 机会筛选与优先级排序 + +缺口: + +- 有候选和决策包,但缺少“助手式排序” +- 还没有把 setup 质量、上下文质量、时段、风险状态合成一个可读优先级 + +需要开发: + +- setup ranking +- watchlist 评分 +- no-trade reason 分类 +- 候选分层:强观察 / 弱观察 / 只留档 / 直接忽略 +- 面向人工的“最值得看 3 个机会” + +### 4.4 盘前 / 盘中 / 盘后分析工作流 + +缺口: + +- 当前更像能力集合,缺一条固定工作流 + +需要开发: + +- 盘前摘要 +- 盘中监控摘要 +- 盘后复盘摘要 +- 每日固定的分析节奏 +- session 级别的任务完成状态 + +### 4.5 复盘与学习闭环 + +缺口: + +- 已有 journal / incident / feedback,但还没有形成“分析助手的学习闭环” + +需要开发: + +- setup 级别复盘 +- 失败原因归因 +- 被忽略机会归因 +- 周度趋势总结 +- 逐 setup / 逐时段 / 逐市场状态的统计 +- 建议下一轮重点关注什么 + +### 4.6 外部只读上下文补齐 + +缺口: + +- 还缺更完整的只读市场上下文源 + +需要开发: + +- 新闻日历 / 宏观日历的只读接入 +- 交易所公告 / 异常页的只读接入 +- 可选的外部 chart 只读源适配 +- 事件窗口 / 高波动窗口 / 低流动性窗口的统一呈现 + +说明: + +- 这里不包含真实交易相关的 OKX 执行 +- 只做分析所需的只读上下文 + +### 4.7 个性化提醒与摘要 + +缺口: + +- 告警已经能发,但还不够像“分析助手给人的工作台” + +需要开发: + +- 今日要点 digest +- 只推高价值变更 +- 用户偏好配置 +- 不同重要度的提醒模板 +- 可回看的摘要历史 + +### 4.8 分析助手 UI 完整度 + +缺口: + +- 目前是多个页面/面板并存,缺少一个更适合分析的连续工作区 + +需要开发: + +- Decision Assistant 主视图 +- Watchlist 区 +- Evidence 区 +- Notes / Journal 区 +- Digest 区 +- 复盘入口 + +## 5. 建议开发顺序 + +### Phase A:统一分析 cockpit + +目标: + +- 让用户先在一个地方看懂“现在该看什么” + +产出: + +- 主面板 +- 今日摘要 +- 风险总览 +- 候选优先级 + +验收: + +- 进入系统后 30 秒内能判断当天主线 +- 能直接看到当前最值得盯的机会和不该做的原因 + +### Phase B:解释与证据链 + +目标: + +- 让每个推荐都有可追踪的理由 + +产出: + +- why-allowed / why-blocked +- 决策链路时间线 +- 证据引用 +- 变化对比 + +验收: + +- 用户能从页面直接复述“为什么现在看 / 为什么现在不做” + +### Phase C:机会排序与 watchlist + +目标: + +- 从“有很多状态”升级到“知道先看哪个” + +产出: + +- setup ranking +- watchlist 分层 +- no-trade 原因分类 +- 机会优先级分数 + +验收: + +- 同一时刻只能有少量真正优先项 +- 噪声候选不会淹没主机会 + +### Phase D:复盘与学习闭环 + +目标: + +- 让助手持续变得更会筛选 + +产出: + +- setup 复盘 +- session 复盘 +- 周度趋势总结 +- 常见失败模式统计 + +验收: + +- 用户能看到“哪些模式有效,哪些模式只是在制造噪声” + +### Phase E:只读外部上下文扩展 + +目标: + +- 把更多市场背景补进分析助手 + +产出: + +- 新闻/宏观日历 +- 公告页 +- 可选外部 chart 只读源 + +验收: + +- 事件窗口、公告窗口、异常窗口能直接影响分析优先级 + +### Phase F:提醒与摘要硬化 + +目标: + +- 让分析助手更像日常可用的值班助手 + +产出: + +- 个性化 digest +- 高价值提醒 +- 历史摘要 + +验收: + +- 用户不会被低价值重复提醒淹没 + +## 6. 建议优先级 + +### P0 + +- 统一分析 cockpit +- 解释与证据链 +- 机会排序与 watchlist + +### P1 + +- 盘前 / 盘中 / 盘后分析工作流 +- 复盘与学习闭环 +- 个性化提醒与摘要 + +### P2 + +- 外部只读上下文扩展 +- UI 完整度增强 + +## 7. 交付标准 + +一套合格的“实盘交易分析决策助手”,至少要满足: + +1. 能告诉用户当前市场主叙事是什么 +2. 能告诉用户当前哪些机会值得盯 +3. 能告诉用户为什么现在不该做 +4. 能告诉用户当前风险在哪 +5. 能告诉用户应该先看哪一单、哪一类 setup +6. 能在盘前、盘中、盘后形成连续工作流 +7. 能把复盘结论持续反馈回下一轮分析 + +## 8. 不做事项 + +- 不做真实交易下单 +- 不做 OKX live 交易执行 +- 不做自动提高执行权限 +- 不做绕过人工确认的决策替代 + +## 9. 结论 + +下一阶段最值得做的,不是再加更多 ICT 术语识别器,而是把现有的事实层、告警层、视觉层、复盘层统一成一个真正可用的分析决策助手。 + +如果把它做成产品,核心不是“会不会下单”,而是: + +- 你是否一眼知道今天该看什么 +- 你是否一眼知道为什么不做 +- 你是否一眼知道下一步该补什么证据 diff --git a/docs/AI_ICT_盯盘告警架构图_v1.md b/docs/AI_ICT_盯盘告警架构图_v1.md index b552c23..8c61c63 100644 --- a/docs/AI_ICT_盯盘告警架构图_v1.md +++ b/docs/AI_ICT_盯盘告警架构图_v1.md @@ -119,7 +119,7 @@ ## 5. 额外吸收的实施边界 -结合 `AI_ICT_视觉盯盘与主动告警增强方案_v1.md`,补充以下边界: +结合 `AI_ICT_盯盘告警技术设计稿_v1.md`,补充以下边界: - 视觉层除了看图表,也适合看新闻日历页、公告页、工作台状态页 - 视觉层适合处理“候选接近可执行”“系统异常巡检”“session 切换复核”三类高价值场景 diff --git a/docs/AI_ICT_自动化流程使用教程_v1.md b/docs/AI_ICT_自动化流程使用教程_v1.md index 981ee58..526eafc 100644 --- a/docs/AI_ICT_自动化流程使用教程_v1.md +++ b/docs/AI_ICT_自动化流程使用教程_v1.md @@ -35,6 +35,23 @@ P4: state writeback renderer P7: monitor maturity rollup and bounded next-slice routing ``` +## 0.1 Default Kickoff Prompt + +Going forward, the default operator kickoff prompt for this repository is: + +```text +启动 D:\ai-exchange 自动化流程。 +请先读取并以这些锚点为准: +1. docs/AI_ICT_实盘交易分析决策助手下一阶段开发方案_v1.md +2. SESSION.md +3. TASKS.md +全程按自动化闭环执行:读取锚点 -> 选择最小切片 -> 实现 -> focused validation -> 必要质量门 -> 必要时远端 deploy/smoke -> 回写 SESSION.md / TASKS.md。 +遇到真实外部阻塞再停下来说明,run-until-blocked。 +``` + +`docs/AI_ICT_全自动开发执行流程_v1.md` remains the protocol specification, but it is no longer required in the user-facing kickoff prompt. +`docs/AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md` remains as a historical governance-freeze reference and is not part of the default kickoff anchors anymore. + ## 1. 文档目标 本文档用于说明当前项目内自动化编排工具的用途、使用方式、运行模式、repair bridge 协议、常见命令和实践建议,作为后续学习和日常使用手册。 @@ -55,7 +72,7 @@ scripts/run_monitoring_alert_rollout.py 相关配套文档: -- `docs/AI_ICT_下一阶段开发任务清单_v1.md` +- `docs/archive/AI_ICT_下一阶段开发任务清单_v1.md` - `docs/AI_ICT_repair_bridge_protocol_v1.md` 这个脚本的目标不是直接替你完成所有业务开发,而是把当前项目的阶段推进流程固化为: diff --git a/docs/AI_ICT_TradingAgents融合实施方案_v1.md b/docs/archive/AI_ICT_TradingAgents融合实施方案_v1.md similarity index 100% rename from docs/AI_ICT_TradingAgents融合实施方案_v1.md rename to docs/archive/AI_ICT_TradingAgents融合实施方案_v1.md diff --git a/docs/AI_ICT_下一阶段开发任务清单_v1.md b/docs/archive/AI_ICT_下一阶段开发任务清单_v1.md similarity index 99% rename from docs/AI_ICT_下一阶段开发任务清单_v1.md rename to docs/archive/AI_ICT_下一阶段开发任务清单_v1.md index 5817eda..361754a 100644 --- a/docs/AI_ICT_下一阶段开发任务清单_v1.md +++ b/docs/archive/AI_ICT_下一阶段开发任务清单_v1.md @@ -447,7 +447,7 @@ - `session_focus_watch` ### 主要文件 -- `docs/AI_ICT_视觉盯盘与主动告警增强方案_v1.md` +- `docs/AI_ICT_盯盘告警架构图_v1.md` - `docs/AI_ICT_盯盘告警技术设计稿_v1.md` - `docs/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/scripts/run_monitoring_alert_rollout.py b/scripts/run_monitoring_alert_rollout.py index d07bfa2..9deb845 100644 --- a/scripts/run_monitoring_alert_rollout.py +++ b/scripts/run_monitoring_alert_rollout.py @@ -14,7 +14,7 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) STATUS_PATH = ROOT / ".claude" / "monitoring_alert_rollout_status.json" -DOC_PATH = ROOT / "docs" / "AI_ICT_下一阶段开发任务清单_v1.md" +DOC_PATH = ROOT / "docs" / "archive" / "AI_ICT_下一阶段开发任务清单_v1.md" SESSION_PATH = ROOT / "SESSION.md" TASKS_PATH = ROOT / "TASKS.md" ENV_LOCAL_PATH = ROOT / ".env.local" @@ -256,7 +256,7 @@ PHASES = [ "N4-2 仅做代码预留点", ], "files": [ - "docs/AI_ICT_视觉盯盘与主动告警增强方案_v1.md", + "docs/AI_ICT_盯盘告警架构图_v1.md", "docs/AI_ICT_盯盘告警技术设计稿_v1.md", "docs/AI_ICT_盯盘告警开发任务清单_v1.md", "src/services/decision/decision_packet_service.py", diff --git a/scripts/run_until_blocked.py b/scripts/run_until_blocked.py index 886bc98..225da02 100644 --- a/scripts/run_until_blocked.py +++ b/scripts/run_until_blocked.py @@ -113,8 +113,7 @@ AUTOMATION_EXECUTION_GATE_STAGE = "automation_execution_layer:P6_governance_tick AUTOMATION_EXECUTION_GATE_CLOSED_STATE = "automation_execution_layer_p0_governance_gate_closed" AUTOMATION_EXECUTION_NEXT_SLICE = "automation_execution_layer_p1_anchor_slice_selector" AUTOMATION_REQUIRED_DOC_PATHS = ( - ROOT / "docs" / "AI_ICT_全自动开发执行流程_v1.md", - ROOT / "docs" / "AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md", + ROOT / "docs" / "AI_ICT_实盘交易分析决策助手下一阶段开发方案_v1.md", ) AUTOMATION_EXECUTION_SLICE_SEQUENCE = [ ("automation_execution_layer_p1_anchor_slice_selector", "Anchor-driven smallest-slice selector"), @@ -3345,7 +3344,7 @@ def render_session(completed_stages: list[str], current_stage: Optional[str], la "", "## Current Goal", "", - "从现在开始按全自动模式持续推进交易模块主线,不再等待阶段确认,直到完成 `docs/AI_ICT_实盘辅助决策系统开发方案_v2.md` 中剩余开发目标。", + "启动 `D:\\ai-exchange` 自动化流程。请先读取并以 `docs/AI_ICT_实盘交易分析决策助手下一阶段开发方案_v1.md`、`SESSION.md`、`TASKS.md` 为准,全程按自动化闭环执行,遇到真实外部阻塞再停下来说明。", "", "## Current Branch", "", @@ -3361,10 +3360,13 @@ def render_session(completed_stages: list[str], current_stage: Optional[str], la "", "## Current Working State", "", - "- 当前主线执行协议已固化到:", + "- 当前默认启动锚点为:", + " - `docs/AI_ICT_实盘交易分析决策助手下一阶段开发方案_v1.md`", + " - `SESSION.md`", + " - `TASKS.md`", + "- 当前执行协议说明保留在:", " - `docs/AI_ICT_全自动开发执行流程_v1.md`", - "- 当前阶段队列来源于:", - " - `docs/AI_ICT_实盘辅助决策系统开发方案_v2.md`", + "- `docs/AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md` 仅作为已完成冻结决策的历史治理参考,不再是每次启动必读锚点。", "- 默认不再等待用户中途确认", "- 每阶段必须完成:", " - 实现", diff --git a/src/services/market_state/exchange_status_provider.py b/src/services/market_state/exchange_status_provider.py new file mode 100644 index 0000000..43c2818 --- /dev/null +++ b/src/services/market_state/exchange_status_provider.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from os import getenv +from typing import Optional + +import httpx + +from src.config.settings import settings + + +DEFAULT_OKX_SYSTEM_STATUS_URL = "https://www.okx.com/api/v5/system/status" +DEFAULT_OKX_ANNOUNCEMENTS_URL = "https://www.okx.com/api/v5/support/announcements" + + +class OkxSystemStatusProvider: + provider_name = "okx_system_status" + + def __init__( + self, + *, + url: Optional[str] = None, + timeout: Optional[int] = None, + proxy_url: Optional[str] = None, + ) -> None: + self.url = url or DEFAULT_OKX_SYSTEM_STATUS_URL + self.timeout = timeout or settings.news_provider_timeout + self.proxy_url = proxy_url or settings.market_truth_proxy_url or getenv("HTTPS_PROXY") or getenv("HTTP_PROXY") or "" + + def fetch_snapshot(self, *, now: Optional[datetime] = None, payload: Optional[dict] = None) -> dict: + observed_now = now or datetime.now(timezone.utc) + try: + data = dict(payload or self._fetch_payload()) + events = [dict(item or {}) for item in list(data.get("data") or []) if isinstance(item, dict)] + active_events = [_normalize_status_event(item) for item in events if _status_event_is_active(item)] + return { + "provider_name": self.provider_name, + "fetched_ts": observed_now.isoformat(), + "status": "active" if active_events else "idle", + "event_count": len(events), + "active_events": active_events[:6], + "error": None, + "raw_payload": data, + } + except Exception as exc: + return { + "provider_name": self.provider_name, + "fetched_ts": observed_now.isoformat(), + "status": "error", + "event_count": 0, + "active_events": [], + "error": str(exc), + "raw_payload": {}, + } + + def _fetch_payload(self) -> dict: + kwargs = {"timeout": self.timeout, "follow_redirects": True} + if self.proxy_url: + kwargs["proxy"] = self.proxy_url + kwargs["trust_env"] = False + with httpx.Client(**kwargs) as client: + response = client.get(self.url, headers={"User-Agent": "ai-ict/exchange-status"}) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, dict): + raise ValueError("unsupported_okx_system_status_payload") + return payload + + +def _status_event_is_active(row: dict) -> bool: + state = str(row.get("state") or "").lower() + return state in {"scheduled", "ongoing", "pre_open"} + + +def _normalize_status_event(row: dict) -> dict: + return { + "title": str(row.get("title") or row.get("serviceType") or "system_status_event"), + "state": str(row.get("state") or "unknown"), + "begin": str(row.get("begin") or ""), + "end": str(row.get("end") or ""), + "description": str(row.get("scheDesc") or row.get("description") or ""), + "service_type": str(row.get("serviceType") or ""), + } + + +class OkxAnnouncementsProvider: + provider_name = "okx_announcements" + + def __init__( + self, + *, + url: Optional[str] = None, + timeout: Optional[int] = None, + proxy_url: Optional[str] = None, + language: str = "en-US", + ) -> None: + self.url = url or DEFAULT_OKX_ANNOUNCEMENTS_URL + self.timeout = timeout or settings.news_provider_timeout + self.proxy_url = proxy_url or settings.market_truth_proxy_url or getenv("HTTPS_PROXY") or getenv("HTTP_PROXY") or "" + self.language = language + + def fetch_snapshot(self, *, payload: Optional[dict] = None, now: Optional[datetime] = None) -> dict: + observed_now = now or datetime.now(timezone.utc) + try: + data = dict(payload or self._fetch_payload()) + details = [] + for row in list(data.get("data") or []): + if isinstance(row, dict): + details.extend([dict(item or {}) for item in list(row.get("details") or []) if isinstance(item, dict)]) + return { + "provider_name": self.provider_name, + "fetched_ts": observed_now.isoformat(), + "status": "active" if details else "idle", + "announcement_count": len(details), + "announcements": [_normalize_announcement_item(item) for item in details[:6]], + "error": None, + "raw_payload": data, + } + except Exception as exc: + return { + "provider_name": self.provider_name, + "fetched_ts": observed_now.isoformat(), + "status": "error", + "announcement_count": 0, + "announcements": [], + "error": str(exc), + "raw_payload": {}, + } + + def _fetch_payload(self) -> dict: + kwargs = {"timeout": self.timeout, "follow_redirects": True} + if self.proxy_url: + kwargs["proxy"] = self.proxy_url + kwargs["trust_env"] = False + with httpx.Client(**kwargs) as client: + response = client.get( + self.url, + headers={ + "User-Agent": "ai-ict/exchange-announcements", + "Accept-Language": self.language, + }, + ) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, dict): + raise ValueError("unsupported_okx_announcements_payload") + return payload + + +def _normalize_announcement_item(row: dict) -> dict: + return { + "title": str(row.get("title") or "announcement"), + "ann_type": str(row.get("annType") or ""), + "published_ts": str(row.get("pTime") or ""), + "business_published_ts": str(row.get("businessPTime") or ""), + "url": str(row.get("url") or ""), + } diff --git a/src/services/operator/__init__.py b/src/services/operator/__init__.py index 7464ced..1c136dd 100644 --- a/src/services/operator/__init__.py +++ b/src/services/operator/__init__.py @@ -1,10 +1,12 @@ from src.services.operator.operator_workspace_service import ( + DecisionAssistantSummaryService, OperatorNoteService, ReviewPacketService, SessionFocusService, ) __all__ = [ + "DecisionAssistantSummaryService", "OperatorNoteService", "ReviewPacketService", "SessionFocusService", diff --git a/src/services/operator/operator_workspace_service.py b/src/services/operator/operator_workspace_service.py index 95aa741..670916b 100644 --- a/src/services/operator/operator_workspace_service.py +++ b/src/services/operator/operator_workspace_service.py @@ -10,7 +10,9 @@ from src.repositories.trade_plan_candidate_repository import TradePlanCandidateR from src.services.decision import DecisionPacketService from src.services.execution import ExecutionService from src.services.journal import WeeklyReviewService -from src.services.alerts import AlertQualitySummaryService +from src.services.alerts import AlertQualitySummaryService, OperatorDigestService +from src.services.market_state.exchange_status_provider import OkxAnnouncementsProvider, OkxSystemStatusProvider +from src.services.readiness import LaunchReadinessService from src.services.research_council import CouncilOptimizationReviewService, ResearchCouncilService from src.services.trust import ModelTrustService @@ -113,6 +115,75 @@ class SessionFocusService: } +class DecisionAssistantSummaryService: + def __init__( + self, + *, + launch_readiness_service: Optional[LaunchReadinessService] = None, + session_focus_service: Optional[SessionFocusService] = None, + operator_digest_service: Optional[OperatorDigestService] = None, + decision_repository: Optional[DecisionPacketRepository] = None, + candidate_repository: Optional[TradePlanCandidateRepository] = None, + research_council_service: Optional[ResearchCouncilService] = None, + exchange_status_provider: Optional[OkxSystemStatusProvider] = None, + announcements_provider: Optional[OkxAnnouncementsProvider] = None, + ) -> None: + self.launch_readiness_service = launch_readiness_service or LaunchReadinessService() + self.session_focus_service = session_focus_service or SessionFocusService() + self.operator_digest_service = operator_digest_service or OperatorDigestService() + self.decision_repository = decision_repository or DecisionPacketRepository() + self.candidate_repository = candidate_repository or TradePlanCandidateRepository() + self.research_council_service = research_council_service or ResearchCouncilService() + self.exchange_status_provider = exchange_status_provider or OkxSystemStatusProvider() + self.announcements_provider = announcements_provider or OkxAnnouncementsProvider() + + def build_summary( + self, + *, + instrument_id: int, + timeframe: str = "1m", + 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() + fallback_session_code = str( + (((launch_readiness.get("market_state") or {}).get("session") or {}).get("session_code") or "") + ) + setup_learning_rollup = _build_decision_assistant_setup_learning_rollup( + decision_packet=dict(decision_packet or {}), + weekly_review=dict(weekly_review or {}), + research_council_service=self.research_council_service, + instrument_id=instrument_id, + fallback_session_code=fallback_session_code or None, + ) + return build_decision_assistant_summary( + instrument_id=instrument_id, + timeframe=timeframe, + decision_packet=dict(decision_packet or {}), + weekly_review=dict(weekly_review or {}), + launch_readiness=dict(launch_readiness or {}), + session_focus=dict(session_focus or {}), + operator_digest=dict(operator_digest or {}), + previous_packet=dict(previous_packet or {}), + recent_candidates=list(recent_candidates or []), + exchange_status=dict(exchange_status or {}), + announcements_snapshot=dict(announcements_snapshot or {}), + setup_learning_rollup=dict(setup_learning_rollup or {}), + recent_alerts=list(recent_alerts or []), + recent_notes=list(recent_notes or []), + recent_executions=list(recent_executions or []), + ) + + class ReviewPacketService: def __init__( self, @@ -126,6 +197,7 @@ class ReviewPacketService: alert_quality_summary_service: Optional[AlertQualitySummaryService] = None, research_council_service: Optional[ResearchCouncilService] = None, optimization_review_service: Optional[CouncilOptimizationReviewService] = None, + decision_assistant_service: Optional[DecisionAssistantSummaryService] = None, ) -> None: self.note_service = note_service or OperatorNoteService() self.decision_service = decision_service or DecisionPacketService() @@ -138,23 +210,40 @@ class ReviewPacketService: ) self.research_council_service = research_council_service or ResearchCouncilService() self.optimization_review_service = optimization_review_service or CouncilOptimizationReviewService() + self.decision_assistant_service = decision_assistant_service or DecisionAssistantSummaryService( + research_council_service=self.research_council_service, + ) def build_packet(self, *, instrument_id: int) -> 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) + recent_notes = self.note_service.get_recent_notes(instrument_id=instrument_id, limit=10) + recent_executions = self.execution_service.build_execution_dashboard_items(instrument_id=instrument_id, limit=10) research_council = self.research_council_service.get_latest_run(instrument_id=instrument_id) optimization_review = self.optimization_review_service.build_review(instrument_id=instrument_id) return { "instrument_id": instrument_id, "generated_ts": datetime.now(timezone.utc).isoformat(), - "decision_packet": self.decision_service.build_preview(instrument_id=instrument_id), - "recent_alerts": self.decision_repository.fetch_recent_decision_alerts(instrument_id=instrument_id, limit=10), - "recent_notes": self.note_service.get_recent_notes(instrument_id=instrument_id, limit=10), - "recent_executions": self.execution_service.build_execution_dashboard_items(instrument_id=instrument_id, limit=10), - "weekly_review": self.weekly_review_service.build_summary(instrument_id=instrument_id), + "decision_packet": decision_packet, + "recent_alerts": recent_alerts, + "recent_notes": recent_notes, + "recent_executions": recent_executions, + "weekly_review": weekly_review, "alert_quality_summary": self.alert_quality_summary_service.build_summary(instrument_id=instrument_id), "trust_score": self.trust_service.build_summary(instrument_id=instrument_id), "research_council": research_council, "research_council_summary": _build_research_council_summary(research_council), "research_council_optimization_review": optimization_review, + "decision_assistant_summary": self.decision_assistant_service.build_summary( + instrument_id=instrument_id, + timeframe="1m", + decision_packet=decision_packet, + weekly_review=weekly_review, + recent_alerts=recent_alerts, + recent_notes=recent_notes, + recent_executions=recent_executions, + ), } @@ -175,6 +264,2270 @@ def _build_research_council_summary(run: Optional[dict]) -> dict: } +def build_decision_assistant_summary( + *, + instrument_id: int, + timeframe: str, + decision_packet: dict, + weekly_review: dict, + launch_readiness: dict, + session_focus: dict, + operator_digest: dict, + previous_packet: dict, + recent_candidates: list[dict], + exchange_status: dict, + announcements_snapshot: dict, + setup_learning_rollup: dict, + recent_alerts: list[dict], + recent_notes: list[dict], + recent_executions: list[dict], +) -> dict: + market_state = dict(launch_readiness.get("market_state") or {}) + session_state = dict(market_state.get("session") or {}) + freshness = dict(market_state.get("freshness") or {}) + stream_state = dict(market_state.get("stream_state") or {}) + gap_state = dict(market_state.get("data_gap_state") or {}) + decision_action = str(decision_packet.get("action_recommendation") or "monitor") + decision_status = str(decision_packet.get("status") or "unknown") + why_blocked = [str(item) for item in list(decision_packet.get("why_blocked") or []) if item] + why_allowed = [str(item) for item in list(decision_packet.get("why_allowed") or []) if item] + launch_blockers = [str(item) for item in list(launch_readiness.get("blocking_reasons") or []) if item] + launch_warnings = [str(item) for item in list(launch_readiness.get("warning_reasons") or []) if item] + current_session = str(session_state.get("session_code") or "OFF_HOURS") + market_posture = _build_market_posture( + current_session=current_session, + session_state=session_state, + freshness=freshness, + stream_state=stream_state, + gap_state=gap_state, + decision_packet=decision_packet, + ) + focus_mode = _resolve_decision_assistant_focus_mode( + launch_blockers=launch_blockers, + decision_action=decision_action, + operator_digest=operator_digest, + session_focus=session_focus, + ) + no_trade_reason_items = _build_no_trade_reason_items( + why_blocked=why_blocked, + launch_blockers=launch_blockers, + launch_warnings=launch_warnings, + ) + no_trade_reasons = [str(item.get("reason") or "") for item in no_trade_reason_items][:6] + risk_posture = _build_risk_posture( + no_trade_reason_items=no_trade_reason_items, + decision_status=decision_status, + ) + top_priority_items = _build_decision_assistant_priority_items( + decision_packet=decision_packet, + session_focus=session_focus, + operator_digest=operator_digest, + ) + candidate_watchlist_tiers = _build_candidate_watchlist_tiers( + candidates=recent_candidates, + session_focus=session_focus, + current_candidate_id=decision_packet.get("candidate_id"), + ) + candidate_watchlist_summary = _build_candidate_watchlist_summary(candidate_watchlist_tiers) + workflow_digest = _build_decision_assistant_workflow_digest( + launch_readiness=launch_readiness, + decision_packet=decision_packet, + session_focus=session_focus, + operator_digest=operator_digest, + weekly_review=weekly_review, + top_priority_items=top_priority_items, + current_session=current_session, + ) + next_actions = _build_decision_assistant_next_actions( + launch_readiness=launch_readiness, + decision_packet=decision_packet, + weekly_review=weekly_review, + operator_digest=operator_digest, + top_priority_items=top_priority_items, + ) + headline = _build_decision_assistant_headline( + focus_mode=focus_mode, + current_session=current_session, + decision_packet=decision_packet, + market_posture=market_posture, + no_trade_reasons=no_trade_reasons, + top_priority_items=top_priority_items, + ) + attention_reason = _build_attention_reason( + focus_mode=focus_mode, + why_allowed=why_allowed, + no_trade_reasons=no_trade_reasons, + operator_digest=operator_digest, + ) + changed_since_last = _build_decision_assistant_change_summary( + current_packet=decision_packet, + previous_packet=previous_packet, + ) + evidence_chain = _build_decision_assistant_evidence_chain( + launch_readiness=launch_readiness, + decision_packet=decision_packet, + operator_digest=operator_digest, + weekly_review=weekly_review, + current_session=current_session, + ) + explanation_drilldown = _build_decision_assistant_explanation_drilldown( + decision_packet=decision_packet, + no_trade_reason_items=no_trade_reason_items, + operator_digest=operator_digest, + launch_readiness=launch_readiness, + ) + reason_citations = _build_decision_assistant_reason_citations( + decision_packet=decision_packet, + no_trade_reason_items=no_trade_reason_items, + launch_readiness=launch_readiness, + ) + learning_rollup = _build_decision_assistant_learning_rollup( + weekly_review=weekly_review, + operator_digest=operator_digest, + ) + external_context_summary = _build_decision_assistant_external_context_summary( + launch_readiness=launch_readiness, + decision_packet=decision_packet, + exchange_status=exchange_status, + announcements_snapshot=announcements_snapshot, + ) + assistant_top_opportunities = [item for item in top_priority_items if str(item.get("kind") or "") in {"decision_packet", "session_focus"}][:3] + review_entrypoints = _build_decision_assistant_review_entrypoints( + recent_notes=recent_notes, + weekly_review=weekly_review, + operator_digest=operator_digest, + focus_mode=focus_mode, + top_opportunities=assistant_top_opportunities, + no_trade_reason_items=no_trade_reason_items, + ) + personalized_reminders = _build_decision_assistant_personalized_reminders( + top_opportunities=assistant_top_opportunities, + no_trade_reason_items=no_trade_reason_items, + operator_digest=operator_digest, + external_context_summary=external_context_summary, + learning_rollup=learning_rollup, + ) + digest_history_preview = _build_decision_assistant_digest_history_preview( + personalized_reminders=personalized_reminders, + recent_alerts=recent_alerts, + recent_notes=recent_notes, + previous_packet=previous_packet, + ) + digest_history_summary = _build_decision_assistant_digest_history_summary(digest_history_preview) + digest_history_groups = _build_decision_assistant_digest_history_groups( + digest_history_preview, + recommended_kind=digest_history_summary.get("recommended_kind"), + ) + digest_surface = _build_decision_assistant_digest_surface( + focus_mode=focus_mode, + personalized_reminders=personalized_reminders, + learning_rollup=learning_rollup, + top_opportunities=assistant_top_opportunities, + digest_history_preview=digest_history_preview, + digest_history_summary=digest_history_summary, + ) + decision_timeline = _build_decision_assistant_timeline( + decision_packet=decision_packet, + previous_packet=previous_packet, + recent_alerts=recent_alerts, + recent_notes=recent_notes, + recent_executions=recent_executions, + ) + scan_summary_cards = _build_decision_assistant_scan_summary_cards( + top_opportunities=[item for item in top_priority_items if str(item.get("kind") or "") in {"decision_packet", "session_focus"}][:3], + no_trade_reason_items=no_trade_reason_items, + review_entrypoints=review_entrypoints, + external_context_summary=external_context_summary, + digest_surface=digest_surface, + ) + scan_completion_summary = _build_decision_assistant_scan_completion_summary(scan_summary_cards) + return { + "instrument_id": instrument_id, + "timeframe": timeframe, + "generated_ts": datetime.now(timezone.utc).isoformat(), + "status": "completed", + "focus_mode": focus_mode, + "headline": headline, + "market_posture": market_posture, + "risk_posture": risk_posture, + "attention_reason": attention_reason, + "current_session": current_session, + "changed_since_last": changed_since_last, + "top_priority_items": top_priority_items, + "top_opportunities": assistant_top_opportunities, + "candidate_watchlist_tiers": candidate_watchlist_tiers, + "candidate_watchlist_summary": candidate_watchlist_summary, + "workflow_digest": workflow_digest, + "evidence_chain": evidence_chain, + "explanation_drilldown": explanation_drilldown, + "reason_citations": reason_citations, + "learning_rollup": learning_rollup, + "external_context_summary": external_context_summary, + "review_entrypoints": review_entrypoints, + "personalized_reminders": personalized_reminders, + "digest_history_preview": digest_history_preview, + "digest_history_summary": digest_history_summary, + "digest_history_groups": digest_history_groups, + "digest_surface": digest_surface, + "setup_learning_rollup": setup_learning_rollup, + "decision_timeline": decision_timeline, + "scan_summary_cards": scan_summary_cards, + "scan_completion_summary": scan_completion_summary, + "no_trade_reason_items": no_trade_reason_items, + "no_trade_reasons": no_trade_reasons, + "next_actions": next_actions, + "evidence_summary": { + "decision_status": decision_status, + "decision_action": decision_action, + "trust_classification": decision_packet.get("trust_classification"), + "operator_mode": decision_packet.get("operator_mode"), + "market_freshness": freshness.get("code"), + "stream_status": stream_state.get("code"), + "data_gap_status": gap_state.get("code"), + "launch_mode": launch_readiness.get("product_operating_mode"), + "digest_status": operator_digest.get("status"), + "high_value_alert_count": ((operator_digest.get("summary_counts") or {}).get("high_value_alert_count") or 0), + }, + } + + +def _build_market_posture( + *, + current_session: str, + session_state: dict, + freshness: dict, + stream_state: dict, + gap_state: dict, + decision_packet: dict, +) -> str: + parts = [ + current_session, + str(session_state.get("status") or "unknown"), + str(freshness.get("code") or "freshness_unknown"), + ] + if stream_state.get("code"): + parts.append(str(stream_state.get("code"))) + if gap_state.get("code") and str(gap_state.get("code")) != "market_data_gap_clear": + parts.append(str(gap_state.get("code"))) + if decision_packet.get("bias"): + parts.append(f"bias={decision_packet.get('bias')}") + return " | ".join(parts) + + +def _resolve_decision_assistant_focus_mode( + *, + launch_blockers: list[str], + decision_action: str, + operator_digest: dict, + session_focus: dict, +) -> str: + if launch_blockers: + return "stabilize_analysis_surface" + if decision_action in {"build_execution_ticket", "confirm_ticket", "manage_ticket"}: + return "review_top_setup" + queue = list(operator_digest.get("review_queue") or []) + if queue: + return "clear_review_queue" + for item in list(session_focus.get("items") or []): + if str(item.get("action_hint") or "") in {"review_alerts", "focus_now"}: + return "watch_priority_sessions" + return "monitor_and_wait" + + +def _build_risk_posture(*, no_trade_reason_items: list[dict], decision_status: str) -> str: + hard_blockers = [str(item.get("reason") or "") for item in no_trade_reason_items if item.get("priority") == "high"] + advisory = [str(item.get("reason") or "") for item in no_trade_reason_items if item.get("priority") != "high"] + if hard_blockers: + return f"{decision_status}: {', '.join(hard_blockers[:3])}" + if advisory: + return f"{decision_status}: advisory {', '.join(advisory[:2])}" + return f"{decision_status}: no active no-trade blocker" + + +def _build_decision_assistant_priority_items(*, decision_packet: dict, session_focus: dict, operator_digest: dict) -> list[dict]: + items: list[dict] = [] + candidate_id = decision_packet.get("candidate_id") + if candidate_id is not None or decision_packet.get("narrative") or decision_packet.get("ai_explanation"): + items.append( + { + "kind": "decision_packet", + "title": f"Candidate #{candidate_id}" if candidate_id is not None else "Decision packet", + "status": decision_packet.get("action_recommendation") or decision_packet.get("status") or "monitor", + "detail": decision_packet.get("ai_explanation") or decision_packet.get("narrative") or "decision_packet_available", + "reason": ", ".join(list(decision_packet.get("why_allowed") or [])[:3]) or ", ".join(list(decision_packet.get("why_blocked") or [])[:3]) or "decision_packet_context", + "priority_score": _decision_packet_priority_score(decision_packet), + } + ) + focus_items = sorted( + list(session_focus.get("items") or []), + key=lambda item: ( + str(item.get("action_hint") or "") == "review_alerts", + str(item.get("action_hint") or "") == "focus_now", + int(item.get("alert_count") or 0), + int(item.get("executable_candidate_count") or 0), + float((item.get("session_recommendation") or {}).get("score") or 0.0), + ), + reverse=True, + ) + for item in focus_items[:2]: + recommendation = dict(item.get("session_recommendation") or {}) + items.append( + { + "kind": "session_focus", + "title": f"Session {item.get('session_code') or 'unknown'}", + "status": recommendation.get("classification") or item.get("status") or "observe", + "detail": f"alerts={item.get('alert_count') or 0}, executable={item.get('executable_candidate_count') or 0}, open={item.get('open_execution_count') or 0}", + "reason": item.get("action_hint") or "observe", + "priority_score": _session_focus_priority_score(item), + } + ) + for item in list(operator_digest.get("review_queue") or [])[:2]: + items.append( + { + "kind": "review_queue", + "title": f"Queue {item.get('queue_type') or 'item'}", + "status": item.get("priority") or item.get("status") or "review", + "detail": item.get("reason") or item.get("label") or item.get("channel") or "digest_item", + "reason": f"subject={item.get('subject_type') or item.get('alert_type') or item.get('visual_state') or 'n/a'}", + "priority_score": _review_queue_priority_score(item), + } + ) + return sorted( + items, + key=lambda item: ( + float(item.get("priority_score") or 0.0), + str(item.get("title") or ""), + ), + reverse=True, + )[:5] + + +def _build_candidate_watchlist_tiers(*, candidates: list[dict], session_focus: dict, current_candidate_id) -> dict: + session_items = { + str(item.get("session_code") or ""): dict(item) + for item in list(session_focus.get("items") or []) + if item + } + tiers = { + "review_now": [], + "strong_watch": [], + "soft_watch": [], + "archive_only": [], + } + for candidate in candidates: + session_item = session_items.get(str(candidate.get("session_code") or ""), {}) + tier, tier_reason, priority_score = _classify_candidate_watchlist_tier( + candidate=candidate, + session_item=session_item, + current_candidate_id=current_candidate_id, + ) + tiers[tier].append( + { + "candidate_id": candidate.get("id"), + "title": f"Candidate #{candidate.get('id')}", + "tier": tier, + "tier_reason": tier_reason, + "priority_score": priority_score, + "suggested_action": _candidate_tier_action_label(tier=tier, candidate_id=candidate.get("id")), + "action_path": _candidate_tier_action_path(tier=tier, candidate_id=candidate.get("id")), + "action_method": _candidate_tier_action_method(tier=tier), + "status": candidate.get("status") or candidate.get("candidate_state") or "unknown", + "session_code": candidate.get("session_code") or "OFF_HOURS", + "quality_score": candidate.get("quality_score"), + "model_code": candidate.get("model_code") or "n/a", + "detail": candidate.get("ai_explanation") or candidate.get("narrative") or "candidate_available", + "missing_confirmations": list(candidate.get("missing_confirmations") or []), + } + ) + for key, rows in tiers.items(): + rows.sort( + key=lambda item: ( + float(item.get("priority_score") or 0.0), + float(item.get("quality_score") or 0.0), + int(item.get("candidate_id") or 0), + ), + reverse=True, + ) + tiers[key] = rows[:5] + return tiers + + +def _build_candidate_watchlist_summary(tiers: dict) -> dict: + return { + "review_now_count": len(list(tiers.get("review_now") or [])), + "strong_watch_count": len(list(tiers.get("strong_watch") or [])), + "soft_watch_count": len(list(tiers.get("soft_watch") or [])), + "archive_only_count": len(list(tiers.get("archive_only") or [])), + "top_review_candidate_id": (tiers.get("review_now") or [{}])[0].get("candidate_id") if list(tiers.get("review_now") or []) else None, + } + + +def _classify_candidate_watchlist_tier(*, candidate: dict, session_item: dict, current_candidate_id) -> tuple[str, str, float]: + score = float(candidate.get("quality_score") or 0.0) + status = str(candidate.get("status") or candidate.get("candidate_state") or "") + action_hint = str(session_item.get("action_hint") or "") + recommendation = dict(session_item.get("session_recommendation") or {}) + session_score = float(recommendation.get("score") or 0.0) + missing_count = len(list(candidate.get("missing_confirmations") or [])) + priority_score = score + if candidate.get("id") == current_candidate_id: + priority_score += 120.0 + return "review_now", "current_decision_packet_candidate", priority_score + if status == "executable" or bool(candidate.get("executable")): + priority_score += 100.0 + return "review_now", "candidate_executable", priority_score + if action_hint in {"review_alerts", "focus_now"}: + priority_score += 20.0 + if recommendation.get("classification") == "prefer": + priority_score += 10.0 + if score >= 70.0 or (score >= 55.0 and action_hint == "focus_now"): + return "strong_watch", "quality_or_session_focus", priority_score + if score >= 40.0 or missing_count > 0 or recommendation.get("classification") == "prefer": + return "soft_watch", "needs_more_confirmation", priority_score + return "archive_only", "low_priority_or_low_context", priority_score + + +def _candidate_tier_action_label(*, tier: str, candidate_id) -> str: + if tier == "review_now": + return f"run_confirm_check:{candidate_id}" + if tier == "strong_watch": + return f"monitor_confirmation:{candidate_id}" + if tier == "soft_watch": + return f"collect_missing_confirmation:{candidate_id}" + return f"archive_for_reference:{candidate_id}" + + +def _candidate_tier_action_path(*, tier: str, candidate_id) -> str | None: + if candidate_id is None: + return None + if tier in {"review_now", "strong_watch"}: + return f"/trading/candidates/{candidate_id}/confirm-check" + return None + + +def _candidate_tier_action_method(*, tier: str) -> str: + if tier in {"review_now", "strong_watch"}: + return "POST" + return "NONE" + + +def _build_decision_assistant_next_actions( + *, + launch_readiness: dict, + decision_packet: dict, + weekly_review: dict, + operator_digest: dict, + top_priority_items: list[dict], +) -> list[str]: + actions = _dedupe( + [ + *[str(item) for item in list(launch_readiness.get("next_actions") or []) if item], + *[str(item) for item in list((decision_packet.get("research_council") or {}).get("follow_up_checks") or []) if item], + *[str(item) for item in list(weekly_review.get("next_week_actions") or []) if item], + ] + ) + if not actions and top_priority_items: + actions = [f"review_{str(top_priority_items[0].get('kind') or 'assistant')}".lower()] + if list(operator_digest.get("review_queue") or []): + actions.append("clear_operator_review_queue") + return _dedupe(actions)[:5] + + +def _build_decision_assistant_headline( + *, + focus_mode: str, + current_session: str, + decision_packet: dict, + market_posture: str, + no_trade_reasons: list[str], + top_priority_items: list[dict], +) -> str: + if focus_mode == "stabilize_analysis_surface": + return f"Stabilize analysis surface before new decisions: {', '.join(no_trade_reasons[:2]) or market_posture}." + if focus_mode == "review_top_setup": + title = top_priority_items[0]["title"] if top_priority_items else "decision packet" + return f"Review {title} in {current_session}: {decision_packet.get('ai_explanation') or decision_packet.get('narrative') or 'setup ready for operator review'}." + if focus_mode == "clear_review_queue": + queue_item = top_priority_items[0]["title"] if top_priority_items else "review queue" + return f"Clear the highest-priority operator queue item first: {queue_item}." + if focus_mode == "watch_priority_sessions": + title = top_priority_items[0]["title"] if top_priority_items else current_session + return f"Watch the priority session flow now: {title}." + return f"Monitor {current_session} and wait for a cleaner setup. {market_posture}." + + +def _build_attention_reason(*, focus_mode: str, why_allowed: list[str], no_trade_reasons: list[str], operator_digest: dict) -> str: + if focus_mode == "review_top_setup" and why_allowed: + return ", ".join(why_allowed[:3]) + if focus_mode == "stabilize_analysis_surface" and no_trade_reasons: + return ", ".join(no_trade_reasons[:3]) + queue = list(operator_digest.get("review_queue") or []) + if focus_mode == "clear_review_queue" and queue: + return f"{queue[0].get('queue_type') or 'queue'} requires review" + return "observe_only_until_context_improves" + + +def _build_decision_assistant_change_summary(*, current_packet: dict, previous_packet: dict) -> dict: + previous_payload = dict(previous_packet.get("payload") or {}) + if not previous_payload: + return { + "summary": "first_visible_snapshot", + "items": ["no_previous_decision_packet"], + "previous_packet_id": previous_packet.get("id"), + } + + changes: list[str] = [] + if str(previous_payload.get("action_recommendation") or "") != str(current_packet.get("action_recommendation") or ""): + changes.append( + f"action:{previous_payload.get('action_recommendation') or 'n/a'}->{current_packet.get('action_recommendation') or 'n/a'}" + ) + if str(previous_payload.get("status") or "") != str(current_packet.get("status") or ""): + changes.append(f"status:{previous_payload.get('status') or 'n/a'}->{current_packet.get('status') or 'n/a'}") + if str(previous_payload.get("candidate_id") or "") != str(current_packet.get("candidate_id") or ""): + changes.append(f"candidate:{previous_payload.get('candidate_id') or 'none'}->{current_packet.get('candidate_id') or 'none'}") + if str(previous_payload.get("bias") or "") != str(current_packet.get("bias") or ""): + changes.append(f"bias:{previous_payload.get('bias') or 'n/a'}->{current_packet.get('bias') or 'n/a'}") + if str(previous_payload.get("trust_classification") or "") != str(current_packet.get("trust_classification") or ""): + changes.append( + f"trust:{previous_payload.get('trust_classification') or 'n/a'}->{current_packet.get('trust_classification') or 'n/a'}" + ) + previous_blocked = set(_dedupe([str(item) for item in list(previous_payload.get("why_blocked") or [])])) + current_blocked = set(_dedupe([str(item) for item in list(current_packet.get("why_blocked") or [])])) + cleared = sorted(previous_blocked - current_blocked) + added = sorted(current_blocked - previous_blocked) + if cleared: + changes.append(f"blocked_cleared:{','.join(cleared[:3])}") + if added: + changes.append(f"blocked_added:{','.join(added[:3])}") + return { + "summary": changes[0] if changes else "no_material_change_from_latest_stored_packet", + "items": changes[:6] if changes else ["no_material_change_from_latest_stored_packet"], + "previous_packet_id": previous_packet.get("id"), + } + + +def _build_decision_assistant_workflow_digest( + *, + launch_readiness: dict, + decision_packet: dict, + session_focus: dict, + operator_digest: dict, + weekly_review: dict, + top_priority_items: list[dict], + current_session: str, +) -> list[dict]: + first_priority = top_priority_items[0] if top_priority_items else {} + return [ + { + "phase": "pre_market", + "title": "Pre-Market", + "status": launch_readiness.get("product_operating_mode") or "unknown", + "detail": decision_packet.get("narrative") or decision_packet.get("bias") or "prepare_daily_context", + "next_step": (launch_readiness.get("next_actions") or ["prepare_context"])[0], + }, + { + "phase": "intraday", + "title": "Intraday", + "status": current_session, + "detail": first_priority.get("title") or "monitor_current_session", + "next_step": first_priority.get("reason") or "watch_priority_sessions", + }, + { + "phase": "post_trade", + "title": "Post-Trade", + "status": f"entries={weekly_review.get('entry_count') or 0}", + "detail": f"pending feedback={dict(operator_digest.get('summary_counts') or {}).get('pending_feedback_count', 0)}", + "next_step": (weekly_review.get("next_week_actions") or ["collect_review_notes"])[0], + }, + ] + + +def _build_decision_assistant_explanation_drilldown( + *, + decision_packet: dict, + no_trade_reason_items: list[dict], + operator_digest: dict, + launch_readiness: dict, +) -> list[dict]: + council = dict(decision_packet.get("research_council") or {}) + return [ + { + "title": "Decision Logic", + "status": decision_packet.get("action_recommendation") or decision_packet.get("status") or "monitor", + "detail": decision_packet.get("ai_explanation") or decision_packet.get("narrative") or "decision_packet_available", + "reasons": list(decision_packet.get("why_allowed") or [])[:4], + }, + { + "title": "Blockers And Guards", + "status": "blocked" if no_trade_reason_items else "clear", + "detail": ", ".join([str(item.get("reason") or "") for item in no_trade_reason_items[:3]]) or "no_active_blocker", + "reasons": [str(item.get("reason") or "") for item in no_trade_reason_items[:4]], + }, + { + "title": "Watch Signals", + "status": "watch" if council.get("follow_up_checks") or council.get("recommended_watch_items") else "idle", + "detail": council.get("operator_summary") or council.get("research_summary") or "no_watch_signal", + "reasons": _dedupe( + [ + *[str(item) for item in list(council.get("follow_up_checks") or []) if item], + *[str(item) for item in list(council.get("recommended_watch_items") or []) if item], + ] + )[:4], + }, + { + "title": "Launch Boundaries", + "status": launch_readiness.get("product_operating_mode") or "unknown", + "detail": ", ".join([str(item) for item in list(launch_readiness.get("blocking_reasons") or [])[:3]]) or "analysis_surface_ready", + "reasons": _dedupe( + [ + *[str(item) for item in list(launch_readiness.get("blocking_reasons") or []) if item], + *[str(item) for item in list(launch_readiness.get("warning_reasons") or []) if item], + ] + )[:4], + }, + ] + + +def _build_decision_assistant_reason_citations( + *, + decision_packet: dict, + no_trade_reason_items: list[dict], + launch_readiness: dict, +) -> list[dict]: + citations: list[dict] = [] + for index, reason in enumerate(list(decision_packet.get("why_allowed") or [])[:4]): + citations.append( + { + "reason": str(reason), + "source_path": f"decision_packet.why_allowed[{index}]", + "source_type": "decision_packet", + "severity": "supporting", + "detail": decision_packet.get("ai_explanation") or "decision_support_reason", + } + ) + for item in no_trade_reason_items[:4]: + source = str(item.get("source") or "") + source_path = "launch_readiness.blocking_reasons" if source == "launch_blocker" else ( + "launch_readiness.warning_reasons" if source == "launch_warning" else "decision_packet.why_blocked" + ) + citations.append( + { + "reason": str(item.get("reason") or ""), + "source_path": source_path, + "source_type": source or "decision_packet", + "severity": str(item.get("priority") or "low"), + "detail": str(item.get("category") or "reason"), + } + ) + council = dict(decision_packet.get("research_council") or {}) + for index, reason in enumerate(list(council.get("follow_up_checks") or [])[:2]): + citations.append( + { + "reason": str(reason), + "source_path": f"decision_packet.research_council.follow_up_checks[{index}]", + "source_type": "research_council", + "severity": "watch", + "detail": council.get("operator_summary") or council.get("research_summary") or "watch_follow_up", + } + ) + return citations[:8] + + +def _build_decision_assistant_learning_rollup(*, weekly_review: dict, operator_digest: dict) -> dict: + digest_counts = dict(operator_digest.get("summary_counts") or {}) + quality_summary = dict(operator_digest.get("quality_summary") or {}) + return { + "status": "active" if weekly_review or operator_digest else "idle", + "entry_count": int(weekly_review.get("entry_count") or 0), + "next_week_actions": list(weekly_review.get("next_week_actions") or [])[:4], + "quality_flags": list(operator_digest.get("quality_flags") or [])[:4], + "pending_feedback_count": int(digest_counts.get("pending_feedback_count") or 0), + "visual_mismatch_count": int(digest_counts.get("visual_mismatch_count") or 0), + "high_value_alert_count": int(digest_counts.get("high_value_alert_count") or 0), + "operator_summary": operator_digest.get("operator_summary") or "learning_rollup_idle", + "quality_summary_status": quality_summary.get("status") or operator_digest.get("status") or "unknown", + } + + +def _build_decision_assistant_external_context_summary( + *, + launch_readiness: dict, + decision_packet: dict, + exchange_status: dict, + announcements_snapshot: dict, +) -> dict: + market_state = dict(launch_readiness.get("market_state") or {}) + news_window = dict(market_state.get("news_window") or market_state.get("news_state") or {}) + council = dict(decision_packet.get("research_council") or {}) + exchange_status = dict(exchange_status or {}) + announcements_snapshot = dict(announcements_snapshot or {}) + active_events = [dict(item or {}) for item in list(news_window.get("active_events") or []) if item] + external_waiting_items = list(launch_readiness.get("external_waiting_items") or [])[:4] + council_watch_items = list(council.get("recommended_watch_items") or [])[:4] + operator_questions = list(council.get("operator_questions") or [])[:4] + next_confirmation_targets = list(council.get("next_confirmation_targets") or [])[:4] + exchange_events = [dict(item or {}) for item in list(exchange_status.get("active_events") or []) if item] + announcements = [dict(item or {}) for item in list(announcements_snapshot.get("announcements") or []) if item] + 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", + "news_window_code": news_window.get("code") or "unknown", + "provider_name": news_window.get("provider_name") or "unknown", + "freshness_code": news_window.get("freshness_code") or "unknown", + "active_event_titles": [str(item.get("title") or item.get("event_id") or "macro_event") for item in active_events[:4]], + "event_count": int(news_window.get("event_count") or 0), + "external_waiting_items": external_waiting_items, + "council_watch_items": council_watch_items, + "operator_questions": operator_questions, + "next_confirmation_targets": next_confirmation_targets, + "exchange_status": exchange_status, + "exchange_status_titles": [str(item.get("title") or item.get("service_type") or "system_status_event") for item in exchange_events[:4]], + "announcements_snapshot": announcements_snapshot, + "announcement_titles": [str(item.get("title") or "announcement") for item in announcements[:4]], + "source_cards": [ + { + "source_type": "news_provider", + "title": news_window.get("provider_name") or "news_provider", + "detail": news_window.get("freshness_code") or news_window.get("code") or "unknown", + }, + { + "source_type": "exchange_status", + "title": exchange_status.get("provider_name") or "okx_system_status", + "detail": exchange_status.get("status") or "unknown", + }, + { + "source_type": "okx_announcements", + "title": announcements_snapshot.get("provider_name") or "okx_announcements", + "detail": announcements_snapshot.get("status") or "unknown", + }, + *[ + { + "source_type": "external_waiting_item", + "title": str(item), + "detail": "launch_readiness.external_waiting_items", + } + for item in external_waiting_items + ], + *[ + { + "source_type": "council_watch_item", + "title": str(item), + "detail": "decision_packet.research_council.recommended_watch_items", + } + for item in council_watch_items + ], + *[ + { + "source_type": "announcement", + "title": str(item.get("title") or "announcement"), + "detail": str(item.get("ann_type") or item.get("published_ts") or "okx_announcement"), + } + for item in announcements[:2] + ], + ][:6], + "headline": _build_external_context_headline( + news_window=news_window, + active_events=active_events, + launch_readiness=launch_readiness, + exchange_status=exchange_status, + exchange_events=exchange_events, + announcements=announcements, + announcements_snapshot=announcements_snapshot, + ), + } + + +def _build_decision_assistant_review_entrypoints( + *, + recent_notes: list[dict], + weekly_review: dict, + operator_digest: dict, + focus_mode: str, + top_opportunities: list[dict], + no_trade_reason_items: list[dict], +) -> dict: + queue = list(operator_digest.get("review_queue") or []) + latest_note = dict(recent_notes[0] or {}) if recent_notes else {} + next_actions = list(weekly_review.get("next_week_actions") or []) + note_templates = _build_review_entry_note_templates( + focus_mode=focus_mode, + top_opportunities=top_opportunities, + no_trade_reason_items=no_trade_reason_items, + next_actions=next_actions, + review_queue=queue, + latest_note=latest_note, + ) + recommended_template_id = _pick_recommended_review_template( + focus_mode=focus_mode, + note_templates=note_templates, + ) + template_reuse_metrics = _build_review_template_reuse_metrics( + recent_notes=recent_notes, + note_templates=note_templates, + recommended_template_id=recommended_template_id, + ) + template_usage = dict(template_reuse_metrics.get("template_usage") or {}) + for item in note_templates: + template_id = str(item.get("template_id") or "") + usage = dict(template_usage.get(template_id) or {}) + item["reuse_count"] = int(usage.get("count") or 0) + item["last_used_ts"] = usage.get("last_used_ts") + item["reuse_status"] = "saved_reuse" if item["reuse_count"] > 0 else "not_yet_reused" + return { + "status": "active", + "note_count": len(recent_notes), + "latest_note_author": latest_note.get("author") or None, + "latest_note_ts": latest_note.get("created_ts") or None, + "journal_entry_count": int(weekly_review.get("entry_count") or 0), + "review_queue_count": len(queue), + "next_review_action": next_actions[0] if next_actions 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), + "template_reuse_metrics": template_reuse_metrics, + "actions": [ + {"action_id": "open_operator_notes", "label": "Open Notes"}, + {"action_id": "open_post_trade_review", "label": "Open Review"}, + {"action_id": "generate_journal_backfill", "label": "Generate Journal"}, + ], + } + + +def _build_review_entry_note_templates( + *, + focus_mode: str, + top_opportunities: list[dict], + no_trade_reason_items: list[dict], + next_actions: list[str], + review_queue: list[dict], + latest_note: dict, +) -> list[dict]: + latest_note_preview = _truncate_text(str(latest_note.get("body") or ""), limit=90) + templates = [] + if top_opportunities: + top = top_opportunities[0] + templates.append( + { + "template_id": "top_opportunity", + "label": "Top setup note", + "body": f"[top-opportunity] {top.get('title') or 'candidate'} | {top.get('detail') or top.get('reason') or 'review next'}", + "note_scope": "workbench", + "tags": ["assistant-template", "top-opportunity"], + "priority": "high", + "reason": "best_current_opportunity", + "context_items": [top.get("status") or "observe", top.get("kind") or "decision_assistant"], + "follow_up_prompt": "Document what would confirm or invalidate this setup next.", + "reuse_hint": "Best when you want to carry the strongest current setup into the next operator loop.", + "carry_over_context": [latest_note_preview] if latest_note_preview else [], + } + ) + hard_blockers = [item for item in no_trade_reason_items if item.get("priority") == "high"] + if hard_blockers: + blocker = hard_blockers[0] + templates.append( + { + "template_id": "top_blocker", + "label": "Blocker note", + "body": f"[blocker] {blocker.get('reason') or 'n/a'} | source={blocker.get('source') or 'unknown'}", + "note_scope": "workbench", + "tags": ["assistant-template", "blocker"], + "priority": "high", + "reason": "top_execution_blocker", + "context_items": [blocker.get("category") or "risk", blocker.get("source") or "assistant"], + "follow_up_prompt": "Record what would clear this blocker before the next review cycle.", + "reuse_hint": "Best when the next pass should start from why you are still not trading.", + "carry_over_context": [latest_note_preview] if latest_note_preview else [], + } + ) + if next_actions: + next_action = str(next_actions[0] or "review_next_action") + templates.append( + { + "template_id": "next_review", + "label": "Next review note", + "body": f"[next-review] {next_action} | checkpoint for next operator pass", + "note_scope": "session", + "tags": ["assistant-template", "next-review"], + "priority": "medium", + "reason": "weekly_follow_up_action", + "context_items": [focus_mode or "monitor_and_wait", next_action], + "follow_up_prompt": "Use this when you want the next operator loop to pick up a concrete follow-up.", + "reuse_hint": "Best when you need the next review loop to inherit one explicit pending action.", + "carry_over_context": [latest_note_preview] if latest_note_preview else [], + } + ) + if review_queue: + queue_item = dict(review_queue[0] or {}) + queue_type = str(queue_item.get("queue_type") or "review_queue") + queue_reason = str(queue_item.get("reason") or queue_item.get("label") or "review pending") + templates.append( + { + "template_id": "queue_follow_up", + "label": "Queue follow-up note", + "body": f"[queue-follow-up] {queue_type} | {queue_reason}", + "note_scope": "workbench", + "tags": ["assistant-template", "queue-follow-up"], + "priority": "medium", + "reason": "operator_review_queue_anchor", + "context_items": [queue_type, queue_item.get("priority") or "review"], + "follow_up_prompt": "Capture the exact queue item that still needs operator attention.", + "reuse_hint": "Best when the operator queue is driving what should happen next.", + "carry_over_context": [latest_note_preview] if latest_note_preview else [], + } + ) + templates.append( + { + "template_id": "focus_mode", + "label": "Focus note", + "body": f"[focus-mode] {focus_mode or 'monitor_and_wait'} | next review checkpoint", + "note_scope": "workbench", + "tags": ["assistant-template", "focus-mode"], + "priority": "medium", + "reason": "current_focus_mode_anchor", + "context_items": [focus_mode or "monitor_and_wait"], + "follow_up_prompt": "Use this when you only need a quick state anchor for the next pass.", + "reuse_hint": "Best when you only need a compact checkpoint note.", + "carry_over_context": [latest_note_preview] if latest_note_preview else [], + } + ) + return templates[:5] + + +def _pick_recommended_review_template(*, focus_mode: str, note_templates: list[dict]) -> str | None: + template_by_id = { + str(item.get("template_id") or ""): dict(item) + for item in note_templates + if item + } + if focus_mode == "clear_review_queue" and template_by_id.get("queue_follow_up"): + return "queue_follow_up" + if focus_mode == "stabilize_analysis_surface" and template_by_id.get("top_blocker"): + return "top_blocker" + if focus_mode == "review_top_setup" and template_by_id.get("top_opportunity"): + return "top_opportunity" + if template_by_id.get("next_review"): + return "next_review" + if note_templates: + return str(note_templates[0].get("template_id") or "") + return None + + +def _build_review_template_reuse_metrics( + *, + recent_notes: list[dict], + note_templates: list[dict], + recommended_template_id: str | None, +) -> dict: + template_prefixes = { + str(item.get("template_id") or ""): _review_template_prefix(item) + for item in note_templates + if item + } + template_usage: dict[str, dict] = { + template_id: {"count": 0, "last_used_ts": None} + for template_id in template_prefixes + if template_id + } + assistant_template_note_count = 0 + latest_template_note_ts = None + for note in recent_notes: + body = str(note.get("body") or "") + tags = [str(tag) for tag in note.get("tags") or []] + author = str(note.get("author") or "") + note_ts = note.get("created_ts") + matched_template_ids: list[str] = [] + for template_id, prefix in template_prefixes.items(): + if prefix and prefix in body: + matched_template_ids.append(template_id) + usage = template_usage.setdefault(template_id, {"count": 0, "last_used_ts": None}) + usage["count"] = int(usage.get("count") or 0) + 1 + if note_ts and (usage.get("last_used_ts") is None or str(note_ts) > str(usage.get("last_used_ts"))): + usage["last_used_ts"] = note_ts + is_template_note = bool(matched_template_ids) or author == "assistant_template" or "assistant-template" in tags + if is_template_note: + assistant_template_note_count += 1 + if note_ts and (latest_template_note_ts is None or str(note_ts) > str(latest_template_note_ts)): + latest_template_note_ts = note_ts + + recommended_usage = dict(template_usage.get(str(recommended_template_id or "")) or {}) + recommended_reuse_count = int(recommended_usage.get("count") or 0) + if not note_templates: + reuse_status = "no_templates" + elif recommended_reuse_count > 0: + reuse_status = "recommended_reused" + elif assistant_template_note_count > 0: + reuse_status = "other_template_reused" + else: + reuse_status = "available_no_saved_reuse" + return { + "assistant_template_note_count": assistant_template_note_count, + "latest_template_note_ts": latest_template_note_ts, + "template_usage": template_usage, + "recommended_template_id": recommended_template_id, + "recommended_reuse_count": recommended_reuse_count, + "recommended_last_used_ts": recommended_usage.get("last_used_ts"), + "reuse_status": reuse_status, + } + + +def _review_template_prefix(template: dict) -> str: + body = str(template.get("body") or "") + if "]" in body and body.startswith("["): + return body.split("]", 1)[0] + "]" + template_id = str(template.get("template_id") or "") + if template_id: + return f"[{template_id.replace('_', '-')}]" + return "" + + +def _build_review_entry_template_summary(note_templates: list[dict], *, recommended_template_id: str | None) -> dict: + counts = {} + recommended_template = None + for item in note_templates: + priority = str(item.get("priority") or "unknown") + counts[priority] = int(counts.get(priority) or 0) + 1 + if str(item.get("template_id") or "") == str(recommended_template_id or ""): + recommended_template = dict(item) + return { + "count": len(note_templates), + "priorities": counts, + "labels": [str(item.get("label") or "template") for item in note_templates], + "recommended_label": (recommended_template or {}).get("label"), + "recommended_priority": (recommended_template or {}).get("priority"), + "recommended_reason": (recommended_template or {}).get("reason"), + "recommended_reuse_hint": (recommended_template or {}).get("reuse_hint"), + } + + +def _build_external_context_headline( + *, + news_window: dict, + active_events: list[dict], + launch_readiness: dict, + exchange_status: dict, + exchange_events: list[dict], + announcements: list[dict], + announcements_snapshot: dict, +) -> str: + if active_events: + return f"External macro context active: {', '.join([str(item.get('title') or item.get('event_id') or 'macro_event') for item in active_events[:2]])}." + if exchange_events: + return f"Exchange status context active: {', '.join([str(item.get('title') or item.get('service_type') or 'system_status_event') for item in exchange_events[:2]])}." + if announcements: + return f"OKX announcement context active: {', '.join([str(item.get('title') or 'announcement') for item in announcements[:2]])}." + waiting = list(launch_readiness.get("external_waiting_items") or []) + if waiting: + return f"External read-only context waiting items: {', '.join([str(item) for item in waiting[:2]])}." + if exchange_status.get("status") == "error": + return f"Exchange status source degraded: {exchange_status.get('error') or 'unknown_error'}." + if announcements_snapshot.get("status") == "error": + return f"Announcement source degraded: {announcements_snapshot.get('error') or 'unknown_error'}." + return f"External context stable: provider={news_window.get('provider_name') or 'unknown'} freshness={news_window.get('freshness_code') or 'unknown'}." + + +def _build_decision_assistant_personalized_reminders( + *, + top_opportunities: list[dict], + no_trade_reason_items: list[dict], + operator_digest: dict, + external_context_summary: dict, + learning_rollup: dict, +) -> list[dict]: + reminders: list[dict] = [] + if top_opportunities: + item = top_opportunities[0] + reminders.append( + { + "severity": "high", + "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", + } + ) + high_blockers = [item for item in no_trade_reason_items if item.get("priority") == "high"] + if high_blockers: + reminders.append( + { + "severity": "high", + "title": "Resolve no-trade blockers first", + "message": ", ".join([str(item.get("reason") or "") for item in high_blockers[:2]]), + "source": "no_trade_reason_items", + } + ) + review_queue = list(operator_digest.get("review_queue") or []) + if review_queue: + reminders.append( + { + "severity": "medium", + "title": "Clear operator review queue", + "message": f"{review_queue[0].get('queue_type') or 'queue'} needs review", + "source": "operator_digest", + } + ) + if list(external_context_summary.get("active_event_titles") or []): + reminders.append( + { + "severity": "medium", + "title": "Watch external macro context", + "message": ", ".join([str(item) for item in list(external_context_summary.get("active_event_titles") or [])[:2]]), + "source": "external_context_summary", + } + ) + if list(learning_rollup.get("next_week_actions") or []): + reminders.append( + { + "severity": "low", + "title": "Capture learning follow-up", + "message": str((learning_rollup.get("next_week_actions") or [])[0]), + "source": "learning_rollup", + } + ) + return reminders[:5] + + +def _build_decision_assistant_digest_surface( + *, + focus_mode: str, + personalized_reminders: list[dict], + learning_rollup: dict, + top_opportunities: list[dict], + digest_history_preview: list[dict], + digest_history_summary: dict, +) -> dict: + return { + "status": "active" if personalized_reminders or top_opportunities else "idle", + "focus_mode": focus_mode, + "reminder_count": len(personalized_reminders), + "high_priority_count": sum(1 for item in personalized_reminders if str(item.get("severity") or "") == "high"), + "top_opportunity_title": top_opportunities[0].get("title") if top_opportunities else None, + "learning_status": learning_rollup.get("status") or "idle", + "history_count": len(digest_history_preview), + "history_summary": digest_history_summary, + "summary": personalized_reminders[0].get("title") if personalized_reminders else "digest_surface_idle", + } + + +def _build_decision_assistant_digest_history_preview( + *, + personalized_reminders: list[dict], + recent_alerts: list[dict], + recent_notes: list[dict], + previous_packet: dict, +) -> list[dict]: + history = [] + for item in personalized_reminders[:2]: + history.append( + { + "kind": "reminder", + "title": item.get("title") or "reminder", + "detail": item.get("message") or "", + "ts": None, + } + ) + if recent_alerts: + latest_alert = dict(recent_alerts[0] or {}) + history.append( + { + "kind": "alert", + "title": latest_alert.get("alert_type") or "alert", + "detail": latest_alert.get("status") or "queued", + "ts": latest_alert.get("created_ts"), + } + ) + if recent_notes: + latest_note = dict(recent_notes[0] or {}) + history.append( + { + "kind": "note", + "title": latest_note.get("author") or "operator_note", + "detail": latest_note.get("body") or latest_note.get("note_scope") or "note", + "ts": latest_note.get("created_ts"), + } + ) + if previous_packet: + history.append( + { + "kind": "previous_packet", + "title": "Previous packet", + "detail": previous_packet.get("action_recommendation") or previous_packet.get("status") or "previous_packet", + "ts": previous_packet.get("packet_ts"), + } + ) + return history[:5] + + +def _build_decision_assistant_digest_history_summary(history: list[dict]) -> dict: + counts = {} + latest_ts = None + stale_kind_count = 0 + fresh_kind_count = 0 + for item in history: + kind = str(item.get("kind") or "history") + counts[kind] = int(counts.get(kind) or 0) + 1 + ts = item.get("ts") + if ts and (latest_ts is None or str(ts) > str(latest_ts)): + latest_ts = ts + groups = _build_decision_assistant_digest_history_groups(history, recommended_kind=None) + for group in groups: + if bool(group.get("stale")): + stale_kind_count += 1 + if str(group.get("age_band") or "") in {"fresh_now", "monitor_window"}: + fresh_kind_count += 1 + refresh_prompts = [ + { + "kind": str(group.get("kind") or "history"), + "prompt": group.get("refresh_prompt"), + "action_label": group.get("refresh_action_label"), + } + for group in groups + if bool(group.get("refresh_needed")) + ] + sorted_kinds = [kind for kind, _count in sorted(counts.items(), key=lambda row: (row[1], row[0]), reverse=True)] + recommended_kind = sorted_kinds[0] if sorted_kinds else None + return { + "counts": counts, + "latest_ts": latest_ts, + "kinds": list(counts.keys()), + "sorted_kinds": sorted_kinds, + "recommended_kind": recommended_kind, + "recommended_count": counts.get(recommended_kind, 0) if recommended_kind else 0, + "stale_kind_count": stale_kind_count, + "fresh_kind_count": fresh_kind_count, + "refresh_needed_count": len(refresh_prompts), + "refresh_prompts": refresh_prompts[:3], + } + + +def _build_decision_assistant_digest_history_groups(history: list[dict], *, recommended_kind: str | None) -> list[dict]: + buckets: dict[str, list[dict]] = {} + for item in history: + kind = str(item.get("kind") or "history") + buckets.setdefault(kind, []).append(dict(item)) + groups = [] + for kind, items in buckets.items(): + latest_ts = None + for item in items: + ts = item.get("ts") + if ts and (latest_ts is None or str(ts) > str(latest_ts)): + latest_ts = ts + age = _resolve_digest_age(latest_ts) + refresh_needed = bool(age.get("stale")) + groups.append( + { + "kind": kind, + "count": len(items), + "latest_ts": latest_ts, + "group_status": _digest_history_group_status(kind=kind, count=len(items)), + "recommended": bool(recommended_kind and kind == recommended_kind), + "headline": _digest_history_group_headline(kind=kind, items=items), + "latest_title": items[0].get("title") if items else None, + "action_label": _digest_history_group_action_label(kind=kind), + "age_seconds": age.get("age_seconds"), + "age_band": age.get("age_band"), + "stale": age.get("stale"), + "staleness_hint": age.get("staleness_hint"), + "refresh_needed": refresh_needed, + "refresh_prompt": _digest_history_refresh_prompt(kind=kind, age=age), + "refresh_action_label": _digest_history_refresh_action_label(kind=kind, refresh_needed=refresh_needed), + "items": items[:3], + } + ) + groups.sort( + key=lambda item: ( + bool(item.get("recommended")), + str(item.get("latest_ts") or ""), + str(item.get("kind") or ""), + ), + reverse=True, + ) + return groups + + +def _digest_history_group_headline(*, kind: str, items: list[dict]) -> str: + latest = dict(items[0] or {}) if items else {} + latest_title = str(latest.get("title") or kind or "history") + if len(items) <= 1: + return f"{latest_title} is the only recent {kind} item." + return f"{latest_title} leads {len(items)} recent {kind} items." + + +def _digest_history_group_action_label(*, kind: str) -> str: + if kind == "reminder": + return "Review reminders" + if kind == "alert": + return "Review alerts" + if kind == "note": + return "Review notes" + if kind == "previous_packet": + return "Review previous packet" + return "Review history" + + +def _digest_history_refresh_prompt(*, kind: str, age: dict) -> str: + age_band = str(age.get("age_band") or "unknown_age") + if bool(age.get("stale")): + return f"Refresh {kind} before treating this recall group as live operator context." + if age_band == "monitor_window": + return f"Recheck {kind} if you are about to use it for the next decision note." + if age_band == "fresh_now": + return f"{kind} is fresh enough for the current assistant loop." + if age_band in {"untimed", "unknown_age"}: + return f"{kind} has weak timing metadata; verify freshness before relying on it." + return f"Review {kind} freshness before the next operator pass." + + +def _digest_history_refresh_action_label(*, kind: str, refresh_needed: bool) -> str: + if refresh_needed: + if kind == "alert": + return "Refresh Alerts" + if kind == "note": + return "Refresh Notes" + if kind == "previous_packet": + return "Refresh Packet" + if kind == "reminder": + return "Refresh Reminders" + return "Refresh History" + return _digest_history_group_action_label(kind=kind) + + +def _resolve_digest_age(latest_ts) -> dict: + if not latest_ts: + return { + "age_seconds": None, + "age_band": "untimed", + "stale": False, + "staleness_hint": "This history group does not carry a concrete timestamp.", + } + parsed = _parse_iso_timestamp(latest_ts) + if parsed is None: + return { + "age_seconds": None, + "age_band": "unknown_age", + "stale": False, + "staleness_hint": "This history group has a timestamp we could not normalize.", + } + age_seconds = max(0, int((datetime.now(timezone.utc) - parsed).total_seconds())) + if age_seconds <= 900: + return { + "age_seconds": age_seconds, + "age_band": "fresh_now", + "stale": False, + "staleness_hint": "Recent enough to trust as current operator context.", + } + if age_seconds <= 3600: + return { + "age_seconds": age_seconds, + "age_band": "monitor_window", + "stale": False, + "staleness_hint": "Still inside the current monitoring window, but worth rechecking before acting.", + } + if age_seconds <= 21600: + return { + "age_seconds": age_seconds, + "age_band": "same_session", + "stale": True, + "staleness_hint": "From the same session, but old enough that conditions may have drifted.", + } + return { + "age_seconds": age_seconds, + "age_band": "stale", + "stale": True, + "staleness_hint": "Old enough that this group should be treated as recall, not live context.", + } + + +def _digest_history_group_status(*, kind: str, count: int) -> str: + if kind == "reminder": + return "actionable" if count else "idle" + if kind in {"alert", "previous_packet"}: + return "review" + if kind == "note": + return "context" + return "history" + + +def _build_decision_assistant_setup_learning_rollup( + *, + decision_packet: dict, + weekly_review: dict, + research_council_service: ResearchCouncilService, + instrument_id: int, + fallback_session_code: str | None, +) -> dict: + model_code = str(decision_packet.get("setup_type") or "") + packet_session_code = str( + decision_packet.get("raw", {}).get("candidate", {}).get("session_code") + or decision_packet.get("raw", {}).get("signal", {}).get("session_code") + or "" + ) + session_code = packet_session_code or str(fallback_session_code or "") + session_code_source = "decision_packet" if packet_session_code else ("market_session_fallback" if fallback_session_code else "unknown") + if hasattr(research_council_service, "get_compact_lessons") and model_code: + lessons = list( + research_council_service.get_compact_lessons( + instrument_id=instrument_id, + session_code=session_code or None, + setup_type=model_code or None, + model_code=model_code or None, + limit=5, + ) + or [] + ) + else: + lessons = [] + session_performance = dict(weekly_review.get("session_performance") or {}) + current_session_stats = dict(session_performance.get(session_code) or {}) + replay_statistics = _build_setup_replay_statistics( + setup_type=model_code, + session_code=session_code, + current_session_stats=current_session_stats, + session_performance=session_performance, + high_quality_session=dict(weekly_review.get("high_quality_session") or {}), + low_quality_session=dict(weekly_review.get("low_quality_session") or {}), + ) + return { + "status": "active" if lessons or current_session_stats else "idle", + "setup_type": model_code or "unknown", + "session_code": session_code or "OFF_HOURS", + "session_code_source": session_code_source, + "lesson_count": len(lessons), + "top_lessons": [ + { + "title": str(item.get("title") or item.get("lesson_key") or "lesson"), + "summary": str(item.get("lesson_summary") or ""), + "lesson_type": str(item.get("lesson_type") or "lesson"), + } + for item in lessons[:3] + ], + "current_session_performance": current_session_stats, + "high_quality_session": dict(weekly_review.get("high_quality_session") or {}), + "low_quality_session": dict(weekly_review.get("low_quality_session") or {}), + "replay_statistics": replay_statistics, + } + + +def _build_decision_assistant_scan_summary_cards( + *, + top_opportunities: list[dict], + no_trade_reason_items: list[dict], + review_entrypoints: dict, + external_context_summary: dict, + digest_surface: dict, +) -> list[dict]: + cards = [] + if top_opportunities: + cards.append( + { + "title": "Top Opportunity", + "status": top_opportunities[0].get("status") or "observe", + "detail": top_opportunities[0].get("title") or "n/a", + "context": top_opportunities[0].get("reason") or top_opportunities[0].get("detail") or "review next", + "rank": 1, + "emphasis": "primary", + "action_label": "Open Setup", + "transition_hint": "Open the setup section and decide whether it is worth a template-backed note.", + "completion_state": "ready_to_review", + "completion_hint": "Review this setup, then either capture a note or leave it in watch mode.", + "target_section": "opportunities", + } + ) + hard_blockers = [item for item in no_trade_reason_items if item.get("priority") == "high"] + if hard_blockers: + cards.append( + { + "title": "Top Blocker", + "status": hard_blockers[0].get("priority") or "high", + "detail": hard_blockers[0].get("reason") or "n/a", + "context": f"{hard_blockers[0].get('category') or 'risk'} / {hard_blockers[0].get('source') or 'assistant'}", + "rank": 2, + "emphasis": "warning", + "action_label": "Open Blockers", + "transition_hint": "Open blockers first and confirm what must clear before the next pass.", + "completion_state": "blocked_until_resolved", + "completion_hint": "This scan step is incomplete until the blocker is explicitly cleared or accepted as observe-only context.", + "target_section": "no-trade-reasons", + } + ) + if review_entrypoints: + cards.append( + { + "title": "Next Review", + "status": review_entrypoints.get("status") or "active", + "detail": review_entrypoints.get("next_review_action") or "open_operator_notes", + "context": review_entrypoints.get("template_summary", {}).get("recommended_label") or "notes ready", + "rank": 3, + "emphasis": "action", + "action_label": "Open Review", + "transition_hint": "Jump into review entry and either use or save the recommended template.", + "completion_state": "ready_to_capture", + "completion_hint": "Capture the recommended review note to make the next operator loop easier to resume.", + "target_section": "review-entry", + } + ) + if external_context_summary: + external_status = str(external_context_summary.get("status") or "idle") + freshness_code = str(external_context_summary.get("freshness_code") or "unknown") + external_needs_refresh = external_status == "error" or freshness_code in {"stale", "unknown", "unavailable", "missing"} + cards.append( + { + "title": "External Context", + "status": external_context_summary.get("status") or "idle", + "detail": external_context_summary.get("headline") or "n/a", + "context": f"{external_context_summary.get('provider_name') or 'unknown'} / {external_context_summary.get('freshness_code') or 'unknown'}", + "rank": 4, + "emphasis": "context", + "action_label": "Open Context", + "transition_hint": "Open context and check whether the latest external source is still fresh enough.", + "completion_state": "needs_refresh" if external_needs_refresh else "fresh_context", + "completion_hint": "Refresh or recheck read-only context before relying on it." if external_needs_refresh else "External context is usable for the current analysis pass.", + "target_section": "external-context", + } + ) + if digest_surface: + digest_history_summary = dict(digest_surface.get("history_summary") or {}) + digest_needs_refresh = int(digest_history_summary.get("refresh_needed_count") or digest_history_summary.get("stale_kind_count") or 0) > 0 + cards.append( + { + "title": "Digest", + "status": digest_surface.get("status") or "idle", + "detail": digest_surface.get("summary") or "n/a", + "context": f"history={digest_surface.get('history_summary', {}).get('recommended_kind') or 'n/a'} reminders={digest_surface.get('reminder_count') or 0}", + "rank": 5, + "emphasis": "summary", + "action_label": "Open Digest", + "transition_hint": "Open digest recall and confirm whether the active history group is still fresh.", + "completion_state": "needs_refresh" if digest_needs_refresh else "fresh_context", + "completion_hint": "Refresh stale recall groups before using them as current context." if digest_needs_refresh else "Digest recall is fresh enough for the current assistant loop.", + "target_section": "digest", + } + ) + return _mark_next_scan_step(cards[:5]) + + +def _mark_next_scan_step(cards: list[dict]) -> list[dict]: + priority = { + "blocked_until_resolved": 0, + "needs_refresh": 1, + "ready_to_review": 2, + "ready_to_capture": 3, + "fresh_context": 4, + } + next_card_title = None + candidates = [ + dict(item) + for item in cards + if str(item.get("completion_state") or "") not in {"complete", "fresh_context"} + ] + if candidates: + selected = sorted(candidates, key=lambda item: (priority.get(str(item.get("completion_state") or ""), 9), int(item.get("rank") or 99)))[0] + next_card_title = str(selected.get("title") or "") + for index, item in enumerate(cards, start=1): + item["scan_step_index"] = index + item["is_next_scan_step"] = bool(next_card_title and str(item.get("title") or "") == next_card_title) + if item["is_next_scan_step"]: + item["completion_hint"] = f"Next scan step: {item.get('completion_hint') or item.get('transition_hint') or 'review this card.'}" + return cards + + +def _build_decision_assistant_scan_completion_summary(cards: list[dict]) -> dict: + state_counts: dict[str, int] = {} + for item in cards: + state = str(item.get("completion_state") or "unknown") + state_counts[state] = int(state_counts.get(state) or 0) + 1 + next_card = next((item for item in cards if bool(item.get("is_next_scan_step"))), {}) + refresh_needed_count = int(state_counts.get("needs_refresh") or 0) + blocked_count = int(state_counts.get("blocked_until_resolved") or 0) + ready_count = sum( + int(state_counts.get(state) or 0) + for state in ("ready_to_review", "ready_to_capture") + ) + return { + "status": "active" if cards else "idle", + "total_count": len(cards), + "state_counts": state_counts, + "refresh_needed_count": refresh_needed_count, + "blocked_count": blocked_count, + "ready_count": ready_count, + "open_count": blocked_count + refresh_needed_count + ready_count, + "next_title": next_card.get("title"), + "next_target_section": next_card.get("target_section"), + "next_completion_state": next_card.get("completion_state"), + "next_hint": next_card.get("completion_hint"), + } + + +def _build_setup_replay_statistics( + *, + setup_type: str, + session_code: str, + current_session_stats: dict, + session_performance: dict, + high_quality_session: dict, + low_quality_session: dict, +) -> dict: + framework_rate = float(current_session_stats.get("framework_execution_rate") or 0.0) + model_outside_rate = float(current_session_stats.get("model_outside_rate") or 0.0) + avg_r_multiple = float(current_session_stats.get("avg_r_multiple") or 0.0) + risk_violation_count = int(current_session_stats.get("risk_violation_count") or 0) + comparison = _build_setup_replay_comparison( + session_code=session_code, + current_session_stats=current_session_stats, + high_quality_session=high_quality_session, + low_quality_session=low_quality_session, + ) + broader_baseline_summary = _build_setup_replay_broader_baseline_summary( + setup_type=setup_type, + session_code=session_code, + current_session_stats=current_session_stats, + session_performance=session_performance, + ) + return { + "setup_type": setup_type or "unknown", + "session_code": session_code or "OFF_HOURS", + "sample_count": int(current_session_stats.get("count") or 0), + "framework_execution_rate": framework_rate, + "model_outside_rate": model_outside_rate, + "risk_violation_count": risk_violation_count, + "avg_r_multiple": avg_r_multiple, + "high_quality_session_code": high_quality_session.get("session_code"), + "low_quality_session_code": low_quality_session.get("session_code"), + "comparative_summary": comparison, + "broader_baseline_summary": broader_baseline_summary, + } + + +def _build_setup_replay_comparison( + *, + session_code: str, + current_session_stats: dict, + high_quality_session: dict, + low_quality_session: dict, +) -> dict: + current_framework = float(current_session_stats.get("framework_execution_rate") or 0.0) + current_r = float(current_session_stats.get("avg_r_multiple") or 0.0) + high_framework = float(high_quality_session.get("framework_execution_rate") or 0.0) + high_r = float(high_quality_session.get("avg_r_multiple") or 0.0) + low_framework = float(low_quality_session.get("framework_execution_rate") or 0.0) + low_r = float(low_quality_session.get("avg_r_multiple") or 0.0) + sample_count = int(current_session_stats.get("count") or 0) + notes = [] + comparison_status = "insufficient_samples" + if sample_count == 0: + notes.append("no_replay_samples_for_current_session") + if session_code and session_code == str(high_quality_session.get("session_code") or ""): + notes.append("current_session_matches_weekly_high_quality_session") + if session_code and session_code == str(low_quality_session.get("session_code") or ""): + notes.append("current_session_matches_weekly_low_quality_session") + if sample_count > 0 and current_framework >= high_framework and current_r >= high_r: + notes.append("current_setup_matches_or_exceeds_high_quality_session") + comparison_status = "stronger_than_high_baseline" + elif sample_count > 0 and (current_framework < low_framework or current_r < low_r): + notes.append("current_setup_is_below_low_quality_session_baseline") + comparison_status = "below_low_baseline" + elif sample_count > 0: + notes.append("current_setup_sits_between_high_and_low_session_baselines") + comparison_status = "between_baselines" + confidence_band = "low" + if sample_count >= 8: + confidence_band = "high" + elif sample_count >= 3: + confidence_band = "medium" + session_alignment = "neutral" + if session_code and session_code == str(high_quality_session.get("session_code") or ""): + session_alignment = "high_quality_session" + elif session_code and session_code == str(low_quality_session.get("session_code") or ""): + session_alignment = "low_quality_session" + return { + "status": "active" if sample_count > 0 else "idle", + "comparison_status": comparison_status, + "confidence_band": confidence_band, + "session_alignment": session_alignment, + "framework_execution_gap_vs_high": current_framework - high_framework, + "avg_r_gap_vs_high": current_r - high_r, + "framework_execution_gap_vs_low": current_framework - low_framework, + "avg_r_gap_vs_low": current_r - low_r, + "notes": notes[:4], + "story": _build_setup_replay_story( + comparison_status=comparison_status, + confidence_band=confidence_band, + session_alignment=session_alignment, + sample_count=sample_count, + ), + } + + +def _build_setup_replay_story( + *, + comparison_status: str, + confidence_band: str, + session_alignment: str, + sample_count: int, +) -> str: + if comparison_status == "insufficient_samples": + return "Replay comparison is advisory because the current session has no local replay samples." + if comparison_status == "stronger_than_high_baseline": + return f"Current replay profile is at or above the weekly high-quality baseline with {confidence_band} confidence from {sample_count} samples." + if comparison_status == "below_low_baseline": + return f"Current replay profile is below the weekly low-quality baseline with {confidence_band} confidence from {sample_count} samples." + alignment_text = "current session aligns with known baselines" if session_alignment != "neutral" else "current session sits between known baselines" + return f"Current replay profile sits between weekly high/low baselines; {alignment_text} with {confidence_band} confidence from {sample_count} samples." + + +def _build_setup_replay_broader_baseline_summary( + *, + setup_type: str, + session_code: str, + current_session_stats: dict, + session_performance: dict, +) -> dict: + setup_family = _setup_family_label(setup_type) + rows = [ + {"session_code": code, **dict(stats or {})} + for code, stats in dict(session_performance or {}).items() + if isinstance(stats, dict) + ] + active_rows = [row for row in rows if int(row.get("count") or 0) > 0] + if not active_rows: + return { + "status": "idle", + "session_count": 0, + "framework_execution_global_avg": 0.0, + "avg_r_global_avg": 0.0, + "risk_violation_global_avg": 0.0, + "framework_gap_vs_global_avg": 0.0, + "avg_r_gap_vs_global_avg": 0.0, + "risk_gap_vs_global_avg": 0.0, + "framework_rank": None, + "avg_r_rank": None, + "risk_rank": None, + "framework_percentile": None, + "avg_r_percentile": None, + "risk_percentile": None, + "composite_percentile": None, + "risk_band": "unranked", + "composite_band": "unranked", + "composite_rank": None, + "position_band": "unranked", + "top_sessions": [], + "top_r_sessions": [], + "top_safe_sessions": [], + "top_composite_sessions": [], + "peer_sessions": [], + "peer_summary": "No peer baseline is available until the current session is ranked.", + "setup_family": setup_family, + "setup_family_status": "idle", + "setup_family_peer_sessions": [], + "setup_family_peer_summary": f"No setup-family peer baseline is available yet for {setup_family}.", + "setup_family_focus": "Wait for active session statistics before comparing this setup family.", + "replay_verdict": "unranked", + "story": "No broader replay baseline is available yet because there are no active session statistics.", + } + + framework_avg = sum(float(row.get("framework_execution_rate") or 0.0) for row in active_rows) / len(active_rows) + avg_r_avg = sum(float(row.get("avg_r_multiple") or 0.0) for row in active_rows) / len(active_rows) + risk_avg = sum(float(row.get("risk_violation_count") or 0.0) for row in active_rows) / len(active_rows) + current_framework = float(current_session_stats.get("framework_execution_rate") or 0.0) + current_r = float(current_session_stats.get("avg_r_multiple") or 0.0) + current_risk = float(current_session_stats.get("risk_violation_count") or 0.0) + framework_rank = _rank_session_metric(active_rows, session_code=session_code, metric="framework_execution_rate") + avg_r_rank = _rank_session_metric(active_rows, session_code=session_code, metric="avg_r_multiple") + risk_rank = _rank_session_metric(active_rows, session_code=session_code, metric="risk_violation_count", reverse=False) + framework_percentile = _rank_to_percentile(rank=framework_rank, total=len(active_rows)) + avg_r_percentile = _rank_to_percentile(rank=avg_r_rank, total=len(active_rows)) + risk_percentile = _rank_to_percentile(rank=risk_rank, total=len(active_rows)) + composite_percentile = _combine_percentiles(framework_percentile, avg_r_percentile) + risk_band = _percentile_band(risk_percentile) + composite_band = _percentile_band(composite_percentile) + composite_rank = _rank_session_composite(active_rows, session_code=session_code) + peer_sessions = _session_composite_peer_lane(active_rows, session_code=session_code, window=1) + sample_count = int(current_session_stats.get("count") or 0) + position_band = "unranked" + if framework_rank and len(active_rows): + if framework_rank <= max(1, len(active_rows) // 3): + position_band = "top_third" + elif framework_rank >= max(1, len(active_rows) - max(1, len(active_rows) // 3) + 1): + position_band = "bottom_third" + else: + position_band = "middle_band" + story = "Current session has no replay samples against broader baselines." + if sample_count > 0: + story = ( + f"Current session ranks framework #{framework_rank or 'n/a'} and avg-R #{avg_r_rank or 'n/a'} " + f"with risk discipline #{risk_rank or 'n/a'} across {len(active_rows)} active sessions; " + f"it sits in the {position_band.replace('_', ' ')}." + ) + replay_verdict = _build_replay_verdict( + composite_band=composite_band, + risk_band=risk_band, + composite_percentile=composite_percentile, + risk_percentile=risk_percentile, + ) + peer_summary = _build_peer_baseline_summary( + session_code=session_code, + composite_rank=composite_rank, + peer_sessions=peer_sessions, + ) + setup_family_peer_sessions = _build_setup_family_peer_sessions( + setup_family=setup_family, + peer_sessions=peer_sessions, + top_composite_sessions=_session_composite_leaderboard(active_rows, limit=3), + ) + setup_family_peer_summary = _build_setup_family_peer_summary( + setup_family=setup_family, + session_code=session_code, + sample_count=sample_count, + peer_sessions=setup_family_peer_sessions, + ) + return { + "status": "active", + "session_count": len(active_rows), + "framework_execution_global_avg": framework_avg, + "avg_r_global_avg": avg_r_avg, + "risk_violation_global_avg": risk_avg, + "framework_gap_vs_global_avg": current_framework - framework_avg, + "avg_r_gap_vs_global_avg": current_r - avg_r_avg, + "risk_gap_vs_global_avg": current_risk - risk_avg, + "framework_rank": framework_rank, + "avg_r_rank": avg_r_rank, + "risk_rank": risk_rank, + "framework_percentile": framework_percentile, + "avg_r_percentile": avg_r_percentile, + "risk_percentile": risk_percentile, + "composite_percentile": composite_percentile, + "risk_band": risk_band, + "composite_band": composite_band, + "composite_rank": composite_rank, + "position_band": position_band, + "top_sessions": _session_metric_leaderboard(active_rows, metric="framework_execution_rate", limit=3), + "top_r_sessions": _session_metric_leaderboard(active_rows, metric="avg_r_multiple", limit=3), + "top_safe_sessions": _session_metric_leaderboard(active_rows, metric="risk_violation_count", limit=3, reverse=False), + "top_composite_sessions": _session_composite_leaderboard(active_rows, limit=3), + "peer_sessions": peer_sessions, + "peer_summary": peer_summary, + "setup_family": setup_family, + "setup_family_status": "active" if sample_count > 0 else "watch_only", + "setup_family_peer_sessions": setup_family_peer_sessions, + "setup_family_peer_summary": setup_family_peer_summary, + "setup_family_focus": f"Compare {setup_family} against adjacent session lanes before promoting this setup family in the next review.", + "replay_verdict": replay_verdict, + "story": f"{story} Verdict: {replay_verdict.replace('_', ' ')}.", + } + + +def _rank_session_metric(rows: list[dict], *, session_code: str, metric: str, reverse: bool = True) -> int | None: + if not session_code: + return None + ranked = sorted( + rows, + key=lambda row: (float(row.get(metric) or 0.0), str(row.get("session_code") or "")), + reverse=reverse, + ) + for index, row in enumerate(ranked, start=1): + if str(row.get("session_code") or "") == str(session_code): + return index + return None + + +def _setup_family_label(setup_type: str) -> str: + normalized = str(setup_type or "").strip().upper() + if not normalized: + return "UNKNOWN_SETUP_FAMILY" + if "SWEEP" in normalized and ("MSS" in normalized or "FVG" in normalized): + return "LIQUIDITY_REVERSAL_FAMILY" + if "FVG" in normalized: + return "IMBALANCE_RETEST_FAMILY" + if "BREAK" in normalized or "MSS" in normalized: + return "STRUCTURE_SHIFT_FAMILY" + return f"{normalized}_FAMILY" + + +def _build_setup_family_peer_sessions( + *, + setup_family: str, + peer_sessions: list[dict], + top_composite_sessions: list[dict], +) -> list[dict]: + source_rows = peer_sessions or top_composite_sessions + family_peers = [] + for item in source_rows[:3]: + family_peers.append( + { + "setup_family": setup_family, + "session_code": str(item.get("session_code") or "unknown"), + "rank": item.get("rank"), + "relative_position": item.get("relative_position") or "leaderboard", + "framework_execution_rate": float(item.get("framework_execution_rate") or 0.0), + "avg_r_multiple": float(item.get("avg_r_multiple") or 0.0), + "risk_violation_count": int(item.get("risk_violation_count") or 0), + "count": int(item.get("count") or 0), + } + ) + return family_peers + + +def _build_setup_family_peer_summary( + *, + setup_family: str, + session_code: str, + sample_count: int, + peer_sessions: list[dict], +) -> str: + if sample_count <= 0: + return f"{setup_family} has no current-session replay samples, so peer baselines remain watch-only." + if not peer_sessions: + return f"{setup_family} has current samples in {session_code or 'unknown'}, but no peer lane is available yet." + peer_codes = ", ".join([str(item.get("session_code") or "unknown") for item in peer_sessions[:3]]) + return f"{setup_family} in {session_code or 'unknown'} should be compared against peer sessions {peer_codes} before reuse." + + +def _rank_session_composite(rows: list[dict], *, session_code: str) -> int | None: + if not session_code: + return None + ranked = sorted( + rows, + key=lambda row: ( + float(row.get("framework_execution_rate") or 0.0), + float(row.get("avg_r_multiple") or 0.0), + -int(row.get("risk_violation_count") or 0), + str(row.get("session_code") or ""), + ), + reverse=True, + ) + for index, row in enumerate(ranked, start=1): + if str(row.get("session_code") or "") == str(session_code): + return index + return None + + +def _session_metric_leaderboard(rows: list[dict], *, metric: str, limit: int, reverse: bool = True) -> list[dict]: + ranked = sorted( + rows, + key=lambda row: (float(row.get(metric) or 0.0), str(row.get("session_code") or "")), + reverse=reverse, + ) + return [ + { + "session_code": str(row.get("session_code") or "unknown"), + "metric": float(row.get(metric) or 0.0), + "count": int(row.get("count") or 0), + } + for row in ranked[:limit] + ] + + +def _session_composite_leaderboard(rows: list[dict], *, limit: int) -> list[dict]: + ranked = sorted( + rows, + key=lambda row: ( + float(row.get("framework_execution_rate") or 0.0), + float(row.get("avg_r_multiple") or 0.0), + -int(row.get("risk_violation_count") or 0), + str(row.get("session_code") or ""), + ), + reverse=True, + ) + return [ + { + "session_code": str(row.get("session_code") or "unknown"), + "framework_execution_rate": float(row.get("framework_execution_rate") or 0.0), + "avg_r_multiple": float(row.get("avg_r_multiple") or 0.0), + "risk_violation_count": int(row.get("risk_violation_count") or 0), + } + for row in ranked[:limit] + ] + + +def _session_composite_peer_lane(rows: list[dict], *, session_code: str, window: int) -> list[dict]: + if not session_code: + return [] + ranked = sorted( + rows, + key=lambda row: ( + float(row.get("framework_execution_rate") or 0.0), + float(row.get("avg_r_multiple") or 0.0), + -int(row.get("risk_violation_count") or 0), + str(row.get("session_code") or ""), + ), + reverse=True, + ) + current_index = None + for index, row in enumerate(ranked): + if str(row.get("session_code") or "") == str(session_code): + current_index = index + break + if current_index is None: + return [] + peers: list[dict] = [] + start = max(0, current_index - window) + end = min(len(ranked), current_index + window + 1) + for index in range(start, end): + if index == current_index: + continue + row = ranked[index] + relative_position = "above" if index < current_index else "below" + peers.append( + { + "session_code": str(row.get("session_code") or "unknown"), + "rank": index + 1, + "relative_position": relative_position, + "framework_execution_rate": float(row.get("framework_execution_rate") or 0.0), + "avg_r_multiple": float(row.get("avg_r_multiple") or 0.0), + "risk_violation_count": int(row.get("risk_violation_count") or 0), + "count": int(row.get("count") or 0), + } + ) + return peers + + +def _rank_to_percentile(*, rank: int | None, total: int) -> float | None: + if rank is None or total <= 0: + return None + return max(0.0, (total - rank) / total) + + +def _combine_percentiles(framework_percentile: float | None, avg_r_percentile: float | None) -> float | None: + values = [value for value in (framework_percentile, avg_r_percentile) if value is not None] + if not values: + return None + return sum(values) / len(values) + + +def _percentile_band(percentile: float | None) -> str: + if percentile is None: + return "unranked" + if percentile >= 0.66: + return "top_band" + if percentile <= 0.33: + return "bottom_band" + return "middle_band" + + +def _build_replay_verdict( + *, + composite_band: str, + risk_band: str, + composite_percentile: float | None, + risk_percentile: float | None, +) -> str: + if composite_percentile is None: + return "unranked" + if composite_band == "top_band" and risk_band == "top_band": + return "repeatable_high_quality" + if composite_band == "bottom_band" or risk_band == "bottom_band": + return "needs_tighter_selection" + if composite_percentile >= 0.5 and (risk_percentile or 0.0) >= 0.5: + return "balanced_operator_ok" + return "mixed_profile_watch_closely" + + +def _build_peer_baseline_summary(*, session_code: str, composite_rank: int | None, peer_sessions: list[dict]) -> str: + if not session_code or composite_rank is None: + return "Current session is not ranked tightly enough to compare against nearby peers yet." + if not peer_sessions: + return f"{session_code} is ranked, but there are no adjacent peer sessions around its current composite position." + descriptions = [ + f"{item.get('session_code')} ({item.get('relative_position')}, rank {item.get('rank')})" + for item in peer_sessions[:2] + ] + return f"{session_code} sits near {' and '.join(descriptions)} on the current composite replay ladder." + + +def _parse_iso_timestamp(value) -> datetime | None: + if not value: + return None + raw = str(value) + normalized = raw.replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _truncate_text(value: str, *, limit: int) -> str: + text = str(value or "").strip() + if len(text) <= limit: + return text + return f"{text[: max(0, limit - 3)].rstrip()}..." + + +def _build_decision_assistant_timeline( + *, + decision_packet: dict, + previous_packet: dict, + recent_alerts: list[dict], + recent_notes: list[dict], + recent_executions: list[dict], +) -> list[dict]: + timeline = [] + packet_ts = decision_packet.get("packet_ts") + if packet_ts: + timeline.append({"title": "Current Packet", "ts": str(packet_ts), "detail": decision_packet.get("action_recommendation") or decision_packet.get("status") or "packet"}) + previous_ts = previous_packet.get("packet_ts") + if previous_ts: + timeline.append({"title": "Previous Packet", "ts": str(previous_ts), "detail": previous_packet.get("action_recommendation") or previous_packet.get("status") or "previous_packet"}) + if recent_alerts: + latest_alert = dict(recent_alerts[0] or {}) + timeline.append({"title": "Latest Alert", "ts": str(latest_alert.get("created_ts") or ""), "detail": latest_alert.get("alert_type") or latest_alert.get("status") or "alert"}) + if recent_notes: + latest_note = dict(recent_notes[0] or {}) + timeline.append({"title": "Latest Note", "ts": str(latest_note.get("created_ts") or ""), "detail": latest_note.get("author") or latest_note.get("note_scope") or "note"}) + if recent_executions: + latest_execution = dict(recent_executions[0] or {}) + timeline.append( + { + "title": "Latest Execution", + "ts": str( + latest_execution.get("updated_ts") + or latest_execution.get("closed_ts") + or latest_execution.get("opened_ts") + or latest_execution.get("created_ts") + or "" + ), + "detail": latest_execution.get("execution_status") or latest_execution.get("side") or "execution", + } + ) + return [item for item in timeline if item.get("ts")] + + +def _build_decision_assistant_evidence_chain( + *, + launch_readiness: dict, + decision_packet: dict, + operator_digest: dict, + weekly_review: dict, + current_session: str, +) -> 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 [ + { + "title": "Decision Gate", + "status": decision_packet.get("action_recommendation") or decision_packet.get("status") or "monitor", + "detail": decision_packet.get("ai_explanation") or decision_packet.get("narrative") or "decision_packet_available", + "reason": ", ".join(list(decision_packet.get("why_allowed") or [])[:3]) or ", ".join(list(decision_packet.get("why_blocked") or [])[:3]) or "decision_gate_context", + }, + { + "title": "Launch Mode", + "status": launch_readiness.get("product_operating_mode") or "unknown", + "detail": ", ".join(blockers[:3]) if blockers else ", ".join(warnings[:3]) or "analysis_surface_ready", + "reason": "blocking_reasons" if blockers else ("warning_reasons" if warnings else "no_launch_blockers"), + }, + { + "title": "Session Context", + "status": current_session, + "detail": launch_readiness.get("market_state", {}).get("freshness", {}).get("code") or "market_state_available", + "reason": launch_readiness.get("market_state", {}).get("session", {}).get("status") or "session_status_unknown", + }, + { + "title": "Operator Queue", + "status": operator_digest.get("status") or "idle", + "detail": f"high_value={digest_counts.get('high_value_alert_count', 0)}, queue={len(list(operator_digest.get('review_queue') or []))}", + "reason": ", ".join(quality_flags[:3]) or "digest_queue_visible", + }, + { + "title": "Weekly Review", + "status": f"entries={weekly_review.get('entry_count') or 0}", + "detail": ", ".join(list(weekly_review.get("next_week_actions") or [])[:3]) or "weekly_review_available", + "reason": f"framework_rate={weekly_review.get('framework_execution_rate') if weekly_review.get('framework_execution_rate') is not None else 'n/a'}", + }, + ] + + +def _build_no_trade_reason_items(*, why_blocked: list[str], launch_blockers: list[str], launch_warnings: list[str]) -> list[dict]: + items = [] + for reason in _dedupe([*launch_blockers, *why_blocked]): + items.append( + { + "reason": reason, + "priority": _reason_priority(reason), + "category": _reason_category(reason), + "source": "launch_blocker" if reason in launch_blockers else "decision_packet", + } + ) + for reason in _dedupe(launch_warnings): + if any(item["reason"] == reason for item in items): + continue + items.append( + { + "reason": reason, + "priority": "low", + "category": "advisory", + "source": "launch_warning", + } + ) + order = {"high": 3, "medium": 2, "low": 1} + return sorted(items, key=lambda item: (order.get(str(item.get("priority")), 0), str(item.get("reason") or "")), reverse=True)[:8] + + +def _decision_packet_priority_score(decision_packet: dict) -> float: + action = str(decision_packet.get("action_recommendation") or "") + base = { + "confirm_ticket": 120.0, + "build_execution_ticket": 115.0, + "manage_ticket": 110.0, + "restricted_decision_support": 70.0, + "decision_support_only": 65.0, + "observe_only": 35.0, + }.get(action, 50.0) + return base + float(decision_packet.get("quality_score") or 0.0) + + +def _session_focus_priority_score(item: dict) -> float: + recommendation = dict(item.get("session_recommendation") or {}) + score = float(recommendation.get("score") or 0.0) + score += float(item.get("alert_count") or 0) * 5.0 + score += float(item.get("executable_candidate_count") or 0) * 8.0 + if str(item.get("action_hint") or "") == "review_alerts": + score += 15.0 + if str(item.get("action_hint") or "") == "focus_now": + score += 10.0 + return score + + +def _review_queue_priority_score(item: dict) -> float: + return { + "high": 92.0, + "medium": 78.0, + "low": 55.0, + }.get(str(item.get("priority") or ""), 60.0) + + +def _reason_priority(reason: str) -> str: + text = str(reason or "") + if any(token in text for token in ("market_", "runtime_", "broker_", "readiness_", "live_", "gap_", "stream_")): + return "high" + if any(token in text for token in ("trust_", "candidate_", "confirmation", "risk_")): + return "medium" + return "low" + + +def _reason_category(reason: str) -> str: + text = str(reason or "") + if any(token in text for token in ("market_", "stream_", "gap_")): + return "market_truth" + if any(token in text for token in ("runtime_", "broker_", "live_", "readiness_")): + return "execution_surface" + if any(token in text for token in ("trust_", "risk_")): + return "policy_guard" + return "setup_quality" + + +def _dedupe(items: list[str]) -> list[str]: + seen = set() + result = [] + for item in items: + value = str(item or "").strip() + if not value or value in seen: + continue + seen.add(value) + result.append(value) + return result + + def aggregate_session_recommendations(models: list[dict]) -> dict[str, dict]: buckets: dict[str, dict] = {} for model in models: diff --git a/src/web/app.js b/src/web/app.js index fc0c3f8..67ee7d9 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -11,6 +11,7 @@ const trustClassFilterInput = document.getElementById('trustClassFilter'); const lastUpdated = document.getElementById('lastUpdated'); const activeFilters = document.getElementById('activeFilters'); const overviewCards = document.getElementById('overviewCards'); +const decisionAssistantPanel = document.getElementById('decisionAssistantPanel'); const marketStatePanel = document.getElementById('marketStatePanel'); const runtimeStatusPanel = document.getElementById('runtimeStatusPanel'); const decisionPacketPanel = document.getElementById('decisionPacketPanel'); @@ -31,6 +32,7 @@ const canvas = document.getElementById('candlesChart'); const ctx = canvas.getContext('2d'); let autoRefreshTimer = null; +let assistantHistoryKindFilter = loadAssistantHistoryKindFilter(); async function fetchJson(url, options = {}) { const fetchOptions = { ...options }; @@ -69,6 +71,67 @@ function clearNode(node) { } } +function revealPanel(node) { + const panel = node?.closest?.('.panel') || node; + if (panel && typeof panel.scrollIntoView === 'function') { + panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } +} + +function loadAssistantHistoryKindFilter() { + try { + return window.localStorage?.getItem('assistantHistoryKindFilter') || 'all'; + } catch { + return 'all'; + } +} + +function persistAssistantHistoryKindFilter(value) { + assistantHistoryKindFilter = value || 'all'; + try { + window.localStorage?.setItem('assistantHistoryKindFilter', assistantHistoryKindFilter); + } catch { + // no-op + } +} + +function normalizeAssistantSectionId(title) { + return String(title || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function revealAssistantSection(sectionId) { + if (!decisionAssistantPanel) return; + const target = decisionAssistantPanel.querySelector(`[data-assistant-section="${sectionId}"]`); + if (target && typeof target.scrollIntoView === 'function') { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } +} + +function scanCardActionLabel(sectionId) { + if (sectionId === 'review-entry') return 'Open Review'; + if (sectionId === 'digest') return 'Open Digest'; + if (sectionId === 'external-context') return 'Open Context'; + return 'Open'; +} + +function prefillOperatorNote(body) { + revealPanel(operatorConsolePanel); + const textarea = operatorConsolePanel.querySelector('textarea'); + if (textarea) { + textarea.value = body || ''; + textarea.focus(); + } +} + +function appendSectionTitle(container, title) { + const element = createElement('div', 'assistant-section-title', title); + element.dataset.assistantSection = normalizeAssistantSectionId(title); + container.appendChild(element); +} + function createElement(tag, className, text) { const element = document.createElement(tag); if (className) element.className = className; @@ -229,6 +292,22 @@ async function runAction(url, body = {}) { } } +async function saveOperatorTemplateNote(template) { + 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'], + }); + await refreshDashboard(); + } catch (error) { + showErrorPanel(String(error)); + } +} + function renderHealth(health) { setText( healthStatus, @@ -256,6 +335,743 @@ function renderOverview(weeklyReview, candidates, tickets, executions) { cards.forEach((card) => overviewCards.appendChild(card)); } +function renderDecisionAssistant(summary) { + clearNode(decisionAssistantPanel); + decisionAssistantPanel.classList.add('assistant-dense'); + window.__latestDecisionAssistantSummary = summary; + if (!summary || summary.status === 'unavailable') { + decisionAssistantPanel.appendChild(createElement('div', 'empty-state', 'Decision assistant summary unavailable.')); + return; + } + const evidence = summary.evidence_summary || {}; + const scanSummaryCards = summary.scan_summary_cards || []; + const scanCompletionSummary = summary.scan_completion_summary || null; + const workflowDigest = summary.workflow_digest || []; + const opportunities = summary.top_opportunities || []; + const watchlistTiers = summary.candidate_watchlist_tiers || {}; + const watchlistSummary = summary.candidate_watchlist_summary || {}; + const noTradeItems = summary.no_trade_reason_items || []; + const decisionTimeline = summary.decision_timeline || []; + const explanationDrilldown = summary.explanation_drilldown || []; + const reasonCitations = summary.reason_citations || []; + const learningRollup = summary.learning_rollup || null; + const externalContext = summary.external_context_summary || null; + const reviewEntrypoints = summary.review_entrypoints || null; + const personalizedReminders = summary.personalized_reminders || []; + const digestHistoryPreview = summary.digest_history_preview || []; + const digestHistoryGroups = summary.digest_history_groups || []; + const digestSurface = summary.digest_surface || null; + const setupLearningRollup = summary.setup_learning_rollup || null; + const assistantSections = [ + scanSummaryCards.length ? { title: 'Scan' } : null, + digestSurface ? { title: 'Digest' } : null, + reviewEntrypoints ? { title: 'Review Entry' } : null, + opportunities.length ? { title: 'Opportunities' } : null, + (watchlistTiers.review_now || []).length ? { title: 'Watchlist: Review Now' } : null, + noTradeItems.length ? { title: 'No-Trade Reasons' } : null, + setupLearningRollup ? { title: 'Setup Replay' } : null, + externalContext ? { title: 'External Context' } : null, + decisionTimeline.length ? { title: 'Timeline' } : null, + explanationDrilldown.length ? { title: 'Explain' } : null, + ].filter(Boolean); + if (scanSummaryCards.length) { + appendSectionTitle(decisionAssistantPanel, 'Scan'); + const scanGrid = createElement('div', 'cards assistant-scan-grid'); + scanSummaryCards.forEach((item) => { + const card = buildItemCard( + item.title || 'summary', + item.status || 'n/a', + [ + ['detail', item.detail || 'n/a'], + ['context', item.context || 'n/a'], + ['rank', String(item.rank ?? 'n/a')], + ['scan step', item.is_next_scan_step ? 'next' : String(item.scan_step_index ?? 'n/a')], + ['completion', item.completion_state || 'n/a'], + ['completion hint', item.completion_hint || 'n/a'], + ['next step', item.transition_hint || 'n/a'], + ], + item.target_section + ? [{ + label: item.action_label || scanCardActionLabel(item.target_section), + onClick: () => revealAssistantSection(item.target_section), + }] + : [] + ); + if (item.target_section) { + card.classList.add('clickable-card'); + card.addEventListener('click', () => revealAssistantSection(item.target_section)); + } + if (item.emphasis) { + card.classList.add(`assistant-scan-${item.emphasis}`); + } + if (item.is_next_scan_step) { + card.classList.add('assistant-scan-next'); + } + scanGrid.appendChild(card); + }); + decisionAssistantPanel.appendChild(scanGrid); + if (scanCompletionSummary) { + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['scan completion', scanCompletionSummary.status || 'idle'], + ['open scan steps', String(scanCompletionSummary.open_count ?? 0)], + ['blocked scan steps', String(scanCompletionSummary.blocked_count ?? 0)], + ['refresh-needed steps', String(scanCompletionSummary.refresh_needed_count ?? 0)], + ['ready steps', String(scanCompletionSummary.ready_count ?? 0)], + ['next scan card', scanCompletionSummary.next_title || 'n/a'], + ['next scan target', scanCompletionSummary.next_target_section || 'n/a'], + ['next scan hint', scanCompletionSummary.next_hint || 'n/a'], + ]) + ); + } + } + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['focus mode', summary.focus_mode || 'monitor_and_wait'], + ['headline', summary.headline || 'n/a'], + ['market posture', summary.market_posture || 'n/a'], + ['risk posture', summary.risk_posture || 'n/a'], + ['attention reason', summary.attention_reason || 'n/a'], + ['current session', summary.current_session || 'n/a'], + ['changed since last', summary.changed_since_last?.summary || 'n/a'], + ['decision', `${evidence.decision_status || 'unknown'} / ${evidence.decision_action || 'monitor'}`], + ['trust / mode', `${evidence.trust_classification || 'unknown'} / ${evidence.operator_mode || 'unknown'}`], + ['market truth', `${evidence.market_freshness || 'unknown'} | ${evidence.stream_status || 'unknown'} | ${evidence.data_gap_status || 'unknown'}`], + ['launch mode', evidence.launch_mode || 'unknown'], + ['top opportunity', opportunities[0]?.title || 'n/a'], + ['watchlist tiers', `review ${watchlistSummary.review_now_count ?? 0} | strong ${watchlistSummary.strong_watch_count ?? 0} | soft ${watchlistSummary.soft_watch_count ?? 0}`], + ['section nav', assistantSections.map((item) => item.title).join(' | ') || 'n/a'], + ['no-trade reasons', formatList(summary.no_trade_reasons || [])], + ['next actions', formatList(summary.next_actions || [])], + ]) + ); + if (assistantSections.length) { + decisionAssistantPanel.appendChild( + buildActionBar( + assistantSections.map((item) => ({ + label: item.title, + onClick: () => revealAssistantSection(normalizeAssistantSectionId(item.title)), + })) + ) + ); + } + if (digestSurface) { + appendSectionTitle(decisionAssistantPanel, 'Digest'); + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['digest status', digestSurface.status || 'idle'], + ['digest summary', digestSurface.summary || 'n/a'], + ['reminders', String(digestSurface.reminder_count ?? 0)], + ['high priority reminders', String(digestSurface.high_priority_count ?? 0)], + ['digest history', String(digestSurface.history_count ?? 0)], + ['digest kinds', formatList(digestSurface.history_summary?.kinds || [])], + ['recommended history kind', digestSurface.history_summary?.recommended_kind || 'n/a'], + ['recommended history count', String(digestSurface.history_summary?.recommended_count ?? 0)], + ['fresh history groups', String(digestSurface.history_summary?.fresh_kind_count ?? 0)], + ['stale history groups', String(digestSurface.history_summary?.stale_kind_count ?? 0)], + ['refresh-needed groups', String(digestSurface.history_summary?.refresh_needed_count ?? 0)], + ['refresh prompts', formatList((digestSurface.history_summary?.refresh_prompts || []).map((item) => `${item.kind}: ${item.prompt}`))], + ['history filter', assistantHistoryKindFilter || 'all'], + ['digest latest', formatTimestamp(digestSurface.history_summary?.latest_ts)], + ['digest top opportunity', digestSurface.top_opportunity_title || 'n/a'], + ['learning status', digestSurface.learning_status || 'n/a'], + ]) + ); + } + if (personalizedReminders.length) { + const reminderGrid = createElement('div', 'cards assistant-tight-grid'); + personalizedReminders.forEach((item) => { + reminderGrid.appendChild( + buildItemCard( + item.title || 'reminder', + item.severity || 'info', + [ + ['message', item.message || 'n/a'], + ['source', item.source || 'n/a'], + ] + ) + ); + }); + decisionAssistantPanel.appendChild(reminderGrid); + } + if (digestHistoryGroups.length) { + const recommendedKind = digestSurface?.history_summary?.recommended_kind || 'all'; + const groupKinds = digestHistoryGroups.map((item) => item.kind); + if (assistantHistoryKindFilter !== 'all' && !groupKinds.includes(assistantHistoryKindFilter)) { + persistAssistantHistoryKindFilter(recommendedKind); + } + const historyFilterBar = buildActionBar([ + { + label: 'All History', + onClick: () => { + persistAssistantHistoryKindFilter('all'); + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }, + }, + ...(recommendedKind && recommendedKind !== 'all' + ? [{ + label: `Recommended ${recommendedKind}`, + onClick: () => { + persistAssistantHistoryKindFilter(recommendedKind); + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }, + }] + : []), + ...digestHistoryGroups.map((item) => ({ + label: `${item.kind} (${item.count})${item.recommended ? ' *' : ''}`, + onClick: () => { + persistAssistantHistoryKindFilter(item.kind || 'all'); + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }, + })), + ]); + decisionAssistantPanel.appendChild(historyFilterBar); + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['active history focus', assistantHistoryKindFilter || 'all'], + ['recommended history focus', recommendedKind || 'n/a'], + ]) + ); + const groupGrid = createElement('div', 'cards assistant-history-grid'); + const visibleGroups = assistantHistoryKindFilter === 'all' + ? digestHistoryGroups + : digestHistoryGroups.filter((item) => String(item.kind || '') === assistantHistoryKindFilter); + visibleGroups.forEach((item) => { + const card = buildItemCard( + item.recommended ? `Recommended ${item.kind || 'history'}` : `History: ${item.kind || 'history'}`, + item.group_status || String(item.count ?? 0), + [ + ['headline', item.headline || 'n/a'], + ['latest', formatTimestamp(item.latest_ts)], + ['latest item', item.latest_title || 'n/a'], + ['age band', item.age_band || 'n/a'], + ['staleness', item.staleness_hint || 'n/a'], + ['refresh', item.refresh_prompt || 'n/a'], + ['count', String(item.count ?? 0)], + ['items', formatList((item.items || []).map((row) => row.title || row.kind || 'item'))], + ], + [{ + label: item.refresh_action_label || item.action_label || 'Review Group', + onClick: () => { + persistAssistantHistoryKindFilter(item.kind || 'all'); + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }, + }] + ); + if (item.recommended) { + card.classList.add('assistant-history-recommended'); + } + groupGrid.appendChild(card); + }); + if (visibleGroups.length) { + decisionAssistantPanel.appendChild(groupGrid); + } else { + decisionAssistantPanel.appendChild(createElement('div', 'empty-state', 'No history items match the current digest filter.')); + } + } + if (reviewEntrypoints) { + appendSectionTitle(decisionAssistantPanel, 'Review Entry'); + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['review entrypoints', reviewEntrypoints.status || 'idle'], + ['notes', String(reviewEntrypoints.note_count ?? 0)], + ['latest note author', reviewEntrypoints.latest_note_author || 'n/a'], + ['latest note time', formatTimestamp(reviewEntrypoints.latest_note_ts)], + ['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'], + ['recommended template', reviewEntrypoints.recommended_template_id || 'n/a'], + ['recommended label', reviewEntrypoints.template_summary?.recommended_label || 'n/a'], + ['recommended priority', reviewEntrypoints.template_summary?.recommended_priority || 'n/a'], + ['recommended reason', reviewEntrypoints.template_summary?.recommended_reason || 'n/a'], + ['recommended reuse hint', reviewEntrypoints.template_summary?.recommended_reuse_hint || 'n/a'], + ['template reuse status', reviewEntrypoints.template_reuse_metrics?.reuse_status || 'n/a'], + ['template notes saved', String(reviewEntrypoints.template_reuse_metrics?.assistant_template_note_count ?? 0)], + ['recommended reuse count', String(reviewEntrypoints.template_reuse_metrics?.recommended_reuse_count ?? 0)], + ['recommended last used', formatTimestamp(reviewEntrypoints.template_reuse_metrics?.recommended_last_used_ts)], + ['latest template note', formatTimestamp(reviewEntrypoints.template_reuse_metrics?.latest_template_note_ts)], + ['template priorities', formatJsonInline(reviewEntrypoints.template_summary?.priorities || {})], + ]) + ); + decisionAssistantPanel.appendChild( + buildActionBar([ + { + label: 'Open Notes', + onClick: () => prefillOperatorNote(''), + }, + { + label: 'Open Review', + onClick: () => revealPanel(postTradeReviewPanel), + }, + { + label: 'Generate Journal', + onClick: () => runAction('/trading/journal/generate', {}), + }, + ]) + ); + const noteTemplates = reviewEntrypoints.note_templates || []; + if (noteTemplates.length) { + const recommendedTemplate = noteTemplates.find((item) => item.template_id === reviewEntrypoints.recommended_template_id) || noteTemplates[0]; + const templateGrid = createElement('div', 'cards assistant-tight-grid'); + noteTemplates.forEach((item) => { + const isRecommended = item.template_id === reviewEntrypoints.recommended_template_id; + templateGrid.appendChild( + buildItemCard( + isRecommended ? `Recommended: ${item.label || 'Template'}` : (item.label || 'Template'), + item.priority || 'medium', + [ + ['reason', item.reason || 'n/a'], + ['scope', item.note_scope || 'workbench'], + ['tags', formatList(item.tags || [])], + ['context', formatList(item.context_items || [])], + ['carry-over', formatList(item.carry_over_context || [])], + ['reuse count', String(item.reuse_count ?? 0)], + ['reuse status', item.reuse_status || 'n/a'], + ['last used', formatTimestamp(item.last_used_ts)], + ], + [ + { + label: 'Use', + onClick: () => prefillOperatorNote(item.body || ''), + }, + { + label: 'Save', + onClick: () => saveOperatorTemplateNote(item), + }, + ], + [item.body || 'n/a', item.follow_up_prompt || '', item.reuse_hint || ''].filter(Boolean).join(' | '), + ) + ); + }); + decisionAssistantPanel.appendChild(templateGrid); + decisionAssistantPanel.appendChild( + buildActionBar([ + { + label: 'Use Recommended', + onClick: () => prefillOperatorNote(recommendedTemplate?.body || ''), + }, + { + label: 'Save Recommended', + onClick: () => saveOperatorTemplateNote(recommendedTemplate), + }, + ]) + ); + const templateBar = buildActionBar( + noteTemplates.map((item) => ({ + label: item.template_id === reviewEntrypoints.recommended_template_id + ? `${item.label || 'Use Template'}` + : (item.label || 'Use Template'), + onClick: () => prefillOperatorNote(item.body || ''), + })) + ); + decisionAssistantPanel.appendChild(templateBar); + const quickSaveBar = buildActionBar( + noteTemplates.map((item) => ({ + label: `Save ${item.label || 'Template'}`, + onClick: () => saveOperatorTemplateNote(item), + })) + ); + decisionAssistantPanel.appendChild(quickSaveBar); + } + } + if (workflowDigest.length) { + appendSectionTitle(decisionAssistantPanel, 'Workflow'); + const workflowGrid = createElement('div', 'cards'); + workflowDigest.forEach((item) => { + workflowGrid.appendChild( + buildItemCard( + item.title || item.phase || 'workflow', + item.status || 'n/a', + [ + ['detail', item.detail || 'n/a'], + ['next step', item.next_step || 'n/a'], + ] + ) + ); + }); + decisionAssistantPanel.appendChild(workflowGrid); + } + if (opportunities.length) { + appendSectionTitle(decisionAssistantPanel, 'Opportunities'); + const opportunityGrid = createElement('div', 'cards'); + opportunities.forEach((item) => { + opportunityGrid.appendChild( + buildItemCard( + item.title || 'opportunity', + item.status || 'observe', + [ + ['kind', item.kind || 'n/a'], + ['detail', item.detail || 'n/a'], + ['priority', String(item.priority_score ?? 'n/a')], + ['reason', item.reason || 'n/a'], + ] + ) + ); + }); + decisionAssistantPanel.appendChild(opportunityGrid); + } + [ + ['review_now', 'Review Now'], + ['strong_watch', 'Strong Watch'], + ['soft_watch', 'Soft Watch'], + ['archive_only', 'Archive Only'], + ].forEach(([key, label]) => { + const items = watchlistTiers[key] || []; + if (!items.length) return; + appendSectionTitle(decisionAssistantPanel, `Watchlist: ${label}`); + const tierGrid = createElement('div', 'cards assistant-tight-grid'); + items.forEach((item) => { + tierGrid.appendChild( + buildItemCard( + `${label}: ${item.title || 'candidate'}`, + item.status || key, + [ + ['session', item.session_code || 'n/a'], + ['model', item.model_code || 'n/a'], + ['quality', String(item.quality_score ?? 'n/a')], + ['tier reason', item.tier_reason || 'n/a'], + ['suggested action', item.suggested_action || 'n/a'], + ['detail', item.detail || 'n/a'], + ['missing confirmations', formatList(item.missing_confirmations || [])], + ], + item.action_path && item.action_method === 'POST' + ? [{ + label: 'Run Confirm Check', + onClick: () => runAction(item.action_path, {}), + }] + : [] + ) + ); + }); + decisionAssistantPanel.appendChild(tierGrid); + }); + if (noTradeItems.length) { + appendSectionTitle(decisionAssistantPanel, 'No-Trade Reasons'); + const noTradeGrid = createElement('div', 'cards assistant-tight-grid'); + noTradeItems.forEach((item) => { + noTradeGrid.appendChild( + buildItemCard( + item.reason || 'no-trade', + item.priority || 'low', + [ + ['category', item.category || 'n/a'], + ['source', item.source || 'n/a'], + ] + ) + ); + }); + decisionAssistantPanel.appendChild(noTradeGrid); + } + if (decisionTimeline.length) { + appendSectionTitle(decisionAssistantPanel, 'Timeline'); + const timelineGrid = createElement('div', 'cards'); + decisionTimeline.forEach((item) => { + timelineGrid.appendChild( + buildItemCard( + item.title || 'timeline', + item.ts || 'n/a', + [ + ['detail', item.detail || 'n/a'], + ] + ) + ); + }); + decisionAssistantPanel.appendChild(timelineGrid); + } + if (explanationDrilldown.length) { + appendSectionTitle(decisionAssistantPanel, 'Explain'); + const drilldownGrid = createElement('div', 'cards'); + explanationDrilldown.forEach((item) => { + drilldownGrid.appendChild( + buildItemCard( + item.title || 'explanation', + item.status || 'n/a', + [ + ['detail', item.detail || 'n/a'], + ['reasons', formatList(item.reasons || [])], + ] + ) + ); + }); + decisionAssistantPanel.appendChild(drilldownGrid); + } + if (reasonCitations.length) { + appendSectionTitle(decisionAssistantPanel, 'Reason Citations'); + const citationGrid = createElement('div', 'cards'); + reasonCitations.forEach((item) => { + citationGrid.appendChild( + buildItemCard( + item.reason || 'citation', + item.severity || 'supporting', + [ + ['source', item.source_type || 'n/a'], + ['path', item.source_path || 'n/a'], + ['detail', item.detail || 'n/a'], + ] + ) + ); + }); + decisionAssistantPanel.appendChild(citationGrid); + } + if (learningRollup) { + appendSectionTitle(decisionAssistantPanel, 'Learning'); + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['learning status', learningRollup.status || 'idle'], + ['review entries', String(learningRollup.entry_count ?? 0)], + ['pending feedback', String(learningRollup.pending_feedback_count ?? 0)], + ['visual mismatches', String(learningRollup.visual_mismatch_count ?? 0)], + ['high-value alerts', String(learningRollup.high_value_alert_count ?? 0)], + ['quality flags', formatList(learningRollup.quality_flags || [])], + ['next review actions', formatList(learningRollup.next_week_actions || [])], + ['learning summary', learningRollup.operator_summary || 'n/a'], + ]) + ); + } + if (externalContext) { + appendSectionTitle(decisionAssistantPanel, 'External Context'); + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['external context', externalContext.status || 'idle'], + ['external headline', externalContext.headline || 'n/a'], + ['news provider', `${externalContext.provider_name || 'unknown'} / ${externalContext.freshness_code || 'unknown'}`], + ['news window', externalContext.news_window_code || 'unknown'], + ['active events', formatList(externalContext.active_event_titles || [])], + ['exchange status', `${externalContext.exchange_status?.provider_name || 'unknown'} / ${externalContext.exchange_status?.status || 'unknown'}`], + ['exchange events', formatList(externalContext.exchange_status_titles || [])], + ['announcements', `${externalContext.announcements_snapshot?.provider_name || 'unknown'} / ${externalContext.announcements_snapshot?.status || 'unknown'}`], + ['announcement titles', formatList(externalContext.announcement_titles || [])], + ['waiting items', formatList(externalContext.external_waiting_items || [])], + ['council watch items', formatList(externalContext.council_watch_items || [])], + ['operator questions', formatList(externalContext.operator_questions || [])], + ['next confirmation targets', formatList(externalContext.next_confirmation_targets || [])], + ]) + ); + const sourceCards = externalContext.source_cards || []; + if (sourceCards.length) { + const sourceGrid = createElement('div', 'cards'); + sourceCards.forEach((item) => { + sourceGrid.appendChild( + buildItemCard( + item.title || item.source_type || 'context source', + item.source_type || 'source', + [ + ['detail', item.detail || 'n/a'], + ] + ) + ); + }); + decisionAssistantPanel.appendChild(sourceGrid); + } + } + if (setupLearningRollup) { + appendSectionTitle(decisionAssistantPanel, 'Setup Replay'); + 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'], + ['lesson count', String(setupLearningRollup.lesson_count ?? 0)], + ['replay samples', String(setupLearningRollup.replay_statistics?.sample_count ?? 0)], + ['framework rate', formatPercent(setupLearningRollup.replay_statistics?.framework_execution_rate)], + ['model outside rate', formatPercent(setupLearningRollup.replay_statistics?.model_outside_rate)], + ['avg R', formatNumber(setupLearningRollup.replay_statistics?.avg_r_multiple, 2)], + ['risk violations', String(setupLearningRollup.replay_statistics?.risk_violation_count ?? 0)], + ['framework gap vs high', formatPercent(setupLearningRollup.replay_statistics?.comparative_summary?.framework_execution_gap_vs_high)], + ['avg R gap vs high', formatNumber(setupLearningRollup.replay_statistics?.comparative_summary?.avg_r_gap_vs_high, 2)], + ['framework gap vs low', formatPercent(setupLearningRollup.replay_statistics?.comparative_summary?.framework_execution_gap_vs_low)], + ['avg R gap vs low', formatNumber(setupLearningRollup.replay_statistics?.comparative_summary?.avg_r_gap_vs_low, 2)], + ['comparison status', setupLearningRollup.replay_statistics?.comparative_summary?.comparison_status || 'n/a'], + ['confidence band', setupLearningRollup.replay_statistics?.comparative_summary?.confidence_band || 'n/a'], + ['session alignment', setupLearningRollup.replay_statistics?.comparative_summary?.session_alignment || 'n/a'], + ['comparison notes', formatList(setupLearningRollup.replay_statistics?.comparative_summary?.notes || [])], + ['comparison story', setupLearningRollup.replay_statistics?.comparative_summary?.story || 'n/a'], + ['global framework avg', formatPercent(setupLearningRollup.replay_statistics?.broader_baseline_summary?.framework_execution_global_avg)], + ['global avg R', formatNumber(setupLearningRollup.replay_statistics?.broader_baseline_summary?.avg_r_global_avg, 2)], + ['global risk avg', formatNumber(setupLearningRollup.replay_statistics?.broader_baseline_summary?.risk_violation_global_avg, 2)], + ['framework rank', String(setupLearningRollup.replay_statistics?.broader_baseline_summary?.framework_rank ?? 'n/a')], + ['framework percentile', formatPercent(setupLearningRollup.replay_statistics?.broader_baseline_summary?.framework_percentile)], + ['avg R rank', String(setupLearningRollup.replay_statistics?.broader_baseline_summary?.avg_r_rank ?? 'n/a')], + ['avg R percentile', formatPercent(setupLearningRollup.replay_statistics?.broader_baseline_summary?.avg_r_percentile)], + ['risk rank', String(setupLearningRollup.replay_statistics?.broader_baseline_summary?.risk_rank ?? 'n/a')], + ['risk percentile', formatPercent(setupLearningRollup.replay_statistics?.broader_baseline_summary?.risk_percentile)], + ['composite percentile', formatPercent(setupLearningRollup.replay_statistics?.broader_baseline_summary?.composite_percentile)], + ['risk band', setupLearningRollup.replay_statistics?.broader_baseline_summary?.risk_band || 'n/a'], + ['composite band', setupLearningRollup.replay_statistics?.broader_baseline_summary?.composite_band || 'n/a'], + ['composite rank', String(setupLearningRollup.replay_statistics?.broader_baseline_summary?.composite_rank ?? 'n/a')], + ['position band', setupLearningRollup.replay_statistics?.broader_baseline_summary?.position_band || 'n/a'], + ['replay verdict', setupLearningRollup.replay_statistics?.broader_baseline_summary?.replay_verdict || 'n/a'], + ['peer summary', setupLearningRollup.replay_statistics?.broader_baseline_summary?.peer_summary || 'n/a'], + ['setup family', setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family || 'n/a'], + ['setup family status', setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_status || 'n/a'], + ['setup family peers', setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_peer_summary || 'n/a'], + ['setup family focus', setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_focus || 'n/a'], + ['broader story', setupLearningRollup.replay_statistics?.broader_baseline_summary?.story || 'n/a'], + ['high-quality session', setupLearningRollup.high_quality_session?.session_code || 'n/a'], + ['low-quality session', setupLearningRollup.low_quality_session?.session_code || 'n/a'], + ]) + ); + 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 || []; + const broadTopComposite = setupLearningRollup.replay_statistics?.broader_baseline_summary?.top_composite_sessions || []; + const peerSessions = setupLearningRollup.replay_statistics?.broader_baseline_summary?.peer_sessions || []; + const setupFamilyPeers = setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_peer_sessions || []; + if (broadTopSessions.length || broadTopRSessions.length || broadTopSafe.length || broadTopComposite.length || peerSessions.length || setupFamilyPeers.length) { + const leaderboardGrid = createElement('div', 'cards assistant-tight-grid'); + broadTopSessions.forEach((item) => { + leaderboardGrid.appendChild( + buildItemCard( + `Top Framework: ${item.session_code || 'session'}`, + formatPercent(item.metric), + [ + ['samples', String(item.count ?? 0)], + ] + ) + ); + }); + broadTopRSessions.forEach((item) => { + leaderboardGrid.appendChild( + buildItemCard( + `Top Avg R: ${item.session_code || 'session'}`, + formatNumber(item.metric, 2), + [ + ['samples', String(item.count ?? 0)], + ] + ) + ); + }); + broadTopSafe.forEach((item) => { + leaderboardGrid.appendChild( + buildItemCard( + `Safest: ${item.session_code || 'session'}`, + formatNumber(item.metric, 0), + [ + ['samples', String(item.count ?? 0)], + ] + ) + ); + }); + peerSessions.forEach((item) => { + leaderboardGrid.appendChild( + buildItemCard( + `Peer ${String(item.relative_position || 'nearby')}: ${item.session_code || 'session'}`, + `rank ${String(item.rank ?? 'n/a')}`, + [ + ['framework', formatPercent(item.framework_execution_rate)], + ['avg R', formatNumber(item.avg_r_multiple, 2)], + ['risk violations', String(item.risk_violation_count ?? 0)], + ['samples', String(item.count ?? 0)], + ] + ) + ); + }); + setupFamilyPeers.forEach((item) => { + leaderboardGrid.appendChild( + buildItemCard( + `Family Peer: ${item.session_code || 'session'}`, + item.setup_family || 'setup family', + [ + ['relative', item.relative_position || 'n/a'], + ['rank', String(item.rank ?? 'n/a')], + ['framework', formatPercent(item.framework_execution_rate)], + ['avg R', formatNumber(item.avg_r_multiple, 2)], + ['risk violations', String(item.risk_violation_count ?? 0)], + ['samples', String(item.count ?? 0)], + ] + ) + ); + }); + broadTopComposite.forEach((item) => { + leaderboardGrid.appendChild( + buildItemCard( + `Top Composite: ${item.session_code || 'session'}`, + `${formatPercent(item.framework_execution_rate)} | ${formatNumber(item.avg_r_multiple, 2)}`, + [ + ['risk violations', String(item.risk_violation_count ?? 0)], + ] + ) + ); + }); + decisionAssistantPanel.appendChild(leaderboardGrid); + } + const lessonItems = setupLearningRollup.top_lessons || []; + if (lessonItems.length) { + const lessonGrid = createElement('div', 'cards'); + lessonItems.forEach((item) => { + lessonGrid.appendChild( + buildItemCard( + item.title || 'lesson', + item.lesson_type || 'lesson', + [ + ['summary', item.summary || 'n/a'], + ] + ) + ); + }); + decisionAssistantPanel.appendChild(lessonGrid); + } + } + if (digestHistoryPreview.length) { + const filteredHistory = assistantHistoryKindFilter === 'all' + ? digestHistoryPreview + : digestHistoryPreview.filter((item) => String(item.kind || '') === assistantHistoryKindFilter); + const historyGrid = createElement('div', 'cards assistant-history-grid'); + filteredHistory.forEach((item) => { + historyGrid.appendChild( + buildItemCard( + item.title || item.kind || 'history', + item.kind || 'history', + [ + ['detail', item.detail || 'n/a'], + ['time', formatTimestamp(item.ts)], + ] + ) + ); + }); + decisionAssistantPanel.appendChild(historyGrid); + } + const items = summary.top_priority_items || []; + const evidenceChain = summary.evidence_chain || []; + if (!items.length) { + decisionAssistantPanel.appendChild(createElement('div', 'empty-state', 'No priority items yet. Monitor and wait.')); + } else { + const grid = createElement('div', 'cards'); + items.forEach((item) => { + grid.appendChild( + buildItemCard( + item.title || item.kind || 'priority', + item.status || 'observe', + [ + ['kind', item.kind || 'n/a'], + ['detail', item.detail || 'n/a'], + ['reason', item.reason || 'n/a'], + ] + ) + ); + }); + decisionAssistantPanel.appendChild(grid); + } + if (!evidenceChain.length) { + return; + } + const evidenceGrid = createElement('div', 'cards'); + evidenceChain.forEach((item) => { + evidenceGrid.appendChild( + buildItemCard( + item.title || 'evidence', + item.status || 'n/a', + [ + ['detail', item.detail || 'n/a'], + ['reason', item.reason || 'n/a'], + ] + ) + ); + }); + decisionAssistantPanel.appendChild(evidenceGrid); +} + function renderMarketState(payload, executions) { clearNode(marketStatePanel); const marketState = payload?.market_state || {}; @@ -1364,6 +2180,7 @@ function renderDashboardData({ window.__latestTrustScorePayload = trustScore; renderHealth(health); renderOverview(weeklyReview, tradeCandidates.items || [], executionTickets.items || [], tradingExecutions.items || []); + renderDecisionAssistant(reviewPacket?.decision_assistant_summary || null); renderMarketState(marketState, tradingExecutions.items || []); renderRuntimeStatus(runtimeStatus, runtimeIncidents, maintenanceSummary); renderDecisionPacket(decisionPacket, decisionAlerts); diff --git a/src/web/index.html b/src/web/index.html index 756a285..92ae4a9 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -72,6 +72,11 @@
+
+

Decision Assistant

+
+
+

Trading Status

diff --git a/src/web/styles.css b/src/web/styles.css index 737525a..7ccbffa 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -188,6 +188,68 @@ th { color: #9db0d3; } font-size: 12px; line-height: 1.5; } +.assistant-section-title { + margin: 16px 0 10px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #8fb0ff; +} +.assistant-dense .info-grid { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 7px 9px; +} +.assistant-dense .info-row { + padding: 8px 9px; + border: 1px solid #233154; + border-radius: 10px; + background: #0c132b; +} +.assistant-dense .cards { + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 9px; +} +.assistant-scan-grid { + grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); +} +.assistant-scan-primary { + border-color: #4f7cff; + background: linear-gradient(180deg, rgba(79, 124, 255, 0.18), rgba(15, 21, 48, 0.95)); +} +.assistant-scan-warning { + border-color: rgba(239, 68, 68, 0.45); + background: linear-gradient(180deg, rgba(239, 68, 68, 0.14), rgba(15, 21, 48, 0.95)); +} +.assistant-scan-action { + border-color: rgba(56, 189, 248, 0.4); +} +.assistant-scan-context { + border-color: rgba(148, 163, 184, 0.35); +} +.assistant-scan-summary { + border-color: rgba(250, 204, 21, 0.35); +} +.assistant-scan-next { + box-shadow: inset 0 0 0 1px rgba(56, 189, 248, 0.45), 0 0 0 1px rgba(56, 189, 248, 0.18); +} +.assistant-tight-grid { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); +} +.assistant-history-grid { + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); +} +.assistant-history-recommended { + border-color: #4f7cff; + box-shadow: inset 0 0 0 1px rgba(79, 124, 255, 0.25); +} +.assistant-dense .card { + padding: 11px; +} +.assistant-dense .actions { + margin-top: 0; + margin-bottom: 5px; +} .clickable-row { cursor: pointer; } diff --git a/tests/unit/test_api_static_server.py b/tests/unit/test_api_static_server.py index 48413d9..07319ec 100644 --- a/tests/unit/test_api_static_server.py +++ b/tests/unit/test_api_static_server.py @@ -78,6 +78,7 @@ class ApiStaticServerTests(unittest.TestCase): app_js = app_file.read_text(encoding="utf-8") for expected in ( + "Decision Assistant", "Trading Status", "Pre-Market Plan", "Intraday Candidates", diff --git a/tests/unit/test_documentation_consistency.py b/tests/unit/test_documentation_consistency.py index 320ae75..a664d52 100644 --- a/tests/unit/test_documentation_consistency.py +++ b/tests/unit/test_documentation_consistency.py @@ -17,6 +17,13 @@ TRADING_APP_JS = ROOT / "src" / "web" / "app.js" 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", +} OPTIONAL_DORMANT_DOCS = { "AI_ICT_TradingAgents_P0自动任务清单_v1.md", "AI_ICT_TradingAgents_P1自动任务清单_v1.md", @@ -26,18 +33,14 @@ OPTIONAL_DORMANT_DOCS = { "AI_ICT_TradingAgents_P5自动任务清单_v1.md", "AI_ICT_TradingAgents_P6自动任务清单_v1.md", "AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md", - "AI_ICT_TradingAgents融合实施方案_v1.md", "AI_ICT_TradingAgents融合实施方案_最终版.md", "AI_ICT_盯盘告警开发任务清单_v1.md", "AI_ICT_盯盘告警技术设计稿_v1.md", "AI_ICT_盯盘告警架构图_v1.md", - "AI_ICT_下一阶段开发计划_v1.md", - "AI_ICT_下一阶段开发任务清单_v1.md", "AI_ICT_自动化流程使用教程_v1.md", "AI_ICT_外部repair_agent接入说明_v1.md", "AI_ICT_repair_bridge_protocol_v1.md", "AI_ICT_research_council脚手架与数据表草案_v1.md", - "AI_ICT_方案一系统边界图与模块融合图_v1.md", "AI_ICT_自动化执行层_P7监控成熟度与晋级路由_v1.md", "AI_ICT_自动化执行层_P15-PXX闭环任务清单_v1.md", } @@ -54,6 +57,7 @@ class DocumentationConsistencyTests(unittest.TestCase): top_level_docs, { "AI_ICT_全自动开发执行流程_v1.md", + "AI_ICT_实盘交易分析决策助手下一阶段开发方案_v1.md", "AI_ICT_实盘交易分析系统开发计划_v1.md", "AI_ICT_实盘辅助决策系统开发方案_v2.md", "ICT交易全书_出版终校版.md", @@ -61,6 +65,12 @@ class DocumentationConsistencyTests(unittest.TestCase): } | OPTIONAL_DORMANT_DOCS, ) self.assertTrue(TRAINING_PACKAGE_SMOKE_DOC.is_file()) + archive_docs = { + path.name + for path in (ROOT / "docs" / "archive").iterdir() + if path.is_file() + } + self.assertTrue(ARCHIVED_DORMANT_DOCS <= archive_docs) self.assertFalse(OLD_TRAINING_DOC.exists()) def test_readme_and_runbook_use_current_training_entry_and_not_old_doc(self) -> None: @@ -71,6 +81,21 @@ class DocumentationConsistencyTests(unittest.TestCase): self.assertNotIn("training_case_engine_m1_m6_product_readme.md", text) self.assertIn("Training Console at `/training`", README.read_text(encoding="utf-8")) + def test_repo_no_longer_references_nonexistent_visual_plan_doc(self) -> None: + search_roots = [ + ROOT / "docs", + ROOT / "scripts", + ROOT / "tests", + ] + allowed_suffixes = {".md", ".py", ".html", ".js", ".json"} + for root in search_roots: + for path in root.rglob("*"): + if not path.is_file() or path.suffix not in allowed_suffixes: + continue + text = path.read_text(encoding="utf-8") + with self.subTest(path=str(path.relative_to(ROOT))): + self.assertNotIn(NONEXISTENT_VISUAL_PLAN_NAME, text) + def test_training_public_doc_matches_current_console_and_routes(self) -> None: text = TRAINING_PUBLIC_DOC.read_text(encoding="utf-8") for snippet in ( diff --git a/tests/unit/test_operator_workspace_service.py b/tests/unit/test_operator_workspace_service.py index e9d70e8..b003191 100644 --- a/tests/unit/test_operator_workspace_service.py +++ b/tests/unit/test_operator_workspace_service.py @@ -4,7 +4,7 @@ from unittest.mock import patch os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") -from src.services.operator import OperatorNoteService, ReviewPacketService, SessionFocusService +from src.services.operator import DecisionAssistantSummaryService, OperatorNoteService, ReviewPacketService, SessionFocusService class OperatorWorkspaceServiceTests(unittest.TestCase): @@ -50,6 +50,7 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): alert_quality_summary_service=_AlertQualitySummaryService(), research_council_service=_ResearchCouncilService(), optimization_review_service=_OptimizationReviewService(), + decision_assistant_service=_DecisionAssistantService(), ) packet = service.build_packet(instrument_id=1) @@ -61,6 +62,7 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("trust_score", packet) self.assertIn("research_council", packet) self.assertIn("research_council_optimization_review", packet) + self.assertIn("decision_assistant_summary", packet) self.assertEqual(packet["research_council"]["id"], 31) self.assertEqual(packet["research_council_optimization_review"]["health_state"], "healthy") @@ -75,6 +77,7 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): alert_quality_summary_service=_AlertQualitySummaryService(), research_council_service=_EmptyResearchCouncilService(), optimization_review_service=_OptimizationReviewService(), + decision_assistant_service=_DecisionAssistantService(), ) packet = service.build_packet(instrument_id=1) @@ -85,6 +88,174 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("trust_score", packet) self.assertIsNone(packet["research_council"]) + def test_decision_assistant_summary_prioritizes_current_setup(self) -> None: + service = DecisionAssistantSummaryService( + launch_readiness_service=_LaunchReadinessService(), + session_focus_service=_SessionFocusSummaryService(), + operator_digest_service=_OperatorDigestSummaryService(), + decision_repository=_PreviousPacketRepo(), + candidate_repository=_CandidateWatchlistRepo(), + research_council_service=_ResearchCouncilSummaryService(), + exchange_status_provider=_ExchangeStatusProvider(), + announcements_provider=_AnnouncementsProvider(), + ) + + summary = service.build_summary( + instrument_id=1, + timeframe="5m", + decision_packet={ + "status": "actionable", + "action_recommendation": "confirm_ticket", + "candidate_id": 18, + "packet_ts": "2026-05-12T02:00:00+00:00", + "ai_explanation": "London sweep is confirmed and risk stays bounded.", + "narrative": "London draw into discount then MSS reclaim.", + "bias": "bullish", + "setup_type": "SWEEP_MSS_FVG", + "why_allowed": ["candidate_confirmed", "risk_governor_passed"], + "why_blocked": ["trust_soft_block"], + "trust_classification": "observe", + "operator_mode": "decision_support_only", + "research_council": { + "follow_up_checks": ["watch_london_open"], + "recommended_watch_items": ["news_window_review"], + "operator_questions": ["Should London wait for CPI to clear?"], + "next_confirmation_targets": ["wait_for_mss_reclaim"], + }, + }, + weekly_review={ + "next_week_actions": ["review_london_session_playbook"], + "session_performance": { + "LONDON": { + "count": 5, + "framework_execution_rate": 0.8, + "model_outside_rate": 0.1, + "risk_violation_count": 1, + "avg_r_multiple": 1.4, + }, + "NY_AM": { + "count": 4, + "framework_execution_rate": 0.5, + "model_outside_rate": 0.3, + "risk_violation_count": 2, + "avg_r_multiple": 0.4, + }, + }, + "high_quality_session": {"session_code": "LONDON", "framework_execution_rate": 0.8, "avg_r_multiple": 1.4}, + "low_quality_session": {"session_code": "NY_AM", "framework_execution_rate": 0.5, "avg_r_multiple": 0.4}, + }, + recent_alerts=[{"created_ts": "2026-05-12T01:59:00+00:00", "alert_type": "candidate_actionable"}], + recent_notes=[ + { + "created_ts": "2026-05-12T01:58:00+00:00", + "author": "assistant_template", + "body": "[top-opportunity] Candidate #18 | prior handoff", + "tags": ["assistant-template"], + } + ], + recent_executions=[{"updated_ts": "2026-05-12T01:57:00+00:00", "execution_status": "open"}], + ) + + self.assertEqual(summary["focus_mode"], "review_top_setup") + self.assertIn("Candidate #18", summary["headline"]) + self.assertEqual(summary["top_priority_items"][0]["kind"], "decision_packet") + self.assertEqual(summary["top_opportunities"][0]["kind"], "decision_packet") + self.assertEqual(summary["candidate_watchlist_tiers"]["review_now"][0]["candidate_id"], 18) + self.assertEqual(summary["candidate_watchlist_tiers"]["strong_watch"][0]["candidate_id"], 21) + self.assertEqual(summary["candidate_watchlist_tiers"]["soft_watch"][0]["candidate_id"], 22) + self.assertEqual(summary["candidate_watchlist_tiers"]["review_now"][0]["action_method"], "POST") + self.assertIn("/trading/candidates/18/confirm-check", summary["candidate_watchlist_tiers"]["review_now"][0]["action_path"]) + self.assertEqual(summary["workflow_digest"][0]["phase"], "pre_market") + self.assertEqual(summary["workflow_digest"][1]["phase"], "intraday") + self.assertEqual(summary["workflow_digest"][2]["phase"], "post_trade") + 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.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") + self.assertEqual(summary["external_context_summary"]["status"], "active") + self.assertEqual(summary["review_entrypoints"]["status"], "active") + self.assertEqual(summary["review_entrypoints"]["actions"][0]["action_id"], "open_operator_notes") + self.assertTrue(summary["review_entrypoints"]["note_templates"]) + self.assertEqual(summary["review_entrypoints"]["note_templates"][0]["note_scope"], "workbench") + self.assertTrue(summary["review_entrypoints"]["note_templates"][0]["tags"]) + self.assertEqual(summary["review_entrypoints"]["recommended_template_id"], "top_opportunity") + self.assertEqual(summary["review_entrypoints"]["template_summary"]["recommended_label"], "Top setup note") + self.assertEqual(summary["review_entrypoints"]["template_summary"]["recommended_reason"], "best_current_opportunity") + self.assertTrue(summary["review_entrypoints"]["template_summary"]["recommended_reuse_hint"]) + self.assertEqual(summary["review_entrypoints"]["template_reuse_metrics"]["reuse_status"], "recommended_reused") + self.assertEqual(summary["review_entrypoints"]["template_reuse_metrics"]["recommended_reuse_count"], 1) + self.assertEqual(summary["review_entrypoints"]["note_templates"][0]["reuse_count"], 1) + self.assertEqual(summary["review_entrypoints"]["note_templates"][0]["reuse_status"], "saved_reuse") + self.assertTrue(any(item["template_id"] == "next_review" for item in summary["review_entrypoints"]["note_templates"])) + self.assertTrue(any(item["template_id"] == "queue_follow_up" for item in summary["review_entrypoints"]["note_templates"])) + self.assertTrue(any(item["reuse_hint"] for item in summary["review_entrypoints"]["note_templates"])) + self.assertEqual(summary["digest_surface"]["status"], "active") + self.assertIn("history_summary", summary["digest_surface"]) + self.assertIn("recommended_kind", summary["digest_surface"]["history_summary"]) + self.assertIn("stale_kind_count", summary["digest_surface"]["history_summary"]) + self.assertIn("refresh_needed_count", summary["digest_surface"]["history_summary"]) + self.assertIn("refresh_prompts", summary["digest_surface"]["history_summary"]) + self.assertIn("digest_history_groups", summary) + self.assertIn("group_status", summary["digest_history_groups"][0]) + self.assertIn("headline", summary["digest_history_groups"][0]) + self.assertIn("action_label", summary["digest_history_groups"][0]) + self.assertIn("age_band", summary["digest_history_groups"][0]) + self.assertIn("staleness_hint", summary["digest_history_groups"][0]) + self.assertIn("refresh_prompt", summary["digest_history_groups"][0]) + self.assertIn("refresh_action_label", summary["digest_history_groups"][0]) + self.assertTrue(summary["digest_history_preview"]) + self.assertEqual(summary["digest_history_preview"][0]["kind"], "reminder") + self.assertEqual(summary["personalized_reminders"][0]["severity"], "high") + self.assertEqual(summary["setup_learning_rollup"]["status"], "active") + 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"], "market_session_fallback") + self.assertIn("replay_statistics", summary["setup_learning_rollup"]) + self.assertIn("sample_count", summary["setup_learning_rollup"]["replay_statistics"]) + self.assertIn("comparative_summary", summary["setup_learning_rollup"]["replay_statistics"]) + self.assertIn("broader_baseline_summary", summary["setup_learning_rollup"]["replay_statistics"]) + self.assertIn("notes", summary["setup_learning_rollup"]["replay_statistics"]["comparative_summary"]) + self.assertIn("story", summary["setup_learning_rollup"]["replay_statistics"]["comparative_summary"]) + self.assertIn("story", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]) + self.assertIn("framework_percentile", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]) + self.assertIn("composite_percentile", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]) + self.assertIn("composite_band", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]) + self.assertIn("composite_rank", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]) + self.assertIn("risk_rank", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]) + self.assertIn("risk_percentile", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]) + self.assertIn("replay_verdict", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]) + self.assertIn("peer_summary", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]) + self.assertEqual(summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family"], "LIQUIDITY_REVERSAL_FAMILY") + self.assertIn("setup_family_peer_summary", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]) + self.assertTrue(summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_peer_sessions"]) + self.assertTrue(summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["top_sessions"]) + self.assertTrue(summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["top_safe_sessions"]) + self.assertTrue(summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["top_composite_sessions"]) + self.assertTrue(summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["peer_sessions"]) + self.assertTrue(summary["scan_summary_cards"]) + self.assertEqual(summary["scan_summary_cards"][0]["action_label"], "Open Setup") + self.assertEqual(summary["scan_summary_cards"][0]["emphasis"], "primary") + self.assertTrue(summary["scan_summary_cards"][0]["context"]) + self.assertTrue(summary["scan_summary_cards"][0]["transition_hint"]) + self.assertIn("completion_state", summary["scan_summary_cards"][0]) + self.assertIn("completion_hint", summary["scan_summary_cards"][0]) + self.assertIn("scan_completion_summary", summary) + self.assertEqual(summary["scan_completion_summary"]["status"], "active") + self.assertTrue(summary["scan_completion_summary"]["next_target_section"]) + self.assertTrue(summary["external_context_summary"]["operator_questions"]) + self.assertTrue(summary["external_context_summary"]["source_cards"]) + self.assertEqual(summary["external_context_summary"]["exchange_status"]["status"], "active") + self.assertEqual(summary["external_context_summary"]["announcements_snapshot"]["status"], "active") + self.assertTrue(summary["external_context_summary"]["announcement_titles"]) + self.assertIn("CPI", summary["external_context_summary"]["headline"]) + self.assertTrue(summary["no_trade_reason_items"]) + self.assertIn("watch_london_open", summary["next_actions"]) + self.assertIn("review_london_session_playbook", summary["next_actions"]) + self.assertIn("action:observe_only->confirm_ticket", summary["changed_since_last"]["items"]) + self.assertIn("candidate:none->18", summary["changed_since_last"]["items"]) + class _NoteRepo: def insert_note(self, **kwargs): @@ -162,6 +333,17 @@ class _OptimizationReviewService: } +class _DecisionAssistantService: + def build_summary(self, **kwargs): + return { + "status": "completed", + "focus_mode": "monitor_and_wait", + "headline": "Monitor session flow.", + "top_priority_items": [], + "next_actions": [], + } + + class _SessionRepo: def fetch_recent_sessions(self, **kwargs): return [ @@ -170,5 +352,170 @@ class _SessionRepo: ] +class _LaunchReadinessService: + def build_summary(self, *, instrument_id: int, timeframe: str = "1m"): + return { + "instrument_id": instrument_id, + "timeframe": timeframe, + "analysis_only_ready": True, + "decision_support_ready": True, + "blocking_reasons": [], + "warning_reasons": ["trust_observe_mode"], + "next_actions": ["review_priority_setup"], + "product_operating_mode": "decision_support_only", + "market_state": { + "session": {"session_code": "LONDON", "status": "active"}, + "freshness": {"code": "market_data_fresh"}, + "stream_state": {"code": "market_stream_connected"}, + "data_gap_state": {"code": "market_data_gap_clear"}, + "news_window": { + "code": "news_window_active", + "provider_name": "tradingeconomics_snapshot", + "freshness_code": "news_provider_fresh", + "event_count": 1, + "active_events": [{"title": "CPI"}], + }, + }, + "external_waiting_items": ["real_trade_sample_verified"], + } + + +class _SessionFocusSummaryService: + def build_summary(self, *, instrument_id: int, limit: int = 6): + return { + "instrument_id": instrument_id, + "items": [ + { + "session_code": "LONDON", + "status": "active", + "alert_count": 1, + "executable_candidate_count": 1, + "open_execution_count": 0, + "action_hint": "focus_now", + "session_recommendation": {"classification": "prefer", "score": 82.0}, + } + ], + } + + +class _OperatorDigestSummaryService: + def build_digest(self, *, instrument_id: int, top_limit: int = 5): + return { + "instrument_id": instrument_id, + "status": "completed", + "summary_counts": {"high_value_alert_count": 1}, + "quality_flags": ["visual_mismatch_present"], + "quality_summary": {"status": "completed"}, + "operator_summary": "pending feedback=2", + "review_queue": [ + { + "queue_type": "high_value_alert", + "priority": "medium", + "alert_type": "candidate_actionable", + "reason": "needs_operator_review", + } + ], + } + + +class _CandidateWatchlistRepo: + def fetch_recent_candidates(self, *, instrument_id: int, limit: int = 20): + return [ + { + "id": 18, + "session_code": "LONDON", + "status": "candidate", + "quality_score": 81.0, + "model_code": "SWEEP_MSS_FVG", + "ai_explanation": "Current focus candidate.", + "missing_confirmations": ["trust_soft_block"], + }, + { + "id": 21, + "session_code": "LONDON", + "status": "candidate", + "quality_score": 72.0, + "model_code": "SWEEP_MSS_FVG", + "ai_explanation": "Strong session-aligned watch.", + "missing_confirmations": ["mss"], + }, + { + "id": 22, + "session_code": "NY_AM", + "status": "candidate", + "quality_score": 44.0, + "model_code": "SWEEP_MSS_FVG", + "ai_explanation": "Needs more confirmation.", + "missing_confirmations": ["fvg_retrace"], + }, + { + "id": 23, + "session_code": "OFF_HOURS", + "status": "candidate", + "quality_score": 12.0, + "model_code": "SWEEP_MSS_FVG", + "ai_explanation": "Low-priority background candidate.", + "missing_confirmations": [], + }, + ] + + +class _PreviousPacketRepo: + def fetch_latest_decision_packet(self, *, instrument_id: int): + return { + "id": 7, + "instrument_id": instrument_id, + "packet_ts": "2026-05-12T01:55:00+00:00", + "action_recommendation": "observe_only", + "status": "observe_only", + "payload": { + "status": "observe_only", + "action_recommendation": "observe_only", + "candidate_id": None, + "bias": "neutral", + "trust_classification": "observe", + "why_blocked": ["candidate_not_confirmed"], + }, + } + + +class _ResearchCouncilSummaryService: + def get_compact_lessons( + self, + *, + instrument_id: int, + session_code=None, + setup_type=None, + model_code=None, + limit: int = 5, + ): + return [ + { + "title": "London setup lesson", + "lesson_key": "lesson:london", + "lesson_summary": "Confirmed London sweep setups perform better after MSS reclaim.", + "lesson_type": "journal_pattern", + } + ] + + +class _ExchangeStatusProvider: + def fetch_snapshot(self): + return { + "provider_name": "okx_system_status", + "status": "active", + "active_events": [{"title": "API maintenance", "service_type": "api"}], + } + + +class _AnnouncementsProvider: + def fetch_snapshot(self): + return { + "provider_name": "okx_announcements", + "status": "active", + "announcements": [{"title": "Listing schedule update", "ann_type": "listing"}], + } + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_run_until_blocked.py b/tests/unit/test_run_until_blocked.py index c396247..35e19d4 100644 --- a/tests/unit/test_run_until_blocked.py +++ b/tests/unit/test_run_until_blocked.py @@ -61,6 +61,31 @@ class RunUntilBlockedTests(unittest.TestCase): self.assertTrue(selection["requires_code"]) self.assertTrue(selection["already_in_progress"]) + def test_required_automation_anchor_paths_match_new_kickoff_prompt(self) -> None: + anchors = run_until_blocked.get_automation_required_anchor_paths() + + self.assertEqual( + [path.name for path in anchors], + [ + "AI_ICT_实盘交易分析决策助手下一阶段开发方案_v1.md", + "SESSION.md", + "TASKS.md", + ], + ) + + def test_render_session_uses_new_kickoff_anchors(self) -> None: + text = run_until_blocked.render_session( + completed_stages=[], + current_stage="P0-2", + last_result={}, + blocked_reason=None, + ) + + self.assertIn("AI_ICT_实盘交易分析决策助手下一阶段开发方案_v1.md", text) + self.assertIn("SESSION.md", text) + self.assertIn("TASKS.md", text) + self.assertNotIn("AI_ICT_实盘辅助决策系统开发方案_v2.md", text) + def test_selector_blocks_when_required_anchors_are_missing(self) -> None: selection = run_until_blocked.select_automation_execution_slice( anchor_state={