feat(refactor): Document refactoring progress and phases in markdown

feat(scripts): Add backfill script for content_hash in cache tables

feat(scripts): Create recompute script for analysis_cache population

test(tests): Implement comprehensive tests for analysis module functions

fix(tests): Update CLI tests to assert errors on stderr instead of stdout

fix(tests): Adjust MCP integration tests to pass context parameter correctly

fix(tests): Modify service tests to return hash on save functions for consistency
This commit is contained in:
Ole
2026-05-29 15:16:57 +00:00
parent 5b772b2ae5
commit 55d93894ac
18 changed files with 1457 additions and 60 deletions
+52 -4
View File
@@ -32,12 +32,14 @@ from .cache import (
save_analysis,
save_eiendom_unit,
save_finn_ad,
save_search_run,
save_similar_units,
)
from .config import (
EIENDOM_NO_CACHE_TTL_HOURS,
EIENDOM_NO_CACHE_TTL_SIMILAR_UNITS_DAYS,
EIENDOM_NO_CACHE_TTL_STRUCTURAL_DAYS,
FINN_CACHE_PATH,
FINN_CACHE_TTL_AD_HOURS,
FINN_CACHE_TTL_AD_STRUCTURAL_DAYS,
FINN_DETAIL_LIMIT,
FINN_MAX_SEARCH_PAGES,
)
@@ -147,6 +149,12 @@ async def analyze_ad(
"""
conn = cache.init_db(FINN_CACHE_PATH)
# ------------------------------------------------------------------
# 0. Backfill eiendom_unit_code if provided.
# ------------------------------------------------------------------
if unit_code and not finn_ad.eiendom_unit_code:
finn_ad.eiendom_unit_code = unit_code
# ------------------------------------------------------------------
# 1. Ensure the ad is in the DB so we have a stable hash to key on.
# ------------------------------------------------------------------
@@ -173,8 +181,10 @@ async def analyze_ad(
comps_hash_changed = False
if enriched:
# Convert similar units TTL from days to hours
ttl_hours = EIENDOM_NO_CACHE_TTL_SIMILAR_UNITS_DAYS * 24
similar_units = cache.get_similar_units(
conn, enriched.unit_code, "RECENTLY_SOLD", ttl_hours=EIENDOM_NO_CACHE_TTL_HOURS
conn, enriched.unit_code, "RECENTLY_SOLD", ttl_hours=ttl_hours
)
if not similar_units:
vector = enriched.unit_vector or eiendom_no.build_unit_vector(enriched)
@@ -210,11 +220,38 @@ async def analyze_ad(
categories = scoring.classify_ad(scores)
summary = _build_ad_summary(finn_ad, enriched, similar_units, scores, categories)
# Get price history and cache age metadata
from .cache import get_price_history, get_finn_ad_hash
from datetime import datetime, UTC, timedelta
price_history = get_price_history(conn, finn_ad.finnkode, limit=20)
# Compute cache age: how long since we last fetched this ad
cursor = conn.cursor()
cursor.execute(
"SELECT fetched_at, last_verified_at FROM finn_ads WHERE finnkode = ?",
(finn_ad.finnkode,),
)
db_row = cursor.fetchone()
cache_age = None
if db_row:
fetched_at = datetime.fromisoformat(db_row["fetched_at"])
last_verified = db_row["last_verified_at"]
if last_verified:
last_verified_at = datetime.fromisoformat(last_verified)
structural_age_days = (datetime.now(UTC) - fetched_at).days
price_age_hours = (datetime.now(UTC) - last_verified_at).total_seconds() / 3600
cache_age = {
"structural_days": structural_age_days,
"price_hours": round(price_age_hours, 1),
}
result = {
"finnkode": finn_ad.finnkode,
"url": finn_ad.url,
"title": finn_ad.title,
"address": finn_ad.address,
"listing_description": finn_ad.listing_description,
"district": finn_ad.district,
"property_type": finn_ad.property_type,
"ownership_type": finn_ad.ownership_type,
@@ -236,6 +273,8 @@ async def analyze_ad(
"score": scores,
"categories": categories,
"summary": summary,
"price_history": price_history,
"cache_age": cache_age,
"eiendom_unit": enriched.model_dump(mode="json") if enriched else None,
"similar_units": [unit.model_dump(mode="json") for unit in similar_units],
}
@@ -262,7 +301,7 @@ async def _fetch_card_to_db(
treats None as a skip without aborting the whole batch.
"""
try:
finn_ad = cache.get_finn_ad(conn, card.finnkode, ttl_hours=FINN_CACHE_TTL_AD_HOURS)
finn_ad = cache.get_finn_ad(conn, card.finnkode, ttl_hours=FINN_CACHE_TTL_AD_STRUCTURAL_DAYS * 24)
if finn_ad is None:
finn_ad = await ad_module.fetch_ad_details(card.finnkode, client=client)
save_finn_ad(conn, finn_ad)
@@ -275,6 +314,11 @@ async def _fetch_card_to_db(
try:
matched_unit = await eiendom_no.search_unit_from_finn_url(card.url)
unit_code = matched_unit.unit_code if matched_unit else None
# Backfill unit_code into the ad object and persist.
# This ensures the cached ad has the eiendom_unit_code field populated.
if unit_code and not finn_ad.eiendom_unit_code:
finn_ad.eiendom_unit_code = unit_code
_, _ = save_finn_ad(conn, finn_ad)
except Exception as exc:
logger.warning("Eiendom.no unit search failed for %s: %s", card.finnkode, exc)
@@ -384,6 +428,10 @@ async def analyze_search(
f"{skipped_count} skipped."
)
# Record this search run in the database
finnkodes = [card.finnkode for card in cards]
save_search_run(conn, search_url, finnkodes)
return {
"search_url": search_url,
"search_cards": [card.model_dump(mode="json") for card in cards],
+205 -5
View File
@@ -80,12 +80,14 @@ def init_db(path: str | None = None) -> sqlite3.Connection:
url TEXT,
payload TEXT NOT NULL,
content_hash TEXT,
fetched_at TEXT NOT NULL
fetched_at TEXT NOT NULL,
last_verified_at TEXT
)
"""
)
# Migration: add content_hash column if the table already existed without it.
# Migrations: add columns if the table already existed without them.
_add_column_if_missing(cursor, "finn_ads", "content_hash", "TEXT")
_add_column_if_missing(cursor, "finn_ads", "last_verified_at", "TEXT")
cursor.execute(
"""
@@ -136,6 +138,50 @@ def init_db(path: str | None = None) -> sqlite3.Connection:
"""
)
# New tables for Phase 2 enhancements
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS user_feedback (
finnkode TEXT PRIMARY KEY,
verdict TEXT NOT NULL,
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS price_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
finnkode TEXT NOT NULL,
total_price INTEGER,
asking_price INTEGER,
sale_status TEXT,
recorded_at TEXT NOT NULL
)
"""
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_price_history_finnkode_recorded ON price_history(finnkode, recorded_at)")
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS search_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
search_url TEXT NOT NULL,
finnkodes TEXT NOT NULL,
created_at TEXT NOT NULL
)
"""
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_search_runs_url_created ON search_runs(search_url, created_at)")
# Create indexes for efficient staleness queries
cursor.execute("CREATE INDEX IF NOT EXISTS idx_finn_ads_verified ON finn_ads(last_verified_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_eiendom_units_fetched ON eiendom_units(fetched_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_units_fetched ON similar_units(fetched_at)")
conn.commit()
return conn
@@ -258,6 +304,8 @@ def save_finn_ad(conn: sqlite3.Connection, ad: FinnAd) -> tuple[str, bool]:
if ad.detail_fetched_at
else datetime.now(UTC).isoformat()
)
# Update last_verified_at to now when saving (indicates we just checked the data)
last_verified_at = datetime.now(UTC).isoformat()
# Check existing hash before writing.
cursor.execute(
@@ -270,9 +318,9 @@ def save_finn_ad(conn: sqlite3.Connection, ad: FinnAd) -> tuple[str, bool]:
cursor.execute(
"INSERT OR REPLACE INTO finn_ads"
" (finnkode, url, payload, content_hash, fetched_at)"
" VALUES (?, ?, ?, ?, ?)",
(ad.finnkode, ad.url, json.dumps(payload, default=_json_default), new_hash, fetched_at),
" (finnkode, url, payload, content_hash, fetched_at, last_verified_at)"
" VALUES (?, ?, ?, ?, ?, ?)",
(ad.finnkode, ad.url, json.dumps(payload, default=_json_default), new_hash, fetched_at, last_verified_at),
)
conn.commit()
logger.debug("finn_ad %s saved (hash=%s)", ad.finnkode, new_hash[:8])
@@ -522,6 +570,158 @@ def invalidate_analysis(conn: sqlite3.Connection, finnkode: str) -> None:
conn.commit()
# ---------------------------------------------------------------------------
# User feedback
# ---------------------------------------------------------------------------
def save_feedback(
conn: sqlite3.Connection, finnkode: str, verdict: str, notes: str | None = None
) -> dict[str, Any]:
"""Store user feedback/verdict for a FINN listing."""
cursor = conn.cursor()
now = datetime.now(UTC).isoformat()
cursor.execute(
"INSERT OR REPLACE INTO user_feedback"
" (finnkode, verdict, notes, created_at, updated_at)"
" VALUES (?, ?, ?, ?, ?)",
(finnkode, verdict, notes, now, now),
)
conn.commit()
logger.debug("feedback saved for %s (verdict=%s)", finnkode, verdict)
return {"finnkode": finnkode, "verdict": verdict, "notes": notes}
def get_feedback(conn: sqlite3.Connection, finnkode: str) -> dict[str, Any] | None:
"""Retrieve stored feedback for a FINN listing."""
cursor = conn.cursor()
cursor.execute(
"SELECT finnkode, verdict, notes, created_at, updated_at FROM user_feedback WHERE finnkode = ?",
(finnkode,),
)
row = cursor.fetchone()
if not row:
return None
return {
"finnkode": row["finnkode"],
"verdict": row["verdict"],
"notes": row["notes"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def get_feedback_by_verdict(
conn: sqlite3.Connection, verdict: str, limit: int = 100
) -> list[dict[str, Any]]:
"""Retrieve all stored feedback with a given verdict."""
cursor = conn.cursor()
cursor.execute(
"SELECT finnkode, verdict, notes, created_at, updated_at FROM user_feedback"
" WHERE verdict = ? ORDER BY updated_at DESC LIMIT ?",
(verdict, limit),
)
return [
{
"finnkode": row["finnkode"],
"verdict": row["verdict"],
"notes": row["notes"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
for row in cursor.fetchall()
]
# ---------------------------------------------------------------------------
# Price history
# ---------------------------------------------------------------------------
def save_price_history(
conn: sqlite3.Connection,
finnkode: str,
total_price: int | None = None,
asking_price: int | None = None,
sale_status: str | None = None,
) -> None:
"""Record a price/status snapshot for a listing."""
cursor = conn.cursor()
cursor.execute(
"INSERT INTO price_history (finnkode, total_price, asking_price, sale_status, recorded_at)"
" VALUES (?, ?, ?, ?, ?)",
(finnkode, total_price, asking_price, sale_status, datetime.now(UTC).isoformat()),
)
conn.commit()
logger.debug("price_history recorded for %s (total=%s, asking=%s)", finnkode, total_price, asking_price)
def get_price_history(conn: sqlite3.Connection, finnkode: str, limit: int = 100) -> list[dict[str, Any]]:
"""Retrieve price history for a listing."""
cursor = conn.cursor()
cursor.execute(
"SELECT total_price, asking_price, sale_status, recorded_at FROM price_history"
" WHERE finnkode = ? ORDER BY recorded_at DESC LIMIT ?",
(finnkode, limit),
)
return [
{
"total_price": row["total_price"],
"asking_price": row["asking_price"],
"sale_status": row["sale_status"],
"recorded_at": row["recorded_at"],
}
for row in cursor.fetchall()
]
# ---------------------------------------------------------------------------
# Search runs
# ---------------------------------------------------------------------------
def save_search_run(
conn: sqlite3.Connection, search_url: str, finnkodes: list[str]
) -> None:
"""Record a search run with the finnkodes found."""
cursor = conn.cursor()
finnkodes_json = json.dumps(finnkodes)
cursor.execute(
"INSERT INTO search_runs (search_url, finnkodes, created_at)"
" VALUES (?, ?, ?)",
(search_url, finnkodes_json, datetime.now(UTC).isoformat()),
)
conn.commit()
logger.debug("search_run recorded for %s (%d finnkodes)", search_url, len(finnkodes))
def get_latest_search_run(conn: sqlite3.Connection, search_url: str) -> dict[str, Any] | None:
"""Retrieve the most recent search run for a URL."""
cursor = conn.cursor()
cursor.execute(
"SELECT search_url, finnkodes, created_at FROM search_runs"
" WHERE search_url = ? ORDER BY created_at DESC LIMIT 1",
(search_url,),
)
row = cursor.fetchone()
if not row:
return None
return {
"search_url": row["search_url"],
"finnkodes": json.loads(row["finnkodes"]),
"created_at": row["created_at"],
}
def delete_feedback(conn: sqlite3.Connection, finnkode: str) -> dict[str, Any]:
"""Delete stored feedback for a FINN listing."""
cursor = conn.cursor()
cursor.execute("DELETE FROM user_feedback WHERE finnkode = ?", (finnkode,))
conn.commit()
logger.debug("feedback deleted for %s", finnkode)
return {"finnkode": finnkode, "deleted": True}
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
+63 -2
View File
@@ -320,8 +320,69 @@ def diff(
def stats() -> None:
"""Show cache statistics."""
try:
# TODO: implement cache stats via cache.py
typer.echo("Cache stats (not yet implemented)")
import json
import sqlite3
from .config import FINN_CACHE_PATH
conn = sqlite3.connect(str(FINN_CACHE_PATH))
cursor = conn.cursor()
# Get row counts and hash statistics for each table
tables = ["finn_ads", "eiendom_units", "similar_units", "analysis_cache", "cache_meta"]
stats = {}
for table in tables:
cursor.execute(f"SELECT COUNT(*) FROM {table}")
total = cursor.fetchone()[0]
if total == 0:
stats[table] = {"total_rows": 0}
continue
# For tables with content_hash or deps_hash
if table == "analysis_cache":
cursor.execute(f"SELECT COUNT(*) FROM {table} WHERE deps_hash IS NOT NULL")
with_hash = cursor.fetchone()[0]
elif table != "cache_meta" or True: # All have content_hash or value
cursor.execute(f"SELECT COUNT(*) FROM {table} WHERE content_hash IS NOT NULL")
with_hash = cursor.fetchone()[0]
stats[table] = {
"total_rows": total,
"rows_with_hash": with_hash,
"pct_with_hash": round(100 * with_hash / total, 1) if total > 0 else 0,
}
# Special checks for finn_ads
cursor.execute(
'SELECT COUNT(*) FROM finn_ads '
'WHERE json_extract(payload, "$.eiendom_unit_code") IS NOT NULL '
'AND json_extract(payload, "$.eiendom_unit_code") != "null"'
)
ads_with_unit_code = cursor.fetchone()[0]
if "finn_ads" in stats and stats["finn_ads"]["total_rows"] > 0:
stats["finn_ads"]["with_eiendom_unit_code"] = ads_with_unit_code
stats["finn_ads"]["pct_with_unit_code"] = round(100 * ads_with_unit_code / stats["finn_ads"]["total_rows"], 1)
# Get fetched_at date ranges
for table in ["finn_ads", "eiendom_units", "similar_units"]:
cursor.execute(f"SELECT MIN(fetched_at), MAX(fetched_at) FROM {table}")
min_date, max_date = cursor.fetchone()
if min_date and max_date:
stats[table]["oldest_fetch"] = min_date
stats[table]["newest_fetch"] = max_date
conn.close()
# Format output
typer.echo("\n=== Cache Statistics ===\n")
for table, table_stats in stats.items():
typer.echo(f"{table}:")
for key, value in table_stats.items():
typer.echo(f" {key}: {value}")
typer.echo()
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
+21 -3
View File
@@ -11,20 +11,38 @@ FINN_MAX_SEARCH_PAGES = int(os.getenv("FINN_MAX_SEARCH_PAGES", "3"))
FINN_DETAIL_LIMIT = int(os.getenv("FINN_DETAIL_LIMIT", "20"))
FINN_REQUEST_DELAY_SECONDS = float(os.getenv("FINN_REQUEST_DELAY_SECONDS", "2"))
FINN_USER_AGENT = os.getenv("FINN_USER_AGENT", "personal-finn-eiendom-analyzer/0.1")
FINN_CACHE_TTL_SEARCH_MINUTES = int(os.getenv("FINN_CACHE_TTL_SEARCH_MINUTES", "60"))
FINN_CACHE_TTL_AD_HOURS = int(os.getenv("FINN_CACHE_TTL_AD_HOURS", "24"))
# Cache TTLs (refactor v2)
# Structural data (address, area, year, etc.) changes rarely; long TTL
FINN_CACHE_TTL_AD_STRUCTURAL_DAYS = int(
os.getenv("FINN_CACHE_TTL_AD_STRUCTURAL_DAYS", "30")
)
# Price/status changes frequently; short TTL for lightweight verification
FINN_CACHE_TTL_AD_PRICE_HOURS = int(os.getenv("FINN_CACHE_TTL_AD_PRICE_HOURS", "6"))
# Search pages/cards also TTL-based (content changes with added/removed listings)
FINN_CACHE_TTL_SEARCH_MINUTES = int(os.getenv("FINN_CACHE_TTL_SEARCH_MINUTES", "360"))
# Eiendom.no API settings
EIENDOM_NO_ENABLED = os.getenv("EIENDOM_NO_ENABLED", "true").lower() == "true"
EIENDOM_NO_BASE_URL = os.getenv("EIENDOM_NO_BASE_URL", "https://api.eiendom.no/api/v1")
EIENDOM_NO_REQUEST_DELAY_SECONDS = float(os.getenv("EIENDOM_NO_REQUEST_DELAY_SECONDS", "1"))
EIENDOM_NO_CACHE_TTL_HOURS = int(os.getenv("EIENDOM_NO_CACHE_TTL_HOURS", "24"))
# Structural data (lat, lng, property_type) has long TTL; estimates have shorter TTL
EIENDOM_NO_CACHE_TTL_STRUCTURAL_DAYS = int(
os.getenv("EIENDOM_NO_CACHE_TTL_STRUCTURAL_DAYS", "30")
)
EIENDOM_NO_CACHE_TTL_ESTIMATE_DAYS = int(
os.getenv("EIENDOM_NO_CACHE_TTL_ESTIMATE_DAYS", "7")
)
EIENDOM_NO_SIMILAR_UNITS_ENABLED = (
os.getenv("EIENDOM_NO_SIMILAR_UNITS_ENABLED", "true").lower() == "true"
)
EIENDOM_NO_SIMILAR_UNITS_DEFAULT_STATUS = os.getenv(
"EIENDOM_NO_SIMILAR_UNITS_DEFAULT_STATUS", "RECENTLY_SOLD"
)
# Similar units (comps) are immutable; very long TTL (only new entries appear over time)
EIENDOM_NO_CACHE_TTL_SIMILAR_UNITS_DAYS = int(
os.getenv("EIENDOM_NO_CACHE_TTL_SIMILAR_UNITS_DAYS", "60")
)
# Logging
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
+20 -17
View File
@@ -3,7 +3,11 @@
import logging
from typing import Any
from .cache import delete_feedback as cache_delete_feedback
from .cache import get_feedback as cache_get_feedback
from .cache import get_feedback_by_verdict
from .cache import init_db
from .cache import save_feedback as cache_save_feedback
from .config import FINN_CACHE_PATH
logger = logging.getLogger(__name__)
@@ -21,15 +25,7 @@ def save_feedback(finnkode: str, verdict: str, notes: str | None = None) -> dict
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,
}
return cache_save_feedback(conn, finnkode, verdict, notes)
def get_feedback(finnkode: str) -> dict[str, Any] | None:
@@ -42,9 +38,21 @@ def get_feedback(finnkode: str) -> dict[str, Any] | None:
Feedback dict if exists, else None
"""
conn = init_db(FINN_CACHE_PATH)
return cache_get_feedback(conn, finnkode)
# TODO: implement via feedback table in cache.py
return None
def get_feedback_by_verdict_impl(verdict: str, limit: int = 100) -> list[dict[str, Any]]:
"""Retrieve all stored feedback with a given verdict.
Args:
verdict: Verdict to filter by
limit: Max results to return
Returns:
List of feedback dicts
"""
conn = init_db(FINN_CACHE_PATH)
return get_feedback_by_verdict(conn, verdict, limit=limit)
def delete_feedback(finnkode: str) -> dict[str, Any]:
@@ -57,9 +65,4 @@ def delete_feedback(finnkode: str) -> dict[str, Any]:
Status dict
"""
conn = init_db(FINN_CACHE_PATH)
# TODO: implement via feedback table in cache.py
return {
"finnkode": finnkode,
"deleted": True,
}
return cache_delete_feedback(conn, finnkode)
+7 -2
View File
@@ -51,8 +51,8 @@ logger = logging.getLogger(__name__)
def _slim_listing(rank: int, item: dict) -> dict:
"""Collapse one full analyze_ad result into a compact listing card.
Drops: listing_description, unit_images, unit_vector, all timestamps,
full similar_units list, score dimension breakdown.
Keeps: listing_description (for AI interpretation), price_history, cache_age, score breakdown.
Drops: unit_images, unit_vector, internal eiendom_unit timestamps.
Derives: avg_comp_sqm_price from similar_units.
"""
eu = item.get("eiendom_unit") or {}
@@ -84,6 +84,8 @@ def _slim_listing(rank: int, item: dict) -> dict:
score = item.get("score") or {}
summary = item.get("summary") or {}
price_history = item.get("price_history") or []
cache_age = item.get("cache_age")
# Keep full score breakdown — 12 dimensions + nearby_transit = ~220 bytes, all signal.
# Drop nothing from scores.
@@ -113,6 +115,7 @@ def _slim_listing(rank: int, item: dict) -> dict:
"url": item.get("url"),
"title": item.get("title"),
"address": item.get("address"),
"listing_description": item.get("listing_description"),
"district": item.get("district"),
"property_type": item.get("property_type"),
"ownership_type": item.get("ownership_type"),
@@ -135,6 +138,8 @@ def _slim_listing(rank: int, item: dict) -> dict:
"categories": item.get("categories"),
"why_interesting": summary.get("why_interesting"),
"risks": summary.get("risks"),
"cache_age": cache_age,
"price_history": price_history[:5], # Last 5 price records
"eiendom": eiendom,
"similar_units": slim_comps,
}
+25 -4
View File
@@ -30,9 +30,16 @@ from .cache import (
invalidate_analysis,
save_eiendom_unit,
save_finn_ad,
save_price_history,
save_similar_units,
)
from .config import EIENDOM_NO_CACHE_TTL_HOURS, FINN_CACHE_PATH, FINN_CACHE_TTL_AD_HOURS
from .config import (
EIENDOM_NO_CACHE_TTL_ESTIMATE_DAYS,
EIENDOM_NO_CACHE_TTL_SIMILAR_UNITS_DAYS,
EIENDOM_NO_CACHE_TTL_STRUCTURAL_DAYS,
FINN_CACHE_PATH,
FINN_CACHE_TTL_AD_STRUCTURAL_DAYS,
)
from .eiendom_no import (
build_unit_vector,
decode_unit_vector,
@@ -56,13 +63,23 @@ async def get_or_fetch_ad(finnkode: str, force_refresh: bool = False) -> FinnAd:
invalidated.
"""
conn = init_db(FINN_CACHE_PATH)
ad = None if force_refresh else get_finn_ad(conn, finnkode, ttl_hours=FINN_CACHE_TTL_AD_HOURS)
# Convert structural TTL from days to hours
ttl_hours = FINN_CACHE_TTL_AD_STRUCTURAL_DAYS * 24
ad = None if force_refresh else get_finn_ad(conn, finnkode, ttl_hours=ttl_hours)
if ad is not None:
return ad
# Cache miss or force_refresh: fetch from remote.
ad = await fetch_ad_details(finnkode)
_, changed = save_finn_ad(conn, ad)
# Record price snapshot for history tracking
save_price_history(
conn,
finnkode,
total_price=ad.total_price,
asking_price=ad.asking_price,
sale_status=None,
)
if changed:
logger.debug("finn_ad %s updated -- invalidating analysis cache", finnkode)
invalidate_analysis(conn, finnkode)
@@ -118,10 +135,12 @@ async def get_or_fetch_eiendom_unit(
the DB row is not updated (analysis_cache stays valid).
"""
conn = init_db(FINN_CACHE_PATH)
# Convert structural TTL from days to hours
ttl_hours = EIENDOM_NO_CACHE_TTL_STRUCTURAL_DAYS * 24
unit = (
None
if force_refresh
else get_cached_eiendom_unit(conn, unit_code, ttl_hours=24)
else get_cached_eiendom_unit(conn, unit_code, ttl_hours=ttl_hours)
)
if unit is not None:
return unit
@@ -157,8 +176,10 @@ async def get_or_fetch_similar_units(
return []
if not force_refresh:
# Convert similar units TTL from days to hours
ttl_hours = EIENDOM_NO_CACHE_TTL_SIMILAR_UNITS_DAYS * 24
cached_similar = get_cached_similar_units(
conn, unit_code, listing_status, ttl_hours=EIENDOM_NO_CACHE_TTL_HOURS
conn, unit_code, listing_status, ttl_hours=ttl_hours
)
if cached_similar:
logger.debug(