feat: AI-enriched news scoring + directional alignment on pattern matching

Hook ai_score_news_batch() into /api/geo/news so every news fetch is enriched
by GPT-4o-mini: corrected impact_score, ai_dir_energy/metals/indices, ai_resolution
(ceasefire/peace deal flag), ai_insight (1 French sentence). Gracefully no-ops
when OpenAI is not configured.

Add _compute_ai_alignment() in geo_analyzer: for each pattern compares the news
AI directional signals against the pattern's expected_move direction and produces
a -25..+25 bonus injected into similarity/relevance scores. Contra-signals
(e.g. peace deal → oil bearish while pattern expects oil spike) are flagged.

Frontend GeoRadar: PatternRelevanceCard shows AI alignment badge (green = aligned,
red = contra-signal) + base relevance diff + AI insights. NewsCard shows ai_insight,
directional arrows per asset class (🥇↓) and resolution badge when expanded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-19 12:57:52 +02:00
parent 44ada3f8f1
commit 0ee9cf5707
3 changed files with 127 additions and 4 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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<string, string> = {
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 && (
<div className="flex items-start gap-1.5 mb-2 text-xs text-blue-300 italic bg-blue-900/10 border border-blue-800/30 rounded px-2 py-1">
<Brain className="w-3 h-3 mt-0.5 shrink-0 text-blue-400" />
{(news as any).ai_insight}
</div>
)}
{/* AI directional signals */}
{((news as any).ai_dir_energy || (news as any).ai_dir_metals || (news as any).ai_dir_indices) && (
<div className="flex gap-1 flex-wrap mb-2">
{(['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<string, string> = { ai_dir_energy: '⛽', ai_dir_metals: '🥇', ai_dir_indices: '📊' }
return (
<span key={field} className={clsx('text-[10px] px-1.5 py-0.5 rounded font-semibold',
dir === 'bullish' ? 'bg-emerald-900/40 text-emerald-400' : 'bg-red-900/40 text-red-400'
)}>
{labels[field]} {dir === 'bullish' ? '↑' : '↓'}
</span>
)
})}
{(news as any).ai_resolution && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-900/30 text-blue-400">🕊 résolution</span>
)}
</div>
)}
<p className="text-xs text-slate-400 leading-relaxed mb-3">{news.summary}</p>
{Object.keys(news.asset_impacts).length > 0 && (
<div className="mb-3">
@@ -135,9 +162,25 @@ function PatternRelevanceCard({ p }: { p: any }) {
<Search className="w-2.5 h-2.5" />{p.matching_news.length} news
</span>
)}
{/* AI alignment badge */}
{p.ai_scored_count > 0 && p.ai_alignment !== 0 && (
<span className={clsx('text-[10px] px-1.5 py-0.5 rounded flex items-center gap-0.5 font-semibold',
p.ai_contra_signal
? 'bg-red-900/30 text-red-400 border border-red-700/30'
: 'bg-emerald-900/30 text-emerald-400 border border-emerald-700/30'
)}>
<Brain className="w-2.5 h-2.5" />
{p.ai_contra_signal ? `contre-signal (${p.ai_alignment > 0 ? '+' : ''}${p.ai_alignment}pts)` : `IA aligné (+${p.ai_alignment}pts)`}
</span>
)}
</div>
</div>
<RelevanceBar pct={p.relevance} />
<div className="text-right">
<RelevanceBar pct={p.relevance} />
{p.base_relevance !== undefined && p.ai_alignment !== 0 && (
<div className="text-[10px] text-slate-600 mt-0.5">base {p.base_relevance}%</div>
)}
</div>
</div>
{/* Matched keywords */}
@@ -151,6 +194,18 @@ function PatternRelevanceCard({ p }: { p: any }) {
</div>
)}
{/* AI insights */}
{p.ai_insights?.length > 0 && (
<div className="mb-2 space-y-1">
{p.ai_insights.map((insight: string, i: number) => (
<div key={i} className="flex items-start gap-1 text-[10px] text-blue-300 italic">
<Brain className="w-2.5 h-2.5 mt-0.5 shrink-0 text-blue-400" />
{insight}
</div>
))}
</div>
)}
{/* Toggle matching news */}
{hasNews && (
<>