返回 ppt-master
chart_recall.py
根目录 / skills / ppt-master / scripts / chart_recall.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Chart Candidate Recall
4
5 Recalls a deterministic chart-template shortlist from page-shape semantic tags,
6 or validates selected chart keys against the live chart catalog.
7
8 Usage:
9 python3 scripts/chart_recall.py recall --page P03 --tag "time series" --tag "three metrics" --tag "trend"
10 python3 scripts/chart_recall.py validate line_chart column_chart
11
12 Examples:
13 python3 scripts/chart_recall.py recall --page P07 --tag "named quadrants" \
14 --tag "bullet lists" --tag "SWOT" --limit 6
15 python3 scripts/chart_recall.py validate quadrant_text_bullets
16
17 Dependencies:
18 None (only uses standard library)
19
20 See scripts/docs/chart-recall.md for the Strategist workflow and output contract.
21 """
22
23 from __future__ import annotations
24
25 import argparse
26 import json
27 import re
28 import sys
29 import unicodedata
30 from pathlib import Path
31 from typing import Optional
32
33 from console_encoding import configure_utf8_stdio
34
35 configure_utf8_stdio()
36
37 _SCRIPTS_DIR = Path(__file__).resolve().parent
38 _INDEX_PATH = _SCRIPTS_DIR.parent / "templates" / "charts" / "charts_index.json"
39 _TOKEN_RE = re.compile(r"[a-z0-9]+")
40 _PAGE_RE = re.compile(r"^P\d{2,}$")
41 _KEY_RE = re.compile(r"^[a-z0-9]+(?:_[a-z0-9]+)*$")
42 _SKIP_RE = re.compile(r"\bskip\s+(?:if|for)\b", re.IGNORECASE)
43 _STOP_WORDS = {
44 "a",
45 "an",
46 "and",
47 "as",
48 "at",
49 "by",
50 "for",
51 "from",
52 "if",
53 "in",
54 "into",
55 "of",
56 "on",
57 "or",
58 "per",
59 "the",
60 "to",
61 "use",
62 "with",
63 }
64
65
66 def _normalize(text: str) -> str:
67 normalized = unicodedata.normalize("NFKC", text).casefold().replace("_", " ")
68 return " ".join(_TOKEN_RE.findall(normalized))
69
70
71 def _stem(token: str) -> str:
72 if len(token) > 5 and token.endswith("ies"):
73 return token[:-3] + "y"
74 if len(token) > 5 and token.endswith("ing"):
75 return token[:-3]
76 if len(token) > 4 and token.endswith("ed"):
77 return token[:-2]
78 if len(token) > 4 and token.endswith("es"):
79 return token[:-2]
80 if len(token) > 3 and token.endswith("s"):
81 return token[:-1]
82 return token
83
84
85 def _tokens(text: str) -> set[str]:
86 return {
87 _stem(token)
88 for token in _TOKEN_RE.findall(_normalize(text))
89 if token not in _STOP_WORDS
90 }
91
92
93 def load_catalog() -> dict[str, str]:
94 """Load and validate the live chart catalog."""
95 try:
96 payload = json.loads(_INDEX_PATH.read_text(encoding="utf-8"))
97 except (OSError, json.JSONDecodeError) as exc:
98 raise RuntimeError(f"Cannot read chart catalog {_INDEX_PATH}: {exc}") from exc
99
100 if not isinstance(payload, dict):
101 raise RuntimeError(f"Chart catalog {_INDEX_PATH} root must be an object")
102 raw_charts = payload.get("charts")
103 if not isinstance(raw_charts, dict) or not raw_charts:
104 raise RuntimeError(f"Chart catalog {_INDEX_PATH} has no non-empty 'charts' object")
105
106 charts: dict[str, str] = {}
107 for key, item in raw_charts.items():
108 if (
109 not isinstance(key, str)
110 or _KEY_RE.fullmatch(key) is None
111 or not isinstance(item, dict)
112 ):
113 raise RuntimeError(f"Chart catalog entry {key!r} is malformed")
114 summary = item.get("summary")
115 if not isinstance(summary, str) or not summary.strip():
116 raise RuntimeError(f"Chart catalog entry {key!r} has no non-empty summary")
117 charts[key] = summary.strip()
118 return charts
119
120
121 def _score_candidate(key: str, summary: str, tags: list[str]) -> tuple[int, list[str]]:
122 skip_match = _SKIP_RE.search(summary)
123 if skip_match is None:
124 pick_clause, skip_clause = summary, ""
125 else:
126 pick_clause = summary[:skip_match.start()]
127 skip_clause = summary[skip_match.start():]
128 key_text = _normalize(key)
129 pick_text = _normalize(pick_clause)
130 skip_text = _normalize(skip_clause)
131 key_tokens = _tokens(key)
132 pick_tokens = _tokens(pick_clause)
133 skip_tokens = _tokens(skip_clause)
134 score = 0
135 matched_tags: list[str] = []
136
137 for tag in tags:
138 tag_text = _normalize(tag)
139 tag_tokens = _tokens(tag)
140 positive_score = 0
141 negative_score = 0
142 if tag_text and tag_text in key_text:
143 positive_score += 20
144 elif tag_text and tag_text in pick_text:
145 positive_score += 14
146 if tag_text and tag_text in skip_text:
147 negative_score += 12
148
149 for token in tag_tokens:
150 if token in key_tokens:
151 positive_score += 9
152 elif token in pick_tokens:
153 positive_score += 5
154 if token in skip_tokens:
155 negative_score += 6
156
157 if positive_score:
158 matched_tags.append(tag)
159 score += positive_score - negative_score
160
161 return score, matched_tags
162
163
164 def recall_candidates(
165 page: str,
166 tags: list[str],
167 limit: int,
168 *,
169 force_semantic_fallback: bool = False,
170 ) -> dict[str, object]:
171 """Recall a deterministic shortlist for one page."""
172 charts = load_catalog()
173 scored: list[tuple[int, str, str, list[str]]] = []
174 for key, summary in charts.items():
175 score, matched_tags = _score_candidate(key, summary, tags)
176 if score > 0 and matched_tags:
177 scored.append((score, key, summary, matched_tags))
178 scored.sort(key=lambda item: (-item[0], item[1]))
179
180 candidates = []
181 for score, key, summary, matched_tags in scored[:limit]:
182 candidates.append(
183 {
184 "key": key,
185 "path": f"templates/charts/{key}.svg",
186 "summary": summary,
187 "score": score,
188 "matched_tags": matched_tags,
189 }
190 )
191
192 top_score = candidates[0]["score"] if candidates else 0
193 if top_score >= 35:
194 confidence = "high"
195 elif top_score >= 15:
196 confidence = "medium"
197 elif top_score > 0:
198 confidence = "low"
199 else:
200 confidence = "none"
201
202 fallback_required_before_no_match = (
203 confidence in {"low", "none"} and not force_semantic_fallback
204 )
205 if fallback_required_before_no_match:
206 no_match_instruction = (
207 f"Lexical confidence is {confidence}. Select a bounded candidate when one "
208 "fits; otherwise rerun the same recall with --semantic-fallback before "
209 "keeping no-template-match. Keep the final negative result out of Design "
210 "Spec Section VII and describe the chosen fallback in the page's Section "
211 "IX block."
212 )
213 else:
214 no_match_instruction = (
215 "Use when none of the reviewed candidates fits the page structure. Keep "
216 "this result out of Design Spec Section VII and describe the chosen fallback "
217 "in the page's Section IX block."
218 )
219
220 result: dict[str, object] = {
221 "page": page,
222 "semantic_tags": tags,
223 "confidence": confidence,
224 "candidates": candidates,
225 "no_template_match": {
226 "allowed": not fallback_required_before_no_match,
227 "key": "no-template-match",
228 "instruction": no_match_instruction,
229 },
230 }
231 if force_semantic_fallback:
232 result["semantic_fallback"] = {
233 "reason": "requested-after-bounded-review",
234 "instruction": (
235 "Semantically compare the page tags with every returned selection rule. "
236 "Choose one exact catalog key or keep no-template-match; lexical overlap "
237 "is not required in this review."
238 ),
239 "path_pattern": "templates/charts/{key}.svg",
240 "catalog": charts,
241 }
242 return result
243
244
245 def _dedupe(values: list[str]) -> list[str]:
246 result: list[str] = []
247 seen: set[str] = set()
248 for value in values:
249 stripped = value.strip()
250 normalized = _normalize(stripped)
251 if not stripped or not normalized or normalized in seen:
252 continue
253 seen.add(normalized)
254 result.append(stripped)
255 return result
256
257
258 def _run_recall(args: argparse.Namespace) -> int:
259 page = args.page.upper()
260 if not _PAGE_RE.fullmatch(page):
261 print("Error: --page must match P<NN>, for example P03.", file=sys.stderr)
262 return 2
263
264 tags = _dedupe(args.tag)
265 if not 3 <= len(tags) <= 8:
266 print("Error: recall requires 3-8 distinct non-empty --tag values.", file=sys.stderr)
267 return 2
268
269 result = recall_candidates(
270 page,
271 tags,
272 args.limit,
273 force_semantic_fallback=args.semantic_fallback,
274 )
275 print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
276 return 0
277
278
279 def _run_validate(args: argparse.Namespace) -> int:
280 charts = load_catalog()
281 selected = _dedupe(args.keys)
282 invalid = sorted(key for key in selected if key not in charts)
283 result = {
284 "invalid": invalid,
285 "valid": sorted(key for key in selected if key in charts),
286 }
287 print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
288 if invalid:
289 print(
290 "Error: replace each invalid key with a key returned by the recall command, "
291 "or keep no-template-match out of Section VII and page_charts while "
292 "recording the custom fallback in the page's Section IX block.",
293 file=sys.stderr,
294 )
295 return 1
296 return 0
297
298
299 def build_parser() -> argparse.ArgumentParser:
300 parser = argparse.ArgumentParser(
301 description="Recall chart-template candidates or validate selected keys.",
302 formatter_class=argparse.RawDescriptionHelpFormatter,
303 )
304 subparsers = parser.add_subparsers(dest="command", required=True)
305
306 recall = subparsers.add_parser("recall", help="Recall candidates for one planned page.")
307 recall.add_argument("--page", required=True, help="Planned page key, for example P03.")
308 recall.add_argument(
309 "--tag",
310 action="append",
311 required=True,
312 help="English semantic content-shape tag; repeat 3-8 times.",
313 )
314 recall.add_argument(
315 "--limit",
316 type=int,
317 choices=range(3, 9),
318 default=6,
319 metavar="3..8",
320 help="Candidate count (default: 6).",
321 )
322 recall.add_argument(
323 "--semantic-fallback",
324 action="store_true",
325 help="Include the full catalog when bounded recall may have missed a semantic match.",
326 )
327 recall.set_defaults(handler=_run_recall)
328
329 validate = subparsers.add_parser("validate", help="Validate selected catalog keys.")
330 validate.add_argument("keys", nargs="+", help="One or more selected chart keys.")
331 validate.set_defaults(handler=_run_validate)
332 return parser
333
334
335 def main(argv: Optional[list[str]] = None) -> int:
336 parser = build_parser()
337 args = parser.parse_args(argv)
338 try:
339 return args.handler(args)
340 except RuntimeError as exc:
341 print(f"Error: {exc}", file=sys.stderr)
342 return 1
343
344
345 if __name__ == "__main__":
346 raise SystemExit(main())
347
347 lines PYTHON