Persist digest preferences and history

This commit is contained in:
Codex
2026-05-13 23:14:08 +08:00
parent 19d9d14cc6
commit ba9f24086a
11 changed files with 619 additions and 26 deletions
+8 -3
View File
@@ -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
+9 -1
View File
@@ -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
@@ -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);
+4
View File
@@ -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",
+43
View File
@@ -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()
+20
View File
@@ -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)
@@ -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)
@@ -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."
+86 -13
View File
@@ -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(
+40
View File
@@ -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",
@@ -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()