sql_store.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. from __future__ import annotations
  2. import csv
  3. import re
  4. import sqlite3
  5. from datetime import date, timedelta
  6. from pathlib import Path
  7. from app.config import PROJECT_ROOT
  8. from app.schemas import Evidence
  9. SCHEMA_SQL = """
  10. CREATE TABLE IF NOT EXISTS incidents (
  11. id INTEGER PRIMARY KEY AUTOINCREMENT,
  12. occurred_at TEXT NOT NULL,
  13. incident_id TEXT NOT NULL UNIQUE,
  14. service_name TEXT NOT NULL,
  15. error_type TEXT NOT NULL,
  16. client_os TEXT NOT NULL,
  17. status TEXT NOT NULL,
  18. resolution TEXT NOT NULL
  19. );
  20. CREATE INDEX IF NOT EXISTS idx_incidents_service_time
  21. ON incidents(service_name, occurred_at);
  22. CREATE INDEX IF NOT EXISTS idx_incidents_resolution
  23. ON incidents(resolution);
  24. CREATE INDEX IF NOT EXISTS idx_incidents_error_type
  25. ON incidents(error_type);
  26. """
  27. DEFAULT_SOURCE_PATH = PROJECT_ROOT / "data" / "source" / "incidents.csv"
  28. INCIDENT_ID_PATTERN = re.compile(r"^INC\d+$")
  29. SERVICE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$")
  30. ERROR_TYPE_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_]{1,63}$")
  31. CLIENT_OS_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._/+-]{1,63}$")
  32. ALLOWED_STATUSES = {"resolved", "open", "in_progress", "pending", "closed", "escalated"}
  33. ACTIVE_STATUSES = ("open", "in_progress", "resolved")
  34. def load_incidents(
  35. source_path: Path,
  36. ) -> list[tuple[date, str, str, str, str, str, str]]:
  37. """从脱敏 CSV 读取工单记录并转换为 (date, ...) 元组。
  38. 返回字段顺序与 `SCHEMA_SQL` 列顺序一致(除自增 id),
  39. 便于 `initialize_database` 直接复用 executemany。
  40. """
  41. if not source_path.exists():
  42. raise FileNotFoundError(f"SQLite 原始工单不存在:{source_path}")
  43. # 相对天数在导入时转换,保证"最近 30 天"样例不会随课程日期过期。
  44. today = date.today()
  45. rows: list[tuple[date, str, str, str, str, str, str]] = []
  46. with source_path.open("r", encoding="utf-8-sig", newline="") as file:
  47. reader = csv.DictReader(file)
  48. required_fields = {
  49. "days_ago",
  50. "incident_id",
  51. "service_name",
  52. "error_type",
  53. "client_os",
  54. "status",
  55. "resolution",
  56. }
  57. if not reader.fieldnames or not required_fields <= set(reader.fieldnames):
  58. raise ValueError("incidents.csv 缺少必需字段")
  59. for line_number, item in enumerate(reader, start=2):
  60. try:
  61. days_ago = int(item["days_ago"])
  62. except (TypeError, ValueError) as exc:
  63. raise ValueError(f"第 {line_number} 行 days_ago 非法") from exc
  64. if not 0 <= days_ago <= 3650:
  65. raise ValueError(f"第 {line_number} 行 days_ago 超出范围")
  66. incident_id = item["incident_id"].strip().upper()
  67. service_name = item["service_name"].strip()
  68. error_type = item["error_type"].strip()
  69. client_os = item["client_os"].strip()
  70. status = item["status"].strip().lower()
  71. resolution = item["resolution"].strip()
  72. if not INCIDENT_ID_PATTERN.fullmatch(incident_id):
  73. raise ValueError(f"第 {line_number} 行 incident_id 非法")
  74. if not SERVICE_NAME_PATTERN.fullmatch(service_name):
  75. raise ValueError(f"第 {line_number} 行 service_name 非法")
  76. if not ERROR_TYPE_PATTERN.fullmatch(error_type):
  77. raise ValueError(f"第 {line_number} 行 error_type 非法")
  78. if not CLIENT_OS_PATTERN.fullmatch(client_os):
  79. raise ValueError(f"第 {line_number} 行 client_os 非法")
  80. if not resolution:
  81. raise ValueError(f"第 {line_number} 行 resolution 为空")
  82. if len(resolution) > 64:
  83. raise ValueError(f"第 {line_number} 行 resolution 过长")
  84. if status not in ALLOWED_STATUSES:
  85. raise ValueError(f"第 {line_number} 行 status 非法")
  86. rows.append(
  87. (
  88. today - timedelta(days=days_ago),
  89. incident_id,
  90. service_name,
  91. error_type,
  92. client_os,
  93. status,
  94. resolution,
  95. )
  96. )
  97. if not rows:
  98. raise ValueError("incidents.csv 没有数据")
  99. return rows
  100. def initialize_database(
  101. path: Path,
  102. source_path: Path | None = None,
  103. reset: bool = False,
  104. ) -> int:
  105. path.parent.mkdir(parents=True, exist_ok=True)
  106. if reset and path.exists():
  107. path.unlink()
  108. # CSV 是可审计真源,SQLite 只负责课程中的精确过滤和聚合。
  109. rows = load_incidents(source_path or DEFAULT_SOURCE_PATH)
  110. with sqlite3.connect(path) as connection:
  111. connection.executescript(SCHEMA_SQL)
  112. current = connection.execute("SELECT COUNT(*) FROM incidents").fetchone()[0]
  113. if current == 0:
  114. connection.executemany(
  115. """
  116. INSERT INTO incidents(
  117. occurred_at, incident_id, service_name,
  118. error_type, client_os, status, resolution
  119. )
  120. VALUES (?, ?, ?, ?, ?, ?, ?)
  121. """,
  122. [
  123. (
  124. item[0].isoformat(),
  125. item[1],
  126. item[2],
  127. item[3],
  128. item[4],
  129. item[5],
  130. item[6],
  131. )
  132. for item in rows
  133. ],
  134. )
  135. return connection.execute("SELECT COUNT(*) FROM incidents").fetchone()[0]
  136. class IncidentRepository:
  137. """企业 IT 工单聚合查询,统一返回结构化结果与可引用 Evidence。"""
  138. def __init__(self, path: Path) -> None:
  139. self.path = path
  140. def summarize_recent(
  141. self,
  142. service_name: str,
  143. days: int = 30,
  144. error_type: str = "",
  145. client_os: str = "",
  146. ) -> tuple[dict, Evidence]:
  147. if not self.path.exists():
  148. raise RuntimeError(
  149. "业务数据库不存在,请先执行 uv run python scripts/prepare_data.py"
  150. )
  151. normalized_service = service_name.strip()
  152. if not SERVICE_NAME_PATTERN.fullmatch(normalized_service):
  153. raise ValueError("service_name 只能包含字母数字、下划线和连字符")
  154. if not 1 <= days <= 365:
  155. raise ValueError("days 必须在 1 到 365 之间")
  156. normalized_error = error_type.strip()
  157. if normalized_error and not ERROR_TYPE_PATTERN.fullmatch(normalized_error):
  158. raise ValueError("error_type 只能包含字母数字和下划线")
  159. if len(normalized_error) > 64:
  160. raise ValueError("error_type 过长")
  161. normalized_os = client_os.strip()
  162. if normalized_os and not CLIENT_OS_PATTERN.fullmatch(normalized_os):
  163. raise ValueError("client_os 非法")
  164. if len(normalized_os) > 64:
  165. raise ValueError("client_os 过长")
  166. start_date = (date.today() - timedelta(days=days)).isoformat()
  167. # SQL 模板固定并使用占位符;模型不能生成 SQL 或拼接 WHERE 表达式。
  168. # 单次查询按 resolution 分组返回,每组自带该组的 incident_ids 与 resolved 数;
  169. # Python 端再做二次聚合得到总数 / 已解决 / 仍需跟进,避免两次往返。
  170. sql = """
  171. SELECT resolution,
  172. COUNT(*) AS count,
  173. SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END) AS resolved_count,
  174. GROUP_CONCAT(incident_id) AS incident_ids
  175. FROM incidents
  176. WHERE service_name = ?
  177. AND occurred_at >= ?
  178. AND status IN (?, ?, ?)
  179. AND (? = '' OR error_type = ?)
  180. AND (? = '' OR client_os = ?)
  181. GROUP BY resolution
  182. ORDER BY count DESC, resolution ASC
  183. """
  184. parameters = (
  185. normalized_service,
  186. start_date,
  187. *ACTIVE_STATUSES,
  188. normalized_error,
  189. normalized_error,
  190. normalized_os,
  191. normalized_os,
  192. )
  193. with sqlite3.connect(self.path) as connection:
  194. connection.row_factory = sqlite3.Row
  195. rows = connection.execute(sql, parameters).fetchall()
  196. incident_ids: list[str] = []
  197. resolution_counts: dict[str, int] = {}
  198. total_count = 0
  199. resolved_count = 0
  200. for row in rows:
  201. count = int(row["count"])
  202. total_count += count
  203. resolved_count += int(row["resolved_count"] or 0)
  204. resolution = str(row["resolution"])
  205. resolution_counts[resolution] = count
  206. if row["incident_ids"]:
  207. incident_ids.extend(row["incident_ids"].split(","))
  208. # 仍需跟进:在已过滤 ACTIVE_STATUSES 之内,排除已解决即为仍需跟进。
  209. open_count = total_count - resolved_count
  210. result = {
  211. "service_name": normalized_service,
  212. "days": days,
  213. "error_type": normalized_error,
  214. "client_os": normalized_os,
  215. "incident_count": total_count,
  216. "resolved_count": resolved_count,
  217. "open_count": open_count,
  218. "resolution_counts": resolution_counts,
  219. "incident_ids": incident_ids,
  220. }
  221. scope_clauses: list[str] = []
  222. if normalized_error:
  223. scope_clauses.append(f"错误类型为“{normalized_error}”")
  224. if normalized_os:
  225. scope_clauses.append(f"客户端为“{normalized_os}”")
  226. scope_clause = (
  227. f"、{' 且 '.join(scope_clauses)}的" if scope_clauses else ""
  228. )
  229. if resolution_counts:
  230. resolution_summary = "、".join(
  231. f"{name}({count} 次)" for name, count in resolution_counts.items()
  232. )
  233. else:
  234. resolution_summary = "暂无解决记录"
  235. evidence = Evidence(
  236. source_type="sql",
  237. source="incidents",
  238. content=(
  239. f"服务 {normalized_service} 最近 {days} 天{scope_clause}工单共 "
  240. f"{total_count} 起,其中已解决 {resolved_count} 起、"
  241. f"仍需跟进 {open_count} 起;"
  242. f"采取的解决方案包括:{resolution_summary}。"
  243. ),
  244. metadata={"query_name": "summarize_recent_incidents", **result},
  245. )
  246. return result, evidence