Files
playbook/skills/tsl-api-reference/scripts/dictionary_lookup.py
T

775 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())