#!/usr/bin/env python3
"""Ask Claude for the questions buyers type into AI assistants about a category, then check
every question with plain code before a person sees it.

    pip install anthropic
    export ANTHROPIC_API_KEY=...            # LOOP_ANTHROPIC_API_KEY works too
    python3 buyer_questions.py              # live: one Claude call, then the checks
    python3 buyer_questions.py --plant      # the same, plus seven planted faults the checks must stop
    python3 buyer_questions.py --replay buyer_questions-answers.json [--plant]   # no key, no cost

The AI writes. The checks are ordinary Python (no second AI call): fields, shape, stage, brand,
duplicate, evidence. Questions that pass go to approved-questions.csv for a person to approve.
Everything the script writes goes to --out (default: the folder this file is in), and each run
overwrites the answers file, approved-questions.csv and run-view.html there.
The product page below is invented; example.com addresses only.
"""
import argparse
import csv
import datetime
import hashlib
import html
import json
import os
import pathlib
import platform
import re
import sys

MODEL = "claude-haiku-5-5"          # small and fast is enough: the checks do the policing
PRICE_IN, PRICE_OUT = 0.10, 0.50    # USD per million tokens, Anthropic pricing page, read 2026-11-03
COST_STOP = 2.00                    # refuse a run whose estimated cost passes this (USD)
MAX_TOKENS = 8000
GUIDE_PATH = "/guides/write-buyer-questions-for-ai-search-with-ai/"   # the run view's canonical

# ---- SETTINGS: change these four for your own product -------------------------------------
PRODUCT_NAME = "Larkspur Ledger"
BRAND_TERMS = ["Larkspur Ledger", "Larkspur", "larkspur-ledger.example.com"]
CATEGORY = "accounts payable automation software"
PAGE_TEXT = """
Larkspur Ledger is accounts payable automation software for finance teams at mid-sized companies. It reads supplier invoices from email and PDF, pulls out the supplier, amount, date and line items, and matches each invoice to its purchase order and goods receipt. Invoices that do not match are held and sent to the person who raised the purchase order. Approvers get a daily digest and can approve from their phone. Larkspur Ledger flags a possible duplicate invoice before it is paid. Approved invoices are posted to the general ledger and scheduled in the next payment run. Every approval is kept in an audit trail with the name of the approver and the time. The software connects to common accounting systems through a published API, and a finance team can set its own approval limits by department.
"""
HOW_MANY = 10
# ---- END SETTINGS -----------------------------------------------------------------------------

STAGES = ("category", "comparison", "implementation")
HERE = pathlib.Path(__file__).resolve().parent
ANSWERS_NAME = pathlib.Path(__file__).stem + "-answers.json"

SYSTEM = ("You help a marketing team find the questions buyers type into AI assistants such as ChatGPT, "
          "Claude, Gemini and Copilot. Reply with one JSON object and nothing else.")


def build_prompt(page):
    return (
        f"A company sells {CATEGORY}. Its product page is between the <page> tags.\n\n"
        f"<page>\n{page}\n</page>\n\n"
        f"Write exactly {HOW_MANY} questions that a buyer who has never heard of this product would type "
        f"into an AI assistant while looking for {CATEGORY}. Rules:\n"
        "- Never use the product name, the company name or a web address in a question. Buyers who "
        "know the name are not the ones we are looking for.\n"
        "- Each question is a full sentence that ends with a question mark.\n"
        "- Give each question a stage: \"category\" (who or what is out there), \"comparison\" "
        "(which option or approach is better) or \"implementation\" (how do I do this).\n"
        "- Give each question evidence: a short phrase of 5 to 20 words copied exactly, character for "
        "character, from the page, showing which capability the question is about.\n\n"
        "Reply as JSON: {\"questions\": [{\"question\": \"...\", \"stage\": \"...\", \"evidence\": \"...\"}]}"
    )


# ---- asking Claude ----------------------------------------------------------------------------
def api_key():
    return os.environ.get("LOOP_ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_API_KEY")


