File size: 7,692 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
"""Tool interfaces: search, fetch, extract.

- MockTools: scenario-driven, with failure injection (eval harness; no network).
- NetTools: optional real fetch of public pages via an network client; search is a fixed seed-URL stub
  (no scraping of search engines; wire official source APIs later if needed).
- extract_claims: LLM-free regex extractor with plausibility guards (default).
- Optional LLM extraction is wired through interfaces.LLMExtractor + providers.json (SQ_LLM_* env).
"""

from __future__ import annotations
import json
import math
import os
import re

from bounds import area_lower_bound, trivial_upper_bound


class ToolError(Exception):
    pass


class MockTools:
    """tools spec (from scenario JSON):
    {"search": [ {url, date, source_type, source_key?}, ... ] | "error",
     "search_fail_times": int,          # transient failures before success (-1 = always)
     "pages": {url: {"text": str, "fail_times": int, "error": str}}}"""

    def __init__(self, spec):
        self.spec = spec
        self.calls = {}

    def _maybe_fail(self, key, fail_times, err):
        k = self.calls.get(key, 0)
        self.calls[key] = k + 1
        if fail_times == -1 or k < fail_times:
            raise ToolError(f"{key}: {err}")

    def search(self, query):
        if self.spec.get("search") == "error":
            raise ToolError("search: status 503")
        self._maybe_fail("search", self.spec.get("search_fail_times", 0), "rate limited (429)")
        return list(self.spec.get("search", []))

    def fetch(self, url):
        page = self.spec.get("pages", {}).get(url)
        if page is None:
            raise ToolError(f"fetch {url}: 404")
        self._maybe_fail(url, page.get("fail_times", 0), page.get("error", "timeout"))
        if "text_file" in page:  # frozen real capture
            with open(page["text_file"], encoding="utf-8") as f:
                return f.read()
        return page.get("text", "")


class NetTools:
    """Real-network tools for manual runs. Search returns configured seed URLs only."""

    def __init__(self, seeds, timeout=20.0):
        self.seeds = seeds  # list of {url, date, source_type}
        self.timeout = timeout

    def search(self, query):
        return list(self.seeds)

    def fetch(self, url):
        import httpx  # optional dependency
        try:
            r = httpx.get(url, timeout=self.timeout, follow_redirects=True,
                          headers={"User-Agent": "squares-progress-agent/0.1"})
            r.raise_for_status()
        except Exception as e:  # network errors become ToolError for uniform handling
            raise ToolError(f"fetch {url}: {e}") from e
        text = re.sub(r"<script.*?</script>|<style.*?</style>", " ", r.text, flags=re.S | re.I)
        return re.sub(r"<[^>]+>", " ", text)


# ----------------------------------------------------------------- extraction
NUM = r"([0-9]+(?:[.,][0-9]+)?)"
REL = r"\s*(?:=|≈|~|:|is|≤|<=)\s*"
PATTERNS = [
    re.compile(r"\bs\s*\(\s*(\d{1,4})\s*\)" + REL + NUM),
    re.compile(r"\bn\s*=\s*(\d{1,4})\b[^\n]{0,60}?\b(?:s|L|side(?: length)?|container side)" + REL + NUM),
    re.compile(r"\b(\d{1,4})\s+(?:unit\s+)?squares?\b[^\n]{0,60}?\b(?:side|container)[^\n0-9]{0,30}?" + NUM),
]
TABLE_HEADER = re.compile(r"^\s*\|\s*n\s*\|\s*(?:s|side|L|best s)\s*\|", re.I)
TABLE_ROW = re.compile(r"^\s*\|\s*(\d{1,4})\s*\|\s*" + NUM + r"\s*\|")
SKIP_LINE = re.compile(r"\b(retract\w*|withdrawn|was wrong|was a bug|target|goal|hope|if we|would be)\b", re.I)
SCORE = re.compile(r"score\s*[=:]?\s*(-?[0-9]+\.[0-9]+)", re.I)
BLOCK = re.compile(r"```packing\s*\n(.*?)(?:```|\Z)", re.S)


