From b12c76d315be515bf7f595018e216be857eb6965 Mon Sep 17 00:00:00 2001 From: karuna Date: Wed, 29 Jul 2026 15:39:01 +0530 Subject: [PATCH] enhancements of resume parsing --- .../models/document_parser_service.py | 8 +- .../security/ir.model.access.csv | 6 + .../wizard/hr_recruitment_auto_doc_wizard.py | 412 ++++++++++++++++++ .../hr_recruitment_auto_doc_wizard_views.xml | 52 +++ 4 files changed, 477 insertions(+), 1 deletion(-) diff --git a/addons_extensions/document_parser/models/document_parser_service.py b/addons_extensions/document_parser/models/document_parser_service.py index ff5d8da16..f59727146 100644 --- a/addons_extensions/document_parser/models/document_parser_service.py +++ b/addons_extensions/document_parser/models/document_parser_service.py @@ -269,6 +269,9 @@ RULES: - Scan the entire document carefully before answering. - Extract ONLY what exists in text. - FOR ANY DATES CHANGE FORMAT TO %Y-%m-%d +- For list fields, always return an array. Return [] when no explicit entries exist. +- For list fields that describe objects, return one object per real document entry, not a sentence blob. +- Do not skip education, employer, project, certification, or family sections if they appear later in the document. FIELD RULES: - If "skills" exists, extract only explicit technical skills written in the document. @@ -283,6 +286,9 @@ FIELD RULES: - If "name" exists, prefer the full name at the top and exclude titles, companies, and addresses. - If "phone" exists, return the most complete phone number found. - If "experience" exists, return only clearly supported numeric values. +- If "education_history" exists, extract every education row/section with education_type, specialization, university, start_year, end_year, and marks_or_grade. +- If "employer_history" exists, extract every employer/project/work-experience entry with company_name, designation, date_of_joining, last_working_day, ctc, and work_description. +- If "family_details" exists, extract only explicitly written family members with relation_type, name, contact_no, dob, and location. Schema: {schema_text} @@ -301,7 +307,7 @@ Document: "model": model, "messages": [{"role": "user", "content": prompt}], "temperature": 0, - "max_tokens": 1500, + "max_tokens": 4000, } try: response = requests.post(endpoint, headers=headers, json=payload, timeout=90) diff --git a/addons_extensions/hr_recruitment_auto_doc/security/ir.model.access.csv b/addons_extensions/hr_recruitment_auto_doc/security/ir.model.access.csv index 50393c7aa..4f95c0ff8 100644 --- a/addons_extensions/hr_recruitment_auto_doc/security/ir.model.access.csv +++ b/addons_extensions/hr_recruitment_auto_doc/security/ir.model.access.csv @@ -1,3 +1,9 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_hr_recruitment_auto_doc_wizard,hr.recruitment.auto.doc.wizard,model_hr_recruitment_auto_doc_wizard,base.group_user,1,1,1,1 access_hr_recruitment_auto_doc_wizard_line,hr.recruitment.auto.doc.wizard.line,model_hr_recruitment_auto_doc_wizard_line,base.group_user,1,1,1,1 +access_hr_recruitment_auto_doc_education_line,hr.recruitment.auto.doc.education.line,model_hr_recruitment_auto_doc_education_line,base.group_user,1,1,1,1 +access_hr_recruitment_auto_doc_employer_line,hr.recruitment.auto.doc.employer.line,model_hr_recruitment_auto_doc_employer_line,base.group_user,1,1,1,1 +access_hr_recruitment_auto_doc_family_line,hr.recruitment.auto.doc.family.line,model_hr_recruitment_auto_doc_family_line,base.group_user,1,1,1,1 +access_hr_recruitment_auto_doc_certification_line,hr.recruitment.auto.doc.certification.line,model_hr_recruitment_auto_doc_certification_line,base.group_user,1,1,1,1 +access_hr_recruitment_auto_doc_project_line,hr.recruitment.auto.doc.project.line,model_hr_recruitment_auto_doc_project_line,base.group_user,1,1,1,1 +access_hr_recruitment_auto_doc_other_line,hr.recruitment.auto.doc.other.line,model_hr_recruitment_auto_doc_other_line,base.group_user,1,1,1,1 diff --git a/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard.py b/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard.py index b780b8ad9..edde98818 100644 --- a/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard.py +++ b/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard.py @@ -484,6 +484,17 @@ class HrRecruitmentAutoDocWizard(models.TransientModel): "relation_type (father/mother/spouse/kid1/kid2), name, contact_no, dob, and location." ), }, + "certifications": { + "type": "list", + "description": "List of explicit certifications, courses, training, or licenses if present.", + }, + "projects": { + "type": "list", + "description": ( + "List of explicit resume projects if present. Each item should include project_name, role, " + "technologies, duration, and description when available." + ), + }, } def _get_jd_required_fields(self): @@ -514,6 +525,10 @@ class HrRecruitmentAutoDocWizard(models.TransientModel): "Normalize skills into clean individual names. " "For experience values, return numeric years when clearly inferable. " "If the resume contains education details, previous employer details, or family details, return them as structured arrays of objects using the requested field names. " + "Read the complete resume including tables, side columns, and later pages before returning JSON. " + "education_history must include every school, intermediate, diploma, graduation, post graduation, or additional qualification row that is explicitly present. " + "employer_history must include internships, current employer, previous employers, project/company experience, and work periods when explicitly present. " + "family_details must include every explicitly mentioned family member and must be [] if the resume has no family section. " "For each employer entry, extract the role-specific work description into work_description. " "Only include entries that are explicitly present in the document. " "Do not consider certifications, responsibilities, Non Technical Stuff as skills" @@ -2145,6 +2160,40 @@ class HrRecruitmentAutoDocWizardLine(models.TransientModel): degree = fields.Char() skills_text = fields.Text(string="Skills") summary = fields.Text() + education_history_json = fields.Text(string="Education History") + employer_history_json = fields.Text(string="Employer History") + family_details_json = fields.Text(string="Family Details") + other_payload_json = fields.Text(string="Other Parsed Data") + education_line_ids = fields.One2many( + "hr.recruitment.auto.doc.education.line", + "wizard_line_id", + string="Education History", + ) + employer_line_ids = fields.One2many( + "hr.recruitment.auto.doc.employer.line", + "wizard_line_id", + string="Employer History", + ) + family_line_ids = fields.One2many( + "hr.recruitment.auto.doc.family.line", + "wizard_line_id", + string="Family Details", + ) + certification_line_ids = fields.One2many( + "hr.recruitment.auto.doc.certification.line", + "wizard_line_id", + string="Certifications", + ) + project_line_ids = fields.One2many( + "hr.recruitment.auto.doc.project.line", + "wizard_line_id", + string="Projects", + ) + other_line_ids = fields.One2many( + "hr.recruitment.auto.doc.other.line", + "wizard_line_id", + string="Other Parsed Data", + ) request_id = fields.Char(string="Request ID") start_date = fields.Char() @@ -2197,6 +2246,16 @@ class HrRecruitmentAutoDocWizardLine(models.TransientModel): "degree": parsed_data.get("degree"), "skills_text": self._list_to_text(parsed_data.get("skills")), "summary": parsed_data.get("summary"), + "education_history_json": self._json_to_text(parsed_data.get("education_history")), + "employer_history_json": self._json_to_text(parsed_data.get("employer_history")), + "family_details_json": self._json_to_text(parsed_data.get("family_details")), + "other_payload_json": self._json_to_text(self._get_other_parsed_data(parsed_data)), + "education_line_ids": self._prepare_education_line_commands(parsed_data.get("education_history")), + "employer_line_ids": self._prepare_employer_line_commands(parsed_data.get("employer_history"), parsed_data.get("projects")), + "family_line_ids": self._prepare_family_line_commands(parsed_data.get("family_details")), + "certification_line_ids": self._prepare_certification_line_commands(parsed_data.get("certifications")), + "project_line_ids": self._prepare_project_line_commands(parsed_data.get("projects")), + "other_line_ids": self._prepare_other_line_commands(self._get_other_parsed_data(parsed_data)), "request_id": parsed_data.get("request_id"), "start_date": parsed_data.get("start_date"), "end_date": parsed_data.get("end_date"), @@ -2245,9 +2304,158 @@ class HrRecruitmentAutoDocWizardLine(models.TransientModel): "degree": self.degree, "skills": self._text_to_list(self.skills_text), "summary": self.summary, + "education_history": self._get_education_history_from_lines(), + "employer_history": self._get_employer_history_from_lines(), + "family_details": self._get_family_details_from_lines(), + "certifications": self._get_certifications_from_lines(), + "projects": self._get_projects_from_lines(), }) + data.update(self._get_other_payload_from_lines()) return data + def _prepare_education_line_commands(self, education_history): + commands = [(5, 0, 0)] + for education in self._normalize_dict_list(education_history): + commands.append((0, 0, { + "education_type": education.get("education_type"), + "specialization": education.get("specialization") or education.get("name") or education.get("degree"), + "university": education.get("university") or education.get("institution") or education.get("college"), + "start_year": education.get("start_year"), + "end_year": education.get("end_year"), + "marks_or_grade": education.get("marks_or_grade") or education.get("marks") or education.get("grade") or education.get("cgpa") or education.get("percentage"), + })) + return commands + + def _prepare_employer_line_commands(self, employer_history, projects=None): + commands = [(5, 0, 0)] + employers = self._normalize_shared_employer_descriptions( + self._normalize_dict_list(employer_history), + projects, + ) + for employer in employers: + commands.append((0, 0, { + "company_name": employer.get("company_name") or employer.get("employer"), + "designation": employer.get("designation") or employer.get("role") or employer.get("job_title"), + "date_of_joining": employer.get("date_of_joining") or employer.get("start_date"), + "last_working_day": employer.get("last_working_day") or employer.get("end_date"), + "ctc": employer.get("ctc") or employer.get("salary"), + "work_description": employer.get("work_description") or employer.get("summary") or employer.get("description") or employer.get("responsibilities"), + })) + return commands + + def _prepare_family_line_commands(self, family_details): + commands = [(5, 0, 0)] + for member in self._normalize_dict_list(family_details): + commands.append((0, 0, { + "relation_type": member.get("relation_type") or member.get("relation"), + "name": member.get("name"), + "contact_no": member.get("contact_no") or member.get("contact") or member.get("phone"), + "dob": member.get("dob"), + "location": member.get("location") or member.get("address"), + })) + return commands + + def _prepare_certification_line_commands(self, certifications): + commands = [(5, 0, 0)] + for certification in self._normalize_value_list(certifications): + if isinstance(certification, dict): + commands.append((0, 0, { + "certification_name": certification.get("certification_name") or certification.get("name") or certification.get("title"), + "provider": certification.get("provider") or certification.get("issuer") or certification.get("organization"), + "completion_date": certification.get("completion_date") or certification.get("date") or certification.get("year"), + })) + else: + commands.append((0, 0, {"certification_name": str(certification)})) + return commands + + def _prepare_project_line_commands(self, projects): + commands = [(5, 0, 0)] + for project in self._normalize_dict_list(projects): + technologies = project.get("technologies") + commands.append((0, 0, { + "project_name": project.get("project_name") or project.get("name") or project.get("title"), + "role": project.get("role"), + "technologies": ", ".join(technologies) if isinstance(technologies, list) else technologies, + "duration": project.get("duration"), + "description": project.get("description") or project.get("summary") or project.get("work_description"), + })) + return commands + + def _prepare_other_line_commands(self, other_payload): + commands = [(5, 0, 0)] + for key, value in (other_payload or {}).items(): + commands.append((0, 0, { + "key": key, + "value": self._json_to_text(value) or str(value), + })) + return commands + + def _get_education_history_from_lines(self): + return [line._to_payload() for line in self.education_line_ids if line._to_payload()] + + def _get_employer_history_from_lines(self): + return [line._to_payload() for line in self.employer_line_ids if line._to_payload()] + + def _get_family_details_from_lines(self): + return [line._to_payload() for line in self.family_line_ids if line._to_payload()] + + def _get_certifications_from_lines(self): + return [line._to_payload() for line in self.certification_line_ids if line._to_payload()] + + def _get_projects_from_lines(self): + return [line._to_payload() for line in self.project_line_ids if line._to_payload()] + + def _get_other_payload_from_lines(self): + return { + line.key: line._json_value() + for line in self.other_line_ids + if line.key and line.value not in (False, None, "") + } + + def _normalize_dict_list(self, value): + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + if isinstance(value, dict): + return [value] + return [] + + def _normalize_value_list(self, value): + if isinstance(value, list): + return [item for item in value if item not in (False, None, "", {})] + if value in (False, None, "", {}): + return [] + return [value] + + def _normalize_shared_employer_descriptions(self, employers, projects=None): + if len(employers) < 2: + return employers + + descriptions = [ + employer.get("work_description") or employer.get("summary") or employer.get("description") or employer.get("responsibilities") + for employer in employers + ] + descriptions = [description for description in descriptions if description] + if descriptions: + shared_description = max(descriptions, key=lambda item: len(str(item))) + else: + project_descriptions = [] + for project in self._normalize_dict_list(projects): + description = project.get("description") or project.get("summary") or project.get("work_description") + if description: + project_descriptions.append("%s: %s" % ( + project.get("project_name") or project.get("name") or project.get("title") or "Project", + description, + )) + shared_description = "\n".join(project_descriptions) if project_descriptions else False + + if not shared_description: + return employers + + for employer in employers: + if not (employer.get("work_description") or employer.get("summary") or employer.get("description") or employer.get("responsibilities")): + employer["work_description"] = shared_description + return employers + def _list_to_text(self, value): if not value: return False @@ -2260,6 +2468,47 @@ class HrRecruitmentAutoDocWizardLine(models.TransientModel): return [] return [item.strip(" -,\t") for item in str(value).splitlines() if item.strip(" -,\t")] + def _json_to_text(self, value): + if value in (False, None, "", [], {}): + return False + return json.dumps(value, indent=2, ensure_ascii=False) + + def _text_to_json_list(self, value): + parsed = self._text_to_json_value(value, []) + if isinstance(parsed, list): + return [item for item in parsed if item not in (False, None, "", {})] + if isinstance(parsed, dict): + return [parsed] + return [] + + def _text_to_json_dict(self, value): + parsed = self._text_to_json_value(value, {}) + return parsed if isinstance(parsed, dict) else {} + + def _text_to_json_value(self, value, default): + if not value: + return default + try: + return json.loads(value) + except Exception: + return default + + def _get_other_parsed_data(self, parsed_data): + known_keys = { + "full_name", "first_name", "last_name", "email", "phone", "alternate_phone", + "linkedin_profile", "current_location", "current_organization", + "total_experience_years", "relevant_experience_years", "notice_period", + "degree", "skills", "summary", "education_history", "employer_history", + "family_details", "certifications", "projects", "request_id", "start_date", "end_date", "site_location", + "job_title", "job_summary", "requirements", "primary_skills", + "secondary_skills", "budget", "experience_years", "job_category", + } + return { + key: value + for key, value in (parsed_data or {}).items() + if key not in known_keys and value not in (False, None, "", [], {}) + } + def _float_or_zero(self, value): if value in (False, None, ""): return 0.0 @@ -2267,3 +2516,166 @@ class HrRecruitmentAutoDocWizardLine(models.TransientModel): return float(value) except Exception: return 0.0 + + +class HrRecruitmentAutoDocEducationLine(models.TransientModel): + _name = "hr.recruitment.auto.doc.education.line" + _description = "HR Recruitment Auto Document Education Line" + _order = "id" + + wizard_line_id = fields.Many2one( + "hr.recruitment.auto.doc.wizard.line", + required=True, + ondelete="cascade", + ) + education_type = fields.Char() + specialization = fields.Char() + university = fields.Char() + start_year = fields.Char() + end_year = fields.Char() + marks_or_grade = fields.Char() + + def _to_payload(self): + self.ensure_one() + payload = { + "education_type": self.education_type, + "specialization": self.specialization, + "university": self.university, + "start_year": self.start_year, + "end_year": self.end_year, + "marks_or_grade": self.marks_or_grade, + } + return {key: value for key, value in payload.items() if value not in (False, None, "")} + + +class HrRecruitmentAutoDocEmployerLine(models.TransientModel): + _name = "hr.recruitment.auto.doc.employer.line" + _description = "HR Recruitment Auto Document Employer Line" + _order = "id" + + wizard_line_id = fields.Many2one( + "hr.recruitment.auto.doc.wizard.line", + required=True, + ondelete="cascade", + ) + company_name = fields.Char() + designation = fields.Char() + date_of_joining = fields.Char() + last_working_day = fields.Char() + ctc = fields.Char() + work_description = fields.Text() + + def _to_payload(self): + self.ensure_one() + payload = { + "company_name": self.company_name, + "designation": self.designation, + "date_of_joining": self.date_of_joining, + "last_working_day": self.last_working_day, + "ctc": self.ctc, + "work_description": self.work_description, + } + return {key: value for key, value in payload.items() if value not in (False, None, "")} + + +class HrRecruitmentAutoDocFamilyLine(models.TransientModel): + _name = "hr.recruitment.auto.doc.family.line" + _description = "HR Recruitment Auto Document Family Line" + _order = "id" + + wizard_line_id = fields.Many2one( + "hr.recruitment.auto.doc.wizard.line", + required=True, + ondelete="cascade", + ) + relation_type = fields.Char() + name = fields.Char() + contact_no = fields.Char() + dob = fields.Char() + location = fields.Char() + + def _to_payload(self): + self.ensure_one() + payload = { + "relation_type": self.relation_type, + "name": self.name, + "contact_no": self.contact_no, + "dob": self.dob, + "location": self.location, + } + return {key: value for key, value in payload.items() if value not in (False, None, "")} + + +class HrRecruitmentAutoDocCertificationLine(models.TransientModel): + _name = "hr.recruitment.auto.doc.certification.line" + _description = "HR Recruitment Auto Document Certification Line" + _order = "id" + + wizard_line_id = fields.Many2one( + "hr.recruitment.auto.doc.wizard.line", + required=True, + ondelete="cascade", + ) + certification_name = fields.Char() + provider = fields.Char() + completion_date = fields.Char() + + def _to_payload(self): + self.ensure_one() + payload = { + "certification_name": self.certification_name, + "provider": self.provider, + "completion_date": self.completion_date, + } + return {key: value for key, value in payload.items() if value not in (False, None, "")} + + +class HrRecruitmentAutoDocProjectLine(models.TransientModel): + _name = "hr.recruitment.auto.doc.project.line" + _description = "HR Recruitment Auto Document Project Line" + _order = "id" + + wizard_line_id = fields.Many2one( + "hr.recruitment.auto.doc.wizard.line", + required=True, + ondelete="cascade", + ) + project_name = fields.Char() + role = fields.Char() + technologies = fields.Char() + duration = fields.Char() + description = fields.Text() + + def _to_payload(self): + self.ensure_one() + payload = { + "project_name": self.project_name, + "role": self.role, + "technologies": [item.strip() for item in (self.technologies or "").split(",") if item.strip()], + "duration": self.duration, + "description": self.description, + } + return {key: value for key, value in payload.items() if value not in (False, None, "", [])} + + +class HrRecruitmentAutoDocOtherLine(models.TransientModel): + _name = "hr.recruitment.auto.doc.other.line" + _description = "HR Recruitment Auto Document Other Parsed Data Line" + _order = "id" + + wizard_line_id = fields.Many2one( + "hr.recruitment.auto.doc.wizard.line", + required=True, + ondelete="cascade", + ) + key = fields.Char(required=True) + value = fields.Text() + + def _json_value(self): + self.ensure_one() + if not self.value: + return False + try: + return json.loads(self.value) + except Exception: + return self.value diff --git a/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard_views.xml b/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard_views.xml index cf19f3130..ce46616fa 100644 --- a/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard_views.xml +++ b/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard_views.xml @@ -95,6 +95,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +