1.4.5
release-tag / release-image (push) Successful in 3m42s

This commit is contained in:
2026-08-27 06:06:00 +02:00
parent 0b3db9afa8
commit 1decb831d6
30 changed files with 6458 additions and 319 deletions
+12 -10
View File
@@ -239,14 +239,16 @@ func (a *app) handleAIFallback(w http.ResponseWriter, r *http.Request) {
}
type integrationStagingRequest struct {
Source string `json:"source"`
Query string `json:"query"`
Title string `json:"title"`
Text string `json:"text"`
Answer string `json:"answer"`
Categories []string `json:"categories"`
Keywords []string `json:"keywords"`
MinScore *float64 `json:"min_score,omitempty"`
Source string `json:"source"`
Query string `json:"query"`
Title string `json:"title"`
Text string `json:"text"`
Answer string `json:"answer"`
Categories []string `json:"categories"`
Keywords []string `json:"keywords"`
MinScore *float64 `json:"min_score,omitempty"`
IntegrationKey string `json:"integration_key,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
func integrationBearerAuthorized(r *http.Request) (bool, bool) {
@@ -296,9 +298,9 @@ func (a *app) handleIntegrationStaging(w http.ResponseWriter, r *http.Request) {
if req.MinScore != nil {
minScore = *req.MinScore
}
result, err := a.staging.SaveFromSource(req.Query, req.Source, staging.Draft{
result, err := a.staging.SaveFromIntegration(req.Query, req.Source, staging.Draft{
Title: req.Title, Text: req.Text, Answer: req.Answer, Categories: req.Categories, Keywords: req.Keywords,
}, false, minScore)
}, false, minScore, staging.IntegrationOptions{IntegrationKey: req.IntegrationKey, Metadata: req.Metadata})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
+35
View File
@@ -424,3 +424,38 @@ func TestEditorBasicAuthDoesNotLeakCredentialsToIntegrationClient(t *testing.T)
t.Fatalf("editor API unexpectedly bypassed basic auth: %d", itemsRR.Code)
}
}
func TestIntegrationDraftWithStableKeyUpdatesInsteadOfDuplicating(t *testing.T) {
t.Setenv("KB_INTEGRATION_TOKEN", "integration-secret")
s, err := store.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
st, err := staging.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
web, _ := fs.Sub(webFS, "web")
h := newApp(s, web).withStaging(st).routes()
post := func(answer string) {
payload := fmt.Sprintf(`{"source":"NeuroForge Research","query":"NVIDIA","title":"NVIDIA","answer":%q,"integration_key":"neuroforge-goal:g1","metadata":{"research_goal_id":"g1"}}`, answer)
req := httptest.NewRequest(http.MethodPost, "/api/integrations/staging", bytes.NewBufferString(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer integration-secret")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
}
post("erste Fassung")
post("zweite Fassung")
if st.Count() != 1 {
t.Fatalf("expected one active draft, got %d", st.Count())
}
items, _ := st.List(staging.Query{Page: 1, PageSize: 10})
got, _ := st.Get(items.Items[0].Key)
if got.Document["answer"] != "zweite Fassung" {
t.Fatalf("draft not refreshed: %#v", got.Document)
}
}
@@ -33,6 +33,14 @@ type Result struct {
Meta map[string]any `json:"meta"`
}
// IntegrationOptions carries idempotency and provenance metadata for machine-generated
// proposals. IntegrationKey is stable for one producer/goal and causes active staging
// to be updated instead of creating a new draft every research cycle.
type IntegrationOptions struct {
IntegrationKey string
Metadata map[string]any
}
type Summary struct {
Key string `json:"key"`
ID string `json:"id"`
@@ -111,6 +119,12 @@ func (s *Store) Save(query, model string, draft Draft, autoReply bool, minScore
// SaveFromSource stores a proposal in the human-review staging area while
// preserving the system that produced it. It never promotes into production.
func (s *Store) SaveFromSource(query, source string, draft Draft, autoReply bool, minScore float64) (Result, error) {
return s.SaveFromIntegration(query, source, draft, autoReply, minScore, IntegrationOptions{})
}
// SaveFromIntegration stores or refreshes an active machine-generated staging draft.
// A stable IntegrationKey makes the operation idempotent across autonomous cycles.
func (s *Store) SaveFromIntegration(query, source string, draft Draft, autoReply bool, minScore float64, opts IntegrationOptions) (Result, error) {
draft.Title = clampString(draft.Title, 320)
draft.Text = clampString(draft.Text, 16000)
draft.Answer = clampString(draft.Answer, 32000)
@@ -151,6 +165,36 @@ func (s *Store) SaveFromSource(query, source string, draft Draft, autoReply bool
"language": "de-DE",
"communication_style": "formal",
}
key := strings.TrimSpace(opts.IntegrationKey)
if key != "" {
doc["integration_key"] = clampString(key, 240)
}
for k, v := range opts.Metadata {
k = strings.TrimSpace(k)
if k == "" || k == "id" || k == "auto_reply" {
continue
}
doc[k] = v
}
if key != "" {
if existing, ok := s.findByIntegrationKey(key); ok {
doc["id"] = existing.Key
if oldCreated, exists := existing.Document["created_at"]; exists {
doc["created_at"] = oldCreated
}
doc["updated_at"] = now.Format(time.RFC3339)
if _, err := s.Update(existing.Key, doc); err != nil {
return Result{}, err
}
result, err := s.Get(existing.Key)
if err == nil {
result.Meta["integration_action"] = "updated"
}
return result, err
}
}
doc["created_at"] = now.Format(time.RFC3339)
doc["updated_at"] = now.Format(time.RFC3339)
if err := s.writeNew(id, doc); err != nil {
return Result{}, err
}
@@ -159,9 +203,31 @@ func (s *Store) SaveFromSource(query, source string, draft Draft, autoReply bool
return Result{}, err
}
result.Meta["generated_at"] = now.Format(time.RFC3339)
result.Meta["integration_action"] = "created"
return result, nil
}
func (s *Store) findByIntegrationKey(key string) (Result, bool) {
entries, err := os.ReadDir(s.dir)
if err != nil {
return Result{}, false
}
for _, entry := range entries {
if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".json") {
continue
}
id := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name()))
result, err := s.Get(id)
if err != nil {
continue
}
if strings.TrimSpace(fmt.Sprint(result.Document["integration_key"])) == strings.TrimSpace(key) {
return result, true
}
}
return Result{}, false
}
func (s *Store) Get(key string) (Result, error) {
key = strings.TrimSpace(key)
path, err := s.pathForKey(key)
@@ -82,3 +82,34 @@ func TestListUpdateAndSoftDelete(t *testing.T) {
t.Fatalf("deleted staging file should be gone, err=%v", err)
}
}
func TestIntegrationKeyUpdatesExistingDraft(t *testing.T) {
s, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
first, err := s.SaveFromIntegration("vpn", "NeuroForge Research", Draft{Title: "VPN", Answer: "Erste Fassung"}, false, .85, IntegrationOptions{IntegrationKey: "neuroforge-goal:g1", Metadata: map[string]any{"research_goal_id": "g1"}})
if err != nil {
t.Fatal(err)
}
second, err := s.SaveFromIntegration("vpn", "NeuroForge Research", Draft{Title: "VPN", Answer: "Aktualisierte Fassung"}, false, .85, IntegrationOptions{IntegrationKey: "neuroforge-goal:g1", Metadata: map[string]any{"research_goal_id": "g1"}})
if err != nil {
t.Fatal(err)
}
if first.Key != second.Key {
t.Fatalf("expected stable staging key, got %q then %q", first.Key, second.Key)
}
if second.Document["answer"] != "Aktualisierte Fassung" {
t.Fatalf("draft was not updated: %#v", second.Document)
}
if second.Document["auto_reply"] != false {
t.Fatalf("integration must remain auto_reply=false")
}
list, err := s.List(Query{Page: 1, PageSize: 10})
if err != nil {
t.Fatal(err)
}
if list.Total != 1 {
t.Fatalf("expected one active staging draft, got %d", list.Total)
}
}