@laitszkin/apollo-toolkit 2.8.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +17 -0
  3. package/README.md +2 -0
  4. package/document-vision-reader/LICENSE +21 -0
  5. package/document-vision-reader/README.md +66 -0
  6. package/document-vision-reader/SKILL.md +151 -0
  7. package/document-vision-reader/agents/openai.yaml +4 -0
  8. package/document-vision-reader/references/legibility-checklist.md +13 -0
  9. package/document-vision-reader/references/rendering-guide.md +37 -0
  10. package/katex/SKILL.md +92 -0
  11. package/katex/agents/openai.yaml +4 -0
  12. package/katex/references/insertion-patterns.md +54 -0
  13. package/katex/references/official-docs.md +35 -0
  14. package/katex/scripts/render_katex.py +247 -0
  15. package/katex/scripts/render_katex.sh +11 -0
  16. package/learning-error-book/SKILL.md +46 -31
  17. package/learning-error-book/agents/openai.yaml +2 -2
  18. package/learning-error-book/assets/long_question_reference_template.json +57 -0
  19. package/learning-error-book/assets/mc_question_reference_template.json +49 -0
  20. package/learning-error-book/scripts/render_error_book_json_to_pdf.py +590 -0
  21. package/open-github-issue/README.md +7 -1
  22. package/open-github-issue/SKILL.md +10 -3
  23. package/open-github-issue/scripts/open_github_issue.py +25 -0
  24. package/open-github-issue/tests/test_open_github_issue.py +49 -1
  25. package/package.json +1 -1
  26. package/scheduled-runtime-health-check/README.md +26 -15
  27. package/scheduled-runtime-health-check/SKILL.md +70 -53
  28. package/scheduled-runtime-health-check/agents/openai.yaml +2 -2
  29. package/learning-error-book/assets/error_book_template.md +0 -66
  30. package/learning-error-book/scripts/render_markdown_to_pdf.py +0 -367
