From 615d1bdb9eef69626b36645113616213f812474a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 09:34:01 +0000 Subject: [PATCH] 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 --- .env.example | 5 + apps/backend/fastapi/app/api/router.py | 3 +- .../fastapi/app/api/routes/insights.py | 41 ++++ apps/backend/fastapi/app/schemas/insights.py | 34 ++++ apps/backend/fastapi/app/services/insights.py | 37 ++++ apps/webapp/public/demo-cv.docx | Bin 0 -> 37550 bytes apps/webapp/src/app/dashboard/demo-data.ts | 165 +++++++++++++++ apps/webapp/src/app/dashboard/page.tsx | 83 ++++++-- .../src/components/cv/InsightsPanel.tsx | 134 +++++++++++++ apps/webapp/src/libs/api.ts | 19 ++ dlib/ai/insights.py | 189 ++++++++++++++++++ scripts/gen_demo_cv.py | 87 ++++++++ 12 files changed, 780 insertions(+), 17 deletions(-) create mode 100644 apps/backend/fastapi/app/api/routes/insights.py create mode 100644 apps/backend/fastapi/app/schemas/insights.py create mode 100644 apps/backend/fastapi/app/services/insights.py create mode 100644 apps/webapp/public/demo-cv.docx create mode 100644 apps/webapp/src/app/dashboard/demo-data.ts create mode 100644 apps/webapp/src/components/cv/InsightsPanel.tsx create mode 100644 dlib/ai/insights.py create mode 100644 scripts/gen_demo_cv.py diff --git a/.env.example b/.env.example index 48b9b6d..772b570 100644 --- a/.env.example +++ b/.env.example @@ -56,3 +56,8 @@ AUTHENTIK_CLIENT_SECRET= # ── AI tailoring (optional) ─────────────────────────────────────────────────── # Leave blank to use the built-in rule-based tailoring instead of Claude. ANTHROPIC_API_KEY= + +# ── Demo mode ───────────────────────────────────────────────────────────────── +# Set to true to enable standalone demo mode in the webapp. +# Demo mode uses hardcoded dummy data — no backend or DB required. +NEXT_PUBLIC_DEMO=false diff --git a/apps/backend/fastapi/app/api/router.py b/apps/backend/fastapi/app/api/router.py index dccc0ca..25ab068 100644 --- a/apps/backend/fastapi/app/api/router.py +++ b/apps/backend/fastapi/app/api/router.py @@ -2,10 +2,11 @@ from __future__ import annotations from fastapi import APIRouter -from app.api.routes import documents, versions, submissions, public +from app.api.routes import documents, insights, versions, submissions, public api_router = APIRouter() api_router.include_router(documents.router) api_router.include_router(versions.router) api_router.include_router(submissions.router) api_router.include_router(public.router) +api_router.include_router(insights.router) diff --git a/apps/backend/fastapi/app/api/routes/insights.py b/apps/backend/fastapi/app/api/routes/insights.py new file mode 100644 index 0000000..5cdae76 --- /dev/null +++ b/apps/backend/fastapi/app/api/routes/insights.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user, get_db +from app.schemas.insights import InsightsResponse +from app.services.insights import get_insights +from dlib.auth import AuthenticatedUser + +router = APIRouter(prefix="/insights", tags=["insights"]) + + +@router.get("", response_model=InsightsResponse) +async def insights_endpoint( + session: AsyncSession = Depends(get_db), + user: AuthenticatedUser = Depends(get_current_user), +): + result = await get_insights(session, owner_id=user.sub) + return InsightsResponse( + total_submissions=result.total_submissions, + positive_count=result.positive_count, + positive_rate=result.positive_rate, + operation_impact=[ + {"operation": o.operation, "total": o.total, "positive": o.positive, "rate": o.rate} + for o in result.operation_impact + ], + top_positive_keywords=[ + {"keyword": k.keyword, "positive_count": k.positive_count, "negative_count": k.negative_count, "lift": k.lift} + for k in result.top_positive_keywords + ], + top_negative_keywords=[ + {"keyword": k.keyword, "positive_count": k.positive_count, "negative_count": k.negative_count, "lift": k.lift} + for k in result.top_negative_keywords + ], + section_impact=[ + {"section": s.section, "positive_rate": s.positive_rate, "count": s.count} + for s in result.section_impact + ], + has_data=result.has_data, + ) diff --git a/apps/backend/fastapi/app/schemas/insights.py b/apps/backend/fastapi/app/schemas/insights.py new file mode 100644 index 0000000..84eff18 --- /dev/null +++ b/apps/backend/fastapi/app/schemas/insights.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from pydantic import BaseModel + + +class OperationImpactSchema(BaseModel): + operation: str + total: int + positive: int + rate: float + + +class KeywordSignalSchema(BaseModel): + keyword: str + positive_count: int + negative_count: int + lift: float + + +class SectionImpactSchema(BaseModel): + section: str + positive_rate: float + count: int + + +class InsightsResponse(BaseModel): + total_submissions: int + positive_count: int + positive_rate: float + operation_impact: list[OperationImpactSchema] + top_positive_keywords: list[KeywordSignalSchema] + top_negative_keywords: list[KeywordSignalSchema] + section_impact: list[SectionImpactSchema] + has_data: bool diff --git a/apps/backend/fastapi/app/services/insights.py b/apps/backend/fastapi/app/services/insights.py new file mode 100644 index 0000000..a876e78 --- /dev/null +++ b/apps/backend/fastapi/app/services/insights.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from dlib.ai.insights import InsightsResult, SubmissionRecord, SuggestionRecord, analyze +from app.models import AiSuggestion, CvDocument, CvVersion, Submission + + +async def get_insights(session: AsyncSession, *, owner_id: str) -> InsightsResult: + stmt = ( + select(Submission) + .join(Submission.version) + .join(CvVersion.document) + .where(CvDocument.owner_id == owner_id) + .options(selectinload(Submission.suggestions)) + ) + rows = list((await session.execute(stmt)).scalars().all()) + + records = [ + SubmissionRecord( + status=s.status.value, + suggestions=[ + SuggestionRecord( + operation=sug.operation, + target_path=sug.target_path, + proposed_text=sug.proposed_text, + rationale=sug.rationale, + accepted=sug.accepted, + ) + for sug in s.suggestions + ], + ) + for s in rows + ] + return analyze(records) diff --git a/apps/webapp/public/demo-cv.docx b/apps/webapp/public/demo-cv.docx new file mode 100644 index 0000000000000000000000000000000000000000..e32e6a008743257fce05558eb0e69624804012dd GIT binary patch literal 37550 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 new Date(Date.now() - daysAgo * 86_400_000).toISOString(); + +const ROOT_VERSION_ID = 'demo-v1'; +const ML_VERSION_ID = 'demo-v2'; +const BACKEND_VERSION_ID = 'demo-v3'; +export const DEMO_DOC_ID = 'demo-doc-1'; + +export const DEMO_DOCUMENTS: Document[] = [ + { + id: DEMO_DOC_ID, + title: 'Alex Rivera — Software Engineer', + description: 'Main CV, ATS-safe baseline', + owner_id: 'demo-user', + root_version_id: ROOT_VERSION_ID, + created_at: D(45), + updated_at: D(3), + versions: [ + { + id: ROOT_VERSION_ID, + branch_name: 'root', + version_label: 'v1.0 baseline', + parent_version_id: null, + structured_blocks: [ + { path: 'heading[1]', block_type: 'heading', text: 'Alex Rivera', keywords: [] }, + { path: 'summary[1]', block_type: 'summary', text: 'Software engineer with 5 years of experience building distributed systems and ML pipelines at scale.', keywords: ['distributed', 'systems', 'machine', 'learning'] }, + { path: 'heading[2]', block_type: 'heading', text: 'Experience', keywords: [] }, + { path: 'bullet[1]', block_type: 'bullet', text: 'Led migration of monolithic data pipeline to distributed microservices, reducing p99 latency by 40%.', keywords: ['distributed', 'microservices', 'latency', 'pipeline'] }, + { path: 'bullet[2]', block_type: 'bullet', text: 'Designed feature flag system used by 50+ engineers across 3 teams.', keywords: ['system', 'design', 'engineers'] }, + { path: 'heading[3]', block_type: 'heading', text: 'Skills', keywords: [] }, + { path: 'skills[1]', block_type: 'skills', text: 'Python, Go, TypeScript, SQL, Kubernetes, AWS, PyTorch', keywords: ['python', 'go', 'typescript', 'pytorch', 'kubernetes'] }, + ], + artifact_docx_key: 'demo/demo-cv.docx', + patches: [], + public_assets: [], + created_at: D(45), + updated_at: D(45), + }, + { + id: ML_VERSION_ID, + branch_name: 'ml-engineer', + version_label: 'ML-focused variant', + parent_version_id: ROOT_VERSION_ID, + structured_blocks: [ + { path: 'heading[1]', block_type: 'heading', text: 'Alex Rivera', keywords: [] }, + { path: 'summary[1]', block_type: 'summary', text: 'ML engineer specialising in large-scale PyTorch training pipelines, distributed inference, and production-grade MLOps.', keywords: ['pytorch', 'distributed', 'mlops', 'inference'] }, + { path: 'heading[2]', block_type: 'heading', text: 'Experience', keywords: [] }, + { path: 'bullet[1]', block_type: 'bullet', text: 'Contributed PyTorch anomaly detection model achieving 92% precision on production traffic at 2M events/day.', keywords: ['pytorch', 'machine learning', 'production', 'precision'] }, + { path: 'bullet[2]', block_type: 'bullet', text: 'Built streaming data ingestion system (Kafka + Flink) powering real-time ML feature store.', keywords: ['kafka', 'flink', 'streaming', 'feature store'] }, + { path: 'heading[3]', block_type: 'heading', text: 'Skills', keywords: [] }, + { path: 'skills[1]', block_type: 'skills', text: 'PyTorch, Python, Go, Kubernetes, Spark, dbt, AWS SageMaker', keywords: ['pytorch', 'python', 'kubernetes', 'spark', 'sagemaker'] }, + ], + artifact_docx_key: 'demo/demo-cv.docx', + patches: [ + { id: 'dp1', target_path: 'summary[1]', operation: 'replace_text', old_value: 'Software engineer…', new_value: 'ML engineer specialising…', created_at: D(30) }, + { id: 'dp2', target_path: 'skills[1]', operation: 'boost_keyword', old_value: null, new_value: 'PyTorch', created_at: D(30) }, + ], + public_assets: [{ + id: 'demo-asset-1', slug: 'alex-ml', artifact_key: 'public/alex-ml.docx', + is_public: true, url: '/demo-cv.docx', version_id: ML_VERSION_ID, submission_id: null, created_at: D(20), + }], + created_at: D(30), + updated_at: D(3), + }, + { + id: BACKEND_VERSION_ID, + branch_name: 'backend-engineer', + version_label: 'Backend-focused variant', + parent_version_id: ROOT_VERSION_ID, + structured_blocks: [ + { path: 'heading[1]', block_type: 'heading', text: 'Alex Rivera', keywords: [] }, + { path: 'summary[1]', block_type: 'summary', text: 'Backend engineer focused on high-throughput API design, distributed systems, and reliability engineering.', keywords: ['backend', 'api', 'distributed', 'reliability'] }, + { path: 'bullet[1]', block_type: 'bullet', text: 'Led migration to microservices, reducing p99 latency by 40% under 10k RPS sustained load.', keywords: ['microservices', 'latency', 'rps', 'distributed'] }, + { path: 'skills[1]', block_type: 'skills', text: 'Go, Python, PostgreSQL, Redis, gRPC, Kubernetes, AWS', keywords: ['go', 'postgresql', 'redis', 'grpc', 'kubernetes'] }, + ], + artifact_docx_key: 'demo/demo-cv.docx', + patches: [ + { id: 'dp3', target_path: 'summary[1]', operation: 'replace_text', old_value: 'Software engineer…', new_value: 'Backend engineer…', created_at: D(25) }, + ], + public_assets: [], + created_at: D(25), + updated_at: D(10), + }, + ], + }, +]; + +export const DEMO_SUBMISSIONS: Submission[] = [ + { + id: 'ds1', version_id: ML_VERSION_ID, company_name: 'Anthropic', role_title: 'ML Research Engineer', + job_url: null, job_description: null, status: 'pending_review', created_at: D(18), + suggestions: [ + { id: 's1', target_path: 'summary[1]', operation: 'boost_keyword', proposed_text: 'constitutional ai', rationale: 'Highlight alignment research experience', accepted: true, metadata_json: { confidence: 0.82 } }, + { id: 's2', target_path: 'bullet[1]', operation: 'replace_text', proposed_text: 'Built distributed PyTorch training pipeline handling constitutional AI fine-tuning at scale.', rationale: 'Align with Anthropic stack', accepted: true, metadata_json: { confidence: 0.74 } }, + ], + }, + { + id: 'ds2', version_id: ML_VERSION_ID, company_name: 'Google DeepMind', role_title: 'Senior ML Engineer', + job_url: null, job_description: null, status: 'pending_review', created_at: D(14), + suggestions: [ + { id: 's3', target_path: 'skills[1]', operation: 'boost_keyword', proposed_text: 'JAX', rationale: 'DeepMind uses JAX heavily', accepted: true, metadata_json: { confidence: 0.71 } }, + { id: 's4', target_path: 'bullet[2]', operation: 'replace_text', proposed_text: 'Built large-scale streaming pipeline underpinning real-time feature store for JAX model serving.', rationale: 'Add JAX context', accepted: true, metadata_json: { confidence: 0.68 } }, + ], + }, + { + id: 'ds3', version_id: ML_VERSION_ID, company_name: 'OpenAI', role_title: 'Research Engineer', + job_url: null, job_description: null, status: 'published', created_at: D(10), + suggestions: [ + { id: 's5', target_path: 'summary[1]', operation: 'replace_text', proposed_text: 'ML engineer with track record in large-scale training infrastructure and RLHF pipelines.', rationale: 'OpenAI focus on RLHF', accepted: true, metadata_json: { confidence: 0.77 } }, + ], + }, + { + id: 'ds4', version_id: ML_VERSION_ID, company_name: 'Meta AI', role_title: 'ML Infrastructure Engineer', + job_url: null, job_description: null, status: 'archived', created_at: D(22), + suggestions: [ + { id: 's6', target_path: 'bullet[1]', operation: 'boost_keyword', proposed_text: 'PyTorch', rationale: 'Meta maintains PyTorch', accepted: true, metadata_json: { confidence: 0.55 } }, + { id: 's7', target_path: 'summary[1]', operation: 'suppress_block', proposed_text: null, rationale: 'Summary too generic', accepted: false, metadata_json: { confidence: 0.3 } }, + ], + }, + { + id: 'ds5', version_id: BACKEND_VERSION_ID, company_name: 'Stripe', role_title: 'Senior Backend Engineer', + job_url: null, job_description: null, status: 'pending_review', created_at: D(8), + suggestions: [ + { id: 's8', target_path: 'bullet[1]', operation: 'replace_text', proposed_text: 'Led migration to microservices achieving 99.99% uptime across Stripe-scale payment processing.', rationale: 'Emphasise reliability', accepted: true, metadata_json: { confidence: 0.79 } }, + ], + }, + { + id: 'ds6', version_id: BACKEND_VERSION_ID, company_name: 'Cloudflare', role_title: 'Staff Engineer', + job_url: null, job_description: null, status: 'archived', created_at: D(20), + suggestions: [ + { id: 's9', target_path: 'skills[1]', operation: 'boost_keyword', proposed_text: 'Rust', rationale: 'Cloudflare uses Rust', accepted: true, metadata_json: { confidence: 0.4 } }, + ], + }, +]; + +export const DEMO_INSIGHTS: InsightsResult = { + total_submissions: 6, + positive_count: 4, + positive_rate: 0.667, + has_data: true, + operation_impact: [ + { operation: 'replace_text', total: 5, positive: 4, rate: 0.8 }, + { operation: 'boost_keyword', total: 5, positive: 3, rate: 0.6 }, + { operation: 'suppress_block', total: 1, positive: 0, rate: 0.0 }, + ], + top_positive_keywords: [ + { keyword: 'pytorch', positive_count: 4, negative_count: 1, lift: 4.0 }, + { keyword: 'distributed', positive_count: 3, negative_count: 0, lift: 3.0 }, + { keyword: 'pipeline', positive_count: 3, negative_count: 1, lift: 3.0 }, + { keyword: 'scale', positive_count: 3, negative_count: 1, lift: 3.0 }, + { keyword: 'reliability', positive_count: 2, negative_count: 0, lift: 2.0 }, + { keyword: 'inference', positive_count: 2, negative_count: 0, lift: 2.0 }, + ], + top_negative_keywords: [ + { keyword: 'generic', positive_count: 0, negative_count: 2, lift: 0.0 }, + { keyword: 'suppress', positive_count: 0, negative_count: 1, lift: 0.0 }, + ], + section_impact: [ + { section: 'summary', positive_rate: 0.83, count: 6 }, + { section: 'bullet', positive_rate: 0.75, count: 4 }, + { section: 'skills', positive_rate: 0.5, count: 4 }, + ], +}; diff --git a/apps/webapp/src/app/dashboard/page.tsx b/apps/webapp/src/app/dashboard/page.tsx index 7054698..a4c26ab 100644 --- a/apps/webapp/src/app/dashboard/page.tsx +++ b/apps/webapp/src/app/dashboard/page.tsx @@ -3,12 +3,15 @@ import { useEffect, useRef, useState } from 'react'; import CVTree from '@/components/cv/CVTree'; import DiffViewer from '@/components/cv/DiffViewer'; +import InsightsPanel from '@/components/cv/InsightsPanel'; import Link from 'next/link'; import { appendPatches, createBranch, createSubmission, deleteDocument, deleteVersion, Document, downloadVersionUrl, - fetchDocuments, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl, + fetchDocuments, fetchInsights, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl, + InsightsResult, + IS_DEMO, publishVersion, PublicAsset, PublicAssetAnalytics, requestAiSuggestions, Submission, @@ -20,6 +23,9 @@ import { uploadDocument, Version, } from '@/libs/api'; +import { + DEMO_DOCUMENTS, DEMO_DOC_ID, DEMO_INSIGHTS, DEMO_SUBMISSIONS, +} from './demo-data'; // ── helpers ─────────────────────────────────────────────────────────────────── @@ -548,7 +554,7 @@ function SubmissionsTab({ // ── main dashboard ──────────────────────────────────────────────────────────── type Modal = 'upload' | 'branch' | 'submission' | 'publish' | null; -type Tab = 'content' | 'patches' | 'submissions'; +type Tab = 'content' | 'patches' | 'submissions' | 'insights'; export default function Dashboard() { const [docs, setDocs] = useState([]); @@ -568,8 +574,17 @@ export default function Dashboard() { const [docHovered, setDocHovered] = useState(null); const [applyLoading, setApplyLoading] = useState(false); const [applyError, setApplyError] = useState(''); + const [insights, setInsights] = useState(null); useEffect(() => { + if (IS_DEMO) { + setDocs(DEMO_DOCUMENTS); + setAllSubmissions(DEMO_SUBMISSIONS); + setSelectedDocId(DEMO_DOC_ID); + setInsights(DEMO_INSIGHTS); + setLoading(false); + return; + } Promise.all([fetchDocuments(), fetchSubmissions().catch(() => [])]) .then(([d, allSubs]) => { setDocs(d); @@ -580,6 +595,11 @@ export default function Dashboard() { .finally(() => setLoading(false)); }, []); + useEffect(() => { + if (IS_DEMO || !selectedDocId) return; + fetchInsights().then(setInsights).catch(() => setInsights(null)); + }, [selectedDocId]); + useEffect(() => { setPendingEdits(new Map()); setApplyError(''); @@ -691,6 +711,7 @@ export default function Dashboard() { }; const handleDeleteDoc = async (docId: string) => { + if (IS_DEMO) return; if (!confirm('Delete this CV and all its branches? This cannot be undone.')) return; try { await deleteDocument(docId); @@ -706,6 +727,7 @@ export default function Dashboard() { }; const handleDeleteVersion = async (versionId: string) => { + if (IS_DEMO) return; const hasChildren = selectedDoc?.versions.some(v => v.parent_version_id === versionId); const msg = hasChildren ? 'Delete this branch and all its sub-branches? This cannot be undone.' @@ -758,12 +780,21 @@ export default function Dashboard() {

