Files
xinmi/internal/application/service/wiki_candidate_catalog.go
T
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

255 lines
9.3 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"unicode/utf8"
"github.com/Tencent/WeKnora/internal/agent"
"github.com/Tencent/WeKnora/internal/modelcontext"
"github.com/Tencent/WeKnora/internal/models/chat"
"github.com/Tencent/WeKnora/internal/types"
)
const (
wikiCandidateCatalogVersion = "wiki-candidate-catalog-v1"
wikiCitationRequestRunes = 65536
)
type wikiCandidateEntry struct {
Kind string `json:"kind"`
Item extractedItem `json:"item"`
}
type wikiCandidateShard struct {
Hash string `json:"hash"`
Entries []wikiCandidateEntry `json:"entries"`
XML string `json:"-"`
}
type wikiCitationSourceRange struct {
ChunkID string `json:"chunk_id"`
StartRune int `json:"start_rune"`
EndRune int `json:"end_rune"`
TextHash string `json:"text_hash"`
}
type wikiCitationSourceBatch struct {
Hash string `json:"hash"`
Ranges []wikiCitationSourceRange `json:"ranges"`
Batch chunkBatch `json:"-"`
}
type wikiCitationPlan struct {
Version string `json:"version"`
Hash string `json:"hash"`
Shards []wikiCandidateShard `json:"shards"`
Sources []wikiCitationSourceBatch `json:"sources"`
}
func renderWikiCandidateEntries(entries []wikiCandidateEntry) string {
var entities, concepts []extractedItem
for _, entry := range entries {
if entry.Kind == "entity" {
entities = append(entities, entry.Item)
} else {
concepts = append(concepts, entry.Item)
}
}
return renderCandidateSlugsXML(entities, concepts)
}
// Whole structured entries, including every alias and audit field, stay in the
// plan. Only the actual routing renderer determines the request shard budget.
func buildWikiCandidateShards(entities, concepts []extractedItem, budget int) ([]wikiCandidateShard, error) {
var entries []wikiCandidateEntry
for _, group := range []struct {
kind string
items []extractedItem
}{{"entity", entities}, {"concept", concepts}} {
for _, item := range group.items {
if strings.TrimSpace(item.Name) == "" || strings.TrimSpace(item.Slug) == "" {
return nil, errors.New("Wiki candidate catalog contains an empty identity")
}
entries = append(entries, wikiCandidateEntry{group.kind, item})
}
}
sort.Slice(entries, func(i, j int) bool {
a, b := entries[i], entries[j]
if a.Kind != b.Kind {
return a.Kind < b.Kind
}
if a.Item.Slug != b.Item.Slug {
return a.Item.Slug < b.Item.Slug
}
return wikiCheckpointHash(a) < wikiCheckpointHash(b)
})
var shards []wikiCandidateShard
var current []wikiCandidateEntry
flush := func() {
if len(current) == 0 {
return
}
xml := renderWikiCandidateEntries(current)
shards = append(shards, wikiCandidateShard{Hash: wikiCheckpointHash([]any{wikiCandidateCatalogVersion, budget, len(shards), current, xml}), Entries: current, XML: xml})
current = nil
}
runes := 0
for _, entry := range entries {
entryRunes := utf8.RuneCountInString(renderWikiCandidateEntries([]wikiCandidateEntry{entry}))
if entryRunes > budget {
return nil, fmt.Errorf("Wiki candidate %q requires %d routing characters, exceeding the %d-character single-entry budget; no identity or alias was truncated", entry.Item.Slug, entryRunes, budget)
}
if runes+entryRunes > budget {
flush()
runes = 0
}
current = append(current, entry)
runes += entryRunes
}
flush()
return shards, nil
}
func wikiCitationBatchForChunks(chunks []*types.Chunk) chunkBatch {
batch := chunkBatch{chunks: chunks, handles: modelcontext.NewHandleTable("c", 3, 0)}
for _, chunk := range chunks {
batch.handles.Register(chunk.ID)
batch.totalRuneLen += utf8.RuneCountInString(chunk.Content)
}
return batch
}
// Source ranges are exact, contiguous views of each original text chunk. The
// budget includes XML wrappers, and one batch never aliases two ranges to the
// same source handle. The legacy string caller keeps its original batching.
func buildWikiCitationSources(chunks []*types.Chunk, budget int) ([]wikiCitationSourceBatch, error) {
var ordered []*types.Chunk
seen := make(map[string]bool)
for _, chunk := range chunks {
if chunk == nil || chunk.Content == "" || (chunk.ChunkType != "" && chunk.ChunkType != types.ChunkTypeText) {
continue
}
if chunk.ID == "" || seen[chunk.ID] {
return nil, errors.New("Wiki citation source has an empty or duplicate chunk identity")
}
seen[chunk.ID] = true
ordered = append(ordered, chunk)
}
sort.Slice(ordered, func(i, j int) bool {
if ordered[i].ChunkIndex != ordered[j].ChunkIndex {
return ordered[i].ChunkIndex < ordered[j].ChunkIndex
}
if ordered[i].StartAt != ordered[j].StartAt {
return ordered[i].StartAt < ordered[j].StartAt
}
return ordered[i].ID < ordered[j].ID
})
var sources []wikiCitationSourceBatch
var current wikiCitationSourceBatch
flush := func() {
if len(current.Ranges) == 0 {
return
}
current.Hash = wikiCheckpointHash([]any{wikiCandidateCatalogVersion, budget, current.Ranges, wikiCheckpointChunks(current.Batch.chunks)})
sources = append(sources, current)
current = wikiCitationSourceBatch{}
}
for _, chunk := range ordered {
empty := *chunk
empty.Content = ""
wrapperRunes := utf8.RuneCountInString(renderChunksXML(wikiCitationBatchForChunks([]*types.Chunk{&empty})))
if wrapperRunes >= budget {
return nil, errors.New("Wiki citation source wrapper exceeds the source request budget")
}
for _, window := range splitWikiReadWindows(chunk.Content, budget-wrapperRunes) {
part := *chunk
part.Content = window.Text
if len(current.Ranges) > 0 && current.Ranges[len(current.Ranges)-1].ChunkID == chunk.ID {
flush()
}
trial := wikiCitationBatchForChunks(append(append([]*types.Chunk(nil), current.Batch.chunks...), &part))
if utf8.RuneCountInString(renderChunksXML(trial)) > budget {
flush()
trial = wikiCitationBatchForChunks([]*types.Chunk{&part})
}
current.Batch = trial
current.Ranges = append(current.Ranges, wikiCitationSourceRange{chunk.ID, window.StartRune, window.EndRune, wikiCheckpointHash(window.Text)})
}
}
flush()
return sources, nil
}
func buildWikiCitationPlan(entities, concepts []extractedItem, chunks []*types.Chunk, lang string) (*wikiCitationPlan, error) {
shards, err := buildWikiCandidateShards(entities, concepts, wikiReadCandidateRunes)
if err != nil {
return nil, err
}
sources, err := buildWikiCitationSources(chunks, maxRunesPerCitationBatch)
if err != nil {
return nil, err
}
plan := &wikiCitationPlan{Version: wikiCandidateCatalogVersion, Shards: shards, Sources: sources}
plan.Hash = wikiCheckpointHash([]any{plan, lang, wikiReadCandidateRunes, maxRunesPerCitationBatch, wikiCitationRequestRunes, wikiCitationMergeVersion, agent.WikiChunkCitationPrompt})
return plan, nil
}
type wikiCitationPair struct {
CandidatesXML string
Batch chunkBatch
Key string
}
type wikiCitationProgress struct {
Version string `json:"version"`
PlanHash string `json:"plan_hash"`
CandidateShards int `json:"candidate_shards"`
SourceBatches int `json:"source_batches"`
TotalPairs int `json:"total_pairs"`
CompletedPairs int `json:"completed_pairs"`
StageComplete bool `json:"stage_complete"`
}
// Guard the actual templated messages, including provider-repair instructions.
// Oversize requests stop before sending; the guard never truncates source text.
type wikiCitationChat interface{ chat.Chat }
type wikiCitationBoundedChat struct{ wikiCitationChat }
func (model wikiCitationBoundedChat) Chat(ctx context.Context, messages []chat.Message, options *chat.ChatOptions) (*types.ChatResponse, error) {
runes := 0
for _, message := range messages {
runes += utf8.RuneCountInString(message.Content)
}
if runes > wikiCitationRequestRunes {
return nil, fmt.Errorf("Wiki citation request requires %d characters, exceeding the %d-character complete-request budget; source was not truncated", runes, wikiCitationRequestRunes)
}
return model.wikiCitationChat.Chat(ctx, messages, options)
}
func (s *wikiIngestService) classifyWikiCitationPlan(ctx context.Context, model chat.Chat, plan *wikiCitationPlan, chunks []*types.Chunk, lang string, scope *wikiCheckpointScope) (map[string][]string, []newSlugFromCitation, int, error) {
total := len(plan.Shards) * len(plan.Sources)
if total > wikiCitationSlice && (scope == nil || s.checkpointDB == nil) {
return nil, nil, 0, errors.New("Wiki 分批引用需要持久断点存储,未启用时不能丢弃未完成批次")
}
if err := s.saveWikiCheckpoint(ctx, scope, "citation_plan", plan.Hash, plan); err != nil {
return nil, nil, 0, err
}
var pairs []wikiCitationPair
for _, shard := range plan.Shards {
for _, source := range plan.Sources {
key := wikiCheckpointHash([]any{wikiCandidateCatalogVersion, plan.Hash, shard.Hash, source.Hash, lang})
pairs = append(pairs, wikiCitationPair{shard.XML, source.Batch, key})
}
}
citations, declarations, completed, err := s.classifyWikiCitationPairs(ctx, wikiCitationBoundedChat{model}, pairs, chunks, lang, scope, "citation_pair")
progress := wikiCitationProgress{wikiCandidateCatalogVersion, plan.Hash, len(plan.Shards), len(plan.Sources), total, completed, err == nil && completed == total}
if saveErr := s.saveWikiCheckpoint(ctx, scope, "citation_progress", plan.Hash, progress); saveErr != nil {
return nil, nil, completed, saveErr
}
return citations, declarations, completed, err
}