@@ -0,0 +1,590 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ from typing import Any, List, Optional, Sequence, Tuple
8
+ from xml.sax.saxutils import escape
9
+
10
+ from reportlab.lib import colors
11
+ from reportlab.lib.pagesizes import A4, letter
12
+ from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
13
+ from reportlab.lib.units import mm
14
+ from reportlab.pdfbase import pdfmetrics
15
+ from reportlab.pdfbase.ttfonts import TTFont
16
+ from reportlab.platypus import (
17
+ HRFlowable,
18
+ PageBreak,
19
+ Paragraph,
20
+ SimpleDocTemplate,
21
+ Spacer,
22
+ Table,
23
+ TableStyle,
24
+ )
25
+
26
+
27
+ THEME = {
28
+ "ink": colors.HexColor("#1F2937"),
29
+ "muted": colors.HexColor("#6B7280"),
30
+ "line": colors.HexColor("#D1D5DB"),
31
+ "panel": colors.HexColor("#F8FAFC"),
32
+ "panel_alt": colors.HexColor("#EEF2FF"),
33
+ "accent": colors.HexColor("#0F766E"),
34
+ "warning": colors.HexColor("#9A3412"),
35
+ "warning_soft": colors.HexColor("#FFEDD5"),
36
+ }
37
+
38
+
39
+ def _read_json(path: str) -> Any:
40
+ with open(path, "r", encoding="utf-8") as f:
41
+ return json.load(f)
42
+
43
+
44
+ def _ensure_parent_dir(path: str) -> None:
45
+ parent = os.path.dirname(os.path.abspath(path))
46
+ if parent:
47
+ os.makedirs(parent, exist_ok=True)
48
+
49
+
50
+ def _detect_cjk_font_path(user_font_path: Optional[str]) -> Tuple[str, int]:
51
+ if user_font_path:
52
+ return user_font_path, 0
53
+
54
+ candidates = [
55
+ "/System/Library/Fonts/PingFang.ttc",
56
+ "/System/Library/Fonts/STHeiti Light.ttc",
57
+ "/System/Library/Fonts/STHeiti Medium.ttc",
58
+ "/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc",
59
+ "/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc",
60
+ "/System/Library/Fonts/ヒラギノ角ゴシック W5.ttc",
61
+ "/System/Library/Fonts/ヒラギノ明朝 ProN.ttc",
62
+ "/Library/Fonts/Arial Unicode.ttf",
63
+ ]
64
+ for path in candidates:
65
+ if os.path.exists(path):
66
+ return path, 0
67
+ raise SystemExit("No CJK font found. Re-run with --font-path pointing to a .ttf/.otf/.ttc font file.")
68
+
69
+
70
+ def _register_font(font_path: str, subfont_index: int) -> str:
71
+ font_name = "ErrorBookBodyFont"
72
+ if font_name not in pdfmetrics.getRegisteredFontNames():
73
+ pdfmetrics.registerFont(TTFont(font_name, font_path, subfontIndex=subfont_index))
74
+ return font_name
75
+
76
+
77
+ def _safe_text(value: Any, default: str = "-") -> str:
78
+ if value is None:
79
+ return default
80
+ if isinstance(value, str):
81
+ stripped = value.strip()
82
+ return stripped if stripped else default
83
+ if isinstance(value, (int, float)):
84
+ return str(value)
85
+ if isinstance(value, list):
86
+ parts = [_safe_text(item, default="") for item in value]
87
+ joined = ", ".join(part for part in parts if part)
88
+ return joined if joined else default
89
+ return str(value)
90
+
91
+
92
+ def _markup(value: Any, default: str = "-") -> str:
93
+ return escape(_safe_text(value, default=default)).replace("\n", "<br/>")
94
+
95
+
96
+ def _paragraph(text: Any, style: ParagraphStyle, default: str = "-") -> Paragraph:
97
+ return Paragraph(_markup(text, default=default), style)
98
+
99
+
100
+ def _bullet_lines(items: Sequence[Any], styles: dict, empty_text: str = "-") -> List[Any]:
101
+ values = list(items or [])
102
+ if not values:
103
+ return [_paragraph(empty_text, styles["body"])]
104
+ return [Paragraph(f"- {_markup(item)}", styles["bullet"]) for item in values]
105
+
106
+
107
+ def _bullet_paragraph(items: Sequence[Any], style: ParagraphStyle, empty_text: str = "-") -> Paragraph:
108
+ values = list(items or [])
109
+ if not values:
110
+ return Paragraph(_markup(empty_text), style)
111
+ lines = "<br/>".join(f"- {_markup(item)}" for item in values)
112
+ return Paragraph(lines, style)
113
+
114
+
115
+ def _table(data: List[List[Any]], col_widths: Sequence[float], table_style: TableStyle) -> Table:
116
+ table = Table(data, colWidths=list(col_widths), repeatRows=1)
117
+ table.setStyle(table_style)
118
+ return table
119
+
120
+
121
+ def _build_styles(font_name: str, font_size: int) -> dict:
122
+ styles = getSampleStyleSheet()
123
+ return {
124
+ "title": ParagraphStyle(
125
+ "ErrorBookTitle",
126
+ parent=styles["Title"],
127
+ fontName=font_name,
128
+ fontSize=24,
129
+ leading=30,
130
+ textColor=THEME["ink"],
131
+ spaceAfter=10,
132
+ ),
133
+ "subtitle": ParagraphStyle(
134
+ "ErrorBookSubtitle",
135
+ parent=styles["BodyText"],
136
+ fontName=font_name,
137
+ fontSize=11,
138
+ leading=15,
139
+ textColor=THEME["muted"],
140
+ spaceAfter=8,
141
+ ),
142
+ "section": ParagraphStyle(
143
+ "ErrorBookSection",
144
+ parent=styles["Heading1"],
145
+ fontName=font_name,
146
+ fontSize=16,
147
+ leading=20,
148
+ textColor=THEME["accent"],
149
+ spaceBefore=8,
150
+ spaceAfter=6,
151
+ ),
152
+ "question": ParagraphStyle(
153
+ "ErrorBookQuestion",
154
+ parent=styles["Heading2"],
155
+ fontName=font_name,
156
+ fontSize=14,
157
+ leading=18,
158
+ textColor=THEME["ink"],
159
+ spaceAfter=4,
160
+ ),
161
+ "subhead": ParagraphStyle(
162
+ "ErrorBookSubhead",
163
+ parent=styles["Heading3"],
164
+ fontName=font_name,
165
+ fontSize=11,
166
+ leading=14,
167
+ textColor=THEME["ink"],
168
+ spaceBefore=4,
169
+ spaceAfter=2,
170
+ ),
171
+ "body": ParagraphStyle(
172
+ "ErrorBookBody",
173
+ parent=styles["BodyText"],
174
+ fontName=font_name,
175
+ fontSize=font_size,
176
+ leading=int(font_size * 1.55),
177
+ textColor=THEME["ink"],
178
+ spaceAfter=4,
179
+ ),
180
+ "bullet": ParagraphStyle(
181
+ "ErrorBookBullet",
182
+ parent=styles["BodyText"],
183
+ fontName=font_name,
184
+ fontSize=font_size,
185
+ leading=int(font_size * 1.5),
186
+ textColor=THEME["ink"],
187
+ leftIndent=10,
188
+ spaceAfter=3,
189
+ ),
190
+ "meta": ParagraphStyle(
191
+ "ErrorBookMeta",
192
+ parent=styles["BodyText"],
193
+ fontName=font_name,
194
+ fontSize=10,
195
+ leading=13,
196
+ textColor=THEME["muted"],
197
+ ),
198
+ "label": ParagraphStyle(
199
+ "ErrorBookLabel",
200
+ parent=styles["BodyText"],
201
+ fontName=font_name,
202
+ fontSize=10,
203
+ leading=13,
204
+ textColor=THEME["accent"],
205
+ ),
206
+ "table": ParagraphStyle(
207
+ "ErrorBookTable",
208
+ parent=styles["BodyText"],
209
+ fontName=font_name,
210
+ fontSize=9,
211
+ leading=12,
212
+ textColor=THEME["ink"],
213
+ ),
214
+ "table_head": ParagraphStyle(
215
+ "ErrorBookTableHead",
216
+ parent=styles["BodyText"],
217
+ fontName=font_name,
218
+ fontSize=9,
219
+ leading=12,
220
+ textColor=colors.white,
221
+ ),
222
+ "callout": ParagraphStyle(
223
+ "ErrorBookCallout",
224
+ parent=styles["BodyText"],
225
+ fontName=font_name,
226
+ fontSize=10,
227
+ leading=14,
228
+ textColor=THEME["warning"],
229
+ ),
230
+ }
231
+
232
+
233
+ def _header_footer(canvas, doc) -> None:
234
+ canvas.saveState()
235
+ canvas.setFont("Helvetica", 9)
236
+ canvas.setFillColor(THEME["muted"])
237
+ canvas.drawString(doc.leftMargin, doc.height + doc.topMargin + 8, doc.title)
238
+ canvas.drawRightString(doc.pagesize[0] - doc.rightMargin, 12, f"Page {canvas.getPageNumber()}")
239
+ canvas.restoreState()
240
+
241
+
242
+ def _section_heading(text: str, styles: dict) -> List[Any]:
243
+ return [Spacer(1, 6), Paragraph(_markup(text), styles["section"]), HRFlowable(color=THEME["line"], thickness=1, width="100%"), Spacer(1, 6)]
244
+
245
+
246
+ def _coverage_story(data: dict, styles: dict, width: float) -> List[Any]:
247
+ rows: List[List[Any]] = [[
248
+ Paragraph("Source", styles["table_head"]),
249
+ Paragraph("Questions", styles["table_head"]),
250
+ Paragraph("Notes", styles["table_head"]),
251
+ ]]
252
+ for item in data.get("coverage_scope", []):
253
+ rows.append([
254
+ _paragraph(item.get("source_path"), styles["table"]),
255
+ _paragraph(item.get("included_questions", []), styles["table"]),
256
+ _paragraph(item.get("notes"), styles["table"]),
257
+ ])
258
+ if len(rows) == 1:
259
+ rows.append([_paragraph("-", styles["table"]), _paragraph("-", styles["table"]), _paragraph("-", styles["table"])])
260
+
261
+ style = TableStyle([
262
+ ("BACKGROUND", (0, 0), (-1, 0), THEME["accent"]),
263
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
264
+ ("FONTNAME", (0, 0), (-1, -1), styles["table"].fontName),
265
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
266
+ ("GRID", (0, 0), (-1, -1), 0.6, THEME["line"]),
267
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, THEME["panel"]]),
268
+ ("LEFTPADDING", (0, 0), (-1, -1), 8),
269
+ ("RIGHTPADDING", (0, 0), (-1, -1), 8),
270
+ ("TOPPADDING", (0, 0), (-1, -1), 6),
271
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
272
+ ])
273
+ return _section_heading("Coverage Scope", styles) + [_table(rows, [width * 0.36, width * 0.24, width * 0.40], style)]
274
+
275
+
276
+ def _overview_story(data: dict, styles: dict) -> List[Any]:
277
+ story = _section_heading("Common Mistake Types Overview", styles)
278
+ entries = data.get("mistake_overview", []) or []
279
+ if not entries:
280
+ return story + [_paragraph("No mistake overview provided.", styles["body"])]
281
+
282
+ for entry in entries:
283
+ box = [
284
+ Paragraph(_markup(entry.get("type")), styles["subhead"]),
285
+ _paragraph(entry.get("summary"), styles["body"]),
286
+ _paragraph(f"Representative questions: {_safe_text(entry.get('representative_questions', []))}", styles["meta"]),
287
+ ]
288
+ story.append(
289
+ Table(
290
+ [[box]],
291
+ colWidths=[None],
292
+ style=TableStyle([
293
+ ("BACKGROUND", (0, 0), (-1, -1), THEME["panel"]),
294
+ ("BOX", (0, 0), (-1, -1), 0.8, THEME["line"]),
295
+ ("LEFTPADDING", (0, 0), (-1, -1), 10),
296
+ ("RIGHTPADDING", (0, 0), (-1, -1), 10),
297
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
298
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
299
+ ]),
300
+ )
301
+ )
302
+ story.append(Spacer(1, 6))
303
+ return story
304
+
305
+
306
+ def _concept_story(data: dict, styles: dict, width: float) -> List[Any]:
307
+ story = _section_heading("Conceptual Mistake Highlights", styles)
308
+ concepts = data.get("concept_highlights", []) or []
309
+ if not concepts:
310
+ return story + [_paragraph("No concept highlights provided.", styles["body"])]
311
+
312
+ for concept in concepts:
313
+ rows = [
314
+ [_paragraph("Definition", styles["label"]), _paragraph(concept.get("definition"), styles["body"])],
315
+ [_paragraph("Common misjudgment", styles["label"]), _paragraph(concept.get("common_misjudgment"), styles["body"])],
316
+ [_paragraph("Checklist", styles["label"]), _bullet_paragraph(concept.get("checklist", []), styles["body"])],
317
+ ]
318
+ story.append(Paragraph(_markup(concept.get("name"), default="Unnamed concept"), styles["question"]))
319
+ story.append(
320
+ Table(
321
+ rows,
322
+ colWidths=[width * 0.22, width * 0.78],
323
+ style=TableStyle([
324
+ ("BACKGROUND", (0, 0), (0, -1), THEME["panel_alt"]),
325
+ ("BACKGROUND", (1, 0), (1, -1), colors.white),
326
+ ("BOX", (0, 0), (-1, -1), 0.8, THEME["line"]),
327
+ ("INNERGRID", (0, 0), (-1, -1), 0.6, THEME["line"]),
328
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
329
+ ("LEFTPADDING", (0, 0), (-1, -1), 8),
330
+ ("RIGHTPADDING", (0, 0), (-1, -1), 8),
331
+ ("TOPPADDING", (0, 0), (-1, -1), 6),
332
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
333
+ ]),
334
+ )
335
+ )
336
+ story.append(Spacer(1, 8))
337
+ return story
338
+
339
+
340
+ def _question_meta(question: dict, styles: dict, width: float) -> Table:
341
+ rows = [
342
+ [_paragraph("Source", styles["label"]), _paragraph(question.get("source_path"), styles["body"])],
343
+ [_paragraph("Locator", styles["label"]), _paragraph(question.get("page_or_locator"), styles["body"])],
344
+ [_paragraph("User answer", styles["label"]), _paragraph(question.get("user_answer"), styles["body"])],
345
+ [_paragraph("Correct answer", styles["label"]), _paragraph(question.get("correct_answer"), styles["body"])],
346
+ [_paragraph("Mistake type", styles["label"]), _paragraph(question.get("mistake_type"), styles["body"])],
347
+ [_paragraph("Concepts", styles["label"]), _paragraph(question.get("concepts", []), styles["body"])],
348
+ ]
349
+ return Table(
350
+ rows,
351
+ colWidths=[width * 0.18, width * 0.82],
352
+ style=TableStyle([
353
+ ("BACKGROUND", (0, 0), (0, -1), THEME["panel"]),
354
+ ("BACKGROUND", (1, 0), (1, -1), colors.white),
355
+ ("BOX", (0, 0), (-1, -1), 0.8, THEME["line"]),
356
+ ("INNERGRID", (0, 0), (-1, -1), 0.6, THEME["line"]),
357
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
358
+ ("LEFTPADDING", (0, 0), (-1, -1), 8),
359
+ ("RIGHTPADDING", (0, 0), (-1, -1), 8),
360
+ ("TOPPADDING", (0, 0), (-1, -1), 6),
361
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
362
+ ]),
363
+ )
364
+
365
+
366
+ def _steps_block(title: str, steps: Sequence[Any], styles: dict) -> List[Any]:
367
+ return [Paragraph(_markup(title), styles["subhead"])] + _bullet_lines(steps or [], styles, empty_text="No steps provided.")
368
+
369
+
370
+ def _mc_options_table(question: dict, styles: dict, width: float) -> Table:
371
+ rows: List[List[Any]] = [[
372
+ Paragraph("Option", styles["table_head"]),
373
+ Paragraph("Text", styles["table_head"]),
374
+ Paragraph("Verdict", styles["table_head"]),
375
+ Paragraph("Reason", styles["table_head"]),
376
+ ]]
377
+ for option in question.get("options", []) or []:
378
+ rows.append([
379
+ _paragraph(option.get("label"), styles["table"]),
380
+ _paragraph(option.get("text"), styles["table"]),
381
+ _paragraph(option.get("verdict"), styles["table"]),
382
+ _paragraph(option.get("reason"), styles["table"]),
383
+ ])
384
+ if len(rows) == 1:
385
+ rows.append([_paragraph("-", styles["table"]), _paragraph("-", styles["table"]), _paragraph("-", styles["table"]), _paragraph("-", styles["table"])])
386
+ return _table(
387
+ rows,
388
+ [width * 0.10, width * 0.28, width * 0.12, width * 0.50],
389
+ TableStyle([
390
+ ("BACKGROUND", (0, 0), (-1, 0), THEME["accent"]),
391
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
392
+ ("FONTNAME", (0, 0), (-1, -1), styles["table"].fontName),
393
+ ("GRID", (0, 0), (-1, -1), 0.6, THEME["line"]),
394
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, THEME["panel"]]),
395
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
396
+ ("LEFTPADDING", (0, 0), (-1, -1), 6),
397
+ ("RIGHTPADDING", (0, 0), (-1, -1), 6),
398
+ ("TOPPADDING", (0, 0), (-1, -1), 5),
399
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
400
+ ]),
401
+ )
402
+
403
+
404
+ def _long_key_concepts_table(question: dict, styles: dict, width: float) -> Table:
405
+ rows: List[List[Any]] = [[
406
+ Paragraph("Concept", styles["table_head"]),
407
+ Paragraph("Why it matters", styles["table_head"]),
408
+ ]]
409
+ for item in question.get("key_concepts", []) or []:
410
+ rows.append([
411
+ _paragraph(item.get("name"), styles["table"]),
412
+ _paragraph(item.get("why_it_matters"), styles["table"]),
413
+ ])
414
+ if len(rows) == 1:
415
+ rows.append([_paragraph("-", styles["table"]), _paragraph("-", styles["table"])])
416
+ return _table(
417
+ rows,
418
+ [width * 0.24, width * 0.76],
419
+ TableStyle([
420
+ ("BACKGROUND", (0, 0), (-1, 0), THEME["accent"]),
421
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
422
+ ("FONTNAME", (0, 0), (-1, -1), styles["table"].fontName),
423
+ ("GRID", (0, 0), (-1, -1), 0.6, THEME["line"]),
424
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, THEME["panel"]]),
425
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
426
+ ("LEFTPADDING", (0, 0), (-1, -1), 6),
427
+ ("RIGHTPADDING", (0, 0), (-1, -1), 6),
428
+ ("TOPPADDING", (0, 0), (-1, -1), 5),
429
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
430
+ ]),
431
+ )
432
+
433
+
434
+ def _step_comparison_table(question: dict, styles: dict, width: float) -> Table:
435
+ rows: List[List[Any]] = [[
436
+ Paragraph("Step", styles["table_head"]),
437
+ Paragraph("Expected", styles["table_head"]),
438
+ Paragraph("User", styles["table_head"]),
439
+ Paragraph("Gap", styles["table_head"]),
440
+ Paragraph("Fix", styles["table_head"]),
441
+ ]]
442
+ for item in question.get("step_comparison", []) or []:
443
+ rows.append([
444
+ _paragraph(item.get("step_no"), styles["table"]),
445
+ _paragraph(item.get("expected_step"), styles["table"]),
446
+ _paragraph(item.get("user_step"), styles["table"]),
447
+ _paragraph(item.get("gap"), styles["table"]),
448
+ _paragraph(item.get("fix"), styles["table"]),
449
+ ])
450
+ if len(rows) == 1:
451
+ rows.append([
452
+ _paragraph("-", styles["table"]),
453
+ _paragraph("-", styles["table"]),
454
+ _paragraph("-", styles["table"]),
455
+ _paragraph("-", styles["table"]),
456
+ _paragraph("-", styles["table"]),
457
+ ])
458
+ return _table(
459
+ rows,
460
+ [width * 0.08, width * 0.24, width * 0.22, width * 0.20, width * 0.26],
461
+ TableStyle([
462
+ ("BACKGROUND", (0, 0), (-1, 0), THEME["warning"]),
463
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
464
+ ("FONTNAME", (0, 0), (-1, -1), styles["table"].fontName),
465
+ ("GRID", (0, 0), (-1, -1), 0.6, THEME["line"]),
466
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, THEME["warning_soft"]]),
467
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
468
+ ("LEFTPADDING", (0, 0), (-1, -1), 6),
469
+ ("RIGHTPADDING", (0, 0), (-1, -1), 6),
470
+ ("TOPPADDING", (0, 0), (-1, -1), 5),
471
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
472
+ ]),
473
+ )
474
+
475
+
476
+ def _mc_question_story(question: dict, styles: dict, width: float) -> List[Any]:
477
+ story: List[Any] = [
478
+ Paragraph(_markup(question.get("question_id"), default="Unnamed question"), styles["question"]),
479
+ _question_meta(question, styles, width),
480
+ Spacer(1, 6),
481
+ Paragraph("Stem", styles["subhead"]),
482
+ _paragraph(question.get("stem"), styles["body"]),
483
+ Paragraph("Why it was wrong", styles["subhead"]),
484
+ _paragraph(question.get("why_wrong"), styles["body"]),
485
+ ]
486
+ story.extend(_steps_block("Correct solution", question.get("correct_solution_steps", []), styles))
487
+ story.extend([Spacer(1, 4), Paragraph("Option-by-option reasoning", styles["subhead"]), _mc_options_table(question, styles, width), Spacer(1, 10)])
488
+ return story
489
+
490
+
491
+ def _long_question_story(question: dict, styles: dict, width: float) -> List[Any]:
492
+ first_wrong = _safe_text(question.get("first_incorrect_step"))
493
+ story: List[Any] = [
494
+ Paragraph(_markup(question.get("question_id"), default="Unnamed question"), styles["question"]),
495
+ _question_meta(question, styles, width),
496
+ Spacer(1, 6),
497
+ Paragraph("Stem", styles["subhead"]),
498
+ _paragraph(question.get("stem"), styles["body"]),
499
+ Paragraph("Why it was wrong", styles["subhead"]),
500
+ _paragraph(question.get("why_wrong"), styles["body"]),
501
+ Spacer(1, 4),
502
+ Table(
503
+ [[Paragraph(f"First incorrect step: {_markup(first_wrong)}", styles["callout"])]],
504
+ colWidths=[width],
505
+ style=TableStyle([
506
+ ("BACKGROUND", (0, 0), (-1, -1), THEME["warning_soft"]),
507
+ ("BOX", (0, 0), (-1, -1), 0.8, THEME["warning"]),
508
+ ("LEFTPADDING", (0, 0), (-1, -1), 10),
509
+ ("RIGHTPADDING", (0, 0), (-1, -1), 10),
510
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
511
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
512
+ ]),
513
+ ),
514
+ Spacer(1, 6),
515
+ Paragraph("Key concepts involved", styles["subhead"]),
516
+ _long_key_concepts_table(question, styles, width),
517
+ Spacer(1, 6),
518
+ Paragraph("Step-by-step comparison", styles["subhead"]),
519
+ _step_comparison_table(question, styles, width),
520
+ ]
521
+ story.extend([Spacer(1, 6)] + _steps_block("Correct solution", question.get("correct_solution_steps", []), styles) + [Spacer(1, 10)])
522
+ return story
523
+
524
+
525
+ def _questions_story(data: dict, styles: dict, width: float) -> List[Any]:
526
+ story = _section_heading("Mistake-by-Mistake Analysis & Solutions", styles)
527
+ questions = data.get("questions", []) or []
528
+ if not questions:
529
+ return story + [_paragraph("No question analysis provided.", styles["body"])]
530
+
531
+ builder = _mc_question_story if data.get("book_type") == "mc-question" else _long_question_story
532
+ for question in questions:
533
+ story.extend(builder(question, styles, width))
534
+ return story
535
+
536
+
537
+ def _build_story(data: dict, styles: dict, doc: SimpleDocTemplate) -> List[Any]:
538
+ title = _safe_text(data.get("title"), default="Error Book")
539
+ subtitle = f"Type: {_safe_text(data.get('book_type'), default='general')} | Last updated: {_safe_text(data.get('last_updated'))}"
540
+ story: List[Any] = [
541
+ Spacer(1, 8),
542
+ Paragraph(_markup(title), styles["title"]),
543
+ Paragraph(_markup(subtitle), styles["subtitle"]),
544
+ Paragraph(_markup("A structured review of mistakes, concepts, and corrections."), styles["subtitle"]),
545
+ ]
546
+ story.extend(_coverage_story(data, styles, doc.width))
547
+ story.extend(_overview_story(data, styles))
548
+ story.extend(_concept_story(data, styles, doc.width))
549
+ story.append(PageBreak())
550
+ story.extend(_questions_story(data, styles, doc.width))
551
+ return story
552
+
553
+
554
+ def main() -> int:
555
+ parser = argparse.ArgumentParser(description="Render structured error-book JSON to a polished PDF.")
556
+ parser.add_argument("input_json", help="Input JSON file path")
557
+ parser.add_argument("output_pdf", help="Output PDF file path")
558
+ parser.add_argument("--font-path", default=None, help="Path to a CJK-capable font file (.ttf/.otf/.ttc)")
559
+ parser.add_argument("--font-size", type=int, default=11, help="Base font size (default: 11)")
560
+ parser.add_argument("--pagesize", choices=["a4", "letter"], default="a4", help="Page size (default: a4)")
561
+ parser.add_argument("--margin-mm", type=float, default=16.0, help="Page margin in mm (default: 16)")
562
+ args = parser.parse_args()
563
+
564
+ data = _read_json(args.input_json)
565
+ font_path, subfont_index = _detect_cjk_font_path(args.font_path)
566
+ font_name = _register_font(font_path, subfont_index)
567
+ styles = _build_styles(font_name, args.font_size)
568
+
569
+ page_size = A4 if args.pagesize == "a4" else letter
570
+ margin = args.margin_mm * mm
571
+
572
+ _ensure_parent_dir(args.output_pdf)
573
+ doc = SimpleDocTemplate(
574
+ args.output_pdf,
575
+ pagesize=page_size,
576
+ leftMargin=margin,
577
+ rightMargin=margin,
578
+ topMargin=margin,
579
+ bottomMargin=margin,
580
+ title=_safe_text(data.get("title"), default=os.path.basename(args.output_pdf)),
581
+ author="Apollo Toolkit Learning Error Book Skill",
582
+ )
583
+
584
+ story = _build_story(data, styles, doc)
585
+ doc.build(story, onFirstPage=_header_footer, onLaterPages=_header_footer)
586
+ return 0
587
+
588
+
589
+ if __name__ == "__main__":
590
+ raise SystemExit(main())
@@ -42,7 +42,7 @@ The bundled script can also be called directly:
42
42
  python scripts/open_github_issue.py \
