Files
playbook/skills/tsl-syntax-reference/scripts/lookup.py
T

1021 lines
35 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
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())