diff --git a/.env.example b/.env.example index 5e8726a..29dccfe 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/MANIFEST.sha256 b/MANIFEST.sha256 index 81c564c..7dadc33 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -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 diff --git a/README.md b/README.md index a31b5e0..432307f 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 26ca594..4cda8f1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.5.1 +1.5.2 diff --git a/docker-compose.yml b/docker-compose.yml index 506ea8c..1e82ef5 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 0000000..9334091 --- /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 0000000..a135189 --- /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 62b23f5..9c840c1 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/patches/SHA256SUMS b/patches/SHA256SUMS index 64d1fa4..46391b9 100644 --- a/patches/SHA256SUMS +++ b/patches/SHA256SUMS @@ -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 diff --git a/patches/v1.5.1-to-v1.5.2.diff b/patches/v1.5.1-to-v1.5.2.diff new file mode 100644 index 0000000..72edaba --- /dev/null +++ b/patches/v1.5.1-to-v1.5.2.diff @@ -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{} diff --git a/platform/neuroforge/internal/brain/goal_progress_test.go b/platform/neuroforge/internal/brain/goal_progress_test.go index e2c1517..6c96722 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 da3f3f8..bae1afd 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 } - 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 { diff --git a/platform/neuroforge/internal/brain/v8_test.go b/platform/neuroforge/internal/brain/v8_test.go index eadbf5c..25c8ec4 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{}