granuscore 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- granuscore/__init__.py +13 -0
- granuscore/artifcats.py +116 -0
- granuscore/bucket_output.py +53 -0
- granuscore/cache.py +189 -0
- granuscore/claim_splitter.py +300 -0
- granuscore/default_assets.py +38 -0
- granuscore/granularity_predictor.py +796 -0
- granuscore/loader.py +118 -0
- granuscore/pipeline.py +562 -0
- granuscore/text_cleaner.py +61 -0
- granuscore/utils.py +48 -0
- granuscore-1.0.0.dist-info/METADATA +242 -0
- granuscore-1.0.0.dist-info/RECORD +16 -0
- granuscore-1.0.0.dist-info/WHEEL +5 -0
- granuscore-1.0.0.dist-info/licenses/LICENSE +21 -0
- granuscore-1.0.0.dist-info/top_level.txt +1 -0
granuscore/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .granularity_predictor import HitGranularityPredictor
|
|
2
|
+
from .pipeline import GranuScore
|
|
3
|
+
|
|
4
|
+
def granuscore(
|
|
5
|
+
texts,
|
|
6
|
+
**kwargs,
|
|
7
|
+
):
|
|
8
|
+
return GranuScore()(
|
|
9
|
+
texts,
|
|
10
|
+
**kwargs,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = ["GranuScore", "granuscore", "HitGranularityPredictor"]
|
granuscore/artifcats.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from platformdirs import user_cache_dir
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _sha256(path: Path) -> str:
|
|
13
|
+
h = hashlib.sha256()
|
|
14
|
+
with path.open("rb") as f:
|
|
15
|
+
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
|
16
|
+
h.update(chunk)
|
|
17
|
+
return h.hexdigest()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ArtifactSpec:
|
|
22
|
+
name: str
|
|
23
|
+
subdir: str # "faiss" or "models"
|
|
24
|
+
url: str | None = None # if None: must be bundled or already cached
|
|
25
|
+
sha256: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ArtifactManager:
|
|
29
|
+
"""
|
|
30
|
+
Resolution order:
|
|
31
|
+
1) cache (and sha256 if provided)
|
|
32
|
+
2) bundled assets inside the package -> copied to cache
|
|
33
|
+
3) download from spec.url (public) -> cached
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, app_name: str = "granuscore"):
|
|
37
|
+
self.cache_root = Path(user_cache_dir(app_name))
|
|
38
|
+
self.cache_root.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
def ensure(self, spec: ArtifactSpec, force: bool = False) -> Path:
|
|
41
|
+
dst_dir = self.cache_root / spec.subdir
|
|
42
|
+
dst_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
dst = dst_dir / spec.name
|
|
44
|
+
|
|
45
|
+
# 1) cache hit
|
|
46
|
+
if dst.exists() and not force:
|
|
47
|
+
if spec.sha256 is None or _sha256(dst) == spec.sha256:
|
|
48
|
+
return dst
|
|
49
|
+
dst.unlink(missing_ok=True)
|
|
50
|
+
|
|
51
|
+
# 2) bundled asset
|
|
52
|
+
bundled = files("granuscore") / "../../assets" / spec.subdir / spec.name
|
|
53
|
+
if bundled.is_file() and not force:
|
|
54
|
+
self._copy_to_cache(bundled, dst)
|
|
55
|
+
self._verify_hash_if_needed(dst, spec)
|
|
56
|
+
return dst
|
|
57
|
+
|
|
58
|
+
# 3) public download
|
|
59
|
+
if not spec.url:
|
|
60
|
+
raise FileNotFoundError(
|
|
61
|
+
f"{spec.name} not found in cache and not bundled, and no public download URL provided."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
self._download_public(spec.url, dst)
|
|
65
|
+
self._verify_hash_if_needed(dst, spec)
|
|
66
|
+
return dst
|
|
67
|
+
|
|
68
|
+
def _verify_hash_if_needed(self, path: Path, spec: ArtifactSpec) -> None:
|
|
69
|
+
if spec.sha256 is None:
|
|
70
|
+
return
|
|
71
|
+
got = _sha256(path)
|
|
72
|
+
if got != spec.sha256:
|
|
73
|
+
path.unlink(missing_ok=True)
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"Hash mismatch for {spec.name}. Expected {spec.sha256}, got {got}."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def _copy_to_cache(self, src_path: Path, dst: Path) -> None:
|
|
79
|
+
tmp = dst.with_suffix(dst.suffix + ".part")
|
|
80
|
+
with src_path.open("rb") as src, tmp.open("wb") as out:
|
|
81
|
+
shutil.copyfileobj(src, out)
|
|
82
|
+
tmp.replace(dst)
|
|
83
|
+
|
|
84
|
+
def _download_public(self, url: str, dst: Path) -> None:
|
|
85
|
+
tmp = dst.with_suffix(dst.suffix + ".part")
|
|
86
|
+
|
|
87
|
+
with requests.get(url, stream=True, timeout=60, allow_redirects=True) as r:
|
|
88
|
+
# Common mistake: GitHub "blob" links download HTML
|
|
89
|
+
if r.status_code >= 400 and "github.com/" in url and "/blob/" in url:
|
|
90
|
+
raise requests.HTTPError(
|
|
91
|
+
"You are using a GitHub 'blob' URL (HTML). Use a release asset URL "
|
|
92
|
+
"(.../releases/download/...) or a raw.githubusercontent.com URL."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
r.raise_for_status()
|
|
96
|
+
total = int(r.headers.get("Content-Length", 0)) or None
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
from tqdm import tqdm # type: ignore
|
|
100
|
+
bar = tqdm(total=total, unit="B", unit_scale=True, desc=f"Downloading {dst.name}")
|
|
101
|
+
except Exception:
|
|
102
|
+
bar = None
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
with tmp.open("wb") as f:
|
|
106
|
+
for chunk in r.iter_content(chunk_size=1024 * 1024):
|
|
107
|
+
if not chunk:
|
|
108
|
+
continue
|
|
109
|
+
f.write(chunk)
|
|
110
|
+
if bar is not None:
|
|
111
|
+
bar.update(len(chunk))
|
|
112
|
+
finally:
|
|
113
|
+
if bar is not None:
|
|
114
|
+
bar.close()
|
|
115
|
+
|
|
116
|
+
os.replace(tmp, dst)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from scipy.stats import percentileofscore
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PercentileOutput:
|
|
6
|
+
def __init__(self, path):
|
|
7
|
+
self.reference_scores = np.load(path)
|
|
8
|
+
self.percentiles: dict[float, float] | None = None
|
|
9
|
+
|
|
10
|
+
def _compute_percentiles(self):
|
|
11
|
+
self.percentiles = {
|
|
12
|
+
p: float(np.percentile(self.reference_scores, p))
|
|
13
|
+
for p in [33.33, 66.66]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
def score_to_percentile(self, scores: float | list[float] | np.ndarray):
|
|
17
|
+
scores = np.asarray(scores)
|
|
18
|
+
return percentileofscore(self.reference_scores, scores)
|
|
19
|
+
|
|
20
|
+
def percentile_to_score(self, percentiles: float | list[float] | np.ndarray):
|
|
21
|
+
is_scalar = np.isscalar(percentiles)
|
|
22
|
+
arr = np.asarray([percentiles] if is_scalar else percentiles, dtype=float)
|
|
23
|
+
|
|
24
|
+
scores = np.percentile(self.reference_scores, arr)
|
|
25
|
+
|
|
26
|
+
if is_scalar:
|
|
27
|
+
return float(scores[0])
|
|
28
|
+
return scores
|
|
29
|
+
|
|
30
|
+
def bucket(self, scores: float | list[float] | np.ndarray) -> str:
|
|
31
|
+
"""
|
|
32
|
+
Assign a granularity bucket based on score-space percentiles.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
scores:
|
|
36
|
+
Granuscore percentile value.
|
|
37
|
+
Returns:
|
|
38
|
+
One of: "low", "medium", "high"
|
|
39
|
+
"""
|
|
40
|
+
if self.percentiles is None:
|
|
41
|
+
self._compute_percentiles()
|
|
42
|
+
|
|
43
|
+
q1 = 33.33
|
|
44
|
+
q2 = 66.66
|
|
45
|
+
|
|
46
|
+
is_scalar = np.isscalar(scores)
|
|
47
|
+
arr = np.asarray([scores] if is_scalar else scores, dtype=float)
|
|
48
|
+
|
|
49
|
+
labels = np.where(arr < q1, "low", np.where(arr < q2, "medium", "high"))
|
|
50
|
+
|
|
51
|
+
if is_scalar:
|
|
52
|
+
return str(labels[0])
|
|
53
|
+
return labels.tolist()
|
granuscore/cache.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from typing import List, Sequence, Protocol
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import sqlite3
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
__all__ = ["GranuscoreCache"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _md5(s: str) -> str:
|
|
13
|
+
return hashlib.md5(s.encode()).hexdigest()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _Scorer(Protocol):
|
|
17
|
+
def score_wo_cache(self, answers: Sequence[str], encoding_batch_size: int | None = None) -> np.ndarray | list: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GranuscoreCache:
|
|
21
|
+
"""Thin wrapper around the Granuscore with SQLite caching."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
scorer: "_Scorer",
|
|
26
|
+
hierarchy_model: str,
|
|
27
|
+
faiss_index: str,
|
|
28
|
+
lgb_model: str,
|
|
29
|
+
cache_dir: str | os.PathLike = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.scorer = scorer
|
|
32
|
+
self.hierarchy_model = hierarchy_model
|
|
33
|
+
self.faiss_index = faiss_index
|
|
34
|
+
self.lgb_model = lgb_model
|
|
35
|
+
|
|
36
|
+
if cache_dir is None:
|
|
37
|
+
cache_dir = os.getenv("GRANUSCORE_CACHE_DIR", f"{Path(__file__).parent}/.cache") # default is .cache dir next to this file
|
|
38
|
+
cache_path = Path(cache_dir)
|
|
39
|
+
cache_path.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
self.db_path = cache_path / "granuscores.db"
|
|
41
|
+
|
|
42
|
+
self._init_db()
|
|
43
|
+
|
|
44
|
+
self._w_lock = threading.Lock()
|
|
45
|
+
|
|
46
|
+
def get(self, text: str) -> float:
|
|
47
|
+
"""Return the granuscore for *text*, fetching from cache or scoring."""
|
|
48
|
+
text_hash = _md5(text)
|
|
49
|
+
|
|
50
|
+
# 1. try cache ------------------------------------------------------
|
|
51
|
+
row = self._select(text_hash)
|
|
52
|
+
if row is not None:
|
|
53
|
+
return row
|
|
54
|
+
|
|
55
|
+
score = self.scorer.score_wo_cache([text])[0]
|
|
56
|
+
self._insert(text_hash, text, score)
|
|
57
|
+
return score
|
|
58
|
+
|
|
59
|
+
def batch(self, texts: Sequence[str], encoding_batch_size: int | None = None) -> List[float]:
|
|
60
|
+
texts = list(texts)
|
|
61
|
+
if not texts:
|
|
62
|
+
return []
|
|
63
|
+
|
|
64
|
+
# Precompute hashes and preserve order
|
|
65
|
+
hashes = [_md5(t) for t in texts]
|
|
66
|
+
|
|
67
|
+
# 1) Bulk fetch cache hits
|
|
68
|
+
hit_map = self._select_many(hashes) # {text_hash: granuscore}
|
|
69
|
+
|
|
70
|
+
# 2) Identify misses (preserve order)
|
|
71
|
+
miss_texts = [t for t, h in zip(texts, hashes) if h not in hit_map]
|
|
72
|
+
miss_hashes = [h for h in hashes if h not in hit_map]
|
|
73
|
+
|
|
74
|
+
# 3) Score misses in one model call
|
|
75
|
+
if miss_texts:
|
|
76
|
+
miss_scores = self.scorer.score_wo_cache(miss_texts, encoding_batch_size)
|
|
77
|
+
miss_scores = np.asarray(miss_scores, dtype=float)
|
|
78
|
+
|
|
79
|
+
# 4) Bulk insert misses
|
|
80
|
+
self._insert_many(miss_hashes, miss_texts, miss_scores)
|
|
81
|
+
|
|
82
|
+
# update hit_map so we can fill outputs
|
|
83
|
+
hit_map.update({h: float(s) for h, s in zip(miss_hashes, miss_scores)})
|
|
84
|
+
|
|
85
|
+
# 5) Build output aligned to input order
|
|
86
|
+
return [float(hit_map[h]) for h in hashes]
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
# private db helpers
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
def _open_conn(self) -> sqlite3.Connection:
|
|
92
|
+
conn = sqlite3.connect(
|
|
93
|
+
self.db_path,
|
|
94
|
+
timeout=30,
|
|
95
|
+
check_same_thread=False,
|
|
96
|
+
isolation_level=None # autocommit
|
|
97
|
+
)
|
|
98
|
+
conn.execute("PRAGMA journal_mode=WAL;")
|
|
99
|
+
conn.execute("PRAGMA synchronous=NORMAL;")
|
|
100
|
+
return conn
|
|
101
|
+
|
|
102
|
+
def _init_db(self) -> None:
|
|
103
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
104
|
+
conn.execute(
|
|
105
|
+
"""
|
|
106
|
+
CREATE TABLE IF NOT EXISTS granuscores (
|
|
107
|
+
text_hash TEXT,
|
|
108
|
+
hierarchy_model TEXT,
|
|
109
|
+
faiss_index TEXT,
|
|
110
|
+
lgb_model TEXT,
|
|
111
|
+
granuscore REAL NOT NULL,
|
|
112
|
+
text TEXT,
|
|
113
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
114
|
+
PRIMARY KEY(text_hash, hierarchy_model, faiss_index, lgb_model)
|
|
115
|
+
)
|
|
116
|
+
"""
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_model ON granuscores(hierarchy_model, faiss_index, lgb_model);")
|
|
120
|
+
|
|
121
|
+
def _select(self, text_hash: str) -> float | None:
|
|
122
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
123
|
+
row = conn.execute(
|
|
124
|
+
"SELECT granuscore FROM granuscores WHERE text_hash=? AND hierarchy_model=? AND faiss_index=? AND lgb_model=?",
|
|
125
|
+
(text_hash, self.hierarchy_model, self.faiss_index, self.lgb_model),
|
|
126
|
+
).fetchone()
|
|
127
|
+
if row:
|
|
128
|
+
return row[0]
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
def _select_many(self, text_hashes: Sequence[str], chunk_size: int = 900) -> dict[str, float]:
|
|
132
|
+
"""
|
|
133
|
+
Fetch many hashes at once. Chunked because SQLite has a limit on bound variables
|
|
134
|
+
(commonly 999).
|
|
135
|
+
"""
|
|
136
|
+
out: dict[str, float] = {}
|
|
137
|
+
if not text_hashes:
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
141
|
+
for i in range(0, len(text_hashes), chunk_size):
|
|
142
|
+
chunk = text_hashes[i:i + chunk_size]
|
|
143
|
+
placeholders = ",".join(["?"] * len(chunk))
|
|
144
|
+
|
|
145
|
+
rows = conn.execute(
|
|
146
|
+
f"""
|
|
147
|
+
SELECT text_hash, granuscore
|
|
148
|
+
FROM granuscores
|
|
149
|
+
WHERE hierarchy_model = ?
|
|
150
|
+
AND faiss_index = ?
|
|
151
|
+
AND lgb_model = ?
|
|
152
|
+
AND text_hash IN ({placeholders})
|
|
153
|
+
""",
|
|
154
|
+
(self.hierarchy_model, self.faiss_index, self.lgb_model, *chunk),
|
|
155
|
+
).fetchall()
|
|
156
|
+
|
|
157
|
+
out.update({h: float(s) for h, s in rows})
|
|
158
|
+
|
|
159
|
+
return out
|
|
160
|
+
|
|
161
|
+
def _insert(self, text_hash: str, text: str, granusccore: float) -> None:
|
|
162
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
163
|
+
conn.execute(
|
|
164
|
+
"INSERT OR REPLACE INTO granuscores (text_hash, hierarchy_model, faiss_index, lgb_model, granuscore, text) VALUES (?,?,?,?,?,?)",
|
|
165
|
+
(text_hash, self.hierarchy_model, self.faiss_index, self.lgb_model, granusccore, text),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def _insert_many(self, hashes: Sequence[str], texts: Sequence[str], scores: Sequence[float]) -> None:
|
|
169
|
+
"""
|
|
170
|
+
Bulk insert inside one transaction. Much faster than opening a connection per row.
|
|
171
|
+
"""
|
|
172
|
+
if not hashes:
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
rows = [
|
|
176
|
+
(h, self.hierarchy_model, self.faiss_index, self.lgb_model, float(s), t)
|
|
177
|
+
for h, t, s in zip(hashes, texts, scores)
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
with self._w_lock:
|
|
181
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
182
|
+
conn.executemany(
|
|
183
|
+
"""
|
|
184
|
+
INSERT OR REPLACE INTO granuscores
|
|
185
|
+
(text_hash, hierarchy_model, faiss_index, lgb_model, granuscore, text)
|
|
186
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
187
|
+
""",
|
|
188
|
+
rows,
|
|
189
|
+
)
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claim splitting module for extracting atomic facts from text.
|
|
3
|
+
|
|
4
|
+
This module provides various strategies for splitting text into atomic claims,
|
|
5
|
+
from simple word-based splitting to sophisticated LLM-based extraction.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from typing import List, Sequence, Literal, Dict, Any
|
|
10
|
+
|
|
11
|
+
import spacy
|
|
12
|
+
from spacy.matcher import Matcher
|
|
13
|
+
|
|
14
|
+
from granuscore.text_cleaner import TextCleaner
|
|
15
|
+
from granuscore.utils import ensure_spacy_model
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
SplitScope = Literal["document", "sentence"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UnitSplitter(ABC):
|
|
22
|
+
"""
|
|
23
|
+
Abstract base class for unit splitting strategies.
|
|
24
|
+
|
|
25
|
+
Defines the interface for extracting referential units from text,
|
|
26
|
+
supporting both single-text and batch processing.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __call__(self, batch: List[str], split_scope: SplitScope = "document") -> list[list[dict[str, Any]]]:
|
|
30
|
+
"""Allow callable interface for batch processing."""
|
|
31
|
+
return self.get_referential_units_batch(batch, split_scope)
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def get_referential_units(self, text: str, split_scope: SplitScope = "document") -> list[dict[str, Any]]:
|
|
35
|
+
"""
|
|
36
|
+
Extract referential units from a single text.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
text: Input text to process
|
|
40
|
+
split_scope: Defines the structural scope over inside which referential units should be split.
|
|
41
|
+
"document" splits the whole text.
|
|
42
|
+
"sentence" applies splitting within each sentence.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
List[Dict[str, Any]]
|
|
46
|
+
One entry per group (document or sentence) with:
|
|
47
|
+
- text: str
|
|
48
|
+
- referential_units: List[str]
|
|
49
|
+
"""
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def get_referential_units_batch(self, texts: Sequence[str], split_scope: SplitScope = "document") -> list[list[dict[str, Any]]]:
|
|
54
|
+
"""
|
|
55
|
+
Extract referential units from multiple texts.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
texts: List of input texts to process
|
|
59
|
+
split_scope: Defines the structural scope over inside which referential units should be split.
|
|
60
|
+
"document" splits the whole text.
|
|
61
|
+
"sentence" applies splitting within each sentence.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
List[List[Dict[str, Any]]]:
|
|
65
|
+
A nested list structure where:
|
|
66
|
+
- the outer list indexes input texts,
|
|
67
|
+
- the second level indexes document- or sentence-level groups
|
|
68
|
+
(depending on ``split_scope``),
|
|
69
|
+
- each group is represented as a dictionary with the following keys:
|
|
70
|
+
- ``"text"`` (str):
|
|
71
|
+
The text span corresponding to the group.
|
|
72
|
+
- ``"referential_units"`` (List[str]):
|
|
73
|
+
The extracted referential units for the group.
|
|
74
|
+
"""
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SpacyNounPhraseSplitter(UnitSplitter):
|
|
79
|
+
"""
|
|
80
|
+
Spacy-based noun phrase extractor.
|
|
81
|
+
|
|
82
|
+
Extracts noun phrases and significant tokens using spaCy's NLP pipeline.
|
|
83
|
+
Includes cleaning options for academic text (LaTeX, figures, etc.).
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
model: str = "en_core_web_sm",
|
|
89
|
+
skip_brackets: bool = False,
|
|
90
|
+
skip_latex: bool = False,
|
|
91
|
+
skip_arxiv_meta: bool = False,
|
|
92
|
+
skip_figures: bool = False,
|
|
93
|
+
skip_pdf_noise: bool = False,
|
|
94
|
+
):
|
|
95
|
+
"""
|
|
96
|
+
Initialize with spaCy model and cleaning options.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
model: spaCy model name to load
|
|
100
|
+
skip_brackets: Remove bracketed content
|
|
101
|
+
skip_latex: Remove LaTeX blocks
|
|
102
|
+
skip_arxiv_meta: Remove arXiv metadata
|
|
103
|
+
skip_figures: Remove figure captions
|
|
104
|
+
skip_pdf_noise: Remove PDF noise
|
|
105
|
+
"""
|
|
106
|
+
ensure_spacy_model("en_core_web_sm")
|
|
107
|
+
self.nlp = spacy.load(model)
|
|
108
|
+
self._add_et_al_merging()
|
|
109
|
+
self.clean_kwargs = {
|
|
110
|
+
'skip_brackets': skip_brackets,
|
|
111
|
+
'skip_latex': skip_latex,
|
|
112
|
+
'skip_arxiv_meta': skip_arxiv_meta,
|
|
113
|
+
'skip_figures': skip_figures,
|
|
114
|
+
'skip_pdf_noise': skip_pdf_noise,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
def _add_et_al_merging(self):
|
|
118
|
+
"""Add custom component to merge 'et al' into single tokens."""
|
|
119
|
+
matcher = Matcher(self.nlp.vocab)
|
|
120
|
+
matcher.add(
|
|
121
|
+
"ET_AL",
|
|
122
|
+
[[
|
|
123
|
+
{"IS_TITLE": True},
|
|
124
|
+
{"LOWER": "et"},
|
|
125
|
+
{"LOWER": "al"},
|
|
126
|
+
]]
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
@spacy.Language.component("merge_et_al")
|
|
130
|
+
def merge_et_al(doc):
|
|
131
|
+
matches = matcher(doc)
|
|
132
|
+
with doc.retokenize() as retokenizer:
|
|
133
|
+
for _, start, end in matches:
|
|
134
|
+
retokenizer.merge(doc[start:end])
|
|
135
|
+
return doc
|
|
136
|
+
|
|
137
|
+
self.nlp.add_pipe("merge_et_al", before="parser")
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def _is_valid_noun_chunk(chunk) -> bool:
|
|
141
|
+
"""Check if a noun chunk is valid for extraction."""
|
|
142
|
+
# Must be nominal
|
|
143
|
+
if chunk.root.pos_ not in {"NOUN", "PROPN", "PRON"}:
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
# Reject punctuation-only
|
|
147
|
+
if all(tok.is_punct for tok in chunk):
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
# Reject single hyphen or fragments
|
|
151
|
+
if chunk.text.strip() in {"-", "–", "—"}:
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _extract_from_doclike(self, text) -> list[str]:
|
|
158
|
+
"""
|
|
159
|
+
Extract noun phrases and significant tokens from a spaCy Doc or Span.
|
|
160
|
+
Returns a flat list of units for that segment.
|
|
161
|
+
"""
|
|
162
|
+
doclike = self.nlp(text)
|
|
163
|
+
|
|
164
|
+
spans = {
|
|
165
|
+
(chunk.start, chunk.end)
|
|
166
|
+
for chunk in doclike.noun_chunks
|
|
167
|
+
if self._is_valid_noun_chunk(chunk)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
results: List[str] = []
|
|
171
|
+
i = 0
|
|
172
|
+
while i < len(doclike):
|
|
173
|
+
for start, end in spans:
|
|
174
|
+
if i == start:
|
|
175
|
+
results.append(doclike[start:end].text)
|
|
176
|
+
i = end
|
|
177
|
+
break
|
|
178
|
+
else:
|
|
179
|
+
tok = doclike[i]
|
|
180
|
+
if (not tok.is_stop or tok.like_num) and any(c.isalnum() for c in tok.text):
|
|
181
|
+
results.append(tok.text)
|
|
182
|
+
i += 1
|
|
183
|
+
|
|
184
|
+
return results
|
|
185
|
+
|
|
186
|
+
def get_referential_units(self, text: str, split_scope: SplitScope = "document") -> list[dict[str, Any]]:
|
|
187
|
+
"""
|
|
188
|
+
Extract noun phrases and significant tokens, optionally grouped by sentence.
|
|
189
|
+
|
|
190
|
+
Returns
|
|
191
|
+
-------
|
|
192
|
+
List[Dict[str, Any]]
|
|
193
|
+
One entry per group (document or sentence) with:
|
|
194
|
+
- referential_units: List[str]
|
|
195
|
+
- cleaned_text: str
|
|
196
|
+
- original_text: str
|
|
197
|
+
"""
|
|
198
|
+
text = TextCleaner.clean(text, **self.clean_kwargs)
|
|
199
|
+
|
|
200
|
+
if split_scope == "document":
|
|
201
|
+
return [{
|
|
202
|
+
"referential_units": self._extract_from_doclike(text),
|
|
203
|
+
"text": text,
|
|
204
|
+
}]
|
|
205
|
+
|
|
206
|
+
if split_scope == "sentence":
|
|
207
|
+
doc = self.nlp(text)
|
|
208
|
+
groups: List[Dict[str, Any]] = []
|
|
209
|
+
for sent in doc.sents:
|
|
210
|
+
sent_text = sent.text.strip()
|
|
211
|
+
if not sent_text:
|
|
212
|
+
continue
|
|
213
|
+
groups.append({
|
|
214
|
+
"referential_units": self._extract_from_doclike(sent_text),
|
|
215
|
+
"text": sent_text,
|
|
216
|
+
})
|
|
217
|
+
if not groups:
|
|
218
|
+
groups.append({
|
|
219
|
+
"referential_units": [],
|
|
220
|
+
"text": text,
|
|
221
|
+
})
|
|
222
|
+
return groups
|
|
223
|
+
|
|
224
|
+
raise ValueError('split_scope must be either "document" or "sentence"')
|
|
225
|
+
|
|
226
|
+
def get_referential_units_batch(self, texts: Sequence[str], split_scope: SplitScope = 'document') -> list[list[dict[str, Any]]]:
|
|
227
|
+
"""Process multiple texts."""
|
|
228
|
+
return [self.get_referential_units(text, split_scope) for text in texts]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class SpacyObjectSplitter(SpacyNounPhraseSplitter):
|
|
232
|
+
DEP_KEEP = {
|
|
233
|
+
"nsubj", "nsubjpass", # subjects
|
|
234
|
+
"dobj", "obj", "iobj", # objects
|
|
235
|
+
"obl", # oblique nominal (where/when/how)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
def _extract_from_doclike(self, text: str) -> list[str]:
|
|
239
|
+
"""
|
|
240
|
+
Extract units in document order:
|
|
241
|
+
1) noun chunks (preferred)
|
|
242
|
+
2) otherwise, subject/object-like tokens (dep-based)
|
|
243
|
+
"""
|
|
244
|
+
doc = self.nlp(text)
|
|
245
|
+
|
|
246
|
+
# spans for noun chunks
|
|
247
|
+
spans = {
|
|
248
|
+
(chunk.start, chunk.end)
|
|
249
|
+
for chunk in doc.noun_chunks
|
|
250
|
+
if self._is_valid_noun_chunk(chunk)
|
|
251
|
+
}
|
|
252
|
+
start_to_end = {start: end for start, end in spans}
|
|
253
|
+
|
|
254
|
+
results = []
|
|
255
|
+
seen = set()
|
|
256
|
+
|
|
257
|
+
i = 0
|
|
258
|
+
while i < len(doc):
|
|
259
|
+
end = start_to_end.get(i)
|
|
260
|
+
if end is not None:
|
|
261
|
+
text_unit = doc[i:end].text.strip()
|
|
262
|
+
if text_unit and text_unit not in seen:
|
|
263
|
+
results.append(text_unit)
|
|
264
|
+
seen.add(text_unit)
|
|
265
|
+
i = end
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
tok = doc[i]
|
|
269
|
+
if tok.dep_ in self.DEP_KEEP and any(c.isalnum() for c in tok.text):
|
|
270
|
+
if tok.text not in seen:
|
|
271
|
+
results.append(tok.text)
|
|
272
|
+
seen.add(tok.text)
|
|
273
|
+
|
|
274
|
+
i += 1
|
|
275
|
+
|
|
276
|
+
return results
|
|
277
|
+
|
|
278
|
+
if __name__ == '__main__':
|
|
279
|
+
# Example: Using the noun phrase splitter
|
|
280
|
+
splitter = SpacyNounPhraseSplitter(
|
|
281
|
+
skip_brackets=True,
|
|
282
|
+
skip_latex=True,
|
|
283
|
+
skip_figures=True,
|
|
284
|
+
skip_arxiv_meta=True,
|
|
285
|
+
skip_pdf_noise=True
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
sample_texts = [
|
|
289
|
+
" O'Reilly",
|
|
290
|
+
"The population is 12,000 (approx.)."
|
|
291
|
+
"three",
|
|
292
|
+
"Two."
|
|
293
|
+
]
|
|
294
|
+
|
|
295
|
+
results = splitter.get_referential_units_batch(sample_texts)
|
|
296
|
+
|
|
297
|
+
for i, claims in enumerate(results, 1):
|
|
298
|
+
print(f"\nText {i} claims:")
|
|
299
|
+
for claim in claims:
|
|
300
|
+
print(f" - {claim}")
|