#!/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 ```` 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"^$") 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"(? 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"" 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())