Skip to content

Scribe Detection Reference

Authoritative values for how Scribe turns session events and the visitor’s own words into notes. Source of truth is idx-api/src/idx_api/services/preference_inference.py (inference), services/scribe_observations.py (sentences), routers/sessions.py (gates, merge and response shapes) and services/scribe_tools.py (what the thread writes and searches). Where this page and the code disagree, the code wins and this page has a bug.

Scribe is ready when all four of these hold, or when the session carries at least one confirmed preference (see Confirmed preferences). The first three are settings; the fourth is implied.

Gate Setting Environment variable Default
Distinct listings opened scribe_min_properties_viewed SCRIBE_MIN_PROPERTIES_VIEWED 3
Minutes since the session’s first event scribe_min_session_age_minutes SCRIBE_MIN_SESSION_AGE_MINUTES 2
Average confidence across inferred preferences scribe_min_confidence SCRIBE_MIN_CONFIDENCE 0.5
At least one inferred preference exists (none)
ready = gates_met or any_confirmed

The listing count is count(distinct listing_id) over property_view events, not the session’s raw view counter, so a reload is not a second listing. Session age is measured from first_seen_at to now. Average confidence is the plain mean over the merged preference list.

The confirmed path is what lets a visitor who opens the card cold and types “five bedrooms under a million” get notes with zero listings opened. progress on the status response is not affected by it: it keeps reporting the view gates as they stand (properties_viewed: 0 next to ready: true is a normal response for such a session).

Setting SCRIBE_MIN_SESSION_AGE_MINUTES=0 removes the time gate and hides the “Time looking” bar in the sheet. The API service reads these from .env at container creation; a running container needs docker compose up -d idx-api (recreate, not restart) to pick up a change.

progress on the status response reports the current value and the required value for each gate; see Scribe API.

The tracker (idx-app/src/lib/behavior-tracker.ts) posts batches to /api/v1/sessions/events. Inference reads five event types.

Event Fields used Yields
property_view list_price, beds, baths, sqft, lot_size_acres or lot_size_sqft, city, property_type, features[] Price band, beds, baths, sqft, lot size, location, property type; return visits; bounce attributes
page_exit (with listing_id) time_on_page_ms, scroll_depth_percent, photos_viewed_percent, used_calculator, viewed_pdf, clicked_schedule_tour, shared, favorited Engagement score and bounce detection, joined onto the matching view (see below)
photo_view detected_features[], dwell_time_ms Feature preferences
filter_change filter, new_value Explicit preferences at 0.95
search query Feature preferences from keywords

Other tracked events (page_view, feature_interest, pdf_download, tour_request, contact_submit) are stored but not read by inference today.

A property_view is sent when the page loads and carries the listing’s attributes. Time on page, scroll depth and the interaction flags are only known when the visitor leaves, so they ride on page_exit. Every scorer reads those fields off the view. Without a join, engagement was zero for every view and bounce detection could not fire.

merge_exit_engagement() gives each view the engagement fields from the page_exit for the same listing, matching an exit to the latest view of that listing at or before it. A view with no later exit (tab still open, beacon lost) keeps whatever it had. The merge works on copies, never the ORM rows, so nothing is written back to session_events.

Per view, 0 to 100, from the joined fields. Used to weight return visits.

Input Points Full credit
time_on_page_ms up to 30 5 minutes
scroll_depth_percent up to 20 100%
photos_viewed_percent up to 20 100%
used_calculator 10
viewed_pdf 10
clicked_schedule_tour 10
shared 5
favorited 5

All view-derived confidences are capped below 0.95 so a typed filter always wins the merge.

Preference Minimum data Value Confidence
price_range (key budget) 3 prices Median band, see below min(0.9, 0.3 + 0.1 × n)
beds (key min_beds) 1 Most common bedroom count min(0.9, share of views)
baths (key min_baths) 1 Most common count, rounded to 0.5 min(0.85, share)
sqft (key min_sqft) 3 80% of the mean min(0.85, 0.3 + 0.08 × n + 0.2 × consistency), consistency = 1 - stdev/mean
lot_size (key min_lot_acres) 3, mean at least 0.25 acre 70% of the mean, 2 decimals min(0.8, 0.3 + 0.1 × n)
location (key = city slug) City on 2+ views and at least 20% of views City name min(0.85, share)
property_type (key = type slug) 3 typed views, one type above 50% RESO type min(0.85, share)
feature from photos (key = feature slug) Total dwell 5 s and 2+ photo views "true" min(0.8, 0.3 + dwell_ms/20000 + 0.1 × views)
feature from searches (key = keyword) Keyword in 2+ searches "true" min(0.85, 0.5 + 0.1 × mentions)

