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

74 lines
2.9 KiB
Python

from typing import Protocol, runtime_checkable
from deerflow.domain.feedback.model import Feedback
@runtime_checkable
class FeedbackRepository(Protocol):
"""Output port for feedback persistence.
Implementations exchange domain objects only and translate storage
failures into domain errors -- no storage vocabulary (SQL, tables,
files) may leak through this contract.
"""
async def save(self, feedback: Feedback) -> Feedback:
"""Store the user's current rating for a run (idempotent upsert).
Creates the entry if absent; otherwise replaces rating/comment and
refreshes created_at while keeping the aggregate identity
(thread_id, run_id, user_id). Returns the stored state.
"""
...
async def latest_per_run_in_thread(self, thread_id: str, *, user_id: str | None) -> dict[str, Feedback]:
"""Return each run's current feedback across a whole thread, keyed by run_id.
Single bulk read used to badge the message list -- avoids one
query per run. Ownership filter: a non-None user_id restricts results to the
user's entries; None means no filtering (no-auth mode).
"""
...
async def latest_for_runs(self, thread_id: str, run_ids: set[str], *, user_id: str | None) -> dict[str, Feedback]:
"""Return current feedback for only the selected runs of a thread.
Paged variant of latest_per_run_in_thread: the message-list page
endpoint only needs badges for the runs on the current page.
Returns an empty mapping when run_ids is empty. Same ownership
filter as find_for_run.
"""
...
async def remove_for_run(self, thread_id: str, run_id: str, *, user_id: str | None) -> bool:
"""Retract the user's feedback for a run.
Returns True if an entry was removed, False if none existed.
Unlike the read methods, ownership here is an EQUALITY match, not a
filter: a non-None user_id removes only that user's entry, and None
matches only entries stored with a NULL owner (what no-auth mode
writes) -- it does NOT mean "remove regardless of owner". A delete
must never reach across owners, so the asymmetry with the reads'
"None = no filtering" is deliberate.
"""
...
class RunLookup(Protocol):
"""Narrow output port: the only question the feedback context asks
about runs -- which thread does a run belong to.
Deliberately not the full run store: depending on this one-method
contract keeps the feedback context decoupled from the execution
context's wide repository interface (interface segregation).
"""
async def thread_of(self, run_id: str) -> str | None:
"""Return the thread_id owning the given run, or None if the run
does not exist.
Used by the service to verify run ownership before writing
feedback (rejects cross-thread run ids as RunNotFoundError).
"""
...