Matching séries→site par dates ciblées (CSS section-spectacle-dates-date)
- _get_performance_text_blocks : cible les éléments CSS spécifiques au site (section-spectacle-dates-date) plutôt que le texte brut de la page, évitant la contamination par le calendrier de navigation du site - match_series_to_catalog : remplace le matching LLM (trop imprécis avec les titres poétiques) par un recoupement de dates entre PDF et site - cluster_notes_into_series : passe les événements complets (avec dates) au lieu des notes seules → le LLM identifie correctement les répétitions partielles (ex: STRAUSS/PREVIN = même série que BEETHOVEN/STRAUSS/PREVIN) Résultat : Beethoven/Strauss/Previn→"Là où bat le cœur", Chostakovitch/Salonen/Prokofiev→"Virtuosité et destin", etc. Scraping réduit de 143 à 9 requêtes HTTP pour mars+avril. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+143
-80
@@ -267,21 +267,40 @@ def _extract_description(soup: BeautifulSoup) -> str:
|
|||||||
return soup.get_text('\n', strip=True)[:2000]
|
return soup.get_text('\n', strip=True)[:2000]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_performance_text_blocks(soup: BeautifulSoup) -> list[str]:
|
||||||
|
"""
|
||||||
|
Retourne les blocs de texte contenant les vraies dates de représentation.
|
||||||
|
Cible d'abord les éléments CSS spécifiques au site de l'opéra,
|
||||||
|
puis utilise le texte complet en fallback.
|
||||||
|
"""
|
||||||
|
# Balises ciblées sur le site opera-orchestre-montpellier.fr
|
||||||
|
for css_class in ['section-spectacle-dates-date', 'section-spectacle-dates',
|
||||||
|
'event-dates', 'dates-seances', 'programme-dates']:
|
||||||
|
elements = soup.find_all(class_=css_class)
|
||||||
|
if elements:
|
||||||
|
return [el.get_text(' ', strip=True) for el in elements]
|
||||||
|
|
||||||
|
# Fallback : balises <time>
|
||||||
|
times = soup.find_all('time')
|
||||||
|
if times:
|
||||||
|
return [t.get('datetime', '') + ' ' + t.get_text(' ', strip=True) for t in times]
|
||||||
|
|
||||||
|
# Fallback final : texte complet (moins précis)
|
||||||
|
return [soup.get_text(' ')]
|
||||||
|
|
||||||
|
|
||||||
def _extract_dates_from_page(soup: BeautifulSoup) -> list[str]:
|
def _extract_dates_from_page(soup: BeautifulSoup) -> list[str]:
|
||||||
"""Extrait les dates de représentation depuis une page événement du site."""
|
"""Extrait les dates de représentation depuis une page événement du site."""
|
||||||
text = soup.get_text(' ')
|
months = '|'.join(FRENCH_MONTHS.keys())
|
||||||
months = '|'.join(FRENCH_MONTHS.keys())
|
|
||||||
pattern = rf'\b(\d{{1,2}})\s+({months})\s*(?:(\d{{4}}))?\b'
|
pattern = rf'\b(\d{{1,2}})\s+({months})\s*(?:(\d{{4}}))?\b'
|
||||||
found = []
|
blocks = _get_performance_text_blocks(soup)
|
||||||
for m in re.finditer(pattern, text, re.IGNORECASE):
|
seen, unique = set(), []
|
||||||
found.append(m.group(0).strip())
|
for block in blocks:
|
||||||
# Déduplique en conservant l'ordre
|
for m in re.finditer(pattern, block, re.IGNORECASE):
|
||||||
seen = set()
|
d = m.group(0).strip()
|
||||||
unique = []
|
if d not in seen:
|
||||||
for d in found:
|
seen.add(d)
|
||||||
if d not in seen:
|
unique.append(d)
|
||||||
seen.add(d)
|
|
||||||
unique.append(d)
|
|
||||||
return unique[:15]
|
return unique[:15]
|
||||||
|
|
||||||
|
|
||||||
@@ -290,21 +309,22 @@ def _parse_french_dates_from_page(soup: BeautifulSoup, reference_year: int) -> s
|
|||||||
Extrait et convertit les dates de représentation en objets date.
|
Extrait et convertit les dates de représentation en objets date.
|
||||||
Utilisé pour filtrer les pages site par correspondance avec les dates PDF.
|
Utilisé pour filtrer les pages site par correspondance avec les dates PDF.
|
||||||
"""
|
"""
|
||||||
months = '|'.join(FRENCH_MONTHS.keys())
|
months = '|'.join(FRENCH_MONTHS.keys())
|
||||||
pattern = rf'\b(\d{{1,2}})\s+({months})(?:\s+(\d{{4}}))?\b'
|
pattern = rf'\b(\d{{1,2}})\s+({months})(?:\s+(\d{{4}}))?\b'
|
||||||
text = soup.get_text(' ')
|
blocks = _get_performance_text_blocks(soup)
|
||||||
result = set()
|
result = set()
|
||||||
for m in re.finditer(pattern, text, re.IGNORECASE):
|
for block in blocks:
|
||||||
day = int(m.group(1))
|
for m in re.finditer(pattern, block, re.IGNORECASE):
|
||||||
month_name = m.group(2).lower()
|
day = int(m.group(1))
|
||||||
year = int(m.group(3)) if m.group(3) else reference_year
|
month_name = m.group(2).lower()
|
||||||
month = FRENCH_MONTHS.get(month_name)
|
year = int(m.group(3)) if m.group(3) else reference_year
|
||||||
if not month:
|
month = FRENCH_MONTHS.get(month_name)
|
||||||
continue
|
if not month:
|
||||||
try:
|
continue
|
||||||
result.add(date(year, month, day))
|
try:
|
||||||
except ValueError:
|
result.add(date(year, month, day))
|
||||||
pass
|
except ValueError:
|
||||||
|
pass
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -351,10 +371,11 @@ def _parse_json_response(content: str, key: str) -> dict:
|
|||||||
|
|
||||||
# ── Étape 1 : clustering PDF-first ───────────────────────────────────────────
|
# ── Étape 1 : clustering PDF-first ───────────────────────────────────────────
|
||||||
|
|
||||||
def cluster_notes_into_series(unique_notes: set, config: dict, cache_dir: Path,
|
def cluster_notes_into_series(all_events: list, config: dict, cache_dir: Path,
|
||||||
log: Callable = None, force: bool = False) -> dict:
|
log: Callable = None, force: bool = False) -> dict:
|
||||||
"""
|
"""
|
||||||
Groupe les notes du PDF en séries canoniques via LLM.
|
Groupe les notes du PDF en séries canoniques via LLM.
|
||||||
|
Utilise les dates des événements pour identifier les répétitions partielles.
|
||||||
Ne nécessite PAS le catalogue du site.
|
Ne nécessite PAS le catalogue du site.
|
||||||
Retourne {note: nom_canonique_série}.
|
Retourne {note: nom_canonique_série}.
|
||||||
"""
|
"""
|
||||||
@@ -367,7 +388,19 @@ def cluster_notes_into_series(unique_notes: set, config: dict, cache_dir: Path,
|
|||||||
with open(cache_file) as f:
|
with open(cache_file) as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
non_empty = sorted(n for n in unique_notes if n.strip())
|
# Construire un index note → dates et types d'événements
|
||||||
|
note_context: dict[str, dict] = {}
|
||||||
|
for e in all_events:
|
||||||
|
n = e['note']
|
||||||
|
if not n.strip():
|
||||||
|
continue
|
||||||
|
if n not in note_context:
|
||||||
|
note_context[n] = {'dates': [], 'titres': []}
|
||||||
|
note_context[n]['dates'].append(e['date'].isoformat())
|
||||||
|
if e['titre'] not in note_context[n]['titres']:
|
||||||
|
note_context[n]['titres'].append(e['titre'])
|
||||||
|
|
||||||
|
non_empty = sorted(note_context.keys())
|
||||||
if not non_empty:
|
if not non_empty:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@@ -378,24 +411,35 @@ def cluster_notes_into_series(unique_notes: set, config: dict, cache_dir: Path,
|
|||||||
if m:
|
if m:
|
||||||
heuristic[note] = m.group(2).strip().strip('"\'')
|
heuristic[note] = m.group(2).strip().strip('"\'')
|
||||||
|
|
||||||
notes_list = '\n'.join(f'- {repr(n)}' for n in non_empty)
|
# Construire la liste avec contexte de dates
|
||||||
|
lines = []
|
||||||
|
for n in non_empty:
|
||||||
|
ctx = note_context[n]
|
||||||
|
dates = sorted(set(ctx['dates']))
|
||||||
|
titres = ', '.join(ctx['titres'])
|
||||||
|
date_range = f"{dates[0]} → {dates[-1]}" if len(dates) > 1 else dates[0]
|
||||||
|
lines.append(f'- {repr(n)}\n dates: {date_range} | types: {titres}')
|
||||||
|
notes_block = '\n'.join(lines)
|
||||||
|
|
||||||
prompt = f"""Tu analyses le planning interne d'un orchestre (Opéra Orchestre National Montpellier).
|
prompt = f"""Tu analyses le planning interne d'un orchestre (Opéra Orchestre National Montpellier).
|
||||||
Voici des notes de programme extraites du PDF de planning interne.
|
Voici des notes de programme extraites du PDF, avec leurs dates et types d'événements.
|
||||||
|
|
||||||
Ta tâche : regrouper ces notes en SÉRIES (même programme = même série).
|
Ta tâche : regrouper ces notes en SÉRIES (même programme de concert = même série).
|
||||||
Pour chaque note, donne un nom canonique court et propre de la série.
|
Pour chaque note, donne un nom canonique court et propre de la série.
|
||||||
|
|
||||||
Règles :
|
Règles IMPORTANTES :
|
||||||
1. Mêmes œuvres/compositeurs avec formulations différentes → MÊME série
|
1. Mêmes compositeurs avec formulations ou ordres différents → MÊME série
|
||||||
2. Préfixes (A), (B), (A'), (B') → SÉRIES PARALLÈLES DIFFÉRENTES (noms distincts)
|
2. Une note avec seulement CERTAINS compositeurs d'un programme plus complet,
|
||||||
3. Mentions entre parenthèses (captation, présence de...) ne changent PAS la série
|
dont les dates se chevauchent avec ce programme → c'est une répétition PARTIELLE
|
||||||
4. Répétitions partielles (Cordes, Vents...) = même série que le Tutti complet
|
de la MÊME série (ex: "STRAUSS / PREVIN" est une répétition partielle de
|
||||||
5. Les séries avec seulement des répétitions (pas de concert) sont valides
|
"BEETHOVEN / STRAUSS / PREVIN" si leurs dates sont proches)
|
||||||
6. Nom canonique = titre principal de l'œuvre ou des compositeurs (ex: "Pelléas et Mélisande", "Concert Beethoven / Brahms")
|
3. Préfixes (A), (B), (A'), (B') → SÉRIES PARALLÈLES DIFFÉRENTES (noms distincts)
|
||||||
|
4. Mentions entre parenthèses (captation, présence de..., ordre différent...) → MÊME série
|
||||||
|
5. Les séries avec seulement des répétitions (pas de concert public) sont valides
|
||||||
|
6. Nom canonique = programme COMPLET de la série (inclure tous les compositeurs du concert)
|
||||||
|
|
||||||
Notes à analyser :
|
Notes à analyser (avec contexte de dates) :
|
||||||
{notes_list}
|
{notes_block}
|
||||||
|
|
||||||
Réponds UNIQUEMENT avec un JSON valide, sans texte autour :
|
Réponds UNIQUEMENT avec un JSON valide, sans texte autour :
|
||||||
{{
|
{{
|
||||||
@@ -431,15 +475,34 @@ Réponds UNIQUEMENT avec un JSON valide, sans texte autour :
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ── Étape 2 : correspondance séries PDF ↔ site (enrichissement) ──────────────
|
# ── Étape 2 : correspondance séries PDF ↔ site par dates ─────────────────────
|
||||||
|
|
||||||
def match_series_to_catalog(canonical_series: set, catalog: dict, config: dict,
|
def _parse_catalog_dates(date_strings: list, reference_year: int) -> set:
|
||||||
|
"""Parse les dates texte du catalogue site en objets date."""
|
||||||
|
months = '|'.join(FRENCH_MONTHS.keys())
|
||||||
|
pattern = rf'(\d{{1,2}})\s+({months})(?:\s+(\d{{4}}))?'
|
||||||
|
result = set()
|
||||||
|
for s in date_strings:
|
||||||
|
m = re.search(pattern, s, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
day = int(m.group(1))
|
||||||
|
month = FRENCH_MONTHS.get(m.group(2).lower())
|
||||||
|
year = int(m.group(3)) if m.group(3) else reference_year
|
||||||
|
if month:
|
||||||
|
try:
|
||||||
|
result.add(date(year, month, day))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def match_series_to_catalog(series_concert_dates: dict, catalog: dict,
|
||||||
cache_dir: Path, log: Callable = None,
|
cache_dir: Path, log: Callable = None,
|
||||||
force: bool = False) -> dict:
|
force: bool = False) -> dict:
|
||||||
"""
|
"""
|
||||||
Fait correspondre les noms canoniques (PDF) aux titres officiels du site.
|
Fait correspondre les séries PDF aux événements du site par recoupement de dates.
|
||||||
|
series_concert_dates : {nom_canonique: set[date]} — dates de concerts du PDF par série.
|
||||||
Retourne {nom_canonique: titre_site_ou_None}.
|
Retourne {nom_canonique: titre_site_ou_None}.
|
||||||
Les séries sans correspondance reçoivent None (pas perdues).
|
|
||||||
"""
|
"""
|
||||||
cache_file = cache_dir / "series_site_match.json"
|
cache_file = cache_dir / "series_site_match.json"
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -450,44 +513,39 @@ def match_series_to_catalog(canonical_series: set, catalog: dict, config: dict,
|
|||||||
with open(cache_file) as f:
|
with open(cache_file) as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
if not canonical_series or not catalog:
|
if not series_concert_dates or not catalog:
|
||||||
return {s: None for s in canonical_series}
|
return {s: None for s in series_concert_dates}
|
||||||
|
|
||||||
catalog_titles = sorted(catalog.keys())
|
# Année de référence : la plus fréquente dans toutes les dates
|
||||||
titles_list = '\n'.join(f'- "{t}"' for t in catalog_titles)
|
all_dates = [d for dates in series_concert_dates.values() for d in dates]
|
||||||
series_list = '\n'.join(f'- "{s}"' for s in sorted(canonical_series))
|
reference_year = max(set(d.year for d in all_dates), key=lambda y: sum(1 for d in all_dates if d.year == y)) if all_dates else datetime.now().year
|
||||||
|
|
||||||
prompt = f"""Tu dois faire correspondre des noms de séries (extraits d'un planning interne)
|
# Pré-calculer les dates parsées pour chaque événement du site
|
||||||
aux titres officiels du site web de l'Opéra Orchestre National Montpellier.
|
site_dates: dict[str, set] = {}
|
||||||
|
for title, info in catalog.items():
|
||||||
|
site_dates[title] = _parse_catalog_dates(info.get('dates', []), reference_year)
|
||||||
|
|
||||||
Titres officiels du site :
|
result = {}
|
||||||
{titles_list}
|
|
||||||
|
|
||||||
Séries du planning PDF :
|
|
||||||
{series_list}
|
|
||||||
|
|
||||||
Pour chaque série PDF, donne le titre officiel le plus proche, ou null si aucun ne correspond
|
|
||||||
(ex: série de répétitions sans concert public).
|
|
||||||
|
|
||||||
Réponds UNIQUEMENT avec un JSON valide :
|
|
||||||
{{
|
|
||||||
"matches": {{
|
|
||||||
"Nom série PDF": "Titre officiel du site",
|
|
||||||
"Série répétitions seules": null
|
|
||||||
}}
|
|
||||||
}}"""
|
|
||||||
|
|
||||||
model = config['ollama']['local_model']
|
|
||||||
if log:
|
if log:
|
||||||
log("Correspondance séries PDF ↔ site web...")
|
log("Correspondance séries PDF ↔ site par recoupement de dates...")
|
||||||
|
|
||||||
content = _llm_call(prompt, config['ollama']['url'], model)
|
for canonical, pdf_concert_dates in series_concert_dates.items():
|
||||||
result = _parse_json_response(content, 'matches')
|
if not pdf_concert_dates:
|
||||||
|
result[canonical] = None
|
||||||
|
continue
|
||||||
|
|
||||||
# S'assurer que toutes les séries ont une entrée (null si absente)
|
best_title = None
|
||||||
for s in canonical_series:
|
best_overlap = 0
|
||||||
if s not in result:
|
for site_title, s_dates in site_dates.items():
|
||||||
result[s] = None
|
overlap = len(pdf_concert_dates & s_dates)
|
||||||
|
if overlap > best_overlap:
|
||||||
|
best_overlap = overlap
|
||||||
|
best_title = site_title
|
||||||
|
|
||||||
|
result[canonical] = best_title if best_overlap > 0 else None
|
||||||
|
if log:
|
||||||
|
status = f"→ {best_title!r} ({best_overlap} date(s) communes)" if best_title else "→ aucune correspondance site"
|
||||||
|
log(f" {canonical!r} {status}")
|
||||||
|
|
||||||
with open(cache_file, 'w') as f:
|
with open(cache_file, 'w') as f:
|
||||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||||
@@ -597,7 +655,7 @@ def process_pdfs(pdf_paths: list, config: dict, data_dir: Path,
|
|||||||
unique_notes = {e['note'] for e in all_events}
|
unique_notes = {e['note'] for e in all_events}
|
||||||
if log:
|
if log:
|
||||||
log(f"{len(unique_notes)} notes uniques à analyser...")
|
log(f"{len(unique_notes)} notes uniques à analyser...")
|
||||||
note_to_canonical = cluster_notes_into_series(unique_notes, config, cache_dir, log)
|
note_to_canonical = cluster_notes_into_series(all_events, config, cache_dir, log)
|
||||||
|
|
||||||
# ── 3. Scraping site ciblé sur les dates de concerts du PDF ───────────────
|
# ── 3. Scraping site ciblé sur les dates de concerts du PDF ───────────────
|
||||||
concert_dates = {e['date'] for e in all_events if is_public_event(e['titre'])}
|
concert_dates = {e['date'] for e in all_events if is_public_event(e['titre'])}
|
||||||
@@ -605,9 +663,14 @@ def process_pdfs(pdf_paths: list, config: dict, data_dir: Path,
|
|||||||
log(f"{len(concert_dates)} dates de concerts identifiées dans le PDF")
|
log(f"{len(concert_dates)} dates de concerts identifiées dans le PDF")
|
||||||
catalog = scrape_catalog(config, cache_dir, log, target_dates=concert_dates or None)
|
catalog = scrape_catalog(config, cache_dir, log, target_dates=concert_dates or None)
|
||||||
|
|
||||||
# ── 4. Correspondance canonique → site (optionnel) ─────────────────────────
|
# ── 4. Correspondance canonique → site par dates ───────────────────────────
|
||||||
canonical_series = set(v for v in note_to_canonical.values() if v)
|
# Dates de concerts du PDF par série canonique
|
||||||
canonical_to_site = match_series_to_catalog(canonical_series, catalog, config, cache_dir, log)
|
series_concert_dates: dict[str, set] = {}
|
||||||
|
for evt in all_events:
|
||||||
|
canonical = note_to_canonical.get(evt['note'])
|
||||||
|
if canonical and is_public_event(evt['titre']):
|
||||||
|
series_concert_dates.setdefault(canonical, set()).add(evt['date'])
|
||||||
|
canonical_to_site = match_series_to_catalog(series_concert_dates, catalog, cache_dir, log)
|
||||||
|
|
||||||
# ── 5. Groupement des événements par série ─────────────────────────────────
|
# ── 5. Groupement des événements par série ─────────────────────────────────
|
||||||
series_events: dict[str, list] = {}
|
series_events: dict[str, list] = {}
|
||||||
|
|||||||
Reference in New Issue
Block a user