feat: quality_tier CPAM (A/B/C) + requires_review + warnings catégorisés

- ControleCPAM enrichi : quality_tier, requires_review, quality_warnings
- _assess_quality_tier() : classification basée sur score adversarial + warnings
  - Tier C (requires_review) : score <4, code hors périmètre, >2 preuves non traçables
  - Tier B : score 4-6, warnings mineurs
  - Tier A : score >=7, 0 critique
- _format_response() : bandeau "REVUE MANUELLE REQUISE" pour tier C,
  sections CRITIQUES/MINEURS séparées
- Badge qualité dans le viewer CPAM (vert A / orange B / rouge C)
- 17 tests : tier A/B/C, bandeau, séparation warnings, backward compat

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-20 11:01:21 +01:00
parent 77ffbc56d4
commit 5d5f119057
5 changed files with 404 additions and 7 deletions

View File

@@ -729,6 +729,9 @@ class ControleCPAM(BaseModel):
contre_argumentation: Optional[str] = None
response_data: Optional[dict] = None
sources_reponse: list[RAGSource] = Field(default_factory=list)
quality_tier: Optional[str] = None # "A" | "B" | "C"
requires_review: bool = False
quality_warnings: list[str] = Field(default_factory=list)
# --- Qualité / Vetos (contestabilité) ---

View File

