From 7eb8c24719a565f3365d67851a92bc2cdc8a4d90 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Thu, 25 Jun 2026 22:02:47 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20category=20matching=20=E2=80=94=20?= =?UTF-8?q?pass=20all=20categories=20to=20GPT=20for=20best-fit=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/services/impact_service.py | 144 ++++++++++++++++++---------- frontend/src/pages/MarketEvents.tsx | 3 +- 2 files changed, 94 insertions(+), 53 deletions(-) diff --git a/backend/services/impact_service.py b/backend/services/impact_service.py index d415fcb..bd5ed92 100644 --- a/backend/services/impact_service.py +++ b/backend/services/impact_service.py @@ -41,12 +41,39 @@ def _get_api_key() -> str: return key -def _build_prompt(event: Dict[str, Any], category_defaults: Optional[List[Dict]] = None) -> str: - cat_context = "" - if category_defaults: - top = sorted(category_defaults, key=lambda x: x.get("sensitivity", 0), reverse=True)[:8] - lines = [f" • {d['instrument_id']}: sensibilité {d.get('sensitivity',0):.0%}, direction usuelle = {d.get('typical_direction','?')}" for d in top] - cat_context = "\nImpacts par défaut de cette catégorie (ajuste si l'événement est atypique) :\n" + "\n".join(lines) +def _build_prompt( + event: Dict[str, Any], + all_cats: List[Dict], + preselected_defaults: Optional[List[Dict]] = None, +) -> str: + # ── 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. @@ -56,10 +83,11 @@ def _build_prompt(event: Dict[str, Any], category_defaults: Optional[List[Dict]] - Type : {event.get('category', '')} / {event.get('sub_type', '')} - Description : {event.get('description', '')} - Impact attendu (résumé) : {event.get('market_impact', '')} -{cat_context} +{cat_selection_block}{defaults_block} -MISSION : -Évalue l'impact potentiel de cet événement sur chacun des 20 instruments suivants. +É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} 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.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": [ - {{"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", "key_driver": "Instrument le plus impacté et pourquoi" @@ -121,46 +150,33 @@ def evaluate_event_impacts( if force and existing: 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 - all_cats = get_all_event_categories() + all_cats_raw = get_all_event_categories() - def _find_cat(candidates): - """Returns (category_name, default_impacts) or (None, None).""" - for c in candidates: - if c is None: - continue - row = next((x for x in all_cats if x["name"].lower() == c.lower()), None) - if row: - try: - return row["name"], json.loads(row.get("default_impacts") or "[]") - except Exception: - pass - return None, None + # Parse default_impacts JSON for each category + all_cats: List[Dict] = [] + for c in all_cats_raw: + try: + c["default_impacts"] = json.loads(c.get("default_impacts") or "[]") + except Exception: + c["default_impacts"] = [] + all_cats.append(c) - sub = (event.get("sub_type") or "").strip() - cat = (event.get("category") or "").strip() - name = (event.get("name") or "").upper() - - # Priority: exact sub_type name → fuzzy keyword in category name → type match - candidates = [sub] - for row in all_cats: - rname = row["name"].upper() - 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) + # Heuristic pre-selection: if sub_type matches a category exactly, pass its + # defaults as a hint (the AI will still pick the best category independently) + sub = (event.get("sub_type") or "").strip() + preselected_defaults: Optional[List[Dict]] = None + if sub: + pre = next((c for c in all_cats if c["name"].lower() == sub.lower()), None) + if pre: + preselected_defaults = pre["default_impacts"] api_key = _get_api_key() if not api_key: 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: from openai import OpenAI @@ -178,9 +194,32 @@ def evaluate_event_impacts( return {"error": str(e)} ai_impacts = raw.get("impacts", []) - summary = raw.get("summary", "") + summary = raw.get("summary", "") 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 to_save = [] for imp in ai_impacts: @@ -191,7 +230,7 @@ def evaluate_event_impacts( "source_id": event_id, "source_name": event["name"], "source_date": event["start_date"], - "category_name": matched_cat_name or sub or cat, + "category_name": cat_label, "instrument_id": imp["instrument_id"], "impact_score": float(imp.get("impact_score", 0.5)), "direction": imp.get("direction", "neutral"), @@ -204,13 +243,14 @@ def evaluate_event_impacts( saved = get_impacts_for_source("event", event_id) return { - "event_id": event_id, - "event_name": event["name"], - "impacts": saved, - "summary": summary, - "key_driver": key_driver, - "n_instruments": len(saved), - "from_cache": False, + "event_id": event_id, + "event_name": event["name"], + "matched_category": matched_cat_name, + "impacts": saved, + "summary": summary, + "key_driver": key_driver, + "n_instruments": len(saved), + "from_cache": False, } diff --git a/frontend/src/pages/MarketEvents.tsx b/frontend/src/pages/MarketEvents.tsx index c356a49..58a93d6 100644 --- a/frontend/src/pages/MarketEvents.tsx +++ b/frontend/src/pages/MarketEvents.tsx @@ -356,7 +356,8 @@ function EventDetail({ const r = await fetch(`/api/market-events/${event.id}/evaluate?force=${force}`, { method: 'POST' }) const d = await r.json() 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() } catch (e: any) { setEvalMsg(`✗ ${e.message}`)