This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
11
docs/MIGRATION-v1.5.1-to-v1.5.2.md
Normal file
11
docs/MIGRATION-v1.5.1-to-v1.5.2.md
Normal 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
18
docs/RELEASE-v1.5.2.md
Normal 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.
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
470
patches/v1.5.1-to-v1.5.2.diff
Normal file
470
patches/v1.5.1-to-v1.5.2.diff
Normal 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{}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
Reference in New Issue
Block a user