@@ -27,6 +27,7 @@ from .cpam_validation import (
_validate_codes_in_response,
_build_correction_prompt,
_format_response,
_assess_quality_tier,
)
# Backward compat — sera retiré dans un commit futur
@@ -38,7 +39,7 @@ from .cpam_context import ( # noqa: F401
_build_bio_summary,
_check_das_bio_coherence,
)
from .cpam_validation import _CIM10_CODE_RE, _validate_adversarial as _validate_adversarial # noqa: F401
from .cpam_validation import _CIM10_CODE_RE, _validate_adversarial as _validate_adversarial, _assess_quality_tier as _assess_quality_tier # noqa: F401
logger = logging.getLogger(__name__)
@@ -220,8 +221,23 @@ def generate_cpam_response(
all_warnings = ref_warnings + grounding_warnings + code_warnings + adversarial_warnings
# 8c. Classification qualité (A/B/C)
tier, needs_review, cat_warnings = _assess_quality_tier(
result, ref_warnings, grounding_warnings, code_warnings, validation,
)
controle.quality_tier = tier
controle.requires_review = needs_review
controle.quality_warnings = cat_warnings
logger.info(" Qualité CPAM : tier %s, requires_review=%s, %d warnings",
tier, needs_review, len(cat_warnings))
# 9. Formater la réponse
text = _format_response(result, all_warnings)
text = _format_response(
result,
ref_warnings=all_warnings,
quality_tier=tier,
categorized_warnings=cat_warnings,
)
logger.info(" Contre-argumentation générée (%d caractères)", len(text))
return text, result, rag_sources

View File

@@ -300,10 +300,91 @@ def _build_correction_prompt(
return original_prompt + correction_block
def _format_response(parsed: dict, ref_warnings: list[str] | None = None) -> str:
def _assess_quality_tier(
parsed: dict,
ref_warnings: list[str],
grounding_warnings: list[str],
code_warnings: list[str],
adversarial_result: dict | None,
) -> tuple[str, bool, list[str]]:
"""Évalue le tier qualité (A/B/C) et le flag requires_review.
Classification :
- Tier C (requires_review=True) :
score adversarial < 4 OU code_warnings > 0 OU grounding_warnings > 2
- Tier B :
score adversarial 4-6 OU ref_warnings > 0 OU grounding_warnings 1-2
- Tier A :
score adversarial >= 7, 0 warning critique, <= 1 warning mineur
Returns:
(tier, requires_review, categorized_warnings)
"""
categorized: list[str] = []
score = adversarial_result.get("score_confiance", -1) if adversarial_result else -1
has_critical = False
minor_count = 0
# --- Warnings critiques ---
for w in code_warnings:
categorized.append(f"[CRITIQUE] {w}")
has_critical = True
if score != -1 and score <= 3:
categorized.append(f"[CRITIQUE] Score adversarial très bas : {score}/10")
has_critical = True
if len(grounding_warnings) > 2:
for w in grounding_warnings:
categorized.append(f"[CRITIQUE] {w}")
has_critical = True
elif grounding_warnings:
for w in grounding_warnings:
categorized.append(f"[MINEUR] {w}")
minor_count += 1
# --- Warnings mineurs ---
for w in ref_warnings:
categorized.append(f"[MINEUR] {w}")
minor_count += 1
if adversarial_result and not adversarial_result.get("coherent", True):
for e in adversarial_result.get("erreurs", []):
if isinstance(e, str) and e.strip():
categorized.append(f"[MINEUR] Incohérence : {e}")
minor_count += 1
if score != -1 and 4 <= score <= 6:
categorized.append(f"[MINEUR] Score adversarial moyen : {score}/10")
minor_count += 1
# --- Classification ---
if has_critical or (score != -1 and score < 4):
tier = "C"
requires_review = True
elif minor_count > 0 or (score != -1 and 4 <= score <= 6):
tier = "B"
requires_review = False
else:
tier = "A"
requires_review = False
return tier, requires_review, categorized
def _format_response(
parsed: dict,
ref_warnings: list[str] | None = None,
quality_tier: str | None = None,
categorized_warnings: list[str] | None = None,
) -> str:
"""Formate la réponse LLM en texte lisible."""
sections = []
# Bandeau qualité si tier C
if quality_tier == "C":
sections.append("⚠ REVUE MANUELLE REQUISE (Qualité : C)")
analyse = parsed.get("analyse_contestation")
if analyse:
sections.append(f"ANALYSE DE LA CONTESTATION\n{analyse}")
@@ -368,8 +449,20 @@ def _format_response(parsed: dict, ref_warnings: list[str] | None = None) -> str
if conclusion:
sections.append(f"CONCLUSION\n{conclusion}")
# Avertissements sur les références non vérifiables
if ref_warnings:
# Avertissements catégorisés (nouveau format)
if categorized_warnings:
critiques = [w for w in categorized_warnings if w.startswith("[CRITIQUE]")]
mineurs = [w for w in categorized_warnings if w.startswith("[MINEUR]")]
if critiques:
sections.append(
"AVERTISSEMENTS CRITIQUES\n" + "\n".join(f"- {w}" for w in critiques)
)
if mineurs:
sections.append(
"AVERTISSEMENTS MINEURS\n" + "\n".join(f"- {w}" for w in mineurs)
)
elif ref_warnings:
# Fallback ancien format
warning_text = "\n".join(f"- {w}" for w in ref_warnings)
sections.append(f"AVERTISSEMENT — REFERENCES NON VÉRIFIÉES\n{warning_text}")

View File

@@ -33,6 +33,7 @@
<tr>
<th>Dossier</th>
<th>OGC</th>
<th>Qualité</th>
<th>Titre</th>
<th>Décision</th>
<th>Codes contestés</th>
@@ -51,6 +52,17 @@
{% endif %}
</td>
<td style="font-weight:600;">{{ c.ctrl.numero_ogc }}</td>
<td style="text-align:center;">
{% if c.ctrl.quality_tier == 'A' %}
<span class="badge" style="background:#2ecc71;color:#fff;font-weight:700;font-size:0.8rem;padding:3px 10px;">A</span>
{% elif c.ctrl.quality_tier == 'B' %}
<span class="badge" style="background:#f39c12;color:#fff;font-weight:700;font-size:0.8rem;padding:3px 10px;">B</span>
{% elif c.ctrl.quality_tier == 'C' %}
<span class="badge" style="background:#e74c3c;color:#fff;font-weight:700;font-size:0.8rem;padding:3px 10px;">C</span>
{% else %}
<span style="color:#94a3b8;font-size:0.7rem;"></span>
{% endif %}
</td>
<td style="max-width:200px;">{{ c.ctrl.titre }}</td>
<td>
{% if 'retient' in c.ctrl.decision_ucr|lower %}