File size: 16,175 Bytes
aad7814
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
"""Discover a :class:`TemplateSchema` from the operator report template (PDF).

The **report template** (PDF) is the structural authority: section order, titles,
and ratings. **Standard paragraphs** (Word) supply approved wording and are
ingested separately into the MASTER RAG tier via
:func:`discover_standard_paragraph_chunks`.

Structural discovery is deterministic (heading detection over extracted blocks).
When an OpenAI key is configured, an enrichment pass adds routing keywords and
confirms any rating system.
"""

from __future__ import annotations

import json
import logging
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path

from backend.config import settings
from backend.core.rics_canonical_l3 import (
    PARENT_SECTION_COUNT,
    build_canonical_template_schema,
    is_canonical_schema,
)
from backend.llm import openai_client
from backend.models.schema import (
    PlaceholderSyntax,
    RatingSystem,
    RatingValue,
    SectionDefinition,
    TemplateSchema,
)
from backend.prompts.discovery_prompt import build_enrichment_messages
from backend.utils import doc_extractor, tenant_store

logger = logging.getLogger(__name__)

# Report-template headings: "A About the inspection", "D1 Chimney stacks", "Section E1 - ..."
_REPORT_SECTION_RE = re.compile(
    r"^(?:section\s+)?([A-Z]\d{0,2})\s+(.+)$",
    re.IGNORECASE,
)
# Single-letter section group codes (A–Z).
_SINGLE_LETTER_CODES = frozenset(chr(c) for c in range(ord("A"), ord("Z") + 1))
_SEPARATORS = ".:-–—\u2013\u2014)"
_VARIANT_TAG_RE = re.compile(r"\[[A-Z]\]")
_LIST_NUM_PREFIX_RE = re.compile(r"^\s*\d{1,4}\s*[.)]\s*")
_SPARE_RE = re.compile(r"^\s*(?:\d{1,4}\s*[.)]\s*)?(?:\[[A-Z]\]\s*)?spare\.?\s*$", re.IGNORECASE)
_RATING_HINT_RE = re.compile(r"\bcondition\s+rating\b|\brating\s*[:#]?\s*[123]\b", re.IGNORECASE)


@dataclass
class DiscoveredChunk:
    section_id: str
    text: str


def _slug(title: str) -> str:
    s = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
    return s[:40] or "section"


def _heading_id_title(text: str) -> tuple[str, str]:
    """Return (id, title) for a heading line."""
    s = text.strip()
    body = re.sub(r"^section\s+", "", s, count=1, flags=re.IGNORECASE)
    m = _REPORT_SECTION_RE.match(body) or _REPORT_SECTION_RE.match(s)
    if m:
        code = m.group(1).upper()
        title = m.group(2).strip().lstrip(_SEPARATORS + " ").strip()
        return code, title or code
    return _slug(body), body


def _looks_like_report_heading(text: str, styled_heading: bool) -> bool:
    """Strict heading detector for report templates (PDF/DOCX structure files)."""
    if styled_heading:
        return True
    s = text.strip()
    if not s or len(s) > 80:
        return False
    m = _REPORT_SECTION_RE.match(s)
    if not m:
        return False
    title = (m.group(2) or "").strip()
    # Reject body sentences ("AS agreed…", "OF this matter…").
    if not title or not title[0].isupper():
        return False
    code = m.group(1).upper()
    if len(code) == 1 and code not in _SINGLE_LETTER_CODES:
        return False
    if len(code) == 2 and not code[1].isdigit():
        return False
    return True


def _looks_like_paragraph_heading(text: str, styled_heading: bool) -> bool:
    """Heading detector for the standard-paragraphs Word master (E1, K3, …)."""
    if styled_heading:
        return True
    s = text.strip()
    m = re.match(r"^(?:section\s+)?([A-Z]{1,2}\d{0,2})\b", s, re.IGNORECASE)
    if not m:
        return False
    code = m.group(1)
    rest = s[m.end():]
    had_prefix = s.lower().startswith("section ")
    has_digit = any(ch.isdigit() for ch in code)
    if not had_prefix and not has_digit:
        return False
    if not rest:
        return had_prefix or has_digit
    sep = rest[0]
    return sep in _SEPARATORS or sep.isspace()


