# IMPLEMENTATION.md — Phase 2 build runbook How to drive Phase 2 (the 12 steps in `PRD.md` §29) to completion using an AI agent. Each step has its own kickoff prompt, files affected, and "done" criteria. Run them in order. Each step is independently mergeable. --- ## 0. Pre-flight Before starting step 1: 1. ls -la 2. **Venv is healthy.** From the project root: ```bash source .venv/bin/activate pytest -x # green except for any pre-existing FastMCP-related skips ruff check . # zero issues ``` 3. **Docs are in place.** Re-confirm `PRD.md` §17 (code ownership) is current — every step below references it. If any of these fail, stop and fix before proceeding. --- ## How to use this runbook For each step: 1. Create a feature branch: `git checkout -b feat/phase2-step--` off `main`. 2. Open a fresh agent chat with repo access. Paste the kickoff prompt verbatim. 3. Let the agent propose, implement, and test. Push back where it skips tests or violates §17. 4. When all "done" boxes are checked, merge into `main`. 5. Move to the next step. Each kickoff prompt assumes the agent reads PRD.md, AGENTS.md, and the relevant instruction files first — that's encoded in the prompt. After step 12, merge `main` into `main`. --- ## Step 1 — Dev workflow already switched to local venv This step is **done** by the time `CLEANUP.md` is merged. The instruction files and `AGENTS.md` already use local venv. Sanity check: ```bash source .venv/bin/activate which finn-eiendom 2>/dev/null || echo "expected: not yet installed; entry points come in steps 6 and 7" ruff check . # zero issues pytest -x # green (allow mcp_server failures) ``` Move on. --- ## Step 2 — Pydantic v2 cleanup ### Kickoff prompt > Read **PRD.md** (especially §17 code ownership and A8 acceptance criterion), **`.github/instructions/python.instructions.md`**, and **`.github/instructions/clean-code.instructions.md`**. > > Implement Phase 2 step 2: convert every Pydantic model in `finn_eiendom/models.py` from v1 (`class Config:`) to v2 (`model_config = ConfigDict(...)`). Use `context7:query-docs` on `pydantic/pydantic` if you're not sure of the v2 syntax — don't guess. > > Add `tests/test_models.py` with a JSON roundtrip test per model. > > Run `ruff check .`, `ruff format .`, and `pytest tests/test_models.py -v` before declaring done. ### Files * `finn_eiendom/models.py` (edit) * `tests/test_models.py` (new) ### Done when * `grep -rn "class Config:" finn_eiendom/` produces zero output. * `pytest tests/test_models.py` is green. * Existing tests still pass. --- ## Step 3 — Service layer ### Kickoff prompt > Read **PRD.md** §16 (Service layer) and §17 (code ownership), **`.github/instructions/python.instructions.md`** and **`.github/instructions/clean-code.instructions.md`**. > > Create `finn_eiendom/service.py` with the public surface listed in PRD §16: `get_or_fetch_ad`, `get_or_fetch_eiendom_unit`, `get_or_fetch_similar_units`, `analyze_search`, `analyze_ad`, `analyze_ad_against_comps`, `find_similar_to_liked`, `compare_ads`, `resolve_eiendom_unit_from_finn_url`, `build_unit_vector_for_unit_code`, `decode_unit_vector_to_dict`, `save_feedback`, `get_shortlist`, `get_new_ads_since_last_run`. > > Each function: > 1. Opens its own SQLite connection via `cache.init_db(FINN_CACHE_PATH)`. > 2. Reads cache first with TTLs from `config.py`. > 3. On miss or `force_refresh=True`, calls the fetcher in `ad.py` / `eiendom_no.py`. > 4. Writes the fresh result back. > 5. Returns a typed model or dict. > > Do not duplicate behavior from `analysis.py` — delegate to it. Add `tests/test_service.py` covering the five service tests listed in PRD §25.2. ### Files * `finn_eiendom/service.py` (new) * `tests/test_service.py` (new) * `tests/conftest.py` (may need a `tmp_db` fixture if it doesn't exist) ### Done when * `pytest tests/test_service.py` is green. * `service.py` imports only from `models`, `config`, `cache`, `analysis`, `ad`, `eiendom_no`, `feedback`, `scoring`, stdlib. * No `import httpx` or `import sqlite3` outside their owners. --- ## Step 4 — Formatting layer ### Kickoff prompt > Read **PRD.md** §17.6 (shared formatting module) and §19 (output formats), **`.github/instructions/clean-code.instructions.md`**. > > Create `finn_eiendom/formatting.py` with these renderers (signatures in PRD §17.6): `render_ad`, `render_shortlist`, `render_comparison`, `render_diff`, `render_similar_units`, `render_unit`, `render_score_breakdown`, plus `render_cache_stats` for the CLI cache subcommand. > > Each renderer accepts `(payload, fmt: Literal["json","markdown","table"]) -> str`. Unsupported formats raise `ValueError` listing supported options. Table rendering only applies where it makes sense (shortlist, comparison, diff, similar-units). > > Add `tests/test_formatting.py` covering the three tests listed in PRD §25.5. ### Files * `finn_eiendom/formatting.py` (new) * `tests/test_formatting.py` (new) ### Done when * `pytest tests/test_formatting.py` is green. * `render_*` is the *only* place that formats output. No inline rendering anywhere else (verified by reading diffs of steps 6 and 7). --- ## Step 5 — HTTP retry on 5xx ### Kickoff prompt > Read **PRD.md** A9 (acceptance criterion), **`.github/instructions/python.instructions.md`**. > > Extend `HTTPClient.get()` in `finn_eiendom/http.py` to retry on 5xx responses (500/502/503/504) with exponential backoff `1s, 2s, 4s`, up to `retries` attempts (default 3). Surface 4xx as `httpx.HTTPStatusError` immediately. Apply the existing `request_delay` between any two calls. > > If you're unsure about `httpx` retry semantics or `respx` test patterns, use `context7`. > > Add `tests/test_http.py` covering the three tests listed in PRD §25.6 using `respx`. ### Files * `finn_eiendom/http.py` (edit) * `tests/test_http.py` (new) ### Done when * `pytest tests/test_http.py` is green. * `httpx` imports remain confined to `http.py`. --- ## Step 6 — Replace FastAPI with FastMCP ### Kickoff prompt > Read **PRD.md** §14 (MCP design — every tool and input schema), §17 (code ownership), and **`.github/instructions/mcp.instructions.md`** end-to-end. > > Rewrite `finn_eiendom/mcp_server.py` from scratch: > - Use `from mcp.server.fastmcp import FastMCP`. > - Configure stderr-only logging. > - Register all 14 tools listed in PRD §14.1 with the `finn_` prefix. > - Each tool body has the shape in `mcp.instructions.md` §"Tool body shape": one `service.` call, one `formatting.render_*` call, try/except returning the JSON error envelope. > - Input schemas as in PRD §14.2. > - Annotations: `readOnlyHint=True` for all except `finn_save_feedback`. > - `main()` calls `mcp.run(transport="stdio")`. > - Add `finn-eiendom-mcp = "finn_eiendom.mcp_server:main"` to `[project.scripts]` in `pyproject.toml`. > > If unsure about FastMCP annotations or transport options, use `context7:query-docs` on the MCP Python SDK. > > Rewrite `tests/test_mcp_server.py` to cover the three tests in PRD §25.3. Use the SDK's testing helpers — do not spawn a subprocess. > > Verify: `finn-eiendom-mcp` boots over stdio, `mcp dev finn_eiendom/mcp_server.py` lists all 14 tools. ### Files * `finn_eiendom/mcp_server.py` (full rewrite) * `tests/test_mcp_server.py` (full rewrite) * `pyproject.toml` (edit `[project.scripts]`) ### Done when * `mcp_server.py` imports only `service`, `formatting`, `models`, `config`, stdlib, `mcp`, `pydantic`. * All 14 tools registered. * `pytest tests/test_mcp_server.py` is green. * `grep -rn "FastAPI" finn_eiendom/` is empty. --- ## Step 7 — CLI ### Kickoff prompt > Read **PRD.md** §15 (CLI design — every command and option) and **`.github/instructions/cli.instructions.md`** end-to-end. > > Create `finn_eiendom/cli.py` with a `typer.Typer` app exposing all commands in PRD §15.1, plus `finn_eiendom/__main__.py` that calls the app. Add to `pyproject.toml`: > ``` > [project.scripts] > finn-eiendom = "finn_eiendom.cli:app" > ``` > > Each command: > - Translates options into a `service.` call. > - Calls `formatting.render_*(result, format)` and `typer.echo(...)`. > - No business logic, no inline rendering. > - Body under ~20 lines. > > Sub-app for `cache` (stats/clear/clear-html/clear-json) and `config` (show/path). `serve` accepts `--transport stdio|http` and dispatches to `mcp_server.main()` or the HTTP transport. > > If unsure about Typer sub-apps or `CliRunner`, use `context7`. > > Add `tests/test_cli.py` covering the five tests in PRD §25.4 using `typer.testing.CliRunner`. Mock `service.*` with `monkeypatch` — do not exercise the full stack here, that's `test_service.py`. ### Files * `finn_eiendom/cli.py` (new) * `finn_eiendom/__main__.py` (new) * `tests/test_cli.py` (new) * `pyproject.toml` (edit) ### Done when * `finn-eiendom --help` lists every command in PRD §15.1. * `cli.py` imports only `service`, `formatting`, `models`, `config`, stdlib, `typer`. * `pytest tests/test_cli.py` is green. --- ## Step 8 — Diff workflow (new / removed / changed) ### Kickoff prompt > Read **PRD.md** §10.8, §13 (search_runs table), workflow I in §18, and **`.github/instructions/clean-code.instructions.md`**. > > Implement: > 1. `search_runs` and `scores` tables in `cache.py` (use existing migration pattern). > 2. `service.get_new_ads_since_last_run(search_url)` that compares against the previous run for the same `normalized_url` and returns `{new_ads, removed_ads, changed_ads}` with price/common_costs/status diffs on changed. > 3. `finn_get_new_ads_since_last_run` MCP tool. > 4. `finn-eiendom diff ` CLI command. > 5. `formatting.render_diff(result, fmt)`. > > Add tests covering: empty previous-run case, all-new case, mixed new+removed+changed case. ### Done when * The three new tests pass. * MCP and CLI both expose the same behavior with identical defaults. --- ## Step 9 — Compare workflow ### Kickoff prompt > Read **PRD.md** workflow K in §18 and §14.2 (`CompareAdsInput`). > > Implement `service.compare_ads(finnkoder, include_eiendom_no=True, include_comps=True)` returning a comparison table + winners by category (best value / lifestyle / hybel / bargain / safest / highest risk / most overpriced). > > Wire `finn_compare_ads` MCP tool and `finn-eiendom compare ` CLI command. Add `formatting.render_comparison`. Tests for service and CLI. ### Done when * `finn-eiendom compare 462400360 461153194 --format markdown` produces a readable comparison. * Service test covers the winners-by-category logic. --- ## Step 10 — Similar-to-liked ### Kickoff prompt > Read **PRD.md** workflow G in §18 and `FindSimilarToLikedInput` in §14.2. > > Implement `service.find_similar_to_liked(finnkode, mode, listing_status)`: > 1. Load FinnAd; verify `feedback` has `verdict=liked` for this finnkode. > 2. Ensure Eiendom.no enrichment + unit_vector exist. > 3. Fetch similar-units (prefer `FOR_SALE` for recommendations, `RECENTLY_SOLD` for comps). > 4. Score candidates against user preferences. > 5. Return ranked recommendations. > > Wire MCP tool and CLI command. Tests covering: no liked feedback raises clear error; happy path returns ranked list. ### Done when * `finn-eiendom similar-to-liked 462400360` returns ranked candidates when the listing has a liked verdict, and a clear error otherwise. --- ## Step 11 — Architecture tests ### Kickoff prompt > Read **PRD.md** A10 (architecture acceptance criterion) and §17.3 (layering invariants). > > Create `tests/test_architecture.py` that walks every `.py` file under `finn_eiendom/` with `ast`, collects all `import` and `from X import Y` statements, and asserts the layering invariants in PRD A10: > - No `httpx` outside `http.py`. > - No `sqlite3` outside `cache.py`. > - No `BeautifulSoup` outside `search.py` / `ad.py`. > - No `msgpack` outside `eiendom_no.py`. > - `mcp_server.py` and `cli.py` import only from the allowed set. > - `service.py` never imports `mcp_server` or `cli`. > > Add a parametrize'd test per invariant so failures show which module violated which rule. Failures should print the offending import line and module. ### Done when * `pytest tests/test_architecture.py` is green. * Deliberately introducing a violation (e.g. `import httpx` in `service.py`) makes a test fail with a clear message. --- ## Step 12 — README + Claude Desktop config + final verification ### Kickoff prompt > Read **PRD.md** §21 (deployment), §22 (MVP scope), §24 (all acceptance criteria), **README.md** and **USAGE.md**. > > Update `README.md` and `USAGE.md` so every command, env var, and Claude Desktop snippet matches what was actually built in steps 1–11. Verify with the user's exact paths. > > Run the full A1–A11 acceptance check: > > - A1: `finn-eiendom-mcp` boots over stdio; `mcp dev finn_eiendom/mcp_server.py` lists all 14 tools. > - A2: `finn-eiendom --help` lists every §15.1 command; each command runs against fixtures. > - A3 – A9: matching service tests pass. > - A10: `pytest tests/test_architecture.py` is green. > - A11: `ruff check .` is clean; `pytest` is fully green; `mypy --strict finn_eiendom` passes or is documented as a gap. > > Report any failures with specific file/line references — don't paper over them. ### Files * `README.md` (edit to match reality) * `USAGE.md` (edit to match reality) ### Done when * All 11 acceptance criteria in PRD §24 pass. * README + USAGE quickstart examples actually work end-to-end on a fresh clone. --- ## Definition of done for the whole phase Merge `main` into `main` when **every** box is checked: * [ ] All 12 steps merged in order. * [ ] `finn-eiendom-mcp` boots over stdio with all 14 tools. * [ ] `finn-eiendom --help` lists every command in PRD §15.1. * [ ] `pytest` is green, including the new `test_service.py`, `test_cli.py`, `test_http.py`, `test_formatting.py`, `test_models.py`, `test_architecture.py`. * [ ] `ruff check .` is clean. * [ ] `mypy --strict finn_eiendom` passes or has a documented exception list. * [ ] `README.md` and `USAGE.md` quickstart examples work on a fresh clone in under 5 minutes. * [ ] Claude Desktop config in USAGE.md is verified to work against your installation. --- ## When a step blocks If a step blocks on an unclear requirement: 1. Re-read the relevant PRD section. 2. Check `PRD.md` §28 (open questions) — the answer may be a deferred decision. 3. If still unclear, write the question down, pick the simplest interpretation, mark it `# TODO(): revisit ` in code, and move on. If a step blocks on a library question (FastMCP, Pydantic v2, Typer, httpx, msgpack, respx): 1. Use `context7` — see `.github/instructions/docs.instructions.md`. 2. If context7 returns nothing useful, write the smallest possible spike in `scratch/` (gitignored) to verify behavior. If a step blocks on §17 (code ownership) — i.e. it feels like the right answer requires putting logic in the "wrong" place: 1. Stop. 2. Re-read PRD §17.2 (decision table) and §17.3 (layering invariants). 3. Ask whether the service layer is actually missing a function. Usually it is. 4. Add the missing service function instead of bending the layering.