agent_panel: alerts use the macro persona's verdict; all 4 takes still logged
agent_panel.verdict_source defaults to "macro": the macro take's stance is the verdict, the synthesizer call is skipped (4 LLM calls per panel instead of 5); "chair" restores the four-voice consensus + OPPOSE guard. Verdict source is cached per mode and logged (panel_verdict_source). 406 tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC4s6VCURFte4dub4xKxYL
This commit is contained in:
+47
-13
@@ -7,6 +7,13 @@ SignalReport from their own angle, then a synthesizer chair weighs the
|
||||
takes and issues a consensus verdict (AGREE / NEUTRAL / OPPOSE with
|
||||
confidence and a short rationale).
|
||||
|
||||
Verdict source (2026-09-03, owner decision): all four personas always
|
||||
run and are logged, but by default (``agent_panel.verdict_source =
|
||||
"macro"``) the verdict shown to consumers is the macro persona's own
|
||||
stance, and the synthesizer call is skipped (4 LLM calls per panel
|
||||
instead of 5); ``"chair"`` restores the synthesizer + OPPOSE-guard path
|
||||
below.
|
||||
|
||||
Two invariants, both load-bearing (CLAUDE.md rules 1 and 4):
|
||||
|
||||
- ADVISORY / DISPLAY-ONLY. The verdict is written onto SignalReport
|
||||
@@ -80,6 +87,11 @@ DEFAULT_MAX_PANELS_PER_DAY = 20
|
||||
DEFAULT_REUSE_TTL_SECONDS = 3600.0
|
||||
DEFAULT_CANDLE_BARS_PRIMARY = 20
|
||||
DEFAULT_CANDLE_BARS_CONFIRM = 10
|
||||
DEFAULT_VERDICT_SOURCE = "macro"
|
||||
|
||||
# Maps a persona's stance straight onto the verdict vocabulary when the
|
||||
# macro take stands in for the chair.
|
||||
_STANCE_TO_VERDICT = {"support": "AGREE", "neutral": "NEUTRAL", "oppose": "OPPOSE"}
|
||||
|
||||
# Quality ranks for the min_quality gate. Deliberately a local copy —
|
||||
# telegram_bot's ranking is private and dashboard_publisher must not
|
||||
@@ -294,6 +306,7 @@ class PanelVerdict:
|
||||
takes: List[PersonaTake] = field(default_factory=list)
|
||||
created_ts: float = 0.0
|
||||
payload_hash: str = ""
|
||||
source: str = "chair" # "macro" | "chair" — which path produced this
|
||||
|
||||
|
||||
# ── Payload builders (pure functions; llm_client json-dumps sorted) ──
|
||||
@@ -719,10 +732,14 @@ def run_panel(report: "SignalReport",
|
||||
context: Optional["FundamentalContext"] = None,
|
||||
candles: Optional[Dict[str, Any]] = None,
|
||||
jobs: Optional[Tuple[Tuple[str, str, Dict[str, Any]], ...]] = None,
|
||||
verdict_source: str = DEFAULT_VERDICT_SOURCE,
|
||||
) -> Optional[PanelVerdict]:
|
||||
"""Run 4 personas + synthesizer; None = no verdict (fail-safe).
|
||||
"""Run 4 personas, then either take the macro persona's stance as the
|
||||
verdict (``verdict_source="macro"``, default — the synthesizer is not
|
||||
called) or weigh all four takes through the synthesizer chair
|
||||
(``"chair"``); None = no verdict (fail-safe).
|
||||
|
||||
Requires ≥2 valid persona takes before spending the synthesizer
|
||||
Requires ≥2 valid persona takes before spending a synthesizer
|
||||
request; the synthesizer's reply is validated strictly and there is
|
||||
deliberately NO programmatic fallback verdict — a panel that could
|
||||
not really convene reports nothing rather than something invented.
|
||||
@@ -747,6 +764,15 @@ def run_panel(report: "SignalReport",
|
||||
return None
|
||||
order = {"macro": 0, "technical": 1, "risk": 2, "contrarian": 3}
|
||||
takes.sort(key=lambda t: order.get(t.persona, 9))
|
||||
if verdict_source == "macro":
|
||||
macro = next((t for t in takes if t.persona == "macro"), None)
|
||||
if macro is not None:
|
||||
return PanelVerdict(
|
||||
verdict=_STANCE_TO_VERDICT[macro.stance],
|
||||
confidence=macro.confidence, rationale=macro.note[:240],
|
||||
takes=takes, created_ts=time.time(),
|
||||
payload_hash=payload_hash, source="macro")
|
||||
# Macro take missing (timed out / invalid) — fall back to the chair.
|
||||
reply = get_llm_client().chat_json(_SYNTH_SYSTEM, {
|
||||
"direction": direction,
|
||||
"takes": [{"persona": t.persona, "stance": t.stance,
|
||||
@@ -764,7 +790,8 @@ def run_panel(report: "SignalReport",
|
||||
verdict, confidence, rationale, takes)
|
||||
return PanelVerdict(verdict=verdict, confidence=confidence,
|
||||
rationale=rationale, takes=takes,
|
||||
created_ts=time.time(), payload_hash=payload_hash)
|
||||
created_ts=time.time(), payload_hash=payload_hash,
|
||||
source="chair")
|
||||
|
||||
|
||||
# Deterministic backstop for the chair (ADVISORY-ONLY, no gate reads it).
|
||||
@@ -807,13 +834,15 @@ def _apply_oppose_guard(verdict: str, confidence: float, rationale: str,
|
||||
_state_lock = threading.Lock()
|
||||
# Exact result per reviewed bar: repeated attaches on the same signal
|
||||
# (re-scan, multi-consumer) are free.
|
||||
_cache: "OrderedDict[Tuple[str, str, str, str, str], PanelVerdict]" = OrderedDict()
|
||||
_cache: "OrderedDict[Tuple[str, str, str, str, str, str], PanelVerdict]" = OrderedDict()
|
||||
_CACHE_MAX = 32
|
||||
# Reuse only when the full prompt + evidence payload is byte-equivalent.
|
||||
# Direction-only reuse served stale warnings/setups to materially changed
|
||||
# signals; the daily panel budget remains the hard spend bound.
|
||||
_last_by_payload: Dict[str, PanelVerdict] = {}
|
||||
_inflight: Dict[str, threading.Event] = {}
|
||||
# signals; the daily panel budget remains the hard spend bound. Keyed by
|
||||
# (reuse_hash, verdict_source) so a macro-mode verdict is never served to
|
||||
# a chair-mode attach (and vice versa).
|
||||
_last_by_payload: Dict[Tuple[str, str], PanelVerdict] = {}
|
||||
_inflight: Dict[Any, threading.Event] = {}
|
||||
# Per-process UTC-day budget of panel attempts (mirrors llm_client._under_cap).
|
||||
_panel_day: Optional[_dt.date] = None
|
||||
_panel_count: int = 0
|
||||
@@ -877,6 +906,7 @@ def _write_fields(
|
||||
v.created_ts, tz=_dt.timezone.utc).isoformat()
|
||||
report.panel_cache_status = cache_status
|
||||
report.panel_payload_hash = v.payload_hash
|
||||
report.panel_verdict_source = v.source
|
||||
|
||||
|
||||
def _log_panel_verdict(report: "SignalReport") -> None:
|
||||
@@ -921,6 +951,8 @@ def attach_panel(report: "SignalReport",
|
||||
if (_QUALITY_RANK.get(report.signal_quality, -1)
|
||||
< _QUALITY_RANK.get(min_quality, 2)):
|
||||
return False
|
||||
verdict_source = str(settings.get("verdict_source",
|
||||
DEFAULT_VERDICT_SOURCE))
|
||||
direction = _direction(report)
|
||||
if direction is None:
|
||||
return False
|
||||
@@ -944,10 +976,11 @@ def attach_panel(report: "SignalReport",
|
||||
analysis = report.primary_analysis
|
||||
tf = analysis.timeframe if analysis else ""
|
||||
exact_key = (report.symbol, tf, report.timestamp, direction,
|
||||
payload_hash)
|
||||
payload_hash, verdict_source)
|
||||
reuse_key = (reuse_hash, verdict_source)
|
||||
ttl = float(settings.get("reuse_ttl_seconds",
|
||||
DEFAULT_REUSE_TTL_SECONDS))
|
||||
inflight_key = reuse_hash if ttl > 0 else payload_hash
|
||||
inflight_key = reuse_key if ttl > 0 else payload_hash
|
||||
hit = None
|
||||
cache_status = ""
|
||||
leader = False
|
||||
@@ -957,7 +990,7 @@ def attach_panel(report: "SignalReport",
|
||||
if hit is not None:
|
||||
cache_status = "EXACT_HIT"
|
||||
elif ttl > 0:
|
||||
prior = _last_by_payload.get(reuse_hash)
|
||||
prior = _last_by_payload.get(reuse_key)
|
||||
if prior is not None and time.time() - prior.created_ts < ttl:
|
||||
hit = prior
|
||||
cache_status = "TTL_REUSE"
|
||||
@@ -984,7 +1017,7 @@ def attach_panel(report: "SignalReport",
|
||||
hit = _cache.get(exact_key)
|
||||
cache_status = "EXACT_HIT"
|
||||
if hit is None and ttl > 0:
|
||||
hit = _last_by_payload.get(reuse_hash)
|
||||
hit = _last_by_payload.get(reuse_key)
|
||||
cache_status = "TTL_REUSE"
|
||||
if hit is None:
|
||||
return False
|
||||
@@ -998,7 +1031,8 @@ def attach_panel(report: "SignalReport",
|
||||
if not _under_panel_budget(cap):
|
||||
return False
|
||||
|
||||
verdict = run_panel(report, context, candles=candles, jobs=jobs)
|
||||
verdict = run_panel(report, context, candles=candles, jobs=jobs,
|
||||
verdict_source=verdict_source)
|
||||
if verdict is None:
|
||||
return False
|
||||
_write_fields(report, verdict, "MISS")
|
||||
@@ -1006,7 +1040,7 @@ def attach_panel(report: "SignalReport",
|
||||
_cache[exact_key] = verdict
|
||||
while len(_cache) > _CACHE_MAX:
|
||||
_cache.popitem(last=False)
|
||||
_last_by_payload[reuse_hash] = verdict
|
||||
_last_by_payload[reuse_key] = verdict
|
||||
_log_panel_verdict(report)
|
||||
return True
|
||||
finally:
|
||||
|
||||
Reference in New Issue
Block a user