File size: 12,952 Bytes
4f32045
 
 
 
f565440
4f32045
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7435134
 
4f32045
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f565440
 
 
 
 
 
 
4f32045
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
deb8048
 
 
4f32045
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f565440
4f32045
f565440
 
 
 
4f32045
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f565440
4f32045
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
"""
Genere un jeu d'evaluation (question -> article attendu) a partir du corpus.

Le principe est simple et le piege l'est tout autant : des questions ecrites par
un modele (celui de LLM_PROVIDER) a partir d'un article, notees par un systeme qui doit retrouver cet
article, forment une boucle fermee. Deux garde-fous l'ouvrent, et ils ne sont pas
optionnels :

1. FILTRE DE RECOUPEMENT LEXICAL. Sans lui, le modele produit une question qui
   recopie la phrase de l'article, la recherche plein texte marque 100 % et le
   jeu ne mesure rien. Le recoupement de Jaccard entre la question et l'article
   est calcule, et tout ce qui depasse le seuil est rejete.
2. RELECTURE HUMAINE. Le script ecrit un fichier de relecture ; les items ne
   sont utilisables qu'une fois marques `reviewed`.

Un troisieme controle SIGNALE sans supprimer : si l'article attendu est absent
du haut du classement actuel, l'item est marque a relire. Le supprimer
biaiserait le jeu vers ce que le systeme trouve deja — le mode d'echec classique
des evaluations auto-generees.

Usage:
    python -m scripts.eval.generate_eval_set --sample 120 --seed 7
    python -m scripts.eval.generate_eval_set --sample 20 --dry-run
"""

import argparse
import json
import logging
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence

from sqlalchemy import text

sys.path.insert(0, ".")

from app.core.database import SyncSessionLocal
from app.services.text_features import content_words, jaccard

logger = logging.getLogger("generate_eval_set")

DEFAULT_OUT = Path("tests/fixtures/eval/retrieval_eval_v1.json")

# Au-dela, la question recopie le vocabulaire de l'article et ne teste plus la
# recherche mais la correspondance de chaines.
MAX_LEXICAL_OVERLAP = 0.35

# Formules d'execution : presentes dans des milliers de decrets a l'identique,
# elles ne peuvent designer aucun document en particulier.
BOILERPLATE = "%enregistr%journal officiel%"

_SCHEMA = {
    "type": "object",
    "properties": {
        "questions": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "keyword": {"type": "string"},
                    "paraphrase": {"type": "string"},
                },
                "required": ["keyword", "paraphrase"],
            },
        }
    },
    "required": ["questions"],
}

_PROMPT = """Voici un article de droit camerounais.

Loi : {law_title} ({reference})
Article {number}{title_part}
Contenu :
{content}

Écris DEUX questions que poserait une personne qui n'a PAS lu ce texte et qui cherche justement l'information qu'il contient :
1. "keyword" : formulation courte, comme une recherche.
2. "paraphrase" : question complète en langage courant.

Contraintes strictes :
- ne cite JAMAIS le numéro de l'article ni le titre exact de la loi ;
- reprends au maximum DEUX mots de contenu présents dans l'article ;
- interroge la SITUATION concrète, pas la formulation du texte ;
- français, une phrase par question."""

# Systeme neutre, OBLIGATOIRE : sans lui, `generate` retombe sur l'instruction
# du chat, qui exige de citer des articles et de finir par « Sources: ».
_SYSTEME = (
    "Tu rédiges des questions de test pour un moteur de recherche juridique. "
    "Tu réponds uniquement par le JSON demandé."
)


def _parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--sample", type=int, default=120,
                        help="articles candidats (viser ~2x la taille cible)")
    parser.add_argument("--seed", type=int, default=7)
    parser.add_argument("--out", type=Path, default=DEFAULT_OUT)
    parser.add_argument("--max-overlap", type=float, default=MAX_LEXICAL_OVERLAP)
    parser.add_argument("--holdout", type=int, default=15,
                        help="items reserves au lot 'test', jamais utilises pour regler")
    parser.add_argument("--dry-run", action="store_true",
                        help="echantillonne et affiche, sans appeler l'API")
    return parser.parse_args(argv)