43
43
  --issue-type problem \
44
44
  --title "[Log] Payment timeout spike" \
45
- --problem-description "Repeated timeout warnings escalated into request failures during the incident window." \
45
+ --problem-description $'Expected Behavior (BDD)\nGiven the payment service sees transient upstream latency\nWhen the retry path runs\nThen requests should recover without user-visible failures\n\nCurrent Behavior (BDD)\nGiven the payment service sees transient upstream latency\nWhen the retry path runs\nThen repeated timeout warnings still escalate into request failures\n\nBehavior Gap\n- Expected: retries absorb transient upstream slowness.\n- Actual: retries still end in request failures.\n- Difference/Impact: customers receive failed payment attempts during the incident window.\n\nEvidence\n- symptom: repeated timeout warnings escalated into request failures.\n- impact: payment attempts failed for end users.\n- key evidence: logs from the incident window show retries without successful recovery.' \
46
46
  --suspected-cause "payment-api/handler.py:84 retries immediately against a slow upstream with no jitter; confidence high." \
47
47
  --reproduction "Not yet reliably reproducible; more runtime evidence is required." \
48
48
  --repo owner/repo
@@ -72,6 +72,12 @@ Problem issues always include exactly three sections:
72
72
  - `Suspected Cause`
73
73
  - `Reproduction Conditions (if available)`
