Files
workinf_Blender_Wasm/my-tools/web_context.py
2026-09-02 18:35:58 +08:00

172 lines
7.4 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Bounded context helpers for the Blender/WBV2 task queue.
The production context implementation lives in `tools/web/task-context-lib.mjs`.
These helpers provide a small Python-compatible surface for local scripts and
never load the full Blender source tree or historical evidence.
"""
from __future__ import annotations
import hashlib
import json
import re
from pathlib import Path
from typing import Any, Iterable
ROOT = Path(__file__).resolve().parents[1]
DOCS = ROOT / "docs"
TASKS = DOCS / "tasks"
CONTROL_ROOT = ROOT / "tests/golden/WBV2"
QUEUE = DOCS / "EXECUTION_QUEUE.md"
INDEX = CONTROL_ROOT / "task-index.json"
INVENTORY = CONTROL_ROOT / "dynamic-inventory.json"
CARD_ID = re.compile(r"^#\s+(WBV2-P\d+-\d{3})", re.MULTILINE)
CARD_STATUS = re.compile(r"^-\s*(?:status|状态):\s*`?([a-z_]+)`?", re.MULTILINE | re.IGNORECASE)
PARENT = re.compile(r"^-\s*parent:\s*`?([^`\s]+)", re.MULTILINE | re.IGNORECASE)
PATH_TOKEN = re.compile(r"(?:[A-Za-z0-9_.-]+/)+[A-Za-z0-9_.-]+(?:\.[A-Za-z0-9_.-]+)?")
RAW_SUFFIXES = (".log", ".trace", ".out", ".err")
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def load_cards() -> dict[str, tuple[Path, str, str]]:
cards: dict[str, tuple[Path, str, str]] = {}
for path in sorted(TASKS.glob("WBV2-P*.md")):
text = path.read_text(encoding="utf-8", errors="replace")
card = CARD_ID.search(text)
if not card:
continue
status_match = CARD_STATUS.search(text)
status = status_match.group(1).lower() if status_match else "unknown"
cards[card.group(1)] = (path, status, text)
return cards
def predecessor_ids(text: str) -> list[str]:
parent = PARENT.search(text)
return [parent.group(1)] if parent and parent.group(1).startswith("WBV2-") else []
def field_values(text: str, field_name: str) -> list[str]:
pattern = re.compile(rf"^-\s*{re.escape(field_name)}\s*[::](.*)$", re.MULTILINE)
return [match.group(1).strip() for match in pattern.finditer(text)]
def resolve_repo_path(candidate: str, origin: Path | None = None) -> Path | None:
candidate = candidate.strip().strip("`[]()")
if candidate.startswith(("http://", "https://")):
return None
candidates = []
if origin is not None:
candidates.append(origin.parent / candidate)
candidates.extend((ROOT / candidate, DOCS / candidate))
for path in candidates:
try:
path = path.resolve()
path.relative_to(ROOT.resolve())
except (OSError, ValueError):
continue
if path.is_file():
return path
return None
def extract_referenced_files(texts: Iterable[tuple[str, Path | None]]) -> list[Path]:
found: dict[str, Path] = {}
for text, origin in texts:
for candidate in PATH_TOKEN.findall(text):
path = resolve_repo_path(candidate, origin)
if path:
found[str(path)] = path
return sorted(found.values(), key=lambda path: str(path))
def classify_path(path: Path) -> tuple[str, str | None]:
rel = path.relative_to(ROOT).as_posix()
if path.name.endswith(RAW_SUFFIXES):
return "excluded", "raw log or trace"
if rel.startswith("blender-5.2.0/source/"):
return "excluded", "source tree is inspected by the task command"
if path.stat().st_size > 8 * 1024:
return "metadata_only", "evidence exceeds the context file cap"
return "read", None
def _queue_files(task_id: str, card: tuple[Path, str, str]) -> list[tuple[str, Path]]:
files: list[tuple[str, Path]] = [("queue", QUEUE), ("task_card", card[0])]
manifest = CONTROL_ROOT.parent / task_id / "manifest.json"
if manifest.is_file():
files.append(("task_manifest", manifest))
value = json.loads(manifest.read_text(encoding="utf-8"))
parent = value.get("parentTask")
parent_manifest = CONTROL_ROOT.parent / str(parent) / "manifest.json" if parent else None
if parent_manifest and parent_manifest.is_file():
files.append(("parent_manifest", parent_manifest))
return files
def context_manifest(task_id: str, *, include_evidence: bool = True, cards: dict[str, tuple[Path, str, str]] | None = None) -> dict[str, Any]:
cards = cards or load_cards()
if task_id not in cards:
raise KeyError(f"unknown task card: {task_id}")
card = cards[task_id]
entries: list[dict[str, Any]] = []
excluded: list[dict[str, str]] = []
for role, path in _queue_files(task_id, card):
kind, reason = classify_path(path)
if kind == "read":
entries.append({"role": role, "path": str(path.relative_to(ROOT)), "bytes": path.stat().st_size, "sha256": sha256(path), "mode": "read"})
else:
excluded.append({"path": str(path.relative_to(ROOT)), "reason": reason or kind})
if include_evidence:
for path in extract_referenced_files([(card[2], card[0])]):
if any(item["path"] == str(path.relative_to(ROOT)) for item in entries):
continue
kind, reason = classify_path(path)
if kind == "read":
entries.append({"role": "direct_evidence", "path": str(path.relative_to(ROOT)), "bytes": path.stat().st_size, "sha256": sha256(path), "mode": "read"})
else:
excluded.append({"path": str(path.relative_to(ROOT)), "reason": reason or kind})
total_bytes = sum(item["bytes"] for item in entries)
evidence = [item for item in entries if item["role"] == "direct_evidence"]
return {
"schema": "blender-web-task-context/v1",
"task_id": task_id,
"status": card[1],
"active_task": queue_pointer().get("task"),
"files": entries,
"excluded": excluded,
"totals": {"bytes": total_bytes, "estimated_tokens": total_bytes // 4, "evidence_bytes": sum(item["bytes"] for item in evidence), "evidence_files": len(evidence)},
"budget": {"total_bytes_limit": 16 * 1024, "evidence_bytes_limit": 12 * 1024, "evidence_file_limit": 6},
"read_order": ["queue", "task_card", "parent_manifest", "direct_evidence"],
}
def queue_pointer() -> dict[str, str | None]:
text = QUEUE.read_text(encoding="utf-8")
task = re.search(r"\| 当前任务 \| `([^`]+)` \|", text)
parent = re.search(r"\| parent manifest \| `([^`]+)` \|", text, re.IGNORECASE)
return {"task": task.group(1) if task else None, "parentManifest": parent.group(1) if parent else None}
def write_context_manifest(task_id: str, output: Path | None = None, manifest: dict[str, Any] | None = None) -> Path:
output = output or (CONTROL_ROOT.parent / task_id / "my-tools-context.json")
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(manifest or context_manifest(task_id), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return output
def write_context_summary(task_id: str, manifest: dict[str, Any] | None = None) -> Path:
manifest = manifest or context_manifest(task_id)
output = CONTROL_ROOT / "my-tools-context-summary.md"
lines = ["# Blender Web 最小续接上下文", "", f"- 当前任务:`{task_id}`(`{manifest['status']}`)", "- 只读取以下 mode=read 文件:"]
lines.extend(f" - `{item['path']}`" for item in manifest["files"] if item["mode"] == "read")
lines.append(f"- 清单 hash:`{sha256(write_context_manifest(task_id, manifest=manifest))}`")
output.write_text("\n".join(lines) + "\n", encoding="utf-8")
return output