Skip to content

Commit 2cd4a34

Browse files
committed
feat(chat): 👍/👎 feedback on assistant messages — closes chat↔eval loop
Foundation for the closed feedback loop the system has been missing. Every assistant message can now be rated by the viewer; the rating lives on the chat_messages row itself (rating: -1/0/+1 + rating_comment). When user-auth lands this can move to a join table without changing the API surface — the rating semantic is the same either way. Migration 033 adds two nullable-on-display, non-null-on-storage columns. ORM model updated. ChatMessageResponse exposes the rating so already-rated messages show their state when conversation history loads. API: PUT /chat/conversations/{cid}/messages/{mid}/rating accepts {rating: -1|0|1, comment?: str}. Setting rating to 0 also clears the comment (so the two never disagree). Validates the message belongs to the conversation; bumps conversation.updated_at so sort order reflects the activity. UI: small 👍 / 👎 buttons in the assistant message header, next to Copy and Delete. Click toggles the rating (clicking the active one clears it). Optimistic local update so the click feels instant; on API failure we revert and show a toast. Buttons only appear for assistant messages — user messages stay clean. This commit deliberately stops at "rating is captured and visible". The next step (promoting +1 messages into qa_eval gold samples and surfacing -1 ones for admin review) is a separate piece that can work on whatever feedback accumulates here.
1 parent 12d424f commit 2cd4a34

9 files changed

Lines changed: 242 additions & 1 deletion

File tree

