返回 ppt-master
visualization_recall.py
根目录 / skills / ppt-master / scripts / visualization_recall.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Visualization Candidate Recall
4
5 Recall a deterministic chart or table shortlist from semantic tags, or validate
6 selected references against the live family catalogs.
7
8 Usage:
9 python3 scripts/visualization_recall.py recall --page P03 --tag "time series" --tag "three metrics" --tag "trend"
10 python3 scripts/visualization_recall.py validate chart/line_chart table/record_table
11
12 Examples:
13 python3 scripts/visualization_recall.py recall --page P07 --family table \
14 --tag "option comparison" --tag "shared criteria" \
15 --tag "cell values" --limit 6
16 python3 scripts/visualization_recall.py validate table/record_table
17 python3 scripts/visualization_recall.py validate --legacy-bare process_flow
18
19 Dependencies:
20 None (only uses the standard library)
21
22 See scripts/docs/visualization-recall.md for the planning workflow and output contract.
23 """
24
25 from __future__ import annotations
26
27 import argparse
28 import json
29 import re
30 import sys
31 import unicodedata
32 from typing import Optional
33
34 from console_encoding import configure_utf8_stdio
35 from visualization_catalog import (
36 LEGACY_STRUCTURE_INTENT_KIND,
37 VISUALIZATION_SVG_KIND,
38 VisualizationCatalogError,
39 VisualizationEntry,
40 load_visualization_entries,
41 resolve_visualization_reference,
42 visualization_families,
43 )
44
45 configure_utf8_stdio()
46
47 _TOKEN_RE = re.compile(r"[a-z0-9]+")
48 _PAGE_RE = re.compile(r"^P\d{2,}$")
49 _SKIP_RE = re.compile(r"\bskip\s+(?:if|for)\b", re.IGNORECASE)
50 _STOP_WORDS = {
51 "a",
52 "an",
53 "and",
54 "as",
55 "at",
56 "by",
57 "for",
58 "from",
59 "if",
60 "in",
61 "into",
62 "of",
63 "on",
64 "or",
65 "per",
66 "the",
67 "to",
68 "use",
69 "with",
70 }
71
72
73 def _normalize(text: str) -> str:
74 normalized = unicodedata.normalize("NFKC", text).casefold().replace("_", " ")
75 return " ".join(_TOKEN_RE.findall(normalized))
76
77
78 def _stem(token: str) -> str:
79 if len(token) > 5 and token.endswith("ies"):
80 return token[:-3] + "y"
81 if len(token) > 5 and token.endswith("ing"):
82 return token[:-3]
83 if len(token) > 4 and token.endswith("ed"):
84 return token[:-2]
85 if len(token) > 4 and token.endswith("es"):
86 return token[:-2]
87 if len(token) > 3 and token.endswith("s"):
88 return token[:-1]
89 return token
90
91
92 def _tokens(text: str) -> set[str]:
93 return {
94 _stem(token)
95 for token in _TOKEN_RE.findall(_normalize(text))
96 if token not in _STOP_WORDS
97 }
98
99
100 def _selected_families(family: str) -> tuple[str, ...]:
101 return visualization_families() if family == "all" else (family,)
102
103
104 def load_catalog(family: str = "all") -> dict[str, VisualizationEntry]:
105 """Load the selected live family catalogs."""
106 return load_visualization_entries(
107 _selected_families(family),
108 )
109
110
111 def _score_candidate(
112 key: str,
113 summary: str,
114 tags: list[str],
115 ) -> tuple[int, list[str]]:
116 skip_match = _SKIP_RE.search(summary)
117 if skip_match is None:
118 pick_clause, skip_clause = summary, ""
119 else:
120 pick_clause = summary[:skip_match.start()]
121 skip_clause = summary[skip_match.start():]
122 key_text = _normalize(key)
123 pick_text = _normalize(pick_clause)
124 skip_text = _normalize(skip_clause)
125 key_tokens = _tokens(key)
126 pick_tokens = _tokens(pick_clause)
127 skip_tokens = _tokens(skip_clause)
128 score = 0
129 matched_tags: list[str] = []
130
131 for tag in tags:
132 tag_text = _normalize(tag)
133 tag_tokens = _tokens(tag)
134 positive_score = 0
135 negative_score = 0
136 if tag_text and tag_text in key_text:
137 positive_score += 20
138 elif tag_text and tag_text in pick_text:
139 positive_score += 14
140 if tag_text and tag_text in skip_text:
141 negative_score += 12
142
143 for token in tag_tokens:
144 if token in key_tokens:
145 positive_score += 9
146 elif token in pick_tokens:
147 positive_score += 5
148 if token in skip_tokens:
149 negative_score += 6
150
151 if positive_score:
152 matched_tags.append(tag)
153 score += positive_score - negative_score
154
155 return score, matched_tags
156
157
158 def _candidate_payload(
159 entry: VisualizationEntry,
160 score: int,
161 matched_tags: list[str],
162 *,
163 legacy_output: bool,
164 ) -> dict[str, object]:
165 payload: dict[str, object] = {
166 "key": entry.key,
167 "path": entry.display_path,
168 "summary": entry.summary,
169 "score": score,
170 "matched_tags": matched_tags,
171 }
172 if not legacy_output:
173 payload.update(
174 {
175 "family": entry.family,
176 "reference": entry.reference,
177 }
178 )
179 return payload
180
181
182 def _validation_payload(entry: VisualizationEntry) -> dict[str, object]:
183 """Describe one resolution without inventing an asset for intent-only keys."""
184 payload: dict[str, object] = {
185 "family": entry.family,
186 "key": entry.key,
187 "kind": entry.kind,
188 }
189 if entry.kind == VISUALIZATION_SVG_KIND:
190 payload.update(
191 {
192 "path": entry.display_path,
193 "reference": entry.reference,
194 }
195 )
196 elif entry.kind != LEGACY_STRUCTURE_INTENT_KIND:
197 raise VisualizationCatalogError(
198 f"{entry.key!r} resolves to unsupported kind {entry.kind!r}"
199 )
200 return payload
201
202
203 def recall_candidates(
204 page: str,
205 tags: list[str],
206 limit: int,
207 *,
208 family: str = "all",
209 force_semantic_fallback: bool = False,
210 legacy_output: bool = False,
211 ) -> dict[str, object]:
212 """Recall a deterministic shortlist for one page."""
213 entries = load_catalog(family)
214 scored: list[tuple[int, VisualizationEntry, list[str]]] = []
215 for entry in entries.values():
216 score, matched_tags = _score_candidate(entry.key, entry.summary, tags)
217 if score > 0 and matched_tags:
218 scored.append((score, entry, matched_tags))
219 scored.sort(key=lambda item: (-item[0], item[1].family, item[1].key))
220
221 candidates = [
222 _candidate_payload(
223 entry,
224 score,
225 matched_tags,
226 legacy_output=legacy_output,
227 )
228 for score, entry, matched_tags in scored[:limit]
229 ]
230
231 top_score = candidates[0]["score"] if candidates else 0
232 if top_score >= 35:
233 confidence = "high"
234 elif top_score >= 15:
235 confidence = "medium"
236 elif top_score > 0:
237 confidence = "low"
238 else:
239 confidence = "none"
240
241 fallback_required_before_no_match = (
242 confidence in {"low", "none"} and not force_semantic_fallback
243 )
244 if fallback_required_before_no_match:
245 no_match_instruction = (
246 f"Lexical confidence is {confidence}. Select a bounded candidate when one "
247 "fits; otherwise rerun the same recall with --semantic-fallback before "
248 "keeping no-template-match. Keep the final negative result out of Design "
249 "Spec Section VII and describe the chosen fallback in the page's Section "
250 "IX block."
251 )
252 else:
253 no_match_instruction = (
254 "Use when none of the reviewed candidates fits the page structure. Keep "
255 "this result out of Design Spec Section VII and describe the chosen fallback "
256 "in the page's Section IX block."
257 )
258
259 result: dict[str, object] = {
260 "page": page,
261 "semantic_tags": tags,
262 "confidence": confidence,
263 "candidates": candidates,
264 "no_template_match": {
265 "allowed": not fallback_required_before_no_match,
266 "key": "no-template-match",
267 "instruction": no_match_instruction,
268 },
269 }
270 if not legacy_output:
271 result["family_filter"] = family
272 if force_semantic_fallback:
273 if legacy_output:
274 catalog: object = {
275 entry.key: entry.summary
276 for entry in sorted(entries.values(), key=lambda item: item.key)
277 }
278 else:
279 catalog = {
280 entry.reference: {
281 "family": entry.family,
282 "key": entry.key,
283 "path": entry.display_path,
284 "summary": entry.summary,
285 }
286 for entry in sorted(
287 entries.values(),
288 key=lambda item: (item.family, item.key),
289 )
290 }
291 result["semantic_fallback"] = {
292 "reason": "requested-after-bounded-review",
293 "instruction": (
294 "Semantically compare the page tags with every returned selection rule. "
295 "Choose one exact catalog reference or keep no-template-match; lexical "
296 "overlap is not required in this review."
297 ),
298 "reference_pattern": "family/key",
299 "catalog": catalog,
300 }
301 return result
302
303
304 def _dedupe(values: list[str]) -> list[str]:
305 result: list[str] = []
306 seen: set[str] = set()
307 for value in values:
308 stripped = value.strip()
309 normalized = _normalize(stripped)
310 if not stripped or not normalized or normalized in seen:
311 continue
312 seen.add(normalized)
313 result.append(stripped)
314 return result
315
316
317 def _run_recall(args: argparse.Namespace) -> int:
318 page = args.page.upper()
319 if not _PAGE_RE.fullmatch(page):
320 print("Error: --page must match P<NN>, for example P03.", file=sys.stderr)
321 return 2
322
323 tags = _dedupe(args.tag)
324 if not 3 <= len(tags) <= 8:
325 print("Error: recall requires 3-8 distinct non-empty --tag values.", file=sys.stderr)
326 return 2
327
328 result = recall_candidates(
329 page,
330 tags,
331 args.limit,
332 family=args.family,
333 force_semantic_fallback=args.semantic_fallback,
334 legacy_output=args.legacy_output,
335 )
336 print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
337 return 0
338
339
340 def _resolve_validation_value(
341 raw: str,
342 family: str,
343 *,
344 allow_legacy_bare: bool,
345 ) -> VisualizationEntry:
346 if "/" in raw:
347 return resolve_visualization_reference(raw)
348 if family != "all":
349 return resolve_visualization_reference(f"{family}/{raw}")
350 return resolve_visualization_reference(
351 raw,
352 allow_legacy_bare=allow_legacy_bare,
353 )
354
355
356 def _run_validate(args: argparse.Namespace) -> int:
357 selected = _dedupe(args.keys)
358 invalid: list[str] = []
359 valid_entries: list[tuple[str, VisualizationEntry]] = []
360 for value in selected:
361 try:
362 valid_entries.append(
363 (
364 value,
365 _resolve_validation_value(
366 value,
367 args.family,
368 allow_legacy_bare=args.legacy_bare,
369 ),
370 )
371 )
372 except VisualizationCatalogError:
373 invalid.append(value)
374
375 if args.legacy_output:
376 valid: object = sorted(raw for raw, _entry in valid_entries)
377 else:
378 valid = [
379 _validation_payload(entry)
380 for _raw, entry in sorted(
381 valid_entries,
382 key=lambda item: (item[1].kind, item[1].family, item[1].key),
383 )
384 ]
385 result: dict[str, object] = {"invalid": sorted(invalid), "valid": valid}
386 if args.legacy_output:
387 result["resolved"] = [
388 {"input": raw, **_validation_payload(entry)}
389 for raw, entry in sorted(valid_entries, key=lambda item: item[0])
390 ]
391 print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
392 if invalid:
393 mapping_name = (
394 "page_charts"
395 if args.legacy_output or args.legacy_bare
396 else "page_visualizations"
397 )
398 print(
399 "Error: replace each invalid reference with one returned by recall, or "
400 f"keep no-template-match out of Section VII and {mapping_name} while "
401 "recording the custom fallback in the page's Section IX block.",
402 file=sys.stderr,
403 )
404 return 1
405 return 0
406
407
408 def _add_family_argument(parser: argparse.ArgumentParser) -> None:
409 parser.add_argument(
410 "--family",
411 choices=("all",) + visualization_families(),
412 default="all",
413 help="Limit candidates to one family (default: all).",
414 )
415
416
417 def build_parser(*, legacy_output: bool = False) -> argparse.ArgumentParser:
418 description = (
419 "Recall legacy chart-catalog candidates or validate selected keys."
420 if legacy_output
421 else "Recall visualization candidates or validate selected references."
422 )
423 parser = argparse.ArgumentParser(
424 description=description,
425 formatter_class=argparse.RawDescriptionHelpFormatter,
426 )
427 parser.set_defaults(legacy_output=legacy_output)
428 subparsers = parser.add_subparsers(dest="command", required=True)
429
430 recall = subparsers.add_parser("recall", help="Recall candidates for one page.")
431 recall.add_argument("--page", required=True, help="Planned page key, for example P03.")
432 recall.add_argument(
433 "--tag",
434 action="append",
435 required=True,
436 help="English semantic content-shape tag; repeat 3-8 times.",
437 )
438 recall.add_argument(
439 "--limit",
440 type=int,
441 choices=range(3, 9),
442 default=6,
443 metavar="3..8",
444 help="Candidate count (default: 6).",
445 )
446 recall.add_argument(
447 "--semantic-fallback",
448 action="store_true",
449 help="Include the selected live catalogs after a low-confidence review.",
450 )
451 _add_family_argument(recall)
452 recall.set_defaults(handler=_run_recall)
453
454 validate = subparsers.add_parser("validate", help="Validate selected references.")
455 validate.add_argument("keys", nargs="+", help="One or more keys or family/key references.")
456 validate.add_argument(
457 "--legacy-bare",
458 action="store_true",
459 default=legacy_output,
460 help=(
461 argparse.SUPPRESS
462 if legacy_output
463 else "Allow unqualified keys from an existing legacy page_charts mapping."
464 ),
465 )
466 _add_family_argument(validate)
467 validate.set_defaults(handler=_run_validate)
468 return parser
469
470
471 def main(
472 argv: Optional[list[str]] = None,
473 *,
474 legacy_output: bool = False,
475 ) -> int:
476 parser = build_parser(legacy_output=legacy_output)
477 args = parser.parse_args(argv)
478 try:
479 return args.handler(args)
480 except VisualizationCatalogError as exc:
481 print(f"Error: {exc}", file=sys.stderr)
482 return 1
483
484
485 if __name__ == "__main__":
486 raise SystemExit(main())
487
487 lines PYTHON