#!/usr/bin/env python3
"""
Receipts - does the text have anything behind it?

Takes a piece of text, pulls out its checkable factual claims, looks each one up
against a source (Wikipedia by default), and marks it:

  SUPPORTED     the source states it
  CONTRADICTED  the source says otherwise
  UNSUPPORTED   no source found that addresses it

Nothing runs on a closed API by default: the language model is an open-weight
one (gpt-oss-20b) served keyless by pollinations, and the source is Wikipedia's
open API. Point --endpoint at any OpenAI-compatible server (your own Ollama,
llama.cpp, vLLM, an HF router) and it uses that instead.

Usage:
  python3 receipts.py "text to check"
  cat draft.txt | python3 receipts.py
  python3 receipts.py --file draft.txt --json
  python3 receipts.py --file draft.txt --endpoint http://localhost:11434/v1/chat/completions --model gemma3:4b
"""
import argparse
import json
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

DEFAULT_ENDPOINT = "https://text.pollinations.ai/openai"
DEFAULT_MODEL = "openai"  # pollinations alias -> gpt-oss-20b (open weights)
UA = "Receipts/0.1 (https://dev.to/fray; fray-2@ilands.app)"
RETRIES = 3
PAUSE = 3.0  # polite spacing between calls to a free anonymous endpoint

STOP = {
    "the", "a", "an", "is", "are", "was", "were", "of", "in", "on", "at", "to",
    "and", "or", "that", "this", "it", "its", "as", "by", "for", "with", "from",
    "has", "have", "had", "be", "been", "than", "more", "also", "which", "who",
    "his", "her", "their", "they", "he", "she", "but", "not", "into", "over",
    "after", "before", "than", "one", "two", "first", "still", "only", "most",
}


def http_json(url, data=None, headers=None, timeout=150):
    body = json.dumps(data).encode() if data is not None else None
    req = urllib.request.Request(url, data=body, headers=headers or {})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read().decode("utf-8", "replace"))


def chat(messages, endpoint, model, api_key=None, retries=None, max_tokens=700):
    """One completion from any OpenAI-compatible endpoint."""
    retries = retries or RETRIES
    url = endpoint.rstrip("/")
    if not url.endswith("/chat/completions"):
        url += "/chat/completions"
    headers = {"Content-Type": "application/json", "User-Agent": UA}
    if api_key:
        headers["Authorization"] = "Bearer " + api_key
    payload = {
        "model": model,
        "messages": messages,
        "temperature": 0.1,
        "max_tokens": max_tokens,
        "reasoning_effort": "low",
        "response_format": {"type": "json_object"},
    }
    last = None
    for i in range(retries):
        try:
            out = http_json(url, payload, headers)
            return out["choices"][0]["message"]["content"]
        except urllib.error.HTTPError as e:
            last = e                      # 402/429 = anonymous rate limit
            time.sleep(8 + 8 * i)
        except Exception as e:            # noqa: BLE001
            last = e
            time.sleep(2 + 3 * i)
    raise RuntimeError("model call failed: %s" % last)


def loads_loose(s):
    """Models wrap JSON in prose or fences. Dig it out."""
    if not isinstance(s, str):
        return s
    s = re.sub(r"^```(?:json)?|```$", "", s.strip(), flags=re.M).strip()
    try:
        return json.loads(s)
    except Exception:  # noqa: BLE001
        pass
    m = re.search(r"\{.*\}|\[.*\]", s, re.S)
    if m:
        try:
            return json.loads(m.group(0))
        except Exception:  # noqa: BLE001
            pass
    return None


EXTRACT_SYS = (
    "You find the checkable factual claims in a piece of text. A checkable claim "
    "is a statement about the world that evidence could settle: a date, number, "
    "name, place, event, attribution, or causal assertion. Drop opinions, tastes, "
    "advice, predictions, and rhetorical questions. Rules for each claim:\n"
    "- Make it stand alone. Replace every pronoun with the actual name "
    "('She won...' becomes 'Marie Curie won...').\n"
    "- Keep the text's own meaning; do not add facts and do not flip it.\n"
    "- One fact per claim.\n"
    "For each claim give a short Wikipedia search query: the single most likely "
    "article title plus one or two discriminating words. "
    'Return JSON: {"claims":[{"claim":"...","query":"..."}]}. '
    'If nothing is checkable, return {"claims":[]}.'
)

