enrichment: Haiku flat details + image gallery on expand

apply service
- POST /internal/fetch-listing: headless Playwright fetch of a listing URL,
  returns {html, image_urls[], final_url}. Uses the same browser
  fingerprint/profile as the apply run so bot guards don't kick in

web service
- New enrichment pipeline (web/enrichment.py):
  /internal/flats → upsert → kick() enrichment in a background thread
    1. POST /internal/fetch-listing on apply
    2. llm.extract_flat_details(html, url) — Haiku tool-use call returns
       structured JSON (address, rooms, rent, description, pros/cons, etc.)
    3. Download each image directly to /data/flats/<slug>/NN.<ext>
    4. Persist enrichment_json + image_count + enrichment_status on the flat
- llm.py: minimal Anthropic /v1/messages wrapper, no SDK
- DB migration v5 adds enrichment_json/_status/_updated_at + image_count
- Admin "Altbestand anreichern" button (POST /actions/enrich-all) queues
  backfill for all pending/failed rows; runs in a detached task
- GET /partials/wohnung/<id> renders _wohnung_detail.html
- GET /flat-images/<slug>/<n> serves the downloaded image

UI
- Chevron on each list row toggles an inline detail pane (HTMX fetch on
  first open, hx-preserve keeps it open across the 3–30 s polls)
- CSS .flat-gallery normalises image tiles to a 4/3 aspect with object-fit:
  cover so different source sizes align cleanly
- "analysiert…" / "?" chips on the list reflect enrichment_status

Config
- ANTHROPIC_API_KEY + ANTHROPIC_MODEL wired into docker-compose's web
  service (default model: claude-haiku-4-5-20251001)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
EiSiMo 2026-04-21 14:46:12 +02:00
parent 2609d3504a
commit eb66284172
11 changed files with 688 additions and 44 deletions

View file

