Initial human archive app
Some checks failed
Build and publish Docker image / docker (push) Failing after 2m33s

This commit is contained in:
2026-06-26 21:31:46 +02:00
parent ee2a0202d7
commit f29339cf12
38 changed files with 3644 additions and 112 deletions

66
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,66 @@
import Link from "next/link";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
export default async function AdminPage() {
const decisions = await getDecisions();
return (
<main className="min-h-screen bg-[#f4efe6] px-4 py-6 text-stone-950 sm:px-6">
<div className="mx-auto max-w-7xl">
<header className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[.18em] text-teal-800">Admin Center</p>
<h1 className="text-3xl font-semibold tracking-normal">KI-Moderation</h1>
</div>
<Link className="rounded-md border border-stone-300 bg-white px-3 py-2 text-sm font-semibold" href="/">
Zurueck
</Link>
</header>
<section className="overflow-hidden rounded-lg border border-stone-300 bg-white shadow-sm">
<div className="grid grid-cols-[1.2fr_.7fr_.6fr_.7fr] border-b border-stone-300 bg-stone-100 px-4 py-3 text-xs font-semibold uppercase tracking-[.12em] text-stone-600">
<span>Beitrag</span>
<span>Entscheidung</span>
<span>Vertrauen</span>
<span>Zeitpunkt</span>
</div>
{decisions.length === 0 ? (
<div className="p-6 text-sm text-stone-600">Noch keine Moderationsentscheidungen vorhanden.</div>
) : (
<div className="divide-y divide-stone-200">
{decisions.map((decision) => (
<article key={decision.id} className="grid gap-3 px-4 py-4 text-sm md:grid-cols-[1.2fr_.7fr_.6fr_.7fr]">
<div>
<h2 className="font-semibold">{decision.contribution.title}</h2>
<p className="mt-1 line-clamp-2 text-stone-600">{decision.reasoning}</p>
</div>
<span className="font-semibold">{decision.outcome}</span>
<span>{Math.round(decision.confidence * 100)}%</span>
<time className="text-stone-600">{decision.createdAt.toLocaleString("de-DE")}</time>
</article>
))}
</div>
)}
</section>
</div>
</main>
);
}
async function getDecisions() {
try {
return await prisma.moderationDecision.findMany({
include: {
contribution: true,
},
orderBy: {
createdAt: "desc",
},
take: 30,
});
} catch {
return [];
}
}

View File

@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getCurrentRole, hasRole } from "@/lib/auth";
import { prisma } from "@/lib/db";
const overrideSchema = z.object({
outcome: z.enum(["APPROVE", "REJECT", "ESCALATE"]),
reason: z.string().min(4).max(1200),
});
export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) {
const role = await getCurrentRole(request.headers);
if (!hasRole(role, ["ADMIN"])) {
return NextResponse.json({ error: "Admin role required." }, { status: 403 });
}
const parsed = overrideSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { id } = await context.params;
const decision = await prisma.moderationDecision.update({
where: { id },
data: {
outcome: parsed.data.outcome,
overrideReason: parsed.data.reason,
overriddenAt: new Date(),
},
include: {
contribution: true,
},
});
const status =
parsed.data.outcome === "APPROVE"
? "APPROVED"
: parsed.data.outcome === "REJECT"
? "REJECTED"
: "NEEDS_ADMIN_REVIEW";
await prisma.contribution.update({
where: {
id: decision.contributionId,
},
data: {
status,
},
});
return NextResponse.json({ decision });
}

View File

