Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
376 lines
14 KiB
Python
376 lines
14 KiB
Python
"""
|
||
实验 5-10:自然语言交互的 ERP Agent(NL -> SQL,artifact 模式)命令行入口。
|
||
|
||
核心思想(artifact 模式):Agent 只负责「生成 SQL 制品」,不亲自搬运数据;
|
||
真正的查询由系统用生成的 SQL 在数据库上执行,结果表直达用户界面。
|
||
|
||
子命令:
|
||
run 在线:Agent 生成 SQL -> 执行 -> 与参考实现比对(需 OPENAI_API_KEY,默认子命令)
|
||
gold 离线:执行内置「标准 SQL」跑 10 题 -> 与参考实现比对(无需 API,用于自检/演示)
|
||
ask 在线:单条自然语言查询 -> 生成 SQL -> 执行并打印结果表(需 OPENAI_API_KEY)
|
||
initdb 建表并把可复现的种子数据灌入一个 SQLite 文件(离线,便于用 sqlite3 手工查看)
|
||
|
||
不带子命令时等价于 `run`,保持与旧版 `python demo.py` 相同的默认行为。
|
||
完整用法见 `python demo.py --help`,或某个子命令的 `python demo.py <子命令> --help`。
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import sqlite3
|
||
import sys
|
||
from datetime import date
|
||
|
||
try:
|
||
from dotenv import load_dotenv
|
||
load_dotenv()
|
||
except Exception:
|
||
pass
|
||
|
||
import seed
|
||
import reference
|
||
import gold
|
||
from questions import QUESTIONS
|
||
from agent import SQLAgent, MODEL
|
||
|
||
|
||
# ---------------- 结果比对 ----------------
|
||
def _norm(v):
|
||
"""把单个值归一化为 ('n', 数值) 或 ('s', 字符串),便于容差比对。"""
|
||
if isinstance(v, bool):
|
||
return ("n", float(v))
|
||
if isinstance(v, (int, float)):
|
||
return ("n", round(float(v), 2))
|
||
return ("s", str(v).strip())
|
||
|
||
|
||
def _row_match(a, b, tol):
|
||
if len(a) != len(b):
|
||
return False
|
||
for x, y in zip(a, b):
|
||
if x[0] != y[0]:
|
||
return False
|
||
if x[0] == "n":
|
||
if abs(x[1] - y[1]) > tol:
|
||
return False
|
||
else:
|
||
if x[1] != y[1]:
|
||
return False
|
||
return True
|
||
|
||
|
||
def compare(expected, actual, tol=0.1):
|
||
"""按多重集合(忽略行顺序)比对期望与实际结果,数值带容差。"""
|
||
exp = [tuple(_norm(v) for v in r) for r in expected]
|
||
act = [tuple(_norm(v) for v in r) for r in actual]
|
||
if len(exp) != len(act):
|
||
return False, f"行数不一致:期望 {len(exp)} 行,实际 {len(act)} 行"
|
||
remaining = list(act)
|
||
for er in exp:
|
||
for i, ar in enumerate(remaining):
|
||
if _row_match(er, ar, tol):
|
||
remaining.pop(i)
|
||
break
|
||
else:
|
||
return False, f"缺少匹配行:{_readable(er)}"
|
||
return True, "结果一致"
|
||
|
||
|
||
def _readable(norm_row):
|
||
return tuple(v[1] for v in norm_row)
|
||
|
||
|
||
# ---------------- 结果表打印 ----------------
|
||
def print_table(rows, max_rows=12):
|
||
if not rows:
|
||
print(" (空结果)")
|
||
return
|
||
for r in rows[:max_rows]:
|
||
cells = []
|
||
for v in r:
|
||
if isinstance(v, float):
|
||
cells.append(f"{v:.2f}")
|
||
else:
|
||
cells.append(str(v))
|
||
print(" | " + " | ".join(cells) + " |")
|
||
if len(rows) > max_rows:
|
||
print(f" ... 共 {len(rows)} 行")
|
||
|
||
|
||
# ---------------- 逐题执行主循环(在线/离线共用) ----------------
|
||
def run_questions(conn, employees, salaries, today, sql_provider,
|
||
qids=None, print_sql=True, max_rows=12):
|
||
"""对每个问题:取 SQL -> 执行 -> 与 Python 参考实现比对,逐题打印。
|
||
|
||
sql_provider(q) -> str:给出该题的 SQL;可能抛异常(如在线调用 LLM 失败)。
|
||
在线模式传入 `lambda q: agent.generate_sql(q["nl"], q["hint"])`,
|
||
离线模式传入 `lambda q: gold.GOLD[q["id"]]`。
|
||
qids:只跑这些题号(None 表示全部)。
|
||
返回 (passed, total, results),results 为逐题明细 dict,便于 --output 导出。
|
||
"""
|
||
results = []
|
||
passed = 0
|
||
total = 0
|
||
for q in QUESTIONS:
|
||
if qids and q["id"] not in qids:
|
||
continue
|
||
total += 1
|
||
qid, nl, hint = q["id"], q["nl"], q["hint"]
|
||
print(f"\n【问题 {qid}】{nl}")
|
||
rec = {"id": qid, "nl": nl, "sql": None, "rows": None,
|
||
"passed": False, "error": None}
|
||
|
||
# 1) 取 SQL 制品(在线由 Agent 生成,离线取内置 gold SQL)
|
||
try:
|
||
sql = sql_provider(q)
|
||
except Exception as e:
|
||
print(f" [生成 SQL 失败] {e}")
|
||
rec["error"] = f"生成 SQL 失败:{e}"
|
||
results.append(rec)
|
||
continue
|
||
rec["sql"] = sql
|
||
if print_sql:
|
||
print(" 生成的 SQL:")
|
||
for line in sql.splitlines():
|
||
print(" " + line)
|
||
|
||
# 2) 系统执行 SQL
|
||
try:
|
||
cur = conn.cursor()
|
||
cur.execute(sql)
|
||
actual = cur.fetchall()
|
||
except Exception as e:
|
||
print(f" [SQL 执行出错] {e}")
|
||
print(" 结果:不通过 ✗")
|
||
rec["error"] = f"SQL 执行出错:{e}"
|
||
results.append(rec)
|
||
continue
|
||
rec["rows"] = [list(r) for r in actual]
|
||
|
||
print(" 查询结果:")
|
||
print_table(actual, max_rows=max_rows)
|
||
|
||
# 3) 与参考实现比对
|
||
expected = reference.REFERENCE[qid](employees, salaries, today)
|
||
ok, msg = compare(expected, actual)
|
||
rec["passed"] = ok
|
||
if ok:
|
||
passed += 1
|
||
print(f" 校验:通过 ✓({msg})")
|
||
else:
|
||
print(f" 校验:不通过 ✗({msg})")
|
||
print(f" 参考期望:{[tuple(r) for r in expected][:12]}")
|
||
results.append(rec)
|
||
|
||
return passed, total, results
|
||
|
||
|
||
# ---------------- 公用:建库、题号过滤、导出、页眉页脚 ----------------
|
||
def _build_db(db_path, today):
|
||
"""按固定种子生成数据并灌入指定的 SQLite 库(':memory:' 或文件路径)。
|
||
|
||
每次都重新灌入,保证与 reference.py 的期望答案严格对齐、结果可复现。
|
||
"""
|
||
employees, salaries = seed.generate(today)
|
||
conn = sqlite3.connect(db_path)
|
||
seed.create_db(conn, employees, salaries)
|
||
return conn, employees, salaries
|
||
|
||
|
||
def _parse_only(only):
|
||
"""把 '1,5,10' 解析成 {1,5,10};空/None 表示全部题目。"""
|
||
if not only:
|
||
return None
|
||
ids = set()
|
||
for part in only.split(","):
|
||
part = part.strip()
|
||
if part:
|
||
try:
|
||
ids.add(int(part))
|
||
except ValueError:
|
||
raise SystemExit(f"题号必须是整数:{part!r}(--only 形如 1,5,10)")
|
||
unknown = ids - {q["id"] for q in QUESTIONS}
|
||
if unknown:
|
||
raise SystemExit(f"未知题号:{sorted(unknown)}(有效题号 1~{len(QUESTIONS)})")
|
||
return ids
|
||
|
||
|
||
def _header(mode, today, employees, salaries, model=None):
|
||
print("=" * 70)
|
||
tail = f" | 模型:{model}" if model else " | 离线(不调用 API)"
|
||
print(f"ERP Agent 实验 5-10 | {mode}{tail}")
|
||
print(f"今天:{today.isoformat()} | 员工 {len(employees)} 人,"
|
||
f"工资记录 {len(salaries)} 条")
|
||
print("=" * 70)
|
||
|
||
|
||
def _footer(passed, total):
|
||
print("\n" + "=" * 70)
|
||
rate = (passed / total * 100) if total else 0
|
||
print(f"总通过率:{passed}/{total} ({rate:.0f}%)")
|
||
print("=" * 70)
|
||
|
||
|
||
def _write_output(path, mode, today, passed, total, results):
|
||
payload = {
|
||
"experiment": "5-10 ERP Agent NL->SQL",
|
||
"mode": mode,
|
||
"date": today.isoformat(),
|
||
"passed": passed,
|
||
"total": total,
|
||
"results": results,
|
||
}
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||
print(f"\n已写出结果 JSON:{path}")
|
||
|
||
|
||
def _require_api():
|
||
if not (os.environ.get("OPENAI_API_KEY") or os.environ.get("OPENROUTER_API_KEY")):
|
||
print("请先设置 OPENAI_API_KEY(或 OPENROUTER_API_KEY 兜底)环境变量(可复制 env.example 为 .env)。")
|
||
print("若只想离线跑通、不调用 API,请改用:python demo.py gold")
|
||
sys.exit(1)
|
||
|
||
|
||
# ---------------- 子命令 ----------------
|
||
def cmd_run(args):
|
||
"""在线:Agent 生成 SQL -> 执行 -> 比对。"""
|
||
_require_api()
|
||
today = date.today()
|
||
conn, emps, sals = _build_db(args.db, today)
|
||
model = args.model or os.environ.get("OPENAI_MODEL", MODEL)
|
||
_header("在线(Agent 生成 SQL)", today, emps, sals, model=model)
|
||
agent = SQLAgent(model=model)
|
||
qids = _parse_only(args.only)
|
||
passed, total, results = run_questions(
|
||
conn, emps, sals, today,
|
||
sql_provider=lambda q: agent.generate_sql(q["nl"], q["hint"]),
|
||
qids=qids, max_rows=args.max_rows,
|
||
)
|
||
_footer(passed, total)
|
||
if args.output:
|
||
_write_output(args.output, "run", today, passed, total, results)
|
||
|
||
|
||
def cmd_gold(args):
|
||
"""离线:执行内置标准 SQL -> 比对(无需 API)。"""
|
||
today = date.today()
|
||
conn, emps, sals = _build_db(args.db, today)
|
||
_header("离线自检(内置 gold SQL)", today, emps, sals, model=None)
|
||
qids = _parse_only(args.only)
|
||
passed, total, results = run_questions(
|
||
conn, emps, sals, today,
|
||
sql_provider=lambda q: gold.GOLD[q["id"]],
|
||
qids=qids, max_rows=args.max_rows,
|
||
)
|
||
_footer(passed, total)
|
||
if args.output:
|
||
_write_output(args.output, "gold", today, passed, total, results)
|
||
|
||
|
||
def cmd_ask(args):
|
||
"""在线:单条自然语言查询 -> 生成 SQL -> 执行并打印结果表。"""
|
||
_require_api()
|
||
today = date.today()
|
||
conn, emps, sals = _build_db(args.db, today)
|
||
model = args.model or os.environ.get("OPENAI_MODEL", MODEL)
|
||
agent = SQLAgent(model=model)
|
||
hint = args.hint or "自行判断需要返回的列;只输出一条 SELECT。"
|
||
print(f"【问题】{args.query}")
|
||
try:
|
||
sql = agent.generate_sql(args.query, hint)
|
||
except Exception as e:
|
||
print(f"[Agent 生成 SQL 失败] {e}")
|
||
sys.exit(1)
|
||
print("生成的 SQL:")
|
||
for line in sql.splitlines():
|
||
print(" " + line)
|
||
try:
|
||
cur = conn.cursor()
|
||
cur.execute(sql)
|
||
rows = cur.fetchall()
|
||
except Exception as e:
|
||
print(f"[SQL 执行出错] {e}")
|
||
sys.exit(1)
|
||
print("查询结果:")
|
||
print_table(rows, max_rows=args.max_rows)
|
||
|
||
|
||
def cmd_initdb(args):
|
||
"""建表并把种子数据灌入一个 SQLite 文件,便于手工用 sqlite3 查看。"""
|
||
today = date.today()
|
||
if args.db == ":memory:":
|
||
raise SystemExit("initdb 需要一个文件路径,例如:python demo.py initdb --db erp.db")
|
||
if os.path.exists(args.db):
|
||
os.remove(args.db)
|
||
conn, emps, sals = _build_db(args.db, today)
|
||
conn.close()
|
||
print(f"已写入 SQLite 库:{args.db}")
|
||
print(f" 员工 {len(emps)} 人,工资记录 {len(sals)} 条,基准日期 {today.isoformat()}")
|
||
print(f" 手工查看: sqlite3 {args.db} \"SELECT * FROM employees LIMIT 5;\"")
|
||
print(f" 离线复跑: python demo.py gold --db {args.db}")
|
||
|
||
|
||
# ---------------- argparse CLI ----------------
|
||
def build_parser():
|
||
p = argparse.ArgumentParser(
|
||
prog="demo.py",
|
||
description="实验 5-10:自然语言交互的 ERP Agent(NL -> SQL,artifact 模式)",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="不带子命令时等价于 run(保持旧版默认行为)。"
|
||
"离线自检不需要 API:python demo.py gold",
|
||
)
|
||
sub = p.add_subparsers(dest="cmd", metavar="子命令")
|
||
|
||
def add_common(sp, with_model=False):
|
||
sp.add_argument("--db", default=":memory:",
|
||
help="SQLite 库:':memory:'(默认,内存库)或文件路径")
|
||
sp.add_argument("--only", default=None, metavar="题号列表",
|
||
help="只跑指定题号,逗号分隔,如 1,5,10(默认全部)")
|
||
sp.add_argument("--max-rows", type=int, default=12, dest="max_rows",
|
||
help="每题结果表最多打印多少行(默认 12)")
|
||
sp.add_argument("--output", default=None, metavar="路径",
|
||
help="把逐题结果写成 JSON 文件")
|
||
if with_model:
|
||
sp.add_argument("--model", default=None,
|
||
help=f"覆盖模型(默认读 OPENAI_MODEL,否则 {MODEL})")
|
||
|
||
sp_run = sub.add_parser("run", help="在线:Agent 生成 SQL 跑 10 题并校验(需 API)")
|
||
add_common(sp_run, with_model=True)
|
||
sp_run.set_defaults(func=cmd_run)
|
||
|
||
sp_gold = sub.add_parser("gold", help="离线:执行内置标准 SQL 跑 10 题并校验(无需 API)")
|
||
add_common(sp_gold, with_model=False)
|
||
sp_gold.set_defaults(func=cmd_gold)
|
||
|
||
sp_ask = sub.add_parser("ask", help="在线:单条自然语言查询 -> SQL -> 结果表(需 API)")
|
||
sp_ask.add_argument("query", help="要查询的自然语言问题,如“研发部现在有多少在职员工?”")
|
||
sp_ask.add_argument("--hint", default=None, help="可选:补充业务口径/期望返回列")
|
||
sp_ask.add_argument("--db", default=":memory:",
|
||
help="SQLite 库:':memory:'(默认)或文件路径")
|
||
sp_ask.add_argument("--max-rows", type=int, default=20, dest="max_rows",
|
||
help="结果表最多打印多少行(默认 20)")
|
||
sp_ask.add_argument("--model", default=None,
|
||
help=f"覆盖模型(默认读 OPENAI_MODEL,否则 {MODEL})")
|
||
sp_ask.set_defaults(func=cmd_ask)
|
||
|
||
sp_init = sub.add_parser("initdb", help="建表并把种子数据灌入 SQLite 文件(离线)")
|
||
sp_init.add_argument("--db", default="erp.db",
|
||
help="目标 SQLite 文件路径(默认 erp.db)")
|
||
sp_init.set_defaults(func=cmd_initdb)
|
||
|
||
return p
|
||
|
||
|
||
def main(argv=None):
|
||
parser = build_parser()
|
||
args = parser.parse_args(argv)
|
||
if args.cmd is None:
|
||
# 不带子命令 -> 沿用旧版默认行为:在线跑全部 10 题
|
||
args = parser.parse_args((argv or []) + ["run"])
|
||
args.func(args)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|