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

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
.next
out
coverage
.git
.env
.env.*
npm-debug.log*
yarn-debug.log*
Dockerfile
docker-compose*.yml
README.md

View File

@@ -0,0 +1,51 @@
name: Build and publish Docker image
on:
push:
branches:
- master
tags:
- "v*"
workflow_dispatch:
env:
REGISTRY: git.wilkensxl.de
IMAGE_NAME: code-inc/human-archive
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=sha-
type=ref,event=tag
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

37
Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
FROM node:22-bookworm-slim AS base
WORKDIR /app
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
ENV DATABASE_URL="postgresql://archive:archive@localhost:5432/human_archive?schema=public"
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]

View File

@@ -1,36 +1,44 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Menschheits-Enzyklopaedie
## Getting Started
Eine vorbereitete V1 fuer eine quellenpflichtige Wissensplattform mit KI-Frageportal, KI-Recherche, Community-Beitraegen und auditierbarer KI-Moderation.
First, run the development server:
## Stack
- Next.js App Router mit TypeScript und Tailwind CSS
- Postgres mit Prisma
- NextAuth Credentials-Grundlage fuer spaetere Rollen-Logins
- LiteLLM-kompatible LLM-Schicht fuer lokale und externe Modelle
- Vitest fuer fokussierte Service-Tests
## Lokale Einrichtung
1. `.env.example` nach `.env` kopieren und Werte setzen.
2. Postgres starten, zum Beispiel mit `docker compose -f docker-compose.example.yml up postgres`.
3. Prisma Client generieren: `npm run prisma:generate`.
4. Migrationen fuer die Entwicklung ausfuehren: `npm run db:dev`.
5. App starten: `npm run dev`.
## Wichtige Routen
- `/` - Frageportal und KI-Recherche
- `/admin` - Admin Center fuer KI-Moderationsentscheidungen
- `/api/health` - Health Check fuer App und Datenbank
- `/api/questions` - quellenbasierte KI-Antworten
- `/api/research` - KI-Recherche mit Quellenpflicht und Moderation
- `/api/contributions` - Community-Beitraege
## Docker-Vorbereitung
Das Repository enthaelt ein Dockerfile und `.dockerignore`, aber noch keinen Release- oder Publish-Prozess. Das Image erwartet Runtime-Konfiguration ueber Umgebungsvariablen und fuehrt keine destruktiven Migrationen beim Start aus.
Produktionsnaher Build:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
docker build -t human-archive:local .
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## Entwicklungshinweise
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
- Ohne `LITELLM_BASE_URL` und `LITELLM_API_KEY` nutzt die App einen lokalen Fallback, damit Builds und UI-Flows nicht an fehlenden Modellzugangsdaten scheitern.
- Die API akzeptiert in `NODE_ENV !== "production"` den Header `x-demo-role: MEMBER` oder `x-demo-role: ADMIN`, um Workflows ohne eingerichtetes Login zu testen.
- Kein veroeffentlichter Wissenseintrag darf ohne gespeicherte Quelle entstehen.

View File

@@ -0,0 +1,26 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: human_archive
POSTGRES_USER: archive
POSTGRES_PASSWORD: archive
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U archive -d human_archive"]
interval: 10s
timeout: 5s
retries: 5
litellm:
image: ghcr.io/berriai/litellm:main-latest
ports:
- "4000:4000"
environment:
LITELLM_MASTER_KEY: replace-with-litellm-key
volumes:
postgres-data:

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
};
export default nextConfig;

1634
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,21 +6,37 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"prisma:generate": "prisma generate",
"db:migrate": "prisma migrate deploy",
"db:dev": "prisma migrate dev"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.2",
"@prisma/client": "^6.18.0",
"bcryptjs": "^3.0.3",
"csv-parse": "^7.0.0",
"date-fns": "^4.4.0",
"next": "16.2.9",
"next-auth": "^4.24.14",
"prisma": "^6.18.0",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.1.9",
"eslint": "^9",
"eslint-config-next": "16.2.9",
"magicast": "^0.3.5",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.1.9"
}
}

View File

