"""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)