diff --git a/CHANGELOG.md b/CHANGELOG.md index cee4fbb..c66f292 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ release du même nom. ## [Non publié] — 1.2.0 +- #44 — Import/analyse des CV : au chargement d'un CV, si une IA est configurée, + une case (cochée par défaut, avec un avertissement) propose de l'**analyser**. + L'IA extrait alors les informations principales (profil, expériences, + formations, compétences, langues, infos diverses), enregistrées **par CV** et + consultables depuis la fiche du CV (clic dans la liste). L'analyse est remise à + zéro à chaque (ré)analyse, relançable à la demande depuis la fiche. - #43 — Sites d'emploi : suppression de la gestion des **identifiants et mots de passe**. Seuls le nom, l'URL et le logo d'un site sont désormais conservés ; les champs `username`/`password` (et leur stockage chiffré) sont retirés du diff --git a/CLAUDE.md b/CLAUDE.md index 10b50ca..5750371 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,9 @@ docker compose up -d --build # → http://127.0.0.1:53487/ `JobSite` (nom, URL, `is_builtin`, `logo_url` — plus d'identifiants depuis l'issue #43), `Candidature` (cœur du suivi, étapes de progression + `motif_cloture` = clôture), `StatusHistory`, -`Reminder`, `Interview`, `Contact`, `ApiToken`, `CV`, `AIConfig` (singleton de +`Reminder`, `Interview`, `Contact`, `ApiToken`, `CV` (avec analyse IA des +informations principales — champs `analysis`/`analyzed_at`/… , issue #44), +`AIConfig` (singleton de config du coaching IA, clé Gemini chiffrée — issue #33). Énumérations `TextChoices` : `Source`, `Canal`, `Statut`, `MotifCloture` (certaines avec icône emoji dans le libellé pour les menus). @@ -75,6 +77,23 @@ emoji dans le libellé pour les menus). catégorie IA, issue #34). Endpoints POST AJAX `api/coaching/` (bilan) et `api/candidatures//relance/` (mail de relance) ; UI = modal partagé `#ai-modal` dans `base.html` (spinner + rendu Markdown). +- Analyse de CV (issue #44) : `coaching.analyze_cv(cv)` demande à l'IA un JSON + structuré (profil, expériences, formations, compétences, langues, coordonnées/ + références — adresse, téléphone, email, permis —, loisirs, infos diverses), + normalisé et stocké dans `CV.analysis`. CV joint pour Gemini, texte brut pour + les autres fournisseurs (formats texte seulement). Déclenchée au chargement via + la case « Analyser » (vue `cv_create`) ou à la demande (`cv_analyze`) ; + résultat affiché sur la fiche `cv_detail`. Le profil donne aussi une + `localisation`, et chaque expérience/formation un `lieu` + un `lien` (URL du + site, validée http(s) via `_as_url`). La fiche affiche les expériences et + formations en timeline et **cartographie les lieux** (`_cv_localisations`) avec + **OpenStreetMap/Leaflet** (géocodage **Nominatim**, marqueur emoji par type, + popup société) — aucune clé API requise. +- Exports de CV (issue #44) : `tracking/cv_export.py` convertit `CV.analysis` en + **JSON Resume**, **Europass** (SkillsPassport) et **HR-Open Standards** + (`EXPORTERS`/`EXPORT_LABELS`, stdlib) ; vues `cv_export` (téléchargement JSON) et + `cv_print` (template `cv_print.html` autonome, **PDF via impression navigateur**). + Boutons sur la fiche `cv_detail`. - Quotas IA (issue #36) : `ai.generate` renvoie un `GenerationResult` (texte + tokens) ; `coaching._run` journalise chaque appel dans `AIUsage`. `AIConfig` porte une limite mensuelle de tokens par fournisseur (0 = illimitée) ; la diff --git a/README.md b/README.md index 8ad3eae..53d107b 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ données. | `/` (`/candidatures/`) | Liste des candidatures (créer / modifier / consulter) | | `/sites/` | Sites d'emploi : ajout, modification, suppression, logo (#366) | | `/stats/` | Statistiques (#367 — premiers KPI) | -| `/cv/` | CV : chargement et suppression (#368) | +| `/cv/` | CV : chargement, analyse IA des informations principales et suppression (#368, #44) | | `/aide/` | **Options** : apparence (thème), jetons de l'extension Chrome et configuration du coaching IA (#33) | ## Prochaines itérations @@ -220,3 +220,6 @@ données. - #367 : KPI complets (taux de réponse par source, délais moyens, graphes) - #368 : reformatage du CV (technique/pro) + import LinkedIn - #365 : intégration API France Travail, rappels actifs et notifications + +> L'analyse IA du CV (extraction des informations principales) est disponible +> depuis l'issue #44. diff --git a/templates/tracking/cv_detail.html b/templates/tracking/cv_detail.html new file mode 100644 index 0000000..8dcafe9 --- /dev/null +++ b/templates/tracking/cv_detail.html @@ -0,0 +1,323 @@ +{% extends "base.html" %} +{% block title %}{{ cv.label }} — CandiTrack{% endblock %} +{% block content %} + + +
+

{{ cv.label }}

+
+
+ {% csrf_token %} + +
+ ← Retour +
+
+ +
+ + 📄 Télécharger le fichier + · Ajouté le {{ cv.uploaded_at|date:"d/m/Y H:i" }} + + {% if not ai_config.is_configured %} + Configurez une IA dans Options → IA pour analyser ce CV. + {% endif %} +
+ +{% if cv.analysis_error %} +
⚠️ {{ cv.analysis_error }}
+{% endif %} + +{% if cv.is_analyzed %} +{% with a=cv.analysis %} +
+
👤
+
+
{% if a.titre_profil %}{{ a.titre_profil }}{% else %}Profil analysé{% endif %}
+ {% if a.localisation %}
📍 {{ a.localisation }}
{% endif %} +
+ Analysé le {{ cv.analyzed_at|date:"d/m/Y H:i" }} + {% if cv.analysis_provider_label %}par {{ cv.analysis_provider_label }}{% endif %} + {% if cv.analysis_model %} · {{ cv.analysis_model }}{% endif %} +
+
+
+ +
+ ⬇️ Exporter : + {% for fmt, label in export_formats.items %} + {{ label }} + {% endfor %} + 📄 PDF professionnel +
+ +
+
+ {% if a.experiences %} +
+

💼 Expériences

+
    + {% for exp in a.experiences %} +
  • +
    + {{ exp.poste }}{% if exp.entreprise %} · {{ exp.entreprise }}{% endif %} +
    +
    + {% if exp.periode %}🗓️ {{ exp.periode }}{% endif %} + {% if exp.lieu %}📍 {{ exp.lieu }}{% endif %} + {% if exp.lien %}🔗 Site{% endif %} +
    + {% if exp.description %}
    {{ exp.description }}
    {% endif %} +
  • + {% endfor %} +
+
+ {% endif %} + + {% if a.formations %} +
+

🎓 Formations