@@ -0,0 +1,6 @@
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -0,0 +1,104 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getCurrentRole, hasRole } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { toInputJson } from "@/lib/json";
import { moderateContribution } from "@/lib/moderation";
const sourceSchema = z.object({
title: z.string().min(1).max(240),
url: z.string().url(),
author: z.string().max(160).optional(),
institution: z.string().max(160).optional(),
license: z.string().min(1).max(120),
language: z.enum(["DE", "EN"]),
trustLevel: z.enum(["UNKNOWN", "LOW", "MEDIUM", "HIGH", "PRIMARY"]).default("UNKNOWN"),
notes: z.string().max(1000).optional(),
});
const contributionSchema = z.object({
title: z.string().min(3).max(220),
language: z.enum(["DE", "EN"]).default("DE"),
proposedContent: z.unknown(),
rationale: z.string().max(2000).optional(),
sources: z.array(sourceSchema).min(1).max(12),
});
export async function POST(request: Request) {
const role = await getCurrentRole(request.headers);
if (!hasRole(role, ["MEMBER", "ADMIN"])) {
return NextResponse.json({ error: "Contributions require a member role." }, { status: 401 });
}
const parsed = contributionSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const moderation = await moderateContribution({
title: parsed.data.title,
content: parsed.data.proposedContent,
sources: parsed.data.sources,
});
const status =
moderation.outcome === "APPROVE"
? "APPROVED"
: moderation.outcome === "REJECT"
? "REJECTED"
: "NEEDS_ADMIN_REVIEW";
const contribution = await prisma.$transaction(async (tx) => {
const storedSources = await Promise.all(
parsed.data.sources.map((source) =>
tx.source.upsert({
where: {
url_language: {
url: source.url,
language: source.language,
},
},
create: source,
update: source,
}),
),
);
return tx.contribution.create({
data: {
origin: "COMMUNITY",
status,
title: parsed.data.title,
language: parsed.data.language,
proposedContent: toInputJson(parsed.data.proposedContent),
rationale: parsed.data.rationale,
sources: {
create: storedSources.map((source) => ({
sourceId: source.id,
})),
},
moderationDecisions: {
create: {
outcome: moderation.outcome,
riskCategories: moderation.riskCategories,
confidence: moderation.confidence,
reasoning: moderation.reasoning,
model: moderation.model,
promptVersion: moderation.promptVersion,
rawResponse: moderation.rawResponse === undefined ? undefined : toInputJson(moderation.rawResponse),
},
},
},
include: {
sources: {
include: {
source: true,
},
},
moderationDecisions: true,
},
});
});
return NextResponse.json({ contribution, moderation }, { status: 201 });
}

View File

@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET() {
const startedAt = Date.now();
try {
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({
status: "ok",
database: "ok",
latencyMs: Date.now() - startedAt,
});
} catch (error) {
return NextResponse.json(
{
status: "degraded",
database: "unavailable",
error: error instanceof Error ? error.message : "Unknown database error",
},
{ status: 503 },
);
}
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getCurrentRole, hasRole } from "@/lib/auth";
import { moderateContribution } from "@/lib/moderation";
const reviewSchema = z.object({
title: z.string().min(3).max(220),
content: z.unknown(),
sources: z.array(
z.object({
title: z.string(),
url: z.string(),
license: z.string().optional(),
language: z.enum(["DE", "EN"]),
trustLevel: z.enum(["UNKNOWN", "LOW", "MEDIUM", "HIGH", "PRIMARY"]).optional(),
}),
),
});
export async function POST(request: Request) {
const role = await getCurrentRole(request.headers);
if (!hasRole(role, ["ADMIN"])) {
return NextResponse.json({ error: "Moderation review requires admin role." }, { status: 403 });
}
const parsed = reviewSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const moderation = await moderateContribution(parsed.data);
return NextResponse.json({ moderation });
}

View File

