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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c1b4868cd9
commit
12e315a9bb
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { REPLY_QUALITY_NOTE_MAX, REPLY_QUALITY_REASON_LABELS, REPLY_QUALITY_REASONS } from "@/lib/reply-quality-labels";
|
||||
import { saveReplyQualityReason } from "@/lib/reply-ratings";
|
||||
|
||||
const REASONS = REPLY_QUALITY_REASONS.map((key) => [key, REPLY_QUALITY_REASON_LABELS[key]] as const);
|
||||
|
||||
/**
|
||||
* 不满意原因 after a 👎 (compliance round 2026-09-30). Everything here is
|
||||
* optional: the 👎 and the turn's snapshot are already saved when this opens;
|
||||
* closing it keeps them. One line says the turn is sent for review.
|
||||
*/
|
||||
export function ReplyDownReason({ feedbackKey, onClose }: Readonly<{ feedbackKey: string; onClose: () => void }>) {
|
||||
const [picked, setPicked] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const [note, setNote] = useState("");
|
||||
const [state, setState] = useState<"idle" | "sending" | "sent" | "failed">("idle");
|
||||
|
||||
async function submit() {
|
||||
if (state === "sending") return;
|
||||
setState("sending");
|
||||
const ok = await saveReplyQualityReason(feedbackKey, REASONS.map(([key]) => key).filter((key) => picked.has(key)), note);
|
||||
setState(ok ? "sent" : "failed");
|
||||
if (ok) window.setTimeout(onClose, 1200);
|
||||
}
|
||||
|
||||
if (state === "sent") {
|
||||
return <p className="reply-down-reason is-sent" role="status">已收到,谢谢。</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="reply-down-reason" role="group" aria-label="不满意原因">
|
||||
<div className="reply-down-reason-head">
|
||||
<span>哪里不满意?可以不选</span>
|
||||
<button type="button" className="reply-down-reason-close" aria-label="关闭" onClick={onClose}><X aria-hidden="true" /></button>
|
||||
</div>
|
||||
<div className="reply-down-reason-chips">
|
||||
{REASONS.map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={picked.has(key)}
|
||||
onClick={() => setPicked((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
return next;
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
className="reply-down-reason-note"
|
||||
type="text"
|
||||
value={note}
|
||||
maxLength={REPLY_QUALITY_NOTE_MAX}
|
||||
placeholder="补充一句(可不填)"
|
||||
aria-label="补充说明"
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
/>
|
||||
<div className="reply-down-reason-foot">
|
||||
<span>会把这一轮对话发给我们排查</span>
|
||||
<button type="button" className="reply-down-reason-send" disabled={state === "sending"} onClick={() => void submit()}>
|
||||
{state === "failed" ? "再试一次" : "提交"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user