Files
0pt/docs/06-data-engine.md
SIAX Ops 44e83ad1de
CI (SIAX Cloud) / security (push) Successful in 17s
CI (SIAX Cloud) / quality (push) Successful in 39s
CI (SIAX Cloud) / security (pull_request) Successful in 20s
CI (SIAX Cloud) / quality (pull_request) Successful in 46s
resolve: README adopts main (newer, fail-closed auth docs)
2026-08-09 11:30:18 +02:00

245 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 06 · Data engine
The engine turns sources into delivered, provenance-tagged data. It is the legal motor — the whole product positioning depends on it. The real code lives in `services/api/` (FastAPI orchestrator + workers), **not** at the `src/workers/` path this doc used to point to — the "Pipeline stages" section below states plainly, stage by stage, what's built vs. still spec.
> ### Known issue — two diverged git remotes for this repo (unresolved, Simon's call)
> This doc describes the compliant-only sourcing chain as *the law*. That law was briefly violated on one
> of this repo's two remotes, so anyone reading this doc needs to know both exist:
> - `github.com/sax3l/0pt` (this session's `origin`) — the remote this documentation pass was verified against.
> - `git.siax.io/sax3l/0pt` (Gitea) — a **separate, diverged** remote that shares origin history with GitHub for
> at least part of the codebase (`services/api/workers/adaptive_router.py` is byte-for-byte identical between
> GitHub's current `main` and Gitea's post-fix PR branch, so they were once the same commit lineage).
>
> Gitea's `main` independently accumulated three commits on 2026-07-13 that added a real WAF-bypass /
> proxy-rotation / fake-identity scraper stack (`services/scrapers/*`) — exactly the category of code
> §"Prohibited" below and CLAUDE.md §1 forbid. Those commits never touched GitHub's `main`. The stack was
> since removed on Gitea via PR #1, but:
> - **Which remote is authoritative / deployed to `0pt.app` is unknown** — not resolved this session.
> - **Whether the two remotes are supposed to be kept in sync is unknown** — not resolved this session.
>
> This pass does not resolve either question. Simon: decide which remote is canonical, confirm the other has
> no lingering bypass code, and either merge or retire the loser before treating "the 0pt repo" as singular
> in any future doc pass.
## Sourcing chain (strict priority — this is the law)
For each field × market, acquire in this order; stop at the first that works:
1. **Official API / bulk** — registries with programmatic/bulk access (highest confidence).
2. **Authorized public web** — clearly public pages, **respecting `robots.txt` and rate limits**, no auth walls.
3. **Licensed partner data** — contractually obtained.
4. **Otherwise → "not available / licensed only"** — delivered honestly as level 0. **Never guessed.**
**Current implementation covers tier 1 only.** All five working connectors in `services/api/sources/connectors.py`
(`gleif_by_lei`, `brreg_by_orgnr`, `companies_house_by_number`, `prh_by_businessid`, `cvr_by_number`) call
official JSON APIs over `httpx`, nothing else. There is no HTML-scraping/parsing library anywhere in
`services/api` (no `bs4`/`lxml`/`selectolax`, no `playwright`, no `scrapy`/`crawlee` — checked against
`requirements.txt` and the source tree), so tier 2 (authorized public web) has no code path yet. Tier 3
(licensed partner) also has no connector — `sources.yaml`'s `bolagsverket` entry is `access: licensed` with
no matching function in `connectors.py`; it correctly falls through to level 0 today. Both tiers remain
target design, not built.
### Prohibited (do not build, keep, or extend)
- WAF/bot-detection bypass — **including FlareSolverr**, "stealth"/patched browsers, undetected-* used to defeat detection.
- Proxy/fingerprint rotation to evade bans; scraping behind login walls; automated fake-account creation (Apollo etc.).
- **Reconciliation note (stale — kept for history):** this section used to say "if `browser_worker.py` has an
`HTTP → FlareSolverr → Playwright` chain, drop the FlareSolverr stage." **`browser_worker.py` does not exist
anywhere in this repository** (verified this pass — no file of that name on GitHub's `main`). There is
nothing to reconcile in the current Python engine. The bypass code that *did* exist, briefly, lived in
`services/scrapers/*` on the Gitea remote only (see the Known Issue callout above) and has since been
removed there via PR #1. `adaptive_router.py` (below) has never contained a bypass tier on either remote —
it chooses among **compliant** methods only (API > bulk > authorized fetch > licensed), by design.
## Pipeline stages (per job)
```
sourcing → extraction/OCR → structuring → enrichment/fusion → QA
```
The stage names below are the target architecture. `services/api/workers/pipeline.py::run_job` is the one
function that actually runs today, and it implements a much smaller slice of this than the stage list
implies — see the "built vs. not built" status called out under each stage below.
1. **Sourcing** — `adaptive_router.plan_for_market(market)` returns a hardcoded, ordered list of compliant
`source_id`s per market (see table below); `pipeline.py` tries each in order for a given key and **stops at
the first one that returns a result** (`break` after first hit). This is sequential fallback, not
concurrent racing — the phrase "source-racing = query several authorized sources and take the
fastest/freshest" describes a target behavior that isn't implemented; today only one source is ever queried
per key unless the first fails. Writes raw payload fields directly into the record dict (no separate
raw-payload store).
2. **Extraction / OCR — not built.** There is no `ocr_worker`, no `unstructured`, no `paddleocr`/`pytesseract`
anywhere in this repo (checked `services/api/requirements.txt` and the full source tree — none present).
"Extraction" today is just each connector in `connectors.py` parsing the JSON response of its own API call
into a `CompanyResult`. Document/PDF/image ingestion and the OCR cascade described here are spec only.
3. **Structuring — partially built.** `services/api/workers/resolve.py::resolve()` does real, deterministic
entity resolution: block by `(market, name[:1].upper())`, then `rapidfuzz.fuzz.token_sort_ratio(...) >= 92`
to merge duplicates, bumping `confidence` by +0.1 per corroborating match (capped 0.99) and incrementing
`agreed_sources`. That part is real and unit-testable. What's **not** built: no spaCy/transformers, no NER,
no multilingual normalization (`sv/no/da/fi/de/fr` from the original text), and no explicit LEI-based
cross-market entity linking — matching is name-string-similarity only, within the same market. `spacy` and
`transformers` are not in `requirements.txt`.
4. **Enrichment / fusion — partially built, not fully wired into the job.** The confidence/agreement bump
described above happens inside `resolve()`. Separately, `opensanctions_screen()` (sanctions/PEP) and
`rdw_vehicle()` (NL vehicle data) are real, working connectors in `connectors.py` — but `pipeline.py` never
imports or calls either one. They're only reachable as their own standalone endpoints
(`GET /screen`, `GET /vehicles/{plate}` in `orchestrator/main.py`), not as an automatic enrichment step on
an order's records. NACE/SNI classification and "website/tech" enrichment have no code anywhere.
5. **QA — not built beyond a single threshold filter.** The entire QA stage in `pipeline.py` is:
`delivered = [r for r in deduped if r["confidence"] >= threshold]`. That's real and does drop
below-threshold rows (deliver fewer, never fabricate — that principle holds). But there is no
Great Expectations (not in `requirements.txt`, not imported anywhere), no separate validity/format,
completeness, freshness, or post-dedup-uniqueness checks beyond the confidence number already computed in
structuring, and no quality-report object emitted or stored anywhere — `deliveries` (the SQL table defined
in `sql/001_init.sql`) is never written to by this service. This mirrors a confirmed, broader finding this
session: 0pt's data-quality/QA layer was at one point documented elsewhere (TypeScript side,
`src/services/quality-check-service.ts` etc.) as if built and tested, but those files **do not exist** in
this repo's history at all (zero commits, any branch — see `docs/07-dependencies.md` note). `docs/ICEBERG_WRITE_BOUNDARY.md`,
`docs/11-lakehouse-write-plan.md`, and `docs/P0_QUERY_SOURCE_FLOW.md` have all since been corrected — each
now carries its own explicit "fabricated, not built" callout rather than asserting those files as real —
and `docs/P0_DATASET_QUALITY_FLOW.md` documents this gap as target-spec-only. The Python engine
independently confirms the same gap: **no real quality-check enforcement exists in this codebase today**,
in either language.
**"Each stage updates `jobs` ... so the production tracker / SSE can render it live" is not what happens.**
`run_job` runs synchronously start-to-finish and returns one final dict (`stage: "qa", pct: 100, state: "done",
metrics, records`). `orchestrator/main.py`'s `POST /orders/{oid}/run` awaits the whole thing and stores that one
result in an in-memory `_JOBS` dict; `GET /orders/{oid}/status` just returns whatever's there, or a static
`{"stage": "queued", "pct": 0, "state": "queued"}` placeholder before it's run. There is no per-stage
incremental progress, no `pct` that climbs, and **no SSE endpoint exists anywhere in `orchestrator/main.py`**
(grepped — none). Live progress rendering is target design, not current behavior.
### `adaptive_router` — actual market coverage (verified against `services/api/workers/adaptive_router.py`)
`MARKET_SOURCES` currently has real entries for exactly five markets, each a short compliant list — not the
"elaborate multi-source lists" an earlier version of this router may have implied:
| Market | Ordered plan | Note |
|---|---|---|
| NO | `brreg`, `gleif` | |
| GB | `companies_house`, `gleif` | |
| FI | `prh`, `gleif` | |
| DK | `cvr`, `gleif` | |
| SE | `gleif` only | Bolagsverket bulk is licensed (see above); SE company core relies on GLEIF + a not-yet-built licensed/partner route |
| anything else | `["gleif"]` | fallback in `plan_for_market()` |
`ACCESS_RANK` (`api=0, bulk=1, authorized_web=2, licensed=3`) is defined in the same file but is **not
actually used** by `plan_for_market()` to sort anything — the lists above are hand-ordered constants already
in the desired priority, and the rank dict is currently dead code (documentation-as-code, not logic).
## Confidence & provenance scoring
- Per field, `connectors.py`'s `Provenance` dataclass carries: `field_name`, `source_id`, `confidence` (0–1),
`level` (0–3), `freshness`. **`agreed_sources` is not a field on `Provenance`** — it's tracked separately, at
the merged-record level, by `resolve()` (`match["agreed_sources"] = ... + 1`). If a doc or downstream
consumer expects `agreed_sources` inside each `bridge_provenance` row, that's a mismatch to reconcile against
the actual dataclass shape, not a fact to assume.
- Level: 0 = unavailable/licensed; 1 = single source; 2 = partial corroboration; 3 = ≥2 independent sources agree.
- `est_confidence` on the order = aggregate target (computed in `orchestrator/main.py::compute_estimate`);
delivered `avg_confidence` = actual, computed in `pipeline.py` as the mean confidence of delivered rows.
- **None of this is persisted to Postgres by the current service.** `sql/001_init.sql` defines the
`bridge_provenance` table (plus `dim_company`, `dim_source`, `fact_financials`, `deliveries`, etc.), and
`asyncpg` is a listed dependency — but nothing in `services/api` actually calls `asyncpg` or issues a query.
`DATABASE_URL` is read exactly once, in `orchestrator/main.py`'s `/health` handler, purely to report a
boolean (`"db": bool(os.getenv("DATABASE_URL"))`). `orchestrator/main.py` stores orders and jobs in
in-memory dicts (`_ORDERS`, `_JOBS`) that reset on every restart. The schema exists; the wiring from
pipeline output → Postgres rows does not yet.
### Network-layer provenance: `1p` (not yet in use)
`bridge_provenance` answers *which source a field came from*. It does not answer *what
actually happened on the wire* — which policy authorized the fetch, whether `robots.txt` was
read and what it said, or whether the bytes we parsed were the page or a bot wall served with
HTTP 200. **[`1p`](https://git.siax.io/sax3l/1p)**, the SIAX egress measurement point, closes
that gap and extends the provenance chain all the way down to the network layer.
1p owns four things: a per-destination policy as code (default DENY; rate, robots/ToS stance,
allowed methods), a single egress choke point, block detection, and one provenance record per
fetch written to AUD0. Everything else is composed (AUD0, ST0RE, CL0UD).
Why this matters for 0pt specifically — three facts that are hard to assert without it:
| Claim 0pt makes | What 1p adds |
|---|---|
| "acquired from an authorized public page, respecting `robots.txt`" | The record carries the robots decision **plus a sha256 of the `robots.txt` it was taken from**. A policy that says `respect` and cannot read robots denies the fetch — silence is not permission. |
| "these are the bytes we parsed" | `body_sha256` per fetch, so a delivered field can be tied back to the exact response at the exact time. |
| "level 1/2/3 corroboration from independent sources" | Responses are classified `ok` / `rate_limited` / `forbidden` / `captcha` / `blocked_soft` / `error`. `blocked_soft` is the expensive one: **HTTP 200 whose body is a block page**. Without it, a bot wall gets parsed and scored as corroborating data. |
**It is a measurement point, not a bypass.** This is consistent with, not an exception to,
the prohibitions above and in `CLAUDE.md` §1: 1p exists to *detect* blocking and *prove*
provenance, never to evade. Its purchased-residential egress pool is an interface with a stub
that throws — wiring it up would be a separate, explicit decision. Block detection also
alarms on a rising *trend*, never on single events, and deliberately excludes `error`
(their 5xx, our timeouts) from the blocking measure.
**Status: not in use.** 1p is Phase 0 — code, contracts, tests; not deployed, no credentials
set. No 0pt worker routes through it today, and `bridge_provenance` remains the only
provenance 0pt writes. Adopting it is a later, separate step.
## `sources.yaml` (machine-readable registry)
Single registry that drives the router, the `/sources` API, and the (still-to-be-built) public sourcing-map
page. `services/api/sources/sources.yaml` currently has **8 entries**: `gleif`, `brreg`, `companies_house`,
`prh`, `cvr`, `bolagsverket`, `rdw`, `opensanctions`. Schema per entry (as actually used, confirmed against the
file — no `entities`/`fields` mismatch found):
```yaml
- source_id: gleif
name: GLEIF (LEI)
market: global
access: api # api | bulk | authorized_web | licensed
cadence: daily
licence: "open (CC0) — VERIFY current terms"
entities: [company]
fields: [lei, legal_name, address, status]
base_url: https://api.gleif.org
robots_ok: true # for authorized_web only; must be honored
notes: "cross-border key"
```
`orchestrator/main.py` loads this file once at import time (`yaml.safe_load(...)`) and serves it through
`GET /sources` (id/name/market/access/licence/cadence only) and `GET /sources/{market}`. It is genuinely the
live registry behind those two endpoints — that part of the doc's original claim holds.
### Starter set (VERIFY current access/licence per source before relying — terms change)
- **GLEIF / LEI** — typically open; cross-border key. Connector: `gleif_by_lei` — real, working.
- **Brønnøysund (Brreg, NO)** — typically open API. Connector: `brreg_by_orgnr` — real, working.
- **Companies House (UK)** — free API (key required, `COMPANIES_HOUSE_API_KEY`). Connector:
`companies_house_by_number` — real; degrades honestly to level-0 unavailable if the key is unset (does not
fabricate).
- **PRH (FI)** — Connector: `prh_by_businessid` — real, working.
- **CVR / Virk (DK)** — connector actually calls `cvrapi.dk` (a third-party aggregator), **not** Virk's own API
directly, despite the registry entry's `base_url: https://datacvr.virk.dk`. `sources.yaml`'s `base_url` for
`cvr` does not match what `connectors.py::cvr_by_number` actually calls — flag for reconciliation (either
update `base_url` to `cvrapi.dk` or switch the connector to call Virk directly; not resolved this pass).
- **Bolagsverket (SE)** — restricted for bulk; `sources.yaml` marks it `access: licensed`; **no connector
exists** for it in `connectors.py`. Consistent with the doc's own "plan licensed/partner route" framing —
not a false claim, just confirming it's still unbuilt.
- **RDW (NL)** vehicle data — open, Socrata-backed. Connector: `rdw_vehicle` — real, working, but **not called
by the job pipeline** (only reachable via `GET /vehicles/{plate}`).
- **OpenSanctions** — global sanctions/PEP screening. Connector: `opensanctions_screen` — real, working, same
caveat: reachable only via `GET /screen`, not part of `run_job`.
- Vehicle beyond RDW (**Traficom FI**, **Transportstyrelsen SE**, **DVLA UK**) — no connectors exist yet;
*(verify each before building; vehicle/PII rules differ)*.
> Do not hardcode access terms as fact. When building each connector, confirm the current API/licence/robots
> status and record it in `sources.yaml`. If unclear or disallowed for bulk, route to licensed/partner or mark
> fields level 0.
## Structured financials (XBRL) — real but unwired
`services/api/sources/financials.py::companies_house_filings()` is a real, working function: given a
Companies House number and `COMPANIES_HOUSE_API_KEY`, it fetches the accounts filing history and returns up to
10 filings. It is **not imported or called anywhere else in the codebase** (grepped `services/api` — only
`financials.py` itself references the function name). The file's closing comment describes a further XBRL
extraction step ("with `arelle` installed, download each accounts filing's iXBRL document and pull tagged
facts...") that has no code at all — `arelle` is not in `requirements.txt`. Treat the whole file as a real but
disconnected building block, not a wired pipeline stage.
## Libraries used here
Target list only — see `docs/07-dependencies.md` for what's actually installed today vs. still to be added,
with an explicit install-status callout per package. As of this pass, the *only* Python packages installed for
this engine are `fastapi`, `uvicorn`, `pydantic`, `httpx`, `asyncpg` (unused), `python-dotenv`, `pyyaml`,
`rapidfuzz`, `tenacity` — i.e. the API-only tier 1 sourcing + structuring-by-fuzzy-match slice described above,
nothing from the extraction/OCR/NER/quality layers this section used to list as if already in place.