✨ feat(tsl-api): add parameter value domain lookup
This commit is contained in:
@@ -6,16 +6,29 @@ 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",
|
||||
@@ -36,20 +49,24 @@ REQUIRED_COLUMNS = (
|
||||
)
|
||||
|
||||
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
|
||||
status: no_match 表/字段/参数取值域均无匹配,不等于 API not found
|
||||
|
||||
参数取值域结果会标明值、名称、绑定 API、关联表和来源;动态目录只提供已知样例,
|
||||
完整名称必须使用结果中的运行时解析器重新确认。
|
||||
|
||||
退出码:
|
||||
0 查询完成;ok、ambiguous、no_match 均由 status 表达
|
||||
1 dictionary_index.tsv 或词表缺失、损坏、格式错误
|
||||
1 dictionary_index.tsv、词表或 value_domains.json 缺失、损坏、格式错误
|
||||
2 参数不合法
|
||||
"""
|
||||
|
||||
@@ -73,10 +90,47 @@ class QueryItem:
|
||||
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, ...]
|
||||
items: tuple[QueryItem | ValueDomainItem | ValueDomainSummaryItem, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -229,6 +283,205 @@ def _item(scored: _ScoredRow) -> QueryItem:
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
@@ -243,6 +496,7 @@ def query_index(
|
||||
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 = []
|
||||
@@ -294,6 +548,26 @@ def query_index(
|
||||
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}]",
|
||||
@@ -314,14 +588,66 @@ def _render_item(index: int, item: QueryItem) -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
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("存在多个同分候选,请补充表或数据源范围")
|
||||
lines.append("存在多个同分候选,请补充 scope、表、数据源或字段范围")
|
||||
for index, item in enumerate(result.items, start=1):
|
||||
lines.extend(["", *_render_item(index, item)])
|
||||
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"
|
||||
|
||||
|
||||
@@ -337,7 +663,7 @@ def _positive_int(value: str) -> int:
|
||||
|
||||
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="查询独立的天软表、数据源与字段字典索引。",
|
||||
description="查询天软表、数据源、字段及 API 参数取值域。",
|
||||
epilog=HELP_EPILOG,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
allow_abbrev=False,
|
||||
@@ -351,15 +677,31 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
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)
|
||||
result = query_index(
|
||||
catalog = ValueDomainCatalog.load(domain_path) if domain_path else None
|
||||
dictionary_result = query_index(
|
||||
rows,
|
||||
args.query,
|
||||
scope=args.scope,
|
||||
@@ -368,6 +710,59 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user