fd-open-data-protocol 0.2.0__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/PKG-INFO +93 -3
- {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/README.md +90 -2
- {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/__init__.py +1 -1
- fd_open_data_protocol-0.3.1/fd_open_data_protocol/registry.py +274 -0
- fd_open_data_protocol-0.3.1/fd_open_data_protocol/registry_cli.py +239 -0
- fd_open_data_protocol-0.3.1/fd_open_data_protocol/registry_store.py +458 -0
- fd_open_data_protocol-0.3.1/fd_open_data_protocol/schema.py +403 -0
- fd_open_data_protocol-0.3.1/fd_open_data_protocol/vocabulary/concepts.yaml +230 -0
- fd_open_data_protocol-0.3.1/fd_open_data_protocol/vocabulary/crosswalks/datacommons-worldbank.yaml +51 -0
- fd_open_data_protocol-0.3.1/fd_open_data_protocol/vocabulary/entity_types.yaml +99 -0
- fd_open_data_protocol-0.3.1/fd_open_data_protocol/vocabulary/event_types.yaml +17 -0
- fd_open_data_protocol-0.3.1/fd_open_data_protocol/vocabulary/qualifiers.yaml +51 -0
- fd_open_data_protocol-0.3.1/fd_open_data_protocol/vocabulary/relation_types.yaml +73 -0
- fd_open_data_protocol-0.3.1/fd_open_data_protocol/vocabulary/units.yaml +94 -0
- fd_open_data_protocol-0.3.1/fd_open_data_protocol/vocabulary.py +349 -0
- {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol.egg-info/PKG-INFO +93 -3
- fd_open_data_protocol-0.3.1/fd_open_data_protocol.egg-info/SOURCES.txt +29 -0
- fd_open_data_protocol-0.3.1/fd_open_data_protocol.egg-info/entry_points.txt +2 -0
- {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol.egg-info/requires.txt +3 -0
- {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/pyproject.toml +9 -0
- {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/tests/test_loader.py +35 -0
- fd_open_data_protocol-0.3.1/tests/test_registry_pipeline.py +209 -0
- fd_open_data_protocol-0.3.1/tests/test_registry_schema.py +91 -0
- fd_open_data_protocol-0.3.1/tests/test_schema_vocabulary.py +86 -0
- fd_open_data_protocol-0.3.1/tests/test_vocabulary.py +186 -0
- fd_open_data_protocol-0.2.0/fd_open_data_protocol/schema.py +0 -216
- fd_open_data_protocol-0.2.0/fd_open_data_protocol.egg-info/SOURCES.txt +0 -13
- {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/constants.py +0 -0
- {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/loader.py +0 -0
- {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/provider.py +0 -0
- {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol.egg-info/dependency_links.txt +0 -0
- {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol.egg-info/top_level.txt +0 -0
- {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fd-open-data-protocol
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: The open-data datasource protocol: a manifest contract a datasource exposes to be ingested by fd-open-data-mcp (or any consumer).
|
|
5
5
|
Author: FindDataOfficial
|
|
6
6
|
License: MIT
|
|
@@ -10,9 +10,13 @@ Requires-Dist: pydantic>=2.0
|
|
|
10
10
|
Requires-Dist: pyyaml>=6.0
|
|
11
11
|
Provides-Extra: dev
|
|
12
12
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
13
|
+
Provides-Extra: registry
|
|
14
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == "registry"
|
|
13
15
|
|
|
14
16
|
# fd-open-data-protocol
|
|
15
17
|
|
|
18
|
+
> **Wire (柏讯) product line** · the open-data supply line of [FindData](https://www.finddatatech.cloud/products/wire) — the datasource manifest contract
|
|
19
|
+
|
|
16
20
|
**English** | [中文](README.zh-CN.md)
|
|
17
21
|
|
|
18
22
|
The **open-data datasource protocol**: a manifest contract a datasource exposes
|
|
@@ -99,7 +103,7 @@ my-source = "my_pkg.catalog:CATALOG"
|
|
|
99
103
|
- **`DatasourceManifest`**: name, label, source_url, scanner_mode, ranking_seed, functions[], concepts[], entities[], entity_definitions[], relationships[], fetch.
|
|
100
104
|
- **`FunctionSpec`**: command, category, description, parameters[], columns[], frequency, verified.
|
|
101
105
|
- **`ColumnSpec`**: name, type, description, meaning, semantic_type, `frequency` + `datasource` (column-level).
|
|
102
|
-
- **`ConceptHint`**: column, concept, `entity_type`, `measure`, unit, frequency, confidence.
|
|
106
|
+
- **`ConceptHint`**: column, concept, `entity_type`, `measure`, unit, frequency, confidence, `concept_family` (optional).
|
|
103
107
|
- **`EntitySpec`**: entity_type, coverage ("universe"|"explicit"), codes[] (for explicit coverage).
|
|
104
108
|
- **`Entity`**: entity_type, code, name_en, name_zh, metadata{}, relationships[].
|
|
105
109
|
- **`EntityRelationship`**: target_entity_type, target_code, relation_type, confidence, metadata{}.
|
|
@@ -149,9 +153,33 @@ entity_definitions:
|
|
|
149
153
|
|
|
150
154
|
When included, entities are registered in the ontology database during `register_datasource()`.
|
|
151
155
|
|
|
156
|
+
#### Reserved metadata key: `external_ids`
|
|
157
|
+
|
|
158
|
+
An entity definition may anchor itself in an external entity graph under the
|
|
159
|
+
reserved metadata key `external_ids`. Registration persists each entry as a
|
|
160
|
+
per-source identifier, so consumers can resolve the external id back to the
|
|
161
|
+
local entity.
|
|
162
|
+
|
|
163
|
+
```yaml
|
|
164
|
+
entity_definitions:
|
|
165
|
+
- entity_type: country
|
|
166
|
+
code: CN
|
|
167
|
+
name_en: China
|
|
168
|
+
metadata:
|
|
169
|
+
external_ids:
|
|
170
|
+
wikidata: Q148 # Wikidata QID (Q followed by digits)
|
|
171
|
+
datacommons: country/CHN # Data Commons DCID
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Supported anchor sources are `wikidata` (QID, validated as `Q<digits>`) and
|
|
175
|
+
`datacommons` (non-empty DCID). A malformed or unknown anchor is logged and
|
|
176
|
+
skipped — it never fails registration.
|
|
177
|
+
|
|
152
178
|
### Canonical Entity Types
|
|
153
179
|
|
|
154
|
-
|
|
180
|
+
The entity-type vocabulary is defined by
|
|
181
|
+
[`fd_open_data_protocol/vocabulary/entity_types.yaml`](fd_open_data_protocol/vocabulary/entity_types.yaml)
|
|
182
|
+
(14 types) and is the authority for manifest validation:
|
|
155
183
|
|
|
156
184
|
| Type | Description | Example IDs |
|
|
157
185
|
|------|-------------|-------------|
|
|
@@ -165,7 +193,69 @@ All `entity_type` values must be from this vocabulary:
|
|
|
165
193
|
| `crypto` | Cryptocurrencies | btc, eth |
|
|
166
194
|
| `organization` | General orgs | org_code |
|
|
167
195
|
| `industry` | Classifications | shenwan_1_01, gics_10 |
|
|
196
|
+
| `exchange` | Exchanges / venues | XNYS, XNAS, SSE |
|
|
168
197
|
| `company` | Public companies | AAPL, TSLA |
|
|
198
|
+
| `commodity` | Raw goods | steel, oil, grain |
|
|
199
|
+
| `person` | Humans (fund managers) | person_code |
|
|
200
|
+
|
|
201
|
+
## Semantic vocabulary
|
|
202
|
+
|
|
203
|
+
The protocol ships the publishable semantic core of FindData as versioned YAML
|
|
204
|
+
under [`fd_open_data_protocol/vocabulary/`](fd_open_data_protocol/vocabulary/):
|
|
205
|
+
|
|
206
|
+
| File | Registry |
|
|
207
|
+
|------|----------|
|
|
208
|
+
| `entity_types.yaml` | entity types (the manifest vocabulary above) |
|
|
209
|
+
| `concepts.yaml` | concept families + their seeded Variables |
|
|
210
|
+
| `qualifiers.yaml` | qualifiers (nominal_current, real_constant, ppp, per_capita, growth, ...) |
|
|
211
|
+
| `units.yaml` | units of measure (UN/CEFACT codes + ISO 4217 currencies) |
|
|
212
|
+
| `relation_types.yaml` | entity relationship types |
|
|
213
|
+
| `event_types.yaml` | event types (empty in this release) |
|
|
214
|
+
| `crosswalks/*.yaml` | Variable ↔ external-vocabulary assertions |
|
|
215
|
+
|
|
216
|
+
The vocabulary files ship as package data (`0.3.0`+), so `load_vocabulary()`
|
|
217
|
+
works from an installed wheel with no checkout on disk.
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
from fd_open_data_protocol.vocabulary import load_vocabulary, validate_vocabulary
|
|
221
|
+
|
|
222
|
+
vocab = load_vocabulary() # shipped files
|
|
223
|
+
vocab = load_vocabulary("/path/to/dir") # or an explicit directory
|
|
224
|
+
vocab.version # release version of the file set
|
|
225
|
+
vocab.get("concept", "GDP").uri # https://schema.finddata.tech/concept/GDP
|
|
226
|
+
vocab.entity_type_ids() # the manifest entity-type vocabulary
|
|
227
|
+
vocab.seeded_variables() # Variables a family declares
|
|
228
|
+
errors = validate_vocabulary() # [] when the file set is valid
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Every entry carries a stable URI of the form
|
|
232
|
+
`https://schema.finddata.tech/<kind>/<Id>`, where `<kind>` is one of
|
|
233
|
+
`entity-type`, `concept`, `qualifier`, `unit`, `relation-type`, `event-type`.
|
|
234
|
+
|
|
235
|
+
**Stability rules.** A URI is never reassigned to a different meaning and
|
|
236
|
+
entries are never deleted. Retirement is expressed with a `superseded_by`
|
|
237
|
+
pointer to the successor entry, which keeps the URI resolvable. The file set
|
|
238
|
+
carries one release `version`, and the loader rejects a file set whose files
|
|
239
|
+
disagree.
|
|
240
|
+
|
|
241
|
+
## Concept families and Variables
|
|
242
|
+
|
|
243
|
+
Concepts are two-level. A **concept family** (`fd:GDP`, `fd:Population`) is the
|
|
244
|
+
small curated vocabulary from `concepts.yaml`; a **Variable** is the qualified
|
|
245
|
+
combination `(code, entity_type, measure, unit, frequency)` — the identity a
|
|
246
|
+
datasource actually binds columns to.
|
|
247
|
+
|
|
248
|
+
A manifest concept hint may point at its family with the optional
|
|
249
|
+
`concept_family` field, which the loader validates against `concepts.yaml`:
|
|
250
|
+
|
|
251
|
+
```yaml
|
|
252
|
+
concepts:
|
|
253
|
+
- {column: close, concept: price.close, entity_type: stock, unit: currency,
|
|
254
|
+
frequency: daily, concept_family: PriceClose}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Omitting it preserves the previous behaviour — no family validation occurs, and
|
|
258
|
+
the consumer derives a family from the concept code.
|
|
169
259
|
|
|
170
260
|
## Manifest Declaration Requirement
|
|
171
261
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# fd-open-data-protocol
|
|
2
2
|
|
|
3
|
+
> **Wire (柏讯) product line** · the open-data supply line of [FindData](https://www.finddatatech.cloud/products/wire) — the datasource manifest contract
|
|
4
|
+
|
|
3
5
|
**English** | [中文](README.zh-CN.md)
|
|
4
6
|
|
|
5
7
|
The **open-data datasource protocol**: a manifest contract a datasource exposes
|
|
@@ -86,7 +88,7 @@ my-source = "my_pkg.catalog:CATALOG"
|
|
|
86
88
|
- **`DatasourceManifest`**: name, label, source_url, scanner_mode, ranking_seed, functions[], concepts[], entities[], entity_definitions[], relationships[], fetch.
|
|
87
89
|
- **`FunctionSpec`**: command, category, description, parameters[], columns[], frequency, verified.
|
|
88
90
|
- **`ColumnSpec`**: name, type, description, meaning, semantic_type, `frequency` + `datasource` (column-level).
|
|
89
|
-
- **`ConceptHint`**: column, concept, `entity_type`, `measure`, unit, frequency, confidence.
|
|
91
|
+
- **`ConceptHint`**: column, concept, `entity_type`, `measure`, unit, frequency, confidence, `concept_family` (optional).
|
|
90
92
|
- **`EntitySpec`**: entity_type, coverage ("universe"|"explicit"), codes[] (for explicit coverage).
|
|
91
93
|
- **`Entity`**: entity_type, code, name_en, name_zh, metadata{}, relationships[].
|
|
92
94
|
- **`EntityRelationship`**: target_entity_type, target_code, relation_type, confidence, metadata{}.
|
|
@@ -136,9 +138,33 @@ entity_definitions:
|
|
|
136
138
|
|
|
137
139
|
When included, entities are registered in the ontology database during `register_datasource()`.
|
|
138
140
|
|
|
141
|
+
#### Reserved metadata key: `external_ids`
|
|
142
|
+
|
|
143
|
+
An entity definition may anchor itself in an external entity graph under the
|
|
144
|
+
reserved metadata key `external_ids`. Registration persists each entry as a
|
|
145
|
+
per-source identifier, so consumers can resolve the external id back to the
|
|
146
|
+
local entity.
|
|
147
|
+
|
|
148
|
+
```yaml
|
|
149
|
+
entity_definitions:
|
|
150
|
+
- entity_type: country
|
|
151
|
+
code: CN
|
|
152
|
+
name_en: China
|
|
153
|
+
metadata:
|
|
154
|
+
external_ids:
|
|
155
|
+
wikidata: Q148 # Wikidata QID (Q followed by digits)
|
|
156
|
+
datacommons: country/CHN # Data Commons DCID
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Supported anchor sources are `wikidata` (QID, validated as `Q<digits>`) and
|
|
160
|
+
`datacommons` (non-empty DCID). A malformed or unknown anchor is logged and
|
|
161
|
+
skipped — it never fails registration.
|
|
162
|
+
|
|
139
163
|
### Canonical Entity Types
|
|
140
164
|
|
|
141
|
-
|
|
165
|
+
The entity-type vocabulary is defined by
|
|
166
|
+
[`fd_open_data_protocol/vocabulary/entity_types.yaml`](fd_open_data_protocol/vocabulary/entity_types.yaml)
|
|
167
|
+
(14 types) and is the authority for manifest validation:
|
|
142
168
|
|
|
143
169
|
| Type | Description | Example IDs |
|
|
144
170
|
|------|-------------|-------------|
|
|
@@ -152,7 +178,69 @@ All `entity_type` values must be from this vocabulary:
|
|
|
152
178
|
| `crypto` | Cryptocurrencies | btc, eth |
|
|
153
179
|
| `organization` | General orgs | org_code |
|
|
154
180
|
| `industry` | Classifications | shenwan_1_01, gics_10 |
|
|
181
|
+
| `exchange` | Exchanges / venues | XNYS, XNAS, SSE |
|
|
155
182
|
| `company` | Public companies | AAPL, TSLA |
|
|
183
|
+
| `commodity` | Raw goods | steel, oil, grain |
|
|
184
|
+
| `person` | Humans (fund managers) | person_code |
|
|
185
|
+
|
|
186
|
+
## Semantic vocabulary
|
|
187
|
+
|
|
188
|
+
The protocol ships the publishable semantic core of FindData as versioned YAML
|
|
189
|
+
under [`fd_open_data_protocol/vocabulary/`](fd_open_data_protocol/vocabulary/):
|
|
190
|
+
|
|
191
|
+
| File | Registry |
|
|
192
|
+
|------|----------|
|
|
193
|
+
| `entity_types.yaml` | entity types (the manifest vocabulary above) |
|
|
194
|
+
| `concepts.yaml` | concept families + their seeded Variables |
|
|
195
|
+
| `qualifiers.yaml` | qualifiers (nominal_current, real_constant, ppp, per_capita, growth, ...) |
|
|
196
|
+
| `units.yaml` | units of measure (UN/CEFACT codes + ISO 4217 currencies) |
|
|
197
|
+
| `relation_types.yaml` | entity relationship types |
|
|
198
|
+
| `event_types.yaml` | event types (empty in this release) |
|
|
199
|
+
| `crosswalks/*.yaml` | Variable ↔ external-vocabulary assertions |
|
|
200
|
+
|
|
201
|
+
The vocabulary files ship as package data (`0.3.0`+), so `load_vocabulary()`
|
|
202
|
+
works from an installed wheel with no checkout on disk.
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
from fd_open_data_protocol.vocabulary import load_vocabulary, validate_vocabulary
|
|
206
|
+
|
|
207
|
+
vocab = load_vocabulary() # shipped files
|
|
208
|
+
vocab = load_vocabulary("/path/to/dir") # or an explicit directory
|
|
209
|
+
vocab.version # release version of the file set
|
|
210
|
+
vocab.get("concept", "GDP").uri # https://schema.finddata.tech/concept/GDP
|
|
211
|
+
vocab.entity_type_ids() # the manifest entity-type vocabulary
|
|
212
|
+
vocab.seeded_variables() # Variables a family declares
|
|
213
|
+
errors = validate_vocabulary() # [] when the file set is valid
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Every entry carries a stable URI of the form
|
|
217
|
+
`https://schema.finddata.tech/<kind>/<Id>`, where `<kind>` is one of
|
|
218
|
+
`entity-type`, `concept`, `qualifier`, `unit`, `relation-type`, `event-type`.
|
|
219
|
+
|
|
220
|
+
**Stability rules.** A URI is never reassigned to a different meaning and
|
|
221
|
+
entries are never deleted. Retirement is expressed with a `superseded_by`
|
|
222
|
+
pointer to the successor entry, which keeps the URI resolvable. The file set
|
|
223
|
+
carries one release `version`, and the loader rejects a file set whose files
|
|
224
|
+
disagree.
|
|
225
|
+
|
|
226
|
+
## Concept families and Variables
|
|
227
|
+
|
|
228
|
+
Concepts are two-level. A **concept family** (`fd:GDP`, `fd:Population`) is the
|
|
229
|
+
small curated vocabulary from `concepts.yaml`; a **Variable** is the qualified
|
|
230
|
+
combination `(code, entity_type, measure, unit, frequency)` — the identity a
|
|
231
|
+
datasource actually binds columns to.
|
|
232
|
+
|
|
233
|
+
A manifest concept hint may point at its family with the optional
|
|
234
|
+
`concept_family` field, which the loader validates against `concepts.yaml`:
|
|
235
|
+
|
|
236
|
+
```yaml
|
|
237
|
+
concepts:
|
|
238
|
+
- {column: close, concept: price.close, entity_type: stock, unit: currency,
|
|
239
|
+
frequency: daily, concept_family: PriceClose}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Omitting it preserves the previous behaviour — no family validation occurs, and
|
|
243
|
+
the consumer derives a family from the concept code.
|
|
156
244
|
|
|
157
245
|
## Manifest Declaration Requirement
|
|
158
246
|
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Declarative registry adapters and coverage computation.
|
|
2
|
+
|
|
3
|
+
An adapter is a small YAML file mapping a source table's rows onto
|
|
4
|
+
:class:`~fd_open_data_protocol.schema.RegistryEntry` fields - no core code
|
|
5
|
+
changes when a new source database is onboarded. The recipe (see REGISTRY.md):
|
|
6
|
+
|
|
7
|
+
写适配器 → 批量登记(register)→ 概念绑定(bind)→ 命名门核验(verify)
|
|
8
|
+
|
|
9
|
+
Row sources are independent of mapping: rows can come from a Postgres source
|
|
10
|
+
DSN or a CSV file (tests, dry runs, the shipped template example), so the
|
|
11
|
+
whole pipeline is exercisable without a database.
|
|
12
|
+
|
|
13
|
+
Coverage is asset-denominated: per source "registered / actual in source",
|
|
14
|
+
with the denominator taken from the source database (or supplied counts) -
|
|
15
|
+
never from an MCP concept dictionary.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import csv
|
|
20
|
+
import json
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Iterable, Optional, Sequence
|
|
23
|
+
|
|
24
|
+
import yaml
|
|
25
|
+
from pydantic import BaseModel, Field, field_validator
|
|
26
|
+
|
|
27
|
+
from fd_open_data_protocol.schema import RegistryEntry
|
|
28
|
+
|
|
29
|
+
#: registry fields an adapter may map; anchors come from dedicated keys.
|
|
30
|
+
MAPPABLE_FIELDS = ("semantic_code", "name_zh", "name_en", "unit", "frequency", "domain")
|
|
31
|
+
|
|
32
|
+
#: sources known to the fleet but NOT in the first registration batch; the
|
|
33
|
+
#: coverage view must show them explicitly instead of silently omitting them.
|
|
34
|
+
DEFAULT_SCOPE: dict[str, dict[str, Any]] = {
|
|
35
|
+
"world_bank": {"phase": 1, "label": "世界银行 WDI(含宏观数据链路)"},
|
|
36
|
+
"yearbook_catalog": {"phase": 1, "label": "国家统计年鉴"},
|
|
37
|
+
"gta_panel": {"phase": 1, "label": "GTA 治理面板"},
|
|
38
|
+
"china_city_panel": {"phase": 1, "label": "中国城市面板"},
|
|
39
|
+
"law_db": {
|
|
40
|
+
"phase": 1,
|
|
41
|
+
"label": "法律库·文档域(法条/切片/指导案例为文档资产,非指标口径;登记 0 属预期,经 business-mcp 可查)",
|
|
42
|
+
},
|
|
43
|
+
"fd_open_data": {"phase": 1, "label": "fd_open_data 镜像概念字典"},
|
|
44
|
+
"ia35": {"phase": 2, "label": "工商注册 161G(实体为主,二期)"},
|
|
45
|
+
"wenshu_db": {"phase": 2, "label": "裁判文书(增量暂停,二期)"},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class FieldSource(BaseModel):
|
|
50
|
+
"""Where one registry field's value comes from.
|
|
51
|
+
|
|
52
|
+
``from: column`` - a column of the source row (or CSV header)
|
|
53
|
+
``from: literal`` - a constant (e.g. ``domain: macro``)
|
|
54
|
+
``from: map_file``- a JSON ``{native_code: value}`` lookup, typically a
|
|
55
|
+
curated/LLM-drafted naming map (name translations,
|
|
56
|
+
semantic codes); misses fall back to ``missing``
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from_: str = Field(alias="from")
|
|
60
|
+
value: str
|
|
61
|
+
missing: Optional[str] = None # value to store on a miss; default None = absent
|
|
62
|
+
uppercase: bool = False
|
|
63
|
+
|
|
64
|
+
@field_validator("from_")
|
|
65
|
+
@classmethod
|
|
66
|
+
def _known_kind(cls, v: str) -> str:
|
|
67
|
+
if v not in ("column", "literal", "map_file"):
|
|
68
|
+
raise ValueError(f"unknown field source kind: {v!r} (column|literal|map_file)")
|
|
69
|
+
return v
|
|
70
|
+
|
|
71
|
+
model_config = {"populate_by_name": True}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AdapterConfig(BaseModel):
|
|
75
|
+
"""Declarative source-table → registry mapping."""
|
|
76
|
+
|
|
77
|
+
source_db: str
|
|
78
|
+
source_table: str
|
|
79
|
+
source_column: str = "" # anchor label: the column the native code was read from
|
|
80
|
+
native_code_column: str
|
|
81
|
+
fields: dict[str, FieldSource]
|
|
82
|
+
provenance_columns: list[str] = Field(default_factory=list)
|
|
83
|
+
source_query: Optional[str] = None # default: SELECT * FROM <source_table>
|
|
84
|
+
semantic_code_dedupe_suffix: str = "-%d" # in-batch collision suffix (marked needs_review)
|
|
85
|
+
|
|
86
|
+
@field_validator("fields")
|
|
87
|
+
@classmethod
|
|
88
|
+
def _fields_known(cls, v: dict[str, FieldSource]) -> dict[str, FieldSource]:
|
|
89
|
+
unknown = set(v) - set(MAPPABLE_FIELDS)
|
|
90
|
+
if unknown:
|
|
91
|
+
raise ValueError(f"unknown mappable field(s): {sorted(unknown)}")
|
|
92
|
+
return v
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load_adapter(source: str | Path | dict) -> AdapterConfig:
|
|
96
|
+
"""Load an adapter from a YAML/JSON file path or an already-parsed dict."""
|
|
97
|
+
if isinstance(source, dict):
|
|
98
|
+
return AdapterConfig(**source)
|
|
99
|
+
p = Path(source)
|
|
100
|
+
data = yaml.safe_load(p.read_text(encoding="utf-8"))
|
|
101
|
+
if not isinstance(data, dict):
|
|
102
|
+
raise ValueError(f"adapter file did not parse to a mapping: {p}")
|
|
103
|
+
adapter = AdapterConfig(**data)
|
|
104
|
+
adapter._base_dir = p.parent # type: ignore[attr-defined] # resolve relative map_files
|
|
105
|
+
return adapter
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _lookup(
|
|
109
|
+
spec: FieldSource, row: dict, native_code: str, base_dir: Path
|
|
110
|
+
) -> Optional[str]:
|
|
111
|
+
value: Any
|
|
112
|
+
if spec.from_ == "literal":
|
|
113
|
+
value = spec.value
|
|
114
|
+
elif spec.from_ == "column":
|
|
115
|
+
value = row.get(spec.value)
|
|
116
|
+
else: # map_file
|
|
117
|
+
map_path = (base_dir / spec.value) if not Path(spec.value).is_absolute() else Path(spec.value)
|
|
118
|
+
try:
|
|
119
|
+
mapping = json.loads(map_path.read_text(encoding="utf-8"))
|
|
120
|
+
except FileNotFoundError:
|
|
121
|
+
value = spec.missing
|
|
122
|
+
mapping = None
|
|
123
|
+
else:
|
|
124
|
+
value = mapping.get(native_code, spec.missing)
|
|
125
|
+
if value is None:
|
|
126
|
+
return None
|
|
127
|
+
text = str(value).strip()
|
|
128
|
+
if not text:
|
|
129
|
+
return None
|
|
130
|
+
return text.upper() if spec.uppercase else text
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def map_rows(
|
|
134
|
+
adapter: AdapterConfig, rows: Iterable[dict[str, Any]]
|
|
135
|
+
) -> list[RegistryEntry]:
|
|
136
|
+
"""Map source rows to registry entries (pure; no store access).
|
|
137
|
+
|
|
138
|
+
In-batch semantic_code collisions get a numeric suffix and a
|
|
139
|
+
``needs_review: true`` provenance flag - never a silent merge.
|
|
140
|
+
"""
|
|
141
|
+
base_dir = getattr(adapter, "_base_dir", Path("."))
|
|
142
|
+
seen_codes: dict[str, int] = {}
|
|
143
|
+
entries: list[RegistryEntry] = []
|
|
144
|
+
for row in rows:
|
|
145
|
+
native_code = str(row.get(adapter.native_code_column, "") or "").strip()
|
|
146
|
+
if not native_code:
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"row has empty {adapter.native_code_column!r}; adapter={adapter.source_db}.{adapter.source_table}"
|
|
149
|
+
)
|
|
150
|
+
fields: dict[str, Optional[str]] = {}
|
|
151
|
+
needs_review = False
|
|
152
|
+
for name, spec in adapter.fields.items():
|
|
153
|
+
value = _lookup(spec, row, native_code, base_dir)
|
|
154
|
+
if name == "semantic_code" and value is not None:
|
|
155
|
+
base = value
|
|
156
|
+
n = seen_codes.get(base, 0)
|
|
157
|
+
if n:
|
|
158
|
+
value = base + (adapter.semantic_code_dedupe_suffix % (n + 1))
|
|
159
|
+
needs_review = True
|
|
160
|
+
seen_codes[base] = n + 1
|
|
161
|
+
fields[name] = value
|
|
162
|
+
provenance = {c: row.get(c) for c in adapter.provenance_columns if c in row}
|
|
163
|
+
if needs_review:
|
|
164
|
+
provenance["needs_review"] = True
|
|
165
|
+
entries.append(
|
|
166
|
+
RegistryEntry(
|
|
167
|
+
source_db=adapter.source_db,
|
|
168
|
+
source_table=adapter.source_table,
|
|
169
|
+
source_column=adapter.source_column or adapter.native_code_column,
|
|
170
|
+
native_code=native_code,
|
|
171
|
+
provenance=provenance or None,
|
|
172
|
+
**fields,
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
return entries
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def fetch_rows(adapter: AdapterConfig, source_dsn: str) -> list[dict[str, Any]]:
|
|
179
|
+
"""Read all source rows via Postgres (psycopg3, dict rows)."""
|
|
180
|
+
import psycopg
|
|
181
|
+
|
|
182
|
+
sql = adapter.source_query or f"SELECT * FROM {adapter.source_table}"
|
|
183
|
+
with psycopg.connect(source_dsn) as conn:
|
|
184
|
+
with conn.cursor() as cur:
|
|
185
|
+
cur.execute(sql)
|
|
186
|
+
cols = [d.name for d in cur.description]
|
|
187
|
+
return [dict(zip(cols, r)) for r in cur.fetchall()]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def read_rows_csv(path: str | Path) -> list[dict[str, Any]]:
|
|
191
|
+
"""Read source rows from a CSV (headers = column names). DB-free path."""
|
|
192
|
+
with open(path, encoding="utf-8-sig", newline="") as fh:
|
|
193
|
+
return [{k: v for k, v in row.items() if k is not None} for row in csv.DictReader(fh)]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Coverage (asset-denominated)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def count_source_rows(adapter: AdapterConfig, source_dsn: str) -> int:
|
|
201
|
+
"""Actual indicator rows in the source table - the coverage denominator."""
|
|
202
|
+
import psycopg
|
|
203
|
+
|
|
204
|
+
table = adapter.source_table
|
|
205
|
+
with psycopg.connect(source_dsn) as conn:
|
|
206
|
+
with conn.cursor() as cur:
|
|
207
|
+
cur.execute(f"SELECT count(*) FROM {table}")
|
|
208
|
+
return cur.fetchone()[0]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def coverage_report(
|
|
212
|
+
store,
|
|
213
|
+
actual_counts: Optional[dict[str, int]] = None,
|
|
214
|
+
scope: Optional[dict[str, dict[str, Any]]] = None,
|
|
215
|
+
module_map: Optional[dict[str, dict[str, list[str]]]] = None,
|
|
216
|
+
) -> dict[str, Any]:
|
|
217
|
+
"""Per-source registered/actual coverage + phase-2 visibility + unmapped audit.
|
|
218
|
+
|
|
219
|
+
``actual_counts``: {source_db: actual rows} - from the source databases
|
|
220
|
+
themselves (``count_source_rows``) or supplied by the caller.
|
|
221
|
+
``module_map``: {"module": {"source_dbs": [...]}} - registered entries not
|
|
222
|
+
matched by any module rule are listed as unmapped.
|
|
223
|
+
"""
|
|
224
|
+
actual_counts = actual_counts or {}
|
|
225
|
+
scope = scope or DEFAULT_SCOPE
|
|
226
|
+
registered_by_db: dict[str, dict[str, int]] = {}
|
|
227
|
+
for (source_db, _source_table), counts in store.count_by_source().items():
|
|
228
|
+
bucket = registered_by_db.setdefault(source_db, {"registered": 0, "verified": 0})
|
|
229
|
+
bucket["registered"] += counts["registered"]
|
|
230
|
+
bucket["verified"] += counts["verified"]
|
|
231
|
+
|
|
232
|
+
sources: list[dict[str, Any]] = []
|
|
233
|
+
visible = (
|
|
234
|
+
set(registered_by_db)
|
|
235
|
+
| set(actual_counts)
|
|
236
|
+
| {s for s, v in scope.items() if v.get("phase") == 2}
|
|
237
|
+
)
|
|
238
|
+
for source_db in sorted(visible):
|
|
239
|
+
reg = registered_by_db.get(source_db, {"registered": 0, "verified": 0})
|
|
240
|
+
actual = actual_counts.get(source_db)
|
|
241
|
+
info = scope.get(source_db, {})
|
|
242
|
+
sources.append(
|
|
243
|
+
{
|
|
244
|
+
"source_db": source_db,
|
|
245
|
+
"label": info.get("label", ""),
|
|
246
|
+
"phase": info.get("phase", 1),
|
|
247
|
+
"registered": reg["registered"],
|
|
248
|
+
"verified": reg["verified"],
|
|
249
|
+
"unverified": reg["registered"] - reg["verified"],
|
|
250
|
+
"actual_in_source": actual,
|
|
251
|
+
"coverage": (
|
|
252
|
+
round(reg["registered"] / actual, 4) if actual else None
|
|
253
|
+
),
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
report: dict[str, Any] = {"sources": sources}
|
|
258
|
+
if module_map:
|
|
259
|
+
unmapped = []
|
|
260
|
+
covered_dbs = {db for m in module_map.values() for db in m.get("source_dbs", [])}
|
|
261
|
+
for entry in store.iter_entries():
|
|
262
|
+
if entry.source_db not in covered_dbs:
|
|
263
|
+
unmapped.append(
|
|
264
|
+
{
|
|
265
|
+
"source_db": entry.source_db,
|
|
266
|
+
"native_code": entry.native_code,
|
|
267
|
+
"semantic_code": entry.semantic_code,
|
|
268
|
+
"name_zh": entry.name_zh,
|
|
269
|
+
}
|
|
270
|
+
)
|
|
271
|
+
report["module_map"] = module_map
|
|
272
|
+
report["unmapped_count"] = len(unmapped)
|
|
273
|
+
report["unmapped_sample"] = unmapped[:200]
|
|
274
|
+
return report
|