acuity-framework 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.
- acuity/__init__.py +27 -0
- acuity/config.py +47 -0
- acuity/extraction/__init__.py +22 -0
- acuity/extraction/ner_crf.py +164 -0
- acuity/extraction/ner_transformer.py +76 -0
- acuity/extraction/pipeline.py +171 -0
- acuity/extraction/postprocessing.py +74 -0
- acuity/extraction/preprocessing.py +57 -0
- acuity/extraction/rules.py +49 -0
- acuity/recommendation/__init__.py +16 -0
- acuity/recommendation/engine.py +138 -0
- acuity/recommendation/proximity.py +35 -0
- acuity/recommendation/ranker.py +61 -0
- acuity/recommendation/similarity.py +48 -0
- acuity/recommendation/vectorizer.py +174 -0
- acuity/scraper/__init__.py +16 -0
- acuity/scraper/scraper.py +376 -0
- acuity/scraper/utils.py +62 -0
- acuity/utils.py +84 -0
- acuity/verification/__init__.py +16 -0
- acuity/verification/bplo.py +134 -0
- acuity_framework-1.0.0.dist-info/METADATA +255 -0
- acuity_framework-1.0.0.dist-info/RECORD +26 -0
- acuity_framework-1.0.0.dist-info/WHEEL +5 -0
- acuity_framework-1.0.0.dist-info/licenses/LICENSE +21 -0
- acuity_framework-1.0.0.dist-info/top_level.txt +1 -0
acuity/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ACUITY Framework
|
|
3
|
+
================
|
|
4
|
+
|
|
5
|
+
**A**utomated **C**ommunity **U**nstructured **I**nformation to **T**argeted visibilit**Y**
|
|
6
|
+
|
|
7
|
+
A machine learning framework for extracting, verifying, and recommending
|
|
8
|
+
local micro-enterprise profiles from unstructured community posts.
|
|
9
|
+
|
|
10
|
+
Modules:
|
|
11
|
+
- acuity.extraction: NLP pipeline (preprocessing → NER → rule-based → profile building)
|
|
12
|
+
- acuity.recommendation: TF-IDF + cosine similarity + Haversine proximity ranking
|
|
13
|
+
- acuity.verification: Business legitimacy verification via fuzzy matching against registries
|
|
14
|
+
- acuity.scraper: Facebook community group post scraper (optional)
|
|
15
|
+
|
|
16
|
+
Quick Start:
|
|
17
|
+
>>> from acuity.extraction import ExtractionPipeline
|
|
18
|
+
>>> from acuity.recommendation import RecommendationEngine
|
|
19
|
+
>>> from acuity.verification import BPLOVerifier
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__version__ = "1.0.0"
|
|
23
|
+
__author__ = "ACUITY Research Team"
|
|
24
|
+
|
|
25
|
+
from .config import AcuityConfig
|
|
26
|
+
|
|
27
|
+
__all__ = ["AcuityConfig"]
|
acuity/config.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ACUITY Framework Configuration
|
|
3
|
+
|
|
4
|
+
Provides a centralized dataclass for all configurable parameters.
|
|
5
|
+
No hardcoded paths — users supply their own paths and thresholds.
|
|
6
|
+
"""
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class AcuityConfig:
|
|
12
|
+
"""Configuration for the ACUITY framework.
|
|
13
|
+
|
|
14
|
+
All paths default to None, requiring the user to set them
|
|
15
|
+
based on their own project structure.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
ner_model_path: Path to a trained NER model (CRF .pkl or HuggingFace directory).
|
|
19
|
+
ner_backend: Which NER backend to use: ``"crf"`` or ``"transformer"``.
|
|
20
|
+
completeness_threshold: Minimum number of populated detail fields
|
|
21
|
+
for a profile to be considered complete enough to keep.
|
|
22
|
+
relevance_weight: Weight for textual relevance in recommendation ranking [0, 1].
|
|
23
|
+
proximity_weight: Weight for geographic proximity in recommendation ranking [0, 1].
|
|
24
|
+
default_top_k: Default number of results returned by the recommendation engine.
|
|
25
|
+
fuzzy_match_threshold_verified: Levenshtein ratio threshold (0–1) to mark
|
|
26
|
+
a business as "Verified" against a BPLO registry.
|
|
27
|
+
fuzzy_match_threshold_pending: Levenshtein ratio threshold (0–1) to mark
|
|
28
|
+
a business as "Pending Verification".
|
|
29
|
+
max_flags_threshold: Number of user flags before a profile is auto-deactivated.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
# NLP / Pipeline settings
|
|
33
|
+
ner_model_path: str | None = None
|
|
34
|
+
ner_backend: str = "crf" # "crf" or "transformer"
|
|
35
|
+
completeness_threshold: int = 2
|
|
36
|
+
|
|
37
|
+
# Recommendation settings
|
|
38
|
+
relevance_weight: float = 0.6
|
|
39
|
+
proximity_weight: float = 0.4
|
|
40
|
+
default_top_k: int = 10
|
|
41
|
+
|
|
42
|
+
# BPLO Verification settings
|
|
43
|
+
fuzzy_match_threshold_verified: float = 0.8
|
|
44
|
+
fuzzy_match_threshold_pending: float = 0.6
|
|
45
|
+
|
|
46
|
+
# Business Profile limits
|
|
47
|
+
max_flags_threshold: int = 3
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ACUITY Framework — Extraction Module
|
|
3
|
+
|
|
4
|
+
NLP pipeline for extracting structured business information from
|
|
5
|
+
unstructured community group posts (e.g., Facebook, forums).
|
|
6
|
+
|
|
7
|
+
Components:
|
|
8
|
+
- preprocessing: Text cleaning and normalisation
|
|
9
|
+
- ner: Named Entity Recognition (CRF or Transformer backend)
|
|
10
|
+
- rules: Regex-based structured field extraction
|
|
11
|
+
- postprocessing: Profile construction from extraction outputs
|
|
12
|
+
- pipeline: End-to-end orchestrator
|
|
13
|
+
|
|
14
|
+
Quick Start:
|
|
15
|
+
>>> from acuity.extraction import ExtractionPipeline
|
|
16
|
+
>>> pipeline = ExtractionPipeline()
|
|
17
|
+
>>> profiles = pipeline.extract_from_texts(["Mang Juan's Bakery, Mamatid, open 8am-5pm, 0917-123-4567"])
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .pipeline import ExtractionPipeline
|
|
21
|
+
|
|
22
|
+
__all__ = ["ExtractionPipeline"]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ACUITY Framework — Named Entity Recognition (CRF Backend)
|
|
3
|
+
|
|
4
|
+
Extracts business-related entities from preprocessed post text using
|
|
5
|
+
a trained CRF (Conditional Random Field) model with BIO tagging:
|
|
6
|
+
- BUSINESS_NAME
|
|
7
|
+
- SERVICE_CATEGORY
|
|
8
|
+
- LOCATION
|
|
9
|
+
|
|
10
|
+
The CRF model path must be supplied via ``AcuityConfig.ner_model_path``.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import pickle
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import nltk # type: ignore
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
nltk.data.find("taggers/averaged_perceptron_tagger_eng")
|
|
22
|
+
except LookupError:
|
|
23
|
+
nltk.download("averaged_perceptron_tagger_eng", quiet=True)
|
|
24
|
+
|
|
25
|
+
_NLTK_AVAILABLE = True
|
|
26
|
+
except ImportError:
|
|
27
|
+
nltk: Any = None
|
|
28
|
+
_NLTK_AVAILABLE = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _extract_features(tokens: list[str], pos_tags: list[tuple[str, str]], i: int) -> dict[str, Any]:
|
|
32
|
+
"""Build the feature dictionary for token at index *i*."""
|
|
33
|
+
word = tokens[i]
|
|
34
|
+
postag = pos_tags[i][1]
|
|
35
|
+
|
|
36
|
+
features: dict[str, Any] = {
|
|
37
|
+
"bias": 1.0,
|
|
38
|
+
"word.lower()": word.lower(),
|
|
39
|
+
"word[-3:]": word[-3:],
|
|
40
|
+
"word[-2:]": word[-2:],
|
|
41
|
+
"word[:3]": word[:3],
|
|
42
|
+
"word[:2]": word[:2],
|
|
43
|
+
"word.isupper()": word.isupper(),
|
|
44
|
+
"word.istitle()": word.istitle(),
|
|
45
|
+
"word.isdigit()": word.isdigit(),
|
|
46
|
+
"postag": postag,
|
|
47
|
+
"postag[:2]": postag[:2],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if i > 0:
|
|
51
|
+
word1 = tokens[i - 1]
|
|
52
|
+
postag1 = pos_tags[i - 1][1]
|
|
53
|
+
features.update({
|
|
54
|
+
"-1:word.lower()": word1.lower(),
|
|
55
|
+
"-1:word.istitle()": word1.istitle(),
|
|
56
|
+
"-1:word.isupper()": word1.isupper(),
|
|
57
|
+
"-1:postag": postag1,
|
|
58
|
+
"-1:postag[:2]": postag1[:2],
|
|
59
|
+
})
|
|
60
|
+
else:
|
|
61
|
+
features["BOS"] = True
|
|
62
|
+
|
|
63
|
+
if i < len(tokens) - 1:
|
|
64
|
+
word1 = tokens[i + 1]
|
|
65
|
+
postag1 = pos_tags[i + 1][1]
|
|
66
|
+
features.update({
|
|
67
|
+
"+1:word.lower()": word1.lower(),
|
|
68
|
+
"+1:word.istitle()": word1.istitle(),
|
|
69
|
+
"+1:word.isupper()": word1.isupper(),
|
|
70
|
+
"+1:postag": postag1,
|
|
71
|
+
"+1:postag[:2]": postag1[:2],
|
|
72
|
+
})
|
|
73
|
+
else:
|
|
74
|
+
features["EOS"] = True
|
|
75
|
+
|
|
76
|
+
return features
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _sent2features(tokens: list[str]) -> list[dict[str, Any]]:
|
|
80
|
+
"""Convert a sentence (list of tokens) into a list of feature dicts."""
|
|
81
|
+
if not _NLTK_AVAILABLE or nltk is None:
|
|
82
|
+
return []
|
|
83
|
+
pos_tags = nltk.pos_tag(tokens)
|
|
84
|
+
return [_extract_features(tokens, pos_tags, i) for i in range(len(tokens))]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def load_crf_model(model_path: str):
|
|
88
|
+
"""Load a pickled CRF model from disk.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
model_path: Absolute path to the ``.pkl`` file.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The loaded CRF model object, or ``None`` on failure.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
with open(model_path, "rb") as f:
|
|
98
|
+
return pickle.load(f)
|
|
99
|
+
except Exception as e:
|
|
100
|
+
print(f"Warning: Could not load CRF model from {model_path}: {e}")
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def extract_entities_crf(text: str, model: Any) -> dict:
|
|
105
|
+
"""Extract named entities from *text* using a CRF model.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
text: Preprocessed post text.
|
|
109
|
+
model: A loaded CRF model (e.g., from ``load_crf_model``).
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
dict with keys: ``business_name``, ``categories``, ``locations``.
|
|
113
|
+
Each value is a list of extracted strings.
|
|
114
|
+
"""
|
|
115
|
+
extracted: dict[str, list[str]] = {
|
|
116
|
+
"business_name": [],
|
|
117
|
+
"categories": [],
|
|
118
|
+
"locations": [],
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if not model or not _NLTK_AVAILABLE:
|
|
122
|
+
return extracted
|
|
123
|
+
|
|
124
|
+
tokens = text.split()
|
|
125
|
+
if not tokens:
|
|
126
|
+
return extracted
|
|
127
|
+
|
|
128
|
+
features = _sent2features(tokens)
|
|
129
|
+
predictions = model.predict([features])[0]
|
|
130
|
+
|
|
131
|
+
# Reconstruct entities from BIO tags
|
|
132
|
+
current_entity_type: str | None = None
|
|
133
|
+
current_entity_tokens: list[str] = []
|
|
134
|
+
|
|
135
|
+
def _save_entity() -> None:
|
|
136
|
+
if current_entity_type and current_entity_tokens:
|
|
137
|
+
entity_text = " ".join(current_entity_tokens)
|
|
138
|
+
if current_entity_type == "BUSINESS_NAME":
|
|
139
|
+
extracted["business_name"].append(entity_text)
|
|
140
|
+
elif current_entity_type == "SERVICE_CATEGORY":
|
|
141
|
+
extracted["categories"].append(entity_text)
|
|
142
|
+
elif current_entity_type == "LOCATION":
|
|
143
|
+
extracted["locations"].append(entity_text)
|
|
144
|
+
|
|
145
|
+
for token, tag in zip(tokens, predictions):
|
|
146
|
+
if tag.startswith("B-"):
|
|
147
|
+
_save_entity()
|
|
148
|
+
current_entity_type = tag[2:]
|
|
149
|
+
current_entity_tokens = [token]
|
|
150
|
+
elif tag.startswith("I-"):
|
|
151
|
+
if current_entity_type == tag[2:]:
|
|
152
|
+
current_entity_tokens.append(token)
|
|
153
|
+
else:
|
|
154
|
+
_save_entity()
|
|
155
|
+
current_entity_type = tag[2:]
|
|
156
|
+
current_entity_tokens = [token]
|
|
157
|
+
else:
|
|
158
|
+
_save_entity()
|
|
159
|
+
current_entity_type = None
|
|
160
|
+
current_entity_tokens = []
|
|
161
|
+
|
|
162
|
+
_save_entity()
|
|
163
|
+
|
|
164
|
+
return extracted
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ACUITY Framework — Named Entity Recognition (Transformer Backend)
|
|
3
|
+
|
|
4
|
+
Extracts business-related entities using a fine-tuned HuggingFace
|
|
5
|
+
transformer model with BIO tagging:
|
|
6
|
+
- BUSINESS_NAME
|
|
7
|
+
- SERVICE_CATEGORY
|
|
8
|
+
- LOCATION
|
|
9
|
+
|
|
10
|
+
Requires ``transformers`` and ``torch`` (install with ``pip install acuity-framework[transformers]``).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_transformer_model(model_path: str):
|
|
18
|
+
"""Load a HuggingFace NER pipeline from a local directory.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
model_path: Path to the fine-tuned model directory.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
A HuggingFace ``pipeline`` object, or ``None`` on failure.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
from transformers import pipeline as hf_pipeline # type: ignore
|
|
28
|
+
print(f"Loading Fine-Tuned NER model from {model_path}...")
|
|
29
|
+
return hf_pipeline("ner", model=model_path, aggregation_strategy="simple")
|
|
30
|
+
except Exception as e:
|
|
31
|
+
print(f"Warning: Could not load NER transformer model: {e}")
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def extract_entities_transformer(text: str, model: Any) -> dict:
|
|
36
|
+
"""Extract named entities from *text* using a HuggingFace NER model.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
text: Preprocessed post text.
|
|
40
|
+
model: A loaded HuggingFace NER pipeline (from ``load_transformer_model``).
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
dict with keys: ``business_name``, ``categories``, ``locations``.
|
|
44
|
+
Each value is a list of extracted strings.
|
|
45
|
+
"""
|
|
46
|
+
extracted: dict[str, list[str]] = {
|
|
47
|
+
"business_name": [],
|
|
48
|
+
"categories": [],
|
|
49
|
+
"locations": [],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if not model:
|
|
53
|
+
return extracted
|
|
54
|
+
|
|
55
|
+
# Truncate to ~2000 chars to stay within 512 token limit
|
|
56
|
+
truncated_text = text[:2000]
|
|
57
|
+
|
|
58
|
+
hf_results = model(truncated_text)
|
|
59
|
+
|
|
60
|
+
for entity in hf_results:
|
|
61
|
+
label = entity.get("entity_group")
|
|
62
|
+
word = entity.get("word", "").strip()
|
|
63
|
+
score = float(entity.get("score", 0))
|
|
64
|
+
|
|
65
|
+
# Only keep confident extractions
|
|
66
|
+
if score < 0.30:
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
if label == "BUSINESS_NAME":
|
|
70
|
+
extracted["business_name"].append(word)
|
|
71
|
+
elif label == "LOCATION":
|
|
72
|
+
extracted["locations"].append(word)
|
|
73
|
+
elif label == "SERVICE_CATEGORY":
|
|
74
|
+
extracted["categories"].append(word)
|
|
75
|
+
|
|
76
|
+
return extracted
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ACUITY Framework — Extraction Pipeline Orchestrator
|
|
3
|
+
|
|
4
|
+
Coordinates the full extraction flow:
|
|
5
|
+
Raw post text → preprocessing → NER → rule-based extraction → profile construction
|
|
6
|
+
|
|
7
|
+
This is the main public API for the extraction module. It is fully decoupled
|
|
8
|
+
from any web framework, database, or frontend — it takes text in and returns
|
|
9
|
+
structured profiles out.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
>>> from acuity.extraction import ExtractionPipeline
|
|
13
|
+
>>> pipeline = ExtractionPipeline()
|
|
14
|
+
>>> profiles = pipeline.extract_from_texts([
|
|
15
|
+
... "Mang Juan's Bakery, Mamatid, open 8am-5pm, 0917-123-4567",
|
|
16
|
+
... ])
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from .preprocessing import preprocess
|
|
23
|
+
from .rules import extract_structured_fields
|
|
24
|
+
from .postprocessing import build_business_profile
|
|
25
|
+
from ..config import AcuityConfig
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ExtractionPipeline:
|
|
29
|
+
"""End-to-end extraction pipeline for community post text.
|
|
30
|
+
|
|
31
|
+
This class coordinates preprocessing, NER, rule-based extraction,
|
|
32
|
+
and profile construction into a single callable pipeline.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
config: An ``AcuityConfig`` instance. If ``None``, uses defaults.
|
|
36
|
+
ner_model: A pre-loaded NER model object. If provided, the pipeline
|
|
37
|
+
will use this model directly instead of loading from ``config.ner_model_path``.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, config: AcuityConfig | None = None, ner_model: Any = None):
|
|
41
|
+
self.config = config or AcuityConfig()
|
|
42
|
+
self._ner_model = ner_model
|
|
43
|
+
self._ner_extract_fn = None
|
|
44
|
+
self._setup_ner()
|
|
45
|
+
|
|
46
|
+
def _setup_ner(self) -> None:
|
|
47
|
+
"""Initialise the NER backend based on configuration."""
|
|
48
|
+
backend = self.config.ner_backend
|
|
49
|
+
|
|
50
|
+
if backend == "crf":
|
|
51
|
+
from .ner_crf import extract_entities_crf, load_crf_model
|
|
52
|
+
|
|
53
|
+
if self._ner_model is None and self.config.ner_model_path:
|
|
54
|
+
self._ner_model = load_crf_model(self.config.ner_model_path)
|
|
55
|
+
|
|
56
|
+
self._ner_extract_fn = lambda text: extract_entities_crf(text, self._ner_model)
|
|
57
|
+
|
|
58
|
+
elif backend == "transformer":
|
|
59
|
+
from .ner_transformer import extract_entities_transformer, load_transformer_model
|
|
60
|
+
|
|
61
|
+
if self._ner_model is None and self.config.ner_model_path:
|
|
62
|
+
self._ner_model = load_transformer_model(self.config.ner_model_path)
|
|
63
|
+
|
|
64
|
+
self._ner_extract_fn = lambda text: extract_entities_transformer(text, self._ner_model)
|
|
65
|
+
|
|
66
|
+
else:
|
|
67
|
+
raise ValueError(f"Unknown NER backend: {backend!r}. Use 'crf' or 'transformer'.")
|
|
68
|
+
|
|
69
|
+
def extract_single(
|
|
70
|
+
self,
|
|
71
|
+
text: str,
|
|
72
|
+
metadata: dict | None = None,
|
|
73
|
+
poster_name: str | None = None,
|
|
74
|
+
) -> dict | None:
|
|
75
|
+
"""Run the full extraction pipeline on a single post.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
text: Raw post text.
|
|
79
|
+
metadata: Optional metadata dict (e.g., source_index, scraped_at).
|
|
80
|
+
poster_name: Optional poster name to use as fallback business name.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
A business profile dict, or ``None`` if not enough info was extracted.
|
|
84
|
+
"""
|
|
85
|
+
# Step 1: Preprocess (clean, normalise)
|
|
86
|
+
cleaned = preprocess(text)
|
|
87
|
+
|
|
88
|
+
# Step 2: Named Entity Recognition
|
|
89
|
+
entities = self._ner_extract_fn(cleaned) if self._ner_extract_fn else {
|
|
90
|
+
"business_name": [], "categories": [], "locations": []
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# Step 3: Rule-based extraction (contacts, hours, address patterns)
|
|
94
|
+
structured = extract_structured_fields(cleaned)
|
|
95
|
+
|
|
96
|
+
# Step 4: Build business profile
|
|
97
|
+
profile = build_business_profile(
|
|
98
|
+
raw_text=text,
|
|
99
|
+
entities=entities,
|
|
100
|
+
structured_fields=structured,
|
|
101
|
+
metadata=metadata,
|
|
102
|
+
poster_name=poster_name,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return profile
|
|
106
|
+
|
|
107
|
+
def extract_from_texts(
|
|
108
|
+
self,
|
|
109
|
+
texts: list[str | dict],
|
|
110
|
+
completeness_threshold: int | None = None,
|
|
111
|
+
deduplicate: bool = True,
|
|
112
|
+
) -> list[dict]:
|
|
113
|
+
"""Run extraction on a batch of texts and return quality-filtered profiles.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
texts: A list of raw text strings, or a list of dicts with at least
|
|
117
|
+
a ``"text"`` key (and optionally ``"poster"`` and ``"scraped_at"``).
|
|
118
|
+
completeness_threshold: Minimum number of populated detail fields
|
|
119
|
+
(categories, locations, phones, prices, hours) to keep a profile.
|
|
120
|
+
Defaults to ``config.completeness_threshold``.
|
|
121
|
+
deduplicate: If True, deduplicate profiles by business name.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
List of extracted business profile dicts.
|
|
125
|
+
"""
|
|
126
|
+
threshold = completeness_threshold if completeness_threshold is not None else self.config.completeness_threshold
|
|
127
|
+
|
|
128
|
+
profiles = []
|
|
129
|
+
for i, item in enumerate(texts):
|
|
130
|
+
# Accept either plain strings or dicts
|
|
131
|
+
if isinstance(item, str):
|
|
132
|
+
raw_text = item
|
|
133
|
+
poster = None
|
|
134
|
+
scraped_at = None
|
|
135
|
+
else:
|
|
136
|
+
raw_text = item.get("text", "")
|
|
137
|
+
poster = item.get("poster")
|
|
138
|
+
scraped_at = item.get("scraped_at")
|
|
139
|
+
|
|
140
|
+
profile = self.extract_single(
|
|
141
|
+
text=raw_text,
|
|
142
|
+
metadata={"source_index": i, "scraped_at": scraped_at},
|
|
143
|
+
poster_name=poster,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if profile:
|
|
147
|
+
# Filter weak profiles by counting populated detail fields
|
|
148
|
+
detail_lists = [
|
|
149
|
+
profile.get("categories", []),
|
|
150
|
+
profile.get("locations", []),
|
|
151
|
+
profile.get("phones", []),
|
|
152
|
+
profile.get("prices", []),
|
|
153
|
+
profile.get("hours", []),
|
|
154
|
+
]
|
|
155
|
+
populated = sum(1 for field in detail_lists if len(field) > 0)
|
|
156
|
+
|
|
157
|
+
if populated >= threshold:
|
|
158
|
+
profiles.append(profile)
|
|
159
|
+
|
|
160
|
+
# Deduplicate by business name
|
|
161
|
+
if deduplicate:
|
|
162
|
+
seen_names: set[str] = set()
|
|
163
|
+
unique_profiles = []
|
|
164
|
+
for p in profiles:
|
|
165
|
+
name = p.get("business_name")
|
|
166
|
+
if name and name not in seen_names:
|
|
167
|
+
unique_profiles.append(p)
|
|
168
|
+
seen_names.add(name)
|
|
169
|
+
profiles = unique_profiles
|
|
170
|
+
|
|
171
|
+
return profiles
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ACUITY Framework — Post-processing & Profile Construction
|
|
3
|
+
|
|
4
|
+
Merges NER outputs and rule-based fields into a unified business profile,
|
|
5
|
+
performs validation, and assigns confidence scores.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def format_business_name(name: str) -> str:
|
|
13
|
+
"""Normalise and title-case a raw business name string.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
name: Raw business name extracted from a post.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Cleaned and title-cased name.
|
|
20
|
+
"""
|
|
21
|
+
if not name:
|
|
22
|
+
return name
|
|
23
|
+
cleaned = re.sub(r'[#.,!_]', ' ', name)
|
|
24
|
+
cleaned = re.sub(r'([a-z])([A-Z])', r'\1 \2', cleaned)
|
|
25
|
+
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
|
|
26
|
+
return cleaned.title()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_business_profile(
|
|
30
|
+
raw_text: str,
|
|
31
|
+
entities: dict,
|
|
32
|
+
structured_fields: dict,
|
|
33
|
+
metadata: dict | None = None,
|
|
34
|
+
poster_name: str | None = None,
|
|
35
|
+
) -> dict | None:
|
|
36
|
+
"""Construct a business profile dict from extraction outputs.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
raw_text: The original post text.
|
|
40
|
+
entities: Output from NER (``business_name``, ``categories``, ``locations``).
|
|
41
|
+
structured_fields: Output from rule-based extraction (``phones``, ``prices``, ``hours``).
|
|
42
|
+
metadata: Optional metadata (``source_index``, ``scraped_at``, etc.).
|
|
43
|
+
poster_name: Optional poster name to fall back to if business_name is missing.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
A business profile dict, or ``None`` if insufficient information was extracted.
|
|
47
|
+
"""
|
|
48
|
+
b_names = entities.get("business_name", [])
|
|
49
|
+
raw_business_name = " ".join(b_names) if b_names else None
|
|
50
|
+
if not raw_business_name and poster_name:
|
|
51
|
+
raw_business_name = poster_name
|
|
52
|
+
|
|
53
|
+
business_name = format_business_name(raw_business_name) if raw_business_name else None
|
|
54
|
+
|
|
55
|
+
profile = {
|
|
56
|
+
"business_name": business_name,
|
|
57
|
+
"categories": entities.get("categories", []),
|
|
58
|
+
"locations": entities.get("locations", []),
|
|
59
|
+
"phones": structured_fields.get("phones", []),
|
|
60
|
+
"prices": structured_fields.get("prices", []),
|
|
61
|
+
"hours": structured_fields.get("hours", []),
|
|
62
|
+
"description": raw_text,
|
|
63
|
+
"metadata": metadata or {},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Basic validation: require at least some extractable information
|
|
67
|
+
has_info = (
|
|
68
|
+
profile["business_name"]
|
|
69
|
+
or profile["categories"]
|
|
70
|
+
or profile["phones"]
|
|
71
|
+
or profile["prices"]
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return profile if has_info else None
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ACUITY Framework — Text Preprocessing
|
|
3
|
+
|
|
4
|
+
Cleans and normalises raw social media post text for downstream NLP tasks.
|
|
5
|
+
Handles Taglish (Tagalog-English code-switching), informal spelling,
|
|
6
|
+
and social-media-specific noise.
|
|
7
|
+
"""
|
|
8
|
+
import re
|
|
9
|
+
import unicodedata
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def preprocess(text: str) -> str:
|
|
13
|
+
"""Full preprocessing pipeline for a single post.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
text: Raw post text.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Cleaned and normalised text.
|
|
20
|
+
"""
|
|
21
|
+
text = normalise_unicode(text)
|
|
22
|
+
text = remove_urls(text)
|
|
23
|
+
text = remove_emojis(text)
|
|
24
|
+
text = normalise_whitespace(text)
|
|
25
|
+
return text.strip()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def normalise_unicode(text: str) -> str:
|
|
29
|
+
"""Normalise Unicode characters (e.g. fullwidth → ASCII)."""
|
|
30
|
+
return unicodedata.normalize("NFKC", text)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def remove_urls(text: str) -> str:
|
|
34
|
+
"""Strip URLs from the text."""
|
|
35
|
+
return re.sub(r"https?://\S+", "", text)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def remove_emojis(text: str) -> str:
|
|
39
|
+
"""Remove common emoji / symbol characters."""
|
|
40
|
+
emoji_pattern = re.compile(
|
|
41
|
+
"["
|
|
42
|
+
"\U0001f600-\U0001f64f" # emoticons
|
|
43
|
+
"\U0001f300-\U0001f5ff" # symbols & pictographs
|
|
44
|
+
"\U0001f680-\U0001f6ff" # transport & map
|
|
45
|
+
"\U0001f1e0-\U0001f1ff" # flags
|
|
46
|
+
"\U00002700-\U000027bf" # dingbats
|
|
47
|
+
"\U0000fe00-\U0000fe0f" # variation selectors
|
|
48
|
+
"\U0000200d" # zero width joiner
|
|
49
|
+
"]+",
|
|
50
|
+
flags=re.UNICODE,
|
|
51
|
+
)
|
|
52
|
+
return emoji_pattern.sub("", text)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def normalise_whitespace(text: str) -> str:
|
|
56
|
+
"""Collapse multiple whitespace characters into single spaces."""
|
|
57
|
+
return re.sub(r"\s+", " ", text)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ACUITY Framework — Rule-Based Extraction
|
|
3
|
+
|
|
4
|
+
Complements the NER module by extracting structured fields that follow
|
|
5
|
+
predictable patterns in community posts:
|
|
6
|
+
- Phone numbers (PH mobile formats)
|
|
7
|
+
- Addresses / barangay references
|
|
8
|
+
- Operating hours
|
|
9
|
+
- Price mentions
|
|
10
|
+
"""
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Pattern definitions
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
# Philippine mobile: 09XX-XXX-XXXX or +639XX-XXX-XXXX
|
|
19
|
+
PHONE_PATTERN = re.compile(
|
|
20
|
+
r"(?:\+63|0)9\d{2}[\s\-]?\d{3}[\s\-]?\d{4}"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Simple price patterns (₱, PHP, P followed by digits)
|
|
24
|
+
PRICE_PATTERN = re.compile(
|
|
25
|
+
r"(?:[₱Pp](?:HP)?)\s?\d[\d,]*(?:\.\d{2})?"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Operating hours heuristic (e.g. "open 8am-5pm", "available 24/7")
|
|
29
|
+
HOURS_PATTERN = re.compile(
|
|
30
|
+
r"\b(?:open|available|hours?)\b.*?\d{1,2}\s?(?:am|pm|AM|PM)",
|
|
31
|
+
re.IGNORECASE,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def extract_structured_fields(text: str) -> dict:
|
|
36
|
+
"""Extract structured information from *text* using regex patterns.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
text: Preprocessed post text.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
dict with keys: ``phones``, ``prices``, ``hours``.
|
|
43
|
+
Each value is a list of matched strings.
|
|
44
|
+
"""
|
|
45
|
+
return {
|
|
46
|
+
"phones": PHONE_PATTERN.findall(text),
|
|
47
|
+
"prices": PRICE_PATTERN.findall(text),
|
|
48
|
+
"hours": HOURS_PATTERN.findall(text),
|
|
49
|
+
}
|