Quellcode durchsuchen

init: Doris 农业病虫害自然语言查询助手

基于 openai-agents-python + 百炼 glm-5 + Gradio 的 NL2SQL 服务。
单 Agent + list_tables/get_table_schema/search_pest/execute_sql 四个工具,
针对测报灯图片识别表(虫码,数量格式)+ 设备地理 join 做了数据模型固化。

Co-Authored-By: Claude <noreply@anthropic.com>
YangLiyang vor 4 Tagen
Commit
eaffad9d31
5 geänderte Dateien mit 386 neuen und 0 gelöschten Zeilen
  1. 14 0
      .env.example
  2. 13 0
      .gitignore
  3. 54 0
      README.md
  4. 300 0
      app.py
  5. 5 0
      requirements.txt

+ 14 - 0
.env.example

@@ -0,0 +1,14 @@
+# 复制为 .env 并填入真实值。.env 已在 .gitignore 中,不会被提交。
+
+# 阿里百炼 (DashScope) OpenAI 兼容
+LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+# 备用(专用实例): https://llm-vtgvcndnvnmnbuix.cn-beijing.maas.aliyuncs.com/compatible-mode/v1
+LLM_API_KEY=your-dashscope-key
+LLM_MODEL=glm-5
+
+# Doris 只读账号
+DB_HOST=192.168.4.25
+DB_PORT=9030
+DB_USER=readonly_yfkj
+DB_PASSWORD=your-readonly-password
+DB_NAME=ods

+ 13 - 0
.gitignore

@@ -0,0 +1,13 @@
+.venv/
+__pycache__/
+*.pyc
+.env
+schema.json
+server.log
+.DS_Store
+
+# 含明文密码/密钥的本地信息文件,严禁入库
+平台信息.md
+数据库连接信息.md
+大模型信息.md
+密钥.rtf

+ 54 - 0
README.md

