diff --git a/backend/agenteval/intelligent_eval/decision_logs.py b/backend/agenteval/intelligent_eval/decision_logs.py index 6c7f133..bb3744a 100644 --- a/backend/agenteval/intelligent_eval/decision_logs.py +++ b/backend/agenteval/intelligent_eval/decision_logs.py @@ -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] diff --git a/tests/integration/test_decision_log_immutability.py b/tests/integration/test_decision_log_immutability.py index 294f545..f4597be 100644 --- a/tests/integration/test_decision_log_immutability.py +++ b/tests/integration/test_decision_log_immutability.py @@ -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 ):