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] = []