intro text
This commit is contained in:
+215
-30
@@ -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, Marvel’s 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 d’Arma: Cold War Assault et le lancement mondial de DragonSword :
|
||||||
|
Awakening, après le rejet de l’injonction 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user