176 lines
5.9 KiB
Python
176 lines
5.9 KiB
Python
"""router.selflearning.status RPC handler."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from opensquilla.gateway.config import (
|
|
GatewayConfig,
|
|
RouterSelfLearningConfig,
|
|
)
|
|
from opensquilla.gateway.rpc import RpcContext, get_dispatcher
|
|
from opensquilla.gateway.rpc_router import _handle_selflearning_status
|
|
from opensquilla.gateway.scopes import METHOD_SCOPES, READ_SCOPE
|
|
from opensquilla.squilla_router.self_learning import encode_features, write_sample
|
|
from opensquilla.squilla_router.self_learning.promotion import write_active_atomic
|
|
from opensquilla.squilla_router.self_learning.schema import RouterTrainSample
|
|
from opensquilla.squilla_router.self_learning.state import TrainState, save_train_state
|
|
|
|
|
|
def _config(
|
|
*,
|
|
sl_enabled: bool,
|
|
dream_enabled: bool = False,
|
|
auto_schedule: bool = False,
|
|
) -> GatewayConfig:
|
|
cfg = GatewayConfig()
|
|
cfg.squilla_router.self_learning = RouterSelfLearningConfig(enabled=sl_enabled)
|
|
cfg.memory.dream.enabled = dream_enabled
|
|
cfg.memory.dream.auto_schedule = auto_schedule
|
|
return cfg
|
|
|
|
|
|
def _sample(i: int, *, complaint: bool = False) -> RouterTrainSample:
|
|
return RouterTrainSample(
|
|
session_key="s1",
|
|
turn_index=i,
|
|
ts=f"2026-06-01T00:00:{i:02d}Z",
|
|
feature_schema_version="v1",
|
|
features_390_b64=encode_features(np.zeros(390, np.float32)),
|
|
route_class="R0",
|
|
final_route_class="R1" if complaint else "R0",
|
|
complaint_detected=complaint,
|
|
)
|
|
|
|
|
|
async def test_status_disabled_is_minimal() -> None:
|
|
payload = await _handle_selflearning_status(
|
|
{}, RpcContext(conn_id="t", config=_config(sl_enabled=False))
|
|
)
|
|
assert payload["enabled"] is False
|
|
assert payload["trainingReachable"] is False
|
|
assert payload["samples"] is None and payload["gate"] is None
|
|
assert payload["activeModel"]["kind"] == "baseline"
|
|
|
|
|
|
async def test_status_flags_unreachable_training_when_dream_off(
|
|
tmp_path, monkeypatch
|
|
) -> None:
|
|
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path))
|
|
payload = await _handle_selflearning_status(
|
|
{},
|
|
RpcContext(conn_id="t", config=_config(sl_enabled=True, dream_enabled=False)),
|
|
)
|
|
assert payload["enabled"] is True
|
|
assert payload["trainingReachable"] is False
|
|
assert payload["dream"] == {
|
|
"enabled": False,
|
|
"autoSchedule": False,
|
|
"killSwitchActive": False,
|
|
}
|
|
|
|
|
|
async def test_status_reports_samples_gate_and_active_model(
|
|
tmp_path, monkeypatch
|
|
) -> None:
|
|
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path))
|
|
for i in range(5):
|
|
write_sample(_sample(i, complaint=(i < 2)), "main", home=tmp_path)
|
|
save_train_state(
|
|
TrainState(active_version="v7", promoted_at="2026-06-01T00:00:00Z"),
|
|
"main",
|
|
tmp_path,
|
|
)
|
|
write_active_atomic("learned/v7", tmp_path)
|
|
|
|
payload = await _handle_selflearning_status(
|
|
{},
|
|
RpcContext(
|
|
conn_id="t",
|
|
config=_config(sl_enabled=True, dream_enabled=True, auto_schedule=True),
|
|
),
|
|
)
|
|
assert payload["trainingReachable"] is True
|
|
assert payload["samples"]["total"] == 5
|
|
assert payload["samples"]["highValue"] == 2
|
|
assert payload["gate"]["wouldTrain"] is False # nowhere near the volume gate
|
|
# Verbatim gate reason codes are a client contract (localized in the UI).
|
|
# The samples are dated 2026-06-01 (idle gate passes), and 2 high-value
|
|
# samples are far below the 200 default -> the volume gate trips.
|
|
assert payload["gate"]["reason"] == "insufficient_data"
|
|
assert payload["activeModel"] == {
|
|
"kind": "learned",
|
|
"version": "v7",
|
|
"promotedAt": "2026-06-01T00:00:00Z",
|
|
}
|
|
|
|
|
|
async def test_status_rejects_free_text_agent_id() -> None:
|
|
ctx = RpcContext(conn_id="t", config=_config(sl_enabled=False))
|
|
res = await get_dispatcher().dispatch(
|
|
"r1", "router.selflearning.status", {"agentId": "not a token!!"}, ctx
|
|
)
|
|
assert res.error is not None
|
|
|
|
|
|
def test_status_scope_is_read() -> None:
|
|
assert METHOD_SCOPES["router.selflearning.status"] == READ_SCOPE
|
|
assert "router.selflearning.status" in get_dispatcher().methods()
|
|
|
|
|
|
async def test_status_never_errors_on_broken_state(tmp_path, monkeypatch) -> None:
|
|
"""A corrupt state file degrades to a partial payload, not an RPC error."""
|
|
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path))
|
|
state_dir = tmp_path / "router" / "data" / "main"
|
|
state_dir.mkdir(parents=True)
|
|
(state_dir / ".train_state.json").write_text("{not json", encoding="utf-8")
|
|
(state_dir / "samples-2026-06.jsonl").write_text("{also broken\n", encoding="utf-8")
|
|
|
|
payload = await _handle_selflearning_status(
|
|
{},
|
|
RpcContext(
|
|
conn_id="t",
|
|
config=_config(sl_enabled=True, dream_enabled=True, auto_schedule=True),
|
|
),
|
|
)
|
|
assert payload["enabled"] is True # degraded, never raised
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
pytest.main([__file__, "-q"])
|
|
|
|
|
|
async def test_status_includes_feedback_block(tmp_path, monkeypatch) -> None:
|
|
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path))
|
|
from opensquilla.squilla_router.self_learning.feedback import write_feedback
|
|
|
|
for i in range(5):
|
|
write_sample(_sample(i), "main", home=tmp_path)
|
|
write_feedback(
|
|
"main",
|
|
decision_id="f1",
|
|
session_key="agent:main:webchat:s1",
|
|
turn_index=0,
|
|
rating="down",
|
|
home=tmp_path,
|
|
)
|
|
write_feedback(
|
|
"main",
|
|
decision_id="f2",
|
|
session_key="agent:main:webchat:s1",
|
|
turn_index=1,
|
|
rating="down",
|
|
executed_kind="ensemble",
|
|
home=tmp_path,
|
|
)
|
|
|
|
payload = await _handle_selflearning_status(
|
|
{},
|
|
RpcContext(
|
|
conn_id="t",
|
|
config=_config(sl_enabled=True, dream_enabled=True, auto_schedule=True),
|
|
),
|
|
)
|
|
assert payload["samples"]["feedback"] == {"up": 0, "down": 2, "downSingle": 1}
|