@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user