hsa-app/internal/classify/prompt.go

193 lines
7.4 KiB
Go
Raw Permalink Normal View History

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
}