Files
Jyotisha/frontend/src/lib/reply-ratings.ts
T
Jesse_ChenandClaude Opus 5.5 12e315a9bb feat(reply-quality): 👎 keeps a snapshot of the turn; admin 对话质量记录
A 👎 now saves, server side, the rated turn plus the context window the
model read for it (reconstructed from the stored session with the same
consultationHistoryWindow the consult route uses), model and run facts.
👍 is only counted. Switching to 👍 or clearing deletes the snapshot.
Bodies are blanked after 90 days; the row cascades on session delete
and on account deletion.

- Optional 不满意原因 panel under the answer after a 👎 (five reasons,
  200-char note, "会把这一轮对话发给我们排查").
- Admin 对话质量记录: 👍/👎 stats by day and model, list without text,
  audited snapshot open, 处理状态 + note (support.quality.read/write).
- Privacy draft: what a 👎 keeps, why, 90 days, deletion.
- Migration 20260930050000 is add-only; set_reply_rating() replaced with
  the same signature.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-09-30 09:42:11 +08:00

90 lines
3.8 KiB
TypeScript

import type { ChatMessageFeedback } from "@/components/chat-message-actions";
/**
* Client side of 回复评价 persistence (compliance round 2026-09-30). The key
* the transcript uses is `${sessionId}:message-${index}`; the server keys a
* rating by session + index and keeps a short hash of the reply text, so a
* rating is not shown on a different (regenerated) answer at the same index.
*/
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function parseFeedbackKey(feedbackKey: string): { sessionId: string; messageIndex: number } | null {
const match = /^(.+):message-(\d+)$/.exec(feedbackKey);
if (!match || !UUID.test(match[1]!)) return null;
return { sessionId: match[1]!, messageIndex: Number(match[2]) };
}
export function feedbackKeyFor(sessionId: string, messageIndex: number): string {
return `${sessionId}:message-${messageIndex}`;
}
/** First 16 hex characters of SHA-256 over the reply text. */
export async function answerHash(text: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return [...new Uint8Array(digest)].slice(0, 8).map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
/** The latest rating save per reply, so a 不满意原因 lands after its 👎 (and snapshot) exists. */
const pendingRatingSaves = new Map<string, Promise<void>>();
/** Fire-and-forget; a failed save never disturbs the conversation. */
export function persistReplyRating(feedbackKey: string, rating: ChatMessageFeedback | undefined, text: string): Promise<void> {
const key = parseFeedbackKey(feedbackKey);
if (!key) return Promise.resolve();
const saved = answerHash(text)
.then((answerSha256) => fetch("/api/reply-ratings", {
method: "PUT",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...key, rating: rating ?? null, answerSha256 }),
}))
.then(() => undefined, () => undefined);
pendingRatingSaves.set(feedbackKey, saved);
return saved;
}
/**
* 不满意原因 for a 👎: optional reasons and a short note, added to the snapshot
* the rating save kept. Resolves false when it could not be saved.
*/
export async function saveReplyQualityReason(
feedbackKey: string,
reasons: readonly string[],
note: string,
): Promise<boolean> {
const key = parseFeedbackKey(feedbackKey);
if (!key) return false;
await pendingRatingSaves.get(feedbackKey);
try {
const response = await fetch("/api/reply-ratings/reason", {
method: "PUT",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...key, reasons, ...(note.trim() ? { note: note.trim() } : {}) }),
});
return response.ok;
} catch {
return false;
}
}
type StoredRating = { messageIndex: number; rating: ChatMessageFeedback; answerSha256: string };
/**
* Stored ratings for one session, keeping only those whose reply text still
* hashes the same. `texts[i]` is the i-th message's text ("" for user turns).
*/
export async function loadReplyRatings(sessionId: string, texts: readonly string[]): Promise<Record<string, ChatMessageFeedback>> {
if (!UUID.test(sessionId)) return {};
const response = await fetch(`/api/reply-ratings?sessionId=${encodeURIComponent(sessionId)}`, { credentials: "same-origin" });
if (!response.ok) return {};
const json = await response.json().catch(() => null) as { ratings?: StoredRating[] } | null;
const result: Record<string, ChatMessageFeedback> = {};
for (const stored of json?.ratings ?? []) {
const text = texts[stored.messageIndex];
if (!text) continue;
if (await answerHash(text) === stored.answerSha256) result[feedbackKeyFor(sessionId, stored.messageIndex)] = stored.rating;
}
return result;
}