feat(compliance): content moderation on model input and output — local lexicon, Aliyun adapter, free completion, admin log
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
e742da268d
commit
4f3d8d7c62
@@ -0,0 +1,2 @@
|
||||
import ModerationEventsResource from "@/components/admin/moderation-events-resource";
|
||||
export default function Page() { return <ModerationEventsResource />; }
|
||||
@@ -0,0 +1,101 @@
|
||||
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";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type ModerationRow = {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
side: string;
|
||||
verdict: string;
|
||||
route: string;
|
||||
categories: string[];
|
||||
rule_ids: string[];
|
||||
provider: string;
|
||||
model_id: string | null;
|
||||
request_id: string | null;
|
||||
created_at: Date;
|
||||
total_count: string;
|
||||
};
|
||||
|
||||
type SummaryRow = { day: string; category: string; hits: string };
|
||||
|
||||
const sortColumns = new Map([
|
||||
["createdAt", "m.created_at"],
|
||||
["route", "m.route"],
|
||||
]);
|
||||
|
||||
export const POST = readonlyAdminMutation;
|
||||
export const PUT = readonlyAdminMutation;
|
||||
export const PATCH = readonlyAdminMutation;
|
||||
export const DELETE = readonlyAdminMutation;
|
||||
|
||||
/**
|
||||
* Content moderation hits (2026-09-30). Read-only, audit permission. Rows
|
||||
* carry categories and rule ids only — no user or model text is stored.
|
||||
* `?summary=1` returns hit counts per category per day for the last 14 days.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requirePermission("audit.read");
|
||||
if (new URL(request.url).searchParams.get("summary") === "1") {
|
||||
const rows = await queryAdminRows<SummaryRow>(`
|
||||
select to_char(date_trunc('day', m.created_at), 'YYYY-MM-DD') as day, c.category, count(*)::text as hits
|
||||
from public.moderation_events m, unnest(m.categories) as c(category)
|
||||
where m.created_at >= now() - interval '14 days'
|
||||
group by 1, 2
|
||||
order by 1 desc, 3 desc
|
||||
`, []);
|
||||
return NextResponse.json({ data: rows.map((row) => ({ day: row.day, category: row.category, hits: Number(row.hits) })) });
|
||||
}
|
||||
const parsed = parseListQuery(request);
|
||||
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
|
||||
const { page, pageSize, sort, order, q, status } = parsed.data;
|
||||
const values: unknown[] = [];
|
||||
const conditions: string[] = [];
|
||||
if (q) {
|
||||
values.push(`%${q}%`);
|
||||
conditions.push(`(m.route ilike $${values.length} or m.request_id ilike $${values.length} or m.user_id::text ilike $${values.length})`);
|
||||
}
|
||||
if (status === "input" || status === "output") {
|
||||
values.push(status);
|
||||
conditions.push(`m.side = $${values.length}`);
|
||||
}
|
||||
values.push(pageSize, pageOffset(page, pageSize));
|
||||
const sortColumn = sortColumns.get(sort ?? "createdAt") ?? "m.created_at";
|
||||
const rows = await queryAdminRows<ModerationRow>(`
|
||||
select m.id::text as id, m.user_id, m.side, m.verdict, m.route, m.categories, m.rule_ids, m.provider,
|
||||
m.model_id, m.request_id, m.created_at, count(*) over()::text as total_count
|
||||
from public.moderation_events m
|
||||
${conditions.length ? `where ${conditions.join(" and ")}` : ""}
|
||||
order by ${sortColumn} ${order === "asc" ? "asc" : "desc"}, m.id desc
|
||||
limit $${values.length - 1} offset $${values.length}
|
||||
`, values);
|
||||
return NextResponse.json({
|
||||
data: rows.map((row) => ({
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
side: row.side,
|
||||
verdict: row.verdict,
|
||||
route: row.route,
|
||||
categories: row.categories,
|
||||
ruleIds: row.rule_ids,
|
||||
provider: row.provider,
|
||||
modelId: row.model_id,
|
||||
requestId: row.request_id,
|
||||
createdAt: row.created_at.toISOString(),
|
||||
})),
|
||||
total: Number(rows[0]?.total_count ?? 0),
|
||||
});
|
||||
} catch (error) {
|
||||
return adminErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
import { StaleJourneyTurnError } from "@/lib/birth-time-journey-turn-persistence";
|
||||
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { inputBlockedCopy, moderate } from "@/lib/moderation";
|
||||
import { recordModerationEvent } from "@/lib/moderation/log";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { getBirthTimeGuideAgent } from "@/mastra";
|
||||
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
|
||||
@@ -59,6 +61,21 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Content moderation, input side (2026-09-30): typed text never reaches the guide model if it fails.
|
||||
const typedText = parsed.data.type === "draft_evidence" ? parsed.data.message
|
||||
: parsed.data.type === "reframe_unmatched" ? parsed.data.note : "";
|
||||
if (typedText.trim()) {
|
||||
const context = { route: "birth-time-guide", userId: user.id };
|
||||
const verdict = await moderate(typedText, "input", context);
|
||||
if (verdict.verdict !== "pass") await recordModerationEvent(createAdminSupabaseClient() as never, verdict, "input", context);
|
||||
if (verdict.verdict === "block") {
|
||||
return NextResponse.json(
|
||||
{ error: "无法处理该请求", code: "content_blocked", message: inputBlockedCopy(verdict) },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const store = createSupabaseBirthTimeJourneyStore(createAdminSupabaseClient());
|
||||
const actions = createJourneyTurnActions({ store });
|
||||
const journey = createBirthTimeJourneyService({
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
runConsultationWorkflow,
|
||||
} from "@/mastra";
|
||||
import { blocksPromptExtraction } from "@/lib/consult-safety";
|
||||
import { inputBlockedCopy, moderate, MODERATION_OUTPUT_REPLACED_COPY } from "@/lib/moderation";
|
||||
import { recordModerationEvent } from "@/lib/moderation/log";
|
||||
import { consultationDomainSchema } from "@/lib/consultation-domain-registry";
|
||||
import { parseAgentReply } from "@/lib/agent-reply";
|
||||
import { createConsultationReplyMetadata } from "@/lib/consultation-reply-metadata";
|
||||
@@ -409,6 +411,17 @@ export async function POST(request: Request) {
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
// Content moderation, input side (2026-09-30): before any reservation, so a
|
||||
// blocked question is never sent to a model and never charged.
|
||||
const moderationContext = { route: "consult", userId: user.id, requestId: parsed.data.requestId, modelId: chatSession.model_id };
|
||||
const inputModeration = await moderate(visibleQuestion, "input", moderationContext);
|
||||
if (inputModeration.verdict !== "pass") await recordModerationEvent(accounting, inputModeration, "input", moderationContext);
|
||||
if (inputModeration.verdict === "block") {
|
||||
return NextResponse.json(
|
||||
{ error: "无法处理该请求", code: "content_blocked", message: inputBlockedCopy(inputModeration) },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
type SelectedModel = NonNullable<Awaited<ReturnType<typeof resolveSessionLanguageModel>>>;
|
||||
type ReservationResult = { success: boolean; credits: number | null; error_code: string | null };
|
||||
type ModelSelection = Awaited<ReturnType<typeof reserveConsultationModel<SelectedModel, ReservationResult>>>;
|
||||
@@ -785,6 +798,38 @@ export async function POST(request: Request) {
|
||||
};
|
||||
}
|
||||
|
||||
// Content moderation, output side (2026-09-30). The finished answer is
|
||||
// checked once; a hit replaces it with a notice and the turn completes free.
|
||||
let outputModerated = false;
|
||||
let outputBlocked = false;
|
||||
async function moderateAnswer(text: string): Promise<string | null> {
|
||||
outputModerated = true;
|
||||
const verdict = await moderate(text, "output", moderationContext);
|
||||
if (verdict.verdict !== "pass") await recordModerationEvent(accounting, verdict, "output", moderationContext);
|
||||
if (verdict.verdict !== "block") return null;
|
||||
outputBlocked = true;
|
||||
return MODERATION_OUTPUT_REPLACED_COPY;
|
||||
}
|
||||
|
||||
async function completeModeratedResponse(usage: Promise<{ inputTokens?: number; outputTokens?: number }>): Promise<AgentSettlementResult> {
|
||||
const actualUsage = await usagePayload(usage);
|
||||
await retryDetachedSettlement(async () => {
|
||||
const { data, error } = await accounting.rpc("complete_consultation_moderated", {
|
||||
p_user_id: userId,
|
||||
p_request_id: requestId,
|
||||
p_session_id: sessionId,
|
||||
p_response_message: { role: "assistant", text: MODERATION_OUTPUT_REPLACED_COPY, responseKind: "moderated" },
|
||||
p_actual_usage: actualUsage,
|
||||
});
|
||||
const result = consultationCompletionSchema.safeParse(first(data ?? []));
|
||||
if (error || !result.success || !result.data.success) {
|
||||
throw new CreditRpcError(error?.message || (result.success ? result.data.error_code : "invalid_moderated_completion") || "moderated_completion_rejected");
|
||||
}
|
||||
return result.data;
|
||||
});
|
||||
return "completed";
|
||||
}
|
||||
|
||||
async function completeResponse(
|
||||
rawTransformedText: string,
|
||||
usage: Promise<{ inputTokens?: number; outputTokens?: number }>,
|
||||
@@ -800,6 +845,8 @@ export async function POST(request: Request) {
|
||||
createConsultationReplyMetadata({ question: visibleQuestion }),
|
||||
);
|
||||
if (!reply.text) throw new Error("empty_agent_reply");
|
||||
if (!outputModerated) await moderateAnswer(reply.text);
|
||||
if (outputBlocked) return await completeModeratedResponse(usage);
|
||||
const persistedThinking = thinkingText?.trim().slice(0, 4_000);
|
||||
const responseMessage = {
|
||||
role: "assistant" as const,
|
||||
@@ -1164,6 +1211,7 @@ export async function POST(request: Request) {
|
||||
: {}),
|
||||
});
|
||||
return streamAgentResponse({
|
||||
moderateOutput: moderateAnswer,
|
||||
runId: requestId,
|
||||
requestId,
|
||||
sideEvent: titleSideEvent,
|
||||
@@ -1266,6 +1314,7 @@ export async function POST(request: Request) {
|
||||
: {}),
|
||||
});
|
||||
return streamAgentResponse({
|
||||
moderateOutput: moderateAnswer,
|
||||
runId: requestId,
|
||||
requestId,
|
||||
sideEvent: titleSideEvent,
|
||||
@@ -1402,6 +1451,7 @@ export async function POST(request: Request) {
|
||||
: {}),
|
||||
});
|
||||
return streamAgentResponse({
|
||||
moderateOutput: moderateAnswer,
|
||||
runId: requestId,
|
||||
requestId,
|
||||
sideEvent: titleSideEvent,
|
||||
|
||||
Reference in New Issue
Block a user