Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Project Management Internals
|
||||
|
||||
Internal project lifecycle, planning-artifact, and page-context implementation.
|
||||
Use the stable ``scripts/project_manager.py`` entry point for CLI operations.
|
||||
|
||||
Usage:
|
||||
Imported by the top-level project-management compatibility modules.
|
||||
|
||||
Examples:
|
||||
from project_management.project_specs import validate_project_artifacts
|
||||
|
||||
Dependencies:
|
||||
Same as the selected project-management module.
|
||||
"""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,984 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Page Context Projection
|
||||
|
||||
Build deterministic per-page execution views and optional token telemetry.
|
||||
|
||||
Usage:
|
||||
Imported by project_management.cli.
|
||||
|
||||
Examples:
|
||||
build_page_context(Path("projects/demo"), "P07")
|
||||
|
||||
Dependencies:
|
||||
None for projection; tiktoken is optional for exact usage counts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import statistics
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from .paths import SKILL_DIR as _SKILL_DIR
|
||||
from .project_specs import (
|
||||
default_spec_lock_forbidden,
|
||||
parse_markdown_artifact,
|
||||
parse_spec_lock_artifact,
|
||||
validate_project_artifacts,
|
||||
)
|
||||
from svg_to_pptx.pptx_package.template_structure import (
|
||||
PptxStructureLock,
|
||||
TemplateStructureError,
|
||||
load_pptx_structure_lock,
|
||||
)
|
||||
from visualization_catalog import (
|
||||
LEGACY_STRUCTURE_INTENT_KIND,
|
||||
VISUALIZATION_SVG_KIND,
|
||||
VisualizationCatalogError,
|
||||
VisualizationEntry,
|
||||
resolve_visualization_reference,
|
||||
)
|
||||
|
||||
|
||||
PAGE_CONTEXT_SCHEMA = "ppt-master.page-context.v2"
|
||||
PAGE_CONTEXT_USAGE_SCHEMA = "ppt-master.page-context-usage.v2"
|
||||
PAGE_CONTEXT_REPORT_SCHEMA = "ppt-master.page-context-usage-report.v2"
|
||||
TOKEN_ENCODING = "o200k_base"
|
||||
PAGE_CONTEXT_TOKEN_TARGET = 2000
|
||||
LOCK_PROJECTION_TOKEN_TARGET = 1000
|
||||
|
||||
_PAGE_RE = re.compile(r"^(?:P)?([0-9]+)$", re.IGNORECASE)
|
||||
_SLIDE_HEADING_RE = re.compile(
|
||||
r"^#{3,6}[ \t]+Slide[ \t]+0*([0-9]+)(?:[ \t]*(?:[-:–—]).*)?$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_BLOCK_BOUNDARY_RE = re.compile(r"^#{2,6}[ \t]+", re.MULTILINE)
|
||||
_PART_HEADING_RE = re.compile(r"^###[ \t]+(?!#)(.+?)[ \t]*$", re.MULTILINE)
|
||||
_PAGE_TOKEN_RE = re.compile(
|
||||
r"(?<![A-Za-z0-9_])P0*([1-9][0-9]*)(?![A-Za-z0-9_])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class PageContextError(RuntimeError):
|
||||
"""Reject an incomplete or ambiguous page-context request."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PageRead:
|
||||
"""One exact model-visible page payload."""
|
||||
|
||||
kind: str
|
||||
path: str
|
||||
payload: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PageContextResult:
|
||||
"""One projected page view plus the files that make it current."""
|
||||
|
||||
project_path: Path
|
||||
page: str
|
||||
context: dict[str, object]
|
||||
inputs: tuple[Path, ...]
|
||||
|
||||
|
||||
def normalize_page_key(raw_page: str) -> tuple[str, int]:
|
||||
"""Normalize a positive page identifier to the schema's P<NN> form."""
|
||||
match = _PAGE_RE.fullmatch(raw_page.strip())
|
||||
if match is None or int(match.group(1)) <= 0:
|
||||
raise PageContextError("page must be a positive P<NN> identifier")
|
||||
number = int(match.group(1))
|
||||
return f"P{number:02d}", number
|
||||
|
||||
|
||||
def _section_index(
|
||||
sections: Iterable[dict[str, object]],
|
||||
) -> dict[str, dict[str, object]]:
|
||||
return {
|
||||
str(section["heading"]).strip().casefold(): section
|
||||
for section in sections
|
||||
}
|
||||
|
||||
|
||||
def _section_fields(
|
||||
sections: dict[str, dict[str, object]],
|
||||
heading: str,
|
||||
) -> dict[str, str]:
|
||||
section = sections.get(heading.casefold())
|
||||
if section is None:
|
||||
return {}
|
||||
fields = section.get("fields", {})
|
||||
if not isinstance(fields, dict):
|
||||
return {}
|
||||
return {str(key): str(value) for key, value in fields.items()}
|
||||
|
||||
|
||||
def _forbidden_items(
|
||||
sections: dict[str, dict[str, object]],
|
||||
) -> list[str]:
|
||||
section = sections.get("forbidden")
|
||||
if section is None:
|
||||
return []
|
||||
items: list[str] = []
|
||||
default_items = default_spec_lock_forbidden()
|
||||
for raw_line in str(section.get("body", "")).splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
item = re.sub(r"^-[ \t]+", "", line)
|
||||
if item not in default_items:
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def _outline_section(
|
||||
sections: Iterable[dict[str, object]],
|
||||
) -> dict[str, object] | None:
|
||||
for section in sections:
|
||||
heading = str(section.get("heading", "")).strip().casefold()
|
||||
if heading == "content outline" or heading.endswith(". content outline"):
|
||||
return section
|
||||
return None
|
||||
|
||||
|
||||
def _page_image_filenames(
|
||||
design_sections: Iterable[dict[str, object]],
|
||||
page_number: int,
|
||||
) -> tuple[set[str], set[str]]:
|
||||
"""Read explicit P<NN> usage from the canonical image-resource table."""
|
||||
section = next(
|
||||
(
|
||||
item
|
||||
for item in design_sections
|
||||
if (
|
||||
(heading := str(item.get("heading", "")).strip().casefold())
|
||||
== "image resource list"
|
||||
or heading.startswith("viii. image resource list")
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if section is None:
|
||||
return set(), set()
|
||||
table_rows = [
|
||||
[
|
||||
cell.strip().replace(r"\|", "|")
|
||||
for cell in re.split(r"(?<!\\)\|", line.strip().strip("|"))
|
||||
]
|
||||
for line in str(section.get("body", "")).splitlines()
|
||||
if line.strip().startswith("|") and line.strip().endswith("|")
|
||||
]
|
||||
if not table_rows:
|
||||
return set(), set()
|
||||
header = {
|
||||
name.casefold(): index
|
||||
for index, name in enumerate(table_rows[0])
|
||||
}
|
||||
filename_index = header.get("filename")
|
||||
purpose_index = header.get("purpose")
|
||||
if filename_index is None or purpose_index is None:
|
||||
return set(), set()
|
||||
assigned: set[str] = set()
|
||||
selected: set[str] = set()
|
||||
for row in table_rows[1:]:
|
||||
if len(row) <= max(filename_index, purpose_index):
|
||||
continue
|
||||
purpose = row[purpose_index]
|
||||
pages = {int(match.group(1)) for match in _PAGE_TOKEN_RE.finditer(purpose)}
|
||||
if not pages:
|
||||
continue
|
||||
filename = row[filename_index].strip().strip("`")
|
||||
if not filename:
|
||||
continue
|
||||
basename = Path(filename).name
|
||||
assigned.add(basename)
|
||||
if page_number in pages:
|
||||
selected.add(basename)
|
||||
return assigned, selected
|
||||
|
||||
|
||||
def _locked_image_basename(value: str) -> str:
|
||||
return Path(value.split("|", 1)[0].strip()).name
|
||||
|
||||
|
||||
def _outline_image_assignments(
|
||||
design_sections: Iterable[dict[str, object]],
|
||||
locked_images: dict[str, str],
|
||||
) -> set[str]:
|
||||
outline = _outline_section(design_sections)
|
||||
if outline is None:
|
||||
return set()
|
||||
body = str(outline.get("body", ""))
|
||||
return {
|
||||
_locked_image_basename(value)
|
||||
for key, value in locked_images.items()
|
||||
if any(
|
||||
_contains_token(body, token)
|
||||
for token in (
|
||||
key,
|
||||
value.split("|", 1)[0].strip(),
|
||||
_locked_image_basename(value),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _slide_block(
|
||||
design_sections: Iterable[dict[str, object]],
|
||||
page_number: int,
|
||||
) -> tuple[str | None, str]:
|
||||
outline = _outline_section(design_sections)
|
||||
if outline is None:
|
||||
raise PageContextError("design_spec.md has no Content Outline section")
|
||||
body = str(outline.get("body", ""))
|
||||
matches = [
|
||||
match
|
||||
for match in _SLIDE_HEADING_RE.finditer(body)
|
||||
if int(match.group(1)) == page_number
|
||||
]
|
||||
if not matches:
|
||||
raise PageContextError(
|
||||
f"design_spec.md Content Outline has no Slide {page_number:02d} block"
|
||||
)
|
||||
if len(matches) > 1:
|
||||
raise PageContextError(
|
||||
f"design_spec.md Content Outline repeats Slide {page_number:02d}"
|
||||
)
|
||||
match = matches[0]
|
||||
next_boundary = _BLOCK_BOUNDARY_RE.search(body, match.end())
|
||||
block_end = next_boundary.start() if next_boundary else len(body)
|
||||
block = body[match.start():block_end].strip()
|
||||
part_matches = list(_PART_HEADING_RE.finditer(body, 0, match.start()))
|
||||
part = part_matches[-1].group(1).strip() if part_matches else None
|
||||
return part, block
|
||||
|
||||
|
||||
def _relative_project_path(project_path: Path, path: Path) -> str:
|
||||
try:
|
||||
return path.resolve().relative_to(project_path).as_posix()
|
||||
except ValueError as exc:
|
||||
raise PageContextError(f"path escapes project: {path}") from exc
|
||||
|
||||
|
||||
def _prototype_image_refs(svg_path: Path) -> list[str]:
|
||||
try:
|
||||
root = ET.parse(svg_path).getroot()
|
||||
except (OSError, ET.ParseError) as exc:
|
||||
raise PageContextError(f"cannot read prototype SVG {svg_path}: {exc}") from exc
|
||||
refs: set[str] = set()
|
||||
for element in root.iter():
|
||||
if element.tag.rsplit("}", 1)[-1] != "image":
|
||||
continue
|
||||
for name, value in element.attrib.items():
|
||||
if name.rsplit("}", 1)[-1] != "href":
|
||||
continue
|
||||
normalized = value.strip()
|
||||
if normalized and not normalized.startswith(("data:", "#")):
|
||||
refs.add(normalized)
|
||||
return sorted(refs)
|
||||
|
||||
|
||||
def _contains_token(text: str, token: str) -> bool:
|
||||
if not token:
|
||||
return False
|
||||
if re.fullmatch(r"[A-Za-z0-9_]+", token):
|
||||
return re.search(
|
||||
rf"(?<![A-Za-z0-9_]){re.escape(token)}(?![A-Za-z0-9_])",
|
||||
text,
|
||||
) is not None
|
||||
return token in text
|
||||
|
||||
|
||||
def _page_images(
|
||||
locked_images: dict[str, str],
|
||||
brief: str,
|
||||
prototype_refs: list[str],
|
||||
assigned_filenames: set[str],
|
||||
resolved_filenames: set[str],
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
if not locked_images:
|
||||
return "none", {}
|
||||
ref_basenames = {Path(ref).name for ref in prototype_refs}
|
||||
selected: dict[str, str] = {}
|
||||
unresolved: dict[str, str] = {}
|
||||
for key, value in locked_images.items():
|
||||
basename = _locked_image_basename(value)
|
||||
if (
|
||||
_contains_token(brief, key)
|
||||
or _contains_token(brief, value)
|
||||
or _contains_token(brief, basename)
|
||||
or basename in ref_basenames
|
||||
or basename in assigned_filenames
|
||||
):
|
||||
selected[key] = value
|
||||
elif basename not in resolved_filenames:
|
||||
unresolved[key] = value
|
||||
if selected and unresolved:
|
||||
return "explicit+unassigned", {**selected, **unresolved}
|
||||
if selected:
|
||||
return "explicit", selected
|
||||
if unresolved:
|
||||
return "unassigned", unresolved
|
||||
return "confirmed-none", {}
|
||||
|
||||
|
||||
def _page_template(
|
||||
project_path: Path,
|
||||
structure_lock: PptxStructureLock | None,
|
||||
page_number: int,
|
||||
) -> tuple[dict[str, object] | None, Path | None]:
|
||||
if structure_lock is None or structure_lock.mode != "structured":
|
||||
return None, None
|
||||
prototype = next(
|
||||
(item for item in structure_lock.prototypes if item.slide_num == page_number),
|
||||
None,
|
||||
)
|
||||
assignment = next(
|
||||
(item for item in structure_lock.layouts if item.slide_num == page_number),
|
||||
None,
|
||||
)
|
||||
if prototype is None or assignment is None:
|
||||
raise PageContextError(
|
||||
f"structured lock has no complete mapping for P{page_number:02d}"
|
||||
)
|
||||
definition = next(
|
||||
(
|
||||
item
|
||||
for item in structure_lock.layout_definitions
|
||||
if item.layout_key == assignment.layout_key
|
||||
),
|
||||
None,
|
||||
)
|
||||
if definition is None:
|
||||
raise PageContextError(
|
||||
f"structured lock has no definition for Layout {assignment.layout_key!r}"
|
||||
)
|
||||
master = next(
|
||||
(
|
||||
item
|
||||
for item in structure_lock.masters
|
||||
if item.master_key == definition.master_key
|
||||
),
|
||||
None,
|
||||
)
|
||||
if master is None:
|
||||
raise PageContextError(
|
||||
f"structured lock has no definition for Master {definition.master_key!r}"
|
||||
)
|
||||
template = {
|
||||
"reuse_scope": structure_lock.template_reuse_scope,
|
||||
"adherence": structure_lock.template_adherence,
|
||||
"prototype": prototype.template_basename,
|
||||
"prototype_path": _relative_project_path(project_path, prototype.svg_path),
|
||||
"layout": {
|
||||
"key": definition.layout_key,
|
||||
"name": definition.layout_name,
|
||||
"source": (
|
||||
f"P{definition.prototype_slide_num:02d}"
|
||||
if definition.prototype_slide_num is not None
|
||||
else _relative_project_path(
|
||||
project_path,
|
||||
definition.prototype_svg_path,
|
||||
)
|
||||
),
|
||||
},
|
||||
"master": {
|
||||
"key": master.master_key,
|
||||
"name": master.master_name,
|
||||
},
|
||||
}
|
||||
return template, prototype.svg_path
|
||||
|
||||
|
||||
def _reference_payload(
|
||||
kind: str,
|
||||
path: Path,
|
||||
*,
|
||||
scope: str,
|
||||
display_path: str,
|
||||
same_context_edit_policy: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Describe one large reference without injecting its contents per page."""
|
||||
payload = {
|
||||
"kind": kind,
|
||||
"scope": scope,
|
||||
"path": display_path,
|
||||
"sha256": _file_sha256(path),
|
||||
"load_policy": "once-per-execution-context",
|
||||
}
|
||||
if same_context_edit_policy is not None:
|
||||
payload["same_context_edit_policy"] = same_context_edit_policy
|
||||
return payload
|
||||
|
||||
|
||||
def _visualization_reference(
|
||||
value: str,
|
||||
*,
|
||||
allow_legacy_bare: bool,
|
||||
) -> tuple[dict[str, str] | None, Path | None, VisualizationEntry]:
|
||||
"""Resolve one live asset or one legacy intent-only Structure key."""
|
||||
source_section = "page_charts" if allow_legacy_bare else "page_visualizations"
|
||||
try:
|
||||
entry = resolve_visualization_reference(
|
||||
value,
|
||||
allow_legacy_bare=allow_legacy_bare,
|
||||
)
|
||||
except VisualizationCatalogError as exc:
|
||||
raise PageContextError(
|
||||
f"{source_section} value {value!r} cannot resolve a visualization: {exc}"
|
||||
) from exc
|
||||
if entry.kind == LEGACY_STRUCTURE_INTENT_KIND:
|
||||
if not allow_legacy_bare or entry.path is not None:
|
||||
raise PageContextError(
|
||||
f"{source_section} value {value!r} has an invalid legacy "
|
||||
"Structure intent resolution"
|
||||
)
|
||||
return None, None, entry
|
||||
if entry.kind != VISUALIZATION_SVG_KIND or entry.path is None:
|
||||
raise PageContextError(
|
||||
f"{source_section} value {value!r} resolves to unsupported kind "
|
||||
f"{entry.kind!r}"
|
||||
)
|
||||
path = Path(entry.path).resolve()
|
||||
try:
|
||||
display_path = path.relative_to(_SKILL_DIR.resolve()).as_posix()
|
||||
except ValueError as exc:
|
||||
raise PageContextError(
|
||||
f"{source_section} value {value!r} resolves outside the Skill: {path}"
|
||||
) from exc
|
||||
payload = _reference_payload(
|
||||
entry.kind,
|
||||
path,
|
||||
scope="skill",
|
||||
display_path=display_path,
|
||||
)
|
||||
payload.update(
|
||||
{
|
||||
"reference": entry.reference,
|
||||
"family": entry.family,
|
||||
"key": entry.key,
|
||||
}
|
||||
)
|
||||
return payload, path, entry
|
||||
|
||||
|
||||
def build_page_context(project: str | Path, raw_page: str) -> PageContextResult:
|
||||
"""Build one current per-page projection without writing the project."""
|
||||
project_path = Path(project).resolve()
|
||||
if not project_path.is_dir():
|
||||
raise PageContextError(f"project directory not found: {project_path}")
|
||||
page, page_number = normalize_page_key(raw_page)
|
||||
lock_path = project_path / "spec_lock.md"
|
||||
design_path = project_path / "design_spec.md"
|
||||
for required in (lock_path, design_path):
|
||||
if not required.is_file():
|
||||
raise PageContextError(f"required artifact not found: {required.name}")
|
||||
preflight_errors, _preflight_warnings = validate_project_artifacts(
|
||||
project_path,
|
||||
include_design=False,
|
||||
)
|
||||
if preflight_errors:
|
||||
preview = "; ".join(preflight_errors[:8])
|
||||
suffix = (
|
||||
""
|
||||
if len(preflight_errors) <= 8
|
||||
else f"; +{len(preflight_errors) - 8} more"
|
||||
)
|
||||
raise PageContextError(
|
||||
"spec_lock/template preflight failed before page generation: "
|
||||
f"{preview}{suffix}"
|
||||
)
|
||||
try:
|
||||
lock_sections_raw = parse_spec_lock_artifact(
|
||||
lock_path,
|
||||
report_duplicate_fields=True,
|
||||
)
|
||||
design_sections = parse_markdown_artifact(design_path)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise PageContextError(str(exc)) from exc
|
||||
lock_sections = _section_index(lock_sections_raw)
|
||||
part, brief = _slide_block(design_sections, page_number)
|
||||
warnings: list[str] = []
|
||||
rhythm_fields = _section_fields(lock_sections, "page_rhythm")
|
||||
rhythm = rhythm_fields.get(page)
|
||||
if rhythm is None:
|
||||
rhythm = "dense"
|
||||
warnings.append(f"page_rhythm has no {page}; using compatibility default dense")
|
||||
visualization_value = _section_fields(
|
||||
lock_sections,
|
||||
"page_visualizations",
|
||||
).get(page)
|
||||
legacy_chart_key = _section_fields(lock_sections, "page_charts").get(page)
|
||||
if visualization_value is not None and legacy_chart_key is not None:
|
||||
raise PageContextError(
|
||||
f"{page} is declared in both page_visualizations and legacy "
|
||||
"page_charts; keep only page_visualizations"
|
||||
)
|
||||
try:
|
||||
structure_lock = load_pptx_structure_lock(project_path)
|
||||
except TemplateStructureError as exc:
|
||||
raise PageContextError(str(exc)) from exc
|
||||
template, prototype_path = _page_template(
|
||||
project_path,
|
||||
structure_lock,
|
||||
page_number,
|
||||
)
|
||||
prototype_refs = (
|
||||
_prototype_image_refs(prototype_path)
|
||||
if prototype_path is not None
|
||||
else []
|
||||
)
|
||||
table_assigned_filenames, assigned_filenames = _page_image_filenames(
|
||||
design_sections,
|
||||
page_number,
|
||||
)
|
||||
locked_images = _section_fields(lock_sections, "images")
|
||||
resolved_filenames = (
|
||||
{_locked_image_basename(value) for value in locked_images.values()}
|
||||
if structure_lock is not None
|
||||
and structure_lock.template_reuse_scope == "mirror"
|
||||
else table_assigned_filenames
|
||||
| _outline_image_assignments(design_sections, locked_images)
|
||||
)
|
||||
image_selection, selected_images = _page_images(
|
||||
locked_images,
|
||||
brief,
|
||||
(
|
||||
prototype_refs
|
||||
if structure_lock is not None
|
||||
and structure_lock.template_reuse_scope == "mirror"
|
||||
else []
|
||||
),
|
||||
assigned_filenames,
|
||||
resolved_filenames,
|
||||
)
|
||||
inputs = [lock_path, design_path]
|
||||
reference_set: list[dict[str, str]] = [
|
||||
_reference_payload(
|
||||
"design-spec",
|
||||
design_path,
|
||||
scope="project",
|
||||
display_path="design_spec.md",
|
||||
same_context_edit_policy="targeted-readback-and-rebind",
|
||||
),
|
||||
]
|
||||
template_design_path = project_path / "templates" / "design_spec.md"
|
||||
if template_design_path.is_file():
|
||||
inputs.append(template_design_path)
|
||||
reference_set.append(
|
||||
_reference_payload(
|
||||
"template-design-spec",
|
||||
template_design_path,
|
||||
scope="project",
|
||||
display_path="templates/design_spec.md",
|
||||
)
|
||||
)
|
||||
if prototype_path is not None:
|
||||
inputs.append(prototype_path)
|
||||
reference_set.append(
|
||||
_reference_payload(
|
||||
"prototype-svg",
|
||||
prototype_path,
|
||||
scope="project",
|
||||
display_path=_relative_project_path(project_path, prototype_path),
|
||||
)
|
||||
)
|
||||
visualization_entry: VisualizationEntry | None = None
|
||||
selected_visualization = (
|
||||
visualization_value
|
||||
if visualization_value is not None
|
||||
else legacy_chart_key
|
||||
)
|
||||
if selected_visualization is not None:
|
||||
visualization_reference, visualization_path, visualization_entry = (
|
||||
_visualization_reference(
|
||||
selected_visualization,
|
||||
allow_legacy_bare=visualization_value is None,
|
||||
)
|
||||
)
|
||||
if visualization_path is not None and visualization_reference is not None:
|
||||
inputs.append(visualization_path)
|
||||
reference_set.append(visualization_reference)
|
||||
mode_fields = _section_fields(lock_sections, "mode")
|
||||
visual_style_fields = _section_fields(lock_sections, "visual_style")
|
||||
# Each on-demand projection includes bounded lock anchors; large reference
|
||||
# payloads stay outside it and are represented by reference_set.
|
||||
global_context = {
|
||||
"communication": _section_fields(lock_sections, "communication"),
|
||||
"canvas": _section_fields(lock_sections, "canvas"),
|
||||
"mode": mode_fields.get("mode"),
|
||||
"mode_behavior": mode_fields.get("mode_behavior"),
|
||||
"visual_style": visual_style_fields.get("visual_style"),
|
||||
"visual_style_behavior": visual_style_fields.get(
|
||||
"visual_style_behavior"
|
||||
),
|
||||
"colors": _section_fields(lock_sections, "colors"),
|
||||
"typography": _section_fields(lock_sections, "typography"),
|
||||
"icons": _section_fields(lock_sections, "icons"),
|
||||
"pptx_structure": _section_fields(lock_sections, "pptx_structure"),
|
||||
"forbidden": _forbidden_items(lock_sections),
|
||||
}
|
||||
global_context = {
|
||||
key: value
|
||||
for key, value in global_context.items()
|
||||
if value not in ({}, [], None, "")
|
||||
}
|
||||
current_page: dict[str, object] = {
|
||||
"part": part,
|
||||
"brief_markdown": brief,
|
||||
"rhythm": rhythm,
|
||||
"image_selection": image_selection,
|
||||
}
|
||||
if (
|
||||
visualization_entry is not None
|
||||
and visualization_entry.kind == VISUALIZATION_SVG_KIND
|
||||
):
|
||||
current_page["visualization"] = visualization_entry.reference
|
||||
elif (
|
||||
visualization_entry is not None
|
||||
and visualization_entry.kind == LEGACY_STRUCTURE_INTENT_KIND
|
||||
):
|
||||
current_page["structure_intent"] = visualization_entry.key
|
||||
if legacy_chart_key is not None:
|
||||
current_page["chart"] = legacy_chart_key
|
||||
if selected_images:
|
||||
current_page["images"] = selected_images
|
||||
if template is not None:
|
||||
current_page["template"] = template
|
||||
context: dict[str, object] = {
|
||||
"schema": PAGE_CONTEXT_SCHEMA,
|
||||
"page": page,
|
||||
"lock_source": {
|
||||
"path": "spec_lock.md",
|
||||
"sha256": _file_sha256(lock_path),
|
||||
"load_policy": "on-demand-anchor-projection",
|
||||
},
|
||||
"global": global_context,
|
||||
"page_context": current_page,
|
||||
"reference_set": reference_set,
|
||||
}
|
||||
if warnings:
|
||||
context["warnings"] = warnings
|
||||
unique_inputs = tuple(dict.fromkeys(path.resolve() for path in inputs))
|
||||
return PageContextResult(
|
||||
project_path=project_path,
|
||||
page=page,
|
||||
context=context,
|
||||
inputs=unique_inputs,
|
||||
)
|
||||
|
||||
|
||||
def _compact_json(payload: object) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
|
||||
|
||||
def _pretty_json(payload: object) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
|
||||
|
||||
|
||||
def render_page_context(
|
||||
result: PageContextResult,
|
||||
*,
|
||||
bundle: bool = False,
|
||||
pretty: bool = False,
|
||||
) -> tuple[str, tuple[PageRead, ...]]:
|
||||
"""Render compact stdout; ``bundle`` remains a compatibility no-op."""
|
||||
context_payload = (
|
||||
_pretty_json(result.context) if pretty else _compact_json(result.context)
|
||||
)
|
||||
context_read = PageRead(
|
||||
kind="page-context",
|
||||
path="stdout:page-context",
|
||||
payload=context_payload,
|
||||
)
|
||||
return context_payload, (context_read,)
|
||||
|
||||
|
||||
def _sha256_bytes(payload: bytes) -> str:
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def _file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _input_location(project_path: Path, path: Path) -> tuple[str, str]:
|
||||
"""Return a stable project- or Skill-relative locator for telemetry."""
|
||||
resolved = path.resolve()
|
||||
for scope, root in (("project", project_path), ("skill", _SKILL_DIR)):
|
||||
try:
|
||||
return scope, resolved.relative_to(root.resolve()).as_posix()
|
||||
except ValueError:
|
||||
continue
|
||||
raise PageContextError(f"input escapes project and Skill roots: {path}")
|
||||
|
||||
|
||||
def _resolve_input_location(
|
||||
project_path: Path,
|
||||
scope: str,
|
||||
relative_path: str,
|
||||
) -> Path | None:
|
||||
"""Resolve one recorded input without accepting arbitrary filesystem roots."""
|
||||
roots = {"project": project_path, "skill": _SKILL_DIR}
|
||||
root = roots.get(scope)
|
||||
if root is None:
|
||||
return None
|
||||
resolved = (root / relative_path).resolve()
|
||||
try:
|
||||
resolved.relative_to(root.resolve())
|
||||
except ValueError:
|
||||
return None
|
||||
return resolved
|
||||
|
||||
|
||||
def _token_counter() -> tuple[Callable[[str], int] | None, str]:
|
||||
try:
|
||||
import tiktoken
|
||||
except ImportError:
|
||||
return None, "unavailable"
|
||||
try:
|
||||
encoder = tiktoken.get_encoding(TOKEN_ENCODING)
|
||||
except Exception:
|
||||
return None, "unavailable"
|
||||
return (
|
||||
lambda text: len(encoder.encode(text, disallowed_special=())),
|
||||
"exact",
|
||||
)
|
||||
|
||||
|
||||
def _payload_measurement(
|
||||
read: PageRead,
|
||||
count_tokens: Callable[[str], int] | None,
|
||||
) -> dict[str, object]:
|
||||
payload = read.payload.encode("utf-8")
|
||||
measurement: dict[str, object] = {
|
||||
"kind": read.kind,
|
||||
"scope": "component" if read.kind == "lock-projection" else "page",
|
||||
"path": read.path,
|
||||
"sha256": _sha256_bytes(payload),
|
||||
"utf8_bytes": len(payload),
|
||||
"characters": len(read.payload),
|
||||
"tokens": count_tokens(read.payload) if count_tokens else None,
|
||||
}
|
||||
return measurement
|
||||
|
||||
|
||||
def record_page_context_usage(
|
||||
result: PageContextResult,
|
||||
output: str,
|
||||
measured_reads: tuple[PageRead, ...],
|
||||
) -> tuple[Path, str]:
|
||||
"""Write one deterministic, derived token snapshot for the current page."""
|
||||
count_tokens, token_status = _token_counter()
|
||||
lock_read = PageRead(
|
||||
kind="lock-projection",
|
||||
path="stdout:global",
|
||||
payload=_compact_json(result.context["global"]),
|
||||
)
|
||||
documents = [
|
||||
_payload_measurement(read, count_tokens)
|
||||
for read in (*measured_reads, lock_read)
|
||||
]
|
||||
output_bytes = output.encode("utf-8")
|
||||
input_records: list[dict[str, object]] = []
|
||||
for path in result.inputs:
|
||||
scope, relative_path = _input_location(result.project_path, path)
|
||||
input_records.append(
|
||||
{
|
||||
"scope": scope,
|
||||
"path": relative_path,
|
||||
"exists": True,
|
||||
"sha256": _file_sha256(path),
|
||||
}
|
||||
)
|
||||
by_kind = {
|
||||
str(item["kind"]): item.get("tokens")
|
||||
for item in documents
|
||||
}
|
||||
route = dict(result.context["global"].get("pptx_structure", {}))
|
||||
template = result.context["page_context"].get("template")
|
||||
if isinstance(template, dict):
|
||||
if isinstance(value := template.get("reuse_scope"), str):
|
||||
route["template_reuse_scope"] = value
|
||||
usage = {
|
||||
"schema": PAGE_CONTEXT_USAGE_SCHEMA,
|
||||
"page": result.page,
|
||||
"output_mode": "compact",
|
||||
"route": route,
|
||||
"encoding": TOKEN_ENCODING,
|
||||
"token_status": token_status,
|
||||
"image_selection": result.context["page_context"]["image_selection"],
|
||||
"inputs": input_records,
|
||||
"references": result.context.get("reference_set", []),
|
||||
"documents": documents,
|
||||
"controlled_output": {
|
||||
"sha256": _sha256_bytes(output_bytes),
|
||||
"utf8_bytes": len(output_bytes),
|
||||
"characters": len(output),
|
||||
"tokens": count_tokens(output) if count_tokens else None,
|
||||
},
|
||||
"totals": {
|
||||
"page_context": by_kind.get("page-context"),
|
||||
"lock_projection": by_kind.get("lock-projection"),
|
||||
},
|
||||
"targets": {
|
||||
"page_context_max_tokens": PAGE_CONTEXT_TOKEN_TARGET,
|
||||
"lock_projection_max_tokens": LOCK_PROJECTION_TOKEN_TARGET,
|
||||
},
|
||||
"untracked": [
|
||||
"source-material reads",
|
||||
"once-per-execution-context reference payloads",
|
||||
"other session-level prompt references",
|
||||
],
|
||||
}
|
||||
usage_dir = result.project_path / "analysis" / "page-context"
|
||||
usage_dir.mkdir(parents=True, exist_ok=True)
|
||||
usage_path = usage_dir / f"{result.page}.usage.json"
|
||||
temporary_path = usage_path.with_suffix(".usage.json.tmp")
|
||||
temporary_path.write_text(_pretty_json(usage), encoding="utf-8")
|
||||
temporary_path.replace(usage_path)
|
||||
return usage_path, token_status
|
||||
|
||||
|
||||
def _nearest_rank(values: list[int], percentile: float) -> int:
|
||||
rank = max(1, math.ceil(percentile * len(values)))
|
||||
return sorted(values)[rank - 1]
|
||||
|
||||
|
||||
def _metric(values: list[int], *, target: int | None = None) -> dict[str, object]:
|
||||
if not values:
|
||||
return {
|
||||
"count": 0,
|
||||
"sum": 0,
|
||||
"min": None,
|
||||
"p50": None,
|
||||
"p95": None,
|
||||
"max": None,
|
||||
**({"over_target_count": 0, "target": target} if target else {}),
|
||||
}
|
||||
metric: dict[str, object] = {
|
||||
"count": len(values),
|
||||
"sum": sum(values),
|
||||
"min": min(values),
|
||||
"p50": round(statistics.median(values)),
|
||||
"p95": _nearest_rank(values, 0.95),
|
||||
"max": max(values),
|
||||
}
|
||||
if target is not None:
|
||||
metric.update({
|
||||
"target": target,
|
||||
"over_target_count": sum(value > target for value in values),
|
||||
})
|
||||
return metric
|
||||
|
||||
|
||||
def page_context_usage_report(project: str | Path) -> dict[str, object]:
|
||||
"""Summarize fresh per-page telemetry without changing recorded history."""
|
||||
project_path = Path(project).resolve()
|
||||
usage_dir = project_path / "analysis" / "page-context"
|
||||
records: list[dict[str, object]] = []
|
||||
stale_pages: list[str] = []
|
||||
unavailable_pages: list[str] = []
|
||||
if usage_dir.is_dir():
|
||||
for usage_path in sorted(usage_dir.glob("P*.usage.json")):
|
||||
try:
|
||||
record = json.loads(usage_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
stale_pages.append(usage_path.stem.split(".", 1)[0])
|
||||
continue
|
||||
page = str(record.get("page", usage_path.stem.split(".", 1)[0]))
|
||||
if record.get("schema") != PAGE_CONTEXT_USAGE_SCHEMA:
|
||||
stale_pages.append(page)
|
||||
continue
|
||||
if record.get("output_mode") != "compact":
|
||||
stale_pages.append(page)
|
||||
continue
|
||||
stale = False
|
||||
for item in record.get("inputs", []):
|
||||
if not isinstance(item, dict):
|
||||
stale = True
|
||||
break
|
||||
source_path = _resolve_input_location(
|
||||
project_path,
|
||||
str(item.get("scope", "project")),
|
||||
str(item.get("path", "")),
|
||||
)
|
||||
if source_path is None:
|
||||
stale = True
|
||||
break
|
||||
expected_exists = item.get("exists", True)
|
||||
if expected_exists is False:
|
||||
if source_path.exists():
|
||||
stale = True
|
||||
break
|
||||
elif (
|
||||
not source_path.is_file()
|
||||
or _file_sha256(source_path) != item.get("sha256")
|
||||
):
|
||||
stale = True
|
||||
break
|
||||
if stale:
|
||||
stale_pages.append(page)
|
||||
continue
|
||||
if record.get("token_status") != "exact":
|
||||
unavailable_pages.append(page)
|
||||
records.append(record)
|
||||
|
||||
def tokens_for(kind: str) -> list[int]:
|
||||
values: list[int] = []
|
||||
for record in records:
|
||||
for document in record.get("documents", []):
|
||||
if not isinstance(document, dict) or document.get("kind") != kind:
|
||||
continue
|
||||
value = document.get("tokens")
|
||||
if isinstance(value, int):
|
||||
values.append(value)
|
||||
return values
|
||||
|
||||
controlled = [
|
||||
value
|
||||
for record in records
|
||||
if isinstance(
|
||||
value := record.get("controlled_output", {}).get("tokens"),
|
||||
int,
|
||||
)
|
||||
]
|
||||
unique_references = sorted({
|
||||
f"{reference.get('scope', 'project')}:{reference.get('path', '')}"
|
||||
for record in records
|
||||
for reference in record.get("references", [])
|
||||
if isinstance(reference, dict) and reference.get("path")
|
||||
})
|
||||
return {
|
||||
"schema": PAGE_CONTEXT_REPORT_SCHEMA,
|
||||
"project": project_path.name,
|
||||
"record_count": len(records),
|
||||
"pages": sorted(str(record["page"]) for record in records),
|
||||
"stale_pages": sorted(set(stale_pages)),
|
||||
"token_unavailable_pages": sorted(set(unavailable_pages)),
|
||||
"unique_reference_count": len(unique_references),
|
||||
"unique_references": unique_references,
|
||||
"metrics": {
|
||||
"page_context": _metric(
|
||||
tokens_for("page-context"),
|
||||
target=PAGE_CONTEXT_TOKEN_TARGET,
|
||||
),
|
||||
"lock_projection": _metric(
|
||||
tokens_for("lock-projection"),
|
||||
target=LOCK_PROJECTION_TOKEN_TARGET,
|
||||
),
|
||||
"controlled_output": _metric(controlled),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Project Management Paths
|
||||
|
||||
Own the repository and Skill resource roots used by project-management modules.
|
||||
|
||||
Usage:
|
||||
Import the required path constants from project_management.paths.
|
||||
|
||||
Examples:
|
||||
from project_management.paths import PROJECTS_ROOT, SCHEMA_DIR
|
||||
|
||||
Dependencies:
|
||||
None (only uses the standard library)
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
PACKAGE_DIR = Path(__file__).resolve().parent
|
||||
SCRIPTS_DIR = PACKAGE_DIR.parent
|
||||
SKILL_DIR = SCRIPTS_DIR.parent
|
||||
REPO_ROOT = SKILL_DIR.parent.parent
|
||||
PROJECTS_ROOT = REPO_ROOT / "projects"
|
||||
SOURCE_TO_MD_DIR = SCRIPTS_DIR / "source_to_md"
|
||||
CHARTS_DIR = SKILL_DIR / "templates" / "charts"
|
||||
SCHEMA_DIR = SKILL_DIR / "templates" / "schemas"
|
||||
SCAFFOLD_DIR = SKILL_DIR / "templates" / "scaffolds"
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user