@@ -0,0 +1,244 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('VISITOR', 'MEMBER', 'ADMIN');
-- CreateEnum
CREATE TYPE "Language" AS ENUM ('DE', 'EN');
-- CreateEnum
CREATE TYPE "TrustLevel" AS ENUM ('UNKNOWN', 'LOW', 'MEDIUM', 'HIGH', 'PRIMARY');
-- CreateEnum
CREATE TYPE "EntryStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'BLOCKED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "ContributionStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'NEEDS_ADMIN_REVIEW');
-- CreateEnum
CREATE TYPE "ModerationOutcome" AS ENUM ('APPROVE', 'REJECT', 'ESCALATE');
-- CreateEnum
CREATE TYPE "ContributionOrigin" AS ENUM ('COMMUNITY', 'AI_RESEARCH', 'ADMIN');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT,
"email" TEXT,
"emailVerified" TIMESTAMP(3),
"image" TEXT,
"passwordHash" TEXT,
"role" "Role" NOT NULL DEFAULT 'MEMBER',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "Source" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"url" TEXT NOT NULL,
"author" TEXT,
"institution" TEXT,
"license" TEXT,
"language" "Language" NOT NULL,
"retrievedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"trustLevel" "TrustLevel" NOT NULL DEFAULT 'UNKNOWN',
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Source_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "KnowledgeEntry" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"language" "Language" NOT NULL,
"summary" TEXT NOT NULL,
"content" JSONB NOT NULL,
"timelineStart" TIMESTAMP(3),
"timelineEnd" TIMESTAMP(3),
"tags" TEXT[],
"status" "EntryStatus" NOT NULL DEFAULT 'DRAFT',
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "KnowledgeEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "KnowledgeEntrySource" (
"entryId" TEXT NOT NULL,
"sourceId" TEXT NOT NULL,
"note" TEXT,
CONSTRAINT "KnowledgeEntrySource_pkey" PRIMARY KEY ("entryId","sourceId")
);
-- CreateTable
CREATE TABLE "Contribution" (
"id" TEXT NOT NULL,
"origin" "ContributionOrigin" NOT NULL,
"status" "ContributionStatus" NOT NULL DEFAULT 'PENDING',
"title" TEXT NOT NULL,
"language" "Language" NOT NULL,
"proposedContent" JSONB NOT NULL,
"rationale" TEXT,
"authorId" TEXT,
"entryId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Contribution_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ContributionSource" (
"contributionId" TEXT NOT NULL,
"sourceId" TEXT NOT NULL,
CONSTRAINT "ContributionSource_pkey" PRIMARY KEY ("contributionId","sourceId")
);
-- CreateTable
CREATE TABLE "ModerationDecision" (
"id" TEXT NOT NULL,
"contributionId" TEXT NOT NULL,
"outcome" "ModerationOutcome" NOT NULL,
"riskCategories" TEXT[],
"confidence" DOUBLE PRECISION NOT NULL,
"reasoning" TEXT NOT NULL,
"model" TEXT NOT NULL,
"promptVersion" TEXT NOT NULL,
"rawResponse" JSONB,
"overriddenById" TEXT,
"overrideReason" TEXT,
"overriddenAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ModerationDecision_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "QuestionAnswer" (
"id" TEXT NOT NULL,
"question" TEXT NOT NULL,
"language" "Language" NOT NULL,
"answer" TEXT NOT NULL,
"model" TEXT NOT NULL,
"tokenCount" INTEGER,
"costCents" INTEGER,
"entryId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "QuestionAnswer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AnswerSource" (
"answerId" TEXT NOT NULL,
"sourceId" TEXT NOT NULL,
CONSTRAINT "AnswerSource_pkey" PRIMARY KEY ("answerId","sourceId")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- CreateIndex
CREATE INDEX "Source_language_trustLevel_idx" ON "Source"("language", "trustLevel");
-- CreateIndex
CREATE UNIQUE INDEX "Source_url_language_key" ON "Source"("url", "language");
-- CreateIndex
CREATE INDEX "KnowledgeEntry_status_language_idx" ON "KnowledgeEntry"("status", "language");
-- CreateIndex
CREATE UNIQUE INDEX "KnowledgeEntry_slug_language_key" ON "KnowledgeEntry"("slug", "language");
-- CreateIndex
CREATE INDEX "Contribution_status_origin_idx" ON "Contribution"("status", "origin");
-- CreateIndex
CREATE INDEX "ModerationDecision_outcome_createdAt_idx" ON "ModerationDecision"("outcome", "createdAt");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KnowledgeEntrySource" ADD CONSTRAINT "KnowledgeEntrySource_entryId_fkey" FOREIGN KEY ("entryId") REFERENCES "KnowledgeEntry"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KnowledgeEntrySource" ADD CONSTRAINT "KnowledgeEntrySource_sourceId_fkey" FOREIGN KEY ("sourceId") REFERENCES "Source"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Contribution" ADD CONSTRAINT "Contribution_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Contribution" ADD CONSTRAINT "Contribution_entryId_fkey" FOREIGN KEY ("entryId") REFERENCES "KnowledgeEntry"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ContributionSource" ADD CONSTRAINT "ContributionSource_contributionId_fkey" FOREIGN KEY ("contributionId") REFERENCES "Contribution"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ContributionSource" ADD CONSTRAINT "ContributionSource_sourceId_fkey" FOREIGN KEY ("sourceId") REFERENCES "Source"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ModerationDecision" ADD CONSTRAINT "ModerationDecision_contributionId_fkey" FOREIGN KEY ("contributionId") REFERENCES "Contribution"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ModerationDecision" ADD CONSTRAINT "ModerationDecision_overriddenById_fkey" FOREIGN KEY ("overriddenById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QuestionAnswer" ADD CONSTRAINT "QuestionAnswer_entryId_fkey" FOREIGN KEY ("entryId") REFERENCES "KnowledgeEntry"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AnswerSource" ADD CONSTRAINT "AnswerSource_answerId_fkey" FOREIGN KEY ("answerId") REFERENCES "QuestionAnswer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AnswerSource" ADD CONSTRAINT "AnswerSource_sourceId_fkey" FOREIGN KEY ("sourceId") REFERENCES "Source"("id") ON DELETE CASCADE ON UPDATE CASCADE;

228
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,228 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
VISITOR
MEMBER
ADMIN
}
enum Language {
DE
EN
}
enum TrustLevel {
UNKNOWN
LOW
MEDIUM
HIGH
PRIMARY
}
enum EntryStatus {
DRAFT
PUBLISHED
BLOCKED
ARCHIVED
}
enum ContributionStatus {
PENDING
APPROVED
REJECTED
NEEDS_ADMIN_REVIEW
}
enum ModerationOutcome {
APPROVE
REJECT
ESCALATE
}
enum ContributionOrigin {
COMMUNITY
AI_RESEARCH
ADMIN
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
passwordHash String?
role Role @default(MEMBER)
accounts Account[]
sessions Session[]
contributions Contribution[]
moderationOverrides ModerationDecision[] @relation("ModerationOverride")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model Source {
id String @id @default(cuid())
title String
url String
author String?
institution String?
license String?
language Language
retrievedAt DateTime @default(now())
trustLevel TrustLevel @default(UNKNOWN)
notes String?
entryLinks KnowledgeEntrySource[]
contributionLinks ContributionSource[]
answerLinks AnswerSource[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([url, language])
@@index([language, trustLevel])
}
model KnowledgeEntry {
id String @id @default(cuid())
title String
slug String
language Language
summary String
content Json
timelineStart DateTime?
timelineEnd DateTime?
tags String[]
status EntryStatus @default(DRAFT)
version Int @default(1)
sources KnowledgeEntrySource[]
contributions Contribution[]
answers QuestionAnswer[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([slug, language])
@@index([status, language])
}
model KnowledgeEntrySource {
entryId String
sourceId String
note String?
entry KnowledgeEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
source Source @relation(fields: [sourceId], references: [id], onDelete: Cascade)
@@id([entryId, sourceId])
}
model Contribution {
id String @id @default(cuid())
origin ContributionOrigin
status ContributionStatus @default(PENDING)
title String
language Language
proposedContent Json
rationale String?
authorId String?
entryId String?
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
entry KnowledgeEntry? @relation(fields: [entryId], references: [id], onDelete: SetNull)
sources ContributionSource[]
moderationDecisions ModerationDecision[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, origin])
}
model ContributionSource {
contributionId String
sourceId String
contribution Contribution @relation(fields: [contributionId], references: [id], onDelete: Cascade)
source Source @relation(fields: [sourceId], references: [id], onDelete: Cascade)
@@id([contributionId, sourceId])
}
model ModerationDecision {
id String @id @default(cuid())
contributionId String
outcome ModerationOutcome
riskCategories String[]
confidence Float
reasoning String
model String
promptVersion String
rawResponse Json?
overriddenById String?
overrideReason String?
overriddenAt DateTime?
contribution Contribution @relation(fields: [contributionId], references: [id], onDelete: Cascade)
overriddenBy User? @relation("ModerationOverride", fields: [overriddenById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
@@index([outcome, createdAt])
}
model QuestionAnswer {
id String @id @default(cuid())
question String
language Language
answer String
model String
tokenCount Int?
costCents Int?
entryId String?
entry KnowledgeEntry? @relation(fields: [entryId], references: [id], onDelete: SetNull)
sources AnswerSource[]
createdAt DateTime @default(now())
}
model AnswerSource {
answerId String
sourceId String
answer QuestionAnswer @relation(fields: [answerId], references: [id], onDelete: Cascade)
source Source @relation(fields: [sourceId], references: [id], onDelete: Cascade)
@@id([answerId, sourceId])
}

41
public/archive-field.svg Normal file
View File

@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 520" role="img" aria-labelledby="title desc">
<title id="title">Knowledge archive field</title>
<desc id="desc">A structured field of connected archive records and source nodes.</desc>
<rect width="900" height="520" fill="#f7f3ea"/>
<g fill="none" stroke="#1f2937" stroke-opacity=".16">
<path d="M92 382C192 288 270 270 386 320s195 31 291-44 139-86 183-82"/>
<path d="M54 162c121 38 204 50 318 14s198-42 310 30 153 77 199 42"/>
<path d="M138 94c79 72 175 98 287 78s198-68 324-18"/>
</g>
<g fill="#ffffff" stroke="#172033" stroke-width="2">
<rect x="90" y="78" width="166" height="106" rx="8"/>
<rect x="314" y="134" width="196" height="124" rx="8"/>
<rect x="590" y="86" width="210" height="116" rx="8"/>
<rect x="135" y="300" width="210" height="132" rx="8"/>
<rect x="470" y="315" width="256" height="124" rx="8"/>
</g>
<g fill="#172033">
<rect x="116" y="108" width="94" height="10" rx="5"/>
<rect x="116" y="132" width="114" height="8" rx="4" opacity=".48"/>
<rect x="116" y="152" width="76" height="8" rx="4" opacity=".32"/>
<rect x="344" y="166" width="120" height="10" rx="5"/>
<rect x="344" y="192" width="136" height="8" rx="4" opacity=".48"/>
<rect x="344" y="214" width="92" height="8" rx="4" opacity=".32"/>
<rect x="624" y="120" width="130" height="10" rx="5"/>
<rect x="624" y="146" width="102" height="8" rx="4" opacity=".48"/>
<rect x="624" y="168" width="148" height="8" rx="4" opacity=".32"/>
<rect x="166" y="336" width="132" height="10" rx="5"/>
<rect x="166" y="364" width="104" height="8" rx="4" opacity=".48"/>
<rect x="166" y="386" width="142" height="8" rx="4" opacity=".32"/>
<rect x="506" y="350" width="148" height="10" rx="5"/>
<rect x="506" y="378" width="182" height="8" rx="4" opacity=".48"/>
<rect x="506" y="400" width="120" height="8" rx="4" opacity=".32"/>
</g>
<g>
<circle cx="286" cy="230" r="15" fill="#d14b3f"/>
<circle cx="546" cy="282" r="15" fill="#287d7d"/>
<circle cx="775" cy="252" r="15" fill="#d6a130"/>
<circle cx="402" cy="88" r="12" fill="#4867a8"/>
<path d="M286 230 402 88 546 282 775 252" fill="none" stroke="#172033" stroke-width="3" stroke-opacity=".42"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

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.
<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>
</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="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>
<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>
<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>
</div>
);
}
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;
}
}

14
vitest.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from "vitest/config";
import { fileURLToPath } from "node:url";
export default defineConfig({
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});