Spaces:
Runtime error
Runtime error
Download tests/test_services/test_title_priority.py from rahim237cm/jurix-api: direct link, hf CLI and curl.
- Browser
- Download file 12.8 kB
-
https://huggingface.co/spaces/rahim237cm/jurix-api/resolve/main/tests/test_services/test_title_priority.py
- Command line
-
hf download hf://spaces/rahim237cm/jurix-api/tests/test_services/test_title_priority.py
-
curl -L -o test_title_priority.py https://huggingface.co/spaces/rahim237cm/jurix-api/resolve/main/tests/test_services/test_title_priority.py
12.8 kB
| """ | |
| Le titre passe devant le corps du texte, et les fautes de frappe passent. | |
| Ces tests figent trois comportements qui n'existaient pas : | |
| 1. Un document dont le TITRE porte le mot cherche arrive avant un document qui | |
| ne le porte que dans son corps. Mesure avant correctif, requete | |
| « nomination » : le document dont le titre ne contient PAS le mot sortait | |
| PREMIER (ts_rank_cd 0,4000) devant cinq documents dont le titre le porte | |
| (0,2000), parce qu'il le repete dans son corps. | |
| 2. Une faute de frappe trouve quand meme, sans jamais devancer une | |
| correspondance exacte. | |
| 3. Une seule loi ne peut plus prendre tout le budget de chunks. Mesure avant | |
| correctif : « avancement de grade » rendait 60 chunks issus de la MEME loi, | |
| donc UN SEUL document affiche. | |
| Le principe de ces tests : ils assertent un ORDRE et des IDENTIFIANTS. Le test | |
| qu'ils remplacent (`test_text_search_typo_tolerance`) n'assertait que | |
| `isinstance(results, list)`, ne demandait meme pas de donnees, et passait sur une | |
| base vide. | |
| Usage: | |
| pytest tests/test_services/test_title_priority.py -v | |
| """ | |
| from datetime import date | |
| import pytest | |
| from app.models.law import Article, Law | |
| from app.schemas.search import SearchRequest | |
| from app.services.legal_domain_classifier import AFFAIRES | |
| from app.services.postgres_search_service import ( | |
| MAX_CHUNKS_PER_LAW, | |
| mots_significatifs, | |
| search_articles_pg, | |
| ) | |
| from app.services.search_service import SearchService | |
| from app.services.search_vectors import REINDEX_ARTICLES_SQL, REINDEX_LAWS_SQL | |
| # Loi 200-204 : le mot « nomination » est dans le TITRE. | |
| # Loi 210-211 : il n'est QUE dans le corps, et repete, ce qui suffisait a les | |
| # faire passer devant. | |
| TITLE_LAWS = (200, 201, 202, 203, 204) | |
| BODY_LAWS = (210, 211) | |
| async def ranking_corpus(db_session, category_ids): | |
| """Cinq lois « nomination » par le titre, deux par le corps seulement.""" | |
| from sqlalchemy import text as sql_text | |
| laws = [] | |
| for offset, law_id in enumerate(TITLE_LAWS): | |
| laws.append(Law( | |
| id=law_id, reference=f"DEC-2024-{law_id}", | |
| title=f"Décret N°2024/{law_id} portant nomination d'un Directeur", | |
| content="Le présent décret sera enregistré et publié au Journal Officiel.", | |
| type="décret", language="fr", status="published", | |
| category_id=category_ids["Fonction Publique"], | |
| publication_date=date(2024, 1, 1 + offset), | |
| )) | |
| for offset, law_id in enumerate(BODY_LAWS): | |
| laws.append(Law( | |
| id=law_id, reference=f"DEC-2024-{law_id}", | |
| title=f"Décret N°2024/{law_id} portant approbation des statuts", | |
| # « nomination » repete : c'est ce qui faisait gagner ces lois. | |
| content=("La nomination des dirigeants, la nomination du président et " | |
| "la nomination des commissaires obéissent aux statuts."), | |
| type="décret", language="fr", status="published", | |
| category_id=category_ids[AFFAIRES], | |
| publication_date=date(2024, 2, 1 + offset), | |
| )) | |
| db_session.add_all(laws) | |
| await db_session.flush() | |
| # 25 articles par loi : sans plafond par loi, UNE SEULE loi remplirait les | |
| # 60 chunks du budget et le front n'afficherait qu'un document. C'est le | |
| # comportement mesure sur le corpus reel avant correctif. | |
| articles = [] | |
| for law in laws: | |
| for number in range(1, 26): | |
| articles.append(Article( | |
| law_id=law.id, number=str(number), | |
| content=(f"Article {number} du texte. " + (law.content or "")) * 2, | |
| order=number, kind="article", embed=True, | |
| )) | |
| db_session.add_all(articles) | |
| await db_session.commit() | |
| await db_session.execute(sql_text(REINDEX_LAWS_SQL)) | |
| await db_session.execute(sql_text(REINDEX_ARTICLES_SQL)) | |
| await db_session.commit() | |
| return laws | |
| def service(db_session): | |
| return SearchService(db_session, use_cache=False) | |
| class TestTitleBeforeBody: | |
| async def test_a_title_match_leads_a_body_only_match(self, service, ranking_corpus): | |
| results = await service.text_search("nomination", limit=20) | |
| assert results, "aucun resultat" | |
| assert results[0].law_id in TITLE_LAWS, ( | |
| f"le premier resultat est {results[0].law_id}, qui ne porte pas le mot " | |
| "dans son titre" | |
| ) | |
| async def test_no_body_match_appears_before_a_title_match(self, service, ranking_corpus): | |
| scopes = [r.match_scope for r in await service.text_search("nomination", limit=20)] | |
| assert scopes, "aucun resultat" | |
| assert scopes == sorted(scopes, key=lambda s: s != "title"), ( | |
| f"ordre des portees obtenu : {scopes}" | |
| ) | |
| async def test_nothing_is_lost(self, service, ranking_corpus): | |
| """« Titre d'abord » ne veut pas dire « titre seulement ».""" | |
| found = {r.law_id for r in await service.text_search("nomination", limit=20)} | |
| assert set(TITLE_LAWS) <= found | |
| assert set(BODY_LAWS) <= found, "les correspondances de corps ont disparu" | |
| async def test_match_scope_is_exact_not_guessed(self, service, ranking_corpus): | |
| by_law = {r.law_id: r.match_scope for r in await service.text_search("nomination", limit=20)} | |
| for law_id in TITLE_LAWS: | |
| assert by_law[law_id] == "title" | |
| for law_id in BODY_LAWS: | |
| assert by_law[law_id] == "body" | |
| async def test_a_word_absent_from_every_title_still_returns_results( | |
| self, service, ranking_corpus | |
| ): | |
| """La branche titre s'ajoute a la recherche de corps, elle ne la remplace pas.""" | |
| results = await service.text_search("commissaires", limit=20) | |
| assert {r.law_id for r in results} >= set(BODY_LAWS) | |
| assert all(r.match_scope == "body" for r in results) | |
| async def test_the_stored_vector_carries_the_A_weight(self, db_session, ranking_corpus): | |
| """Sans `setweight`, tout ce qui precede est indecidable.""" | |
| from sqlalchemy import text as sql_text | |
| vector = (await db_session.execute(sql_text( | |
| "SELECT search_vector::text FROM laws WHERE id = :i"), {"i": TITLE_LAWS[0]} | |
| )).scalar_one() | |
| assert "A" in vector, "aucun lexeme de poids A : la ponderation est absente" | |
| class TestTypoTolerance: | |
| async def test_a_typo_finds_exactly_the_right_laws(self, service, ranking_corpus): | |
| """« nominaton » ne rend RIEN en plein texte ; le trigramme rattrape.""" | |
| results = await service.text_search("nominaton", limit=20) | |
| assert {r.law_id for r in results} == set(TITLE_LAWS) | |
| assert all(r.match_scope == "title" for r in results) | |
| async def test_a_typo_below_the_default_threshold_is_still_caught( | |
| self, service, ranking_corpus | |
| ): | |
| """ | |
| word_similarity('nominasion', <titre>) = 0,571 : SOUS le defaut de | |
| session de l'operateur %> (0,6), au-dessus du seuil configure (0,5). | |
| Ce test echoue si le `SET LOCAL` disparait — la faute moyenne serait | |
| alors silencieusement perdue. | |
| """ | |
| results = await service.text_search("nominasion", limit=20) | |
| assert {r.law_id for r in results} == set(TITLE_LAWS) | |
| async def test_noise_is_not_a_typo(self, service, ranking_corpus): | |
| """« fonciere » plafonne a 0,333 sur ce corpus : du bruit, pas une faute.""" | |
| assert await service.text_search("fonciere", limit=20) == [] | |
| async def test_an_exact_match_is_never_pushed_down_by_a_fuzzy_one( | |
| self, service, ranking_corpus | |
| ): | |
| results = await service.text_search("nomination", limit=20) | |
| assert results[0].law_id in TITLE_LAWS | |
| assert results[0].relevance_score >= max(r.relevance_score for r in results) | |
| class TestMotsSignificatifs: | |
| """La passe floue compare chaque mot qui designe quelque chose, et eux seuls.""" | |
| def test_mots_retenus(self, requete, attendus): | |
| assert mots_significatifs(requete) == attendus | |
| # Loi 300 : titre en « ... de Base au Cameroun », sans rapport avec la requete. | |
| # Loi 301 : les articles parlent de la peine de mort. | |
| DECRET_DE_PRET, CODE_PENAL = 300, 301 | |
| async def peine_corpus(db_session, category_ids): | |
| from sqlalchemy import text as sql_text | |
| db_session.add_all([ | |
| Law( | |
| id=DECRET_DE_PRET, reference="PRC-10703", | |
| # Titre reel du corpus : word_similarity avec la requete entiere, | |
| # 0,516, au-dessus du seuil de 0,5. | |
| title=("Décret N°2026/176 du 4 mai 2026 ratifiant l'Accord de Prêt pour le " | |
| "financement du Projet d'Appui à l'Education de Base au Cameroun (PAEBC)"), | |
| content="Est ratifié l'Accord de Prêt conclu entre la République du Cameroun et la Banque.", | |
| type="décret", language="fr", status="published", | |
| category_id=category_ids["Fonction Publique"], publication_date=date(2026, 5, 4), | |
| ), | |
| Law( | |
| id=CODE_PENAL, reference="LOI-2016-007", | |
| title="Loi N° 2016/007 du 12 juillet 2016 portant Code Pénal", | |
| content="Au Cameroun, la peine de mort est exécutée par les armes.", | |
| type="loi", language="fr", status="published", | |
| category_id=category_ids[AFFAIRES], publication_date=date(2016, 7, 12), | |
| ), | |
| ]) | |
| await db_session.flush() | |
| db_session.add(Article( | |
| law_id=CODE_PENAL, number="22", order=1, kind="article", embed=True, | |
| content="Au Cameroun, la peine de mort est exécutée par les armes ou par pendaison.", | |
| )) | |
| await db_session.commit() | |
| await db_session.execute(sql_text(REINDEX_LAWS_SQL)) | |
| await db_session.execute(sql_text(REINDEX_ARTICLES_SQL)) | |
| await db_session.commit() | |
| class TestPhraseRequest: | |
| async def test_common_words_do_not_make_a_title_match(self, service, peine_corpus): | |
| """ | |
| Requete reelle. Comparee d'un bloc, elle passait le seuil sur « de », | |
| « au » et « cameroun » : le decret de pret sortait PREMIER, comme | |
| correspondance de titre, devant le Code penal. | |
| """ | |
| results = await service.text_search("peine de mort au cameroun", limit=20) | |
| assert results, "aucun resultat" | |
| assert results[0].law_id == CODE_PENAL | |
| assert DECRET_DE_PRET not in {r.law_id for r in results} | |
| async def test_a_typo_in_a_phrase_is_still_caught(self, service, peine_corpus): | |
| """Mot par mot, la tolerance aux fautes reste : « penal » mal ecrit.""" | |
| results = await service.text_search("code penall", limit=20) | |
| assert results and results[0].law_id == CODE_PENAL | |
| class TestPerLawCap: | |
| async def test_one_law_cannot_take_the_whole_budget(self, db_session, ranking_corpus): | |
| """ | |
| Sans plafond, une loi dont le titre correspond marque le meme score sur | |
| TOUS ses articles et rafle le budget. Mesure sur le corpus reel : | |
| « avancement de grade » rendait 60 chunks, tous de la meme loi. | |
| """ | |
| from collections import Counter | |
| chunks = await search_articles_pg(db_session, "nomination", None, 60, 0) | |
| per_law = Counter(c.law_id for c in chunks) | |
| assert per_law, "aucun chunk" | |
| assert max(per_law.values()) <= MAX_CHUNKS_PER_LAW | |
| assert len(per_law) >= 4, f"un seul document remonte : {dict(per_law)}" | |
| class TestTotalCount: | |
| async def test_total_counts_documents_not_the_page(self, service, ranking_corpus): | |
| """ | |
| `total` valait `len(results)` APRES la decoupe de page, donc toujours | |
| <= limit : le front calcule `Math.ceil(total / 20)` et n'affichait | |
| jamais plus d'une page. | |
| """ | |
| response = await service.search( | |
| SearchRequest(query="nomination", mode="text", limit=3) | |
| ) | |
| assert len(response.results) == 3 | |
| assert response.total >= 7, f"total={response.total}, attendu au moins 7" | |