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.
Files changed (33) hide show
  1. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/PKG-INFO +5 -1
  2. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/README.md +2 -0
  3. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/__init__.py +1 -1
  4. fd_open_data_protocol-0.3.2/fd_open_data_protocol/openalex_provider.py +213 -0
  5. fd_open_data_protocol-0.3.2/fd_open_data_protocol/registry.py +274 -0
  6. fd_open_data_protocol-0.3.2/fd_open_data_protocol/registry_cli.py +239 -0
  7. fd_open_data_protocol-0.3.2/fd_open_data_protocol/registry_store.py +458 -0
  8. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/schema.py +120 -1
  9. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/concepts.yaml +62 -1
  10. {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
  11. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/entity_types.yaml +78 -1
  12. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/event_types.yaml +1 -1
  13. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/qualifiers.yaml +1 -1
  14. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/relation_types.yaml +46 -1
  15. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/units.yaml +8 -1
  16. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol.egg-info/PKG-INFO +5 -1
  17. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol.egg-info/SOURCES.txt +8 -0
  18. fd_open_data_protocol-0.3.2/fd_open_data_protocol.egg-info/entry_points.txt +2 -0
  19. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol.egg-info/requires.txt +3 -0
  20. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/pyproject.toml +4 -0
  21. fd_open_data_protocol-0.3.2/tests/test_openalex_provider.py +170 -0
  22. fd_open_data_protocol-0.3.2/tests/test_registry_pipeline.py +209 -0
  23. fd_open_data_protocol-0.3.2/tests/test_registry_schema.py +91 -0
  24. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/constants.py +0 -0
  25. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/loader.py +0 -0
  26. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/provider.py +0 -0
  27. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary.py +0 -0
  28. {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
  29. {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
  30. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/setup.cfg +0 -0
  31. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/tests/test_loader.py +0 -0
  32. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.2}/tests/test_schema_vocabulary.py +0 -0
  33. {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.0
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
@@ -5,4 +5,4 @@ concept hints + fetch reference) so that fd-open-data-mcp - or any consumer -
5
5
  can ingest it via ``register_datasource``. Ship one manifest file -> the
6
6
  datasource is added; no consumer-side wiring.
7
7
  """
8
- __version__ = "0.3.0"
8
+ __version__ = "0.3.2"
@@ -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