def _sample_articles(session, sample: int, seed: int) -> List[Dict[str, Any]]:
    """
    Echantillonnage stratifie.

    Un tirage uniforme donnerait un jeu compose a moitie de decrets de
    nomination : il mesurerait la recherche sur la partie la moins interessante
    du corpus.
    """
    session.execute(text("SELECT setseed(:seed)"), {"seed": (seed % 1000) / 1000})

    strata = [
        # 60 % : articles normatifs (loi consequente, article substantiel)
        ("normatif", int(sample * 0.6), """
            SELECT a.id, a.number, a.title, a.section, a.content,
                   l.id AS law_id, l.title AS law_title, l.reference
            FROM articles a JOIN laws l ON l.id = a.law_id
            WHERE length(a.content) > 400
              AND lower(a.content) NOT LIKE :boilerplate
              AND (SELECT count(*) FROM articles x WHERE x.law_id = l.id) >= 5
            ORDER BY random() LIMIT :n
        """),
        # 20 % : gros textes (constitution, codes, actes uniformes)
        ("gros_texte", int(sample * 0.2), """
            SELECT a.id, a.number, a.title, a.section, a.content,
                   l.id AS law_id, l.title AS law_title, l.reference
            FROM articles a JOIN laws l ON l.id = a.law_id
            WHERE length(a.content) > 200
              AND lower(a.content) NOT LIKE :boilerplate
              AND (SELECT count(*) FROM articles x WHERE x.law_id = l.id) > 100
            ORDER BY random() LIMIT :n
        """),
        # 20 % : tirage uniforme, pour garder la longue traine
        ("aleatoire", sample - int(sample * 0.6) - int(sample * 0.2), """
            SELECT a.id, a.number, a.title, a.section, a.content,
                   l.id AS law_id, l.title AS law_title, l.reference
            FROM articles a JOIN laws l ON l.id = a.law_id
            WHERE length(a.content) > 200
              AND lower(a.content) NOT LIKE :boilerplate
            ORDER BY random() LIMIT :n
        """),
    ]

    seen, rows = set(), []
    for label, count, sql in strata:
        if count <= 0:
            continue
        result = session.execute(text(sql), {"n": count, "boilerplate": BOILERPLATE})
        for row in result.mappings():
            if row["id"] in seen:
                continue
            seen.add(row["id"])
            rows.append({**dict(row), "stratum": label})
    return rows


def _corpus_snapshot(session) -> Dict[str, Any]:
    """Le jeu n'a de sens que contre un etat de corpus donne : on l'enregistre."""
    return {
        "laws": int(session.execute(text("SELECT count(*) FROM laws")).scalar() or 0),
        "articles": int(session.execute(text("SELECT count(*) FROM articles")).scalar() or 0),
        "alembic_head": str(
            session.execute(text("SELECT version_num FROM alembic_version")).scalar() or ""
        ),
    }


def _build_items(article: Dict[str, Any], questions: Dict[str, str],
                 max_overlap: float) -> List[Dict[str, Any]]:
    article_words = content_words(article["content"])
    items = []
    for difficulty, question in questions.items():
        overlap = jaccard(content_words(question), article_words)
        items.append({
            "question": question.strip(),
            "expected_article_id": int(article["id"]),
            "expected_law_id": int(article["law_id"]),
            "expected_article_number": str(article["number"] or ""),
            "stratum": article["stratum"],
            "difficulty": difficulty,
            "lexical_overlap": round(overlap, 4),
            "rejected": overlap > max_overlap,
            "reviewed": False,
            "reviewer_note": "",
        })
    return items


def _write_review_file(path: Path, items: List[Dict[str, Any]],
                       articles: Dict[int, Dict[str, Any]]) -> None:
    lines = [
        "# Relecture du jeu d'evaluation",
        "",
        "Marquez `reviewed: true` dans le JSON pour chaque question retenue,",
        "corrigez celles qui sont ambigues, supprimez les autres.",
        "Un item non relu n'est pas utilise par run_eval.py.",
        "",
        "| # | recoupement | question | article attendu |",
        "|---|---|---|---|",
    ]
    for index, item in enumerate(items):
        source = articles[item["expected_article_id"]]
        excerpt = " ".join(source["content"].split())[:140]
        # `rejected` est retire des items retenus avant l'ecriture du jeu :
        # le lire en dur levait KeyError apres une generation entiere.
        flag = " ⚠️" if item.get("rejected") else ""
        lines.append(
            f"| {index} | {item['lexical_overlap']:.2f}{flag} | {item['question']} | "
            f"{source['reference']} art. {source['number']} — {excerpt}… |"
        )
    path.write_text("\n".join(lines) + "\n", encoding="utf-8")


