brain / src /core /saving /grounding.py
Leon4gr45's picture
Upload folder using huggingface_hub (part 2)
e70c280 verified
Raw History Blame Contribute Delete
11.5 kB
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any, Literal, Optional, Sequence
_WHITESPACE_RE = re.compile(r"\s+")
GroundingVerdict = Literal["accept", "reject", "ambiguous"]
def normalize_span(text: str) -> str:
return _WHITESPACE_RE.sub(" ", (text or "").strip().lower())
def extract_source_span(
rel: Any,
source_text: str,
*,
allow_synthetic: bool = True,
) -> str:
props = getattr(rel, "properties", None) or {}
if isinstance(props, dict):
for key in ("source_span", "span", "evidence", "quote"):
value = props.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
explicit = getattr(rel, "source_span", None)
if isinstance(explicit, str) and explicit.strip():
return explicit.strip()
description = getattr(rel, "description", None) or ""
if description and source_text:
score, aligned = align_span(description, source_text)
if score >= 0.55 and aligned:
return aligned
if description.strip():
return description.strip()
if not allow_synthetic:
return ""
name = getattr(rel, "name", None) or ""
tail = getattr(getattr(rel, "tail", None), "name", "") or ""
tip = getattr(getattr(rel, "tip", None), "name", "") or ""
return " ".join(p for p in (tail, name, tip) if p).strip()
def extract_explicit_evidence(rel: Any, source_text: str) -> str:
"""Evidence quote/description only — never invent from endpoint names."""
return extract_source_span(rel, source_text, allow_synthetic=False)
def align_span(span: str, source_text: str) -> tuple[float, str]:
"""
Cheap string alignment: exact / containment / token Jaccard against windows.
Returns (score in [0,1], best matching source window or "").
"""
needle = normalize_span(span)
hay = normalize_span(source_text)
if not needle or not hay:
return 0.0, ""
if needle in hay:
return 1.0, span.strip()
needle_tokens = [t for t in needle.split(" ") if t]
if not needle_tokens:
return 0.0, ""
window = max(len(needle_tokens) + 4, 8)
hay_tokens = hay.split(" ")
best_score = 0.0
best_window = ""
needle_set = set(needle_tokens)
for i in range(0, max(1, len(hay_tokens) - window + 1)):
chunk = hay_tokens[i : i + window]
chunk_set = set(chunk)
if not chunk_set:
continue
score = len(needle_set & chunk_set) / len(needle_set | chunk_set)
if score > best_score:
best_score = score
best_window = " ".join(chunk)
return best_score, best_window
def find_span_offsets(
span: str,
source_text: str,
) -> Optional[tuple[int, int]]:
"""
Locate `span` inside `source_text` with exact then whitespace-tolerant match.
Returns (start, end) character offsets into the original source, or None.
"""
text = source_text or ""
needle = (span or "").strip()
if not needle or not text:
return None
exact = text.find(needle)
if exact >= 0:
return exact, exact + len(needle)
# Whitespace-tolerant: map normalized needle back onto original offsets.
norm_needle = normalize_span(needle)
if not norm_needle:
return None
lower = text.lower()
# Build map from normalized index -> original index
norm_chars: list[str] = []
norm_to_orig: list[int] = []
prev_space = False
for i, ch in enumerate(lower):
if ch.isspace():
if norm_chars and not prev_space:
norm_chars.append(" ")
norm_to_orig.append(i)
prev_space = True
continue
if not norm_chars and ch.isspace():
continue
norm_chars.append(ch)
norm_to_orig.append(i)
prev_space = False
norm_hay = "".join(norm_chars).strip()
# Trim leading spaces recorded above
while norm_to_orig and norm_hay and norm_hay[0] == " ":
norm_hay = norm_hay[1:]
norm_to_orig = norm_to_orig[1:]
idx = norm_hay.find(norm_needle)
if idx < 0:
return None
start_orig = norm_to_orig[idx]
end_idx = idx + len(norm_needle) - 1
if end_idx >= len(norm_to_orig):
return None
end_orig = norm_to_orig[end_idx] + 1
return start_orig, end_orig
def realign_span_offsets(
*,
span: str,
source_text: str,
span_start: Optional[int] = None,
span_end: Optional[int] = None,
min_score: float = 0.45,
) -> tuple[bool, Optional[int], Optional[int], str]:
"""
Validate or repair character offsets for a source span.
Returns (ok, new_start, new_end, reason). On success with repaired or
cleared offsets, reason is "". On failure, reason is a machine-readable code.
"""
text = source_text or ""
needle = (span or "").strip()
if not needle:
return False, span_start, span_end, "missing_source_span"
score, _ = align_span(needle, text)
if score < min_score:
return False, span_start, span_end, "source_span_not_grounded"
if span_start is None or span_end is None:
found = find_span_offsets(needle, text)
if found:
return True, found[0], found[1], ""
return True, None, None, ""
if span_start < 0 or span_end > len(text) or span_start >= span_end:
found = find_span_offsets(needle, text)
if found:
return True, found[0], found[1], ""
# Offsets unusable but span text is grounded — clear offsets.
return True, None, None, ""
sliced = text[span_start:span_end]
if sliced.strip() and normalize_span(sliced) == normalize_span(needle):
return True, span_start, span_end, ""
slice_score, _ = align_span(needle, sliced)
if slice_score >= min_score:
return True, span_start, span_end, ""
found = find_span_offsets(needle, text)
if found:
return True, found[0], found[1], ""
# Soften: keep the grounded quote, drop bad offsets instead of rejecting.
return True, None, None, ""
def relationship_looks_grounded(
rel: Any,
source_text: str,
*,
min_score: float = 0.45,
) -> tuple[bool, float, str]:
span = extract_explicit_evidence(rel, source_text)
score, aligned = align_span(span, source_text)
if not span:
return False, 0.0, ""
return score >= min_score, score, aligned or span
def endpoints_well_formed(rel: Any) -> bool:
for endpoint in (getattr(rel, "tail", None), getattr(rel, "tip", None)):
if endpoint is None:
return False
name = (getattr(endpoint, "name", None) or "").strip()
typ = (getattr(endpoint, "type", None) or "").strip()
if not name or not typ:
return False
if name.lower() == typ.lower():
return False
predicate = (getattr(rel, "name", None) or "").strip()
return bool(predicate)
def _endpoint_is_type_named(endpoint: Any) -> bool:
if endpoint is None:
return True
name = (getattr(endpoint, "name", None) or "").strip()
typ = (getattr(endpoint, "type", None) or "").strip()
if not name or not typ:
return True
return name.lower() == typ.lower()
@dataclass
class GroundingDecision:
decision: GroundingVerdict
reason: str
score: float = 0.0
span: str = ""
relationship: Any = None
@dataclass
class JanitorTriageResult:
accept: list[Any] = field(default_factory=list)
reject: list[GroundingDecision] = field(default_factory=list)
ambiguous: list[Any] = field(default_factory=list)
def decide_relationship_grounding(
rel: Any,
source_text: str,
*,
min_score: float = 0.45,
reject_below: float = 0.15,
) -> GroundingDecision:
"""
Classify one relationship as accept / reject / ambiguous for Janitor triage.
"""
if _endpoint_is_type_named(getattr(rel, "tail", None)) or _endpoint_is_type_named(
getattr(rel, "tip", None)
):
return GroundingDecision(
decision="reject",
reason="type_named_placeholder_endpoint",
relationship=rel,
)
if not endpoints_well_formed(rel):
return GroundingDecision(
decision="reject",
reason="malformed_endpoints",
relationship=rel,
)
predicate = (getattr(rel, "name", None) or "").strip()
if not predicate:
return GroundingDecision(
decision="reject",
reason="missing_predicate",
relationship=rel,
)
span = extract_explicit_evidence(rel, source_text)
score, aligned = align_span(span, source_text) if span else (0.0, "")
if not span:
return GroundingDecision(
decision="ambiguous",
reason="missing_source_span",
score=0.0,
relationship=rel,
)
if score >= min_score:
if isinstance(getattr(rel, "properties", None), dict) and aligned:
props = rel.properties or {}
if not props.get("source_span"):
try:
rel.properties = {
**props,
"source_span": aligned,
"grounding_score": score,
}
except Exception:
pass
return GroundingDecision(
decision="accept",
reason="grounded_endpoints_ok",
score=score,
span=aligned or span,
relationship=rel,
)
if score < reject_below:
return GroundingDecision(
decision="reject",
reason="source_span_not_grounded",
score=score,
span=span,
relationship=rel,
)
return GroundingDecision(
decision="ambiguous",
reason="weak_grounding",
score=score,
span=span,
relationship=rel,
)
def triage_relationships_for_janitor(
relationships: Sequence[Any],
source_text: str,
*,
min_grounding: float = 0.45,
reject_below: float = 0.15,
) -> JanitorTriageResult:
"""
Split relationships into accept (skip LLM), reject (drop), ambiguous (LLM).
"""
result = JanitorTriageResult()
for rel in relationships:
decision = decide_relationship_grounding(
rel,
source_text,
min_score=min_grounding,
reject_below=reject_below,
)
if decision.decision == "accept":
result.accept.append(rel)
elif decision.decision == "reject":
result.reject.append(decision)
else:
result.ambiguous.append(rel)
return result
def cheap_janitor_precheck(
relationships: Sequence[Any],
source_text: str,
*,
min_grounding: float = 0.45,
) -> tuple[list[Any], list[Any]]:
"""
Split relationships into (skip_janitor, need_janitor).
Rejects from triage are folded into need_janitor for backward compatibility;
prefer triage_relationships_for_janitor for explicit drop auditing.
"""
triage = triage_relationships_for_janitor(
relationships, source_text, min_grounding=min_grounding
)
need = list(triage.ambiguous)
need.extend(d.relationship for d in triage.reject if d.relationship is not None)
return list(triage.accept), need