Spaces:
Sleeping
Sleeping
| """ | |
| Neo4j writer for cross-reference relationships. | |
| Responsibility: take ExtractionResult (pure data) and persist to Neo4j. | |
| This is the ONLY file that imports neo4j driver — keeps extractor.py testable. | |
| Interface contract with Team A | |
| ------------------------------- | |
| - Neo4j URL, user, password passed via environment or config dict. | |
| - Node UIDs follow schema from T1.4: Article.uid, Clause.uid, Point.uid. | |
| Interface contract with Team C | |
| ------------------------------- | |
| - Relationships written: | |
| [:REFERENCES_INTERNAL {context_text, confidence}] | |
| [:REFERENCES_EXTERNAL {context_text, raw_so_ky_hieu, match_method, confidence}] | |
| [:MODIFIES {action, target_clause, target_point, context_text, new_text, confidence}] | |
| - All MERGE operations (idempotent — safe to re-run). | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| from typing import TYPE_CHECKING, Optional | |
| from .models import ExtractionResult, InternalRef, ExternalRef, ModificationRef | |
| if TYPE_CHECKING: | |
| from neo4j import Driver | |
| logger = logging.getLogger(__name__) | |
| class CrossReferenceWriter: | |
| """ | |
| Persists ExtractionResult to Neo4j with Stubbing support. | |
| """ | |
| def __init__(self, driver: "Driver") -> None: | |
| self._driver = driver | |
| self.stub_counts = {"document": 0, "article": 0} | |
| def close(self) -> None: | |
| self._driver.close() | |
| # ------------------------------------------------------------------ | |
| # Public | |
| # ------------------------------------------------------------------ | |
| def write(self, result: ExtractionResult) -> dict[str, int]: | |
| """ | |
| Write all references in result to Neo4j. | |
| """ | |
| counts = {"internal": 0, "external": 0, "modification": 0, "errors": len(result.parse_errors)} | |
| self.stub_counts = {"document": 0, "article": 0} | |
| with self._driver.session() as session: | |
| for ref in result.internal_refs: | |
| try: | |
| session.execute_write(self._write_internal_ref, ref) | |
| counts["internal"] += 1 | |
| except Exception as exc: | |
| logger.warning("Failed to write internal ref %s: %s", ref, exc) | |
| counts["errors"] += 1 | |
| for ref in result.external_refs: | |
| if ref.target_doc_id is None: | |
| continue | |
| try: | |
| session.execute_write(self._write_external_ref, ref) | |
| counts["external"] += 1 | |
| except Exception as exc: | |
| logger.warning("Failed to write external ref %s: %s", ref, exc) | |
| counts["errors"] += 1 | |
| for ref in result.modification_refs: | |
| if ref.target_doc_id is None: | |
| continue | |
| try: | |
| session.execute_write(self._write_modification_ref, ref) | |
| counts["modification"] += 1 | |
| except Exception as exc: | |
| logger.warning("Failed to write mod ref %s: %s", ref, exc) | |
| counts["errors"] += 1 | |
| # Trả về cả counts và stub counts | |
| final_summary = {**counts, "stub_doc": self.stub_counts["document"], "stub_art": self.stub_counts["article"]} | |
| return final_summary | |
| # ------------------------------------------------------------------ | |
| # Private Cypher helpers | |
| # ------------------------------------------------------------------ | |
| def _ensure_doc_stub(self, tx, doc_id: str) -> None: | |
| """Đảm bảo node Document tồn tại (tạo stub nếu chưa có).""" | |
| query = """ | |
| MERGE (d:Document {id: $id}) | |
| ON CREATE SET d.is_stub = true, d.title = 'Stub Document' | |
| RETURN id(d) as node_id, d.is_stub as is_stub | |
| """ | |
| result = tx.run(query, id=str(doc_id)) | |
| record = result.single() | |
| if record and record["is_stub"]: | |
| self.stub_counts["document"] += 1 | |
| def _ensure_article_stub(self, tx, doc_id: str, article_index: str) -> str: | |
| """Đảm bảo node Article tồn tại (tạo stub nếu chưa có) và trả về UID.""" | |
| # 1. Đảm bảo Doc cha tồn tại | |
| self._ensure_doc_stub(tx, doc_id) | |
| # 2. Tạo UID theo quy ước doc_{id}_dieu_{n} | |
| uid = f"doc_{doc_id}_dieu_{article_index}" | |
| # 3. MERGE Article | |
| query = """ | |
| MATCH (d:Document {id: $doc_id}) | |
| MERGE (a:Article {uid: $uid}) | |
| ON CREATE SET | |
| a.index = $idx, | |
| a.is_stub = true, | |
| a.title = 'Stub Article' | |
| MERGE (d)-[:HAS_ARTICLE]->(a) | |
| RETURN a.is_stub as is_stub | |
| """ | |
| result = tx.run(query, doc_id=str(doc_id), uid=uid, idx=article_index) | |
| record = result.single() | |
| if record and record["is_stub"]: | |
| self.stub_counts["article"] += 1 | |
| return uid | |
| def _write_internal_ref(self, tx, ref: InternalRef) -> None: | |
| """Write [:REFERENCES_INTERNAL] relationship.""" | |
| # Nội bộ thì không cần stub Document vì source đã tồn tại, | |
| # nhưng đích Article có thể chưa có (ví dụ dẫn chiếu đến Điều chưa được bóc tách). | |
| target_uid = self._ensure_article_stub(tx, str(ref.source_doc_id), str(ref.target_article_index)) | |
| query_merge = """ | |
| MATCH (src:Article {uid: $source_uid}) | |
| MATCH (tgt:Article {uid: $target_uid}) | |
| MERGE (src)-[r:REFERENCES_INTERNAL]->(tgt) | |
| SET r.context_text = $context, | |
| r.confidence = $conf | |
| """ | |
| tx.run(query_merge, source_uid=ref.source_article_uid, target_uid=target_uid, | |
| context=ref.context_text, conf=ref.confidence) | |
| def _write_external_ref(self, tx, ref: ExternalRef) -> None: | |
| """Write [:REFERENCES_EXTERNAL] relationship.""" | |
| doc_id = str(ref.target_doc_id) | |
| if ref.target_article_index: | |
| # Tạo stub Article (bao gồm cả Doc cha) | |
| target_uid = self._ensure_article_stub(tx, doc_id, str(ref.target_article_index)) | |
| query_merge = """ | |
| MATCH (src:Article {uid: $source_uid}) | |
| MATCH (tgt:Article {uid: $target_uid}) | |
| MERGE (src)-[r:REFERENCES_EXTERNAL]->(tgt) | |
| SET r.context_text = $context, | |
| r.raw_so_ky_hieu = $skh, | |
| r.confidence = $conf | |
| """ | |
| tx.run(query_merge, source_uid=ref.source_article_uid, target_uid=target_uid, | |
| context=ref.context_text, skh=ref.raw_so_ky_hieu, conf=ref.confidence) | |
| else: | |
| # Chỉ tạo stub Document | |
| self._ensure_doc_stub(tx, doc_id) | |
| query_doc = """ | |
| MATCH (src:Article {uid: $source_uid}) | |
| MATCH (tgt:Document {id: $target_doc_id}) | |
| MERGE (src)-[r:REFERENCES_EXTERNAL]->(tgt) | |
| SET r.context_text = $context, | |
| r.raw_so_ky_hieu = $skh, | |
| r.match_method = $method, | |
| r.confidence = $conf | |
| """ | |
| tx.run(query_doc, source_uid=ref.source_article_uid, target_doc_id=doc_id, | |
| context=ref.context_text, skh=ref.raw_so_ky_hieu, | |
| method=ref.match_method, conf=ref.confidence) | |
| def _write_modification_ref(self, tx, ref: ModificationRef) -> None: | |
| """Write [:MODIFIES] relationship.""" | |
| doc_id = str(ref.target_doc_id) | |
| target_label = "Document" | |
| target_key_name = "id" | |
| target_key_val = doc_id | |
| if ref.target_article_index: | |
| target_uid = self._ensure_article_stub(tx, doc_id, str(ref.target_article_index)) | |
| target_label = "Article" | |
| target_key_name = "uid" | |
| target_key_val = target_uid | |
| else: | |
| self._ensure_doc_stub(tx, doc_id) | |
| query_merge = f""" | |
| MATCH (src:Article {{uid: $source_uid}}) | |
| MATCH (tgt:{target_label} {{{target_key_name}: $target_val}}) | |
| MERGE (src)-[r:MODIFIES]->(tgt) | |
| SET r.action = $action, | |
| r.context_text = $context, | |
| r.confidence = $conf, | |
| r.new_text = $new_text | |
| """ | |
| tx.run(query_merge, source_uid=ref.source_article_uid, target_val=target_key_val, | |
| action=ref.action, context=ref.context_text, conf=ref.confidence, new_text=ref.new_text) | |