#!/usr/bin/env python3 """ AI-powered scoring of DBA listings using Claude. Usage: python3 score.py results_car_89a242.json python3 score.py results_rtx_3090_623595.json python3 score.py results_car_89a242.json --top 10 # show only top N python3 score.py results_car_89a242.json --save # write ranked output to ranked_*.json python3 score.py results_car_89a242.json --force # ignore cache, re-score everything python3 score.py results_car_89a242.json --prefs "Ikke franske biler" Scores are cached in results_*.json — only new/unscored listings call Claude. Change --prefs to invalidate cache and re-score with new preferences. Requires: ANTHROPIC_API_KEY env var pip install anthropic """ import hashlib, json, os, re, sys, uuid as _uuid from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime from pathlib import Path import anthropic MODEL = "claude-haiku-4-5-20251001" # fast + cheap; swap to sonnet for better ranking API_KEY = "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" MAX_TOKENS = 2048 BASE_DIR = Path(__file__).parent DATA_DIR = BASE_DIR / "data" SCORE_CACHE = BASE_DIR / "data" / "score_cache" # persistent cross-search score cache UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") METRICS_FILE = DATA_DIR / "metrics.json" # Pricing: Claude Haiku 4.5 — https://www.anthropic.com/pricing _PRICE_INPUT_PER_TOKEN = 0.80 / 1_000_000 # $0.80 per MTok _PRICE_OUTPUT_PER_TOKEN = 4.00 / 1_000_000 # $4.00 per MTok def calc_cost(input_tokens: int, output_tokens: int) -> float: return round(input_tokens * _PRICE_INPUT_PER_TOKEN + output_tokens * _PRICE_OUTPUT_PER_TOKEN, 6) def update_metrics(search_id: str, input_tokens: int, output_tokens: int, listings_scored: int) -> None: """Save per-search metrics and update global metrics.json.""" cost = calc_cost(input_tokens, output_tokens) now = datetime.now().isoformat(timespec="seconds") # Per-search metrics search_dir = DATA_DIR / search_id if search_dir.exists(): search_metrics = { "search_id": search_id, "scored_at": now, "model": MODEL, "listings_scored": listings_scored, "input_tokens": input_tokens, "output_tokens": output_tokens, "cost_usd": cost, } (search_dir / "metrics.json").write_text(json.dumps(search_metrics, indent=2)) # Global metrics global_metrics = {} if METRICS_FILE.exists(): try: global_metrics = json.loads(METRICS_FILE.read_text()) except Exception: pass global_metrics["total_searches"] = global_metrics.get("total_searches", 0) + 1 global_metrics["total_listings_scored"] = global_metrics.get("total_listings_scored", 0) + listings_scored global_metrics["total_input_tokens"] = global_metrics.get("total_input_tokens", 0) + input_tokens global_metrics["total_output_tokens"] = global_metrics.get("total_output_tokens", 0) + output_tokens global_metrics["total_cost_usd"] = round(global_metrics.get("total_cost_usd", 0.0) + cost, 6) global_metrics["last_updated"] = now METRICS_FILE.write_text(json.dumps(global_metrics, indent=2)) def prefs_hash(prefs: str) -> str: """Short stable hash of the user's preference string (empty → 'none').""" return hashlib.md5(prefs.strip().encode()).hexdigest()[:8] if prefs.strip() else "none" def _score_cache_key(item_id: str, prefs: str, category: str) -> Path: """Return path to the persistent score cache file for this item+context.""" ph = prefs_hash(prefs) ch = hashlib.md5(category.encode()).hexdigest()[:6] return SCORE_CACHE / f"{item_id}_{ph}_{ch}.json" def load_score_cache(item_id: str, prefs: str, category: str) -> dict | None: """Return cached score dict or None if not cached.""" p = _score_cache_key(item_id, prefs, category) if p.exists(): try: return json.loads(p.read_text()) except Exception: pass return None def save_score_cache(item_id: str, prefs: str, category: str, score_data: dict) -> None: """Persist a score result so future searches with same item/prefs/category hit cache.""" SCORE_CACHE.mkdir(parents=True, exist_ok=True) p = _score_cache_key(item_id, prefs, category) p.write_text(json.dumps(score_data, ensure_ascii=False)) # ── Helpers ─────────────────────────────────────────────────────────────────── def trim_text(raw: str, max_chars: int = 800) -> str: """Cut DBA boilerplate header/footer, keep the meat.""" # Skip past the standard navigation header for marker in ["Varebeskrivelse", "Beskrivelse", "Specifikationer"]: idx = raw.find(marker) if idx != -1: raw = raw[idx:] break # Trim to max length if len(raw) > max_chars: raw = raw[:max_chars] + "…" return raw.strip() def extract_structured_fields(raw: str) -> dict: """Pull key structured fields out of DBA raw_text before trimming.""" fields = {} patterns = { "year": r"(?:Modelår|Årstal|Årgang)[^\d]*(\d{4})", "km": r"Kilometertal\s+([\d\.,]+ km)", "condition": r"Stand\s*:\s*([^\n|]{3,60})", "gear": r"Geartype\s+(\S+)", "fuel": r"Drivmiddel\s+(\S+)", "owners": r"Antal ejere\s+(\d+)", } for key, pattern in patterns.items(): m = re.search(pattern, raw, re.IGNORECASE) if m: fields[key] = m.group(1).strip() return fields def listing_summary(item: dict, idx: int) -> str: """Compact text representation of a listing for the AI prompt.""" raw = item.get("details", {}).get("raw_text", item.get("description", "")) fields = extract_structured_fields(raw) text = trim_text(raw) meta_parts = [] if fields.get("year"): meta_parts.append(f"Årgang: {fields['year']}") if fields.get("km"): meta_parts.append(f"Km: {fields['km']}") if fields.get("fuel"): meta_parts.append(f"Brændstof: {fields['fuel']}") if fields.get("gear"): meta_parts.append(f"Gear: {fields['gear']}") if fields.get("owners"): meta_parts.append(f"Ejere: {fields['owners']}") if fields.get("condition"): meta_parts.append(f"Stand: {fields['condition']}") meta_line = " | ".join(meta_parts) return ( f"--- Annonce #{idx + 1} (ID: {item['id']}) ---\n" f"Navn: {item['name']}\n" f"Pris: {item['price_dkk']} DKK\n" + (f"{meta_line}\n" if meta_line else "") + f"{text}\n" ) def detect_category(items: list[dict]) -> str: """Detect category from item URLs and breadcrumb in raw_text.""" if not items: return "brugte varer" url = items[0].get("url", "") if "/mobility/" in url: return "brugte biler" # Extract breadcrumb from raw_text to detect subcategory raw = items[0].get("details", {}).get("raw_text", "") m = re.search(r"Du er her\s+(.+?)(?:\n|Billedgalleri)", raw) breadcrumb = m.group(1).lower() if m else "" for keywords, context_key in _CATEGORY_MAP: if any(kw in breadcrumb for kw in keywords): return context_key return "brugte varer" KNOWLEDGE_CONTEXT = { "brugte biler": ( "- Kendte reliabilitetsproblemer (fx Peugeot 1.2 PureTech timing-kæde, VW DSG-gearkasse, BMW N47 dieselmotor)\n" "- Km-stand og alder sat i forhold til markedsværdi for den specifikke model og variant\n" "- Kendte stærke og svage modeller (fx Toyota/Mazda høj reliabilitet, Renault/Citroën/Fiat lavere)\n" "- Typiske brugtpriser for modellen baseret på år og km" ), "elektronik": ( "- Produktgenerationens relative ydelse og markedsværdi (fx RTX 4070 > RTX 3080, iPhone 15 > 13)\n" "- Kendte problemer: mining-slid på GPU'er, batterinedgang på telefoner/laptops, kondensatorfejl\n" "- Hvad er en rimelig brugtpris for dette produkt i denne stand?\n" "- Stand er afgørende — 'Som ny' vs 'Brugt - med synlige brugsspor' bør veje tungt" ), "sport": ( "- Kendte mærker og deres relative kvalitet (fx Titleist/Callaway/TaylorMade til golf, Shimano-grupper til cykler)\n" "- Produktets alder og teknologisk forældelse (fx ældre golfkøller med stålskaft vs moderne grafit)\n" "- Stand er meget afgørende for sportsudstyr — slid påvirker ydeevne direkte\n" "- Hvad er en rimelig brugtpris for dette udstyr i denne stand og fra dette mærke?" ), "møbler": ( "- Kendte mærker og materialer (fx massivt træ > spånplade, dansk design har høj gensalgsværdi)\n" "- Stand og alder — patina kan være positivt for vintage, negativt for moderne møbler\n" "- Originale vs efterligninger (fx IKEA POÄNG vs original Fritz Hansen)\n" "- Hvad er en rimelig brugtpris baseret på stand, alder og mærke?" ), "brugte varer": ( "- Produktets markedsværdi brugt i denne stand\n" "- Kendte problemer eller svagheder ved denne model/variant\n" "- Stand er afgørende — 'Som ny' vs 'Brugt - med synlige brugsspor' bør veje tungt\n" "- Er varen komplet? Mangler tilbehør eller dokumentation?" ), } # Breadcrumb keywords → knowledge context key _CATEGORY_MAP = [ (["elektronik", "computer", "grafikkort", "telefon", "mobil", "tv", "hifi", "kamera"], "elektronik"), (["golf", "sport", "cykel", "fitness", "jagt", "fiskeri", "friluftsliv"], "sport"), (["møbel", "stol", "bord", "sofa", "seng", "reol", "lampe", "bolig", "indretning"], "møbler"), ] def build_prompt(items: list[dict], category: str, criteria: str, prefs: str = "") -> str: summaries = "\n".join(listing_summary(i, n) for n, i in enumerate(items)) prefs_block = "" if prefs.strip(): prefs_block = f""" KØBERENS EGNE PRÆFERENCER (vigtig — vej disse tungt i din scoring): {prefs.strip()} Annoncer der strider mod disse præferencer skal have markant lavere score. """ knowledge = KNOWLEDGE_CONTEXT.get(category, KNOWLEDGE_CONTEXT["brugte varer"]) return f"""Du er en ekspert køberrådgiver for {category} på DBA. Brug BÅDE annonceteksten OG din egen viden om produkterne: {knowledge} {prefs_block} Scorer HVER annonce UAFHÆNGIGT på en absolut skala 1-10 baseret på disse kriterier: {criteria} ABSOLUT SCORESKALA (brug din viden om markedet — scoren må IKKE afhænge af de andre annoncer i denne batch): - 9-10: Fremragende køb — markant under markedspris, pålidelig model, god stand/historik - 7-8: Godt køb — fair pris, solid model, få eller ingen bekymringer - 5-6: Middel — markedspris, eller visse risici/ukendte faktorer - 3-4: Under middel — overpriset eller kendte modelproblem - 1-2: Undgå — alvorlige røde flag, stor risiko eller klart overpriset WARNINGS — list KUN konkrete, faktuelle røde flag der er direkte støttet af annonceteksten eller veldokumenterede modelproblemer: - Nævn KUN ting der er bekræftet i annonceteksten (fx "sælger nævner støj", "ingen billeder", "kun afhentning") - Eller veldokumenterede modelspecifikke problemer (fx "Turbo-variant har historisk køleproblemer") - Skriv IKKE generiske advarsler om mining, stand etc. medmindre det eksplicit nævnes i annoncen - Hvis ingen konkrete røde flag: tom streng "" Returner KUN et JSON-array — ingen forklaringer udenfor JSON: [ {{ "id": "annonce-ID", "score": 8.5, "reason": "Begrundelse på dansk (maks 2 sætninger). Nævn gerne konkret viden om modellen.", "warnings": "Kun konkrete røde flag fra annonceteksten eller kendte modelproblemer. Tom streng hvis ingen." }}, ... ] Alle {len(items)} annoncer skal med. Score er 1-10 (10 = suverænt køb). ANNONCER: {summaries}""" # ── Scoring ─────────────────────────────────────────────────────────────────── def score_listings( items: list[dict], criteria: str, prefs: str = "", batch_size: int = 10, force: bool = False, source_file: Path | None = None, ) -> list[dict]: """Score listings with AI — skips items that are already cached. Runs batches in parallel.""" client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY", API_KEY)) category = detect_category(items) phash = prefs_hash(prefs) # ── Split: persistent score cache → in-file cache → needs AI scoring ──── to_score, cached = [], [] now = datetime.now().isoformat(timespec="seconds") for item in items: if not force: # 1. Check persistent cross-search score cache sc = load_score_cache(str(item["id"]), prefs, category) if sc: item["ai_score"] = sc["score"] item["ai_rank"] = sc.get("rank") item["ai_reason"] = sc.get("reason", "") item["ai_warnings"] = sc.get("warnings", "") item["ai_prefs_hash"] = phash item["ai_scored_at"] = sc.get("scored_at", now) cached.append(item) continue # 2. In-file cache (same search UUID, already scored) if item.get("ai_score") is not None and item.get("ai_prefs_hash") == phash: cached.append(item) continue to_score.append(item) if cached: print(f" ♻️ {len(cached)} annoncer genbruger cache", file=sys.stderr) if to_score: print(f" 🤖 {len(to_score)} annoncer sendes til AI…", file=sys.stderr) elif not cached: print(" Ingen annoncer at score.", file=sys.stderr) return [] # ── Score only uncached items — parallel batches ────────────────────────── all_scores: dict[str, dict] = {} if to_score: batches = [to_score[i:i + batch_size] for i in range(0, len(to_score), batch_size)] print(f" ({len(batches)} parallelle batches à max {batch_size})", file=sys.stderr) def score_batch(b_idx: int, batch: list[dict]) -> tuple[dict[str, dict], int, int]: prompt = build_prompt(batch, category, criteria, prefs) response = client.messages.create( model=MODEL, max_tokens=MAX_TOKENS, temperature=0, messages=[{"role": "user", "content": prompt}], ) inp = response.usage.input_tokens out = response.usage.output_tokens text = response.content[0].text.strip() json_m = re.search(r"\[.*\]", text, re.DOTALL) if not json_m: print(f" ⚠ Kunne ikke parse svar fra batch {b_idx + 1}:\n{text[:300]}", file=sys.stderr) return {}, inp, out result = {} for s in json.loads(json_m.group(0)): result[str(s["id"])] = s print(f" ✓ Batch {b_idx + 1}/{len(batches)} færdig ({len(result)} scores, {inp}+{out} tok)", file=sys.stderr) return result, inp, out total_input = total_output = 0 with ThreadPoolExecutor(max_workers=min(len(batches), 8)) as pool: futures = {pool.submit(score_batch, i, b): i for i, b in enumerate(batches)} for future in as_completed(futures): scores, inp, out = future.result() all_scores.update(scores) total_input += inp total_output += out # Write scores + cache metadata back onto items now = datetime.now().isoformat(timespec="seconds") for item in to_score: s = all_scores.get(str(item["id"]), {}) if s: item["ai_score"] = s.get("score") item["ai_rank"] = s.get("rank") item["ai_reason"] = s.get("reason", "") item["ai_warnings"] = s.get("warnings", "") # Persist to cross-search score cache so same item never re-scored save_score_cache(str(item["id"]), prefs, category, { "score": s.get("score"), "rank": s.get("rank"), "reason": s.get("reason", ""), "warnings": s.get("warnings", ""), "scored_at": now, }) item["ai_prefs_hash"] = phash item["ai_scored_at"] = now # Auto-save scores back into source file so cache persists next run if source_file: all_items_map = {str(i["id"]): i for i in cached + to_score} source_file.write_text(json.dumps(list(all_items_map.values()), ensure_ascii=False, indent=2)) scored_count = sum(1 for i in to_score if i.get("ai_score") is not None) print(f" 💾 {scored_count} nye scores gemt → {source_file}", file=sys.stderr) cost = calc_cost(total_input, total_output) print(f" 💰 {total_input}+{total_output} tokens → ${cost:.4f}", file=sys.stderr) update_metrics(source_file.parent.name, total_input, total_output, scored_count) # ── Combine, re-sort, re-rank ───────────────────────────────────────────── combined = [i for i in (cached + to_score) if i.get("ai_score") is not None] combined.sort(key=lambda x: x["ai_score"], reverse=True) for rank, item in enumerate(combined, 1): item["ai_rank"] = rank return combined # ── Output ──────────────────────────────────────────────────────────────────── def print_results(ranked: list[dict], top: int | None = None) -> None: show = ranked[:top] if top else ranked print(f"\n{'═' * 60}") print(f" TOP {len(show)} ANNONCER (af {len(ranked)} scoret)") print(f"{'═' * 60}\n") for item in show: score = item.get("ai_score", "?") bar = "█" * int(score) + "░" * (10 - int(score)) if isinstance(score, (int, float)) else "" print( f"#{item['ai_rank']:>2} [{score:4.1f}] {bar} {item['name']}\n" f" Pris: {item['price_dkk']} DKK | {item['url']}\n" f" ✅ {item.get('ai_reason','')}\n" ) if item.get("ai_warnings"): print(f" ⚠️ {item['ai_warnings']}\n") # ── Main ────────────────────────────────────────────────────────────────────── CRITERIA = { "mobility": ( "- Pris ift. markedsværdi for den specifikke model/år/km (brug din viden)\n" "- Modelreliabilitet og kendte svagheder (timing-kæde, gearkasse, rust etc.)\n" "- Km-stand og alder (Årgang og Kilometertal er angivet hvis tilgængeligt)\n" "- Privat sælger foretrukket (forhandler = højere pris, ingen reklamationsret ved brugt)\n" "- Servicehistorik, nysynet, tandrem nævnt?\n" "- Udstyrsniveau og antal ejere" ), "recommerce": ( "- Pris ift. aktuel markedsværdi for produktet (brug din viden om typiske priser)\n" "- Produktgenerationens relative ydelse og værdi (fx GPU-generationer, produktionsår)\n" "- Stand (DBA's standbeskrivelse er angivet: 'Som ny', 'Brugt - men i god stand', 'Brugt - med synlige brugsspor')\n" "- Kendte problemer med denne model/variant\n" "- Er varen komplet? Mangler tilbehør?\n" "- Privat sælger foretrukket" ), } def main() -> None: if "ANTHROPIC_API_KEY" not in os.environ and not API_KEY: print("Fejl: ANTHROPIC_API_KEY er ikke sat.", file=sys.stderr) sys.exit(1) # Parse args properly — handles both --top 3 and --top=3 top_n = None prefs = "" force = False save = False positional = [] argv = sys.argv[1:] i = 0 while i < len(argv): a = argv[i] if a in ("--top", "--prefs") and i + 1 < len(argv): if a == "--top": top_n = int(argv[i + 1]) else: prefs = argv[i + 1] i += 2 elif a.startswith("--top="): top_n = int(a[6:]) i += 1 elif a.startswith("--prefs="): prefs = a[8:] i += 1 elif a == "--force": force = True i += 1 elif a == "--save": save = True i += 1 elif not a.startswith("--"): positional.append(a) i += 1 else: i += 1 if not positional: # Auto-detect: most recent data//listings.json searches = sorted( (d for d in DATA_DIR.iterdir() if (d / "listings.json").exists()), key=lambda d: d.stat().st_mtime, reverse=True ) if DATA_DIR.exists() else [] if not searches: print("Ingen søgninger fundet. Kør fetch_dba.py først.", file=sys.stderr) sys.exit(1) search_dir = searches[0] results_file = search_dir / "listings.json" print(f"Bruger nyeste søgning: {search_dir.name}", file=sys.stderr) else: ref = positional[0] if UUID_RE.match(ref): results_file = DATA_DIR / ref / "listings.json" else: results_file = Path(ref) if not results_file.exists(): print(f"Fejl: {results_file} ikke fundet.", file=sys.stderr) sys.exit(1) items = json.loads(results_file.read_text()) print(f"Loaded {len(items)} annoncer fra {results_file}", file=sys.stderr) domain = "mobility" if items and "/mobility/" in items[0].get("url", "") else "recommerce" criteria = CRITERIA[domain] # ── Interaktiv refinement-løkke (op til 3 forsøg) ──────────────────────── MAX_ROUNDS = 3 interactive = sys.stdin.isatty() and not prefs for attempt in range(MAX_ROUNDS): if prefs: print(f"\n🎯 Præferencer: {prefs}", file=sys.stderr) ranked = score_listings(items, criteria, prefs, force=force, source_file=results_file) # After first run, don't force re-score on subsequent interactive rounds force = False print_results(ranked, top_n) if save: out = results_file.parent / "ranked.json" out.write_text(json.dumps(ranked, ensure_ascii=False, indent=2)) print(f"\n💾 Ranked output gemt → {out}", file=sys.stderr) if not interactive or attempt >= MAX_ROUNDS - 1: break remaining = MAX_ROUNDS - attempt - 1 print(f"\n{'─' * 60}") print(f" Tilføj præferencer for at re-score ({remaining} forsøg tilbage)") print(f" Eks: 'Ikke franske biler' / 'Helst manuel gear' / 'Max 50 km fra Aarhus'") print(f" (Tryk Enter for at afslutte)") print(f"{'─' * 60}") try: new_prefs = input(" > ").strip() except (EOFError, KeyboardInterrupt): break if not new_prefs: break prefs = f"{prefs}\n{new_prefs}".strip() if prefs else new_prefs # Force re-score when prefs change (cache hash will differ anyway, but be explicit) force = True print(f"\n🔄 Re-scorer med dine præferencer…\n", file=sys.stderr) if __name__ == "__main__": main()