- Introduced a new `/analyze` endpoint in the analyst API to process analysis requests, allowing users to receive textual interpretations and chart specifications based on provided prompts. - Enhanced `analyst_schemas.py` with `AnalyzeRequest` and `ChartSpec` models to structure analysis requests and validate chart specifications. - Implemented chart validation logic in a new `analyst_chart.py` service, ensuring that chart types and fields are correctly specified and conform to allowed values. - Updated `AnalystAgent` to handle analysis requests, integrating the new logic for generating responses based on user prompts and data availability. - Added unit tests to verify the functionality of the new endpoint and validation mechanisms, ensuring robustness and reliability. This update significantly enhances the analytical capabilities of the application, providing users with improved tools for data interpretation and visualization.
225 lines
6.8 KiB
Python
225 lines
6.8 KiB
Python
"""问数 SQL 模板匹配与填参(D-06 模板缓存 / D-11 资产联动)。
|
|
|
|
published 模板由 analytics_query_template 加载;命中后跳过 LLM 生成 SQL。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
from app.service.analytics_repo import AnalyticsRepo
|
|
|
|
RECENT_DAYS_PATTERNS = (
|
|
re.compile(r"近\s*(\d+)\s*天"),
|
|
re.compile(r"近\s*(\d+)\s*日"),
|
|
re.compile(r"最近\s*(\d+)\s*天"),
|
|
re.compile(r"(\d+)\s*天内"),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class QueryTemplate:
|
|
template_key: str
|
|
template_sql: str
|
|
params_schema: dict[str, Any] = field(default_factory=dict)
|
|
tags: list[str] | None = None
|
|
gray_roles: list[str] | None = None
|
|
|
|
|
|
def _parse_json(value: Any) -> Any:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, (dict, list)):
|
|
return value
|
|
try:
|
|
return json.loads(value)
|
|
except (TypeError, json.JSONDecodeError):
|
|
return None
|
|
|
|
|
|
def _role_allowed(template: QueryTemplate, roles: list[str]) -> bool:
|
|
gray = template.gray_roles
|
|
if not gray:
|
|
return True
|
|
return any(r in gray for r in roles)
|
|
|
|
|
|
def normalize_question(question: str) -> str:
|
|
"""问句规范化(原句命中用)。"""
|
|
return re.sub(r"\s+", " ", (question or "").strip())
|
|
|
|
|
|
def _exact_phrase_match(question: str, template: QueryTemplate) -> bool:
|
|
schema = template.params_schema or {}
|
|
phrases = schema.get("match_phrases") or []
|
|
if not phrases:
|
|
return False
|
|
nq = normalize_question(question)
|
|
for raw in phrases:
|
|
if nq == normalize_question(str(raw)):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _question_matches(question: str, template: QueryTemplate) -> bool:
|
|
if _exact_phrase_match(question, template):
|
|
return True
|
|
schema = template.params_schema or {}
|
|
# 配置了 match_phrases 的模板不再走 match_all,避免口径误命中
|
|
if schema.get("match_phrases"):
|
|
return False
|
|
match_all = schema.get("match_all") or template.tags or []
|
|
if not match_all:
|
|
return False
|
|
return all(kw in question for kw in match_all)
|
|
|
|
|
|
def _extract_recent_days(question: str, default: int) -> int:
|
|
for pat in RECENT_DAYS_PATTERNS:
|
|
m = pat.search(question)
|
|
if m:
|
|
return max(1, min(int(m.group(1)), 366))
|
|
if any(x in question for x in ("近一月", "近一个月", "最近一个月", "近30天")):
|
|
return 30
|
|
if any(x in question for x in ("近一周", "最近一周", "近7天")):
|
|
return 7
|
|
return default
|
|
|
|
|
|
def _sanitize_int(value: int, *, lo: int = 1, hi: int = 366) -> int:
|
|
return max(lo, min(int(value), hi))
|
|
|
|
|
|
def _sanitize_customer_id(value: str) -> str | None:
|
|
v = (value or "").strip()
|
|
if re.fullmatch(r"CUST-\d+", v):
|
|
return v
|
|
return None
|
|
|
|
|
|
def _extract_param(name: str, extract: str, question: str, default: Any, scope: list[str]) -> Any:
|
|
if extract == "recent_days":
|
|
return _extract_recent_days(question, int(default or 30))
|
|
if extract == "scope_customer":
|
|
if scope:
|
|
return scope[0]
|
|
return default
|
|
return default
|
|
|
|
|
|
def render_template_sql(
|
|
template: QueryTemplate,
|
|
question: str,
|
|
*,
|
|
domain: str,
|
|
scope: list[str],
|
|
) -> str | None:
|
|
"""填参渲染;参数非法时返回 None。"""
|
|
sql = template.template_sql
|
|
schema = template.params_schema or {}
|
|
for spec in schema.get("params") or []:
|
|
placeholder = spec.get("placeholder") or f":{spec.get('name')}"
|
|
if placeholder not in sql:
|
|
continue
|
|
raw = _extract_param(
|
|
spec.get("name", ""),
|
|
spec.get("extract", ""),
|
|
question,
|
|
spec.get("default"),
|
|
scope,
|
|
)
|
|
if spec.get("extract") == "recent_days":
|
|
value = str(_sanitize_int(int(raw)))
|
|
elif spec.get("extract") == "scope_customer":
|
|
cid = _sanitize_customer_id(str(raw or ""))
|
|
if not cid:
|
|
return None
|
|
value = cid
|
|
elif isinstance(raw, int):
|
|
value = str(_sanitize_int(raw))
|
|
else:
|
|
value = str(raw)
|
|
if not value:
|
|
return None
|
|
sql = sql.replace(placeholder, value)
|
|
if re.search(r":\w+", sql):
|
|
return None
|
|
return sql
|
|
|
|
|
|
class TemplateService:
|
|
def __init__(
|
|
self,
|
|
repo: AnalyticsRepo | None = None,
|
|
templates: list[QueryTemplate] | None = None,
|
|
) -> None:
|
|
self.repo = repo or AnalyticsRepo()
|
|
self._fixed_templates = templates
|
|
self._loaded: list[QueryTemplate] | None = templates
|
|
|
|
def _templates(self) -> list[QueryTemplate]:
|
|
if self._fixed_templates is not None:
|
|
return self._fixed_templates
|
|
if self._loaded is None:
|
|
self._loaded = self.repo.list_published_templates()
|
|
return self._loaded
|
|
|
|
def reload(self) -> None:
|
|
if self._fixed_templates is None:
|
|
self._loaded = None
|
|
|
|
def try_render(
|
|
self,
|
|
question: str,
|
|
domain: str,
|
|
scope: list[str],
|
|
roles: list[str],
|
|
) -> tuple[str, str] | None:
|
|
"""匹配 published 模板并填参;返回 (sql, template_key) 或 None。"""
|
|
q = question.strip()
|
|
if not q:
|
|
return None
|
|
candidates = [
|
|
t
|
|
for t in self._templates()
|
|
if _role_allowed(t, roles) and _question_matches(q, t)
|
|
]
|
|
candidates.sort(
|
|
key=lambda t: (
|
|
1 if _exact_phrase_match(q, t) else 0,
|
|
len((t.params_schema or {}).get("match_phrases") or []),
|
|
len((t.params_schema or {}).get("match_all") or t.tags or []),
|
|
),
|
|
reverse=True,
|
|
)
|
|
for tpl in candidates:
|
|
sql = render_template_sql(tpl, q, domain=domain, scope=scope)
|
|
if sql:
|
|
return sql, tpl.template_key
|
|
return None
|
|
|
|
def list_published_prompts(self, roles: list[str]) -> list[dict[str, Any]]:
|
|
"""供问数页展示:收录原句 + 短标签。"""
|
|
items: list[dict[str, Any]] = []
|
|
for tpl in self._templates():
|
|
if not _role_allowed(tpl, roles):
|
|
continue
|
|
schema = tpl.params_schema or {}
|
|
phrases = schema.get("match_phrases") or []
|
|
if not phrases:
|
|
continue
|
|
label = str(schema.get("prompt_label") or tpl.template_key)
|
|
featured = bool(schema.get("featured"))
|
|
for phrase in phrases:
|
|
items.append(
|
|
{
|
|
"template_key": tpl.template_key,
|
|
"label": label,
|
|
"question": str(phrase),
|
|
"featured": featured,
|
|
}
|
|
)
|
|
return items
|