enrichfold 0.2.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.
- enrichfold-0.2.0/.github/workflows/ci.yml +20 -0
- enrichfold-0.2.0/.github/workflows/publish.yml +35 -0
- enrichfold-0.2.0/.gitignore +8 -0
- enrichfold-0.2.0/CHANGELOG.md +15 -0
- enrichfold-0.2.0/LICENSE +21 -0
- enrichfold-0.2.0/PKG-INFO +134 -0
- enrichfold-0.2.0/README.md +118 -0
- enrichfold-0.2.0/SECURITY.md +5 -0
- enrichfold-0.2.0/pyproject.toml +28 -0
- enrichfold-0.2.0/src/enrichfold/__init__.py +47 -0
- enrichfold-0.2.0/src/enrichfold/identity.py +233 -0
- enrichfold-0.2.0/src/enrichfold/models.py +127 -0
- enrichfold-0.2.0/src/enrichfold/pipeline.py +110 -0
- enrichfold-0.2.0/tests/test_pipeline.py +171 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: actions/setup-python@v5
|
|
14
|
+
with:
|
|
15
|
+
python-version: "3.12"
|
|
16
|
+
- name: Test
|
|
17
|
+
run: |
|
|
18
|
+
python -m pip install --upgrade pip pytest
|
|
19
|
+
python -m pip install .
|
|
20
|
+
pytest -q
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- uses: actions/setup-python@v5
|
|
13
|
+
with:
|
|
14
|
+
python-version: "3.12"
|
|
15
|
+
- name: Build distributions
|
|
16
|
+
run: |
|
|
17
|
+
python -m pip install --upgrade build twine
|
|
18
|
+
python -m build
|
|
19
|
+
twine check dist/*
|
|
20
|
+
- uses: actions/upload-artifact@v4
|
|
21
|
+
with:
|
|
22
|
+
name: dist
|
|
23
|
+
path: dist/
|
|
24
|
+
|
|
25
|
+
publish:
|
|
26
|
+
needs: build
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
permissions:
|
|
29
|
+
id-token: write
|
|
30
|
+
steps:
|
|
31
|
+
- uses: actions/download-artifact@v4
|
|
32
|
+
with:
|
|
33
|
+
name: dist
|
|
34
|
+
path: dist/
|
|
35
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 - 2026-08-20
|
|
4
|
+
|
|
5
|
+
- Added provenance-bearing `Claim` contracts and deterministic claim
|
|
6
|
+
reconciliation.
|
|
7
|
+
- Made contradictory values and inferred claims explicit `needs_review` gates.
|
|
8
|
+
- Added offline, fail-closed company identity resolution for corporate email
|
|
9
|
+
domains and supplied websites.
|
|
10
|
+
- Preserved the 0.1 provider protocol and the simple `EnrichmentPipeline` API;
|
|
11
|
+
pipeline results now expose `review_fields` when provider values disagree.
|
|
12
|
+
|
|
13
|
+
## 0.1.0 - 2026-08-20
|
|
14
|
+
|
|
15
|
+
- Initial provider-neutral, provenance-first enrichment core.
|
enrichfold-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mihail R.
|
|
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,134 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: enrichfold
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Provider-neutral, provenance-first entity enrichment for people and companies.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Mihailorama/enrichfold
|
|
6
|
+
Project-URL: Repository, https://github.com/Mihailorama/enrichfold
|
|
7
|
+
Project-URL: Issues, https://github.com/Mihailorama/enrichfold/issues
|
|
8
|
+
Author: Mihail R.
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Enrichfold
|
|
18
|
+
|
|
19
|
+
Provider-neutral, provenance-first entity enrichment for people and companies.
|
|
20
|
+
|
|
21
|
+
`enrichfold` is an offline-first core, not a scraping product: applications supply
|
|
22
|
+
their own discovery providers and credentials. Every accepted attribute retains its
|
|
23
|
+
source URL, observation time, and confidence, so downstream systems can decide
|
|
24
|
+
whether a result is suitable for an automated action or requires review.
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from enrichfold import Entity, EnrichmentPipeline, Evidence
|
|
28
|
+
|
|
29
|
+
class CompanyProvider:
|
|
30
|
+
def discover(self, entity):
|
|
31
|
+
return [Evidence(
|
|
32
|
+
source_url="https://example.com/team",
|
|
33
|
+
observed_at="2026-08-20T12:00:00Z",
|
|
34
|
+
confidence=0.94,
|
|
35
|
+
attributes={"industry": "software", "company_size": "51-200"},
|
|
36
|
+
)]
|
|
37
|
+
|
|
38
|
+
company = Entity.company(domain="example.com")
|
|
39
|
+
result = EnrichmentPipeline([CompanyProvider()]).enrich(company)
|
|
40
|
+
print(result.attributes["industry"].value) # software
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Evidence, conflicts, and review gates
|
|
44
|
+
|
|
45
|
+
`enrichfold` keeps provider I/O outside the library. Its core turns supplied
|
|
46
|
+
evidence into deterministic decisions while retaining disagreements for a human
|
|
47
|
+
approval flow. A conflicting value is never silently accepted:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from enrichfold import Claim, Evidence, reconcile_claims
|
|
51
|
+
|
|
52
|
+
result = reconcile_claims([
|
|
53
|
+
Claim(
|
|
54
|
+
field="industry",
|
|
55
|
+
value="software",
|
|
56
|
+
evidence=Evidence(
|
|
57
|
+
source_url="https://acme.example/about",
|
|
58
|
+
observed_at="2026-08-20T12:00:00Z",
|
|
59
|
+
confidence=0.91,
|
|
60
|
+
),
|
|
61
|
+
),
|
|
62
|
+
Claim(
|
|
63
|
+
field="industry",
|
|
64
|
+
value="retail",
|
|
65
|
+
evidence=Evidence(
|
|
66
|
+
source_url="https://directory.example/acme",
|
|
67
|
+
observed_at="2026-08-20T12:00:00Z",
|
|
68
|
+
confidence=0.88,
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
])
|
|
72
|
+
|
|
73
|
+
industry = result.fields["industry"]
|
|
74
|
+
assert industry.value == "software" # stable suggested value
|
|
75
|
+
assert industry.status == "needs_review" # do not automate an action
|
|
76
|
+
assert result.requires_review is True
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`Claim(kind="inferred", ...)` also requires review even with no competing
|
|
80
|
+
claim. This distinction makes it possible to keep model-produced hypotheses
|
|
81
|
+
without presenting them as observed facts.
|
|
82
|
+
|
|
83
|
+
## Company identity gate
|
|
84
|
+
|
|
85
|
+
Before a caller enriches or acts on a company, use the offline identity gate.
|
|
86
|
+
It is deliberately conservative: free mailboxes, invalid sites, domain
|
|
87
|
+
conflicts, and corporate domains that do not exactly match the name receive a
|
|
88
|
+
review status. Applications can pass separately verified site metadata when
|
|
89
|
+
they have it.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from enrichfold import derive_company_identity
|
|
93
|
+
|
|
94
|
+
identity = derive_company_identity(
|
|
95
|
+
email="hello@acme.example",
|
|
96
|
+
company_name="Acme",
|
|
97
|
+
website="https://www.acme.example/about",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
assert identity.status == "verified"
|
|
101
|
+
assert identity.canonical_domain == "acme.example"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Design boundaries
|
|
105
|
+
|
|
106
|
+
- No network calls, scraping, credential handling, contact exporting, or outreach.
|
|
107
|
+
- No inferred facts: a field is returned only when a provider supplies evidence.
|
|
108
|
+
- Conflicts have a deterministic suggested value but are marked `needs_review`.
|
|
109
|
+
- Inferred claims are always marked `needs_review`.
|
|
110
|
+
- Company identity is verified only through an exact name/domain match or
|
|
111
|
+
caller-supplied, independently verified same-domain site metadata.
|
|
112
|
+
- Bring your own providers for search engines, public data APIs, browser tools, or
|
|
113
|
+
internal approved sources.
|
|
114
|
+
|
|
115
|
+
The package intentionally does not decide whether a review is approved or run
|
|
116
|
+
an action after one; persistence, permissions, UI, and provider adapters stay
|
|
117
|
+
with the host application.
|
|
118
|
+
|
|
119
|
+
## Installation
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
pip install enrichfold
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Development
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
uv run --with pytest pytest -q
|
|
129
|
+
python -m build
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Enrichfold
|
|
2
|
+
|
|
3
|
+
Provider-neutral, provenance-first entity enrichment for people and companies.
|
|
4
|
+
|
|
5
|
+
`enrichfold` is an offline-first core, not a scraping product: applications supply
|
|
6
|
+
their own discovery providers and credentials. Every accepted attribute retains its
|
|
7
|
+
source URL, observation time, and confidence, so downstream systems can decide
|
|
8
|
+
whether a result is suitable for an automated action or requires review.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from enrichfold import Entity, EnrichmentPipeline, Evidence
|
|
12
|
+
|
|
13
|
+
class CompanyProvider:
|
|
14
|
+
def discover(self, entity):
|
|
15
|
+
return [Evidence(
|
|
16
|
+
source_url="https://example.com/team",
|
|
17
|
+
observed_at="2026-08-20T12:00:00Z",
|
|
18
|
+
confidence=0.94,
|
|
19
|
+
attributes={"industry": "software", "company_size": "51-200"},
|
|
20
|
+
)]
|
|
21
|
+
|
|
22
|
+
company = Entity.company(domain="example.com")
|
|
23
|
+
result = EnrichmentPipeline([CompanyProvider()]).enrich(company)
|
|
24
|
+
print(result.attributes["industry"].value) # software
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Evidence, conflicts, and review gates
|
|
28
|
+
|
|
29
|
+
`enrichfold` keeps provider I/O outside the library. Its core turns supplied
|
|
30
|
+
evidence into deterministic decisions while retaining disagreements for a human
|
|
31
|
+
approval flow. A conflicting value is never silently accepted:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from enrichfold import Claim, Evidence, reconcile_claims
|
|
35
|
+
|
|
36
|
+
result = reconcile_claims([
|
|
37
|
+
Claim(
|
|
38
|
+
field="industry",
|
|
39
|
+
value="software",
|
|
40
|
+
evidence=Evidence(
|
|
41
|
+
source_url="https://acme.example/about",
|
|
42
|
+
observed_at="2026-08-20T12:00:00Z",
|
|
43
|
+
confidence=0.91,
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
Claim(
|
|
47
|
+
field="industry",
|
|
48
|
+
value="retail",
|
|
49
|
+
evidence=Evidence(
|
|
50
|
+
source_url="https://directory.example/acme",
|
|
51
|
+
observed_at="2026-08-20T12:00:00Z",
|
|
52
|
+
confidence=0.88,
|
|
53
|
+
),
|
|
54
|
+
),
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
industry = result.fields["industry"]
|
|
58
|
+
assert industry.value == "software" # stable suggested value
|
|
59
|
+
assert industry.status == "needs_review" # do not automate an action
|
|
60
|
+
assert result.requires_review is True
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`Claim(kind="inferred", ...)` also requires review even with no competing
|
|
64
|
+
claim. This distinction makes it possible to keep model-produced hypotheses
|
|
65
|
+
without presenting them as observed facts.
|
|
66
|
+
|
|
67
|
+
## Company identity gate
|
|
68
|
+
|
|
69
|
+
Before a caller enriches or acts on a company, use the offline identity gate.
|
|
70
|
+
It is deliberately conservative: free mailboxes, invalid sites, domain
|
|
71
|
+
conflicts, and corporate domains that do not exactly match the name receive a
|
|
72
|
+
review status. Applications can pass separately verified site metadata when
|
|
73
|
+
they have it.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from enrichfold import derive_company_identity
|
|
77
|
+
|
|
78
|
+
identity = derive_company_identity(
|
|
79
|
+
email="hello@acme.example",
|
|
80
|
+
company_name="Acme",
|
|
81
|
+
website="https://www.acme.example/about",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
assert identity.status == "verified"
|
|
85
|
+
assert identity.canonical_domain == "acme.example"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Design boundaries
|
|
89
|
+
|
|
90
|
+
- No network calls, scraping, credential handling, contact exporting, or outreach.
|
|
91
|
+
- No inferred facts: a field is returned only when a provider supplies evidence.
|
|
92
|
+
- Conflicts have a deterministic suggested value but are marked `needs_review`.
|
|
93
|
+
- Inferred claims are always marked `needs_review`.
|
|
94
|
+
- Company identity is verified only through an exact name/domain match or
|
|
95
|
+
caller-supplied, independently verified same-domain site metadata.
|
|
96
|
+
- Bring your own providers for search engines, public data APIs, browser tools, or
|
|
97
|
+
internal approved sources.
|
|
98
|
+
|
|
99
|
+
The package intentionally does not decide whether a review is approved or run
|
|
100
|
+
an action after one; persistence, permissions, UI, and provider adapters stay
|
|
101
|
+
with the host application.
|
|
102
|
+
|
|
103
|
+
## Installation
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pip install enrichfold
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Development
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
uv run --with pytest pytest -q
|
|
113
|
+
python -m build
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "enrichfold"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Provider-neutral, provenance-first entity enrichment for people and companies."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Mihail R." }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/Mihailorama/enrichfold"
|
|
21
|
+
Repository = "https://github.com/Mihailorama/enrichfold"
|
|
22
|
+
Issues = "https://github.com/Mihailorama/enrichfold/issues"
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
testpaths = ["tests"]
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/enrichfold"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Provider-neutral, provenance-first entity enrichment."""
|
|
2
|
+
|
|
3
|
+
from .identity import (
|
|
4
|
+
DEFAULT_FREE_EMAIL_DOMAINS,
|
|
5
|
+
DEFAULT_GENERIC_COMPANY_NAMES,
|
|
6
|
+
CompanyIdentity,
|
|
7
|
+
SiteIdentity,
|
|
8
|
+
canonical_website_domain,
|
|
9
|
+
company_email_domain,
|
|
10
|
+
company_name_matches_domain,
|
|
11
|
+
derive_company_identity,
|
|
12
|
+
is_free_email_domain,
|
|
13
|
+
is_generic_company_name,
|
|
14
|
+
)
|
|
15
|
+
from .models import (
|
|
16
|
+
Claim,
|
|
17
|
+
EnrichmentResult,
|
|
18
|
+
Entity,
|
|
19
|
+
Evidence,
|
|
20
|
+
FieldResolution,
|
|
21
|
+
ReconciliationResult,
|
|
22
|
+
ResolvedAttribute,
|
|
23
|
+
)
|
|
24
|
+
from .pipeline import DiscoveryProvider, EnrichmentPipeline, reconcile_claims
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Claim",
|
|
28
|
+
"CompanyIdentity",
|
|
29
|
+
"DEFAULT_FREE_EMAIL_DOMAINS",
|
|
30
|
+
"DEFAULT_GENERIC_COMPANY_NAMES",
|
|
31
|
+
"DiscoveryProvider",
|
|
32
|
+
"EnrichmentPipeline",
|
|
33
|
+
"EnrichmentResult",
|
|
34
|
+
"Entity",
|
|
35
|
+
"Evidence",
|
|
36
|
+
"FieldResolution",
|
|
37
|
+
"ReconciliationResult",
|
|
38
|
+
"ResolvedAttribute",
|
|
39
|
+
"SiteIdentity",
|
|
40
|
+
"canonical_website_domain",
|
|
41
|
+
"company_email_domain",
|
|
42
|
+
"company_name_matches_domain",
|
|
43
|
+
"derive_company_identity",
|
|
44
|
+
"is_free_email_domain",
|
|
45
|
+
"is_generic_company_name",
|
|
46
|
+
"reconcile_claims",
|
|
47
|
+
]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Offline company identity checks that fail closed when evidence is weak."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Collection, Literal
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DEFAULT_FREE_EMAIL_DOMAINS = frozenset(
|
|
12
|
+
{
|
|
13
|
+
"gmail.com",
|
|
14
|
+
"googlemail.com",
|
|
15
|
+
"outlook.com",
|
|
16
|
+
"hotmail.com",
|
|
17
|
+
"icloud.com",
|
|
18
|
+
"proton.me",
|
|
19
|
+
"protonmail.com",
|
|
20
|
+
"yahoo.com",
|
|
21
|
+
}
|
|
22
|
+
)
|
|
23
|
+
DEFAULT_GENERIC_COMPANY_NAMES = frozenset({"company", "unknown", "unknown company"})
|
|
24
|
+
_DOMAIN_RE = re.compile(
|
|
25
|
+
r"^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])$",
|
|
26
|
+
re.IGNORECASE,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
IdentityStatus = Literal["verified", "needs_review"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class SiteIdentity:
|
|
34
|
+
"""A caller-verified identity read from a supplied website."""
|
|
35
|
+
|
|
36
|
+
name: str
|
|
37
|
+
domain: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class CompanyIdentity:
|
|
42
|
+
"""An identity decision that is safe to gate before enrichment or actions."""
|
|
43
|
+
|
|
44
|
+
status: IdentityStatus
|
|
45
|
+
name: str | None
|
|
46
|
+
canonical_domain: str | None
|
|
47
|
+
website: str | None
|
|
48
|
+
slug: str | None
|
|
49
|
+
reason: str
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def requires_review(self) -> bool:
|
|
53
|
+
return self.status == "needs_review"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def company_email_domain(email: str) -> str | None:
|
|
57
|
+
local, separator, domain = email.strip().lower().rpartition("@")
|
|
58
|
+
if not separator or not local or not _DOMAIN_RE.fullmatch(domain):
|
|
59
|
+
return None
|
|
60
|
+
return domain
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def canonical_website_domain(website: str | None) -> str | None:
|
|
64
|
+
if not website or not website.strip():
|
|
65
|
+
return None
|
|
66
|
+
value = website.strip()
|
|
67
|
+
parsed = urlsplit(value if re.match(r"^https?://", value, re.IGNORECASE) else f"https://{value}")
|
|
68
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
69
|
+
return None
|
|
70
|
+
hostname = parsed.hostname.lower().removeprefix("www.")
|
|
71
|
+
return hostname if _DOMAIN_RE.fullmatch(hostname) else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def is_free_email_domain(domain: str, *, free_email_domains: Collection[str] = DEFAULT_FREE_EMAIL_DOMAINS) -> bool:
|
|
75
|
+
return domain.lower() in {item.lower() for item in free_email_domains}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_generic_company_name(name: str | None, *, generic_names: Collection[str] = DEFAULT_GENERIC_COMPANY_NAMES) -> bool:
|
|
79
|
+
normalized = re.sub(r"\s+", " ", (name or "").strip().casefold())
|
|
80
|
+
return not normalized or normalized in {item.strip().casefold() for item in generic_names}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def slugify(value: str) -> str:
|
|
84
|
+
"""Locale-safe readable slug; applications may replace it for their own URLs."""
|
|
85
|
+
|
|
86
|
+
return "-".join(part for part in re.split(r"[^\w]+", value.casefold(), flags=re.UNICODE) if part)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _domains_match(left: str, right: str) -> bool:
|
|
90
|
+
return left == right or left.endswith(f".{right}") or right.endswith(f".{left}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def company_name_matches_domain(name: str, domain: str) -> bool:
|
|
94
|
+
"""Match only the registrable-looking first label; never substring-match."""
|
|
95
|
+
|
|
96
|
+
labels = domain.lower().removeprefix("www.").split(".")
|
|
97
|
+
if len(labels) != 2:
|
|
98
|
+
return False
|
|
99
|
+
normalized_name = re.sub(r"[^a-z0-9]", "", name.lower())
|
|
100
|
+
return bool(normalized_name) and normalized_name == labels[0].replace("-", "")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _identity(
|
|
104
|
+
*,
|
|
105
|
+
status: IdentityStatus,
|
|
106
|
+
name: str | None,
|
|
107
|
+
domain: str | None,
|
|
108
|
+
slug: str | None,
|
|
109
|
+
reason: str,
|
|
110
|
+
) -> CompanyIdentity:
|
|
111
|
+
return CompanyIdentity(
|
|
112
|
+
status=status,
|
|
113
|
+
name=name,
|
|
114
|
+
canonical_domain=domain,
|
|
115
|
+
website=f"https://{domain}" if domain else None,
|
|
116
|
+
slug=slug,
|
|
117
|
+
reason=reason,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def derive_company_identity(
|
|
122
|
+
*,
|
|
123
|
+
email: str,
|
|
124
|
+
company_name: str | None,
|
|
125
|
+
website: str | None,
|
|
126
|
+
verified_site_identity: SiteIdentity | None = None,
|
|
127
|
+
free_email_domains: Collection[str] = DEFAULT_FREE_EMAIL_DOMAINS,
|
|
128
|
+
generic_names: Collection[str] = DEFAULT_GENERIC_COMPANY_NAMES,
|
|
129
|
+
) -> CompanyIdentity:
|
|
130
|
+
"""Derive an identity without network access and require review by default.
|
|
131
|
+
|
|
132
|
+
A corporate domain is only verified when it exactly matches the company
|
|
133
|
+
name, or when a caller supplies independently verified same-domain site
|
|
134
|
+
metadata. Free mailboxes and conflicts never become automatically verified.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
name = None if is_generic_company_name(company_name, generic_names=generic_names) else company_name.strip()
|
|
138
|
+
slug = slugify(name) if name else None
|
|
139
|
+
email_domain = company_email_domain(email)
|
|
140
|
+
website_domain = canonical_website_domain(website)
|
|
141
|
+
|
|
142
|
+
if not name:
|
|
143
|
+
return _identity(
|
|
144
|
+
status="needs_review",
|
|
145
|
+
name=None,
|
|
146
|
+
domain=website_domain or email_domain,
|
|
147
|
+
slug=None,
|
|
148
|
+
reason="missing_company_name",
|
|
149
|
+
)
|
|
150
|
+
if not email_domain:
|
|
151
|
+
return _identity(
|
|
152
|
+
status="needs_review",
|
|
153
|
+
name=name,
|
|
154
|
+
domain=website_domain,
|
|
155
|
+
slug=slug,
|
|
156
|
+
reason="invalid_email",
|
|
157
|
+
)
|
|
158
|
+
if website and not website_domain:
|
|
159
|
+
return _identity(
|
|
160
|
+
status="needs_review",
|
|
161
|
+
name=name,
|
|
162
|
+
domain=None,
|
|
163
|
+
slug=slug,
|
|
164
|
+
reason="invalid_website",
|
|
165
|
+
)
|
|
166
|
+
if website_domain and not is_free_email_domain(email_domain, free_email_domains=free_email_domains) and not _domains_match(email_domain, website_domain):
|
|
167
|
+
return _identity(
|
|
168
|
+
status="needs_review",
|
|
169
|
+
name=name,
|
|
170
|
+
domain=website_domain,
|
|
171
|
+
slug=slug,
|
|
172
|
+
reason="domain_conflict",
|
|
173
|
+
)
|
|
174
|
+
if website_domain and is_free_email_domain(email_domain, free_email_domains=free_email_domains):
|
|
175
|
+
return _identity(
|
|
176
|
+
status="needs_review",
|
|
177
|
+
name=name,
|
|
178
|
+
domain=website_domain,
|
|
179
|
+
slug=slug,
|
|
180
|
+
reason="free_email_with_unverified_website",
|
|
181
|
+
)
|
|
182
|
+
if (
|
|
183
|
+
website_domain
|
|
184
|
+
and verified_site_identity
|
|
185
|
+
and verified_site_identity.name.strip() == name
|
|
186
|
+
and _domains_match(website_domain, verified_site_identity.domain.lower().removeprefix("www."))
|
|
187
|
+
):
|
|
188
|
+
return _identity(
|
|
189
|
+
status="verified",
|
|
190
|
+
name=name,
|
|
191
|
+
domain=website_domain,
|
|
192
|
+
slug=slug,
|
|
193
|
+
reason="corporate_email_domain_site_metadata",
|
|
194
|
+
)
|
|
195
|
+
if website_domain and company_name_matches_domain(name, website_domain):
|
|
196
|
+
return _identity(
|
|
197
|
+
status="verified",
|
|
198
|
+
name=name,
|
|
199
|
+
domain=website_domain,
|
|
200
|
+
slug=slug,
|
|
201
|
+
reason="matching_email_and_website",
|
|
202
|
+
)
|
|
203
|
+
if website_domain:
|
|
204
|
+
return _identity(
|
|
205
|
+
status="needs_review",
|
|
206
|
+
name=name,
|
|
207
|
+
domain=website_domain,
|
|
208
|
+
slug=slug,
|
|
209
|
+
reason="corporate_email_domain_unverified",
|
|
210
|
+
)
|
|
211
|
+
if is_free_email_domain(email_domain, free_email_domains=free_email_domains):
|
|
212
|
+
return _identity(
|
|
213
|
+
status="needs_review",
|
|
214
|
+
name=name,
|
|
215
|
+
domain=None,
|
|
216
|
+
slug=slug,
|
|
217
|
+
reason="free_email_without_website",
|
|
218
|
+
)
|
|
219
|
+
if company_name_matches_domain(name, email_domain):
|
|
220
|
+
return _identity(
|
|
221
|
+
status="verified",
|
|
222
|
+
name=name,
|
|
223
|
+
domain=email_domain,
|
|
224
|
+
slug=slug,
|
|
225
|
+
reason="corporate_email_domain",
|
|
226
|
+
)
|
|
227
|
+
return _identity(
|
|
228
|
+
status="needs_review",
|
|
229
|
+
name=name,
|
|
230
|
+
domain=email_domain,
|
|
231
|
+
slug=slug,
|
|
232
|
+
reason="corporate_email_domain_unverified",
|
|
233
|
+
)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Immutable, provenance-first data contracts for enrichment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Literal, Mapping
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
EntityKind = Literal["person", "company"]
|
|
11
|
+
ClaimKind = Literal["observed", "inferred"]
|
|
12
|
+
ReviewStatus = Literal["accepted", "needs_review"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Entity:
|
|
17
|
+
"""A person or company identified by one or more non-empty identifiers."""
|
|
18
|
+
|
|
19
|
+
kind: EntityKind
|
|
20
|
+
identifiers: Mapping[str, str]
|
|
21
|
+
|
|
22
|
+
def __post_init__(self) -> None:
|
|
23
|
+
normalized = {
|
|
24
|
+
key.strip(): value.strip()
|
|
25
|
+
for key, value in self.identifiers.items()
|
|
26
|
+
if key.strip() and value.strip()
|
|
27
|
+
}
|
|
28
|
+
if not normalized:
|
|
29
|
+
raise ValueError("an entity requires at least one non-empty identifier")
|
|
30
|
+
object.__setattr__(self, "identifiers", normalized)
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def person(cls, **identifiers: str) -> Entity:
|
|
34
|
+
return cls(kind="person", identifiers=identifiers)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def company(cls, **identifiers: str) -> Entity:
|
|
38
|
+
return cls(kind="company", identifiers=identifiers)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Evidence:
|
|
43
|
+
"""A provider-observed source. The library never fetches it itself."""
|
|
44
|
+
|
|
45
|
+
source_url: str
|
|
46
|
+
observed_at: str
|
|
47
|
+
confidence: float
|
|
48
|
+
attributes: Mapping[str, Any] = field(default_factory=dict)
|
|
49
|
+
source_title: str | None = None
|
|
50
|
+
source_date: str | None = None
|
|
51
|
+
provider: str | None = None
|
|
52
|
+
|
|
53
|
+
def __post_init__(self) -> None:
|
|
54
|
+
if not self.source_url.startswith(("https://", "http://")):
|
|
55
|
+
raise ValueError("evidence requires an http(s) source_url")
|
|
56
|
+
if not 0.0 <= self.confidence <= 1.0:
|
|
57
|
+
raise ValueError("confidence must be between 0 and 1")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class Claim:
|
|
62
|
+
"""One value asserted for a field, with an immutable evidence reference.
|
|
63
|
+
|
|
64
|
+
``inferred`` claims are intentionally never silently auto-approved by the
|
|
65
|
+
reconciliation API; callers must record their review decision.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
field: str
|
|
69
|
+
value: Any
|
|
70
|
+
evidence: Evidence
|
|
71
|
+
kind: ClaimKind = "observed"
|
|
72
|
+
|
|
73
|
+
def __post_init__(self) -> None:
|
|
74
|
+
if not self.field.strip():
|
|
75
|
+
raise ValueError("a claim requires a non-empty field")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class ResolvedAttribute:
|
|
80
|
+
"""One chosen attribute and the evidence that supports it."""
|
|
81
|
+
|
|
82
|
+
value: Any
|
|
83
|
+
source_url: str
|
|
84
|
+
observed_at: str
|
|
85
|
+
confidence: float
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class FieldResolution:
|
|
90
|
+
"""A deterministic choice plus alternatives that require human review."""
|
|
91
|
+
|
|
92
|
+
value: Any
|
|
93
|
+
evidence: Evidence
|
|
94
|
+
kind: ClaimKind
|
|
95
|
+
status: ReviewStatus
|
|
96
|
+
alternatives: tuple[Claim, ...] = ()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class ReconciliationResult:
|
|
101
|
+
"""Field-level decisions from one or more independently supplied claims."""
|
|
102
|
+
|
|
103
|
+
fields: Mapping[str, FieldResolution]
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def review_fields(self) -> tuple[str, ...]:
|
|
107
|
+
return tuple(name for name, value in self.fields.items() if value.status == "needs_review")
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def requires_review(self) -> bool:
|
|
111
|
+
return bool(self.review_fields)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class EnrichmentResult:
|
|
116
|
+
"""Resolved attributes plus requested fields for which no evidence exists."""
|
|
117
|
+
|
|
118
|
+
entity: Entity
|
|
119
|
+
attributes: Mapping[str, ResolvedAttribute]
|
|
120
|
+
missing: tuple[str, ...]
|
|
121
|
+
review_fields: tuple[str, ...] = ()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def canonical_value(value: Any) -> str:
|
|
125
|
+
"""Stable value identity for deterministic grouping and conflict detection."""
|
|
126
|
+
|
|
127
|
+
return json.dumps(value, sort_keys=True, ensure_ascii=False, default=str, separators=(",", ":"))
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Provider orchestration and review-safe deterministic reconciliation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from .models import (
|
|
9
|
+
Claim,
|
|
10
|
+
EnrichmentResult,
|
|
11
|
+
Entity,
|
|
12
|
+
Evidence,
|
|
13
|
+
FieldResolution,
|
|
14
|
+
ReconciliationResult,
|
|
15
|
+
ResolvedAttribute,
|
|
16
|
+
canonical_value,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DiscoveryProvider(Protocol):
|
|
21
|
+
"""A caller-owned adapter that returns externally observed evidence."""
|
|
22
|
+
|
|
23
|
+
def discover(self, entity: Entity) -> Iterable[Evidence]: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
Provider = DiscoveryProvider | Callable[[Entity], Iterable[Evidence]]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _rank(claim: Claim) -> tuple[int, float, str, str]:
|
|
30
|
+
"""Rank observed evidence before inference, then use reproducible tie-breaks."""
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
0 if claim.kind == "observed" else 1,
|
|
34
|
+
-claim.evidence.confidence,
|
|
35
|
+
claim.evidence.source_url,
|
|
36
|
+
canonical_value(claim.value),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def reconcile_claims(claims: Iterable[Claim]) -> ReconciliationResult:
|
|
41
|
+
"""Resolve claims deterministically while failing closed on disagreement.
|
|
42
|
+
|
|
43
|
+
Multiple claims for the same canonical value reinforce one another. Distinct
|
|
44
|
+
values are retained as alternatives and make the field ``needs_review``;
|
|
45
|
+
an inferred winning value also requires review. This lets an application
|
|
46
|
+
choose its own approval workflow without losing provenance.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
by_field: dict[str, list[Claim]] = {}
|
|
50
|
+
for claim in claims:
|
|
51
|
+
by_field.setdefault(claim.field.strip(), []).append(claim)
|
|
52
|
+
|
|
53
|
+
fields: dict[str, FieldResolution] = {}
|
|
54
|
+
for name, field_claims in by_field.items():
|
|
55
|
+
by_value: dict[str, list[Claim]] = {}
|
|
56
|
+
for claim in field_claims:
|
|
57
|
+
by_value.setdefault(canonical_value(claim.value), []).append(claim)
|
|
58
|
+
|
|
59
|
+
representatives = [min(group, key=_rank) for group in by_value.values()]
|
|
60
|
+
chosen = min(representatives, key=_rank)
|
|
61
|
+
alternatives = tuple(
|
|
62
|
+
claim
|
|
63
|
+
for claim in sorted(representatives, key=_rank)
|
|
64
|
+
if canonical_value(claim.value) != canonical_value(chosen.value)
|
|
65
|
+
)
|
|
66
|
+
status = "needs_review" if alternatives or chosen.kind == "inferred" else "accepted"
|
|
67
|
+
fields[name] = FieldResolution(
|
|
68
|
+
value=chosen.value,
|
|
69
|
+
evidence=chosen.evidence,
|
|
70
|
+
kind=chosen.kind,
|
|
71
|
+
status=status,
|
|
72
|
+
alternatives=alternatives,
|
|
73
|
+
)
|
|
74
|
+
return ReconciliationResult(fields=fields)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class EnrichmentPipeline:
|
|
78
|
+
"""Combine provider evidence without making network calls itself."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, providers: Sequence[Provider]) -> None:
|
|
81
|
+
self._providers = tuple(providers)
|
|
82
|
+
|
|
83
|
+
def enrich(self, entity: Entity, *, requested_fields: Iterable[str] = ()) -> EnrichmentResult:
|
|
84
|
+
claims: list[Claim] = []
|
|
85
|
+
for provider in self._providers:
|
|
86
|
+
discovered = provider.discover(entity) if hasattr(provider, "discover") else provider(entity)
|
|
87
|
+
for evidence in discovered:
|
|
88
|
+
claims.extend(
|
|
89
|
+
Claim(field=attribute, value=value, evidence=evidence)
|
|
90
|
+
for attribute, value in evidence.attributes.items()
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
reconciled = reconcile_claims(claims)
|
|
94
|
+
resolved = {
|
|
95
|
+
attribute: ResolvedAttribute(
|
|
96
|
+
value=resolution.value,
|
|
97
|
+
source_url=resolution.evidence.source_url,
|
|
98
|
+
observed_at=resolution.evidence.observed_at,
|
|
99
|
+
confidence=resolution.evidence.confidence,
|
|
100
|
+
)
|
|
101
|
+
for attribute, resolution in reconciled.fields.items()
|
|
102
|
+
}
|
|
103
|
+
requested = tuple(dict.fromkeys(field.strip() for field in requested_fields if field.strip()))
|
|
104
|
+
missing = tuple(field for field in requested if field not in resolved)
|
|
105
|
+
return EnrichmentResult(
|
|
106
|
+
entity=entity,
|
|
107
|
+
attributes=resolved,
|
|
108
|
+
missing=missing,
|
|
109
|
+
review_fields=reconciled.review_fields,
|
|
110
|
+
)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from enrichfold import (
|
|
2
|
+
Claim,
|
|
3
|
+
Entity,
|
|
4
|
+
EnrichmentPipeline,
|
|
5
|
+
Evidence,
|
|
6
|
+
derive_company_identity,
|
|
7
|
+
reconcile_claims,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CompanyProvider:
|
|
12
|
+
def discover(self, entity: Entity) -> list[Evidence]:
|
|
13
|
+
assert entity.identifiers == {"domain": "example.com"}
|
|
14
|
+
return [
|
|
15
|
+
Evidence(
|
|
16
|
+
source_url="https://example.com/team",
|
|
17
|
+
observed_at="2026-08-20T12:00:00Z",
|
|
18
|
+
confidence=0.94,
|
|
19
|
+
attributes={"industry": "software", "company_size": "51-200"},
|
|
20
|
+
)
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_enriches_a_company_with_provenance() -> None:
|
|
25
|
+
result = EnrichmentPipeline([CompanyProvider()]).enrich(Entity.company(domain="example.com"))
|
|
26
|
+
|
|
27
|
+
assert result.attributes["industry"].value == "software"
|
|
28
|
+
assert result.attributes["industry"].source_url == "https://example.com/team"
|
|
29
|
+
assert result.attributes["industry"].confidence == 0.94
|
|
30
|
+
assert result.missing == ()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_uses_stronger_evidence_for_a_conflicting_value() -> None:
|
|
34
|
+
weak = Evidence(
|
|
35
|
+
source_url="https://directory.example/acme",
|
|
36
|
+
observed_at="2026-08-20T12:00:00Z",
|
|
37
|
+
confidence=0.30,
|
|
38
|
+
attributes={"industry": "retail"},
|
|
39
|
+
)
|
|
40
|
+
strong = Evidence(
|
|
41
|
+
source_url="https://acme.example/about",
|
|
42
|
+
observed_at="2026-08-20T12:00:00Z",
|
|
43
|
+
confidence=0.90,
|
|
44
|
+
attributes={"industry": "software"},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
result = EnrichmentPipeline([lambda entity: [weak, strong]]).enrich(Entity.company(domain="acme.example"))
|
|
48
|
+
|
|
49
|
+
assert result.attributes["industry"].value == "software"
|
|
50
|
+
assert result.attributes["industry"].source_url == "https://acme.example/about"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_identity_requires_review_for_an_unverified_corporate_domain() -> None:
|
|
54
|
+
identity = derive_company_identity(
|
|
55
|
+
email="partnerships@parent-company.example",
|
|
56
|
+
company_name="Acme Labs",
|
|
57
|
+
website=None,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
assert identity.status == "needs_review"
|
|
61
|
+
assert identity.canonical_domain == "parent-company.example"
|
|
62
|
+
assert identity.reason == "corporate_email_domain_unverified"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_identity_accepts_matching_corporate_domain_and_site() -> None:
|
|
66
|
+
identity = derive_company_identity(
|
|
67
|
+
email="hello@acme.example",
|
|
68
|
+
company_name="Acme",
|
|
69
|
+
website="https://www.acme.example/about",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
assert identity.status == "verified"
|
|
73
|
+
assert identity.canonical_domain == "acme.example"
|
|
74
|
+
assert identity.website == "https://acme.example"
|
|
75
|
+
assert identity.reason == "matching_email_and_website"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_identity_does_not_trust_a_free_mailbox_with_a_supplied_site() -> None:
|
|
79
|
+
identity = derive_company_identity(
|
|
80
|
+
email="person@gmail.com",
|
|
81
|
+
company_name="Acme",
|
|
82
|
+
website="https://acme.example",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
assert identity.status == "needs_review"
|
|
86
|
+
assert identity.reason == "free_email_with_unverified_website"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_identity_detects_a_corporate_domain_conflict() -> None:
|
|
90
|
+
identity = derive_company_identity(
|
|
91
|
+
email="hello@acme.example",
|
|
92
|
+
company_name="Acme",
|
|
93
|
+
website="https://unrelated.example",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
assert identity.status == "needs_review"
|
|
97
|
+
assert identity.reason == "domain_conflict"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_reconciliation_marks_differing_observations_for_review() -> None:
|
|
101
|
+
result = reconcile_claims(
|
|
102
|
+
[
|
|
103
|
+
Claim(
|
|
104
|
+
field="industry",
|
|
105
|
+
value="software",
|
|
106
|
+
evidence=Evidence(
|
|
107
|
+
source_url="https://acme.example/about",
|
|
108
|
+
observed_at="2026-08-20T12:00:00Z",
|
|
109
|
+
confidence=0.91,
|
|
110
|
+
attributes={},
|
|
111
|
+
),
|
|
112
|
+
),
|
|
113
|
+
Claim(
|
|
114
|
+
field="industry",
|
|
115
|
+
value="retail",
|
|
116
|
+
evidence=Evidence(
|
|
117
|
+
source_url="https://directory.example/acme",
|
|
118
|
+
observed_at="2026-08-20T12:00:00Z",
|
|
119
|
+
confidence=0.88,
|
|
120
|
+
attributes={},
|
|
121
|
+
),
|
|
122
|
+
),
|
|
123
|
+
]
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
resolution = result.fields["industry"]
|
|
127
|
+
assert resolution.status == "needs_review"
|
|
128
|
+
assert resolution.value == "software"
|
|
129
|
+
assert len(resolution.alternatives) == 1
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_reconciliation_requires_review_for_an_inferred_claim() -> None:
|
|
133
|
+
result = reconcile_claims(
|
|
134
|
+
[
|
|
135
|
+
Claim(
|
|
136
|
+
field="market",
|
|
137
|
+
value="Europe",
|
|
138
|
+
kind="inferred",
|
|
139
|
+
evidence=Evidence(
|
|
140
|
+
source_url="https://acme.example/press",
|
|
141
|
+
observed_at="2026-08-20T12:00:00Z",
|
|
142
|
+
confidence=0.99,
|
|
143
|
+
attributes={},
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
]
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
assert result.requires_review is True
|
|
150
|
+
assert result.review_fields == ("market",)
|
|
151
|
+
assert result.fields["market"].status == "needs_review"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_pipeline_exposes_conflicts_without_dropping_deterministic_value() -> None:
|
|
155
|
+
first = Evidence(
|
|
156
|
+
source_url="https://acme.example/about",
|
|
157
|
+
observed_at="2026-08-20T12:00:00Z",
|
|
158
|
+
confidence=0.91,
|
|
159
|
+
attributes={"industry": "software"},
|
|
160
|
+
)
|
|
161
|
+
second = Evidence(
|
|
162
|
+
source_url="https://directory.example/acme",
|
|
163
|
+
observed_at="2026-08-20T12:00:00Z",
|
|
164
|
+
confidence=0.88,
|
|
165
|
+
attributes={"industry": "retail"},
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
result = EnrichmentPipeline([lambda entity: [first, second]]).enrich(Entity.company(domain="acme.example"))
|
|
169
|
+
|
|
170
|
+
assert result.attributes["industry"].value == "software"
|
|
171
|
+
assert result.review_fields == ("industry",)
|