company profile enrichment: Wikidata facts/executives/structure, Wikipedia description, homepage JSON-LD/logo/socials, grounded LLM text; API profile/facts
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
17 changed files +4,166 −14
modified
docs/API.md
+47 −0
@@ -236,6 +236,53 @@ ever stored hashed. Admin endpoints answer `401 { "detail": "admin token require | ||
| 236 | 236 | duplicate | noise | misclassified) feeding `/admin/quality.calibration`; retract/restore append an audit trail under `payload._audit` and |
| 237 | 237 | never delete; `GET /admin/storage` reports the object store. `/admin/overview` also returns `companies_by_onboarding`, `scheduler_heartbeat`. |
| 238 | 238 | |
| 239 | +## Profile & facts (additive, 2026-09-13 — `services/enrichment.py`) | |
| 240 | + | |
| 241 | +Companies are enriched from **Wikidata** (entity), **Wikipedia** (lead summary, CC BY-SA 4.0), the **homepage** (meta description, | |
| 242 | +JSON-LD `Organization`, icons) and, only when no Wikipedia text exists, a **grounded LLM description** built from the company's own pages. | |
| 243 | +Every accepted value keeps its provenance; a weaker source never overwrites a better one (description: wikipedia > llm > homepage > wikidata; | |
| 244 | +other facts: wikidata > homepage > registry > wikipedia). Nothing is inferred — absent facts are `null`. | |
| 245 | + | |
| 246 | +```ts | |
| 247 | +type Provenance = { field: string; source: 'wikidata'|'wikipedia'|'homepage'|'llm'|'registry'; url: string | null; retrieved_at: string }; | |
| 248 | +type Money = { value: number; currency: string; year: number }; // ISO 4217; the fiscal year the figure refers to | |
| 249 | + | |
| 250 | +type CompanyProfile = { | |
| 251 | + description: string | null; description_source: 'wikipedia'|'wikidata'|'homepage'|'llm'|null; description_url: string | null; | |
| 252 | + description_license: string | null; // 'CC BY-SA 4.0' for Wikipedia text (attribution required: show description_url) | |
| 253 | + description_attribution: string | null; // e.g. 'Text from Wikipedia (en), CC BY-SA 4.0' · "Generated from the company's public pages" | |
| 254 | + logo_url: string | null; icon_url: string | null; founded_year: number | null; legal_form: string | null; legal_name: string | null; | |
| 255 | + employees: number | null; employees_year: number | null; revenue: Money | null; net_income: Money | null; total_assets: Money | null; | |
| 256 | + hq: { city: string | null; region: string | null; country: string | null; address: string | null; lat: number | null; lon: number | null }; | |
| 257 | + ticker: string | null; exchange: string | null; isin: string | null; lei: string | null; sec_cik: string | null; public_company: boolean; | |
| 258 | + wikipedia_url: string | null; wikidata_url: string | null; official_website: string | null; phone: string | null; | |
| 259 | + products: string[]; industries: string[]; industry_labels: string[]; | |
| 260 | + socials: { linkedin?: string; x?: string; youtube?: string; facebook?: string; instagram?: string; github?: string; tiktok?: string; crunchbase?: string }; | |
| 261 | + enriched_at: string | null; sources: Provenance[]; version: 'profile-v1'; | |
| 262 | +}; | |
| 263 | +type Fact = { key: 'founded'|'headquarters'|'employees'|'revenue'|'net_income'|'total_assets'|'legal_form'|'listing'|'isin'|'lei'|'sec_cik'|'website'|'wikipedia'; | |
| 264 | + label: string; value: string; raw: unknown; source: string | null; url: string | null; retrieved_at: string | null }; | |
| 265 | +``` | |
| 266 | + | |
| 267 | +- **`CompanyCard.profile?: CompanyProfile`** — present on every card (lists, rankings, detail…) once the company has been enriched | |
| 268 | + (`companies.source_meta.profile`); absent before. `sources[]` holds one entry per populated field. | |
| 269 | +- **`GET /companies/{slug}`** adds `profile: CompanyProfile | null` and `facts: Fact[]` (display-ready strings such as `"47,756 (2013)"`, | |
| 270 | + `"USD 305.6 B (2023)"`, `"Mountain View, California, US"`; `raw` carries the underlying value). `relationships` rows now carry | |
| 271 | + `provenance: {source, property, qid?, retrieved_at?}`, `first_seen_at`, `last_seen_at`, and `kind` ∈ `PARENT_OF | SUBSIDIARY_OF | OWNED_BY | | |
| 272 | + OWNER_OF | ACQUIRED_BY | ACQUIRED | PARTNER_OF | COMPETITOR_OF | INVESTOR_IN | BRAND_OF` (Wikidata rows: confidence 0.85, `valid_from` / | |
| 273 | + `valid_to` from the start/end qualifiers; `company` is set when the counterpart is in the atlas, else only `to_name`). Current | |
| 274 | + relationships are listed before ended ones (limit 200). | |
| 275 | +- **`GET /companies/{slug}/people`** rows add `source: 'wikidata' | 'page'` (derived from `source_url`; Wikidata rows have | |
| 276 | + `title` = role such as `Chief Executive Officer`, `Chairperson`, `Founder`, or the position label for key people) and the payload adds | |
| 277 | + `sources: string[]`. A person whose Wikidata role ended is `no_longer_listed` (never "left"); page-sourced rows are never overridden by Wikidata. | |
| 278 | +- Columns back-filled by enrichment (`description`, `logo_url`, `hq_city`, `hq_region`, `country`, `founded_year`, `employees`, `ticker`, | |
| 279 | + `exchange`, `legal_name`, `lei`, `sec_cik`, `industries`) record their source in `companies.source_meta.provenance[column]` | |
| 280 | + (`{source, url, retrieved_at, previous?}`), so `CompanyCard.description` may now be a Wikipedia paragraph — show the attribution from | |
| 281 | + `profile.description_attribution` / `profile.description_url` when `profile.description_source === 'wikipedia'`. | |
| 282 | +- CLI: `catlas enrich-companies [--limit N] [--company slug…] [--source wikidata|wikipedia|homepage|llm|all] [--concurrency N] [--no-llm]`, | |
| 283 | + `catlas profile <slug> [--json]`. Periodic task `company-enrichment` (every 10 min, batch `CA_ENRICH_BATCH` = 300; never-enriched first, | |
| 284 | + active companies first, then profiles older than `CA_ENRICH_REFRESH_DAYS` = 30). | |
| 285 | + | |
| 239 | 286 | ## Conventions for implementers |
| 240 | 287 | - Every list endpoint is bounded (`per_page ≤ 200`, `limit ≤ 500`), uses indexed predicates, and returns `Cache-Control: public, max-age=60` for public aggregates (`/pulse`, `/stats`, `/rankings`, `/industries`, `/countries`) and `no-store` for `/live*`, owner and admin routes. |
| 241 | 288 | - Company lookups accept slug **or** id. Unknown → 404 `{detail: "company not found"}`. |
modified
docs/ARCHITECTURE.md
+1 −0
@@ -37,6 +37,7 @@ registry/ (seed companies, industries, countries) ──catlas seed──▶ c | ||
| 37 | 37 | | Crawl core | `sdk/normalize.py`, `sdk/diff.py`, `sdk/connector.py` (+ registry), `connectors/*`, `services/discovery.py`, `services/pipeline.py`, `services/scheduler.py`, `services/repair.py` | `crawl.py`: `onboard`, `discover`, `run-sensor`, `schedule`, `sensors`, `repair`, `connectors` | |
| 38 | 38 | | Intelligence | `services/events.py`, `services/clustering.py`, `services/llm/{gateway,enrich,schemas}.py`, `prompts/*`, `services/metrics.py`, `services/signals.py`, `services/trends.py`, `services/alerts.py`, `services/digest.py` | `intel.py`: `process-changes`, `enrich`, `metrics`, `daily`, `signals`, `alerts` | |
| 39 | 39 | | Seeds | `registry/` data files, `companyatlas/registry/seed.py`, `scripts/seed_wikidata.py`, `scripts/seed_edgar.py` | `seed.py`: `seed`, `import-companies` | |
| 40 | +| Profile enrichment | `services/enrichment.py` (Wikidata entity + Wikipedia summary + homepage facts + grounded LLM text → `companies.source_meta.profile`, column back-fills with provenance, `people`, `company_relationships`; periodic `company-enrichment`), `prompts/company-profile/` | `enrichment.py`: `enrich-companies`, `profile` | | |
| 40 | 41 | | API | `api/routers/*.py` (auto-included; `ORDER` for precedence), `api/sse.py`, `api/ratelimit.py` | — | |
| 41 | 42 | | Web | `apps/web` | — | |
| 42 | 43 | | Ops | `deploy/*.mld.json`, `deploy/render-manifest.sh`, `deploy/first-run.sh`, `scripts/backup*.sh` | `ops.py`: `stats`, `status`, `backup`, `retention` | |
added
fixtures/enrichment/homepage.html
+54 −0
@@ -0,0 +1,54 @@ | ||
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="en"> | |
| 3 | +<head> | |
| 4 | + <meta charset="utf-8"> | |
| 5 | + <title>Example Robotics — autonomous warehouse robots</title> | |
| 6 | + <meta name="description" content="Example Robotics designs and builds autonomous mobile robots for warehouses and distribution centres in North America and Europe."> | |
| 7 | + <meta property="og:description" content="Autonomous mobile robots for warehouses."> | |
| 8 | + <meta property="og:image" content="/static/og-card.png"> | |
| 9 | + <link rel="icon" href="/favicon.ico" sizes="32x32"> | |
| 10 | + <link rel="apple-touch-icon" href="/static/apple-touch-icon.png" sizes="180x180"> | |
| 11 | + <script type="application/ld+json"> | |
| 12 | + { | |
| 13 | + "@context": "https://schema.org", | |
| 14 | + "@type": "Organization", | |
| 15 | + "name": "Example Robotics", | |
| 16 | + "legalName": "Example Robotics Inc.", | |
| 17 | + "url": "https://www.example-robotics.test/", | |
| 18 | + "logo": "https://www.example-robotics.test/static/logo.svg", | |
| 19 | + "foundingDate": "2014-03-01", | |
| 20 | + "numberOfEmployees": {"@type": "QuantitativeValue", "value": 420}, | |
| 21 | + "telephone": "+1 514-555-0100", | |
| 22 | + "address": { | |
| 23 | + "@type": "PostalAddress", | |
| 24 | + "streetAddress": "1200 Rue Example", | |
| 25 | + "addressLocality": "Montréal", | |
| 26 | + "addressRegion": "Quebec", | |
| 27 | + "postalCode": "H2X 1Y4", | |
| 28 | + "addressCountry": "CA" | |
| 29 | + }, | |
| 30 | + "sameAs": [ | |
| 31 | + "https://www.linkedin.com/company/example-robotics", | |
| 32 | + "https://twitter.com/examplerobotics", | |
| 33 | + "https://github.com/example-robotics", | |
| 34 | + "https://en.wikipedia.org/wiki/Example_Robotics" | |
| 35 | + ] | |
| 36 | + } | |
| 37 | + </script> | |
| 38 | +</head> | |
| 39 | +<body> | |
| 40 | + <header><nav><a href="/">Home</a> <a href="/products">Products</a> <a href="/about">About</a> <a href="/careers">Careers</a></nav></header> | |
| 41 | + <main> | |
| 42 | + <h1>Autonomous mobile robots for the modern warehouse</h1> | |
| 43 | + <p>Example Robotics designs and builds autonomous mobile robots for warehouses and distribution centres. Our fleet software coordinates | |
| 44 | + hundreds of robots per site, integrates with existing warehouse management systems and is deployed with customers in Canada, the | |
| 45 | + United States, Germany and the Netherlands.</p> | |
| 46 | + <p>Founded in Montréal in 2014, the company operates two manufacturing sites and employs 420 people across engineering, operations and | |
| 47 | + customer support. Example Robotics serves retailers, third-party logistics providers and manufacturers that need flexible automation | |
| 48 | + without fixed conveyors.</p> | |
| 49 | + <h2>Products</h2> | |
| 50 | + <ul><li>Carry 200 — goods-to-person robot</li><li>Lift 1000 — pallet mover</li><li>Fleet Manager — orchestration software</li></ul> | |
| 51 | + </main> | |
| 52 | + <footer><p>© Example Robotics Inc. · 1200 Rue Example, Montréal, Quebec H2X 1Y4 · +1 514-555-0100</p></footer> | |
| 53 | +</body> | |
| 54 | +</html> | |
added
fixtures/enrichment/wikidata-Q95.json
+1862 −0
@@ -0,0 +1,1862 @@ | ||
| 1 | +{ | |
| 2 | + "entities": { | |
| 3 | + "Q95": { | |
| 4 | + "type": "item", | |
| 5 | + "id": "Q95", | |
| 6 | + "labels": { | |
| 7 | + "en": { | |
| 8 | + "language": "en", | |
| 9 | + "value": "Google" | |
| 10 | + } | |
| 11 | + }, | |
| 12 | + "descriptions": { | |
| 13 | + "en": { | |
| 14 | + "language": "en", | |
| 15 | + "value": "American multinational technology company, a subsidiary of Alphabet Inc." | |
| 16 | + } | |
| 17 | + }, | |
| 18 | + "claims": { | |
| 19 | + "P1448": [ | |
| 20 | + { | |
| 21 | + "mainsnak": { | |
| 22 | + "snaktype": "value", | |
| 23 | + "property": "P1448", | |
| 24 | + "datavalue": { | |
| 25 | + "value": { | |
| 26 | + "text": "Google LLC", | |
| 27 | + "language": "en" | |
| 28 | + }, | |
| 29 | + "type": "monolingualtext" | |
| 30 | + }, | |
| 31 | + "datatype": "monolingualtext" | |
| 32 | + }, | |
| 33 | + "type": "statement", | |
| 34 | + "qualifiers": { | |
| 35 | + "P580": [ | |
| 36 | + { | |
| 37 | + "snaktype": "value", | |
| 38 | + "property": "P580", | |
| 39 | + "datavalue": { | |
| 40 | + "value": { | |
| 41 | + "time": "+2017-00-00T00:00:00Z", | |
| 42 | + "timezone": 0, | |
| 43 | + "before": 0, | |
| 44 | + "after": 0, | |
| 45 | + "precision": 9, | |
| 46 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 47 | + }, | |
| 48 | + "type": "time" | |
| 49 | + }, | |
| 50 | + "datatype": "time" | |
| 51 | + } | |
| 52 | + ] | |
| 53 | + }, | |
| 54 | + "rank": "preferred" | |
| 55 | + }, | |
| 56 | + { | |
| 57 | + "mainsnak": { | |
| 58 | + "snaktype": "value", | |
| 59 | + "property": "P1448", | |
| 60 | + "datavalue": { | |
| 61 | + "value": { | |
| 62 | + "text": "Google Inc.", | |
| 63 | + "language": "en" | |
| 64 | + }, | |
| 65 | + "type": "monolingualtext" | |
| 66 | + }, | |
| 67 | + "datatype": "monolingualtext" | |
| 68 | + }, | |
| 69 | + "type": "statement", | |
| 70 | + "qualifiers": { | |
| 71 | + "P580": [ | |
| 72 | + { | |
| 73 | + "snaktype": "somevalue", | |
| 74 | + "property": "P580", | |
| 75 | + "datatype": "time" | |
| 76 | + } | |
| 77 | + ], | |
| 78 | + "P582": [ | |
| 79 | + { | |
| 80 | + "snaktype": "value", | |
| 81 | + "property": "P582", | |
| 82 | + "datavalue": { | |
| 83 | + "value": { | |
| 84 | + "time": "+2017-09-01T00:00:00Z", | |
| 85 | + "timezone": 0, | |
| 86 | + "before": 0, | |
| 87 | + "after": 0, | |
| 88 | + "precision": 11, | |
| 89 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 90 | + }, | |
| 91 | + "type": "time" | |
| 92 | + }, | |
| 93 | + "datatype": "time" | |
| 94 | + } | |
| 95 | + ] | |
| 96 | + }, | |
| 97 | + "rank": "normal" | |
| 98 | + } | |
| 99 | + ], | |
| 100 | + "P571": [ | |
| 101 | + { | |
| 102 | + "mainsnak": { | |
| 103 | + "snaktype": "value", | |
| 104 | + "property": "P571", | |
| 105 | + "datavalue": { | |
| 106 | + "value": { | |
| 107 | + "time": "+1998-09-04T00:00:00Z", | |
| 108 | + "timezone": 0, | |
| 109 | + "before": 0, | |
| 110 | + "after": 0, | |
| 111 | + "precision": 11, | |
| 112 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 113 | + }, | |
| 114 | + "type": "time" | |
| 115 | + }, | |
| 116 | + "datatype": "time" | |
| 117 | + }, | |
| 118 | + "type": "statement", | |
| 119 | + "rank": "normal" | |
| 120 | + } | |
| 121 | + ], | |
| 122 | + "P1454": [ | |
| 123 | + { | |
| 124 | + "mainsnak": { | |
| 125 | + "snaktype": "value", | |
| 126 | + "property": "P1454", | |
| 127 | + "datavalue": { | |
| 128 | + "value": { | |
| 129 | + "entity-type": "item", | |
| 130 | + "numeric-id": 57655560, | |
| 131 | + "id": "Q57655560" | |
| 132 | + }, | |
| 133 | + "type": "wikibase-entityid" | |
| 134 | + }, | |
| 135 | + "datatype": "wikibase-item" | |
| 136 | + }, | |
| 137 | + "type": "statement", | |
| 138 | + "qualifiers": { | |
| 139 | + "P582": [ | |
| 140 | + { | |
| 141 | + "snaktype": "value", | |
| 142 | + "property": "P582", | |
| 143 | + "datavalue": { | |
| 144 | + "value": { | |
| 145 | + "time": "+2017-00-00T00:00:00Z", | |
| 146 | + "timezone": 0, | |
| 147 | + "before": 0, | |
| 148 | + "after": 0, | |
| 149 | + "precision": 9, | |
| 150 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 151 | + }, | |
| 152 | + "type": "time" | |
| 153 | + }, | |
| 154 | + "datatype": "time" | |
| 155 | + } | |
| 156 | + ] | |
| 157 | + }, | |
| 158 | + "rank": "normal" | |
| 159 | + }, | |
| 160 | + { | |
| 161 | + "mainsnak": { | |
| 162 | + "snaktype": "value", | |
| 163 | + "property": "P1454", | |
| 164 | + "datavalue": { | |
| 165 | + "value": { | |
| 166 | + "entity-type": "item", | |
| 167 | + "numeric-id": 149789, | |
| 168 | + "id": "Q149789" | |
| 169 | + }, | |
| 170 | + "type": "wikibase-entityid" | |
| 171 | + }, | |
| 172 | + "datatype": "wikibase-item" | |
| 173 | + }, | |
| 174 | + "type": "statement", | |
| 175 | + "qualifiers": { | |
| 176 | + "P580": [ | |
| 177 | + { | |
| 178 | + "snaktype": "value", | |
| 179 | + "property": "P580", | |
| 180 | + "datavalue": { | |
| 181 | + "value": { | |
| 182 | + "time": "+2017-00-00T00:00:00Z", | |
| 183 | + "timezone": 0, | |
| 184 | + "before": 0, | |
| 185 | + "after": 0, | |
| 186 | + "precision": 9, | |
| 187 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 188 | + }, | |
| 189 | + "type": "time" | |
| 190 | + }, | |
| 191 | + "datatype": "time" | |
| 192 | + } | |
| 193 | + ] | |
| 194 | + }, | |
| 195 | + "rank": "preferred" | |
| 196 | + } | |
| 197 | + ], | |
| 198 | + "P159": [ | |
| 199 | + { | |
| 200 | + "mainsnak": { | |
| 201 | + "snaktype": "value", | |
| 202 | + "property": "P159", | |
| 203 | + "datavalue": { | |
| 204 | + "value": { | |
| 205 | + "entity-type": "item", | |
| 206 | + "numeric-id": 486860, | |
| 207 | + "id": "Q486860" | |
| 208 | + }, | |
| 209 | + "type": "wikibase-entityid" | |
| 210 | + }, | |
| 211 | + "datatype": "wikibase-item" | |
| 212 | + }, | |
| 213 | + "type": "statement", | |
| 214 | + "qualifiers": { | |
| 215 | + "P131": [ | |
| 216 | + { | |
| 217 | + "snaktype": "value", | |
| 218 | + "property": "P131", | |
| 219 | + "datavalue": { | |
| 220 | + "value": { | |
| 221 | + "entity-type": "item", | |
| 222 | + "numeric-id": 99, | |
| 223 | + "id": "Q99" | |
| 224 | + }, | |
| 225 | + "type": "wikibase-entityid" | |
| 226 | + }, | |
| 227 | + "datatype": "wikibase-item" | |
| 228 | + } | |
| 229 | + ], | |
| 230 | + "P17": [ | |
| 231 | + { | |
| 232 | + "snaktype": "value", | |
| 233 | + "property": "P17", | |
| 234 | + "datavalue": { | |
| 235 | + "value": { | |
| 236 | + "entity-type": "item", | |
| 237 | + "numeric-id": 30, | |
| 238 | + "id": "Q30" | |
| 239 | + }, | |
| 240 | + "type": "wikibase-entityid" | |
| 241 | + }, | |
| 242 | + "datatype": "wikibase-item" | |
| 243 | + } | |
| 244 | + ], | |
| 245 | + "P625": [ | |
| 246 | + { | |
| 247 | + "snaktype": "value", | |
| 248 | + "property": "P625", | |
| 249 | + "datavalue": { | |
| 250 | + "value": { | |
| 251 | + "latitude": 37.42205555555555, | |
| 252 | + "longitude": -122.08444444444444, | |
| 253 | + "altitude": null, | |
| 254 | + "precision": 2.777777777777778e-05, | |
| 255 | + "globe": "http://www.wikidata.org/entity/Q2" | |
| 256 | + }, | |
| 257 | + "type": "globecoordinate" | |
| 258 | + }, | |
| 259 | + "datatype": "globe-coordinate" | |
| 260 | + } | |
| 261 | + ], | |
| 262 | + "P580": [ | |
| 263 | + { | |
| 264 | + "snaktype": "value", | |
| 265 | + "property": "P580", | |
| 266 | + "datavalue": { | |
| 267 | + "value": { | |
| 268 | + "time": "+2003-00-00T00:00:00Z", | |
| 269 | + "timezone": 0, | |
| 270 | + "before": 0, | |
| 271 | + "after": 0, | |
| 272 | + "precision": 9, | |
| 273 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 274 | + }, | |
| 275 | + "type": "time" | |
| 276 | + }, | |
| 277 | + "datatype": "time" | |
| 278 | + } | |
| 279 | + ] | |
| 280 | + }, | |
| 281 | + "rank": "normal" | |
| 282 | + }, | |
| 283 | + { | |
| 284 | + "mainsnak": { | |
| 285 | + "snaktype": "value", | |
| 286 | + "property": "P159", | |
| 287 | + "datavalue": { | |
| 288 | + "value": { | |
| 289 | + "entity-type": "item", | |
| 290 | + "numeric-id": 694178, | |
| 291 | + "id": "Q694178" | |
| 292 | + }, | |
| 293 | + "type": "wikibase-entityid" | |
| 294 | + }, | |
| 295 | + "datatype": "wikibase-item" | |
| 296 | + }, | |
| 297 | + "type": "statement", | |
| 298 | + "rank": "normal" | |
| 299 | + } | |
| 300 | + ], | |
| 301 | + "P17": [ | |
| 302 | + { | |
| 303 | + "mainsnak": { | |
| 304 | + "snaktype": "value", | |
| 305 | + "property": "P17", | |
| 306 | + "datavalue": { | |
| 307 | + "value": { | |
| 308 | + "entity-type": "item", | |
| 309 | + "numeric-id": 30, | |
| 310 | + "id": "Q30" | |
| 311 | + }, | |
| 312 | + "type": "wikibase-entityid" | |
| 313 | + }, | |
| 314 | + "datatype": "wikibase-item" | |
| 315 | + }, | |
| 316 | + "type": "statement", | |
| 317 | + "rank": "normal" | |
| 318 | + } | |
| 319 | + ], | |
| 320 | + "P1128": [ | |
| 321 | + { | |
| 322 | + "mainsnak": { | |
| 323 | + "snaktype": "value", | |
| 324 | + "property": "P1128", | |
| 325 | + "datavalue": { | |
| 326 | + "value": { | |
| 327 | + "amount": "+47756", | |
| 328 | + "unit": "1" | |
| 329 | + }, | |
| 330 | + "type": "quantity" | |
| 331 | + }, | |
| 332 | + "datatype": "quantity" | |
| 333 | + }, | |
| 334 | + "type": "statement", | |
| 335 | + "qualifiers": { | |
| 336 | + "P585": [ | |
| 337 | + { | |
| 338 | + "snaktype": "value", | |
| 339 | + "property": "P585", | |
| 340 | + "datavalue": { | |
| 341 | + "value": { | |
| 342 | + "time": "+2013-01-01T00:00:00Z", | |
| 343 | + "timezone": 0, | |
| 344 | + "before": 0, | |
| 345 | + "after": 0, | |
| 346 | + "precision": 9, | |
| 347 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 348 | + }, | |
| 349 | + "type": "time" | |
| 350 | + }, | |
| 351 | + "datatype": "time" | |
| 352 | + } | |
| 353 | + ] | |
| 354 | + }, | |
| 355 | + "rank": "normal" | |
| 356 | + }, | |
| 357 | + { | |
| 358 | + "mainsnak": { | |
| 359 | + "snaktype": "value", | |
| 360 | + "property": "P1128", | |
| 361 | + "datavalue": { | |
| 362 | + "value": { | |
| 363 | + "amount": "+53861", | |
| 364 | + "unit": "1" | |
| 365 | + }, | |
| 366 | + "type": "quantity" | |
| 367 | + }, | |
| 368 | + "datatype": "quantity" | |
| 369 | + }, | |
| 370 | + "type": "statement", | |
| 371 | + "qualifiers": { | |
| 372 | + "P585": [ | |
| 373 | + { | |
| 374 | + "snaktype": "value", | |
| 375 | + "property": "P585", | |
| 376 | + "datavalue": { | |
| 377 | + "value": { | |
| 378 | + "time": "+2012-01-01T00:00:00Z", | |
| 379 | + "timezone": 0, | |
| 380 | + "before": 0, | |
| 381 | + "after": 0, | |
| 382 | + "precision": 9, | |
| 383 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 384 | + }, | |
| 385 | + "type": "time" | |
| 386 | + }, | |
| 387 | + "datatype": "time" | |
| 388 | + } | |
| 389 | + ] | |
| 390 | + }, | |
| 391 | + "rank": "normal" | |
| 392 | + }, | |
| 393 | + { | |
| 394 | + "mainsnak": { | |
| 395 | + "snaktype": "value", | |
| 396 | + "property": "P1128", | |
| 397 | + "datavalue": { | |
| 398 | + "value": { | |
| 399 | + "amount": "+53600", | |
| 400 | + "unit": "1" | |
| 401 | + }, | |
| 402 | + "type": "quantity" | |
| 403 | + }, | |
| 404 | + "datatype": "quantity" | |
| 405 | + }, | |
| 406 | + "type": "statement", | |
| 407 | + "qualifiers": { | |
| 408 | + "P585": [ | |
| 409 | + { | |
| 410 | + "snaktype": "value", | |
| 411 | + "property": "P585", | |
| 412 | + "datavalue": { | |
| 413 | + "value": { | |
| 414 | + "time": "+2014-00-00T00:00:00Z", | |
| 415 | + "timezone": 0, | |
| 416 | + "before": 0, | |
| 417 | + "after": 0, | |
| 418 | + "precision": 9, | |
| 419 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 420 | + }, | |
| 421 | + "type": "time" | |
| 422 | + }, | |
| 423 | + "datatype": "time" | |
| 424 | + } | |
| 425 | + ] | |
| 426 | + }, | |
| 427 | + "rank": "normal" | |
| 428 | + }, | |
| 429 | + { | |
| 430 | + "mainsnak": { | |
| 431 | + "snaktype": "value", | |
| 432 | + "property": "P1128", | |
| 433 | + "datavalue": { | |
| 434 | + "value": { | |
| 435 | + "amount": "+187000", | |
| 436 | + "unit": "1" | |
| 437 | + }, | |
| 438 | + "type": "quantity" | |
| 439 | + }, | |
| 440 | + "datatype": "quantity" | |
| 441 | + }, | |
| 442 | + "type": "statement", | |
| 443 | + "qualifiers": { | |
| 444 | + "P585": [ | |
| 445 | + { | |
| 446 | + "snaktype": "value", | |
| 447 | + "property": "P585", | |
| 448 | + "datavalue": { | |
| 449 | + "value": { | |
| 450 | + "time": "+2022-00-00T00:00:00Z", | |
| 451 | + "timezone": 0, | |
| 452 | + "before": 0, | |
| 453 | + "after": 0, | |
| 454 | + "precision": 9, | |
| 455 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 456 | + }, | |
| 457 | + "type": "time" | |
| 458 | + }, | |
| 459 | + "datatype": "time" | |
| 460 | + } | |
| 461 | + ] | |
| 462 | + }, | |
| 463 | + "rank": "preferred" | |
| 464 | + } | |
| 465 | + ], | |
| 466 | + "P452": [ | |
| 467 | + { | |
| 468 | + "mainsnak": { | |
| 469 | + "snaktype": "value", | |
| 470 | + "property": "P452", | |
| 471 | + "datavalue": { | |
| 472 | + "value": { | |
| 473 | + "entity-type": "item", | |
| 474 | + "numeric-id": 56611700, | |
| 475 | + "id": "Q56611700" | |
| 476 | + }, | |
| 477 | + "type": "wikibase-entityid" | |
| 478 | + }, | |
| 479 | + "datatype": "wikibase-item" | |
| 480 | + }, | |
| 481 | + "type": "statement", | |
| 482 | + "rank": "normal" | |
| 483 | + }, | |
| 484 | + { | |
| 485 | + "mainsnak": { | |
| 486 | + "snaktype": "value", | |
| 487 | + "property": "P452", | |
| 488 | + "datavalue": { | |
| 489 | + "value": { | |
| 490 | + "entity-type": "item", | |
| 491 | + "numeric-id": 880371, | |
| 492 | + "id": "Q880371" | |
| 493 | + }, | |
| 494 | + "type": "wikibase-entityid" | |
| 495 | + }, | |
| 496 | + "datatype": "wikibase-item" | |
| 497 | + }, | |
| 498 | + "type": "statement", | |
| 499 | + "rank": "normal" | |
| 500 | + }, | |
| 501 | + { | |
| 502 | + "mainsnak": { | |
| 503 | + "snaktype": "value", | |
| 504 | + "property": "P452", | |
| 505 | + "datavalue": { | |
| 506 | + "value": { | |
| 507 | + "entity-type": "item", | |
| 508 | + "numeric-id": 189507, | |
| 509 | + "id": "Q189507" | |
| 510 | + }, | |
| 511 | + "type": "wikibase-entityid" | |
| 512 | + }, | |
| 513 | + "datatype": "wikibase-item" | |
| 514 | + }, | |
| 515 | + "type": "statement", | |
| 516 | + "rank": "normal" | |
| 517 | + }, | |
| 518 | + { | |
| 519 | + "mainsnak": { | |
| 520 | + "snaktype": "value", | |
| 521 | + "property": "P452", | |
| 522 | + "datavalue": { | |
| 523 | + "value": { | |
| 524 | + "entity-type": "item", | |
| 525 | + "numeric-id": 11661, | |
| 526 | + "id": "Q11661" | |
| 527 | + }, | |
| 528 | + "type": "wikibase-entityid" | |
| 529 | + }, | |
| 530 | + "datatype": "wikibase-item" | |
| 531 | + }, | |
| 532 | + "type": "statement", | |
| 533 | + "rank": "normal" | |
| 534 | + } | |
| 535 | + ], | |
| 536 | + "P1056": [ | |
| 537 | + { | |
| 538 | + "mainsnak": { | |
| 539 | + "snaktype": "value", | |
| 540 | + "property": "P1056", | |
| 541 | + "datavalue": { | |
| 542 | + "value": { | |
| 543 | + "entity-type": "item", | |
| 544 | + "numeric-id": 9366, | |
| 545 | + "id": "Q9366" | |
| 546 | + }, | |
| 547 | + "type": "wikibase-entityid" | |
| 548 | + }, | |
| 549 | + "datatype": "wikibase-item" | |
| 550 | + }, | |
| 551 | + "type": "statement", | |
| 552 | + "rank": "normal" | |
| 553 | + }, | |
| 554 | + { | |
| 555 | + "mainsnak": { | |
| 556 | + "snaktype": "value", | |
| 557 | + "property": "P1056", | |
| 558 | + "datavalue": { | |
| 559 | + "value": { | |
| 560 | + "entity-type": "item", | |
| 561 | + "numeric-id": 111897729, | |
| 562 | + "id": "Q111897729" | |
| 563 | + }, | |
| 564 | + "type": "wikibase-entityid" | |
| 565 | + }, | |
| 566 | + "datatype": "wikibase-item" | |
| 567 | + }, | |
| 568 | + "type": "statement", | |
| 569 | + "rank": "normal" | |
| 570 | + }, | |
| 571 | + { | |
| 572 | + "mainsnak": { | |
| 573 | + "snaktype": "value", | |
| 574 | + "property": "P1056", | |
| 575 | + "datavalue": { | |
| 576 | + "value": { | |
| 577 | + "entity-type": "item", | |
| 578 | + "numeric-id": 51712, | |
| 579 | + "id": "Q51712" | |
| 580 | + }, | |
| 581 | + "type": "wikibase-entityid" | |
| 582 | + }, | |
| 583 | + "datatype": "wikibase-item" | |
| 584 | + }, | |
| 585 | + "type": "statement", | |
| 586 | + "rank": "normal" | |
| 587 | + }, | |
| 588 | + { | |
| 589 | + "mainsnak": { | |
| 590 | + "snaktype": "value", | |
| 591 | + "property": "P1056", | |
| 592 | + "datavalue": { | |
| 593 | + "value": { | |
| 594 | + "entity-type": "item", | |
| 595 | + "numeric-id": 271982, | |
| 596 | + "id": "Q271982" | |
| 597 | + }, | |
| 598 | + "type": "wikibase-entityid" | |
| 599 | + }, | |
| 600 | + "datatype": "wikibase-item" | |
| 601 | + }, | |
| 602 | + "type": "statement", | |
| 603 | + "rank": "normal" | |
| 604 | + }, | |
| 605 | + { | |
| 606 | + "mainsnak": { | |
| 607 | + "snaktype": "value", | |
| 608 | + "property": "P1056", | |
| 609 | + "datavalue": { | |
| 610 | + "value": { | |
| 611 | + "entity-type": "item", | |
| 612 | + "numeric-id": 19834616, | |
| 613 | + "id": "Q19834616" | |
| 614 | + }, | |
| 615 | + "type": "wikibase-entityid" | |
| 616 | + }, | |
| 617 | + "datatype": "wikibase-item" | |
| 618 | + }, | |
| 619 | + "type": "statement", | |
| 620 | + "rank": "normal" | |
| 621 | + } | |
| 622 | + ], | |
| 623 | + "P414": [ | |
| 624 | + { | |
| 625 | + "mainsnak": { | |
| 626 | + "snaktype": "value", | |
| 627 | + "property": "P414", | |
| 628 | + "datavalue": { | |
| 629 | + "value": { | |
| 630 | + "entity-type": "item", | |
| 631 | + "numeric-id": 82059, | |
| 632 | + "id": "Q82059" | |
| 633 | + }, | |
| 634 | + "type": "wikibase-entityid" | |
| 635 | + }, | |
| 636 | + "datatype": "wikibase-item" | |
| 637 | + }, | |
| 638 | + "type": "statement", | |
| 639 | + "qualifiers": { | |
| 640 | + "P249": [ | |
| 641 | + { | |
| 642 | + "snaktype": "value", | |
| 643 | + "property": "P249", | |
| 644 | + "datavalue": { | |
| 645 | + "value": "GOOG", | |
| 646 | + "type": "string" | |
| 647 | + }, | |
| 648 | + "datatype": "string" | |
| 649 | + } | |
| 650 | + ], | |
| 651 | + "P580": [ | |
| 652 | + { | |
| 653 | + "snaktype": "value", | |
| 654 | + "property": "P580", | |
| 655 | + "datavalue": { | |
| 656 | + "value": { | |
| 657 | + "time": "+2004-08-19T00:00:00Z", | |
| 658 | + "timezone": 0, | |
| 659 | + "before": 0, | |
| 660 | + "after": 0, | |
| 661 | + "precision": 11, | |
| 662 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 663 | + }, | |
| 664 | + "type": "time" | |
| 665 | + }, | |
| 666 | + "datatype": "time" | |
| 667 | + } | |
| 668 | + ], | |
| 669 | + "P582": [ | |
| 670 | + { | |
| 671 | + "snaktype": "value", | |
| 672 | + "property": "P582", | |
| 673 | + "datavalue": { | |
| 674 | + "value": { | |
| 675 | + "time": "+2016-00-00T00:00:00Z", | |
| 676 | + "timezone": 0, | |
| 677 | + "before": 0, | |
| 678 | + "after": 0, | |
| 679 | + "precision": 9, | |
| 680 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 681 | + }, | |
| 682 | + "type": "time" | |
| 683 | + }, | |
| 684 | + "datatype": "time" | |
| 685 | + } | |
| 686 | + ] | |
| 687 | + }, | |
| 688 | + "rank": "normal" | |
| 689 | + }, | |
| 690 | + { | |
| 691 | + "mainsnak": { | |
| 692 | + "snaktype": "value", | |
| 693 | + "property": "P414", | |
| 694 | + "datavalue": { | |
| 695 | + "value": { | |
| 696 | + "entity-type": "item", | |
| 697 | + "numeric-id": 82059, | |
| 698 | + "id": "Q82059" | |
| 699 | + }, | |
| 700 | + "type": "wikibase-entityid" | |
| 701 | + }, | |
| 702 | + "datatype": "wikibase-item" | |
| 703 | + }, | |
| 704 | + "type": "statement", | |
| 705 | + "qualifiers": { | |
| 706 | + "P249": [ | |
| 707 | + { | |
| 708 | + "snaktype": "value", | |
| 709 | + "property": "P249", | |
| 710 | + "datavalue": { | |
| 711 | + "value": "GOOGL", | |
| 712 | + "type": "string" | |
| 713 | + }, | |
| 714 | + "datatype": "string" | |
| 715 | + } | |
| 716 | + ], | |
| 717 | + "P580": [ | |
| 718 | + { | |
| 719 | + "snaktype": "value", | |
| 720 | + "property": "P580", | |
| 721 | + "datavalue": { | |
| 722 | + "value": { | |
| 723 | + "time": "+2014-04-00T00:00:00Z", | |
| 724 | + "timezone": 0, | |
| 725 | + "before": 0, | |
| 726 | + "after": 0, | |
| 727 | + "precision": 10, | |
| 728 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 729 | + }, | |
| 730 | + "type": "time" | |
| 731 | + }, | |
| 732 | + "datatype": "time" | |
| 733 | + } | |
| 734 | + ], | |
| 735 | + "P582": [ | |
| 736 | + { | |
| 737 | + "snaktype": "value", | |
| 738 | + "property": "P582", | |
| 739 | + "datavalue": { | |
| 740 | + "value": { | |
| 741 | + "time": "+2016-00-00T00:00:00Z", | |
| 742 | + "timezone": 0, | |
| 743 | + "before": 0, | |
| 744 | + "after": 0, | |
| 745 | + "precision": 9, | |
| 746 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 747 | + }, | |
| 748 | + "type": "time" | |
| 749 | + }, | |
| 750 | + "datatype": "time" | |
| 751 | + } | |
| 752 | + ] | |
| 753 | + }, | |
| 754 | + "rank": "normal" | |
| 755 | + } | |
| 756 | + ], | |
| 757 | + "P1278": [ | |
| 758 | + { | |
| 759 | + "mainsnak": { | |
| 760 | + "snaktype": "value", | |
| 761 | + "property": "P1278", | |
| 762 | + "datavalue": { | |
| 763 | + "value": "7ZW8QJWVPR4P1J1KQY45", | |
| 764 | + "type": "string" | |
| 765 | + }, | |
| 766 | + "datatype": "external-id" | |
| 767 | + }, | |
| 768 | + "type": "statement", | |
| 769 | + "rank": "normal" | |
| 770 | + } | |
| 771 | + ], | |
| 772 | + "P5531": [ | |
| 773 | + { | |
| 774 | + "mainsnak": { | |
| 775 | + "snaktype": "value", | |
| 776 | + "property": "P5531", | |
| 777 | + "datavalue": { | |
| 778 | + "value": "0001824723", | |
| 779 | + "type": "string" | |
| 780 | + }, | |
| 781 | + "datatype": "external-id" | |
| 782 | + }, | |
| 783 | + "type": "statement", | |
| 784 | + "rank": "normal" | |
| 785 | + }, | |
| 786 | + { | |
| 787 | + "mainsnak": { | |
| 788 | + "snaktype": "value", | |
| 789 | + "property": "P5531", | |
| 790 | + "datavalue": { | |
| 791 | + "value": "0001288776", | |
| 792 | + "type": "string" | |
| 793 | + }, | |
| 794 | + "datatype": "external-id" | |
| 795 | + }, | |
| 796 | + "type": "statement", | |
| 797 | + "rank": "normal" | |
| 798 | + } | |
| 799 | + ], | |
| 800 | + "P856": [ | |
| 801 | + { | |
| 802 | + "mainsnak": { | |
| 803 | + "snaktype": "value", | |
| 804 | + "property": "P856", | |
| 805 | + "datavalue": { | |
| 806 | + "value": "https://about.google/", | |
| 807 | + "type": "string" | |
| 808 | + }, | |
| 809 | + "datatype": "url" | |
| 810 | + }, | |
| 811 | + "type": "statement", | |
| 812 | + "qualifiers": { | |
| 813 | + "P407": [ | |
| 814 | + { | |
| 815 | + "snaktype": "value", | |
| 816 | + "property": "P407", | |
| 817 | + "datavalue": { | |
| 818 | + "value": { | |
| 819 | + "entity-type": "item", | |
| 820 | + "numeric-id": 20923490, | |
| 821 | + "id": "Q20923490" | |
| 822 | + }, | |
| 823 | + "type": "wikibase-entityid" | |
| 824 | + }, | |
| 825 | + "datatype": "wikibase-item" | |
| 826 | + } | |
| 827 | + ] | |
| 828 | + }, | |
| 829 | + "rank": "deprecated" | |
| 830 | + }, | |
| 831 | + { | |
| 832 | + "mainsnak": { | |
| 833 | + "snaktype": "value", | |
| 834 | + "property": "P856", | |
| 835 | + "datavalue": { | |
| 836 | + "value": "https://about.google/", | |
| 837 | + "type": "string" | |
| 838 | + }, | |
| 839 | + "datatype": "url" | |
| 840 | + }, | |
| 841 | + "type": "statement", | |
| 842 | + "rank": "preferred" | |
| 843 | + } | |
| 844 | + ], | |
| 845 | + "P154": [ | |
| 846 | + { | |
| 847 | + "mainsnak": { | |
| 848 | + "snaktype": "value", | |
| 849 | + "property": "P154", | |
| 850 | + "datavalue": { | |
| 851 | + "value": "Google 2026 logo.svg", | |
| 852 | + "type": "string" | |
| 853 | + }, | |
| 854 | + "datatype": "commonsMedia" | |
| 855 | + }, | |
| 856 | + "type": "statement", | |
| 857 | + "qualifiers": { | |
| 858 | + "P580": [ | |
| 859 | + { | |
| 860 | + "snaktype": "value", | |
| 861 | + "property": "P580", | |
| 862 | + "datavalue": { | |
| 863 | + "value": { | |
| 864 | + "time": "+2026-05-19T00:00:00Z", | |
| 865 | + "timezone": 0, | |
| 866 | + "before": 0, | |
| 867 | + "after": 0, | |
| 868 | + "precision": 11, | |
| 869 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 870 | + }, | |
| 871 | + "type": "time" | |
| 872 | + }, | |
| 873 | + "datatype": "time" | |
| 874 | + } | |
| 875 | + ] | |
| 876 | + }, | |
| 877 | + "rank": "preferred" | |
| 878 | + }, | |
| 879 | + { | |
| 880 | + "mainsnak": { | |
| 881 | + "snaktype": "value", | |
| 882 | + "property": "P154", | |
| 883 | + "datavalue": { | |
| 884 | + "value": "Google 2015 logo.svg", | |
| 885 | + "type": "string" | |
| 886 | + }, | |
| 887 | + "datatype": "commonsMedia" | |
| 888 | + }, | |
| 889 | + "type": "statement", | |
| 890 | + "qualifiers": { | |
| 891 | + "P580": [ | |
| 892 | + { | |
| 893 | + "snaktype": "value", | |
| 894 | + "property": "P580", | |
| 895 | + "datavalue": { | |
| 896 | + "value": { | |
| 897 | + "time": "+2015-09-01T00:00:00Z", | |
| 898 | + "timezone": 0, | |
| 899 | + "before": 0, | |
| 900 | + "after": 0, | |
| 901 | + "precision": 11, | |
| 902 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 903 | + }, | |
| 904 | + "type": "time" | |
| 905 | + }, | |
| 906 | + "datatype": "time" | |
| 907 | + } | |
| 908 | + ], | |
| 909 | + "P582": [ | |
| 910 | + { | |
| 911 | + "snaktype": "value", | |
| 912 | + "property": "P582", | |
| 913 | + "datavalue": { | |
| 914 | + "value": { | |
| 915 | + "time": "+2026-05-19T00:00:00Z", | |
| 916 | + "timezone": 0, | |
| 917 | + "before": 0, | |
| 918 | + "after": 0, | |
| 919 | + "precision": 11, | |
| 920 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 921 | + }, | |
| 922 | + "type": "time" | |
| 923 | + }, | |
| 924 | + "datatype": "time" | |
| 925 | + } | |
| 926 | + ] | |
| 927 | + }, | |
| 928 | + "rank": "normal" | |
| 929 | + } | |
| 930 | + ], | |
| 931 | + "P4264": [ | |
| 932 | + { | |
| 933 | + "mainsnak": { | |
| 934 | + "snaktype": "value", | |
| 935 | + "property": "P4264", | |
| 936 | + "datavalue": { | |
| 937 | + "value": "google", | |
| 938 | + "type": "string" | |
| 939 | + }, | |
| 940 | + "datatype": "external-id" | |
| 941 | + }, | |
| 942 | + "type": "statement", | |
| 943 | + "rank": "normal" | |
| 944 | + } | |
| 945 | + ], | |
| 946 | + "P2002": [ | |
| 947 | + { | |
| 948 | + "mainsnak": { | |
| 949 | + "snaktype": "value", | |
| 950 | + "property": "P2002", | |
| 951 | + "datavalue": { | |
| 952 | + "value": "Google", | |
| 953 | + "type": "string" | |
| 954 | + }, | |
| 955 | + "datatype": "external-id" | |
| 956 | + }, | |
| 957 | + "type": "statement", | |
| 958 | + "qualifiers": { | |
| 959 | + "P585": [ | |
| 960 | + { | |
| 961 | + "snaktype": "value", | |
| 962 | + "property": "P585", | |
| 963 | + "datavalue": { | |
| 964 | + "value": { | |
| 965 | + "time": "+2022-08-08T00:00:00Z", | |
| 966 | + "timezone": 0, | |
| 967 | + "before": 0, | |
| 968 | + "after": 0, | |
| 969 | + "precision": 11, | |
| 970 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 971 | + }, | |
| 972 | + "type": "time" | |
| 973 | + }, | |
| 974 | + "datatype": "time" | |
| 975 | + } | |
| 976 | + ], | |
| 977 | + "P580": [ | |
| 978 | + { | |
| 979 | + "snaktype": "value", | |
| 980 | + "property": "P580", | |
| 981 | + "datavalue": { | |
| 982 | + "value": { | |
| 983 | + "time": "+2009-02-10T00:00:00Z", | |
| 984 | + "timezone": 0, | |
| 985 | + "before": 0, | |
| 986 | + "after": 0, | |
| 987 | + "precision": 11, | |
| 988 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 989 | + }, | |
| 990 | + "type": "time" | |
| 991 | + }, | |
| 992 | + "datatype": "time" | |
| 993 | + } | |
| 994 | + ] | |
| 995 | + }, | |
| 996 | + "rank": "normal" | |
| 997 | + }, | |
| 998 | + { | |
| 999 | + "mainsnak": { | |
| 1000 | + "snaktype": "value", | |
| 1001 | + "property": "P2002", | |
| 1002 | + "datavalue": { | |
| 1003 | + "value": "madebygoogle", | |
| 1004 | + "type": "string" | |
| 1005 | + }, | |
| 1006 | + "datatype": "external-id" | |
| 1007 | + }, | |
| 1008 | + "type": "statement", | |
| 1009 | + "qualifiers": { | |
| 1010 | + "P585": [ | |
| 1011 | + { | |
| 1012 | + "snaktype": "value", | |
| 1013 | + "property": "P585", | |
| 1014 | + "datavalue": { | |
| 1015 | + "value": { | |
| 1016 | + "time": "+2020-02-27T00:00:00Z", | |
| 1017 | + "timezone": 0, | |
| 1018 | + "before": 0, | |
| 1019 | + "after": 0, | |
| 1020 | + "precision": 11, | |
| 1021 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 1022 | + }, | |
| 1023 | + "type": "time" | |
| 1024 | + }, | |
| 1025 | + "datatype": "time" | |
| 1026 | + } | |
| 1027 | + ], | |
| 1028 | + "P580": [ | |
| 1029 | + { | |
| 1030 | + "snaktype": "value", | |
| 1031 | + "property": "P580", | |
| 1032 | + "datavalue": { | |
| 1033 | + "value": { | |
| 1034 | + "time": "+2016-09-14T00:00:00Z", | |
| 1035 | + "timezone": 0, | |
| 1036 | + "before": 0, | |
| 1037 | + "after": 0, | |
| 1038 | + "precision": 11, | |
| 1039 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 1040 | + }, | |
| 1041 | + "type": "time" | |
| 1042 | + }, | |
| 1043 | + "datatype": "time" | |
| 1044 | + } | |
| 1045 | + ] | |
| 1046 | + }, | |
| 1047 | + "rank": "normal" | |
| 1048 | + } | |
| 1049 | + ], | |
| 1050 | + "P2397": [ | |
| 1051 | + { | |
| 1052 | + "mainsnak": { | |
| 1053 | + "snaktype": "value", | |
| 1054 | + "property": "P2397", | |
| 1055 | + "datavalue": { | |
| 1056 | + "value": "UCK8sQmJBp8GCxrOtXWBpyEA", | |
| 1057 | + "type": "string" | |
| 1058 | + }, | |
| 1059 | + "datatype": "external-id" | |
| 1060 | + }, | |
| 1061 | + "type": "statement", | |
| 1062 | + "qualifiers": { | |
| 1063 | + "P1810": [ | |
| 1064 | + { | |
| 1065 | + "snaktype": "value", | |
| 1066 | + "property": "P1810", | |
| 1067 | + "datavalue": { | |
| 1068 | + "value": "Google", | |
| 1069 | + "type": "string" | |
| 1070 | + }, | |
| 1071 | + "datatype": "string" | |
| 1072 | + } | |
| 1073 | + ], | |
| 1074 | + "P580": [ | |
| 1075 | + { | |
| 1076 | + "snaktype": "value", | |
| 1077 | + "property": "P580", | |
| 1078 | + "datavalue": { | |
| 1079 | + "value": { | |
| 1080 | + "time": "+2005-09-18T00:00:00Z", | |
| 1081 | + "timezone": 0, | |
| 1082 | + "before": 0, | |
| 1083 | + "after": 0, | |
| 1084 | + "precision": 11, | |
| 1085 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 1086 | + }, | |
| 1087 | + "type": "time" | |
| 1088 | + }, | |
| 1089 | + "datatype": "time" | |
| 1090 | + } | |
| 1091 | + ], | |
| 1092 | + "P585": [ | |
| 1093 | + { | |
| 1094 | + "snaktype": "value", | |
| 1095 | + "property": "P585", | |
| 1096 | + "datavalue": { | |
| 1097 | + "value": { | |
| 1098 | + "time": "+2024-07-14T00:00:00Z", | |
| 1099 | + "timezone": 0, | |
| 1100 | + "before": 0, | |
| 1101 | + "after": 0, | |
| 1102 | + "precision": 11, | |
| 1103 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 1104 | + }, | |
| 1105 | + "type": "time" | |
| 1106 | + }, | |
| 1107 | + "datatype": "time" | |
| 1108 | + } | |
| 1109 | + ] | |
| 1110 | + }, | |
| 1111 | + "rank": "normal" | |
| 1112 | + } | |
| 1113 | + ], | |
| 1114 | + "P2013": [ | |
| 1115 | + { | |
| 1116 | + "mainsnak": { | |
| 1117 | + "snaktype": "value", | |
| 1118 | + "property": "P2013", | |
| 1119 | + "datavalue": { | |
| 1120 | + "value": "google", | |
| 1121 | + "type": "string" | |
| 1122 | + }, | |
| 1123 | + "datatype": "external-id" | |
| 1124 | + }, | |
| 1125 | + "type": "statement", | |
| 1126 | + "rank": "normal" | |
| 1127 | + } | |
| 1128 | + ], | |
| 1129 | + "P2003": [ | |
| 1130 | + { | |
| 1131 | + "mainsnak": { | |
| 1132 | + "snaktype": "value", | |
| 1133 | + "property": "P2003", | |
| 1134 | + "datavalue": { | |
| 1135 | + "value": "google", | |
| 1136 | + "type": "string" | |
| 1137 | + }, | |
| 1138 | + "datatype": "external-id" | |
| 1139 | + }, | |
| 1140 | + "type": "statement", | |
| 1141 | + "rank": "normal" | |
| 1142 | + } | |
| 1143 | + ], | |
| 1144 | + "P2037": [ | |
| 1145 | + { | |
| 1146 | + "mainsnak": { | |
| 1147 | + "snaktype": "value", | |
| 1148 | + "property": "P2037", | |
| 1149 | + "datavalue": { | |
| 1150 | + "value": "google", | |
| 1151 | + "type": "string" | |
| 1152 | + }, | |
| 1153 | + "datatype": "external-id" | |
| 1154 | + }, | |
| 1155 | + "type": "statement", | |
| 1156 | + "qualifiers": { | |
| 1157 | + "P1810": [ | |
| 1158 | + { | |
| 1159 | + "snaktype": "value", | |
| 1160 | + "property": "P1810", | |
| 1161 | + "datavalue": { | |
| 1162 | + "value": "Google", | |
| 1163 | + "type": "string" | |
| 1164 | + }, | |
| 1165 | + "datatype": "string" | |
| 1166 | + } | |
| 1167 | + ] | |
| 1168 | + }, | |
| 1169 | + "rank": "preferred" | |
| 1170 | + } | |
| 1171 | + ], | |
| 1172 | + "P7085": [ | |
| 1173 | + { | |
| 1174 | + "mainsnak": { | |
| 1175 | + "snaktype": "value", | |
| 1176 | + "property": "P7085", | |
| 1177 | + "datavalue": { | |
| 1178 | + "value": "google", | |
| 1179 | + "type": "string" | |
| 1180 | + }, | |
| 1181 | + "datatype": "external-id" | |
| 1182 | + }, | |
| 1183 | + "type": "statement", | |
| 1184 | + "rank": "normal" | |
| 1185 | + } | |
| 1186 | + ], | |
| 1187 | + "P2088": [ | |
| 1188 | + { | |
| 1189 | + "mainsnak": { | |
| 1190 | + "snaktype": "value", | |
| 1191 | + "property": "P2088", | |
| 1192 | + "datavalue": { | |
| 1193 | + "value": "google", | |
| 1194 | + "type": "string" | |
| 1195 | + }, | |
| 1196 | + "datatype": "external-id" | |
| 1197 | + }, | |
| 1198 | + "type": "statement", | |
| 1199 | + "rank": "normal" | |
| 1200 | + } | |
| 1201 | + ], | |
| 1202 | + "P169": [ | |
| 1203 | + { | |
| 1204 | + "mainsnak": { | |
| 1205 | + "snaktype": "value", | |
| 1206 | + "property": "P169", | |
| 1207 | + "datavalue": { | |
| 1208 | + "value": { | |
| 1209 | + "entity-type": "item", | |
| 1210 | + "numeric-id": 4934, | |
| 1211 | + "id": "Q4934" | |
| 1212 | + }, | |
| 1213 | + "type": "wikibase-entityid" | |
| 1214 | + }, | |
| 1215 | + "datatype": "wikibase-item" | |
| 1216 | + }, | |
| 1217 | + "type": "statement", | |
| 1218 | + "qualifiers": { | |
| 1219 | + "P580": [ | |
| 1220 | + { | |
| 1221 | + "snaktype": "value", | |
| 1222 | + "property": "P580", | |
| 1223 | + "datavalue": { | |
| 1224 | + "value": { | |
| 1225 | + "time": "+1998-00-00T00:00:00Z", | |
| 1226 | + "timezone": 0, | |
| 1227 | + "before": 0, | |
| 1228 | + "after": 0, | |
| 1229 | + "precision": 9, | |
| 1230 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 1231 | + }, | |
| 1232 | + "type": "time" | |
| 1233 | + }, | |
| 1234 | + "datatype": "time" | |
| 1235 | + } | |
| 1236 | + ], | |
| 1237 | + "P582": [ | |
| 1238 | + { | |
| 1239 | + "snaktype": "value", | |
| 1240 | + "property": "P582", | |
| 1241 | + "datavalue": { | |
| 1242 | + "value": { | |
| 1243 | + "time": "+2001-00-00T00:00:00Z", | |
| 1244 | + "timezone": 0, | |
| 1245 | + "before": 0, | |
| 1246 | + "after": 0, | |
| 1247 | + "precision": 9, | |
| 1248 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 1249 | + }, | |
| 1250 | + "type": "time" | |
| 1251 | + }, | |
| 1252 | + "datatype": "time" | |
| 1253 | + } | |
| 1254 | + ] | |
| 1255 | + }, | |
| 1256 | + "rank": "normal" | |
| 1257 | + }, | |
| 1258 | + { | |
| 1259 | + "mainsnak": { | |
| 1260 | + "snaktype": "value", | |
| 1261 | + "property": "P169", | |
| 1262 | + "datavalue": { | |
| 1263 | + "value": { | |
| 1264 | + "entity-type": "item", | |
| 1265 | + "numeric-id": 92747, | |
| 1266 | + "id": "Q92747" | |
| 1267 | + }, | |
| 1268 | + "type": "wikibase-entityid" | |
| 1269 | + }, | |
| 1270 | + "datatype": "wikibase-item" | |
| 1271 | + }, | |
| 1272 | + "type": "statement", | |
| 1273 | + "qualifiers": { | |
| 1274 | + "P582": [ | |
| 1275 | + { | |
| 1276 | + "snaktype": "value", | |
| 1277 | + "property": "P582", | |
| 1278 | + "datavalue": { | |
| 1279 | + "value": { | |
| 1280 | + "time": "+2011-04-08T00:00:00Z", | |
| 1281 | + "timezone": 0, | |
| 1282 | + "before": 0, | |
| 1283 | + "after": 0, | |
| 1284 | + "precision": 11, | |
| 1285 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 1286 | + }, | |
| 1287 | + "type": "time" | |
| 1288 | + }, | |
| 1289 | + "datatype": "time" | |
| 1290 | + } | |
| 1291 | + ], | |
| 1292 | + "P580": [ | |
| 1293 | + { | |
| 1294 | + "snaktype": "value", | |
| 1295 | + "property": "P580", | |
| 1296 | + "datavalue": { | |
| 1297 | + "value": { | |
| 1298 | + "time": "+2001-08-00T00:00:00Z", | |
| 1299 | + "timezone": 0, | |
| 1300 | + "before": 0, | |
| 1301 | + "after": 0, | |
| 1302 | + "precision": 10, | |
| 1303 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 1304 | + }, | |
| 1305 | + "type": "time" | |
| 1306 | + }, | |
| 1307 | + "datatype": "time" | |
| 1308 | + } | |
| 1309 | + ] | |
| 1310 | + }, | |
| 1311 | + "rank": "normal" | |
| 1312 | + }, | |
| 1313 | + { | |
| 1314 | + "mainsnak": { | |
| 1315 | + "snaktype": "value", | |
| 1316 | + "property": "P169", | |
| 1317 | + "datavalue": { | |
| 1318 | + "value": { | |
| 1319 | + "entity-type": "item", | |
| 1320 | + "numeric-id": 4934, | |
| 1321 | + "id": "Q4934" | |
| 1322 | + }, | |
| 1323 | + "type": "wikibase-entityid" | |
| 1324 | + }, | |
| 1325 | + "datatype": "wikibase-item" | |
| 1326 | + }, | |
| 1327 | + "type": "statement", | |
| 1328 | + "qualifiers": { | |
| 1329 | + "P580": [ | |
| 1330 | + { | |
| 1331 | + "snaktype": "value", | |
| 1332 | + "property": "P580", | |
| 1333 | + "datavalue": { | |
| 1334 | + "value": { | |
| 1335 | + "time": "+2011-04-04T00:00:00Z", | |
| 1336 | + "timezone": 0, | |
| 1337 | + "before": 0, | |
| 1338 | + "after": 0, | |
| 1339 | + "precision": 11, | |
| 1340 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 1341 | + }, | |
| 1342 | + "type": "time" | |
| 1343 | + }, | |
| 1344 | + "datatype": "time" | |
| 1345 | + } | |
| 1346 | + ], | |
| 1347 | + "P582": [ | |
| 1348 | + { | |
| 1349 | + "snaktype": "value", | |
| 1350 | + "property": "P582", | |
| 1351 | + "datavalue": { | |
| 1352 | + "value": { | |
| 1353 | + "time": "+2015-08-10T00:00:00Z", | |
| 1354 | + "timezone": 0, | |
| 1355 | + "before": 0, | |
| 1356 | + "after": 0, | |
| 1357 | + "precision": 11, | |
| 1358 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 1359 | + }, | |
| 1360 | + "type": "time" | |
| 1361 | + }, | |
| 1362 | + "datatype": "time" | |
| 1363 | + } | |
| 1364 | + ] | |
| 1365 | + }, | |
| 1366 | + "rank": "normal" | |
| 1367 | + }, | |
| 1368 | + { | |
| 1369 | + "mainsnak": { | |
| 1370 | + "snaktype": "value", | |
| 1371 | + "property": "P169", | |
| 1372 | + "datavalue": { | |
| 1373 | + "value": { | |
| 1374 | + "entity-type": "item", | |
| 1375 | + "numeric-id": 3503829, | |
| 1376 | + "id": "Q3503829" | |
| 1377 | + }, | |
| 1378 | + "type": "wikibase-entityid" | |
| 1379 | + }, | |
| 1380 | + "datatype": "wikibase-item" | |
| 1381 | + }, | |
| 1382 | + "type": "statement", | |
| 1383 | + "qualifiers": { | |
| 1384 | + "P580": [ | |
| 1385 | + { | |
| 1386 | + "snaktype": "value", | |
| 1387 | + "property": "P580", | |
| 1388 | + "datavalue": { | |
| 1389 | + "value": { | |
| 1390 | + "time": "+2015-08-10T00:00:00Z", | |
| 1391 | + "timezone": 0, | |
| 1392 | + "before": 0, | |
| 1393 | + "after": 0, | |
| 1394 | + "precision": 11, | |
| 1395 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 1396 | + }, | |
| 1397 | + "type": "time" | |
| 1398 | + }, | |
| 1399 | + "datatype": "time" | |
| 1400 | + } | |
| 1401 | + ] | |
| 1402 | + }, | |
| 1403 | + "rank": "preferred" | |
| 1404 | + } | |
| 1405 | + ], | |
| 1406 | + "P112": [ | |
| 1407 | + { | |
| 1408 | + "mainsnak": { | |
| 1409 | + "snaktype": "value", | |
| 1410 | + "property": "P112", | |
| 1411 | + "datavalue": { | |
| 1412 | + "value": { | |
| 1413 | + "entity-type": "item", | |
| 1414 | + "numeric-id": 92764, | |
| 1415 | + "id": "Q92764" | |
| 1416 | + }, | |
| 1417 | + "type": "wikibase-entityid" | |
| 1418 | + }, | |
| 1419 | + "datatype": "wikibase-item" | |
| 1420 | + }, | |
| 1421 | + "type": "statement", | |
| 1422 | + "rank": "normal" | |
| 1423 | + }, | |
| 1424 | + { | |
| 1425 | + "mainsnak": { | |
| 1426 | + "snaktype": "value", | |
| 1427 | + "property": "P112", | |
| 1428 | + "datavalue": { | |
| 1429 | + "value": { | |
| 1430 | + "entity-type": "item", | |
| 1431 | + "numeric-id": 4934, | |
| 1432 | + "id": "Q4934" | |
| 1433 | + }, | |
| 1434 | + "type": "wikibase-entityid" | |
| 1435 | + }, | |
| 1436 | + "datatype": "wikibase-item" | |
| 1437 | + }, | |
| 1438 | + "type": "statement", | |
| 1439 | + "rank": "normal" | |
| 1440 | + } | |
| 1441 | + ], | |
| 1442 | + "P749": [ | |
| 1443 | + { | |
| 1444 | + "mainsnak": { | |
| 1445 | + "snaktype": "value", | |
| 1446 | + "property": "P749", | |
| 1447 | + "datavalue": { | |
| 1448 | + "value": { | |
| 1449 | + "entity-type": "item", | |
| 1450 | + "numeric-id": 20800404, | |
| 1451 | + "id": "Q20800404" | |
| 1452 | + }, | |
| 1453 | + "type": "wikibase-entityid" | |
| 1454 | + }, | |
| 1455 | + "datatype": "wikibase-item" | |
| 1456 | + }, | |
| 1457 | + "type": "statement", | |
| 1458 | + "qualifiers": { | |
| 1459 | + "P580": [ | |
| 1460 | + { | |
| 1461 | + "snaktype": "value", | |
| 1462 | + "property": "P580", | |
| 1463 | + "datavalue": { | |
| 1464 | + "value": { | |
| 1465 | + "time": "+2015-00-00T00:00:00Z", | |
| 1466 | + "timezone": 0, | |
| 1467 | + "before": 0, | |
| 1468 | + "after": 0, | |
| 1469 | + "precision": 9, | |
| 1470 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 1471 | + }, | |
| 1472 | + "type": "time" | |
| 1473 | + }, | |
| 1474 | + "datatype": "time" | |
| 1475 | + } | |
| 1476 | + ] | |
| 1477 | + }, | |
| 1478 | + "rank": "normal" | |
| 1479 | + } | |
| 1480 | + ], | |
| 1481 | + "P355": [ | |
| 1482 | + { | |
| 1483 | + "mainsnak": { | |
| 1484 | + "snaktype": "value", | |
| 1485 | + "property": "P355", | |
| 1486 | + "datavalue": { | |
| 1487 | + "value": { | |
| 1488 | + "entity-type": "item", | |
| 1489 | + "numeric-id": 1318441, | |
| 1490 | + "id": "Q1318441" | |
| 1491 | + }, | |
| 1492 | + "type": "wikibase-entityid" | |
| 1493 | + }, | |
| 1494 | + "datatype": "wikibase-item" | |
| 1495 | + }, | |
| 1496 | + "type": "statement", | |
| 1497 | + "qualifiers": { | |
| 1498 | + "P580": [ | |
| 1499 | + { | |
| 1500 | + "snaktype": "value", | |
| 1501 | + "property": "P580", | |
| 1502 | + "datavalue": { | |
| 1503 | + "value": { | |
| 1504 | + "time": "+2010-05-27T00:00:00Z", | |
| 1505 | + "timezone": 0, | |
| 1506 | + "before": 0, | |
| 1507 | + "after": 0, | |
| 1508 | + "precision": 11, | |
| 1509 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 1510 | + }, | |
| 1511 | + "type": "time" | |
| 1512 | + }, | |
| 1513 | + "datatype": "time" | |
| 1514 | + } | |
| 1515 | + ] | |
| 1516 | + }, | |
| 1517 | + "rank": "normal" | |
| 1518 | + }, | |
| 1519 | + { | |
| 1520 | + "mainsnak": { | |
| 1521 | + "snaktype": "value", | |
| 1522 | + "property": "P355", | |
| 1523 | + "datavalue": { | |
| 1524 | + "value": { | |
| 1525 | + "entity-type": "item", | |
| 1526 | + "numeric-id": 1053674, | |
| 1527 | + "id": "Q1053674" | |
| 1528 | + }, | |
| 1529 | + "type": "wikibase-entityid" | |
| 1530 | + }, | |
| 1531 | + "datatype": "wikibase-item" | |
| 1532 | + }, | |
| 1533 | + "type": "statement", | |
| 1534 | + "qualifiers": { | |
| 1535 | + "P580": [ | |
| 1536 | + { | |
| 1537 | + "snaktype": "value", | |
| 1538 | + "property": "P580", | |
| 1539 | + "datavalue": { | |
| 1540 | + "value": { | |
| 1541 | + "time": "+2008-03-11T00:00:00Z", | |
| 1542 | + "timezone": 0, | |
| 1543 | + "before": 0, | |
| 1544 | + "after": 0, | |
| 1545 | + "precision": 11, | |
| 1546 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 1547 | + }, | |
| 1548 | + "type": "time" | |
| 1549 | + }, | |
| 1550 | + "datatype": "time" | |
| 1551 | + } | |
| 1552 | + ] | |
| 1553 | + }, | |
| 1554 | + "rank": "deprecated" | |
| 1555 | + }, | |
| 1556 | + { | |
| 1557 | + "mainsnak": { | |
| 1558 | + "snaktype": "value", | |
| 1559 | + "property": "P355", | |
| 1560 | + "datavalue": { | |
| 1561 | + "value": { | |
| 1562 | + "entity-type": "item", | |
| 1563 | + "numeric-id": 1816282, | |
| 1564 | + "id": "Q1816282" | |
| 1565 | + }, | |
| 1566 | + "type": "wikibase-entityid" | |
| 1567 | + }, | |
| 1568 | + "datatype": "wikibase-item" | |
| 1569 | + }, | |
| 1570 | + "type": "statement", | |
| 1571 | + "rank": "deprecated" | |
| 1572 | + } | |
| 1573 | + ], | |
| 1574 | + "P1830": [ | |
| 1575 | + { | |
| 1576 | + "mainsnak": { | |
| 1577 | + "snaktype": "value", | |
| 1578 | + "property": "P1830", | |
| 1579 | + "datavalue": { | |
| 1580 | + "value": { | |
| 1581 | + "entity-type": "item", | |
| 1582 | + "numeric-id": 7854718, | |
| 1583 | + "id": "Q7854718" | |
| 1584 | + }, | |
| 1585 | + "type": "wikibase-entityid" | |
| 1586 | + }, | |
| 1587 | + "datatype": "wikibase-item" | |
| 1588 | + }, | |
| 1589 | + "type": "statement", | |
| 1590 | + "rank": "normal" | |
| 1591 | + }, | |
| 1592 | + { | |
| 1593 | + "mainsnak": { | |
| 1594 | + "snaktype": "value", | |
| 1595 | + "property": "P1830", | |
| 1596 | + "datavalue": { | |
| 1597 | + "value": { | |
| 1598 | + "entity-type": "item", | |
| 1599 | + "numeric-id": 7948665, | |
| 1600 | + "id": "Q7948665" | |
| 1601 | + }, | |
| 1602 | + "type": "wikibase-entityid" | |
| 1603 | + }, | |
| 1604 | + "datatype": "wikibase-item" | |
| 1605 | + }, | |
| 1606 | + "type": "statement", | |
| 1607 | + "rank": "normal" | |
| 1608 | + }, | |
| 1609 | + { | |
| 1610 | + "mainsnak": { | |
| 1611 | + "snaktype": "value", | |
| 1612 | + "property": "P1830", | |
| 1613 | + "datavalue": { | |
| 1614 | + "value": { | |
| 1615 | + "entity-type": "item", | |
| 1616 | + "numeric-id": 10847349, | |
| 1617 | + "id": "Q10847349" | |
| 1618 | + }, | |
| 1619 | + "type": "wikibase-entityid" | |
| 1620 | + }, | |
| 1621 | + "datatype": "wikibase-item" | |
| 1622 | + }, | |
| 1623 | + "type": "statement", | |
| 1624 | + "rank": "normal" | |
| 1625 | + } | |
| 1626 | + ], | |
| 1627 | + "P31": [ | |
| 1628 | + { | |
| 1629 | + "mainsnak": { | |
| 1630 | + "snaktype": "value", | |
| 1631 | + "property": "P31", | |
| 1632 | + "datavalue": { | |
| 1633 | + "value": { | |
| 1634 | + "entity-type": "item", | |
| 1635 | + "numeric-id": 4830453, | |
| 1636 | + "id": "Q4830453" | |
| 1637 | + }, | |
| 1638 | + "type": "wikibase-entityid" | |
| 1639 | + }, | |
| 1640 | + "datatype": "wikibase-item" | |
| 1641 | + }, | |
| 1642 | + "type": "statement", | |
| 1643 | + "rank": "normal" | |
| 1644 | + }, | |
| 1645 | + { | |
| 1646 | + "mainsnak": { | |
| 1647 | + "snaktype": "value", | |
| 1648 | + "property": "P31", | |
| 1649 | + "datavalue": { | |
| 1650 | + "value": { | |
| 1651 | + "entity-type": "item", | |
| 1652 | + "numeric-id": 18388277, | |
| 1653 | + "id": "Q18388277" | |
| 1654 | + }, | |
| 1655 | + "type": "wikibase-entityid" | |
| 1656 | + }, | |
| 1657 | + "datatype": "wikibase-item" | |
| 1658 | + }, | |
| 1659 | + "type": "statement", | |
| 1660 | + "rank": "normal" | |
| 1661 | + }, | |
| 1662 | + { | |
| 1663 | + "mainsnak": { | |
| 1664 | + "snaktype": "value", | |
| 1665 | + "property": "P31", | |
| 1666 | + "datavalue": { | |
| 1667 | + "value": { | |
| 1668 | + "entity-type": "item", | |
| 1669 | + "numeric-id": 5988403, | |
| 1670 | + "id": "Q5988403" | |
| 1671 | + }, | |
| 1672 | + "type": "wikibase-entityid" | |
| 1673 | + }, | |
| 1674 | + "datatype": "wikibase-item" | |
| 1675 | + }, | |
| 1676 | + "type": "statement", | |
| 1677 | + "rank": "normal" | |
| 1678 | + }, | |
| 1679 | + { | |
| 1680 | + "mainsnak": { | |
| 1681 | + "snaktype": "value", | |
| 1682 | + "property": "P31", | |
| 1683 | + "datavalue": { | |
| 1684 | + "value": { | |
| 1685 | + "entity-type": "item", | |
| 1686 | + "numeric-id": 891723, | |
| 1687 | + "id": "Q891723" | |
| 1688 | + }, | |
| 1689 | + "type": "wikibase-entityid" | |
| 1690 | + }, | |
| 1691 | + "datatype": "wikibase-item" | |
| 1692 | + }, | |
| 1693 | + "type": "statement", | |
| 1694 | + "rank": "normal" | |
| 1695 | + }, | |
| 1696 | + { | |
| 1697 | + "mainsnak": { | |
| 1698 | + "snaktype": "value", | |
| 1699 | + "property": "P31", | |
| 1700 | + "datavalue": { | |
| 1701 | + "value": { | |
| 1702 | + "entity-type": "item", | |
| 1703 | + "numeric-id": 19967801, | |
| 1704 | + "id": "Q19967801" | |
| 1705 | + }, | |
| 1706 | + "type": "wikibase-entityid" | |
| 1707 | + }, | |
| 1708 | + "datatype": "wikibase-item" | |
| 1709 | + }, | |
| 1710 | + "type": "statement", | |
| 1711 | + "rank": "normal" | |
| 1712 | + } | |
| 1713 | + ], | |
| 1714 | + "P2139": [ | |
| 1715 | + { | |
| 1716 | + "mainsnak": { | |
| 1717 | + "snaktype": "value", | |
| 1718 | + "property": "P2139", | |
| 1719 | + "datavalue": { | |
| 1720 | + "value": { | |
| 1721 | + "amount": "+257637000000", | |
| 1722 | + "unit": "http://www.wikidata.org/entity/Q4917" | |
| 1723 | + }, | |
| 1724 | + "type": "quantity" | |
| 1725 | + }, | |
| 1726 | + "datatype": "quantity" | |
| 1727 | + }, | |
| 1728 | + "type": "statement", | |
| 1729 | + "rank": "normal", | |
| 1730 | + "qualifiers": { | |
| 1731 | + "P585": [ | |
| 1732 | + { | |
| 1733 | + "snaktype": "value", | |
| 1734 | + "property": "P585", | |
| 1735 | + "datavalue": { | |
| 1736 | + "value": { | |
| 1737 | + "time": "+2021-12-31T00:00:00Z", | |
| 1738 | + "timezone": 0, | |
| 1739 | + "before": 0, | |
| 1740 | + "after": 0, | |
| 1741 | + "precision": 11, | |
| 1742 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 1743 | + }, | |
| 1744 | + "type": "time" | |
| 1745 | + }, | |
| 1746 | + "datatype": "time" | |
| 1747 | + } | |
| 1748 | + ] | |
| 1749 | + } | |
| 1750 | + }, | |
| 1751 | + { | |
| 1752 | + "mainsnak": { | |
| 1753 | + "snaktype": "value", | |
| 1754 | + "property": "P2139", | |
| 1755 | + "datavalue": { | |
| 1756 | + "value": { | |
| 1757 | + "amount": "+305630000000", | |
| 1758 | + "unit": "http://www.wikidata.org/entity/Q4917" | |
| 1759 | + }, | |
| 1760 | + "type": "quantity" | |
| 1761 | + }, | |
| 1762 | + "datatype": "quantity" | |
| 1763 | + }, | |
| 1764 | + "type": "statement", | |
| 1765 | + "rank": "normal", | |
| 1766 | + "qualifiers": { | |
| 1767 | + "P585": [ | |
| 1768 | + { | |
| 1769 | + "snaktype": "value", | |
| 1770 | + "property": "P585", | |
| 1771 | + "datavalue": { | |
| 1772 | + "value": { | |
| 1773 | + "time": "+2023-12-31T00:00:00Z", | |
| 1774 | + "timezone": 0, | |
| 1775 | + "before": 0, | |
| 1776 | + "after": 0, | |
| 1777 | + "precision": 11, | |
| 1778 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 1779 | + }, | |
| 1780 | + "type": "time" | |
| 1781 | + }, | |
| 1782 | + "datatype": "time" | |
| 1783 | + } | |
| 1784 | + ] | |
| 1785 | + } | |
| 1786 | + } | |
| 1787 | + ], | |
| 1788 | + "P2295": [ | |
| 1789 | + { | |
| 1790 | + "mainsnak": { | |
| 1791 | + "snaktype": "value", | |
| 1792 | + "property": "P2295", | |
| 1793 | + "datavalue": { | |
| 1794 | + "value": { | |
| 1795 | + "amount": "+73795000000", | |
| 1796 | + "unit": "http://www.wikidata.org/entity/Q4917" | |
| 1797 | + }, | |
| 1798 | + "type": "quantity" | |
| 1799 | + }, | |
| 1800 | + "datatype": "quantity" | |
| 1801 | + }, | |
| 1802 | + "type": "statement", | |
| 1803 | + "rank": "normal", | |
| 1804 | + "qualifiers": { | |
| 1805 | + "P585": [ | |
| 1806 | + { | |
| 1807 | + "snaktype": "value", | |
| 1808 | + "property": "P585", | |
| 1809 | + "datavalue": { | |
| 1810 | + "value": { | |
| 1811 | + "time": "+2023-12-31T00:00:00Z", | |
| 1812 | + "timezone": 0, | |
| 1813 | + "before": 0, | |
| 1814 | + "after": 0, | |
| 1815 | + "precision": 11, | |
| 1816 | + "calendarmodel": "http://www.wikidata.org/entity/Q1985727" | |
| 1817 | + }, | |
| 1818 | + "type": "time" | |
| 1819 | + }, | |
| 1820 | + "datatype": "time" | |
| 1821 | + } | |
| 1822 | + ] | |
| 1823 | + } | |
| 1824 | + } | |
| 1825 | + ], | |
| 1826 | + "P946": [ | |
| 1827 | + { | |
| 1828 | + "mainsnak": { | |
| 1829 | + "snaktype": "value", | |
| 1830 | + "property": "P946", | |
| 1831 | + "datavalue": { | |
| 1832 | + "value": "US02079K3059", | |
| 1833 | + "type": "string" | |
| 1834 | + }, | |
| 1835 | + "datatype": "external-id" | |
| 1836 | + }, | |
| 1837 | + "type": "statement", | |
| 1838 | + "rank": "normal" | |
| 1839 | + } | |
| 1840 | + ] | |
| 1841 | + }, | |
| 1842 | + "sitelinks": { | |
| 1843 | + "dewiki": { | |
| 1844 | + "site": "dewiki", | |
| 1845 | + "title": "Google LLC" | |
| 1846 | + }, | |
| 1847 | + "enwiki": { | |
| 1848 | + "site": "enwiki", | |
| 1849 | + "title": "Google" | |
| 1850 | + }, | |
| 1851 | + "frwiki": { | |
| 1852 | + "site": "frwiki", | |
| 1853 | + "title": "Google" | |
| 1854 | + }, | |
| 1855 | + "jawiki": { | |
| 1856 | + "site": "jawiki", | |
| 1857 | + "title": "Google" | |
| 1858 | + } | |
| 1859 | + } | |
| 1860 | + } | |
| 1861 | + } | |
| 1862 | +} | |
| \ No newline at end of file | ||
added
fixtures/enrichment/wikidata-labels.json
+134 −0
@@ -0,0 +1,134 @@ | ||
| 1 | +{ | |
| 2 | + "Q1053674": { | |
| 3 | + "description": "provides Internet ad serving services", | |
| 4 | + "label": "DoubleClick" | |
| 5 | + }, | |
| 6 | + "Q10847349": { | |
| 7 | + "description": "discontinued URL shortening service", | |
| 8 | + "label": "Google URL Shortener" | |
| 9 | + }, | |
| 10 | + "Q111897729": { | |
| 11 | + "description": "scientific article published on 07 August 2003", | |
| 12 | + "label": "Software tools" | |
| 13 | + }, | |
| 14 | + "Q11661": { | |
| 15 | + "description": "development, management, and use of computer-based information systems", | |
| 16 | + "label": "information technology" | |
| 17 | + }, | |
| 18 | + "Q1318441": { | |
| 19 | + "description": "mobile advertising company", | |
| 20 | + "label": "AdMob" | |
| 21 | + }, | |
| 22 | + "Q149789": { | |
| 23 | + "description": "US form of a private limited company", | |
| 24 | + "label": "limited liability company" | |
| 25 | + }, | |
| 26 | + "Q1816282": { | |
| 27 | + "description": "American video technology company", | |
| 28 | + "label": "On2 Technologies" | |
| 29 | + }, | |
| 30 | + "Q18388277": { | |
| 31 | + "description": "company specializing in technology", | |
| 32 | + "label": "technology company" | |
| 33 | + }, | |
| 34 | + "Q189507": { | |
| 35 | + "description": "marketing based on the use of online assets", | |
| 36 | + "label": "internet marketing" | |
| 37 | + }, | |
| 38 | + "Q19834616": { | |
| 39 | + "description": "Mobile payments platform developed by Google", | |
| 40 | + "label": null | |
| 41 | + }, | |
| 42 | + "Q19967801": { | |
| 43 | + "description": "product or service provided on the Internet", | |
| 44 | + "label": "online service" | |
| 45 | + }, | |
| 46 | + "Q20800404": { | |
| 47 | + "description": "American multinational technology conglomerate", | |
| 48 | + "label": "Alphabet Inc." | |
| 49 | + }, | |
| 50 | + "Q20923490": { | |
| 51 | + "description": "characteristic of material that includes more than one language", | |
| 52 | + "label": "multiple languages" | |
| 53 | + }, | |
| 54 | + "Q271982": { | |
| 55 | + "description": "online advertising platform owned by Google", | |
| 56 | + "label": "Google Ads" | |
| 57 | + }, | |
| 58 | + "Q30": { | |
| 59 | + "description": "country located primarily in North America", | |
| 60 | + "label": "United States" | |
| 61 | + }, | |
| 62 | + "Q3503829": { | |
| 63 | + "description": "Indian-American business executive, CEO of Google LLC & Alphabet Inc.", | |
| 64 | + "label": "Sundar Pichai" | |
| 65 | + }, | |
| 66 | + "Q4830453": { | |
| 67 | + "description": "organization undertaking commercial, industrial, or professional activity", | |
| 68 | + "label": "business" | |
| 69 | + }, | |
| 70 | + "Q486860": { | |
| 71 | + "description": "city in Santa Clara County, California, United States", | |
| 72 | + "label": "Mountain View" | |
| 73 | + }, | |
| 74 | + "Q4934": { | |
| 75 | + "description": "American computer scientist and Internet entrepreneur (born 1973)", | |
| 76 | + "label": "Larry Page" | |
| 77 | + }, | |
| 78 | + "Q51712": { | |
| 79 | + "description": "telecommunications service by Google", | |
| 80 | + "label": "Google Voice" | |
| 81 | + }, | |
| 82 | + "Q56611700": { | |
| 83 | + "description": "type of industry for Internet activities", | |
| 84 | + "label": "Internet industry" | |
| 85 | + }, | |
| 86 | + "Q57655560": { | |
| 87 | + "description": "in the United States, a business entity incorporated under any state or territorial statute", | |
| 88 | + "label": "corporation" | |
| 89 | + }, | |
| 90 | + "Q5988403": { | |
| 91 | + "description": "entity that manages identity information of users and provides authentication services to relying applications", | |
| 92 | + "label": "identity provider" | |
| 93 | + }, | |
| 94 | + "Q694178": { | |
| 95 | + "description": "building complex in California, United States", | |
| 96 | + "label": "Googleplex" | |
| 97 | + }, | |
| 98 | + "Q7854718": { | |
| 99 | + "description": "note-taking service developed by Google", | |
| 100 | + "label": null | |
| 101 | + }, | |
| 102 | + "Q7948665": { | |
| 103 | + "description": "search engine from Google", | |
| 104 | + "label": "WDYL" | |
| 105 | + }, | |
| 106 | + "Q82059": { | |
| 107 | + "description": "American fully electronic stock exchange", | |
| 108 | + "label": "Nasdaq" | |
| 109 | + }, | |
| 110 | + "Q880371": { | |
| 111 | + "description": "businesses for development, maintenance and publication of software", | |
| 112 | + "label": "software industry" | |
| 113 | + }, | |
| 114 | + "Q891723": { | |
| 115 | + "description": "company that offers its securities for sale to the general public", | |
| 116 | + "label": "public company" | |
| 117 | + }, | |
| 118 | + "Q92747": { | |
| 119 | + "description": "software engineer, businessman, former Google CEO", | |
| 120 | + "label": "Eric Schmidt" | |
| 121 | + }, | |
| 122 | + "Q92764": { | |
| 123 | + "description": "American billionaire businessman (born 1973)", | |
| 124 | + "label": "Sergey Brin" | |
| 125 | + }, | |
| 126 | + "Q9366": { | |
| 127 | + "description": "search engine by Google LLC", | |
| 128 | + "label": "Google Search" | |
| 129 | + }, | |
| 130 | + "Q99": { | |
| 131 | + "description": "state of the United States of America", | |
| 132 | + "label": "California" | |
| 133 | + } | |
| 134 | +} | |
| \ No newline at end of file | ||
added
fixtures/enrichment/wikipedia-summary.json
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +{ | |
| 2 | + "type": "standard", | |
| 3 | + "title": "Google", | |
| 4 | + "lang": "en", | |
| 5 | + "dir": "ltr", | |
| 6 | + "description": "American multinational technology company", | |
| 7 | + "extract": "Google LLC is an American multinational technology corporation focused on information technology, online advertising, search engine technology, email, cloud computing, software, quantum computing, e-commerce, consumer electronics, and artificial intelligence (AI). It has been referred to as \"the most powerful company in the world\" by the BBC, and is one of the world's most valuable brands. Google's parent company Alphabet Inc. has been described as a Big Tech company.", | |
| 8 | + "extract_html": "<p><b>Google LLC</b> is an American multinational technology corporation focused on information technology, online advertising, search engine technology, email, cloud computing, software, quantum computing, e-commerce, consumer electronics, and artificial intelligence (AI). It has been referred to as \"the most powerful company in the world\" by the BBC, and is one of the world's most valuable brands. Google's parent company Alphabet Inc. has been described as a Big Tech company.</p>", | |
| 9 | + "thumbnail": { | |
| 10 | + "source": "https://thumb.wikimedia.org/wikipedia/commons/thumb/3/32/Googleplex_HQ_%28cropped%29.jpg/330px-Googleplex_HQ_%28cropped%29.jpg?utm_source=en.wikipedia.org&utm_campaign=api&utm_content=thumbnail", | |
| 11 | + "width": 330, | |
| 12 | + "height": 241 | |
| 13 | + }, | |
| 14 | + "originalimage": { | |
| 15 | + "source": "https://upload.wikimedia.org/wikipedia/commons/3/32/Googleplex_HQ_%28cropped%29.jpg?utm_source=en.wikipedia.org&utm_campaign=api&utm_content=thumbnail_unscaled", | |
| 16 | + "width": 3024, | |
| 17 | + "height": 2212 | |
| 18 | + }, | |
| 19 | + "content_urls": { | |
| 20 | + "desktop": { | |
| 21 | + "page": "https://en.wikipedia.org/wiki/Google" | |
| 22 | + } | |
| 23 | + }, | |
| 24 | + "timestamp": "2026-09-03T09:34:18Z", | |
| 25 | + "wikibase_item": "Q95", | |
| 26 | + "pageid": 1092923 | |
| 27 | +} | |
| \ No newline at end of file | ||
added
prompts/company-profile/v1.md
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +<!-- company-profile v1 · model tier: medium · schema: CompanyProfileText (profile-v1) --> | |
| 2 | +You write the short factual description shown at the top of a Company Atlas company page when no encyclopedic summary exists. Company Atlas is a reference atlas: every sentence must be traceable to the company's own public pages that you are given. | |
| 3 | + | |
| 4 | +You receive a JSON context: the company name, its canonical domain, its country (ISO-2) when known, and `text` — plain text extracted from the company's homepage and/or "about" page (already truncated). Nothing else is known. | |
| 5 | + | |
| 6 | +Write: | |
| 7 | +- `description`: 2–3 sentences, 40–700 characters, in English (translate if the source is in another language), stating what the company does, for whom, and where it operates — only when the text says so. Neutral encyclopedic register, third person, present tense. | |
| 8 | +- `language`: ISO-639-1 code of the source text. | |
| 9 | +- `confidence` (0–1): how completely the text supports the description (≤ 0.4 when the text is mostly navigation, cookie notices or slogans). | |
| 10 | + | |
| 11 | +Strict rules: | |
| 12 | +1. Use ONLY facts literally present in `text`. Do not add anything you believe you know about the company, its founders, its size, its history or its finances. | |
| 13 | +2. Never write a number, year, amount, headcount or percentage that does not appear verbatim in `text`. If the text has no figures, the description has no figures. | |
| 14 | +3. No marketing language: drop superlatives and adjectives such as "leading", "innovative", "world-class", "trusted", "best". Convert slogans into plain statements or omit them. | |
| 15 | +4. Do not describe people, employment changes, legal matters or intentions. No speculation ("appears to", "seems to") — if the text does not state it, leave it out. | |
| 16 | +5. If the text is too thin to describe the business (parked domain, error page, login wall, only menus), return `description` as the single sentence "The company's public pages do not describe its business." with `confidence` 0.1. | |
| 17 | +6. Output a single JSON object only. | |
modified
src/companyatlas/api/queries.py
+1 −1
@@ -92,7 +92,7 @@ COMPANY_CARD_SQL = """ | ||
| 92 | 92 | select c.id, c.slug, c.display_name, c.legal_name, c.canonical_domain, c.website, c.description, c.industries, c.industry_primary, |
| 93 | 93 | c.country, c.hq_city, c.hq_region, c.public_company, c.ticker, c.exchange, c.founded_year, c.employees_band, c.logo_url, |
| 94 | 94 | c.status, c.onboarding_status, c.onboarding_error, c.importance, c.tier, c.indexed, c.stats, c.last_event_at, c.last_observed_at, |
| 95 | − c.first_observed_at, c.discovered_at, c.created_at, c.updated_at, | |
| 95 | + c.first_observed_at, c.discovered_at, c.created_at, c.updated_at, c.source_meta->'profile' as profile, | |
| 96 | 96 | mx.metrics, sc.sensors, sc.observations, sc.changes, sc.events, jc.jobs_open{sparkline_col} |
| 97 | 97 | from companies c |
| 98 | 98 | left join lateral (select jsonb_object_agg(m.metric, m.value) as metrics from metrics_current m where m.company_id = c.id) mx on true |
modified
src/companyatlas/api/routers/companies.py
+11 −9
@@ -10,6 +10,7 @@ from companyatlas.api import queries as q | ||
| 10 | 10 | from companyatlas.api import serializers as ser |
| 11 | 11 | from companyatlas.api.common import PageDep, page_payload, public_cache_value |
| 12 | 12 | from companyatlas.db import connection, fetch_all, fetch_one |
| 13 | +from companyatlas.services.enrichment import profile_facts | |
| 13 | 14 | from companyatlas.taxonomy import Metric |
| 14 | 15 | |
| 15 | 16 | ORDER = 40 |
@@ -112,12 +113,12 @@ async def company_detail(key: str, response: Response) -> dict[str, Any]: | ||
| 112 | 113 | out["last_change_at"] = c.get("last_change_at") |
| 113 | 114 | out["aliases"] = [r["alias"] for r in await fetch_all(conn, "select alias from company_aliases where company_id = :id order by kind, alias limit 50", id=cid)] |
| 114 | 115 | out["domains"] = await fetch_all(conn, "select domain, kind, status, first_seen_at, last_seen_at from domains where company_id = :id order by kind, domain limit 100", id=cid) |
| 115 | − rel = await fetch_all(conn, "select r.kind, r.to_name, r.valid_from, r.valid_to, r.confidence, r.source_url, o.slug, o.display_name " | |
| 116 | − "from company_relationships r left join companies o on o.id = r.to_company_id where r.from_company_id = :id " | |
| 117 | − "order by r.kind, r.last_seen_at desc limit 100", id=cid) | |
| 118 | − out["relationships"] = [{"kind": r["kind"], "company": {"slug": r["slug"], "display_name": r["display_name"]} if r["slug"] else None, | |
| 119 | − "to_name": r["to_name"] or r["display_name"], "valid_from": r["valid_from"], "valid_to": r["valid_to"], | |
| 120 | − "confidence": ser._float(r["confidence"], 3), "source_url": r["source_url"]} for r in rel] | |
| 116 | + rel = await fetch_all(conn, "select r.kind, r.to_name, r.valid_from, r.valid_to, r.confidence, r.source_url, r.provenance, r.first_seen_at, " | |
| 117 | + "r.last_seen_at, o.slug, o.display_name from company_relationships r left join companies o on o.id = r.to_company_id " | |
| 118 | + "where r.from_company_id = :id order by (r.valid_to is not null), r.kind, o.importance desc nulls last, r.last_seen_at desc limit 200", id=cid) | |
| 119 | + out["relationships"] = [ser.relationship(r) for r in rel] | |
| 120 | + out["profile"] = ser._dict(c.get("source_meta")).get("profile") or None | |
| 121 | + out["facts"] = profile_facts(out["profile"]) | |
| 121 | 122 | md = await fetch_all(conn, "select metric, value, confidence, computed_at, formula_version, inputs from metrics_current where company_id = :id order by metric", id=cid) |
| 122 | 123 | out["metrics_detail"] = [{"metric": r["metric"], "value": ser.metric_value(r["metric"], r["value"]), "confidence": ser._float(r["confidence"], 3), |
| 123 | 124 | "computed_at": r["computed_at"], "formula_version": r["formula_version"], "inputs": ser._dict(r["inputs"])} for r in md] |
@@ -250,14 +251,15 @@ async def company_jobs(key: str, response: Response, p: PageDep, status: str = Q | ||
| 250 | 251 | return payload |
| 251 | 252 | |
| 252 | 253 | |
| 253 | −@router.get("/companies/{key}/people", summary="Leadership listed on monitored pages") | |
| 254 | +@router.get("/companies/{key}/people", summary="Leadership listed on monitored pages and on Wikidata") | |
| 254 | 255 | async def company_people(key: str, response: Response) -> dict[str, Any]: |
| 255 | 256 | _pub(response, 300) |
| 256 | 257 | async with connection() as conn: |
| 257 | 258 | c = await q.require_company(conn, key) |
| 258 | 259 | rows = await fetch_all(conn, "select * from people where company_id = :id order by is_executive desc, status, last_seen_at desc limit 500", id=c["id"]) |
| 259 | − return {"listed": [ser.person(r) for r in rows if r["status"] == "listed"], | |
| 260 | − "no_longer_listed": [ser.person(r) for r in rows if r["status"] != "listed"]} | |
| 260 | + people = [ser.person(r) for r in rows] | |
| 261 | + return {"listed": [p for p in people if p["status"] == "listed"], "no_longer_listed": [p for p in people if p["status"] != "listed"], | |
| 262 | + "sources": sorted({p["source"] for p in people})} | |
| 261 | 263 | |
| 262 | 264 | |
| 263 | 265 | @router.get("/companies/{key}/products", summary="Products in the public catalog") |
modified
src/companyatlas/api/serializers.py
+17 −2
@@ -3,6 +3,7 @@ from __future__ import annotations | ||
| 3 | 3 | |
| 4 | 4 | import json |
| 5 | 5 | from typing import Any |
| 6 | +from urllib.parse import urlparse | |
| 6 | 7 | |
| 7 | 8 | from companyatlas.taxonomy import Metric, confidence_label |
| 8 | 9 | |
@@ -103,9 +104,20 @@ def company_card(row: dict[str, Any]) -> dict[str, Any]: | ||
| 103 | 104 | } |
| 104 | 105 | if "sparkline" in row: |
| 105 | 106 | card["sparkline"] = [round(float(x), 1) for x in (row.get("sparkline") or [])] |
| 107 | + profile = _dict(row.get("profile")) | |
| 108 | + if profile: | |
| 109 | + card["profile"] = profile # docs/API.md "Profile & facts" — present once the company has been enriched | |
| 106 | 110 | return card |
| 107 | 111 | |
| 108 | 112 | |
| 113 | +def relationship(row: dict[str, Any]) -> dict[str, Any]: | |
| 114 | + """`company_relationships` row joined with the target company (`slug`, `display_name` aliases).""" | |
| 115 | + return {"kind": row["kind"], "company": {"slug": row["slug"], "display_name": row["display_name"]} if row.get("slug") else None, | |
| 116 | + "to_name": row.get("to_name") or row.get("display_name"), "valid_from": row.get("valid_from"), "valid_to": row.get("valid_to"), | |
| 117 | + "confidence": _float(row.get("confidence"), 3), "source_url": row.get("source_url"), "provenance": _dict(row.get("provenance")), | |
| 118 | + "first_seen_at": row.get("first_seen_at"), "last_seen_at": row.get("last_seen_at")} | |
| 119 | + | |
| 120 | + | |
| 109 | 121 | # ------------------------------------------------------------------------------------------------ events |
| 110 | 122 | |
| 111 | 123 | |
@@ -191,9 +203,12 @@ def job(row: dict[str, Any]) -> dict[str, Any]: | ||
| 191 | 203 | |
| 192 | 204 | |
| 193 | 205 | def person(row: dict[str, Any]) -> dict[str, Any]: |
| 206 | + source_url = row.get("source_url") | |
| 207 | + host = (urlparse(source_url).hostname or "").lower() if source_url else "" | |
| 194 | 208 | return {"id": row["id"], "name": row["name"], "title": row.get("title"), "role_category": row.get("role_category"), |
| 195 | 209 | "is_executive": bool(row.get("is_executive")), "first_seen_at": row["first_seen_at"], "last_seen_at": row["last_seen_at"], |
| 196 | − "removed_at": row.get("removed_at"), "status": row.get("status") or "listed", "source_url": row.get("source_url")} | |
| 210 | + "removed_at": row.get("removed_at"), "status": row.get("status") or "listed", "source_url": source_url, | |
| 211 | + "source": "wikidata" if host.endswith("wikidata.org") else "page"} | |
| 197 | 212 | |
| 198 | 213 | |
| 199 | 214 | def product(row: dict[str, Any]) -> dict[str, Any]: |
@@ -287,4 +302,4 @@ def connector(row: dict[str, Any]) -> dict[str, Any]: | ||
| 287 | 302 | |
| 288 | 303 | __all__ = ["alert", "alert_delivery", "change", "company_card", "company_ref", "company_ref_from_company", "connector", "event", "event_source", |
| 289 | 304 | "failure", "job", "llm_job", "location", "metric_point", "metric_value", "metrics_map", "news_item", "person", "plan", "product", |
| 290 | − "queue_job", "review", "sensor", "sensor_admin", "signal", "snapshot"] | |
| 305 | + "queue_job", "relationship", "review", "sensor", "sensor_admin", "signal", "snapshot"] | |
added
src/companyatlas/commands/enrichment.py
+99 −0
@@ -0,0 +1,99 @@ | ||
| 1 | +"""`catlas` profile enrichment commands: `enrich-companies`, `profile` (services/enrichment.py).""" | |
| 2 | +from __future__ import annotations | |
| 3 | + | |
| 4 | +import json | |
| 5 | +from typing import Annotated | |
| 6 | + | |
| 7 | +import typer | |
| 8 | +from rich.table import Table | |
| 9 | + | |
| 10 | + | |
| 11 | +def register(app: typer.Typer) -> None: | |
| 12 | + @app.command("enrich-companies") | |
| 13 | + def enrich_companies( | |
| 14 | + limit: Annotated[int | None, typer.Option("--limit", help="companies per run (default: CA_ENRICH_BATCH)")] = None, | |
| 15 | + company: Annotated[list[str] | None, typer.Option("--company", help="slug, id or QID (repeatable); forces re-enrichment")] = None, | |
| 16 | + source: Annotated[str, typer.Option("--source", help="wikidata|wikipedia|homepage|llm|all (comma-separated allowed)")] = "all", | |
| 17 | + concurrency: Annotated[int | None, typer.Option("--concurrency")] = None, | |
| 18 | + no_llm: Annotated[bool, typer.Option("--no-llm", help="never call the LLM even when configured")] = False, | |
| 19 | + ) -> None: | |
| 20 | + """Enrich company profiles (Wikidata → Wikipedia → homepage → optional LLM) with provenance; never-enriched companies first.""" | |
| 21 | + from companyatlas.cli import console, out, run_async | |
| 22 | + from companyatlas.services.enrichment import SOURCES, enrich_pending | |
| 23 | + | |
| 24 | + wanted = tuple(SOURCES) if source.strip().lower() in ("all", "") else tuple(s.strip().lower() for s in source.split(",") if s.strip()) | |
| 25 | + unknown = [s for s in wanted if s not in SOURCES] | |
| 26 | + if unknown: | |
| 27 | + console.print(f"[red]unknown source(s): {', '.join(unknown)} — choose from {', '.join(SOURCES)} or all[/]") | |
| 28 | + raise typer.Exit(1) | |
| 29 | + stats = run_async(enrich_pending(limit=limit, concurrency=concurrency, sources=wanted, llm=not no_llm, company_keys=company)) | |
| 30 | + table = Table(title="enrich-companies", show_header=False) | |
| 31 | + for k, v in stats.items(): | |
| 32 | + table.add_row(k, json.dumps(v) if isinstance(v, dict) else str(v)) | |
| 33 | + out.print(table) | |
| 34 | + | |
| 35 | + @app.command("profile") | |
| 36 | + def profile_cmd(key: Annotated[str, typer.Argument(help="company slug, id or Wikidata QID")], | |
| 37 | + as_json: Annotated[bool, typer.Option("--json", help="raw profile JSON")] = False) -> None: | |
| 38 | + """Print a company's stored profile (source_meta.profile) with the key facts and their sources.""" | |
| 39 | + from companyatlas.cli import console, out, run_async | |
| 40 | + from companyatlas.db import connection, fetch_all | |
| 41 | + from companyatlas.services.enrichment import load_company, person_source, profile_facts | |
| 42 | + | |
| 43 | + async def go() -> tuple[dict | None, list[dict], list[dict]]: | |
| 44 | + async with connection() as conn: | |
| 45 | + c = await load_company(conn, key) | |
| 46 | + if c is None: | |
| 47 | + return None, [], [] | |
| 48 | + people = await fetch_all(conn, "select name, title, status, source_url from people where company_id = :c order by is_executive desc, name limit 40", c=c["id"]) | |
| 49 | + rels = await fetch_all(conn, "select r.kind, coalesce(o.display_name, r.to_name) as name, r.valid_from, r.valid_to, r.provenance from company_relationships r " | |
| 50 | + "left join companies o on o.id = r.to_company_id where r.from_company_id = :c order by r.kind, name limit 60", c=c["id"]) | |
| 51 | + return c, people, rels | |
| 52 | + | |
| 53 | + company, people, rels = run_async(go()) | |
| 54 | + if company is None: | |
| 55 | + console.print(f"[red]company not found: {key}[/]") | |
| 56 | + raise typer.Exit(1) | |
| 57 | + meta = company.get("source_meta") or {} | |
| 58 | + if isinstance(meta, str): | |
| 59 | + meta = json.loads(meta) | |
| 60 | + profile = meta.get("profile") | |
| 61 | + if not profile: | |
| 62 | + console.print(f"[yellow]{company['slug']} has not been enriched yet — run `catlas enrich-companies --company {company['slug']}`[/]") | |
| 63 | + raise typer.Exit(2) | |
| 64 | + if as_json: | |
| 65 | + out.print_json(json.dumps(profile, default=str, ensure_ascii=False)) | |
| 66 | + return | |
| 67 | + out.print(f"[bold]{company['display_name']}[/] · {company['canonical_domain']} · enriched {profile.get('enriched_at')} · sources: " | |
| 68 | + f"{', '.join(meta.get('enrichment', {}).get('sources') or [])}") | |
| 69 | + if profile.get("description"): | |
| 70 | + out.print(f"\n{profile['description']}\n[dim]— {profile.get('description_source')} · {profile.get('description_attribution') or ''} " | |
| 71 | + f"{profile.get('description_url') or ''}[/]\n") | |
| 72 | + t = Table(title="facts") | |
| 73 | + for col in ("fact", "value", "source", "url"): | |
| 74 | + t.add_column(col) | |
| 75 | + for f in profile_facts(profile): | |
| 76 | + t.add_row(f["label"], str(f["value"]), f.get("source") or "", (f.get("url") or "")[:80]) | |
| 77 | + out.print(t) | |
| 78 | + if profile.get("industries") or profile.get("products"): | |
| 79 | + out.print(f"industries: {', '.join(profile.get('industries') or [])} · labels: {', '.join(profile.get('industry_labels') or [])}") | |
| 80 | + out.print(f"products: {', '.join(profile.get('products') or [])}") | |
| 81 | + if profile.get("socials"): | |
| 82 | + out.print("socials: " + " ".join(f"{k}={v}" for k, v in profile["socials"].items())) | |
| 83 | + if people: | |
| 84 | + p = Table(title="people") | |
| 85 | + for col in ("name", "title", "status", "source"): | |
| 86 | + p.add_column(col) | |
| 87 | + for r in people: | |
| 88 | + p.add_row(r["name"], r.get("title") or "", r["status"], person_source(r.get("source_url"))) | |
| 89 | + out.print(p) | |
| 90 | + if rels: | |
| 91 | + r_ = Table(title="relationships") | |
| 92 | + for col in ("kind", "company", "valid_from", "valid_to", "property"): | |
| 93 | + r_.add_column(col) | |
| 94 | + for r in rels: | |
| 95 | + prov = r.get("provenance") or {} | |
| 96 | + if isinstance(prov, str): | |
| 97 | + prov = json.loads(prov) | |
| 98 | + r_.add_row(r["kind"], r.get("name") or "", str(r.get("valid_from") or ""), str(r.get("valid_to") or ""), prov.get("property") or "") | |
| 99 | + out.print(r_) | |
modified
src/companyatlas/config.py
+18 −0
@@ -90,6 +90,24 @@ class Settings(BaseSettings): | ||
| 90 | 90 | prompts_dir: Path | None = Field(None, alias="CA_PROMPTS_DIR") # defaults to <repo>/prompts |
| 91 | 91 | worker_concurrency: int = Field(1, alias="CA_WORKER_CONCURRENCY") |
| 92 | 92 | |
| 93 | + # ---------------------------------------------------------------- company profile enrichment (services/enrichment.py) | |
| 94 | + enrich_batch: int = Field(300, alias="CA_ENRICH_BATCH") # companies per periodic pass (every 10 min) | |
| 95 | + enrich_concurrency: int = Field(4, alias="CA_ENRICH_CONCURRENCY") | |
| 96 | + enrich_refresh_days: int = Field(30, alias="CA_ENRICH_REFRESH_DAYS") # re-enrich profiles older than this | |
| 97 | + enrich_wikidata_rate_per_min: int = Field(300, alias="CA_ENRICH_WIKIDATA_RATE_PER_MIN") # ≤ 5 req/s (Wikimedia API etiquette) | |
| 98 | + enrich_wikipedia_rate_per_min: int = Field(600, alias="CA_ENRICH_WIKIPEDIA_RATE_PER_MIN") # ≤ 10 req/s | |
| 99 | + enrich_wikidata_entity_batch: int = Field(10, alias="CA_ENRICH_WIKIDATA_ENTITY_BATCH") # full entities per wbgetentities call | |
| 100 | + enrich_wikidata_label_batch: int = Field(50, alias="CA_ENRICH_WIKIDATA_LABEL_BATCH") # API maximum for labels-only lookups | |
| 101 | + enrich_wikidata_max_bytes: int = Field(48 * 1024 * 1024, alias="CA_ENRICH_WIKIDATA_MAX_BYTES") | |
| 102 | + enrich_max_relationships_per_property: int = Field(40, alias="CA_ENRICH_MAX_RELATIONSHIPS") | |
| 103 | + enrich_max_products: int = Field(12, alias="CA_ENRICH_MAX_PRODUCTS") | |
| 104 | + enrich_description_max_chars: int = Field(1500, alias="CA_ENRICH_DESCRIPTION_MAX_CHARS") | |
| 105 | + enrich_llm_min_text_chars: int = Field(400, alias="CA_ENRICH_LLM_MIN_TEXT_CHARS") | |
| 106 | + enrich_llm_max_text_chars: int = Field(6000, alias="CA_ENRICH_LLM_MAX_TEXT_CHARS") | |
| 107 | + enrich_http_timeout_s: float = Field(15.0, alias="CA_ENRICH_HTTP_TIMEOUT_S") # live homepage fetch when no snapshot exists (no retries) | |
| 108 | + enrich_llm_timeout_s: float = Field(120.0, alias="CA_ENRICH_LLM_TIMEOUT_S") # per profile call (the gateway retries inside) | |
| 109 | + enrich_llm_cooldown_s: float = Field(1800.0, alias="CA_ENRICH_LLM_COOLDOWN_S") # circuit breaker after a transport/timeout failure | |
| 110 | + | |
| 93 | 111 | # ---------------------------------------------------------------- metrics / retention |
| 94 | 112 | metrics_cron: str = Field("7 * * * *", alias="CA_METRICS_CRON") |
| 95 | 113 | daily_cron: str = Field("20 0 * * *", alias="CA_DAILY_CRON") |
added
src/companyatlas/services/enrichment.py
+1415 −0
@@ -0,0 +1,1415 @@ | ||
| 1 | +"""Company profile enrichment (reference atlas): Wikidata entity → Wikipedia summary → homepage facts → optional grounded LLM text. | |
| 2 | + | |
| 3 | +Every accepted value carries provenance `{field, source, url, retrieved_at}` and a better source is never overwritten by a weaker one | |
| 4 | +(`FIELD_RANKS`). Nothing is inferred: what a source does not state stays `null`. | |
| 5 | + | |
| 6 | + result = await enrich_company(company, fetcher=fetcher) # pure: network via `fetcher`, no writes (reads stored snapshots) | |
| 7 | + await persist(conn, company, result) # companies.source_meta.profile + column back-fills + people + relationships | |
| 8 | + await enrich_pending(limit=300, concurrency=4) # batch runner, also the periodic task `company-enrichment` | |
| 9 | + | |
| 10 | +Sources | |
| 11 | +- Wikidata `wbgetentities` (batched, JSON, labels/descriptions/claims/sitelinks) + labels-only lookups + `wbgetclaims` for country ISO | |
| 12 | + codes (P297) and currency codes (P498). Wikimedia asks API clients for a descriptive User-Agent and moderate rates, not robots.txt | |
| 13 | + (which targets page crawlers) — the API calls use `respect_robots=False` at ≤ 5 req/s (`settings.enrich_wikidata_rate_per_min`). | |
| 14 | +- Wikipedia REST `page/summary` (≤ 10 req/s): `extract` → description under CC BY-SA 4.0 with attribution URL. | |
| 15 | +- Homepage: the latest stored homepage snapshot (raw object re-parsed: meta description, JSON-LD Organization, icons) — fetched only | |
| 16 | + when no snapshot exists. Icon/logo URLs are http(s)-only and SSRF-validated. | |
| 17 | +- LLM (`llm_jobs` kind `company_profile`, medium model, budgeted): only when no Wikipedia extract exists and ≥ 400 chars of first-party | |
| 18 | + text are available; figures absent from the source text reject the output (`numbers_grounded`). | |
| 19 | + | |
| 20 | +Column back-fills (description, logo_url, hq_*, country, founded_year, employees, ticker/exchange, industries, legal_name, lei, sec_cik) | |
| 21 | +happen when the column is null or the new source outranks the recorded one (`source_meta.provenance[column]`); the replaced value is kept | |
| 22 | +under `previous`. | |
| 23 | +""" | |
| 24 | +from __future__ import annotations | |
| 25 | + | |
| 26 | +import asyncio | |
| 27 | +import contextlib | |
| 28 | +import json | |
| 29 | +import logging | |
| 30 | +import re | |
| 31 | +import time | |
| 32 | +from dataclasses import dataclass, field | |
| 33 | +from datetime import UTC, date, datetime, timedelta | |
| 34 | +from typing import Any | |
| 35 | +from urllib.parse import quote, unquote, urlencode | |
| 36 | + | |
| 37 | +from selectolax.lexbor import LexborHTMLParser | |
| 38 | + | |
| 39 | +from companyatlas import archive | |
| 40 | +from companyatlas.config import settings | |
| 41 | +from companyatlas.connectors._util import country_code, norm_name | |
| 42 | +from companyatlas.db import connection, execute, fetch_all, fetch_one, fetch_val, jsonb, transaction | |
| 43 | +from companyatlas.fetch import Fetcher, FetchError, FetchResult, decode_text, validate_destination_async | |
| 44 | +from companyatlas.ids import new_id | |
| 45 | +from companyatlas.registry.industries import is_valid_slug, map_industry | |
| 46 | +from companyatlas.sdk.normalize import extract_jsonld, normalize_whitespace | |
| 47 | +from companyatlas.sdk.normalize import parse as parse_page | |
| 48 | +from companyatlas.services.periodic import periodic | |
| 49 | +from companyatlas.taxonomy import FORBIDDEN_WORDING | |
| 50 | +from companyatlas.urls import absolutize, host_of | |
| 51 | + | |
| 52 | +log = logging.getLogger(__name__) | |
| 53 | + | |
| 54 | +PROFILE_VERSION = "profile-v1" | |
| 55 | +USER_AGENT = settings.user_agent | |
| 56 | +WIKIDATA_API = "https://www.wikidata.org/w/api.php" | |
| 57 | +WIKIDATA_ITEM = "https://www.wikidata.org/wiki/{qid}" | |
| 58 | +COMMONS_FILE = "https://commons.wikimedia.org/wiki/Special:FilePath/{name}" | |
| 59 | +WIKIPEDIA_SUMMARY = "https://{lang}.wikipedia.org/api/rest_v1/page/summary/{title}" | |
| 60 | +WIKIPEDIA_LICENSE = "CC BY-SA 4.0" | |
| 61 | +LLM_ATTRIBUTION = "Generated from the company's public pages" | |
| 62 | +HOMEPAGE_ATTRIBUTION = "Meta description of the company's homepage" | |
| 63 | +REF_CACHE_KEY = "enrichment:wikidata_refs" | |
| 64 | + | |
| 65 | +SOURCES = ("wikidata", "wikipedia", "homepage", "llm") | |
| 66 | +DEFAULT_RANKS = {"wikidata": 4, "homepage": 3, "registry": 2, "wikipedia": 1, "llm": 0} | |
| 67 | +FIELD_RANKS: dict[str, dict[str, int]] = { | |
| 68 | + "description": {"wikipedia": 4, "llm": 3, "homepage": 2, "wikidata": 1, "registry": 0}, | |
| 69 | + "logo_url": {"wikidata": 4, "homepage": 3, "registry": 2, "wikipedia": 1}, | |
| 70 | + "icon_url": {"homepage": 3}, | |
| 71 | +} | |
| 72 | +# profile field → companies column (only these are ever back-filled). | |
| 73 | +COLUMN_FIELDS: dict[str, str] = { | |
| 74 | + "description": "description", "logo_url": "logo_url", "hq_city": "hq_city", "hq_region": "hq_region", "country": "country", | |
| 75 | + "founded_year": "founded_year", "employees": "employees", "ticker": "ticker", "exchange": "exchange", "legal_name": "legal_name", | |
| 76 | + "lei": "lei", "sec_cik": "sec_cik", | |
| 77 | +} | |
| 78 | + | |
| 79 | +# Wikidata property ids used below. | |
| 80 | +P = { | |
| 81 | + "official_name": "P1448", "inception": "P571", "legal_form": "P1454", "hq": "P159", "coords": "P625", "country": "P17", | |
| 82 | + "employees": "P1128", "point_in_time": "P585", "revenue": "P2139", "net_income": "P2295", "total_assets": "P2403", | |
| 83 | + "industry": "P452", "products": "P1056", "ticker": "P249", "exchange": "P414", "isin": "P946", "lei": "P1278", "cik": "P5531", | |
| 84 | + "website": "P856", "logo": "P154", "linkedin": "P4264", "x": "P2002", "youtube": "P2397", "facebook": "P2013", "instagram": "P2003", | |
| 85 | + "github": "P2037", "tiktok": "P7085", "crunchbase": "P2088", "ceo": "P169", "chair": "P488", "founder": "P112", "key_people": "P3320", | |
| 86 | + "parent": "P749", "subsidiary": "P355", "owned_by": "P127", "owner_of": "P1830", "start": "P580", "end": "P582", | |
| 87 | + "position": "P39", "role": "P2868", "region_in": "P131", "iso2": "P297", "iso4217": "P498", | |
| 88 | +} | |
| 89 | +SOCIAL_TEMPLATES = { | |
| 90 | + "linkedin": ("P4264", "https://www.linkedin.com/company/{}"), "x": ("P2002", "https://x.com/{}"), "youtube": ("P2397", "https://www.youtube.com/channel/{}"), | |
| 91 | + "facebook": ("P2013", "https://www.facebook.com/{}"), "instagram": ("P2003", "https://www.instagram.com/{}"), "github": ("P2037", "https://github.com/{}"), | |
| 92 | + "tiktok": ("P7085", "https://www.tiktok.com/@{}"), "crunchbase": ("P2088", "https://www.crunchbase.com/organization/{}"), | |
| 93 | +} | |
| 94 | +SOCIAL_HOSTS = {"linkedin.com": "linkedin", "twitter.com": "x", "x.com": "x", "youtube.com": "youtube", "facebook.com": "facebook", | |
| 95 | + "instagram.com": "instagram", "github.com": "github", "tiktok.com": "tiktok", "crunchbase.com": "crunchbase"} | |
| 96 | +PEOPLE_ROLES = {"P169": ("Chief Executive Officer", "ceo", True), "P488": ("Chairperson", "chair", True), "P112": ("Founder", "founder", True), | |
| 97 | + "P3320": ("Key person", "other", False)} | |
| 98 | +RELATION_KINDS = {"P749": "SUBSIDIARY_OF", "P355": "PARENT_OF", "P127": "OWNED_BY", "P1830": "OWNER_OF"} | |
| 99 | +INVERSE_KIND = {"PARENT_OF": "SUBSIDIARY_OF", "SUBSIDIARY_OF": "PARENT_OF", "OWNED_BY": "OWNER_OF", "OWNER_OF": "OWNED_BY"} | |
| 100 | +# Country → Wikipedia language used when the entity has no English sitelink. | |
| 101 | +COUNTRY_LANG = {"DE": "de", "AT": "de", "CH": "de", "FR": "fr", "BE": "fr", "LU": "fr", "JP": "ja", "CN": "zh", "TW": "zh", "HK": "zh", "IT": "it", | |
| 102 | + "ES": "es", "MX": "es", "AR": "es", "CL": "es", "CO": "es", "PE": "es", "KR": "ko", "RU": "ru", "BR": "pt", "PT": "pt", "NL": "nl", | |
| 103 | + "SE": "sv", "NO": "no", "DK": "da", "FI": "fi", "PL": "pl", "TR": "tr", "ID": "id", "VN": "vi", "TH": "th", "CZ": "cs", "HU": "hu", | |
| 104 | + "GR": "el", "IL": "he", "SA": "ar", "AE": "ar", "EG": "ar", "UA": "uk", "RO": "ro", "IR": "fa", "IN": "en", "MY": "ms"} | |
| 105 | +LABEL_LANGS = ["en", "fr", "de", "es", "it", "pt", "nl", "ja", "zh", "ko", "ru", "sv", "pl", "tr", "mul"] | |
| 106 | +SITEFILTER = sorted({f"{lang}wiki" for lang in COUNTRY_LANG.values()} | {"enwiki"}) | |
| 107 | +# Common currencies (Wikidata item → ISO 4217); anything else is resolved live through P498 and cached. | |
| 108 | +CURRENCIES = {"Q4917": "USD", "Q4916": "EUR", "Q25224": "GBP", "Q8146": "JPY", "Q39099": "CNY", "Q25344": "CHF", "Q1104069": "CAD", "Q259502": "AUD", | |
| 109 | + "Q80524": "INR", "Q202040": "KRW", "Q122922": "SEK", "Q31015": "HKD", "Q190951": "SGD", "Q173117": "BRL", "Q4730": "MXN", "Q181907": "ZAR", | |
| 110 | + "Q41044": "RUB", "Q208526": "TWD", "Q132643": "NOK", "Q25417": "DKK", "Q123213": "PLN", "Q172872": "TRY", "Q41588": "IDR", "Q199109": "SAR", | |
| 111 | + "Q200294": "AED", "Q1472704": "NZD", "Q177882": "THB", "Q163712": "MYR", "Q131309": "ILS", "Q131016": "CZK", "Q47190": "HUF", | |
| 112 | + "Q17193": "PHP", "Q199462": "EGP", "Q203567": "NGN", "Q200050": "CLP", "Q244819": "COP", "Q199578": "ARS", "Q188289": "PKR", | |
| 113 | + "Q192090": "VND", "Q202714": "KES", "Q206386": "QAR", "Q319176": "KWD"} | |
| 114 | +_NUMBER_RE = re.compile(r"\d[\d,. ]*\d|\d") | |
| 115 | +_CITATION_RE = re.compile(r"\[\d+\]|\[[a-z]\]|\[citation needed\]", re.IGNORECASE) | |
| 116 | +_PAREN_PRON_RE = re.compile(r"\s*\((?:[^()]*?(?:pronounced|listen|ⓘ|/[^/()]+/)[^()]*)\)") | |
| 117 | + | |
| 118 | + | |
| 119 | +def _now() -> datetime: | |
| 120 | + return datetime.now(UTC).replace(microsecond=0) | |
| 121 | + | |
| 122 | + | |
| 123 | +def _iso(dt: datetime | None = None) -> str: | |
| 124 | + return (dt or _now()).isoformat() | |
| 125 | + | |
| 126 | + | |
| 127 | +# ================================================================================================================ profile builder | |
| 128 | + | |
| 129 | + | |
| 130 | +def empty_profile() -> dict[str, Any]: | |
| 131 | + return { | |
| 132 | + "description": None, "description_source": None, "description_url": None, "description_license": None, "description_attribution": None, | |
| 133 | + "logo_url": None, "icon_url": None, "founded_year": None, "legal_form": None, "legal_name": None, | |
| 134 | + "employees": None, "employees_year": None, "revenue": None, "net_income": None, "total_assets": None, | |
| 135 | + "hq": {"city": None, "region": None, "country": None, "address": None, "lat": None, "lon": None}, | |
| 136 | + "ticker": None, "exchange": None, "isin": None, "lei": None, "sec_cik": None, "public_company": False, | |
| 137 | + "wikipedia_url": None, "wikidata_url": None, "official_website": None, "phone": None, | |
| 138 | + "products": [], "industries": [], "industry_labels": [], "socials": {}, | |
| 139 | + "enriched_at": None, "sources": [], "version": PROFILE_VERSION, | |
| 140 | + } | |
| 141 | + | |
| 142 | + | |
| 143 | +def rank_of(field_name: str, source: str) -> int: | |
| 144 | + return FIELD_RANKS.get(field_name, DEFAULT_RANKS).get(source, DEFAULT_RANKS.get(source, 0)) | |
| 145 | + | |
| 146 | + | |
| 147 | +HQ_FIELDS = {"hq_city": "city", "hq_region": "region", "country": "country", "hq_address": "address", "hq_lat": "lat", "hq_lon": "lon"} | |
| 148 | + | |
| 149 | + | |
| 150 | +class ProfileBuilder: | |
| 151 | + """Rank-aware assignment: a field is (re)assigned only when the new source outranks the one that set it. One provenance row per field.""" | |
| 152 | + | |
| 153 | + def __init__(self, profile: dict[str, Any] | None = None) -> None: | |
| 154 | + self.profile = profile or empty_profile() | |
| 155 | + self.provenance: dict[str, dict[str, Any]] = {} | |
| 156 | + | |
| 157 | + def set(self, field_name: str, value: Any, *, source: str, url: str | None, retrieved_at: str | None = None) -> bool: | |
| 158 | + if value in (None, "", [], {}): | |
| 159 | + return False | |
| 160 | + current = self.provenance.get(field_name) | |
| 161 | + if current is not None and rank_of(field_name, source) <= rank_of(field_name, current["source"]): | |
| 162 | + return False | |
| 163 | + if field_name in HQ_FIELDS: | |
| 164 | + self.profile["hq"][HQ_FIELDS[field_name]] = value | |
| 165 | + else: | |
| 166 | + self.profile[field_name] = value | |
| 167 | + self.provenance[field_name] = {"field": field_name, "source": source, "url": url, "retrieved_at": retrieved_at or _iso()} | |
| 168 | + return True | |
| 169 | + | |
| 170 | + def get(self, field_name: str) -> Any: | |
| 171 | + if field_name in HQ_FIELDS: | |
| 172 | + return self.profile["hq"].get(HQ_FIELDS[field_name]) | |
| 173 | + return self.profile.get(field_name) | |
| 174 | + | |
| 175 | + def source_of(self, field_name: str) -> str | None: | |
| 176 | + p = self.provenance.get(field_name) | |
| 177 | + return p["source"] if p else None | |
| 178 | + | |
| 179 | + def finish(self) -> dict[str, Any]: | |
| 180 | + self.profile["public_company"] = bool(self.profile.get("public_company") or self.profile.get("ticker") or self.profile.get("isin")) | |
| 181 | + self.profile["sources"] = sorted(self.provenance.values(), key=lambda p: p["field"]) | |
| 182 | + self.profile["enriched_at"] = _iso() | |
| 183 | + return self.profile | |
| 184 | + | |
| 185 | + | |
| 186 | +def seed_from_company(builder: ProfileBuilder, company: dict[str, Any]) -> None: | |
| 187 | + """Existing columns form the base layer (source = recorded provenance or `registry`); every later source may outrank them.""" | |
| 188 | + prov = _dict(company.get("source_meta")).get("provenance") or {} | |
| 189 | + base_at = _dict(company.get("source_meta")).get("seeded_at") or _iso(company.get("created_at") if isinstance(company.get("created_at"), datetime) else None) | |
| 190 | + | |
| 191 | + def src(col: str) -> str: | |
| 192 | + return (prov.get(col) or {}).get("source") or "registry" | |
| 193 | + | |
| 194 | + for col in ("description", "logo_url", "hq_city", "hq_region", "country", "founded_year", "employees", "ticker", "exchange", "legal_name", "lei", "sec_cik"): | |
| 195 | + builder.set(col, company.get(col), source=src(col), url=(prov.get(col) or {}).get("url"), retrieved_at=base_at) | |
| 196 | + if company.get("industries"): | |
| 197 | + builder.set("industries", list(company["industries"]), source=src("industries"), url=None, retrieved_at=base_at) | |
| 198 | + labels = _dict(company.get("source_meta")).get("industry_labels") | |
| 199 | + if labels: | |
| 200 | + builder.set("industry_labels", list(labels)[:12], source="registry", url=None, retrieved_at=base_at) | |
| 201 | + if company.get("public_company"): | |
| 202 | + builder.profile["public_company"] = True | |
| 203 | + if company.get("wikidata_id"): | |
| 204 | + builder.set("wikidata_url", WIKIDATA_ITEM.format(qid=company["wikidata_id"]), source="registry", url=None, retrieved_at=base_at) | |
| 205 | + if company.get("description"): | |
| 206 | + builder.profile["description_source"] = "wikidata" if src("description") == "registry" else src("description") | |
| 207 | + | |
| 208 | + | |
| 209 | +# ================================================================================================================ Wikidata | |
| 210 | + | |
| 211 | + | |
| 212 | +def _dict(value: Any) -> dict[str, Any]: | |
| 213 | + if isinstance(value, str): | |
| 214 | + try: | |
| 215 | + value = json.loads(value) | |
| 216 | + except ValueError: | |
| 217 | + return {} | |
| 218 | + return value if isinstance(value, dict) else {} | |
| 219 | + | |
| 220 | + | |
| 221 | +def _snak_value(snak: dict[str, Any] | None) -> Any: | |
| 222 | + if not snak or snak.get("snaktype") != "value": | |
| 223 | + return None | |
| 224 | + return (snak.get("datavalue") or {}).get("value") | |
| 225 | + | |
| 226 | + | |
| 227 | +def statement_value(st: dict[str, Any]) -> Any: | |
| 228 | + return _snak_value(st.get("mainsnak")) | |
| 229 | + | |
| 230 | + | |
| 231 | +def statement_qid(st: dict[str, Any]) -> str | None: | |
| 232 | + v = statement_value(st) | |
| 233 | + return v.get("id") if isinstance(v, dict) and v.get("entity-type") == "item" else None | |
| 234 | + | |
| 235 | + | |
| 236 | +def qualifier(st: dict[str, Any], prop: str) -> Any: | |
| 237 | + snaks = (st.get("qualifiers") or {}).get(prop) or [] | |
| 238 | + return _snak_value(snaks[0]) if snaks else None | |
| 239 | + | |
| 240 | + | |
| 241 | +def wd_time(value: Any) -> tuple[int | None, date | None, int]: | |
| 242 | + """Wikidata time → (year, date-or-None, precision). Day precision (11) gives a full date, month (10) the 1st, year (9) Jan 1.""" | |
| 243 | + if not isinstance(value, dict) or not value.get("time"): | |
| 244 | + return None, None, 0 | |
| 245 | + t = str(value["time"]) | |
| 246 | + precision = int(value.get("precision") or 9) | |
| 247 | + m = re.match(r"^([+-])(\d+)-(\d\d)-(\d\d)T", t) | |
| 248 | + if not m: | |
| 249 | + return None, None, precision | |
| 250 | + sign, y, mo, d = m.groups() | |
| 251 | + year = int(y) * (-1 if sign == "-" else 1) | |
| 252 | + if year <= 0 or precision < 9: | |
| 253 | + return (year if year > 0 else None), None, precision | |
| 254 | + try: | |
| 255 | + dt = date(year, int(mo) if precision >= 10 and int(mo) else 1, int(d) if precision >= 11 and int(d) else 1) | |
| 256 | + except ValueError: | |
| 257 | + dt = None | |
| 258 | + return year, dt, precision | |
| 259 | + | |
| 260 | + | |
| 261 | +def wd_quantity(value: Any) -> tuple[float | None, str | None]: | |
| 262 | + if not isinstance(value, dict) or value.get("amount") is None: | |
| 263 | + return None, None | |
| 264 | + try: | |
| 265 | + amount = float(str(value["amount"]).replace("+", "")) | |
| 266 | + except ValueError: | |
| 267 | + return None, None | |
| 268 | + unit = str(value.get("unit") or "1") | |
| 269 | + return amount, (unit.rsplit("/", 1)[-1] if unit.startswith("http") else None) | |
| 270 | + | |
| 271 | + | |
| 272 | +def _end_date(st: dict[str, Any]) -> date | None: | |
| 273 | + _y, d, _p = wd_time(qualifier(st, P["end"])) | |
| 274 | + return d | |
| 275 | + | |
| 276 | + | |
| 277 | +def _start_date(st: dict[str, Any]) -> date | None: | |
| 278 | + _y, d, _p = wd_time(qualifier(st, P["start"])) | |
| 279 | + return d | |
| 280 | + | |
| 281 | + | |
| 282 | +def is_current(st: dict[str, Any], today: date | None = None) -> bool: | |
| 283 | + end = _end_date(st) | |
| 284 | + return end is None or end > (today or _now().date()) | |
| 285 | + | |
| 286 | + | |
| 287 | +def claims(entity: dict[str, Any], prop: str, *, current_only: bool = False) -> list[dict[str, Any]]: | |
| 288 | + """Statements for `prop`: deprecated dropped, preferred first, then current (no end date) before ended; Wikidata's order otherwise.""" | |
| 289 | + out = [s for s in (entity.get("claims") or {}).get(prop) or [] if s.get("rank") != "deprecated" and statement_value(s) is not None] | |
| 290 | + if current_only: | |
| 291 | + out = [s for s in out if is_current(s)] | |
| 292 | + return sorted(out, key=lambda s: (s.get("rank") != "preferred", not is_current(s))) | |
| 293 | + | |
| 294 | + | |
| 295 | +def latest_quantity(entity: dict[str, Any], prop: str) -> tuple[float, str | None, int | None] | None: | |
| 296 | + """(amount, unit qid, year) for the observation with the most recent point in time (P585); preferred rank wins ties.""" | |
| 297 | + best: tuple[tuple[int, int], float, str | None, int | None] | None = None | |
| 298 | + for st in claims(entity, prop): | |
| 299 | + amount, unit = wd_quantity(statement_value(st)) | |
| 300 | + if amount is None: | |
| 301 | + continue | |
| 302 | + year, _d, _p = wd_time(qualifier(st, P["point_in_time"])) | |
| 303 | + key = (year or 0, 1 if st.get("rank") == "preferred" else 0) | |
| 304 | + if best is None or key > best[0]: | |
| 305 | + best = (key, amount, unit, year) | |
| 306 | + return (best[1], best[2], best[3]) if best else None | |
| 307 | + | |
| 308 | + | |
| 309 | +def commons_url(filename: str) -> str: | |
| 310 | + return COMMONS_FILE.format(name=quote(filename.strip().replace(" ", "_"), safe="")) | |
| 311 | + | |
| 312 | + | |
| 313 | +def wikipedia_url(lang: str, title: str) -> str: | |
| 314 | + return f"https://{lang}.wikipedia.org/wiki/{quote(title.replace(' ', '_'), safe=':()/,')}" | |
| 315 | + | |
| 316 | + | |
| 317 | +class WikidataClient: | |
| 318 | + """Batched read access to the Wikidata API (labels cached for the process; country/currency codes cached in `settings_kv`).""" | |
| 319 | + | |
| 320 | + def __init__(self, fetcher: Any, *, refs: dict[str, Any] | None = None) -> None: | |
| 321 | + self.fetcher = fetcher | |
| 322 | + self.labels_cache: dict[str, str | None] = {} | |
| 323 | + self.descriptions_cache: dict[str, str | None] = {} | |
| 324 | + self.refs: dict[str, Any] = refs if refs is not None else {} # qid → {"iso2": …} / {"iso4217": …} | |
| 325 | + self.requests = 0 | |
| 326 | + | |
| 327 | + # ---------------------------------------------------------------------------------------------- transport | |
| 328 | + @staticmethod | |
| 329 | + def url(params: dict[str, str]) -> str: | |
| 330 | + return WIKIDATA_API + "?" + urlencode({"format": "json", **params}) | |
| 331 | + | |
| 332 | + async def _get(self, url: str, *, max_bytes: int | None = None) -> dict[str, Any] | None: | |
| 333 | + self.requests += 1 | |
| 334 | + try: | |
| 335 | + res: FetchResult = await self.fetcher.get(url, accept="application/json", rate_per_min=settings.enrich_wikidata_rate_per_min, | |
| 336 | + respect_robots=False, max_bytes=max_bytes or settings.enrich_wikidata_max_bytes) | |
| 337 | + except FetchError as exc: | |
| 338 | + log.warning("wikidata request failed", extra={"url": url[:200], "error": str(exc)[:200]}) | |
| 339 | + return None | |
| 340 | + try: | |
| 341 | + data = res.json() | |
| 342 | + except ValueError: | |
| 343 | + return None | |
| 344 | + return data if isinstance(data, dict) else None | |
| 345 | + | |
| 346 | + # ---------------------------------------------------------------------------------------------- entities / labels | |
| 347 | + @staticmethod | |
| 348 | + def entities_url(qids: list[str]) -> str: | |
| 349 | + return WikidataClient.url({"action": "wbgetentities", "ids": "|".join(qids), "props": "labels|descriptions|claims|sitelinks", | |
| 350 | + "languages": "|".join(LABEL_LANGS), "sitefilter": "|".join(SITEFILTER)}) | |
| 351 | + | |
| 352 | + @staticmethod | |
| 353 | + def labels_url(qids: list[str]) -> str: | |
| 354 | + return WikidataClient.url({"action": "wbgetentities", "ids": "|".join(qids), "props": "labels|descriptions", "languages": "|".join(LABEL_LANGS)}) | |
| 355 | + | |
| 356 | + @staticmethod | |
| 357 | + def claims_url(qid: str, prop: str) -> str: | |
| 358 | + return WikidataClient.url({"action": "wbgetclaims", "entity": qid, "property": prop, "props": ""}) | |
| 359 | + | |
| 360 | + async def entities(self, qids: list[str]) -> dict[str, dict[str, Any]]: | |
| 361 | + out: dict[str, dict[str, Any]] = {} | |
| 362 | + ids = list(dict.fromkeys(q for q in qids if q)) | |
| 363 | + size = max(1, settings.enrich_wikidata_entity_batch) | |
| 364 | + for i in range(0, len(ids), size): | |
| 365 | + chunk = ids[i:i + size] | |
| 366 | + data = await self._get(self.entities_url(chunk)) | |
| 367 | + if data is None and len(chunk) > 1: # too large / transient → one by one | |
| 368 | + for q in chunk: | |
| 369 | + single = await self._get(self.entities_url([q])) | |
| 370 | + out.update({k: v for k, v in ((single or {}).get("entities") or {}).items() if "missing" not in v}) | |
| 371 | + continue | |
| 372 | + out.update({k: v for k, v in ((data or {}).get("entities") or {}).items() if "missing" not in v}) | |
| 373 | + for ent in out.values(): | |
| 374 | + lab = _pick_label(ent.get("labels") or {}) | |
| 375 | + if lab: | |
| 376 | + self.labels_cache[ent["id"]] = lab | |
| 377 | + self.descriptions_cache.setdefault(ent["id"], ((ent.get("descriptions") or {}).get("en") or {}).get("value")) | |
| 378 | + return out | |
| 379 | + | |
| 380 | + async def labels(self, qids: list[str]) -> dict[str, str]: | |
| 381 | + missing = list(dict.fromkeys(q for q in qids if q and q not in self.labels_cache)) | |
| 382 | + size = max(1, min(50, settings.enrich_wikidata_label_batch)) | |
| 383 | + for i in range(0, len(missing), size): | |
| 384 | + chunk = missing[i:i + size] | |
| 385 | + data = await self._get(self.labels_url(chunk), max_bytes=settings.max_body_bytes) | |
| 386 | + for q in chunk: | |
| 387 | + ent = ((data or {}).get("entities") or {}).get(q) or {} | |
| 388 | + self.labels_cache[q] = _pick_label(ent.get("labels") or {}) | |
| 389 | + self.descriptions_cache[q] = ((ent.get("descriptions") or {}).get("en") or {}).get("value") | |
| 390 | + return {q: lab for q in qids if (lab := self.labels_cache.get(q))} | |
| 391 | + | |
| 392 | + def description_of(self, qid: str) -> str | None: | |
| 393 | + return self.descriptions_cache.get(qid) | |
| 394 | + | |
| 395 | + async def claim_strings(self, qid: str, prop: str) -> list[str]: | |
| 396 | + data = await self._get(self.claims_url(qid, prop), max_bytes=settings.max_body_bytes) | |
| 397 | + out: list[str] = [] | |
| 398 | + for st in ((data or {}).get("claims") or {}).get(prop) or []: | |
| 399 | + v = statement_value(st) | |
| 400 | + if isinstance(v, str) and st.get("rank") != "deprecated": | |
| 401 | + out.append(v) | |
| 402 | + return out | |
| 403 | + | |
| 404 | + async def country_iso(self, qid: str | None) -> str | None: | |
| 405 | + if not qid: | |
| 406 | + return None | |
| 407 | + ref = self.refs.get(qid) | |
| 408 | + if ref and "iso2" in ref: | |
| 409 | + return ref["iso2"] | |
| 410 | + codes = [c for c in await self.claim_strings(qid, P["iso2"]) if len(c) == 2 and c.isalpha()] | |
| 411 | + code = codes[0].upper() if codes else None | |
| 412 | + self.refs[qid] = {**(ref or {}), "iso2": code} | |
| 413 | + return code | |
| 414 | + | |
| 415 | + async def currency_code(self, qid: str | None) -> str | None: | |
| 416 | + if not qid: | |
| 417 | + return None | |
| 418 | + if qid in CURRENCIES: | |
| 419 | + return CURRENCIES[qid] | |
| 420 | + ref = self.refs.get(qid) | |
| 421 | + if ref and "iso4217" in ref: | |
| 422 | + return ref["iso4217"] | |
| 423 | + codes = [c for c in await self.claim_strings(qid, P["iso4217"]) if len(c) == 3 and c.isalpha()] | |
| 424 | + code = codes[0].upper() if codes else None | |
| 425 | + self.refs[qid] = {**(ref or {}), "iso4217": code} | |
| 426 | + return code | |
| 427 | + | |
| 428 | + | |
| 429 | +LATIN_LANGS = ("en", "mul", "fr", "de", "es", "it", "pt", "nl", "sv", "pl", "tr") | |
| 430 | + | |
| 431 | + | |
| 432 | +def _pick_label(labels: dict[str, Any]) -> str | None: | |
| 433 | + """English first, then the multilingual label, then Latin-script languages, then anything (a Japanese-only item keeps its Japanese name).""" | |
| 434 | + for lang in (*LATIN_LANGS, *LABEL_LANGS): | |
| 435 | + v = labels.get(lang) | |
| 436 | + if isinstance(v, dict) and v.get("value"): | |
| 437 | + return str(v["value"]) | |
| 438 | + for v in labels.values(): | |
| 439 | + if isinstance(v, dict) and v.get("value"): | |
| 440 | + return str(v["value"]) | |
| 441 | + return None | |
| 442 | + | |
| 443 | + | |
| 444 | +# Relationship targets (P355 subsidiaries, P1830 "owner of") are kept only when their English description reads like an organisation; | |
| 445 | +# Wikidata lists domains, buildings, fonts and apps under "owner of". | |
| 446 | +COMPANY_WORDS = re.compile(r"\b(compan(y|ies)|corporation|subsidiar(y|ies)|business|enterprise|manufacturer|bank|airline|firm|holding|startup|start-up|" | |
| 447 | + r"developer|publisher|studio|retailer|provider|provides|services|operator|conglomerate|group|agency|label|network|carrier|brewery|" | |
| 448 | + r"insurer|utility|railway|shipyard|automaker|chain|organi[sz]ation|joint venture|division|venture|fund|institution|cooperative|" | |
| 449 | + r"maker|producer|distributor|supplier|vendor|consultancy|consulting|contractor|lender|broker|marketplace|team)\b", re.IGNORECASE) | |
| 450 | +NON_COMPANY_WORDS = re.compile(r"\b(domain|top-level|building|skyscraper|font|typeface|software|website|web service|application|app|programming language|" | |
| 451 | + r"file format|protocol|operating system|video game|film|album|song|book|magazine|television|product|device|smartphone|laptop|" | |
| 452 | + r"car model|aircraft|satellite|rocket|street|campus|headquarters|stadium|hotel|data center|datacenter|patent|trademark|logo|" | |
| 453 | + r"mascot|character|person|human|browser|search engine|brand of|line of|series of|technology|feature)\b", re.IGNORECASE) | |
| 454 | + | |
| 455 | + | |
| 456 | +def looks_like_organisation(description: str | None, *, default: bool) -> bool: | |
| 457 | + if not description: | |
| 458 | + return default | |
| 459 | + if COMPANY_WORDS.search(description): | |
| 460 | + return True | |
| 461 | + if NON_COMPANY_WORDS.search(description): | |
| 462 | + return False | |
| 463 | + return default | |
| 464 | + | |
| 465 | + | |
| 466 | +@dataclass | |
| 467 | +class PersonFact: | |
| 468 | + name: str | |
| 469 | + title: str | |
| 470 | + role_category: str | |
| 471 | + is_executive: bool | |
| 472 | + status: str # listed | no_longer_listed | |
| 473 | + valid_from: date | None | |
| 474 | + valid_to: date | None | |
| 475 | + source_url: str | |
| 476 | + qid: str | None = None | |
| 477 | + | |
| 478 | + | |
| 479 | +@dataclass | |
| 480 | +class RelationshipFact: | |
| 481 | + kind: str # PARENT_OF | SUBSIDIARY_OF | OWNED_BY | OWNER_OF | |
| 482 | + to_qid: str | |
| 483 | + to_name: str | None | |
| 484 | + valid_from: date | None | |
| 485 | + valid_to: date | None | |
| 486 | + property: str | |
| 487 | + source_url: str | |
| 488 | + | |
| 489 | + | |
| 490 | +@dataclass | |
| 491 | +class EnrichmentResult: | |
| 492 | + company_id: str | |
| 493 | + profile: dict[str, Any] | |
| 494 | + provenance: dict[str, dict[str, Any]] | |
| 495 | + people: list[PersonFact] = field(default_factory=list) | |
| 496 | + relationships: list[RelationshipFact] = field(default_factory=list) | |
| 497 | + column_updates: dict[str, Any] = field(default_factory=dict) | |
| 498 | + industries: list[str] = field(default_factory=list) | |
| 499 | + sources_used: list[str] = field(default_factory=list) | |
| 500 | + errors: list[str] = field(default_factory=list) | |
| 501 | + llm_job_id: str | None = None | |
| 502 | + | |
| 503 | + def summary(self) -> dict[str, Any]: | |
| 504 | + return {"company_id": self.company_id, "sources": self.sources_used, "people": len(self.people), "relationships": len(self.relationships), | |
| 505 | + "columns": sorted(self.column_updates), "description_source": self.profile.get("description_source"), "errors": self.errors} | |
| 506 | + | |
| 507 | + | |
| 508 | +def _role_for(title: str) -> tuple[str, bool]: | |
| 509 | + try: | |
| 510 | + from companyatlas.connectors.generic_html import role_category | |
| 511 | + | |
| 512 | + return role_category(title) | |
| 513 | + except Exception: # noqa: BLE001 — the connector module may be mid-edit; the profile must not depend on it | |
| 514 | + return "other", False | |
| 515 | + | |
| 516 | + | |
| 517 | +async def apply_wikidata(builder: ProfileBuilder, entity: dict[str, Any], wd: WikidataClient, *, country_hint: str | None) -> tuple[list[PersonFact], list[RelationshipFact]]: | |
| 518 | + """Map a Wikidata entity onto the profile; returns people and relationship facts (names resolved through label lookups).""" | |
| 519 | + qid = entity["id"] | |
| 520 | + url = WIKIDATA_ITEM.format(qid=qid) | |
| 521 | + at = _iso() | |
| 522 | + src = "wikidata" | |
| 523 | + builder.set("wikidata_url", url, source=src, url=url, retrieved_at=at) | |
| 524 | + labels_wanted: list[str] = [] | |
| 525 | + | |
| 526 | + def want(q: str | None) -> None: | |
| 527 | + if q: | |
| 528 | + labels_wanted.append(q) | |
| 529 | + | |
| 530 | + # scalar facts ------------------------------------------------------------------------------------------- | |
| 531 | + names = [v for st in claims(entity, P["official_name"], current_only=True) if isinstance(v := statement_value(st), dict) and v.get("text")] | |
| 532 | + official = next((v for v in names if v.get("language") in ("en", "mul")), names[0] if names else None) | |
| 533 | + if official: | |
| 534 | + builder.set("legal_name", normalize_whitespace(official["text"])[:200], source=src, url=url, retrieved_at=at) | |
| 535 | + for st in claims(entity, P["inception"]): | |
| 536 | + year, _d, _p = wd_time(statement_value(st)) | |
| 537 | + if year: | |
| 538 | + builder.set("founded_year", year, source=src, url=url, retrieved_at=at) | |
| 539 | + break | |
| 540 | + legal_form = next((statement_qid(s) for s in claims(entity, P["legal_form"], current_only=True)), None) | |
| 541 | + want(legal_form) | |
| 542 | + hq_st = next(iter(claims(entity, P["hq"], current_only=True)), None) | |
| 543 | + hq_qid = statement_qid(hq_st) if hq_st else None | |
| 544 | + want(hq_qid) | |
| 545 | + hq_country = qualifier(hq_st, P["country"]) if hq_st else None | |
| 546 | + hq_country_qid = hq_country.get("id") if isinstance(hq_country, dict) else None | |
| 547 | + region_qid = None | |
| 548 | + if hq_st and isinstance(qualifier(hq_st, P["region_in"]), dict): | |
| 549 | + region_qid = qualifier(hq_st, P["region_in"]).get("id") | |
| 550 | + want(region_qid) | |
| 551 | + coords = qualifier(hq_st, P["coords"]) if hq_st else None | |
| 552 | + if isinstance(coords, dict) and coords.get("latitude") is not None: | |
| 553 | + builder.set("hq_lat", round(float(coords["latitude"]), 5), source=src, url=url, retrieved_at=at) | |
| 554 | + builder.set("hq_lon", round(float(coords["longitude"]), 5), source=src, url=url, retrieved_at=at) | |
| 555 | + country_qid = next((statement_qid(s) for s in claims(entity, P["country"], current_only=True)), None) or hq_country_qid | |
| 556 | + emp = latest_quantity(entity, P["employees"]) | |
| 557 | + if emp and emp[0] > 0: | |
| 558 | + builder.set("employees", round(emp[0]), source=src, url=url, retrieved_at=at) | |
| 559 | + if emp[2]: | |
| 560 | + builder.set("employees_year", emp[2], source=src, url=url, retrieved_at=at) | |
| 561 | + money: dict[str, tuple[float, str | None, int | None]] = {} | |
| 562 | + for key in ("revenue", "net_income", "total_assets"): | |
| 563 | + q = latest_quantity(entity, P[key]) | |
| 564 | + if q: | |
| 565 | + money[key] = q | |
| 566 | + industry_qids = [statement_qid(s) for s in claims(entity, P["industry"]) if statement_qid(s)] | |
| 567 | + product_qids = [statement_qid(s) for s in claims(entity, P["products"]) if statement_qid(s)][: settings.enrich_max_products] | |
| 568 | + for q in industry_qids + product_qids: | |
| 569 | + want(q) | |
| 570 | + exchange_st = next(iter(claims(entity, P["exchange"], current_only=True)), None) | |
| 571 | + exchange_qid = statement_qid(exchange_st) if exchange_st else None | |
| 572 | + want(exchange_qid) | |
| 573 | + ticker = next((statement_value(s) for s in claims(entity, P["ticker"], current_only=True) if isinstance(statement_value(s), str)), None) | |
| 574 | + if not ticker and exchange_st and isinstance(qualifier(exchange_st, P["ticker"]), str): | |
| 575 | + ticker = qualifier(exchange_st, P["ticker"]) | |
| 576 | + if ticker: | |
| 577 | + builder.set("ticker", ticker.strip()[:20], source=src, url=url, retrieved_at=at) | |
| 578 | + for key, prop in (("isin", "isin"), ("lei", "lei"), ("sec_cik", "cik")): | |
| 579 | + v = next((statement_value(s) for s in claims(entity, P[prop], current_only=True) if isinstance(statement_value(s), str)), None) | |
| 580 | + if v: | |
| 581 | + builder.set(key, v.strip()[:40], source=src, url=url, retrieved_at=at) | |
| 582 | + site = next((statement_value(s) for s in claims(entity, P["website"], current_only=True) if isinstance(statement_value(s), str)), None) | |
| 583 | + if site and site.startswith(("http://", "https://")): | |
| 584 | + builder.set("official_website", site.strip()[:300], source=src, url=url, retrieved_at=at) | |
| 585 | + logo = next((statement_value(s) for s in claims(entity, P["logo"], current_only=True) if isinstance(statement_value(s), str)), None) | |
| 586 | + if logo: | |
| 587 | + builder.set("logo_url", commons_url(logo), source=src, url=url, retrieved_at=at) | |
| 588 | + socials: dict[str, str] = {} | |
| 589 | + for key, (prop, template) in SOCIAL_TEMPLATES.items(): | |
| 590 | + handle = next((statement_value(s) for s in claims(entity, prop, current_only=True) if isinstance(statement_value(s), str)), None) | |
| 591 | + if handle: | |
| 592 | + socials[key] = template.format(quote(handle.strip(), safe="@/")) | |
| 593 | + if socials: | |
| 594 | + builder.set("socials", socials, source=src, url=url, retrieved_at=at) | |
| 595 | + desc = ((entity.get("descriptions") or {}).get("en") or {}).get("value") | |
| 596 | + if desc and builder.get("description") is None: | |
| 597 | + builder.set("description", normalize_whitespace(desc)[:300], source=src, url=url, retrieved_at=at) | |
| 598 | + builder.profile["description_source"] = "wikidata" | |
| 599 | + builder.profile["description_url"] = url | |
| 600 | + sitelinks = entity.get("sitelinks") or {} | |
| 601 | + lang = "en" if "enwiki" in sitelinks else COUNTRY_LANG.get(country_hint or "", None) | |
| 602 | + if lang and f"{lang}wiki" in sitelinks: | |
| 603 | + builder.set("wikipedia_url", wikipedia_url(lang, sitelinks[f"{lang}wiki"]["title"]), source=src, url=url, retrieved_at=at) | |
| 604 | + elif sitelinks: | |
| 605 | + first_site = next((s for s in ("enwiki", *SITEFILTER) if s in sitelinks), None) | |
| 606 | + if first_site: | |
| 607 | + builder.set("wikipedia_url", wikipedia_url(first_site.removesuffix("wiki"), sitelinks[first_site]["title"]), source=src, url=url, retrieved_at=at) | |
| 608 | + | |
| 609 | + # people / relationships (QIDs now, labels below) --------------------------------------------------------- | |
| 610 | + raw_people: list[tuple[str, str, dict[str, Any]]] = [] # (qid, prop, statement) | |
| 611 | + for prop in PEOPLE_ROLES: | |
| 612 | + for st in claims(entity, prop): | |
| 613 | + pq = statement_qid(st) | |
| 614 | + if pq: | |
| 615 | + raw_people.append((pq, prop, st)) | |
| 616 | + want(pq) | |
| 617 | + role_q = qualifier(st, P["position"]) or qualifier(st, P["role"]) | |
| 618 | + if isinstance(role_q, dict): | |
| 619 | + want(role_q.get("id")) | |
| 620 | + raw_rel: list[tuple[str, str, dict[str, Any]]] = [] | |
| 621 | + cap = settings.enrich_max_relationships_per_property | |
| 622 | + for prop in RELATION_KINDS: | |
| 623 | + for st in claims(entity, prop)[:cap]: | |
| 624 | + rq = statement_qid(st) | |
| 625 | + if rq and rq != qid: | |
| 626 | + raw_rel.append((rq, prop, st)) | |
| 627 | + want(rq) | |
| 628 | + | |
| 629 | + # resolve labels + reference codes ------------------------------------------------------------------------ | |
| 630 | + labels = await wd.labels(labels_wanted) | |
| 631 | + if legal_form and labels.get(legal_form): | |
| 632 | + builder.set("legal_form", labels[legal_form][:120], source=src, url=url, retrieved_at=at) | |
| 633 | + if hq_qid and labels.get(hq_qid): | |
| 634 | + builder.set("hq_city", labels[hq_qid][:120], source=src, url=url, retrieved_at=at) | |
| 635 | + if region_qid and labels.get(region_qid): | |
| 636 | + builder.set("hq_region", labels[region_qid][:120], source=src, url=url, retrieved_at=at) | |
| 637 | + iso2 = await wd.country_iso(country_qid) | |
| 638 | + if iso2: | |
| 639 | + builder.set("country", iso2, source=src, url=url, retrieved_at=at) | |
| 640 | + for key, (amount, unit_qid, year) in money.items(): | |
| 641 | + currency = await wd.currency_code(unit_qid) | |
| 642 | + if currency and year: | |
| 643 | + builder.set(key, {"value": amount, "currency": currency, "year": year}, source=src, url=url, retrieved_at=at) | |
| 644 | + ind_labels = [labels[q] for q in industry_qids if labels.get(q)] | |
| 645 | + if ind_labels: | |
| 646 | + builder.set("industry_labels", ind_labels[:12], source=src, url=url, retrieved_at=at) | |
| 647 | + slugs = [s for s in map_industry(ind_labels, limit=6) if is_valid_slug(s)] | |
| 648 | + if slugs: | |
| 649 | + builder.set("industries", slugs, source=src, url=url, retrieved_at=at) | |
| 650 | + prods = [labels[q] for q in product_qids if labels.get(q)] | |
| 651 | + if prods: | |
| 652 | + builder.set("products", prods, source=src, url=url, retrieved_at=at) | |
| 653 | + if exchange_qid and labels.get(exchange_qid): | |
| 654 | + builder.set("exchange", labels[exchange_qid][:80], source=src, url=url, retrieved_at=at) | |
| 655 | + | |
| 656 | + today = _now().date() | |
| 657 | + people: dict[str, PersonFact] = {} | |
| 658 | + for pq, prop, st in raw_people: | |
| 659 | + name = labels.get(pq) | |
| 660 | + if not name: | |
| 661 | + continue | |
| 662 | + title, cat, is_exec = PEOPLE_ROLES[prop] | |
| 663 | + if prop == P["key_people"]: | |
| 664 | + role_q = qualifier(st, P["position"]) or qualifier(st, P["role"]) | |
| 665 | + role_label = labels.get(role_q.get("id")) if isinstance(role_q, dict) else None | |
| 666 | + if role_label: | |
| 667 | + title = role_label[:160] | |
| 668 | + cat, is_exec = _role_for(role_label) | |
| 669 | + start, end = _start_date(st), _end_date(st) | |
| 670 | + status = "no_longer_listed" if end is not None and end <= today else "listed" | |
| 671 | + fact = PersonFact(name=name[:200], title=title, role_category=cat, is_executive=is_exec, status=status, valid_from=start, valid_to=end, | |
| 672 | + source_url=url, qid=pq) | |
| 673 | + prev = people.get(pq) | |
| 674 | + if prev is None or (prev.status != "listed" and status == "listed") or (prev.status == status and prev.role_category == "other" and cat != "other"): | |
| 675 | + people[pq] = fact | |
| 676 | + relationships: list[RelationshipFact] = [] | |
| 677 | + for rq, prop, st in raw_rel: | |
| 678 | + if prop in (P["subsidiary"], P["owner_of"]) and not looks_like_organisation(wd.description_of(rq), default=prop == P["subsidiary"]): | |
| 679 | + continue | |
| 680 | + relationships.append(RelationshipFact(kind=RELATION_KINDS[prop], to_qid=rq, to_name=(labels.get(rq) or None), valid_from=_start_date(st), | |
| 681 | + valid_to=_end_date(st), property=prop, source_url=url)) | |
| 682 | + return list(people.values()), relationships | |
| 683 | + | |
| 684 | + | |
| 685 | +# ================================================================================================================ Wikipedia | |
| 686 | + | |
| 687 | + | |
| 688 | +def clean_extract(text: str, *, max_chars: int | None = None) -> str | None: | |
| 689 | + limit = max_chars or settings.enrich_description_max_chars | |
| 690 | + t = _CITATION_RE.sub("", text or "") | |
| 691 | + t = _PAREN_PRON_RE.sub("", t) | |
| 692 | + paragraphs = [normalize_whitespace(p) for p in re.split(r"\n{1,}", t) if normalize_whitespace(p)] | |
| 693 | + out = " ".join(paragraphs[:3]) | |
| 694 | + if len(out) > limit: | |
| 695 | + cut = out[:limit] | |
| 696 | + end = max(cut.rfind(". "), cut.rfind("! "), cut.rfind("? ")) | |
| 697 | + out = (cut[: end + 1] if end > limit // 2 else cut.rstrip() + "…") | |
| 698 | + return out or None | |
| 699 | + | |
| 700 | + | |
| 701 | +async def fetch_wikipedia_summary(fetcher: Any, lang: str, title: str) -> dict[str, Any] | None: | |
| 702 | + url = WIKIPEDIA_SUMMARY.format(lang=lang, title=quote(title.replace(" ", "_"), safe="")) | |
| 703 | + try: | |
| 704 | + res = await fetcher.get(url, accept="application/json", rate_per_min=settings.enrich_wikipedia_rate_per_min, respect_robots=False) | |
| 705 | + data = res.json() | |
| 706 | + except (FetchError, ValueError) as exc: | |
| 707 | + log.info("wikipedia summary unavailable", extra={"url": url, "error": str(exc)[:160]}) | |
| 708 | + return None | |
| 709 | + return data if isinstance(data, dict) and data.get("type") not in ("disambiguation",) else None | |
| 710 | + | |
| 711 | + | |
| 712 | +def apply_wikipedia(builder: ProfileBuilder, summary: dict[str, Any]) -> bool: | |
| 713 | + extract = clean_extract(summary.get("extract") or "") | |
| 714 | + page_url = ((summary.get("content_urls") or {}).get("desktop") or {}).get("page") or builder.get("wikipedia_url") | |
| 715 | + at = _iso() | |
| 716 | + changed = False | |
| 717 | + if extract and len(extract) >= 40 and builder.set("description", extract, source="wikipedia", url=page_url, retrieved_at=at): | |
| 718 | + builder.profile.update({"description_source": "wikipedia", "description_url": page_url, "description_license": WIKIPEDIA_LICENSE, | |
| 719 | + "description_attribution": f"Text from Wikipedia ({summary.get('lang') or 'en'}), {WIKIPEDIA_LICENSE}"}) | |
| 720 | + changed = True | |
| 721 | + if page_url: | |
| 722 | + builder.set("wikipedia_url", page_url, source="wikipedia", url=page_url, retrieved_at=at) | |
| 723 | + thumb = (summary.get("thumbnail") or {}).get("source") | |
| 724 | + if thumb and str(thumb).startswith("https://") and builder.get("logo_url") is None: | |
| 725 | + builder.set("logo_url", str(thumb).split("?", 1)[0], source="wikipedia", url=page_url, retrieved_at=at) | |
| 726 | + return changed | |
| 727 | + | |
| 728 | + | |
| 729 | +# ================================================================================================================ homepage | |
| 730 | + | |
| 731 | + | |
| 732 | +@dataclass | |
| 733 | +class HomepageFacts: | |
| 734 | + url: str | |
| 735 | + description: str | None = None | |
| 736 | + legal_name: str | None = None | |
| 737 | + name: str | None = None | |
| 738 | + logo: str | None = None | |
| 739 | + icon: str | None = None | |
| 740 | + founded_year: int | None = None | |
| 741 | + employees: int | None = None | |
| 742 | + address: str | None = None | |
| 743 | + city: str | None = None | |
| 744 | + region: str | None = None | |
| 745 | + country: str | None = None | |
| 746 | + phone: str | None = None | |
| 747 | + socials: dict[str, str] = field(default_factory=dict) | |
| 748 | + | |
| 749 | + | |
| 750 | +def _first_str(value: Any) -> str | None: | |
| 751 | + if isinstance(value, list): | |
| 752 | + value = value[0] if value else None | |
| 753 | + if isinstance(value, dict): | |
| 754 | + value = value.get("url") or value.get("contentUrl") or value.get("@id") or value.get("name") | |
| 755 | + return normalize_whitespace(str(value)) if isinstance(value, str | int | float) and str(value).strip() else None | |
| 756 | + | |
| 757 | + | |
| 758 | +def _icon_size(node: Any) -> int: | |
| 759 | + sizes = (node.attributes.get("sizes") or "").lower() | |
| 760 | + m = re.search(r"(\d+)x(\d+)", sizes) | |
| 761 | + return int(m.group(1)) if m else (180 if "apple" in (node.attributes.get("rel") or "").lower() else 32) | |
| 762 | + | |
| 763 | + | |
| 764 | +def parse_homepage(html: str, url: str) -> HomepageFacts: | |
| 765 | + """Meta description / og:description, JSON-LD Organization (name, legalName, logo, foundingDate, numberOfEmployees, address, | |
| 766 | + telephone, sameAs), og:image → icon candidates (apple-touch-icon > largest icon > og:image).""" | |
| 767 | + facts = HomepageFacts(url=url) | |
| 768 | + tree = LexborHTMLParser(html) | |
| 769 | + metas: dict[str, str] = {} | |
| 770 | + for m in tree.css("meta"): | |
| 771 | + name = (m.attributes.get("name") or m.attributes.get("property") or "").lower().strip() | |
| 772 | + content = (m.attributes.get("content") or "").strip() | |
| 773 | + if name and content and name not in metas: | |
| 774 | + metas[name] = content | |
| 775 | + desc = metas.get("description") or metas.get("og:description") or metas.get("twitter:description") | |
| 776 | + if desc and len(normalize_whitespace(desc)) >= 40: | |
| 777 | + facts.description = normalize_whitespace(desc)[:600] | |
| 778 | + icons: list[tuple[int, str]] = [] | |
| 779 | + for ln in tree.css("link[rel]"): | |
| 780 | + rel = (ln.attributes.get("rel") or "").lower() | |
| 781 | + href = ln.attributes.get("href") or "" | |
| 782 | + if "icon" not in rel or not href: | |
| 783 | + continue | |
| 784 | + absu = absolutize(url, href) | |
| 785 | + if absu: | |
| 786 | + icons.append((_icon_size(ln) + (1000 if "apple" in rel else 0), absu)) | |
| 787 | + og_image = absolutize(url, metas.get("og:image") or "") if metas.get("og:image") else None | |
| 788 | + if icons: | |
| 789 | + facts.icon = max(icons)[1] | |
| 790 | + elif og_image: | |
| 791 | + facts.icon = og_image | |
| 792 | + domain = host_of(url).removeprefix("www.") | |
| 793 | + orgs = extract_jsonld(tree).get("organizations") or [] | |
| 794 | + org = next((o for o in orgs if domain and domain in str(o.get("url") or "").lower()), orgs[0] if orgs else None) | |
| 795 | + if org: | |
| 796 | + facts.name = _first_str(org.get("name")) | |
| 797 | + facts.legal_name = _first_str(org.get("legalName")) | |
| 798 | + logo = _first_str(org.get("logo")) or _first_str(org.get("image")) | |
| 799 | + facts.logo = absolutize(url, logo) if logo else None | |
| 800 | + fd = _first_str(org.get("foundingDate")) | |
| 801 | + if fd and re.match(r"^\d{4}", fd): | |
| 802 | + facts.founded_year = int(fd[:4]) | |
| 803 | + emp = org.get("numberOfEmployees") | |
| 804 | + if isinstance(emp, dict): | |
| 805 | + emp = emp.get("value") | |
| 806 | + if isinstance(emp, int | float) or (isinstance(emp, str) and emp.replace(",", "").strip().isdigit()): | |
| 807 | + n = int(float(str(emp).replace(",", ""))) | |
| 808 | + facts.employees = n if n > 0 else None | |
| 809 | + addr = org.get("address") | |
| 810 | + if isinstance(addr, list): | |
| 811 | + addr = addr[0] if addr else None | |
| 812 | + if isinstance(addr, dict): | |
| 813 | + parts = [_first_str(addr.get(k)) for k in ("streetAddress", "postalCode", "addressLocality", "addressRegion", "addressCountry")] | |
| 814 | + facts.city = parts[2] | |
| 815 | + facts.region = parts[3] | |
| 816 | + facts.country = country_code(parts[4]) if parts[4] else None | |
| 817 | + facts.address = ", ".join(p for p in parts if p)[:300] or None | |
| 818 | + elif isinstance(addr, str): | |
| 819 | + facts.address = normalize_whitespace(addr)[:300] | |
| 820 | + tel = _first_str(org.get("telephone")) | |
| 821 | + if tel and re.search(r"\d{3}", tel): | |
| 822 | + facts.phone = tel[:40] | |
| 823 | + same_as = org.get("sameAs") or [] | |
| 824 | + for link in (same_as if isinstance(same_as, list) else [same_as]): | |
| 825 | + if not isinstance(link, str): | |
| 826 | + continue | |
| 827 | + host = host_of(link) | |
| 828 | + key = next((k for h, k in SOCIAL_HOSTS.items() if host == h or host.endswith("." + h)), None) | |
| 829 | + if key and key not in facts.socials and link.startswith(("http://", "https://")): | |
| 830 | + facts.socials[key] = link.strip()[:300] | |
| 831 | + if not facts.icon and facts.logo: | |
| 832 | + facts.icon = facts.logo | |
| 833 | + return facts | |
| 834 | + | |
| 835 | + | |
| 836 | +async def _safe_url(url: str | None) -> str | None: | |
| 837 | + if not url or not url.startswith(("http://", "https://")): | |
| 838 | + return None | |
| 839 | + try: | |
| 840 | + await validate_destination_async(url) | |
| 841 | + except Exception: # noqa: BLE001 — blocked destination or resolution failure: drop the URL | |
| 842 | + return None | |
| 843 | + return url[:500] | |
| 844 | + | |
| 845 | + | |
| 846 | +async def apply_homepage(builder: ProfileBuilder, facts: HomepageFacts, *, retrieved_at: str | None = None) -> None: | |
| 847 | + at = retrieved_at or _iso() | |
| 848 | + src, url = "homepage", facts.url | |
| 849 | + if facts.description and builder.set("description", facts.description, source=src, url=url, retrieved_at=at): | |
| 850 | + builder.profile.update({"description_source": "homepage", "description_url": url, "description_license": None, "description_attribution": HOMEPAGE_ATTRIBUTION}) | |
| 851 | + builder.set("legal_name", facts.legal_name, source=src, url=url, retrieved_at=at) | |
| 852 | + builder.set("logo_url", await _safe_url(facts.logo), source=src, url=url, retrieved_at=at) | |
| 853 | + builder.set("icon_url", await _safe_url(facts.icon), source=src, url=url, retrieved_at=at) | |
| 854 | + builder.set("founded_year", facts.founded_year, source=src, url=url, retrieved_at=at) | |
| 855 | + builder.set("employees", facts.employees, source=src, url=url, retrieved_at=at) | |
| 856 | + builder.set("hq_address", facts.address, source=src, url=url, retrieved_at=at) | |
| 857 | + builder.set("hq_city", facts.city, source=src, url=url, retrieved_at=at) | |
| 858 | + builder.set("hq_region", facts.region, source=src, url=url, retrieved_at=at) | |
| 859 | + builder.set("country", facts.country, source=src, url=url, retrieved_at=at) | |
| 860 | + builder.set("phone", facts.phone, source=src, url=url, retrieved_at=at) | |
| 861 | + if facts.socials: | |
| 862 | + merged = {**facts.socials, **(builder.profile.get("socials") or {})} # Wikidata handles win on conflicts | |
| 863 | + if builder.source_of("socials") in (None, "homepage"): | |
| 864 | + builder.set("socials", merged, source=src, url=url, retrieved_at=at) | |
| 865 | + else: | |
| 866 | + builder.profile["socials"] = merged | |
| 867 | + | |
| 868 | + | |
| 869 | +async def latest_snapshot(conn: Any, company_id: str, surface: str) -> dict[str, Any] | None: | |
| 870 | + return await fetch_one(conn, """select s.id, s.object_key, s.text_key, s.fetched_at, s.extracted, se.url from snapshots s join sensors se on se.id = s.sensor_id | |
| 871 | + where se.company_id = :c and se.surface = :surface order by s.fetched_at desc limit 1""", c=company_id, surface=surface) | |
| 872 | + | |
| 873 | + | |
| 874 | +def _object_text(key: str | None) -> str | None: | |
| 875 | + if not key: | |
| 876 | + return None | |
| 877 | + try: | |
| 878 | + return decode_text(archive.get_bytes(key)) | |
| 879 | + except (FileNotFoundError, OSError, ValueError): | |
| 880 | + return None | |
| 881 | + | |
| 882 | + | |
| 883 | +# ================================================================================================================ LLM | |
| 884 | + | |
| 885 | + | |
| 886 | +def numbers_grounded(text: str, source: str) -> bool: | |
| 887 | + """Every digit group of `text` must occur (as a normalised digit string) in `source`.""" | |
| 888 | + src_digits = {re.sub(r"\D", "", m) for m in _NUMBER_RE.findall(source or "")} | |
| 889 | + src_blob = re.sub(r"\D", "", source or "") | |
| 890 | + for m in _NUMBER_RE.findall(text or ""): | |
| 891 | + digits = re.sub(r"\D", "", m) | |
| 892 | + if digits and digits not in src_digits and digits not in src_blob: | |
| 893 | + return False | |
| 894 | + return True | |
| 895 | + | |
| 896 | + | |
| 897 | +async def llm_budget_left(conn: Any) -> int: | |
| 898 | + used = await fetch_val(conn, "select count(*) from llm_jobs where finished_at >= date_trunc('day', now() at time zone 'utc') and status in ('done', 'failed')") | |
| 899 | + return max(0, settings.llm_daily_budget - int(used or 0)) | |
| 900 | + | |
| 901 | + | |
| 902 | +_llm_breaker = {"disabled_until": 0.0} | |
| 903 | + | |
| 904 | + | |
| 905 | +def llm_available() -> bool: | |
| 906 | + return settings.llm_configured and time.monotonic() >= _llm_breaker["disabled_until"] | |
| 907 | + | |
| 908 | + | |
| 909 | +async def llm_profile_text(company: dict[str, Any], text: str, *, source_url: str) -> tuple[str | None, str | None, dict[str, Any]]: | |
| 910 | + """Grounded description through `llm_jobs` (kind `company_profile`). Returns (description, job_id, info). Never raises. A transport | |
| 911 | + failure or timeout opens a circuit breaker for `settings.enrich_llm_cooldown_s` so one slow model server cannot stall a batch.""" | |
| 912 | + from companyatlas.services.llm.gateway import LLMError, get_provider | |
| 913 | + from companyatlas.services.llm.prompts import load_prompt | |
| 914 | + from companyatlas.services.llm.schemas import SCHEMA_VERSIONS, CompanyProfileText | |
| 915 | + | |
| 916 | + if not llm_available(): | |
| 917 | + return None, None, {"skipped": "not configured" if not settings.llm_configured else "cooldown"} | |
| 918 | + job_id = new_id("llm_job") | |
| 919 | + async with transaction() as conn: | |
| 920 | + if await llm_budget_left(conn) <= 0: | |
| 921 | + return None, None, {"skipped": "budget"} | |
| 922 | + await execute(conn, "insert into llm_jobs (id, kind, ref_id, company_id, status, attempts, started_at) values (:id, 'company_profile', :r, :c, 'running', 1, now())", | |
| 923 | + id=job_id, r=company["id"], c=company["id"]) | |
| 924 | + prompt = load_prompt("company-profile") | |
| 925 | + context = {"company": {"name": company.get("display_name"), "domain": company.get("canonical_domain"), "country": company.get("country")}, | |
| 926 | + "source_url": source_url, "text": text[: settings.enrich_llm_max_text_chars]} | |
| 927 | + status, error, result, model, req, resp, latency = "failed", None, None, None, 0, 0, 0 | |
| 928 | + description: str | None = None | |
| 929 | + try: | |
| 930 | + res = await asyncio.wait_for(get_provider().complete_json("medium", prompt.system, jsonb(context), CompanyProfileText, max_tokens=500), | |
| 931 | + timeout=settings.enrich_llm_timeout_s) | |
| 932 | + model, req, resp, latency = res.model, res.request_tokens, res.response_tokens, res.latency_ms | |
| 933 | + d: CompanyProfileText = res.data | |
| 934 | + if not numbers_grounded(d.description, text): | |
| 935 | + error = "ungrounded figures in description" | |
| 936 | + elif d.confidence < 0.3 or d.description.startswith("The company's public pages do not describe"): | |
| 937 | + error = "insufficient source text" | |
| 938 | + elif any(bad in d.description.lower() for bad in FORBIDDEN_WORDING): | |
| 939 | + error = "forbidden wording" | |
| 940 | + else: | |
| 941 | + description, status = d.description, "done" | |
| 942 | + result = {"schema_version": SCHEMA_VERSIONS["CompanyProfileText"], "description": d.description, "confidence": d.confidence, "language": d.language, | |
| 943 | + "accepted": description is not None, "repaired": res.repaired} | |
| 944 | + except LLMError as exc: | |
| 945 | + error = str(exc)[:500] | |
| 946 | + if exc.retryable or exc.status in (401, 403): # server down / swapping models, or a bad key: no point retrying per company | |
| 947 | + _llm_breaker["disabled_until"] = time.monotonic() + settings.enrich_llm_cooldown_s | |
| 948 | + log.warning("llm profile: server unavailable, pausing LLM enrichment", extra={"cooldown_s": settings.enrich_llm_cooldown_s, "error": error[:160]}) | |
| 949 | + except TimeoutError: | |
| 950 | + error = f"timeout after {settings.enrich_llm_timeout_s:.0f}s" | |
| 951 | + _llm_breaker["disabled_until"] = time.monotonic() + settings.enrich_llm_cooldown_s | |
| 952 | + except Exception as exc: # noqa: BLE001 | |
| 953 | + error = f"{exc.__class__.__name__}: {exc}"[:500] | |
| 954 | + async with transaction() as conn: | |
| 955 | + await execute(conn, """update llm_jobs set status = :status, model = :model, prompt_version = :pv, result = cast(:result as jsonb), error = :error, | |
| 956 | + request_tokens = :req, response_tokens = :resp, latency_ms = :latency, finished_at = now() where id = :id""", | |
| 957 | + status=status, model=model, pv=prompt.ref, result=jsonb(result) if result is not None else None, error=error, req=req, resp=resp, | |
| 958 | + latency=latency, id=job_id) | |
| 959 | + if model and (req or resp): | |
| 960 | + await execute(conn, """insert into cost_ledger (day, dimension, key, units, cost_estimate) values (current_date, 'llm', :key, :units, 0) | |
| 961 | + on conflict (day, dimension, key) do update set units = cost_ledger.units + excluded.units""", key=model, units=float(req + resp)) | |
| 962 | + return description, job_id, {"status": status, "error": error, "model": model, "prompt_version": prompt.ref} | |
| 963 | + | |
| 964 | + | |
| 965 | +# ================================================================================================================ orchestration | |
| 966 | + | |
| 967 | + | |
| 968 | +def plan_column_updates(company: dict[str, Any], builder: ProfileBuilder) -> dict[str, Any]: | |
| 969 | + """Columns to back-fill: null, or the profile's source for that field outranks the column's recorded source.""" | |
| 970 | + prov = _dict(company.get("source_meta")).get("provenance") or {} | |
| 971 | + updates: dict[str, Any] = {} | |
| 972 | + for field_name, col in COLUMN_FIELDS.items(): | |
| 973 | + new = builder.get(field_name) | |
| 974 | + src = builder.source_of(field_name) | |
| 975 | + if new in (None, "") or src in (None, "registry"): | |
| 976 | + continue | |
| 977 | + current = company.get(col) | |
| 978 | + current_src = (prov.get(col) or {}).get("source") or "registry" | |
| 979 | + if current in (None, "") or (rank_of(field_name, src) > rank_of(field_name, current_src) and current != new): | |
| 980 | + updates[col] = new | |
| 981 | + if updates.get("country") and len(str(updates["country"])) != 2: | |
| 982 | + updates.pop("country") | |
| 983 | + if (builder.get("ticker") or builder.get("isin")) and not company.get("public_company"): | |
| 984 | + updates["public_company"] = True | |
| 985 | + return updates | |
| 986 | + | |
| 987 | + | |
| 988 | +def _merge_industries(company: dict[str, Any], builder: ProfileBuilder) -> list[str]: | |
| 989 | + new = [s for s in (builder.get("industries") or []) if is_valid_slug(s)] | |
| 990 | + if builder.source_of("industries") in (None, "registry") or not new: | |
| 991 | + return [] | |
| 992 | + existing = [s for s in (company.get("industries") or []) if s] | |
| 993 | + merged = existing + [s for s in new if s not in existing] | |
| 994 | + return merged if merged != existing else [] | |
| 995 | + | |
| 996 | + | |
| 997 | +async def enrich_company(company: dict[str, Any], *, fetcher: Any, wikidata: WikidataClient | None = None, entity: dict[str, Any] | None = None, | |
| 998 | + sources: tuple[str, ...] | list[str] = SOURCES, use_db: bool = True, llm: bool = True) -> EnrichmentResult: | |
| 999 | + """Build the profile for one company row (dict with the `companies` columns). No writes; stored snapshots are read when `use_db`.""" | |
| 1000 | + wd = wikidata or WikidataClient(fetcher) | |
| 1001 | + builder = ProfileBuilder() | |
| 1002 | + seed_from_company(builder, company) | |
| 1003 | + result = EnrichmentResult(company_id=company["id"], profile=builder.profile, provenance=builder.provenance) | |
| 1004 | + country_hint = company.get("country") | |
| 1005 | + qid = company.get("wikidata_id") | |
| 1006 | + | |
| 1007 | + # 1. Wikidata ------------------------------------------------------------------------------------------------- | |
| 1008 | + if "wikidata" in sources and qid: | |
| 1009 | + try: | |
| 1010 | + ent = entity if entity is not None else (await wd.entities([qid])).get(qid) | |
| 1011 | + if ent: | |
| 1012 | + people, rels = await apply_wikidata(builder, ent, wd, country_hint=country_hint) | |
| 1013 | + result.people, result.relationships = people, rels | |
| 1014 | + result.sources_used.append("wikidata") | |
| 1015 | + country_hint = builder.get("country") or country_hint | |
| 1016 | + else: | |
| 1017 | + result.errors.append("wikidata: entity unavailable") | |
| 1018 | + except Exception as exc: | |
| 1019 | + log.exception("wikidata enrichment failed", extra={"company": company.get("slug")}) | |
| 1020 | + result.errors.append(f"wikidata: {exc.__class__.__name__}: {exc}"[:200]) | |
| 1021 | + | |
| 1022 | + # 2. Wikipedia ------------------------------------------------------------------------------------------------- | |
| 1023 | + wiki_ok = False | |
| 1024 | + wp_url = builder.get("wikipedia_url") | |
| 1025 | + if "wikipedia" in sources and wp_url: | |
| 1026 | + try: | |
| 1027 | + m = re.match(r"^https://([a-z\-]+)\.wikipedia\.org/wiki/(.+)$", wp_url) | |
| 1028 | + if m: | |
| 1029 | + summary = await fetch_wikipedia_summary(fetcher, m.group(1), unquote(m.group(2).split("#", 1)[0]).replace("_", " ")) | |
| 1030 | + if summary: | |
| 1031 | + wiki_ok = apply_wikipedia(builder, summary) | |
| 1032 | + result.sources_used.append("wikipedia") | |
| 1033 | + except Exception as exc: | |
| 1034 | + log.exception("wikipedia enrichment failed", extra={"company": company.get("slug")}) | |
| 1035 | + result.errors.append(f"wikipedia: {exc.__class__.__name__}: {exc}"[:200]) | |
| 1036 | + | |
| 1037 | + # 3. Homepage (stored snapshot first, live fetch otherwise) ----------------------------------------------------- | |
| 1038 | + page_text: str | None = None | |
| 1039 | + about_url: str | None = None | |
| 1040 | + page_url = company.get("website") or "" | |
| 1041 | + if "homepage" in sources or ("llm" in sources and llm): | |
| 1042 | + try: | |
| 1043 | + html: str | None = None | |
| 1044 | + fetched_at: str | None = None | |
| 1045 | + if use_db: | |
| 1046 | + async with connection() as conn: | |
| 1047 | + snap = await latest_snapshot(conn, company["id"], "homepage") | |
| 1048 | + about = await latest_snapshot(conn, company["id"], "about") | |
| 1049 | + if snap: | |
| 1050 | + html = _object_text(snap.get("object_key")) | |
| 1051 | + page_url = snap.get("url") or page_url | |
| 1052 | + fetched_at = _iso(snap["fetched_at"]) if isinstance(snap.get("fetched_at"), datetime) else None | |
| 1053 | + page_text = _object_text(snap.get("text_key")) | |
| 1054 | + if about and about.get("text_key"): | |
| 1055 | + about_text = _object_text(about.get("text_key")) | |
| 1056 | + if about_text and len(about_text) >= settings.enrich_llm_min_text_chars: | |
| 1057 | + page_text, about_url = about_text, about.get("url") | |
| 1058 | + if html is None and "homepage" in sources and page_url: | |
| 1059 | + try: | |
| 1060 | + res = await fetcher.get(page_url, min_bytes=200, retries=0) | |
| 1061 | + html, page_url, fetched_at = res.text, res.final_url, _iso(res.fetched_at) | |
| 1062 | + except FetchError as exc: | |
| 1063 | + result.errors.append(f"homepage: {exc.failure}"[:120]) | |
| 1064 | + if html and "homepage" in sources: | |
| 1065 | + facts = parse_homepage(html, page_url) | |
| 1066 | + await apply_homepage(builder, facts, retrieved_at=fetched_at) | |
| 1067 | + result.sources_used.append("homepage") | |
| 1068 | + if page_text is None: | |
| 1069 | + with contextlib.suppress(Exception): | |
| 1070 | + page_text = parse_page(html, url=page_url, surface="homepage").text | |
| 1071 | + except Exception as exc: | |
| 1072 | + log.exception("homepage enrichment failed", extra={"company": company.get("slug")}) | |
| 1073 | + result.errors.append(f"homepage: {exc.__class__.__name__}: {exc}"[:200]) | |
| 1074 | + | |
| 1075 | + # 4. LLM (only without a Wikipedia extract, with enough first-party text) --------------------------------------- | |
| 1076 | + if "llm" in sources and llm and not wiki_ok and builder.source_of("description") != "wikipedia" and page_text \ | |
| 1077 | + and len(page_text) >= settings.enrich_llm_min_text_chars and llm_available(): | |
| 1078 | + try: | |
| 1079 | + src_url = about_url or page_url | |
| 1080 | + text, job_id, info = await llm_profile_text(company, page_text, source_url=src_url) | |
| 1081 | + result.llm_job_id = job_id | |
| 1082 | + if text and builder.set("description", text, source="llm", url=src_url): | |
| 1083 | + builder.profile.update({"description_source": "llm", "description_url": src_url, "description_license": None, "description_attribution": LLM_ATTRIBUTION}) | |
| 1084 | + result.sources_used.append("llm") | |
| 1085 | + elif info.get("error"): | |
| 1086 | + result.errors.append(f"llm: {info['error']}"[:160]) | |
| 1087 | + except Exception as exc: | |
| 1088 | + log.exception("llm profile failed", extra={"company": company.get("slug")}) | |
| 1089 | + result.errors.append(f"llm: {exc.__class__.__name__}: {exc}"[:200]) | |
| 1090 | + | |
| 1091 | + builder.finish() | |
| 1092 | + if builder.source_of("description") in (None, "registry"): | |
| 1093 | + builder.profile["description_source"] = "wikidata" if builder.get("description") else None | |
| 1094 | + result.column_updates = plan_column_updates(company, builder) | |
| 1095 | + result.industries = _merge_industries(company, builder) | |
| 1096 | + return result | |
| 1097 | + | |
| 1098 | + | |
| 1099 | +# ================================================================================================================ persistence | |
| 1100 | + | |
| 1101 | + | |
| 1102 | +async def _upsert_people(conn: Any, company_id: str, people: list[PersonFact]) -> int: | |
| 1103 | + n = 0 | |
| 1104 | + now = _now() | |
| 1105 | + for p in people: | |
| 1106 | + nn = norm_name(p.name) | |
| 1107 | + if not nn: | |
| 1108 | + continue | |
| 1109 | + removed = datetime.combine(p.valid_to, datetime.min.time(), tzinfo=UTC) if p.status == "no_longer_listed" and p.valid_to else None | |
| 1110 | + await execute(conn, """ | |
| 1111 | + insert into people (id, company_id, name, name_norm, title, role_category, is_executive, first_seen_at, last_seen_at, removed_at, status, source_url) | |
| 1112 | + values (:id, :c, :name, :nn, :title, :rc, :ex, :now, :now, :removed, :status, :url) | |
| 1113 | + on conflict (company_id, name_norm) do update set | |
| 1114 | + title = coalesce(people.title, excluded.title), | |
| 1115 | + role_category = case when people.role_category is null or people.role_category = 'other' then excluded.role_category else people.role_category end, | |
| 1116 | + is_executive = people.is_executive or excluded.is_executive, | |
| 1117 | + last_seen_at = case when people.source_url like 'https://www.wikidata.org/%' then excluded.last_seen_at else people.last_seen_at end, | |
| 1118 | + status = case when people.source_url like 'https://www.wikidata.org/%' then excluded.status else people.status end, | |
| 1119 | + removed_at = case when people.source_url like 'https://www.wikidata.org/%' then excluded.removed_at else people.removed_at end""", | |
| 1120 | + id=new_id("person"), c=company_id, name=p.name, nn=nn[:200], title=p.title[:160], rc=p.role_category, ex=p.is_executive, now=now, | |
| 1121 | + removed=removed, status=p.status, url=p.source_url) | |
| 1122 | + n += 1 | |
| 1123 | + return n | |
| 1124 | + | |
| 1125 | + | |
| 1126 | +async def _upsert_relationships(conn: Any, company_id: str, rels: list[RelationshipFact]) -> dict[str, int]: | |
| 1127 | + if not rels: | |
| 1128 | + return {"new": 0, "seen": 0} | |
| 1129 | + targets = await fetch_all(conn, "select id, wikidata_id from companies where wikidata_id = any(cast(:q as text[]))", q=sorted({r.to_qid for r in rels})) | |
| 1130 | + by_qid = {t["wikidata_id"]: t["id"] for t in targets} | |
| 1131 | + existing = await fetch_all(conn, """select id, from_company_id, to_company_id, lower(to_name) as to_name, kind, provenance from company_relationships | |
| 1132 | + where from_company_id = :c or to_company_id = :c""", c=company_id) | |
| 1133 | + index: dict[tuple[str, str, str], str] = {} | |
| 1134 | + for r in existing: | |
| 1135 | + if r["to_company_id"]: | |
| 1136 | + target = r["to_company_id"] | |
| 1137 | + elif r["to_name"]: | |
| 1138 | + target = f"name:{r['to_name']}" | |
| 1139 | + else: | |
| 1140 | + target = f"qid:{_dict(r['provenance']).get('qid')}" | |
| 1141 | + index[(r["from_company_id"], r["kind"], target)] = r["id"] | |
| 1142 | + counters = {"new": 0, "seen": 0} | |
| 1143 | + now = _now() | |
| 1144 | + | |
| 1145 | + async def upsert(frm: str, kind: str, to_id: str | None, to_name: str | None, rel: RelationshipFact) -> None: | |
| 1146 | + key_target = to_id or (f"name:{to_name.lower()}" if to_name else f"qid:{rel.to_qid}") | |
| 1147 | + prov = {"source": "wikidata", "property": rel.property, "qid": rel.to_qid, "retrieved_at": _iso(now)} | |
| 1148 | + rid = index.get((frm, kind, key_target)) | |
| 1149 | + if rid: | |
| 1150 | + # existing keys (e.g. the seed loader's `source`) win; `retrieved_at` is always refreshed | |
| 1151 | + await execute(conn, """update company_relationships set last_seen_at = :now, valid_from = coalesce(valid_from, :vf), valid_to = coalesce(valid_to, :vt), | |
| 1152 | + to_company_id = coalesce(to_company_id, :to_id), to_name = coalesce(to_name, :to_name), source_url = coalesce(source_url, :url), | |
| 1153 | + provenance = (cast(:prov as jsonb) || provenance) || jsonb_build_object('retrieved_at', cast(:at as text)) where id = :id""", | |
| 1154 | + now=now, vf=rel.valid_from, vt=rel.valid_to, to_id=to_id, to_name=to_name, url=rel.source_url, prov=jsonb(prov), at=_iso(now), id=rid) | |
| 1155 | + counters["seen"] += 1 | |
| 1156 | + return | |
| 1157 | + rid = new_id("relationship") | |
| 1158 | + await execute(conn, """insert into company_relationships (id, from_company_id, to_company_id, to_name, kind, valid_from, valid_to, first_seen_at, last_seen_at, | |
| 1159 | + source_url, confidence, provenance) values (:id, :frm, :to_id, :to_name, :kind, :vf, :vt, :now, :now, :url, 0.85, cast(:prov as jsonb))""", | |
| 1160 | + id=rid, frm=frm, to_id=to_id, to_name=to_name, kind=kind, vf=rel.valid_from, vt=rel.valid_to, now=now, url=rel.source_url, prov=jsonb(prov)) | |
| 1161 | + index[(frm, kind, key_target)] = rid | |
| 1162 | + counters["new"] += 1 | |
| 1163 | + | |
| 1164 | + seen_keys: set[tuple[str, str]] = set() | |
| 1165 | + for rel in rels: | |
| 1166 | + if (rel.kind, rel.to_qid) in seen_keys: | |
| 1167 | + continue | |
| 1168 | + seen_keys.add((rel.kind, rel.to_qid)) | |
| 1169 | + to_id = by_qid.get(rel.to_qid) | |
| 1170 | + if to_id == company_id: | |
| 1171 | + continue | |
| 1172 | + await upsert(company_id, rel.kind, to_id, rel.to_name, rel) | |
| 1173 | + if to_id: | |
| 1174 | + await upsert(to_id, INVERSE_KIND[rel.kind], company_id, None, rel) | |
| 1175 | + return counters | |
| 1176 | + | |
| 1177 | + | |
| 1178 | +async def persist(conn: Any, company: dict[str, Any], result: EnrichmentResult) -> dict[str, Any]: | |
| 1179 | + """Write `source_meta.profile` (+ provenance, enriched_at), back-fill columns, upsert people and relationships. One transaction (caller's).""" | |
| 1180 | + meta = _dict(company.get("source_meta")) | |
| 1181 | + prov = dict(meta.get("provenance") or {}) | |
| 1182 | + at = _iso() | |
| 1183 | + for col, value in result.column_updates.items(): | |
| 1184 | + field_name = next((f for f, c in COLUMN_FIELDS.items() if c == col), col) | |
| 1185 | + p = result.provenance.get(field_name) or {"source": "wikidata", "url": None} | |
| 1186 | + entry = {"source": p["source"], "url": p.get("url"), "retrieved_at": at} | |
| 1187 | + if company.get(col) not in (None, "", False) and company.get(col) != value: | |
| 1188 | + entry["previous"] = company.get(col) | |
| 1189 | + prov[col] = entry | |
| 1190 | + if result.industries: | |
| 1191 | + prov["industries"] = {"source": result.provenance.get("industries", {}).get("source", "wikidata"), "url": result.provenance.get("industries", {}).get("url"), | |
| 1192 | + "retrieved_at": at, "previous": list(company.get("industries") or [])} | |
| 1193 | + patch: dict[str, Any] = {"profile": result.profile, "provenance": prov, "enriched_at": at, "enrichment": {"sources": result.sources_used, "errors": result.errors[:10], | |
| 1194 | + "llm_job_id": result.llm_job_id, "at": at}} | |
| 1195 | + sets = ["source_meta = source_meta || cast(:patch as jsonb)", "updated_at = now()"] | |
| 1196 | + params: dict[str, Any] = {"patch": jsonb(patch), "id": company["id"]} | |
| 1197 | + for col, value in result.column_updates.items(): | |
| 1198 | + if col not in set(COLUMN_FIELDS.values()) | {"public_company"}: | |
| 1199 | + continue | |
| 1200 | + cast = {"founded_year": "int", "employees": "int", "public_company": "boolean", "country": "char(2)"}.get(col, "text") | |
| 1201 | + sets.append(f"{col} = cast(:v_{col} as {cast})") | |
| 1202 | + params[f"v_{col}"] = value | |
| 1203 | + if result.industries: | |
| 1204 | + sets.append("industries = cast(:industries as text[])") | |
| 1205 | + params["industries"] = result.industries | |
| 1206 | + if not company.get("industry_primary"): | |
| 1207 | + sets.append("industry_primary = :industry_primary") | |
| 1208 | + params["industry_primary"] = result.industries[0] | |
| 1209 | + await execute(conn, f"update companies set {', '.join(sets)} where id = :id", **params) | |
| 1210 | + people_n = await _upsert_people(conn, company["id"], result.people) | |
| 1211 | + rel = await _upsert_relationships(conn, company["id"], result.relationships) | |
| 1212 | + return {"columns": sorted(result.column_updates), "industries": bool(result.industries), "people": people_n, "relationships_new": rel["new"], | |
| 1213 | + "relationships_seen": rel["seen"]} | |
| 1214 | + | |
| 1215 | + | |
| 1216 | +# ================================================================================================================ batch runner | |
| 1217 | + | |
| 1218 | + | |
| 1219 | +PENDING_SQL = """ | |
| 1220 | +select * from companies | |
| 1221 | +where status <> 'DISSOLVED' | |
| 1222 | + and ((source_meta->>'enriched_at') is null or cast(source_meta->>'enriched_at' as timestamptz) < cast(:cutoff as timestamptz)) | |
| 1223 | +order by (source_meta->>'enriched_at') is not null, onboarding_status <> 'active', importance desc, id | |
| 1224 | +limit :limit""" | |
| 1225 | + | |
| 1226 | + | |
| 1227 | +async def pending_companies(conn: Any, limit: int) -> list[dict[str, Any]]: | |
| 1228 | + cutoff = datetime.now(UTC) - timedelta(days=settings.enrich_refresh_days) | |
| 1229 | + return await fetch_all(conn, PENDING_SQL, cutoff=cutoff, limit=limit) | |
| 1230 | + | |
| 1231 | + | |
| 1232 | +async def load_company(conn: Any, key: str) -> dict[str, Any] | None: | |
| 1233 | + return await fetch_one(conn, "select * from companies where slug = :k or id = :k or wikidata_id = :k limit 1", k=key) | |
| 1234 | + | |
| 1235 | + | |
| 1236 | +async def _load_refs(conn: Any) -> dict[str, Any]: | |
| 1237 | + return _dict(await fetch_val(conn, "select value from settings_kv where key = :k", k=REF_CACHE_KEY)) | |
| 1238 | + | |
| 1239 | + | |
| 1240 | +async def _save_refs(conn: Any, refs: dict[str, Any]) -> None: | |
| 1241 | + await execute(conn, "insert into settings_kv (key, value, updated_at) values (:k, cast(:v as jsonb), now()) on conflict (key) do update set value = excluded.value, updated_at = now()", | |
| 1242 | + k=REF_CACHE_KEY, v=jsonb(refs)) | |
| 1243 | + | |
| 1244 | + | |
| 1245 | +async def enrich_and_persist(company: dict[str, Any], *, fetcher: Any, wikidata: WikidataClient, entity: dict[str, Any] | None = None, | |
| 1246 | + sources: tuple[str, ...] | list[str] = SOURCES, llm: bool = True) -> dict[str, Any]: | |
| 1247 | + t0 = time.monotonic() | |
| 1248 | + result = await enrich_company(company, fetcher=fetcher, wikidata=wikidata, entity=entity, sources=sources, llm=llm) | |
| 1249 | + async with transaction() as conn: | |
| 1250 | + stored = await persist(conn, company, result) | |
| 1251 | + out = {**result.summary(), **stored, "duration_ms": int((time.monotonic() - t0) * 1000)} | |
| 1252 | + log.info("company enriched", extra={"company": company.get("slug"), **{k: v for k, v in out.items() if k != "company_id"}}) | |
| 1253 | + return out | |
| 1254 | + | |
| 1255 | + | |
| 1256 | +async def enrich_pending(limit: int | None = None, concurrency: int | None = None, *, sources: tuple[str, ...] | list[str] = SOURCES, llm: bool = True, | |
| 1257 | + company_keys: list[str] | None = None, fetcher: Any | None = None) -> dict[str, Any]: | |
| 1258 | + """Enrich never-enriched companies first (active before pending), then profiles older than `enrich_refresh_days`.""" | |
| 1259 | + limit = limit or settings.enrich_batch | |
| 1260 | + conc = max(1, concurrency or settings.enrich_concurrency) | |
| 1261 | + stats: dict[str, Any] = {"companies": 0, "ok": 0, "failed": 0, "people": 0, "relationships_new": 0, "columns": 0, "by_source": {s: 0 for s in SOURCES}, "requests": 0} | |
| 1262 | + async with connection() as conn: | |
| 1263 | + if company_keys: | |
| 1264 | + rows = [c for k in company_keys if (c := await load_company(conn, k))] | |
| 1265 | + else: | |
| 1266 | + rows = await pending_companies(conn, limit) | |
| 1267 | + refs = await _load_refs(conn) | |
| 1268 | + if not rows: | |
| 1269 | + return stats | |
| 1270 | + own_fetcher = fetcher is None | |
| 1271 | + f = fetcher or Fetcher(timeout_s=settings.enrich_http_timeout_s, max_connections=max(8, conc * 2)) | |
| 1272 | + if own_fetcher: | |
| 1273 | + await f.open() | |
| 1274 | + wd = WikidataClient(f, refs=refs) | |
| 1275 | + sem = asyncio.Semaphore(conc) | |
| 1276 | + try: | |
| 1277 | + size = max(1, settings.enrich_wikidata_entity_batch) | |
| 1278 | + tasks: list[asyncio.Task[None]] = [] | |
| 1279 | + for i in range(0, len(rows), size): | |
| 1280 | + chunk = rows[i:i + size] | |
| 1281 | + # entities for the next chunk are fetched while the previous chunk's companies are still being processed (semaphore-bounded) | |
| 1282 | + entities = await wd.entities([c["wikidata_id"] for c in chunk if c.get("wikidata_id")]) if "wikidata" in sources else {} | |
| 1283 | + | |
| 1284 | + async def one(company: dict[str, Any], ents: dict[str, dict[str, Any]]) -> None: | |
| 1285 | + async with sem: | |
| 1286 | + try: | |
| 1287 | + out = await enrich_and_persist(company, fetcher=f, wikidata=wd, entity=ents.get(company.get("wikidata_id") or ""), sources=sources, llm=llm) | |
| 1288 | + except Exception as exc: | |
| 1289 | + log.exception("enrichment failed", extra={"company": company.get("slug")}) | |
| 1290 | + stats["failed"] += 1 | |
| 1291 | + with contextlib.suppress(Exception): | |
| 1292 | + async with transaction() as conn: | |
| 1293 | + await execute(conn, "update companies set source_meta = source_meta || cast(:p as jsonb) where id = :id", | |
| 1294 | + p=jsonb({"enriched_at": _iso(), "enrichment": {"error": f"{exc.__class__.__name__}: {exc}"[:300], "at": _iso()}}), id=company["id"]) | |
| 1295 | + return | |
| 1296 | + stats["ok"] += 1 | |
| 1297 | + stats["people"] += out.get("people", 0) | |
| 1298 | + stats["relationships_new"] += out.get("relationships_new", 0) | |
| 1299 | + stats["columns"] += len(out.get("columns") or []) | |
| 1300 | + for s in out.get("sources") or []: | |
| 1301 | + stats["by_source"][s] = stats["by_source"].get(s, 0) + 1 | |
| 1302 | + | |
| 1303 | + tasks.extend(asyncio.create_task(one(c, entities)) for c in chunk) | |
| 1304 | + stats["companies"] += len(chunk) | |
| 1305 | + await asyncio.gather(*tasks) | |
| 1306 | + finally: | |
| 1307 | + stats["requests"] = wd.requests | |
| 1308 | + with contextlib.suppress(Exception): | |
| 1309 | + async with transaction() as conn: | |
| 1310 | + await _save_refs(conn, wd.refs) | |
| 1311 | + if own_fetcher: | |
| 1312 | + await f.close() | |
| 1313 | + log.info("company-enrichment", extra=stats) | |
| 1314 | + return stats | |
| 1315 | + | |
| 1316 | + | |
| 1317 | +@periodic("company-enrichment", every_s=600, initial_delay_s=90) | |
| 1318 | +async def enrichment_task() -> None: | |
| 1319 | + await enrich_pending(limit=settings.enrich_batch, concurrency=settings.enrich_concurrency) | |
| 1320 | + | |
| 1321 | + | |
| 1322 | +# ================================================================================================================ read side (API / CLI) | |
| 1323 | + | |
| 1324 | + | |
| 1325 | +def profile_facts(profile: dict[str, Any] | None) -> list[dict[str, Any]]: | |
| 1326 | + """Key facts for a company page: `{key, label, value, raw, source, url, retrieved_at}` — only fields the profile actually has.""" | |
| 1327 | + if not profile: | |
| 1328 | + return [] | |
| 1329 | + src = {s["field"]: s for s in profile.get("sources") or []} | |
| 1330 | + | |
| 1331 | + def fact(key: str, label: str, value: str | None, raw: Any, field_name: str) -> dict[str, Any] | None: | |
| 1332 | + if value in (None, ""): | |
| 1333 | + return None | |
| 1334 | + s = src.get(field_name) or {} | |
| 1335 | + return {"key": key, "label": label, "value": value, "raw": raw, "source": s.get("source"), "url": s.get("url"), "retrieved_at": s.get("retrieved_at")} | |
| 1336 | + | |
| 1337 | + hq = profile.get("hq") or {} | |
| 1338 | + hq_text = ", ".join(x for x in (hq.get("city"), hq.get("region"), hq.get("country")) if x) or None | |
| 1339 | + emp = profile.get("employees") | |
| 1340 | + emp_text = f"{emp:,}" + (f" ({profile['employees_year']})" if profile.get("employees_year") else "") if isinstance(emp, int) else None | |
| 1341 | + facts = [ | |
| 1342 | + fact("founded", "Founded", str(profile["founded_year"]) if profile.get("founded_year") else None, profile.get("founded_year"), "founded_year"), | |
| 1343 | + fact("headquarters", "Headquarters", hq_text, hq, "hq_city" if src.get("hq_city") else "country"), | |
| 1344 | + fact("employees", "Employees", emp_text, emp, "employees"), | |
| 1345 | + fact("revenue", "Revenue", _money_text(profile.get("revenue")), profile.get("revenue"), "revenue"), | |
| 1346 | + fact("net_income", "Net income", _money_text(profile.get("net_income")), profile.get("net_income"), "net_income"), | |
| 1347 | + fact("total_assets", "Total assets", _money_text(profile.get("total_assets")), profile.get("total_assets"), "total_assets"), | |
| 1348 | + fact("legal_form", "Legal form", profile.get("legal_form"), profile.get("legal_form"), "legal_form"), | |
| 1349 | + fact("listing", "Listing", " · ".join(x for x in (profile.get("ticker"), profile.get("exchange")) if x) or None, | |
| 1350 | + {"ticker": profile.get("ticker"), "exchange": profile.get("exchange")}, "ticker" if src.get("ticker") else "exchange"), | |
| 1351 | + fact("isin", "ISIN", profile.get("isin"), profile.get("isin"), "isin"), | |
| 1352 | + fact("lei", "LEI", profile.get("lei"), profile.get("lei"), "lei"), | |
| 1353 | + fact("sec_cik", "SEC CIK", profile.get("sec_cik"), profile.get("sec_cik"), "sec_cik"), | |
| 1354 | + fact("website", "Website", profile.get("official_website"), profile.get("official_website"), "official_website"), | |
| 1355 | + fact("wikipedia", "Wikipedia", profile.get("wikipedia_url"), profile.get("wikipedia_url"), "wikipedia_url"), | |
| 1356 | + ] | |
| 1357 | + return [f for f in facts if f] | |
| 1358 | + | |
| 1359 | + | |
| 1360 | +def _money_text(m: Any) -> str | None: | |
| 1361 | + if not isinstance(m, dict) or m.get("value") is None: | |
| 1362 | + return None | |
| 1363 | + v = float(m["value"]) | |
| 1364 | + for unit, div in (("T", 1e12), ("B", 1e9), ("M", 1e6), ("K", 1e3)): | |
| 1365 | + if abs(v) >= div: | |
| 1366 | + num = f"{v / div:.1f}".rstrip("0").rstrip(".") + f" {unit}" | |
| 1367 | + break | |
| 1368 | + else: | |
| 1369 | + num = f"{v:,.0f}" | |
| 1370 | + return f"{m.get('currency') or ''} {num}".strip() + (f" ({m['year']})" if m.get("year") else "") | |
| 1371 | + | |
| 1372 | + | |
| 1373 | +def person_source(source_url: str | None) -> str: | |
| 1374 | + return "wikidata" if source_url and host_of(source_url).endswith("wikidata.org") else "page" | |
| 1375 | + | |
| 1376 | + | |
| 1377 | +__all__ = [ | |
| 1378 | + "COLUMN_FIELDS", | |
| 1379 | + "FIELD_RANKS", | |
| 1380 | + "PROFILE_VERSION", | |
| 1381 | + "SOURCES", | |
| 1382 | + "EnrichmentResult", | |
| 1383 | + "HomepageFacts", | |
| 1384 | + "PersonFact", | |
| 1385 | + "ProfileBuilder", | |
| 1386 | + "RelationshipFact", | |
| 1387 | + "WikidataClient", | |
| 1388 | + "apply_homepage", | |
| 1389 | + "apply_wikidata", | |
| 1390 | + "apply_wikipedia", | |
| 1391 | + "claims", | |
| 1392 | + "clean_extract", | |
| 1393 | + "commons_url", | |
| 1394 | + "empty_profile", | |
| 1395 | + "enrich_and_persist", | |
| 1396 | + "enrich_company", | |
| 1397 | + "enrich_pending", | |
| 1398 | + "fetch_wikipedia_summary", | |
| 1399 | + "latest_quantity", | |
| 1400 | + "llm_available", | |
| 1401 | + "llm_profile_text", | |
| 1402 | + "load_company", | |
| 1403 | + "looks_like_organisation", | |
| 1404 | + "numbers_grounded", | |
| 1405 | + "parse_homepage", | |
| 1406 | + "pending_companies", | |
| 1407 | + "persist", | |
| 1408 | + "person_source", | |
| 1409 | + "plan_column_updates", | |
| 1410 | + "profile_facts", | |
| 1411 | + "rank_of", | |
| 1412 | + "seed_from_company", | |
| 1413 | + "wd_quantity", | |
| 1414 | + "wd_time", | |
| 1415 | +] | |
modified
src/companyatlas/services/llm/schemas.py
+15 −2
@@ -11,7 +11,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator | ||
| 11 | 11 | from companyatlas.taxonomy import EVENT_SUBTYPES, FORBIDDEN_WORDING |
| 12 | 12 | |
| 13 | 13 | SCHEMA_VERSIONS = {"ChangeClassification": "classify-v1", "EventSummary": "summary-v1", "LegalDiffSummary": "legal-v1", "IndustryTags": "industry-v1", |
| 14 | − "AskRoute": "ask-v1"} | |
| 14 | + "AskRoute": "ask-v1", "CompanyProfileText": "profile-v1"} | |
| 15 | 15 | |
| 16 | 16 | |
| 17 | 17 | def _clean_text(value: str | None, limit: int) -> str | None: |
@@ -128,6 +128,19 @@ class IndustryTags(_Strict): | ||
| 128 | 128 | return out |
| 129 | 129 | |
| 130 | 130 | |
| 131 | +class CompanyProfileText(_Strict): | |
| 132 | + """Grounded 2–3 sentence company description (services/enrichment.py, job kind `company_profile`). Numbers are additionally checked | |
| 133 | + against the source text by the caller (`enrichment.numbers_grounded`) — any figure absent from the source rejects the output.""" | |
| 134 | + description: str = Field(min_length=40, max_length=700) | |
| 135 | + language: str | None = Field(default=None, max_length=8) | |
| 136 | + confidence: float = Field(default=0.6, ge=0, le=1) | |
| 137 | + | |
| 138 | + @field_validator("description", mode="before") | |
| 139 | + @classmethod | |
| 140 | + def _description(cls, v: Any) -> str: | |
| 141 | + return _clean_text(str(v or ""), 700) or "" | |
| 142 | + | |
| 143 | + | |
| 131 | 144 | class AskRoute(_Strict): |
| 132 | 145 | """LLM refinement of the deterministic question parser (services/llm/ask.py). It may only tighten filters, never invent results.""" |
| 133 | 146 | intent: str = Field(default="search") # search | hiring | pricing | ai | launch | leadership | expansion | compare | trend |
@@ -152,4 +165,4 @@ class AskRoute(_Strict): | ||
| 152 | 165 | return [s for s in (str(x).strip().upper()[:2] for x in (v or [])[:8]) if len(s) == 2 and s.isalpha()] |
| 153 | 166 | |
| 154 | 167 | |
| 155 | −__all__ = ["SCHEMA_VERSIONS", "AskRoute", "ChangeClassification", "EventSummary", "IndustryTags", "LegalDiffSummary", "LegalSection"] | |
| 168 | +__all__ = ["SCHEMA_VERSIONS", "AskRoute", "ChangeClassification", "CompanyProfileText", "EventSummary", "IndustryTags", "LegalDiffSummary", "LegalSection"] | |
modified
src/companyatlas/services/periodic.py
+1 −0
@@ -73,6 +73,7 @@ TASK_MODULES: list[str] = [ | ||
| 73 | 73 | "companyatlas.services.signals", |
| 74 | 74 | "companyatlas.services.trends", |
| 75 | 75 | "companyatlas.services.repair", |
| 76 | + "companyatlas.services.enrichment", | |
| 76 | 77 | "companyatlas.commands.ops", |
| 77 | 78 | ] |
| 78 | 79 | |
added
tests/test_api_profile.py
+69 −0
@@ -0,0 +1,69 @@ | ||
| 1 | +"""API surface of the profile enrichment (docs/API.md "Profile & facts"): `CompanyCard.profile`, detail `facts`, relationship provenance, | |
| 2 | +people `source`. Uses the shared API fixture family and writes a profile into the alpha company's `source_meta`.""" | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import pytest | |
| 6 | +import test_api_support as support | |
| 7 | + | |
| 8 | +from companyatlas.db import execute, jsonb, transaction | |
| 9 | + | |
| 10 | +client = support.client | |
| 11 | +fixture_data = support.fixture_data | |
| 12 | + | |
| 13 | +pytestmark = pytest.mark.asyncio(loop_scope="session") | |
| 14 | +V = "/api/v1" | |
| 15 | +PROFILE = { | |
| 16 | + "description": "Ztest Alpha is a synthetic company used by the API tests. " * 4, "description_source": "wikipedia", | |
| 17 | + "description_url": "https://en.wikipedia.org/wiki/Ztest_Alpha", "description_license": "CC BY-SA 4.0", "description_attribution": "Text from Wikipedia (en), CC BY-SA 4.0", | |
| 18 | + "logo_url": "https://commons.wikimedia.org/wiki/Special:FilePath/Ztest.svg", "icon_url": None, "founded_year": 1999, "legal_form": "corporation", "legal_name": "Ztest Alpha Inc.", | |
| 19 | + "employees": 47756, "employees_year": 2013, "revenue": {"value": 305630000000, "currency": "USD", "year": 2023}, "net_income": None, "total_assets": None, | |
| 20 | + "hq": {"city": "Testville", "region": "Test Region", "country": "ZZ", "address": None, "lat": None, "lon": None}, | |
| 21 | + "ticker": "ZTA", "exchange": "Nasdaq", "isin": None, "lei": "7ZW8QJWVPR4P1J1KQY45", "sec_cik": None, "public_company": True, | |
| 22 | + "wikipedia_url": "https://en.wikipedia.org/wiki/Ztest_Alpha", "wikidata_url": "https://www.wikidata.org/wiki/Q95", "official_website": "https://ztest.example/", "phone": None, | |
| 23 | + "products": ["Ztest Search"], "industries": [], "industry_labels": ["software industry"], "socials": {"linkedin": "https://www.linkedin.com/company/ztest"}, | |
| 24 | + "enriched_at": "2026-09-13T00:00:00+00:00", "version": "profile-v1", | |
| 25 | + "sources": [{"field": "description", "source": "wikipedia", "url": "https://en.wikipedia.org/wiki/Ztest_Alpha", "retrieved_at": "2026-09-13T00:00:00+00:00"}, | |
| 26 | + {"field": "employees", "source": "wikidata", "url": "https://www.wikidata.org/wiki/Q95", "retrieved_at": "2026-09-13T00:00:00+00:00"}, | |
| 27 | + {"field": "founded_year", "source": "wikidata", "url": "https://www.wikidata.org/wiki/Q95", "retrieved_at": "2026-09-13T00:00:00+00:00"}, | |
| 28 | + {"field": "hq_city", "source": "wikidata", "url": "https://www.wikidata.org/wiki/Q95", "retrieved_at": "2026-09-13T00:00:00+00:00"}, | |
| 29 | + {"field": "ticker", "source": "wikidata", "url": "https://www.wikidata.org/wiki/Q95", "retrieved_at": "2026-09-13T00:00:00+00:00"}, | |
| 30 | + {"field": "revenue", "source": "wikidata", "url": "https://www.wikidata.org/wiki/Q95", "retrieved_at": "2026-09-13T00:00:00+00:00"}], | |
| 31 | +} | |
| 32 | + | |
| 33 | + | |
| 34 | +async def test_profile_facts_relationships_people(client, fixture_data): # type: ignore[no-untyped-def] | |
| 35 | + alpha, beta = fixture_data["alpha"], fixture_data["beta"] | |
| 36 | + async with transaction() as conn: | |
| 37 | + await execute(conn, "update companies set source_meta = source_meta || cast(:p as jsonb) where id = :id", p=jsonb({"profile": PROFILE}), id=alpha) | |
| 38 | + await execute(conn, """insert into company_relationships (id, from_company_id, to_company_id, kind, valid_from, confidence, source_url, provenance) | |
| 39 | + values (:id, :a, :b, 'SUBSIDIARY_OF', '2015-10-02', 0.85, 'https://www.wikidata.org/wiki/Q95', cast(:prov as jsonb))""", | |
| 40 | + id=f"rel_ztestapi{fixture_data['suffix']}p", a=alpha, b=beta, prov=jsonb({"source": "wikidata", "property": "P749", "qid": "Q20800404"})) | |
| 41 | + await execute(conn, """insert into people (id, company_id, name, name_norm, title, role_category, is_executive, status, source_url) | |
| 42 | + values (:id, :c, 'Ztest Chief', :nn, 'Chief Executive Officer', 'ceo', true, 'listed', 'https://www.wikidata.org/wiki/Q95')""", | |
| 43 | + id=f"person_ztestapi{fixture_data['suffix']}w", c=alpha, nn=f"ztestchief{fixture_data['suffix']}") | |
| 44 | + support.cache.clear() | |
| 45 | + # card in a list | |
| 46 | + items = (await client.get(f"{V}/companies", params={"country": "ZZ", "sort": "name"})).json()["items"] | |
| 47 | + cards = {c["slug"]: c for c in items} | |
| 48 | + assert cards[fixture_data["alpha_slug"]]["profile"]["employees"] == 47756 and cards[fixture_data["alpha_slug"]]["profile"]["description_license"] == "CC BY-SA 4.0" | |
| 49 | + assert "profile" not in cards[fixture_data["beta_slug"]] # never enriched → absent, not null-filled | |
| 50 | + # detail: profile, facts, relationships with provenance | |
| 51 | + body = (await client.get(f"{V}/companies/{fixture_data['alpha_slug']}")).json() | |
| 52 | + assert body["profile"]["version"] == "profile-v1" | |
| 53 | + facts = {f["key"]: f for f in body["facts"]} | |
| 54 | + assert set(facts) == {"founded", "headquarters", "employees", "revenue", "legal_form", "listing", "lei", "website", "wikipedia"} | |
| 55 | + assert facts["employees"] == {"key": "employees", "label": "Employees", "value": "47,756 (2013)", "raw": 47756, "source": "wikidata", | |
| 56 | + "url": "https://www.wikidata.org/wiki/Q95", "retrieved_at": "2026-09-13T00:00:00+00:00"} | |
| 57 | + assert facts["revenue"]["value"] == "USD 305.6 B (2023)" and facts["headquarters"]["value"] == "Testville, Test Region, ZZ" | |
| 58 | + assert facts["listing"]["value"] == "ZTA · Nasdaq" and facts["listing"]["source"] == "wikidata" and facts["legal_form"]["source"] is None | |
| 59 | + rel = {r["kind"]: r for r in body["relationships"]} | |
| 60 | + assert rel["SUBSIDIARY_OF"]["company"]["slug"] == fixture_data["beta_slug"] and rel["SUBSIDIARY_OF"]["provenance"] == {"source": "wikidata", "property": "P749", "qid": "Q20800404"} | |
| 61 | + assert rel["SUBSIDIARY_OF"]["valid_from"] == "2015-10-02" and rel["SUBSIDIARY_OF"]["confidence"] == 0.85 and "first_seen_at" in rel["SUBSIDIARY_OF"] | |
| 62 | + assert rel["PARTNER_OF"]["provenance"] == {} | |
| 63 | + # people carry their source | |
| 64 | + people = (await client.get(f"{V}/companies/{fixture_data['alpha_slug']}/people")).json() | |
| 65 | + by_name = {p["name"]: p for p in people["listed"]} | |
| 66 | + assert by_name["Ztest Chief"]["source"] == "wikidata" and by_name["Ztest Chief"]["title"] == "Chief Executive Officer" | |
| 67 | + assert all(p["source"] in ("wikidata", "page") for p in people["listed"] + people["no_longer_listed"]) and "wikidata" in people["sources"] | |
| 68 | + beta_detail = (await client.get(f"{V}/companies/{fixture_data['beta_slug']}")).json() | |
| 69 | + assert beta_detail["profile"] is None and beta_detail["facts"] == [] | |
added
tests/test_enrichment.py
+378 −0
@@ -0,0 +1,378 @@ | ||
| 1 | +"""Company profile enrichment: Wikidata mapping, Wikipedia/homepage precedence, provenance, grounded LLM text, people/relationship | |
| 2 | +persistence and idempotency. Fixtures in `fixtures/enrichment/` (trimmed Alphabet/Google entity, its English labels, a Wikipedia summary, | |
| 3 | +a homepage with a JSON-LD Organization). No live network: the fake fetcher answers the exact API URLs the client builds.""" | |
| 4 | +from __future__ import annotations | |
| 5 | + | |
| 6 | +import json | |
| 7 | +from pathlib import Path | |
| 8 | +from typing import Any | |
| 9 | +from urllib.parse import parse_qs, urlparse | |
| 10 | + | |
| 11 | +import pytest | |
| 12 | +from conftest import FakeFetcher | |
| 13 | +from factories import ( | |
| 14 | + cleanup, | |
| 15 | + intel_db, # noqa: F401 — pytest fixture registered by import | |
| 16 | + make_company, | |
| 17 | +) | |
| 18 | + | |
| 19 | +from companyatlas.db import execute, fetch_all, fetch_one, jsonb, transaction | |
| 20 | +from companyatlas.services import enrichment as en | |
| 21 | + | |
| 22 | +FIX = Path(__file__).resolve().parents[1] / "fixtures" / "enrichment" | |
| 23 | +ENTITY = json.loads((FIX / "wikidata-Q95.json").read_text(encoding="utf-8"))["entities"]["Q95"] | |
| 24 | +_LABELS = json.loads((FIX / "wikidata-labels.json").read_text(encoding="utf-8")) | |
| 25 | +LABELS = {q: v["label"] for q, v in _LABELS.items()} | |
| 26 | +DESCRIPTIONS = {q: v["description"] for q, v in _LABELS.items()} | |
| 27 | +SUMMARY = json.loads((FIX / "wikipedia-summary.json").read_text(encoding="utf-8")) | |
| 28 | +HOMEPAGE = (FIX / "homepage.html").read_text(encoding="utf-8") | |
| 29 | +EXTRA_LABELS: dict[str, str] = {} # test-local QIDs (never real ones, so DB tests cannot touch real companies) | |
| 30 | +CLAIMS = {("Q30", "P297"): ["US"], ("Q4917", "P498"): ["USD"]} | |
| 31 | +WD_URL = "https://www.wikidata.org/wiki/Q95" | |
| 32 | +WP_URL = "https://en.wikipedia.org/wiki/Google" | |
| 33 | + | |
| 34 | + | |
| 35 | +class WikimediaFetcher(FakeFetcher): | |
| 36 | + """Answers wbgetentities / wbgetclaims from the fixtures whatever the id order; other URLs use the FakeFetcher routes.""" | |
| 37 | + | |
| 38 | + async def get(self, url: str, **kw: Any) -> Any: | |
| 39 | + p = urlparse(url) | |
| 40 | + if p.netloc == "www.wikidata.org" and p.path == "/w/api.php": | |
| 41 | + self.calls.append(url) | |
| 42 | + assert kw.get("respect_robots") is False and kw.get("rate_per_min") | |
| 43 | + q = {k: v[0] for k, v in parse_qs(p.query).items()} | |
| 44 | + if q["action"] == "wbgetentities": | |
| 45 | + ids = q["ids"].split("|") | |
| 46 | + if "claims" in q["props"]: | |
| 47 | + ents = {i: ENTITY for i in ids if i == "Q95"} | {i: {"id": i, "missing": ""} for i in ids if i != "Q95"} | |
| 48 | + else: | |
| 49 | + labels = LABELS | EXTRA_LABELS | |
| 50 | + descs = DESCRIPTIONS | {q: "company" for q in EXTRA_LABELS} | |
| 51 | + ents = {i: {"id": i, "labels": ({"en": {"language": "en", "value": labels[i]}} if labels.get(i) else {}), | |
| 52 | + "descriptions": ({"en": {"language": "en", "value": descs[i]}} if descs.get(i) else {})} for i in ids} | |
| 53 | + return self._json({"entities": ents}) | |
| 54 | + if q["action"] == "wbgetclaims": | |
| 55 | + vals = CLAIMS.get((q["entity"], q["property"]), []) | |
| 56 | + return self._json({"claims": {q["property"]: [{"mainsnak": {"snaktype": "value", "datavalue": {"value": v, "type": "string"}}, "rank": "normal"} for v in vals]}}) | |
| 57 | + return await super().get(url, **kw) | |
| 58 | + | |
| 59 | + def _json(self, payload: dict[str, Any]) -> Any: | |
| 60 | + from conftest import make_result | |
| 61 | + | |
| 62 | + return make_result("https://www.wikidata.org/w/api.php", json.dumps(payload), content_type="application/json") | |
| 63 | + | |
| 64 | + | |
| 65 | +def company_row(**over: Any) -> dict[str, Any]: | |
| 66 | + base = {"id": "co_test", "slug": "google", "display_name": "Google", "canonical_domain": "google.com", "website": "https://www.google.com", | |
| 67 | + "description": "American multinational technology company", "industries": [], "industry_primary": None, "country": None, "hq_city": None, | |
| 68 | + "hq_region": None, "founded_year": None, "employees": None, "public_company": False, "ticker": None, "exchange": None, "legal_name": None, | |
| 69 | + "lei": None, "sec_cik": None, "logo_url": None, "wikidata_id": "Q95", "source_meta": {"source": "wikidata", "industry_labels": ["technology company"]}} | |
| 70 | + base.update(over) | |
| 71 | + return base | |
| 72 | + | |
| 73 | + | |
| 74 | +@pytest.fixture | |
| 75 | +def fetcher(monkeypatch: pytest.MonkeyPatch) -> WikimediaFetcher: | |
| 76 | + async def no_dns(url: str) -> None: # SSRF validation resolves hosts — never in tests | |
| 77 | + return None | |
| 78 | + | |
| 79 | + monkeypatch.setattr(en, "validate_destination_async", no_dns) | |
| 80 | + f = WikimediaFetcher() | |
| 81 | + f.add("https://en.wikipedia.org/api/rest_v1/page/summary/Google", json.dumps(SUMMARY), content_type="application/json") | |
| 82 | + f.add("https://www.example-robotics.test/", HOMEPAGE) | |
| 83 | + return f | |
| 84 | + | |
| 85 | + | |
| 86 | +# ================================================================================================================ Wikidata mapping | |
| 87 | + | |
| 88 | + | |
| 89 | +async def test_wikidata_mapping_and_provenance(fetcher: WikimediaFetcher) -> None: | |
| 90 | + res = await en.enrich_company(company_row(), fetcher=fetcher, sources=("wikidata",), use_db=False, llm=False) | |
| 91 | + p = res.profile | |
| 92 | + assert res.sources_used == ["wikidata"] and not res.errors | |
| 93 | + assert p["legal_name"] == "Google LLC" and p["founded_year"] == 1998 and p["legal_form"] == "limited liability company" | |
| 94 | + assert p["hq"]["city"] == "Mountain View" and p["hq"]["country"] == "US" and p["hq"]["region"] == "California" | |
| 95 | + assert p["hq"]["lat"] == pytest.approx(37.42, abs=0.01) and p["hq"]["lon"] == pytest.approx(-122.08, abs=0.01) | |
| 96 | + assert p["employees"] and p["employees_year"] and p["employees_year"] >= 2013 # latest P585 observation wins | |
| 97 | + assert p["revenue"] == {"value": 305630000000.0, "currency": "USD", "year": 2023} # 2023 beats 2021 | |
| 98 | + assert p["net_income"]["year"] == 2023 and p["total_assets"] is None | |
| 99 | + assert "software" in p["industries"] and p["industry_labels"] and "Internet industry" in p["industry_labels"] | |
| 100 | + assert "Google Search" in p["products"] | |
| 101 | + assert p["exchange"] is None and p["ticker"] is None # both Nasdaq listings ended in 2016 (P582) | |
| 102 | + assert p["isin"] == "US02079K3059" and p["lei"] == "7ZW8QJWVPR4P1J1KQY45" and p["sec_cik"] == "0001824723" | |
| 103 | + assert p["official_website"] == "https://about.google/" and p["public_company"] is True # ISIN present | |
| 104 | + assert p["logo_url"].startswith("https://commons.wikimedia.org/wiki/Special:FilePath/Google_2026_logo.svg") | |
| 105 | + assert p["socials"]["linkedin"] == "https://www.linkedin.com/company/google" and p["socials"]["x"] == "https://x.com/Google" | |
| 106 | + assert p["socials"]["github"] == "https://github.com/google" and p["socials"]["crunchbase"] == "https://www.crunchbase.com/organization/google" | |
| 107 | + assert p["wikipedia_url"] == WP_URL and p["wikidata_url"] == WD_URL | |
| 108 | + assert p["description"] == "American multinational technology company" and p["description_source"] == "wikidata" | |
| 109 | + by_field = {s["field"]: s for s in p["sources"]} | |
| 110 | + assert by_field["employees"] == {"field": "employees", "source": "wikidata", "url": WD_URL, "retrieved_at": by_field["employees"]["retrieved_at"]} | |
| 111 | + assert by_field["industry_labels"]["source"] == "wikidata" and by_field["revenue"]["url"] == WD_URL | |
| 112 | + assert p["enriched_at"] and p["version"] == "profile-v1" | |
| 113 | + # people: CEO (current) listed, founders listed, an ended CEO tenure → no_longer_listed (never "left") | |
| 114 | + people = {x.name: x for x in res.people} | |
| 115 | + assert people["Sundar Pichai"].status == "listed" and people["Sundar Pichai"].role_category == "ceo" and people["Sundar Pichai"].is_executive | |
| 116 | + assert people["Larry Page"].role_category == "founder" and people["Sergey Brin"].source_url == WD_URL | |
| 117 | + assert people["Eric Schmidt"].status == "no_longer_listed" and people["Eric Schmidt"].valid_to is not None | |
| 118 | + assert all(x.title in ("Chief Executive Officer", "Founder") for x in res.people) | |
| 119 | + # relationships: parent → SUBSIDIARY_OF, subsidiaries → PARENT_OF, owner of → OWNER_OF, capped per property | |
| 120 | + kinds = {(r.kind, r.to_qid): r for r in res.relationships} | |
| 121 | + assert kinds[("SUBSIDIARY_OF", "Q20800404")].to_name == "Alphabet Inc." and kinds[("SUBSIDIARY_OF", "Q20800404")].valid_from is not None | |
| 122 | + assert kinds[("PARENT_OF", "Q1318441")].to_name == "AdMob" and kinds[("PARENT_OF", "Q1318441")].property == "P355" | |
| 123 | + assert not any(k == "OWNER_OF" for k, _ in kinds) # Google's "owner of" items in the fixture are products → filtered | |
| 124 | + assert ("PARENT_OF", "Q1053674") not in kinds # deprecated-rank statement (DoubleClick) ignored | |
| 125 | + assert en.looks_like_organisation("top-level domain", default=True) is False and en.looks_like_organisation("American advertising company", default=False) | |
| 126 | + assert en.looks_like_organisation("provides Internet ad serving services", default=False) is True | |
| 127 | + assert en.looks_like_organisation("note-taking service developed by Google", default=False) is False | |
| 128 | + assert en.looks_like_organisation(None, default=False) is False and en.looks_like_organisation("something unusual", default=True) is True | |
| 129 | + # column back-fills: null columns filled, registry description NOT replaced by the one-line Wikidata description (same rank) | |
| 130 | + assert res.column_updates["founded_year"] == 1998 and res.column_updates["hq_city"] == "Mountain View" and res.column_updates["country"] == "US" | |
| 131 | + assert res.column_updates["legal_name"] == "Google LLC" and res.column_updates["public_company"] is True | |
| 132 | + assert "description" not in res.column_updates and res.industries and "software" in res.industries | |
| 133 | + # label lookups are batched (≤ 50 ids per call) and country/currency codes resolved through wbgetclaims | |
| 134 | + label_calls = [c for c in fetcher.calls if "props=labels%7Cdescriptions" in c] | |
| 135 | + assert label_calls and all(len(parse_qs(urlparse(c).query)["ids"][0].split("|")) <= 50 for c in label_calls) | |
| 136 | + assert any("wbgetclaims" in c and "P297" in c for c in fetcher.calls) | |
| 137 | + | |
| 138 | + | |
| 139 | +def _item(qid: str) -> dict[str, Any]: | |
| 140 | + return {"snaktype": "value", "datavalue": {"value": {"entity-type": "item", "id": qid}, "type": "wikibase-entityid"}} | |
| 141 | + | |
| 142 | + | |
| 143 | +def _string(v: str) -> dict[str, Any]: | |
| 144 | + return {"snaktype": "value", "datavalue": {"value": v, "type": "string"}} | |
| 145 | + | |
| 146 | + | |
| 147 | +async def test_current_listing_and_ticker_qualifier(fetcher: WikimediaFetcher) -> None: | |
| 148 | + """A current P414 statement gives the exchange; the ticker comes from its P249 qualifier when there is no top-level P249.""" | |
| 149 | + entity = {"id": "Q95", "labels": {}, "descriptions": {}, "sitelinks": {}, | |
| 150 | + "claims": {"P414": [{"mainsnak": {**_item("Q82059"), "property": "P414"}, "rank": "normal", "qualifiers": {"P249": [{**_string("GOOGL"), "property": "P249"}]}}, | |
| 151 | + {"mainsnak": {**_item("Q82059"), "property": "P414"}, "rank": "normal", | |
| 152 | + "qualifiers": {"P249": [{**_string("OLD"), "property": "P249"}], | |
| 153 | + "P582": [{"snaktype": "value", "property": "P582", "datavalue": {"value": {"time": "+2010-01-01T00:00:00Z", "precision": 11}, "type": "time"}}]}}], | |
| 154 | + "P1128": [{"mainsnak": {"snaktype": "value", "property": "P1128", "datavalue": {"value": {"amount": "+10", "unit": "1"}, "type": "quantity"}}, "rank": "normal"}, | |
| 155 | + {"mainsnak": {"snaktype": "value", "property": "P1128", "datavalue": {"value": {"amount": "+12", "unit": "1"}, "type": "quantity"}}, "rank": "normal", | |
| 156 | + "qualifiers": {"P585": [{"snaktype": "value", "property": "P585", "datavalue": {"value": {"time": "+2020-01-01T00:00:00Z", "precision": 9}, "type": "time"}}]}}]}} | |
| 157 | + res = await en.enrich_company(company_row(description=None), fetcher=fetcher, entity=entity, sources=("wikidata",), use_db=False, llm=False) | |
| 158 | + assert res.profile["exchange"] == "Nasdaq" and res.profile["ticker"] == "GOOGL" and res.profile["public_company"] is True | |
| 159 | + assert res.profile["employees"] == 12 and res.profile["employees_year"] == 2020 # dated observation beats an undated one | |
| 160 | + assert res.column_updates["ticker"] == "GOOGL" and res.column_updates["exchange"] == "Nasdaq" | |
| 161 | + | |
| 162 | + | |
| 163 | +async def test_wikidata_helpers() -> None: | |
| 164 | + assert en.wd_time({"time": "+1998-09-04T00:00:00Z", "precision": 11})[0] == 1998 | |
| 165 | + assert en.wd_time({"time": "+2015-00-00T00:00:00Z", "precision": 9})[1].isoformat() == "2015-01-01" | |
| 166 | + assert en.wd_time({"time": "-0050-00-00T00:00:00Z", "precision": 9}) == (None, None, 9) | |
| 167 | + assert en.wd_quantity({"amount": "+47756", "unit": "1"}) == (47756.0, None) | |
| 168 | + assert en.wd_quantity({"amount": "-3.5", "unit": "http://www.wikidata.org/entity/Q4917"}) == (-3.5, "Q4917") | |
| 169 | + assert en.commons_url("Google 2026 logo.svg") == "https://commons.wikimedia.org/wiki/Special:FilePath/Google_2026_logo.svg" | |
| 170 | + assert en.commons_url("Éclair (1).png") == "https://commons.wikimedia.org/wiki/Special:FilePath/%C3%89clair_%281%29.png" | |
| 171 | + q = en.latest_quantity(ENTITY, "P2139") | |
| 172 | + assert q and q[2] == 2023 | |
| 173 | + url = en.WikidataClient.entities_url(["Q95", "Q3884"]) | |
| 174 | + assert url.startswith("https://www.wikidata.org/w/api.php?format=json&action=wbgetentities&ids=Q95%7CQ3884&props=labels%7Cdescriptions%7Cclaims%7Csitelinks") | |
| 175 | + assert "sitefilter=" in url and "enwiki" in url | |
| 176 | + assert en.rank_of("description", "wikipedia") > en.rank_of("description", "llm") > en.rank_of("description", "homepage") > en.rank_of("description", "wikidata") | |
| 177 | + assert en.rank_of("employees", "wikidata") > en.rank_of("employees", "homepage") > en.rank_of("employees", "registry") | |
| 178 | + | |
| 179 | + | |
| 180 | +async def test_wikidata_client_tolerates_failures() -> None: | |
| 181 | + f = FakeFetcher() # every URL → 404 | |
| 182 | + wd = en.WikidataClient(f) | |
| 183 | + assert await wd.entities(["Q95"]) == {} and await wd.labels(["Q30"]) == {} and await wd.country_iso("Q30") is None | |
| 184 | + assert await wd.currency_code("Q4917") == "USD" # static table, no request | |
| 185 | + res = await en.enrich_company(company_row(), fetcher=f, sources=("wikidata", "wikipedia"), use_db=False, llm=False) | |
| 186 | + assert res.errors == ["wikidata: entity unavailable"] and res.profile["description"] == "American multinational technology company" | |
| 187 | + assert res.column_updates == {} and res.people == [] and res.relationships == [] | |
| 188 | + | |
| 189 | + | |
| 190 | +# ================================================================================================================ Wikipedia / homepage / precedence | |
| 191 | + | |
| 192 | + | |
| 193 | +async def test_wikipedia_description_with_attribution(fetcher: WikimediaFetcher) -> None: | |
| 194 | + res = await en.enrich_company(company_row(), fetcher=fetcher, sources=("wikidata", "wikipedia"), use_db=False, llm=False) | |
| 195 | + p = res.profile | |
| 196 | + assert res.sources_used == ["wikidata", "wikipedia"] | |
| 197 | + assert p["description"].startswith("Google LLC is an American multinational technology corporation") and len(p["description"]) >= 200 | |
| 198 | + assert p["description_source"] == "wikipedia" and p["description_url"] == WP_URL and p["description_license"] == "CC BY-SA 4.0" | |
| 199 | + assert p["description_attribution"] == "Text from Wikipedia (en), CC BY-SA 4.0" | |
| 200 | + assert p["logo_url"].startswith("https://commons.wikimedia.org/") # Wikidata logo outranks the Wikipedia thumbnail | |
| 201 | + assert res.column_updates["description"] == p["description"] # wikipedia outranks the registry one-liner | |
| 202 | + src = {s["field"]: s["source"] for s in p["sources"]} | |
| 203 | + assert src["description"] == "wikipedia" and src["wikipedia_url"] in ("wikidata", "wikipedia") | |
| 204 | + | |
| 205 | + | |
| 206 | +def test_clean_extract() -> None: | |
| 207 | + raw = "Acme Corp (pronounced /ˈækmi/) is a company.[1] It makes things.[citation needed]\n\nSecond paragraph here.\nThird.\nFourth is dropped." | |
| 208 | + assert en.clean_extract(raw) == "Acme Corp is a company. It makes things. Second paragraph here. Third." | |
| 209 | + long = " ".join([f"Sentence number {i} is here." for i in range(200)]) | |
| 210 | + out = en.clean_extract(long, max_chars=300) | |
| 211 | + assert out and len(out) <= 300 and out.endswith(".") | |
| 212 | + assert en.clean_extract("") is None | |
| 213 | + | |
| 214 | + | |
| 215 | +async def test_homepage_facts_and_icon(fetcher: WikimediaFetcher) -> None: | |
| 216 | + company = company_row(id="co_home", slug="example-robotics", display_name="Example Robotics", canonical_domain="example-robotics.test", | |
| 217 | + website="https://www.example-robotics.test/", wikidata_id=None, description=None, source_meta={"source": "manual"}) | |
| 218 | + res = await en.enrich_company(company, fetcher=fetcher, sources=("homepage",), use_db=False, llm=False) | |
| 219 | + p = res.profile | |
| 220 | + assert res.sources_used == ["homepage"] and not res.errors | |
| 221 | + assert p["description"].startswith("Example Robotics designs and builds autonomous mobile robots") and p["description_source"] == "homepage" | |
| 222 | + assert p["description_attribution"] == en.HOMEPAGE_ATTRIBUTION and p["description_license"] is None | |
| 223 | + assert p["legal_name"] == "Example Robotics Inc." and p["founded_year"] == 2014 and p["employees"] == 420 and p["phone"] == "+1 514-555-0100" | |
| 224 | + assert p["hq"] == {"city": "Montréal", "region": "Quebec", "country": "CA", "address": "1200 Rue Example, H2X 1Y4, Montréal, Quebec, CA", "lat": None, "lon": None} | |
| 225 | + assert p["logo_url"] == "https://www.example-robotics.test/static/logo.svg" | |
| 226 | + assert p["icon_url"] == "https://www.example-robotics.test/static/apple-touch-icon.png" # apple-touch-icon beats favicon and og:image | |
| 227 | + assert p["socials"] == {"linkedin": "https://www.linkedin.com/company/example-robotics", "x": "https://twitter.com/examplerobotics", | |
| 228 | + "github": "https://github.com/example-robotics"} | |
| 229 | + assert res.column_updates["description"] == p["description"] and res.column_updates["country"] == "CA" and res.column_updates["employees"] == 420 | |
| 230 | + facts = en.profile_facts(p) | |
| 231 | + keys = {f["key"]: f for f in facts} | |
| 232 | + assert keys["headquarters"]["value"] == "Montréal, Quebec, CA" and keys["employees"]["value"] == "420" and keys["founded"]["source"] == "homepage" | |
| 233 | + assert keys["founded"]["url"] == "https://www.example-robotics.test/" | |
| 234 | + | |
| 235 | + | |
| 236 | +def test_parse_homepage_ignores_relative_junk() -> None: | |
| 237 | + html = '<html><head><meta name="description" content="short"><link rel="icon" href="javascript:alert(1)"><meta property="og:image" content="//cdn.example.test/og.png">' \ | |
| 238 | + '<script type="application/ld+json">{"@type":"Organization","name":"X","numberOfEmployees":{"minValue":10,"maxValue":50},"foundingDate":"not a date","address":"12 Main St"}</script></head></html>' | |
| 239 | + f = en.parse_homepage(html, "https://www.example.test/") | |
| 240 | + assert f.description is None and f.icon == "https://cdn.example.test/og.png" and f.employees is None and f.founded_year is None and f.address == "12 Main St" | |
| 241 | + | |
| 242 | + | |
| 243 | +async def test_better_source_is_never_overwritten(fetcher: WikimediaFetcher) -> None: | |
| 244 | + """A company whose description column already came from Wikipedia keeps it when only the homepage runs; a homepage logo does not | |
| 245 | + replace a Wikidata logo, but does replace a registry one.""" | |
| 246 | + company = company_row(id="co_keep", slug="example-robotics", canonical_domain="example-robotics.test", website="https://www.example-robotics.test/", | |
| 247 | + wikidata_id=None, description="Long encyclopedic text from Wikipedia about the company.", logo_url="https://commons.wikimedia.org/x.svg", | |
| 248 | + employees=400, source_meta={"source": "wikidata", "provenance": {"description": {"source": "wikipedia", "url": WP_URL}, | |
| 249 | + "logo_url": {"source": "wikidata", "url": WD_URL}}}) | |
| 250 | + res = await en.enrich_company(company, fetcher=fetcher, sources=("homepage",), use_db=False, llm=False) | |
| 251 | + assert res.profile["description"] == "Long encyclopedic text from Wikipedia about the company." and res.profile["description_source"] == "wikipedia" | |
| 252 | + assert res.profile["logo_url"] == "https://commons.wikimedia.org/x.svg" | |
| 253 | + assert res.profile["employees"] == 420 # homepage JSON-LD outranks the registry value… | |
| 254 | + assert res.column_updates == {"employees": 420, "hq_city": "Montréal", "hq_region": "Quebec", "country": "CA", "founded_year": 2014, | |
| 255 | + "legal_name": "Example Robotics Inc."} # …and description / logo_url are left alone | |
| 256 | + # registry logo (no provenance) is replaced by the homepage JSON-LD logo | |
| 257 | + company2 = company_row(id="co_keep2", slug="example-robotics", canonical_domain="example-robotics.test", website="https://www.example-robotics.test/", | |
| 258 | + wikidata_id=None, logo_url="https://seed.example/logo.png", source_meta={"source": "wikidata"}) | |
| 259 | + res2 = await en.enrich_company(company2, fetcher=fetcher, sources=("homepage",), use_db=False, llm=False) | |
| 260 | + assert res2.column_updates["logo_url"] == "https://www.example-robotics.test/static/logo.svg" | |
| 261 | + | |
| 262 | + | |
| 263 | +# ================================================================================================================ LLM guard rails | |
| 264 | + | |
| 265 | + | |
| 266 | +def test_numbers_grounded() -> None: | |
| 267 | + src = "Founded in Montréal in 2014, the company employs 420 people and operates 2 sites." | |
| 268 | + assert en.numbers_grounded("The company was founded in 2014 and has 420 employees.", src) | |
| 269 | + assert not en.numbers_grounded("The company has 1,200 employees.", src) | |
| 270 | + assert not en.numbers_grounded("Revenue reached $3.5 billion in 2014.", src) | |
| 271 | + assert en.numbers_grounded("No figures here.", src) | |
| 272 | + | |
| 273 | + | |
| 274 | +async def test_llm_only_without_wikipedia_and_with_enough_text(fetcher: WikimediaFetcher, monkeypatch: pytest.MonkeyPatch) -> None: | |
| 275 | + calls: list[dict[str, Any]] = [] | |
| 276 | + | |
| 277 | + async def fake_llm(company: dict[str, Any], text: str, *, source_url: str) -> tuple[str | None, str | None, dict[str, Any]]: | |
| 278 | + calls.append({"company": company["id"], "chars": len(text), "url": source_url}) | |
| 279 | + return "Example Robotics builds autonomous mobile robots for warehouses and serves retailers in Canada and Europe.", "llm_x", {"status": "done"} | |
| 280 | + | |
| 281 | + monkeypatch.setattr(en, "llm_profile_text", fake_llm) | |
| 282 | + monkeypatch.setattr(en.settings, "llm_enabled", True) | |
| 283 | + monkeypatch.setattr(en.settings, "llm_base_url", "http://llm.test/v1") | |
| 284 | + company = company_row(id="co_llm", slug="example-robotics", canonical_domain="example-robotics.test", website="https://www.example-robotics.test/", | |
| 285 | + wikidata_id=None, description=None, source_meta={"source": "manual"}) | |
| 286 | + res = await en.enrich_company(company, fetcher=fetcher, sources=("homepage", "llm"), use_db=False, llm=True) | |
| 287 | + assert calls and calls[0]["chars"] >= en.settings.enrich_llm_min_text_chars and calls[0]["url"] == "https://www.example-robotics.test/" | |
| 288 | + assert res.profile["description_source"] == "llm" and res.profile["description_attribution"] == en.LLM_ATTRIBUTION and res.llm_job_id == "llm_x" | |
| 289 | + assert res.profile["description"].startswith("Example Robotics builds") and "llm" in res.sources_used | |
| 290 | + # with a Wikipedia extract the LLM is never called | |
| 291 | + calls.clear() | |
| 292 | + res = await en.enrich_company(company_row(), fetcher=fetcher, sources=("wikidata", "wikipedia", "llm"), use_db=False, llm=True) | |
| 293 | + assert not calls and res.profile["description_source"] == "wikipedia" | |
| 294 | + | |
| 295 | + | |
| 296 | +# ================================================================================================================ persistence (Postgres) | |
| 297 | + | |
| 298 | + | |
| 299 | +def test_entity() -> dict[str, Any]: | |
| 300 | + """The Q95 fixture with every company-like target renamed to a test-only QID (real companies in the local DB must never be linked).""" | |
| 301 | + import copy | |
| 302 | + | |
| 303 | + ent = copy.deepcopy(ENTITY) | |
| 304 | + ent["id"] = "QZTEST95" | |
| 305 | + for prop in ("P749", "P355", "P127", "P1830"): | |
| 306 | + for st in ent["claims"].get(prop) or []: | |
| 307 | + v = st["mainsnak"]["datavalue"]["value"] | |
| 308 | + old = v["id"] | |
| 309 | + v["id"] = "QZTEST" + old[1:] | |
| 310 | + EXTRA_LABELS[v["id"]] = LABELS.get(old) or f"ZTest {old}" | |
| 311 | + EXTRA_LABELS["QZTEST20800404"] = "ZTest Alphabet" | |
| 312 | + return ent | |
| 313 | + | |
| 314 | + | |
| 315 | +@pytest.mark.usefixtures("intel_db") | |
| 316 | +async def test_persist_people_relationships_idempotent(fetcher: WikimediaFetcher) -> None: | |
| 317 | + entity = test_entity() | |
| 318 | + wd_url = "https://www.wikidata.org/wiki/QZTEST95" | |
| 319 | + try: | |
| 320 | + async with transaction() as conn: | |
| 321 | + google = await make_company(conn, name="ZTest Google", country="US") | |
| 322 | + alphabet = await make_company(conn, name="ZTest Alphabet", country="US") | |
| 323 | + await execute(conn, "update companies set wikidata_id = 'QZTEST95', description = 'American multinational technology company', source_meta = cast(:m as jsonb) where id = :id", | |
| 324 | + id=google["id"], m=jsonb({"source": "wikidata"})) | |
| 325 | + await execute(conn, "update companies set wikidata_id = 'QZTEST20800404' where id = :id", id=alphabet["id"]) | |
| 326 | + # a page-sourced person that Wikidata also knows: must keep its page provenance and title | |
| 327 | + await execute(conn, """insert into people (id, company_id, name, name_norm, title, role_category, is_executive, status, source_url) | |
| 328 | + values ('person_ztest_pichai', :c, 'Sundar Pichai', :nn, 'CEO, Google and Alphabet', 'ceo', true, 'listed', :url)""", | |
| 329 | + c=google["id"], nn=en.norm_name("Sundar Pichai"), url="https://about.google/leadership/") | |
| 330 | + row = await fetch_one(conn, "select * from companies where id = :id", id=google["id"]) | |
| 331 | + res = await en.enrich_company(row, fetcher=fetcher, entity=entity, sources=("wikidata", "wikipedia"), use_db=True, llm=False) | |
| 332 | + async with transaction() as conn: | |
| 333 | + stored = await en.persist(conn, row, res) | |
| 334 | + assert stored["people"] >= 4 and stored["relationships_new"] >= 4 | |
| 335 | + async with transaction() as conn: | |
| 336 | + c = await fetch_one(conn, "select * from companies where id = :id", id=google["id"]) | |
| 337 | + people = await fetch_all(conn, "select * from people where company_id = :c order by name", c=google["id"]) | |
| 338 | + rels = await fetch_all(conn, "select * from company_relationships where from_company_id = :c or to_company_id = :c order by kind", c=google["id"]) | |
| 339 | + meta = c["source_meta"] | |
| 340 | + assert meta["profile"]["description_source"] == "wikipedia" and meta["enriched_at"] and meta["enrichment"]["sources"] == ["wikidata", "wikipedia"] | |
| 341 | + assert c["description"].startswith("Google LLC is an American") and meta["provenance"]["description"]["source"] == "wikipedia" | |
| 342 | + assert meta["provenance"]["description"]["previous"] == "American multinational technology company" | |
| 343 | + assert c["founded_year"] == 1998 and c["hq_city"] == "Mountain View" and c["legal_name"] == "Google LLC" and c["public_company"] is True | |
| 344 | + assert "software" in c["industries"] and c["industry_primary"] == c["industries"][0] | |
| 345 | + by_name = {p["name"]: p for p in people} | |
| 346 | + pichai = by_name["Sundar Pichai"] | |
| 347 | + assert pichai["id"] == "person_ztest_pichai" and pichai["title"] == "CEO, Google and Alphabet" and pichai["source_url"] == "https://about.google/leadership/" | |
| 348 | + assert by_name["Larry Page"]["source_url"] == wd_url and by_name["Larry Page"]["title"] == "Founder" and by_name["Larry Page"]["status"] == "listed" | |
| 349 | + assert by_name["Eric Schmidt"]["status"] == "no_longer_listed" and by_name["Eric Schmidt"]["removed_at"] is not None | |
| 350 | + sub = [r for r in rels if r["kind"] == "SUBSIDIARY_OF" and r["from_company_id"] == google["id"]] | |
| 351 | + assert len(sub) == 1 and sub[0]["to_company_id"] == alphabet["id"] and sub[0]["provenance"]["property"] == "P749" and float(sub[0]["confidence"]) == pytest.approx(0.85) | |
| 352 | + inverse = [r for r in rels if r["kind"] == "PARENT_OF" and r["from_company_id"] == alphabet["id"] and r["to_company_id"] == google["id"]] | |
| 353 | + assert len(inverse) == 1 | |
| 354 | + named = [r for r in rels if r["kind"] == "PARENT_OF" and r["from_company_id"] == google["id"]] | |
| 355 | + assert named and all(r["to_company_id"] is None and r["to_name"] for r in named) | |
| 356 | + # second run: no new people / relationships, profile refreshed, columns unchanged | |
| 357 | + res2 = await en.enrich_company(c, fetcher=fetcher, entity=entity, sources=("wikidata", "wikipedia"), use_db=True, llm=False) | |
| 358 | + assert res2.column_updates == {} and res2.industries == [] | |
| 359 | + async with transaction() as conn: | |
| 360 | + stored2 = await en.persist(conn, c, res2) | |
| 361 | + n_people = await fetch_one(conn, "select count(*) as n from people where company_id = :c", c=google["id"]) | |
| 362 | + n_rel = await fetch_one(conn, "select count(*) as n from company_relationships where from_company_id = :c or to_company_id = :c", c=google["id"]) | |
| 363 | + c2 = await fetch_one(conn, "select * from companies where id = :id", id=google["id"]) | |
| 364 | + assert stored2["relationships_new"] == 0 and stored2["relationships_seen"] == len(rels) | |
| 365 | + assert n_people["n"] == len(people) and n_rel["n"] == len(rels) | |
| 366 | + assert c2["source_meta"]["profile"]["enriched_at"] >= meta["profile"]["enriched_at"] and c2["description"] == c["description"] | |
| 367 | + # pending queue: freshly enriched companies are no longer due | |
| 368 | + async with transaction() as conn: | |
| 369 | + due = {r["id"] for r in await en.pending_companies(conn, 100000)} | |
| 370 | + assert google["id"] not in due and alphabet["id"] in due | |
| 371 | + # batch runner end-to-end (fake fetcher: the entity lookup for a test QID is "missing" → recorded, still persisted, never raises) | |
| 372 | + stats = await en.enrich_pending(company_keys=[alphabet["slug"]], fetcher=fetcher, sources=("wikidata",), llm=False) | |
| 373 | + assert stats["companies"] == 1 and stats["ok"] == 1 and stats["failed"] == 0 and stats["requests"] >= 1 | |
| 374 | + async with transaction() as conn: | |
| 375 | + a = await fetch_one(conn, "select source_meta from companies where id = :id", id=alphabet["id"]) | |
| 376 | + assert a["source_meta"]["enrichment"]["errors"] == ["wikidata: entity unavailable"] and a["source_meta"]["profile"]["version"] == "profile-v1" | |
| 377 | + finally: | |
| 378 | + await cleanup() | |
| 379 | ||