1.5.3
release-tag / release-image (push) Successful in 6m17s

This commit is contained in:
2026-08-27 10:43:07 +02:00
parent 173afb9c54
commit 9030c2efb7
18 changed files with 713 additions and 52 deletions
@@ -76,6 +76,16 @@ func TestGoalResearchPublishesIdempotentHumanReviewDraft(t *testing.T) {
if body["answer"] == "" {
t.Fatalf("empty answer")
}
meta, ok := body["metadata"].(map[string]any)
if !ok {
t.Fatalf("missing metadata: %#v", body["metadata"])
}
if meta["research_evidence"] != float64(1) || meta["research_sources"] != float64(1) {
t.Fatalf("draft counters must reflect selected evidence: %#v", meta)
}
if meta["research_goal_evidence"] != float64(13) || meta["research_goal_sources"] != float64(5) {
t.Fatalf("goal totals must remain auditable: %#v", meta)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"staging": map[string]any{"key": "KB-AI-STAGING-1", "meta": map[string]any{"integration_action": "created"}}})
}))
@@ -103,7 +113,7 @@ func TestGoalResearchPublishesIdempotentHumanReviewDraft(t *testing.T) {
e := &Engine{store: s, http: kb.Client()}
e.ConfigureStagingPublisher(StagingPublisherConfig{Enabled: true, URL: kb.URL, Token: "secret", MinEvidence: 1, MinSources: 1, SynthesisMode: "evidence"})
g := &core.Goal{ID: "goal-1", Title: "NVIDIA", ResearchEvidence: 1, ResearchSources: 1, ResearchSourceIDs: []string{src.ID}}
g := &core.Goal{ID: "goal-1", Title: "NVIDIA", ResearchEvidence: 13, ResearchSources: 5, ResearchSourceIDs: []string{src.ID}}
e.maybePublishGoalDraft(context.Background(), g, ResearchResult{RunID: run.ID})
if requests != 1 || g.StagingDraftsCreated != 1 || g.LastStagingDraftID == "" || g.LastStagingError != "" {
t.Fatalf("goal=%#v requests=%d", g, requests)
@@ -289,3 +299,45 @@ func TestGoalArticleTargetUsesCreatedStagingArticles(t *testing.T) {
t.Fatalf("article target must count articles, got progress=%f reason=%q", g.Progress, g.ProgressReason)
}
}
func TestResearchMaterialRelevanceRequiresExactErrorCodeAndSubjectAnchor(t *testing.T) {
g := &core.Goal{Title: "FortiClient SSLVPN 7200"}
for _, tc := range []struct {
name string
parts []string
relevant bool
}{
{
name: "official fortinet title with exact code",
parts: []string{"Troubleshooting Tip: FortiClient error 'Credentials or SSLVPN configuration is wrong. (-7200)'", "FortiClient SSL VPN authentication troubleshooting"},
relevant: true,
},
{
name: "split ssl vpn plus exact code",
parts: []string{"Technical Tip: Credential or SSL VPN configuration is wrong (-7200)", "SSL VPN authentication rule troubleshooting"},
relevant: true,
},
{
name: "docker id only contains 7200 as substring",
parts: []string{"Docker image", "https://hub.docker.com/r/cffork8s/72007bf3-214d-4f19-a618-f74b145ca3a5", "generic container image"},
relevant: false,
},
{
name: "generic sslvpn without error code",
parts: []string{"baselibrary/sslvpn", "VPN_TYPE=fortinet NET_ADMIN /dev/ppp"},
relevant: false,
},
{
name: "exact code without product subject",
parts: []string{"Build 7200 released", "unrelated software package"},
relevant: false,
},
} {
t.Run(tc.name, func(t *testing.T) {
if got := researchMaterialRelevant(g, tc.parts...); got != tc.relevant {
t.Fatalf("researchMaterialRelevant=%v want %v for %#v", got, tc.relevant, tc.parts)
}
})
}
}
@@ -66,13 +66,83 @@ func researchMaterialRelevant(goal *core.Goal, parts ...string) bool {
if len(anchors) == 0 {
return true
}
haystack := strings.ToLower(strings.Join(parts, "\n"))
for _, tok := range anchors {
if strings.Contains(haystack, tok) {
return true
// Relevance is intentionally token-aware and requires coverage of more than
// one anchor for multi-part goals. The previous substring-any rule allowed
// false positives such as goal code "7200" matching a Docker repository id
// beginning with "72007...", or a generic page containing only "sslvpn".
// Numeric anchors (error codes, versions, model numbers) must match an exact
// normalized token and, when a lexical anchor exists, be accompanied by at
// least one subject/product anchor.
words := normalizedResearchWords(parts...)
wordSet := make(map[string]bool, len(words))
for _, w := range words {
wordSet[w] = true
}
// Also index compact adjacent word pairs/triples so an anchor such as
// "sslvpn" matches source text written as "SSL VPN" without falling back to
// unrestricted substring matching.
compact := make(map[string]bool, len(words)*2)
for i := range words {
if i+1 < len(words) {
compact[words[i]+words[i+1]] = true
}
if i+2 < len(words) {
compact[words[i]+words[i+1]+words[i+2]] = true
}
}
return false
lexicalTotal, lexicalMatches := 0, 0
numericTotal, numericMatches := 0, 0
for _, anchor := range anchors {
if allDigits(anchor) {
numericTotal++
if wordSet[anchor] {
numericMatches++
}
continue
}
lexicalTotal++
if wordSet[anchor] || compact[anchor] {
lexicalMatches++
}
}
if numericTotal > 0 {
if numericMatches == 0 {
return false
}
if lexicalTotal > 0 && lexicalMatches == 0 {
return false
}
return true
}
if lexicalTotal <= 1 {
return lexicalMatches == lexicalTotal
}
return lexicalMatches >= 2
}
func normalizedResearchWords(parts ...string) []string {
normalized := strings.Map(func(r rune) rune {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
return unicode.ToLower(r)
}
return ' '
}, strings.Join(parts, "\n"))
return strings.Fields(normalized)
}
func allDigits(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if !unicode.IsDigit(r) {
return false
}
}
return true
}
func goalIDFromTags(tags []string) string {
+13 -8
View File
@@ -138,14 +138,19 @@ func (e *Engine) maybePublishGoalDraft(ctx context.Context, goal *core.Goal, res
}
}
draft.Metadata = map[string]any{
"research_goal_id": goal.ID,
"research_run_id": research.RunID,
"research_evidence": goal.ResearchEvidence,
"research_sources": goal.ResearchSources,
"research_corroborations": goal.ResearchCorroborations,
"research_evidence_ids": evidenceIDs,
"research_source_uris": sourceURIs,
"human_review_required": true,
"research_goal_id": goal.ID,
"research_run_id": research.RunID,
// Draft-level counters describe the evidence actually supplied to the
// synthesizer. Goal totals are preserved separately for audit/history.
"research_evidence": len(evidenceIDs),
"research_sources": len(sourceURIs),
"research_corroborations": goal.ResearchCorroborations,
"research_goal_evidence": goal.ResearchEvidence,
"research_goal_sources": goal.ResearchSources,
"research_goal_corroborations": goal.ResearchCorroborations,
"research_evidence_ids": evidenceIDs,
"research_source_uris": sourceURIs,
"human_review_required": true,
}
body, _ := json.Marshal(draft)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.URL, bytes.NewReader(body))
@@ -0,0 +1,28 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestGoalsCreateReturnsConflictForDuplicateActiveGoal(t *testing.T) {
srv, _ := newMetricsTestServer(t)
payload := `{"title":"FortiClient SSLVPN 7200","description":"Create one support article","target":"1 hochwertiger Wissensartikel","status":"active"}`
post := func() *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/v1/goals", strings.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+srv.store.Secrets().AppAPIKey)
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, req)
return rr
}
if rr := post(); rr.Code != http.StatusCreated {
t.Fatalf("first create status=%d body=%s", rr.Code, rr.Body.String())
}
if rr := post(); rr.Code != http.StatusConflict {
t.Fatalf("duplicate create status=%d body=%s", rr.Code, rr.Body.String())
}
}
@@ -226,7 +226,7 @@ function openGoalResearch(id,title){researchWatch.goalId=id;researchWatch.goalTi
$('liveCloseBtn').addEventListener('click',()=>{$('goalResearchLive').classList.remove('show');stopResearchPolling()});$('liveRefreshBtn').addEventListener('click',()=>{researchWatch.after=0;researchWatch.events=[];researchWatch.runId='';pollGoalResearch();loadResearchHistory()});
async function loadGoals(){try{let xs=await req('/api/v1/goals');xs=Array.isArray(xs)?xs:[];$('goalsList').innerHTML=xs.length?xs.map(g=>{let active=g.status==='active',paused=g.status==='paused';return `<div class="goal ${paused?'goalPaused':''}"><div class="goalHead"><div><h3>${esc(g.title)}</h3><div class="meta"><span>Priorität ${g.priority}</span><span>${g.research_enabled?'⌕ Research':'ohne Research'}</span><span>${g.auto_run?'AUTO':'MANUELL'}</span></div></div><span class="pill ${active?'good':paused?'warn':''}">${paused?'PAUSIERT':esc(g.status)}</span></div><p class="muted small">${esc(g.description||'')}</p><div class="progress"><span style="width:${Math.round((g.progress||0)*100)}%"></span></div><div class="meta"><span>${Math.round((g.progress||0)*100)}%</span><span>${esc(g.progress_reason||'Fortschritt noch nicht messbar')}</span><span>Intervall ${g.interval_minutes||'–'} min</span><span>${paused?'Scheduler angehalten':('Nächster: '+(g.next_cycle_at?when(g.next_cycle_at):'–'))}</span></div>${g.research_enabled?`<div class="meta"><span>${g.research_evidence||0} Evidenzen</span><span>${g.research_sources||0} Quellen</span><span>${g.research_corroborations||0} bestätigt</span>${g.last_staging_draft_id?`<span class="pill good">Staging ${esc(g.last_staging_draft_id)}</span>`:''}</div>`:''}${g.next_action?`<p><b>Nächste Aktion:</b> ${esc(g.next_action)}</p>`:''}${g.last_staging_error?`<p class="error small"><b>Staging:</b> ${esc(g.last_staging_error)}</p>`:''}${g.last_error?`<p class="error small">${esc(g.last_error)}</p>`:''}<div class="goalActions">${active?`<button class="btn secondary" data-goal-action="cycle" data-goal-id="${esc(g.id)}" data-goal-title="${esc(g.title)}">Jetzt lernen</button><button class="btn ghost" data-goal-action="pause" data-goal-id="${esc(g.id)}">Pausieren</button>`:''}${paused?`<button class="btn secondary" data-goal-action="resume" data-goal-id="${esc(g.id)}">Fortsetzen</button>`:''}${g.research_enabled?`<button class="btn ghost" data-research-live="${esc(g.id)}" data-goal-title="${esc(g.title)}">Live Research</button>`:''}<button class="btn ghost" data-focus="${esc(g.id)}">Graph</button><button class="btn danger" data-goal-action="delete" data-goal-id="${esc(g.id)}" data-goal-title="${esc(g.title)}">Löschen</button></div></div>`}).join(''):'<p class="muted">Noch keine Ziele.</p>';$$('[data-goal-action]').forEach(b=>b.addEventListener('click',()=>goalAction(b)));$$('[data-research-live]').forEach(b=>b.addEventListener('click',()=>openGoalResearch(b.dataset.researchLive,b.dataset.goalTitle)));$$('[data-focus]').forEach(b=>b.addEventListener('click',()=>{switchView('knowledge');$('knowledgeQuery').value='goal:'+b.dataset.focus;searchKnowledge()}))}catch(e){toast(e.message,true)}}
async function goalAction(b){let id=b.dataset.goalId,action=b.dataset.goalAction;if(action==='delete'&&!confirm(`Ziel „${b.dataset.goalTitle||id}“ wirklich löschen? Bereits gelerntes Wissen bleibt erhalten.`))return;if(action==='cycle')openGoalResearch(id,b.dataset.goalTitle||id);let old=b.textContent;try{b.disabled=true;b.textContent=action==='cycle'?'läuft …':action==='delete'?'lösche …':'speichere …';let path='/api/v1/goals/'+encodeURIComponent(id)+(action==='cycle'?'/cycle':action==='pause'?'/pause':action==='resume'?'/resume':'');let d=await req(path,{method:action==='delete'?'DELETE':'POST',body:action==='delete'?undefined:'{}'});if(action==='cycle')toast(`Zyklus fertig · Evaluation ${Number(d.evaluation).toFixed(2)} · ${d.sources_ingested||0} Quellen`);else if(action==='pause')toast('Ziel pausiert. Der Scheduler verarbeitet es nicht weiter.');else if(action==='resume')toast('Ziel wieder aktiv. Nächster Lauf wurde eingeplant.');else{toast('Ziel gelöscht. Bereits gelerntes Wissen bleibt erhalten.');if(researchWatch.goalId===id){$('goalResearchLive').classList.remove('show');stopResearchPolling();researchWatch.goalId=''}}await loadGoals();refreshAll()}catch(e){toast(e.message,true)}finally{b.disabled=false;b.textContent=old}}
$('goalCreateBtn').addEventListener('click',async()=>{let title=$('goalTitle').value.trim();if(!title)return toast('Titel fehlt.',true);try{let g=await req('/api/v1/goals',{method:'POST',body:JSON.stringify({title,description:$('goalDesc').value,target:$('goalTarget').value,priority:Number($('goalPriority').value||50),interval_minutes:Number($('goalInterval').value||10),status:'active'})});toast(`Ziel ${g.title} angelegt – erster Lauf ist eingeplant.`);$('goalTitle').value='';$('goalDesc').value='';loadGoals();refreshAll()}catch(e){toast(e.message,true)}});$('runAutonomyBtn').addEventListener('click',async()=>{try{let d=await req('/admin/api/autonomy',{method:'POST',body:'{}'});toast(`${d.cycles?.length||0} fällige Zyklen ausgeführt.`);loadGoals()}catch(e){toast(e.message,true)}});
$('goalCreateBtn').addEventListener('click',async()=>{let title=$('goalTitle').value.trim();if(!title)return toast('Titel fehlt.',true);let b=$('goalCreateBtn'),old=b.textContent;try{b.disabled=true;b.textContent='lege an …';let g=await req('/api/v1/goals',{method:'POST',body:JSON.stringify({title,description:$('goalDesc').value,target:$('goalTarget').value,priority:Number($('goalPriority').value||50),interval_minutes:Number($('goalInterval').value||10),status:'active'})});toast(`Ziel ${g.title} angelegt – erster Lauf ist eingeplant.`);$('goalTitle').value='';$('goalDesc').value='';await loadGoals();refreshAll()}catch(e){toast(e.message,true)}finally{b.disabled=false;b.textContent=old}});$('runAutonomyBtn').addEventListener('click',async()=>{try{let d=await req('/admin/api/autonomy',{method:'POST',body:'{}'});toast(`${d.cycles?.length||0} fällige Zyklen ausgeführt.`);loadGoals()}catch(e){toast(e.message,true)}});
// ---- Chat ----
$('chatBtn').addEventListener('click',async()=>{let input=document.getElementById('chatPrompt').value.trim();if(!input)return toast('Nachricht fehlt.',true);chatAbort=new AbortController();$('chatAbortBtn').disabled=false;$('chatBtn').disabled=true;let start=Date.now(),timer=setInterval(()=>$('chatElapsed').textContent=Math.round((Date.now()-start)/1000)+' s',1000);$('chatOutput').textContent='Ollama / NeuroForge arbeitet …';try{let r=await fetch('/api/v1/chat',{method:'POST',headers:tokenHeaders(),signal:chatAbort.signal,body:JSON.stringify({session_id:$('chatSession').value,input,provider:$('chatProvider').value})}),txt=await r.text(),d;try{d=JSON.parse(txt)}catch{d=txt}if(!r.ok)throw new Error(d.error||d);$('chatOutput').textContent=(d.answer||'')+'\n\n— '+(d.provider||'')+' · '+(d.model||'')+' · Recall '+(d.recalled?.length||0)+' · Reward '+Number(d.auto_reward||0).toFixed(3);refreshAll()}catch(e){$('chatOutput').textContent=e.name==='AbortError'?'Abgebrochen.':e.message}finally{clearInterval(timer);$('chatElapsed').textContent='';$('chatAbortBtn').disabled=true;$('chatBtn').disabled=false;chatAbort=null}});$('chatAbortBtn').addEventListener('click',()=>chatAbort?.abort());
@@ -9,6 +9,7 @@ import (
"neuroforge/internal/brain"
"neuroforge/internal/core"
"neuroforge/internal/store"
)
func (s *Server) goalsList(w http.ResponseWriter, r *http.Request) {
@@ -22,6 +23,10 @@ func (s *Server) goalsCreate(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.store.UpsertGoal(&g); err != nil {
if errors.Is(err, store.ErrDuplicateGoal) {
s.err(w, http.StatusConflict, err)
return
}
s.err(w, 400, err)
return
}
@@ -0,0 +1,42 @@
package store
import (
"errors"
"testing"
"neuroforge/internal/core"
)
func TestUpsertGoalRejectsDuplicateActiveGoalFingerprint(t *testing.T) {
s, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer s.Close()
first := &core.Goal{Title: "FortiClient SSLVPN 7200", Description: "Create one support article", Target: "1 hochwertiger Wissensartikel", Status: core.GoalActive}
if err := s.UpsertGoal(first); err != nil {
t.Fatal(err)
}
duplicate := &core.Goal{Title: " forticlient sslvpn 7200 ", Description: " Create one support article ", Target: "1 hochwertiger Wissensartikel", Status: core.GoalActive}
if err := s.UpsertGoal(duplicate); !errors.Is(err, ErrDuplicateGoal) {
t.Fatalf("duplicate goal error=%v, want %v", err, ErrDuplicateGoal)
}
}
func TestUpsertGoalAllowsSameFingerprintAfterCompletion(t *testing.T) {
s, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer s.Close()
first := &core.Goal{Title: "FortiClient SSLVPN 7200", Description: "Create one support article", Target: "1 hochwertiger Wissensartikel", Status: core.GoalCompleted}
if err := s.UpsertGoal(first); err != nil {
t.Fatal(err)
}
second := &core.Goal{Title: first.Title, Description: first.Description, Target: first.Target, Status: core.GoalActive}
if err := s.UpsertGoal(second); err != nil {
t.Fatalf("completed historical goal must not block a new run: %v", err)
}
}
+23
View File
@@ -12,6 +12,8 @@ import (
"neuroforge/internal/vector"
)
var ErrDuplicateGoal = errors.New("duplicate goal")
func (s *Store) resolveConflictLocked(in *core.Memory) []core.Memory {
key := strings.TrimSpace(in.TruthKey)
if key == "" {
@@ -173,6 +175,17 @@ func (s *Store) UpsertGoal(g *core.Goal) error {
defer s.mu.Unlock()
now := time.Now().UTC()
newGoal := g.ID == "" || s.state.Goals[g.ID] == nil
if newGoal {
fingerprint := goalFingerprint(g)
for id, existing := range s.state.Goals {
if existing == nil || existing.Status == core.GoalCompleted {
continue
}
if goalFingerprint(existing) == fingerprint {
return fmt.Errorf("%w: existing goal %s", ErrDuplicateGoal, id)
}
}
}
if g.ID == "" {
g.ID = NewID("goal")
}
@@ -206,6 +219,16 @@ func (s *Store) UpsertGoal(g *core.Goal) error {
return s.commitLocked("goal.upsert", cp)
}
func goalFingerprint(g *core.Goal) string {
if g == nil {
return ""
}
normalize := func(v string) string {
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(v)), " "))
}
return normalize(g.Title) + "\x00" + normalize(g.Description) + "\x00" + normalize(g.Target)
}
func (s *Store) GetGoal(id string) (*core.Goal, bool) {
s.mu.RLock()
defer s.mu.RUnlock()