Release standalone PPT template workflow 0.5.0

This commit is contained in:
2026-10-10 21:02:47 +08:00
commit 4e3271ff62
303 changed files with 190946 additions and 0 deletions
@@ -0,0 +1,486 @@
#!/usr/bin/env python3
"""
PPT Master - Visualization Candidate Recall
Recall a deterministic chart or table shortlist from semantic tags, or validate
selected references against the live family catalogs.
Usage:
python3 scripts/visualization_recall.py recall --page P03 --tag "time series" --tag "three metrics" --tag "trend"
python3 scripts/visualization_recall.py validate chart/line_chart table/record_table
Examples:
python3 scripts/visualization_recall.py recall --page P07 --family table \
--tag "option comparison" --tag "shared criteria" \
--tag "cell values" --limit 6
python3 scripts/visualization_recall.py validate table/record_table
python3 scripts/visualization_recall.py validate --legacy-bare process_flow
Dependencies:
None (only uses the standard library)
See scripts/docs/visualization-recall.md for the planning workflow and output contract.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import unicodedata
from typing import Optional
from console_encoding import configure_utf8_stdio
from visualization_catalog import (
LEGACY_STRUCTURE_INTENT_KIND,
VISUALIZATION_SVG_KIND,
VisualizationCatalogError,
VisualizationEntry,
load_visualization_entries,
resolve_visualization_reference,
visualization_families,
)
configure_utf8_stdio()
_TOKEN_RE = re.compile(r"[a-z0-9]+")
_PAGE_RE = re.compile(r"^P\d{2,}$")
_SKIP_RE = re.compile(r"\bskip\s+(?:if|for)\b", re.IGNORECASE)
_STOP_WORDS = {
"a",
"an",
"and",
"as",
"at",
"by",
"for",
"from",
"if",
"in",
"into",
"of",
"on",
"or",
"per",
"the",
"to",
"use",
"with",
}
def _normalize(text: str) -> str:
normalized = unicodedata.normalize("NFKC", text).casefold().replace("_", " ")
return " ".join(_TOKEN_RE.findall(normalized))
def _stem(token: str) -> str:
if len(token) > 5 and token.endswith("ies"):
return token[:-3] + "y"
if len(token) > 5 and token.endswith("ing"):
return token[:-3]
if len(token) > 4 and token.endswith("ed"):
return token[:-2]
if len(token) > 4 and token.endswith("es"):
return token[:-2]
if len(token) > 3 and token.endswith("s"):
return token[:-1]
return token
def _tokens(text: str) -> set[str]:
return {
_stem(token)
for token in _TOKEN_RE.findall(_normalize(text))
if token not in _STOP_WORDS
}
def _selected_families(family: str) -> tuple[str, ...]:
return visualization_families() if family == "all" else (family,)
def load_catalog(family: str = "all") -> dict[str, VisualizationEntry]:
"""Load the selected live family catalogs."""
return load_visualization_entries(
_selected_families(family),
)
def _score_candidate(
key: str,
summary: str,
tags: list[str],
) -> tuple[int, list[str]]:
skip_match = _SKIP_RE.search(summary)
if skip_match is None:
pick_clause, skip_clause = summary, ""
else:
pick_clause = summary[:skip_match.start()]
skip_clause = summary[skip_match.start():]
key_text = _normalize(key)
pick_text = _normalize(pick_clause)
skip_text = _normalize(skip_clause)
key_tokens = _tokens(key)
pick_tokens = _tokens(pick_clause)
skip_tokens = _tokens(skip_clause)
score = 0
matched_tags: list[str] = []
for tag in tags:
tag_text = _normalize(tag)
tag_tokens = _tokens(tag)
positive_score = 0
negative_score = 0
if tag_text and tag_text in key_text:
positive_score += 20
elif tag_text and tag_text in pick_text:
positive_score += 14
if tag_text and tag_text in skip_text:
negative_score += 12
for token in tag_tokens:
if token in key_tokens:
positive_score += 9
elif token in pick_tokens:
positive_score += 5
if token in skip_tokens:
negative_score += 6
if positive_score:
matched_tags.append(tag)
score += positive_score - negative_score
return score, matched_tags
def _candidate_payload(
entry: VisualizationEntry,
score: int,
matched_tags: list[str],
*,
legacy_output: bool,
) -> dict[str, object]:
payload: dict[str, object] = {
"key": entry.key,
"path": entry.display_path,
"summary": entry.summary,
"score": score,
"matched_tags": matched_tags,
}
if not legacy_output:
payload.update(
{
"family": entry.family,
"reference": entry.reference,
}
)
return payload
def _validation_payload(entry: VisualizationEntry) -> dict[str, object]:
"""Describe one resolution without inventing an asset for intent-only keys."""
payload: dict[str, object] = {
"family": entry.family,
"key": entry.key,
"kind": entry.kind,
}
if entry.kind == VISUALIZATION_SVG_KIND:
payload.update(
{
"path": entry.display_path,
"reference": entry.reference,
}
)
elif entry.kind != LEGACY_STRUCTURE_INTENT_KIND:
raise VisualizationCatalogError(
f"{entry.key!r} resolves to unsupported kind {entry.kind!r}"
)
return payload
def recall_candidates(
page: str,
tags: list[str],
limit: int,
*,
family: str = "all",
force_semantic_fallback: bool = False,
legacy_output: bool = False,
) -> dict[str, object]:
"""Recall a deterministic shortlist for one page."""
entries = load_catalog(family)
scored: list[tuple[int, VisualizationEntry, list[str]]] = []
for entry in entries.values():
score, matched_tags = _score_candidate(entry.key, entry.summary, tags)
if score > 0 and matched_tags:
scored.append((score, entry, matched_tags))
scored.sort(key=lambda item: (-item[0], item[1].family, item[1].key))
candidates = [
_candidate_payload(
entry,
score,
matched_tags,
legacy_output=legacy_output,
)
for score, entry, matched_tags in scored[:limit]
]
top_score = candidates[0]["score"] if candidates else 0
if top_score >= 35:
confidence = "high"
elif top_score >= 15:
confidence = "medium"
elif top_score > 0:
confidence = "low"
else:
confidence = "none"
fallback_required_before_no_match = (
confidence in {"low", "none"} and not force_semantic_fallback
)
if fallback_required_before_no_match:
no_match_instruction = (
f"Lexical confidence is {confidence}. Select a bounded candidate when one "
"fits; otherwise rerun the same recall with --semantic-fallback before "
"keeping no-template-match. Keep the final negative result out of Design "
"Spec Section VII and describe the chosen fallback in the page's Section "
"IX block."
)
else:
no_match_instruction = (
"Use when none of the reviewed candidates fits the page structure. Keep "
"this result out of Design Spec Section VII and describe the chosen fallback "
"in the page's Section IX block."
)
result: dict[str, object] = {
"page": page,
"semantic_tags": tags,
"confidence": confidence,
"candidates": candidates,
"no_template_match": {
"allowed": not fallback_required_before_no_match,
"key": "no-template-match",
"instruction": no_match_instruction,
},
}
if not legacy_output:
result["family_filter"] = family
if force_semantic_fallback:
if legacy_output:
catalog: object = {
entry.key: entry.summary
for entry in sorted(entries.values(), key=lambda item: item.key)
}
else:
catalog = {
entry.reference: {
"family": entry.family,
"key": entry.key,
"path": entry.display_path,
"summary": entry.summary,
}
for entry in sorted(
entries.values(),
key=lambda item: (item.family, item.key),
)
}
result["semantic_fallback"] = {
"reason": "requested-after-bounded-review",
"instruction": (
"Semantically compare the page tags with every returned selection rule. "
"Choose one exact catalog reference or keep no-template-match; lexical "
"overlap is not required in this review."
),
"reference_pattern": "family/key",
"catalog": catalog,
}
return result
def _dedupe(values: list[str]) -> list[str]:
result: list[str] = []
seen: set[str] = set()
for value in values:
stripped = value.strip()
normalized = _normalize(stripped)
if not stripped or not normalized or normalized in seen:
continue
seen.add(normalized)
result.append(stripped)
return result
def _run_recall(args: argparse.Namespace) -> int:
page = args.page.upper()
if not _PAGE_RE.fullmatch(page):
print("Error: --page must match P<NN>, for example P03.", file=sys.stderr)
return 2
tags = _dedupe(args.tag)
if not 3 <= len(tags) <= 8:
print("Error: recall requires 3-8 distinct non-empty --tag values.", file=sys.stderr)
return 2
result = recall_candidates(
page,
tags,
args.limit,
family=args.family,
force_semantic_fallback=args.semantic_fallback,
legacy_output=args.legacy_output,
)
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
return 0
def _resolve_validation_value(
raw: str,
family: str,
*,
allow_legacy_bare: bool,
) -> VisualizationEntry:
if "/" in raw:
return resolve_visualization_reference(raw)
if family != "all":
return resolve_visualization_reference(f"{family}/{raw}")
return resolve_visualization_reference(
raw,
allow_legacy_bare=allow_legacy_bare,
)
def _run_validate(args: argparse.Namespace) -> int:
selected = _dedupe(args.keys)
invalid: list[str] = []
valid_entries: list[tuple[str, VisualizationEntry]] = []
for value in selected:
try:
valid_entries.append(
(
value,
_resolve_validation_value(
value,
args.family,
allow_legacy_bare=args.legacy_bare,
),
)
)
except VisualizationCatalogError:
invalid.append(value)
if args.legacy_output:
valid: object = sorted(raw for raw, _entry in valid_entries)
else:
valid = [
_validation_payload(entry)
for _raw, entry in sorted(
valid_entries,
key=lambda item: (item[1].kind, item[1].family, item[1].key),
)
]
result: dict[str, object] = {"invalid": sorted(invalid), "valid": valid}
if args.legacy_output:
result["resolved"] = [
{"input": raw, **_validation_payload(entry)}
for raw, entry in sorted(valid_entries, key=lambda item: item[0])
]
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
if invalid:
mapping_name = (
"page_charts"
if args.legacy_output or args.legacy_bare
else "page_visualizations"
)
print(
"Error: replace each invalid reference with one returned by recall, or "
f"keep no-template-match out of Section VII and {mapping_name} while "
"recording the custom fallback in the page's Section IX block.",
file=sys.stderr,
)
return 1
return 0
def _add_family_argument(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--family",
choices=("all",) + visualization_families(),
default="all",
help="Limit candidates to one family (default: all).",
)
def build_parser(*, legacy_output: bool = False) -> argparse.ArgumentParser:
description = (
"Recall legacy chart-catalog candidates or validate selected keys."
if legacy_output
else "Recall visualization candidates or validate selected references."
)
parser = argparse.ArgumentParser(
description=description,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.set_defaults(legacy_output=legacy_output)
subparsers = parser.add_subparsers(dest="command", required=True)
recall = subparsers.add_parser("recall", help="Recall candidates for one page.")
recall.add_argument("--page", required=True, help="Planned page key, for example P03.")
recall.add_argument(
"--tag",
action="append",
required=True,
help="English semantic content-shape tag; repeat 3-8 times.",
)
recall.add_argument(
"--limit",
type=int,
choices=range(3, 9),
default=6,
metavar="3..8",
help="Candidate count (default: 6).",
)
recall.add_argument(
"--semantic-fallback",
action="store_true",
help="Include the selected live catalogs after a low-confidence review.",
)
_add_family_argument(recall)
recall.set_defaults(handler=_run_recall)
validate = subparsers.add_parser("validate", help="Validate selected references.")
validate.add_argument("keys", nargs="+", help="One or more keys or family/key references.")
validate.add_argument(
"--legacy-bare",
action="store_true",
default=legacy_output,
help=(
argparse.SUPPRESS
if legacy_output
else "Allow unqualified keys from an existing legacy page_charts mapping."
),
)
_add_family_argument(validate)
validate.set_defaults(handler=_run_validate)
return parser
def main(
argv: Optional[list[str]] = None,
*,
legacy_output: bool = False,
) -> int:
parser = build_parser(legacy_output=legacy_output)
args = parser.parse_args(argv)
try:
return args.handler(args)
except VisualizationCatalogError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())