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

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