File size: 8,308 Bytes
9bb793c
2d76892
9bb793c
 
2d76892
9bb793c
 
 
 
 
 
 
 
9b4e099
9bb793c
193eb98
cd353f9
2d76892
9bb793c
9097545
9bb793c
 
 
 
9b4e099
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9bb793c
9b4e099
 
 
9bb793c
 
 
 
 
 
 
9b4e099
 
 
9bb793c
 
 
 
 
9b4e099
 
 
 
 
 
 
 
9bb793c
 
 
cd353f9
9bb793c
 
 
 
 
 
 
 
 
cd353f9
9bb793c
 
 
 
 
 
 
 
9b4e099
 
 
 
 
 
 
 
 
 
 
 
9bb793c
2d76892
 
9bb793c
2d76892
9bb793c
2d76892
9bb793c
 
 
 
 
2d76892
 
 
 
d82da85
9bb793c
 
cd353f9
 
9bb793c
 
 
 
9b4e099
 
 
 
 
 
 
 
 
 
 
 
9bb793c
 
9b4e099
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9bb793c
 
 
 
 
 
9b4e099
9bb793c
 
 
193eb98
9bb793c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9b4e099
9bb793c
 
 
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
"""
Analyse primaire IA d'un folio : appel provider IA + รฉcriture master.json (R02, R04, R05).

Point d'entrรฉe : run_primary_analysis().
Chaรฎne : prompt_loader โ†’ model_registry โ†’ provider.generate_content โ†’ master_writer โ†’ response_parser.
"""
# 1. stdlib
import logging
from datetime import datetime, timezone
from pathlib import Path

# 3. local
from app.schemas.corpus_profile import CorpusProfile
from app.schemas.image import ImageDerivativeInfo, ImageSourceInfo
from app.schemas.model_config import ModelConfig
from app.schemas.page_master import EditorialInfo, EditorialStatus, ImageInfo, PageMaster, ProcessingInfo
from app.services.ai.master_writer import write_ai_raw, write_master_json
from app.services.ai.model_registry import get_provider
from app.services.ai.prompt_loader import load_and_render_prompt
from app.services.ai.response_parser import parse_ai_response

logger = logging.getLogger(__name__)


def _scale_bbox_coordinates(layout: dict, scale_x: float, scale_y: float) -> dict:
    """Met ร  l'รฉchelle les bbox de l'espace dรฉrivรฉ vers l'espace canvas original.

    L'IA analyse un dรฉrivรฉ 1500px mais les coordonnรฉes dans master.json
    doivent รชtre en pixels absolus du canvas original (convention IIIF).
    """
    if abs(scale_x - 1.0) < 0.01 and abs(scale_y - 1.0) < 0.01:
        return layout  # pas de scaling nรฉcessaire

    regions = layout.get("regions", [])
    for region in regions:
        bbox = region.get("bbox")
        if bbox and len(bbox) == 4:
            region["bbox"] = [
                round(bbox[0] * scale_x),
                round(bbox[1] * scale_y),
                round(bbox[2] * scale_x),
                round(bbox[3] * scale_y),
            ]
    return layout


