Files
group_xinghuo_jinrong/app/service/template_service.py
T
zhanghongyu_0626 a0f550646e feat(analyst): Add analyze endpoint and chart specification validation
- 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.
2026-09-12 12:33:37 +08:00

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