Files
iAOP/templates/ti-cl4/llm-scenarios/nl_query.py
T

162 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)")