Spaces:
Sleeping
Sleeping
File size: 8,261 Bytes
32d3f14 162c559 32d3f14 f3b0e4a 32d3f14 f3b0e4a 162c559 f3b0e4a 32d3f14 f3b0e4a 32d3f14 | 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 | """Projecteur ``CANONICAL_DOCUMENT → RAW_TEXT`` — Sprint A14-S14.
Convertit un artefact ``CANONICAL_DOCUMENT`` (typiquement un
markdown ou un JSON canonique produit par un VLM) vers du texte
plat comparable.
Stratégies de payload supportées
--------------------------------
1. **str (markdown)** — décape les balises markdown courantes : ``#``,
``*``, ``_``, ``\\``, ``> ``, ``\\`\\`\\``, listes ``- ``, lignes
horizontales. Préserve le contenu textuel.
2. **dict** — cherche en cascade ``"text"``, ``"content"``,
``"markdown"``, ``"plain"``, puis itère récursivement. Si une
liste de paragraphes est trouvée sous ``"paragraphs"``, les
joint avec un saut de ligne.
3. **list** — joint chaque élément (str ou dict récurse) avec ``\n``.
L'objectif n'est pas une conversion markdown→texte parfaite mais
**une comparaison stable** : un VLM qui produit du markdown
``"# Titre\\nLigne"`` et un OCR qui produit ``"Titre\\nLigne"``
doivent comparer égaux côté CER après projection.
"""
from __future__ import annotations
import re
from typing import Any
from picarones.domain.artifacts import Artifact, ArtifactType
from picarones.domain.errors import ProjectionError
from picarones.evaluation.projectors.base import ProjectionReport
# Patterns markdown courants à décaper. Volontairement minimal —
# on ne fait PAS de parsing markdown complet (les libs comme
# mistune ne sont pas dans la whitelist evaluation/).
_MARKDOWN_HEADER_RE = re.compile(r"^#{1,6}\s+", re.MULTILINE)
_MARKDOWN_LIST_BULLET_RE = re.compile(r"^[-*+]\s+", re.MULTILINE)
_MARKDOWN_NUM_LIST_RE = re.compile(r"^\d+\.\s+", re.MULTILINE)
_MARKDOWN_BLOCKQUOTE_RE = re.compile(r"^>\s?", re.MULTILINE)
_MARKDOWN_HR_RE = re.compile(r"^[-*_]{3,}$", re.MULTILINE)
_MARKDOWN_BOLD_ITALIC_RE = re.compile(r"\*{1,3}([^*]+)\*{1,3}")
_MARKDOWN_UNDERLINE_RE = re.compile(r"_{1,2}([^_]+)_{1,2}")
_MARKDOWN_CODE_INLINE_RE = re.compile(r"`([^`]+)`")
_MARKDOWN_CODE_BLOCK_RE = re.compile(r"```[a-zA-Z0-9]*\n?|```", re.MULTILINE)
_MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\([^)]+\)")
_MARKDOWN_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\([^)]+\)")
def markdown_to_text(markdown: str) -> str:
"""Convertit un markdown simple en texte plat.
Conserve le contenu textuel, retire les marqueurs syntaxiques
courants. Pas de parser AST — substitutions regex simples qui
couvrent ~90 % des cas patrimoniaux observés.
"""
text = markdown
# Code blocks (fences) : retire les ``` lignes
text = _MARKDOWN_CODE_BLOCK_RE.sub("", text)
# Images avant liens (les images contiennent des liens)
text = _MARKDOWN_IMAGE_RE.sub(r"\1", text)
text = _MARKDOWN_LINK_RE.sub(r"\1", text)
# Headers, blockquotes, listes
text = _MARKDOWN_HEADER_RE.sub("", text)
text = _MARKDOWN_BLOCKQUOTE_RE.sub("", text)
text = _MARKDOWN_LIST_BULLET_RE.sub("", text)
text = _MARKDOWN_NUM_LIST_RE.sub("", text)
text = _MARKDOWN_HR_RE.sub("", text)
# Inline formatting : **gras**, *italique*, _souligné_, `code`
text = _MARKDOWN_BOLD_ITALIC_RE.sub(r"\1", text)
text = _MARKDOWN_UNDERLINE_RE.sub(r"\1", text)
text = _MARKDOWN_CODE_INLINE_RE.sub(r"\1", text)
return text.strip()
def canonical_payload_to_text(payload: Any) -> str:
"""Extrait le texte plat d'un ``CANONICAL_DOCUMENT`` payload.
Stratégies en cascade selon le type de ``payload`` :
- ``str`` : traite comme markdown, applique ``markdown_to_text``.
- ``dict`` : cherche les clés textuelles standards.
- ``list`` : concatène les éléments avec ``\\n``.
- autre : ``str(payload)`` en dernier recours.
"""
if payload is None:
return ""
if isinstance(payload, str):
return markdown_to_text(payload)
if isinstance(payload, dict):
return _dict_to_text(payload)
if isinstance(payload, (list, tuple)):
parts = [
canonical_payload_to_text(item) for item in payload
]
return "\n".join(p for p in parts if p)
return str(payload).strip()
def _dict_to_text(payload: dict) -> str:
"""Cherche les clés textuelles standards d'un dict canonique."""
# Clés directes
for key in ("text", "content", "markdown", "plain", "value"):
if key in payload and isinstance(payload[key], str):
return markdown_to_text(payload[key])
# Liste de paragraphes
if "paragraphs" in payload and isinstance(payload["paragraphs"], list):
return "\n".join(
canonical_payload_to_text(p)
for p in payload["paragraphs"]
)
# Lignes (alternative)
if "lines" in payload and isinstance(payload["lines"], list):
return "\n".join(
canonical_payload_to_text(line)
for line in payload["lines"]
)
# Sinon : concaténation des valeurs textuelles trouvées
parts: list[str] = []
for value in payload.values():
if isinstance(value, str):
parts.append(markdown_to_text(value))
elif isinstance(value, (list, dict)):
sub = canonical_payload_to_text(value)
if sub:
parts.append(sub)
return "\n".join(parts).strip()
class CanonicalToText:
"""Projecteur ``CANONICAL_DOCUMENT → RAW_TEXT``.
Lit le payload depuis ``artifact.uri`` (chemin filesystem,
interprété comme markdown ou JSON selon l'extension). Pour les
payloads inline (testing), passer par un payload_loader
dédié dans le ``DefaultEvaluationViewExecutor``.
"""
name = "canonical_to_text"
source_type = ArtifactType.CANONICAL_DOCUMENT
target_type = ArtifactType.RAW_TEXT
def project(
self,
artifact: Artifact,
params: dict[str, str | int | float | bool],
) -> tuple[Artifact, str, ProjectionReport]:
if artifact.type != self.source_type:
raise ProjectionError(
f"CanonicalToText n'accepte que CANONICAL_DOCUMENT, "
f"reçu {artifact.type.value!r}"
)
# Lecture du contenu source depuis l'URI (markdown / JSON
# canonique sur disque) puis projection vers texte plat.
# Le texte calculé est retourné via le tuple
# ``(artifact, payload, report)``.
if artifact.uri is None:
raise ProjectionError(
f"CanonicalToText : artifact {artifact.id!r} sans URI."
)
from pathlib import Path
try:
raw = Path(artifact.uri).read_bytes()
except OSError as exc:
raise ProjectionError(
f"CanonicalToText : impossible de lire {artifact.uri!r} : "
f"{exc}",
) from exc
# Tentative de parsing JSON ; sinon on traite comme markdown.
import json
try:
decoded = raw.decode("utf-8")
except UnicodeDecodeError as exc:
raise ProjectionError(
f"CanonicalToText : encodage non-UTF-8 : {exc}",
) from exc
try:
payload = json.loads(decoded)
except json.JSONDecodeError:
payload = decoded # markdown brut
text = canonical_payload_to_text(payload)
target = Artifact(
id=f"{artifact.id}:projected_text",
document_id=artifact.document_id,
type=self.target_type,
produced_by_step=artifact.produced_by_step,
)
report = ProjectionReport(
source_artifact_id=artifact.id,
source_type=self.source_type,
target_type=self.target_type,
projector_name=self.name,
lossy=True,
ignored_dimensions=(
"structure",
"formatting",
"headers",
"links",
),
warnings=(
"Markdown / JSON canonique projeté en texte plat. "
"Les balises markdown sont retirées par regex (pas de "
"parser AST) ; les structures imbriquées (tableaux, "
"listes hiérarchiques) sont aplaties.",
),
)
return target, text, report
__all__ = [
"markdown_to_text",
"canonical_payload_to_text",
"CanonicalToText",
]
|