1.5.2
All checks were successful
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

View File

@@ -1,5 +1,5 @@
###############################################################################
# GLPI NEUROFORGE MEGA v1.5.0 - VOLLSTÄNDIGE .ENV.example
# GLPI NEUROFORGE MEGA v1.5.2 - VOLLSTÄNDIGE .ENV.example
#
# Diese Datei ist die zentrale Konfiguration für docker compose.
# Sie enthält:
@@ -29,7 +29,7 @@
# 01. MEGA STACK - RELEASE / HOST PORTS / PFADE
###############################################################################
# Immutable Registry-Tag der sechs Projekt-Images. "latest" ist produktiv verboten.
IMAGE_TAG=1.5.0
IMAGE_TAG=1.5.2
CONTROL_HOST_PORT=8070
AGENT_HOST_PORT=8080

View File

@@ -1,9 +1,9 @@
27dc46be5cbb1b171deff7fbd2f28bff1be802dff403797535fd8968bb98c8eb ./.cbmignore
b350af5d01efc6ad7927bec94ef0c502e1980cdf91516d01147460b3c1049740 ./.env.example
db413beb17cc2a71be57ba39bdeeab1f380a51c806177b9df39881bb77f2d05f ./.env.example
ed22fda7661db8203563611dc144998161cd024e161b0471d615eaf0defeb7db ./.gitea/workflows/release-tag.yml
e1ff71187cc3411a85067b964264011db7bd109a585ef7b9ea5b08bda039d813 ./.gitignore
ccfc4c139345a69d05cd2b809b0d09b4332d98e5b4f79aace4db0b648e3ea814 ./Makefile
fef01582db99291bf25dce75f4dcd12c6e46be470893b7a7b23a82dfab6d0b3d ./README.md
d3951624cd67488d07866d712a4294c6f5aac52b6e5c8e994a3fe179fd05a129 ./README.md
4858caa52c0fb6cf302a1c581d07d448e5e90e0daa797e5819610fd6223bd348 ./RELEASE-NOTES-v1.1.0.md
01163462f46314f57660677fdef407c6c2884412ea850aab13a4f650e8c29f50 ./RELEASE-NOTES-v1.2.0.md
4da388ce660aa3b7a0d8075ec066a025b5437960973397360fcb9a5d4cb58c96 ./RELEASE-NOTES-v1.3.0.md
@@ -14,11 +14,11 @@ fef01582db99291bf25dce75f4dcd12c6e46be470893b7a7b23a82dfab6d0b3d ./README.md
61ceebe5a891388336795fda2fd0d1ad10373b4ebaae7d2e670f35766115c604 ./RELEASE-NOTES-v1.4.4.md
15a3defdd5bbf07ebaec1a2e8626347a027a3622f10afb508f7a21c9b0bdfa71 ./RELEASE-NOTES-v1.4.5.md
d10b1dbd87585d72144d816449800dc17b6d7f8faba2dfb4eca93ae3def2c631 ./RELEASE-NOTES-v1.5.0.md
dfe7d71b3f428eb73ed853f8fded65e2986896e21d69f45ebf06bb623b450e14 ./VERSION
ded1b601e81d31ca1f65c4f3ccf2ebd7992b6390e37bc2a86f3837282f1e00cf ./VERSION
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ./backups/.gitkeep
8127e9db5e5e0af1d88770dc8fa60b381de45dbcc843262698cf9501409b4d58 ./deploy/searxng/settings.yml
3e153ad540d6cb1b20275775f2d7c129ec0216764cc9d0d27b963c634f347b43 ./docker-compose.dev.yml
96a4d1024248c6bd0bec88f3b654bb506cafb218ba1fb249f7572dab125aad2e ./docker-compose.yml
dcda7c439002ec6ff4254f38dbf5ee21bb39cc4b2fd74c6c37d6173531211c75 ./docker-compose.yml
0f2adaa0765ff00d9c3a1133840c7a768a011f9d8f820f8a340b43600c7649d8 ./docs/ARCHITECTURE.md
34e2c7ac5992389cc51b40f740c1e206f20bffc67bdf19cb59d94b5df67a7082 ./docs/BACKUP-RESTORE-v1.5.0.md
9467a3c0796a87bada0eab6ad191913e628b4abc05520cbb4b73fac76d334481 ./docs/CODEBASE-MEMORY-MCP.md
@@ -37,11 +37,13 @@ a71d28353529906cee08cf90f35e0b0cf97bbc94ccfcef830145a2d561b21a9c ./docs/MIGRATI
b285d050223844f5fd05014c2278199eaccd7525fbbf8a6dae724ed87ced0ee8 ./docs/MIGRATION-v1.4.4-to-v1.4.5.md
57858f4f2b6670106edf5a88ce705649d547c65c26407d6117040296122a0e26 ./docs/MIGRATION-v1.4.5-to-v1.5.0.md
c905084e03788887894e37c040492b8ce2386ecc25ac3ab2e10bfd4b0a7cd167 ./docs/MIGRATION-v1.5.0-to-v1.5.1.md
12619874bce206ed6e817685d2def62244d6bcceb6eeccbca9b0936fed74b8a2 ./docs/MIGRATION-v1.5.1-to-v1.5.2.md
2a01fb10a3e04eae1800a7c7e0aafc31e9bbb23584cea54d849b004716ef81b4 ./docs/OBSIDIAN-EXPORT.md
040010a807178d33797106e04716822f8d147d7d5833c1ba70865a1827f8484a ./docs/OPERATIONS.md
69ea49bc76690ac489aea908f4e784a14293e92d62478a240eabea53c5f90820 ./docs/QUALITY-REPLAY-example.json
bd2d3c43a09a89fefd0e844433064d7d7384a414179a754c6de94232d738fb04 ./docs/QUALITY-REPLAY.md
6d72b49b5b0ce93b94597cf5688403c468cd3b82f4d87470d80f108c5a7ec531 ./docs/RELEASE-v1.5.1.md
84b12cafd6320d781b99970cb48b7252da5aff94797d45af164f8a56dee94e5c ./docs/RELEASE-v1.5.2.md
be749a09ddbcd4cf427316c6fe531f138e9bfaa6c104f4c11a08f7555231e8b2 ./docs/UNIFIED-GRAPH.md
77d91565660789c621b05bd69fecfa3c2ef736f93855eca1f58b017e61c76f90 ./docs/VALIDATION-v1.5.0.md
3ea1953b2b4eb8f966310a65a12a681fb44e4eb3857b843312e235759aacfce5 ./docs/VALIDATION.md
@@ -151,8 +153,8 @@ cb3cde795233c0d4842dc7451c5855d1c071e459efbde3be0cabac32a837ba1b ./knowledge/14
7f1d67faf4a6cea0c41c84d7b275d8a8979b19420b52db4925d8e2cd71ead3e0 ./knowledge/16_office-aktivierung-und-lizenzierung.json
f1eab883370e0a40ef52a6b6d785a510d8ed48a19d25e0a2bc95f4f2cc8e329e ./knowledge/17_serienbriefe-und-dokumentfunktionen.json
5a0b3d5d5bc712e30a67a4de3432f69070f7e43dd99931f675e72c213006b363 ./knowledge/example-vpn.json
4fcf49ee06fc1aa712ade9fdca357ca29afa21ce888a6885f9040c876eee1082 ./mega-project.json
87ec7642014d4f20e1c75c6d01156c60eb0ed4e65c8cba42fbb4f1a33dee7039 ./patches/SHA256SUMS
d6626ebcf2bf97ecfca640489ea589dc96ed0c7cfa6d41dbab1a3008d04fd34e ./mega-project.json
565568c083ac68e28de7ce4d17625666bc009b8a2fb6bbcf8380c0cdb5ee2e20 ./patches/SHA256SUMS
47a6fa2c79bbba0c04af86dfa65d58529f492c698060fe586456c22a4eadb877 ./patches/glpi-agent-mega.diff
9b411c90d96a86c80f088ee4637046eeefaf31c62059d11aa1a999d6eb08b5b4 ./patches/glpi-knowledge-mega.diff
f0491de3cb6201f98ca6be8e865237adbba4772471f7fc7165d030e0c045fdb6 ./patches/neuroforge-mega.diff
@@ -165,6 +167,7 @@ f0491de3cb6201f98ca6be8e865237adbba4772471f7fc7165d030e0c045fdb6 ./patches/neur
d90be604acf6ecf5d821890d5864307853caf0e0678a3249eeaa1d29748a6ada ./patches/v1.4.3-to-v1.4.4.diff
01ba8cb6ea6ab0abd84295fb969fcfb93d841cd46ae1813fb0de9b23b2d5b22e ./patches/v1.4.4-to-v1.4.5.diff
617b434dcdc0dd0be5cdb15740661f0a513b330b99835364b508e21467f86ae4 ./patches/v1.4.5-to-v1.5.0.diff
b031ea42356a022d7bb03e7e2bda23c4ceddb66ccc59a5d1f0ebe787a790b829 ./patches/v1.5.1-to-v1.5.2.diff
564817f8edabde0c4e4a1a427a3aa5418aae7bf12e9463044a7e6e0f13973657 ./platform/neuroforge/.env.example
39319b6f2058e4c8d6656a9cf01675374f81a04075b956b093a2befb5e05ada4 ./platform/neuroforge/.gitignore
189486a885c7fb78e0eb878d93cda0c70ca6d7ff9bfdfb3f5f32487cf03a9688 ./platform/neuroforge/BENCHMARK-v0.5.0.md
@@ -205,7 +208,7 @@ fc993dc95fa49802ecb62994e4140dff18a27438e8a4f3c6352229c79b041710 ./platform/neu
995aa439cd162029b3e545d9270b3951285771553021ab3474256d59bee9d933 ./platform/neuroforge/internal/brain/brain.go
976288422c0c4116d8c98af8a9b164ac670f9caf03eddc2d5e2e48747456d3b4 ./platform/neuroforge/internal/brain/consolidation_test.go
7bea749edea9bfbe8cca9515aa05bc4d44eb6285a6e20a5b7276a73e55fab646 ./platform/neuroforge/internal/brain/goal_progress.go
47a6b750067ff7397318d3b0a8e1307aafd4fd862680324581fefc7e38c4597e ./platform/neuroforge/internal/brain/goal_progress_test.go
5418ac804bfc517a2e143536e8a8b0ae2a393576f58ad5dac5fe98287c3c5462 ./platform/neuroforge/internal/brain/goal_progress_test.go
359955653c647125559afd6dc3ebe69aa5ca19ff7e825ce801b7bc24e5fbcfcb ./platform/neuroforge/internal/brain/policy.go
27e87af473d2d71ba94ffb9bf7a70934776f8c23ce45496ca0998ad3000fc156 ./platform/neuroforge/internal/brain/policy_test.go
367e4964aee4c3b7dcda6a01ad105b3b909d102c1899b34b567144b9fcbac22d ./platform/neuroforge/internal/brain/research_quality.go
@@ -218,8 +221,8 @@ a9619d9571f6ab6363b36af9fdf1690f9f88e9333eb6f2773ae68580b19666d8 ./platform/neu
76319080d3faaf856e9fe5e1aac5e06be433d2f06cc6b152bac5f05dcc943fff ./platform/neuroforge/internal/brain/v5.go
816b725594ea5f6938999799394eaea9dd619addaeec5447d8e7707da4c28c69 ./platform/neuroforge/internal/brain/v5_cluster_test.go
cd8f1281e25ce42ae8918abbed16d4bf57ada9c3a3cc1212edd28f77ccc9328f ./platform/neuroforge/internal/brain/v6.go
1aed51425f83995a4e3f1fa1ab9fcb3c6247b0621b8678e6d29496d2823d70fc ./platform/neuroforge/internal/brain/v8.go
cc2ab445fbc69e64f2e4adafbab045828c80f00ca97170cd8cf80346b247509e ./platform/neuroforge/internal/brain/v8_test.go
aff60dc685221b10520f4dbc49e1440375a66edc57cd74d6e622a4503a0b6c25 ./platform/neuroforge/internal/brain/v8.go
31420af7145686a2c7eb42fc18ca1299604afcb5942cfdfd132bef5c5f05d980 ./platform/neuroforge/internal/brain/v8_test.go
cf6e43d8daa0461ec38dc725197aa851332caa466bf9e0414c02fa88c446e064 ./platform/neuroforge/internal/core/types.go
65a8b8196343e7cfd9444ca314da4a83c81bc046d9478b8b93217d9cc68ba562 ./platform/neuroforge/internal/cost/cost.go
b9bb2934e01ed2bfb6b16e4139e10e51f1387112eb8654f7f692779a2fd2d273 ./platform/neuroforge/internal/cost/cost_test.go

