Files
hyperguild/ingestion/internal/pipeline/prompt.go
T
mathiasandClaude Sonnet 5 6e83303752 feat(pipeline): feed the current source page into the extraction prompt
#103 made ingest merge into the page already on disk instead of
clobbering it, but the extraction itself stayed page-blind: the model
wrote every page as if fresh, and Merge reconciled the two texts
afterwards by heading. It could restate a claim but never refine it,
and had no way to know what already existed.

BuildPrompt now accepts the source page's current on-disk content
(when one exists) and asks the model to revise it explicitly: add
what's missing, strengthen what the source now supports, leave the
rest alone and reuse its wording verbatim so unchanged claims are
recognized as unchanged. Scoped to the source page specifically —
its path (wiki/sources/<sourceSlug>.md) is deterministic before
extraction runs, unlike a concept/entity page's path, which only
exists once the model has produced a title.

The deterministic merge in wiki.Merge is unchanged and stays the
backstop: an on-disk section the model doesn't touch survives
untouched regardless of what the prompt asks for, so a model that
ignores the instruction still cannot destroy human content.

Closes hyperguild#106 (first half; heading normalization follows).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 07:38:07 +02:00

86 lines
4.2 KiB
Go

// ingestion/internal/pipeline/prompt.go
package pipeline
import (
"fmt"
"strings"
"time"
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
)
const systemPrompt = `You are a wiki agent. Read the source material and produce structured wiki pages following the schema provided.
Output ONLY a valid JSON array — no markdown fences, no other text before or after.
Each element must have exactly these fields:
"title" — exact page title (e.g. "FinBERT", "Ryan Singer", "Shape Up")
"type" — exactly one of: "source", "concept", "entity"
"subtype" — for source: article|pdf|book|video|note|project; for entity: person|company|tool|model|framework|technology; omit for concept
"domain" — one of the domains in the schema (omit if none fits)
"content" — Markdown body only — NO frontmatter, NO path, NO slug
Wikilinks in content: [[Display Name]] — just the display name, no slug, no pipe separator.
Only link to pages listed in the inventory or pages you are creating in this response.`
// BuildPrompt constructs the user prompt for a single chunk. existingPage is
// the full current content (frontmatter + body) of the source's own wiki page
// when one already exists on disk, or "" for a source being ingested for the
// first time. When non-empty, the model is asked to revise that page rather
// than write a fresh one — see hyperguild#106. The deterministic merge in
// wiki.Merge remains the backstop regardless of what the model returns: an
// on-disk section the model doesn't touch survives untouched, and a section
// it does touch only takes effect where Merge's strategy allows the fresh
// side to change anything (bullet union, append) — never for an ordinary
// section, so the model proposing a revision is never how a page gets
// overwritten.
func BuildPrompt(schema, source, content string, inventory map[wiki.PageType][]wiki.Entry, existingPage string) string {
var sb strings.Builder
fmt.Fprintf(&sb, "Today's date is %s.\n\n", time.Now().UTC().Format("2006-01-02"))
sb.WriteString("## Schema\n\n")
sb.WriteString(schema)
sb.WriteString("\n\n")
sb.WriteString("## Existing wiki pages\n\n")
sb.WriteString("Reference these pages by display name only — [[Display Name]] — in your content.\n\n")
for _, pt := range []wiki.PageType{wiki.PageTypeConcept, wiki.PageTypeEntity, wiki.PageTypeSource} {
entries := inventory[pt]
label := strings.ToUpper(string(pt)[:1]) + string(pt)[1:]
if len(entries) == 0 {
fmt.Fprintf(&sb, "%s — (none yet)\n\n", label)
continue
}
fmt.Fprintf(&sb, "%s:\n", label)
for _, e := range entries {
fmt.Fprintf(&sb, " - %s\n", e.Title)
}
sb.WriteString("\n")
}
if existingPage != "" {
sb.WriteString("## Current page for this source — revise it, don't restart\n\n")
sb.WriteString("This source has already been ingested and has the wiki page shown below.\n")
sb.WriteString("You are revising that page, not writing a fresh one:\n")
sb.WriteString(" - Add sections or claims the source below supports that this page doesn't have yet.\n")
sb.WriteString(" - Strengthen a claim where the source gives more detail, evidence, or nuance than what's already there.\n")
sb.WriteString(" - Leave unchanged whatever the source doesn't affect — reuse the existing wording verbatim rather than paraphrasing it, so an unchanged claim is recognized as unchanged.\n")
sb.WriteString(" - A merge step keeps this page's on-disk content for any section you don't return, so it's fine to omit a section you aren't revising.\n\n")
sb.WriteString(existingPage)
sb.WriteString("\n\n")
}
sb.WriteString("## Non-negotiable rules\n\n")
sb.WriteString("1. Output ONLY a valid JSON array — no prose, no fences.\n")
sb.WriteString("2. Fields: title, type, subtype (if applicable), domain (if applicable), content.\n")
sb.WriteString("3. Wikilinks: [[Display Name]] — no slug, no pipe. The pipeline handles slugs.\n")
sb.WriteString("4. Section links must match their section type (Related Concepts → concepts only, etc.).\n")
sb.WriteString("5. One source page per book — if inventory shows it exists, return it as an UPDATE.\n\n")
fmt.Fprintf(&sb, "## Source: %s\n\n", source)
sb.WriteString(content)
return sb.String()
}