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
_KNOWN_TYPES = {"geopolitical", "fundamental", "event_calendar", "report", "sentiment", "technical"}
def _build_prompt(
event: Dict[str, Any],
all_cats: List[Dict],
@@ -48,23 +51,25 @@ def _build_prompt(
) -> str:
# ── Category selection block ──────────────────────────────────────────────
if all_cats:
# Number each category so the AI can refer to it unambiguously
cat_lines = "\n".join(
f" {i+1}. [{c['type']}] {c['name']}"
+ (f" {c.get('sub_type','')}" if c.get('sub_type') else "")
+ (f" : {c.get('description','')[:80]}" if c.get('description') else "")
f" CAT_{i+1}: \"{c['name']}\""
+ (f" ({c.get('description','')[:70]})" if c.get('description') else "")
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"""
ÉTAPE 1 — CHOIX DE CATÉGORIE :
Voici toutes les catégories disponibles. Choisis celle qui correspond le mieux à cet événement (ou "none" si aucune ne convient) :
LISTE DES CATÉGORIES DISPONIBLES (utilise uniquement ces noms) :
{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:
cat_selection_block = ""
# ── Default impacts from pre-selected category (fallback heuristic) ───────
# ── Default impacts from pre-selected category ────────────────────────────
defaults_block = ""
if preselected_defaults:
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','?')}"
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.
ÉVÉNEMENT À ANALYSER :
- Nom : {event.get('name', '')}
- Date : {event.get('start_date', '')}
- Type : {event.get('category', '')} / {event.get('sub_type', '')}
- Famille : {event.get('category', '')}
- Description : {event.get('description', '')}
- Impact attendu (résumé) : {event.get('market_impact', '')}
- Impact attendu : {event.get('market_impact', '')}
{cat_selection_block}{defaults_block}
ÉTAPE 2 — IMPACTS PAR INSTRUMENT :
Évalue l'impact potentiel de cet événement sur chacun des 20 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.
Instruments disponibles : {_INST_LIST_STR}
MISSION — IMPACTS PAR INSTRUMENT :
Choisis d'abord la catégorie la plus proche dans la liste ci-dessus, puis évalue l'impact sur chacun des instruments.
Si une catégorie est choisie, utilise ses impacts par défaut comme point de départ.
Instruments : {_INST_LIST_STR}
Pour chaque instrument IMPACTÉ (score >= 0.15), retourne :
- instrument_id : identifiant exact (ex: "SPY", "EURUSD=X", "BTC-USD")
- impact_score : 0.0 (aucun) → 1.0 (impact majeur, mouvement > 2%)
Pour chaque instrument IMPACTÉ (score >= 0.15) :
- instrument_id : identifiant exact (ex: "SPY", "EURUSD=X")
- impact_score : 0.0 → 1.0
- direction : "bullish" | "bearish" | "neutral" | "depends_on_outcome"
- rationale : 1 phrase MAX expliquant le mécanisme
- confidence : 0.0-1.0 (ta certitude sur l'estimation)
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)
- rationale : 1 phrase MAX
- confidence : 0.0-1.0
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": [
{{"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"
}}"""
@@ -210,7 +208,7 @@ def evaluate_event_impacts(
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}")
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
matched = (
next((c for c in all_cats if c["name"].lower() == ai_cat_name.lower()), None)