| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320 |
- """Template-driven production graph, independent of the legacy fixed schemas."""
- from __future__ import annotations
- import hashlib
- import json
- import logging
- import math
- from collections import Counter, defaultdict
- from contextlib import closing
- from dataclasses import asdict, dataclass
- from datetime import date, datetime
- from pathlib import Path
- import numpy as np
- from openpyxl import load_workbook
- from step1_data_aggregation.template_mapping import load_templates
- class GraphValidationError(ValueError):
- def __init__(self, issues):
- self.issues = issues
- super().__init__(f"生产数据校验失败,共 {len(issues)} 项;见构图报告")
- @dataclass(frozen=True)
- class Rule:
- source: str
- target: str
- source_field: str
- target_field: str
- method: str
- edge: str
- location: str
- @property
- def id(self) -> str:
- return digest([self.source, self.target, self.source_field,
- self.target_field, self.method, self.edge])
- def digest(value) -> str:
- return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True,
- allow_nan=False).encode()).hexdigest()
- def missing(value) -> bool:
- return value is None or isinstance(value, str) and not value.strip()
- def scalar(value):
- if isinstance(value, (datetime, date)):
- return value.isoformat()
- if isinstance(value, float) and not math.isfinite(value):
- return None
- return value
- def property_values(value):
- """Merged fields are alternatives, never concatenated matching text."""
- return [v for v in (value if isinstance(value, list) else [value]) if not missing(v)]
- def merge_value(left, right):
- values = {json.dumps(v, ensure_ascii=False, sort_keys=True, allow_nan=False): v
- for v in property_values(left) + property_values(right)}
- ordered = [values[k] for k in sorted(values)]
- return ordered[0] if len(ordered) == 1 else ordered or None
- def neo4j_properties(props):
- """Neo4j arrays must be homogeneous; retain mixed-type originals as JSON."""
- result = dict(props)
- mixed = {}
- for field, value in props.items():
- if isinstance(value, list) and len({type(v) for v in value}) > 1:
- mixed[field] = value
- result[field] = [json.dumps(v, ensure_ascii=False) for v in value]
- if mixed:
- result['_kg_mixed_values_json'] = json.dumps(mixed, ensure_ascii=False)
- return result
- def load_rules(path: Path, fields: dict[str, list[str]]) -> list[Rule]:
- required = ['起点', '终点', '连接字段', '判断方法', '边名']
- rules, seen = [], set()
- with closing(load_workbook(path, read_only=True, data_only=True)) as wb:
- for sheet in wb:
- rows = iter(sheet.values)
- headers = [str(v).strip() if v is not None else '' for v in next(rows, ())]
- if not any(headers):
- continue
- if len(headers) != 5 or set(headers) != set(required):
- raise ValueError(f'{path}:{sheet.title}: 必须包含且仅包含五个关系列 {required}')
- for rownum, values in enumerate(rows, 2):
- if all(missing(v) for v in values):
- continue
- row = dict(zip(headers, values))
- loc = f'{path}:{sheet.title}!{rownum}'
- if any(missing(row.get(k)) for k in required):
- raise ValueError(f'{loc}: 关系定义有空字段')
- src, dst, link, method, edge = [str(row[k]).strip() for k in required]
- if src not in fields or dst not in fields:
- raise ValueError(f'{loc}: 未知节点类型 {src}/{dst}')
- # Resolve against real field names; hyphens inside a field are valid.
- pairs = [(a, b) for a in fields[src] for b in fields[dst] if f'{a}-{b}' == link]
- if len(pairs) != 1:
- raise ValueError(f'{loc}: 连接字段不存在或有歧义: {link}')
- if method not in {'相等', '包含', '评分'}:
- raise ValueError(f'{loc}: 未知判断方法 {method}')
- rule = Rule(src, dst, *pairs[0], method, edge, loc)
- if rule.id in seen:
- raise ValueError(f'{loc}: 重复关系规则')
- seen.add(rule.id)
- rules.append(rule)
- if not rules:
- raise ValueError(f'{path}: 没有关系规则')
- return rules
- def read_production(template_dir: Path, production_dir: Path, *, keys=None, validation_warnings=None):
- """Merge valid keys; skip missing keys and optionally collect warning details."""
- templates = load_templates(template_dir)
- fields = {t.name: [f.template_field for f in t.fields] for t in templates}
- if set(keys or {}) - fields.keys():
- raise ValueError('主键配置包含未知模板')
- nodes, stats, issues = {}, {}, []
- for template in templates:
- name = template.name
- path = production_dir / f'{name}.xlsx'
- if any(f.startswith('_kg_') for f in fields[name]):
- raise ValueError(f'{name}: _kg_ 为保留属性前缀')
- primary = (keys or {}).get(name, [])
- 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):
- raise ValueError(f'{name}: 无效主键配置')
- unique = {}
- source_count = 0
- skipped_rows = 0
- with closing(load_workbook(path, read_only=True, data_only=True)) as wb:
- if wb.sheetnames != ['数据']:
- raise ValueError(f'{path}: 需要且仅需要“数据”工作表')
- rows = iter(wb['数据'].values)
- headers = list(next(rows, ()))
- if headers != fields[name]:
- raise ValueError(f'{path}: 表头与当前模板不一致')
- for rownum, values in enumerate(rows, 2):
- if all(missing(v) for v in values):
- continue
- source_count += 1
- props = {k: scalar(v) for k, v in zip(headers, values)}
- missing_fields = [k for k in primary if missing(props[k])]
- if missing_fields:
- issues.append({'type': 'missing_key', 'template': name, 'file': str(path),
- 'sheet': '数据', 'row': rownum, 'fields': missing_fields,
- 'action': 'skipped'})
- skipped_rows += 1
- continue
- identity = [props[k] for k in primary]
- node_id = digest([name, identity])
- if node_id in unique:
- current = unique[node_id]['properties']
- for field in headers:
- current[field] = merge_value(current[field], props[field])
- unique[node_id]['rows'].append(rownum)
- else:
- unique[node_id] = {'id': node_id, 'properties': {k: merge_value(None, v) for k, v in props.items()}, 'rows': [rownum],
- 'file': str(path.resolve()), 'sheet': '数据'}
- nodes[name] = list(unique.values())
- stats[name] = {'source_rows': source_count, 'node_count': len(unique),
- 'identity_fields': primary, 'identity_mode': 'key',
- 'skipped_rows': skipped_rows,
- 'merged_rows': source_count - skipped_rows - len(unique),
- 'merged_key_groups': sum(len(n['rows']) > 1 for n in unique.values()),
- 'multivalue_fields': dict(Counter(
- k for n in unique.values() for k, v in n['properties'].items() if isinstance(v, list))),
- 'merge_policy': 'distinct_nonempty_values',
- 'relation_value_policy': 'any_pair_matches'}
- if validation_warnings is not None:
- validation_warnings.extend(issues)
- logger = logging.getLogger(__name__)
- for issue in issues:
- logger.warning('警告:%s [数据] 第 %s 行缺少主键字段 %s,已跳过该记录',
- issue['file'], issue['row'], '、'.join(issue['fields']))
- if issues:
- logger.warning('共跳过 %s 条缺少主键的记录,其余有效记录继续构图', len(issues))
- return templates, fields, nodes, stats
- class LocalEncoder:
- def __init__(self):
- self.model = None
- self.cache = {}
- def encode(self, texts):
- uncached = list(dict.fromkeys(t for t in texts if t not in self.cache))
- if uncached:
- if self.model is None:
- from sentence_transformers import SentenceTransformer
- from step2_graph_building.config import get_embedding_model_dir
- path = get_embedding_model_dir()
- if not path.is_dir():
- raise ValueError(f'本地嵌入模型不存在: {path}')
- self.model = SentenceTransformer(str(path), device='cpu', local_files_only=True)
- vectors = self.model.encode(uncached, normalize_embeddings=True,
- convert_to_numpy=True, show_progress_bar=False)
- self.cache.update(zip(uncached, vectors))
- return np.asarray([self.cache[t] for t in texts])
- def match_rule(rule, nodes, threshold, encoder):
- left, right = defaultdict(list), defaultdict(list)
- for group, name, field in ((left, rule.source, rule.source_field),
- (right, rule.target, rule.target_field)):
- for node in nodes[name]:
- for value in property_values(node['properties'][field]):
- if rule.method in {'包含', '评分'} and not isinstance(value, str):
- raise ValueError(f'{rule.location}: {field} 必须为文本以执行 {rule.method}')
- # Exact comparison preserves types and whitespace.
- key = (type(value).__name__, value) if rule.method == '相等' else value
- group[key].append(node['id'])
- def expand(a, b, score=None):
- for src in left[a]:
- for dst in right[b]:
- yield {'source': src, 'target': dst, 'score': score}
- if rule.method == '相等':
- for key in left.keys() & right.keys():
- yield from expand(key, key)
- elif rule.method == '包含':
- for a in left:
- for b in right:
- if a in b or b in a:
- yield from expand(a, b)
- elif left and right:
- atexts, btexts = list(left), list(right)
- av, bv = encoder.encode(atexts), encoder.encode(btexts)
- # Normalize here as well so injected encoders obey cosine semantics.
- av = np.asarray(av, dtype=np.float64)
- bv = np.asarray(bv, dtype=np.float64)
- for vectors in (av, bv):
- norms = np.linalg.norm(vectors, axis=1, keepdims=True)
- if not np.isfinite(vectors).all() or (norms == 0).any():
- raise ValueError('嵌入模型返回无效向量')
- vectors /= norms
- for start in range(0, len(av), 256):
- for offset in range(0, len(bv), 256):
- scores = av[start:start+256] @ bv[offset:offset+256].T
- for i, j in np.argwhere(scores > threshold):
- yield from expand(atexts[start+i], btexts[offset+j], float(scores[i, j]))
- def prepare_graph(template_dir: Path, production_dir: Path, relation_path: Path,
- *, threshold: float, keys=None, encoder=None):
- if not math.isfinite(threshold) or not -1 <= threshold <= 1:
- raise ValueError('相似度阈值必须是 [-1, 1] 内的有限数')
- validation_warnings = []
- templates, fields, nodes, stats = read_production(
- template_dir, production_dir, keys=keys, validation_warnings=validation_warnings)
- rules = load_rules(relation_path, fields)
- encoder = encoder or LocalEncoder()
- edges, relation_stats = {}, []
- for rule in rules:
- by_pair = {}
- for match in match_rule(rule, nodes, threshold, encoder):
- pair = (match['source'], match['target'])
- previous = by_pair.get(pair)
- if previous is None or (match['score'] is not None and match['score'] > previous['score']):
- by_pair[pair] = match
- matches = [by_pair[pair] for pair in sorted(by_pair)]
- edges[rule.id] = matches
- source_degree, target_degree = Counter(), Counter()
- for edge in matches:
- source_degree[edge['source']] += 1
- target_degree[edge['target']] += 1
- relation_stats.append({**asdict(rule), 'id': rule.id, 'count': len(matches),
- 'unmatched_source_nodes': len(nodes[rule.source]) - len(source_degree),
- 'cardinality': ('N' if max(target_degree.values(), default=0) > 1 else '1')
- + ':' + ('N' if max(source_degree.values(), default=0) > 1 else '1')
- if matches else 'unknown'})
- return {'templates': templates, 'fields': fields, 'nodes': nodes, 'rules': rules,
- 'edges': edges, 'node_stats': stats, 'relation_stats': relation_stats,
- 'threshold': threshold, 'warnings': validation_warnings,
- 'skipped_rows': len(validation_warnings)}
- def identifier(value: str) -> str:
- return '`' + value.replace('`', '``') + '`'
- def write_graph(graph, driver, *, build_id: str, batch_size=500):
- """Stage a separately tagged build; never erase previous/user-owned graphs."""
- if batch_size < 1:
- raise ValueError('batch_size must be positive')
- driver.execute_query('CREATE CONSTRAINT step2_identity IF NOT EXISTS '
- 'FOR (n:_Step2Record) REQUIRE (n._kg_build, n._kg_id) IS UNIQUE')
- for name, nodes in graph['nodes'].items():
- for start in range(0, len(nodes), batch_size):
- batch = [{'id': n['id'], 'props': {**neo4j_properties(n['properties']), '_kg_file': n['file'],
- '_kg_sheet': n['sheet'], '_kg_rows': n['rows']}} for n in nodes[start:start+batch_size]]
- driver.execute_query(
- f'UNWIND $rows AS row MERGE (n:_Step2Record:{identifier(name)} '
- '{_kg_build: $build, _kg_id: row.id}) SET n += row.props', rows=batch, build=build_id)
- for rule in graph['rules']:
- edges = graph['edges'][rule.id]
- for start in range(0, len(edges), batch_size):
- driver.execute_query(
- 'UNWIND $rows AS row MATCH (a:_Step2Record {_kg_build: $build, _kg_id: row.source}), '
- '(b:_Step2Record {_kg_build: $build, _kg_id: row.target}) '
- f'MERGE (a)-[r:{identifier(rule.edge)} {{_kg_rule: $rule}}]->(b) '
- 'SET r._kg_method=$method, r._kg_score=row.score, r._kg_threshold=$threshold',
- rows=edges[start:start+batch_size], build=build_id, rule=rule.id,
- method=rule.method, threshold=graph['threshold'] if rule.method == '评分' else None)
- result = driver.execute_query(
- 'MATCH (n:_Step2Record {_kg_build:$build}) OPTIONAL MATCH (n)-[r]->(m:_Step2Record {_kg_build:$build}) '
- 'RETURN count(DISTINCT n) AS nodes, count(r) AS edges', build=build_id).records
- expected = {'nodes': sum(map(len, graph['nodes'].values())), 'edges': sum(map(len, graph['edges'].values()))}
- if result != [expected]:
- raise RuntimeError(f'Neo4j 数量校验失败: expected={expected}, actual={result}')
- return expected
|