Spaces:
Sleeping
Sleeping
| """ | |
| VB hợp nhất validator — T3.4 (Người B) | |
| Compares composed EffectiveArticle text against 35 ground-truth | |
| "Văn bản hợp nhất" documents to measure composition accuracy. | |
| Input: | |
| - ComposedArticle list (from merger.py) | |
| - VB hợp nhất parsed content (from Neo4j or parquet) | |
| Output: | |
| - HopNhatReport (pure data) | |
| This module is the quality gate for T3.2 (TextMerger). | |
| If agreement_rate < 0.90, mismatches become training data | |
| for future LLM-assisted composition. | |
| """ | |
| from __future__ import annotations | |
| import difflib | |
| import json | |
| import logging | |
| from pathlib import Path | |
| from typing import Optional, TYPE_CHECKING | |
| from .models import ValidationMatch, HopNhatReport, ComposedArticle | |
| if TYPE_CHECKING: | |
| from neo4j import Driver | |
| logger = logging.getLogger(__name__) | |
| # Minimum character similarity to consider a match | |
| MATCH_THRESHOLD = 0.90 | |
| class HopNhatValidator: | |
| """ | |
| Validates composed EffectiveArticle text against VB hợp nhất ground truth. | |
| Usage | |
| ----- | |
| validator = HopNhatValidator(driver) | |
| report = validator.validate(composed_articles) | |
| report_dict = validator.to_dict(report) | |
| # Save mismatches for training data | |
| validator.export_mismatches(report, "output/hop_nhat_mismatches.jsonl") | |
| """ | |
| def __init__(self, driver: "Driver") -> None: | |
| self._driver = driver | |
| # ------------------------------------------------------------------ | |
| # Public API | |
| # ------------------------------------------------------------------ | |
| def validate(self, composed_articles: list[ComposedArticle]) -> HopNhatReport: | |
| """ | |
| Compare all relevant composed articles against VB hợp nhất ground truth. | |
| Parameters | |
| ---------- | |
| composed_articles : list[ComposedArticle] | |
| All composed articles from Phase 3 pipeline. | |
| Only articles whose source document has a VB hợp nhất counterpart | |
| will be checked. | |
| Returns | |
| ------- | |
| HopNhatReport | |
| matches list contains one ValidationMatch per compared Điều. | |
| TODO (T3.4): implement this method. | |
| Algorithm: | |
| 1. Query Neo4j: find all Document nodes with loai_van_ban="Văn bản hợp nhất" | |
| → 35 documents expected | |
| 2. For each hop_nhat doc, find the original doc it consolidates | |
| (via DETAILS/DETAILED_BY or doc title pattern) | |
| 3. Parse hop_nhat content into article_index → text mapping | |
| 4. Find corresponding ComposedArticle in composed_articles list | |
| 5. Call self._compare(composed.effective_text, hop_nhat_text) | |
| 6. Accumulate ValidationMatch objects into HopNhatReport | |
| """ | |
| raise NotImplementedError("T3.4: implement validate()") | |
| def export_mismatches( | |
| self, report: HopNhatReport, output_path: str | Path | |
| ) -> None: | |
| """ | |
| Export mismatched articles as JSONL for future LLM fine-tuning. | |
| Each line: {"article_uid": "...", "composed": "...", "ground_truth": "...", "diff": "..."} | |
| TODO (T3.4): implement. | |
| """ | |
| raise NotImplementedError("T3.4: implement export_mismatches()") | |
| # ------------------------------------------------------------------ | |
| # Comparison utilities (implement these first — testable independently) | |
| # ------------------------------------------------------------------ | |
| def char_similarity(text_a: str, text_b: str) -> float: | |
| """ | |
| Character-level similarity using SequenceMatcher. | |
| Returns 0.0-1.0. | |
| This is ALREADY IMPLEMENTED — use in validate() and tests immediately. | |
| """ | |
| if not text_a and not text_b: | |
| return 1.0 | |
| if not text_a or not text_b: | |
| return 0.0 | |
| return difflib.SequenceMatcher(None, text_a.strip(), text_b.strip()).ratio() | |
| def structural_match(text_a: str, text_b: str) -> bool: | |
| """ | |
| Check if two article texts have the same number of Khoản and Điểm. | |
| Returns True if structure matches. | |
| This is ALREADY IMPLEMENTED — use in validate() immediately. | |
| """ | |
| import re | |
| khoan_a = len(re.findall(r"(?:^|\n)\d+\.\s", text_a, re.MULTILINE)) | |
| khoan_b = len(re.findall(r"(?:^|\n)\d+\.\s", text_b, re.MULTILINE)) | |
| diem_a = len(re.findall(r"(?:^|\n)[a-zđ]\)\s", text_a, re.MULTILINE | re.UNICODE)) | |
| diem_b = len(re.findall(r"(?:^|\n)[a-zđ]\)\s", text_b, re.MULTILINE | re.UNICODE)) | |
| return khoan_a == khoan_b and diem_a == diem_b | |
| def unified_diff(text_a: str, text_b: str) -> str: | |
| """ | |
| Return a unified diff string between composed and ground truth. | |
| Used for human review and training data generation. | |
| """ | |
| lines_a = text_a.splitlines(keepends=True) | |
| lines_b = text_b.splitlines(keepends=True) | |
| diff = difflib.unified_diff(lines_a, lines_b, | |
| fromfile="composed", tofile="hop_nhat", lineterm="") | |
| return "".join(diff) | |
| def _compare( | |
| self, composed_text: str, ground_truth_text: str, | |
| article_uid: str, hop_nhat_doc_id: str, | |
| semantic_score: float = 0.0, | |
| ) -> ValidationMatch: | |
| """Build a ValidationMatch from two texts.""" | |
| return ValidationMatch( | |
| article_uid=article_uid, | |
| hop_nhat_doc_id=hop_nhat_doc_id, | |
| char_similarity=self.char_similarity(composed_text, ground_truth_text), | |
| structural_match=self.structural_match(composed_text, ground_truth_text), | |
| semantic_score=semantic_score, | |
| composed_text=composed_text, | |
| ground_truth_text=ground_truth_text, | |
| ) | |