Files
oma-memorial/src/app/api/contributions/route.ts
T
denshooter c242976b41 fix: configurable OLLAMA_URL for Docker container
Ollama runs on host, container needs host.docker.internal mapping.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-18 13:38:12 +01:00

231 lines
6.9 KiB
TypeScript

import { NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
export const runtime = 'nodejs'
// Simple bad word check
function hasBadWords(text: string): { flag: boolean; reason?: string } {
const lower = text.toLowerCase()
const badWords = [
{ word: 'hurensohn', reason: 'Beleidigung' },
{ word: 'arschloch', reason: 'Beleidigung' },
{ word: 'wichser', reason: 'Beleidigung' },
{ word: 'fotze', reason: 'Beleidigung' },
{ word: 'spam', reason: 'Spam-Verdacht' },
{ word: 'werbung', reason: 'Werbung' },
{ word: 'casino', reason: 'Werbung' },
{ word: 'viagra', reason: 'Werbung' }
]
for (const { word, reason } of badWords) {
if (lower.includes(word)) {
return { flag: true, reason }
}
}
return { flag: false }
}
// Background AI moderation with Ollama
async function moderateWithAI(contributionId: number, content: string) {
console.log(`[AI-Mod] Starting for ${contributionId}`)
// Step 1: Instant bad word check
const lowerCheck = content.toLowerCase()
const badWords = ['hurensohn', 'arschloch', 'wichser', 'fotze']
const foundBadWord = badWords.find(word => lowerCheck.includes(word))
if (foundBadWord) {
console.log(`[AI-Mod] ⚠️ INSTANT FLAG: "${foundBadWord}" detected!`)
const db = getDb()
db.prepare(`
UPDATE contributions
SET status = 'flagged', moderation_reason = ?
WHERE id = ?
`).run(`Unangemessene Sprache: "${foundBadWord}"`, contributionId)
console.log(`[AI-Mod] ✅ FLAGGED ${contributionId} instantly`)
return
}
// Step 2: AI check for subtle issues (irrelevant content, hidden insults etc.)
console.log(`[AI-Mod] No bad words, asking AI...`)
try {
const prompt = `Ist dieser Text angemessen für eine Gedenkseite einer verstorbenen Großmutter?
"${content}"
ERLAUBT: Liebe, Vermissen, Trauer, Erinnerungen, persönliche Geschichten, Beileidsbekundungen
VERBOTEN: Beleidigungen, Spam, Hassrede, Werbung, völlig zusammenhanglose oder sinnlose Texte ohne Bezug
Antworte NUR mit JSON:
{"appropriate": true} oder {"appropriate": false, "reason": "kurze Begründung"}
JSON:`
const controller = new AbortController()
setTimeout(() => controller.abort(), 15000)
const ollamaUrl = process.env.OLLAMA_URL || 'http://localhost:11434'
const res = await fetch(`${ollamaUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
model: 'llama3.2:latest',
prompt,
stream: false,
options: {
temperature: 0.1,
num_predict: 60,
num_ctx: 512
}
})
})
if (!res.ok) {
console.warn(`[AI-Mod] Ollama error: ${res.status}`)
return
}
const data = await res.json()
const responseText = (data.response || '').trim()
console.log(`[AI-Mod] Response: "${responseText}"`)
// Parse JSON
let result: any = null
try {
const jsonMatch = responseText.match(/\{[^}]+\}/)
if (jsonMatch) {
result = JSON.parse(jsonMatch[0])
}
} catch (e) {
console.error(`[AI-Mod] Parse error:`, e)
}
// Flag if inappropriate
if (result && result.appropriate === false) {
const db = getDb()
db.prepare(`
UPDATE contributions
SET status = 'flagged', moderation_reason = ?
WHERE id = ?
`).run(result.reason || 'KI-Warnung', contributionId)
console.log(`[AI-Mod] ⚠️ FLAGGED ${contributionId}: ${result.reason}`)
} else {
console.log(`[AI-Mod] ✅ Passed ${contributionId}`)
}
} catch (error: any) {
if (error.name === 'AbortError') {
console.warn(`[AI-Mod] Timeout for ${contributionId}`)
} else {
console.error(`[AI-Mod] Error for ${contributionId}:`, error.message)
}
}
}
export async function POST(request: Request) {
try {
const body = await request.json()
const { name, type, title, content, year, month, day, location, media_filenames } = body
if (!type) {
return NextResponse.json(
{ error: 'Type is required' },
{ status: 400 }
)
}
// Require content for memory type, title for timeline
if (type === 'memory' && !content) {
return NextResponse.json(
{ error: 'Content is required for memories' },
{ status: 400 }
)
}
if (type === 'timeline' && !title) {
return NextResponse.json(
{ error: 'Title is required for timeline entries' },
{ status: 400 }
)
}
const db = getDb()
// 1. Check bad words instantly - clean content is auto-approved
const textToCheck = [content, title].filter(Boolean).join(' ')
const badWordCheck = textToCheck ? hasBadWords(textToCheck) : { flag: false }
const initialStatus = badWordCheck.flag ? 'flagged' : 'approved'
const moderationReason = badWordCheck.flag ? (badWordCheck as any).reason : null
// 2. Insert contribution with all fields
const result = db.prepare(`
INSERT INTO contributions (name, type, title, content, year, month, day, location, media_filenames, status, moderation_reason)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
name || 'Anonym',
type,
title || null,
content || null,
year || null,
month || null,
day || null,
location || null,
media_filenames || null,
initialStatus,
moderationReason || null
)
const contributionId = Number(result.lastInsertRowid)
console.log(`[API] Created contribution ${contributionId}, type: ${type}, status: ${initialStatus}`)
// 3. If not already flagged and has text, run AI check in background
if (!badWordCheck.flag && textToCheck) {
moderateWithAI(contributionId, textToCheck).catch(e =>
console.error('[AI-Mod] Background error:', e)
)
}
return NextResponse.json({
success: true,
id: contributionId,
message: 'Beitrag wurde gespeichert'
})
} catch (error) {
console.error('[API] Error:', error)
return NextResponse.json(
{ error: 'Failed to create contribution' },
{ status: 500 }
)
}
}
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const status = searchParams.get('status')
const db = getDb()
let query: string
let params: string[] = []
if (status) {
query = 'SELECT * FROM contributions WHERE status = ? ORDER BY created_at DESC'
params = [status]
} else {
// Return ALL contributions (admin needs all, public will filter client-side)
query = 'SELECT * FROM contributions ORDER BY created_at DESC'
}
const contributions = db.prepare(query).all(...params)
return NextResponse.json(contributions)
} catch (error) {
console.error('[API] Error fetching:', error)
return NextResponse.json(
{ error: 'Failed to fetch contributions' },
{ status: 500 }
)
}
}