@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { answerQuestion } from "@/lib/questions";
const questionSchema = z.object({
question: z.string().min(4).max(1200),
language: z.enum(["DE", "EN"]).default("DE"),
});
export async function POST(request: Request) {
const parsed = questionSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
try {
const answer = await answerQuestion(parsed.data.question, parsed.data.language);
return NextResponse.json({ answer });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Question answering failed" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getCurrentRole, hasRole } from "@/lib/auth";
import { createResearchContribution } from "@/lib/research";
const researchSchema = z.object({
topic: z.string().min(3).max(180),
language: z.enum(["DE", "EN"]).default("DE"),
});
export async function POST(request: Request) {
const role = await getCurrentRole(request.headers);
if (!hasRole(role, ["MEMBER", "ADMIN"])) {
return NextResponse.json({ error: "Research requires a member role." }, { status: 401 });
}
const parsed = researchSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
try {
const result = await createResearchContribution(parsed.data);
return NextResponse.json(result, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Research failed" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,89 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
export default async function EntryPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const entry = await getEntry(slug);
if (!entry) {
notFound();
}
const sections = normalizeSections(entry.content);
return (
<main className="min-h-screen bg-[#f4efe6] px-4 py-6 text-stone-950 sm:px-6">
<article className="mx-auto max-w-4xl rounded-lg border border-stone-300 bg-white p-5 shadow-sm">
<Link className="text-sm font-semibold text-teal-800" href="/">
Zurueck
</Link>
<p className="mt-6 text-xs font-semibold uppercase tracking-[.16em] text-stone-500">
{entry.language} · {entry.status} · Version {entry.version}
</p>
<h1 className="mt-2 text-4xl font-semibold tracking-normal">{entry.title}</h1>
<p className="mt-4 text-lg leading-8 text-stone-700">{entry.summary}</p>
<div className="mt-8 space-y-6">
{sections.map((section) => (
<section key={section.heading}>
<h2 className="text-xl font-semibold">{section.heading}</h2>
<p className="mt-2 leading-7 text-stone-700">{section.body}</p>
</section>
))}
</div>
<footer className="mt-8 border-t border-stone-200 pt-5">
<h2 className="text-base font-semibold">Quellen</h2>
<ul className="mt-3 space-y-2 text-sm">
{entry.sources.map((link) => (
<li key={link.sourceId}>
<a className="font-medium text-teal-800 underline-offset-4 hover:underline" href={link.source.url}>
{link.source.title}
</a>
<span className="text-stone-500"> · {link.source.license ?? "Lizenz unbekannt"}</span>
</li>
))}
</ul>
</footer>
</article>
</main>
);
}
async function getEntry(slug: string) {
try {
return await prisma.knowledgeEntry.findFirst({
where: {
slug,
},
include: {
sources: {
include: {
source: true,
},
},
},
});
} catch {
return null;
}
}
function normalizeSections(content: unknown) {
if (
typeof content === "object" &&
content !== null &&
"sections" in content &&
Array.isArray((content as { sections: unknown }).sections)
) {
return (content as { sections: Array<{ heading?: unknown; body?: unknown }> }).sections.map((section, index) => ({
heading: typeof section.heading === "string" ? section.heading : `Abschnitt ${index + 1}`,
body: typeof section.body === "string" ? section.body : "",
}));
}
return [{ heading: "Inhalt", body: "Dieser Eintrag hat noch keinen strukturierten Inhalt." }];
}

View File

@@ -1,8 +1,8 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #f4efe6;
--foreground: #1c1917;
}
@theme inline {
@@ -12,15 +12,13 @@
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
button,
textarea,
input {
font: inherit;
}

View File

@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Menschheits-Enzyklopaedie",
description: "Quellenpflichtige Wissensplattform mit KI-Recherche und KI-Moderation.",
};
export default function RootLayout({
@@ -24,7 +24,7 @@ export default function RootLayout({
}>) {
return (
<html
lang="en"
lang="de"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>

View File

@@ -1,65 +1,83 @@
import Image from "next/image";
import Link from "next/link";
import { WorkspacePanel } from "@/components/workspace-panel";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<main className="min-h-screen bg-[#f4efe6] text-stone-950">
<header className="border-b border-stone-300 bg-white/90">
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6">
<div>
<p className="text-xs font-semibold uppercase tracking-[.18em] text-teal-800">Human Archive</p>
<h1 className="text-2xl font-semibold tracking-normal">Menschheits-Enzyklopaedie</h1>
</div>
<nav className="flex items-center gap-2 text-sm font-medium">
<Link className="rounded-md px-3 py-2 text-stone-700 hover:bg-stone-100" href="/entries/example">
Eintrag
</Link>
<Link className="rounded-md px-3 py-2 text-stone-700 hover:bg-stone-100" href="/admin">
Admin
</Link>
</nav>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</header>
<div className="mx-auto grid max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[1fr_360px]">
<div className="space-y-6">
<section className="grid gap-5 rounded-lg border border-stone-300 bg-white p-5 shadow-sm md:grid-cols-[1.1fr_.9fr]">
<div className="flex flex-col justify-center">
<p className="mb-3 text-sm font-semibold text-teal-800">Quellenpflichtige Wissensarbeit</p>
<h2 className="max-w-2xl text-4xl font-semibold leading-tight tracking-normal">
Fragen stellen, Wissen recherchieren und KI-Entscheidungen auditieren.
</h2>
<p className="mt-4 max-w-2xl text-base leading-7 text-stone-700">
V1 verbindet eine strukturierte Enzyklopaedie mit LiteLLM-gestuetzter Recherche,
Quellenverwaltung und automatischer Moderation.
</p>
</div>
<div className="relative min-h-64 overflow-hidden rounded-md border border-stone-200 bg-stone-100">
<Image src="/archive-field.svg" alt="" fill priority className="object-cover" />
</div>
</section>
<WorkspacePanel />
</div>
</main>
</div>
<aside className="space-y-4">
<StatusPanel />
<div className="rounded-lg border border-stone-300 bg-white p-4 shadow-sm">
<h2 className="text-base font-semibold">V1-Grenzen</h2>
<ul className="mt-3 space-y-3 text-sm leading-6 text-stone-700">
<li>Keine Massen-Crawls ohne konfigurierte Quellen-APIs.</li>
<li>Keine Veroeffentlichung ohne gespeicherte Quelle.</li>
<li>KI-Freigaben bleiben im Admin Center nachvollziehbar.</li>
</ul>
</div>
</aside>
</div>
</main>
);
}
function StatusPanel() {
const items = [
["Datenhaltung", "Postgres + Prisma"],
["LLM-Gateway", "LiteLLM-kompatibel"],
["Sprachen", "Deutsch / Englisch"],
["Container", "Runtime vorbereitet"],
];
return (
<section className="rounded-lg border border-stone-300 bg-white p-4 shadow-sm">
<h2 className="text-base font-semibold">Systemstatus</h2>
<dl className="mt-3 space-y-3">
{items.map(([label, value]) => (
<div key={label} className="flex items-center justify-between gap-3 border-b border-stone-200 pb-2 last:border-0">
<dt className="text-sm text-stone-600">{label}</dt>
<dd className="text-right text-sm font-semibold text-stone-950">{value}</dd>
</div>
))}
</dl>
</section>
);
}

View File

@@ -0,0 +1,136 @@
"use client";
import { useState } from "react";
type ApiState = {
status: "idle" | "loading" | "success" | "error";
message: string;
};
export function WorkspacePanel() {
const [language, setLanguage] = useState<"DE" | "EN">("DE");
const [question, setQuestion] = useState("Was wissen wir ueber die fruehe Landwirtschaft?");
const [topic, setTopic] = useState("Fruehe Landwirtschaft");
const [questionState, setQuestionState] = useState<ApiState>({ status: "idle", message: "" });
const [researchState, setResearchState] = useState<ApiState>({ status: "idle", message: "" });
async function askQuestion() {
setQuestionState({ status: "loading", message: "Antwort wird aus gespeicherten Quellen erstellt..." });
const response = await fetch("/api/questions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ question, language }),
});
const data = await response.json();
setQuestionState({
status: response.ok ? "success" : "error",
message: response.ok ? data.answer.answer : data.error ?? "Die Frage konnte nicht beantwortet werden.",
});
}
async function startResearch() {
setResearchState({ status: "loading", message: "Recherche-Entwurf wird erzeugt und moderiert..." });
const response = await fetch("/api/research", {
method: "POST",
headers: {
"content-type": "application/json",
"x-demo-role": "MEMBER",
},
body: JSON.stringify({ topic, language }),
});
const data = await response.json();
const moderation = data.moderation;
setResearchState({
status: response.ok ? "success" : "error",
message: response.ok
? `Status: ${data.contribution.status}. Moderation: ${moderation.outcome} (${Math.round(
moderation.confidence * 100,
)}% Vertrauen).`
: data.error ?? "Die Recherche konnte nicht gestartet werden.",
});
}
return (
<section className="grid gap-4 lg:grid-cols-[1.05fr_.95fr]">
<div className="rounded-lg border border-stone-300 bg-white p-4 shadow-sm">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-stone-950">Frageportal</h2>
<LanguageToggle language={language} onChange={setLanguage} />
</div>
<textarea
value={question}
onChange={(event) => setQuestion(event.target.value)}
className="min-h-32 w-full resize-y rounded-md border border-stone-300 bg-stone-50 p-3 text-sm leading-6 text-stone-950 outline-none focus:border-teal-700"
/>
<button
type="button"
onClick={askQuestion}
disabled={questionState.status === "loading"}
className="mt-3 h-11 rounded-md bg-stone-950 px-4 text-sm font-semibold text-white hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-500"
>
Antwort erzeugen
</button>
<ResultBox state={questionState} />
</div>
<div className="rounded-lg border border-stone-300 bg-white p-4 shadow-sm">
<h2 className="text-lg font-semibold text-stone-950">KI-Recherche</h2>
<input
value={topic}
onChange={(event) => setTopic(event.target.value)}
className="mt-4 h-11 w-full rounded-md border border-stone-300 bg-stone-50 px-3 text-sm text-stone-950 outline-none focus:border-teal-700"
/>
<button
type="button"
onClick={startResearch}
disabled={researchState.status === "loading"}
className="mt-3 h-11 rounded-md bg-teal-800 px-4 text-sm font-semibold text-white hover:bg-teal-700 disabled:cursor-not-allowed disabled:bg-stone-500"
>
Recherche starten
</button>
<ResultBox state={researchState} />
</div>
</section>
);
}
function LanguageToggle({
language,
onChange,
}: {
language: "DE" | "EN";
onChange: (language: "DE" | "EN") => void;
}) {
return (
<div className="grid grid-cols-2 rounded-md border border-stone-300 bg-stone-100 p-1 text-xs font-semibold">
{(["DE", "EN"] as const).map((item) => (
<button
key={item}
type="button"
onClick={() => onChange(item)}
className={`h-8 rounded px-3 ${language === item ? "bg-white text-stone-950 shadow-sm" : "text-stone-600"}`}
>
{item}
</button>
))}
</div>
);
}
function ResultBox({ state }: { state: ApiState }) {
if (state.status === "idle") {
return null;
}
return (
<pre
className={`mt-4 max-h-72 overflow-auto whitespace-pre-wrap rounded-md border p-3 text-sm leading-6 ${
state.status === "error"
? "border-red-300 bg-red-50 text-red-950"
: "border-stone-300 bg-stone-50 text-stone-800"
}`}
>
{state.message}
</pre>
);
}

84
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,84 @@
import { compare } from "bcryptjs";
import type { NextAuthOptions } from "next-auth";
import { getServerSession } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { prisma } from "@/lib/db";
export const authOptions: NextAuthOptions = {
session: {
strategy: "jwt",
},
providers: [
CredentialsProvider({
name: "Email",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials.password) {
return null;
}
const user = await prisma.user.findUnique({
where: {
email: credentials.email,
},
});
if (!user?.passwordHash) {
return null;
}
const passwordValid = await compare(credentials.password, user.passwordHash);
if (!passwordValid) {
return null;
}
return {
id: user.id,
name: user.name,
email: user.email,
image: user.image,
role: user.role,
};
},
}),
],
callbacks: {
jwt({ token, user }) {
if (user) {
token.role = "role" in user && typeof user.role === "string" ? user.role : "MEMBER";
}
return token;
},
session({ session, token }) {
if (session.user) {
session.user.role = typeof token.role === "string" ? token.role : "MEMBER";
}
return session;
},
},
pages: {
signIn: "/admin",
},
};
export async function getCurrentRole(headers?: Headers) {
if (process.env.NODE_ENV !== "production") {
const demoRole = headers?.get("x-demo-role");
if (demoRole === "ADMIN" || demoRole === "MEMBER") {
return demoRole;
}
}
const session = await getServerSession(authOptions);
return session?.user?.role ?? "VISITOR";
}
export function hasRole(role: string, allowed: Array<"MEMBER" | "ADMIN">) {
if (role === "ADMIN") {
return true;
}
return allowed.includes(role as "MEMBER" | "ADMIN");
}

15
src/lib/db.ts Normal file
View File

@@ -0,0 +1,15 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}

13
src/lib/env.ts Normal file
View File

@@ -0,0 +1,13 @@
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().optional(),
AUTH_SECRET: z.string().optional(),
AUTH_TRUST_HOST: z.string().optional(),
LITELLM_BASE_URL: z.string().url().optional(),
LITELLM_API_KEY: z.string().optional(),
DEFAULT_LLM_MODEL: z.string().default("local/default"),
SEARCH_PROVIDER_API_KEY: z.string().optional(),
});
export const env = envSchema.parse(process.env);

