File size: 12,293 Bytes
569bceb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
732b14f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
569bceb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
732b14f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
569bceb
 
 
 
 
 
 
 
 
 
 
 
 
732b14f
 
569bceb
 
 
 
 
732b14f
569bceb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
732b14f
 
 
569bceb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
732b14f
 
 
 
569bceb
 
 
 
 
 
 
 
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
"""Semantic scan and best-effort apply for canonical paragraph rollout across the library."""

from __future__ import annotations

import logging
import re
from pathlib import Path

from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession

from app.chunking.splitter import split_documents
from app.config import settings
from app.ingest.pipeline import ingest_document, load_raw_documents
from app.models.schemas import (
    CanonicalApplyRequest,
    CanonicalApplyResponse,
    CanonicalRolloutMatch,
    CanonicalRolloutRequest,
    CanonicalRolloutResponse,
)
from app.services.content_similarity import jaccard_similarity
from app.services.provenance_enrichment import fetch_doc_filenames
from app.vectorstore.factory import get_vectorstore

logger = logging.getLogger(__name__)

_MIN_CANON_LEN = 12
_FETCH_MULT = 8
_MAX_SNIPPET = 4000

# Numbers / units likely to be document-specific (conservative extraction)
_FACT_TOKEN_RE = re.compile(
    r"\b\d+(?:\.\d+)?(?:\s*(?:mm|cm|m|kN|kPa|%|°C|°F))?\b|\b(?:19|20)\d{2}\b",
    re.IGNORECASE,
)


def _preservation_note(chunk_text: str, canonical: str) -> str:
    """List fact-like tokens present in chunk but not in canonical (case-insensitive)."""
    cn = canonical.lower()
    found = [m.group(0) for m in _FACT_TOKEN_RE.finditer(chunk_text)]
    extra = [x for x in found if x.lower() not in cn]
    if not extra:
        return ""
    # Dedupe preserving order
    seen: set[str] = set()
    uniq = []
    for x in extra:
        k = x.lower()
        if k in seen:
            continue
        seen.add(k)
        uniq.append(x)
    return "Document-specific tokens in chunk (verify in replacement): " + ", ".join(uniq[:24])


def _compatibility_tier(relevance_pct: float, jac: float) -> str:
    if relevance_pct >= 55.0 and jac >= 0.14:
        return "high"
    if relevance_pct >= 28.0 and jac >= 0.08:
        return "review"
    return "low"


def _adapt_with_llm(canonical: str, chunk_text: str) -> str:
    from langchain_core.messages import HumanMessage, SystemMessage
    from langchain_openai import ChatOpenAI

    llm = ChatOpenAI(
        model=settings.chat_model,
        temperature=0.1,
        api_key=settings.openai_api_key,
        max_retries=2,
    )
    sys = SystemMessage(
        content=(
            "You merge an improved canonical paragraph with text from an indexed document chunk. "
            "Respond with exactly one replacement paragraph, plain text. "
            "Preserve every number, unit, date, and document-specific proper noun from the original chunk "
            "that does not already appear in the canonical text. Do not invent facts. "
            "If preservation is impossible without invention, return the canonical text verbatim."
        )
    )
    hum = HumanMessage(content=f"CANONICAL:\n{canonical}\n\nORIGINAL_CHUNK:\n{chunk_text}")
    out = llm.invoke([sys, hum])
    text = (getattr(out, "content", None) or "").strip()
    return text if text else canonical


async def _adapt_with_llm_async(canonical: str, chunk_text: str) -> str:
    from langchain_core.messages import HumanMessage, SystemMessage
    from langchain_openai import ChatOpenAI

    from app.llm.llm_throttle import throttled_llm_call

    llm = ChatOpenAI(
        model=settings.chat_model,
        temperature=0.1,
        api_key=settings.openai_api_key,
        max_retries=2,
    )
    sys = SystemMessage(
        content=(
            "You merge an improved canonical paragraph with text from an indexed document chunk. "
            "Respond with exactly one replacement paragraph, plain text. "
            "Preserve every number, unit, date, and document-specific proper noun from the original chunk "
            "that does not already appear in the canonical text. Do not invent facts. "
            "If preservation is impossible without invention, return the canonical text verbatim."
        )
    )
    hum = HumanMessage(content=f"CANONICAL:\n{canonical}\n\nORIGINAL_CHUNK:\n{chunk_text}")

    async def _call() -> str:
        out = await llm.ainvoke([sys, hum])
        return (getattr(out, "content", None) or "").strip()

    text = await throttled_llm_call(
        phase="canonical_adapt",
        section_id=None,
        cache_hit=None,
        call=_call,
    )
    return text if text else canonical


