SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%

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>
Simon-Pierre Boucher committed 11 days ago (Sep 13, 2026) parent 82b8305

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