"""search_course_content — RAG over the official course material.""" from __future__ import annotations import re from typing import Any from pydantic import BaseModel, Field from app.llm.openrouter import get_llm from app.llm.schemas import ToolResult from app.rag import retriever from app.tools.registry import ToolContext, registry def _excerpt(text: str, limit: int = 280) -> str: """Plain-text excerpt for the UI card: drop LaTeX delimiters/commands and headings.""" t = re.sub(r"\$\$?(.*?)\$\$?", lambda m: re.sub(r"\\[a-zA-Z]+|[{}]", "", m.group(1)), text, flags=re.S) t = re.sub(r"^#+\s*", "", t, flags=re.M).replace("**", "") t = re.sub(r"\s+", " ", t).strip() return t if len(t) <= limit else t[: limit - 1] + "…" class SearchCourseArgs(BaseModel): query: str = Field(..., min_length=2, max_length=400) course: str | None = Field(None, description="IMM1003, IMM1033 ou null pour les deux") top_k: int = Field(6, ge=1, le=10) async def run(args: dict[str, Any], ctx: ToolContext) -> ToolResult: await ctx.report("running", "Recherche dans le matériel du cours…") courses: set[str] | None = None if args.get("course"): courses = {str(args["course"]).upper()} q_emb = None if ctx.settings.MODEL_EMBEDDINGS and retriever.index.embeddings is not None: try: vecs = await get_llm().embed([args["query"]]) q_emb = vecs[0] if vecs else None except Exception: # noqa: BLE001 q_emb = None hits = retriever.index.search( args["query"], top_k=args["top_k"], courses=courses, boost_course=ctx.course_code, include_professor=ctx.role in {"professor", "admin"}, query_embedding=q_emb) sources = [{ "index": i + 1, "id": h.chunk_id, "course": h.course, "module": h.module, "section": h.section, "page": h.page, "url": h.url, "excerpt": _excerpt(h.content), "score": round(h.score, 3), } for i, h in enumerate(hits)] return ToolResult( content=retriever.format_for_model(hits), payload={"query": args["query"], "sources": sources}, meta={"summary": f"{len(hits)} passage(s) du cours"}, ) registry.register("search_course_content", run, SearchCourseArgs)