# 3-Layer Attribute Mapping — Adopter Matching Diagnosis Report date: 2026-04-20 Read-only diagnosis. No changes made. --- ## Layer 1 — What the Profiler Collects (Animal Attributes) ### 1A. Profiler Endpoint - **Endpoint:** `POST /api/caregiver/process` — server.ts:3542 - **Parser:** `parseBehaviorNotes()` — attributeParser.ts:118 - **LLM:** GPT-4o with `BEHAVIOR_EXTRACTION_PROMPT` (attributeParser.ts:79-115) - **Validation:** `BehaviorNotesSchema` (Zod, attributeParser.ts:23-56) - **Table:** `behavior_notes` ### 1B. Fields Extracted from Voice Transcript | Field | DB Column | Type | Fixed Values | Example | |---|---|---|---|---| | `color` | `color` | string | No | "Orange tabby with white chest" | | `specialFeatures` | `special_features` | string | No | "Bright green eyes, crooked tail" | | `energyLevel` | `energy_level` | string | No | "High energy, loves fetch" | | `energyLevel_match` | `energy_level_match` | enum | low / medium / high / unknown | "high" | | `peopleReaction` | `people_reaction` | string | No | "Very social and outgoing" | | `goodWithCats_text` | `good_with_cats_text` | string | No | "Gets along great, grooms other cats" | | `goodWithCats_match` | `good_with_cats_match` | enum | yes / somewhat / no / unknown | "yes" | | `goodWithDogs_text` | `good_with_dogs_text` | string | No | "Loves dogs, plays well" | | `goodWithDogs_match` | `good_with_dogs_match` | enum | yes / somewhat / no / unknown | "somewhat" | | `goodWithKids_text` | `good_with_kids_text` | string | No | "Great with kids of all ages" | | `goodWithKids_match` | `good_with_kids_match` | enum | yes / somewhat / no / unknown | "yes" | | `specialNeeds` | `special_needs` | string | No | "FIV+ but healthy", "None" | | `backstory` | `backstory` | string | No | "Found as stray" | | `additionalNotes` | `additional_notes` | string | No | "Best for quiet home" | | `caregiver` | `caregiver` | string/null | No | "Maria" | **Legacy fields** (set to `''` on all new records — attributeParser.ts:158-160): | Field | DB Column | Status | |---|---|---| | `goodWithCats` | `good_with_cats` | Always empty on new records | | `goodWithDogs` | `good_with_dogs` | Always empty on new records | | `goodWithKids` | `good_with_kids` | Always empty on new records | | `otherAnimalReaction` | `other_animal_reaction` | Always empty on new records | | `kidBehavior` | `kid_behavior` | Always empty on new records | ### 1C. ShelterManager Fields Source: `fetchAnimals()` in shelterManagerService.ts:81, normalized by `normalizeAnimal()` at line 41. Interface: `Animal` (types.ts:27-49), raw from `RawShelterAnimal`. | SM Raw Field | Animal Field | Type | Example | |---|---|---|---| | `SPECIESNAME` | `species` | string | "Dog", "Cat" | | `BREEDNAME` | `breed` | string | "Pit Bull Terrier Mix" | | `ANIMALAGE` | `age` | string | "2 years" | | `DATEOFBIRTH` | `ageInYears` | number (calculated) | 2.3 | | `SEXNAME` | `sex` | string | "Male", "Female" | | `SIZENAME` | `size` | string (normalized) | "small", "medium", "large", "extra-large" | | `BASECOLOURNAME` | `color` | string | "Black/White" | | `ANIMALCOMMENTS` | `description` | string | SM free text | | `PHOTOURLS` | `photoUrl` | string/null | First photo URL | | `DISPLAYLOCATION` | `location` | string | Shelter location | | `DATEBROUGHTIN` | `dateIntake` | string | ISO date | | `ADOPTABLE` | `isAvailable` | boolean | true/false | | `ADDITIONALFLAGS` | `additionalFlags` | string | "On Meds|Bite History|" | | `SHELTERCODE` | `shelterCode` | string | "S2025592" | | `ID` | `id` | string | "4441" | Also: `animal_metadata` table caches (animalId, shelterCode, name, species, breed, age, dateOfBirth, sex) so data persists after animals leave SM. Written at server.ts:3572-3585 during profiler processing. ### 1D. Combined Animal Attributes Table | Attribute | Source | Field | Type | Example Values | |---|---|---|---|---| | Species | SM | `animal.species` | string | "Dog", "Cat" | | Breed | SM | `animal.breed` | string | "Pit Bull Terrier Mix" | | Age (text) | SM | `animal.age` | string | "2 years" | | Age (numeric) | SM (calc) | `animal.ageInYears` | number | 2.3 | | Sex | SM | `animal.sex` | string | "Male", "Female" | | Size | SM (normalized) | `animal.size` | string | "small", "medium", "large" | | Color (SM) | SM | `animal.color` | string | "Black/White" | | Color (profiler) | Profiler | `notes.color` | string | "Orange tabby with white chest" | | Description | SM | `animal.description` | string | Free text | | Flags | SM | `animal.additionalFlags` | string | "On Meds|Bite History|" | | Energy (text) | Profiler | `notes.energyLevel` | string | "High energy, loves fetch" | | Energy (enum) | Profiler | `notes.energyLevel_match` | enum | low/medium/high/unknown | | Good w/ Cats (text) | Profiler | `notes.goodWithCats_text` | string | Rich description | | Good w/ Cats (enum) | Profiler | `notes.goodWithCats_match` | enum | yes/somewhat/no/unknown | | Good w/ Dogs (text) | Profiler | `notes.goodWithDogs_text` | string | Rich description | | Good w/ Dogs (enum) | Profiler | `notes.goodWithDogs_match` | enum | yes/somewhat/no/unknown | | Good w/ Kids (text) | Profiler | `notes.goodWithKids_text` | string | Rich description | | Good w/ Kids (enum) | Profiler | `notes.goodWithKids_match` | enum | yes/somewhat/no/unknown | | People reaction | Profiler | `notes.peopleReaction` | string | "Very social" | | Special needs | Profiler | `notes.specialNeeds` | string | "FIV+ but healthy" | | Backstory | Profiler | `notes.backstory` | string | "Found as stray" | | Special features | Profiler | `notes.specialFeatures` | string | "Bright green eyes" | | Additional notes | Profiler | `notes.additionalNotes` | string | "Best for quiet home" | --- ## Layer 2 — What the Adopter Extraction Captures ### 2A. Endpoint - **Endpoint:** `POST /api/coordinator/process` — server.ts:3497 - **Parser:** `parseAdopterPreferences()` — attributeParser.ts:164 - **LLM:** GPT-4o with `ADOPTER_EXTRACTION_PROMPT` (attributeParser.ts:107-116) - **Validation:** `AdopterPreferencesSchema` (Zod, attributeParser.ts:58-68) - **Table:** `adopter_preferences` ### 2B. LLM Prompt (verbatim, attributeParser.ts:107-116) ``` You are an expert at extracting adopter preferences from coordinator summaries. Extract the following information from the transcript. The transcript is a coordinator's summary of an adopter interview. Return a JSON object with these exact fields: - livingSituation: Their housing (e.g., "Apartment with balcony", "House with fenced yard", "Condo") - householdMembers: Who lives in the home (e.g., "Two adults, no children", "Family with two kids ages 5 and 8", "Single adult with one cat") - homeEnergyLevel: Activity level of the home (e.g., "Quiet and calm", "Active family", "Moderate, work from home") - timeAvailable: Time for pet care (e.g., "Home most of the day", "Working full-time, home evenings", "Retired, lots of time") - petExperience: Previous pet experience (e.g., "First-time pet owner", "Experienced with cats", "Had dogs growing up") - preferredTraits: What they're looking for (e.g., "Young, playful cat", "Calm adult dog, medium size", "Any age, cuddly personality") - openToSpecialNeeds: Boolean - are they open to special needs animals - locationPreferences: Any location constraints (e.g., "Local only", "Can travel within state", "No preference") - additionalNotes: Other relevant information Be accurate and preserve the adopter's specific preferences. ``` ### 2C. Preference Fields | Field | DB Column | Type | Example | |---|---|---|---| | `livingSituation` | `living_situation` | string | "House with fenced yard" | | `householdMembers` | `household_members` | string | "Family with two kids ages 5 and 8, one cat" | | `homeEnergyLevel` | `home_energy_level` | string | "Quiet and calm" | | `timeAvailable` | `time_available` | string | "Home most of the day" | | `petExperience` | `pet_experience` | string | "First-time pet owner" | | `preferredTraits` | `preferred_traits` | string | "Young, playful cat" | | `openToSpecialNeeds` | `open_to_special_needs` | boolean (INTEGER) | true/false | | `locationPreferences` | `location_preferences` | string | "Local only" | | `additionalNotes` | `additional_notes` | string | Freeform | ### 2D. Does extraction capture species? **No dedicated species field.** The prompt's `preferredTraits` is described as "What they're looking for" with examples like "Young, playful cat" and "Calm adult dog, medium size." Species gets baked into the `preferredTraits` freeform string alongside size, age, energy, breed, and personality. There is no `species` or `preferredSpecies` field in the schema. If the coordinator says "they want a black cat," GPT-4o produces something like `preferredTraits: "Black cat, calm personality"`. Species is captured only as an incidental substring in a mixed bag of traits. --- ## Layer 3 — What the Matcher Uses ### 3A. Endpoint - **Endpoint:** `POST /api/match` — server.ts:3468 - **Engine:** `findMatches()` — matchingEngine.ts - **Input:** `AdopterPreferences` object + `limit` (default 5) - **Animal source:** `fetchAnimals()` (all adoptable from SM) + `getBehaviorNotes()` per animal ### 3B. Scoring Architecture 100% deterministic weighted scoring — no LLM. Runs every adoptable animal through 7 scoring functions, computes weighted average, returns top N. | Factor | Weight | Function | Animal Data Read | Adopter Data Read | Scoring Logic | |---|---|---|---|---|---| | `energyMatch` | 0.20 | `scoreEnergyMatch()` | `notes.energyLevel` (freeform) | `prefs.homeEnergyLevel` | Parses both to low/med/high via keyword scan. Same=100, adjacent=70, opposite=30, unknown=50 | | `sizeMatch` | 0.10 | `scoreSizeMatch()` | `animal.size` (SM) | `prefs.preferredTraits` | Scans preferredTraits for "small"/"medium"/"large". Exact=100, "any"=80, no pref=60, mismatch=40 | | `experienceMatch` | 0.15 | `scoreExperience()` | `notes.specialNeeds` | `prefs.petExperience` | First-timer+special needs=30, experienced+special needs=90, first-timer+no needs=80, default=70 | | `householdMatch` | 0.25 | `scoreHouseholdMatch()` | `notes.kidBehavior` (LEGACY), `notes.otherAnimalReaction` (LEGACY), `animal.size` | `prefs.householdMembers`, `prefs.livingSituation` | Scans household for "kid"/"child" → checks kidBehavior. Scans for "dog"/"cat"/"pet" → checks otherAnimalReaction. Apartment+large=-15. Base=70 | | `specialNeedsMatch` | 0.10 | `scoreSpecialNeeds()` | `notes.specialNeeds` | `prefs.openToSpecialNeeds` | Has needs+open=90, has needs+not open=20, no needs=80 | | `traitMatch` | 0.10 | `scoreTraitMatch()` | `animal.ageInYears`, `animal.breed`, `notes.backstory`+`notes.energyLevel`+`notes.peopleReaction` | `prefs.preferredTraits` | Age buckets (young<2, adult 2-8, senior 8+), breed substring, 9 personality keywords | | `colorMatch` | 0.10 | `scoreColorMatch()` | `animal.color` (SM only) | `prefs.preferredTraits` | 21 color keywords + related-color fuzzy lookup. Exact=100, related=80, no pref=60, no match=30 | Bonus: +5 for animals with behavior notes on file. Final score: weighted sum, capped at 100. ### 3C. Does the matcher filter or score on species? **No.** There is: - No species pre-filter (all adoptable animals of all species enter the scoring loop) - No species scoring factor among the 7 factors - No species check in any individual scoring function The word "cat" or "dog" appearing in `preferredTraits` is never matched against `animal.species`. ### 3D. Does the matcher use an LLM? **No.** Entirely deterministic keyword matching and weighted arithmetic. --- ## Cross-Layer Mapping Table | Interview Question | What It Should Capture | Layer 2 Extraction Field | Layer 1 Animal DB Field | Layer 3 Matcher Behavior | |---|---|---|---|---| | Q1: Species | species preference | Buried in `preferredTraits` (no dedicated field) | `animal.species` (SM) | **NOT USED** — no filter, no score | | Q1: Color | color preference | Buried in `preferredTraits` | `animal.color` (SM), `notes.color` (profiler) | `scoreColorMatch` weight 0.10 — reads SM `animal.color` only, ignores profiler `notes.color` | | Q1: Size | size preference | Buried in `preferredTraits` | `animal.size` (SM, normalized) | `scoreSizeMatch` weight 0.10 — keyword scan of `preferredTraits` vs `animal.size` | | Q1: Energy | preferred energy | Buried in `preferredTraits` | `notes.energyLevel` (text), `notes.energyLevel_match` (enum) | **MISMATCH** — `scoreEnergyMatch` reads `prefs.homeEnergyLevel` (Q6), NOT `preferredTraits`. Q1 energy pref ignored. | | Q2: House/apt, yard | living situation | `livingSituation` | — | `scoreHouseholdMatch` weight 0.25 — apartment+large=-15. No yard scoring. | | Q3: First pet | experience level | `petExperience` | — | `scoreExperience` weight 0.15 — first-timer+special needs=30 | | Q4: Other animals, kinds | compatibility | Buried in `householdMembers` | `notes.goodWithDogs_match` (enum), `notes.goodWithCats_match` (enum), `notes.otherAnimalReaction` (LEGACY, empty) | `scoreHouseholdMatch` reads `notes.otherAnimalReaction` (LEGACY). **BROKEN** — always empty on new records. Enums exist but unused. | | Q5: Kids, elderly | household | Buried in `householdMembers` | `notes.goodWithKids_match` (enum), `notes.kidBehavior` (LEGACY, empty) | `scoreHouseholdMatch` reads `notes.kidBehavior` (LEGACY). **BROKEN** — always empty on new records. Enum exists but unused. No elderly scoring. | | Q6: Energy of home | home energy level | `homeEnergyLevel` | `notes.energyLevel` (text), `notes.energyLevel_match` (enum) | `scoreEnergyMatch` weight 0.20 — parses freeform text to low/med/high. Does NOT use `energyLevel_match` enum. | | Q7: Time available | time | `timeAvailable` | — | **NOT USED** — no scoring factor | | Q8: Special needs | willingness | `openToSpecialNeeds` (boolean) | `notes.specialNeeds` (text) | `scoreSpecialNeeds` weight 0.10 — boolean vs text keyword scan | | Q9: Anything else | freeform | `additionalNotes` | — | **NOT USED** — no scoring factor | --- ## Known Issues ### Critical 1. **Species: BROKEN.** Adopter says "I want a cat" → buried in `preferredTraits` → matcher never checks `animal.species` → adopter asking for a cat gets scored against every dog too. Single biggest gap — every match set is polluted with wrong species. 2. **Compatibility enums (`_match` fields) completely unused by matcher.** Profiler extracts `goodWithCats_match`, `goodWithDogs_match`, `goodWithKids_match`, and `energyLevel_match` — purpose-built for filtering (Zod annotations: "Enum for filtering") — but matchingEngine.ts never reads them. Reads legacy freeform fields instead. 3. **Legacy fields empty on all new records → household scoring dead.** `parseBehaviorNotes()` (attributeParser.ts:158-160) sets `otherAnimalReaction: ''` and `kidBehavior: ''` on every new record. `scoreHouseholdMatch` (weight 0.25, the heaviest factor) reads exactly those empty fields. For any animal profiled after the dual-storage migration, household scoring always returns base 70 regardless of actual compatibility. ### Moderate 4. **Energy: dual input, single read.** Q1 captures desired animal energy (lands in `preferredTraits`), Q6 captures home energy (lands in `homeEnergyLevel`). Matcher only reads `homeEnergyLevel`. Q1 energy preference ignored. 5. **Color: SM only, ignores profiler.** `scoreColorMatch` reads `animal.color` (SM's `BASECOLOURNAME`). Profiler's richer `notes.color` is never consulted. 6. **`preferredTraits` is a catch-all.** Species, color, size, energy, age, breed, and personality all crammed into one freeform string. Matcher keyword-scans it for different purposes in 3 separate functions. Can't distinguish "wants a cat" from "has a cat at home." ### Low Priority 7. **Time available (Q7): NOT SCORED.** Extracted and stored, no matching factor. 8. **Additional notes (Q9): NOT SCORED.** Could contain deal-breakers. 9. **Location preferences: NOT SCORED.** Low relevance for local shelter. 10. **No yard scoring.** `livingSituation` captures "fenced yard" but matcher only penalizes apartment+large. ### Architectural Notes - The dual-storage pattern (`_text` for bios + `_match` enums for filtering) is well-designed on the extraction side. The consumer side (matchingEngine.ts) was never wired to use the enums. - `householdMatch` has the highest weight (0.25) but is currently non-functional for post-migration animals. - The matcher is deterministic (no LLM), making fixes straightforward: add species pre-filter, swap legacy field reads for enum reads, optionally add new scoring factors. --- REPORT COMPLETE — 199 total lines.