fix: AI returns type instead of category name — clearer prompt + guard

The AI was returning 'geopolitical' (the event type/family) instead of
a category name like 'Guerre — Moyen-Orient'. Two fixes:
1. Prompt: rename 'Type' → 'Famille', list categories as CAT_N: "name",
   explicit warning not to return a generic type, show valid name examples
2. Code: reject any response that matches a known type string before matching

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-25 22:14:21 +02:00
parent 91a448e8c9
commit 4071193385

View File

@@ -41,6 +41,9 @@ def _get_api_key() -> str:
return key return key
_KNOWN_TYPES = {"geopolitical", "fundamental", "event_calendar", "report", "sentiment", "technical"}
def _build_prompt( def _build_prompt(
event: Dict[str, Any], event: Dict[str, Any],
all_cats: List[Dict], all_cats: List[Dict],
@@ -48,23 +51,25 @@ def _build_prompt(
) -> str: ) -> str:
# ── Category selection block ────────────────────────────────────────────── # ── Category selection block ──────────────────────────────────────────────
if all_cats: if all_cats:
# Number each category so the AI can refer to it unambiguously
cat_lines = "\n".join( cat_lines = "\n".join(
f" {i+1}. [{c['type']}] {c['name']}" f" CAT_{i+1}: \"{c['name']}\""
+ (f" {c.get('sub_type','')}" if c.get('sub_type') else "") + (f" ({c.get('description','')[:70]})" if c.get('description') else "")
+ (f" : {c.get('description','')[:80]}" if c.get('description') else "")
for i, c in enumerate(all_cats) for i, c in enumerate(all_cats)
) )
# Build the valid names list for the JSON instruction
valid_names = " | ".join(f'"{c["name"]}"' for c in all_cats[:6]) + " | ..."
cat_selection_block = f""" cat_selection_block = f"""
ÉTAPE 1 — CHOIX DE CATÉGORIE : LISTE DES CATÉGORIES DISPONIBLES (utilise uniquement ces noms) :
Voici toutes les catégories disponibles. Choisis celle qui correspond le mieux à cet événement (ou "none" si aucune ne convient) :
{cat_lines} {cat_lines}
Retourne le nom EXACT de la catégorie choisie dans le champ "matched_category" du JSON. → Dans le JSON, "matched_category" DOIT être l'un des noms entre guillemets ci-dessus (ex: {valid_names}), ou null si vraiment aucune ne convient.
NE retourne PAS un type générique comme "geopolitical" ou "fundamental" — ces valeurs sont invalides ici.
""" """
else: else:
cat_selection_block = "" cat_selection_block = ""
# ── Default impacts from pre-selected category (fallback heuristic) ─────── # ── Default impacts from pre-selected category ────────────────────────────
defaults_block = "" defaults_block = ""
if preselected_defaults: if preselected_defaults:
top = sorted(preselected_defaults, key=lambda x: x.get("sensitivity", 0), reverse=True)[:8] top = sorted(preselected_defaults, key=lambda x: x.get("sensitivity", 0), reverse=True)[:8]
@@ -73,44 +78,37 @@ Retourne le nom EXACT de la catégorie choisie dans le champ "matched_category"
f"direction usuelle = {d.get('typical_direction','?')}" f"direction usuelle = {d.get('typical_direction','?')}"
for d in top for d in top
] ]
defaults_block = "\nImpacts par défaut (ajuste si l'événement est atypique) :\n" + "\n".join(lines) defaults_block = "\nImpacts par défaut de la catégorie (ajuste si besoin) :\n" + "\n".join(lines)
return f"""Tu es un analyste macro senior spécialisé en options et trading multi-actifs. return f"""Tu es un analyste macro senior spécialisé en options et trading multi-actifs.
ÉVÉNEMENT À ANALYSER : ÉVÉNEMENT À ANALYSER :
- Nom : {event.get('name', '')} - Nom : {event.get('name', '')}
- Date : {event.get('start_date', '')} - Date : {event.get('start_date', '')}
- Type : {event.get('category', '')} / {event.get('sub_type', '')} - Famille : {event.get('category', '')}
- Description : {event.get('description', '')} - Description : {event.get('description', '')}
- Impact attendu (résumé) : {event.get('market_impact', '')} - Impact attendu : {event.get('market_impact', '')}
{cat_selection_block}{defaults_block} {cat_selection_block}{defaults_block}
ÉTAPE 2 — IMPACTS PAR INSTRUMENT : MISSION — IMPACTS PAR INSTRUMENT :
Évalue l'impact potentiel de cet événement sur chacun des 20 instruments. Choisis d'abord la catégorie la plus proche dans la liste ci-dessus, puis évalue l'impact sur chacun des instruments.
Si tu as choisi une catégorie à l'étape 1, utilise ses impacts par défaut comme point de départ et ajuste selon le contexte spécifique de l'événement. Si une catégorie est choisie, utilise ses impacts par défaut comme point de départ.
Instruments disponibles : {_INST_LIST_STR} Instruments : {_INST_LIST_STR}
Pour chaque instrument IMPACTÉ (score >= 0.15), retourne : Pour chaque instrument IMPACTÉ (score >= 0.15) :
- instrument_id : identifiant exact (ex: "SPY", "EURUSD=X", "BTC-USD") - instrument_id : identifiant exact (ex: "SPY", "EURUSD=X")
- impact_score : 0.0 (aucun) → 1.0 (impact majeur, mouvement > 2%) - impact_score : 0.0 → 1.0
- direction : "bullish" | "bearish" | "neutral" | "depends_on_outcome" - direction : "bullish" | "bearish" | "neutral" | "depends_on_outcome"
- rationale : 1 phrase MAX expliquant le mécanisme - rationale : 1 phrase MAX
- confidence : 0.0-1.0 (ta certitude sur l'estimation) - confidence : 0.0-1.0
Critères de scoring :
- 0.0-0.2 : négligeable (bruit)
- 0.2-0.4 : faible (< 0.5% mouvement)
- 0.4-0.6 : modéré (0.5-1% mouvement)
- 0.6-0.8 : fort (1-2% mouvement)
- 0.8-1.0 : majeur (> 2%, event driver primaire)
FORMAT JSON STRICT : FORMAT JSON STRICT :
{{ {{
"matched_category": "Nom exact de la catégorie choisie ou null", "matched_category": "Nom exact d'une CAT ci-dessus, ou null",
"impacts": [ "impacts": [
{{"instrument_id": "SPY", "impact_score": 0.85, "direction": "bearish", "rationale": "...", "confidence": 0.90}} {{"instrument_id": "SPY", "impact_score": 0.85, "direction": "bearish", "rationale": "...", "confidence": 0.90}}
], ],
"summary": "Contexte macro de cet événement en 1 phrase", "summary": "Contexte macro en 1 phrase",
"key_driver": "Instrument le plus impacté et pourquoi" "key_driver": "Instrument le plus impacté et pourquoi"
}}""" }}"""
@@ -210,7 +208,7 @@ def evaluate_event_impacts(
ai_cat_name = (raw.get("matched_category") or "").strip() ai_cat_name = (raw.get("matched_category") or "").strip()
logger.info(f"[impact_service] AI returned matched_category='{ai_cat_name}' for event {event_id}") logger.info(f"[impact_service] AI returned matched_category='{ai_cat_name}' for event {event_id}")
if ai_cat_name and ai_cat_name.lower() not in ("none", "null", ""): if ai_cat_name and ai_cat_name.lower() not in ("none", "null", "") and ai_cat_name.lower() not in _KNOWN_TYPES:
# Exact match first, then normalized fuzzy match # Exact match first, then normalized fuzzy match
matched = ( matched = (
next((c for c in all_cats if c["name"].lower() == ai_cat_name.lower()), None) next((c for c in all_cats if c["name"].lower() == ai_cat_name.lower()), None)