Metadata-Version: 2.5
Name: enrichfold
Version: 0.6.0
Summary: Provider-neutral, provenance-first entity enrichment for people and companies.
Project-URL: Homepage, https://mihailorama.github.io/enrichfold/
Project-URL: Repository, https://github.com/Mihailorama/enrichfold
Project-URL: Issues, https://github.com/Mihailorama/enrichfold/issues
Author: Mihail R.
License: MIT
License-File: LICENSE
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Requires-Python: >=3.10
Provides-Extra: search
Requires-Dist: httpx>=0.27; extra == 'search'
Provides-Extra: test
Requires-Dist: pytest-asyncio>=1.3; extra == 'test'
Requires-Dist: pytest-httpx>=0.36; extra == 'test'
Requires-Dist: pytest>=9.0; extra == 'test'
Description-Content-Type: text/markdown

# Enrichfold

Provider-neutral, provenance-first entity enrichment for people and companies.

[Website](https://mihailorama.github.io/enrichfold/) · [PyPI](https://pypi.org/project/enrichfold/) · [npm](https://www.npmjs.com/package/@mihailorama/enrichfold)

`enrichfold` has an offline-first enrichment core and an optional web search
package. Applications supply credentials. Search engines include Exa, Parallel,
You.com, Tavily, Linkup, Seltz, TinyFish, Nimble, Browserbase, Serper, and
DuckDuckGo. Optional Keenable and Scrapefold page adapters are also available.
Every accepted attribute retains its source
URL, observation time, and confidence, so downstream systems can decide whether a
result is suitable for an automated action or requires review.

```python
from enrichfold import Entity, EnrichmentPipeline, Evidence

class CompanyProvider:
    def discover(self, entity):
        return [Evidence(
            source_url="https://example.com/team",
            observed_at="2026-08-20T12:00:00Z",
            confidence=0.94,
            attributes={"industry": "software", "company_size": "51-200"},
        )]

company = Entity.company(domain="example.com")
result = EnrichmentPipeline([CompanyProvider()]).enrich(company)
print(result.attributes["industry"].value)  # software
```

## Evidence, conflicts, and review gates

The core keeps provider I/O in adapters. It turns supplied
evidence into deterministic decisions while retaining disagreements for a human
approval flow. A conflicting value is never silently accepted:

```python
from enrichfold import Claim, Evidence, reconcile_claims

result = reconcile_claims([
    Claim(
        field="industry",
        value="software",
        evidence=Evidence(
            source_url="https://acme.example/about",
            observed_at="2026-08-20T12:00:00Z",
            confidence=0.91,
        ),
    ),
    Claim(
        field="industry",
        value="retail",
        evidence=Evidence(
            source_url="https://directory.example/acme",
            observed_at="2026-08-20T12:00:00Z",
            confidence=0.88,
        ),
    ),
])

industry = result.fields["industry"]
assert industry.value == "software"          # stable suggested value
assert industry.status == "needs_review"     # do not automate an action
assert result.requires_review is True
```

`Claim(kind="inferred", ...)` also requires review even with no competing
claim. This distinction makes it possible to keep model-produced hypotheses
without presenting them as observed facts.

## Multi-provider research runs

`ResearchEngine` is the boundary for applications that call several research
providers. It runs caller-owned adapters concurrently, reserves generic units
before starting work, retains each provider outcome, and returns an explicit
coverage/review state. It does not make network calls itself.

```python
from enrichfold import (
    Claim,
    Evidence,
    Entity,
    ProviderOutput,
    ProviderSpec,
    ResearchBudget,
    ResearchEngine,
)

def official_site(entity):
    # The host application owns this adapter, its HTTP client, and credentials.
    return ProviderOutput(
        claims=(Claim(
            field="industry",
            value="software",
            evidence=Evidence(
                source_url="https://example.com/about",
                observed_at="2026-08-20T12:00:00Z",
                confidence=0.9,
            ),
        ),),
        usage_units=2,
    )

result = ResearchEngine(
    [ProviderSpec("official-site", official_site, reserved_units=2)],
    budget=ResearchBudget(max_units=5),
).run(Entity.company(domain="example.com"), requested_fields=("industry",))

assert result.status in {"completed", "partial", "needs_review", "failed"}
assert result.budget.reserved_units == 2
```

An optional `EvidenceValidator` can return `EvidenceVerdict("needs_review",
reason)` for a weak source or `EvidenceVerdict("rejected", reason)` to keep
it out of resolution. In either case, the result preserves the original claim,
source URL, and verdict in `evidence_assessments`.

## Keenable search provider

`KeenableProvider` uses the same REST search contract as Scrapefold's Keenable
engine. It reads `KEENABLE_API_KEY` by default and performs one search per entity.
Because search results are sources rather than verified attributes, the caller maps
them to claims explicitly:

```python
from enrichfold import (
    Claim,
    Entity,
    Evidence,
    KeenableProvider,
    ProviderSpec,
    ResearchEngine,
)

def claims(entity, results):
    for result in results:
        yield Claim(
            field="summary",
            value=result["description"],
            kind="inferred",
            evidence=Evidence(
                source_url=result["url"],
                observed_at=result["acquired_at"],
                confidence=0.8,
                provider="keenable",
            ),
        )

provider = KeenableProvider(
    lambda entity: f'{entity.identifiers["domain"]} company',
    claims,
)
result = ResearchEngine([
    ProviderSpec("keenable", provider, reserved_units=1),
]).run(Entity.company(domain="example.com"), requested_fields=("summary",))
```

## Web search and Scrapefold pages

Install `enrichfold[search]` for direct search. Scrapefold's `search()` delegates
to this API; its URL engines fetch pages. The caller decides which results
substantiate claims:

```python
from enrichfold import ScrapefoldScrapeProvider, WebSearchProvider
from enrichfold.search import SearchOptions, search

# await search("example.com company", SearchOptions(engines=("parallel", "tavily")))
provider = WebSearchProvider(
    query=lambda entity: f'{entity.identifiers["domain"]} company',
    map_results=claims,  # same (entity, results) mapper as KeenableProvider
    engines=("parallel", "tavily"),
    usage_units=2,
)

page_provider = ScrapefoldScrapeProvider(
    url=lambda entity: f'https://{entity.identifiers["domain"]}/about',
    map_result=map_page,  # (entity, page) -> claims
    engines=("firecrawl",),
    usage_units=1,
)
```

Each result passed to `map_results` has `url`, `title`, `description`,
`engines`, and `acquired_at`. Pass `usage_units` and reserve at least that many
units in its `ProviderSpec` when enforcing a research budget. The page mapper
receives `url`, `text`, `markdown`, `html`, `json`, `engine`, and `acquired_at`.

## Grounding validator

`GroundingValidator` is an optional `EvidenceValidator` that checks a
provider-asserted value against the actual text of its own source page. The
default engine behaviour is to trust provider values (the verdict is literally
`accepted, "no validator configured"`); this adapter closes that gap.

It is a standalone, opt-in adapter like `KeenableProvider`: enrichfold's core
never imports it and never gains an HTTP client. The adapter does I/O only
through a caller-supplied `fetch(url) -> str` callable, so "core never fetches"
stays true. Wire in a Scrapefold-backed fetch (or any other):

```python
from enrichfold import GroundingValidator, ProviderSpec, ResearchEngine, Entity
import scrapefold  # host dependency, not enrichfold's

validator = GroundingValidator(lambda url: scrapefold.scrape_sync(url).text)

result = ResearchEngine(
    [ProviderSpec("official-site", official_site, reserved_units=2)],
    evidence_validator=validator,
).run(Entity.company(domain="example.com"), requested_fields=("industry",))
```

For each claim the validator fetches `claim.evidence.source_url` and grounds
`claim.value` (and any `evidence.attributes` values) in the returned text:

- `accepted` when the value is found (coverage reaches `min_accept_coverage`,
  all values by default).
- `rejected` when no value is found - it is kept out of resolution.
- `needs_review` when only some values are found, when there is no groundable
  value, or when the fetch fails. A fetch failure never raises: it becomes a
  review verdict with a redacted reason.

The adapter adds no claims - it only returns a verdict, so it "must not perform
hidden enrichment" holds. As with any validator, the original claim, source
URL, and verdict are preserved in `evidence_assessments`.

The matching is done by `find_citations(text, targets)`, a stdlib-only,
two-pass exact-then-normalized substring matcher returning coverage. It is a
port of Scrapefold's citation algorithm, kept inside this adapter rather than
imported so enrichfold has no dependency on Scrapefold and stays
offline-testable.

## Company identity gate

Before a caller enriches or acts on a company, use the offline identity gate.
It is deliberately conservative: free mailboxes, invalid sites, domain
conflicts, and corporate domains that do not exactly match the name receive a
review status. Applications can pass separately verified site metadata when
they have it.

```python
from enrichfold import derive_company_identity

identity = derive_company_identity(
    email="hello@acme.example",
    company_name="Acme",
    website="https://www.acme.example/about",
)

assert identity.status == "verified"
assert identity.canonical_domain == "acme.example"
```

## Design boundaries

- Core reconciliation and identity APIs make no network calls; explicit provider
  adapters such as `KeenableProvider` may do so.
- No inferred facts: a field is returned only when a provider supplies evidence.
- Conflicts have a deterministic suggested value but are marked `needs_review`.
- Inferred claims are always marked `needs_review`.
- Multi-provider runs reserve caller-defined generic units before execution and
  expose partial coverage rather than hiding failed or skipped providers.
- Optional source-policy hooks can accept, reject, or route evidence to review.
  `GroundingValidator` uses only a caller-supplied fetch callable; the optional
  search package performs network calls only when explicitly invoked.
- Company identity is verified only through an exact name/domain match or
  caller-supplied, independently verified same-domain site metadata.
- Built-in search adapters and caller-owned providers can be combined with public
  data APIs, browser tools, or internal approved sources.

The package intentionally does not decide whether a review is approved or run
an action after one; persistence, permissions, UI, and provider-specific claim
extraction stay with the host application.

## Installation

### Python

```bash
pip install enrichfold
# For direct web search:
pip install 'enrichfold[search]'
```

### TypeScript / Node.js

The TypeScript companion currently exposes the same offline company identity
gate. It is intentionally a normal npm dependency, rather than a Python
subprocess hidden inside a web application:

```bash
npm install @mihailorama/enrichfold
```

Its provider runtime will follow as a compatible TypeScript surface; Python and
TypeScript package versions are released independently.

## Development

```bash
uv run --with pytest pytest -q
python -m build
```

## License

MIT.
