Initial human archive app
Some checks failed
Build and publish Docker image / docker (push) Failing after 2m33s
Some checks failed
Build and publish Docker image / docker (push) Failing after 2m33s
This commit is contained in:
66
src/app/admin/page.tsx
Normal file
66
src/app/admin/page.tsx
Normal 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 [];
|
||||
}
|
||||
}
|
||||
52
src/app/api/admin/moderation/[id]/route.ts
Normal file
52
src/app/api/admin/moderation/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
6
src/app/api/auth/[...nextauth]/route.ts
Normal 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 };
|
||||
104
src/app/api/contributions/route.ts
Normal file
104
src/app/api/contributions/route.ts
Normal 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 });
|
||||
}
|
||||
24
src/app/api/health/route.ts
Normal file
24
src/app/api/health/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
33
src/app/api/moderation/review/route.ts
Normal file
33
src/app/api/moderation/review/route.ts
Normal 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 });
|
||||
}
|
||||
25
src/app/api/questions/route.ts
Normal file
25
src/app/api/questions/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
31
src/app/api/research/route.ts
Normal file
31
src/app/api/research/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
89
src/app/entries/[slug]/page.tsx
Normal file
89
src/app/entries/[slug]/page.tsx
Normal 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." }];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
132
src/app/page.tsx
132
src/app/page.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
136
src/components/workspace-panel.tsx
Normal file
136
src/components/workspace-panel.tsx
Normal 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
84
src/lib/auth.ts
Normal 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
15
src/lib/db.ts
Normal 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
13
src/lib/env.ts
Normal 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
5
src/lib/json.ts
Normal 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
68
src/lib/litellm.ts
Normal 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");
|
||||
}
|
||||
33
src/lib/moderation.test.ts
Normal file
33
src/lib/moderation.test.ts
Normal 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
111
src/lib/moderation.ts
Normal 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
78
src/lib/questions.ts
Normal 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
191
src/lib/research.ts
Normal 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
8
src/lib/slugs.test.ts
Normal 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
10
src/lib/slugs.ts
Normal 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
22
src/lib/types.ts
Normal 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
19
src/types/next-auth.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user