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:
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;
|
||||
};
|
||||
Reference in New Issue
Block a user