Spaces:
Running
Running
File size: 7,977 Bytes
f0307a2 1121d82 f0307a2 1121d82 f0307a2 1121d82 f0307a2 1121d82 f0307a2 | 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 | from __future__ import annotations
import math
import re
from dataclasses import dataclass
from .schemas import QueryPlan, SearchHit
_STOPWORDS = {
"what", "which", "when", "where", "who", "why", "how", "does", "do", "did", "is", "are", "was", "were",
"the", "a", "an", "and", "or", "of", "to", "for", "from", "in", "on", "with", "about", "our", "this",
"that", "these", "those", "current", "indexed", "document", "documents", "collection", "please", "give",
}
@dataclass(slots=True)
class EvidenceCompressionDecision:
texts: dict[str, str]
used: bool
reason: str
chunks: int
chars_before: int
chars_after: int
tokens_est_before: int
tokens_est_after: int
@property
def reduction_ratio(self) -> float:
if self.chars_before <= 0:
return 0.0
return max(0.0, min(1.0, 1.0 - (self.chars_after / self.chars_before)))
def trace_fields(self) -> dict[str, object]:
return {
"evidence_compression_used": self.used,
"evidence_compression_reason": self.reason,
"evidence_compression_chunks": self.chunks,
"evidence_chars_before_compression": self.chars_before,
"evidence_chars_after_compression": self.chars_after,
"evidence_tokens_est_before_compression": self.tokens_est_before,
"evidence_tokens_est_after_compression": self.tokens_est_after,
"evidence_compression_reduction_pct": round(self.reduction_ratio * 100.0, 1),
}
def _tokens_est(chars: int) -> int:
return int(math.ceil(max(0, chars) / 4.0))
def _query_terms(query: str) -> set[str]:
return {
token.lower()
for token in re.findall(r"[A-Za-z0-9_]+", query or "")
if len(token) >= 3 and token.lower() not in _STOPWORDS
}
def _split_units(text: str) -> list[str]:
cleaned = re.sub(r"\r\n?", "\n", text or "")
# Keep bullet/list lines as their own candidate units while splitting long
# prose paragraphs into sentences. This is intentionally lightweight and
# deterministic so compression never spends an LLM request.
units: list[str] = []
for line in cleaned.split("\n"):
line = re.sub(r"\s+", " ", line).strip()
if not line:
continue
if re.match(r"^(?:[-*+]\s+|\d+[.)]\s+)", line):
units.append(line)
continue
pieces = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9#*`])", line)
units.extend(piece.strip() for piece in pieces if piece.strip())
return units or [re.sub(r"\s+", " ", cleaned).strip()]
def _entity_labels(text: str) -> list[str]:
"""Return compact entity labels such as ``sev-1`` or ``tier-2``.
These labels often disambiguate nearby sibling facts that otherwise share
the same generic wording (for example, ``Sev-1`` and ``Sev-2`` both having
an acknowledgement target).
"""
labels = []
for prefix, number in re.findall(r"\b([A-Za-z][A-Za-z0-9_]*)-(\d+)\b", text or ""):
labels.append(f"{prefix.lower()}-{number}")
return labels
def _entity_family(label: str) -> str:
return label.rsplit("-", 1)[0] if "-" in label else label
def _score_unit(
unit: str,
terms: set[str],
query_numbers: set[str],
*,
query_entities: set[str] | None = None,
context_entities: set[str] | None = None,
) -> float:
lower = unit.lower()
unit_terms = set(re.findall(r"[A-Za-z0-9_]+", lower))
overlap = len(terms & unit_terms)
number_hits = len(query_numbers & unit_terms)
score = overlap * 3.0 + number_hits * 2.0 + min(1.5, len(unit) / 500.0)
query_entities = query_entities or set()
context_entities = context_entities or set(_entity_labels(unit))
if query_entities:
# Strongly prefer evidence bound to the exact entity named by the query.
# If a nearby generic sentence inherits a sibling entity (e.g. a
# ``15 minutes`` sentence immediately after ``Sev-2``), penalize it so
# it cannot outrank the explicitly bound ``Sev-1`` fact.
exact = query_entities & context_entities
if exact:
score += 8.0 * len(exact)
else:
query_families = {_entity_family(label) for label in query_entities}
sibling = {
label for label in context_entities
if _entity_family(label) in query_families and label not in query_entities
}
if sibling:
score -= 8.0 * len(sibling)
return score
def compress_text_for_query(query: str, text: str, *, max_chars: int = 900, max_units: int = 3) -> str:
original = re.sub(r"\s+", " ", text or "").strip()
if len(original) <= max_chars:
return original
units = _split_units(text)
terms = _query_terms(query)
query_numbers = set(re.findall(r"\b\d+(?:\.\d+)?\b", query or ""))
query_entities = set(_entity_labels(query))
# Short follow-up sentences often omit the entity they belong to. Preserve
# that local binding for one sentence so sibling facts remain distinct.
# Example: ``A Sev-2 incident ... . The acknowledgement target is 15
# minutes.`` The second sentence should inherit ``sev-2``.
unit_context_entities: list[set[str]] = []
previous_entities: set[str] = set()
for unit in units:
explicit = set(_entity_labels(unit))
if explicit:
current = explicit
previous_entities = explicit
else:
current = set(previous_entities)
previous_entities = set()
unit_context_entities.append(current)
ranked = sorted(
enumerate(units),
key=lambda item: (
_score_unit(
item[1],
terms,
query_numbers,
query_entities=query_entities,
context_entities=unit_context_entities[item[0]],
),
-item[0],
),
reverse=True,
)
chosen_idx = sorted(idx for idx, _ in ranked[: max(1, max_units)])
chosen = [units[idx] for idx in chosen_idx]
candidate = " ".join(chosen).strip()
if not candidate:
candidate = original[:max_chars]
if len(candidate) > max_chars:
candidate = candidate[: max_chars - 3].rsplit(" ", 1)[0].rstrip() + "..."
return candidate
def focused_evidence_compression(
hits: list[SearchHit],
plan: QueryPlan,
*,
query: str,
enabled: bool = True,
) -> EvidenceCompressionDecision:
before_chars = sum(len(hit.chunk.text or "") for hit in hits)
def decision(texts: dict[str, str], used: bool, reason: str) -> EvidenceCompressionDecision:
after_chars = sum(len(texts.get(hit.chunk.id, hit.chunk.text or "")) for hit in hits)
return EvidenceCompressionDecision(
texts=texts,
used=used,
reason=reason,
chunks=len(hits),
chars_before=before_chars,
chars_after=after_chars,
tokens_est_before=_tokens_est(before_chars),
tokens_est_after=_tokens_est(after_chars),
)
original = {hit.chunk.id: hit.chunk.text or "" for hit in hits}
if not hits:
return decision(original, False, "no_document_evidence")
if not enabled:
return decision(original, False, "disabled_by_user")
if plan.task_type not in {"fact_lookup", "followup"}:
return decision(original, False, "broad_or_multi_source_task")
if plan.retrieval_strategy not in {"semantic", "hierarchical"}:
return decision(original, False, "strategy_requires_full_chunks")
compressed = {
hit.chunk.id: compress_text_for_query(query, hit.chunk.text or "")
for hit in hits
}
after_chars = sum(len(text) for text in compressed.values())
if after_chars >= before_chars * 0.92:
return decision(original, False, "minimal_compression_gain")
return decision(compressed, True, "focused_sentence_selection")
|