An AI agent for leasing offices that takes a renter from a plain-English search all the way to a screened, booked viewing — matching, automated tenant pre-qualification (income multiple, credit, eviction history), application intake, and scheduling — then hands off to the leasing office.
Inspired by conversational housing startups (e.g. Verdant): the renter just describes what they want and the agent drives the rest, while every screening decision stays deterministic and auditable.
renter (chat) ──▶ agent interviews + searches ──▶ ranked listings
──▶ pre-qualifies against the unit's requirements
──▶ registers the application
──▶ books a viewing slot
──▶ landlord sees the applicant + viewing in their inbox
The LLM (a free local model via Ollama) owns understanding and conversation; deterministic code owns everything that must be correct — which units match, whether an applicant qualifies (income multiple, credit, evictions), and slot booking. The model never decides money or eligibility.
Each turn, the model returns a small validated JSON object (profile updates + one action); the server executes that action against the store and appends the true outcome. Guardrails against weak-model mistakes are built in:
- Move-in dates are a soft signal, not a hard filter (small models emit wrong years) — so a date slip never wipes the results.
- Unambiguous verbs ("book a viewing") deterministically override the model's action choice.
- Listing ids named in a message (or from the last search) resolve the target
even when the model omits
listing_id. - Applications are de-duplicated per applicant+listing.
| File | Responsibility |
|---|---|
src/types.ts |
Domain model: Listing, RenterProfile, Application, Viewing, Session |
src/store.ts |
JSON-file persistence (the seam → Postgres) |
src/seed.ts |
Synthetic listings |
src/matching.ts |
Deterministic search + ranking, plus semantic (embedding) re-ranking |
src/embeddings.ts |
Text embeddings (Ollama, deterministic hashing fallback) |
src/prequal.ts |
Deterministic pre-qualification (hard eligibility rules) + advisory risk score |
src/riskScore.ts |
HTTP client for the risk-model serving endpoint (graceful fallback to null) |
src/scheduling.ts |
Deterministic slot booking + landlord handoff |
src/llm.ts |
OpenAI-compatible client + Zod-validated structured turn |
src/agent.ts |
Per-turn orchestrator (the conversation engine) |
src/server.ts |
Express API + static frontend |
public/index.html |
Renter chat UI (listing cards, booking) |
public/landlord.html |
Landlord: post a unit + applicant/viewing inbox |
ml/generate_dataset.py |
Synthetic historical applicant dataset (5,000 rows) |
ml/risk_model.py |
RiskModel wrapper (logistic regression / gradient boosting) |
ml/train_risk_model.py |
Train/test split, ROC-AUC/precision/recall evaluation, model selection, MLflow logging |
ml/eval_harness.py |
Standalone model-quality gate: fresh-seed re-evaluation + baseline-regression check |
ml/serve.py |
FastAPI inference endpoint: /score, /health, /monitoring (latency + drift) |
ml/Dockerfile |
Containerized model server (trains on first boot if no model is baked in) |
src/prequal.ts is a hard pass/fail gate (income multiple, credit floor,
eviction history). The ml/ model is a separate, complementary layer: among
applicants who already pass that gate, how risky are they really? It's
trained on a synthetic historical dataset (5,000 synthetic applicants — not
real tenant data) with a label generated from a logistic function of the
features plus noise, so the classes are realistically not perfectly
separable.
cd ml
pip install -r requirements.txt
python3 train_risk_model.py
python3 -m pytest tests/ -vReal, measured result on a held-out test set (25% split, stratified): logistic regression baseline reaches ROC-AUC 0.825, beating a gradient- boosted model (0.798) on this dataset size — consistent with the ground truth being generated from a near-linear logistic process. Tests assert the model actually learns real signal (AUC > 0.7 threshold) and gets directionality right (a clearly risky synthetic applicant scores higher than a clearly safe one), not just that the code runs.
Every train_risk_model.py run logs params, metrics, and the fitted model
to MLflow (local SQLite backend, ml/mlflow.db — no server required):
python3 train_risk_model.py
mlflow ui --backend-store-uri sqlite:///mlflow.db # inspect runs at http://localhost:5000The pytest suite above checks the code is correct — shapes, error
handling, directionality. eval_harness.py is a separate, standalone gate
that checks the model artifact on disk is actually good enough to ship: it
re-evaluates risk_model.joblib against a fresh synthetic dataset seeded
disjointly from any training run (so a model that merely memorized its
training sample can't pass by accident), then fails (non-zero exit) if
ROC-AUC/average-precision drop below fixed floors or regress more than a
set tolerance versus the last recorded baseline (ml/eval_baseline.json):
python3 eval_harness.py # evaluate the current model against the baseline
python3 eval_harness.py --update-baseline # after confirming a run passes, record it as the new baselineThis is the gate a CI pipeline (see roadmap) would run before letting a newly retrained model replace the deployed one.
A versioned FastAPI inference endpoint in front of risk_model.joblib:
cd ml
uvicorn serve:app --reload --port 8000
# or containerized:
docker build -t tenantiq-risk-model .
docker run -p 8000:8000 tenantiq-risk-model # trains a model on first boot if none is baked inPOST /score— real applicants in this demo are only interviewed for the fieldsprequal.tsalready collects (income, credit score, eviction history); a full credit-bureau-style feature set (late payments, employment length, debt-to-income, address history, savings) isn't available from a chat conversation. Fields the caller doesn't supply are imputed from the training population's mean (model_metadata.json, written bytrain_risk_model.py) and listed back inimputed_fields.GET /health— current model version + type.GET /monitoring— basic drift/latency monitoring over a rolling window of recent requests: p50/p95 latency, and a drift-flag rate (the fraction of requests where a supplied feature fell more than 3 standard deviations from the training distribution's mean for that feature).
src/riskScore.ts calls this endpoint from src/prequal.ts::prequalify()
as a purely advisory signal: the hard eligibility gate (income multiple,
credit floor, eviction history) is computed first and is entirely
unaffected by the model — prequalify() only attaches a risk_score field
(probability + tier) alongside qualified, and it's null whenever the
service is unreachable or the applicant hasn't given enough profile fields
to score, same graceful-degradation pattern as embeddings.ts/llm.ts.
Verified against the real running FastAPI service (not just mocks) — a
failing applicant still fails the gate even when the model calls them
low-risk, and a passing applicant still passes when the model is down.
ci.yml — every push/PR to main:
- TypeScript:
npm run build+npm test. - Python:
pytest ml/tests/(risk model, eval harness, serving app). - Builds the
ml/Dockerfileimage and smoke-tests it for real — runs the container, waits for/health, then hits/score— so a broken container fails CI, not just a broken unit test.
retrain-eval.yml — the automated retrain/eval loop, triggered on
changes to whatever defines the model (ml/generate_dataset.py,
ml/risk_model.py, ml/train_risk_model.py) or manually via
workflow_dispatch:
- Retrain (
train_risk_model.py). - Evaluate against the quality gate (
eval_harness.py) — a regression or a sub-floor metric fails the workflow here, before anything downstream runs. - On
mainonly, once the gate passes: record the new baseline and commitml/eval_baseline.jsonback to the repo ([skip ci], and scoped to a path the trigger above doesn't watch, so it can't recursively re-trigger itself). - Build the Docker image from the freshly retrained model and smoke-test
it the same way
ci.ymldoes — proving the newly retrained artifact actually builds and serves, not just that the code compiles.
Honest gap: step 4 stops at "builds and serves correctly in a container."
Actually pushing that image to a registry and rolling it out is a couple
more steps (docker/login-action + docker push) that need a registry and
credentials this demo repo doesn't have configured — the pipeline is ready
for that wiring, it just isn't pretending to have it.
searchListings (structured: budget, bedrooms, neighborhood, must-have
amenities) is deterministic but can't catch phrasing outside those fields —
"quiet tree-lined street" or "artsy vibe" don't map to any structured
attribute. searchListingsSemantic adds one more signal on top: it embeds
the renter's raw chat message and each candidate listing's blurb (title,
neighborhood, size, amenities) and adds their cosine similarity to the same
soft score searchListings computes — it's additive, not a replacement, and
hard constraints (budget/bedrooms/pets) still filter first.
Embeddings try a local Ollama model (nomic-embed-text by default) and
fall back to a deterministic feature-hashing embedding (the hashing trick —
no external calls, same vector for the same text every run) if Ollama isn't
reachable, so search degrades gracefully instead of breaking when no
embedding backend is up. All texts in one search are embedded through the
same path — Ollama vectors and fallback vectors are never mixed, since
their dimensions aren't comparable.
npm test # unit tests: fallback determinism, cosine similarity, hard
# constraints still applied, semantic ranking actually reorders
# results toward the listing that matches the free-text query- Language/runtime: TypeScript, Node.js
- API: Express
- LLM integration: OpenAI-compatible chat completions (local Ollama or hosted), structured/validated model output via Zod
- Applied ML/AI engineering patterns: agent orchestration (per-turn tool/action selection), guardrails against unreliable model output, deterministic business logic kept separate from LLM reasoning
- Classical ML: scikit-learn (logistic regression, gradient boosting), train/test evaluation, ROC-AUC/precision/recall, feature engineering
- MLOps: MLflow experiment tracking (params/metrics/model artifacts), a standalone model-quality eval harness with fresh-seed re-evaluation and baseline-regression gating, FastAPI model serving with request-level latency/drift monitoring, Docker containerization
- Semantic search: text embeddings (Ollama, with a deterministic hashing-trick fallback), cosine similarity re-ranking
- Persistence: JSON store with an explicit seam to swap in Postgres
Requires a running LLM endpoint. Defaults to local Ollama (free, offline):
# once: install ollama, then
ollama serve &
ollama pull qwen2.5:3b
npm install
npm run build
npm test # unit tests (matching, embeddings, prequal) — no LLM/model server required
npm run seed # load synthetic listings
npm start # http://localhost:3000- Renter chat: http://localhost:3000/
- Landlord: http://localhost:3000/landlord.html
Optional: run the risk-model server (cd ml && uvicorn serve:app --port 8000,
or the Docker image) so pre-qualification includes the advisory risk score —
the chat works fine without it, it just omits that one field.
Try in the renter chat: "2 bed under $3200 near the Mission, I have a dog, moving Aug 1" → click Check if I qualify → answer income/credit → click Book a viewing. Then open the landlord portal to see the applicant + viewing.
Any OpenAI-compatible endpoint works — just set env vars:
# Groq free tier (stronger reasoning than a local 3B)
export OPENAI_BASE_URL=https://api.groq.com/openai/v1
export OPENAI_API_KEY=gsk_...
export OPENAI_MODEL=llama-3.3-70b-versatile
npm start- Landlord-side agent (screen/rank applicants, auto-reply).
- Real listing sources + geocoding/commute-time matching.
- Auth + per-user sessions; document upload for verification.
- Swap the JSON store for Postgres; move slot booking to real calendars.
- A second model pass to phrase results (currently a deterministic appendix).
- Push the retrained-model image to a real container registry and roll it
out (
retrain-eval.ymlalready builds and smoke-tests it — this is the registry/credentials wiring described above, not a missing capability).