Files
ragflow/internal/ingestion/component/knowledge_compiler/wiki/wiki.go
Zhichang Yu 75ac8cec2e Align Go knowledge compiler RAPTOR with Python and drop guardrails (#17573)
Port RAPTOR knowledge compilation to match Python: remove Go-only
capacity guardrails, fix prompts, add token truncation, response
post-processing, retries, replace AHC with watershed.
2026-07-30 19:56:17 +08:00

1964 lines
51 KiB
Go

// Package wiki implements the "wiki" variant of KnowledgeCompiler: a
// document artifact pipeline MAP -> REDUCE -> PLAN -> REFINE that produces a
// wiki-style page (and supporting section products) with in-memory state only.
// The stage semantics are aligned with the Python wiki.py design, but the Go
// port keeps all intermediate artifacts in memory instead of persisting them to
// ES between stages.
package wiki
import (
"bytes"
"context"
"encoding/json"
"fmt"
"math"
"net/url"
"regexp"
"sort"
"strings"
"ragflow/internal/ingestion/component/knowledge_compiler/common"
"ragflow/internal/ingestion/component/knowledge_compiler/structure"
)
type wikiPipeline struct {
ctx context.Context
deps common.Deps
param common.Param
inputs common.Inputs
docID string
tenantID string
datasetID string
llmID string
mapExtracts []wikiExtract
reduced wikiExtract
plan wikiPlan
pages []wikiPageResult
}
type wikiExtract struct {
Entities []wikiEntity `json:"entities"`
Concepts []wikiConcept `json:"concepts"`
Claims []wikiClaim `json:"claims"`
Relations []wikiRelation `json:"relations"`
Topics []string `json:"topics"`
}
type wikiEntity struct {
Name string `json:"name"`
Type string `json:"type"`
Aliases []string `json:"aliases,omitempty"`
SourceChunkIDs []string `json:"source_chunk_ids,omitempty"`
}
type wikiConcept struct {
Term string `json:"term"`
Definition string `json:"definition_excerpt"`
SourceChunkIDs []string `json:"source_chunk_ids,omitempty"`
}
type wikiClaim struct {
Statement string `json:"statement"`
Subject string `json:"subject"`
Confidence string `json:"confidence"`
SourceChunkIDs []string `json:"source_chunk_ids,omitempty"`
}
type wikiRelation struct {
From string `json:"from"`
To string `json:"to"`
Type string `json:"type"`
SourceChunkIDs []string `json:"source_chunk_ids,omitempty"`
}
type wikiPlan struct {
Title string `json:"title"`
Slug string `json:"slug"`
Lead string `json:"lead"`
Sections []wikiPlanSection `json:"sections"`
PageType string `json:"page_type,omitempty"`
Topic string `json:"topic,omitempty"`
Entities []string `json:"entity_names,omitempty"`
Related []string `json:"related_kb_pages,omitempty"`
Pages []wikiPlanPage `json:"pages,omitempty"`
}
type wikiPlanSection struct {
Heading string `json:"heading"`
Points []string `json:"points"`
}
type wikiPlanPage struct {
Action string `json:"action"`
Slug string `json:"slug"`
Title string `json:"title"`
PageType string `json:"page_type"`
Topic string `json:"topic"`
EntityNames []string `json:"entity_names"`
RelatedKB []string `json:"related_kb_pages"`
Priority int `json:"priority"`
Lead string `json:"lead"`
Sections []wikiPlanSection `json:"sections"`
}
const (
wikiPlanUpdateThreshold = 0.92
wikiPlanMaybeThreshold = 0.78
wikiPlanSearchTopK = 3
wikiMergeShrinkRatio = 0.7
)
// Run executes the wiki variant.
func Run(ctx context.Context, deps common.Deps, param common.Param, inputs common.Inputs) (common.Outputs, error) {
docID := inputs.DocID
llmID := firstNonEmpty(inputs.LLMID, param.LLMID)
if docID == "" {
docID = "unknown"
}
p := &wikiPipeline{
ctx: ctx,
deps: deps,
param: param,
inputs: inputs,
docID: docID,
tenantID: deps.TenantID,
datasetID: deps.DatasetID,
llmID: llmID,
}
if err := p.run(); err != nil {
return common.Outputs{}, err
}
products := buildWikiPageProducts(p.tenantID, p.docID, p.pages)
for i := range products {
if products[i].Meta == nil {
products[i].Meta = map[string]any{}
}
}
if len(products) > 0 {
if deps.Chat == nil {
return common.Outputs{}, fmt.Errorf("wiki: chat model required for page generation")
}
if deps.Embed == nil {
return common.Outputs{}, fmt.Errorf("wiki: embedder required (page + sections must carry vectors)")
}
texts := make([]string, len(products))
for i, p := range products {
texts[i] = p.Content
}
vectors, err := deps.Embed.Encode(ctx, texts)
if err != nil {
return common.Outputs{}, err
}
for i := range products {
if i < len(vectors) {
products[i].Vector = vectors[i]
}
}
}
store := common.NewMemStore()
decider := structure.CosineDecider{Threshold: param.SimilarityThreshold}
stats := structure.MergeStats{}
for _, p := range products {
s, err := structure.MergeIntoStore(ctx, store, decider, []common.Product{p})
if err != nil {
return common.Outputs{}, err
}
stats.Inserted += s.Inserted
stats.Updated += s.Updated
stats.DuplicatesDropped += s.DuplicatesDropped
}
prod := store.Snapshot()
if param.EnableHistoricalDedup || len(inputs.HistoricalCandidates) > 0 {
survivors, dropped, err := dedupHistorical(ctx, deps, param, p.tenantID, p.datasetID, inputs.HistoricalCandidates, prod)
if err != nil {
return common.Outputs{}, err
}
prod = survivors
stats.DuplicatesDropped += dropped
}
// Buffer every product in one slice; the component merges them into the
// upstream chunk stream (matching Python, which appends compiled units onto
// the chunk list).
out := common.Outputs{
Products: prod,
DuplicatesDropped: stats.DuplicatesDropped,
}
return out, nil
}
func (p *wikiPipeline) run() error {
if len(p.inputs.Chunks) == 0 {
return nil
}
if err := p.runMap(); err != nil {
return err
}
p.reduced = reduceExtracts(p.mapExtracts)
plan, err := p.runPlan()
if err != nil {
return err
}
p.plan = plan
pages, err := p.runRefine()
if err != nil {
return err
}
p.pages = pages
return nil
}
func (p *wikiPipeline) runMap() error {
batches := common.PackBatches(p.inputs.Chunks, 4096, p.deps.Tokenizer)
for _, batch := range batches {
if err := p.ctx.Err(); err != nil {
return err
}
extract, err := p.mapBatch(batch)
if err != nil {
return err
}
p.mapExtracts = append(p.mapExtracts, extract)
}
return nil
}
func (p *wikiPipeline) mapBatch(batch []common.Chunk) (wikiExtract, error) {
parserConfig, _ := p.inputs.VariantSpecific["parser_config"].(map[string]any)
user, _ := buildWikiMapPrompt(p.docID, batch, parserConfig, p.param.Language)
raw, err := common.GenJSON(p.ctx, p.deps.Chat, common.ChatRequest{
LLMID: p.llmID,
SystemPrompt: wikiMapSystem,
UserPrompt: user,
})
if err != nil {
return wikiExtract{}, err
}
return parseWikiExtract(raw), nil
}
func (p *wikiPipeline) runPlan() (wikiPlan, error) {
batches := packWikiPlanBatches(p.reduced, wikiPlanTokenBudget)
if len(batches) == 0 {
batches = []wikiExtract{p.reduced}
}
plans := make([]wikiPlan, 0, len(batches))
for i, batch := range batches {
plan, err := p.runPlanBatch(batch, i+1, len(batches))
if err != nil {
return wikiPlan{}, err
}
plans = append(plans, plan)
}
if len(plans) == 1 {
plan := normalizeWikiPlan(plans[0], p.docID, p.reduced)
return p.reconcilePlan(plan)
}
plan, err := p.mergePlanCandidates(plans)
if err != nil {
return wikiPlan{}, err
}
return p.reconcilePlan(plan)
}
func (p *wikiPipeline) runRefine() ([]wikiPageResult, error) {
pages := normalizeWikiPlanPages(p.plan.Pages, p.reduced)
if len(pages) == 0 {
return nil, nil
}
pageTitles := map[string]string{}
allPlanSlugs := make([]string, 0, len(pages))
for _, page := range pages {
if page.Slug == "" {
continue
}
allPlanSlugs = append(allPlanSlugs, page.Slug)
pageTitles[page.Slug] = page.Title
}
entityLookup := buildWikiEntityLookup(p.reduced.Entities)
conceptLookup := buildWikiConceptLookup(p.reduced.Concepts)
results := make([]wikiPageResult, 0, len(pages))
for _, planItem := range pages {
if p.ctx.Err() != nil {
return nil, p.ctx.Err()
}
evidence := assembleWikiPageEvidence(planItem, p.reduced.Claims, entityLookup, conceptLookup)
sourceChunkIDs := collectWikiEvidenceChunkIDs(evidence)
sourceContext := buildSourceContext(p.inputs.Chunks, sourceChunkIDs)
if strings.TrimSpace(sourceContext) == "" {
sourceContext = buildSourceContext(p.inputs.Chunks, p.reduced.sourceChunkIDs())
}
available := make([]string, 0, len(allPlanSlugs))
for _, slug := range allPlanSlugs {
if slug != planItem.Slug {
available = append(available, "- [["+slug+"]]")
}
}
if len(available) == 0 {
available = []string{"(none — this is the only page)"}
}
var existing *common.WikiPageCandidate
var err error
if strings.EqualFold(planItem.Action, "UPDATE") && p.deps.WikiPages != nil {
existing, err = p.deps.WikiPages.GetPageBySlug(p.ctx, p.tenantID, p.datasetID, planItem.Slug)
if err != nil {
return nil, err
}
}
existingSection := ""
existingRaw := ""
if existing != nil {
existingRaw = firstNonEmpty(existing.ContentMDRaw, existing.ContentMD)
if strings.TrimSpace(existingRaw) != "" {
existingSection = "## Existing page content (UPDATE — integrate new evidence into this)\n\n" + existingRaw + "\n"
}
}
user := renderWikiTemplate(wikiRefineWriterUserTemplate, map[string]string{
"action": firstNonEmpty(planItem.Action, "CREATE"),
"slug": planItem.Slug,
"title": firstNonEmpty(planItem.Title, planItem.Slug),
"page_type": firstNonEmpty(planItem.PageType, "concept"),
"all_plan_slugs": strings.Join(available, "\n"),
"existing_section": existingSection,
"source_context": sourceContext,
"evidence_count": fmt.Sprintf("%d", len(evidence)),
"evidence_blocks": formatWikiEvidenceBlocks(evidence),
})
resp, err := p.deps.Chat.Chat(p.ctx, common.ChatRequest{
LLMID: p.llmID,
SystemPrompt: buildWikiRefineWriterSystem(""),
UserPrompt: user,
})
if err != nil {
return nil, err
}
if resp == nil {
return nil, fmt.Errorf("knowledge_compiler: wiki refine returned no response")
}
contentRaw := strings.TrimSpace(firstNonEmpty(resp.Content))
if contentRaw == "" {
contentRaw = "# " + firstNonEmpty(planItem.Title, planItem.Slug) + "\n\n(Page generation produced no content.)"
}
if strings.TrimSpace(existingRaw) != "" {
contentRaw, err = p.mergeWikiPageContent(existingRaw, contentRaw, planItem.Slug)
if err != nil {
return nil, err
}
}
contentRendered, outlinks := transformWikiLinks(contentRaw, firstNonEmpty(p.datasetID, p.docID), pageTitles)
sourceDocIDs := collectWikiSourceDocIDs(p.inputs.Chunks, sourceChunkIDs, p.docID)
summary := firstParagraph(contentRendered)
if summary == "" {
summary = firstNonEmpty(planItem.Title, planItem.Slug)
}
topic := firstNonEmpty(planItem.Topic, planItem.Title, planItem.Slug)
results = append(results, wikiPageResult{
Slug: planItem.Slug,
Title: firstNonEmpty(planItem.Title, planItem.Slug),
PageType: firstNonEmpty(planItem.PageType, "concept"),
Topic: topic,
Action: firstNonEmpty(planItem.Action, "CREATE"),
EntityNames: uniqueStrings(planItem.EntityNames),
RelatedKBPages: uniqueStrings(planItem.RelatedKB),
ContentRaw: contentRaw,
Content: contentRendered,
Summary: summary,
Outlinks: outlinks,
SourceChunkIDs: sourceChunkIDs,
SourceDocIDs: sourceDocIDs,
})
}
return results, nil
}
func (p *wikiPipeline) runPlanBatch(batch wikiExtract, batchIndex, batchTotal int) (wikiPlan, error) {
user := renderWikiTemplate(wikiPlanBatchUserTemplate, map[string]string{
"doc_id": p.docID,
"batch_index": fmt.Sprintf("%d", batchIndex),
"batch_total": fmt.Sprintf("%d", batchTotal),
"entities": mustJSON(batch.Entities),
"concepts": mustJSON(batch.Concepts),
"claims": mustJSON(batch.Claims),
"relations": mustJSON(batch.Relations),
"topics": mustJSON(batch.Topics),
})
raw, err := common.GenJSON(p.ctx, p.deps.Chat, common.ChatRequest{
LLMID: p.llmID,
SystemPrompt: wikiPlanSystem,
UserPrompt: user,
})
if err != nil {
return wikiPlan{}, err
}
return parseWikiPlan(raw, p.docID, batch), nil
}
func (p *wikiPipeline) mergePlanCandidates(plans []wikiPlan) (wikiPlan, error) {
user := renderWikiTemplate(wikiPlanMergeUserTemplate, map[string]string{
"doc_id": p.docID,
"candidates": mustPrettyJSON(plans),
})
raw, err := common.GenJSON(p.ctx, p.deps.Chat, common.ChatRequest{
LLMID: p.llmID,
SystemPrompt: wikiPlanSystem,
UserPrompt: user,
})
if err != nil {
return wikiPlan{}, err
}
return normalizeWikiPlan(parseWikiPlan(raw, p.docID, p.reduced), p.docID, p.reduced), nil
}
func (p *wikiPipeline) reconcilePlan(plan wikiPlan) (wikiPlan, error) {
if p.deps.WikiPages == nil || p.deps.Embed == nil || strings.TrimSpace(p.datasetID) == "" || len(plan.Pages) == 0 {
return plan, nil
}
queryTexts := make([]string, 0, len(plan.Pages))
pageIdx := make([]int, 0, len(plan.Pages))
for i, page := range plan.Pages {
page = normalizeWikiPlanPage(page)
plan.Pages[i] = page
query := buildWikiPageQueryText(page)
if strings.TrimSpace(query) == "" {
continue
}
queryTexts = append(queryTexts, query)
pageIdx = append(pageIdx, i)
}
if len(queryTexts) == 0 {
return plan, nil
}
vectors, err := p.deps.Embed.Encode(p.ctx, queryTexts)
if err != nil {
return wikiPlan{}, err
}
for i, idx := range pageIdx {
if idx >= len(plan.Pages) || i >= len(vectors) {
continue
}
page := plan.Pages[idx]
existing, err := p.reconcilePlanPage(page, vectors[i])
if err != nil {
return wikiPlan{}, err
}
if existing == nil {
continue
}
page.Action = "UPDATE"
page.Slug = firstNonEmpty(existing.Slug, page.Slug)
page.Title = firstNonEmpty(existing.Title, page.Title)
page.PageType = firstNonEmpty(existing.PageType, page.PageType)
page.Topic = firstNonEmpty(existing.Topic, page.Topic)
page.RelatedKB = mergeStrings(page.RelatedKB, existing.RelatedKBPages)
page.EntityNames = mergeStrings(page.EntityNames, existing.EntityNames)
plan.Pages[idx] = page
}
plan.Pages = normalizeWikiPlanPages(plan.Pages, p.reduced)
return plan, nil
}
func (p *wikiPipeline) reconcilePlanPage(page wikiPlanPage, queryVec []float32) (*common.WikiPageCandidate, error) {
if p.deps.WikiPages == nil {
return nil, nil
}
if hit, err := p.deps.WikiPages.GetPageBySlug(p.ctx, p.tenantID, p.datasetID, page.Slug); err != nil {
return nil, err
} else if hit != nil {
return hit, nil
}
cands, err := p.deps.WikiPages.FindSimilarPages(p.ctx, p.tenantID, p.datasetID, queryVec, wikiPlanSearchTopK)
if err != nil || len(cands) == 0 {
return nil, err
}
cands = rerankWikiPlanCandidates(page, cands)
targetNames := normalizedStringSet(page.EntityNames)
pageTitle := normKey(page.Title)
pageTopic := normKey(page.Topic)
maybe := make([]common.WikiPageCandidate, 0, len(cands))
for i := range cands {
cand := cands[i]
if cand.Score >= wikiPlanUpdateThreshold {
return &cand, nil
}
if cand.Score < wikiPlanMaybeThreshold {
continue
}
if normKey(cand.Title) == pageTitle || normKey(cand.Topic) == pageTopic {
return &cand, nil
}
if intersectsNormalized(targetNames, cand.EntityNames) {
return &cand, nil
}
maybe = append(maybe, cand)
}
if len(maybe) > 0 {
return p.resolveMaybePlanPage(page, maybe)
}
return nil, nil
}
func rerankWikiPlanCandidates(page wikiPlanPage, candidates []common.WikiPageCandidate) []common.WikiPageCandidate {
if len(candidates) <= 1 {
return candidates
}
type scored struct {
candidate common.WikiPageCandidate
boost float64
}
targetNames := normalizedStringSet(page.EntityNames)
pageType := normKey(page.PageType)
pageTitle := normKey(page.Title)
pageTopic := normKey(page.Topic)
scoredCands := make([]scored, 0, len(candidates))
for _, cand := range candidates {
boost := cand.Score
if pageType != "" && normKey(cand.PageType) == pageType {
boost += 0.08
}
if pageTitle != "" && normKey(cand.Title) == pageTitle {
boost += 0.12
}
if pageTopic != "" && normKey(cand.Topic) == pageTopic {
boost += 0.08
}
if overlap := normalizedOverlapCount(targetNames, cand.EntityNames); overlap > 0 {
boost += 0.05 * float64(overlap)
}
scoredCands = append(scoredCands, scored{candidate: cand, boost: boost})
}
sort.SliceStable(scoredCands, func(i, j int) bool {
if scoredCands[i].boost == scoredCands[j].boost {
return scoredCands[i].candidate.Slug < scoredCands[j].candidate.Slug
}
return scoredCands[i].boost > scoredCands[j].boost
})
out := make([]common.WikiPageCandidate, 0, len(scoredCands))
for _, item := range scoredCands {
out = append(out, item.candidate)
}
return out
}
func (p *wikiPipeline) resolveMaybePlanPage(page wikiPlanPage, candidates []common.WikiPageCandidate) (*common.WikiPageCandidate, error) {
if len(candidates) == 0 {
return nil, nil
}
raw, err := common.GenJSON(p.ctx, p.deps.Chat, common.ChatRequest{
LLMID: p.llmID,
SystemPrompt: wikiPlanReconcileSystem,
UserPrompt: renderWikiTemplate(wikiPlanReconcileUserTemplate, map[string]string{
"planned_page": mustPrettyJSON(page),
"candidates": mustPrettyJSON(candidates),
}),
})
if err != nil {
return nil, err
}
action := strings.ToUpper(strings.TrimSpace(firstString(raw["action"])))
if action != "UPDATE" {
return nil, nil
}
slug := strings.TrimSpace(firstString(raw["slug"]))
if slug == "" {
return nil, nil
}
for i := range candidates {
if candidates[i].Slug == slug {
return &candidates[i], nil
}
}
return nil, nil
}
func buildWikiPageQueryText(page wikiPlanPage) string {
parts := []string{page.Title, page.Topic, strings.Join(page.EntityNames, " ")}
var out []string
for _, part := range parts {
if s := strings.TrimSpace(part); s != "" {
out = append(out, s)
}
}
return strings.Join(out, "\n")
}
func (p *wikiPipeline) mergeWikiPageContent(existingMD, newMD, slug string) (string, error) {
if strings.TrimSpace(existingMD) == "" {
return newMD, nil
}
if strings.TrimSpace(newMD) == "" {
return existingMD, nil
}
if strings.TrimSpace(existingMD) == strings.TrimSpace(newMD) {
return newMD, nil
}
fallback := conservativeMergeWikiMarkdown(existingMD, newMD)
user := fmt.Sprintf("Merge these two versions of wiki page `%s`:\n\n## EXISTING VERSION\n\n%s\n\n---\n\n## INCOMING VERSION\n\n%s\n\n---\n\nProduce the merged page now. Return only the markdown content.", slug, existingMD, newMD)
resp, err := p.deps.Chat.Chat(p.ctx, common.ChatRequest{
LLMID: p.llmID,
SystemPrompt: wikiRefineMergeSystem,
UserPrompt: user,
})
if err != nil {
return "", err
}
if resp == nil {
return fallback, nil
}
merged := strings.TrimSpace(firstNonEmpty(resp.Content))
if merged == "" {
return fallback, nil
}
minAcceptable := int(float64(maxInt(len(existingMD), len(newMD))) * wikiMergeShrinkRatio)
if len(merged) < minAcceptable {
return fallback, nil
}
if wikiMarkdownDropsContent(merged, existingMD) || wikiMarkdownDropsContent(merged, newMD) {
return fallback, nil
}
return merged, nil
}
func conservativeMergeWikiMarkdown(existingMD, newMD string) string {
if strings.Contains(existingMD, newMD) {
return strings.TrimSpace(existingMD)
}
if strings.Contains(newMD, existingMD) {
return strings.TrimSpace(newMD)
}
title := firstNonEmpty(wikiMarkdownTitle(existingMD), wikiMarkdownTitle(newMD))
blocks := make([]string, 0, 8)
seen := map[string]bool{}
appendBlocks := func(body string) {
for _, block := range splitWikiMarkdownBlocks(body) {
key := normKey(block)
if key == "" || seen[key] {
continue
}
seen[key] = true
blocks = append(blocks, block)
}
}
appendBlocks(wikiMarkdownBody(existingMD))
appendBlocks(wikiMarkdownBody(newMD))
if title != "" {
if len(blocks) == 0 {
return "# " + title
}
return "# " + title + "\n\n" + strings.Join(blocks, "\n\n")
}
return strings.Join(blocks, "\n\n")
}
func wikiMarkdownDropsContent(mergedMD, sourceMD string) bool {
mergedNorm := normKey(mergedMD)
for _, block := range splitWikiMarkdownBlocks(wikiMarkdownBody(sourceMD)) {
if !wikiMarkdownContentBlock(block) {
continue
}
if !strings.Contains(mergedNorm, normKey(block)) {
return true
}
}
return false
}
func wikiMarkdownTitle(md string) string {
for _, line := range strings.Split(md, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "# ") {
return strings.TrimSpace(strings.TrimPrefix(line, "# "))
}
if line != "" {
break
}
}
return ""
}
func wikiMarkdownBody(md string) string {
lines := strings.Split(md, "\n")
for i, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "# ") {
return strings.TrimSpace(strings.Join(lines[i+1:], "\n"))
}
if line != "" {
break
}
}
return strings.TrimSpace(md)
}
func splitWikiMarkdownBlocks(md string) []string {
parts := strings.Split(strings.TrimSpace(md), "\n\n")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
func wikiMarkdownContentBlock(block string) bool {
block = strings.TrimSpace(block)
if block == "" {
return false
}
if strings.HasPrefix(block, "#") {
return false
}
return true
}
func reduceExtracts(extracts []wikiExtract) wikiExtract {
out := wikiExtract{}
type entityKey struct {
Name string
Type string
}
type conceptKey struct {
Term string
}
entities := map[entityKey]*wikiEntity{}
concepts := map[conceptKey]*wikiConcept{}
seenRelations := map[string]bool{}
seenTopics := map[string]bool{}
for _, ex := range extracts {
for _, e := range ex.Entities {
key := entityKey{Name: normKey(e.Name), Type: normKey(e.Type)}
if cur, ok := entities[key]; ok {
cur.Aliases = mergeStrings(cur.Aliases, e.Aliases)
cur.SourceChunkIDs = mergeStrings(cur.SourceChunkIDs, e.SourceChunkIDs)
if cur.Type == "" {
cur.Type = e.Type
}
continue
}
item := e
item.Name = strings.TrimSpace(item.Name)
item.Type = strings.TrimSpace(item.Type)
item.Aliases = uniqueStrings(item.Aliases)
item.SourceChunkIDs = uniqueStrings(item.SourceChunkIDs)
entities[key] = &item
}
for _, c := range ex.Concepts {
key := conceptKey{Term: normKey(c.Term)}
if cur, ok := concepts[key]; ok {
if cur.Definition == "" {
cur.Definition = c.Definition
}
cur.SourceChunkIDs = mergeStrings(cur.SourceChunkIDs, c.SourceChunkIDs)
continue
}
item := c
item.Term = strings.TrimSpace(item.Term)
item.Definition = strings.TrimSpace(item.Definition)
item.SourceChunkIDs = uniqueStrings(item.SourceChunkIDs)
concepts[key] = &item
}
for _, c := range ex.Claims {
c.SourceChunkIDs = uniqueStrings(c.SourceChunkIDs)
out.Claims = append(out.Claims, c)
}
for _, r := range ex.Relations {
key := normKey(r.From) + "\x00" + normKey(r.Type) + "\x00" + normKey(r.To)
if seenRelations[key] {
continue
}
r.SourceChunkIDs = uniqueStrings(r.SourceChunkIDs)
seenRelations[key] = true
out.Relations = append(out.Relations, r)
}
for _, t := range ex.Topics {
t = strings.TrimSpace(t)
if t == "" {
continue
}
key := normKey(t)
if seenTopics[key] {
continue
}
seenTopics[key] = true
out.Topics = append(out.Topics, t)
}
}
out.Entities = make([]wikiEntity, 0, len(entities))
keys := make([]entityKey, 0, len(entities))
for k := range entities {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].Name == keys[j].Name {
return keys[i].Type < keys[j].Type
}
return keys[i].Name < keys[j].Name
})
for _, k := range keys {
out.Entities = append(out.Entities, *entities[k])
}
out.Concepts = make([]wikiConcept, 0, len(concepts))
ckeys := make([]conceptKey, 0, len(concepts))
for k := range concepts {
ckeys = append(ckeys, k)
}
sort.Slice(ckeys, func(i, j int) bool { return ckeys[i].Term < ckeys[j].Term })
for _, k := range ckeys {
out.Concepts = append(out.Concepts, *concepts[k])
}
return out
}
func parseWikiExtract(raw map[string]any) wikiExtract {
out := wikiExtract{}
out.Entities = parseWikiEntities(raw["entities"])
out.Concepts = parseWikiConcepts(raw["concepts"])
out.Claims = parseWikiClaims(raw["claims"])
out.Relations = parseWikiRelations(raw["relations"])
out.Topics = parseWikiStrings(raw["topics"])
return out
}
func parseWikiPlan(raw map[string]any, docID string, reduced wikiExtract) wikiPlan {
plan := wikiPlan{
Title: firstString(raw["title"]),
Slug: firstString(raw["slug"]),
Lead: firstString(raw["lead"]),
Sections: parseWikiPlanSections(raw["sections"]),
PageType: firstString(raw["page_type"]),
Topic: firstString(raw["topic"]),
Entities: parseWikiStrings(raw["entity_names"]),
Related: parseWikiStrings(raw["related_kb_pages"]),
Pages: parseWikiPlanPages(raw["pages"]),
}
if len(plan.Pages) > 0 {
first := plan.Pages[0]
if plan.Title == "" {
plan.Title = first.Title
}
if plan.Slug == "" {
plan.Slug = first.Slug
}
if plan.Lead == "" {
plan.Lead = first.Lead
}
if plan.PageType == "" {
plan.PageType = first.PageType
}
if plan.Topic == "" {
plan.Topic = first.Topic
}
if len(plan.Entities) == 0 {
plan.Entities = first.EntityNames
}
if len(plan.Related) == 0 {
plan.Related = first.RelatedKB
}
if len(plan.Sections) == 0 {
plan.Sections = first.Sections
}
}
if plan.Title == "" {
if len(reduced.Topics) > 0 {
plan.Title = reduced.Topics[0]
} else if len(reduced.Entities) > 0 {
plan.Title = reduced.Entities[0].Name
} else if len(reduced.Concepts) > 0 {
plan.Title = reduced.Concepts[0].Term
} else {
plan.Title = docID
}
}
if plan.Slug == "" {
plan.Slug = slugify(plan.Title)
}
if plan.Lead == "" {
plan.Lead = plan.Title
}
if len(plan.Sections) == 0 {
plan.Sections = []wikiPlanSection{{Heading: "Overview", Points: []string{plan.Lead}}}
}
return plan
}
func normalizeWikiPlan(plan wikiPlan, docID string, reduced wikiExtract) wikiPlan {
plan.Pages = normalizeWikiPlanPages(plan.Pages, reduced)
if plan.Title == "" {
plan.Title = firstPlanTitle(plan.Pages, reduced, docID)
}
if plan.Slug == "" {
plan.Slug = slugify(plan.Title)
}
if plan.Lead == "" {
plan.Lead = plan.Title
}
if len(plan.Sections) == 0 {
plan.Sections = []wikiPlanSection{{Heading: "Overview", Points: []string{plan.Lead}}}
}
return plan
}
func normalizeWikiPlanPages(pages []wikiPlanPage, reduced wikiExtract) []wikiPlanPage {
existing := map[string]wikiPlanPage{}
out := make([]wikiPlanPage, 0, len(pages))
for _, page := range pages {
page = normalizeWikiPlanPage(page)
if page.Slug == "" {
continue
}
if _, ok := existing[page.Slug]; ok {
continue
}
existing[page.Slug] = page
out = append(out, page)
}
fallback := buildWikiFallbackPages(reduced)
if len(out) == 0 {
for _, page := range fallback {
if page.Slug == "" {
continue
}
existing[page.Slug] = page
out = append(out, page)
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Priority == out[j].Priority {
return out[i].Slug < out[j].Slug
}
return out[i].Priority < out[j].Priority
})
for i := range out {
if out[i].Priority <= 0 {
out[i].Priority = i + 1
}
}
return out
}
func normalizeWikiPlanPage(page wikiPlanPage) wikiPlanPage {
page.Action = strings.ToUpper(strings.TrimSpace(page.Action))
if page.Action == "" {
page.Action = "CREATE"
}
page.Slug = strings.TrimSpace(page.Slug)
if page.Slug == "" {
page.Slug = slugify(page.Title)
}
page.Title = strings.TrimSpace(page.Title)
if page.Title == "" {
page.Title = page.Slug
}
page.PageType = strings.TrimSpace(page.PageType)
if page.PageType == "" {
page.PageType = "concept"
}
page.Topic = strings.TrimSpace(page.Topic)
if page.Topic == "" {
page.Topic = page.Title
}
page.EntityNames = uniqueStrings(page.EntityNames)
page.RelatedKB = uniqueStrings(page.RelatedKB)
page.Sections = normalizeWikiPlanSections(page.Sections)
if page.Priority <= 0 {
page.Priority = 99
}
return page
}
func normalizeWikiPlanSections(sections []wikiPlanSection) []wikiPlanSection {
if len(sections) == 0 {
return []wikiPlanSection{{Heading: "Overview", Points: nil}}
}
out := make([]wikiPlanSection, 0, len(sections))
for _, sec := range sections {
sec.Heading = strings.TrimSpace(sec.Heading)
sec.Points = uniqueStrings(sec.Points)
if sec.Heading == "" {
continue
}
out = append(out, sec)
}
if len(out) == 0 {
return []wikiPlanSection{{Heading: "Overview", Points: nil}}
}
return out
}
func buildWikiFallbackPages(reduced wikiExtract) []wikiPlanPage {
var out []wikiPlanPage
seen := map[string]bool{}
appendPage := func(page wikiPlanPage) {
page = normalizeWikiPlanPage(page)
if page.Slug == "" || seen[page.Slug] {
return
}
seen[page.Slug] = true
out = append(out, page)
}
for _, e := range reduced.Entities {
appendPage(wikiPlanPage{
Action: "CREATE",
Slug: "entity/" + slugify(e.Name),
Title: e.Name,
PageType: "entity",
Topic: e.Name,
EntityNames: append([]string{e.Name}, e.Aliases...),
Priority: len(out) + 1,
Lead: e.Name,
Sections: []wikiPlanSection{{Heading: "Overview", Points: []string{e.Name}}},
})
}
for _, c := range reduced.Concepts {
appendPage(wikiPlanPage{
Action: "CREATE",
Slug: "concept/" + slugify(c.Term),
Title: c.Term,
PageType: "concept",
Topic: c.Term,
EntityNames: []string{c.Term},
Priority: len(out) + 1,
Lead: c.Definition,
Sections: []wikiPlanSection{{Heading: "Overview", Points: []string{c.Term}}},
})
}
if len(out) == 0 {
for _, t := range reduced.Topics {
appendPage(wikiPlanPage{
Action: "CREATE",
Slug: "topic/" + slugify(t),
Title: t,
PageType: "topic",
Topic: t,
Priority: len(out) + 1,
Lead: t,
Sections: []wikiPlanSection{{Heading: "Overview", Points: []string{t}}},
})
}
}
if len(out) == 0 {
appendPage(wikiPlanPage{
Action: "CREATE",
Slug: slugify(firstPlanTitle(nil, reduced, "")),
Title: firstPlanTitle(nil, reduced, ""),
PageType: "concept",
Topic: firstPlanTitle(nil, reduced, ""),
Priority: 1,
Lead: firstPlanTitle(nil, reduced, ""),
Sections: []wikiPlanSection{{Heading: "Overview", Points: []string{firstPlanTitle(nil, reduced, "")}}},
})
}
return out
}
func firstPlanTitle(pages []wikiPlanPage, reduced wikiExtract, docID string) string {
for _, page := range pages {
if s := strings.TrimSpace(page.Title); s != "" {
return s
}
}
if len(reduced.Topics) > 0 {
return reduced.Topics[0]
}
if len(reduced.Entities) > 0 {
return reduced.Entities[0].Name
}
if len(reduced.Concepts) > 0 {
return reduced.Concepts[0].Term
}
if docID != "" {
return docID
}
return "wiki"
}
func (p wikiPlan) firstPageCandidate() wikiPlanPage {
if len(p.Pages) > 0 {
return p.Pages[0]
}
return wikiPlanPage{
Action: "CREATE",
Slug: p.Slug,
Title: p.Title,
PageType: p.PageType,
Topic: p.Topic,
EntityNames: uniqueStrings(p.Entities),
RelatedKB: uniqueStrings(p.Related),
Priority: 1,
Lead: p.Lead,
Sections: p.Sections,
}
}
func packWikiPlanBatches(reduced wikiExtract, budget int) []wikiExtract {
if budget <= 0 {
budget = wikiPlanTokenBudget
}
var batches []wikiExtract
cur := wikiExtract{}
curTokens := 0
flush := func() {
if len(cur.Entities)+len(cur.Concepts)+len(cur.Claims)+len(cur.Relations)+len(cur.Topics) > 0 {
batches = append(batches, cur)
cur = wikiExtract{}
curTokens = 0
}
}
add := func(itemTokens int, addFn func()) {
if curTokens > 0 && curTokens+itemTokens > budget {
flush()
}
addFn()
curTokens += itemTokens
}
for _, e := range reduced.Entities {
add(common.EstimateTokens(mustJSON(e)), func() { cur.Entities = append(cur.Entities, e) })
}
for _, c := range reduced.Concepts {
add(common.EstimateTokens(mustJSON(c)), func() { cur.Concepts = append(cur.Concepts, c) })
}
for _, c := range reduced.Claims {
add(common.EstimateTokens(mustJSON(c)), func() { cur.Claims = append(cur.Claims, c) })
}
for _, r := range reduced.Relations {
add(common.EstimateTokens(mustJSON(r)), func() { cur.Relations = append(cur.Relations, r) })
}
for _, t := range reduced.Topics {
add(common.EstimateTokens(mustJSON(t)), func() { cur.Topics = append(cur.Topics, t) })
}
flush()
return batches
}
func parseWikiPlanPages(raw any) []wikiPlanPage {
arr, ok := raw.([]any)
if !ok {
return nil
}
out := make([]wikiPlanPage, 0, len(arr))
for _, item := range arr {
m, ok := item.(map[string]any)
if !ok {
continue
}
page := wikiPlanPage{
Action: strings.TrimSpace(firstString(m["action"])),
Slug: strings.TrimSpace(firstString(m["slug"])),
Title: strings.TrimSpace(firstString(m["title"])),
PageType: strings.TrimSpace(firstString(m["page_type"])),
Topic: strings.TrimSpace(firstString(m["topic"])),
EntityNames: parseWikiStrings(m["entity_names"]),
RelatedKB: parseWikiStrings(m["related_kb_pages"]),
Priority: int(firstNumber(m["priority"])),
Lead: strings.TrimSpace(firstString(m["lead"])),
Sections: parseWikiPlanSections(m["sections"]),
}
if page.Slug != "" || page.Title != "" {
out = append(out, page)
}
}
return out
}
func parseWikiEntities(raw any) []wikiEntity {
arr, ok := raw.([]any)
if !ok {
return nil
}
out := make([]wikiEntity, 0, len(arr))
for _, item := range arr {
m, ok := item.(map[string]any)
if !ok {
continue
}
e := wikiEntity{
Name: strings.TrimSpace(firstString(m["name"])),
Type: strings.TrimSpace(firstString(m["type"])),
Aliases: parseWikiStrings(m["aliases"]),
SourceChunkIDs: parseWikiStrings(m["source_chunk_ids"]),
}
if len(e.SourceChunkIDs) == 0 {
if s := strings.TrimSpace(firstString(m["source_chunk_id"])); s != "" {
e.SourceChunkIDs = []string{s}
}
}
if e.Name != "" {
out = append(out, e)
}
}
return out
}
func parseWikiConcepts(raw any) []wikiConcept {
arr, ok := raw.([]any)
if !ok {
return nil
}
out := make([]wikiConcept, 0, len(arr))
for _, item := range arr {
m, ok := item.(map[string]any)
if !ok {
continue
}
c := wikiConcept{
Term: strings.TrimSpace(firstString(m["term"])),
Definition: strings.TrimSpace(firstString(m["definition_excerpt"])),
SourceChunkIDs: parseWikiStrings(m["source_chunk_ids"]),
}
if len(c.SourceChunkIDs) == 0 {
if s := strings.TrimSpace(firstString(m["source_chunk_id"])); s != "" {
c.SourceChunkIDs = []string{s}
}
}
if c.Term != "" {
out = append(out, c)
}
}
return out
}
func parseWikiClaims(raw any) []wikiClaim {
arr, ok := raw.([]any)
if !ok {
return nil
}
out := make([]wikiClaim, 0, len(arr))
for _, item := range arr {
m, ok := item.(map[string]any)
if !ok {
continue
}
c := wikiClaim{
Statement: strings.TrimSpace(firstString(m["statement"])),
Subject: strings.TrimSpace(firstString(m["subject"])),
Confidence: strings.TrimSpace(firstString(m["confidence"])),
SourceChunkIDs: parseWikiStrings(m["source_chunk_ids"]),
}
if len(c.SourceChunkIDs) == 0 {
if s := strings.TrimSpace(firstString(m["source_chunk_id"])); s != "" {
c.SourceChunkIDs = []string{s}
}
}
if c.Statement != "" {
out = append(out, c)
}
}
return out
}
func parseWikiRelations(raw any) []wikiRelation {
arr, ok := raw.([]any)
if !ok {
return nil
}
out := make([]wikiRelation, 0, len(arr))
for _, item := range arr {
m, ok := item.(map[string]any)
if !ok {
continue
}
r := wikiRelation{
From: strings.TrimSpace(firstString(m["from"])),
To: strings.TrimSpace(firstString(m["to"])),
Type: strings.TrimSpace(firstString(m["type"])),
SourceChunkIDs: parseWikiStrings(m["source_chunk_ids"]),
}
if len(r.SourceChunkIDs) == 0 {
if s := strings.TrimSpace(firstString(m["source_chunk_id"])); s != "" {
r.SourceChunkIDs = []string{s}
}
}
if r.From != "" && r.To != "" {
out = append(out, r)
}
}
return out
}
func parseWikiPlanSections(raw any) []wikiPlanSection {
arr, ok := raw.([]any)
if !ok {
return nil
}
out := make([]wikiPlanSection, 0, len(arr))
for _, item := range arr {
m, ok := item.(map[string]any)
if !ok {
continue
}
sec := wikiPlanSection{
Heading: strings.TrimSpace(firstString(m["heading"])),
Points: parseWikiStrings(m["points"]),
}
if sec.Heading != "" {
out = append(out, sec)
}
}
return out
}
func firstNumber(v any) float64 {
switch x := v.(type) {
case int:
return float64(x)
case int64:
return float64(x)
case float32:
return float64(x)
case float64:
return x
default:
return 0
}
}
func parseWikiStrings(raw any) []string {
switch v := raw.(type) {
case []string:
return uniqueStrings(v)
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
if s := strings.TrimSpace(firstString(item)); s != "" {
out = append(out, s)
}
}
return uniqueStrings(out)
case string:
if s := strings.TrimSpace(v); s != "" {
return []string{s}
}
}
return nil
}
func buildSourceContext(chunks []common.Chunk, sourceChunkIDs []string) string {
if len(sourceChunkIDs) == 0 {
var b strings.Builder
for _, ch := range chunks {
text := firstNonEmpty(ch.Text, ch.Content)
if strings.TrimSpace(text) == "" {
continue
}
b.WriteString(text)
b.WriteString("\n\n")
}
return b.String()
}
lookup := map[string]string{}
for i, ch := range chunks {
id := strings.TrimSpace(ch.ID)
if id == "" {
id = fmt.Sprintf("chunk-%d", i+1)
}
lookup[id] = firstNonEmpty(ch.Text, ch.Content)
}
var b strings.Builder
for _, id := range sourceChunkIDs {
if text := strings.TrimSpace(lookup[id]); text != "" {
b.WriteString("[CHUNK_ID ")
b.WriteString(id)
b.WriteString("]\n")
b.WriteString(text)
b.WriteString("\n\n")
}
}
return b.String()
}
func buildEvidenceChecklist(reduced wikiExtract) string {
var lines []string
for _, e := range reduced.Entities {
lines = append(lines, fmt.Sprintf("- entity: %s (%s)", e.Name, e.Type))
}
for _, c := range reduced.Concepts {
lines = append(lines, fmt.Sprintf("- concept: %s", c.Term))
}
for _, c := range reduced.Claims {
lines = append(lines, fmt.Sprintf("- claim: %s", c.Statement))
}
for _, r := range reduced.Relations {
lines = append(lines, fmt.Sprintf("- relation: %s -> %s (%s)", r.From, r.To, r.Type))
}
for _, t := range reduced.Topics {
lines = append(lines, fmt.Sprintf("- topic: %s", t))
}
if len(lines) == 0 {
return "- (none)"
}
return strings.Join(lines, "\n")
}
type wikiEvidenceItem struct {
Statement string
Subject string
Confidence string
ChunkIDs []string
Synthetic bool
}
func assembleWikiPageEvidence(planItem wikiPlanPage, claims []wikiClaim, entityByName, conceptByTerm map[string]wikiExtractItem) []wikiEvidenceItem {
rawNames := uniqueStrings(append([]string{}, planItem.EntityNames...))
if len(rawNames) == 0 {
if t := strings.TrimSpace(planItem.Title); t != "" {
rawNames = []string{t}
} else if s := strings.TrimSpace(planItem.Slug); s != "" {
rawNames = []string{s}
}
}
if len(rawNames) == 0 {
return nil
}
namesLower := make([]string, 0, len(rawNames))
patterns := make([]*regexp.Regexp, 0, len(rawNames))
for _, n := range rawNames {
namesLower = append(namesLower, strings.ToLower(n))
patterns = append(patterns, regexp.MustCompile(`(?i)\b`+regexp.QuoteMeta(n)+`\b`))
}
var evidence []wikiEvidenceItem
for _, claim := range claims {
subjRaw := strings.TrimSpace(claim.Subject)
if subjRaw == "" {
continue
}
subjLower := strings.ToLower(subjRaw)
matched := false
for _, n := range namesLower {
if subjLower == n {
matched = true
break
}
}
if !matched {
for _, re := range patterns {
if re.MatchString(subjRaw) {
matched = true
break
}
}
}
if !matched {
continue
}
evidence = append(evidence, wikiEvidenceItem{
Statement: claim.Statement,
Subject: claim.Subject,
Confidence: firstNonEmpty(claim.Confidence, "explicit"),
ChunkIDs: uniqueStrings(claim.SourceChunkIDs),
})
}
if len(evidence) > 0 {
return evidence
}
var fallbackChunkIDs []string
matchedNames := make([]string, 0, len(rawNames))
for _, name := range rawNames {
key := strings.ToLower(strings.TrimSpace(name))
var hit wikiExtractItem
var ok bool
if entityByName != nil {
hit, ok = entityByName[key]
}
if !ok && conceptByTerm != nil {
hit, ok = conceptByTerm[key]
}
if !ok {
continue
}
fallbackChunkIDs = mergeStrings(fallbackChunkIDs, hit.SourceChunkIDs)
matchedNames = append(matchedNames, name)
}
if len(fallbackChunkIDs) == 0 {
return nil
}
subject := rawNames[0]
if len(matchedNames) > 0 {
subject = matchedNames[0]
}
return []wikiEvidenceItem{{
Statement: "",
Subject: subject,
Confidence: "inferred",
ChunkIDs: fallbackChunkIDs,
Synthetic: true,
}}
}
type wikiExtractItem struct {
SourceChunkIDs []string
}
func buildWikiEntityLookup(items []wikiEntity) map[string]wikiExtractItem {
out := map[string]wikiExtractItem{}
for _, item := range items {
hit := wikiExtractItem{SourceChunkIDs: uniqueStrings(item.SourceChunkIDs)}
name := strings.TrimSpace(item.Name)
if name != "" {
out[strings.ToLower(name)] = hit
}
for _, alias := range item.Aliases {
if alias = strings.TrimSpace(alias); alias != "" {
out[strings.ToLower(alias)] = hit
}
}
}
return out
}
func buildWikiConceptLookup(items []wikiConcept) map[string]wikiExtractItem {
out := map[string]wikiExtractItem{}
for _, item := range items {
hit := wikiExtractItem{SourceChunkIDs: uniqueStrings(item.SourceChunkIDs)}
term := strings.TrimSpace(item.Term)
if term != "" {
out[strings.ToLower(term)] = hit
}
}
return out
}
func formatWikiEvidenceBlocks(evidence []wikiEvidenceItem) string {
var lines []string
for _, ev := range evidence {
if ev.Synthetic {
continue
}
confidence := strings.ToUpper(firstNonEmpty(ev.Confidence, "explicit"))
lines = append(lines, fmt.Sprintf("%d. [%s] %s\n %s", len(lines)+1, confidence, ev.Subject, ev.Statement))
}
if len(lines) == 0 {
return "(no pre-extracted evidence — extract facts directly from the source document text above)"
}
return strings.Join(lines, "\n\n")
}
func collectWikiEvidenceChunkIDs(evidence []wikiEvidenceItem) []string {
var out []string
for _, ev := range evidence {
out = mergeStrings(out, ev.ChunkIDs)
}
return out
}
func collectWikiSourceDocIDs(chunks []common.Chunk, chunkIDs []string, fallback string) []string {
if len(chunkIDs) == 0 {
if fallback != "" {
return []string{fallback}
}
return nil
}
lookup := map[string][]string{}
for _, ch := range chunks {
id := strings.TrimSpace(ch.ID)
if id == "" {
continue
}
docID := strings.TrimSpace(firstString(ch.Meta["doc_id"]))
if docID == "" {
docID = strings.TrimSpace(firstString(ch.Meta["doc_id_kwd"]))
}
if docID != "" {
lookup[id] = []string{docID}
}
}
out := make([]string, 0, len(chunkIDs))
for _, cid := range chunkIDs {
if ids := lookup[strings.TrimSpace(cid)]; len(ids) > 0 {
out = mergeStrings(out, ids)
}
}
if len(out) == 0 && fallback != "" {
return []string{fallback}
}
return out
}
var (
wikiWikilinkPipeRe = regexp.MustCompile(`\[\[([^\[\]\|]+?)\|([^\[\]]+?)\]\]`)
wikiWikilinkSimpleRe = regexp.MustCompile(`\[\[([^\[\]\|]+?)\]\]`)
wikiArtifactMarkdownLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
)
func transformWikiLinks(content, kbID string, pageTitles map[string]string) (string, []string) {
kbID = strings.TrimSpace(kbID)
seen := map[string]bool{}
var outlinks []string
track := func(slug string) {
slug = strings.TrimSpace(slug)
if slug == "" || seen[slug] {
return
}
seen[slug] = true
outlinks = append(outlinks, slug)
}
displayText := func(label, slug string) string {
label = strings.TrimSpace(label)
if label != slug && label != slugify(lastPathSlug(slug)) {
return label
}
if title := strings.TrimSpace(pageTitles[slug]); title != "" {
return title
}
readable := strings.ReplaceAll(lastPathSlug(slug), "-", " ")
readable = strings.ReplaceAll(readable, "_", " ")
readable = strings.TrimSpace(readable)
if readable == "" {
return label
}
return strings.Title(readable)
}
artifactSlug := func(href string) string {
parsed, err := url.Parse(href)
if err != nil {
return ""
}
path := parsed.Path
if path == "" && parsed.Opaque != "" {
path = parsed.Opaque
}
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) >= 2 && parts[0] == "artifact" && parts[1] == kbID {
return strings.Join(parts[2:], "/")
}
if len(parts) >= 2 && parts[0] == kbID {
return strings.Join(parts[1:], "/")
}
return ""
}
rewriteMD := func(label, href string) string {
slug := artifactSlug(href)
if slug == "" {
return "[" + label + "](" + href + ")"
}
track(slug)
return "[" + displayText(label, slug) + "](artifact/" + kbID + "/" + slug + ")"
}
out := wikiArtifactMarkdownLink.ReplaceAllStringFunc(content, func(match string) string {
sub := wikiArtifactMarkdownLink.FindStringSubmatch(match)
if len(sub) != 3 {
return match
}
return rewriteMD(sub[1], sub[2])
})
out = wikiWikilinkPipeRe.ReplaceAllStringFunc(out, func(match string) string {
sub := wikiWikilinkPipeRe.FindStringSubmatch(match)
if len(sub) != 3 {
return match
}
slug := strings.TrimSpace(sub[1])
track(slug)
return "[" + sub[2] + "](artifact/" + kbID + "/" + slug + ")"
})
out = wikiWikilinkSimpleRe.ReplaceAllStringFunc(out, func(match string) string {
sub := wikiWikilinkSimpleRe.FindStringSubmatch(match)
if len(sub) != 2 {
return match
}
slug := strings.TrimSpace(sub[1])
track(slug)
return "[" + displayText(slug, slug) + "](artifact/" + kbID + "/" + slug + ")"
})
return out, outlinks
}
func lastPathSlug(slug string) string {
slug = strings.TrimSpace(slug)
if idx := strings.LastIndex(slug, "/"); idx >= 0 && idx < len(slug)-1 {
return slug[idx+1:]
}
return slug
}
func mustJSON(v any) string {
b, err := json.Marshal(v)
if err != nil {
return "[]"
}
return string(b)
}
func mustPrettyJSON(v any) string {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return "{}"
}
return string(b)
}
func renderWikiTemplate(tmpl string, values map[string]string) string {
out := tmpl
keys := make([]string, 0, len(values))
for k := range values {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
out = strings.ReplaceAll(out, "{"+k+"}", values[k])
}
return out
}
func prefixWithDash(items []string) []string {
out := make([]string, 0, len(items))
for _, item := range items {
out = append(out, "- "+item)
}
return out
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
return ""
}
func uniqueStrings(in []string) []string {
if len(in) == 0 {
return nil
}
seen := map[string]bool{}
out := make([]string, 0, len(in))
for _, s := range in {
s = strings.TrimSpace(s)
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
func mergeStrings(a, b []string) []string {
return uniqueStrings(append(append([]string{}, a...), b...))
}
func normalizedStringSet(items []string) map[string]struct{} {
out := make(map[string]struct{}, len(items))
for _, item := range items {
if key := normKey(item); key != "" {
out[key] = struct{}{}
}
}
return out
}
func intersectsNormalized(needle map[string]struct{}, haystack []string) bool {
if len(needle) == 0 {
return false
}
for _, item := range haystack {
if _, ok := needle[normKey(item)]; ok {
return true
}
}
return false
}
func normalizedOverlapCount(needle map[string]struct{}, haystack []string) int {
if len(needle) == 0 {
return 0
}
count := 0
seen := map[string]struct{}{}
for _, item := range haystack {
key := normKey(item)
if key == "" {
continue
}
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
if _, ok := needle[key]; ok {
count++
}
}
return count
}
func normKey(s string) string {
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(s)), " "))
}
func firstString(v any) string {
switch x := v.(type) {
case string:
return x
case fmt.Stringer:
return x.String()
default:
return ""
}
}
func (e wikiExtract) sourceChunkIDs() []string {
seen := map[string]bool{}
var out []string
add := func(s string) {
s = strings.TrimSpace(s)
if s == "" || seen[s] {
return
}
seen[s] = true
out = append(out, s)
}
for _, item := range e.Entities {
for _, id := range item.SourceChunkIDs {
add(id)
}
}
for _, item := range e.Concepts {
for _, id := range item.SourceChunkIDs {
add(id)
}
}
for _, item := range e.Claims {
for _, id := range item.SourceChunkIDs {
add(id)
}
}
for _, item := range e.Relations {
for _, id := range item.SourceChunkIDs {
add(id)
}
}
return out
}
func (e wikiExtract) asPlanPayload() map[string]any {
return map[string]any{
"entities": e.Entities,
"concepts": e.Concepts,
"claims": e.Claims,
"relations": e.Relations,
"topics": e.Topics,
}
}
func compactJSON(v any) string {
b, err := json.Marshal(v)
if err != nil {
return "{}"
}
return string(bytes.TrimSpace(b))
}
func (p *wikiPipeline) maybeSourceIDs() []string {
if ids := p.reduced.sourceChunkIDs(); len(ids) > 0 {
return ids
}
seen := map[string]bool{}
var out []string
for _, ch := range p.inputs.Chunks {
id := strings.TrimSpace(ch.ID)
if id == "" || seen[id] {
continue
}
seen[id] = true
out = append(out, id)
}
return out
}
func (p *wikiPipeline) runMapBatch(batch []common.Chunk) error {
extract, err := p.mapBatch(batch)
if err != nil {
return err
}
p.mapExtracts = append(p.mapExtracts, extract)
return nil
}
// dedupHistorical drops products that are near-duplicates of existing historical
// artifacts, implementing cross-run dedup for the wiki variant. This remains a
// read-only historical lookup and does not store any wiki intermediate state.
func dedupHistorical(ctx context.Context, deps common.Deps, param common.Param, tenantID, datasetID string, override []common.Candidate, products []common.Product) (survivors []common.Product, dropped int, err error) {
if len(products) == 0 {
return products, 0, nil
}
threshold := param.SimilarityThreshold
if len(override) > 0 {
for _, p := range products {
if nearDuplicateOf(p.Vector, override, threshold) {
dropped++
continue
}
survivors = append(survivors, p)
}
return survivors, dropped, nil
}
if deps.HistoricalKNN == nil {
return products, 0, nil
}
for _, p := range products {
hits, e := deps.HistoricalKNN.TopKHistory(ctx, tenantID, datasetID, string(common.VariantWiki), p.Vector, wikiHistoricalK, threshold)
if e != nil {
return survivors, dropped, e
}
if len(hits) > 0 {
dropped++
continue
}
survivors = append(survivors, p)
}
return survivors, dropped, nil
}
func nearDuplicateOf(vec []float32, cands []common.Candidate, threshold float64) bool {
qn := l2Norm32(vec)
if qn == 0 {
qn = 1
}
for _, c := range cands {
cn := l2Norm32(c.Vector)
if cn == 0 {
cn = 1
}
var dot float64
for i := 0; i < len(vec) && i < len(c.Vector); i++ {
dot += float64(vec[i]) * float64(c.Vector[i])
}
if dot/(qn*cn) >= threshold {
return true
}
}
return false
}
func l2Norm32(v []float32) float64 {
var s float64
for _, x := range v {
s += float64(x) * float64(x)
}
return math.Sqrt(s)
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// wikiHistoricalK is the K used for the historical-dedup KNN lookup.
const wikiHistoricalK = 5
// wikiPlanTokenBudget caps one planning round's approximate token load.
const wikiPlanTokenBudget = 3500