feat(tsl-api): add parameter value domain lookup

This commit is contained in:
csh
2026-08-22 14:15:54 +08:00
parent 476f999139
commit b101b13bba
17 changed files with 2551 additions and 400 deletions
@@ -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