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

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;
};