Add stage-based retry loop regeneration

This commit is contained in:
2026-04-02 10:55:18 +09:00
parent b864872d1a
commit 2d52427898
23 changed files with 594 additions and 377 deletions

View File

@@ -7,6 +7,7 @@ import re
import subprocess
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
@@ -40,126 +41,6 @@ def strip_tags(text: str) -> str:
return re.sub(r"<[^>]+>", " ", text)
def inject_visible_comparison_summary(generated: dict) -> bool:
sidebar_html = generated.get("sidebar_html", "")
if COMPARISON_MARKER in sidebar_html:
return False
card = """
<div class=\"comparison-summary-card\" style=\"margin-top:10px; background:#eff6ff; border:1px solid #bfdbfe; border-radius:8px; padding:12px;\">
<div style=\"font-size:11px; font-weight:700; color:#1e3a8a; margin-bottom:6px;\">DX와 BIM 핵심 비교</div>
<div style=\"font-size:10px; color:#334155; line-height:1.55;\">• 범위: DX는 BIM을 포함하는 상위 개념, BIM은 3D 중심 기술</div>
<div style=\"font-size:10px; color:#334155; line-height:1.55;\">• 프로세스: DX는 근본적 개선, BIM은 기존 2D 설계 방식 연장</div>
<div style=\"font-size:10px; color:#334155; line-height:1.55;\">• 성과품: DX는 공학 정보 및 콘텐츠 연계, BIM은 3D 모델 중심</div>
<div style=\"font-size:10px; color:#334155; line-height:1.55;\">• 확장성: DX는 전 생애주기 활용 시스템, BIM은 분야별 단절 위험</div>
</div>
""".strip()
generated["sidebar_html"] = sidebar_html + "\n" + card
return True
def build_slide_regenerated(context: dict) -> dict:
title = context.get("analysis", {}).get("title", "건설산업 DX의 올바른 이해")
core_message = context.get(
"analysis", {}
).get("core_message", "건설산업에서 DX는 상위 개념이고 BIM은 그 디지털 전환을 가능하게 하는 핵심 기술 중 하나다.")
body_html = f"""
<div style="width:100%; height:100%; box-sizing:border-box; font-family:'Segoe UI',sans-serif; color:#0f172a; display:flex; flex-direction:column; gap:12px;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:14px;">
<div style="flex:1; background:linear-gradient(135deg,#0f172a 0%,#1d4ed8 100%); color:#ffffff; border-radius:14px; padding:18px 20px; box-sizing:border-box; box-shadow:0 12px 28px rgba(15,23,42,0.18);">
<div style="font-size:11px; font-weight:700; letter-spacing:0.08em; text-transform:uppercase; color:#93c5fd; margin-bottom:8px;">Core Message</div>
<div style="font-size:23px; font-weight:800; line-height:1.22; margin-bottom:8px;">DX는 상위 개념, BIM은 핵심 기술</div>
<div style="font-size:12px; line-height:1.55; color:rgba(255,255,255,0.92);">{core_message}</div>
</div>
<div style="width:170px; background:#fff7ed; border:1px solid #fdba74; border-radius:14px; padding:14px 14px 12px; box-sizing:border-box;">
<div style="font-size:10px; font-weight:700; color:#c2410c; margin-bottom:8px; text-transform:uppercase; letter-spacing:0.06em;">Problem</div>
<div style="font-size:11px; line-height:1.55; color:#7c2d12;">건설산업에서는 DX와 BIM이 자주 혼용되며, BIM 도입이 곧 DX 완성이라는 오해가 생긴다.</div>
</div>
</div>
<div class="relation-diagram-card" style="background:linear-gradient(180deg,#eff6ff 0%,#f8fafc 100%); border:1px solid #bfdbfe; border-radius:16px; padding:18px; box-sizing:border-box; box-shadow:0 8px 18px rgba(59,130,246,0.10);">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:14px; gap:14px;">
<div>
<div style="font-size:11px; font-weight:700; color:#2563eb; letter-spacing:0.08em; text-transform:uppercase; margin-bottom:6px;">Relation Map</div>
<div style="font-size:18px; font-weight:800; color:#0f172a; line-height:1.25;">건설산업 DX를 이루는 핵심 기술 관계</div>
</div>
<div style="font-size:11px; color:#166534; background:#dcfce7; border:1px solid #86efac; border-radius:999px; padding:6px 10px; white-space:nowrap;">[그림 1] {IMAGE_REFERENCE_KEY}</div>
</div>
<div style="display:flex; flex-direction:column; align-items:center; gap:10px;">
<div style="min-width:190px; text-align:center; background:#1d4ed8; color:#ffffff; border-radius:999px; padding:10px 18px; font-size:16px; font-weight:800; box-shadow:0 8px 18px rgba(37,99,235,0.22);">DX</div>
<div style="width:2px; height:20px; background:linear-gradient(180deg,#60a5fa 0%,#94a3b8 100%);"></div>
<div style="width:100%; display:flex; justify-content:center; gap:12px;">
<div style="flex:1; max-width:150px; background:#ffffff; border:1px solid #cbd5e1; border-radius:12px; padding:10px 10px 12px; text-align:center; box-sizing:border-box;">
<div style="font-size:12px; font-weight:800; color:#0f172a; margin-bottom:4px;">GIS</div>
<div style="font-size:10px; line-height:1.45; color:#475569;">공간정보와 위치기반 분석</div>
</div>
<div style="flex:1; max-width:170px; background:linear-gradient(180deg,#dbeafe 0%,#eff6ff 100%); border:2px solid #3b82f6; border-radius:12px; padding:10px 10px 12px; text-align:center; box-sizing:border-box; box-shadow:0 10px 22px rgba(59,130,246,0.16); transform:translateY(-2px);">
<div style="font-size:12px; font-weight:800; color:#1d4ed8; margin-bottom:4px;">BIM</div>
<div style="font-size:10px; line-height:1.45; color:#334155;">형상+내용정보 기반 핵심 기술</div>
</div>
<div style="flex:1; max-width:150px; background:#ffffff; border:1px solid #cbd5e1; border-radius:12px; padding:10px 10px 12px; text-align:center; box-sizing:border-box;">
<div style="font-size:12px; font-weight:800; color:#0f172a; margin-bottom:4px;">Digital Twin</div>
<div style="font-size:10px; line-height:1.45; color:#475569;">가상환경 기반 통합 운영</div>
</div>
</div>
</div>
</div>
</div>
""".strip()
sidebar_html = """
<div style="width:100%; height:100%; box-sizing:border-box; font-family:'Segoe UI',sans-serif; display:flex; flex-direction:column; gap:12px;">
<div class="comparison-summary-card" style="background:#ffffff; border:1px solid #cbd5e1; border-radius:14px; overflow:hidden; box-shadow:0 10px 22px rgba(15,23,42,0.08);">
<div style="padding:12px 14px; background:linear-gradient(135deg,#eff6ff 0%,#dbeafe 100%); border-bottom:1px solid #bfdbfe;">
<div style="font-size:11px; font-weight:700; color:#1d4ed8; letter-spacing:0.08em; text-transform:uppercase; margin-bottom:4px;">Comparison</div>
<div style="font-size:16px; font-weight:800; color:#0f172a;">DX와 BIM 핵심 비교</div>
</div>
<div style="padding:10px 14px 12px; display:grid; grid-template-columns:80px 1fr; row-gap:8px; column-gap:10px; font-size:10px; line-height:1.45; color:#334155;">
<div style="font-weight:800; color:#0f172a;">범위</div><div>DX는 BIM을 포함하는 상위 개념, BIM은 3D 중심 기술</div>
<div style="font-weight:800; color:#0f172a;">프로세스</div><div>DX는 근본적 개선, BIM은 기존 2D 설계 방식 연장</div>
<div style="font-weight:800; color:#0f172a;">성과품</div><div>DX는 공학 정보 및 콘텐츠 연계, BIM은 3D 모델 중심</div>
<div style="font-weight:800; color:#0f172a;">확장성</div><div>DX는 전 생애주기 활용 시스템, BIM은 분야별 단절 위험</div>
</div>
</div>
<div style="background:#f8fafc; border:1px solid #cbd5e1; border-radius:14px; padding:14px; box-sizing:border-box;">
<div style="font-size:11px; font-weight:700; color:#475569; letter-spacing:0.08em; text-transform:uppercase; margin-bottom:6px;">Evidence</div>
<div style="font-size:14px; font-weight:800; color:#0f172a; margin-bottom:8px;">정책 문서에서도 혼용</div>
<div style="font-size:10px; line-height:1.55; color:#475569;">• 스마트 건설 활성화 방안: 디지털화 방향 아래 BIM 전면 도입 제시</div>
<div style="font-size:10px; line-height:1.55; color:#475569;">• 제7차 건설기술진흥 기본계획: DX 추진 방향 아래 BIM 도입 실행 과제 제시</div>
</div>
</div>
""".strip()
footer_html = """
<div style="background:linear-gradient(90deg,#0f766e 0%,#0ea5a3 100%); border-radius:12px; padding:14px 24px; text-align:center; color:#ffffff; width:100%; height:60px; display:flex; flex-direction:column; justify-content:center; box-sizing:border-box; box-shadow:0 10px 24px rgba(15,118,110,0.20);">
<div style="font-size:14px; font-weight:800; line-height:1.35;">결론: BIM은 DX 수행 과정의 가장 기초가 되는 일부분</div>
</div>
""".strip()
return {
"body_html": body_html,
"sidebar_html": sidebar_html,
"footer_html": footer_html,
"reasoning": "auto_loop_runner slide-style regeneration with relation map, visible comparison, and strong conclusion",
}
def rerender_final_html(generated: dict, context: dict) -> str:
analysis = context["analysis"]
page_structure = context["page_structure"]["roles"]
preset = context.get("preset", {})
analysis_dict = {
"topics": context.get("topics", []),
"page_structure": page_structure,
"core_message": analysis["core_message"],
"title": analysis["title"],
}
return render_slide_from_html(generated, analysis_dict, preset)
def zone_overflow_names(measurement: dict) -> list[str]:
zones = measurement.get("zones", {})
names: list[str] = []
@@ -211,7 +92,7 @@ def validate_outputs(generated: dict, measurement: dict) -> tuple[str, list[str]
return "pass", [], []
def build_validation_markdown(run_id: str, status: str, failures: list[str], actions: list[str], measurement: dict) -> str:
def build_validation_markdown(run_id: str, status: str, failures: list[str], actions: list[str], measurement: dict, retry_plan: dict | None = None) -> str:
slide_overflow = measurement.get("slide", {}).get("overflowed", True)
zones = measurement.get("zones", {})
zone_lines = []
@@ -224,6 +105,14 @@ def build_validation_markdown(run_id: str, status: str, failures: list[str], act
status_line = "통과" if status == "pass" else "재작업 필요"
failure_lines = "\n".join(f"- {f}" for f in failures) if failures else "- 없음"
action_lines = "\n".join(f"{i + 1}. {a}" for i, a in enumerate(actions)) if actions else "1. 없음"
retry_section = ""
if retry_plan:
retry_section = f"""
## Retry Plan
```json
{json.dumps(retry_plan, ensure_ascii=False, indent=2)}
```
"""
return f"""# Validation Result
## Run
@@ -244,7 +133,7 @@ def build_validation_markdown(run_id: str, status: str, failures: list[str], act
```json
{json.dumps(measurement, ensure_ascii=False, indent=2)}
```
{retry_section}
## Final Decision
- 판정: `{status}`
@@ -269,6 +158,121 @@ def post_comment_if_configured(repo: str, issue_number: int, body_file: Path) ->
create_comment(base_url, token, repo, issue_number, body)
def compact_text(text: str, max_len: int) -> str:
normalized = re.sub(r"\s+", " ", text).strip()
if len(normalized) <= max_len:
return normalized
cut = normalized[:max_len].rsplit(" ", 1)[0].strip()
return (cut or normalized[:max_len]).rstrip(" ,.;:") + "..."
def ensure_phrase(base: str, phrase: str) -> str:
if phrase in base:
return base
return f"{base} {phrase}".strip()
def load_stage_artifacts(output_dir: Path) -> dict[str, Any]:
artifacts: dict[str, Any] = {}
for name in [
"stage_1b_context.json",
"stage_1_5b_context.json",
"stage_2_context.json",
"stage_2_verification.json",
]:
path = output_dir / name
if path.exists():
artifacts[name] = read_json(path)
return artifacts
def derive_retry_plan(failures: list[str], artifacts: dict[str, Any]) -> dict[str, Any]:
stage_1_5b = artifacts.get("stage_1_5b_context.json", {})
stage_2v = artifacts.get("stage_2_verification.json", {})
rollback_stage = "stage_2"
reasons: list[str] = []
mutations: list[dict[str, Any]] = []
if any(f in failures for f in ["Verify-CoreMessage", "Verify-ImageRef", "Verify-ComparisonVisible", "Verify-DesignStructure"]):
rollback_stage = "stage_1b"
reasons.append("가시 메시지/관계도/비교 요약이 부족하여 topic 표현 지시를 다시 강화해야 함")
mutations.extend([
{"topic_id": 2, "change": "summary", "strategy": "core_message_strengthen"},
{"topic_id": 3, "change": "expression_hint", "strategy": "force_relation_diagram_visible"},
{"topic_id": 5, "change": "expression_hint", "strategy": "force_visible_comparison_summary"},
{"topic_id": 6, "change": "summary", "strategy": "strong_footer_conclusion"},
])
if any(f in failures for f in ["Verify-RenderZone", "Verify-RenderSlide"]):
if rollback_stage != "stage_1b":
rollback_stage = "stage_1_5b"
reasons.append("overflow가 발생하여 budget/문장 길이/보조 정보 밀도를 재조정해야 함")
for role, container in stage_1_5b.get("containers", {}).items():
db = container.get("design_budget", {})
if db and not db.get("fits", True):
mutations.append({"role": role, "change": "budget", "strategy": "compress_visible_copy"})
for area, result in stage_2v.items():
if not result.get("passed"):
mutations.append({"area": area, "change": "verification", "strategy": "reduce_density_and_split_visibility"})
return {
"rollback_stage": rollback_stage,
"failures": failures,
"reasons": reasons,
"mutations": mutations,
}
def apply_retry_plan_to_stage1b(stage1b_path: Path, retry_plan: dict[str, Any], iteration: int) -> dict[str, Any]:
data = read_json(stage1b_path)
backup_dir = stage1b_path.parent / "history"
backup_dir.mkdir(parents=True, exist_ok=True)
backup_path = backup_dir / f"stage-1b-refined-concepts.iteration-{iteration}.json"
write_json(backup_path, data)
concept_map = {item["topic_id"]: item for item in data.get("concepts", [])}
for mutation in retry_plan.get("mutations", []):
topic_id = mutation.get("topic_id")
strategy = mutation.get("strategy")
if topic_id not in concept_map:
continue
concept = concept_map[topic_id]
summary = concept.get("summary", "")
hint = concept.get("expression_hint", "")
if strategy == "core_message_strengthen":
concept["summary"] = compact_text(
ensure_phrase(summary, "DX는 상위 개념이고 BIM은 핵심 기술이다."),
80,
)
concept["expression_hint"] = ensure_phrase(hint, "본문 첫 블록에서 DX는 상위 개념, BIM은 핵심 기술이라는 문구를 그대로 가시 텍스트로 노출한다.")
elif strategy == "force_relation_diagram_visible":
concept["expression_hint"] = ensure_phrase(hint, "관계도는 팝업이나 숨김영역이 아니라 본문 중앙의 가시 다이어그램으로 렌더링한다.")
concept["summary"] = compact_text(ensure_phrase(summary, "DX와 GIS, BIM, Digital Twin의 관계를 시각적으로 드러낸다."), 90)
elif strategy == "force_visible_comparison_summary":
concept["expression_hint"] = ensure_phrase(hint, "범위, 프로세스, 성과품, 확장성 4개 비교축을 sidebar의 가시 요약 카드로 직접 노출한다.")
concept["summary"] = compact_text(
"범위·프로세스·성과품·확장성의 4개 비교축으로 DX와 BIM 차이를 짧고 직접적으로 보여준다.",
90,
)
elif strategy == "strong_footer_conclusion":
concept["summary"] = "결론: BIM은 건설산업 DX를 수행하는 과정의 가장 기초가 되는 일부분이다."
concept["expression_hint"] = ensure_phrase(hint, "footer 또는 결론 배너에서 문장을 축약하지 말고 그대로 강하게 노출한다.")
elif strategy == "compress_visible_copy":
concept["summary"] = compact_text(summary, 60)
concept["expression_hint"] = ensure_phrase(hint, "문장 수를 줄이고 핵심 명사구 위주로 압축하되, 핵심 메시지는 유지한다.")
elif strategy == "reduce_density_and_split_visibility":
concept["summary"] = compact_text(summary, 70)
concept["expression_hint"] = ensure_phrase(hint, "표현 밀도를 낮추고, 장문 설명 대신 짧은 bullet/card 구조로 나눈다.")
write_json(stage1b_path, data)
retry_plan_path = stage1b_path.parent / "retry-plan.json"
write_json(retry_plan_path, retry_plan)
return data
def main() -> None:
parser = argparse.ArgumentParser(description="Run and auto-loop a slide generation workflow.")
parser.add_argument("--run-id", default="run-001")
@@ -373,30 +377,16 @@ KPI / 판정 결과
continue
generated = read_json(generated_path)
context = read_json(context_path)
changed = inject_visible_comparison_summary(generated)
redesign_applied = False
if changed:
write_json(generated_path, generated)
final_html = rerender_final_html(generated, context)
final_html_path.write_text(final_html, encoding="utf-8")
measurement = measure_rendered_heights(final_html)
write_json(measurement_path, measurement)
else:
measurement = read_json(measurement_path)
measurement = read_json(measurement_path)
status, failures, actions = validate_outputs(generated, measurement)
if status != "pass" and any(f in failures for f in ["Verify-RenderZone", "Verify-CoreMessage", "Verify-ComparisonVisible", "Verify-ImageRef", "Verify-DesignStructure"]):
generated = build_slide_regenerated(context)
redesign_applied = True
write_json(generated_path, generated)
final_html = rerender_final_html(generated, context)
final_html_path.write_text(final_html, encoding="utf-8")
measurement = measure_rendered_heights(final_html)
write_json(measurement_path, measurement)
status, failures, actions = validate_outputs(generated, measurement)
retry_plan = None
validation_path.write_text(build_validation_markdown(args.run_id, status, failures, actions, measurement), encoding="utf-8")
if status != "pass" and iteration < args.max_iterations:
artifacts = load_stage_artifacts(output_dir)
retry_plan = derive_retry_plan(failures, artifacts)
apply_retry_plan_to_stage1b(stage1b, retry_plan, iteration)
validation_path.write_text(build_validation_markdown(args.run_id, status, failures, actions, measurement, retry_plan), encoding="utf-8")
zone_names = zone_overflow_names(measurement)
critical_outputs_ok = all(path.exists() and path.stat().st_size > 0 for path in [final_html_path, generated_path, measurement_path, context_path])
@@ -407,8 +397,7 @@ KPI / 판정 결과
- auto_loop_runner.py iteration {iteration}로 실행했다.
- 입력: `docs/{args.run_id}/01-input/{input_file.name}`
- 산출물: `final.html`, `generated_html.json`, `measurement.json`, `context.json`
- 비교 요약 가시 블록 보강: {'적용' if changed else '기존 유지'}
- 슬라이드형 재생성 적용: {'' if redesign_applied else '아니오'}
- stage snapshot: `stage_0_context.json` ~ `final_context.json`
산출물 경로
- `docs/{args.run_id}/05-execution/final.html`
@@ -432,18 +421,19 @@ KPI / 판정 결과
- 최신 실행 산출물
- 최신 measurement
- 최신 context
- stage snapshot 묶음
"""
step6_body = f"""실행 요약
- iteration {iteration} 기준으로 최종 산출물과 측정 결과를 다시 검증했다.
- slide overflow: {measurement.get('slide', {}).get('overflowed')}
- zone overflow: {', '.join(zone_names) if zone_names else '없음'}
- 슬라이드형 재생성 적용: {'' if redesign_applied else '아니오'}
- 최종 판정은 `{status}`이다.
산출물 경로
- `docs/{args.run_id}/06-validation/validation-result.md`
- `docs/{args.run_id}/05-execution/final.html`
- `docs/{args.run_id}/05-execution/measurement.json`
- `docs/{args.run_id}/05-execution/stage_2_verification.json`
KPI / 판정 결과
- 판정: {status}
@@ -455,6 +445,8 @@ KPI / 판정 결과
step6_body += "\n".join(f"- {a}" for a in actions)
else:
step6_body += "- 없음"
if retry_plan:
step6_body += f"\n\nRetry Plan\n- rollback stage: {retry_plan.get('rollback_stage')}\n- reason: {'; '.join(retry_plan.get('reasons', [])) or '없음'}\n- mutation count: {len(retry_plan.get('mutations', []))}"
step6_body += f"\n\n다음 단계 전달물\n- 최신 validation 기록\n- 다음 iteration 여부: {'중단' if status == 'pass' else '재실행'}\n"
write_step_comment(step_comment_bodies[5], step5_body)