File size: 13,622 Bytes
6cb442b | 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 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 | """One interface for the two places an LLM is allowed: search planning and claim extraction.
Everything downstream (guards, validation, bounds comparison, conflicts, staleness) stays deterministic.
Extractor.extract(text, meta) -> (claims, notes) # same claim dict shape as tools.extract_claims
Planner.plan(prompt) -> [query, ...] # search queries, capped by the agent budget
Defaults are rule-based (no network, no spend). LLM versions take any OpenAI-compatible chat client;
MockLLMClient lets you test the plumbing offline. It never produces numbers for reports."""
from __future__ import annotations
import json, os, re
from tools import extract_claims, display_uncertainty, SKIP_LINE
DEFAULT_QUERY = "squares in squares packing"
class RuleExtractor:
name = "rule"
def extract(self, text, meta):
return extract_claims(text, meta)
class FixedPlanner:
name = "fixed"
def plan(self, prompt):
return [prompt.get("query", DEFAULT_QUERY)]
class OpenAICompatClient:
"""Minimal chat client. Env: SQ_LLM_BASE_URL, SQ_LLM_MODEL, SQ_LLM_API_KEY.
json_mode=False omits response_format (needed for providers that reject json_object)."""
def __init__(self, model=None, base_url=None, api_key=None, timeout=120, sleep_s=0.0, max_retries=2, json_mode=True):
self.sleep_s, self.max_retries = sleep_s, max_retries
self.model = model or os.environ.get("SQ_LLM_MODEL")
if not self.model:
raise ValueError("SQ_LLM_MODEL (or model=) required for live LLM calls")
self.base_url = (base_url or os.environ.get("SQ_LLM_BASE_URL") or "").rstrip("/")
if not self.base_url:
raise ValueError("SQ_LLM_BASE_URL (or base_url=) required for live LLM calls")
self.api_key = api_key or os.environ.get("SQ_LLM_API_KEY")
self.timeout = timeout
self.json_mode = json_mode
self.usage = {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0}
if not self.api_key:
raise RuntimeError("no LLM key: set SQ_LLM_API_KEY")
def complete_json(self, prompt):
import httpx, time
body = {"model": self.model, "temperature": 0,
"messages": [{"role": "user", "content": prompt}]}
if self.json_mode:
body["response_format"] = {"type": "json_object"}
last = None
for k in range(self.max_retries + 1):
if self.sleep_s:
time.sleep(self.sleep_s)
self.usage["calls"] += 1 # count every attempt
try:
r = httpx.post(f"{self.base_url}/chat/completions", timeout=self.timeout,
headers={"Authorization": f"Bearer {self.api_key}"},
json=body)
if r.status_code in (429, 500, 502, 503, 504):
last = RuntimeError(f"status {r.status_code}")
if k < self.max_retries:
ra = float(r.headers.get("retry-after", 0) or 0)
time.sleep(ra or min(60, 5 * 2 ** k)); continue
raise last
r.raise_for_status()
j = r.json()
if isinstance(j, dict) and j.get("error"):
last = RuntimeError(f"api error: {j['error']}")
if k < self.max_retries:
time.sleep(min(60, 5 * 2 ** k)); continue
raise last
u = j.get("usage", {}) or {}
for kk in ("prompt_tokens", "completion_tokens"):
self.usage[kk] += u.get(kk, 0)
c = ((j.get("choices") or [{}])[0].get("message") or {}).get("content") or ""
if not str(c).strip():
last = ValueError("empty content")
if k < self.max_retries:
time.sleep(min(60, 5 * 2 ** k)); continue
raise last
m = re.search(r"\{.*\}", c, re.S) # outermost {...}; tolerate prose/fences
return json.loads(m.group(0) if m else c)
except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e:
last = e
if k < self.max_retries:
time.sleep(min(60, 5 * 2 ** k)); continue
raise
raise last if last else RuntimeError("complete_json failed")
class MockLLMClient:
"""Offline stand-in: returns canned JSON (list or callable). For plumbing tests only."""
def __init__(self, responses):
self.responses = responses; self.usage = {"calls": 0}
def complete_json(self, prompt):
self.usage["calls"] += 1
r = self.responses(prompt) if callable(self.responses) else self.responses[min(self.usage["calls"] - 1, len(self.responses) - 1)]
if isinstance(r, Exception):
raise r
return r
EXTRACT_PROMPT = """You find claims about packing n unit squares into a square container of side s.
Return JSON {{"claims":[{{"n":int,"kind":"upper_bound_packing"|"lower_bound"|"other","quote":str}}]}}.
Rules: quote must be copied VERBATIM from the text (one line or sentence) and must contain the side value.
Do NOT return the side value separately; it is parsed from your quote. Use kind=lower_bound for statements like
"cannot be packed in less than". Skip hypotheticals, targets, and retracted values. Only n in {watch}.
TEXT:
{text}"""
# a numeric token: not glued to letters/digits; allows comma decimals (3,70711)
NUM_TOKEN = re.compile(r"(?<![\w.,])(\d+(?:[.,]\d+)?)(?![\w]|[.,]\d)")
def _tokval(tok):
return float(tok.replace(",", "."))
def n_in_quote(q, n):
return re.search(rf"(?<![\w.,]){n}(?![\w]|[.,]\d)", q) is not None
def chunk_lines(text, max_chars=12000, overlap_lines=3):
"""Split on line boundaries (never mid-number). Consecutive chunks share `overlap_lines` lines.
A single line longer than max_chars is split at whitespace."""
lines = []
for ln in text.splitlines():
while len(ln) > max_chars:
cut = ln.rfind(" ", 0, max_chars)
cut = cut if cut > 0 else max_chars # no whitespace at all: hard cut is the only option
lines.append(ln[:cut]); ln = ln[cut:].lstrip()
lines.append(ln)
chunks, cur, size = [], [], 0
for ln in lines:
if cur and size + len(ln) + 1 > max_chars:
chunks.append("\n".join(cur))
cur = cur[-overlap_lines:] if overlap_lines else []
while cur and sum(len(x) + 1 for x in cur) + len(ln) + 1 > max_chars:
cur.pop(0)
size = sum(len(x) + 1 for x in cur)
cur.append(ln); size += len(ln) + 1
if cur or not chunks:
chunks.append("\n".join(cur))
return chunks
def side_from_quote(q, n):
"""Parse the container side from the verbatim quote. Returns (raw_token, start, end) or (None, reason).
The rule extractor's patterns are tried first (they know 's(n) = x' etc.); otherwise exactly one
numeric token other than n itself must remain, else the quote is ambiguous and rejected.
Comma decimals ('3,70711') are accepted via _tokval."""
rule, _ = extract_claims(q, {"url": "quote"})
vals = {c["s"] for c in rule if c["n"] == n and c["s"] is not None and not c.get("rejected")}
toks = list(NUM_TOKEN.finditer(q))
if len(vals) == 1:
v = vals.pop()
hits = [m for m in toks if _tokval(m.group(1)) == v]
if hits:
m = hits[-1]; return m.group(1), m.start(1), m.end(1)
if len(vals) > 1:
return None, "ambiguous (several sides in quote)"
rest = [m for m in toks if not (re.fullmatch(r"\d+", m.group(1)) and int(m.group(1)) == n)]
if len(rest) != 1:
return None, f"ambiguous ({len(rest)} numeric tokens besides n)"
m = rest[0]; return m.group(1), m.start(1), m.end(1)
class LLMExtractor:
"""LLM proposes only (n, kind, quote). The side s is NEVER taken from the model: it is parsed from
the verbatim quote with word-boundary tokens, and the quote must occur verbatim in the chunk. The
canonical line then goes through extract_claims so the deterministic guards apply. Coordinate
blocks are always parsed by the rule extractor (the LLM never supplies coordinates)."""
name = "llm"
def __init__(self, client, watch=None, max_chars=12000, chunk=True, overlap_lines=3):
self.client, self.watch, self.max_chars, self.chunk, self.overlap = client, watch, max_chars, chunk, overlap_lines
def extract(self, text, meta):
notes, claims, seen = [], [], set()
chunks = chunk_lines(text, self.max_chars, self.overlap) if self.chunk else [text[:self.max_chars]]
watch = "all" if not self.watch else ",".join(map(str, sorted(self.watch)))
for ch in chunks:
try:
items = self.client.complete_json(EXTRACT_PROMPT.format(watch=watch, text=ch)).get("claims", [])
notes.append("llm chunk ok")
except Exception as e: # LLM failure degrades to the rule extractor for this chunk
notes.append(f"llm error ({type(e).__name__}); rule fallback")
c, n = extract_claims(ch, meta)
for x in c:
k = (x["n"], x["s"], x["extracted_text"])
if k not in seen: seen.add(k); claims.append(x)
notes += n; continue
for it in items:
try:
n, q = int(it["n"]), str(it.get("quote", ""))
except Exception:
notes.append("llm item malformed; dropped"); continue
if it.get("kind") != "upper_bound_packing":
notes.append(f"llm kind={it.get('kind')} n={n}; dropped"); continue
pos = ch.find(q) if q.strip() else -1
if pos < 0:
notes.append(f"llm quote not verbatim for n={n}; dropped"); continue
line_start = ch.rfind("\n", 0, pos) + 1
line_end = ch.find("\n", pos + len(q)); line_end = len(ch) if line_end < 0 else line_end
if SKIP_LINE.search(ch[line_start:line_end]):
notes.append(f"llm quote on hedged/retracted line n={n}; dropped"); continue
r = side_from_quote(q, n)
if r[0] is None:
notes.append(f"llm quote for n={n}: {r[1]}; dropped"); continue
if not n_in_quote(q, n):
notes.append(f"llm quote does not contain n={n}; dropped"); continue
raw, a, b = r
# the quote may stop mid-number ("5.9" of "5.93383346"): re-read the full token from the chunk
a, b = pos + a, pos + b
while a > 0 and (ch[a - 1].isdigit() or (ch[a - 1] in ".," and a > 1 and ch[a - 2].isdigit())):
a -= 1
while b < len(ch) and (ch[b].isdigit() or (ch[b] in ".," and b + 1 < len(ch) and ch[b + 1].isdigit())):
b += 1
raw = ch[a:b].replace(",", ".")
if raw.count(".") > 1:
notes.append(f"llm quote for n={n}: malformed number {raw!r}; dropped"); continue
c, n2 = extract_claims(f"s({n}) = {raw}", meta); notes += n2
for x in c:
x["extracted_text"] = q[:160]
x["s_uncertainty"] = display_uncertainty(ch, a, b, raw)
k = (x["n"], x["s"], x["extracted_text"])
if k not in seen: seen.add(k); claims.append(x)
blocks, _ = extract_claims("\n".join(m.group(0) for m in re.finditer(r"```packing\s*\n.*?(?:```|\Z)", text, re.S)), meta)
claims += [b for b in blocks if b["coordinates_available"]]
notes.append("llm_extract used")
return claims, notes
class RegexGuardExtractor:
"""Middle arm: the naive regex, plus the same claim-level guards the rule extractor applies
(hedge/retraction skip, area lower bound, trivial-grid upper bound). Report-level guards
(validated coordinates, staleness, printed precision) come from the agent, as for every arm."""
name = "regex_guards"
def extract(self, text, meta):
from baseline_regex import extract_naive
from tools import area_lower_bound, trivial_upper_bound
kept_lines = "\n".join(l for l in text.splitlines() if not SKIP_LINE.search(l))
raw, notes = extract_naive(kept_lines, meta)
out = []
for c in raw:
if c["s"] < area_lower_bound(c["n"]) - 1e-9:
out.append({**c, "rejected": "impossible (area bound)"}); continue
if not 1 <= c["n"] <= 1000 or c["s"] > trivial_upper_bound(c["n"]) + 1e-9:
notes.append(f"ignored n={c['n']} s={c['s']}"); continue
out.append({**c, "s_uncertainty": 0.0})
return out, notes
PLAN_PROMPT = """You plan web searches for a monitor of public progress on packing n unit squares in a square.
Standing request: {req}. Return JSON {{"queries":[str, ...]}} with at most {k} short queries. No other text."""
class LLMPlanner:
name = "llm"
def __init__(self, client, k=3):
self.client, self.k = client, k
def plan(self, prompt):
try:
q = [str(x) for x in self.client.complete_json(PLAN_PROMPT.format(req=json.dumps(prompt), k=self.k)).get("queries", [])]
q = [x.strip()[:120] for x in q if x.strip()][: self.k]
return q or FixedPlanner().plan(prompt)
except Exception:
return FixedPlanner().plan(prompt)
|