Spaces:
Running
Running
| 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] = [] | |
| 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] = [] | |
| 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] = [] | |