Files
DECNET/decnet/web/router/realism/api_config.py
anti f2b3393669 chore: relicense to AGPL-3.0-or-later and add SPDX headers
Replaces LICENSE (GPLv3 -> AGPLv3) and prepends
`SPDX-License-Identifier: AGPL-3.0-or-later` to every source file
across decnet/, decnet_web/, tests/, scripts/, and tools/.

Rationale: closes the GPLv3 ASP loophole so any party operating a
modified DECNET as a network service must offer their modified
source. Personal copyright (Samuel Paschuan) + inbound=outbound
contributions make a future unilateral relicense infeasible.

- LICENSE: full AGPL-3.0 text (gnu.org/licenses/agpl-3.0.txt)
- COPYRIGHT: project copyright notice
- tools/add_spdx_headers.py: idempotent header injector
  (shebang- and PEP 263-aware)

Touches 1565 source files (.py, .ts, .tsx, .js, .jsx, .css, .sh).
No behavior change; comments only.
2026-05-22 21:04:16 -04:00

128 lines
4.3 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
"""GET/PUT ``/api/v1/realism/config`` — operator-tunable realism knobs.
Today only the planner's content-class weights + canary probability
are exposed. The wire shape mirrors what
:func:`decnet.realism.planner.current_payload` produces and
:func:`decnet.realism.planner.apply_payload` consumes.
Reads accept viewer; writes are admin (writes mutate sampling
behaviour across the whole orchestrator fleet, same trust level as
the persona-pool surface).
The orchestrator worker periodically re-loads from the
``realism_config`` table; the API process applies overrides locally
on PUT so the GET-after-PUT round-trip reflects the change without
waiting for the orchestrator's next refresh tick.
"""
from __future__ import annotations
import asyncio
import json
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from decnet.logging import get_logger
from decnet.realism import planner
from decnet.telemetry import traced as _traced
from decnet.web.dependencies import repo, require_admin, require_viewer
router = APIRouter()
log = get_logger("api.realism.config")
_CONFIG_KEY = "weights"
_hydrated = False
_hydrate_lock = asyncio.Lock()
@router.get(
"/realism/config",
tags=["Realism"],
responses={
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
},
)
@_traced("api.realism.get_config")
async def get_config(
user: dict = Depends(require_viewer),
) -> dict[str, Any]:
"""Return the live planner config in this API process.
Note: the API process and the orchestrator worker each carry their
own in-memory copy of the planner config. After a fresh API
restart the ``realism_config`` row is loaded into this process the
first time GET is called; subsequent reads are local.
"""
global _hydrated
if not _hydrated:
async with _hydrate_lock:
if not _hydrated:
row = await repo.get_realism_config(_CONFIG_KEY)
if row is not None:
try:
stored = json.loads(row.get("value") or "{}")
if isinstance(stored, dict):
planner.apply_payload(stored)
except (json.JSONDecodeError, ValueError) as exc:
log.warning(
"api.realism.get_config: stored payload invalid, "
"serving defaults: %s", exc,
)
_hydrated = True
return planner.current_payload()
@router.put(
"/realism/config",
tags=["Realism"],
responses={
400: {"description": "Invalid config payload"},
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
},
)
@_traced("api.realism.put_config")
async def put_config(
body: dict[str, Any],
user: dict = Depends(require_admin),
) -> dict[str, Any]:
"""Replace (partial) planner config and persist to ``realism_config``.
Body shape (all fields optional — unset fields keep current value):
* ``user_class_weights``: ``[{"content_class": "note", "weight": 30}, ...]``
* ``system_class_weights``: same shape
* ``canary_class_weights``: same shape
* ``canary_probability``: float in [0.0, 1.0]
Validation: any structural failure raises 400 *before* the rebind,
so the live config never goes torn.
"""
global _hydrated
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail="body must be an object")
try:
dropped = planner.apply_payload(body)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_hydrated = True
# Persist what the planner now reflects (keeps DB in sync with the
# in-memory state — partial bodies merge into prior config).
snapshot = planner.current_payload()
await repo.set_realism_config(_CONFIG_KEY, json.dumps(snapshot))
log.info(
"api.realism.put_config user=%s canary_probability=%.4f",
user.get("username", user.get("uuid")),
snapshot["canary_probability"],
)
response: dict[str, Any] = dict(snapshot)
if dropped:
response["dropped_entries"] = dropped
return response