feat(04-01): integrazione Unsplash in pipeline + CSVBuilder + export

- CSVBuilder.build_csv() e build_csv_content() accettano image_url_map opzionale
- _resolve_image() risolve keyword->URL Unsplash con fallback keyword originale
- _build_rows() chiama _resolve_image per cover, slides e cta image keywords
- JobStatus ha campo image_url_map con persistenza su disco JSON
- GenerationPipeline._resolve_unsplash_keywords() chiamato dopo batch LLM
- Carica unsplash_api_key da settings.json, crea UnsplashService, chiama resolve_keywords
- image_url_map salvato nel job JSON per riuso in export con edits
- Export router recupera image_url_map dal job JSON e passa a build_csv_content
- generate_single NON risolve Unsplash (velocità e riuso map job originale)
This commit is contained in:
Michele
2026-03-09 08:10:06 +01:00
parent afba4c5e9e
commit 9e7205eca2
3 changed files with 157 additions and 9 deletions

View File

@@ -6,6 +6,7 @@ Caratteristiche:
- Mappa GeneratedPost + CalendarSlot -> riga CSV
- Filtra solo PostResult con status="success"
- Scrive su disco in OUTPUTS_PATH/{job_id}.csv
- Supporta image_url_map opzionale: risolve keyword -> URL Unsplash nelle colonne _image_keyword
"""
from __future__ import annotations
@@ -14,7 +15,7 @@ import csv
import io
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional
from backend.constants import CANVA_FIELDS
from backend.schemas.generate import PostResult
@@ -35,6 +36,7 @@ class CSVBuilder:
calendar: "CalendarResponse",
job_id: str,
output_dir: Path,
image_url_map: Optional[dict[str, str]] = None,
) -> Path:
"""Genera e scrive il CSV su disco.
@@ -42,11 +44,16 @@ class CSVBuilder:
GeneratedPost + CalendarSlot alle 33 colonne CANVA_FIELDS,
e scrive con encoding utf-8-sig per compatibilità Excel.
Se image_url_map è fornita, le colonne _image_keyword contengono
URL Unsplash reali invece delle keyword testuali originali.
Args:
posts: Lista di PostResult (include success e failed).
calendar: CalendarResponse con i metadati degli slot.
job_id: Identificatore univoco del job (usato come nome file).
output_dir: Directory dove scrivere il file CSV.
image_url_map: Mappa opzionale {keyword: url_unsplash}. Se None,
usa le keyword testuali originali.
Returns:
Path del file CSV scritto su disco.
@@ -54,7 +61,7 @@ class CSVBuilder:
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"{job_id}.csv"
rows = self._build_rows(posts, calendar)
rows = self._build_rows(posts, calendar, image_url_map)
# CRITICO: encoding utf-8-sig (BOM) per Excel + caratteri italiani
with open(output_path, "w", newline="", encoding="utf-8-sig") as f:
@@ -62,10 +69,12 @@ class CSVBuilder:
writer.writeheader()
writer.writerows(rows)
url_count = len(image_url_map) if image_url_map else 0
logger.info(
"CSV scritto | job_id=%s | righe_success=%d | path=%s",
"CSV scritto | job_id=%s | righe_success=%d | url_unsplash=%d | path=%s",
job_id,
len(rows),
url_count,
output_path,
)
return output_path
@@ -75,21 +84,26 @@ class CSVBuilder:
posts: list[PostResult],
calendar: "CalendarResponse",
job_id: str,
image_url_map: Optional[dict[str, str]] = None,
) -> str:
"""Genera il CSV come stringa (senza scrivere su disco).
Usato per preview e per la route POST /export/{job_id}/csv
con dati modificati inline dall'utente.
Se image_url_map è fornita, le colonne _image_keyword contengono
URL Unsplash reali invece delle keyword testuali originali.
Args:
posts: Lista di PostResult (include success e failed).
calendar: CalendarResponse con i metadati degli slot.
job_id: Identificatore univoco del job.
image_url_map: Mappa opzionale {keyword: url_unsplash}.
Returns:
Stringa CSV con encoding utf-8-sig (BOM).
"""
rows = self._build_rows(posts, calendar)
rows = self._build_rows(posts, calendar, image_url_map)
output = io.StringIO()
# Aggiungi BOM manualmente per compatibilità Excel
@@ -103,19 +117,38 @@ class CSVBuilder:
# Metodi privati
# ---------------------------------------------------------------------------
def _resolve_image(self, keyword: str, image_url_map: Optional[dict[str, str]]) -> str:
"""Risolve una keyword immagine in URL Unsplash se disponibile.
Args:
keyword: Keyword immagine originale.
image_url_map: Mappa {keyword: url} o None.
Returns:
URL Unsplash se disponibile nella mappa, altrimenti la keyword originale.
"""
if image_url_map and keyword in image_url_map:
return image_url_map[keyword]
return keyword
def _build_rows(
self,
posts: list[PostResult],
calendar: "CalendarResponse",
image_url_map: Optional[dict[str, str]] = None,
) -> list[dict[str, str]]:
"""Costruisce la lista di righe CSV dai risultati.
Filtra solo i post con status="success" e mappa i dati
GeneratedPost + CalendarSlot alle colonne CANVA_FIELDS.
Se image_url_map è fornita, le colonne _image_keyword vengono
risolte in URL Unsplash quando disponibili.
Args:
posts: Lista completa di PostResult.
calendar: CalendarResponse con i metadati degli slot.
image_url_map: Mappa opzionale {keyword: url_unsplash}.
Returns:
Lista di dict con chiavi = CANVA_FIELDS.
@@ -152,7 +185,7 @@ class CSVBuilder:
# --- Cover slide (3 colonne) ---
row["cover_title"] = post.cover_title
row["cover_subtitle"] = post.cover_subtitle
row["cover_image_keyword"] = post.cover_image_keyword
row["cover_image_keyword"] = self._resolve_image(post.cover_image_keyword, image_url_map)
# --- Slide centrali s2-s7 (6 slide x 3 colonne = 18 colonne) ---
slide_labels = ["s2", "s3", "s4", "s5", "s6", "s7"]
@@ -161,7 +194,7 @@ class CSVBuilder:
slide = post.slides[idx]
row[f"{label}_headline"] = slide.headline
row[f"{label}_body"] = slide.body
row[f"{label}_image_keyword"] = slide.image_keyword
row[f"{label}_image_keyword"] = self._resolve_image(slide.image_keyword, image_url_map)
else:
# Fallback se slides ha meno di 6 elementi (non dovrebbe accadere)
row[f"{label}_headline"] = ""
@@ -171,7 +204,7 @@ class CSVBuilder:
# --- CTA slide (3 colonne) ---
row["cta_text"] = post.cta_text
row["cta_subtext"] = post.cta_subtext
row["cta_image_keyword"] = post.cta_image_keyword
row["cta_image_keyword"] = self._resolve_image(post.cta_image_keyword, image_url_map)
# --- Caption Instagram (1 colonna) ---
row["caption_instagram"] = post.caption_instagram