Skip to content
Merged
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
265 changes: 248 additions & 17 deletions internal/policy/payload_text.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,15 @@
package policy

import "github.com/envector/rune-go/internal/domain"
import (
"fmt"
"strings"

"github.com/envector/rune-go/internal/domain"
)

// Payload text renderer — Python canonical: agents/common/schemas/templates.py (364 LoC).
// D15: line-by-line port. Verified via byte-for-byte golden fixture test.
//
// payload.text is the embedding fallback target (when reusable_insight empty)
// and the recall display text. Any divergence from Python changes embedding
// vector space → breaks recall.
//
// Porting scope:
// ✅ PAYLOAD_TEMPLATE (L14~) — multi-line format string
// ✅ _format_* helpers (L52-131) — 7 helpers (alternatives, trade_offs,
// assumptions, risks, evidence, links, tags)
// ✅ render_payload_text (L138-222) — main
// 🟡 render_compact_payload (L225-238) — Post-MVP
// 🟡 render_display_text (L288-363) — Post-MVP (EN/KO/JA locale)
//
// Subtle behaviors (easy to miss in porting):
// 1. phase_line / group_summary post-insertion (L204-216) — inserted AFTER
// template.format(), not in the template string itself
Expand All @@ -25,9 +18,247 @@ import "github.com/envector/rune-go/internal/domain"
// 3. _format_alternatives "chosen" marker bug (L59): chosen=="" makes all
// alternatives marked "(chosen)" — Python current behavior, keep bit-identical

// RenderPayloadText — Python: render_payload_text(record) at L138-222.
// TODO: line-by-line port with golden fixture test.
const payloadTemplate = `
# Decision Record: %s
ID: %s
Status: %s | Sensitivity: %s | Domain: %s
When/Where: %s | %s

## Decision
%s

## Problem
%s

## Alternatives Considered
%s

## Why (Rationale)
%s
Certainty: %s

## Trade-offs
%s

## Assumptions
%s

## Risks & Mitigations
%s

## Evidence (Quotes)
%s

## Links
%s

## Tags
%s
`

// FIXME: if chosen == "", every alternatievs matches since "".lower() == alt.lower() always true
func formatAlternatives(alternatives []string, chosen string) string {
if len(alternatives) == 0 {
return "- (none documented)"
}
var lines []string
chosenLower := strings.ToLower(chosen)
for _, alt := range alternatives {
altLower := strings.ToLower(alt)
if altLower == chosenLower || strings.Contains(altLower, chosenLower) {
lines = append(lines, fmt.Sprintf("- %s (chosen)", alt))
} else {
lines = append(lines, fmt.Sprintf("- %s", alt))
}
}
return strings.Join(lines, "\n")
}

func formatTradeOffs(tradeOffs []string) string {
if len(tradeOffs) == 0 {
return "- (none documented)"
}
var lines []string
for _, t := range tradeOffs {
lines = append(lines, fmt.Sprintf("- %s", t))
}
return strings.Join(lines, "\n")
}

func formatAssumptions(assumptions []domain.Assumption) string {
if len(assumptions) == 0 {
return "- (none documented)"
}
var lines []string
for _, a := range assumptions {
lines = append(lines, fmt.Sprintf("- %s (confidence: %.1f)", a.Assumption, a.Confidence))
}
return strings.Join(lines, "\n")
}

func formatRisks(risks []domain.Risk) string {
if len(risks) == 0 {
return "- (none documented)"
}
var lines []string
for _, r := range risks {
mitigation := "TBD"
if r.Mitigation != nil && *r.Mitigation != "" {
mitigation = *r.Mitigation
}
lines = append(lines, fmt.Sprintf("- Risk: %s\n Mitigation: %s", r.Risk, mitigation))
}
return strings.Join(lines, "\n")
}

