Spaces:
Sleeping
Sleeping
Upload 3 files
Browse files- utils/docx_generator.py +879 -0
- utils/gemini_client.py +383 -0
- utils/pdf_generator.py +363 -0
utils/docx_generator.py
ADDED
|
@@ -0,0 +1,879 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DOCX Generator Utility
|
| 3 |
+
Handles creation of Word documents (.docx) for all template types
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import io
|
| 7 |
+
import sys
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from typing import Any, Dict, List, Optional
|
| 10 |
+
|
| 11 |
+
from docx import Document
|
| 12 |
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
| 13 |
+
from docx.oxml import OxmlElement
|
| 14 |
+
from docx.oxml.ns import qn
|
| 15 |
+
from docx.shared import Inches, Pt, RGBColor
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class DocxGenerator:
|
| 19 |
+
"""Generate professional Word documents"""
|
| 20 |
+
|
| 21 |
+
def __init__(self):
|
| 22 |
+
"""Initialize DOCX generator"""
|
| 23 |
+
self.default_font = "Calibri"
|
| 24 |
+
self.heading_font = "Arial"
|
| 25 |
+
|
| 26 |
+
def _set_cell_border(self, cell, **kwargs):
|
| 27 |
+
"""
|
| 28 |
+
Set cell border
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
cell: Table cell
|
| 32 |
+
kwargs: Border properties (top, bottom, left, right)
|
| 33 |
+
"""
|
| 34 |
+
tc = cell._tc
|
| 35 |
+
tcPr = tc.get_or_add_tcPr()
|
| 36 |
+
|
| 37 |
+
tcBorders = OxmlElement("w:tcBorders")
|
| 38 |
+
for edge in ("left", "top", "right", "bottom"):
|
| 39 |
+
if edge in kwargs:
|
| 40 |
+
edge_data = kwargs.get(edge)
|
| 41 |
+
edge_el = OxmlElement(f"w:{edge}")
|
| 42 |
+
edge_el.set(qn("w:val"), "single")
|
| 43 |
+
edge_el.set(qn("w:sz"), "4")
|
| 44 |
+
edge_el.set(qn("w:space"), "0")
|
| 45 |
+
edge_el.set(qn("w:color"), edge_data.get("color", "000000"))
|
| 46 |
+
tcBorders.append(edge_el)
|
| 47 |
+
|
| 48 |
+
tcPr.append(tcBorders)
|
| 49 |
+
|
| 50 |
+
def _add_heading(self, doc: Document, text: str, level: int = 1):
|
| 51 |
+
"""Add styled heading to document"""
|
| 52 |
+
heading = doc.add_heading(text, level=level)
|
| 53 |
+
heading.style.font.name = self.heading_font
|
| 54 |
+
heading.style.font.color.rgb = RGBColor(0, 51, 102) # Dark blue
|
| 55 |
+
return heading
|
| 56 |
+
|
| 57 |
+
def _add_paragraph(
|
| 58 |
+
self,
|
| 59 |
+
doc: Document,
|
| 60 |
+
text: str,
|
| 61 |
+
bold: bool = False,
|
| 62 |
+
italic: bool = False,
|
| 63 |
+
size: int = 11,
|
| 64 |
+
):
|
| 65 |
+
"""Add styled paragraph to document"""
|
| 66 |
+
para = doc.add_paragraph()
|
| 67 |
+
run = para.add_run(text)
|
| 68 |
+
run.font.name = self.default_font
|
| 69 |
+
run.font.size = Pt(size)
|
| 70 |
+
run.bold = bold
|
| 71 |
+
run.italic = italic
|
| 72 |
+
return para
|
| 73 |
+
|
| 74 |
+
def generate_resume(self, data: Dict[str, Any]) -> io.BytesIO:
|
| 75 |
+
"""
|
| 76 |
+
Generate resume document
|
| 77 |
+
|
| 78 |
+
Args:
|
| 79 |
+
data: Resume data containing:
|
| 80 |
+
- personal_info: name, email, phone, location, linkedin, website
|
| 81 |
+
- summary: Professional summary
|
| 82 |
+
- experience: List of work experiences
|
| 83 |
+
- education: List of education entries
|
| 84 |
+
- skills: List of skills
|
| 85 |
+
- certifications: List of certifications (optional)
|
| 86 |
+
- projects: List of projects (optional)
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
BytesIO buffer containing the .docx file
|
| 90 |
+
"""
|
| 91 |
+
doc = Document()
|
| 92 |
+
|
| 93 |
+
# Set margins
|
| 94 |
+
sections = doc.sections
|
| 95 |
+
for section in sections:
|
| 96 |
+
section.top_margin = Inches(0.5)
|
| 97 |
+
section.bottom_margin = Inches(0.5)
|
| 98 |
+
section.left_margin = Inches(0.75)
|
| 99 |
+
section.right_margin = Inches(0.75)
|
| 100 |
+
|
| 101 |
+
# Personal Information (Header)
|
| 102 |
+
personal = data.get("personal_info", {})
|
| 103 |
+
|
| 104 |
+
# Name (Large and centered)
|
| 105 |
+
name_para = doc.add_paragraph()
|
| 106 |
+
name_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 107 |
+
name_run = name_para.add_run(personal.get("name", "Your Name"))
|
| 108 |
+
name_run.font.name = self.heading_font
|
| 109 |
+
name_run.font.size = Pt(24)
|
| 110 |
+
name_run.bold = True
|
| 111 |
+
name_run.font.color.rgb = RGBColor(0, 51, 102)
|
| 112 |
+
|
| 113 |
+
# Contact Info (Centered)
|
| 114 |
+
contact_para = doc.add_paragraph()
|
| 115 |
+
contact_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 116 |
+
contact_parts = []
|
| 117 |
+
if personal.get("email"):
|
| 118 |
+
contact_parts.append(personal["email"])
|
| 119 |
+
if personal.get("phone"):
|
| 120 |
+
contact_parts.append(personal["phone"])
|
| 121 |
+
if personal.get("location"):
|
| 122 |
+
contact_parts.append(personal["location"])
|
| 123 |
+
|
| 124 |
+
contact_run = contact_para.add_run(" | ".join(contact_parts))
|
| 125 |
+
contact_run.font.name = self.default_font
|
| 126 |
+
contact_run.font.size = Pt(10)
|
| 127 |
+
|
| 128 |
+
# Links (Centered)
|
| 129 |
+
if personal.get("linkedin") or personal.get("website"):
|
| 130 |
+
links_para = doc.add_paragraph()
|
| 131 |
+
links_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 132 |
+
links_parts = []
|
| 133 |
+
if personal.get("linkedin"):
|
| 134 |
+
links_parts.append(f"LinkedIn: {personal['linkedin']}")
|
| 135 |
+
if personal.get("website"):
|
| 136 |
+
links_parts.append(f"Portfolio: {personal['website']}")
|
| 137 |
+
|
| 138 |
+
links_run = links_para.add_run(" | ".join(links_parts))
|
| 139 |
+
links_run.font.name = self.default_font
|
| 140 |
+
links_run.font.size = Pt(10)
|
| 141 |
+
links_run.font.color.rgb = RGBColor(0, 102, 204)
|
| 142 |
+
|
| 143 |
+
doc.add_paragraph() # Spacing
|
| 144 |
+
|
| 145 |
+
# Professional Summary
|
| 146 |
+
if data.get("summary"):
|
| 147 |
+
self._add_heading(doc, "PROFESSIONAL SUMMARY", level=1)
|
| 148 |
+
self._add_paragraph(doc, data["summary"])
|
| 149 |
+
doc.add_paragraph() # Spacing
|
| 150 |
+
|
| 151 |
+
# Work Experience
|
| 152 |
+
if data.get("experience"):
|
| 153 |
+
self._add_heading(doc, "WORK EXPERIENCE", level=1)
|
| 154 |
+
|
| 155 |
+
for exp in data["experience"]:
|
| 156 |
+
# Job Title and Company
|
| 157 |
+
title_para = doc.add_paragraph()
|
| 158 |
+
title_run = title_para.add_run(exp.get("title", "Position"))
|
| 159 |
+
title_run.font.name = self.default_font
|
| 160 |
+
title_run.font.size = Pt(12)
|
| 161 |
+
title_run.bold = True
|
| 162 |
+
|
| 163 |
+
# Company and Dates
|
| 164 |
+
company_para = doc.add_paragraph()
|
| 165 |
+
company_run = company_para.add_run(
|
| 166 |
+
f"{exp.get('company', 'Company')} | "
|
| 167 |
+
f"{exp.get('start_date', 'Start')} - {exp.get('end_date', 'End')}"
|
| 168 |
+
)
|
| 169 |
+
company_run.font.name = self.default_font
|
| 170 |
+
company_run.font.size = Pt(11)
|
| 171 |
+
company_run.italic = True
|
| 172 |
+
|
| 173 |
+
# Location
|
| 174 |
+
if exp.get("location"):
|
| 175 |
+
location_run = company_para.add_run(f" | {exp['location']}")
|
| 176 |
+
location_run.font.name = self.default_font
|
| 177 |
+
location_run.font.size = Pt(11)
|
| 178 |
+
location_run.italic = True
|
| 179 |
+
|
| 180 |
+
# Responsibilities/Achievements
|
| 181 |
+
if exp.get("responsibilities"):
|
| 182 |
+
for resp in exp["responsibilities"]:
|
| 183 |
+
bullet_para = doc.add_paragraph(resp, style="List Bullet")
|
| 184 |
+
bullet_para.paragraph_format.left_indent = Inches(0.25)
|
| 185 |
+
|
| 186 |
+
doc.add_paragraph() # Spacing between jobs
|
| 187 |
+
|
| 188 |
+
# Education
|
| 189 |
+
if data.get("education"):
|
| 190 |
+
self._add_heading(doc, "EDUCATION", level=1)
|
| 191 |
+
|
| 192 |
+
for edu in data["education"]:
|
| 193 |
+
# Degree
|
| 194 |
+
degree_para = doc.add_paragraph()
|
| 195 |
+
degree_run = degree_para.add_run(
|
| 196 |
+
f"{edu.get('degree', 'Degree')} in {edu.get('field', 'Field')}"
|
| 197 |
+
)
|
| 198 |
+
degree_run.font.name = self.default_font
|
| 199 |
+
degree_run.font.size = Pt(12)
|
| 200 |
+
degree_run.bold = True
|
| 201 |
+
|
| 202 |
+
# School and Dates
|
| 203 |
+
school_para = doc.add_paragraph()
|
| 204 |
+
school_run = school_para.add_run(
|
| 205 |
+
f"{edu.get('school', 'School')} | "
|
| 206 |
+
f"{edu.get('graduation_date', 'Graduation Date')}"
|
| 207 |
+
)
|
| 208 |
+
school_run.font.name = self.default_font
|
| 209 |
+
school_run.font.size = Pt(11)
|
| 210 |
+
school_run.italic = True
|
| 211 |
+
|
| 212 |
+
# GPA or Honors
|
| 213 |
+
if edu.get("gpa") or edu.get("honors"):
|
| 214 |
+
details = []
|
| 215 |
+
if edu.get("gpa"):
|
| 216 |
+
details.append(f"GPA: {edu['gpa']}")
|
| 217 |
+
if edu.get("honors"):
|
| 218 |
+
details.append(edu["honors"])
|
| 219 |
+
|
| 220 |
+
details_para = doc.add_paragraph(" | ".join(details))
|
| 221 |
+
details_para.paragraph_format.left_indent = Inches(0.25)
|
| 222 |
+
|
| 223 |
+
doc.add_paragraph() # Spacing
|
| 224 |
+
|
| 225 |
+
# Skills
|
| 226 |
+
if data.get("skills"):
|
| 227 |
+
self._add_heading(doc, "SKILLS", level=1)
|
| 228 |
+
|
| 229 |
+
# Group skills by category if provided
|
| 230 |
+
if isinstance(data["skills"], dict):
|
| 231 |
+
for category, skills_list in data["skills"].items():
|
| 232 |
+
skills_para = doc.add_paragraph()
|
| 233 |
+
category_run = skills_para.add_run(f"{category}: ")
|
| 234 |
+
category_run.font.name = self.default_font
|
| 235 |
+
category_run.font.size = Pt(11)
|
| 236 |
+
category_run.bold = True
|
| 237 |
+
|
| 238 |
+
skills_run = skills_para.add_run(", ".join(skills_list))
|
| 239 |
+
skills_run.font.name = self.default_font
|
| 240 |
+
skills_run.font.size = Pt(11)
|
| 241 |
+
else:
|
| 242 |
+
# Simple list of skills
|
| 243 |
+
skills_para = doc.add_paragraph(", ".join(data["skills"]))
|
| 244 |
+
skills_para.paragraph_format.left_indent = Inches(0.25)
|
| 245 |
+
|
| 246 |
+
doc.add_paragraph() # Spacing
|
| 247 |
+
|
| 248 |
+
# Certifications
|
| 249 |
+
if data.get("certifications"):
|
| 250 |
+
self._add_heading(doc, "CERTIFICATIONS", level=1)
|
| 251 |
+
|
| 252 |
+
for cert in data["certifications"]:
|
| 253 |
+
cert_para = doc.add_paragraph(style="List Bullet")
|
| 254 |
+
cert_run = cert_para.add_run(
|
| 255 |
+
f"{cert.get('name', 'Certification')} - "
|
| 256 |
+
f"{cert.get('issuer', 'Issuer')}"
|
| 257 |
+
)
|
| 258 |
+
cert_run.font.name = self.default_font
|
| 259 |
+
cert_run.font.size = Pt(11)
|
| 260 |
+
|
| 261 |
+
if cert.get("date"):
|
| 262 |
+
date_run = cert_para.add_run(f" ({cert['date']})")
|
| 263 |
+
date_run.font.name = self.default_font
|
| 264 |
+
date_run.font.size = Pt(10)
|
| 265 |
+
date_run.italic = True
|
| 266 |
+
|
| 267 |
+
# Projects
|
| 268 |
+
if data.get("projects"):
|
| 269 |
+
self._add_heading(doc, "PROJECTS", level=1)
|
| 270 |
+
|
| 271 |
+
for proj in data["projects"]:
|
| 272 |
+
# Project Title
|
| 273 |
+
proj_para = doc.add_paragraph()
|
| 274 |
+
proj_run = proj_para.add_run(proj.get("name", "Project"))
|
| 275 |
+
proj_run.font.name = self.default_font
|
| 276 |
+
proj_run.font.size = Pt(12)
|
| 277 |
+
proj_run.bold = True
|
| 278 |
+
|
| 279 |
+
# Description
|
| 280 |
+
if proj.get("description"):
|
| 281 |
+
desc_para = doc.add_paragraph(proj["description"])
|
| 282 |
+
desc_para.paragraph_format.left_indent = Inches(0.25)
|
| 283 |
+
|
| 284 |
+
# Technologies
|
| 285 |
+
if proj.get("technologies"):
|
| 286 |
+
tech_para = doc.add_paragraph()
|
| 287 |
+
tech_para.paragraph_format.left_indent = Inches(0.25)
|
| 288 |
+
tech_label = tech_para.add_run("Technologies: ")
|
| 289 |
+
tech_label.font.size = Pt(10)
|
| 290 |
+
tech_label.italic = True
|
| 291 |
+
tech_list = tech_para.add_run(", ".join(proj["technologies"]))
|
| 292 |
+
tech_list.font.size = Pt(10)
|
| 293 |
+
|
| 294 |
+
doc.add_paragraph() # Spacing
|
| 295 |
+
|
| 296 |
+
# Save to BytesIO
|
| 297 |
+
buffer = io.BytesIO()
|
| 298 |
+
doc.save(buffer)
|
| 299 |
+
buffer.seek(0)
|
| 300 |
+
|
| 301 |
+
return buffer
|
| 302 |
+
|
| 303 |
+
def generate_cover_letter(self, data: Dict[str, Any]) -> io.BytesIO:
|
| 304 |
+
"""
|
| 305 |
+
Generate cover letter document
|
| 306 |
+
|
| 307 |
+
Args:
|
| 308 |
+
data: Cover letter data containing:
|
| 309 |
+
- name: Applicant name
|
| 310 |
+
- address: Applicant address
|
| 311 |
+
- email: Email
|
| 312 |
+
- phone: Phone
|
| 313 |
+
- date: Letter date
|
| 314 |
+
- company: Company name
|
| 315 |
+
- hiring_manager: Hiring manager name (optional)
|
| 316 |
+
- position: Position applied for
|
| 317 |
+
- content: Letter content (paragraphs)
|
| 318 |
+
|
| 319 |
+
Returns:
|
| 320 |
+
BytesIO buffer containing the .docx file
|
| 321 |
+
"""
|
| 322 |
+
doc = Document()
|
| 323 |
+
|
| 324 |
+
# Set margins
|
| 325 |
+
sections = doc.sections
|
| 326 |
+
for section in sections:
|
| 327 |
+
section.top_margin = Inches(1)
|
| 328 |
+
section.bottom_margin = Inches(1)
|
| 329 |
+
section.left_margin = Inches(1)
|
| 330 |
+
section.right_margin = Inches(1)
|
| 331 |
+
|
| 332 |
+
# Applicant Info
|
| 333 |
+
self._add_paragraph(doc, data.get("name", "Your Name"), bold=True, size=12)
|
| 334 |
+
if data.get("address"):
|
| 335 |
+
self._add_paragraph(doc, data["address"], size=10)
|
| 336 |
+
|
| 337 |
+
contact_line = []
|
| 338 |
+
if data.get("email"):
|
| 339 |
+
contact_line.append(data["email"])
|
| 340 |
+
if data.get("phone"):
|
| 341 |
+
contact_line.append(data["phone"])
|
| 342 |
+
if contact_line:
|
| 343 |
+
self._add_paragraph(doc, " | ".join(contact_line), size=10)
|
| 344 |
+
|
| 345 |
+
doc.add_paragraph() # Spacing
|
| 346 |
+
|
| 347 |
+
# Date
|
| 348 |
+
date_str = data.get("date", datetime.now().strftime("%B %d, %Y"))
|
| 349 |
+
self._add_paragraph(doc, date_str, size=11)
|
| 350 |
+
|
| 351 |
+
doc.add_paragraph() # Spacing
|
| 352 |
+
|
| 353 |
+
# Recipient Info
|
| 354 |
+
if data.get("hiring_manager"):
|
| 355 |
+
self._add_paragraph(doc, data["hiring_manager"], size=11)
|
| 356 |
+
self._add_paragraph(
|
| 357 |
+
doc, data.get("company", "Company Name"), bold=True, size=11
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
doc.add_paragraph() # Spacing
|
| 361 |
+
|
| 362 |
+
# Salutation
|
| 363 |
+
salutation = (
|
| 364 |
+
f"Dear {data.get('hiring_manager', 'Hiring Manager')},"
|
| 365 |
+
if data.get("hiring_manager")
|
| 366 |
+
else "Dear Hiring Manager,"
|
| 367 |
+
)
|
| 368 |
+
self._add_paragraph(doc, salutation, size=11)
|
| 369 |
+
|
| 370 |
+
doc.add_paragraph() # Spacing
|
| 371 |
+
|
| 372 |
+
# Letter Content
|
| 373 |
+
content = data.get("content", "")
|
| 374 |
+
|
| 375 |
+
# Split content into paragraphs
|
| 376 |
+
paragraphs = content.split("\n\n") if "\n\n" in content else [content]
|
| 377 |
+
|
| 378 |
+
for para_text in paragraphs:
|
| 379 |
+
if para_text.strip():
|
| 380 |
+
para = doc.add_paragraph()
|
| 381 |
+
para.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
| 382 |
+
run = para.add_run(para_text.strip())
|
| 383 |
+
run.font.name = self.default_font
|
| 384 |
+
run.font.size = Pt(11)
|
| 385 |
+
para.paragraph_format.space_after = Pt(12)
|
| 386 |
+
|
| 387 |
+
doc.add_paragraph() # Spacing
|
| 388 |
+
|
| 389 |
+
# Closing
|
| 390 |
+
self._add_paragraph(doc, "Sincerely,", size=11)
|
| 391 |
+
doc.add_paragraph()
|
| 392 |
+
doc.add_paragraph()
|
| 393 |
+
self._add_paragraph(doc, data.get("name", "Your Name"), bold=True, size=11)
|
| 394 |
+
|
| 395 |
+
# Save to BytesIO
|
| 396 |
+
buffer = io.BytesIO()
|
| 397 |
+
doc.save(buffer)
|
| 398 |
+
buffer.seek(0)
|
| 399 |
+
|
| 400 |
+
return buffer
|
| 401 |
+
|
| 402 |
+
def generate_proposal(self, data: Dict[str, Any]) -> io.BytesIO:
|
| 403 |
+
"""
|
| 404 |
+
Generate business proposal document
|
| 405 |
+
|
| 406 |
+
Args:
|
| 407 |
+
data: Proposal data containing:
|
| 408 |
+
- title: Proposal title
|
| 409 |
+
- client_name: Client name
|
| 410 |
+
- prepared_by: Your name/company
|
| 411 |
+
- date: Proposal date
|
| 412 |
+
- content: Proposal content (can be structured or plain text)
|
| 413 |
+
- project_overview: Project description
|
| 414 |
+
- scope: Scope of work
|
| 415 |
+
- deliverables: List of deliverables
|
| 416 |
+
- timeline: Project timeline
|
| 417 |
+
- budget: Budget information
|
| 418 |
+
|
| 419 |
+
Returns:
|
| 420 |
+
BytesIO buffer containing the .docx file
|
| 421 |
+
"""
|
| 422 |
+
doc = Document()
|
| 423 |
+
|
| 424 |
+
# Set margins
|
| 425 |
+
sections = doc.sections
|
| 426 |
+
for section in sections:
|
| 427 |
+
section.top_margin = Inches(1)
|
| 428 |
+
section.bottom_margin = Inches(1)
|
| 429 |
+
section.left_margin = Inches(1)
|
| 430 |
+
section.right_margin = Inches(1)
|
| 431 |
+
|
| 432 |
+
# Title Page
|
| 433 |
+
title_para = doc.add_paragraph()
|
| 434 |
+
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 435 |
+
title_run = title_para.add_run(data.get("title", "Business Proposal"))
|
| 436 |
+
title_run.font.name = self.heading_font
|
| 437 |
+
title_run.font.size = Pt(28)
|
| 438 |
+
title_run.bold = True
|
| 439 |
+
title_run.font.color.rgb = RGBColor(0, 51, 102)
|
| 440 |
+
|
| 441 |
+
doc.add_paragraph()
|
| 442 |
+
doc.add_paragraph()
|
| 443 |
+
|
| 444 |
+
# Prepared For
|
| 445 |
+
for_para = doc.add_paragraph()
|
| 446 |
+
for_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 447 |
+
for_run = for_para.add_run(
|
| 448 |
+
f"Prepared for:\n{data.get('client_name', 'Client Name')}"
|
| 449 |
+
)
|
| 450 |
+
for_run.font.name = self.default_font
|
| 451 |
+
for_run.font.size = Pt(14)
|
| 452 |
+
|
| 453 |
+
doc.add_paragraph()
|
| 454 |
+
|
| 455 |
+
# Prepared By
|
| 456 |
+
by_para = doc.add_paragraph()
|
| 457 |
+
by_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 458 |
+
by_run = by_para.add_run(
|
| 459 |
+
f"Prepared by:\n{data.get('prepared_by', 'Your Company')}"
|
| 460 |
+
)
|
| 461 |
+
by_run.font.name = self.default_font
|
| 462 |
+
by_run.font.size = Pt(14)
|
| 463 |
+
|
| 464 |
+
doc.add_paragraph()
|
| 465 |
+
|
| 466 |
+
# Date
|
| 467 |
+
date_para = doc.add_paragraph()
|
| 468 |
+
date_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 469 |
+
date_run = date_para.add_run(
|
| 470 |
+
data.get("date", datetime.now().strftime("%B %d, %Y"))
|
| 471 |
+
)
|
| 472 |
+
date_run.font.name = self.default_font
|
| 473 |
+
date_run.font.size = Pt(12)
|
| 474 |
+
|
| 475 |
+
# Page Break
|
| 476 |
+
doc.add_page_break()
|
| 477 |
+
|
| 478 |
+
# If structured content is provided
|
| 479 |
+
if data.get("content") and isinstance(data["content"], str):
|
| 480 |
+
# Parse and format the content
|
| 481 |
+
sections_text = data["content"].split("\n\n")
|
| 482 |
+
for section in sections_text:
|
| 483 |
+
if section.strip():
|
| 484 |
+
lines = section.strip().split("\n")
|
| 485 |
+
# First line as heading if it looks like a heading
|
| 486 |
+
if len(lines[0]) < 50 and not lines[0].endswith("."):
|
| 487 |
+
self._add_heading(doc, lines[0], level=1)
|
| 488 |
+
for line in lines[1:]:
|
| 489 |
+
if line.strip():
|
| 490 |
+
self._add_paragraph(doc, line.strip())
|
| 491 |
+
else:
|
| 492 |
+
for line in lines:
|
| 493 |
+
if line.strip():
|
| 494 |
+
self._add_paragraph(doc, line.strip())
|
| 495 |
+
doc.add_paragraph()
|
| 496 |
+
else:
|
| 497 |
+
# Manual structure
|
| 498 |
+
# Executive Summary
|
| 499 |
+
if data.get("executive_summary"):
|
| 500 |
+
self._add_heading(doc, "Executive Summary", level=1)
|
| 501 |
+
self._add_paragraph(doc, data["executive_summary"])
|
| 502 |
+
doc.add_paragraph()
|
| 503 |
+
|
| 504 |
+
# Project Overview
|
| 505 |
+
if data.get("project_overview"):
|
| 506 |
+
self._add_heading(doc, "Project Overview", level=1)
|
| 507 |
+
self._add_paragraph(doc, data["project_overview"])
|
| 508 |
+
doc.add_paragraph()
|
| 509 |
+
|
| 510 |
+
# Scope of Work
|
| 511 |
+
if data.get("scope"):
|
| 512 |
+
self._add_heading(doc, "Scope of Work", level=1)
|
| 513 |
+
self._add_paragraph(doc, data["scope"])
|
| 514 |
+
doc.add_paragraph()
|
| 515 |
+
|
| 516 |
+
# Deliverables
|
| 517 |
+
if data.get("deliverables"):
|
| 518 |
+
self._add_heading(doc, "Deliverables", level=1)
|
| 519 |
+
for deliverable in data["deliverables"]:
|
| 520 |
+
doc.add_paragraph(deliverable, style="List Bullet")
|
| 521 |
+
doc.add_paragraph()
|
| 522 |
+
|
| 523 |
+
# Timeline
|
| 524 |
+
if data.get("timeline"):
|
| 525 |
+
self._add_heading(doc, "Timeline", level=1)
|
| 526 |
+
self._add_paragraph(doc, data["timeline"])
|
| 527 |
+
doc.add_paragraph()
|
| 528 |
+
|
| 529 |
+
# Budget
|
| 530 |
+
if data.get("budget"):
|
| 531 |
+
self._add_heading(doc, "Investment", level=1)
|
| 532 |
+
self._add_paragraph(doc, data["budget"])
|
| 533 |
+
doc.add_paragraph()
|
| 534 |
+
|
| 535 |
+
# Next Steps
|
| 536 |
+
self._add_heading(doc, "Next Steps", level=1)
|
| 537 |
+
next_steps = data.get(
|
| 538 |
+
"next_steps",
|
| 539 |
+
[
|
| 540 |
+
"Review this proposal",
|
| 541 |
+
"Schedule a meeting to discuss details",
|
| 542 |
+
"Sign agreement and begin work",
|
| 543 |
+
],
|
| 544 |
+
)
|
| 545 |
+
for step in next_steps:
|
| 546 |
+
doc.add_paragraph(step, style="List Number")
|
| 547 |
+
|
| 548 |
+
# Save to BytesIO
|
| 549 |
+
buffer = io.BytesIO()
|
| 550 |
+
doc.save(buffer)
|
| 551 |
+
buffer.seek(0)
|
| 552 |
+
|
| 553 |
+
return buffer
|
| 554 |
+
|
| 555 |
+
def generate_invoice(self, data: Dict[str, Any]) -> io.BytesIO:
|
| 556 |
+
"""
|
| 557 |
+
Generate invoice document
|
| 558 |
+
|
| 559 |
+
Args:
|
| 560 |
+
data: Invoice data containing:
|
| 561 |
+
- invoice_number: Invoice number
|
| 562 |
+
- invoice_date: Invoice date
|
| 563 |
+
- due_date: Payment due date
|
| 564 |
+
- from_info: Your business info (name, address, email, phone)
|
| 565 |
+
- to_info: Client info (name, address, email)
|
| 566 |
+
- items: List of line items (description, quantity, rate, amount)
|
| 567 |
+
- notes: Additional notes (optional)
|
| 568 |
+
- tax_rate: Tax percentage (optional)
|
| 569 |
+
|
| 570 |
+
Returns:
|
| 571 |
+
BytesIO buffer containing the .docx file
|
| 572 |
+
"""
|
| 573 |
+
doc = Document()
|
| 574 |
+
|
| 575 |
+
# Set margins
|
| 576 |
+
sections = doc.sections
|
| 577 |
+
for section in sections:
|
| 578 |
+
section.top_margin = Inches(0.75)
|
| 579 |
+
section.bottom_margin = Inches(0.75)
|
| 580 |
+
section.left_margin = Inches(0.75)
|
| 581 |
+
section.right_margin = Inches(0.75)
|
| 582 |
+
|
| 583 |
+
# Title
|
| 584 |
+
title_para = doc.add_paragraph()
|
| 585 |
+
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 586 |
+
title_run = title_para.add_run("INVOICE")
|
| 587 |
+
title_run.font.name = self.heading_font
|
| 588 |
+
title_run.font.size = Pt(24)
|
| 589 |
+
title_run.bold = True
|
| 590 |
+
title_run.font.color.rgb = RGBColor(0, 51, 102)
|
| 591 |
+
|
| 592 |
+
doc.add_paragraph()
|
| 593 |
+
|
| 594 |
+
# Invoice Info Table
|
| 595 |
+
info_table = doc.add_table(rows=2, cols=2)
|
| 596 |
+
info_table.style = "Light Grid Accent 1"
|
| 597 |
+
|
| 598 |
+
# From Info (Left)
|
| 599 |
+
from_cell = info_table.cell(0, 0)
|
| 600 |
+
from_info = data.get("from_info", {})
|
| 601 |
+
from_text = "FROM:\n"
|
| 602 |
+
from_text += f"{from_info.get('name', 'Your Business')}\n"
|
| 603 |
+
if from_info.get("address"):
|
| 604 |
+
from_text += f"{from_info['address']}\n"
|
| 605 |
+
if from_info.get("email"):
|
| 606 |
+
from_text += f"{from_info['email']}\n"
|
| 607 |
+
if from_info.get("phone"):
|
| 608 |
+
from_text += f"{from_info['phone']}"
|
| 609 |
+
from_cell.text = from_text
|
| 610 |
+
|
| 611 |
+
# Invoice Details (Right)
|
| 612 |
+
details_cell = info_table.cell(0, 1)
|
| 613 |
+
details_text = f"Invoice #: {data.get('invoice_number', 'INV-001')}\n"
|
| 614 |
+
details_text += (
|
| 615 |
+
f"Date: {data.get('invoice_date', datetime.now().strftime('%Y-%m-%d'))}\n"
|
| 616 |
+
)
|
| 617 |
+
details_text += f"Due Date: {data.get('due_date', 'Upon Receipt')}"
|
| 618 |
+
details_cell.text = details_text
|
| 619 |
+
|
| 620 |
+
# Bill To (Left)
|
| 621 |
+
to_cell = info_table.cell(1, 0)
|
| 622 |
+
to_info = data.get("to_info", {})
|
| 623 |
+
to_text = "BILL TO:\n"
|
| 624 |
+
to_text += f"{to_info.get('name', 'Client Name')}\n"
|
| 625 |
+
if to_info.get("address"):
|
| 626 |
+
to_text += f"{to_info['address']}\n"
|
| 627 |
+
if to_info.get("email"):
|
| 628 |
+
to_text += f"{to_info['email']}"
|
| 629 |
+
to_cell.text = to_text
|
| 630 |
+
|
| 631 |
+
doc.add_paragraph()
|
| 632 |
+
doc.add_paragraph()
|
| 633 |
+
|
| 634 |
+
# Items Table
|
| 635 |
+
items = data.get("items", [])
|
| 636 |
+
if items:
|
| 637 |
+
items_table = doc.add_table(rows=len(items) + 1, cols=4)
|
| 638 |
+
items_table.style = "Light Grid Accent 1"
|
| 639 |
+
|
| 640 |
+
# Header
|
| 641 |
+
header_cells = items_table.rows[0].cells
|
| 642 |
+
header_cells[0].text = "Description"
|
| 643 |
+
header_cells[1].text = "Quantity"
|
| 644 |
+
header_cells[2].text = "Rate"
|
| 645 |
+
header_cells[3].text = "Amount"
|
| 646 |
+
|
| 647 |
+
# Make header bold
|
| 648 |
+
for cell in header_cells:
|
| 649 |
+
for paragraph in cell.paragraphs:
|
| 650 |
+
for run in paragraph.runs:
|
| 651 |
+
run.font.bold = True
|
| 652 |
+
|
| 653 |
+
# Items
|
| 654 |
+
subtotal = 0
|
| 655 |
+
for idx, item in enumerate(items, 1):
|
| 656 |
+
row_cells = items_table.rows[idx].cells
|
| 657 |
+
row_cells[0].text = item.get("description", "")
|
| 658 |
+
row_cells[1].text = str(item.get("quantity", 1))
|
| 659 |
+
row_cells[2].text = f"${item.get('rate', 0):.2f}"
|
| 660 |
+
|
| 661 |
+
amount = item.get(
|
| 662 |
+
"amount", item.get("quantity", 1) * item.get("rate", 0)
|
| 663 |
+
)
|
| 664 |
+
row_cells[3].text = f"${amount:.2f}"
|
| 665 |
+
subtotal += amount
|
| 666 |
+
|
| 667 |
+
doc.add_paragraph()
|
| 668 |
+
|
| 669 |
+
# Totals
|
| 670 |
+
totals_table = doc.add_table(rows=4, cols=2)
|
| 671 |
+
totals_table.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
| 672 |
+
|
| 673 |
+
# Subtotal
|
| 674 |
+
totals_table.cell(0, 0).text = "Subtotal:"
|
| 675 |
+
totals_table.cell(0, 1).text = f"${subtotal:.2f}"
|
| 676 |
+
|
| 677 |
+
# Tax
|
| 678 |
+
tax_rate = data.get("tax_rate", 0)
|
| 679 |
+
tax_amount = subtotal * (tax_rate / 100) if tax_rate > 0 else 0
|
| 680 |
+
totals_table.cell(1, 0).text = f"Tax ({tax_rate}%):"
|
| 681 |
+
totals_table.cell(1, 1).text = f"${tax_amount:.2f}"
|
| 682 |
+
|
| 683 |
+
# Discount
|
| 684 |
+
discount = data.get("discount", 0)
|
| 685 |
+
totals_table.cell(2, 0).text = "Discount:"
|
| 686 |
+
totals_table.cell(2, 1).text = f"-${discount:.2f}"
|
| 687 |
+
|
| 688 |
+
# Total
|
| 689 |
+
total = subtotal + tax_amount - discount
|
| 690 |
+
totals_table.cell(3, 0).text = "TOTAL:"
|
| 691 |
+
totals_table.cell(3, 1).text = f"${total:.2f}"
|
| 692 |
+
|
| 693 |
+
# Make total row bold
|
| 694 |
+
for cell in [totals_table.cell(3, 0), totals_table.cell(3, 1)]:
|
| 695 |
+
for paragraph in cell.paragraphs:
|
| 696 |
+
for run in paragraph.runs:
|
| 697 |
+
run.font.bold = True
|
| 698 |
+
run.font.size = Pt(14)
|
| 699 |
+
|
| 700 |
+
doc.add_paragraph()
|
| 701 |
+
doc.add_paragraph()
|
| 702 |
+
|
| 703 |
+
# Notes
|
| 704 |
+
if data.get("notes"):
|
| 705 |
+
self._add_heading(doc, "Notes:", level=2)
|
| 706 |
+
self._add_paragraph(doc, data["notes"])
|
| 707 |
+
|
| 708 |
+
# Payment Instructions
|
| 709 |
+
if data.get("payment_instructions"):
|
| 710 |
+
doc.add_paragraph()
|
| 711 |
+
self._add_heading(doc, "Payment Instructions:", level=2)
|
| 712 |
+
self._add_paragraph(doc, data["payment_instructions"])
|
| 713 |
+
|
| 714 |
+
# Save to BytesIO
|
| 715 |
+
buffer = io.BytesIO()
|
| 716 |
+
doc.save(buffer)
|
| 717 |
+
buffer.seek(0)
|
| 718 |
+
|
| 719 |
+
return buffer
|
| 720 |
+
|
| 721 |
+
def generate_contract(self, data: Dict[str, Any]) -> io.BytesIO:
|
| 722 |
+
"""
|
| 723 |
+
Generate contract document
|
| 724 |
+
|
| 725 |
+
Args:
|
| 726 |
+
data: Contract data containing:
|
| 727 |
+
- contract_type: Type of contract
|
| 728 |
+
- date: Contract date
|
| 729 |
+
- party1: First party info (name, address)
|
| 730 |
+
- party2: Second party info (name, address)
|
| 731 |
+
- terms: Contract terms/content
|
| 732 |
+
- effective_date: When contract takes effect
|
| 733 |
+
- expiration_date: When contract expires (optional)
|
| 734 |
+
|
| 735 |
+
Returns:
|
| 736 |
+
BytesIO buffer containing the .docx file
|
| 737 |
+
"""
|
| 738 |
+
doc = Document()
|
| 739 |
+
|
| 740 |
+
# Set margins
|
| 741 |
+
sections = doc.sections
|
| 742 |
+
for section in sections:
|
| 743 |
+
section.top_margin = Inches(1)
|
| 744 |
+
section.bottom_margin = Inches(1)
|
| 745 |
+
section.left_margin = Inches(1.25)
|
| 746 |
+
section.right_margin = Inches(1.25)
|
| 747 |
+
|
| 748 |
+
# Title
|
| 749 |
+
contract_type = data.get("contract_type", "Service Agreement")
|
| 750 |
+
title_para = doc.add_paragraph()
|
| 751 |
+
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 752 |
+
title_run = title_para.add_run(contract_type.upper())
|
| 753 |
+
title_run.font.name = self.heading_font
|
| 754 |
+
title_run.font.size = Pt(18)
|
| 755 |
+
title_run.bold = True
|
| 756 |
+
|
| 757 |
+
doc.add_paragraph()
|
| 758 |
+
|
| 759 |
+
# Date
|
| 760 |
+
date_para = doc.add_paragraph()
|
| 761 |
+
date_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 762 |
+
date_run = date_para.add_run(
|
| 763 |
+
f"Date: {data.get('date', datetime.now().strftime('%B %d, %Y'))}"
|
| 764 |
+
)
|
| 765 |
+
date_run.font.name = self.default_font
|
| 766 |
+
date_run.font.size = Pt(11)
|
| 767 |
+
|
| 768 |
+
doc.add_paragraph()
|
| 769 |
+
|
| 770 |
+
# Parties
|
| 771 |
+
self._add_heading(doc, "PARTIES", level=1)
|
| 772 |
+
|
| 773 |
+
party1 = data.get("party1", {})
|
| 774 |
+
party2 = data.get("party2", {})
|
| 775 |
+
|
| 776 |
+
parties_text = f"This Agreement is entered into between:\n\n"
|
| 777 |
+
parties_text += f'Party 1 ("Provider"): {party1.get("name", "Party 1 Name")}'
|
| 778 |
+
if party1.get("address"):
|
| 779 |
+
parties_text += f"\nAddress: {party1['address']}"
|
| 780 |
+
|
| 781 |
+
parties_text += f"\n\nAND\n\n"
|
| 782 |
+
parties_text += f'Party 2 ("Client"): {party2.get("name", "Party 2 Name")}'
|
| 783 |
+
if party2.get("address"):
|
| 784 |
+
parties_text += f"\nAddress: {party2['address']}"
|
| 785 |
+
|
| 786 |
+
self._add_paragraph(doc, parties_text)
|
| 787 |
+
doc.add_paragraph()
|
| 788 |
+
|
| 789 |
+
# Effective Date
|
| 790 |
+
if data.get("effective_date"):
|
| 791 |
+
effective_para = doc.add_paragraph()
|
| 792 |
+
effective_run = effective_para.add_run(
|
| 793 |
+
f"Effective Date: {data['effective_date']}"
|
| 794 |
+
)
|
| 795 |
+
effective_run.font.bold = True
|
| 796 |
+
doc.add_paragraph()
|
| 797 |
+
|
| 798 |
+
# Terms
|
| 799 |
+
terms_content = data.get("terms", "")
|
| 800 |
+
|
| 801 |
+
if terms_content:
|
| 802 |
+
# Parse terms into sections
|
| 803 |
+
sections_text = terms_content.split("\n\n")
|
| 804 |
+
for section in sections_text:
|
| 805 |
+
if section.strip():
|
| 806 |
+
lines = section.strip().split("\n")
|
| 807 |
+
# Check if first line is a heading
|
| 808 |
+
if len(lines[0]) < 60 and (
|
| 809 |
+
lines[0].endswith(":") or not lines[0].endswith(".")
|
| 810 |
+
):
|
| 811 |
+
self._add_heading(
|
| 812 |
+
doc, lines[0].replace(":", "").strip(), level=2
|
| 813 |
+
)
|
| 814 |
+
for line in lines[1:]:
|
| 815 |
+
if line.strip():
|
| 816 |
+
self._add_paragraph(doc, line.strip())
|
| 817 |
+
else:
|
| 818 |
+
for line in lines:
|
| 819 |
+
if line.strip():
|
| 820 |
+
self._add_paragraph(doc, line.strip())
|
| 821 |
+
doc.add_paragraph()
|
| 822 |
+
|
| 823 |
+
# Disclaimer
|
| 824 |
+
doc.add_page_break()
|
| 825 |
+
self._add_heading(doc, "LEGAL DISCLAIMER", level=1)
|
| 826 |
+
disclaimer = (
|
| 827 |
+
"This document is provided as a template only and should be reviewed by a "
|
| 828 |
+
"qualified legal professional before use. The parties acknowledge that this "
|
| 829 |
+
"agreement may not be suitable for all situations and that legal advice "
|
| 830 |
+
"should be sought for specific circumstances."
|
| 831 |
+
)
|
| 832 |
+
self._add_paragraph(doc, disclaimer, italic=True, size=10)
|
| 833 |
+
|
| 834 |
+
doc.add_paragraph()
|
| 835 |
+
doc.add_paragraph()
|
| 836 |
+
|
| 837 |
+
# Signature Section
|
| 838 |
+
self._add_heading(doc, "SIGNATURES", level=1)
|
| 839 |
+
|
| 840 |
+
# Party 1 Signature
|
| 841 |
+
doc.add_paragraph()
|
| 842 |
+
self._add_paragraph(doc, "Party 1 (Provider):", bold=True)
|
| 843 |
+
doc.add_paragraph()
|
| 844 |
+
doc.add_paragraph("_" * 50)
|
| 845 |
+
self._add_paragraph(doc, f"Signature: {party1.get('name', '')}")
|
| 846 |
+
doc.add_paragraph()
|
| 847 |
+
doc.add_paragraph("_" * 50)
|
| 848 |
+
self._add_paragraph(doc, "Date:")
|
| 849 |
+
|
| 850 |
+
doc.add_paragraph()
|
| 851 |
+
doc.add_paragraph()
|
| 852 |
+
|
| 853 |
+
# Party 2 Signature
|
| 854 |
+
self._add_paragraph(doc, "Party 2 (Client):", bold=True)
|
| 855 |
+
doc.add_paragraph()
|
| 856 |
+
doc.add_paragraph("_" * 50)
|
| 857 |
+
self._add_paragraph(doc, f"Signature: {party2.get('name', '')}")
|
| 858 |
+
doc.add_paragraph()
|
| 859 |
+
doc.add_paragraph("_" * 50)
|
| 860 |
+
self._add_paragraph(doc, "Date:")
|
| 861 |
+
|
| 862 |
+
# Save to BytesIO
|
| 863 |
+
buffer = io.BytesIO()
|
| 864 |
+
doc.save(buffer)
|
| 865 |
+
buffer.seek(0)
|
| 866 |
+
|
| 867 |
+
return buffer
|
| 868 |
+
|
| 869 |
+
|
| 870 |
+
# Singleton instance
|
| 871 |
+
_docx_generator = None
|
| 872 |
+
|
| 873 |
+
|
| 874 |
+
def get_docx_generator() -> DocxGenerator:
|
| 875 |
+
"""Get or create DocxGenerator singleton"""
|
| 876 |
+
global _docx_generator
|
| 877 |
+
if _docx_generator is None:
|
| 878 |
+
_docx_generator = DocxGenerator()
|
| 879 |
+
return _docx_generator
|
utils/gemini_client.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gemini AI Client
|
| 3 |
+
Handles all interactions with Google's Gemini 2.5 Flash API
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
from typing import Any, Dict, List, Optional
|
| 10 |
+
|
| 11 |
+
import google.generativeai as genai
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class GeminiClient:
|
| 15 |
+
"""Client for Google Gemini AI API"""
|
| 16 |
+
|
| 17 |
+
def __init__(self, api_key: Optional[str] = None):
|
| 18 |
+
"""
|
| 19 |
+
Initialize Gemini client
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
api_key: Google Gemini API key (optional, reads from env if not provided)
|
| 23 |
+
"""
|
| 24 |
+
self.api_key = api_key or os.getenv("GEMINI_API_KEY")
|
| 25 |
+
|
| 26 |
+
if not self.api_key:
|
| 27 |
+
raise ValueError("GEMINI_API_KEY not found in environment variables")
|
| 28 |
+
|
| 29 |
+
# Configure Gemini
|
| 30 |
+
genai.configure(api_key=self.api_key)
|
| 31 |
+
|
| 32 |
+
# Initialize model (Gemini 2.5 Flash)
|
| 33 |
+
self.model = genai.GenerativeModel("gemini-2.0-flash-exp")
|
| 34 |
+
|
| 35 |
+
# Safety settings
|
| 36 |
+
self.safety_settings = [
|
| 37 |
+
{
|
| 38 |
+
"category": "HARM_CATEGORY_HARASSMENT",
|
| 39 |
+
"threshold": "BLOCK_MEDIUM_AND_ABOVE",
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
"category": "HARM_CATEGORY_HATE_SPEECH",
|
| 43 |
+
"threshold": "BLOCK_MEDIUM_AND_ABOVE",
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
| 47 |
+
"threshold": "BLOCK_MEDIUM_AND_ABOVE",
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
|
| 51 |
+
"threshold": "BLOCK_MEDIUM_AND_ABOVE",
|
| 52 |
+
},
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
print(f"✓ Gemini AI client initialized successfully", file=sys.stderr)
|
| 56 |
+
|
| 57 |
+
def generate_text(
|
| 58 |
+
self, prompt: str, temperature: float = 0.7, max_tokens: int = 2048
|
| 59 |
+
) -> str:
|
| 60 |
+
"""
|
| 61 |
+
Generate text using Gemini
|
| 62 |
+
|
| 63 |
+
Args:
|
| 64 |
+
prompt: Input prompt
|
| 65 |
+
temperature: Creativity level (0.0 to 1.0)
|
| 66 |
+
max_tokens: Maximum response length
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
Generated text
|
| 70 |
+
"""
|
| 71 |
+
try:
|
| 72 |
+
generation_config = {
|
| 73 |
+
"temperature": temperature,
|
| 74 |
+
"max_output_tokens": max_tokens,
|
| 75 |
+
"top_p": 0.95,
|
| 76 |
+
"top_k": 40,
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
response = self.model.generate_content(
|
| 80 |
+
prompt,
|
| 81 |
+
generation_config=generation_config,
|
| 82 |
+
safety_settings=self.safety_settings,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
return response.text
|
| 86 |
+
|
| 87 |
+
except Exception as e:
|
| 88 |
+
print(f"Error generating text: {str(e)}", file=sys.stderr)
|
| 89 |
+
raise
|
| 90 |
+
|
| 91 |
+
def enhance_resume_description(self, description: str, role: str = "") -> str:
|
| 92 |
+
"""
|
| 93 |
+
Enhance a resume job description
|
| 94 |
+
|
| 95 |
+
Args:
|
| 96 |
+
description: Original description
|
| 97 |
+
role: Job role/title for context
|
| 98 |
+
|
| 99 |
+
Returns:
|
| 100 |
+
Enhanced description
|
| 101 |
+
"""
|
| 102 |
+
prompt = f"""You are a professional resume writer. Enhance the following job description to be more impactful and achievement-focused.
|
| 103 |
+
|
| 104 |
+
Role: {role}
|
| 105 |
+
Original Description: {description}
|
| 106 |
+
|
| 107 |
+
Requirements:
|
| 108 |
+
- Use strong action verbs
|
| 109 |
+
- Quantify achievements where possible
|
| 110 |
+
- Focus on impact and results
|
| 111 |
+
- Keep it concise (2-3 sentences)
|
| 112 |
+
- Professional tone
|
| 113 |
+
- Do not add fake numbers or achievements
|
| 114 |
+
|
| 115 |
+
Enhanced Description:"""
|
| 116 |
+
|
| 117 |
+
return self.generate_text(prompt, temperature=0.5)
|
| 118 |
+
|
| 119 |
+
def generate_cover_letter(self, data: Dict[str, Any]) -> str:
|
| 120 |
+
"""
|
| 121 |
+
Generate a personalized cover letter
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
data: Dictionary containing:
|
| 125 |
+
- name: Applicant name
|
| 126 |
+
- company: Company name
|
| 127 |
+
- position: Job position
|
| 128 |
+
- skills: List of relevant skills
|
| 129 |
+
- experience: Brief experience summary
|
| 130 |
+
- tone: Tone (formal, creative, technical)
|
| 131 |
+
|
| 132 |
+
Returns:
|
| 133 |
+
Complete cover letter text
|
| 134 |
+
"""
|
| 135 |
+
tone_guides = {
|
| 136 |
+
"formal": "Professional and formal business style",
|
| 137 |
+
"creative": "Engaging and creative while remaining professional",
|
| 138 |
+
"technical": "Technical and detail-oriented style",
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
tone = data.get("tone", "formal")
|
| 142 |
+
tone_guide = tone_guides.get(tone, tone_guides["formal"])
|
| 143 |
+
|
| 144 |
+
prompt = f"""Write a compelling cover letter with the following details:
|
| 145 |
+
|
| 146 |
+
Applicant Name: {data.get("name", "Applicant")}
|
| 147 |
+
Company: {data.get("company", "the company")}
|
| 148 |
+
Position: {data.get("position", "the position")}
|
| 149 |
+
Relevant Skills: {", ".join(data.get("skills", []))}
|
| 150 |
+
Experience Summary: {data.get("experience", "No experience provided")}
|
| 151 |
+
|
| 152 |
+
Tone: {tone_guide}
|
| 153 |
+
|
| 154 |
+
Structure:
|
| 155 |
+
1. Opening paragraph: Show enthusiasm and mention how you learned about the position
|
| 156 |
+
2. Body paragraphs (2-3): Highlight relevant skills and experiences
|
| 157 |
+
3. Closing paragraph: Express interest in an interview and thank them
|
| 158 |
+
|
| 159 |
+
Requirements:
|
| 160 |
+
- Personalized and specific to the role
|
| 161 |
+
- Highlight relevant achievements
|
| 162 |
+
- Professional formatting
|
| 163 |
+
- 3-4 paragraphs
|
| 164 |
+
- Do not include [Date] or address placeholders
|
| 165 |
+
|
| 166 |
+
Cover Letter:"""
|
| 167 |
+
|
| 168 |
+
return self.generate_text(prompt, temperature=0.7, max_tokens=1500)
|
| 169 |
+
|
| 170 |
+
def generate_proposal(self, data: Dict[str, Any]) -> str:
|
| 171 |
+
"""
|
| 172 |
+
Generate a business proposal
|
| 173 |
+
|
| 174 |
+
Args:
|
| 175 |
+
data: Dictionary containing:
|
| 176 |
+
- client_name: Client name
|
| 177 |
+
- project_title: Project title
|
| 178 |
+
- scope: Project scope
|
| 179 |
+
- timeline: Expected timeline
|
| 180 |
+
- budget: Budget estimate (optional)
|
| 181 |
+
- deliverables: List of deliverables
|
| 182 |
+
|
| 183 |
+
Returns:
|
| 184 |
+
Complete proposal text
|
| 185 |
+
"""
|
| 186 |
+
prompt = f"""Create a professional business proposal with the following details:
|
| 187 |
+
|
| 188 |
+
Client: {data.get("client_name", "Client")}
|
| 189 |
+
Project: {data.get("project_title", "Project")}
|
| 190 |
+
Scope: {data.get("scope", "Not specified")}
|
| 191 |
+
Timeline: {data.get("timeline", "To be determined")}
|
| 192 |
+
Budget: {data.get("budget", "To be discussed")}
|
| 193 |
+
Deliverables: {", ".join(data.get("deliverables", []))}
|
| 194 |
+
|
| 195 |
+
Structure:
|
| 196 |
+
1. Executive Summary
|
| 197 |
+
2. Project Overview
|
| 198 |
+
3. Scope of Work
|
| 199 |
+
4. Deliverables
|
| 200 |
+
5. Timeline
|
| 201 |
+
6. Investment (if budget provided)
|
| 202 |
+
7. Next Steps
|
| 203 |
+
|
| 204 |
+
Requirements:
|
| 205 |
+
- Professional and persuasive
|
| 206 |
+
- Clear and specific
|
| 207 |
+
- Well-structured with sections
|
| 208 |
+
- Professional tone
|
| 209 |
+
|
| 210 |
+
Proposal:"""
|
| 211 |
+
|
| 212 |
+
return self.generate_text(prompt, temperature=0.6, max_tokens=2048)
|
| 213 |
+
|
| 214 |
+
def enhance_contract_terms(self, contract_type: str, custom_terms: str = "") -> str:
|
| 215 |
+
"""
|
| 216 |
+
Generate or enhance contract terms
|
| 217 |
+
|
| 218 |
+
Args:
|
| 219 |
+
contract_type: Type of contract (freelance, service, nda, etc.)
|
| 220 |
+
custom_terms: Custom requirements or terms
|
| 221 |
+
|
| 222 |
+
Returns:
|
| 223 |
+
Contract terms text
|
| 224 |
+
"""
|
| 225 |
+
prompt = f"""Generate professional contract terms for a {contract_type} agreement.
|
| 226 |
+
|
| 227 |
+
Custom Requirements: {custom_terms if custom_terms else "Standard terms"}
|
| 228 |
+
|
| 229 |
+
Include:
|
| 230 |
+
1. Scope of Services
|
| 231 |
+
2. Payment Terms
|
| 232 |
+
3. Timeline and Deadlines
|
| 233 |
+
4. Intellectual Property Rights
|
| 234 |
+
5. Confidentiality
|
| 235 |
+
6. Termination Clause
|
| 236 |
+
7. Liability and Warranties
|
| 237 |
+
|
| 238 |
+
Requirements:
|
| 239 |
+
- Professional legal language
|
| 240 |
+
- Clear and specific
|
| 241 |
+
- Balanced for both parties
|
| 242 |
+
- Industry-standard terms
|
| 243 |
+
- Add disclaimer that this should be reviewed by legal counsel
|
| 244 |
+
|
| 245 |
+
Contract Terms:"""
|
| 246 |
+
|
| 247 |
+
return self.generate_text(prompt, temperature=0.4, max_tokens=2048)
|
| 248 |
+
|
| 249 |
+
def enhance_portfolio_description(self, project_data: Dict[str, Any]) -> str:
|
| 250 |
+
"""
|
| 251 |
+
Enhance portfolio project description
|
| 252 |
+
|
| 253 |
+
Args:
|
| 254 |
+
project_data: Dictionary containing:
|
| 255 |
+
- title: Project title
|
| 256 |
+
- description: Current description
|
| 257 |
+
- technologies: List of technologies used
|
| 258 |
+
- role: Your role in the project
|
| 259 |
+
|
| 260 |
+
Returns:
|
| 261 |
+
Enhanced project description
|
| 262 |
+
"""
|
| 263 |
+
prompt = f"""Enhance this portfolio project description to be more compelling and professional:
|
| 264 |
+
|
| 265 |
+
Project: {project_data.get("title", "Project")}
|
| 266 |
+
Current Description: {project_data.get("description", "")}
|
| 267 |
+
Technologies: {", ".join(project_data.get("technologies", []))}
|
| 268 |
+
Role: {project_data.get("role", "Developer")}
|
| 269 |
+
|
| 270 |
+
Requirements:
|
| 271 |
+
- Start with impact/achievement
|
| 272 |
+
- Highlight technical skills
|
| 273 |
+
- Mention problem solved
|
| 274 |
+
- Keep concise (3-4 sentences)
|
| 275 |
+
- Professional and engaging
|
| 276 |
+
|
| 277 |
+
Enhanced Description:"""
|
| 278 |
+
|
| 279 |
+
return self.generate_text(prompt, temperature=0.6)
|
| 280 |
+
|
| 281 |
+
def generate_skills_summary(
|
| 282 |
+
self, skills: List[str], experience_years: int = 0
|
| 283 |
+
) -> str:
|
| 284 |
+
"""
|
| 285 |
+
Generate a professional skills summary
|
| 286 |
+
|
| 287 |
+
Args:
|
| 288 |
+
skills: List of skills
|
| 289 |
+
experience_years: Years of experience
|
| 290 |
+
|
| 291 |
+
Returns:
|
| 292 |
+
Skills summary paragraph
|
| 293 |
+
"""
|
| 294 |
+
prompt = f"""Create a compelling professional summary for someone with:
|
| 295 |
+
|
| 296 |
+
Skills: {", ".join(skills)}
|
| 297 |
+
Years of Experience: {experience_years if experience_years > 0 else "Entry-level"}
|
| 298 |
+
|
| 299 |
+
Requirements:
|
| 300 |
+
- 2-3 sentences
|
| 301 |
+
- Highlight key strengths
|
| 302 |
+
- Professional tone
|
| 303 |
+
- Focus on value proposition
|
| 304 |
+
- Do not exaggerate
|
| 305 |
+
|
| 306 |
+
Professional Summary:"""
|
| 307 |
+
|
| 308 |
+
return self.generate_text(prompt, temperature=0.6, max_tokens=300)
|
| 309 |
+
|
| 310 |
+
def improve_text_quality(self, text: str, style: str = "professional") -> str:
|
| 311 |
+
"""
|
| 312 |
+
General purpose text improvement
|
| 313 |
+
|
| 314 |
+
Args:
|
| 315 |
+
text: Text to improve
|
| 316 |
+
style: Desired style (professional, casual, technical, creative)
|
| 317 |
+
|
| 318 |
+
Returns:
|
| 319 |
+
Improved text
|
| 320 |
+
"""
|
| 321 |
+
prompt = f"""Improve the following text in a {style} style:
|
| 322 |
+
|
| 323 |
+
Original: {text}
|
| 324 |
+
|
| 325 |
+
Requirements:
|
| 326 |
+
- Fix grammar and spelling
|
| 327 |
+
- Improve clarity and flow
|
| 328 |
+
- Maintain original meaning
|
| 329 |
+
- Use appropriate vocabulary
|
| 330 |
+
- Keep similar length
|
| 331 |
+
|
| 332 |
+
Improved Text:"""
|
| 333 |
+
|
| 334 |
+
return self.generate_text(prompt, temperature=0.5)
|
| 335 |
+
|
| 336 |
+
def generate_json_structured(
|
| 337 |
+
self, prompt: str, schema: Dict[str, Any]
|
| 338 |
+
) -> Dict[str, Any]:
|
| 339 |
+
"""
|
| 340 |
+
Generate structured JSON response
|
| 341 |
+
|
| 342 |
+
Args:
|
| 343 |
+
prompt: Prompt for generation
|
| 344 |
+
schema: Expected JSON schema
|
| 345 |
+
|
| 346 |
+
Returns:
|
| 347 |
+
Dictionary with generated data
|
| 348 |
+
"""
|
| 349 |
+
full_prompt = f"""{prompt}
|
| 350 |
+
|
| 351 |
+
Respond with valid JSON matching this structure:
|
| 352 |
+
{json.dumps(schema, indent=2)}
|
| 353 |
+
|
| 354 |
+
JSON Response:"""
|
| 355 |
+
|
| 356 |
+
response_text = self.generate_text(full_prompt, temperature=0.5)
|
| 357 |
+
|
| 358 |
+
try:
|
| 359 |
+
# Extract JSON from response
|
| 360 |
+
if "```json" in response_text:
|
| 361 |
+
json_str = response_text.split("```json")[1].split("```")[0].strip()
|
| 362 |
+
elif "```" in response_text:
|
| 363 |
+
json_str = response_text.split("```")[1].split("```")[0].strip()
|
| 364 |
+
else:
|
| 365 |
+
json_str = response_text.strip()
|
| 366 |
+
|
| 367 |
+
return json.loads(json_str)
|
| 368 |
+
|
| 369 |
+
except Exception as e:
|
| 370 |
+
print(f"Error parsing JSON: {str(e)}", file=sys.stderr)
|
| 371 |
+
return {}
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
# Singleton instance
|
| 375 |
+
_gemini_client = None
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def get_gemini_client() -> GeminiClient:
|
| 379 |
+
"""Get or create Gemini client singleton"""
|
| 380 |
+
global _gemini_client
|
| 381 |
+
if _gemini_client is None:
|
| 382 |
+
_gemini_client = GeminiClient()
|
| 383 |
+
return _gemini_client
|
utils/pdf_generator.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PDF Generator Utility
|
| 3 |
+
Handles creation of PDF documents for portfolio exports
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import io
|
| 7 |
+
import sys
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from typing import Any, Dict, List, Optional
|
| 10 |
+
|
| 11 |
+
from reportlab.lib import colors
|
| 12 |
+
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT, TA_RIGHT
|
| 13 |
+
from reportlab.lib.pagesizes import A4, letter
|
| 14 |
+
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
| 15 |
+
from reportlab.lib.units import inch
|
| 16 |
+
from reportlab.platypus import (
|
| 17 |
+
HRFlowable,
|
| 18 |
+
Image,
|
| 19 |
+
PageBreak,
|
| 20 |
+
Paragraph,
|
| 21 |
+
SimpleDocTemplate,
|
| 22 |
+
Spacer,
|
| 23 |
+
Table,
|
| 24 |
+
TableStyle,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class PDFGenerator:
|
| 29 |
+
"""Generate professional PDF documents"""
|
| 30 |
+
|
| 31 |
+
def __init__(self):
|
| 32 |
+
"""Initialize PDF generator"""
|
| 33 |
+
self.page_size = letter
|
| 34 |
+
self.styles = getSampleStyleSheet()
|
| 35 |
+
self._setup_custom_styles()
|
| 36 |
+
|
| 37 |
+
def _setup_custom_styles(self):
|
| 38 |
+
"""Setup custom paragraph styles"""
|
| 39 |
+
# Title style
|
| 40 |
+
self.styles.add(
|
| 41 |
+
ParagraphStyle(
|
| 42 |
+
name="CustomTitle",
|
| 43 |
+
parent=self.styles["Heading1"],
|
| 44 |
+
fontSize=24,
|
| 45 |
+
textColor=colors.HexColor("#003366"),
|
| 46 |
+
spaceAfter=30,
|
| 47 |
+
alignment=TA_CENTER,
|
| 48 |
+
fontName="Helvetica-Bold",
|
| 49 |
+
)
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# Subtitle style
|
| 53 |
+
self.styles.add(
|
| 54 |
+
ParagraphStyle(
|
| 55 |
+
name="CustomSubtitle",
|
| 56 |
+
parent=self.styles["Normal"],
|
| 57 |
+
fontSize=14,
|
| 58 |
+
textColor=colors.HexColor("#666666"),
|
| 59 |
+
spaceAfter=20,
|
| 60 |
+
alignment=TA_CENTER,
|
| 61 |
+
fontName="Helvetica",
|
| 62 |
+
)
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
# Section heading
|
| 66 |
+
self.styles.add(
|
| 67 |
+
ParagraphStyle(
|
| 68 |
+
name="SectionHeading",
|
| 69 |
+
parent=self.styles["Heading2"],
|
| 70 |
+
fontSize=16,
|
| 71 |
+
textColor=colors.HexColor("#003366"),
|
| 72 |
+
spaceAfter=12,
|
| 73 |
+
spaceBefore=20,
|
| 74 |
+
fontName="Helvetica-Bold",
|
| 75 |
+
borderWidth=1,
|
| 76 |
+
borderColor=colors.HexColor("#003366"),
|
| 77 |
+
borderPadding=5,
|
| 78 |
+
)
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
# Body text
|
| 82 |
+
self.styles.add(
|
| 83 |
+
ParagraphStyle(
|
| 84 |
+
name="CustomBody",
|
| 85 |
+
parent=self.styles["Normal"],
|
| 86 |
+
fontSize=11,
|
| 87 |
+
textColor=colors.HexColor("#333333"),
|
| 88 |
+
spaceAfter=10,
|
| 89 |
+
alignment=TA_JUSTIFY,
|
| 90 |
+
fontName="Helvetica",
|
| 91 |
+
)
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# Contact info
|
| 95 |
+
self.styles.add(
|
| 96 |
+
ParagraphStyle(
|
| 97 |
+
name="ContactInfo",
|
| 98 |
+
parent=self.styles["Normal"],
|
| 99 |
+
fontSize=10,
|
| 100 |
+
textColor=colors.HexColor("#666666"),
|
| 101 |
+
alignment=TA_CENTER,
|
| 102 |
+
fontName="Helvetica",
|
| 103 |
+
)
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
def generate_portfolio_pdf(self, data: Dict[str, Any]) -> io.BytesIO:
|
| 107 |
+
"""
|
| 108 |
+
Generate portfolio PDF
|
| 109 |
+
|
| 110 |
+
Args:
|
| 111 |
+
data: Portfolio data containing:
|
| 112 |
+
- name: Full name
|
| 113 |
+
- title: Professional title
|
| 114 |
+
- bio: Biography
|
| 115 |
+
- contact: Contact info (email, phone, website, linkedin)
|
| 116 |
+
- skills: List or dict of skills
|
| 117 |
+
- experience: List of work experiences
|
| 118 |
+
- education: List of education entries
|
| 119 |
+
- projects: List of projects
|
| 120 |
+
- certifications: List of certifications (optional)
|
| 121 |
+
|
| 122 |
+
Returns:
|
| 123 |
+
BytesIO buffer containing the PDF file
|
| 124 |
+
"""
|
| 125 |
+
buffer = io.BytesIO()
|
| 126 |
+
doc = SimpleDocTemplate(
|
| 127 |
+
buffer,
|
| 128 |
+
pagesize=self.page_size,
|
| 129 |
+
rightMargin=0.75 * inch,
|
| 130 |
+
leftMargin=0.75 * inch,
|
| 131 |
+
topMargin=0.75 * inch,
|
| 132 |
+
bottomMargin=0.75 * inch,
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
# Container for the 'Flowable' objects
|
| 136 |
+
elements = []
|
| 137 |
+
|
| 138 |
+
# Header - Name and Title
|
| 139 |
+
name = data.get("name", "Your Name")
|
| 140 |
+
elements.append(Paragraph(name, self.styles["CustomTitle"]))
|
| 141 |
+
|
| 142 |
+
title = data.get("title", "Professional Title")
|
| 143 |
+
elements.append(Paragraph(title, self.styles["CustomSubtitle"]))
|
| 144 |
+
|
| 145 |
+
# Contact Information
|
| 146 |
+
contact = data.get("contact", {})
|
| 147 |
+
contact_parts = []
|
| 148 |
+
if contact.get("email"):
|
| 149 |
+
contact_parts.append(contact["email"])
|
| 150 |
+
if contact.get("phone"):
|
| 151 |
+
contact_parts.append(contact["phone"])
|
| 152 |
+
if contact.get("website"):
|
| 153 |
+
contact_parts.append(
|
| 154 |
+
f'<link href="{contact["website"]}">{contact["website"]}</link>'
|
| 155 |
+
)
|
| 156 |
+
if contact.get("linkedin"):
|
| 157 |
+
contact_parts.append(f"LinkedIn: {contact['linkedin']}")
|
| 158 |
+
|
| 159 |
+
if contact_parts:
|
| 160 |
+
contact_text = " | ".join(contact_parts)
|
| 161 |
+
elements.append(Paragraph(contact_text, self.styles["ContactInfo"]))
|
| 162 |
+
elements.append(Spacer(1, 0.2 * inch))
|
| 163 |
+
|
| 164 |
+
# Horizontal line
|
| 165 |
+
elements.append(
|
| 166 |
+
HRFlowable(width="100%", thickness=2, color=colors.HexColor("#003366"))
|
| 167 |
+
)
|
| 168 |
+
elements.append(Spacer(1, 0.2 * inch))
|
| 169 |
+
|
| 170 |
+
# Bio/Summary
|
| 171 |
+
if data.get("bio"):
|
| 172 |
+
elements.append(
|
| 173 |
+
Paragraph("PROFESSIONAL SUMMARY", self.styles["SectionHeading"])
|
| 174 |
+
)
|
| 175 |
+
elements.append(Paragraph(data["bio"], self.styles["CustomBody"]))
|
| 176 |
+
elements.append(Spacer(1, 0.2 * inch))
|
| 177 |
+
|
| 178 |
+
# Skills
|
| 179 |
+
if data.get("skills"):
|
| 180 |
+
elements.append(Paragraph("SKILLS", self.styles["SectionHeading"]))
|
| 181 |
+
|
| 182 |
+
skills = data["skills"]
|
| 183 |
+
if isinstance(skills, dict):
|
| 184 |
+
# Categorized skills
|
| 185 |
+
for category, skill_list in skills.items():
|
| 186 |
+
skills_text = f"<b>{category}:</b> {', '.join(skill_list)}"
|
| 187 |
+
elements.append(Paragraph(skills_text, self.styles["CustomBody"]))
|
| 188 |
+
else:
|
| 189 |
+
# Simple list
|
| 190 |
+
skills_text = ", ".join(skills)
|
| 191 |
+
elements.append(Paragraph(skills_text, self.styles["CustomBody"]))
|
| 192 |
+
|
| 193 |
+
elements.append(Spacer(1, 0.2 * inch))
|
| 194 |
+
|
| 195 |
+
# Work Experience
|
| 196 |
+
if data.get("experience"):
|
| 197 |
+
elements.append(Paragraph("WORK EXPERIENCE", self.styles["SectionHeading"]))
|
| 198 |
+
|
| 199 |
+
for exp in data["experience"]:
|
| 200 |
+
# Job title and company
|
| 201 |
+
job_title = exp.get("title", "Position")
|
| 202 |
+
company = exp.get("company", "Company")
|
| 203 |
+
dates = f"{exp.get('start_date', 'Start')} - {exp.get('end_date', 'Present')}"
|
| 204 |
+
|
| 205 |
+
title_text = f"<b>{job_title}</b> at {company}"
|
| 206 |
+
elements.append(Paragraph(title_text, self.styles["CustomBody"]))
|
| 207 |
+
|
| 208 |
+
# Dates and location
|
| 209 |
+
location_text = dates
|
| 210 |
+
if exp.get("location"):
|
| 211 |
+
location_text += f" | {exp['location']}"
|
| 212 |
+
elements.append(
|
| 213 |
+
Paragraph(f"<i>{location_text}</i>", self.styles["CustomBody"])
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
# Responsibilities
|
| 217 |
+
if exp.get("responsibilities"):
|
| 218 |
+
for resp in exp["responsibilities"]:
|
| 219 |
+
elements.append(
|
| 220 |
+
Paragraph(f"• {resp}", self.styles["CustomBody"])
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
elements.append(Spacer(1, 0.15 * inch))
|
| 224 |
+
|
| 225 |
+
# Projects
|
| 226 |
+
if data.get("projects"):
|
| 227 |
+
elements.append(Paragraph("PROJECTS", self.styles["SectionHeading"]))
|
| 228 |
+
|
| 229 |
+
for proj in data["projects"]:
|
| 230 |
+
# Project name
|
| 231 |
+
proj_name = proj.get("name", "Project")
|
| 232 |
+
elements.append(
|
| 233 |
+
Paragraph(f"<b>{proj_name}</b>", self.styles["CustomBody"])
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
# Description
|
| 237 |
+
if proj.get("description"):
|
| 238 |
+
elements.append(
|
| 239 |
+
Paragraph(proj["description"], self.styles["CustomBody"])
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
# Technologies
|
| 243 |
+
if proj.get("technologies"):
|
| 244 |
+
tech_text = (
|
| 245 |
+
f"<i>Technologies: {', '.join(proj['technologies'])}</i>"
|
| 246 |
+
)
|
| 247 |
+
elements.append(Paragraph(tech_text, self.styles["CustomBody"]))
|
| 248 |
+
|
| 249 |
+
# URL
|
| 250 |
+
if proj.get("url"):
|
| 251 |
+
url_text = f'<link href="{proj["url"]}">{proj["url"]}</link>'
|
| 252 |
+
elements.append(Paragraph(url_text, self.styles["CustomBody"]))
|
| 253 |
+
|
| 254 |
+
elements.append(Spacer(1, 0.15 * inch))
|
| 255 |
+
|
| 256 |
+
# Education
|
| 257 |
+
if data.get("education"):
|
| 258 |
+
elements.append(Paragraph("EDUCATION", self.styles["SectionHeading"]))
|
| 259 |
+
|
| 260 |
+
for edu in data["education"]:
|
| 261 |
+
degree = edu.get("degree", "Degree")
|
| 262 |
+
field = edu.get("field", "Field")
|
| 263 |
+
school = edu.get("school", "School")
|
| 264 |
+
grad_date = edu.get("graduation_date", "Graduation Date")
|
| 265 |
+
|
| 266 |
+
edu_text = f"<b>{degree} in {field}</b>"
|
| 267 |
+
elements.append(Paragraph(edu_text, self.styles["CustomBody"]))
|
| 268 |
+
|
| 269 |
+
school_text = f"{school} | {grad_date}"
|
| 270 |
+
elements.append(
|
| 271 |
+
Paragraph(f"<i>{school_text}</i>", self.styles["CustomBody"])
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
# GPA or honors
|
| 275 |
+
if edu.get("gpa"):
|
| 276 |
+
elements.append(
|
| 277 |
+
Paragraph(f"GPA: {edu['gpa']}", self.styles["CustomBody"])
|
| 278 |
+
)
|
| 279 |
+
if edu.get("honors"):
|
| 280 |
+
elements.append(Paragraph(edu["honors"], self.styles["CustomBody"]))
|
| 281 |
+
|
| 282 |
+
elements.append(Spacer(1, 0.15 * inch))
|
| 283 |
+
|
| 284 |
+
# Certifications
|
| 285 |
+
if data.get("certifications"):
|
| 286 |
+
elements.append(Paragraph("CERTIFICATIONS", self.styles["SectionHeading"]))
|
| 287 |
+
|
| 288 |
+
for cert in data["certifications"]:
|
| 289 |
+
cert_name = cert.get("name", "Certification")
|
| 290 |
+
issuer = cert.get("issuer", "Issuer")
|
| 291 |
+
cert_date = cert.get("date", "")
|
| 292 |
+
|
| 293 |
+
cert_text = f"• <b>{cert_name}</b> - {issuer}"
|
| 294 |
+
if cert_date:
|
| 295 |
+
cert_text += f" ({cert_date})"
|
| 296 |
+
|
| 297 |
+
elements.append(Paragraph(cert_text, self.styles["CustomBody"]))
|
| 298 |
+
|
| 299 |
+
# Footer
|
| 300 |
+
elements.append(Spacer(1, 0.5 * inch))
|
| 301 |
+
elements.append(
|
| 302 |
+
HRFlowable(width="100%", thickness=1, color=colors.HexColor("#CCCCCC"))
|
| 303 |
+
)
|
| 304 |
+
footer_text = f"Generated on {datetime.now().strftime('%B %d, %Y')}"
|
| 305 |
+
elements.append(Paragraph(footer_text, self.styles["ContactInfo"]))
|
| 306 |
+
|
| 307 |
+
# Build PDF
|
| 308 |
+
doc.build(elements)
|
| 309 |
+
buffer.seek(0)
|
| 310 |
+
|
| 311 |
+
return buffer
|
| 312 |
+
|
| 313 |
+
def generate_simple_pdf(self, content: str, title: str = "Document") -> io.BytesIO:
|
| 314 |
+
"""
|
| 315 |
+
Generate a simple PDF from text content
|
| 316 |
+
|
| 317 |
+
Args:
|
| 318 |
+
content: Text content
|
| 319 |
+
title: Document title
|
| 320 |
+
|
| 321 |
+
Returns:
|
| 322 |
+
BytesIO buffer containing the PDF file
|
| 323 |
+
"""
|
| 324 |
+
buffer = io.BytesIO()
|
| 325 |
+
doc = SimpleDocTemplate(
|
| 326 |
+
buffer,
|
| 327 |
+
pagesize=self.page_size,
|
| 328 |
+
rightMargin=inch,
|
| 329 |
+
leftMargin=inch,
|
| 330 |
+
topMargin=inch,
|
| 331 |
+
bottomMargin=inch,
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
elements = []
|
| 335 |
+
|
| 336 |
+
# Title
|
| 337 |
+
elements.append(Paragraph(title, self.styles["CustomTitle"]))
|
| 338 |
+
elements.append(Spacer(1, 0.3 * inch))
|
| 339 |
+
|
| 340 |
+
# Content
|
| 341 |
+
paragraphs = content.split("\n\n")
|
| 342 |
+
for para in paragraphs:
|
| 343 |
+
if para.strip():
|
| 344 |
+
elements.append(Paragraph(para.strip(), self.styles["CustomBody"]))
|
| 345 |
+
elements.append(Spacer(1, 0.1 * inch))
|
| 346 |
+
|
| 347 |
+
# Build PDF
|
| 348 |
+
doc.build(elements)
|
| 349 |
+
buffer.seek(0)
|
| 350 |
+
|
| 351 |
+
return buffer
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
# Singleton instance
|
| 355 |
+
_pdf_generator = None
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def get_pdf_generator() -> PDFGenerator:
|
| 359 |
+
"""Get or create PDFGenerator singleton"""
|
| 360 |
+
global _pdf_generator
|
| 361 |
+
if _pdf_generator is None:
|
| 362 |
+
_pdf_generator = PDFGenerator()
|
| 363 |
+
return _pdf_generator
|