def _clean_body_line(line: str) -> str:
    s = (line or "").replace("\t", " ").strip()
    if not s or _SPARE_RE.match(s):
        return ""
    s = _LIST_NUM_PREFIX_RE.sub("", s)
    s = _VARIANT_TAG_RE.sub("", s)
    s = re.sub(r"\s{2,}", " ", s).strip()
    return "" if s.lower() == "spare" else s


def _discover_report_structure(path: Path) -> list[SectionDefinition]:
    """Discover section headings from the report template (structure only)."""
    blocks = doc_extractor.extract_blocks(path)
    sections: list[SectionDefinition] = []
    order = 0
    seen_ids: set[str] = set()

    for blk in blocks:
        if not _looks_like_report_heading(blk.text, blk.is_heading):
            continue
        sid, title = _heading_id_title(blk.text)
        if sid in seen_ids:
            continue
        seen_ids.add(sid)
        sections.append(SectionDefinition(
            id=sid, title=title, order=order, level=max(1, blk.level or 1),
        ))
        order += 1
    return sections


def _collect_raw_section_texts(path: Path) -> dict[str, str]:
    """Map section_id -> body text beneath each report-template heading."""
    blocks = doc_extractor.extract_blocks(path)
    texts: dict[str, str] = {}
    current_id: str | None = None
    buf: list[str] = []

    def flush() -> None:
        nonlocal buf
        if current_id and buf:
            body = "\n".join(b for b in (_clean_body_line(x) for x in buf) if b).strip()
            if body:
                texts[current_id] = body
        buf = []

    for blk in blocks:
        if _looks_like_report_heading(blk.text, blk.is_heading):
            flush()
            current_id, _ = _heading_id_title(blk.text)
        elif current_id:
            buf.append(blk.text)
    flush()
    return texts


def _infer_section_hierarchy(sections: list[SectionDefinition]) -> str:
    max_level = max((s.level for s in sections), default=1)
    if max_level >= 3:
        return "three-level"
    if max_level >= 2:
        return "two-level"
    return "flat"


def _discover_paragraph_structure(path: Path) -> list[DiscoveredChunk]:
    """Extract per-section standard paragraph bodies from the Word master."""
    blocks = doc_extractor.extract_blocks(path)
    chunks: list[DiscoveredChunk] = []
    current_id: str | None = None
    buf: list[str] = []

    def flush() -> None:
        nonlocal buf
        if current_id and buf:
            body = "\n".join(b for b in (_clean_body_line(x) for x in buf) if b).strip()
            if body:
                chunks.append(DiscoveredChunk(section_id=current_id, text=body))
        buf = []

    for blk in blocks:
        if _looks_like_paragraph_heading(blk.text, blk.is_heading):
            flush()
            sid, _title = _heading_id_title(blk.text)
            current_id = sid
        elif current_id:
            buf.append(blk.text)
    flush()
    return chunks


