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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user