399 lines
14 KiB
Go
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}
|
|
}
|