def _enrich_with_llm(
    schema: TemplateSchema,
    sample_text: str,
    *,
    filename: str = "report_template",
) -> TemplateSchema:
    if not openai_client.is_available():
        return schema
    rows = [{"id": s.id, "title": s.title} for s in schema.ordered_sections()]
    heading_outline = "\n".join(f"{s.id} {s.title}" for s in schema.ordered_sections())
    body_sample = sample_text[:8000] if len(sample_text) > 8000 else sample_text
    truncated_sample = f"{heading_outline}\n\n{body_sample}"
    try:
        data = openai_client.chat_json(
            build_enrichment_messages(rows, truncated_sample, filename=filename),
            model=settings.discovery_model,
            max_tokens=settings.max_tokens_discovery,
        )
    except Exception as exc:  # noqa: BLE001
        logger.warning("Discovery enrichment failed (%s); using structural schema only.", exc)
        return schema

    if data.get("report_type"):
        schema.report_type = str(data["report_type"])
    if data.get("section_hierarchy"):
        schema.section_hierarchy = str(data["section_hierarchy"])

    rating = data.get("rating_system") or {}
    if rating.get("detected"):
        values = [
            RatingValue(
                value=str(v.get("value", "")),
                label=str(v.get("meaning") or v.get("label", "")),
            )
            for v in rating.get("values", [])
            if str(v.get("value", "")).strip()
        ]
        schema.rating_system = RatingSystem(
            detected=True,
            name=str(rating.get("name", schema.rating_system.name)),
            type=rating.get("type"),
            values=values,
            format_template=rating.get("format_template"),
            inline_example=rating.get("inline_example"),
        )

    ph = data.get("placeholder_syntax") or {}
    if ph.get("detected_formats"):
        schema.placeholders.detected_formats = [
            str(x) for x in ph.get("detected_formats", []) if str(x).strip()
        ]
    if ph.get("primary_format"):
        schema.placeholders.primary_format = str(ph.get("primary_format"))

    for row in data.get("sections", []):
        sid = str(row.get("id", ""))
        sec = schema.get_section(sid)
        if sec is None:
            continue
        kws = row.get("keywords") or []
        if kws:
            sec.keywords = [str(k).lower() for k in kws if str(k).strip()]
        if row.get("has_rating_field") is not None:
            sec.has_rating_field = bool(row.get("has_rating_field"))
        if row.get("rating_inline_format"):
            sec.rating_inline_format = str(row["rating_inline_format"])
        hints = row.get("placeholder_hints") or []
        if hints:
            sec.placeholder_hints = [str(h) for h in hints if str(h).strip()]

    extra = data.get("additional_metadata")
    if isinstance(extra, dict):
        schema.additional_metadata = extra
    return schema


def _default_keywords(section: SectionDefinition) -> list[str]:
    """Fallback keywords from the title when no LLM enrichment is available."""
    words = re.findall(r"[A-Za-z]{4,}", section.title.lower())
    stop = {"and", "the", "with", "from", "this", "that", "level", "section"}
    return [w for w in dict.fromkeys(words) if w not in stop][:8]


def discover_report_template_schema(path: Path) -> TemplateSchema:
    """Return the canonical RICS Level 3 schema (PDF/DOCX structure is not parsed)."""
    _ = path  # retained for API compatibility; canonical guard bypasses fluid heuristics
    return build_canonical_template_schema(source_filename=path.name if path else "RICS_L3_CANONICAL")


def discover_standard_paragraph_chunks(path: Path) -> list[DiscoveredChunk]:
    """Extract standard paragraph bodies from the Word master."""
    chunks = _discover_paragraph_structure(path)
    if not chunks:
        raise ValueError(f"No standard paragraphs discovered in {path}")
    return chunks


def discover_standard_paragraph_titles(path: Path) -> dict[str, str]:
    """Return ``{section_id: title}`` headings from the standard-paragraphs Word file."""
    blocks = doc_extractor.extract_blocks(path)
    titles: dict[str, str] = {}
    for blk in blocks:
        if not _looks_like_paragraph_heading(blk.text, blk.is_heading):
            continue
        sid, title = _heading_id_title(blk.text)
        if sid not in titles:
            titles[sid] = title
    return titles


def discover_schema(path: Path) -> tuple[TemplateSchema, list[DiscoveredChunk]]:
    """Canonical L3 schema + standard-paragraph chunks from a Word master (tests)."""
    schema = build_canonical_template_schema(source_filename=path.name)
    chunks = _discover_paragraph_structure(path)
    if not chunks:
        raise ValueError(f"No standard paragraphs discovered in {path}")
    return schema, chunks


# ── persistence ──────────────────────────────────────────────────────────────
def safe_filename_id(section_id: str) -> str:
    """Forces names to clean safe codes like 'F2.json', 'M.json', 'section_D.txt'."""
    return "".join(c for c in section_id if c.isalnum() or c in ("_", "-")).strip()


