test_data_analysis.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. """Unit tests for production workbook profiling and QA context generation."""
  2. from __future__ import annotations
  3. import json
  4. import threading
  5. import tempfile
  6. import unittest
  7. from pathlib import Path
  8. from openpyxl import Workbook
  9. from step1_data_aggregation.data_analysis import analyze_production_data, find_candidate_keys
  10. from step1_data_aggregation.template_mapping import DMS_FIELD, DMS_MODEL, MAPPING_SHEET, TEMPLATE_FIELD
  11. from step3_qa_agent.agent.data_context import production_data_context_text
  12. def write_template(path: Path) -> None:
  13. workbook = Workbook()
  14. sheet = workbook.active
  15. sheet.title = MAPPING_SHEET
  16. sheet.append([TEMPLATE_FIELD, DMS_MODEL, DMS_FIELD, "含义"])
  17. sheet.append(["姓名", "Group-People", "c_name", "员工姓名"])
  18. sheet.append(["部门", "Group-People", "c_org", "所属部门"])
  19. workbook.save(path)
  20. workbook.close()
  21. def write_production(path: Path) -> None:
  22. workbook = Workbook()
  23. sheet = workbook.active
  24. sheet.title = "数据"
  25. sheet.append(["姓名", "部门"])
  26. sheet.append(["Alice", "工程部"])
  27. sheet.append(["Bob", None])
  28. workbook.save(path)
  29. workbook.close()
  30. class DataAnalysisTests(unittest.TestCase):
  31. def test_profiles_without_sending_raw_values_and_builds_qa_context(self) -> None:
  32. with tempfile.TemporaryDirectory() as raw_dir:
  33. root = Path(raw_dir)
  34. template_dir = root / "templates"
  35. production_dir = root / "production"
  36. template_dir.mkdir()
  37. production_dir.mkdir()
  38. write_template(template_dir / "人员.xlsx")
  39. write_production(production_dir / "人员.xlsx")
  40. captured_user = ""
  41. def fake_analyzer(system: str, user: str):
  42. nonlocal captured_user
  43. captured_user = user
  44. self.assertIn("不得猜测", system)
  45. return {
  46. "dataset_summary": "人员基础数据,部门字段存在缺失。",
  47. "routing_terms": ["人员", "员工"],
  48. "quality_assessment": {
  49. "score": 75,
  50. "level": "fair",
  51. "strengths": ["姓名完整"],
  52. "issues": [
  53. {
  54. "severity": "medium",
  55. "field": "部门",
  56. "issue": "存在缺失",
  57. "evidence": "缺失率50%",
  58. "recommendation": "补齐部门",
  59. }
  60. ],
  61. },
  62. "fields": [
  63. {
  64. "field": name,
  65. "business_meaning": meaning,
  66. "data_characteristics": "文本字段",
  67. "quality_findings": [],
  68. "qa_usage": {
  69. "query_intents": ["筛选"],
  70. "filterable": True,
  71. "aggregatable": False,
  72. "join_candidate": name == "姓名",
  73. "cautions": [],
  74. },
  75. }
  76. for name, meaning in (("姓名", "员工姓名"), ("部门", "所属部门"))
  77. ],
  78. "record_granularity": {
  79. "one_row_represents": "一名员工",
  80. "candidate_business_key": ["姓名"],
  81. "cardinality_notes": ["员工与部门为 N:1"],
  82. "confidence": "medium",
  83. "evidence": ["姓名在观察数据中唯一,但仍需业务确认"],
  84. },
  85. "dataset_qa_guidance": {
  86. "suitable_questions": ["按部门筛选人员"],
  87. "unsupported_or_risky_questions": [],
  88. "interpretation_notes": ["部门缺失会导致漏计"],
  89. },
  90. }
  91. results = analyze_production_data(
  92. template_dir=template_dir,
  93. production_dir=production_dir,
  94. analyzer=fake_analyzer,
  95. model="fake-model",
  96. )
  97. self.assertEqual(len(results), 1)
  98. self.assertNotIn("Alice", captured_user)
  99. self.assertNotIn("Bob", captured_user)
  100. self.assertNotIn("c_name", captured_user)
  101. self.assertNotIn(str(production_dir), captured_user)
  102. payload = json.loads(Path(results[0].profile_file).read_text(encoding="utf-8"))
  103. self.assertFalse(payload["profile"]["privacy"]["raw_values_sent_to_llm"])
  104. self.assertEqual(payload["profile"]["fields"][1]["missing_rate"], 0.5)
  105. self.assertEqual(
  106. payload["profile"]["record_grain_profile"]["candidate_keys"][0]["fields"],
  107. ["姓名"],
  108. )
  109. context_path = production_dir / "analysis" / "qa_data_context.json"
  110. context = production_data_context_text(context_path, question="按部门筛选人员")
  111. self.assertIn("人员", context)
  112. self.assertIn("记录唯一性依据(候选业务主键):姓名", context)
  113. self.assertIn("部门缺失会导致漏计", context)
  114. self.assertNotIn("Alice", context)
  115. self.assertEqual(
  116. production_data_context_text(context_path, question="项目到期"), ""
  117. )
  118. def test_finds_minimal_composite_candidate_key(self) -> None:
  119. candidates = find_candidate_keys(
  120. ["工号", "证书"],
  121. [("E001", "A"), ("E001", "B"), ("E002", "A")],
  122. )
  123. self.assertEqual(candidates[0]["fields"], ["工号", "证书"])
  124. def test_analyzes_independent_workbooks_concurrently(self) -> None:
  125. with tempfile.TemporaryDirectory() as raw_dir:
  126. root = Path(raw_dir)
  127. template_dir = root / "templates"
  128. production_dir = root / "production"
  129. template_dir.mkdir()
  130. production_dir.mkdir()
  131. for name in ("人员", "项目"):
  132. write_template(template_dir / f"{name}.xlsx")
  133. write_production(production_dir / f"{name}.xlsx")
  134. barrier = threading.Barrier(2, timeout=3)
  135. thread_names: set[str] = set()
  136. lock = threading.Lock()
  137. def fake_analyzer(system: str, user: str):
  138. del system, user
  139. with lock:
  140. thread_names.add(threading.current_thread().name)
  141. barrier.wait()
  142. return {
  143. "dataset_summary": "测试数据。",
  144. "routing_terms": ["测试"],
  145. "quality_assessment": {
  146. "score": 80,
  147. "level": "good",
  148. "strengths": [],
  149. "issues": [],
  150. },
  151. "fields": [
  152. {
  153. "field": name,
  154. "business_meaning": meaning,
  155. "data_characteristics": "文本",
  156. "quality_findings": [],
  157. "qa_usage": {
  158. "query_intents": ["筛选"],
  159. "filterable": True,
  160. "aggregatable": False,
  161. "join_candidate": name == "姓名",
  162. "cautions": [],
  163. },
  164. }
  165. for name, meaning in (("姓名", "员工姓名"), ("部门", "所属部门"))
  166. ],
  167. "record_granularity": {
  168. "one_row_represents": "一条测试记录",
  169. "candidate_business_key": ["姓名+部门"],
  170. "cardinality_notes": [],
  171. "confidence": "low",
  172. "evidence": [],
  173. },
  174. "dataset_qa_guidance": {
  175. "suitable_questions": [],
  176. "unsupported_or_risky_questions": [],
  177. "interpretation_notes": [],
  178. },
  179. }
  180. results = analyze_production_data(
  181. template_dir=template_dir,
  182. production_dir=production_dir,
  183. analyzer=fake_analyzer,
  184. model="fake-model",
  185. max_workers=2,
  186. )
  187. self.assertEqual(len(results), 2)
  188. self.assertEqual(len(thread_names), 2)
  189. payload = json.loads(Path(results[0].profile_file).read_text(encoding="utf-8"))
  190. self.assertEqual(
  191. payload["llm_analysis"]["record_granularity"]["candidate_business_key"],
  192. ["姓名", "部门"],
  193. )
  194. if __name__ == "__main__":
  195. unittest.main()