intro text
This commit is contained in:
+222
-37
@@ -141,7 +141,7 @@ class MistralClient:
|
||||
self.model = model
|
||||
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."""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
@@ -151,8 +151,9 @@ class MistralClient:
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
if json_mode:
|
||||
payload["response_format"] = {"type": "json_object"}
|
||||
try:
|
||||
resp = requests.post(self.base_url, headers=headers, json=payload, timeout=120)
|
||||
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}]
|
||||
}]
|
||||
|
||||
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 -------------
|
||||
|
||||
@@ -420,11 +539,14 @@ class GhostAdmin:
|
||||
return n.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"
|
||||
post = {"title": title, "html": html_content, "status": status}
|
||||
if 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)
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"Ghost create error {resp.status_code}: {resp.text}")
|
||||
@@ -541,41 +663,95 @@ class GhostTask:
|
||||
pass
|
||||
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
|
||||
|
||||
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.
|
||||
Inclut un résumé et une table des matières en haut.
|
||||
Construit le HTML avec un paragraphe d'introduction, puis des
|
||||
catégories et sous-groupes thématiques.
|
||||
Retourne (html, feature_image_url_ou_None).
|
||||
"""
|
||||
parts: List[str] = []
|
||||
first_image: Optional[str] = None
|
||||
|
||||
# --- Build Summary Section ---
|
||||
parts.append('<h2>✨ En bref cette semaine</h2>')
|
||||
parts.append('<ul>')
|
||||
|
||||
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>')
|
||||
|
||||
|
||||
# --- Intro paragraph ---
|
||||
if intro:
|
||||
parts.append(f'<p>{html.escape(intro)}</p>')
|
||||
parts.append('<hr>')
|
||||
|
||||
# --- Build Table of Contents ---
|
||||
# parts.append('<h2>📋 Sommaire</h2>')
|
||||
# parts.append('<ul>')
|
||||
@@ -819,17 +995,22 @@ class GhostTask:
|
||||
if self.mistral:
|
||||
LOG.info("Using Mistral AI to filter non-news content...")
|
||||
filtered_posts = self.mistral.filter_news_items(all_news_posts, dry_run=self.dry_run)
|
||||
LOG.info("After filtering: %d items (removed %d)",
|
||||
LOG.info("After filtering: %d items (removed %d)",
|
||||
len(filtered_posts), len(all_news_posts) - len(filtered_posts))
|
||||
|
||||
|
||||
if filtered_posts:
|
||||
LOG.info("Using Mistral AI to group items by category...")
|
||||
categories = self.mistral.group_similar_items(filtered_posts)
|
||||
total_cats = len(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("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:
|
||||
categories = []
|
||||
top_stories = []
|
||||
else:
|
||||
LOG.warning("No Mistral API key configured, skipping AI filtering/grouping")
|
||||
# Fallback: single category with all items
|
||||
@@ -837,19 +1018,23 @@ class GhostTask:
|
||||
"name": "Actualités de la semaine",
|
||||
"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(
|
||||
len(sg.get("items", [])) == 0
|
||||
for cat in categories
|
||||
len(sg.get("items", [])) == 0
|
||||
for cat in categories
|
||||
for sg in cat.get("subgroups", [])
|
||||
):
|
||||
LOG.warning("No news items after filtering. Skipping this week.")
|
||||
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)
|
||||
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"))
|
||||
|
||||
# 2) Publish + send email (unless dry-run mode)
|
||||
|
||||
Reference in New Issue
Block a user