Search keywords recognised: pool, garage, fireplace, basement, hardwood, granite, view, acreage, modern, ranch, each with a few synonyms. Capitalised words in a query are counted as possible place names but do not become preferences.

Median plus or minus 1.5 sigma, with sigma estimated from the median absolute deviation (MAD × 1.4826) rather than the standard deviation, and a floor of 20% of the median either side. Ends are rounded to the nearest $1,000 and the low end never drops below zero.

sigma = 1.4826 × median(|p - median|)
half_width = max(1.5 × sigma, 0.2 × median)
band = [median - half_width, median + half_width]

Mean and standard deviation were used before. One $3.5M click in a run of $400k to $600k views widened that band to roughly $0 to $2.2M; the same views under MAD stay inside $300k to $750k. The 20% floor stops three identical prices collapsing to a point.

A filter_change whose value was set two or more times becomes a preference at confidence 0.95. Filters mapped: min_price, max_price, min_beds, min_baths, min_sqft, property_type, city, pool, garage. city and property_type key on the slug of the value, the same key the view path uses, so the two merge and the typed value wins.

min_price and max_price are folded into the single budget band: a typed end replaces the guessed end, the other end is kept from views, and the band takes the explicit confidence. With no view-derived band and only a floor, the ceiling is set to max(2 × floor, floor + 100,000) so something displays.

A listing with two or more property_view events is a return visit. Views of that listing are weighted by count × (1 + engagement/100), and the most returned-to city is emitted as a location preference at 0.9 keyed on the city, so it merges with the plain location guess rather than appearing twice. The count of revisited listings feeds the return_visit observation and summary.properties_revisited.

A view is a bounce when, after the exit join, time_on_page_ms < 5000 and scroll_depth_percent < 20. If either field is absent the view is not a bounce. Reading a missing field as zero used to make every view a bounce, which told visitors they were avoiding the very listings they kept opening.

A listing that was later revisited is never a bounce; the set from return-visit detection is excluded before counting.

Three or more bounces sharing an attribute produce a disinterest preference with key attribute:value and confidence min(0.7, 0.3 + 0.1 × count):

Attribute Values
property_type The RESO type as seen on the view
price_bucket under_300k, 300k_500k, 500k_750k, 750k_1m, over_1m
feature Any entry of the view’s features[]

On the status response these arrive as negative_preferences[] with type = attribute and value = the value.

Preferences with the same type:key collapse to one: highest confidence wins, evidence counts are summed. Explicit price bounds are applied first, then the merge. The result is then merged once more with what the visitor said, below.

A confirmed preference is a session_preferences row with user_confirmed = true and confidence 1.0. Three things write them: the thread’s update_notes tool, POST /api/v1/sessions/preferences, and the confirm endpoint. This section is about the first, since it is the one a visitor reaches by typing.

The keys are the same ones inference uses, so a confirmed row and a guessed row for the same thing land in the same slot.

Argument Row written Value
min_price, max_price price_range:budget "min-max". A missing end is taken from the current band; with no ceiling anywhere it becomes max(2 × floor, floor + 100,000), the same rule as explicit filters
min_beds beds:min_beds Integer as text
max_beds beds:max_beds Integer as text. Only for “no more than”; the tool description tells the model that “we need five bedrooms” is min_beds
min_baths baths:min_baths "2" or "2.5"
city location:<slug> The city as typed, title-cased if it came in lower case. Older confirmed cities are deleted: one confirmed location at a time
property_type property_type:<slug> The RESO type. Same one-at-a-time rule
min_sqft sqft:min_sqft Integer as text
min_lot_acres lot_size:min_lot_acres Float as text
add_features[] feature:<slug> per entry The feature as typed
remove_features[] feature:<slug> per entry Marked user_rejected, confidence 0, so inference cannot bring it back
note sessions.freeform_notes Appended on a new line

merged_session_preferences() in routers/sessions.py is what both scribe-status and the thread’s tools read, so the sentences and the model always see the same list.

  • For the single-slot types price_range, beds, baths, property_type, sqft and lot_size, a confirmed row replaces every inferred row of that type.
  • For location and feature, a confirmed row replaces the inferred row with the same key only; a confirmed “Post Falls” does not remove an inferred “Rathdrum” from the list.
  • A rejected row removes the inferred row with the same type and key.
  • The merged list is sorted by confidence, so confirmed rows come first.

Inference never overwrites a confirmed or rejected row; sync_preferences_to_db only touches inferred ones.