5
src/lib/json.ts Normal file
View File

@@ -0,0 +1,5 @@
import type { Prisma } from "@prisma/client";
export function toInputJson(value: unknown): Prisma.InputJsonValue {
return JSON.parse(JSON.stringify(value ?? null)) as Prisma.InputJsonValue;
}

68
src/lib/litellm.ts Normal file
View File

@@ -0,0 +1,68 @@
import { env } from "@/lib/env";
type ChatMessage = {
role: "system" | "user" | "assistant";
content: string;
};
type ChatCompletion = {
choices?: Array<{
message?: {
content?: string;
};
}>;
usage?: {
total_tokens?: number;
};
};
export type LlmResult = {
content: string;
model: string;
tokenCount?: number;
raw?: unknown;
};
export async function runLlmChat(messages: ChatMessage[], model = env.DEFAULT_LLM_MODEL): Promise<LlmResult> {
if (!env.LITELLM_BASE_URL || !env.LITELLM_API_KEY) {
return {
content: localFallback(messages),
model: `${model}:fallback`,
};
}
const response = await fetch(`${env.LITELLM_BASE_URL.replace(/\/$/, "")}/chat/completions`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${env.LITELLM_API_KEY}`,
},
body: JSON.stringify({
model,
messages,
temperature: 0.2,
}),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`LiteLLM request failed: ${response.status} ${errorBody}`);
}
const data = (await response.json()) as ChatCompletion;
return {
content: data.choices?.[0]?.message?.content?.trim() ?? "",
model,
tokenCount: data.usage?.total_tokens,
raw: data,
};
}
function localFallback(messages: ChatMessage[]) {
const latestUserMessage = [...messages].reverse().find((message) => message.role === "user")?.content ?? "";
return [
"Lokaler Fallback: Es ist kein LiteLLM-Endpunkt konfiguriert.",
"Die Anfrage wurde strukturiert angenommen und muss nach Konfiguration eines Modells erneut ausgeführt werden.",
`Auszug der Anfrage: ${latestUserMessage.slice(0, 420)}`,
].join("\n\n");
}

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { validateSources } from "@/lib/moderation";
describe("validateSources", () => {
it("requires at least one source", () => {
expect(validateSources([])).toContain("missing_sources");
});
it("requires a valid URL and license", () => {
expect(
validateSources([
{
title: "Archive",
url: "not-a-url",
language: "DE",
},
]),
).toEqual(expect.arrayContaining(["source_invalid_url", "source_missing_license"]));
});
it("accepts sourced material with licensing metadata", () => {
expect(
validateSources([
{
title: "Public record",
url: "https://example.org/source",
language: "EN",
license: "CC BY 4.0",
},
]),
).toEqual([]);
});
});

111
src/lib/moderation.ts Normal file
View File

@@ -0,0 +1,111 @@
import { runLlmChat } from "@/lib/litellm";
import type { ModerationResult, SourceInput } from "@/lib/types";
export const MODERATION_PROMPT_VERSION = "v1-source-required";
type ModerationInput = {
title: string;
content: unknown;
sources: SourceInput[];
};
export async function moderateContribution(input: ModerationInput): Promise<ModerationResult> {
const sourceRisks = validateSources(input.sources);
if (sourceRisks.length > 0) {
return {
outcome: "REJECT",
riskCategories: sourceRisks,
confidence: 0.98,
reasoning: "Der Beitrag verletzt die Quellenpflicht oder enthält nicht verwertbare Quellen.",
model: "rules:v1",
promptVersion: MODERATION_PROMPT_VERSION,
};
}
const llmResult = await runLlmChat([
{
role: "system",
content:
"Du moderierst Enzyklopaedie-Beitraege. Antworte als JSON mit outcome APPROVE, REJECT oder ESCALATE, riskCategories, confidence und reasoning.",
},
{
role: "user",
content: JSON.stringify({
title: input.title,
content: input.content,
sourceCount: input.sources.length,
sources: input.sources.map((source) => ({
title: source.title,
url: source.url,
license: source.license,
trustLevel: source.trustLevel,
})),
}),
},
]);
const parsed = parseModerationJson(llmResult.content);
return {
outcome: parsed.outcome,
riskCategories: parsed.riskCategories,
confidence: parsed.confidence,
reasoning: parsed.reasoning,
model: llmResult.model,
promptVersion: MODERATION_PROMPT_VERSION,
rawResponse: llmResult.raw ?? llmResult.content,
};
}
export function validateSources(sources: SourceInput[]) {
const risks: string[] = [];
if (sources.length === 0) {
risks.push("missing_sources");
}
for (const source of sources) {
if (!source.title.trim()) {
risks.push("source_missing_title");
}
if (!isHttpUrl(source.url)) {
risks.push("source_invalid_url");
}
if (!source.license?.trim()) {
risks.push("source_missing_license");
}
}
return Array.from(new Set(risks));
}
function parseModerationJson(content: string): Pick<ModerationResult, "outcome" | "riskCategories" | "confidence" | "reasoning"> {
try {
const jsonStart = content.indexOf("{");
const jsonEnd = content.lastIndexOf("}");
const parsed = JSON.parse(content.slice(jsonStart, jsonEnd + 1)) as Partial<ModerationResult>;
const outcome = parsed.outcome === "REJECT" || parsed.outcome === "ESCALATE" ? parsed.outcome : "APPROVE";
return {
outcome,
riskCategories: Array.isArray(parsed.riskCategories) ? parsed.riskCategories.map(String) : [],
confidence: typeof parsed.confidence === "number" ? Math.min(Math.max(parsed.confidence, 0), 1) : 0.72,
reasoning: typeof parsed.reasoning === "string" ? parsed.reasoning : "KI-Moderation abgeschlossen.",
};
} catch {
return {
outcome: "ESCALATE",
riskCategories: ["moderation_parse_error"],
confidence: 0.4,
reasoning: "Die KI-Moderation konnte nicht eindeutig geparst werden und wird zur Admin-Pruefung eskaliert.",
};
}
}
function isHttpUrl(value: string) {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}

78
src/lib/questions.ts Normal file
View File

@@ -0,0 +1,78 @@
import { prisma } from "@/lib/db";
import { runLlmChat } from "@/lib/litellm";
import type { SupportedLanguage } from "@/lib/types";
export async function answerQuestion(question: string, language: SupportedLanguage) {
const entries = await prisma.knowledgeEntry.findMany({
where: {
language,
status: "PUBLISHED",
},
include: {
sources: {
include: {
source: true,
},
},
},
orderBy: {
updatedAt: "desc",
},
take: 6,
});
const context = entries.map((entry) => ({
title: entry.title,
summary: entry.summary,
sources: entry.sources.map((link) => ({
title: link.source.title,
url: link.source.url,
})),
}));
const llmResult = await runLlmChat([
{
role: "system",
content:
"Beantworte Fragen nur mit dem gegebenen Kontext. Wenn Kontext fehlt, sage das klar. Fuege Quellen-URLs in Klammern an.",
},
{
role: "user",
content: JSON.stringify({ question, language, context }),
},
]);
const answer = await prisma.questionAnswer.create({
data: {
question,
language,
answer: llmResult.content,
model: llmResult.model,
tokenCount: llmResult.tokenCount,
sources: {
create: uniqueSources(entries).map((sourceId) => ({
sourceId,
})),
},
},
include: {
sources: {
include: {
source: true,
},
},
},
});
return answer;
}
function uniqueSources(entries: Array<{ sources: Array<{ sourceId: string }> }>) {
const ids = new Set<string>();
for (const entry of entries) {
for (const link of entry.sources) {
ids.add(link.sourceId);
}
}
return [...ids].slice(0, 8);
}

191
src/lib/research.ts Normal file
View File

@@ -0,0 +1,191 @@
import { prisma } from "@/lib/db";
import { toInputJson } from "@/lib/json";
import { runLlmChat } from "@/lib/litellm";
import { moderateContribution } from "@/lib/moderation";
import { slugify } from "@/lib/slugs";
import type { SourceInput, SupportedLanguage } from "@/lib/types";
type ResearchInput = {
topic: string;
language: SupportedLanguage;
requestedByUserId?: string;
};
export async function createResearchContribution(input: ResearchInput) {
const source = buildResearchSource(input.topic, input.language);
const draft = await draftKnowledgeEntry(input.topic, input.language, [source]);
const moderation = await moderateContribution({
title: draft.title,
content: draft.content,
sources: [source],
});
const status =
moderation.outcome === "APPROVE"
? "APPROVED"
: moderation.outcome === "REJECT"
? "REJECTED"
: "NEEDS_ADMIN_REVIEW";
const stored = await prisma.$transaction(async (tx) => {
const storedSource = await tx.source.upsert({
where: {
url_language: {
url: source.url,
language: source.language,
},
},
create: source,
update: {
title: source.title,
license: source.license,
trustLevel: source.trustLevel ?? "UNKNOWN",
notes: source.notes,
},
});
const contribution = await tx.contribution.create({
data: {
origin: "AI_RESEARCH",
status,
title: draft.title,
language: input.language,
proposedContent: toInputJson(draft.content),
rationale: draft.rationale,
authorId: input.requestedByUserId,
sources: {
create: {
sourceId: storedSource.id,
},
},
moderationDecisions: {
create: {
outcome: moderation.outcome,
riskCategories: moderation.riskCategories,
confidence: moderation.confidence,
reasoning: moderation.reasoning,
model: moderation.model,
promptVersion: moderation.promptVersion,
rawResponse: moderation.rawResponse === undefined ? undefined : toInputJson(moderation.rawResponse),
},
},
},
include: {
sources: {
include: {
source: true,
},
},
moderationDecisions: true,
},
});
if (moderation.outcome !== "APPROVE") {
return { contribution, entry: null };
}
const entry = await tx.knowledgeEntry.create({
data: {
title: draft.title,
slug: `${slugify(draft.title)}-${Date.now()}`,
language: input.language,
summary: draft.summary,
content: toInputJson(draft.content),
tags: draft.tags,
status: "PUBLISHED",
sources: {
create: {
sourceId: storedSource.id,
},
},
contributions: {
connect: {
id: contribution.id,
},
},
},
});
return { contribution, entry };
});
return {
...stored,
moderation,
};
}
async function draftKnowledgeEntry(topic: string, language: SupportedLanguage, sources: SourceInput[]) {
const llmResult = await runLlmChat([
{
role: "system",
content:
"Erzeuge einen knappen Enzyklopaedie-Entwurf als JSON mit title, summary, sections, tags und rationale. Jede Aussage muss auf die uebergebenen Quellen verweisen.",
},
{
role: "user",
content: JSON.stringify({ topic, language, sources }),
},
]);
try {
const jsonStart = llmResult.content.indexOf("{");
const jsonEnd = llmResult.content.lastIndexOf("}");
const parsed = JSON.parse(llmResult.content.slice(jsonStart, jsonEnd + 1)) as {
title?: string;
summary?: string;
sections?: Array<{ heading: string; body: string; sourceUrls: string[] }>;
tags?: string[];
rationale?: string;
};
return {
title: parsed.title?.trim() || topic,
summary: parsed.summary?.trim() || fallbackSummary(topic, language),
content: {
sections: parsed.sections?.length
? parsed.sections
: [{ heading: "Ueberblick", body: fallbackSummary(topic, language), sourceUrls: sources.map((source) => source.url) }],
},
tags: parsed.tags?.map(String).slice(0, 8) ?? ["research"],
rationale: parsed.rationale ?? "KI-generierter Entwurf auf Basis der gespeicherten Quellen.",
};
} catch {
return {
title: topic,
summary: fallbackSummary(topic, language),
content: {
sections: [
{
heading: language === "DE" ? "Vorlaeufiger Entwurf" : "Preliminary draft",
body: llmResult.content,
sourceUrls: sources.map((source) => source.url),
},
],
},
tags: ["research", language.toLowerCase()],
rationale: "Fallback-Entwurf, weil die KI-Antwort nicht als JSON geparst werden konnte.",
};
}
}
function buildResearchSource(topic: string, language: SupportedLanguage): SourceInput {
const encodedTopic = encodeURIComponent(topic);
const wikiLanguage = language === "DE" ? "de" : "en";
return {
title: `${topic} - Wikipedia seed`,
url: `https://${wikiLanguage}.wikipedia.org/wiki/Special:Search?search=${encodedTopic}`,
institution: "Wikimedia Foundation",
license: "CC BY-SA",
language,
trustLevel: "MEDIUM",
notes: "V1-Recherche-Seed. Spaeter durch echte Search-Provider und Quellenextraktion ersetzen.",
};
}
function fallbackSummary(topic: string, language: SupportedLanguage) {
return language === "DE"
? `Vorlaeufiger Wissenseintrag zu ${topic}. Die endgueltige Fassung benoetigt weitere Quellenextraktion.`
: `Preliminary knowledge entry about ${topic}. The final version needs deeper source extraction.`;
}

