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:
Claude
2026-04-05 09:34:01 +00:00
parent 0f32d46404
commit 615d1bdb9e
12 changed files with 780 additions and 17 deletions

View File

@@ -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)

View File

@@ -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,
)

View File

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

View File

@@ -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)