c242976b41
Ollama runs on host, container needs host.docker.internal mapping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
231 lines
6.9 KiB
TypeScript
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 }
|
|
)
|
|
}
|
|
}
|