Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -20734,6 +20734,10 @@ const docTemplate = `{
"description": "IngestReduceParallel sets the errgroup limit for the Reduce phase\n(per-slug page write) WITHIN one batch. 0 falls back to 10. Bound by the\nsame LLM concurrency / HTTP pool considerations as the Map phase, plus\nDB connection pool size. Same multiplier caveat as IngestMapParallel.",
"type": "integer"
},
"max_reduce_input_bytes": {
"description": "MaxReduceInputBytes bounds the aggregate, trim-eligible input passed to the WikiPageModify prompt. It is an operator-provided byte budget; 0 disables the bound.",
"type": "integer"
},
"max_pages_per_ingest": {
"description": "MaxPagesPerIngest limits pages created/updated per ingest operation (0 = no limit)",
"type": "integer"
Expand Down
217 changes: 217 additions & 0 deletions internal/application/service/wiki_ingest.go
Original file line number Diff line number Diff line change
Expand Up @@ -1289,6 +1289,10 @@ type WikiBatchContext struct {
ContentInstructions string
ExtractionInstructions string

// ReduceInputBudgetBytes is the explicit operator-provided budget for the
// trim-eligible WikiPageModify prompt inputs. Zero keeps legacy behavior.
ReduceInputBudgetBytes int

// PlannedFolderID holds the per-slug wiki_folders.id assigned by the batch
// taxonomy planning pass (planBatchTaxonomy + folder resolution), keyed by
// page slug. Reduce applies it only to pages that aren't already filed
Expand Down Expand Up @@ -2835,6 +2839,219 @@ func truncateString(s string, maxLen int) string {
return string(runes[:maxLen]) + "..."
}

const wikiReduceInputOmissionMarker = "\n[... content omitted by the configured wiki reduce input budget ...]\n"

// trimWikiReduceInputs bounds the four recoverable reduce inputs while leaving
// DeletedContent intact: a retraction must retain the source text that tells
// the editor what to remove. The budget is deliberately explicit and
// deployment-provided; this code does not infer a model context window.
//
// Inputs are shed in increasing order of authority: shared source context is
// reduced to titles first, then remaining-source and new evidence are trimmed,
// and existing page content is trimmed from the middle so its beginning and
// end remain visible. The returned map is independent of data.
func trimWikiReduceInputs(data map[string]string, budget int) map[string]string {
trimmed := make(map[string]string, len(data))
for key, value := range data {
trimmed[key] = value
}
if budget <= 0 {
return trimmed
}

trimmed["SharedSourceContexts"] = wikiReduceTitlesOnly(trimmed["SharedSourceContexts"])
// Keep the source titles produced above as the minimum identity context.
// Only if the configured budget is smaller than that identity block do we
// trim it further; the recoverable bodies are shed first.
keys := []string{"RemainingSourcesContent", "NewContent", "ExistingContent"}
total := len([]byte(trimmed["SharedSourceContexts"]))
for _, key := range keys {
total += len([]byte(trimmed[key]))
}
for _, key := range keys {
if total <= budget {
break
}
remove := total - budget
current := len([]byte(trimmed[key]))
if current == 0 {
continue
}
keep := current - remove
if key == "NewContent" {
if minimum := wikiReduceRequiredNewPrefix(trimmed[key]); keep < minimum {
keep = minimum
}
}
if keep < 0 {
keep = 0
}
switch key {
case "ExistingContent":
trimmed[key] = trimWikiReduceMiddle(trimmed[key], keep)
default:
trimmed[key] = trimWikiReducePrefix(trimmed[key], keep)
}
total -= current - len([]byte(trimmed[key]))
}
if total > budget {
// A very small budget can conflict with the minimum new-content header.
// In that case, make one final pass without minimum preservation so the
// explicit budget still wins over every optional detail.
for i := len(keys) - 1; i >= 0 && total > budget; i-- {
key := keys[i]
current := len([]byte(trimmed[key]))
if current == 0 {
continue
}
keep := current - (total - budget)
if keep < 0 {
keep = 0
}
if key == "ExistingContent" {
trimmed[key] = trimWikiReduceMiddle(trimmed[key], keep)
} else {
trimmed[key] = trimWikiReducePrefix(trimmed[key], keep)
}
total -= current - len([]byte(trimmed[key]))
}
}
if total > budget {
trimmed["SharedSourceContexts"] = trimWikiReducePrefix(trimmed["SharedSourceContexts"], budget)
}
return trimmed
}

// wikiReduceRequiredNewPrefix returns the shortest prefix that includes the
// first generated name/description line. NewContent is emitted in document
// blocks with that line at the start of <content>; retaining it is more useful
// than retaining arbitrary verbatim evidence when the budget is tight.
func wikiReduceRequiredNewPrefix(s string) int {
content := strings.Index(s, "<content>")
if content < 0 {
return 0
}
lineStart := content + len("<content>")
for lineStart < len(s) {
for lineStart < len(s) && (s[lineStart] == '\n' || s[lineStart] == '\r' || s[lineStart] == ' ' || s[lineStart] == ' ') {
lineStart++
}
lineEnd := strings.IndexByte(s[lineStart:], '\n')
if lineEnd < 0 {
lineEnd = len(s) - lineStart
}
line := strings.TrimSpace(s[lineStart : lineStart+lineEnd])
if line != "" {
return lineStart + lineEnd + len([]byte(wikiReduceInputOmissionMarker))
}
lineStart += lineEnd + 1
}
return 0
}

// wikiReduceTitlesOnly keeps the stable identity of shared sources and drops
// their repeated summary bodies. The simple tag scanner intentionally falls
// back to the original text when the input is not in the expected document
// shape, so malformed legacy data is still handled by the normal budget trim.
func wikiReduceTitlesOnly(s string) string {
var out strings.Builder
remaining := s
for {
open := strings.Index(remaining, "<title>")
if open < 0 {
break
}
remaining = remaining[open+len("<title>"):]
close := strings.Index(remaining, "</title>")
if close < 0 {
return s
}
title := strings.TrimSpace(remaining[:close])
if title != "" {
fmt.Fprintf(&out, "<document>\n<title>%s</title>\n</document>\n", title)
}
remaining = remaining[close+len("</title>"):]
}
if out.Len() == 0 {
return s
}
return out.String()
}

func trimWikiReducePrefix(s string, maxBytes int) string {
if len([]byte(s)) <= maxBytes {
return s
}
if maxBytes <= 0 {
return ""
}
markerBytes := len([]byte(wikiReduceInputOmissionMarker))
if maxBytes <= markerBytes {
return string([]rune(s)[:runeCountWithinBytes(s, maxBytes)])
}
return safeUTF8Prefix(s, maxBytes-markerBytes) + wikiReduceInputOmissionMarker
}

func trimWikiReduceMiddle(s string, maxBytes int) string {
if len([]byte(s)) <= maxBytes {
return s
}
if maxBytes <= 0 {
return ""
}
markerBytes := len([]byte(wikiReduceInputOmissionMarker))
if maxBytes <= markerBytes {
return string([]rune(s)[:runeCountWithinBytes(s, maxBytes)])
}
keep := maxBytes - markerBytes
headBytes := keep / 2
tailBytes := keep - headBytes
head := safeUTF8Prefix(s, headBytes)
tailStart := safeUTF8SuffixStart(s, tailBytes)
return head + wikiReduceInputOmissionMarker + s[tailStart:]
}

func safeUTF8Prefix(s string, maxBytes int) string {
if maxBytes >= len([]byte(s)) {
return s
}
if maxBytes <= 0 {
return ""
}
for maxBytes > 0 && (s[maxBytes]&0xc0) == 0x80 {
maxBytes--
}
return s[:maxBytes]
}

func safeUTF8SuffixStart(s string, maxBytes int) int {
if maxBytes >= len([]byte(s)) {
return 0
}
start := len(s) - maxBytes
for start < len(s) && (s[start]&0xc0) == 0x80 {
start++
}
return start
}

func runeCountWithinBytes(s string, maxBytes int) int {
if maxBytes <= 0 {
return 0
}
count := 0
used := 0
for _, r := range s {
n := len(string(r))
if used+n > maxBytes {
break
}
used += n
count++
}
return count
}

// appendUnique appends a string to a StringArray if not already present
func appendUnique(arr types.StringArray, s string) types.StringArray {
for _, v := range arr {
Expand Down
8 changes: 6 additions & 2 deletions internal/application/service/wiki_ingest_batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,10 +168,12 @@ func (s *wikiIngestService) newWikiBatchContext(
granularity := types.WikiExtractionStandard
contentInstructions := ""
extractionInstructions := ""
reduceInputBudgetBytes := 0
if wikiConfig != nil {
granularity = wikiConfig.ExtractionGranularity.Normalize()
contentInstructions = wikiConfig.ContentInstructions
extractionInstructions = wikiConfig.ExtractionInstructions
reduceInputBudgetBytes = wikiConfig.MaxReduceInputBytes
}
return &WikiBatchContext{
SlugTitle: func(ctx context.Context, slug string) string {
Expand All @@ -186,6 +188,7 @@ func (s *wikiIngestService) newWikiBatchContext(
ExtractionGranularity: granularity,
ContentInstructions: contentInstructions,
ExtractionInstructions: extractionInstructions,
ReduceInputBudgetBytes: reduceInputBudgetBytes,
}
}

Expand Down Expand Up @@ -2036,7 +2039,7 @@ func (s *wikiIngestService) reduceSlugUpdates(
pageAliases := strings.Join(page.Aliases, ", ")

var updatedContent string
updatedContent, err = s.generateWithTemplate(ctx, chatModel, agent.WikiPageModifyUserPrompt, map[string]string{
reduceInputs := trimWikiReduceInputs(map[string]string{
"HasAdditions": hasAdditionsStr,
"HasRetractions": hasRetractionsStr,
"PageSlug": slug,
Expand All @@ -2052,7 +2055,8 @@ func (s *wikiIngestService) reduceSlugUpdates(
"Language": language,
"CustomInstructions": batchCtx.ContentInstructions,
"InstructionScope": "wiki_content",
})
}, batchCtx.ReduceInputBudgetBytes)
updatedContent, err = s.generateWithTemplate(ctx, chatModel, agent.WikiPageModifyUserPrompt, reduceInputs)

if err == nil && updatedContent != "" {
// Translate request-local handles (ref-N) the model copied from the
Expand Down
42 changes: 42 additions & 0 deletions internal/application/service/wiki_ingest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,48 @@ func (r *wikiPendingRepoForCleanupTest) DeleteByDedupKey(
return nil
}

// TestTrimWikiReduceInputsBoundsConfiguredContext is a regression for #2718:
// hub-page reduce inputs must converge under an explicit operator budget.
func TestTrimWikiReduceInputsBoundsConfiguredContext(t *testing.T) {
const budget = 800
data := map[string]string{
"ExistingContent": "HEAD section\n" + strings.Repeat("middle section that may be dropped\n", 20) + "TAIL section",
"SharedSourceContexts": "<document>\n<title>Hub source</title>\n<context>" + strings.Repeat("shared details ", 20) + "</context>\n</document>\n",
"NewContent": "<document>\n<title>New source</title>\n<content>\n**Required name**: Required description\n\n" + strings.Repeat("verbatim evidence ", 20) + "\n</content>\n</document>\n",
"DeletedContent": "deleted source must remain intact",
"RemainingSourcesContent": "<document>\n<title>Remaining source</title>\n<content>" + strings.Repeat("remaining details ", 20) + "</content>\n</document>\n",
}
original := data["ExistingContent"]

got := trimWikiReduceInputs(data, budget)

if got["DeletedContent"] != data["DeletedContent"] {
t.Fatalf("DeletedContent was changed: got %q", got["DeletedContent"])
}
if got["ExistingContent"] == data["ExistingContent"] ||
!strings.Contains(got["ExistingContent"], "HEAD section") ||
!strings.Contains(got["ExistingContent"], "TAIL section") {
t.Fatalf("ExistingContent was not middle-trimmed: %q", got["ExistingContent"])
}
if strings.Contains(got["SharedSourceContexts"], "shared details") ||
!strings.Contains(got["SharedSourceContexts"], "Hub source") {
t.Fatalf("SharedSourceContexts was not reduced to titles: %q", got["SharedSourceContexts"])
}
if !strings.Contains(got["NewContent"], "**Required name**: Required description") {
t.Fatalf("NewContent lost the required name/description line: %q", got["NewContent"])
}
trimmedBytes := len([]byte(got["ExistingContent"])) +
len([]byte(got["SharedSourceContexts"])) +
len([]byte(got["NewContent"])) +
len([]byte(got["RemainingSourcesContent"]))
if trimmedBytes > budget {
t.Fatalf("trim-eligible reduce inputs = %d bytes, want <= %d", trimmedBytes, budget)
}
if data["ExistingContent"] != original {
t.Fatal("trimWikiReduceInputs mutated its input map")
}
}

// TestGenerateWithTemplateSetsMaxTokens is a regression for #2604: without an
// explicit MaxTokens, DeepSeek-class providers default to 8192 completion
// tokens and truncate combined wiki extraction JSON mid-field.
Expand Down
5 changes: 5 additions & 0 deletions internal/types/wiki_page.go
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,11 @@ type WikiConfig struct {
SynthesisModelID string `yaml:"synthesis_model_id" json:"synthesis_model_id"`
// MaxPagesPerIngest limits pages created/updated per ingest operation (0 = no limit)
MaxPagesPerIngest int `yaml:"max_pages_per_ingest" json:"max_pages_per_ingest"`
// MaxReduceInputBytes bounds the aggregate, trim-eligible input passed to the
// WikiPageModify prompt. It is an operator-provided byte budget because the
// model context window is not exposed by the chat abstraction. Zero preserves
// the legacy unbounded behavior.
MaxReduceInputBytes int `yaml:"max_reduce_input_bytes" json:"max_reduce_input_bytes,omitempty"`
// ExtractionGranularity controls how many candidate slugs Pass 0 extracts
// per document. Empty / unknown value is treated as WikiExtractionStandard.
ExtractionGranularity WikiExtractionGranularity `yaml:"extraction_granularity" json:"extraction_granularity,omitempty"`
Expand Down
9 changes: 7 additions & 2 deletions internal/types/wiki_page_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ func TestWikiPageTypes(t *testing.T) {

func TestWikiConfigValueScan(t *testing.T) {
config := WikiConfig{
SynthesisModelID: "model-123",
MaxPagesPerIngest: 20,
SynthesisModelID: "model-123",
MaxPagesPerIngest: 20,
MaxReduceInputBytes: 4096,
}

val, err := config.Value()
Expand All @@ -56,6 +57,9 @@ func TestWikiConfigValueScan(t *testing.T) {
if restored.MaxPagesPerIngest != 20 {
t.Error("MaxPagesPerIngest mismatch")
}
if restored.MaxReduceInputBytes != 4096 {
t.Error("MaxReduceInputBytes mismatch")
}
}

func TestWikiConfigScanNil(t *testing.T) {
Expand Down Expand Up @@ -280,6 +284,7 @@ func TestWikiConfig_JSONRoundTrip_WithGranularity(t *testing.T) {
original := WikiConfig{
SynthesisModelID: "m-1",
MaxPagesPerIngest: 20,
MaxReduceInputBytes: 4096,
ExtractionGranularity: WikiExtractionFocused,
}
data, err := json.Marshal(original)
Expand Down