feat: 完成 issue #76 [Ti-1] 自然语言查询接口(NL→SQL/API)

This commit is contained in:
2026-08-05 03:59:23 +08:00
parent fc4ac42007
commit 5d9a76c3ff
3 changed files with 298 additions and 0 deletions
@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# 模板「自然语言查询(NL→SQL/API)」配置资产:ti-cl4(Template-Ti 一期)。
#
# 说明(issue #76 / 父 Issue #11):
# - metrics:自然语言指标名 → 点位(point_id),NL 查询翻译的依据;
# - intents:意图关键词(trend/latest/kpi/alarm),长词优先;
# - table:TDengine 超级表(对齐 data-bus tdengine_schema 命名);
# - default_time_range:未识别时间范围时的默认窗口;
# - 换行业/换点位只改本文件,翻译器内核零改动。
template: ti-cl4
version: 1.0.0
nl_query:
table: tpl_ti_cl4.points
default_time_range: 1h
# 自然语言指标名 → point_id(演示字典,接客户数据时按点表补充)
metrics:
氯气流量: CLF-01.FLOW
炉温: CLF-01.TEMP
炉压: CLF-01.PRES
进料量: CLF-01.FEED
钛纯度: LAB-01.TI_PURITY
交联度: LAB-01.CROSSLINK
# 意图关键词(长词优先;未命中 → unsupported,转 LLM 问答)
intents:
trend: [趋势, 走势, 曲线, 变化]
alarm: [报警, 告警, 异常]
kpi: [平均, 统计, 均值, 最大值, 最小值]
latest: [最新, 现在, 当前, 多少, 数值]
+161
View File
@@ -0,0 +1,161 @@
# -*- coding: utf-8 -*-
"""Template-Ti 一期 · 自然语言查询接口(NL→SQL/API)—— issue #76。
父 Issue #11「④ LLM 报警解释 / 交接班 / NL 查询」子任务:
把驾驶舱/对话中的自然语言问题翻译为**结构化查询**:
- 意图识别(intent):trend(趋势)/ latest(最新值)/ kpi(统计指标)/ alarm(告警);
- 指标映射(metric):自然语言指标名 → 点位(point_id),配置驱动
(`config/nl_query.template.yaml` 指标字典);
- 时间范围(time_range):从问句抽取("最近 1 小时" → 1h);
- 产出:TDengine SQL(超级表查询)+ 驾驶舱 API 调用参数(to_api_params)。
纯本地规则实现(无 LLM 依赖、可离线测试);未识别意图/指标时给出
结构化降级(intent=unsupported),由上层转 LLM 问答(query_cockpit)。
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass, field
from typing import Dict, List, Optional
#: 默认配置资产路径(相对本模块)
DEFAULT_CONFIG_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "config",
"nl_query.template.yaml")
#: 默认 TDengine 超级表(对齐 data-bus tdengine_schema 命名)
DEFAULT_TABLE = "tpl_ti_cl4.points"
#: 时间范围抽取正则:最近 N 小时/分钟/天
_TIME_RANGE_RE = re.compile(r"最近\s*(\d+)\s*(小时|分钟|天|h|min|d)")
_TIME_UNIT = {"小时": "h", "分钟": "m", "天": "d", "h": "h", "min": "m", "d": "d"}
@dataclass
class NLQuery:
"""一次 NL 查询的结构化结果。"""
question: str
intent: str # trend | latest | kpi | alarm | unsupported
metric: str = ""
point_id: str = ""
device: str = ""
time_range: str = "" # 如 "1h";空 = 默认窗口
sql: str = "" # TDengine SQL(intent=unsupported 时为空)
meta: dict = field(default_factory=dict)
def to_api_params(self) -> dict:
"""驾驶舱 API 调用参数(供前端查询接口使用)。"""
return {
"intent": self.intent, "metric": self.metric,
"point_id": self.point_id, "device": self.device,
"time_range": self.time_range or "1h",
}
class NLQueryTranslator:
"""自然语言 → 结构化查询(NL→SQL/API,规则 + 配置驱动)。"""
#: 意图关键词(长词优先)
_INTENT_KEYWORDS = [
("trend", ["趋势", "走势", "曲线", "变化"]),
("alarm", ["报警", "告警", "异常"]),
("kpi", ["平均", "统计", "均值", "最大值", "最小值"]),
("latest", ["最新", "现在", "当前", "多少", "数值"]),
]
def __init__(
self,
metrics: Optional[Dict[str, str]] = None,
table: str = DEFAULT_TABLE,
default_range: str = "1h",
intent_keywords: Optional[Dict[str, List[str]]] = None,
) -> None:
"""Args:
metrics: 自然语言指标名 → point_id(如 {"氯气流量": "CLF-01.FLOW"});
table: TDengine 超级表名;
default_range: 未识别时间范围时的默认窗口;
intent_keywords: 意图关键词覆盖。
"""
self.metrics: Dict[str, str] = dict(metrics or {})
self.table = table
self.default_range = default_range
self._intent = intent_keywords or dict(self._INTENT_KEYWORDS)
# ------------------------------------------------------------------
@classmethod
def from_template_config(cls, path: str = DEFAULT_CONFIG_PATH) -> "NLQueryTranslator":
"""从模板配置资产加载(config/nl_query.template.yaml)。"""
import yaml
with open(path, "r", encoding="utf-8") as fh:
raw = yaml.safe_load(fh) or {}
cfg = raw.get("nl_query", {}) or {}
return cls(
metrics=cfg.get("metrics", {}),
table=cfg.get("table", DEFAULT_TABLE),
default_range=cfg.get("default_time_range", "1h"),
intent_keywords=cfg.get("intents"),
)
# ------------------------------------------------------------------
def translate(self, question: str) -> NLQuery:
"""把自然语言问题翻译为结构化查询。"""
intent = self._detect_intent(question)
if intent == "unsupported":
return NLQuery(question=question, intent="unsupported",
meta={"reason": "未识别查询意图"})
metric = self._detect_metric(question)
time_range = self._detect_time_range(question)
point_id = self.metrics.get(metric, "") if metric else ""
query = NLQuery(
question=question, intent=intent, metric=metric,
point_id=point_id, time_range=time_range,
)
query.sql = self._build_sql(query)
query.meta = {"table": self.table}
return query
# ------------------------------------------------------------------
def _detect_intent(self, question: str) -> str:
for intent, keywords in self._intent.items():
for kw in keywords:
if kw in question:
return intent
return "unsupported"
def _detect_metric(self, question: str) -> str:
"""指标识别:配置字典中自然语言名作为子串匹配(长名优先)。"""
candidates = sorted(self.metrics, key=len, reverse=True)
for name in candidates:
if name in question:
return name
return ""
@staticmethod
def _detect_time_range(question: str) -> str:
m = _TIME_RANGE_RE.search(question)
if not m:
return ""
return f"{int(m.group(1))}{_TIME_UNIT[m.group(2)]}"
def _build_sql(self, query: NLQuery) -> str:
"""生成 TDengine SQL(超级表,按 point_id 过滤)。"""
point_filter = f"point_id = '{query.point_id}'" if query.point_id else "1=1"
window = query.time_range or self.default_range
if query.intent == "latest":
return (f"SELECT last_row(value) AS value FROM {self.table} "
f"WHERE {point_filter} AND ts >= now - {window}")
if query.intent == "kpi":
return (f"SELECT avg(value) AS value_avg FROM {self.table} "
f"WHERE {point_filter} AND ts >= now - {window}")
if query.intent == "alarm":
return (f"SELECT count(*) AS alarms FROM {self.table} "
f"WHERE {point_filter} AND value > threshold "
f"AND ts >= now - {window}")
# trend
return (f"SELECT _wstart AS ts, avg(value) AS value_avg "
f"FROM {self.table} WHERE {point_filter} "
f"AND ts >= now - {window} INTERVAL(1m)")
@@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
"""NL→SQL/API 查询翻译器测试(issue #76)。
覆盖:
1. 配置资产加载(指标字典/意图关键词/表/默认窗口);
2. 意图识别(trend/latest/kpi/alarm + 未识别降级 unsupported);
3. 指标映射(自然语言名 → point_id,长名优先);
4. 时间范围抽取("最近 1 小时" → 1h);
5. TDengine SQL 生成(latest/kpi/alarm/trend 模板);
6. to_api_params(驾驶舱 API 调用参数)。
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import _bootstrap # noqa: F401
from ti_scenarios.nl_query import NLQueryTranslator # noqa: E402
CONFIG = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"config", "nl_query.template.yaml",
)
class TestConfigLoad(unittest.TestCase):
"""配置资产加载。"""
def setUp(self):
self.t = NLQueryTranslator.from_template_config(CONFIG)
def test_metrics_and_table(self):
self.assertEqual(self.t.metrics["氯气流量"], "CLF-01.FLOW")
self.assertEqual(self.t.table, "tpl_ti_cl4.points")
self.assertEqual(self.t.default_range, "1h")
def test_intent_keywords_loaded(self):
self.assertIn("趋势", self.t._intent["trend"])
class TestTranslate(unittest.TestCase):
"""翻译:意图/指标/时间范围 → SQL/API 参数。"""
def setUp(self):
self.t = NLQueryTranslator.from_template_config(CONFIG)
def test_trend_query(self):
q = self.t.translate("氯气流量最近1小时趋势")
self.assertEqual(q.intent, "trend")
self.assertEqual(q.metric, "氯气流量")
self.assertEqual(q.point_id, "CLF-01.FLOW")
self.assertEqual(q.time_range, "1h")
self.assertIn("INTERVAL(1m)", q.sql)
self.assertIn("avg(value)", q.sql)
self.assertIn("CLF-01.FLOW", q.sql)
def test_latest_query(self):
q = self.t.translate("炉温最新数值是多少")
self.assertEqual(q.intent, "latest")
self.assertIn("last_row", q.sql)
self.assertEqual(q.point_id, "CLF-01.TEMP")
def test_kpi_query_with_default_range(self):
q = self.t.translate("氯气流量平均")
self.assertEqual(q.intent, "kpi")
self.assertEqual(q.time_range, "") # 未识别 → 默认窗口
self.assertIn("now - 1h", q.sql) # 默认 1h
def test_alarm_query(self):
q = self.t.translate("炉温报警")
self.assertEqual(q.intent, "alarm")
self.assertIn("value > threshold", q.sql)
def test_unsupported_intent(self):
q = self.t.translate("今天天气怎么样")
self.assertEqual(q.intent, "unsupported")
self.assertEqual(q.sql, "")
def test_unknown_metric_keeps_intent(self):
# 未识别指标不阻断查询(SQL 用全表过滤,交由上层 LLM 兜底)
q = self.t.translate("进料泵转速趋势")
self.assertEqual(q.intent, "trend")
self.assertEqual(q.point_id, "")
def test_api_params(self):
q = self.t.translate("氯气流量最近1小时趋势")
params = q.to_api_params()
self.assertEqual(params["intent"], "trend")
self.assertEqual(params["metric"], "氯气流量")
self.assertEqual(params["point_id"], "CLF-01.FLOW")
self.assertEqual(params["time_range"], "1h")
class TestTimeRange(unittest.TestCase):
"""时间范围抽取。"""
def test_hour_minute_day(self):
t = NLQueryTranslator(metrics={})
self.assertEqual(t.translate("最近 2 小时趋势").time_range, "2h")
self.assertEqual(t.translate("最近30分钟走势").time_range, "30m")
self.assertEqual(t.translate("最近 3 天曲线").time_range, "3d")
if __name__ == "__main__":
unittest.main()