def run_primary_analysis(
    *,
    derivative_image_bytes: bytes | None = None,
    derivative_image_path: Path | None = None,
    corpus_profile: CorpusProfile,
    model_config: ModelConfig,
    page_id: str,
    manuscript_id: str,
    corpus_slug: str,
    folio_label: str,
    sequence: int,
    image_info: ImageDerivativeInfo | ImageSourceInfo,
    derivative_width: int | None = None,
    derivative_height: int | None = None,
    base_data_dir: Path = Path("data"),
    project_root: Path = Path("."),
) -> PageMaster:
    """Analyse primaire d'un folio : charge le prompt, appelle l'IA, รฉcrit les fichiers.

    Supporte deux modes :
    - IIIF natif : derivative_image_bytes fourni (bytes en RAM, jamais sur disque)
    - Legacy : derivative_image_path fourni (chemin fichier sur disque)

    Respecte R05 : ai_raw.json toujours รฉcrit en premier.

    Si les dimensions originales (canvas) diffรจrent du dรฉrivรฉ, les bbox sont
    mises ร  l'รฉchelle de l'espace dรฉrivรฉ vers l'espace canvas original.
    """
    # โ”€โ”€ Chemins de sortie โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    page_dir = base_data_dir / "corpora" / corpus_slug / "pages" / folio_label
    raw_path = page_dir / "ai_raw.json"
    master_path = page_dir / "master.json"

    # โ”€โ”€ 1. Chargement et rendu du prompt (R04) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    prompt_rel_path: str = corpus_profile.prompt_templates["primary"]
    prompt_abs_path = project_root / prompt_rel_path

    context = {
        "profile_label": corpus_profile.label,
        "language_hints": ", ".join(corpus_profile.language_hints),
        "primary_language": corpus_profile.language_hints[0] if corpus_profile.language_hints else "la",
        "script_type": corpus_profile.script_type.value,
    }
    prompt_text = load_and_render_prompt(prompt_abs_path, context)
    logger.info(
        "Prompt rendu",
        extra={"template": prompt_rel_path, "corpus": corpus_slug, "folio": folio_label},
    )

    # โ”€โ”€ 2. Obtention des bytes image โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    if derivative_image_bytes is not None:
        jpeg_bytes = derivative_image_bytes
    elif derivative_image_path is not None:
        if not derivative_image_path.exists():
            raise FileNotFoundError(f"Image dรฉrivรฉe introuvable : {derivative_image_path}")
        try:
            jpeg_bytes = derivative_image_path.read_bytes()
        except OSError as exc:
            raise RuntimeError(f"Erreur lecture image {derivative_image_path} : {exc}") from exc
    else:
        raise ValueError("Il faut fournir derivative_image_bytes ou derivative_image_path")

    # โ”€โ”€ 3. Appel IA via le provider sรฉlectionnรฉ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    provider = get_provider(model_config.provider)
    logger.info(
        "Appel IA",
        extra={
            "provider": model_config.provider.value,
            "model": model_config.selected_model_id,
            "corpus": corpus_slug,
            "folio": folio_label,
        },
    )
    raw_text = provider.generate_content(
        image_bytes=jpeg_bytes,
        prompt=prompt_text,
        model_id=model_config.selected_model_id,
        supports_vision=model_config.supports_vision,
    )

    # โ”€โ”€ 4. ร‰criture ai_raw.json TOUJOURS EN PREMIER (R05) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    write_ai_raw(raw_text, raw_path)

    # โ”€โ”€ 5. Parsing + validation (ParseError si JSON invalide) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    layout, ocr = parse_ai_response(raw_text)

    # โ”€โ”€ 5b. Scaling bbox si les dimensions originales diffรจrent du dรฉrivรฉ โ”€โ”€
    is_iiif_source = isinstance(image_info, ImageSourceInfo)
    original_w = image_info.original_width
    original_h = image_info.original_height
    deriv_w = derivative_width or (getattr(image_info, "derivative_width", None)) or original_w
    deriv_h = derivative_height or (getattr(image_info, "derivative_height", None)) or original_h

    if original_w > 0 and deriv_w > 0 and (original_w != deriv_w or original_h != deriv_h):
        scale_x = original_w / deriv_w
        scale_y = original_h / deriv_h
        layout = _scale_bbox_coordinates(layout, scale_x, scale_y)

    # โ”€โ”€ 6. Construction du PageMaster โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    processed_at = datetime.now(tz=timezone.utc)

    if is_iiif_source:
        image_block = ImageInfo(
            master=image_info.original_url,
            iiif_service_url=image_info.iiif_service_url,
            manifest_url=image_info.manifest_url,
            width=original_w,
            height=original_h,
        )
    else:
        image_block = ImageInfo(
            master=image_info.original_url,
            derivative_web=getattr(image_info, "derivative_path", None),
            thumbnail=getattr(image_info, "thumbnail_path", None),
            width=original_w,
            height=original_h,
        )

    page_master = PageMaster(
        page_id=page_id,
        corpus_profile=corpus_profile.profile_id,
        manuscript_id=manuscript_id,
        folio_label=folio_label,
        sequence=sequence,
        image=image_block,
        layout=layout,
        ocr=ocr,
        processing=ProcessingInfo(
            provider=model_config.provider.value if hasattr(model_config.provider, "value") else str(model_config.provider),
            model_id=model_config.selected_model_id,
            model_display_name=model_config.selected_model_display_name,
            prompt_version=prompt_rel_path,
            raw_response_path=str(raw_path),
            processed_at=processed_at,
        ),
        editorial=EditorialInfo(status=EditorialStatus.MACHINE_DRAFT),
    )

    # โ”€โ”€ 7. ร‰criture master.json (seulement si parsing OK) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    write_master_json(page_master, master_path)

    logger.info(
        "Analyse primaire terminรฉe",
        extra={
            "page_id": page_id,
            "corpus": corpus_slug,
            "folio": folio_label,
            "regions": len(layout.get("regions", [])),
            "iiif_native": is_iiif_source,
        },
    )
    return page_master