#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Analyse de la photo de façade (1/annonce) via Claude Haiku 4.5. Attributs visuels OBJECTIFS. Parallèle, résumable (facade_specs.jsonl), retry. """ import json, os, time, threading, urllib.request, urllib.error from concurrent.futures import ThreadPoolExecutor SRC = "data/processed/maisons_avec_prix.json" OUT = "data/processed/facade_specs.jsonl" WORKERS = int(os.environ.get("WORKERS", "16")) NSHARDS = int(os.environ.get("NSHARDS", "1")) SHARD = int(os.environ.get("SHARD", "0")) MODEL = "claude-haiku-4-5" for line in open(".env", encoding="utf-8"): if line.startswith("ANTHROPIC_API_KEY="): KEY = line.split("=", 1)[1].strip() SCHEMA = { "type": "object", "additionalProperties": False, "properties": { "is_exterior": {"type": "boolean", "description": "la photo montre-t-elle l'extérieur/façade"}, "building_type_visual": {"type": "string", "enum": ["maison_detachee","jumele","rangee","condo_immeuble","chalet","terrain","autre"]}, "condition_score": {"type": "integer", "description": "état visuel 1=délabré à 10=neuf/luxueux"}, "curb_appeal": {"type": "integer", "description": "attrait extérieur 1-10"}, "renovation_level": {"type": "string", "enum": ["a_renover","date","standard","renove","haut_de_gamme","inconnu"]}, "exterior_materials": {"type": "array", "items": {"type": "string", "enum": ["brique","pierre","vinyle","bois","aluminium","stucco","crepi","beton","autre"]}}, "landscaping": {"type": "string", "enum": ["aucun","minimal","entretenu","amenage_soigne"]}, "has_garage_visible": {"type": "boolean"}, "storeys_visual": {"type": "string", "enum": ["1","1.5","2","3+","inconnu"]}, "setting": {"type": "string", "enum": ["urbain_dense","banlieue","rural","bord_eau","boise","inconnu"]}, "photo_quality": {"type": "integer", "description": "qualité de la photo 1-10"}, "curb_luxury": {"type": "boolean", "description": "signes de luxe (grande taille, matériaux nobles, prestige)"} }, "required": ["is_exterior","building_type_visual","condition_score","curb_appeal", "renovation_level","exterior_materials","landscaping","has_garage_visible", "storeys_visual","setting","photo_quality","curb_luxury"] } SYS = ("Tu es un évaluateur immobilier objectif. Analyse la photo (censée être la " "façade d'une propriété) et renvoie des attributs FACTUELS et reproductibles " "au format JSON. Fonde les scores sur des critères concrets (état des " "surfaces, matériaux, aménagement), pas sur un goût personnel. Si la photo " "n'est pas un extérieur, mets is_exterior=false et remplis au mieux.") ROOM_KW = ("kitchen","cuisine","bathroom","salle de bain","bedroom","chambre", "living","salon","basement","sous-sol","dining","salle à manger", "garage","office","bureau","laundry","closet","stair") def pick_facade(d): imgs = d.get("images") or [] if not imgs: return None if d.get("source") == "realtor": return imgs[0].get("url") # 1re photo = extérieur # duproprio : caption = adresse (sans mot-clé de pièce) = photo extérieure for im in imgs: cap = (im.get("caption") or "").lower() if cap and not any(k in cap for k in ROOM_KW): return im.get("url") return imgs[0].get("url") def call(url, tries=5): body = {"model": MODEL, "max_tokens": 400, "system": SYS, "messages": [{"role": "user", "content": [ {"type": "image", "source": {"type": "url", "url": url}}, {"type": "text", "text": "Analyse cette photo de propriété."}]}], "output_config": {"format": {"type": "json_schema", "schema": SCHEMA}}} data = json.dumps(body).encode() for t in range(tries): try: req = urllib.request.Request( "https://api.anthropic.com/v1/messages", data=data, headers={"x-api-key": KEY, "anthropic-version": "2023-06-01", "content-type": "application/json"}) with urllib.request.urlopen(req, timeout=120) as r: out = json.load(r) txt = next(b["text"] for b in out["content"] if b["type"] == "text") return json.loads(txt) except urllib.error.HTTPError as e: # 400 inclus : fetch d'URL image transitoire côté Anthropic sous charge if e.code in (400, 429, 500, 502, 503, 504, 529) and t < tries - 1: time.sleep(2 ** t + 1); continue raise except (urllib.error.URLError, TimeoutError, ValueError): if t < tries - 1: time.sleep(2 ** t + 1); continue raise return None data = json.load(open(SRC, encoding="utf-8")) done = set() if os.path.exists(OUT): for line in open(OUT, encoding="utf-8"): try: done.add(json.loads(line)["uid"]) except Exception: pass todo = [] for d in data: uid = f"{d.get('source')}:{d.get('listingId')}" if uid in done: continue url = pick_facade(d) if url: todo.append((uid, url)) if NSHARDS > 1: todo = [t for i, t in enumerate(todo) if i % NSHARDS == SHARD] print(f"[shard {SHARD}/{NSHARDS}, workers={WORKERS}] Total={len(data)} " f"déjà fait={len(done)} à traiter (ce shard)={len(todo)}", flush=True) lock = threading.Lock() c = {"n": 0, "err": 0} fh = open(OUT, "a", encoding="utf-8") def work(item): uid, url = item try: rec = {"uid": uid, "facade": call(url)} except Exception as e: rec = {"uid": uid, "error": str(e)[:200]} with lock: fh.write(json.dumps(rec, ensure_ascii=False) + "\n"); fh.flush() c["n"] += 1 if "error" in rec: c["err"] += 1 if c["n"] % 250 == 0: print(f" {c['n']}/{len(todo)} (err={c['err']})", flush=True) t0 = time.time() with ThreadPoolExecutor(max_workers=WORKERS) as ex: list(ex.map(work, todo)) fh.close() print(f"Terminé : {c['n']} traités, {c['err']} erreurs, {time.time()-t0:.0f}s", flush=True)