Files
glpi-neural-brain/internal/engine/article_generate_then_review.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
}