Update Ollama integration
release-tag / release-image (push) Successful in 1m32s

This commit is contained in:
2026-07-29 09:43:02 +02:00
parent afa519b72a
commit a33ff09c41
16 changed files with 1238 additions and 31 deletions
+225
View File
@@ -0,0 +1,225 @@
package staging
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
var keyPattern = regexp.MustCompile(`^KB-AI-STAGING-[0-9]{8}-[0-9]{6}-[A-F0-9]{8}$`)
type Draft struct {
Title string `json:"title"`
Text string `json:"text"`
Answer string `json:"answer"`
Categories []string `json:"categories"`
Keywords []string `json:"keywords"`
}
type Result struct {
Key string `json:"key"`
Document map[string]any `json:"document"`
Meta map[string]any `json:"meta"`
}
type Store struct {
dir string
}
func New(dir string) (*Store, error) {
if strings.TrimSpace(dir) == "" {
return nil, errors.New("staging directory is empty")
}
abs, err := filepath.Abs(dir)
if err != nil {
return nil, err
}
if err := os.MkdirAll(abs, 0o755); err != nil {
return nil, fmt.Errorf("create staging directory: %w", err)
}
return &Store{dir: abs}, nil
}
func (s *Store) Dir() string { return s.dir }
func (s *Store) Save(query, model string, draft Draft, autoReply bool, minScore float64) (Result, error) {
draft.Title = clampString(draft.Title, 320)
draft.Text = clampString(draft.Text, 16000)
draft.Answer = clampString(draft.Answer, 32000)
draft.Categories = clampStrings(draft.Categories, 16, 120)
draft.Keywords = clampStrings(draft.Keywords, 48, 120)
if draft.Title == "" || draft.Answer == "" {
return Result{}, errors.New("AI draft is missing title or answer")
}
if minScore < 0 || minScore > 1 {
minScore = 0.78
}
now := time.Now().UTC()
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(query)) + "\x00" + now.Format(time.RFC3339Nano)))
id := fmt.Sprintf("KB-AI-STAGING-%s-%s-%s", now.Format("20060102"), now.Format("150405"), strings.ToUpper(hex.EncodeToString(sum[:4])))
filename := id + ".json"
path := filepath.Join(s.dir, filename)
categories := uniqueStrings(append([]string{"AI-Staging"}, draft.Categories...))
keywords := uniqueStrings(draft.Keywords)
for _, token := range extractUsefulQueryTokens(query) {
keywords = uniqueStrings(append(keywords, token))
}
doc := map[string]any{
"id": id,
"title": draft.Title,
"text": draft.Text,
"answer": draft.Answer,
"auto_reply": autoReply,
"min_score": minScore,
"categories": categories,
"keywords": keywords,
"source": fmt.Sprintf("Ollama / %s (AI-Staging)", strings.TrimSpace(model)),
"source_uri": "",
"language": "de-DE",
"communication_style": "formal",
}
payload, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return Result{}, err
}
payload = append(payload, '\n')
tmp, err := os.CreateTemp(s.dir, ".staging-*.tmp")
if err != nil {
return Result{}, fmt.Errorf("create staging temp file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if err := tmp.Chmod(0o644); err != nil {
tmp.Close()
return Result{}, err
}
if _, err := tmp.Write(payload); err != nil {
tmp.Close()
return Result{}, err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return Result{}, err
}
if err := tmp.Close(); err != nil {
return Result{}, err
}
if _, err := os.Stat(path); err == nil {
return Result{}, fmt.Errorf("staging target already exists: %s", filename)
} else if !errors.Is(err, os.ErrNotExist) {
return Result{}, err
}
if err := os.Rename(tmpName, path); err != nil {
return Result{}, fmt.Errorf("commit staging file: %w", err)
}
return Result{
Key: id,
Document: doc,
Meta: map[string]any{
"rel_path": filepath.ToSlash(filepath.Join("staging", filename)),
"staging": true,
"generated_at": now.Format(time.RFC3339),
},
}, nil
}
func (s *Store) Get(key string) (Result, error) {
key = strings.TrimSpace(key)
if !keyPattern.MatchString(key) {
return Result{}, os.ErrNotExist
}
filename := key + ".json"
path := filepath.Join(s.dir, filename)
b, err := os.ReadFile(path)
if err != nil {
return Result{}, err
}
var doc map[string]any
if err := json.Unmarshal(b, &doc); err != nil {
return Result{}, fmt.Errorf("invalid staging JSON: %w", err)
}
return Result{
Key: key,
Document: doc,
Meta: map[string]any{
"rel_path": filepath.ToSlash(filepath.Join("staging", filename)),
"staging": true,
},
}, nil
}
func uniqueStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
key := strings.ToLower(value)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, value)
}
if out == nil {
return []string{}
}
return out
}
func clampString(value string, maxRunes int) string {
value = strings.TrimSpace(value)
runes := []rune(value)
if len(runes) <= maxRunes {
return value
}
return strings.TrimSpace(string(runes[:maxRunes]))
}
func clampStrings(values []string, maxItems, maxRunes int) []string {
out := make([]string, 0, min(len(values), maxItems))
for _, value := range values {
value = clampString(value, maxRunes)
if value == "" {
continue
}
out = append(out, value)
if len(out) >= maxItems {
break
}
}
return uniqueStrings(out)
}
func extractUsefulQueryTokens(query string) []string {
fields := strings.Fields(query)
out := make([]string, 0, 6)
for _, field := range fields {
field = strings.Trim(field, `.,;:!?()[]{}"'`)
if len(field) < 3 {
continue
}
if strings.HasPrefix(strings.ToLower(field), "0x") || len(field) >= 5 {
out = append(out, field)
}
if len(out) >= 6 {
break
}
}
return uniqueStrings(out)
}
+44
View File
@@ -0,0 +1,44 @@
package staging
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestSaveAndGet(t *testing.T) {
s, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
result, err := s.Save("0xDEADBEEF test", "test-model", Draft{
Title: "Testartikel", Text: "Symptom", Answer: "Lösung",
Categories: []string{"Windows"}, Keywords: []string{"Fehler"},
}, false, 0.78)
if err != nil {
t.Fatal(err)
}
if result.Key == "" || result.Document["auto_reply"] != false {
t.Fatalf("unexpected result: %+v", result)
}
path := filepath.Join(s.Dir(), result.Key+".json")
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var doc map[string]any
if err := json.Unmarshal(b, &doc); err != nil {
t.Fatal(err)
}
if doc["id"] != result.Key || doc["source"] == "" {
t.Fatalf("unexpected document: %+v", doc)
}
loaded, err := s.Get(result.Key)
if err != nil {
t.Fatal(err)
}
if loaded.Document["title"] != "Testartikel" {
t.Fatalf("loaded=%+v", loaded)
}
}