✨ 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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user