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.
@@ -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,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ uv.lock
@@ -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.
@@ -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,5 @@
1
+ # Security policy
2
+
3
+ Please report vulnerabilities privately through GitHub Security Advisories for
4
+ this repository. Do not include credentials, private contact data, or raw
5
+ provider responses in public issues.
@@ -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",)