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>
132 lines
4.1 KiB
TypeScript
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 };
|
|
}
|