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.
- unitpulse_channels-0.1.0/LICENSE +21 -0
- unitpulse_channels-0.1.0/PKG-INFO +69 -0
- unitpulse_channels-0.1.0/README.md +41 -0
- unitpulse_channels-0.1.0/pyproject.toml +3 -0
- unitpulse_channels-0.1.0/setup.cfg +4 -0
- unitpulse_channels-0.1.0/setup.py +31 -0
- unitpulse_channels-0.1.0/tests/test_channel.py +60 -0
- unitpulse_channels-0.1.0/tests/test_lead_source.py +63 -0
- unitpulse_channels-0.1.0/unitpulse_channels/__init__.py +36 -0
- unitpulse_channels-0.1.0/unitpulse_channels/channel.py +148 -0
- unitpulse_channels-0.1.0/unitpulse_channels/lead_source.py +196 -0
- unitpulse_channels-0.1.0/unitpulse_channels.egg-info/PKG-INFO +69 -0
- unitpulse_channels-0.1.0/unitpulse_channels.egg-info/SOURCES.txt +13 -0
- unitpulse_channels-0.1.0/unitpulse_channels.egg-info/dependency_links.txt +1 -0
- unitpulse_channels-0.1.0/unitpulse_channels.egg-info/top_level.txt +1 -0
|
@@ -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,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
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
unitpulse_channels
|