@@ -0,0 +1,54 @@
+# Doris 自然语言查询助手
+
+用自然语言查询 Doris `ods` 库,基于 [openai-agents-python](https://github.com/openai/openai-agents-python) + 百炼 glm-5 + Gradio WebUI。
+
+## 工作原理
+
+```
+用户提问 → 单个 Agent
+            ├─ list_tables(keyword)      # 在 ods 库找相关表
+            ├─ get_table_schema(table)   # 看列定义和注释
+            └─ execute_sql(sql)          # 只读 SELECT,≤200 行(SQL 报错自动重试)
+          → 用中文总结结果 + 附最终 SQL
+```
+
+- **安全**:只用只读账号 `readonly_yfkj`;`execute_sql` 拒绝非 SELECT,并把 SQL 包进子查询 + LIMIT 200。
+- **多轮**:对话历史由 Gradio 管理(每用户/标签页独立)。
+- **schema 缓存**:表/列/注释抓一次存 `schema.json`,不每轮查。
+
+## 安装
+
+```bash
+python3 -m venv .venv
+source .venv/bin/activate
+pip install -r requirements.txt
+```
+
+填好 `.env`(参考 `.env.example`):百炼 key、glm-5、Doris 只读账号。
+
+## 运行
+
+```bash
+# 1) (首次)抓取表结构到 schema.json —— 需能连通 Doris
+python app.py --build-schema
+
+# 2) (可选)验证 LLM 接线,不连 Doris
+python app.py --selftest
+
+# 3) 启动 WebUI
+python app.py
+# 打开 http://localhost:7860
+```
+
+## 部署为服务
+
+`python app.py` 默认监听 `0.0.0.0:7860`,用 supervisor / systemd / docker 守护即可。
+要对外暴露,建议加反向代理 + 鉴权(Gradio 支持 `auth=(user, pwd)`,在 `build_ui` 里加)。
+
+## 常见问题
+
+- **glm-5 是推理模型**:回答前会先"思考",稍慢、消耗更多 token,属正常。
+- **schema 变了**:重跑 `--build-schema`。
+- **想加 token 级流式**:把 `_run` 改成 generator,用 `Runner.run_streamed()` + `ResponseTextDeltaEvent` 逐字 yield。v1 没做,Gradio 自带 loading 动画够用。
+- **想限制可查的表范围**:在 `load_schema` / `build_schema` 里按表名前缀过滤,别把几百张表全给 agent。
+- **密钥安全**:`.env` 已 gitignore。大模型信息.md / 数据库连接信息.md 里是明文,建议从仓库移走。

+ 300 - 0
app.py

@@ -0,0 +1,300 @@
+"""Doris 自然语言查询助手 —— 基于 openai-agents-python + glm-5 (百炼) + Gradio WebUI。
+
+用法:
+    python app.py --build-schema   # 重新抓取 ods 库的表/列/注释到 schema.json
+    python app.py --selftest       # 不连 Doris,只验证 LLM 接线
+    python app.py                  # 启动 WebUI (默认 http://0.0.0.0:7860)
+"""
+import argparse
+import asyncio
+import json
+import os
+import re
+from functools import lru_cache
+
+import pymysql
+from dotenv import load_dotenv
+
+load_dotenv()
+
+# --- 配置 -----------------------------------------------------------------
+DB = dict(
+    host=os.getenv("DB_HOST"),
+    port=int(os.getenv("DB_PORT", "9030")),
+    user=os.getenv("DB_USER"),
+    password=os.getenv("DB_PASSWORD"),
+    database=os.getenv("DB_NAME", "ods"),
+    charset="utf8mb4",
+    connect_timeout=10,
+    read_timeout=30,
+    write_timeout=30,
+    autocommit=True,
+)
+SCHEMA_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "schema.json")
+
+
+def _conn(retries: int = 4, delay: float = 1.5):
+    """连 Doris。经 VPN 的链路会在 MySQL 握手阶段偶发掉线(2013),这里重试。"""
+    import time
+
+    last = None
+    for i in range(retries):
+        try:
+            return pymysql.connect(**DB)
+        except pymysql.err.OperationalError as e:
+            last = e
+            if i < retries - 1:
+                time.sleep(delay)
+    raise last
+
+
+# --- schema 缓存 ----------------------------------------------------------
+def build_schema():
+    """从 Doris INFORMATION_SCHEMA 抓取所有表/列/注释,写成 schema.json。"""
+    conn = _conn()
+    try:
+        with conn.cursor(pymysql.cursors.DictCursor) as cur:
+            cur.execute(
+                "SELECT TABLE_NAME, TABLE_COMMENT FROM INFORMATION_SCHEMA.TABLES "
+                "WHERE TABLE_SCHEMA=%s",
+                (DB["database"],),
+            )
+            tables = cur.fetchall()
+            cur.execute(
+                "SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT, IS_NULLABLE "
+                "FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=%s "
+                "ORDER BY TABLE_NAME, ORDINAL_POSITION",
+                (DB["database"],),
+            )
+            cols = cur.fetchall()
+    finally:
+        conn.close()
+
+    schema = {t["TABLE_NAME"]: {"comment": t["TABLE_COMMENT"] or "", "columns": []}
+              for t in tables}
+    for c in cols:
+        schema.setdefault(c["TABLE_NAME"], {"comment": "", "columns": []})["columns"].append(
+            {
+                "name": c["COLUMN_NAME"],
+                "type": c["COLUMN_TYPE"],
+                "comment": c["COLUMN_COMMENT"] or "",
+                "nullable": c["IS_NULLABLE"],
+            }
+        )
+    with open(SCHEMA_PATH, "w", encoding="utf-8") as f:
+        json.dump(schema, f, ensure_ascii=False, indent=2)
+    print(f"schema.json 已生成,共 {len(schema)} 张表。")
+    return schema
+
+
+@lru_cache(maxsize=1)
+def load_schema():
+    if not os.path.exists(SCHEMA_PATH):
+        try:
+            return build_schema()
+        except Exception as e:  # selftest 等场景下 Doris 不可达时也能继续
+            print(f"[警告] 无法连接 Doris 生成 schema,使用空 schema: {e}")
+            return {}
+    with open(SCHEMA_PATH, encoding="utf-8") as f:
+        return json.load(f)
+
+
+# --- Agent 设置 -----------------------------------------------------------
+from agents import (  # noqa: E402
+    Agent,
+    OpenAIChatCompletionsModel,
+    Runner,
+    function_tool,
+    set_tracing_disabled,
+)
+from openai import AsyncOpenAI  # noqa: E402
+
+# 框架默认会把 trace 上报到 OpenAI,用国产模型时必须关掉,否则会报错/泄露。
+set_tracing_disabled(True)
+
+_client = AsyncOpenAI(
+    base_url=os.getenv("LLM_BASE_URL"),
+    api_key=os.getenv("LLM_API_KEY"),
+)
+_model = OpenAIChatCompletionsModel(
+    model=os.getenv("LLM_MODEL", "glm-5"),
+    openai_client=_client,
+)
+
+SCHEMA = load_schema()
+
+# 关键表的人工说明(平台字典为空,这些规则是探查数据得出的,agent 易出错的地方)。
+TABLE_NOTES = {
+    "ods_sa_device_cbdphoto_b": (
+        "测报灯图片识别【主表,约2000万行,查虫量默认用这个】。"
+        "indentify_result 格式 '虫码,数量#虫码,数量'(虫码=ods_base_pest_code.pest_yfkj_code,数量=头数),"
+        "例 '3,1#53,3#136,1'。device_id = ods_sa_device.id。时间字段 uptime(datetime)。"
+        "取某虫数量: regexp_extract(indentify_result,'(^|#)<虫码>,([0-9]+)',2),配 RLIKE '(^|#)<虫码>,' 过滤。"
+    ),
+    "ods_sa_device_lpsphoto": "性诱识别(约2.6万行)。indentify_result='虫码,数量'。device_id=ods_sa_device.id。时间 uptime。",
+    "ods_sa_device_lpsphoto_count": "性诱识别计数(约2.6万行),格式同 lpsphoto。",
+    "ods_sa_device": (
+        "设备主表。province/city/district 值带后缀(如'河南省''新乡市')。"
+        "device_id 是 IMEI;id 是数字主键,被各识别表的 device_id 引用。"
+    ),
+    "ods_base_pest_code": "害虫字典(1226种)。pest_yfkj_code=识别结果里的虫码;pest_name=虫名;pest_level=等级;pest_code 是另一套编码,别混用。",
+    "ods_sa_device_cbd_data": "测报灯设备遥测(JSON),只有温湿度/电池/灯状态等,【没有虫种和数量】,别用来查虫量。",
+}
+
+# --- tools ---------------------------------------------------------------
+_DML = re.compile(
+    r"\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|load|merge)\b"
+)
+
+
+@function_tool
+def list_tables(keyword: str = "") -> str:
+    """列出 ods 库的表。可传 keyword 按表名或注释过滤。返回 '表名 - 注释'。"""
+    items = [(n, d["comment"]) for n, d in SCHEMA.items()]
+    if keyword:
+        k = keyword.lower()
+        items = [(n, c) for n, c in items if k in n.lower() or k in (c or "").lower()]
+    items = items[:50]
+    return "\n".join(f"{n} - {c}" for n, c in items) or "没有匹配的表。"
+
+
+@function_tool
+def get_table_schema(table_name: str) -> str:
+    """获取某张表的列定义、类型和注释。"""
+    t = SCHEMA.get(table_name) or SCHEMA.get(table_name.lower())
+    if not t:
+        cands = [n for n in SCHEMA if table_name.lower() in n.lower()][:10]
+        return f"表 {table_name} 不存在。相近的表: {cands}"
+    lines = [f"表 {table_name}: {t['comment']}"]
+    if TABLE_NOTES.get(table_name):
+        lines.append(f"  ⚑ 数据说明: {TABLE_NOTES[table_name]}")
+    for col in t["columns"]:
+        lines.append(
+            f"  - {col['name']} {col['type']} "
+            f"{'NULL' if col['nullable'] == 'YES' else 'NOT NULL'} -- {col['comment']}"
+        )
+    return "\n".join(lines)
+
+
+@function_tool
+def execute_sql(sql: str) -> str:
+    """对 Doris 执行只读 SELECT 查询,返回最多 200 行结果。只允许 SELECT。"""
+    s = sql.strip().rstrip(";").strip()
+    low = re.sub(r"/\*.*?\*/", " ", s, flags=re.S)  # 去掉块注释
+    low = re.sub(r"--[^\n]*", " ", low)  # 去掉行注释
+    if not low.strip().lower().startswith("select") or _DML.search(low.lower()):
+        return "错误: 只允许只读 SELECT 查询。"
+    conn = _conn()
+    try:
+        with conn.cursor() as cur:
+            # ponytail: 把用户 SQL 包进子查询再 LIMIT,是只读/行数上限的主要保障
+            # (只读账号 + 子查询无法 DML/DDL),上面的正则只是第二道防线。
+            cur.execute("SET query_timeout = 30")
+            cur.execute(f"SELECT * FROM ({s}) AS _q LIMIT 200")
+            cols = [d[0] for d in cur.description]
+            rows = cur.fetchall()
+        if not rows:
+            return "查询成功,但没有数据。"
+        body = "\n".join(" | ".join(str(v) for v in r) for r in rows)
+        return f"列: {cols}\n行数(已截断到200): {len(rows)}\n{body}"
+    except Exception as e:
+        return f"SQL 执行错误: {e}\n请根据错误修正 SQL 后重试。"
+    finally:
+        conn.close()
+
+
+@function_tool
+def search_pest(keyword: str) -> str:
+    """按虫名(或虫码)查害虫编码。返回 pest_yfkj_code | 虫名 | 等级。
+    查询某虫的数量前【必须】先调用本工具拿到 pest_yfkj_code(共1226种,不要靠记忆猜)。"""
+    conn = _conn()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                "SELECT pest_yfkj_code, pest_name, pest_level FROM ods_base_pest_code "
+                "WHERE pest_name LIKE %s OR pest_yfkj_code = %s LIMIT 20",
+                (f"%{keyword}%", keyword),
+            )
+            rows = cur.fetchall()
+    finally:
+        conn.close()
+    if not rows:
+        return f"没找到含 '{keyword}' 的害虫,请换关键词(如'夜蛾''飞虱')。"
+    return "\n".join(f"{c} | {n} | {lv}" for c, n, lv in rows)
+
+
+INSTRUCTIONS = """你是农业病虫害监测数据平台的自然语言查询助手。底层是 Apache Doris,库 ods。用户用自然语言问"某地某时某虫监测到多少头",你转成 SQL 查询并用中文回答。
+
+==== 数据模型(务必遵守,这是查对虫量的关键)====
+"虫量(头数)"来自设备的**图片识别结果**,不是设备遥测。
+1. 主表 ods_sa_device_cbdphoto_b(测报灯,约2000万行,查虫量默认用它):每行=一张虫情照片。
+   - indentify_result 格式 `虫码,数量#虫码,数量`,例 "3,1#53,3#136,1" = 虫码3计1头、53计3头、136计1头。
+   - 虫码 = ods_base_pest_code.pest_yfkj_code。
+   - 时间字段 uptime(datetime)。
+   - 取某虫数量: regexp_extract(indentify_result, '(^|#)<虫码>,([0-9]+)', 2) ,并用 indentify_result RLIKE '(^|#)<虫码>,' 过滤后再 SUM。
+2. 地理: 识别表 device_id = ods_sa_device.id(数字主键,不是IMEI)。JOIN ods_sa_device d ON d.id = <识别表>.device_id,取 d.province/d.city/district(值带后缀:"河南省""新乡市")。
+3. 虫名→虫码: 先 search_pest(虫名) 拿 pest_yfkj_code。
+4. device_data 等 JSON 遥测字段没有虫,别用。
+
+==== 工作流程 ====
+1. search_pest 把虫名转 pest_yfkj_code(多个结果挑最匹配并说明)。
+2. 必要时 get_table_schema 看表结构(默认查虫量用 ods_sa_device_cbdphoto_b)。
+3. 写 SQL:识别表 JOIN ods_sa_device 过滤省市 + uptime 时间段 + regexp 取该虫数量求 SUM。
+4. 报错就修正重试(最多3次)。
+5. 中文回答:给总头数 + 覆盖地区/时间/虫名 + 末尾附 SQL 代码块。
+
+==== 注意 ====
+- "某月"按自然月;时间范围拿不准先 SELECT MIN/MAX(uptime) 确认。
+- 大表查询【必须】带时间范围过滤,避免全表扫描。
+- 若该虫查出来是 0 或没有:如实说明——这类设备(测报灯/性诱)主要监测趋光/趋性诱的蛾、飞虱、叶蝉等,不监测蝗虫等非趋光昆虫,不要编造数字。
+- Doris 与 MySQL 有差异,日期用 'YYYY-MM-DD'。
+"""
+
+agent = Agent(
+    name="Doris查询助手",
+    instructions=INSTRUCTIONS,
+    model=_model,
+    tools=[list_tables, get_table_schema, search_pest, execute_sql],
+)
+
+
+# --- WebUI ---------------------------------------------------------------
+def _run(user_message: str, history: list[dict]):
+    # 历史由 Gradio 管理(每用户每标签页独立),直接拼成 input 交给 Runner。
+    result = asyncio.run(
+        Runner.run(agent, history + [{"role": "user", "content": user_message}])
+    )
+    return result.final_output
+
+
+def build_ui():
+    import gradio as gr
+
+    # gradio 6 默认 history 即为 OpenAI 消息字典格式 {"role","content"}。
+    return gr.ChatInterface(
+        fn=_run,
+        title="Doris 数据查询助手",
+        description="用自然语言查询 ods 库。例:『上个月每天的订单量』",
+        textbox=gr.Textbox(placeholder="问点什么,比如:每个地区近 7 天的销售额"),
+    )
+
+
+def main():
+    p = argparse.ArgumentParser()
+    p.add_argument("--build-schema", action="store_true")
+    p.add_argument("--selftest", action="store_true")
+    args = p.parse_args()
+
+    if args.build_schema:
+        build_schema()
+        return
+    if args.selftest:
+        r = asyncio.run(Runner.run(agent, "只回复 OK"))
+        print("模型自检返回:", repr(r.final_output))
+        return
+
+    build_ui().launch(server_name="0.0.0.0", server_port=7860)
+
+
+if __name__ == "__main__":
+    main()

+ 5 - 0
requirements.txt

@@ -0,0 +1,5 @@
+openai-agents
+openai
+pymysql
+python-dotenv
+gradio