VERDICT_SYS = (
    "You are a careful fact-checker. You are given one CLAIM and numbered evidence "
    "passages, each with a title. Choose exactly one:\n"
    "SUPPORTED - a passage states the claim, or clearly entails it.\n"
    "CONTRADICTED - a passage conflicts with the claim.\n"
    "UNSUPPORTED - no passage addresses the claim.\n"
    "Judge only from the passages. Never use outside knowledge. Numbers and dates "
    "must match to the precision the claim states: if a passage gives a more "
    "precise figure that rounds to the claim's, that is SUPPORTED; CONTRADICTED "
    "only when a passage gives a figure that differs beyond the claim's own "
    "precision (e.g. claim 1910 vs passage 1911 is CONTRADICTED, claim 8,848 vs "
    "passage 8,848.86 is SUPPORTED). Do not call something SUPPORTED because it "
    "merely sounds plausible. "
    'Return JSON: {"verdict":"SUPPORTED|CONTRADICTED|UNSUPPORTED",'
    '"why":"<=22 words","best_source":<passage index or -1>}'
)


def extract_claims(text, endpoint, model, api_key, cap):
    raw = chat([{"role": "system", "content": EXTRACT_SYS},
                {"role": "user", "content": text}],
               endpoint, model, api_key)
    data = loads_loose(raw) or {}
    out = []
    for c in data.get("claims") or []:
        if isinstance(c, str) and c.strip():
            out.append({"claim": c.strip(), "query": ""})
        elif isinstance(c, dict) and str(c.get("claim", "")).strip():
            out.append({"claim": str(c["claim"]).strip(),
                        "query": str(c.get("query", "")).strip()})
    return out[:cap]


# ---------- retrieval: Wikipedia ----------

def search_wikipedia(query, limit=2):
    q = urllib.parse.urlencode({"action": "query", "list": "search",
                                "srsearch": query, "format": "json",
                                "srlimit": limit})
    out = http_json("https://en.wikipedia.org/w/api.php?" + q,
                    headers={"User-Agent": UA})
    hits = []
    for h in out.get("query", {}).get("search", []):
        snippet = re.sub(r"<[^>]+>", "", h.get("snippet", ""))
        hits.append({"title": h["title"], "snippet": snippet})
    return hits


def fetch_page_text(title, chars=6000):
    q = urllib.parse.urlencode({"action": "query", "prop": "extracts",
                                "explaintext": 1, "redirects": 1,
                                "titles": title, "format": "json"})
    out = http_json("https://en.wikipedia.org/w/api.php?" + q,
                    headers={"User-Agent": UA})
    for page in out.get("query", {}).get("pages", {}).values():
        if page.get("extract"):
            return page.get("title", title), page["extract"][:chars]
    return title, ""


def keywords(text, n=12):
    words = re.findall(r"[A-Za-z0-9'\-]+", text)
    keep = [w for w in words if w.lower() not in STOP and len(w) > 2]
    seen, out = set(), []
    for w in keep:
        if w.lower() not in seen:
            seen.add(w.lower())
            out.append(w)
    return out[:n]


def pick_sentences(text, claim, cap=3):
    """Sentences of the page that share vocabulary with the claim."""
    kw = {w.lower() for w in keywords(claim, 14)}
    if not kw:
        return ""
    sents = re.split(r"(?<=[.!?])\s+", text)
    scored = []
    for s in sents:
        low = s.lower()
        hits = sum(1 for w in kw if w in low)
        if hits:
            scored.append((hits, len(s), s))
    scored.sort(key=lambda x: (-x[0], x[1]))
    chosen = [s for _, _, s in scored[:cap]]
    return " ".join(chosen)


def wiki_url(title):
    return "https://en.wikipedia.org/wiki/" + urllib.parse.quote(title.replace(" ", "_"))


