RICS / backend /core /ingest.py
StormShadow308's picture
Add demo documentation and Docker setup for v2 report generation system
aad7814
Raw
History Blame Contribute Delete
10 kB
"""Operator bundle ingest: report template (PDF) + standard paragraphs (Word).
* **Report template (PDF)** — schema discovery only (section order, titles, ratings).
Defines how the finished report is structured.
* **Standard paragraphs (Word)** — firm-approved boilerplate per section, ingested
into the MASTER RAG tier (not scrubbed; gated by ``assert_no_pii``).
* **Reference uploads** — optional past completed reports, scrubbed, style only.
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from backend.config import settings
from backend.core import hybrid_discovery_compiler, pii_scrubber, section_bridge, template_discoverer
from backend.core.rics_canonical_l3 import PARENT_SECTION_COUNT
from backend.core.rag_store import TIER_MASTER, TIER_REFERENCE, Chunk, get_rag_store
from backend.utils import doc_extractor
logger = logging.getLogger(__name__)
def _chunk_text(text: str) -> list[str]:
min_c = settings.paragraph_min_chars
max_c = settings.paragraph_max_chars
overlap = settings.chunk_overlap
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
if not paras:
return []
merged: list[str] = []
buf = ""
for para in paras:
if not buf:
buf = para
else:
buf = f"{buf}\n\n{para}"
if len(buf) >= min_c:
merged.append(buf)
buf = ""
if buf:
if merged and len(buf) < min_c:
merged[-1] = f"{merged[-1]}\n\n{buf}"
else:
merged.append(buf)
chunks: list[str] = []
for block in merged:
if len(block) <= max_c:
chunks.append(block)
continue
step = max(max_c - overlap, 1)
for i in range(0, len(block), step):
piece = block[i : i + max_c].strip()
if piece:
chunks.append(piece)
return [c for c in chunks if c.strip()]
def _chunk_reference_text(text: str) -> list[str]:
"""Split REFERENCE past-report text into long-form chunks (heading-aware)."""
from backend.config import settings
min_c = settings.paragraph_min_chars
max_c = settings.reference_paragraph_max_chars
overlap = settings.reference_chunk_overlap
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
if not paras:
return []
merged: list[str] = []
buf = ""
for para in paras:
if not buf:
buf = para
else:
buf = f"{buf}\n\n{para}"
if len(buf) >= min_c:
merged.append(buf)
buf = ""
if buf:
if merged and len(buf) < min_c:
merged[-1] = f"{merged[-1]}\n\n{buf}"
else:
merged.append(buf)
chunks: list[str] = []
for block in merged:
if len(block) <= max_c:
chunks.append(block)
continue
step = max(max_c - overlap, 1)
for i in range(0, len(block), step):
piece = block[i : i + max_c].strip()
if piece:
chunks.append(piece)
return [c for c in chunks if c.strip()]
def _operator_filenames_lower() -> frozenset[str]:
return frozenset({
settings.report_template_filename.lower(),
settings.standard_paragraphs_filename.lower(),
settings.master_template_filename.lower(),
})
def ingest_report_template(tenant_id: str, path: Path) -> dict:
"""Discover and persist schema from the report template PDF/DOCX."""
if not path.is_file():
raise FileNotFoundError(f"Report template not found: {path}")
schema = template_discoverer.discover_report_template_schema(path)
schema = hybrid_discovery_compiler.enrich_schema_with_hybrid_compiler(schema, path)
schema.report_template_source = path.name
schema.source_filename = path.name
template_discoverer.save_schema(tenant_id, schema)
logger.info(
"Report template ingested for tenant=%s: %d parent sections from %s",
tenant_id, PARENT_SECTION_COUNT, path.name,
)
return {
"sections": PARENT_SECTION_COUNT,
"schema_version": schema.version,
"report_template": path.name,
}
def ingest_standard_paragraphs(tenant_id: str, path: Path) -> dict:
"""Ingest standard paragraph wording into the MASTER RAG tier."""
if not path.is_file():
raise FileNotFoundError(f"Standard paragraphs file not found: {path}")
full_text = doc_extractor.extract_text(path)
pii_scrubber.assert_no_pii(full_text, context=f"standard paragraphs {path.name}")
chunks_raw = template_discoverer.discover_standard_paragraph_chunks(path)
store = get_rag_store()
store.clear_tier(tenant_id, TIER_MASTER)
total = 0
for dc in chunks_raw:
chunks = [
Chunk(
text=t,
section_id=dc.section_id,
tier=TIER_MASTER,
is_scrubbed=False,
source_filename=path.name,
)
for t in _chunk_text(dc.text)
]
total += store.ingest_document(
tenant_id,
doc_id=f"paragraphs:{path.name}",
chunks=chunks,
tier=TIER_MASTER,
source_filename=path.name,
)
# Record paragraphs source + build PDF->Word section alias map.
schema = template_discoverer.load_schema(tenant_id)
alias_count = 0
if schema is not None:
schema.standard_paragraphs_source = path.name
ptitles = section_bridge.paragraph_titles_from_word(path)
schema.paragraph_section_titles = ptitles
schema.section_alias_map = section_bridge.build_section_alias_map(
schema.sections, ptitles
)
alias_count = sum(
1 for k, v in schema.section_alias_map.items() if k != v
)
template_discoverer.save_schema(tenant_id, schema)
logger.info(
"Standard paragraphs ingested for tenant=%s: %d chunks, %d aliases from %s",
tenant_id, total, alias_count, path.name,
)
return {
"chunks": total,
"standard_paragraphs": path.name,
"section_aliases": alias_count,
}
def ingest_operator_bundle(tenant_id: str) -> dict:
"""Ingest report template (schema) + standard paragraphs (MASTER RAG)."""
template_res = ingest_report_template(tenant_id, settings.report_template_path)
paragraphs_res = ingest_standard_paragraphs(tenant_id, settings.standard_paragraphs_path)
return {
"sections": template_res["sections"],
"schema_version": template_res["schema_version"],
"report_template": template_res["report_template"],
"standard_paragraphs": paragraphs_res["standard_paragraphs"],
"paragraph_chunks": paragraphs_res["chunks"],
}
def ingest_master(tenant_id: str, path: Path) -> dict:
"""Legacy/admin helper: treat a single file as both template and paragraphs."""
schema, discovered = template_discoverer.discover_schema(path)
full_text = doc_extractor.extract_text(path)
pii_scrubber.assert_no_pii(full_text, context=f"master template {path.name}")
template_discoverer.save_schema(tenant_id, schema)
store = get_rag_store()
store.clear_tier(tenant_id, TIER_MASTER)
total = 0
for dc in discovered:
chunks = [
Chunk(
text=t,
section_id=dc.section_id,
tier=TIER_MASTER,
is_scrubbed=False,
source_filename=path.name,
)
for t in _chunk_text(dc.text)
]
total += store.ingest_document(
tenant_id,
doc_id=f"master:{schema.source_filename}",
chunks=chunks,
tier=TIER_MASTER,
source_filename=path.name,
)
return {
"sections": len(schema.sections),
"chunks": total,
"schema_version": schema.version,
"source": schema.source_filename,
}
def ingest_reference(tenant_id: str, path: Path) -> int:
"""Ingest a past report into REFERENCE tier; PII scrubbed per chunk in RagStore."""
from backend.core.photo_layout import merge_layout_from_reference_docx
text = doc_extractor.extract_text(path)
schema = template_discoverer.load_schema(tenant_id)
valid = set(schema.section_ids()) if schema else None
from backend.core.reference_chunker import build_reference_chunks
chunks = build_reference_chunks(
text,
source_filename=path.name,
valid_section_ids=valid,
)
count = get_rag_store().ingest_document(
tenant_id,
doc_id=f"reference:{path.name}",
chunks=chunks,
tier=TIER_REFERENCE,
source_filename=path.name,
)
if path.suffix.lower() in (".docx", ".docm"):
section_ids = set(schema.section_ids()) if schema else None
merge_layout_from_reference_docx(tenant_id, path, valid_section_ids=section_ids)
return count
def auto_ingest_reference_dir(tenant_id: str) -> dict:
"""Pre-seed optional reference docs; never ingests the operator bundle files."""
folder = settings.reference_dir_path
if not folder.is_dir():
return {"documents": 0, "chunks": 0}
globs = [g.strip() for g in settings.reference_auto_ingest_globs.split(",") if g.strip()]
skip = _operator_filenames_lower()
seen: set[Path] = set()
docs = 0
total = 0
for pattern in globs:
for f in sorted(folder.glob(pattern)):
if f.name.lower() in skip or f in seen:
continue
seen.add(f)
try:
total += ingest_reference(tenant_id, f)
docs += 1
except Exception as exc: # noqa: BLE001
logger.warning("Reference auto-ingest failed for %s (%s).", f.name, exc)
logger.info("Reference auto-ingest for tenant=%s: %d docs, %d chunks", tenant_id, docs, total)
return {"documents": docs, "chunks": total}