Files
glpi-neural-brain/internal/engine/research_work.go
2026-08-07 17:05:03 +02:00

399 lines
14 KiB
Go

package engine
import (
"context"
"fmt"
"sort"
"strings"
"time"
"github.com/local/glpi-neural-brain/internal/model"
"github.com/local/glpi-neural-brain/internal/ollama"
)
type researchIntentProfile struct {
Primary string
Focus []string
Entities []string
}
type researchDedupeEntry struct {
ID string
Kind string
Intent string
RawIntent string
Profile researchIntentProfile
Vector []float64
Created time.Time
Finished time.Time
InFlight bool
Done chan struct{}
Results []model.ResearchResult
}
type researchIntentLease struct {
entry *researchDedupeEntry
owner bool
similarity float64
requestProfile researchIntentProfile
}
func (e *Engine) withSharedResearchWork(ctx context.Context, kind string, fn func() error) error {
if e.sharedWork == nil {
return fn()
}
release, err := e.sharedWork.AcquireKind(ctx, kind)
if err != nil {
if e.Broker != nil {
e.Broker.Publish(model.Activity{Type: "work.queue.rejected", Source: "brain", Phase: "queue", Message: "Gemeinsame Research/Ollama-Queue ist ausgelastet", Strength: .3, Metadata: map[string]any{"kind": kind, "error": err.Error(), "queue": e.sharedWork.Status()}})
}
return err
}
defer release()
return fn()
}
func (e *Engine) beginResearchIntent(ctx context.Context, kind, intent string) (researchIntentLease, []model.ResearchResult, error) {
return e.beginResearchIntentMode(ctx, kind, intent, false)
}
// beginFreshResearchIntent intentionally bypasses semantic reuse. It is used when
// a semantically similar cached research run exists but its evidence fails the
// stricter target-question revalidation. Keeping the old cache entry is useful for
// its original intent while the new run becomes the newest exact candidate.
func (e *Engine) beginFreshResearchIntent(ctx context.Context, kind, intent string) (researchIntentLease, error) {
lease, _, err := e.beginResearchIntentMode(ctx, kind, intent, true)
return lease, err
}
func (e *Engine) beginResearchIntentMode(ctx context.Context, kind, rawIntent string, forceOwner bool) (researchIntentLease, []model.ResearchResult, error) {
kind = strings.ToLower(strings.TrimSpace(kind))
if kind == "" {
kind = "evidence"
}
rawIntent = strings.TrimSpace(rawIntent)
intent := normalizeResearchIntent(rawIntent)
profile := classifyResearchIntent(rawIntent)
if intent == "" {
return researchIntentLease{owner: true, requestProfile: profile}, nil, nil
}
var vector []float64
if e.Ollama != nil {
// Embed the natural-language intent rather than the sorted normalization.
// Word order and phrases such as "volatile evidence before reboot" carry
// information that is lost by a bag-of-terms representation.
vectors, err := e.Ollama.Embed(ollama.WithLowPriority(ctx), []string{rawIntent})
if err == nil && len(vectors) == 1 {
vector = vectors[0]
}
}
now := time.Now().UTC()
ttl := e.Cfg.ResearchDedupeTTL
if ttl <= 0 {
ttl = 45 * time.Minute
}
threshold := e.Cfg.ResearchDedupeThreshold
if threshold <= 0 {
threshold = .92
}
e.researchDedupeMu.Lock()
if e.researchDedupe == nil {
e.researchDedupe = map[string]*researchDedupeEntry{}
}
for id, entry := range e.researchDedupe {
if !entry.InFlight && !entry.Finished.IsZero() && now.Sub(entry.Finished) > ttl {
delete(e.researchDedupe, id)
}
}
if !forceOwner {
var best *researchDedupeEntry
bestSimilarity := 0.0
for _, entry := range e.researchDedupe {
if entry.Kind != kind {
continue
}
compatible, guardReason := researchIntentProfilesCompatible(profile, entry.Profile)
if !compatible {
e.researchDedupeGuardFiltered++
switch guardReason {
case "primary_intent_mismatch":
e.researchDedupeGuardPrimaryMismatch++
case "technical_focus_mismatch":
e.researchDedupeGuardFocusMismatch++
case "core_entity_mismatch":
e.researchDedupeGuardEntityMismatch++
}
continue
}
similarity := researchIntentSimilarity(intent, vector, entry.Intent, entry.Vector)
if similarity > bestSimilarity || (similarity == bestSimilarity && best != nil && entry.Created.After(best.Created)) {
bestSimilarity = similarity
best = entry
}
}
if best != nil && bestSimilarity >= threshold {
done := best.Done
inFlight := best.InFlight
e.researchDedupeMu.Unlock()
if inFlight {
select {
case <-done:
case <-ctx.Done():
return researchIntentLease{}, nil, ctx.Err()
}
}
e.researchDedupeMu.Lock()
current, stillCached := e.researchDedupe[best.ID]
if !stillCached {
e.researchDedupeMu.Unlock()
// The owner failed and removed its cache entry. Retry as a new
// contender instead of treating a failed duplicate as an empty
// successful research result.
return e.beginResearchIntentMode(ctx, kind, rawIntent, false)
}
results := cloneResearchResults(current.Results)
e.researchDedupeMu.Unlock()
return researchIntentLease{entry: current, owner: false, similarity: bestSimilarity, requestProfile: profile}, results, nil
}
}
id := newResearchRunID("research-intent", rawIntent+"\x00"+now.Format(time.RFC3339Nano))
entry := &researchDedupeEntry{ID: id, Kind: kind, Intent: intent, RawIntent: rawIntent, Profile: profile, Vector: append([]float64(nil), vector...), Created: now, InFlight: true, Done: make(chan struct{})}
e.researchDedupe[id] = entry
e.researchDedupeMu.Unlock()
return researchIntentLease{entry: entry, owner: true, similarity: 1, requestProfile: profile}, nil, nil
}
func (e *Engine) completeResearchIntent(lease researchIntentLease, results []model.ResearchResult, err error) {
if !lease.owner || lease.entry == nil {
return
}
e.researchDedupeMu.Lock()
entry, ok := e.researchDedupe[lease.entry.ID]
if !ok {
e.researchDedupeMu.Unlock()
return
}
if err != nil {
delete(e.researchDedupe, lease.entry.ID)
if entry.InFlight {
entry.InFlight = false
close(entry.Done)
}
e.researchDedupeMu.Unlock()
return
}
entry.Results = cloneResearchResults(uniqueResearchEvidence(results))
entry.InFlight = false
entry.Finished = time.Now().UTC()
close(entry.Done)
e.researchDedupeMu.Unlock()
}
func normalizeResearchIntent(value string) string {
terms := researchTerms(value)
if len(terms) == 0 {
return strings.ToLower(strings.TrimSpace(value))
}
ordered := make([]string, 0, len(terms))
for term := range terms {
ordered = append(ordered, term)
}
sort.Strings(ordered)
return strings.Join(ordered, " ")
}
func researchIntentSimilarity(a string, av []float64, b string, bv []float64) float64 {
if len(av) > 0 && len(av) == len(bv) {
return cosineVector(av, bv)
}
at := researchTerms(a)
bt := researchTerms(b)
if len(at) == 0 || len(bt) == 0 {
if strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b)) {
return 1
}
return 0
}
intersection := 0
union := len(at)
for term := range bt {
if at[term] {
intersection++
} else {
union++
}
}
if union == 0 {
return 0
}
return float64(intersection) / float64(union)
}
func classifyResearchIntent(value string) researchIntentProfile {
text := strings.ToLower(strings.TrimSpace(value))
profile := researchIntentProfile{}
// Primary intent is deliberately ordered from specific/high-risk technical
// actions to generic configuration/definition. A forensic preservation task
// therefore stays "evidence-preservation" even if the surrounding product is
// an authentication system.
switch {
case containsResearchMarker(text, "forens", "digital evidence", "evidence preservation", "beweiss", "beweismittel", "chain of custody", "beweiskette", "volatile", "flüchtig", "hash", "checksum", "prüfsumm"):
profile.Primary = "evidence-preservation"
case containsResearchMarker(text, "incident response", "security incident", "sicherheitsvorfall", "vorfall untersuch", "ransomware response"):
profile.Primary = "incident-response"
case containsResearchMarker(text, "troubleshoot", "diagnos", "fehlerbeheb", "beheb", "root cause", "ursachen"):
profile.Primary = "troubleshooting"
case containsResearchMarker(text, "wiederherstell", "restore", "recovery", "recover", "rollback"):
profile.Primary = "recovery"
case containsResearchMarker(text, "hardening", "härt", "secure design", "sicher entwerf"):
profile.Primary = "hardening"
case containsResearchMarker(text, "authorization", "autoris", "berechtig", "least privilege", "privileg"):
profile.Primary = "authorization"
case containsResearchMarker(text, "authentication", "authentifiz", "multi-factor", "multifaktor", " mfa", "login", "sign-in", "signin"):
profile.Primary = "authentication"
case containsResearchMarker(text, "configur", "konfigur", "implement", "einricht", "deploy", "setup", "aktivier", "enable"):
profile.Primary = "configuration"
case containsResearchMarker(text, "compare", "vergleich", "unterschied", "difference", "versus", " vs "):
profile.Primary = "comparison"
case containsResearchMarker(text, "what is", "was ist", "definition", "grundlagen", "concept", "konzept"):
profile.Primary = "definition"
default:
profile.Primary = "general"
}
focus := map[string][]string{
"hash-integrity": {"hash", "checksum", "prüfsumm", "sha-256", "sha256", "sha512", "md5"},
"volatile-memory": {"volatile", "flüchtig", "arbeitsspeicher", " ram", "memory acquisition", "reboot", "neustart"},
"chain-of-custody": {"chain of custody", "beweiskette", "custody"},
"egress-control": {"egress control", "egress-control", "ausgehend", "outbound traffic"},
"segmentation": {"segmentation", "segmentierung", "network segment", "netzsegment"},
"least-privilege": {"least privilege", "geringste privileg", "minimal privilege"},
}
for name, markers := range focus {
if containsResearchMarker(text, markers...) {
profile.Focus = append(profile.Focus, name)
}
}
entities := map[string][]string{
"azure-entra": {"azure active directory", "azure ad", "microsoft entra", "entra id"},
"aws": {"amazon web services", " aws", "aws ", "aws-"},
"kubernetes": {"kubernetes", " k8s", "k8s ", "kubectl"},
"windows": {"microsoft windows", "windows server", "windows-netzwerk", "windows network"},
"dns": {"authoritative dns", " dns", "dns ", "domain name system"},
"mobile-authentication": {"mobile authentication", "mobile authentification", "mobile-authentifizierung", "mobile authentifizierung"},
"multi-factor-auth": {"multi-factor", "multifaktor", " mfa", "mfa ", "2fa"},
"ransomware": {"ransomware"},
}
for name, markers := range entities {
if containsResearchMarker(text, markers...) {
profile.Entities = append(profile.Entities, name)
}
}
sort.Strings(profile.Focus)
sort.Strings(profile.Entities)
return profile
}
func researchIntentProfilesCompatible(a, b researchIntentProfile) (bool, string) {
if a.Primary != "" && b.Primary != "" && a.Primary != "general" && b.Primary != "general" && a.Primary != b.Primary {
return false, "primary_intent_mismatch"
}
if len(a.Focus) > 0 || len(b.Focus) > 0 {
if !stringSliceIntersects(a.Focus, b.Focus) {
return false, "technical_focus_mismatch"
}
}
if len(a.Entities) > 0 && len(b.Entities) > 0 && !stringSliceIntersects(a.Entities, b.Entities) {
return false, "core_entity_mismatch"
}
return true, "compatible"
}
func containsResearchMarker(value string, markers ...string) bool {
for _, marker := range markers {
if strings.Contains(value, marker) {
return true
}
}
return false
}
func stringSliceIntersects(a, b []string) bool {
if len(a) == 0 || len(b) == 0 {
return false
}
seen := make(map[string]struct{}, len(a))
for _, value := range a {
seen[value] = struct{}{}
}
for _, value := range b {
if _, ok := seen[value]; ok {
return true
}
}
return false
}
func researchIntentProfileMetadata(prefix string, profile researchIntentProfile) map[string]any {
if prefix != "" && !strings.HasSuffix(prefix, "_") {
prefix += "_"
}
return map[string]any{
prefix + "intent_class": profile.Primary,
prefix + "intent_focus": append([]string(nil), profile.Focus...),
prefix + "intent_entities": append([]string(nil), profile.Entities...),
}
}
func researchDedupeLeaseMetadata(lease researchIntentLease) map[string]any {
metadata := researchIntentProfileMetadata("request", lease.requestProfile)
if lease.entry != nil {
for key, value := range researchIntentProfileMetadata("cached", lease.entry.Profile) {
metadata[key] = value
}
metadata["cached_research_intent"] = lease.entry.RawIntent
}
return metadata
}
func cloneResearchResults(values []model.ResearchResult) []model.ResearchResult {
out := make([]model.ResearchResult, len(values))
copy(out, values)
for i := range out {
out[i].CoveredGapIDs = append([]string(nil), values[i].CoveredGapIDs...)
}
return out
}
func remapResearchEvidenceToQuestion(values []model.ResearchResult, question model.ResearchQuestion) []model.ResearchResult {
out := cloneResearchResults(values)
for i := range out {
out[i].CoveredGapIDs = unique(append(out[i].CoveredGapIDs, question.GapID))
if strings.TrimSpace(out[i].AssessmentReason) != "" {
out[i].AssessmentReason = fmt.Sprintf("Wiederverwendete semantisch äquivalente Recherche: %s", out[i].AssessmentReason)
}
}
return out
}
func (e *Engine) researchDedupeStatus() map[string]any {
e.researchDedupeMu.Lock()
defer e.researchDedupeMu.Unlock()
inflight, completed := 0, 0
for _, entry := range e.researchDedupe {
if entry.InFlight {
inflight++
} else {
completed++
}
}
return map[string]any{"threshold": e.Cfg.ResearchDedupeThreshold, "ttl": e.Cfg.ResearchDedupeTTL.String(), "inflight": inflight, "cached": completed, "intent_guard_filtered_comparisons": e.researchDedupeGuardFiltered, "intent_guard_primary_mismatch": e.researchDedupeGuardPrimaryMismatch, "intent_guard_focus_mismatch": e.researchDedupeGuardFocusMismatch, "intent_guard_entity_mismatch": e.researchDedupeGuardEntityMismatch}
}