unitpulse-channels 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 UnitPulse
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: unitpulse-channels
3
+ Version: 0.1.0
4
+ Summary: Canonical communication-channel enum and lead-source registry
5
+ Author: UnitPulse
6
+ Author-email: honglu.he@unitpulse.ai
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.8
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: author
21
+ Dynamic: author-email
22
+ Dynamic: classifier
23
+ Dynamic: description
24
+ Dynamic: description-content-type
25
+ Dynamic: license-file
26
+ Dynamic: requires-python
27
+ Dynamic: summary
28
+
29
+ # unitpulse-channels
30
+
31
+ Single source of truth for UnitPulse's communication-channel and lead-source
32
+ vocabulary. Import it everywhere a `channel` or `lead_source` value is produced
33
+ so services stop re-declaring (and drifting) their own lists.
34
+
35
+ ## Concepts
36
+
37
+ Three distinct things that were historically conflated into one free-form field:
38
+
39
+ | concept | field | governance |
40
+ |---|---|---|
41
+ | **channel** | per-message medium | CLOSED enum (`Channel`) |
42
+ | **first_touch_channel** | mode of first contact (prospect) | same `Channel` enum |
43
+ | **lead_source** | acquisition / attribution | OPEN curated registry (`LEAD_SOURCES`) |
44
+
45
+ `channel` is closed: unknown input → `other` (+ WARN log). `lead_source` is an
46
+ open curated registry: unknown input → `other` but the original raw string is
47
+ preserved so new sources can be promoted by review.
48
+
49
+ ## Usage
50
+
51
+ ```python
52
+ from unitpulse_channels import (
53
+ normalize_channel, normalize_lead_source, classify_prospect_source,
54
+ )
55
+
56
+ normalize_channel("FB Messenger") # -> "fb messenger"
57
+ normalize_channel("call in") # -> "phone"
58
+ normalize_channel("walk in") # -> "in_person"
59
+
60
+ normalize_lead_source("ZILLOW").value # -> "zillow"
61
+ normalize_lead_source("1133 Hope") # -> LeadSource(value="other", raw="1133 Hope")
62
+
63
+ # Split a raw prospect "source" into both dimensions:
64
+ classify_prospect_source("Walk in") # -> ("direct", "in_person")
65
+ classify_prospect_source("Zillow") # -> ("zillow", None) # derive channel from 1st activity
66
+ classify_prospect_source("Facebook Messenger") # -> ("facebook", "fb messenger")
67
+ ```
68
+
69
+ Design + backfill: `design-docs` → `unitpulse/specs/2026-06-18-channel-enum-cleanup.md`.
@@ -0,0 +1,41 @@
1
+ # unitpulse-channels
2
+
3
+ Single source of truth for UnitPulse's communication-channel and lead-source
4
+ vocabulary. Import it everywhere a `channel` or `lead_source` value is produced
5
+ so services stop re-declaring (and drifting) their own lists.
6
+
7
+ ## Concepts
8
+
9
+ Three distinct things that were historically conflated into one free-form field:
10
+
11
+ | concept | field | governance |
12
+ |---|---|---|
13
+ | **channel** | per-message medium | CLOSED enum (`Channel`) |
14
+ | **first_touch_channel** | mode of first contact (prospect) | same `Channel` enum |
15
+ | **lead_source** | acquisition / attribution | OPEN curated registry (`LEAD_SOURCES`) |
16
+
17
+ `channel` is closed: unknown input → `other` (+ WARN log). `lead_source` is an
18
+ open curated registry: unknown input → `other` but the original raw string is
19
+ preserved so new sources can be promoted by review.
20
+
21
+ ## Usage
22
+
23
+ ```python
24
+ from unitpulse_channels import (
25
+ normalize_channel, normalize_lead_source, classify_prospect_source,
26
+ )
27
+
28
+ normalize_channel("FB Messenger") # -> "fb messenger"
29
+ normalize_channel("call in") # -> "phone"
30
+ normalize_channel("walk in") # -> "in_person"
31
+
32
+ normalize_lead_source("ZILLOW").value # -> "zillow"
33
+ normalize_lead_source("1133 Hope") # -> LeadSource(value="other", raw="1133 Hope")
34
+
35
+ # Split a raw prospect "source" into both dimensions:
36
+ classify_prospect_source("Walk in") # -> ("direct", "in_person")
37
+ classify_prospect_source("Zillow") # -> ("zillow", None) # derive channel from 1st activity
38
+ classify_prospect_source("Facebook Messenger") # -> ("facebook", "fb messenger")
39
+ ```
40
+
41
+ Design + backfill: `design-docs` → `unitpulse/specs/2026-06-18-channel-enum-cleanup.md`.
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,31 @@
1
+ """Setup for unitpulse-channels SDK."""
2
+
3
+ from pathlib import Path
4
+ from setuptools import setup, find_packages
5
+
6
+ long_description = Path(__file__).parent.joinpath("README.md").read_text(encoding="utf-8")
7
+
8
+ setup(
9
+ name="unitpulse-channels",
10
+ version="0.1.0",
11
+ author="UnitPulse",
12
+ author_email="honglu.he@unitpulse.ai",
13
+ description="Canonical communication-channel enum and lead-source registry",
14
+ long_description=long_description,
15
+ long_description_content_type="text/markdown",
16
+ packages=find_packages(exclude=["tests*"]),
17
+ python_requires=">=3.8",
18
+ install_requires=[],
19
+ classifiers=[
20
+ "Development Status :: 3 - Alpha",
21
+ "Intended Audience :: Developers",
22
+ "License :: OSI Approved :: MIT License",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.8",
25
+ "Programming Language :: Python :: 3.9",
26
+ "Programming Language :: Python :: 3.10",
27
+ "Programming Language :: Python :: 3.11",
28
+ "Programming Language :: Python :: 3.12",
29
+ "Topic :: Software Development :: Libraries :: Python Modules",
30
+ ],
31
+ )
@@ -0,0 +1,60 @@
1
+ import logging
2
+ import pytest
3
+ from unitpulse_channels import Channel, normalize_channel, CANONICAL_CHANNELS, is_canonical
4
+
5
+
6
+ @pytest.mark.parametrize("raw,expected", [
7
+ ("sms", "sms"),
8
+ ("SMS", "sms"),
9
+ (" Email ", "email"),
10
+ ("sms_callback", "sms"), # direction stripped from channel
11
+ ("email_callback", "email"),
12
+ ("gmail", "email"),
13
+ ("call in", "phone"),
14
+ ("call-in", "phone"),
15
+ ("fb messenger", "fb messenger"),
16
+ ("fb-messenger", "fb messenger"),
17
+ ("Facebook Messenger", "fb messenger"),
18
+ ("wechat", "wecom"),
19
+ ("wecom", "wecom"),
20
+ ("rednote", "rednote"),
21
+ ("website chatbot", "website_chatbot"),
22
+ ("walk in", "in_person"),
23
+ ("walk-in", "in_person"),
24
+ ("in person", "in_person"),
25
+ ("system", "system"),
26
+ ("NOTE", "system"),
27
+ ])
28
+ def test_known_aliases_normalize(raw, expected):
29
+ assert normalize_channel(raw) == expected
30
+ assert is_canonical(normalize_channel(raw))
31
+
32
+
33
+ @pytest.mark.parametrize("raw", ["Zillow", "ApartmentList", "1133 Hope", "weird-thing"])
34
+ def test_unknown_maps_to_other_and_logs(raw, caplog):
35
+ with caplog.at_level(logging.WARNING):
36
+ assert normalize_channel(raw) == "other"
37
+ assert any(raw in str(rec.args) or raw in rec.getMessage() for rec in caplog.records)
38
+
39
+
40
+ @pytest.mark.parametrize("raw", [None, "", " "])
41
+ def test_empty_or_none_maps_to_other(raw):
42
+ assert normalize_channel(raw) == "other"
43
+
44
+
45
+ def test_canonical_set_is_closed():
46
+ assert CANONICAL_CHANNELS == {
47
+ "sms", "email", "phone", "fb messenger", "wecom", "rednote",
48
+ "website_chatbot", "in_person", "system", "other",
49
+ }
50
+
51
+
52
+ def test_enum_values_match_strings():
53
+ assert Channel.FB_MESSENGER == "fb messenger"
54
+ assert Channel.IN_PERSON.value == "in_person"
55
+ assert Channel.PHONE.value == "phone"
56
+
57
+
58
+ def test_bare_facebook_is_not_a_channel():
59
+ # bare "facebook" is a lead SOURCE, not the fb messenger channel
60
+ assert normalize_channel("facebook") == "other"
@@ -0,0 +1,63 @@
1
+ import logging
2
+ import pytest
3
+ from unitpulse_channels import (
4
+ LEAD_SOURCES, normalize_lead_source, is_known_lead_source,
5
+ classify_prospect_source,
6
+ )
7
+
8
+
9
+ @pytest.mark.parametrize("raw,expected", [
10
+ ("Zillow", "zillow"),
11
+ ("ZILLOW", "zillow"),
12
+ ("ApartmentList", "apartmentlist"),
13
+ ("Apartmentlist", "apartmentlist"),
14
+ ("apartments.com", "apartments_com"),
15
+ ("EveryApartment", "everyapartment"),
16
+ ("Furnished Finder", "furnished_finder"),
17
+ ("Appfolio", "appfolio"),
18
+ ("Facebook", "facebook"),
19
+ ("Referral", "referral"),
20
+ ])
21
+ def test_known_sources_normalize_to_curated_slug(raw, expected):
22
+ result = normalize_lead_source(raw)
23
+ assert result.value == expected
24
+ assert is_known_lead_source(result.value)
25
+ assert result.raw == raw.strip()
26
+
27
+
28
+ @pytest.mark.parametrize("raw", ["1133 Hope", "tripalink2 unknown blah", "some-new-campaign-2026"])
29
+ def test_unknown_source_buckets_to_other_but_keeps_raw(raw, caplog):
30
+ with caplog.at_level(logging.WARNING):
31
+ result = normalize_lead_source(raw)
32
+ assert result.value == "other"
33
+ assert result.raw == raw.strip()
34
+
35
+
36
+ @pytest.mark.parametrize("raw", [None, "", " "])
37
+ def test_empty_source_is_other_with_empty_raw(raw):
38
+ result = normalize_lead_source(raw)
39
+ assert result.value == "other"
40
+ assert result.raw == ""
41
+
42
+
43
+ def test_registry_excludes_channels():
44
+ # walk_in/call_in and pure media are channels, not sources
45
+ for not_a_source in ("walk_in", "call_in", "sms", "wecom", "rednote"):
46
+ assert not_a_source not in LEAD_SOURCES
47
+
48
+
49
+ @pytest.mark.parametrize("raw,src,ch", [
50
+ ("Walk in", "direct", "in_person"),
51
+ ("Call In", "direct", "phone"),
52
+ ("Email", "direct", "email"),
53
+ ("Zillow", "zillow", None), # channel derived from 1st activity
54
+ ("Facebook Messenger", "facebook", "fb messenger"),
55
+ ("CXM Call-in_Zillow", "zillow", "phone"), # composite: source + channel
56
+ ("Apartmentlists", "apartmentlist", None), # legacy spelling
57
+ ("1133 Hope", "property_website", None), # property name -> property_website
58
+ ("some-brand-new-thing", "other", None), # unrecognized but present -> other
59
+ ("", None, None), # no source at all -> unknown
60
+ (None, None, None),
61
+ ])
62
+ def test_classify_prospect_source_splits_both_dimensions(raw, src, ch):
63
+ assert classify_prospect_source(raw) == (src, ch)
@@ -0,0 +1,36 @@
1
+ """Canonical UnitPulse communication-channel and lead-source vocabulary.
2
+
3
+ Single source of truth for:
4
+ * Channel closed enum (communication medium / first-touch)
5
+ * normalize_channel coerce any string -> canonical channel
6
+ * LEAD_SOURCES curated (open) lead-source registry
7
+ * normalize_lead_source coerce any string -> LeadSource(value, raw)
8
+ * classify_prospect_source split a raw prospect source into (source, channel)
9
+ """
10
+ from .channel import (
11
+ Channel,
12
+ CANONICAL_CHANNELS,
13
+ normalize_channel,
14
+ is_canonical,
15
+ )
16
+ from .lead_source import (
17
+ LeadSource,
18
+ LEAD_SOURCES,
19
+ normalize_lead_source,
20
+ is_known_lead_source,
21
+ classify_prospect_source,
22
+ )
23
+
24
+ __all__ = [
25
+ "Channel",
26
+ "CANONICAL_CHANNELS",
27
+ "normalize_channel",
28
+ "is_canonical",
29
+ "LeadSource",
30
+ "LEAD_SOURCES",
31
+ "normalize_lead_source",
32
+ "is_known_lead_source",
33
+ "classify_prospect_source",
34
+ ]
35
+
36
+ __version__ = "0.1.0"
@@ -0,0 +1,148 @@
1
+ """Canonical communication-channel enum and normalizer.
2
+
3
+ `channel` is a CLOSED set: the communication MEDIUM of a contact. The same enum
4
+ serves both the per-message medium (activities_history[].channel) and the
5
+ prospect-level first_touch_channel. Direction (inbound/outbound) and acquisition
6
+ source (Zillow, ApartmentList, ...) are NOT channels and belong elsewhere.
7
+ """
8
+ import logging
9
+ import re
10
+ from enum import Enum
11
+ from typing import Optional
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class Channel(str, Enum):
17
+ SMS = "sms"
18
+ EMAIL = "email"
19
+ PHONE = "phone" # inbound "call-in" is first_touch_channel=phone
20
+ FB_MESSENGER = "fb messenger" # value matches the system-wide live constant (with space)
21
+ WECOM = "wecom"
22
+ REDNOTE = "rednote"
23
+ WEBSITE_CHATBOT = "website_chatbot"
24
+ IN_PERSON = "in_person" # "walk-in" (face-to-face)
25
+ SYSTEM = "system"
26
+ OTHER = "other"
27
+
28
+
29
+ CANONICAL_CHANNELS = frozenset(c.value for c in Channel)
30
+
31
+ # Known spellings/synonyms -> canonical Channel. Keys MUST be lowercase +
32
+ # whitespace-stripped (lookups lowercase/strip first). Extend as new spellings
33
+ # appear. Bare "facebook" is intentionally NOT here (it is a lead SOURCE); only
34
+ # explicit messenger spellings map to the FB_MESSENGER channel.
35
+ _ALIASES = {
36
+ "sms": Channel.SMS,
37
+ "sms_callback": Channel.SMS,
38
+ "text message": Channel.SMS,
39
+ "email": Channel.EMAIL,
40
+ "email_callback": Channel.EMAIL,
41
+ "gmail": Channel.EMAIL,
42
+ "outlook": Channel.EMAIL,
43
+ "yahoo": Channel.EMAIL,
44
+ "yahoo mail": Channel.EMAIL,
45
+ "icloud": Channel.EMAIL,
46
+ "phone": Channel.PHONE,
47
+ "call in": Channel.PHONE,
48
+ "call-in": Channel.PHONE,
49
+ "callin": Channel.PHONE,
50
+ "cxm call-in": Channel.PHONE,
51
+ "phone call": Channel.PHONE,
52
+ "fb messenger": Channel.FB_MESSENGER,
53
+ "fb-messenger": Channel.FB_MESSENGER,
54
+ "fb_messenger": Channel.FB_MESSENGER,
55
+ "facebook messenger": Channel.FB_MESSENGER,
56
+ "facebook_messenger": Channel.FB_MESSENGER,
57
+ "messenger": Channel.FB_MESSENGER,
58
+ "wecom": Channel.WECOM,
59
+ "wechat": Channel.WECOM,
60
+ "rednote": Channel.REDNOTE,
61
+ "red note": Channel.REDNOTE,
62
+ "website_chatbot": Channel.WEBSITE_CHATBOT,
63
+ "website chatbot": Channel.WEBSITE_CHATBOT,
64
+ "web chatbot": Channel.WEBSITE_CHATBOT,
65
+ "website chatbox": Channel.WEBSITE_CHATBOT,
66
+ "website-chatbot": Channel.WEBSITE_CHATBOT,
67
+ "walk in": Channel.IN_PERSON,
68
+ "walk-in": Channel.IN_PERSON,
69
+ "walkin": Channel.IN_PERSON,
70
+ "in person": Channel.IN_PERSON,
71
+ "in-person": Channel.IN_PERSON,
72
+ "face to face": Channel.IN_PERSON,
73
+ "system": Channel.SYSTEM,
74
+ "note": Channel.SYSTEM,
75
+ }
76
+
77
+
78
+ def _lookup_channel(raw: Optional[str]) -> Optional[str]:
79
+ """Return the canonical channel value for an exact alias hit, else None.
80
+
81
+ Does NOT log or coerce; used by both normalize_channel and the combined
82
+ prospect-source classifier (which must probe without emitting warnings).
83
+ """
84
+ if raw is None:
85
+ return None
86
+ key = str(raw).strip().lower()
87
+ if not key:
88
+ return None
89
+ mapped = _ALIASES.get(key)
90
+ return mapped.value if mapped is not None else None
91
+
92
+
93
+ def normalize_channel(raw: Optional[str]) -> str:
94
+ """Coerce any inbound channel string to a canonical Channel value.
95
+
96
+ Unknown / empty / None inputs map to Channel.OTHER and emit a WARN log
97
+ carrying the original value so a worklist of un-canonicalized inputs can be
98
+ mined from logs.
99
+ """
100
+ v = _lookup_channel(raw)
101
+ if v is not None:
102
+ return v
103
+ if raw is None or not str(raw).strip():
104
+ logger.warning("normalize_channel: empty/None channel -> 'other'")
105
+ else:
106
+ logger.warning("normalize_channel: unknown channel %r -> 'other'", raw)
107
+ return Channel.OTHER.value
108
+
109
+
110
+ def is_canonical(value: str) -> bool:
111
+ return value in CANONICAL_CHANNELS
112
+
113
+
114
+ # Ordered, first-match-wins substring/regex rules for classifying a MESSY prospect
115
+ # "source" string's channel dimension (composites like "CXM Call-in_Zillow",
116
+ # legacy spellings, etc.). This is the readable Python port of the SQL backfill's
117
+ # regex CASE — deterministic, unit-tested, and shared by the backfill + the LLM
118
+ # tagger's residual selection. `normalize_channel` above stays the fast exact-key
119
+ # path for clean forward-writes; these rules are the fuzzy fallback for the
120
+ # backfill. Order matters (e.g. "walk" before "call": a "cxm call-in_walk in"
121
+ # value is an in-person visit).
122
+ _CHANNEL_RULES = [
123
+ (re.compile(r"walk"), Channel.IN_PERSON),
124
+ (re.compile(r"in.person|face.to.face"), Channel.IN_PERSON),
125
+ (re.compile(r"call.?in|cxm call|phone"), Channel.PHONE),
126
+ (re.compile(r"messenger"), Channel.FB_MESSENGER),
127
+ (re.compile(r"website chatbot|web chatbot|chatbox|website_chatbot"), Channel.WEBSITE_CHATBOT),
128
+ (re.compile(r"wechat"), Channel.WECOM),
129
+ (re.compile(r"rednote|red note|^red$"), Channel.REDNOTE),
130
+ (re.compile(r"gmail|outlook|yahoo|icloud|e-?mail"), Channel.EMAIL),
131
+ (re.compile(r"^sms$|text message"), Channel.SMS),
132
+ ]
133
+
134
+
135
+ def regex_channel(raw: Optional[str]) -> Optional[str]:
136
+ """Fuzzy channel classification for a messy source string. Returns a canonical
137
+ channel value, or None when the string carries no channel signal. Unlike
138
+ normalize_channel this does NOT coerce unknowns to 'other' — a missing signal
139
+ is None so the caller can fall back to deriving the channel from activity."""
140
+ if raw is None:
141
+ return None
142
+ key = str(raw).strip().lower()
143
+ if not key:
144
+ return None
145
+ for pattern, channel in _CHANNEL_RULES:
146
+ if pattern.search(key):
147
+ return channel.value
148
+ return None
@@ -0,0 +1,196 @@
1
+ """Curated lead-source registry, normalizer, and combined prospect classifier.
2
+
3
+ `lead_source` is an OPEN but curated registry (acquisition/attribution). Unknown
4
+ values coerce to "other" but the original raw string is preserved so genuinely
5
+ new sources can be promoted into the registry by review.
6
+
7
+ Unlike `channel`, this set is extensible. Channel-type strings (walk in, call in,
8
+ email, wechat, ...) are NOT sources -- they classify as channels.
9
+ """
10
+ import logging
11
+ import re
12
+ from dataclasses import dataclass
13
+ from typing import Optional, Tuple
14
+
15
+ from .channel import regex_channel
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Curated canonical lead-source slugs. Finalized from a full production
20
+ # customers_wide_table dry-run (~300 raw values). Extend by adding a slug here
21
+ # and its spellings to _SOURCE_ALIASES.
22
+ LEAD_SOURCES = frozenset({
23
+ # ILS / marketplaces
24
+ "zillow", "apartmentlist", "apartments_com", "everyapartment",
25
+ "furnished_finder", "zumper", "hotpads", "uloop", "padmapper",
26
+ "realtor_com", "redfin", "trulia", "rent_com", "rentcafe", "entrata",
27
+ "airbnb", "college_pads", "university_living", "uhomes",
28
+ # social / search
29
+ "facebook", "instagram", "google",
30
+ # owned / direct / PMS
31
+ "property_website", "appfolio", "unitpulse_app", "jotform", "tripalink",
32
+ "direct",
33
+ # offline / people
34
+ "referral", "signage", "campus_outreach", "existing_resident",
35
+ # system / catch-all
36
+ "internal_system", "other",
37
+ })
38
+
39
+ # Exact-key spellings -> canonical slug (lowercase + whitespace-collapsed keys).
40
+ # Starter map for clean forward-writes; the FULL historical map is seeded from
41
+ # the reviewed migration.source_channel_map after the backfill (see the
42
+ # backfill runbook + spec Op 8). Extend as needed.
43
+ _SOURCE_ALIASES = {
44
+ "zillow": "zillow", "alexis-zillow": "zillow",
45
+ "apartmentlist": "apartmentlist", "apartment list": "apartmentlist",
46
+ "apartmentlists": "apartmentlist",
47
+ "apartments": "apartments_com", "apartments.com": "apartments_com",
48
+ "apartment.com": "apartments_com", "apartments247": "apartments_com",
49
+ "everyapartment": "everyapartment", "every apartment": "everyapartment",
50
+ "furnished finder": "furnished_finder",
51
+ "zumper": "zumper", "hotpads": "hotpads", "uloop": "uloop",
52
+ "padmapper": "padmapper", "realtor.com": "realtor_com", "realtor": "realtor_com",
53
+ "redfin": "redfin", "trulia": "trulia",
54
+ "rentcafe": "rentcafe", "rent cafe": "rentcafe",
55
+ "rent.com": "rent_com", "rent": "rent_com",
56
+ "entrata": "entrata", "airbnb": "airbnb",
57
+ "college pads": "college_pads", "rent college pads": "college_pads",
58
+ "collegerentals": "college_pads",
59
+ "university living": "university_living",
60
+ "uhome": "uhomes", "uhomes": "uhomes", "awehome": "uhomes",
61
+ "facebook": "facebook", "facebook messenger": "facebook",
62
+ "facebook marketplace and groups": "facebook",
63
+ "instagram": "instagram",
64
+ "google": "google", "google search": "google",
65
+ "appfolio": "appfolio", "jotform": "jotform",
66
+ "property website": "property_website", "website": "property_website",
67
+ "tripalink": "tripalink",
68
+ "referral": "referral", "friend referral": "referral",
69
+ "word of mouth": "referral", "roommate": "referral",
70
+ "signage": "signage", "walk in": "direct", "call in": "direct",
71
+ "campus promotion": "campus_outreach", "open house": "campus_outreach",
72
+ "current resident": "existing_resident", "renewal": "existing_resident",
73
+ "transfer": "existing_resident", "relet": "existing_resident",
74
+ "unitpulse-app": "unitpulse_app", "unitpulse-website": "unitpulse_app",
75
+ "unit pulse": "unitpulse_app",
76
+ "other": "other",
77
+ }
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class LeadSource:
82
+ value: str # canonical slug or "other"
83
+ raw: str # original trimmed input, always preserved
84
+
85
+
86
+ def _slug(text: str) -> str:
87
+ return re.sub(r"\s+", " ", text.strip()).lower()
88
+
89
+
90
+ def normalize_lead_source(raw: Optional[str]) -> LeadSource:
91
+ """Normalize a lead-source string against the curated registry.
92
+
93
+ Unknown inputs map to "other" and WARN-log so new sources can be promoted.
94
+ The original trimmed string is always preserved on the returned object.
95
+ """
96
+ original = "" if raw is None else str(raw).strip()
97
+ if not original:
98
+ return LeadSource(value="other", raw="")
99
+ mapped = _SOURCE_ALIASES.get(_slug(original))
100
+ if mapped is not None:
101
+ return LeadSource(value=mapped, raw=original)
102
+ logger.warning("normalize_lead_source: unknown source %r -> 'other'", original)
103
+ return LeadSource(value="other", raw=original)
104
+
105
+
106
+ def is_known_lead_source(value: str) -> bool:
107
+ return value in LEAD_SOURCES
108
+
109
+
110
+ # Ordered, first-match-wins regex rules for the SOURCE dimension of a messy
111
+ # prospect "source" string. Readable Python port of the SQL backfill's regex
112
+ # CASE (same order — marketplace/specific before generic). Deterministic and
113
+ # unit-tested. `normalize_lead_source` above stays the exact-key path for clean
114
+ # forward-writes; these rules are the fuzzy fallback used by classify_prospect_source.
115
+ _SOURCE_RULES = [
116
+ (re.compile(r"zillow"), "zillow"),
117
+ (re.compile(r"apartmentlist|apartment list"), "apartmentlist"),
118
+ (re.compile(r"apartments\.com|apartment\.com|\bapartments\b|apartments247"), "apartments_com"),
119
+ (re.compile(r"everyapartment|every apartment"), "everyapartment"),
120
+ (re.compile(r"furnished finder"), "furnished_finder"),
121
+ (re.compile(r"zumper"), "zumper"),
122
+ (re.compile(r"hotpads"), "hotpads"),
123
+ (re.compile(r"uloop"), "uloop"),
124
+ (re.compile(r"padmapper"), "padmapper"),
125
+ (re.compile(r"realtor"), "realtor_com"),
126
+ (re.compile(r"redfin"), "redfin"),
127
+ (re.compile(r"trulia"), "trulia"),
128
+ (re.compile(r"rentcafe|rent cafe"), "rentcafe"),
129
+ (re.compile(r"rent\.com|^rent\.?$"), "rent_com"),
130
+ (re.compile(r"entrata"), "entrata"),
131
+ (re.compile(r"airbnb"), "airbnb"),
132
+ (re.compile(r"college ?pads|college ?rentals|collegerentals"), "college_pads"),
133
+ (re.compile(r"university living"), "university_living"),
134
+ (re.compile(r"uhome|uhomes|异乡好居|集好家|awehome|amber ?student"), "uhomes"),
135
+ (re.compile(r"jotform"), "jotform"),
136
+ (re.compile(r"appfolio"), "appfolio"),
137
+ (re.compile(r"instagram"), "instagram"),
138
+ (re.compile(r"facebook|fb-|fb |^fb$"), "facebook"),
139
+ (re.compile(r"google|^seo|^sem|search engine"), "google"),
140
+ (re.compile(
141
+ r"property website|_property_website|hopedtla|1133 |squarespace|cyberoptik|"
142
+ r"websitefirstlogin|corporate website|apartment website|liveinktown|liveatlas|"
143
+ r"solare arcadia|sawyerliving|letterman|sclark|kara apartments|somi apartments|"
144
+ r"audrey ?apts|atlas house|maude official|nexen official|archer official|keystone|"
145
+ r"maxwell official|gemma ?official|addams|orange box|30 ?sixty|funliving|casita|"
146
+ r"hooli|unishack|coliving|resident360|officialwebsite|^official|official$|^website$"
147
+ ), "property_website"),
148
+ (re.compile(r"tripa+link|tripaaalink"), "tripalink"),
149
+ (re.compile(r"referral|refer$|reffer|friend|word of mouth|roommate|同事推荐"), "referral"),
150
+ (re.compile(r"signage|drive/|drive by|signs"), "signage"),
151
+ (re.compile(r"campus|off.?campus|dawg daze|housing fair|open house|outreach|innout"), "campus_outreach"),
152
+ (re.compile(r"current resident|relet|renewal|transfer|^owner"), "existing_resident"),
153
+ (re.compile(r"unit ?pulse|unitpulse|up leasing|web-messages"), "unitpulse_app"),
154
+ (re.compile(
155
+ r"parse_conversation|renty_|gemma_|arden_|_request_tour|_save_lead|_contact_us|"
156
+ r"_apply_now|conversion_cloud|tenant_event|auto_?ref|postofficial|post official"
157
+ ), "internal_system"),
158
+ (re.compile(r"^direct|direct website|direct contact|direct inquiry|^online|post to internet"), "direct"),
159
+ ]
160
+
161
+
162
+ def _regex_source(key: str) -> Optional[str]:
163
+ for pattern, slug in _SOURCE_RULES:
164
+ if pattern.search(key):
165
+ return slug
166
+ return None
167
+
168
+
169
+ def classify_prospect_source(raw: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
170
+ """Split a messy prospect "source" string into (lead_source, first_touch_channel).
171
+
172
+ A raw value can carry a source, a channel, or both — classified independently
173
+ (mirrors the SQL backfill's two-output map):
174
+ "Walk in" -> ("direct", "in_person")
175
+ "Call In" -> ("direct", "phone")
176
+ "Zillow" -> ("zillow", None) # channel derived from 1st activity
177
+ "CXM Call-in_Zillow" -> ("zillow", "phone")
178
+ "Facebook Messenger" -> ("facebook","fb messenger")
179
+ "1133 Hope" -> ("property_website", None) # property name -> property_website
180
+ "" -> (None, None) # no source at all -> unknown
181
+
182
+ Rules:
183
+ - lead_source: a matched source slug; if the string is only a channel (walk/call/
184
+ email/...) -> "direct"; if it carries neither -> "other"; if the string is
185
+ empty/None -> None (genuinely unknown, hand to the LLM / leave NULL).
186
+ - first_touch_channel: matched channel value, else None (caller derives from
187
+ the earliest activity).
188
+ """
189
+ key = "" if raw is None else str(raw).strip().lower()
190
+ if not key:
191
+ return None, None
192
+ channel = regex_channel(key)
193
+ source = _regex_source(key)
194
+ if source is None:
195
+ source = "direct" if channel is not None else "other"
196
+ return source, channel
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: unitpulse-channels
3
+ Version: 0.1.0
4
+ Summary: Canonical communication-channel enum and lead-source registry
5
+ Author: UnitPulse
6
+ Author-email: honglu.he@unitpulse.ai
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.8
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: author
21
+ Dynamic: author-email
22
+ Dynamic: classifier
23
+ Dynamic: description
24
+ Dynamic: description-content-type
25
+ Dynamic: license-file
26
+ Dynamic: requires-python
27
+ Dynamic: summary
28
+
29
+ # unitpulse-channels
30
+
31
+ Single source of truth for UnitPulse's communication-channel and lead-source
32
+ vocabulary. Import it everywhere a `channel` or `lead_source` value is produced
33
+ so services stop re-declaring (and drifting) their own lists.
34
+
35
+ ## Concepts
36
+
37
+ Three distinct things that were historically conflated into one free-form field:
38
+
39
+ | concept | field | governance |
40
+ |---|---|---|
41
+ | **channel** | per-message medium | CLOSED enum (`Channel`) |
42
+ | **first_touch_channel** | mode of first contact (prospect) | same `Channel` enum |
43
+ | **lead_source** | acquisition / attribution | OPEN curated registry (`LEAD_SOURCES`) |
44
+
45
+ `channel` is closed: unknown input → `other` (+ WARN log). `lead_source` is an
46
+ open curated registry: unknown input → `other` but the original raw string is
47
+ preserved so new sources can be promoted by review.
48
+
49
+ ## Usage
50
+
51
+ ```python
52
+ from unitpulse_channels import (
53
+ normalize_channel, normalize_lead_source, classify_prospect_source,
54
+ )
55
+
56
+ normalize_channel("FB Messenger") # -> "fb messenger"
57
+ normalize_channel("call in") # -> "phone"
58
+ normalize_channel("walk in") # -> "in_person"
59
+
60
+ normalize_lead_source("ZILLOW").value # -> "zillow"
61
+ normalize_lead_source("1133 Hope") # -> LeadSource(value="other", raw="1133 Hope")
62
+
63
+ # Split a raw prospect "source" into both dimensions:
64
+ classify_prospect_source("Walk in") # -> ("direct", "in_person")
65
+ classify_prospect_source("Zillow") # -> ("zillow", None) # derive channel from 1st activity
66
+ classify_prospect_source("Facebook Messenger") # -> ("facebook", "fb messenger")
67
+ ```
68
+
69
+ Design + backfill: `design-docs` → `unitpulse/specs/2026-06-18-channel-enum-cleanup.md`.
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ setup.py
5
+ tests/test_channel.py
6
+ tests/test_lead_source.py
7
+ unitpulse_channels/__init__.py
8
+ unitpulse_channels/channel.py
9
+ unitpulse_channels/lead_source.py
10
+ unitpulse_channels.egg-info/PKG-INFO
11
+ unitpulse_channels.egg-info/SOURCES.txt
12
+ unitpulse_channels.egg-info/dependency_links.txt
13
+ unitpulse_channels.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ unitpulse_channels