An observation built from a confirmed row starts “You told me:”, whatever its kind (“You told me: 5+ bedrooms”, “You told me: up to $1M”, “You told me: Post Falls”, “You told me: shop and view”). The wording is chosen on the row’s confirmed flag, not its confidence, so a typed filter at 0.95 still reads “You asked for” and a thread line reads “You told me”. No sentence is generated for a max_beds ceiling; it appears only as preferences.beds.max on the status response and in the chip (“up to 6 beds”, “5 to 6 beds”).

The thread’s search_listings tool has two paths. Both run the site’s own listing handlers in-process with the visitor’s request, so Host scoping, the per-feed display policy and the seller flags apply exactly as they do on the search page, and a closing price never reaches the model.

The tool starts from the merged preference list and turns it into filters: min_beds, max_beds, min_baths, min_price, max_price, city, property_type, min_sqft, min_lot_acres. Any of those passed by the model as an argument overrides the note for that one search only; the note itself is unchanged. limit is capped at 6; sort is one of newest (default), price_asc, price_desc.

If every note applied finds nothing, the search runs once more with only the confirmed notes plus the overrides, so a guessed square-footage floor cannot hide the five-bedroom houses the visitor asked for. The model is told which filters were dropped and says so.

The tool fetches a page of 48 rows (SPREAD_SAMPLE) rather than the six it will show, and picks the cards from that page round robin across cities, largest city first, each city keeping its own sort order. Six newest in one town was the first prod thread’s failure, on a lake with nine towns around it.

The spread the model is told about does not come from those 48 rows. Beside list_properties the tool calls listing_facets, the handler behind GET /api/listings/facets, with the same parameters, so the count and the per-city split cover the whole match, not a sample. The sentence handed to the model is “All in Harrison.” for one city, otherwise “Spread across 9 areas: Harrison (41), Hayden (12), … and 4 more.” naming the five largest (SPREAD_NAMED), and it ends with “N carry no city on the listing.” when the feed left rows blank. Those counts are real, so the model is free to repeat them.

The words path has no facets query; the semantic handler returns its whole match (up to 30 rows), so its spread is counted over the rows returned.

choices is emitted after cards in the same tool outcome when both of these hold, on the filter path only:

total > WIDE_MATCH (12) and named cities > 1

total and the cities are the facet numbers above. A city that is NULL on the listing is never a button. The options are the largest cities first, at most CHOICE_MAX (6), each {label, value, count} with value the exact properties.city string, because the visitor’s tap comes back as their next message and update_notes(city=...) filters on it verbatim. The prompt is always “Which area?”.

When the gate opens the tool’s text also tells the model that the visitor sees the areas as buttons, and the system prompt carries two rules to match: ask which area in one short question without listing areas or counts, and treat a reply that is only a place name (“Harrison”) as the answer, calling update_notes with city set to it and then search_listings. A search on the words path, or one that lands in a single city, emits no choices; and since choices always follow their cards, a later search in the same turn clears an earlier one.

The loop stores the last cards and the last choices of the turn on the final reply row (scribe_messages.choices, migration 086), so a reload shows the same buttons. The card renders them only on the newest reply with no visitor line after it, and disables them while a reply is streaming.

The last search is remembered, not its rows

Section titled “The last search is remembered, not its rows”

Every search, on either path, writes scribe_conversations.last_search as {filters, query, sort, total, at}. The rows are never stored. GET /api/v1/sessions/scribe/results runs that search again through the public handlers with the visitor’s Host, up to 48 rows, so the full view’s homes pane shows the whole match under the live display policy rather than a copy that may have lost a listing or a licence since.

When the model passes query (3 to 200 characters: “shop building”, “mountain view”, “near Lincoln Elementary”), the tool calls the semantic search handler instead (POST /api/search/semantic). That handler embeds the words and ranks listings by how close their text is, carrying only city, min_price, max_price and min_beds from the filters; baths, type, size and lot size do not apply on this path.

A listing that comes up this way probably has the thing and may not, and the tool’s result says so in as many words. The model is told to say the homes “came up for” the query rather than that they “have” it, and to use explain_listing before asserting more about one.

When the words match nothing, the tool does not report “no home has a shop”. On a fresh install that result usually means the listings have no text embeddings yet. It falls back to the plain filter search with the same four filters, tells the model that the words could not be matched and that whether any result has the thing is unknown, and shows those cards. Only when the fallback is also empty does the reply say nothing came up.

explain_listing(listing_id) fetches the public detail record and hands the model what a visitor may see: address, list price, status, beds, baths, size, lot, year built, type, stories, garage, waterfront and view flags, subdivision, school district, days on market, listing agent and office, and the first 700 characters of the public remarks. No closing price or date, no agent-only remarks. A listing the display policy hides is reported as “not available to show”.

