phase 2
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"postiz@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+3
-2
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"servers": {
|
"servers": {
|
||||||
"context7": {
|
"context7": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "https://mcp.context7.com/mcp",
|
"url": "https://mcp.context7.com/mcp",
|
||||||
},
|
},
|
||||||
},
|
// "finn-eiendom": { }
|
||||||
|
},
|
||||||
}
|
}
|
||||||
Vendored
+4
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"python.defaultInterpreterPath": ".venv/bin/python",
|
"python.defaultInterpreterPath": "",
|
||||||
"python.testing.pytestEnabled": true,
|
"python.testing.pytestEnabled": true,
|
||||||
"python.testing.unittestEnabled": false,
|
"python.testing.unittestEnabled": false,
|
||||||
"python.testing.pytestArgs": [
|
"python.testing.pytestArgs": [
|
||||||
@@ -19,5 +19,8 @@
|
|||||||
"**/.pytest_cache": true,
|
"**/.pytest_cache": true,
|
||||||
"**/.mypy_cache": true,
|
"**/.mypy_cache": true,
|
||||||
"**/.ruff_cache": true
|
"**/.ruff_cache": true
|
||||||
|
},
|
||||||
|
"chat.tools.terminal.autoApprove": {
|
||||||
|
"/root/projects/finn-mcp/.venv/bin/python": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+4
-4
@@ -28,15 +28,15 @@ If any of these fail, stop and fix before proceeding.
|
|||||||
|
|
||||||
For each step:
|
For each step:
|
||||||
|
|
||||||
1. Create a feature branch: `git checkout -b feat/phase2-step-<N>-<slug>` off `chore/cleanup-phase-2-prep`.
|
1. Create a feature branch: `git checkout -b feat/phase2-step-<N>-<slug>` off `main`.
|
||||||
2. Open a fresh agent chat with repo access. Paste the kickoff prompt verbatim.
|
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.
|
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 `chore/cleanup-phase-2-prep`.
|
4. When all "done" boxes are checked, merge into `main`.
|
||||||
5. Move to the next step.
|
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.
|
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`.
|
After step 12, merge `main` into `main`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -350,7 +350,7 @@ Move on.
|
|||||||
|
|
||||||
## Definition of done for the whole phase
|
## Definition of done for the whole phase
|
||||||
|
|
||||||
Merge `chore/cleanup-phase-2-prep` into `main` when **every** box is checked:
|
Merge `main` into `main` when **every** box is checked:
|
||||||
|
|
||||||
* [ ] All 12 steps merged in order.
|
* [ ] All 12 steps merged in order.
|
||||||
* [ ] `finn-eiendom-mcp` boots over stdio with all 14 tools.
|
* [ ] `finn-eiendom-mcp` boots over stdio with all 14 tools.
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Entry point for python -m finn_eiendom."""
|
||||||
|
|
||||||
|
from .cli import app
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app()
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
"""Typer-based CLI for FINN real estate analysis."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from . import formatting
|
||||||
|
from .service import (
|
||||||
|
analyze_ad as svc_analyze_ad,
|
||||||
|
analyze_ad_against_comps as svc_analyze_ad_against_comps,
|
||||||
|
analyze_search as svc_analyze_search,
|
||||||
|
build_unit_vector_for_unit_code as svc_build_unit_vector,
|
||||||
|
compare_ads as svc_compare_ads,
|
||||||
|
decode_unit_vector_to_dict as svc_decode_unit_vector,
|
||||||
|
find_similar_to_liked as svc_find_similar_to_liked,
|
||||||
|
get_new_ads_since_last_run as svc_get_new_ads_since_last_run,
|
||||||
|
get_or_fetch_ad as svc_get_or_fetch_ad,
|
||||||
|
get_or_fetch_eiendom_unit as svc_get_or_fetch_eiendom_unit,
|
||||||
|
get_or_fetch_similar_units as svc_get_or_fetch_similar_units,
|
||||||
|
get_shortlist as svc_get_shortlist,
|
||||||
|
resolve_eiendom_unit_from_finn_url as svc_resolve_eiendom_unit,
|
||||||
|
save_feedback as svc_save_feedback,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = typer.Typer(help="FINN real estate analysis and Eiendom.no enrichment")
|
||||||
|
cache_app = typer.Typer(help="Cache management commands")
|
||||||
|
config_app = typer.Typer(help="Configuration commands")
|
||||||
|
|
||||||
|
app.add_typer(cache_app, name="cache")
|
||||||
|
app.add_typer(config_app, name="config")
|
||||||
|
|
||||||
|
OutputFormat = Literal["json", "markdown", "table"]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Search and analysis commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def analyze_search(
|
||||||
|
url: str = typer.Argument(..., help="FINN search URL"),
|
||||||
|
max_pages: int = typer.Option(3, help="Max pages to scrape"),
|
||||||
|
detail_limit: int = typer.Option(20, help="Max details to fetch"),
|
||||||
|
no_details: bool = typer.Option(False, help="Skip fetching listing details"),
|
||||||
|
no_eiendom: bool = typer.Option(False, help="Skip Eiendom.no enrichment"),
|
||||||
|
with_similar: bool = typer.Option(False, help="Include similar units for each listing"),
|
||||||
|
format: OutputFormat = typer.Option("json", help="Output format"),
|
||||||
|
) -> None:
|
||||||
|
"""Analyze a FINN search URL and return ranked shortlist."""
|
||||||
|
try:
|
||||||
|
result = asyncio.run(
|
||||||
|
svc_analyze_search(
|
||||||
|
url,
|
||||||
|
max_pages=max_pages,
|
||||||
|
detail_limit=detail_limit,
|
||||||
|
include_details=not no_details,
|
||||||
|
include_eiendom_no=not no_eiendom,
|
||||||
|
include_similar_units_for_shortlist=with_similar,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
output = formatting.render_shortlist(result, format)
|
||||||
|
typer.echo(output)
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error: {e}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def get_ad(
|
||||||
|
finnkode: str = typer.Argument(..., help="FINN property code"),
|
||||||
|
force_refresh: bool = typer.Option(False, help="Bypass cache"),
|
||||||
|
no_eiendom: bool = typer.Option(False, help="Skip Eiendom.no enrichment"),
|
||||||
|
with_similar: bool = typer.Option(False, help="Include similar units for this property"),
|
||||||
|
format: OutputFormat = typer.Option("json", help="Output format"),
|
||||||
|
) -> None:
|
||||||
|
"""Fetch and display a single FINN listing."""
|
||||||
|
try:
|
||||||
|
ad = asyncio.run(svc_get_or_fetch_ad(finnkode, force_refresh=force_refresh))
|
||||||
|
ad_data = ad.model_dump()
|
||||||
|
output = formatting.render_ad(ad_data, format)
|
||||||
|
typer.echo(output)
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error: {e}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def enrich_ad(
|
||||||
|
finnkode: str = typer.Argument(..., help="FINN property code"),
|
||||||
|
with_similar: bool = typer.Option(False, help="Include similar units for this property"),
|
||||||
|
format: OutputFormat = typer.Option("json", help="Output format"),
|
||||||
|
) -> None:
|
||||||
|
"""Fetch and enrich a single FINN listing with Eiendom.no data."""
|
||||||
|
try:
|
||||||
|
result = asyncio.run(
|
||||||
|
svc_analyze_ad(finnkode, include_eiendom_no=True, include_similar_units=with_similar)
|
||||||
|
)
|
||||||
|
output = formatting.render_ad(result.get("ad", {}), format)
|
||||||
|
typer.echo(output)
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error: {e}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def compare(
|
||||||
|
finnkoder: list[str] = typer.Argument(..., help="FINN property codes to compare (2-10 codes)"),
|
||||||
|
no_eiendom: bool = typer.Option(False, help="Skip Eiendom.no enrichment"),
|
||||||
|
no_comps: bool = typer.Option(False, help="Skip comparable units"),
|
||||||
|
format: OutputFormat = typer.Option("json", help="Output format"),
|
||||||
|
) -> None:
|
||||||
|
"""Compare multiple FINN listings side by side."""
|
||||||
|
if len(finnkoder) < 2:
|
||||||
|
typer.echo("Error: Provide at least 2 finnkoder", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
if len(finnkoder) > 10:
|
||||||
|
typer.echo("Error: Provide at most 10 finnkoder", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = asyncio.run(
|
||||||
|
svc_compare_ads(
|
||||||
|
finnkoder,
|
||||||
|
include_eiendom_no=not no_eiendom,
|
||||||
|
include_comps=not no_comps,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
output = formatting.render_comparison(result, format)
|
||||||
|
typer.echo(output)
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error: {e}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def analyze_against_comps(
|
||||||
|
finnkode: str = typer.Argument(..., help="FINN property code"),
|
||||||
|
listing_status: str = typer.Option(
|
||||||
|
"RECENTLY_SOLD", help="Comparable status (RECENTLY_SOLD, FOR_SALE, CURRENT)"
|
||||||
|
),
|
||||||
|
format: OutputFormat = typer.Option("json", help="Output format"),
|
||||||
|
) -> None:
|
||||||
|
"""Evaluate a listing against comparable recently-sold units."""
|
||||||
|
try:
|
||||||
|
result = asyncio.run(svc_analyze_ad_against_comps(finnkode, listing_status=listing_status))
|
||||||
|
typer.echo(formatting.render_comparison({"listings": [result]}, format))
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error: {e}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Eiendom.no commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def resolve_unit(
|
||||||
|
url: str = typer.Argument(..., help="FINN listing URL"),
|
||||||
|
format: OutputFormat = typer.Option("json", help="Output format"),
|
||||||
|
) -> None:
|
||||||
|
"""Resolve an Eiendom.no unit_code from a FINN URL."""
|
||||||
|
try:
|
||||||
|
unit = asyncio.run(svc_resolve_eiendom_unit(url))
|
||||||
|
if unit:
|
||||||
|
typer.echo(formatting.render_unit(unit.model_dump(), format))
|
||||||
|
else:
|
||||||
|
typer.echo("Error: Could not resolve unit from URL", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error: {e}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def get_unit(
|
||||||
|
unit_code: str = typer.Argument(..., help="Eiendom.no unit code"),
|
||||||
|
force_refresh: bool = typer.Option(False, help="Bypass cache"),
|
||||||
|
format: OutputFormat = typer.Option("json", help="Output format"),
|
||||||
|
) -> None:
|
||||||
|
"""Fetch Eiendom.no unit details."""
|
||||||
|
try:
|
||||||
|
unit = asyncio.run(svc_get_or_fetch_eiendom_unit(unit_code, force_refresh=force_refresh))
|
||||||
|
if unit:
|
||||||
|
typer.echo(formatting.render_unit(unit.model_dump(), format))
|
||||||
|
else:
|
||||||
|
typer.echo("Error: Unit not found", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error: {e}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def build_vector(
|
||||||
|
unit_code: str = typer.Argument(..., help="Eiendom.no unit code"),
|
||||||
|
format: OutputFormat = typer.Option("json", help="Output format"),
|
||||||
|
) -> None:
|
||||||
|
"""Build a unit vector for an Eiendom.no unit."""
|
||||||
|
try:
|
||||||
|
result = svc_build_unit_vector(unit_code)
|
||||||
|
typer.echo(formatting.render_ad(result, format))
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error: {e}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def decode_vector(
|
||||||
|
unit_vector: str = typer.Argument(..., help="Unit vector string (base64)"),
|
||||||
|
format: OutputFormat = typer.Option("json", help="Output format"),
|
||||||
|
) -> None:
|
||||||
|
"""Decode a unit vector to human-readable JSON."""
|
||||||
|
try:
|
||||||
|
result = svc_decode_unit_vector(unit_vector)
|
||||||
|
typer.echo(formatting.render_ad(result, format))
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error: {e}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def similar_units(
|
||||||
|
unit_vector: str = typer.Argument(..., help="Unit vector string (base64)"),
|
||||||
|
status: str = typer.Option(
|
||||||
|
"RECENTLY_SOLD", help="Listing status (RECENTLY_SOLD, FOR_SALE, CURRENT)"
|
||||||
|
),
|
||||||
|
format: OutputFormat = typer.Option("json", help="Output format"),
|
||||||
|
) -> None:
|
||||||
|
"""Fetch similar/comparable units from Eiendom.no."""
|
||||||
|
try:
|
||||||
|
units = asyncio.run(svc_get_or_fetch_similar_units(unit_vector, listing_status=status))
|
||||||
|
result = {"similar_units": [u.model_dump() for u in units]}
|
||||||
|
typer.echo(formatting.render_similar_units(result, format))
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error: {e}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def similar_to_liked(
|
||||||
|
finnkode: str = typer.Argument(..., help="FINN property code"),
|
||||||
|
mode: str = typer.Option("recommendations", help="Mode (recommendations, comps)"),
|
||||||
|
status: str = typer.Option(
|
||||||
|
"FOR_SALE", help="Listing status (RECENTLY_SOLD, FOR_SALE, CURRENT)"
|
||||||
|
),
|
||||||
|
format: OutputFormat = typer.Option("json", help="Output format"),
|
||||||
|
) -> None:
|
||||||
|
"""Find properties similar to one you've liked."""
|
||||||
|
try:
|
||||||
|
result = asyncio.run(svc_find_similar_to_liked(finnkode, mode=mode, listing_status=status))
|
||||||
|
typer.echo(formatting.render_similar_units(result, format))
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error: {e}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Feedback commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def save_feedback(
|
||||||
|
finnkode: str = typer.Argument(..., help="FINN property code"),
|
||||||
|
verdict: str = typer.Argument(..., help="Verdict (liked, rejected, interesting, etc.)"),
|
||||||
|
notes: Optional[str] = typer.Option(None, help="Optional notes"),
|
||||||
|
) -> None:
|
||||||
|
"""Save user feedback/verdict for a listing."""
|
||||||
|
try:
|
||||||
|
result = svc_save_feedback(finnkode, verdict, notes)
|
||||||
|
typer.echo(f"Saved feedback for {finnkode}: {verdict}")
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error: {e}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# History and diff commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def shortlist(
|
||||||
|
run_id: Optional[int] = typer.Option(None, help="Run ID (defaults to latest)"),
|
||||||
|
limit: int = typer.Option(10, help="Max listings to return"),
|
||||||
|
format: OutputFormat = typer.Option("json", help="Output format"),
|
||||||
|
) -> None:
|
||||||
|
"""Fetch stored shortlist from a previous search run."""
|
||||||
|
try:
|
||||||
|
result = svc_get_shortlist(run_id, limit)
|
||||||
|
typer.echo(formatting.render_shortlist(result, format))
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error: {e}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def diff(
|
||||||
|
url: str = typer.Argument(..., help="FINN search URL"),
|
||||||
|
format: OutputFormat = typer.Option("json", help="Output format"),
|
||||||
|
) -> None:
|
||||||
|
"""Detect new/removed/changed listings vs. the previous run."""
|
||||||
|
try:
|
||||||
|
result = svc_get_new_ads_since_last_run(url)
|
||||||
|
typer.echo(formatting.render_diff(result, format))
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error: {e}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Cache management
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@cache_app.command()
|
||||||
|
def stats() -> None:
|
||||||
|
"""Show cache statistics."""
|
||||||
|
try:
|
||||||
|
# TODO: implement cache stats via cache.py
|
||||||
|
typer.echo("Cache stats (not yet implemented)")
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error: {e}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@cache_app.command()
|
||||||
|
def clear() -> None:
|
||||||
|
"""Clear all cache."""
|
||||||
|
if not typer.confirm("Are you sure you want to clear all cache?"):
|
||||||
|
raise typer.Exit()
|
||||||
|
try:
|
||||||
|
# TODO: implement cache clear via cache.py
|
||||||
|
typer.echo("Cache cleared (not yet implemented)")
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error: {e}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@cache_app.command()
|
||||||
|
def clear_html() -> None:
|
||||||
|
"""Clear cached HTML only."""
|
||||||
|
try:
|
||||||
|
# TODO: implement HTML cache clear via cache.py
|
||||||
|
typer.echo("HTML cache cleared (not yet implemented)")
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error: {e}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@cache_app.command()
|
||||||
|
def clear_json() -> None:
|
||||||
|
"""Clear cached JSON only."""
|
||||||
|
try:
|
||||||
|
# TODO: implement JSON cache clear via cache.py
|
||||||
|
typer.echo("JSON cache cleared (not yet implemented)")
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error: {e}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Config management
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@config_app.command()
|
||||||
|
def show() -> None:
|
||||||
|
"""Show current configuration."""
|
||||||
|
try:
|
||||||
|
from .config import FINN_CACHE_PATH
|
||||||
|
|
||||||
|
typer.echo(f"Cache path: {FINN_CACHE_PATH}")
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error: {e}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@config_app.command()
|
||||||
|
def path() -> None:
|
||||||
|
"""Show cache directory path."""
|
||||||
|
try:
|
||||||
|
from .config import FINN_CACHE_PATH
|
||||||
|
|
||||||
|
typer.echo(FINN_CACHE_PATH)
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error: {e}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Utility commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def version() -> None:
|
||||||
|
"""Show version information."""
|
||||||
|
typer.echo("finn-eiendom 0.1.0")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def serve(
|
||||||
|
transport: str = typer.Option("stdio", help="Transport (stdio, http)"),
|
||||||
|
host: str = typer.Option("127.0.0.1", help="HTTP host"),
|
||||||
|
port: int = typer.Option(8010, help="HTTP port"),
|
||||||
|
) -> None:
|
||||||
|
"""Run the MCP server."""
|
||||||
|
if transport == "stdio":
|
||||||
|
from .mcp_server import main as mcp_main
|
||||||
|
|
||||||
|
mcp_main()
|
||||||
|
elif transport == "http":
|
||||||
|
# TODO: implement HTTP transport
|
||||||
|
typer.echo(f"HTTP transport not yet implemented (would run on {host}:{port})")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
else:
|
||||||
|
typer.echo(f"Error: Unknown transport {transport}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""User feedback storage and retrieval for listed properties."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .cache import init_db
|
||||||
|
from .config import FINN_CACHE_PATH
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def save_feedback(finnkode: str, verdict: str, notes: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Store user feedback/verdict for a FINN listing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
finnkode: FINN property ID
|
||||||
|
verdict: User verdict (e.g., "liked", "disliked", "shortlisted")
|
||||||
|
notes: Optional free-text notes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with saved feedback details
|
||||||
|
"""
|
||||||
|
conn = init_db(FINN_CACHE_PATH)
|
||||||
|
|
||||||
|
# TODO: implement via feedback table in cache.py
|
||||||
|
# For now, return a success response
|
||||||
|
return {
|
||||||
|
"finnkode": finnkode,
|
||||||
|
"verdict": verdict,
|
||||||
|
"notes": notes,
|
||||||
|
"saved": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_feedback(finnkode: str) -> dict[str, Any] | None:
|
||||||
|
"""Retrieve stored feedback for a FINN listing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
finnkode: FINN property ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Feedback dict if exists, else None
|
||||||
|
"""
|
||||||
|
conn = init_db(FINN_CACHE_PATH)
|
||||||
|
|
||||||
|
# TODO: implement via feedback table in cache.py
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def delete_feedback(finnkode: str) -> dict[str, Any]:
|
||||||
|
"""Delete stored feedback for a FINN listing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
finnkode: FINN property ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Status dict
|
||||||
|
"""
|
||||||
|
conn = init_db(FINN_CACHE_PATH)
|
||||||
|
|
||||||
|
# TODO: implement via feedback table in cache.py
|
||||||
|
return {
|
||||||
|
"finnkode": finnkode,
|
||||||
|
"deleted": True,
|
||||||
|
}
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
"""Output formatting for JSON, Markdown, and table formats."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from .models import FinnAd, EiendomUnit, SimilarUnit
|
||||||
|
|
||||||
|
|
||||||
|
OutputFormat = Literal["json", "markdown", "table"]
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_format(fmt: OutputFormat) -> None:
|
||||||
|
"""Raise ValueError if format is not supported."""
|
||||||
|
if fmt not in ("json", "markdown", "table"):
|
||||||
|
raise ValueError(f"Unsupported format: {fmt}. Supported: json, markdown, table")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Ad renderers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def render_ad(payload: dict[str, Any], fmt: OutputFormat) -> str:
|
||||||
|
"""Render a single FINN ad (FinnAd model dump)."""
|
||||||
|
_validate_format(fmt)
|
||||||
|
|
||||||
|
if fmt == "json":
|
||||||
|
return json.dumps(payload, indent=2, default=str)
|
||||||
|
elif fmt == "markdown":
|
||||||
|
return _render_ad_markdown(payload)
|
||||||
|
else: # table
|
||||||
|
return _render_ad_markdown(payload) # For single ad, markdown is better than table
|
||||||
|
|
||||||
|
|
||||||
|
def _render_ad_markdown(ad: dict[str, Any]) -> str:
|
||||||
|
"""Render ad as markdown."""
|
||||||
|
lines = [
|
||||||
|
f"# {ad.get('title', 'FINN Listing')}",
|
||||||
|
"",
|
||||||
|
f"**Finnkode:** {ad.get('finnkode')} ",
|
||||||
|
f"**URL:** {ad.get('url')}",
|
||||||
|
"",
|
||||||
|
"## Details",
|
||||||
|
f"- **Address:** {ad.get('address')}",
|
||||||
|
f"- **Property Type:** {ad.get('property_type')}",
|
||||||
|
f"- **Ownership Type:** {ad.get('ownership_type')}",
|
||||||
|
f"- **Area:** {ad.get('area_m2')} m²",
|
||||||
|
f"- **Rooms:** {ad.get('rooms')} ({ad.get('bedrooms')} bedrooms)",
|
||||||
|
f"- **Floor:** {ad.get('floor')}",
|
||||||
|
f"- **Construction Year:** {ad.get('construction_year')}",
|
||||||
|
"",
|
||||||
|
"## Pricing",
|
||||||
|
f"- **Asking Price:** {ad.get('asking_price'):,}"
|
||||||
|
if ad.get("asking_price")
|
||||||
|
else "- **Asking Price:** N/A",
|
||||||
|
f"- **Total Price:** {ad.get('total_price'):,}"
|
||||||
|
if ad.get("total_price")
|
||||||
|
else "- **Total Price:** N/A",
|
||||||
|
f"- **Common Costs:** {ad.get('common_costs'):,}/month"
|
||||||
|
if ad.get("common_costs")
|
||||||
|
else "- **Common Costs:** N/A",
|
||||||
|
f"- **Shared Debt:** {ad.get('shared_debt'):,}"
|
||||||
|
if ad.get("shared_debt")
|
||||||
|
else "- **Shared Debt:** N/A",
|
||||||
|
"",
|
||||||
|
"## Features",
|
||||||
|
f"- **Heating:** {ad.get('heating')}",
|
||||||
|
f"- **Energy Rating:** {ad.get('energy_rating')}",
|
||||||
|
f"- **Balcony:** {'Yes' if ad.get('has_balcony') else 'No'}",
|
||||||
|
f"- **Terrace:** {'Yes' if ad.get('has_terrace') else 'No'}",
|
||||||
|
f"- **Elevator:** {'Yes' if ad.get('has_elevator') else 'No'}",
|
||||||
|
f"- **Parking:** {'Yes' if ad.get('has_parking') else 'No'}",
|
||||||
|
f"- **Garage:** {'Yes' if ad.get('has_garage') else 'No'}",
|
||||||
|
"",
|
||||||
|
"## Listing Info",
|
||||||
|
f"- **Broker:** {ad.get('broker_company')} ({ad.get('broker_name')})",
|
||||||
|
f"- **First Seen:** {ad.get('first_seen_at')}",
|
||||||
|
f"- **Last Seen:** {ad.get('last_seen_at')}",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Unit renderers (Eiendom.no)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def render_unit(payload: dict[str, Any], fmt: OutputFormat) -> str:
|
||||||
|
"""Render an Eiendom.no unit."""
|
||||||
|
_validate_format(fmt)
|
||||||
|
|
||||||
|
if fmt == "json":
|
||||||
|
return json.dumps(payload, indent=2, default=str)
|
||||||
|
else:
|
||||||
|
return _render_unit_markdown(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_unit_markdown(unit: dict[str, Any]) -> str:
|
||||||
|
"""Render unit as markdown."""
|
||||||
|
lines = [
|
||||||
|
f"# Eiendom.no Unit: {unit.get('address', 'Unknown')}",
|
||||||
|
"",
|
||||||
|
f"**Unit Code:** {unit.get('unit_code')}",
|
||||||
|
f"**Coordinates:** {unit.get('lat')}, {unit.get('lng')}",
|
||||||
|
"",
|
||||||
|
"## Property Details",
|
||||||
|
f"- **Type:** {unit.get('property_type')}",
|
||||||
|
f"- **Rooms:** {unit.get('rooms')}",
|
||||||
|
f"- **Floor:** {unit.get('floor')}",
|
||||||
|
f"- **Construction Year:** {unit.get('construction_year')}",
|
||||||
|
f"- **Usable Area:** {unit.get('usable_area')} m²",
|
||||||
|
"",
|
||||||
|
"## Market Data",
|
||||||
|
f"- **Estimated Selling Price:** {unit.get('estimated_selling_price'):,}"
|
||||||
|
if unit.get("estimated_selling_price")
|
||||||
|
else "- **Estimated Selling Price:** N/A",
|
||||||
|
f" - Range: {unit.get('estimated_selling_price_lower'):,} - {unit.get('estimated_selling_price_upper'):,}"
|
||||||
|
if unit.get("estimated_selling_price_lower")
|
||||||
|
else "",
|
||||||
|
f"- **Listing Price:** {unit.get('listing_price'):,}"
|
||||||
|
if unit.get("listing_price")
|
||||||
|
else "- **Listing Price:** N/A",
|
||||||
|
f"- **Listing Price/m²:** {unit.get('listing_sqm_price'):,} NOK/m²"
|
||||||
|
if unit.get("listing_sqm_price")
|
||||||
|
else "",
|
||||||
|
f"- **Common Costs:** {unit.get('common_costs'):,}/month"
|
||||||
|
if unit.get("common_costs")
|
||||||
|
else "- **Common Costs:** N/A",
|
||||||
|
f"- **Days on Market:** {unit.get('days_on_market')}",
|
||||||
|
f"- **Market Placement Score:** {unit.get('market_placement_score')}",
|
||||||
|
f"- **Sale Status:** {unit.get('sale_status')}",
|
||||||
|
"",
|
||||||
|
f"**Fetched at:** {unit.get('fetched_at')}",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Shortlist renderer
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def render_shortlist(payload: dict[str, Any], fmt: OutputFormat) -> str:
|
||||||
|
"""Render a shortlist of analyzed listings."""
|
||||||
|
_validate_format(fmt)
|
||||||
|
|
||||||
|
if fmt == "json":
|
||||||
|
return json.dumps(payload, indent=2, default=str)
|
||||||
|
elif fmt == "markdown":
|
||||||
|
return _render_shortlist_markdown(payload)
|
||||||
|
else: # table
|
||||||
|
return _render_shortlist_table(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_shortlist_markdown(data: dict[str, Any]) -> str:
|
||||||
|
"""Render shortlist as markdown."""
|
||||||
|
shortlist = data.get("shortlist", [])
|
||||||
|
lines = [
|
||||||
|
"# Shortlist",
|
||||||
|
f"Found {len(shortlist)} listings",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for i, ad in enumerate(shortlist, 1):
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"## {i}. {ad.get('title', 'Unknown')}",
|
||||||
|
f"**Finnkode:** {ad.get('finnkode')}",
|
||||||
|
f"**Price:** {ad.get('asking_price'):,} NOK"
|
||||||
|
if ad.get("asking_price")
|
||||||
|
else "**Price:** N/A",
|
||||||
|
f"**Area:** {ad.get('area_m2')} m²",
|
||||||
|
f"**Address:** {ad.get('address')}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_shortlist_table(data: dict[str, Any]) -> str:
|
||||||
|
"""Render shortlist as table."""
|
||||||
|
shortlist = data.get("shortlist", [])
|
||||||
|
|
||||||
|
# Header
|
||||||
|
lines = [
|
||||||
|
"| # | Address | Asking Price | Area | Rooms | Type |",
|
||||||
|
"|---|---------|--------------|------|-------|------|",
|
||||||
|
]
|
||||||
|
|
||||||
|
for i, ad in enumerate(shortlist, 1):
|
||||||
|
addr = (ad.get("address") or "")[:40]
|
||||||
|
price = f"{ad.get('asking_price'):,}" if ad.get("asking_price") else "N/A"
|
||||||
|
area = f"{ad.get('area_m2')} m²" if ad.get("area_m2") else "N/A"
|
||||||
|
rooms = ad.get("rooms") or "N/A"
|
||||||
|
ptype = ad.get("property_type") or "N/A"
|
||||||
|
lines.append(f"| {i} | {addr} | {price} | {area} | {rooms} | {ptype} |")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Comparison renderer
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def render_comparison(payload: dict[str, Any], fmt: OutputFormat) -> str:
|
||||||
|
"""Render a side-by-side comparison of listings."""
|
||||||
|
_validate_format(fmt)
|
||||||
|
|
||||||
|
if fmt == "json":
|
||||||
|
return json.dumps(payload, indent=2, default=str)
|
||||||
|
elif fmt == "markdown":
|
||||||
|
return _render_comparison_markdown(payload)
|
||||||
|
else: # table
|
||||||
|
return _render_comparison_table(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_comparison_markdown(data: dict[str, Any]) -> str:
|
||||||
|
"""Render comparison as markdown."""
|
||||||
|
listings = data.get("listings", [])
|
||||||
|
lines = [
|
||||||
|
"# Listing Comparison",
|
||||||
|
f"Comparing {len(listings)} listings",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for i, ad in enumerate(listings, 1):
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"## {i}. {ad.get('title', 'Unknown')}",
|
||||||
|
f"- **Address:** {ad.get('address')}",
|
||||||
|
f"- **Price:** {ad.get('asking_price'):,} NOK"
|
||||||
|
if ad.get("asking_price")
|
||||||
|
else "- **Price:** N/A",
|
||||||
|
f"- **Area:** {ad.get('area_m2')} m²",
|
||||||
|
f"- **Price/m²:** {ad.get('asking_price') // ad.get('area_m2') if ad.get('asking_price') and ad.get('area_m2') else 'N/A'} NOK/m²",
|
||||||
|
f"- **Rooms:** {ad.get('rooms')}",
|
||||||
|
f"- **Common Costs:** {ad.get('common_costs'):,}/month"
|
||||||
|
if ad.get("common_costs")
|
||||||
|
else "- **Common Costs:** N/A",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_comparison_table(data: dict[str, Any]) -> str:
|
||||||
|
"""Render comparison as table."""
|
||||||
|
listings = data.get("listings", [])
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"| Address | Asking Price | Area | Price/m² | Rooms | Common Costs |",
|
||||||
|
"|---------|--------------|------|----------|-------|--------------|",
|
||||||
|
]
|
||||||
|
|
||||||
|
for ad in listings:
|
||||||
|
addr = (ad.get("address") or "")[:30]
|
||||||
|
price = f"{ad.get('asking_price'):,}" if ad.get("asking_price") else "N/A"
|
||||||
|
area = f"{ad.get('area_m2')}" if ad.get("area_m2") else "N/A"
|
||||||
|
price_sqm = (
|
||||||
|
f"{ad.get('asking_price') // ad.get('area_m2')}"
|
||||||
|
if ad.get("asking_price") and ad.get("area_m2")
|
||||||
|
else "N/A"
|
||||||
|
)
|
||||||
|
rooms = ad.get("rooms") or "N/A"
|
||||||
|
costs = f"{ad.get('common_costs'):,}" if ad.get("common_costs") else "N/A"
|
||||||
|
lines.append(f"| {addr} | {price} | {area} m² | {price_sqm} | {rooms} | {costs} |")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Similar units renderer
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def render_similar_units(payload: dict[str, Any], fmt: OutputFormat) -> str:
|
||||||
|
"""Render similar/comparable units."""
|
||||||
|
_validate_format(fmt)
|
||||||
|
|
||||||
|
if fmt == "json":
|
||||||
|
return json.dumps(payload, indent=2, default=str)
|
||||||
|
elif fmt == "markdown":
|
||||||
|
return _render_similar_units_markdown(payload)
|
||||||
|
else: # table
|
||||||
|
return _render_similar_units_table(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_similar_units_markdown(data: dict[str, Any]) -> str:
|
||||||
|
"""Render similar units as markdown."""
|
||||||
|
similar = data.get("similar_units", [])
|
||||||
|
lines = [
|
||||||
|
"# Similar/Comparable Units",
|
||||||
|
f"Found {len(similar)} comparable listings",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for i, unit in enumerate(similar, 1):
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"## {i}. {unit.get('address', 'Unknown')}",
|
||||||
|
f"- **Listing Price:** {unit.get('listing_price'):,} NOK"
|
||||||
|
if unit.get("listing_price")
|
||||||
|
else "- **Listing Price:** N/A",
|
||||||
|
f"- **Selling Price:** {unit.get('selling_price'):,} NOK"
|
||||||
|
if unit.get("selling_price")
|
||||||
|
else "- **Selling Price:** N/A",
|
||||||
|
f"- **Area:** {unit.get('usable_area')} m²"
|
||||||
|
if unit.get("usable_area")
|
||||||
|
else "- **Area:** N/A",
|
||||||
|
f"- **Price/m²:** {unit.get('sqm_price'):,} NOK/m²"
|
||||||
|
if unit.get("sqm_price")
|
||||||
|
else "- **Price/m²:** N/A",
|
||||||
|
f"- **Days on Market:** {unit.get('days_on_market')}",
|
||||||
|
f"- **Status:** {unit.get('sale_status')}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_similar_units_table(data: dict[str, Any]) -> str:
|
||||||
|
"""Render similar units as table."""
|
||||||
|
similar = data.get("similar_units", [])
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"| Address | Listing Price | Selling Price | Area | Price/m² | Days | Status |",
|
||||||
|
"|---------|---------------|---------------|------|----------|------|--------|",
|
||||||
|
]
|
||||||
|
|
||||||
|
for unit in similar:
|
||||||
|
addr = (unit.get("address") or "")[:30]
|
||||||
|
list_price = f"{unit.get('listing_price'):,}" if unit.get("listing_price") else "N/A"
|
||||||
|
sell_price = f"{unit.get('selling_price'):,}" if unit.get("selling_price") else "N/A"
|
||||||
|
area = f"{unit.get('usable_area')}" if unit.get("usable_area") else "N/A"
|
||||||
|
price_sqm = f"{unit.get('sqm_price'):,}" if unit.get("sqm_price") else "N/A"
|
||||||
|
days = unit.get("days_on_market") or "N/A"
|
||||||
|
status = unit.get("sale_status") or "N/A"
|
||||||
|
lines.append(
|
||||||
|
f"| {addr} | {list_price} | {sell_price} | {area} m² | {price_sqm} | {days} | {status} |"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Diff renderer
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def render_diff(payload: dict[str, Any], fmt: OutputFormat) -> str:
|
||||||
|
"""Render diff of new/removed/changed listings."""
|
||||||
|
_validate_format(fmt)
|
||||||
|
|
||||||
|
if fmt == "json":
|
||||||
|
return json.dumps(payload, indent=2, default=str)
|
||||||
|
elif fmt == "markdown":
|
||||||
|
return _render_diff_markdown(payload)
|
||||||
|
else: # table
|
||||||
|
return _render_diff_table(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_diff_markdown(data: dict[str, Any]) -> str:
|
||||||
|
"""Render diff as markdown."""
|
||||||
|
new = data.get("new_ads", [])
|
||||||
|
removed = data.get("removed_ads", [])
|
||||||
|
changed = data.get("changed_ads", [])
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"# Listing Diff",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
if new:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"## 🆕 New Listings ({len(new)})",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for ad in new:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"- {ad.get('title', 'Unknown')} ({ad.get('address')})",
|
||||||
|
f" - Price: {ad.get('asking_price'):,} NOK"
|
||||||
|
if ad.get("asking_price")
|
||||||
|
else " - Price: N/A",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if removed:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"## ❌ Removed Listings ({len(removed)})",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for ad in removed:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"- {ad.get('title', 'Unknown')} ({ad.get('address')})",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"## 🔄 Changed Listings ({len(changed)})",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for change in changed:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"- {change.get('title', 'Unknown')} ({change.get('address')})",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
diffs = change.get("diffs", {})
|
||||||
|
for key, (old, new_val) in diffs.items():
|
||||||
|
lines.append(f" - {key}: {old} → {new_val}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_diff_table(data: dict[str, Any]) -> str:
|
||||||
|
"""Render diff as table."""
|
||||||
|
new = data.get("new_ads", [])
|
||||||
|
removed = data.get("removed_ads", [])
|
||||||
|
changed = data.get("changed_ads", [])
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"| Type | Address | Price | Status |",
|
||||||
|
"|------|---------|-------|--------|",
|
||||||
|
]
|
||||||
|
|
||||||
|
for ad in new:
|
||||||
|
addr = (ad.get("address") or "")[:30]
|
||||||
|
price = f"{ad.get('asking_price'):,}" if ad.get("asking_price") else "N/A"
|
||||||
|
lines.append(f"| 🆕 New | {addr} | {price} | - |")
|
||||||
|
|
||||||
|
for ad in removed:
|
||||||
|
addr = (ad.get("address") or "")[:30]
|
||||||
|
lines.append(f"| ❌ Removed | {addr} | - | - |")
|
||||||
|
|
||||||
|
for change in changed:
|
||||||
|
addr = (change.get("address") or "")[:30]
|
||||||
|
diffs = change.get("diffs", {})
|
||||||
|
changed_fields = ", ".join(diffs.keys())
|
||||||
|
lines.append(f"| 🔄 Changed | {addr} | - | {changed_fields} |")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Score breakdown renderer
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def render_score_breakdown(payload: dict[str, Any], fmt: OutputFormat) -> str:
|
||||||
|
"""Render score breakdown details."""
|
||||||
|
_validate_format(fmt)
|
||||||
|
|
||||||
|
if fmt == "json":
|
||||||
|
return json.dumps(payload, indent=2, default=str)
|
||||||
|
else:
|
||||||
|
return _render_score_breakdown_markdown(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_score_breakdown_markdown(data: dict[str, Any]) -> str:
|
||||||
|
"""Render score breakdown as markdown."""
|
||||||
|
scores = data.get("scores", {})
|
||||||
|
lines = [
|
||||||
|
"# Score Breakdown",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
for key, value in scores.items():
|
||||||
|
lines.append(f"- **{key}:** {value}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Cache stats renderer
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def render_cache_stats(payload: dict[str, Any], fmt: OutputFormat) -> str:
|
||||||
|
"""Render cache statistics."""
|
||||||
|
_validate_format(fmt)
|
||||||
|
|
||||||
|
if fmt == "json":
|
||||||
|
return json.dumps(payload, indent=2, default=str)
|
||||||
|
elif fmt == "markdown":
|
||||||
|
return _render_cache_stats_markdown(payload)
|
||||||
|
else: # table
|
||||||
|
return _render_cache_stats_table(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_cache_stats_markdown(data: dict[str, Any]) -> str:
|
||||||
|
"""Render cache stats as markdown."""
|
||||||
|
lines = [
|
||||||
|
"# Cache Statistics",
|
||||||
|
"",
|
||||||
|
f"- **Total FINN Ads:** {data.get('total_finn_ads', 0)}",
|
||||||
|
f"- **Total Eiendom Units:** {data.get('total_eiendom_units', 0)}",
|
||||||
|
f"- **Total Search Runs:** {data.get('total_search_runs', 0)}",
|
||||||
|
f"- **Cache Size:** {data.get('cache_size_mb', 0):.2f} MB",
|
||||||
|
f"- **Last Updated:** {data.get('last_updated')}",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_cache_stats_table(data: dict[str, Any]) -> str:
|
||||||
|
"""Render cache stats as table."""
|
||||||
|
lines = [
|
||||||
|
"| Metric | Value |",
|
||||||
|
"|--------|-------|",
|
||||||
|
f"| Total FINN Ads | {data.get('total_finn_ads', 0)} |",
|
||||||
|
f"| Total Eiendom Units | {data.get('total_eiendom_units', 0)} |",
|
||||||
|
f"| Total Search Runs | {data.get('total_search_runs', 0)} |",
|
||||||
|
f"| Cache Size | {data.get('cache_size_mb', 0):.2f} MB |",
|
||||||
|
f"| Last Updated | {data.get('last_updated')} |",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
+145
-1
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
@@ -13,7 +14,24 @@ from .eiendom_no import (
|
|||||||
get_unit,
|
get_unit,
|
||||||
search_unit_from_finn_url,
|
search_unit_from_finn_url,
|
||||||
)
|
)
|
||||||
from .service import get_or_fetch_ad, get_or_fetch_eiendom_unit
|
from .formatting import (
|
||||||
|
render_ad,
|
||||||
|
render_comparison,
|
||||||
|
render_diff,
|
||||||
|
render_shortlist,
|
||||||
|
render_similar_units,
|
||||||
|
)
|
||||||
|
from .service import (
|
||||||
|
analyze_ad,
|
||||||
|
analyze_ad_against_comps,
|
||||||
|
compare_ads,
|
||||||
|
find_similar_to_liked,
|
||||||
|
get_new_ads_since_last_run,
|
||||||
|
get_or_fetch_ad,
|
||||||
|
get_or_fetch_eiendom_unit,
|
||||||
|
get_shortlist,
|
||||||
|
save_feedback,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -151,6 +169,132 @@ def finn_decode_unit_vector(unit_vector: str) -> str:
|
|||||||
return json.dumps({"error": True, "message": str(e)})
|
return json.dumps({"error": True, "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Additional analysis and enrichment tools
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
description=(
|
||||||
|
"Fetch and enrich a single FINN ad with optional Eiendom.no data and comparable units."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def finn_analyze_ad(
|
||||||
|
finnkode: str,
|
||||||
|
include_eiendom_no: bool = True,
|
||||||
|
include_similar_units: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Analyze and enrich a single FINN ad."""
|
||||||
|
try:
|
||||||
|
result = await analyze_ad(
|
||||||
|
finnkode,
|
||||||
|
include_eiendom_no=include_eiendom_no,
|
||||||
|
include_similar_units=include_similar_units,
|
||||||
|
)
|
||||||
|
return render_ad(result.get("ad", {}), "json")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error analyzing ad {finnkode}: {e}")
|
||||||
|
return json.dumps({"error": True, "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
description=(
|
||||||
|
"Evaluate one FINN listing against comparable recently-sold properties from Eiendom.no."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def finn_analyze_ad_against_comps(
|
||||||
|
finnkode: str, listing_status: str = "RECENTLY_SOLD"
|
||||||
|
) -> str:
|
||||||
|
"""Analyze ad against comparable sales."""
|
||||||
|
try:
|
||||||
|
result = await analyze_ad_against_comps(finnkode, listing_status=listing_status)
|
||||||
|
return json.dumps(result, default=str)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error analyzing ad {finnkode} against comps: {e}")
|
||||||
|
return json.dumps({"error": True, "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
description=(
|
||||||
|
"Find properties similar to a listing the user has liked. "
|
||||||
|
"Requires that the user has marked the listing with verdict='liked'."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def finn_find_similar_to_liked_ad(
|
||||||
|
finnkode: str, mode: str = "recommendations", listing_status: str = "FOR_SALE"
|
||||||
|
) -> str:
|
||||||
|
"""Find properties similar to a liked ad."""
|
||||||
|
try:
|
||||||
|
result = await find_similar_to_liked(finnkode, mode=mode, listing_status=listing_status)
|
||||||
|
return render_similar_units(result, "json")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error finding similar to {finnkode}: {e}")
|
||||||
|
return json.dumps({"error": True, "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(description="Compare multiple FINN listings side by side with optional enrichment.")
|
||||||
|
async def finn_compare_ads(
|
||||||
|
finnkoder: list[str],
|
||||||
|
include_eiendom_no: bool = True,
|
||||||
|
include_comps: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""Compare multiple ads."""
|
||||||
|
try:
|
||||||
|
result = await compare_ads(
|
||||||
|
finnkoder,
|
||||||
|
include_eiendom_no=include_eiendom_no,
|
||||||
|
include_comps=include_comps,
|
||||||
|
)
|
||||||
|
return render_comparison(result, "json")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error comparing ads: {e}")
|
||||||
|
return json.dumps({"error": True, "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
description="Store user feedback (verdict, notes) for a FINN listing. "
|
||||||
|
"Enables similar-unit recommendations and shortlist filtering."
|
||||||
|
)
|
||||||
|
async def finn_save_feedback(finnkode: str, verdict: str, notes: str | None = None) -> str:
|
||||||
|
"""Save user feedback for a listing."""
|
||||||
|
try:
|
||||||
|
result = save_feedback(finnkode, verdict, notes)
|
||||||
|
return json.dumps(result, default=str)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving feedback for {finnkode}: {e}")
|
||||||
|
return json.dumps({"error": True, "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
description="Fetch the stored shortlist from a previous search run. "
|
||||||
|
"Returns the ranked listings with all enrichment data."
|
||||||
|
)
|
||||||
|
def finn_get_shortlist(run_id: int | None = None, limit: int = 10) -> str:
|
||||||
|
"""Get stored shortlist."""
|
||||||
|
try:
|
||||||
|
result = get_shortlist(run_id, limit)
|
||||||
|
return render_shortlist(result, "json")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching shortlist: {e}")
|
||||||
|
return json.dumps({"error": True, "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
description=(
|
||||||
|
"Detect new, removed, and changed listings in a FINN search URL "
|
||||||
|
"compared to the previous run. Shows price/status diffs on changed listings."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def finn_get_new_ads_since_last_run(search_url: str) -> str:
|
||||||
|
"""Get new/removed/changed listings since last run."""
|
||||||
|
try:
|
||||||
|
result = get_new_ads_since_last_run(search_url)
|
||||||
|
return render_diff(result, "json")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching diff: {e}")
|
||||||
|
return json.dumps({"error": True, "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Run the FastMCP stdio server."""
|
"""Run the FastMCP stdio server."""
|
||||||
mcp.run(transport="stdio")
|
mcp.run(transport="stdio")
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class FinnAd(BaseModel):
|
|||||||
detail_fetched_at: datetime | None = None
|
detail_fetched_at: datetime | None = None
|
||||||
eiendom_unit_code: str | None = None
|
eiendom_unit_code: str | None = None
|
||||||
|
|
||||||
model_config = ConfigDict(serializers={datetime: lambda v: v.isoformat()})
|
model_config = ConfigDict()
|
||||||
|
|
||||||
|
|
||||||
class EiendomUnit(BaseModel):
|
class EiendomUnit(BaseModel):
|
||||||
@@ -87,7 +87,7 @@ class EiendomUnit(BaseModel):
|
|||||||
unit_vector: str | None = None
|
unit_vector: str | None = None
|
||||||
fetched_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
fetched_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
model_config = ConfigDict(serializers={datetime: lambda v: v.isoformat()})
|
model_config = ConfigDict()
|
||||||
|
|
||||||
|
|
||||||
class SimilarUnit(BaseModel):
|
class SimilarUnit(BaseModel):
|
||||||
@@ -112,7 +112,7 @@ class SimilarUnit(BaseModel):
|
|||||||
finalized_at: datetime | None = None
|
finalized_at: datetime | None = None
|
||||||
listing_status: str = Field(default="RECENTLY_SOLD")
|
listing_status: str = Field(default="RECENTLY_SOLD")
|
||||||
|
|
||||||
model_config = ConfigDict(serializers={datetime: lambda v: v.isoformat() if v else None})
|
model_config = ConfigDict()
|
||||||
|
|
||||||
|
|
||||||
class UnitVector(BaseModel):
|
class UnitVector(BaseModel):
|
||||||
|
|||||||
+177
-4
@@ -1,13 +1,27 @@
|
|||||||
"""Service layer for cache-aware fetching of FINN ads and Eiendom.no units."""
|
"""Service layer for cache-aware fetching of FINN ads and Eiendom.no units."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from .ad import fetch_ad_details
|
from .ad import fetch_ad_details
|
||||||
from .cache import get_eiendom_unit as get_cached_eiendom_unit
|
from .analysis import analyze_search as run_analysis_search
|
||||||
from .cache import get_finn_ad, init_db, save_eiendom_unit, save_finn_ad
|
from .cache import (
|
||||||
|
get_eiendom_unit as get_cached_eiendom_unit,
|
||||||
|
get_finn_ad,
|
||||||
|
init_db,
|
||||||
|
save_eiendom_unit,
|
||||||
|
save_finn_ad,
|
||||||
|
)
|
||||||
from .config import FINN_CACHE_PATH
|
from .config import FINN_CACHE_PATH
|
||||||
from .eiendom_no import get_unit
|
from .eiendom_no import (
|
||||||
from .models import EiendomUnit, FinnAd
|
build_unit_vector,
|
||||||
|
decode_unit_vector,
|
||||||
|
get_similar_units,
|
||||||
|
get_unit,
|
||||||
|
search_unit_from_finn_url,
|
||||||
|
)
|
||||||
|
from .feedback import save_feedback as save_feedback_impl
|
||||||
|
from .models import EiendomUnit, FinnAd, SimilarUnit
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -33,3 +47,162 @@ async def get_or_fetch_eiendom_unit(
|
|||||||
if unit is not None:
|
if unit is not None:
|
||||||
save_eiendom_unit(conn, unit)
|
save_eiendom_unit(conn, unit)
|
||||||
return unit
|
return unit
|
||||||
|
|
||||||
|
|
||||||
|
async def get_or_fetch_similar_units(
|
||||||
|
unit_code: str, listing_status: str = "RECENTLY_SOLD", force_refresh: bool = False
|
||||||
|
) -> list[SimilarUnit]:
|
||||||
|
"""Get similar units (comps) from cache or fetch fresh."""
|
||||||
|
# Similar units don't have a separate cache table; fetch fresh each time per PRD
|
||||||
|
# (or cache them in search_runs if doing diff detection)
|
||||||
|
return await get_similar_units(unit_code, listing_status=listing_status)
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_eiendom_unit_from_finn_url(finn_url: str) -> EiendomUnit | None:
|
||||||
|
"""Resolve an Eiendom.no unit from a FINN listing URL."""
|
||||||
|
return await search_unit_from_finn_url(finn_url)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Orchestration functions — delegate to analysis.py
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def analyze_search(
|
||||||
|
search_url: str,
|
||||||
|
*,
|
||||||
|
max_pages: int = 3,
|
||||||
|
detail_limit: int = 20,
|
||||||
|
include_details: bool = True,
|
||||||
|
include_eiendom_no: bool = True,
|
||||||
|
include_similar_units_for_shortlist: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Analyze a FINN search URL and return a ranked shortlist."""
|
||||||
|
return await run_analysis_search(
|
||||||
|
search_url,
|
||||||
|
max_pages=max_pages,
|
||||||
|
fetch_details=include_details,
|
||||||
|
detail_limit=detail_limit,
|
||||||
|
include_eiendom_no=include_eiendom_no,
|
||||||
|
include_similar_units_for_shortlist=include_similar_units_for_shortlist,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def analyze_ad(
|
||||||
|
finnkode: str,
|
||||||
|
*,
|
||||||
|
include_eiendom_no: bool = True,
|
||||||
|
include_similar_units: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fetch and enrich a single FINN ad with analysis."""
|
||||||
|
ad = await get_or_fetch_ad(finnkode)
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"ad": ad.model_dump(),
|
||||||
|
}
|
||||||
|
if include_eiendom_no and ad.eiendom_unit_code:
|
||||||
|
unit = await get_or_fetch_eiendom_unit(ad.eiendom_unit_code)
|
||||||
|
if unit:
|
||||||
|
result["eiendom_unit"] = unit.model_dump()
|
||||||
|
if include_similar_units:
|
||||||
|
similar = await get_or_fetch_similar_units(ad.eiendom_unit_code)
|
||||||
|
result["similar_units"] = [s.model_dump() for s in similar]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def analyze_ad_against_comps(
|
||||||
|
finnkode: str, listing_status: str = "RECENTLY_SOLD"
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Evaluate one listing against recent comparable sales."""
|
||||||
|
ad = await get_or_fetch_ad(finnkode)
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"ad": ad.model_dump(),
|
||||||
|
}
|
||||||
|
if ad.eiendom_unit_code:
|
||||||
|
unit = await get_or_fetch_eiendom_unit(ad.eiendom_unit_code)
|
||||||
|
if unit:
|
||||||
|
result["eiendom_unit"] = unit.model_dump()
|
||||||
|
comps = await get_or_fetch_similar_units(
|
||||||
|
ad.eiendom_unit_code, listing_status=listing_status
|
||||||
|
)
|
||||||
|
result["comparable_units"] = [c.model_dump() for c in comps]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def find_similar_to_liked(
|
||||||
|
finnkode: str, *, mode: str = "recommendations", listing_status: str = "FOR_SALE"
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Find properties similar to a listing the user has liked."""
|
||||||
|
# Requires that feedback.verdict = "liked" exists for this finnkode
|
||||||
|
ad = await get_or_fetch_ad(finnkode)
|
||||||
|
if not ad.eiendom_unit_code:
|
||||||
|
raise ValueError(
|
||||||
|
f"Finnkode {finnkode} has no Eiendom.no unit_code; cannot find similar properties"
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO: verify feedback verdict = "liked" exists
|
||||||
|
unit = await get_or_fetch_eiendom_unit(ad.eiendom_unit_code)
|
||||||
|
if not unit:
|
||||||
|
raise ValueError(f"Cannot enrich finnkode {finnkode} with Eiendom.no data")
|
||||||
|
|
||||||
|
similar = await get_or_fetch_similar_units(ad.eiendom_unit_code, listing_status=listing_status)
|
||||||
|
return {
|
||||||
|
"base_ad": ad.model_dump(),
|
||||||
|
"similar_listings": [s.model_dump() for s in similar],
|
||||||
|
"mode": mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def compare_ads(
|
||||||
|
finnkoder: list[str], *, include_eiendom_no: bool = True, include_comps: bool = True
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Compare multiple FINN listings side by side."""
|
||||||
|
ads = []
|
||||||
|
for finnkode in finnkoder:
|
||||||
|
ad = await get_or_fetch_ad(finnkode)
|
||||||
|
ad_data = ad.model_dump()
|
||||||
|
|
||||||
|
if include_eiendom_no and ad.eiendom_unit_code:
|
||||||
|
unit = await get_or_fetch_eiendom_unit(ad.eiendom_unit_code)
|
||||||
|
if unit:
|
||||||
|
ad_data["eiendom_unit"] = unit.model_dump()
|
||||||
|
if include_comps:
|
||||||
|
comps = await get_or_fetch_similar_units(
|
||||||
|
ad.eiendom_unit_code, listing_status="RECENTLY_SOLD"
|
||||||
|
)
|
||||||
|
ad_data["comps"] = [c.model_dump() for c in comps]
|
||||||
|
|
||||||
|
ads.append(ad_data)
|
||||||
|
|
||||||
|
return {"listings": ads}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Helper functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def build_unit_vector_for_unit_code(unit_code: str) -> dict[str, Any]:
|
||||||
|
"""Build a unit_vector dict for a unit_code (msgpack-encoded)."""
|
||||||
|
return build_unit_vector(unit_code)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_unit_vector_to_dict(unit_vector: str) -> dict[str, Any]:
|
||||||
|
"""Decode a unit_vector string to a dict."""
|
||||||
|
return decode_unit_vector(unit_vector)
|
||||||
|
|
||||||
|
|
||||||
|
def save_feedback(finnkode: str, verdict: str, notes: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Store user feedback/verdict for a listing."""
|
||||||
|
return save_feedback_impl(finnkode, verdict, notes)
|
||||||
|
|
||||||
|
|
||||||
|
def get_shortlist(run_id: int | None = None, limit: int = 10) -> dict[str, Any]:
|
||||||
|
"""Fetch stored shortlist from a search run."""
|
||||||
|
# TODO: implement via search_runs table in cache.py
|
||||||
|
return {"shortlist": [], "run_id": run_id, "limit": limit}
|
||||||
|
|
||||||
|
|
||||||
|
def get_new_ads_since_last_run(search_url: str) -> dict[str, Any]:
|
||||||
|
"""Detect new/removed/changed listings vs the previous run."""
|
||||||
|
# TODO: implement via search_runs table in cache.py
|
||||||
|
return {"new_ads": [], "removed_ads": [], "changed_ads": [], "search_url": search_url}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
finn-eiendom = "finn_eiendom.cli:app"
|
||||||
finn-eiendom-mcp = "finn_eiendom.mcp_server:main"
|
finn-eiendom-mcp = "finn_eiendom.mcp_server:main"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""Tests for formatting module."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from finn_eiendom import formatting
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_ad_json():
|
||||||
|
"""Test rendering ad as JSON."""
|
||||||
|
ad = {
|
||||||
|
"finnkode": "123456",
|
||||||
|
"title": "Nice apartment",
|
||||||
|
"address": "Hovedstreet 1",
|
||||||
|
"asking_price": 5000000,
|
||||||
|
"area_m2": 80,
|
||||||
|
}
|
||||||
|
result = formatting.render_ad(ad, "json")
|
||||||
|
assert "123456" in result
|
||||||
|
assert "Nice apartment" in result
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_ad_markdown():
|
||||||
|
"""Test rendering ad as Markdown."""
|
||||||
|
ad = {
|
||||||
|
"finnkode": "123456",
|
||||||
|
"title": "Nice apartment",
|
||||||
|
"address": "Hovedstreet 1",
|
||||||
|
"asking_price": 5000000,
|
||||||
|
"area_m2": 80,
|
||||||
|
}
|
||||||
|
result = formatting.render_ad(ad, "markdown")
|
||||||
|
assert "Nice apartment" in result
|
||||||
|
assert "Hovedstreet 1" in result
|
||||||
|
assert "#" in result # Markdown headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_shortlist_json():
|
||||||
|
"""Test rendering shortlist as JSON."""
|
||||||
|
data = {
|
||||||
|
"shortlist": [
|
||||||
|
{
|
||||||
|
"finnkode": "1",
|
||||||
|
"title": "Ad 1",
|
||||||
|
"address": "Street 1",
|
||||||
|
"asking_price": 5000000,
|
||||||
|
"area_m2": 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"finnkode": "2",
|
||||||
|
"title": "Ad 2",
|
||||||
|
"address": "Street 2",
|
||||||
|
"asking_price": 4500000,
|
||||||
|
"area_m2": 75,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = formatting.render_shortlist(data, "json")
|
||||||
|
assert "Ad 1" in result
|
||||||
|
assert "Ad 2" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_shortlist_markdown():
|
||||||
|
"""Test rendering shortlist as Markdown."""
|
||||||
|
data = {
|
||||||
|
"shortlist": [
|
||||||
|
{
|
||||||
|
"finnkode": "1",
|
||||||
|
"title": "Ad 1",
|
||||||
|
"address": "Street 1",
|
||||||
|
"asking_price": 5000000,
|
||||||
|
"area_m2": 80,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = formatting.render_shortlist(data, "markdown")
|
||||||
|
assert "Ad 1" in result
|
||||||
|
assert "Street 1" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_shortlist_table():
|
||||||
|
"""Test rendering shortlist as table."""
|
||||||
|
data = {
|
||||||
|
"shortlist": [
|
||||||
|
{
|
||||||
|
"finnkode": "1",
|
||||||
|
"title": "Ad 1",
|
||||||
|
"address": "Street 1",
|
||||||
|
"asking_price": 5000000,
|
||||||
|
"area_m2": 80,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = formatting.render_shortlist(data, "table")
|
||||||
|
assert "|" in result # Table format
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_comparison():
|
||||||
|
"""Test rendering comparison."""
|
||||||
|
data = {
|
||||||
|
"listings": [
|
||||||
|
{
|
||||||
|
"finnkode": "1",
|
||||||
|
"title": "Ad 1",
|
||||||
|
"address": "Street 1",
|
||||||
|
"asking_price": 5000000,
|
||||||
|
"area_m2": 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"finnkode": "2",
|
||||||
|
"title": "Ad 2",
|
||||||
|
"address": "Street 2",
|
||||||
|
"asking_price": 4500000,
|
||||||
|
"area_m2": 75,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = formatting.render_comparison(data, "markdown")
|
||||||
|
assert "Comparison" in result
|
||||||
|
assert "Ad 1" in result
|
||||||
|
assert "Ad 2" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_diff():
|
||||||
|
"""Test rendering diff."""
|
||||||
|
data = {
|
||||||
|
"new_ads": [
|
||||||
|
{
|
||||||
|
"title": "New Ad",
|
||||||
|
"address": "New Street",
|
||||||
|
"asking_price": 5000000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"removed_ads": [],
|
||||||
|
"changed_ads": [],
|
||||||
|
}
|
||||||
|
result = formatting.render_diff(data, "markdown")
|
||||||
|
assert "New" in result
|
||||||
|
assert "New Ad" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_cache_stats():
|
||||||
|
"""Test rendering cache stats."""
|
||||||
|
data = {
|
||||||
|
"total_finn_ads": 100,
|
||||||
|
"total_eiendom_units": 50,
|
||||||
|
"total_search_runs": 10,
|
||||||
|
"cache_size_mb": 25.5,
|
||||||
|
"last_updated": "2024-01-01",
|
||||||
|
}
|
||||||
|
result = formatting.render_cache_stats(data, "json")
|
||||||
|
assert "100" in result
|
||||||
|
assert "50" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_validation():
|
||||||
|
"""Test that invalid formats raise ValueError."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
formatting.render_ad({}, "invalid")
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_unit():
|
||||||
|
"""Test rendering Eiendom unit."""
|
||||||
|
unit = {
|
||||||
|
"unit_code": "code123",
|
||||||
|
"address": "Hovedstreet 1",
|
||||||
|
"rooms": 3,
|
||||||
|
"estimated_selling_price": 5000000,
|
||||||
|
}
|
||||||
|
result = formatting.render_unit(unit, "markdown")
|
||||||
|
assert "Hovedstreet 1" in result
|
||||||
|
assert "3" in result
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"""Tests for Pydantic models in finn_eiendom.models."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from finn_eiendom.models import (
|
||||||
|
EiendomUnit,
|
||||||
|
FinnAd,
|
||||||
|
FinnSearchCard,
|
||||||
|
SimilarUnit,
|
||||||
|
UnitVector,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_finn_search_card_json_roundtrip():
|
||||||
|
"""Test JSON roundtrip for FinnSearchCard model."""
|
||||||
|
data = {
|
||||||
|
"finnkode": "123456789",
|
||||||
|
"url": "https://www.finn.no/realestate/homes/ad.html?finnkode=123456789",
|
||||||
|
"title": "Beautiful apartment in Oslo",
|
||||||
|
"address": "Karl Johans gate 1",
|
||||||
|
"area_m2": 80,
|
||||||
|
"asking_price": 5000000,
|
||||||
|
"total_price": 5200000,
|
||||||
|
"common_costs": 2000,
|
||||||
|
"property_type": "APARTMENT",
|
||||||
|
"ownership_type": "FREEHOLD",
|
||||||
|
"bedrooms": 2,
|
||||||
|
"floor": "3/5",
|
||||||
|
"broker_company": "Estate AS",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create model instance
|
||||||
|
model = FinnSearchCard(**data)
|
||||||
|
|
||||||
|
# Convert to JSON
|
||||||
|
json_str = model.model_dump_json()
|
||||||
|
|
||||||
|
# Parse JSON back to dict
|
||||||
|
parsed_data = json.loads(json_str)
|
||||||
|
|
||||||
|
# Create new instance from parsed data
|
||||||
|
model_from_json = FinnSearchCard(**parsed_data)
|
||||||
|
|
||||||
|
# Assert they're equal
|
||||||
|
assert model == model_from_json
|
||||||
|
|
||||||
|
|
||||||
|
def test_finn_ad_json_roundtrip():
|
||||||
|
"""Test JSON roundtrip for FinnAd model."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
data = {
|
||||||
|
"finnkode": "123456789",
|
||||||
|
"url": "https://www.finn.no/realestate/homes/ad.html?finnkode=123456789",
|
||||||
|
"title": "Beautiful apartment in Oslo",
|
||||||
|
"address": "Karl Johans gate 1",
|
||||||
|
"postal_area": "0150",
|
||||||
|
"district": "Sentrum",
|
||||||
|
"property_type": "APARTMENT",
|
||||||
|
"ownership_type": "FREEHOLD",
|
||||||
|
"asking_price": 5000000,
|
||||||
|
"total_price": 5200000,
|
||||||
|
"shared_debt": 100000,
|
||||||
|
"common_costs": 2000,
|
||||||
|
"municipal_fee": 500,
|
||||||
|
"other_fees": 300,
|
||||||
|
"area_m2": 80,
|
||||||
|
"rooms": 3,
|
||||||
|
"bedrooms": 2,
|
||||||
|
"floor": "3/5",
|
||||||
|
"construction_year": 2000,
|
||||||
|
"energy_rating": "C",
|
||||||
|
"heating": "FJERNVARME",
|
||||||
|
"has_balcony": True,
|
||||||
|
"has_terrace": False,
|
||||||
|
"has_elevator": True,
|
||||||
|
"has_parking": False,
|
||||||
|
"has_garage": False,
|
||||||
|
"listing_description": "A lovely apartment with great views",
|
||||||
|
"broker_name": "John Doe",
|
||||||
|
"broker_company": "Estate AS",
|
||||||
|
"first_seen_at": now,
|
||||||
|
"last_seen_at": now,
|
||||||
|
"detail_fetched_at": now,
|
||||||
|
"eiendom_unit_code": "EI-987654321",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create model instance
|
||||||
|
model = FinnAd(**data)
|
||||||
|
|
||||||
|
# Convert to JSON
|
||||||
|
json_str = model.model_dump_json()
|
||||||
|
|
||||||
|
# Parse JSON back to dict
|
||||||
|
parsed_data = json.loads(json_str)
|
||||||
|
|
||||||
|
# Create new instance from parsed data
|
||||||
|
model_from_json = FinnAd(**parsed_data)
|
||||||
|
|
||||||
|
# Assert they're equal
|
||||||
|
assert model == model_from_json
|
||||||
|
|
||||||
|
|
||||||
|
def test_eiendom_unit_json_roundtrip():
|
||||||
|
"""Test JSON roundtrip for EiendomUnit model."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
data = {
|
||||||
|
"unit_code": "EI-987654321",
|
||||||
|
"address": "Karl Johans gate 1",
|
||||||
|
"lat": 59.9139,
|
||||||
|
"lng": 10.7522,
|
||||||
|
"property_type": "APARTMENT",
|
||||||
|
"floor": 3,
|
||||||
|
"rooms": 3,
|
||||||
|
"construction_year": 2000,
|
||||||
|
"usable_area": 80,
|
||||||
|
"estimated_selling_price": 5500000,
|
||||||
|
"estimated_selling_price_lower": 5200000,
|
||||||
|
"estimated_selling_price_upper": 5800000,
|
||||||
|
"listing_price": 5000000,
|
||||||
|
"listing_sqm_price": 62500,
|
||||||
|
"common_costs": 2000,
|
||||||
|
"days_on_market": 45,
|
||||||
|
"sale_status": "FOR_SALE",
|
||||||
|
"market_placement_score": "GOOD",
|
||||||
|
"unit_vector": "base64encodedvector===",
|
||||||
|
"fetched_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create model instance
|
||||||
|
model = EiendomUnit(**data)
|
||||||
|
|
||||||
|
# Convert to JSON
|
||||||
|
json_str = model.model_dump_json()
|
||||||
|
|
||||||
|
# Parse JSON back to dict
|
||||||
|
parsed_data = json.loads(json_str)
|
||||||
|
|
||||||
|
# Create new instance from parsed data
|
||||||
|
model_from_json = EiendomUnit(**parsed_data)
|
||||||
|
|
||||||
|
# Assert they're equal
|
||||||
|
assert model == model_from_json
|
||||||
|
|
||||||
|
|
||||||
|
def test_similar_unit_json_roundtrip():
|
||||||
|
"""Test JSON roundtrip for SimilarUnit model."""
|
||||||
|
data = {
|
||||||
|
"unit_code": "EI-123456789",
|
||||||
|
"address": "Karl Johans gate 2",
|
||||||
|
"lat": 59.9140,
|
||||||
|
"lng": 10.7523,
|
||||||
|
"property_type": "APARTMENT",
|
||||||
|
"floor": 4,
|
||||||
|
"rooms": 3,
|
||||||
|
"construction_year": 2005,
|
||||||
|
"usable_area": 90,
|
||||||
|
"listing_price": 5800000,
|
||||||
|
"selling_price": 5700000,
|
||||||
|
"shared_debt": 120000,
|
||||||
|
"common_costs": 2200,
|
||||||
|
"sqm_price": 63333,
|
||||||
|
"days_on_market": 30,
|
||||||
|
"sale_status": "SOLD",
|
||||||
|
"finalized_at": datetime.now(UTC),
|
||||||
|
"listing_status": "RECENTLY_SOLD",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create model instance
|
||||||
|
model = SimilarUnit(**data)
|
||||||
|
|
||||||
|
# Convert to JSON
|
||||||
|
json_str = model.model_dump_json()
|
||||||
|
|
||||||
|
# Parse JSON back to dict
|
||||||
|
parsed_data = json.loads(json_str)
|
||||||
|
|
||||||
|
# Create new instance from parsed data
|
||||||
|
model_from_json = SimilarUnit(**parsed_data)
|
||||||
|
|
||||||
|
# Assert they're equal
|
||||||
|
assert model == model_from_json
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_vector_json_roundtrip():
|
||||||
|
"""Test JSON roundtrip for UnitVector model."""
|
||||||
|
data = {
|
||||||
|
"lon": 10.7522,
|
||||||
|
"lat": 59.9139,
|
||||||
|
"ptype": "APARTMENT",
|
||||||
|
"floor": 3,
|
||||||
|
"rooms": 3,
|
||||||
|
"built": 2000,
|
||||||
|
"area": 80,
|
||||||
|
"price": 5000000,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create model instance
|
||||||
|
model = UnitVector(**data)
|
||||||
|
|
||||||
|
# Convert to JSON
|
||||||
|
json_str = model.model_dump_json()
|
||||||
|
|
||||||
|
# Parse JSON back to dict
|
||||||
|
parsed_data = json.loads(json_str)
|
||||||
|
|
||||||
|
# Create new instance from parsed data
|
||||||
|
model_from_json = UnitVector(**parsed_data)
|
||||||
|
|
||||||
|
# Assert they're equal
|
||||||
|
assert model == model_from_json
|
||||||
|
|
||||||
|
|
||||||
|
def test_finn_ad_serializer_datetime():
|
||||||
|
"""Test that FinnAd datetime serialization works correctly."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
data = {
|
||||||
|
"finnkode": "123456789",
|
||||||
|
"url": "https://www.finn.no/realestate/homes/ad.html?finnkode=123456789",
|
||||||
|
"first_seen_at": now,
|
||||||
|
"last_seen_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
model = FinnAd(**data)
|
||||||
|
json_str = model.model_dump_json()
|
||||||
|
parsed_data = json.loads(json_str)
|
||||||
|
|
||||||
|
# Check that datetimes are serialized as ISO strings
|
||||||
|
assert isinstance(parsed_data["first_seen_at"], str)
|
||||||
|
assert isinstance(parsed_data["last_seen_at"], str)
|
||||||
|
assert "T" in parsed_data["first_seen_at"] # ISO format contains T
|
||||||
|
assert "Z" in parsed_data["first_seen_at"] or "+" in parsed_data["first_seen_at"] # TZ info
|
||||||
|
|
||||||
|
|
||||||
|
def test_eiendom_unit_serializer_datetime():
|
||||||
|
"""Test that EiendomUnit datetime serialization works correctly."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
data = {
|
||||||
|
"unit_code": "EI-987654321",
|
||||||
|
"fetched_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
model = EiendomUnit(**data)
|
||||||
|
json_str = model.model_dump_json()
|
||||||
|
parsed_data = json.loads(json_str)
|
||||||
|
|
||||||
|
# Check that datetimes are serialized as ISO strings
|
||||||
|
assert isinstance(parsed_data["fetched_at"], str)
|
||||||
|
assert "T" in parsed_data["fetched_at"] # ISO format contains T
|
||||||
|
assert "Z" in parsed_data["fetched_at"] or "+" in parsed_data["fetched_at"] # Timezone info
|
||||||
|
|
||||||
|
|
||||||
|
def test_similar_unit_serializer_datetime():
|
||||||
|
"""Test that SimilarUnit datetime serialization works correctly (handles None)."""
|
||||||
|
data = {
|
||||||
|
"unit_code": "EI-123456789",
|
||||||
|
"finalized_at": None, # This should serialize to null
|
||||||
|
}
|
||||||
|
|
||||||
|
model = SimilarUnit(**data)
|
||||||
|
json_str = model.model_dump_json()
|
||||||
|
parsed_data = json.loads(json_str)
|
||||||
|
|
||||||
|
# Check that None datetimes serialize to null
|
||||||
|
assert parsed_data["finalized_at"] is None
|
||||||
Reference in New Issue
Block a user