#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>
86 lines
4.2 KiB
Go
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()
|
|
}
|