FairValue
feat: upgrade NLP engine with deep search, fallbacks, and neutral signal detection
778ab74
import { useState } from 'react'
import { motion } from 'framer-motion'
import { useUsageLimiter } from '../hooks/useUsageLimiter'
import AccessModal from '../components/AccessModal'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
interface ScoutResult {
player: string
durability: number
recency: number
agent: number
logs: string[]
from_cache: boolean
nlp_found?: boolean
}
function ScoreCard({
label, value, icon, desc, found
}: { label: string; value: number; icon: string; desc: string; found?: boolean }) {
const norm = (value + 1) / 2 // –1..+1 → 0..1
const pct = Math.round(norm * 100)
const color = value > 0.1 ? 'var(--profit-color)' : value < -0.1 ? 'var(--loss-color)' : 'var(--accent-blue)'
const label2 = value > 0.15 ? 'Positive' : value < -0.15 ? 'Negative' : 'Neutral'
const badge = value > 0.15 ? 'badge-green' : value < -0.15 ? 'badge-red' : 'badge-blue'
return (
<div className="glass" style={{ padding:24 }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', marginBottom:14 }}>
<div>
<div style={{ fontSize:'1.6rem', marginBottom:4 }}>{icon}</div>
<h3 style={{ marginBottom:2 }}>{label}</h3>
<p style={{ fontSize:'0.78rem', lineHeight:1.5 }}>{desc}</p>
</div>
<div style={{ textAlign:'right' }}>
<div style={{ fontSize:'2rem', fontWeight:900, color, letterSpacing:'-0.03em', lineHeight:1 }}>
{value === 0 ? (found ? '0.00' : 'N/A') : (value > 0 ? '+' : '') + value.toFixed(2)}
</div>
<span className={`badge ${badge}`} style={{ marginTop:6 }}>
{value === 0 ? (found ? 'Neutral' : 'No Data') : label2}
</span>
</div>
</div>
{/* Bar */}
<div style={{ position:'relative', height:8, borderRadius:4, background:'var(--bg-elevated-2)', overflow:'hidden' }}>
{/* Neutral marker at 50% */}
<div style={{ position:'absolute', left:'50%', top:0, width:1, height:'100%', background:'rgba(255,255,255,0.15)', zIndex:1 }}/>
<div style={{
position:'absolute',
left: value >= 0 ? '50%' : `${pct}%`,
width: `${Math.abs(value) * 50}%`,
height:'100%', borderRadius:4,
background: color,
transition:'all 0.5s cubic-bezier(0.4,0,0.2,1)',
}}/>
</div>
<div style={{ display:'flex', justifyContent:'space-between', marginTop:4 }}>
<span style={{ fontSize:'0.68rem', color:'var(--text-3)' }}>−1.0 Negative</span>
<span style={{ fontSize:'0.68rem', color:'var(--text-3)' }}>+1.0 Positive</span>
</div>
</div>
)
}
function HypeFactorDisplay({ durability, recency, agent }: { durability: number; recency: number; agent: number }) {
// Mirror the tier logic from api/main.py but use a generic baseline
const dur_adj = Math.min(0.0, durability) * 0.15
const rec_adj = Math.max(0.0, recency) * 0.25 // Elite ceiling assumed
const agt_adj = Math.min(0.0, agent) * 0.05
const mult = 1.0 + rec_adj + dur_adj + agt_adj
const pct = ((mult - 1) * 100).toFixed(1)
const isPos = mult >= 1
return (
<div className="glass" style={{
padding:24,
background: isPos ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
borderColor: isPos ? 'rgba(0,232,122,0.2)' : 'rgba(255,77,109,0.2)',
}}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center' }}>
<div>
<div className="metric-label" style={{ marginBottom:4 }}>🔥 Derived Hype Factor</div>
<p style={{ fontSize:'0.8rem', maxWidth:380 }}>
NLP multiplier applied to the ML baseline in the Transfer Estimator.
Computed from weighted sentiment across all three axes.
</p>
</div>
<div style={{ textAlign:'right' }}>
<div style={{ fontSize:'2.8rem', fontWeight:900, color: isPos ? 'var(--profit-color)' : 'var(--loss-color)', letterSpacing:'-0.04em' }}>
×{mult.toFixed(3)}
</div>
<div style={{ fontSize:'0.82rem', color: isPos ? 'var(--profit-color)' : 'var(--loss-color)', fontWeight:600 }}>
{isPos ? `+${pct}% sentiment premium` : `${pct}% sentiment discount`}
</div>
</div>
</div>
</div>
)
}
export default function Intel() {
const { isLocked, incrementUsage } = useUsageLimiter()
const [player, setPlayer] = useState('')
const [club, setClub] = useState('')
const [result, setResult] = useState<ScoutResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showLog, setShowLog] = useState(false)
const handleFetch = async () => {
if (!incrementUsage()) return;
if (!player.trim()) { setError('Enter a player name first.'); return }
setLoading(true); setError(null); setResult(null)
try {
const params = new URLSearchParams({ player: player.trim(), club: club.trim() })
const res = await fetch(`${API_URL}/api/scout?${params}`)
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || `API error ${res.status}`)
}
setResult(await res.json())
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to fetch intel.')
} finally {
setLoading(false)
}
}
return (
<div className="page" style={{ position: 'relative' }}>
{isLocked && <AccessModal />}
<motion.div
className="container"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
{/* Header */}
<div style={{ marginBottom:36 }}>
<span className="badge badge-gold" style={{ marginBottom:10, display:'inline-flex' }}>Live Intelligence</span>
<h1 style={{ marginBottom:8 }}>🔍 Live Player Intel</h1>
<p>Scrape real-time news across three NLP axes to compute the market sentiment multiplier before bidding.</p>
</div>
{/* Search card */}
<div className="glass-flat" style={{ padding:28, marginBottom:24 }}>
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr auto', gap:12, alignItems:'flex-end' }}>
<div className="input-group">
<label className="field-label">Player Name *</label>
<input className="input" placeholder="e.g. Jude Bellingham"
value={player} onChange={e => setPlayer(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleFetch()}/>
</div>
<div className="input-group">
<label className="field-label">Current Club (optional)</label>
<input className="input" placeholder="e.g. Real Madrid"
value={club} onChange={e => setClub(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleFetch()}/>
</div>
<button className="btn btn-primary" style={{ height:42 }}
onClick={handleFetch} disabled={loading}>
{loading ? <><span className="spinner"/> Scraping…</> : '⚡ Fetch Intel'}
</button>
</div>
{error && <div className="alert alert-danger" style={{ marginTop:14 }}>{error}</div>}
<p style={{ fontSize:'0.72rem', color:'var(--text-3)', marginTop:12 }}>
ℹ️ First request per player takes 15–30 seconds (live DDGS scrape). Subsequent requests within 1 hour are instant (cached).
</p>
</div>
{/* Loading */}
{loading && (
<div className="glass" style={{ padding:48, textAlign:'center' }}>
<div className="spinner" style={{ width:40, height:40, margin:'0 auto 20px' }}/>
<p>Scraping live transfer news, injury reports, and agent whispers…</p>
</div>
)}
{/* Results */}
{result && !loading && (
<div className="animate-in" style={{ display:'flex', flexDirection:'column', gap:18 }}>
{/* Cache badge */}
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center' }}>
<h2>{result.player}</h2>
<span className={`badge ${result.from_cache ? 'badge-blue' : 'badge-green'}`}>
{result.from_cache ? '⚡ Cached (< 1hr)' : '🔴 Live Scraped'}
</span>
</div>
{/* Hype Factor */}
<HypeFactorDisplay
durability={result.durability}
recency={result.recency}
agent={result.agent}
/>
{/* Three score cards */}
<div className="grid-3">
<ScoreCard
icon="🏥" label="Durability"
value={result.durability}
found={result.nlp_found}
desc="Based on injury reports, games missed, and fitness news. Negative = active concerns."
/>
<ScoreCard
icon="📈" label="Recent Form"
value={result.recency}
found={result.nlp_found}
desc="Based on match ratings, goal/assist data, and performance commentary."
/>
<ScoreCard
icon="🗞️" label="Transfer Heat"
value={result.agent}
found={result.nlp_found}
desc="Based on rumour intensity, agent activity, and bid speculation volume."
/>
</div>
{/* Methodology note */}
<div className="glass" style={{ padding:20 }}>
<h4 style={{ marginBottom:8 }}>Methodology</h4>
<p style={{ fontSize:'0.8rem', lineHeight:1.7 }}>
Sentiment is derived from TextBlob polarity analysis applied to DDGS (DuckDuckGo)
search snippets scraped in real time. Scores range from −1.0 (very negative) to
+1.0 (very positive). The Hype Factor multiplier is then applied to the XGBoost
ML baseline in the Transfer Estimator to compute the PSR hard cap.
</p>
</div>
{/* Recon log */}
<div>
<button className="btn btn-ghost" style={{ fontSize:'0.78rem', padding:'6px 14px' }}
onClick={() => setShowLog(!showLog)}>
{showLog ? '▲ Hide' : '▼ Show'} Raw Recon Log ({result.logs.length} entries)
</button>
{showLog && (
<div style={{ marginTop:10, background:'var(--bg-elevated-2)', borderRadius:8, padding:'12px 14px' }}>
{result.logs.length === 0
? <span style={{ color:'var(--text-3)', fontSize:'0.78rem' }}>No log entries.</span>
: result.logs.map((l, i) => (
<div key={i} style={{ fontSize:'0.75rem', color:'var(--text-2)', padding:'4px 0', borderBottom:'1px solid var(--glass-border)' }}>{l}</div>
))}
</div>
)}
</div>
</div>
)}
{/* Empty state */}
{!result && !loading && !error && (
<div className="glass" style={{ padding:56, textAlign:'center', color:'var(--text-3)' }}>
<div style={{ fontSize:'3rem', marginBottom:16 }}>🔍</div>
<p>Enter a player name above and click Fetch Intel.</p>
</div>
)}
</motion.div>
</div>
)
}