View File

@@ -1,6 +1,6 @@
# GLPI NeuroForge Mega v1.5.0
# GLPI NeuroForge Mega v1.5.2
> Release: **v1.5.0** · Stop-the-Line-Hardening für Trust Boundaries, Registry-Deployment, Readiness und transaktionales Knowledge-Staging.
> Release: **v1.5.2** · Research-Query-Hardening mit kompakten deterministischen Suchanfragen und automatischem SearXNG-Kategorie-Fallback.
Ein kontrolliertes Monorepo aus **GLPI AI Agent**, **GLPI AI Knowledgebase** und **NeuroForge + SQAR**. Ziel ist nicht ein untrennbarer Monolith, sondern eine gemeinsame Plattform mit klaren Zuständigkeiten, getrennten Credentials und nachvollziehbaren Failure-Modi.

View File

@@ -1 +1 @@
1.5.1
1.5.2

View File

@@ -33,7 +33,7 @@ services:
cap_drop:
- ALL
neuroforge:
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.2}
command:
- -data
- /app/data
@@ -99,7 +99,7 @@ services:
start_period: 15s
stop_grace_period: 35s
neuroforge-worker:
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge-worker:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge-worker:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.2}
command:
- -server
- http://neuroforge:8080
@@ -119,7 +119,7 @@ services:
cap_drop:
- ALL
agent-data-init:
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-agent-data-init:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-agent-data-init:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.2}
restart: 'no'
user: 0:0
volumes:
@@ -132,7 +132,7 @@ services:
- CHOWN
- FOWNER
agent:
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-agent:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-agent:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.2}
restart: unless-stopped
environment:
AI_CONTENT_LABEL_ENABLED: ${AI_CONTENT_LABEL_ENABLED:-}
@@ -353,7 +353,7 @@ services:
start_period: 10s
stop_grace_period: 20s
knowledge:
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-knowledge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-knowledge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.2}
restart: unless-stopped
environment:
APP_MODE: ${KB_APP_MODE:-editor}
@@ -394,7 +394,7 @@ services:
- ALL
stop_grace_period: 35s
control:
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-control:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-control:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.2}
restart: unless-stopped
environment:
CONTROL_ADDR: :8070

