File size: 13,028 Bytes
5fca0ca
 
879e4e0
 
 
 
 
 
 
 
 
 
 
 
 
5fca0ca
 
 
 
 
 
 
 
 
879e4e0
 
 
 
 
5fca0ca
 
 
 
 
 
 
 
 
 
 
 
 
879e4e0
 
 
5fca0ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
879e4e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5fca0ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
879e4e0
5fca0ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
879e4e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5fca0ca
879e4e0
 
 
 
 
5fca0ca
 
879e4e0
 
 
 
 
 
 
 
 
5fca0ca
 
 
 
 
 
 
879e4e0
 
 
 
 
 
 
 
5fca0ca
 
 
 
879e4e0
 
 
 
 
 
 
5fca0ca
 
 
 
 
879e4e0
 
5fca0ca
 
 
879e4e0
 
 
 
5fca0ca
 
 
 
 
 
 
 
 
 
 
 
 
879e4e0
5fca0ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
879e4e0
5fca0ca
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Load per-section standard paragraphs from the firm's Word boilerplate.

Default source (see ``settings.rics_standard_paragraphs_docx``): a single
``HB-BS STANDARD PARAS`` master document under ``Behrang RICS Documents/``,
with headings keyed by RICS section codes (``A``, ``E1``, ``K3``, …).

Supported formats on disk:

* **.docx / .docm** β€” parsed directly with ``python-docx`` (Open XML).
* **.doc** (legacy Word 97–2003) β€” converted to a temporary ``.docx`` first,
  using the first available tool: **Pandoc**, **LibreOffice** (``soffice``), or
  **Microsoft Word** via ``pywin32`` (Windows only, optional install).

When an explicit path is configured and the file exists, **only** that file is
used β€” no other boilerplate under the knowledge base is merged in.

