15 KiB
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:
-
ls -la
-
Venv is healthy. From the project root:
source .venv/bin/activate pytest -x # green except for any pre-existing FastMCP-related skips ruff check . # zero issues -
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:
- Create a feature branch:
git checkout -b feat/phase2-step-<N>-<slug>offchore/cleanup-phase-2-prep. - Open a fresh agent chat with repo access. Paste the kickoff prompt verbatim.
- Let the agent propose, implement, and test. Push back where it skips tests or violates §17.
- When all "done" boxes are checked, merge into
chore/cleanup-phase-2-prep. - 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 chore/cleanup-phase-2-prep 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:
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.pyfrom v1 (class Config:) to v2 (model_config = ConfigDict(...)). Usecontext7:query-docsonpydantic/pydanticif you're not sure of the v2 syntax — don't guess.Add
tests/test_models.pywith a JSON roundtrip test per model.Run
ruff check .,ruff format ., andpytest tests/test_models.py -vbefore 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.pyis 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.mdand.github/instructions/clean-code.instructions.md.Create
finn_eiendom/service.pywith 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:
- Opens its own SQLite connection via
cache.init_db(FINN_CACHE_PATH).- Reads cache first with TTLs from
config.py.- On miss or
force_refresh=True, calls the fetcher inad.py/eiendom_no.py.- Writes the fresh result back.
- Returns a typed model or dict.
Do not duplicate behavior from
analysis.py— delegate to it. Addtests/test_service.pycovering 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 atmp_dbfixture if it doesn't exist)
Done when
pytest tests/test_service.pyis green.service.pyimports only frommodels,config,cache,analysis,ad,eiendom_no,feedback,scoring, stdlib.- No
import httpxorimport sqlite3outside 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.pywith these renderers (signatures in PRD §17.6):render_ad,render_shortlist,render_comparison,render_diff,render_similar_units,render_unit,render_score_breakdown, plusrender_cache_statsfor the CLI cache subcommand.Each renderer accepts
(payload, fmt: Literal["json","markdown","table"]) -> str. Unsupported formats raiseValueErrorlisting supported options. Table rendering only applies where it makes sense (shortlist, comparison, diff, similar-units).Add
tests/test_formatting.pycovering 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.pyis 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()infinn_eiendom/http.pyto retry on 5xx responses (500/502/503/504) with exponential backoff1s, 2s, 4s, up toretriesattempts (default 3). Surface 4xx ashttpx.HTTPStatusErrorimmediately. Apply the existingrequest_delaybetween any two calls.If you're unsure about
httpxretry semantics orrespxtest patterns, usecontext7.Add
tests/test_http.pycovering the three tests listed in PRD §25.6 usingrespx.
Files
finn_eiendom/http.py(edit)tests/test_http.py(new)
Done when
pytest tests/test_http.pyis green.httpximports remain confined tohttp.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.mdend-to-end.Rewrite
finn_eiendom/mcp_server.pyfrom 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": oneservice.<function>call, oneformatting.render_*call, try/except returning the JSON error envelope.- Input schemas as in PRD §14.2.
- Annotations:
readOnlyHint=Truefor all exceptfinn_save_feedback.main()callsmcp.run(transport="stdio").- Add
finn-eiendom-mcp = "finn_eiendom.mcp_server:main"to[project.scripts]inpyproject.toml.If unsure about FastMCP annotations or transport options, use
context7:query-docson the MCP Python SDK.Rewrite
tests/test_mcp_server.pyto cover the three tests in PRD §25.3. Use the SDK's testing helpers — do not spawn a subprocess.Verify:
finn-eiendom-mcpboots over stdio,mcp dev finn_eiendom/mcp_server.pylists 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.pyimports onlyservice,formatting,models,config, stdlib,mcp,pydantic.- All 14 tools registered.
pytest tests/test_mcp_server.pyis 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.mdend-to-end.Create
finn_eiendom/cli.pywith atyper.Typerapp exposing all commands in PRD §15.1, plusfinn_eiendom/__main__.pythat calls the app. Add topyproject.toml:[project.scripts] finn-eiendom = "finn_eiendom.cli:app"Each command:
- Translates options into a
service.<function>call.- Calls
formatting.render_*(result, format)andtyper.echo(...).- No business logic, no inline rendering.
- Body under ~20 lines.
Sub-app for
cache(stats/clear/clear-html/clear-json) andconfig(show/path).serveaccepts--transport stdio|httpand dispatches tomcp_server.main()or the HTTP transport.If unsure about Typer sub-apps or
CliRunner, usecontext7.Add
tests/test_cli.pycovering the five tests in PRD §25.4 usingtyper.testing.CliRunner. Mockservice.*withmonkeypatch— do not exercise the full stack here, that'stest_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 --helplists every command in PRD §15.1.cli.pyimports onlyservice,formatting,models,config, stdlib,typer.pytest tests/test_cli.pyis 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:
search_runsandscorestables incache.py(use existing migration pattern).service.get_new_ads_since_last_run(search_url)that compares against the previous run for the samenormalized_urland returns{new_ads, removed_ads, changed_ads}with price/common_costs/status diffs on changed.finn_get_new_ads_since_last_runMCP tool.finn-eiendom diff <url>CLI command.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_adsMCP tool andfinn-eiendom compare <finnkode...>CLI command. Addformatting.render_comparison. Tests for service and CLI.
Done when
finn-eiendom compare 462400360 461153194 --format markdownproduces 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
FindSimilarToLikedInputin §14.2.Implement
service.find_similar_to_liked(finnkode, mode, listing_status):
- Load FinnAd; verify
feedbackhasverdict=likedfor this finnkode.- Ensure Eiendom.no enrichment + unit_vector exist.
- Fetch similar-units (prefer
FOR_SALEfor recommendations,RECENTLY_SOLDfor comps).- Score candidates against user preferences.
- 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 462400360returns 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.pythat walks every.pyfile underfinn_eiendom/withast, collects allimportandfrom X import Ystatements, and asserts the layering invariants in PRD A10:
- No
httpxoutsidehttp.py.- No
sqlite3outsidecache.py.- No
BeautifulSoupoutsidesearch.py/ad.py.- No
msgpackoutsideeiendom_no.py.mcp_server.pyandcli.pyimport only from the allowed set.service.pynever importsmcp_serverorcli.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.pyis green.- Deliberately introducing a violation (e.g.
import httpxinservice.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.mdandUSAGE.mdso 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-mcpboots over stdio;mcp dev finn_eiendom/mcp_server.pylists all 14 tools.- A2:
finn-eiendom --helplists every §15.1 command; each command runs against fixtures.- A3 – A9: matching service tests pass.
- A10:
pytest tests/test_architecture.pyis green.- A11:
ruff check .is clean;pytestis fully green;mypy --strict finn_eiendompasses 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 chore/cleanup-phase-2-prep into main when every box is checked:
- All 12 steps merged in order.
finn-eiendom-mcpboots over stdio with all 14 tools.finn-eiendom --helplists every command in PRD §15.1.pytestis green, including the newtest_service.py,test_cli.py,test_http.py,test_formatting.py,test_models.py,test_architecture.py.ruff check .is clean.mypy --strict finn_eiendompasses or has a documented exception list.README.mdandUSAGE.mdquickstart 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:
- Re-read the relevant PRD section.
- Check
PRD.md§28 (open questions) — the answer may be a deferred decision. - If still unclear, write the question down, pick the simplest interpretation, mark it
# TODO(<date>): revisit <question>in code, and move on.
If a step blocks on a library question (FastMCP, Pydantic v2, Typer, httpx, msgpack, respx):
- Use
context7— see.github/instructions/docs.instructions.md. - 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:
- Stop.
- Re-read PRD §17.2 (decision table) and §17.3 (layering invariants).
- Ask whether the service layer is actually missing a function. Usually it is.
- Add the missing service function instead of bending the layering.