mirror of
https://github.com/velocitatem/cvfs.git
synced 2026-06-01 01:03:36 +00:00
feat: NLP patch insights + standalone demo mode
- dlib/ai/insights.py: pure-Python NLP analysis that correlates accepted AI suggestion operations/keywords/sections with submission outcomes (pending_review / published = positive, archived = negative) - Backend: GET /api/v1/insights route + service + Pydantic schema - Frontend: InsightsPanel component with bar charts for operation impact, section impact, and keyword signal lift scores - Insights tab added to the version panel; compact preview on doc overview - NEXT_PUBLIC_DEMO=true makes the webapp fully standalone: loads DEMO_DOCUMENTS / DEMO_SUBMISSIONS / DEMO_INSIGHTS from demo-data.ts, disables all mutating actions, shows a DEMO badge in the top bar - apps/webapp/public/demo-cv.docx: static dummy CV (Alex Rivera) for demo - scripts/gen_demo_cv.py: script to regenerate the demo DOCX - .env.example: document NEXT_PUBLIC_DEMO flag https://claude.ai/code/session_01LWxu2qrwY6BRjUFXXn7NiM
This commit is contained in:
134
apps/webapp/src/components/cv/InsightsPanel.tsx
Normal file
134
apps/webapp/src/components/cv/InsightsPanel.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import type { InsightsResult } from '@/libs/api';
|
||||
|
||||
function Bar({ rate, positive }: { rate: number; positive?: boolean }) {
|
||||
return (
|
||||
<div style={{ flex: 1, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
width: `${Math.round(rate * 100)}%`,
|
||||
height: '100%',
|
||||
background: positive === false ? '#ef4444' : rate >= 0.6 ? '#22c55e' : rate >= 0.4 ? '#f59e0b' : '#94a3b8',
|
||||
borderRadius: 3,
|
||||
transition: 'width 0.3s',
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pct({ v }: { v: number }) {
|
||||
return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12, fontWeight: 600, color: v >= 0.6 ? '#16a34a' : v >= 0.4 ? '#d97706' : '#6b7280' }}>{Math.round(v * 100)}%</span>;
|
||||
}
|
||||
|
||||
export default function InsightsPanel({ data }: { data: InsightsResult | null }) {
|
||||
if (!data) return (
|
||||
<div style={{ padding: '24px 0', color: 'var(--text-faint)', fontSize: 13, textAlign: 'center' }}>
|
||||
Loading insights…
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!data.has_data) return (
|
||||
<div style={{ padding: '24px 0', color: 'var(--text-faint)', fontSize: 13 }}>
|
||||
Not enough data yet. Submit applications and mark outcomes to unlock insights.
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{/* headline numbers */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
|
||||
{[
|
||||
{ label: 'Total submissions', value: data.total_submissions },
|
||||
{ label: 'Passed screening', value: data.positive_count },
|
||||
{ label: 'Screening rate', value: `${Math.round(data.positive_rate * 100)}%` },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', background: 'var(--surface)' }}>
|
||||
<div className="label" style={{ marginBottom: 3 }}>{label}</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 600 }}>{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* operation impact */}
|
||||
{data.operation_impact.length > 0 && (
|
||||
<section>
|
||||
<div className="label" style={{ marginBottom: 8 }}>Patch operation impact</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{data.operation_impact.map(op => (
|
||||
<div key={op.operation} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, width: 140, flexShrink: 0, color: 'var(--text-muted)' }}>
|
||||
{op.operation}
|
||||
</span>
|
||||
<Bar rate={op.rate} />
|
||||
<Pct v={op.rate} />
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', width: 50, textAlign: 'right' }}>
|
||||
{op.positive}/{op.total}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 6 }}>
|
||||
% of accepted patches of this type in submissions that passed screening.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* section impact */}
|
||||
{data.section_impact.length > 0 && (
|
||||
<section>
|
||||
<div className="label" style={{ marginBottom: 8 }}>CV section impact</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{data.section_impact.map(s => (
|
||||
<div key={s.section} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, width: 80, flexShrink: 0, color: 'var(--text-muted)' }}>
|
||||
{s.section}
|
||||
</span>
|
||||
<Bar rate={s.positive_rate} />
|
||||
<Pct v={s.positive_rate} />
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', width: 50, textAlign: 'right' }}>
|
||||
{s.count} edits
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* keyword signals */}
|
||||
{(data.top_positive_keywords.length > 0 || data.top_negative_keywords.length > 0) && (
|
||||
<section>
|
||||
<div className="label" style={{ marginBottom: 8 }}>Keyword signals</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#16a34a', fontWeight: 600, marginBottom: 6 }}>Positive signals</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{data.top_positive_keywords.map(k => (
|
||||
<div key={k.keyword} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text)' }}>{k.keyword}</span>
|
||||
<span style={{ fontSize: 11, color: '#16a34a' }}>+{k.positive_count} ({k.lift}×)</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#dc2626', fontWeight: 600, marginBottom: 6 }}>Negative signals</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{data.top_negative_keywords.length === 0
|
||||
? <span style={{ fontSize: 12, color: 'var(--text-faint)' }}>None yet</span>
|
||||
: data.top_negative_keywords.map(k => (
|
||||
<div key={k.keyword} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text)' }}>{k.keyword}</span>
|
||||
<span style={{ fontSize: 11, color: '#dc2626' }}>{k.negative_count}×</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 6 }}>
|
||||
Keywords extracted from accepted AI suggestions, split by outcome.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user