906 lines
32 KiB
Python
906 lines
32 KiB
Python
#!/usr/bin/env python3
|
||
"""Conservatively enrich TSL API descriptions and search tags.
|
||
|
||
Markdown is the fact source. This tool never changes API headings, signatures,
|
||
parameter tables, return types, examples, or scope placement. It only updates
|
||
the first prose description line and the optional ``<!-- tags: ... -->`` line.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import html
|
||
import re
|
||
import sys
|
||
import unicodedata
|
||
from collections import defaultdict
|
||
from dataclasses import dataclass, field, replace
|
||
from pathlib import Path
|
||
|
||
from bs4 import BeautifulSoup
|
||
|
||
|
||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||
if str(SCRIPT_DIR) not in sys.path:
|
||
sys.path.insert(0, str(SCRIPT_DIR))
|
||
|
||
from api_markdown import DECLARATION_LINE_RE, iter_api_entries
|
||
|
||
|
||
TAGS_RE = re.compile(r"^<!--\s*tags:\s*(.*?)\s*-->$")
|
||
ACCESS_RE = re.compile(r"^访问[::]\s*(.*?)\s*$", re.IGNORECASE)
|
||
H1_RE = re.compile(r"^#(?!#)\s+(.+?)\s*$")
|
||
TERMINAL_PUNCTUATION_RE = re.compile(r"[。..!!??;;,,::]+$")
|
||
MULTISPACE_RE = re.compile(r"\s+")
|
||
FORMULA_RE = re.compile(
|
||
r"^([^。;;,,]{2,40}?)\s*(?<![<>=!])=(?!=)\s*(.+)$"
|
||
)
|
||
COMPARISON_RE = re.compile(r"<=|>=|==|!=|<|>")
|
||
RELATED_FUNCTION_RE = re.compile(
|
||
r"^([A-Za-z][A-Za-z0-9_]*)\s*相关函数$"
|
||
)
|
||
ACRONYM_RE = re.compile(r"(?<![A-Za-z0-9_])([A-Z][A-Z0-9_]{1,15})(?![A-Za-z0-9_])")
|
||
ACTION_CLAUSE_RE = re.compile(
|
||
r"^(?:用于|如果|由于|若(?!干)|支持|查询|输入|按照|按(?!钮)|与系统参数|"
|
||
r"对.+?(?:进行|使用)|从.+?(?:获取|读取|计算|返回)|"
|
||
r"由.+?(?:倒推|获取|计算|得到|生成))"
|
||
)
|
||
|
||
PROHIBITED_PROCESS_RE = re.compile(
|
||
r"Source\s*ID|来源[::]|已验证|验证通过|not[_ ]?found|"
|
||
r"测试过程|探测结果|官网定义",
|
||
re.IGNORECASE,
|
||
)
|
||
GENERIC_SUMMARIES = {
|
||
"函数",
|
||
"方法",
|
||
"属性",
|
||
"常量",
|
||
"接口",
|
||
"对象",
|
||
"类",
|
||
"内置函数",
|
||
"内部函数",
|
||
"统计分布相关函数",
|
||
}
|
||
GENERIC_TAGS = {
|
||
"函数",
|
||
"方法",
|
||
"属性",
|
||
"常量",
|
||
"数据",
|
||
"tsl",
|
||
"builtin",
|
||
"dotnet",
|
||
"third",
|
||
"deprecated",
|
||
"function",
|
||
"method",
|
||
"property",
|
||
}
|
||
CLASS_DESCRIPTIONS = {
|
||
"imap": "用于连接 IMAP 服务器、管理邮箱并读取邮件的内置对象",
|
||
"mailmsg": "用于创建、解析、保存和发送邮件消息的内置对象",
|
||
"messagepart": "表示邮件正文、附件等消息分段的内置对象",
|
||
"pop3": "用于连接 POP3 服务器并读取或删除邮件的内置对象",
|
||
"smtp": "用于连接 SMTP 服务器并发送邮件的内置对象",
|
||
"tcipher": "提供对称加密和解密能力的内置对象",
|
||
"tcookie": "表示 HTTP Cookie 名称、值和属性的内置对象",
|
||
"tcookiecollection": "用于创建、查找和管理 HTTP Cookie 集合的内置对象",
|
||
"tfilestream": "以文件为存储后端并提供流式读写能力的内置对象",
|
||
"thandlestream": "封装系统句柄并提供流式读写能力的内置对象",
|
||
"thashedstringlist": "使用哈希索引加速查找的字符串列表内置对象",
|
||
"tinifile": "用于读取和写入 INI 配置文件的内置对象",
|
||
"tmeminifile": "在内存中读取、修改和保存 INI 配置的内置对象",
|
||
"tmemorystream": "以内存缓冲区保存和操作流数据的内置对象",
|
||
"tregistryinifile": "以系统注册表为存储后端的 INI 配置内置对象",
|
||
"trsa": "提供 RSA 非对称加密、解密和签名能力的内置对象",
|
||
"tsession": "表示服务端会话及其键值数据的内置对象",
|
||
"tsessionman": "用于创建、查找、维护和删除服务端会话的内置对象",
|
||
"tstream": "定义顺序读写、定位和复制操作的流基类",
|
||
"tstringlist": "提供字符串集合存储、查找、排序和名称值管理能力的内置对象",
|
||
"twebrequest": "封装 HTTP 请求头、请求参数、Cookie 和请求正文的内置对象",
|
||
"twebresponse": "用于设置 HTTP 状态、响应头、Cookie 和响应正文的内置对象",
|
||
}
|
||
GENERIC_DESCRIPTION_OVERRIDES = {
|
||
"unicodeesc2": "将字符串转换为 \\uxxxx 形式的 Unicode 编码串",
|
||
"sf_x2inv": "根据累计概率计算卡方分布的逆函数值",
|
||
"sf_tinv": "根据累计概率计算 t 分布的逆函数值",
|
||
"sf_sinx": "计算正弦积分",
|
||
"sf_normalinv2": "根据累计概率计算标准正态分布的逆函数值",
|
||
"sf_normalinv": "根据累计概率计算正态分布的逆函数值",
|
||
"sf_normal": "计算正态分布函数值",
|
||
"sf_gammainv": "根据累计概率计算标准 Gamma 分布的逆函数值",
|
||
"sf_gamma": "计算伽马函数值",
|
||
"sf_finv": "根据累计概率计算 F 分布的逆函数值",
|
||
"sf_ex": "计算指数积分",
|
||
"sf_error": "计算误差函数(概率函数)值",
|
||
"sf_cosx": "计算余弦积分",
|
||
"sf_betainv": "根据累计概率计算贝塔分布的逆函数值",
|
||
"sf_beta": "计算贝塔函数值",
|
||
"readfile": "读取本地文件中的数据",
|
||
"millisecondspan": "计算两个日期时间之间相差的毫秒数",
|
||
"importfile": "从文件中导入数据",
|
||
"getmsgdigest": "计算字符串或文件的摘要并返回摘要字符串",
|
||
"exportfile": "将数据导出到文件",
|
||
}
|
||
AWKWARD_DESCRIPTION_OVERRIDES = {
|
||
"encodegraph": "根据指定的类型、名称、数据和属性数组生成图形并返回",
|
||
"slndepreciation": (
|
||
"按直线折旧法计算每期折旧额,计算公式为"
|
||
"(资产原值 Cost - 资产残值 Salvage)/ 折旧周期数 Life"
|
||
),
|
||
"format": "根据 formatstr 指定的格式将参数连接成字符串并返回",
|
||
"ansiformat": "根据 formatstr 指定的格式将参数连接成字符串并返回",
|
||
"dupestring": "将字符串 AText 重复 ACount 次并返回结果",
|
||
"stockpjcj": "返回区间平均成交价,计算公式为区间成交金额 / 区间成交量",
|
||
}
|
||
LEADING_VERBS = (
|
||
"返回",
|
||
"获取",
|
||
"读取",
|
||
"写入",
|
||
"设置",
|
||
"创建",
|
||
"删除",
|
||
"移除",
|
||
"清除",
|
||
"添加",
|
||
"插入",
|
||
"查找",
|
||
"搜索",
|
||
"判断",
|
||
"检查",
|
||
"验证",
|
||
"计算",
|
||
"转换",
|
||
"解析",
|
||
"格式化",
|
||
"比较",
|
||
"执行",
|
||
"调用",
|
||
"连接",
|
||
"关闭",
|
||
"打开",
|
||
"发送",
|
||
"接收",
|
||
"保存",
|
||
"加载",
|
||
"列出",
|
||
"表示",
|
||
"提供",
|
||
"释放",
|
||
"交换",
|
||
"排序",
|
||
"过滤",
|
||
"统计",
|
||
"控制",
|
||
"指示",
|
||
"提取",
|
||
"根据",
|
||
"将",
|
||
"把",
|
||
"生成",
|
||
"构造",
|
||
"采用",
|
||
"利用",
|
||
"输出",
|
||
"得到",
|
||
"获得",
|
||
"取得",
|
||
"新建",
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SourceRecord:
|
||
corpus: str
|
||
page_id: str
|
||
title: str
|
||
page: Path
|
||
description: str = ""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class EntryContext:
|
||
name: str
|
||
signature: str
|
||
kind: str
|
||
scope: str
|
||
page: str
|
||
summary: str
|
||
access: str = ""
|
||
owner: str = ""
|
||
tags: tuple[str, ...] = ()
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PageContext:
|
||
title: str
|
||
path: str
|
||
|
||
|
||
@dataclass
|
||
class AuditChange:
|
||
page: str
|
||
name: str
|
||
kind: str
|
||
old_description: str
|
||
new_description: str
|
||
old_tags: tuple[str, ...]
|
||
new_tags: tuple[str, ...]
|
||
sources: tuple[str, ...]
|
||
|
||
|
||
@dataclass
|
||
class Audit:
|
||
entries: int = 0
|
||
source_matches: int = 0
|
||
ambiguous_sources: int = 0
|
||
changed_descriptions: int = 0
|
||
changed_tags: int = 0
|
||
changes: list[AuditChange] = field(default_factory=list)
|
||
|
||
def merge(self, other: "Audit") -> None:
|
||
self.entries += other.entries
|
||
self.source_matches += other.source_matches
|
||
self.ambiguous_sources += other.ambiguous_sources
|
||
self.changed_descriptions += other.changed_descriptions
|
||
self.changed_tags += other.changed_tags
|
||
self.changes.extend(other.changes)
|
||
|
||
|
||
def normalize_key(value: str) -> str:
|
||
return unicodedata.normalize("NFKC", value).casefold().strip()
|
||
|
||
|
||
def clean_text(value: str) -> str:
|
||
value = html.unescape(value).replace("\xa0", " ")
|
||
value = value.replace("“", '"').replace("”", '"')
|
||
value = value.replace("‘", "'").replace("’", "'")
|
||
return MULTISPACE_RE.sub(" ", value).strip()
|
||
|
||
|
||
def strip_terminal_punctuation(value: str) -> str:
|
||
return TERMINAL_PUNCTUATION_RE.sub("", clean_text(value)).rstrip()
|
||
|
||
|
||
def safe_source_description(value: str) -> str:
|
||
value = strip_terminal_punctuation(value)
|
||
if not value or PROHIBITED_PROCESS_RE.search(value):
|
||
return ""
|
||
return value
|
||
|
||
|
||
def _extract_source_description(page: Path) -> str:
|
||
try:
|
||
soup = BeautifulSoup(page.read_text(encoding="utf-8"), "html.parser")
|
||
except (OSError, UnicodeError):
|
||
return ""
|
||
root = soup.select_one("#help_content") or soup
|
||
for expected in ("简述", "说明"):
|
||
for label in root.select(".DescriteTitle"):
|
||
if clean_text(label.get_text(" ", strip=True)) != expected:
|
||
continue
|
||
marker = label.parent if label.parent is not None else label
|
||
sibling = marker.find_next_sibling()
|
||
if sibling is None:
|
||
sibling = label.find_next_sibling()
|
||
if sibling is not None:
|
||
return safe_source_description(sibling.get_text(" ", strip=True))
|
||
return ""
|
||
|
||
|
||
class SourceCatalog:
|
||
PREFERENCES = {
|
||
"builtin": ("tsl_base", "net_function", "knowledge_base"),
|
||
"third": ("net_function", "tsl_base", "knowledge_base"),
|
||
"deprecated": ("net_function", "tsl_base", "knowledge_base"),
|
||
"dotnet": ("net_function", "tsl_base", "knowledge_base"),
|
||
}
|
||
|
||
def __init__(self, records: dict[str, list[SourceRecord]] | None = None):
|
||
self.records = records or {}
|
||
self._description_cache: dict[Path, str] = {}
|
||
|
||
@classmethod
|
||
def empty(cls) -> "SourceCatalog":
|
||
return cls({})
|
||
|
||
@classmethod
|
||
def from_docs_root(cls, docs_root: Path) -> "SourceCatalog":
|
||
records: dict[str, list[SourceRecord]] = defaultdict(list)
|
||
for corpus in ("net_function", "tsl_base", "knowledge_base"):
|
||
root = docs_root / corpus
|
||
manifest = root / "manifest.tsv"
|
||
if not manifest.is_file():
|
||
continue
|
||
with manifest.open(encoding="utf-8", newline="") as handle:
|
||
for row in csv.DictReader(handle, delimiter="\t"):
|
||
if row.get("status") != "ok" or not row.get("title"):
|
||
continue
|
||
record = SourceRecord(
|
||
corpus=corpus,
|
||
page_id=row.get("id", ""),
|
||
title=row["title"],
|
||
page=root / row.get("path", ""),
|
||
)
|
||
records[normalize_key(record.title)].append(record)
|
||
return cls(dict(records))
|
||
|
||
def _load_description(self, record: SourceRecord) -> SourceRecord:
|
||
if record.description:
|
||
return record
|
||
if record.page not in self._description_cache:
|
||
self._description_cache[record.page] = _extract_source_description(
|
||
record.page
|
||
)
|
||
return replace(
|
||
record, description=self._description_cache[record.page]
|
||
)
|
||
|
||
def match(self, entry: EntryContext) -> list[SourceRecord]:
|
||
matches = self.records.get(normalize_key(entry.name), [])
|
||
preference = self.PREFERENCES.get(
|
||
entry.scope,
|
||
("net_function", "tsl_base", "knowledge_base"),
|
||
)
|
||
rank = {name: index for index, name in enumerate(preference)}
|
||
|
||
def page_order(record: SourceRecord):
|
||
try:
|
||
page_id = (0, int(record.page_id))
|
||
except ValueError:
|
||
page_id = (1, record.page_id)
|
||
return rank.get(record.corpus, 99), page_id
|
||
|
||
return sorted(matches, key=page_order)
|
||
|
||
def describe(self, records: list[SourceRecord]) -> list[SourceRecord]:
|
||
return [self._load_description(record) for record in records]
|
||
|
||
|
||
def _starts_with_verb(value: str) -> bool:
|
||
return value.startswith(LEADING_VERBS) or bool(
|
||
ACTION_CLAUSE_RE.match(value)
|
||
)
|
||
|
||
|
||
def _choose_source_description(
|
||
current: str, sources: list[SourceRecord]
|
||
) -> str:
|
||
candidates = [
|
||
safe_source_description(record.description) for record in sources
|
||
]
|
||
candidates = [value for value in candidates if value]
|
||
if not candidates:
|
||
return current
|
||
if len(sources) == 1 and current in GENERIC_SUMMARIES:
|
||
candidate = candidates[0]
|
||
if candidate not in GENERIC_SUMMARIES and len(candidate) > len(current):
|
||
return candidate
|
||
if PROHIBITED_PROCESS_RE.search(current):
|
||
return candidates[0]
|
||
return current
|
||
|
||
|
||
def _property_description(value: str, access: str) -> str:
|
||
if _starts_with_verb(value):
|
||
return value
|
||
if (
|
||
len(value) > 30
|
||
or any(mark in value for mark in "。;,")
|
||
or value.startswith(("功能同", "只读", "用于"))
|
||
):
|
||
return value
|
||
normalized = normalize_key(access).replace(" ", "")
|
||
if value.startswith("是否"):
|
||
return (
|
||
f"控制{value}"
|
||
if "write" in normalized
|
||
else f"指示{value}"
|
||
)
|
||
if "read" in normalized and "write" in normalized:
|
||
return f"获取或设置{value}"
|
||
if "write" in normalized:
|
||
return f"设置{value}"
|
||
return f"获取{value}"
|
||
|
||
|
||
def _name_intent(name: str) -> str:
|
||
key = normalize_key(name)
|
||
rules = (
|
||
(("is", "if", "has", "can", "check", "valid"), "判断"),
|
||
(("get", "find", "query", "search"), "获取"),
|
||
(("read", "load"), "读取"),
|
||
(("write", "save", "export"), "写入"),
|
||
(("set",), "设置"),
|
||
(("create", "make", "new"), "创建"),
|
||
(("delete", "remove", "drop"), "删除"),
|
||
(("clear", "reset"), "清除"),
|
||
(("add", "append", "insert"), "添加"),
|
||
(("parse", "decode"), "解析"),
|
||
(("encode", "convert", "to"), "转换"),
|
||
(("format",), "格式化"),
|
||
(("calc", "compute"), "计算"),
|
||
(("open",), "打开"),
|
||
(("close",), "关闭"),
|
||
(("send", "post"), "发送"),
|
||
(("connect", "login"), "连接"),
|
||
(("list",), "列出"),
|
||
)
|
||
for prefixes, intent in rules:
|
||
if key.startswith(prefixes):
|
||
return intent
|
||
return ""
|
||
|
||
|
||
def improve_description(
|
||
entry: EntryContext, sources: list[SourceRecord]
|
||
) -> str:
|
||
current = strip_terminal_punctuation(entry.summary)
|
||
current = _choose_source_description(current, sources)
|
||
current = strip_terminal_punctuation(current)
|
||
if not current:
|
||
return entry.summary
|
||
|
||
if entry.kind == "class":
|
||
replacement = CLASS_DESCRIPTIONS.get(normalize_key(entry.name))
|
||
if replacement and re.fullmatch(
|
||
rf"{re.escape(entry.name)}\s*内置对象", current, re.IGNORECASE
|
||
):
|
||
return replacement
|
||
|
||
if entry.kind == "property":
|
||
return _property_description(current, entry.access)
|
||
|
||
key = normalize_key(entry.name)
|
||
generic_override = GENERIC_DESCRIPTION_OVERRIDES.get(key)
|
||
if generic_override and current.endswith("相关函数"):
|
||
return generic_override
|
||
awkward_override = AWKWARD_DESCRIPTION_OVERRIDES.get(key)
|
||
if awkward_override and current.startswith(
|
||
("返回根据", "返回将", "返回:", "返回:")
|
||
):
|
||
return awkward_override
|
||
|
||
current = re.sub(
|
||
r"^(?:该|本)函数(?:主要(?:是)?)?\s*", "", current
|
||
)
|
||
current = re.sub(r"^功能[::]\s*", "", current)
|
||
if current.startswith("是否"):
|
||
current = f"判断{current}"
|
||
|
||
for old, new in (
|
||
("读出", "读取"),
|
||
("取出", "获取"),
|
||
("得到", "获取"),
|
||
("获得", "获取"),
|
||
("取得", "获取"),
|
||
("新建", "创建"),
|
||
):
|
||
if current.startswith(old):
|
||
current = new + current[len(old):]
|
||
break
|
||
if current.startswith("读") and not current.startswith("读取"):
|
||
current = "读取" + current[1:]
|
||
if current.startswith("写") and not current.startswith("写入"):
|
||
current = "写入" + current[1:]
|
||
current = current.replace("读出", "读取")
|
||
current = current.replace("装载内容", "加载内容")
|
||
|
||
related_function = RELATED_FUNCTION_RE.fullmatch(current)
|
||
if related_function:
|
||
return f"返回 {related_function.group(1)} 对应的数据"
|
||
|
||
formula = FORMULA_RE.match(current)
|
||
page_key = normalize_key(entry.page)
|
||
if (
|
||
formula
|
||
and ("/financial/" in page_key or "/financial_report/" in page_key)
|
||
and not _starts_with_verb(current)
|
||
):
|
||
left, right = formula.groups()
|
||
return f"计算{left.strip()},公式为{right.strip()}"
|
||
|
||
if _starts_with_verb(current):
|
||
return current
|
||
|
||
if "/financial/" in page_key or "/financial_report/" in page_key:
|
||
if (
|
||
re.match(r"^\d+(?:\s|[..、)])", current)
|
||
or COMPARISON_RE.search(current)
|
||
):
|
||
return current
|
||
return f"返回{current}"
|
||
|
||
intent = _name_intent(entry.name)
|
||
if intent and len(current) <= 28 and not any(
|
||
verb in current[:16] for verb in LEADING_VERBS
|
||
):
|
||
return f"{intent}{current}"
|
||
return current
|
||
|
||
|
||
DOMAIN_RULES = (
|
||
(r"数组|列表|(?:^|[^a-z])(?:array|fmarray|list)(?:$|[^a-z])", ("数组", "列表", "array", "list")),
|
||
(r"矩阵|(?:^|[^a-z])matrix(?:$|[^a-z])|\bmt_", ("矩阵", "matrix")),
|
||
(r"字符串|文本|(?:^|[^a-z])(?:string|char|text)(?:$|[^a-z])", ("字符串", "文本", "string", "text")),
|
||
(r"日期|时间|(?:^|[^a-z])(?:datetime|date|time)(?:$|[^a-z])", ("日期时间", "日期", "时间", "datetime")),
|
||
(r"文件|目录|路径|(?:^|[^a-z])(?:file|folder|directory|path)(?:$|[^a-z])", ("文件", "目录", "路径", "file")),
|
||
(r"网络|(?:^|[^a-z])(?:http|https|cgi|cookie|url)(?:$|[^a-z])", ("网络", "HTTP", "请求", "network")),
|
||
(r"缓存|(?:^|[^a-z])cache(?:$|[^a-z])", ("缓存", "缓存管理", "cache")),
|
||
(r"数据库|(?:^|[^a-z])(?:sql|dbf|ini)(?:$|[^a-z])", ("数据库", "SQL", "database")),
|
||
(r"进程|线程|(?:^|[^a-z])(?:process|thread|pipe)(?:$|[^a-z])", ("进程", "线程", "process")),
|
||
(r"颜色|(?:^|[^a-z])(?:color|rgb|cmyk)(?:$|[^a-z])", ("颜色", "RGB", "color")),
|
||
(r"图形|图表|(?:^|[^a-z])(?:graph|chart)(?:$|[^a-z])", ("图形", "图表", "graph")),
|
||
(r"统计|概率|分布|(?:^|[^a-z])(?:statistics|cdf|pdf)(?:$|[^a-z])", ("统计", "概率", "statistics")),
|
||
(r"优化|线性规划|(?:^|[^a-z])optimization(?:$|[^a-z])", ("优化", "求解", "optimization")),
|
||
(r"数值|数学|(?:^|[^a-z])(?:numeric|math)(?:$|[^a-z])", ("数学", "数值", "numeric")),
|
||
(r"类型转换|(?:^|[^a-z])(?:convert|conversion)(?:$|[^a-z])", ("类型转换", "转换", "conversion")),
|
||
(r"对象|(?:^|[^a-z])(?:object|class)(?:$|[^a-z])", ("对象", "实例", "object")),
|
||
(r"(?:^|[^a-z])(?:com|ole)(?:$|[^a-z])|activex", ("COM", "OLE", "自动化")),
|
||
(r"(?:^|[^a-z])ftp(?:$|[^a-z])", ("FTP", "文件传输")),
|
||
(r"邮件|(?:^|[^a-z])(?:smtp|pop3|imap|mail)(?:$|[^a-z])", ("邮件", "SMTP", "POP3", "IMAP")),
|
||
(r"财务|金融|证券|股票|基金|债券|期货|期权", ("金融",)),
|
||
(r"报表|报告期|report", ("报表", "报告期")),
|
||
)
|
||
|
||
SPECIAL_TAG_RULES = (
|
||
(r"去重|删除重复|dropduplicate|dedup", ("去重", "删除重复", "deduplicate")),
|
||
(r"哈希索引|哈希表", ("哈希索引", "哈希表", "hash")),
|
||
(
|
||
r"摘要|(?:^|[^a-z0-9_])(?:crc32|md5|sha1|sha224|sha256|sha384|sha512|sm3|digest|hash)(?:$|[^a-z0-9_])",
|
||
("摘要", "哈希", "散列", "digest", "hash"),
|
||
),
|
||
(r"排序|sort", ("排序", "sort")),
|
||
(r"过滤|筛选|filter", ("过滤", "筛选", "filter")),
|
||
(r"查找|搜索|find|search|lookup", ("查找", "搜索", "lookup")),
|
||
)
|
||
|
||
INTENT_TAGS = {
|
||
"返回": ("返回", "获取"),
|
||
"获取": ("获取", "查询"),
|
||
"读取": ("读取", "获取"),
|
||
"写入": ("写入", "保存"),
|
||
"设置": ("设置", "修改"),
|
||
"创建": ("创建", "生成"),
|
||
"删除": ("删除", "移除"),
|
||
"清除": ("清除", "重置"),
|
||
"添加": ("添加", "追加"),
|
||
"查找": ("查找", "搜索"),
|
||
"判断": ("判断", "检查"),
|
||
"计算": ("计算", "求解"),
|
||
"转换": ("转换", "编码"),
|
||
"解析": ("解析", "parse"),
|
||
"格式化": ("格式化", "format"),
|
||
"打开": ("打开", "open"),
|
||
"关闭": ("关闭", "close"),
|
||
"发送": ("发送", "提交"),
|
||
"连接": ("连接", "登录"),
|
||
"列出": ("列出", "列表"),
|
||
"输出": ("输出", "打印"),
|
||
}
|
||
|
||
|
||
def _taxonomy_tags(title: str) -> list[str]:
|
||
result = []
|
||
for raw in re.split(r"\s*/\s*|\s+-\s+", title):
|
||
value = raw.strip(" #")
|
||
value = re.sub(r"\([^)]*\)|([^)]*)", "", value).strip()
|
||
for suffix in ("相关函数", "及其实现", "相关"):
|
||
if value.endswith(suffix):
|
||
value = value[: -len(suffix)].rstrip()
|
||
if not value or normalize_key(value) in GENERIC_TAGS:
|
||
continue
|
||
if len(value) > 18:
|
||
continue
|
||
chinese = "".join(re.findall(r"[\u3400-\u9fff]+", value))
|
||
latin = re.sub(r"[\u3400-\u9fff]+", " ", value).strip()
|
||
if latin and normalize_key(latin) not in GENERIC_TAGS:
|
||
result.extend(
|
||
re.findall(r"[A-Za-z][A-Za-z0-9_+.-]*", latin)
|
||
)
|
||
if chinese:
|
||
result.append(chinese)
|
||
return result
|
||
|
||
|
||
def _description_intent(summary: str) -> str:
|
||
value = strip_terminal_punctuation(summary)
|
||
replacements = {
|
||
"得到": "获取",
|
||
"获得": "获取",
|
||
"取得": "获取",
|
||
"新建": "创建",
|
||
}
|
||
for source, target in replacements.items():
|
||
if value.startswith(source):
|
||
return target
|
||
for intent in INTENT_TAGS:
|
||
if value.startswith(intent):
|
||
return intent
|
||
for intent in ("读取", "写入", "保存", "计算", "判断", "转换", "解析"):
|
||
if intent in value[:16]:
|
||
return "写入" if intent == "保存" else intent
|
||
return ""
|
||
|
||
|
||
def derive_tags(entry: EntryContext, page: PageContext) -> list[str]:
|
||
tags: list[str] = []
|
||
seen: set[str] = set()
|
||
|
||
def add(value: str) -> None:
|
||
value = clean_text(value).strip()
|
||
key = normalize_key(value)
|
||
if (
|
||
not value
|
||
or key in seen
|
||
or key in GENERIC_TAGS
|
||
or len(tags) >= 12
|
||
):
|
||
return
|
||
seen.add(key)
|
||
tags.append(value)
|
||
|
||
for tag in entry.tags:
|
||
add(tag)
|
||
|
||
for tag in _taxonomy_tags(page.title):
|
||
add(tag)
|
||
|
||
haystack = " ".join(
|
||
(entry.name, entry.signature, entry.summary, page.title, page.path)
|
||
)
|
||
domain_haystack = " ".join((page.title, page.path))
|
||
for pattern, aliases in SPECIAL_TAG_RULES:
|
||
if re.search(pattern, haystack, re.IGNORECASE):
|
||
for alias in aliases:
|
||
add(alias)
|
||
|
||
summary = strip_terminal_punctuation(entry.summary)
|
||
intent = _description_intent(summary)
|
||
if not intent and not _starts_with_verb(summary):
|
||
is_market_price = (
|
||
normalize_key(entry.name) in {"open", "close"}
|
||
and "盘价" in summary
|
||
)
|
||
if not is_market_price:
|
||
intent = _name_intent(entry.name)
|
||
for alias in INTENT_TAGS.get(intent, ()):
|
||
add(alias)
|
||
for pattern, aliases in DOMAIN_RULES:
|
||
if re.search(pattern, domain_haystack, re.IGNORECASE):
|
||
for alias in aliases:
|
||
add(alias)
|
||
for acronym in ACRONYM_RE.findall(entry.summary):
|
||
add(acronym)
|
||
return tags
|
||
|
||
|
||
def _scope_for_page(page: str) -> str:
|
||
parts = Path(page).parts
|
||
return parts[0] if len(parts) > 1 else Path(page).stem
|
||
|
||
|
||
def _page_title(lines: list[str], page: str) -> PageContext:
|
||
for line in lines:
|
||
match = H1_RE.match(line)
|
||
if match:
|
||
return PageContext(match.group(1), page)
|
||
return PageContext(Path(page).stem, page)
|
||
|
||
|
||
def _entry_metadata(lines: list[str], start: int, end: int):
|
||
declaration_seen = False
|
||
description_index = None
|
||
tag_index = None
|
||
access = ""
|
||
for index in range(start + 1, end):
|
||
text = lines[index].strip()
|
||
if not text:
|
||
continue
|
||
if not declaration_seen:
|
||
if DECLARATION_LINE_RE.fullmatch(text):
|
||
declaration_seen = True
|
||
continue
|
||
tag_match = TAGS_RE.fullmatch(text)
|
||
if tag_match:
|
||
tag_index = index
|
||
continue
|
||
access_match = ACCESS_RE.fullmatch(text)
|
||
if access_match:
|
||
access = access_match.group(1)
|
||
continue
|
||
if description_index is None:
|
||
if text.startswith(("|", "#", "返回:", "类型:", "可见性:", "值:")):
|
||
continue
|
||
description_index = index
|
||
tags = ()
|
||
if tag_index is not None:
|
||
tags = tuple(TAGS_RE.fullmatch(lines[tag_index].strip()).group(1).split())
|
||
return description_index, tag_index, access, tags
|
||
|
||
|
||
def enrich_markdown(
|
||
text: str, page: str, source_catalog: SourceCatalog
|
||
) -> tuple[str, Audit]:
|
||
had_final_newline = text.endswith("\n")
|
||
lines = text.splitlines()
|
||
page_context = _page_title(lines, page)
|
||
entries = [entry for entry in iter_api_entries(lines) if entry.heading.valid]
|
||
audit = Audit(entries=len(entries))
|
||
root_owner = ""
|
||
owners: dict[int, str] = {}
|
||
for entry in entries:
|
||
if entry.heading.level == 2:
|
||
root_owner = entry.heading.name if entry.heading.kind in {"class", "unit"} else ""
|
||
owners[entry.start] = root_owner if entry.heading.level > 2 else ""
|
||
|
||
for api in reversed(entries):
|
||
description_index, tag_index, access, existing_tags = _entry_metadata(
|
||
lines, api.start, api.end
|
||
)
|
||
if description_index is None:
|
||
continue
|
||
old_description = lines[description_index].strip()
|
||
entry = EntryContext(
|
||
name=api.heading.name,
|
||
signature=api.heading.signature,
|
||
kind=api.heading.kind,
|
||
scope=_scope_for_page(page),
|
||
page=page,
|
||
summary=old_description,
|
||
access=access,
|
||
owner=owners.get(api.start, ""),
|
||
tags=existing_tags,
|
||
)
|
||
sources = source_catalog.match(entry)
|
||
if sources:
|
||
audit.source_matches += 1
|
||
if len(sources) > 1:
|
||
audit.ambiguous_sources += 1
|
||
evidence = sources
|
||
normalized_summary = strip_terminal_punctuation(old_description)
|
||
if (
|
||
normalized_summary in GENERIC_SUMMARIES
|
||
or PROHIBITED_PROCESS_RE.search(normalized_summary)
|
||
):
|
||
evidence = source_catalog.describe(sources)
|
||
new_description = improve_description(entry, evidence)
|
||
entry_for_tags = replace(entry, summary=new_description)
|
||
new_tags = tuple(derive_tags(entry_for_tags, page_context))
|
||
|
||
if new_description != old_description:
|
||
lines[description_index] = new_description
|
||
audit.changed_descriptions += 1
|
||
if new_tags != existing_tags:
|
||
rendered = f"<!-- tags: {' '.join(new_tags)} -->"
|
||
if tag_index is not None:
|
||
lines[tag_index] = rendered
|
||
else:
|
||
insert_at = description_index + 1
|
||
if insert_at < len(lines) and lines[insert_at].strip() == "":
|
||
insert_at += 1
|
||
lines[insert_at:insert_at] = [rendered, ""]
|
||
else:
|
||
lines[insert_at:insert_at] = ["", rendered, ""]
|
||
audit.changed_tags += 1
|
||
|
||
if new_description != old_description or new_tags != existing_tags:
|
||
audit.changes.append(
|
||
AuditChange(
|
||
page=page,
|
||
name=api.heading.name,
|
||
kind=api.heading.kind,
|
||
old_description=old_description,
|
||
new_description=new_description,
|
||
old_tags=existing_tags,
|
||
new_tags=new_tags,
|
||
sources=tuple(
|
||
f"{record.corpus}:{record.page_id}" for record in sources
|
||
),
|
||
)
|
||
)
|
||
|
||
result = "\n".join(lines)
|
||
if had_final_newline:
|
||
result += "\n"
|
||
return result, audit
|
||
|
||
|
||
def _write_report(path: Path, audit: Audit) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
with path.open("w", encoding="utf-8", newline="") as handle:
|
||
writer = csv.writer(handle, delimiter="\t", lineterminator="\n")
|
||
writer.writerow(
|
||
(
|
||
"page",
|
||
"name",
|
||
"kind",
|
||
"old_description",
|
||
"new_description",
|
||
"old_tags",
|
||
"new_tags",
|
||
"sources",
|
||
)
|
||
)
|
||
for item in audit.changes:
|
||
writer.writerow(
|
||
(
|
||
item.page,
|
||
item.name,
|
||
item.kind,
|
||
item.old_description,
|
||
item.new_description,
|
||
" ".join(item.old_tags),
|
||
" ".join(item.new_tags),
|
||
" ".join(item.sources),
|
||
)
|
||
)
|
||
|
||
|
||
def _selected(page: str, scopes: set[str], prefixes: tuple[str, ...]) -> bool:
|
||
if scopes and _scope_for_page(page) not in scopes:
|
||
return False
|
||
return not prefixes or page.startswith(prefixes)
|
||
|
||
|
||
def main(argv=None) -> int:
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
mode = parser.add_mutually_exclusive_group(required=True)
|
||
mode.add_argument("--audit", action="store_true", help="report changes only")
|
||
mode.add_argument("--apply", action="store_true", help="write proposed changes")
|
||
parser.add_argument("--root", type=Path, required=True, help="codegen Markdown root")
|
||
parser.add_argument(
|
||
"--docs-root", type=Path, required=True, help="cached tmp/docs root"
|
||
)
|
||
parser.add_argument("--report", type=Path, help="optional TSV audit report")
|
||
parser.add_argument("--scope", action="append", default=[])
|
||
parser.add_argument("--prefix", action="append", default=[])
|
||
args = parser.parse_args(argv)
|
||
|
||
catalog = SourceCatalog.from_docs_root(args.docs_root)
|
||
aggregate = Audit()
|
||
changed_files = 0
|
||
for md in sorted(args.root.rglob("*.md")):
|
||
page = md.relative_to(args.root).as_posix()
|
||
if not _selected(page, set(args.scope), tuple(args.prefix)):
|
||
continue
|
||
before_stat = md.stat()
|
||
original = md.read_text(encoding="utf-8")
|
||
enriched, audit = enrich_markdown(original, page, catalog)
|
||
aggregate.merge(audit)
|
||
if enriched == original:
|
||
continue
|
||
changed_files += 1
|
||
if args.apply:
|
||
after_stat = md.stat()
|
||
if (
|
||
after_stat.st_mtime_ns != before_stat.st_mtime_ns
|
||
or after_stat.st_size != before_stat.st_size
|
||
):
|
||
raise RuntimeError(f"target changed while processing: {md}")
|
||
md.write_text(enriched, encoding="utf-8", newline="\n")
|
||
|
||
if args.report:
|
||
_write_report(args.report, aggregate)
|
||
print(
|
||
"entries={entries} source_matches={source_matches} "
|
||
"ambiguous_sources={ambiguous_sources} changed_files={changed_files} "
|
||
"changed_descriptions={changed_descriptions} changed_tags={changed_tags}".format(
|
||
entries=aggregate.entries,
|
||
source_matches=aggregate.source_matches,
|
||
ambiguous_sources=aggregate.ambiguous_sources,
|
||
changed_files=changed_files,
|
||
changed_descriptions=aggregate.changed_descriptions,
|
||
changed_tags=aggregate.changed_tags,
|
||
)
|
||
)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|