162 lines
6.7 KiB
Python
162 lines
6.7 KiB
Python
# -*- 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)")
|