This commit is contained in:
+369
-52
@@ -1,6 +1,7 @@
|
||||
package staging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -9,11 +10,14 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var keyPattern = regexp.MustCompile(`^KB-AI-STAGING-[0-9]{8}-[0-9]{6}-[A-F0-9]{8}$`)
|
||||
var safeKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,179}$`)
|
||||
|
||||
type Draft struct {
|
||||
Title string `json:"title"`
|
||||
@@ -29,7 +33,44 @@ type Result struct {
|
||||
Meta map[string]any `json:"meta"`
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
Key string `json:"key"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
AutoReply *bool `json:"auto_reply,omitempty"`
|
||||
MinScore *float64 `json:"min_score,omitempty"`
|
||||
Language string `json:"language"`
|
||||
CommunicationStyle string `json:"communication_style"`
|
||||
Source string `json:"source"`
|
||||
Keywords []string `json:"keywords"`
|
||||
Categories []string `json:"categories"`
|
||||
RelPath string `json:"rel_path"`
|
||||
ModifiedAt string `json:"modified_at"`
|
||||
Size int64 `json:"size"`
|
||||
Checksum string `json:"checksum"`
|
||||
Staging bool `json:"staging"`
|
||||
}
|
||||
|
||||
type Query struct {
|
||||
Q string `json:"q"`
|
||||
AutoReply string `json:"auto_reply"`
|
||||
Language string `json:"language"`
|
||||
CommunicationStyle string `json:"communication_style"`
|
||||
Source string `json:"source"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
type ListResult struct {
|
||||
Items []Summary `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
dir string
|
||||
}
|
||||
|
||||
@@ -49,6 +90,20 @@ func New(dir string) (*Store, error) {
|
||||
|
||||
func (s *Store) Dir() string { return s.dir }
|
||||
|
||||
func (s *Store) Count() int {
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.EqualFold(filepath.Ext(entry.Name()), ".json") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -65,8 +120,6 @@ func (s *Store) Save(query, model string, draft Draft, autoReply bool, minScore
|
||||
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)
|
||||
@@ -88,79 +141,343 @@ func (s *Store) Save(query, model string, draft Draft, autoReply bool, minScore
|
||||
"language": "de-DE",
|
||||
"communication_style": "formal",
|
||||
}
|
||||
|
||||
payload, err := json.MarshalIndent(doc, "", " ")
|
||||
if err := s.writeNew(id, doc); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
result, err := s.Get(id)
|
||||
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
|
||||
result.Meta["generated_at"] = now.Format(time.RFC3339)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) Get(key string) (Result, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if !keyPattern.MatchString(key) {
|
||||
path, err := s.pathForKey(key)
|
||||
if err != nil {
|
||||
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 {
|
||||
dec := json.NewDecoder(bytes.NewReader(b))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&doc); err != nil {
|
||||
return Result{}, fmt.Errorf("invalid staging JSON: %w", err)
|
||||
}
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
return Result{
|
||||
Key: key,
|
||||
Document: doc,
|
||||
Meta: map[string]any{
|
||||
"rel_path": filepath.ToSlash(filepath.Join("staging", filename)),
|
||||
"staging": true,
|
||||
"rel_path": filepath.ToSlash(filepath.Join("staging", filepath.Base(path))),
|
||||
"staging": true,
|
||||
"modified_at": st.ModTime().Format(time.RFC3339),
|
||||
"size": st.Size(),
|
||||
"checksum": fmt.Sprintf("%x", sum[:8]),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) List(q Query) (ListResult, error) {
|
||||
if q.Page < 1 {
|
||||
q.Page = 1
|
||||
}
|
||||
if q.PageSize < 1 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
if q.PageSize > 500 {
|
||||
q.PageSize = 500
|
||||
}
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
return ListResult{}, err
|
||||
}
|
||||
items := make([]Summary, 0)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".json") {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name()))
|
||||
if !safeKeyPattern.MatchString(key) {
|
||||
continue
|
||||
}
|
||||
result, err := s.Get(key)
|
||||
if err != nil {
|
||||
return ListResult{}, fmt.Errorf("load staging %s: %w", entry.Name(), err)
|
||||
}
|
||||
summary := summarize(result)
|
||||
if matches(summary, result.Document, q) {
|
||||
items = append(items, summary)
|
||||
}
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].ModifiedAt != items[j].ModifiedAt {
|
||||
return items[i].ModifiedAt > items[j].ModifiedAt
|
||||
}
|
||||
return strings.ToLower(items[i].Title) < strings.ToLower(items[j].Title)
|
||||
})
|
||||
total := len(items)
|
||||
totalPages := 0
|
||||
if total > 0 {
|
||||
totalPages = (total + q.PageSize - 1) / q.PageSize
|
||||
if q.Page > totalPages {
|
||||
q.Page = totalPages
|
||||
}
|
||||
}
|
||||
start := (q.Page - 1) * q.PageSize
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + q.PageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
return ListResult{Items: items[start:end], Total: total, Page: q.Page, PageSize: q.PageSize, TotalPages: totalPages}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Update(key string, doc map[string]any) (Result, error) {
|
||||
if doc == nil {
|
||||
return Result{}, errors.New("JSON root must be an object")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path, err := s.pathForKey(key)
|
||||
if err != nil {
|
||||
return Result{}, os.ErrNotExist
|
||||
}
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
payload, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
if err := atomicWrite(path, payload, st.Mode().Perm()); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return s.Get(key)
|
||||
}
|
||||
|
||||
// Delete moves a staging file into .trash instead of irreversibly removing it.
|
||||
func (s *Store) Delete(key string) (string, error) {
|
||||
return s.archive(key, ".trash")
|
||||
}
|
||||
|
||||
// ArchiveApproved removes a reviewed item from active staging while keeping the original
|
||||
// draft for audit purposes below .approved.
|
||||
func (s *Store) ArchiveApproved(key string) (string, error) {
|
||||
return s.archive(key, ".approved")
|
||||
}
|
||||
|
||||
func (s *Store) archive(key, bucket string) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path, err := s.pathForKey(key)
|
||||
if err != nil {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
archiveDir := filepath.Join(s.dir, bucket)
|
||||
if err := os.MkdirAll(archiveDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := fmt.Sprintf("%s-%s.json", time.Now().UTC().Format("20060102-150405.000000000"), key)
|
||||
dst := filepath.Join(archiveDir, name)
|
||||
if err := os.Rename(path, dst); err != nil {
|
||||
return "", fmt.Errorf("move staging file to %s: %w", bucket, err)
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func (s *Store) writeNew(key string, doc map[string]any) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path, err := s.pathForKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return fmt.Errorf("staging target already exists: %s", filepath.Base(path))
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
payload, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
return atomicWrite(path, payload, 0o644)
|
||||
}
|
||||
|
||||
func (s *Store) pathForKey(key string) (string, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if !safeKeyPattern.MatchString(key) {
|
||||
return "", errors.New("invalid staging key")
|
||||
}
|
||||
return filepath.Join(s.dir, key+".json"), nil
|
||||
}
|
||||
|
||||
func atomicWrite(path string, payload []byte, mode os.FileMode) error {
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".staging-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if err := tmp.Chmod(mode); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := tmp.Write(payload); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func summarize(result Result) Summary {
|
||||
doc := result.Document
|
||||
meta := result.Meta
|
||||
var autoReply *bool
|
||||
if v, ok := doc["auto_reply"].(bool); ok {
|
||||
vv := v
|
||||
autoReply = &vv
|
||||
}
|
||||
var minScore *float64
|
||||
if v, ok := number(doc["min_score"]); ok {
|
||||
vv := v
|
||||
minScore = &vv
|
||||
}
|
||||
return Summary{
|
||||
Key: result.Key,
|
||||
ID: str(doc["id"]),
|
||||
Title: str(doc["title"]),
|
||||
AutoReply: autoReply,
|
||||
MinScore: minScore,
|
||||
Language: str(doc["language"]),
|
||||
CommunicationStyle: str(doc["communication_style"]),
|
||||
Source: str(doc["source"]),
|
||||
Keywords: toStrings(doc["keywords"]),
|
||||
Categories: toStrings(doc["categories"]),
|
||||
RelPath: str(meta["rel_path"]),
|
||||
ModifiedAt: str(meta["modified_at"]),
|
||||
Size: int64Number(meta["size"]),
|
||||
Checksum: str(meta["checksum"]),
|
||||
Staging: true,
|
||||
}
|
||||
}
|
||||
|
||||
func matches(summary Summary, doc map[string]any, q Query) bool {
|
||||
if text := strings.ToLower(strings.TrimSpace(q.Q)); text != "" {
|
||||
search := strings.ToLower(strings.Join([]string{
|
||||
summary.ID, summary.Title, str(doc["text"]), str(doc["answer"]), summary.Source,
|
||||
strings.Join(summary.Keywords, " "), strings.Join(summary.Categories, " "),
|
||||
}, "\n"))
|
||||
for _, term := range strings.Fields(text) {
|
||||
if !strings.Contains(search, term) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(q.AutoReply); v != "" && v != "any" {
|
||||
expected, err := strconv.ParseBool(v)
|
||||
if err != nil || summary.AutoReply == nil || *summary.AutoReply != expected {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if q.Language != "" && !strings.EqualFold(summary.Language, q.Language) {
|
||||
return false
|
||||
}
|
||||
if q.CommunicationStyle != "" && !strings.EqualFold(summary.CommunicationStyle, q.CommunicationStyle) {
|
||||
return false
|
||||
}
|
||||
if q.Source != "" && !strings.Contains(strings.ToLower(summary.Source), strings.ToLower(q.Source)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func str(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
|
||||
func number(v any) (float64, bool) {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x, true
|
||||
case float32:
|
||||
return float64(x), true
|
||||
case int:
|
||||
return float64(x), true
|
||||
case json.Number:
|
||||
f, err := x.Float64()
|
||||
return f, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func int64Number(v any) int64 {
|
||||
switch x := v.(type) {
|
||||
case int64:
|
||||
return x
|
||||
case int:
|
||||
return int64(x)
|
||||
case float64:
|
||||
return int64(x)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func toStrings(v any) []string {
|
||||
switch x := v.(type) {
|
||||
case []string:
|
||||
return append([]string(nil), x...)
|
||||
case []any:
|
||||
out := make([]string, 0, len(x))
|
||||
for _, item := range x {
|
||||
if value, ok := item.(string); ok {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return []string{}
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
|
||||
@@ -42,3 +42,43 @@ func TestSaveAndGet(t *testing.T) {
|
||||
t.Fatalf("loaded=%+v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUpdateAndSoftDelete(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
one, err := s.Save("0xFEEDFACE Netzwerk", "model", Draft{Title: "Netzwerk", Answer: "Prüfen"}, false, .78)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Save("anderes", "model", Draft{Title: "Drucker", Answer: "Prüfen"}, false, .78); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err := s.List(Query{Q: "FEEDFACE", Page: 1, PageSize: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if list.Total != 1 || list.Items[0].Key != one.Key {
|
||||
t.Fatalf("unexpected list: %+v", list)
|
||||
}
|
||||
one.Document["title"] = "Geprüftes Netzwerk"
|
||||
updated, err := s.Update(one.Key, one.Document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Document["title"] != "Geprüftes Netzwerk" {
|
||||
t.Fatalf("update failed: %+v", updated.Document)
|
||||
}
|
||||
trash, err := s.Delete(one.Key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(trash); err != nil {
|
||||
t.Fatalf("trash file missing: %v", err)
|
||||
}
|
||||
if _, err := s.Get(one.Key); !os.IsNotExist(err) {
|
||||
t.Fatalf("deleted staging file should be gone, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,6 +619,110 @@ func (s *Store) Save(key string, doc map[string]any) (Summary, string, error) {
|
||||
return summarize(newRec), backupBatch, nil
|
||||
}
|
||||
|
||||
// ImportDocument creates a new production JSON file without overwriting an existing entry.
|
||||
// It is used when a reviewed staging article is promoted into the productive knowledge base.
|
||||
func (s *Store) ImportDocument(doc map[string]any, preferredBase string) (Summary, error) {
|
||||
if doc == nil {
|
||||
return Summary{}, errors.New("JSON root must be an object")
|
||||
}
|
||||
id := strings.TrimSpace(str(doc["id"]))
|
||||
if id == "" {
|
||||
id = strings.TrimSpace(preferredBase)
|
||||
doc = cloneMap(doc)
|
||||
doc["id"] = id
|
||||
}
|
||||
base := safeFilenameBase(id)
|
||||
if base == "" {
|
||||
base = safeFilenameBase(preferredBase)
|
||||
}
|
||||
if base == "" {
|
||||
return Summary{}, errors.New("cannot derive a safe production filename from document id")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, rec := range s.records {
|
||||
if strings.EqualFold(strings.TrimSpace(str(rec.Doc["id"])), id) {
|
||||
return Summary{}, fmt.Errorf("knowledge entry with id %q already exists", id)
|
||||
}
|
||||
}
|
||||
rel := base + ".json"
|
||||
path := filepath.Join(s.dataDir, rel)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return Summary{}, fmt.Errorf("production target already exists: %s", rel)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return Summary{}, err
|
||||
}
|
||||
payload, err := marshalDocument(doc)
|
||||
if err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
tmp, err := os.CreateTemp(s.dataDir, ".kb-import-*.tmp")
|
||||
if err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
cleanup := func() {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
}
|
||||
if err := tmp.Chmod(0o644); err != nil {
|
||||
cleanup()
|
||||
return Summary{}, err
|
||||
}
|
||||
if _, err := tmp.Write(payload); err != nil {
|
||||
cleanup()
|
||||
return Summary{}, err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
cleanup()
|
||||
return Summary{}, err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return Summary{}, err
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return Summary{}, err
|
||||
}
|
||||
rec, err := s.readRecord(path)
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
return Summary{}, err
|
||||
}
|
||||
s.records[rec.Key] = rec
|
||||
s.order = append(s.order, rec.Key)
|
||||
s.resortLocked()
|
||||
return summarize(rec), nil
|
||||
}
|
||||
|
||||
func safeFilenameBase(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range value {
|
||||
valid := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.'
|
||||
if valid {
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
continue
|
||||
}
|
||||
if !lastDash {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), ".-_ ")
|
||||
if len(out) > 180 {
|
||||
out = out[:180]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) ApplyBulk(keys []string, patch BulkPatch, dryRun bool) (BulkResult, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -161,3 +161,28 @@ func TestSearchRanksExactIdentifiersAndBuildsExcerpt(t *testing.T) {
|
||||
t.Fatalf("unexpected excerpt: %q", result.Items[0].Excerpt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportDocumentCreatesNewFileAndRejectsDuplicateID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc := map[string]any{
|
||||
"id": "KB-AI-STAGING-TEST-001", "title": "Reviewed", "answer": "Lösung",
|
||||
"auto_reply": false, "categories": []any{"AI-Staging"},
|
||||
}
|
||||
created, err := s.ImportDocument(doc, "fallback")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.ID != "KB-AI-STAGING-TEST-001" || s.Count() != 1 {
|
||||
t.Fatalf("unexpected created item: %+v count=%d", created, s.Count())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "KB-AI-STAGING-TEST-001.json")); err != nil {
|
||||
t.Fatalf("production file missing: %v", err)
|
||||
}
|
||||
if _, err := s.ImportDocument(doc, "fallback"); err == nil {
|
||||
t.Fatal("expected duplicate ID to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user