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) }