def _build_proposed(
    canonical: str,
    chunk_text: str,
    adapt_with_llm: bool,
) -> tuple[str, str]:
    note = _preservation_note(chunk_text, canonical)
    if adapt_with_llm and settings.openai_api_key.strip():
        try:
            proposed = _adapt_with_llm(canonical, chunk_text)
            return proposed, note
        except Exception as exc:  # noqa: BLE001
            logger.warning("LLM adaptation failed: %s", exc)
            return canonical, note + " (LLM adaptation failed; proposed text is canonical.)"
    if adapt_with_llm and not settings.openai_api_key.strip():
        return canonical, note + " (Set OPENAI_API_KEY to enable adapt_with_llm.)"
    return canonical, note


async def _build_proposed_async(
    canonical: str,
    chunk_text: str,
    adapt_with_llm: bool,
) -> tuple[str, str]:
    note = _preservation_note(chunk_text, canonical)
    if adapt_with_llm and settings.openai_api_key.strip():
        try:
            proposed = await _adapt_with_llm_async(canonical, chunk_text)
            return proposed, note
        except Exception as exc:  # noqa: BLE001
            logger.warning("LLM adaptation failed: %s", exc)
            return canonical, note + " (LLM adaptation failed; proposed text is canonical.)"
    if adapt_with_llm and not settings.openai_api_key.strip():
        return canonical, note + " (Set OPENAI_API_KEY to enable adapt_with_llm.)"
    return canonical, note


async def scan_canonical_rollout(
    db: AsyncSession,
    tenant_id: str,
    body: CanonicalRolloutRequest,
) -> CanonicalRolloutResponse:
    """Find semantically similar chunks; propose replacements (canonical or LLM-adapted)."""
    canonical = body.canonical_text.strip()
    if len(canonical) < _MIN_CANON_LEN:
        return CanonicalRolloutResponse(
            matches=[],
            message="canonical_text is too short — use at least 12 characters for a stable embedding match.",
        )

    from app.retrieval.vector_search import async_vs_search

    vs = get_vectorstore()
    exclude_docs = set(body.exclude_document_ids or [])
    scope_docs = set(body.document_ids) if body.document_ids else None

    fetch_k = min(max(body.limit * _FETCH_MULT, 32), 200)
    raw = await async_vs_search(vs, canonical, tenant_id, k=fetch_k)
    if not raw:
        return CanonicalRolloutResponse(
            matches=[],
            message="No indexed chunks for this tenant, or nothing similar to the canonical paragraph.",
        )

    mx = max(r.score for r in raw)
    doc_ids = {r.doc_id for r in raw if r.doc_id}
    filenames = await fetch_doc_filenames(db, tenant_id, doc_ids)

    matches: list[CanonicalRolloutMatch] = []
    seen_chunk: set[str] = set()

    for r in raw:
        if not r.doc_id or r.doc_id in exclude_docs:
            continue
        if scope_docs is not None and r.doc_id not in scope_docs:
            continue
        if r.chunk_id in seen_chunk:
            continue

        chunk_text = (r.text or "").strip()
        jac = jaccard_similarity(canonical, chunk_text)
        if jac < body.min_jaccard_vs_canonical:
            continue

        pct = 100.0 * r.score / mx if mx > 0 else 0.0
        if pct < body.min_relevance_percent:
            continue

        seen_chunk.add(r.chunk_id)
        compat = _compatibility_tier(pct, jac)
        proposed, note = await _build_proposed_async(
            canonical, chunk_text, body.adapt_with_llm
        )

        snip = chunk_text if len(chunk_text) <= _MAX_SNIPPET else chunk_text[: _MAX_SNIPPET - 1] + "…"

        matches.append(
            CanonicalRolloutMatch(
                chunk_id=r.chunk_id,
                document_id=r.doc_id,
                filename=filenames.get(r.doc_id),
                original_snippet=snip,
                relevance_percent=round(pct, 1),
                jaccard_vs_canonical=round(jac, 4),
                compatibility=compat,
                proposed_replacement=proposed,
                preservation_note=note,
            )
        )
        if len(matches) >= body.limit:
            break

    msg = ""
    if not matches and raw:
        msg = (
            "No chunks passed your filters (relevance, Jaccard vs canonical, or document scope). "
            "Lower min_relevance_percent or min_jaccard_vs_canonical, or widen document_ids."
        )

    return CanonicalRolloutResponse(matches=matches, message=msg)