def ask_claude(page):
    """One call. Returns the record that is saved to the answers file."""
    import anthropic
    key = api_key()
    if not key:
        sys.exit("No key: set ANTHROPIC_API_KEY (or LOOP_ANTHROPIC_API_KEY), or use --replay <file>.")
    prompt = build_prompt(page)
    estimate = (len(SYSTEM + prompt) / 3 * PRICE_IN + MAX_TOKENS * PRICE_OUT) / 1e6
    if estimate > COST_STOP:
        sys.exit(f"Stopped: the estimated cost ${estimate:.2f} passes COST_STOP ${COST_STOP:.2f}.")
    # an empty ANTHROPIC_BASE_URL (some shells export one) would break the SDK's default
    client = anthropic.Anthropic(api_key=key, base_url=os.environ.get("ANTHROPIC_BASE_URL") or "https://api.anthropic.com")
    args = dict(model=MODEL, max_tokens=MAX_TOKENS, system=SYSTEM,
                messages=[{"role": "user", "content": prompt}])
    temperature = 0
    try:
        msg = client.messages.create(temperature=0, **args)
    except anthropic.BadRequestError as e:
        if "temperature" not in str(e):
            raise
        temperature = None      # this model refuses the setting; run without it and say so in the record
        msg = client.messages.create(**args)
    return {"model": MODEL, "date": datetime.date.today().isoformat(), "temperature": temperature,
            "system": SYSTEM, "prompt": prompt,
            "raw_answer": "".join(b.text for b in msg.content if b.type == "text"),
            "input_tokens": msg.usage.input_tokens, "output_tokens": msg.usage.output_tokens,
            "python": platform.python_version(), "anthropic": anthropic.__version__,
            "page_sha256": hashlib.sha256(page.encode("utf-8")).hexdigest()}


def parse_answer(raw):
    """(items, problem). An answer that does not parse is reported, never skipped."""
    text = raw.strip()
    text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text)
    try:
        data = json.loads(text[text.index("{"): text.rindex("}") + 1])
    except ValueError as e:
        return [], f"the answer is not JSON ({e.__class__.__name__})"
    qs = data.get("questions") if isinstance(data, dict) else None
    if not isinstance(qs, list) or not qs:
        return [], 'the JSON has no non-empty "questions" list'
    return [q if isinstance(q, dict) else {"_not_an_object": str(q)[:60]} for q in qs], ""


# ---- the checks: plain code ----------------------------------------------------------------------
def tokens(s):
    return re.findall(r"[a-z0-9]+", s.lower())


def clean_terms(terms):
    """Brand terms as token lists. Blank and None entries are dropped (an empty term would match
    every question); returns (term token lists, how many were dropped)."""
    good = [t for t in terms if isinstance(t, str) and tokens(t)]
    return [tokens(t) for t in good], len(terms) - len(good)


def names_brand(question, term_tokens):
    """The first brand term the question names, or ''. Matches whole words ('Larkspur's' counts),
    and a multi-word term written without spaces ('LarkspurLedger')."""
    qt = tokens(question)
    joined = "".join(qt)
    for t in term_tokens:
        n = len(t)
        if any(qt[i:i + n] == t for i in range(len(qt) - n + 1)) or (n > 1 and "".join(t) in joined):
            return " ".join(t)
    return ""


def check_item(item, n, earlier, page_tokens, term_tokens):
    """[(check, reason)] for one item. Empty means the item passed every check."""
    q, stage, ev = (item.get(k) for k in ("question", "stage", "evidence"))
    empty = [k for k, v in (("question", q), ("stage", stage), ("evidence", ev)) if not (isinstance(v, str) and v.strip())]
    if empty:
        return [("fields", "missing or empty: " + ", ".join(empty))]
    out = []
    words = len(q.split())
    if not q.strip().endswith("?") or not 5 <= words <= 30:
        out.append(("shape", f"a question of 5 to 30 words that ends with ?; this has {words} words"))
    if stage not in STAGES:
        out.append(("stage", f'"{stage}" is not one of {", ".join(STAGES)}'))
    hit = names_brand(q, term_tokens)
    if hit:
        out.append(("brand", f'an unbranded question names the brand ("{hit}")'))
    key = " ".join(tokens(q))
    if key in earlier:
        out.append(("duplicate", f"same question as item {earlier[key]}"))
    et = tokens(ev)
    if len(et) < 4 or f" {' '.join(et)} " not in page_tokens:
        out.append(("evidence", "the evidence is not a phrase on the product page"))
    return out


