mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 22:20:33 +00:00
* fix(feedback): return 409 instead of 500 when feedback is POSTed twice
POST /api/threads/{id}/runs/{rid}/feedback did a plain INSERT. A second
POST by the same user for the same run (a retry, or feedback on another
message of that run) hit uq_feedback_thread_run_user and the uncaught
IntegrityError surfaced as HTTP 500.
FeedbackRepository.create now rolls back and raises
FeedbackAlreadyExistsError on that collision, and the route maps it to
409 pointing callers at PUT, which already upserts.
* fix(feedback): only map the run/user unique key to 409; cover concurrent POSTs
Address review on #8064:
- create() now re-reads the (thread, run, user) row after an
IntegrityError and raises FeedbackAlreadyExistsError only when it
exists; any other violation (e.g. a feedback_id collision) propagates.
- Add a commit-barrier test where two first POSTs race: one 200, one
409, and the winner's row is kept.
- Add CHANGELOG.md / CHANGELOG_zh.md Fixed entries.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
197 lines
6.8 KiB
Python
197 lines
6.8 KiB
Python
"""Feedback endpoints — create, list, stats, delete.
|
|
|
|
Allows users to submit thumbs-up/down feedback on runs,
|
|
optionally scoped to a specific message.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
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_repo, get_run_store
|
|
from deerflow.persistence.feedback import FeedbackAlreadyExistsError
|
|
from deerflow.utils.thread_id import ThreadId
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/threads", tags=["feedback"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Request / response models
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class FeedbackCreateRequest(BaseModel):
|
|
rating: int = Field(..., description="Feedback rating: +1 (positive) or -1 (negative)")
|
|
comment: str | None = Field(default=None, description="Optional text feedback")
|
|
message_id: str | None = Field(default=None, description="Optional: scope feedback to a specific message")
|
|
|
|
|
|
class FeedbackUpsertRequest(BaseModel):
|
|
rating: int = Field(..., description="Feedback rating: +1 (positive) or -1 (negative)")
|
|
comment: str | None = Field(default=None, description="Optional text feedback")
|
|
|
|
|
|
class FeedbackResponse(BaseModel):
|
|
feedback_id: str
|
|
run_id: str
|
|
thread_id: str
|
|
user_id: str | None = None
|
|
message_id: str | None = None
|
|
rating: int
|
|
comment: str | None = None
|
|
created_at: str = ""
|
|
|
|
|
|
class FeedbackStatsResponse(BaseModel):
|
|
run_id: str
|
|
total: int = 0
|
|
positive: int = 0
|
|
negative: int = 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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: ThreadId,
|
|
run_id: str,
|
|
body: FeedbackUpsertRequest,
|
|
request: Request,
|
|
) -> dict[str, Any]:
|
|
"""Create or update feedback for a run (idempotent)."""
|
|
if body.rating not in (1, -1):
|
|
raise HTTPException(status_code=400, detail="rating must be +1 or -1")
|
|
|
|
user_id = await get_current_user(request)
|
|
|
|
run_store = get_run_store(request)
|
|
run = await run_store.get(run_id)
|
|
if run is None:
|
|
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
|
if run.get("thread_id") != thread_id:
|
|
raise HTTPException(status_code=404, detail=f"Run {run_id} not found in thread {thread_id}")
|
|
|
|
feedback_repo = get_feedback_repo(request)
|
|
return await feedback_repo.upsert(
|
|
run_id=run_id,
|
|
thread_id=thread_id,
|
|
rating=body.rating,
|
|
user_id=user_id,
|
|
comment=body.comment,
|
|
)
|
|
|
|
|
|
@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: ThreadId,
|
|
run_id: str,
|
|
request: Request,
|
|
) -> dict[str, bool]:
|
|
"""Delete the current user's feedback for a run."""
|
|
user_id = await get_current_user(request)
|
|
feedback_repo = get_feedback_repo(request)
|
|
deleted = await feedback_repo.delete_by_run(
|
|
thread_id=thread_id,
|
|
run_id=run_id,
|
|
user_id=user_id,
|
|
)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="No feedback found for this run")
|
|
return {"success": True}
|
|
|
|
|
|
@router.post("/{thread_id}/runs/{run_id}/feedback", response_model=FeedbackResponse)
|
|
@require_permission("threads", "write", owner_check=True, require_existing=True)
|
|
async def create_feedback(
|
|
thread_id: ThreadId,
|
|
run_id: str,
|
|
body: FeedbackCreateRequest,
|
|
request: Request,
|
|
) -> dict[str, Any]:
|
|
"""Submit feedback (thumbs-up/down) for a run."""
|
|
if body.rating not in (1, -1):
|
|
raise HTTPException(status_code=400, detail="rating must be +1 or -1")
|
|
|
|
user_id = await get_current_user(request)
|
|
|
|
# Validate run exists and belongs to thread
|
|
run_store = get_run_store(request)
|
|
run = await run_store.get(run_id)
|
|
if run is None:
|
|
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
|
if run.get("thread_id") != thread_id:
|
|
raise HTTPException(status_code=404, detail=f"Run {run_id} not found in thread {thread_id}")
|
|
|
|
feedback_repo = get_feedback_repo(request)
|
|
try:
|
|
return await feedback_repo.create(
|
|
run_id=run_id,
|
|
thread_id=thread_id,
|
|
rating=body.rating,
|
|
user_id=user_id,
|
|
message_id=body.message_id,
|
|
comment=body.comment,
|
|
)
|
|
except FeedbackAlreadyExistsError:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Feedback already exists for this run; use PUT to update it",
|
|
) from None
|
|
|
|
|
|
@router.get("/{thread_id}/runs/{run_id}/feedback", response_model=list[FeedbackResponse])
|
|
@require_permission("threads", "read", owner_check=True)
|
|
async def list_feedback(
|
|
thread_id: ThreadId,
|
|
run_id: str,
|
|
request: Request,
|
|
) -> list[dict[str, Any]]:
|
|
"""List all feedback for a run."""
|
|
feedback_repo = get_feedback_repo(request)
|
|
return await feedback_repo.list_by_run(thread_id, run_id)
|
|
|
|
|
|
@router.get("/{thread_id}/runs/{run_id}/feedback/stats", response_model=FeedbackStatsResponse)
|
|
@require_permission("threads", "read", owner_check=True)
|
|
async def feedback_stats(
|
|
thread_id: ThreadId,
|
|
run_id: str,
|
|
request: Request,
|
|
) -> dict[str, Any]:
|
|
"""Get aggregated feedback stats (positive/negative counts) for a run."""
|
|
feedback_repo = get_feedback_repo(request)
|
|
return await feedback_repo.aggregate_by_run(thread_id, run_id)
|
|
|
|
|
|
@router.delete("/{thread_id}/runs/{run_id}/feedback/{feedback_id}")
|
|
@require_permission("threads", "delete", owner_check=True, require_existing=True)
|
|
async def delete_feedback(
|
|
thread_id: ThreadId,
|
|
run_id: str,
|
|
feedback_id: str,
|
|
request: Request,
|
|
) -> dict[str, bool]:
|
|
"""Delete a feedback record."""
|
|
feedback_repo = get_feedback_repo(request)
|
|
# Verify feedback belongs to the specified thread/run before deleting
|
|
existing = await feedback_repo.get(feedback_id)
|
|
if existing is None:
|
|
raise HTTPException(status_code=404, detail=f"Feedback {feedback_id} not found")
|
|
if existing.get("thread_id") != thread_id or existing.get("run_id") != run_id:
|
|
raise HTTPException(status_code=404, detail=f"Feedback {feedback_id} not found in run {run_id}")
|
|
deleted = await feedback_repo.delete(feedback_id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail=f"Feedback {feedback_id} not found")
|
|
return {"success": True}
|