Used at **0% AI involvement** (deterministic stitch) and prepended to RAG
snippets for the assembly tier (≀12%) so the LLM sees firm text first.
"""

from __future__ import annotations

import fnmatch
import logging
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

from app.config import settings
from app.services.knowledge_base import iter_knowledge_base_roots
from app.templates.registry import ALL_VALID_SECTION_CODES

logger = logging.getLogger(__name__)

_REPO_ROOT = Path(__file__).resolve().parents[2]

# (signature of discovered files + mtimes) -> merged code -> body
_index_cache: tuple[tuple[tuple[str, int], ...], dict[str, str]] | None = None

# Word VBA: wdFormatXMLDocument β€” Office Open XML (.docx) without macros
_WD_FORMAT_DOCX = 12


def _codes_for_heading_match() -> tuple[str, ...]:
    """Longest-first so ``E10`` would beat ``E1`` if both existed; RICS uses E1–E9 etc."""
    return tuple(sorted(ALL_VALID_SECTION_CODES, key=lambda c: (-len(c), c)))


def _match_section_header(line: str, codes: tuple[str, ...]) -> str | None:
    """If ``line`` opens a section, return its RICS code."""
    s = (line or "").strip()
    if not s:
        return None
    upper = s.upper()
    for code in codes:
        cu = code.upper()
        if not upper.startswith(cu):
            continue
        tail = s[len(code) :].strip()
        if not tail:
            return code
        first = tail[0]
        if first in ".:-–—\u2013\u2014)":
            return code
        if first.isspace():
            return code
        # "E1Roof" without separator β€” reject
    return None


def _strip_heading_line_from_body(first_body_line: str, code: str) -> str:
    """Remove duplicate title fragment when the heading line was merged into body."""
    s = first_body_line.strip()
    if s.upper().startswith(code.upper()):
        tail = s[len(code) :].strip()
        if not tail:
            return ""
        if tail[0] in ".:-–—\u2013\u2014":
            tail = tail[1:].strip()
        return tail
    return s


def _soffice_executable() -> str | None:
    for name in ("soffice", "libreoffice"):
        p = shutil.which(name)
        if p:
            return p
    if sys.platform == "win32":
        for pf in (
            os.environ.get("ProgramFiles", r"C:\Program Files"),
            os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"),
        ):
            cand = Path(pf) / "LibreOffice" / "program" / "soffice.exe"
            if cand.is_file():
                return str(cand)
    return None


def _convert_binary_word_to_docx(source: Path) -> tuple[Path | None, Path | None]:
    """Convert legacy ``.doc`` to ``.docx`` in a temp dir.

    Returns:
        ``(path_to_docx, temp_dir)`` β€” caller must ``shutil.rmtree(temp_dir)``.
        ``(None, None)`` if conversion failed.
    """
    out_dir = Path(tempfile.mkdtemp(prefix="rics_stdpar_"))
    out = out_dir / f"{source.stem}.docx"

    pandoc = shutil.which("pandoc")
    if pandoc:
        try:
            subprocess.run(
                [pandoc, str(source.resolve()), "-o", str(out.resolve())],
                check=True,
                capture_output=True,
                text=True,
                timeout=180,
            )
            if out.is_file() and out.stat().st_size > 0:
                logger.info("Standard paragraphs: converted %s via pandoc", source.name)
                return out, out_dir
        except (subprocess.CalledProcessError, OSError, subprocess.TimeoutExpired) as exc:
            logger.debug("pandoc .doc→.docx failed for %s: %s", source, exc)

    soffice = _soffice_executable()
    if soffice:
        try:
            subprocess.run(
                [
                    soffice,
                    "--headless",
                    "--convert-to",
                    "docx",
                    "--outdir",
                    str(out_dir),
                    str(source.resolve()),
                ],
                check=True,
                capture_output=True,
                text=True,
                timeout=240,
            )
            if out.is_file() and out.stat().st_size > 0:
                logger.info("Standard paragraphs: converted %s via LibreOffice", source.name)
                return out, out_dir
        except (subprocess.CalledProcessError, OSError, subprocess.TimeoutExpired) as exc:
            logger.debug("LibreOffice .doc→.docx failed for %s: %s", source, exc)

    try:
        import win32com.client  # type: ignore[import-untyped]
    except ImportError:
        pass
    else:
        word = None
        doc = None
        try:
            word = win32com.client.Dispatch("Word.Application")
            word.Visible = False
            word.DisplayAlerts = 0
            doc = word.Documents.Open(str(source.resolve()), ReadOnly=True)
            doc.SaveAs2(str(out.resolve()), FileFormat=_WD_FORMAT_DOCX)
            doc.Close(SaveChanges=False)
            doc = None
            word.Quit()
            word = None
            if out.is_file() and out.stat().st_size > 0:
                logger.info("Standard paragraphs: converted %s via Microsoft Word (COM)", source.name)
                return out, out_dir
        except Exception as exc:  # noqa: BLE001
            logger.debug("Word COM .doc→.docx failed for %s: %s", source, exc)
        finally:
            if doc is not None:
                try:
                    doc.Close(SaveChanges=False)
                except Exception:  # noqa: BLE001
                    pass
            if word is not None:
                try:
                    word.Quit()
                except Exception:  # noqa: BLE001
                    pass

    shutil.rmtree(out_dir, ignore_errors=True)
    logger.warning(
        "Could not convert legacy Word .doc %s to .docx. Install one of: pandoc, LibreOffice, "
        "or on Windows Microsoft Word + ``pip install pywin32``. "
        "Alternatively save the master as .docx next to the .doc file.",
        source,
    )
    return None, None


def _parse_openxml_word_doc(path: Path) -> dict[str, str]:
    """Parse a .docx/.docm package into ``section_code -> prose``."""
    try:
        from docx import Document  # type: ignore[import-untyped]
    except ImportError:
        logger.warning("python-docx missing β€” cannot load standard paragraphs from %s", path)
        return {}

    codes = _codes_for_heading_match()
    out: dict[str, str] = {}
    current: str | None = None
    buf: list[str] = []

    def flush() -> None:
        nonlocal current, buf
        if not current:
            buf = []
            return
        parts = [b.strip() for b in buf if b and str(b).strip()]
        if parts:
            parts[0] = _strip_heading_line_from_body(parts[0], current)
            parts = [p for p in parts if p]
        text = "\n\n".join(parts).strip()
        if text:
            out[current] = text
        current = None
        buf = []

    try:
        doc = Document(str(path))
    except Exception as exc:  # noqa: BLE001
        logger.warning("Failed to open standard paragraphs Word file %s: %s", path, exc)
        return {}

    for para in doc.paragraphs:
        raw = (para.text or "").strip()
        if not raw:
            continue
        hdr = _match_section_header(raw, codes)
        if hdr:
            flush()
            current = hdr
            tail = raw[len(hdr) :].strip()
            if tail and tail[0] in ".:-–—\u2013\u2014":
                tail = tail[1:].strip()
            if tail:
                buf.append(tail)
        elif current:
            buf.append(raw)

    flush()
    return out


def parse_standard_paragraphs_docx(path: Path) -> dict[str, str]:
    """Parse standard-paragraphs master (``.docx``, ``.docm``, or legacy ``.doc``)."""
    suf = path.suffix.lower()
    if suf == ".doc":
        converted, tmp = _convert_binary_word_to_docx(path)
        if converted is None:
            return {}
        try:
            return _parse_openxml_word_doc(converted)
        finally:
            if tmp is not None:
                shutil.rmtree(tmp, ignore_errors=True)
    if suf not in (".docx", ".docm"):
        logger.warning(
            "Unsupported standard-paragraphs format %r β€” use .docx, .docm, or .doc (%s)",
            path.suffix,
            path,
        )
        return {}
    return _parse_openxml_word_doc(path)


def _resolve_config_path(raw: str) -> Path | None:
    """Resolve configured path; try alternate extension and repo-root basename."""
    raw_s = raw.strip()
    if not raw_s:
        return None
    p = Path(raw_s)
    if not p.is_absolute():
        p = _REPO_ROOT / p
    if p.is_file():
        return p
    alt = p.with_suffix(".doc") if p.suffix.lower() == ".docx" else p.with_suffix(".docx")
    if alt.is_file():
        return alt
    root_named = _REPO_ROOT / Path(raw_s).name
    if root_named.is_file():
        return root_named
    return None


def _filename_matches_glob(name: str, pattern: str) -> bool:
    return fnmatch.fnmatch(name.lower(), pattern.lower().lstrip())


def discover_standard_paragraph_docx_paths() -> list[Path]:
    """Resolve Word sources for standard paragraphs.

    * If ``rics_standard_paragraphs_docx`` is set and a matching file exists β†’ return
      **only** that path (exclusive; no glob merge under KB roots).
    * If set but missing β†’ return empty (do not silently load other boilerplate).
    * If unset/empty β†’ discover under ``knowledge_base_dirs`` via
      ``standard_paragraphs_docx_globs`` (``.docx`` and legacy ``.doc``).
    """
    explicit = (settings.rics_standard_paragraphs_docx or "").strip()
    if explicit:
        rp = _resolve_config_path(explicit)
        if rp:
            return [rp]
        logger.warning(
            "rics_standard_paragraphs_docx is set (%r) but file not found β€” "
            "standard paragraphs disabled (no glob fallback)",
            explicit,
        )
        return []

    globs = [g.strip() for g in (settings.standard_paragraphs_docx_globs or "").split(",") if g.strip()]
    if not globs:
        globs = ["*standard*paragraph*.docx"]

    paths: list[Path] = []
    seen: set[str] = set()
    for root in iter_knowledge_base_roots():
        if not root.is_dir():
            continue
        candidates: list[Path] = []
        for ext in ("*.docx", "*.docm", "*.doc"):
            candidates.extend(sorted(root.rglob(ext)))
        for fp in candidates:
            name = fp.name
            if not any(_filename_matches_glob(name, g) for g in globs):
                continue
            key = str(fp.resolve()).lower()
            if key in seen:
                continue
            seen.add(key)
            paths.append(fp)

    return paths


def get_standard_paragraph_index() -> dict[str, str]:
    """Section_code β†’ text from discovered boilerplate Word file(s) (cached by mtime)."""
    global _index_cache
    files = discover_standard_paragraph_docx_paths()
    sig = tuple((str(p.resolve()), int(p.stat().st_mtime_ns)) for p in files if p.exists())
    if _index_cache is not None and _index_cache[0] == sig:
        return _index_cache[1]

    merged: dict[str, str] = {}
    for fp in files:
        chunk = parse_standard_paragraphs_docx(fp)
        if not chunk:
            continue
        for code, body in chunk.items():
            if code not in merged:
                merged[code] = body
        logger.info(
            "Standard paragraphs: loaded %d section(s) from %s",
            len(chunk),
            fp.name,
        )

    _index_cache = (sig, merged)
    return merged


def get_standard_paragraph_for_section(section_code: str) -> str | None:
    """Return firm standard wording for ``section_code``, or ``None`` if not in the master."""
    code = (section_code or "").strip()
    if not code:
        return None
    idx = get_standard_paragraph_index()
    body = idx.get(code)
    if not body:
        return None
    return body.strip() or None


def clear_standard_paragraph_cache() -> None:
    """Test hook / reload after replacing the Word file on disk."""
    global _index_cache
    _index_cache = None