View File

@@ -0,0 +1,11 @@
# Migration v1.5.1 → v1.5.2
v1.5.2 is a drop-in research-planning fix. Persistent volumes and existing secrets are retained.
1. Build/publish the v1.5.2 images through the normal Gitea pipeline.
2. Set `IMAGE_TAG=1.5.2`.
3. `docker compose --profile research pull`
4. `docker compose --profile research up -d --force-recreate neuroforge neuroforge-worker`
5. Re-run the FortiClient SSLVPN 7200 goal and inspect `/api/v1/goals/{id}/research/live`.
Expected behavior: `query.planned` starts with `FortiClient SSLVPN 7200`; if the configured category mix produces no relevant result, `search.fallback` is emitted and a `general` search is used. Relevant Fortinet hits should proceed to `download.started` rather than being rejected before fetch.

18
docs/RELEASE-v1.5.2.md Normal file
View File

@@ -0,0 +1,18 @@
# GLPI NeuroForge Mega v1.5.2
v1.5.2 fixes a real autonomous-research failure observed with a narrow FortiClient SSLVPN 7200 goal. SearXNG itself returned relevant Fortinet sources for a focused query, while NeuroForge's deterministic planner sent the complete goal description and target as one oversized query. With a mixed `general,science,it` category configuration this allowed arXiv results to dominate the top-N; the strict relevance gate then correctly rejected every result, leaving the cycle with zero evidence.
## Fixes
- Deterministic goal research no longer sends `title + full description + target` to SearXNG when `autonomy.use_llm=false`.
- The first deterministic query is the compact goal title; an optional second query adds only a small number of non-generic description terms.
- Scheduler/process text remains excluded from all search queries.
- Goal-bound research retries once with SearXNG `category=general` when the configured category mix returns no goal-anchored result.
- The strict pre-fetch relevance gate remains fail-closed; it is not weakened to compensate for bad search ranking.
- Regression coverage reproduces the observed `general,science,it -> unrelated arXiv` result set and verifies that the general fallback returns and learns FortiClient 7200 evidence.
## Validation
All four Go modules pass `go test ./...`, `go vet ./...`, and `go build ./...`. Race checks pass for NeuroForge brain/store/http/research, Agent core/config/knowledge/learning/state, Knowledge staging/store/server, and Control.
The real Docker/SearXNG/Ollama host smoke remains a deployment-host gate.

