ramco-brain / query /ask.py
VizuaraAI's picture
deploy PO brain
519cffb verified
Raw
History Blame Contribute Delete
27.7 kB
"""Step 4 — the conversational answer engine.
question -> (target on the spine, intent) -> grounded facts -> 2-4 sentences of English
+ the verifiable chain (screen -> service/API -> SP -> tables) + source citations.
The composer is deterministic and fully grounded. query.llm.polish() optionally smooths the
prose with Gemini using ONLY these facts; it never changes the grounding or the chain.
"""
from __future__ import annotations
import json
import re
import urllib.parse
import config
from compiler.common import eid
from query.retriever import get_brain
from query.docstore import get_docstore
from query import llm
# ---------------------------------------------------------------- loading
class V2Brain:
def __init__(self):
self.spine = json.loads((config.GRAPH_DIR / "spine.json").read_text())["activities"]
self.glosses = json.loads((config.GRAPH_DIR / "glosses.json").read_text())
self.brain = get_brain()
self.docs = get_docstore()
self.xref = {}
if config.JOURNEY_XREF_JSON.exists():
self.xref = json.loads(config.JOURNEY_XREF_JSON.read_text())
# quick lookup: lowercase activity desc/name -> activity name
self.activity_by_word = {}
for name, a in self.spine.items():
for w in re.findall(r"[a-z]+", (a.get("desc", "") + " " + name).lower()):
self.activity_by_word.setdefault(w, name)
def gloss(self, node_id):
return self.glosses.get(node_id, {})
_V2 = None
def v2() -> V2Brain:
global _V2
if _V2 is None:
_V2 = V2Brain()
return _V2
# ---------------------------------------------------------------- intent
VERB_ACT = {"creat": "PoCrt", "raise": "PoCrt", "amend": "PoAmnd", "approv": "PoApp",
"edit": "PoEdt", "hold": "PoHold", "copy": "PoCopy", "view": "PoViewDtls",
"display": "PoViewDtls", "schedul": "PoScl"}
def classify(q: str) -> str:
s = q.lower()
if re.search(r"\bcompare\b|difference between|\bvs\.?\b|versus", s):
return "COMPARE"
# runtime/journey status — check before the transactional rule ("live ... today")
if re.search(r"implemented|runtime|chatbot|journey|in the bot|live (today|now|in)", s):
return "META_JOURNEY"
# transactional / live-data questions the brain (a metadata index) cannot answer
if (re.search(r"\b(last|this|next)\s+(month|week|year|quarter|day)\b|yesterday|today"
r"|total spend|how much did|\bactual\b", s)
or re.search(r"how many (pos|purchase orders|orders|transactions)\b", s)):
return "META_LIMITS"
if re.search(r"can('| n)?o?t? (answer|do)|not answer|boundaries|limitation|where would i look", s):
return "META_LIMITS"
if re.search(r"variant|types? of|capital|dropship|consignment|general po|kinds of", s):
return "VARIANTS"
if re.search(r"valid|error|rule|reject|mandatory|required|fail|stop me|cannot be|blank", s):
return "VALIDATIONS"
if re.search(r"which (api|service|endpoint)|what api|endpoint|payload|fires? when", s):
return "API"
if re.search(r"which (tables?|sps?|stored proc)|tables? (get|are) |populat|written|stored|"
r"writes? to|data footprint|lands? in", s):
return "TABLES"
if re.search(r"\btrace\b|chain|from .* to |every step|between clicking", s):
return "TRACE"
if re.search(r"screens?|sub-?screens?", s):
return "SCREENS"
if re.search(r"what happens|walk me|explain the|how do i|process|overview|what does .* do|"
r"what is the purpose", s):
return "WHAT_HAPPENS"
if re.search(r"what is|what does|meaning|mean|stand for|explain|describe|tell me about", s):
return "WHAT_IS"
return "WHAT_IS"
def resolve_activity(q: str) -> str | None:
s = q.lower()
# 1) explicit action verb (create / amend / approve / edit / hold / copy / view / schedule)
for verb, act in VERB_ACT.items():
if re.search(rf"\b{verb}", s) and act in v2().spine:
return act
# 2) a full activity description mentioned verbatim ("amend purchase order")
best = None
for name, a in v2().spine.items():
desc = (a.get("desc") or "").lower()
# require a distinctive multi-word match, not just "purchase order"
if desc and desc in s and desc not in ("purchase order",):
if best is None or len(desc) > len(v2().spine[best]["desc"]):
best = name
return best # None -> caller defaults generic PO questions to the canonical create flow
# ---------------------------------------------------------------- helpers
def _cite(*items):
out, seen = [], set()
for it in items:
if not it:
continue
key = (it.get("source"), it.get("loc"))
if key not in seen:
seen.add(key)
out.append({k: it[k] for k in ("source", "loc", "title") if k in it})
return out
def _g_text(node_id):
g = v2().gloss(node_id)
return g.get("gloss", "")
def _table_phrase(tname):
g = v2().gloss(eid("table", tname))
txt = g.get("gloss", tname)
return re.sub(r"^Stores (the )?", "", txt).rstrip(".")
def build_chain(act_name) -> list[dict]:
a = v2().spine[act_name]
chain = [{"step": "screen", "label": "Screens", "items": a["screens"]}]
sections = [{"name": s["label"], "id": s["section_id"],
"sps": [x["sp"] for x in s["save_sps"]],
"api": s.get("sub_screen_api"), "tables": s["tables"]}
for s in a["sections"]]
chain.append({"step": "section", "label": "Sub-screen sections", "items": sections})
if a.get("primary_api"):
chain.append({"step": "api", "label": "API", "items": [a["primary_api"]["name"]]})
chain.append({"step": "sp", "label": "Save procedures",
"items": sorted({x["sp"] for s in a["sections"] for x in s["save_sps"]})})
chain.append({"step": "table", "label": "Tables written", "items": a["tables"]})
return chain
# ---------------------------------------------------------------- composers
def answer(q: str, use_llm: bool | None = None) -> dict:
vb = v2()
intent = classify(q)
mentions = vb.brain.resolve_mentions(q.lower())
primary = vb.brain.entities[mentions[0]] if mentions else None
act = resolve_activity(q)
# generic PO action question with no explicit target -> default to the canonical create flow
if (not act and not (primary and primary["type"] in ("table", "sp", "api"))
and re.search(r"purchase order|\bpos?\b", q.lower())
and intent in {"WHAT_HAPPENS", "TABLES", "API", "VALIDATIONS", "VARIANTS",
"TRACE", "SCREENS"}):
act = "PoCrt"
# entity-first intents (table/sp/api meaning, traceability)
if primary and primary["type"] in ("table", "sp", "api") and intent in (
"WHAT_IS", "TABLES", "TRACE", "API", "VALIDATIONS"):
res = _entity_answer(primary, intent, q)
elif intent == "META_JOURNEY":
res = _meta_journey(q)
elif intent == "META_LIMITS":
res = _meta_limits()
elif intent == "COMPARE":
res = _compare(q)
elif act:
res = _activity_answer(act, intent, q)
elif primary:
res = _entity_answer(primary, intent, q)
else:
res = _doc_fallback(q)
res.setdefault("intent", intent)
# handoff: if the answer is about an activity that IS a live journey, offer to run it
tgt = res.get("target")
if tgt and tgt.get("type") == "activity":
xr = vb.xref.get(eid("activity", tgt["name"]))
if xr and xr.get("implemented_in_runtime"):
seed = urllib.parse.quote(xr.get("activity_desc") or tgt["name"])
res["handoff"] = {"label": "Do this in the assistant",
"url": f"{config.RAMCO_CHAT_URL}/?start={seed}",
"journey": xr.get("activity_desc", tgt["name"])}
# LLM composition: rewrite the grounded sections into a tailored, natural answer.
# Grounded material comes from the deterministic layer; the LLM may only rephrase it.
want_llm = llm.available() if use_llm is None else use_llm
if want_llm and res.get("sections"):
composed = llm.compose(q, res["sections"], res.get("facts", {}))
if composed:
res["sections_grounded"] = res["sections"] # keep the deterministic source
res["sections"] = composed
res["answer"] = _clean_join(composed)
res["llm"] = True
return res
# ---- section builders (rich, grounded, multi-paragraph) -------------------
def _human_list(items):
items = [i for i in dict.fromkeys([x for x in items if x])]
if not items:
return ""
if len(items) == 1:
return items[0]
return ", ".join(items[:-1]) + " and " + items[-1]
def _sec(h, b):
return {"heading": h, "body": _clean(b)}
def _activity_errors(act_name):
b = v2().brain
a = v2().spine[act_name]
save = {eid("sp", x["sp"]) for s in a["sections"] for x in s["save_sps"]}
seen, examples, total = set(), [], 0
for sp in save:
for e in b.fwd[sp].get("raises", []):
ent = b.entities[e]
if ent["name"] in seen:
continue
seen.add(ent["name"]); total += 1
msg = (ent["attrs"].get("message") or "").strip()
if msg and not msg.startswith("^") and len(msg) < 110 and len(examples) < 4:
examples.append(msg)
return total, examples
def _sec_overview(act):
g = v2().gloss(eid("activity", act))
txt = re.sub(r"^(Process\s+Overview|Overview|Introduction)\s+", "", g.get("gloss", ""))
return _sec("What it is", txt), g.get("sources", [])
def _sec_fill(act):
a = v2().spine[act]
secs = [s["label"].lower() for s in a["sections"]]
body = (f"It begins on the main screen, which captures the core of the order — the supplier, "
f"the buyer, the purchase-order type, the currency, and the individual line items being "
f"ordered. From there, {len(secs)} optional sub-screens capture the rest of the document: "
f"{_human_list(secs)}. You fill only the sub-screens that are relevant to your order, so a "
f"simple PO might use just the main screen while a complex one touches several.")
return _sec("What you fill in", body), [{"source": a["source"]}]
def _sec_save(act):
a = v2().spine[act]
api = a.get("primary_api")
nsp = a["counts"]["save_sps"]
subs = list(dict.fromkeys([s["sub_screen_api"] for s in a["sections"] if s.get("sub_screen_api")]))
body = (f"When you save, the values on screen are assembled into a single payload and sent to the "
+ (f"{api['name']} service (versions {', '.join(api['versions'])}, {api['fields']} request "
f"and response fields)" if api else "Purchase Order service")
+ f". Behind that service, {nsp} stored procedures run: they first validate the document "
f"and then write each part of the order to its own set of tables — the header and line "
f"items first, then each sub-screen's data. ")
if subs:
body += f"Several sub-screens save through their own service as well, such as {_human_list(subs)}."
return _sec("What happens when you save", body), ([{"source": api["source"]}] if api else [])
def _sec_tables(act):
a = v2().spine[act]
anchors = {"PO_POMAS_PUR_ORDER_HDR", "PO_POITM_ITEM_DETAIL"}
groups = []
for s in a["sections"]:
d = [t for t in s["tables"] if t not in anchors]
if d:
groups.append(f"{s['label'].lower()} in {_human_list(d)}")
groups = list(dict.fromkeys(groups))
body = (f"In all, saving writes to {a['counts']['tables']} tables. The order header is held in "
f"PO_POMAS_PUR_ORDER_HDR — one row per purchase order — and every ordered line in "
f"PO_POITM_ITEM_DETAIL. Beyond those, each part of the document has its own tables: "
+ "; ".join(groups) + ". This is why a single PO touches so many tables: it is one logical "
"document spread across header, lines, schedules, taxes, terms and notes.")
return _sec("Where the data is stored", body), [{"source": a["source"]}]
def _sec_variants(act):
d = _types_sentence()
drop = "Dropship" in str(v2().spine[act]["sections"])
base = (d["text"] + " ") if d and d.get("text") else \
"Purchase orders come in several types — General, Capital, Consignment and Dropship. "
body = (base + "Each type becomes its own variant with different required inputs: a General PO is "
"the standard procurement of stockable or non-stockable items; a Capital PO is for capital "
"assets and needs capital-proposal details; a Consignment PO covers supplier-owned stock; "
"and a Dropship PO ships straight to a customer and therefore needs a customer / dropship "
"address" + (", which is captured on a dedicated Dropship sub-screen here" if drop else "") +
". The combination of type plus the sub-screens you fill defines the specific variant.")
cites = [{k: d[k] for k in ("source", "loc", "title") if k in d}] if d else []
return _sec("Types and variants", body), cites
def _sec_screens(act):
a = v2().spine[act]
main = next((s for s in a["screens"] if s.lower().endswith("main")), None)
items = []
for sc in a["screens"]:
if sc == main:
continue
g = (v2().gloss(eid("screen", sc)).get("gloss") or "").strip()
items.append(f"{sc} ({g})" if g else sc)
main_g = (v2().gloss(eid("screen", main)).get("gloss") or "the main order") if main else "the main order"
body = (f"{a['desc']} is made up of {len(a['screens'])} screens. You start on the main screen"
+ (f" — {main} ({main_g})" if main else "")
+ f", which captures the core order. The sub-screens, each capturing one part of the "
f"document, are: " + _human_list(items) + ". You open only the sub-screens your order "
"actually needs.")
return _sec("Screens and sub-screens", body), [{"source": a["source"]}]
def _sec_validations(act):
a = v2().spine[act]
total, ex = _activity_errors(act)
examples = " ".join(f"“{e}”;" for e in ex).rstrip(";")
body = (f"Saving is not unconditional. Before the order is written, the stored procedures run a "
f"large body of validation and will stop the save if something is inconsistent. Across "
f"{a['desc'].lower()} there are roughly {total} distinct error checks drawn from Ramco's "
f"own error catalogue. Typical examples: {examples}." if ex else
f"Before saving, the procedures run roughly {total} distinct validation checks and stop "
f"the save on any failure.")
return _sec("Validations and checks", body), [{"source": "PO/ModelInfo/PO_Design_ErrorMessage.xlsx"}]
_SECTION_ORDER = {
"WHAT_HAPPENS": ["overview", "fill", "save", "tables", "variants"],
"WHAT_IS": ["overview", "fill", "save", "tables", "variants"],
"TABLES": ["tables", "save", "overview"],
"TRACE": ["tables", "save", "fill"],
"API": ["save", "fill", "tables"],
"SCREENS": ["screens", "overview", "save"],
"VARIANTS": ["variants", "overview", "fill"],
"VALIDATIONS": ["validations", "save", "overview"],
}
_BUILDERS = {"overview": _sec_overview, "fill": _sec_fill, "save": _sec_save,
"tables": _sec_tables, "variants": _sec_variants, "validations": _sec_validations,
"screens": _sec_screens}
def _activity_answer(act_name, intent, q) -> dict:
a = v2().spine[act_name]
order = _SECTION_ORDER.get(intent, _SECTION_ORDER["WHAT_HAPPENS"])
sections, cites = [], []
for key in order:
sec, c = _BUILDERS[key](act_name)
if sec["body"]:
sections.append(sec); cites += c
cites += [{"source": a["source"]}]
if a.get("primary_api"):
cites.append({"source": a["primary_api"]["source"]})
return {"answer": _clean_join(sections), "sections": sections,
"target": {"name": act_name, "type": "activity"},
"chain": build_chain(act_name), "citations": _cite(*cites),
"facts": {"activity": act_name, "desc": a["desc"], "tables": a["tables"]}}
def _clean_join(sections) -> str:
return "\n\n".join(f"{s['heading']}. {s['body']}" for s in sections)
def _entity_answer(ent, intent, q) -> dict:
nid = ent["id"]
gloss = _g_text(nid)
if ent["type"] == "table":
writers = vb_writers(ent)
acts = sorted({a for a, _ in writers})
g = v2().gloss(nid)
detail = g.get("detail", "")
cols = ent["attrs"].get("columns", [])
secs = [_sec("What it stores",
f"{gloss} {detail}" if detail else gloss)]
secs.append(_sec("Where it fits in the flow",
(f"This table sits on the save path of {_human_list(acts)} — those activities write to it "
f"when their purchase order is saved. " if acts else "It is part of the PO data model. ")
+ (f"{len(writers)} stored procedures write to it in total." if writers else "")))
if cols:
names = ", ".join(c["name"] for c in cols[:18])
secs.append(_sec(f"Columns ({len(cols)})",
f"Its columns include {names}" + (" …and more." if len(cols) > 18 else ".")))
chain = [{"step": "table", "label": "Table", "items": [ent["name"]]},
{"step": "sp", "label": "Written by SPs",
"items": sorted({sp for _, sp in writers})[:30]},
{"step": "activity", "label": "Via activities", "items": acts}]
return {"answer": _clean_join(secs), "sections": secs,
"target": {"name": ent["name"], "type": "table"},
"chain": chain, "citations": _cite(*g.get("sources", []), {"source": ent.get("source")}),
"facts": {"table": ent["name"], "written_by": acts}}
if ent["type"] == "api":
a = ent["attrs"]
ans = (f"{gloss} It exposes {len(a.get('fields', []))} request/response fields "
f"across versions {', '.join(a.get('versions', []))}.")
return {"answer": _clean(ans), "target": {"name": ent["name"], "type": "api"},
"chain": [{"step": "api", "label": "API", "items": [ent["name"]]}],
"citations": _cite({"source": ent.get("source")}),
"facts": {"api": ent["name"], "fields": len(a.get("fields", []))}}
# sp
writes = sorted({d.split(":")[1].upper() for d in vb_sp_writes(ent)})
purpose = ent["attrs"].get("purpose") or (ent["attrs"].get("csv_tasks") or [""])[0]
ans = (f"{ent['name']}{purpose or 'a Purchase Order stored procedure'}. "
+ (f"It writes to {', '.join(writes[:6])}." if writes else
"It performs validation/fetch logic and writes no core PO table."))
return {"answer": _clean(ans), "target": {"name": ent["name"], "type": "sp"},
"chain": [{"step": "sp", "label": "Procedure", "items": [ent["name"]]},
{"step": "table", "label": "Writes", "items": writes}],
"citations": _cite({"source": ent.get("source")}),
"facts": {"sp": ent["name"], "writes": writes}}
def vb_writers(table_ent):
"""[(activity, sp)] that write a table, from the spine."""
out = []
tname = table_ent["name"]
for act, a in v2().spine.items():
for s in a["sections"]:
if tname in s["tables"]:
for x in s["save_sps"]:
if tname in x["writes"]:
out.append((act, x["sp"]))
return out
def vb_sp_writes(sp_ent):
b = v2().brain
return [d for d in b.fwd[sp_ent["id"]].get("writes", [])]
def _validations_for_activity(act_name, q) -> dict:
b = v2().brain
a = v2().spine[act_name]
save_sps = {eid("sp", x["sp"]) for s in a["sections"] for x in s["save_sps"]}
errs = []
for sp in save_sps:
for e in b.fwd[sp].get("raises", []):
errs.append(b.entities[e])
seen, uniq = set(), []
for e in errs:
if e["name"] not in seen:
seen.add(e["name"]); uniq.append(e)
top = uniq[:6]
lines = "; ".join(f"“{e['attrs'].get('message','')[:90]}”" for e in top)
ans = (f"When saving {a['desc'].lower()}, the system runs validation in its save procedures "
f"and can raise {len(uniq)} distinct errors. Examples: {lines}.")
return {"answer": _clean(ans), "intent": "VALIDATIONS",
"target": {"name": act_name, "type": "activity"},
"chain": [{"step": "error", "label": "Sample validations",
"items": [e["name"] for e in top]}],
"citations": _cite({"source": "PO/ModelInfo/PO_Design_ErrorMessage.xlsx"}),
"facts": {"activity": act_name, "error_count": len(uniq),
"examples": [e["attrs"].get("message", "") for e in top]}}
def _types_sentence():
"""A sentence that actually lists >=2 PO types, from the manuals (or the PoCrt gloss)."""
from query.docstore import sentences, is_clean_sentence
TYPES = ("general", "capital", "consignment", "dropship")
for c in v2().docs.search("types of purchase order general capital consignment dropship",
k=20, prefer_types=["arm", "procurement_manual"]):
for s in sentences(c["text"]):
if is_clean_sentence(s) and sum(t in s.lower() for t in TYPES) >= 2:
return {"text": re.sub(r"\s+", " ", s)[:300].strip(),
"source": c["source"], "loc": c["loc"], "title": c["doc_title"]}
g = v2().gloss(eid("activity", "PoCrt"))
return {"text": g.get("gloss", ""), **({"source": g["sources"][0]["source"],
"loc": g["sources"][0].get("loc")} if g.get("sources") else {})}
def _variants(act_name, q) -> dict:
d = _types_sentence()
drop = "Dropship" in str(v2().spine[act_name]["sections"])
base = (d["text"] if d and d.get("text") else
"Purchase orders come in several types — General, Capital, Consignment and Dropship.")
ans = (f"{base} Each type unrolls into a variant: e.g. a Capital PO needs capital-proposal "
f"details, and a Dropship PO needs a customer/dropship address"
+ (" (the brain sees a dedicated Dropship sub-screen on this activity)." if drop else "."))
return {"answer": _clean(ans), "intent": "VARIANTS",
"target": {"name": act_name, "type": "activity"}, "chain": build_chain(act_name),
"citations": _cite(d) if d else [], "facts": {"activity": act_name}}
def _compare(q) -> dict:
acts = []
for verb, a in VERB_ACT.items():
if re.search(rf"\b{verb}", q.lower()) and a in v2().spine and a not in acts:
acts.append(a)
if len(acts) < 2:
acts = ["PoCrt", "PoAmnd"]
a1, a2 = (v2().spine[acts[0]], v2().spine[acts[1]])
t1, t2 = set(a1["tables"]), set(a2["tables"])
only1 = sorted(t1 - t2)[:5]; only2 = sorted(t2 - t1)[:5]; both = len(t1 & t2)
ans = (f"{a1['desc']} writes {len(t1)} tables; {a2['desc']} writes {len(t2)}. "
f"They share {both} core tables (header, items, schedule…). "
f"Only {a1['desc']}: {', '.join(only1) or 'none'}. "
f"Only {a2['desc']}: {', '.join(only2) or 'none'}.")
return {"answer": _clean(ans), "intent": "COMPARE",
"target": {"name": f"{acts[0]} vs {acts[1]}", "type": "compare"},
"chain": [{"step": "table", "label": f"{acts[0]} tables", "items": a1["tables"]},
{"step": "table", "label": f"{acts[1]} tables", "items": a2["tables"]}],
"citations": _cite({"source": a1["source"]}), "facts": {"compare": acts}}
def _meta_journey(q) -> dict:
xref = {}
p = config.JOURNEY_XREF_JSON
if p.exists():
xref = json.loads(p.read_text())
impl = [v["activity_name"] for v in xref.values() if v.get("implemented_in_runtime")]
modeled = [v["activity_name"] for v in xref.values()]
ans = (f"Of the {len(modeled)} PO activities, the ramco-chat runtime currently implements "
f"{len(impl)} as a live, executable journey: {', '.join(impl) or 'none'} "
f"(Create Direct Purchase Order). The others are recognised but not yet built. "
f"This brain answers structural questions about all of them.")
return {"answer": _clean(ans), "intent": "META_JOURNEY", "target": None,
"chain": [], "citations": _cite({"source": "ramco-chat/kb/PO/journeys.json"}),
"facts": {"implemented": impl}}
def _meta_limits() -> dict:
ans = ("I answer questions about the Purchase Order *system* — its processes, screens, "
"APIs, stored procedures, the tables they populate, validations, and what each means "
"— all grounded in the Ramco artifacts and manuals. I am not connected to a live "
"database, so I can't return actual transactions (e.g. 'how many POs last month'). "
"For live data, query the PO tables directly or the reporting layer.")
return {"answer": ans, "intent": "META_LIMITS", "target": None, "chain": [],
"citations": [], "facts": {}}
def _doc_fallback(q) -> dict:
d = v2().docs.best_definition(q, cues=q.lower().split()[:4],
prefer_types=["arm", "procurement_manual", "system_manual"])
if d:
return {"answer": _clean(d["text"]), "intent": "WHAT_IS", "target": None,
"chain": [], "citations": _cite(d), "facts": {}}
hits = v2().docs.search(q, k=3)
if hits:
return {"answer": "Closest documentation: " + hits[0]["text"][:280],
"intent": "WHAT_IS", "target": None, "chain": [],
"citations": _cite(*hits), "facts": {}}
return {"answer": "I couldn't find that in the PO brain. Try an activity (create/amend/"
"approve a PO), a table, an API, or ask what happens during a process.",
"intent": "WHAT_IS", "target": None, "chain": [], "citations": [], "facts": {}}
_PDF_FIX = [(r"\bt he\b", "the"), (r"\bo f\b", "of"), (r"\ba nd\b", "and"),
(r"\bP O\b", "PO"), (r"\bi s\b", "is"), (r"\bt o\b", "to")]
def _clean(s: str) -> str:
s = re.sub(r"\s+", " ", s).strip()
for pat, rep in _PDF_FIX:
s = re.sub(pat, rep, s)
return re.sub(r"\s+", " ", s).strip()
if __name__ == "__main__":
import sys
qs = [" ".join(sys.argv[1:])] if len(sys.argv) > 1 else [
"What happens when someone creates a purchase order?",
"Which tables get populated when I create a PO?",
"Which API fires when I save a new PO?",
"What are the variants of creating a purchase order?",
"What validations run when I save a PO?",
"What does PO_POSHD_SCHEDULE_DTL mean in business terms?",
"Which SPs write to PO_POMAS_PUR_ORDER_HDR?",
"Compare the data footprint of Create vs Amend PO",
"Which PO activities are implemented in the chatbot runtime today?",
"How many POs did we raise last month?",
]
for q in qs:
r = answer(q)
print("Q:", q)
print("→", r["answer"])
print(" [intent]", r["intent"], "| citations:", len(r["citations"]),
"| chain steps:", len(r["chain"]))
print()