Checkpoint Type B pipeline refinement for run-002 and run-003

This commit is contained in:
2026-04-07 12:16:58 +09:00
parent f48dbe5227
commit 11e9165a8f
71 changed files with 1318 additions and 1051 deletions

View File

@@ -21,7 +21,10 @@ if str(DESIGN_AGENT_ROOT) not in sys.path:
sys.path.insert(0, str(DESIGN_AGENT_ROOT))
from src.renderer import render_slide_from_html # type: ignore
from src.slide_measurer import measure_rendered_heights # type: ignore
from src.slide_measurer import measure_rendered_heights, capture_slide_screenshot, settings # type: ignore
from selenium import webdriver # type: ignore
from selenium.webdriver.chrome.options import Options # type: ignore
from selenium.webdriver.common.by import By # type: ignore
COMPARISON_MARKER = "comparison-summary-card"
RELATION_MARKER = "relation-diagram-card"
@@ -52,7 +55,13 @@ def zone_overflow_names(measurement: dict) -> list[str]:
return names
def validate_outputs(generated: dict, measurement: dict, required_titles: list[str], run_mode: str) -> tuple[str, list[str], list[str]]:
def validate_outputs(
generated: dict,
measurement: dict,
required_titles: list[str],
run_mode: str,
layout_template: str = "",
) -> tuple[str, list[str], list[str]]:
body_html = generated.get("body_html", "")
sidebar_html = generated.get("sidebar_html", "")
footer_html = generated.get("footer_html", "")
@@ -69,50 +78,74 @@ def validate_outputs(generated: dict, measurement: dict, required_titles: list[s
if measurement_missing:
failures.append("Verify-Measurement")
actions.append("?? ??? ?? ???? stage 3/4 ?? ? ?? ??? ?? ???? ?? ??? ?? ????.")
actions.append("?? ??? ?? ?? stage 3/4 ???? ?? ???? ??.")
if slide_overflow:
failures.append("Verify-RenderSlide")
actions.append("slide 전체 overflow를 해소하도록 layout budget 또는 전체 레이아웃 구조를 조정한다.")
actions.append("slide ?? overflow? ????? layout budget ?? ?? ???? ??? ????.")
if zone_overflows:
failures.append("Verify-RenderZone")
actions.append(f"overflow가 발생한 zone({', '.join(zone_overflows)}) content budget, block , typography를 재조정한다.")
actions.append(f"overflow? ??? zone({', '.join(zone_overflows)})? content budget, block ?, typography? ?????.")
if '???' in visible_text or '?? ??' in visible_text:
failures.append("Verify-Placeholder")
actions.append("placeholder나 깨진 라벨을 제거하고, 원문 제목/문장으로 다시 채운다.")
actions.append("placeholder? ?? ??? ????, ?? ??/???? ?? ???.")
matched_titles = sum(1 for title in required_titles if title and title in visible_text)
visible_len = len(re.sub(r'\s+', ' ', visible_text).strip())
if matched_titles < max(2, min(len(required_titles), 3)):
failures.append("Verify-SectionTitles")
actions.append("원문 섹션 제목을 가시 텍스트에 더 직접적으로 유지한다.")
actions.append("?? ?? ??? ?? ???? ? ????? ????.")
if run_mode == 'run001':
core_message_ok = all(any(marker in visible_text for marker in variants) for variants in CORE_MESSAGE_MARKERS)
if not core_message_ok:
failures.append("Verify-CoreMessage")
actions.append("원문 표현을 유지하되 `상위 개념`과 `핵심 기술/핵심 인프라 기술` 판단이 가시 텍스트에 분명히 드러나도록 정리한다.")
actions.append("?? ??? ???? `?? ??`? `?? ??/?? ??? ??` ??? ?? ???? ??? ????? ????.")
if IMAGE_REFERENCE_KEY not in visible_text:
failures.append("Verify-ImageRef")
actions.append("이미지/도해 참조 문구 `DX와 핵심기술간 상호관계`를 숨김 영역이 아닌 가시 블록으로 유지한다.")
actions.append("???/?? ?? ?? `DX? ????? ????`? ?? ??? ?? ?? ???? ????.")
comparison_visible = (COMPARISON_MARKER in body_html) and all(key in visible_text for key in COMPARE_KEYS)
if not comparison_visible:
failures.append("Verify-ComparisonVisible")
actions.append("비교 핵심 4축(범위, 프로세스, 성과품, 확장성)을 화면에 바로 보이는 요약 블록으로 강제한다.")
actions.append("?? ?? 4?(??, ????, ???, ???)? ??? ?? ??? ?? ???? ????.")
if RELATION_MARKER not in body_html:
failures.append("Verify-DesignStructure")
actions.append("핵심 관계를 설명하는 시각적 관계도 블록을 본문 중심 구조로 유지한다.")
actions.append("?? ??? ???? ??? ??? ??? ?? ?? ??? ????.")
else:
if len(re.sub(r'\s+', ' ', visible_text).strip()) < 260:
if visible_len < 420:
failures.append("Verify-ContentDensity")
actions.append("본문과 보조 영역의 원문 문장 보존량을 높여 내용 밀도를 보강한다.")
if not body_html or not sidebar_html:
actions.append("??? ?? ??? ?? ?? ???? ?? ?? ??? ????.")
if not body_html:
failures.append("Verify-DesignStructure")
actions.append("body와 sidebar의 역할을 분리하여 섹션별 배치를 다시 잡는다.")
actions.append("body ??? ?? ??? ?? ?? ?? ??? ???.")
if matched_titles < max(3, len([title for title in required_titles if title]) - 1):
failures.append("Verify-SectionTitles")
actions.append("?? ?? ??? ? ?? ?? ?? ???? ????.")
if layout_template == "B_GOAL":
for marker, reason in [
("Goal details", "?? ?? ?? ?? ?? ???? ????."),
("Process details", "?? ?? ?? ?? ???? ????."),
("Stakeholder details", "??? ???? ?? ?? ???? ????."),
]:
if marker not in body_html:
failures.append("Verify-DesignStructure")
actions.append(reason)
if body_html.count("<li") < 10:
failures.append("Verify-ContentDensity")
actions.append("??/??/?? ??? ?? bullet ?? ?? ?? ??? ???.")
elif layout_template == "B_RPP":
for title in required_titles[:3]:
if title and title not in body_html:
failures.append("Verify-SectionTitles")
actions.append("??/??/??? ?? ?? ??? ??? ?? ????.")
if body_html.count("<li") < 14:
failures.append("Verify-ContentDensity")
actions.append("??/??/?? ??? ?? bullet ?? ??? ????? ??? ???.")
if failures:
return "revise", sorted(set(failures)), list(dict.fromkeys(actions))
@@ -224,6 +257,42 @@ def post_comment_if_configured(repo: str, issue_number: int, body_file: Path) ->
create_comment(base_url, token, repo, issue_number, body)
def refresh_final_screenshot(final_html_path: Path, output_dir: Path) -> None:
if not final_html_path.exists():
return
driver = None
try:
options = Options()
options.add_argument("--headless=new")
options.add_argument("--disable-gpu")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--force-device-scale-factor=1")
options.add_argument(f"--window-size={settings.slide_width},{settings.slide_height + 200}")
driver = webdriver.Chrome(options=options)
driver.get(final_html_path.resolve().as_uri())
slide = driver.find_element(By.CSS_SELECTOR, ".slide")
screenshot_bytes = slide.screenshot_as_png
(output_dir / "final-screenshot-current.png").write_bytes(screenshot_bytes)
(output_dir / "final-screenshot.png").write_bytes(screenshot_bytes)
except Exception:
html = final_html_path.read_text(encoding="utf-8")
screenshot_b64 = capture_slide_screenshot(html)
if not screenshot_b64:
return
import base64
screenshot_bytes = base64.b64decode(screenshot_b64)
(output_dir / "final-screenshot-current.png").write_bytes(screenshot_bytes)
(output_dir / "final-screenshot.png").write_bytes(screenshot_bytes)
finally:
if driver:
try:
driver.quit()
except Exception:
pass
def compact_text(text: str, max_len: int) -> str:
normalized = re.sub(r"\s+", " ", text).strip()
if len(normalized) <= max_len:
@@ -496,13 +565,18 @@ KPI / 판정 결과
post_comment_if_configured(args.repo_slug, issue_numbers[5], step_comment_bodies[6])
continue
screenshot_path = output_dir / "final-screenshot-current.png"
if (not screenshot_path.exists()) or (screenshot_path.stat().st_mtime < final_html_path.stat().st_mtime):
refresh_final_screenshot(final_html_path, output_dir)
generated = read_json(generated_path)
measurement = read_json(measurement_path)
stage1a_data = read_json(stage1a)
required_titles = [item.get("title", "") for item in stage1a_data.get("topics", [])]
topic_count = len(required_titles)
run_mode = "run001" if topic_count >= 5 else "generic"
status, failures, actions = validate_outputs(generated, measurement, required_titles, run_mode)
layout_template = str(stage1a_data.get("analysis", {}).get("layout_template", "") or "")
status, failures, actions = validate_outputs(generated, measurement, required_titles, run_mode, layout_template)
final_html_text = final_html_path.read_text(encoding="utf-8")
if 'width:100%; height:28px' in final_html_text:
status = "revise"

View File

@@ -1,4 +1,4 @@
from __future__ import annotations
from __future__ import annotations
import json
import re
@@ -7,61 +7,65 @@ from typing import Any
def _read_text(path: Path) -> str:
return path.read_text(encoding="utf-8-sig")
return path.read_text(encoding='utf-8-sig')
def _write_json(path: Path, data: dict[str, Any]) -> None:
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def _write_json(path: Path, data: dict[str, Any] | list[Any]) -> None:
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
def _write_text(path: Path, text: str) -> None:
path.write_text(text, encoding="utf-8")
path.write_text(text, encoding='utf-8')
def _normalize_space(text: str) -> str:
return re.sub(r'\s+', ' ', text or '').strip()
def _compact(text: str, max_len: int) -> str:
normalized = re.sub(r"\s+", " ", text).strip()
normalized = _normalize_space(text)
if len(normalized) <= max_len:
return normalized
cut = normalized[:max_len].rsplit(" ", 1)[0].strip()
return (cut or normalized[:max_len]).rstrip(" ,.;:") + "..."
cut = normalized[:max_len].rsplit(' ', 1)[0].strip()
return (cut or normalized[:max_len]).rstrip(' ,.;:') + '...'
def _preserve_len(text: str, ratio: float = 0.85, floor: int = 180, ceiling: int = 900) -> int:
normalized = re.sub(r"\s+", " ", text).strip()
normalized = _normalize_space(text)
if not normalized:
return floor
return max(floor, min(ceiling, int(len(normalized) * ratio)))
def _normalize_title_key(text: str) -> str:
return re.sub(r'\s+', '', text or '').lower()
def _strip_frontmatter_and_imports(raw: str) -> str:
text = raw.replace("\r\n", "\n")
if text.startswith("---\n"):
end = text.find("\n---", 4)
text = raw.replace('\r\n', '\n')
if text.startswith('---\n'):
end = text.find('\n---', 4)
if end != -1:
text = text[end + 4 :]
text = re.sub(r"^import\s+.+?$", "", text, flags=re.M)
text = re.sub(r'^import\s+.+?$', '', text, flags=re.M)
return text.strip()
def _dx_effect_lines(repo_root: Path) -> list[str]:
path = repo_root / "components" / "dx.astro"
path = repo_root / 'components' / 'dx.astro'
if not path.exists():
return []
text = _read_text(path)
text = re.sub(r"<style.*?</style>", "", text, flags=re.S)
text = text.replace("<br />", " ")
text = re.sub(r"</?(div|table|thead|tbody|tr|td|th|colgroup|col|ul|strong)[^>]*>", "\n", text)
text = re.sub(r"<li[^>]*>", "- ", text)
text = re.sub(r"</li>", "\n", text)
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r'<style.*?</style>', '', text, flags=re.S)
text = text.replace('<br />', ' ')
text = re.sub(r'</?(div|table|thead|tbody|tr|td|th|colgroup|col|ul|strong)[^>]*>', '\n', text)
text = re.sub(r'<li[^>]*>', '- ', text)
text = re.sub(r'</li>', '\n', text)
text = re.sub(r'<[^>]+>', ' ', text)
lines: list[str] = []
for raw in text.splitlines():
line = re.sub(r"\s+", " ", raw).strip()
if not line:
continue
if line.startswith("/*") or line.startswith("["):
continue
if len(line) < 6:
line = _normalize_space(raw)
if not line or line.startswith('/*') or line.startswith('[') or len(line) < 6:
continue
lines.append(line)
deduped: list[str] = []
@@ -73,72 +77,29 @@ def _dx_effect_lines(repo_root: Path) -> list[str]:
def _normalize_block_for_storage(text: str, repo_root: Path) -> str:
dx_lines = _dx_effect_lines(repo_root)
if "<DxEffect" in text and dx_lines:
replacement = "\n".join(f"* {line}" for line in dx_lines)
text = re.sub(r"<DxEffect\s*/>", replacement, text)
text = re.sub(r"<summary[^>]*>(.*?)</summary>", lambda m: f"**{re.sub(r'<[^>]+>', ' ', m.group(1)).strip()}**", text, flags=re.S)
text = text.replace("<details>", "").replace("</details>", "")
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
text = re.sub(r"</?div[^>]*>", "", text)
text = re.sub(r":::\s*note\[(.*?)\]", r"**\1**", text)
text = text.replace(":::", "")
text = re.sub(r"!\[([^\]]+)\]\(([^\)]+)\)", r"[???] \1", text)
text = re.sub(r"\n{3,}", "\n\n", text)
if '<DxEffect' in text and dx_lines:
replacement = '\n'.join(f'* {line}' for line in dx_lines)
text = re.sub(r'<DxEffect\s*/>', replacement, text)
text = re.sub(r'<summary[^>]*>(.*?)</summary>', lambda m: f"**{re.sub(r'<[^>]+>', ' ', m.group(1)).strip()}**", text, flags=re.S)
text = text.replace('<details>', '').replace('</details>', '')
text = re.sub(r'<br\s*/?>', '\n', text, flags=re.I)
text = re.sub(r'</?div[^>]*>', '', text)
text = re.sub(r':::\s*note\[(.*?)\]', r'**\1**', text)
text = text.replace(':::', '')
text = re.sub(r'!\[([^\]]+)\]\(([^\)]+)\)', r'[image] \1', text)
text = re.sub(r'\n{3,}', '\n\n', text)
return text.strip()
def _first_nonempty_lines(text: str, limit: int = 8) -> list[str]:
lines: list[str] = []
for raw in text.splitlines():
line = raw.strip()
if not line:
continue
if line.startswith("---"):
continue
lines.append(line)
if len(lines) >= limit:
break
return lines
def _extract_detail_topics(block: str, start_id: int, repo_root: Path) -> tuple[list[dict[str, Any]], str, int]:
topics: list[dict[str, Any]] = []
next_id = start_id
def repl(match: re.Match[str]) -> str:
nonlocal next_id
inner = match.group(1)
summary_match = re.search(r"<summary[^>]*>(.*?)</summary>", inner, flags=re.S)
summary = re.sub(r"<[^>]+>", " ", summary_match.group(1)).strip() if summary_match else "?? ??"
detail_body = re.sub(r"<summary[^>]*>.*?</summary>", "", inner, flags=re.S)
detail_source = _normalize_block_for_storage(detail_body, repo_root)
if detail_source:
topics.append({
"id": next_id,
"title": summary,
"purpose": "?? ?? ??",
"role": "reference",
"layer": "supporting",
"source_hint": summary,
"summary": _compact(detail_source, _preserve_len(detail_source, floor=220, ceiling=560)),
"source_data": detail_source,
})
next_id += 1
return f"\n* **{summary}**\n"
stripped = re.sub(r"<details>(.*?)</details>", repl, block, flags=re.S)
return topics, stripped, next_id
def _extract_title_from_intro(block: str) -> str:
m = re.search(r"\*\s+\*\*(.+?)\*\*", block)
m = re.search(r'\*\s+\*\*(.+?)\*\*', block)
if m:
return m.group(1).strip()
return "도입"
return '서론'
def _section_chunks(text: str) -> list[tuple[str, str]]:
matches = list(re.finditer(r"^##\s+(.+)$", text, flags=re.M))
matches = list(re.finditer(r'^##\s+(.+)$', text, flags=re.M))
chunks: list[tuple[str, str]] = []
for idx, match in enumerate(matches):
title = match.group(1).strip()
@@ -149,7 +110,7 @@ def _section_chunks(text: str) -> list[tuple[str, str]]:
def _subsection_chunks(text: str) -> list[tuple[str, str]]:
matches = list(re.finditer(r"^###\s+(.+)$", text, flags=re.M))
matches = list(re.finditer(r'^###\s+(.+)$', text, flags=re.M))
chunks: list[tuple[str, str]] = []
for idx, match in enumerate(matches):
title = match.group(1).strip()
@@ -159,94 +120,150 @@ def _subsection_chunks(text: str) -> list[tuple[str, str]]:
return chunks
def _classify(title: str, layer_hint: str = "core") -> tuple[str, str, str]:
def _classify(title: str, layer_hint: str = 'core') -> tuple[str, str, str]:
clean = title.strip()
if "혼용" in clean:
return "problem", "flow", "intro"
if "정의" in clean:
return "definition", "flow", "core"
if "상호관계" in clean or "관계" in clean:
return "hierarchy", "flow", "core"
if "구분" in clean or "비교" in clean:
return "comparison", "reference", "supporting"
if "사례" in clean:
return "evidence", "reference", "supporting"
if "궁극적 목표" in clean:
return "goal", "flow", "core"
if "기대효과" in clean:
return "stakeholder_effect", "flow", "core"
if "필수 요건" in clean:
return "requirements", "flow", "core"
if "Process" in clean or "과정" in clean:
return "process", "flow", "core"
if "Product" in clean or "결과" in clean:
return "product", "flow", "core"
if "핵심 요약" in clean or "결론" in clean:
return "conclusion", "flow", "conclusion"
if layer_hint == "supporting":
return "support", "reference", "supporting"
return "section", "flow", "core"
key = _normalize_title_key(clean)
if any(token in key for token in ['혼용', '실태', '현실']):
return 'problem', 'flow', 'intro'
if any(token in key for token in ['정의', '개념', '용어']):
return 'definition', 'flow', 'core'
if any(token in key for token in ['상호관계', '관계', '위치']):
return 'hierarchy', 'flow', 'core'
if any(token in key for token in ['구분', '비교']):
return 'comparison', 'reference', 'supporting'
if any(token in key for token in ['사례', '근거', '대표']):
return 'evidence', 'reference', 'supporting'
if any(token in key for token in ['궁극적목표', '시행목표', '목표']):
return 'goal', 'flow', 'core'
if any(token in key for token in ['기대효과', '주체별', '효과']):
return 'stakeholder_effect', 'flow', 'core'
if any(token in key for token in ['필수요건', '요건']):
return 'requirements', 'flow', 'core'
if 'process' in key or '과정' in clean:
return 'process', 'flow', 'core'
if 'product' in key or '결과' in clean:
return 'product', 'flow', 'core'
if any(token in key for token in ['핵심요약', '요약', '결론']):
return 'conclusion', 'flow', 'conclusion'
if layer_hint == 'supporting':
return 'support', 'reference', 'supporting'
return 'section', 'flow', 'core'
def _extract_detail_topics(block: str, start_id: int, repo_root: Path) -> tuple[list[dict[str, Any]], str, int]:
topics: list[dict[str, Any]] = []
next_id = start_id
def repl(match: re.Match[str]) -> str:
nonlocal next_id
inner = match.group(1)
summary_match = re.search(r'<summary[^>]*>(.*?)</summary>', inner, flags=re.S)
summary = re.sub(r'<[^>]+>', ' ', summary_match.group(1)).strip() if summary_match else '상세 내용'
detail_body = re.sub(r'<summary[^>]*>.*?</summary>', '', inner, flags=re.S)
detail_source = _normalize_block_for_storage(detail_body, repo_root)
if detail_source:
topics.append({
'id': next_id,
'title': summary,
'purpose': '상세 근거 또는 부연 설명',
'role': 'reference',
'layer': 'supporting',
'relation_type': 'evidence',
'source_hint': summary,
'summary': _compact(detail_source, _preserve_len(detail_source, floor=220, ceiling=560)),
'source_data': detail_source,
'structured_text': detail_source,
'popup_candidate': True,
})
next_id += 1
return f'\n* **{summary}**\n'
stripped = re.sub(r'<details>(.*?)</details>', repl, block, flags=re.S)
return topics, stripped, next_id
def _extract_conclusion(text: str, repo_root: Path) -> tuple[str, str]:
m = re.search(r":::\s*note\[(.*?)\](.*?):::", text, flags=re.S)
m = re.search(r':::\s*note\[(.*?)\](.*?):::', text, flags=re.S)
if not m:
return text, ""
note_title = re.sub(r"\s+", " ", m.group(1)).strip() or "\ud575\uc2ec \uc694\uc57d"
return text, ''
note_title = _normalize_space(m.group(1)) or '핵심 요약'
note_body = _normalize_block_for_storage(m.group(2), repo_root)
note_source = f"**{note_title}**\n{note_body}".strip()
note_source = f'**{note_title}**\n{note_body}'.strip()
stripped = text[: m.start()] + text[m.end() :]
return stripped.strip(), note_source
def extract_topics_from_raw(raw: str, repo_root: Path) -> tuple[str, list[dict[str, Any]]]:
title_match = re.search(r"^title:\s*(.+)$", raw, flags=re.M)
doc_title = title_match.group(1).strip() if title_match else "Document"
def _content_family(topics: list[dict[str, Any]]) -> str:
relation_types = {str(t.get('relation_type', '') or '') for t in topics}
if ('comparison' in relation_types or 'definition' in relation_types or 'hierarchy' in relation_types) and 'goal' not in relation_types:
return 'type-a-compare-define-relate'
if 'goal' in relation_types or 'stakeholder_effect' in relation_types:
return 'type-b-goal-effect'
if 'requirements' in relation_types or 'product' in relation_types or 'process' in relation_types:
return 'type-b-requirements-process-product'
return 'type-b-section-stack'
def _popup_candidate(topic: dict[str, Any]) -> bool:
relation = str(topic.get('relation_type', '') or '')
source = _normalize_space(str(topic.get('source_data', '') or ''))
return relation in {'comparison', 'evidence'} or len(source) > 520
def extract_topics_from_raw(raw: str, repo_root: Path) -> tuple[str, list[dict[str, Any]], str]:
title_match = re.search(r'^title:\s*(.+)$', raw, flags=re.M)
doc_title = title_match.group(1).strip() if title_match else 'Document'
clean = _strip_frontmatter_and_imports(raw)
clean, conclusion_source = _extract_conclusion(clean, repo_root)
topics: list[dict[str, Any]] = []
next_id = 1
first_section = re.search(r"^##\s+", clean, flags=re.M)
first_section = re.search(r'^##\s+', clean, flags=re.M)
intro_block = clean[: first_section.start()].strip() if first_section else clean.strip()
if intro_block:
detail_topics, intro_stripped, _ = _extract_detail_topics(intro_block, next_id + 1, repo_root)
intro_source = _normalize_block_for_storage(intro_stripped, repo_root)
if intro_source:
title = _extract_title_from_intro(intro_source)
relation, role, layer = _classify(title, "intro")
relation, role, layer = _classify(title, 'intro')
topics.append({
"id": next_id,
"title": title,
"purpose": "?? ?? ?? ??",
"role": role,
"layer": layer,
"source_hint": title,
"summary": _compact(intro_source, _preserve_len(intro_source, floor=260, ceiling=760)),
"source_data": intro_source,
'id': next_id,
'title': title,
'purpose': '문서 도입 또는 문제 제기',
'role': role,
'layer': layer,
'relation_type': relation,
'source_hint': title,
'summary': _compact(intro_source, _preserve_len(intro_source, floor=260, ceiling=760)),
'source_data': intro_source,
'structured_text': intro_source,
'popup_candidate': False,
})
next_id += 1
topics.extend(detail_topics)
next_id = max([t["id"] for t in topics], default=0) + 1
next_id = max([t['id'] for t in topics], default=0) + 1
for section_title, section_body in _section_chunks(clean):
detail_topics, section_stripped, next_id = _extract_detail_topics(section_body, next_id, repo_root)
subsections = _subsection_chunks(section_stripped)
lead = re.split(r"^###\s+.+$", section_stripped, maxsplit=1, flags=re.M)[0].strip() if subsections else section_stripped
lead = re.split(r'^###\s+.+$', section_stripped, maxsplit=1, flags=re.M)[0].strip() if subsections else section_stripped
if lead:
source = _normalize_block_for_storage(lead, repo_root)
if source:
relation, role, layer = _classify(section_title)
topics.append({
"id": next_id,
"title": section_title,
"purpose": f"{section_title} ?? ??",
"role": role,
"layer": layer,
"source_hint": section_title,
"summary": _compact(source, _preserve_len(source, floor=240, ceiling=780)),
"source_data": source,
'id': next_id,
'title': section_title,
'purpose': f'{section_title}의 핵심 내용',
'role': role,
'layer': layer,
'relation_type': relation,
'source_hint': section_title,
'summary': _compact(source, _preserve_len(source, floor=240, ceiling=780)),
'source_data': source,
'structured_text': source,
'popup_candidate': False,
})
next_id += 1
for sub_title, sub_body in subsections:
@@ -254,135 +271,181 @@ def extract_topics_from_raw(raw: str, repo_root: Path) -> tuple[str, list[dict[s
if source:
relation, role, layer = _classify(sub_title)
topics.append({
"id": next_id,
"title": sub_title,
"purpose": f"{sub_title} ?? ??",
"role": role,
"layer": layer,
"source_hint": sub_title,
"summary": _compact(source, _preserve_len(source, floor=220, ceiling=760)),
"source_data": source,
'id': next_id,
'title': sub_title,
'purpose': f'{sub_title}의 세부 내용',
'role': role,
'layer': layer,
'relation_type': relation,
'source_hint': sub_title,
'summary': _compact(source, _preserve_len(source, floor=220, ceiling=760)),
'source_data': source,
'structured_text': source,
'popup_candidate': False,
})
next_id += 1
topics.extend(detail_topics)
next_id = max([t["id"] for t in topics], default=0) + 1
next_id = max([t['id'] for t in topics], default=0) + 1
if conclusion_source:
topics.append({
"id": next_id,
"title": "\ud575\uc2ec \uc694\uc57d",
"purpose": "?? ?? ??",
"role": "flow",
"layer": "conclusion",
"source_hint": "\ud575\uc2ec \uc694\uc57d",
"summary": _compact(conclusion_source, _preserve_len(conclusion_source, floor=140, ceiling=360)),
"source_data": conclusion_source,
'id': next_id,
'title': '핵심 요약',
'purpose': '결론 또는 핵심 메시지',
'role': 'flow',
'layer': 'conclusion',
'relation_type': 'conclusion',
'source_hint': '핵심 요약',
'summary': _compact(conclusion_source, _preserve_len(conclusion_source, floor=140, ceiling=360)),
'source_data': conclusion_source,
'structured_text': conclusion_source,
'popup_candidate': False,
})
return doc_title, topics
for topic in topics:
topic['popup_candidate'] = _popup_candidate(topic)
return doc_title, topics, _content_family(topics)
def _page_structure(topics: list[dict[str, Any]]) -> dict[str, Any]:
intro_ids = [t["id"] for t in topics if t["layer"] == "intro"]
core_ids = [t["id"] for t in topics if t["layer"] == "core"]
support_ids = [t["id"] for t in topics if t["layer"] == "supporting"]
conclusion_ids = [t["id"] for t in topics if t["layer"] == "conclusion"]
def _page_structure(topics: list[dict[str, Any]], family: str) -> dict[str, Any]:
intro_ids = [t['id'] for t in topics if t['layer'] == 'intro']
core_ids = [t['id'] for t in topics if t['layer'] == 'core']
support_ids = [t['id'] for t in topics if t['layer'] == 'supporting']
conclusion_ids = [t['id'] for t in topics if t['layer'] == 'conclusion']
structure: dict[str, Any] = {}
if intro_ids:
structure["background"] = {"topic_ids": intro_ids, "weight": 0.24}
if core_ids:
structure["body"] = {"topic_ids": core_ids, "weight": 0.48 if support_ids else 0.58}
if support_ids:
structure["support"] = {"topic_ids": support_ids, "weight": 0.18}
if family == 'type-a-compare-define-relate':
if intro_ids:
structure['background'] = {'topic_ids': intro_ids, 'weight': 0.22}
if core_ids:
structure['body'] = {'topic_ids': core_ids, 'weight': 0.50}
if support_ids:
structure['support'] = {'topic_ids': support_ids, 'weight': 0.18}
else:
top_ids = intro_ids + core_ids[:1]
body_ids = core_ids[1:] if len(core_ids) > 1 else core_ids[:1]
support_main = support_ids[:]
if top_ids:
structure['body'] = {'topic_ids': top_ids + body_ids, 'weight': 0.58 if support_main else 0.64}
if support_main:
structure['support'] = {'topic_ids': support_main, 'weight': 0.18}
if conclusion_ids:
structure["key_message"] = {"topic_ids": conclusion_ids, "weight": 0.10}
structure['key_message'] = {'topic_ids': conclusion_ids, 'weight': 0.10}
return structure
def rebuild_run_from_raw(repo_root: Path, run_dir: Path, input_file: Path) -> dict[str, Any]:
raw = _read_text(input_file)
doc_title, topics = extract_topics_from_raw(raw, repo_root)
core_topic = next((t for t in topics if t["layer"] == "conclusion"), topics[-1] if topics else {"source_data": ""})
doc_title, topics, family = extract_topics_from_raw(raw, repo_root)
core_topic = next((t for t in topics if t['layer'] == 'conclusion'), topics[-1] if topics else {'source_data': ''})
stage1a = {
"analysis": {
"title": doc_title,
"core_message": re.sub(r"\s+", " ", str(core_topic.get("source_data", ""))).strip(),
"total_pages": 1,
'analysis': {
'title': doc_title,
'core_message': _normalize_space(str(core_topic.get('source_data', ''))),
'total_pages': 1,
'layout_template': ('A' if family == 'type-a-compare-define-relate' else ('B_GOAL' if family == 'type-b-goal-effect' else ('B_RPP' if family == 'type-b-requirements-process-product' else 'B_STACK'))),
'content_family': family,
},
"page_structure": _page_structure(topics),
"topics": topics,
'page_structure': _page_structure(topics, family),
'topics': topics,
}
stage1b = {
"concepts": [
'concepts': [
{
"topic_id": t["id"],
"relation_type": _classify(t["title"], t["layer"])[0],
"expression_hint": "?? ??? ??? ???. ??? ? ?? ??? ??? popup?? ???. visible ??? ?? ???? 85% ??? ?? ???.",
"summary": t["summary"],
'topic_id': t['id'],
'relation_type': t['relation_type'],
'expression_hint': (
'원문 제목과 원문 bullet을 우선 유지한다. 긴 세부 설명이나 큰 표는 popup으로 이동하되, 본문에는 핵심 bullet과 진입 요약을 남긴다.'
if t.get('popup_candidate') else
'원문 제목과 원문 bullet을 visible block으로 유지하고, 임의 재서술을 최소화한다.'
),
'summary': t['summary'],
}
for t in topics
]
}
plan_dir = run_dir / "04-plan"
plan_dir.mkdir(parents=True, exist_ok=True)
_write_json(plan_dir / "stage-1a-topics.json", stage1a)
_write_json(plan_dir / "stage-1b-refined-concepts.json", stage1b)
input_dir = run_dir / '01-input'
interp_dir = run_dir / '02-kei-interpretation'
structure_dir = run_dir / '03-structure'
plan_dir = run_dir / '04-plan'
for d in (input_dir, interp_dir, structure_dir, plan_dir):
d.mkdir(parents=True, exist_ok=True)
_write_json(plan_dir / 'stage-1a-topics.json', stage1a)
_write_json(plan_dir / 'stage-1b-refined-concepts.json', stage1b)
_write_json(structure_dir / 'source-blocks.json', {
'title': doc_title,
'content_family': family,
'blocks': [
{
'id': t['id'],
'title': t['title'],
'layer': t['layer'],
'relation_type': t['relation_type'],
'popup_candidate': bool(t.get('popup_candidate')),
'source_data': t['source_data'],
}
for t in topics
],
})
input_dir = run_dir / "01-input"
input_dir.mkdir(parents=True, exist_ok=True)
input_lines = [
"# Input Review",
"",
f"- ?? ???: {input_file.name}",
f"- ?? ??: {doc_title}",
"- ?? ?? ??: ?? block? ???? ?? ???? ???.",
"- ?? ??: ???? ?? 85% ?? ????, ? ?/?? ??? popup ??? ???.",
"",
"## ?? ??",
'# Input Review',
'',
f'- 입력 파일: {input_file.name}',
f'- 문서 제목: {doc_title}',
f'- content family 후보: {family}',
'- 우선 목표: 원문 block과 원문 순서를 최대한 보존한다.',
'- popup 전략: 큰 표, 긴 사례, 긴 근거는 popup 후보로 분리하고 본문에는 제목과 핵심 bullet을 남긴다.',
'',
'## 원문 블록 식별',
]
for topic in topics:
input_lines.append(f"- {topic['title']}: { _compact(re.sub(r'\s+', ' ', topic['source_data']), 160) }")
_write_text(input_dir / "input-review.md", "\n".join(input_lines) + "\n")
popup_mark = ' [popup]' if topic.get('popup_candidate') else ''
input_lines.append(f"- {topic['title']} ({topic['relation_type']}/{topic['layer']}){popup_mark}: {_compact(_normalize_space(topic['source_data']), 180)}")
_write_text(input_dir / 'input-review.md', '\n'.join(input_lines) + '\n')
interp_dir = run_dir / "02-kei-interpretation"
interp_dir.mkdir(parents=True, exist_ok=True)
interp_lines = [
"# Interpretation",
"",
"- ?? ??: ????? ?? ??? ???.",
"- ?? ??: ?? ??? ????, ??/??/popup ???? ???.",
"- popup ??: ? ?, ?? ??, ? ??? ??? popup?? ?? ???.",
"",
"## Topic Classification",
'# Interpretation',
'',
f'- content family: {family}',
'- 해석 원칙: 원문 제목/순서/표현을 우선 보존하고, 임의 재서술은 최소화한다.',
'- grouping 원칙: 관계가 같은 block만 묶고, 내용이 길다고 해서 본문에서 제거하지 않는다.',
'- popup 원칙: 상세는 popup으로 보내되 본문에는 핵심 bullet과 진입 문장을 남긴다.',
'',
'## Topic Classification',
]
for topic in topics:
interp_lines.append(f"- {topic['title']}: layer={topic['layer']} / role={topic['role']}")
_write_text(interp_dir / "kei-interpretation.md", "\n".join(interp_lines) + "\n")
interp_lines.append(
f"- {topic['title']}: relation={topic['relation_type']} / layer={topic['layer']} / popup_candidate={str(bool(topic.get('popup_candidate'))).lower()}"
)
_write_text(interp_dir / 'kei-interpretation.md', '\n'.join(interp_lines) + '\n')
structure_dir = run_dir / "03-structure"
structure_dir.mkdir(parents=True, exist_ok=True)
structure_lines = [
"# Content Structure",
"",
"- ??? ??: ?? ?? ??? ???.",
"- ??? ??: ?? ? ???? ????, ?? ???? ????.",
"- popup ??: ??? ? ?? ??? ? ?/? ??? popup?? ???.",
"",
"## Ordered Blocks",
'# Content Structure',
'',
f'- content family: {family}',
'- visible block 원칙: 각 섹션 제목과 핵심 bullet은 본문에 남긴다.',
'- popup block 원칙: 큰 표, 긴 사례, 긴 상세 설명만 popup으로 보낸다.',
'- 결론 원칙: note/결론 문장은 footer 또는 결론 배너에 직접 노출한다.',
'',
'## Ordered Blocks',
]
for idx, topic in enumerate(topics, start=1):
structure_lines.append(f"{idx}. {topic['title']} ({topic['layer']})")
_write_text(structure_dir / "content-structure.md", "\n".join(structure_lines) + "\n")
popup_mark = ' popup' if topic.get('popup_candidate') else ' visible'
structure_lines.append(f"{idx}. {topic['title']} ({topic['relation_type']} / {topic['layer']} /{popup_mark})")
_write_text(structure_dir / 'content-structure.md', '\n'.join(structure_lines) + '\n')
plan_lines = [
"# Execution Plan",
"",
"- ??? raw mdx?? ?? ???? stage-1a/stage-1b? ???.",
"- ?? ??? ??? ???.",
"- ?? ??, ? ?, ??? ?? ??? popup?? ?? ???.",
"- visible ??? section title + ?? bullet + ?? ?? ???? ???.",
'# Execution Plan',
'',
f'- content family: {family}',
'- stage-1a/stage-1b는 raw MDX 기반 block 추출 결과를 그대로 사용한다.',
'- Type A는 비교/정의/관계형으로, Type B는 본문 중심형으로 렌더한다.',
'- popup 후보 block은 삭제하지 않고 popup overlay로 이동한다.',
'- visible 영역에는 섹션 제목과 핵심 bullet을 남겨 원문 85% 보존 목표를 유지한다.',
]
_write_text(plan_dir / "execution-plan.md", "\n".join(plan_lines) + "\n")
_write_text(plan_dir / 'execution-plan.md', '\n'.join(plan_lines) + '\n')
return {"title": doc_title, "topics": topics}
return {'title': doc_title, 'topics': topics, 'content_family': family}

View File

@@ -103,6 +103,7 @@ def _stage_1a(ctx: PipelineContext, stage1a: dict) -> PipelineContext:
core_message=analysis_raw['core_message'],
title=analysis_raw['title'],
total_pages=analysis_raw.get('total_pages', 1),
layout_template=analysis_raw.get('layout_template', 'A'),
)
ctx.page_structure = PageStructure(roles=stage1a['page_structure'])
ctx.topics = [Topic(**raw) for raw in stage1a['topics']]
@@ -812,6 +813,13 @@ def _flatten_group_items(groups: list[dict[str, list[str] | str]]) -> list[str]:
def _detect_generic_layout_family(ctx: PipelineContext, raw: str) -> str:
template = getattr(getattr(ctx, 'analysis', None), 'layout_template', '') or ''
if template == 'B_GOAL':
return 'goal-image-stakeholder'
if template == 'B_RPP':
return 'requirements-process-product'
if template == 'B_STACK':
return 'section-stack'
relation_types = {getattr(t, 'relation_type', '') for t in ctx.topics}
if '<DxEffect' in raw or 'stakeholder_effect' in relation_types:
return 'goal-image-stakeholder'
@@ -832,9 +840,8 @@ def _build_goal_image_stakeholder_layout(ctx: PipelineContext, raw: str) -> dict
conclusion_text = _prefer_source_text(conclusion_topic, ctx.analysis.core_message if ctx.analysis else '')
goal_groups = _extract_grouped_bullets(_extract_heading_block(raw, goal_title), base_indent=0)[:3]
goal_popup_lines = _flatten_group_items(goal_groups)
process_block_raw = _extract_heading_block(raw, process_title)
process_lines = _bullet_lines_from_block(process_block_raw, limit=8)
process_lines = _bullet_lines_from_block(process_block_raw, limit=10)
process_popup_lines = process_lines[:] or _flatten_group_items(_extract_grouped_bullets(process_block_raw, base_indent=0))
dx_cards = _load_dx_effect_cards()
@@ -849,68 +856,73 @@ def _build_goal_image_stakeholder_layout(ctx: PipelineContext, raw: str) -> dict
image_src = ''
image_caption = _extract_caption_from_raw(raw) or goal_title
goal_sections_html = ''.join(
'<div style="background:#ffffff; border:1px solid #d6e2ef; border-left:6px solid {color}; border-radius:12px; padding:10px 12px;">'
'<div style="font-size:12px; font-weight:900; color:#0f172a; margin-bottom:6px;">{title}</div>'
'<ul style="margin:0; padding-left:16px; font-size:8.8px; line-height:1.28; color:#334155;">{items}</ul>'
goal_summary_strips = ''.join(
'<div style="background:#ffffff; border:1px solid #d6e2ef; border-top:5px solid {color}; border-radius:10px; padding:6px 8px; min-height:60px;">'
'<div style="font-size:9.6px; font-weight:900; color:#0f172a; margin-bottom:3px;">{title}</div>'
'<div style="font-size:8px; line-height:1.18; color:#334155;">{item}</div>'
'</div>'.format(
color=color,
title=group['title'],
items=_line_list_html([_plain_text(str(item)) for item in group.get('items', [])[:1]], floor=170, ceiling=360, margin_bottom=4),
item=_trim_visible_copy(_plain_text(str(group.get('items', [''])[0])), floor=90, ceiling=180),
)
for group, color in zip(goal_groups, ['#c2410c', '#8b6b2e', '#166534'])
)
goal_popup = _popup_overlay('popup-goal', goal_title, _popup_list_html(goal_popup_lines, floor=240, ceiling=900)) if goal_popup_lines else ''
process_popup = _popup_overlay('popup-process', process_title, _popup_list_html(process_popup_lines, floor=240, ceiling=900)) if process_popup_lines else ''
stakeholder_popup = _popup_overlay('popup-stakeholder', support_title, _popup_list_html(stakeholder_popup_lines, floor=240, ceiling=900)) if stakeholder_popup_lines else ''
goal_popup = _popup_overlay('popup-goal', goal_title, _popup_list_html(_flatten_group_items(goal_groups), floor=220, ceiling=900)) if goal_groups else ''
process_popup = _popup_overlay('popup-process', process_title, _popup_list_html(process_popup_lines, floor=220, ceiling=900)) if process_popup_lines else ''
stakeholder_popup = _popup_overlay('popup-stakeholder', support_title, _popup_list_html(stakeholder_popup_lines, floor=220, ceiling=900)) if stakeholder_popup_lines else ''
visual_html = _relation_visual(image_src, image_caption).replace('height:220px', 'height:104px').replace('padding:10px', 'padding:4px') if image_src else _section_card('Goal visual', [_trim_visible_copy(_prefer_source_text(goal_topic, ''), floor=120, ceiling=260)], tone='blue')
goal_card = (
'<div style="background:#ffffff; border:1px solid #cbd5e1; border-radius:14px; padding:12px; box-sizing:border-box;">'
f'<div style="font-size:15px; font-weight:900; color:#0f172a; margin-bottom:8px;">{goal_title}</div>'
'<div style="display:grid; grid-template-columns:1.06fr 0.94fr; gap:10px; align-items:stretch;">'
f'<div style="display:flex; flex-direction:column; gap:8px;">{goal_sections_html}</div>'
'<div style="display:flex; flex-direction:column; gap:4px;">'
f'{_relation_visual(image_src, image_caption).replace("height:220px", "height:132px").replace("padding:10px", "padding:6px")}'
f'<div style="font-size:8.5px; line-height:1.2; color:#64748b; text-align:center;">{image_caption}</div>'
'<div style="background:#ffffff; border:1px solid #cbd5e1; border-radius:14px; padding:8px; box-sizing:border-box;">'
f'<div style="font-size:12.5px; font-weight:900; color:#0f172a; margin-bottom:5px;">{goal_title}</div>'
'<div style="display:grid; grid-template-columns:1.08fr 0.92fr; gap:8px; align-items:start;">'
f'<div style="display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:6px; align-items:stretch;">{goal_summary_strips}</div>'
'<div style="display:flex; flex-direction:column; gap:3px; align-items:center;">'
f'{visual_html}'
f'<div style="font-size:7.2px; line-height:1.12; color:#64748b; text-align:center;">{image_caption}</div>'
'</div></div>'
f'<div style="display:flex; justify-content:flex-end; margin-top:6px;">{_popup_button("popup-goal", "Details")}</div>'
f'<div style="display:flex; justify-content:flex-end; margin-top:4px;">{_popup_button("popup-goal", "Goal details")}</div>'
'</div>'
)
process_card = (
'<div style="background:#ffffff; border:1px solid #cbd5e1; border-radius:14px; padding:10px; box-sizing:border-box;">'
f'<div style="font-size:12.5px; font-weight:900; color:#0f172a; margin-bottom:6px;">{process_title}</div>'
f'<ul style="margin:0; padding-left:18px; font-size:9px; line-height:1.34; color:#334155;">{_line_list_html(process_lines[:4], floor=190, ceiling=480, margin_bottom=4)}</ul>'
f'<div style="display:flex; justify-content:flex-end; margin-top:6px;">{_popup_button("popup-process", "Details")}</div>'
'<div style="background:#ffffff; border:1px solid #cbd5e1; border-radius:14px; padding:9px; box-sizing:border-box; min-height:118px;">'
f'<div style="font-size:11px; font-weight:900; color:#0f172a; margin-bottom:5px;">{process_title}</div>'
f'<ul style="margin:0; padding-left:16px; font-size:8.8px; line-height:1.28; color:#334155;">{_line_list_html(process_lines[:4], floor=160, ceiling=340, margin_bottom=2)}</ul>'
f'<div style="display:flex; justify-content:flex-end; margin-top:4px;">{_popup_button("popup-process", "Process details")}</div>'
'</div>'
)
if dx_cards:
stakeholder_body = ''.join(
'<div style="margin-bottom:8px;">'
f'<div style="font-size:10.5px; font-weight:800; color:#1e3a8a; margin-bottom:3px;">{title}</div>'
f'<ul style="margin:0; padding-left:14px; font-size:8.6px; line-height:1.3; color:#334155;">{_line_list_html(lines[:1], floor=170, ceiling=380, margin_bottom=3)}</ul>'
stakeholder_preview = ''.join(
'<div style="background:#ffffff; border:1px solid #d7e2f0; border-radius:10px; padding:7px 9px;">'
f'<div style="font-size:10px; font-weight:800; color:#1e3a8a; margin-bottom:3px;">{title}</div>'
f'<div style="font-size:8.4px; line-height:1.24; color:#334155;">{_trim_visible_copy(lines[0], floor=100, ceiling=180) if lines else ""}</div>'
'</div>'
for title, lines in dx_cards[:3]
for title, lines in dx_cards[:4]
)
else:
stakeholder_body = f'<div style="font-size:9.4px; line-height:1.45; color:#475569;">{_trim_visible_copy(_prefer_source_text(support_topic, "No stakeholder detail available."), floor=260, ceiling=560)}</div>'
stakeholder_preview = ''.join(
f'<div style="font-size:8.8px; line-height:1.3; color:#334155;">{_trim_visible_copy(_prefer_source_text(support_topic, ""), floor=120, ceiling=240)}</div>'
)
stakeholder_card = (
'<div style="background:#ffffff; border:1px solid #cbd5e1; border-radius:14px; padding:10px; box-sizing:border-box;">'
f'<div style="font-size:13px; font-weight:900; color:#0f172a; margin-bottom:8px;">{support_title}</div>'
f'{stakeholder_body}'
f'<div style="display:flex; justify-content:flex-end; margin-top:6px;">{_popup_button("popup-stakeholder", "??Details")}</div>'
'<div style="background:#f8fafc; border:1px solid #cbd5e1; border-radius:14px; padding:9px; box-sizing:border-box; min-height:118px;">'
f'<div style="font-size:11px; font-weight:900; color:#0f172a; margin-bottom:5px;">{support_title}</div>'
f'<div style="display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:6px;">{stakeholder_preview}</div>'
f'<div style="display:flex; justify-content:flex-end; margin-top:4px;">{_popup_button("popup-stakeholder", "Stakeholder details")}</div>'
'</div>'
)
lower_block = '<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; align-items:start;">' + process_card + stakeholder_card + '</div>'
lower_block = '<div style="display:grid; grid-template-columns:1fr 1fr; gap:8px; align-items:start;">' + process_card + stakeholder_card + '</div>'
body_inner = f'{goal_card}{lower_block}{goal_popup}{process_popup}{stakeholder_popup}'
body_html = _type_b_body_shell(body_inner)
sidebar_html = '<div style="width:100%; height:100%; opacity:0; pointer-events:none;"></div>'
footer_html = '<div style="background:linear-gradient(135deg, #0b6ef3 0%, #17a6f5 100%); border-radius:10px; padding:5px 16px; text-align:center; color:#ffffff; width:100%; height:40px; display:flex; align-items:center; justify-content:center; box-sizing:border-box;">' + f'<div style="font-size:11.5px; font-weight:900; line-height:1.28;">{_trim_visible_copy(conclusion_text, floor=150, ceiling=420)}</div>' + '</div>'
return {'body_html': body_html, 'sidebar_html': sidebar_html, 'footer_html': footer_html, 'reasoning': 'goal-image-stakeholder layout selected from document content traits'}
footer_html = '<div style="background:linear-gradient(135deg, #0b6ef3 0%, #17a6f5 100%); border-radius:10px; padding:4px 14px; text-align:center; color:#ffffff; width:100%; height:34px; display:flex; align-items:center; justify-content:center; box-sizing:border-box;">' + f'<div style="font-size:10.2px; font-weight:900; line-height:1.18;">{_trim_visible_copy(conclusion_text, floor=140, ceiling=360)}</div>' + '</div>'
return {'body_html': body_html, 'sidebar_html': sidebar_html, 'footer_html': footer_html, 'reasoning': 'goal/effect Type B layout selected from document content traits'}
def _build_requirements_process_product_layout(ctx: PipelineContext, raw: str) -> dict:
@@ -935,9 +947,9 @@ def _build_requirements_process_product_layout(ctx: PipelineContext, raw: str) -
product_popup = _popup_overlay('popup-product', product_title, _popup_list_html(_flatten_group_items(product_groups), floor=240, ceiling=940))
req_cards = ''.join(
'<div style="background:#ffffff; border:1px solid #d7e2f0; border-top:5px solid {color}; border-radius:12px; padding:10px 12px; min-height:104px;">'
'<div style="font-size:11px; font-weight:900; color:#0f172a; margin-bottom:6px;">{title}</div>'
'<ul style="margin:0; padding-left:16px; font-size:9.2px; line-height:1.38; color:#334155;">{items}</ul>'
'<div style="background:#ffffff; border:1px solid #d7e2f0; border-top:5px solid {color}; border-radius:12px; padding:8px 10px; min-height:90px;">'
'<div style="font-size:10px; font-weight:900; color:#0f172a; margin-bottom:5px;">{title}</div>'
'<ul style="margin:0; padding-left:15px; font-size:8.7px; line-height:1.3; color:#334155;">{items}</ul>'
'</div>'.format(
color=color,
title=group['title'],
@@ -946,9 +958,9 @@ def _build_requirements_process_product_layout(ctx: PipelineContext, raw: str) -
for group, color in zip(req_groups, ['#2563eb', '#7c3aed', '#16a34a'])
)
requirements_block = (
'<div style="background:#ffffff; border:1px solid #cbd5e1; border-radius:14px; padding:12px; box-sizing:border-box;">'
f'<div style="font-size:14px; font-weight:900; color:#0f172a; margin-bottom:8px;">{req_title}</div>'
f'<div style="display:grid; grid-template-columns:repeat(3, minmax(0,1fr)); gap:8px;">{req_cards}</div>'
'<div style="background:#ffffff; border:1px solid #cbd5e1; border-radius:14px; padding:10px; box-sizing:border-box;">'
f'<div style="font-size:13px; font-weight:900; color:#0f172a; margin-bottom:6px;">{req_title}</div>'
f'<div style="display:grid; grid-template-columns:repeat(3, minmax(0,1fr)); gap:6px;">{req_cards}</div>'
f'<div style="display:flex; justify-content:flex-end; margin-top:6px;">{_popup_button("popup-req", "Details")}</div>'
'</div>'
)
@@ -958,15 +970,15 @@ def _build_requirements_process_product_layout(ctx: PipelineContext, raw: str) -
process_left_title = process_left_groups[0]['title'] if process_left_groups else process_title
process_left_lines = process_table_lines or _flatten_group_items(process_left_groups)
process_left_card = (
'<div style="background:#f8fafc; border:1px solid #d7e2f0; border-radius:12px; padding:12px; min-height:142px;">'
f'<div style="font-size:11px; font-weight:800; color:#1e3a8a; margin-bottom:6px;">{process_left_title}</div>'
f'<ul style="margin:0; padding-left:16px; font-size:9.2px; line-height:1.38; color:#334155;">{_line_list_html(process_left_lines[:4], floor=200, ceiling=500, margin_bottom=3)}</ul>'
'<div style="background:#f8fafc; border:1px solid #d7e2f0; border-radius:12px; padding:10px; min-height:126px;">'
f'<div style="font-size:10px; font-weight:800; color:#1e3a8a; margin-bottom:5px;">{process_left_title}</div>'
f'<ul style="margin:0; padding-left:15px; font-size:8.8px; line-height:1.3; color:#334155;">{_line_list_html(process_left_lines[:4], floor=200, ceiling=500, margin_bottom=2)}</ul>'
'</div>'
)
process_right_cards = ''.join(
'<div style="background:#f8fafc; border:1px solid #d7e2f0; border-radius:12px; padding:10px 12px; min-height:66px;">'
'<div style="font-size:10.8px; font-weight:800; color:#1e3a8a; margin-bottom:5px;">{title}</div>'
'<ul style="margin:0; padding-left:16px; font-size:9px; line-height:1.36; color:#334155;">{items}</ul>'
'<div style="background:#f8fafc; border:1px solid #d7e2f0; border-radius:12px; padding:8px 10px; min-height:60px;">'
'<div style="font-size:10px; font-weight:800; color:#1e3a8a; margin-bottom:4px;">{title}</div>'
'<ul style="margin:0; padding-left:15px; font-size:8.6px; line-height:1.28; color:#334155;">{items}</ul>'
'</div>'.format(
title=group['title'],
items=_line_list_html([_plain_text(str(item)) for item in group.get('items', [])], floor=190, ceiling=420, margin_bottom=3),
@@ -976,9 +988,9 @@ def _build_requirements_process_product_layout(ctx: PipelineContext, raw: str) -
process_card = (
'<div style="background:#ffffff; border:1px solid #cbd5e1; border-radius:14px; padding:12px; box-sizing:border-box;">'
f'<div style="font-size:13px; font-weight:900; color:#0f172a; margin-bottom:8px;">{process_title}</div>'
'<div style="display:grid; grid-template-columns:1.12fr 0.88fr; gap:8px; align-items:start;">'
'<div style="display:grid; grid-template-columns:1fr 1fr; gap:6px; align-items:start;">'
f'{process_left_card}'
f'<div style="display:grid; grid-auto-rows:minmax(0,1fr); gap:8px;">{process_right_cards}</div>'
f'<div style="display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:6px;">{process_right_cards}</div>'
'</div>'
f'<div style="display:flex; justify-content:flex-end; margin-top:6px;">{_popup_button("popup-process", "Details")}</div>'
'</div>'
@@ -999,12 +1011,12 @@ def _build_requirements_process_product_layout(ctx: PipelineContext, raw: str) -
'</div>'
)
lower_block = '<div style="display:grid; grid-template-columns:1.02fr 0.98fr; gap:10px; align-items:start;">' + process_card + product_card + '</div>'
lower_block = '<div style="display:grid; grid-template-columns:1fr 1fr; gap:8px; align-items:start;">' + process_card + product_card + '</div>'
body_inner = f'{requirements_block}{lower_block}{req_popup}{process_popup}{product_popup}'
body_html = _type_b_body_shell(body_inner)
sidebar_html = '<div style="width:100%; height:100%; opacity:0; pointer-events:none;"></div>'
footer_html = '<div style="background:linear-gradient(135deg, #0b6ef3 0%, #17a6f5 100%); border-radius:10px; padding:7px 16px; text-align:center; color:#ffffff; width:100%; height:48px; display:flex; align-items:center; justify-content:center; box-sizing:border-box;">' + f'<div style="font-size:11.5px; font-weight:900; line-height:1.28;">{_trim_visible_copy(conclusion_text, floor=150, ceiling=420)}</div>' + '</div>'
footer_html = '<div style="background:linear-gradient(135deg, #0b6ef3 0%, #17a6f5 100%); border-radius:10px; padding:5px 14px; text-align:center; color:#ffffff; width:100%; height:40px; display:flex; align-items:center; justify-content:center; box-sizing:border-box;">' + f'<div style="font-size:10.6px; font-weight:900; line-height:1.2;">{_trim_visible_copy(conclusion_text, floor=150, ceiling=420)}</div>' + '</div>'
return {'body_html': body_html, 'sidebar_html': sidebar_html, 'footer_html': footer_html, 'reasoning': 'requirements-process-product layout selected from document content traits'}