def gather_evidence(query, claim):
    """Search Wikipedia, then pull the sentences of the top pages that match."""
    hits = search_wikipedia(query or claim)
    evidence = []
    for h in hits:
        try:
            real, full = fetch_page_text(h["title"])
        except Exception:  # noqa: BLE001
            continue
        bits = []
        if h.get("snippet"):
            bits.append(h["snippet"])
        picked = pick_sentences(full, claim)
        if picked:
            bits.append(picked)
        text = " ".join(bits)[:1100]
        if text:
            evidence.append({"title": real, "text": text})
        time.sleep(0.2)
    return evidence


def check_claim(claim, query, endpoint, model, api_key):
    evidence = gather_evidence(query, claim)
    if not evidence:
        return {"verdict": "UNSUPPORTED", "why": "no source found", "source": None}
    passages = "\n\n".join("[%d] %s\n%s" % (i, e["title"], e["text"])
                           for i, e in enumerate(evidence))
    raw = chat([{"role": "system", "content": VERDICT_SYS},
                {"role": "user",
                 "content": "CLAIM: %s\n\nEVIDENCE:\n%s" % (claim, passages)}],
               endpoint, model, api_key)
    data = loads_loose(raw) or {}
    verdict = str(data.get("verdict", "UNSUPPORTED")).upper()
    if verdict not in ("SUPPORTED", "CONTRADICTED", "UNSUPPORTED"):
        verdict = "UNSUPPORTED"
    src = None
    idx = data.get("best_source", -1)
    if isinstance(idx, int) and 0 <= idx < len(evidence):
        src = wiki_url(evidence[idx]["title"])
    return {"verdict": verdict, "why": str(data.get("why", ""))[:220], "source": src}


MARK = {"SUPPORTED": "[+]", "CONTRADICTED": "[x]", "UNSUPPORTED": "[?]"}


def render(results):
    n = len(results)
    ok = sum(1 for r in results if r["verdict"] == "SUPPORTED")
    bad = sum(1 for r in results if r["verdict"] == "CONTRADICTED")
    flo = sum(1 for r in results if r["verdict"] == "UNSUPPORTED")
    lines = ["# Receipts",
             "%d claim%s  |  %d with a receipt  |  %d contradicted  |  %d floating"
             % (n, "" if n == 1 else "s", ok, bad, flo), ""]
    for i, r in enumerate(results, 1):
        lines.append("%d. %s %s" % (i, MARK[r["verdict"]], r["claim"]))
        tail = r["verdict"].title()
        if r["source"]:
            tail += "  " + r["source"]
        if r.get("why"):
            tail += "  (" + r["why"] + ")"
        lines.append("   " + tail)
    return "\n".join(lines)


def main():
    ap = argparse.ArgumentParser(description="Do the claims in this text have receipts?")
    ap.add_argument("text", nargs="?", help="text to check (or use --file / stdin)")
    ap.add_argument("--file", help="read text from a file")
    ap.add_argument("--endpoint", default=DEFAULT_ENDPOINT,
                    help="OpenAI-compatible chat-completions URL")
    ap.add_argument("--model", default=DEFAULT_MODEL)
    ap.add_argument("--api-key", default=None)
    ap.add_argument("--max-claims", type=int, default=12)
    ap.add_argument("--retries", type=int, default=3)
    ap.add_argument("--json", action="store_true", help="machine-readable output")
    args = ap.parse_args()

    global RETRIES
    RETRIES = args.retries

    if args.file:
        text = open(args.file, encoding="utf-8").read()
    elif args.text:
        text = args.text
    else:
        text = sys.stdin.read()
    text = text.strip()
    if not text:
        print("nothing to check", file=sys.stderr)
        return 2

    print("reading claims...", file=sys.stderr)
    claims = extract_claims(text, args.endpoint, args.model, args.api_key,
                            args.max_claims)
    results = []
    for c in claims:
        time.sleep(PAUSE)
        r = check_claim(c["claim"], c["query"], args.endpoint, args.model,
                        args.api_key)
        r["claim"] = c["claim"]
        results.append(r)
        print("  %-6s %s" % (r["verdict"], c["claim"][:70]), file=sys.stderr)

    if args.json:
        print(json.dumps({"claims": results}, indent=2))
    else:
        print(render(results))
        print("\n%d of %d claims checked out." % (
            sum(1 for r in results if r["verdict"] == "SUPPORTED"), len(results)))
    return 0


if __name__ == "__main__":
    sys.exit(main())
