diff --git a/backend/routers/geopolitical.py b/backend/routers/geopolitical.py index aa76dbf..545ab91 100644 --- a/backend/routers/geopolitical.py +++ b/backend/routers/geopolitical.py @@ -17,6 +17,14 @@ def geo_news(force_refresh: bool = False): if not force_refresh and _news_cache["data"] and (now - _news_cache["ts"]) < 3600: return _news_cache["data"] news = fetch_geo_news() + # Enrich with AI: corrects impact_score, adds ai_dir_energy/metals/indices, + # ai_resolution (ceasefire/peace deal), ai_insight (1 French sentence). + # Gracefully skips if OpenAI not configured. + try: + from services.ai_analyzer import ai_score_news_batch + news = ai_score_news_batch(news) + except Exception: + pass _news_cache["data"] = news _news_cache["ts"] = now return news diff --git a/backend/services/geo_analyzer.py b/backend/services/geo_analyzer.py index 622ea5f..ca5c158 100644 --- a/backend/services/geo_analyzer.py +++ b/backend/services/geo_analyzer.py @@ -217,6 +217,57 @@ def compute_geo_risk_score(events: List[Dict[str, Any]]) -> Dict[str, Any]: } +# Map asset_class → AI directional field produced by ai_score_news_batch() +_ASSET_AI_DIR: Dict[str, str] = { + "energy": "ai_dir_energy", + "metals": "ai_dir_metals", + "indices": "ai_dir_indices", + "equities": "ai_dir_indices", +} + + +def _compute_ai_alignment(events: List[Dict[str, Any]], pattern: Dict[str, Any]) -> Dict[str, Any]: + """Return AI directional alignment between news signals and pattern expected direction. + Uses ai_dir_* fields added by ai_score_news_batch(). + Returns alignment bonus (-25..+25) and metadata for display. + """ + asset_class = pattern.get("asset_class", "") + expected_positive = (pattern.get("expected_move_pct") or 0) > 0 + ai_field = _ASSET_AI_DIR.get(asset_class) + + ai_news = [e for e in events if e.get("ai_scored")] + empty = {"ai_alignment": 0, "ai_contra_signal": False, "ai_insights": [], "ai_scored_count": 0} + if not ai_news or not ai_field: + return empty + + bullish = sum(1 for e in ai_news if e.get(ai_field) == "bullish") + bearish = sum(1 for e in ai_news if e.get(ai_field) == "bearish") + resolutions = sum(1 for e in ai_news if e.get("ai_resolution")) + total = len(ai_news) + + # Positive alignment = news confirms pattern direction + if expected_positive: + raw = (bullish - bearish) / total + contra = bearish > bullish or (resolutions > 0 and asset_class in ("energy", "metals")) + else: + raw = (bearish - bullish) / total + contra = bullish > bearish + + bonus = int(round(max(-25.0, min(25.0, raw * 25)))) + + insights = [ + e["ai_insight"] for e in ai_news + if e.get("ai_insight") and e.get(ai_field, "neutral") != "neutral" + ][:3] + + return { + "ai_alignment": bonus, + "ai_contra_signal": bool(contra), + "ai_insights": insights, + "ai_scored_count": len(ai_news), + } + + def match_patterns(events: List[Dict[str, Any]], patterns: Optional[List[Dict[str, Any]]] = None) -> List[Dict[str, Any]]: """Find which historical geo-patterns best match current event feed.""" if not events: @@ -237,17 +288,21 @@ def match_patterns(events: List[Dict[str, Any]], patterns: Optional[List[Dict[st similarity = round((trigger_match * 0.5 + keyword_match * 0.5) * 100, 1) if similarity > 10: + ai = _compute_ai_alignment(events, pattern) + adjusted = max(0, min(100, similarity + ai["ai_alignment"])) matches.append({ "pattern_id": pattern["id"], "name": pattern["name"], "description": pattern["description"], - "similarity": similarity, + "similarity": round(adjusted, 1), + "base_similarity": similarity, "suggested_trades": pattern["suggested_trades"], "asset_class": pattern["asset_class"], "expected_move_pct": pattern["expected_move_pct"], "probability": pattern["probability"], "horizon_days": pattern["horizon_days"], "historical_instances": pattern["historical_instances"], + **ai, }) return sorted(matches, key=lambda x: x["similarity"], reverse=True)[:5] @@ -324,12 +379,16 @@ def compute_pattern_relevance( }) matching_news.sort(key=lambda x: x["impact"], reverse=True) + ai = _compute_ai_alignment(events, pattern) + adjusted_relevance = max(0, min(100, relevance + ai["ai_alignment"])) + result.append({ "pattern_id": pattern.get("id", ""), "name": pattern.get("name", ""), "description": pattern.get("description", ""), "asset_class": pattern.get("asset_class", ""), - "relevance": relevance, + "relevance": round(adjusted_relevance, 1), + "base_relevance": relevance, "keyword_hits": len(kw_hits), "keyword_total": len(keywords_list), "matched_keywords": kw_hits, @@ -338,6 +397,7 @@ def compute_pattern_relevance( "expected_move_pct": pattern.get("expected_move_pct", 0), "probability": pattern.get("probability", 0), "horizon_days": pattern.get("horizon_days", 0), + **ai, }) result.sort(key=lambda x: x["relevance"], reverse=True) diff --git a/frontend/src/pages/GeoRadar.tsx b/frontend/src/pages/GeoRadar.tsx index 48f4b01..d599f76 100644 --- a/frontend/src/pages/GeoRadar.tsx +++ b/frontend/src/pages/GeoRadar.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useGeoNews, usePatternRelevance, useGeoRiskScore } from '../hooks/useApi' import clsx from 'clsx' import type { GeoNews } from '../types' -import { Globe, ExternalLink, Search } from 'lucide-react' +import { Globe, ExternalLink, Search, Brain } from 'lucide-react' const CATEGORY_COLORS: Record = { military: 'badge-red', sanctions: 'badge-orange', elections: 'badge-purple', @@ -67,6 +67,33 @@ function NewsCard({ news }: { news: GeoNews }) { {expanded && ( <> + {/* AI insight */} + {(news as any).ai_insight && ( +
+ + {(news as any).ai_insight} +
+ )} + {/* AI directional signals */} + {((news as any).ai_dir_energy || (news as any).ai_dir_metals || (news as any).ai_dir_indices) && ( +
+ {(['ai_dir_energy', 'ai_dir_metals', 'ai_dir_indices'] as const).map(field => { + const dir = (news as any)[field] + if (!dir || dir === 'neutral') return null + const labels: Record = { ai_dir_energy: '⛽', ai_dir_metals: '🥇', ai_dir_indices: '📊' } + return ( + + {labels[field]} {dir === 'bullish' ? '↑' : '↓'} + + ) + })} + {(news as any).ai_resolution && ( + 🕊 résolution + )} +
+ )}

{news.summary}

{Object.keys(news.asset_impacts).length > 0 && (
@@ -135,9 +162,25 @@ function PatternRelevanceCard({ p }: { p: any }) { {p.matching_news.length} news )} + {/* AI alignment badge */} + {p.ai_scored_count > 0 && p.ai_alignment !== 0 && ( + + + {p.ai_contra_signal ? `contre-signal (${p.ai_alignment > 0 ? '+' : ''}${p.ai_alignment}pts)` : `IA aligné (+${p.ai_alignment}pts)`} + + )}
- +
+ + {p.base_relevance !== undefined && p.ai_alignment !== 0 && ( +
base {p.base_relevance}%
+ )} +
{/* Matched keywords */} @@ -151,6 +194,18 @@ function PatternRelevanceCard({ p }: { p: any }) { )} + {/* AI insights */} + {p.ai_insights?.length > 0 && ( +
+ {p.ai_insights.map((insight: string, i: number) => ( +
+ + {insight} +
+ ))} +
+ )} + {/* Toggle matching news */} {hasNews && ( <>