An AI relocation assistant where the model talks and the scorer decides.
A chat-first relocation assistant for corporate transferees, built for Sterling Lexicon. It turns a conversation into sourced program answers, an explainable neighborhood ranking, and a working plan, and it withholds whatever the evidence cannot support.
I built it solo over about three and a half months: the Angular front end, the .NET API, the AI pipeline, the scorer, the geospatial measurement tooling, and the consulting artifacts that went with it. The whole thing rests on one rule, and most of what is interesting here follows from it. Language models interpret and explain. Named sources establish facts. Deterministic code ranks. Humans take the rest.
Sterling Lexicon relocates employees for corporate clients. Somebody accepting a move usually knows their destination city and their allowance. They do not know where to live, what their program actually covers, what the move will cost them, or what has to happen before the truck arrives.
That knowledge sits with human relocation consultants, who spend a large share of their time answering the same twenty questions. The product handles the repeatable part and hands everything else to a person with the context already attached.
Intake is six sections asked as a conversation, with structured inputs sitting inside the chat bubbles so answers arrive typed rather than parsed. Free text is welcome at any point. It answers whole cards ahead of their turn and the flow catches up instead of asking again.
Program questions get answered only when a passage in the corpus supports the answer, and the source is named underneath. Citing is an explicit act rather than an inference: the source line appears only when the model calls the citation tool with a valid passage id, and an unknown id gets back "No passage with that id. Do not cite it."
Everything confirmed lands in a live plan: facts, preferences, open items, matched services, and a setup checklist chosen from a 23-task catalogue and ordered by lead time. Visa before lease. Pet import earliest, because it takes months.
Neighborhood recommendations are the product's core promise and its largest liability. Ask a language model to rank neighborhoods and it will do it fluently, differently each time, and with no answer to the question "why did families with children see different areas?" Fair-housing law does not care that the steering was statistical.
So I wrote a one-directional contract into a binding design doc. The scorer decides the order. The model describes the order it was given. It cannot reorder, add, drop or introduce a factor, and anything outside the scorer's payload is discarded.
The exclusion is structural rather than promised. The scoring request type cannot carry a protected characteristic, so protected-proxy data is not an input that has been filtered out, it is an input that has nowhere to go. Absent data stays null with a coverage figure attached instead of becoming a fabricated neutral score, because zeroing it would rank a thin-data city below a well-known one for reasons that have nothing to do with the city.
The constraints are tests rather than prose. No factor may score something intake does not ask everybody about. No interest may be named after the people who might live there. Crime is displayable on request and can never become a scored factor, and the test that says so is named Crime_is_shown_on_request_and_can_never_become_a_scored_factor.
A pair of drift tests parse the Angular source from C# and assert that the UI's option lists and the tool schemas match in both directions, across nine properties. Two languages, one vocabulary, and it cannot fork quietly.
What the transferee sees is exactly what the contract promised, "AI-generated neighborhood recommendations". What changed is internal: an auditable ranking with inputs and weights, where a complaint would otherwise be answered with "the model thought it seemed relevant". That is not a defence.
Why the boundary is where it isIf a factor cannot be measured well enough to publish, it does not publish. There is no review queue anywhere in the system, by design, because a review queue is how unresolved work reaches a client anyway.
"Distance to the nearest beach" sounds like one query. It is three unsolved problems. Polygons tagged as beach include river sand and geometry nobody can reach, the nearest point on a polygon is often somewhere you cannot route to, and establishing whether sand faces open water is genuinely hard. The rebuilt pipeline splits what a destination is from where you route to it: a validated beach anchor, and an access point that is legitimately inland, like a car park or a boardwalk.
That grew into a city compiler. Expensive geography runs per physical beach rather than per entrance, which in Chicago collapses 135 access points into 44 entities. Twelve benchmark cities include deliberate hard cases, and Austin is in there because Austin should produce nothing at all.
Each dataset had to earn its place. Waterbody classification was benchmarked against three sources with a baseline run before each addition, and two were demoted when they did not resolve anything the others had not. One claim about Lisbon was withdrawn in writing. Registry parity against an independently written verifier is 11 of 11, and the code labels it "parity, not truth", because both sides read the same approved list.
An A/B test comparing the pipeline against model estimates flagged disagreements too large to explain. Every distance in the system had been measured on swapped coordinate axes. The database was already configured to read longitude and latitude in the right order, and an extra flip put it back. The numbers had looked believable for weeks.
The fix was one shared spatial kernel that owns axis order and the distance macros, self-verifying with known-distance assertions before it hands back a connection. One of those assertions sits at 60 degrees north on purpose, where the latitude to longitude ratio is two to one, so rounding cannot hide a flip. The regression test reinstalls the buggy macro and proves the guard rejects it. Then I re-ran every fixture and threw away every conclusion that predated the fix. One published distance had been 11.32 km against an actual 34.06 km.
The bug had travelled by copy and paste through six files, which is the reason no tool defines its own distance macro any more.
The Google integration looked costly at scale and the obvious suspects were the exotic APIs. Nobody had traced what the code actually called, so I modelled monthly cost from call sites rather than from the price sheet, per scenario and per lifecycle stage.
The dominant cost was geocoding. The same workplaces and areas were geocoded again and again across reranks and services, sometimes through a text search whose location lookup bills at $32 per thousand where plain geocoding costs $5. The fixes were unglamorous: geocode first with text search as the fallback for business names, one shared cache keyed on normalised text, coordinates reused across the amenity and commute paths, and a singleton client, because the framework's default registration would have given every consumer its own private cache.
At the 20-city scenario the model went from $545.60 to $40 a month. At 50 cities, from $4,244 to $500. A warm repeat ranking makes no Google calls at all. Two details I would defend in review: the report separates per-user, per-city, per-office and per-restart costs instead of blending them into one number, and when Google changed prices the analysis was redone and the old headline was demoted to a dated historical figure rather than quietly kept.
Mid-project the client suggested dropping the structured data sources for general web search, on cost and schedule grounds. It was a reasonable-sounding idea, and the kind of argument that can be lost by winning it rudely.
I measured instead. Asked to reproduce the system's own numbers, web search returned 0 of 171 amenity counts, 9 of 19 commute times, and rents a median 20.9% off the official figures, citing a listings site nine times and a government source once across 27 citations. The cost model was built honestly enough to concede the other side's point: cached web search also reaches zero marginal cost per transferee, and is actually cheaper on the area sweep. Cost was never the argument. Coverage, provenance and testability were.
It went into a plain-language brief with the jargon removed, three scenarios, and a recommendation that avoided dogma. Keep the split for the first release, review one provider at a time after the pilot, and no provider gets a lifetime exemption. A supporting audit quantified what keeping the APIs actually costs to maintain: 506 provider-specific lines out of 2,678 measured, 10 wire-contract tests, and zero observed breaking changes, with the caveat written into the file that zero is not a rate on a three-week-old integration.
Sterling needs to know what transferees ask for and where in the journey the questions arrive. The obvious implementation is an events table keyed to users, which in a small relocation cohort is a re-identification kit. Destination plus household size plus a date is close enough to a name.
So anonymity is structural. A signal row is one fact, and there is no key column to join on. Time coarsens to the month on the server, because "Austin, band 2, July" is an aggregate and "Austin, band 2, the 31st" is close to a fingerprint. Budget becomes a position on one of eight currency-specific ladders, derived server-side, because client labels cannot be trusted and kronor cannot be added to dollars.
Free-text questions are classified into 14 topics by a separate model call. The question text never leaves the process and is never stored, and a classification outside the closed list becomes null rather than "other", so an unusable question is counted without being recorded.
Two guards are aimed at future developers rather than at attackers. Required-yet-nullable columns force every construction site to decide the value, which exists because a bug once bucketed everything as "other". Column widths are capped so free text does not fit. Analytics runs fire-and-forget on its own scope and always answers 202, because measurement must never break the thing it measures.
The UI drew a connector rail from assistant replies down to the plan cards they had populated. Some of those cards the transferee had filled in themselves. Separately, free-text preferences looked acknowledged but never reached the ranking. Both are the same defect: an interface claiming more intelligence than exists.
Attribution became explicit state. A set of derived cards is written only when the model writes answers, persists across reload, and is what the rail consults. There is a regression test named "draws no rail to a card the transferee answered themselves".
The free-text path was rebuilt end to end. A server-side reader maps prose onto the scorer's own vocabulary, and its prompt is generated from the scorer's factor list so the two cannot diverge. It splits compounds like "coffee shops and beach" into recognised factors and returns the remainder as unmatched, and the UI renders unmatched phrases as visibly unranked. Typed and ticked preferences merge into one set and weigh the same, because somebody who wrote "near the art scene" asked for it exactly as much as somebody who ticked a box.
The same standard forced smaller corrections everywhere. Placeholder commute and cycling numbers were deleted rather than left looking plausible. The amenity radius was widened to match its label instead of the label being softened to match the radius, because the label would have overstated the catchment by 1.8 times. Offline mode announces itself as "answering from a limited script, so it will miss things" instead of impersonating the assistant.
Almost none of the hardest bugs here announced themselves. They returned plausible numbers or exited zero. That shaped how the whole system is built, so this is the list I would actually want to be asked about.
Believable for weeks. Now one shared kernel self-verifies with known-distance assertions before returning a connection, including one at 60 degrees north where rounding cannot hide a flip.
A silently missing dataset rejected all 20 Chicago beaches. Branch canaries now prove each dataset is live against a fact that cannot be true by accident.
A source-text parse quietly lost every Jacksonville case and produced a believable 17-case run. The harness now refuses to run unless it sees exactly the expected count and ids.
Health is now split from the model. The configuration check never calls the model, and a separate canary makes one real call behind a semaphore and a 10-minute verdict cache.
A stale assertion outlived the numbers it guarded. The tool now asserts its own headline figures after printing them, so a drifted line range fails loudly.
One representative point can sit in a dead zone or reach across a boundary. A canary compares the count around the point against the count inside the area's own boundary and classifies the pair, so starved areas are withheld and dropped before ranking rather than distorting everyone else's percentile.
The general answer was to make wrongness expensive to hide: typed status enums, execution status separated from validation status, atomic artifacts written to a partial name and then renamed, strict expected failures that must keep failing, independent re-implementations sharing no code, and self-tests that reinstall a known-bad configuration to prove the guard still rejects it.
One of those strict expected failures is a known defect I chose not to fix. The conservative version halved coverage in two cities, so it was reverted with the measurements left in the file, and the reverted constants are still there so the next attempt starts from the measured collapse rather than from scratch.
This is a substantial pre-production MVP, not a launched product, and the delivery tracker only ticks a box when something works end to end.
Working: the Angular SPA, the .NET API, the AI pipeline, the deterministic scorer, the session store, the analytics, and the measurement tooling. Blocked, mostly on client dependencies: Azure hosting, Auth0, the profile integration, server-side PDF export and email, and security sign-off. PDF today is the browser's own print, and mail never leaves the app.
Two caveats belong on any honest description of it. The program corpus is placeholder content, so the architecture around it is real and the corpus is not. The seeded neighborhood lists were hand-typed from memory, which the data file itself discloses, and a later probe against a real neighborhood dataset agreed with six of the eight Austin entries. The two that disagreed are the instructive ones: a commercial district and a park were being used where a residential neighborhood name belongs.
Two more gaps surfaced while I was reviewing the repo to write this up. The retention sweeper is implemented and tested but never registered as a hosted service, so it does not run. The analytics endpoint mishandles the no-database mode. Both are recorded rather than quietly fixed in the write-up.
Angular 22 on the front end, zoneless, standalone components, signals for state and OnPush everywhere, with no component library, no state-management library and no CSS framework. Design tokens across nine CSS files and a hand-rolled UI kit of 18 components. Server-sent events are consumed with raw fetch rather than the HTTP client, because the client buffers the whole body, which is the one thing streaming must not do.
The API is .NET 8 minimal APIs with no controllers, EF Core on SQL Server, and three NuGet packages in total. The assistant runs on the OpenAI Responses API with a closed set of four function tools and no web search, streamed over hand-rolled SSE with an explicit flush per frame. Token use is metered on every call. The measurement platform is Python over DuckDB Spatial and GeoParquet, reading Overture Maps, shoreline and hydrology datasets, a population raster, HUD fair-market rents, the Census geocoder and city open data, with four Google APIs for amenities and commute times.
Built for Sterling Lexicon. Screenshots show the development build with placeholder program content and seeded measurement data.