Skip to content

Web enrichment architecture

Status: implemented (#842). Decision: ADR-0029. Reference: company pass and operations.

Goal

For a company with a CVR, collect evidenced information from search, Google AI Mode, and the company's own website: the website and social profiles, company contacts, what the company does and for whom, the people it names, and the companies it names as customers, suppliers, partners, or competitors. Every value keeps its source and an exact evidence locator.

Runtime view

flowchart LR
    CALLER[Backend or web-starter job] -->|POST /company-requests| SVC[web-enrichment service]
    SVC -->|runs + receipt| DB[(Postgres)]
    SVC -->|one task per run| Q1[web-enrich-batch]
    SVC -->|one task per run| Q2[web-enrich-ad-hoc]
    Q1 -->|POST /tasks/runs/ID| SVC
    Q2 -->|POST /tasks/runs/ID| SVC
    SCHED[Cloud Scheduler, every 10 min] -->|POST /tasks/sweep| SVC
    SVC --> DFS[DataForSEO live search and AI Mode]
    SVC --> OR[OpenRouter model]
    SVC --> OAI[OpenAI embeddings]
    SVC -->|through Webshare| WEB[Company websites]
    SVC --> GCS[(GCS raw evidence)]

One service does everything. A company pass runs inside the task request that delivers its run; each instance runs up to eight passes at the same time and shares one headless Chromium between them. Cloud Run adds instances when the queues hold work and scales to zero when they are empty.

The service reaches company websites through the Webshare proxy, which gives Danish addresses: many Danish web firewalls refuse cloud addresses, and a direct fetch spends the reputation of our own address. The direct route is used only when the proxy itself fails. The paid APIs are called directly.

One company pass

flowchart TD
    FACTS[Registry facts] --> LEADS[Leads: registry website and email domain]
    FACTS --> AI[Live AI Mode question]
    FACTS --> ORG[Live organic search]
    LEADS --> VISIT[Visit site: HTTP, plain HTTP, browser]
    AI -->|site named as the website| VISIT
    ORG -->|non-directory results| VISIT
    VISIT --> DECIDE{Ownership from registry facts on the site}
    DECIDE -->|clear| ACCEPT[Verified website]
    DECIDE -->|unclear| JUDGE[Model judges with the same facts]
    JUDGE --> ACCEPT
    DECIDE -->|refused| VISIT
    ACCEPT --> PAGES[Select up to 8 pages by topic]
    PAGES --> RETAIN[Store raw pages and search responses]
    RETAIN --> READ[Deterministic contacts, socials, technologies]
    RETAIN --> SITE[One model call: profile, classes, mentions, people]
    AI --> SOURCES[One model call: AI Mode signals]
    READ --> PUBLISH[One transaction to the published tables]
    SITE --> PUBLISH
    SOURCES --> PUBLISH
    PUBLISH --> EMBED[Profile embedding]

The AI Mode question starts at once. The organic search also starts at once when the registry names no website; otherwise it runs only when the registry leads fail. A pass checks at most four sites.

A site is checked at its root, not at the page a search returned, so a directory profile page cannot stand in for a website. The home page is fetched first; the contact and about pages linked from it are fetched only when the home page alone does not decide ownership.

Module map

Layer Module Responsibility
adapters adapters/web/fetch.py HTTP and browser fetching, redirects, TLS, proxy fallback
adapters adapters/web/dataforseo.py, llm.py, embeddings.py Provider calls with bounded retries
enrichment enrichment/web/facts.py Registry facts, name folding, registrable domains
enrichment enrichment/web/leads.py Leads from registry, AI Mode, search; directory exclusion
enrichment enrichment/web/identity.py, ownership.py Registry facts on a site; the ownership decision and judge prompt
enrichment enrichment/web/page.py, page_selection.py Text projection, link and sitemap page selection
enrichment enrichment/web/extraction.py, sources.py Model schemas and prompts, quote checks, evidence locators
orchestration orchestration/web/company_pass.py The pass and its log
orchestration orchestration/web/store.py Registry reads and the publication transaction
orchestration orchestration/web/runner.py, requests.py, tasks.py Claims and retries, admission and progress, Cloud Tasks
runtime runtime/web_service.py, web_starter.py, web_auth.py The service, the operator job, caller identity

The existing domain rules for contacts, people attribution and privacy, company mentions, technology signatures, and the embedding text recipe are reused unchanged (enrichment/contacts.py, people.py, mentions.py, technology.py, embedding_text.py).

Data

A run writes to the tables the read contracts already publish:

Table Content
company_enrichment_run Status, attempts, outcome, reason, and the pass log with costs
web_evidence_bundle, web_evidence_bundle_page The sealed manifest of retained pages for the verified site, with the ownership decision
web_company_presence Website and social profiles
web_company_contact Company contacts with exact locators
web_company_profile_signal Profile fields with quotes and locators
company_web_filter_evaluation, web_extractor_result, web_extraction_application Classification and technology filters, extractor records
web_entity_mention, entity_relationships Named companies and, after a unique active-company match, observed edges
person_mentions, person_mention_contact Named people and the work contacts the page attributes to them
source_retrieval, web_source_extraction, web_source_signal Search responses and the AI Mode signals read from them
company_embedding The profile embedding

Raw evidence is stored create-only in GCS under raw/web/pages/ and raw/web/search/ before any model reads it. Locators name the object generation, digest, projection version, and character range.

Failure and retry

  • A duplicate or stale delivery finds the run claimed or finished and does nothing.
  • A provider fault inside a pass is retried by its adapter. A fault that remains becomes a named step error in the log; the pass continues with what it has.
  • An unexpected error releases the claim and returns HTTP 500; Cloud Tasks retries with backoff. The third failed attempt ends the run as failed.
  • A lost task is found by the sweep: a queued run untouched for ten minutes, or a claim older than twenty minutes, is enqueued again.
  • A retry may repeat searches and model calls (about USD 0.01).

Scale

A pass takes 10 to 60 seconds: on the deployed service a mean of 23 to 38 seconds and a 95th percentile of 41 to 64 seconds. The batch queue runs at most 40 passes at a time and dispatches at most four per second; each instance runs up to eight. A 300-company batch finished in 246 seconds (1.22 companies per second). Admission takes about one second for 500 companies. Queue rates and concurrency, instance concurrency, and the maximum instance count are Terraform values in infra/web_service.tf; raise the queue concurrency for more throughput, within the provider and proxy limits.

Provider cost per company is about USD 0.004 to 0.006 for search and AI Mode, plus one to three small model calls and one embedding.