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:
@@ -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:
|
if not force_refresh and _news_cache["data"] and (now - _news_cache["ts"]) < 3600:
|
||||||
return _news_cache["data"]
|
return _news_cache["data"]
|
||||||
news = fetch_geo_news()
|
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["data"] = news
|
||||||
_news_cache["ts"] = now
|
_news_cache["ts"] = now
|
||||||
return news
|
return news
|
||||||
|
|||||||
@@ -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]]:
|
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."""
|
"""Find which historical geo-patterns best match current event feed."""
|
||||||
if not events:
|
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)
|
similarity = round((trigger_match * 0.5 + keyword_match * 0.5) * 100, 1)
|
||||||
|
|
||||||
if similarity > 10:
|
if similarity > 10:
|
||||||
|
ai = _compute_ai_alignment(events, pattern)
|
||||||
|
adjusted = max(0, min(100, similarity + ai["ai_alignment"]))
|
||||||
matches.append({
|
matches.append({
|
||||||
"pattern_id": pattern["id"],
|
"pattern_id": pattern["id"],
|
||||||
"name": pattern["name"],
|
"name": pattern["name"],
|
||||||
"description": pattern["description"],
|
"description": pattern["description"],
|
||||||
"similarity": similarity,
|
"similarity": round(adjusted, 1),
|
||||||
|
"base_similarity": similarity,
|
||||||
"suggested_trades": pattern["suggested_trades"],
|
"suggested_trades": pattern["suggested_trades"],
|
||||||
"asset_class": pattern["asset_class"],
|
"asset_class": pattern["asset_class"],
|
||||||
"expected_move_pct": pattern["expected_move_pct"],
|
"expected_move_pct": pattern["expected_move_pct"],
|
||||||
"probability": pattern["probability"],
|
"probability": pattern["probability"],
|
||||||
"horizon_days": pattern["horizon_days"],
|
"horizon_days": pattern["horizon_days"],
|
||||||
"historical_instances": pattern["historical_instances"],
|
"historical_instances": pattern["historical_instances"],
|
||||||
|
**ai,
|
||||||
})
|
})
|
||||||
|
|
||||||
return sorted(matches, key=lambda x: x["similarity"], reverse=True)[:5]
|
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)
|
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({
|
result.append({
|
||||||
"pattern_id": pattern.get("id", ""),
|
"pattern_id": pattern.get("id", ""),
|
||||||
"name": pattern.get("name", ""),
|
"name": pattern.get("name", ""),
|
||||||
"description": pattern.get("description", ""),
|
"description": pattern.get("description", ""),
|
||||||
"asset_class": pattern.get("asset_class", ""),
|
"asset_class": pattern.get("asset_class", ""),
|
||||||
"relevance": relevance,
|
"relevance": round(adjusted_relevance, 1),
|
||||||
|
"base_relevance": relevance,
|
||||||
"keyword_hits": len(kw_hits),
|
"keyword_hits": len(kw_hits),
|
||||||
"keyword_total": len(keywords_list),
|
"keyword_total": len(keywords_list),
|
||||||
"matched_keywords": kw_hits,
|
"matched_keywords": kw_hits,
|
||||||
@@ -338,6 +397,7 @@ def compute_pattern_relevance(
|
|||||||
"expected_move_pct": pattern.get("expected_move_pct", 0),
|
"expected_move_pct": pattern.get("expected_move_pct", 0),
|
||||||
"probability": pattern.get("probability", 0),
|
"probability": pattern.get("probability", 0),
|
||||||
"horizon_days": pattern.get("horizon_days", 0),
|
"horizon_days": pattern.get("horizon_days", 0),
|
||||||
|
**ai,
|
||||||
})
|
})
|
||||||
|
|
||||||
result.sort(key=lambda x: x["relevance"], reverse=True)
|
result.sort(key=lambda x: x["relevance"], reverse=True)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useGeoNews, usePatternRelevance, useGeoRiskScore } from '../hooks/useApi'
|
import { useGeoNews, usePatternRelevance, useGeoRiskScore } from '../hooks/useApi'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import type { GeoNews } from '../types'
|
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> = {
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
military: 'badge-red', sanctions: 'badge-orange', elections: 'badge-purple',
|
military: 'badge-red', sanctions: 'badge-orange', elections: 'badge-purple',
|
||||||
@@ -67,6 +67,33 @@ function NewsCard({ news }: { news: GeoNews }) {
|
|||||||
|
|
||||||
{expanded && (
|
{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>
|
<p className="text-xs text-slate-400 leading-relaxed mb-3">{news.summary}</p>
|
||||||
{Object.keys(news.asset_impacts).length > 0 && (
|
{Object.keys(news.asset_impacts).length > 0 && (
|
||||||
<div className="mb-3">
|
<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
|
<Search className="w-2.5 h-2.5" />{p.matching_news.length} news
|
||||||
</span>
|
</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>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Matched keywords */}
|
{/* Matched keywords */}
|
||||||
@@ -151,6 +194,18 @@ function PatternRelevanceCard({ p }: { p: any }) {
|
|||||||
</div>
|
</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 */}
|
{/* Toggle matching news */}
|
||||||
{hasNews && (
|
{hasNews && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Reference in New Issue
Block a user