def _num(v):
    return float(v.replace(",", "."))


TRUNC_AFTER = re.compile(r"^\s*(?:…|\.\.\.)")
APPROX_BEFORE = re.compile(r"(?:≈|~|approx\w*\.?)\s*$", re.I)


def display_uncertainty(line, start, end, raw):
    """Absolute uncertainty (container-side units) implied by how a number is printed.
    '2.707...' / '2.707…' (truncated) or '≈ 2.707' (rounded) -> 10**-decimals; otherwise 0."""
    dec = len(raw.replace(",", ".").split(".")[1]) if re.search(r"[.,]", raw) else 0
    if TRUNC_AFTER.match(line[end:]) or APPROX_BEFORE.search(line[max(0, start - 16):start]):
        return 10.0 ** -dec
    return 0.0


def extract_claims(text, meta):
    """Return (claims, notes). Each claim: n, s, coordinates_available, packing, score,
    source_url, source_type, source_key, date, extracted_text."""
    claims, notes = [], []
    base = {"source_url": meta["url"], "source_type": meta.get("source_type", "unknown"),
            "source_key": meta.get("source_key", meta["url"]), "date": meta.get("date", "")}

    def add(n, s, quote, packing=None, score=None, unc=0.0):
        if not 1 <= n <= 1000:
            notes.append(f"ignored n={n} (out of range)"); return
        if s is not None:
            if s < area_lower_bound(n) - 1e-9:
                notes.append(f"rejected n={n} s={s}: below area bound sqrt(n)={area_lower_bound(n):.6f}")
                claims.append({**base, "n": n, "s": s, "rejected": "impossible (area bound)",
                               "coordinates_available": packing is not None, "packing": packing,
                               "score": score, "extracted_text": quote[:160]})
                return
            if s > trivial_upper_bound(n) + 1e-9:
                notes.append(f"ignored n={n} s={s}: worse than trivial grid (likely noise)"); return
        claims.append({**base, "n": n, "s": s, "coordinates_available": packing is not None,
                       "packing": packing, "score": score, "s_uncertainty": unc, "extracted_text": quote[:160]})

    # 1) structured packing blocks (coordinates)
    for m in BLOCK.finditer(text):
        raw = m.group(1)
        try:
            d = json.loads(raw)
            n, side = int(d["n"]), d["side"]
            s = float(eval_side(side))
            add(n, s, raw[:160], packing=d)
        except Exception as e:  # truncated/malformed block -> partial observation
            notes.append(f"unparseable packing block ({type(e).__name__})")
    text_wo_blocks = BLOCK.sub(" ", text)

    # 2) line-level claims
    in_table = False
    seen = set()
    for line in text_wo_blocks.splitlines():
        if TABLE_HEADER.match(line):
            in_table = True; continue
        if in_table:
            m = TABLE_ROW.match(line)
            if m:
                key = (int(m.group(1)), _num(m.group(2)))
                if key not in seen:
                    seen.add(key); add(key[0], key[1], line.strip())
                continue
            if not line.strip().startswith("|"):
                in_table = False
        if SKIP_LINE.search(line):
            if any(p.search(line) for p in PATTERNS):
                notes.append(f"skipped hedged/retracted line: {line.strip()[:80]}")
            continue
        sc = SCORE.search(line)
        for p in PATTERNS:
            for m in p.finditer(line):
                key = (int(m.group(1)), _num(m.group(2)))
                if key in seen:
                    continue
                seen.add(key)
                add(key[0], key[1], line.strip(), score=float(sc.group(1)) if sc else None,
                    unc=display_uncertainty(line, m.start(2), m.end(2), m.group(2)))
    return claims, notes


def eval_side(side):
    """side may be a number or an exact string like '7/5'. Returns Fraction or float."""
    from fractions import Fraction
    if isinstance(side, str) and re.fullmatch(r"\s*-?\d+(?:/\d+)?\s*", side):
        return Fraction(side.strip())
    return float(side)


# LLM extraction lives in interfaces.LLMExtractor (same interface as the rule extractor).