---
title: Classification des thématiques des accords d'entreprise
---

## Les accords d'entreprise

Les accords d'entreprise sont des textes négociés entre représentant du personnel et direction, traitant d'une ou plusieurs thématiques. La majeure partie des textes sont publiées sur [Légifrance](https://www.legifrance.gouv.fr/search/acco?tab_selection=acco&searchField=ALL&query=%2A&searchType=ALL&typePagination=DEFAULT&sortValue=PERTINENCE&pageSize=25&page=1#acco) et sont de longueurs variables, d'une page à plusieurs dizaines de pages.
Les parties prenantes déposent le texte sur la plateforme numérique [TéléAccords](https://accords-depot.travail.gouv.fr/accueil) auprès de la Direction Générale du Travail en renseignant les thématiques traités dans les textes, parmi une cinquaine de thématique à sélectionner. Ces thématiques déclarées sont ensuite vérifiées et corrigées par l'administration.


## Notre projet

L'administration souhaite automatiser et fiabiliser ce processus, pour simplifier la démarche d'enregistrement des textes et alléger la charge administrative. Une particularité notable des textes des accords d'entreprises est la longueur variable du texte, allant d'un texte succinct à un document long. Ces documents sont rédigés par les entreprises selon leur propre charte graphique et rédactionnelle.

Plusieurs expérimentations et projets dans l'administration sont confrontés à des problématiques similaires (classification, analyse de sentiments, recherche d'informations parmi une base documentaire), mais aucun ne propose une solution publique sur des documents longs.

En termes d'intégration, la solution devra pouvoir être prise en main par les équipes métiers de l'administration, avec une formation à l'outil proposé.

En termes d'impact, la solution devra répondre au besoin de manière légale et sécurisée, tout en ayant un impact environnemental limité


## Notre solution

Pour répondre au besoin, une solution propose ici les différentes étapes :

1. Récupérer les données
2. Choisir et réutiliser un modèle (avec RAG)
3. Evaluer les performances du modèles
4. Mettre en production

### Récupération des données

Les données sont disponibles sur [Légifrance](https://www.legifrance.gouv.fr/search/acco?tab_selection=acco&searchField=ALL&query=%2A&searchType=ALL&typePagination=DEFAULT&sortValue=PERTINENCE&pageSize=25&page=1#acco). Le stock des textes est également publié par le [FTP de la DILA](https://echanges.dila.gouv.fr/OPENDATA/ACCO/) et les thématiques déclarées sont à la fois dans les métadonnées XML publié conjointement avec les textes, ou retrouvables sur Légifrance.

Pour des raisons pratiques, nous travaillerons avec une [photographie du stock au 1er semestre 2024](https://minio.lab.sspcloud.fr/cthiounn2/Accords/10p_accords_publics_et_thematiques_240815.parquet) et sur un [échantillon des 1000 textes d'accords](https://minio.lab.sspcloud.fr/cthiounn2/Accords/10p_accords_publics_et_thematiques_240815_sample_of_1000.parquet), convertis au format parquet.


Ces données comportent le numéro de dossier de l'accord, identifiant unique, puis le texte et les thématiques déclarées, et enfin suivies des thématiques une à une :
```{python}
#| echo: false
import pandas as pd
from IPython.display import display

df = pd.DataFrame({
    'numdossier': ['T02120002618'],
    'texte': ['ACCORD D’ENTREPRISE FONDANT \nLE COMITÉ SOCIAL...'],
    'theme': ['Autre, précisez ;'],
    'accord_methode_penibilite' :['False'],
    '...' : ['...']
})

df=df.set_index('numdossier')
display(df)
```

### Choisir et réutiliser un LLM

A date d'écriture de ce guide, Llama3.1 est un modèle offrant des performances correctes et peut tourner sur la plupart des GPU du marché. Nous utiliserons dans cette exemple Llama3.1, tout en gardant à l'esprit que la méthodologie reste valable pour d'autres modèles de différentes tailles.

Compte tenu de la variabilité de la longueur du texte et afin d'être économe dans l'utilisation des LLM, le choix a été porté sur une statégie de Retrieval Augmented Generation. Pour ce faire, nous utiliserons les librairies Langchain pour l'implémentation du RAG, ChromaDB pour la vectorisation, Ollama pour déployer un LLM en mode API.

Nous reproduisons ici pas à pas la solution. Le notebook complet se trouve [ici](../notebooks/10p_RAG_OLLAMA.ipynb)


#### Déployer un LLM avec Ollama

Pour déployer un LLM en local avec Ollama, il faut au préalable installer Ollama, télécharger et lancer le modèle 

En Linux/Unix :
```{sh}
curl -fsSL https://ollama.com/install.sh | sh
```

Une fois installé, il faut lancer le serveur Ollama

```{sh}
ollama serve&
```

Puis lancer le modèle choisi, ici llama3.1

```{sh}
ollama run llama3.1
```

Cette dernière commande vous propose un prompt et vous pouvez intéragir avec le modèle en ligne de commande. Dans la suite de cet exemple, nous allons utiliser python pour automatiser nos requêtes vers le LLM à partir de nos données. Mais avant cela, nous allons lire les données et nous devons vectoriser nos textes par morceaux, afin de pouvoir sélectionner les meilleurs morceaux adéquats à la tâche demandé.

#### Lecture de données 

Veuillez installer les librairies suivantes

```
pandas==2.2.2
pyarrow==17.0.0
ollama==0.3.3
sentence_transformers==3.1.0
langchain==0.2.16
langchain-community==0.2.16
langchain-huggingface==0.0.3
langchain-text-splitters==0.2.4
chromadb==0.5.3
langchain-chroma==0.1.3
jupyter==1.1.1
ipykernel==6.29.5
```


Nous allons dans cet exemple, extraire 10 textes pour des raisons de rapidité :

```{python}
#| eval: false

import json
import numpy as np
import pandas as pd
import requests

from langchain.chains import StuffDocumentsChain, RetrievalQA, LLMChain, create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain.document_loaders import TextLoader
from langchain.embeddings import OpenAIEmbeddings, OllamaEmbeddings
from langchain.llms import Ollama, BaseLLM
from langchain.schema import Document, Generation, LLMResult
from langchain.vectorstores import Chroma
from langchain_chroma import Chroma
from langchain_community.llms import OpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_text_splitters import CharacterTextSplitter
from pathlib import Path
from tqdm import tqdm
from glob import glob

df_sample=pd.read_parquet("./10p_accords_publics_et_thematiques_240815_sample_of_1000.parquet")
df_sample=df_sample[:10]
```


#### Vectoriser nos textes avec ChromaDB

Pour vectoriser nos textes, nous utilisons ChromaDB qui s'intègre avec Langchain.
Nous allons découper en morceau des 3000 caractères à chaque saut à ligne, ce qui correspond à un paragraphe. Les morceaux de textes, ici paragraphes, sont stockés dans une boutique de vecteur avec le numéro de dossier et le numéro de paragraphe en métadonnées.


```{python}
#| eval: false
text_splitter = CharacterTextSplitter(
    separator="\n\n",
    chunk_size=3000,
    chunk_overlap=200,
    length_function=len,
    is_separator_regex=False,
)

model_kwargs = {'device': 'cuda'}
embedder = HuggingFaceEmbeddings(model_name="BAAI/bge-m3", model_kwargs=model_kwargs,show_progress=False)

vector_store = Chroma(embedding_function=embedder, persist_directory="./chroma_db")
for index, row in tqdm(df_sample.iterrows(), total=len(df_sample)):
    text=df_sample.texte[index]
    texts = text_splitter.create_documents([text])
    i=0
    for t in texts:
        t.metadata["id"]=f"{index}_{i}"
        t.metadata["index"]=f"{index}"
        vector_store.add_documents([t])
        i+=1
```

#### Interroger un LLM en mode API

Pour interroger le LLM, nous construisons une classe qui permet de générer les requêtes et de traiter les réponses :
```{python}
#| eval: false

MODEL="llama3.1"


class LocalOllamaLLM(BaseLLM):
    api_url : str
    def _generate(self, prompt, stop):
        response = requests.post(f"{self.api_url}/api/generate", json={"model": MODEL , "prompt": str(prompt) })
        response.raise_for_status()
        response_text=''.join([json.loads(line)['response'] for line in response.text.splitlines()])
        generations=[]
        generations.append([Generation(text=response_text)])
        return LLMResult(generations=generations)


    def _llm_type(self):
        return "local"  

    llm = LocalOllamaLLM(api_url="http://127.0.0.1:11434")
```



Nous définissons également un prompt de base, améliorable par la suite, et une chaîne LangChain entre le prompt et le LLM :

```{python}
#| eval: false
system_prompt = (
    " Répondez à la question posée "
    " Utilisez le contexte (sélection des meilleurs paragraphes liés à la question) donné pour répondre à la question "
    " Si la réponse ne se trouve pas dans le contexte, répondez par 'Non'"
    " Contexte : {context}  "
)

prompt = ChatPromptTemplate.from_messages(
    [
        ("system", system_prompt),
        ("human", "{input}"),
    ]
)
question_answer_chain = create_stuff_documents_chain(llm, prompt)
```

Nous définissons une fonction pour effectuer le RAG, avec à la fois la recherche de similarité par rapport à la question, et la soumission augmentée pour une réponse du LLM : 

```{python}
#| eval: false
def search_and_invoke_llm(vector_store,index,query,k=5):
    if k==0:
        print(f"bug with {index}")
        return None
    else:
        pass
    try:
        retriever=vector_store.as_retriever(
        search_kwargs={
                "k": k, 
                "filter": {'index': index}
            }
        )
        chain = create_retrieval_chain(retriever, question_answer_chain)
        result=chain.invoke({"input": query})
        return result
    except:
        search_and_invoke_llm(vector_store,index,query,k=k-1)
    return None
```

#### Automatiser la classification sur l'ensemble des thématiques


Nous automatisons ici la classification sous forme de classification binaire pour chaque thématique, en posant une question "oui ou non" et en inférant oui si la réponse commence par oui, non sinon.

```{python}
#| eval: false
THEMATIQUES={
    "accord_methode_penibilite":"Accords de méthode (pénibilité)",
"accord_methode_pse":"Accords de méthode (PSE)",
"amenagement_temps_travail":"Aménagement du temps de travail (modulation, annualisation, cycles)",
"autres":"Autre, précisez",
"autres_condition_travail":"Autres dispositions de conditions de travail (CHSCT, médecine du travail, politique générale de prévention)",
"autres_dispositions_duree":"Autres dispositions durée et aménagement du temps de travail ",
"autres_dispositions_egalite":"Autres dispositions Egalité professionnelle",
"autres_dispositions_emploi":"Autres dispositions emploi",
"calendrier_negociation":"Calendrier des négociations",
"classifications":"Classifications",
"commision_paritaire":"Commissions paritaires",
"cet":"Compte épargne temps",
"couverture_complementaire":"Couverture complémentaire santé - maladie",
"don_jour":"Dispositifs don de jour et jour de solidarité",
"distribution_actions_gratuites":"Distribution d'actions gratuites",
"droit_deconnexion":"Droit à la déconnexion et outils numériques",
"droit_syndical":"Droit syndical, IRP, expression des salariés",
"duree_collective_temps_travail":"Durée collective du temps de travail",
"egalite_salariale":"Egalité salariale F/H",
"election_pro":"Elections professionnelles, prorogations de mandat et vote électronique",
"evolution_prime":"Evolution des primes",
"evolution_salariale":"Evolution des salaires (augmentation, gel, diminution)",
"fin_conflit":"Fin de conflit",
"conges":"Fixation des congés (jours fériés, ponts, RTT)",
"forfait":"Forfaits (en heures, en jours)",
"formation_pro":"Formation professionnelle",
"gpec":"GPEC",
"heures_supp":"Heures supplémentaires (contingent, majoration)",
"indemnites":"Indemnités (dont kilométrique)",
"interessement":"Intéressement",
"mesure_age":"Mesures d'âge (seniors, contrat de génération...)",
"mobilite":"Mobilité (géographique, professionnelle - promotions)",
"diversite":"Non discrimination - Diversité",
"participation":"Participation",
"pee_peg":"PEE ou PEG",
"pei":"PEI",
"penibilite":"Pénibilité du travail (1% pénibilité, prévention, compensation/réparation)",
"perco_percoi":"PERCO et PERCOI",
"performance_collecte":"Performance collective (accord de compétitivité)",
"prevoyance_collective":"Prévoyance collective, autre que santé maladie",
"prime_partage_profit":"Prime de partage des profits",
"qvt":"QVT, conciliation vie personnelle/vie professionnelle",
"reprise_des_donnees":"Reprise des données",
"retraite_complementaire":"Retraite complémentaire - supplémentaire",
"rupture_conventionnelle_collective":"Rupture conventionnelle collective",
"stress_rps":"Stress, risques psycho-sociaux",
"supplement_participation":"Supplément de participation",
"supplement_interessement":"Supplément d'intéressement",
"systeme_prime":"Système de prime (autre qu'évolution)",
"système_de_remuneration":"Système de rémunération (autres qu'évolution)",
"teletravail":"Télétravail",
"travail_temps_partiel":"Travail à temps partiel",
"travail_nuit":"Travail de nuit",
"travail_dimanche":"Travail du dimanche",
"travailleurs_handicapes":"Travailleurs handicapés"}


already_done={el.split("/")[1].split(".")[0] for el in glob("results/*.answer")}
new_dir = Path('results').mkdir(exist_ok=True)

list_of_df=[]
for index, row in df_sample.iterrows():
    dict_answer=dict()
    answer=""
    if index not in already_done:
        for (k,v) in THEMATIQUES.items():
            Q0=f"Oui ou non : est-ce qu'il y a un article sur : {v}?"
            if ans:=search_and_invoke_llm(vector_store,index,Q0,k=2):
                answer_txt=ans['answer']
                reponse=0
                if answer_txt.lower().startswith("oui") :
                    reponse=1
                dict_answer[k]=reponse
                answer_k = f"{k} : {answer_txt}"
                answer += answer_k
            answer += "\n-----\n"
            
        if answer:
            with open(f"results/{index}.answer","w") as f:
                f.write(answer)
        list_of_df.append(pd.DataFrame(dict_answer, index=[index]))

df_results=pd.concat(list_of_df)
```


### Evaluation

Nous évaluons les performances de cette solution simple, en affichant la matrice de confusion et les différentes métriques, pour chaque thématique :


```{python}
#| eval: false
import numpy as np
from sklearn.metrics import confusion_matrix
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, classification_report

for (k,v) in THEMATIQUES.items():
    df=pd.DataFrame(df_sample[k].astype(int)).merge(df_results[k],how="inner",left_index=True,right_index=True,suffixes=["_expected","_predicted"])
    y_true, y_pred=df[f"{k}_expected"], df[f"{k}_predicted"]
    cm = confusion_matrix(y_true, y_pred)
    print(k)
    print(cm)

    accuracy = accuracy_score(y_true, y_pred)
    precision = precision_score(y_true, y_pred, average='macro')
    recall = recall_score(y_true, y_pred, average='macro')
    f1 = f1_score(y_true, y_pred, average='macro')
    report = classification_report(y_true, y_pred)
    
    print(f'Accuracy: {accuracy}')
    print(f'Precision (macro): {precision}')
    print(f'Recall (macro): {recall}')
    print(f'F1 Score (macro): {f1}')
    print("-"*10)
    print('Classification Report:')
    print(report)
```
