diff --git a/.env.example b/.env.example index 29dccfe..3fd8d69 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ ############################################################################### -# GLPI NEUROFORGE MEGA v1.5.2 - VOLLSTÄNDIGE .ENV.example +# GLPI NEUROFORGE MEGA v1.5.3 - 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.2 +IMAGE_TAG=1.5.3 CONTROL_HOST_PORT=8070 AGENT_HOST_PORT=8080 diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 7dadc33..60eda55 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -1,9 +1,9 @@ 27dc46be5cbb1b171deff7fbd2f28bff1be802dff403797535fd8968bb98c8eb ./.cbmignore -db413beb17cc2a71be57ba39bdeeab1f380a51c806177b9df39881bb77f2d05f ./.env.example +effc9563ca91cf7356ede825a2f6e58cfb3ab7982db7747f5cddc71cc7071f15 ./.env.example ed22fda7661db8203563611dc144998161cd024e161b0471d615eaf0defeb7db ./.gitea/workflows/release-tag.yml e1ff71187cc3411a85067b964264011db7bd109a585ef7b9ea5b08bda039d813 ./.gitignore ccfc4c139345a69d05cd2b809b0d09b4332d98e5b4f79aace4db0b648e3ea814 ./Makefile -d3951624cd67488d07866d712a4294c6f5aac52b6e5c8e994a3fe179fd05a129 ./README.md +f15ee2669a665104420b0bfd3219912f291272f06074c196d8bfa2b0031d2e5b ./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 @@ d3951624cd67488d07866d712a4294c6f5aac52b6e5c8e994a3fe179fd05a129 ./README.md 61ceebe5a891388336795fda2fd0d1ad10373b4ebaae7d2e670f35766115c604 ./RELEASE-NOTES-v1.4.4.md 15a3defdd5bbf07ebaec1a2e8626347a027a3622f10afb508f7a21c9b0bdfa71 ./RELEASE-NOTES-v1.4.5.md d10b1dbd87585d72144d816449800dc17b6d7f8faba2dfb4eca93ae3def2c631 ./RELEASE-NOTES-v1.5.0.md -ded1b601e81d31ca1f65c4f3ccf2ebd7992b6390e37bc2a86f3837282f1e00cf ./VERSION +c03e3824001f39b9518249b8d6522422fc4de7227e12a568c0164662b03b7758 ./VERSION e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ./backups/.gitkeep 8127e9db5e5e0af1d88770dc8fa60b381de45dbcc843262698cf9501409b4d58 ./deploy/searxng/settings.yml 3e153ad540d6cb1b20275775f2d7c129ec0216764cc9d0d27b963c634f347b43 ./docker-compose.dev.yml -dcda7c439002ec6ff4254f38dbf5ee21bb39cc4b2fd74c6c37d6173531211c75 ./docker-compose.yml +fb70b76fe7f599334aba85d9bf10c89ae065e4979127cbe313bf158740a216c0 ./docker-compose.yml 0f2adaa0765ff00d9c3a1133840c7a768a011f9d8f820f8a340b43600c7649d8 ./docs/ARCHITECTURE.md 34e2c7ac5992389cc51b40f740c1e206f20bffc67bdf19cb59d94b5df67a7082 ./docs/BACKUP-RESTORE-v1.5.0.md 9467a3c0796a87bada0eab6ad191913e628b4abc05520cbb4b73fac76d334481 ./docs/CODEBASE-MEMORY-MCP.md @@ -38,12 +38,14 @@ b285d050223844f5fd05014c2278199eaccd7525fbbf8a6dae724ed87ced0ee8 ./docs/MIGRATI 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 +44219c5fe32a0d6d2c32719e243793a54f3b7624cb45a497cda78c4a443774ae ./docs/MIGRATION-v1.5.2-to-v1.5.3.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 +f0ab4fa6b353059acf6b41a1e5a484ebd698684102724eddf8f8b91d7ff321a3 ./docs/RELEASE-v1.5.3.md be749a09ddbcd4cf427316c6fe531f138e9bfaa6c104f4c11a08f7555231e8b2 ./docs/UNIFIED-GRAPH.md 77d91565660789c621b05bd69fecfa3c2ef736f93855eca1f58b017e61c76f90 ./docs/VALIDATION-v1.5.0.md 3ea1953b2b4eb8f966310a65a12a681fb44e4eb3857b843312e235759aacfce5 ./docs/VALIDATION.md @@ -153,8 +155,8 @@ cb3cde795233c0d4842dc7451c5855d1c071e459efbde3be0cabac32a837ba1b ./knowledge/14 7f1d67faf4a6cea0c41c84d7b275d8a8979b19420b52db4925d8e2cd71ead3e0 ./knowledge/16_office-aktivierung-und-lizenzierung.json f1eab883370e0a40ef52a6b6d785a510d8ed48a19d25e0a2bc95f4f2cc8e329e ./knowledge/17_serienbriefe-und-dokumentfunktionen.json 5a0b3d5d5bc712e30a67a4de3432f69070f7e43dd99931f675e72c213006b363 ./knowledge/example-vpn.json -d6626ebcf2bf97ecfca640489ea589dc96ed0c7cfa6d41dbab1a3008d04fd34e ./mega-project.json -565568c083ac68e28de7ce4d17625666bc009b8a2fb6bbcf8380c0cdb5ee2e20 ./patches/SHA256SUMS +4e80435353ee4aaf577886eae6fb118e3a3b657b0f19e362f3f59a586a683a40 ./mega-project.json +e96578cd54f549b8931dc389dd63477a2cd70a31f1dfbbd5bc75525b97633309 ./patches/SHA256SUMS 47a6fa2c79bbba0c04af86dfa65d58529f492c698060fe586456c22a4eadb877 ./patches/glpi-agent-mega.diff 9b411c90d96a86c80f088ee4637046eeefaf31c62059d11aa1a999d6eb08b5b4 ./patches/glpi-knowledge-mega.diff f0491de3cb6201f98ca6be8e865237adbba4772471f7fc7165d030e0c045fdb6 ./patches/neuroforge-mega.diff @@ -168,6 +170,7 @@ d90be604acf6ecf5d821890d5864307853caf0e0678a3249eeaa1d29748a6ada ./patches/v1.4 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 +5c799db6701dce8019aeead5bccef6fc5fdc869e30785853eaae8d0eaf7ea43b ./patches/v1.5.2-to-v1.5.3.diff 564817f8edabde0c4e4a1a427a3aa5418aae7bf12e9463044a7e6e0f13973657 ./platform/neuroforge/.env.example 39319b6f2058e4c8d6656a9cf01675374f81a04075b956b093a2befb5e05ada4 ./platform/neuroforge/.gitignore 189486a885c7fb78e0eb878d93cda0c70ca6d7ff9bfdfb3f5f32487cf03a9688 ./platform/neuroforge/BENCHMARK-v0.5.0.md @@ -208,12 +211,12 @@ 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 -5418ac804bfc517a2e143536e8a8b0ae2a393576f58ad5dac5fe98287c3c5462 ./platform/neuroforge/internal/brain/goal_progress_test.go +14731dde4df09f25dc677914db00570acc85e4b67a93de76df56587c51076d6f ./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 +9022a797aa1d49af20ced5d8c3e0b903be740fbaa7251e8a80d43cd04d2f68fe ./platform/neuroforge/internal/brain/research_quality.go 0a3c8f7d149e814e091595982dbaa69467f6bf11eec631471d133a9b21585ae4 ./platform/neuroforge/internal/brain/research_trace.go -0a2518c8a87406372dfed6093080945d785ef55a3bc2705d8ac22ad26a7c01da ./platform/neuroforge/internal/brain/staging.go +f4f6ef6f643ab35c117ac2e4346a1292e9a47e2501d469602b7105d5574a5b9f ./platform/neuroforge/internal/brain/staging.go d4fed8b68d31f6fbd1992bde76abd4e7b2f9d211e53bf8b234369a6a53692fe8 ./platform/neuroforge/internal/brain/v3.go 3ae13251512ecad1423a33ce09889f961130fefaed9342170b2d5cc6b3b51893 ./platform/neuroforge/internal/brain/v3_test.go 4bc58463b659bd7e51db4c7dbeba053de90fcb41f392a7d6e62a8cd84ddaa092 ./platform/neuroforge/internal/brain/v4.go @@ -227,8 +230,9 @@ cf6e43d8daa0461ec38dc725197aa851332caa466bf9e0414c02fa88c446e064 ./platform/neu 65a8b8196343e7cfd9444ca314da4a83c81bc046d9478b8b93217d9cc68ba562 ./platform/neuroforge/internal/cost/cost.go b9bb2934e01ed2bfb6b16e4139e10e51f1387112eb8654f7f692779a2fd2d273 ./platform/neuroforge/internal/cost/cost_test.go fb66ce4ab760b979eacd4f7f17dedc41916f93c53ba582be572d250f99654695 ./platform/neuroforge/internal/httpapi/admin_app_auth_test.go +eb2b8c599e4e0838d920296391ecaacdba1578c4f3a6a0a9e8469fdba31217ed ./platform/neuroforge/internal/httpapi/goal_duplicate_test.go 1e2cbeecce139a32d4a12137dea59d5071ed9dbebd26a2d9933c1737ae6a851a ./platform/neuroforge/internal/httpapi/httpapi.go -668eec30df3bc92fccbdacbd77effeab985189480fb39c48e3af0832e352de24 ./platform/neuroforge/internal/httpapi/index.html +680b7fdcb20ac76c9305dfb94e4ec7474526be7a44b56afb3db7e4a66c8e2c38 ./platform/neuroforge/internal/httpapi/index.html 20f4a6cc30d5ce84ceed4fbdfc9f5c57ab5f82274e0c54464817d571e0e881b0 ./platform/neuroforge/internal/httpapi/integration.go c346dd0205548f7cdb00ea026f1afd82d59f87824eb983ebe84abfa7cb078974 ./platform/neuroforge/internal/httpapi/integration_api_test.go f8cfcc7a2bc781394231c25e36e75c41563f5250a1387e5687880d2591efd93b ./platform/neuroforge/internal/httpapi/integration_graph.go @@ -244,7 +248,7 @@ c40412c77dd8635bbd4bf9c6d7eb11b0d7902aee896bdd1ee99e5cdfb72c7453 ./platform/neu b3b1f5cfd6b0ec04341978898d72856af03a95b8922daf9864caeb27e37f1925 ./platform/neuroforge/internal/httpapi/research_live.go b37f7ec3be867666999a9ec314139dd1bc4c74efb53edfa224b4fe8119858aac ./platform/neuroforge/internal/httpapi/research_live_test.go 4e65ab0a760369a6d6c4932c180ff6c67f0768d91e08ccf471f53c6ecd5d09be ./platform/neuroforge/internal/httpapi/status_dashboard_test.go -cccfebd3fdac137cc7334b7059c8ecb53951056499e5b8895f2efa38584178ee ./platform/neuroforge/internal/httpapi/v3.go +3eb47f1b666336efe952961c19a1f46a7475acff8fe4dd7a59ade000aa1b9466 ./platform/neuroforge/internal/httpapi/v3.go b1587e066b56ef72f17162583698614d31c413e2ac7e75359b731c2e882a50ee ./platform/neuroforge/internal/httpapi/v4.go 1ef5cacb0652f2a6301c57f8987dd848fbe590abb73cab44899d9152ccce45d0 ./platform/neuroforge/internal/httpapi/v5.go 1d534726d7a75f21f7ad28a959f73a3402c021f7f4a1b554f0600cf3abf669d1 ./platform/neuroforge/internal/httpapi/v6.go @@ -262,6 +266,7 @@ e890897c662b070997c3b18134bafbee5bf9b3fa5220045ac09dc6e3dfd4ac41 ./platform/neu 8e7a029a904ca1c22821c5e4871c72958f0d2645786871d75f3e00c186b00778 ./platform/neuroforge/internal/store/cluster.go 6637e541f61a58f624baca31d972095ea01a95491ddb30492d9ac055740213c5 ./platform/neuroforge/internal/store/diskann.go 1858ed8045b16282de815a6668ff6027c7a3a3795e38659c753c504d0f2a8383 ./platform/neuroforge/internal/store/diskann_test.go +366e3b2f29b4abb1997aceb9c2b934ebe8f4ee44605b54167c70b8ddad683c7c ./platform/neuroforge/internal/store/goal_duplicate_test.go d49925fda3cbe3ceeffde3369073bce5a5bc67a23defb6827e27cd4a7f4c59f5 ./platform/neuroforge/internal/store/index_segments.go 478efaeb260cb5e970af8b3bb99f232ef846f54c6e4ff9deb5499df6e008e1c1 ./platform/neuroforge/internal/store/index_segments_test.go bea84ff9bd7812249ada186b97be58766a37d319f4d986252a0fd43e399a3dc2 ./platform/neuroforge/internal/store/knowledge.go @@ -282,7 +287,7 @@ ea968456538367ed536c212707209a173098ec65823152a0813422bc8071c952 ./platform/neu 61cd82b106e74f2aa0a7591d757a0872491a635dc1fccc78c66d41c052d0c9d2 ./platform/neuroforge/internal/store/sqar_vector.go d8f9d45c6675fdd6dbc04b8c1ca5173c2e013024676ccab3a6594572f30a51d7 ./platform/neuroforge/internal/store/store.go f86b648e8b4845ab83e672ae3fcfb0bcb0dc1bf891abf4bd30d380304f0b259a ./platform/neuroforge/internal/store/tiering.go -55f8e410258a37b4da1175753342017b94481a77142f9015a8b1f9a99b34d36a ./platform/neuroforge/internal/store/v3.go +2138e467981a3c814d1e88842dfbd470b76333898614543cafb4f93ea8b9beaa ./platform/neuroforge/internal/store/v3.go 091e4c9cacfc76786a76f6afc4eb2b697e5a641e611475b6dab97e99371360ea ./platform/neuroforge/internal/store/v3_test.go 7f0bca5419a895258b7921e940c3fd69cce04dedcb0b9d8f1127cd24b9c9e201 ./platform/neuroforge/internal/store/v5_test.go b3a0115907719a8a7aa9e6b244eec55be71a2535ba20db65907987d5fb78dc77 ./platform/neuroforge/internal/store/vector_journal.go diff --git a/README.md b/README.md index 432307f..cd2ebb9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# GLPI NeuroForge Mega v1.5.2 +# GLPI NeuroForge Mega v1.5.3 -> Release: **v1.5.2** · Research-Query-Hardening mit kompakten deterministischen Suchanfragen und automatischem SearXNG-Kategorie-Fallback. +> Release: **v1.5.3** · Research-Relevanz-Hardening, nachvollziehbare Draft-Belegzahlen und Schutz vor doppelten aktiven Goals. 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 4cda8f1..8af85be 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.5.2 +1.5.3 diff --git a/docker-compose.yml b/docker-compose.yml index 1e82ef5..aca2e75 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.2} + image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.3} 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.2} + image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge-worker:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.3} 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.2} + 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.3} 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.2} + image: git.send.nrw/sendnrw/glpi-neuroforge-mega-agent:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.3} 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.2} + image: git.send.nrw/sendnrw/glpi-neuroforge-mega-knowledge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.3} 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.2} + image: git.send.nrw/sendnrw/glpi-neuroforge-mega-control:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.3} restart: unless-stopped environment: CONTROL_ADDR: :8070 diff --git a/docs/MIGRATION-v1.5.2-to-v1.5.3.md b/docs/MIGRATION-v1.5.2-to-v1.5.3.md new file mode 100644 index 0000000..5eeb24f --- /dev/null +++ b/docs/MIGRATION-v1.5.2-to-v1.5.3.md @@ -0,0 +1,13 @@ +# Migration v1.5.2 → v1.5.3 + +v1.5.3 is a drop-in quality-gate update. Persistent volumes and existing secrets are retained. + +1. Build/publish the v1.5.3 images through the normal Gitea pipeline. +2. Set `IMAGE_TAG=1.5.3`. +3. Run `docker compose --profile research pull`. +4. Recreate NeuroForge and its worker with `docker compose --profile research up -d --force-recreate neuroforge neuroforge-worker`. +5. Remove/reject any previously generated staging draft whose sources are not actually relevant to the goal. The upgrade intentionally does not delete human-review artifacts automatically. +6. If duplicate active goals already exist, keep the desired goal and delete the duplicate through the UI/API. New identical active goals are rejected with HTTP 409. +7. Re-run the narrow FortiClient SSLVPN 7200 test. Sources that merely contain `sslvpn`, or a longer identifier containing `7200` as a substring, must be rejected before evidence ingestion. + +Expected audit behavior: staging `research_evidence` and `research_sources` match the actual `research_evidence_ids` / `research_source_uris` used for the draft; aggregate goal totals are available in the new `research_goal_*` fields. diff --git a/docs/RELEASE-v1.5.3.md b/docs/RELEASE-v1.5.3.md new file mode 100644 index 0000000..2de8c43 --- /dev/null +++ b/docs/RELEASE-v1.5.3.md @@ -0,0 +1,21 @@ +# GLPI NeuroForge Mega v1.5.3 + +v1.5.3 hardens the research-quality gate after a live FortiClient SSLVPN 7200 test exposed two remaining failure modes: generic Docker Hub pages could pass relevance because a single goal anchor or a numeric substring was sufficient, and two identical active goals could create two independent staging drafts because their integration keys correctly differ by goal ID. + +## Fixes + +- Goal relevance is token-aware instead of unrestricted substring matching. +- Numeric anchors such as error code `7200` must occur as exact normalized tokens; values such as `72007bf3` no longer match. +- A goal containing a numeric/error-code anchor also requires at least one lexical product/topic anchor. +- Multi-anchor nonnumeric goals require at least two independent title anchors instead of any single generic overlap. +- `SSLVPN` still matches source wording such as `SSL VPN` through bounded adjacent-token compaction. +- Identical non-completed goal fingerprints (title + description + target) are rejected atomically by the store; the HTTP API returns `409 Conflict`. +- The Goal UI disables the create button while the POST is in flight, closing the common double-click path in addition to the backend guard. +- Staging metadata now distinguishes the evidence actually supplied to the synthesizer from aggregate goal history: `research_evidence` and `research_sources` describe the selected draft inputs, while `research_goal_evidence` / `research_goal_sources` preserve the totals. +- Regression coverage includes the observed Docker Hub false positives and duplicate-goal creation. + +## Validation + +NeuroForge passes its complete test suite after the changes. The full Mega release gate additionally covers test/vet/build for all four Go modules, targeted race checks, static Compose preflight and secret scanning before packaging. + +Existing bad staging drafts are not deleted automatically. Human-review content remains operator-controlled; delete/reject drafts that were generated by older relevance rules. diff --git a/mega-project.json b/mega-project.json index 9c840c1..d12824a 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.2", + "version": "1.5.3", "controlled_learning": { "raw_chat_auto_learning": false, "validated_outcomes": [ diff --git a/patches/SHA256SUMS b/patches/SHA256SUMS index 46391b9..af63905 100644 --- a/patches/SHA256SUMS +++ b/patches/SHA256SUMS @@ -1,13 +1,14 @@ -47a6fa2c79bbba0c04af86dfa65d58529f492c698060fe586456c22a4eadb877 patches/glpi-agent-mega.diff -9b411c90d96a86c80f088ee4637046eeefaf31c62059d11aa1a999d6eb08b5b4 patches/glpi-knowledge-mega.diff -f0491de3cb6201f98ca6be8e865237adbba4772471f7fc7165d030e0c045fdb6 patches/neuroforge-mega.diff -576e75ca9191bbef7f136abde90c10a1c6bf2e36450a421fd3796e988bd2af00 patches/v1.1.0-to-v1.2.0.diff -7a5e0ae1d09b268d3ac62a51f92bfedf6771be26b909605a347f26d47d6fa8cb patches/v1.2.0-to-v1.3.0.diff -163a8a990aacd986bacb385494c2b2062ac10a9a8f0a456d19a6d1746c2fc500 patches/v1.3.0-to-v1.4.0.diff -654de4aac7cf318680872897c3de192afbafb61c7169acb8e7514116df86efde patches/v1.4.0-envfix-to-v1.4.1.diff -1ebc306bd6870389500c86cf07dcd677b5bd0448c762812c7e1c0f8732f36eb1 patches/v1.4.1-to-v1.4.2.diff -651c883facddfd0316f89e3cf3c24fd913371fcec2a5f447b8d105fa5dec463e patches/v1.4.2-to-v1.4.3.diff -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 +47a6fa2c79bbba0c04af86dfa65d58529f492c698060fe586456c22a4eadb877 glpi-agent-mega.diff +9b411c90d96a86c80f088ee4637046eeefaf31c62059d11aa1a999d6eb08b5b4 glpi-knowledge-mega.diff +f0491de3cb6201f98ca6be8e865237adbba4772471f7fc7165d030e0c045fdb6 neuroforge-mega.diff +576e75ca9191bbef7f136abde90c10a1c6bf2e36450a421fd3796e988bd2af00 v1.1.0-to-v1.2.0.diff +7a5e0ae1d09b268d3ac62a51f92bfedf6771be26b909605a347f26d47d6fa8cb v1.2.0-to-v1.3.0.diff +163a8a990aacd986bacb385494c2b2062ac10a9a8f0a456d19a6d1746c2fc500 v1.3.0-to-v1.4.0.diff +654de4aac7cf318680872897c3de192afbafb61c7169acb8e7514116df86efde v1.4.0-envfix-to-v1.4.1.diff +1ebc306bd6870389500c86cf07dcd677b5bd0448c762812c7e1c0f8732f36eb1 v1.4.1-to-v1.4.2.diff +651c883facddfd0316f89e3cf3c24fd913371fcec2a5f447b8d105fa5dec463e v1.4.2-to-v1.4.3.diff +d90be604acf6ecf5d821890d5864307853caf0e0678a3249eeaa1d29748a6ada v1.4.3-to-v1.4.4.diff +01ba8cb6ea6ab0abd84295fb969fcfb93d841cd46ae1813fb0de9b23b2d5b22e v1.4.4-to-v1.4.5.diff +617b434dcdc0dd0be5cdb15740661f0a513b330b99835364b508e21467f86ae4 v1.4.5-to-v1.5.0.diff +b031ea42356a022d7bb03e7e2bda23c4ceddb66ccc59a5d1f0ebe787a790b829 v1.5.1-to-v1.5.2.diff +5c799db6701dce8019aeead5bccef6fc5fdc869e30785853eaae8d0eaf7ea43b v1.5.2-to-v1.5.3.diff diff --git a/patches/v1.5.2-to-v1.5.3.diff b/patches/v1.5.2-to-v1.5.3.diff new file mode 100644 index 0000000..c6a1a31 --- /dev/null +++ b/patches/v1.5.2-to-v1.5.3.diff @@ -0,0 +1,396 @@ +diff --git a/.env.example b/.env.example +index 29dccfe3cc0acceb42597be0217c2f7ddeae6752..3fd8d691392605c5b0971b3758c1d91286cadbe6 100644 +--- a/.env.example ++++ b/.env.example +@@ -1,5 +1,5 @@ + ############################################################################### +-# GLPI NEUROFORGE MEGA v1.5.2 - VOLLSTÄNDIGE .ENV.example ++# GLPI NEUROFORGE MEGA v1.5.3 - 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.2 ++IMAGE_TAG=1.5.3 + + CONTROL_HOST_PORT=8070 + AGENT_HOST_PORT=8080 +diff --git a/README.md b/README.md +index 432307f253bb5f4368a6da35e8209d1253d8e759..cd2ebb9bf588270fa3e5b02a12a8f96dc0091c16 100644 +--- a/README.md ++++ b/README.md +@@ -1,6 +1,6 @@ +-# GLPI NeuroForge Mega v1.5.2 ++# GLPI NeuroForge Mega v1.5.3 + +-> Release: **v1.5.2** · Research-Query-Hardening mit kompakten deterministischen Suchanfragen und automatischem SearXNG-Kategorie-Fallback. ++> Release: **v1.5.3** · Research-Relevanz-Hardening, nachvollziehbare Draft-Belegzahlen und Schutz vor doppelten aktiven Goals. + + 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 4cda8f19edc7ffa01ba13d5dbe4909a6dc2ce3c8..8af85beb5159909f7df13705826e8c937f2b050b 100644 +--- a/VERSION ++++ b/VERSION +@@ -1 +1 @@ +-1.5.2 ++1.5.3 +diff --git a/docker-compose.yml b/docker-compose.yml +index 1e82ef5fff7ad747c8f335f457fc7e08d3b9e5ed..aca2e75cd7b856d95435ac477327313cd02a9ed9 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.2} ++ image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.3} + 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.2} ++ image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge-worker:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.3} + 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.2} ++ 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.3} + 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.2} ++ image: git.send.nrw/sendnrw/glpi-neuroforge-mega-agent:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.3} + 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.2} ++ image: git.send.nrw/sendnrw/glpi-neuroforge-mega-knowledge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.3} + 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.2} ++ image: git.send.nrw/sendnrw/glpi-neuroforge-mega-control:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.3} + restart: unless-stopped + environment: + CONTROL_ADDR: :8070 +diff --git a/mega-project.json b/mega-project.json +index 9c840c1deb461108a879aba189bb844b0981a8ed..d12824a263e59d507a344f0751755775a2ba4b94 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.2", ++ "version": "1.5.3", + "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 6c96722c8d50543db9820cf2284dcf87f76be854..19d5edb71c119233ff8390dd8a9214c179f9cb79 100644 +--- a/platform/neuroforge/internal/brain/goal_progress_test.go ++++ b/platform/neuroforge/internal/brain/goal_progress_test.go +@@ -76,6 +76,16 @@ func TestGoalResearchPublishesIdempotentHumanReviewDraft(t *testing.T) { + if body["answer"] == "" { + t.Fatalf("empty answer") + } ++ meta, ok := body["metadata"].(map[string]any) ++ if !ok { ++ t.Fatalf("missing metadata: %#v", body["metadata"]) ++ } ++ if meta["research_evidence"] != float64(1) || meta["research_sources"] != float64(1) { ++ t.Fatalf("draft counters must reflect selected evidence: %#v", meta) ++ } ++ if meta["research_goal_evidence"] != float64(13) || meta["research_goal_sources"] != float64(5) { ++ t.Fatalf("goal totals must remain auditable: %#v", meta) ++ } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"staging": map[string]any{"key": "KB-AI-STAGING-1", "meta": map[string]any{"integration_action": "created"}}}) + })) +@@ -103,7 +113,7 @@ func TestGoalResearchPublishesIdempotentHumanReviewDraft(t *testing.T) { + + e := &Engine{store: s, http: kb.Client()} + e.ConfigureStagingPublisher(StagingPublisherConfig{Enabled: true, URL: kb.URL, Token: "secret", MinEvidence: 1, MinSources: 1, SynthesisMode: "evidence"}) +- g := &core.Goal{ID: "goal-1", Title: "NVIDIA", ResearchEvidence: 1, ResearchSources: 1, ResearchSourceIDs: []string{src.ID}} ++ g := &core.Goal{ID: "goal-1", Title: "NVIDIA", ResearchEvidence: 13, ResearchSources: 5, ResearchSourceIDs: []string{src.ID}} + e.maybePublishGoalDraft(context.Background(), g, ResearchResult{RunID: run.ID}) + if requests != 1 || g.StagingDraftsCreated != 1 || g.LastStagingDraftID == "" || g.LastStagingError != "" { + t.Fatalf("goal=%#v requests=%d", g, requests) +@@ -289,3 +299,45 @@ func TestGoalArticleTargetUsesCreatedStagingArticles(t *testing.T) { + t.Fatalf("article target must count articles, got progress=%f reason=%q", g.Progress, g.ProgressReason) + } + } ++ ++func TestResearchMaterialRelevanceRequiresExactErrorCodeAndSubjectAnchor(t *testing.T) { ++ g := &core.Goal{Title: "FortiClient SSLVPN 7200"} ++ ++ for _, tc := range []struct { ++ name string ++ parts []string ++ relevant bool ++ }{ ++ { ++ name: "official fortinet title with exact code", ++ parts: []string{"Troubleshooting Tip: FortiClient error 'Credentials or SSLVPN configuration is wrong. (-7200)'", "FortiClient SSL VPN authentication troubleshooting"}, ++ relevant: true, ++ }, ++ { ++ name: "split ssl vpn plus exact code", ++ parts: []string{"Technical Tip: Credential or SSL VPN configuration is wrong (-7200)", "SSL VPN authentication rule troubleshooting"}, ++ relevant: true, ++ }, ++ { ++ name: "docker id only contains 7200 as substring", ++ parts: []string{"Docker image", "https://hub.docker.com/r/cffork8s/72007bf3-214d-4f19-a618-f74b145ca3a5", "generic container image"}, ++ relevant: false, ++ }, ++ { ++ name: "generic sslvpn without error code", ++ parts: []string{"baselibrary/sslvpn", "VPN_TYPE=fortinet NET_ADMIN /dev/ppp"}, ++ relevant: false, ++ }, ++ { ++ name: "exact code without product subject", ++ parts: []string{"Build 7200 released", "unrelated software package"}, ++ relevant: false, ++ }, ++ } { ++ t.Run(tc.name, func(t *testing.T) { ++ if got := researchMaterialRelevant(g, tc.parts...); got != tc.relevant { ++ t.Fatalf("researchMaterialRelevant=%v want %v for %#v", got, tc.relevant, tc.parts) ++ } ++ }) ++ } ++} +diff --git a/platform/neuroforge/internal/brain/research_quality.go b/platform/neuroforge/internal/brain/research_quality.go +index 328c000b75d270ace41b9170a2c76a39b870cb83..541a68d982368beef3eb2a7a755e87a451233a73 100644 +--- a/platform/neuroforge/internal/brain/research_quality.go ++++ b/platform/neuroforge/internal/brain/research_quality.go +@@ -66,13 +66,83 @@ func researchMaterialRelevant(goal *core.Goal, parts ...string) bool { + if len(anchors) == 0 { + return true + } +- haystack := strings.ToLower(strings.Join(parts, "\n")) +- for _, tok := range anchors { +- if strings.Contains(haystack, tok) { +- return true ++ ++ // Relevance is intentionally token-aware and requires coverage of more than ++ // one anchor for multi-part goals. The previous substring-any rule allowed ++ // false positives such as goal code "7200" matching a Docker repository id ++ // beginning with "72007...", or a generic page containing only "sslvpn". ++ // Numeric anchors (error codes, versions, model numbers) must match an exact ++ // normalized token and, when a lexical anchor exists, be accompanied by at ++ // least one subject/product anchor. ++ words := normalizedResearchWords(parts...) ++ wordSet := make(map[string]bool, len(words)) ++ for _, w := range words { ++ wordSet[w] = true ++ } ++ // Also index compact adjacent word pairs/triples so an anchor such as ++ // "sslvpn" matches source text written as "SSL VPN" without falling back to ++ // unrestricted substring matching. ++ compact := make(map[string]bool, len(words)*2) ++ for i := range words { ++ if i+1 < len(words) { ++ compact[words[i]+words[i+1]] = true ++ } ++ if i+2 < len(words) { ++ compact[words[i]+words[i+1]+words[i+2]] = true + } + } +- return false ++ ++ lexicalTotal, lexicalMatches := 0, 0 ++ numericTotal, numericMatches := 0, 0 ++ for _, anchor := range anchors { ++ if allDigits(anchor) { ++ numericTotal++ ++ if wordSet[anchor] { ++ numericMatches++ ++ } ++ continue ++ } ++ lexicalTotal++ ++ if wordSet[anchor] || compact[anchor] { ++ lexicalMatches++ ++ } ++ } ++ ++ if numericTotal > 0 { ++ if numericMatches == 0 { ++ return false ++ } ++ if lexicalTotal > 0 && lexicalMatches == 0 { ++ return false ++ } ++ return true ++ } ++ if lexicalTotal <= 1 { ++ return lexicalMatches == lexicalTotal ++ } ++ return lexicalMatches >= 2 ++} ++ ++func normalizedResearchWords(parts ...string) []string { ++ normalized := strings.Map(func(r rune) rune { ++ if unicode.IsLetter(r) || unicode.IsDigit(r) { ++ return unicode.ToLower(r) ++ } ++ return ' ' ++ }, strings.Join(parts, "\n")) ++ return strings.Fields(normalized) ++} ++ ++func allDigits(s string) bool { ++ if s == "" { ++ return false ++ } ++ for _, r := range s { ++ if !unicode.IsDigit(r) { ++ return false ++ } ++ } ++ return true + } + + func goalIDFromTags(tags []string) string { +diff --git a/platform/neuroforge/internal/brain/staging.go b/platform/neuroforge/internal/brain/staging.go +index 801b1ec8957a08392ffaff47a6f7a8f447d503f3..fcea5df063d7c2ec098aae09ec6ddc0f5f0c216f 100644 +--- a/platform/neuroforge/internal/brain/staging.go ++++ b/platform/neuroforge/internal/brain/staging.go +@@ -138,14 +138,19 @@ func (e *Engine) maybePublishGoalDraft(ctx context.Context, goal *core.Goal, res + } + } + draft.Metadata = map[string]any{ +- "research_goal_id": goal.ID, +- "research_run_id": research.RunID, +- "research_evidence": goal.ResearchEvidence, +- "research_sources": goal.ResearchSources, +- "research_corroborations": goal.ResearchCorroborations, +- "research_evidence_ids": evidenceIDs, +- "research_source_uris": sourceURIs, +- "human_review_required": true, ++ "research_goal_id": goal.ID, ++ "research_run_id": research.RunID, ++ // Draft-level counters describe the evidence actually supplied to the ++ // synthesizer. Goal totals are preserved separately for audit/history. ++ "research_evidence": len(evidenceIDs), ++ "research_sources": len(sourceURIs), ++ "research_corroborations": goal.ResearchCorroborations, ++ "research_goal_evidence": goal.ResearchEvidence, ++ "research_goal_sources": goal.ResearchSources, ++ "research_goal_corroborations": goal.ResearchCorroborations, ++ "research_evidence_ids": evidenceIDs, ++ "research_source_uris": sourceURIs, ++ "human_review_required": true, + } + body, _ := json.Marshal(draft) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.URL, bytes.NewReader(body)) +diff --git a/platform/neuroforge/internal/httpapi/index.html b/platform/neuroforge/internal/httpapi/index.html +index 3fc4fd742602c5cdc907c38edd371ddd3cf2987f..2909d9bef560a7a3fc623efdd97f0ac9cb2dd2d2 100644 +--- a/platform/neuroforge/internal/httpapi/index.html ++++ b/platform/neuroforge/internal/httpapi/index.html +@@ -226,7 +226,7 @@ function openGoalResearch(id,title){researchWatch.goalId=id;researchWatch.goalTi + $('liveCloseBtn').addEventListener('click',()=>{$('goalResearchLive').classList.remove('show');stopResearchPolling()});$('liveRefreshBtn').addEventListener('click',()=>{researchWatch.after=0;researchWatch.events=[];researchWatch.runId='';pollGoalResearch();loadResearchHistory()}); + async function loadGoals(){try{let xs=await req('/api/v1/goals');xs=Array.isArray(xs)?xs:[];$('goalsList').innerHTML=xs.length?xs.map(g=>{let active=g.status==='active',paused=g.status==='paused';return `
${esc(g.description||'')}
Nächste Aktion: ${esc(g.next_action)}
`:''}${g.last_staging_error?`Staging: ${esc(g.last_staging_error)}
`:''}${g.last_error?`${esc(g.last_error)}
`:''}Noch keine Ziele.
';$$('[data-goal-action]').forEach(b=>b.addEventListener('click',()=>goalAction(b)));$$('[data-research-live]').forEach(b=>b.addEventListener('click',()=>openGoalResearch(b.dataset.researchLive,b.dataset.goalTitle)));$$('[data-focus]').forEach(b=>b.addEventListener('click',()=>{switchView('knowledge');$('knowledgeQuery').value='goal:'+b.dataset.focus;searchKnowledge()}))}catch(e){toast(e.message,true)}} + async function goalAction(b){let id=b.dataset.goalId,action=b.dataset.goalAction;if(action==='delete'&&!confirm(`Ziel „${b.dataset.goalTitle||id}“ wirklich löschen? Bereits gelerntes Wissen bleibt erhalten.`))return;if(action==='cycle')openGoalResearch(id,b.dataset.goalTitle||id);let old=b.textContent;try{b.disabled=true;b.textContent=action==='cycle'?'läuft …':action==='delete'?'lösche …':'speichere …';let path='/api/v1/goals/'+encodeURIComponent(id)+(action==='cycle'?'/cycle':action==='pause'?'/pause':action==='resume'?'/resume':'');let d=await req(path,{method:action==='delete'?'DELETE':'POST',body:action==='delete'?undefined:'{}'});if(action==='cycle')toast(`Zyklus fertig · Evaluation ${Number(d.evaluation).toFixed(2)} · ${d.sources_ingested||0} Quellen`);else if(action==='pause')toast('Ziel pausiert. Der Scheduler verarbeitet es nicht weiter.');else if(action==='resume')toast('Ziel wieder aktiv. Nächster Lauf wurde eingeplant.');else{toast('Ziel gelöscht. Bereits gelerntes Wissen bleibt erhalten.');if(researchWatch.goalId===id){$('goalResearchLive').classList.remove('show');stopResearchPolling();researchWatch.goalId=''}}await loadGoals();refreshAll()}catch(e){toast(e.message,true)}finally{b.disabled=false;b.textContent=old}} +-$('goalCreateBtn').addEventListener('click',async()=>{let title=$('goalTitle').value.trim();if(!title)return toast('Titel fehlt.',true);try{let g=await req('/api/v1/goals',{method:'POST',body:JSON.stringify({title,description:$('goalDesc').value,target:$('goalTarget').value,priority:Number($('goalPriority').value||50),interval_minutes:Number($('goalInterval').value||10),status:'active'})});toast(`Ziel ${g.title} angelegt – erster Lauf ist eingeplant.`);$('goalTitle').value='';$('goalDesc').value='';loadGoals();refreshAll()}catch(e){toast(e.message,true)}});$('runAutonomyBtn').addEventListener('click',async()=>{try{let d=await req('/admin/api/autonomy',{method:'POST',body:'{}'});toast(`${d.cycles?.length||0} fällige Zyklen ausgeführt.`);loadGoals()}catch(e){toast(e.message,true)}}); ++$('goalCreateBtn').addEventListener('click',async()=>{let title=$('goalTitle').value.trim();if(!title)return toast('Titel fehlt.',true);let b=$('goalCreateBtn'),old=b.textContent;try{b.disabled=true;b.textContent='lege an …';let g=await req('/api/v1/goals',{method:'POST',body:JSON.stringify({title,description:$('goalDesc').value,target:$('goalTarget').value,priority:Number($('goalPriority').value||50),interval_minutes:Number($('goalInterval').value||10),status:'active'})});toast(`Ziel ${g.title} angelegt – erster Lauf ist eingeplant.`);$('goalTitle').value='';$('goalDesc').value='';await loadGoals();refreshAll()}catch(e){toast(e.message,true)}finally{b.disabled=false;b.textContent=old}});$('runAutonomyBtn').addEventListener('click',async()=>{try{let d=await req('/admin/api/autonomy',{method:'POST',body:'{}'});toast(`${d.cycles?.length||0} fällige Zyklen ausgeführt.`);loadGoals()}catch(e){toast(e.message,true)}}); + + // ---- Chat ---- + $('chatBtn').addEventListener('click',async()=>{let input=document.getElementById('chatPrompt').value.trim();if(!input)return toast('Nachricht fehlt.',true);chatAbort=new AbortController();$('chatAbortBtn').disabled=false;$('chatBtn').disabled=true;let start=Date.now(),timer=setInterval(()=>$('chatElapsed').textContent=Math.round((Date.now()-start)/1000)+' s',1000);$('chatOutput').textContent='Ollama / NeuroForge arbeitet …';try{let r=await fetch('/api/v1/chat',{method:'POST',headers:tokenHeaders(),signal:chatAbort.signal,body:JSON.stringify({session_id:$('chatSession').value,input,provider:$('chatProvider').value})}),txt=await r.text(),d;try{d=JSON.parse(txt)}catch{d=txt}if(!r.ok)throw new Error(d.error||d);$('chatOutput').textContent=(d.answer||'')+'\n\n— '+(d.provider||'')+' · '+(d.model||'')+' · Recall '+(d.recalled?.length||0)+' · Reward '+Number(d.auto_reward||0).toFixed(3);refreshAll()}catch(e){$('chatOutput').textContent=e.name==='AbortError'?'Abgebrochen.':e.message}finally{clearInterval(timer);$('chatElapsed').textContent='';$('chatAbortBtn').disabled=true;$('chatBtn').disabled=false;chatAbort=null}});$('chatAbortBtn').addEventListener('click',()=>chatAbort?.abort()); +diff --git a/platform/neuroforge/internal/httpapi/v3.go b/platform/neuroforge/internal/httpapi/v3.go +index 8c2b8b4f5edfd46f1e355a63bd81c622654f4cad..e01f17d73e493b205c609085fa57637007ba3bb3 100644 +--- a/platform/neuroforge/internal/httpapi/v3.go ++++ b/platform/neuroforge/internal/httpapi/v3.go +@@ -9,6 +9,7 @@ import ( + + "neuroforge/internal/brain" + "neuroforge/internal/core" ++ "neuroforge/internal/store" + ) + + func (s *Server) goalsList(w http.ResponseWriter, r *http.Request) { +@@ -22,6 +23,10 @@ func (s *Server) goalsCreate(w http.ResponseWriter, r *http.Request) { + return + } + if err := s.store.UpsertGoal(&g); err != nil { ++ if errors.Is(err, store.ErrDuplicateGoal) { ++ s.err(w, http.StatusConflict, err) ++ return ++ } + s.err(w, 400, err) + return + } +diff --git a/platform/neuroforge/internal/store/v3.go b/platform/neuroforge/internal/store/v3.go +index f0e1ceaf4d21e99616e4e4ab756f801463652cef..033be4b7ca8cf7dda411e18b62730dc0c1ca9ce6 100644 +--- a/platform/neuroforge/internal/store/v3.go ++++ b/platform/neuroforge/internal/store/v3.go +@@ -12,6 +12,8 @@ import ( + "neuroforge/internal/vector" + ) + ++var ErrDuplicateGoal = errors.New("duplicate goal") ++ + func (s *Store) resolveConflictLocked(in *core.Memory) []core.Memory { + key := strings.TrimSpace(in.TruthKey) + if key == "" { +@@ -173,6 +175,17 @@ func (s *Store) UpsertGoal(g *core.Goal) error { + defer s.mu.Unlock() + now := time.Now().UTC() + newGoal := g.ID == "" || s.state.Goals[g.ID] == nil ++ if newGoal { ++ fingerprint := goalFingerprint(g) ++ for id, existing := range s.state.Goals { ++ if existing == nil || existing.Status == core.GoalCompleted { ++ continue ++ } ++ if goalFingerprint(existing) == fingerprint { ++ return fmt.Errorf("%w: existing goal %s", ErrDuplicateGoal, id) ++ } ++ } ++ } + if g.ID == "" { + g.ID = NewID("goal") + } +@@ -206,6 +219,16 @@ func (s *Store) UpsertGoal(g *core.Goal) error { + return s.commitLocked("goal.upsert", cp) + } + ++func goalFingerprint(g *core.Goal) string { ++ if g == nil { ++ return "" ++ } ++ normalize := func(v string) string { ++ return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(v)), " ")) ++ } ++ return normalize(g.Title) + "\x00" + normalize(g.Description) + "\x00" + normalize(g.Target) ++} ++ + func (s *Store) GetGoal(id string) (*core.Goal, bool) { + s.mu.RLock() + defer s.mu.RUnlock() diff --git a/platform/neuroforge/internal/brain/goal_progress_test.go b/platform/neuroforge/internal/brain/goal_progress_test.go index 6c96722..19d5edb 100644 --- a/platform/neuroforge/internal/brain/goal_progress_test.go +++ b/platform/neuroforge/internal/brain/goal_progress_test.go @@ -76,6 +76,16 @@ func TestGoalResearchPublishesIdempotentHumanReviewDraft(t *testing.T) { if body["answer"] == "" { t.Fatalf("empty answer") } + meta, ok := body["metadata"].(map[string]any) + if !ok { + t.Fatalf("missing metadata: %#v", body["metadata"]) + } + if meta["research_evidence"] != float64(1) || meta["research_sources"] != float64(1) { + t.Fatalf("draft counters must reflect selected evidence: %#v", meta) + } + if meta["research_goal_evidence"] != float64(13) || meta["research_goal_sources"] != float64(5) { + t.Fatalf("goal totals must remain auditable: %#v", meta) + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"staging": map[string]any{"key": "KB-AI-STAGING-1", "meta": map[string]any{"integration_action": "created"}}}) })) @@ -103,7 +113,7 @@ func TestGoalResearchPublishesIdempotentHumanReviewDraft(t *testing.T) { e := &Engine{store: s, http: kb.Client()} e.ConfigureStagingPublisher(StagingPublisherConfig{Enabled: true, URL: kb.URL, Token: "secret", MinEvidence: 1, MinSources: 1, SynthesisMode: "evidence"}) - g := &core.Goal{ID: "goal-1", Title: "NVIDIA", ResearchEvidence: 1, ResearchSources: 1, ResearchSourceIDs: []string{src.ID}} + g := &core.Goal{ID: "goal-1", Title: "NVIDIA", ResearchEvidence: 13, ResearchSources: 5, ResearchSourceIDs: []string{src.ID}} e.maybePublishGoalDraft(context.Background(), g, ResearchResult{RunID: run.ID}) if requests != 1 || g.StagingDraftsCreated != 1 || g.LastStagingDraftID == "" || g.LastStagingError != "" { t.Fatalf("goal=%#v requests=%d", g, requests) @@ -289,3 +299,45 @@ func TestGoalArticleTargetUsesCreatedStagingArticles(t *testing.T) { t.Fatalf("article target must count articles, got progress=%f reason=%q", g.Progress, g.ProgressReason) } } + +func TestResearchMaterialRelevanceRequiresExactErrorCodeAndSubjectAnchor(t *testing.T) { + g := &core.Goal{Title: "FortiClient SSLVPN 7200"} + + for _, tc := range []struct { + name string + parts []string + relevant bool + }{ + { + name: "official fortinet title with exact code", + parts: []string{"Troubleshooting Tip: FortiClient error 'Credentials or SSLVPN configuration is wrong. (-7200)'", "FortiClient SSL VPN authentication troubleshooting"}, + relevant: true, + }, + { + name: "split ssl vpn plus exact code", + parts: []string{"Technical Tip: Credential or SSL VPN configuration is wrong (-7200)", "SSL VPN authentication rule troubleshooting"}, + relevant: true, + }, + { + name: "docker id only contains 7200 as substring", + parts: []string{"Docker image", "https://hub.docker.com/r/cffork8s/72007bf3-214d-4f19-a618-f74b145ca3a5", "generic container image"}, + relevant: false, + }, + { + name: "generic sslvpn without error code", + parts: []string{"baselibrary/sslvpn", "VPN_TYPE=fortinet NET_ADMIN /dev/ppp"}, + relevant: false, + }, + { + name: "exact code without product subject", + parts: []string{"Build 7200 released", "unrelated software package"}, + relevant: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + if got := researchMaterialRelevant(g, tc.parts...); got != tc.relevant { + t.Fatalf("researchMaterialRelevant=%v want %v for %#v", got, tc.relevant, tc.parts) + } + }) + } +} diff --git a/platform/neuroforge/internal/brain/research_quality.go b/platform/neuroforge/internal/brain/research_quality.go index 328c000..541a68d 100644 --- a/platform/neuroforge/internal/brain/research_quality.go +++ b/platform/neuroforge/internal/brain/research_quality.go @@ -66,13 +66,83 @@ func researchMaterialRelevant(goal *core.Goal, parts ...string) bool { if len(anchors) == 0 { return true } - haystack := strings.ToLower(strings.Join(parts, "\n")) - for _, tok := range anchors { - if strings.Contains(haystack, tok) { - return true + + // Relevance is intentionally token-aware and requires coverage of more than + // one anchor for multi-part goals. The previous substring-any rule allowed + // false positives such as goal code "7200" matching a Docker repository id + // beginning with "72007...", or a generic page containing only "sslvpn". + // Numeric anchors (error codes, versions, model numbers) must match an exact + // normalized token and, when a lexical anchor exists, be accompanied by at + // least one subject/product anchor. + words := normalizedResearchWords(parts...) + wordSet := make(map[string]bool, len(words)) + for _, w := range words { + wordSet[w] = true + } + // Also index compact adjacent word pairs/triples so an anchor such as + // "sslvpn" matches source text written as "SSL VPN" without falling back to + // unrestricted substring matching. + compact := make(map[string]bool, len(words)*2) + for i := range words { + if i+1 < len(words) { + compact[words[i]+words[i+1]] = true + } + if i+2 < len(words) { + compact[words[i]+words[i+1]+words[i+2]] = true } } - return false + + lexicalTotal, lexicalMatches := 0, 0 + numericTotal, numericMatches := 0, 0 + for _, anchor := range anchors { + if allDigits(anchor) { + numericTotal++ + if wordSet[anchor] { + numericMatches++ + } + continue + } + lexicalTotal++ + if wordSet[anchor] || compact[anchor] { + lexicalMatches++ + } + } + + if numericTotal > 0 { + if numericMatches == 0 { + return false + } + if lexicalTotal > 0 && lexicalMatches == 0 { + return false + } + return true + } + if lexicalTotal <= 1 { + return lexicalMatches == lexicalTotal + } + return lexicalMatches >= 2 +} + +func normalizedResearchWords(parts ...string) []string { + normalized := strings.Map(func(r rune) rune { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + return unicode.ToLower(r) + } + return ' ' + }, strings.Join(parts, "\n")) + return strings.Fields(normalized) +} + +func allDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if !unicode.IsDigit(r) { + return false + } + } + return true } func goalIDFromTags(tags []string) string { diff --git a/platform/neuroforge/internal/brain/staging.go b/platform/neuroforge/internal/brain/staging.go index 801b1ec..fcea5df 100644 --- a/platform/neuroforge/internal/brain/staging.go +++ b/platform/neuroforge/internal/brain/staging.go @@ -138,14 +138,19 @@ func (e *Engine) maybePublishGoalDraft(ctx context.Context, goal *core.Goal, res } } draft.Metadata = map[string]any{ - "research_goal_id": goal.ID, - "research_run_id": research.RunID, - "research_evidence": goal.ResearchEvidence, - "research_sources": goal.ResearchSources, - "research_corroborations": goal.ResearchCorroborations, - "research_evidence_ids": evidenceIDs, - "research_source_uris": sourceURIs, - "human_review_required": true, + "research_goal_id": goal.ID, + "research_run_id": research.RunID, + // Draft-level counters describe the evidence actually supplied to the + // synthesizer. Goal totals are preserved separately for audit/history. + "research_evidence": len(evidenceIDs), + "research_sources": len(sourceURIs), + "research_corroborations": goal.ResearchCorroborations, + "research_goal_evidence": goal.ResearchEvidence, + "research_goal_sources": goal.ResearchSources, + "research_goal_corroborations": goal.ResearchCorroborations, + "research_evidence_ids": evidenceIDs, + "research_source_uris": sourceURIs, + "human_review_required": true, } body, _ := json.Marshal(draft) req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.URL, bytes.NewReader(body)) diff --git a/platform/neuroforge/internal/httpapi/goal_duplicate_test.go b/platform/neuroforge/internal/httpapi/goal_duplicate_test.go new file mode 100644 index 0000000..196f890 --- /dev/null +++ b/platform/neuroforge/internal/httpapi/goal_duplicate_test.go @@ -0,0 +1,28 @@ +package httpapi + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGoalsCreateReturnsConflictForDuplicateActiveGoal(t *testing.T) { + srv, _ := newMetricsTestServer(t) + payload := `{"title":"FortiClient SSLVPN 7200","description":"Create one support article","target":"1 hochwertiger Wissensartikel","status":"active"}` + + post := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/v1/goals", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+srv.store.Secrets().AppAPIKey) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + return rr + } + if rr := post(); rr.Code != http.StatusCreated { + t.Fatalf("first create status=%d body=%s", rr.Code, rr.Body.String()) + } + if rr := post(); rr.Code != http.StatusConflict { + t.Fatalf("duplicate create status=%d body=%s", rr.Code, rr.Body.String()) + } +} diff --git a/platform/neuroforge/internal/httpapi/index.html b/platform/neuroforge/internal/httpapi/index.html index 3fc4fd7..2909d9b 100644 --- a/platform/neuroforge/internal/httpapi/index.html +++ b/platform/neuroforge/internal/httpapi/index.html @@ -226,7 +226,7 @@ function openGoalResearch(id,title){researchWatch.goalId=id;researchWatch.goalTi $('liveCloseBtn').addEventListener('click',()=>{$('goalResearchLive').classList.remove('show');stopResearchPolling()});$('liveRefreshBtn').addEventListener('click',()=>{researchWatch.after=0;researchWatch.events=[];researchWatch.runId='';pollGoalResearch();loadResearchHistory()}); async function loadGoals(){try{let xs=await req('/api/v1/goals');xs=Array.isArray(xs)?xs:[];$('goalsList').innerHTML=xs.length?xs.map(g=>{let active=g.status==='active',paused=g.status==='paused';return `${esc(g.description||'')}
Nächste Aktion: ${esc(g.next_action)}
`:''}${g.last_staging_error?`Staging: ${esc(g.last_staging_error)}
`:''}${g.last_error?`${esc(g.last_error)}
`:''}Noch keine Ziele.
';$$('[data-goal-action]').forEach(b=>b.addEventListener('click',()=>goalAction(b)));$$('[data-research-live]').forEach(b=>b.addEventListener('click',()=>openGoalResearch(b.dataset.researchLive,b.dataset.goalTitle)));$$('[data-focus]').forEach(b=>b.addEventListener('click',()=>{switchView('knowledge');$('knowledgeQuery').value='goal:'+b.dataset.focus;searchKnowledge()}))}catch(e){toast(e.message,true)}} async function goalAction(b){let id=b.dataset.goalId,action=b.dataset.goalAction;if(action==='delete'&&!confirm(`Ziel „${b.dataset.goalTitle||id}“ wirklich löschen? Bereits gelerntes Wissen bleibt erhalten.`))return;if(action==='cycle')openGoalResearch(id,b.dataset.goalTitle||id);let old=b.textContent;try{b.disabled=true;b.textContent=action==='cycle'?'läuft …':action==='delete'?'lösche …':'speichere …';let path='/api/v1/goals/'+encodeURIComponent(id)+(action==='cycle'?'/cycle':action==='pause'?'/pause':action==='resume'?'/resume':'');let d=await req(path,{method:action==='delete'?'DELETE':'POST',body:action==='delete'?undefined:'{}'});if(action==='cycle')toast(`Zyklus fertig · Evaluation ${Number(d.evaluation).toFixed(2)} · ${d.sources_ingested||0} Quellen`);else if(action==='pause')toast('Ziel pausiert. Der Scheduler verarbeitet es nicht weiter.');else if(action==='resume')toast('Ziel wieder aktiv. Nächster Lauf wurde eingeplant.');else{toast('Ziel gelöscht. Bereits gelerntes Wissen bleibt erhalten.');if(researchWatch.goalId===id){$('goalResearchLive').classList.remove('show');stopResearchPolling();researchWatch.goalId=''}}await loadGoals();refreshAll()}catch(e){toast(e.message,true)}finally{b.disabled=false;b.textContent=old}} -$('goalCreateBtn').addEventListener('click',async()=>{let title=$('goalTitle').value.trim();if(!title)return toast('Titel fehlt.',true);try{let g=await req('/api/v1/goals',{method:'POST',body:JSON.stringify({title,description:$('goalDesc').value,target:$('goalTarget').value,priority:Number($('goalPriority').value||50),interval_minutes:Number($('goalInterval').value||10),status:'active'})});toast(`Ziel ${g.title} angelegt – erster Lauf ist eingeplant.`);$('goalTitle').value='';$('goalDesc').value='';loadGoals();refreshAll()}catch(e){toast(e.message,true)}});$('runAutonomyBtn').addEventListener('click',async()=>{try{let d=await req('/admin/api/autonomy',{method:'POST',body:'{}'});toast(`${d.cycles?.length||0} fällige Zyklen ausgeführt.`);loadGoals()}catch(e){toast(e.message,true)}}); +$('goalCreateBtn').addEventListener('click',async()=>{let title=$('goalTitle').value.trim();if(!title)return toast('Titel fehlt.',true);let b=$('goalCreateBtn'),old=b.textContent;try{b.disabled=true;b.textContent='lege an …';let g=await req('/api/v1/goals',{method:'POST',body:JSON.stringify({title,description:$('goalDesc').value,target:$('goalTarget').value,priority:Number($('goalPriority').value||50),interval_minutes:Number($('goalInterval').value||10),status:'active'})});toast(`Ziel ${g.title} angelegt – erster Lauf ist eingeplant.`);$('goalTitle').value='';$('goalDesc').value='';await loadGoals();refreshAll()}catch(e){toast(e.message,true)}finally{b.disabled=false;b.textContent=old}});$('runAutonomyBtn').addEventListener('click',async()=>{try{let d=await req('/admin/api/autonomy',{method:'POST',body:'{}'});toast(`${d.cycles?.length||0} fällige Zyklen ausgeführt.`);loadGoals()}catch(e){toast(e.message,true)}}); // ---- Chat ---- $('chatBtn').addEventListener('click',async()=>{let input=document.getElementById('chatPrompt').value.trim();if(!input)return toast('Nachricht fehlt.',true);chatAbort=new AbortController();$('chatAbortBtn').disabled=false;$('chatBtn').disabled=true;let start=Date.now(),timer=setInterval(()=>$('chatElapsed').textContent=Math.round((Date.now()-start)/1000)+' s',1000);$('chatOutput').textContent='Ollama / NeuroForge arbeitet …';try{let r=await fetch('/api/v1/chat',{method:'POST',headers:tokenHeaders(),signal:chatAbort.signal,body:JSON.stringify({session_id:$('chatSession').value,input,provider:$('chatProvider').value})}),txt=await r.text(),d;try{d=JSON.parse(txt)}catch{d=txt}if(!r.ok)throw new Error(d.error||d);$('chatOutput').textContent=(d.answer||'')+'\n\n— '+(d.provider||'')+' · '+(d.model||'')+' · Recall '+(d.recalled?.length||0)+' · Reward '+Number(d.auto_reward||0).toFixed(3);refreshAll()}catch(e){$('chatOutput').textContent=e.name==='AbortError'?'Abgebrochen.':e.message}finally{clearInterval(timer);$('chatElapsed').textContent='';$('chatAbortBtn').disabled=true;$('chatBtn').disabled=false;chatAbort=null}});$('chatAbortBtn').addEventListener('click',()=>chatAbort?.abort()); diff --git a/platform/neuroforge/internal/httpapi/v3.go b/platform/neuroforge/internal/httpapi/v3.go index 8c2b8b4..e01f17d 100644 --- a/platform/neuroforge/internal/httpapi/v3.go +++ b/platform/neuroforge/internal/httpapi/v3.go @@ -9,6 +9,7 @@ import ( "neuroforge/internal/brain" "neuroforge/internal/core" + "neuroforge/internal/store" ) func (s *Server) goalsList(w http.ResponseWriter, r *http.Request) { @@ -22,6 +23,10 @@ func (s *Server) goalsCreate(w http.ResponseWriter, r *http.Request) { return } if err := s.store.UpsertGoal(&g); err != nil { + if errors.Is(err, store.ErrDuplicateGoal) { + s.err(w, http.StatusConflict, err) + return + } s.err(w, 400, err) return } diff --git a/platform/neuroforge/internal/store/goal_duplicate_test.go b/platform/neuroforge/internal/store/goal_duplicate_test.go new file mode 100644 index 0000000..a0004c7 --- /dev/null +++ b/platform/neuroforge/internal/store/goal_duplicate_test.go @@ -0,0 +1,42 @@ +package store + +import ( + "errors" + "testing" + + "neuroforge/internal/core" +) + +func TestUpsertGoalRejectsDuplicateActiveGoalFingerprint(t *testing.T) { + s, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + first := &core.Goal{Title: "FortiClient SSLVPN 7200", Description: "Create one support article", Target: "1 hochwertiger Wissensartikel", Status: core.GoalActive} + if err := s.UpsertGoal(first); err != nil { + t.Fatal(err) + } + duplicate := &core.Goal{Title: " forticlient sslvpn 7200 ", Description: " Create one support article ", Target: "1 hochwertiger Wissensartikel", Status: core.GoalActive} + if err := s.UpsertGoal(duplicate); !errors.Is(err, ErrDuplicateGoal) { + t.Fatalf("duplicate goal error=%v, want %v", err, ErrDuplicateGoal) + } +} + +func TestUpsertGoalAllowsSameFingerprintAfterCompletion(t *testing.T) { + s, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + first := &core.Goal{Title: "FortiClient SSLVPN 7200", Description: "Create one support article", Target: "1 hochwertiger Wissensartikel", Status: core.GoalCompleted} + if err := s.UpsertGoal(first); err != nil { + t.Fatal(err) + } + second := &core.Goal{Title: first.Title, Description: first.Description, Target: first.Target, Status: core.GoalActive} + if err := s.UpsertGoal(second); err != nil { + t.Fatalf("completed historical goal must not block a new run: %v", err) + } +} diff --git a/platform/neuroforge/internal/store/v3.go b/platform/neuroforge/internal/store/v3.go index f0e1cea..033be4b 100644 --- a/platform/neuroforge/internal/store/v3.go +++ b/platform/neuroforge/internal/store/v3.go @@ -12,6 +12,8 @@ import ( "neuroforge/internal/vector" ) +var ErrDuplicateGoal = errors.New("duplicate goal") + func (s *Store) resolveConflictLocked(in *core.Memory) []core.Memory { key := strings.TrimSpace(in.TruthKey) if key == "" { @@ -173,6 +175,17 @@ func (s *Store) UpsertGoal(g *core.Goal) error { defer s.mu.Unlock() now := time.Now().UTC() newGoal := g.ID == "" || s.state.Goals[g.ID] == nil + if newGoal { + fingerprint := goalFingerprint(g) + for id, existing := range s.state.Goals { + if existing == nil || existing.Status == core.GoalCompleted { + continue + } + if goalFingerprint(existing) == fingerprint { + return fmt.Errorf("%w: existing goal %s", ErrDuplicateGoal, id) + } + } + } if g.ID == "" { g.ID = NewID("goal") } @@ -206,6 +219,16 @@ func (s *Store) UpsertGoal(g *core.Goal) error { return s.commitLocked("goal.upsert", cp) } +func goalFingerprint(g *core.Goal) string { + if g == nil { + return "" + } + normalize := func(v string) string { + return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(v)), " ")) + } + return normalize(g.Title) + "\x00" + normalize(g.Description) + "\x00" + normalize(g.Target) +} + func (s *Store) GetGoal(id string) (*core.Goal, bool) { s.mu.RLock() defer s.mu.RUnlock()