8
src/lib/slugs.test.ts Normal file
View File

@@ -0,0 +1,8 @@
import { describe, expect, it } from "vitest";
import { slugify } from "@/lib/slugs";
describe("slugify", () => {
it("normalizes titles into stable ASCII slugs", () => {
expect(slugify("Fruehe Landwirtschaft: 10.000 v. Chr.")).toBe("fruehe-landwirtschaft-10-000-v-chr");
});
});

10
src/lib/slugs.ts Normal file
View File

@@ -0,0 +1,10 @@
export function slugify(value: string) {
return value
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 96);
}

22
src/lib/types.ts Normal file
View File

@@ -0,0 +1,22 @@
export type SupportedLanguage = "DE" | "EN";
export type SourceInput = {
title: string;
url: string;
author?: string;
institution?: string;
license?: string;
language: SupportedLanguage;
trustLevel?: "UNKNOWN" | "LOW" | "MEDIUM" | "HIGH" | "PRIMARY";
notes?: string;
};
export type ModerationResult = {
outcome: "APPROVE" | "REJECT" | "ESCALATE";
riskCategories: string[];
confidence: number;
reasoning: string;
model: string;
promptVersion: string;
rawResponse?: unknown;
};

19
src/types/next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
import type { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user?: DefaultSession["user"] & {
role?: string;
};
}
interface User {
role?: string;
}
}
declare module "next-auth/jwt" {
interface JWT {
role?: string;
}
}