275 lines
11 KiB
Go
275 lines
11 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/local/glpi-neural-brain/internal/model"
|
|
)
|
|
|
|
// collectResearchMaterialForArticle performs breadth-first research for the
|
|
// synthesis model. Unlike the legacy iterative brief gate it does not require
|
|
// each source to close a knowledge gap before drafting. Full text is collected
|
|
// first; the reviewer validates the generated article against that material.
|
|
func (e *Engine) collectResearchMaterialForArticle(ctx context.Context, trigger string, nodeIDs []string, sources []articleSource, plan model.ArticlePlanDecision, brief model.KnowledgeBrief, initial []model.ResearchResult) ([]model.ResearchResult, articleResearchReport, error) {
|
|
material := uniqueResearchEvidence(append([]model.ResearchResult{}, filterUsableResearchEvidence(initial)...))
|
|
report := articleResearchReport{}
|
|
attemptedQueries := map[string]bool{}
|
|
attemptedURLs := map[string]bool{}
|
|
seenURLs := map[string]bool{}
|
|
for _, item := range material {
|
|
if key := canonicalResearchURL(item.URL); key != "" {
|
|
attemptedURLs[key] = true
|
|
seenURLs[key] = true
|
|
}
|
|
}
|
|
|
|
if !e.evidenceAcquisitionEnabled() {
|
|
return material, report, nil
|
|
}
|
|
maxRounds := e.Cfg.ArticleResearchRounds
|
|
if maxRounds < 1 {
|
|
maxRounds = 1
|
|
}
|
|
maxQueries := e.Cfg.ArticleMaxResearchQueries
|
|
if maxQueries < 1 {
|
|
maxQueries = 6
|
|
}
|
|
|
|
for round := 1; round <= maxRounds && report.Queries < maxQueries; round++ {
|
|
report.Rounds = round
|
|
researchPlan, err := e.planArticleResearchRound(ctx, plan, brief, round, attemptedQueries)
|
|
if err != nil {
|
|
researchPlan = fallbackResearchPlan(plan, brief, attemptedQueries, maxQueries-report.Queries)
|
|
}
|
|
researchPlan = normalizeResearchPlan(researchPlan, plan, brief, attemptedQueries, maxQueries-report.Queries)
|
|
if len(researchPlan.Questions) == 0 {
|
|
researchPlan = fallbackSynthesisEnrichmentPlan(plan, brief, sources, attemptedQueries, maxQueries-report.Queries)
|
|
}
|
|
if len(researchPlan.Questions) == 0 {
|
|
break
|
|
}
|
|
e.Broker.Publish(model.Activity{Type: "article.research.collection.round.started", Source: "brain", Phase: "knowledge-research-collection", NodeIDs: nodeIDs, Message: fmt.Sprintf("Recherche-Runde %d sammelt Volltextmaterial für das Synthese-Modell", round), Strength: .84, Metadata: map[string]any{"trigger": trigger, "round": round, "question_count": len(researchPlan.Questions), "synthesis_model": e.Cfg.ArticleSynthesisModel}})
|
|
collectedThisRound := 0
|
|
for _, question := range researchPlan.Questions {
|
|
if report.Queries >= maxQueries {
|
|
break
|
|
}
|
|
inboxMaterial := e.sourceInboxResearch(ctx, question.Question, e.Cfg.ArticleResearchFetchResults, containsFreshnessLanguage(question.Question))
|
|
if len(inboxMaterial) > 0 {
|
|
report.InboxResults += len(inboxMaterial)
|
|
report.Accepted += len(inboxMaterial)
|
|
for _, item := range inboxMaterial {
|
|
key := canonicalResearchURL(item.URL)
|
|
if key == "" || seenURLs[key] {
|
|
continue
|
|
}
|
|
seenURLs[key] = true
|
|
material = append(material, item)
|
|
collectedThisRound++
|
|
}
|
|
}
|
|
if len(inboxMaterial) >= e.Cfg.SourceInboxMinResults || !e.ResearchEnabledForRuntime() {
|
|
continue
|
|
}
|
|
lease, reused, err := e.beginResearchIntent(ctx, "synthesis-material", question.Question)
|
|
if err != nil {
|
|
return material, report, err
|
|
}
|
|
questionMaterial := []model.ResearchResult{}
|
|
if !lease.owner {
|
|
// Reuse stays deterministic in the generate-then-review pipeline. The
|
|
// intent guard already blocks incompatible topics; a lightweight
|
|
// lexical/domain check decides whether cached material is good enough
|
|
// to reuse. Final semantic validation happens only on the article.
|
|
for i, item := range reused {
|
|
assessment := heuristicResearchAssessment(i+1, question, item, true)
|
|
if assessment.Relevance < e.Cfg.ArticleResearchMinRelevance || assessment.SourceQualityScore < e.Cfg.ArticleResearchMinQuality {
|
|
continue
|
|
}
|
|
item.Relevant = true
|
|
item.Relevance = assessment.Relevance
|
|
item.SourceQuality = assessment.SourceQuality
|
|
item.SourceQualityScore = assessment.SourceQualityScore
|
|
item.Actionable = assessment.Actionable
|
|
item.CoveredGapIDs = unique(append(item.CoveredGapIDs, question.GapID))
|
|
item.AssessmentReason = "Deterministisch wiederverwendetes Recherchematerial; finale Belegprüfung erfolgt am generierten Artikel."
|
|
questionMaterial = append(questionMaterial, item)
|
|
}
|
|
if len(questionMaterial) == 0 {
|
|
lease, err = e.beginFreshResearchIntent(ctx, "synthesis-material", question.Question)
|
|
if err != nil {
|
|
return material, report, err
|
|
}
|
|
}
|
|
}
|
|
if lease.owner {
|
|
for _, querySpec := range researchQuestionQueries(question) {
|
|
if report.Queries >= maxQueries {
|
|
break
|
|
}
|
|
query := sanitizeSearchQuerySiteFilters(querySpec.Query)
|
|
key := strings.ToLower(strings.TrimSpace(query))
|
|
if query == "" || attemptedQueries[key] {
|
|
continue
|
|
}
|
|
attemptedQueries[key] = true
|
|
report.Queries++
|
|
collected, stats := e.executeArticleResearchQueryForSynthesis(ctx, trigger, nodeIDs, question, query, querySpec.Language, round, attemptedURLs)
|
|
report.SearchResults += stats.SearchResults
|
|
report.Fetched += stats.Fetched
|
|
report.Accepted += stats.Accepted
|
|
report.Rejected += stats.Rejected
|
|
report.FetchFailed += stats.FetchFailed
|
|
report.SearchFailed += stats.SearchFailed
|
|
questionMaterial = uniqueResearchEvidence(append(questionMaterial, collected...))
|
|
}
|
|
e.completeResearchIntent(lease, questionMaterial, nil)
|
|
}
|
|
for _, item := range questionMaterial {
|
|
key := canonicalResearchURL(item.URL)
|
|
if key == "" || seenURLs[key] {
|
|
continue
|
|
}
|
|
seenURLs[key] = true
|
|
material = append(material, item)
|
|
collectedThisRound++
|
|
}
|
|
}
|
|
e.Broker.Publish(model.Activity{Type: "article.research.collection.round.completed", Source: "brain", Phase: "knowledge-research-collection", NodeIDs: nodeIDs, Message: fmt.Sprintf("Recherche-Runde %d abgeschlossen · %d neue Volltextquellen für die Synthese", round, collectedThisRound), Strength: .86, Metadata: map[string]any{"trigger": trigger, "round": round, "collected_sources": collectedThisRound, "total_material": len(material), "queries": report.Queries}})
|
|
// One broad collection round is intentional. Any remaining unsupported
|
|
// statement is discovered on the generated article and drives the targeted
|
|
// reviewer repair loop instead of another abstract pre-draft gate.
|
|
break
|
|
}
|
|
return uniqueResearchEvidence(material), report, nil
|
|
}
|
|
|
|
func fallbackSynthesisEnrichmentPlan(plan model.ArticlePlanDecision, brief model.KnowledgeBrief, sources []articleSource, attempted map[string]bool, limit int) model.ResearchPlan {
|
|
if limit <= 0 {
|
|
return model.ResearchPlan{}
|
|
}
|
|
topic := strings.TrimSpace(brief.Topic)
|
|
if topic == "" {
|
|
topic = strings.TrimSpace(plan.ExpectedValue)
|
|
}
|
|
if topic == "" && len(sources) > 0 {
|
|
topic = strings.TrimSpace(sources[0].Node.Label)
|
|
}
|
|
if topic == "" {
|
|
return model.ResearchPlan{}
|
|
}
|
|
queries := []string{
|
|
topic + " Best Practices technische Dokumentation Validierung",
|
|
topic + " official documentation best practices validation troubleshooting",
|
|
}
|
|
queries = cleanUnattemptedQueries(queries, attempted)
|
|
if len(queries) == 0 {
|
|
return model.ResearchPlan{}
|
|
}
|
|
if len(queries) > limit {
|
|
queries = queries[:limit]
|
|
}
|
|
q := model.ResearchQuestion{GapID: "ENRICH-1", Question: "Welche belastbaren externen Best Practices, Validierungen und typischen Fehlerbilder ergänzen den geplanten Artikel zu " + topic + "?", Critical: false, ExpectActionable: plan.ArticleType == "how_to" || plan.ArticleType == "troubleshooting"}
|
|
for _, query := range queries {
|
|
if looksEnglish(query) {
|
|
q.QueriesEN = append(q.QueriesEN, query)
|
|
} else {
|
|
q.QueriesDE = append(q.QueriesDE, query)
|
|
}
|
|
}
|
|
return model.ResearchPlan{Questions: []model.ResearchQuestion{q}}
|
|
}
|
|
|
|
func fallbackSynthesisBrief(plan model.ArticlePlanDecision, sources []articleSource) model.KnowledgeBrief {
|
|
brief := model.KnowledgeBrief{Topic: strings.TrimSpace(plan.ExpectedValue), Purpose: strings.TrimSpace(plan.Reason), ReadyForArticle: true}
|
|
if brief.Topic == "" && len(sources) > 0 {
|
|
brief.Topic = sources[0].Node.Label
|
|
}
|
|
for i, value := range unique(plan.MissingInformation) {
|
|
brief.CriticalGaps = append(brief.CriticalGaps, model.KnowledgeGap{ID: fmt.Sprintf("PLAN-%d", i+1), Description: value, Reason: "Vom Artikelplan als fehlende Information markiert."})
|
|
}
|
|
if strings.TrimSpace(plan.ResearchQuery) != "" {
|
|
brief.ResearchQueries = []string{plan.ResearchQuery}
|
|
}
|
|
return brief
|
|
}
|
|
|
|
func (e *Engine) collectReviewerRepairResearch(ctx context.Context, trigger string, nodeIDs []string, queries []string, round int, attemptedURLs map[string]bool) ([]model.ResearchResult, articleResearchReport) {
|
|
var out []model.ResearchResult
|
|
report := articleResearchReport{Rounds: round}
|
|
queryLimit := e.Cfg.ArticleMaxResearchQueries
|
|
fetchCap := e.Cfg.ArticleResearchFetchResults
|
|
if e.effectiveArticleResearchStrategy() == "adaptive" {
|
|
// Repair research should be narrow: the reviewer has already identified
|
|
// concrete unsupported claims, so broad six-query/six-page fan-out is
|
|
// wasteful. A later review can request another repair round if needed.
|
|
if queryLimit > 3 {
|
|
queryLimit = 3
|
|
}
|
|
fetchCap = e.Cfg.ArticleAdaptiveInitialFetch
|
|
}
|
|
for i, raw := range unique(queries) {
|
|
if i >= queryLimit {
|
|
break
|
|
}
|
|
query := sanitizeSearchQuerySiteFilters(raw)
|
|
if query == "" {
|
|
continue
|
|
}
|
|
report.Queries++
|
|
inbox := e.sourceInboxResearch(ctx, query, fetchCap, containsFreshnessLanguage(query))
|
|
if len(inbox) > 0 {
|
|
report.InboxResults += len(inbox)
|
|
report.Accepted += len(inbox)
|
|
out = uniqueResearchEvidence(append(out, inbox...))
|
|
}
|
|
if len(inbox) >= e.Cfg.SourceInboxMinResults || !e.ResearchEnabledForRuntime() {
|
|
continue
|
|
}
|
|
remaining := fetchCap - len(inbox)
|
|
if remaining < 1 {
|
|
remaining = 1
|
|
}
|
|
language := "en-US"
|
|
if looksGermanResearchQuery(query) {
|
|
language = "de-DE"
|
|
}
|
|
question := model.ResearchQuestion{GapID: fmt.Sprintf("REVIEW-%d", i+1), Question: query, Critical: true, ExpectActionable: containsActionableLanguage(query)}
|
|
items, stats := e.executeArticleResearchQueryForSynthesis(ctx, trigger, nodeIDs, question, query, language, round, attemptedURLs, remaining)
|
|
report.SearchResults += stats.SearchResults
|
|
report.Fetched += stats.Fetched
|
|
report.Accepted += stats.Accepted
|
|
report.Rejected += stats.Rejected
|
|
report.FetchFailed += stats.FetchFailed
|
|
report.SearchFailed += stats.SearchFailed
|
|
out = uniqueResearchEvidence(append(out, items...))
|
|
}
|
|
return out, report
|
|
}
|
|
|
|
func looksGermanResearchQuery(value string) bool {
|
|
lower := " " + strings.ToLower(value) + " "
|
|
if strings.ContainsAny(lower, "äöüß") {
|
|
return true
|
|
}
|
|
for _, token := range []string{" der ", " die ", " das ", " welche ", " wie ", " warum ", " schritte ", " konfigurieren ", " prüfen ", " sichern "} {
|
|
if strings.Contains(lower, token) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func articleClaimReviewCounts(values []model.ArticleClaimReview) map[string]int {
|
|
out := map[string]int{"supported": 0, "partially_supported": 0, "unsupported": 0, "contradicted": 0}
|
|
for _, value := range values {
|
|
verdict := strings.ToLower(strings.TrimSpace(value.Verdict))
|
|
if _, ok := out[verdict]; ok {
|
|
out[verdict]++
|
|
}
|
|
}
|
|
return out
|
|
}
|