FDN-01: repository & projektgerüst

Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
This commit is contained in:
2026-08-11 21:27:53 +02:00
parent 40ed80da71
commit 9a24ea29e1
274 changed files with 53708 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
// Package llm is a minimal HTTP client for an EXTERNAL, already-running Ollama
// server (never installed on the archivdms host — the base URL is provided per
// tenant, see internal/storage/ollama_config.go). It intentionally does no
// retrying, no connection pooling and no streaming: a single direct call to
// Ollama's /api/generate endpoint, CGO-free, net/http only.
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// generateRequest is the JSON body POSTed to <base_url>/api/generate. stream is
// always false (we want the whole answer at once) and format is "json" so the
// model is nudged to emit valid JSON in the response field.
type generateRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Stream bool `json:"stream"`
Format string `json:"format"`
}
// generateResponse is the (non-streaming) envelope Ollama returns; the actual
// model output is the Response string, which — because we requested
// format=json — is itself a JSON document the caller parses structurally.
type generateResponse struct {
Response string `json:"response"`
Done bool `json:"done"`
Error string `json:"error"`
}
// tagsResponse is the JSON envelope Ollama returns from GET /api/tags: a list
// of the models installed on that server. Only the name is consumed here.
type tagsResponse struct {
Models []struct {
Name string `json:"name"`
} `json:"models"`
}
// ListModels performs a single blocking GET /api/tags call against the given
// Ollama base URL and returns the names of the models installed on that server,
// so the frontend can offer a picklist instead of a free-text model field. Any
// network error, timeout or non-200 status yields a clear error — there is NO
// silent fallback. The returned slice is always non-nil (make, never nil) so it
// JSON-encodes as [] rather than null.
func ListModels(ctx context.Context, baseURL string, timeout time.Duration) ([]string, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
return nil, fmt.Errorf("llm: ollama base_url is empty")
}
if timeout <= 0 {
timeout = 10 * time.Second
}
reqCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, baseURL+"/api/tags", nil)
if err != nil {
return nil, fmt.Errorf("llm: build tags request: %w", err)
}
client := &http.Client{Timeout: timeout}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("llm: ollama tags request failed: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, fmt.Errorf("llm: read ollama tags response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("llm: ollama returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var env tagsResponse
if err := json.Unmarshal(raw, &env); err != nil {
return nil, fmt.Errorf("llm: parse ollama tags envelope: %w", err)
}
out := make([]string, 0, len(env.Models))
for _, m := range env.Models {
if name := strings.TrimSpace(m.Name); name != "" {
out = append(out, name)
}
}
return out, nil
}
// GenerateJSON performs a single blocking /api/generate call against the given
// Ollama base URL and returns the model's inner `response` field as a
// json.RawMessage (the caller unmarshals it into its own schema). Any network
// error, timeout, non-200 status, Ollama-reported error or empty/invalid outer
// response yields a clear error — there is NO silent fallback, so the caller
// can report to the frontend exactly that Ollama did not answer.
func GenerateJSON(ctx context.Context, baseURL, model string, timeout time.Duration, prompt string) (json.RawMessage, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
return nil, fmt.Errorf("llm: ollama base_url is empty")
}
if model == "" {
return nil, fmt.Errorf("llm: ollama model is empty")
}
if timeout <= 0 {
timeout = 30 * time.Second
}
body, err := json.Marshal(generateRequest{
Model: model,
Prompt: prompt,
Stream: false,
Format: "json",
})
if err != nil {
return nil, fmt.Errorf("llm: marshal request: %w", err)
}
reqCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, baseURL+"/api/generate", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("llm: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: timeout}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("llm: ollama request failed: %w", err)
}
defer resp.Body.Close()
// Cap the read so a misbehaving/unexpected endpoint cannot exhaust memory.
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, fmt.Errorf("llm: read ollama response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("llm: ollama returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var env generateResponse
if err := json.Unmarshal(raw, &env); err != nil {
return nil, fmt.Errorf("llm: parse ollama envelope: %w", err)
}
if env.Error != "" {
return nil, fmt.Errorf("llm: ollama error: %s", env.Error)
}
inner := strings.TrimSpace(env.Response)
if inner == "" {
return nil, fmt.Errorf("llm: ollama returned an empty response")
}
if !json.Valid([]byte(inner)) {
return nil, fmt.Errorf("llm: ollama response field is not valid JSON")
}
return json.RawMessage(inner), nil
}