Real estate listing writer with fair housing checker
Python AI assistant that turns bullets and photos into listings in three tones and flags fair housing issues before publishing.
pythonfastapipydanticanthropicpytestuv
Built 1 time, downloaded 0 times.
Install from your terminal
Run this in your project folder. It signs you in, shows what changes and asks before it overwrites a file.
npx claude-lab add real-estate-listing-writer-with-fair-housing-checker-fd9ee5CLAUDE.md# Real estate listing writer
## Purpose
Agents give bullets and photos. The app returns three listing drafts (professional, warm, punchy). Each draft gets a fair housing check before it can be published. The check has a deterministic layer and an LLM review layer. The checker is advisory support, not legal advice.
## Stack
- Python 3.12, FastAPI, Pydantic v2
- Anthropic Python SDK
- pytest, ruff, mypy
- uv for dependencies
## Layout
- `src/listings/api.py`: FastAPI app
- `src/listings/generator.py`: bullets to three tones
- `src/listings/vision.py`: photo resize and captions
- `src/listings/fairhousing/rules.py` and `checker.py`: rule checks
- `src/listings/prompts/`: tone prompt files
- `data/rules/fair_housing_terms.yaml`: term and pattern list
- `tests/`, `tests/fixtures/` (sample bullets, small sample photos)
## Commands
- `uv sync`
- `uv run pytest -q`
- `uv run ruff check .`
- `uv run ruff format .`
- `uv run mypy src`
- `uv run uvicorn listings.api:app --reload`
- `uv run python -m listings.cli sample`
## Rules
- A listing is never publishable unless the fair housing check ran on the exact final text. Any edit invalidates the prior check.
- Checking runs in two layers. First, deterministic terms and patterns for protected classes: race, color, religion, sex, disability, familial status, national origin, plus state extras. Then an LLM contextual review that returns structured JSON. Either layer failing blocks publish.
- Output and UI must say the checker is not legal advice. Never claim a listing is compliant. Only say no issues were found.
- Describe the property, not the people. No steering language such as "perfect for young families" or "walking distance to church". The word "master" is flagged for review per the rules file.
- Only describe what is in the bullets or visible in photos. Never invent square footage, school ratings, or neighborhood demographics.
## Models and secrets
- Model names live in `config.py` only, never in prompts.
- Use the small model for the LLM compliance pass and photo captions. Use the default model for writing.
- `ANTHROPIC_API_KEY` comes from the environment or `.env`. `.env` is gitignored.
- `.env.example` uses `YOUR_API_KEY`.
- Never log photo contents or agent client names.
## Gotchas
- Resize images to 1568px on the long edge, then send them as base64.
- Validate structured output with Pydantic. Retry once on parse failure, then fail loudly.
## Testing
- Unit tests mock LLM calls with recorded fixtures.
- Real API calls are not allowed in tests.
- One optional live test is marked `@pytest.mark.live` and is skipped by default.
## Conventions
- Type hints everywhere.
- Pure functions for rules. No global state.
- Small modules.
## Workflow
- Explore, plan, code, then run the verify skill before saying done.
- Use `/clear` between unrelated tasks.
@REVIEW.md
.claude/agents/test-runner.md---
name: test-runner
description: Runs pytest, ruff and mypy for the listing assistant and returns only the failures. Use after code changes when the main session should stay out of long test output.
model: haiku
tools: Bash, Read, Grep
disallowedTools: Edit, Write
---
You run the project's checks and report only what failed. You never edit files.
Run these commands from the project root, in this order:
1. `uv run ruff check .`
2. `uv run ruff format --check .`
3. `uv run mypy src`
4. `uv run pytest -q`
Run every command even if an earlier one fails, so the report covers all failures.
For each failure, return:
- The failing test name, or the ruff rule or mypy error code and file.
- The key error line, quoted exactly.
- The suspected source file, found by reading the traceback or using Grep. Use Read only to confirm the file and line.
Do not return passing output, full logs, or stack traces beyond the key line. If every command passes, reply with one line: `All checks passed: ruff, format, mypy, pytest.`
Do not suggest fixes beyond naming the suspected file. Do not run tests marked `live`, since they cost money.
.claude/settings.json{
"permissions": {
"allow": [
"Bash(uv run pytest:*)",
"Bash(uv run ruff:*)",
"Bash(uv run mypy:*)",
"Bash(uv sync)",
"Bash(git status)",
"Bash(git diff:*)",
"Bash(git log:*)"
],
"ask": [
"Bash(git push:*)",
"Bash(uv add:*)",
"Bash(pip install:*)"
],
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Bash(rm -rf:*)",
"Bash(curl:*| sh)"
],
"blockWritesOutsideWorkingDirectories": true
},
"env": {
"CLAUDE_CODE_SUBAGENT_MODEL": "haiku"
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *.py) uv run ruff format \"$f\" ;; esac"
}
]
}
]
}
}
.claude/skills/fair-housing-rules/SKILL.md---
name: fair-housing-rules
description: Guides editing the fair housing term list and checker so rules, tests and severity stay consistent when flagging listing wording.
paths:
- src/listings/fairhousing/**
- data/rules/**
---
# Fair housing rules
Use this skill when changing `data/rules/fair_housing_terms.yaml`, `src/listings/fairhousing/rules.py`, or `src/listings/fairhousing/checker.py`. The checker is advisory support, not legal advice. Write every reason and suggestion in neutral, factual terms.
## Federal protected classes
The deterministic layer covers these classes:
- race
- color
- religion
- sex
- disability
- familial status
- national origin
The rules file may add state extras. Mark each state extra with its source in the `reason` field.
## Typical wording to flag
- **Steering:** language that prefers or excludes a group, such as "perfect for young families" or "ideal for single professionals."
- **Familial status:** limits on children or adults, such as "adults only" or "no kids."
- **Disability:** wording that discourages people with disabilities, such as "able-bodied residents preferred."
- **Religion:** references that signal a preference, such as "walking distance to church." Flag the phrase for review. Do not treat the building itself as the problem.
- **Review-level terms:** words that can be innocent but need a human look. The rules file currently includes "master" for this reason.
Flagged wording is about the people a listing attracts or excludes, not the property. Describe the property instead.
## Adding a term
1. Add one entry to `data/rules/fair_housing_terms.yaml` with these fields:
- `pattern`: the phrase or regex
- `category`: one of the protected classes or a steering category
- `severity`: `blocked` or `needs_review`
- `reason`: one sentence explaining the concern
- `suggestion`: a neutral property-focused rewrite
2. Add one failing test (bad text is caught, with the exact quoted span) in `tests/test_fairhousing.py`.
3. Add one passing test (clean text is not flagged).
4. Run the `verify` skill.
## Severity
- `blocked`: the text cannot be published until it is rewritten. Use this for clear violations.
- `needs_review`: a person must confirm the wording. Use this for ambiguous terms.
Any `blocked` finding sets the report status to `blocked`. Any `needs_review` finding with no blocked findings sets it to `needs_review`. Otherwise the status is `pass`.
## Handling false positives
Do not delete a rule because it over-flags. Instead, add allowlist context to the entry, such as a context phrase that shows the term describes a physical feature. Add a clean test that uses the allowed context, and keep the bad test that shows the rule still fires without it.
## Reminders
- The list is a starting point, not a complete compliance system.
- The user should have a compliance professional review the rules file before publishing listings.
- Every category needs both a failing test and a passing test.
.claude/skills/listing-prompts/SKILL.md---
name: listing-prompts
description: Guides editing tone prompt files in src/listings/prompts/ so the professional, warm and punchy drafts stay consistent and grounded in the inputs.
when_to_use: Use when changing any prompt file, tone definition, or generation constraint.
---
# Listing prompts
Prompt files live in `src/listings/prompts/`. Each tone has its own file. Shared constraints apply to all three tones. The model writes the drafts; the fair housing checker reviews them afterward, so prompts should avoid the problems the checker flags.
## The three tones
**Professional**: measured, factual, and concise.
> Three-bedroom ranch with an updated kitchen and a fenced backyard. Hardwood floors run throughout the main level.
**Warm**: friendly and inviting, still tied to the property.
> Come see this sunny three-bedroom ranch, where the kitchen was recently updated and the backyard is fenced for a garden or a dog.
**Punchy**: short sentences with energy, no invented claims.
> Updated kitchen. Fenced backyard. Hardwood throughout the main floor.
## Shared constraints
Every prompt must instruct the model to:
- write 120 to 180 words per body
- use only facts from the bullets or the visible photo captions
- avoid protected-class and steering language (see the `fair-housing-rules` skill)
- avoid superlatives that cannot be proven, such as "best" or "most desirable"
- avoid inventing square footage, school ratings, or neighborhood demographics
- return output in the structured format the Pydantic model expects
Keep these constraints in one shared section that each tone file includes. Do not copy them into three separate places.
## Editing a prompt
1. Edit the file in `src/listings/prompts/`. Keep the model name out of the prompt. Model names come from `config.py`.
2. Keep each tone's voice distinct. A change to one tone should not alter the others.
3. Update or add prompt fixture tests. Run them with `uv run pytest -q -k prompt`.
## Checklist after any prompt change
1. Run the `verify` skill.
2. Generate three sample outputs from the fixtures in `tests/fixtures/`.
3. Read each output and check:
- word count is within 120 to 180 for the body
- every claim appears in the bullets or photo captions
- no protected-class or steering language
- no unprovable superlatives
4. Report any problems you find. Do not declare the change done until all three samples pass the check.
.claude/skills/verify/SKILL.md---
name: verify
description: Runs lint, format, type, test and smoke checks in a fixed order and reports pass or fail before any change is called done.
when_to_use: Use after any code, prompt, or rule change, before saying the work is done.
---
# Verify
Run this sequence every time. Do not skip steps.
## Steps in order
1. `uv run ruff check .`
2. `uv run ruff format --check .`
3. `uv run mypy src`
4. `uv run pytest -q`
5. Smoke run: `uv run python -m listings.cli sample`
6. Fair housing regression: `uv run pytest tests/test_fairhousing.py -q -rs`
The `-rs` flag lists any skipped tests, so skips are visible.
## Smoke run
Use the fixtures in `tests/fixtures/` with the mocked client. Check `uv run python -m listings.cli sample --help` for the mock flag name. The step passes only if the output shows:
- three drafts: professional, warm, and punchy
- a headline and body for each draft
- a compliance report with a status of `pass`, `needs_review`, or `blocked`
If any of these is missing, the step fails.
## Fair housing regression
This step must pass with zero skipped tests. Any skip counts as a failure. Do not add skip markers to turn it green.
## Report format
Give one line per step:
- PASS `uv run ruff check .`
- FAIL `uv run pytest -q`
For failing steps only, show the relevant output. Do not paste passing output.
## Rules
- If anything fails, fix it and rerun the whole sequence from step 1.
- Never say "done" on a partial pass.
- Do not run the live test. `uv run pytest -m live` calls the real API and costs money. Run it only when the user asks.
- The smoke run must use the mocked client. No real API calls in verification.
INSTALL.md# Install and first session
## Prerequisites
- Python 3.12
- uv
- git
- Claude Code 2.1.296 or newer (check with `claude --version`)
- An Anthropic API key
## What each file does
- CLAUDE.md: project memory Claude reads every session. Commands, rules, gotchas.
- SPEC.md: the starter spec you fill in before building.
- PLAN.md: six build phases, each ending in a /goal prompt.
- REVIEW.md: the checklist /code-review uses for this repo.
- INSTALL.md: this guide.
- .claude/settings.json: shared permissions and env defaults.
- .claude/skills/verify/SKILL.md: the check sequence Claude runs before saying done.
- .claude/skills/fair-housing-rules/SKILL.md: how to edit the rule list consistently.
- .claude/skills/listing-prompts/SKILL.md: how to edit the tone prompts.
- .claude/agents/test-runner.md: a cheap subagent that returns only test failures.
## Set up the project
1. Create a folder, then run `git init` and `uv init --python 3.12`.
2. Copy the generated files into the project root, including the .claude folder.
3. Copy `.env.example` to `.env`. Replace YOUR_API_KEY with your real key.
4. Open `.gitignore` and confirm `.env` is listed.
Claude Code is set not to read `.env`. Keep your key there and out of git.
## Install the plugins
```
claude plugin install code-review@claude-plugins-official
claude plugin install commit-commands@claude-plugins-official
claude plugin install security-guidance@claude-plugins-official
```
## Start the first session
1. Run `claude` in the project folder.
2. Fill in the open questions in SPEC.md, then ask Claude to refine it.
3. Start Phase 1 with the /goal prompt in PLAN.md. It stops after 25 turns and reports what remains.
4. After each phase, run the verify skill, then `/code-review`, then `/commit`.
5. Run `/clear` before starting the next phase.
## Why compliance checks are advisory
The checker finds known risky wording. It is not legal advice, and fair housing rules vary by state. A pass means the checker found no issues. It does not mean a listing is compliant. Have a compliance professional or your broker review the rule list and any listing before you publish it.
## Token tips
- `/clear` between unrelated tasks and between phases.
- `/compact` at natural breaks in a long task.
- `/usage` shows where your tokens went.
## Troubleshooting
- Skills not showing: run `/skill-doctor`. Confirm each SKILL.md sits in `.claude/skills/<name>/`.
- uv or mypy errors: run `uv sync` first, then rerun the failing command.
- Mocked versus live tests: normal runs use mocked calls and cost nothing. Run `uv run pytest -m live` only when you choose to spend API credits.
PLAN.md# Build plan
Build in six phases. Each phase ends with a `/goal` prompt. Run `/clear` before starting each phase. Use `ultrathink` only for the design decisions in Phase 2 and Phase 5.
## Phase 1: Skeleton and verify
Scope: uv project, FastAPI health route, config, pytest, ruff and mypy passing, fixtures folder.
Done when: the check command passes and the output is shown in the conversation.
```
/goal Phase 1 skeleton for the listing assistant. End state: a uv-managed Python 3.12 project with src/listings/ (api.py with GET /health returning {"status":"ok"}, config.py reading ANTHROPIC_API_KEY and model names from env with defaults, cli.py with a sample command that prints a placeholder), tests for the health route and config, tests/fixtures/ with 3 sample bullet sets as JSON, data/rules/ with .gitkeep, .env.example using YOUR_API_KEY, and .gitignore covering .env, .venv and __pycache__. Check: uv run pytest -q && uv run ruff check . && uv run mypy src, all passing, with the output shown. Constraints: no new dependencies without asking, no real API calls in tests, do not read or write .env, no destructive commands. Stop after 25 turns and report what remains.
```
## Phase 2: Fair housing checker
Scope: YAML rules in data/rules/, a deterministic checker, a structured report model, and tests for at least 30 phrases (bad and clean).
Done when: every rule category has a failing and a passing test, and every finding includes the quoted span.
Suggest `ultrathink` for the rule design decisions in this phase.
```
/goal Phase 2 fair housing checker. End state: data/rules/fair_housing_terms.yaml with fields pattern, category, severity, reason and suggestion; src/listings/fairhousing/rules.py and checker.py as pure functions; a Pydantic report model with status (pass, needs_review, blocked) and findings each carrying quoted_span, category, reason and suggested_rewrite; tests/test_fairhousing.py with at least 30 phrases, bad and clean, and at least one bad and one clean test per category covering race, color, religion, sex, disability, familial status, national origin and steering. Check: uv run pytest -q && uv run ruff check . && uv run mypy src, all passing, with the output shown. Constraints: no LLM calls, no new dependencies without asking, no real API calls in tests, no skipped tests. Stop after 40 turns and report what remains.
```
## Phase 3: Generation
Scope: bullets converted into three tones using prompt files, word-count limits, and mocked tests.
Done when: each tone produces a draft within 120 to 180 words from mocked model output.
```
/goal Phase 3 generation. End state: src/listings/generator.py produces three drafts (professional, warm, punchy) from bullets; prompt files live in src/listings/prompts/; each draft has a headline and a body between 120 and 180 words, enforced in code; model names come only from config.py; output is parsed with Pydantic and fails loudly after one retry. Tests in tests/ mock the Anthropic client using recorded fixtures and cover word-count limits and parse failure. Check: uv run pytest -q && uv run ruff check . && uv run mypy src, all passing, with the output shown. Constraints: prompts must forbid invented features and steering language, no real API calls in tests, no new dependencies without asking, do not log bullet or photo content. Stop after 40 turns and report what remains.
```
## Phase 4: Photo understanding
Scope: image resizing, a vision caption step, and a grounding rule so only visible facts are used.
Done when: photos are resized to 1568px on the long edge before the API call, and captions feed the generator only as visible facts.
```
/goal Phase 4 photo understanding. End state: src/listings/vision.py resizes each image to a maximum of 1568px on the long edge and sends it as base64; a caption step uses the small model from config.py and returns structured captions validated with Pydantic; the generator receives captions as grounded facts and the prompt says to use only what is visible or stated in the bullets. Tests use small generated test images and mocked client calls, with no real API calls. Check: uv run pytest -q && uv run ruff check . && uv run mypy src, all passing, with the output shown. Constraints: no new dependencies without asking, do not store photo contents in logs or fixtures beyond small generated images, no destructive commands. Stop after 40 turns and report what remains.
```
## Phase 5: LLM compliance pass and publish gate
Scope: contextual LLM review, an auto-rewrite loop capped at 2 attempts, and a publish endpoint that refuses unchecked or edited text.
Done when: the publish endpoint refuses any text whose hash does not match the last passing check.
Suggest `ultrathink` for the gate design in this phase.
```
/goal Phase 5 compliance pass and publish gate. End state: the small model from config.py runs a contextual fair housing review that returns structured JSON validated with Pydantic, and either that layer or the deterministic layer can block publish; an auto-rewrite loop runs at most 2 attempts; a publish endpoint in api.py refuses any text whose hash does not match the text of the last passing check, and refuses text that was never checked; any edit invalidates the prior check. Tests cover blocked, needs_review and pass paths using mocked clients. Check: uv run pytest -q && uv run ruff check . && uv run mypy src, all passing, with the output shown. Constraints: no real API calls in tests, no new dependencies without asking, the output must state that the check is advisory and not legal advice, no destructive commands. Stop after 40 turns and report what remains.
```
## Phase 6: Simple UI and polish
Scope: a single-page form or CLI, disclaimer text, README, and an optional live test.
Done when: a user can enter bullets and see three drafts with reports, and the disclaimer appears on every output.
```
/goal Phase 6 UI and polish. End state: a single-page form or a CLI flow that accepts bullets and photos, shows three drafts with their compliance reports, and displays the not-legal-advice disclaimer on every output; README.md explains setup and commands; one test marked @pytest.mark.live exists and is skipped by default. Check: uv run pytest -q && uv run ruff check . && uv run mypy src, all passing, with the output shown, and the sample command runs end to end with mocked clients. Constraints: no new dependencies without asking, never claim a listing is compliant, no real API calls in the default test run. Stop after 30 turns and report what remains.
```
## After the build
- Run `/verify` before each commit.
- Run `/code-review` against REVIEW.md, then commit with `/commit`.
- Use `/usage` to see where tokens went at the end of each phase.
REVIEW.md# Review checklist
Read by /code-review through the import in CLAUDE.md. Keep findings short and cite the file and line.
## Block on
- Any path that publishes text without a matching compliance check of the final text.
- Secrets, API keys, client names, or photo data in logs, fixtures, or commits.
## Check
- Deterministic rules have both a failing and a passing test for each category.
- LLM output is parsed through Pydantic with a failure path. No bare json.loads.
- Prompts do not tell the model to invent features. Generated text stays grounded in the bullets and visible photos.
- No hardcoded model names outside src/listings/config.py.
- User-facing text includes the not-legal-advice disclaimer.
- Edited listing text clears any prior compliance result.
- Tests make no real API calls. Only the @pytest.mark.live test may call the API.
## Skip these nitpicks
- Formatting and import order. Ruff handles them.
SPEC.md# Listing writer spec
## Problem and user
Solo or small-team real estate agents write listings by hand. This tool turns rough bullets and photos into listing drafts. It checks each draft for fair housing wording before anything is published.
## Inputs
- 3 to 10 bullets describing the property
- 1 to 12 photos
- Optional: property type and price band
## Outputs
- Three drafts: professional, warm, punchy
- Each draft has a headline and a body of 120 to 180 words
- Each draft has a compliance report
## Compliance report
- `status`: pass, needs_review, or blocked
- `findings`: a list where each item has:
- `quoted_span`: the exact text that was flagged
- `category`: the protected class or rule category
- `reason`: a plain-language explanation
- `suggested_rewrite`: a safer replacement
- The report is advisory. It is not legal advice.
## Acceptance criteria
- Known-bad phrases in `tests/fixtures/` are always caught.
- Clean text passes.
- Every finding includes the exact quoted text.
- Publish is refused for any text that was not checked in its final form.
## Non-goals (v1)
- MLS posting integration
- Legal advice
- User accounts
- Multi-language output
## Open questions for you
- Which state fair housing rules should be included beyond federal rules?
- Which MLS length limits apply to your board?
- Should HEIC photos be accepted?
## Risks
- False negatives on subtle steering language
- Hallucinated features from photos
- Over-flagging that frustrates agents