- - + {IS_DEMO && ( + + DEMO + + )} + {!IS_DEMO && ( + + )} + {!IS_DEMO && ( + + )}
@@ -900,6 +931,13 @@ export default function Dashboard() { onSelect={selectVersion} /> + + {insights?.has_data && ( +
+
NLP insights
+ +
+ )} ) : (
@@ -938,10 +976,15 @@ export default function Dashboard() { {/* action buttons */}
- - - - {selectedVersion.artifact_docx_key && selectedDoc && ( + {!IS_DEMO && } + {!IS_DEMO && } + {!IS_DEMO && } + {IS_DEMO && ( + + ↓ DOCX + + )} + {!IS_DEMO && selectedVersion.artifact_docx_key && selectedDoc && ( ↓ DOCX @@ -1044,7 +1087,7 @@ export default function Dashboard() { {/* tabs */}
- {(['content', 'patches', 'submissions'] as Tab[]).map(t => ( + {(['content', 'patches', 'submissions', 'insights'] as Tab[]).map(t => (
)} diff --git a/apps/webapp/src/components/cv/InsightsPanel.tsx b/apps/webapp/src/components/cv/InsightsPanel.tsx new file mode 100644 index 0000000..bbdcd8c --- /dev/null +++ b/apps/webapp/src/components/cv/InsightsPanel.tsx @@ -0,0 +1,134 @@ +'use client'; + +import type { InsightsResult } from '@/libs/api'; + +function Bar({ rate, positive }: { rate: number; positive?: boolean }) { + return ( +
+
= 0.6 ? '#22c55e' : rate >= 0.4 ? '#f59e0b' : '#94a3b8', + borderRadius: 3, + transition: 'width 0.3s', + }} /> +
+ ); +} + +function Pct({ v }: { v: number }) { + return = 0.6 ? '#16a34a' : v >= 0.4 ? '#d97706' : '#6b7280' }}>{Math.round(v * 100)}%; +} + +export default function InsightsPanel({ data }: { data: InsightsResult | null }) { + if (!data) return ( +
+ Loading insights… +
+ ); + + if (!data.has_data) return ( +
+ Not enough data yet. Submit applications and mark outcomes to unlock insights. +
+ ); + + return ( +
+ {/* headline numbers */} +
+ {[ + { 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 }) => ( +
+
{label}
+
{value}
+
+ ))} +
+ + {/* operation impact */} + {data.operation_impact.length > 0 && ( +
+
Patch operation impact
+
+ {data.operation_impact.map(op => ( +
+ + {op.operation} + + + + + {op.positive}/{op.total} + +
+ ))} +
+

+ % of accepted patches of this type in submissions that passed screening. +

+
+ )} + + {/* section impact */} + {data.section_impact.length > 0 && ( +
+
CV section impact
+
+ {data.section_impact.map(s => ( +
+ + {s.section} + + + + + {s.count} edits + +
+ ))} +
+
+ )} + + {/* keyword signals */} + {(data.top_positive_keywords.length > 0 || data.top_negative_keywords.length > 0) && ( +
+
Keyword signals
+
+
+
Positive signals
+
+ {data.top_positive_keywords.map(k => ( +
+ {k.keyword} + +{k.positive_count} ({k.lift}×) +
+ ))} +
+
+
+
Negative signals
+
+ {data.top_negative_keywords.length === 0 + ? None yet + : data.top_negative_keywords.map(k => ( +
+ {k.keyword} + {k.negative_count}× +
+ ))} +
+
+
+

+ Keywords extracted from accepted AI suggestions, split by outcome. +

+
+ )} +
+ ); +} diff --git a/apps/webapp/src/libs/api.ts b/apps/webapp/src/libs/api.ts index 736f4a9..6260fe8 100644 --- a/apps/webapp/src/libs/api.ts +++ b/apps/webapp/src/libs/api.ts @@ -1,4 +1,5 @@ const API = ""; +export const IS_DEMO = process.env.NEXT_PUBLIC_DEMO === 'true'; export type StructuredBlock = { path: string; @@ -87,6 +88,21 @@ export type PublicAssetAnalytics = { last_viewed_at?: string | null; }; +export type OperationImpact = { operation: string; total: number; positive: number; rate: number }; +export type KeywordSignal = { keyword: string; positive_count: number; negative_count: number; lift: number }; +export type SectionImpact = { section: string; positive_rate: number; count: number }; + +export type InsightsResult = { + total_submissions: number; + positive_count: number; + positive_rate: number; + operation_impact: OperationImpact[]; + top_positive_keywords: KeywordSignal[]; + top_negative_keywords: KeywordSignal[]; + section_impact: SectionImpact[]; + has_data: boolean; +}; + // reads OIDC bearer token from client-readable cookie (set by /api/auth/callback) function getAuthHeader(): Record { if (typeof document === 'undefined') return {}; @@ -238,6 +254,9 @@ export async function deleteDocument(documentId: string): Promise { } } +export const fetchInsights = (): Promise => + req('/api/v1/insights'); + export async function deleteVersion(versionId: string): Promise { const res = await fetch(`${API}/api/v1/versions/${versionId}`, { method: 'DELETE', diff --git a/dlib/ai/insights.py b/dlib/ai/insights.py new file mode 100644 index 0000000..db87003 --- /dev/null +++ b/dlib/ai/insights.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import re +from collections import Counter, defaultdict +from dataclasses import dataclass, field +from typing import Literal + +STOPWORDS = frozenset( + "a an the and or but in on at to for of with is are was were be been have has" + " had do does did this that these those it its i you he she we they their our" + " your my his her from by into through about as so if then when where which who" + " can will may should would could also just not no more some all any each" + " than other up out off over how what new using use used with well per".split() +) + +Outcome = Literal["positive", "negative"] # positive = pending_review / published + + +@dataclass +class SuggestionRecord: + operation: str + target_path: str + proposed_text: str | None + rationale: str | None + accepted: bool | None + + +@dataclass +class SubmissionRecord: + status: str + suggestions: list[SuggestionRecord] = field(default_factory=list) + + +@dataclass +class OperationImpact: + operation: str + total: int + positive: int + rate: float + + +@dataclass +class KeywordSignal: + keyword: str + positive_count: int + negative_count: int + lift: float # positive_count / max(negative_count, 1) + + +@dataclass +class SectionImpact: + section: str + positive_rate: float + count: int + + +@dataclass +class InsightsResult: + total_submissions: int + positive_count: int + positive_rate: float + operation_impact: list[OperationImpact] + top_positive_keywords: list[KeywordSignal] + top_negative_keywords: list[KeywordSignal] + section_impact: list[SectionImpact] + has_data: bool + + +def _outcome(status: str) -> Outcome | None: + if status in ("pending_review", "published"): + return "positive" + if status == "archived": + return "negative" + return None # draft / tailoring — not enough signal + + +def _tokens(text: str | None) -> list[str]: + if not text: + return [] + return [ + t for t in re.findall(r"[a-z][a-z0-9+.-]{1,}", text.lower()) + if t not in STOPWORDS and len(t) > 2 + ] + + +def _section_prefix(path: str) -> str: + """heading[1] -> heading, bullet[3] -> bullet, table[1].0-1 -> table""" + return re.match(r"([a-z_]+)", path).group(1) if path else "unknown" + + +def analyze(submissions: list[SubmissionRecord]) -> InsightsResult: + labeled = [(s, _outcome(s.status)) for s in submissions] + labeled_known = [(s, o) for s, o in labeled if o is not None] + + positive_count = sum(1 for _, o in labeled_known if o == "positive") + + # operation impact: only accepted suggestions in outcome-labeled submissions + op_positive: Counter[str] = Counter() + op_total: Counter[str] = Counter() + for sub, outcome in labeled_known: + for sug in sub.suggestions: + if sug.accepted is not True: + continue + op_total[sug.operation] += 1 + if outcome == "positive": + op_positive[sug.operation] += 1 + + op_impact = sorted( + [ + OperationImpact( + operation=op, + total=total, + positive=op_positive[op], + rate=round(op_positive[op] / total, 3), + ) + for op, total in op_total.items() + ], + key=lambda x: x.rate, + reverse=True, + ) + + # keyword signals from accepted-suggestion text in outcome-labeled submissions + kw_pos: Counter[str] = Counter() + kw_neg: Counter[str] = Counter() + for sub, outcome in labeled_known: + bucket = kw_pos if outcome == "positive" else kw_neg + for sug in sub.suggestions: + if sug.accepted is not True: + continue + for t in _tokens(sug.proposed_text) + _tokens(sug.rationale): + bucket[t] += 1 + + all_kws = set(kw_pos) | set(kw_neg) + signals = [ + KeywordSignal( + keyword=kw, + positive_count=kw_pos[kw], + negative_count=kw_neg[kw], + lift=round(kw_pos[kw] / max(kw_neg[kw], 1), 2), + ) + for kw in all_kws + if kw_pos[kw] + kw_neg[kw] >= 2 # minimum support + ] + top_pos_kw = sorted( + [s for s in signals if s.positive_count > 0], + key=lambda s: (s.lift, s.positive_count), + reverse=True, + )[:8] + top_neg_kw = sorted( + [s for s in signals if s.negative_count > 0], + key=lambda s: (s.negative_count, -s.lift), + reverse=True, + )[:8] + + # section impact: group target_path prefix by outcome + sec_pos: Counter[str] = Counter() + sec_total: Counter[str] = Counter() + for sub, outcome in labeled_known: + for sug in sub.suggestions: + if sug.accepted is not True: + continue + sec = _section_prefix(sug.target_path) + sec_total[sec] += 1 + if outcome == "positive": + sec_pos[sec] += 1 + + section_impact = sorted( + [ + SectionImpact( + section=sec, + positive_rate=round(sec_pos[sec] / total, 3), + count=total, + ) + for sec, total in sec_total.items() + ], + key=lambda s: s.positive_rate, + reverse=True, + ) + + return InsightsResult( + total_submissions=len(submissions), + positive_count=positive_count, + positive_rate=round(positive_count / len(submissions), 3) if submissions else 0.0, + operation_impact=op_impact, + top_positive_keywords=top_pos_kw, + top_negative_keywords=top_neg_kw, + section_impact=section_impact, + has_data=bool(labeled_known), + ) diff --git a/scripts/gen_demo_cv.py b/scripts/gen_demo_cv.py new file mode 100644 index 0000000..fbb055d --- /dev/null +++ b/scripts/gen_demo_cv.py @@ -0,0 +1,87 @@ +"""Generate the static demo CV DOCX used by DEMO mode in the webapp.""" +from __future__ import annotations + +import sys +from pathlib import Path + +from docx import Document +from docx.shared import Pt, RGBColor +from docx.enum.text import WD_ALIGN_PARAGRAPH + + +def add_heading(doc: Document, text: str, level: int = 1) -> None: + p = doc.add_heading(text, level=level) + p.alignment = WD_ALIGN_PARAGRAPH.LEFT + + +def add_bullet(doc: Document, text: str) -> None: + doc.add_paragraph(text, style="List Bullet") + + +def build(path: Path) -> None: + doc = Document() + + # Name / contact + name_para = doc.add_paragraph() + name_para.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = name_para.add_run("Alex Rivera") + run.bold = True + run.font.size = Pt(18) + + contact = doc.add_paragraph() + contact.alignment = WD_ALIGN_PARAGRAPH.CENTER + contact.add_run("alex.rivera@email.com · linkedin.com/in/alexrivera · github.com/alexrivera") + + doc.add_paragraph() # spacer + + # Summary + add_heading(doc, "Summary", level=2) + doc.add_paragraph( + "Software engineer with 5 years of experience building distributed systems and " + "machine learning pipelines at scale. Strong background in Python, Go, and cloud-native " + "architectures. Passionate about developer tooling and open-source contribution." + ) + + # Experience + add_heading(doc, "Experience", level=2) + + add_heading(doc, "Senior Software Engineer — Acme Corp", level=3) + doc.add_paragraph("Jan 2022 – Present · San Francisco, CA") + add_bullet(doc, "Led migration of monolithic data pipeline to distributed microservices, reducing p99 latency by 40%.") + add_bullet(doc, "Designed and shipped an internal feature flag system used by 50+ engineers across 3 teams.") + add_bullet(doc, "Mentored 4 junior engineers and ran weekly technical design review sessions.") + + add_heading(doc, "Software Engineer — DataFlow Inc", level=3) + doc.add_paragraph("Aug 2019 – Dec 2021 · Remote") + add_bullet(doc, "Built real-time streaming ingestion system processing 2M events/day using Kafka and Flink.") + add_bullet(doc, "Developed Python SDK for internal data platform, adopted by 8 product teams.") + add_bullet(doc, "Contributed PyTorch-based anomaly detection model achieving 92% precision on production traffic.") + + # Education + add_heading(doc, "Education", level=2) + add_heading(doc, "B.S. Computer Science — State University", level=3) + doc.add_paragraph("Graduated May 2019 · GPA 3.8 / 4.0") + add_bullet(doc, "Senior thesis: Efficient approximate nearest-neighbour search for high-dimensional embeddings.") + + # Skills + add_heading(doc, "Skills", level=2) + skills_para = doc.add_paragraph() + skills_para.add_run("Languages: ").bold = True + skills_para.add_run("Python, Go, TypeScript, SQL") + + infra_para = doc.add_paragraph() + infra_para.add_run("Infrastructure: ").bold = True + infra_para.add_run("Kubernetes, AWS, GCP, Terraform, Docker") + + ml_para = doc.add_paragraph() + ml_para.add_run("ML / Data: ").bold = True + ml_para.add_run("PyTorch, scikit-learn, Spark, Kafka, dbt") + + doc.save(path) + print(f"Saved demo CV to {path}") + + +if __name__ == "__main__": + out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("apps/webapp/public/demo-cv.docx") + out.parent.mkdir(parents=True, exist_ok=True) + build(out)