RICS / app /extraction /extractor.py
StormShadow308's picture
Ship production RAG hardening: citation extraction, full-library retrieval, auth.
865bc90
Raw
History Blame Contribute Delete
9.01 kB
"""Citation-grounded, schema-constrained section extraction (STEP 3/4/8).
Pipeline for one section:
section-scoped chunks
-> deterministic LLM extraction (temperature=0, top_p=1, seed, JSON mode)
-> Pydantic schema validation (closed enums, mandatory evidence)
-> citation/source-span validation (drop unsupported claims)
-> contradiction audit (resolve conflicts, collapse duplicates)
-> SectionExtraction (with confidence + dropped-claim audit trail)
The LLM is the only non-deterministic component, and it is pinned as hard as
the API allows. Everything after it is pure, deterministic Python.
This module is additive: it does not alter the existing generation path. Callers
opt in (see ``settings.enable_citation_extraction``). It degrades safely to an
empty extraction when no API key is configured, so import and unit tests never
require network access.
"""
from __future__ import annotations
import json
import logging
from app.config import settings
from app.extraction.citation_validator import validate_findings
from app.extraction.contradiction import audit_contradictions
from app.extraction.domain_scope import GENERAL, classify_section, scope_chunks
from app.extraction.prompts import (
EXTRACTION_SYSTEM_PROMPT,
EXTRACTION_USER_TEMPLATE,
build_chunks_block,
)
from app.extraction.output_validator import should_abstain
from app.extraction.schemas import (
AtomicClaim,
ClaimEvidence,
ClaimType,
ClaimVerification,
ConditionRating,
SectionExtraction,
SupportLevel,
SurveyFinding,
)
logger = logging.getLogger(__name__)
# Fixed seed for reproducible decoding across identical inputs.
_EXTRACTION_SEED = 7
def _chunk_tuples(chunks: list[object]) -> list[tuple[str, str, str | None]]:
"""Adapt SearchResult-like rows into (chunk_id, text, label) tuples."""
out: list[tuple[str, str, str | None]] = []
for c in chunks:
cid = getattr(c, "chunk_id", None)
text = getattr(c, "text", None)
if not cid or not text:
continue
label = getattr(c, "section_title", None)
out.append((str(cid), str(text), label))
return out
def _parse_findings(raw_json: str, section: str) -> list[SurveyFinding]:
"""Parse model JSON into validated SurveyFinding models, skipping malformed rows."""
try:
data = json.loads(raw_json)
except (json.JSONDecodeError, TypeError):
logger.warning("extractor: model returned non-JSON for section=%s", section)
return []
rows = data.get("findings", []) if isinstance(data, dict) else []
findings: list[SurveyFinding] = []
for row in rows:
if not isinstance(row, dict):
continue
row.setdefault("section", section)
try:
findings.append(SurveyFinding.model_validate(row))
except Exception as exc: # malformed row -> skip, never fabricate
logger.debug("extractor: skipped malformed finding: %s", exc)
return findings
async def extract_section(
*,
section: str,
chunks: list[object],
tenant_id: str | None = None,
drop_partial: bool = False,
domain: str | None = None,
model: str | None = None,
) -> SectionExtraction:
"""Extract verified, contradiction-free findings for one section.
When ``domain`` is provided (STEP 6), chunks clearly belonging to a
different domain are excluded before extraction as defense-in-depth — the
caller should still scope retrieval, but this guarantees the extractor never
sees cross-domain evidence. The supplied (scoped) pool is the ONLY
admissible evidence.
Returns a :class:`SectionExtraction`. With no API key (or no chunks), returns
an empty extraction rather than raising.
"""
if domain and domain != GENERAL:
chunks = scope_chunks(domain, chunks)
pool = _chunk_tuples(chunks)
if not pool:
return SectionExtraction(section=section)
if not (settings.openai_api_key or "").strip():
logger.info("extractor: no API key; returning empty extraction for %s", section)
return SectionExtraction(section=section)
user_prompt = EXTRACTION_USER_TEMPLATE.format(
section=section,
chunks_block=build_chunks_block(pool),
)
from app.llm.openai_chat import chat_completions_create
raw = await chat_completions_create(
messages=[
{"role": "system", "content": EXTRACTION_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
model=model or settings.chat_model,
max_tokens=settings.extraction_max_tokens,
temperature=0.0,
top_p=1.0,
seed=_EXTRACTION_SEED,
response_format={"type": "json_object"},
phase="extraction",
section_id=section,
tenant_id=tenant_id,
)
findings = _parse_findings(raw, section)
# Hard citation gate: drop everything not grounded in the supplied pool.
kept, dropped = validate_findings(findings, pool, drop_partial=drop_partial)
# Contradiction/duplicate audit on the survivors.
resolved, contradictions = audit_contradictions(kept)
logger.info(
"extract_section=%s extracted=%d kept=%d dropped=%d contradictions=%d",
section, len(findings), len(resolved), len(dropped), len(contradictions),
)
return SectionExtraction(
section=section,
findings=resolved,
contradictions=contradictions,
dropped_claims=dropped,
)
_SUPPORT_CONFIDENCE: dict[SupportLevel, float] = {
SupportLevel.SUPPORTED: 1.0,
SupportLevel.PARTIAL: 0.5,
SupportLevel.NOT_FOUND: 0.0,
}
def findings_to_atomic_claims(
findings: list[SurveyFinding],
*,
min_confidence: float = 1.0,
) -> list[AtomicClaim]:
"""Decompose grounded findings into atomic, evidence-bound claim records.
Each finding yields at most two atomic claims: a ``condition_rating`` claim
(only when the source stated a real CR1/CR2/CR3) and an ``observation``
claim for the finding text. Every claim is bound to the finding's first
valid evidence span and then passed through the abstention gate
(:func:`should_abstain`) — any claim that introduces forbidden/fabricated
language or falls below ``min_confidence`` is dropped (RETURN NOTHING).
Pure and deterministic: no network, no synthesis. Findings are assumed to
have already passed citation validation.
"""
claims: list[AtomicClaim] = []
real_ratings = {ConditionRating.CR1, ConditionRating.CR2, ConditionRating.CR3}
for f in findings:
if not f.evidence:
continue
span = f.evidence[0]
evidence_text = " \n ".join(e.text for e in f.evidence)
confidence = _SUPPORT_CONFIDENCE.get(f.support, 0.0)
page = span.page if span.page is not None else 0
ev = ClaimEvidence(
text=span.text,
page=page,
section=span.section_label or "",
chunk_id=span.chunk_id,
)
if f.condition_rating in real_ratings:
rating_claim = f"{f.element} condition rating is {f.condition_rating.value}"
if not should_abstain(
rating_claim, evidence_text,
min_confidence=min_confidence, confidence=confidence,
):
claims.append(AtomicClaim(
claim=rating_claim,
claim_type=ClaimType.CONDITION_RATING,
evidence=ev,
verification=ClaimVerification(
supported=True, contradiction_detected=False, confidence=confidence,
),
))
if not should_abstain(
f.finding, evidence_text,
min_confidence=min_confidence, confidence=confidence,
):
claims.append(AtomicClaim(
claim=f.finding,
claim_type=ClaimType.OBSERVATION,
evidence=ev,
verification=ClaimVerification(
supported=True, contradiction_detected=False, confidence=confidence,
),
))
return claims
async def audit_section_grounding(
*,
section_name: str,
template_id: str | None,
chunks: list[object],
tenant_id: str | None = None,
) -> SectionExtraction:
"""High-level, non-destructive grounding audit for a generated section.
Resolves the section's domain, scopes the retrieved evidence to that domain,
and runs citation-grounded extraction + contradiction audit. Intended to be
called alongside generation (behind ``settings.enable_citation_extraction``)
to produce a traceability artifact — it never mutates generated prose.
"""
domain = classify_section(section_name, template_id)
return await extract_section(
section=section_name or (template_id or "section"),
chunks=chunks,
tenant_id=tenant_id,
domain=domain,
)