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
@@ -31,6 +31,8 @@ export const adminPermissions = [
|
||||
"audit.read",
|
||||
"support.feedback.read",
|
||||
"support.feedback.write",
|
||||
"support.quality.read",
|
||||
"support.quality.write",
|
||||
] as const;
|
||||
|
||||
export type AdminPermission = (typeof adminPermissions)[number];
|
||||
|
||||
@@ -206,6 +206,7 @@ const resourcePermissions: Record<string, { read: string; write?: string }> = {
|
||||
usage: { read: "billing.orders.read" },
|
||||
"rectification-telemetry": { read: "admin.customers.read" },
|
||||
feedback: { read: "support.feedback.read", write: "support.feedback.write" },
|
||||
"reply-quality": { read: "support.quality.read", write: "support.quality.write" },
|
||||
models: { read: "models.read", write: "models.write" },
|
||||
"model-releases": { read: "models.read", write: "models.publish" },
|
||||
"feature-flags": { read: "admin.access", write: "ops.flags.write" },
|
||||
|
||||
@@ -124,6 +124,7 @@ export const PRIVACY: LegalDocument = {
|
||||
"· 使用内容:你与服务的对话内容、出生时间校正过程中的回答、生成的个人报告与合盘结果。",
|
||||
"· 交易信息:点数与会员的订单、兑换码使用与点数流水;在线支付时由支付服务商处理支付信息,我们不保存你的银行卡或支付账户密码。",
|
||||
"· 反馈信息:你通过「反馈与投诉」提交的内容与所附的会话信息。",
|
||||
"· 回复评价:你对回答点「赞」时,我们只记录一次计数;你点「踩」时,我们会保存这一轮的提问与回答、模型当时参考的前文对话(可能包含你在对话里提到的出生资料等内容)、所用模型与耗时等运行信息,以及你选填的不满意原因与补充说明。改为点赞或取消点踩时,这份记录随之删除。",
|
||||
"· 设备与日志信息:为保障安全与排查故障,服务器会记录访问时间、请求地址、错误信息等必要日志【待确认:日志是否包含 IP 地址及保存期限】。浏览器中会保存登录状态 Cookie 与少量本地偏好(例如当前选中的人物)。",
|
||||
],
|
||||
},
|
||||
@@ -135,6 +136,7 @@ export const PRIVACY: LegalDocument = {
|
||||
"· 登录验证、账号安全、防止滥用(例如验证码发送与请求频率限制);",
|
||||
"· 点数扣除与退回、订单处理与客户服务;",
|
||||
"· 内容安全检查:对提问与模型回答进行检查,命中规则时拦截或替换,并记录去除身份信息的命中日志以便改进规则;",
|
||||
"· 排查回答质量问题:由经授权的工作人员查看你点「踩」时保存的那一轮对话,找出答非所问、内容不准等问题的原因并改进服务;每次查看都会记入内部审计日志;",
|
||||
"· 以去除个人身份信息的汇总统计改进服务(例如出生时间校正的问题数量与收敛情况,不含出生资料与对话原文)。",
|
||||
"我们不会用你的出生资料或对话内容做与上述目的无关的用途,也不会出售你的个人信息。",
|
||||
],
|
||||
@@ -160,6 +162,7 @@ export const PRIVACY: LegalDocument = {
|
||||
"· 注销账号:冷静期 7 天满后,上述数据与账号一并删除;法律法规要求保留的订单与点数流水去除个人身份信息后保存,期限依法确定【待确认:保存年限】;反馈与投诉保留处理记录并删除其中的联系方式【待确认:保存年限】。",
|
||||
"· 内容安全命中日志:去除身份信息后保存【待确认:保存期限】。",
|
||||
"· 出生时间校正的匿名统计:保存 180 天。",
|
||||
"· 点「踩」时保存的对话记录:保存 90 天,期满后清空对话内容,只保留评价与不满意原因用于统计;你删除该条对话或注销账号时一并删除。",
|
||||
"· 服务器日志:【待确认:保存期限】。",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import "server-only";
|
||||
|
||||
import { resolveSessionLanguageModel } from "./model-catalog";
|
||||
import { buildReplyQualitySnapshot } from "./reply-quality-snapshot";
|
||||
|
||||
/** The narrow part of the service client this module uses. */
|
||||
export type ReplyQualityService = {
|
||||
from(table: string): {
|
||||
select(columns: string): {
|
||||
eq(column: string, value: unknown): {
|
||||
eq(column: string, value: unknown): { maybeSingle(): PromiseLike<{ data: unknown; error: unknown }> };
|
||||
};
|
||||
};
|
||||
};
|
||||
rpc(name: string, args: Record<string, unknown>): PromiseLike<{ data: unknown; error: { message?: string } | null }>;
|
||||
};
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
export type ReplyQualityCaptureResult = "saved" | "skipped" | "failed";
|
||||
|
||||
/**
|
||||
* Keeps the 故障上下文快照 for a 👎 (compliance round 2026-09-30): reads the
|
||||
* stored session, rebuilds the turn and its context, adds the run facts the
|
||||
* usage ledger already has for that request (model, tokens, duration), and
|
||||
* saves through save_reply_quality_snapshot(), which refuses unless the reply
|
||||
* is currently rated 👎.
|
||||
*/
|
||||
export async function captureReplyQualitySnapshot(
|
||||
service: ReplyQualityService,
|
||||
input: Readonly<{ userId: string; sessionId: string; messageIndex: number; answerSha256: string }>,
|
||||
): Promise<ReplyQualityCaptureResult> {
|
||||
const { data: sessionRow, error: sessionError } = await service
|
||||
.from("chat_sessions")
|
||||
.select("id,model_id,model_config_version,session_type,messages,context_summary")
|
||||
.eq("id", input.sessionId)
|
||||
.eq("user_id", input.userId)
|
||||
.maybeSingle();
|
||||
const session = record(sessionRow);
|
||||
if (sessionError || !session) return "skipped";
|
||||
const sessionType = session.session_type === "birth_time_rectification" ? "birth_time_rectification" : "consultation";
|
||||
const modelId = typeof session.model_id === "string" ? session.model_id : null;
|
||||
let contextWindow: number | null = null;
|
||||
if (modelId) {
|
||||
const model = await resolveSessionLanguageModel(
|
||||
modelId,
|
||||
typeof session.model_config_version === "number" ? session.model_config_version : null,
|
||||
).catch(() => null);
|
||||
contextWindow = typeof model?.contextWindow === "number" ? model.contextWindow : null;
|
||||
}
|
||||
const built = buildReplyQualitySnapshot({
|
||||
messages: session.messages,
|
||||
messageIndex: input.messageIndex,
|
||||
answerSha256: input.answerSha256,
|
||||
sessionType,
|
||||
contextSummary: session.context_summary,
|
||||
contextWindow,
|
||||
});
|
||||
if (!built.ok) return "skipped";
|
||||
|
||||
let runFacts: Record<string, unknown> = { contextWindow };
|
||||
if (built.snapshot.requestId) {
|
||||
const { data: ledger } = await service
|
||||
.from("usage_ledger")
|
||||
.select("actual_model_id,requested_model_id,input_tokens,output_tokens,duration_ms,feature_key")
|
||||
.eq("user_id", input.userId)
|
||||
.eq("request_id", built.snapshot.requestId)
|
||||
.maybeSingle();
|
||||
const row = record(ledger);
|
||||
if (row) {
|
||||
runFacts = {
|
||||
...runFacts,
|
||||
actualModelId: row.actual_model_id ?? null,
|
||||
requestedModelId: row.requested_model_id ?? null,
|
||||
inputTokens: row.input_tokens ?? null,
|
||||
outputTokens: row.output_tokens ?? null,
|
||||
durationMs: row.duration_ms ?? null,
|
||||
featureKey: row.feature_key ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await service.rpc("save_reply_quality_snapshot", {
|
||||
p_user_id: input.userId,
|
||||
p_session_id: input.sessionId,
|
||||
p_message_index: input.messageIndex,
|
||||
p_session_type: sessionType,
|
||||
p_model_id: typeof runFacts.actualModelId === "string" ? runFacts.actualModelId : modelId,
|
||||
p_request_id: built.snapshot.requestId,
|
||||
p_question: built.snapshot.question,
|
||||
p_answer: built.snapshot.answer,
|
||||
p_context: built.snapshot.context,
|
||||
p_run_facts: runFacts,
|
||||
});
|
||||
return error ? "failed" : "saved";
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 对话质量记录 vocabulary shared by the 👎 picker, the API and the admin page
|
||||
* (compliance round 2026-09-30). Client-safe: no node imports.
|
||||
*/
|
||||
export const REPLY_QUALITY_REASONS = ["off_topic", "inaccurate", "too_long_or_empty", "tone", "other"] as const;
|
||||
export type ReplyQualityReason = (typeof REPLY_QUALITY_REASONS)[number];
|
||||
|
||||
/** 不满意原因, in the order the picker shows them. */
|
||||
export const REPLY_QUALITY_REASON_LABELS: Readonly<Record<ReplyQualityReason, string>> = {
|
||||
off_topic: "答非所问",
|
||||
inaccurate: "内容不准",
|
||||
too_long_or_empty: "太长太空",
|
||||
tone: "语气不对",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
export const REPLY_QUALITY_NOTE_MAX = 200;
|
||||
export const REPLY_QUALITY_RETENTION_DAYS = 90;
|
||||
|
||||
/** 处理状态 of a 对话质量记录. */
|
||||
export const REPLY_QUALITY_STATUSES = ["new", "in_progress", "resolved", "ignored"] as const;
|
||||
export type ReplyQualityStatus = (typeof REPLY_QUALITY_STATUSES)[number];
|
||||
|
||||
export const REPLY_QUALITY_STATUS_LABELS: Readonly<Record<ReplyQualityStatus, string>> = {
|
||||
new: "待处理",
|
||||
in_progress: "处理中",
|
||||
resolved: "已解决",
|
||||
ignored: "忽略",
|
||||
};
|
||||
|
||||
export type ReplyQualityFilter = Readonly<{
|
||||
status: ReplyQualityStatus | null;
|
||||
reason: ReplyQualityReason | null;
|
||||
modelId: string | null;
|
||||
days: number | null;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The admin table has one filter slot; it carries `status`, `reason:<key>`,
|
||||
* `model:<id>` or `days:<n>`. Unknown values mean no filter.
|
||||
*/
|
||||
export function parseReplyQualityFilter(value: string | null | undefined): ReplyQualityFilter {
|
||||
const empty = { status: null, reason: null, modelId: null, days: null };
|
||||
if (!value) return empty;
|
||||
if ((REPLY_QUALITY_STATUSES as readonly string[]).includes(value)) return { ...empty, status: value as ReplyQualityStatus };
|
||||
const [kind, rest] = [value.slice(0, value.indexOf(":")), value.slice(value.indexOf(":") + 1)];
|
||||
if (kind === "reason" && (REPLY_QUALITY_REASONS as readonly string[]).includes(rest)) return { ...empty, reason: rest as ReplyQualityReason };
|
||||
if (kind === "model" && /^[\w.:/-]{1,120}$/.test(rest)) return { ...empty, modelId: rest };
|
||||
if (kind === "days" && /^\d{1,2}$/.test(rest) && Number(rest) >= 1 && Number(rest) <= 90) return { ...empty, days: Number(rest) };
|
||||
return empty;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { REPLY_QUALITY_RETENTION_DAYS } from "./reply-quality-labels.ts";
|
||||
|
||||
/**
|
||||
* 对话质量记录 retention (compliance round 2026-09-30): snapshot bodies older
|
||||
* than 90 days are blanked by expire_reply_quality_snapshot_bodies(); date,
|
||||
* model, 不满意原因 and 处理状态 stay for statistics. Logs carry a count only.
|
||||
*/
|
||||
export const REPLY_QUALITY_RETENTION_TICK_MS = 6 * 60 * 60 * 1000;
|
||||
export const REPLY_QUALITY_RETENTION_FIRST_TICK_MS = 2 * 60 * 1000;
|
||||
|
||||
export async function runReplyQualityRetentionTick(deps: Readonly<{
|
||||
expire: (days: number) => Promise<number | null>;
|
||||
log: (line: string) => void;
|
||||
}>): Promise<number | null> {
|
||||
const expired = await deps.expire(REPLY_QUALITY_RETENTION_DAYS);
|
||||
if (expired === null) deps.log("[reply-quality-retention] expire failed");
|
||||
else if (expired > 0) deps.log(`[reply-quality-retention] expired=${expired}`);
|
||||
return expired;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import "server-only";
|
||||
|
||||
import {
|
||||
REPLY_QUALITY_RETENTION_FIRST_TICK_MS,
|
||||
REPLY_QUALITY_RETENTION_TICK_MS,
|
||||
runReplyQualityRetentionTick,
|
||||
} from "@/lib/reply-quality-retention-core";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
|
||||
type WorkerGlobal = typeof globalThis & { jyotishaReplyQualityRetention?: { stop: () => void } };
|
||||
|
||||
async function tick(): Promise<void> {
|
||||
const admin = createAdminSupabaseClient();
|
||||
await runReplyQualityRetentionTick({
|
||||
expire: async (days) => {
|
||||
const { data, error } = await admin.rpc("expire_reply_quality_snapshot_bodies", { p_days: days });
|
||||
return error ? null : typeof data === "number" ? data : 0;
|
||||
},
|
||||
log: (line) => console.info(line),
|
||||
});
|
||||
}
|
||||
|
||||
/** One unref'ed timer per process: two minutes after start, then every six hours. Idempotent. */
|
||||
export function startReplyQualityRetentionWorker(): void {
|
||||
const state = globalThis as WorkerGlobal;
|
||||
if (state.jyotishaReplyQualityRetention) return;
|
||||
const run = () => {
|
||||
tick().catch((error: unknown) => {
|
||||
console.error(`[reply-quality-retention] tick failed reason=${error instanceof Error ? error.name : "UnknownError"}`);
|
||||
});
|
||||
};
|
||||
const first = setTimeout(run, REPLY_QUALITY_RETENTION_FIRST_TICK_MS);
|
||||
const every = setInterval(run, REPLY_QUALITY_RETENTION_TICK_MS);
|
||||
first.unref?.();
|
||||
every.unref?.();
|
||||
state.jyotishaReplyQualityRetention = { stop: () => { clearTimeout(first); clearInterval(every); } };
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { REPLY_QUALITY_NOTE_MAX, REPLY_QUALITY_REASONS, type ReplyQualityReason } from "./reply-quality-labels.ts";
|
||||
import {
|
||||
consultationHistoryWindow,
|
||||
parseSessionContextSummary,
|
||||
} from "./consultation-session-history.ts";
|
||||
|
||||
/**
|
||||
* 故障上下文快照 for a 负向回复评价 (compliance round 2026-09-30).
|
||||
*
|
||||
* Built on the server from the stored session only — the client sends the
|
||||
* rated position and the hash it saw, never text. The rated reply must still
|
||||
* be the stored reply at that position (same 16-hex hash the client computes),
|
||||
* otherwise nothing is kept: a regenerated or not-yet-stored answer is not
|
||||
* the one the user judged.
|
||||
*
|
||||
* The context is the window the consultation route gives the model for that
|
||||
* turn — `consultationHistoryWindow` over the messages before the question,
|
||||
* with the session summary when it already covered them and the model's
|
||||
* context-window budget. It is a reconstruction: a summary written after that
|
||||
* turn is not used, and a model change since then only moves the budget.
|
||||
* Rectification sessions assemble context differently; for them the snapshot
|
||||
* keeps the last six turns before the question and says so.
|
||||
*/
|
||||
|
||||
export {
|
||||
REPLY_QUALITY_NOTE_MAX,
|
||||
REPLY_QUALITY_REASON_LABELS,
|
||||
REPLY_QUALITY_REASONS,
|
||||
REPLY_QUALITY_RETENTION_DAYS,
|
||||
type ReplyQualityReason,
|
||||
} from "./reply-quality-labels.ts";
|
||||
const RECTIFICATION_CONTEXT_TURNS = 6;
|
||||
const TEXT_MAX = 16_000;
|
||||
|
||||
export type ReplyQualityContext = Readonly<{
|
||||
kind: "consultation_window" | "rectification_recent_turns";
|
||||
summaryText: string | null;
|
||||
droppedCount: number;
|
||||
turns: readonly Readonly<{ role: "user" | "assistant"; text: string }>[];
|
||||
}>;
|
||||
|
||||
export type ReplyQualitySnapshot = Readonly<{
|
||||
question: string | null;
|
||||
answer: string;
|
||||
requestId: string | null;
|
||||
context: ReplyQualityContext;
|
||||
}>;
|
||||
|
||||
export type ReplyQualitySnapshotFailure = "not_assistant" | "answer_not_stored" | "answer_changed";
|
||||
|
||||
/** Same first 16 hex characters of SHA-256 the client stores with a rating. */
|
||||
export function replyAnswerHash(text: string): string {
|
||||
return createHash("sha256").update(text, "utf8").digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
type StoredMessage = { role?: unknown; text?: unknown; requestId?: unknown };
|
||||
|
||||
function asMessages(value: unknown): StoredMessage[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is StoredMessage => Boolean(item) && typeof item === "object") : [];
|
||||
}
|
||||
|
||||
function clip(text: string): string {
|
||||
return text.length > TEXT_MAX ? text.slice(0, TEXT_MAX) : text;
|
||||
}
|
||||
|
||||
export function buildReplyQualitySnapshot(input: Readonly<{
|
||||
messages: unknown;
|
||||
messageIndex: number;
|
||||
answerSha256: string;
|
||||
sessionType: "consultation" | "birth_time_rectification";
|
||||
contextSummary?: unknown;
|
||||
contextWindow?: number | null;
|
||||
}>): { ok: true; snapshot: ReplyQualitySnapshot } | { ok: false; reason: ReplyQualitySnapshotFailure } {
|
||||
const messages = Array.isArray(input.messages) ? input.messages : [];
|
||||
const rated = messages[input.messageIndex] as StoredMessage | undefined;
|
||||
if (!rated || typeof rated !== "object") return { ok: false, reason: "answer_not_stored" };
|
||||
if (rated.role !== "assistant") return { ok: false, reason: "not_assistant" };
|
||||
if (typeof rated.text !== "string" || !rated.text) return { ok: false, reason: "answer_not_stored" };
|
||||
if (replyAnswerHash(rated.text) !== input.answerSha256) return { ok: false, reason: "answer_changed" };
|
||||
|
||||
let questionIndex = -1;
|
||||
for (let index = input.messageIndex - 1; index >= 0; index -= 1) {
|
||||
const candidate = messages[index] as StoredMessage | undefined;
|
||||
if (candidate?.role === "user" && typeof candidate.text === "string" && candidate.text) {
|
||||
questionIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const question = questionIndex >= 0 ? String((messages[questionIndex] as StoredMessage).text) : null;
|
||||
const before = messages.slice(0, Math.max(0, questionIndex));
|
||||
const requestId = typeof rated.requestId === "string"
|
||||
? rated.requestId
|
||||
: questionIndex >= 0 && typeof (messages[questionIndex] as StoredMessage).requestId === "string"
|
||||
? String((messages[questionIndex] as StoredMessage).requestId)
|
||||
: null;
|
||||
|
||||
let context: ReplyQualityContext;
|
||||
if (input.sessionType === "consultation") {
|
||||
const summary = parseSessionContextSummary(input.contextSummary);
|
||||
// A summary written after this turn was not what the model read then.
|
||||
const usableSummary = summary && summary.throughMessageIndex < questionIndex ? summary : null;
|
||||
const window = consultationHistoryWindow(before, usableSummary, { contextWindow: input.contextWindow ?? null });
|
||||
context = {
|
||||
kind: "consultation_window",
|
||||
summaryText: window.summaryText,
|
||||
droppedCount: window.droppedCount,
|
||||
turns: window.tail.map((turn) => ({ role: turn.role, text: clip(turn.text) })),
|
||||
};
|
||||
} else {
|
||||
const turns = asMessages(before)
|
||||
.filter((message) => (message.role === "user" || message.role === "assistant") && typeof message.text === "string" && message.text)
|
||||
.slice(-RECTIFICATION_CONTEXT_TURNS)
|
||||
.map((message) => ({ role: message.role as "user" | "assistant", text: clip(String(message.text)) }));
|
||||
context = { kind: "rectification_recent_turns", summaryText: null, droppedCount: 0, turns };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
snapshot: { question: question === null ? null : clip(question), answer: clip(rated.text), requestId, context },
|
||||
};
|
||||
}
|
||||
|
||||
/** Keeps only known reasons, in picker order, and a trimmed note. */
|
||||
export function normalizeReplyQualityReasons(reasons: readonly unknown[], note: unknown): {
|
||||
reasons: ReplyQualityReason[];
|
||||
note: string | null;
|
||||
} {
|
||||
const picked = new Set(reasons.filter((reason): reason is ReplyQualityReason => (
|
||||
typeof reason === "string" && (REPLY_QUALITY_REASONS as readonly string[]).includes(reason)
|
||||
)));
|
||||
const text = typeof note === "string" ? note.trim().slice(0, REPLY_QUALITY_NOTE_MAX) : "";
|
||||
return { reasons: REPLY_QUALITY_REASONS.filter((reason) => picked.has(reason)), note: text || null };
|
||||
}
|
||||
@@ -24,11 +24,14 @@ export async function answerHash(text: string): Promise<string> {
|
||||
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();
|
||||
return answerHash(text)
|
||||
const saved = answerHash(text)
|
||||
.then((answerSha256) => fetch("/api/reply-ratings", {
|
||||
method: "PUT",
|
||||
credentials: "same-origin",
|
||||
@@ -36,6 +39,33 @@ export function persistReplyRating(feedbackKey: string, rating: ChatMessageFeedb
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user