Files
planning2ics/webapp/core.py
T
sylvain da14137bd9 Refonte identification des séries : PDF-first en deux étapes
Avant : le LLM devait simultanément grouper les notes ET les matcher
aux titres du site → résultats incohérents, séries perdues si pas de
correspondance sur le site.

Après (pipeline en 4 étapes) :
1. cluster_notes_into_series : LLM groupe les notes du PDF en séries
   canoniques, SANS le catalogue du site
2. scrape_catalog : enrichissement optionnel (+ extraction des dates
   de représentation depuis chaque page événement)
3. match_series_to_catalog : correspondance canonique→site pour
   enrichir le titre et la description (null si pas de match)
4. Génération ICS pour TOUTES les séries PDF, même sans correspondance
   site (répétitions seules incluses)

Autres changements :
- _build_description : inclut les dates du site et l'URL quand dispo
- clear_cache : inclut series_clusters.json et series_site_match.json
- _parse_json_response : helper robuste pour parser les réponses LLM

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 16:27:20 +01:00

592 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
core.py - Logique métier pour planning2ics web app.
Adapté de planning2ics.py pour usage web (config injectable, callback de progression).
Pipeline :
1. Extraction PDF → événements bruts
2. Clustering PDF-first → notes groupées en séries canoniques (LLM, sans site)
3. Scraping site → catalogue officiel (enrichissement optionnel)
4. Match canonique→site → correspondance pour titre officiel + description (LLM)
5. Génération ICS → une série = un fichier, même sans correspondance site
"""
import re
import json
import time as time_module
from pathlib import Path
from datetime import datetime, date, time, timedelta
from typing import Callable, Optional
import pdfplumber
import requests
from bs4 import BeautifulSoup
from icalendar import Calendar, Event
from uuid import uuid4
MONTH_MAP = {
"JANV": 1, "JAN": 1, "JANVIER": 1,
"FEV": 2, "FEVR": 2, "FEVRIER": 2,
"MARS": 3, "MAR": 3,
"AVRIL": 4, "AVR": 4,
"MAI": 5, "JUIN": 6,
"JUIL": 7, "JUILLET": 7,
"AOUT": 8, "AOÛT": 8,
"SEPT": 9, "SEP": 9, "SEPTEMBRE": 9,
"OCT": 10, "OCTOBRE": 10,
"NOV": 11, "NOVEMBRE": 11,
"DEC": 12, "DÉC": 12, "DECEMBRE": 12, "DÉCEMBRE": 12,
}
CONCERT_KEYWORDS = {
'concert', 'représentation', 'générale publique',
'raccord', 'italienne', 'scène orch'
}
FRENCH_MONTHS = {
'janvier': 1, 'février': 2, 'mars': 3, 'avril': 4,
'mai': 5, 'juin': 6, 'juillet': 7, 'août': 8,
'septembre': 9, 'octobre': 10, 'novembre': 11, 'décembre': 12,
}
# ── Utilitaires ───────────────────────────────────────────────────────────────
def normalize_note(note: str) -> str:
return re.sub(r'\s+', ' ', note).strip()
def is_public_event(titre: str) -> bool:
t = titre.lower()
return any(k in t for k in CONCERT_KEYWORDS)
def sanitize_filename(name: str) -> str:
clean = re.sub(r'[^\w\s\-éèêàùûîôç]', '', name, flags=re.UNICODE)
return clean.strip().replace(' ', '_')[:80] or 'SERIE_INCONNUE'
def extract_year_month_from_filename(filename: str):
year_match = re.search(r'(\d{4})', filename)
year = int(year_match.group(1)) if year_match else 2026
stem = Path(filename).stem.upper()
main_month = 1
for key, val in MONTH_MAP.items():
if key in stem:
main_month = val
break
return year, main_month
def parse_date(date_str: str, main_year: int, main_month: int) -> Optional[date]:
try:
day, month = map(int, date_str.strip().split('/'))
if month > main_month + 3:
year = main_year - 1
elif month < main_month - 3:
year = main_year + 1
else:
year = main_year
return date(year, month, day)
except Exception:
return None
def parse_time(s: str) -> Optional[time]:
m = re.match(r'(\d{1,2}):(\d{2})', s.strip())
return time(int(m.group(1)), int(m.group(2))) if m else None
def parse_horaires(s: str):
s = s.strip()
m = re.match(r'(\d{1,2}:\d{2})\s*[-]\s*(\d{1,2}:\d{2})', s)
if m:
return parse_time(m.group(1)), parse_time(m.group(2))
m = re.match(r'(\d{1,2}:\d{2})', s)
if m:
return parse_time(m.group(1)), None
return None, None
# ── Extraction PDF ────────────────────────────────────────────────────────────
def extract_events_from_pdf(pdf_path: Path) -> list:
events = []
main_year, main_month = extract_year_month_from_filename(pdf_path.name)
current_date = None
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
for table in (page.extract_tables() or []):
for row in table:
if not row:
continue
cells = [str(c).strip() if c else '' for c in row]
if cells[0].lower() == 'jour' or len(cells) < 5:
continue
date_str = cells[1]
horaires = cells[2]
titre = cells[3]
lieu = cells[4]
note = cells[5] if len(cells) > 5 else ''
dec = cells[6] if len(cells) > 6 else ''
voy = cells[7] if len(cells) > 7 else ''
if date_str and re.match(r'\d{1,2}/\d{2}', date_str):
parsed = parse_date(date_str, main_year, main_month)
if parsed:
current_date = parsed
if not current_date:
continue
if 'repos' in horaires.lower():
continue
if not re.search(r'\d{1,2}:\d{2}', horaires):
continue
start_time, end_time = parse_horaires(horaires)
if not start_time:
continue
events.append({
'date': current_date,
'horaires': horaires,
'start_time': start_time,
'end_time': end_time,
'titre': titre,
'lieu': lieu,
'note': normalize_note(note),
'dec': dec,
'voy': voy,
'source_file': pdf_path.name,
})
return events
# ── Scraping site web ─────────────────────────────────────────────────────────
def scrape_catalog(config: dict, cache_dir: Path,
log: Callable = None, force: bool = False) -> dict:
cache_file = cache_dir / "website_catalog.json"
cache_dir.mkdir(parents=True, exist_ok=True)
if not force and cache_file.exists():
if log:
log("Catalogue site web chargé depuis le cache")
with open(cache_file) as f:
return json.load(f)
headers = {'User-Agent': 'Mozilla/5.0 (compatible; planning2ics/1.0)'}
calendar_url = config['site']['calendar_url']
site_base = config['site']['base_url']
if log:
log("Scraping du site web de l'opéra...")
resp = requests.get(calendar_url, headers=headers, timeout=30)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, 'html.parser')
event_links = {}
for a in soup.find_all('a', href=True):
href = a['href']
if '/evenements/' in href and href.rstrip('/') != f'{site_base}/evenements':
full_url = href if href.startswith('http') else site_base + href
h3 = a.find('h3')
cat_tag = a.find('p')
title = h3.get_text(strip=True) if h3 else a.get_text(strip=True)
category = cat_tag.get_text(strip=True) if cat_tag else ''
if title and len(title) > 3:
event_links[title] = {'url': full_url, 'category': category}
catalog = {}
total = len(event_links)
if log:
log(f"{total} événements trouvés sur le site, récupération des descriptions...")
for i, (title, info) in enumerate(event_links.items()):
if log and i % 20 == 0:
log(f"Descriptions : {i}/{total}")
try:
r = requests.get(info['url'], headers=headers, timeout=20)
r.raise_for_status()
page_soup = BeautifulSoup(r.text, 'html.parser')
catalog[title] = {
'url': info['url'],
'description': _extract_description(page_soup),
'category': info['category'],
'dates': _extract_dates_from_page(page_soup),
}
time_module.sleep(0.2)
except Exception:
catalog[title] = {
'url': info['url'], 'description': '', 'category': info['category'],
'dates': [],
}
with open(cache_file, 'w') as f:
json.dump(catalog, f, ensure_ascii=False, indent=2)
if log:
log(f"Catalogue mis en cache : {len(catalog)} événements")
return catalog
def _extract_description(soup: BeautifulSoup) -> str:
for selector in ['div.wp-block-group', 'div.entry-content', 'article', 'main']:
container = soup.select_one(selector)
if container:
for tag in container.find_all(['nav', 'header', 'footer', 'button', 'form']):
tag.decompose()
lines = [
l.strip() for l in container.get_text('\n', strip=True).splitlines()
if l.strip() and len(l.strip()) > 15
][:40]
if lines:
return '\n'.join(lines)
return soup.get_text('\n', strip=True)[:2000]
def _extract_dates_from_page(soup: BeautifulSoup) -> list[str]:
"""Extrait les dates de représentation depuis une page événement du site."""
text = soup.get_text(' ')
months = '|'.join(FRENCH_MONTHS.keys())
pattern = rf'\b(\d{{1,2}})\s+({months})\s*(?:(\d{{4}}))?\b'
found = []
for m in re.finditer(pattern, text, re.IGNORECASE):
found.append(m.group(0).strip())
# Déduplique en conservant l'ordre
seen = set()
unique = []
for d in found:
if d not in seen:
seen.add(d)
unique.append(d)
return unique[:15]
# ── LLM ───────────────────────────────────────────────────────────────────────
def _llm_call(prompt: str, ollama_url: str, model: str) -> str:
resp = requests.post(
f"{ollama_url}/api/chat",
json={
"model": model,
"messages": [{"role": "user", "content": prompt}],
"stream": True,
"options": {"temperature": 0.05, "num_predict": 16384},
"think": False,
},
stream=True,
timeout=600,
)
resp.raise_for_status()
content = ""
for line in resp.iter_lines():
if line:
chunk = json.loads(line)
content += chunk.get('message', {}).get('content', '')
if chunk.get('done'):
break
return content
def _parse_json_response(content: str, key: str) -> dict:
"""Extrait un dict depuis une réponse LLM JSON (robuste aux erreurs de parsing)."""
json_match = re.search(r'\{[\s\S]*\}', content)
if not json_match:
return {}
raw = json_match.group()
try:
return json.loads(raw).get(key, {})
except json.JSONDecodeError:
result = {}
for m in re.finditer(r'"((?:[^"\\]|\\.)*)"\s*:\s*(?:"((?:[^"\\]|\\.)*)"|null)', raw):
result[m.group(1)] = m.group(2) if m.group(2) is not None else None
return result
# ── Étape 1 : clustering PDF-first ───────────────────────────────────────────
def cluster_notes_into_series(unique_notes: set, config: dict, cache_dir: Path,
log: Callable = None, force: bool = False) -> dict:
"""
Groupe les notes du PDF en séries canoniques via LLM.
Ne nécessite PAS le catalogue du site.
Retourne {note: nom_canonique_série}.
"""
cache_file = cache_dir / "series_clusters.json"
cache_dir.mkdir(parents=True, exist_ok=True)
if not force and cache_file.exists():
if log:
log("Clusters de séries chargés depuis le cache")
with open(cache_file) as f:
return json.load(f)
non_empty = sorted(n for n in unique_notes if n.strip())
if not non_empty:
return {}
# Heuristique (A)/(B) : pré-traitement avant LLM
heuristic = {}
for note in non_empty:
m = re.match(r"^\(([AB]'?)\)\s*:\s*[\"']?(.+?)[\"']?\s*$", note, re.IGNORECASE)
if m:
heuristic[note] = m.group(2).strip().strip('"\'')
notes_list = '\n'.join(f'- {repr(n)}' for n in non_empty)
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.
Ta tâche : regrouper ces notes en SÉRIES (même programme = même série).
Pour chaque note, donne un nom canonique court et propre de la série.
Règles :
1. Mêmes œuvres/compositeurs avec formulations différentes → MÊME série
2. Préfixes (A), (B), (A'), (B') → SÉRIES PARALLÈLES DIFFÉRENTES (noms distincts)
3. Mentions entre parenthèses (captation, présence de...) ne changent PAS la série
4. Répétitions partielles (Cordes, Vents...) = même série que le Tutti complet
5. Les séries avec seulement des répétitions (pas de concert) sont valides
6. Nom canonique = titre principal de l'œuvre ou des compositeurs (ex: "Pelléas et Mélisande", "Concert Beethoven / Brahms")
Notes à analyser :
{notes_list}
Réponds UNIQUEMENT avec un JSON valide, sans texte autour :
{{
"clusters": {{
"note exacte telle quelle": "Nom Canonique de la Série",
...
}}
}}"""
model = config['ollama']['cluster_model']
if log:
log(f"Identification des séries depuis le PDF ({model})...")
content = _llm_call(prompt, config['ollama']['url'], model)
result = _parse_json_response(content, 'clusters')
# Appliquer les heuristiques pour les notes non assignées
for note, canonical in heuristic.items():
if note not in result:
result[note] = canonical
# Fallback : note non assignée → elle-même (tronquée)
for note in non_empty:
if note not in result or not result[note]:
result[note] = note[:80]
with open(cache_file, 'w') as f:
json.dump(result, f, ensure_ascii=False, indent=2)
nb_series = len(set(v for v in result.values() if v))
if log:
log(f"{len(result)} notes → {nb_series} séries identifiées depuis le PDF")
return result
# ── Étape 2 : correspondance séries PDF ↔ site (enrichissement) ──────────────
def match_series_to_catalog(canonical_series: set, catalog: dict, config: dict,
cache_dir: Path, log: Callable = None,
force: bool = False) -> dict:
"""
Fait correspondre les noms canoniques (PDF) aux titres officiels du site.
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_dir.mkdir(parents=True, exist_ok=True)
if not force and cache_file.exists():
if log:
log("Correspondances séries↔site chargées depuis le cache")
with open(cache_file) as f:
return json.load(f)
if not canonical_series or not catalog:
return {s: None for s in canonical_series}
catalog_titles = sorted(catalog.keys())
titles_list = '\n'.join(f'- "{t}"' for t in catalog_titles)
series_list = '\n'.join(f'- "{s}"' for s in sorted(canonical_series))
prompt = f"""Tu dois faire correspondre des noms de séries (extraits d'un planning interne)
aux titres officiels du site web de l'Opéra Orchestre National Montpellier.
Titres officiels du site :
{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:
log("Correspondance séries PDF ↔ site web...")
content = _llm_call(prompt, config['ollama']['url'], model)
result = _parse_json_response(content, 'matches')
# S'assurer que toutes les séries ont une entrée (null si absente)
for s in canonical_series:
if s not in result:
result[s] = None
with open(cache_file, 'w') as f:
json.dump(result, f, ensure_ascii=False, indent=2)
matched = sum(1 for v in result.values() if v)
if log:
log(f"{matched}/{len(result)} séries PDF trouvées sur le site")
return result
# ── Génération ICS ────────────────────────────────────────────────────────────
def _build_description(evt: dict, catalog_entry: Optional[dict]) -> str:
lines = []
if is_public_event(evt['titre']) and catalog_entry:
desc = catalog_entry.get('description', '')
if desc:
lines.append(desc[:1500])
site_dates = catalog_entry.get('dates', [])
if site_dates:
lines.append(f"Dates sur le site : {', '.join(site_dates)}")
url = catalog_entry.get('url', '')
if url:
lines.append(f"Site : {url}")
else:
if evt['note']:
lines.append(f"Œuvres : {evt['note']}")
lines.append(f"Type : {evt['titre']}")
if evt['dec']:
lines.append(f"Durée déclarée : {evt['dec']}")
if evt['voy']:
lines.append(f"Déplacement : {evt['voy']}h de trajet")
lines.append(f"Source : {evt['source_file']}")
return '\n'.join(lines)
def _create_ics_bytes(series_title: str, events: list,
catalog_entry: Optional[dict]) -> bytes:
cal = Calendar()
cal.add('prodid', '-//Opéra Orchestre National Montpellier//planning2ics//FR')
cal.add('version', '2.0')
cal.add('x-wr-calname', series_title)
cal.add('x-wr-timezone', 'Europe/Paris')
for evt in sorted(events, key=lambda e: (e['date'], e['start_time'])):
vevent = Event()
start_dt = datetime.combine(evt['date'], evt['start_time'])
vevent.add('dtstart', start_dt)
if evt['end_time']:
end_dt = datetime.combine(evt['date'], evt['end_time'])
else:
dec_m = re.match(r'(\d{1,2}):(\d{2})', evt['dec'])
duration = (
timedelta(hours=int(dec_m.group(1)), minutes=int(dec_m.group(2)))
if dec_m else timedelta(hours=2)
)
end_dt = start_dt + duration
vevent.add('dtend', end_dt)
vevent.add('summary', f"{evt['titre']} {series_title}")
if evt['lieu']:
vevent.add('location', evt['lieu'])
vevent.add('description', _build_description(evt, catalog_entry))
vevent.add('uid', str(uuid4()) + '@planning-orchestre')
cal.add_component(vevent)
return cal.to_ical()
# ── Point d'entrée principal ──────────────────────────────────────────────────
def process_pdfs(pdf_paths: list, config: dict, data_dir: Path,
log: Callable = None) -> dict:
"""
Traite une liste de PDFs.
Retourne {series_title: {filename, bytes, event_count}}.
Pipeline :
PDF → extraction → clustering PDF-first → scraping site → match optionnel → ICS
Toutes les séries du PDF sont générées, même sans correspondance site.
"""
cache_dir = data_dir / "cache"
# ── 1. Extraction PDF ──────────────────────────────────────────────────────
if log:
log(f"Extraction de {len(pdf_paths)} PDF(s)...")
all_events = []
seen = set()
for i, pdf_path in enumerate(pdf_paths):
if log:
log(f"Extraction {i+1}/{len(pdf_paths)} : {pdf_path.name}")
for evt in extract_events_from_pdf(pdf_path):
key = (evt['date'], evt['start_time'], evt['titre'], evt['note'])
if key not in seen:
seen.add(key)
all_events.append(evt)
if log:
log(f"{len(all_events)} événements extraits au total")
if not all_events:
return {}
# ── 2. Clustering PDF-first (sans site) ────────────────────────────────────
unique_notes = {e['note'] for e in all_events}
if log:
log(f"{len(unique_notes)} notes uniques à analyser...")
note_to_canonical = cluster_notes_into_series(unique_notes, config, cache_dir, log)
# ── 3. Scraping site (pour enrichissement) ─────────────────────────────────
catalog = scrape_catalog(config, cache_dir, log)
# ── 4. Correspondance canonique → site (optionnel) ─────────────────────────
canonical_series = set(v for v in note_to_canonical.values() if v)
canonical_to_site = match_series_to_catalog(canonical_series, catalog, config, cache_dir, log)
# ── 5. Groupement des événements par série ─────────────────────────────────
series_events: dict[str, list] = {}
for evt in all_events:
canonical = note_to_canonical.get(evt['note'])
if not canonical:
# Note vide : regrouper par type de titre
canonical = evt['titre'] or 'SANS PROGRAMME'
# Titre final = titre site si trouvé, sinon nom canonique PDF
site_title = canonical_to_site.get(canonical)
series_key = site_title if site_title else canonical
evt['_canonical'] = canonical
evt['_site_title'] = site_title
series_events.setdefault(series_key, []).append(evt)
if log:
log(f"{len(series_events)} séries à générer...")
# ── 6. Génération ICS ──────────────────────────────────────────────────────
result = {}
for series_key, events in series_events.items():
catalog_entry = catalog.get(series_key) # None si pas de correspondance site
result[series_key] = {
'filename': sanitize_filename(series_key) + '.ics',
'bytes': _create_ics_bytes(series_key, events, catalog_entry),
'event_count': len(events),
}
if log:
log(f"Terminé : {len(result)} séries générées")
return result