cards/docs/marketplace/archive/code/lib/ai-moderation.ts
Till JS 7dbbf63523 Phase 12 R2: Marketplace-Backend α + β — Authors + Deck-Init + Publish
Routes (additiv unter /api/v1/marketplace/*):
- POST/GET /authors/me — eigenes Author-Profil anlegen/updaten/lesen
- GET /authors/:slug — public Profile-Lookup (banned-reason gestrippt)
- POST /decks — Deck-Init (Slug-Validation + Pflicht-Author-Profil +
  CHECK auf paid + Pro-License)
- POST /decks/:slug/publish — Versions-Snapshot mit per-Karte
  cardContentHash aus @cards/domain, per-Version-Hash, AI-Mod-Stub-Log,
  atomarer latest_version_id-Bump in Drizzle-Transaction
- PATCH /decks/:slug — Metadaten-Update (Owner-Only)
- GET /decks/:slug — Public-Detail mit optional-auth-Middleware

Geport aus cards-decommission-base:services/cards-server/, mit
Greenfield-Anpassungen:
- Hashing über @cards/domain.cardContentHash (gemeinsame SoT
  zwischen privatem cards.cards und marketplace.deck_cards), per-
  Version-Hash als SHA-256 über sortierte Karten-Hashes mit Ord-Prefix
- AI-Moderation als R2-Stub (pass+rationale+model='stub'),
  echte mana-llm-Anbindung in späterer Welle
- Auth-Middleware-Shape an Greenfield (userId/tier/authMode in
  c.get(...) statt user-Object), optional-auth als Schwester für
  anonymen Public-Read
- Hono-typing: outer Marketplace-Decks-Router ist Partial<AuthVars>
  weil Public-GET kein JWT braucht; Auth-Subroute ist strict

Lese-Referenz:
- 3331 LOC altes cards-server-Code (routes, services, middleware,
  lib) unter docs/marketplace/archive/code/ archiviert. Read-only,
  nicht im Build-Path.

Verifikation:
- 16 neue Vitest-Tests (Slug + Version-Hash), 72 gesamt grün
- type-check 0 errors
- E2E-Smoke gegen lokale cards-api: Cardecky-Author + Deck
  r2-stoische-ethik mit 3 Karten v1.0.0 (basic + basic + cloze),
  per-Karten-Hashes geschrieben, ai_moderation_log-Row da, semver-409
  + paid-422-Errors verifiziert. Smoke-Daten danach aufgeräumt.

Verbleibend für R3+: Discovery (explore + search), Engagement (stars/
subscribe/fork), Smart-Merge mit FSRS-State-Erhalt; danach R4 PRs +
Card-Discussions, R5 Frontend-Routes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:13:58 +02:00

132 lines
4.1 KiB
TypeScript

/**
* AI moderation first-pass via mana-llm.
*
* Asks the model to classify a deck's content into one of three
* verdicts: pass, flag, block. `flag` means a human reviewer should
* look at it before the deck goes public; `block` means refuse the
* publish outright.
*
* Per MARKETPLACE_PLAN principle 6: "AI is moderator, not gatekeeper"
* — `block` is only used for unambiguous offences (CSAM, real-world
* doxxing). Anything ambiguous flows to human review.
*
* Fail-open: if mana-llm is unreachable or returns malformed JSON,
* the verdict defaults to `flag` so the human reviewer catches it.
* Better a slow publish than a quietly-skipped check.
*/
const SYSTEM_PROMPT = `Du bist Inhalts-Moderator für eine Karteikarten-Plattform. Bewerte den vorgelegten Inhalt nach folgenden Kategorien:
- spam: Werbe-Spam, ohne erkennbaren Lerninhalt
- copyright: offensichtliche, lange Lehrbuch-Auszüge ohne Quelle/Lizenzhinweis
- nsfw: sexuell explizit, jugendgefährdend
- misinformation: nachweislich falsche Fakten als Tatsachen präsentiert (außerhalb subjektiver Themen)
- hate: Hassrede, Diskriminierung gegen geschützte Gruppen
- csam: Material, das Minderjährige sexualisiert (führt IMMER zu block)
Antworte AUSSCHLIESSLICH mit einem JSON-Objekt:
{"verdict":"pass|flag|block","categories":["..."],"rationale":"kurze Begründung"}
Regeln:
- pass: keine Kategorien getroffen
- flag: eine oder mehrere Kategorien außer csam
- block: csam ODER unmissverständliche Kombination aus mehreren schweren Kategorien
- Im Zweifel: flag (nicht block) — eine menschliche Moderatorin entscheidet final.`;
export interface ModerationVerdict {
verdict: 'pass' | 'flag' | 'block';
categories: string[];
rationale: string;
model: string;
}
export interface ModerationInput {
title: string;
description?: string;
cards: { fields: Record<string, string> }[];
}
const MODEL = process.env.AI_MODERATION_MODEL || 'gpt-4o-mini';
const MAX_CARDS_FOR_PROMPT = 50;
function buildPrompt(input: ModerationInput): string {
const sample = input.cards.slice(0, MAX_CARDS_FOR_PROMPT).map((c, i) => {
const fieldsStr = Object.entries(c.fields)
.map(([k, v]) => ` ${k}: ${v}`)
.join('\n');
return `Karte ${i + 1}:\n${fieldsStr}`;
});
return [
`Deck-Titel: ${input.title}`,
input.description ? `Beschreibung: ${input.description}` : '',
`Karten (${input.cards.length} insgesamt, erste ${sample.length} gezeigt):`,
...sample,
]
.filter(Boolean)
.join('\n\n');
}
function failOpen(rationale: string): ModerationVerdict {
return {
verdict: 'flag',
categories: ['_internal'],
rationale: `AI-Mod fail-open: ${rationale}`,
model: MODEL,
};
}
function stripCodeFences(s: string): string {
return s
.replace(/^\s*```(?:json)?\s*/i, '')
.replace(/\s*```\s*$/i, '')
.trim();
}
export async function moderateDeckContent(
input: ModerationInput,
llmUrl: string
): Promise<ModerationVerdict> {
let res: Response;
try {
res = await fetch(`${llmUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: MODEL,
temperature: 0,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: buildPrompt(input) },
],
}),
});
} catch (e) {
return failOpen(`network: ${(e as Error).message}`);
}
if (!res.ok) return failOpen(`http ${res.status}`);
const json = (await res.json().catch(() => null)) as {
choices?: { message?: { content?: string } }[];
} | null;
const raw = json?.choices?.[0]?.message?.content?.trim();
if (!raw) return failOpen('empty response');
let parsed: { verdict?: unknown; categories?: unknown; rationale?: unknown };
try {
parsed = JSON.parse(stripCodeFences(raw));
} catch {
return failOpen('invalid JSON');
}
const verdict =
parsed.verdict === 'pass' || parsed.verdict === 'flag' || parsed.verdict === 'block'
? parsed.verdict
: 'flag';
const categories = Array.isArray(parsed.categories)
? parsed.categories.filter((c): c is string => typeof c === 'string')
: [];
const rationale = typeof parsed.rationale === 'string' ? parsed.rationale : '';
return { verdict, categories, rationale, model: MODEL };
}