From ba9f24086ace5822cf310a08c7e9cf908ebdce53 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 13 May 2026 23:14:08 +0800 Subject: [PATCH] Persist digest preferences and history --- SESSION.md | 11 +- TASKS.md | 10 +- ...create_decision_assistant_digest_state.sql | 32 ++++ src/api/__init__.py | 4 + src/api/alerts.py | 43 +++++ src/api/server.py | 20 +++ .../decision_assistant_repository.py | 169 ++++++++++++++++++ .../operator/operator_workspace_service.py | 169 +++++++++++++++++- src/web/app.js | 99 ++++++++-- tests/unit/test_api_server.py | 40 +++++ tests/unit/test_operator_workspace_service.py | 48 +++++ 11 files changed, 619 insertions(+), 26 deletions(-) create mode 100644 migrations/044_create_decision_assistant_digest_state.sql create mode 100644 src/repositories/decision_assistant_repository.py diff --git a/SESSION.md b/SESSION.md index 1dc829e..722b86c 100644 --- a/SESSION.md +++ b/SESSION.md @@ -2,7 +2,7 @@ ## Current Goal -Automation execution-layer PXX remains closed and its known scope stays frozen. Under explicit user direction, the active product phase is a dense `Decision Assistant` operator workspace with grouped recall, broader replay baselines, note templates, quick-save shortcuts, read-only market context, and a continuous operator cockpit. This run closed C3/G20/H26 as one internal batch: watchlist now exposes a focus matrix and next-focus prompt, review entry now exposes a prioritized review-action queue and handoff summary, and the main `assistant_loop_queue` now carries watchlist focus alongside review-entry actions. Local validation passed, the current working tree was deployed, and remote smoke confirmed the latest Decision Assistant fields on the live host. +Automation execution-layer PXX remains closed and its known scope stays frozen. Under explicit user direction, the active product phase is a dense `Decision Assistant` operator workspace with grouped recall, broader replay baselines, note templates, quick-save shortcuts, read-only market context, and a continuous operator cockpit. This run closed F26/H27 as a real backend batch: digest preferences now persist on the server, digest snapshots archive into a history table, cockpit reads back the persisted mode/history archive, and the front-end writes preference changes back through a dedicated API instead of local-only storage. Local validation passed, remote migration `044_create_decision_assistant_digest_state.sql` was applied, the current working tree was deployed, and remote smoke confirmed the latest Decision Assistant fields on the live host. ## Current Branch @@ -129,6 +129,8 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - latest_decision_assistant_watchlist_focus_slice: watchlist_focus_matrix - latest_decision_assistant_review_action_slice: review_entry_action_queue - latest_decision_assistant_review_handoff_slice: review_entry_handoff_summary +- latest_decision_assistant_digest_persistence_slice: digest_preferences_api_archive_history +- latest_decision_assistant_digest_archive_slice: persisted_digest_archive_visibility - latest_decision_assistant_summary_status: implemented - latest_decision_assistant_change_summary_status: implemented - latest_decision_assistant_evidence_status: implemented @@ -242,10 +244,12 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - latest_decision_assistant_watchlist_focus_status: implemented - latest_decision_assistant_review_action_status: implemented - latest_decision_assistant_review_handoff_status: implemented +- latest_decision_assistant_digest_persistence_status: implemented +- latest_decision_assistant_digest_archive_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: watchlist_review_loop_batch_visible +- latest_remote_decision_assistant_review_packet_status: digest_preferences_archive_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 @@ -661,11 +665,12 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - Latest E7 validation passed: focused operator workspace tests, 68 related unit/API/static tests, 713 full unit tests, 73 script static checks, `node --check src/web/app.js`, deploy smoke passed 11 endpoints, remote ops smoke passed 9 checks, and remote field smoke confirmed `chart_adapter_mode=optional_reference`, `chart_primary_mode=internal_review_packet_html`, and a non-blocking external chart adapter even when other supporting context sources are stale. - Latest W4/D25/F25/H25 validation passed: focused operator workspace tests, 68 related unit/API/static tests, 713 full unit tests, 73 script static checks, `node --check src/web/app.js`, deploy smoke passed 11 endpoints, remote ops smoke passed 9 checks, and remote field smoke confirmed `workflow_artifact_summary`, `digest_action_queue`, `learning_focus_matrix`, and `workspace_continuity_handoff` plus matching UI strings. - Latest C3/G20/H26 validation passed: focused operator workspace tests, 68 related unit/API/static tests, 713 full unit tests, 73 script static checks, `node --check src/web/app.js`, deploy smoke passed 11 endpoints, remote ops smoke passed 9 checks, and remote field smoke confirmed `watchlist focus matrix`, `review_entry_action_queue`, `review_entry_handoff_summary`, and watchlist-focus presence inside `assistant_loop_queue`. +- Latest F26/H27 validation passed: focused operator workspace/API tests, 69 related unit/API/static tests, 714 full unit tests, 73 script static checks, `node --check src/web/app.js`, remote migration `044_create_decision_assistant_digest_state.sql` applied successfully, deploy completed, remote ops smoke passed 9 checks, and remote field smoke confirmed `/trading/alerts/digest-preferences`, persisted `digest_preference_profile`, and `digest_history_archive`. - Real-live order submission remains intentionally disabled because the OKX order adapter is still not a real-live submitter. ## Exact Next Step -C3/G20/H26 batch is closed. External chart URLs remain optional reference enhancements and are not required for the current analysis-only loop. Internal non-trading backlog still exists, but PXX remains closed; do not create P25/P26 automatically, and do not enable real trading. +F26/H27 backend digest persistence batch is closed. External chart URLs remain optional reference enhancements and are not required for the current analysis-only loop. Internal non-trading backlog still exists, but PXX remains closed; do not create P25/P26 automatically, and do not enable real trading. ## Validation Commands Used Recently diff --git a/TASKS.md b/TASKS.md index c71c3c2..87e428c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -160,6 +160,8 @@ - [x] C3 watchlist focus matrix / next-focus prompt slice - [x] G20 review entry action queue slice - [x] H26 review/watchlist continuity into loop queue slice +- [x] F26 digest preference backend persistence / history archive slice +- [x] H27 persisted digest archive / cockpit sync slice - [x] P8 local closure complete - [x] P9 local closure complete - [x] P10 local closure complete @@ -282,6 +284,8 @@ - [x] latest_decision_assistant_watchlist_focus_slice: watchlist_focus_matrix - [x] latest_decision_assistant_review_action_slice: review_entry_action_queue - [x] latest_decision_assistant_review_handoff_slice: review_entry_handoff_summary +- [x] latest_decision_assistant_digest_persistence_slice: digest_preferences_api_archive_history +- [x] latest_decision_assistant_digest_archive_slice: persisted_digest_archive_visibility - [x] latest_decision_assistant_summary_status: implemented - [x] latest_decision_assistant_change_summary_status: implemented - [x] latest_decision_assistant_evidence_status: implemented @@ -392,10 +396,12 @@ - [x] latest_decision_assistant_watchlist_focus_status: implemented - [x] latest_decision_assistant_review_action_status: implemented - [x] latest_decision_assistant_review_handoff_status: implemented +- [x] latest_decision_assistant_digest_persistence_status: implemented +- [x] latest_decision_assistant_digest_archive_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: watchlist_review_loop_batch_visible +- [x] latest_remote_decision_assistant_review_packet_status: digest_preferences_archive_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 @@ -707,6 +713,8 @@ - [x] Deployed W4/D25/F25/H25 current tree and passed remote smoke: 11 deploy endpoints, 9 remote ops checks, and review-packet/static field smoke for `workflow_artifact_summary`, `digest_action_queue`, `learning_focus_matrix`, and `workspace_continuity_handoff` - [x] Completed C3/G20/H26 focused validation: operator workspace tests, 68 related unit/API/static tests, 713 full unit tests, 73 script static checks, and dashboard JS syntax - [x] Deployed C3/G20/H26 current tree and passed remote smoke: 11 deploy endpoints, 9 remote ops checks, and review-packet/static field smoke for `watchlist focus matrix`, `review_entry_action_queue`, `review_entry_handoff_summary`, and watchlist-focus presence inside `assistant_loop_queue` +- [x] Completed F26/H27 focused validation: operator workspace/API tests, 69 related unit/API/static tests, 714 full unit tests, 73 script static checks, and dashboard JS syntax +- [x] Deployed F26/H27 current tree, applied remote migration `044_create_decision_assistant_digest_state.sql`, and passed remote smoke for `/trading/alerts/digest-preferences`, persisted `digest_preference_profile`, and `digest_history_archive` ## Blocked diff --git a/migrations/044_create_decision_assistant_digest_state.sql b/migrations/044_create_decision_assistant_digest_state.sql new file mode 100644 index 0000000..879b15e --- /dev/null +++ b/migrations/044_create_decision_assistant_digest_state.sql @@ -0,0 +1,32 @@ +create table if not exists decision_assistant_preferences ( + id bigserial primary key, + profile_key varchar(64) not null, + instrument_id bigint not null, + digest_mode varchar(32) not null default 'all_reminders', + preferred_history_kind varchar(64), + importance_threshold integer not null default 70, + payload jsonb not null default '{}'::jsonb, + updated_ts timestamptz not null default now(), + unique (profile_key, instrument_id) +); + +create index if not exists idx_decision_assistant_preferences_instrument + on decision_assistant_preferences (instrument_id, updated_ts desc); + +create table if not exists decision_assistant_digest_history ( + id bigserial primary key, + profile_key varchar(64) not null default 'local_operator', + instrument_id bigint not null, + digest_mode varchar(32) not null default 'all_reminders', + preferred_history_kind varchar(64), + top_summary text, + snapshot_signature varchar(128) not null, + payload jsonb not null default '{}'::jsonb, + created_ts timestamptz not null default now() +); + +create index if not exists idx_decision_assistant_digest_history_instrument_ts + on decision_assistant_digest_history (instrument_id, created_ts desc); + +create index if not exists idx_decision_assistant_digest_history_signature + on decision_assistant_digest_history (instrument_id, snapshot_signature); diff --git a/src/api/__init__.py b/src/api/__init__.py index 7079eef..d05e954 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -57,6 +57,8 @@ from src.api.alerts import ( get_alert_quality_summary, get_alert_silent_window_status, get_operator_digest, + get_operator_digest_preferences, + save_operator_digest_preferences, ) from src.api.feedback import ( create_alert_feedback, @@ -137,6 +139,8 @@ __all__ = [ "get_alert_deliveries", "get_alert_quality_summary", "get_operator_digest", + "get_operator_digest_preferences", + "save_operator_digest_preferences", "get_alert_silent_window_status", "create_alert_feedback", "create_visual_review_feedback", diff --git a/src/api/alerts.py b/src/api/alerts.py index 339a222..dbf97c4 100644 --- a/src/api/alerts.py +++ b/src/api/alerts.py @@ -1,4 +1,5 @@ from src.repositories.alert_delivery_repository import AlertDeliveryRepository +from src.repositories.decision_assistant_repository import DecisionAssistantRepository from src.services.alerts import ( AlertDispatcherService, AlertQualitySummaryService, @@ -43,5 +44,47 @@ def get_operator_digest( ) +def get_operator_digest_preferences( + instrument_id: int, + *, + profile_key: str = DecisionAssistantRepository.DEFAULT_PROFILE_KEY, +) -> dict: + repository = DecisionAssistantRepository() + payload = repository.fetch_preference(instrument_id=instrument_id, profile_key=profile_key) + if payload: + return payload + return { + "profile_key": profile_key, + "instrument_id": instrument_id, + "digest_mode": "all_reminders", + "preferred_history_kind": None, + "importance_threshold": 70, + "payload": {}, + "updated_ts": None, + "status": "default", + } + + +def save_operator_digest_preferences( + instrument_id: int, + *, + profile_key: str = DecisionAssistantRepository.DEFAULT_PROFILE_KEY, + digest_mode: str = "all_reminders", + preferred_history_kind: str | None = None, + importance_threshold: int = 70, + payload: dict | None = None, +) -> dict: + saved = DecisionAssistantRepository().upsert_preference( + instrument_id=instrument_id, + profile_key=profile_key, + digest_mode=digest_mode, + preferred_history_kind=preferred_history_kind, + importance_threshold=importance_threshold, + payload=payload, + ) + saved["status"] = "saved" + return saved + + def get_alert_silent_window_status() -> dict: return AlertSilentWindowService().build_status() diff --git a/src/api/server.py b/src/api/server.py index 1ea718d..3953a46 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -17,6 +17,8 @@ from src.api.alerts import ( get_alert_quality_summary, get_alert_silent_window_status, get_operator_digest, + get_operator_digest_preferences, + save_operator_digest_preferences, ) from src.api.feedback import ( create_alert_feedback, @@ -124,6 +126,8 @@ def build_api_dependencies() -> dict: "trading_alert_dispatch": run_trading_alert_dispatch, "trading_alert_quality_summary": get_alert_quality_summary, "trading_alert_operator_digest": get_operator_digest, + "trading_alert_operator_digest_preferences": get_operator_digest_preferences, + "trading_alert_operator_digest_preferences_save": save_operator_digest_preferences, "trading_alert_silent_window_status": get_alert_silent_window_status, "trading_reconciliation_run": run_trading_reconciliation, "trading_runtime_status": get_trading_runtime_status, @@ -307,6 +311,22 @@ def route_request( visual_limit=visual_limit, top_limit=top_limit, ) + if method == "GET" and path == "/trading/alerts/digest-preferences": + instrument_id = _require_int(query, "instrument_id") + return 200, deps["trading_alert_operator_digest_preferences"]( + instrument_id=instrument_id, + profile_key=_get_single(query, "profile_key", "local_operator"), + ) + if method == "POST" and path == "/trading/alerts/digest-preferences": + instrument_id = _require_json_int(body or {}, "instrument_id") + return 200, deps["trading_alert_operator_digest_preferences_save"]( + instrument_id=instrument_id, + profile_key=_get_body_value(body, "profile_key", "local_operator"), + digest_mode=_get_body_value(body, "digest_mode", "all_reminders"), + preferred_history_kind=_get_body_value(body, "preferred_history_kind"), + importance_threshold=_get_body_optional_int(body, "importance_threshold") or 70, + payload=body.get("payload") if isinstance(body, dict) else None, + ) if method == "POST" and path == "/trading/alerts/dispatch": limit = _get_body_optional_int(body, "limit") or 50 return 200, deps["trading_alert_dispatch"](limit=limit) diff --git a/src/repositories/decision_assistant_repository.py b/src/repositories/decision_assistant_repository.py new file mode 100644 index 0000000..9f1fafb --- /dev/null +++ b/src/repositories/decision_assistant_repository.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import json + +from sqlalchemy import text + +from src.db.session import SessionLocal + + +class DecisionAssistantRepository: + DEFAULT_PROFILE_KEY = "local_operator" + + def fetch_preference(self, *, instrument_id: int, profile_key: str = DEFAULT_PROFILE_KEY) -> dict | None: + sql = text( + """ + select id, profile_key, instrument_id, digest_mode, preferred_history_kind, + importance_threshold, payload, updated_ts + from decision_assistant_preferences + where instrument_id = :instrument_id and profile_key = :profile_key + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute( + sql, + {"instrument_id": instrument_id, "profile_key": profile_key}, + ).mappings().first() + return dict(row) if row else None + + def upsert_preference( + self, + *, + instrument_id: int, + profile_key: str = DEFAULT_PROFILE_KEY, + digest_mode: str = "all_reminders", + preferred_history_kind: str | None = None, + importance_threshold: int = 70, + payload: dict | None = None, + updated_ts=None, + ) -> dict: + sql = text( + """ + insert into decision_assistant_preferences ( + profile_key, + instrument_id, + digest_mode, + preferred_history_kind, + importance_threshold, + payload, + updated_ts + ) values ( + :profile_key, + :instrument_id, + :digest_mode, + :preferred_history_kind, + :importance_threshold, + cast(:payload as jsonb), + coalesce(:updated_ts, now()) + ) + on conflict (profile_key, instrument_id) do update + set digest_mode = excluded.digest_mode, + preferred_history_kind = excluded.preferred_history_kind, + importance_threshold = excluded.importance_threshold, + payload = excluded.payload, + updated_ts = excluded.updated_ts + returning id, profile_key, instrument_id, digest_mode, preferred_history_kind, + importance_threshold, payload, updated_ts + """ + ) + with SessionLocal.begin() as session: + row = session.execute( + sql, + { + "profile_key": profile_key, + "instrument_id": instrument_id, + "digest_mode": digest_mode, + "preferred_history_kind": preferred_history_kind, + "importance_threshold": importance_threshold, + "payload": json.dumps(payload or {}, ensure_ascii=False, default=str), + "updated_ts": updated_ts, + }, + ).mappings().one() + return dict(row) + + def fetch_recent_digest_history( + self, + *, + instrument_id: int, + profile_key: str = DEFAULT_PROFILE_KEY, + limit: int = 20, + ) -> list[dict]: + sql = text( + """ + select id, profile_key, instrument_id, digest_mode, preferred_history_kind, + top_summary, snapshot_signature, payload, created_ts + from decision_assistant_digest_history + where instrument_id = :instrument_id and profile_key = :profile_key + order by created_ts desc, id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute( + sql, + {"instrument_id": instrument_id, "profile_key": profile_key, "limit": limit}, + ).mappings().all() + return [dict(row) for row in rows] + + def fetch_latest_digest_history( + self, + *, + instrument_id: int, + profile_key: str = DEFAULT_PROFILE_KEY, + ) -> dict | None: + rows = self.fetch_recent_digest_history(instrument_id=instrument_id, profile_key=profile_key, limit=1) + return rows[0] if rows else None + + def insert_digest_history( + self, + *, + instrument_id: int, + profile_key: str = DEFAULT_PROFILE_KEY, + digest_mode: str = "all_reminders", + preferred_history_kind: str | None = None, + top_summary: str | None = None, + snapshot_signature: str, + payload: dict | None = None, + created_ts=None, + ) -> dict: + sql = text( + """ + insert into decision_assistant_digest_history ( + profile_key, + instrument_id, + digest_mode, + preferred_history_kind, + top_summary, + snapshot_signature, + payload, + created_ts + ) values ( + :profile_key, + :instrument_id, + :digest_mode, + :preferred_history_kind, + :top_summary, + :snapshot_signature, + cast(:payload as jsonb), + coalesce(:created_ts, now()) + ) + returning id, profile_key, instrument_id, digest_mode, preferred_history_kind, + top_summary, snapshot_signature, payload, created_ts + """ + ) + with SessionLocal.begin() as session: + row = session.execute( + sql, + { + "profile_key": profile_key, + "instrument_id": instrument_id, + "digest_mode": digest_mode, + "preferred_history_kind": preferred_history_kind, + "top_summary": top_summary, + "snapshot_signature": snapshot_signature, + "payload": json.dumps(payload or {}, ensure_ascii=False, default=str), + "created_ts": created_ts, + }, + ).mappings().one() + return dict(row) diff --git a/src/services/operator/operator_workspace_service.py b/src/services/operator/operator_workspace_service.py index 6676618..20cea01 100644 --- a/src/services/operator/operator_workspace_service.py +++ b/src/services/operator/operator_workspace_service.py @@ -1,9 +1,12 @@ from __future__ import annotations from datetime import datetime, timezone +import hashlib +import json from typing import Optional from src.repositories.decision_packet_repository import DecisionPacketRepository +from src.repositories.decision_assistant_repository import DecisionAssistantRepository from src.repositories.market_session_repository import MarketSessionRepository from src.repositories.operator_note_repository import OperatorNoteRepository from src.repositories.trade_plan_candidate_repository import TradePlanCandidateRepository @@ -127,6 +130,7 @@ class DecisionAssistantSummaryService: research_council_service: Optional[ResearchCouncilService] = None, exchange_status_provider: Optional[OkxSystemStatusProvider] = None, announcements_provider: Optional[OkxAnnouncementsProvider] = None, + decision_assistant_repository: Optional[DecisionAssistantRepository] = None, ) -> None: self.launch_readiness_service = launch_readiness_service or LaunchReadinessService() self.session_focus_service = session_focus_service or SessionFocusService() @@ -136,6 +140,7 @@ class DecisionAssistantSummaryService: 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() + self.decision_assistant_repository = decision_assistant_repository or DecisionAssistantRepository() def build_summary( self, @@ -165,7 +170,15 @@ class DecisionAssistantSummaryService: instrument_id=instrument_id, fallback_session_code=fallback_session_code or None, ) - return build_decision_assistant_summary( + persisted_digest_preference = _safe_fetch_digest_preference( + repository=self.decision_assistant_repository, + instrument_id=instrument_id, + ) + persisted_digest_history = _safe_fetch_digest_history( + repository=self.decision_assistant_repository, + instrument_id=instrument_id, + ) + summary = build_decision_assistant_summary( instrument_id=instrument_id, timeframe=timeframe, decision_packet=dict(decision_packet or {}), @@ -181,7 +194,28 @@ class DecisionAssistantSummaryService: recent_alerts=list(recent_alerts or []), recent_notes=list(recent_notes or []), recent_executions=list(recent_executions or []), + persisted_digest_preference=dict(persisted_digest_preference or {}), + persisted_digest_history=list(persisted_digest_history or []), ) + persistence_result = _safe_persist_digest_history_snapshot( + repository=self.decision_assistant_repository, + instrument_id=instrument_id, + summary=summary, + ) + refreshed_digest_history = _safe_fetch_digest_history( + repository=self.decision_assistant_repository, + instrument_id=instrument_id, + ) + digest_history_archive = _build_decision_assistant_digest_history_archive(refreshed_digest_history) + summary["digest_history_archive"] = digest_history_archive + summary["digest_persistence_status"] = persistence_result.get("status") + digest_surface = dict(summary.get("digest_surface") or {}) + digest_surface["digest_history_archive"] = digest_history_archive + digest_surface["digest_history_archive_count"] = digest_history_archive.get("snapshot_count") or 0 + digest_surface["next_digest_history_archive_review"] = digest_history_archive.get("next_review_prompt") + digest_surface["digest_persistence_status"] = persistence_result.get("status") + summary["digest_surface"] = digest_surface + return summary class ReviewPacketService: @@ -247,6 +281,57 @@ class ReviewPacketService: } +def _safe_fetch_digest_preference(*, repository: DecisionAssistantRepository, instrument_id: int) -> dict: + try: + return dict(repository.fetch_preference(instrument_id=instrument_id) or {}) + except Exception: + return {} + + +def _safe_fetch_digest_history(*, repository: DecisionAssistantRepository, instrument_id: int, limit: int = 20) -> list[dict]: + try: + return list(repository.fetch_recent_digest_history(instrument_id=instrument_id, limit=limit) or []) + except Exception: + return [] + + +def _safe_persist_digest_history_snapshot( + *, + repository: DecisionAssistantRepository, + instrument_id: int, + summary: dict, +) -> dict: + digest_surface = dict(summary.get("digest_surface") or {}) + preference_profile = dict(digest_surface.get("digest_preference_profile") or {}) + snapshot_payload = { + "focus_mode": summary.get("focus_mode"), + "headline": summary.get("headline"), + "digest_mode": preference_profile.get("default_mode"), + "preferred_history_kind": preference_profile.get("preferred_history_kind"), + "top_summary": digest_surface.get("summary"), + "top_opportunity_title": digest_surface.get("top_opportunity_title"), + "next_digest_action_label": dict(digest_surface.get("next_digest_action") or {}).get("label"), + "reminder_count": digest_surface.get("reminder_count"), + "high_value_reminder_count": digest_surface.get("high_value_reminder_count"), + } + signature = hashlib.sha1(json.dumps(snapshot_payload, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")).hexdigest() + try: + latest = repository.fetch_latest_digest_history(instrument_id=instrument_id) + if latest and str(latest.get("snapshot_signature") or "") == signature: + return {"status": "duplicate_skipped", "snapshot_signature": signature} + repository.insert_digest_history( + instrument_id=instrument_id, + digest_mode=str(preference_profile.get("default_mode") or "all_reminders"), + preferred_history_kind=preference_profile.get("preferred_history_kind"), + top_summary=str(digest_surface.get("summary") or ""), + snapshot_signature=signature, + payload=snapshot_payload, + ) + return {"status": "persisted", "snapshot_signature": signature} + except Exception: + return {"status": "unavailable", "snapshot_signature": signature} + + def _build_research_council_summary(run: Optional[dict]) -> dict: payload = dict(((run or {}).get("payload") or {}).get("decision") or {}) return { @@ -281,6 +366,8 @@ def build_decision_assistant_summary( recent_alerts: list[dict], recent_notes: list[dict], recent_executions: list[dict], + persisted_digest_preference: dict, + persisted_digest_history: list[dict], ) -> dict: market_state = dict(launch_readiness.get("market_state") or {}) session_state = dict(market_state.get("session") or {}) @@ -425,6 +512,7 @@ def build_decision_assistant_summary( digest_history_preview, recommended_kind=digest_history_summary.get("recommended_kind"), ) + digest_history_archive = _build_decision_assistant_digest_history_archive(persisted_digest_history) digest_surface = _build_decision_assistant_digest_surface( focus_mode=focus_mode, personalized_reminders=personalized_reminders, @@ -433,6 +521,8 @@ def build_decision_assistant_summary( digest_history_preview=digest_history_preview, digest_history_summary=digest_history_summary, digest_history_groups=digest_history_groups, + persisted_digest_preference=persisted_digest_preference, + digest_history_archive=digest_history_archive, ) digest_history_browser = dict(digest_surface.get("history_browser") or {}) decision_timeline = _build_decision_assistant_timeline( @@ -506,6 +596,7 @@ def build_decision_assistant_summary( "digest_history_summary": digest_history_summary, "digest_history_groups": digest_history_groups, "digest_history_browser": digest_history_browser, + "digest_history_archive": digest_history_archive, "digest_surface": digest_surface, "setup_learning_rollup": setup_learning_rollup, "decision_timeline": decision_timeline, @@ -3235,6 +3326,8 @@ def _build_decision_assistant_digest_surface( digest_history_preview: list[dict], digest_history_summary: dict, digest_history_groups: list[dict], + persisted_digest_preference: dict, + digest_history_archive: dict, ) -> dict: high_value_reminders = [ dict(item) @@ -3244,7 +3337,9 @@ def _build_decision_assistant_digest_surface( digest_preference_profile = _build_digest_preference_profile( personalized_reminders=personalized_reminders, digest_history_summary=digest_history_summary, + persisted_digest_preference=persisted_digest_preference, ) + importance_threshold = int((persisted_digest_preference or {}).get("importance_threshold") or 70) digest_review_back_actions = _build_digest_review_back_actions( digest_history_preview=digest_history_preview, digest_history_summary=digest_history_summary, @@ -3266,7 +3361,7 @@ def _build_decision_assistant_digest_surface( "high_priority_count": sum(1 for item in personalized_reminders if str(item.get("severity") or "") == "high"), "high_value_reminders": high_value_reminders[:5], "high_value_reminder_count": len(high_value_reminders), - "important_change_filter": _digest_important_change_filter(personalized_reminders), + "important_change_filter": _digest_important_change_filter(personalized_reminders, threshold=importance_threshold), "importance_templates": _digest_importance_templates(), "digest_preference_profile": digest_preference_profile, "next_high_value_change": high_value_reminders[0] if high_value_reminders else None, @@ -3280,6 +3375,9 @@ def _build_decision_assistant_digest_surface( "digest_action_queue": digest_action_queue[:6], "digest_action_queue_count": len(digest_action_queue), "next_digest_action": digest_action_queue[0] if digest_action_queue else None, + "digest_history_archive": digest_history_archive, + "digest_history_archive_count": digest_history_archive.get("snapshot_count") or 0, + "next_digest_history_archive_review": digest_history_archive.get("next_review_prompt"), "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), @@ -3323,12 +3421,13 @@ def _reminder_high_value_reason(reminder: dict) -> str: return "below_high_value_threshold" -def _digest_important_change_filter(personalized_reminders: list[dict]) -> dict: +def _digest_important_change_filter(personalized_reminders: list[dict], *, threshold: int = 70) -> dict: + effective_threshold = max(0, int(threshold or 70)) return { - "threshold": 70, + "threshold": effective_threshold, "mode": "high_value_only", "input_count": len(personalized_reminders), - "passed_count": sum(1 for item in personalized_reminders if bool(item.get("high_value"))), + "passed_count": sum(1 for item in personalized_reminders if int(item.get("importance_score") or 0) >= effective_threshold), "template_counts": _digest_importance_template_counts(personalized_reminders), } @@ -3351,7 +3450,12 @@ def _digest_importance_templates() -> list[dict]: ] -def _build_digest_preference_profile(*, personalized_reminders: list[dict], digest_history_summary: dict) -> dict: +def _build_digest_preference_profile( + *, + personalized_reminders: list[dict], + digest_history_summary: dict, + persisted_digest_preference: dict, +) -> dict: template_counts = _digest_importance_template_counts(personalized_reminders) preferred_template = next( ( @@ -3365,20 +3469,26 @@ def _build_digest_preference_profile(*, personalized_reminders: list[dict], dige None, ) high_value_count = sum(1 for item in personalized_reminders if bool(item.get("high_value"))) - default_mode = "high_value_only" if high_value_count else "all_reminders" + default_mode = str((persisted_digest_preference or {}).get("digest_mode") or ("high_value_only" if high_value_count else "all_reminders")) if preferred_template == "risk_first_change": default_mode = "risk_first" + persisted_threshold = int((persisted_digest_preference or {}).get("importance_threshold") or 70) + preferred_history_kind = (persisted_digest_preference or {}).get("preferred_history_kind") or digest_history_summary.get("recommended_kind") return { "status": "active" if personalized_reminders else "idle", "default_mode": default_mode, "available_modes": ["all_reminders", "high_value_only", "risk_first"], - "preferred_history_kind": digest_history_summary.get("recommended_kind"), + "preferred_history_kind": preferred_history_kind, "preferred_importance_template": preferred_template, + "importance_threshold": persisted_threshold, + "persistence_status": "persisted" if persisted_digest_preference else "computed_default", + "persisted_digest_mode": (persisted_digest_preference or {}).get("digest_mode"), + "persisted_updated_ts": (persisted_digest_preference or {}).get("updated_ts"), "high_value_only_recommended": high_value_count > 0, "profile_reason": _digest_preference_profile_reason( preferred_template=preferred_template, high_value_count=high_value_count, - recommended_kind=str(digest_history_summary.get("recommended_kind") or ""), + recommended_kind=str(preferred_history_kind or ""), ), } @@ -3552,6 +3662,47 @@ def _build_digest_action_queue( return queue +def _build_decision_assistant_digest_history_archive(rows: list[dict]) -> dict: + if not rows: + return { + "status": "idle", + "snapshot_count": 0, + "latest_created_ts": None, + "latest_summary": None, + "next_review_prompt": None, + "items": [], + } + items = [] + for row in rows[:12]: + payload = dict(row.get("payload") or {}) + items.append( + { + "created_ts": row.get("created_ts"), + "digest_mode": row.get("digest_mode"), + "preferred_history_kind": row.get("preferred_history_kind"), + "top_summary": row.get("top_summary"), + "focus_mode": payload.get("focus_mode"), + "top_opportunity_title": payload.get("top_opportunity_title"), + "next_digest_action_label": payload.get("next_digest_action_label"), + "high_value_reminder_count": payload.get("high_value_reminder_count"), + "reminder_count": payload.get("reminder_count"), + } + ) + latest = dict(items[0] or {}) + return { + "status": "active", + "snapshot_count": len(rows), + "latest_created_ts": latest.get("created_ts"), + "latest_summary": latest.get("top_summary"), + "next_review_prompt": ( + f"Review persisted digest snapshot from {latest.get('created_ts')} before comparing today's recall drift." + if latest.get("created_ts") + else None + ), + "items": items, + } + + def _digest_history_browser_hint(*, recommended_kind: str, total_count: int) -> str: if recommended_kind: return f"Open {recommended_kind} first, then review the remaining {total_count} digest history item(s) by freshness debt." diff --git a/src/web/app.js b/src/web/app.js index c41d9f4..687108f 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -82,9 +82,9 @@ function revealPanel(node) { function loadAssistantHistoryKindFilter() { try { - return window.localStorage?.getItem('assistantHistoryKindFilter') || 'all'; + return window.localStorage?.getItem('assistantHistoryKindFilter') || ''; } catch { - return 'all'; + return ''; } } @@ -99,9 +99,9 @@ function persistAssistantHistoryKindFilter(value) { function loadAssistantDigestPreferenceMode() { try { - return window.localStorage?.getItem('assistantDigestPreferenceMode') || 'all_reminders'; + return window.localStorage?.getItem('assistantDigestPreferenceMode') || ''; } catch { - return 'all_reminders'; + return ''; } } @@ -114,6 +114,22 @@ function persistAssistantDigestPreferenceMode(value) { } } +async function persistAssistantDigestPreferenceServer({ digestMode, preferredHistoryKind, importanceThreshold = 70 }) { + try { + await postJson('/trading/alerts/digest-preferences', { + instrument_id: Number(instrumentIdInput.value || 1), + digest_mode: digestMode || 'all_reminders', + preferred_history_kind: preferredHistoryKind || null, + importance_threshold: Number(importanceThreshold || 70), + payload: { + source: 'decision_assistant_workbench', + }, + }); + } catch { + // no-op + } +} + function loadAssistantScanHandoffMarker() { try { const raw = window.localStorage?.getItem('assistantScanHandoffMarker'); @@ -522,7 +538,14 @@ function renderDecisionAssistant(summary) { const continuousWorkspaceRail = summary.continuous_workspace_rail || []; const continuousWorkspaceSummary = summary.continuous_workspace_summary || null; const workspaceContinuityHandoff = summary.workspace_continuity_handoff || null; - const visibleReminders = filterAssistantReminders(personalizedReminders, assistantDigestPreferenceMode); + const effectiveDigestPreferenceMode = assistantDigestPreferenceMode + || digestPreferenceProfile?.persisted_digest_mode + || digestPreferenceProfile?.default_mode + || 'all_reminders'; + const activeHistoryFilter = assistantHistoryKindFilter + || digestPreferenceProfile?.preferred_history_kind + || 'all'; + const visibleReminders = filterAssistantReminders(personalizedReminders, effectiveDigestPreferenceMode); const reminderCards = visibleReminders.length ? visibleReminders : personalizedReminders; const savedHandoffAge = resolveAssistantHandoffAge(assistantScanHandoffMarker, scanHandoffSummary?.default_stale_after_seconds); const assistantSections = [ @@ -850,8 +873,11 @@ function renderDecisionAssistant(summary) { ['reminders', String(digestSurface.reminder_count ?? 0)], ['high priority reminders', String(digestSurface.high_priority_count ?? 0)], ['high value reminders', String(digestSurface.high_value_reminder_count ?? 0)], - ['digest preference', assistantDigestPreferenceMode || 'all_reminders'], + ['digest preference', effectiveDigestPreferenceMode || 'all_reminders'], ['preference default', digestPreferenceProfile?.default_mode || 'n/a'], + ['preference persistence', digestPreferenceProfile?.persistence_status || 'n/a'], + ['preference threshold', String(digestPreferenceProfile?.importance_threshold ?? 'n/a')], + ['preference updated', formatTimestamp(digestPreferenceProfile?.persisted_updated_ts)], ['preferred history kind', digestPreferenceProfile?.preferred_history_kind || 'n/a'], ['preferred template', digestPreferenceProfile?.preferred_importance_template || 'n/a'], ['preference reason', digestPreferenceProfile?.profile_reason || 'n/a'], @@ -869,6 +895,9 @@ function renderDecisionAssistant(summary) { ['digest action queue', String(digestSurface.digest_action_queue_count ?? 0)], ['next digest action', digestSurface.next_digest_action?.label || 'n/a'], ['next digest prompt', digestSurface.next_digest_action?.prompt || 'n/a'], + ['digest archive', String(digestSurface.digest_history_archive_count ?? 0)], + ['next archive review', digestSurface.next_digest_history_archive_review || 'n/a'], + ['digest persistence', digestSurface.digest_persistence_status || 'n/a'], ['digest history', String(digestSurface.history_count ?? 0)], ['digest kinds', formatList(digestSurface.history_summary?.kinds || [])], ['recommended history kind', digestSurface.history_summary?.recommended_kind || 'n/a'], @@ -912,7 +941,7 @@ function renderDecisionAssistant(summary) { ['confirmation trail', formatList((digestSurface.history_summary?.refresh_confirmation_trail || []).map((item) => `${item.kind}:${item.proof_label}`))], ['refresh evidence anchors', String(digestSurface.history_summary?.refresh_evidence_anchor_count ?? 0)], ['refresh prompts', formatList((digestSurface.history_summary?.refresh_prompts || []).map((item) => `${item.kind}: ${item.prompt}`))], - ['history filter', assistantHistoryKindFilter || 'all'], + ['history filter', activeHistoryFilter || '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'], @@ -941,6 +970,11 @@ function renderDecisionAssistant(summary) { label: `${item.label || item.kind}${item.recommended ? ' *' : ''}`, onClick: () => { persistAssistantHistoryKindFilter(item.kind || 'all'); + persistAssistantDigestPreferenceServer({ + digestMode: effectiveDigestPreferenceMode, + preferredHistoryKind: item.kind || 'all', + importanceThreshold: digestPreferenceProfile?.importance_threshold || 70, + }); renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); }, })) @@ -953,12 +987,36 @@ function renderDecisionAssistant(summary) { label: mode === 'all_reminders' ? 'All Reminders' : (mode === 'high_value_only' ? 'High Value' : 'Risk First'), onClick: () => { persistAssistantDigestPreferenceMode(mode); + persistAssistantDigestPreferenceServer({ + digestMode: mode, + preferredHistoryKind: activeHistoryFilter === 'all' ? null : activeHistoryFilter, + importanceThreshold: digestPreferenceProfile?.importance_threshold || 70, + }); renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); }, })) ); decisionAssistantPanel.appendChild(preferenceBar); } + if (digestSurface?.digest_history_archive?.items?.length) { + const archiveGrid = createElement('div', 'cards assistant-tight-grid'); + (digestSurface.digest_history_archive.items || []).forEach((item) => { + archiveGrid.appendChild( + buildItemCard( + item.top_summary || item.top_opportunity_title || 'digest snapshot', + item.digest_mode || 'digest', + [ + ['time', formatTimestamp(item.created_ts)], + ['history kind', item.preferred_history_kind || 'n/a'], + ['next action', item.next_digest_action_label || 'n/a'], + ['high value reminders', String(item.high_value_reminder_count ?? 0)], + ['reminders', String(item.reminder_count ?? 0)], + ] + ) + ); + }); + decisionAssistantPanel.appendChild(archiveGrid); + } if (reminderCards.length) { const reminderGrid = createElement('div', 'cards assistant-tight-grid'); reminderCards.forEach((item) => { @@ -982,7 +1040,7 @@ function renderDecisionAssistant(summary) { if (digestHistoryGroups.length) { const recommendedKind = digestSurface?.history_summary?.recommended_kind || 'all'; const groupKinds = digestHistoryGroups.map((item) => item.kind); - if (assistantHistoryKindFilter !== 'all' && !groupKinds.includes(assistantHistoryKindFilter)) { + if (activeHistoryFilter !== 'all' && !groupKinds.includes(activeHistoryFilter)) { persistAssistantHistoryKindFilter(recommendedKind); } const historyFilterBar = buildActionBar([ @@ -990,6 +1048,11 @@ function renderDecisionAssistant(summary) { label: 'All History', onClick: () => { persistAssistantHistoryKindFilter('all'); + persistAssistantDigestPreferenceServer({ + digestMode: effectiveDigestPreferenceMode, + preferredHistoryKind: null, + importanceThreshold: digestPreferenceProfile?.importance_threshold || 70, + }); renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); }, }, @@ -998,6 +1061,11 @@ function renderDecisionAssistant(summary) { label: `Recommended ${recommendedKind}`, onClick: () => { persistAssistantHistoryKindFilter(recommendedKind); + persistAssistantDigestPreferenceServer({ + digestMode: effectiveDigestPreferenceMode, + preferredHistoryKind: recommendedKind, + importanceThreshold: digestPreferenceProfile?.importance_threshold || 70, + }); renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); }, }] @@ -1006,6 +1074,11 @@ function renderDecisionAssistant(summary) { label: `${item.kind} (${item.count})${item.recommended ? ' *' : ''}`, onClick: () => { persistAssistantHistoryKindFilter(item.kind || 'all'); + persistAssistantDigestPreferenceServer({ + digestMode: effectiveDigestPreferenceMode, + preferredHistoryKind: item.kind || null, + importanceThreshold: digestPreferenceProfile?.importance_threshold || 70, + }); renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); }, })), @@ -1013,14 +1086,14 @@ function renderDecisionAssistant(summary) { decisionAssistantPanel.appendChild(historyFilterBar); decisionAssistantPanel.appendChild( buildInfoGrid([ - ['active history focus', assistantHistoryKindFilter || 'all'], + ['active history focus', activeHistoryFilter || 'all'], ['recommended history focus', recommendedKind || 'n/a'], ]) ); const groupGrid = createElement('div', 'cards assistant-history-grid'); - const visibleGroups = assistantHistoryKindFilter === 'all' + const visibleGroups = activeHistoryFilter === 'all' ? digestHistoryGroups - : digestHistoryGroups.filter((item) => String(item.kind || '') === assistantHistoryKindFilter); + : digestHistoryGroups.filter((item) => String(item.kind || '') === activeHistoryFilter); visibleGroups.forEach((item) => { const card = buildItemCard( item.recommended ? `Recommended ${item.kind || 'history'}` : `History: ${item.kind || 'history'}`, @@ -1845,9 +1918,9 @@ function renderDecisionAssistant(summary) { } } if (digestHistoryPreview.length) { - const filteredHistory = assistantHistoryKindFilter === 'all' + const filteredHistory = activeHistoryFilter === 'all' ? digestHistoryPreview - : digestHistoryPreview.filter((item) => String(item.kind || '') === assistantHistoryKindFilter); + : digestHistoryPreview.filter((item) => String(item.kind || '') === activeHistoryFilter); const historyGrid = createElement('div', 'cards assistant-history-grid'); filteredHistory.forEach((item) => { historyGrid.appendChild( diff --git a/tests/unit/test_api_server.py b/tests/unit/test_api_server.py index 3ba6454..99c98bc 100644 --- a/tests/unit/test_api_server.py +++ b/tests/unit/test_api_server.py @@ -119,6 +119,46 @@ class ApiServerRoutingTests(unittest.TestCase): self.assertEqual(payload["top_limit"], 3) self.assertEqual(payload["review_queue"][0]["queue_type"], "failed_retry") + def test_routes_trading_alert_digest_preferences(self) -> None: + get_status, get_payload = route_request( + "/trading/alerts/digest-preferences", + {"instrument_id": ["1"], "profile_key": ["local_operator"]}, + dependencies={ + "trading_alert_operator_digest_preferences": lambda **kwargs: { + "instrument_id": kwargs["instrument_id"], + "profile_key": kwargs["profile_key"], + "digest_mode": "high_value_only", + } + }, + ) + post_status, post_payload = route_request( + "/trading/alerts/digest-preferences", + {}, + method="POST", + body={ + "instrument_id": 1, + "profile_key": "local_operator", + "digest_mode": "risk_first", + "preferred_history_kind": "reminder", + "importance_threshold": 80, + }, + dependencies={ + "trading_alert_operator_digest_preferences_save": lambda **kwargs: { + "instrument_id": kwargs["instrument_id"], + "digest_mode": kwargs["digest_mode"], + "preferred_history_kind": kwargs["preferred_history_kind"], + "importance_threshold": kwargs["importance_threshold"], + "status": "saved", + } + }, + ) + + self.assertEqual(get_status, 200) + self.assertEqual(get_payload["digest_mode"], "high_value_only") + self.assertEqual(post_status, 200) + self.assertEqual(post_payload["digest_mode"], "risk_first") + self.assertEqual(post_payload["importance_threshold"], 80) + def test_routes_maintenance_summary(self) -> None: status, payload = route_request( "/trading/maintenance-summary", diff --git a/tests/unit/test_operator_workspace_service.py b/tests/unit/test_operator_workspace_service.py index 6b8ef13..faa1953 100644 --- a/tests/unit/test_operator_workspace_service.py +++ b/tests/unit/test_operator_workspace_service.py @@ -98,6 +98,7 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): research_council_service=_ResearchCouncilSummaryService(), exchange_status_provider=_ExchangeStatusProvider(), announcements_provider=_AnnouncementsProvider(), + decision_assistant_repository=_DecisionAssistantRepo(), ) summary = service.build_summary( @@ -347,6 +348,9 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("default_mode", summary["digest_surface"]["digest_preference_profile"]) self.assertIn("available_modes", summary["digest_surface"]["digest_preference_profile"]) self.assertIn("profile_reason", summary["digest_surface"]["digest_preference_profile"]) + self.assertIn("digest_history_archive", summary["digest_surface"]) + self.assertIn("digest_history_archive_count", summary["digest_surface"]) + self.assertIn("digest_persistence_status", summary["digest_surface"]) self.assertIn("next_high_value_change", summary["digest_surface"]) self.assertIn("digest_review_back_actions", summary["digest_surface"]) self.assertIn("digest_review_back_action_count", summary["digest_surface"]) @@ -849,5 +853,49 @@ class _AnnouncementsProvider: } +class _DecisionAssistantRepo: + def fetch_preference(self, **kwargs): + return { + "profile_key": "local_operator", + "instrument_id": kwargs["instrument_id"], + "digest_mode": "high_value_only", + "preferred_history_kind": "reminder", + "importance_threshold": 75, + "payload": {"source": "unit"}, + "updated_ts": "2026-05-12T02:05:00+00:00", + } + + def fetch_recent_digest_history(self, **kwargs): + return [ + { + "profile_key": "local_operator", + "instrument_id": kwargs["instrument_id"], + "digest_mode": "high_value_only", + "preferred_history_kind": "reminder", + "top_summary": "Review Candidate #18", + "snapshot_signature": "unit-history-signature", + "payload": { + "focus_mode": "review_top_setup", + "top_opportunity_title": "Candidate #18", + "next_digest_action_label": "Review reminder", + "high_value_reminder_count": 1, + "reminder_count": 2, + }, + "created_ts": "2026-05-12T02:06:00+00:00", + } + ] + + def fetch_latest_digest_history(self, **kwargs): + return self.fetch_recent_digest_history(**kwargs)[0] + + def insert_digest_history(self, **kwargs): + return { + "id": 9, + "instrument_id": kwargs["instrument_id"], + "snapshot_signature": kwargs["snapshot_signature"], + "created_ts": "2026-05-12T02:07:00+00:00", + } + + if __name__ == "__main__": unittest.main()