feat(realism): operator-tunable planner weights via realism_config
New realism_config table (uuid PK + unique key) + two repo methods (get/set) backs an admin-only GET/PUT /api/v1/realism/config surface. The planner now exposes apply_payload(payload) / current_payload() / reset_to_defaults() and reads its weights through mutable module globals; pick() resolves the live values each call. Validation catches negative weights, zero totals, out-of-range canary_probability, unknown content_class names, and silently drops cross-list entries (canary class on the user list, etc). The orchestrator worker calls _refresh_realism_config(repo) on startup and every 5 ticks (~5min at 60s interval). Operator changes land within one refresh window with no bus signal — the simpler path for a knob whose latency tolerance is minutes.
This commit is contained in:
@@ -39,6 +39,7 @@ from decnet.orchestrator.emailgen import (
|
||||
scheduler as email_scheduler,
|
||||
)
|
||||
from decnet.orchestrator.emailgen.scheduler import EmailAction
|
||||
from decnet.realism import planner as realism_planner
|
||||
from decnet.realism.llm.circuit import LLMCircuitBreaker
|
||||
from decnet.web.db.repository import BaseRepository
|
||||
|
||||
@@ -52,6 +53,12 @@ _PRUNE_EVERY_TICKS = 100
|
||||
_PRUNE_PER_DST_CAP = 10000
|
||||
_PRUNE_PER_MAIL_DECKY_CAP = 5000
|
||||
|
||||
# Refresh planner weights from realism_config every N ticks. Operator
|
||||
# tunables drift slowly; ~minute-scale latency between PUT and effect
|
||||
# is fine. No bus signal — keeps the path simple and the orchestrator
|
||||
# self-contained.
|
||||
_REALISM_CONFIG_REFRESH_TICKS = 5
|
||||
|
||||
# Action-kind weights for the per-tick roll. Email is rare because
|
||||
# each LLM round-trip is expensive (~seconds) and the prior emailgen
|
||||
# worker only ticked every 5 minutes. At a 60s orchestrator interval,
|
||||
@@ -115,6 +122,12 @@ async def orchestrator_worker(
|
||||
)
|
||||
bus = None
|
||||
|
||||
# Initial load — pulls the operator-tuned weights from
|
||||
# realism_config so the orchestrator starts ticking with the
|
||||
# operator's intent rather than the baked-in defaults. A failure
|
||||
# here logs and falls through; the planner already holds defaults.
|
||||
await _refresh_realism_config(repo)
|
||||
|
||||
shutdown = asyncio.Event()
|
||||
heartbeat_task = asyncio.create_task(
|
||||
run_health_heartbeat(
|
||||
@@ -141,6 +154,8 @@ async def orchestrator_worker(
|
||||
tick_n += 1
|
||||
if tick_n % _PRUNE_EVERY_TICKS == 0:
|
||||
await _periodic_prune(repo)
|
||||
if tick_n % _REALISM_CONFIG_REFRESH_TICKS == 0:
|
||||
await _refresh_realism_config(repo)
|
||||
finally:
|
||||
for t in (heartbeat_task, control_task):
|
||||
t.cancel()
|
||||
@@ -174,6 +189,35 @@ async def _periodic_prune(repo: BaseRepository) -> None:
|
||||
logger.error("orchestrator emails prune failed: %s", exc)
|
||||
|
||||
|
||||
async def _refresh_realism_config(repo: BaseRepository) -> None:
|
||||
"""Pull operator-tuned weights from realism_config into the planner.
|
||||
|
||||
Failure modes (DB unreachable, malformed JSON, validation reject)
|
||||
log and leave the planner's current weights in place. The orchestrator
|
||||
keeps ticking with whatever it had — never blocks on config.
|
||||
"""
|
||||
try:
|
||||
row = await repo.get_realism_config("weights")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("realism config refresh: DB read failed: %s", exc)
|
||||
return
|
||||
if row is None:
|
||||
return # no overrides set; defaults stand
|
||||
import json
|
||||
try:
|
||||
payload = json.loads(row.get("value") or "{}")
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("realism config refresh: malformed JSON: %s", exc)
|
||||
return
|
||||
if not isinstance(payload, dict):
|
||||
logger.warning("realism config refresh: payload not an object")
|
||||
return
|
||||
try:
|
||||
realism_planner.apply_payload(payload)
|
||||
except ValueError as exc:
|
||||
logger.warning("realism config refresh: rejected payload: %s", exc)
|
||||
|
||||
|
||||
def _roll_action_kind(rng: secrets.SystemRandom) -> str:
|
||||
total = sum(w for _, w in _ACTION_WEIGHTS)
|
||||
target = rng.randint(1, total)
|
||||
|
||||
Reference in New Issue
Block a user