production.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. """Template-driven production graph, independent of the legacy fixed schemas."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import logging
  6. import math
  7. from collections import Counter, defaultdict
  8. from contextlib import closing
  9. from dataclasses import asdict, dataclass
  10. from datetime import date, datetime
  11. from pathlib import Path
  12. import numpy as np
  13. from openpyxl import load_workbook
  14. from step1_data_aggregation.template_mapping import load_templates
  15. class GraphValidationError(ValueError):
  16. def __init__(self, issues):
  17. self.issues = issues
  18. super().__init__(f"生产数据校验失败,共 {len(issues)} 项;见构图报告")
  19. @dataclass(frozen=True)
  20. class Rule:
  21. source: str
  22. target: str
  23. source_field: str
  24. target_field: str
  25. method: str
  26. edge: str
  27. location: str
  28. @property
  29. def id(self) -> str:
  30. return digest([self.source, self.target, self.source_field,
  31. self.target_field, self.method, self.edge])
  32. def digest(value) -> str:
  33. return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True,
  34. allow_nan=False).encode()).hexdigest()
  35. def missing(value) -> bool:
  36. return value is None or isinstance(value, str) and not value.strip()
  37. def scalar(value):
  38. if isinstance(value, (datetime, date)):
  39. return value.isoformat()
  40. if isinstance(value, float) and not math.isfinite(value):
  41. return None
  42. return value
  43. def property_values(value):
  44. """Merged fields are alternatives, never concatenated matching text."""
  45. return [v for v in (value if isinstance(value, list) else [value]) if not missing(v)]
  46. def merge_value(left, right):
  47. values = {json.dumps(v, ensure_ascii=False, sort_keys=True, allow_nan=False): v
  48. for v in property_values(left) + property_values(right)}
  49. ordered = [values[k] for k in sorted(values)]
  50. return ordered[0] if len(ordered) == 1 else ordered or None
  51. def neo4j_properties(props):
  52. """Neo4j arrays must be homogeneous; retain mixed-type originals as JSON."""
  53. result = dict(props)
  54. mixed = {}
  55. for field, value in props.items():
  56. if isinstance(value, list) and len({type(v) for v in value}) > 1:
  57. mixed[field] = value
  58. result[field] = [json.dumps(v, ensure_ascii=False) for v in value]
  59. if mixed:
  60. result['_kg_mixed_values_json'] = json.dumps(mixed, ensure_ascii=False)
  61. return result
  62. def load_rules(path: Path, fields: dict[str, list[str]]) -> list[Rule]:
  63. required = ['起点', '终点', '连接字段', '判断方法', '边名']
  64. rules, seen = [], set()
  65. with closing(load_workbook(path, read_only=True, data_only=True)) as wb:
  66. for sheet in wb:
  67. rows = iter(sheet.values)
  68. headers = [str(v).strip() if v is not None else '' for v in next(rows, ())]
  69. if not any(headers):
  70. continue
  71. if len(headers) != 5 or set(headers) != set(required):
  72. raise ValueError(f'{path}:{sheet.title}: 必须包含且仅包含五个关系列 {required}')
  73. for rownum, values in enumerate(rows, 2):
  74. if all(missing(v) for v in values):
  75. continue
  76. row = dict(zip(headers, values))
  77. loc = f'{path}:{sheet.title}!{rownum}'
  78. if any(missing(row.get(k)) for k in required):
  79. raise ValueError(f'{loc}: 关系定义有空字段')
  80. src, dst, link, method, edge = [str(row[k]).strip() for k in required]
  81. if src not in fields or dst not in fields:
  82. raise ValueError(f'{loc}: 未知节点类型 {src}/{dst}')
  83. # Resolve against real field names; hyphens inside a field are valid.
  84. pairs = [(a, b) for a in fields[src] for b in fields[dst] if f'{a}-{b}' == link]
  85. if len(pairs) != 1:
  86. raise ValueError(f'{loc}: 连接字段不存在或有歧义: {link}')
  87. if method not in {'相等', '包含', '评分'}:
  88. raise ValueError(f'{loc}: 未知判断方法 {method}')
  89. rule = Rule(src, dst, *pairs[0], method, edge, loc)
  90. if rule.id in seen:
  91. raise ValueError(f'{loc}: 重复关系规则')
  92. seen.add(rule.id)
  93. rules.append(rule)
  94. if not rules:
  95. raise ValueError(f'{path}: 没有关系规则')
  96. return rules
  97. def read_production(template_dir: Path, production_dir: Path, *, keys=None, validation_warnings=None):
  98. """Merge valid keys; skip missing keys and optionally collect warning details."""
  99. templates = load_templates(template_dir)
  100. fields = {t.name: [f.template_field for f in t.fields] for t in templates}
  101. if set(keys or {}) - fields.keys():
  102. raise ValueError('主键配置包含未知模板')
  103. nodes, stats, issues = {}, {}, []
  104. for template in templates:
  105. name = template.name
  106. path = production_dir / f'{name}.xlsx'
  107. if any(f.startswith('_kg_') for f in fields[name]):
  108. raise ValueError(f'{name}: _kg_ 为保留属性前缀')
  109. primary = (keys or {}).get(name, [])
  110. if not isinstance(primary, list) or not primary or len(primary) != len(set(primary)) or any(f not in fields[name] for f in primary):
  111. raise ValueError(f'{name}: 无效主键配置')
  112. unique = {}
  113. source_count = 0
  114. skipped_rows = 0
  115. with closing(load_workbook(path, read_only=True, data_only=True)) as wb:
  116. if wb.sheetnames != ['数据']:
  117. raise ValueError(f'{path}: 需要且仅需要“数据”工作表')
  118. rows = iter(wb['数据'].values)
  119. headers = list(next(rows, ()))
  120. if headers != fields[name]:
  121. raise ValueError(f'{path}: 表头与当前模板不一致')
  122. for rownum, values in enumerate(rows, 2):
  123. if all(missing(v) for v in values):
  124. continue
  125. source_count += 1
  126. props = {k: scalar(v) for k, v in zip(headers, values)}
  127. missing_fields = [k for k in primary if missing(props[k])]
  128. if missing_fields:
  129. issues.append({'type': 'missing_key', 'template': name, 'file': str(path),
  130. 'sheet': '数据', 'row': rownum, 'fields': missing_fields,
  131. 'action': 'skipped'})
  132. skipped_rows += 1
  133. continue
  134. identity = [props[k] for k in primary]
  135. node_id = digest([name, identity])
  136. if node_id in unique:
  137. current = unique[node_id]['properties']
  138. for field in headers:
  139. current[field] = merge_value(current[field], props[field])
  140. unique[node_id]['rows'].append(rownum)
  141. else:
  142. unique[node_id] = {'id': node_id, 'properties': {k: merge_value(None, v) for k, v in props.items()}, 'rows': [rownum],
  143. 'file': str(path.resolve()), 'sheet': '数据'}
  144. nodes[name] = list(unique.values())
  145. stats[name] = {'source_rows': source_count, 'node_count': len(unique),
  146. 'identity_fields': primary, 'identity_mode': 'key',
  147. 'skipped_rows': skipped_rows,
  148. 'merged_rows': source_count - skipped_rows - len(unique),
  149. 'merged_key_groups': sum(len(n['rows']) > 1 for n in unique.values()),
  150. 'multivalue_fields': dict(Counter(
  151. k for n in unique.values() for k, v in n['properties'].items() if isinstance(v, list))),
  152. 'merge_policy': 'distinct_nonempty_values',
  153. 'relation_value_policy': 'any_pair_matches'}
  154. if validation_warnings is not None:
  155. validation_warnings.extend(issues)
  156. logger = logging.getLogger(__name__)
  157. for issue in issues:
  158. logger.warning('警告:%s [数据] 第 %s 行缺少主键字段 %s,已跳过该记录',
  159. issue['file'], issue['row'], '、'.join(issue['fields']))
  160. if issues:
  161. logger.warning('共跳过 %s 条缺少主键的记录,其余有效记录继续构图', len(issues))
  162. return templates, fields, nodes, stats
  163. class LocalEncoder:
  164. def __init__(self):
  165. self.model = None
  166. self.cache = {}
  167. def encode(self, texts):
  168. uncached = list(dict.fromkeys(t for t in texts if t not in self.cache))
  169. if uncached:
  170. if self.model is None:
  171. from sentence_transformers import SentenceTransformer
  172. from step2_graph_building.config import get_embedding_model_dir
  173. path = get_embedding_model_dir()
  174. if not path.is_dir():
  175. raise ValueError(f'本地嵌入模型不存在: {path}')
  176. self.model = SentenceTransformer(str(path), device='cpu', local_files_only=True)
  177. vectors = self.model.encode(uncached, normalize_embeddings=True,
  178. convert_to_numpy=True, show_progress_bar=False)
  179. self.cache.update(zip(uncached, vectors))
  180. return np.asarray([self.cache[t] for t in texts])
  181. def match_rule(rule, nodes, threshold, encoder):
  182. left, right = defaultdict(list), defaultdict(list)
  183. for group, name, field in ((left, rule.source, rule.source_field),
  184. (right, rule.target, rule.target_field)):
  185. for node in nodes[name]:
  186. for value in property_values(node['properties'][field]):
  187. if rule.method in {'包含', '评分'} and not isinstance(value, str):
  188. raise ValueError(f'{rule.location}: {field} 必须为文本以执行 {rule.method}')
  189. # Exact comparison preserves types and whitespace.
  190. key = (type(value).__name__, value) if rule.method == '相等' else value
  191. group[key].append(node['id'])
  192. def expand(a, b, score=None):
  193. for src in left[a]:
  194. for dst in right[b]:
  195. yield {'source': src, 'target': dst, 'score': score}
  196. if rule.method == '相等':
  197. for key in left.keys() & right.keys():
  198. yield from expand(key, key)
  199. elif rule.method == '包含':
  200. for a in left:
  201. for b in right:
  202. if a in b or b in a:
  203. yield from expand(a, b)
  204. elif left and right:
  205. atexts, btexts = list(left), list(right)
  206. av, bv = encoder.encode(atexts), encoder.encode(btexts)
  207. # Normalize here as well so injected encoders obey cosine semantics.
  208. av = np.asarray(av, dtype=np.float64)
  209. bv = np.asarray(bv, dtype=np.float64)
  210. for vectors in (av, bv):
  211. norms = np.linalg.norm(vectors, axis=1, keepdims=True)
  212. if not np.isfinite(vectors).all() or (norms == 0).any():
  213. raise ValueError('嵌入模型返回无效向量')
  214. vectors /= norms
  215. for start in range(0, len(av), 256):
  216. for offset in range(0, len(bv), 256):
  217. scores = av[start:start+256] @ bv[offset:offset+256].T
  218. for i, j in np.argwhere(scores > threshold):
  219. yield from expand(atexts[start+i], btexts[offset+j], float(scores[i, j]))
  220. def prepare_graph(template_dir: Path, production_dir: Path, relation_path: Path,
  221. *, threshold: float, keys=None, encoder=None):
  222. if not math.isfinite(threshold) or not -1 <= threshold <= 1:
  223. raise ValueError('相似度阈值必须是 [-1, 1] 内的有限数')
  224. validation_warnings = []
  225. templates, fields, nodes, stats = read_production(
  226. template_dir, production_dir, keys=keys, validation_warnings=validation_warnings)
  227. rules = load_rules(relation_path, fields)
  228. encoder = encoder or LocalEncoder()
  229. edges, relation_stats = {}, []
  230. for rule in rules:
  231. by_pair = {}
  232. for match in match_rule(rule, nodes, threshold, encoder):
  233. pair = (match['source'], match['target'])
  234. previous = by_pair.get(pair)
  235. if previous is None or (match['score'] is not None and match['score'] > previous['score']):
  236. by_pair[pair] = match
  237. matches = [by_pair[pair] for pair in sorted(by_pair)]
  238. edges[rule.id] = matches
  239. source_degree, target_degree = Counter(), Counter()
  240. for edge in matches:
  241. source_degree[edge['source']] += 1
  242. target_degree[edge['target']] += 1
  243. relation_stats.append({**asdict(rule), 'id': rule.id, 'count': len(matches),
  244. 'unmatched_source_nodes': len(nodes[rule.source]) - len(source_degree),
  245. 'cardinality': ('N' if max(target_degree.values(), default=0) > 1 else '1')
  246. + ':' + ('N' if max(source_degree.values(), default=0) > 1 else '1')
  247. if matches else 'unknown'})
  248. return {'templates': templates, 'fields': fields, 'nodes': nodes, 'rules': rules,
  249. 'edges': edges, 'node_stats': stats, 'relation_stats': relation_stats,
  250. 'threshold': threshold, 'warnings': validation_warnings,
  251. 'skipped_rows': len(validation_warnings)}
  252. def identifier(value: str) -> str:
  253. return '`' + value.replace('`', '``') + '`'
  254. def write_graph(graph, driver, *, build_id: str, batch_size=500):
  255. """Stage a separately tagged build; never erase previous/user-owned graphs."""
  256. if batch_size < 1:
  257. raise ValueError('batch_size must be positive')
  258. driver.execute_query('CREATE CONSTRAINT step2_identity IF NOT EXISTS '
  259. 'FOR (n:_Step2Record) REQUIRE (n._kg_build, n._kg_id) IS UNIQUE')
  260. for name, nodes in graph['nodes'].items():
  261. for start in range(0, len(nodes), batch_size):
  262. batch = [{'id': n['id'], 'props': {**neo4j_properties(n['properties']), '_kg_file': n['file'],
  263. '_kg_sheet': n['sheet'], '_kg_rows': n['rows']}} for n in nodes[start:start+batch_size]]
  264. driver.execute_query(
  265. f'UNWIND $rows AS row MERGE (n:_Step2Record:{identifier(name)} '
  266. '{_kg_build: $build, _kg_id: row.id}) SET n += row.props', rows=batch, build=build_id)
  267. for rule in graph['rules']:
  268. edges = graph['edges'][rule.id]
  269. for start in range(0, len(edges), batch_size):
  270. driver.execute_query(
  271. 'UNWIND $rows AS row MATCH (a:_Step2Record {_kg_build: $build, _kg_id: row.source}), '
  272. '(b:_Step2Record {_kg_build: $build, _kg_id: row.target}) '
  273. f'MERGE (a)-[r:{identifier(rule.edge)} {{_kg_rule: $rule}}]->(b) '
  274. 'SET r._kg_method=$method, r._kg_score=row.score, r._kg_threshold=$threshold',
  275. rows=edges[start:start+batch_size], build=build_id, rule=rule.id,
  276. method=rule.method, threshold=graph['threshold'] if rule.method == '评分' else None)
  277. result = driver.execute_query(
  278. 'MATCH (n:_Step2Record {_kg_build:$build}) OPTIONAL MATCH (n)-[r]->(m:_Step2Record {_kg_build:$build}) '
  279. 'RETURN count(DISTINCT n) AS nodes, count(r) AS edges', build=build_id).records
  280. expected = {'nodes': sum(map(len, graph['nodes'].values())), 'edges': sum(map(len, graph['edges'].values()))}
  281. if result != [expected]:
  282. raise RuntimeError(f'Neo4j 数量校验失败: expected={expected}, actual={result}')
  283. return expected