def get_chunk_text_from_file(file_path: Path, doc_id: str, chunk_id: str) -> str:
    """Recompute chunk text from the file using the same split as ingestion."""
    if not chunk_id.startswith(doc_id + "_"):
        msg = f"chunk_id {chunk_id!r} does not belong to document {doc_id}"
        raise ValueError(msg)
    idx_str = chunk_id.rsplit("_", 1)[-1]
    try:
        idx = int(idx_str)
    except ValueError as exc:
        raise ValueError(f"invalid chunk index in chunk_id={chunk_id!r}") from exc

    raw = load_raw_documents(file_path)
    chunks = split_documents(raw)
    if idx < 0 or idx >= len(chunks):
        raise ValueError(f"chunk index {idx} out of range (file has {len(chunks)} chunks)")
    return (chunks[idx].page_content or "").strip()


def _write_plain_docx(path: Path, full_text: str) -> None:
    """Rebuild a .docx from plain text (one paragraph per line). Destroys complex layout."""
    from docx import Document as DocxDocument

    doc = DocxDocument()
    lines = full_text.split("\n")
    if not lines:
        doc.add_paragraph("")
    else:
        for line in lines:
            doc.add_paragraph(line)
    doc.save(str(path))


async def apply_canonical_replacement(
    db: AsyncSession,
    tenant_id: str,
    body: CanonicalApplyRequest,
) -> CanonicalApplyResponse:
    """Replace one chunk inside a .docx by editing extracted text, then re-ingest."""
    if not body.confirm_destructive_docx:
        raise HTTPException(
            status_code=400,
            detail="Set confirm_destructive_docx=true to acknowledge that the .docx will be rebuilt as plain paragraphs.",
        )

    from app.db.models import Document as DBDocument

    doc = await db.get(DBDocument, body.document_id)
    if doc is None or doc.tenant_id != tenant_id:
        raise HTTPException(status_code=404, detail="Document not found")

    path = Path(doc.file_path)
    if path.suffix.lower() != ".docx":
        raise HTTPException(
            status_code=422,
            detail="Automatic apply supports .docx only. Replace PDF content offline and re-upload.",
        )

    if not path.is_file():
        raise HTTPException(status_code=404, detail="File missing on disk")

    try:
        chunk_text = get_chunk_text_from_file(path, doc.id, body.chunk_id)
    except ValueError as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from exc

    raw_docs = load_raw_documents(path)
    if len(raw_docs) != 1:
        raise HTTPException(
            status_code=422,
            detail="Apply supports a single continuous text body per .docx; edit multi-part files manually.",
        )
    full = raw_docs[0].page_content or ""

    if chunk_text not in full:
        raise HTTPException(
            status_code=422,
            detail="Chunk text not found verbatim in extracted file text — file may have changed; re-ingest and retry.",
        )

    new_full = full.replace(chunk_text, body.replacement_text, 1)
    _write_plain_docx(path, new_full)

    vs = get_vectorstore()
    vs.delete_document(doc.id)
    await ingest_document(doc.id, path)

    from app.retrieval.semantic_cache import invalidate_semantic_cache_for_tenant

    await invalidate_semantic_cache_for_tenant(doc.tenant_id)

    n = vs.count_for_doc(doc.id)
    return CanonicalApplyResponse(
        document_id=doc.id,
        chunk_id=body.chunk_id,
        filename=doc.filename,
        detail="Document rewritten with replacement text and re-indexed.",
        chunks_indexed=n,
    )