74
74
 
75
+ Within `Problem Description`, include:
76
+
77
+ - `Expected Behavior (BDD)`
78
+ - `Current Behavior (BDD)`
79
+ - `Behavior Gap`
80
+
75
81
  For Chinese-language repositories, use translated section titles with the same meaning.
76
82
 
77
83
  Feature proposal issues always include:
@@ -14,9 +14,9 @@ description: Publish structured GitHub issues and feature proposals with determi
14
14
 
15
15
  ## Standards
16
16
 
17
- - Evidence: Require structured issue inputs and detect repository language from the target README instead of guessing.
17
+ - Evidence: Require structured issue inputs, detect repository language from the target README instead of guessing, and for `problem` issues capture BDD-style expected vs current behavior with an explicit delta.
18
18
  - Execution: Resolve the repo, normalize the issue body, publish with strict auth order, then return the publication result.
19
- - Quality: Preserve upstream evidence, localize only the structural parts, and keep publication deterministic and reproducible.
19
+ - Quality: Preserve upstream evidence, localize only the structural parts, keep publication deterministic and reproducible, and make behavioral mismatches easy for maintainers to verify.
20
20
  - Output: Return publication mode, issue URL when created, rendered body, and any publish error in the standardized JSON contract.
21
21
 
22
22
  ## Overview
