fd-open-data-protocol 0.3.0__tar.gz → 0.3.2__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.3.0 → fd_open_data_protocol-0.3.2}/PKG-INFO +5 -1
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/README.md +2 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/__init__.py +1 -1
- fd_open_data_protocol-0.3.2/fd_open_data_protocol/openalex_provider.py +213 -0
- fd_open_data_protocol-0.3.2/fd_open_data_protocol/registry.py +274 -0
- fd_open_data_protocol-0.3.2/fd_open_data_protocol/registry_cli.py +239 -0
- fd_open_data_protocol-0.3.2/fd_open_data_protocol/registry_store.py +458 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/schema.py +120 -1
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/concepts.yaml +62 -1
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/crosswalks/datacommons-worldbank.yaml +1 -1
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/entity_types.yaml +78 -1
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/event_types.yaml +1 -1
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/qualifiers.yaml +1 -1
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/relation_types.yaml +46 -1
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/units.yaml +8 -1
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol.egg-info/PKG-INFO +5 -1
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol.egg-info/SOURCES.txt +8 -0
- fd_open_data_protocol-0.3.2/fd_open_data_protocol.egg-info/entry_points.txt +2 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol.egg-info/requires.txt +3 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/pyproject.toml +4 -0
- fd_open_data_protocol-0.3.2/tests/test_openalex_provider.py +170 -0
- fd_open_data_protocol-0.3.2/tests/test_registry_pipeline.py +209 -0
- fd_open_data_protocol-0.3.2/tests/test_registry_schema.py +91 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/constants.py +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/loader.py +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/provider.py +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary.py +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol.egg-info/dependency_links.txt +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol.egg-info/top_level.txt +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/setup.cfg +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/tests/test_loader.py +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/tests/test_schema_vocabulary.py +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/tests/test_vocabulary.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fd-open-data-protocol
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
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
|
|
@@ -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
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""OpenAlex 学术图谱数据源 provider(CC0,免费)。
|
|
2
|
+
|
|
3
|
+
契约要点(对应 academic-graph-connector spec):
|
|
4
|
+
- 礼貌调用:请求带含 mail 的 User-Agent(免费档 1000 credits/次级)
|
|
5
|
+
- 失败显式:配额/限流/不可达分别报错,**禁止返回空结果冒充无数据**
|
|
6
|
+
- 收录偏差:未收录如实标注,不断言「不存在引用」
|
|
7
|
+
- 引用图是增益,不替代既有 papers 检索
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.parse
|
|
15
|
+
import urllib.request
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .provider import BaseDataProvider
|
|
19
|
+
|
|
20
|
+
API = "https://api.openalex.org"
|
|
21
|
+
DEFAULT_MAILTO = os.environ.get("OPENALEX_MAILTO", "ops@finddata.dev")
|
|
22
|
+
TIMEOUT = float(os.environ.get("OPENALEX_TIMEOUT", "25"))
|
|
23
|
+
|
|
24
|
+
# 礼貌调用:OpenAlex 要求可识别的 User-Agent(含 mail),否则可能被限流
|
|
25
|
+
UA = f"finddata-academic-connector/1.0 (mailto:{DEFAULT_MAILTO})"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OpenAlexUnavailable(RuntimeError):
|
|
29
|
+
"""上游不可用/不可达——显式失败,不静默返回空。"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class OpenAlexQuotaExceeded(RuntimeError):
|
|
33
|
+
"""配额/限流——显式失败,不冒充「无数据」。"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OpenAlexProvider(BaseDataProvider):
|
|
37
|
+
name = "academic-openalex"
|
|
38
|
+
|
|
39
|
+
def registry(self) -> dict:
|
|
40
|
+
return {"manifest": "academic_openalex.yaml"}
|
|
41
|
+
|
|
42
|
+
def run(self, command: str, params: dict) -> Any:
|
|
43
|
+
handlers = {
|
|
44
|
+
"get_work": self.get_work,
|
|
45
|
+
"search_works": self.search_works,
|
|
46
|
+
"get_references": self.get_references,
|
|
47
|
+
"get_citations": self.get_citations,
|
|
48
|
+
}
|
|
49
|
+
if command not in handlers:
|
|
50
|
+
raise ValueError(f"unknown command {command!r}")
|
|
51
|
+
return handlers[command](params or {})
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------------ HTTP
|
|
54
|
+
|
|
55
|
+
def _get(self, path: str, params: dict[str, Any] | None = None) -> tuple[dict, dict]:
|
|
56
|
+
"""GET 并返回 (payload, quota_headers)。失败显式抛错。"""
|
|
57
|
+
url = f"{API}/{path}"
|
|
58
|
+
if params:
|
|
59
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
60
|
+
req = urllib.request.Request(
|
|
61
|
+
url, headers={"User-Agent": UA, "Accept": "application/json"}
|
|
62
|
+
)
|
|
63
|
+
try:
|
|
64
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
|
65
|
+
raw = json.loads(resp.read().decode("utf-8", "replace"))
|
|
66
|
+
headers = dict(resp.headers.items())
|
|
67
|
+
except urllib.error.HTTPError as exc:
|
|
68
|
+
# 429 或 5xx 都要区分清楚:前者是配额,后者是上游故障
|
|
69
|
+
if exc.code == 429:
|
|
70
|
+
raise OpenAlexQuotaExceeded(
|
|
71
|
+
f"OpenAlex rate limit / quota exceeded (HTTP 429): {exc.read()[:200]!r}"
|
|
72
|
+
) from exc
|
|
73
|
+
if 500 <= exc.code < 600:
|
|
74
|
+
raise OpenAlexUnavailable(
|
|
75
|
+
f"OpenAlex upstream unavailable (HTTP {exc.code})"
|
|
76
|
+
) from exc
|
|
77
|
+
raise OpenAlexUnavailable(
|
|
78
|
+
f"OpenAlex request failed (HTTP {exc.code}): {exc.read()[:200]!r}"
|
|
79
|
+
) from exc
|
|
80
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
81
|
+
raise OpenAlexUnavailable(f"OpenAlex unreachable: {exc}") from exc
|
|
82
|
+
quota = {
|
|
83
|
+
k.lower(): v
|
|
84
|
+
for k, v in headers.items()
|
|
85
|
+
if k.lower().startswith("x-ratelimit") or k.lower() == "retry-after"
|
|
86
|
+
}
|
|
87
|
+
return raw, quota
|
|
88
|
+
|
|
89
|
+
def _resolve_work_id(self, params: dict) -> str:
|
|
90
|
+
"""按 work_id 或 doi 定位作品。两者皆缺则报错(不猜)。"""
|
|
91
|
+
work_id = (params.get("work_id") or "").strip()
|
|
92
|
+
doi = (params.get("doi") or "").strip()
|
|
93
|
+
if not work_id and not doi:
|
|
94
|
+
raise ValueError("get_references/get_citations requires `work_id` or `doi`")
|
|
95
|
+
if work_id:
|
|
96
|
+
# 允许传完整 URL,统一取末段
|
|
97
|
+
return work_id.rstrip("/").split("/")[-1]
|
|
98
|
+
payload, _ = self._get(
|
|
99
|
+
"works",
|
|
100
|
+
{"filter": f"doi:{doi}", "select": "id", "per-page": "1"},
|
|
101
|
+
)
|
|
102
|
+
results = payload.get("results") or []
|
|
103
|
+
if not results:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"work not found in OpenAlex for doi={doi!r} "
|
|
106
|
+
f"(未收录,不等于该作品不存在)"
|
|
107
|
+
)
|
|
108
|
+
return str(results[0]["id"]).rstrip("/").split("/")[-1]
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------- commands
|
|
111
|
+
|
|
112
|
+
def get_work(self, params: dict) -> dict:
|
|
113
|
+
doi = (params.get("doi") or "").strip()
|
|
114
|
+
if not doi:
|
|
115
|
+
raise ValueError("get_work requires `doi`")
|
|
116
|
+
payload, quota = self._get(
|
|
117
|
+
"works", {"filter": f"doi:{doi}", "per-page": "1"}
|
|
118
|
+
)
|
|
119
|
+
results = payload.get("results") or []
|
|
120
|
+
if not results:
|
|
121
|
+
# 未收录 ≠ 不存在:如实说明
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"work not found in OpenAlex for doi={doi!r} (未收录,不等于该作品不存在)"
|
|
124
|
+
)
|
|
125
|
+
w = results[0]
|
|
126
|
+
return {
|
|
127
|
+
"work_id": str(w.get("id", "")).rstrip("/").split("/")[-1],
|
|
128
|
+
"title": w.get("title"),
|
|
129
|
+
"year": w.get("publication_year"),
|
|
130
|
+
"venue": (w.get("primary_location") or {}).get("source", {}).get("display_name")
|
|
131
|
+
if isinstance(w.get("primary_location"), dict)
|
|
132
|
+
else None,
|
|
133
|
+
"cited_by_count": w.get("cited_by_count"),
|
|
134
|
+
"doi": w.get("doi"),
|
|
135
|
+
"source": "openalex",
|
|
136
|
+
"license": "CC0",
|
|
137
|
+
"quota": quota,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
def search_works(self, params: dict) -> dict:
|
|
141
|
+
query = (params.get("query") or "").strip()
|
|
142
|
+
if not query:
|
|
143
|
+
raise ValueError("search_works requires `query`")
|
|
144
|
+
limit = int(params.get("limit") or 10)
|
|
145
|
+
payload, quota = self._get(
|
|
146
|
+
"works",
|
|
147
|
+
{
|
|
148
|
+
"search": query,
|
|
149
|
+
"per-page": str(min(limit, 200)),
|
|
150
|
+
"select": "id,title,publication_year,cited_by_count",
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
rows = []
|
|
154
|
+
for w in payload.get("results") or []:
|
|
155
|
+
rows.append(
|
|
156
|
+
{
|
|
157
|
+
"work_id": str(w.get("id", "")).rstrip("/").split("/")[-1],
|
|
158
|
+
"title": w.get("title"),
|
|
159
|
+
"year": w.get("publication_year"),
|
|
160
|
+
"cited_by_count": w.get("cited_by_count"),
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
return {
|
|
164
|
+
"total": (payload.get("meta") or {}).get("count"),
|
|
165
|
+
"results": rows,
|
|
166
|
+
"source": "openalex",
|
|
167
|
+
"license": "CC0",
|
|
168
|
+
"quota": quota,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
def get_references(self, params: dict) -> dict:
|
|
172
|
+
work_id = self._resolve_work_id(params)
|
|
173
|
+
payload, quota = self._get(
|
|
174
|
+
f"works/{work_id}", {"select": "id,referenced_works_count,referenced_works"}
|
|
175
|
+
)
|
|
176
|
+
refs = payload.get("referenced_works") or []
|
|
177
|
+
return {
|
|
178
|
+
"work_id": work_id,
|
|
179
|
+
"reference_count": payload.get("referenced_works_count"),
|
|
180
|
+
"reference_ids": [str(r).rstrip("/").split("/")[-1] for r in refs],
|
|
181
|
+
"source": "openalex",
|
|
182
|
+
"license": "CC0",
|
|
183
|
+
"quota": quota,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
def get_citations(self, params: dict) -> dict:
|
|
187
|
+
work_id = self._resolve_work_id(params)
|
|
188
|
+
limit = int(params.get("limit") or 10)
|
|
189
|
+
payload, quota = self._get(
|
|
190
|
+
"works",
|
|
191
|
+
{
|
|
192
|
+
"filter": f"cites:{work_id}",
|
|
193
|
+
"per-page": str(min(limit, 200)),
|
|
194
|
+
"select": "id,title,publication_year",
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
rows = []
|
|
198
|
+
for w in payload.get("results") or []:
|
|
199
|
+
rows.append(
|
|
200
|
+
{
|
|
201
|
+
"citing_work_id": str(w.get("id", "")).rstrip("/").split("/")[-1],
|
|
202
|
+
"title": w.get("title"),
|
|
203
|
+
"year": w.get("publication_year"),
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
return {
|
|
207
|
+
"work_id": work_id,
|
|
208
|
+
"total_count": (payload.get("meta") or {}).get("count"),
|
|
209
|
+
"results": rows,
|
|
210
|
+
"source": "openalex",
|
|
211
|
+
"license": "CC0",
|
|
212
|
+
"quota": quota,
|
|
213
|
+
}
|
|
@@ -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
|