feedback: thumb up/down on logs + reviewer self-score 0-10
Two unrelated features bundled because they ship together and share
migration 0028.
Strategic-log feedback (thumb up/down):
- New strategic_log_feedback table with UNIQUE(log_id, user_id) so each
user has one vote per log, flippable in place (up -> down -> clear).
UI shows aggregate counts only.
- app/services/log_feedback.py: set_vote, get_counts, sign/verify
feedback tokens (same itsdangerous pattern as auth.sign_pending,
30-day TTL for email links).
- POST /api/log/{id}/feedback: web vote, auth required, returns counts
+ the requesting user's own vote.
- GET /feedback?token=...&vote=...: email-link target, no auth, signed
token encodes (user, log, vote), renders feedback_thanks.html.
- partials/log.html: thumbs row below content, JS-driven swap via the
POST endpoint. Dashboard latest-log card and /log page both render
this partial via htmx, so the buttons appear in all three surfaces.
- digest emails: a "How was today's read?" row above the unsub footer,
with signed-token URLs against the latest StrategicLog at send time.
Plain-text fallback included.
Reviewer self-score (0-10):
- _SYSTEM_PROMPT asks for an integer score with anchors (10 exemplary,
5 borderline, 0 unfit). Verdict gains score: int | None.
- Deterministic-layer hits get score=0 (hard rule, no nuance);
error rows get None; LLM rows get the model's score clamped 0..10.
- ReviewerVerdict.score, StrategicLog.reviewer_score, and
IndicatorSummary.reviewer_score all new SMALLINT NULL columns.
- ai_log_job + indicator_summary_job persist verdict.score onto their
content rows when committing the row alongside content.
Tests:
- tests/test_strategic_log_feedback.py: vote, flip, clear, aggregate
across users, invalid vote, token round-trip + tamper + garbage +
'clear' not signable for email path.
- tests/test_output_review.py: score parsing, clamping (>10, <0),
missing/non-numeric -> None, deterministic-layer score=0.
Full suite: 427 passed (was 412), 5 skipped, no regressions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
f3ac65f8f7
commit
8946dee2e0
14 changed files with 962 additions and 14 deletions
|
|
@ -285,11 +285,13 @@ async def news_list(
|
|||
def _log_partial_payload(
|
||||
row: StrategicLog | None,
|
||||
content_override: str | None = None,
|
||||
feedback: object | None = None,
|
||||
) -> dict | None:
|
||||
if row is None:
|
||||
return None
|
||||
content = content_override if content_override is not None else row.content
|
||||
return {
|
||||
"id": row.id,
|
||||
"content_html": _md_to_html(content),
|
||||
"generated_at": row.generated_at,
|
||||
"model": row.model,
|
||||
|
|
@ -299,6 +301,7 @@ def _log_partial_payload(
|
|||
"cost_usd": row.cost_usd,
|
||||
"prompt_tokens": row.prompt_tokens,
|
||||
"completion_tokens": row.completion_tokens,
|
||||
"feedback": feedback,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -404,9 +407,12 @@ async def log_latest(
|
|||
|
||||
if as_ == "html":
|
||||
content_override = await _localized_content(session, row, principal)
|
||||
feedback = await _feedback_for(session, row, principal)
|
||||
return templates.TemplateResponse(
|
||||
request, "partials/log.html",
|
||||
{"log": _log_partial_payload(row, content_override=content_override),
|
||||
{"log": _log_partial_payload(
|
||||
row, content_override=content_override, feedback=feedback,
|
||||
),
|
||||
"tone": wanted_tone, "paid": not free_only},
|
||||
)
|
||||
|
||||
|
|
@ -415,6 +421,21 @@ async def log_latest(
|
|||
return StrategicLogOut.model_validate(row, from_attributes=True)
|
||||
|
||||
|
||||
async def _feedback_for(
|
||||
session: AsyncSession,
|
||||
row: StrategicLog | None,
|
||||
principal: CurrentUser | None,
|
||||
):
|
||||
"""Aggregate up/down counts + the principal's own vote, or None when
|
||||
there's no log to fetch feedback for. Always safe to await; runs two
|
||||
indexed queries."""
|
||||
if row is None:
|
||||
return None
|
||||
from app.services.log_feedback import get_counts
|
||||
user_id = principal.user.id if (principal and principal.user) else None
|
||||
return await get_counts(session, log_id=row.id, user_id=user_id)
|
||||
|
||||
|
||||
@router.get("/log/by-date/{day}")
|
||||
async def log_by_date(
|
||||
request: Request,
|
||||
|
|
@ -459,9 +480,12 @@ async def log_by_date(
|
|||
|
||||
if as_ == "html":
|
||||
content_override = await _localized_content(session, row, principal)
|
||||
feedback = await _feedback_for(session, row, principal)
|
||||
return templates.TemplateResponse(
|
||||
request, "partials/log.html",
|
||||
{"log": _log_partial_payload(row, content_override=content_override),
|
||||
{"log": _log_partial_payload(
|
||||
row, content_override=content_override, feedback=feedback,
|
||||
),
|
||||
"tone": wanted_tone, "paid": not free_only},
|
||||
)
|
||||
if row is None:
|
||||
|
|
@ -469,6 +493,53 @@ async def log_by_date(
|
|||
return StrategicLogOut.model_validate(row, from_attributes=True)
|
||||
|
||||
|
||||
# --- Log feedback (thumb up/down) --------------------------------------------
|
||||
|
||||
|
||||
class FeedbackIn(BaseModel):
|
||||
vote: Literal["up", "down", "clear"]
|
||||
|
||||
|
||||
class FeedbackOut(BaseModel):
|
||||
up: int
|
||||
down: int
|
||||
user_vote: str | None
|
||||
|
||||
|
||||
@router.post("/log/{log_id}/feedback", response_model=FeedbackOut)
|
||||
async def post_log_feedback(
|
||||
log_id: int,
|
||||
body: FeedbackIn,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
principal: CurrentUser = Depends(require_token),
|
||||
) -> FeedbackOut:
|
||||
"""Record (or flip / clear) the authenticated user's thumb on a log.
|
||||
|
||||
Anonymous-in-UI: the response carries only aggregate counts plus the
|
||||
*requesting* user's own vote (so the UI can highlight it). Other
|
||||
users' votes are never exposed."""
|
||||
if principal.user is None:
|
||||
raise HTTPException(status_code=400, detail="admin token cannot vote")
|
||||
|
||||
# Guard against votes on non-existent logs (don't want orphan FKs).
|
||||
exists = (await session.execute(
|
||||
select(StrategicLog.id).where(StrategicLog.id == log_id).limit(1)
|
||||
)).scalar_one_or_none()
|
||||
if exists is None:
|
||||
raise HTTPException(status_code=404, detail="log not found")
|
||||
|
||||
from app.services.log_feedback import FeedbackError, set_vote
|
||||
try:
|
||||
counts = await set_vote(
|
||||
session, log_id=log_id, user_id=principal.user.id, vote=body.vote,
|
||||
)
|
||||
except FeedbackError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return FeedbackOut(
|
||||
up=counts.up, down=counts.down, user_vote=counts.user_vote,
|
||||
)
|
||||
|
||||
|
||||
# --- Calendar archive --------------------------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue