775 lines
26 KiB
Python
775 lines
26 KiB
Python
#!/usr/bin/env python3
|
||
"""Query the independent Tinysoft table and field dictionary index."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import json
|
||
import re
|
||
import sys
|
||
import unicodedata
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Iterable, Mapping, Sequence
|
||
|
||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||
if str(SCRIPT_DIR) not in sys.path:
|
||
sys.path.insert(0, str(SCRIPT_DIR))
|
||
|
||
from value_domains import ( # noqa: E402
|
||
RelatedTable,
|
||
ValueDomainCatalog,
|
||
ValueMatch,
|
||
domains_path_for_index,
|
||
)
|
||
|
||
|
||
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
||
DEFAULT_TSV = SKILL_ROOT / "data" / "dictionary_index.tsv"
|
||
DEFAULT_LEXICON = SKILL_ROOT / "data" / "dictionary_lexicon.json"
|
||
DEFAULT_VALUE_DOMAINS = SKILL_ROOT / "data" / "value_domains.json"
|
||
REQUIRED_COLUMNS = (
|
||
"kind",
|
||
"scope",
|
||
"table_id",
|
||
"table_name",
|
||
"table_alias",
|
||
"field_id",
|
||
"field_name",
|
||
"field_alias",
|
||
"data_type",
|
||
"unit",
|
||
"description",
|
||
"extract_method",
|
||
"access_code",
|
||
"api_name",
|
||
"page",
|
||
"tags",
|
||
)
|
||
|
||
HELP_EPILOG = """\
|
||
按中文业务含义查询表、数据源、字段或参数取值域:
|
||
dictionary_lookup.py --query "股票现金流指标销售现金比率"
|
||
dictionary_lookup.py --query "市值" --scope fund
|
||
dictionary_lookup.py --query "买一量" --table TradeTable
|
||
dictionary_lookup.py --query "9900700" --field 销售现金比率
|
||
dictionary_lookup.py --query "申万煤炭"
|
||
|
||
输出第一行是 in-band 状态:
|
||
status: ok 最高分候选唯一
|
||
status: ambiguous 多个同分候选;必须补充 scope、table 或 field 范围
|
||
status: no_match 表/字段/参数取值域均无匹配,不等于 API not found
|
||
|
||
参数取值域结果会标明值、名称、绑定 API、关联表和来源;动态目录只提供已知样例,
|
||
完整名称必须使用结果中的运行时解析器重新确认。
|
||
|
||
退出码:
|
||
0 查询完成;ok、ambiguous、no_match 均由 status 表达
|
||
1 dictionary_index.tsv、词表或 value_domains.json 缺失、损坏、格式错误
|
||
2 参数不合法
|
||
"""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class QueryItem:
|
||
kind: str
|
||
scope: str
|
||
table_id: str
|
||
table_name: str
|
||
field_id: str
|
||
field_name: str
|
||
field_alias: str
|
||
data_type: str
|
||
unit: str
|
||
description: str
|
||
extract_method: str
|
||
access_code: str
|
||
api_name: str
|
||
page: str
|
||
score: int
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ValueDomainItem:
|
||
kind: str
|
||
domain_id: str
|
||
domain_label: str
|
||
value: str
|
||
label: str
|
||
parent: str
|
||
parent_label: str
|
||
level: int | None
|
||
valid_from: str
|
||
valid_to: str
|
||
note: str
|
||
related_tables: tuple[str, ...]
|
||
bindings: tuple[str, ...]
|
||
mode: str
|
||
complete: bool
|
||
as_of: str
|
||
sources: tuple[str, ...]
|
||
resolver_text: str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ValueDomainSummaryItem:
|
||
kind: str
|
||
domain_id: str
|
||
label: str
|
||
related_tables: tuple[str, ...]
|
||
bindings: tuple[str, ...]
|
||
mode: str
|
||
complete: bool
|
||
as_of: str
|
||
sources: tuple[str, ...]
|
||
resolvers: tuple[str, ...]
|
||
candidates: tuple[str, ...]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class QueryResult:
|
||
status: str
|
||
items: tuple[QueryItem | ValueDomainItem | ValueDomainSummaryItem, ...]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _ScoredRow:
|
||
score: int
|
||
field_match: int
|
||
table_match: int
|
||
row: Mapping[str, str]
|
||
|
||
|
||
def _compact(value: str) -> str:
|
||
normalized = unicodedata.normalize("NFKC", value).casefold()
|
||
return "".join(
|
||
character
|
||
for character in normalized
|
||
if character == "_" or unicodedata.category(character)[0] in {"L", "N"}
|
||
)
|
||
|
||
|
||
def _terms(value: str) -> tuple[str, ...]:
|
||
terms = []
|
||
for item in value.split("|"):
|
||
compact = _compact(item)
|
||
if compact and compact not in terms:
|
||
terms.append(compact)
|
||
return tuple(terms)
|
||
|
||
|
||
def _row_terms(row: Mapping[str, str], name: str, alias: str) -> tuple[str, ...]:
|
||
values = []
|
||
for value in (_compact(row.get(name, "")), *_terms(row.get(alias, ""))):
|
||
if value and value not in values:
|
||
values.append(value)
|
||
return tuple(values)
|
||
|
||
|
||
def validate_lexicon(value: object) -> dict[str, list[str]]:
|
||
if not isinstance(value, dict):
|
||
raise ValueError("dictionary lexicon must be a JSON object")
|
||
result = {}
|
||
for canonical, aliases in value.items():
|
||
if not isinstance(canonical, str) or not canonical.strip():
|
||
raise ValueError("dictionary lexicon contains an empty canonical term")
|
||
if not isinstance(aliases, list) or not all(isinstance(alias, str) and alias.strip() for alias in aliases):
|
||
raise ValueError(f"aliases for {canonical!r} must be non-empty strings")
|
||
result[canonical] = list(dict.fromkeys(aliases))
|
||
return result
|
||
|
||
|
||
def load_lexicon(path: Path) -> dict[str, list[str]]:
|
||
return validate_lexicon(json.loads(path.read_text(encoding="utf-8")))
|
||
|
||
|
||
def load_rows(path: Path) -> list[dict[str, str]]:
|
||
with path.open(encoding="utf-8", newline="") as handle:
|
||
reader = csv.DictReader(handle, delimiter="\t")
|
||
if reader.fieldnames is None:
|
||
raise ValueError("dictionary index has no header")
|
||
missing = [column for column in REQUIRED_COLUMNS if column not in reader.fieldnames]
|
||
if missing:
|
||
raise ValueError(f"dictionary index is missing columns: {', '.join(missing)}")
|
||
if len(reader.fieldnames) != len(set(reader.fieldnames)):
|
||
raise ValueError("dictionary index has duplicate columns")
|
||
return [dict(row) for row in reader]
|
||
|
||
|
||
def _query_variants(query: str, lexicon: Mapping[str, Sequence[str]]) -> tuple[str, ...]:
|
||
base = _compact(query)
|
||
variants = {base}
|
||
for canonical, aliases in lexicon.items():
|
||
canonical_key = _compact(canonical)
|
||
if not canonical_key:
|
||
continue
|
||
for alias in aliases:
|
||
alias_key = _compact(alias)
|
||
if alias_key and alias_key in base:
|
||
variants.add(base.replace(alias_key, canonical_key))
|
||
return tuple(sorted(variant for variant in variants if variant))
|
||
|
||
|
||
def _name_score(query: str, terms: Iterable[str], *, exact: int, contained: int) -> int:
|
||
score = 0
|
||
for term in terms:
|
||
if query == term:
|
||
score = max(score, exact)
|
||
elif term and term in query:
|
||
score = max(score, contained + min(len(term), 100))
|
||
return score
|
||
|
||
|
||
def _score_row(row: Mapping[str, str], query_variants: Sequence[str]) -> _ScoredRow:
|
||
table_terms = _row_terms(row, "table_name", "table_alias")
|
||
field_terms = _row_terms(row, "field_name", "field_alias")
|
||
field_id = _compact(row.get("field_id", ""))
|
||
table_id = _compact(row.get("table_id", ""))
|
||
api_name = _compact(row.get("api_name", ""))
|
||
description = _compact(row.get("description", ""))
|
||
tags = _terms(row.get("tags", ""))
|
||
best = _ScoredRow(0, 0, 0, row)
|
||
for query in query_variants:
|
||
table_match = _name_score(query, table_terms, exact=1000, contained=600)
|
||
field_match = _name_score(query, field_terms, exact=2000, contained=1300)
|
||
if field_id and query == field_id:
|
||
field_match = max(field_match, 3000)
|
||
elif field_id and len(field_id) >= 3 and field_id in query:
|
||
field_match = max(field_match, 2600)
|
||
if table_id and query == table_id:
|
||
table_match = max(table_match, 2400)
|
||
elif table_id and len(table_id) >= 3 and table_id in query:
|
||
table_match = max(table_match, 2100)
|
||
if api_name and query == api_name:
|
||
field_match = max(field_match, 2200)
|
||
elif api_name and api_name in query:
|
||
field_match = max(field_match, 1700 + min(len(api_name), 100))
|
||
fallback = 0
|
||
if not field_match and not table_match:
|
||
if description and query in description:
|
||
fallback = 200
|
||
else:
|
||
fallback = _name_score(query, tags, exact=180, contained=120)
|
||
score = table_match + field_match + fallback
|
||
if score > best.score:
|
||
best = _ScoredRow(score, field_match, table_match, row)
|
||
return best
|
||
|
||
|
||
def _matches_filter(row: Mapping[str, str], value: str, name: str, alias: str) -> bool:
|
||
key = _compact(value)
|
||
return any(key == term or key in term or term in key for term in _row_terms(row, name, alias))
|
||
|
||
|
||
def _item(scored: _ScoredRow) -> QueryItem:
|
||
row = scored.row
|
||
return QueryItem(
|
||
kind=row.get("kind", ""),
|
||
scope=row.get("scope", ""),
|
||
table_id=row.get("table_id", ""),
|
||
table_name=row.get("table_name", ""),
|
||
field_id=row.get("field_id", ""),
|
||
field_name=row.get("field_name", ""),
|
||
field_alias=row.get("field_alias", ""),
|
||
data_type=row.get("data_type", ""),
|
||
unit=row.get("unit", ""),
|
||
description=row.get("description", ""),
|
||
extract_method=row.get("extract_method", ""),
|
||
access_code=row.get("access_code", ""),
|
||
api_name=row.get("api_name", ""),
|
||
page=row.get("page", ""),
|
||
score=scored.score,
|
||
)
|
||
|
||
|
||
def _value_item(
|
||
catalog: ValueDomainCatalog,
|
||
match: ValueMatch,
|
||
related_tables: Sequence[RelatedTable] | None = None,
|
||
) -> ValueDomainItem:
|
||
value = match.value
|
||
hints = catalog.api_hints((match,))
|
||
bindings = tuple(dict.fromkeys(hint.target for hint in hints))
|
||
verification = []
|
||
for hint in hints:
|
||
if not hint.verification:
|
||
continue
|
||
if hint.verification.status == "runtime_required":
|
||
call = hint.verification.resolver
|
||
if hint.verification.argument:
|
||
call += f"('{hint.verification.argument}')"
|
||
verification.append(f"{call}(运行时)")
|
||
elif hint.verification.status == "documented":
|
||
verification.append("文档已确认")
|
||
resolver_text = "; ".join(dict.fromkeys(verification))
|
||
table = match.domain.related_table(value.related_table)
|
||
selected_tables = tuple(related_tables) if related_tables is not None else ()
|
||
if not selected_tables:
|
||
selected_tables = (table,) if table else match.domain.related_tables
|
||
rendered_tables = tuple(
|
||
f"{related.table_id} {related.name}"
|
||
for related in selected_tables
|
||
if related is not None
|
||
)
|
||
return ValueDomainItem(
|
||
kind="parameter_value",
|
||
domain_id=match.domain.domain_id,
|
||
domain_label=match.domain.label,
|
||
value=value.value,
|
||
label=value.label,
|
||
parent=value.parent,
|
||
parent_label=value.parent_label,
|
||
level=value.level,
|
||
valid_from=value.valid_from,
|
||
valid_to=value.valid_to,
|
||
note=value.note,
|
||
related_tables=rendered_tables,
|
||
bindings=bindings,
|
||
mode=match.domain.mode,
|
||
complete=match.domain.complete,
|
||
as_of=value.as_of or match.domain.as_of,
|
||
sources=value.sources,
|
||
resolver_text=resolver_text,
|
||
)
|
||
|
||
|
||
def _domain_summary_items(
|
||
catalog: ValueDomainCatalog,
|
||
query: str,
|
||
*,
|
||
scope: str | None,
|
||
table: str | None,
|
||
limit: int,
|
||
) -> tuple[ValueDomainSummaryItem, ...]:
|
||
scope_key = _compact(scope or "")
|
||
table_key = _compact(table or "")
|
||
items = []
|
||
for domain in catalog.exact_domains(query):
|
||
related_tables = domain.related_tables
|
||
if scope_key:
|
||
related_tables = tuple(
|
||
related
|
||
for related in related_tables
|
||
if _compact(related.scope) == scope_key
|
||
)
|
||
if table_key:
|
||
related_tables = tuple(
|
||
related
|
||
for related in related_tables
|
||
if table_key in _compact(related.name)
|
||
or table_key in _compact(related.table_id)
|
||
)
|
||
if (scope_key or table_key) and not related_tables:
|
||
continue
|
||
bindings = tuple(
|
||
f"{binding.api}.{binding.parameter}" for binding in domain.bindings
|
||
)
|
||
resolvers = []
|
||
for resolver in domain.resolvers:
|
||
catalog_label = {
|
||
"system": "系统",
|
||
"user": "用户",
|
||
}.get(resolver.catalog, resolver.catalog)
|
||
resolvers.append(
|
||
f"{resolver.api}({resolver.parameter})({catalog_label}目录)"
|
||
)
|
||
candidates = tuple(
|
||
value if not label or label == value else f"{value}({label})"
|
||
for value, label in catalog.recorded_candidates(domain)
|
||
)
|
||
items.append(
|
||
ValueDomainSummaryItem(
|
||
kind="parameter_domain",
|
||
domain_id=domain.domain_id,
|
||
label=domain.label,
|
||
related_tables=tuple(
|
||
f"{related.table_id} {related.name}" for related in related_tables
|
||
),
|
||
bindings=bindings,
|
||
mode=domain.mode,
|
||
complete=domain.complete,
|
||
as_of=domain.as_of,
|
||
sources=domain.sources,
|
||
resolvers=tuple(resolvers),
|
||
candidates=candidates,
|
||
)
|
||
)
|
||
return tuple(items[:limit])
|
||
|
||
|
||
def _value_matches(
|
||
catalog: ValueDomainCatalog,
|
||
query: str,
|
||
*,
|
||
scope: str | None,
|
||
table: str | None,
|
||
field: str | None,
|
||
limit: int,
|
||
strong_only: bool,
|
||
) -> tuple[ValueDomainItem, ...]:
|
||
if field:
|
||
return ()
|
||
query_terms = tuple(query.split())
|
||
matches = catalog.search(query_terms) if len(query_terms) > 1 else ()
|
||
if not matches:
|
||
matches = catalog.search(query)
|
||
if re.fullmatch(r"\s*-?\d{1,2}\s*", query):
|
||
matches = tuple(
|
||
match
|
||
for match in matches
|
||
if not re.fullmatch(r"-?\d{1,2}", match.value.value)
|
||
)
|
||
if strong_only:
|
||
query_key = _compact(query)
|
||
intent_markers = ("代码", "分类", "属性", "取值", "枚举")
|
||
matches = tuple(
|
||
match
|
||
for match in matches
|
||
if match.exact
|
||
or (
|
||
len(_compact(match.value.value)) >= 4
|
||
and any(
|
||
character.isascii() and character.isalpha()
|
||
for character in match.value.value
|
||
)
|
||
and any(character.isdigit() for character in match.value.value)
|
||
and _compact(match.value.value) in query_key
|
||
)
|
||
or (
|
||
match.domain.mode == "versioned_catalog"
|
||
and _compact(match.value.label) in query_key
|
||
and any(marker in query for marker in intent_markers)
|
||
)
|
||
or (
|
||
match.domain.mode == "enum"
|
||
and _compact(match.domain.label) in query_key
|
||
and _compact(match.value.value) in query_key
|
||
)
|
||
)
|
||
filtered = []
|
||
scope_key = _compact(scope or "")
|
||
table_key = _compact(table or "")
|
||
for match in matches:
|
||
related_tables = match.domain.related_tables
|
||
if match.value.related_table:
|
||
related = match.domain.related_table(match.value.related_table)
|
||
related_tables = (related,) if related else ()
|
||
if scope_key:
|
||
related_tables = tuple(
|
||
related
|
||
for related in related_tables
|
||
if _compact(related.scope) == scope_key
|
||
)
|
||
if table_key:
|
||
related_tables = tuple(
|
||
related
|
||
for related in related_tables
|
||
if table_key in _compact(related.name)
|
||
or table_key in _compact(related.table_id)
|
||
)
|
||
if (scope_key or table_key) and not related_tables:
|
||
continue
|
||
filtered.append((match, related_tables))
|
||
matches_with_tables = tuple(filtered)
|
||
if not matches_with_tables:
|
||
return ()
|
||
top_score = matches_with_tables[0][0].score
|
||
top = [item for item in matches_with_tables if item[0].score == top_score]
|
||
return tuple(
|
||
_value_item(catalog, match, related_tables)
|
||
for match, related_tables in top[:limit]
|
||
)
|
||
|
||
|
||
def query_index(
|
||
rows: Iterable[Mapping[str, str]],
|
||
query: str,
|
||
*,
|
||
scope: str | None = None,
|
||
table: str | None = None,
|
||
field: str | None = None,
|
||
limit: int = 10,
|
||
lexicon: Mapping[str, Sequence[str]] | None = None,
|
||
) -> QueryResult:
|
||
if not query.strip():
|
||
raise ValueError("query must not be empty")
|
||
if limit < 1:
|
||
raise ValueError("limit must be at least 1")
|
||
rows = list(rows)
|
||
variants = _query_variants(query, lexicon or {})
|
||
scope_key = _compact(scope or "")
|
||
scored = []
|
||
for row in rows:
|
||
if scope_key and _compact(row.get("scope", "")) != scope_key:
|
||
continue
|
||
if table and not _matches_filter(row, table, "table_name", "table_alias"):
|
||
continue
|
||
if field and not _matches_filter(row, field, "field_name", "field_alias"):
|
||
continue
|
||
candidate = _score_row(row, variants)
|
||
if candidate.score:
|
||
scored.append(candidate)
|
||
if not scored:
|
||
return QueryResult("no_match", ())
|
||
|
||
field_candidates = [item for item in scored if item.field_match]
|
||
if field_candidates:
|
||
scored = field_candidates
|
||
else:
|
||
entity_candidates = [item for item in scored if item.row.get("kind") in {"table", "source"}]
|
||
if entity_candidates:
|
||
scored = entity_candidates
|
||
scored.sort(
|
||
key=lambda item: (
|
||
-item.score,
|
||
_compact(item.row.get("table_name", "")),
|
||
_compact(item.row.get("field_name", "")),
|
||
item.row.get("field_id", ""),
|
||
)
|
||
)
|
||
top_score = scored[0].score
|
||
top = [candidate for candidate in scored if candidate.score == top_score]
|
||
unique = []
|
||
seen = set()
|
||
for candidate in top:
|
||
row = candidate.row
|
||
key = (
|
||
row.get("table_name", "").casefold(),
|
||
row.get("field_id", ""),
|
||
row.get("field_name", "").casefold(),
|
||
row.get("page", ""),
|
||
)
|
||
if key not in seen:
|
||
seen.add(key)
|
||
unique.append(_item(candidate))
|
||
items = tuple(unique[:limit])
|
||
status = "ambiguous" if len(unique) > 1 else "ok"
|
||
return QueryResult(status, items)
|
||
|
||
|
||
def _direct_dictionary_items(result: QueryResult, query: str) -> tuple[QueryItem, ...]:
|
||
query_key = _compact(query)
|
||
if not query_key:
|
||
return ()
|
||
direct = []
|
||
for item in result.items:
|
||
if not isinstance(item, QueryItem):
|
||
continue
|
||
names = (_compact(item.table_name), _compact(item.field_name))
|
||
identifiers = (_compact(item.table_id), _compact(item.field_id))
|
||
access_code = _compact(item.access_code)
|
||
if (
|
||
any(name and (query_key == name or name in query_key) for name in names)
|
||
or any(identifier and query_key == identifier for identifier in identifiers)
|
||
or (len(query_key) >= 3 and query_key in access_code)
|
||
):
|
||
direct.append(item)
|
||
return tuple(direct)
|
||
|
||
|
||
def _render_item(index: int, item: QueryItem) -> list[str]:
|
||
return [
|
||
f"[{index}]",
|
||
f"类型:{item.kind}",
|
||
f"范围:{item.scope}",
|
||
f"表或数据源:{item.table_name}",
|
||
f"表 ID:{item.table_id}",
|
||
f"字段:{item.field_name}",
|
||
f"字段别名或中文名:{item.field_alias}",
|
||
f"字段 ID:{item.field_id}",
|
||
f"字段类型:{item.data_type}",
|
||
f"单位:{item.unit}",
|
||
f"提取方式:{item.extract_method}",
|
||
f"访问代码:{item.access_code}",
|
||
f"对应 API:{item.api_name}",
|
||
f"说明:{item.description}",
|
||
f"页面:{item.page}",
|
||
]
|
||
|
||
|
||
def _render_value_item(index: int, item: ValueDomainItem) -> list[str]:
|
||
validity = ""
|
||
if item.valid_from or item.valid_to:
|
||
validity = f"{item.valid_from or '不限'} 至 {item.valid_to or '今'}"
|
||
parent = item.parent or "无"
|
||
if item.parent_label:
|
||
parent = f"{parent}({item.parent_label})"
|
||
return [
|
||
f"[{index}]",
|
||
"类型:参数取值",
|
||
f"参数域:{item.domain_id}({item.domain_label})",
|
||
f"取值:{item.value}",
|
||
f"名称:{item.label}",
|
||
f"父级:{parent}",
|
||
f"层级:{item.level if item.level is not None else ''}",
|
||
f"有效期:{validity}",
|
||
f"备注:{item.note}",
|
||
f"关联表:{'; '.join(item.related_tables)}",
|
||
f"绑定 API:{'; '.join(item.bindings)}",
|
||
f"模式:{item.mode}",
|
||
f"完整性:{'完整' if item.complete else '静态记录不完整'}",
|
||
f"快照日期:{item.as_of}",
|
||
f"核验:{item.resolver_text}",
|
||
f"来源:{', '.join(item.sources)}",
|
||
]
|
||
|
||
|
||
def _render_domain_summary_item(
|
||
index: int, item: ValueDomainSummaryItem
|
||
) -> list[str]:
|
||
return [
|
||
f"[{index}]",
|
||
"类型:参数取值域",
|
||
f"参数域:{item.domain_id}",
|
||
f"名称:{item.label}",
|
||
f"关联表:{'; '.join(item.related_tables)}",
|
||
f"绑定 API:{'; '.join(item.bindings)}",
|
||
f"模式:{item.mode}",
|
||
f"完整性:{'完整' if item.complete else '静态记录不完整'}",
|
||
f"快照日期:{item.as_of}",
|
||
f"运行时解析器:{'; '.join(item.resolvers)}",
|
||
f"已记录值或候选:{'; '.join(item.candidates)}",
|
||
f"来源:{', '.join(item.sources)}",
|
||
]
|
||
|
||
|
||
def render_result(result: QueryResult) -> str:
|
||
lines = [f"status: {result.status}"]
|
||
if result.status == "no_match":
|
||
lines.append("未找到匹配的数据字典条目")
|
||
elif result.status == "ambiguous":
|
||
lines.append("存在多个同分候选,请补充 scope、表、数据源或字段范围")
|
||
for index, item in enumerate(result.items, start=1):
|
||
if isinstance(item, ValueDomainSummaryItem):
|
||
renderer = _render_domain_summary_item
|
||
elif isinstance(item, ValueDomainItem):
|
||
renderer = _render_value_item
|
||
else:
|
||
renderer = _render_item
|
||
lines.extend(["", *renderer(index, item)])
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def _positive_int(value: str) -> int:
|
||
try:
|
||
parsed = int(value)
|
||
except ValueError as error:
|
||
raise argparse.ArgumentTypeError("must be an integer") from error
|
||
if parsed < 1:
|
||
raise argparse.ArgumentTypeError("must be at least 1")
|
||
return parsed
|
||
|
||
|
||
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||
parser = argparse.ArgumentParser(
|
||
description="查询天软表、数据源、字段及 API 参数取值域。",
|
||
epilog=HELP_EPILOG,
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
allow_abbrev=False,
|
||
)
|
||
parser.add_argument("--query", required=True, help="中文业务描述、名称或 ID")
|
||
parser.add_argument("--scope", help="按业务范围过滤")
|
||
parser.add_argument("--table", help="按表名或数据源名过滤")
|
||
parser.add_argument("--field", help="按字段名或字段 ID 过滤")
|
||
parser.add_argument("--limit", type=_positive_int, default=10, help="候选条数上限")
|
||
parser.add_argument("--tsv", type=Path, default=DEFAULT_TSV, help="字典索引路径")
|
||
parser.add_argument(
|
||
"--lexicon", type=Path, default=DEFAULT_LEXICON, help="受控同义词词表路径"
|
||
)
|
||
parser.add_argument(
|
||
"--value-domains",
|
||
type=Path,
|
||
help="显式指定 value_domains.json;默认使用所选索引同目录的数据,"
|
||
"自定义索引旁没有该文件时不加载参数取值域",
|
||
)
|
||
return parser.parse_args(argv)
|
||
|
||
|
||
def main(argv: Sequence[str] | None = None) -> int:
|
||
args = parse_args(argv)
|
||
domain_path = (
|
||
args.value_domains
|
||
if args.value_domains
|
||
else domains_path_for_index(
|
||
args.tsv,
|
||
default_index=DEFAULT_TSV,
|
||
default_domains=DEFAULT_VALUE_DOMAINS,
|
||
)
|
||
)
|
||
try:
|
||
rows = load_rows(args.tsv)
|
||
lexicon = load_lexicon(args.lexicon)
|
||
catalog = ValueDomainCatalog.load(domain_path) if domain_path else None
|
||
dictionary_result = query_index(
|
||
rows,
|
||
args.query,
|
||
scope=args.scope,
|
||
table=args.table,
|
||
field=args.field,
|
||
limit=args.limit,
|
||
lexicon=lexicon,
|
||
)
|
||
domain_items = (
|
||
_domain_summary_items(
|
||
catalog,
|
||
args.query,
|
||
scope=args.scope,
|
||
table=args.table,
|
||
limit=args.limit,
|
||
)
|
||
if catalog and not args.field
|
||
else ()
|
||
)
|
||
value_items = (
|
||
_value_matches(
|
||
catalog,
|
||
args.query,
|
||
scope=args.scope,
|
||
table=args.table,
|
||
field=args.field,
|
||
limit=args.limit,
|
||
strong_only=True,
|
||
)
|
||
if catalog
|
||
else ()
|
||
)
|
||
if domain_items:
|
||
result = QueryResult(
|
||
"ambiguous" if len(domain_items) > 1 else "ok",
|
||
domain_items,
|
||
)
|
||
elif value_items:
|
||
dictionary_items = _direct_dictionary_items(dictionary_result, args.query)
|
||
combined = (*value_items, *dictionary_items)[: args.limit]
|
||
result = QueryResult(
|
||
"ambiguous" if len(value_items) > 1 or dictionary_items else "ok",
|
||
combined,
|
||
)
|
||
else:
|
||
result = dictionary_result
|
||
if result.status == "no_match" and catalog:
|
||
fallback_items = _value_matches(
|
||
catalog,
|
||
args.query,
|
||
scope=args.scope,
|
||
table=args.table,
|
||
field=args.field,
|
||
limit=args.limit,
|
||
strong_only=False,
|
||
)
|
||
if fallback_items:
|
||
result = QueryResult(
|
||
"ambiguous" if len(fallback_items) > 1 else "ok",
|
||
fallback_items,
|
||
)
|
||
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||
print(f"ERROR: {error}", file=sys.stderr)
|
||
return 1
|
||
print(render_result(result), end="")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|