build_observations() turns the merged list into at most six sentences, highest confidence first, one per kind except disinterest, which gets one per signal. Locations and features fold into one sentence each. A confirmed row of any kind below reads “You told me: …” ahead of the rules in the table (see How they read).

Kind Source Wording rule
price price_range Confirmed: “You told me: $864k to $1M” or “You told me: up to $1M”. 0.95 and up: “You set your budget between…”; otherwise “Most of what you opened was between…” (or “under” when the floor is zero)
beds beds (min_beds only) Confirmed: “You told me: N+ bedrooms”. 0.95 and up: “You asked for N+ bedrooms”; otherwise “Homes with N+ bedrooms keep catching your eye”
baths baths Confirmed: “You told me: N+ baths”. 0.95 and up: “You asked for N+ baths”; otherwise “You’ve been opening homes with N+ baths”
location all location rows Any confirmed: “You told me: A, B and C”. One city: 0.95 and up “You’ve narrowed it to”; 0.85 and up “You keep coming back to”; otherwise “Most of what you’ve opened is in”. Two or three cities: “You’ve been looking around A, B and C”
property_type property_type Confirmed: “You told me: … listings”. 0.95 and up: “You filtered to … listings”; otherwise “Nearly everything you opened was a … listing”
sqft sqft Confirmed: “You told me: at least N sq ft”. 0.95 and up: “You asked for at least N sq ft”; otherwise “Space matters to you: most of what you opened was over N sq ft”
lot_size lot_size Confirmed: “You told me: X or more”. Otherwise “You’ve been looking at properties on X or more”, with a quarter acre, half an acre, an acre spelled out
feature all feature rows Any confirmed: “You told me: A, B and C”. Otherwise “You lingered on photos showing A, B and C”, “and more” past three
return_visit revisited listing count “You went back to one listing for a second look” or “… to N listings …”; confidence min(0.9, 0.5 + 0.2 × count)
disinterest each disinterest row “You skipped past three condominium listings”, “… three listings over $1M”, “… three homes with a fireplace”

Numbers are formatted server-side: $425k, $1.3M, 2,100 sq ft, counts up to ten as words. Confidence on each observation is rounded to two places.

suggest_agents_for_session() returns up to three agents. Preferences that were not rejected are joined into text, embedded, and compared with each eligible agent’s criteria_embedding by cosine distance; similarity is 1 - distance / 2. Eligible means active, not disabled, belonging to the session’s brokerage, and set to receive leads. Agents with no generated criteria embedding are not in the ranked query; they fill remaining slots at 0.5 (primary agent) or 0.3.

With no preferences at all, the primary agent alone is returned at 0.5 with the reason “Primary agent for your search area”. If the embedding call fails, the first eligible agent is returned at 0.5.

Match reasons come from the agent’s criteria: price range overlap, a service area containing the visitor’s city, a property type match, a client type match, else the agent’s first two specializations.

The sheet labels the first card “Closest match” regardless of score, then “Strong match” at 0.6 and above, “Good match” at 0.4 and above, and no label below that.

Key Type Purpose
scribe_disabled "true" Permanent opt-out on this browser. Cleared by the switch on /my-data
scribe_dismissed Unix ms “Not now” or “I’ll decide later”; the pill returns 24 hours later
scribe_seen_observations integer Observation count at last open. The dot shows when the live count is higher
scribe_results_view "grid" or "map" Session storage. The full view’s homes pane toggle, kept for the tab’s lifetime. ?view=map on /scribe overrides it on load
scribe_notes_rail "collapsed" or "open" Session storage. Whether the notes column is folded to the rail at 1024px

The chosen agent is not stored in the browser; it lives on the session (assigned_agent_id) and comes back on the status response. Whether the full view is open is not stored either: a reload lands on the corner card, and /scribe is the way to open the full view from a URL.

Runs once when a session converts through /api/contact. Only preferences with confidence 0.7 or above, or confirmed by the visitor, are copied. Rejected ones are skipped.

Session preference Client field Rule
price_range budget_min, budget_max Widens the existing range, never narrows it
beds (min_beds) beds_min Raises, never lowers
location preferred_areas Appended if absent
property_type property_types Appended if absent
freeform_notes notes Always appended as “From browsing session:”

Baths, sqft, lot size and features are not copied to the client today. The same notes also land on the lead’s notes field so they are visible without opening the client. A note written through the thread is part of freeform_notes, so it travels the same way.

The thread itself is linked to the lead (scribe_conversations.lead_id) on the same conversion, and when the form was sent from the card (source == "scribe") the last six spoken lines are appended to the lead’s notes under “From the Scribe thread:”, each prefixed Visitor: or with the brokerage’s Scribe name. The thread is not copied to the client record.