🐛 fix(tsl-syntax): close lookup review gaps

This commit is contained in:
csh
2026-07-13 09:17:00 +08:00
parent 455661c936
commit d663c2dae5
2 changed files with 299 additions and 66 deletions
+149 -65
View File
@@ -46,7 +46,7 @@ PAGE_TITLE_EXACT_SCORE = 8
BODY_TOKEN_SCORE = 3
BODY_EXACT_SCORE = 4
DIRECT_EXAMPLE_BOOST = 8
PITFALL_PAGE_BOOST = 18
PITFALL_PAGE_BOOST = 30
COUNTEREXAMPLE_BOOST = 8
EXACT_ERROR_BOOST = 14
MIXED_QUERY_MIN_SCORE = 10
@@ -67,22 +67,36 @@ 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": ("最简单能跑", "天软脚本"),
"01_quickstart.md": ("最简单能跑", "天软脚本", "tinysoft"),
"02_core_model.md": ("脚本和可复用", "可复用函数文件"),
"03_values_and_literals.md": ("字符串和数组下标",),
"04_variables_and_constants.md": ("常量怎么声明", "变量能不能直接赋值"),
@@ -95,8 +109,8 @@ PAGE_INTENT_ALIASES = {
"11_pitfalls.md": ("声明函数后面写代码", "语法报错"),
"12_matrix_and_collections.md": ("某行存在", "二维数组怎么判断"),
"13_resultset_and_filters.md": ("保留匹配行", "按某一列"),
"14_ts_sql.md": ("左连接", "左联接", "数据库", "分组排序"),
"15_debug_and_profiler.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", "开线程"),
@@ -116,6 +130,7 @@ class Section:
page_title: str
heading_path: tuple[str, ...]
body: str
local_body: str
identities: tuple[str, ...]
searchable_text: str
@@ -132,6 +147,7 @@ class QueryMatch:
section: Section
score: int
priority: tuple[int, ...] = ()
reasons: tuple[str, ...] = ()
@dataclass(frozen=True)
@@ -142,6 +158,7 @@ class ScoreBreakdown:
page_title: int
body: int
mode_boost: int
synonym_hits: int
diagnose_priority: bool = False
@property
@@ -185,6 +202,7 @@ class QueryResult:
mode: str
matches: list[QueryMatch]
prelude: list[Section]
limit: int
def normalize(value: str) -> str:
@@ -304,9 +322,15 @@ def load_sections(references_dir: Path = DEFAULT_REFERENCES_DIR) -> list[Section
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, body))
"\n".join((page.stem, page_title, *heading_path, local_body))
)
sections.append(
Section(
@@ -315,7 +339,8 @@ def load_sections(references_dir: Path = DEFAULT_REFERENCES_DIR) -> list[Section
page_title=page_title,
heading_path=heading_path,
body=body,
identities=_identities(body),
local_body=local_body,
identities=_identities(local_body),
searchable_text=searchable_text,
)
)
@@ -513,17 +538,43 @@ def _base_query_tokens(text: str) -> set[str]:
}
def query_tokens(text: str) -> set[str]:
tokens = _base_query_tokens(text)
normalized = normalize(text)
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 normalize(phrase) not in normalized:
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):
@@ -532,11 +583,17 @@ def _text_contains_token(text: str, token: str) -> bool:
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:
normalized_query = normalize(query)
aliases = PAGE_INTENT_ALIASES.get(section.page.name, ())
return PAGE_INTENT_SCORE * sum(
normalize(alias) in normalized_query for alias in aliases
_query_contains_phrase(query, alias) for alias in aliases
)
@@ -565,8 +622,16 @@ 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.body)
term_text = _code_text(section.body)
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
@@ -580,13 +645,13 @@ def _score_section(section: Section, query: str, mode: str) -> ScoreBreakdown:
page_title_score += PAGE_TITLE_TOKEN_SCORE
if _text_contains_token(body_text, token):
body_score += BODY_TOKEN_SCORE
if normalized_query and normalized_query in heading_text:
if _text_contains_exact_query(heading_text, query):
heading_score += HEADING_EXACT_SCORE
if normalized_query and normalized_query in term_text:
if _text_contains_exact_query(term_text, query):
term_score += TERM_EXACT_SCORE
if normalized_query and normalized_query in page_title_text:
if _text_contains_exact_query(page_title_text, query):
page_title_score += PAGE_TITLE_EXACT_SCORE
if normalized_query and normalized_query in body_text:
if _text_contains_exact_query(body_text, query):
body_score += BODY_EXACT_SCORE
mode_boost = 0
if mode == "write" and "可直接照写示例" in section.identities:
@@ -605,10 +670,26 @@ def _score_section(section: Section, query: str, mode: str) -> ScoreBreakdown:
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:
@@ -647,7 +728,10 @@ def _ascii_anchor(section: Section, ascii_tokens: set[str]) -> bool:
ASCII_TOKEN_RE.findall(normalize("\n".join(section.heading_path)))
)
code_text = "\n".join(
(*INLINE_CODE_RE.findall(section.body), *FENCED_CODE_RE.findall(section.body))
(
*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)))
@@ -674,8 +758,18 @@ def query_sections(
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 _base_query_tokens(query) if ASCII_TOKEN_RE.fullmatch(token)
token
for token in raw_ascii_tokens
if any(_ascii_anchor(section, {token}) for section in all_sections)
}
if ascii_tokens:
sections = [
@@ -702,9 +796,17 @@ def query_sections(
score = _score_section(section, query, mode)
if score.lexical_total < minimum_score:
continue
ranked.append(QueryMatch(section, score.total, score.priority))
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,
@@ -715,6 +817,8 @@ def query_sections(
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
):
@@ -725,16 +829,26 @@ def query_sections(
page_counts[match.section.page] = page_counts.get(match.section.page, 0) + 1
if len(matches) == limit:
break
prelude = _write_prelude(all_sections) if mode == "write" else []
match_ids = {match.section.id for match in matches}
prelude = [section for section in prelude if section.id not in match_ids]
return QueryResult(query=query, mode=mode, matches=matches, prelude=prelude)
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(
@@ -758,14 +872,16 @@ def render_candidates(result: QueryResult) -> str:
"# TSL Syntax Candidates",
"",
f"Mode: `{result.mode}`",
f"Query: {json.dumps(result.query, ensure_ascii=False)}",
f"Query: {_safe_json_string(result.query)}",
]
candidates = [
(section, 0, True, (0, 0, 0, 0, 0)) for section in result.prelude
(section, 0, True, ("required=1",)) for section in result.prelude
] + [
(match.section, match.score, False, match.priority) for match in result.matches
(match.section, match.score, False, match.reasons) for match in result.matches
]
for index, (section, score, required, priority) in enumerate(candidates, start=1):
for index, (section, score, required, reasons) in enumerate(
candidates[: result.limit], start=1
):
lines.extend(
[
"",
@@ -776,8 +892,8 @@ def render_candidates(result: QueryResult) -> str:
f"Section ID: `{section.id}`",
f"Source: `{_logical_source(section)}`",
f"Heading: `{' > '.join(section.heading_path)}`",
f"Why: `rank={','.join(str(value) for value in priority)}`",
f"Summary: {_plain_text_summary(section.body)}",
f"Why: `{', '.join(reasons) or 'lexical=1'}`",
f"Summary: {_plain_text_summary(section.local_body)}",
]
)
return "\n".join(lines).rstrip() + "\n"
@@ -795,39 +911,6 @@ def render_section(section: Section) -> str:
return "\n".join(lines).rstrip() + "\n"
def _duty_summary(body: str) -> str:
lines = body.splitlines()
paragraph: list[str] = []
for line in lines[1:]:
stripped = line.strip()
if not stripped:
if paragraph:
break
continue
paragraph.append(stripped)
summary = " ".join(paragraph)
# 职责段以冒号引出列表时(如 09),首段本身空洞,把随后的
# 列表项折叠进摘要才能保留实际覆盖面。
if summary.endswith(("", ":")):
items: list[str] = []
seen_paragraph = False
for line in lines[1:]:
stripped = line.strip()
if not stripped:
continue
if not seen_paragraph:
if stripped == summary or stripped in summary:
seen_paragraph = True
continue
if stripped.startswith(("-", "*", "·")):
items.append(stripped.lstrip("-*· ").strip())
elif items:
break
if items:
summary = summary + "".join(items) + ""
return summary
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()
@@ -837,7 +920,7 @@ def build_concept_map(references_dir: Path = DEFAULT_REFERENCES_DIR) -> list[tup
continue
if section.heading_path != (DUTY_HEADING,):
continue
summary = _plain_text_summary(section.body, limit=600)
summary = _plain_text_summary(section.local_body, limit=600)
if not summary:
continue
seen_pages.add(page_name)
@@ -926,6 +1009,7 @@ def main(argv: list[str] | None = None) -> int:
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="")