Files
2026-08-11 10:40:10 +01:00

265 lines
7.4 KiB
Go

// Package analysis performs model-assisted structured tasks: entity extraction,
// claim extraction, relationship classification and answer generation.
//
// Two rules govern everything here:
//
// 1. Stored document content is data, never instruction. Retrieved text is
// delimited, labelled untrusted, and the system prompt states that any
// instructions inside it must be ignored and reported.
// 2. Model output is untrusted input. Responses are decoded against a strict
// schema with unknown fields rejected, enum values checked, and referenced
// IDs verified against what was actually supplied. Invalid output is
// recorded and discarded — never repaired with regular expressions.
package analysis
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/embeddings"
"github.com/andydixon/relata/internal/observability"
)
// OllamaOptions configure the Ollama chat adapter.
type OllamaOptions struct {
BaseURL string
Model string
Timeout time.Duration
Retry embeddings.RetryPolicy
MaxTokens int
HTTPClient *http.Client
Metrics *observability.Metrics
}
// OllamaAnalyser implements domain.Analyser against an Ollama server.
type OllamaAnalyser struct {
base string
model string
retry embeddings.RetryPolicy
maxTokens int
client *http.Client
metrics *observability.Metrics
}
var _ domain.Analyser = (*OllamaAnalyser)(nil)
// NewOllamaAnalyser builds an Ollama-backed analyser.
func NewOllamaAnalyser(o OllamaOptions) *OllamaAnalyser {
if o.Timeout <= 0 {
o.Timeout = 120 * time.Second
}
if o.MaxTokens <= 0 {
o.MaxTokens = 2048
}
client := o.HTTPClient
if client == nil {
client = &http.Client{
Timeout: o.Timeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
return &OllamaAnalyser{
base: strings.TrimRight(o.BaseURL, "/"),
model: o.Model,
retry: o.Retry,
maxTokens: o.MaxTokens,
client: client,
metrics: o.Metrics,
}
}
// ModelID returns the configured model name.
func (a *OllamaAnalyser) ModelID() string { return a.model }
type ollamaChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type ollamaChatRequest struct {
Model string `json:"model"`
Messages []ollamaChatMessage `json:"messages"`
Stream bool `json:"stream"`
// Format carries a JSON schema when structured output is requested.
Format json.RawMessage `json:"format,omitempty"`
Options ollamaOptions `json:"options"`
}
type ollamaOptions struct {
Temperature float64 `json:"temperature"`
NumPredict int `json:"num_predict"`
}
type ollamaChatResponse struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
Model string `json:"model"`
PromptEvalCount int `json:"prompt_eval_count"`
EvalCount int `json:"eval_count"`
Done bool `json:"done"`
}
// maxResponseBytes caps a model response. Output size is bounded both by
// num_predict and here, because a provider is not trusted to honour either.
const maxResponseBytes = 1 << 20
// Complete sends a bounded chat request and returns the raw content.
//
// The content is not parsed here: callers decode it against a task-specific
// schema so that validation failures are attributed to the right task.
func (a *OllamaAnalyser) Complete(ctx context.Context, req domain.CompletionRequest) (*domain.CompletionResponse, error) {
if len(req.Messages) == 0 {
return nil, embeddings.Permanent(fmt.Errorf("completion request has no messages"))
}
maxTok := req.MaxTokens
if maxTok <= 0 || maxTok > a.maxTokens {
maxTok = a.maxTokens
}
msgs := make([]ollamaChatMessage, len(req.Messages))
for i, m := range req.Messages {
msgs[i] = ollamaChatMessage{Role: m.Role, Content: m.Content}
}
chatReq := ollamaChatRequest{
Model: a.model,
Messages: msgs,
Stream: false,
Options: ollamaOptions{Temperature: req.Temperature, NumPredict: maxTok},
}
if len(req.JSONSchema) > 0 {
if !json.Valid(req.JSONSchema) {
return nil, embeddings.Permanent(fmt.Errorf("supplied JSON schema is not valid JSON"))
}
chatReq.Format = json.RawMessage(req.JSONSchema)
}
body, err := json.Marshal(chatReq)
if err != nil {
return nil, embeddings.Permanent(fmt.Errorf("encode chat request: %w", err))
}
var out ollamaChatResponse
started := time.Now()
err = embeddings.Do(ctx, a.retry, func(ctx context.Context, attempt int) error {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.base+"/api/chat", bytes.NewReader(body))
if err != nil {
return embeddings.Permanent(err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := a.client.Do(httpReq)
if err != nil {
return err
}
defer func() {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
se := &embeddings.StatusError{
Status: resp.StatusCode,
Body: strings.TrimSpace(string(msg)),
Op: "ollama chat",
}
if resp.StatusCode == http.StatusNotFound {
return embeddings.Permanent(fmt.Errorf(
"%w — is the model pulled? try: ollama pull %s", se, a.model))
}
return se
}
var decoded ollamaChatResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBytes)).Decode(&decoded); err != nil {
return fmt.Errorf("decode chat response: %w", err)
}
out = decoded
return nil
})
outcome := "success"
if err != nil {
outcome = "error"
}
if a.metrics != nil {
a.metrics.AIRequests.WithLabelValues("ollama", "chat", outcome).Inc()
a.metrics.AIDuration.WithLabelValues("ollama", "chat").Observe(time.Since(started).Seconds())
}
if err != nil {
return nil, err
}
return &domain.CompletionResponse{
Content: out.Message.Content,
Model: firstNonEmpty(out.Model, a.model),
PromptTokens: out.PromptEvalCount,
OutputTokens: out.EvalCount,
Duration: time.Since(started),
}, nil
}
// Health reports whether the analysis model is reachable and installed.
func (a *OllamaAnalyser) Health(ctx context.Context) domain.ProviderHealth {
h := domain.ProviderHealth{Name: "ollama-analysis", Endpoint: a.base, Model: a.model}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.base+"/api/tags", nil)
if err != nil {
h.Detail = err.Error()
return h
}
resp, err := a.client.Do(req)
if err != nil {
h.Detail = "cannot reach Ollama: " + err.Error()
return h
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
h.Detail = fmt.Sprintf("Ollama returned HTTP %d", resp.StatusCode)
return h
}
var tags struct {
Models []struct {
Name string `json:"name"`
} `json:"models"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&tags); err != nil {
h.Detail = "unreadable model list: " + err.Error()
return h
}
for _, m := range tags.Models {
h.Models = append(h.Models, m.Name)
}
h.Available = true
for _, m := range h.Models {
if m == a.model || m == a.model+":latest" || strings.TrimSuffix(m, ":latest") == a.model {
return h
}
}
h.Available = false
h.Detail = fmt.Sprintf("model %q is not installed — run: ollama pull %s", a.model, a.model)
return h
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}