Compare commits

...

1 Commits

Author SHA1 Message Date
m
f539102937 feat(dokumente): Phase H — AI deadline extraction from documents
Ports KanzlAI document upload + AI extraction into paliad. PDFs are stored
in Supabase Storage (bucket paliad-documents); Claude Sonnet extracts
deadlines with tool-forced structured output; the user reviews candidates
and picks which to persist as Fristen.

Backend
- internal/services/ai_service.go — Anthropic SDK wrapper. Uses native PDF
  content blocks, forced tool_use for structured output, ephemeral prompt
  caching on the system prompt. Sonnet 4.6.
- internal/services/storage.go — Supabase Storage REST client (upload,
  download, delete). Nil when SUPABASE_SERVICE_KEY is unset.
- internal/services/dokument_service.go — upload (PDF magic-number check,
  20 MB cap), list, download, extract, persist-confirmed-as-Fristen. All
  visibility-checked through AkteService.GetByID.
- internal/handlers/dokumente.go — five endpoints plus /api/config/features
  so the UI can hide disabled buttons.
- internal/handlers/ratelimit.go — in-memory per-user cap of 20 extractions
  per UTC day (design §9.7).
- Both optional services (storage, AI) degrade to 501 with friendly German
  messages when their env vars are unset.

Schema
- migration 013 adds fristen.source_document_id (FK to dokumente) and
  dokumente.ai_extraction_count + ai_extracted_at for the UI badge.

Frontend
- Dokumente tab in /akten/{id}/dokumente replaces the Phase D placeholder:
  drag-drop upload zone with live progress bar (XHR), document table with
  download + extract actions, extraction-review modal with per-row
  checkboxes, confidence chips, expandable source-quote, editable title +
  due date + rule code, POST to the from-extraction endpoint.
- Upload + extract buttons hide automatically when the server reports the
  feature is disabled.
- Full DE/EN i18n. CSS for the upload zone, extraction modal, and
  confidence chips.

Env vars (not set here — flag to head):
- ANTHROPIC_API_KEY (enables extraction)
- SUPABASE_SERVICE_KEY (enables upload/download)

Branch: mai/ritchie/phase-h-ai-deadline
2026-04-16 17:43:42 +02:00
17 changed files with 2138 additions and 75 deletions

View File

