test_step2_production.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. """Offline Step2 regressions with synthetic workbooks and fake external services."""
  2. from __future__ import annotations
  3. import json
  4. import os
  5. import tempfile
  6. import unittest
  7. from pathlib import Path
  8. from types import SimpleNamespace
  9. from unittest.mock import patch
  10. import numpy as np
  11. from openpyxl import Workbook
  12. from step2_graph_building.graph.production import (
  13. Rule, identifier, load_rules, match_rule, prepare_graph, read_production, write_graph,
  14. )
  15. from step2_graph_building.meta.production import build_schema, export_schema, load_production_schema
  16. from step2_graph_building.production_pipeline import build_production_graph
  17. def workbook(path, title, headers, rows):
  18. wb = Workbook()
  19. ws = wb.active
  20. ws.title = title
  21. ws.append(headers)
  22. for row in rows:
  23. ws.append(row)
  24. wb.save(path)
  25. wb.close()
  26. def fake_analyzer(system, user):
  27. data = json.loads(user)
  28. return {kind: [{'id': item['id'], 'description': '合成数据的语义说明'} for item in data[kind]]
  29. for kind in ('nodes', 'relations')}
  30. class FakeDriver:
  31. def __init__(self, fail=False):
  32. self.calls = []
  33. self.nodes, self.edges = set(), set()
  34. self.fail = fail
  35. def execute_query(self, query, **params):
  36. self.calls.append((query, params))
  37. if 'SET n += row.props' in query:
  38. self.nodes.update(r['id'] for r in params['rows'])
  39. if 'SET r._kg_method' in query:
  40. if self.fail:
  41. raise RuntimeError('synthetic database failure')
  42. self.edges.update((r['source'], r['target'], params['rule']) for r in params['rows'])
  43. if 'count(DISTINCT n)' in query:
  44. return SimpleNamespace(records=[{'nodes': len(self.nodes), 'edges': len(self.edges)}])
  45. return SimpleNamespace(records=[])
  46. class ProductionGraphTests(unittest.TestCase):
  47. def setUp(self):
  48. self.tmp = tempfile.TemporaryDirectory()
  49. self.root = Path(self.tmp.name)
  50. self.templates = self.root / 'templates'
  51. self.production = self.root / 'production'
  52. self.output = self.root / 'output'
  53. self.templates.mkdir()
  54. self.production.mkdir()
  55. self.fields = {'人员信息': ['工号', '姓名', '服务项目'], '项目信息': ['项目编号', '项目名称', '上级编号']}
  56. for name, fields in self.fields.items():
  57. workbook(self.templates / f'{name}.xlsx', '字段来源',
  58. ['模板字段', 'DMS模型', 'DMS字段', '含义'],
  59. [(f, '模型', f'c_{i}', '') for i, f in enumerate(fields)])
  60. self.people = [('A', '合成人员甲', '北园 / 南园'), ('B', '合成人员乙', None),
  61. ('C', '合成人员丙', '园'), ('D', '合成人员丁', '')]
  62. self.projects = [('P1', '北园', None), ('P2', '南园', 'P1'), ('P3', '东园', '')]
  63. self.write_data()
  64. self.relations = self.root / 'relation.xlsx'
  65. workbook(self.relations, '关系', ['起点', '终点', '连接字段', '判断方法', '边名'],
  66. [('人员信息', '项目信息', '服务项目-项目名称', '包含', '服务于'),
  67. ('项目信息', '项目信息', '上级编号-项目编号', '相等', '父项目为')])
  68. self.keys = {'人员信息': ['工号'], '项目信息': ['项目编号']}
  69. self.key_path = self.root / 'keys.json'
  70. self.key_path.write_text(json.dumps(self.keys), encoding='utf-8')
  71. self.env = patch.dict(os.environ, {'STEP2_RELATION_SIMILARITY_THRESHOLD': '0.8'})
  72. self.env.start()
  73. def tearDown(self):
  74. self.env.stop()
  75. self.tmp.cleanup()
  76. def write_data(self):
  77. for name, rows in [('人员信息', self.people), ('项目信息', self.projects)]:
  78. workbook(self.production / f'{name}.xlsx', '数据', self.fields[name], rows)
  79. def graph(self):
  80. return prepare_graph(self.templates, self.production, self.relations, keys=self.keys, threshold=.8)
  81. def pipeline(self, **kwargs):
  82. return build_production_graph(template_dir=self.templates, production_dir=self.production,
  83. relation_path=self.relations, keys_path=self.key_path, output_dir=self.output, **kwargs)
  84. def test_exact_contains_direction_and_blank(self):
  85. graph = self.graph()
  86. self.assertEqual([s['count'] for s in graph['relation_stats']], [5, 1])
  87. rule = graph['rules'][1]
  88. project_ids = {n['properties']['项目编号']: n['id'] for n in graph['nodes']['项目信息']}
  89. self.assertEqual(graph['edges'][rule.id], [{'source': project_ids['P2'], 'target': project_ids['P1'], 'score': None}])
  90. self.assertEqual(graph['relation_stats'][0]['unmatched_source_nodes'], 2)
  91. rule = Rule('A', 'B', 'v', 'v', '相等', 'r', 'test')
  92. nodes = {'A': [{'id': 'a', 'properties': {'v': ' x '}}],
  93. 'B': [{'id': 'b', 'properties': {'v': 'x'}}]}
  94. self.assertEqual(list(match_rule(rule, nodes, .8, None)), [])
  95. def test_merge_preserves_provenance_and_identity_across_reorder(self):
  96. original = self.graph()
  97. self.people.append(self.people[0])
  98. self.write_data()
  99. merged = self.graph()
  100. self.assertEqual(merged['node_stats']['人员信息']['source_rows'], 5)
  101. self.assertEqual(merged['node_stats']['人员信息']['node_count'], 4)
  102. self.assertEqual(merged['nodes']['人员信息'][0]['rows'], [2, 6])
  103. self.people.reverse()
  104. self.write_data()
  105. reordered = self.graph()
  106. self.assertEqual({n['id'] for n in original['nodes']['人员信息']},
  107. {n['id'] for n in reordered['nodes']['人员信息']})
  108. def test_different_values_merge_without_losing_relationships(self):
  109. self.people += [('A', '不同属性', '北园'), ('C', '冲突', '园'), ('B', '合成人员乙', '东园')]
  110. self.write_data()
  111. graph = self.graph()
  112. stats = graph['node_stats']['人员信息']
  113. self.assertEqual(stats['node_count'], 4)
  114. self.assertEqual(stats['merged_rows'], 3)
  115. self.assertEqual(stats['merged_key_groups'], 3)
  116. person = next(n for n in graph['nodes']['人员信息'] if n['properties']['工号'] == 'A')
  117. self.assertEqual(set(person['properties']['服务项目']), {'北园 / 南园', '北园'})
  118. self.assertEqual(person['rows'], [2, 6])
  119. self.assertEqual(graph['relation_stats'][0]['count'], 6)
  120. driver = FakeDriver()
  121. result = self.pipeline(driver=driver, analyzer=fake_analyzer)
  122. self.assertTrue(result['ok'])
  123. schema = load_production_schema(self.output / 'meta_graph_schema.json')
  124. self.assertEqual(schema['nodes'][0]['multivalue_fields']['服务项目'], 1)
  125. self.people.reverse()
  126. self.write_data()
  127. again = self.graph()
  128. original = {n['id']: n['properties'] for n in graph['nodes']['人员信息']}
  129. self.assertEqual(original, {n['id']: n['properties'] for n in again['nodes']['人员信息']})
  130. def test_multivalued_exact_and_score_deduplicate_edges(self):
  131. from step2_graph_building.graph.production import neo4j_properties, merge_value
  132. self.assertEqual(merge_value(None, 'x'), 'x')
  133. self.assertEqual(merge_value('x', ''), 'x')
  134. self.assertEqual(merge_value('x', 'x'), 'x')
  135. self.assertEqual(merge_value(0, None), 0)
  136. mixed = neo4j_properties({'x': [1, '1']})
  137. self.assertEqual(json.loads(mixed['_kg_mixed_values_json']), {'x': [1, '1']})
  138. rule = Rule('A', 'B', 'v', 'v', '相等', 'r', 'test')
  139. nodes = {'A': [{'id': 'a', 'properties': {'v': ['x', 'y']}}],
  140. 'B': [{'id': 'b', 'properties': {'v': ['y', 'z']}}]}
  141. self.assertEqual(len(list(match_rule(rule, nodes, .8, None))), 1)
  142. self.people += [('A', '合成人员甲', '北园'), ('A', '合成人员甲', '南园')]
  143. self.write_data()
  144. workbook(self.relations, '关系', ['起点', '终点', '连接字段', '判断方法', '边名'],
  145. [('人员信息', '项目信息', '服务项目-项目名称', '评分', '相似于')])
  146. vectors = {'北园 / 南园': [.9, .1], '北园': [1, 0], '南园': [.85, .2],
  147. '园': [0, 1], '东园': [0, 1]}
  148. encoder = SimpleNamespace(encode=lambda texts: np.array([vectors[t] for t in texts]))
  149. graph = prepare_graph(self.templates, self.production, self.relations,
  150. keys=self.keys, threshold=.8, encoder=encoder)
  151. person = next(n['id'] for n in graph['nodes']['人员信息'] if n['properties']['工号'] == 'A')
  152. edges = [e for e in graph['edges'][graph['rules'][0].id] if e['source'] == person]
  153. self.assertEqual(len(edges), 2)
  154. self.assertTrue(all(abs(e['score'] - 1) < 1e-12 for e in edges))
  155. def test_missing_keys_warn_skip_and_preserve_valid_graph(self):
  156. original = self.graph()
  157. self.people += [(None, '缺编号', '园'), (' ', '空白编号', '园'),
  158. self.people[0], (None, None, None)]
  159. self.write_data()
  160. before = (self.production / '人员信息.xlsx').read_bytes()
  161. with self.assertLogs('step2_graph_building.graph.production', level='WARNING') as logs:
  162. graph = self.graph()
  163. self.assertEqual(graph['nodes'], original['nodes'] | {
  164. '人员信息': [dict(n, rows=[2, 8]) if n['properties']['工号'] == 'A' else n
  165. for n in original['nodes']['人员信息']]})
  166. self.assertEqual(graph['edges'], original['edges'])
  167. self.assertEqual(graph['skipped_rows'], 2)
  168. self.assertEqual([w['row'] for w in graph['warnings']], [6, 7])
  169. self.assertTrue(all(w['fields'] == ['工号'] and w['action'] == 'skipped'
  170. for w in graph['warnings']))
  171. stats = graph['node_stats']['人员信息']
  172. self.assertEqual((stats['source_rows'], stats['skipped_rows'], stats['merged_rows'], stats['node_count']),
  173. (7, 2, 1, 4))
  174. self.assertIn('第 6 行', '\n'.join(logs.output))
  175. self.assertEqual((self.production / '人员信息.xlsx').read_bytes(), before)
  176. result = self.pipeline(check_only=True)
  177. self.assertTrue(result['ok'])
  178. saved = json.loads((self.output / 'step2_build_report.json').read_text(encoding='utf-8'))
  179. self.assertEqual(saved['warnings'], graph['warnings'])
  180. captured = []
  181. def analyzer(system, user):
  182. captured.append(user)
  183. return fake_analyzer(system, user)
  184. build_schema(graph, analyzer=analyzer, model='fake', build_id='test')
  185. self.assertNotIn(str(self.production), captured[0])
  186. self.assertNotIn('缺编号', captured[0])
  187. def test_composite_keys_only_report_missing_parts_and_allow_zero(self):
  188. self.keys['人员信息'] = ['工号', '姓名']
  189. self.people = [('A', None, '园'), (0, '零编号', '园')]
  190. self.write_data()
  191. graph = self.graph()
  192. self.assertEqual(graph['warnings'][0]['fields'], ['姓名'])
  193. self.assertEqual(graph['nodes']['人员信息'][0]['properties']['工号'], 0)
  194. self.assertEqual(graph['node_stats']['人员信息']['merged_rows'], 0)
  195. with self.assertRaises(ValueError):
  196. read_production(self.templates, self.production, keys={})
  197. with self.assertRaises(ValueError):
  198. read_production(self.templates, self.production, keys={**self.keys, '未知模板': ['编号']})
  199. def test_all_missing_keys_keeps_empty_template_without_false_merges(self):
  200. self.people = [(None, '缺编号', '园')]
  201. self.write_data()
  202. graph = self.graph()
  203. self.assertEqual(graph['nodes']['人员信息'], [])
  204. self.assertEqual(graph['node_stats']['人员信息']['merged_rows'], 0)
  205. self.assertEqual(graph['node_stats']['人员信息']['skipped_rows'], 1)
  206. self.assertEqual(graph['relation_stats'][0]['count'], 0)
  207. def test_bad_relations_and_schema_rejected(self):
  208. for row in [
  209. ('人员信息', '未知', '服务项目-项目名称', '包含', '服务于'),
  210. ('人员信息', '项目信息', '不存在-项目名称', '包含', '服务于'),
  211. ('人员信息', '项目信息', '服务项目-项目名称', '相似', '服务于'),
  212. ]:
  213. workbook(self.relations, '关系', ['起点', '终点', '连接字段', '判断方法', '边名'], [row])
  214. with self.assertRaises(ValueError):
  215. self.graph()
  216. workbook(self.production / '人员信息.xlsx', '数据', ['错误表头'], [('x',)])
  217. with self.assertRaises(ValueError):
  218. read_production(self.templates, self.production, keys=self.keys)
  219. def test_score_cosine_strict_threshold_all_matches(self):
  220. rule = Rule('A', 'B', 'v', 'v', '评分', '相似于', 'test')
  221. nodes = {'A': [{'id': 'a', 'properties': {'v': 'source'}}],
  222. 'B': [{'id': key, 'properties': {'v': key}} for key in ['equal', 'higher', 'lower', 'same']]}
  223. vectors = {'source': [1, 0], 'equal': [.8, .6], 'higher': [.9, .1],
  224. 'lower': [.7, .7], 'same': [1, 0]}
  225. encoder = SimpleNamespace(encode=lambda texts: np.array([vectors[t] for t in texts]))
  226. matches = list(match_rule(rule, nodes, .8, encoder))
  227. self.assertEqual({m['target'] for m in matches}, {'higher', 'same'})
  228. self.assertTrue(all(m['score'] > .8 for m in matches))
  229. with self.assertRaises(ValueError):
  230. prepare_graph(self.templates, self.production, self.relations, keys=self.keys, threshold=float('nan'))
  231. def test_meta_deterministic_topology_and_no_raw_values(self):
  232. graph = self.graph()
  233. captured = []
  234. def analyzer(system, user):
  235. captured.append(user)
  236. return fake_analyzer(system, user)
  237. schema = build_schema(graph, analyzer=analyzer, model='fake', build_id='test')
  238. self.assertNotIn('合成人员甲', captured[0])
  239. self.assertNotIn('北园 / 南园', captured[0])
  240. self.assertNotIn(str(self.root), captured[0])
  241. self.assertEqual(schema['nodes'][0]['attributes'], self.fields['人员信息'])
  242. self.assertEqual(schema['relations'][0]['count'], 5)
  243. export_schema(schema, self.output)
  244. self.assertEqual(load_production_schema(self.output / 'meta_graph_schema.json'), schema)
  245. display = json.loads((self.output / 'meta_graph_schema_display.json').read_text(encoding='utf-8'))
  246. self.assertEqual(len(display['nodes']), 2)
  247. self.assertEqual(len(display['relations']), 2)
  248. for response in ({}, {'nodes': [], 'relations': []}):
  249. with self.assertRaises(ValueError):
  250. build_schema(graph, analyzer=lambda *_: response, model='bad', build_id='test')
  251. def fabricated(system, user):
  252. result = fake_analyzer(system, user)
  253. result['nodes'][0]['attributes'] = ['invented']
  254. return result
  255. with self.assertRaises(ValueError):
  256. build_schema(graph, analyzer=fabricated, model='bad', build_id='test')
  257. def test_batched_database_order_idempotence_and_identifier_escape(self):
  258. graph = self.graph()
  259. driver = FakeDriver()
  260. self.assertEqual(write_graph(graph, driver, build_id='test', batch_size=2), {'nodes': 7, 'edges': 6})
  261. self.assertEqual(write_graph(graph, driver, build_id='test', batch_size=2), {'nodes': 7, 'edges': 6})
  262. first_edge = next(i for i,(q,_) in enumerate(driver.calls) if 'SET r._kg_method' in q)
  263. self.assertEqual(sum(len(p['rows']) for q,p in driver.calls[:first_edge] if 'SET n += row.props' in q), 7)
  264. self.assertEqual(identifier('a`b'), '`a``b`')
  265. def test_pipeline_publish_and_rollback(self):
  266. driver = FakeDriver()
  267. result = self.pipeline(driver=driver, analyzer=fake_analyzer, model='fake')
  268. self.assertTrue(result['ok'])
  269. self.assertTrue(result['schema_published'])
  270. failed = FakeDriver(fail=True)
  271. before = (self.output / 'meta_graph_schema.json').read_bytes()
  272. result = self.pipeline(driver=failed, analyzer=fake_analyzer, model='fake')
  273. self.assertFalse(result['ok'])
  274. self.assertTrue(result['staging_rolled_back'])
  275. self.assertEqual((self.output / 'meta_graph_schema.json').read_bytes(), before)
  276. self.assertFalse(any('WHERE n._kg_build <>' in q for q,_ in failed.calls))
  277. def test_ambiguous_activation_keeps_new_build_for_recovery(self):
  278. class UncertainDriver(FakeDriver):
  279. def execute_query(self, query, **params):
  280. result = super().execute_query(query, **params)
  281. if 'WHERE n._kg_build <>' in query:
  282. raise RuntimeError('activation response lost')
  283. return result
  284. driver = UncertainDriver()
  285. result = self.pipeline(driver=driver, analyzer=fake_analyzer, model='fake')
  286. self.assertFalse(result['ok'])
  287. self.assertEqual(result['stage'], 'neo4j_activation')
  288. self.assertNotIn('staging_rolled_back', result)
  289. self.assertFalse(any('{_kg_build:$build}) DETACH DELETE' in q for q,_ in driver.calls))
  290. self.assertTrue((self.output / 'step2_schema_candidate.json').exists())
  291. def test_qa_schema_uses_production_names(self):
  292. from step2_graph_building.meta.schema import llm_schema_entities, llm_schema_relations
  293. schema = build_schema(self.graph(), analyzer=fake_analyzer, model='fake', build_id='test')
  294. with patch('step2_graph_building.meta.production.load_production_schema', return_value=schema):
  295. self.assertIn('人员信息', llm_schema_entities())
  296. self.assertIn('(人员信息)-[:服务于]->(项目信息)', llm_schema_relations())
  297. self.assertNotIn('有考勤', llm_schema_relations())
  298. def test_check_only_and_llm_failure_never_write_database(self):
  299. driver = FakeDriver()
  300. result = self.pipeline(check_only=True, driver=driver)
  301. self.assertTrue(result['ok'])
  302. self.assertFalse(driver.calls)
  303. result = self.pipeline(driver=driver, analyzer=lambda *_: {})
  304. self.assertFalse(result['ok'])
  305. self.assertEqual(result['stage'], 'meta_generation')
  306. self.assertFalse(driver.calls)
  307. if __name__ == '__main__':
  308. unittest.main(verbosity=2)