package classify import ( "fmt" "sort" "strings" "maisym.com/hsa/internal/config" ) // NameVariants returns example renderings of one person's name as it might appear // on a receipt, derived from the structured (first/middle/last) data rather than // hand-written. `all` is the full people list, used to suppress initial-based // variants that would be ambiguous: with several people sharing a surname (e.g. // the Tremblays), "J. Tremblay" could be Jean-Michel or Jude, so neither gets it, // while "A. Tremblay" (unique to Alex) is kept. func NameVariants(p config.Person, all []config.Person) []string { first := strings.TrimSpace(p.First) last := strings.TrimSpace(p.Last) mid := strings.TrimSpace(p.Middle) var out []string add := func(s string) { s = strings.Join(strings.Fields(s), " ") // collapse whitespace if s == "" { return } for _, e := range out { if e == s { return } } out = append(out, s) } // Order and punctuation variants are always unambiguous (full first name present). add(first + " " + last) add(last + " " + first) add(last + ", " + first) if mid != "" { mi := initial(mid) add(first + " " + mi + ". " + last) add(last + ", " + first + " " + mi + ".") } // Compound first names ("Jean-Michel") yield specific multi-letter initials // ("J-M", "J.M.") that are far less likely to collide — keep them if unique. if parts := firstParts(first); len(parts) > 1 { comp := compoundInitial(parts, "-") // J-M compDot := compoundInitial(parts, ".") + "." if uniqueCompound(p, all) { add(comp + " " + last) add(compDot + " " + last) } } // A single first-initial ("J.") is only safe when no one else shares the // surname with the same initial. if fi := initial(first); fi != "" && uniqueSingleInitial(p, all) { add(fi + ". " + last) add(last + ", " + fi + ".") } return out } func initial(s string) string { for _, r := range strings.TrimSpace(s) { return strings.ToUpper(string(r)) } return "" } // firstParts splits a first name on spaces and hyphens ("Jean-Michel" -> Jean, Michel). func firstParts(first string) []string { return strings.FieldsFunc(first, func(r rune) bool { return r == '-' || r == ' ' }) } // compoundInitial joins the initial of each part with sep: ["Jean","Michel"] -> "J-M" / "J.M". func compoundInitial(parts []string, sep string) string { var b strings.Builder for i, p := range parts { if i > 0 { b.WriteString(sep) } b.WriteString(initial(p)) } return b.String() } // sameSurname reports whether q is a different person sharing p's surname. func sameSurname(p, q config.Person) bool { return !strings.EqualFold(strings.TrimSpace(p.First), strings.TrimSpace(q.First)) && strings.EqualFold(strings.TrimSpace(p.Last), strings.TrimSpace(q.Last)) } // uniqueSingleInitial reports whether p's first initial is unique among people // sharing p's surname. func uniqueSingleInitial(p config.Person, all []config.Person) bool { fi := initial(p.First) for _, q := range all { if sameSurname(p, q) && initial(q.First) == fi { return false } } return true } // uniqueCompound reports whether p's compound first-initial is unique among people // sharing p's surname. func uniqueCompound(p config.Person, all []config.Person) bool { pc := compoundInitial(firstParts(p.First), "-") for _, q := range all { if sameSurname(p, q) && compoundInitial(firstParts(q.First), "-") == pc { return false } } return true } // BuildSystemPrompt assembles the full instruction text for the classifier from the // catalog and today's date (YYYY-MM-DD). The catalog's canonical labels are the only // allowed outputs; variants and examples are presented as illustrations, never as // data to extract. func BuildSystemPrompt(persons []config.Person, categories []config.Category, today string) string { var b strings.Builder b.WriteString("You extract data from a US health-care receipt for HSA reimbursement. ") b.WriteString("Call the record_receipt tool exactly once with your best reading. ") b.WriteString("Never invent values; when a field is genuinely unreadable or absent, pass null for it.\n\n") // People. b.WriteString("PATIENT — who the receipt is FOR. Output one of these EXACT names, or null:\n") for _, p := range persons { variants := NameVariants(p, persons) // Drop the canonical form itself from the "also appears as" list. var also []string for _, v := range variants { if v != p.Label() { also = append(also, v) } } if len(also) > 0 { fmt.Fprintf(&b, " - %q (may also appear as: %s)\n", p.Label(), strings.Join(also, "; ")) } else { fmt.Fprintf(&b, " - %q\n", p.Label()) } } b.WriteString("Rules for the patient:\n") b.WriteString(" - The variants above are formatting illustrations of the SAME person, not separate people.\n") b.WriteString(" - Match on first AND last name together, in any order, with or without a comma, accents, or a middle name/initial.\n") b.WriteString(" - A lone surname, or an initial that fits more than one person above (e.g. \"J. Tremblay\" could be Jean-Michel or Jude), is AMBIGUOUS — output null.\n") b.WriteString(" - IGNORE everyone who is not the patient: doctors, dentists, pharmacists, nurses, providers, billing/account reps, the store manager, the cashier. Titles like Dr., MD, DDS, RPh, NP mark a provider, not a patient. A clinic, pharmacy, or business name is not a patient.\n\n") // Categories. b.WriteString("CATEGORY — output exactly one of these names (default to the most general bucket if unsure, never invent one):\n") for _, c := range categories { if len(c.Examples) > 0 { fmt.Fprintf(&b, " - %q: %s\n", c.Name, strings.Join(c.Examples, ", ")) } else { fmt.Fprintf(&b, " - %q\n", c.Name) } } b.WriteString("\n") // Date. fmt.Fprintf(&b, "DATE — the date of SERVICE or purchase as YYYY-MM-DD, or null. Today is %s.\n", today) b.WriteString(" - Receipts are contemporary: almost always within the last year or two, occasionally an older backfilled receipt, but NEVER in the future.\n") b.WriteString(" - If several dates appear (service, print, due, date of birth), use the service/transaction date — not a print/due date or DOB.\n") b.WriteString(" - Formats are usually numeric MM-DD-YY or MM-DD-YYYY (US order), sometimes a written month (\"Jun 5, 2025\"), and ISO YYYY-MM-DD when the first part is a 4-digit year. YY-MM-DD also occurs but is rare.\n") b.WriteString(" - Expand a 2-digit year to 20YY. When a date is ambiguous, pick the interpretation CLOSEST to today without going into the future.\n\n") // Amount. b.WriteString("AMOUNT — the total the patient actually PAID (grand total / amount due / amount paid / patient responsibility) as a plain number string like \"42.50\", or null.\n") b.WriteString(" - Strip currency symbols and thousands separators. Do not return a subtotal, list price, insurance-covered portion, or a single line item when a final total exists.\n\n") // Raw echoes. b.WriteString("RAW — also pass back the literal text you read for the name, date, and amount (raw_name, raw_date, raw_amount), exactly as printed, for auditing. Use \"\" if nothing was found.\n") b.WriteString("If the image holds more than one receipt, read the primary (largest/topmost) one.\n") return b.String() } // allowedLabels returns the canonical labels of a lookup, sorted for stable output. func allowedLabels(persons []config.Person) []string { out := make([]string, 0, len(persons)) for _, p := range persons { out = append(out, p.Label()) } sort.Strings(out) return out }