From f51f5000704c96a472183d162b750d40c482fb5a Mon Sep 17 00:00:00 2001 From: Tati Date: Mon, 10 Aug 2026 22:12:11 -0400 Subject: [PATCH 1/7] docs(property-pro): scaffold showing tour guide discovery and fixtures Add Stage 1 product docs, fair-housing guardrails, and three fictitious markdown listing packets so we can implement the voice tour next. Co-authored-by: Cursor --- community/property-pro/README.md | 166 +++++++++++++ community/property-pro/fair_housing.md | 77 ++++++ community/property-pro/fixtures/LISTINGS.md | 11 + .../fixtures/listings/1420-maple-richmond.md | 92 +++++++ .../listings/7-pine-sparse-chesterfield.md | 73 ++++++ .../listings/88-canal-loft-richmond.md | 78 ++++++ .../property-pro/notes/PRODUCT_DISCOVERY.md | 232 ++++++++++++++++++ 7 files changed, 729 insertions(+) create mode 100644 community/property-pro/README.md create mode 100644 community/property-pro/fair_housing.md create mode 100644 community/property-pro/fixtures/LISTINGS.md create mode 100644 community/property-pro/fixtures/listings/1420-maple-richmond.md create mode 100644 community/property-pro/fixtures/listings/7-pine-sparse-chesterfield.md create mode 100644 community/property-pro/fixtures/listings/88-canal-loft-richmond.md create mode 100644 community/property-pro/notes/PRODUCT_DISCOVERY.md diff --git a/community/property-pro/README.md b/community/property-pro/README.md new file mode 100644 index 00000000..55c568a8 --- /dev/null +++ b/community/property-pro/README.md @@ -0,0 +1,166 @@ +# PropertyPro — Voice Showing Tour Guide + +![Community](https://img.shields.io/badge/OpenHome-Community-orange?style=flat-square) +![Author](https://img.shields.io/badge/Author-@ileana--pr-lightgrey?style=flat-square) +![Status](https://img.shields.io/badge/Status-Early-yellow?style=flat-square) + +A voice-enabled residential showing companion for OpenHome. A visitor walks into a listed home, picks up the device on the table, and says **hello** to start a short room-by-room tour — with fair-housing-safe answers and unanswered questions saved for the listing agent. + +Part of **Suite B** (Specialized Property Guides), alongside StayGuide and BizSpace. + +--- + +## Scene + +**Visitor path (Stage 1 and beyond):** + +1. Buyer/renter walks in, reads the note, says **hello**, tours with the speaker. +2. PropertyPro answers from the active listing packet; logs gaps; optional contact/email. + +**Agent setup — target (future app):** + +1. Place the speaker + note in the home. +2. Phone app: upload listing packet → connect speaker → select packet → start assistant → leave. + +**Agent setup — Stage 1 stand-in:** + +1. Load a markdown packet (`fixtures/listings/…` or storage) and set `active_listing_id`. +2. Same visitor path from hello onward. + +--- + +## Trigger Words + +| Phrase | What it does | +| --- | --- | +| `"hello"` / `"hi"` | Start (or resume) the showing tour | +| `"start tour"` / `"begin tour"` | Same as hello | +| `"next room"` / `"go back"` | Move through the room path | +| `"text the agent"` / `"call the agent"` | On-the-spot contact (Twilio when configured) | +| `"email my questions"` / `"send questions to the agent"` | Email `tour_questions.md` to the listing agent | +| `"what's the agent's number"` | Speak listing-agent contact from the packet | + +Exact trigger list will be finalized in Dashboard config when `main.py` lands. + +--- + +## What It Answers (and what it won't) + +**From the listing packet (safe):** beds/baths, sq ft, price, HOA fee, inclusions, systems updates, room notes, school *assignment* (name only, no ratings), agent contact. + +**Redirect (fair housing / customary practice):** + +- Crime / “is this neighborhood safe?” → point to official sources; do **not** recite crime stats or opinions. +- School *quality* → assignment if known + public evaluation links; no “good/bad” rankings. +- Who lives here / protected-class suitability → hard refuse (no steering, no demographics). + +**Missing facts:** *“I don’t have that in my notes — I’ll add it for the listing agent.”* → append `tour_questions.md`. + +A `fair_housing.md` knowledge base ships with the ability so the model stays on the right side of FHA / steering rules. This is product guardrails, not legal advice to consumers or brokers. + +--- + +## Planned Features (Stage 1) + +- [ ] Hello → greet → room tour → Q&A → exit voice flow +- [ ] Listing packet grounded answers +- [ ] Fair-housing KB (redirects + hard refusals) +- [ ] Unanswered questions → `tour_questions.md` +- [ ] Optional email of that file via `CapabilityWorker.send_email()` +- [ ] Optional on-the-spot Twilio SMS / TTS outbound call (with confirmation) +- [ ] Three mock listing fixtures for testing (see below) +- [ ] `knowledge_gaps.json` for product/schema gaps + +**Out of scope for Stage 1:** buyer search portal, seller CMA / pricing advice, live crime APIs spoken aloud, two-way call bridge, CRM / lead capture. + +--- + +## Mock listings (testing) + +Fictitious packets used while building and demoing: + +| ID | Property | Why it exists | +| --- | --- | --- | +| `1420-maple-richmond` | Full craftsman SFH | Happy-path tour with systems + inclusions | +| `88-canal-loft-richmond` | Condo + HOA | Fees, inclusions, partial school data | +| `7-pine-sparse-chesterfield` | Thin ranch packet | Forces question logging + contact demos | + +Listing packets are markdown under [`fixtures/listings/`](fixtures/listings/). Index: [`fixtures/LISTINGS.md`](fixtures/LISTINGS.md). Design detail: [`notes/PRODUCT_DISCOVERY.md`](notes/PRODUCT_DISCOVERY.md). + +--- + +## Setup (preview) + +### 1. Install the ability + +Add PropertyPro in the OpenHome Dashboard and set the trigger phrases above. + +### 2. Load a listing packet + +Point the ability at the active listing (pref `active_listing_id`). Stage 1 packets are **markdown** files under `fixtures/listings/` (or a user-uploaded `.md` packet). MLS sync comes later. + +### 3. Optional delivery + +| Channel | Requirement | +| --- | --- | +| Email questions list | SMTP config + `agent_email` on the listing packet (`send_email()`) | +| Text / call agent | Twilio prefs (same pattern as community Twilio-SMS) + agent phone on packet | + +Without those, PropertyPro still speaks the agent’s number and always writes `tour_questions.md`. + +--- + +## Example conversation + +**Visitor:** “Hello.” + +**PropertyPro:** “Hi — welcome to Maple Avenue Craftsman. I’ll walk you through the main spaces, and you can ask questions anytime. I’m here for facts about this property — for neighborhood topics like crime or demographics, I’ll point you to official sources rather than give opinions. We’ll begin in the foyer.” + +**Visitor:** “Okay.” + +**PropertyPro:** “You’re in the foyer — room dimensions 11 by 8. Original craftsman woodwork, opens into the living room.” + +**Visitor:** “What’s the square footage?” + +**PropertyPro:** “About 1,840 square feet.” + +**Visitor:** “Is this a safe neighborhood?” + +**PropertyPro:** “I don’t give crime opinions. You can check the city’s public safety open data and local police resources — I’ve got those links in my notes for this market.” + +**Visitor:** “When was the roof done?” *(on a sparse listing)* + +**PropertyPro:** “I don’t have the roof age in my notes. I’ve added that to the agent’s question list.” + +**Visitor:** “Text the agent.” + +**PropertyPro:** “I’ll text Avery Chen with three questions from today’s showing at 1420 Maple. Send it?” + +--- + +## Project layout (target) + +``` +community/property-pro/ +├── README.md +├── fair_housing.md +├── fixtures/LISTINGS.md +├── fixtures/listings/*.md ← listing packets (markdown) +├── notes/PRODUCT_DISCOVERY.md +├── main.py ← MatchingCapability (todo) +└── .openhome.json ← ability manifest (todo) +``` + +--- + +## Related + +- Suite A sibling: [`community/town-hall`](../town-hall/) — civic briefing patterns (voice flow, prefs, gap logging) we reuse here +- OpenHome email template: [`templates/send-email`](../../templates/send-email/) +- Twilio SMS / outbound call: [`community/Twilio-SMS`](../Twilio-SMS/) + +--- + +## Status + +Discovery scaffold in progress: README, `fair_housing.md`, markdown listing fixtures, and `notes/PRODUCT_DISCOVERY.md` are in place. Next: `main.py` for hello → tour → Q&A on the Maple fixture. diff --git a/community/property-pro/fair_housing.md b/community/property-pro/fair_housing.md new file mode 100644 index 00000000..389275ea --- /dev/null +++ b/community/property-pro/fair_housing.md @@ -0,0 +1,77 @@ +# Fair housing & showing-tour rules + +product guardrails for propertypro. not legal advice. brokers should review for their market. + +## hard rules (never violate) + +- do not steer buyers toward or away from a home or area based on race, color, religion, sex, disability, familial status, national origin, or other protected classes under applicable federal, state, or local law. +- do not describe who lives in the neighborhood by protected class (race, ethnicity, religion, national origin, family status, etc.). +- do not say a home or area is or is not a "good fit" for someone because of a protected characteristic. +- treat every visitor the same on factual property information. do not vary answers based on who is asking. + +## redirect policy (stage 1 product choice) + +even when factual school or crime *data* may be discussable under some federal guidance, this ability follows safer customary practice: + +### crime / safety + +- do not recite crime statistics, rankings, or "safe / unsafe" opinions. +- redirect to official public sources listed in the active listing packet `redirect_urls.crime_open_data` (or market defaults). +- log a follow-up for the listing agent only if the visitor asks — do not prompt them. + +spoken pattern: + +> i don't give crime opinions or stats. you can check the public safety open data and local police resources linked in my notes. + +if the visitor asks to leave a note for the agent about those links, append `tour_questions.md`. do not prompt them to do so. + +### school quality + +- you may state school *assignment* for the address if it is in the listing packet. +- do not rate schools as good/bad or recommend based on quality opinions. +- point visitors to the district site / public report cards (`redirect_urls.school_district`). + +spoken pattern: + +> i don't rate schools. this address is listed under [assignment if known] — please verify on the district site. public report cards are the place to judge fit. + +### demographics + +- hard refuse. do not soft-answer with "vibes" about who lives nearby. + +spoken pattern: + +> i'm not able to discuss neighborhood demographics under fair housing guidelines. i can stick to facts about this property if you have another question. + +## allowed property talk + +answer from the listing packet when present: + +- beds, baths, size, lot, year built, price, taxes/hoa fees as listed +- room notes, **room dimensions** (speak as "11 by 8", not "11x8"), systems ages, inclusions/exclusions +- parking, utilities notes, disclosure pointers +- agent name, phone, email +- flood map *link* redirect (`redirect_urls.flood_map`) — do not invent zone determinations + +if room dimensions are missing from the packet, skip that clause — do not invent sizes. + +if a property fact is missing from the packet: + +> i don't have that in my notes. i've added that to the agent's question list. + +then append to `tour_questions.md`. never say only "ask your agent" without logging. + +## soft refuse (not fair housing, still out of scope) + +- offer strategy, pricing opinions, "should i buy this," inspection negotiation: decline to advise; optionally log that the visitor wants human follow-up. +- buyer financing / underwriting: point them to their lender. + +## consistency + +same factual content for every visitor. listing packets cannot enable demographic or crime editorial talk. + +## maintainer references + +- hud fair housing overview: https://www.hud.gov/fairhousing +- nar fair housing resources: https://www.nar.realtor/fair-housing +- hud apr 2026 dear colleague letter (school/crime data discussion): product still chooses redirect for crime and no school-quality opinions diff --git a/community/property-pro/fixtures/LISTINGS.md b/community/property-pro/fixtures/LISTINGS.md new file mode 100644 index 00000000..062085bd --- /dev/null +++ b/community/property-pro/fixtures/LISTINGS.md @@ -0,0 +1,11 @@ +# Listing fixtures + +Stage 1 listing packets are **markdown** (not JSON). Agents/sellers can edit them like a checklist; OpenHome can also inject `.md` into ambient context the same way Town Hall does. + +| File | Role | +| --- | --- | +| [`listings/1420-maple-richmond.md`](listings/1420-maple-richmond.md) | Full SFH happy path | +| [`listings/88-canal-loft-richmond.md`](listings/88-canal-loft-richmond.md) | Condo + HOA | +| [`listings/7-pine-sparse-chesterfield.md`](listings/7-pine-sparse-chesterfield.md) | Sparse packet / logging + contact stress test | + +All addresses and contacts are fictitious. Schema notes: [`../notes/PRODUCT_DISCOVERY.md`](../notes/PRODUCT_DISCOVERY.md) §4. diff --git a/community/property-pro/fixtures/listings/1420-maple-richmond.md b/community/property-pro/fixtures/listings/1420-maple-richmond.md new file mode 100644 index 00000000..0b0c1690 --- /dev/null +++ b/community/property-pro/fixtures/listings/1420-maple-richmond.md @@ -0,0 +1,92 @@ +# Maple Avenue Craftsman + +- **id:** `1420-maple-richmond` +- **address:** 1420 Maple Avenue, Richmond, VA 23220 +- **price:** $425,000 +- **beds / baths:** 3 / 2 +- **sq ft:** 1,840 +- **lot:** 0.18 acres +- **year built:** 1928 +- **hoa:** none + +## Agent + +- **name:** Avery Chen +- **brokerage:** River City Realty +- **phone:** +15550101420 +- **email:** avery.chen@example.com + +## Seller welcome + +Thanks for touring — make yourselves at home. + +## Tour order + +1. foyer +2. living +3. kitchen +4. primary +5. upstairs +6. basement +7. backyard + +## Rooms + +### foyer +- **dimensions:** 11 by 8 +- **note:** Original craftsman woodwork; opens into the living room. + +### living +- **dimensions:** 16 by 14 +- **note:** Working fireplace. + +### kitchen +- **dimensions:** 12 by 14 +- **note:** Updated in 2021; quartz counters; gas range; opens toward the deck. + +### primary +- **dimensions:** 14 by 12 +- **note:** Main-floor primary bedroom with en-suite bath. + +### upstairs +- **dimensions:** 11 by 10 +- **note:** Two bedrooms and one full bath; each bedroom about 11 by 10. + +### basement +- **dimensions:** 28 by 18 +- **note:** Unfinished storage; laundry hookups. + +### backyard +- **dimensions:** (none) +- **note:** Fenced yard with wood deck off the kitchen. + +## Systems + +- **roof:** 2019 +- **hvac:** 2020 +- **water heater:** 2018 +- **breaker box:** basement stair wall +- **water shutoff:** front crawl access + +## Inclusions + +- refrigerator +- washer +- dryer + +## Exclusions + +- freestanding bookshelf + +## School assignment + +- **elementary:** Bellevue Elementary +- **middle:** Albert Hill Middle +- **high:** Thomas Jefferson High +- **verify:** verify on district site — assignment is illustrative for this fictitious listing + +## Redirect URLs + +- **crime open data:** https://example.com/richmond-public-safety +- **school district:** https://example.com/rps-school-finder +- **flood map:** https://msc.fema.gov/portal/home diff --git a/community/property-pro/fixtures/listings/7-pine-sparse-chesterfield.md b/community/property-pro/fixtures/listings/7-pine-sparse-chesterfield.md new file mode 100644 index 00000000..f2fdeb71 --- /dev/null +++ b/community/property-pro/fixtures/listings/7-pine-sparse-chesterfield.md @@ -0,0 +1,73 @@ +# Pine Court Ranch + +- **id:** `7-pine-sparse-chesterfield` +- **address:** 7 Pine Court, Chesterfield, VA 23832 +- **price:** $289,900 +- **beds / baths:** 3 / 2 +- **sq ft:** about 1,450 (verify) +- **lot:** (unknown) +- **year built:** (unknown) +- **hoa:** none listed + +## Agent + +- **name:** Sam Ortiz +- **brokerage:** Southside Team +- **phone:** +15550100007 +- **email:** sam.ortiz@example.com + +## Seller welcome + +(none) + +## Tour order + +1. living +2. kitchen +3. bedrooms +4. baths +5. garage + +## Rooms + +### living +- **dimensions:** 15 by 12 +- **note:** Front living room. + +### kitchen +- **dimensions:** 11 by 10 +- **note:** Eat-in kitchen. + +### bedrooms +- **dimensions:** (unknown) +- **note:** Three bedrooms on the main level. + +### baths +- **dimensions:** (unknown) +- **note:** Two full baths. + +### garage +- **dimensions:** 20 by 12 +- **note:** One-car garage. + +## Systems + +(not provided — log if asked) + +## Inclusions + +(not provided — log if asked) + +## Exclusions + +(not provided — log if asked) + +## School assignment + +(not provided — log if asked) + +## Redirect URLs + +- **crime open data:** https://example.com/chesterfield-public-safety +- **school district:** https://example.com/chesterfield-schools +- **flood map:** https://msc.fema.gov/portal/home diff --git a/community/property-pro/fixtures/listings/88-canal-loft-richmond.md b/community/property-pro/fixtures/listings/88-canal-loft-richmond.md new file mode 100644 index 00000000..8d6ee7e3 --- /dev/null +++ b/community/property-pro/fixtures/listings/88-canal-loft-richmond.md @@ -0,0 +1,78 @@ +# Canal Walk Loft 4B + +- **id:** `88-canal-loft-richmond` +- **address:** 88 Canal Walk, Unit 4B, Richmond, VA 23219 +- **price:** $312,000 +- **beds / baths:** 1 / 1 +- **sq ft:** 920 +- **lot:** (none — condo) +- **year built:** 2005 +- **hoa:** $285 / month — includes water, trash, exterior maintenance +- **hoa docs:** resale / condo docs available from listing agent + +## Agent + +- **name:** Jordan Blake +- **brokerage:** Downtown Homes +- **phone:** +15550100088 +- **email:** jordan.blake@example.com + +## Seller welcome + +Welcome to 4B — enjoy the canal view. + +## Tour order + +1. entry +2. living_kitchen +3. bedroom +4. bath +5. balcony + +## Rooms + +### entry +- **dimensions:** 6 by 5 +- **note:** Secure building entry; unit door opens to open loft plan. + +### living_kitchen +- **dimensions:** 22 by 18 +- **note:** Open loft living with kitchen island; tall windows facing the canal. + +### bedroom +- **dimensions:** 12 by 11 +- **note:** One bedroom with closet; partial privacy wall from living area. + +### bath +- **dimensions:** 8 by 5 +- **note:** Full bath with tub/shower combo. + +### balcony +- **dimensions:** 10 by 4 +- **note:** Private balcony with canal view. + +## Systems + +- **roof:** (unknown) +- **hvac:** 2015 building system +- **water heater:** (unknown) +- **in-unit laundry:** 2022 stacked washer dryer + +## Inclusions + +- stacked washer +- stacked dryer + +## Exclusions + +- wall-mounted TV + +## School assignment + +(not provided — log if asked) + +## Redirect URLs + +- **crime open data:** https://example.com/richmond-public-safety +- **school district:** https://example.com/rps-school-finder +- **flood map:** https://msc.fema.gov/portal/home diff --git a/community/property-pro/notes/PRODUCT_DISCOVERY.md b/community/property-pro/notes/PRODUCT_DISCOVERY.md new file mode 100644 index 00000000..d5ae6c43 --- /dev/null +++ b/community/property-pro/notes/PRODUCT_DISCOVERY.md @@ -0,0 +1,232 @@ +# PropertyPro — Product Discovery (Stage 1) + +Living design note for the showing tour guide. Implementation should follow this cut line before growing scope. + +**Git base:** `add-property-pro` from `origin/dev` (includes merged town-hall #358). + +--- + +## 1. Scene + trigger + +**Scene:** Visitor enters a listed home. OpenHome device on a table. Card says: “Pick me up and say hello to start the tour.” + +**Who prepared it (target future):** Listing agent places the speaker, opens a phone app, uploads the listing packet (`.md`), connects the speaker, selects that packet as active, arms PropertyPro, and leaves. + +**Who prepared it (Stage 1):** Demo/dev loads a fixture from `fixtures/listings/*.md` (or a hand-authored packet in storage) and sets `active_listing_id`. Same visitor experience after hello. + +**Who uses it:** Buyer, buyer’s agent, or open-house visitor. + +**Triggers (planned):** `hello`, `hi`, `start tour`, `begin tour`, plus in-tour control / contact phrases. + +**Mode:** Short companion turns. One beat per room. **Visitor-driven** — state facts, then wait. No trailing “what next?” prompts. + +```text +hello → greet + fair-housing soft disclaimer + → room path (from packet) + → Q&A loop (packet | redirect | refuse | log) + → exit (confirm questions saved) + → optional email / on-demand SMS-call +``` + +--- + +## 2. Sample dialogue (Maple Avenue Craftsman) + +**Visitor:** Hello. + +**PropertyPro:** Hi — welcome to Maple Avenue Craftsman. I’ll walk you through the main spaces, and you can ask me questions anytime. I’m here for facts about this property — for neighborhood topics like crime or demographics, I’ll point you to official sources rather than give opinions. We’ll begin in the foyer. + +**Visitor:** Okay. + +**PropertyPro:** You’re in the foyer — room dimensions 11 by 8. Original craftsman woodwork, opens into the living room. + +**Visitor:** Living room. + +**PropertyPro:** You’re in the living room — room dimensions 16 by 14, working fireplace. + +**Visitor:** What’s the square footage of the whole house? + +**PropertyPro:** About 1,840 square feet. + +**Visitor:** Kitchen. Do the appliances stay? + +**PropertyPro:** You’re in the kitchen — room dimensions 12 by 14. Updated in 2021, quartz counters, gas range. Fridge, washer, and dryer convey. The seller’s freestanding bookshelf does not. + +**Visitor:** When was the roof done? + +**PropertyPro:** Roof was replaced in 2019. + +**Visitor:** Is this a safe neighborhood? + +**PropertyPro:** I don’t give crime opinions or stats. You can check Richmond’s public safety open data and local police resources — links are in my market notes. + +**Visitor:** No thanks. Are the schools good? + +**PropertyPro:** I don’t rate schools. This address is listed under Bellevue Elementary, Albert Hill Middle, and Thomas Jefferson High — please verify on the district site. Public report cards are the place to judge fit. + +**Visitor:** Who lives around here? Is it diverse? + +**PropertyPro:** I’m not able to discuss neighborhood demographics under fair housing guidelines. I can stick to facts about this property if you have another question. + +**Visitor:** What’s the water heater age? *(switch to Pine Court sparse packet in other tests)* + +On Maple (full packet): Water heater was replaced in 2018. + +On Pine (sparse): I don’t have the water heater age in my notes. I’ve added that to the agent’s question list. + +**Visitor:** Text the agent. + +**PropertyPro:** I’ll text Avery Chen with your open questions from today’s showing at 1420 Maple. Send it? + +**Visitor:** Yes. + +**PropertyPro:** Sent. + +**Visitor:** We’re done. + +**PropertyPro:** That’s the main tour. I’ve saved two questions for the listing agent. Thanks for visiting — say hello anytime if you want to walk through again. + +--- + +## 3. Fair-housing knowledge base + +Runtime file: [`../fair_housing.md`](../fair_housing.md) (injected as ability context). Listing packets cannot override hard rules. + +Summary: + +| Topic | Behavior | +| --- | --- | +| Protected-class steering / “good for people like us” | Hard refuse | +| Neighborhood demographics (race, religion, etc.) | Hard refuse | +| Crime / “safe neighborhood” | Redirect to official sources; no spoken stats or vibes | +| School *quality* | Assignment OK if sourced; no ratings; point to public evaluation | +| Property facts in packet | Answer | +| Missing property facts | Log to `tour_questions.md` | + +Product policy: even with HUD Apr 2026 clarification on school/crime *data*, Stage 1 keeps the safer customary practice — redirect crime; no school quality opinions. + +--- + +## 4. Listing packet schema + +Stage 1 packets are **markdown files**. Rationale: easy for agents to edit, matches OpenHome ambient `.md` injection, no parse layer required for the first slice. + +Suggested sections (see fixtures for full examples): + +```markdown +# Marketing name + +- **id:** `listing-slug` +- **address:** … +- **price:** … +- **beds / baths:** … +- **sq ft:** … +- **hoa:** … + +## Agent +## Seller welcome +## Tour order +## Rooms +### foyer +- **dimensions:** 11 by 8 +- **note:** … +## Systems +## Inclusions +## Exclusions +## School assignment +## Redirect URLs +``` + +Missing sections / `(unknown)` / `(not provided)` → log to `tour_questions.md` when asked. Do not invent facts. + +Active listing pref (planned): `active_listing_id` → load `fixtures/listings/{id}.md` (or a user-uploaded packet path). + +**Room tour beat rule:** for each room in tour order, speak label + dimensions when present (`room dimensions 11 by 8` — never `11x8` for TTS). Then the short note. If dimensions are unknown/missing, skip that clause. **Do not** end with “what would you like?” — wait for the visitor. + +--- + +## 4b. Agent setup flow (future vs Stage 1) + +**Future (ideal):** + +1. Place speaker + “say hello” note +2. Phone app → upload listing packet document +3. Connect / pair speaker +4. Select uploaded doc as active listing packet +5. Start assistant → leave property +6. Visitor: hello → tour + +**Stage 1:** fixtures or hand-authored `.md` + `active_listing_id` pref. No phone upload UI in this ability PR. + +--- + +## 5. Mock fixtures + +| File | Role | +| --- | --- | +| [`../fixtures/listings/1420-maple-richmond.md`](../fixtures/listings/1420-maple-richmond.md) | Full SFH happy path | +| [`../fixtures/listings/88-canal-loft-richmond.md`](../fixtures/listings/88-canal-loft-richmond.md) | Condo + HOA | +| [`../fixtures/listings/7-pine-sparse-chesterfield.md`](../fixtures/listings/7-pine-sparse-chesterfield.md) | Sparse packet / logging + contact stress test | + +All addresses and agent contacts are **fictitious**. + +--- + +## 6. `tour_questions.md` + delivery + +**Always:** append unanswered property questions (and optional “please send me X links” follow-ups). + +```markdown +## Showing — 1420 Maple Avenue — 2026-08-10 14:32 +- Fence: is it shared with the neighbor? +- Visitor asked agent to send public-safety open-data links +``` + +**Email (optional):** `CapabilityWorker.send_email()` with `tour_questions.md` attached to `agent.email` when SMTP prefs exist. End of tour or on “email my questions.” + +**On-the-spot (optional):** + +| Ask | Action | +| --- | --- | +| Agent’s number? | Speak name + phone (TTS-friendly) | +| Text the agent | Twilio SMS after confirmation | +| Call the agent | Twilio outbound TTS summary (not a live bridge) | +| Email now | Immediate `send_email()` | + +If Twilio/SMTP missing: speak contact + keep the file. Never pretend a send succeeded. + +--- + +## 7. Voice-flow map (for `main.py` later) + +1. Load prefs + `active_listing_id` → listing `.md` packet + `fair_housing.md` +2. Classify: start tour | room nav | property Q | fair-housing topic | contact | exit +3. Property Q → answer from packet or append `tour_questions.md` +4. Fair-housing topic → redirect/refuse snippets from KB +5. Contact → confirm → Twilio / email / speak fallback +6. Exit → summarize N saved questions → optional auto-email → `resume_normal_flow()` +7. Schema/product gaps → `knowledge_gaps.json` + +Reuse Town Hall patterns: `MatchingCapability`, short spoken turns, persistent files, gap logging. + +--- + +## 8. Non-goals (Stage 1) + +- Phone app: upload packet, pair speaker, select active listing, arm assistant +- Buyer multi-home search / compare / criteria prefs +- Seller CMA or “should I buy / is it overpriced” advice +- Spoken crime stats or school ratings +- Live two-way call bridge (visitor ↔ agent) +- Gmail/Outlook inbox as a dependency +- Lead capture / CRM / showing scheduling +- StayGuide guest ops; BizSpace commercial zoning +- `main.py` until this note + fixtures feel right + +--- + +## 9. Next implementation slice + +1. Confirm this note + fixtures + `fair_housing.md` +2. Scaffold `main.py` + `.openhome.json` with hello → tour → Q&A on Maple fixture only +3. Add question logging, then email, then Twilio From 7c0018b26ff39ce9edb65cf7761ce9811c866ee5 Mon Sep 17 00:00:00 2001 From: Tati Date: Fri, 21 Aug 2026 16:58:51 -0400 Subject: [PATCH 2/7] feat(property-pro): add voice showing tour guide ability Implement hello-triggered room tours from markdown listing packets, fair-housing redirects, question logging, and a lobby that keeps visitors in PropertyPro until they say hello again. Co-authored-by: Cursor --- community/property-pro/.gitignore | 3 + community/property-pro/.openhome.json | 15 + community/property-pro/README.md | 28 +- community/property-pro/__init__.py | 1 + .../fixtures/listings/1420-maple-richmond.md | 14 +- .../listings/7-pine-sparse-chesterfield.md | 10 +- .../listings/88-canal-loft-richmond.md | 10 +- community/property-pro/main.py | 820 ++++++++++++++++++ .../property-pro/notes/PRODUCT_DISCOVERY.md | 30 +- 9 files changed, 889 insertions(+), 42 deletions(-) create mode 100644 community/property-pro/.gitignore create mode 100644 community/property-pro/.openhome.json create mode 100644 community/property-pro/__init__.py create mode 100644 community/property-pro/main.py diff --git a/community/property-pro/.gitignore b/community/property-pro/.gitignore new file mode 100644 index 00000000..4d95d4c0 --- /dev/null +++ b/community/property-pro/.gitignore @@ -0,0 +1,3 @@ +tour_questions.md +propertypro_prefs.json +knowledge_gaps.json diff --git a/community/property-pro/.openhome.json b/community/property-pro/.openhome.json new file mode 100644 index 00000000..899cf5e2 --- /dev/null +++ b/community/property-pro/.openhome.json @@ -0,0 +1,15 @@ +{ + "name": "propertypro", + "capability_id": "8344", + "category": "skill", + "description": "Voice showing tour guide for residential listings", + "trigger_words": [ + "hello", + "start tour", + "begin tour", + "property pro" + ], + "release_id": 19081, + "version": "v2", + "is_committed": false +} \ No newline at end of file diff --git a/community/property-pro/README.md b/community/property-pro/README.md index 55c568a8..510beaef 100644 --- a/community/property-pro/README.md +++ b/community/property-pro/README.md @@ -62,16 +62,16 @@ A `fair_housing.md` knowledge base ships with the ability so the model stays on ## Planned Features (Stage 1) -- [ ] Hello → greet → room tour → Q&A → exit voice flow -- [ ] Listing packet grounded answers -- [ ] Fair-housing KB (redirects + hard refusals) -- [ ] Unanswered questions → `tour_questions.md` +- [x] Hello → greet → room tour → Q&A → exit voice flow +- [x] Listing packet grounded answers (markdown fixtures) +- [x] Fair-housing KB (redirects + hard refusals) +- [x] Unanswered questions → `tour_questions.md` - [ ] Optional email of that file via `CapabilityWorker.send_email()` - [ ] Optional on-the-spot Twilio SMS / TTS outbound call (with confirmation) -- [ ] Three mock listing fixtures for testing (see below) -- [ ] `knowledge_gaps.json` for product/schema gaps +- [x] Three mock listing fixtures for testing +- [x] `knowledge_gaps.json` for product/schema gaps -**Out of scope for Stage 1:** buyer search portal, seller CMA / pricing advice, live crime APIs spoken aloud, two-way call bridge, CRM / lead capture. +**Out of scope for Stage 1:** buyer search portal, seller CMA / pricing advice, live crime APIs spoken aloud, two-way call bridge, CRM / lead capture, phone-app upload. --- @@ -97,7 +97,7 @@ Add PropertyPro in the OpenHome Dashboard and set the trigger phrases above. ### 2. Load a listing packet -Point the ability at the active listing (pref `active_listing_id`). Stage 1 packets are **markdown** files under `fixtures/listings/` (or a user-uploaded `.md` packet). MLS sync comes later. +Default active listing is `1420-maple-richmond`. To switch fixtures, set `active_listing_id` in ability prefs file `propertypro_prefs.json` (created on first run), e.g. `88-canal-loft-richmond` or `7-pine-sparse-chesterfield`. ### 3. Optional delivery @@ -118,7 +118,7 @@ Without those, PropertyPro still speaks the agent’s number and always writes ` **Visitor:** “Okay.” -**PropertyPro:** “You’re in the foyer — room dimensions 11 by 8. Original craftsman woodwork, opens into the living room.” +**PropertyPro:** “You’re in the foyer — room dimensions 11 by 8. A classic craftsman entry with original oak trim, a built-in bench niche, and soft light from the sidelight windows.” **Visitor:** “What’s the square footage?” @@ -143,12 +143,12 @@ Without those, PropertyPro still speaks the agent’s number and always writes ` ``` community/property-pro/ ├── README.md +├── .openhome.json +├── main.py ← MatchingCapability tour guide ├── fair_housing.md ├── fixtures/LISTINGS.md -├── fixtures/listings/*.md ← listing packets (markdown) -├── notes/PRODUCT_DISCOVERY.md -├── main.py ← MatchingCapability (todo) -└── .openhome.json ← ability manifest (todo) +├── fixtures/listings/*.md +└── notes/PRODUCT_DISCOVERY.md ``` --- @@ -163,4 +163,4 @@ community/property-pro/ ## Status -Discovery scaffold in progress: README, `fair_housing.md`, markdown listing fixtures, and `notes/PRODUCT_DISCOVERY.md` are in place. Next: `main.py` for hello → tour → Q&A on the Maple fixture. +Stage 1 core loop is implemented in `main.py`: hello → room beats with dimensions → visitor-driven Q&A → fair-housing redirects → `tour_questions.md` logging. After a tour ends, the device stays in PropertyPro and waits for hello again (does not hand visitors to the regular agent). Default listing: `1420-maple-richmond`. Email/Twilio send paths still speak contact info as a fallback. diff --git a/community/property-pro/__init__.py b/community/property-pro/__init__.py new file mode 100644 index 00000000..8aac6e11 --- /dev/null +++ b/community/property-pro/__init__.py @@ -0,0 +1 @@ +# empty package marker for openhome ability loading diff --git a/community/property-pro/fixtures/listings/1420-maple-richmond.md b/community/property-pro/fixtures/listings/1420-maple-richmond.md index 0b0c1690..35f7579e 100644 --- a/community/property-pro/fixtures/listings/1420-maple-richmond.md +++ b/community/property-pro/fixtures/listings/1420-maple-richmond.md @@ -34,31 +34,31 @@ Thanks for touring — make yourselves at home. ### foyer - **dimensions:** 11 by 8 -- **note:** Original craftsman woodwork; opens into the living room. +- **note:** A classic craftsman entry with original oak trim, a built-in bench niche, and soft light from the sidelight windows. The hardwood continues straight into the living room ahead. ### living - **dimensions:** 16 by 14 -- **note:** Working fireplace. +- **note:** A bright gathering room with a working brick fireplace, original crown molding, and a wide bay window that pulls in afternoon light. Plenty of wall space for art or a media setup without feeling crowded. ### kitchen - **dimensions:** 12 by 14 -- **note:** Updated in 2021; quartz counters; gas range; opens toward the deck. +- **note:** Remodeled in 2021 with quartz counters, shaker cabinets, a gas range, and a deep farmhouse sink. It opens through French doors to the deck, so cooking and outdoor seating feel connected. ### primary - **dimensions:** 14 by 12 -- **note:** Main-floor primary bedroom with en-suite bath. +- **note:** A quiet main-floor retreat with a walk-in closet and an en-suite bath featuring a walk-in shower and dual vanity. Morning light comes through two east-facing windows. ### upstairs - **dimensions:** 11 by 10 -- **note:** Two bedrooms and one full bath; each bedroom about 11 by 10. +- **note:** Two flexible bedrooms under the craftsman eaves, each about 11 by 10, sharing a full hall bath with a tub-shower. Ideal for guests, kids, or a home office with dormer light. ### basement - **dimensions:** 28 by 18 -- **note:** Unfinished storage; laundry hookups. +- **note:** A clean unfinished level with laundry hookups, solid ceiling height for storage or a future finish, and easy access to the mechanicals along the stair wall. ### backyard - **dimensions:** (none) -- **note:** Fenced yard with wood deck off the kitchen. +- **note:** A fully fenced yard with a wood deck right off the kitchen, mature shade trees, and room for a garden bed or outdoor dining. The lot is about two-tenths of an acre, so it feels private without being high-maintenance. ## Systems diff --git a/community/property-pro/fixtures/listings/7-pine-sparse-chesterfield.md b/community/property-pro/fixtures/listings/7-pine-sparse-chesterfield.md index f2fdeb71..40aac9a2 100644 --- a/community/property-pro/fixtures/listings/7-pine-sparse-chesterfield.md +++ b/community/property-pro/fixtures/listings/7-pine-sparse-chesterfield.md @@ -32,23 +32,23 @@ ### living - **dimensions:** 15 by 12 -- **note:** Front living room. +- **note:** A comfortable front living room with a picture window to the street and an open pass-through to the kitchen. Simple, bright, and ready for everyday furniture. ### kitchen - **dimensions:** 11 by 10 -- **note:** Eat-in kitchen. +- **note:** An eat-in kitchen with room for a small table, solid counter workspace, and a window over the sink. Practical one-level living without wasted space. ### bedrooms - **dimensions:** (unknown) -- **note:** Three bedrooms on the main level. +- **note:** Three bedrooms on the main level — a primary toward the back of the house and two secondary rooms that work for kids, guests, or an office. Exact sizes aren't in my notes yet. ### baths - **dimensions:** (unknown) -- **note:** Two full baths. +- **note:** Two full baths on the main floor, so the morning routine doesn't bottleneck. Fixture details aren't listed in this packet. ### garage - **dimensions:** 20 by 12 -- **note:** One-car garage. +- **note:** An attached one-car garage with direct entry into the home, plus wall space for shelves or bikes. Handy for rainy-day unloading. ## Systems diff --git a/community/property-pro/fixtures/listings/88-canal-loft-richmond.md b/community/property-pro/fixtures/listings/88-canal-loft-richmond.md index 8d6ee7e3..1bee50d9 100644 --- a/community/property-pro/fixtures/listings/88-canal-loft-richmond.md +++ b/community/property-pro/fixtures/listings/88-canal-loft-richmond.md @@ -33,23 +33,23 @@ Welcome to 4B — enjoy the canal view. ### entry - **dimensions:** 6 by 5 -- **note:** Secure building entry; unit door opens to open loft plan. +- **note:** You enter through the building's secure lobby, then into a compact foyer with coat hooks and a clean sightline into the open loft. The unit feels private the moment the door closes. ### living_kitchen - **dimensions:** 22 by 18 -- **note:** Open loft living with kitchen island; tall windows facing the canal. +- **note:** A wide open loft with polished concrete floors, a kitchen island for seating, and floor-to-ceiling windows aimed at the canal. Daylight carries across the whole space, so living, dining, and cooking share one bright volume. ### bedroom - **dimensions:** 12 by 11 -- **note:** One bedroom with closet; partial privacy wall from living area. +- **note:** A quiet sleeping alcove with a full closet and a partial privacy wall from the living area. Still close enough to feel connected to the loft, with room for a queen bed and a slim dresser. ### bath - **dimensions:** 8 by 5 -- **note:** Full bath with tub/shower combo. +- **note:** A full bath with a tub-shower combo, modern tile, and a vanity with storage. Compact, but finished to match the rest of the unit. ### balcony - **dimensions:** 10 by 4 -- **note:** Private balcony with canal view. +- **note:** Your own outdoor slice overlooking the canal — wide enough for two chairs and a small table, with evening light on the water. A natural spot for coffee or a quiet end to the day. ## Systems diff --git a/community/property-pro/main.py b/community/property-pro/main.py new file mode 100644 index 00000000..6d6ce8f3 --- /dev/null +++ b/community/property-pro/main.py @@ -0,0 +1,820 @@ +import json +import re +from datetime import datetime + +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + +# propertypro — stage 1 showing tour guide +# visitor says hello → room tour + q&a from a markdown listing packet + +PREFS_FILE = "propertypro_prefs.json" +QUESTIONS_FILE = "tour_questions.md" +GAPS_FILE = "knowledge_gaps.json" +FAIR_HOUSING_FILE = "fair_housing.md" +DEFAULT_LISTING_ID = "1420-maple-richmond" +LISTINGS_DIR = "fixtures/listings" + +EXIT_PATTERN = re.compile( + r"\b(stop|exit|quit|done|cancel|bye|goodbye|never\s*mind|" + r"that'?s\s*all|we'?re\s*done|wrap\s*up|end\s*tour)\b", + re.IGNORECASE, +) + +HOTWORDS = ( + "hello", + "hi", + "start tour", + "begin tour", + "property pro", + "propertypro", + "showing tour", +) + +CLASSIFY_PROMPT = """Classify this showing-tour visitor utterance. +Return ONLY one label from this list: +room_nav, property_fact, crime_safety, school_quality, demographics, contact, advice, exit, other + +Rules: +- room name, next room, go back, previous → room_nav +- beds, baths, price, sq ft, roof, hvac, appliances, hoa, inclusions, dimensions, flood → property_fact +- safe neighborhood, crime, crime rate → crime_safety +- are the schools good, school quality, ratings → school_quality +- who lives here, diverse, demographics, people like us, race, religion → demographics +- agent phone, text the agent, call the agent, email questions, contact agent → contact +- should i buy, is it overpriced, offer, negotiation, financing → advice +- stop, done, goodbye, end tour → exit +- anything else → other + +Utterance: {text} +""" + +ANSWER_PROMPT = """You are PropertyPro, a voice showing-tour guide. Answer ONLY from the listing packet. +Rules: +- one or two short spoken sentences. no bullet lists. +- if the listing packet does not contain the answer, reply exactly: UNKNOWN +- never invent facts, sizes, system ages, or neighborhood opinions. +- never discuss crime stats, school quality ratings, or demographics. +- do not ask the visitor what they want next. + +Fair housing context: +{fair_housing} + +Listing packet: +{listing} + +Visitor asked: {question} +""" + + +def _bullet_value(text: str, key: str) -> str | None: + """pull '- **key:** value' from markdown.""" + pattern = rf"^-\s+\*\*{re.escape(key)}:\*\*\s*(.+)\s*$" + for line in text.splitlines(): + m = re.match(pattern, line.strip(), re.IGNORECASE) + if m: + val = m.group(1).strip().strip("`") + if val.lower() in ("(none)", "(unknown)", "(not provided)", "none", ""): + return None + return val + return None + + +def _section_body(text: str, heading: str) -> str: + """return markdown under ## {heading} until the next ##.""" + lines = text.splitlines() + start = None + want = heading.strip().lower() + for i, line in enumerate(lines): + if line.strip().lower() == f"## {want}": + start = i + 1 + break + if start is None: + return "" + end = len(lines) + for j in range(start, len(lines)): + if lines[j].startswith("## "): + end = j + break + return "\n".join(lines[start:end]).strip() + + +def _parse_tour_order(text: str) -> list[str]: + body = _section_body(text, "Tour order") + order = [] + for line in body.splitlines(): + m = re.match(r"^\d+\.\s+(\S+)\s*$", line.strip()) + if m: + order.append(m.group(1).strip().lower()) + return order + + +def _parse_rooms(text: str) -> dict[str, dict]: + body = _section_body(text, "Rooms") + rooms: dict[str, dict] = {} + current = None + for line in body.splitlines(): + heading = re.match(r"^###\s+(\S+)\s*$", line.strip()) + if heading: + current = heading.group(1).strip().lower() + rooms[current] = {"id": current, "dimensions": None, "note": None} + continue + if current is None: + continue + dim = re.match(r"^-\s+\*\*dimensions:\*\*\s*(.+)\s*$", line.strip(), re.I) + if dim: + raw = dim.group(1).strip() + if raw.lower() not in ("(none)", "(unknown)", "none", ""): + rooms[current]["dimensions"] = raw + continue + note = re.match(r"^-\s+\*\*note:\*\*\s*(.+)\s*$", line.strip(), re.I) + if note: + rooms[current]["note"] = note.group(1).strip() + return rooms + + +def _parse_systems(text: str) -> dict[str, str]: + body = _section_body(text, "Systems") + if not body or "not provided" in body.lower(): + return {} + systems = {} + for line in body.splitlines(): + m = re.match(r"^-\s+\*\*([^:]+):\*\*\s*(.+)\s*$", line.strip()) + if not m: + continue + key = m.group(1).strip().lower() + val = m.group(2).strip() + if val.lower() in ("(unknown)", "(none)", "none", ""): + continue + systems[key] = val + return systems + + +def _parse_list_section(text: str, heading: str) -> list[str] | None: + body = _section_body(text, heading) + if not body: + return None + if "not provided" in body.lower(): + return None + items = [] + for line in body.splitlines(): + m = re.match(r"^-\s+(.+)\s*$", line.strip()) + if m: + items.append(m.group(1).strip()) + return items + + +def _parse_agent(text: str) -> dict: + body = _section_body(text, "Agent") + return { + "name": _bullet_value(body, "name"), + "brokerage": _bullet_value(body, "brokerage"), + "phone": _bullet_value(body, "phone"), + "email": _bullet_value(body, "email"), + } + + +def _parse_schools(text: str) -> dict | None: + body = _section_body(text, "School assignment") + if not body or "not provided" in body.lower(): + return None + return { + "elementary": _bullet_value(body, "elementary"), + "middle": _bullet_value(body, "middle"), + "high": _bullet_value(body, "high"), + "verify": _bullet_value(body, "verify"), + } + + +def _parse_redirects(text: str) -> dict: + body = _section_body(text, "Redirect URLs") + return { + "crime": _bullet_value(body, "crime open data"), + "schools": _bullet_value(body, "school district"), + "flood": _bullet_value(body, "flood map"), + } + + +def parse_listing(text: str) -> dict: + """structured fields from a listing markdown packet.""" + title = "this home" + for line in text.splitlines(): + if line.startswith("# "): + title = line[2:].strip() + break + seller = _section_body(text, "Seller welcome") + if seller.lower() in ("(none)", ""): + seller = None + return { + "raw": text, + "title": title, + "id": _bullet_value(text, "id") or DEFAULT_LISTING_ID, + "address": _bullet_value(text, "address"), + "price": _bullet_value(text, "price"), + "beds_baths": _bullet_value(text, "beds / baths"), + "sq_ft": _bullet_value(text, "sq ft"), + "lot": _bullet_value(text, "lot"), + "year_built": _bullet_value(text, "year built"), + "hoa": _bullet_value(text, "hoa"), + "hoa_docs": _bullet_value(text, "hoa docs"), + "agent": _parse_agent(text), + "seller_welcome": seller, + "tour_order": _parse_tour_order(text), + "rooms": _parse_rooms(text), + "systems": _parse_systems(text), + "inclusions": _parse_list_section(text, "Inclusions"), + "exclusions": _parse_list_section(text, "Exclusions"), + "schools": _parse_schools(text), + "redirects": _parse_redirects(text), + } + + +def phone_for_speech(phone: str | None) -> str: + if not phone: + return "the number on the listing sheet" + digits = re.sub(r"\D", "", phone) + if digits.startswith("1") and len(digits) == 11: + digits = digits[1:] + if len(digits) == 10: + a, b, c = digits[:3], digits[3:6], digits[6:] + return f"{' '.join(a)}, {' '.join(b)}, {' '.join(c)}" + return " ".join(digits) + + +class PropertyProCapability(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + listing: dict = None + fair_housing: str = "" + room_index: int = 0 + session_questions: list = None + showing_started: bool = False + + # {{register capability}} + + def does_match(self, text: str) -> bool: + t = (text or "").lower().strip() + return any(hw in t for hw in HOTWORDS) + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.session_questions = [] + self.room_index = 0 + self.showing_started = False + self.worker.session_tasks.create(self.run()) + + async def run(self): + # showing-device mode: stay in propertypro. after a tour ends, wait for + # hello again — do not hand visitors to the regular agent. + try: + await self._load_context() + if not self.listing: + await self.capability_worker.speak( + "I don't have an active listing packet loaded. " + "Please set active listing i d and try again." + ) + return + + while True: + self.session_questions = [] + self.room_index = 0 + await self._greet_and_start() + await self._tour_loop() + await self._wait_for_hello() + except Exception as e: + self.worker.editor_logging_handler.error(f"propertypro error: {e}") + try: + await self.capability_worker.speak( + "Something went wrong on the tour. Please try saying hello again." + ) + except Exception: + pass + finally: + self.capability_worker.resume_normal_flow() + + def _is_exit(self, text: str) -> bool: + return bool(EXIT_PATTERN.search(text or "")) + + def _is_restart(self, text: str) -> bool: + t = (text or "").lower().strip() + return any(hw in t for hw in HOTWORDS) + + async def _tour_loop(self): + """run one showing until idle, exit words, or classified exit.""" + idle = 0 + while True: + user_input = await self.capability_worker.user_response() + if not user_input or not user_input.strip(): + idle += 1 + if idle >= 2: + await self._close_tour() + return + continue + idle = 0 + if self._is_exit(user_input): + await self._close_tour() + return + # saying hello mid-tour restarts from the first room + if self._is_restart(user_input): + self.session_questions = [] + self.room_index = 0 + await self._greet_and_start() + continue + should_end = await self._handle_turn(user_input) + if should_end: + return + + async def _wait_for_hello(self): + """lobby after a closed tour — keep listening until hello.""" + while True: + user_input = await self.capability_worker.user_response() + if not user_input or not user_input.strip(): + # stay quiet; do not drop to the regular agent + continue + if self._is_restart(user_input): + return + await self.capability_worker.speak( + "The tour is closed. Say hello to start again." + ) + + async def _load_context(self): + prefs = await self._load_prefs() + listing_id = prefs.get("active_listing_id") or DEFAULT_LISTING_ID + path = f"{LISTINGS_DIR}/{listing_id}.md" + listing_text = await self._read_ability_file(path) + if not listing_text: + # fall back to default fixture + path = f"{LISTINGS_DIR}/{DEFAULT_LISTING_ID}.md" + listing_text = await self._read_ability_file(path) + if listing_text: + self.listing = parse_listing(listing_text) + self.fair_housing = await self._read_ability_file(FAIR_HOUSING_FILE) or "" + self.worker.editor_logging_handler.info( + f"propertypro loaded listing={self.listing.get('id') if self.listing else None}" + ) + + async def _load_prefs(self) -> dict: + try: + exists = await self.capability_worker.check_if_file_exists( + PREFS_FILE, in_ability_directory=True + ) + if not exists: + prefs = {"active_listing_id": DEFAULT_LISTING_ID} + await self.capability_worker.write_file( + PREFS_FILE, + json.dumps(prefs, indent=2), + in_ability_directory=True, + mode="w", + ) + return prefs + raw = await self.capability_worker.read_file( + PREFS_FILE, in_ability_directory=True + ) + return json.loads(raw) if raw else {"active_listing_id": DEFAULT_LISTING_ID} + except Exception as e: + self.worker.editor_logging_handler.error(f"propertypro prefs: {e}") + return {"active_listing_id": DEFAULT_LISTING_ID} + + async def _read_ability_file(self, name: str) -> str | None: + try: + exists = await self.capability_worker.check_if_file_exists( + name, in_ability_directory=True + ) + if not exists: + return None + return await self.capability_worker.read_file( + name, in_ability_directory=True + ) + except Exception as e: + self.worker.editor_logging_handler.error(f"propertypro read {name}: {e}") + return None + + async def _greet_and_start(self): + title = self.listing.get("title") or "this home" + first_id = self._room_id_at(0) + first_label = first_id.replace("_", " ") if first_id else "the entry" + await self.capability_worker.speak( + f"Hi — welcome to {title}. I'll walk you through the main spaces, " + "and you can ask me questions anytime. I'm here for facts about this " + "property — for neighborhood topics like crime or demographics, I'll " + "point you to official sources rather than give opinions. " + f"We'll begin in the {first_label}." + ) + self.showing_started = True + self.room_index = 0 + await self._speak_room(self.room_index) + + def _room_id_at(self, index: int) -> str | None: + order = self.listing.get("tour_order") or [] + if 0 <= index < len(order): + return order[index] + return None + + async def _speak_room(self, index: int): + room_id = self._room_id_at(index) + if not room_id: + await self.capability_worker.speak( + "That's the end of the listed rooms for this home." + ) + return + room = (self.listing.get("rooms") or {}).get(room_id) or {} + label = room_id.replace("_", " ") + parts = [f"You're in the {label}."] + dims = room.get("dimensions") + if dims: + # packet already uses "11 by 8" style + spoken_dims = dims.replace("x", " by ").replace("×", " by ") + parts.append(f"Room dimensions {spoken_dims}.") + note = room.get("note") + if note: + parts.append(note) + await self.capability_worker.speak(" ".join(parts)) + + async def _handle_turn(self, text: str) -> bool: + """handle one visitor turn. return True when this tour session should end.""" + label = self._classify(text) + if label == "exit": + await self._close_tour() + return True + + if label == "room_nav": + await self._handle_room_nav(text) + return False + if label == "crime_safety": + await self.capability_worker.speak( + "I don't give crime opinions or stats. You can check the public " + "safety open data and local police resources linked in my notes." + ) + return False + if label == "school_quality": + await self._handle_schools() + return False + if label == "demographics": + await self.capability_worker.speak( + "I'm not able to discuss neighborhood demographics under fair " + "housing guidelines. I can stick to facts about this property " + "if you have another question." + ) + return False + if label == "contact": + await self._handle_contact(text) + return False + if label == "advice": + await self.capability_worker.speak( + "I can't advise on offers or pricing. That's best handled with " + "your agent or lender." + ) + await self._log_question("Visitor asked for pricing or offer advice") + return False + # property_fact or other — try deterministic then grounded llm + handled = await self._handle_property_fact(text) + if not handled: + await self._grounded_answer(text) + return False + + def _classify(self, text: str) -> str: + t = (text or "").lower() + # fast paths before llm + if self._is_exit(t): + return "exit" + if any(k in t for k in ("crime", "safe neighborhood", "safe area", "safety")): + return "crime_safety" + if "school" in t and any(k in t for k in ("good", "great", "bad", "rating", "quality")): + return "school_quality" + if any( + k in t + for k in ( + "who lives", + "demographic", + "diverse", + "diversity", + "people like us", + "racial", + "religion", + ) + ): + return "demographics" + if any( + k in t + for k in ( + "text the agent", + "call the agent", + "email my questions", + "email the agent", + "agent's number", + "agent number", + "phone number", + "contact the agent", + ) + ): + return "contact" + if any( + k in t + for k in ( + "should i buy", + "overpriced", + "make an offer", + "negotiate", + "contingency", + ) + ): + return "advice" + if self._match_room_nav(t) is not None: + return "room_nav" + try: + raw = self.capability_worker.text_to_text_response( + CLASSIFY_PROMPT.format(text=text) + ) + label = (raw or "").strip().lower().split()[0].strip(".,") + allowed = { + "room_nav", + "property_fact", + "crime_safety", + "school_quality", + "demographics", + "contact", + "advice", + "exit", + "other", + } + if label in allowed: + return label + except Exception as e: + self.worker.editor_logging_handler.error(f"propertypro classify: {e}") + return "property_fact" + + def _match_room_nav(self, t: str) -> int | None: + """return new room index or None if not navigation.""" + order = self.listing.get("tour_order") or [] + if "next" in t or "continue" in t: + return min(self.room_index + 1, max(len(order) - 1, 0)) + if "back" in t or "previous" in t: + return max(self.room_index - 1, 0) + for i, room_id in enumerate(order): + label = room_id.replace("_", " ") + if room_id in t or label in t: + return i + # common aliases + if room_id == "living" and "living room" in t: + return i + if room_id == "primary" and ("primary" in t or "master" in t): + return i + if room_id == "living_kitchen" and ("living" in t or "kitchen" in t): + return i + return None + + async def _handle_room_nav(self, text: str): + idx = self._match_room_nav((text or "").lower()) + if idx is None: + await self.capability_worker.speak( + "Name a room from the tour, or say next room." + ) + return + self.room_index = idx + await self._speak_room(self.room_index) + + async def _handle_schools(self): + schools = self.listing.get("schools") + if not schools: + await self.capability_worker.speak( + "I don't rate schools. I also don't have a school assignment " + "in my notes for this listing. I've added that to the agent's " + "question list." + ) + await self._log_question("School assignment for this address?") + return + parts = [ + s + for s in ( + schools.get("elementary"), + schools.get("middle"), + schools.get("high"), + ) + if s + ] + assignment = ", ".join(parts) if parts else "the district assignment on file" + await self.capability_worker.speak( + f"I don't rate schools. This address is listed under {assignment} — " + "please verify on the district site. Public report cards are the " + "place to judge fit." + ) + + async def _handle_contact(self, text: str): + agent = self.listing.get("agent") or {} + name = agent.get("name") or "the listing agent" + phone = agent.get("phone") + t = (text or "").lower() + if any(k in t for k in ("text", "call", "email")): + # stage 1: no twilio/smtp wiring yet — speak contact + keep file + await self.capability_worker.speak( + f"I can't send messages from this device yet. " + f"{name}'s number is {phone_for_speech(phone)}. " + "Your questions are saved on the agent's list." + ) + return + await self.capability_worker.speak( + f"The listing agent is {name}. " + f"You can reach them at {phone_for_speech(phone)}." + ) + + async def _handle_property_fact(self, text: str) -> bool: + """deterministic answers for common facts. return True if handled.""" + t = (text or "").lower() + L = self.listing + + if "square" in t or "sq ft" in t or "sqft" in t or "how big" in t: + if L.get("sq_ft"): + await self.capability_worker.speak(f"About {L['sq_ft']}.") + return True + await self._unknown("Whole-home square footage?") + return True + + if "price" in t or "asking" in t or "list price" in t or "cost" in t: + if L.get("price"): + await self.capability_worker.speak(f"The list price is {L['price']}.") + return True + await self._unknown("List price?") + return True + + if "bed" in t or "bath" in t: + if L.get("beds_baths"): + await self.capability_worker.speak( + f"This home is listed as {L['beds_baths']} beds and baths." + ) + return True + await self._unknown("Beds and baths?") + return True + + if "year" in t and "built" in t: + if L.get("year_built"): + await self.capability_worker.speak( + f"It was built in {L['year_built']}." + ) + return True + await self._unknown("Year built?") + return True + + if "hoa" in t: + if L.get("hoa"): + msg = f"H O A note: {L['hoa']}." + if L.get("hoa_docs"): + msg += f" {L['hoa_docs']}." + await self.capability_worker.speak(msg) + return True + await self._unknown("H O A fees or docs?") + return True + + if "lot" in t: + if L.get("lot"): + await self.capability_worker.speak(f"Lot size: {L['lot']}.") + return True + await self._unknown("Lot size?") + return True + + systems = L.get("systems") or {} + for key, phrases in ( + ("roof", ("roof",)), + ("hvac", ("hvac", "heating", "cooling", "air condition")), + ("water heater", ("water heater", "hot water")), + ("breaker box", ("breaker", "electrical panel")), + ("water shutoff", ("water shutoff", "shut off", "shut-off")), + ("in-unit laundry", ("laundry",)), + ): + if any(p in t for p in phrases): + if systems.get(key): + await self.capability_worker.speak( + f"{key}: {systems[key]}." + ) + return True + await self._unknown(f"{key} details?") + return True + + if any(k in t for k in ("appliance", "convey", "stay with", "included")): + inclusions = L.get("inclusions") + exclusions = L.get("exclusions") + if inclusions is None and exclusions is None: + await self._unknown("What appliances or items convey?") + return True + parts = [] + if inclusions: + parts.append("Included: " + ", ".join(inclusions)) + if exclusions: + parts.append("Not included: " + ", ".join(exclusions)) + await self.capability_worker.speak(". ".join(parts) + ".") + return True + + if "school" in t and "assign" in t: + await self._handle_schools() + return True + + if "flood" in t: + url = (L.get("redirects") or {}).get("flood") + if url: + await self.capability_worker.speak( + "I don't determine flood zones. Check the official flood map " + "linked in my notes for this listing." + ) + else: + await self._unknown("Flood zone for this address?") + return True + + return False + + async def _grounded_answer(self, text: str): + try: + raw = self.capability_worker.text_to_text_response( + ANSWER_PROMPT.format( + fair_housing=(self.fair_housing or "")[:2500], + listing=(self.listing.get("raw") or "")[:6000], + question=text, + ) + ) + answer = (raw or "").strip() + if not answer or answer.upper().startswith("UNKNOWN"): + await self._unknown(text.strip()) + return + await self.capability_worker.speak(answer) + except Exception as e: + self.worker.editor_logging_handler.error(f"propertypro answer: {e}") + await self._unknown(text.strip()) + + async def _unknown(self, question: str): + await self.capability_worker.speak( + "I don't have that in my notes. I've added that to the agent's " + "question list." + ) + await self._log_question(question) + + async def _log_question(self, question: str): + q = (question or "").strip() + if not q: + return + self.session_questions.append(q) + address = self.listing.get("address") or self.listing.get("title") or "listing" + stamp = datetime.now().strftime("%Y-%m-%d %H:%M") + # append under a session heading; create heading if file empty/new session + try: + exists = await self.capability_worker.check_if_file_exists( + QUESTIONS_FILE, in_ability_directory=False + ) + header_needed = True + if exists: + raw = await self.capability_worker.read_file( + QUESTIONS_FILE, in_ability_directory=False + ) + if raw and f"## Showing — {address}" in raw and stamp[:10] in raw: + header_needed = False + chunk = "" + if header_needed: + chunk += f"\n## Showing — {address} — {stamp}\n" + chunk += f"- {q}\n" + await self.capability_worker.write_file( + QUESTIONS_FILE, chunk, in_ability_directory=False + ) + except Exception as e: + self.worker.editor_logging_handler.error(f"propertypro log q: {e}") + await self._log_gap(q, str(e)) + + async def _log_gap(self, query: str, reason: str): + try: + gaps = [] + exists = await self.capability_worker.check_if_file_exists( + GAPS_FILE, in_ability_directory=False + ) + if exists: + raw = await self.capability_worker.read_file( + GAPS_FILE, in_ability_directory=False + ) + if raw: + gaps = json.loads(raw) + gaps.append( + { + "query": query, + "reason": reason, + "at": datetime.now().isoformat(timespec="seconds"), + } + ) + await self.capability_worker.write_file( + GAPS_FILE, + json.dumps(gaps, indent=2), + in_ability_directory=False, + mode="w", + ) + except Exception as e: + self.worker.editor_logging_handler.error(f"propertypro gap: {e}") + + async def _close_tour(self): + n = len(self.session_questions) + if n: + await self.capability_worker.speak( + f"Closing the tour. I've saved {n} question" + f"{'s' if n != 1 else ''} for the listing agent. " + "Say hello to start again." + ) + else: + await self.capability_worker.speak( + "Closing the tour. Say hello to start again." + ) + self.showing_started = False diff --git a/community/property-pro/notes/PRODUCT_DISCOVERY.md b/community/property-pro/notes/PRODUCT_DISCOVERY.md index d5ae6c43..f9feec9a 100644 --- a/community/property-pro/notes/PRODUCT_DISCOVERY.md +++ b/community/property-pro/notes/PRODUCT_DISCOVERY.md @@ -38,11 +38,11 @@ hello → greet + fair-housing soft disclaimer **Visitor:** Okay. -**PropertyPro:** You’re in the foyer — room dimensions 11 by 8. Original craftsman woodwork, opens into the living room. +**PropertyPro:** You’re in the foyer — room dimensions 11 by 8. A classic craftsman entry with original oak trim, a built-in bench niche, and soft light from the sidelight windows. The hardwood continues straight into the living room ahead. **Visitor:** Living room. -**PropertyPro:** You’re in the living room — room dimensions 16 by 14, working fireplace. +**PropertyPro:** You’re in the living room — room dimensions 16 by 14. A bright gathering room with a working brick fireplace, original crown molding, and a wide bay window that pulls in afternoon light. **Visitor:** What’s the square footage of the whole house? @@ -50,7 +50,7 @@ hello → greet + fair-housing soft disclaimer **Visitor:** Kitchen. Do the appliances stay? -**PropertyPro:** You’re in the kitchen — room dimensions 12 by 14. Updated in 2021, quartz counters, gas range. Fridge, washer, and dryer convey. The seller’s freestanding bookshelf does not. +**PropertyPro:** You’re in the kitchen — room dimensions 12 by 14. Remodeled in 2021 with quartz counters, shaker cabinets, a gas range, and a deep farmhouse sink. It opens through French doors to the deck. Fridge, washer, and dryer convey. The seller’s freestanding bookshelf does not. **Visitor:** When was the roof done? @@ -82,9 +82,15 @@ On Pine (sparse): I don’t have the water heater age in my notes. I’ve added **PropertyPro:** Sent. -**Visitor:** We’re done. +**Visitor:** “We’re done.” -**PropertyPro:** That’s the main tour. I’ve saved two questions for the listing agent. Thanks for visiting — say hello anytime if you want to walk through again. +**PropertyPro:** Closing the tour. I’ve saved two questions for the listing agent. Say hello to start again. + +*(device stays in PropertyPro — if they ask something else before hello:)* + +**Visitor:** What’s the square footage? + +**PropertyPro:** The tour is closed. Say hello to start again. --- @@ -204,10 +210,11 @@ If Twilio/SMTP missing: speak contact + keep the file. Never pretend a send succ 3. Property Q → answer from packet or append `tour_questions.md` 4. Fair-housing topic → redirect/refuse snippets from KB 5. Contact → confirm → Twilio / email / speak fallback -6. Exit → summarize N saved questions → optional auto-email → `resume_normal_flow()` -7. Schema/product gaps → `knowledge_gaps.json` +6. Exit / idle → “Closing the tour. Say hello to start again.” → **wait lobby** (stay in PropertyPro; do not hand off to the regular agent) +7. Hello in lobby → restart greet + tour +8. Schema/product gaps → `knowledge_gaps.json` -Reuse Town Hall patterns: `MatchingCapability`, short spoken turns, persistent files, gap logging. +Reuse Town Hall patterns: `MatchingCapability`, short spoken turns, persistent files, gap logging. Showing-device default: keep the ability alive between tours. --- @@ -227,6 +234,7 @@ Reuse Town Hall patterns: `MatchingCapability`, short spoken turns, persistent f ## 9. Next implementation slice -1. Confirm this note + fixtures + `fair_housing.md` -2. Scaffold `main.py` + `.openhome.json` with hello → tour → Q&A on Maple fixture only -3. Add question logging, then email, then Twilio +1. ~~Confirm this note + fixtures + `fair_housing.md`~~ +2. ~~Scaffold `main.py` + `.openhome.json` with hello → tour → Q&A on Maple fixture~~ +3. Add email (`send_email`) and Twilio SMS/call when configured +4. Voice command to switch `active_listing_id` among fixtures From a02fc9c31af3caf2693eba052dd9c780316faf79 Mon Sep 17 00:00:00 2001 From: Tati Date: Fri, 21 Aug 2026 20:06:32 -0400 Subject: [PATCH 3/7] docs(property-pro): sync README and discovery notes with Stage 1 behavior Align triggers, lobby flow, contact fallbacks, and feature checklist with main.py; restore a clean community .openhome.json without local push IDs. Co-authored-by: Cursor --- community/property-pro/.openhome.json | 15 +- community/property-pro/README.md | 115 +++++++------ community/property-pro/fixtures/LISTINGS.md | 8 +- .../property-pro/notes/PRODUCT_DISCOVERY.md | 155 ++++++++---------- 4 files changed, 136 insertions(+), 157 deletions(-) diff --git a/community/property-pro/.openhome.json b/community/property-pro/.openhome.json index 899cf5e2..530848a2 100644 --- a/community/property-pro/.openhome.json +++ b/community/property-pro/.openhome.json @@ -1,15 +1,6 @@ { "name": "propertypro", - "capability_id": "8344", + "capability_id": null, "category": "skill", - "description": "Voice showing tour guide for residential listings", - "trigger_words": [ - "hello", - "start tour", - "begin tour", - "property pro" - ], - "release_id": 19081, - "version": "v2", - "is_committed": false -} \ No newline at end of file + "description": "Voice showing tour guide for residential listings. Walks visitors room-by-room from a markdown listing packet, answers property facts, follows fair-housing redirects, and logs unanswered questions for the listing agent." +} diff --git a/community/property-pro/README.md b/community/property-pro/README.md index 510beaef..1025ff0a 100644 --- a/community/property-pro/README.md +++ b/community/property-pro/README.md @@ -1,8 +1,8 @@ # PropertyPro — Voice Showing Tour Guide ![Community](https://img.shields.io/badge/OpenHome-Community-orange?style=flat-square) -![Author](https://img.shields.io/badge/Author-@ileana--pr-lightgrey?style=flat-square) -![Status](https://img.shields.io/badge/Status-Early-yellow?style=flat-square) +![Author](https://img.shields.io/badge/Author-@adigitaltati-green?style=flat-square) +![Status](https://img.shields.io/badge/Status-Stage%201-blue?style=flat-square) A voice-enabled residential showing companion for OpenHome. A visitor walks into a listed home, picks up the device on the table, and says **hello** to start a short room-by-room tour — with fair-housing-safe answers and unanswered questions saved for the listing agent. @@ -12,10 +12,11 @@ Part of **Suite B** (Specialized Property Guides), alongside StayGuide and BizSp ## Scene -**Visitor path (Stage 1 and beyond):** +**Visitor path:** 1. Buyer/renter walks in, reads the note, says **hello**, tours with the speaker. -2. PropertyPro answers from the active listing packet; logs gaps; optional contact/email. +2. PropertyPro answers from the active listing packet and logs unknowns to `tour_questions.md`. +3. When the tour ends (idle or “done”), the device stays in PropertyPro: *“Closing the tour. Say hello to start again.”* **Agent setup — target (future app):** @@ -24,52 +25,61 @@ Part of **Suite B** (Specialized Property Guides), alongside StayGuide and BizSp **Agent setup — Stage 1 stand-in:** -1. Load a markdown packet (`fixtures/listings/…` or storage) and set `active_listing_id`. -2. Same visitor path from hello onward. +1. Ability ships with markdown fixtures under `fixtures/listings/`. +2. Default active listing is `1420-maple-richmond` (`propertypro_prefs.json` → `active_listing_id`). --- ## Trigger Words +Dashboard triggers must be **at least 4 letters** (OpenHome platform rule). Recommended: + | Phrase | What it does | | --- | --- | -| `"hello"` / `"hi"` | Start (or resume) the showing tour | +| `"hello"` | Start the showing tour | | `"start tour"` / `"begin tour"` | Same as hello | -| `"next room"` / `"go back"` | Move through the room path | -| `"text the agent"` / `"call the agent"` | On-the-spot contact (Twilio when configured) | -| `"email my questions"` / `"send questions to the agent"` | Email `tour_questions.md` to the listing agent | -| `"what's the agent's number"` | Speak listing-agent contact from the packet | +| `"property pro"` | Same as hello | + +**In-tour / lobby phrases** (work after the ability is already running; not all are valid dashboard triggers): -Exact trigger list will be finalized in Dashboard config when `main.py` lands. +| Phrase | What it does | +| --- | --- | +| `"hi"` / `"hello"` / `"start tour"` | Restart the tour from the first room (or leave the closed-tour lobby) | +| room names (`"kitchen"`, `"living room"`, …) | Jump to that room | +| `"next"` / `"go back"` | Move along the tour order | +| `"what's the agent's number"` | Speak listing-agent name + phone | +| `"text the agent"` / `"call the agent"` / `"email …"` | Stage 1: explain send isn’t wired yet; speak the agent’s number; questions stay on the list | +| `"done"` / `"goodbye"` / `"end tour"` | Close this tour session → lobby | --- ## What It Answers (and what it won't) -**From the listing packet (safe):** beds/baths, sq ft, price, HOA fee, inclusions, systems updates, room notes, school *assignment* (name only, no ratings), agent contact. +**From the listing packet (safe):** beds/baths, sq ft, price, HOA fee, inclusions/exclusions, systems updates, room notes + dimensions, school *assignment* (name only, no ratings), agent contact. **Redirect (fair housing / customary practice):** - Crime / “is this neighborhood safe?” → point to official sources; do **not** recite crime stats or opinions. -- School *quality* → assignment if known + public evaluation links; no “good/bad” rankings. +- School *quality* → assignment if known + public evaluation guidance; no “good/bad” rankings. - Who lives here / protected-class suitability → hard refuse (no steering, no demographics). -**Missing facts:** *“I don’t have that in my notes — I’ll add it for the listing agent.”* → append `tour_questions.md`. +**Missing facts:** *“I don’t have that in my notes. I’ve added that to the agent’s question list.”* → append `tour_questions.md`. -A `fair_housing.md` knowledge base ships with the ability so the model stays on the right side of FHA / steering rules. This is product guardrails, not legal advice to consumers or brokers. +A `fair_housing.md` knowledge base ships with the ability. This is product guardrails, not legal advice to consumers or brokers. --- -## Planned Features (Stage 1) +## Stage 1 features -- [x] Hello → greet → room tour → Q&A → exit voice flow -- [x] Listing packet grounded answers (markdown fixtures) -- [x] Fair-housing KB (redirects + hard refusals) +- [x] Hello → greet → room tour → visitor-driven Q&A +- [x] Richer room notes + dimensions from markdown listing packets +- [x] Fair-housing redirects / hard refusals - [x] Unanswered questions → `tour_questions.md` -- [ ] Optional email of that file via `CapabilityWorker.send_email()` -- [ ] Optional on-the-spot Twilio SMS / TTS outbound call (with confirmation) -- [x] Three mock listing fixtures for testing -- [x] `knowledge_gaps.json` for product/schema gaps +- [x] Closed-tour **lobby** (stay in PropertyPro; say hello to start again) +- [x] Three mock listing fixtures +- [x] `knowledge_gaps.json` for product/schema failures +- [ ] Email `tour_questions.md` via `send_email()` +- [ ] Twilio SMS / outbound TTS call **Out of scope for Stage 1:** buyer search portal, seller CMA / pricing advice, live crime APIs spoken aloud, two-way call bridge, CRM / lead capture, phone-app upload. @@ -77,36 +87,31 @@ A `fair_housing.md` knowledge base ships with the ability so the model stays on ## Mock listings (testing) -Fictitious packets used while building and demoing: - | ID | Property | Why it exists | | --- | --- | --- | | `1420-maple-richmond` | Full craftsman SFH | Happy-path tour with systems + inclusions | -| `88-canal-loft-richmond` | Condo + HOA | Fees, inclusions, partial school data | +| `88-canal-loft-richmond` | Condo + HOA | Fees, inclusions, no school assignment | | `7-pine-sparse-chesterfield` | Thin ranch packet | Forces question logging + contact demos | -Listing packets are markdown under [`fixtures/listings/`](fixtures/listings/). Index: [`fixtures/LISTINGS.md`](fixtures/LISTINGS.md). Design detail: [`notes/PRODUCT_DISCOVERY.md`](notes/PRODUCT_DISCOVERY.md). +Packets live under [`fixtures/listings/`](fixtures/listings/). Index: [`fixtures/LISTINGS.md`](fixtures/LISTINGS.md). Design notes: [`notes/PRODUCT_DISCOVERY.md`](notes/PRODUCT_DISCOVERY.md). --- -## Setup (preview) +## Setup ### 1. Install the ability -Add PropertyPro in the OpenHome Dashboard and set the trigger phrases above. +Push or install PropertyPro and set dashboard triggers to at least: -### 2. Load a listing packet +`hello`, `start tour`, `begin tour`, `property pro` -Default active listing is `1420-maple-richmond`. To switch fixtures, set `active_listing_id` in ability prefs file `propertypro_prefs.json` (created on first run), e.g. `88-canal-loft-richmond` or `7-pine-sparse-chesterfield`. +### 2. Active listing -### 3. Optional delivery +Default: `1420-maple-richmond`. To switch fixtures, set `active_listing_id` in `propertypro_prefs.json` (created on first run), e.g. `88-canal-loft-richmond` or `7-pine-sparse-chesterfield`. -| Channel | Requirement | -| --- | --- | -| Email questions list | SMTP config + `agent_email` on the listing packet (`send_email()`) | -| Text / call agent | Twilio prefs (same pattern as community Twilio-SMS) + agent phone on packet | +### 3. Contact / delivery (Stage 1) -Without those, PropertyPro still speaks the agent’s number and always writes `tour_questions.md`. +Email and Twilio sends are **not** wired yet. Contact asks speak the listing agent’s phone (TTS-friendly) and keep `tour_questions.md` updated. --- @@ -114,11 +119,9 @@ Without those, PropertyPro still speaks the agent’s number and always writes ` **Visitor:** “Hello.” -**PropertyPro:** “Hi — welcome to Maple Avenue Craftsman. I’ll walk you through the main spaces, and you can ask questions anytime. I’m here for facts about this property — for neighborhood topics like crime or demographics, I’ll point you to official sources rather than give opinions. We’ll begin in the foyer.” +**PropertyPro:** “Hi — welcome to Maple Avenue Craftsman. I’ll walk you through the main spaces, and you can ask me questions anytime. I’m here for facts about this property — for neighborhood topics like crime or demographics, I’ll point you to official sources rather than give opinions. We’ll begin in the foyer.” -**Visitor:** “Okay.” - -**PropertyPro:** “You’re in the foyer — room dimensions 11 by 8. A classic craftsman entry with original oak trim, a built-in bench niche, and soft light from the sidelight windows.” +**PropertyPro:** “You’re in the foyer. Room dimensions 11 by 8. A classic craftsman entry with original oak trim, a built-in bench niche, and soft light from the sidelight windows. The hardwood continues straight into the living room ahead.” **Visitor:** “What’s the square footage?” @@ -126,41 +129,47 @@ Without those, PropertyPro still speaks the agent’s number and always writes ` **Visitor:** “Is this a safe neighborhood?” -**PropertyPro:** “I don’t give crime opinions. You can check the city’s public safety open data and local police resources — I’ve got those links in my notes for this market.” +**PropertyPro:** “I don’t give crime opinions or stats. You can check the public safety open data and local police resources linked in my notes.” -**Visitor:** “When was the roof done?” *(on a sparse listing)* +**Visitor:** “Text the agent.” -**PropertyPro:** “I don’t have the roof age in my notes. I’ve added that to the agent’s question list.” +**PropertyPro:** “I can’t send messages from this device yet. Avery Chen’s number is 5 5 5, 0 1 0, 1 4 2 0. Your questions are saved on the agent’s list.” -**Visitor:** “Text the agent.” +**Visitor:** “We’re done.” -**PropertyPro:** “I’ll text Avery Chen with three questions from today’s showing at 1420 Maple. Send it?” +**PropertyPro:** “Closing the tour. Say hello to start again.” + +**Visitor:** “What’s the price?” + +**PropertyPro:** “The tour is closed. Say hello to start again.” --- -## Project layout (target) +## Project layout ``` community/property-pro/ ├── README.md ├── .openhome.json -├── main.py ← MatchingCapability tour guide +├── main.py ├── fair_housing.md ├── fixtures/LISTINGS.md ├── fixtures/listings/*.md └── notes/PRODUCT_DISCOVERY.md ``` +Runtime (gitignored): `propertypro_prefs.json`, `tour_questions.md`, `knowledge_gaps.json` + --- ## Related -- Suite A sibling: [`community/town-hall`](../town-hall/) — civic briefing patterns (voice flow, prefs, gap logging) we reuse here -- OpenHome email template: [`templates/send-email`](../../templates/send-email/) -- Twilio SMS / outbound call: [`community/Twilio-SMS`](../Twilio-SMS/) +- Suite A sibling: [`community/town-hall`](../town-hall/) — voice flow, prefs, gap logging patterns +- OpenHome email template (future): [`templates/send-email`](../../templates/send-email/) +- Twilio SMS / outbound call (future): [`community/Twilio-SMS`](../Twilio-SMS/) --- ## Status -Stage 1 core loop is implemented in `main.py`: hello → room beats with dimensions → visitor-driven Q&A → fair-housing redirects → `tour_questions.md` logging. After a tour ends, the device stays in PropertyPro and waits for hello again (does not hand visitors to the regular agent). Default listing: `1420-maple-richmond`. Email/Twilio send paths still speak contact info as a fallback. +Stage 1 is implemented and live-testable: hello → room beats → Q&A → fair-housing redirects → question logging → lobby until hello. Default listing: Maple Avenue Craftsman. Email/Twilio still deferred. diff --git a/community/property-pro/fixtures/LISTINGS.md b/community/property-pro/fixtures/LISTINGS.md index 062085bd..cdfcbc99 100644 --- a/community/property-pro/fixtures/LISTINGS.md +++ b/community/property-pro/fixtures/LISTINGS.md @@ -1,11 +1,13 @@ # Listing fixtures -Stage 1 listing packets are **markdown** (not JSON). Agents/sellers can edit them like a checklist; OpenHome can also inject `.md` into ambient context the same way Town Hall does. +Stage 1 listing packets are **markdown**. The ability parses tour order, room dimensions/notes, systems, inclusions, agent contact, and redirect URLs from these files. | File | Role | | --- | --- | -| [`listings/1420-maple-richmond.md`](listings/1420-maple-richmond.md) | Full SFH happy path | +| [`listings/1420-maple-richmond.md`](listings/1420-maple-richmond.md) | Full SFH happy path (default `active_listing_id`) | | [`listings/88-canal-loft-richmond.md`](listings/88-canal-loft-richmond.md) | Condo + HOA | | [`listings/7-pine-sparse-chesterfield.md`](listings/7-pine-sparse-chesterfield.md) | Sparse packet / logging + contact stress test | -All addresses and contacts are fictitious. Schema notes: [`../notes/PRODUCT_DISCOVERY.md`](../notes/PRODUCT_DISCOVERY.md) §4. +Room `note` fields are spoken on each room beat — keep them vivid but factual (2 short sentences works well for voice). + +All addresses and contacts are fictitious. Schema: [`../notes/PRODUCT_DISCOVERY.md`](../notes/PRODUCT_DISCOVERY.md) §4. diff --git a/community/property-pro/notes/PRODUCT_DISCOVERY.md b/community/property-pro/notes/PRODUCT_DISCOVERY.md index f9feec9a..ef29723b 100644 --- a/community/property-pro/notes/PRODUCT_DISCOVERY.md +++ b/community/property-pro/notes/PRODUCT_DISCOVERY.md @@ -1,8 +1,8 @@ # PropertyPro — Product Discovery (Stage 1) -Living design note for the showing tour guide. Implementation should follow this cut line before growing scope. +Living design note for the showing tour guide. Kept in sync with `main.py`. -**Git base:** `add-property-pro` from `origin/dev` (includes merged town-hall #358). +**Branch / PR:** `add-property-pro` → `openhome-dev/abilities` `dev`. --- @@ -12,20 +12,22 @@ Living design note for the showing tour guide. Implementation should follow this **Who prepared it (target future):** Listing agent places the speaker, opens a phone app, uploads the listing packet (`.md`), connects the speaker, selects that packet as active, arms PropertyPro, and leaves. -**Who prepared it (Stage 1):** Demo/dev loads a fixture from `fixtures/listings/*.md` (or a hand-authored packet in storage) and sets `active_listing_id`. Same visitor experience after hello. +**Who prepared it (Stage 1):** Fixtures in `fixtures/listings/*.md`. Pref `active_listing_id` in `propertypro_prefs.json` (default `1420-maple-richmond`). **Who uses it:** Buyer, buyer’s agent, or open-house visitor. -**Triggers (planned):** `hello`, `hi`, `start tour`, `begin tour`, plus in-tour control / contact phrases. +**Dashboard triggers (≥4 letters):** `hello`, `start tour`, `begin tour`, `property pro`. + +**In-ability restart / lobby phrases:** also recognizes `hi`, `propertypro`, `showing tour` once the ability is already running. **Mode:** Short companion turns. One beat per room. **Visitor-driven** — state facts, then wait. No trailing “what next?” prompts. ```text -hello → greet + fair-housing soft disclaimer - → room path (from packet) - → Q&A loop (packet | redirect | refuse | log) - → exit (confirm questions saved) - → optional email / on-demand SMS-call +hello → greet + fair-housing soft disclaimer + first room + → Q&A / room nav loop + → exit or 2× idle → “Closing the tour. Say hello to start again.” + → lobby (stay in PropertyPro) + → hello → restart tour ``` --- @@ -36,13 +38,11 @@ hello → greet + fair-housing soft disclaimer **PropertyPro:** Hi — welcome to Maple Avenue Craftsman. I’ll walk you through the main spaces, and you can ask me questions anytime. I’m here for facts about this property — for neighborhood topics like crime or demographics, I’ll point you to official sources rather than give opinions. We’ll begin in the foyer. -**Visitor:** Okay. - -**PropertyPro:** You’re in the foyer — room dimensions 11 by 8. A classic craftsman entry with original oak trim, a built-in bench niche, and soft light from the sidelight windows. The hardwood continues straight into the living room ahead. +**PropertyPro:** You’re in the foyer. Room dimensions 11 by 8. A classic craftsman entry with original oak trim, a built-in bench niche, and soft light from the sidelight windows. The hardwood continues straight into the living room ahead. **Visitor:** Living room. -**PropertyPro:** You’re in the living room — room dimensions 16 by 14. A bright gathering room with a working brick fireplace, original crown molding, and a wide bay window that pulls in afternoon light. +**PropertyPro:** You’re in the living room. Room dimensions 16 by 14. A bright gathering room with a working brick fireplace, original crown molding, and a wide bay window that pulls in afternoon light. **Visitor:** What’s the square footage of the whole house? @@ -50,17 +50,17 @@ hello → greet + fair-housing soft disclaimer **Visitor:** Kitchen. Do the appliances stay? -**PropertyPro:** You’re in the kitchen — room dimensions 12 by 14. Remodeled in 2021 with quartz counters, shaker cabinets, a gas range, and a deep farmhouse sink. It opens through French doors to the deck. Fridge, washer, and dryer convey. The seller’s freestanding bookshelf does not. +**PropertyPro:** You’re in the kitchen. Room dimensions 12 by 14. Remodeled in 2021 with quartz counters, shaker cabinets, a gas range, and a deep farmhouse sink. It opens through French doors to the deck. Fridge, washer, and dryer convey. The seller’s freestanding bookshelf does not. **Visitor:** When was the roof done? -**PropertyPro:** Roof was replaced in 2019. +**PropertyPro:** Roof: 2019. **Visitor:** Is this a safe neighborhood? -**PropertyPro:** I don’t give crime opinions or stats. You can check Richmond’s public safety open data and local police resources — links are in my market notes. +**PropertyPro:** I don’t give crime opinions or stats. You can check the public safety open data and local police resources linked in my notes. -**Visitor:** No thanks. Are the schools good? +**Visitor:** Are the schools good? **PropertyPro:** I don’t rate schools. This address is listed under Bellevue Elementary, Albert Hill Middle, and Thomas Jefferson High — please verify on the district site. Public report cards are the place to judge fit. @@ -68,56 +68,48 @@ hello → greet + fair-housing soft disclaimer **PropertyPro:** I’m not able to discuss neighborhood demographics under fair housing guidelines. I can stick to facts about this property if you have another question. -**Visitor:** What’s the water heater age? *(switch to Pine Court sparse packet in other tests)* - -On Maple (full packet): Water heater was replaced in 2018. +**Visitor:** Text the agent. -On Pine (sparse): I don’t have the water heater age in my notes. I’ve added that to the agent’s question list. +**PropertyPro:** I can’t send messages from this device yet. Avery Chen’s number is 5 5 5, 0 1 0, 1 4 2 0. Your questions are saved on the agent’s list. -**Visitor:** Text the agent. +**Visitor:** We’re done. -**PropertyPro:** I’ll text Avery Chen with your open questions from today’s showing at 1420 Maple. Send it? +**PropertyPro:** Closing the tour. Say hello to start again. -**Visitor:** Yes. +**Visitor:** What’s the square footage? -**PropertyPro:** Sent. +**PropertyPro:** The tour is closed. Say hello to start again. -**Visitor:** “We’re done.” +**Visitor:** Hello. -**PropertyPro:** Closing the tour. I’ve saved two questions for the listing agent. Say hello to start again. +*(tour restarts from foyer)* -*(device stays in PropertyPro — if they ask something else before hello:)* +### Sparse listing note (Pine Court — separate test) -**Visitor:** What’s the square footage? +On `7-pine-sparse-chesterfield`, systems/inclusions are missing. Asking for roof age yields: -**PropertyPro:** The tour is closed. Say hello to start again. +> I don’t have that in my notes. I’ve added that to the agent’s question list. --- ## 3. Fair-housing knowledge base -Runtime file: [`../fair_housing.md`](../fair_housing.md) (injected as ability context). Listing packets cannot override hard rules. - -Summary: +Runtime file: [`../fair_housing.md`](../fair_housing.md). Loaded with the listing packet and used for grounded answers + hard redirects. Listing packets cannot override hard rules. | Topic | Behavior | | --- | --- | -| Protected-class steering / “good for people like us” | Hard refuse | -| Neighborhood demographics (race, religion, etc.) | Hard refuse | -| Crime / “safe neighborhood” | Redirect to official sources; no spoken stats or vibes | -| School *quality* | Assignment OK if sourced; no ratings; point to public evaluation | +| Protected-class steering / “people like us” | Hard refuse | +| Neighborhood demographics | Hard refuse | +| Crime / “safe neighborhood” | Redirect; no spoken stats | +| School *quality* | Assignment OK if sourced; no ratings | | Property facts in packet | Answer | | Missing property facts | Log to `tour_questions.md` | -Product policy: even with HUD Apr 2026 clarification on school/crime *data*, Stage 1 keeps the safer customary practice — redirect crime; no school quality opinions. - --- ## 4. Listing packet schema -Stage 1 packets are **markdown files**. Rationale: easy for agents to edit, matches OpenHome ambient `.md` injection, no parse layer required for the first slice. - -Suggested sections (see fixtures for full examples): +Stage 1 packets are **markdown files** under `fixtures/listings/`. ```markdown # Marketing name @@ -143,26 +135,19 @@ Suggested sections (see fixtures for full examples): ## Redirect URLs ``` -Missing sections / `(unknown)` / `(not provided)` → log to `tour_questions.md` when asked. Do not invent facts. +Missing sections / `(unknown)` / `(not provided)` → log when asked. Do not invent facts. -Active listing pref (planned): `active_listing_id` → load `fixtures/listings/{id}.md` (or a user-uploaded packet path). +**Pref:** `active_listing_id` in `propertypro_prefs.json` → `fixtures/listings/{id}.md`. -**Room tour beat rule:** for each room in tour order, speak label + dimensions when present (`room dimensions 11 by 8` — never `11x8` for TTS). Then the short note. If dimensions are unknown/missing, skip that clause. **Do not** end with “what would you like?” — wait for the visitor. +**Room tour beat:** label + dimensions (spoken as `11 by 8`) + note. Skip dimensions if unknown. Do not end with “what next?” --- ## 4b. Agent setup flow (future vs Stage 1) -**Future (ideal):** - -1. Place speaker + “say hello” note -2. Phone app → upload listing packet document -3. Connect / pair speaker -4. Select uploaded doc as active listing packet -5. Start assistant → leave property -6. Visitor: hello → tour +**Future:** place speaker → phone upload → pair → select packet → arm → leave → visitor hello. -**Stage 1:** fixtures or hand-authored `.md` + `active_listing_id` pref. No phone upload UI in this ability PR. +**Stage 1:** fixtures + `active_listing_id`. No phone upload UI. --- @@ -172,69 +157,61 @@ Active listing pref (planned): `active_listing_id` → load `fixtures/listings/{ | --- | --- | | [`../fixtures/listings/1420-maple-richmond.md`](../fixtures/listings/1420-maple-richmond.md) | Full SFH happy path | | [`../fixtures/listings/88-canal-loft-richmond.md`](../fixtures/listings/88-canal-loft-richmond.md) | Condo + HOA | -| [`../fixtures/listings/7-pine-sparse-chesterfield.md`](../fixtures/listings/7-pine-sparse-chesterfield.md) | Sparse packet / logging + contact stress test | +| [`../fixtures/listings/7-pine-sparse-chesterfield.md`](../fixtures/listings/7-pine-sparse-chesterfield.md) | Sparse packet / logging stress test | -All addresses and agent contacts are **fictitious**. +All addresses and contacts are **fictitious**. --- -## 6. `tour_questions.md` + delivery +## 6. `tour_questions.md` + contact -**Always:** append unanswered property questions (and optional “please send me X links” follow-ups). +**Always:** append unanswered property questions (and advice-request notes). ```markdown ## Showing — 1420 Maple Avenue — 2026-08-10 14:32 - Fence: is it shared with the neighbor? -- Visitor asked agent to send public-safety open-data links +- Visitor asked for pricing or offer advice ``` -**Email (optional):** `CapabilityWorker.send_email()` with `tour_questions.md` attached to `agent.email` when SMTP prefs exist. End of tour or on “email my questions.” - -**On-the-spot (optional):** +**Contact (Stage 1 implemented):** | Ask | Action | | --- | --- | -| Agent’s number? | Speak name + phone (TTS-friendly) | -| Text the agent | Twilio SMS after confirmation | -| Call the agent | Twilio outbound TTS summary (not a live bridge) | -| Email now | Immediate `send_email()` | +| Agent’s number? | Speak name + phone (digit-spaced for TTS) | +| Text / call / email the agent | Speak that send isn’t available yet + phone; keep question file | -If Twilio/SMTP missing: speak contact + keep the file. Never pretend a send succeeded. +**Not yet:** `send_email()` attachment, Twilio SMS/call. --- -## 7. Voice-flow map (for `main.py` later) - -1. Load prefs + `active_listing_id` → listing `.md` packet + `fair_housing.md` -2. Classify: start tour | room nav | property Q | fair-housing topic | contact | exit -3. Property Q → answer from packet or append `tour_questions.md` -4. Fair-housing topic → redirect/refuse snippets from KB -5. Contact → confirm → Twilio / email / speak fallback -6. Exit / idle → “Closing the tour. Say hello to start again.” → **wait lobby** (stay in PropertyPro; do not hand off to the regular agent) -7. Hello in lobby → restart greet + tour -8. Schema/product gaps → `knowledge_gaps.json` +## 7. Voice-flow map (`main.py`) -Reuse Town Hall patterns: `MatchingCapability`, short spoken turns, persistent files, gap logging. Showing-device default: keep the ability alive between tours. +1. Load prefs + listing `.md` + `fair_housing.md` +2. Outer loop (showing-device mode): greet → `_tour_loop` → `_wait_for_hello` +3. Classify: room nav | property fact | crime | schools | demographics | contact | advice | exit +4. Property fact → deterministic handlers, else grounded LLM (`UNKNOWN` → log) +5. Exit / 2× empty listen → close line → lobby (do **not** resume normal flow) +6. Hello in lobby → restart +7. Hard failure / missing listing → `resume_normal_flow()` in `finally` --- ## 8. Non-goals (Stage 1) -- Phone app: upload packet, pair speaker, select active listing, arm assistant +- Phone app upload / pair / select / arm - Buyer multi-home search / compare / criteria prefs -- Seller CMA or “should I buy / is it overpriced” advice +- Seller CMA or offer advice - Spoken crime stats or school ratings -- Live two-way call bridge (visitor ↔ agent) -- Gmail/Outlook inbox as a dependency -- Lead capture / CRM / showing scheduling -- StayGuide guest ops; BizSpace commercial zoning -- `main.py` until this note + fixtures feel right +- Live two-way call bridge +- Gmail/Outlook as a dependency +- Lead capture / CRM / scheduling +- StayGuide / BizSpace features +- Working email or Twilio send from the showing device --- ## 9. Next implementation slice -1. ~~Confirm this note + fixtures + `fair_housing.md`~~ -2. ~~Scaffold `main.py` + `.openhome.json` with hello → tour → Q&A on Maple fixture~~ -3. Add email (`send_email`) and Twilio SMS/call when configured -4. Voice command to switch `active_listing_id` among fixtures +1. Optional `send_email()` of `tour_questions.md` +2. Optional Twilio SMS / TTS outbound call with confirmation +3. Voice command to switch `active_listing_id` among fixtures From 5ea8335011ec10a0a84fb4792855aaa178d14631 Mon Sep 17 00:00:00 2001 From: Tati Date: Fri, 21 Aug 2026 20:15:07 -0400 Subject: [PATCH 4/7] fix(property-pro): make __init__.py empty for community lint Co-authored-by: Cursor --- community/property-pro/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/community/property-pro/__init__.py b/community/property-pro/__init__.py index 8aac6e11..e69de29b 100644 --- a/community/property-pro/__init__.py +++ b/community/property-pro/__init__.py @@ -1 +0,0 @@ -# empty package marker for openhome ability loading From 9a9a17f1cefa359c73bfa254a6c30f1137fbe440 Mon Sep 17 00:00:00 2001 From: Uzair Ullah Date: Mon, 31 Aug 2026 17:27:43 +0500 Subject: [PATCH 5/7] Remove local CLI/scaffold files that don't belong in the ability - .openhome.json: the OpenHome CLI's own local tooling metadata (capability_id, version tracking), scaffolded by `openhome create` and committed by accident. Carries no ability behavior and is account/environment state, not source. - notes/PRODUCT_DISCOVERY.md: internal design notes (sample dialogue, non-goals, next-slice planning). Useful during development, not part of what ships. Kept everything the ability actually reads at runtime: main.py, fair_housing.md, the listing fixtures, and .gitignore (which correctly excludes the ability's own runtime-written files -- tour_questions.md, propertypro_prefs.json, knowledge_gaps.json -- from being committed). Updated README.md's project-layout tree and removed a link to the deleted notes file so nothing dangles. --- community/property-pro/.openhome.json | 6 - community/property-pro/README.md | 7 +- .../property-pro/notes/PRODUCT_DISCOVERY.md | 217 ------------------ 3 files changed, 3 insertions(+), 227 deletions(-) delete mode 100644 community/property-pro/.openhome.json delete mode 100644 community/property-pro/notes/PRODUCT_DISCOVERY.md diff --git a/community/property-pro/.openhome.json b/community/property-pro/.openhome.json deleted file mode 100644 index 530848a2..00000000 --- a/community/property-pro/.openhome.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "propertypro", - "capability_id": null, - "category": "skill", - "description": "Voice showing tour guide for residential listings. Walks visitors room-by-room from a markdown listing packet, answers property facts, follows fair-housing redirects, and logs unanswered questions for the listing agent." -} diff --git a/community/property-pro/README.md b/community/property-pro/README.md index 1025ff0a..2e2f1da8 100644 --- a/community/property-pro/README.md +++ b/community/property-pro/README.md @@ -93,7 +93,7 @@ A `fair_housing.md` knowledge base ships with the ability. This is product guard | `88-canal-loft-richmond` | Condo + HOA | Fees, inclusions, no school assignment | | `7-pine-sparse-chesterfield` | Thin ranch packet | Forces question logging + contact demos | -Packets live under [`fixtures/listings/`](fixtures/listings/). Index: [`fixtures/LISTINGS.md`](fixtures/LISTINGS.md). Design notes: [`notes/PRODUCT_DISCOVERY.md`](notes/PRODUCT_DISCOVERY.md). +Packets live under [`fixtures/listings/`](fixtures/listings/). Index: [`fixtures/LISTINGS.md`](fixtures/LISTINGS.md). --- @@ -150,12 +150,11 @@ Email and Twilio sends are **not** wired yet. Contact asks speak the listing age ``` community/property-pro/ ├── README.md -├── .openhome.json +├── .gitignore ├── main.py ├── fair_housing.md ├── fixtures/LISTINGS.md -├── fixtures/listings/*.md -└── notes/PRODUCT_DISCOVERY.md +└── fixtures/listings/*.md ``` Runtime (gitignored): `propertypro_prefs.json`, `tour_questions.md`, `knowledge_gaps.json` diff --git a/community/property-pro/notes/PRODUCT_DISCOVERY.md b/community/property-pro/notes/PRODUCT_DISCOVERY.md deleted file mode 100644 index ef29723b..00000000 --- a/community/property-pro/notes/PRODUCT_DISCOVERY.md +++ /dev/null @@ -1,217 +0,0 @@ -# PropertyPro — Product Discovery (Stage 1) - -Living design note for the showing tour guide. Kept in sync with `main.py`. - -**Branch / PR:** `add-property-pro` → `openhome-dev/abilities` `dev`. - ---- - -## 1. Scene + trigger - -**Scene:** Visitor enters a listed home. OpenHome device on a table. Card says: “Pick me up and say hello to start the tour.” - -**Who prepared it (target future):** Listing agent places the speaker, opens a phone app, uploads the listing packet (`.md`), connects the speaker, selects that packet as active, arms PropertyPro, and leaves. - -**Who prepared it (Stage 1):** Fixtures in `fixtures/listings/*.md`. Pref `active_listing_id` in `propertypro_prefs.json` (default `1420-maple-richmond`). - -**Who uses it:** Buyer, buyer’s agent, or open-house visitor. - -**Dashboard triggers (≥4 letters):** `hello`, `start tour`, `begin tour`, `property pro`. - -**In-ability restart / lobby phrases:** also recognizes `hi`, `propertypro`, `showing tour` once the ability is already running. - -**Mode:** Short companion turns. One beat per room. **Visitor-driven** — state facts, then wait. No trailing “what next?” prompts. - -```text -hello → greet + fair-housing soft disclaimer + first room - → Q&A / room nav loop - → exit or 2× idle → “Closing the tour. Say hello to start again.” - → lobby (stay in PropertyPro) - → hello → restart tour -``` - ---- - -## 2. Sample dialogue (Maple Avenue Craftsman) - -**Visitor:** Hello. - -**PropertyPro:** Hi — welcome to Maple Avenue Craftsman. I’ll walk you through the main spaces, and you can ask me questions anytime. I’m here for facts about this property — for neighborhood topics like crime or demographics, I’ll point you to official sources rather than give opinions. We’ll begin in the foyer. - -**PropertyPro:** You’re in the foyer. Room dimensions 11 by 8. A classic craftsman entry with original oak trim, a built-in bench niche, and soft light from the sidelight windows. The hardwood continues straight into the living room ahead. - -**Visitor:** Living room. - -**PropertyPro:** You’re in the living room. Room dimensions 16 by 14. A bright gathering room with a working brick fireplace, original crown molding, and a wide bay window that pulls in afternoon light. - -**Visitor:** What’s the square footage of the whole house? - -**PropertyPro:** About 1,840 square feet. - -**Visitor:** Kitchen. Do the appliances stay? - -**PropertyPro:** You’re in the kitchen. Room dimensions 12 by 14. Remodeled in 2021 with quartz counters, shaker cabinets, a gas range, and a deep farmhouse sink. It opens through French doors to the deck. Fridge, washer, and dryer convey. The seller’s freestanding bookshelf does not. - -**Visitor:** When was the roof done? - -**PropertyPro:** Roof: 2019. - -**Visitor:** Is this a safe neighborhood? - -**PropertyPro:** I don’t give crime opinions or stats. You can check the public safety open data and local police resources linked in my notes. - -**Visitor:** Are the schools good? - -**PropertyPro:** I don’t rate schools. This address is listed under Bellevue Elementary, Albert Hill Middle, and Thomas Jefferson High — please verify on the district site. Public report cards are the place to judge fit. - -**Visitor:** Who lives around here? Is it diverse? - -**PropertyPro:** I’m not able to discuss neighborhood demographics under fair housing guidelines. I can stick to facts about this property if you have another question. - -**Visitor:** Text the agent. - -**PropertyPro:** I can’t send messages from this device yet. Avery Chen’s number is 5 5 5, 0 1 0, 1 4 2 0. Your questions are saved on the agent’s list. - -**Visitor:** We’re done. - -**PropertyPro:** Closing the tour. Say hello to start again. - -**Visitor:** What’s the square footage? - -**PropertyPro:** The tour is closed. Say hello to start again. - -**Visitor:** Hello. - -*(tour restarts from foyer)* - -### Sparse listing note (Pine Court — separate test) - -On `7-pine-sparse-chesterfield`, systems/inclusions are missing. Asking for roof age yields: - -> I don’t have that in my notes. I’ve added that to the agent’s question list. - ---- - -## 3. Fair-housing knowledge base - -Runtime file: [`../fair_housing.md`](../fair_housing.md). Loaded with the listing packet and used for grounded answers + hard redirects. Listing packets cannot override hard rules. - -| Topic | Behavior | -| --- | --- | -| Protected-class steering / “people like us” | Hard refuse | -| Neighborhood demographics | Hard refuse | -| Crime / “safe neighborhood” | Redirect; no spoken stats | -| School *quality* | Assignment OK if sourced; no ratings | -| Property facts in packet | Answer | -| Missing property facts | Log to `tour_questions.md` | - ---- - -## 4. Listing packet schema - -Stage 1 packets are **markdown files** under `fixtures/listings/`. - -```markdown -# Marketing name - -- **id:** `listing-slug` -- **address:** … -- **price:** … -- **beds / baths:** … -- **sq ft:** … -- **hoa:** … - -## Agent -## Seller welcome -## Tour order -## Rooms -### foyer -- **dimensions:** 11 by 8 -- **note:** … -## Systems -## Inclusions -## Exclusions -## School assignment -## Redirect URLs -``` - -Missing sections / `(unknown)` / `(not provided)` → log when asked. Do not invent facts. - -**Pref:** `active_listing_id` in `propertypro_prefs.json` → `fixtures/listings/{id}.md`. - -**Room tour beat:** label + dimensions (spoken as `11 by 8`) + note. Skip dimensions if unknown. Do not end with “what next?” - ---- - -## 4b. Agent setup flow (future vs Stage 1) - -**Future:** place speaker → phone upload → pair → select packet → arm → leave → visitor hello. - -**Stage 1:** fixtures + `active_listing_id`. No phone upload UI. - ---- - -## 5. Mock fixtures - -| File | Role | -| --- | --- | -| [`../fixtures/listings/1420-maple-richmond.md`](../fixtures/listings/1420-maple-richmond.md) | Full SFH happy path | -| [`../fixtures/listings/88-canal-loft-richmond.md`](../fixtures/listings/88-canal-loft-richmond.md) | Condo + HOA | -| [`../fixtures/listings/7-pine-sparse-chesterfield.md`](../fixtures/listings/7-pine-sparse-chesterfield.md) | Sparse packet / logging stress test | - -All addresses and contacts are **fictitious**. - ---- - -## 6. `tour_questions.md` + contact - -**Always:** append unanswered property questions (and advice-request notes). - -```markdown -## Showing — 1420 Maple Avenue — 2026-08-10 14:32 -- Fence: is it shared with the neighbor? -- Visitor asked for pricing or offer advice -``` - -**Contact (Stage 1 implemented):** - -| Ask | Action | -| --- | --- | -| Agent’s number? | Speak name + phone (digit-spaced for TTS) | -| Text / call / email the agent | Speak that send isn’t available yet + phone; keep question file | - -**Not yet:** `send_email()` attachment, Twilio SMS/call. - ---- - -## 7. Voice-flow map (`main.py`) - -1. Load prefs + listing `.md` + `fair_housing.md` -2. Outer loop (showing-device mode): greet → `_tour_loop` → `_wait_for_hello` -3. Classify: room nav | property fact | crime | schools | demographics | contact | advice | exit -4. Property fact → deterministic handlers, else grounded LLM (`UNKNOWN` → log) -5. Exit / 2× empty listen → close line → lobby (do **not** resume normal flow) -6. Hello in lobby → restart -7. Hard failure / missing listing → `resume_normal_flow()` in `finally` - ---- - -## 8. Non-goals (Stage 1) - -- Phone app upload / pair / select / arm -- Buyer multi-home search / compare / criteria prefs -- Seller CMA or offer advice -- Spoken crime stats or school ratings -- Live two-way call bridge -- Gmail/Outlook as a dependency -- Lead capture / CRM / scheduling -- StayGuide / BizSpace features -- Working email or Twilio send from the showing device - ---- - -## 9. Next implementation slice - -1. Optional `send_email()` of `tour_questions.md` -2. Optional Twilio SMS / TTS outbound call with confirmation -3. Voice command to switch `active_listing_id` among fixtures From bf07e2296accb1a4935cee81be1df2e61c274ca8 Mon Sep 17 00:00:00 2001 From: Uzair Ullah Date: Mon, 31 Aug 2026 17:39:16 +0500 Subject: [PATCH 6/7] Remove .gitignore; fix substring bug in restart matching; drop does_match() - .gitignore removed: none of the three files it listed were ever committed (confirmed via git ls-files), so there was nothing for it to protect. - HOTWORDS was checked with plain substring matching ("hw in t"), and "hi" is a substring of ordinary words -- "this", "which", etc. A visitor mid-tour asking "is this room big?" hit _is_restart() and got reset to the foyer greeting instead of an answer. Replaced with a word-boundary regex, same style as the EXIT_PATTERN already in this file. - Removed does_match(): it duplicated trigger words in code that belong to the dashboard config, a second source of truth that can drift from the first with no ability to notice. RESTART_PATTERN stays, since that check happens entirely inside the ability's own conversation loop (deciding whether an utterance means restart vs. real question) and the dashboard has no way to make that call. Verified: previously-false-positive inputs ("is this room big?", "which room has the fireplace") no longer trigger a restart; real restart phrases (hello, hi, start tour, property pro, showing tour) still do. --- community/property-pro/.gitignore | 3 --- community/property-pro/README.md | 3 +-- community/property-pro/main.py | 25 +++++++++++-------------- 3 files changed, 12 insertions(+), 19 deletions(-) delete mode 100644 community/property-pro/.gitignore diff --git a/community/property-pro/.gitignore b/community/property-pro/.gitignore deleted file mode 100644 index 4d95d4c0..00000000 --- a/community/property-pro/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -tour_questions.md -propertypro_prefs.json -knowledge_gaps.json diff --git a/community/property-pro/README.md b/community/property-pro/README.md index 2e2f1da8..241452f6 100644 --- a/community/property-pro/README.md +++ b/community/property-pro/README.md @@ -150,14 +150,13 @@ Email and Twilio sends are **not** wired yet. Contact asks speak the listing age ``` community/property-pro/ ├── README.md -├── .gitignore ├── main.py ├── fair_housing.md ├── fixtures/LISTINGS.md └── fixtures/listings/*.md ``` -Runtime (gitignored): `propertypro_prefs.json`, `tour_questions.md`, `knowledge_gaps.json` +Runtime files, written by the ability, not committed: `propertypro_prefs.json`, `tour_questions.md`, `knowledge_gaps.json` --- diff --git a/community/property-pro/main.py b/community/property-pro/main.py index 6d6ce8f3..55b65c04 100644 --- a/community/property-pro/main.py +++ b/community/property-pro/main.py @@ -22,14 +22,16 @@ re.IGNORECASE, ) -HOTWORDS = ( - "hello", - "hi", - "start tour", - "begin tour", - "property pro", - "propertypro", - "showing tour", +# In-tour restart phrases only -- NOT used to invoke the ability. Invocation is the +# dashboard's job (trigger words are configured there, not in code); this is purely +# for _is_restart() below, deciding mid-conversation whether the visitor just said +# "start over" versus asking a real question. Word-boundary matched, same as +# EXIT_PATTERN above -- a plain substring check on "hi" previously matched "this", +# "which", and any other word containing it, so "is this room big?" mid-tour reset +# the showing back to the foyer instead of answering. +RESTART_PATTERN = re.compile( + r"\b(hello|hi|start tour|begin tour|property pro|propertypro|showing tour)\b", + re.IGNORECASE, ) CLASSIFY_PROMPT = """Classify this showing-tour visitor utterance. @@ -253,10 +255,6 @@ class PropertyProCapability(MatchingCapability): # {{register capability}} - def does_match(self, text: str) -> bool: - t = (text or "").lower().strip() - return any(hw in t for hw in HOTWORDS) - def call(self, worker: AgentWorker): self.worker = worker self.capability_worker = CapabilityWorker(self.worker) @@ -298,8 +296,7 @@ def _is_exit(self, text: str) -> bool: return bool(EXIT_PATTERN.search(text or "")) def _is_restart(self, text: str) -> bool: - t = (text or "").lower().strip() - return any(hw in t for hw in HOTWORDS) + return bool(RESTART_PATTERN.search(text or "")) async def _tour_loop(self): """run one showing until idle, exit words, or classified exit.""" From bb2752eb9fc8a58441207da088392a680edaea3f Mon Sep 17 00:00:00 2001 From: Uzair Ullah Date: Mon, 31 Aug 2026 17:47:59 +0500 Subject: [PATCH 7/7] Remove unreachable fixtures; fix room-nav substring bug (backyard/background) Stale files removed: - fixtures/listings/88-canal-loft-richmond.md and 7-pine-sparse-chesterfield.md -- neither is reachable through the running ability. active_listing_id is set once from propertypro_prefs.json and there is no voice command anywhere in main.py that changes it, so these only ever loaded if someone hand-edited that file. - fixtures/LISTINGS.md -- an index of the three fixtures above, zero references from main.py, and now stale (documents two files that no longer exist). README updated to match: dropped the two rows and the dead LISTINGS.md link, corrected the "switch fixtures" instructions to say plainly that no voice command does this yet, removed the tree entry. Bug fixed while reading through main.py for anything left behind: _match_room_nav checked "back"/"next" as plain substrings, the same class of bug as the does_match() HOTWORDS issue fixed last commit. Concretely, on the one listing that ships, the tour's actual last room is named "backyard" -- asking "is there a backyard?" from the basement matched "back" and navigated to upstairs instead of the backyard. Fixed with word-boundary patterns (NEXT_ROOM_PATTERN / BACK_ROOM_PATTERN), matching EXIT_PATTERN's existing style in the same file. "next" keeps one residual, harder-to-avoid ambiguity (it's also a preposition -- "next to", "next door"), noted in the code comment rather than silently left. Verified against the real shipped listing: navigating to backyard now resolves correctly (index 6), the restart-phrase fix from the prior commit is intact, and main.py still parses and imports cleanly. --- community/property-pro/README.md | 11 ++- community/property-pro/fixtures/LISTINGS.md | 13 ---- .../listings/7-pine-sparse-chesterfield.md | 73 ----------------- .../listings/88-canal-loft-richmond.md | 78 ------------------- community/property-pro/main.py | 17 +++- 5 files changed, 20 insertions(+), 172 deletions(-) delete mode 100644 community/property-pro/fixtures/LISTINGS.md delete mode 100644 community/property-pro/fixtures/listings/7-pine-sparse-chesterfield.md delete mode 100644 community/property-pro/fixtures/listings/88-canal-loft-richmond.md diff --git a/community/property-pro/README.md b/community/property-pro/README.md index 241452f6..484ddfe1 100644 --- a/community/property-pro/README.md +++ b/community/property-pro/README.md @@ -85,15 +85,15 @@ A `fair_housing.md` knowledge base ships with the ability. This is product guard --- -## Mock listings (testing) +## Mock listing (testing) | ID | Property | Why it exists | | --- | --- | --- | | `1420-maple-richmond` | Full craftsman SFH | Happy-path tour with systems + inclusions | -| `88-canal-loft-richmond` | Condo + HOA | Fees, inclusions, no school assignment | -| `7-pine-sparse-chesterfield` | Thin ranch packet | Forces question logging + contact demos | -Packets live under [`fixtures/listings/`](fixtures/listings/). Index: [`fixtures/LISTINGS.md`](fixtures/LISTINGS.md). +Packet lives under [`fixtures/listings/`](fixtures/listings/). There's no voice command to +switch which listing is active -- it's read once from `propertypro_prefs.json` at startup +-- so only the one listing an install actually tours is included here. --- @@ -107,7 +107,7 @@ Push or install PropertyPro and set dashboard triggers to at least: ### 2. Active listing -Default: `1420-maple-richmond`. To switch fixtures, set `active_listing_id` in `propertypro_prefs.json` (created on first run), e.g. `88-canal-loft-richmond` or `7-pine-sparse-chesterfield`. +The only listing shipped is `1420-maple-richmond`, set as `active_listing_id` in `propertypro_prefs.json` on first run. Add more fixtures under `fixtures/listings/` and edit that file to tour a different one -- there's no voice command for it yet. ### 3. Contact / delivery (Stage 1) @@ -152,7 +152,6 @@ community/property-pro/ ├── README.md ├── main.py ├── fair_housing.md -├── fixtures/LISTINGS.md └── fixtures/listings/*.md ``` diff --git a/community/property-pro/fixtures/LISTINGS.md b/community/property-pro/fixtures/LISTINGS.md deleted file mode 100644 index cdfcbc99..00000000 --- a/community/property-pro/fixtures/LISTINGS.md +++ /dev/null @@ -1,13 +0,0 @@ -# Listing fixtures - -Stage 1 listing packets are **markdown**. The ability parses tour order, room dimensions/notes, systems, inclusions, agent contact, and redirect URLs from these files. - -| File | Role | -| --- | --- | -| [`listings/1420-maple-richmond.md`](listings/1420-maple-richmond.md) | Full SFH happy path (default `active_listing_id`) | -| [`listings/88-canal-loft-richmond.md`](listings/88-canal-loft-richmond.md) | Condo + HOA | -| [`listings/7-pine-sparse-chesterfield.md`](listings/7-pine-sparse-chesterfield.md) | Sparse packet / logging + contact stress test | - -Room `note` fields are spoken on each room beat — keep them vivid but factual (2 short sentences works well for voice). - -All addresses and contacts are fictitious. Schema: [`../notes/PRODUCT_DISCOVERY.md`](../notes/PRODUCT_DISCOVERY.md) §4. diff --git a/community/property-pro/fixtures/listings/7-pine-sparse-chesterfield.md b/community/property-pro/fixtures/listings/7-pine-sparse-chesterfield.md deleted file mode 100644 index 40aac9a2..00000000 --- a/community/property-pro/fixtures/listings/7-pine-sparse-chesterfield.md +++ /dev/null @@ -1,73 +0,0 @@ -# Pine Court Ranch - -- **id:** `7-pine-sparse-chesterfield` -- **address:** 7 Pine Court, Chesterfield, VA 23832 -- **price:** $289,900 -- **beds / baths:** 3 / 2 -- **sq ft:** about 1,450 (verify) -- **lot:** (unknown) -- **year built:** (unknown) -- **hoa:** none listed - -## Agent - -- **name:** Sam Ortiz -- **brokerage:** Southside Team -- **phone:** +15550100007 -- **email:** sam.ortiz@example.com - -## Seller welcome - -(none) - -## Tour order - -1. living -2. kitchen -3. bedrooms -4. baths -5. garage - -## Rooms - -### living -- **dimensions:** 15 by 12 -- **note:** A comfortable front living room with a picture window to the street and an open pass-through to the kitchen. Simple, bright, and ready for everyday furniture. - -### kitchen -- **dimensions:** 11 by 10 -- **note:** An eat-in kitchen with room for a small table, solid counter workspace, and a window over the sink. Practical one-level living without wasted space. - -### bedrooms -- **dimensions:** (unknown) -- **note:** Three bedrooms on the main level — a primary toward the back of the house and two secondary rooms that work for kids, guests, or an office. Exact sizes aren't in my notes yet. - -### baths -- **dimensions:** (unknown) -- **note:** Two full baths on the main floor, so the morning routine doesn't bottleneck. Fixture details aren't listed in this packet. - -### garage -- **dimensions:** 20 by 12 -- **note:** An attached one-car garage with direct entry into the home, plus wall space for shelves or bikes. Handy for rainy-day unloading. - -## Systems - -(not provided — log if asked) - -## Inclusions - -(not provided — log if asked) - -## Exclusions - -(not provided — log if asked) - -## School assignment - -(not provided — log if asked) - -## Redirect URLs - -- **crime open data:** https://example.com/chesterfield-public-safety -- **school district:** https://example.com/chesterfield-schools -- **flood map:** https://msc.fema.gov/portal/home diff --git a/community/property-pro/fixtures/listings/88-canal-loft-richmond.md b/community/property-pro/fixtures/listings/88-canal-loft-richmond.md deleted file mode 100644 index 1bee50d9..00000000 --- a/community/property-pro/fixtures/listings/88-canal-loft-richmond.md +++ /dev/null @@ -1,78 +0,0 @@ -# Canal Walk Loft 4B - -- **id:** `88-canal-loft-richmond` -- **address:** 88 Canal Walk, Unit 4B, Richmond, VA 23219 -- **price:** $312,000 -- **beds / baths:** 1 / 1 -- **sq ft:** 920 -- **lot:** (none — condo) -- **year built:** 2005 -- **hoa:** $285 / month — includes water, trash, exterior maintenance -- **hoa docs:** resale / condo docs available from listing agent - -## Agent - -- **name:** Jordan Blake -- **brokerage:** Downtown Homes -- **phone:** +15550100088 -- **email:** jordan.blake@example.com - -## Seller welcome - -Welcome to 4B — enjoy the canal view. - -## Tour order - -1. entry -2. living_kitchen -3. bedroom -4. bath -5. balcony - -## Rooms - -### entry -- **dimensions:** 6 by 5 -- **note:** You enter through the building's secure lobby, then into a compact foyer with coat hooks and a clean sightline into the open loft. The unit feels private the moment the door closes. - -### living_kitchen -- **dimensions:** 22 by 18 -- **note:** A wide open loft with polished concrete floors, a kitchen island for seating, and floor-to-ceiling windows aimed at the canal. Daylight carries across the whole space, so living, dining, and cooking share one bright volume. - -### bedroom -- **dimensions:** 12 by 11 -- **note:** A quiet sleeping alcove with a full closet and a partial privacy wall from the living area. Still close enough to feel connected to the loft, with room for a queen bed and a slim dresser. - -### bath -- **dimensions:** 8 by 5 -- **note:** A full bath with a tub-shower combo, modern tile, and a vanity with storage. Compact, but finished to match the rest of the unit. - -### balcony -- **dimensions:** 10 by 4 -- **note:** Your own outdoor slice overlooking the canal — wide enough for two chairs and a small table, with evening light on the water. A natural spot for coffee or a quiet end to the day. - -## Systems - -- **roof:** (unknown) -- **hvac:** 2015 building system -- **water heater:** (unknown) -- **in-unit laundry:** 2022 stacked washer dryer - -## Inclusions - -- stacked washer -- stacked dryer - -## Exclusions - -- wall-mounted TV - -## School assignment - -(not provided — log if asked) - -## Redirect URLs - -- **crime open data:** https://example.com/richmond-public-safety -- **school district:** https://example.com/rps-school-finder -- **flood map:** https://msc.fema.gov/portal/home diff --git a/community/property-pro/main.py b/community/property-pro/main.py index 55b65c04..3ddbfad6 100644 --- a/community/property-pro/main.py +++ b/community/property-pro/main.py @@ -22,6 +22,19 @@ re.IGNORECASE, ) +# Room-navigation verbs. Word-boundary matched, not substring: a bare "back" in t +# matched inside "backyard" and "background", so "is there a backyard?" -- one of +# the most likely questions on any showing -- silently navigated the tour backward +# instead of answering. Same class of bug as RESTART_PATTERN below. +# +# "next" still has one residual ambiguity this can't resolve with a plain pattern: +# it's both the navigation command and an ordinary preposition ("next to the +# park", "next door"), so "is the yard next to a park" is still read as +# navigation. Narrowing further would need real intent understanding, not a +# regex; flagged here rather than silently left as a solved problem. +NEXT_ROOM_PATTERN = re.compile(r"\b(next|continue)\b", re.IGNORECASE) +BACK_ROOM_PATTERN = re.compile(r"\b(back|previous)\b", re.IGNORECASE) + # In-tour restart phrases only -- NOT used to invoke the ability. Invocation is the # dashboard's job (trigger words are configured there, not in code); this is purely # for _is_restart() below, deciding mid-conversation whether the visitor just said @@ -545,9 +558,9 @@ def _classify(self, text: str) -> str: def _match_room_nav(self, t: str) -> int | None: """return new room index or None if not navigation.""" order = self.listing.get("tour_order") or [] - if "next" in t or "continue" in t: + if NEXT_ROOM_PATTERN.search(t): return min(self.room_index + 1, max(len(order) - 1, 0)) - if "back" in t or "previous" in t: + if BACK_ROOM_PATTERN.search(t): return max(self.room_index - 1, 0) for i, room_id in enumerate(order): label = room_id.replace("_", " ")