@@ -37,6 +37,7 @@ It is designed to be reusable by other skills that already know the issue title
37
37
  - Detect repository issue language from the target remote README instead of guessing.
38
38
  - Preserve upstream evidence content; only localize section headers and default fallback text.
39
39
  - Make the issue type explicit: `problem` for defects/incidents, `feature` for proposals.
40
+ - For `problem` issues, describe the expected behavior and current behavior with BDD-style `Given / When / Then`, then state the behavioral difference explicitly.
40
41
 
41
42
  ## Workflow
42
43
 
@@ -49,6 +50,11 @@ It is designed to be reusable by other skills that already know the issue title
49
50
  - `problem-description`
50
51
  - `suspected-cause`
51
52
  - `reproduction` (optional)
53
+ - Within `problem-description`, require a precise behavior diff:
54
+ - `Expected Behavior (BDD)`: `Given / When / Then` for what the program should do.
55
+ - `Current Behavior (BDD)`: `Given / When / Then` for what the program does now.
56
+ - `Behavior Gap`: a short explicit comparison of the observable difference and impact.
57
+ - Include the symptom, impact, and key evidence alongside the behavior diff; do not leave the mismatch implicit.
52
58
  - For `feature` issues, require these structured sections:
53
59
  - `proposal` (optional; defaults to title when omitted)
