rayhpeng 793169529c refactor(feedback): align the slice with the hexagonal spec
- command-ify the write use cases (RateRun / RetractRunRating; queries
  keep plain parameters, commands stay dumb data)
- split the domain errors into exceptions.py, a peer of model.py
  (PEP 8 Error suffixes, AWS-style module name)
- unify the aggregate->row mapping as _apply(row, feedback) so one
  explicit field list serves both the insert and the update path
- drop the unused feedback.message_id column (migration 0011): feedback
  is bound to a run, nothing ever wrote or read the field
- pin remove_for_run's equality semantics for user_id=None in the
  contract suite and fix the port docstring that contradicted both
  implementations
2026-07-29 18:28:54 +08:00

145 lines
5.1 KiB
Python

"""Feedback endpoints — rate a run, retract a rating.
Thumbs-up/down on a run, aligned with the mainstream chat UX: PUT is an
idempotent upsert ("my current verdict is X"), DELETE retracts it
(clicking the active button again). The current rating is echoed back to
the UI inside the message-list payload, not through a separate read
endpoint.
Primary adapter only: resolves the current user, delegates to
``FeedbackService`` (input port), and maps domain errors to HTTP codes.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.authz import require_permission
from app.gateway.deps import get_current_user, get_feedback_service
from deerflow.domain.feedback import (
DuplicateFeedbackError,
Feedback,
InvalidRatingError,
InvalidTagError,
RateRun,
RetractRunRating,
RunNotFoundError,
)
from deerflow.utils.time import coerce_iso
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/threads", tags=["feedback"])
# ---------------------------------------------------------------------------
# Request / response models
# ---------------------------------------------------------------------------
class RateRunRequest(BaseModel):
"""Request model of the RateRun use case (name = command name + Request).
Carries only what the client may supply: identity (user_id) never
appears on a request model -- it is resolved server-side and injected
through ``to_command``.
"""
rating: int = Field(..., description="Feedback rating: +1 (positive) or -1 (negative)")
comment: str | None = Field(default=None, description="Optional text feedback")
tags: list[str] = Field(
default_factory=list,
description="Optional thumbs-down reason slugs (e.g. 'incorrect', 'slow')",
)
def to_command(self, thread_id: str, run_id: str, user_id: str | None) -> RateRun:
"""Body + path + resolved identity -> the use-case command.
The wire shape owns the inbound translation into domain vocabulary
(wire types become domain types here, e.g. list -> tuple).
"""
return RateRun(
thread_id=thread_id,
run_id=run_id,
rating=self.rating,
user_id=user_id,
comment=self.comment,
tags=tuple(self.tags),
)
class FeedbackResponse(BaseModel):
feedback_id: str
run_id: str
thread_id: str
user_id: str | None = None
rating: int
comment: str | None = None
tags: list[str] = []
created_at: str = ""
@classmethod
def from_domain(cls, feedback: Feedback) -> FeedbackResponse:
"""Domain object -> wire shape (allowlist). ``created_at`` keeps the
legacy ``coerce_iso`` serialization so the API output is
byte-identical."""
return cls(
feedback_id=feedback.feedback_id,
run_id=feedback.run_id,
thread_id=feedback.thread_id,
user_id=feedback.user_id,
rating=feedback.rating,
comment=feedback.comment,
tags=list(feedback.tags),
created_at=coerce_iso(feedback.created_at),
)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.put("/{thread_id}/runs/{run_id}/feedback", response_model=FeedbackResponse)
@require_permission("threads", "write", owner_check=True, require_existing=True)
async def upsert_feedback(
thread_id: str,
run_id: str,
body: RateRunRequest,
request: Request,
) -> FeedbackResponse:
"""Set the current user's rating for a run (idempotent upsert)."""
user_id = await get_current_user(request)
service = get_feedback_service(request)
try:
feedback = await service.rate_run(body.to_command(thread_id, run_id, user_id))
except InvalidRatingError:
raise HTTPException(status_code=400, detail="rating must be +1 or -1")
except InvalidTagError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except RunNotFoundError:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found in thread {thread_id}")
except DuplicateFeedbackError:
# Lost a concurrent-upsert race (legacy behavior was a 500); the
# client can simply retry.
raise HTTPException(status_code=409, detail="Concurrent feedback update, please retry")
return FeedbackResponse.from_domain(feedback)
@router.delete("/{thread_id}/runs/{run_id}/feedback")
@require_permission("threads", "delete", owner_check=True, require_existing=True)
async def delete_run_feedback(
thread_id: str,
run_id: str,
request: Request,
) -> dict[str, bool]:
"""Retract the current user's rating for a run."""
user_id = await get_current_user(request)
service = get_feedback_service(request)
retracted = await service.retract_run_rating(RetractRunRating(thread_id=thread_id, run_id=run_id, user_id=user_id))
if not retracted:
raise HTTPException(status_code=404, detail="No feedback found for this run")
return {"success": True}