"""Ingestion: course website HTML (generated notes), PDF, DOCX, PPTX, MD → chunks → DB.""" from __future__ import annotations import hashlib import re from pathlib import Path from selectolax.parser import HTMLParser, Node from sqlalchemy import delete, select from app.core.logging import get_logger from app.db import SessionLocal from app.models import CourseChunk, CourseDocument from app.rag.chunking import Chunk, chunk_text log = get_logger("rag.ingest") SITE_URLS = {"IMM1003": "https://www.uqo-imm1003.app", "IMM1033": "https://www.uqo-imm1033.app"} # ------------------------------------------------------------------ HTML helpers def _katex_to_tex(root: Node) -> None: for k in root.css("span.katex, span.katex-display"): ann = k.css_first("annotation") tex = ann.text() if ann else "" display = "katex-display" in (k.attributes.get("class") or "") k.replace_with(f" $${tex}$$ " if display else f" ${tex}$ ") def _clean(root: Node) -> None: for sel in ("script", "style", "svg", "nav", "button", ".h-anchor", ".box-anchor", ".widget", ".explorer", "form", "input", ".ch-hero .deco", ".watermark"): for n in root.css(sel): n.decompose() def _node_text(n: Node) -> str: txt = n.text(separator=" ", strip=False) return re.sub(r"[ \t]+", " ", txt).strip() def _block_to_text(n: Node) -> str: tag = n.tag cls = n.attributes.get("class") or "" if tag in {"h2", "h3", "h4"}: return f"\n\n{'#' * (int(tag[1]))} {_node_text(n)}\n\n" if "box" in cls.split() or cls.startswith("box"): label = n.css_first(".box-label") title = n.css_first(".box-title") body = n.css_first(".box-body") head = " — ".join(x.text(strip=True) for x in (label, title) if x is not None) return f"\n\n**{head}** : {_node_text(body) if body else _node_text(n)}\n\n" if tag in {"ul", "ol"}: items = [f"- {_node_text(li)}" for li in n.css("li")] return "\n" + "\n".join(items) + "\n\n" if tag == "table": rows = [] for tr in n.css("tr"): cells = [_node_text(td) for td in tr.css("th, td")] rows.append(" | ".join(cells)) return "\n" + "\n".join(rows) + "\n\n" if tag in {"p", "div", "blockquote", "figure", "figcaption", "dl", "pre"}: return "\n\n" + _node_text(n) + "\n\n" return " " + _node_text(n) + " " def _flatten(container: Node) -> str: out = [] for child in container.iter(include_text=True): if child.tag == "-text": t = child.text(strip=False) if t and t.strip(): out.append(" " + t.strip() + " ") else: out.append(_block_to_text(child)) text = "".join(out) text = re.sub(r"[ \t]+", " ", text) text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() def _split_sections(article: Node) -> list[tuple[str, str, str]]: """Return [(section_id, section_title, text)] split on h2.""" sections: list[tuple[str, str, list[str]]] = [] current: tuple[str, str, list[str]] | None = ("", "Introduction", []) for child in article.iter(include_text=True): if child.tag == "h2": if current and "".join(current[2]).strip(): sections.append(current) sid = child.attributes.get("id") or "" current = (sid, _node_text(child), []) elif child.tag == "-text": t = child.text(strip=False) if t and t.strip() and current is not None: current[2].append(" " + t.strip() + " ") else: if current is not None: current[2].append(_block_to_text(child)) if current and "".join(current[2]).strip(): sections.append(current) out = [] for sid, title, parts in sections: text = re.sub(r"\n{3,}", "\n\n", re.sub(r"[ \t]+", " ", "".join(parts))).strip() out.append((sid, title, text)) return out def parse_seance_html(html: str, course: str, num: str) -> tuple[str, list[Chunk]]: tree = HTMLParser(html) main = tree.css_first("main") if main is None: return "", [] _clean(main) _katex_to_tex(main) title_attr = main.attributes.get("data-title") or "" h1 = main.css_first("h1") chapter = title_attr or (h1.text(strip=True) if h1 else f"Séance {num}") module = chapter if chapter.lower().startswith("séance") else f"Séance {int(num)} — {chapter}" article = main.css_first("article") or main base = f"{SITE_URLS.get(course, '')}/seance/{num}/" chunks: list[Chunk] = [] for sid, sec_title, text in _split_sections(article): if len(text) < 80: continue url = f"{base}#{sid}" if sid else base for c in chunk_text(text, module=module, section=sec_title, page=f"S{int(num)}", url=url): c.metadata = {"kind": "notes", "seance": int(num)} chunks.append(c) return module, chunks def parse_generic_page(html: str, course: str, slug: str, title: str) -> list[Chunk]: tree = HTMLParser(html) main = tree.css_first("main") if main is None: return [] _clean(main) _katex_to_tex(main) entries = main.css(".gl-entry") chunks: list[Chunk] = [] url = f"{SITE_URLS.get(course, '')}/{slug}/" if entries: # glossary: pack definitions buf: list[str] = [] for e in entries: term = e.css_first("h3") body = e.css_first(".gl-def") buf.append(f"**{term.text(strip=True) if term else ''}** : " f"{_node_text(body) if body else _node_text(e)}") text = "\n\n".join(buf) for c in chunk_text(text, module=title, section="Définitions", page="G", url=url): c.metadata = {"kind": "glossary"} chunks.append(c) return chunks text = _flatten(main) for c in chunk_text(text, module=title, section=title, page="", url=url): c.metadata = {"kind": slug} chunks.append(c) return chunks # ------------------------------------------------------------------ file parsers def parse_pdf(path: Path) -> list[tuple[str, str]]: import pdfplumber pages = [] with pdfplumber.open(str(path)) as pdf: for i, page in enumerate(pdf.pages, 1): text = page.extract_text() or "" for table in page.extract_tables() or []: text += "\n\n" + "\n".join(" | ".join(str(c or "") for c in row) for row in table) if text.strip(): pages.append((str(i), text)) return pages def parse_docx(path: Path) -> str: import docx d = docx.Document(str(path)) parts = [p.text for p in d.paragraphs if p.text.strip()] for t in d.tables: for row in t.rows: parts.append(" | ".join(c.text for c in row.cells)) return "\n\n".join(parts) def parse_pptx(path: Path) -> list[tuple[str, str]]: from pptx import Presentation prs = Presentation(str(path)) out = [] for i, slide in enumerate(prs.slides, 1): texts = [] for shape in slide.shapes: if shape.has_text_frame: texts.append(shape.text_frame.text) if any(t.strip() for t in texts): out.append((str(i), "\n".join(texts))) return out # ------------------------------------------------------------------ persistence def _checksum(data: bytes) -> str: return hashlib.sha256(data).hexdigest() async def _replace_document(course: str, filename: str, title: str, checksum: str, chunks: list[Chunk], visibility: str) -> CourseDocument: async with SessionLocal() as session: existing = await session.execute( select(CourseDocument).where(CourseDocument.course_code == course, CourseDocument.filename == filename)) for doc in existing.scalars(): await session.execute(delete(CourseChunk).where(CourseChunk.document_id == doc.id)) await session.delete(doc) doc = CourseDocument(course_code=course, filename=filename, title=title, checksum=checksum, visibility=visibility, n_chunks=len(chunks)) session.add(doc) await session.flush() for c in chunks: session.add(CourseChunk(document_id=doc.id, course_code=course, module=c.module, section=c.section, page=c.page, url=c.url, content=c.content, metadata_=c.metadata, visibility=visibility)) await session.commit() return doc async def ingest_site(course: str, dist_dir: Path, visibility: str = "students") -> int: """Ingest a generated course website (dist//).""" course = course.upper() total = 0 for seance_dir in sorted((dist_dir / "seance").glob("*/")): page = seance_dir / "index.html" if not page.exists(): continue html = page.read_bytes() module, chunks = parse_seance_html(html.decode("utf-8", errors="replace"), course, seance_dir.name) if chunks: await _replace_document(course, f"seance-{seance_dir.name}.html", module, _checksum(html), chunks, visibility) total += len(chunks) log.info("ingested", course=course, doc=module, n=len(chunks)) for slug, title in (("glossaire", "Glossaire"), ("aide-memoire", "Aide-mémoire des formules"), ("fiches", "Fiches synthèse"), ("definitions", "Définitions"), ("exercices", "Exercices"), ("ressources", "Ressources")): page = dist_dir / slug / "index.html" if not page.exists(): continue html = page.read_bytes() chunks = parse_generic_page(html.decode("utf-8", errors="replace"), course, slug, title) if chunks: await _replace_document(course, f"{slug}.html", title, _checksum(html), chunks, visibility) total += len(chunks) log.info("ingested", course=course, doc=title, n=len(chunks)) return total async def ingest_file(course: str, path: Path, visibility: str = "students", module: str | None = None) -> int: course = course.upper() data = path.read_bytes() ext = path.suffix.lower() title = module or path.stem.replace("_", " ") chunks: list[Chunk] = [] if ext == ".pdf": for page, text in parse_pdf(path): chunks += chunk_text(text, module=title, section=f"p. {page}", page=page) elif ext == ".docx": chunks = chunk_text(parse_docx(path), module=title, section=title) elif ext == ".pptx": for slide, text in parse_pptx(path): chunks += chunk_text(text, module=title, section=f"diapo {slide}", page=slide) elif ext in {".md", ".txt", ".tex"}: text = data.decode("utf-8", errors="replace") if ext == ".tex": text = re.sub(r"\\[a-zA-Z]+\*?(\[[^\]]*\])?", " ", text) text = re.sub(r"[{}%]", " ", text) chunks = chunk_text(text, module=title, section=title) elif ext in {".html", ".htm"}: chunks = parse_generic_page(data.decode("utf-8", errors="replace"), course, path.stem, title) else: raise ValueError(f"Format non pris en charge : {ext}") for c in chunks: c.metadata = {"kind": "upload", "file": path.name} await _replace_document(course, path.name, title, _checksum(data), chunks, visibility) return len(chunks)