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:
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user