def main(argv: Optional[Sequence[str]] = None) -> int:
    args = _parse_args(argv)
    logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s",
                        datefmt="%H:%M:%S")

    with SyncSessionLocal() as session:
        snapshot = _corpus_snapshot(session)
        logger.info("Corpus : %s lois, %s articles, head %s",
                    snapshot["laws"], snapshot["articles"], snapshot["alembic_head"])
        if snapshot["articles"] == 0:
            logger.error("Corpus vide : rien a echantillonner")
            return 1

        articles = _sample_articles(session, args.sample, args.seed)

    logger.info("%s articles echantillonnes", len(articles))
    if args.dry_run:
        for article in articles[:10]:
            logger.info("  [%s] %s art. %s (%s car.)", article["stratum"],
                        article["reference"], article["number"], len(article["content"]))
        logger.info("--dry-run : aucun appel a l'API, aucune ecriture")
        return 0

    from app.services.llm import get_llm_service

    try:
        llm = get_llm_service()
    except Exception as exc:
        logger.error("Service de generation indisponible : %s", exc)
        return 1

    import asyncio

    by_id = {a["id"]: a for a in articles}
    items: List[Dict[str, Any]] = []

    async def _generate() -> None:
        for position, article in enumerate(articles, start=1):
            prompt = _PROMPT.format(
                law_title=article["law_title"],
                reference=article["reference"],
                number=article["number"],
                title_part=f" — {article['title']}" if article["title"] else "",
                content=article["content"][:4000],
            )
            try:
                response = await llm.generate(
                    prompt=prompt, system=_SYSTEME, temperature=0.7, max_tokens=512,
                    response_mime_type="application/json", response_schema=_SCHEMA,
                )
                payload = json.loads(response["response"])
                pair = payload["questions"][0]
            except Exception as exc:
                logger.warning("Article %s ignore : %s", article["id"], exc)
                continue

            items.extend(_build_items(
                article,
                {"keyword": pair.get("keyword", ""), "paraphrase": pair.get("paraphrase", "")},
                args.max_overlap,
            ))
            if position % 10 == 0:
                logger.info("%s/%s articles traites", position, len(articles))

    asyncio.run(_generate())

    kept = [item for item in items if not item["rejected"] and item["question"]]
    rejected = len(items) - len(kept)

    # Lot tenu a l'ecart, jamais utilise pour regler quoi que ce soit : si les
    # deux lots ne s'accordent pas, le reglage est du bruit.
    for index, item in enumerate(kept):
        item["split"] = "test" if index < args.holdout else "dev"
        item.pop("rejected", None)

    overlaps = [item["lexical_overlap"] for item in kept]
    logger.info(
        "%s questions retenues, %s rejetees pour recoupement > %.2f "
        "(recoupement median des retenues : %.2f)",
        len(kept), rejected, args.max_overlap,
        sorted(overlaps)[len(overlaps) // 2] if overlaps else 0.0,
    )

    args.out.parent.mkdir(parents=True, exist_ok=True)
    args.out.write_text(json.dumps({
        "version": 1,
        "generator_model": getattr(llm, "model_name", "?"),
        "sampling_seed": args.seed,
        "max_lexical_overlap": args.max_overlap,
        "corpus_snapshot": snapshot,
        "items": kept,
    }, ensure_ascii=False, indent=2), encoding="utf-8")

    _write_review_file(args.out.with_suffix(".review.md"), kept, by_id)
    logger.info("Ecrit : %s et %s", args.out, args.out.with_suffix(".review.md"))
    logger.info("RELECTURE OBLIGATOIRE : marquez reviewed=true sur les items retenus.")
    return 0


if __name__ == "__main__":
    sys.exit(main())