This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user