1.5.2
release-tag / release-image (push) Successful in 11m6s

This commit is contained in:
2026-08-27 09:34:33 +02:00
parent 07b5cc4fc4
commit 173afb9c54
13 changed files with 738 additions and 45 deletions
@@ -124,12 +124,47 @@ func TestGoalResearchQueryNeverUsesSchedulerNextActionAsSearchSubject(t *testing
e := &Engine{store: s}
g := &core.Goal{Title: "NVIDIA", Description: "Sammle Informationen zu den neuen RTX Grafikkarten.", Target: "100 quellengebundene Wissenseinträge", NextAction: "Review the strongest negative evidence and create a corrective task before the next cycle."}
qs, _ := e.goalResearchQueries(context.Background(), g, 2, nil)
if len(qs) != 1 {
if len(qs) != 2 {
t.Fatalf("queries=%#v", qs)
}
q := qs[0]
if !strings.Contains(strings.ToLower(q), "nvidia") || strings.Contains(strings.ToLower(q), "negative evidence") || strings.Contains(strings.ToLower(q), "next cycle") {
t.Fatalf("bad research query: %q", q)
for _, q := range qs {
if !strings.Contains(strings.ToLower(q), "nvidia") || strings.Contains(strings.ToLower(q), "negative evidence") || strings.Contains(strings.ToLower(q), "next cycle") {
t.Fatalf("bad research query: %q", q)
}
}
if qs[0] != "NVIDIA" || !strings.Contains(strings.ToLower(qs[1]), "rtx") {
t.Fatalf("deterministic queries are not compact/topic-focused: %#v", qs)
}
}
func TestDeterministicResearchQueryDoesNotSendFullGoalPromptToSearch(t *testing.T) {
s, err := store.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer s.Close()
cfg := s.Config()
cfg.Autonomy.UseLLM = false
if err := s.UpdateConfig(cfg); err != nil {
t.Fatal(err)
}
e := &Engine{store: s}
g := &core.Goal{
Title: "FortiClient SSLVPN 7200",
Description: "Erstelle einen deutschsprachigen Support-Wissensartikel zum FortiClient SSL-VPN Fehler 7200. Recherchiere Ursache, typische Auslöser, sichere Diagnose-Schritte und geeignete Lösungswege. Bevorzuge offizielle Fortinet-Dokumentation und technisch belastbare Quellen.",
Target: "1 hochwertiger Wissensartikel",
}
qs, _ := e.goalResearchQueries(context.Background(), g, 2, nil)
if len(qs) != 2 {
t.Fatalf("queries=%#v", qs)
}
if qs[0] != "FortiClient SSLVPN 7200" {
t.Fatalf("first query must be exact compact title, got %q", qs[0])
}
for _, q := range qs {
if strings.Contains(strings.ToLower(q), "wissensartikel") || strings.Contains(strings.ToLower(q), "hochwertiger") || len(q) > 100 {
t.Fatalf("query contains goal-instruction noise: %q", q)
}
}
}
+125 -19
View File
@@ -284,6 +284,18 @@ type ResearchRequest struct {
trace *researchTrace
}
func researchResultsContainRelevant(goal *core.Goal, results []research.Result) bool {
if goal == nil {
return len(results) > 0
}
for _, r := range results {
if researchMaterialRelevant(goal, r.Title, r.Abstract, r.Content, r.URL) {
return true
}
}
return false
}
type ResearchResult struct {
RunID string `json:"run_id,omitempty"`
Query string `json:"query"`
@@ -308,23 +320,39 @@ func (e *Engine) Research(ctx context.Context, q ResearchRequest) (ResearchResul
if max <= 0 || max > cfg.Research.SearXNG.MaxResults {
max = cfg.Research.SearXNG.MaxResults
}
if q.trace != nil {
q.trace.emit(core.ResearchEvent{Type: "search.started", Phase: "search", Status: "running", Query: query, Message: fmt.Sprintf("SearXNG-Suche gestartet · max %d Ergebnisse", max)})
}
results, err := research.Search(ctx, research.SearchConfig{BaseURL: cfg.Research.SearXNG.BaseURL, Language: cfg.Research.SearXNG.Language, Categories: cfg.Research.SearXNG.Categories, SafeSearch: cfg.Research.SearXNG.SafeSearch, Timeout: time.Duration(cfg.Research.SearXNG.TimeoutSeconds) * time.Second, MaxResults: max, Authorization: e.store.Secrets().SearXNGAuthHeader}, query)
if err != nil {
if q.trace != nil {
q.trace.emit(core.ResearchEvent{Type: "search.error", Phase: "search", Status: "error", Query: query, Message: err.Error()})
}
return ResearchResult{}, err
}
out := ResearchResult{Query: query, Results: results}
var researchGoal *core.Goal
if q.goalID != "" {
if g, ok := e.store.GetGoal(q.goalID); ok {
researchGoal = g
}
}
searchCfg := research.SearchConfig{BaseURL: cfg.Research.SearXNG.BaseURL, Language: cfg.Research.SearXNG.Language, Categories: cfg.Research.SearXNG.Categories, SafeSearch: cfg.Research.SearXNG.SafeSearch, Timeout: time.Duration(cfg.Research.SearXNG.TimeoutSeconds) * time.Second, MaxResults: max, Authorization: e.store.Secrets().SearXNGAuthHeader}
if q.trace != nil {
q.trace.emit(core.ResearchEvent{Type: "search.started", Phase: "search", Status: "running", Query: query, Message: fmt.Sprintf("SearXNG-Suche gestartet · max %d Ergebnisse", max)})
}
results, err := research.Search(ctx, searchCfg, query)
if err != nil {
if q.trace != nil {
q.trace.emit(core.ResearchEvent{Type: "search.error", Phase: "search", Status: "error", Query: query, Message: err.Error()})
}
return ResearchResult{}, err
}
// Category mixes are useful for broad autonomous research, but a specialized
// engine can occasionally dominate the top-N with completely unrelated hits.
// For goal-bound research, retry once in the general category if none of the
// configured-category results is anchored to the goal. This preserves the
// strict relevance gate while avoiding false "zero evidence" cycles.
if researchGoal != nil && !researchResultsContainRelevant(researchGoal, results) && !strings.EqualFold(strings.TrimSpace(searchCfg.Categories), "general") {
fallbackCfg := searchCfg
fallbackCfg.Categories = "general"
if q.trace != nil {
q.trace.emit(core.ResearchEvent{Type: "search.fallback", Phase: "search", Status: "warn", Query: query, Message: "Keine goal-relevanten Treffer in den konfigurierten Kategorien; Wiederholung mit category=general"})
}
if fallback, ferr := research.Search(ctx, fallbackCfg, query); ferr == nil && researchResultsContainRelevant(researchGoal, fallback) {
results = fallback
}
}
out := ResearchResult{Query: query, Results: results}
if q.trace != nil {
out.RunID = q.trace.runID
q.trace.emit(core.ResearchEvent{Type: "search.completed", Phase: "search", Status: "ok", Query: query, Message: fmt.Sprintf("%d Suchtreffer gefunden", len(results))})
@@ -477,13 +505,15 @@ func (e *Engine) goalResearchQueries(ctx context.Context, goal *core.Goal, max i
}
// Search subject and scheduler action are deliberately separated. NextAction
// describes what the autonomy loop should do, not what a search engine should
// search for. Feeding it back as a query caused self-referential searches such
// as "Review the strongest negative evidence ... next cycle".
base := strings.TrimSpace(strings.Join([]string{goal.Title, goal.Description, goal.Target}, " "))
if base == "" {
base = strings.TrimSpace(goal.Title)
// search for. Deterministic planning must also stay compact: sending the full
// goal description/target to SearXNG diluted specific support queries and could
// cause a category engine (notably arXiv) to dominate otherwise obvious results.
deterministic := deterministicResearchQueries(goal, max)
base := strings.TrimSpace(goal.Title)
if len(deterministic) > 0 {
base = deterministic[0]
}
queries := []string{base}
queries := append([]string(nil), deterministic...)
cost := 0.0
cfg := e.store.Config()
if trace != nil {
@@ -506,7 +536,7 @@ func (e *Engine) goalResearchQueries(ctx context.Context, goal *core.Goal, max i
}
}
if len(queries) == 0 {
queries = []string{base}
queries = append([]string(nil), deterministic...)
}
} else if trace != nil {
trace.emit(core.ResearchEvent{Type: "plan.fallback", Phase: "plan", Status: "warn", Message: "LLM-Queryplanung fehlgeschlagen; deterministische Query wird verwendet: " + shortPreview(err.Error(), 180)})
@@ -522,7 +552,10 @@ func (e *Engine) goalResearchQueries(ctx context.Context, goal *core.Goal, max i
}
}
if len(filtered) == 0 {
filtered = []string{base}
filtered = append([]string(nil), deterministic...)
if len(filtered) == 0 && base != "" {
filtered = []string{base}
}
}
queries = dedupeStrings(filtered)
if trace != nil {
@@ -534,6 +567,79 @@ func (e *Engine) goalResearchQueries(ctx context.Context, goal *core.Goal, max i
return queries, cost
}
func deterministicResearchQueries(goal *core.Goal, max int) []string {
if goal == nil {
return nil
}
if max <= 0 {
max = 2
}
title := strings.Join(strings.Fields(strings.TrimSpace(goal.Title)), " ")
out := make([]string, 0, max)
if title != "" {
out = append(out, title)
}
if len(out) >= max {
return out[:max]
}
stop := map[string]bool{
"der": true, "die": true, "das": true, "den": true, "dem": true, "des": true, "ein": true, "eine": true, "einen": true, "einer": true,
"und": true, "oder": true, "mit": true, "für": true, "von": true, "zum": true, "zur": true, "zu": true, "nach": true, "bei": true, "auf": true,
"erstelle": true, "erstellen": true, "recherchiere": true, "suche": true, "sammle": true, "informationen": true, "information": true,
"deutschsprachigen": true, "deutschsprachig": true, "wissensartikel": true, "support": true, "hochwertigen": true, "hochwertiger": true, "sichere": true,
"typische": true, "geeignete": true, "bevorzuge": true, "offizielle": true, "technisch": true, "belastbare": true, "quellen": true,
"keine": true, "keinen": true, "allgemeinen": true, "ohne": true, "direkten": true, "bezug": true, "themen": true,
"the": true, "and": true, "for": true, "with": true, "from": true, "about": true, "create": true, "research": true, "find": true, "article": true,
}
canonical := func(v string) string {
v = strings.ToLower(strings.TrimSpace(v))
v = strings.NewReplacer("-", "", "_", "", "/", "", ".", "", ":", "").Replace(v)
return v
}
seen := map[string]bool{}
for _, raw := range strings.Fields(strings.ToLower(title)) {
tok := strings.Trim(raw, ".,:;!?()[]{}\"'/_-")
if key := canonical(tok); key != "" {
seen[key] = true
}
}
extras := make([]string, 0, 4)
for _, raw := range strings.Fields(goal.Description) {
tok := strings.Trim(raw, ".,:;!?()[]{}\"'/_-")
lower := strings.ToLower(tok)
key := canonical(lower)
if len([]rune(lower)) < 3 || stop[lower] || strings.Contains(lower, "wissensartikel") || seen[key] {
continue
}
seen[key] = true
extras = append(extras, tok)
if len(extras) >= 4 {
break
}
}
if len(extras) > 0 {
q := strings.TrimSpace(strings.Join(append([]string{title}, extras...), " "))
if q != "" && !strings.EqualFold(q, title) {
out = append(out, q)
}
}
if len(out) == 0 {
parts := strings.Fields(strings.TrimSpace(goal.Description))
if len(parts) > 8 {
parts = parts[:8]
}
q := strings.Join(parts, " ")
if q != "" {
out = append(out, q)
}
}
if len(out) > max {
out = out[:max]
}
return dedupeStrings(out)
}
func researchQueryUseful(goal *core.Goal, query string) bool {
q := strings.ToLower(strings.TrimSpace(query))
if len(q) < 3 {
+50 -1
View File
@@ -57,6 +57,55 @@ func TestResearchLearnsSearXNGSnippetWithoutExplicitLearnPermission(t *testing.T
}
}
func TestGoalResearchFallsBackToGeneralWhenCategoryMixIsOffTopic(t *testing.T) {
var categories []string
searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cat := r.URL.Query().Get("categories")
categories = append(categories, cat)
if cat == "general" {
_ = json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{{
"title": "FortiClient SSL VPN error -7200 troubleshooting", "url": "https://community.fortinet.com/forticlient-7200", "content": "FortiClient SSLVPN error 7200 can indicate credential or SSL VPN configuration problems.", "engine": "general-test",
}}})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{{
"title": "Unrelated arXiv graph paper", "url": "https://arxiv.org/abs/1234.5678", "content": "Graph transformation research unrelated to VPN clients.", "engine": "arxiv",
}}})
}))
defer searx.Close()
s, e := policyTestEngine(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/embed" {
_ = json.NewEncoder(w).Encode(map[string]any{"embeddings": [][]float32{{1, 0, 0, 0}}, "prompt_eval_count": 1})
return
}
http.NotFound(w, r)
})
cfg := s.Config()
cfg.Research.Enabled = true
cfg.Research.SearXNG.Enabled = true
cfg.Research.SearXNG.BaseURL = searx.URL
cfg.Research.SearXNG.Categories = "general,science,it"
cfg.Research.WebFetch.Enabled = false
if err := s.UpdateConfig(cfg); err != nil {
t.Fatal(err)
}
goal := core.Goal{Title: "FortiClient SSLVPN 7200", Status: core.GoalActive, ResearchEnabled: true}
if err := s.UpsertGoal(&goal); err != nil {
t.Fatal(err)
}
out, err := e.Research(context.Background(), ResearchRequest{Query: "FortiClient SSLVPN 7200", Learn: true, FetchPages: false, MaxResults: 6, goalID: goal.ID})
if err != nil {
t.Fatal(err)
}
if len(categories) != 2 || categories[0] != "general,science,it" || categories[1] != "general" {
t.Fatalf("expected category fallback, calls=%#v", categories)
}
if len(out.Results) != 1 || out.Results[0].Engine != "general-test" || out.Ingested < 1 {
t.Fatalf("fallback result was not used: %#v", out)
}
}
func TestGoalResearchPersistsTransparentTrace(t *testing.T) {
searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{{
@@ -101,7 +150,7 @@ func TestGoalResearchPersistsTransparentTrace(t *testing.T) {
if run.ID != cycle.ResearchRunID || run.Status != "completed" {
t.Fatalf("unexpected run %#v", run)
}
if run.Stats.Results != 1 || run.Stats.Claims < 1 || run.Stats.NewEvidence < 1 {
if run.Stats.Results < 1 || run.Stats.Claims < 1 || run.Stats.NewEvidence < 1 {
t.Fatalf("trace stats do not expose search/claim/learning: %#v", run.Stats)
}
seen := map[string]bool{}