RICS / backend /core /paragraph_retriever.py
StormShadow308's picture
Add demo documentation and Docker setup for v2 report generation system
aad7814
Raw
History Blame Contribute Delete
16.1 kB
"""Retrieve MASTER or REFERENCE paragraphs for mapping messy field notes."""
from __future__ import annotations
import re
from typing import Literal
from backend.config import settings
from backend.core.rag_store import SearchHit, get_rag_store
from backend.models.schema import TemplateSchema
InterferenceLevel = Literal["minimum", "medium", "maximum"]
RetrievalLevel = Literal["paragraph", "section", "document"]
_STOP = frozenset({
"about", "with", "from", "this", "that", "level", "section", "inside",
"outside", "other", "property", "report", "survey", "your", "the", "and",
"were", "was", "has", "have", "been", "noted", "inspected", "ground",
})
def build_retrieval_query(
section_label: str,
observations: list[str],
*,
section_id: str = "",
) -> str:
"""Semantic query anchored to the target section ID + observations."""
sid = (section_id or "").strip().upper()
label = section_label.strip()
prefix = f"{sid} {label}".strip() if sid else label
parts = [o.strip() for o in observations if o and o.strip()]
if not parts:
return prefix
ranked = sorted(parts, key=len, reverse=True)
return f"{prefix}: " + " ".join(ranked[:5])
def _content_tokens(text: str) -> set[str]:
return {
w
for w in re.findall(r"[a-z]{4,}", text.lower())
if w not in _STOP
}
def rerank_hits_by_observations(
hits: list[SearchHit],
observations: list[str],
) -> list[SearchHit]:
if not hits or not observations:
return hits
obs_tokens = _content_tokens(" ".join(observations))
if not obs_tokens:
return hits
boost = settings.retrieval_lexical_boost
def rank_key(h: SearchHit) -> float:
para_tokens = _content_tokens(h.text)
overlap = len(obs_tokens & para_tokens)
return h.score + overlap * boost
return sorted(hits, key=rank_key, reverse=True)
def rerank_hits_for_comprehensive_baseline(hits: list[SearchHit]) -> list[SearchHit]:
"""Among similarly scored REFERENCE hits, prefer the longest / fullest text block."""
if len(hits) < 2:
return hits
top_score = hits[0].score
score_floor = top_score - max(0.06, top_score * 0.08)
def sort_key(h: SearchHit) -> tuple[float, int, int]:
return (
h.score,
len(h.text.split()),
-(h.paragraph_index or 0),
)
close = [h for h in hits if h.score >= score_floor]
tail = [h for h in hits if h.score < score_floor]
close.sort(key=sort_key, reverse=True)
return close + tail
def _filter_hits_to_section(
hits: list[SearchHit],
paragraph_section_id: str,
) -> list[SearchHit]:
"""Keep only chunks tagged with the target section (case-insensitive)."""
want = (paragraph_section_id or "").strip().upper()
if not want:
return hits
return [
h for h in hits
if (h.section_id or "").strip().upper() == want
]
def _complete_section_group(
tenant_id: str,
source_key: str | None,
section_id: str,
*,
tier: str | None,
allowed_doc_keys: frozenset[str] | None,
property_context: dict | None,
) -> list[SearchHit]:
"""Pull EVERY stored chunk for a section (optionally pinned to one source).
Mirrors the property guard applied to similarity hits so a section-complete
baseline cannot reintroduce wrong-property content the ranked path would drop.
"""
from backend.core.rag_store import TIER_REFERENCE, get_rag_store
use_tier = tier or TIER_REFERENCE
chunks = get_rag_store().fetch_section_chunks(
tenant_id,
tier=use_tier,
section_id=section_id,
source_key=source_key,
allowed_doc_keys=allowed_doc_keys,
)
if property_context and chunks:
from backend.core.property_blocklist import filter_hits_by_property
chunks = filter_hits_by_property(chunks, property_context)
return chunks
def assemble_reference_baseline(
hits: list[SearchHit],
*,
paragraph_section_id: str = "",
tenant_id: str | None = None,
tier: str | None = None,
allowed_doc_keys: frozenset[str] | None = None,
property_context: dict | None = None,
) -> tuple[str, list[SearchHit]]:
"""Build the fullest REFERENCE baseline from ranked hits (same doc + section).
When ``tenant_id`` is supplied and section-complete assembly is enabled, the
best source is chosen by similarity (as before) but the baseline is then built
from EVERY chunk that source holds for the section — in document order — rather
than only the top-K chunks similarity surfaced. This is what makes the whole
past-report section get mapped instead of a partial fragment. A character budget
bounds the assembled length so the mapping prompt stays sane.
"""
from backend.core.paragraph_merge import combine_reference_blocks
if not hits:
return "", []
by_source: dict[str, list[SearchHit]] = {}
for hit in hits:
key = hit.source_filename or hit.doc_id or "unknown"
by_source.setdefault(key, []).append(hit)
sid = (paragraph_section_id or "").upper()
def source_rank(key: str) -> tuple[float, int]:
group = by_source[key]
max_score = max(h.score for h in group)
matched = [
h for h in group if sid and (h.section_id or "").upper() == sid
] or group
word_count = sum(len(h.text.split()) for h in matched)
return max_score + min(word_count / 400.0, 0.2), word_count
best_source = max(by_source, key=source_rank)
group = by_source[best_source]
# Section-complete expansion: replace the top-K group with the chosen source's
# ENTIRE section so no paragraph the surveyor expects is left unmapped.
section_complete = False
if (
tenant_id
and sid
and settings.reference_section_complete_enabled
):
complete = _complete_section_group(
tenant_id,
best_source,
sid,
tier=tier,
allowed_doc_keys=allowed_doc_keys,
property_context=property_context,
)
if complete:
group = complete
section_complete = True
section_hits = [
h for h in group if sid and (h.section_id or "").upper() == sid
]
if not section_hits:
return "", []
section_hits.sort(
key=lambda h: (h.paragraph_index or 0, -len(h.text.split()), -h.score),
)
# Only the expanded path is char-budgeted; the bounded top-K path is untouched.
budget = (
settings.reference_section_complete_max_chars if section_complete else None
)
texts: list[str] = []
contributing: list[SearchHit] = []
seen_para: set[int] = set()
used = 0
for hit in section_hits:
para_idx = hit.paragraph_index or 0
if para_idx and para_idx in seen_para:
continue
body = hit.text.strip()
if not body:
continue
if budget is not None and texts and used + len(body) > budget:
break
if para_idx:
seen_para.add(para_idx)
if body not in texts:
texts.append(body)
contributing.append(hit)
used += len(body)
if not texts:
best = max(section_hits, key=lambda h: (h.score, len(h.text.split())))
return best.text.strip(), [best]
baseline = combine_reference_blocks(texts[0], texts[1:])
return baseline, contributing
def fetch_complete_section_baseline(
tenant_id: str,
*,
paragraph_section_id: str,
tier: str | None = None,
allowed_doc_keys: frozenset[str] | None = None,
property_context: dict | None = None,
) -> tuple[str, list[SearchHit]]:
"""Metadata-only baseline for a section (no similarity query).
Fallback for when similarity search surfaced nothing for a section that
nonetheless exists in the index (weak query match, alias drift). Returns the
fullest single-source section, or ``("", [])`` when the section is genuinely
absent — in which case the caller correctly degrades to a notes-only section.
"""
if not settings.reference_section_complete_enabled:
return "", []
chunks = _complete_section_group(
tenant_id,
None,
paragraph_section_id,
tier=tier,
allowed_doc_keys=allowed_doc_keys,
property_context=property_context,
)
if not chunks:
return "", []
return assemble_reference_baseline(
chunks,
paragraph_section_id=paragraph_section_id,
tenant_id=tenant_id,
tier=tier,
allowed_doc_keys=allowed_doc_keys,
property_context=property_context,
)
def _uses_reference_tier(interference_level: InterferenceLevel) -> bool:
return interference_level in ("minimum", "medium", "maximum")
def _retrieval_params(
retrieval_level: RetrievalLevel,
*,
paragraph_section_id: str,
k: int,
) -> tuple[bool, int]:
level = (retrieval_level or "paragraph").lower()
if level == "section":
return bool(paragraph_section_id), max(k, 8)
if level == "document":
return bool(paragraph_section_id), max(k, 15)
return bool(paragraph_section_id), k
def _focus_document_hits(hits: list[SearchHit], k: int) -> list[SearchHit]:
"""Prefer chunks from the single best-matching past report."""
if not hits:
return hits
by_source: dict[str, list[SearchHit]] = {}
for hit in hits:
key = hit.source_filename or hit.doc_id or "unknown"
by_source.setdefault(key, []).append(hit)
best_key = max(by_source, key=lambda name: max(h.score for h in by_source[name]))
focused = sorted(by_source[best_key], key=lambda h: h.score, reverse=True)
return focused[:k]
def _search_tier(
tenant_id: str,
query: str,
*,
paragraph_section_id: str,
k: int,
interference_level: InterferenceLevel,
retrieval_level: RetrievalLevel = "paragraph",
allowed_doc_keys: frozenset[str] | None = None,
) -> list[SearchHit]:
store = get_rag_store()
if _uses_reference_tier(interference_level):
search = store.search_for_reference_mapping
else:
search = store.search_for_generation
strict, fetch_k = _retrieval_params(
retrieval_level,
paragraph_section_id=paragraph_section_id,
k=k,
)
search_kwargs = {
"section_id": paragraph_section_id or None,
"allowed_doc_keys": allowed_doc_keys,
}
# Never widen to cross-section retrieval — empty is safer than wrong-section bleed.
use_strict = strict or bool(paragraph_section_id)
hits = search(
tenant_id,
query,
top_k=max(fetch_k * 3, fetch_k),
section_strict=use_strict,
**search_kwargs,
)
hits = _filter_hits_to_section(hits, paragraph_section_id)
if (retrieval_level or "paragraph").lower() == "document":
hits = _focus_document_hits(hits, fetch_k)
return hits
def retrieve_paragraphs_for_mapping(
tenant_id: str,
*,
section_label: str,
paragraph_section_id: str,
observations: list[str],
interference_level: InterferenceLevel = "minimum",
retrieval_level: RetrievalLevel = "paragraph",
top_k: int | None = None,
allowed_doc_keys: frozenset[str] | None = None,
property_context: dict | None = None,
) -> list[SearchHit]:
"""Ranked paragraphs from uploaded past reports for all interference levels.
When ``property_context`` is supplied, paragraphs whose terminology is
incompatible with the confirmed property type (e.g. flat-only content in a
house report) are dropped before ranking — the next compatible candidate is
used rather than leaving a wrong-property baseline.
"""
k = top_k or settings.reference_baseline_top_k
if interference_level == "medium":
k = max(k, settings.reference_baseline_top_k)
elif interference_level == "maximum":
k = max(k, settings.reference_baseline_top_k + 2)
query = build_retrieval_query(
section_label,
observations,
section_id=paragraph_section_id,
)
hits = _search_tier(
tenant_id,
query,
paragraph_section_id=paragraph_section_id,
k=k,
interference_level=interference_level,
retrieval_level=retrieval_level,
allowed_doc_keys=allowed_doc_keys,
)
if property_context:
from backend.core.property_blocklist import filter_hits_by_property
hits = filter_hits_by_property(hits, property_context)
hits = rerank_hits_by_observations(hits, observations)
hits = rerank_hits_for_comprehensive_baseline(hits)
return hits[:k]
def find_paragraph_by_topic(
tenant_id: str,
observations: list[str],
*,
interference_level: InterferenceLevel = "minimum",
top_k: int = 3,
allowed_doc_keys: frozenset[str] | None = None,
paragraph_section_id: str = "",
) -> list[SearchHit]:
"""Topic search scoped to ``paragraph_section_id`` when provided."""
parts = [o.strip() for o in observations if o and o.strip()]
if not parts:
return []
query = " ".join(sorted(parts, key=len, reverse=True)[:5])
store = get_rag_store()
sid = (paragraph_section_id or "").strip() or None
use_strict = bool(sid)
if _uses_reference_tier(interference_level):
hits = store.search_for_reference_mapping(
tenant_id,
query,
section_id=sid,
top_k=max(top_k * 5, top_k),
section_strict=use_strict,
allowed_doc_keys=allowed_doc_keys,
)
else:
hits = store.search_for_generation(
tenant_id,
query,
section_id=sid,
top_k=max(top_k * 5, top_k),
section_strict=use_strict,
)
hits = _filter_hits_to_section(hits, paragraph_section_id)
ranked = rerank_hits_by_observations(hits, parts)
return rerank_hits_for_comprehensive_baseline(ranked)[:top_k]
def report_section_for_paragraph_id(
schema: TemplateSchema,
paragraph_section_id: str,
) -> str | None:
if not paragraph_section_id:
return None
for report_id, para_id in schema.section_alias_map.items():
if para_id == paragraph_section_id:
return report_id
if schema.get_section(paragraph_section_id):
return paragraph_section_id
return None
def guess_report_section_from_topic(
schema: TemplateSchema,
observations: list[str],
reference_text: str = "",
) -> str | None:
"""Best-effort section when REFERENCE chunks lack section_id metadata."""
corpus = " ".join(observations) + " " + reference_text
words = _content_tokens(corpus)
if not words:
return None
best_id: str | None = None
best_score = 0
for sec in schema.ordered_sections():
title_words = _content_tokens(f"{sec.id} {sec.title}")
overlap = len(words & title_words)
if overlap > best_score:
best_score = overlap
best_id = sec.id
return best_id if best_score > 0 else None
# Backward-compatible alias — always searches MASTER tier.
def retrieve_master_paragraphs(
tenant_id: str,
*,
section_label: str,
paragraph_section_id: str,
observations: list[str],
top_k: int | None = None,
) -> list[SearchHit]:
k = top_k or settings.retrieval_top_k
query = build_retrieval_query(
section_label,
observations,
section_id=paragraph_section_id,
)
store = get_rag_store()
hits = store.search_for_generation(
tenant_id,
query,
section_id=paragraph_section_id or None,
top_k=max(k * 3, k),
section_strict=bool(paragraph_section_id),
)
hits = _filter_hits_to_section(hits, paragraph_section_id)
hits = rerank_hits_by_observations(hits, observations)
hits = rerank_hits_for_comprehensive_baseline(hits)
return hits[:k]