SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%
7.8 KB · 196 lines python
Raw Blame History
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