def _atomic_write_text(path: Path, content: str) -> None:
    """Write UTF-8 text atomically (Windows-safe replace)."""
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_name(f"{path.stem}.write{path.suffix}")
    tmp.write_text(content, encoding="utf-8")
    if path.is_file():
        path.unlink()
    tmp.replace(path)


def save_schema(tenant_id: str, schema: TemplateSchema) -> None:
    """Persist schema.json, rotating any existing schema to schema_prev.json.

    On-disk layout uses ``tenant_id`` only β€” section titles/labels belong
    inside the JSON document, never as path segments.
    """
    safe_tenant = tenant_store.normalize_tenant_id(tenant_id)
    schema.tenant_id = safe_tenant
    path = tenant_store.schema_path(safe_tenant)
    if path.is_file():
        prev = tenant_store.schema_prev_path(safe_tenant)
        _atomic_write_text(prev, path.read_text(encoding="utf-8"))
    _atomic_write_text(path, schema.model_dump_json(indent=2))
    logger.info(
        "Saved schema for tenant=%s (%d sections, v%d)",
        safe_tenant,
        len(schema.sections),
        schema.version,
    )


def install_canonical_schema(tenant_id: str, *, source_filename: str = "RICS_L3_CANONICAL") -> TemplateSchema:
    """Wipe and persist the canonical RICS L3 schema for a tenant."""
    safe_tenant = tenant_store.normalize_tenant_id(tenant_id)
    schema = build_canonical_template_schema(source_filename=source_filename, tenant_id=safe_tenant)
    save_schema(safe_tenant, schema)
    _schema_repair_marker(safe_tenant).write_text("1", encoding="utf-8")
    logger.info(
        "Installed canonical RICS L3 schema for tenant=%s (%d parents, %d leaf sections)",
        safe_tenant,
        PARENT_SECTION_COUNT,
        len(schema.sections),
    )
    return schema


def _schema_repair_marker(tenant_id: str) -> Path:
    return tenant_store.tenant_root(tenant_id) / ".schema_repaired"


def _schema_has_unsafe_labels(schema: TemplateSchema) -> bool:
    """True when persisted labels still contain path-breaking characters."""
    for sec in schema.sections:
        title = sec.title or ""
        if "/" in title or "\\" in title:
            return True
    meta = schema.additional_metadata or {}
    for row in meta.get("parent_sections") or []:
        label = str(row.get("label") or "")
        if "/" in label or "\\" in label:
            return True
    return False


def ensure_canonical_schema(tenant_id: str) -> TemplateSchema:
    """Load schema or replace corrupt/non-canonical persisted schemas."""
    safe_tenant = tenant_store.normalize_tenant_id(tenant_id)
    schema = load_schema(safe_tenant, repair=False)
    if schema is not None and is_canonical_schema(schema) and not _schema_has_unsafe_labels(schema):
        return schema
    if schema is not None:
        logger.warning(
            "Replacing schema for tenant=%s (%d sections) β€” non-canonical or unsafe labels.",
            safe_tenant,
            len(schema.sections),
        )
    return install_canonical_schema(safe_tenant)


def load_schema(tenant_id: str, *, repair: bool = True) -> TemplateSchema | None:
    """Load tenant schema; when ``repair=True``, auto-seed canonical L3 if missing."""
    safe_tenant = tenant_store.normalize_tenant_id(tenant_id)
    path = tenant_store.schema_path(safe_tenant)
    if not path.is_file():
        if repair:
            logger.info(
                "No schema.json for tenant=%s β€” installing canonical RICS L3 (%d parents).",
                safe_tenant,
                PARENT_SECTION_COUNT,
            )
            return install_canonical_schema(safe_tenant)
        return None
    try:
        schema = TemplateSchema.model_validate_json(path.read_text(encoding="utf-8"))
    except Exception as exc:  # noqa: BLE001
        logger.warning("Failed to load schema for tenant=%s (%s).", safe_tenant, exc)
        return install_canonical_schema(safe_tenant) if repair else None
    if repair and (
        not is_canonical_schema(schema) or _schema_has_unsafe_labels(schema)
    ):
        return install_canonical_schema(safe_tenant)
    return schema