195 lines
6.2 KiB
Python
195 lines
6.2 KiB
Python
"""Feedback sidecar: write/merge/revoke/stats/retention semantics."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
from opensquilla.squilla_router.self_learning.feedback import (
|
|
FeedbackStats,
|
|
feedback_path,
|
|
load_feedback_map,
|
|
scan_feedback_stats,
|
|
write_feedback,
|
|
)
|
|
|
|
NOW = datetime(2026, 7, 9, 12, 0, 0, tzinfo=UTC)
|
|
|
|
|
|
def _write(
|
|
tmp_path, decision_id, rating, *,
|
|
turn=0, kind="single", now=NOW, session="agent:main:webchat:s1",
|
|
):
|
|
return write_feedback(
|
|
"main",
|
|
decision_id=decision_id,
|
|
session_key=session,
|
|
turn_index=turn,
|
|
rating=rating,
|
|
executed_kind=kind,
|
|
home=tmp_path,
|
|
now=now,
|
|
)
|
|
|
|
|
|
def test_write_and_load_roundtrip(tmp_path) -> None:
|
|
_write(tmp_path, "d1", "down")
|
|
_write(tmp_path, "d2", "up", turn=1)
|
|
|
|
fb = load_feedback_map("main", home=tmp_path)
|
|
assert fb["d1"].rating == "down"
|
|
assert fb["d2"].rating == "up"
|
|
assert all(e.executed_kind == "single" for e in fb.values())
|
|
|
|
|
|
def test_last_write_wins_per_decision(tmp_path) -> None:
|
|
_write(tmp_path, "d1", "down")
|
|
_write(tmp_path, "d1", "up")
|
|
|
|
fb = load_feedback_map("main", home=tmp_path)
|
|
assert fb["d1"].rating == "up"
|
|
|
|
|
|
def test_neutral_revokes(tmp_path) -> None:
|
|
_write(tmp_path, "d1", "down")
|
|
_write(tmp_path, "d1", "neutral")
|
|
|
|
assert load_feedback_map("main", home=tmp_path) == {}
|
|
# The audit trail keeps both rows.
|
|
lines = feedback_path("main", tmp_path).read_text().splitlines()
|
|
assert len(lines) == 2
|
|
|
|
|
|
def test_ensemble_kind_preserved_and_stats_split(tmp_path) -> None:
|
|
_write(tmp_path, "d1", "down", turn=0, kind="single")
|
|
_write(tmp_path, "d2", "down", turn=1, kind="ensemble")
|
|
_write(tmp_path, "d3", "up", turn=2, kind="ensemble")
|
|
|
|
fb = load_feedback_map("main", home=tmp_path)
|
|
assert fb["d2"].executed_kind == "ensemble"
|
|
|
|
stats = scan_feedback_stats("main", home=tmp_path)
|
|
assert stats == FeedbackStats(total=3, up=1, down=2, total_single=1, down_single=1)
|
|
# Rate slices numerator AND denominator to single-model ratings.
|
|
assert stats.downvote_rate == 1.0
|
|
|
|
|
|
def test_stats_since_ts_window(tmp_path) -> None:
|
|
early = datetime(2026, 7, 1, 0, 0, 0, tzinfo=UTC)
|
|
_write(tmp_path, "d1", "down", now=early)
|
|
_write(tmp_path, "d2", "down", turn=1, now=NOW)
|
|
|
|
post = scan_feedback_stats("main", since_ts="2026-07-05T00:00:00Z", home=tmp_path)
|
|
assert post.down == 1 # only the recent one
|
|
|
|
# A pre-window rating revised inside the window counts (revision is the
|
|
# operative judgment).
|
|
_write(tmp_path, "d1", "up", now=NOW)
|
|
post2 = scan_feedback_stats("main", since_ts="2026-07-05T00:00:00Z", home=tmp_path)
|
|
assert post2.up == 1 and post2.down == 1
|
|
|
|
|
|
def test_retention_prunes_old_rows(tmp_path) -> None:
|
|
old = datetime(2026, 5, 1, 0, 0, 0, tzinfo=UTC)
|
|
_write(tmp_path, "dOld", "down", now=old)
|
|
# The next write (retention_days=30 vs a 69-day-old row) prunes it.
|
|
_write(tmp_path, "dNew", "up", turn=1, now=NOW)
|
|
|
|
fb = load_feedback_map("main", home=tmp_path)
|
|
assert "dOld" not in fb
|
|
assert fb["dNew"].rating == "up"
|
|
|
|
|
|
def test_corrupt_lines_are_skipped(tmp_path) -> None:
|
|
_write(tmp_path, "d1", "down")
|
|
path = feedback_path("main", tmp_path)
|
|
with path.open("a", encoding="utf-8") as fh:
|
|
fh.write("{broken json\n")
|
|
fh.write("[]\n") # valid JSON, wrong shape
|
|
|
|
fb = load_feedback_map("main", home=tmp_path)
|
|
assert fb["d1"].rating == "down"
|
|
|
|
|
|
def test_invalid_rating_rejected(tmp_path) -> None:
|
|
import pytest
|
|
|
|
with pytest.raises(ValueError):
|
|
_write(tmp_path, "d1", "amazing")
|
|
|
|
|
|
def test_unknown_executed_kind_coerces_to_single(tmp_path) -> None:
|
|
_write(tmp_path, "d1", "down", kind="mystery")
|
|
fb = load_feedback_map("main", home=tmp_path)
|
|
assert fb["d1"].executed_kind == "single"
|
|
|
|
|
|
def test_rollback_window_keys_on_decision_ts_not_rating_ts(tmp_path) -> None:
|
|
"""A post-promotion rating of a PRE-promotion decision must not count
|
|
against the newly promoted classifier."""
|
|
promo_ts = "2026-07-05T00:00:00Z"
|
|
# Decision made before promotion, rated after.
|
|
write_feedback(
|
|
"main",
|
|
decision_id="old-turn",
|
|
session_key="agent:main:webchat:s1",
|
|
turn_index=0,
|
|
rating="down",
|
|
decision_ts="2026-07-01T00:00:00Z",
|
|
home=tmp_path,
|
|
now=NOW, # rating arrives 2026-07-09, after promotion
|
|
)
|
|
# Decision made after promotion, rated after.
|
|
write_feedback(
|
|
"main",
|
|
decision_id="new-turn",
|
|
session_key="agent:main:webchat:s1",
|
|
turn_index=1,
|
|
rating="down",
|
|
decision_ts="2026-07-06T00:00:00Z",
|
|
home=tmp_path,
|
|
now=NOW,
|
|
)
|
|
|
|
post = scan_feedback_stats("main", since_ts=promo_ts, home=tmp_path)
|
|
assert post.down == 1 # only the new-model decision counts
|
|
|
|
|
|
def test_retention_keys_on_rating_ts(tmp_path) -> None:
|
|
"""Rating an old-but-valid decision must survive the retention prune."""
|
|
old_decision = "2026-05-01T00:00:00Z" # decision older than retention
|
|
_write(tmp_path, "d1", "down", now=NOW) # trigger prune context
|
|
write_feedback(
|
|
"main",
|
|
decision_id="d2",
|
|
session_key="agent:main:webchat:s1",
|
|
turn_index=1,
|
|
rating="down",
|
|
decision_ts=old_decision,
|
|
home=tmp_path,
|
|
now=NOW, # the rating itself is fresh
|
|
)
|
|
fb = load_feedback_map("main", home=tmp_path)
|
|
assert "d2" in fb # not pruned despite the old decision
|
|
|
|
|
|
def test_concurrent_writes_with_prune_lose_nothing(tmp_path) -> None:
|
|
"""The write lock serializes append+prune; parallel ratings all survive."""
|
|
import threading
|
|
from datetime import timedelta
|
|
|
|
# Seed one expired row so every write triggers a real prune rewrite.
|
|
_write(tmp_path, "expired", "down", now=NOW - timedelta(days=40))
|
|
|
|
def submit(i: int) -> None:
|
|
_write(tmp_path, f"c{i}", "up", turn=i, now=NOW)
|
|
|
|
threads = [threading.Thread(target=submit, args=(i,)) for i in range(16)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
|
|
fb = load_feedback_map("main", home=tmp_path)
|
|
assert "expired" not in fb
|
|
assert all(f"c{i}" in fb for i in range(16)) # no rating lost
|