export_coaching_blocks.py
1 """Build Notion block dicts from coaching pipeline output. 2 3 Pure domain logic -- no I/O, no SDK imports. Transforms coaching models into 4 Notion block structures ready for NotionClient.append_blocks(). 5 """ 6 7 __all__ = ["CoachingBlocks", "build_coaching_blocks"] 8 9 from dataclasses import dataclass 10 from typing import Any 11 12 from models.coaching import ( 13 AnalyzedRequirement, 14 RequirementPriority, 15 TailoredExperience, 16 TailoredSkills, 17 TailoredSummary, 18 ) 19 from services.notion_blocks import ( 20 bulleted_list_item, 21 callout, 22 divider, 23 heading, 24 paragraph, 25 text, 26 toggle, 27 ) 28 29 # Type alias for section builder return: (blocks, labels). 30 # Labels use indices relative to 0 within the section. 31 _SectionResult = tuple[list[dict[str, Any]], dict[str, int]] 32 33 _STATUS_ORDER: dict[str, int] = {"MATCH": 0, "INFERRED": 1, "MISSING": 2} 34 35 _STATUS_CONFIG: dict[str, dict[str, str]] = { 36 "MATCH": {"icon": "\u2705", "color": "green"}, 37 "INFERRED": {"icon": "\u26a0\ufe0f", "color": "yellow"}, 38 "MISSING": {"icon": "\u274c", "color": "red"}, 39 } 40 41 _PRIORITY_CONFIG: dict[RequirementPriority, dict[str, str]] = { 42 "MUST_HAVE": { 43 "label": "Must-Have Requirements", 44 "icon": "\U0001f534", 45 "color": "red_background", 46 }, 47 "CORE": { 48 "label": "Core Requirements", 49 "icon": "\U0001f7e1", 50 "color": "yellow_background", 51 }, 52 "NICE_TO_HAVE": { 53 "label": "Nice-to-Have Requirements", 54 "icon": "\U0001f7e2", 55 "color": "green_background", 56 }, 57 } 58 59 _PRIORITY_ORDER: list[RequirementPriority] = ["MUST_HAVE", "CORE", "NICE_TO_HAVE"] 60 61 _SUMMARY_CALLOUT_ICON = "\u2728" 62 63 _RELEVANCE_HIGH_THRESHOLD = 7 # 7-10: strong match, rendered green 64 _RELEVANCE_MID_THRESHOLD = 4 # 4-6: partial match, rendered yellow; <4 is red 65 _RELEVANCE_BOLD_THRESHOLD = 8 # skills at 8+ are bolded to surface strongest matches 66 67 68 def _relevance_color(score: int) -> str: 69 """Map relevance score to a 3-tier Notion text color.""" 70 if score >= _RELEVANCE_HIGH_THRESHOLD: 71 return "green" 72 if score >= _RELEVANCE_MID_THRESHOLD: 73 return "yellow" 74 return "red" 75 76 77 def _relevance_icon(score: int) -> str: 78 """Map relevance score to a 3-tier colored circle emoji.""" 79 if score >= _RELEVANCE_HIGH_THRESHOLD: 80 return "\U0001f7e2" 81 if score >= _RELEVANCE_MID_THRESHOLD: 82 return "\U0001f7e1" 83 return "\U0001f534" 84 85 _SECTION_TITLES: dict[str, str] = { 86 "summary": "Summary Evolution", 87 "gap": "Gap Analysis", 88 "experience": "Experience Audit", 89 "skills": "Skills Audit", 90 } 91 92 93 @dataclass(frozen=True) 94 class CoachingBlocks: 95 """Coaching report blocks with a label index for future comment retrieval. 96 97 Args: 98 blocks: Flat list of Notion block dicts, ready for append_blocks(). 99 label_index: Maps a semantic label to its index in the top-level blocks 100 list. After append_blocks() returns block IDs, callers can build 101 {label: block_id} by looking up label_index values in the IDs list. 102 103 Note: only top-level blocks are labelled. Nested children (achievements 104 inside experience toggles, requirement details inside gap toggles, excluded 105 skills inside the skills toggle, original summary inside its toggle) are 106 NOT in this index because Notion's append API only returns IDs for 107 top-level blocks. To resolve nested block IDs for comment retrieval, 108 call blocks.children.list(block_id=parent_toggle_id) and match by 109 position (child ordering is deterministic). 110 """ 111 112 blocks: list[dict[str, Any]] 113 label_index: dict[str, int] 114 115 116 def build_coaching_blocks( 117 analyzed: list[AnalyzedRequirement], 118 tailored_summary: TailoredSummary, 119 original_headline: str, 120 original_summary: str, 121 experiences: list[TailoredExperience], 122 skills: TailoredSkills | None, 123 ) -> CoachingBlocks: 124 """Build Notion block dicts for a coaching report. 125 126 Args: 127 analyzed: Gap analysis results (sorted by priority then extraction order). 128 tailored_summary: Tailored headline and summary from the tailor phase. 129 original_headline: CandidateIdentity.title (pre-tailoring baseline). 130 original_summary: CandidateIdentity.summary (pre-tailoring baseline). 131 experiences: Tailored experience audit results (one per experience). 132 skills: Tailored skills audit, or None if skills phase was skipped/failed. 133 134 Returns: 135 CoachingBlocks with the block list and a label-to-index mapping. 136 """ 137 all_blocks: list[dict[str, Any]] = [] 138 label_index: dict[str, int] = {} 139 140 for section_blocks, section_labels in [ 141 _summary_evolution_blocks(tailored_summary, original_headline, original_summary), 142 _gap_analysis_blocks(analyzed), 143 _experience_audit_blocks(experiences), 144 ]: 145 offset = len(all_blocks) 146 all_blocks.extend(section_blocks) 147 for label, idx in section_labels.items(): 148 label_index[label] = offset + idx 149 150 if skills is not None: 151 sk_blocks, sk_labels = _skills_audit_blocks(skills) 152 offset = len(all_blocks) 153 all_blocks.extend(sk_blocks) 154 for label, idx in sk_labels.items(): 155 label_index[label] = offset + idx 156 157 return CoachingBlocks(blocks=all_blocks, label_index=label_index) 158 159 160 # ── Section builders ───────────────────────────────────────────────────────── 161 162 163 def _summary_evolution_blocks( 164 tailored: TailoredSummary, 165 original_headline: str, 166 original_summary: str, 167 ) -> _SectionResult: 168 """Build blocks for the Summary Evolution section.""" 169 blocks: list[dict[str, Any]] = [] 170 labels: dict[str, int] = {} 171 172 labels["section:summary"] = len(blocks) 173 blocks.append(heading(1, _SECTION_TITLES["summary"])) 174 175 labels["summary:headline"] = len(blocks) 176 blocks.append( 177 callout( 178 [text("Tailored Headline", bold=True)], 179 icon_emoji="\u270d\ufe0f", 180 color="green_background", 181 ) 182 ) 183 blocks.append( 184 paragraph([ 185 text(original_headline, strikethrough=True, color="gray"), 186 text(" -> "), 187 text(tailored.headline, bold=True, color="green"), 188 ]) 189 ) 190 191 labels["summary:tailored"] = len(blocks) 192 blocks.append( 193 callout( 194 [text("Tailored Summary", bold=True)], 195 icon_emoji=_SUMMARY_CALLOUT_ICON, 196 color="green_background", 197 ) 198 ) 199 blocks.append(paragraph([text(tailored.summary)])) 200 201 labels["summary:original"] = len(blocks) 202 blocks.append( 203 toggle( 204 [text("Original Summary", italic=True, color="gray")], 205 [paragraph([text(original_summary)])], 206 ) 207 ) 208 209 naturalness_color = _relevance_color(tailored.naturalness_score) 210 labels["summary:rationale"] = len(blocks) 211 blocks.append( 212 toggle( 213 [text("Coach Rationale", italic=True, color="gray")], 214 [ 215 paragraph([text(tailored.coach_rationale, italic=True)]), 216 paragraph([ 217 text("Naturalness: ", bold=True), 218 text(f"{tailored.naturalness_score}/10", color=naturalness_color), 219 ]), 220 ], 221 ) 222 ) 223 224 blocks.append(divider()) 225 226 return blocks, labels 227 228 229 def _gap_analysis_blocks(analyzed: list[AnalyzedRequirement]) -> _SectionResult: 230 """Build blocks for the Gap Analysis section.""" 231 blocks: list[dict[str, Any]] = [] 232 labels: dict[str, int] = {} 233 234 labels["section:gap"] = len(blocks) 235 blocks.append(heading(1, _SECTION_TITLES["gap"])) 236 237 # Group by priority. 238 grouped: dict[RequirementPriority, list[AnalyzedRequirement]] = { 239 p: [] for p in _PRIORITY_ORDER 240 } 241 for req in analyzed: 242 grouped[req.priority].append(req) 243 244 for priority in _PRIORITY_ORDER: 245 reqs = grouped[priority] 246 if not reqs: 247 continue 248 249 pcfg = _PRIORITY_CONFIG[priority] 250 blocks.append( 251 callout( 252 [text(pcfg["label"], bold=True)], 253 icon_emoji=pcfg["icon"], 254 color=pcfg["color"], 255 ) 256 ) 257 258 # Sort by status within group. 259 reqs_sorted = sorted(reqs, key=lambda r: _STATUS_ORDER.get(r.status, 3)) 260 261 for idx, req in enumerate(reqs_sorted): 262 cfg = _STATUS_CONFIG.get(req.status, {"icon": "[?]", "color": "default"}) 263 children = _gap_requirement_children(req) 264 265 labels[f"gap:{priority.lower()}:{idx}"] = len(blocks) 266 blocks.append( 267 toggle( 268 [ 269 text(f"{cfg['icon']} {req.status} ", bold=True, color=cfg["color"]), 270 text(f"\u2014 {req.text}"), 271 ], 272 children, 273 ) 274 ) 275 276 blocks.append(divider()) 277 278 return blocks, labels 279 280 281 def _gap_requirement_children(req: AnalyzedRequirement) -> list[dict[str, Any]]: 282 """Build toggle children for a single gap requirement.""" 283 children: list[dict[str, Any]] = [] 284 285 action_label = req.action.replace("_", " ") 286 children.append(paragraph([text(f"\U0001f3af Action: {action_label}", bold=True)])) 287 288 if req.target_section is not None: 289 children.append( 290 bulleted_list_item([ 291 text("Target section: ", bold=True), 292 text(req.target_section, code=True), 293 ]) 294 ) 295 if req.cv_suggestion is not None: 296 children.append( 297 bulleted_list_item([text("Suggested edit: ", bold=True), text(req.cv_suggestion)]) 298 ) 299 if req.keywords_to_add: 300 children.append( 301 bulleted_list_item([ 302 text("Keywords to add: ", bold=True), 303 text(", ".join(req.keywords_to_add), code=True), 304 ]) 305 ) 306 if req.cover_letter_suggestion is not None: 307 children.append( 308 bulleted_list_item([ 309 text("Cover letter talking point: ", bold=True), 310 text(req.cover_letter_suggestion), 311 ]) 312 ) 313 children.append( 314 toggle( 315 [text("Reasoning", italic=True, color="gray")], 316 [paragraph([text(req.gap_reasoning, italic=True)])], 317 ) 318 ) 319 320 return children 321 322 323 def _experience_audit_blocks( 324 experiences: list[TailoredExperience], 325 ) -> _SectionResult: 326 """Build blocks for the Experience Audit section.""" 327 blocks: list[dict[str, Any]] = [] 328 labels: dict[str, int] = {} 329 330 labels["section:experience"] = len(blocks) 331 blocks.append(heading(1, _SECTION_TITLES["experience"])) 332 333 for exp in experiences: 334 labels[f"exp:{exp.experience_id}"] = len(blocks) 335 blocks.append( 336 callout( 337 [text(f"{exp.company} -- {exp.role}", bold=True)], 338 icon_emoji="\U0001f4bc", 339 color="blue_background", 340 ) 341 ) 342 343 if exp.kept_achievements: 344 sorted_kept = sorted( 345 exp.kept_achievements, key=lambda a: a.relevance_score, reverse=True 346 ) 347 kept_bullets: list[dict[str, Any]] = [] 348 for ach in sorted_kept: 349 icon = _relevance_icon(ach.relevance_score) 350 kept_bullets.append( 351 bulleted_list_item([ 352 text(f"{icon} {ach.text}", bold=True), 353 text(f"\n -> {ach.reason}", italic=True, color="gray"), 354 ]) 355 ) 356 blocks.append( 357 toggle( 358 [text(f"\u2705 Kept Achievements ({len(exp.kept_achievements)})", bold=True)], 359 kept_bullets, 360 ) 361 ) 362 363 if exp.excluded_achievements: 364 excluded_bullets: list[dict[str, Any]] = [] 365 for ach in exp.excluded_achievements: 366 icon = _relevance_icon(ach.relevance_score) 367 excluded_bullets.append( 368 bulleted_list_item([ 369 text(f"{icon} {ach.text}", bold=True), 370 text(f"\n -> {ach.reason}", italic=True, color="gray"), 371 ]) 372 ) 373 blocks.append( 374 toggle( 375 [text( 376 "\U0001f5d1\ufe0f Excluded Achievements" 377 f" ({len(exp.excluded_achievements)})", 378 bold=True, color="gray", 379 )], 380 excluded_bullets, 381 ) 382 ) 383 384 blocks.append(divider()) 385 386 return blocks, labels 387 388 389 def _skills_audit_blocks(skills: TailoredSkills) -> _SectionResult: 390 """Build blocks for the Skills Audit section.""" 391 blocks: list[dict[str, Any]] = [] 392 labels: dict[str, int] = {} 393 394 labels["section:skills"] = len(blocks) 395 blocks.append(heading(1, _SECTION_TITLES["skills"])) 396 397 # Kept and required toggles are always emitted (even with 0 items) so the 398 # reader sees "(0)" as explicit signal, unlike experience toggles which are 399 # omitted when empty. toggle() omits the children key when the list is empty. 400 kept_bullets: list[dict[str, Any]] = [] 401 for skill in skills.kept_skills: 402 icon = _relevance_icon(skill.relevance_score) 403 kept_bullets.append( 404 bulleted_list_item([ 405 text( 406 f"{icon} {skill.skill}", 407 bold=skill.relevance_score >= _RELEVANCE_BOLD_THRESHOLD, 408 ), 409 text(f"\n -> {skill.reason}", italic=True, color="gray"), 410 ]) 411 ) 412 labels["skill:kept"] = len(blocks) 413 blocks.append( 414 toggle( 415 [text(f"\u2705 Kept Skills ({len(skills.kept_skills)})", bold=True)], 416 kept_bullets, 417 ) 418 ) 419 420 # Required but missing skills. 421 required_bullets: list[dict[str, Any]] = [] 422 for skill in skills.required_skills: 423 required_bullets.append( 424 bulleted_list_item([ 425 text(skill.skill, bold=True, color="red"), 426 text(f"\n -> {skill.reason}", italic=True, color="gray"), 427 ]) 428 ) 429 labels["skill:required"] = len(blocks) 430 blocks.append( 431 toggle( 432 [text(f"\u274c Required but Missing ({len(skills.required_skills)})", bold=True)], 433 required_bullets, 434 ) 435 ) 436 437 # Excluded skills. 438 if skills.excluded_skills: 439 excluded_bullets: list[dict[str, Any]] = [] 440 for skill in skills.excluded_skills: 441 excluded_bullets.append( 442 bulleted_list_item([ 443 text(f"\u274c {skill.skill}"), 444 text(f"\n -> {skill.reason}", italic=True, color="gray"), 445 ]) 446 ) 447 labels["skill:excluded"] = len(blocks) 448 blocks.append( 449 toggle( 450 [text( 451 f"\U0001f5d1\ufe0f Excluded Skills ({len(skills.excluded_skills)})", 452 bold=True, color="gray", 453 )], 454 excluded_bullets, 455 ) 456 ) 457 458 return blocks, labels