172 lines
5.1 KiB
Go
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) }
|