func formatEvidence(evidence []domain.Evidence) string {
if len(evidence) == 0 {
return "(no evidence recorded)"
}
var lines []string
for i, e := range evidence {
sourceType := string(e.Source.Type)
sourceURL := "(no url)"
if e.Source.URL != nil && *e.Source.URL != "" {
sourceURL = *e.Source.URL
}

lines = append(lines, fmt.Sprintf("%d) Claim: %s", i+1, e.Claim))
lines = append(lines, fmt.Sprintf(" Quote: \"%s\"", e.Quote))
lines = append(lines, fmt.Sprintf(" Source: %s %s", sourceType, sourceURL))
if e.Source.Pointer != nil && *e.Source.Pointer != "" {
lines = append(lines, fmt.Sprintf(" Pointer: %s", *e.Source.Pointer))
}
lines = append(lines, "")
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}

func formatLinks(links []map[string]any) string {
if len(links) == 0 {
return "- (none)"
}
var lines []string
for _, link := range links {
rel := "link"
if r, ok := link["rel"].(string); ok {
rel = r
}
url := ""
if u, ok := link["url"].(string); ok {
url = u
}
lines = append(lines, fmt.Sprintf("- %s: %s", rel, url))
}
return strings.Join(lines, "\n")
}

func formatTags(tags []string) string {
if len(tags) == 0 {
return "(none)"
}
return strings.Join(tags, ", ")
}

func RenderPayloadText(r *domain.DecisionRecord) string {
// TODO: port per D15 canonical reference
return ""
domainStr := string(r.Domain)
sensitivity := string(r.Sensitivity)
status := string(r.Status)
certainty := string(r.Why.Certainty)

alternatives := formatAlternatives(r.Context.Alternatives, r.Context.Chosen)
tradeOffs := formatTradeOffs(r.Context.TradeOffs)
assumptions := formatAssumptions(r.Context.Assumptions)
risks := formatRisks(r.Context.Risks)
evidenceBlock := formatEvidence(r.Evidence)
links := formatLinks(r.Links)
tags := formatTags(r.Tags)

rationale := r.Why.RationaleSummary
if rationale == "" {
rationale = "(no rationale documented)"
}
if len(r.Why.MissingInfo) > 0 {
var missingLines []string
for _, m := range r.Why.MissingInfo {
missingLines = append(missingLines, fmt.Sprintf("- %s", m))
}
rationale += "\n\nMissing Information:\n" + strings.Join(missingLines, "\n")
}

when := r.Decision.When
if when == "" {
when = "(unknown)"
}
where := r.Decision.Where
if where == "" {
where = "(unknown)"
}
problem := r.Context.Problem
if problem == "" {
problem = "(not documented)"
}

text := fmt.Sprintf(payloadTemplate,
r.Title,
r.ID,
status, sensitivity, domainStr,
when, where,
r.Decision.What,
problem,
alternatives,
rationale,
certainty,
tradeOffs,
assumptions,
risks,
evidenceBlock,
links,
tags,
)

// Post insertion
if r.GroupID != nil && *r.GroupID != "" {
seq := 0
if r.PhaseSeq != nil {
seq = *r.PhaseSeq
}
seq++

totalStr := "?"
if r.PhaseTotal != nil {
totalStr = fmt.Sprintf("%d", *r.PhaseTotal)
}
gtype := "phase_chain"
if r.GroupType != nil && *r.GroupType != "" {
gtype = *r.GroupType
}
phaseLine := fmt.Sprintf("Part: %d of %s | Type: %s | Group: %s", seq, totalStr, gtype, *r.GroupID)

lines := strings.Split(text, "\n")
for i, line := range lines {
if strings.HasPrefix(line, "ID: ") {
insertPos := i + 1
// Insert phase line after ID line
newLines := make([]string, 0, len(lines)+2)
newLines = append(newLines, lines[:insertPos]...)
newLines = append(newLines, phaseLine)
// Insert group summary if present
if r.GroupSummary != nil && *r.GroupSummary != "" {
newLines = append(newLines, fmt.Sprintf("Group Summary: %s", *r.GroupSummary))
}
newLines = append(newLines, lines[insertPos:]...)
lines = newLines
break
}
}
text = strings.Join(lines, "\n")
}

// Clean up blank lines
for strings.Contains(text, "\n\n\n") {
text = strings.ReplaceAll(text, "\n\n\n", "\n\n")
}

return strings.TrimSpace(text)
}
61 changes: 44 additions & 17 deletions internal/policy/pii.go
Original file line number Diff line number Diff line change
@@ -1,28 +1,55 @@
package policy

import (
"fmt"
"regexp"
"strings"
)

// PII redaction — Python: agents/scribe/record_builder.py:L89-95 SENSITIVE_PATTERNS
// + L406-418 _redact_sensitive.
//
// Called from BuildPhases entry (L228) in BOTH legacy and agent-delegated modes
// (rune-mcp is responsible for PII redaction per D13 Option A).
//
// 5 patterns (exact regex to port from Python):
// 1. Email: [A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+
// 2. Phone: \d{3}[-.]?\d{3}[-.]?\d{4}
// 3. API key: [sk|pk|api|key|token|secret|password]_[a-zA-Z0-9_-]{15,}
// 4. Long hex: [A-Za-z0-9]{32,}
// 5. Credit card: [0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{4}
//
// Replacement labels per Python (exact strings): see record_builder.py:L89-95.

type sensitivePattern struct {
regex *regexp.Regexp
replacement string
}

// sensitivePatterns — 5 patterns, exact regex from Python L89-95.
// Order matters: more specific patterns (e.g., API key with prefix) must come
// before the generic long-alphanumeric pattern.
var sensitivePatterns = []sensitivePattern{
{regexp.MustCompile(`(?i)\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b`), "[EMAIL]"},
{regexp.MustCompile(`\b\d{3}[-.]?\d{3}[-.]?\d{4}\b`), "[PHONE]"},
{regexp.MustCompile(`(?i)\b(?:sk|pk|api|key|token|secret|password)[_\-][a-zA-Z0-9_\-]{15,}\b`), "[API_KEY]"},
{regexp.MustCompile(`\b[A-Za-z0-9]{32,}\b`), "[API_KEY]"},
{regexp.MustCompile(`\b[0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{4}\b`), "[CARD]"},
}

// RedactSensitive — Python: record_builder.py:L406-418 _redact_sensitive.
// Also applies MAX_INPUT_CHARS truncate (L227) after redaction.
//
// TODO: port 5 regex + replacement labels + length cap.
func RedactSensitive(text string) string {
// TODO: bit-identical to _redact_sensitive
if len(text) > MaxInputChars {
text = text[:MaxInputChars]
// Returns the redacted text and an optional notes string (e.g. "Redacted 2 [EMAIL]; Redacted 1 [PHONE]").
// Also applies MAX_INPUT_CHARS truncation after redaction.
func RedactSensitive(text string) (string, string) {
redacted := text
var notes []string

for _, sp := range sensitivePatterns {
matches := sp.regex.FindAllString(redacted, -1)
if len(matches) > 0 {
redacted = sp.regex.ReplaceAllString(redacted, sp.replacement)
notes = append(notes, fmt.Sprintf("Redacted %d %s", len(matches), sp.replacement))
}
}

if len(redacted) > MaxInputChars {
redacted = redacted[:MaxInputChars]
}

noteStr := ""
if len(notes) > 0 {
noteStr = strings.Join(notes, "; ")
}
return text
return redacted, noteStr
}
Loading
Loading