initial public release
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
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) }
|
||||
Reference in New Issue
Block a user