‎app/api/v1/chat.py‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
ConversationSettings,
2727
ConversationSummary,
2828
ConversationTitleUpdate,
29+
MessageRatingUpdate,
2930
SourceChunk,
3031
)
3132
from app.services.chat_titles import build_conversation_title
@@ -764,6 +765,8 @@ async def get_conversation_messages(
764765
model=msg.model,
765766
use_self_check=msg.use_self_check,
766767
prompt_version_id=msg.prompt_version_id,
768+
rating=msg.rating,
769+
rating_comment=msg.rating_comment,
767770
timestamp=msg.created_at,
768771
message_index=msg.message_index,
769772
)
@@ -772,6 +775,77 @@ async def get_conversation_messages(
772775
return response_messages
773776

774777

778+
@router.put(
779+
"/conversations/{conversation_id}/messages/{message_id}/rating",
780+
response_model=ChatMessageResponse,
781+
)
782+
async def set_message_rating(
783+
conversation_id: UUID,
784+
message_id: UUID,
785+
payload: MessageRatingUpdate,
786+
db: AsyncSession = Depends(get_db),
787+
user_id: Optional[UUID] = Depends(get_current_user_id),
788+
):
789+
"""
790+
Set or clear a 👍/👎 rating on a stored chat message.
791+
792+
Ratings are the chat → eval bridge: thumbs-up messages can later be
793+
promoted into the qa_eval gold corpus and thumbs-down ones surfaced
794+
for review. A rating of 0 clears any previous rating and its comment.
795+
User-role messages can also be rated (e.g. to flag a confusing
796+
question) — we do not restrict by role here.
797+
"""
798+
convo_query = select(ConversationModel).where(
799+
ConversationModel.id == conversation_id,
800+
ConversationModel.is_deleted == False,
801+
)
802+
conversation = (await db.execute(convo_query)).scalar_one_or_none()
803+
if not conversation:
804+
raise HTTPException(
805+
status_code=status.HTTP_404_NOT_FOUND,
806+
detail=f"Conversation {conversation_id} not found",
807+
)
808+
809+
msg_query = select(ChatMessageModel).where(
810+
ChatMessageModel.id == message_id,
811+
ChatMessageModel.conversation_id == conversation_id,
812+
)
813+
message = (await db.execute(msg_query)).scalar_one_or_none()
814+
if not message:
815+
raise HTTPException(
816+
status_code=status.HTTP_404_NOT_FOUND,
817+
detail=f"Message {message_id} not found",
818+
)
819+
820+
message.rating = payload.rating
821+
# Clearing the rating also clears any stale comment so the two
822+
# never disagree.
823+
message.rating_comment = payload.comment if payload.rating != 0 else None
824+
conversation.updated_at = utcnow()
825+
826+
sources_payload: Optional[list[SourceChunk]] = None
827+
if message.sources_json:
828+
try:
829+
raw_sources = json.loads(message.sources_json)
830+
sources_payload = [SourceChunk(**source) for source in raw_sources]
831+
except Exception:
832+
sources_payload = None
833+
834+
return ChatMessageResponse(
835+
id=message.id,
836+
role=message.role,
837+
content=message.content,
838+
sources=sources_payload,
839+
model=message.model,
840+
use_self_check=message.use_self_check,
841+
prompt_version_id=message.prompt_version_id,
842+
rating=message.rating,
843+
rating_comment=message.rating_comment,
844+
timestamp=message.created_at,
845+
message_index=message.message_index,
846+
)
847+
848+
775849
@router.delete(
776850
"/conversations/{conversation_id}/messages/{message_id}",
777851
response_model=ChatDeleteResponse,
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Add feedback (thumbs rating + comment) to chat messages.
2+
3+
Closes the chat ↔ eval loop foundation: every assistant message can be
4+
rated by the viewer, so real-world successful Q&A pairs can later be
5+
promoted into the qa_eval gold corpus and bad ones flagged for review.
6+
7+
The rating is stored on the message itself rather than in a separate
8+
table because in the current MVP there is no per-user data — each
9+
conversation has one effective rater. When multi-user auth lands, this
10+
can move to a join table without changing the API surface.
11+
12+
Revision ID: 033
13+
Revises: 032
14+
Create Date: 2026-05-16
15+
"""
16+
17+
import sqlalchemy as sa
18+
from alembic import op
19+
20+
revision = "033"
21+
down_revision = "032"
22+
branch_labels = None
23+
depends_on = None
24+
25+
26+
def upgrade() -> None:
27+
op.add_column(
28+
"chat_messages",
29+
sa.Column(
30+
"rating",
31+
sa.SmallInteger(),
32+
nullable=False,
33+
server_default="0",
34+
comment="-1 = thumbs down, 0 = no rating, +1 = thumbs up",
35+
),
36+
)
37+
op.add_column(
38+
"chat_messages",
39+
sa.Column(
40+
"rating_comment",
41+
sa.Text(),
42+
nullable=True,
43+
comment="Optional free-text feedback attached to the rating",
44+
),
45+
)
46+
47+
48+
def downgrade() -> None:
49+
op.drop_column("chat_messages", "rating_comment")
50+
op.drop_column("chat_messages", "rating")

‎app/models/database.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,23 @@ class ChatMessage(Base):
407407
)
408408
message_index: Mapped[int] = mapped_column(Integer, nullable=False)
409409

410+
# Feedback signal: -1 thumbs down, 0 unrated, +1 thumbs up. Optional
411+
# free-text comment for the rater. Closes the chat ↔ eval loop:
412+
# later, +1 rated assistant messages can be promoted into the
413+
# qa_samples gold corpus and -1 ones surfaced for review.
414+
rating: Mapped[int] = mapped_column(
415+
sa.SmallInteger,
416+
default=0,
417+
nullable=False,
418+
server_default="0",
419+
comment="-1 = thumbs down, 0 = no rating, +1 = thumbs up",
420+
)
421+
rating_comment: Mapped[Optional[str]] = mapped_column(
422+
Text,
423+
nullable=True,
424+
comment="Optional free-text feedback attached to the rating",
425+
)
426+
410427
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, nullable=False)
411428

412429
conversation: Mapped["Conversation"] = relationship("Conversation", back_populates="messages")

‎app/models/schemas.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,10 +454,25 @@ class ChatMessageResponse(BaseModel):
454454
default=None, description="Whether self-check was applied"
455455
)
456456
prompt_version_id: Optional[UUID] = Field(default=None, description="Prompt version used")
457+
rating: int = Field(default=0, description="-1 thumbs down, 0 unrated, +1 thumbs up")
458+
rating_comment: Optional[str] = Field(
459+
default=None, description="Optional free-text feedback attached to the rating"
460+
)
457461
timestamp: datetime
458462
message_index: int
459463

460464

465+
class MessageRatingUpdate(BaseModel):
466+
"""Payload for setting a 👍/👎 rating on a chat message."""
467+
468+
rating: int = Field(
469+
..., ge=-1, le=1, description="-1 thumbs down, 0 clears rating, +1 thumbs up"
470+
)
471+
comment: Optional[str] = Field(
472+
default=None, max_length=2000, description="Optional free-text feedback (max 2000 chars)"
473+
)
474+
475+
461476
class ConversationSummary(BaseModel):
462477
"""Conversation list item."""
463478

‎frontend/src/components/chat/MessageBubble.tsx‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ interface MessageBubbleProps {
1111
onDelete?: () => void
1212
showPromptVersion?: boolean
1313
sourceAnchorPrefix?: string
14+
onRate?: (messageId: string, rating: -1 | 0 | 1) => void | Promise<void>
1415
}
1516

16-
export function MessageBubble({ message, onDelete, showPromptVersion, sourceAnchorPrefix }: MessageBubbleProps) {
17+
export function MessageBubble({ message, onDelete, showPromptVersion, sourceAnchorPrefix, onRate }: MessageBubbleProps) {
1718
const isUser = message.role === 'user'
1819
const timestamp = new Date(message.timestamp).toLocaleTimeString('en-US', {
1920
hour: '2-digit',
@@ -100,6 +101,36 @@ export function MessageBubble({ message, onDelete, showPromptVersion, sourceAnch
100101
<span className="text-xs opacity-50">{timestamp}</span>
101102
</div>
102103
<div className="flex items-center gap-2">
104+
{!isUser && onRate && message.id && (
105+
<>
106+
<button
107+
type="button"
108+
onClick={() => onRate(message.id!, message.rating === 1 ? 0 : 1)}
109+
className={`text-base leading-none transition-colors ${
110+
message.rating === 1
111+
? 'text-emerald-400'
112+
: 'text-gray-500 hover:text-emerald-300'
113+
}`}
114+
aria-label={message.rating === 1 ? 'Clear thumbs up' : 'Thumbs up'}
115+
title="Helpful answer — promote to gold corpus"
116+
>
117+
👍
118+
</button>
119+
<button
120+
type="button"
121+
onClick={() => onRate(message.id!, message.rating === -1 ? 0 : -1)}
122+
className={`text-base leading-none transition-colors ${
123+
message.rating === -1
124+
? 'text-rose-400'
125+
: 'text-gray-500 hover:text-rose-300'
126+
}`}
127+
aria-label={message.rating === -1 ? 'Clear thumbs down' : 'Thumbs down'}
128+
title="Bad answer — flag for review"
129+
>
130+
👎
131+
</button>
132+
</>
133+
)}
103134
{onDelete && (
104135
<button
105136
type="button"

‎frontend/src/hooks/useChat.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ export function useChat(kbId: string) {
111111
model: msg.model,
112112
use_self_check: msg.use_self_check,
113113
prompt_version_id: msg.prompt_version_id,
114+
rating: msg.rating ?? 0,
115+
rating_comment: msg.rating_comment ?? null,
114116
})))
115117
} catch (e) {
116118
console.error('Failed to load conversation history:', e)
@@ -356,6 +358,38 @@ export function useChat(kbId: string) {
356358
}
357359
}, [refreshConversations])
358360

361+
const rateMessage = useCallback(
362+
async (messageId: string, rating: -1 | 0 | 1) => {
363+
if (!conversationId) return
364+
// Optimistic local update — the click feels instant and the API call
365+
// either confirms or reverts.
366+
const previous = messages.find((m) => m.id === messageId)?.rating ?? 0
367+
setMessages((prev) =>
368+
prev.map((m) => (m.id === messageId ? { ...m, rating } : m))
369+
)
370+
try {
371+
const updated = await apiClient.rateMessage(conversationId, messageId, rating)
372+
setMessages((prev) =>
373+
prev.map((m) =>
374+
m.id === messageId
375+
? { ...m, rating: updated.rating ?? 0, rating_comment: updated.rating_comment ?? null }
376+
: m
377+
)
378+
)
379+
} catch (e) {
380+
// Revert on failure.
381+
setMessages((prev) =>
382+
prev.map((m) => (m.id === messageId ? { ...m, rating: previous } : m))
383+
)
384+
console.error('Failed to set message rating:', e)
385+
toast.error('Failed to save rating', {
386+
description: e instanceof Error ? e.message : undefined,
387+
})
388+
}
389+
},
390+
[conversationId, messages]
391+
)
392+
359393
return {
360394
conversations,
361395
conversationsLoading,
@@ -369,6 +403,7 @@ export function useChat(kbId: string) {
369403
deleteConversation,
370404
renameConversation,
371405
selectConversation,
406+
rateMessage,
372407
conversationId,
373408
}
374409
}

‎frontend/src/pages/ChatPage.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export function ChatPage() {
146146
deleteConversation,
147147
renameConversation,
148148
selectConversation,
149+
rateMessage,
149150
conversationId,
150151
} = useChat(id!)
151152

@@ -1100,6 +1101,7 @@ export function ChatPage() {
11001101
onDelete={handleDelete}
11011102
showPromptVersion={showPromptVersions}
11021103
sourceAnchorPrefix={sourceAnchorPrefix}
1104+
onRate={rateMessage}
11031105
/>
11041106
{message.sources && message.sources.length > 0 && (
11051107
<details className="mt-4 space-y-2">

‎frontend/src/services/api.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,19 @@ class APIClient {
611611
return response.data
612612
}
613613

614+
async rateMessage(
615+
conversationId: string,
616+
messageId: string,
617+
rating: -1 | 0 | 1,
618+
comment?: string | null
619+
): Promise<ChatMessageResponse> {
620+
const response = await this.client.put<ChatMessageResponse>(
621+
`/chat/conversations/${conversationId}/messages/${messageId}/rating`,
622+
{ rating, comment: comment ?? null }
623+
)
624+
return response.data
625+
}
626+
614627
async getConversation(conversationId: string): Promise<ConversationDetail> {
615628
const response = await this.client.get<ConversationDetail>(
616629
`/chat/conversations/${conversationId}`

‎frontend/src/types/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,8 @@ export interface ChatMessage {
273273
use_conversation_history?: boolean
274274
conversation_history_limit?: number
275275
prompt_version_id?: string | null
276+
rating?: number
277+
rating_comment?: string | null
276278
}
277279

278280
export interface SourceChunk {
@@ -404,6 +406,8 @@ export interface ChatMessageResponse {
404406
model?: string
405407
use_self_check?: boolean
406408
prompt_version_id?: string | null
409+
rating?: number
410+
rating_comment?: string | null
407411
}
408412

409413
export interface AppSettings {

0 commit comments

Comments
 (0)