Python 64.6%
TypeScript 33.7%
CSS 0.8%
1"""create_docx — Markdown → Word document (fiches de révision, plans d'étude, gabarits de rapport)."""23from __future__ import annotations45import io6import re7from datetime import date8from typing import Any910from pydantic import BaseModel, Field1112from app.llm.schemas import Artifact, ToolResult13from app.services import files as file_service14from app.tools.registry import ToolContext, registry1516UQO_BLUE = "0F6180"171819class Args(BaseModel):20 title: str = Field(..., max_length=200)21 markdown: str = Field(..., min_length=10, max_length=60000)22 filename: str | None = None23 course: str = ""24 subtitle: str = ""252627def _add_runs(par: Any, text: str) -> None:28 """Bold / italic / inline code / inline math ($…$ → italic)."""29 tokens = re.split(r"(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\$[^$]+\$)", text)30 for t in tokens:31 if not t:32 continue33 if t.startswith("**") and t.endswith("**"):34 par.add_run(t[2:-2]).bold = True35 elif t.startswith("`") and t.endswith("`"):36 r = par.add_run(t[1:-1])37 r.font.name = "Consolas"38 elif t.startswith("$") and t.endswith("$"):39 r = par.add_run(_tex_to_text(t[1:-1]))40 r.italic = True41 elif t.startswith("*") and t.endswith("*"):42 par.add_run(t[1:-1]).italic = True43 else:44 par.add_run(t)454647def _tex_to_text(s: str) -> str:48 s = re.sub(r"\\frac\{([^}]*)\}\{([^}]*)\}", r"(\1)/(\2)", s)49 s = s.replace("\\times", "×").replace("\\cdot", "·").replace("\\approx", "≈").replace("\\le", "≤").replace("\\ge", "≥")50 s = re.sub(r"\\text\{([^}]*)\}", r"\1", s)51 s = s.replace("\\,", " ").replace("\\ ", " ").replace("\\$", "$")52 s = re.sub(r"\^\{([^}]*)\}", r"^\1", s)53 s = re.sub(r"_\{([^}]*)\}", r"_\1", s)54 return s.replace("{", "").replace("}", "")555657def build(args: dict[str, Any]) -> bytes:58 from docx import Document59 from docx.enum.text import WD_ALIGN_PARAGRAPH60 from docx.shared import Pt, RGBColor6162 doc = Document()63 style = doc.styles["Normal"]64 style.font.name = "Calibri"65 style.font.size = Pt(11)66 for lvl, size in ((1, 18), (2, 14), (3, 12)):67 hs = doc.styles[f"Heading {lvl}"]68 hs.font.color.rgb = RGBColor.from_string(UQO_BLUE)69 hs.font.size = Pt(size)70 # header band71 hp = doc.add_paragraph()72 r = hp.add_run("UQO-Chat · Tuteur IA — IMM1003 · IMM1033")73 r.font.size = Pt(9)74 r.font.color.rgb = RGBColor(0x5B, 0x6B, 0x7B)75 t = doc.add_paragraph()76 tr = t.add_run(args["title"])77 tr.bold = True78 tr.font.size = Pt(22)79 tr.font.color.rgb = RGBColor.from_string(UQO_BLUE)80 sub = " · ".join(x for x in [args.get("course", ""), args.get("subtitle", ""), date.today().strftime("%d %B %Y")] if x)81 sp = doc.add_paragraph(sub)82 sp.runs[0].font.color.rgb = RGBColor(0x5B, 0x6B, 0x7B)8384 lines = args["markdown"].replace("\r\n", "\n").split("\n")85 i = 086 while i < len(lines):87 line = lines[i]88 s = line.strip()89 if not s:90 i += 191 continue92 if s.startswith("```"):93 code = []94 i += 195 while i < len(lines) and not lines[i].strip().startswith("```"):96 code.append(lines[i])97 i += 198 p = doc.add_paragraph()99 run = p.add_run("\n".join(code))100 run.font.name = "Consolas"101 run.font.size = Pt(9)102 i += 1103 continue104 if s.startswith("$$") and s.endswith("$$") and len(s) > 4:105 p = doc.add_paragraph()106 p.alignment = WD_ALIGN_PARAGRAPH.CENTER107 p.add_run(_tex_to_text(s[2:-2])).italic = True108 i += 1109 continue110 m = re.match(r"^(#{1,4})\s+(.*)", s)111 if m:112 doc.add_heading(re.sub(r"[*`]", "", m.group(2)), level=min(len(m.group(1)), 3))113 i += 1114 continue115 if s.startswith("|") and i + 1 < len(lines) and re.match(r"^\|?\s*:?-{2,}", lines[i + 1].strip()):116 header = [c.strip() for c in s.strip("|").split("|")]117 rows = []118 i += 2119 while i < len(lines) and lines[i].strip().startswith("|"):120 rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")])121 i += 1122 table = doc.add_table(rows=1 + len(rows), cols=len(header))123 table.style = "Light Grid Accent 1"124 for j, h in enumerate(header):125 cell = table.rows[0].cells[j]126 cell.text = ""127 _add_runs(cell.paragraphs[0], h)128 for run in cell.paragraphs[0].runs:129 run.bold = True130 for ri, row in enumerate(rows, 1):131 for j in range(len(header)):132 cell = table.rows[ri].cells[j]133 cell.text = ""134 _add_runs(cell.paragraphs[0], row[j] if j < len(row) else "")135 doc.add_paragraph()136 continue137 if re.match(r"^[-*•]\s+", s):138 p = doc.add_paragraph(style="List Bullet")139 _add_runs(p, re.sub(r"^[-*•]\s+", "", s))140 i += 1141 continue142 if re.match(r"^\d+[.)]\s+", s):143 p = doc.add_paragraph(style="List Number")144 _add_runs(p, re.sub(r"^\d+[.)]\s+", "", s))145 i += 1146 continue147 if s.startswith(">"):148 p = doc.add_paragraph()149 p.paragraph_format.left_indent = Pt(18)150 _add_runs(p, s.lstrip("> "))151 for run in p.runs:152 run.italic = True153 i += 1154 continue155 if re.match(r"^(-{3,}|\*{3,})$", s):156 i += 1157 continue158 # paragraph (merge consecutive lines)159 buf = [s]160 i += 1161 while i < len(lines) and lines[i].strip() and not re.match(r"^(#{1,4}\s|[-*•]\s|\d+[.)]\s|\||>|```|\$\$)", lines[i].strip()):162 buf.append(lines[i].strip())163 i += 1164 p = doc.add_paragraph()165 _add_runs(p, " ".join(buf))166 foot = doc.add_paragraph()167 fr = foot.add_run("Document pédagogique produit avec UQO-Chat — ne constitue pas une évaluation professionnelle. "168 "Seul un évaluateur agréé (É.A.) membre de l'OEAQ peut signer un rapport d'évaluation. "169 "L'utilisation de l'IA doit être déclarée conformément au plan de cours.")170 fr.font.size = Pt(8)171 fr.font.color.rgb = RGBColor(0x5B, 0x6B, 0x7B)172 buf_io = io.BytesIO()173 doc.save(buf_io)174 return buf_io.getvalue()175176177async def run(args: dict[str, Any], ctx: ToolContext) -> ToolResult:178 await ctx.report("running", "Mise en page du document Word…")179 data = build(args)180 name = args.get("filename") or re.sub(r"[^\w\- ]", "", args["title"])[:60].strip().replace(" ", "_") or "document"181 if not name.lower().endswith(".docx"):182 name += ".docx"183 rec = await file_service.store_artifact(ctx.user_id, ctx.conversation_id, name, data, ftype="docx")184 art = Artifact(type="docx", file_id=rec.id, filename=rec.filename, url=f"/api/v1/files/{rec.id}")185 words = len(args["markdown"].split())186 return ToolResult(content=f"Document Word « {rec.filename} » créé ({words} mots, {len(data) // 1024} Ko) et "187 "affiché à l'étudiant avec un bouton Télécharger. Ne recopie pas son contenu intégral.",188 artifacts=[art],189 payload={"filename": rec.filename, "file_id": rec.id, "title": args["title"], "words": words,190 "outline": [re.sub(r"^#+\s*", "", ln.strip()) for ln in args["markdown"].split("\n")191 if ln.strip().startswith("#")][:20]},192 meta={"summary": f"Word : {rec.filename}"})193194195registry.register("create_docx", run, Args, heavy=True)196