intro text

This commit is contained in:
Gaël
2026-07-27 10:09:13 +02:00
parent 85d79db3fd
commit 3b4ffd0fb7
+215 -30
View File
@@ -141,7 +141,7 @@ class MistralClient:
self.model = model self.model = model
self.base_url = "https://api.mistral.ai/v1/chat/completions" self.base_url = "https://api.mistral.ai/v1/chat/completions"
def _call_api(self, messages: List[Dict], temperature: float = 0.3) -> Optional[str]: def _call_api(self, messages: List[Dict], temperature: float = 0.3, json_mode: bool = True) -> Optional[str]:
"""Make a call to the Mistral API.""" """Make a call to the Mistral API."""
headers = { headers = {
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {self.api_key}",
@@ -151,8 +151,9 @@ class MistralClient:
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
"temperature": temperature, "temperature": temperature,
"response_format": {"type": "json_object"},
} }
if json_mode:
payload["response_format"] = {"type": "json_object"}
try: try:
resp = requests.post(self.base_url, headers=headers, json=payload, timeout=120) resp = requests.post(self.base_url, headers=headers, json=payload, timeout=120)
resp.raise_for_status() resp.raise_for_status()
@@ -363,6 +364,124 @@ IMPORTANT: Chaque article dans UN SEUL sous-groupe. Titre = nom de franchise/sé
"subgroups": [{"title": "Toutes les news", "items": items}] "subgroups": [{"title": "Toutes les news", "items": items}]
}] }]
def select_top_story_ids(self, items: List[dict], count: int = 5) -> List[int]:
"""
Sélectionne UNIQUEMENT les ids des actualités les plus importantes de la semaine
(pas de réécriture : le texte affiché reste celui de l'article original).
Retourne une liste d'ids, du plus important au moins important.
"""
if not items:
return []
items_for_analysis = []
for i, item in enumerate(items):
raw_summary = html.unescape(item.get("summary", "") or "")
clean_summary = re.sub("<[^<]+?>", "", raw_summary).strip()
items_for_analysis.append({
"id": i,
"title": item.get("title", ""),
"summary": clean_summary[:400],
})
prompt = f"""Tu es rédacteur pour une newsletter jeux vidéo en français.
Parmi ces articles de la semaine, sélectionne les {count} actualités les PLUS IMPORTANTES
(grosses annonces, sorties majeures, gros trailers, actu qui va marquer la semaine).
Ignore les tests/critiques individuels sauf s'ils concernent un jeu très attendu.
Articles à analyser:
{json.dumps(items_for_analysis, ensure_ascii=False, indent=2)}
Retourne ce JSON, du plus important au moins important, avec au maximum {count} ids:
{{"top_ids": [3, 0, 7]}}"""
response = self._call_api([{"role": "user", "content": prompt}], temperature=0.2)
if response:
try:
result = json.loads(response)
ids: List[int] = []
for idx in (result.get("top_ids") or [])[:count]:
if isinstance(idx, int) and 0 <= idx < len(items) and idx not in ids:
ids.append(idx)
if ids:
return ids
except json.JSONDecodeError:
LOG.warning("Failed to parse Mistral response for top stories")
return []
def write_intro(self, top_titles: List[str]) -> Optional[str]:
"""
Rédige une courte introduction (façon "Cette semaine, ...") qui cite
naturellement les titres les plus importants de la semaine.
"""
if not top_titles:
return None
prompt = f"""Tu rédiges l'introduction d'une newsletter jeux vidéo hebdomadaire en français.
Voici les titres des actualités les plus importantes de la semaine, du plus au moins important:
{json.dumps(top_titles, ensure_ascii=False)}
Rédige UNE introduction FACTUELLE (2 à 3 phrases maximum, un seul paragraphe) qui énumère ces
actus. Reformule légèrement les titres pour qu'ils s'enchaînent naturellement, avec des verbes
simples et neutres (dévoile, présente, revient avec, sort, annonce, c'est aussi/également...).
Reste fidèle au contenu, n'invente aucune information.
INTERDIT: adjectifs et formules d'accroche marketing ("particulièrement riche", "frappe fort",
"en force", "sans oublier"), questions rhétoriques ou phrases d'accroche finales ("prêts à...",
"de quoi...", "à ne pas manquer"). Pas de ton commercial, pas de superlatifs. Un simple constat
factuel de ce qui s'est passé, comme un résumé de dépêches.
Exemple de ton attendu:
"Cette semaine, Marvels Wolverine nous dévoile ses personnages clés, Microsoft présente This is
an Xbox, Battlefield 6 revient avec une saison 4 centrée sur la mer. C'est également la sortie de
la version remastérisée dArma: Cold War Assault et le lancement mondial de DragonSword :
Awakening, après le rejet de linjonction de Webzen."
Commence par "Cette semaine, ...".
Retourne UNIQUEMENT le texte brut de l'introduction, sans guillemets, sans markdown, sans JSON."""
response = self._call_api([{"role": "user", "content": prompt}], temperature=0.5, json_mode=False)
if response:
text = response.strip().strip('"').strip()
text = re.sub(r'[*_`]+', '', text) # strip stray markdown emphasis (raw HTML post, not markdown)
if text:
return text
return None
def write_excerpt(self, top_titles: List[str], max_len: int = 300) -> Optional[str]:
"""
Version courte de l'intro (une seule phrase) qui tient dans la limite de
caractères de Ghost, plutôt que de tronquer l'intro complète du corps du post.
"""
if not top_titles:
return None
prompt = f"""Tu rédiges le résumé (excerpt) d'une newsletter jeux vidéo hebdomadaire en français.
Voici les titres des actualités les plus importantes de la semaine, du plus au moins important:
{json.dumps(top_titles, ensure_ascii=False)}
Rédige UNE SEULE phrase factuelle qui tient en moins de {max_len} caractères, résumant les 2 ou 3
actus les plus importantes de cette liste. Verbes simples et neutres (dévoile, présente, sort,
annonce...). Reste fidèle au contenu, n'invente rien.
INTERDIT: adjectifs et formules d'accroche marketing, questions rhétoriques, phrases d'accroche
finales. Pas de ton commercial. Commence par "Cette semaine, ...".
Retourne UNIQUEMENT le texte brut, sans guillemets, sans markdown, sans JSON. La phrase DOIT
tenir en moins de {max_len} caractères."""
response = self._call_api([{"role": "user", "content": prompt}], temperature=0.5, json_mode=False)
if response:
text = response.strip().strip('"').strip()
text = re.sub(r'[*_`]+', '', text)
if text:
return text
return None
# ------------- Ghost Admin API client ------------- # ------------- Ghost Admin API client -------------
@@ -420,11 +539,14 @@ class GhostAdmin:
return n.get("slug") return n.get("slug")
return (actives or nls)[0].get("slug") return (actives or nls)[0].get("slug")
def create_post_html(self, title: str, html_content: str, status: str = "draft", feature_image: Optional[str] = None): def create_post_html(self, title: str, html_content: str, status: str = "draft",
feature_image: Optional[str] = None, custom_excerpt: Optional[str] = None):
url = self.base + "posts/?source=html" url = self.base + "posts/?source=html"
post = {"title": title, "html": html_content, "status": status} post = {"title": title, "html": html_content, "status": status}
if feature_image: if feature_image:
post["feature_image"] = feature_image post["feature_image"] = feature_image
if custom_excerpt:
post["custom_excerpt"] = custom_excerpt[:300] # Ghost limit
resp = requests.post(url, headers=self._headers(), json={"posts": [post]}, timeout=30) resp = requests.post(url, headers=self._headers(), json={"posts": [post]}, timeout=30)
if resp.status_code >= 400: if resp.status_code >= 400:
raise RuntimeError(f"Ghost create error {resp.status_code}: {resp.text}") raise RuntimeError(f"Ghost create error {resp.status_code}: {resp.text}")
@@ -541,39 +663,93 @@ class GhostTask:
pass pass
return None return None
def _select_top_stories(self, items: List[dict], count: int = 5) -> List[Dict]:
"""
Choisit les news les plus importantes (via Mistral si dispo, sinon les
premières de la liste). Sert de base à l'introduction rédigée.
"""
if not items:
return []
ids: List[int] = self.mistral.select_top_story_ids(items, count=count) if self.mistral else []
if not ids:
ids = list(range(min(count, len(items))))
return [items[idx] for idx in ids]
@staticmethod
def _deterministic_summary(titles: List[str], max_len: Optional[int] = None) -> str:
"""No-AI fallback sentence, optionally shortened (by dropping items, not mid-word) to fit max_len."""
def render(ts: List[str]) -> str:
if len(ts) == 1:
return f"Cette semaine : {ts[0]}."
return "Cette semaine : " + ", ".join(ts[:-1]) + " et " + ts[-1] + "."
text = render(titles)
if not max_len or len(text) <= max_len:
return text
for n in range(len(titles) - 1, 0, -1):
candidate = render(titles[:n])
if len(candidate) <= max_len:
return candidate
truncated = text[:max_len - 1]
last_space = truncated.rfind(" ")
if last_space > 20:
truncated = truncated[:last_space]
return truncated.rstrip(",.;: ") + ""
def _build_intro(self, top_stories: List[dict]) -> Optional[str]:
"""Rédige le paragraphe d'intro ('Cette semaine, ...') citant les news les plus importantes."""
titles = [(t.get("title") or "").strip() for t in top_stories]
titles = [t for t in titles if t]
if not titles:
return None
if self.mistral:
intro = self.mistral.write_intro(titles)
if intro:
return intro
return self._deterministic_summary(titles)
def _build_excerpt_text(self, top_stories: List[dict], max_len: int = 300) -> Optional[str]:
"""
Résumé COURT dédié au champ excerpt de Ghost : rédigé pour tenir dans max_len,
plutôt que de tronquer l'intro complète du corps du post.
"""
titles = [(t.get("title") or "").strip() for t in top_stories]
titles = [t for t in titles if t]
if not titles:
return None
if self.mistral:
excerpt = self.mistral.write_excerpt(titles, max_len=max_len)
if excerpt:
if len(excerpt) <= max_len:
return excerpt
truncated = excerpt[:max_len - 1]
last_space = truncated.rfind(" ")
if last_space > 20:
truncated = truncated[:last_space]
return truncated.rstrip(",.;: ") + ""
return self._deterministic_summary(titles, max_len=max_len)
# --- HTML builder for grouped content # --- HTML builder for grouped content
def _build_html_roundup_grouped(self, categories: List[Dict], feeds: List[RSSfeed]): def _build_html_roundup_grouped(self, categories: List[Dict], feeds: List[RSSfeed],
intro: Optional[str] = None):
""" """
Construit le HTML avec des catégories et sous-groupes thématiques. Construit le HTML avec un paragraphe d'introduction, puis des
Inclut un résumé et une table des matières en haut. catégories et sous-groupes thématiques.
Retourne (html, feature_image_url_ou_None). Retourne (html, feature_image_url_ou_None).
""" """
parts: List[str] = [] parts: List[str] = []
first_image: Optional[str] = None first_image: Optional[str] = None
# --- Build Summary Section --- # --- Intro paragraph ---
parts.append('<h2>✨ En bref cette semaine</h2>') if intro:
parts.append('<ul>') parts.append(f'<p>{html.escape(intro)}</p>')
for cat in categories:
cat_name = cat.get("name", "Actualités")
subgroups = cat.get("subgroups", [])
# Get top subgroups with more than 1 item (by item count) for summary
multi_item_subgroups = [sg for sg in subgroups if len(sg.get("items", [])) > 1]
sorted_subgroups = sorted(multi_item_subgroups, key=lambda sg: len(sg.get("items", [])), reverse=True)
top_subgroups = sorted_subgroups[:5] # Max 5 highlights per category
total_items = sum(len(sg.get("items", [])) for sg in subgroups)
if top_subgroups:
highlights = ", ".join(sg.get("title", "Divers") for sg in top_subgroups)
total_items = sum(len(sg.get("items", [])) for sg in subgroups)
parts.append(f'<li><strong>{html.escape(cat_name)}</strong>: {html.escape(highlights)} ({total_items} articles)</li>')
elif total_items > 0:
parts.append(f'<li><strong>{html.escape(cat_name)}</strong>: {total_items} articles</li>')
parts.append('</ul>')
parts.append('<hr>') parts.append('<hr>')
# --- Build Table of Contents --- # --- Build Table of Contents ---
@@ -828,8 +1004,13 @@ class GhostTask:
total_cats = len(categories) total_cats = len(categories)
total_subgroups = sum(len(cat.get("subgroups", [])) for cat in categories) total_subgroups = sum(len(cat.get("subgroups", [])) for cat in categories)
LOG.info("Created %d categories with %d sub-groups", total_cats, total_subgroups) LOG.info("Created %d categories with %d sub-groups", total_cats, total_subgroups)
LOG.info("Picking the top stories of the week...")
top_stories = self._select_top_stories(filtered_posts, count=5)
LOG.info("Selected %d top stories", len(top_stories))
else: else:
categories = [] categories = []
top_stories = []
else: else:
LOG.warning("No Mistral API key configured, skipping AI filtering/grouping") LOG.warning("No Mistral API key configured, skipping AI filtering/grouping")
# Fallback: single category with all items # Fallback: single category with all items
@@ -837,6 +1018,7 @@ class GhostTask:
"name": "Actualités de la semaine", "name": "Actualités de la semaine",
"subgroups": [{"title": "Toutes les news", "items": all_news_posts}] "subgroups": [{"title": "Toutes les news", "items": all_news_posts}]
}] }]
top_stories = self._select_top_stories(all_news_posts, count=5)
if not categories or all( if not categories or all(
len(sg.get("items", [])) == 0 len(sg.get("items", [])) == 0
@@ -846,10 +1028,13 @@ class GhostTask:
LOG.warning("No news items after filtering. Skipping this week.") LOG.warning("No news items after filtering. Skipping this week.")
return return
roundup_html, feature_image = self._build_html_roundup_grouped(categories, self.feeds) intro = self._build_intro(top_stories)
excerpt = self._build_excerpt_text(top_stories, max_len=300)
roundup_html, feature_image = self._build_html_roundup_grouped(categories, self.feeds, intro)
# 1) Create draft (with feature image if any) # 1) Create draft (with feature image if any)
created = self.ghost.create_post_html(title_post, roundup_html, status="draft", feature_image=feature_image) created = self.ghost.create_post_html(title_post, roundup_html, status="draft",
feature_image=feature_image, custom_excerpt=excerpt)
LOG.info("Created draft post: %s (id: %s)", created.get("title"), created.get("id")) LOG.info("Created draft post: %s (id: %s)", created.get("title"), created.get("id"))
# 2) Publish + send email (unless dry-run mode) # 2) Publish + send email (unless dry-run mode)