#!/usr/bin/env python3 import argparse import difflib import json import re import sys import unicodedata from dataclasses import dataclass from pathlib import Path from urllib.parse import unquote SKILL_ROOT = Path(__file__).resolve().parents[1] DEFAULT_REFERENCES_DIR = SKILL_ROOT / "references" HEADING_RE = re.compile(r"^(#{1,6})(?!#)\s+(.+?)\s*$") FENCE_RE = re.compile(r"^```([^`]*)$") MARKDOWN_LINK_RE = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)") ASCII_TOKEN_RE = re.compile(r"[a-z_][a-z0-9_.$:+-]*", re.IGNORECASE) CHINESE_RUN_RE = re.compile(r"[\u3400-\u9fff]+") INLINE_CODE_RE = re.compile(r"`([^`\n]+)`") FENCED_CODE_RE = re.compile(r"```[^\n]*\n(.*?)```", re.DOTALL) IDENTITY_PREFIX = "代码块身份:" BLOCK_DESCRIPTION_PREFIX = "代码块说明:" ALLOWED_IDENTITIES = { "可直接照写示例", "反例 / 不可照写", "输出片段", "配置片段 / 概念骨架", "仅服务端可执行示例", } ROUTER_PHRASES = ("路由中心", "选择一个主专题", "候选页继续判断") EXCLUDED_REFERENCE_FILES = {"index.md"} DUTY_HEADING = "本篇职责" SUSPICIOUS_FENCE_RE = re.compile(r"^(?:\s+`{3}|`{4,})") WRITE_PRELUDE_ANCHORS = ( ("02_core_model.md", "文件模型核心规则"), ("01_quickstart.md", "语言核心事实速查"), ) HEADING_TOKEN_SCORE = 12 HEADING_EXACT_SCORE = 20 TERM_TOKEN_SCORE = 10 TERM_EXACT_SCORE = 16 PAGE_TITLE_TOKEN_SCORE = 5 PAGE_TITLE_EXACT_SCORE = 8 BODY_TOKEN_SCORE = 3 BODY_EXACT_SCORE = 4 DIRECT_EXAMPLE_BOOST = 8 PITFALL_PAGE_BOOST = 30 COUNTEREXAMPLE_BOOST = 8 EXACT_ERROR_BOOST = 14 MIXED_QUERY_MIN_SCORE = 10 PAGE_INTENT_SCORE = 80 CHINESE_STOP_TOKENS = { "一个", "为什", "什么", "怎么", "怎样", "是否", "能不", "不能", "帮我", "想要", "然后", "里面", } ASCII_FILTER_STOP_TOKENS = { "debug", "please", "program", "tinysoft", "tsl", "tsf", } QUERY_SYNONYMS = { "打印": ("输出", "writeLn"), "打出来": ("输出", "writeLn"), "左连接": ("左联接", "left join", "TS-SQL"), "左外连接": ("左联接", "left join", "TS-SQL"), "left outer join": ("left join", "左联接", "TS-SQL"), "列表": ("数组",), "复用文件": ("tsf", "unit"), "多个文件": ("unit", "uses", "作用域"), "跳出去": ("break", "控制流"), "程序慢": ("性能分析", "计时", "profiler"), "瓶颈": ("性能分析", "profiler"), "debug": ("调试", "性能分析"), "program": ("脚本",), "tinysoft": ("天软", "TSL"), "字符串转整数": ("类型转换", "strToInt"), "高性能矩阵": ("FMArray",), } PAGE_INTENT_ALIASES = { "01_quickstart.md": ("最简单能跑", "天软脚本", "tinysoft"), "02_core_model.md": ("脚本和可复用", "可复用函数文件"), "03_values_and_literals.md": ("字符串和数组下标",), "04_variables_and_constants.md": ("常量怎么声明", "变量能不能直接赋值"), "05_functions_and_calls.md": ("默认参数", "函数怎么带"), "06_expressions_and_operators.md": ("赋值和相等比较",), "07_control_flow.md": ("跳出去", "循环里满足条件"), "08_objects_and_classes.md": ("定义类", "创建对象"), "09_units_and_scope.md": ("多个文件", "复用一组函数"), "10_runtime_context_and_with.md": ("临时切换系统参数",), "11_pitfalls.md": ("声明函数后面写代码", "语法报错"), "12_matrix_and_collections.md": ("某行存在", "二维数组怎么判断"), "13_resultset_and_filters.md": ("保留匹配行", "按某一列"), "14_ts_sql.md": ("左连接", "左外连接", "左联接", "数据库", "分组排序", "聚合排序"), "15_debug_and_profiler.md": ("程序慢", "计时找瓶颈", "性能瓶颈", "性能问题", "debug"), "16_lexical_structure_and_compile_options.md": ("变量名区分大小写", "注释怎么写"), "17_types_and_conversions.md": ("字符串转整数", "类型转换"), "18_external_calls_and_threads.md": ("调用 dll", "开线程"), "19_namespace_libpath_and_unit_runtime.md": ("找不到 tsf", "搜索路径"), "20_object_runtime_and_introspection.md": ("查看对象属于哪个类", "运行时对象"), "21_builtin_runtime_objects.md": ("内存流",), "22_matrix_deep_dive.md": ("矩阵求逆", "矩阵转置", "求逆和转置"), "23_fmarray.md": ("高性能矩阵", "fmarray"), "24_object_overloads_and_iteration.md": ("自定义对象支持下标", "for in"), } @dataclass(frozen=True) class Section: id: str page: Path page_title: str heading_path: tuple[str, ...] body: str local_body: str identities: tuple[str, ...] searchable_text: str @dataclass(frozen=True) class ValidationProblem: page: Path line: int message: str @dataclass(frozen=True) class QueryMatch: section: Section score: int priority: tuple[int, ...] = () reasons: tuple[str, ...] = () @dataclass(frozen=True) class ScoreBreakdown: intent: int heading_path: int exact_term: int page_title: int body: int mode_boost: int synonym_hits: int diagnose_priority: bool = False @property def lexical_total(self) -> int: return ( self.intent + self.heading_path + self.exact_term + self.page_title + self.body ) @property def total(self) -> int: return self.lexical_total + self.mode_boost @property def priority(self) -> tuple[int, ...]: if self.diagnose_priority: return ( self.intent, self.mode_boost, self.heading_path, self.exact_term, self.page_title, self.body, ) return ( self.intent, self.heading_path, self.exact_term, self.page_title, self.mode_boost, self.body, ) @dataclass class QueryResult: query: str mode: str matches: list[QueryMatch] prelude: list[Section] limit: int def normalize(value: str) -> str: return unicodedata.normalize("NFKC", value).casefold() SYMBOL_SLUG_REPLACEMENTS = ( ("**", " double-star "), ("[]", " index "), ("::", " double-colon "), (":.", " colon-dot "), ("*", " star "), ) def _slug(value: str) -> str: normalized = normalize(value) for symbol, replacement in SYMBOL_SLUG_REPLACEMENTS: normalized = normalized.replace(symbol, replacement) slug = re.sub(r"[^\w]+", "-", normalized, flags=re.UNICODE).strip("-_") return slug or "section" def section_id(relative_page: Path | str, heading_path: tuple[str, ...]) -> str: page = Path(relative_page) parts = [_slug(page.with_suffix("").as_posix()), *(_slug(item) for item in heading_path)] return "--".join(parts) def _reference_pages(references_dir: Path) -> list[Path]: return [ page for page in sorted(references_dir.glob("*.md"), key=lambda item: item.name) if page.name not in EXCLUDED_REFERENCE_FILES ] def _heading_records(lines: list[str]) -> tuple[str, list[tuple[int, int, str]]]: page_title = "" records: list[tuple[int, int, str]] = [] in_fence = False for index, line in enumerate(lines): if FENCE_RE.match(line.rstrip("\r\n")): in_fence = not in_fence continue if in_fence: continue match = HEADING_RE.match(line.rstrip("\r\n")) if not match: continue level = len(match.group(1)) title = match.group(2) if level == 1 and not page_title: page_title = title elif level in (2, 3, 4): records.append((index, level, title)) return page_title, records def _associated_identity(lines: list[str], opening_fence: int) -> str | None: previous = opening_fence - 1 while previous >= 0 and not lines[previous].strip(): previous -= 1 while previous >= 0 and lines[previous].strip().startswith(BLOCK_DESCRIPTION_PREFIX): previous -= 1 while previous >= 0 and not lines[previous].strip(): previous -= 1 if previous < 0: return None metadata = lines[previous].strip() if not metadata.startswith(IDENTITY_PREFIX): return None identity = metadata[len(IDENTITY_PREFIX) :].strip() earlier = previous - 1 while earlier >= 0 and not lines[earlier].strip(): earlier -= 1 if earlier >= 0 and lines[earlier].strip().startswith(IDENTITY_PREFIX): return None return identity if identity in ALLOWED_IDENTITIES else None def _identities(body: str) -> tuple[str, ...]: identities: list[str] = [] lines = body.splitlines() in_fence = False for index, line in enumerate(lines): if not FENCE_RE.match(line): continue if not in_fence: identity = _associated_identity(lines, index) if identity is not None: identities.append(identity) in_fence = not in_fence return tuple(identities) def load_sections(references_dir: Path = DEFAULT_REFERENCES_DIR) -> list[Section]: sections: list[Section] = [] for page in _reference_pages(Path(references_dir)): text = page.read_text(encoding="utf-8") lines = text.splitlines(keepends=True) page_title, headings = _heading_records(lines) heading_stack: dict[int, str] = {} for position, (start, level, title) in enumerate(headings): for stacked_level in tuple(heading_stack): if stacked_level >= level: del heading_stack[stacked_level] heading_stack[level] = title heading_path = tuple( heading_stack[item] for item in range(2, level + 1) if item in heading_stack ) end = len(lines) for next_start, next_level, _ in headings[position + 1 :]: if next_level <= level: end = next_start break body = "".join(lines[start:end]) local_end = ( headings[position + 1][0] if position + 1 < len(headings) else len(lines) ) local_body = "".join(lines[start:local_end]) base_id = section_id(page.relative_to(references_dir), heading_path) searchable_text = normalize( "\n".join((page.stem, page_title, *heading_path, local_body)) ) sections.append( Section( id=base_id, page=page, page_title=page_title, heading_path=heading_path, body=body, local_body=local_body, identities=_identities(local_body), searchable_text=searchable_text, ) ) return sections def _identity_problems(page: Path, lines: list[str]) -> list[ValidationProblem]: problems: list[ValidationProblem] = [] for index, line in enumerate(lines, start=1): stripped = line.strip() if stripped.startswith(IDENTITY_PREFIX): identity = stripped[len(IDENTITY_PREFIX) :].strip() if identity not in ALLOWED_IDENTITIES: problems.append(ValidationProblem(page, index, f"未知身份:{identity}")) in_fence = False for index, line in enumerate(lines): if FENCE_RE.match(line): if in_fence: in_fence = False else: if _associated_identity(lines, index) is None: problems.append( ValidationProblem( page, index + 1, "每个代码围栏必须关联恰好一个代码块身份" ) ) in_fence = True continue if not in_fence and SUSPICIOUS_FENCE_RE.match(line): problems.append( ValidationProblem( page, index + 1, "不支持的代码围栏形态(缩进围栏或四个及以上反引号)" ) ) if in_fence: problems.append(ValidationProblem(page, len(lines), "代码围栏未闭合")) return problems def _local_link_problems( page: Path, text: str, references_dir: Path ) -> list[ValidationProblem]: problems: list[ValidationProblem] = [] # Blank out fenced/inline code without changing line offsets, so reported # line numbers line up with the original file. without_fences = re.sub( r"```.*?```", lambda match: re.sub(r"[^\n]", " ", match.group(0)), text, flags=re.DOTALL, ) searchable_markdown = re.sub( r"`[^`\n]*`", lambda match: " " * len(match.group(0)), without_fences ) for match in MARKDOWN_LINK_RE.finditer(searchable_markdown): target = match.group(1).strip().split(maxsplit=1)[0].strip("<>") if target.startswith(("#", "http://", "https://", "mailto:")): continue target_path = unquote(target.split("#", 1)[0].replace("\\", "/")) resolved = (page.parent / target_path).resolve() try: resolved.relative_to(references_dir.resolve()) except ValueError: exists = False else: exists = resolved.is_file() if not exists: line = searchable_markdown.count("\n", 0, match.start()) + 1 problems.append(ValidationProblem(page, line, f"本地链接不存在:{target}")) return problems def validate_references( references_dir: Path = DEFAULT_REFERENCES_DIR, ) -> list[ValidationProblem]: references_dir = Path(references_dir) problems: list[ValidationProblem] = [] index_page = references_dir / "index.md" if index_page.exists(): problems.append(ValidationProblem(index_page, 1, "references 中不得保留 index.md")) for page in sorted(references_dir.glob("*.md"), key=lambda item: item.name): text = page.read_text(encoding="utf-8") lines = text.splitlines() problems.extend(_identity_problems(page, lines)) problems.extend(_local_link_problems(page, text, references_dir)) page_title, headings = _heading_records(text.splitlines(keepends=True)) h1_count = 0 in_fence = False previous_level = 1 for index, line in enumerate(lines): if FENCE_RE.match(line): in_fence = not in_fence continue if in_fence: continue match = HEADING_RE.match(line) if match is None: continue level = len(match.group(1)) if level == 1: h1_count += 1 previous_level = 1 continue if level not in (2, 3, 4): continue if level > previous_level + 1: problems.append( ValidationProblem(page, index + 1, "H2/H3/H4 标题层级跳跃") ) previous_level = level if h1_count != 1 or not page_title: problems.append(ValidationProblem(page, 1, "每页必须有且仅有一个非空 H1")) duty_sections = [] for position, (start, level, title) in enumerate(headings): if level != 2 or title != DUTY_HEADING: continue end = len(lines) for next_start, next_level, _ in headings[position + 1 :]: if next_level <= level: end = next_start break body = "\n".join(lines[start + 1 : end]).strip() duty_sections.append(body) if len(duty_sections) != 1 or not duty_sections[0]: problems.append( ValidationProblem( page, 1, "每页必须有且仅有一个非空「本篇职责」" ) ) for phrase in ROUTER_PHRASES: for index, line in enumerate(lines, start=1): if phrase in line: problems.append( ValidationProblem(page, index, f"包含人工路由协议:{phrase}") ) sections = load_sections(references_dir) ids: dict[str, Section] = {} for section in sections: if section.id in ids: problems.append(ValidationProblem(section.page, 1, f"重复 section ID:{section.id}")) ids[section.id] = section for page_name, heading in WRITE_PRELUDE_ANCHORS: page_sections = [ section for section in sections if section.page.name == page_name ] # Only enforce the anchor when the page is present, so validating a # synthetic references dir (tests) does not demand the bundled pages. if page_sections and not any( heading in section.heading_path for section in page_sections ): problems.append( ValidationProblem( references_dir / page_name, 1, f"write 模式前置章节缺失:{page_name} 的「{heading}」", ) ) # 概念地图逐页从「本篇职责」段生成;有该段的页必须产出非空摘要, # 否则某页职责段被清空/写坏时地图会静默缺页。 mapped_pages = {page_name for page_name, _, _ in build_concept_map(references_dir)} reference_pages = {page.name for page in _reference_pages(references_dir)} if mapped_pages != reference_pages: missing = ", ".join(sorted(reference_pages - mapped_pages)) or "none" problems.append( ValidationProblem( references_dir, 1, f"概念地图页数与参考页不一致;缺失:{missing}", ) ) for section in sections: if section.heading_path != (DUTY_HEADING,): continue if section.page.name not in mapped_pages: problems.append( ValidationProblem( section.page, 1, f"概念地图摘要为空:{section.page.name} 的「{DUTY_HEADING}」", ) ) return problems def _base_query_tokens(text: str) -> set[str]: normalized = normalize(text) tokens = set(ASCII_TOKEN_RE.findall(normalized)) for run in CHINESE_RUN_RE.findall(normalized): tokens.add(run) tokens.update(run[index : index + 2] for index in range(len(run) - 1)) return { token for token in tokens if token.strip() and token not in CHINESE_STOP_TOKENS } def _ascii_token_sequence(text: str) -> list[str]: return [ token.rstrip(".$:+-") for token in ASCII_TOKEN_RE.findall(normalize(text)) if token.rstrip(".$:+-") ] def _query_contains_phrase(query: str, phrase: str) -> bool: normalized_phrase = normalize(phrase) if CHINESE_RUN_RE.search(normalized_phrase): return normalized_phrase in normalize(query) phrase_tokens = _ascii_token_sequence(phrase) query_tokens_in_order = _ascii_token_sequence(query) if not phrase_tokens: return False width = len(phrase_tokens) return any( query_tokens_in_order[index : index + width] == phrase_tokens for index in range(len(query_tokens_in_order) - width + 1) ) def _synonym_tokens(text: str) -> set[str]: tokens: set[str] = set() for phrase, synonyms in QUERY_SYNONYMS.items(): if not _query_contains_phrase(text, phrase): continue for synonym in synonyms: tokens.update(_base_query_tokens(synonym)) return tokens def query_tokens(text: str) -> set[str]: return _base_query_tokens(text) | _synonym_tokens(text) def _text_contains_token(text: str, token: str) -> bool: normalized_text = normalize(text) if ASCII_TOKEN_RE.fullmatch(token): text_tokens = _bare_tokens(set(ASCII_TOKEN_RE.findall(normalized_text))) return token.rstrip(".$:+-") in text_tokens return token in normalized_text def _text_contains_exact_query(text: str, query: str) -> bool: normalized_query = normalize(query).strip() if ASCII_TOKEN_RE.fullmatch(normalized_query): return _text_contains_token(text, normalized_query) return bool(normalized_query and normalized_query in normalize(text)) def _intent_score(section: Section, query: str) -> int: aliases = PAGE_INTENT_ALIASES.get(section.page.name, ()) return PAGE_INTENT_SCORE * sum( _query_contains_phrase(query, alias) for alias in aliases ) def _has_chinese_context(section: Section, query: str) -> bool: runs = CHINESE_RUN_RE.findall(normalize(query)) for run in runs: tokens = ( [run] if len(run) < 2 else [run[index : index + 2] for index in range(len(run) - 1)] ) matched = sum(token in section.searchable_text for token in tokens) if matched >= (len(tokens) + 1) // 2: return True return not runs def _code_text(body: str) -> str: inline = INLINE_CODE_RE.findall(body) fenced = FENCED_CODE_RE.findall(body) return normalize("\n".join((*inline, *fenced))) def _score_section(section: Section, query: str, mode: str) -> ScoreBreakdown: normalized_query = normalize(query).strip() tokens = query_tokens(query) heading_text = normalize("\n".join(section.heading_path)) page_title_text = normalize(section.page_title) body_text = normalize(section.local_body) term_text = _code_text(section.local_body) expanded_only_tokens = _synonym_tokens(query) - _base_query_tokens(query) synonym_hits = sum( any( _text_contains_token(text, token) for text in (heading_text, term_text, page_title_text, body_text) ) for token in expanded_only_tokens ) heading_score = 0 term_score = 0 page_title_score = 0 body_score = 0 for token in tokens: if _text_contains_token(heading_text, token): heading_score += HEADING_TOKEN_SCORE if _text_contains_token(term_text, token): term_score += TERM_TOKEN_SCORE if _text_contains_token(page_title_text, token): page_title_score += PAGE_TITLE_TOKEN_SCORE if _text_contains_token(body_text, token): body_score += BODY_TOKEN_SCORE if _text_contains_exact_query(heading_text, query): heading_score += HEADING_EXACT_SCORE if _text_contains_exact_query(term_text, query): term_score += TERM_EXACT_SCORE if _text_contains_exact_query(page_title_text, query): page_title_score += PAGE_TITLE_EXACT_SCORE if _text_contains_exact_query(body_text, query): body_score += BODY_EXACT_SCORE mode_boost = 0 if mode == "write" and "可直接照写示例" in section.identities: mode_boost += DIRECT_EXAMPLE_BOOST if mode == "diagnose": if section.page.name == "11_pitfalls.md": mode_boost += PITFALL_PAGE_BOOST if "反例 / 不可照写" in section.identities: mode_boost += COUNTEREXAMPLE_BOOST if normalized_query and normalized_query in section.searchable_text: mode_boost += EXACT_ERROR_BOOST return ScoreBreakdown( intent=_intent_score(section, query), heading_path=heading_score, exact_term=term_score, page_title=page_title_score, body=body_score, mode_boost=mode_boost, synonym_hits=synonym_hits, diagnose_priority=mode == "diagnose", ) def _score_reasons(score: ScoreBreakdown) -> tuple[str, ...]: components = ( ("intent", score.intent), ("heading", score.heading_path), ("identifier", score.exact_term), ("page_title", score.page_title), ("body", score.body), ("mode", score.mode_boost), ) reasons = tuple(f"{name}={value}" for name, value in components if value) if score.synonym_hits: reasons += (f"synonym={score.synonym_hits}",) return reasons def _write_prelude(sections: list[Section]) -> list[Section]: prelude: list[Section] = [] for page_name, heading in WRITE_PRELUDE_ANCHORS: match = next( ( section for section in sections if section.page.name == page_name and heading in section.heading_path ), None, ) if match is not None: prelude.append(match) return prelude STRING_LITERAL_RE = re.compile(r'"[^"\n]*"|\'[^\'\n]*\'') def _bare_tokens(tokens: set[str]) -> set[str]: # ASCII_TOKEN_RE 的连续字符类会把尾缀符号吞进 token(如编译开关 # `{$varByRef-}` 产出 `varbyref-`);按剥掉尾部符号的裸形态比较。 return {token.rstrip(".$:+-") for token in tokens} - {""} def _ascii_anchor(section: Section, ascii_tokens: set[str]) -> bool: # 只有足够长的标识符按标识符位置命中(标题、行内代码或剥离字符串 # 字面量后的围栏代码)才豁免中文门控;短 token 与字符串样例数据 # (如 "XYZ"、"xyz*")不算点名,避免样例值驱动召回。 anchor_tokens = { token for token in _bare_tokens(ascii_tokens) if len(token) >= 4 } if not anchor_tokens: return False heading_tokens = set( ASCII_TOKEN_RE.findall(normalize("\n".join(section.heading_path))) ) code_text = "\n".join( ( *INLINE_CODE_RE.findall(section.local_body), *FENCED_CODE_RE.findall(section.local_body), ) ) code_tokens = set( ASCII_TOKEN_RE.findall(normalize(STRING_LITERAL_RE.sub(" ", code_text))) ) return bool(anchor_tokens & _bare_tokens(heading_tokens | code_tokens)) def _related_sections(a: Section, b: Section) -> bool: if a.page != b.page or len(a.heading_path) == len(b.heading_path): return False shorter, longer = sorted((a.heading_path, b.heading_path), key=len) return longer[: len(shorter)] == shorter def query_sections( query: str, mode: str, limit: int = 5, references_dir: Path = DEFAULT_REFERENCES_DIR, ) -> QueryResult: if mode not in {"write", "diagnose", "explain"}: raise ValueError(f"unsupported mode: {mode}") if not 1 <= limit <= 10: raise ValueError("limit must be between 1 and 10") all_sections = load_sections(references_dir) sections = all_sections prelude = _write_prelude(all_sections) if mode == "write" else [] required_ids = {section.id for section in prelude} raw_ascii_tokens = { token for token in _base_query_tokens(query) if ASCII_TOKEN_RE.fullmatch(token) and token not in ASCII_FILTER_STOP_TOKENS } ascii_tokens = { token for token in raw_ascii_tokens if any(_ascii_anchor(section, {token}) for section in all_sections) } if ascii_tokens: sections = [ section for section in sections if any( _text_contains_token(section.searchable_text, token) for token in ascii_tokens ) ] has_chinese = bool(CHINESE_RUN_RE.search(normalize(query))) minimum_score = MIXED_QUERY_MIN_SCORE if ascii_tokens and has_chinese else 1 ranked: list[QueryMatch] = [] for section in sections: # 中文上下文门控只裁剪正文级 ASCII 噪声;标识符在标题或代码里 # 精确命中的 section 不因中文措辞不同而被丢弃。 if ( ascii_tokens and has_chinese and not _ascii_anchor(section, ascii_tokens) and not _has_chinese_context(section, query) ): continue score = _score_section(section, query, mode) if score.lexical_total < minimum_score: continue ranked.append( QueryMatch( section, score.total, score.priority, _score_reasons(score), ) ) ranked.sort( key=lambda match: ( -match.score, *(-value for value in match.priority), match.section.page.as_posix(), match.section.id, ) ) # H2 聚合 section 的正文逐字包含其 H3 子节;父子同时入选时只保留 # 排名更高的一个,避免同一内容重复返回。 matches: list[QueryMatch] = [] page_counts: dict[Path, int] = {} for match in ranked: if match.section.id in required_ids: continue if any( _related_sections(match.section, kept.section) for kept in matches ): continue if page_counts.get(match.section.page, 0) >= 2: continue matches.append(match) page_counts[match.section.page] = page_counts.get(match.section.page, 0) + 1 if len(matches) == limit: break return QueryResult( query=query, mode=mode, matches=matches, prelude=prelude, limit=limit, ) def _logical_source(section: Section) -> str: return f"references/{section.page.name}" def _safe_json_string(value: str) -> str: encoded = json.dumps(value, ensure_ascii=False) for separator in ("\u0085", "\u2028", "\u2029"): encoded = encoded.replace(separator, f"\\u{ord(separator):04x}") return encoded def _plain_text_summary(body: str, limit: int = 180) -> str: without_fences = FENCED_CODE_RE.sub(" ", body) without_links = re.sub( r"!?\[([^\]]*)\]\([^)]+\)", lambda match: match.group(1), without_fences ) without_inline_code = INLINE_CODE_RE.sub(lambda match: match.group(1), without_links) content_lines = [] for line in without_inline_code.splitlines(): stripped = line.strip() if not stripped or stripped.startswith(("#", IDENTITY_PREFIX, BLOCK_DESCRIPTION_PREFIX)): continue content_lines.append(stripped.lstrip("-* ")) summary = re.sub(r"\s+", " ", " ".join(content_lines)).strip() if len(summary) <= limit: return summary return summary[: limit - 1].rstrip() + "…" def render_candidates(result: QueryResult) -> str: lines = [ "# TSL Syntax Candidates", "", f"Mode: `{result.mode}`", f"Query: {_safe_json_string(result.query)}", ] candidates = [ (section, 0, True, ("required=1",)) for section in result.prelude ] + [ (match.section, match.score, False, match.reasons) for match in result.matches ] for index, (section, score, required, reasons) in enumerate( candidates[: result.limit], start=1 ): lines.extend( [ "", f"## Candidate {index}", "", f"Score: {score}", f"Required: {'yes' if required else 'no'}", f"Section ID: `{section.id}`", f"Source: `{_logical_source(section)}`", f"Heading: `{' > '.join(section.heading_path)}`", f"Why: `{', '.join(reasons) or 'lexical=1'}`", f"Summary: {_plain_text_summary(section.local_body)}", ] ) return "\n".join(lines).rstrip() + "\n" def render_section(section: Section) -> str: lines = [ "# TSL Syntax Section", "", f"Section ID: `{section.id}`", f"Source: `{_logical_source(section)}`", "", section.body.rstrip(), ] return "\n".join(lines).rstrip() + "\n" def build_concept_map(references_dir: Path = DEFAULT_REFERENCES_DIR) -> list[tuple[str, str, str]]: entries: list[tuple[str, str, str]] = [] seen_pages: set[str] = set() for section in load_sections(references_dir): page_name = section.page.name if page_name in seen_pages: continue if section.heading_path != (DUTY_HEADING,): continue summary = _plain_text_summary(section.local_body, limit=600) if not summary: continue seen_pages.add(page_name) entries.append((page_name, section.page_title, summary)) entries.sort(key=lambda item: item[0]) return entries def render_concept_map(entries: list[tuple[str, str, str]]) -> str: lines = [ "# TSL 概念地图", "", "把自然语言需求映射到该查哪个 TSL 概念,随后仍用 --query 获取候选、用 --section 取回事实正文。", "本清单不含可照写语法,也不替代精确章节取回。", ] for page_name, page_title, summary in entries: lines.extend(["", f"## {page_title}", "", summary]) return "\n".join(lines).rstrip() + "\n" def _configure_utf8() -> None: for stream in (sys.stdout, sys.stderr): reconfigure = getattr(stream, "reconfigure", None) if reconfigure is not None: reconfigure(encoding="utf-8") def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Search TSL syntax reference sections") action = parser.add_mutually_exclusive_group(required=True) action.add_argument("--query") action.add_argument("--section") action.add_argument("--map", dest="show_map", action="store_true") action.add_argument("--check", action="store_true") parser.add_argument("--mode", choices=("write", "diagnose", "explain")) parser.add_argument("--limit", type=int, default=5) parser.add_argument("--references-dir", type=Path, default=DEFAULT_REFERENCES_DIR) return parser def _nearest_section_ids( requested: str, sections: list[Section], limit: int = 5 ) -> list[str]: ranked = sorted( sections, key=lambda section: ( -difflib.SequenceMatcher(None, requested, section.id).ratio(), section.id, ), ) return [section.id for section in ranked[:limit]] def main(argv: list[str] | None = None) -> int: _configure_utf8() parser = _parser() args = parser.parse_args(argv) if not 1 <= args.limit <= 10: parser.error("--limit must be between 1 and 10") if args.query is not None and args.mode is None: parser.error("--mode is required with --query") if args.mode is not None and args.query is None: parser.error("--mode only applies to --query") if args.show_map: print(render_concept_map(build_concept_map(args.references_dir)), end="") return 0 if args.check: problems = validate_references(args.references_dir) for problem in problems: print(f"{problem.page}:{problem.line}: {problem.message}", file=sys.stderr) return 1 if problems else 0 if args.section is not None: sections = load_sections(args.references_dir) section = next( (item for item in sections if item.id == args.section), None, ) if section is None: print(f"section not found: {args.section}", file=sys.stderr) print("Nearest section IDs:", file=sys.stderr) for candidate in _nearest_section_ids(args.section, sections): print(f"- {candidate}", file=sys.stderr) return 2 print(render_section(section), end="") return 0 else: result = query_sections(args.query, args.mode, args.limit, args.references_dir) if not result.matches: print(render_candidates(result), end="") print("no matching sections", file=sys.stderr) return 2 print(render_candidates(result), end="") return 0 if __name__ == "__main__": raise SystemExit(main())