CHECKS = ("fields", "shape", "stage", "brand", "duplicate", "evidence")


def planted(items, page):
    """Seven faults added after Claude's answers: one for each of the six checks, plus a second spelling of
    the brand ("LarkspurLedger"). Each is built to trip only its own check."""
    sentences = re.split(r"(?<=[.!?])\s", page.strip())
    first = next((s for s in sentences if len(tokens(s)) >= 8), sentences[0])   # a quote long enough to pass the evidence check
    ok ={"stage": "category", "evidence": first}
    joined = "".join(PRODUCT_NAME.split())
    return [
        ("brand", {**ok, "question": f"Is {PRODUCT_NAME} worth it for a small finance team?"}),
        ("brand", {**ok, "question": f"How does {joined} handle three-way invoice matching?"}),
        ("duplicate", dict(items[0]) if items and isinstance(items[0].get("question"), str) else {**ok, "question": "x"}),
        ("evidence", {"question": f"Which {CATEGORY} features do buyers regret not asking about?", "stage": "category",
                      "evidence": "Our patented quantum engine pays every invoice before it exists."}),
        ("stage", {**ok, "question": f"What does {CATEGORY} usually cost per month for a small team?", "stage": "pricing"}),
        ("shape", {**ok, "question": "invoice approval software"}),
        ("fields", {"question": "What is the difference between two-way and three-way invoice matching?",
                    "stage": "implementation", "evidence": ""}),
    ]


# ---- run view: a page the script writes, which plays the run one item at a time --------------------
RUN_VIEW = """<!doctype html>
<html lang="en"><head><meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<link rel="canonical" href="__URL__" />
<title>Run view: buyer questions for AI assistants</title>
<style>
html,body{margin:0;height:100%;background:#0F172A}
body{font-family:Inter,system-ui,-apple-system,"Segoe UI",sans-serif}
.stage{container-type:size;width:min(100vw,56.25vh);height:min(100vh,177.78vw);margin:0 auto;background:#fff;color:#0F172A;position:relative;overflow:hidden;font-size:2.8cqw;line-height:1.35}
.head{position:absolute;left:6cqw;right:6cqw;top:5cqw}
.kicker{font-family:"Space Grotesk",Inter,system-ui,sans-serif;font-size:2.8cqw;letter-spacing:.12em;text-transform:uppercase;font-weight:700;color:#2E63C6}
.input{color:#64748B;margin-top:1.2cqw}
.step{margin:0 6cqw 4cqw;padding-top:1cqw}
.js .step{position:absolute;left:0;right:0;top:19cqw;bottom:6cqw;margin:0 6cqw;display:none;flex-direction:column;gap:3.2cqw}
.js .step.on{display:flex;animation:in .35s ease-out}
@keyframes in{from{opacity:0;transform:translateY(2cqw)}to{opacity:1;transform:none}}
.meta{color:#64748B;font-weight:600}
.q{font-family:"Space Grotesk",Inter,system-ui,sans-serif;font-size:4.2cqw;line-height:1.25;font-weight:700}
.tags span{display:inline-block;border:.3cqw solid #E2E8F0;border-radius:10px;padding:.6cqw 2cqw;margin-right:1.6cqw;color:#64748B;font-weight:600}
.tags span.pl{border-color:#0F172A;color:#0F172A}
.ev{color:#64748B;border-left:.8cqw solid #E2E8F0;padding-left:2.4cqw}
.verdict{border-radius:10px;padding:3cqw;font-size:3.4cqw;font-weight:700;border:.4cqw solid #E2E8F0;color:#64748B}
.verdict small{display:block;font-size:2.8cqw;font-weight:500;margin-top:1cqw}
.verdict.stop{background:#2E63C6;border-color:#2E63C6;color:#fff}
.sum{font-family:"Space Grotesk",Inter,system-ui,sans-serif;font-size:4.6cqw;font-weight:700}
.big{font-size:9cqw;font-weight:700;color:#2E63C6;line-height:1}
.rows div{border-bottom:.3cqw solid #E2E8F0;padding:1.6cqw 0;display:flex;justify-content:space-between}
html:not(.js) .stage{container-type:inline-size;height:auto;overflow:visible;padding:5cqw 0 6cqw}
html:not(.js) .head{position:static;margin:0 6cqw 4cqw}
@media (prefers-reduced-motion: reduce){.js .step.on{animation:none}}
</style></head>
<body data-dur="__DUR__">
<div class="stage">
<div class="head"><div class="kicker">Run view &middot; buyer questions</div>
<div class="input">Input: __INPUT__</div></div>
__STEPS__
</div>
<script>
(function(){
var steps=[].slice.call(document.querySelectorAll('.step'));
if(window.matchMedia&&matchMedia('(prefers-reduced-motion: reduce)').matches)return;
document.documentElement.classList.add('js');
var dur=+document.body.getAttribute('data-dur')||2000,timer=null;
function show(i){steps.forEach(function(s,k){s.classList.toggle('on',k===i);});}
function fromHash(){var m=/^#step-(\\d+)$/.exec(location.hash);
if(m&&+m[1]>=1&&+m[1]<steps.length)return +m[1]-1;
return location.hash==='#step-summary'?steps.length-1:-1;}
function play(i){show(i);if(i<steps.length-1)timer=setTimeout(function(){play(i+1);},dur);}
function start(){clearTimeout(timer);var h=fromHash();if(h>=0)show(h);else play(0);}
window.addEventListener('hashchange',start);start();
})();
</script></body></html>
"""