+
    + {% for f in a.formations %} +
  • +
    + {{ f.intitule }}{% if f.etablissement %} · {{ f.etablissement }}{% endif %} +
    +
    + {% if f.periode %}🗓️ {{ f.periode }}{% endif %} + {% if f.lieu %}📍 {{ f.lieu }}{% endif %} + {% if f.lien %}🔗 Site{% endif %} +
    +
  • + {% endfor %} +
+
+ {% endif %} + + {% if a.infos %} +
+

ℹ️ Informations diverses

+

{{ a.infos|linebreaksbr }}

+
+ {% endif %} +
+ +
+ {% if a.coordonnees %} + {% with c=a.coordonnees %} +
+

📇 Références

+
+ {% if c.adresse %} +
🏠 + Adresse{{ c.adresse }}
+ {% endif %} + {% if c.telephone %} +
📞 + Téléphone + {{ c.telephone }}
+ {% endif %} + {% if c.email %} +
✉️ + Email + {{ c.email }}
+ {% endif %} + {% if c.permis %} +
🚗 + Permis{{ c.permis }}
+ {% endif %} +
+
+ {% endwith %} + {% endif %} + + {% if a.competences %} +
+

🛠️ Compétences

+
+ {% for c in a.competences %}{{ c }}{% endfor %} +
+
+ {% endif %} + + {% if a.langues %} +
+

🌐 Langues

+
+ {% for l in a.langues %}{{ l }}{% endfor %} +
+
+ {% endif %} + + {% if a.loisirs %} +
+

🎨 Loisirs

+
+ {% for h in a.loisirs %}{{ h }}{% endfor %} +
+
+ {% endif %} + + {% if localisations %} +
+

🗺️ Localisations

+ {{ localisations|json_script:"cv-localisations-data" }} +
+ +
+ {% endif %} +
+
+ +{% if localisations %} + + + +{% endif %} +{% endwith %} +{% elif not cv.analysis_error %} +

Ce CV n'a pas encore été analysé.

+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/tracking/cv_form.html b/templates/tracking/cv_form.html index a9a1281..232063b 100644 --- a/templates/tracking/cv_form.html +++ b/templates/tracking/cv_form.html @@ -7,6 +7,17 @@

{{ title }}

{% csrf_token %} {{ form.as_p }}

Formats acceptés : PDF, DOC, DOCX, ODT, RTF, TXT.

+ {% if ai_config.is_configured %} +
+

⚠️ Ce CV sera analysé par l'IA configurée + ({{ ai_config.get_provider_display }}) pour en extraire + automatiquement les informations principales (expériences, formations…).

+ +
+ {% endif %}
Annuler diff --git a/templates/tracking/cv_list.html b/templates/tracking/cv_list.html index 29f00de..ca06132 100644 --- a/templates/tracking/cv_list.html +++ b/templates/tracking/cv_list.html @@ -5,20 +5,21 @@

Mes CV

+ Charger un CV
-

Le reformatage en formats techniques/professionnels et l'import LinkedIn - sont prévus pour une prochaine itération.

-
{% if cvs %} - + {% for cv in cvs %} - + + - + {% endfor %} diff --git a/templates/tracking/cv_print.html b/templates/tracking/cv_print.html new file mode 100644 index 0000000..505b6f9 --- /dev/null +++ b/templates/tracking/cv_print.html @@ -0,0 +1,136 @@ + +{% with a=cv.analysis %} + + + + + {{ cv.label }} — CV + + + +
+ + ← Retour à la fiche +
+ +
+
+

{{ cv.label }}

+ {% if a.titre_profil %}
{{ a.titre_profil }}
{% endif %} +
+ {% with c=a.coordonnees %} + {% if c.adresse %}🏠 {{ c.adresse }}{% endif %} + {% if a.localisation %}📍 {{ a.localisation }}{% endif %} + {% if c.telephone %}📞 {{ c.telephone }}{% endif %} + {% if c.email %}✉️ {{ c.email }}{% endif %} + {% if c.permis %}🚗 {{ c.permis }}{% endif %} + {% endwith %} +
+
+ + {% if a.experiences %} +
+

Expériences professionnelles

+ {% for exp in a.experiences %} +
+
+ {{ exp.poste }}{% if exp.entreprise %} · {{ exp.entreprise }}{% endif %} + {% if exp.periode %}{{ exp.periode }}{% endif %} +
+ {% if exp.lieu %}
📍 {{ exp.lieu }}{% if exp.lien %} · {{ exp.lien }}{% endif %}
+ {% elif exp.lien %}{% endif %} + {% if exp.description %}
{{ exp.description }}
{% endif %} +
+ {% endfor %} +
+ {% endif %} + + {% if a.formations %} +
+

Formations

+ {% for f in a.formations %} +
+
+ {{ f.intitule }}{% if f.etablissement %} · {{ f.etablissement }}{% endif %} + {% if f.periode %}{{ f.periode }}{% endif %} +
+ {% if f.lieu %}
📍 {{ f.lieu }}
{% endif %} +
+ {% endfor %} +
+ {% endif %} + + {% if a.competences %} +
+

Compétences

+
{% for c in a.competences %}{{ c }}{% endfor %}
+
+ {% endif %} + + {% if a.langues %} +
+

Langues

+
{% for l in a.langues %}{{ l }}{% endfor %}
+
+ {% endif %} + + {% if a.loisirs %} +
+

Loisirs

+
{% for h in a.loisirs %}{{ h }}{% endfor %}
+
+ {% endif %} + + {% if a.infos %} +
+

Informations diverses

+

{{ a.infos|linebreaksbr }}

