Files
QuantEngineByItz/tools/build_bundle.py
kjh2064 a1bbeb99a6
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (pull_request) Failing after 4s
Quant Engine CI/CD Pipeline / validate-core (pull_request) Failing after 2m18s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (pull_request) Has been skipped
chore: 최상위 룰 매니페스트 파일을 spec/ 폴더로 정리하고 도구 경로 참조 수정
2026-06-26 11:40:51 +09:00

219 lines
8.2 KiB
Python

from __future__ import annotations
import hashlib
import json
from pathlib import Path
import yaml
from orchestration_harness_v1 import run_plan
from pipeline_runtime_anomaly_lib_v1 import finalize_runtime_profile, runtime_profile_from_steps
ROOT = Path(__file__).resolve().parents[1]
DIST = ROOT / "dist"
RUNTIME_PROFILE = ROOT / "Temp" / "build_bundle_runtime_profile_v1.json"
CACHE_PATH = ROOT / "Temp" / "build_bundle_cache_v1.json"
def read(path: str) -> str:
return (ROOT / path).read_text(encoding="utf-8").rstrip()
def load_bundle_contents(paths: list[str]) -> dict[str, str]:
contents: dict[str, str] = {}
for path in paths:
contents[path] = read(path)
return contents
def compute_content_signature(contents: dict[str, str]) -> str:
digest = hashlib.sha256()
for path in sorted(contents):
digest.update(path.encode("utf-8"))
digest.update(b"\0")
digest.update(contents[path].encode("utf-8"))
digest.update(b"\0")
return digest.hexdigest()
def flatten_manifest_files(manifest: dict) -> list[str]:
ordered: list[str] = ["spec/RetirementAssetPortfolio.yaml", "AGENTS.md"]
for step in (manifest.get("load_sequence") or {}).values():
for file_name in step.get("files", []):
if "*" not in file_name and file_name not in ordered:
ordered.append(file_name)
for extra in (
"spec/ownership_map.yaml",
"spec/aliases.yaml",
"spec/xref_matrix.yaml",
"prompts/analysis_prompt.md",
"prompts/review_prompt.md",
):
if extra not in ordered and (ROOT / extra).exists():
ordered.append(extra)
return ordered
def bundle_profile(manifest: dict, mode: str) -> dict:
if mode == "full":
return {
"output": "dist/retirement_portfolio_bundle.yaml",
"purpose": "분리된 문서를 manifest 순서로 묶은 전체 LLM 입력용 합본",
"files": flatten_manifest_files(manifest),
}
profiles = manifest.get("bundle_profiles") or {}
profile = profiles.get(mode)
if not isinstance(profile, dict):
raise ValueError(f"missing bundle profile: {mode}")
return profile
def write_bundle(
paths: list[str],
output: Path,
mode: str = "full",
purpose_text: str | None = None,
*,
contents: dict[str, str] | None = None,
) -> None:
title_suffix = {
"full": "Full",
"compact": "Compact",
"ultra_compact": "Ultra Compact",
}[mode]
purpose = purpose_text or "분리된 문서를 manifest 순서로 묶은 LLM 입력용 합본"
chunks = [
"meta:",
f" title: \"은퇴자산포트폴리오 {title_suffix} Bundle\"",
" generated_by: \"tools/build_bundle.py\"",
f" purpose: \"{purpose}\"",
f" mode: \"{mode}\"",
f" file_count: {len(paths)}",
"",
"bundle_files:",
]
for path in paths:
chunks.append(f" - \"{path}\"")
chunks.append("")
chunks.append("bundle_content:")
for path in paths:
text = contents[path] if contents and path in contents else read(path)
indented = "\n".join(" " + line for line in text.splitlines())
key = path.replace("\\", "/").replace("/", "__").replace(".", "_")
chunks.append(f" {key}: |")
chunks.append(indented if indented else " ")
output.write_text("\n".join(chunks).rstrip() + "\n", encoding="utf-8")
def _write_bundle_job(mode: str, profile: dict, contents: dict[str, str]) -> dict[str, str]:
output = ROOT / profile["output"]
paths = [path for path in profile["files"] if "*" not in path]
write_bundle(paths, output, mode=mode, purpose_text=profile.get("purpose"), contents=contents)
return {"mode": mode, "output": str(output), "status": "OK"}
def _validate_bundle(path: Path) -> dict[str, str]:
yaml.safe_load(path.read_text(encoding="utf-8"))
return {"path": str(path), "status": "OK"}
def load_cache() -> dict[str, str]:
if not CACHE_PATH.exists():
return {}
try:
data = json.loads(CACHE_PATH.read_text(encoding="utf-8"))
except Exception:
return {}
return data if isinstance(data, dict) else {}
def save_cache(payload: dict[str, str]) -> None:
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
CACHE_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def main() -> int:
DIST.mkdir(exist_ok=True)
# 1) 공통 입력 로드는 순차로 고정한다.
manifest = yaml.safe_load((ROOT / "spec" / "RetirementAssetPortfolio.yaml").read_text(encoding="utf-8"))
profiles = {mode: bundle_profile(manifest, mode) for mode in ("full", "compact", "ultra_compact")}
all_profile_paths = [path for profile in profiles.values() for path in profile.get("files", [])]
all_unique_paths = sorted({path for path in all_profile_paths if "*" not in path})
missing = [path for path in all_profile_paths if "*" not in path and not (ROOT / path).exists()]
if missing:
print("BUNDLE BUILD FAIL")
for path in missing:
print(f"- missing: {path}")
return 1
shared_contents = load_bundle_contents(all_unique_paths)
content_signature = compute_content_signature(shared_contents)
latest_source_mtime = max((ROOT / path).stat().st_mtime for path in all_unique_paths) if all_unique_paths else 0.0
cache = load_cache()
output_paths = [ROOT / profiles[mode]["output"] for mode in ("full", "compact", "ultra_compact")]
cache_hit = bool(cache) and all(path.exists() for path in output_paths) and min(path.stat().st_mtime for path in output_paths) >= latest_source_mtime
bundle_paths = [
ROOT / profiles["full"]["output"],
ROOT / profiles["compact"]["output"],
ROOT / profiles["ultra_compact"]["output"],
]
if cache_hit:
# 소스와 산출물 해시가 같으면 기존 번들을 재사용한다.
orchestration_steps = []
for mode, profile in profiles.items():
orchestration_steps.append({
"name": f"build_{mode}",
"callable": (lambda m=mode, p=profile: {"mode": m, "output": str(ROOT / p["output"]), "status": "CACHED"}),
})
for mode, path in zip(("full", "compact", "ultra_compact"), bundle_paths):
orchestration_steps.append({
"name": f"validate_{path.stem}",
"depends_on": [f"build_{mode}"],
"callable": (lambda p=path: {"path": str(p), "status": "OK_CACHED"}),
})
results = run_plan(orchestration_steps, label="build-bundle:cached")
else:
# 2) 서로 독립인 3개 번들 생성은 병렬, 각 생성물 검증은 해당 생성 직후 수행한다.
orchestration_steps = []
for mode, profile in profiles.items():
orchestration_steps.append({
"name": f"build_{mode}",
"callable": (lambda m=mode, p=profile, c=shared_contents: _write_bundle_job(m, p, c)),
})
for mode, path in zip(("full", "compact", "ultra_compact"), bundle_paths):
orchestration_steps.append({
"name": f"validate_{path.stem}",
"depends_on": [f"build_{mode}"],
"callable": (lambda p=path: _validate_bundle(p)),
})
results = run_plan(orchestration_steps, label="build-bundle")
save_cache({
"content_signature": content_signature,
"latest_source_mtime": latest_source_mtime,
"bundle_outputs": [str(p) for p in bundle_paths],
})
profile = runtime_profile_from_steps(
harness_name="build-bundle",
mode="bundle",
steps=results,
runtime_context={
"harness_name": "build-bundle",
"mode": "bundle",
},
file_count=len(all_profile_paths),
gate_status="OK",
)
profile["cache_hit"] = cache_hit
analysis = finalize_runtime_profile(
profile_path=RUNTIME_PROFILE,
payload=profile,
)
if analysis.get("status") == "ALERT":
print("RUNTIME_ANOMALY:", ";".join(analysis.get("anomaly_reason_codes") or []))
print("BUNDLE BUILD OK" + (" (cached)" if cache_hit else ""))
return 0
if __name__ == "__main__":
raise SystemExit(main())