Files
apex-public/internal/eval/parser.go
T
2026-07-06 11:05:50 -04:00

172 lines
5.1 KiB
Go

package eval
import (
"regexp"
"strconv"
"strings"
)
// Block header regex. Matches "## A) Role Summary", "## G) Posting Legitimacy", etc.
// Tolerant of extra whitespace and alternate heading text after the letter.
var blockHeaderRE = regexp.MustCompile(`(?m)^##\s+([A-G])\)[^\n]*$`)
// archetypeRE captures "**Archetype:** FDE" style metadata lines.
var archetypeRE = regexp.MustCompile(`(?mi)^\*\*Archetype:\*\*\s*(.+?)\s*$`)
// summaryRE captures the final "**Score:** 4.2/5 **Legitimacy:** High Confidence" line.
// Score and Legitimacy may appear in either order, on one or two lines.
var scoreRE = regexp.MustCompile(`(?mi)\*\*Score:\*\*\s*([0-9]+(?:\.[0-9]+)?)\s*/\s*5`)
var legitRE = regexp.MustCompile(`(?mi)\*\*Legitimacy:\*\*\s*([A-Za-z ]+?)(?:\s*$|\s*\*\*)`)
// weightedScoreRE is the fallback location for the score — Block B's final line.
var weightedScoreRE = regexp.MustCompile(`(?mi)\*\*Weighted score:\*\*\s*([0-9]+(?:\.[0-9]+)?)\s*/\s*5`)
// tierAssessmentRE catches the in-Block-G tier line when the trailing summary
// line is missing: "**Assessment:** Proceed with Caution".
var tierAssessmentRE = regexp.MustCompile(`(?mi)\*\*Assessment:\*\*\s*(.+?)\s*(?:$|\*\*|\n)`)
// ParseEvaluation turns Claude's markdown output into a structured Evaluation.
// Missing blocks yield empty strings rather than errors — the caller decides
// whether incomplete output should be rejected or persisted with a warning.
func ParseEvaluation(raw string) (Evaluation, error) {
if strings.TrimSpace(raw) == "" {
return Evaluation{}, errEmpty
}
eval := Evaluation{}
if m := archetypeRE.FindStringSubmatch(raw); len(m) > 1 {
eval.Archetype = strings.TrimSpace(m[1])
}
// Score: prefer final summary line, fall back to Block B weighted score.
if m := scoreRE.FindStringSubmatch(raw); len(m) > 1 {
if v, err := strconv.ParseFloat(m[1], 64); err == nil {
eval.Score = v
}
} else if m := weightedScoreRE.FindStringSubmatch(raw); len(m) > 1 {
if v, err := strconv.ParseFloat(m[1], 64); err == nil {
eval.Score = v
}
}
// Legitimacy: summary line first, then Block-G Assessment.
if m := legitRE.FindStringSubmatch(raw); len(m) > 1 {
eval.Legitimacy = ParseTier(strings.TrimSpace(m[1]))
} else if m := tierAssessmentRE.FindStringSubmatch(raw); len(m) > 1 {
eval.Legitimacy = ParseTier(strings.TrimSpace(m[1]))
}
if eval.Legitimacy == "" {
eval.Legitimacy = TierUnknown
}
// Split into blocks by heading position.
headers := blockHeaderRE.FindAllStringSubmatchIndex(raw, -1)
for i, h := range headers {
letter := raw[h[2]:h[3]]
start := h[0]
end := len(raw)
if i+1 < len(headers) {
end = headers[i+1][0]
}
body := strings.TrimSpace(raw[start:end])
switch letter {
case "A":
eval.BlockA = body
case "B":
eval.BlockB = body
case "C":
eval.BlockC = body
case "D":
eval.BlockD = body
case "E":
eval.BlockE = body
case "F":
eval.BlockF = body
case "G":
eval.BlockG = body
}
}
// Parse structured stories from Block F.
eval.Stories = ParseStories(eval.BlockF)
return eval, nil
}
// storyHeaderRE matches "### Story N:" or "### Story N " headings.
var storyHeaderRE = regexp.MustCompile(`(?m)^###\s+Story\s+\d+[:\s]`)
// starFieldRE matches "**Situation:** ..." style lines.
var starFieldRE = regexp.MustCompile(`(?i)^\*\*([A-Za-z]+):\*\*\s*(.*)$`)
// ParseStories extracts structured STAR stories from Block F markdown.
// Tolerates missing fields and returns an empty slice if the format is not recognized.
func ParseStories(blockF string) []Story {
if strings.TrimSpace(blockF) == "" {
return nil
}
// Split on story headings, keeping the heading line.
parts := storyHeaderRE.Split(blockF, -1)
if len(parts) < 2 {
// No story headings found.
return nil
}
var stories []Story
// First element is before the first heading (often empty), skip it.
for i := 1; i < len(parts); i++ {
section := parts[i]
// Extract title from the start (everything before first newline after heading).
lines := strings.SplitN(section, "\n", 2)
title := strings.TrimSpace(lines[0])
// Remove trailing colons.
title = strings.TrimSuffix(title, ":")
body := ""
if len(lines) > 1 {
body = lines[1]
}
story := Story{Title: title}
// Extract STAR fields from body.
bodyLines := strings.Split(body, "\n")
for _, line := range bodyLines {
if m := starFieldRE.FindStringSubmatch(line); len(m) > 2 {
field := strings.ToLower(strings.TrimSpace(m[1]))
value := strings.TrimSpace(m[2])
switch field {
case "situation":
story.Situation = value
case "task":
story.Task = value
case "action":
story.Action = value
case "result":
story.Result = value
}
}
}
// Only add if at least one field was populated.
if story.Situation != "" || story.Task != "" || story.Action != "" || story.Result != "" {
stories = append(stories, story)
}
}
return stories
}
// errEmpty is returned for empty input so the caller can distinguish "nothing
// parsed" from "parsed but missing fields".
var errEmpty = parseError("empty evaluation input")
type parseError string
func (e parseError) Error() string { return string(e) }