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:
Jesse_Chen
2026-09-30 09:42:11 +08:00
co-authored by Claude Opus 5.5
parent e742da268d
commit 4f3d8d7c62
28 changed files with 1143 additions and 6 deletions
@@ -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({
+50
View File
@@ -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,