feat(tsl-syntax-reference): harden retrieval contracts

Add stable section IDs, structural and routing regressions, quickstart consistency checks, and CI enforcement.

BREAKING CHANGE: replace heading-derived Section IDs with explicit syntax-NN-NNN identifiers.
This commit is contained in:
csh
2026-07-31 10:05:15 +08:00
parent 21e688a436
commit 736d1a8ad7
30 changed files with 1635 additions and 137 deletions
+493 -89
View File
@@ -26,6 +26,21 @@ FENCED_CODE_RE = re.compile(r"```[^\n]*\n(.*?)```", re.DOTALL)
# 用户说"传引用" / "中括号")。逗号或顿号分隔,只影响检索,不是事实正文。
SECTION_TAG_RE = re.compile(r"<!--\s*tags?\s*:\s*(.*?)\s*-->", re.DOTALL | re.IGNORECASE)
TAG_SEPARATOR_RE = re.compile(r"[,,、]\s*")
SECTION_ID_RE = re.compile(
r"^<!--\s*section-id\s*:\s*([a-z0-9][a-z0-9._-]*)\s*-->$", re.IGNORECASE
)
SECTION_ID_PREFIX_RE = re.compile(r"^<!--\s*section-id\s*:", re.IGNORECASE)
QUICKSTART_RULE_RE = re.compile(
r"^<!--\s*quickstart-rule\s*:\s*([a-z0-9][a-z0-9._-]*)\s*-->$",
re.IGNORECASE,
)
QUICKSTART_RULE_PREFIX_RE = re.compile(
r"^<!--\s*quickstart-rule\s*:", re.IGNORECASE
)
STRUCTURAL_METADATA_RE = re.compile(
r"<!--\s*(?:section-id|quickstart-rule)\s*:.*?-->",
re.DOTALL | re.IGNORECASE,
)
IDENTITY_PREFIX = "代码块身份:"
BLOCK_DESCRIPTION_PREFIX = "代码块说明:"
ALLOWED_IDENTITIES = {
@@ -61,10 +76,28 @@ GENERIC_HEADINGS = frozenset(
}
)
SUSPICIOUS_FENCE_RE = re.compile(r"^(?:\s+`{3}|`{4,})")
WRITE_PRELUDE_ANCHORS = (
("02_core_model.md", "文件模型核心规则"),
("01_quickstart.md", "语言核心事实速查"),
WRITE_PRELUDE_SECTIONS = (
("syntax-02-002", "02_core_model.md", "文件模型核心规则"),
("syntax-01-002", "01_quickstart.md", "语言核心事实速查"),
)
QUICKSTART_PAGE = "01_quickstart.md"
QUICKSTART_SUMMARY_HEADING = "语言核心事实速查"
QUICKSTART_RULE_OWNER_PAGES = {
"assignment": "06_expressions_and_operators.md",
"file-choice": "02_core_model.md",
"tsl-layout": "02_core_model.md",
"tsf-layout": "02_core_model.md",
"tsf-filename": "02_core_model.md",
"function-default": "05_functions_and_calls.md",
"procedure-explicit": "05_functions_and_calls.md",
"class-shape": "08_objects_and_classes.md",
"object-creation": "08_objects_and_classes.md",
"unit-shape": "09_units_and_scope.md",
"unit-default": "09_units_and_scope.md",
"named-arguments": "05_functions_and_calls.md",
"named-argument-order": "05_functions_and_calls.md",
"index-origins": "03_values_and_literals.md",
}
HEADING_TOKEN_SCORE = 12
HEADING_EXACT_SCORE = 20
@@ -108,6 +141,18 @@ CHINESE_STOP_TOKENS = {
"里面",
}
CHINESE_QUERY_FILLERS = (
"请帮我",
"请问",
"麻烦",
"帮我",
"解释一下",
"说明一下",
"看一下",
"一下",
)
CHINESE_QUERY_PARTICLES = ("", "", "", "", "")
ASCII_FILTER_STOP_TOKENS = {
"debug",
"please",
@@ -178,6 +223,23 @@ class ValidationProblem:
message: str
@dataclass(frozen=True)
class QuickstartRule:
page: Path
line: int
bullet_line: int
key: str
text: str
class ReferenceInstallationError(RuntimeError):
pass
class ReferenceStructureError(RuntimeError):
pass
@dataclass(frozen=True)
class QueryMatch:
section: Section
@@ -258,29 +320,6 @@ 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
@@ -289,6 +328,49 @@ def _reference_pages(references_dir: Path) -> list[Path]:
]
def _reference_installation_problem(
references_dir: Path,
) -> ValidationProblem | None:
references_dir = Path(references_dir)
if references_dir.is_dir() and _reference_pages(references_dir):
return None
return ValidationProblem(
references_dir,
1,
"参考目录不可用:目录不存在或没有参考页;检查 --references-dir 或重新安装 skill",
)
def _require_reference_pages(references_dir: Path) -> list[Path]:
references_dir = Path(references_dir)
problem = _reference_installation_problem(references_dir)
if problem is not None:
raise ReferenceInstallationError(problem.message)
return _reference_pages(references_dir)
def _next_nonblank_line(lines: list[str], start: int) -> int | None:
for index in range(start, len(lines)):
if lines[index].strip():
return index
return None
def _heading_section_id(lines: list[str], heading_line: int) -> tuple[str, int]:
metadata_line = _next_nonblank_line(lines, heading_line + 1)
if metadata_line is None:
raise ReferenceStructureError(
f"{heading_line + 1} 行标题缺少显式 section ID"
)
metadata = lines[metadata_line].strip()
match = SECTION_ID_RE.fullmatch(metadata)
if match is None:
raise ReferenceStructureError(
f"{heading_line + 1} 行标题缺少显式 section ID"
)
return match.group(1), metadata_line
def _heading_records(lines: list[str]) -> tuple[str, list[tuple[int, int, str]]]:
page_title = ""
records: list[tuple[int, int, str]] = []
@@ -311,7 +393,9 @@ def _heading_records(lines: list[str]) -> tuple[str, list[tuple[int, int, str]]]
return page_title, records
def _associated_identity(lines: list[str], opening_fence: int) -> str | None:
def _associated_identity(
lines: list[str], opening_fence: int
) -> tuple[str | None, int | None]:
previous = opening_fence - 1
while previous >= 0 and not lines[previous].strip():
previous -= 1
@@ -320,17 +404,19 @@ def _associated_identity(lines: list[str], opening_fence: int) -> str | None:
while previous >= 0 and not lines[previous].strip():
previous -= 1
if previous < 0:
return None
return None, None
metadata = lines[previous].strip()
if not metadata.startswith(IDENTITY_PREFIX):
return None
return None, 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
return None, None
if identity not in ALLOWED_IDENTITIES:
return None, previous
return identity, previous
def _identities(body: str) -> tuple[str, ...]:
@@ -341,7 +427,7 @@ def _identities(body: str) -> tuple[str, ...]:
if not FENCE_RE.match(line):
continue
if not in_fence:
identity = _associated_identity(lines, index)
identity, _ = _associated_identity(lines, index)
if identity is not None:
identities.append(identity)
in_fence = not in_fence
@@ -359,8 +445,10 @@ def _section_tags(body: str) -> tuple[str, ...]:
def load_sections(references_dir: Path = DEFAULT_REFERENCES_DIR) -> list[Section]:
references_dir = Path(references_dir)
sections: list[Section] = []
for page in _reference_pages(Path(references_dir)):
seen_ids: set[str] = set()
for page in _require_reference_pages(references_dir):
text = page.read_text(encoding="utf-8")
lines = text.splitlines(keepends=True)
page_title, headings = _heading_records(lines)
@@ -387,10 +475,14 @@ def load_sections(references_dir: Path = DEFAULT_REFERENCES_DIR) -> list[Section
else len(lines)
)
local_body = "".join(lines[start:local_end])
base_id = section_id(page.relative_to(references_dir), heading_path)
base_id, _ = _heading_section_id(lines, start)
if base_id in seen_ids:
raise ReferenceStructureError(f"重复 section ID{base_id}")
seen_ids.add(base_id)
tags = _section_tags(local_body)
searchable_body = STRUCTURAL_METADATA_RE.sub(" ", local_body)
searchable_text = normalize(
"\n".join((page.stem, page_title, *heading_path, *tags, local_body))
"\n".join((page.stem, page_title, *heading_path, *tags, searchable_body))
)
sections.append(
Section(
@@ -417,13 +509,14 @@ def _identity_problems(page: Path, lines: list[str]) -> list[ValidationProblem]:
if identity not in ALLOWED_IDENTITIES:
problems.append(ValidationProblem(page, index, f"未知身份:{identity}"))
in_fence = False
associated_identity_lines: set[int] = set()
for index, line in enumerate(lines):
fence = FENCE_RE.match(line)
if fence:
if in_fence:
in_fence = False
else:
identity = _associated_identity(lines, index)
identity, identity_line = _associated_identity(lines, index)
if identity is None:
problems.append(
ValidationProblem(
@@ -431,6 +524,8 @@ def _identity_problems(page: Path, lines: list[str]) -> list[ValidationProblem]:
)
)
else:
if identity_line is not None:
associated_identity_lines.add(identity_line)
expected = IDENTITY_FENCE_LANGUAGES.get(identity)
language = (fence.group(1) or "").strip()
if expected is not None and language != expected:
@@ -452,6 +547,15 @@ def _identity_problems(page: Path, lines: list[str]) -> list[ValidationProblem]:
)
if in_fence:
problems.append(ValidationProblem(page, len(lines), "代码围栏未闭合"))
for index, line in enumerate(lines):
stripped = line.strip()
if not stripped.startswith(IDENTITY_PREFIX):
continue
identity = stripped[len(IDENTITY_PREFIX) :].strip()
if identity in ALLOWED_IDENTITIES and index not in associated_identity_lines:
problems.append(
ValidationProblem(page, index + 1, "孤立的代码块身份:后面没有关联代码围栏")
)
return problems
@@ -529,28 +633,261 @@ def _tag_problems(sections: list[Section]) -> list[ValidationProblem]:
return problems
def _section_id_problems(
page: Path, lines: list[str]
) -> list[ValidationProblem]:
problems: list[ValidationProblem] = []
_, headings = _heading_records([f"{line}\n" for line in lines])
claimed_metadata_lines: set[int] = set()
for heading_line, _, title in headings:
metadata_line = _next_nonblank_line(lines, heading_line + 1)
if metadata_line is None:
problems.append(
ValidationProblem(
page,
heading_line + 1,
f"标题缺少显式 section ID{title}",
)
)
continue
metadata = lines[metadata_line].strip()
if not SECTION_ID_PREFIX_RE.match(metadata):
problems.append(
ValidationProblem(
page,
heading_line + 1,
f"标题缺少显式 section ID{title}",
)
)
continue
claimed_metadata_lines.add(metadata_line)
if SECTION_ID_RE.fullmatch(metadata) is None:
problems.append(
ValidationProblem(
page,
metadata_line + 1,
"显式 section ID 格式无效;只允许 ASCII 字母、数字、点、下划线和连字符",
)
)
in_fence = False
for index, line in enumerate(lines):
if FENCE_RE.match(line):
in_fence = not in_fence
continue
if in_fence:
continue
if SECTION_ID_PREFIX_RE.match(line.strip()) and index not in claimed_metadata_lines:
problems.append(
ValidationProblem(page, index + 1, "孤立的 section ID:前面没有可索引标题")
)
return problems
def _duplicate_section_id_problems(
pages: dict[Path, list[str]],
) -> list[ValidationProblem]:
problems: list[ValidationProblem] = []
seen: dict[str, tuple[Path, int]] = {}
for page, lines in pages.items():
in_fence = False
for index, line in enumerate(lines):
if FENCE_RE.match(line):
in_fence = not in_fence
continue
if in_fence:
continue
match = SECTION_ID_RE.fullmatch(line.strip())
if match is None:
continue
section_id_value = match.group(1)
if section_id_value in seen:
first_page, first_line = seen[section_id_value]
problems.append(
ValidationProblem(
page,
index + 1,
f"重复 section ID{section_id_value};首次位于 "
f"{first_page}:{first_line}",
)
)
else:
seen[section_id_value] = (page, index + 1)
return problems
def _quickstart_rule_records(
page: Path, lines: list[str]
) -> tuple[list[QuickstartRule], list[ValidationProblem]]:
records: list[QuickstartRule] = []
problems: list[ValidationProblem] = []
in_fence = False
for index, line in enumerate(lines):
if FENCE_RE.match(line):
in_fence = not in_fence
continue
if in_fence:
continue
stripped = line.strip()
if not QUICKSTART_RULE_PREFIX_RE.match(stripped):
continue
marker = QUICKSTART_RULE_RE.fullmatch(stripped)
if marker is None:
problems.append(
ValidationProblem(page, index + 1, "quickstart-rule 标记格式无效")
)
continue
bullet_line = _next_nonblank_line(lines, index + 1)
if bullet_line is None or not lines[bullet_line].startswith("- "):
problems.append(
ValidationProblem(
page,
index + 1,
"quickstart-rule 标记后必须紧跟一个顶格列表项",
)
)
continue
text_parts = [lines[bullet_line].strip()]
continuation = bullet_line + 1
while continuation < len(lines):
line = lines[continuation]
if not line.strip():
break
if not line.startswith((" ", "\t")):
break
text_parts.append(line.strip())
continuation += 1
records.append(
QuickstartRule(
page=page,
line=index + 1,
bullet_line=bullet_line + 1,
key=marker.group(1),
text=" ".join(text_parts),
)
)
return records, problems
def _quickstart_rule_problems(
pages: dict[Path, list[str]],
require_complete: bool = False,
) -> list[ValidationProblem]:
quickstart = next(
(page for page in pages if page.name == QUICKSTART_PAGE),
None,
)
if quickstart is None:
return []
problems: list[ValidationProblem] = []
records: list[QuickstartRule] = []
for page, lines in pages.items():
page_records, page_problems = _quickstart_rule_records(page, lines)
records.extend(page_records)
problems.extend(page_problems)
quick_lines = pages[quickstart]
_, headings = _heading_records([f"{line}\n" for line in quick_lines])
summary = next(
(
(position, start, level)
for position, (start, level, title) in enumerate(headings)
if level == 2 and title == QUICKSTART_SUMMARY_HEADING
),
None,
)
if summary is None:
problems.append(
ValidationProblem(
quickstart, 1, f"quickstart 缺少「{QUICKSTART_SUMMARY_HEADING}」派生摘要"
)
)
return problems
position, start, level = summary
end = len(quick_lines)
for next_start, next_level, _ in headings[position + 1 :]:
if next_level <= level:
end = next_start
break
marked_bullets = {
record.bullet_line - 1 for record in records if record.page == quickstart
}
for index in range(start + 1, end):
if quick_lines[index].startswith("- ") and index not in marked_bullets:
problems.append(
ValidationProblem(
quickstart,
index + 1,
"quickstart 派生摘要列表项缺少 quickstart-rule 标记",
)
)
by_key: dict[str, list[QuickstartRule]] = {}
for record in records:
by_key.setdefault(record.key, []).append(record)
expected_keys = set(QUICKSTART_RULE_OWNER_PAGES) if require_complete else set()
for key in sorted(set(by_key) | expected_keys):
key_records = by_key.get(key, [])
summaries = [record for record in key_records if record.page == quickstart]
canonicals = [record for record in key_records if record.page != quickstart]
if len(summaries) != 1 or len(canonicals) != 1:
problems.append(
ValidationProblem(
quickstart,
1,
f"quickstart-rule {key!r} 必须恰好有一条派生摘要和一条专题事实",
)
)
continue
expected_page = QUICKSTART_RULE_OWNER_PAGES.get(key)
if expected_page is not None and canonicals[0].page.name != expected_page:
problems.append(
ValidationProblem(
canonicals[0].page,
canonicals[0].line,
f"quickstart-rule {key!r} 的专题 owner 应为 {expected_page}",
)
)
if summaries[0].text != canonicals[0].text:
problems.append(
ValidationProblem(
quickstart,
summaries[0].bullet_line,
f"派生摘要规则与专题事实不一致:{key}",
)
)
if not any(record.page == quickstart for record in records):
problems.append(
ValidationProblem(quickstart, 1, "quickstart 派生摘要没有可校验规则")
)
return problems
def validate_references(
references_dir: Path = DEFAULT_REFERENCES_DIR,
) -> list[ValidationProblem]:
references_dir = Path(references_dir)
problems: list[ValidationProblem] = []
# 逐页校验在零页时全部静默通过;参考页缺失属于安装/路径错误,必须报错,
# 否则 --check 会为一个空目录返回成功。
if not _reference_pages(references_dir):
problems.append(
ValidationProblem(
references_dir, 1, "references 中没有可校验的参考页;检查路径或重新安装 skill"
)
)
installation_problem = _reference_installation_problem(references_dir)
if installation_problem is not None:
problems.append(installation_problem)
return problems
index_page = references_dir / "index.md"
if index_page.exists():
problems.append(ValidationProblem(index_page, 1, "references 中不得保留 index.md"))
pages: dict[Path, list[str]] = {}
invalid_section_ids = False
for page in sorted(references_dir.glob("*.md"), key=lambda item: item.name):
text = page.read_text(encoding="utf-8")
lines = text.splitlines()
pages[page] = lines
problems.extend(_identity_problems(page, lines))
problems.extend(_local_link_problems(page, text, references_dir))
section_id_problems = _section_id_problems(page, lines)
problems.extend(section_id_problems)
invalid_section_ids = invalid_section_ids or bool(section_id_problems)
page_title, headings = _heading_records(text.splitlines(keepends=True))
h1_count = 0
in_fence = False
@@ -569,6 +906,15 @@ def validate_references(
h1_count += 1
previous_level = 1
continue
if level in (5, 6):
problems.append(
ValidationProblem(
page,
index + 1,
"H5/H6 标题不会被索引;参考页只允许使用 H2/H3/H4",
)
)
continue
if level not in (2, 3, 4):
continue
if level > previous_level + 1:
@@ -587,7 +933,12 @@ def validate_references(
if next_level <= level:
end = next_start
break
body = "\n".join(lines[start + 1 : end]).strip()
body = re.sub(
r"<!--.*?-->",
" ",
"\n".join(lines[start + 1 : end]),
flags=re.DOTALL,
).strip()
duty_sections.append(body)
if len(duty_sections) != 1 or not duty_sections[0]:
problems.append(
@@ -601,6 +952,21 @@ def validate_references(
problems.append(
ValidationProblem(page, index, f"包含人工路由协议:{phrase}")
)
duplicate_id_problems = _duplicate_section_id_problems(pages)
problems.extend(duplicate_id_problems)
invalid_section_ids = invalid_section_ids or bool(duplicate_id_problems)
problems.extend(
_quickstart_rule_problems(
pages,
require_complete=(
references_dir.resolve() == DEFAULT_REFERENCES_DIR.resolve()
),
)
)
# load_sections intentionally refuses headings without explicit IDs. Keep
# --check diagnostic by returning the collected structural problems first.
if invalid_section_ids:
return problems
sections = load_sections(references_dir)
problems.extend(_tag_problems(sections))
ids: dict[str, Section] = {}
@@ -608,22 +974,22 @@ def validate_references(
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}",
if references_dir.resolve() == DEFAULT_REFERENCES_DIR.resolve():
for section_id_value, page_name, heading in WRITE_PRELUDE_SECTIONS:
section = ids.get(section_id_value)
if (
section is None
or section.page.name != page_name
or section.heading_path != (heading,)
):
problems.append(
ValidationProblem(
references_dir,
1,
f"write 模式前置章节错误:{section_id_value} 必须指向 "
f"{page_name} 的「{heading}",
)
)
)
# 概念地图逐页从「本篇职责」段生成;有该段的页必须产出非空摘要,
# 否则某页职责段被清空/写坏时地图会静默缺页。
mapped_pages = {page_name for page_name, _, _ in build_concept_map(references_dir)}
@@ -671,8 +1037,17 @@ def validate_references(
return problems
def _base_query_tokens(text: str) -> set[str]:
def _normalized_retrieval_text(text: str) -> str:
normalized = normalize(text)
for filler in CHINESE_QUERY_FILLERS:
normalized = normalized.replace(filler, "")
for particle in CHINESE_QUERY_PARTICLES:
normalized = normalized.replace(particle, "")
return normalized
def _base_query_tokens(text: str) -> set[str]:
normalized = _normalized_retrieval_text(text)
tokens = set(ASCII_TOKEN_RE.findall(normalized))
for run in CHINESE_RUN_RE.findall(normalized):
tokens.add(run)
@@ -698,9 +1073,13 @@ def _query_contains_phrase(query: str, phrase: str) -> bool:
# 含中文的短语按去空白后的串比较。SKILL.md 要求智能体传「术语」而不是
# 用户原话,术语常以空格分隔(「数组 下标 起点」),逐字子串匹配会
# 整条落空;去空白后 phrase 仍要求连续出现,不放宽词序。
return _WHITESPACE_RE.sub("", normalized_phrase) in _WHITESPACE_RE.sub(
"", normalize(query)
normalized_alias = _WHITESPACE_RE.sub(
"", _normalized_retrieval_text(phrase)
)
normalized_query = _WHITESPACE_RE.sub(
"", _normalized_retrieval_text(query)
)
return normalized_alias in normalized_query
phrase_tokens = _ascii_token_sequence(phrase)
query_tokens_in_order = _ascii_token_sequence(query)
if not phrase_tokens:
@@ -749,7 +1128,7 @@ def _intent_score(section: Section, query: str) -> int:
def _has_chinese_context(section: Section, query: str) -> bool:
runs = CHINESE_RUN_RE.findall(normalize(query))
runs = CHINESE_RUN_RE.findall(_normalized_retrieval_text(query))
for run in runs:
tokens = (
[run]
@@ -790,7 +1169,8 @@ def _score_section(section: Section, query: str, mode: str) -> ScoreBreakdown:
tokens = query_tokens(query)
heading_text = normalize("\n".join(section.heading_path))
page_title_text = normalize(section.page_title)
body_text = normalize(SECTION_TAG_RE.sub(" ", section.local_body))
body_without_metadata = STRUCTURAL_METADATA_RE.sub(" ", section.local_body)
body_text = normalize(SECTION_TAG_RE.sub(" ", body_without_metadata))
tag_text = normalize("\n".join(section.tags))
term_text = _code_text(section.local_body)
expanded_only_tokens = _synonym_tokens(query) - _base_query_tokens(query)
@@ -867,19 +1247,12 @@ def _score_reasons(score: ScoreBreakdown) -> tuple[str, ...]:
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
by_id = {section.id: section for section in sections}
return [
by_id[section_id]
for section_id, _, _ in WRITE_PRELUDE_SECTIONS
if section_id in by_id
]
STRING_LITERAL_RE = re.compile(r'"[^"\n]*"|\'[^\'\n]*\'')
@@ -1028,7 +1401,8 @@ def _safe_json_string(value: str) -> str:
def _plain_text_summary(body: str, limit: int = 180) -> str:
# 标签是检索元数据,不是事实正文;不能泄进候选摘要。
without_tags = SECTION_TAG_RE.sub(" ", body)
without_metadata = STRUCTURAL_METADATA_RE.sub(" ", body)
without_tags = SECTION_TAG_RE.sub(" ", without_metadata)
without_fences = FENCED_CODE_RE.sub(" ", without_tags)
without_links = re.sub(
r"!?\[([^\]]*)\]\([^)]+\)", lambda match: match.group(1), without_fences
@@ -1090,13 +1464,15 @@ def render_candidates(result: QueryResult) -> str:
def render_section(section: Section) -> str:
body = STRUCTURAL_METADATA_RE.sub("", section.body)
body = re.sub(r"\n{3,}", "\n\n", body).rstrip()
lines = [
"# TSL Syntax Section",
"",
f"Section ID: `{section.id}`",
f"Source: `{_logical_source(section)}`",
"",
section.body.rstrip(),
body,
]
return "\n".join(lines).rstrip() + "\n"
@@ -1138,15 +1514,22 @@ def _configure_utf8() -> None:
reconfigure(encoding="utf-8")
def _reference_error_message(
error: ReferenceInstallationError | ReferenceStructureError,
) -> str:
if isinstance(error, ReferenceInstallationError):
return str(error)
return f"参考资料结构不可用:{error}"
HELP_EPILOG = """\
检索分两步,缺一步都不算取回事实:
1. 先取候选(只有摘要和 Section ID,不含事实正文)
lookup.py --query "命名参数 默认参数" --mode write
lookup.py --query "命名参数" --mode write
2. 再按候选里的 Section ID 取回正文;多个要素各自跑完第 1 步后,
选定的 Section ID 可以合并成一次取回
lookup.py --section "05_functions_and_calls--可直接照写示例--基础函数-过程骨架" \\
"02_core_model--文件模型核心规则"
lookup.py --section "syntax-05-004" "syntax-02-002"
查询词无从下手时先 --map 把需求映射到 TSL 概念;改动参考页或 data/ 词表后用 --check 校验。
弱命中(候选标 Weak: yes)没有意图/标题/标识符/标签命中,只靠正文低分撞词;
@@ -1175,7 +1558,7 @@ def _parser() -> argparse.ArgumentParser:
)
action.add_argument(
"--query",
help="术语、报错原文或语法要素名检索候选章节(不要传用户原话);需配合 "
help="从用户原话提取术语、报错原文或单一语法要素名,保留原写法但不传完整用户句;需配合 "
"--mode。候选标 `Weak: yes` 表示只有正文低分撞词,全部候选皆弱时退出码为 2",
)
action.add_argument(
@@ -1194,8 +1577,8 @@ def _parser() -> argparse.ArgumentParser:
action.add_argument(
"--check",
action="store_true",
help="校验参考页结构、代码块身份、本地链接,以及 data/lexicon.json 的页级"
"意图短语与参考页是否一一对应;发现问题时退出码为 1",
help="校验参考页结构、显式 Section ID、代码块身份、本地链接、quickstart "
"派生规则和词表页覆盖;检索排序由测试套件校验。发现问题时退出码为 1",
)
parser.add_argument(
"--mode",
@@ -1245,8 +1628,21 @@ def main(argv: list[str] | None = None) -> int:
parser.error("--query 必须同时指定 --mode")
if args.mode is not None and args.query is None:
parser.error("--mode 仅用于 --query")
installation_problem = _reference_installation_problem(args.references_dir)
if installation_problem is not None:
print(
f"{installation_problem.page}:{installation_problem.line}: "
f"{installation_problem.message}",
file=sys.stderr,
)
return 1
if args.show_map:
print(render_concept_map(build_concept_map(args.references_dir)), end="")
try:
entries = build_concept_map(args.references_dir)
except (ReferenceInstallationError, ReferenceStructureError) as error:
print(_reference_error_message(error), file=sys.stderr)
return 1
print(render_concept_map(entries), end="")
return 0
if args.check:
problems = validate_references(args.references_dir)
@@ -1254,7 +1650,11 @@ def main(argv: list[str] | None = None) -> int:
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)
try:
sections = load_sections(args.references_dir)
except (ReferenceInstallationError, ReferenceStructureError) as error:
print(_reference_error_message(error), file=sys.stderr)
return 1
by_id = {item.id: item for item in sections}
requested = list(dict.fromkeys(args.section))
missing = [item for item in requested if item not in by_id]
@@ -1272,7 +1672,11 @@ def main(argv: list[str] | None = None) -> int:
end="",
)
return 0
result = query_sections(args.query, args.mode, args.limit, args.references_dir)
try:
result = query_sections(args.query, args.mode, args.limit, args.references_dir)
except (ReferenceInstallationError, ReferenceStructureError) as error:
print(_reference_error_message(error), file=sys.stderr)
return 1
print(render_candidates(result), end="")
if not result.matches:
print("no matching sections", file=sys.stderr)