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:
parent
2609d3504a
commit
eb66284172
11 changed files with 688 additions and 44 deletions
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue