feat: AI category matching — pass all categories to GPT for best-fit selection
Previously: heuristic text matching (sub_type in category.name) — failed for semantic cases like 'Iran Ceasefire Talks' → 'Guerre — Moyen-Orient'. Now: all EventCategories are sent in the prompt (name + type + description). The AI picks matched_category by semantic understanding, returns it in JSON. - Validates returned name against known categories (case-insensitive) - Auto-sets event sub_type when AI finds a match and sub_type was empty - Returns matched_category in evaluate response + shown in UI eval message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -41,12 +41,39 @@ def _get_api_key() -> str:
|
|||||||
return key
|
return key
|
||||||
|
|
||||||
|
|
||||||
def _build_prompt(event: Dict[str, Any], category_defaults: Optional[List[Dict]] = None) -> str:
|
def _build_prompt(
|
||||||
cat_context = ""
|
event: Dict[str, Any],
|
||||||
if category_defaults:
|
all_cats: List[Dict],
|
||||||
top = sorted(category_defaults, key=lambda x: x.get("sensitivity", 0), reverse=True)[:8]
|
preselected_defaults: Optional[List[Dict]] = None,
|
||||||
lines = [f" • {d['instrument_id']}: sensibilité {d.get('sensitivity',0):.0%}, direction usuelle = {d.get('typical_direction','?')}" for d in top]
|
) -> str:
|
||||||
cat_context = "\nImpacts par défaut de cette catégorie (ajuste si l'événement est atypique) :\n" + "\n".join(lines)
|
# ── Category selection block ──────────────────────────────────────────────
|
||||||
|
if all_cats:
|
||||||
|
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 "")
|
||||||
|
for i, c in enumerate(all_cats)
|
||||||
|
)
|
||||||
|
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) :
|
||||||
|
{cat_lines}
|
||||||
|
|
||||||
|
Retourne le nom EXACT de la catégorie choisie dans le champ "matched_category" du JSON.
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
cat_selection_block = ""
|
||||||
|
|
||||||
|
# ── Default impacts from pre-selected category (fallback heuristic) ───────
|
||||||
|
defaults_block = ""
|
||||||
|
if preselected_defaults:
|
||||||
|
top = sorted(preselected_defaults, key=lambda x: x.get("sensitivity", 0), reverse=True)[:8]
|
||||||
|
lines = [
|
||||||
|
f" • {d['instrument_id']}: sensibilité {d.get('sensitivity',0):.0%}, "
|
||||||
|
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)
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -56,10 +83,11 @@ def _build_prompt(event: Dict[str, Any], category_defaults: Optional[List[Dict]]
|
|||||||
- Type : {event.get('category', '')} / {event.get('sub_type', '')}
|
- Type : {event.get('category', '')} / {event.get('sub_type', '')}
|
||||||
- Description : {event.get('description', '')}
|
- Description : {event.get('description', '')}
|
||||||
- Impact attendu (résumé) : {event.get('market_impact', '')}
|
- Impact attendu (résumé) : {event.get('market_impact', '')}
|
||||||
{cat_context}
|
{cat_selection_block}{defaults_block}
|
||||||
|
|
||||||
MISSION :
|
ÉTAPE 2 — IMPACTS PAR INSTRUMENT :
|
||||||
Évalue l'impact potentiel de cet événement sur chacun des 20 instruments suivants.
|
É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}
|
Instruments disponibles : {_INST_LIST_STR}
|
||||||
|
|
||||||
Pour chaque instrument IMPACTÉ (score >= 0.15), retourne :
|
Pour chaque instrument IMPACTÉ (score >= 0.15), retourne :
|
||||||
@@ -76,10 +104,11 @@ Critères de scoring :
|
|||||||
- 0.6-0.8 : fort (1-2% mouvement)
|
- 0.6-0.8 : fort (1-2% mouvement)
|
||||||
- 0.8-1.0 : majeur (> 2%, event driver primaire)
|
- 0.8-1.0 : majeur (> 2%, event driver primaire)
|
||||||
|
|
||||||
FORMAT JSON STRICT (pas de texte hors du JSON) :
|
FORMAT JSON STRICT :
|
||||||
{{
|
{{
|
||||||
|
"matched_category": "Nom exact de la catégorie choisie ou null",
|
||||||
"impacts": [
|
"impacts": [
|
||||||
{{"instrument_id": "SPY", "impact_score": 0.85, "direction": "bearish", "rationale": "Hausse taux comprime les multiples P/E", "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 de cet événement en 1 phrase",
|
||||||
"key_driver": "Instrument le plus impacté et pourquoi"
|
"key_driver": "Instrument le plus impacté et pourquoi"
|
||||||
@@ -121,46 +150,33 @@ def evaluate_event_impacts(
|
|||||||
if force and existing:
|
if force and existing:
|
||||||
delete_impacts_for_source("event", event_id)
|
delete_impacts_for_source("event", event_id)
|
||||||
|
|
||||||
# Get category defaults — try exact name match first, then fuzzy by sub_type/type
|
|
||||||
category_defaults = None
|
|
||||||
from services.database import get_all_event_categories
|
from services.database import get_all_event_categories
|
||||||
all_cats = get_all_event_categories()
|
all_cats_raw = get_all_event_categories()
|
||||||
|
|
||||||
def _find_cat(candidates):
|
# Parse default_impacts JSON for each category
|
||||||
"""Returns (category_name, default_impacts) or (None, None)."""
|
all_cats: List[Dict] = []
|
||||||
for c in candidates:
|
for c in all_cats_raw:
|
||||||
if c is None:
|
try:
|
||||||
continue
|
c["default_impacts"] = json.loads(c.get("default_impacts") or "[]")
|
||||||
row = next((x for x in all_cats if x["name"].lower() == c.lower()), None)
|
except Exception:
|
||||||
if row:
|
c["default_impacts"] = []
|
||||||
try:
|
all_cats.append(c)
|
||||||
return row["name"], json.loads(row.get("default_impacts") or "[]")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
sub = (event.get("sub_type") or "").strip()
|
# Heuristic pre-selection: if sub_type matches a category exactly, pass its
|
||||||
cat = (event.get("category") or "").strip()
|
# defaults as a hint (the AI will still pick the best category independently)
|
||||||
name = (event.get("name") or "").upper()
|
sub = (event.get("sub_type") or "").strip()
|
||||||
|
preselected_defaults: Optional[List[Dict]] = None
|
||||||
# Priority: exact sub_type name → fuzzy keyword in category name → type match
|
if sub:
|
||||||
candidates = [sub]
|
pre = next((c for c in all_cats if c["name"].lower() == sub.lower()), None)
|
||||||
for row in all_cats:
|
if pre:
|
||||||
rname = row["name"].upper()
|
preselected_defaults = pre["default_impacts"]
|
||||||
if sub and sub.upper() in rname:
|
|
||||||
candidates.append(row["name"])
|
|
||||||
elif any(k in name for k in ["FOMC", "FED"] if k in rname):
|
|
||||||
candidates.append(row["name"])
|
|
||||||
elif row.get("type") == cat and not sub:
|
|
||||||
candidates.append(row["name"])
|
|
||||||
|
|
||||||
matched_cat_name, category_defaults = _find_cat(candidates)
|
|
||||||
|
|
||||||
api_key = _get_api_key()
|
api_key = _get_api_key()
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return {"error": "No OpenAI API key configured"}
|
return {"error": "No OpenAI API key configured"}
|
||||||
|
|
||||||
prompt = _build_prompt(event, category_defaults)
|
prompt = _build_prompt(event, all_cats, preselected_defaults)
|
||||||
|
matched_cat_name: Optional[str] = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
@@ -178,9 +194,32 @@ def evaluate_event_impacts(
|
|||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
||||||
ai_impacts = raw.get("impacts", [])
|
ai_impacts = raw.get("impacts", [])
|
||||||
summary = raw.get("summary", "")
|
summary = raw.get("summary", "")
|
||||||
key_driver = raw.get("key_driver", "")
|
key_driver = raw.get("key_driver", "")
|
||||||
|
|
||||||
|
# ── Use AI-chosen category ────────────────────────────────────────────────
|
||||||
|
ai_cat_name = (raw.get("matched_category") or "").strip()
|
||||||
|
if ai_cat_name and ai_cat_name.lower() != "none":
|
||||||
|
# Validate against known categories (case-insensitive)
|
||||||
|
matched = next((c for c in all_cats if c["name"].lower() == ai_cat_name.lower()), None)
|
||||||
|
if matched:
|
||||||
|
matched_cat_name = matched["name"]
|
||||||
|
logger.info(f"[impact_service] AI matched event {event_id} → category '{matched_cat_name}'")
|
||||||
|
# Auto-update event sub_type if not already set
|
||||||
|
current_sub = (event.get("sub_type") or "").strip()
|
||||||
|
if not current_sub:
|
||||||
|
try:
|
||||||
|
from services.database import update_market_event
|
||||||
|
update_market_event(event_id, {**event, "sub_type": matched_cat_name,
|
||||||
|
"source_refs": event.get("source_refs", [])})
|
||||||
|
logger.info(f"[impact_service] Auto-set sub_type='{matched_cat_name}' on event {event_id}")
|
||||||
|
except Exception as _e:
|
||||||
|
logger.warning(f"[impact_service] Could not auto-set sub_type: {_e}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[impact_service] AI returned unknown category '{ai_cat_name}' for event {event_id}")
|
||||||
|
|
||||||
|
cat_label = matched_cat_name or (event.get("sub_type") or "").strip() or (event.get("category") or "")
|
||||||
|
|
||||||
# Persist
|
# Persist
|
||||||
to_save = []
|
to_save = []
|
||||||
for imp in ai_impacts:
|
for imp in ai_impacts:
|
||||||
@@ -191,7 +230,7 @@ def evaluate_event_impacts(
|
|||||||
"source_id": event_id,
|
"source_id": event_id,
|
||||||
"source_name": event["name"],
|
"source_name": event["name"],
|
||||||
"source_date": event["start_date"],
|
"source_date": event["start_date"],
|
||||||
"category_name": matched_cat_name or sub or cat,
|
"category_name": cat_label,
|
||||||
"instrument_id": imp["instrument_id"],
|
"instrument_id": imp["instrument_id"],
|
||||||
"impact_score": float(imp.get("impact_score", 0.5)),
|
"impact_score": float(imp.get("impact_score", 0.5)),
|
||||||
"direction": imp.get("direction", "neutral"),
|
"direction": imp.get("direction", "neutral"),
|
||||||
@@ -204,13 +243,14 @@ def evaluate_event_impacts(
|
|||||||
saved = get_impacts_for_source("event", event_id)
|
saved = get_impacts_for_source("event", event_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"event_id": event_id,
|
"event_id": event_id,
|
||||||
"event_name": event["name"],
|
"event_name": event["name"],
|
||||||
"impacts": saved,
|
"matched_category": matched_cat_name,
|
||||||
"summary": summary,
|
"impacts": saved,
|
||||||
"key_driver": key_driver,
|
"summary": summary,
|
||||||
"n_instruments": len(saved),
|
"key_driver": key_driver,
|
||||||
"from_cache": False,
|
"n_instruments": len(saved),
|
||||||
|
"from_cache": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -356,7 +356,8 @@ function EventDetail({
|
|||||||
const r = await fetch(`/api/market-events/${event.id}/evaluate?force=${force}`, { method: 'POST' })
|
const r = await fetch(`/api/market-events/${event.id}/evaluate?force=${force}`, { method: 'POST' })
|
||||||
const d = await r.json()
|
const d = await r.json()
|
||||||
if (!r.ok) throw new Error(d.detail || r.statusText)
|
if (!r.ok) throw new Error(d.detail || r.statusText)
|
||||||
setEvalMsg(`✓ ${d.n_instruments} instruments évalués`)
|
const catInfo = d.matched_category ? ` · catégorie: ${d.matched_category}` : ''
|
||||||
|
setEvalMsg(`✓ ${d.n_instruments} instruments évalués${catInfo}`)
|
||||||
await loadDetail()
|
await loadDetail()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setEvalMsg(`✗ ${e.message}`)
|
setEvalMsg(`✗ ${e.message}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user