@@ -30,6 +30,20 @@ func main() {
log.Println("GITEA_TOKEN not set — file proxy will not be able to access private repos")
}
// Phase H: optional dependencies for document upload + AI extraction.
// Both services degrade to a 501 response when their env vars are unset;
// the UI hides their buttons based on GET /api/config/features.
supabaseServiceKey := os.Getenv("SUPABASE_SERVICE_KEY")
anthropicAPIKey := os.Getenv("ANTHROPIC_API_KEY")
storageClient := services.NewStorageClient(supabaseURL, supabaseServiceKey)
aiService := services.NewAIService(anthropicAPIKey)
if storageClient == nil {
log.Println("SUPABASE_SERVICE_KEY not set — document upload/download disabled")
}
if aiService == nil {
log.Println("ANTHROPIC_API_KEY not set — AI deadline extraction disabled")
}
// DATABASE_URL is optional during the Phase A → Phase D transition. The
// existing knowledge-platform features (Kostenrechner, Glossar, etc.) work
// without a DB. Akten/Frist endpoints return 503 until DATABASE_URL is set.
@@ -50,15 +64,18 @@ func main() {
users := services.NewUserService(pool)
akteSvc := services.NewAkteService(pool, users)
rules := services.NewDeadlineRuleService(pool)
fristSvc := services.NewFristService(pool, akteSvc)
dokumentSvc := services.NewDokumentService(pool, storageClient, aiService, akteSvc, fristSvc)
svcBundle = &handlers.Services{
Akte: akteSvc,
Parteien: services.NewParteienService(pool, akteSvc),
Frist: services.NewFristService(pool, akteSvc),
Frist: fristSvc,
Rules: rules,
Calculator: services.NewDeadlineCalculator(holidays),
Users: users,
Fristenrechner: services.NewFristenrechnerService(rules, holidays),
Dashboard: services.NewDashboardService(pool, users),
Dokument: dokumentSvc,
}
log.Println("Phase B services initialised")
} else {

View File

@@ -156,16 +156,95 @@ export function renderAktenDetail(): string {
</div>
</section>
{/* Dokumente — Phase H placeholder */}
{/* Dokumente — Phase H (upload + AI extraction) */}
<section className="akten-tab-panel" id="tab-dokumente" style="display:none">
<div className="akten-soon">
<h2 data-i18n="akten.detail.soon">Bald verf&uuml;gbar</h2>
<p data-i18n="akten.detail.soon.dokumente">
Dokumenten-Upload folgt in Phase H.
<div className="dokumente-intro">
<h2 className="dokumente-heading" data-i18n="akten.detail.dokumente.heading">Dokumente</h2>
<p className="dokumente-subtitle" data-i18n="akten.detail.dokumente.subtitle">
Gerichtsdokumente hochladen und per KI automatisch Fristen extrahieren.
</p>
</div>
<div id="dokument-upload-wrap">
<div id="dokument-upload-disabled" className="dokumente-disabled-notice" style="display:none" data-i18n="akten.detail.dokumente.upload.disabled">
Dokumenten-Upload ist auf diesem Server nicht konfiguriert.
</div>
<label id="dokument-upload-zone" className="dokumente-upload-zone" htmlFor="dokument-file-input">
<svg className="dokumente-upload-icon" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="18" x2="12" y2="12" />
<polyline points="9 15 12 12 15 15" />
</svg>
<div className="dokumente-upload-text" data-i18n="akten.detail.dokumente.upload.zone">
PDF hierher ziehen oder klicken zum Ausw&auml;hlen
</div>
<div className="dokumente-upload-hint" data-i18n="akten.detail.dokumente.upload.hint">Nur PDF, max. 20 MB</div>
<input type="file" id="dokument-file-input" accept="application/pdf" style="display:none" />
</label>
<div id="dokument-upload-progress" className="dokumente-upload-progress" style="display:none">
<div className="dokumente-upload-bar"><div className="dokumente-upload-bar-fill" id="dokument-upload-bar-fill" /></div>
<span id="dokument-upload-status" data-i18n="akten.detail.dokumente.upload.progress">Hochladen&hellip;</span>
</div>
<div id="dokument-upload-msg" className="form-msg" />
</div>
<div className="akten-table-wrap" id="dokumente-tablewrap" style="margin-top:1.5rem">
<table className="akten-table dokumente-table">
<thead>
<tr>
<th data-i18n="akten.detail.dokumente.col.name">Dateiname</th>
<th data-i18n="akten.detail.dokumente.col.uploaded">Hochgeladen</th>
<th data-i18n="akten.detail.dokumente.col.size">Gr&ouml;&szlig;e</th>
<th data-i18n="akten.detail.dokumente.col.actions">Aktionen</th>
</tr>
</thead>
<tbody id="dokumente-body" />
</table>
</div>
<p className="akten-events-empty" id="dokumente-empty" style="display:none" data-i18n="akten.detail.dokumente.list.empty">
Noch keine Dokumente hochgeladen.
</p>
</section>
{/* Phase H — Extraction review modal */}
<div className="modal-overlay" id="extraction-modal" style="display:none">
<div className="modal-card modal-card-wide">
<div className="modal-header">
<div>
<h2 data-i18n="akten.detail.dokumente.extraction.title">Extrahierte Fristen</h2>
<p className="modal-subtitle" data-i18n="akten.detail.dokumente.extraction.subtitle">
W&auml;hlen Sie aus, welche Vorschl&auml;ge als Fristen an die Akte &uuml;bernommen werden sollen.
</p>
</div>
<button className="modal-close" id="extraction-modal-close" type="button">&times;</button>
</div>
<div className="extraction-body">
<p className="extraction-none" id="extraction-none" style="display:none" data-i18n="akten.detail.dokumente.extraction.none">
Die KI hat keine Fristen im Dokument gefunden.
</p>
<table className="akten-table extraction-table" id="extraction-table">
<thead>
<tr>
<th data-i18n="akten.detail.dokumente.extraction.col.keep">&Uuml;bernehmen</th>
<th data-i18n="akten.detail.dokumente.extraction.col.title">Titel</th>
<th data-i18n="akten.detail.dokumente.extraction.col.due">F&auml;llig</th>
<th data-i18n="akten.detail.dokumente.extraction.col.rule">Regel</th>
<th data-i18n="akten.detail.dokumente.extraction.col.confidence">Konfidenz</th>
<th data-i18n="akten.detail.dokumente.extraction.col.source">Quelle</th>
</tr>
</thead>
<tbody id="extraction-body" />
</table>
</div>
<div className="form-actions">
<button type="button" className="btn-cancel" id="extraction-cancel" data-i18n="akten.detail.dokumente.extraction.cancel">Abbrechen</button>
<button type="button" className="btn-primary btn-cta-lime" id="extraction-save" data-i18n="akten.detail.dokumente.extraction.save">Als Fristen speichern</button>
</div>
<p className="form-msg" id="extraction-msg" />
</div>
</div>
{/* Notizen — Phase I placeholder */}
<section className="akten-tab-panel" id="tab-notizen" style="display:none">
<div className="akten-soon">

View File

@@ -46,6 +46,31 @@ interface Me {
office: string;
}
interface Dokument {
id: string;
akte_id: string;
title: string;
file_size?: number;
mime_type?: string;
ai_extraction_count: number;
ai_extracted_at?: string;
uploaded_by?: string;
created_at: string;
}
interface ExtractedDeadline {
title: string;
due_date: string;
rule_code: string;
confidence: number;
source_quote: string;
}
interface FeatureFlags {
document_upload: boolean;
ai_extraction: boolean;
}
type TabId = "verlauf" | "parteien" | "fristen" | "termine" | "dokumente" | "notizen";
const VALID_TABS: TabId[] = ["verlauf", "parteien", "fristen", "termine", "dokumente", "notizen"];
@@ -55,6 +80,10 @@ let me: Me | null = null;
let parteien: Partei[] = [];
let events: AkteEvent[] = [];
let fristen: Frist[] = [];
let dokumente: Dokument[] = [];
let features: FeatureFlags = { document_upload: false, ai_extraction: false };
let extractionDoc: Dokument | null = null;
let extractionItems: ExtractedDeadline[] = [];
function parseAkteID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
@@ -116,6 +145,407 @@ async function loadFristen(id: string) {
}
}
async function loadDokumente(id: string) {
try {
const resp = await fetch(`/api/akten/${id}/dokumente`);
if (resp.ok) dokumente = await resp.json();
} catch {
dokumente = [];
}
}
async function loadFeatures() {
try {
const resp = await fetch("/api/config/features");
if (resp.ok) features = await resp.json();
} catch {
/* optional — default flags stay false */
}
}
function fmtFileSize(bytes: number | undefined): string {
if (!bytes || bytes <= 0) return "\u2014";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function confidenceClass(c: number): { cls: string; label: string } {
if (c >= 0.75) {
return { cls: "extraction-confidence-high", label: t("akten.detail.dokumente.extraction.confidence.high") };
}
if (c >= 0.5) {
return { cls: "extraction-confidence-mid", label: t("akten.detail.dokumente.extraction.confidence.mid") };
}
return { cls: "extraction-confidence-low", label: t("akten.detail.dokumente.extraction.confidence.low") };
}
function renderDokumente() {
const body = document.getElementById("dokumente-body") as HTMLTableSectionElement | null;
const wrap = document.getElementById("dokumente-tablewrap") as HTMLElement | null;
const empty = document.getElementById("dokumente-empty") as HTMLElement | null;
const uploadWrap = document.getElementById("dokument-upload-wrap") as HTMLElement | null;
const uploadZone = document.getElementById("dokument-upload-zone") as HTMLLabelElement | null;
const uploadDisabled = document.getElementById("dokument-upload-disabled") as HTMLElement | null;
if (!body || !wrap || !empty || !uploadWrap || !uploadZone || !uploadDisabled) return;
if (features.document_upload) {
uploadZone.style.display = "";
uploadDisabled.style.display = "none";
} else {
uploadZone.style.display = "none";
uploadDisabled.style.display = "";
}
if (dokumente.length === 0) {
body.innerHTML = "";
wrap.style.display = "none";
empty.style.display = "block";
return;
}
wrap.style.display = "";
empty.style.display = "none";
body.innerHTML = dokumente
.map((d) => {
const extractedBadge =
d.ai_extraction_count > 0
? `<span class="dokument-extraction-badge">${d.ai_extraction_count}\u00d7</span>`
: "";
const extractBtn = features.ai_extraction
? `<button type="button" class="dokumente-action-btn dokumente-action-extract" data-action="extract" data-id="${esc(d.id)}">${esc(
t("akten.detail.dokumente.action.extract"),
)}</button>`
: "";
return `<tr data-id="${esc(d.id)}">
<td>
<div class="dokument-name-cell">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span>${esc(d.title)}</span>${extractedBadge}
</div>
</td>
<td>${fmtDateTime(d.created_at)}</td>
<td>${fmtFileSize(d.file_size)}</td>
<td class="dokumente-col-actions">
<a class="dokumente-action-btn" href="/api/dokumente/${esc(d.id)}/download" target="_blank" rel="noopener">${esc(
t("akten.detail.dokumente.action.download"),
)}</a>
${extractBtn}
</td>
</tr>`;
})
.join("");
body.querySelectorAll<HTMLButtonElement>('button[data-action="extract"]').forEach((btn) => {
btn.addEventListener("click", async () => {
const id = btn.dataset.id!;
const doc = dokumente.find((d) => d.id === id) || null;
if (!doc) return;
await runExtraction(btn, doc);
});
});
}
async function runExtraction(btn: HTMLButtonElement, doc: Dokument) {
const msgEl = document.getElementById("dokument-upload-msg") as HTMLElement | null;
btn.disabled = true;
const originalText = btn.textContent || "";
btn.textContent = t("akten.detail.dokumente.extract.running");
if (msgEl) {
msgEl.textContent = "";
msgEl.className = "form-msg";
}
try {
const resp = await fetch(`/api/dokumente/${doc.id}/extract-deadlines`, { method: "POST" });
if (resp.status === 429) {
if (msgEl) {
msgEl.textContent = t("akten.detail.dokumente.extract.ratelimit");
msgEl.className = "form-msg form-msg-error";
}
return;
}
if (resp.status === 501) {
if (msgEl) {
msgEl.textContent = t("akten.detail.dokumente.extract.disabled");
msgEl.className = "form-msg form-msg-error";
}
return;
}
if (!resp.ok) {
if (msgEl) {
msgEl.textContent = t("akten.detail.dokumente.extract.failed");
msgEl.className = "form-msg form-msg-error";
}
return;
}
const data = (await resp.json()) as { deadlines: ExtractedDeadline[] };
extractionDoc = doc;
extractionItems = data.deadlines || [];
openExtractionModal();
// Refresh doc list so the extraction badge updates.
if (akte) {
await loadDokumente(akte.id);
renderDokumente();
}
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
function openExtractionModal() {
const modal = document.getElementById("extraction-modal") as HTMLElement | null;
const body = document.getElementById("extraction-body") as HTMLTableSectionElement | null;
const none = document.getElementById("extraction-none") as HTMLElement | null;
const table = document.getElementById("extraction-table") as HTMLElement | null;
const msg = document.getElementById("extraction-msg") as HTMLElement | null;
if (!modal || !body || !none || !table) return;
if (msg) {
msg.textContent = "";
msg.className = "form-msg";
}
if (extractionItems.length === 0) {
table.style.display = "none";
none.style.display = "block";
} else {
table.style.display = "";
none.style.display = "none";
body.innerHTML = extractionItems
.map((it, idx) => {
const confCls = confidenceClass(it.confidence).cls;
const confLabel = confidenceClass(it.confidence).label;
const confPct = Math.round(it.confidence * 100);
const missingDate = !it.due_date
? `<div class="extraction-missing-date">${esc(t("akten.detail.dokumente.extraction.missing.date"))}</div>`
: "";
const quoteId = `extraction-quote-${idx}`;
return `<tr data-idx="${idx}">
<td><input type="checkbox" class="extraction-keep" ${it.due_date ? "checked" : ""} /></td>
<td><input type="text" class="extraction-title-input" value="${esc(it.title)}" /></td>
<td>
<input type="date" class="extraction-due-input" value="${esc(it.due_date)}" />
${missingDate}
</td>
<td><input type="text" class="extraction-rule-input" value="${esc(it.rule_code)}" placeholder="z.B. Rule 23 RoP" /></td>
<td><span class="extraction-confidence ${confCls}">${confPct}% ${esc(confLabel)}</span></td>
<td>
<button type="button" class="extraction-source-toggle" data-target="${quoteId}">${esc(
t("akten.detail.dokumente.extraction.source.show"),
)}</button>
<div class="extraction-source-quote" id="${quoteId}">\u201E${esc(it.source_quote)}\u201C</div>
</td>
</tr>`;
})
.join("");
body.querySelectorAll<HTMLButtonElement>(".extraction-source-toggle").forEach((btn) => {
btn.addEventListener("click", () => {
const tgt = document.getElementById(btn.dataset.target!) as HTMLElement | null;
if (!tgt) return;
const open = tgt.classList.toggle("extraction-source-quote-open");
btn.textContent = open
? t("akten.detail.dokumente.extraction.source.hide")
: t("akten.detail.dokumente.extraction.source.show");
});
});
}
modal.style.display = "flex";
}
function closeExtractionModal() {
const modal = document.getElementById("extraction-modal") as HTMLElement | null;
if (modal) modal.style.display = "none";
extractionDoc = null;
extractionItems = [];
}
async function saveExtractedFristen() {
if (!akte || !extractionDoc) return;
const msg = document.getElementById("extraction-msg") as HTMLElement | null;
const saveBtn = document.getElementById("extraction-save") as HTMLButtonElement | null;
if (msg) {
msg.textContent = "";
msg.className = "form-msg";
}
const rows = Array.from(document.querySelectorAll<HTMLTableRowElement>("#extraction-body tr"));
const items: Array<{ title: string; due_date: string; rule_code?: string; source_quote?: string }> = [];
for (const row of rows) {
const cb = row.querySelector<HTMLInputElement>(".extraction-keep");
if (!cb?.checked) continue;
const titleInput = row.querySelector<HTMLInputElement>(".extraction-title-input");
const dueInput = row.querySelector<HTMLInputElement>(".extraction-due-input");
const ruleInput = row.querySelector<HTMLInputElement>(".extraction-rule-input");
if (!titleInput || !dueInput) continue;
const title = titleInput.value.trim();
const due = dueInput.value.trim();
const rule = ruleInput?.value.trim() || "";
if (!title || !due) continue;
const idx = parseInt(row.dataset.idx || "-1", 10);
const sourceQuote = idx >= 0 ? extractionItems[idx]?.source_quote : "";
items.push({
title,
due_date: due,
rule_code: rule || undefined,
source_quote: sourceQuote || undefined,
});
}
if (items.length === 0) {
if (msg) {
msg.textContent = t("akten.detail.dokumente.extraction.save.none");
msg.className = "form-msg form-msg-error";
}
return;
}
if (saveBtn) saveBtn.disabled = true;
try {
const resp = await fetch(`/api/akten/${akte.id}/fristen/from-extraction`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ document_id: extractionDoc.id, items }),
});
if (!resp.ok) {
if (msg) {
msg.textContent = t("akten.detail.dokumente.extraction.save.failed");
msg.className = "form-msg form-msg-error";
}
return;
}
const created = await resp.json();
const count = Array.isArray(created) ? created.length : items.length;
if (msg) {
const template = t("akten.detail.dokumente.extraction.save.success");
msg.textContent = template.replace("{n}", String(count));
msg.className = "form-msg form-msg-success";
}
if (akte) {
await Promise.all([loadFristen(akte.id), loadEvents(akte.id), loadDokumente(akte.id)]);
renderFristen();
renderEvents();
renderDokumente();
}
// Auto-close after short delay so user can see the success message.
setTimeout(() => closeExtractionModal(), 1200);
} finally {
if (saveBtn) saveBtn.disabled = false;
}
}
function initDokumenteUpload() {
const input = document.getElementById("dokument-file-input") as HTMLInputElement | null;
const zone = document.getElementById("dokument-upload-zone") as HTMLLabelElement | null;
const progress = document.getElementById("dokument-upload-progress") as HTMLElement | null;
const bar = document.getElementById("dokument-upload-bar-fill") as HTMLElement | null;
const status = document.getElementById("dokument-upload-status") as HTMLElement | null;
const msg = document.getElementById("dokument-upload-msg") as HTMLElement | null;
if (!input || !zone || !progress || !bar || !status || !msg) return;
const handleFiles = async (files: FileList | null) => {
if (!files || files.length === 0 || !akte) return;
const file = files[0]!;
msg.textContent = "";
msg.className = "form-msg";
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
msg.textContent = t("akten.detail.dokumente.upload.notpdf");
msg.className = "form-msg form-msg-error";
return;
}
if (file.size > 20 * 1024 * 1024) {
msg.textContent = t("akten.detail.dokumente.upload.toolarge");
msg.className = "form-msg form-msg-error";
return;
}
progress.style.display = "flex";
bar.style.width = "0%";
status.textContent = t("akten.detail.dokumente.upload.progress");
const fd = new FormData();
fd.append("file", file);
// Use XHR for real upload-progress reporting (fetch can't stream upload
// progress in browsers without the experimental ReadableStream.pipeTo).
const xhr = new XMLHttpRequest();
xhr.open("POST", `/api/akten/${akte.id}/dokumente`);
xhr.upload.addEventListener("progress", (ev) => {
if (ev.lengthComputable) {
const pct = Math.round((ev.loaded / ev.total) * 100);
bar.style.width = `${pct}%`;
}
});
xhr.onload = async () => {
progress.style.display = "none";
input.value = "";
if (xhr.status >= 200 && xhr.status < 300) {
msg.textContent = t("akten.detail.dokumente.upload.success");
msg.className = "form-msg form-msg-success";
if (akte) {
await loadDokumente(akte.id);
renderDokumente();
await loadEvents(akte.id);
renderEvents();
}
} else if (xhr.status === 413) {
msg.textContent = t("akten.detail.dokumente.upload.toolarge");
msg.className = "form-msg form-msg-error";
} else {
let reason = t("akten.detail.dokumente.upload.failed");
try {
const body = JSON.parse(xhr.responseText) as { error?: string };
if (body.error) reason = body.error;
} catch {
/* ignore parse errors */
}
msg.textContent = reason;
msg.className = "form-msg form-msg-error";
}
};
xhr.onerror = () => {
progress.style.display = "none";
msg.textContent = t("akten.detail.dokumente.upload.failed");
msg.className = "form-msg form-msg-error";
};
xhr.send(fd);
};
input.addEventListener("change", () => handleFiles(input.files));
// Drag-drop on the zone.
zone.addEventListener("dragover", (e) => {
e.preventDefault();
zone.classList.add("dokumente-upload-zone-drag");
});
zone.addEventListener("dragleave", () => {
zone.classList.remove("dokumente-upload-zone-drag");
});
zone.addEventListener("drop", (e) => {
e.preventDefault();
zone.classList.remove("dokumente-upload-zone-drag");
handleFiles(e.dataTransfer?.files ?? null);
});
}
function initExtractionModal() {
const modal = document.getElementById("extraction-modal") as HTMLElement | null;
const closeBtn = document.getElementById("extraction-modal-close") as HTMLElement | null;
const cancelBtn = document.getElementById("extraction-cancel") as HTMLElement | null;
const saveBtn = document.getElementById("extraction-save") as HTMLElement | null;
if (!modal || !closeBtn || !cancelBtn || !saveBtn) return;
closeBtn.addEventListener("click", closeExtractionModal);
cancelBtn.addEventListener("click", closeExtractionModal);
modal.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeExtractionModal();
});
saveBtn.addEventListener("click", saveExtractedFristen);
}
function fmtDateOnly(iso: string): string {
try {
const d = new Date(iso.slice(0, 10) + "T00:00:00");
@@ -500,7 +930,7 @@ async function main() {
return;
}
await loadMe();
await Promise.all([loadMe(), loadFeatures()]);
const ok = await loadAkte(id);
if (!ok || !akte) {
loading.style.display = "none";
@@ -508,7 +938,7 @@ async function main() {
return;
}
await Promise.all([loadParteien(id), loadEvents(id), loadFristen(id)]);
await Promise.all([loadParteien(id), loadEvents(id), loadFristen(id), loadDokumente(id)]);
loading.style.display = "none";
body.style.display = "";
@@ -516,11 +946,14 @@ async function main() {
renderParteien();
renderEvents();
renderFristen();
renderDokumente();
initFristAddLink();
initTabs();
initTitleEdit();
initParteienForm();
initDelete();
initDokumenteUpload();
initExtractionModal();
showTab(parseTab());
}
@@ -532,6 +965,7 @@ document.addEventListener("DOMContentLoaded", () => {
renderEvents();
renderParteien();
renderFristen();
renderDokumente();
});
main();
});

View File

@@ -428,8 +428,51 @@ const translations: Record<Lang, Record<string, string>> = {
"akten.detail.soon": "Bald verf\u00fcgbar",
"akten.detail.soon.fristen": "Fristenverwaltung kommt in Phase E \u2014 diese Akte wird dann Fristen anzeigen.",
"akten.detail.soon.termine": "Termine & CalDAV-Sync folgen in Phase F.",
"akten.detail.soon.dokumente": "Dokumenten-Upload folgt in Phase H.",
"akten.detail.soon.notizen": "Notizfunktion folgt in Phase I.",
// Phase H — Dokumente + KI-Extraktion
"akten.detail.dokumente.heading": "Dokumente",
"akten.detail.dokumente.subtitle": "Gerichtsdokumente hochladen und per KI automatisch Fristen extrahieren.",
"akten.detail.dokumente.upload.zone": "PDF hierher ziehen oder klicken zum Ausw\u00e4hlen",
"akten.detail.dokumente.upload.hint": "Nur PDF, max. 20 MB",
"akten.detail.dokumente.upload.disabled": "Dokumenten-Upload ist auf diesem Server nicht konfiguriert.",
"akten.detail.dokumente.upload.progress": "Hochladen\u2026",
"akten.detail.dokumente.upload.success": "Dokument hochgeladen.",
"akten.detail.dokumente.upload.failed": "Upload fehlgeschlagen.",
"akten.detail.dokumente.upload.notpdf": "Nur PDF-Dateien werden akzeptiert.",
"akten.detail.dokumente.upload.toolarge": "Datei ist zu gro\u00df (max. 20 MB).",
"akten.detail.dokumente.list.empty": "Noch keine Dokumente hochgeladen.",
"akten.detail.dokumente.col.name": "Dateiname",
"akten.detail.dokumente.col.uploaded": "Hochgeladen",
"akten.detail.dokumente.col.size": "Gr\u00f6\u00dfe",
"akten.detail.dokumente.col.actions": "Aktionen",
"akten.detail.dokumente.action.download": "Herunterladen",
"akten.detail.dokumente.action.extract": "Fristen extrahieren",
"akten.detail.dokumente.extract.running": "KI extrahiert\u2026",
"akten.detail.dokumente.extract.failed": "Extraktion fehlgeschlagen.",
"akten.detail.dokumente.extract.ratelimit": "Tageslimit f\u00fcr KI-Extraktionen erreicht (20 pro Tag).",
"akten.detail.dokumente.extract.disabled": "KI-Extraktion ist auf diesem Server nicht konfiguriert.",
"akten.detail.dokumente.extraction.title": "Extrahierte Fristen",
"akten.detail.dokumente.extraction.subtitle": "W\u00e4hlen Sie aus, welche Vorschl\u00e4ge als Fristen an die Akte \u00fcbernommen werden sollen.",
"akten.detail.dokumente.extraction.none": "Die KI hat keine Fristen im Dokument gefunden.",
"akten.detail.dokumente.extraction.col.keep": "\u00dcbernehmen",
"akten.detail.dokumente.extraction.col.title": "Titel",
"akten.detail.dokumente.extraction.col.due": "F\u00e4llig",
"akten.detail.dokumente.extraction.col.rule": "Regel",
"akten.detail.dokumente.extraction.col.confidence": "Konfidenz",
"akten.detail.dokumente.extraction.col.source": "Quelle",
"akten.detail.dokumente.extraction.source.show": "Zitat anzeigen",
"akten.detail.dokumente.extraction.source.hide": "Zitat verbergen",
"akten.detail.dokumente.extraction.cancel": "Abbrechen",
"akten.detail.dokumente.extraction.save": "Als Fristen speichern",
"akten.detail.dokumente.extraction.save.none": "Mindestens eine Frist mit F\u00e4lligkeitsdatum ausw\u00e4hlen.",
"akten.detail.dokumente.extraction.save.success": "{n} Fristen \u00fcbernommen.",
"akten.detail.dokumente.extraction.save.failed": "Speichern fehlgeschlagen.",
"akten.detail.dokumente.extraction.view.fristen": "Zu den Fristen \u2192",
"akten.detail.dokumente.extraction.confidence.low": "niedrig",
"akten.detail.dokumente.extraction.confidence.mid": "mittel",
"akten.detail.dokumente.extraction.confidence.high": "hoch",
"akten.detail.dokumente.extraction.missing.date": "Kein Datum",
"akten.detail.verlauf.empty": "Noch keine Ereignisse aufgezeichnet.",
"akten.detail.parteien.add": "Partei hinzuf\u00fcgen",
"akten.detail.parteien.empty": "Noch keine Parteien eingetragen.",
@@ -1041,8 +1084,51 @@ const translations: Record<Lang, Record<string, string>> = {
"akten.detail.soon": "Coming soon",
"akten.detail.soon.fristen": "Deadline management ships in Phase E \u2014 this matter will then list its deadlines here.",
"akten.detail.soon.termine": "Appointments & CalDAV sync follow in Phase F.",
"akten.detail.soon.dokumente": "Document upload lands in Phase H.",
"akten.detail.soon.notizen": "Notes ship in Phase I.",
// Phase H — Documents + AI extraction
"akten.detail.dokumente.heading": "Documents",
"akten.detail.dokumente.subtitle": "Upload court documents and extract deadlines automatically with AI.",
"akten.detail.dokumente.upload.zone": "Drop a PDF here or click to choose",
"akten.detail.dokumente.upload.hint": "PDF only, max 20 MB",
"akten.detail.dokumente.upload.disabled": "Document upload is not configured on this server.",
"akten.detail.dokumente.upload.progress": "Uploading\u2026",
"akten.detail.dokumente.upload.success": "Document uploaded.",
"akten.detail.dokumente.upload.failed": "Upload failed.",
"akten.detail.dokumente.upload.notpdf": "Only PDF files are accepted.",
"akten.detail.dokumente.upload.toolarge": "File is too large (max 20 MB).",
"akten.detail.dokumente.list.empty": "No documents uploaded yet.",
"akten.detail.dokumente.col.name": "Filename",
"akten.detail.dokumente.col.uploaded": "Uploaded",
"akten.detail.dokumente.col.size": "Size",
"akten.detail.dokumente.col.actions": "Actions",
"akten.detail.dokumente.action.download": "Download",
"akten.detail.dokumente.action.extract": "Extract deadlines",
"akten.detail.dokumente.extract.running": "AI extracting\u2026",
"akten.detail.dokumente.extract.failed": "Extraction failed.",
"akten.detail.dokumente.extract.ratelimit": "Daily limit for AI extractions reached (20 per day).",
"akten.detail.dokumente.extract.disabled": "AI extraction is not configured on this server.",
"akten.detail.dokumente.extraction.title": "Extracted deadlines",
"akten.detail.dokumente.extraction.subtitle": "Choose which suggestions to save as deadlines on this matter.",
"akten.detail.dokumente.extraction.none": "The AI did not find any deadlines in this document.",
"akten.detail.dokumente.extraction.col.keep": "Keep",
"akten.detail.dokumente.extraction.col.title": "Title",
"akten.detail.dokumente.extraction.col.due": "Due",
"akten.detail.dokumente.extraction.col.rule": "Rule",
"akten.detail.dokumente.extraction.col.confidence": "Confidence",
"akten.detail.dokumente.extraction.col.source": "Source",
"akten.detail.dokumente.extraction.source.show": "Show quote",
"akten.detail.dokumente.extraction.source.hide": "Hide quote",
"akten.detail.dokumente.extraction.cancel": "Cancel",
"akten.detail.dokumente.extraction.save": "Save as deadlines",
"akten.detail.dokumente.extraction.save.none": "Select at least one deadline with a due date.",
"akten.detail.dokumente.extraction.save.success": "{n} deadlines saved.",
"akten.detail.dokumente.extraction.save.failed": "Save failed.",
"akten.detail.dokumente.extraction.view.fristen": "View deadlines \u2192",
"akten.detail.dokumente.extraction.confidence.low": "low",
"akten.detail.dokumente.extraction.confidence.mid": "medium",
"akten.detail.dokumente.extraction.confidence.high": "high",
"akten.detail.dokumente.extraction.missing.date": "No date",
"akten.detail.verlauf.empty": "No events recorded yet.",
"akten.detail.parteien.add": "Add party",
"akten.detail.parteien.empty": "No parties recorded yet.",

View File

@@ -4930,3 +4930,267 @@ input[type="range"]::-moz-range-thumb {
gap: 0.2rem;
}
}
/* ============================================================================
Phase H — Documents tab + AI extraction
============================================================================ */
.dokumente-intro {
margin-bottom: 1.25rem;
}
.dokumente-heading {
font-size: 1.25rem;
font-weight: 700;
margin: 0 0 0.25rem;
}
.dokumente-subtitle {
color: var(--color-text-muted);
font-size: 0.92rem;
margin: 0;
}
.dokumente-disabled-notice {
background: rgba(245, 158, 11, 0.08);
border: 1px solid rgba(245, 158, 11, 0.35);
color: #7a5a00;
padding: 0.75rem 1rem;
border-radius: var(--radius);
font-size: 0.9rem;
margin-bottom: 1rem;
}
.dokumente-upload-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 2rem 1.5rem;
border: 2px dashed var(--color-border);
border-radius: calc(var(--radius) * 1.25);
text-align: center;
cursor: pointer;
background: var(--color-surface-muted, #fafafa);
transition: border-color 0.15s ease, background 0.15s ease;
color: var(--color-text-muted);
}
.dokumente-upload-zone:hover,
.dokumente-upload-zone.dokumente-upload-zone-drag {
border-color: var(--color-accent, #c6f41c);
background: rgba(198, 244, 28, 0.08);
color: var(--color-text);
}
.dokumente-upload-icon {
color: var(--color-text-muted);
}
.dokumente-upload-zone:hover .dokumente-upload-icon,
.dokumente-upload-zone.dokumente-upload-zone-drag .dokumente-upload-icon {
color: #4a6a0b;
}
.dokumente-upload-text {
font-weight: 600;
font-size: 1rem;
}
.dokumente-upload-hint {
font-size: 0.82rem;
color: var(--color-text-muted);
}
.dokumente-upload-progress {
margin-top: 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.88rem;
color: var(--color-text-muted);
}
.dokumente-upload-bar {
flex: 1;
height: 6px;
background: var(--color-border);
border-radius: 999px;
overflow: hidden;
}
.dokumente-upload-bar-fill {
height: 100%;
background: var(--color-accent, #c6f41c);
width: 0%;
transition: width 0.2s ease;
}
.dokumente-table .dokumente-col-actions {
white-space: nowrap;
}
.dokumente-action-btn {
background: none;
border: 1px solid var(--color-border);
color: var(--color-text);
padding: 0.35rem 0.7rem;
border-radius: var(--radius);
font-size: 0.82rem;
cursor: pointer;
margin-right: 0.35rem;
text-decoration: none;
display: inline-block;
}
.dokumente-action-btn:hover {
background: var(--color-surface-muted, #f5f5f5);
border-color: var(--color-text-muted);
}
.dokumente-action-btn.dokumente-action-extract {
background: var(--color-accent, #c6f41c);
border-color: var(--color-accent, #c6f41c);
color: #1a2a00;
font-weight: 600;
}
.dokumente-action-btn.dokumente-action-extract:disabled {
opacity: 0.6;
cursor: wait;
}
.dokument-name-cell {
display: flex;
align-items: center;
gap: 0.5rem;
}
.dokument-name-cell svg {
color: #a00;
flex-shrink: 0;
}
.dokument-extraction-badge {
font-size: 0.75rem;
color: var(--color-text-muted);
margin-left: 0.5rem;
}
/* Extraction review modal — wider than default */
.modal-card-wide {
max-width: 960px;
}
.modal-header .modal-subtitle {
font-size: 0.88rem;
color: var(--color-text-muted);
margin: 0.2rem 0 0;
}
.extraction-body {
padding: 0.5rem 1.5rem 1rem;
}
.extraction-none {
text-align: center;
padding: 2rem;
color: var(--color-text-muted);
}
.extraction-table {
width: 100%;
}
.extraction-table td,
.extraction-table th {
vertical-align: top;
padding: 0.6rem 0.5rem;
}
.extraction-table tbody tr:hover {
background: var(--color-surface-muted, #fafafa);
}
.extraction-title-input,
.extraction-due-input,
.extraction-rule-input {
width: 100%;
padding: 0.35rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: 0.88rem;
font-family: inherit;
background: var(--color-surface);
}
.extraction-due-input {
min-width: 140px;
}
.extraction-confidence {
font-size: 0.82rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
font-weight: 600;
}
.extraction-confidence-high {
background: rgba(34, 197, 94, 0.15);
color: #14532d;
}
.extraction-confidence-mid {
background: rgba(245, 158, 11, 0.15);
color: #7a4a00;
}
.extraction-confidence-low {
background: rgba(239, 68, 68, 0.12);
color: #7a0d0d;
}
.extraction-source-toggle {
background: none;
border: none;
padding: 0;
color: var(--color-link, #3b5bdb);
cursor: pointer;
font-size: 0.82rem;
text-decoration: underline;
}
.extraction-source-quote {
display: none;
font-size: 0.82rem;
background: var(--color-surface-muted, #fafafa);
border-left: 3px solid var(--color-accent, #c6f41c);
padding: 0.5rem 0.75rem;
margin-top: 0.35rem;
color: var(--color-text-muted);
font-style: italic;
}
.extraction-source-quote.extraction-source-quote-open {
display: block;
}
.extraction-missing-date {
font-size: 0.75rem;
color: #b00;
margin-top: 0.25rem;
}
@media (max-width: 900px) {
.modal-card-wide {
max-width: 95vw;
}
.extraction-table {
font-size: 0.82rem;
}
.extraction-due-input {
min-width: unset;
}
}

17
go.mod
View File

@@ -3,8 +3,17 @@ module mgit.msbls.de/m/patholo
go 1.24.0
require (
github.com/golang-migrate/migrate/v4 v4.19.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/anthropics/anthropic-sdk-go v1.37.0
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/jmoiron/sqlx v1.4.0
github.com/lib/pq v1.12.3
)
require (
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
golang.org/x/sync v0.18.0 // indirect
)

80
go.sum
View File

@@ -1,13 +1,91 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/anthropics/anthropic-sdk-go v1.37.0 h1:yBKUaBG3TCRb6das/Q5qNB9Fsafon09gu2yYVgvapKE=
github.com/anthropics/anthropic-sdk-go v1.37.0/go.mod h1:dSIO7kSrOI7MA4fE6RRVaw8tyWP7HNQU5/H/KS4cax8=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,8 @@
DROP INDEX IF EXISTS paliad.fristen_source_document_idx;
ALTER TABLE paliad.fristen
DROP COLUMN IF EXISTS source_document_id;
ALTER TABLE paliad.dokumente
DROP COLUMN IF EXISTS ai_extraction_count,
DROP COLUMN IF EXISTS ai_extracted_at;

View File

@@ -0,0 +1,24 @@
-- Phase H: wire documents to AI deadline extraction.
--
-- paliad.dokumente already exists (migration 005). This migration:
-- 1. Adds source_document_id to paliad.fristen so Fristen extracted from a
-- document remember where they came from (for audit + "extracted from X"
-- badges in the UI).
-- 2. Adds ai_extraction_count + ai_extracted_at to paliad.dokumente so the
-- UI can show "extracted N times" and link to the last extraction result
-- stored in ai_extracted (jsonb, already present).
--
-- Rate limiting (20 extractions/day/user) is enforced in-memory in the
-- application layer; persisting it would need a new table but the spec says
-- in-memory is acceptable for the first release.
ALTER TABLE paliad.fristen
ADD COLUMN source_document_id uuid REFERENCES paliad.dokumente(id) ON DELETE SET NULL;
CREATE INDEX fristen_source_document_idx
ON paliad.fristen (source_document_id)
WHERE source_document_id IS NOT NULL;
ALTER TABLE paliad.dokumente
ADD COLUMN ai_extraction_count integer NOT NULL DEFAULT 0,
ADD COLUMN ai_extracted_at timestamptz;

View File

@@ -22,6 +22,7 @@ type dbServices struct {
users *services.UserService
fristenrechner *services.FristenrechnerService
dashboard *services.DashboardService
dokument *services.DokumentService
}
var dbSvc *dbServices

View File

@@ -0,0 +1,279 @@
package handlers
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/google/uuid"
"mgit.msbls.de/m/patholo/internal/services"
)
// GET /api/config/features — public feature flags for the logged-in UI.
// Tells the frontend which Phase H capabilities are available so it can
// hide upload / extract buttons rather than show broken controls.
func handleFeatures(w http.ResponseWriter, r *http.Request) {
upload := false
extract := false
if dbSvc != nil && dbSvc.dokument != nil {
upload = dbSvc.dokument.UploadEnabled()
extract = dbSvc.dokument.ExtractionEnabled()
}
writeJSON(w, http.StatusOK, map[string]bool{
"document_upload": upload,
"ai_extraction": extract,
})
}
// POST /api/akten/{id}/dokumente — multipart PDF upload.
func handleUploadDokument(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.dokument == nil || !dbSvc.dokument.UploadEnabled() {
writeJSON(w, http.StatusNotImplemented, map[string]string{
"error": "Dokumenten-Upload ist nicht konfiguriert (SUPABASE_SERVICE_KEY fehlt).",
})
return
}
akteID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
// Cap the total request size slightly above the 20 MB blob limit so we
// refuse oversized uploads before buffering the full body.
r.Body = http.MaxBytesReader(w, r.Body, services.MaxUploadBytes+4096)
if err := r.ParseMultipartForm(services.MaxUploadBytes + 4096); err != nil {
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{
"error": "Upload zu groß (Maximum 20 MB).",
})
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "Feld 'file' fehlt im Upload.",
})
return
}
defer file.Close()
filename := sanitizeFilename(header.Filename)
if filename == "" {
filename = "document.pdf"
}
doc, err := dbSvc.dokument.UploadPDF(r.Context(), uid, akteID, filename, file)
if err != nil {
writeDokumentError(w, err)
return
}
writeJSON(w, http.StatusCreated, doc)
}
// GET /api/akten/{id}/dokumente
func handleListDokumente(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.dokument == nil {
writeJSON(w, http.StatusOK, []any{})
return
}
akteID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
rows, err := dbSvc.dokument.ListForAkte(r.Context(), uid, akteID)
if err != nil {
writeDokumentError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// GET /api/dokumente/{id}/download — streams the stored PDF bytes.
func handleDownloadDokument(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.dokument == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{
"error": "Dokument-Download ist nicht konfiguriert.",
})
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
body, contentType, filename, err := dbSvc.dokument.Download(r.Context(), uid, id)
if err != nil {
writeDokumentError(w, err)
return
}
defer body.Close()
if contentType == "" {
contentType = "application/pdf"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition",
fmt.Sprintf(`inline; filename="%s"`, escapeHeaderValue(filename)))
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, body)
}
// POST /api/dokumente/{id}/extract-deadlines — kicks off AI extraction.
func handleExtractDeadlines(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.dokument == nil || !dbSvc.dokument.ExtractionEnabled() {
writeJSON(w, http.StatusNotImplemented, map[string]string{
"error": "KI-Extraktion ist nicht konfiguriert (ANTHROPIC_API_KEY fehlt).",
})
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
// Per-user rate limit (20/day) — fail fast before incurring API cost.
if ok, _ := extractionLimiter.Check(uid); !ok {
writeJSON(w, http.StatusTooManyRequests, map[string]string{
"error": "Tageslimit für KI-Extraktionen erreicht (20 pro Tag). Bitte morgen erneut versuchen.",
})
return
}
items, err := dbSvc.dokument.ExtractDeadlines(r.Context(), uid, id)
if err != nil {
writeDokumentError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"document_id": id,
"deadlines": items,
})
}
// POST /api/akten/{id}/fristen/from-extraction — persists confirmed items.
func handlePersistExtractedFristen(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.dokument == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{
"error": "Dokument-Service ist nicht konfiguriert.",
})
return
}
akteID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var body struct {
DocumentID string `json:"document_id"`
Items []services.PersistFristenInput `json:"items"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
docID, err := uuid.Parse(body.DocumentID)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid document_id"})
return
}
created, err := dbSvc.dokument.PersistExtractedFristen(r.Context(), uid, akteID, docID, body.Items)
if err != nil {
writeDokumentError(w, err)
return
}
writeJSON(w, http.StatusCreated, created)
}
// writeDokumentError translates service errors into HTTP responses, folding
// in the Phase H-specific ErrAIDisabled / ErrStorageDisabled sentinels.
func writeDokumentError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, services.ErrAIDisabled):
writeJSON(w, http.StatusNotImplemented, map[string]string{
"error": "KI-Extraktion ist nicht konfiguriert.",
})
case errors.Is(err, services.ErrStorageDisabled):
writeJSON(w, http.StatusNotImplemented, map[string]string{
"error": "Dokument-Speicher ist nicht konfiguriert.",
})
default:
writeServiceError(w, err)
}
}
// sanitizeFilename strips directory components and a limited set of risky
// characters from the user-supplied filename before we store it.
func sanitizeFilename(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
// Drop any path prefix (Windows or Unix).
if i := strings.LastIndexAny(raw, "/\\"); i >= 0 {
raw = raw[i+1:]
}
// Kill control chars + quotes + semicolons to keep the Content-
// Disposition header safe.
var b strings.Builder
for _, r := range raw {
if r < 32 || r == '"' || r == ';' || r == '\\' {
continue
}
b.WriteRune(r)
}
out := strings.TrimSpace(b.String())
if len(out) > 180 {
out = out[:180]
}
return out
}
// escapeHeaderValue is a minimal guard so a filename like `a"b` cannot break
// out of the Content-Disposition quoted-string.
func escapeHeaderValue(s string) string {
return strings.NewReplacer(`"`, ``, "\n", "", "\r", "").Replace(s)
}

View File

@@ -21,6 +21,7 @@ type Services struct {
Users *services.UserService
Fristenrechner *services.FristenrechnerService
Dashboard *services.DashboardService
Dokument *services.DokumentService
}
func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc *Services) {
@@ -37,6 +38,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
users: svc.Users,
fristenrechner: svc.Fristenrechner,
dashboard: svc.Dashboard,
dokument: svc.Dokument,
}
}
@@ -112,6 +114,14 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/akten/{id}/fristen", handleCreateFrist)
protected.HandleFunc("POST /api/akten/{id}/fristen/bulk", handleBulkCreateFristen)
// Phase H — Documents + AI deadline extraction
protected.HandleFunc("GET /api/config/features", handleFeatures)
protected.HandleFunc("POST /api/akten/{id}/dokumente", handleUploadDokument)
protected.HandleFunc("GET /api/akten/{id}/dokumente", handleListDokumente)
protected.HandleFunc("GET /api/dokumente/{id}/download", handleDownloadDokument)
protected.HandleFunc("POST /api/dokumente/{id}/extract-deadlines", handleExtractDeadlines)
protected.HandleFunc("POST /api/akten/{id}/fristen/from-extraction", handlePersistExtractedFristen)
protected.HandleFunc("GET /api/me", handleGetMe)
protected.HandleFunc("GET /api/users", handleListUsers)
protected.HandleFunc("GET /api/dashboard", handleDashboardAPI)

View File

@@ -0,0 +1,47 @@
package handlers
import (
"sync"
"time"
"github.com/google/uuid"
)
// extractionRateLimit enforces a per-user daily cap on AI extraction calls.
// In-memory, not persistent — acceptable for the first release per the
// Phase H spec. Keys roll over at local midnight; the map is pruned on each
// check to keep it bounded.
type extractionRateLimit struct {
mu sync.Mutex
limit int
hits map[uuid.UUID]*rateBucket
}
type rateBucket struct {
count int
day string // YYYY-MM-DD; rotate when it changes
}
var extractionLimiter = &extractionRateLimit{
limit: 20,
hits: make(map[uuid.UUID]*rateBucket),
}
// Check increments the user's counter and returns true if under the limit.
// Returns (false, remaining=0) once the daily cap is hit.
func (r *extractionRateLimit) Check(userID uuid.UUID) (ok bool, remaining int) {
r.mu.Lock()
defer r.mu.Unlock()
today := time.Now().UTC().Format("2006-01-02")
b, exists := r.hits[userID]
if !exists || b.day != today {
b = &rateBucket{day: today}
r.hits[userID] = b
}
if b.count >= r.limit {
return false, 0
}
b.count++
return true, r.limit - b.count
}

View File

@@ -26,21 +26,21 @@ type User struct {
// Akte is a matter (case file). Office-scoped visibility: see paliad.can_see_akte.
type Akte struct {
ID uuid.UUID `db:"id" json:"id"`
Aktenzeichen string `db:"aktenzeichen" json:"aktenzeichen"`
Title string `db:"title" json:"title"`
AkteType *string `db:"akte_type" json:"akte_type,omitempty"`
Court *string `db:"court" json:"court,omitempty"`
CourtRef *string `db:"court_ref" json:"court_ref,omitempty"`
Status string `db:"status" json:"status"`
AISummary *string `db:"ai_summary" json:"ai_summary,omitempty"`
OwningOffice string `db:"owning_office" json:"owning_office"`
Collaborators pq.StringArray `db:"collaborators" json:"collaborators"`
FirmWideVisible bool `db:"firm_wide_visible" json:"firm_wide_visible"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
Metadata json.RawMessage `db:"metadata" json:"metadata"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ID uuid.UUID `db:"id" json:"id"`
Aktenzeichen string `db:"aktenzeichen" json:"aktenzeichen"`
Title string `db:"title" json:"title"`
AkteType *string `db:"akte_type" json:"akte_type,omitempty"`
Court *string `db:"court" json:"court,omitempty"`
CourtRef *string `db:"court_ref" json:"court_ref,omitempty"`
Status string `db:"status" json:"status"`
AISummary *string `db:"ai_summary" json:"ai_summary,omitempty"`
OwningOffice string `db:"owning_office" json:"owning_office"`
Collaborators pq.StringArray `db:"collaborators" json:"collaborators"`
FirmWideVisible bool `db:"firm_wide_visible" json:"firm_wide_visible"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
Metadata json.RawMessage `db:"metadata" json:"metadata"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// AkteEvent is one row in the per-Akte audit trail.
@@ -60,23 +60,24 @@ type AkteEvent struct {
// Frist is one persistent deadline attached to an Akte.
// Visibility is inherited from the parent Akte (see paliad.can_see_akte).
type Frist struct {
ID uuid.UUID `db:"id" json:"id"`
AkteID uuid.UUID `db:"akte_id" json:"akte_id"`
Title string `db:"title" json:"title"`
Description *string `db:"description" json:"description,omitempty"`
DueDate time.Time `db:"due_date" json:"due_date"`
OriginalDueDate *time.Time `db:"original_due_date" json:"original_due_date,omitempty"`
WarningDate *time.Time `db:"warning_date" json:"warning_date,omitempty"`
Source string `db:"source" json:"source"`
RuleID *uuid.UUID `db:"rule_id" json:"rule_id,omitempty"`
Status string `db:"status" json:"status"`
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"`
CalDAVEtag *string `db:"caldav_etag" json:"caldav_etag,omitempty"`
Notes *string `db:"notes" json:"notes,omitempty"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ID uuid.UUID `db:"id" json:"id"`
AkteID uuid.UUID `db:"akte_id" json:"akte_id"`
Title string `db:"title" json:"title"`
Description *string `db:"description" json:"description,omitempty"`
DueDate time.Time `db:"due_date" json:"due_date"`
OriginalDueDate *time.Time `db:"original_due_date" json:"original_due_date,omitempty"`
WarningDate *time.Time `db:"warning_date" json:"warning_date,omitempty"`
Source string `db:"source" json:"source"`
RuleID *uuid.UUID `db:"rule_id" json:"rule_id,omitempty"`
Status string `db:"status" json:"status"`
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"`
CalDAVEtag *string `db:"caldav_etag" json:"caldav_etag,omitempty"`
Notes *string `db:"notes" json:"notes,omitempty"`
SourceDocumentID *uuid.UUID `db:"source_document_id" json:"source_document_id,omitempty"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// FristWithAkte enriches a Frist with parent Akte fields needed by the
@@ -84,12 +85,31 @@ type Frist struct {
// per-row /api/akten/{id} fetch.
type FristWithAkte struct {
Frist
AkteAktenzeichen string `db:"akte_aktenzeichen" json:"akte_aktenzeichen"`
AkteTitle string `db:"akte_title" json:"akte_title"`
AkteOffice string `db:"akte_office" json:"akte_office"`
AkteAktenzeichen string `db:"akte_aktenzeichen" json:"akte_aktenzeichen"`
AkteTitle string `db:"akte_title" json:"akte_title"`
AkteOffice string `db:"akte_office" json:"akte_office"`
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
}
// Dokument is a file uploaded against an Akte. Blob lives in Supabase
// Storage (bucket paliad-documents); only metadata is stored here.
// Visibility is inherited from the parent Akte.
type Dokument struct {
ID uuid.UUID `db:"id" json:"id"`
AkteID uuid.UUID `db:"akte_id" json:"akte_id"`
Title string `db:"title" json:"title"`
DocType *string `db:"doc_type" json:"doc_type,omitempty"`
FilePath *string `db:"file_path" json:"file_path,omitempty"`
FileSize *int64 `db:"file_size" json:"file_size,omitempty"`
MimeType *string `db:"mime_type" json:"mime_type,omitempty"`
AIExtracted json.RawMessage `db:"ai_extracted" json:"ai_extracted,omitempty"`
AIExtractionCount int `db:"ai_extraction_count" json:"ai_extraction_count"`
AIExtractedAt *time.Time `db:"ai_extracted_at" json:"ai_extracted_at,omitempty"`
UploadedBy *uuid.UUID `db:"uploaded_by" json:"uploaded_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// Partei is a party to an Akte (Kläger, Beklagter, etc.).
type Partei struct {
ID uuid.UUID `db:"id" json:"id"`
@@ -104,31 +124,31 @@ type Partei struct {
// DeadlineRule is one rule in the proceeding-rule tree (UPC R.023, etc.).
type DeadlineRule struct {
ID uuid.UUID `db:"id" json:"id"`
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
Code *string `db:"code" json:"code,omitempty"`
Name string `db:"name" json:"name"`
NameEN string `db:"name_en" json:"name_en"`
Description *string `db:"description" json:"description,omitempty"`
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
EventType *string `db:"event_type" json:"event_type,omitempty"`
IsMandatory bool `db:"is_mandatory" json:"is_mandatory"`
DurationValue int `db:"duration_value" json:"duration_value"`
DurationUnit string `db:"duration_unit" json:"duration_unit"`
Timing *string `db:"timing" json:"timing,omitempty"`
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"`
SequenceOrder int `db:"sequence_order" json:"sequence_order"`
ConditionRuleID *uuid.UUID `db:"condition_rule_id" json:"condition_rule_id,omitempty"`
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`
IsActive bool `db:"is_active" json:"is_active"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ID uuid.UUID `db:"id" json:"id"`
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
Code *string `db:"code" json:"code,omitempty"`
Name string `db:"name" json:"name"`
NameEN string `db:"name_en" json:"name_en"`
Description *string `db:"description" json:"description,omitempty"`
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
EventType *string `db:"event_type" json:"event_type,omitempty"`
IsMandatory bool `db:"is_mandatory" json:"is_mandatory"`
DurationValue int `db:"duration_value" json:"duration_value"`
DurationUnit string `db:"duration_unit" json:"duration_unit"`
Timing *string `db:"timing" json:"timing,omitempty"`
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"`
SequenceOrder int `db:"sequence_order" json:"sequence_order"`
ConditionRuleID *uuid.UUID `db:"condition_rule_id" json:"condition_rule_id,omitempty"`
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`
IsActive bool `db:"is_active" json:"is_active"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// ProceedingType is one of INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL (matter

View File

@@ -0,0 +1,195 @@
// Package services — Phase H: AI-powered deadline extraction.
//
// AIService wraps the Anthropic SDK to extract deadlines from uploaded court
// documents (PDFs). The service is optional: if ANTHROPIC_API_KEY is unset at
// startup, NewAIService returns nil and the handlers respond with 501.
//
// Design (from docs/design-kanzlai-integration.md §8 Phase H):
// - Claude Sonnet for cost/quality balance; PDF sent directly as a document
// content block (no preprocessing needed).
// - Tool-forced structured output: a single `extract_deadlines` tool with a
// strict schema, with `ToolChoice` pinned to it so the response is always
// parseable JSON.
// - System prompt is cached (ephemeral, 5m TTL) so repeat extractions in
// the same session hit the cache and pay ~1/10 the prompt-token cost.
package services
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
)
// ErrAIDisabled is returned when the service is called but ANTHROPIC_API_KEY
// was unset at startup. Handlers should map this to 501 Not Implemented.
var ErrAIDisabled = errors.New("AI extraction not configured")
// AIService performs Claude-backed deadline extraction on document uploads.
// A nil pointer is a valid "disabled" state — the handlers check for it.
type AIService struct {
client anthropic.Client
model anthropic.Model
}
// NewAIService constructs the service. Returns nil if apiKey is empty so the
// caller can store the nil and let handlers return 501.
func NewAIService(apiKey string) *AIService {
if apiKey == "" {
return nil
}
client := anthropic.NewClient(option.WithAPIKey(apiKey))
return &AIService{
client: client,
// Sonnet 4.6 — cheapest capable model for this task. Sonnet handles
// legal German + English well and is materially cheaper than Opus.
model: anthropic.ModelClaudeSonnet4_6,
}
}
// ExtractedDeadline is one item returned by Claude for the user to review
// before persisting. Dates are YYYY-MM-DD strings (may be empty when the
// document only gives a duration, not an absolute date).
type ExtractedDeadline struct {
Title string `json:"title"`
DueDate string `json:"due_date"`
RuleCode string `json:"rule_code"`
Confidence float64 `json:"confidence"`
SourceQuote string `json:"source_quote"`
}
type extractDeadlinesToolInput struct {
Deadlines []ExtractedDeadline `json:"deadlines"`
}
// extractDeadlinesTool is the tool schema Claude must call. Using forced
// tool use guarantees structured JSON output without regex parsing.
var extractDeadlinesTool = anthropic.ToolParam{
Name: "extract_deadlines",
Description: anthropic.String("Record every legal deadline, hearing date, or filing obligation identified in the document."),
InputSchema: anthropic.ToolInputSchemaParam{
Properties: map[string]any{
"deadlines": map[string]any{
"type": "array",
"description": "List of extracted deadlines, hearings, or filing obligations actionable by a patent lawyer.",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"title": map[string]any{
"type": "string",
"description": "Short actionable label (e.g. 'Statement of Defence', 'Oral hearing', 'Reply to Counterclaim').",
},
"due_date": map[string]any{
"type": "string",
"description": "Absolute due date in YYYY-MM-DD format if determinable from the document. Empty string if the document only gives a duration without a trigger date.",
},
"rule_code": map[string]any{
"type": "string",
"description": "Legal rule reference if identifiable (e.g. 'Rule 23 RoP', 'Rule 222 RoP', '§ 276 ZPO'). Empty string if none.",
},
"confidence": map[string]any{
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Confidence score 0.0 to 1.0. Only include items with confidence above 0.4.",
},
"source_quote": map[string]any{
"type": "string",
"description": "Exact quote from the document where the deadline was identified, up to ~280 characters.",
},
},
"required": []string{"title", "due_date", "rule_code", "confidence", "source_quote"},
},
},
},
Required: []string{"deadlines"},
},
}
// extractionSystemPrompt is cached on the server side (ephemeral, 5m). The
// content is intentionally stable so repeat extractions hit the cache.
const extractionSystemPrompt = `You are a patent-law deadline extractor for German and UPC (Unified Patent Court) litigation.
Your task: identify every deadline, hearing date, or filing obligation in the provided document that is actionable by a patent lawyer.
For each item, return:
- A short actionable title (e.g. "Statement of Defence", "Oral hearing", "Reply to Counterclaim").
- The absolute due date in YYYY-MM-DD format when determinable. If the document only gives a duration ("within 2 months of service") without a concrete trigger date, return an empty string.
- The legal rule reference if identifiable (e.g. "Rule 23 RoP", "§ 276 ZPO"). Empty if none.
- A confidence score 0.01.0. Only include items with confidence above 0.4.
- The exact source quote from the document (up to ~280 characters) where the deadline appears.
Rules:
1. Only report items a patent lawyer would actually put in the Fristenkalender. Skip generic references like "the court may schedule a hearing in due course" unless a specific date or window is given.
2. Do not invent dates. If the document says "within 3 months" but no trigger date is visible, leave due_date empty.
3. Cite the original passage verbatim. Do not paraphrase the source_quote.
4. Return bilingual documents correctly (UPC documents often mix DE/EN).
5. If the document contains no deadlines, return an empty array.
Always call the extract_deadlines tool.`
// ExtractDeadlines sends the PDF bytes to Claude and returns the parsed
// deadline candidates. Caller handles user review + persistence.
//
// The pdf must be a valid PDF byte slice. A nil/empty slice is a programming
// error (handlers validate the magic number before calling this).
func (s *AIService) ExtractDeadlines(ctx context.Context, pdfData []byte) ([]ExtractedDeadline, error) {
if s == nil {
return nil, ErrAIDisabled
}
if len(pdfData) == 0 {
return nil, fmt.Errorf("empty pdf data")
}
encoded := base64.StdEncoding.EncodeToString(pdfData)
userContent := []anthropic.ContentBlockParamUnion{
{OfDocument: &anthropic.DocumentBlockParam{
Source: anthropic.DocumentBlockParamSourceUnion{
OfBase64: &anthropic.Base64PDFSourceParam{
Data: encoded,
},
},
}},
anthropic.NewTextBlock("Extract every patent-law-relevant deadline, hearing, or filing obligation from this document. Call the extract_deadlines tool."),
}
msg, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{
Model: s.model,
MaxTokens: 4096,
System: []anthropic.TextBlockParam{
{
Text: extractionSystemPrompt,
CacheControl: anthropic.NewCacheControlEphemeralParam(),
},
},
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(userContent...),
},
Tools: []anthropic.ToolUnionParam{
{OfTool: &extractDeadlinesTool},
},
ToolChoice: anthropic.ToolChoiceParamOfTool("extract_deadlines"),
})
if err != nil {
return nil, fmt.Errorf("claude API call: %w", err)
}
for _, block := range msg.Content {
if block.Type == "tool_use" && block.Name == "extract_deadlines" {
var input extractDeadlinesToolInput
if err := json.Unmarshal(block.Input, &input); err != nil {
return nil, fmt.Errorf("parsing tool output: %w", err)
}
if input.Deadlines == nil {
return []ExtractedDeadline{}, nil
}
return input.Deadlines, nil
}
}
return nil, fmt.Errorf("claude response had no tool_use block")
}

View File

@@ -0,0 +1,381 @@
// Package services — Phase H: document upload / listing / extraction.
//
// DokumentService persists PDFs uploaded against an Akte. Visibility is
// inherited from the parent Akte through AkteService.GetByID (same pattern
// FristService and ParteienService use), so every read/write re-checks the
// office-scoped visibility predicate.
//
// Blobs live in Supabase Storage bucket `paliad-documents`, path
// `{akte_id}/{document_id}.pdf`. Metadata lives in paliad.dokumente.
//
// AI extraction flow:
//
// UploadPDF -> returns Dokument row (no extraction yet)
// ExtractDeadlines -> downloads blob, calls AIService, caches result
// in dokumente.ai_extracted, bumps count.
// PersistExtractedFristen-> creates Fristen from confirmed items, links them
// back via source_document_id.
package services
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/patholo/internal/models"
)
// DocumentBucket is the Supabase Storage bucket holding Paliad document blobs.
const DocumentBucket = "paliad-documents"
// MaxUploadBytes caps individual uploads at 20 MiB (spec §Phase H).
const MaxUploadBytes = 20 * 1024 * 1024
// pdfMagic is the first four bytes of any valid PDF: `%PDF`.
var pdfMagic = []byte{0x25, 0x50, 0x44, 0x46}
// DokumentService owns CRUD on paliad.dokumente plus the extraction flow.
// StorageClient and AIService may be nil if their env vars are unset at
// startup — the service returns ErrStorageDisabled / ErrAIDisabled and the
// handler layer maps those to 501 with a friendly message.
type DokumentService struct {
db *sqlx.DB
storage *StorageClient
ai *AIService
akten *AkteService
frist *FristService
}
// NewDokumentService wires the service to its dependencies. storage and ai
// may be nil (disabled).
func NewDokumentService(db *sqlx.DB, storage *StorageClient, ai *AIService, akten *AkteService, frist *FristService) *DokumentService {
return &DokumentService{db: db, storage: storage, ai: ai, akten: akten, frist: frist}
}
const dokumentColumns = `id, akte_id, title, doc_type, file_path, file_size, mime_type,
ai_extracted, ai_extraction_count, ai_extracted_at, uploaded_by,
created_at, updated_at`
// UploadEnabled reports whether document upload is configured (storage present).
func (s *DokumentService) UploadEnabled() bool { return s.storage != nil }
// ExtractionEnabled reports whether AI extraction is configured (AI key present).
func (s *DokumentService) ExtractionEnabled() bool { return s.ai != nil }
// UploadPDF validates the incoming file and writes it to storage + metadata.
// The reader is consumed; callers typically pass r.MultipartReader part.
func (s *DokumentService) UploadPDF(ctx context.Context, userID, akteID uuid.UUID, filename string, data io.Reader) (*models.Dokument, error) {
if s.storage == nil {
return nil, ErrStorageDisabled
}
// Visibility check first — never reveal whether an Akte exists.
if _, err := s.akten.GetByID(ctx, userID, akteID); err != nil {
return nil, err
}
filename = strings.TrimSpace(filename)
if filename == "" {
return nil, fmt.Errorf("%w: filename required", ErrInvalidInput)
}
// Buffer the file so we can (a) check magic number, (b) check size, (c)
// stream it to storage. 20 MiB is small enough to hold in memory on the
// server; we reject anything larger before buffering.
var buf bytes.Buffer
limited := io.LimitReader(data, MaxUploadBytes+1)
n, err := io.Copy(&buf, limited)
if err != nil {
return nil, fmt.Errorf("reading upload: %w", err)
}
if n > MaxUploadBytes {
return nil, fmt.Errorf("%w: file exceeds 20 MB limit", ErrInvalidInput)
}
if n == 0 {
return nil, fmt.Errorf("%w: empty file", ErrInvalidInput)
}
if !bytes.HasPrefix(buf.Bytes(), pdfMagic) {
return nil, fmt.Errorf("%w: only PDF files accepted", ErrInvalidInput)
}
id := uuid.New()
storagePath := fmt.Sprintf("%s/%s.pdf", akteID, id)
if err := s.storage.Upload(ctx, DocumentBucket, storagePath, "application/pdf", bytes.NewReader(buf.Bytes())); err != nil {
return nil, fmt.Errorf("upload to storage: %w", err)
}
now := time.Now().UTC()
size := n
mime := "application/pdf"
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
// best effort: remove orphan blob
_ = s.storage.Delete(ctx, DocumentBucket, []string{storagePath})
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.dokumente
(id, akte_id, title, file_path, file_size, mime_type,
uploaded_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)`,
id, akteID, filename, storagePath, size, mime, userID, now,
); err != nil {
_ = s.storage.Delete(ctx, DocumentBucket, []string{storagePath})
return nil, fmt.Errorf("insert dokument: %w", err)
}
desc := fmt.Sprintf("Dokument \u201E%s\u201C hochgeladen", filename)
descPtr := &desc
if err := insertAkteEvent(ctx, tx, akteID, userID, "document_uploaded", "Dokument hochgeladen", descPtr); err != nil {
_ = s.storage.Delete(ctx, DocumentBucket, []string{storagePath})
return nil, err
}
if err := tx.Commit(); err != nil {
_ = s.storage.Delete(ctx, DocumentBucket, []string{storagePath})
return nil, fmt.Errorf("commit upload: %w", err)
}
return s.get(ctx, id)
}
// ListForAkte returns the documents on an Akte, newest first. Visibility
// is checked once via AkteService.
func (s *DokumentService) ListForAkte(ctx context.Context, userID, akteID uuid.UUID) ([]models.Dokument, error) {
if _, err := s.akten.GetByID(ctx, userID, akteID); err != nil {
return nil, err
}
var rows []models.Dokument
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+dokumentColumns+`
FROM paliad.dokumente
WHERE akte_id = $1
ORDER BY created_at DESC`, akteID); err != nil {
return nil, fmt.Errorf("list dokumente: %w", err)
}
return rows, nil
}
// GetByID fetches a single document, visibility-checked via its parent Akte.
func (s *DokumentService) GetByID(ctx context.Context, userID, docID uuid.UUID) (*models.Dokument, error) {
d, err := s.get(ctx, docID)
if err != nil {
return nil, err
}
if _, err := s.akten.GetByID(ctx, userID, d.AkteID); err != nil {
return nil, err
}
return d, nil
}
// Download returns a reader streaming the stored blob, plus its content type
// and filename. Caller must close the reader.
func (s *DokumentService) Download(ctx context.Context, userID, docID uuid.UUID) (io.ReadCloser, string, string, error) {
if s.storage == nil {
return nil, "", "", ErrStorageDisabled
}
d, err := s.GetByID(ctx, userID, docID)
if err != nil {
return nil, "", "", err
}
if d.FilePath == nil {
return nil, "", "", fmt.Errorf("document has no stored file")
}
body, contentType, err := s.storage.Download(ctx, DocumentBucket, *d.FilePath)
if err != nil {
return nil, "", "", err
}
if d.MimeType != nil && *d.MimeType != "" {
contentType = *d.MimeType
}
return body, contentType, d.Title, nil
}
// ExtractDeadlines downloads the stored PDF, sends it to Claude, caches the
// result in ai_extracted, and returns the extracted items (not yet persisted
// as Fristen — user must confirm via PersistExtractedFristen).
func (s *DokumentService) ExtractDeadlines(ctx context.Context, userID, docID uuid.UUID) ([]ExtractedDeadline, error) {
if s.ai == nil {
return nil, ErrAIDisabled
}
if s.storage == nil {
return nil, ErrStorageDisabled
}
d, err := s.GetByID(ctx, userID, docID)
if err != nil {
return nil, err
}
if d.FilePath == nil {
return nil, fmt.Errorf("document has no stored file")
}
body, _, err := s.storage.Download(ctx, DocumentBucket, *d.FilePath)
if err != nil {
return nil, err
}
defer body.Close()
pdfBytes, err := io.ReadAll(io.LimitReader(body, MaxUploadBytes+1))
if err != nil {
return nil, fmt.Errorf("reading blob: %w", err)
}
if len(pdfBytes) > MaxUploadBytes {
return nil, fmt.Errorf("stored file exceeds size cap")
}
items, err := s.ai.ExtractDeadlines(ctx, pdfBytes)
if err != nil {
return nil, err
}
// Cache the raw extraction payload on the document so the UI can show
// "last extracted at" and re-open the review screen without hitting
// Claude again.
payload, _ := json.Marshal(items)
now := time.Now().UTC()
_, _ = s.db.ExecContext(ctx,
`UPDATE paliad.dokumente
SET ai_extracted = $1,
ai_extraction_count = ai_extraction_count + 1,
ai_extracted_at = $2,
updated_at = $2
WHERE id = $3`, payload, now, docID)
return items, nil
}
// PersistFristenInput is one row in the from-extraction request body.
type PersistFristenInput struct {
Title string `json:"title"`
DueDate string `json:"due_date"` // YYYY-MM-DD
RuleCode *string `json:"rule_code,omitempty"`
SourceQuote *string `json:"source_quote,omitempty"`
}
// PersistExtractedFristen creates Fristen from AI-extracted items that the
// user confirmed. Each new Frist points back at the source document via
// source_document_id, and the rule_code (if supplied) is matched against
// paliad.deadline_rules by code for the rule_id link.
func (s *DokumentService) PersistExtractedFristen(ctx context.Context, userID, akteID, docID uuid.UUID, items []PersistFristenInput) ([]models.Frist, error) {
if len(items) == 0 {
return nil, fmt.Errorf("%w: at least one item required", ErrInvalidInput)
}
// Visibility gate + confirm the document really belongs to this Akte.
if _, err := s.akten.GetByID(ctx, userID, akteID); err != nil {
return nil, err
}
d, err := s.GetByID(ctx, userID, docID)
if err != nil {
return nil, err
}
if d.AkteID != akteID {
return nil, fmt.Errorf("%w: document does not belong to this Akte", ErrInvalidInput)
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
ids := make([]uuid.UUID, 0, len(items))
for _, in := range items {
title := strings.TrimSpace(in.Title)
if title == "" {
return nil, fmt.Errorf("%w: title required", ErrInvalidInput)
}
due, err := time.Parse("2006-01-02", in.DueDate)
if err != nil {
return nil, fmt.Errorf("%w: due_date must be YYYY-MM-DD", ErrInvalidInput)
}
var ruleID *uuid.UUID
if in.RuleCode != nil && strings.TrimSpace(*in.RuleCode) != "" {
var id uuid.UUID
err := tx.GetContext(ctx, &id,
`SELECT id FROM paliad.deadline_rules WHERE code = $1 LIMIT 1`,
strings.TrimSpace(*in.RuleCode))
if err == nil {
ruleID = &id
}
// If no match, leave rule_id null — rule_code still on notes.
}
notes := buildExtractionNotes(in)
id := uuid.New()
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.fristen
(id, akte_id, title, due_date, source, rule_id, status,
notes, source_document_id, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, 'ai_extracted', $5, 'pending',
$6, $7, $8, $9, $9)`,
id, akteID, title, due, ruleID, notes, docID, userID, now,
); err != nil {
return nil, fmt.Errorf("insert frist: %w", err)
}
ids = append(ids, id)
}
desc := fmt.Sprintf("%d Fristen aus Dokument \u201E%s\u201C übernommen", len(items), d.Title)
descPtr := &desc
if err := insertAkteEvent(ctx, tx, akteID, userID, "fristen_from_document", "Fristen aus Dokument", descPtr); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit extraction: %w", err)
}
out := make([]models.Frist, 0, len(ids))
for _, id := range ids {
f, err := s.frist.GetByID(ctx, userID, id)
if err != nil {
return nil, err
}
out = append(out, *f)
}
return out, nil
}
// buildExtractionNotes bundles rule_code + source_quote into the Frist's
// notes column so the information the user accepted stays searchable even
// if the source document is later deleted.
func buildExtractionNotes(in PersistFristenInput) *string {
parts := []string{}
if in.RuleCode != nil && strings.TrimSpace(*in.RuleCode) != "" {
parts = append(parts, fmt.Sprintf("Regel: %s", strings.TrimSpace(*in.RuleCode)))
}
if in.SourceQuote != nil && strings.TrimSpace(*in.SourceQuote) != "" {
parts = append(parts, fmt.Sprintf("Quelle: \u201E%s\u201C", strings.TrimSpace(*in.SourceQuote)))
}
if len(parts) == 0 {
return nil
}
s := strings.Join(parts, "\n")
return &s
}
// get fetches without visibility check (caller must verify via GetByID).
func (s *DokumentService) get(ctx context.Context, id uuid.UUID) (*models.Dokument, error) {
var d models.Dokument
err := s.db.GetContext(ctx, &d,
`SELECT `+dokumentColumns+` FROM paliad.dokumente WHERE id = $1`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible
}
if err != nil {
return nil, fmt.Errorf("get dokument: %w", err)
}
return &d, nil
}

View File

@@ -0,0 +1,131 @@
// Package services — Supabase Storage client for Paliad document uploads.
//
// Paliad uses Supabase Storage (youpc instance) for the PDF blobs backing
// paliad.dokumente rows. Writes require the service-role key; the anon key
// used for auth is not enough. If SUPABASE_SERVICE_KEY is unset at startup,
// NewStorageClient returns nil and handlers respond with 501.
package services
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
)
// ErrStorageDisabled is returned when handlers invoke a storage-backed
// operation but SUPABASE_SERVICE_KEY was unset at startup.
var ErrStorageDisabled = errors.New("document storage not configured")
// StorageClient is a thin wrapper around the Supabase Storage REST API.
// A nil pointer is a valid "disabled" state; callers check for it.
type StorageClient struct {
baseURL string
serviceKey string
httpClient *http.Client
}
// NewStorageClient returns a ready-to-use client, or nil if either baseURL
// or serviceKey is empty. Handlers treat nil as "storage not configured".
func NewStorageClient(baseURL, serviceKey string) *StorageClient {
if baseURL == "" || serviceKey == "" {
return nil
}
return &StorageClient{
baseURL: baseURL,
serviceKey: serviceKey,
httpClient: &http.Client{Timeout: 60 * time.Second},
}
}
// Upload writes a single object to the given bucket/path. Existing objects
// at the path are replaced (x-upsert: true). contentType is required.
func (s *StorageClient) Upload(ctx context.Context, bucket, path, contentType string, data io.Reader) error {
if s == nil {
return ErrStorageDisabled
}
url := fmt.Sprintf("%s/storage/v1/object/%s/%s", s.baseURL, bucket, path)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, data)
if err != nil {
return fmt.Errorf("creating upload request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+s.serviceKey)
req.Header.Set("Content-Type", contentType)
req.Header.Set("x-upsert", "true")
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("uploading to storage: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("storage upload failed (status %d): %s", resp.StatusCode, string(body))
}
return nil
}
// Download fetches an object. The caller must close the returned ReadCloser.
func (s *StorageClient) Download(ctx context.Context, bucket, path string) (io.ReadCloser, string, error) {
if s == nil {
return nil, "", ErrStorageDisabled
}
url := fmt.Sprintf("%s/storage/v1/object/%s/%s", s.baseURL, bucket, path)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", fmt.Errorf("creating download request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+s.serviceKey)
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("downloading from storage: %w", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, "", fmt.Errorf("storage object not found")
}
body, _ := io.ReadAll(resp.Body)
return nil, "", fmt.Errorf("storage download failed (status %d): %s", resp.StatusCode, string(body))
}
return resp.Body, resp.Header.Get("Content-Type"), nil
}
// Delete removes the given paths from a bucket (Supabase accepts an array).
func (s *StorageClient) Delete(ctx context.Context, bucket string, paths []string) error {
if s == nil {
return ErrStorageDisabled
}
url := fmt.Sprintf("%s/storage/v1/object/%s", s.baseURL, bucket)
body, err := json.Marshal(map[string][]string{"prefixes": paths})
if err != nil {
return fmt.Errorf("marshaling delete request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("creating delete request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+s.serviceKey)
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("deleting from storage: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("storage delete failed (status %d): %s", resp.StatusCode, string(respBody))
}
return nil
}