درس 2 — Construire pas à pas

Découper avec ast

5 دقائق قراءة

هذا المحتوى غير مترجم بعد. تُعرض النسخة الأصلية.

Découper avec ast

L'arbre syntaxique

ast.parse(source) transforme le code en arbre syntaxique abstrait (AST) : chaque fonction, classe ou affectation devient un nœud qui connaît sa ligne de début (lineno) et de fin (end_lineno). Pas besoin d'expressions régulières fragiles pour trouver où finit une fonction.

flowchart TD
  M["Module facturo/models.py"] --> C1["ClassDef Client (l. 6-11)"]
  M --> C2["ClassDef LigneFacture (l. 14-22)"]
  M --> C3["ClassDef Facture (l. 25-44)"]
  C2 --> F1["FunctionDef montant_ht"]
  C3 --> F2["FunctionDef total_ht (l. 35-38)"]
  C3 --> F3["FunctionDef total_ttc (l. 40-44)"]

Le découpeur

files/code_rag.py
def chunk_python(path: str, source: str) -> list[dict]:
    tree = ast.parse(source)
    module_doc = (ast.get_docstring(tree) or "").split("\n")[0]
    lines = source.splitlines()
    seg = lambda a, b: "\n".join(lines[a - 1:b])
    chunks = []

    def add(kind, name, node, text=None):
        start = min([d.lineno for d in getattr(node, "decorator_list", [])] + [node.lineno])
        chunks.append({"path": path, "kind": kind, "name": name, "start": start, "end": node.end_lineno,
                       "text": text or seg(start, node.end_lineno), "module_doc": module_doc})

    for node in tree.body:
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            add("fonction", node.name, node)
        elif isinstance(node, ast.ClassDef):
            methods = [n for n in node.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
            resume = [seg(node.lineno, node.lineno)]
            if ast.get_docstring(node):
                resume.append(f'    """{ast.get_docstring(node)}"""')
            resume += [f"    def {m.name}({ast.unparse(m.args)}): ..." for m in methods]
            add("classe", node.name, node, "\n".join(resume))
            for m in methods:
                add("methode", f"{node.name}.{m.name}", m)
        elif isinstance(node, (ast.Assign, ast.AnnAssign)):
            ...  # constantes de module : TAUX_TVA, REMISE_MAX_PCT, PALIERS_RELANCE
    return chunks
Type de morceauContenuPourquoi
fonctioncode complet, décorateurs comprisl'unité naturelle d'une question « comment ? »
methodecode complet, nom qualifié Facture.total_ttcune classe entière serait trop longue et trop vague
classeen-tête + docstring + signatures des méthodesvue d'ensemble : « que sait faire DepotFactures ? »
constantel'affectation de module« où est défini le taux réduit ? » pointe sur TAUX_TVA

Pourquoi le morceau « classe » ne contient-il que les signatures des méthodes ?

La docstring du module : un titre, pas un morceau

Une première version créait aussi un morceau par docstring de module (« Relances des factures impayées. »). Mesuré sur les questions de référence, c'était nuisible : ces textes courts et génériques se plaçaient en tête et poussaient les vraies fonctions hors du top-k (rappel@3 de la recherche hybride : 0,85 avec, 1,0 sans). Elle sert désormais de contexte dans le titre d'embedding de chaque morceau :

files/code_rag.py
def titre(c) -> str:
    """Titre d'embedding : fichier, rôle du module (1re ligne de sa docstring), type et nom."""
    return f"{c['path']} ({c['module_doc']}) {c['kind']} {c['name']}"

Exemple : title: facturo/tva.py (Taux et calcul de la TVA.) fonction calcul_tva | text: def calcul_tva(...).

Index incrémental

Même principe qu'au lab 1 : une table files(path, sha256), et seuls les fichiers dont l'empreinte change sont redécoupés et réembeddés. Les fichiers supprimés disparaissent de l'index. Les chemins sont relatifs à la racine du projet (facturo/tva.py), pour que les liens restent valables quel que soit l'emplacement du dépôt.

Un développeur renomme une fonction dans facturo/tva.py. Que recalcule index_folder ?

Exercice : les fonctions imbriquées

chunk_python ne parcourt que tree.body (le premier niveau) et le corps des classes. Une fonction définie à l'intérieur d'une autre fonction n'a pas de morceau propre. Est-ce un problème ? Comment l'ajouter si besoin ?

Voir la solution

En général ce n'est pas un problème : la fonction imbriquée est déjà dans le texte de sa fonction parente, donc retrouvable. Si vous voulez quand même un morceau dédié (fonctions internes très longues), parcourez l'arbre avec ast.walk :

files/code_rag.py (variante)
for parent in ast.walk(tree):
    if isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef)):
        for child in parent.body:
            if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
                add("fonction_interne", f"{parent.name}.<locals>.{child.name}", child)

<locals> reprend la convention de Python pour les noms qualifiés (__qualname__). Mesurez avant de garder : plus de morceaux n'est pas forcément mieux, comme l'a montré la docstring de module.

Résultat obtenu

Indexation réelle du 2026-10-15, puis réindexation sans modification :

$ python code_rag.py index projet_demo
{'fichiers_indexes': 16, 'inchanges': 0, 'supprimes': 0, 'morceaux': 44}
$ python code_rag.py index projet_demo
{'fichiers_indexes': 0, 'inchanges': 16, 'supprimes': 0, 'morceaux': 0}

44 morceaux pour 16 fichiers (la version avec un morceau par docstring de module en comptait 60).

Je sais faire

  • Parcourir un AST Python et récupérer les lignes exactes de chaque nœud
  • Choisir le contenu d'un morceau selon son type (fonction, méthode, classe, constante)
  • Utiliser un contexte (docstring de module) dans le titre d'embedding plutôt qu'en morceau séparé
التالي