✨ feat(tsl-api-reference): improve tagged API discovery
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
TOP_LEVEL_RE = re.compile(r"^##(?!#)\s+`(.+?)`\s*$")
|
||||
@@ -43,6 +44,25 @@ OPTIONAL_COLUMNS = (
|
||||
"owner",
|
||||
"qualified_name",
|
||||
)
|
||||
SEARCH_FIELD_WEIGHTS = (
|
||||
("name", 120),
|
||||
("qualified_name", 120),
|
||||
("signature", 90),
|
||||
("tags", 70),
|
||||
("summary", 50),
|
||||
("module", 30),
|
||||
("scope", 30),
|
||||
("owner", 25),
|
||||
("kind", 20),
|
||||
("binding", 20),
|
||||
("visibility", 20),
|
||||
)
|
||||
# Complete coverage in curated semantic text must outrank incidental short
|
||||
# aliases in composite identifiers, such as "index" and "max".
|
||||
SEMANTIC_COVERAGE_BONUSES = (
|
||||
("tags", 240),
|
||||
("summary", 200),
|
||||
)
|
||||
|
||||
HELP_EPILOG = """\
|
||||
检索分两步,--kw 的摘要不能直接作为 API 事实:
|
||||
@@ -53,6 +73,7 @@ HELP_EPILOG = """\
|
||||
lookup.py --name arrDropDuplicate
|
||||
|
||||
已知确切名称时可以直接用 --name,跳过第 1 步。
|
||||
只查询一个 API scope 时使用 --scope,例如 --scope builtin 或 --scope dotnet。
|
||||
|
||||
退出码:
|
||||
0 取回成功;--name 无匹配也是 0(打印提示,不算错误)
|
||||
@@ -67,20 +88,6 @@ def non_empty(value):
|
||||
return value
|
||||
|
||||
|
||||
def find_tsv(start):
|
||||
"""Walk upward from start and find a bundled or legacy function_index.tsv."""
|
||||
for directory in [start, *start.parents]:
|
||||
candidates = [
|
||||
directory / "data" / "function_index.tsv",
|
||||
directory / "function_index.tsv",
|
||||
directory / "docs" / "tsl" / "codegen" / "function_index.tsv",
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def load_rows(tsv_path):
|
||||
lines = tsv_path.read_text(encoding="utf-8").splitlines()
|
||||
if not lines:
|
||||
@@ -108,6 +115,45 @@ def load_rows(tsv_path):
|
||||
return rows
|
||||
|
||||
|
||||
def normalize(value):
|
||||
return unicodedata.normalize("NFKC", value).casefold().strip()
|
||||
|
||||
|
||||
def field_match_score(value, variant, weight):
|
||||
normalized = normalize(value)
|
||||
if not normalized or not variant:
|
||||
return 0
|
||||
if normalized == variant:
|
||||
return weight + 20
|
||||
if variant in normalized:
|
||||
return weight
|
||||
return 0
|
||||
|
||||
|
||||
def term_score(row, term):
|
||||
best = 0
|
||||
for field, weight in SEARCH_FIELD_WEIGHTS:
|
||||
score = field_match_score(row.get(field, ""), term, weight)
|
||||
if score:
|
||||
best = max(best, score)
|
||||
return best
|
||||
|
||||
|
||||
def field_covers_terms(row, field, terms):
|
||||
value = normalize(row.get(field, ""))
|
||||
return bool(value) and all(term in value for term in terms)
|
||||
|
||||
|
||||
def keyword_sort_key(row):
|
||||
return (
|
||||
normalize(row.get("qualified_name", "") or row.get("name", "")),
|
||||
normalize(row.get("signature", "")),
|
||||
normalize(row.get("scope", "")),
|
||||
row.get("page", ""),
|
||||
row.get("anchor", ""),
|
||||
)
|
||||
|
||||
|
||||
def search_exact(rows, name):
|
||||
key = name.casefold()
|
||||
return [
|
||||
@@ -122,26 +168,19 @@ def search_exact(rows, name):
|
||||
|
||||
|
||||
def search_keyword(rows, terms):
|
||||
lowered_terms = [term.lower() for term in terms]
|
||||
matches = []
|
||||
normalized_terms = [normalize(term) for term in terms]
|
||||
ranked = []
|
||||
for row in rows:
|
||||
haystack = " ".join(
|
||||
[
|
||||
row.get("name", ""),
|
||||
row.get("signature", ""),
|
||||
row.get("module", ""),
|
||||
row.get("tags", ""),
|
||||
row.get("summary", ""),
|
||||
row.get("kind", ""),
|
||||
row.get("binding", ""),
|
||||
row.get("visibility", ""),
|
||||
row.get("owner", ""),
|
||||
row.get("qualified_name", ""),
|
||||
]
|
||||
).casefold()
|
||||
if all(term in haystack for term in lowered_terms):
|
||||
matches.append(row)
|
||||
return matches
|
||||
scores = [term_score(row, term) for term in normalized_terms]
|
||||
if scores and all(scores):
|
||||
coverage_bonus = sum(
|
||||
bonus
|
||||
for field, bonus in SEMANTIC_COVERAGE_BONUSES
|
||||
if field_covers_terms(row, field, normalized_terms)
|
||||
)
|
||||
ranked.append((sum(scores) + coverage_bonus, row))
|
||||
ranked.sort(key=lambda item: (-item[0], *keyword_sort_key(item[1])))
|
||||
return [row for _, row in ranked]
|
||||
|
||||
|
||||
def slug(text):
|
||||
@@ -334,12 +373,6 @@ def codegen_root_for_tsv(tsv_path):
|
||||
return DEFAULT_CODEGEN_ROOT
|
||||
|
||||
|
||||
def default_tsv_path():
|
||||
if DEFAULT_TSV.is_file():
|
||||
return DEFAULT_TSV
|
||||
return find_tsv(Path.cwd())
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
@@ -372,15 +405,24 @@ def main(argv=None):
|
||||
"--kw",
|
||||
nargs="+",
|
||||
type=non_empty,
|
||||
help="按关键词检索候选清单,多个词是 AND 关系(全部命中才返回);"
|
||||
"逐行搜索 name、signature、module、tags、summary、kind、binding、"
|
||||
"visibility、owner 和 qualified_name 这些列的拼接文本,"
|
||||
"大小写不敏感且按子串匹配。只返回摘要表格,不含条目正文",
|
||||
help="按关键词检索候选清单:多个查询词是 AND 关系;"
|
||||
"逐词在 name、signature、tags、summary、module、scope、kind、binding、"
|
||||
"visibility、owner 和 qualified_name 中做字面子串匹配,按字段加权并稳定排序。"
|
||||
"同义词由索引的 tags 字段提供。"
|
||||
"大小写不敏感,只返回摘要表格,不含条目正文",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tsv",
|
||||
metavar="PATH",
|
||||
help="显式指定 function_index.tsv;默认使用本 skill 内置的 data/function_index.tsv",
|
||||
help="显式指定 function_index.tsv;默认只使用本 skill 内置的 "
|
||||
"data/function_index.tsv,不从工作目录回退查找旧索引",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--scope",
|
||||
type=non_empty,
|
||||
metavar="SCOPE",
|
||||
help="只查询指定 scope,大小写不敏感;作用于 --name 和 --kw。"
|
||||
"内置索引当前提供 builtin 与 dotnet",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
@@ -394,8 +436,8 @@ def main(argv=None):
|
||||
if args.limit < 1:
|
||||
parser.error("--limit must be >= 1")
|
||||
|
||||
tsv_path = Path(args.tsv) if args.tsv else default_tsv_path()
|
||||
if not tsv_path or not tsv_path.is_file():
|
||||
tsv_path = Path(args.tsv) if args.tsv else DEFAULT_TSV
|
||||
if not tsv_path.is_file():
|
||||
print(
|
||||
"ERROR: function_index.tsv not found; reinstall tsl-api-reference "
|
||||
"or pass --tsv PATH",
|
||||
@@ -415,6 +457,20 @@ def main(argv=None):
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if args.scope is not None:
|
||||
requested_scope = normalize(args.scope)
|
||||
available_scopes = sorted(
|
||||
{row["scope"] for row in rows if row.get("scope")},
|
||||
key=normalize,
|
||||
)
|
||||
if requested_scope not in {normalize(scope) for scope in available_scopes}:
|
||||
print(
|
||||
f"ERROR: unknown scope {args.scope!r}; available scopes: "
|
||||
f"{', '.join(available_scopes)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
rows = [row for row in rows if normalize(row["scope"]) == requested_scope]
|
||||
codegen_root = codegen_root_for_tsv(tsv_path)
|
||||
|
||||
if args.name is not None:
|
||||
@@ -450,6 +506,8 @@ def main(argv=None):
|
||||
print(format_row(row))
|
||||
if len(matches) > args.limit:
|
||||
print(f"... {len(matches) - args.limit} more; refine keywords or raise --limit")
|
||||
if not matches:
|
||||
print("No matching API candidates; do not infer an API name.")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user