54
60
  - `reason`
@@ -74,7 +80,7 @@ Problem issue:
74
80
  python scripts/open_github_issue.py \
75
81
  --issue-type problem \
76
82
  --title "[Log] <short symptom>" \
77
- --problem-description "<symptom + impact + key evidence>" \
83
+ --problem-description $'Expected Behavior (BDD)\nGiven ...\nWhen ...\nThen ...\n\nCurrent Behavior (BDD)\nGiven ...\nWhen ...\nThen ...\n\nBehavior Gap\n- Expected: ...\n- Actual: ...\n- Difference/Impact: ...\n\nEvidence\n- symptom: ...\n- impact: ...\n- key evidence: ...' \
78
84
  --suspected-cause "<path:line + causal chain + confidence>" \
79
85
  --reproduction "<steps/conditions or leave empty>" \
80
86
  --repo <owner/repo>
@@ -111,6 +117,7 @@ When another skill depends on `open-github-issue`:
111
117
 
112
118
  - Pass exactly one confirmed problem or one accepted feature proposal per invocation.
113
119
  - Prepare evidence or proposal details before calling this skill; do not ask this skill to infer root cause or architecture.
120
+ - For `problem` issues, pass a `problem-description` that contains `Expected Behavior (BDD)`, `Current Behavior (BDD)`, and `Behavior Gap`; the difference must be explicit, not implied.
114
121
  - Reuse the returned `mode`, `issue_url`, and `publish_error` in the parent skill response.
115
122
  - For accepted feature proposals, pass `--issue-type feature` plus `--proposal`, `--reason`, and `--suggested-architecture`.
116
123