#!/usr/bin/env python3 """Shared parsing and bounded-context helpers for the Web task system. The task cards are intentionally human-readable. This module turns the references in a card into a small, auditable context closure without ever loading project-wide source trees, raw logs, or full inventories. """ 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" CURRENT = DOCS / "current" TASKS = DOCS / "tasks" HANDOFF = CURRENT / "FREECAD_WEB_HANDOFF.md" MIN_CONTEXT = CURRENT / "FREECAD_WEB_CONTEXT_MIN.md" STATE = CURRENT / "task-state.json" BUDGET = CURRENT / "FREECAD_WEB_CONTEXT_BUDGET.json" CARD_ID = re.compile(r"^#\s+(FW-[^:: ]+)", re.MULTILINE) CARD_STATUS = re.compile(r"^[-*]\s*状态:`([^`]+)`", re.MULTILINE) PREDECESSORS = re.compile(r"^[-*]\s*前置:(.*)$", re.MULTILINE) FIELD = re.compile(r"^[-*]\s*([^::]+)[::](.*)$", re.MULTILINE) ID_TOKEN = re.compile(r"FW-[A-Z0-9-]+") # These files are useful audit inputs but are too broad to be task context. BROAD_NAMES = { "format-api-inventory.json", "object-inventory.json", "workbench-inventory.json", "dependency-closure.json", "web-parity-matrix.json", "web-parity-matrix.md", } RAW_SUFFIXES = (".log", ".trace", ".out", ".err") SOURCE_PREFIXES = ("freecad-source/", "src/", "FreeCAD/freecad-source/") def load_budget() -> dict[str, Any]: return json.loads(BUDGET.read_text(encoding="utf-8")) def load_cards(task_ids: Iterable[str] | None = None) -> dict[str, tuple[Path, str, str]]: """Load all cards or only the requested IDs. Continuation turns need only the active/planned cards and direct prerequisites. Listing paths is cheap; reading every card body on each context refresh was a measurable gate-side cost. Full callers retain the original behavior by omitting ``task_ids``. """ cards: dict[str, tuple[Path, str, str]] = {} paths = sorted(TASKS.rglob("*.md")) if task_ids is not None: wanted = {str(item).lower() for item in task_ids if item} selected = [path for path in paths if path.stem.lower() in wanted] # Module package cards use human-readable lowercase filenames (for # example ``FW-P6-cam.md``) while their canonical IDs are uppercase. # Resolve those few IDs from the compact P6 index without reading all # task bodies. index_path = TASKS / "p6" / "INDEX.md" try: index_text = index_path.read_text(encoding="utf-8", errors="replace") except OSError: index_text = "" for match in re.finditer(r"\[([^\]]+)\]\(([^)]+)\)", index_text): if match.group(1).lower() not in wanted: continue candidate = (index_path.parent / match.group(2)).resolve() if candidate.is_file() and candidate not in selected: selected.append(candidate) paths = sorted(selected) for path in paths: if path.name in {"INDEX.md", "TASK_CARD_TEMPLATE.md"}: continue text = path.read_text(encoding="utf-8", errors="replace") card = CARD_ID.search(text) status = CARD_STATUS.search(text) if card and status: cards[card.group(1)] = (path, status.group(1), text) return cards def predecessor_ids(text: str) -> list[str]: match = PREDECESSORS.search(text) return ID_TOKEN.findall(match.group(1)) if match else [] def field_values(text: str, field_name: str) -> list[str]: return [match.group(2).strip() for match in FIELD.finditer(text) if match.group(1).strip() == field_name] def _path_candidates(value: str) -> Iterable[str]: # Markdown links, backticks and plain relative paths are all accepted. for target in re.findall(r"\]\(([^)]+)\)", value): target = target.split("#", 1)[0].strip() if target: yield target for candidate in re.findall(r"`([^`]+)`", value): candidate = candidate.split("#", 1)[0].strip("[]") if candidate: yield candidate for candidate in re.findall(r"<([^>]+)>|([^\s,;`()]+)", value): candidate = next((part for part in candidate if part), "") candidate = candidate.split("#", 1)[0].strip("[]") if candidate: yield candidate def resolve_repo_path(candidate: str, origin: Path | None = None) -> Path | None: candidate = candidate.replace("\\", "/") if candidate.startswith("https://") or candidate.startswith("http://"): return None # Links in task cards are relative to docs/tasks/; normalize the # common docs-relative and repository-relative spellings. options = [] if origin is not None: options.append(origin.parent / candidate) options.extend([ROOT / candidate, DOCS / candidate, TASKS / candidate]) if candidate.startswith("../") and origin is None: # Most task-card links are relative to docs/tasks/. Try each # module directory as the origin, then the docs root. options = [ *(TASKS / module / candidate for module in ("p6", "cam", "bim", "assembly")), DOCS / candidate, *options, ] for path in options: try: resolved = path.resolve() resolved.relative_to(ROOT.resolve()) except (OSError, ValueError): continue if resolved.exists() and resolved.is_file(): return resolved return None def classify_path(path: Path) -> tuple[str, str | None]: rel = path.relative_to(ROOT).as_posix() name = path.name if rel == "docs/current/module-batch-manifest.json": return "read", None if name in BROAD_NAMES: return "excluded", "broad inventory or matrix" if name.endswith(RAW_SUFFIXES): return "excluded", "raw log/trace" if any(rel.startswith(prefix) for prefix in SOURCE_PREFIXES): return "excluded", "source tree is inspected by the task command, not loaded as context" if "/archive/" in rel or rel.startswith("docs/archive/"): return "excluded", "historical archive" # A task may cite a large JSON artifact for audit, but loading the whole # artifact defeats bounded context. Keep only compact summaries in L2. if path.stat().st_size > 4 * 1024: return "metadata_only", "evidence exceeds per-file context cap" return "read", None def sha256(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def extract_referenced_files(texts: Iterable[tuple[str, Path | None]]) -> list[Path]: found: dict[str, Path] = {} for text, origin in texts: for field_name in ("输入", "产物", "证据", "复现命令", "命令/方法"): for value in field_values(text, field_name): for candidate in _path_candidates(value): path = resolve_repo_path(candidate, origin) if path: found[str(path)] = path return sorted(found.values(), key=lambda path: str(path)) 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([task_id]) if task_id not in cards: raise KeyError(f"unknown task card: {task_id}") state = json.loads(STATE.read_text(encoding="utf-8")) budget = load_budget() card_path, status, card_text = cards[task_id] predecessor_list = predecessor_ids(card_text) missing_predecessors = [item for item in predecessor_list if item not in cards] if missing_predecessors: cards.update(load_cards(missing_predecessors)) entries: list[dict[str, Any]] = [] excluded: list[dict[str, str]] = [] def add(path: Path, role: str, task: str | None = None, mode: str = "read") -> None: kind, reason = classify_path(path) if mode == "read" and kind != "read": excluded.append({"path": str(path.relative_to(ROOT)), "reason": reason or kind}) return file_stat = path.stat() entries.append( { "role": role, "task_id": task, "path": str(path.relative_to(ROOT)), "bytes": file_stat.st_size, "mtime_ns": file_stat.st_mtime_ns, "sha256": sha256(path), "mode": mode if kind == "read" else kind, } ) add(HANDOFF, "handoff") add(card_path, "task_card", task_id) batch_path = CURRENT / "module-batch-manifest.json" if batch_path.exists() and state.get("active_task") == task_id: add(batch_path, "module_batch") for predecessor in predecessor_list: if predecessor in cards: add(cards[predecessor][0], "direct_prerequisite_card", predecessor) else: excluded.append({"path": predecessor, "reason": "missing prerequisite card"}) if include_evidence: evidence_texts = [(card_text, card_path)] + [ (cards[item][2], cards[item][0]) for item in predecessor_list if item in cards ] for path in extract_referenced_files(evidence_texts): if path in {HANDOFF, card_path} or any(path == cards[item][0] for item in predecessor_list if item in cards): continue kind, reason = classify_path(path) if kind == "read": add(path, "direct_evidence") else: excluded.append({"path": str(path.relative_to(ROOT)), "reason": reason or kind}) # A context packet must remain small even if a card accidentally names many # artifacts. Keep the mandatory L0/L1 files first, then greedily select the # smallest evidence files that fit *both* evidence and total-byte budgets. # The old implementation applied only the file-count cap, which allowed a # handful of large JSON files to overflow the packet before validation. limits = budget["limits"] max_files = limits["direct_evidence_file_count"] evidence = [item for item in entries if item["role"] == "direct_evidence"] base_bytes = sum(item["bytes"] for item in entries if item["role"] != "direct_evidence") available_evidence_bytes = max(0, limits["task_context_bytes"] - base_bytes) available_evidence_bytes = min(available_evidence_bytes, limits["direct_evidence_bytes"]) keep_evidence: list[dict[str, Any]] = [] keep_paths: set[str] = set() selected_bytes = 0 for item in sorted(evidence, key=lambda item: (item["bytes"], item["path"])): if len(keep_evidence) >= max_files: excluded.append({"path": item["path"], "reason": "evidence file-count cap"}) continue if selected_bytes + item["bytes"] > available_evidence_bytes: excluded.append({"path": item["path"], "reason": "evidence byte budget"}) continue keep_evidence.append(item) keep_paths.add(item["path"]) selected_bytes += item["bytes"] entries = [ item for item in entries if item["role"] != "direct_evidence" or item["path"] in keep_paths ] # Keep the active card and L0 handoff authoritative, then fit completed # prerequisite cards into the bounded packet. The dependency graph remains # unchanged; omitted cards are explicitly recorded in excluded so a fresh # executor never mistakes a compact packet for missing prerequisites. total_bytes = sum(item["bytes"] for item in entries) if total_bytes > limits["task_context_bytes"]: prerequisite_entries = [item for item in entries if item["role"] == "direct_prerequisite_card"] for item in sorted(prerequisite_entries, key=lambda value: (-value["bytes"], value["path"])): if total_bytes <= limits["task_context_bytes"]: break entries.remove(item) total_bytes -= item["bytes"] excluded.append({"path": item["path"], "reason": "prerequisite byte budget; dependency retained in task card"}) total_bytes = sum(item["bytes"] for item in entries) evidence_bytes = sum(item["bytes"] for item in entries if item["role"] == "direct_evidence") return { "schema": "freecad-web-task-context/v2", "task_id": task_id, "status": status, "active_task": state.get("active_task"), "source_matrix_sha256": state.get("source_matrix_sha256"), "budget": { "total_bytes_limit": limits["task_context_bytes"], "evidence_bytes_limit": limits["direct_evidence_bytes"], "evidence_file_limit": limits["direct_evidence_file_count"], "estimated_tokens": total_bytes // budget["bytes_per_token_estimate"], }, "files": entries, "excluded": excluded, "totals": { "bytes": total_bytes, "estimated_tokens": total_bytes // budget["bytes_per_token_estimate"], "evidence_bytes": evidence_bytes, "evidence_files": len(keep_evidence), "direct_prerequisites": len(predecessor_list), }, "read_order": [ "handoff", "task_card", "direct_prerequisite_card", "direct_evidence", ], "instructions": [ "只读取 files 中 mode=read 的路径;excluded 不得加载。", "原始日志、源码树和全量 inventory 只能按任务命令定点检查,不得复制进对话上下文。", "若 totals 超限,先拆卡或生成更小的 JSON 摘要,不得放宽预算。", ], } def write_context_manifest( task_id: str, output: Path | None = None, manifest: dict[str, Any] | None = None, ) -> Path: """Write one context snapshot without recomputing it when provided.""" if manifest is None: manifest = context_manifest(task_id) output = output or (CURRENT / "context" / f"{task_id}.json") output.parent.mkdir(parents=True, exist_ok=True) output.write_text(json.dumps(manifest, 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: """Write the small human-facing continuation file for the active task.""" if manifest is None: manifest = context_manifest(task_id) manifest_path = CURRENT / "context" / f"{task_id}.json" manifest_hash = sha256(manifest_path) if manifest_path.exists() else "" read_files = [item["path"] for item in manifest["files"] if item["mode"] == "read"] excluded = [item["path"] for item in manifest["excluded"]] lines = [ "# FreeCAD Web 最小续接上下文", "", f"- 当前任务:`{task_id}`(`{manifest['status']}`)", f"- 上下文包:`{manifest['totals']['bytes']}B`,约 `{manifest['totals']['estimated_tokens']}` token", "- 只读取以下文件;不要打开历史归档、完整 inventory、源码树或原始日志:", ] lines.extend(f" - `{path}`" for path in read_files) if excluded: lines.append("- 已排除(禁止装入上下文):") lines.extend(f" - `{path}`" for path in excluded) lines.extend( [ "- 完成或阻断当前卡后,先更新任务卡和状态账本,再重新生成本摘要。", f"- 清单 hash:`{manifest_hash}`", ] ) MIN_CONTEXT.write_text("\n".join(lines) + "\n", encoding="utf-8") return MIN_CONTEXT