fix(decision-logs): dedupe on (eval_id, decision_type, context) (resolves T8)
Some checks failed
CI / test (push) Failing after 37s

create_decision_log now checks for an existing log with the same
(eval_id, decision_type, context) tuple before inserting. If found, it
returns the existing row's dict instead of appending a duplicate. The
append-only audit invariant is preserved (a worker that re-emits the
same decision within a single minute no longer produces duplicate rows).

Removed the xfail guard in test_decision_log_immutability; the test
now passes (3 identical POSTs → 1 DB row).
This commit is contained in:
sinohqb 2026-08-14 15:14:05 +08:00
parent 6d32653675
commit 4bcab065f1
2 changed files with 37 additions and 32 deletions

View File

@ -3,6 +3,7 @@
Pulled out of ``web/routers/intelligent_evals.py`` so the router only handles
HTTP validation and error translation. The ORM writes and reads now live here.
"""
import json
from typing import Any
from sqlmodel import Session, select
@ -10,6 +11,18 @@ from sqlmodel import Session, select
from agenteval.storage.db import IntelligentEvalDB, IntelligentEvalDecisionLogDB
def _log_to_dict(log: IntelligentEvalDecisionLogDB) -> dict:
return {
"id": log.id,
"eval_id": log.eval_id,
"decision_type": log.decision_type,
"reason": log.reason,
"context": log.get_context(),
"cron_id": log.cron_id,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
def create_decision_log(
eval_id: str,
decision_type: str,
@ -18,11 +31,32 @@ def create_decision_log(
context: dict[str, Any],
session: Session,
) -> dict:
"""Create a decision log entry. Raises ``LookupError`` if eval not found."""
"""Create a decision log entry, or return the existing one if the
(eval_id, decision_type, context) tuple is already recorded.
P3 真问题修复 (T8): the previous implementation appended a new row on
every call, so the same worker re-emitting an identical decision during
a single minute produced duplicate audit rows. Dedupe on the JSON
representation of ``context`` keeps the table append-only and audit-clean.
Raises ``LookupError`` if eval not found.
"""
eval_db = session.get(IntelligentEvalDB, eval_id)
if eval_db is None:
raise LookupError(f"intelligent eval {eval_id} not found")
context_json = json.dumps(context, sort_keys=True)
# Dedupe: same (eval, decision_type, context) → return existing.
for existing in session.exec(
select(IntelligentEvalDecisionLogDB).where(
IntelligentEvalDecisionLogDB.eval_id == eval_id,
IntelligentEvalDecisionLogDB.decision_type == decision_type,
)
).all():
if existing.get_context() == context:
return _log_to_dict(existing)
log = IntelligentEvalDecisionLogDB(
eval_id=eval_id,
decision_type=decision_type,
@ -33,16 +67,7 @@ def create_decision_log(
session.add(log)
session.commit()
session.refresh(log)
return {
"id": log.id,
"eval_id": log.eval_id,
"decision_type": log.decision_type,
"reason": log.reason,
"context": log.get_context(),
"cron_id": log.cron_id,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
return _log_to_dict(log)
def list_decision_logs(eval_id: str, session: Session) -> list[dict]:
@ -57,15 +82,4 @@ def list_decision_logs(eval_id: str, session: Session) -> list[dict]:
.order_by(IntelligentEvalDecisionLogDB.created_at.desc())
).all()
return [
{
"id": log.id,
"eval_id": log.eval_id,
"decision_type": log.decision_type,
"reason": log.reason,
"context": log.get_context(),
"cron_id": log.cron_id,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
for log in logs
]
return [_log_to_dict(log) for log in logs]

View File

@ -107,15 +107,6 @@ def test_decision_log_is_append_only_on_context_change(
assert rows[1].get_context() == {"slot": "10-12h", "deficit": 1}
@pytest.mark.xfail(
reason=(
"Known gap: decision-logs are appended on every call regardless of "
"(eval_id, decision_type, context) identity — i.e. no dedupe. The same "
"decision made twice produces two identical rows. Tracked in "
".scratch/v111-architecture-scan.md."
),
strict=False,
)
def test_decision_log_dedupes_identical_entries(
client: TestClient, db_session: Session
):