@ -0,0 +1,82 @@
{# Expanded detail for a single flat, loaded into #flat-detail-<id> via HTMX. #}
{% if enrichment_status == 'pending' %}
<div class="px-4 py-5 text-sm text-slate-500">Analyse läuft kommt in wenigen Augenblicken zurück…</div>
{% elif enrichment_status == 'failed' %}
<div class="px-4 py-5 text-sm text-slate-500">
Detail-Analyse konnte nicht abgerufen werden.
<a href="{{ flat.link }}" target="_blank" rel="noopener">Zur Original-Anzeige →</a>
</div>
{% else %}
<div class="px-4 py-4 space-y-4">
{% if image_urls %}
<div class="flat-gallery">
{% for src in image_urls %}
<a class="flat-gallery-tile" href="{{ src }}" target="_blank" rel="noopener">
<img src="{{ src }}" loading="lazy" alt="Foto {{ loop.index }}">
</a>
{% endfor %}
</div>
{% endif %}
{% if enrichment and enrichment.description %}
<p class="text-sm text-slate-700">{{ enrichment.description }}</p>
{% endif %}
{% if enrichment %}
<div class="grid grid-cols-2 md:grid-cols-3 gap-x-6 gap-y-1.5 text-xs">
{% macro kv(label, value) %}
{% if value is not none and value != '' %}
<div class="flex justify-between gap-3 border-b border-soft py-1">
<span class="text-slate-500">{{ label }}</span>
<span class="text-slate-800 text-right">{{ value }}</span>
</div>
{% endif %}
{% endmacro %}
{{ kv('Adresse', enrichment.address) }}
{{ kv('Zimmer', enrichment.rooms) }}
{{ kv('Größe', enrichment.size_sqm ~ ' m²' if enrichment.size_sqm else none) }}
{{ kv('Kaltmiete', enrichment.rent_cold ~ ' €' if enrichment.rent_cold else none) }}
{{ kv('Nebenkosten', enrichment.utilities ~ ' €' if enrichment.utilities else none) }}
{{ kv('Gesamtmiete', enrichment.rent_total ~ ' €' if enrichment.rent_total else none) }}
{{ kv('Kaution', enrichment.deposit ~ ' €' if enrichment.deposit else none) }}
{{ kv('Bezugsfrei ab', enrichment.available_from) }}
{{ kv('Etage', enrichment.floor) }}
{{ kv('Heizung', enrichment.heating) }}
{{ kv('Energieausweis', enrichment.energy_certificate) }}
{{ kv('Energiewert', enrichment.energy_value) }}
{{ kv('Baujahr', enrichment.year_built) }}
{{ kv('WBS', 'erforderlich' if enrichment.wbs_required else ('nicht erforderlich' if enrichment.wbs_required == false else none)) }}
{{ kv('WBS-Typ', enrichment.wbs_type) }}
</div>
{% endif %}
{% if enrichment and enrichment.features %}
<div class="flex flex-wrap gap-1.5">
{% for f in enrichment.features %}<span class="chip chip-info">{{ f }}</span>{% endfor %}
</div>
{% endif %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% if enrichment and enrichment.pros %}
<div>
<div class="text-xs uppercase tracking-wide text-slate-500 mb-1">Pro</div>
<ul class="text-sm space-y-1">
{% for p in enrichment.pros %}<li>+ {{ p }}</li>{% endfor %}
</ul>
</div>
{% endif %}
{% if enrichment and enrichment.cons %}
<div>
<div class="text-xs uppercase tracking-wide text-slate-500 mb-1">Contra</div>
<ul class="text-sm space-y-1">
{% for c in enrichment.cons %}<li> {{ c }}</li>{% endfor %}
</ul>
</div>
{% endif %}
</div>
<div class="text-xs">
<a href="{{ flat.link }}" target="_blank" rel="noopener">Zur Original-Anzeige →</a>
</div>
</div>
{% endif %}

View file

@ -106,53 +106,63 @@
<!-- Liste -->
<section class="view-list card">
<div class="divide-y divide-soft">
<div>
{% for item in flats %}
{% set f = item.row %}
<div class="px-4 py-3 flex flex-col md:flex-row md:items-center gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<a class="font-medium truncate" href="{{ f.link }}" target="_blank" rel="noopener noreferrer">
{{ f.address or f.link }}
</a>
{% if item.last and item.last.finished_at is none %}
<span class="chip chip-warn">läuft…</span>
{% elif item.last and item.last.success == 1 %}<span class="chip chip-ok">beworben</span>
{% elif item.last and item.last.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
<div class="flat-row">
<div class="px-4 py-3 flex flex-col md:flex-row md:items-center gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<a class="font-medium truncate" href="{{ f.link }}" target="_blank" rel="noopener noreferrer">
{{ f.address or f.link }}
</a>
{% if item.last and item.last.finished_at is none %}
<span class="chip chip-warn">läuft…</span>
{% elif item.last and item.last.success == 1 %}<span class="chip chip-ok">beworben</span>
{% elif item.last and item.last.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
{% endif %}
{% if f.enrichment_status == 'pending' %}<span class="chip">analysiert…</span>
{% elif f.enrichment_status == 'failed' %}<span class="chip chip-warn" title="Detail-Analyse fehlgeschlagen">?</span>
{% endif %}
</div>
<div class="text-xs text-slate-500 mt-0.5">
{% if f.rooms %}{{ "%.1f"|format(f.rooms) }} Z{% endif %}
{% if f.size %} · {{ "%.0f"|format(f.size) }} m²{% endif %}
{% if f.total_rent %} · {{ "%.0f"|format(f.total_rent) }} €{% endif %}
{% if f.wbs %} · WBS: {{ f.wbs }}{% endif %}
· <span data-rel-utc="{{ f.discovered_at|iso_utc }}" title="{{ f.discovered_at|de_dt }}"></span>
</div>
</div>
<div class="flex gap-2 items-center">
{% if apply_allowed and not (item.last and item.last.success == 1) %}
{% set is_running = item.last and item.last.finished_at is none %}
<form method="post" action="/actions/apply"
hx-post="/actions/apply" hx-target="#wohnungen-body" hx-swap="outerHTML">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="flat_id" value="{{ f.id }}">
<button class="btn btn-primary text-sm" type="submit"
{% if is_running %}disabled{% endif %}
hx-confirm="Bewerbung für {{ (f.address or f.link)|e }} starten?">
{% if is_running %}läuft…{% else %}Bewerben{% endif %}
</button>
</form>
{% endif %}
</div>
<div class="text-xs text-slate-500 mt-0.5">
{% if f.rooms %}{{ "%.1f"|format(f.rooms) }} Z{% endif %}
{% if f.size %} · {{ "%.0f"|format(f.size) }} m²{% endif %}
{% if f.total_rent %} · {{ "%.0f"|format(f.total_rent) }} €{% endif %}
{% if f.wbs %} · WBS: {{ f.wbs }}{% endif %}
· <span data-rel-utc="{{ f.discovered_at|iso_utc }}" title="{{ f.discovered_at|de_dt }}"></span>
<form method="post" action="/actions/reject"
hx-post="/actions/reject" hx-target="#wohnungen-body" hx-swap="outerHTML">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="flat_id" value="{{ f.id }}">
<button class="btn btn-ghost text-sm" type="submit"
hx-confirm="Ablehnen und aus der Liste entfernen?">
Ablehnen
</button>
</form>
<button type="button" class="flat-expand-btn" aria-label="Details"
data-flat-id="{{ f.id }}">
<svg width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 8 10 13 15 8"/></svg>
</button>
</div>
</div>
<div class="flex gap-2">
{% if apply_allowed and not (item.last and item.last.success == 1) %}
{% set is_running = item.last and item.last.finished_at is none %}
<form method="post" action="/actions/apply"
hx-post="/actions/apply" hx-target="#wohnungen-body" hx-swap="outerHTML">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="flat_id" value="{{ f.id }}">
<button class="btn btn-primary text-sm" type="submit"
{% if is_running %}disabled{% endif %}
hx-confirm="Bewerbung für {{ (f.address or f.link)|e }} starten?">
{% if is_running %}läuft…{% else %}Bewerben{% endif %}
</button>
</form>
{% endif %}
<form method="post" action="/actions/reject"
hx-post="/actions/reject" hx-target="#wohnungen-body" hx-swap="outerHTML">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="flat_id" value="{{ f.id }}">
<button class="btn btn-ghost text-sm" type="submit"
hx-confirm="Ablehnen und aus der Liste entfernen?">
Ablehnen
</button>
</form>
</div>
<div class="flat-detail" id="flat-detail-{{ f.id|flat_slug }}" hx-preserve="true"></div>
</div>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">
@ -166,6 +176,19 @@
</div>
</section>
{% if is_admin %}
<section class="flex justify-end">
<form method="post" action="/actions/enrich-all"
hx-post="/actions/enrich-all" hx-target="#wohnungen-body" hx-swap="outerHTML">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button class="btn btn-ghost text-xs" type="submit"
hx-confirm="Altbestand jetzt durch Haiku nachträglich anreichern? Kann einige Minuten dauern.">
Altbestand anreichern
</button>
</form>
</section>
{% endif %}
{% if rejected_flats %}
<section class="card">
<details class="group">

View file

@ -83,6 +83,29 @@
body:has(#v_map:checked) .view-map { display: block; }
#flats-map { height: 520px; border-radius: 10px; }
/* Flat detail expand */
.flat-row { border-top: 1px solid var(--border); }
.flat-row:first-child { border-top: 0; }
.flat-expand-btn { width: 1.75rem; height: 1.75rem; border-radius: 999px;
display: inline-flex; align-items: center; justify-content: center;
border: 1px solid var(--border); background: var(--surface);
color: var(--muted); cursor: pointer; transition: transform .2s, background .15s; }
.flat-expand-btn:hover { background: var(--ghost); color: var(--text); }
.flat-expand-btn.open { transform: rotate(180deg); }
.flat-detail { background: #fafcfe; border-top: 1px solid var(--border); }
.flat-detail:empty { display: none; }
/* Normalised image gallery — every tile has the same aspect ratio */
.flat-gallery { display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 8px; }
.flat-gallery-tile { aspect-ratio: 4 / 3; overflow: hidden;
border-radius: 8px; border: 1px solid var(--border);
background: #f0f5fa; display: block; }
.flat-gallery-tile img { width: 100%; height: 100%; object-fit: cover;
display: block; transition: transform .3s; }
.flat-gallery-tile:hover img { transform: scale(1.04); }
/* Leaflet popup — match site visual */
.leaflet-popup-content-wrapper { border-radius: 12px; box-shadow: 0 6px 20px rgba(16,37,63,.15); }
.leaflet-popup-content { margin: 12px 14px; min-width: 220px; color: var(--text); }