bored-cv-api / app /models.py
Aramente's picture
security round 2: payload caps, traceback strip, GDPR delete, path allowlist
3da512e
from pydantic import BaseModel, Field, field_validator
def _coerce_languages(v):
"""Normalize language entries: accept strings or {language, level} dicts."""
if not isinstance(v, list):
return v
out = []
for item in v:
if isinstance(item, str):
out.append(item)
elif isinstance(item, dict):
name = item.get("language") or item.get("name") or ""
level = item.get("level") or item.get("proficiency") or ""
if name and level:
out.append(f"{name} ({level})")
elif name:
out.append(name)
else:
out.append(str(item))
return out
class Experience(BaseModel):
title: str
company: str
dates: str
description: str
bullets: list[str] = []
class Education(BaseModel):
degree: str
school: str
year: str
class Profile(BaseModel):
name: str
title: str
location: str = ""
email: str = ""
phone: str = ""
linkedin: str = ""
summary: str = ""
experiences: list[Experience] = []
education: list[Education] = []
skills: list[str] = []
languages: list[str] = []
@field_validator("languages", mode="before")
@classmethod
def _normalize_languages(cls, v):
return _coerce_languages(v)
class OfferRequirement(BaseModel):
text: str
category: str = ""
class Offer(BaseModel):
title: str
company: str
location: str = ""
description: str
requirements: list[OfferRequirement] = []
nice_to_have: list[OfferRequirement] = []
class GapAnalysis(BaseModel):
matched_skills: list[str] = []
gaps: list[str] = []
questions: list[str] = []
class ChatMessage(BaseModel):
role: str
# Cap chat content. A realistic answer is a paragraph; 8 KB is already
# several pages. The cap is the structural defence against
# "ignore-previous-instructions"-style prompt injections that try to bury
# the override in a wall of text.
content: str = Field(default="", max_length=8000)
class ChatRequest(BaseModel):
profile: Profile
offer: Offer
gap_analysis: GapAnalysis
messages: list[ChatMessage] = []
ui_language: str = "en"
known_facts: list[str] = []
contradictions: list[str] = []
cv_draft: "CVData | None" = None
class CvAction(BaseModel):
action: str # "remove_experience", "add_bullet", "reorder", "edit_field", "merge_experiences"
target: str = "" # company name, field path, etc.
value: str | dict | list = "" # new value for edits/adds; merge_experiences uses dict
index: int = -1 # for positional operations
class ChatResponse(BaseModel):
message: str
is_complete: bool = False
cv_actions: list[CvAction] = []
progress: int = 0 # 0-100, percentage of key points covered
class CompanyContext(BaseModel):
sector: str = ""
stage: str = ""
headcount_start: str = ""
headcount_end: str = ""
team_size: str = ""
class RewrittenExperience(BaseModel):
title: str
company: str
dates: str
bullets: list[str]
context: CompanyContext | None = None
# Dedicated UI slots so the chat/LLM stops cramming this into the title.
# All optional — empty string means "user hasn't filled this yet".
# camelCase field names so JSON keys match the frontend store directly
# (the rest of CVData uses snake_case for backwards-compat with persisted
# client state — only these new fields are camelCase on both sides).
contractType: str = "" # "Permanent", "CDI", "Founder", "Freelance"…
headcountStart: str = "" # company size when joined, e.g. "12"
headcountEnd: str = "" # company size when left / now, e.g. "45"
exitReason: str = "" # short reason for leaving (optional)
class CVData(BaseModel):
name: str
title: str
email: str = ""
phone: str = ""
linkedin: str = ""
location: str = ""
summary: str
experiences: list[RewrittenExperience]
education: list[Education]
skills: list[str]
languages: list[str] = []
language: str = "en"
match_score: int = 0
strengths: list[str] = []
improvements: list[str] = []
@field_validator("languages", mode="before")
@classmethod
def _normalize_languages(cls, v):
return _coerce_languages(v)
class GenerateRequest(BaseModel):
profile: Profile
offer: Offer
gap_analysis: GapAnalysis
messages: list[ChatMessage]
ui_language: str = "en"
tone: str = "startup" # startup, corporate, creative, minimal
target_market: str = "france" # france, europe, us, global
class ToneSamplesRequest(BaseModel):
profile: Profile
offer: Offer
ui_language: str = "en"
class ToneSamples(BaseModel):
source: str = "" # the original bullet we rewrote (context for the UI)
company: str = "" # which experience it came from
startup: str = ""
creative: str = ""
minimal: str = ""
class ImproveBulletRequest(BaseModel):
"""Per-bullet AI rewrite — Notion-style "improve wording" hover button."""
text: str = Field(default="", max_length=2000)
role: str = Field(default="", max_length=200)
company: str = Field(default="", max_length=200)
offer_title: str = Field(default="", max_length=200)
ui_language: str = "en"
tone: str = "startup"
class ImproveBulletResponse(BaseModel):
text: str
class AuditCvRequest(BaseModel):
"""End-of-edit CV audit — grammar pass + offer-fit gaps + last-mile advice."""
cv_data: "CVData"
offer: Offer
ui_language: str = "en"
class AuditFinding(BaseModel):
"""One actionable item the user can act on. `where` is a human pointer
(e.g. 'Summary' or 'Experience 2 / bullet 3') so the UI can render it
without needing structured paths."""
where: str = ""
text: str
class AuditCvResponse(BaseModel):
grammar: list[AuditFinding] = [] # spelling, grammar, awkward phrasing
missing_from_offer: list[AuditFinding] = [] # requirements not addressed in the CV
advice: list[AuditFinding] = [] # more numbers, more bullets, more examples
class ApplyGrammarFixesRequest(BaseModel):
"""Apply only the grammar bucket of an audit to a CV. The LLM is asked to
return a list of (path, old, new) substitutions which the backend then
applies mechanically — that way nothing outside the listed fixes can drift,
and any field the LLM hallucinates a path for just gets skipped."""
cv_data: "CVData"
findings: list[AuditFinding] = []
ui_language: str = "en"
class GrammarSubstitution(BaseModel):
path: str # dot-path like "summary" or "experiences.2.bullets.3"
old: str
new: str
class ApplyGrammarFixesResponse(BaseModel):
cv_data: "CVData"
applied: int = 0 # how many substitutions actually landed
skipped: int = 0 # paths the LLM returned that didn't resolve / didn't match
skipped_indices: list[int] = [] # indices into the original findings list that were not applied
class FieldTranslation(BaseModel):
"""A single field to translate, identified by its dot-path within the CV
(e.g. 'summary', 'experiences.0.bullets.3'). The path is opaque to the
backend — it just round-trips back in the response so the frontend can
re-apply the translation to the right field."""
path: str
text: str
class TranslateFieldsRequest(BaseModel):
"""Per-field translation for the FR/EN sync flow in the editor. Sent when
the user toggles language and some fields on the now-active side are
stale because the user edited the other side."""
fields: list[FieldTranslation]
source_language: str # "en" or "fr"
target_language: str # "en" or "fr"
class TranslateFieldsResponse(BaseModel):
translations: list[FieldTranslation]
class OfferScrapeRequest(BaseModel):
url: str = ""
raw_text: str = ""
class AnalyzeRequest(BaseModel):
profile: Profile
offer: Offer
ui_language: str = "en"
class ProjectSummary(BaseModel):
id: int
name: str
offer_title: str
match_score: int
template: str
tone: str
created_at: str
updated_at: str
class ProjectDetail(BaseModel):
id: int
name: str
offer_title: str
offer_url: str
offer_data: Offer | None = None
profile_data: Profile | None = None
gap_analysis: GapAnalysis | None = None
cv_data: CVData | None = None
messages: list[ChatMessage] = []
match_score: int = 0
template: str = "clean"
tone: str = "startup"
created_at: str = ""
updated_at: str = ""
class CoverLetterData(BaseModel):
greeting: str = ""
opening: str = "" # hook paragraph
body: str = "" # 2-3 paragraphs linking experience to role
closing: str = "" # call to action
signature: str = ""
language: str = "en"
class CoverLetterRequest(BaseModel):
profile: Profile
offer: Offer
cv_data: CVData
messages: list[ChatMessage] = []
ui_language: str = "en"
tone: str = "startup"
target_market: str = "france"
class KnowledgeEntry(BaseModel):
id: int = 0
company: str
company_context: str = ""
title: str
dates: str = ""
description: str = ""
facts: dict = {}
best_bullets: list[str] = []
class FactEntry(BaseModel):
id: int = 0
knowledge_id: int = 0
key: str
value: str
source_project_id: int = 0
class KnowledgeBase(BaseModel):
experiences: list[KnowledgeEntry] = []
facts: list[FactEntry] = []
contradictions: list[str] = []