Ajout de 2 fonctionnalitées principales : import PDF que ce soit pour les règles ou les campagnes directement.
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m28s
Build & Push Images / build (core) (push) Successful in 1m40s
Build & Push Images / build-switcher (push) Successful in 19s
Build & Push Images / build (web) (push) Successful in 1m46s

Fonctionnalité de comparaison PDF / campagne pour faire un mix et demander des conseils à l'IA
This commit is contained in:
2026-06-04 13:55:27 +02:00
parent 79a68bc27b
commit 439f43875b
78 changed files with 5250 additions and 183 deletions

View File

@@ -0,0 +1,126 @@
"""Extraction robuste d'un objet JSON depuis une réponse LLM.
Les LLM enrobent souvent leur JSON : fences markdown ```json … ```, texte
d'introduction, commentaire de fin, voire un 2e objet. Un simple
`json.loads(raw)` ou un `raw[first_brace:last_brace]` échoue dans ces cas
("Extra data", accolade parasite dans une string, etc.).
Cette fonction scanne depuis la PREMIÈRE `{` et renvoie exactement le premier
objet `{…}` ÉQUILIBRÉ, en ignorant les accolades à l'intérieur des chaînes JSON
et tout ce qui suit. Renvoie None si aucun objet complet n'est trouvé
(sortie tronquée / accolades non refermées).
"""
from __future__ import annotations
import json
def load_json_object(raw: str) -> tuple[object | None, bool]:
"""Parse un objet JSON depuis une réponse LLM, avec récupération si tronqué.
Renvoie (objet_parsé, récupéré_partiellement) :
- d'abord on tente le 1er objet complet (extract_json_object) ;
- sinon on tente une réparation du JSON tronqué (repair_truncated_json),
auquel cas le second élément vaut True.
(None, False) si rien d'exploitable.
"""
obj = extract_json_object(raw)
if obj is not None:
try:
return json.loads(obj), False
except json.JSONDecodeError:
pass
repaired = repair_truncated_json(raw)
if repaired is not None:
try:
return json.loads(repaired), True
except json.JSONDecodeError:
pass
return None, False
def extract_json_object(raw: str) -> str | None:
if not raw:
return None
text = raw.strip()
start = text.find("{")
if start == -1:
return None
depth = 0
in_string = False
escape = False
for i in range(start, len(text)):
c = text[i]
if in_string:
if escape:
escape = False
elif c == "\\":
escape = True
elif c == '"':
in_string = False
else:
if c == '"':
in_string = True
elif c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return text[start : i + 1]
return None # accolades non refermées (réponse probablement tronquée)
# Fermeture correspondante de chaque ouvrant, pour reconstituer un JSON tronqué.
_CLOSE_OF = {"{": "}", "[": "]"}
def repair_truncated_json(raw: str) -> str | None:
"""Répare un JSON COUPÉ (sortie LLM tronquée) en gardant les éléments complets.
On scanne depuis la première `{` et on retient le DERNIER point où un conteneur
(`}` ou `]`) vient de se fermer — donc juste après une sous-structure complète
(un arc / chapitre / scène / pièce / section entièrement écrit). On coupe là et
on referme les conteneurs encore ouverts. L'élément en cours d'écriture au moment
de la troncature est abandonné, mais tous les précédents sont sauvés.
Renvoie une chaîne JSON équilibrée (à valider par json.loads) ou None.
"""
if not raw:
return None
text = raw.strip()
start = text.find("{")
if start == -1:
return None
stack: list[str] = []
in_string = False
escape = False
best_cut = -1 # index (exclusif) où couper
best_closing = "" # fermetures à ajouter pour rééquilibrer
for i in range(start, len(text)):
c = text[i]
if in_string:
if escape:
escape = False
elif c == "\\":
escape = True
elif c == '"':
in_string = False
else:
if c == '"':
in_string = True
elif c in "{[":
stack.append(c)
elif c in "}]":
if stack:
stack.pop()
# Point de coupe sûr : on vient de fermer une sous-structure complète.
best_cut = i + 1
best_closing = "".join(_CLOSE_OF[b] for b in reversed(stack))
if best_cut == -1:
return None # rien de complet à sauver
head = text[start:best_cut].rstrip().rstrip(",")
return head + best_closing