Spaces:
Sleeping
Sleeping
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,
)
|