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,5 @@
|
||||
import { ReplyQualityResource } from "@/components/admin/reply-quality-resource";
|
||||
|
||||
export default function Page() {
|
||||
return <ReplyQualityResource />;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requirePermission } from "@/lib/admin/auth";
|
||||
import { queryAdminRows } from "@/lib/admin/database";
|
||||
import { adminErrorResponse, invalidQueryResponse, requestId, requireAdminMutation } from "@/lib/admin/http";
|
||||
import { REPLY_QUALITY_STATUSES } from "@/lib/reply-quality-labels";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* One 对话质量记录 (compliance round 2026-09-30). GET returns the 故障上下文快照
|
||||
* (question, answer, context turns, run facts) and writes an audit row for the
|
||||
* open in the same database call; the body may hold birth data and is shown
|
||||
* only here. POST sets the 处理状态 and an internal note, also audited.
|
||||
*/
|
||||
const idSchema = z.string().uuid();
|
||||
const updateSchema = z.object({
|
||||
status: z.enum(REPLY_QUALITY_STATUSES),
|
||||
adminNote: z.string().trim().max(2000).optional(),
|
||||
});
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const session = await requirePermission("support.quality.read");
|
||||
const { id } = await context.params;
|
||||
if (!idSchema.safeParse(id).success) return invalidQueryResponse({ id: ["invalid"] });
|
||||
const rows = await queryAdminRows<{
|
||||
id: string;
|
||||
question: string | null;
|
||||
answer: string | null;
|
||||
context: unknown;
|
||||
run_facts: unknown;
|
||||
body_expired_at: Date | null;
|
||||
}>("select * from public.admin_open_reply_quality($1::uuid,$2::uuid,$3::text)", [session.user.id, id, requestId(request)]);
|
||||
const row = rows[0];
|
||||
if (!row) return NextResponse.json({ error: "记录不存在" }, { status: 404 });
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
id: row.id,
|
||||
question: row.question,
|
||||
answer: row.answer,
|
||||
context: row.context,
|
||||
runFacts: row.run_facts,
|
||||
bodyExpiredAt: row.body_expired_at ? row.body_expired_at.toISOString() : null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("reply_quality_not_found")) {
|
||||
return NextResponse.json({ error: "记录不存在" }, { status: 404 });
|
||||
}
|
||||
return adminErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const session = await requireAdminMutation(request, "support.quality.write");
|
||||
const { id } = await context.params;
|
||||
if (!idSchema.safeParse(id).success) return invalidQueryResponse({ id: ["invalid"] });
|
||||
const parsed = updateSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
|
||||
const rows = await queryAdminRows<{ id: string; status: string; admin_note: string | null; updated_at: Date }>(
|
||||
"select * from public.admin_update_reply_quality($1::uuid,$2::uuid,$3::text,$4::text,$5::text)",
|
||||
[session.user.id, id, parsed.data.status, parsed.data.adminNote ?? null, requestId(request)],
|
||||
);
|
||||
const row = rows[0];
|
||||
return NextResponse.json({
|
||||
data: row ? { id: row.id, status: row.status, adminNote: row.admin_note, updatedAt: row.updated_at.toISOString() } : null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("reply_quality_not_found")) {
|
||||
return NextResponse.json({ error: "记录不存在" }, { status: 404 });
|
||||
}
|
||||
return adminErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { requirePermission } from "@/lib/admin/auth";
|
||||
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
|
||||
import { adminErrorResponse, invalidQueryResponse, parseListQuery, readonlyAdminMutation } from "@/lib/admin/http";
|
||||
import { parseReplyQualityFilter } from "@/lib/reply-quality-labels";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* 对话质量记录 list (compliance round 2026-09-30): 👎 records without their
|
||||
* body. The snapshot itself is only returned by /api/admin/reply-quality/[id],
|
||||
* which audits every open. The `status` filter slot carries a status,
|
||||
* `reason:<key>`, `model:<id>` or `days:<n>`.
|
||||
*/
|
||||
type Row = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
email: string | null;
|
||||
session_id: string;
|
||||
message_index: number;
|
||||
session_type: string;
|
||||
model_id: string | null;
|
||||
reasons: string[];
|
||||
reason_note: string | null;
|
||||
has_body: boolean;
|
||||
status: string;
|
||||
admin_note: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
total_count: string;
|
||||
};
|
||||
|
||||
export const POST = readonlyAdminMutation;
|
||||
export const PUT = readonlyAdminMutation;
|
||||
export const PATCH = readonlyAdminMutation;
|
||||
export const DELETE = readonlyAdminMutation;
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await requirePermission("support.quality.read");
|
||||
const parsed = parseListQuery(request);
|
||||
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
|
||||
const filter = parseReplyQualityFilter(parsed.data.status);
|
||||
const from = filter.days ? new Date(Date.now() - filter.days * 86_400_000).toISOString() : null;
|
||||
const rows = await queryAdminRows<Row>(
|
||||
"select * from public.admin_list_reply_quality($1::uuid,$2::text,$3::text,$4::text,$5::timestamptz,$6::timestamptz,$7::text,$8::integer,$9::integer)",
|
||||
[
|
||||
session.user.id,
|
||||
filter.status,
|
||||
filter.reason,
|
||||
filter.modelId,
|
||||
from,
|
||||
null,
|
||||
parsed.data.q ? `%${parsed.data.q}%` : null,
|
||||
parsed.data.pageSize,
|
||||
pageOffset(parsed.data.page, parsed.data.pageSize),
|
||||
],
|
||||
);
|
||||
return NextResponse.json({
|
||||
data: rows.map((row) => ({
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
email: row.email,
|
||||
sessionId: row.session_id,
|
||||
messageIndex: row.message_index,
|
||||
sessionType: row.session_type,
|
||||
modelId: row.model_id,
|
||||
reasons: row.reasons,
|
||||
reasonNote: row.reason_note,
|
||||
hasBody: row.has_body,
|
||||
status: row.status,
|
||||
adminNote: row.admin_note,
|
||||
createdAt: row.created_at.toISOString(),
|
||||
updatedAt: row.updated_at.toISOString(),
|
||||
})),
|
||||
total: Number(rows[0]?.total_count ?? 0),
|
||||
});
|
||||
} catch (error) {
|
||||
return adminErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { requirePermission } from "@/lib/admin/auth";
|
||||
import { queryAdminRows } from "@/lib/admin/database";
|
||||
import { adminErrorResponse, readonlyAdminMutation } from "@/lib/admin/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/** 👍 / 👎 counts by day and by the session's model, last `days` days (default 14, max 90). */
|
||||
export const POST = readonlyAdminMutation;
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await requirePermission("support.quality.read");
|
||||
const raw = Number(new URL(request.url).searchParams.get("days") ?? 14);
|
||||
const days = Number.isInteger(raw) && raw >= 1 && raw <= 90 ? raw : 14;
|
||||
const rows = await queryAdminRows<{ bucket_kind: string; bucket: string; up_count: string; down_count: string }>(
|
||||
"select * from public.admin_reply_quality_stats($1::uuid,$2::integer)",
|
||||
[session.user.id, days],
|
||||
);
|
||||
const shape = (kind: string) => rows.filter((row) => row.bucket_kind === kind).map((row) => ({
|
||||
bucket: row.bucket,
|
||||
up: Number(row.up_count),
|
||||
down: Number(row.down_count),
|
||||
}));
|
||||
return NextResponse.json({ data: { days, byDay: shape("day"), byModel: shape("model") } });
|
||||
} catch (error) {
|
||||
return adminErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
|
||||
import { normalizeReplyQualityReasons, REPLY_QUALITY_NOTE_MAX } from "@/lib/reply-quality-snapshot";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* 不满意原因 for a 👎 (compliance round 2026-09-30). Optional: the rating and
|
||||
* its snapshot are already saved by PUT /api/reply-ratings; this only adds the
|
||||
* picked reasons and a short note to that snapshot.
|
||||
*/
|
||||
const bodySchema = z.object({
|
||||
sessionId: z.string().uuid(),
|
||||
messageIndex: z.number().int().min(0).max(100_000),
|
||||
reasons: z.array(z.string()).max(5),
|
||||
note: z.string().max(REPLY_QUALITY_NOTE_MAX).optional(),
|
||||
});
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
let supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>;
|
||||
let service: ReturnType<typeof createAdminSupabaseClient>;
|
||||
try {
|
||||
supabase = await createServerSupabaseClient();
|
||||
service = createAdminSupabaseClient();
|
||||
} catch (error) {
|
||||
return jsonForSupabaseSetupFailure(error, "PUT /api/reply-ratings/reason");
|
||||
}
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
const parsed = bodySchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ error: "原因格式不正确" }, { status: 400 });
|
||||
const { reasons, note } = normalizeReplyQualityReasons(parsed.data.reasons, parsed.data.note);
|
||||
const { error } = await service.rpc("set_reply_quality_reason", {
|
||||
p_user_id: user.id,
|
||||
p_session_id: parsed.data.sessionId,
|
||||
p_message_index: parsed.data.messageIndex,
|
||||
p_reasons: reasons,
|
||||
p_note: note,
|
||||
});
|
||||
if (error) {
|
||||
const missing = error.message?.includes("reply_quality_not_found");
|
||||
return NextResponse.json({ error: missing ? "这条回答的排查记录不存在" : "暂时没能保存原因" }, { status: missing ? 404 : 503 });
|
||||
}
|
||||
return NextResponse.json({ saved: true });
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
|
||||
import { captureReplyQualitySnapshot, type ReplyQualityService } from "@/lib/reply-quality-capture";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
@@ -11,7 +12,9 @@ export const runtime = "nodejs";
|
||||
* 回复评价 (CONTEXT.md): the 👍 / 👎 on one assistant reply, persisted per
|
||||
* session + message position (compliance round 2026-09-30). Reads and writes
|
||||
* go through list_reply_ratings() / set_reply_rating(), which check that the
|
||||
* session belongs to the caller.
|
||||
* session belongs to the caller. A 👎 also keeps a 故障上下文快照 of that turn,
|
||||
* built here from the stored session (reply-quality-capture); 👍 or clearing
|
||||
* drops it inside set_reply_rating().
|
||||
*/
|
||||
const putSchema = z.object({
|
||||
sessionId: z.string().uuid(),
|
||||
@@ -73,5 +76,13 @@ export async function PUT(request: Request) {
|
||||
const notOwned = error.message?.includes("rating_session_not_owned");
|
||||
return NextResponse.json({ error: notOwned ? "会话不属于当前账号" : "暂时没能保存评价" }, { status: notOwned ? 403 : 503 });
|
||||
}
|
||||
return NextResponse.json({ saved: true });
|
||||
if (parsed.data.rating !== "down") return NextResponse.json({ saved: true });
|
||||
// The rating is saved either way; a snapshot that cannot be built is skipped, never an error.
|
||||
const snapshot = await captureReplyQualitySnapshot(context.service as unknown as ReplyQualityService, {
|
||||
userId: user.id,
|
||||
sessionId: parsed.data.sessionId,
|
||||
messageIndex: parsed.data.messageIndex,
|
||||
answerSha256: parsed.data.answerSha256,
|
||||
}).catch(() => "failed" as const);
|
||||
return NextResponse.json({ saved: true, snapshot });
|
||||
}
|
||||
|
||||
@@ -1459,6 +1459,31 @@ button:disabled:where(:not([data-slot="button"])) { cursor: default; opacity: .4
|
||||
.rectification-message-entry.rectification-message-flash {
|
||||
animation: rectification-message-flash 600ms ease;
|
||||
}
|
||||
/* 不满意原因 after a 👎 (2026-09-30): a quiet inline card under the actions,
|
||||
everything optional; one line says the turn goes to review. */
|
||||
.reply-down-reason {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
max-width: 520px;
|
||||
margin: var(--space-2) 0 var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-caption);
|
||||
}
|
||||
.reply-down-reason.is-sent { display: block; border: 0; padding: 0; background: transparent; }
|
||||
.reply-down-reason-head, .reply-down-reason-foot { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); }
|
||||
.reply-down-reason-close { display: grid; place-items: center; min-width: 32px; min-height: 32px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--color-ink-tertiary); cursor: pointer; }
|
||||
.reply-down-reason-close svg { width: 16px; height: 16px; }
|
||||
.reply-down-reason-chips { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||
.reply-down-reason-chips button { min-height: 32px; padding: 0 var(--space-3); border: 1px solid var(--color-border); border-radius: 999px; background: transparent; color: var(--color-ink-secondary); font: inherit; cursor: pointer; }
|
||||
.reply-down-reason-chips button[aria-pressed="true"] { border-color: var(--color-ink-secondary); background: var(--color-canvas-muted); color: var(--color-ink); }
|
||||
.reply-down-reason-note { min-height: 36px; padding: 0 var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: transparent; color: var(--color-ink); font: inherit; }
|
||||
.reply-down-reason-send { min-height: 32px; padding: 0 var(--space-4); border: 0; border-radius: var(--radius-sm); background: var(--color-canvas-muted); color: var(--color-ink); font: inherit; font-weight: 600; cursor: pointer; }
|
||||
.reply-down-reason-send:disabled { opacity: .6; cursor: default; }
|
||||
.reply-down-reason button:focus-visible, .reply-down-reason-note:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
|
||||
.message-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user