feat(platform): course-plan student visibility, multi-voice TTS, OCR sources, branches
Backend (encoach_ai_course):
- workbook_attempt model + scoring + REST endpoints for student attempts
- dialogue_parser splits scripts by speaker, classifies gender, strips labels
- media_service: multi-voice TTS via Polly/ElevenLabs, ffmpeg concatenation,
manual media upload endpoint (audio/image/video) with size validation
- source_indexer: OCR fallback (pytesseract + pdf2image) for scanned PDFs,
page-streaming to stay under memory limit
- exercise_extractor + rag_context for RAG-grounded interactive workbooks
- course_plan_pipeline: v2 generator that grounds week material on indexed
sources and persists grounded_on_json metadata
- security: access rules for new models
Backend (encoach_lms_api):
- branches model + controller (entity-scoped LMS branches)
- classroom_ext + course_ext (assignment + section workflow)
- classrooms controller: students/teachers/assign-course endpoints
Frontend:
- StudentDashboard: surface assigned AI course plans alongside enrollments;
enrolled-courses stat now counts plans+enrollments
- InteractiveWorkbook + PlanReader components
- AdminCoursePlanDetail: media drawer with upload buttons (audio/image/video),
hidden file inputs, upload mutation
- AdminBranches page + sidebar entry
- coursePlan/lms/classrooms services + types updated for new endpoints
- i18n: studentDash.myCoursePlans/noCoursePlans (en + ar)
Infra & docs:
- odoo.conf: bump memory limits to 4G/5G for OCR + sentence-transformers
- .gitignore: ignore *.tsbuildinfo
- docs/ASSIGNMENT_WORKFLOW.{md,pdf}
- smoke_*.py end-to-end tests for assignment workflow, entity isolation,
course-plan RAG pipeline
Made-with: Cursor
This commit is contained in:
418
scripts/render_md_to_pdf.py
Normal file
418
scripts/render_md_to_pdf.py
Normal file
@@ -0,0 +1,418 @@
|
||||
"""Render a Markdown file to a polished PDF using reportlab + markdown-it-py.
|
||||
|
||||
Supports the subset of Markdown used in our project docs:
|
||||
- ATX headings (#, ##, ###, ####)
|
||||
- Paragraphs with **bold**, *italic*, and `inline code`
|
||||
- Bullet and ordered lists (single level)
|
||||
- Fenced code blocks (```lang ... ```)
|
||||
- Tables (GFM pipe tables with header separator)
|
||||
- Horizontal rules (---)
|
||||
- Blockquotes (>)
|
||||
|
||||
Usage:
|
||||
python scripts/render_md_to_pdf.py docs/ASSIGNMENT_WORKFLOW.md
|
||||
python scripts/render_md_to_pdf.py docs/ASSIGNMENT_WORKFLOW.md docs/ASSIGNMENT_WORKFLOW.pdf
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from html import escape as h
|
||||
|
||||
from markdown_it import MarkdownIt
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_LEFT
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import cm
|
||||
from reportlab.platypus import (
|
||||
HRFlowable,
|
||||
KeepTogether,
|
||||
ListFlowable,
|
||||
ListItem,
|
||||
PageBreak,
|
||||
Paragraph,
|
||||
Preformatted,
|
||||
SimpleDocTemplate,
|
||||
Spacer,
|
||||
Table,
|
||||
TableStyle,
|
||||
)
|
||||
|
||||
|
||||
# ── Styles ─────────────────────────────────────────────────────────────
|
||||
|
||||
INK = colors.HexColor("#0f172a")
|
||||
MUTED = colors.HexColor("#64748b")
|
||||
ACCENT = colors.HexColor("#2563eb")
|
||||
BORDER = colors.HexColor("#e2e8f0")
|
||||
SUBTLE = colors.HexColor("#f8fafc")
|
||||
CODE_BG = colors.HexColor("#f1f5f9")
|
||||
CODE_INK = colors.HexColor("#0f172a")
|
||||
|
||||
|
||||
def _styles():
|
||||
base = getSampleStyleSheet()
|
||||
return {
|
||||
"h1": ParagraphStyle(
|
||||
"h1", parent=base["Title"], fontName="Helvetica-Bold",
|
||||
fontSize=22, leading=28, textColor=INK, spaceAfter=14, alignment=TA_LEFT,
|
||||
),
|
||||
"h2": ParagraphStyle(
|
||||
"h2", parent=base["Heading2"], fontName="Helvetica-Bold",
|
||||
fontSize=15, leading=20, textColor=INK, spaceBefore=18, spaceAfter=8,
|
||||
),
|
||||
"h3": ParagraphStyle(
|
||||
"h3", parent=base["Heading3"], fontName="Helvetica-Bold",
|
||||
fontSize=12, leading=16, textColor=ACCENT, spaceBefore=12, spaceAfter=4,
|
||||
),
|
||||
"h4": ParagraphStyle(
|
||||
"h4", parent=base["Heading4"], fontName="Helvetica-Bold",
|
||||
fontSize=11, leading=14, textColor=INK, spaceBefore=8, spaceAfter=2,
|
||||
),
|
||||
"body": ParagraphStyle(
|
||||
"body", parent=base["BodyText"], fontName="Helvetica",
|
||||
fontSize=9.5, leading=14, textColor=INK, spaceAfter=6,
|
||||
),
|
||||
"muted": ParagraphStyle(
|
||||
"muted", parent=base["BodyText"], fontName="Helvetica-Oblique",
|
||||
fontSize=8.5, leading=12, textColor=MUTED, spaceAfter=6,
|
||||
),
|
||||
"code": ParagraphStyle(
|
||||
"code", parent=base["Code"], fontName="Courier",
|
||||
fontSize=8, leading=11, textColor=CODE_INK,
|
||||
backColor=CODE_BG, borderPadding=8, leftIndent=0, rightIndent=0,
|
||||
spaceBefore=4, spaceAfter=10,
|
||||
),
|
||||
"tableCell": ParagraphStyle(
|
||||
"tableCell", parent=base["BodyText"], fontName="Helvetica",
|
||||
fontSize=8.5, leading=11, textColor=INK,
|
||||
),
|
||||
"tableHead": ParagraphStyle(
|
||||
"tableHead", parent=base["BodyText"], fontName="Helvetica-Bold",
|
||||
fontSize=8.5, leading=11, textColor=colors.white,
|
||||
),
|
||||
"quote": ParagraphStyle(
|
||||
"quote", parent=base["BodyText"], fontName="Helvetica-Oblique",
|
||||
fontSize=9.5, leading=14, textColor=MUTED,
|
||||
leftIndent=12, borderColor=BORDER, borderWidth=0,
|
||||
backColor=SUBTLE, borderPadding=8, spaceAfter=8,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ── Inline rendering: turn markdown-it inline tokens into reportlab markup ─
|
||||
|
||||
_RL_ESCAPE = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
}
|
||||
|
||||
|
||||
def _esc(text: str) -> str:
|
||||
return "".join(_RL_ESCAPE.get(c, c) for c in text)
|
||||
|
||||
|
||||
def _render_inline(token) -> str:
|
||||
"""Convert an `inline` markdown-it token's children into rl markup."""
|
||||
if not getattr(token, "children", None):
|
||||
return _esc(token.content or "")
|
||||
out: list[str] = []
|
||||
for child in token.children:
|
||||
t = child.type
|
||||
if t == "text":
|
||||
out.append(_esc(child.content))
|
||||
elif t == "softbreak" or t == "hardbreak":
|
||||
out.append("<br/>")
|
||||
elif t == "code_inline":
|
||||
out.append(
|
||||
f'<font name="Courier" size="8.5" backColor="#f1f5f9">'
|
||||
f' {_esc(child.content)} </font>'
|
||||
)
|
||||
elif t == "strong_open":
|
||||
out.append("<b>")
|
||||
elif t == "strong_close":
|
||||
out.append("</b>")
|
||||
elif t == "em_open":
|
||||
out.append("<i>")
|
||||
elif t == "em_close":
|
||||
out.append("</i>")
|
||||
elif t == "s_open":
|
||||
out.append("<strike>")
|
||||
elif t == "s_close":
|
||||
out.append("</strike>")
|
||||
elif t == "link_open":
|
||||
href = ""
|
||||
for k, v in child.attrs.items() if isinstance(child.attrs, dict) else child.attrs:
|
||||
if k == "href":
|
||||
href = v
|
||||
out.append(f'<a href="{_esc(href)}" color="#2563eb">')
|
||||
elif t == "link_close":
|
||||
out.append("</a>")
|
||||
elif t == "image":
|
||||
alt = _esc(child.content or "image")
|
||||
out.append(f"[{alt}]")
|
||||
else:
|
||||
out.append(_esc(child.content or ""))
|
||||
return "".join(out)
|
||||
|
||||
|
||||
# ── Block rendering: walk markdown-it tokens and emit reportlab flowables ──
|
||||
|
||||
|
||||
def md_to_flowables(md_text: str, styles):
|
||||
md = MarkdownIt("commonmark", {"breaks": False, "html": False}).enable("table").enable("strikethrough")
|
||||
tokens = md.parse(md_text)
|
||||
flow: list = []
|
||||
|
||||
i = 0
|
||||
n = len(tokens)
|
||||
while i < n:
|
||||
t = tokens[i]
|
||||
ttype = t.type
|
||||
|
||||
if ttype == "heading_open":
|
||||
level = int(t.tag[1])
|
||||
inline = tokens[i + 1]
|
||||
text = _render_inline(inline)
|
||||
style_key = {1: "h1", 2: "h2", 3: "h3"}.get(level, "h4")
|
||||
flow.append(Paragraph(text, styles[style_key]))
|
||||
if level == 1:
|
||||
flow.append(HRFlowable(width="100%", thickness=0.6, color=BORDER, spaceAfter=10))
|
||||
i += 3
|
||||
continue
|
||||
|
||||
if ttype == "paragraph_open":
|
||||
inline = tokens[i + 1]
|
||||
text = _render_inline(inline)
|
||||
flow.append(Paragraph(text, styles["body"]))
|
||||
i += 3
|
||||
continue
|
||||
|
||||
if ttype == "bullet_list_open" or ttype == "ordered_list_open":
|
||||
ordered = ttype == "ordered_list_open"
|
||||
items, consumed = _parse_list_items(tokens, i, styles)
|
||||
flow.append(
|
||||
ListFlowable(
|
||||
items,
|
||||
bulletType="1" if ordered else "bullet",
|
||||
bulletFontName="Helvetica-Bold",
|
||||
bulletFontSize=9,
|
||||
leftIndent=18,
|
||||
bulletDedent=10,
|
||||
spaceAfter=8,
|
||||
)
|
||||
)
|
||||
i += consumed
|
||||
continue
|
||||
|
||||
if ttype == "fence" or ttype == "code_block":
|
||||
content = t.content.rstrip("\n")
|
||||
flow.append(Preformatted(content, styles["code"]))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ttype == "hr":
|
||||
flow.append(HRFlowable(width="100%", thickness=0.4, color=BORDER, spaceBefore=4, spaceAfter=10))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ttype == "blockquote_open":
|
||||
inner_md = _collect_blockquote(tokens, i)
|
||||
inner_flow = md_to_flowables(inner_md, styles)
|
||||
for fl in inner_flow:
|
||||
if isinstance(fl, Paragraph):
|
||||
fl.style = styles["quote"]
|
||||
flow.append(KeepTogether(inner_flow))
|
||||
i = _skip_to(tokens, i, "blockquote_close") + 1
|
||||
continue
|
||||
|
||||
if ttype == "table_open":
|
||||
tbl, consumed = _parse_table(tokens, i, styles)
|
||||
flow.append(tbl)
|
||||
flow.append(Spacer(1, 8))
|
||||
i += consumed
|
||||
continue
|
||||
|
||||
i += 1
|
||||
|
||||
return flow
|
||||
|
||||
|
||||
def _parse_list_items(tokens, start, styles):
|
||||
"""Parse bullet_list_open/ordered_list_open block, return (items, consumed)."""
|
||||
items: list = []
|
||||
i = start + 1
|
||||
depth = 1
|
||||
while i < len(tokens) and depth > 0:
|
||||
t = tokens[i]
|
||||
if t.type in ("bullet_list_open", "ordered_list_open"):
|
||||
depth += 1
|
||||
i += 1
|
||||
continue
|
||||
if t.type in ("bullet_list_close", "ordered_list_close"):
|
||||
depth -= 1
|
||||
i += 1
|
||||
continue
|
||||
if t.type == "list_item_open" and depth == 1:
|
||||
j = i + 1
|
||||
chunks: list = []
|
||||
while j < len(tokens) and tokens[j].type != "list_item_close":
|
||||
if tokens[j].type == "paragraph_open":
|
||||
inline = tokens[j + 1]
|
||||
chunks.append(Paragraph(_render_inline(inline), styles["body"]))
|
||||
j += 3
|
||||
else:
|
||||
j += 1
|
||||
items.append(ListItem(chunks or [Paragraph("", styles["body"])], leftIndent=4))
|
||||
i = j + 1
|
||||
continue
|
||||
i += 1
|
||||
return items, i - start
|
||||
|
||||
|
||||
def _skip_to(tokens, start, target_type):
|
||||
i = start
|
||||
while i < len(tokens) and tokens[i].type != target_type:
|
||||
i += 1
|
||||
return i
|
||||
|
||||
|
||||
def _collect_blockquote(tokens, start) -> str:
|
||||
end = _skip_to(tokens, start, "blockquote_close")
|
||||
parts = []
|
||||
for t in tokens[start + 1 : end]:
|
||||
if t.type == "inline":
|
||||
parts.append(t.content)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _parse_table(tokens, start, styles):
|
||||
headers: list[str] = []
|
||||
rows: list[list[str]] = []
|
||||
i = start + 1
|
||||
while i < len(tokens) and tokens[i].type != "table_close":
|
||||
t = tokens[i]
|
||||
if t.type == "thead_open":
|
||||
i += 1
|
||||
while tokens[i].type != "thead_close":
|
||||
if tokens[i].type == "tr_open":
|
||||
row: list[str] = []
|
||||
j = i + 1
|
||||
while tokens[j].type != "tr_close":
|
||||
if tokens[j].type in ("th_open", "td_open"):
|
||||
inline = tokens[j + 1]
|
||||
row.append(_render_inline(inline))
|
||||
j += 3
|
||||
else:
|
||||
j += 1
|
||||
headers = row
|
||||
i = j + 1
|
||||
else:
|
||||
i += 1
|
||||
i += 1
|
||||
continue
|
||||
if t.type == "tbody_open":
|
||||
i += 1
|
||||
while tokens[i].type != "tbody_close":
|
||||
if tokens[i].type == "tr_open":
|
||||
row = []
|
||||
j = i + 1
|
||||
while tokens[j].type != "tr_close":
|
||||
if tokens[j].type in ("th_open", "td_open"):
|
||||
inline = tokens[j + 1]
|
||||
row.append(_render_inline(inline))
|
||||
j += 3
|
||||
else:
|
||||
j += 1
|
||||
rows.append(row)
|
||||
i = j + 1
|
||||
else:
|
||||
i += 1
|
||||
i += 1
|
||||
continue
|
||||
i += 1
|
||||
consumed = i - start + 1
|
||||
|
||||
head_p = [Paragraph(h, styles["tableHead"]) for h in headers]
|
||||
body_p = [[Paragraph(c, styles["tableCell"]) for c in row] for row in rows]
|
||||
data = [head_p] + body_p
|
||||
|
||||
page_w = A4[0] - 2 * 1.8 * cm
|
||||
n_cols = max(1, len(headers) or (len(rows[0]) if rows else 1))
|
||||
col_w = [page_w / n_cols] * n_cols
|
||||
|
||||
tbl = Table(data, colWidths=col_w, repeatRows=1)
|
||||
tbl.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, 0), ACCENT),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("ALIGN", (0, 0), (-1, -1), "LEFT"),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, SUBTLE]),
|
||||
("GRID", (0, 0), (-1, -1), 0.25, BORDER),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
]
|
||||
)
|
||||
)
|
||||
return tbl, consumed
|
||||
|
||||
|
||||
# ── Page decoration ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _on_page(canvas, doc):
|
||||
canvas.saveState()
|
||||
canvas.setFont("Helvetica", 8)
|
||||
canvas.setFillColor(MUTED)
|
||||
page_num = canvas.getPageNumber()
|
||||
canvas.drawRightString(A4[0] - 1.8 * cm, 1.0 * cm, f"Page {page_num}")
|
||||
canvas.drawString(1.8 * cm, 1.0 * cm, "EnCoach LMS — Assignment Workflow")
|
||||
canvas.setStrokeColor(BORDER)
|
||||
canvas.setLineWidth(0.4)
|
||||
canvas.line(1.8 * cm, 1.4 * cm, A4[0] - 1.8 * cm, 1.4 * cm)
|
||||
canvas.restoreState()
|
||||
|
||||
|
||||
def render(md_path: Path, pdf_path: Path) -> None:
|
||||
md_text = md_path.read_text(encoding="utf-8")
|
||||
styles = _styles()
|
||||
flow = md_to_flowables(md_text, styles)
|
||||
|
||||
doc = SimpleDocTemplate(
|
||||
str(pdf_path),
|
||||
pagesize=A4,
|
||||
leftMargin=1.8 * cm,
|
||||
rightMargin=1.8 * cm,
|
||||
topMargin=2.0 * cm,
|
||||
bottomMargin=2.0 * cm,
|
||||
title="EnCoach LMS — Assignment Workflow",
|
||||
author="EnCoach Platform",
|
||||
)
|
||||
doc.build(flow, onFirstPage=_on_page, onLaterPages=_on_page)
|
||||
print(f"wrote {pdf_path}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
return 2
|
||||
src = Path(sys.argv[1]).resolve()
|
||||
dst = Path(sys.argv[2]).resolve() if len(sys.argv) > 2 else src.with_suffix(".pdf")
|
||||
if not src.exists():
|
||||
print(f"not found: {src}", file=sys.stderr)
|
||||
return 1
|
||||
render(src, dst)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user