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
132 lines
4.3 KiB
Go
132 lines
4.3 KiB
Go
// 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
|
|
}
|