+
+ {% endif %} +
+ + +{% endwith %} diff --git a/tracking/coaching.py b/tracking/coaching.py index 257cf61..3587de5 100644 --- a/tracking/coaching.py +++ b/tracking/coaching.py @@ -6,9 +6,17 @@ - :func:`coaching_advice` — un bilan global à partir du CV, des postes visés et des retours reçus (volume de candidatures, motifs de refus…). - :func:`relance_email` — un brouillon de mail de relance pour une candidature. +- :func:`analyze_cv` — extraction des informations principales d'un CV (issue #44). """ +import html +import io +import json +import re +import zipfile + from django.db.models import Count +from django.utils import timezone from . import ai from .models import CV, AIConfig, AIUsage, Candidature, MotifCloture, Statut @@ -19,9 +27,8 @@ MAX_CV_BYTES = 5 * 1024 * 1024 -def _latest_cv_attachment(): - """Renvoie ``(mime_type, bytes)`` du dernier CV, ou ``None``.""" - cv = CV.objects.order_by("-uploaded_at").first() +def cv_attachment(cv): + """``(mime_type, bytes)`` d'un CV donné pour Gemini, ou ``None``.""" if not cv or not cv.file: return None mime = ai.guess_mime(cv.file.name) @@ -36,6 +43,49 @@ def _latest_cv_attachment(): return None +def _latest_cv_attachment(): + """Renvoie ``(mime_type, bytes)`` du dernier CV, ou ``None``.""" + return cv_attachment(CV.objects.order_by("-uploaded_at").first()) + + +def _office_xml_text(raw, member, para_close): + """Texte d'un document Office (zip + XML), paragraphes séparés (stdlib).""" + try: + with zipfile.ZipFile(io.BytesIO(raw)) as archive: + xml = archive.read(member).decode("utf-8", errors="replace") + except (zipfile.BadZipFile, KeyError, OSError, ValueError): + return "" + # Saut de ligne en fin de paragraphe, puis suppression de toutes les balises. + xml = xml.replace(para_close, "\n") + text = re.sub(r"<[^>]+>", "", xml) + return html.unescape(text).strip() + + +def _cv_text(cv): + """Texte brut d'un CV pour les fournisseurs qui ne lisent pas les pièces jointes. + + Gère, en bibliothèque standard uniquement, les formats texte (.txt), Word + (.docx) et OpenDocument (.odt). Renvoie une chaîne vide pour les formats non + extractibles (ex. PDF, à confier à Gemini). + """ + if not cv or not cv.file: + return "" + name = (cv.file.name or "").lower() + try: + with cv.file.open("rb") as handle: + raw = handle.read(MAX_CV_BYTES) + except (OSError, ValueError): + return "" + if name.endswith(".docx"): + return _office_xml_text(raw, "word/document.xml", "") + if name.endswith(".odt"): + return _office_xml_text(raw, "content.xml", "") + mime = ai.guess_mime(cv.file.name) or "" + if mime.startswith("text/"): + return raw.decode("utf-8", errors="replace").strip() + return "" + + def _run(config, prompt, attachments=None): """Appelle l'IA, journalise la consommation (issue #36) et renvoie le texte.""" result = ai.generate( @@ -151,3 +201,189 @@ def relance_email(candidature, config=None): ) return _run(config, prompt) + + +# --- Analyse de CV (issue #44) -------------------------------------------- + +# Schéma JSON attendu de l'IA. On l'impose dans le prompt et on normalise la +# réponse pour que le gabarit puisse l'afficher sans surprise. +CV_ANALYSIS_PROMPT = ( + "Tu es un assistant RH qui analyse des CV. À partir du CV fourni, extrais " + "les informations principales et renvoie UNIQUEMENT un objet JSON valide, " + "sans texte autour ni balises de code, avec exactement ces clés :\n" + '- "titre_profil" : chaîne (intitulé/poste principal du profil) ;\n' + '- "localisation" : chaîne (ville/région du candidat) ;\n' + '- "experiences" : liste d\'objets ' + '{"poste", "entreprise", "lieu", "lien", "periode", "description"} ;\n' + '- "formations" : liste d\'objets ' + '{"intitule", "etablissement", "lieu", "lien", "periode"} ;\n' + '- "competences" : liste de chaînes ;\n' + '- "langues" : liste de chaînes ;\n' + '- "coordonnees" : objet {"adresse", "telephone", "email", "permis"} ' + "(coordonnées et références personnelles du candidat) ;\n" + '- "loisirs" : liste de chaînes (centres d\'intérêt, hobbies) ;\n' + '- "infos" : chaîne (autres informations diverses : certifications, ' + "distinctions… qui ne sont ni des coordonnées ni des loisirs).\n" + 'Pour chaque "lieu", indique la localisation (ville, pays) de l\'entreprise ' + "ou du centre de formation si elle figure dans le CV. " + 'Pour chaque "lien", indique l\'URL du site officiel de l\'entreprise ou du ' + "centre de formation : reprends-la si elle figure dans le CV, sinon déduis " + "l'URL officielle la plus plausible quand tu la connais avec certitude " + "(format https://…), et laisse vide en cas de doute.\n" + "Utilise une liste vide ou une chaîne vide quand l'information est absente. " + "N'invente pas d'expériences, de formations ni de coordonnées. " + "Réponds en français." +) + + +def _as_text(value): + """Chaîne nettoyée à partir d'une valeur JSON quelconque.""" + return str(value).strip() if value is not None else "" + + +def _as_str_list(value): + """Liste de chaînes non vides à partir d'une valeur JSON.""" + if not isinstance(value, list): + return [] + return [_as_text(item) for item in value if _as_text(item)] + + +def _as_url(value): + """URL https nettoyée, ou chaîne vide. + + Seuls les schémas http/https sont acceptés ; un lien http est promu en https + (évite tout contenu mixte sur une page servie en https) et les schémas + douteux (``javascript:``…) sont écartés. + """ + url = _as_text(value) + if not url: + return "" + scheme, sep, rest = url.partition("://") + if sep: + return "https://" + rest if scheme.lower() in {"http", "https"} else "" + # Tolère une URL sans schéma (« exemple.com ») en la préfixant en https. + if "." in url and " " not in url and ":" not in url: + return "https://" + url + return "" + + +def _as_dict_list(value, keys): + """Liste de dictionnaires restreints à ``keys`` (entrées vides ignorées).""" + if not isinstance(value, list): + return [] + rows = [] + for item in value: + if not isinstance(item, dict): + continue + row = { + key: (_as_url(item.get(key)) if key == "lien" else _as_text(item.get(key))) + for key in keys + } + if any(row.values()): + rows.append(row) + return rows + + +COORDONNEES_KEYS = ["adresse", "telephone", "email", "permis"] + + +def _as_coordonnees(value): + """Coordonnées/références normalisées, ou ``{}`` si toutes vides (issue #44).""" + if not isinstance(value, dict): + return {} + coords = {key: _as_text(value.get(key)) for key in COORDONNEES_KEYS} + return coords if any(coords.values()) else {} + + +def _normalize_cv_analysis(data): + """Structure stable de l'analyse, quel que soit le détail renvoyé par l'IA.""" + return { + "titre_profil": _as_text(data.get("titre_profil")), + "localisation": _as_text(data.get("localisation")), + "experiences": _as_dict_list( + data.get("experiences"), + ["poste", "entreprise", "lieu", "lien", "periode", "description"], + ), + "formations": _as_dict_list( + data.get("formations"), + ["intitule", "etablissement", "lieu", "lien", "periode"], + ), + "competences": _as_str_list(data.get("competences")), + "langues": _as_str_list(data.get("langues")), + "coordonnees": _as_coordonnees(data.get("coordonnees")), + "loisirs": _as_str_list(data.get("loisirs")), + "infos": _as_text(data.get("infos")), + } + + +def _parse_cv_analysis(text): + """Parse la réponse de l'IA en dict normalisé, ou ``None`` si illisible.""" + cleaned = text.strip() + # Retire d'éventuelles clôtures Markdown (```json … ```), tolérées en sortie. + if cleaned.startswith("```"): + lines = cleaned.splitlines() + if lines and lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip().startswith("```"): + lines = lines[:-1] + cleaned = "\n".join(lines).strip() + try: + data = json.loads(cleaned) + except (json.JSONDecodeError, ValueError): + return None + if not isinstance(data, dict): + return None + return _normalize_cv_analysis(data) + + +def analyze_cv(cv, config=None): + """Analyse un CV via l'IA et enregistre les infos extraites (issue #44). + + L'analyse est remise à zéro avant chaque passe. Le CV est joint en pièce + jointe pour Gemini ; pour les autres fournisseurs, seul un CV au format + texte peut être lu. Lève :class:`ai.AIError` si l'appel échoue ; en cas de + réponse non exploitable ou de format illisible, enregistre un message + d'erreur sur le CV (sans interrompre le chargement). + """ + config = config or AIConfig.load() + cv.reset_analysis() + + attachments = None + extra = "" + if config.provider == AIConfig.Provider.GEMINI: + attachment = cv_attachment(cv) + if attachment: + attachments = [attachment] + extra = "\n\nLe CV est joint à ce message ; analyse-le." + if attachments is None: + text = _cv_text(cv) + if not text: + cv.analysis_error = ( + "Ce format de CV n'a pas pu être lu par le fournisseur actif " + f"({config.get_provider_display()}). Les PDF sont lus directement " + "par Google Gemini ; les formats Word (.docx), OpenDocument (.odt) " + "et texte (.txt) sont lus par tous les fournisseurs." + ) + cv.save(update_fields=cv.ANALYSIS_FIELDS) + return cv + extra = "\n\nVoici le contenu texte du CV :\n" + text + + result = ai.generate( + CV_ANALYSIS_PROMPT + extra, + provider=config.provider, + api_key=config.api_key, + model=config.model, + attachments=attachments, + ) + AIUsage.record(config.provider, config.model, result) + + data = _parse_cv_analysis(result.text) + if data is None: + cv.analysis_error = "L'IA n'a pas renvoyé d'analyse exploitable." + else: + cv.analysis = data + cv.analyzed_at = timezone.now() + cv.analysis_provider = config.provider + cv.analysis_model = config.model + cv.save(update_fields=cv.ANALYSIS_FIELDS) + return cv diff --git a/tracking/cv_export.py b/tracking/cv_export.py new file mode 100644 index 0000000..08f0f35 --- /dev/null +++ b/tracking/cv_export.py @@ -0,0 +1,212 @@ +"""Export de l'analyse d'un CV vers des formats standards (issue #44). + +À partir du dictionnaire normalisé ``CV.analysis`` (voir +:func:`tracking.coaching._normalize_cv_analysis`), on produit des représentations +structurées selon trois standards de CV/recrutement : + +- **JSON Resume** (https://jsonresume.org/schema/) ; +- **Europass** (modèle « SkillsPassport » JSON) ; +- **HR Open Standards** (objet ``Candidate``, conventions camelCase). + +Les périodes d'expérience/formation du CV étant du texte libre, elles sont +exposées telles quelles (champ ``period`` / ``description``) plutôt que découpées +en dates structurées. Stdlib uniquement. +""" + +EXPORT_LABELS = { + "json-resume": "JSON Resume", + "europass": "Europass", + "hr-open": "HR-Open", +} + + +def _nonempty(items): + """Filtre les chaînes vides d'une liste.""" + return [x for x in items if x] + + +def to_json_resume(cv): + """Convertit l'analyse en document JSON Resume (schéma v1.0.0).""" + a = cv.analysis + coord = a.get("coordonnees", {}) + summary = " ".join(_nonempty([a.get("infos", ""), + "Permis : " + coord["permis"] if coord.get("permis") else ""])) + return { + "$schema": "https://raw.githubusercontent.com/jsonresume/resume-schema/v1.0.0/schema.json", + "basics": { + "name": cv.label, + "label": a.get("titre_profil", ""), + "email": coord.get("email", ""), + "phone": coord.get("telephone", ""), + "summary": summary, + "location": { + "address": coord.get("adresse", ""), + "region": a.get("localisation", ""), + }, + }, + "work": [ + { + "name": exp.get("entreprise", ""), + "position": exp.get("poste", ""), + "location": exp.get("lieu", ""), + "url": exp.get("lien", ""), + "period": exp.get("periode", ""), + "summary": exp.get("description", ""), + } + for exp in a.get("experiences", []) + ], + "education": [ + { + "institution": form.get("etablissement", ""), + "area": form.get("intitule", ""), + "url": form.get("lien", ""), + "location": form.get("lieu", ""), + "period": form.get("periode", ""), + } + for form in a.get("formations", []) + ], + "skills": [{"name": c} for c in a.get("competences", [])], + "languages": [{"language": lang} for lang in a.get("langues", [])], + "interests": [{"name": h} for h in a.get("loisirs", [])], + } + + +def to_europass(cv): + """Convertit l'analyse au modèle Europass « SkillsPassport » (JSON).""" + a = cv.analysis + coord = a.get("coordonnees", {}) + identification = { + "PersonName": {"FirstName": "", "Surname": cv.label}, + "ContactInfo": {}, + } + contact = identification["ContactInfo"] + if coord.get("adresse") or a.get("localisation"): + contact["Address"] = {"Contact": { + "AddressLine": coord.get("adresse", ""), + "Municipality": a.get("localisation", ""), + }} + if coord.get("email"): + contact["Email"] = {"Contact": coord["email"]} + if coord.get("telephone"): + contact["Telephone"] = [{"Contact": coord["telephone"]}] + + learner = { + "Identification": identification, + "Headline": { + "Type": {"Code": "position", "Label": "Position visée"}, + "Description": {"Label": a.get("titre_profil", "")}, + }, + "WorkExperience": [ + { + "Period": {"Label": exp.get("periode", "")}, + "Position": {"Label": exp.get("poste", "")}, + "Activities": exp.get("description", ""), + "Employer": { + "Name": exp.get("entreprise", ""), + "ContactInfo": { + "Address": {"Contact": {"Municipality": exp.get("lieu", "")}}, + "Website": {"Contact": exp.get("lien", "")}, + }, + }, + } + for exp in a.get("experiences", []) + ], + "Education": [ + { + "Period": {"Label": form.get("periode", "")}, + "Title": form.get("intitule", ""), + "Organisation": { + "Name": form.get("etablissement", ""), + "ContactInfo": { + "Address": {"Contact": {"Municipality": form.get("lieu", "")}}, + "Website": {"Contact": form.get("lien", "")}, + }, + }, + } + for form in a.get("formations", []) + ], + "Skills": { + "Linguistic": { + "ForeignLanguage": [ + {"Description": {"Label": lang}} for lang in a.get("langues", []) + ], + }, + "Other": [{"Description": {"Label": c}} for c in a.get("competences", [])], + }, + } + if coord.get("permis"): + learner["DrivingLicence"] = [coord["permis"]] + if a.get("loisirs"): + learner["Hobbies"] = {"Description": ", ".join(a["loisirs"])} + if a.get("infos"): + learner["Achievement"] = [{"Description": a["infos"]}] + + return { + "SkillsPassport": { + "DocumentInfo": {"DocumentType": "ECV", "Generator": "CandiTrack"}, + "LearnerInfo": learner, + } + } + + +def to_hr_open(cv): + """Convertit l'analyse en objet ``Candidate`` HR Open Standards (JSON).""" + a = cv.analysis + coord = a.get("coordonnees", {}) + communication = {} + if coord.get("email"): + communication["email"] = [{"address": coord["email"]}] + if coord.get("telephone"): + communication["phone"] = [{"dialNumber": coord["telephone"]}] + if coord.get("adresse") or a.get("localisation"): + communication["address"] = [{ + "line": _nonempty([coord.get("adresse", "")]), + "cityName": a.get("localisation", ""), + }] + + candidate = { + "personName": {"formattedName": cv.label}, + "communication": communication, + "profiles": [{"summary": a.get("titre_profil", "")}], + "employmentHistory": [ + { + "organizationName": exp.get("entreprise", ""), + "positionTitle": exp.get("poste", ""), + "locationSummary": exp.get("lieu", ""), + "organizationUrl": exp.get("lien", ""), + "validPeriod": {"description": exp.get("periode", "")}, + "description": exp.get("description", ""), + } + for exp in a.get("experiences", []) + ], + "educationHistory": [ + { + "institutionName": form.get("etablissement", ""), + "programName": form.get("intitule", ""), + "locationSummary": form.get("lieu", ""), + "institutionUrl": form.get("lien", ""), + "validPeriod": {"description": form.get("periode", "")}, + } + for form in a.get("formations", []) + ], + "personCompetencies": [{"competencyName": c} for c in a.get("competences", [])], + "languageCompetencies": [{"languageName": lang} for lang in a.get("langues", [])], + "interests": list(a.get("loisirs", [])), + } + if coord.get("permis"): + candidate["licenses"] = [{"name": coord["permis"]}] + if a.get("infos"): + candidate["additionalInformation"] = a["infos"] + + return { + "schemaName": "Candidate", + "schemaVersion": "1.0", + "candidate": candidate, + } + + +EXPORTERS = { + "json-resume": to_json_resume, + "europass": to_europass, + "hr-open": to_hr_open, +} diff --git a/tracking/migrations/0019_cv_analysis_cv_analysis_error_cv_analysis_model_and_more.py b/tracking/migrations/0019_cv_analysis_cv_analysis_error_cv_analysis_model_and_more.py new file mode 100644 index 0000000..af7a1d2 --- /dev/null +++ b/tracking/migrations/0019_cv_analysis_cv_analysis_error_cv_analysis_model_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 6.0.1 on 2026-06-14 13:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0018_remove_jobsite_password_remove_jobsite_username'), + ] + + operations = [ + migrations.AddField( + model_name='cv', + name='analysis', + field=models.JSONField(blank=True, default=dict, verbose_name='analyse IA'), + ), + migrations.AddField( + model_name='cv', + name='analysis_error', + field=models.CharField(blank=True, max_length=300, verbose_name="erreur d'analyse"), + ), + migrations.AddField( + model_name='cv', + name='analysis_model', + field=models.CharField(blank=True, max_length=100, verbose_name="modèle de l'analyse"), + ), + migrations.AddField( + model_name='cv', + name='analysis_provider', + field=models.CharField(blank=True, max_length=10, verbose_name="fournisseur de l'analyse"), + ), + migrations.AddField( + model_name='cv', + name='analyzed_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='analysé le'), + ), + ] diff --git a/tracking/models.py b/tracking/models.py index 3219cf5..660fe83 100644 --- a/tracking/models.py +++ b/tracking/models.py @@ -351,12 +351,36 @@ def cv_upload_path(instance, filename): class CV(models.Model): - """An uploaded CV (issue #368). Reformatting/import is a later iteration.""" + """An uploaded CV (issue #368), optionnellement analysé par l'IA (issue #44). + + Lorsqu'une IA est configurée et que l'utilisateur l'accepte au chargement, + le contenu du CV est analysé pour en extraire les informations principales + (expériences, formations, compétences…), stockées dans :attr:`analysis`. + L'analyse est propre à chaque CV et remise à zéro à chaque (ré)analyse. + """ label = models.CharField(LIBELLE_VERBOSE, max_length=200) file = models.FileField("fichier", upload_to=cv_upload_path) uploaded_at = models.DateTimeField("ajouté le", auto_now_add=True) + # Analyse IA du contenu du CV (issue #44). + analysis = models.JSONField("analyse IA", default=dict, blank=True) + analyzed_at = models.DateTimeField("analysé le", null=True, blank=True) + analysis_provider = models.CharField( + "fournisseur de l'analyse", max_length=10, blank=True + ) + analysis_model = models.CharField("modèle de l'analyse", max_length=100, blank=True) + analysis_error = models.CharField("erreur d'analyse", max_length=300, blank=True) + + # Champs réécrits à chaque (ré)analyse (issue #44). + ANALYSIS_FIELDS = [ + "analysis", + "analyzed_at", + "analysis_provider", + "analysis_model", + "analysis_error", + ] + class Meta: verbose_name = "CV" verbose_name_plural = "CV" @@ -365,6 +389,26 @@ class Meta: def __str__(self): return self.label + @property + def is_analyzed(self): + """Vrai si une analyse IA exploitable a été enregistrée (issue #44).""" + return bool(self.analyzed_at and self.analysis) + + @property + def analysis_provider_label(self): + """Libellé lisible du fournisseur ayant produit l'analyse (issue #44).""" + return dict(AIConfig.Provider.choices).get( + self.analysis_provider, self.analysis_provider + ) + + def reset_analysis(self): + """Remet l'analyse à zéro avant une nouvelle passe (issue #44).""" + self.analysis = {} + self.analyzed_at = None + self.analysis_provider = "" + self.analysis_model = "" + self.analysis_error = "" + class AIConfig(models.Model): """Configuration du module de coaching IA (issues #33, #34, #39). diff --git a/tracking/tests.py b/tracking/tests.py index 2e19106..12601e2 100644 --- a/tracking/tests.py +++ b/tracking/tests.py @@ -1,4 +1,5 @@ import json +import tempfile import urllib.error from unittest import mock @@ -7,9 +8,10 @@ from django.test import TestCase, override_settings from django.urls import reverse -from . import ai +from . import ai, coaching, cv_export, views from .forms import CandidatureForm, CVForm, JobSiteForm from .models import ( + CV, AIConfig, AIUsage, ApiToken, @@ -920,6 +922,411 @@ def test_provider_passed_to_client(self, gen): self.assertIsNone(gen.call_args.kwargs.get("attachments")) +CV_ANALYSIS_JSON = json.dumps( + { + "titre_profil": "Développeur Python", + "experiences": [ + { + "poste": "Développeur backend", + "entreprise": "ACME", + "periode": "2020-2023", + "description": "APIs Django", + } + ], + "formations": [ + {"intitule": "Master Informatique", "etablissement": "Univ", "periode": "2018"} + ], + "competences": ["Python", "Django"], + "langues": ["Français", "Anglais"], + "infos": "Permis B", + } +) + + +@override_settings( + CANDITRACK_FERNET_KEY=TEST_FERNET_KEY, MEDIA_ROOT=tempfile.mkdtemp() +) +class CVAnalysisTests(TestCase): + """Issue #44 — analyse IA des CV au chargement et à la demande.""" + + def _configure_ai(self): + config = AIConfig.load() + config.gemini_api_key = "k" + config.save() + return config + + def _upload(self, analyser=True, content=b"Contenu du CV"): + upload = SimpleUploadedFile("cv.txt", content, content_type="text/plain") + data = {"label": "Mon CV", "file": upload} + if analyser: + data["analyser"] = "on" + return self.client.post(reverse("tracking:cv_create"), data) + + @mock.patch( + "tracking.coaching.ai.generate", + return_value=ai.GenerationResult(CV_ANALYSIS_JSON, 10, 20, 30), + ) + def test_upload_avec_analyse_stocke_les_infos(self, gen): + self._configure_ai() + self._upload(analyser=True) + self.assertTrue(gen.called) + cv = CV.objects.get() + self.assertTrue(cv.is_analyzed) + self.assertEqual(cv.analysis["titre_profil"], "Développeur Python") + self.assertEqual(len(cv.analysis["experiences"]), 1) + self.assertIn("Python", cv.analysis["competences"]) + self.assertEqual(cv.analysis_provider, "gemini") + # La consommation de tokens est journalisée (issue #36). + self.assertEqual(AIUsage.objects.count(), 1) + + @mock.patch("tracking.coaching.ai.generate") + def test_upload_sans_case_cochee_pas_d_analyse(self, gen): + self._configure_ai() + self._upload(analyser=False) + self.assertFalse(gen.called) + self.assertFalse(CV.objects.get().is_analyzed) + + @mock.patch("tracking.coaching.ai.generate") + def test_upload_sans_ia_configuree_pas_d_analyse(self, gen): + self._upload(analyser=True) + self.assertFalse(gen.called) + self.assertFalse(CV.objects.get().is_analyzed) + + @mock.patch( + "tracking.coaching.ai.generate", + return_value=ai.GenerationResult("ceci n'est pas du JSON", 1, 1, 2), + ) + def test_reponse_illisible_enregistre_une_erreur(self, _gen): + self._configure_ai() + self._upload(analyser=True) + cv = CV.objects.get() + self.assertFalse(cv.is_analyzed) + self.assertTrue(cv.analysis_error) + + @mock.patch("tracking.coaching.ai.generate") + def test_reanalyse_remet_a_zero_et_met_a_jour(self, gen): + config = self._configure_ai() + # Première analyse. + gen.return_value = ai.GenerationResult(CV_ANALYSIS_JSON, 1, 1, 2) + self._upload(analyser=True) + cv = CV.objects.get() + # Ré-analyse avec un autre contenu. + autre = json.dumps({"titre_profil": "Chef de projet", "competences": ["Agile"]}) + gen.return_value = ai.GenerationResult(autre, 1, 1, 2) + self.client.post(reverse("tracking:cv_analyze", args=[cv.pk])) + cv.refresh_from_db() + self.assertEqual(cv.analysis["titre_profil"], "Chef de projet") + self.assertEqual(cv.analysis["experiences"], []) + + @mock.patch( + "tracking.coaching.ai.generate", + return_value=ai.GenerationResult(CV_ANALYSIS_JSON, 1, 1, 2), + ) + def test_detail_affiche_les_sections(self, _gen): + self._configure_ai() + self._upload(analyser=True) + cv = CV.objects.get() + resp = self.client.get(reverse("tracking:cv_detail", args=[cv.pk])) + self.assertContains(resp, "Expériences") + self.assertContains(resp, "Développeur Python") + self.assertContains(resp, "Python") + + @mock.patch("tracking.coaching.ai.generate") + def test_carte_localisations(self, gen): + """Issue #44 — points de la carte (société + type) ne gardent que les lieux.""" + analyse = json.dumps( + { + "experiences": [ + {"poste": "Dev", "entreprise": "ACME", "lieu": "Paris"}, + {"poste": "Lead", "entreprise": "Sans lieu"}, + ], + "formations": [ + {"intitule": "Master", "etablissement": "Univ", "lieu": "Lyon"} + ], + } + ) + gen.return_value = ai.GenerationResult(analyse, 1, 1, 2) + self._configure_ai() + self._upload(analyser=True) + cv = CV.objects.get() + points = views._cv_localisations(cv) + # Seuls les éléments avec un lieu sont retenus. + self.assertEqual([p["lieu"] for p in points], ["Paris", "Lyon"]) + self.assertEqual(points[0]["type"], "exp") + self.assertEqual(points[0]["societe"], "ACME") + self.assertEqual(points[1]["type"], "form") + + @mock.patch( + "tracking.coaching.ai.generate", + return_value=ai.GenerationResult( + json.dumps( + {"experiences": [{"poste": "Dev", "entreprise": "ACME", "lieu": "Paris"}]} + ), + 1, 1, 2, + ), + ) + def test_carte_openstreetmap(self, _gen): + """Issue #44 — carte OpenStreetMap/Leaflet sans clé API.""" + self._configure_ai() + self._upload(analyser=True) + cv = CV.objects.get() + resp = self.client.get(reverse("tracking:cv_detail", args=[cv.pk])) + self.assertContains(resp, 'id="cv-map"') + self.assertContains(resp, 'id="cv-localisations-data"') + # Leaflet + tuiles OSM + données de localisation embarquées. + self.assertContains(resp, "unpkg.com/leaflet") + self.assertContains(resp, "tile.openstreetmap.org") + self.assertContains(resp, "nominatim.openstreetmap.org") + self.assertContains(resp, "ACME") + # Plus aucune dépendance à Google Maps. + self.assertNotContains(resp, "maps.googleapis.com") + + def test_pas_de_carte_sans_localisation(self): + """Sans lieu géolocalisable, aucune carte n'est rendue (issue #44).""" + analyse = json.dumps({"titre_profil": "Dev", "competences": ["Python"]}) + with mock.patch( + "tracking.coaching.ai.generate", + return_value=ai.GenerationResult(analyse, 1, 1, 2), + ): + self._configure_ai() + self._upload(analyser=True) + cv = CV.objects.get() + resp = self.client.get(reverse("tracking:cv_detail", args=[cv.pk])) + self.assertNotContains(resp, 'id="cv-map"') + + # --- Exports (issue #44) --------------------------------------------- + + EXPORT_ANALYSIS = json.dumps( + { + "titre_profil": "Développeur Python", + "localisation": "Lyon", + "experiences": [ + {"poste": "Dev", "entreprise": "ACME", "lieu": "Paris", + "lien": "https://acme.example", "periode": "2020-2023", + "description": "APIs Django"} + ], + "formations": [ + {"intitule": "Master", "etablissement": "Univ", "lieu": "Lyon", + "periode": "2018"} + ], + "competences": ["Python", "Django"], + "langues": ["Français", "Anglais"], + "coordonnees": {"adresse": "1 rue X", "telephone": "0600", + "email": "a@b.fr", "permis": "Permis B"}, + "loisirs": ["Course"], + "infos": "Certifié AWS", + } + ) + + def _upload_analysed(self): + with mock.patch( + "tracking.coaching.ai.generate", + return_value=ai.GenerationResult(self.EXPORT_ANALYSIS, 1, 1, 2), + ): + self._configure_ai() + self._upload(analyser=True) + return CV.objects.get() + + def test_export_json_resume(self): + cv = self._upload_analysed() + resp = self.client.get( + reverse("tracking:cv_export", args=[cv.pk, "json-resume"]) + ) + self.assertEqual(resp.status_code, 200) + self.assertIn("application/json", resp["Content-Type"]) + self.assertIn("attachment", resp["Content-Disposition"]) + data = json.loads(resp.content) + self.assertEqual(data["basics"]["label"], "Développeur Python") + self.assertEqual(data["basics"]["email"], "a@b.fr") + self.assertEqual(data["work"][0]["position"], "Dev") + self.assertEqual(data["work"][0]["name"], "ACME") + self.assertEqual([s["name"] for s in data["skills"]], ["Python", "Django"]) + self.assertEqual(data["interests"][0]["name"], "Course") + + def test_export_europass(self): + cv = self._upload_analysed() + resp = self.client.get(reverse("tracking:cv_export", args=[cv.pk, "europass"])) + data = json.loads(resp.content) + learner = data["SkillsPassport"]["LearnerInfo"] + self.assertEqual(learner["Headline"]["Description"]["Label"], "Développeur Python") + self.assertEqual(learner["WorkExperience"][0]["Position"]["Label"], "Dev") + self.assertEqual(learner["DrivingLicence"], ["Permis B"]) + + def test_export_hr_open(self): + cv = self._upload_analysed() + resp = self.client.get(reverse("tracking:cv_export", args=[cv.pk, "hr-open"])) + data = json.loads(resp.content) + cand = data["candidate"] + self.assertEqual(cand["employmentHistory"][0]["positionTitle"], "Dev") + self.assertEqual(cand["languageCompetencies"][0]["languageName"], "Français") + self.assertEqual(cand["licenses"][0]["name"], "Permis B") + + def test_export_format_inconnu_404(self): + cv = self._upload_analysed() + resp = self.client.get(reverse("tracking:cv_export", args=[cv.pk, "xml"])) + self.assertEqual(resp.status_code, 404) + + def test_export_cv_non_analyse_404(self): + self._upload(analyser=False) + cv = CV.objects.get() + resp = self.client.get( + reverse("tracking:cv_export", args=[cv.pk, "json-resume"]) + ) + self.assertEqual(resp.status_code, 404) + + def test_vue_impression_pdf(self): + cv = self._upload_analysed() + resp = self.client.get(reverse("tracking:cv_print", args=[cv.pk])) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Développeur Python") + self.assertContains(resp, "window.print()") + + def test_boutons_export_sur_la_fiche(self): + cv = self._upload_analysed() + resp = self.client.get(reverse("tracking:cv_detail", args=[cv.pk])) + self.assertContains(resp, reverse("tracking:cv_export", args=[cv.pk, "europass"])) + self.assertContains(resp, reverse("tracking:cv_print", args=[cv.pk])) + self.assertContains(resp, "JSON Resume") + + def test_formulaire_propose_la_case_si_ia_configuree(self): + self._configure_ai() + resp = self.client.get(reverse("tracking:cv_create")) + self.assertContains(resp, 'name="analyser"') + + def test_formulaire_sans_case_si_pas_d_ia(self): + resp = self.client.get(reverse("tracking:cv_create")) + self.assertNotContains(resp, 'name="analyser"') + + def test_liste_ne_montre_plus_la_note_future(self): + resp = self.client.get(reverse("tracking:cv_list")) + self.assertNotContains(resp, "prochaine itération") + + def test_parse_tolere_les_balises_de_code(self): + text = "```json\n" + CV_ANALYSIS_JSON + "\n```" + data = coaching._parse_cv_analysis(text) + self.assertIsNotNone(data) + self.assertEqual(data["titre_profil"], "Développeur Python") + + def test_parse_json_invalide_renvoie_none(self): + self.assertIsNone(coaching._parse_cv_analysis("pas du json")) + + def test_parse_extrait_lieux_et_liens(self): + """Issue #44 — localisation, lieux et liens des expériences/formations.""" + text = json.dumps( + { + "localisation": "Lyon, France", + "experiences": [ + { + "poste": "Dev", + "entreprise": "ACME", + "lieu": "Paris", + "lien": "acme.example", + "periode": "2020", + } + ], + "formations": [ + { + "intitule": "Master", + "etablissement": "Univ", + "lieu": "Lyon", + "lien": "https://univ.example", + } + ], + } + ) + data = coaching._parse_cv_analysis(text) + self.assertEqual(data["localisation"], "Lyon, France") + self.assertEqual(data["experiences"][0]["lieu"], "Paris") + # URL sans schéma → préfixée en https. + self.assertEqual(data["experiences"][0]["lien"], "https://acme.example") + self.assertEqual(data["formations"][0]["lien"], "https://univ.example") + + def test_parse_separe_coordonnees_et_loisirs(self): + """Issue #44 — références (coordonnées) et loisirs en sections distinctes.""" + text = json.dumps( + { + "coordonnees": { + "adresse": "1 rue X, Paris", + "telephone": "0600000000", + "email": "a@b.fr", + "permis": "Permis B", + }, + "loisirs": ["Course à pied", "Photographie"], + "infos": "Certifié AWS", + } + ) + data = coaching._parse_cv_analysis(text) + self.assertEqual(data["coordonnees"]["telephone"], "0600000000") + self.assertEqual(data["coordonnees"]["permis"], "Permis B") + self.assertEqual(data["loisirs"], ["Course à pied", "Photographie"]) + self.assertEqual(data["infos"], "Certifié AWS") + + def test_parse_coordonnees_vides_donnent_dict_vide(self): + """Des coordonnées absentes restent un dict vide (issue #44).""" + data = coaching._parse_cv_analysis(json.dumps({"titre_profil": "Dev"})) + self.assertEqual(data["coordonnees"], {}) + self.assertEqual(data["loisirs"], []) + + def test_parse_ecarte_les_liens_dangereux(self): + """Un schéma non http(s) est écarté (issue #44).""" + text = json.dumps( + { + "experiences": [ + {"poste": "A", "lien": "javascript:alert(1)"}, + {"poste": "B", "lien": "ftp://host.example/x"}, + ] + } + ) + data = coaching._parse_cv_analysis(text) + self.assertEqual(data["experiences"][0]["lien"], "") + self.assertEqual(data["experiences"][1]["lien"], "") + + def test_parse_promeut_les_liens_http_en_https(self): + """Un lien http est promu en https pour éviter le contenu mixte (issue #44).""" + text = json.dumps( + {"experiences": [{"poste": "Dev", "lien": "http://acme.example/x"}]} + ) + data = coaching._parse_cv_analysis(text) + self.assertEqual(data["experiences"][0]["lien"], "https://acme.example/x") + + def _docx_bytes(self, text): + import io + import zipfile + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as archive: + archive.writestr( + "word/document.xml", + '' + f"{text}" + "", + ) + return buf.getvalue() + + def test_extrait_le_texte_d_un_docx(self): + cv = CV.objects.create( + label="x", + file=SimpleUploadedFile("cv.docx", self._docx_bytes("Ingénieur logiciel")), + ) + self.assertIn("Ingénieur logiciel", coaching._cv_text(cv)) + + @mock.patch( + "tracking.coaching.ai.generate", + return_value=ai.GenerationResult(CV_ANALYSIS_JSON, 1, 1, 2), + ) + def test_analyse_docx_passe_le_texte_dans_le_prompt(self, gen): + self._configure_ai() # Gemini par défaut + cv = CV.objects.create( + label="x", + file=SimpleUploadedFile("cv.docx", self._docx_bytes("Ingénieur logiciel")), + ) + coaching.analyze_cv(cv) + self.assertTrue(cv.is_analyzed) + # Word n'est pas joignable : le texte extrait part dans le prompt, sans pièce jointe. + self.assertIsNone(gen.call_args.kwargs.get("attachments")) + self.assertIn("Ingénieur logiciel", gen.call_args.args[0]) + + def io_bytes(data): """Petit helper : un flux binaire lisible pour simuler HTTPError.read().""" import io diff --git a/tracking/urls.py b/tracking/urls.py index 59c8b9e..b7af25e 100644 --- a/tracking/urls.py +++ b/tracking/urls.py @@ -26,6 +26,10 @@ path("stats/", views.stats, name="stats"), path("cv/", views.cv_list, name="cv_list"), path("cv/charger/", views.cv_create, name="cv_create"), + path("cv//", views.cv_detail, name="cv_detail"), + path("cv//analyser/", views.cv_analyze, name="cv_analyze"), + path("cv//export//", views.cv_export, name="cv_export"), + path("cv//imprimer/", views.cv_print, name="cv_print"), path("cv//supprimer/", views.cv_delete, name="cv_delete"), # Aide & configuration de l'extension (issue #6) path("aide/", views.help_page, name="help"), diff --git a/tracking/views.py b/tracking/views.py index 510d3c1..2afa5c8 100644 --- a/tracking/views.py +++ b/tracking/views.py @@ -6,12 +6,14 @@ from django.conf import settings from django.contrib import messages from django.db.models import Case, IntegerField, Q, When -from django.http import HttpResponse, JsonResponse +from django.http import Http404, HttpResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect, render +from django.utils.text import slugify from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_GET, require_POST from . import coaching +from . import cv_export as cv_exporters from .ai import AIError from .forms import CandidatureForm, CVForm, JobSiteForm from .models import ( @@ -289,27 +291,123 @@ def stats(request): @require_GET def cv_list(request): - """Issue #368 — list uploaded CVs (reformat/LinkedIn import come later).""" + """Issue #368 — liste des CV chargés (analyse IA optionnelle, issue #44).""" cvs = CV.objects.all() return render(request, "tracking/cv_list.html", {"cvs": cvs}) +def _cv_localisations(cv): + """Points du parcours (lieu + société + type) pour la carte des lieux (issue #44).""" + if not cv.is_analyzed: + return [] + analysis = cv.analysis + points = [] + for exp in analysis.get("experiences", []): + if exp.get("lieu"): + points.append( + {"type": "exp", "lieu": exp["lieu"], "societe": exp.get("entreprise", "")} + ) + for form in analysis.get("formations", []): + if form.get("lieu"): + points.append( + { + "type": "form", + "lieu": form["lieu"], + "societe": form.get("etablissement", ""), + } + ) + return points + + +@require_GET +def cv_detail(request, pk): + """Détail d'un CV et de son analyse IA (issue #44).""" + cv = get_object_or_404(CV, pk=pk) + return render( + request, + "tracking/cv_detail.html", + { + "cv": cv, + "ai_config": AIConfig.load(), + "localisations": _cv_localisations(cv), + "export_formats": cv_exporters.EXPORT_LABELS, + }, + ) + + +@require_GET +def cv_export(request, pk, fmt): + """Exporte l'analyse d'un CV vers un format standard (issue #44).""" + cv = get_object_or_404(CV, pk=pk) + exporter = cv_exporters.EXPORTERS.get(fmt) + if not cv.is_analyzed or exporter is None: + raise Http404("Export indisponible pour ce CV.") + payload = json.dumps(exporter(cv), ensure_ascii=False, indent=2) + filename = f"{slugify(cv.label) or 'cv'}-{fmt}.json" + response = HttpResponse(payload, content_type="application/json; charset=utf-8") + response["Content-Disposition"] = f'attachment; filename="{filename}"' + return response + + +@require_GET +def cv_print(request, pk): + """Vue d'impression d'un CV (PDF via l'impression navigateur, issue #44).""" + cv = get_object_or_404(CV, pk=pk) + if not cv.is_analyzed: + raise Http404("Ce CV n'a pas encore été analysé.") + return render(request, "tracking/cv_print.html", {"cv": cv}) + + +def _analyze_cv_safely(request, cv): + """Analyse un CV en convertissant les erreurs en messages (issue #44).""" + try: + coaching.analyze_cv(cv) + except AIError as exc: + messages.warning(request, f"CV chargé, mais l'analyse IA a échoué : {exc}") + return + if cv.analysis_error: + messages.warning( + request, f"CV chargé, mais l'analyse n'a pu aboutir : {cv.analysis_error}" + ) + else: + messages.success(request, "CV chargé et analysé par l'IA.") + + def cv_create(request): + config = AIConfig.load() if request.method == "POST": form = CVForm(request.POST, request.FILES) if form.is_valid(): - form.save() - messages.success(request, "CV chargé.") + cv = form.save() + # Analyse IA optionnelle, si une IA est configurée et acceptée (issue #44). + if config.is_configured and request.POST.get("analyser"): + _analyze_cv_safely(request, cv) + else: + messages.success(request, "CV chargé.") return redirect("tracking:cv_list") else: form = CVForm() return render( request, "tracking/cv_form.html", - {"form": form, "title": "Charger un CV"}, + {"form": form, "title": "Charger un CV", "ai_config": config}, ) +@require_POST +def cv_analyze(request, pk): + """(Ré)analyse un CV à la demande (issue #44).""" + cv = get_object_or_404(CV, pk=pk) + config = AIConfig.load() + if not config.is_configured: + messages.error( + request, "Aucune clé IA configurée. Renseignez-la dans Options → IA." + ) + else: + _analyze_cv_safely(request, cv) + return redirect("tracking:cv_detail", pk=pk) + + def cv_delete(request, pk): cv = get_object_or_404(CV, pk=pk) if request.method == "POST":
LibelléFichierAjouté leActions
LibelléFichierAnalyse IAAjouté leActions
{{ cv.label }}{{ cv.label }} Télécharger{% if cv.is_analyzed %}✓ analysé{% elif cv.analysis_error %}⚠️ échec{% else %}{% endif %} {{ cv.uploaded_at|date:"d/m/Y H:i" }}Supprimer + Détails + Supprimer +