/ services / export_coaching_blocks.py
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