File size: 18,545 Bytes
c1708ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
#!/usr/bin/env python3
"""Chunk an already-parsed RICS report (markdown/text) or a PDF/DOCX with the
production segmenters.

Same mechanism as reference ingest:
  LLM markers (llm_segmenter)  ->  else regex (reference_chunker)

Accepts ``.md`` / ``.txt`` (e.g. LlamaParse output) or ``.pdf`` / ``.docx``
(text extracted via ``backend.ingest.doc_extractor``).

Usage (from repo root):
  python scripts/chunk_rics_text.py report.llamaparse.md -o ./chunks_out --regex-only --one-chunk-per-section
  python scripts/chunk_rics_text.py "E:\\path\\report.pdf" -o ./chunks_out --regex-only --one-chunk-per-section
"""
"""

python scripts/chunk_rics_text.py "E:\my report ai\20b Harvist Road, London, NW6 6SD_llamaparse\20b Harvist Road, London, NW6 6SD.llamaparse.md" -o "E:\my report ai\20b Harvist Road, London, NW6 6SD_llamaparse\chunks" --regex-only --one-chunk-per-section
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
    sys.path.insert(0, str(_REPO_ROOT))

_TEXT_SUFFIXES = {".md", ".markdown", ".txt"}
_DOC_SUFFIXES = {".pdf", ".docx", ".docm"}
_SUPPORTED_SUFFIXES = _TEXT_SUFFIXES | _DOC_SUFFIXES


def _load_dotenv() -> None:
    import os

    for path in (_REPO_ROOT / ".env", Path.cwd() / ".env"):
        if not path.is_file():
            continue
        for raw in path.read_text(encoding="utf-8").splitlines():
            line = raw.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            key, _, val = line.partition("=")
            key, val = key.strip(), val.strip().strip('"').strip("'")
            if key and key not in os.environ:
                os.environ[key] = val


def load_input_text(path: Path) -> str:
    """Load report text from markdown/plain text or PDF/DOCX."""
    suffix = path.suffix.lower()
    if suffix in _TEXT_SUFFIXES:
        text = path.read_text(encoding="utf-8")
        if suffix in {".md", ".markdown"}:
            return normalize_markdown_for_rics(text)
        return text
    if suffix in _DOC_SUFFIXES:
        from backend.ingest.doc_extractor import extract_text

        return extract_text(path)
    raise ValueError(
        f"Unsupported file type {suffix!r}. "
        f"Use one of: {', '.join(sorted(_SUPPORTED_SUFFIXES))}"
    )


def _chunks_to_rows(chunks: list[Any], *, scrubbed: bool) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for c in chunks:
        text = getattr(c, "text", "") or ""
        rows.append(
            {
                "chunk_id": getattr(c, "chunk_id", "") or "",
                "section_id": getattr(c, "section_id", "") or "",
                "paragraph_index": getattr(c, "paragraph_index", 1) or 1,
                "content_role": getattr(c, "content_role", "body") or "body",
                "parent_id": getattr(c, "parent_id", "") or "",
                "document_type": getattr(c, "document_type", "reference_report")
                or "reference_report",
                "is_scrubbed": scrubbed,
                "text": text,
            }
        )
    return rows


def normalize_markdown_for_rics(text: str) -> str:
    """Delegate to the production normalizer so CLI and ingest never drift.

    Kept as a thin wrapper (same name/signature) for callers of this script.
    """
    from backend.rag.reference_chunker import normalize_reference_markdown

    return normalize_reference_markdown(text)


def _legacy_normalize_markdown_for_rics(text: str) -> str:
    """Original CLI-local implementation, retained for reference only."""
    from backend.domain.section_scope import parent_letter_for_title

    raw_lines = (text or "").splitlines()
    lines: list[str] = []
    for line in raw_lines:
        stripped = line.strip()

        # Bold leaf: **J1 Insulation**
        bold_leaf = re.match(
            r"^\*\*\s*([A-N]\d{1,2})\b(?:\s*[:.\-\u2013\u2014]\s*|\s+)([^*]+?)\s*\*\*\s*$",
            stripped,
            re.IGNORECASE,
        )
        if bold_leaf:
            code = bold_leaf.group(1).upper()
            title = bold_leaf.group(2).strip()
            lines.append(f"{code} {title}".rstrip() if title else code)
            continue

        # ATX headings: "# D1 …" / "## Outside the property" / "# J"
        m = re.match(r"^(#{1,6})\s+(.*)$", line)
        if m:
            body = m.group(2).strip()
            leaf = re.match(
                r"^([A-N]\d{1,2})\b(?:\s*[:.\-\u2013\u2014]\s*|\s+)(.*)$",
                body,
                re.IGNORECASE,
            )
            if leaf:
                code = leaf.group(1).upper()
                title = (leaf.group(2) or "").strip()
                lines.append(f"{code} {title}".rstrip() if title else code)
                continue
            # Lone parent letter: "# J"
            if re.fullmatch(r"[A-N]", body, re.IGNORECASE):
                lines.append(body.upper())
                continue
            # "D Outside the property" — but not "D icon Full detail…" chrome.
            parent = re.match(r"^([A-N])\s+(.+)$", body, re.IGNORECASE)
            if parent:
                rest = parent.group(2).strip()
                rest_l = rest.lower()
                if rest_l.startswith("icon") or rest_l.startswith("logo"):
                    continue
                lines.append(parent.group(1).upper())
                lines.append(rest)
                continue
            lines.append(body)
            continue

        lines.append(line)

    # Second pass: title-only parent lines -> Letter\nTitle (unless already preceded
    # by that letter). Prevents "Inside the property" from sticking to D9.
    out: list[str] = []
    for i, line in enumerate(lines):
        letter = parent_letter_for_title(line.strip())
        if letter:
            prev = out[-1].strip().upper() if out else ""
            if prev != letter:
                out.append(letter)
            out.append(line.strip())
            continue
        out.append(line)
    return "\n".join(out)


def segment_text(
    text: str,
    *,
    source_filename: str,
    prefer_llm: bool,
    regex_only: bool,
    one_chunk_per_section: bool,
) -> tuple[list[Any], str]:
    from backend.domain.rics_level3_schema import build_canonical_template_schema
    from backend.rag.reference_chunker import build_reference_chunks

    schema = build_canonical_template_schema(source_filename="RICS_L3_CANONICAL")
    valid_ids = set(schema.section_ids())
    # Parent letters are also valid storage keys for A/B/C/K/L/M/N.
    valid_ids |= {s.id[0].upper() for s in schema.sections if s.id}

    if not regex_only and prefer_llm:
        from backend.ingest import llm_segmenter

        llm_chunks = llm_segmenter.llm_segment_reference_text(
            text,
            source_filename=source_filename,
            one_chunk_per_section=one_chunk_per_section,
        )
        if llm_chunks:
            return llm_chunks, "llm"

    chunks = build_reference_chunks(
        text,
        source_filename=source_filename,
        valid_section_ids=valid_ids,
        one_chunk_per_section=one_chunk_per_section,
        include_section_headings=True,
    )
    return chunks, "regex"


def maybe_scrub(chunks: list[Any], *, enabled: bool) -> tuple[list[Any], bool]:
    if not enabled:
        return chunks, False
    from backend.pii import scrubber as pii_scrubber

    scrubbed: list[Any] = []
    for c in chunks:
        text = getattr(c, "text", "") or ""
        cleaned, _hits = pii_scrubber.scrub_text(text)
        # Chunk is a dataclass-like object; rebuild via replace if available.
        replace = getattr(c, "model_copy", None) or getattr(c, "replace", None)
        if callable(replace):
            try:
                scrubbed.append(replace(text=cleaned, is_scrubbed=True))
                continue
            except TypeError:
                pass
        try:
            from dataclasses import replace as dc_replace

            scrubbed.append(dc_replace(c, text=cleaned, is_scrubbed=True))
        except Exception:
            c.text = cleaned  # type: ignore[attr-defined]
            if hasattr(c, "is_scrubbed"):
                c.is_scrubbed = True  # type: ignore[attr-defined]
            scrubbed.append(c)
    return scrubbed, True


def write_section_files(out_dir: Path, stem: str, rows: list[dict[str, Any]]) -> Path:
    sections_dir = out_dir / "sections"
    sections_dir.mkdir(parents=True, exist_ok=True)
    by_key: dict[str, list[str]] = {}
    for row in rows:
        if row.get("content_role") == "parent_intro":
            key = f"parent_intro_{row.get('parent_id') or 'unknown'}"
        else:
            key = row.get("section_id") or "unscoped"
        by_key.setdefault(key, []).append(row.get("text") or "")
    for key, texts in by_key.items():
        (sections_dir / f"{stem}__{key}.txt").write_text(
            "\n\n".join(t for t in texts if t.strip()),
            encoding="utf-8",
        )
    return sections_dir


def process_one(
    path: Path,
    out_dir: Path,
    *,
    prefer_llm: bool,
    regex_only: bool,
    scrub: bool,
    source_name: str | None,
    one_chunk_per_section: bool,
) -> dict[str, Any]:
    text = load_input_text(path)
    source_filename = source_name or path.name

    # Optional sidecar of extracted plain text for PDF/DOCX runs.
    if path.suffix.lower() in _DOC_SUFFIXES:
        out_dir.mkdir(parents=True, exist_ok=True)
        (out_dir / f"{path.stem}_extracted.txt").write_text(text, encoding="utf-8")

    chunks, method = segment_text(
        text,
        source_filename=source_filename,
        prefer_llm=prefer_llm,
        regex_only=regex_only,
        one_chunk_per_section=one_chunk_per_section,
    )
    chunks, was_scrubbed = maybe_scrub(chunks, enabled=scrub)
    rows = _chunks_to_rows(chunks, scrubbed=was_scrubbed)

    body_sections = sorted(
        {
            r["section_id"]
            for r in rows
            if r.get("content_role") != "parent_intro" and r.get("section_id")
        }
    )
    parent_intros = sorted(
        {
            r["parent_id"]
            for r in rows
            if r.get("content_role") == "parent_intro" and r.get("parent_id")
        }
    )

    out_dir.mkdir(parents=True, exist_ok=True)
    stem = path.stem

    file_entry = {
        "document_id": "",
        "status": "chunked",
        "file_size": path.stat().st_size,
        "content_hash": "",
        "created_at": datetime.now(timezone.utc).isoformat(),
        "source_path": str(path.resolve()),
        "segmentation_method": method,
        "one_chunk_per_section": one_chunk_per_section,
        "chunk_count": len(rows),
        "sections": body_sections,
        "parent_intro_sections": parent_intros,
        "chunks": rows,
    }

    # Tenant-manifest shape: keyed by source filename
    extracted = {source_filename: file_entry}
    extracted_path = out_dir / "extracted_chunks.json"
    extracted_path.write_text(
        json.dumps(extracted, indent=2, ensure_ascii=False), encoding="utf-8"
    )

    # Flat list sidecar (easier to inspect)
    chunks_only = {
        "source_filename": source_filename,
        "segmentation_method": method,
        "chunk_count": len(rows),
        "chunks": rows,
    }
    chunks_only_path = out_dir / "chunks_only.json"
    chunks_only_path.write_text(
        json.dumps(chunks_only, indent=2, ensure_ascii=False), encoding="utf-8"
    )

    manifest = {
        "source_file": str(path.resolve()),
        "source_filename": source_filename,
        "extracted_at": file_entry["created_at"],
        "segmentation_method": method,
        "scrubbed": was_scrubbed,
        "chunk_count": len(rows),
        "sections": body_sections,
        "parent_intro_sections": parent_intros,
        "outputs": {
            "extracted_chunks": str(extracted_path.resolve()),
            "chunks_only": str(chunks_only_path.resolve()),
        },
    }
    manifest_path = out_dir / f"{stem}_chunk_manifest.json"
    manifest_path.write_text(
        json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8"
    )

    sections_dir = write_section_files(out_dir, stem, rows)
    manifest["outputs"]["sections_dir"] = str(sections_dir.resolve())
    manifest_path.write_text(
        json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8"
    )

    return {
        "method": method,
        "chunk_count": len(rows),
        "sections": body_sections,
        "out_dir": str(out_dir.resolve()),
        "extracted_chunks": str(extracted_path.resolve()),
    }


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    p = argparse.ArgumentParser(
        description=(
            "Create RICS L3 chunks from an existing .md/.txt using "
            "llm_segmenter + reference_chunker (same as reference ingest)."
        )
    )
    p.add_argument(
        "inputs",
        nargs="+",
        type=Path,
        help="Report path(s): .md/.txt or .pdf/.docx",
    )
    p.add_argument(
        "-o",
        "--output",
        type=Path,
        default=None,
        help=(
            "Output root (writes under <output>/<source_stem>/ so the folder "
            "names the PDF/MD; default: <source_dir>/<stem>_chunks)"
        ),
    )
    p.add_argument(
        "--regex-only",
        action="store_true",
        help="Skip LLM; use reference_chunker regex only (default: LLM then regex)",
    )
    p.add_argument(
        "--one-chunk-per-section",
        action="store_true",
        help=(
            "Emit exactly one chunk per subsection / parent-intro / parent body "
            "(no paragraph or max-char splitting)"
        ),
    )
    p.add_argument(
        "--multi-chunk",
        action="store_true",
        help=(
            "Allow multiple chunks per subsection when bodies exceed "
            "reference_paragraph_max_chars (default unless --one-chunk-per-section "
            "or REFERENCE_ONE_CHUNK_PER_SECTION=true)"
        ),
    )
    p.add_argument(
        "--scrub",
        action="store_true",
        help="Run PII scrubber on chunk text before save",
    )
    p.add_argument(
        "--source-name",
        default=None,
        help="Override source_filename used in chunk_ids (default: input basename)",
    )
    return p.parse_args(argv)


def main(argv: list[str] | None = None) -> int:
    _load_dotenv()
    args = parse_args(argv)

    paths: list[Path] = []
    for p in args.inputs:
        if not p.is_file():
            print(f"Error: not found: {p}", file=sys.stderr)
            continue
        if p.suffix.lower() not in _SUPPORTED_SUFFIXES:
            print(
                f"Error: unsupported type {p.suffix!r} for {p.name} "
                f"(use {', '.join(sorted(_SUPPORTED_SUFFIXES))})",
                file=sys.stderr,
            )
            continue
        paths.append(p)
    if not paths:
        return 1

    prefer_llm = not args.regex_only
    from backend.config import settings

    if args.one_chunk_per_section and args.multi_chunk:
        print(
            "Error: pass only one of --one-chunk-per-section / --multi-chunk",
            file=sys.stderr,
        )
        return 1
    if args.one_chunk_per_section:
        one_chunk = True
    elif args.multi_chunk:
        one_chunk = False
    else:
        one_chunk = bool(settings.reference_one_chunk_per_section)

    ok = 0
    for path in paths:
        # Always keep the source stem in the output path so PDF vs MD runs
        # do not overwrite each other and the folder names the source file.
        if args.output is None:
            out_dir = path.parent / f"{path.stem}_chunks"
        else:
            out_dir = args.output / path.stem

        print(
            f"Chunking {path.name} "
            f"(one_chunk_per_section={one_chunk}) ..."
        )
        try:
            result = process_one(
                path,
                out_dir,
                prefer_llm=prefer_llm,
                regex_only=args.regex_only,
                scrub=args.scrub,
                source_name=args.source_name,
                one_chunk_per_section=one_chunk,
            )
        except Exception as exc:
            print(f"  Error: {exc}", file=sys.stderr)
            continue
        ok += 1
        print(
            f"  method={result['method']} chunks={result['chunk_count']} "
            f"sections={len(result['sections'])}"
        )
        print(f"  saved: {result['extracted_chunks']}")

    return 0 if ok else 1


if __name__ == "__main__":
    raise SystemExit(main())

# ---------------------------------------------------------------------------
# Run examples (from Report-genius-ai repo root):
#
# Chunk LlamaParse markdown:
#   python scripts/chunk_rics_text.py "E:\my report ai\out_llamaparse\5 Hillcrest Avenue, Pinner, HA5 1AJ.llamaparse.md" -o "E:\my report ai\out_llamaparse" --regex-only --one-chunk-per-section
#
#   python scripts/chunk_rics_text.py "E:\my report ai\out_llamaparse\1a Woodland Hill London SE19 1PB.llamaparse.md" -o "E:\my report ai\out_llamaparse" --regex-only --one-chunk-per-section
#
# Chunk Textract markdown:
#   python scripts/chunk_rics_text.py "E:\my report ai\out_textract\1a Woodland Hill London SE19 1PB.textract.md" -o "E:\my report ai\out_textract" --regex-only --one-chunk-per-section
#
# Chunk a PDF directly (local extract, NOT LlamaParse/Textract):
#   python scripts/chunk_rics_text.py "E:\my report ai\5 Hillcrest Avenue, Pinner, HA5 1AJ.pdf" -o "E:\my report ai\out_llamaparse" --regex-only --one-chunk-per-section
#
# One-shot PDF -> LlamaParse MD -> chunks:
#   python scripts/rics_pdf_to_chunks.py "E:\my report ai\5 Hillcrest Avenue, Pinner, HA5 1AJ.pdf" -o "E:\my report ai\out_llamaparse" --regex-only --one-chunk-per-section
#
# One-shot PDF -> Textract MD -> chunks:
#   python scripts/rics_pdf_textract_to_chunks.py "E:\my report ai\1a Woodland Hill London SE19 1PB.pdf" -o "E:\my report ai\out_textract" --regex-only --one-chunk-per-section
#
# Chunk existing Textract MD via one-shot helper:
#   python scripts/rics_pdf_textract_to_chunks.py --from-md "E:\my report ai\out_textract\1a Woodland Hill London SE19 1PB.textract.md" -o "E:\my report ai\out_textract" --regex-only --one-chunk-per-section
# ---------------------------------------------------------------------------