service.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. from __future__ import annotations
  2. import asyncio
  3. import logging
  4. import re
  5. import uuid
  6. from datetime import datetime
  7. from pathlib import Path
  8. from typing import Any
  9. import pandas as pd
  10. from .codex_runtime import CodexRuntime
  11. from .commands import HELP_TEXT, SKILLS_TEXT, parse_command
  12. from .config import Settings
  13. from .feishu import FeishuApi, FeishuLongConnection
  14. from .models import IncomingMessage, QueryAnalysis, QueryDecision
  15. from .odps_client import ODPSClient, validate_for_odps
  16. from .reports import build_profile, write_json
  17. from .skill_executor import SkillExecutor
  18. from .sql_guard import SQLGuard
  19. from .state import StateStore
  20. logger = logging.getLogger(__name__)
  21. class DataQueryService:
  22. def __init__(self, settings: Settings) -> None:
  23. self.settings = settings
  24. settings.runtime_dir.mkdir(parents=True, exist_ok=True)
  25. (settings.runtime_dir / "runs").mkdir(parents=True, exist_ok=True)
  26. self.state = StateStore(settings.db_path, settings.conversation_idle_hours)
  27. self.codex = CodexRuntime(settings)
  28. self.odps = ODPSClient(settings)
  29. self.guard = SQLGuard(settings.odps_allowed_projects)
  30. self.skills = SkillExecutor(settings)
  31. self.feishu = FeishuApi(settings)
  32. self.queue: asyncio.Queue[IncomingMessage] = asyncio.Queue(maxsize=1000)
  33. self.query_slots = asyncio.Semaphore(settings.query_concurrency)
  34. self._locks: dict[str, asyncio.Lock] = {}
  35. self._listener: FeishuLongConnection | None = None
  36. self._worker_tasks: list[asyncio.Task[Any]] = []
  37. self._loop: asyncio.AbstractEventLoop | None = None
  38. def _authorized(self, message: IncomingMessage) -> bool:
  39. return (
  40. message.message_type in {"text", "post"}
  41. and bool(message.text)
  42. and bool(message.message_id)
  43. and bool(message.chat_id)
  44. and bool(message.sender_open_id)
  45. and message.chat_id in self.settings.allowed_chat_ids
  46. and message.chat_type == "group"
  47. and message.mentioned_bot
  48. )
  49. def _enqueue_from_websocket(self, message: IncomingMessage) -> None:
  50. if not self._authorized(message) or self._loop is None:
  51. return
  52. def enqueue() -> None:
  53. try:
  54. self.queue.put_nowait(message)
  55. except asyncio.QueueFull:
  56. logger.error("Inbound queue full; message_id=%s dropped", message.message_id)
  57. self._loop.call_soon_threadsafe(enqueue)
  58. async def start(self) -> None:
  59. self._loop = asyncio.get_running_loop()
  60. bot_open_id = await self.feishu.bot_open_id()
  61. self._listener = FeishuLongConnection(self.settings, bot_open_id, self._enqueue_from_websocket)
  62. self._listener.start()
  63. worker_count = max(2, self.settings.query_concurrency * 2)
  64. self._worker_tasks = [asyncio.create_task(self._worker(index)) for index in range(worker_count)]
  65. logger.info("Data query agent started workers=%d", worker_count)
  66. async def stop(self) -> None:
  67. for task in self._worker_tasks:
  68. task.cancel()
  69. await asyncio.gather(*self._worker_tasks, return_exceptions=True)
  70. await self.feishu.close()
  71. self.state.close()
  72. async def _worker(self, index: int) -> None:
  73. while True:
  74. message = await self.queue.get()
  75. try:
  76. await self.handle(message)
  77. except asyncio.CancelledError:
  78. raise
  79. except Exception:
  80. logger.exception("Unhandled message failure worker=%d message_id=%s", index, message.message_id)
  81. finally:
  82. self.queue.task_done()
  83. async def handle(self, message: IncomingMessage) -> None:
  84. if not self._authorized(message):
  85. return
  86. if not self.state.claim_message(message.message_id, message.conversation_key):
  87. logger.info("Duplicate Feishu message ignored message_id=%s", message.message_id)
  88. return
  89. lock = self._locks.setdefault(message.conversation_key, asyncio.Lock())
  90. async with lock:
  91. try:
  92. command = parse_command(message.text)
  93. if command:
  94. await self._handle_command(message, command)
  95. self.state.finish_message(message.message_id, "command")
  96. return
  97. await self.feishu.reply_text(message.message_id, "收到,正在处理你的请求…")
  98. async with self.query_slots:
  99. await self._handle_query(message)
  100. self.state.finish_message(message.message_id, "completed")
  101. except Exception as exc:
  102. safe_error = self._safe_error(exc)
  103. logger.error("Message handling failed message_id=%s error=%s", message.message_id, safe_error)
  104. self.state.finish_message(message.message_id, "failed", safe_error)
  105. self.state.fail_active_runs(message.message_id, safe_error)
  106. try:
  107. await self.feishu.reply_text(message.message_id, f"处理失败:{safe_error}")
  108. except Exception as reply_exc:
  109. logger.error(
  110. "Failed to send error reply message_id=%s error=%s",
  111. message.message_id,
  112. self._safe_error(reply_exc),
  113. )
  114. async def _handle_command(self, message: IncomingMessage, command: str) -> None:
  115. if command == "help":
  116. await self.feishu.reply_text(message.message_id, HELP_TEXT)
  117. return
  118. if command == "skills":
  119. await self.feishu.reply_text(message.message_id, SKILLS_TEXT)
  120. return
  121. conversation = self.state.reset_conversation(message.conversation_key, new_session=command == "new")
  122. if command == "new":
  123. text = f"已开启新会话({conversation.session_id[:8]}),下一条问题将使用全新上下文。"
  124. else:
  125. text = "已清空当前上下文,下一条问题将重新开始。"
  126. await self.feishu.reply_text(message.message_id, text)
  127. async def _handle_query(self, message: IncomingMessage) -> None:
  128. conversation = self.state.get_conversation(message.conversation_key)
  129. thread_id, decision = await self.codex.plan(conversation.thread_id, message.text)
  130. self.state.set_thread(message.conversation_key, thread_id)
  131. if decision.status != "ready":
  132. await self.feishu.reply_text(message.message_id, decision.reply)
  133. return
  134. if decision.execution_mode == "direct_reply":
  135. if decision.sql:
  136. raise RuntimeError("Agent 的自然语言回复不应包含 SQL")
  137. if not decision.reply.strip():
  138. raise RuntimeError("Agent 的自然语言回复为空")
  139. await self.feishu.reply_text(message.message_id, decision.reply)
  140. return
  141. if decision.execution_mode == "sql" and not decision.sql:
  142. raise RuntimeError("Agent 返回 ready 但没有 SQL")
  143. if decision.execution_mode == "skill_script" and decision.selected_skill != "query-user-behavior-path":
  144. raise RuntimeError("Agent 请求了未授权的 Skill 脚本")
  145. run_id = datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:8]
  146. run_dir = self.settings.runtime_dir / "runs" / run_id
  147. run_dir.mkdir(parents=True, exist_ok=False)
  148. self.state.create_run(run_id, message.conversation_key, message.message_id, run_dir)
  149. write_json(run_dir / "request.json", {
  150. "run_id": run_id,
  151. "message_id": message.message_id,
  152. "conversation_key": message.conversation_key,
  153. "session_id": conversation.session_id,
  154. "question": message.text,
  155. "selected_skill": decision.selected_skill,
  156. "execution_mode": decision.execution_mode,
  157. "parameters": decision.parameters.model_dump(),
  158. })
  159. await self.feishu.reply_text(
  160. message.message_id,
  161. f"已匹配 Skill:{decision.selected_skill},正在执行查询…",
  162. )
  163. if decision.execution_mode == "skill_script":
  164. self.state.update_run(run_id, "running", metadata={"skill": decision.selected_skill})
  165. artifact = await self.skills.run_user_timeline(decision.parameters, run_dir)
  166. await self._publish_result(
  167. message,
  168. run_id,
  169. thread_id,
  170. decision.title,
  171. decision.selected_skill,
  172. artifact.dataframe,
  173. artifact.xlsx_path,
  174. artifact.instance_id,
  175. artifact.truncated,
  176. )
  177. return
  178. current = decision
  179. result = None
  180. repairs: list[dict[str, Any]] = []
  181. for attempt in range(self.settings.query_max_repairs + 1):
  182. sql = (current.sql or "").strip().rstrip(";")
  183. (run_dir / "query.sql").write_text(sql + "\n", encoding="utf-8")
  184. write_json(run_dir / "query_plan.json", {
  185. "title": current.title,
  186. "selected_skill": current.selected_skill,
  187. "execution_mode": current.execution_mode,
  188. "parameters": current.parameters.model_dump(),
  189. "assumptions": current.assumptions,
  190. "attempt": attempt,
  191. "repairs": repairs,
  192. })
  193. try:
  194. if current.selected_skill == "odps-product-efficiency-report":
  195. self.guard.validate_product_efficiency_contract(sql, current.parameters.data_mode)
  196. await validate_for_odps(sql, self.guard, self.odps)
  197. self.state.update_run(run_id, "running")
  198. result = await self.odps.execute(sql)
  199. break
  200. except Exception as exc:
  201. if attempt >= self.settings.query_max_repairs:
  202. self.state.update_run(run_id, "failed", metadata={"attempts": attempt + 1})
  203. raise
  204. repairs.append({"attempt": attempt + 1, "error": self._safe_error(exc)})
  205. thread_id, current = await self.codex.repair(thread_id, sql, str(exc), attempt + 1)
  206. self.state.set_thread(message.conversation_key, thread_id)
  207. if current.status != "ready" or current.execution_mode != "sql" or not current.sql:
  208. raise RuntimeError(current.reply or "Agent 无法修复 SQL")
  209. if result is None:
  210. raise RuntimeError("查询未返回结果")
  211. frame = result.dataframe
  212. if not isinstance(frame, pd.DataFrame):
  213. frame = pd.DataFrame(frame)
  214. info = {
  215. "run_id": run_id,
  216. "原始问题": message.text,
  217. "匹配 Skill": current.selected_skill,
  218. "ODPS instance_id": result.instance_id,
  219. "返回行数": len(frame.index),
  220. "是否截断": result.truncated,
  221. "生成时间": datetime.now().isoformat(timespec="seconds"),
  222. }
  223. artifact = await self.skills.format_report(
  224. current.selected_skill,
  225. current.parameters,
  226. run_dir,
  227. frame,
  228. current.sql or "",
  229. info,
  230. )
  231. await self._publish_result(
  232. message,
  233. run_id,
  234. thread_id,
  235. current.title,
  236. current.selected_skill,
  237. artifact.dataframe,
  238. artifact.xlsx_path,
  239. result.instance_id,
  240. result.truncated,
  241. )
  242. async def _publish_result(
  243. self,
  244. message: IncomingMessage,
  245. run_id: str,
  246. thread_id: str,
  247. title: str,
  248. selected_skill: str,
  249. frame: pd.DataFrame,
  250. xlsx_path: Path,
  251. instance_id: str,
  252. truncated: bool,
  253. ) -> None:
  254. profile = build_profile(frame, truncated)
  255. try:
  256. thread_id, analysis = await self.codex.analyze(thread_id, message.text, profile)
  257. self.state.set_thread(message.conversation_key, thread_id)
  258. except Exception as exc:
  259. logger.error("Codex result analysis failed; using deterministic summary: %s", self._safe_error(exc))
  260. analysis = QueryAnalysis(
  261. summary=f"查询完成,共返回 {len(frame.index)} 行、{len(frame.columns)} 列。",
  262. highlights=[],
  263. caveats=["结果达到行数上限,表格仅包含前若干行。"] if truncated else [],
  264. )
  265. summary_payload = analysis.model_dump()
  266. summary_payload["profile"] = profile
  267. run_dir = self.settings.runtime_dir / "runs" / run_id
  268. write_json(run_dir / "summary.json", summary_payload)
  269. self.state.update_run(
  270. run_id,
  271. "publishing",
  272. instance_id=instance_id,
  273. metadata={"skill": selected_skill, "rows": len(frame.index), "truncated": truncated},
  274. )
  275. await self.feishu.reply_text(message.message_id, "查询完成,正在生成并发布飞书表格…")
  276. url = await self.feishu.publish_sheet(xlsx_path, title)
  277. self.state.update_run(
  278. run_id,
  279. "completed",
  280. feishu_url=url,
  281. metadata={"skill": selected_skill, "rows": len(frame.index), "truncated": truncated},
  282. )
  283. lines = [analysis.summary]
  284. lines.extend(f"- {item}" for item in analysis.highlights)
  285. lines.extend(f"- 注意:{item}" for item in analysis.caveats)
  286. lines.append(f"\n返回 **{len(frame.index)}** 行" + ("(已截断)" if truncated else ""))
  287. await self.feishu.reply_card(message.message_id, title, "\n".join(lines)[:5000], url)
  288. @staticmethod
  289. def _safe_error(exc: Exception) -> str:
  290. text = str(exc).replace("\n", " ")
  291. text = re.sub(r"(?i)(access[_ -]?key|secret|token)\s*[:=]\s*\S+", r"\1=<redacted>", text)
  292. return text[:500] or type(exc).__name__