"""create_docx — Markdown → Word document (fiches de révision, plans d'étude, gabarits de rapport).""" from __future__ import annotations import io import re from datetime import date from typing import Any from pydantic import BaseModel, Field from app.llm.schemas import Artifact, ToolResult from app.services import files as file_service from app.tools.registry import ToolContext, registry UQO_BLUE = "0F6180" class Args(BaseModel): title: str = Field(..., max_length=200) markdown: str = Field(..., min_length=10, max_length=60000) filename: str | None = None course: str = "" subtitle: str = "" def _add_runs(par: Any, text: str) -> None: """Bold / italic / inline code / inline math ($…$ → italic).""" tokens = re.split(r"(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\$[^$]+\$)", text) for t in tokens: if not t: continue if t.startswith("**") and t.endswith("**"): par.add_run(t[2:-2]).bold = True elif t.startswith("`") and t.endswith("`"): r = par.add_run(t[1:-1]) r.font.name = "Consolas" elif t.startswith("$") and t.endswith("$"): r = par.add_run(_tex_to_text(t[1:-1])) r.italic = True elif t.startswith("*") and t.endswith("*"): par.add_run(t[1:-1]).italic = True else: par.add_run(t) def _tex_to_text(s: str) -> str: s = re.sub(r"\\frac\{([^}]*)\}\{([^}]*)\}", r"(\1)/(\2)", s) s = s.replace("\\times", "×").replace("\\cdot", "·").replace("\\approx", "≈").replace("\\le", "≤").replace("\\ge", "≥") s = re.sub(r"\\text\{([^}]*)\}", r"\1", s) s = s.replace("\\,", " ").replace("\\ ", " ").replace("\\$", "$") s = re.sub(r"\^\{([^}]*)\}", r"^\1", s) s = re.sub(r"_\{([^}]*)\}", r"_\1", s) return s.replace("{", "").replace("}", "") def build(args: dict[str, Any]) -> bytes: from docx import Document from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.shared import Pt, RGBColor doc = Document() style = doc.styles["Normal"] style.font.name = "Calibri" style.font.size = Pt(11) for lvl, size in ((1, 18), (2, 14), (3, 12)): hs = doc.styles[f"Heading {lvl}"] hs.font.color.rgb = RGBColor.from_string(UQO_BLUE) hs.font.size = Pt(size) # header band hp = doc.add_paragraph() r = hp.add_run("UQO-Chat · Tuteur IA — IMM1003 · IMM1033") r.font.size = Pt(9) r.font.color.rgb = RGBColor(0x5B, 0x6B, 0x7B) t = doc.add_paragraph() tr = t.add_run(args["title"]) tr.bold = True tr.font.size = Pt(22) tr.font.color.rgb = RGBColor.from_string(UQO_BLUE) sub = " · ".join(x for x in [args.get("course", ""), args.get("subtitle", ""), date.today().strftime("%d %B %Y")] if x) sp = doc.add_paragraph(sub) sp.runs[0].font.color.rgb = RGBColor(0x5B, 0x6B, 0x7B) lines = args["markdown"].replace("\r\n", "\n").split("\n") i = 0 while i < len(lines): line = lines[i] s = line.strip() if not s: i += 1 continue if s.startswith("```"): code = [] i += 1 while i < len(lines) and not lines[i].strip().startswith("```"): code.append(lines[i]) i += 1 p = doc.add_paragraph() run = p.add_run("\n".join(code)) run.font.name = "Consolas" run.font.size = Pt(9) i += 1 continue if s.startswith("$$") and s.endswith("$$") and len(s) > 4: p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER p.add_run(_tex_to_text(s[2:-2])).italic = True i += 1 continue m = re.match(r"^(#{1,4})\s+(.*)", s) if m: doc.add_heading(re.sub(r"[*`]", "", m.group(2)), level=min(len(m.group(1)), 3)) i += 1 continue if s.startswith("|") and i + 1 < len(lines) and re.match(r"^\|?\s*:?-{2,}", lines[i + 1].strip()): header = [c.strip() for c in s.strip("|").split("|")] rows = [] i += 2 while i < len(lines) and lines[i].strip().startswith("|"): rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")]) i += 1 table = doc.add_table(rows=1 + len(rows), cols=len(header)) table.style = "Light Grid Accent 1" for j, h in enumerate(header): cell = table.rows[0].cells[j] cell.text = "" _add_runs(cell.paragraphs[0], h) for run in cell.paragraphs[0].runs: run.bold = True for ri, row in enumerate(rows, 1): for j in range(len(header)): cell = table.rows[ri].cells[j] cell.text = "" _add_runs(cell.paragraphs[0], row[j] if j < len(row) else "") doc.add_paragraph() continue if re.match(r"^[-*•]\s+", s): p = doc.add_paragraph(style="List Bullet") _add_runs(p, re.sub(r"^[-*•]\s+", "", s)) i += 1 continue if re.match(r"^\d+[.)]\s+", s): p = doc.add_paragraph(style="List Number") _add_runs(p, re.sub(r"^\d+[.)]\s+", "", s)) i += 1 continue if s.startswith(">"): p = doc.add_paragraph() p.paragraph_format.left_indent = Pt(18) _add_runs(p, s.lstrip("> ")) for run in p.runs: run.italic = True i += 1 continue if re.match(r"^(-{3,}|\*{3,})$", s): i += 1 continue # paragraph (merge consecutive lines) buf = [s] i += 1 while i < len(lines) and lines[i].strip() and not re.match(r"^(#{1,4}\s|[-*•]\s|\d+[.)]\s|\||>|```|\$\$)", lines[i].strip()): buf.append(lines[i].strip()) i += 1 p = doc.add_paragraph() _add_runs(p, " ".join(buf)) foot = doc.add_paragraph() fr = foot.add_run("Document pédagogique produit avec UQO-Chat — ne constitue pas une évaluation professionnelle. " "Seul un évaluateur agréé (É.A.) membre de l'OEAQ peut signer un rapport d'évaluation. " "L'utilisation de l'IA doit être déclarée conformément au plan de cours.") fr.font.size = Pt(8) fr.font.color.rgb = RGBColor(0x5B, 0x6B, 0x7B) buf_io = io.BytesIO() doc.save(buf_io) return buf_io.getvalue() async def run(args: dict[str, Any], ctx: ToolContext) -> ToolResult: await ctx.report("running", "Mise en page du document Word…") data = build(args) name = args.get("filename") or re.sub(r"[^\w\- ]", "", args["title"])[:60].strip().replace(" ", "_") or "document" if not name.lower().endswith(".docx"): name += ".docx" rec = await file_service.store_artifact(ctx.user_id, ctx.conversation_id, name, data, ftype="docx") art = Artifact(type="docx", file_id=rec.id, filename=rec.filename, url=f"/api/v1/files/{rec.id}") words = len(args["markdown"].split()) return ToolResult(content=f"Document Word « {rec.filename} » créé ({words} mots, {len(data) // 1024} Ko) et " "affiché à l'étudiant avec un bouton Télécharger. Ne recopie pas son contenu intégral.", artifacts=[art], payload={"filename": rec.filename, "file_id": rec.id, "title": args["title"], "words": words, "outline": [re.sub(r"^#+\s*", "", ln.strip()) for ln in args["markdown"].split("\n") if ln.strip().startswith("#")][:20]}, meta={"summary": f"Word : {rec.filename}"}) registry.register("create_docx", run, Args, heavy=True)