def write_run_view(out, rows, summary_rows, page_chars, planted_line):
    e = html.escape
    n = len(rows)
    steps = []
    for r in rows:
        stopped = bool(r["reasons"])
        tags = f'<span>{e(str(r["item"].get("stage", "")))}</span>' + ('<span class="pl">planted fault</span>' if r["planted"] else "")
        if stopped:
            why = "; ".join(f"{c}: {m}" for c, m in r["reasons"])
            verdict = f'<div class="verdict stop">STOPPED<small>{e(why)}</small></div>'
        else:
            verdict = '<div class="verdict">PASSED<small>all six checks</small></div>'
        steps.append(
            f'<section class="step" id="step-{r["n"]}"><div class="meta">Item {r["n"]} of {n} &middot; {"planted after Claude&rsquo;s answer" if r["planted"] else "Claude returned"}</div>'
            f'<div class="q">{e(str(r["item"].get("question", "")))}</div><div class="tags">{tags}</div>'
            f'<div class="ev">Evidence quoted from the page: {e(str(r["item"].get("evidence", "")))}</div>{verdict}</section>')
    cells = "".join(f"<div><span>{e(k)}</span><b>{e(str(v))}</b></div>" for k, v in summary_rows)
    steps.append('<section class="step" id="step-summary"><div class="sum">Summary</div>'
                 f'<div class="rows">{cells}</div><div class="meta">{e(planted_line)}</div></section>')
    dur = int(min(2600, max(1500, 27000 / n)))
    page = (RUN_VIEW.replace("__URL__", GUIDE_PATH).replace("__DUR__", str(dur))
            .replace("__INPUT__", e(f"product page for {PRODUCT_NAME}, {page_chars} characters"))
            .replace("__STEPS__", "\n".join(steps)))
    (out / "run-view.html").write_text(page, encoding="utf-8")


