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 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
+ }