🐛 fix(tsl-syntax): close lookup review gaps
This commit is contained in:
@@ -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="")
|
||||
|
||||
Reference in New Issue
Block a user