View File

@@ -28,7 +28,7 @@
"schema": "Wiki/Schema.md",
"glpi_relations": "KnowbaseItem_Item when exposed by GLPI OpenAPI"
},
"version": "1.5.0",
"version": "1.5.2",
"controlled_learning": {
"raw_chat_auto_learning": false,
"validated_outcomes": [

View File

@@ -10,3 +10,4 @@ f0491de3cb6201f98ca6be8e865237adbba4772471f7fc7165d030e0c045fdb6 patches/neurof
d90be604acf6ecf5d821890d5864307853caf0e0678a3249eeaa1d29748a6ada patches/v1.4.3-to-v1.4.4.diff
01ba8cb6ea6ab0abd84295fb969fcfb93d841cd46ae1813fb0de9b23b2d5b22e patches/v1.4.4-to-v1.4.5.diff
617b434dcdc0dd0be5cdb15740661f0a513b330b99835364b508e21467f86ae4 patches/v1.4.5-to-v1.5.0.diff
b031ea42356a022d7bb03e7e2bda23c4ceddb66ccc59a5d1f0ebe787a790b829 patches/v1.5.1-to-v1.5.2.diff

View File

@@ -0,0 +1,470 @@
diff --git a/.env.example b/.env.example
index 5e8726a5df3df1aa912a9f79a1c33f3c39018216..29dccfe3cc0acceb42597be0217c2f7ddeae6752 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,5 @@
###############################################################################
-# GLPI NEUROFORGE MEGA v1.5.0 - VOLLSTÄNDIGE .ENV.example
+# GLPI NEUROFORGE MEGA v1.5.2 - VOLLSTÄNDIGE .ENV.example
#
# Diese Datei ist die zentrale Konfiguration für docker compose.
# Sie enthält:
@@ -29,7 +29,7 @@
# 01. MEGA STACK - RELEASE / HOST PORTS / PFADE
###############################################################################
# Immutable Registry-Tag der sechs Projekt-Images. "latest" ist produktiv verboten.
-IMAGE_TAG=1.5.0
+IMAGE_TAG=1.5.2
CONTROL_HOST_PORT=8070
AGENT_HOST_PORT=8080
diff --git a/README.md b/README.md
index a31b5e06dc5f385aa25e56479badfabd4b452599..432307f253bb5f4368a6da35e8209d1253d8e759 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-# GLPI NeuroForge Mega v1.5.0
+# GLPI NeuroForge Mega v1.5.2
-> Release: **v1.5.0** · Stop-the-Line-Hardening für Trust Boundaries, Registry-Deployment, Readiness und transaktionales Knowledge-Staging.
+> Release: **v1.5.2** · Research-Query-Hardening mit kompakten deterministischen Suchanfragen und automatischem SearXNG-Kategorie-Fallback.
Ein kontrolliertes Monorepo aus **GLPI AI Agent**, **GLPI AI Knowledgebase** und **NeuroForge + SQAR**. Ziel ist nicht ein untrennbarer Monolith, sondern eine gemeinsame Plattform mit klaren Zuständigkeiten, getrennten Credentials und nachvollziehbaren Failure-Modi.
diff --git a/VERSION b/VERSION
index 26ca594609a93d60b6543102d639fa76b50a6925..4cda8f19edc7ffa01ba13d5dbe4909a6dc2ce3c8 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.5.1
+1.5.2
diff --git a/docker-compose.yml b/docker-compose.yml
index 506ea8cd77f71ae3ad1a588897d408e872a998b3..1e82ef5fff7ad747c8f335f457fc7e08d3b9e5ed 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -33,7 +33,7 @@ services:
cap_drop:
- ALL
neuroforge:
- image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}
+ image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.2}
command:
- -data
- /app/data
@@ -99,7 +99,7 @@ services:
start_period: 15s
stop_grace_period: 35s
neuroforge-worker:
- image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge-worker:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}
+ image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge-worker:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.2}
command:
- -server
- http://neuroforge:8080
@@ -119,7 +119,7 @@ services:
cap_drop:
- ALL
agent-data-init:
- image: git.send.nrw/sendnrw/glpi-neuroforge-mega-agent-data-init:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}
+ image: git.send.nrw/sendnrw/glpi-neuroforge-mega-agent-data-init:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.2}
restart: 'no'
user: 0:0
volumes:
@@ -132,7 +132,7 @@ services:
- CHOWN
- FOWNER
agent:
- image: git.send.nrw/sendnrw/glpi-neuroforge-mega-agent:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}
+ image: git.send.nrw/sendnrw/glpi-neuroforge-mega-agent:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.2}
restart: unless-stopped
environment:
AI_CONTENT_LABEL_ENABLED: ${AI_CONTENT_LABEL_ENABLED:-}
@@ -353,7 +353,7 @@ services:
start_period: 10s
stop_grace_period: 20s
knowledge:
- image: git.send.nrw/sendnrw/glpi-neuroforge-mega-knowledge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}
+ image: git.send.nrw/sendnrw/glpi-neuroforge-mega-knowledge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.2}
restart: unless-stopped
environment:
APP_MODE: ${KB_APP_MODE:-editor}
@@ -394,7 +394,7 @@ services:
- ALL
stop_grace_period: 35s
control:
- image: git.send.nrw/sendnrw/glpi-neuroforge-mega-control:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}
+ image: git.send.nrw/sendnrw/glpi-neuroforge-mega-control:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.2}
restart: unless-stopped
environment:
CONTROL_ADDR: :8070
diff --git a/docs/MIGRATION-v1.5.1-to-v1.5.2.md b/docs/MIGRATION-v1.5.1-to-v1.5.2.md
new file mode 100644
index 0000000000000000000000000000000000000000..933409113f008ed6527cb896541dd1d6dde40e5b
--- /dev/null
+++ b/docs/MIGRATION-v1.5.1-to-v1.5.2.md
@@ -0,0 +1,11 @@
+# Migration v1.5.1 → v1.5.2
+
+v1.5.2 is a drop-in research-planning fix. Persistent volumes and existing secrets are retained.
+
+1. Build/publish the v1.5.2 images through the normal Gitea pipeline.
+2. Set `IMAGE_TAG=1.5.2`.
+3. `docker compose --profile research pull`
+4. `docker compose --profile research up -d --force-recreate neuroforge neuroforge-worker`
+5. Re-run the FortiClient SSLVPN 7200 goal and inspect `/api/v1/goals/{id}/research/live`.
+
+Expected behavior: `query.planned` starts with `FortiClient SSLVPN 7200`; if the configured category mix produces no relevant result, `search.fallback` is emitted and a `general` search is used. Relevant Fortinet hits should proceed to `download.started` rather than being rejected before fetch.
diff --git a/docs/RELEASE-v1.5.2.md b/docs/RELEASE-v1.5.2.md
new file mode 100644
index 0000000000000000000000000000000000000000..a135189c14feee103910b2c38a99282af82f56ab
--- /dev/null
+++ b/docs/RELEASE-v1.5.2.md
@@ -0,0 +1,18 @@
+# GLPI NeuroForge Mega v1.5.2
+
+v1.5.2 fixes a real autonomous-research failure observed with a narrow FortiClient SSLVPN 7200 goal. SearXNG itself returned relevant Fortinet sources for a focused query, while NeuroForge's deterministic planner sent the complete goal description and target as one oversized query. With a mixed `general,science,it` category configuration this allowed arXiv results to dominate the top-N; the strict relevance gate then correctly rejected every result, leaving the cycle with zero evidence.
+
+## Fixes
+
+- Deterministic goal research no longer sends `title + full description + target` to SearXNG when `autonomy.use_llm=false`.
+- The first deterministic query is the compact goal title; an optional second query adds only a small number of non-generic description terms.
+- Scheduler/process text remains excluded from all search queries.
+- Goal-bound research retries once with SearXNG `category=general` when the configured category mix returns no goal-anchored result.
+- The strict pre-fetch relevance gate remains fail-closed; it is not weakened to compensate for bad search ranking.
+- Regression coverage reproduces the observed `general,science,it -> unrelated arXiv` result set and verifies that the general fallback returns and learns FortiClient 7200 evidence.
+
+## Validation
+
+All four Go modules pass `go test ./...`, `go vet ./...`, and `go build ./...`. Race checks pass for NeuroForge brain/store/http/research, Agent core/config/knowledge/learning/state, Knowledge staging/store/server, and Control.
+
+The real Docker/SearXNG/Ollama host smoke remains a deployment-host gate.
diff --git a/mega-project.json b/mega-project.json
index 62b23f57728905388b5a1b86a8f301ef5f736de7..9c840c1deb461108a879aba189bb844b0981a8ed 100644
--- a/mega-project.json
+++ b/mega-project.json
@@ -28,7 +28,7 @@
"schema": "Wiki/Schema.md",
"glpi_relations": "KnowbaseItem_Item when exposed by GLPI OpenAPI"
},
- "version": "1.5.0",
+ "version": "1.5.2",
"controlled_learning": {
"raw_chat_auto_learning": false,
"validated_outcomes": [
diff --git a/platform/neuroforge/internal/brain/goal_progress_test.go b/platform/neuroforge/internal/brain/goal_progress_test.go
index e2c15177a251b1425af295abd6f6a94f6d01c862..6c96722c8d50543db9820cf2284dcf87f76be854 100644
--- a/platform/neuroforge/internal/brain/goal_progress_test.go
+++ b/platform/neuroforge/internal/brain/goal_progress_test.go
@@ -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)
+ }
}
}
diff --git a/platform/neuroforge/internal/brain/v8.go b/platform/neuroforge/internal/brain/v8.go
index da3f3f805a7137188c70b4928bdfb3e70c6e841f..bae1afd8faf92399f8da6905b9719824b9df6c47 100644
--- a/platform/neuroforge/internal/brain/v8.go
+++ b/platform/neuroforge/internal/brain/v8.go
@@ -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
}
+ 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, 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)
+ 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
}
- out := ResearchResult{Query: query, Results: results}
- var researchGoal *core.Goal
- if q.goalID != "" {
- if g, ok := e.store.GetGoal(q.goalID); ok {
- researchGoal = g
+ // 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)
- }
- queries := []string{base}
+ // 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 := 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 {
diff --git a/platform/neuroforge/internal/brain/v8_test.go b/platform/neuroforge/internal/brain/v8_test.go
index eadbf5c42fdab625840f67c52d6098554e9b161e..25c8ec4ca6b6e9477e47f4a7fb42a3e8d22f8e70 100644
--- a/platform/neuroforge/internal/brain/v8_test.go
+++ b/platform/neuroforge/internal/brain/v8_test.go
@@ -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{}

View File

@@ -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)
}
}
}

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 {

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{}