Spaces:
Runtime error
Runtime error
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())
|