# ---- main ---------------------------------------------------------------------------------------------
def main():
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("--plant", action="store_true", help="add seven planted faults the checks must stop")
    ap.add_argument("--replay", metavar="FILE", help="re-run the checks on a saved answers file: no key, no cost")
    ap.add_argument("--out", metavar="FOLDER", help="where files are written (default: this script's folder)")
    a = ap.parse_args()
    out = pathlib.Path(a.out) if a.out else HERE
    out.mkdir(parents=True, exist_ok=True)

    page = PAGE_TEXT.strip()
    term_tokens, dropped = clean_terms(BRAND_TERMS)
    if not term_tokens:
        sys.exit("BRAND_TERMS has no usable term, so the brand check cannot run. Stopped.")

    if a.replay:
        rec = json.loads(pathlib.Path(a.replay).read_text(encoding="utf-8"))
        if rec.get("page_sha256") != hashlib.sha256(page.encode("utf-8")).hexdigest():
            sys.exit("These answers were recorded for a different product page. Stopped.")
    else:
        rec = ask_claude(page)
        (out / ANSWERS_NAME).write_text(json.dumps(rec, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")

    cost = (rec["input_tokens"] * PRICE_IN + rec["output_tokens"] * PRICE_OUT) / 1e6
    print("Buyer questions for AI assistants: the checks")
    print(f"Model {rec['model']}, answers recorded {rec['date']}, temperature "
          f"{'0' if rec.get('temperature') == 0 else 'not set (the model refuses it)'}")
    print(f"Software (as recorded): Python {rec.get('python', 'not recorded')}, anthropic {rec.get('anthropic', 'not recorded')}")
    print(f"Product page: {PRODUCT_NAME}, {len(page)} characters, SHA-256 {rec['page_sha256'][:12]}")
    print(f"Category: {CATEGORY}")
    print(f"Brand terms: {len(term_tokens)}" + (f" ({dropped} blank dropped)" if dropped else ""))
    print(f"Tokens: {rec['input_tokens']} in, {rec['output_tokens']} out. Cost ${cost:.6f} "
          f"(at ${PRICE_IN:.2f} and ${PRICE_OUT:.2f} per million tokens)\n")

    items, problem = parse_answer(rec["raw_answer"])
    if problem:
        print(f"ANSWER NOT USABLE: {problem}. Nothing was approved.")
        return 1
    entries = [(None, it) for it in items] + (planted(items, page) if a.plant else [])

    page_tokens = f" {' '.join(tokens(page))} "
    rows, earlier = [], {}
    for n, (target, it) in enumerate(entries, 1):
        reasons = check_item(it, n, earlier, page_tokens, term_tokens)
        q = it.get("question")
        if isinstance(q, str) and " ".join(tokens(q)) not in earlier:
            earlier[" ".join(tokens(q))] = n
        rows.append({"n": n, "item": it, "planted": target, "reasons": reasons})
        tag = f"[{it.get('stage', '?')}]"
        print(f"{n:>3} {'STOP' if reasons else 'PASS'}  {tag:<18}{'(planted) ' if target else ''}{it.get('question', it)}")
        for c, m in reasons:
            print(f"      -> {c}: {m}")

    stopped = [r for r in rows if r["reasons"]]
    approved = [r for r in rows if not r["reasons"]]
    print(f"\nResult: {len(approved)} approved, {len(stopped)} stopped")
    ok = not stopped
    plant_line = ""
    if a.plant:
        caught = [r for r in rows if r["planted"] and r["planted"] in {c for c, _ in r["reasons"]} and len(r["reasons"]) == 1]
        n_pl = sum(1 for r in rows if r["planted"])
        real_stopped = sum(1 for r in stopped if not r["planted"])
        plant_line = f"Planted faults: {n_pl} planted, {len(caught)} stopped by the check each was built for"
        print(plant_line)
        print(f"Questions Claude wrote that were stopped: {real_stopped}")
        ok = len(caught) == n_pl and real_stopped == 0
    print("Next: a person reads approved-questions.csv and approves it." if ok or a.plant else
          "Next: a person reads the stopped questions above; nothing was approved.")

    with open(out / "approved-questions.csv", "w", newline="", encoding="utf-8") as f:
        w = csv.writer(f)
        w.writerow(["question", "stage", "evidence"])
        for r in approved:
            w.writerow([r["item"]["question"], r["item"]["stage"], r["item"]["evidence"]])
    by_check = {c: sum(1 for r in stopped for k, _ in r["reasons"] if k == c) for c in CHECKS}
    summary = [("Questions shown", len(rows)), ("Approved", len(approved)), ("Stopped", len(stopped))]
    summary += [(f"Stopped by {c}", v) for c, v in by_check.items() if v]
    write_run_view(out, rows, summary, len(page), plant_line)
    return 0 if ok else 1


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