3 Commits
707 changed files with 1531 additions and 722183 deletions
+1 -82
View File
@@ -400,88 +400,7 @@
"Read(//tmp/**)", "Read(//tmp/**)",
"Read(//d/tmp/**)", "Read(//d/tmp/**)",
"Bash(python run_mdx03_pipeline.py --phase-z2 --run-id mvp1.5b_test5)", "Bash(python run_mdx03_pipeline.py --phase-z2 --run-id mvp1.5b_test5)",
"Bash(python run_mdx03_pipeline.py --phase-z2 --run-id mvp1.5b_test7)", "Bash(python run_mdx03_pipeline.py --phase-z2 --run-id mvp1.5b_test7)"
"Bash(git commit -m ' *)",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\"; \"Content-Type\" = \"application/json\" }; $body = @{ state = \"open\" } | ConvertTo-Json; Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/90\" -Method Patch -Headers $headers -Body $body | Select-Object number, state, title)",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $resp = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/90\" -Method Get -Headers $headers; \"#$\\($resp.number\\) state=$\\($resp.state\\) title=$\\($resp.title\\)\")",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\"; \"Content-Type\" = \"application/json\" }; $body = @{ state = \"closed\" } | ConvertTo-Json; $bytes = [System.Text.Encoding]::UTF8.GetBytes\\($body\\); Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/90\" -Method Patch -Headers $headers -Body $bytes | Select-Object number, state, closed_at, title | Format-List)",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $all = @\\(\\); $page = 1; do { $url = \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues?state=closed&type=issues&page=$page&limit=50\"; $batch = Invoke-RestMethod -Uri $url -Method Get -Headers $headers; $all += $batch; $page++ } while \\($batch.Count -eq 50\\); \"Total closed issues: $\\($all.Count\\)\"; $all | Select-Object number, title | Sort-Object number | Format-Table -AutoSize -Wrap)",
"Bash(git log *)",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; foreach \\($n in 14, 64, 74, 91\\) { $r = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/$n\" -Method Get -Headers $headers; \"#$\\($r.number\\) state=$\\($r.state\\) closed_at=$\\($r.closed_at\\)\" })",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $issue = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/84\" -Method Get -Headers $headers; \"=== #84 META ===\"; \"state=$\\($issue.state\\) created=$\\($issue.created_at\\) closed=$\\($issue.closed_at\\)\"; \"title=$\\($issue.title\\)\"; \"\"; \"=== #84 BODY ===\"; $issue.body)",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $comments = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/84/comments\" -Method Get -Headers $headers; \"=== #84 COMMENT COUNT: $\\($comments.Count\\) ===\"; \"\"; $i = 0; foreach \\($c in $comments\\) { $i++; $bodyPreview = $c.body.Substring\\(0, [Math]::Min\\(200, $c.body.Length\\)\\); \"--- [$i] id=$\\($c.id\\) created=$\\($c.created_at\\) ---\"; \"$bodyPreview\"; \"\" })",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $r = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/83\" -Method Get -Headers $headers; \"#83 state=$\\($r.state\\) closed_at=$\\($r.closed_at\\) title=$\\($r.title\\)\")",
"Bash(git status *)",
"Bash(python -m py_compile orchestrator.py)",
"Bash(echo \"origin = $\\(git ls-remote origin refs/heads/main)",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\"; \"Content-Type\" = \"application/json\" }; $body = @{ state = \"open\" } | ConvertTo-Json; Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/83\" -Method Patch -Headers $headers -Body $body | Select-Object number, state, closed_at, title | Format-List)",
"Bash(git restore *)",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $r83 = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/83\" -Method Get -Headers $headers; \"#83 state=$\\($r83.state\\) closed_at=[$\\($r83.closed_at\\)] labels=[$\\($r83.labels.Count\\)]\")",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $cs = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/84/comments?limit=100\" -Method Get -Headers $headers; \"#84 current comment count = $\\($cs.Count\\)\")",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $cs = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/84/comments?limit=100\" -Method Get -Headers $headers; \"Total comments now: $\\($cs.Count\\)\"; \"Recent 3:\"; $cs | Select-Object -Last 3 | ForEach-Object { $preview = $_.body.Substring\\(0, [Math]::Min\\(80, $_.body.Length\\)\\); \" id=$\\($_.id\\) [$preview...]\" })",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $r = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/84\" -Method Get -Headers $headers; \"#84 Gitea: state=$\\($r.state\\) closed_at=[$\\($r.closed_at\\)]\")",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\"; \"Content-Type\" = \"application/json\" }; $body = @{ state = \"closed\" } | ConvertTo-Json; $bytes = [System.Text.Encoding]::UTF8.GetBytes\\($body\\); Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/84\" -Method Patch -Headers $headers -Body $bytes | Select-Object number, state, closed_at, title | Format-List)",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $r84 = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/84\" -Method Get -Headers $headers; \"#84 state=$\\($r84.state\\) closed_at=$\\($r84.closed_at\\)\"; \"\"; \"orchestrator HEAD = $\\(git rev-parse HEAD\\)\"; \"orchestrator fix commits in chain:\"; git log --oneline -3 -- orchestrator.py)",
"Bash(python -m py_compile src/phase_z2_pipeline.py)",
"Bash(grep -n \"^import re\\\\|^import re$\" src/phase_z2_pipeline.py)",
"Bash(grep -n \"^import os\\\\|^import os$\" src/phase_z2_pipeline.py)",
"Bash(python -m src.phase_z2_pipeline samples/mdx_batch/05.mdx mdx05_emergency_p3)",
"Bash(python -m src.phase_z2_pipeline samples/mdx_batch/04.mdx mdx04_emergency_p3)",
"Bash(python -c \"from src.config import settings; print\\(f'ai_fallback_enabled={settings.ai_fallback_enabled} model={settings.ai_fallback_model} has_api_key={bool\\(settings.anthropic_api_key\\)}'\\)\")",
"Bash(python -m src.phase_z2_pipeline samples/mdx_batch/05.mdx mdx05_emergency_p4)",
"Bash(python -m src.phase_z2_pipeline samples/mdx_batch/04.mdx mdx04_emergency_p4)",
"Bash(python -m src.phase_z2_pipeline samples/mdx_batch/04.mdx mdx04_emergency_p4b)",
"Bash(python -m src.phase_z2_pipeline samples/mdx_batch/04.mdx mdx04_p4b_full)",
"Bash(python -m src.phase_z2_pipeline samples/mdx_batch/03.mdx mdx03_layout_test --override-layout vertical-2)",
"Bash(python -m src.phase_z2_pipeline samples/mdx_batch/05.mdx mdx05_layout_test --override-layout horizontal-2)",
"Bash(cp data/user_overrides/*.json data/user_overrides_backup_20260526/)",
"Bash(rm -f data/user_overrides/*.json)",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $issue = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/94\" -Method Get -Headers $headers; \"#94 state=$\\($issue.state\\)\"; \"title=$\\($issue.title\\)\"; \"\"; \"=== body ===\"; $issue.body)",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $cs = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/94/comments\" -Method Get -Headers $headers; \"Total comments: $\\($cs.Count\\)\"; foreach \\($c in $cs | Select-Object -Last 5\\) { \"--- comment_id=$\\($c.id\\) ---\"; $c.body.Substring\\(0, [Math]::Min\\(600, $c.body.Length\\)\\); \"\" })",
"Bash(git ls-tree *)",
"Bash(grep -i \".html$\")",
"Bash(grep \".html$\")",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $issue = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/98\" -Method Get -Headers $headers; \"=== #$\\($issue.number\\) state=$\\($issue.state\\) ===\"; $issue.title; \"\"; \"=== BODY \\(truncated\\) ===\"; $issue.body.Substring\\(0, [Math]::Min\\(3000, $issue.body.Length\\)\\))",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $issue = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/98\" -Method Get -Headers $headers; if \\($issue.body.Length -gt 3000\\) { $issue.body.Substring\\(3000, [Math]::Min\\(4000, $issue.body.Length - 3000\\)\\) })",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $cs = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/98/comments\" -Method Get -Headers $headers; \"Total: $\\($cs.Count\\)\"; \"\"; \"=== LATEST 2 comments ===\"; foreach \\($c in $cs | Select-Object -Last 2\\) { \"--- comment_id=$\\($c.id\\) created=$\\($c.created_at\\) ---\"; $c.body.Substring\\(0, [Math]::Min\\(3000, $c.body.Length\\)\\); \"\" })",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $cs = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/98/comments\" -Method Get -Headers $headers; $last = $cs[-1]; if \\($last.body.Length -gt 3000\\) { $last.body.Substring\\(3000, [Math]::Min\\(4500, $last.body.Length - 3000\\)\\) })",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $cs = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/98/comments\" -Method Get -Headers $headers; $last = $cs[-1]; if \\($last.body.Length -gt 7500\\) { $last.body.Substring\\(7500, $last.body.Length - 7500\\) })",
"Bash(echo '=== task8_e2e___TRACKED_VAR__ ===')",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $c = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/comments/28849\" -Method Get -Headers $headers; \"comment_id=$\\($c.id\\) created=$\\($c.created_at\\)\"; \"\"; $c.body)",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $cs = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/98/comments\" -Method Get -Headers $headers; \"Total: $\\($cs.Count\\)\"; \"\"; \"=== Recent comments \\(last 8\\) — id / time / first line ===\"; foreach \\($c in $cs | Select-Object -Last 8\\) { $first = \\($c.body -split \"`n\"\\)[0]; \"[$\\($c.id\\)] $\\($c.created_at\\) | $first\" })",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $cs = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/98/comments\" -Method Get -Headers $headers; foreach \\($id in 28850, 28856, 28857, 28858, 28859\\) { $c = $cs | Where-Object { $_.id -eq $id }; if \\($c\\) { \"═══════════════════════════════════════════════════════\"; \"ID $id @ $\\($c.created_at\\)\"; \"═══════════════════════════════════════════════════════\"; $c.body.Substring\\(0, [Math]::Min\\(1800, $c.body.Length\\)\\); \"\" } })",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $cs = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/98/comments\" -Method Get -Headers $headers; \"Total: $\\($cs.Count\\)\"; \"\"; \"=== All comments after 28850 ===\"; foreach \\($c in $cs | Where-Object { $_.id -gt 28850 }\\) { $first = \\($c.body -split \"`n\"\\)[0]; \"[$\\($c.id\\)] $\\($c.created_at\\) | $first\" })",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $cs = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/98/comments\" -Method Get -Headers $headers; \"Total: $\\($cs.Count\\)\"; \"\"; foreach \\($c in $cs | Where-Object { $_.id -gt 28850 }\\) { $first = \\($c.body -split \"`n\"\\)[0]; \"[$\\($c.id\\)] $\\($c.created_at\\) | $first\" })",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $c = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/comments/28868\" -Method Get -Headers $headers; $c.body)",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $cs = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/98/comments\" -Method Get -Headers $headers; \"Total: $\\($cs.Count\\)\"; \"\"; foreach \\($c in $cs | Where-Object { $_.id -gt 28868 }\\) { $first = \\($c.body -split \"`n\"\\)[0]; \"[$\\($c.id\\)] $\\($c.created_at\\) | $first\" })",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $c = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/comments/28869\" -Method Get -Headers $headers; \"Length: $\\($c.body.Length\\)\"; \"\"; $c.body)",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $cs = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/98/comments\" -Method Get -Headers $headers; \"Total: $\\($cs.Count\\)\"; \"\"; foreach \\($c in $cs | Where-Object { $_.id -gt 28869 }\\) { $first = \\($c.body -split \"`n\"\\)[0]; \"[$\\($c.id\\)] $\\($c.created_at\\) | $first\" })",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; foreach \\($id in 28871, 28872, 28873, 28907\\) { $c = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/comments/$id\" -Method Get -Headers $headers; \"═══ [$id] @ $\\($c.created_at\\) ═══\"; $c.body.Substring\\(0, [Math]::Min\\(2000, $c.body.Length\\)\\); \"\" })",
"Bash(python -m pytest -q tests/test_phase_z2_task15_summary_footer.py tests/test_phase_z2_task14_parent_units.py tests/test_mdx_text_atoms.py)",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $cs = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/98/comments\" -Method Get -Headers $headers; \"Total: $\\($cs.Count\\)\"; \"\"; foreach \\($c in $cs | Where-Object { $_.id -gt 28907 }\\) { $first = \\($c.body -split \"`n\"\\)[0]; \"[$\\($c.id\\)] $\\($c.created_at\\) | $first\" })",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $c = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/comments/28908\" -Method Get -Headers $headers; $c.body)",
"Bash(python -m pytest -q tests/test_phase_z2_task18_5_semantic_sanitizer.py)",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $c = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/comments/28916\" -Method Get -Headers $headers; \"Length: $\\($c.body.Length\\)\"; \"\"; $c.body)",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; foreach \\($id in 28928, 28929\\) { $c = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/comments/$id\" -Method Get -Headers $headers; \"═══ [$id] @ $\\($c.created_at\\) \\($\\($c.body.Length\\) chars\\) ═══\"; $c.body.Substring\\(0, [Math]::Min\\(3000, $c.body.Length\\)\\); \"\" })",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $c = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/comments/28955\" -Method Get -Headers $headers; \"Length: $\\($c.body.Length\\) @ $\\($c.created_at\\)\"; \"\"; $c.body)",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $c = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/comments/28965\" -Method Get -Headers $headers; \"Length: $\\($c.body.Length\\) @ $\\($c.created_at\\)\"; \"\"; $c.body)",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $c = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/comments/29012\" -Method Get -Headers $headers; \"Length: $\\($c.body.Length\\) @ $\\($c.created_at\\)\"; \"\"; $c.body.Substring\\(0, [Math]::Min\\(2500, $c.body.Length\\)\\))",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $c = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/comments/29018\" -Method Get -Headers $headers; \"Length: $\\($c.body.Length\\) @ $\\($c.created_at\\)\"; \"\"; $c.body.Substring\\(0, [Math]::Min\\(2500, $c.body.Length\\)\\))",
"Bash(python scripts/check_mdx_source_parity.py --max-items 1)",
"Bash(sed -n '8025,8040p' src/phase_z2_pipeline.py)",
"Bash(python -m pytest tests/test_phase_z2_task22_section_tree.py -q)",
"Bash(python scripts/check_mdx_source_parity.py)",
"Bash(python -c \"import json; d=json.load\\(open\\('Front/package.json'\\)\\); print\\('scripts:', d.get\\('scripts'\\)\\)\")",
"Bash(python -c \"import json; d=json.load\\(open\\('Front_test_v515/package.json'\\)\\); print\\('name:', d.get\\('name'\\)\\); print\\('scripts:', d.get\\('scripts'\\)\\)\")",
"Bash(python -m pytest tests/test_phase_z2_task23_component_expansion.py -q)",
"Bash(python -m pytest tests/test_phase_z2_task24_layout_policy.py -q)",
"Bash(python -m pytest tests/test_phase_z2_task22_section_tree.py tests/test_phase_z2_task23_component_expansion.py -q)",
"Bash(python -m pytest tests/test_phase_z2_task26_coverage_state.py -q)",
"Bash(python -m pytest tests/test_phase_z2_task22_section_tree.py tests/test_phase_z2_task23_component_expansion.py tests/test_phase_z2_task24_layout_policy.py tests/test_phase_z2_task25_slot_mapping.py tests/test_phase_z2_task26_coverage_state.py -q)",
"Bash(python -m pytest tests/test_phase_z2_task26_coverage_state.py tests/test_phase_z2_task25_slot_mapping.py tests/test_phase_z2_task23_component_expansion.py -q)",
"Bash(npx vitest *)",
"Bash(python -m pytest tests/test_phase_z2_task27_expand_state.py -q)",
"Bash(python -m pytest tests/test_phase_z2_task22_section_tree.py tests/test_phase_z2_task23_component_expansion.py tests/test_phase_z2_task24_layout_policy.py tests/test_phase_z2_task25_slot_mapping.py tests/test_phase_z2_task26_coverage_state.py tests/test_phase_z2_task27_expand_state.py -q)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"d:\\ad-hoc\\kei\\design_agent\\templates\\blocks\\new", "d:\\ad-hoc\\kei\\design_agent\\templates\\blocks\\new",
@@ -1,71 +0,0 @@
name: Multi-MDX Regression (IMP-91)
# IMP-#91 u13 — auto-gate the mdx 01-05 acceptance set on every push to main
# and on PRs targeting main. Failure of any integration test blocks the
# commit. JSON report is emitted via pytest-json-report (u12 dep) and
# uploaded as an artifact for u14/u15 status-board updater consumption.
#
# [[feedback_validation_first_for_closed_issues]] — fresh subprocess per CI run.
# [[feedback_auto_pipeline_first]] — no manual review queue; deterministic gate.
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
multi-mdx-regression:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
- name: Install Chrome and ChromeDriver
uses: browser-actions/setup-chrome@v1
with:
install-chromedriver: true
- name: Install project (dev extras + selenium)
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
python -m pip install "selenium>=4.20"
- name: Run multi-mdx regression tests
run: |
python -m pytest -q -m integration \
tests/integration/test_multi_mdx_regression.py \
--json-report \
--json-report-file=imp91-report.json \
--json-report-omit keywords streams
- name: Upload pytest JSON report
if: always()
uses: actions/upload-artifact@v4
with:
name: imp91-multi-mdx-report
path: imp91-report.json
if-no-files-found: warn
- name: Update status-board markers (IMP-91 u15)
if: always()
run: |
python scripts/update_status_board.py \
--report imp91-report.json \
--board docs/architecture/PHASE-Z-PIPELINE-STATUS-BOARD.md
- name: Upload updated status board
if: always()
uses: actions/upload-artifact@v4
with:
name: imp91-status-board
path: docs/architecture/PHASE-Z-PIPELINE-STATUS-BOARD.md
if-no-files-found: warn
-13
View File
@@ -16,16 +16,3 @@ data/frame_cache/*
# session workspace (push X — 작업 흐름 trace, 사용자 결정 2026-05-08) # session workspace (push X — 작업 흐름 trace, 사용자 결정 2026-05-08)
forex/ forex/
# 임시/생성물 정리 커밋 제외 (사용자 결정 2026-07-02)
/tmp*.json
/ad-hoc*.json
/D*tmp*.json
.orchestrator/
chromedriver/
*.pkl
.claude/settings.local.json
# 프로젝트 전체 스냅샷 사본 (395MB/130MB — push 제외, 사용자 결정 2026-07-02)
Front_test/
Front_test_v515/
@@ -1,569 +0,0 @@
{
"v4_source": "tests\\matching\\v4_full32_result.yaml",
"v4_label_to_phase_z_status": {
"use_as_is": "matched_zone",
"light_edit": "adapt_matched_zone",
"restructure": "extract_matched_zone",
"reject": "fallback_candidate"
},
"mvp1_allowed_statuses": [
"adapt_matched_zone",
"matched_zone"
],
"mode": "composition_v0_layout_8preset",
"mode_note": "MVP-1.5b w/ composition planner v0 — sections → candidates (separate / parent_merged) → score → greedy select → 8-preset layout vocabulary (single / horizontal-2 / vertical-2 / top-1-bottom-2 / top-2-bottom-1 / left-1-right-2 / left-2-right-1 / grid-2x2). v0 layout = count-based; v1 axes (cardinality_fit / hierarchy_coherence / density_score) 추후.",
"layout_preset": "horizontal-2",
"layout_css": {
"areas": "\"top\" \"bottom\"",
"cols": "1fr",
"rows": "228px 343px",
"heights_px": [
228,
343
],
"ratios": [
0.39,
0.586
],
"computation": "min_height_first + content_weight_distribution",
"dynamic_rows": true,
"raw_zone_layout": {
"computation": "min_height_first + content_weight_distribution",
"slide_body_height": 585,
"gap": 14,
"available_after_gap": 571,
"min_heights_px": [
228,
342
],
"min_scaled": true,
"total_min_height": 570,
"remaining_after_min": 1,
"content_weights": [
{
"position": "top",
"template_id": "three_parallel_requirements",
"score": 4.425
},
{
"position": "bottom",
"template_id": "process_product_two_way",
"score": 7.305
}
],
"weight_shares": [
0.377,
0.623
],
"extras_px": [
0,
1
],
"heights_px": [
228,
343
],
"ratios": [
0.39,
0.586
]
}
},
"slide_status": {
"rendered": true,
"visual_check_passed": true,
"full_mdx_coverage": true,
"aligned_section_ids": [
"03-1",
"03-2"
],
"covered_section_ids": [
"03-1",
"03-2"
],
"filtered_section_ids": [],
"filtered_section_reasons": [],
"visual_fail_reasons": [],
"adapter_needed_count": 0,
"adapter_needed_units": [],
"content_truncated_count": 0,
"content_truncated_units": [],
"overall": "PASS",
"note": "자동 파이프라인 결과 보고. review/UI 개념 X. final.html 파일명 != PASS 의미. overall == PASS 는 visual OK + full coverage + adapter_needed=0 일 때만. adapter_needed_count > 0 = mapper 가 contract 와 안 맞아 자동 렌더 못 한 zone 존재. content_truncated_count > 0 = builder 가 truncate 한 zone 존재 (rendered 됐지만 일부 콘텐츠 손실)."
},
"fit_classification": {
"visual_check_passed": true,
"classifications": [],
"summary": "visual check passed — no overflow to classify",
"categories_seen": [],
"unclassified_signals": [],
"placement_diagnostics": [
{
"zone_position": "top",
"mapper_frame_template_id": "three_parallel_requirements",
"b4_selected_template_id": "three_parallel_requirements",
"frame_selection_matches_mapper": true,
"frame_selection_match_note": null,
"region_count": 1,
"slot_assignment_count": 1,
"rejection_count": 0
},
{
"zone_position": "bottom",
"mapper_frame_template_id": "process_product_two_way",
"b4_selected_template_id": "process_product_two_way",
"frame_selection_matches_mapper": true,
"frame_selection_match_note": null,
"region_count": 2,
"slot_assignment_count": 2,
"rejection_count": 0
}
]
},
"router_decision": {
"router_active": false,
"proposed_actions_summary": [],
"implementation_status_summary": {},
"routed_count": 0,
"routed_details": [],
"missing_actions_pending_impl": [],
"note": "visual check passed — no overflow to route"
},
"retry_trace": {
"retry_attempted": false,
"retry_action": null,
"plan": null,
"rerender_attempted": false,
"retry_passed": false,
"retry_failure_reason": null,
"retried_candidate_path": null,
"safety_margin_px": 4,
"policy": "A3 locked rules : retry budget=1, slide-base/spacing/gap fixed, donor strict (sibling/visual ok/capacity ok/slack>0/min_height 보존), (b) revert on redistribution fail or rerender visual fail.",
"retry_skipped_reason": "router_active=False (visual check passed — no overflow)",
"failure_classification": {
"failure_type": "not_attempted",
"failure_type_description": "retry was not attempted (router_active=False or zone_ratio_retry not in proposed actions). 정상 path 의 일부 — 실패 X",
"classification_rule": "retry_attempted=False — router_active=False or zone_ratio_retry not in proposed_actions"
},
"next_action_proposal": {
"next_proposed_action": "none",
"next_action_rationale": "retry 시도 자체가 없었음 (visual ok 등) — escalation 불필요",
"next_action_implementation_status": "n/a",
"mapping_source": "A4 NEXT_ACTION_BY_FAILURE (사용자 잠금 2026-04-29)"
}
},
"composition_planner_debug": {
"planner_version": "v0.2",
"selection_rule": "score desc, then source_section_ids count desc (coverage tiebreak). filter = phase_z_status ∉ allowed_statuses OR auto_selectable=False. auto_selectable=False 사유 : C1 (capacity mismatch — silent truncate / FitError 차단), W1 (rep not auto-renderable), W2 (all children reject), W3 (majority children non-auto-renderable).",
"candidates_total": 2,
"candidates_viable_auto": 2,
"candidates_summary": [
{
"source_section_ids": [
"03-1"
],
"merge_type": "single",
"template_id": "three_parallel_requirements",
"label": "use_as_is",
"phase_z_status": "matched_zone",
"score": 0.9268,
"selection_state": "selected",
"auto_selectable": true,
"filter_reasons": [],
"notes": [],
"capacity_fit": {
"item_count": 3,
"source_shape": "top_bullets",
"capacity": {
"strict": 3,
"min": null,
"max": null,
"truncate_at": null,
"pad_to": null
},
"fit_status": "ok",
"mismatch_reason": null
}
},
{
"source_section_ids": [
"03-2"
],
"merge_type": "single",
"template_id": "process_product_two_way",
"label": "use_as_is",
"phase_z_status": "matched_zone",
"score": 0.9198,
"selection_state": "selected",
"auto_selectable": true,
"filter_reasons": [],
"notes": [],
"capacity_fit": {
"item_count": 2,
"source_shape": "h3_subsections",
"capacity": {
"strict": 2,
"min": null,
"max": null,
"truncate_at": null,
"pad_to": null
},
"fit_status": "ok",
"mismatch_reason": null
}
}
],
"merge_candidates": [],
"capacity_mismatches": [],
"selected_units_count": 2,
"layout_preset": "horizontal-2",
"layout_preset_rationale": "v0 count-based: 2 units → horizontal-2"
},
"zones": [
{
"position": "top",
"source_section_ids": [
"03-1"
],
"merge_type": "single",
"title": "1. DX 시행을 위한 필수 요건",
"v4_rank1_frame_id": "1171281190",
"v4_rank1_frame_number": 13,
"v4_template_id": "three_parallel_requirements",
"v4_label": "use_as_is",
"v4_confidence": 0.9268,
"phase_z_status": "matched_zone",
"composition_score": 0.9268,
"composition_rationale": {
"capacity_fit": {
"item_count": 3,
"source_shape": "top_bullets",
"capacity": {
"strict": 3,
"min": null,
"max": null,
"truncate_at": null,
"pad_to": null
},
"fit_status": "ok",
"mismatch_reason": null
},
"frame_compat": 0.9268,
"confidence": 0.9268,
"label": "use_as_is",
"label_weight": 1.0,
"merge_type": "single",
"hierarchy_coherence": null,
"density_score": null
},
"composition_notes": [],
"mapper_type": "contract",
"contract_id": "three_parallel_requirements",
"contract_frame_id": 1171281190,
"builder": "items_with_role",
"min_height_px": 230,
"slot_payload_keys": [
"pillars",
"title"
],
"content_truncated_count": null,
"assets_dir": null,
"content_weight": {
"score": 4.425,
"text_length": 660,
"top_bullets": 3,
"nested_bullets": 16,
"has_table": false,
"subsection_count": 0
},
"placement_trace": {
"section_id": "03-1",
"selected_frame_id": "1171281190",
"selected_template_id": "three_parallel_requirements",
"internal_regions": [
{
"region_id": "03-1.region-1",
"role": "primary",
"content_type": "text_block",
"ratio_estimate": 1.0,
"content_unit_ids": [
"03-1.text-1"
],
"frame_match_strategy": {
"kind": "frame_match",
"frame_id": null,
"display_strategy": "inline_full"
},
"source_shape_index": null
}
],
"slot_assignments": [
{
"region_id": "03-1.region-1",
"content_unit_id": "03-1.text-1",
"frame_slot_id": "pillar_1",
"partial_target_path": ".f13b__cols > .f13b__col:nth-child(1)",
"display_strategy": "inline_full"
}
],
"overflow_buffer": [],
"rejection": [],
"mapper_frame_template_id": "three_parallel_requirements",
"frame_selection_matches_mapper": true,
"frame_selection_match_note": null
},
"height_px": 228,
"ratio": 0.39
},
{
"position": "bottom",
"source_section_ids": [
"03-2"
],
"merge_type": "single",
"title": "2. Process의 혁신과 Product의 변화",
"v4_rank1_frame_id": "1171281210",
"v4_rank1_frame_number": 29,
"v4_template_id": "process_product_two_way",
"v4_label": "use_as_is",
"v4_confidence": 0.9198,
"phase_z_status": "matched_zone",
"composition_score": 0.9198,
"composition_rationale": {
"capacity_fit": {
"item_count": 2,
"source_shape": "h3_subsections",
"capacity": {
"strict": 2,
"min": null,
"max": null,
"truncate_at": null,
"pad_to": null
},
"fit_status": "ok",
"mismatch_reason": null
},
"frame_compat": 0.9198,
"confidence": 0.9198,
"label": "use_as_is",
"label_weight": 1.0,
"merge_type": "single",
"hierarchy_coherence": null,
"density_score": null
},
"composition_notes": [],
"mapper_type": "contract",
"contract_id": "process_product_two_way",
"contract_frame_id": 1171281210,
"builder": "process_product_pair",
"min_height_px": 345,
"slot_payload_keys": [
"banner_left",
"banner_right",
"process",
"product",
"title"
],
"content_truncated_count": null,
"assets_dir": "assets\\process_product_two_way",
"content_weight": {
"score": 7.305,
"text_length": 924,
"top_bullets": 6,
"nested_bullets": 7,
"has_table": true,
"subsection_count": 2
},
"placement_trace": {
"section_id": "03-2",
"selected_frame_id": "1171281210",
"selected_template_id": "process_product_two_way",
"internal_regions": [
{
"region_id": "03-2.region-1",
"role": "primary",
"content_type": "text_block",
"ratio_estimate": 0.8421,
"content_unit_ids": [
"03-2.text-1"
],
"frame_match_strategy": {
"kind": "frame_match",
"frame_id": null,
"display_strategy": "inline_full"
},
"source_shape_index": null
},
{
"region_id": "03-2.region-2",
"role": "supporting",
"content_type": "transform_table",
"ratio_estimate": 0.1579,
"content_unit_ids": [
"03-2.transform-1"
],
"frame_match_strategy": {
"kind": "frame_match",
"frame_id": null,
"display_strategy": "inline_full"
},
"source_shape_index": null
}
],
"slot_assignments": [
{
"region_id": "03-2.region-1",
"content_unit_id": "03-2.text-1",
"frame_slot_id": "product_column",
"partial_target_path": ".f29b__grid .f29b__cell--right",
"display_strategy": "inline_full"
},
{
"region_id": "03-2.region-2",
"content_unit_id": "03-2.transform-1",
"frame_slot_id": "process_column",
"partial_target_path": ".f29b__grid .f29b__cell--left",
"display_strategy": "inline_full"
}
],
"overflow_buffer": [],
"rejection": [],
"mapper_frame_template_id": "process_product_two_way",
"frame_selection_matches_mapper": true,
"frame_selection_match_note": null
},
"height_px": 343,
"ratio": 0.586
}
],
"visual_runtime_check": {
"frame_slot_metrics": [
{
"class_name": "f29b__cell f29b__cell--left",
"clientHeight": 125,
"clientWidth": 577,
"excess_x": 0,
"excess_y": 0,
"frame_slot_id": "process_column",
"overflowed": false,
"scrollHeight": 125,
"scrollWidth": 577,
"zone_position": "bottom",
"zone_template_id": "process_product_two_way"
},
{
"class_name": "f29b__cell f29b__cell--right",
"clientHeight": 125,
"clientWidth": 577,
"excess_x": 0,
"excess_y": 0,
"frame_slot_id": "product_column",
"overflowed": false,
"scrollHeight": 125,
"scrollWidth": 577,
"zone_position": "bottom",
"zone_template_id": "process_product_two_way"
},
{
"class_name": "f29b__cell f29b__cell--left",
"clientHeight": 43,
"clientWidth": 577,
"excess_x": 0,
"excess_y": 0,
"frame_slot_id": "process_column",
"overflowed": false,
"scrollHeight": 43,
"scrollWidth": 577,
"zone_position": "bottom",
"zone_template_id": "process_product_two_way"
},
{
"class_name": "f29b__cell f29b__cell--right",
"clientHeight": 43,
"clientWidth": 577,
"excess_x": 0,
"excess_y": 0,
"frame_slot_id": "product_column",
"overflowed": false,
"scrollHeight": 43,
"scrollWidth": 577,
"zone_position": "bottom",
"zone_template_id": "process_product_two_way"
},
{
"class_name": "f29b__cell f29b__cell--left",
"clientHeight": 63,
"clientWidth": 577,
"excess_x": 0,
"excess_y": 0,
"frame_slot_id": "process_column",
"overflowed": false,
"scrollHeight": 63,
"scrollWidth": 577,
"zone_position": "bottom",
"zone_template_id": "process_product_two_way"
},
{
"class_name": "f29b__cell f29b__cell--right",
"clientHeight": 63,
"clientWidth": 577,
"excess_x": 0,
"excess_y": 0,
"frame_slot_id": "product_column",
"overflowed": false,
"scrollHeight": 63,
"scrollWidth": 577,
"zone_position": "bottom",
"zone_template_id": "process_product_two_way"
}
],
"slide": {
"clientHeight": 720,
"clientWidth": 1280,
"excess_x": 0,
"excess_y": 0,
"overflowed": false,
"scrollHeight": 720,
"scrollWidth": 1280,
"size_correct": true
},
"slide_body": {
"clientHeight": 585,
"clientWidth": 1180,
"excess_x": 0,
"excess_y": 0,
"overflowed": false,
"scrollHeight": 585,
"scrollWidth": 1180
},
"zones": [
{
"clientHeight": 228,
"clientWidth": 1180,
"clipped_inner": [],
"excess_x": 0,
"excess_y": 0,
"overflowed": false,
"position": "top",
"scrollHeight": 228,
"scrollWidth": 1180,
"template_id": "three_parallel_requirements"
},
{
"clientHeight": 343,
"clientWidth": 1180,
"clipped_inner": [],
"excess_x": 0,
"excess_y": 0,
"overflowed": false,
"position": "bottom",
"scrollHeight": 343,
"scrollWidth": 1180,
"template_id": "process_product_two_way"
}
],
"screenshot": "data\\runs\\mdx03_f29_fix_check\\phase_z2\\preview.png",
"passed": true,
"fail_reasons": []
}
}
@@ -1,927 +0,0 @@
<!-- Phase Z-2 MVP-1.5b — single slide + Type B layout (top/bottom zones).
원래 Phase Z 설계 복귀: MDX 1 = slide 1, layout preset = zone 분할, frame-derived block ⊂ zone. -->
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1280">
<title>DX 실행 체계 구축 방안</title>
<style>
/* ── existing tokens (inlined) ── */
/* === typography.css === */
/* ══════════════════════════════════════
Typography Tokens v1
══════════════════════════════════════
슬라이드 전체의 공통 글자 위계.
모든 블록은 이 토큰을 참조한다.
직접값(font-size: 11px) 금지 → var(--font-body) 사용.
══════════════════════════════════════ */
:root {
/* ── 1층: Global Hierarchy ── */
/* 대목차: 슬라이드 상단 제목 */
--font-slide-title: 22px;
--weight-slide-title: 700;
--lh-slide-title: 1.4;
/* 중목차: zone 제목 */
--font-zone-title: 16px;
--weight-zone-title: 700;
--lh-zone-title: 1.4;
/* 소목차: 블록 내 소제목, 카드 제목 */
--font-sub-title: 12px;
--weight-sub-title: 700;
--lh-sub-title: 1.45;
/* 본문: 블릿, 설명 텍스트 */
--font-body: 10px;
--weight-body: 500;
--lh-body: 1.55;
/* 캡션: 각주, 출처, 보조 텍스트 */
--font-caption: 10px;
--weight-caption: 400;
--lh-caption: 1.4;
/* footer: 핵심 인사이트 pill */
--font-footer: 20px;
--weight-footer: 700;
--lh-footer: 1.2;
/* ── 2층: Component Semantic Tokens ── */
/* 가까운 위계에서 기본값, 필요시 override */
/* 본문 강조 (인라인 heading) */
--font-body-strong: var(--font-body);
--weight-body-strong: 600;
/* 자세히보기 링크 */
--font-detail-link: var(--font-caption);
--weight-detail-link: 500;
/* pill/badge 라벨 */
--font-pill-label: var(--font-sub-title);
--weight-pill-label: 700;
/* 표 헤더 */
--font-table-header: var(--font-sub-title);
--weight-table-header: 700;
/* 표 셀 */
--font-table-cell: var(--font-body);
--weight-table-cell: 400;
/* 비교 뱃지 */
--font-compare-badge: var(--font-sub-title);
--weight-compare-badge: 700;
/* 강조 인용 */
--font-callout: var(--font-sub-title);
--weight-callout: 700;
/* 상단 라벨 (overline) */
--font-overline: var(--font-caption);
--weight-overline: 600;
--ls-overline: 0.05em;
}
/* === spacing.css === */
/* ══════════════════════════════════════
Spacing Tokens v1
══════════════════════════════════════
여백/간격/들여쓰기 공통 기준.
══════════════════════════════════════ */
:root {
/* ── 기본 스케일 ── */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 16px;
--space-xl: 24px;
/* ── slide-base 레이아웃 ── */
--slide-padding-x: 40px; /* 본문 좌우 여백 */
--slide-title-left: 52px; /* 대목차 시작 */
--slide-title-top: 22px; /* 대목차 위치 */
--slide-divider-top: 58px; /* 구분선 위치 (slide_base.html 와 일치 2026-05-07) */
--slide-body-top: 76px; /* 본문 시작 (사용자 직설: divider-body 16px) */
--slide-body-height: 585px; /* 본문 높이 (사용자 직설: body-footer 10px) */
--slide-footer-bottom: 8px; /* footer 하단 여백 (slide_base.html 와 일치) */
--slide-footer-height: 41px; /* footer 높이 */
/* ── zone ── */
--zone-gap: 8px; /* zone 간 간격 */
--zone-padding-left: 12px; /* zone 안쪽 좌측 여백 */
--zone-padding-right: 8px; /* zone 안쪽 우측 여백 */
--zone-title-mb: 8px; /* zone 제목 아래 여백 */
/* ── 블록 공통 ── */
--card-padding: 16px; /* 카드 내부 여백 */
--card-padding-sm: 8px; /* 작은 카드 내부 여백 */
--card-gap: 12px; /* 카드 간 간격 */
--card-radius: 6px; /* 카드 모서리 */
/* ── 블릿/텍스트 ── */
--bullet-indent: 14px; /* 불릿 hanging indent */
--bullet-gap: 2px; /* 불릿 항목 간 간격 */
--heading-gap: 4px; /* 소제목 아래 간격 */
--section-gap: 10px; /* 섹션 간 간격 */
/* ── flex/grid 기본 ── */
--flex-gap: 12px; /* 기본 flex gap */
--flex-gap-sm: 6px; /* 좁은 flex gap */
--grid-gap: 16px; /* 기본 grid gap */
}
/* === colors.css === */
/* ══════════════════════════════════════
Color Tokens v1
══════════════════════════════════════
2층 구조: 공통 테마색 + 블록 의미색.
══════════════════════════════════════ */
:root {
/* ══ 1층: 공통 테마색 ══ */
/* 모든 블록이 공유하는 기본 팔레트 */
/* 텍스트 */
--color-title: #1e293b; /* 제목 (진한 남색) */
--color-zone-title: #1a365d; /* zone 중목차 제목 */
--color-body: #475569; /* 본문 */
--color-body-strong: #1e293b; /* 본문 강조 */
--color-caption: #94a3b8; /* 캡션, 보조 */
--color-muted: #64748b; /* 약한 텍스트 */
/* 배경 */
--color-bg: #ffffff; /* 슬라이드 배경 */
--color-bg-subtle: #f8fafc; /* 카드/영역 배경 */
--color-bg-page: #e8ecf0; /* 페이지 배경 (슬라이드 바깥) */
/* 보더 */
--color-border: #e2e8f0; /* 기본 보더 */
--color-border-strong: #cbd5e1; /* 강한 보더 */
/* 강조 */
--color-accent: #2563eb; /* 링크, 강조 (파랑) */
--color-accent-hover: #eff6ff; /* 링크 hover 배경 */
--color-danger: #dc2626; /* 경고, 에러 */
/* slide-base 전용 */
--color-slide-title-from: #296b55; /* 대목차 gradient 시작 */
--color-slide-title-to: #123328; /* 대목차 gradient 끝 */
--color-footer-bg-from: #3b3523; /* footer gradient 시작 */
--color-footer-bg-mid: #263a2a; /* footer gradient 중간 */
--color-footer-bg-to: #113f31; /* footer gradient 끝 */
--color-footer-text: #ffffff; /* footer 텍스트 */
--color-footer-accent: #ffee33; /* footer 강조 (em) */
/* ══ 2층: 블록 의미색 ══ */
/* 특정 블록 유형에서 의미를 가지는 색 */
/* 3열 비교 (prerequisites-3col) */
--color-col-1-from: #0D78D0; /* 1열 gradient 시작 */
--color-col-1-to: #023056; /* 1열 gradient 끝 */
--color-col-2-from: #FF9A23; /* 2열 gradient 시작 */
--color-col-2-to: #CC5200; /* 2열 gradient 끝 */
--color-col-3-from: #39BE49; /* 3열 gradient 시작 */
--color-col-3-to: #23742C; /* 3열 gradient 끝 */
/* 비교 블록 (compare) */
--color-compare-left: #5c3714; /* 좌측 (AS-IS 계열) */
--color-compare-right: #285b4a; /* 우측 (TO-BE 계열) */
--color-compare-badge: #ae3607; /* VS 뱃지 */
/* 다크 카드 (direct render) */
--color-dark-card-1: #1a365d; /* 카드 1 배경 */
--color-dark-card-2: #1e3a2f; /* 카드 2 배경 */
--color-dark-card-3: #3b1f2b; /* 카드 3 배경 */
--color-dark-card-title: #fbbf24; /* 다크카드 제목 (금색) */
--color-dark-card-body: #e2e8f0; /* 다크카드 본문 */
/* 표 */
--color-table-header-bg: #64748b; /* 표 헤더 배경 */
--color-table-header-text: #ffffff; /* 표 헤더 텍스트 */
/* 블록 제목 gradient (Figma 원본) */
--color-block-title-from: #CC5200; /* 주황 gradient 시작 */
--color-block-title-to: #883700; /* 주황 gradient 끝 */
/* pill */
--color-pill-bg: #1e293b; /* pill 배경 */
--color-pill-text: #ffffff; /* pill 텍스트 */
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
background: #e8ecf0;
display: flex; justify-content: center; align-items: center;
min-height: 100vh;
word-break: keep-all;
padding: 20px 0;
}
/* ── 16:9 슬라이드 (single, 1280×720) ── */
.slide {
width: 1280px; height: 720px;
position: relative;
overflow: hidden;
background: #fff;
box-shadow: 0 4px 20px rgba(0,0,0,.15);
flex-shrink: 0;
}
.slide-bg {
position: absolute; inset: 0;
background: linear-gradient(180deg, #f0f0f0 0%, #ffffff 100%);
z-index: 0;
}
.slide-title {
position: absolute;
left: 52px; top: 22px;
width: calc(100% - 104px);
font-weight: 700;
font-size: var(--font-slide-title);
line-height: var(--lh-slide-title);
background-image: linear-gradient(180deg, #296b55 0%, #123328 100%);
-webkit-background-clip: text; background-clip: text;
color: transparent;
text-shadow: 0 0 2px #322c1e;
z-index: 2;
}
.slide-divider {
position: absolute;
left: 50px; top: 58px;
width: calc(100% - 100px);
height: 2px;
background: #cbd5e1;
z-index: 2;
}
.slide-body {
position: absolute;
left: 50px; top: 76px;
width: calc(100% - 100px); /* 1180 */
height: 585px;
z-index: 1;
overflow: hidden;
}
/* ── Layout preset (composition planner v0 — 8 vocabulary) ──
단일 .layout-{preset} 클래스에 grid CSS 주입.
layout_css = pipeline build_layout_css() 결과 :
- horizontal-2 : rows = dynamic px (content_weight + frame_min_height)
- 그 외 : rows / cols = fr default (LAYOUT_PRESETS)
positions (single / top / bottom / left / right / top-left / ... / grid-2x2 9개)
은 .zone 의 inline grid-area 로 지정 — preset CSS 는 areas 만 정의. */
.layout-horizontal-2 {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 228px 343px;
grid-template-areas: "top" "bottom";
gap: 14px;
height: 100%;
}
/* ── Zone — block 가용 공간 최대화. 프레임 자체 styling 이 boundary 역할 ── */
.zone {
position: relative;
overflow: hidden;
padding: 0;
min-width: 0;
min-height: 0;
}
/* ── Frame-family text layout contract (shared, reusable) ──
feedback-1 (mvp1.5b_test7): visible improvement 강화.
Stronger hanging indent + breathing line spacing + visible hierarchy. */
.text-line {
font-size: var(--font-body); /* 11px */
font-weight: var(--weight-body);
line-height: 1.6; /* breathing room */
margin: 0;
}
.text-line + .text-line {
margin-top: 4px; /* visible gap between lines (was 2px) */
}
/* bullet line — hanging indent via padding + absolute marker (안정적) */
.text-line--bullet {
padding-left: 14px;
position: relative;
}
.text-line--bullet::before {
content: "•"; /* visible bullet 마커 */
position: absolute;
left: 2px;
top: 0;
font-weight: 700;
line-height: inherit;
color: inherit;
}
/* indent levels — visible nested hierarchy */
.text-line--indent-1 {
padding-left: 30px;
}
.text-line--indent-1::before {
content: "◦"; /* sub-bullet 다른 마커 */
left: 18px;
}
.text-line--indent-2 {
padding-left: 46px;
}
.text-line--indent-2::before {
content: "▪";
left: 34px;
}
/* body line — 마커 없음 */
.text-line--body {
padding-left: 0;
}
.text-line strong { color: #000; font-weight: 700; }
/* ── Transform-block family (frame 29 AS-IS/TO-BE dedicated comparison component) ──
feedback-3: bullet rows 가 아닌 *전용 비교 component*. Header (AS-IS / TO-BE) + paired rows.
color-coded cells, left-border accent → 단번에 *비교 구조* 인지. */
.transform-block {
margin-top: 4px;
padding: 6px 8px 6px;
background: rgba(255, 255, 255, 0.4);
border-radius: 3px;
border: 1px solid rgba(0,0,0,0.05);
}
.transform-block__header {
display: grid;
grid-template-columns: 1fr 22px 1fr;
gap: 6px;
margin-bottom: 4px;
padding-bottom: 3px;
border-bottom: 1px dashed rgba(0,0,0,0.12);
font-size: 9px;
font-weight: 900;
letter-spacing: 0.5px;
}
.transform-block__label--from { color: #6b5444; }
.transform-block__label--to { color: #2563eb; text-align: left; }
.transform-rows {
display: flex; flex-direction: column;
gap: 4px;
}
.transform-row {
display: grid;
grid-template-columns: 1fr 22px 1fr;
gap: 6px;
align-items: stretch;
font-size: var(--font-body);
line-height: 1.45;
}
.transform-row__from {
padding: 3px 8px;
background: linear-gradient(90deg, rgba(140,120,90,0.16), rgba(140,120,90,0.08));
border-left: 2px solid #b8a98a;
border-radius: 2px;
color: #4b3f30;
font-weight: 500;
}
.transform-row__arrow {
text-align: center;
color: #2563eb;
font-weight: 900;
font-size: 14px;
align-self: center;
}
.transform-row__to {
padding: 3px 8px;
background: linear-gradient(90deg, rgba(37,99,235,0.10), rgba(37,99,235,0.18));
border-left: 2px solid #2563eb;
border-radius: 2px;
color: #1e40af;
font-weight: 700;
}
/* ── Footer pill — feedback-3: body 와 weight balance, 살짝 가벼워짐 ── */
.slide-footer {
position: absolute;
left: 50px; bottom: 8px;
width: calc(100% - 100px); /* front 기준 (body 와 정렬) */
height: 41px; /* front 기준 */
border-radius: 999px;
overflow: hidden;
z-index: 2;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(90deg, #3b3523 5%, #263a2a 50%, #113f31 95%);
opacity: 0.92; /* slightly lighter */
}
.slide-footer-text {
position: relative; z-index: 1;
font-size: 15px; /* was 20px (--font-footer) */
font-weight: 700;
line-height: 1.2;
color: #fff;
text-align: center;
text-shadow: 0 0 3px rgba(0,0,0,.4);
letter-spacing: -0.01em;
}
.slide-footer-text em { color: #fe3; font-style: normal; }
.phase-z2-marker {
position: absolute;
top: 4px; right: 8px;
font-size: 9px; color: #94a3b8;
font-family: monospace;
z-index: 3;
}
</style>
</head>
<body>
<div class="slide" data-page="1">
<div class="slide-bg"></div>
<div class="phase-z2-marker">phase_z2 / mvp-1.5b / horizontal-2 / single slide</div>
<div class="slide-title">DX 실행 체계 구축 방안</div>
<div class="slide-divider"></div>
<div class="slide-body">
<div class="layout-horizontal-2">
<div class="zone" data-zone-position="top" data-template-id="three_parallel_requirements" style="grid-area: top;">
<!-- Phase Z-2 MVP-1.5b frame-derived adapted block.
§17 룰 — Figma 시각 언어 promote, geometry 만 zone-compatible adapt.
Legacy templates/blocks/structures/prerequisites-3col.html 의 token-fixed font 접근 학습. -->
<style>
.f13b {
width: 100%; height: 100%;
display: flex; flex-direction: column;
gap: 4px;
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
word-break: keep-all;
}
.f13b__title {
font-size: var(--font-zone-title);
font-weight: 700;
line-height: var(--lh-zone-title);
background-image: linear-gradient(180deg, #000 0%, #883700 100%);
-webkit-background-clip: text; background-clip: text;
color: transparent;
flex-shrink: 0;
}
.f13b__cols {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
flex: 1;
min-height: 0;
}
.f13b__col {
display: flex;
border-top: 2px solid #000; /* PROMOTED line 57 */
border-bottom: 2px solid #000;
overflow: hidden;
min-width: 0;
background: rgba(255,255,255,0.5);
}
.f13b__bar {
flex: 0 0 50px;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
color: #fff;
font-weight: 700;
text-align: center;
padding: 6px 2px;
gap: 3px;
}
.f13b__col--tech .f13b__bar { background: linear-gradient(180deg, #0D78D0 0%, #023056 100%); }
.f13b__col--people .f13b__bar { background: linear-gradient(180deg, #FF9A23 0%, #CC5200 100%); }
.f13b__col--nature .f13b__bar { background: linear-gradient(180deg, #39BE49 21%, #23742C 100%); }
/* PROMOTED Figma frame 13 line 92-93 — main + parenthetical stacked vertically */
.f13b__bar-main {
font-size: var(--font-sub-title); /* 12px — main word "기술/사람/자연" */
font-weight: 700;
line-height: 1.2;
display: block;
}
.f13b__bar-paren {
font-size: var(--font-caption); /* 10px — "(디지털/역량/여건)" */
font-weight: 500;
line-height: 1.2;
display: block;
opacity: 0.95;
}
.f13b__body {
flex: 1;
display: flex; flex-direction: column;
padding: 8px 12px; /* breathing (was 6px 10px) */
min-width: 0;
overflow: hidden;
}
.f13b__section {
flex: 1;
padding: 6px 0; /* breathing (was 4px) */
overflow: hidden;
min-height: 0;
}
.f13b__section + .f13b__section {
border-top: 2px dashed #000; /* PROMOTED line 103 */
margin-top: 6px; /* visible section gap (was 4px) */
padding-top: 8px; /* breathing (was 6px) */
}
/* heading — 부소목차 (사용자 5-layer 직설 2026-05-07): Digital 기술과... 같은 깊이 1 굵은 bullet */
.f13b__heading {
font-size: var(--font-sub-title); /* 12px — 부소목차 위계 */
font-weight: 700;
line-height: var(--lh-sub-title);
-webkit-background-clip: text; background-clip: text;
color: transparent;
margin-bottom: 6px; /* visible heading-body gap (was 4px) */
}
.f13b__col--tech .f13b__heading { background-image: linear-gradient(180deg, #0D78D0 0%, #134D7F 100%); }
.f13b__col--people .f13b__heading { background-image: linear-gradient(180deg, #FE900C 0%, #D18B37 100%); }
.f13b__col--nature .f13b__heading { background-image: linear-gradient(180deg, #3CA649 21%, #23742C 100%); }
/* desc — frame-family text layout contract (.text-line 공통 클래스 사용)
feedback (mvp1.5b_test5): browser-native ul/li 폐기. */
.f13b__desc {
color: #3E3523; /* PROMOTED line 118 */
overflow: hidden;
}
/* desc 안 .text-line 색 override */
.f13b__desc .text-line { color: #3E3523; }
</style>
<div class="f13b" data-frame-id="1171281190" data-template-id="three_parallel_requirements">
<div class="f13b__title">1. DX 시행을 위한 필수 요건</div>
<div class="f13b__cols">
<div class="f13b__col f13b__col--tech">
<div class="f13b__bar">
<span class="f13b__bar-main">기술</span>
<span class="f13b__bar-paren">(디지털)</span>
</div>
<div class="f13b__body">
<div class="f13b__section">
<div class="f13b__heading">Digital 기술(S/W, H/W)과 업무 Process의 통합</div>
<div class="f13b__desc">
<div class="text-line text-line--bullet">기존 업무 프로세스에 다양한 디지털 기술을 접목하여 업무 수행</div><div class="text-line text-line--bullet">프로젝트 전반에 걸친 업무 프로세스의 연결 및 조율</div>
</div>
</div>
<div class="f13b__section">
<div class="f13b__heading">분야별 전문 지식(설계, 시공, 유지관리 등) 보유</div>
<div class="f13b__desc">
<div class="text-line text-line--bullet">건설 전 단계에 대한 근본적인 이해와 지식 및 경험</div><div class="text-line text-line--bullet">최신 토목 기술 트랜드 및 표준 기준 등에 대한 높은 지식</div>
</div>
</div>
</div>
</div>
<div class="f13b__col f13b__col--people">
<div class="f13b__bar">
<span class="f13b__bar-main">사람</span>
<span class="f13b__bar-paren">(역량)</span>
</div>
<div class="f13b__body">
<div class="f13b__section">
<div class="f13b__heading">혁신적 사고방식과 창의적 문제 해결 능력</div>
<div class="f13b__desc">
<div class="text-line text-line--bullet">기존 수행 방식과 관습적 사고 등에 의한 접근 방식 탈피</div><div class="text-line text-line--bullet">디지털 기술을 활용한 창의적, 혁신적인 솔루션 제시</div>
</div>
</div>
<div class="f13b__section">
<div class="f13b__heading">사용자 중심 사고와 DX 수행 경험</div>
<div class="f13b__desc">
<div class="text-line text-line--bullet">사용자의 요구와 기대를 충족시키는 설계 및 구현</div><div class="text-line text-line--bullet">시행착오를 포함한 수행 경험과 사용자 경험(UX)을 반영한 해결 방안 제시</div>
</div>
</div>
</div>
</div>
<div class="f13b__col f13b__col--nature">
<div class="f13b__bar">
<span class="f13b__bar-main">자연</span>
<span class="f13b__bar-paren">(여건)</span>
</div>
<div class="f13b__body">
<div class="f13b__section">
<div class="f13b__heading">지속적인 투자 및 실행 의지</div>
<div class="f13b__desc">
<div class="text-line text-line--bullet">기술 도입 초기 단계에 필요한 인력·기간·비용 등의 대규모 투자</div><div class="text-line text-line--bullet">기술 고도화를 위한 지속적인 개선 및 투자 체계 구축</div><div class="text-line text-line--bullet">변화와 혁신을 통해 부가가치를 창출하려는 실행 의지와 추진력</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="zone" data-zone-position="bottom" data-template-id="process_product_two_way" style="grid-area: bottom;">
<!-- Phase Z-2 MVP-1.5b frame-derived adapted block.
§17 룰 — Figma 시각 언어 promote, geometry 만 zone-compatible adapt. -->
<style>
.f29b {
width: 100%; height: 100%;
display: flex; flex-direction: column;
gap: 4px;
position: relative;
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
word-break: keep-all;
overflow: hidden;
}
.f29b__title {
font-size: var(--font-zone-title); /* 13px — frame 13 의 f13b__title 과 동일 위계 */
font-weight: 700;
line-height: var(--lh-zone-title);
background-image: linear-gradient(180deg, #000 0%, #883700 100%);
-webkit-background-clip: text; background-clip: text;
color: transparent;
flex-shrink: 0;
padding-left: 4px;
}
/* banners — top */
.f29b__banners {
flex: 0 0 auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
position: relative;
height: 36px;
}
.f29b__banner {
position: relative;
overflow: hidden;
display: flex; align-items: center; justify-content: center;
}
/* PROMOTED background gradients — figma origin lines 84-98 (header-bg--left/right).
원본 SVG 3 개 → CSS gradient 로 재현 (R8/R9 준수). border-radius 9999px = 자동 pill. */
.f29b__banner--left {
background: linear-gradient(to right,
rgba(136, 131, 118, 0.343) 0%,
#39321E 73.1%,
#39321E 100%);
border-radius: 0 9999px 9999px 0;
}
.f29b__banner--right {
background: linear-gradient(to right,
#032118 0%,
rgba(41, 107, 85, 0.1) 100%);
border-radius: 9999px 0 0 9999px;
}
.f29b__banner-text {
position: relative;
z-index: 1;
font-size: var(--font-sub-title);
font-weight: 900;
line-height: 1;
text-shadow: 0 0 2px #322C1E;
-webkit-background-clip: text; background-clip: text;
color: transparent;
padding: 0 8px;
}
/* PROMOTED gradient — lines 101-104 / 110-113 */
.f29b__banner--left .f29b__banner-text {
background-image:
linear-gradient(90deg, rgb(62,53,35) 0%, rgb(62,53,35) 100%),
linear-gradient(180deg, rgb(41,107,85) 0%, rgb(18,51,40) 100%);
}
.f29b__banner--right .f29b__banner-text {
background-image:
linear-gradient(90deg, rgb(34,95,74) 0%, rgb(34,95,74) 100%),
linear-gradient(180deg, rgb(41,107,85) 0%, rgb(18,51,40) 100%);
}
/* body — 6 cell grid (3 rows × 2 cols) — 좌우 row divider Y 정렬 */
/* PROMOTED background — figma origin lines 53-68 (body-bg-left/right).
좌(0~50%): #39321E α0.3 (top) → white α0.3 (bottom)
우(50~100%): #296B55 α0.3 (top) → white α0.3 (bottom)
원본 SVG → CSS multi-bg 로 재현. */
.f29b__body {
flex: 1;
position: relative;
min-height: 0;
padding-top: 4px;
background:
linear-gradient(to bottom, rgba(57,50,30,0.3) 0%, rgba(255,255,255,0.3) 100%) 0% 0% / 50% 100% no-repeat,
linear-gradient(to bottom, rgba(41,107,85,0.3) 0%, rgba(255,255,255,0.3) 100%) 100% 0% / 50% 100% no-repeat;
}
/* 6 cell grid: 3 rows (section 1/2/3), 2 cols (process/product). 각 row 의 height = max(좌, 우) */
.f29b__grid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr 1fr;
grid-auto-rows: auto;
gap: 6px 10px;
padding: 4px 8px;
height: 100%;
align-content: start;
}
.f29b__cell {
overflow: hidden;
min-width: 0;
min-height: 0;
}
/* row 2/3 (3 번째 cell 이후) 위에 dashed divider — 좌우 같은 Y */
.f29b__cell:nth-child(n+3) {
border-top: 1px dashed rgba(0,0,0,0.15);
padding-top: 6px;
margin-top: 4px;
}
/* section-title — 부소목차 (사용자 5-layer 직설 2026-05-07): Analogue 기반... 같은 깊이 0 굵은 bullet */
.f29b__section-title {
font-size: var(--font-sub-title); /* 12px — 부소목차 위계 */
font-weight: 900;
line-height: 1.3;
margin-bottom: 5px;
padding-left: 6px;
border-left: 3px solid currentColor;
}
.f29b__cell--left .f29b__section-title { color: #5C3714; } /* PROMOTED line 125 */
.f29b__cell--right .f29b__section-title { color: #084C56; } /* PROMOTED line 126 */
.f29b__section-title__sub {
font-size: var(--font-caption); /* 10px */
margin-left: 3px;
}
/* f29b 의 본문 = .text-line 공통 contract 사용. bullet color override. */
.f29b__cell .text-line--bullet { color: #000; font-weight: 700; }
/* AS-IS/TO-BE 좌우 + arrow (PROMOTED 구조) */
.f29b__transform {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 4px;
align-items: start;
}
.f29b__transform__list {
/* .text-line 공통 contract 그대로. wrapper 만 grid 안의 cell 역할 */
}
.f29b__transform__arrow {
align-self: center;
width: 28px;
flex-shrink: 0;
}
.f29b__transform__arrow img {
width: 100%;
height: auto;
display: block;
}
.f29b__footnote {
font-size: var(--font-caption); /* 10px */
line-height: var(--lh-caption);
color: #333;
padding-left: 14px;
margin-top: 1px;
white-space: pre-wrap;
}
.f29b__aster-small { font-size: 9px; }
</style>
<div class="f29b" data-frame-id="1171281210" data-template-id="process_product_two_way">
<div class="f29b__title">2. Process의 혁신과 Product의 변화</div>
<div class="f29b__banners">
<div class="f29b__banner f29b__banner--left">
<div class="f29b__banner-text">과정(Process)의 혁신</div>
</div>
<div class="f29b__banner f29b__banner--right">
<div class="f29b__banner-text">결과(Product)의 변화</div>
</div>
</div>
<div class="f29b__body">
<div class="f29b__grid">
<div class="f29b__cell f29b__cell--left" data-frame-slot-id="process_column">
<div class="f29b__section-title">Analogue 기반 업무의 Digital화</div>
<div class="transform-block">
<div class="transform-block__header">
<span class="transform-block__label--from">AS-IS</span>
<span></span>
<span class="transform-block__label--to">TO-BE</span>
</div>
<div class="transform-rows">
<div class="transform-row">
<div class="transform-row__from">개념·문서·행정 절차 중심</div>
<div class="transform-row__arrow"></div>
<div class="transform-row__to">시각화된 목적물, 소통, 투명성 중심</div>
</div>
<div class="transform-row">
<div class="transform-row__from">2D 도면, 전문가, 규정</div>
<div class="transform-row__arrow"></div>
<div class="transform-row__to">3D 모델, 참여자, 실체</div>
</div>
<div class="transform-row">
<div class="transform-row__from">업무 구분(단절), 책임</div>
<div class="transform-row__arrow"></div>
<div class="transform-row__to">협업(융·복합), 창의성</div>
</div>
</div>
</div>
</div>
<div class="f29b__cell f29b__cell--right" data-frame-slot-id="product_column">
<div class="f29b__section-title">Copy & Paste로 인해 하향 평준화된 기존 성과물의 품질 향상</div>
<div class="text-line text-line--bullet">과거 수작업으로 시행하면서 발생하던 오류 등의 최소화</div><div class="text-line text-line--bullet">정확한 Data에 기반한 계획으로 고품질 성과물 도출</div>
</div>
<div class="f29b__cell f29b__cell--left" data-frame-slot-id="process_column">
<div class="f29b__section-title">GIS + BIM의 연계</div>
<div class="text-line text-line--bullet">지리·지형·지반 등 위치정보(GIS)와 3D모델(형상, 속성정보) 기반의 건설 정보를 포함하는 BIM의 연계를 통한 업무 프로세스의 혁신</div>
</div>
<div class="f29b__cell f29b__cell--right" data-frame-slot-id="product_column">
<div class="f29b__section-title">Analogue 기반 도서 외 Digital 기반 정보물 추가</div>
<div class="text-line text-line--bullet">기존 성과물(도면, 수량, 계산서, 시방서 등)에 3D 모델, Simulation 등의 Digital 기반 정보물 추가</div>
</div>
<div class="f29b__cell f29b__cell--left" data-frame-slot-id="process_column">
<div class="f29b__section-title">사용자 중심의 Solution 제공</div>
<div class="text-line text-line--bullet">서로 다른 S/W로 작성되어 분절화된 Analogue 방식의 성과물과 정보물을 연계할 수 있는 설계·시공 Solution 제공</div>
</div>
<div class="f29b__cell f29b__cell--right" data-frame-slot-id="product_column">
<div class="f29b__section-title">Solution을 활용한 업무 효율화</div>
<div class="text-line text-line--bullet">Engn. Solution을 통해 성과물에 관한 이슈를 함께 검토·논의하는 협업 환경 조성</div><div class="text-line text-line--bullet">건설 단계별 정보를 디지털 데이터로 축적하여, 건설 전 과정을 통합관리</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="slide-footer">
<div class="slide-footer-text">DX는 필요한 요건과 체계를 갖춘 후 시행해야만 그 효과를 기대할 수 있다.</div>
</div>
</div>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

@@ -1,89 +0,0 @@
---
title: DX 실행 체계 구축 방안
sidebar:
order: 02
---
## 1. DX 시행을 위한 필수 요건
<br/>
* **기술(디지털)**
* **Digital 기술(S/W, H/W)과 업무 Process의 통합**
* 기존 업무 프로세스에 다양한 디지털 기술을 접목하여 업무 수행
* 프로젝트 전반에 걸친 업무 프로세스의 연결 및 조율
* **분야별 전문 지식(설계, 시공, 유지관리 등) 보유**
* 건설 전 단계에 대한 근본적인 이해와 지식 및 경험
* 최신 토목 기술 트랜드 및 표준 기준 등에 대한 높은 지식
<br/>
* **사람(역량)**
* **혁신적 사고방식과 창의적 문제 해결 능력**
* 기존 수행 방식과 관습적 사고 등에 의한 접근 방식 탈피
* 디지털 기술을 활용한 창의적, 혁신적인 솔루션 제시
* **사용자 중심 사고와 DX 수행 경험**
* 사용자의 요구와 기대를 충족시키는 설계 및 구현
* 시행착오를 포함한 수행 경험과 사용자 경험(UX)을 반영한 해결 방안 제시
<br/>
* **자연(여건)**
* **지속적인 투자 및 실행 의지**
* 기술 도입 초기 단계에 필요한 인력·기간·비용 등의 대규모 투자
* 기술 고도화를 위한 지속적인 개선 및 투자 체계 구축
* 변화와 혁신을 통해 부가가치를 창출하려는 실행 의지와 추진력
<br/>
---
## 2. Process의 혁신과 Product의 변화
<br/>
### 2.1 과정(Process)의 혁신
* **Analogue 기반 업무의 Digital화**
| As-is [Analogue] | 구분 | To-be [Digital] |
| :--- | :---: | :--- |
| **개념·문서·행정 절차 중심** | ➠ | **시각화된 목적물, 소통, 투명성 중심** |
| **2D 도면, 전문가, 규정** | ➠ | **3D 모델, 참여자, 실체** |
| **업무 구분(단절), 책임** | ➠ | **협업(융·복합), 창의성** |
<br/>
* **GIS + BIM의 연계**
* 지리·지형·지반 등 위치정보(GIS)와 3D모델(형상, 속성정보) 기반의 건설 정보를 포함하는 BIM의 연계를 통한 업무 프로세스의 혁신
<br/>
* **사용자 중심의 Solution 제공**
* 서로 다른 S/W로 작성되어 분절화된 Analogue 방식의 성과물과 정보물을 연계할 수 있는 설계·시공 Solution 제공
<br/>
<br/>
### 2.2 결과(Product)의 변화
* **Copy & Paste로 인해 하향 평준화된 기존 성과물의 품질 향상**
* 과거 수작업으로 시행하면서 발생하던 오류 등의 최소화
* 정확한 Data에 기반한 계획으로 고품질 성과물 도출
<br/>
* **Analogue 기반 도서 외 Digital 기반 정보물 추가**
* 기존 성과물(도면, 수량, 계산서, 시방서 등)에 3D 모델, Simulation 등의 Digital 기반 정보물 추가
<br/>
* **Solution을 활용한 업무 효율화**
* Engn. Solution을 통해 성과물에 관한 이슈를 함께 검토·논의하는 협업 환경 조성
* 건설 단계별 정보를 디지털 데이터로 축적하여, 건설 전 과정을 통합관리
<br/>
---
:::note[핵심 요약]
* **DX는 필요한 요건과 체계를 갖춘 후 시행해야만 그 효과를 기대할 수 있다.**
:::
@@ -1,21 +0,0 @@
{
"step_num": 1,
"step_name": "mdx_upload",
"step_status": "done",
"pipeline_path_connected": true,
"input": [
"samples\\mdx_batch\\03.mdx"
],
"output": [
"step01_mdx_upload.json",
"step01_mdx_source.md"
],
"note": "MDX 원본 그대로 step01_mdx_source.md 에 복사.",
"data": {
"mdx_path": "samples\\mdx_batch\\03.mdx",
"run_id": "mdx03_f29_fix_check",
"run_dir": "d:\\ad-hoc\\kei\\design_agent\\data\\runs\\mdx03_f29_fix_check\\phase_z2",
"mdx_source_size_bytes": 3213,
"mdx_source_lines": 89
}
}
@@ -1,36 +0,0 @@
{
"step_num": 2,
"step_name": "normalized",
"step_status": "partial",
"pipeline_path_connected": true,
"input": [
"step01_mdx_source.md"
],
"output": [
"step02_normalized.json"
],
"note": "parse_mdx 결과: title / sections / footer 분리 + raw_content 보존. heading tree 미생성, orphan / details 감지 미완 (Step 2 ⚠ partial — 별 axis). orphans / details 필드는 schema lock — 빈 배열이라도 'detection 미수행' marker.",
"data": {
"slide_title": "DX 실행 체계 구축 방안",
"slide_footer": "DX는 필요한 요건과 체계를 갖춘 후 시행해야만 그 효과를 기대할 수 있다.",
"sections_count": 2,
"sections": [
{
"section_id": "03-1",
"section_num": 1,
"title": "1. DX 시행을 위한 필수 요건",
"raw_content_length": 660,
"raw_content": "<br/>\n\n* **기술(디지털)**\n * **Digital 기술(S/W, H/W)과 업무 Process의 통합**\n * 기존 업무 프로세스에 다양한 디지털 기술을 접목하여 업무 수행\n * 프로젝트 전반에 걸친 업무 프로세스의 연결 및 조율\n * **분야별 전문 지식(설계, 시공, 유지관리 등) 보유**\n * 건설 전 단계에 대한 근본적인 이해와 지식 및 경험\n * 최신 토목 기술 트랜드 및 표준 기준 등에 대한 높은 지식\n\n<br/>\n\n* **사람(역량)**\n * **혁신적 사고방식과 창의적 문제 해결 능력**\n * 기존 수행 방식과 관습적 사고 등에 의한 접근 방식 탈피\n * 디지털 기술을 활용한 창의적, 혁신적인 솔루션 제시\n * **사용자 중심 사고와 DX 수행 경험**\n * 사용자의 요구와 기대를 충족시키는 설계 및 구현\n * 시행착오를 포함한 수행 경험과 사용자 경험(UX)을 반영한 해결 방안 제시\n\n<br/>\n\n* **자연(여건)**\n * **지속적인 투자 및 실행 의지**\n * 기술 도입 초기 단계에 필요한 인력·기간·비용 등의 대규모 투자\n * 기술 고도화를 위한 지속적인 개선 및 투자 체계 구축\n * 변화와 혁신을 통해 부가가치를 창출하려는 실행 의지와 추진력\n\n<br/>\n---"
},
{
"section_id": "03-2",
"section_num": 2,
"title": "2. Process의 혁신과 Product의 변화",
"raw_content_length": 924,
"raw_content": "<br/>\n\n### 2.1 과정(Process)의 혁신\n\n* **Analogue 기반 업무의 Digital화**\n\n | As-is [Analogue] | 구분 | To-be [Digital] |\n | :--- | :---: | :--- |\n | **개념·문서·행정 절차 중심** | ➠ | **시각화된 목적물, 소통, 투명성 중심** |\n | **2D 도면, 전문가, 규정** | ➠ | **3D 모델, 참여자, 실체** |\n | **업무 구분(단절), 책임** | ➠ | **협업(융·복합), 창의성** |\n\n<br/>\n\n* **GIS + BIM의 연계**\n * 지리·지형·지반 등 위치정보(GIS)와 3D모델(형상, 속성정보) 기반의 건설 정보를 포함하는 BIM의 연계를 통한 업무 프로세스의 혁신\n\n<br/>\n\n* **사용자 중심의 Solution 제공**\n * 서로 다른 S/W로 작성되어 분절화된 Analogue 방식의 성과물과 정보물을 연계할 수 있는 설계·시공 Solution 제공\n\n<br/>\n<br/>\n\n### 2.2 결과(Product)의 변화\n\n* **Copy & Paste로 인해 하향 평준화된 기존 성과물의 품질 향상**\n * 과거 수작업으로 시행하면서 발생하던 오류 등의 최소화\n * 정확한 Data에 기반한 계획으로 고품질 성과물 도출\n\n<br/>\n\n* **Analogue 기반 도서 외 Digital 기반 정보물 추가**\n * 기존 성과물(도면, 수량, 계산서, 시방서 등)에 3D 모델, Simulation 등의 Digital 기반 정보물 추가\n\n<br/>\n\n* **Solution을 활용한 업무 효율화**\n * Engn. Solution을 통해 성과물에 관한 이슈를 함께 검토·논의하는 협업 환경 조성\n * 건설 단계별 정보를 디지털 데이터로 축적하여, 건설 전 과정을 통합관리\n\n<br/>\n---"
}
],
"orphans": [],
"details": []
}
}
@@ -1,63 +0,0 @@
{
"step_num": 5,
"step_name": "v4_evidence",
"step_status": "done",
"pipeline_path_connected": true,
"input": [
"step02_normalized.json",
"tests/matching/v4_full32_result.yaml"
],
"output": [
"step05_v4_evidence.json"
],
"note": "V4 non-reject max-6 후보 list (Step 9 application_plan input). raw 32 entry 는 tests/matching/v4_full32_result.yaml 에 영속. candidate_status='ok' = 후보 1개 이상 / 'no_non_reject_v4_candidate' = 0개 (Step 9 fallback path 입력). Step 6 plan_composition() 은 lookup_v4_match() (rank-1) 그대로 사용 — backward compat (Step 6-A axis 까지).",
"data": {
"v4_source": "tests\\matching\\v4_full32_result.yaml",
"aligned_section_ids": [
"03-1",
"03-2"
],
"evidence_per_section": [
{
"section_id": "03-1",
"v4_candidates": [
{
"template_id": "three_parallel_requirements",
"frame_id": "1171281190",
"frame_number": 13,
"confidence": 0.9268,
"label": "use_as_is"
},
{
"template_id": "dx_sw_necessity_three_perspectives",
"frame_id": "1171281198",
"frame_number": 20,
"confidence": 0.8413,
"label": "light_edit"
}
],
"candidate_status": "ok"
},
{
"section_id": "03-2",
"v4_candidates": [
{
"template_id": "process_product_two_way",
"frame_id": "1171281210",
"frame_number": 29,
"confidence": 0.9198,
"label": "use_as_is"
},
{
"template_id": "bim_adoption_central_split",
"frame_id": "1171281208",
"frame_number": 27,
"confidence": 0.6278,
"label": "restructure"
}
],
"candidate_status": "ok"
}
]
}
}
@@ -1,180 +0,0 @@
{
"step_num": 6,
"step_name": "composition_plan",
"step_status": "done",
"pipeline_path_connected": true,
"input": [
"step02_normalized.json",
"step05_v4_evidence.json"
],
"output": [
"step06_composition_plan.json"
],
"note": "composition v0 count-based — sections → candidates → score → greedy select. Step 6-A (사용자 lock 2026-05-08): selected_units[i].v4_candidates 추가 (non-reject max-6 후보 list, candidates[0] = 단일 frame_* 와 일관). logic 무변 — runtime 결과 동일. Step 9 application_plan input.",
"data": {
"selected_units_count": 2,
"layout_preset_decided": "horizontal-2",
"candidates_summary": [
{
"source_section_ids": [
"03-1"
],
"merge_type": "single",
"template_id": "three_parallel_requirements",
"label": "use_as_is",
"phase_z_status": "matched_zone",
"score": 0.9268,
"selection_state": "selected",
"auto_selectable": true,
"filter_reasons": [],
"notes": [],
"capacity_fit": {
"item_count": 3,
"source_shape": "top_bullets",
"capacity": {
"strict": 3,
"min": null,
"max": null,
"truncate_at": null,
"pad_to": null
},
"fit_status": "ok",
"mismatch_reason": null
}
},
{
"source_section_ids": [
"03-2"
],
"merge_type": "single",
"template_id": "process_product_two_way",
"label": "use_as_is",
"phase_z_status": "matched_zone",
"score": 0.9198,
"selection_state": "selected",
"auto_selectable": true,
"filter_reasons": [],
"notes": [],
"capacity_fit": {
"item_count": 2,
"source_shape": "h3_subsections",
"capacity": {
"strict": 2,
"min": null,
"max": null,
"truncate_at": null,
"pad_to": null
},
"fit_status": "ok",
"mismatch_reason": null
}
}
],
"candidates_total": 2,
"candidates_viable_auto": 2,
"selected_units": [
{
"source_section_ids": [
"03-1"
],
"merge_type": "single",
"frame_id": "1171281190",
"frame_number": 13,
"frame_template_id": "three_parallel_requirements",
"label": "use_as_is",
"score": 0.9268,
"phase_z_status": "matched_zone",
"rationale": {
"capacity_fit": {
"item_count": 3,
"source_shape": "top_bullets",
"capacity": {
"strict": 3,
"min": null,
"max": null,
"truncate_at": null,
"pad_to": null
},
"fit_status": "ok",
"mismatch_reason": null
},
"frame_compat": 0.9268,
"confidence": 0.9268,
"label": "use_as_is",
"label_weight": 1.0,
"merge_type": "single",
"hierarchy_coherence": null,
"density_score": null
},
"notes": [],
"v4_candidates": [
{
"template_id": "three_parallel_requirements",
"frame_id": "1171281190",
"frame_number": 13,
"confidence": 0.9268,
"label": "use_as_is"
},
{
"template_id": "dx_sw_necessity_three_perspectives",
"frame_id": "1171281198",
"frame_number": 20,
"confidence": 0.8413,
"label": "light_edit"
}
]
},
{
"source_section_ids": [
"03-2"
],
"merge_type": "single",
"frame_id": "1171281210",
"frame_number": 29,
"frame_template_id": "process_product_two_way",
"label": "use_as_is",
"score": 0.9198,
"phase_z_status": "matched_zone",
"rationale": {
"capacity_fit": {
"item_count": 2,
"source_shape": "h3_subsections",
"capacity": {
"strict": 2,
"min": null,
"max": null,
"truncate_at": null,
"pad_to": null
},
"fit_status": "ok",
"mismatch_reason": null
},
"frame_compat": 0.9198,
"confidence": 0.9198,
"label": "use_as_is",
"label_weight": 1.0,
"merge_type": "single",
"hierarchy_coherence": null,
"density_score": null
},
"notes": [],
"v4_candidates": [
{
"template_id": "process_product_two_way",
"frame_id": "1171281210",
"frame_number": 29,
"confidence": 0.9198,
"label": "use_as_is"
},
{
"template_id": "bim_adoption_central_split",
"frame_id": "1171281208",
"frame_number": 27,
"confidence": 0.6278,
"label": "restructure"
}
]
}
]
}
}
@@ -1,79 +0,0 @@
{
"step_num": 7,
"step_name": "layout",
"step_status": "partial",
"pipeline_path_connected": true,
"input": [
"step06_composition_plan.json"
],
"output": [
"step07_layout.json",
"step07_selected_layout.html"
],
"note": "count-based v0 — 들여쓰기 / 정렬 미세 layout 미구현 (Step 7 ⚠ partial). Step 7-conn (사용자 lock 2026-05-08): unit_count → layout_candidates list (select_layout_candidates) artifact 기록. default 결정 자체는 무변 — candidates[0] 가 default (default_selection: true), 나머지는 alternative. Step 9 application_plan input.",
"data": {
"layout_preset": "horizontal-2",
"layout_css": {
"areas": "\"top\" \"bottom\"",
"cols": "1fr",
"rows": "228px 343px",
"heights_px": [
228,
343
],
"ratios": [
0.39,
0.586
],
"computation": "min_height_first + content_weight_distribution",
"dynamic_rows": true,
"raw_zone_layout": {
"computation": "min_height_first + content_weight_distribution",
"slide_body_height": 585,
"gap": 14,
"available_after_gap": 571,
"min_heights_px": [
228,
342
],
"min_scaled": true,
"total_min_height": 570,
"remaining_after_min": 1,
"content_weights": [
{
"position": "top",
"template_id": "three_parallel_requirements",
"score": 4.425
},
{
"position": "bottom",
"template_id": "process_product_two_way",
"score": 7.305
}
],
"weight_shares": [
0.377,
0.623
],
"extras_px": [
0,
1
],
"heights_px": [
228,
343
],
"ratios": [
0.39,
0.586
]
}
},
"zones_count": 2,
"unit_count": 2,
"layout_candidates": [
"horizontal-2",
"vertical-2"
]
}
}
@@ -1,122 +0,0 @@
{
"step_num": 8,
"step_name": "zone_region_ratios",
"step_status": "partial",
"pipeline_path_connected": true,
"input": [
"step07_layout.json",
"step10_frame_contract.json"
],
"output": [
"step08_zone_region_ratios.json",
"step08_zone_content_placement.html"
],
"note": "계획값 only. 실측값은 Step 14 (visual_check). zone-level 만 dynamic, region-level (sub_zone 안 sections) 은 균등 분배 (1/1/1). F29 처럼 비균등 콘텐츠 (transform_table + plain) 는 첫 cell 부족 가능 — Step 8 ⚠ partial. Step 8-conn (사용자 lock 2026-05-08): per_zone_plan[i] 에 region_layout_candidates + display_strategy_candidates 추가 (passive 기록). placeholder signals = text-only / region_count=1 (Step 3/4 부재 종속). 실제 content_object 신호 활성화는 별 axis. Step 9 application_plan input.",
"data": {
"zone_heights_px_planned": [
228,
343
],
"zone_ratios_planned": [
0.39,
0.586
],
"per_zone_plan": [
{
"position": "top",
"zone_height_px_planned": 228,
"zone_ratio_planned": 0.39,
"min_height_px": 230,
"frame_cardinality_strict": 3,
"sub_zones_planned": [
{
"id": "pillar_1",
"role": "main_text",
"accepts": [
"text_block"
],
"cardinality_strict": 1,
"partial_target_path": ".f13b__cols > .f13b__col:nth-child(1)"
},
{
"id": "pillar_2",
"role": "main_text",
"accepts": [
"text_block"
],
"cardinality_strict": 1,
"partial_target_path": ".f13b__cols > .f13b__col:nth-child(2)"
},
{
"id": "pillar_3",
"role": "main_text",
"accepts": [
"text_block"
],
"cardinality_strict": 1,
"partial_target_path": ".f13b__cols > .f13b__col:nth-child(3)"
}
],
"child_distribution_note": "현재 child zone (sub_zones 안 sections) 균등 분배 — Step 8 region-level ratio ⚠ partial.",
"region_layout_candidates": [
"region-single"
],
"display_strategy_candidates": [
"inline_full",
"inline_preview_with_details",
"details_only"
]
},
{
"position": "bottom",
"zone_height_px_planned": 343,
"zone_ratio_planned": 0.586,
"min_height_px": 345,
"frame_cardinality_strict": 2,
"sub_zones_planned": [
{
"id": "process_column",
"role": "main_text",
"accepts": [
"text_block",
"transform_table"
],
"cardinality_strict": 3,
"partial_target_path": ".f29b__grid .f29b__cell--left"
},
{
"id": "product_column",
"role": "main_text",
"accepts": [
"text_block"
],
"cardinality_strict": 3,
"partial_target_path": ".f29b__grid .f29b__cell--right"
}
],
"child_distribution_note": "현재 child zone (sub_zones 안 sections) 균등 분배 — Step 8 region-level ratio ⚠ partial.",
"region_layout_candidates": [
"region-single"
],
"display_strategy_candidates": [
"inline_full",
"inline_preview_with_details",
"details_only"
]
}
],
"step8_conn_placeholder_signals": {
"region_count": 1,
"content_type": "text_block",
"flow_type": null,
"role_pattern": null,
"ratio_asymmetric": false,
"has_visual_element": false,
"details_presence": false,
"large_table": false,
"long_text": false,
"fits_in_region": null,
"_note": "Step 3/4 부재 종속 placeholder — 모든 zone 을 text-only / region_count=1 로 가정. 실제 content_object 신호 활성화는 별 axis."
}
}
}
@@ -1,135 +0,0 @@
{
"step_num": 9,
"step_name": "application_plan",
"step_status": "partial",
"pipeline_path_connected": true,
"input": [
"step05_v4_evidence.json",
"step06_composition_plan.json",
"step07_layout.json",
"step08_zone_region_ratios.json"
],
"output": [
"step09_application_plan.json",
"step09_application_plan.html"
],
"note": "Step 9 v0 passive application_plan trace (사용자 lock 2026-05-08). V4 후보 + layout / region / display 후보 + V4 label → application_mode 변환을 side-by-side 로 기록. v0 invariant 5 가지 (status.md §4) 만족. Step 6 의 default 결정 그대로 (current_default_candidate). auto decision 은 Step 9 v1 (별 axis). region/display 후보는 Step 8-conn 의 placeholder signal 종속 (Step 3/4 부재).",
"data": {
"units": [
{
"unit_id": "03-1",
"layout_preset": "horizontal-2",
"layout_candidates": [
"horizontal-2",
"vertical-2"
],
"region_layout_candidates": [
"region-single"
],
"display_strategy_candidates": [
"inline_full",
"inline_preview_with_details",
"details_only"
],
"candidate_status": "ok",
"application_status": "ok",
"current_default_candidate": "three_parallel_requirements",
"v4_candidates": [
{
"template_id": "three_parallel_requirements",
"frame_id": "1171281190",
"frame_number": 13,
"confidence": 0.9268,
"label": "use_as_is"
},
{
"template_id": "dx_sw_necessity_three_perspectives",
"frame_id": "1171281198",
"frame_number": 20,
"confidence": 0.8413,
"label": "light_edit"
}
],
"application_candidates": [
{
"template_id": "three_parallel_requirements",
"frame_id": "1171281190",
"v4_label": "use_as_is",
"application_mode": "direct_insert",
"auto_applicable": true,
"required_changes": [],
"delegated_to": "step10_contract_check"
},
{
"template_id": "dx_sw_necessity_three_perspectives",
"frame_id": "1171281198",
"v4_label": "light_edit",
"application_mode": "same_frame_with_adjustment",
"auto_applicable": true,
"required_changes": [],
"delegated_to": "step10_contract_check"
}
]
},
{
"unit_id": "03-2",
"layout_preset": "horizontal-2",
"layout_candidates": [
"horizontal-2",
"vertical-2"
],
"region_layout_candidates": [
"region-single"
],
"display_strategy_candidates": [
"inline_full",
"inline_preview_with_details",
"details_only"
],
"candidate_status": "ok",
"application_status": "ok",
"current_default_candidate": "process_product_two_way",
"v4_candidates": [
{
"template_id": "process_product_two_way",
"frame_id": "1171281210",
"frame_number": 29,
"confidence": 0.9198,
"label": "use_as_is"
},
{
"template_id": "bim_adoption_central_split",
"frame_id": "1171281208",
"frame_number": 27,
"confidence": 0.6278,
"label": "restructure"
}
],
"application_candidates": [
{
"template_id": "process_product_two_way",
"frame_id": "1171281210",
"v4_label": "use_as_is",
"application_mode": "direct_insert",
"auto_applicable": true,
"required_changes": [],
"delegated_to": "step10_contract_check"
},
{
"template_id": "bim_adoption_central_split",
"frame_id": "1171281208",
"v4_label": "restructure",
"application_mode": "layout_or_region_change",
"auto_applicable": false,
"required_changes": [],
"delegated_to": "human_review"
}
]
}
],
"candidate_status_summary": {
"units_with_no_v4_candidate": []
},
"v0_lock_note": "Step 9 v0 passive (사용자 lock 2026-05-08). Step 6 default 그대로 사용 — runtime 결과 byte-동일. auto decision / scoring 은 Step 9 v1 (별 axis)."
}
}
@@ -1,38 +0,0 @@
{
"step_num": 20,
"step_name": "slide_status",
"step_status": "done",
"pipeline_path_connected": true,
"input": [
"step14_visual_check.json",
"step17_retry_trace.json",
"step19_next_action.json"
],
"output": [
"step20_slide_status.json",
"step20_final_status.html"
],
"note": "자동 파이프라인 최종 결과 보고. overall = PASS/RENDERED_WITH_VISUAL_REGRESSION/PARTIAL_COVERAGE 등.",
"data": {
"rendered": true,
"visual_check_passed": true,
"full_mdx_coverage": true,
"aligned_section_ids": [
"03-1",
"03-2"
],
"covered_section_ids": [
"03-1",
"03-2"
],
"filtered_section_ids": [],
"filtered_section_reasons": [],
"visual_fail_reasons": [],
"adapter_needed_count": 0,
"adapter_needed_units": [],
"content_truncated_count": 0,
"content_truncated_units": [],
"overall": "PASS",
"note": "자동 파이프라인 결과 보고. review/UI 개념 X. final.html 파일명 != PASS 의미. overall == PASS 는 visual OK + full coverage + adapter_needed=0 일 때만. adapter_needed_count > 0 = mapper 가 contract 와 안 맞아 자동 렌더 못 한 zone 존재. content_truncated_count > 0 = builder 가 truncate 한 zone 존재 (rendered 됐지만 일부 콘텐츠 손실)."
}
}
File diff suppressed because it is too large Load Diff
@@ -1,992 +0,0 @@
<!-- Phase Z-2 MVP-1.5b — single slide + Type B layout (top/bottom zones).
원래 Phase Z 설계 복귀: MDX 1 = slide 1, layout preset = zone 분할, frame-derived block ⊂ zone. -->
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1280">
<title>DX 지연 요인</title>
<style>
/* ── existing tokens (inlined) ── */
/* === typography.css === */
/* ══════════════════════════════════════
Typography Tokens v1
══════════════════════════════════════
슬라이드 전체의 공통 글자 위계.
모든 블록은 이 토큰을 참조한다.
직접값(font-size: 11px) 금지 → var(--font-body) 사용.
══════════════════════════════════════ */
:root {
/* ── 1층: Global Hierarchy ── */
/* 대목차: 슬라이드 상단 제목 */
--font-slide-title: 22px;
--weight-slide-title: 700;
--lh-slide-title: 1.4;
/* 중목차: zone 제목 */
--font-zone-title: 16px;
--weight-zone-title: 700;
--lh-zone-title: 1.4;
/* 소목차: 블록 내 소제목, 카드 제목 */
--font-sub-title: 12px;
--weight-sub-title: 700;
--lh-sub-title: 1.45;
/* 본문: 블릿, 설명 텍스트 */
--font-body: 10px;
--weight-body: 500;
--lh-body: 1.55;
/* 캡션: 각주, 출처, 보조 텍스트 */
--font-caption: 10px;
--weight-caption: 400;
--lh-caption: 1.4;
/* footer: 핵심 인사이트 pill */
--font-footer: 20px;
--weight-footer: 700;
--lh-footer: 1.2;
/* ── 2층: Component Semantic Tokens ── */
/* 가까운 위계에서 기본값, 필요시 override */
/* 본문 강조 (인라인 heading) */
--font-body-strong: var(--font-body);
--weight-body-strong: 600;
/* 자세히보기 링크 */
--font-detail-link: var(--font-caption);
--weight-detail-link: 500;
/* pill/badge 라벨 */
--font-pill-label: var(--font-sub-title);
--weight-pill-label: 700;
/* 표 헤더 */
--font-table-header: var(--font-sub-title);
--weight-table-header: 700;
/* 표 셀 */
--font-table-cell: var(--font-body);
--weight-table-cell: 400;
/* 비교 뱃지 */
--font-compare-badge: var(--font-sub-title);
--weight-compare-badge: 700;
/* 강조 인용 */
--font-callout: var(--font-sub-title);
--weight-callout: 700;
/* 상단 라벨 (overline) */
--font-overline: var(--font-caption);
--weight-overline: 600;
--ls-overline: 0.05em;
}
/* === spacing.css === */
/* ══════════════════════════════════════
Spacing Tokens v1
══════════════════════════════════════
여백/간격/들여쓰기 공통 기준.
══════════════════════════════════════ */
:root {
/* ── 기본 스케일 ── */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 16px;
--space-xl: 24px;
/* ── slide-base 레이아웃 ── */
--slide-padding-x: 40px; /* 본문 좌우 여백 */
--slide-title-left: 52px; /* 대목차 시작 */
--slide-title-top: 22px; /* 대목차 위치 */
--slide-divider-top: 58px; /* 구분선 위치 (slide_base.html 와 일치 2026-05-07) */
--slide-body-top: 76px; /* 본문 시작 (사용자 직설: divider-body 16px) */
--slide-body-height: 585px; /* 본문 높이 (사용자 직설: body-footer 10px) */
--slide-footer-bottom: 8px; /* footer 하단 여백 (slide_base.html 와 일치) */
--slide-footer-height: 41px; /* footer 높이 */
/* ── zone ── */
--zone-gap: 8px; /* zone 간 간격 */
--zone-padding-left: 12px; /* zone 안쪽 좌측 여백 */
--zone-padding-right: 8px; /* zone 안쪽 우측 여백 */
--zone-title-mb: 8px; /* zone 제목 아래 여백 */
/* ── 블록 공통 ── */
--card-padding: 16px; /* 카드 내부 여백 */
--card-padding-sm: 8px; /* 작은 카드 내부 여백 */
--card-gap: 12px; /* 카드 간 간격 */
--card-radius: 6px; /* 카드 모서리 */
/* ── 블릿/텍스트 ── */
--bullet-indent: 14px; /* 불릿 hanging indent */
--bullet-gap: 2px; /* 불릿 항목 간 간격 */
--heading-gap: 4px; /* 소제목 아래 간격 */
--section-gap: 10px; /* 섹션 간 간격 */
/* ── flex/grid 기본 ── */
--flex-gap: 12px; /* 기본 flex gap */
--flex-gap-sm: 6px; /* 좁은 flex gap */
--grid-gap: 16px; /* 기본 grid gap */
}
/* === colors.css === */
/* ══════════════════════════════════════
Color Tokens v1
══════════════════════════════════════
2층 구조: 공통 테마색 + 블록 의미색.
══════════════════════════════════════ */
:root {
/* ══ 1층: 공통 테마색 ══ */
/* 모든 블록이 공유하는 기본 팔레트 */
/* 텍스트 */
--color-title: #1e293b; /* 제목 (진한 남색) */
--color-zone-title: #1a365d; /* zone 중목차 제목 */
--color-body: #475569; /* 본문 */
--color-body-strong: #1e293b; /* 본문 강조 */
--color-caption: #94a3b8; /* 캡션, 보조 */
--color-muted: #64748b; /* 약한 텍스트 */
/* 배경 */
--color-bg: #ffffff; /* 슬라이드 배경 */
--color-bg-subtle: #f8fafc; /* 카드/영역 배경 */
--color-bg-page: #e8ecf0; /* 페이지 배경 (슬라이드 바깥) */
/* 보더 */
--color-border: #e2e8f0; /* 기본 보더 */
--color-border-strong: #cbd5e1; /* 강한 보더 */
/* 강조 */
--color-accent: #2563eb; /* 링크, 강조 (파랑) */
--color-accent-hover: #eff6ff; /* 링크 hover 배경 */
--color-danger: #dc2626; /* 경고, 에러 */
/* slide-base 전용 */
--color-slide-title-from: #296b55; /* 대목차 gradient 시작 */
--color-slide-title-to: #123328; /* 대목차 gradient 끝 */
--color-footer-bg-from: #3b3523; /* footer gradient 시작 */
--color-footer-bg-mid: #263a2a; /* footer gradient 중간 */
--color-footer-bg-to: #113f31; /* footer gradient 끝 */
--color-footer-text: #ffffff; /* footer 텍스트 */
--color-footer-accent: #ffee33; /* footer 강조 (em) */
/* ══ 2층: 블록 의미색 ══ */
/* 특정 블록 유형에서 의미를 가지는 색 */
/* 3열 비교 (prerequisites-3col) */
--color-col-1-from: #0D78D0; /* 1열 gradient 시작 */
--color-col-1-to: #023056; /* 1열 gradient 끝 */
--color-col-2-from: #FF9A23; /* 2열 gradient 시작 */
--color-col-2-to: #CC5200; /* 2열 gradient 끝 */
--color-col-3-from: #39BE49; /* 3열 gradient 시작 */
--color-col-3-to: #23742C; /* 3열 gradient 끝 */
/* 비교 블록 (compare) */
--color-compare-left: #5c3714; /* 좌측 (AS-IS 계열) */
--color-compare-right: #285b4a; /* 우측 (TO-BE 계열) */
--color-compare-badge: #ae3607; /* VS 뱃지 */
/* 다크 카드 (direct render) */
--color-dark-card-1: #1a365d; /* 카드 1 배경 */
--color-dark-card-2: #1e3a2f; /* 카드 2 배경 */
--color-dark-card-3: #3b1f2b; /* 카드 3 배경 */
--color-dark-card-title: #fbbf24; /* 다크카드 제목 (금색) */
--color-dark-card-body: #e2e8f0; /* 다크카드 본문 */
/* 표 */
--color-table-header-bg: #64748b; /* 표 헤더 배경 */
--color-table-header-text: #ffffff; /* 표 헤더 텍스트 */
/* 블록 제목 gradient (Figma 원본) */
--color-block-title-from: #CC5200; /* 주황 gradient 시작 */
--color-block-title-to: #883700; /* 주황 gradient 끝 */
/* pill */
--color-pill-bg: #1e293b; /* pill 배경 */
--color-pill-text: #ffffff; /* pill 텍스트 */
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
background: #e8ecf0;
display: flex; justify-content: center; align-items: center;
min-height: 100vh;
word-break: keep-all;
padding: 20px 0;
}
/* ── 16:9 슬라이드 (single, 1280×720) ── */
.slide {
width: 1280px; height: 720px;
position: relative;
overflow: hidden;
background: #fff;
box-shadow: 0 4px 20px rgba(0,0,0,.15);
flex-shrink: 0;
}
.slide-bg {
position: absolute; inset: 0;
background: linear-gradient(180deg, #f0f0f0 0%, #ffffff 100%);
z-index: 0;
}
.slide-title {
position: absolute;
left: 52px; top: 22px;
width: calc(100% - 104px);
font-weight: 700;
font-size: var(--font-slide-title);
line-height: var(--lh-slide-title);
background-image: linear-gradient(180deg, #296b55 0%, #123328 100%);
-webkit-background-clip: text; background-clip: text;
color: transparent;
text-shadow: 0 0 2px #322c1e;
z-index: 2;
}
.slide-divider {
position: absolute;
left: 50px; top: 58px;
width: calc(100% - 100px);
height: 2px;
background: #cbd5e1;
z-index: 2;
}
.slide-body {
position: absolute;
left: 50px; top: 76px;
width: calc(100% - 100px); /* 1180 */
height: 585px;
z-index: 1;
overflow: hidden;
}
/* ── Layout preset (composition planner v0 — 8 vocabulary) ──
단일 .layout-{preset} 클래스에 grid CSS 주입.
layout_css = pipeline build_layout_css() 결과 :
- horizontal-2 : rows = dynamic px (content_weight + frame_min_height)
- 그 외 : rows / cols = fr default (LAYOUT_PRESETS)
positions (single / top / bottom / left / right / top-left / ... / grid-2x2 9개)
은 .zone 의 inline grid-area 로 지정 — preset CSS 는 areas 만 정의. */
.layout-horizontal-2 {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 211px 360px;
grid-template-areas: "top" "bottom";
gap: 14px;
height: 100%;
}
/* ── Zone — block 가용 공간 최대화. 프레임 자체 styling 이 boundary 역할 ── */
.zone {
position: relative;
overflow: hidden;
padding: 0;
min-width: 0;
min-height: 0;
}
/* ── Frame-family text layout contract (shared, reusable) ──
feedback-1 (mvp1.5b_test7): visible improvement 강화.
Stronger hanging indent + breathing line spacing + visible hierarchy. */
.text-line {
font-size: var(--font-body); /* 11px */
font-weight: var(--weight-body);
line-height: 1.6; /* breathing room */
margin: 0;
}
.text-line + .text-line {
margin-top: 4px; /* visible gap between lines (was 2px) */
}
/* bullet line — hanging indent via padding + absolute marker (안정적) */
.text-line--bullet {
padding-left: 14px;
position: relative;
}
.text-line--bullet::before {
content: "•"; /* visible bullet 마커 */
position: absolute;
left: 2px;
top: 0;
font-weight: 700;
line-height: inherit;
color: inherit;
}
/* indent levels — visible nested hierarchy */
.text-line--indent-1 {
padding-left: 30px;
}
.text-line--indent-1::before {
content: "◦"; /* sub-bullet 다른 마커 */
left: 18px;
}
.text-line--indent-2 {
padding-left: 46px;
}
.text-line--indent-2::before {
content: "▪";
left: 34px;
}
/* body line — 마커 없음 */
.text-line--body {
padding-left: 0;
}
.text-line strong { color: #000; font-weight: 700; }
/* ── Transform-block family (frame 29 AS-IS/TO-BE dedicated comparison component) ──
feedback-3: bullet rows 가 아닌 *전용 비교 component*. Header (AS-IS / TO-BE) + paired rows.
color-coded cells, left-border accent → 단번에 *비교 구조* 인지. */
.transform-block {
margin-top: 4px;
padding: 6px 8px 6px;
background: rgba(255, 255, 255, 0.4);
border-radius: 3px;
border: 1px solid rgba(0,0,0,0.05);
}
.transform-block__header {
display: grid;
grid-template-columns: 1fr 22px 1fr;
gap: 6px;
margin-bottom: 4px;
padding-bottom: 3px;
border-bottom: 1px dashed rgba(0,0,0,0.12);
font-size: 9px;
font-weight: 900;
letter-spacing: 0.5px;
}
.transform-block__label--from { color: #6b5444; }
.transform-block__label--to { color: #2563eb; text-align: left; }
.transform-rows {
display: flex; flex-direction: column;
gap: 4px;
}
.transform-row {
display: grid;
grid-template-columns: 1fr 22px 1fr;
gap: 6px;
align-items: stretch;
font-size: var(--font-body);
line-height: 1.45;
}
.transform-row__from {
padding: 3px 8px;
background: linear-gradient(90deg, rgba(140,120,90,0.16), rgba(140,120,90,0.08));
border-left: 2px solid #b8a98a;
border-radius: 2px;
color: #4b3f30;
font-weight: 500;
}
.transform-row__arrow {
text-align: center;
color: #2563eb;
font-weight: 900;
font-size: 14px;
align-self: center;
}
.transform-row__to {
padding: 3px 8px;
background: linear-gradient(90deg, rgba(37,99,235,0.10), rgba(37,99,235,0.18));
border-left: 2px solid #2563eb;
border-radius: 2px;
color: #1e40af;
font-weight: 700;
}
/* ── Footer pill — feedback-3: body 와 weight balance, 살짝 가벼워짐 ── */
.slide-footer {
position: absolute;
left: 50px; bottom: 8px;
width: calc(100% - 100px); /* front 기준 (body 와 정렬) */
height: 41px; /* front 기준 */
border-radius: 999px;
overflow: hidden;
z-index: 2;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(90deg, #3b3523 5%, #263a2a 50%, #113f31 95%);
opacity: 0.92; /* slightly lighter */
}
.slide-footer-text {
position: relative; z-index: 1;
font-size: 15px; /* was 20px (--font-footer) */
font-weight: 700;
line-height: 1.2;
color: #fff;
text-align: center;
text-shadow: 0 0 3px rgba(0,0,0,.4);
letter-spacing: -0.01em;
}
.slide-footer-text em { color: #fe3; font-style: normal; }
.phase-z2-marker {
position: absolute;
top: 4px; right: 8px;
font-size: 9px; color: #94a3b8;
font-family: monospace;
z-index: 3;
}
</style>
</head>
<body>
<div class="slide" data-page="1">
<div class="slide-bg"></div>
<div class="phase-z2-marker">phase_z2 / mvp-1.5b / horizontal-2 / single slide</div>
<div class="slide-title">DX 지연 요인</div>
<div class="slide-divider"></div>
<div class="slide-body">
<div class="layout-horizontal-2">
<div class="zone" data-zone-position="top" data-template-id="pre_construction_model_info_stacked" style="grid-area: top;">
<!-- Phase Z-2 신규 frame partial — Frame 9 (1171281180) pre_construction_model_info_stacked.
2026-05-14 — V4 04-1 top rank-1 매칭 (의미 conf=0.722) + structure cardinality 5 일치.
사용자 룰 부합 : Figma visual (계단 pill / 5 색상 / vertical label) 유지 + 콘텐츠 cardinality
에 맞춰 보완 (pill 안 1 라인 → multi-line: pill_N_label + pill_N_body). -->
<style>
.f9b {
width: 100%; height: 100%;
display: flex; flex-direction: column;
gap: 6px;
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
word-break: keep-all;
position: relative;
}
/* 2026-05-14 — title 스타일 통일 (frame 29 process_product_two_way 와 동일 패턴) :
배경 box + box-shadow 제거, gradient 텍스트 만 유지. 사용자 룰 Q1. */
.f9b__title {
font-size: var(--font-zone-title);
font-weight: 700;
line-height: var(--lh-zone-title);
background-image: linear-gradient(180deg, #000 0%, #883700 100%);
-webkit-background-clip: text; background-clip: text;
color: transparent;
flex-shrink: 0;
padding-left: 4px;
}
.f9b__body {
flex: 1;
display: flex;
flex-direction: row;
gap: 8px;
min-height: 0;
}
/* 좌측 vertical label — 콘텐츠 있을 때만 표시 */
.f9b__vlabel {
flex: 0 0 auto;
width: 56px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: var(--font-sub-title);
line-height: 1.2;
color: #144838;
text-align: center;
writing-mode: vertical-rl;
text-orientation: upright;
text-shadow: 0 2px 2px rgba(0,0,0,0.15);
}
/* 2026-05-14 — pill 배치 vertical → horizontal (grid). 5 pills 를 가로로 나열 →
우측 공란 활용 + 세로 공간 절약. auto-fit + minmax 으로 N 동적 (4 / 5 / 6 모두 자동). */
.f9b__pill-rows {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 6px;
min-height: 0;
}
/* 2026-05-14 — bullet 줄간격 1/3 으로 압축 + 다른 간격 (padding, label↔body) 살짝 늘림. */
.f9b__pill {
min-height: 0;
background: rgba(255,255,255,0.7);
border-radius: 10px;
border-bottom: 3px solid;
box-shadow: 1px 2px 4px rgba(0,0,0,0.15);
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 5px;
overflow: hidden;
align-self: start;
}
/* Figma frame 9 의 5 색상 — nth-child cycle. N>5 면 default border-bottom 색 (회색). */
.f9b__pill:nth-child(5n+1) { border-bottom-color: #fb5915; }
.f9b__pill:nth-child(5n+2) { border-bottom-color: #e79000; }
.f9b__pill:nth-child(5n+3) { border-bottom-color: #e9a804; }
.f9b__pill:nth-child(5n+4) { border-bottom-color: #919f00; }
.f9b__pill:nth-child(5n) { border-bottom-color: #0d6361; }
.f9b__pill-label {
font-size: var(--font-sub-title);
font-weight: 900;
line-height: 1.2;
color: #144838;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 6px;
}
/* arrow indicator — Figma frame 9 의 좌측 화살표 (▶) approximation */
.f9b__pill-label::before {
content: "▶";
flex-shrink: 0;
color: currentColor;
font-size: 0.85em;
}
/* 2026-05-14 — bullet 줄간격 1/3 으로 압축 : line-height 1.3 → 1.05 (약 1/3 감소).
bullet 간 gap 2 → 1 로 더 압축. 가독성 임계 (한 bullet 안 line wrap 시 라인 겹침 직전). */
.f9b__pill-body {
flex: 1 1 auto;
overflow: hidden;
min-height: 0;
color: #0c271e;
font-size: var(--font-body);
line-height: 1.05;
display: flex;
flex-direction: column;
gap: 1px;
}
/* 첫 line = 인용구 (italic + 무 마커) — 04-1 의 따옴표 인용구 패턴 */
.f9b__pill-body .text-line:first-child {
font-style: italic;
color: #5C3714;
padding-left: 0;
}
/* 이후 lines = 각 이슈 bullet (▪ 마커로 구분 강화) */
.f9b__pill-body .text-line:not(:first-child) {
position: relative;
padding-left: 12px;
}
.f9b__pill-body .text-line:not(:first-child)::before {
content: "▪";
position: absolute;
left: 0;
color: #919f00;
font-size: 0.9em;
}
</style>
<div class="f9b" data-frame-id="1171281180" data-template-id="pre_construction_model_info_stacked">
<div class="f9b__title">1. DX에 대한 인식</div>
<div class="f9b__body">
<div class="f9b__pill-rows">
<div class="f9b__pill" data-frame-slot-id="pill_dynamic" data-pill-n="1">
<div class="f9b__pill-label">기술 및 소프트웨어 이해도</div>
<div class="f9b__pill-body">
<div class="text-line">"무슨 말인지 잘 모르겠다, 어디까지 어떻게 해야 하는지 모르겠다"</div><div class="text-line">기본지침, 시행지침 등 새롭게 알아야 할 게 너무 많다.</div><div class="text-line">3D 모델과 별 차이점을 모르겠다. S/W마다 사용법이 다르다.</div><div class="text-line">필요한 것은 쉽고 간단한 건데, 왜 이렇게 복잡하게 만들까?</div>
</div>
</div>
<div class="f9b__pill" data-frame-slot-id="pill_dynamic" data-pill-n="2">
<div class="f9b__pill-label">효과와 효율성</div>
<div class="f9b__pill-body">
<div class="text-line">"2D 설계 후 전환설계를 수행하는데 효과는 모르겠고, 효율은 낮다"</div><div class="text-line">성과품 작성은 기존과 같게 하고, 추가 업무만 발생해 효율이 낮다.</div><div class="text-line">BIM으로 인해 가중되는 업무 대비 효과가 거의 없어 보인다.</div><div class="text-line">결과적으로 큰 차이를 못 느끼겠고, 이런 노력이 정말 가치 있는 일이야?</div>
</div>
</div>
<div class="f9b__pill" data-frame-slot-id="pill_dynamic" data-pill-n="3">
<div class="f9b__pill-label">인력 및 교육</div>
<div class="f9b__pill-body">
<div class="text-line">"수행 인력이 부족하고, 기존 직원들은 어떻게 교육해야 하나"</div><div class="text-line">교육시간 손실로 일손이 더 필요해지고, 적응하는데도 시간이 걸린다.</div><div class="text-line">신입사원은 없고 BIM 수행을 할 수 있는 기술자가 부족하다.</div><div class="text-line">여러 회사의 S/W별 사용법이 달라 새로운 S/W에 적용에 시간이 필요하다.</div>
</div>
</div>
<div class="f9b__pill" data-frame-slot-id="pill_dynamic" data-pill-n="4">
<div class="f9b__pill-label">경제적 부담</div>
<div class="f9b__pill-body">
<div class="text-line">"S/W 구독료만 크게 발생되고, 비용 보전은 안 된다"</div><div class="text-line">사용해야 할 S/W의 종류가 너무 많고 복잡한데 모두 필요한가?</div><div class="text-line">모든 Project에 적용되는 것도 아닌데, 다수의 S/W 구독료를 내야 한다.</div><div class="text-line">디지털전환 하기 위해 이 비용을 쓰는게 정말 경제적인 이득이 있는 거야?</div>
</div>
</div>
<div class="f9b__pill" data-frame-slot-id="pill_dynamic" data-pill-n="5">
<div class="f9b__pill-label">실무 및 적용성</div>
<div class="f9b__pill-body">
<div class="text-line">"실무적 사용에 의한 효율성 증진보다는 홍보, PQ용으로 사용한다"</div><div class="text-line">구체적 적용에 의한 비용, 시간, 품질 등의 효과 사례가 없다.</div><div class="text-line">지형, 선형, 도로, 교량 Model을 만드는 S/W가 모두 달라 적용이 어렵다.</div><div class="text-line">속성정보를 반영하지 않은 형상 위주의 3D 모델 제작에만 초점이 맞춰져 있다.</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="zone" data-zone-position="bottom" data-template-id="process_product_two_way" style="grid-area: bottom;">
<!-- Phase Z-2 MVP-1.5b frame-derived adapted block.
§17 룰 — Figma 시각 언어 promote, geometry 만 zone-compatible adapt. -->
<style>
.f29b {
width: 100%; height: 100%;
display: flex; flex-direction: column;
gap: 4px;
position: relative;
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
word-break: keep-all;
overflow: hidden;
}
.f29b__title {
font-size: var(--font-zone-title); /* 13px — frame 13 의 f13b__title 과 동일 위계 */
font-weight: 700;
line-height: var(--lh-zone-title);
background-image: linear-gradient(180deg, #000 0%, #883700 100%);
-webkit-background-clip: text; background-clip: text;
color: transparent;
flex-shrink: 0;
padding-left: 4px;
}
/* banners — top */
.f29b__banners {
flex: 0 0 auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
position: relative;
height: 36px;
}
.f29b__banner {
position: relative;
overflow: hidden;
display: flex; align-items: center; justify-content: center;
}
/* PROMOTED background gradients — figma origin lines 84-98 (header-bg--left/right).
원본 SVG 3 개 → CSS gradient 로 재현 (R8/R9 준수). border-radius 9999px = 자동 pill. */
.f29b__banner--left {
background: linear-gradient(to right,
rgba(136, 131, 118, 0.343) 0%,
#39321E 73.1%,
#39321E 100%);
border-radius: 0 9999px 9999px 0;
}
.f29b__banner--right {
background: linear-gradient(to right,
#032118 0%,
rgba(41, 107, 85, 0.1) 100%);
border-radius: 9999px 0 0 9999px;
}
.f29b__banner-text {
position: relative;
z-index: 1;
font-size: var(--font-sub-title);
font-weight: 900;
line-height: 1;
text-shadow: 0 0 2px #322C1E;
-webkit-background-clip: text; background-clip: text;
color: transparent;
padding: 0 8px;
}
/* PROMOTED gradient — lines 101-104 / 110-113 */
.f29b__banner--left .f29b__banner-text {
background-image:
linear-gradient(90deg, rgb(62,53,35) 0%, rgb(62,53,35) 100%),
linear-gradient(180deg, rgb(41,107,85) 0%, rgb(18,51,40) 100%);
}
.f29b__banner--right .f29b__banner-text {
background-image:
linear-gradient(90deg, rgb(34,95,74) 0%, rgb(34,95,74) 100%),
linear-gradient(180deg, rgb(41,107,85) 0%, rgb(18,51,40) 100%);
}
/* body — 6 cell grid (3 rows × 2 cols) — 좌우 row divider Y 정렬 */
/* PROMOTED background — figma origin lines 53-68 (body-bg-left/right).
좌(0~50%): #39321E α0.3 (top) → white α0.3 (bottom)
우(50~100%): #296B55 α0.3 (top) → white α0.3 (bottom)
원본 SVG → CSS multi-bg 로 재현. */
.f29b__body {
flex: 1;
position: relative;
min-height: 0;
padding-top: 4px;
background:
linear-gradient(to bottom, rgba(57,50,30,0.3) 0%, rgba(255,255,255,0.3) 100%) 0% 0% / 50% 100% no-repeat,
linear-gradient(to bottom, rgba(41,107,85,0.3) 0%, rgba(255,255,255,0.3) 100%) 100% 0% / 50% 100% no-repeat;
}
/* 6 cell grid: 3 rows (section 1/2/3), 2 cols (process/product). 각 row 의 height = max(좌, 우) */
.f29b__grid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr 1fr;
grid-auto-rows: auto;
gap: 6px 10px;
padding: 4px 8px;
height: 100%;
}
.f29b__cell {
overflow: hidden;
min-width: 0;
min-height: 0;
}
/* row 2/3 (3 번째 cell 이후) 위에 dashed divider — 좌우 같은 Y */
.f29b__cell:nth-child(n+3) {
border-top: 1px dashed rgba(0,0,0,0.15);
padding-top: 6px;
margin-top: 4px;
}
/* section-title — 부소목차 (사용자 5-layer 직설 2026-05-07): Analogue 기반... 같은 깊이 0 굵은 bullet */
.f29b__section-title {
font-size: var(--font-sub-title); /* 12px — 부소목차 위계 */
font-weight: 900;
line-height: 1.3;
margin-bottom: 5px;
padding-left: 6px;
border-left: 3px solid currentColor;
}
.f29b__cell--left .f29b__section-title { color: #5C3714; } /* PROMOTED line 125 */
.f29b__cell--right .f29b__section-title { color: #084C56; } /* PROMOTED line 126 */
.f29b__section-title__sub {
font-size: var(--font-caption); /* 10px */
margin-left: 3px;
}
/* f29b 의 본문 = .text-line 공통 contract 사용. bullet color override. */
.f29b__cell .text-line--bullet { color: #000; font-weight: 700; }
/* 2026-05-14 — line-line gap 만 1/2 압축 (사용자 lock 정확 의도) :
line-height 1.6 (공통 contract) 유지. .text-line + .text-line margin 4px → 2px. */
.f29b__cell .text-line + .text-line { margin-top: 2px; }
/* popup link — 각 column 끝, 파란색 글씨 (사용자 lock 2026-05-14). */
.f29b__popup-link {
margin-top: 2px;
font-size: var(--font-caption);
font-weight: 600;
color: #1a73e8;
cursor: pointer;
text-decoration: underline;
}
.f29b__popup-link::before { content: "▶ "; font-size: 0.85em; }
/* AS-IS/TO-BE 좌우 + arrow (PROMOTED 구조) */
.f29b__transform {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 4px;
align-items: start;
}
.f29b__transform__list {
/* .text-line 공통 contract 그대로. wrapper 만 grid 안의 cell 역할 */
}
.f29b__transform__arrow {
align-self: center;
width: 28px;
flex-shrink: 0;
}
.f29b__transform__arrow img {
width: 100%;
height: auto;
display: block;
}
.f29b__footnote {
font-size: var(--font-caption); /* 10px */
line-height: var(--lh-caption);
color: #333;
padding-left: 14px;
margin-top: 1px;
white-space: pre-wrap;
}
.f29b__aster-small { font-size: 9px; }
</style>
<div class="f29b" data-frame-id="1171281210" data-template-id="process_product_two_way">
<div class="f29b__title">2. DX 추진의 실태</div>
<div class="f29b__banners">
<div class="f29b__banner f29b__banner--left">
<div class="f29b__banner-text">정책 및 발주 체계</div>
</div>
<div class="f29b__banner f29b__banner--right">
<div class="f29b__banner-text">조직 및 수행 역량</div>
</div>
</div>
<div class="f29b__body">
<div class="f29b__grid">
<div class="f29b__cell f29b__cell--left" data-frame-slot-id="process_column">
<div class="f29b__section-title">실질적 기술 경쟁을 저해하는 정책 집행</div>
<div class="text-line text-line--bullet">모든 설계사가 수행 능력을 갖추었다는 전제하에 정책 시행</div><div class="text-line text-line--bullet">수행 능력이 없는 업체 선정 후 성과품의 수준을 낮추어 시행</div>
</div>
<div class="f29b__cell f29b__cell--right" data-frame-slot-id="product_column">
<div class="f29b__section-title">공학적 개념 정립 부재</div>
<div class="text-line text-line--bullet">DX와 BIM의 차이점을 명확히 구분하지 못하고 접근 방식과 기술적 도구 사이의 혼란만 가중</div><div class="text-line text-line--bullet">단순히 기술적 도구의 사용에 초점을 맞추느라 3D모델 제작 S/W에 과도하게 의존</div>
</div>
<div class="f29b__cell f29b__cell--left" data-frame-slot-id="process_column">
<div class="f29b__section-title">적용 효과가 있는 사례도 없이 방침부터 도입</div>
<div class="text-line text-line--bullet">DX/BIM 적용에 따른 실무적 이득이 있다고 판단된 사례 부족</div><div class="text-line text-line--bullet">BIM 지침/방침 등을 시행 경험과 효과 검증도 없이 남발</div>
</div>
<div class="f29b__cell f29b__cell--right" data-frame-slot-id="product_column">
<div class="f29b__section-title">‘본업 기술력 확보’ 우선의 개념 부재</div>
<div class="text-line text-line--bullet">고도의 전문지식과 현장 경험이 축적된 Manual의 중요성과 필요성에 대한 이해 부족</div><div class="text-line text-line--bullet">국가·발주처의 지침·방침에만 의존한 업체의 기술력</div>
</div>
<div class="f29b__cell f29b__cell--left" data-frame-slot-id="process_column">
<div class="f29b__section-title">엔지니어링 S/W에 대한 개념 부재</div>
<div class="text-line text-line--bullet">다양한 엔지니어링 S/W의 특성에 대한 깊은 이해 없이 범용 S/W 선택</div><div class="text-line text-line--bullet">대형 Global S/W 회사에 과도한 의존과 이에 예속되는 방침 남발로 전용 S/W 소멸</div>
</div>
<div class="f29b__cell f29b__cell--right" data-frame-slot-id="product_column">
<div class="f29b__section-title">DX/BIM의 근본 취지와 목표의 이해 부족</div>
<div class="text-line text-line--bullet">DX에 의한 과정의 혁신과 결과물의 변화에 대한 고민 부재</div><div class="text-line text-line--bullet">기술자가 직접 3D모델을 만들고 수정하며 설계를 수행하지 않고, 별도로 외주 처리하여 본질 회피</div>
</div>
<div class="f29b__cell f29b__cell--left" data-frame-slot-id="process_column">
<div class="f29b__section-title">기술투자(R&D) 없는 성과 창출 기대</div>
<div class="text-line text-line--bullet">단순 BIM S/W만 구입하면 될 것이라는 안일한 생각</div><div class="text-line text-line--bullet">실질적 기술 개발 투자 노력 없이 남들이 하는 대로 하면 된다는 착각</div>
</div>
<div class="f29b__cell f29b__cell--right" data-frame-slot-id="product_column">
<div class="f29b__section-title">과거의 타성에 머무르고 있는 기술자 집단</div>
<div class="text-line text-line--bullet">설계/감리/시공 임직원들의 Digital 무지와 전략적 무지</div><div class="text-line text-line--bullet">교육과 학습을 통한 인재 양성보다 당장 실무 활용이 가능한 타사 인력 빼오기에 집중</div>
</div>
</div>
<div class="f29b__popups" style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px 10px; padding: 0 8px;">
<div>
<div class="f29b__popup-link">발주처 반응</div>
</div>
<div>
<div class="f29b__popup-link">설계·시공업계 반응</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="slide-footer">
<div class="slide-footer-text">검증 없는 정책의 일방적 추진과 조직의 회피, 이해 부족은 DX 지연을 반복시키고 있다.</div>
</div>
</div>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

@@ -1,89 +0,0 @@
---
title: DX 지연 요인
sidebar:
order: 03
---
## 1. DX에 대한 인식
- **기술 및 소프트웨어 이해도**
- "무슨 말인지 잘 모르겠다, 어디까지 어떻게 해야 하는지 모르겠다"
- 기본지침, 시행지침 등 새롭게 알아야 할 게 너무 많다.
- 3D 모델과 별 차이점을 모르겠다. S/W마다 사용법이 다르다.
- 필요한 것은 쉽고 간단한 건데, 왜 이렇게 복잡하게 만들까?
- **효과와 효율성**
- "2D 설계 후 전환설계를 수행하는데 효과는 모르겠고, 효율은 낮다"
- 성과품 작성은 기존과 같게 하고, 추가 업무만 발생해 효율이 낮다.
- BIM으로 인해 가중되는 업무 대비 효과가 거의 없어 보인다.
- 결과적으로 큰 차이를 못 느끼겠고, 이런 노력이 정말 가치 있는 일이야?
- **인력 및 교육**
- "수행 인력이 부족하고, 기존 직원들은 어떻게 교육해야 하나"
- 교육시간 손실로 일손이 더 필요해지고, 적응하는데도 시간이 걸린다.
- 신입사원은 없고 BIM 수행을 할 수 있는 기술자가 부족하다.
- 여러 회사의 S/W별 사용법이 달라 새로운 S/W에 적용에 시간이 필요하다.
- **경제적 부담**
- "S/W 구독료만 크게 발생되고, 비용 보전은 안 된다"
- 사용해야 할 S/W의 종류가 너무 많고 복잡한데 모두 필요한가?
- 모든 Project에 적용되는 것도 아닌데, 다수의 S/W 구독료를 내야 한다.
- 디지털전환 하기 위해 이 비용을 쓰는게 정말 경제적인 이득이 있는 거야?
- **실무 및 적용성**
- "실무적 사용에 의한 효율성 증진보다는 홍보, PQ용으로 사용한다"
- 구체적 적용에 의한 비용, 시간, 품질 등의 효과 사례가 없다.
- 지형, 선형, 도로, 교량 Model을 만드는 S/W가 모두 달라 적용이 어렵다.
- 속성정보를 반영하지 않은 형상 위주의 3D 모델 제작에만 초점이 맞춰져 있다.
<br/>
---
<br/>
## 2. DX 추진의 실태
<br/>
### 2.1 정책 및 발주 체계
- **실질적 기술 경쟁을 저해하는 정책 집행**
- 모든 설계사가 수행 능력을 갖추었다는 전제하에 정책 시행
- 수행 능력이 없는 업체 선정 후 성과품의 수준을 낮추어 시행
<br/>
- **적용 효과가 있는 사례도 없이 방침부터 도입**
- DX/BIM 적용에 따른 실무적 이득이 있다고 판단된 사례 부족
- BIM 지침/방침 등을 시행 경험과 효과 검증도 없이 남발
<br/>
- **엔지니어링 S/W에 대한 개념 부재**
- 다양한 엔지니어링 S/W의 특성에 대한 깊은 이해 없이 범용 S/W 선택
- 대형 Global S/W 회사에 과도한 의존과 이에 예속되는 방침 남발로 전용 S/W 소멸
<br/>
- **기술투자(R&D) 없는 성과 창출 기대**
- 단순 BIM S/W만 구입하면 될 것이라는 안일한 생각
- 실질적 기술 개발 투자 노력 없이 남들이 하는 대로 하면 된다는 착각
<br/>
<details>
<summary style={{cursor: 'pointer', fontWeight: 'bold', color: '#555'}}>발주처 반응</summary>
<img src="/assets/images/발주처반응.png" />
</details>
### 2.2 조직 및 수행 역량
- **공학적 개념 정립 부재**
- DX와 BIM의 차이점을 명확히 구분하지 못하고 접근 방식과 기술적 도구 사이의 혼란만 가중
- 단순히 기술적 도구의 사용에 초점을 맞추느라 3D모델 제작 S/W에 과도하게 의존
<br/>
- **‘본업 기술력 확보’ 우선의 개념 부재**
- 고도의 전문지식과 현장 경험이 축적된 Manual의 중요성과 필요성에 대한 이해 부족
- 국가·발주처의 지침·방침에만 의존한 업체의 기술력
<br/>
- **DX/BIM의 근본 취지와 목표의 이해 부족**
- DX에 의한 과정의 혁신과 결과물의 변화에 대한 고민 부재
- 기술자가 직접 3D모델을 만들고 수정하며 설계를 수행하지 않고, 별도로 외주 처리하여 본질 회피
<br/>
- **과거의 타성에 머무르고 있는 기술자 집단**
- 설계/감리/시공 임직원들의 Digital 무지와 전략적 무지
- 교육과 학습을 통한 인재 양성보다 당장 실무 활용이 가능한 타사 인력 빼오기에 집중
<br/>
<details>
<summary style={{cursor: 'pointer', fontWeight: 'bold', color: '#555'}}>설계·시공업계 반응</summary>
<img src="/assets/images/설계시공업계반응.png" />
</details>
<br/>
---
:::note[핵심 요약]
* 검증 없는 정책의 일방적 추진과 조직의 회피, 이해 부족은 DX 지연을 반복시키고 있다.
:::
@@ -1,21 +0,0 @@
{
"step_num": 1,
"step_name": "mdx_upload",
"step_status": "done",
"pipeline_path_connected": true,
"input": [
"samples\\mdx\\04. DX 지연 요인.mdx"
],
"output": [
"step01_mdx_upload.json",
"step01_mdx_source.md"
],
"note": "MDX 원본 그대로 step01_mdx_source.md 에 복사.",
"data": {
"mdx_path": "samples\\mdx\\04. DX 지연 요인.mdx",
"run_id": "mdx04_b3_v12",
"run_dir": "d:\\ad-hoc\\kei\\design_agent\\data\\runs\\mdx04_b3_v12\\phase_z2",
"mdx_source_size_bytes": 4710,
"mdx_source_lines": 89
}
}
@@ -1,51 +0,0 @@
{
"step_num": 2,
"step_name": "normalized",
"step_status": "partial",
"pipeline_path_connected": true,
"input": [
"step01_mdx_source.md"
],
"output": [
"step02_normalized.json"
],
"note": "parse_mdx 결과: title / sections / footer 분리 + raw_content 보존. heading tree 미생성, orphan / details 감지 미완 (Step 2 ⚠ partial — 별 axis). orphans / details 필드는 schema lock — 빈 배열이라도 'detection 미수행' marker. stage0_adapter_diagnostics = IMP-02 chained adapter trace (default OFF canary). stage0_normalized_assets = IMP-03 Step 3 slide-level handoff (popups/images/tables list).",
"data": {
"slide_title": "DX 지연 요인",
"slide_footer": "검증 없는 정책의 일방적 추진과 조직의 회피, 이해 부족은 DX 지연을 반복시키고 있다.",
"sections_count": 2,
"sections": [
{
"section_id": "04-1",
"section_num": 1,
"title": "1. DX에 대한 인식",
"raw_content_length": 934,
"raw_content": "- **기술 및 소프트웨어 이해도**\n - \"무슨 말인지 잘 모르겠다, 어디까지 어떻게 해야 하는지 모르겠다\"\n - 기본지침, 시행지침 등 새롭게 알아야 할 게 너무 많다.\n - 3D 모델과 별 차이점을 모르겠다. S/W마다 사용법이 다르다.\n - 필요한 것은 쉽고 간단한 건데, 왜 이렇게 복잡하게 만들까?\n- **효과와 효율성**\n - \"2D 설계 후 전환설계를 수행하는데 효과는 모르겠고, 효율은 낮다\"\n - 성과품 작성은 기존과 같게 하고, 추가 업무만 발생해 효율이 낮다.\n - BIM으로 인해 가중되는 업무 대비 효과가 거의 없어 보인다.\n - 결과적으로 큰 차이를 못 느끼겠고, 이런 노력이 정말 가치 있는 일이야?\n- **인력 및 교육**\n - \"수행 인력이 부족하고, 기존 직원들은 어떻게 교육해야 하나\"\n - 교육시간 손실로 일손이 더 필요해지고, 적응하는데도 시간이 걸린다.\n - 신입사원은 없고 BIM 수행을 할 수 있는 기술자가 부족하다.\n - 여러 회사의 S/W별 사용법이 달라 새로운 S/W에 적용에 시간이 필요하다.\n- **경제적 부담**\n - \"S/W 구독료만 크게 발생되고, 비용 보전은 안 된다\"\n - 사용해야 할 S/W의 종류가 너무 많고 복잡한데 모두 필요한가?\n - 모든 Project에 적용되는 것도 아닌데, 다수의 S/W 구독료를 내야 한다.\n - 디지털전환 하기 위해 이 비용을 쓰는게 정말 경제적인 이득이 있는 거야?\n- **실무 및 적용성**\n - \"실무적 사용에 의한 효율성 증진보다는 홍보, PQ용으로 사용한다\"\n - 구체적 적용에 의한 비용, 시간, 품질 등의 효과 사례가 없다.\n - 지형, 선형, 도로, 교량 Model을 만드는 S/W가 모두 달라 적용이 어렵다.\n - 속성정보를 반영하지 않은 형상 위주의 3D 모델 제작에만 초점이 맞춰져 있다.\n<br/>\n---\n<br/>"
},
{
"section_id": "04-2",
"section_num": 2,
"title": "2. DX 추진의 실태",
"raw_content_length": 1349,
"raw_content": "<br/>\n\n### 2.1 정책 및 발주 체계\n- **실질적 기술 경쟁을 저해하는 정책 집행** \n - 모든 설계사가 수행 능력을 갖추었다는 전제하에 정책 시행\n - 수행 능력이 없는 업체 선정 후 성과품의 수준을 낮추어 시행 \n<br/>\n- **적용 효과가 있는 사례도 없이 방침부터 도입** \n - DX/BIM 적용에 따른 실무적 이득이 있다고 판단된 사례 부족\n - BIM 지침/방침 등을 시행 경험과 효과 검증도 없이 남발\n<br/>\n- **엔지니어링 S/W에 대한 개념 부재** \n - 다양한 엔지니어링 S/W의 특성에 대한 깊은 이해 없이 범용 S/W 선택\n - 대형 Global S/W 회사에 과도한 의존과 이에 예속되는 방침 남발로 전용 S/W 소멸\n<br/>\n- **기술투자(R&D) 없는 성과 창출 기대** \n - 단순 BIM S/W만 구입하면 될 것이라는 안일한 생각\n - 실질적 기술 개발 투자 노력 없이 남들이 하는 대로 하면 된다는 착각\n<br/>\n<details>\n <summary style={{cursor: 'pointer', fontWeight: 'bold', color: '#555'}}>발주처 반응</summary>\n <img src=\"/assets/images/발주처반응.png\" />\n</details>\n\n### 2.2 조직 및 수행 역량\n- **공학적 개념 정립 부재** \n - DX와 BIM의 차이점을 명확히 구분하지 못하고 접근 방식과 기술적 도구 사이의 혼란만 가중\n - 단순히 기술적 도구의 사용에 초점을 맞추느라 3D모델 제작 S/W에 과도하게 의존\n<br/>\n- **‘본업 기술력 확보’ 우선의 개념 부재** \n - 고도의 전문지식과 현장 경험이 축적된 Manual의 중요성과 필요성에 대한 이해 부족\n - 국가·발주처의 지침·방침에만 의존한 업체의 기술력 \n<br/>\n- **DX/BIM의 근본 취지와 목표의 이해 부족** \n - DX에 의한 과정의 혁신과 결과물의 변화에 대한 고민 부재\n - 기술자가 직접 3D모델을 만들고 수정하며 설계를 수행하지 않고, 별도로 외주 처리하여 본질 회피\n<br/>\n- **과거의 타성에 머무르고 있는 기술자 집단** \n - 설계/감리/시공 임직원들의 Digital 무지와 전략적 무지\n - 교육과 학습을 통한 인재 양성보다 당장 실무 활용이 가능한 타사 인력 빼오기에 집중\n<br/>\n<details>\n <summary style={{cursor: 'pointer', fontWeight: 'bold', color: '#555'}}>설계·시공업계 반응</summary>\n <img src=\"/assets/images/설계시공업계반응.png\" />\n</details>\n<br/>\n---"
}
],
"orphans": [],
"details": [],
"stage0_adapter_diagnostics": {
"enabled": false,
"used": false,
"fallback_reason": null,
"id_reconstruction_log": [],
"adapter_counts": null,
"legacy_counts": {
"sections": 2
}
},
"stage0_normalized_assets": {
"popups": [],
"images": [],
"tables": []
}
}
}
@@ -1,41 +0,0 @@
{
"step_num": 5,
"step_name": "v4_evidence",
"step_status": "done",
"pipeline_path_connected": true,
"input": [
"step02_normalized.json",
"tests/matching/v4_full32_result.yaml"
],
"output": [
"step05_v4_evidence.json"
],
"note": "V4 non-reject max-6 후보 list (Step 9 application_plan input). raw 32 entry 는 tests/matching/v4_full32_result.yaml 에 영속. candidate_status='ok' = 후보 1개 이상 / 'no_non_reject_v4_candidate' = 0개 (Step 9 fallback path 입력). Step 6 plan_composition() 은 lookup_v4_match() (rank-1) 그대로 사용 — backward compat (Step 6-A axis 까지).",
"data": {
"v4_source": "tests\\matching\\v4_full32_result.yaml",
"aligned_section_ids": [
"04-1",
"04-2"
],
"evidence_per_section": [
{
"section_id": "04-1",
"v4_candidates": [],
"candidate_status": "no_non_reject_v4_candidate"
},
{
"section_id": "04-2",
"v4_candidates": [
{
"template_id": "process_product_two_way",
"frame_id": "1171281210",
"frame_number": 29,
"confidence": 0.7711,
"label": "light_edit"
}
],
"candidate_status": "ok"
}
]
}
}
@@ -1,170 +0,0 @@
{
"step_num": 6,
"step_name": "composition_plan",
"step_status": "done",
"pipeline_path_connected": true,
"input": [
"step02_normalized.json",
"step05_v4_evidence.json"
],
"output": [
"step06_composition_plan.json"
],
"note": "composition v0 count-based — sections → candidates → score → greedy select. Step 6-A (사용자 lock 2026-05-08): selected_units[i].v4_candidates 추가 (non-reject max-6 후보 list, candidates[0] = 단일 frame_* 와 일관). logic 무변 — runtime 결과 동일. Step 9 application_plan input.",
"data": {
"selected_units_count": 2,
"layout_preset_decided": "horizontal-2",
"candidates_summary": [
{
"source_section_ids": [
"04-1"
],
"merge_type": "single",
"template_id": "pre_construction_model_info_stacked",
"label": "reject",
"phase_z_status": "fallback_candidate",
"v4_rank": 1,
"selection_path": "rank_1",
"fallback_reason": null,
"score": 0.0,
"selection_state": "selected",
"auto_selectable": true,
"filter_reasons": [],
"notes": [],
"capacity_fit": {
"item_count": 5,
"source_shape": "top_bullets",
"capacity": {
"strict": null,
"min": null,
"max": null,
"truncate_at": null,
"pad_to": null
},
"fit_status": "ok",
"mismatch_reason": null
}
},
{
"source_section_ids": [
"04-2"
],
"merge_type": "single",
"template_id": "process_product_two_way",
"label": "light_edit",
"phase_z_status": "adapt_matched_zone",
"v4_rank": 2,
"selection_path": "rank_2_fallback",
"fallback_reason": "capacity_mismatch:strict_mismatch",
"score": 0.53977,
"selection_state": "selected",
"auto_selectable": true,
"filter_reasons": [],
"notes": [],
"capacity_fit": {
"item_count": 2,
"source_shape": "h3_subsections",
"capacity": {
"strict": 2,
"min": null,
"max": null,
"truncate_at": null,
"pad_to": null
},
"fit_status": "ok",
"mismatch_reason": null
}
}
],
"candidates_total": 2,
"candidates_viable_auto": 2,
"selected_units": [
{
"source_section_ids": [
"04-1"
],
"merge_type": "single",
"frame_id": "1171281180",
"frame_number": 9,
"frame_template_id": "pre_construction_model_info_stacked",
"label": "reject",
"v4_rank": 1,
"selection_path": "rank_1",
"fallback_reason": null,
"score": 0.0,
"phase_z_status": "fallback_candidate",
"rationale": {
"capacity_fit": {
"item_count": 5,
"source_shape": "top_bullets",
"capacity": {
"strict": null,
"min": null,
"max": null,
"truncate_at": null,
"pad_to": null
},
"fit_status": "ok",
"mismatch_reason": null
},
"frame_compat": 0.0,
"confidence": 0.7223,
"label": "reject",
"label_weight": 0.0,
"merge_type": "single",
"hierarchy_coherence": null,
"density_score": null
},
"notes": [],
"v4_candidates": []
},
{
"source_section_ids": [
"04-2"
],
"merge_type": "single",
"frame_id": "1171281210",
"frame_number": 29,
"frame_template_id": "process_product_two_way",
"label": "light_edit",
"v4_rank": 2,
"selection_path": "rank_2_fallback",
"fallback_reason": "capacity_mismatch:strict_mismatch",
"score": 0.53977,
"phase_z_status": "adapt_matched_zone",
"rationale": {
"capacity_fit": {
"item_count": 2,
"source_shape": "h3_subsections",
"capacity": {
"strict": 2,
"min": null,
"max": null,
"truncate_at": null,
"pad_to": null
},
"fit_status": "ok",
"mismatch_reason": null
},
"frame_compat": 0.5398,
"confidence": 0.7711,
"label": "light_edit",
"label_weight": 0.7,
"merge_type": "single",
"hierarchy_coherence": null,
"density_score": null
},
"notes": [],
"v4_candidates": [
{
"template_id": "process_product_two_way",
"frame_id": "1171281210",
"frame_number": 29,
"confidence": 0.7711,
"label": "light_edit"
}
]
}
]
}
}
@@ -1,81 +0,0 @@
{
"step_num": 7,
"step_name": "layout",
"step_status": "partial",
"pipeline_path_connected": true,
"input": [
"step06_composition_plan.json"
],
"output": [
"step07_layout.json",
"step07_selected_layout.html"
],
"note": "count-based v0 — 들여쓰기 / 정렬 미세 layout 미구현 (Step 7 ⚠ partial). Step 7-conn (사용자 lock 2026-05-08): unit_count → layout_candidates list (select_layout_candidates) artifact 기록. default 결정 자체는 무변 — candidates[0] 가 default (default_selection: true), 나머지는 alternative. Step 9 application_plan input.",
"data": {
"layout_preset": "horizontal-2",
"layout_css": {
"areas": "\"top\" \"bottom\"",
"cols": "1fr",
"rows": "211px 360px",
"heights_px": [
211,
360
],
"ratios": [
0.361,
0.615
],
"computation": "min_height_first + content_weight_distribution",
"dynamic_rows": true,
"raw_zone_layout": {
"computation": "min_height_first + content_weight_distribution",
"slide_body_height": 585,
"gap": 14,
"available_after_gap": 571,
"min_heights_px": [
200,
345
],
"min_scaled": false,
"total_min_height": 545,
"remaining_after_min": 26,
"content_weights": [
{
"position": "top",
"template_id": "pre_construction_model_info_stacked",
"score": 6.168
},
{
"position": "bottom",
"template_id": "process_product_two_way",
"score": 8.486
}
],
"weight_shares": [
0.421,
0.579
],
"extras_px": [
11,
15
],
"heights_px": [
211,
360
],
"ratios": [
0.361,
0.615
]
}
},
"zones_count": 2,
"unit_count": 2,
"layout_candidates": [
"horizontal-2",
"vertical-2"
],
"layout_override_applied": false,
"auto_layout_preset": "horizontal-2"
}
}
@@ -1,104 +0,0 @@
{
"step_num": 8,
"step_name": "zone_region_ratios",
"step_status": "partial",
"pipeline_path_connected": true,
"input": [
"step07_layout.json",
"step10_frame_contract.json"
],
"output": [
"step08_zone_region_ratios.json",
"step08_zone_content_placement.html"
],
"note": "계획값 only. 실측값은 Step 14 (visual_check). zone-level 만 dynamic, region-level (sub_zone 안 sections) 은 균등 분배 (1/1/1). F29 처럼 비균등 콘텐츠 (transform_table + plain) 는 첫 cell 부족 가능 — Step 8 ⚠ partial. Step 8-conn (사용자 lock 2026-05-08): per_zone_plan[i] 에 region_layout_candidates + display_strategy_candidates 추가 (passive 기록). placeholder signals = text-only / region_count=1 (Step 3/4 부재 종속). 실제 content_object 신호 활성화는 별 axis. Step 9 application_plan input.",
"data": {
"zone_heights_px_planned": [
211,
360
],
"zone_ratios_planned": [
0.361,
0.615
],
"per_zone_plan": [
{
"position": "top",
"zone_height_px_planned": 211,
"zone_ratio_planned": 0.361,
"min_height_px": 200,
"frame_cardinality_strict": null,
"sub_zones_planned": [
{
"id": "pill_dynamic",
"role": "main_text",
"accepts": [
"text_block"
],
"cardinality_strict": null,
"partial_target_path": ".f9b__pill-rows .f9b__pill:nth-child(N)"
}
],
"child_distribution_note": "현재 child zone (sub_zones 안 sections) 균등 분배 — Step 8 region-level ratio ⚠ partial.",
"region_layout_candidates": [
"region-single"
],
"display_strategy_candidates": [
"inline_full",
"inline_preview_with_details",
"details_only"
]
},
{
"position": "bottom",
"zone_height_px_planned": 360,
"zone_ratio_planned": 0.615,
"min_height_px": 345,
"frame_cardinality_strict": 2,
"sub_zones_planned": [
{
"id": "process_column",
"role": "main_text",
"accepts": [
"text_block",
"transform_table"
],
"cardinality_strict": null,
"partial_target_path": ".f29b__grid .f29b__cell--left"
},
{
"id": "product_column",
"role": "main_text",
"accepts": [
"text_block"
],
"cardinality_strict": null,
"partial_target_path": ".f29b__grid .f29b__cell--right"
}
],
"child_distribution_note": "현재 child zone (sub_zones 안 sections) 균등 분배 — Step 8 region-level ratio ⚠ partial.",
"region_layout_candidates": [
"region-single"
],
"display_strategy_candidates": [
"inline_full",
"inline_preview_with_details",
"details_only"
]
}
],
"step8_conn_placeholder_signals": {
"region_count": 1,
"content_type": "text_block",
"flow_type": null,
"role_pattern": null,
"ratio_asymmetric": false,
"has_visual_element": false,
"details_presence": false,
"large_table": false,
"long_text": false,
"fits_in_region": null,
"_note": "Step 3/4 부재 종속 placeholder — 모든 zone 을 text-only / region_count=1 로 가정. 실제 content_object 신호 활성화는 별 axis."
}
}
}
@@ -1,893 +0,0 @@
{
"step_num": 9,
"step_name": "application_plan",
"step_status": "partial",
"pipeline_path_connected": true,
"input": [
"step05_v4_evidence.json",
"step06_composition_plan.json",
"step07_layout.json",
"step08_zone_region_ratios.json"
],
"output": [
"step09_application_plan.json",
"step09_application_plan.html"
],
"note": "Step 9 v0 passive application_plan trace (사용자 lock 2026-05-08). V4 후보 + layout / region / display 후보 + V4 label → application_mode 변환을 side-by-side 로 기록. v0 invariant 5 가지 (status.md §4) 만족. Step 6 의 default 결정 그대로 (current_default_candidate). auto decision 은 Step 9 v1 (별 axis). region/display 후보는 Step 8-conn 의 placeholder signal 종속 (Step 3/4 부재). Step 7-A axis (2026-05-08): frame_overrides_applied 가 사용자 LayoutPanel/FramePanel 선택값을 강제 적용한 trace.",
"data": {
"units": [
{
"unit_id": "04-1",
"layout_preset": "horizontal-2",
"layout_candidates": [
"horizontal-2",
"vertical-2"
],
"region_layout_candidates": [
"region-single"
],
"display_strategy_candidates": [
"inline_full",
"inline_preview_with_details",
"details_only"
],
"candidate_status": "no_non_reject_v4_candidate",
"application_status": "no_v4_candidate",
"current_default_candidate": null,
"selected_v4_rank": 1,
"selection_path": "rank_1",
"fallback_used": false,
"fallback_reason": null,
"candidate_evidence": [
{
"rank": 1,
"template_id": "pre_construction_model_info_stacked",
"frame_id": "1171281180",
"frame_number": 9,
"confidence": 0.7223,
"label": "reject",
"v4_label": "reject",
"phase_z_status": "fallback_candidate",
"catalog_registered": true,
"filtered_for_direct_execution": false,
"route_hint": "design_reference_only",
"decision": "selected",
"reason": "primary_selected",
"capacity_fit": {
"item_count": 5,
"source_shape": "top_bullets",
"capacity": {
"strict": null,
"min": null,
"max": null,
"truncate_at": null,
"pad_to": null
},
"fit_status": "ok",
"mismatch_reason": null
}
}
],
"fallback_chain": [
{
"rank": 1,
"template_id": "pre_construction_model_info_stacked",
"frame_id": "1171281180",
"frame_number": 9,
"confidence": 0.7223,
"label": "reject",
"v4_label": "reject",
"phase_z_status": "fallback_candidate",
"catalog_registered": true,
"filtered_for_direct_execution": false,
"route_hint": "design_reference_only",
"decision": "selected",
"reason": "primary_selected",
"capacity_fit": {
"item_count": 5,
"source_shape": "top_bullets",
"capacity": {
"strict": null,
"min": null,
"max": null,
"truncate_at": null,
"pad_to": null
},
"fit_status": "ok",
"mismatch_reason": null
}
}
],
"v4_candidates": [],
"v4_all_judgments": [
{
"template_id": "pre_construction_model_info_stacked",
"frame_id": "1171281180",
"frame_number": 9,
"v4_rank": 1,
"confidence": 0.7223,
"label": "reject",
"catalog_registered": true
},
{
"template_id": "field_effectiveness_five_elements",
"frame_id": "1171281181",
"frame_number": 10,
"v4_rank": 2,
"confidence": 0.6586,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "policy_achievement_five_goals",
"frame_id": "1171281213",
"frame_number": 32,
"v4_rank": 3,
"confidence": 0.6514,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "compensation_complaint_side_card",
"frame_id": "1171281176",
"frame_number": 5,
"v4_rank": 4,
"confidence": 0.6058,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "solution_engn_split_diagram",
"frame_id": "1171281201",
"frame_number": 21,
"v4_rank": 5,
"confidence": 0.4773,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "model_specialized_engn_sw",
"frame_id": "1171281202",
"frame_number": 22,
"v4_rank": 6,
"confidence": 0.4624,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "commercial_sw_four_categories",
"frame_id": "1171281205",
"frame_number": 25,
"v4_rank": 7,
"confidence": 0.4293,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "construction_bim_three_usage",
"frame_id": "1171281182",
"frame_number": 11,
"v4_rank": 8,
"confidence": 0.4234,
"label": "reject",
"catalog_registered": true
},
{
"template_id": "sw_dependency_four_problems",
"frame_id": "1171281206",
"frame_number": 26,
"v4_rank": 9,
"confidence": 0.3986,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "engn_sw_development_domain_knowledge",
"frame_id": "1171281173",
"frame_number": 2,
"v4_rank": 10,
"confidence": 0.3942,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "sw_development_cycle_six_nodes",
"frame_id": "1171281172",
"frame_number": 1,
"v4_rank": 11,
"confidence": 0.3899,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "policy_goals_plus_execution_requirements",
"frame_id": "1171281192",
"frame_number": 15,
"v4_rank": 12,
"confidence": 0.3833,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "bim_current_problems_paired",
"frame_id": "1171281194",
"frame_number": 17,
"v4_rank": 13,
"confidence": 0.3773,
"label": "reject",
"catalog_registered": true
},
{
"template_id": "overseas_bim_numbered_list",
"frame_id": "1171281174",
"frame_number": 3,
"v4_rank": 14,
"confidence": 0.3748,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "three_parallel_requirements",
"frame_id": "1171281190",
"frame_number": 13,
"v4_rank": 15,
"confidence": 0.3713,
"label": "reject",
"catalog_registered": true
},
{
"template_id": "bim_issues_quadrant_four",
"frame_id": "1171281193",
"frame_number": 16,
"v4_rank": 16,
"confidence": 0.3699,
"label": "reject",
"catalog_registered": true
},
{
"template_id": "dx_sw_necessity_three_perspectives",
"frame_id": "1171281198",
"frame_number": 20,
"v4_rank": 17,
"confidence": 0.3158,
"label": "reject",
"catalog_registered": true
},
{
"template_id": "domestic_bim_actor_relations",
"frame_id": "1171281175",
"frame_number": 4,
"v4_rank": 18,
"confidence": 0.2897,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "bigroom_system_components",
"frame_id": "1171281178",
"frame_number": 7,
"v4_rank": 19,
"confidence": 0.2742,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "info_management_what_how_when",
"frame_id": "1171281179",
"frame_number": 8,
"v4_rank": 20,
"confidence": 0.2604,
"label": "reject",
"catalog_registered": true
},
{
"template_id": "process_product_two_way",
"frame_id": "1171281210",
"frame_number": 29,
"v4_rank": 21,
"confidence": 0.2313,
"label": "reject",
"catalog_registered": true
},
{
"template_id": "compensation_complaint_map",
"frame_id": "1171281177",
"frame_number": 6,
"v4_rank": 22,
"confidence": 0.201,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "sw_reality_three_emphasis",
"frame_id": "1171281209",
"frame_number": 28,
"v4_rank": 23,
"confidence": 0.201,
"label": "reject",
"catalog_registered": true
},
{
"template_id": "bim_dx_comparison_table",
"frame_id": "1171281195",
"frame_number": 18,
"v4_rank": 24,
"confidence": 0.1798,
"label": "reject",
"catalog_registered": true
},
{
"template_id": "three_persona_benefits",
"frame_id": "1171281191",
"frame_number": 14,
"v4_rank": 25,
"confidence": 0.1787,
"label": "reject",
"catalog_registered": true
},
{
"template_id": "construction_goals_three_circle_intersection",
"frame_id": "1171281189",
"frame_number": 12,
"v4_rank": 26,
"confidence": 0.1706,
"label": "reject",
"catalog_registered": true
},
{
"template_id": "bim_adoption_central_split",
"frame_id": "1171281208",
"frame_number": 27,
"v4_rank": 27,
"confidence": 0.1399,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "engn_sw_three_types",
"frame_id": "1171281204",
"frame_number": 24,
"v4_rank": 28,
"confidence": 0.0929,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "design_method_distortion_three_col",
"frame_id": "1171281197",
"frame_number": 19,
"v4_rank": 29,
"confidence": 0.0584,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "app_sw_package_vs_solution",
"frame_id": "1171281203",
"frame_number": 23,
"v4_rank": 30,
"confidence": 0.0052,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "industry_current_status_three_col",
"frame_id": "1171281211",
"frame_number": 30,
"v4_rank": 31,
"confidence": 0.0044,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "industry_characteristics_three_col",
"frame_id": "1171281212",
"frame_number": 31,
"v4_rank": 32,
"confidence": 0.0029,
"label": "reject",
"catalog_registered": false
}
],
"application_candidates": [],
"position": null,
"assignment_source": null,
"section_assignment_override": false,
"replaced_auto_unit": null,
"skipped_collided_auto_units": [],
"skipped_reason": null
},
{
"unit_id": "04-2",
"layout_preset": "horizontal-2",
"layout_candidates": [
"horizontal-2",
"vertical-2"
],
"region_layout_candidates": [
"region-single"
],
"display_strategy_candidates": [
"inline_full",
"inline_preview_with_details",
"details_only"
],
"candidate_status": "ok",
"application_status": "ok",
"current_default_candidate": "process_product_two_way",
"selected_v4_rank": 2,
"selection_path": "rank_2_fallback",
"fallback_used": true,
"fallback_reason": "capacity_mismatch:strict_mismatch",
"candidate_evidence": [
{
"rank": 1,
"template_id": "bim_dx_comparison_table",
"frame_id": "1171281195",
"frame_number": 18,
"confidence": 0.8152,
"label": "reject",
"v4_label": "reject",
"phase_z_status": "fallback_candidate",
"catalog_registered": true,
"filtered_for_direct_execution": false,
"route_hint": "design_reference_only",
"decision": "skipped",
"reason": "capacity_mismatch:strict_mismatch",
"capacity_fit": {
"item_count": 8,
"source_shape": "top_bullets",
"capacity": {
"strict": 2,
"min": null,
"max": null,
"truncate_at": null,
"pad_to": null
},
"fit_status": "strict_mismatch",
"mismatch_reason": "strict cardinality 2, content has 8 items. mapper 가 FitError 를 raise 할 것."
}
},
{
"rank": 2,
"template_id": "process_product_two_way",
"frame_id": "1171281210",
"frame_number": 29,
"confidence": 0.7711,
"label": "light_edit",
"v4_label": "light_edit",
"phase_z_status": "adapt_matched_zone",
"catalog_registered": true,
"filtered_for_direct_execution": false,
"route_hint": "deterministic_minor_adjustment",
"decision": "selected",
"reason": "fallback_selected",
"capacity_fit": {
"item_count": 2,
"source_shape": "h3_subsections",
"capacity": {
"strict": 2,
"min": null,
"max": null,
"truncate_at": null,
"pad_to": null
},
"fit_status": "ok",
"mismatch_reason": null
}
}
],
"fallback_chain": [
{
"rank": 1,
"template_id": "bim_dx_comparison_table",
"frame_id": "1171281195",
"frame_number": 18,
"confidence": 0.8152,
"label": "reject",
"v4_label": "reject",
"phase_z_status": "fallback_candidate",
"catalog_registered": true,
"filtered_for_direct_execution": false,
"route_hint": "design_reference_only",
"decision": "skipped",
"reason": "capacity_mismatch:strict_mismatch",
"capacity_fit": {
"item_count": 8,
"source_shape": "top_bullets",
"capacity": {
"strict": 2,
"min": null,
"max": null,
"truncate_at": null,
"pad_to": null
},
"fit_status": "strict_mismatch",
"mismatch_reason": "strict cardinality 2, content has 8 items. mapper 가 FitError 를 raise 할 것."
}
},
{
"rank": 2,
"template_id": "process_product_two_way",
"frame_id": "1171281210",
"frame_number": 29,
"confidence": 0.7711,
"label": "light_edit",
"v4_label": "light_edit",
"phase_z_status": "adapt_matched_zone",
"catalog_registered": true,
"filtered_for_direct_execution": false,
"route_hint": "deterministic_minor_adjustment",
"decision": "selected",
"reason": "fallback_selected",
"capacity_fit": {
"item_count": 2,
"source_shape": "h3_subsections",
"capacity": {
"strict": 2,
"min": null,
"max": null,
"truncate_at": null,
"pad_to": null
},
"fit_status": "ok",
"mismatch_reason": null
}
}
],
"v4_candidates": [
{
"template_id": "process_product_two_way",
"frame_id": "1171281210",
"frame_number": 29,
"v4_rank": 2,
"confidence": 0.7711,
"label": "light_edit"
}
],
"v4_all_judgments": [
{
"template_id": "bim_dx_comparison_table",
"frame_id": "1171281195",
"frame_number": 18,
"v4_rank": 1,
"confidence": 0.8152,
"label": "reject",
"catalog_registered": true
},
{
"template_id": "process_product_two_way",
"frame_id": "1171281210",
"frame_number": 29,
"v4_rank": 2,
"confidence": 0.7711,
"label": "light_edit",
"catalog_registered": true
},
{
"template_id": "app_sw_package_vs_solution",
"frame_id": "1171281203",
"frame_number": 23,
"v4_rank": 3,
"confidence": 0.6389,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "engn_sw_three_types",
"frame_id": "1171281204",
"frame_number": 24,
"v4_rank": 4,
"confidence": 0.6372,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "industry_characteristics_three_col",
"frame_id": "1171281212",
"frame_number": 31,
"v4_rank": 5,
"confidence": 0.5996,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "bim_adoption_central_split",
"frame_id": "1171281208",
"frame_number": 27,
"v4_rank": 6,
"confidence": 0.55,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "bigroom_system_components",
"frame_id": "1171281178",
"frame_number": 7,
"v4_rank": 7,
"confidence": 0.5172,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "industry_current_status_three_col",
"frame_id": "1171281211",
"frame_number": 30,
"v4_rank": 8,
"confidence": 0.4774,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "design_method_distortion_three_col",
"frame_id": "1171281197",
"frame_number": 19,
"v4_rank": 9,
"confidence": 0.4179,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "sw_dependency_four_problems",
"frame_id": "1171281206",
"frame_number": 26,
"v4_rank": 10,
"confidence": 0.4138,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "bim_current_problems_paired",
"frame_id": "1171281194",
"frame_number": 17,
"v4_rank": 11,
"confidence": 0.3559,
"label": "reject",
"catalog_registered": true
},
{
"template_id": "domestic_bim_actor_relations",
"frame_id": "1171281175",
"frame_number": 4,
"v4_rank": 12,
"confidence": 0.3457,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "pre_construction_model_info_stacked",
"frame_id": "1171281180",
"frame_number": 9,
"v4_rank": 13,
"confidence": 0.332,
"label": "reject",
"catalog_registered": true
},
{
"template_id": "model_specialized_engn_sw",
"frame_id": "1171281202",
"frame_number": 22,
"v4_rank": 14,
"confidence": 0.3275,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "commercial_sw_four_categories",
"frame_id": "1171281205",
"frame_number": 25,
"v4_rank": 15,
"confidence": 0.3074,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "policy_goals_plus_execution_requirements",
"frame_id": "1171281192",
"frame_number": 15,
"v4_rank": 16,
"confidence": 0.2847,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "construction_goals_three_circle_intersection",
"frame_id": "1171281189",
"frame_number": 12,
"v4_rank": 17,
"confidence": 0.2558,
"label": "reject",
"catalog_registered": true
},
{
"template_id": "bim_issues_quadrant_four",
"frame_id": "1171281193",
"frame_number": 16,
"v4_rank": 18,
"confidence": 0.2302,
"label": "reject",
"catalog_registered": true
},
{
"template_id": "compensation_complaint_map",
"frame_id": "1171281177",
"frame_number": 6,
"v4_rank": 19,
"confidence": 0.2077,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "dx_sw_necessity_three_perspectives",
"frame_id": "1171281198",
"frame_number": 20,
"v4_rank": 20,
"confidence": 0.1842,
"label": "reject",
"catalog_registered": true
},
{
"template_id": "three_parallel_requirements",
"frame_id": "1171281190",
"frame_number": 13,
"v4_rank": 21,
"confidence": 0.1399,
"label": "reject",
"catalog_registered": true
},
{
"template_id": "sw_reality_three_emphasis",
"frame_id": "1171281209",
"frame_number": 28,
"v4_rank": 22,
"confidence": 0.1271,
"label": "reject",
"catalog_registered": true
},
{
"template_id": "solution_engn_split_diagram",
"frame_id": "1171281201",
"frame_number": 21,
"v4_rank": 23,
"confidence": 0.1171,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "overseas_bim_numbered_list",
"frame_id": "1171281174",
"frame_number": 3,
"v4_rank": 24,
"confidence": 0.1007,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "policy_achievement_five_goals",
"frame_id": "1171281213",
"frame_number": 32,
"v4_rank": 25,
"confidence": 0.0751,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "three_persona_benefits",
"frame_id": "1171281191",
"frame_number": 14,
"v4_rank": 26,
"confidence": 0.0722,
"label": "reject",
"catalog_registered": true
},
{
"template_id": "engn_sw_development_domain_knowledge",
"frame_id": "1171281173",
"frame_number": 2,
"v4_rank": 27,
"confidence": 0.0635,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "sw_development_cycle_six_nodes",
"frame_id": "1171281172",
"frame_number": 1,
"v4_rank": 28,
"confidence": 0.0633,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "field_effectiveness_five_elements",
"frame_id": "1171281181",
"frame_number": 10,
"v4_rank": 29,
"confidence": 0.0618,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "info_management_what_how_when",
"frame_id": "1171281179",
"frame_number": 8,
"v4_rank": 30,
"confidence": 0.0487,
"label": "reject",
"catalog_registered": true
},
{
"template_id": "compensation_complaint_side_card",
"frame_id": "1171281176",
"frame_number": 5,
"v4_rank": 31,
"confidence": 0.0457,
"label": "reject",
"catalog_registered": false
},
{
"template_id": "construction_bim_three_usage",
"frame_id": "1171281182",
"frame_number": 11,
"v4_rank": 32,
"confidence": 0.0,
"label": "reject",
"catalog_registered": true
}
],
"application_candidates": [
{
"template_id": "process_product_two_way",
"frame_id": "1171281210",
"v4_label": "light_edit",
"application_mode": "same_frame_with_adjustment",
"auto_applicable": true,
"required_changes": [],
"delegated_to": "step10_contract_check"
}
],
"position": null,
"assignment_source": null,
"section_assignment_override": false,
"replaced_auto_unit": null,
"skipped_collided_auto_units": [],
"skipped_reason": null
}
],
"candidate_status_summary": {
"units_with_no_v4_candidate": [
"04-1"
],
"units_with_fallback": [
"04-2"
]
},
"fallback_policy": {
"fallback_used_count": 1,
"fallback_selection_count": 1,
"chain_exhausted_count": 0,
"selection_paths": [
{
"section_id": "04-1",
"selection_path": "rank_1",
"selected_rank": 1,
"selected_template_id": "pre_construction_model_info_stacked",
"fallback_trigger": null
},
{
"section_id": "04-2",
"selection_path": "rank_2_fallback",
"selected_rank": 2,
"selected_template_id": "process_product_two_way",
"fallback_trigger": "capacity_mismatch:strict_mismatch"
}
],
"policy": "IMP-05: rank-1 is kept when usable; rank-2/3 may be promoted only when the earlier rank is not auto-renderable, has no catalog contract, or fails capacity precheck. calculate_fit is not used."
},
"frame_overrides_applied": [],
"frame_overrides_skipped": [],
"section_assignment_plan": null,
"section_assignment_summary": null,
"v0_lock_note": "Step 9 v0 passive (사용자 lock 2026-05-08). Step 6 default 그대로 사용 — runtime 결과 byte-동일. auto decision / scoring 은 Step 9 v1 (별 axis)."
}
}
@@ -1,72 +0,0 @@
{
"step_num": 20,
"step_name": "slide_status",
"step_status": "done",
"pipeline_path_connected": true,
"input": [
"step14_visual_check.json",
"step17_retry_trace.json",
"step19_next_action.json"
],
"output": [
"step20_slide_status.json",
"step20_final_status.html"
],
"note": "자동 파이프라인 최종 결과 보고. overall = PASS/RENDERED_WITH_VISUAL_REGRESSION/PARTIAL_COVERAGE 등.",
"data": {
"rendered": true,
"visual_check_passed": false,
"full_mdx_coverage": true,
"aligned_section_ids": [
"04-1",
"04-2"
],
"covered_section_ids": [
"04-1",
"04-2"
],
"filtered_section_ids": [],
"filtered_section_reasons": [],
"selection_path": "fallback_used",
"fallback_used": true,
"fallback_selections": [
{
"position": "bottom",
"source_section_ids": [
"04-2"
],
"template_id": "process_product_two_way",
"selected_v4_rank": 2,
"selection_path": "rank_2_fallback",
"fallback_reason": "capacity_mismatch:strict_mismatch"
}
],
"fallback_selection_count": 1,
"selection_paths": [
{
"section_id": "04-1",
"selection_path": "rank_1",
"selected_rank": 1,
"selected_template_id": "pre_construction_model_info_stacked",
"fallback_trigger": null
},
{
"section_id": "04-2",
"selection_path": "rank_2_fallback",
"selected_rank": 2,
"selected_template_id": "process_product_two_way",
"fallback_trigger": "capacity_mismatch:strict_mismatch"
}
],
"visual_fail_reasons": [
"zone--bottom: inner clipped .f29b — excess 17px vert / 0px horiz (content 377 vs container 360)",
"zone--bottom: inner clipped .f29b__body — excess 17px vert / 0px horiz (content 311 vs container 294)"
],
"adapter_needed_count": 0,
"adapter_needed_units": [],
"content_truncated_count": 0,
"content_truncated_units": [],
"overall": "RENDERED_WITH_VISUAL_REGRESSION",
"note": "자동 파이프라인 결과 보고. review/UI 개념 X. final.html 파일명 != PASS 의미. overall == PASS 는 visual OK + full coverage + adapter_needed=0 일 때만. adapter_needed_count > 0 = mapper 가 contract 와 안 맞아 자동 렌더 못 한 zone 존재. content_truncated_count > 0 = builder 가 truncate 한 zone 존재 (rendered 됐지만 일부 콘텐츠 손실)."
}
}
+40 -141
View File
@@ -1,200 +1,99 @@
/** /**
* BottomActions — Step 22 footer wire-up (IMP-56 #90 u20). * BottomActions - 하단 액션 버튼 영역
* *
* Two real endpoints replace the prior placeholder toasts: * 생성하기, 다운로드, 연동하기 버튼 컴포넌트
* • POST /api/connect (u18 / Front/vite.config.ts) — copies
* data/runs/<run_id>/phase_z2/final.html + assets/ into the cel mirror
* (`<CEL_PROJECT_ROOT>/public/slides/<slug>.html`).
* • POST /api/export (u19 / Front/vite.config.ts) — returns a standalone
* text/html body with every `url(assets/...)` ref inlined as base64
* data URLs. Response is piped into a Blob → a[download] click chain so
* the user receives `<run_id>.html` portable for file:// or any host.
*
* The prior `serializeSlidePlan` JSON-download path was a dead reference
* (the export never existed in slidePlanUtils) and is removed here — the
* "다운로드" button now means standalone HTML download via /api/export.
* Both buttons disable when no run is loaded (runMeta == null) so the
* UI cannot fire requests with an undefined run_id.
*/ */
import { useState } from "react";
import { Sparkles, Download, Link2, Loader2 } from "lucide-react"; import { Sparkles, Download, Link2, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { toast } from "sonner"; import { toast } from "sonner";
import type { SlidePlan } from "../types/designAgent"; import type { SlidePlan, UserSelection } from "../types/designAgent";
import type { RunMeta } from "../services/designAgentApi"; import { serializeSlidePlan } from "../utils/slidePlanUtils";
import { deriveUserOverridesKey } from "../utils/slidePlanUtils";
// ─── pure request builders (exported for vitest; jsdom-free) ─────────────
// The component below uses these verbatim. Each returns a {url, body} pair
// so the test surface is the *literal* HTTP payload sent to the u18 / u19
// middlewares — any future shape drift fails here before the network call.
export function buildConnectRequest(
run_id: string,
slug: string,
): { url: string; body: string } {
return {
url: "/api/connect",
body: JSON.stringify({ run_id, slug }),
};
}
export function buildExportRequest(
run_id: string,
): { url: string; body: string } {
return {
url: "/api/export",
body: JSON.stringify({ run_id }),
};
}
export function buildDownloadFilename(run_id: string): string {
return `${run_id}.html`;
}
interface BottomActionsProps { interface BottomActionsProps {
slidePlan: SlidePlan | null; slidePlan: SlidePlan | null;
runMeta: RunMeta | null; userSelection: UserSelection;
uploadedFile: File | null;
isLoading: boolean; isLoading: boolean;
onGenerate: () => void; onGenerate: () => void;
} }
export default function BottomActions({ export default function BottomActions({
slidePlan, slidePlan,
runMeta, userSelection,
uploadedFile,
isLoading, isLoading,
onGenerate, onGenerate,
}: BottomActionsProps) { }: BottomActionsProps) {
const [isConnecting, setIsConnecting] = useState(false); const handleDownload = () => {
const [isExporting, setIsExporting] = useState(false); if (!slidePlan) {
toast.error("슬라이드 플랜이 없습니다. 먼저 생성하기를 눌러주세요.");
const runReady = !!runMeta && !!slidePlan;
const handleExport = async () => {
if (!runMeta) {
toast.error("Run 산출물이 없습니다. 먼저 생성하기를 눌러주세요.");
return; return;
} }
setIsExporting(true);
try { const json = serializeSlidePlan(slidePlan, userSelection);
const exportReq = buildExportRequest(runMeta.run_id); console.log("[Download] SlidePlan JSON:", json);
const resp = await fetch(exportReq.url, {
method: "POST", // JSON 파일 다운로드
headers: { "Content-Type": "application/json" }, const blob = new Blob([json], { type: "application/json" });
body: exportReq.body, const url = URL.createObjectURL(blob);
});
if (!resp.ok) {
const text = await resp.text();
toast.error(`Export 실패 (${resp.status}): ${text.slice(0, 160)}`);
return;
}
const blob = await resp.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = objectUrl; a.href = url;
a.download = buildDownloadFilename(runMeta.run_id); a.download = `slide-plan-${Date.now()}.json`;
document.body.appendChild(a);
a.click(); a.click();
a.remove(); URL.revokeObjectURL(url);
URL.revokeObjectURL(objectUrl);
toast.success(`standalone HTML 다운로드 — ${runMeta.run_id}.html`); toast.success("SlidePlan JSON이 다운로드되었습니다.");
} catch (err) {
toast.error(`Export 네트워크 오류: ${(err as Error).message}`);
} finally {
setIsExporting(false);
}
}; };
const handleConnect = async () => { const handleConnect = () => {
if (!runMeta) { toast.info("연동하기 기능은 파이프라인 연결 후 활성화됩니다.");
toast.error("Run 산출물이 없습니다. 먼저 생성하기를 눌러주세요.");
return;
}
if (!uploadedFile) {
toast.error("MDX 파일이 없습니다 — slug 도출 불가.");
return;
}
const slug = deriveUserOverridesKey(uploadedFile.name);
setIsConnecting(true);
try {
const connectReq = buildConnectRequest(runMeta.run_id, slug);
const resp = await fetch(connectReq.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: connectReq.body,
});
const payload = (await resp.json().catch(() => ({}))) as {
success?: boolean;
assets_copied?: number;
error?: string;
};
if (!resp.ok || !payload.success) {
toast.error(
`Connect 실패 (${resp.status}): ${payload.error ?? "unknown error"}`,
);
return;
}
toast.success(
`cel 미러 연동 완료 — ${slug}.html (assets ${payload.assets_copied ?? 0}개 복사)`,
);
} catch (err) {
toast.error(`Connect 네트워크 오류: ${(err as Error).message}`);
} finally {
setIsConnecting(false);
}
}; };
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center justify-center gap-3 px-6 py-3 bg-white border-t border-slate-200">
<div className="flex items-center gap-1.5 mr-2">
<span className="w-5 h-5 rounded-full bg-blue-600 text-white text-xs flex items-center justify-center font-bold">4</span>
<span className="text-xs text-slate-500 font-medium"></span>
</div>
{/* 생성하기 */}
<Button <Button
onClick={onGenerate} onClick={onGenerate}
disabled={isLoading} disabled={isLoading}
className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest bg-slate-900 hover:bg-slate-800" className="gap-2 min-w-[120px]"
size="default" size="default"
> >
{isLoading ? ( {isLoading ? (
<> <>
<Loader2 className="w-3.5 h-3.5 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
... ...
</> </>
) : ( ) : (
<> <>
<Sparkles className="w-3.5 h-3.5" /> <Sparkles className="w-4 h-4" />
</> </>
)} )}
</Button> </Button>
{/* 다운로드 */}
<Button <Button
variant="outline" variant="outline"
onClick={handleExport} onClick={handleDownload}
disabled={!runReady || isExporting || isLoading} disabled={!slidePlan || isLoading}
className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200" className="gap-2 min-w-[120px]"
size="default" size="default"
> >
{isExporting ? ( <Download className="w-4 h-4" />
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Download className="w-3.5 h-3.5" />
)}
</Button> </Button>
{/* 연동하기 */}
<Button <Button
variant="outline" variant="outline"
onClick={handleConnect} onClick={handleConnect}
disabled={!runReady || isConnecting || isLoading} className="gap-2 min-w-[120px] text-slate-500"
className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"
size="default" size="default"
> >
{isConnecting ? ( <Link2 className="w-4 h-4" />
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Link2 className="w-3.5 h-3.5" />
)}
</Button> </Button>
</div> </div>
+44 -55
View File
@@ -9,7 +9,6 @@ import { Badge } from '@/components/ui/badge';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import type { Zone, InternalRegion, UserSelection, FrameCandidate, SlidePlan } from '../types/designAgent'; import type { Zone, InternalRegion, UserSelection, FrameCandidate, SlidePlan } from '../types/designAgent';
import { getSectionsForZone } from '../utils/slidePlanUtils'; import { getSectionsForZone } from '../utils/slidePlanUtils';
import { buildBadgeTitle } from '../services/applicationMode';
interface FramePanelProps { interface FramePanelProps {
slidePlan: SlidePlan | null; slidePlan: SlidePlan | null;
@@ -20,18 +19,19 @@ interface FramePanelProps {
onNoDesignToggle: () => void; onNoDesignToggle: () => void;
} }
// IMP-#84 u1silent-automation contract: frame selection delegates directly // ─── IMP-41 u3application_mode consequence tooltip map (issue #70) ────────
// to onFrameSelect for every V4 label (use_as_is / light_edit / restructure / // Keyed by application_mode VALUE (backend authoritative), NOT V4 label.
// reject). Prior IMP-47B u11 surfaced a window.confirm popup on reject; that // Source = src/phase_z2_pipeline.py APPLICATION_MODE_BY_V4_LABEL (:107-112)
// popup is informational UI noise per `feedback_auto_pipeline_first` and is // emitted via Step 9 unit.application_candidates[] and forwarded by
// removed. Frame identity is preserved on reject (AI 재구성 = content-only, // designAgentApi.ts (IMP-41 u2). When applicationMode is absent (legacy
// per AI 격리 contract); the popup never gated that contract. // fixtures pre-IMP-32, or candidate filtered out at Step 9) the tooltip
export function applyFrameSelection( // falls back to the raw V4 label string per Stage 2 contract.
candidate: FrameCandidate, const APPLICATION_MODE_TOOLTIP_KR: Record<string, string> = {
onFrameSelect: (frameId: string) => void, direct_insert: "코드 직접 적용",
): void { same_frame_with_adjustment: "AI 보강 필요",
onFrameSelect(candidate.id); layout_or_region_change: "AI restructure 필요",
} exclude: "render path 제외",
};
export default function FramePanel({ export default function FramePanel({
slidePlan, slidePlan,
@@ -60,11 +60,24 @@ export default function FramePanel({
return userSelection.overrides.zone_frames[targetRegion.id] || targetRegion.frame_match_strategy.frame_id; return userSelection.overrides.zone_frames[targetRegion.id] || targetRegion.frame_match_strategy.frame_id;
}, [selectedZone, selectedRegion, userSelection.overrides.zone_frames]); }, [selectedZone, selectedRegion, userSelection.overrides.zone_frames]);
// IMP-47B u11 — reject-click confirm guard. Per #76 policy: 사용자가 reject
// 카드 명시 클릭 → backend `--override-frame` 전달 + reject frame 유지 + AI 재구성.
// The window.confirm makes the AI-rebuild intent explicit (deselecting an
// already-applied reject frame does not prompt). Pure UX gate — no state
// mutation here; the parent `onFrameSelect` still owns the override apply.
const handleFrameSelect = React.useCallback( const handleFrameSelect = React.useCallback(
(candidate: FrameCandidate) => { (candidate: FrameCandidate) => {
applyFrameSelection(candidate, onFrameSelect); const isReject = candidate.label === "reject";
const alreadyApplied = currentFrameId === candidate.id;
if (isReject && !alreadyApplied) {
const ok = window.confirm(
`"${candidate.name}" 은 V4 reject 라벨입니다.\n선택 시 frame 은 유지되고 AI 가 콘텐츠를 frame 구조에 맞게 재구성합니다.\n계속하시겠습니까?`,
);
if (!ok) return;
}
onFrameSelect(candidate.id);
}, },
[onFrameSelect], [currentFrameId, onFrameSelect],
); );
if (!selectedZone) { if (!selectedZone) {
@@ -99,9 +112,6 @@ export default function FramePanel({
{candidates.length === 0 ? ( {candidates.length === 0 ? (
<div className="py-20 text-center border-2 border-dashed border-slate-50 rounded-2xl"> <div className="py-20 text-center border-2 border-dashed border-slate-50 rounded-2xl">
<p className="text-[10px] font-bold text-slate-300 uppercase">No Candidates Available</p> <p className="text-[10px] font-bold text-slate-300 uppercase">No Candidates Available</p>
<p className="mt-2 px-4 text-[10px] leading-relaxed text-slate-400">
backend candidate pool is empty for {assignedSectionIds.join(", ") || selectedZone.zone_id}.
</p>
</div> </div>
) : ( ) : (
candidates.map((candidate, index) => { candidates.map((candidate, index) => {
@@ -123,7 +133,6 @@ export default function FramePanel({
candidate.routeHint && candidate.routeHint !== "direct_render"; candidate.routeHint && candidate.routeHint !== "direct_render";
const showStatusChip = const showStatusChip =
candidate.phaseZStatus && candidate.phaseZStatus !== "auto_renderable"; candidate.phaseZStatus && candidate.phaseZStatus !== "auto_renderable";
const showCoverageChip = Boolean(candidate.coverageState);
const hasCapacityFit = const hasCapacityFit =
candidate.capacityFit && candidate.capacityFit.fit_status; candidate.capacityFit && candidate.capacityFit.fit_status;
const capacityMismatch = const capacityMismatch =
@@ -136,10 +145,6 @@ export default function FramePanel({
if (candidate.routeHint) evidenceLines.push(`route: ${candidate.routeHint}`); if (candidate.routeHint) evidenceLines.push(`route: ${candidate.routeHint}`);
if (candidate.phaseZStatus) if (candidate.phaseZStatus)
evidenceLines.push(`phase_z_status: ${candidate.phaseZStatus}`); evidenceLines.push(`phase_z_status: ${candidate.phaseZStatus}`);
if (candidate.candidateStatus)
evidenceLines.push(`candidate_status: ${candidate.candidateStatus}`);
if (candidate.coverageState)
evidenceLines.push(`coverage_state: ${candidate.coverageState}`);
if (hasCapacityFit) { if (hasCapacityFit) {
const cf = candidate.capacityFit!; const cf = candidate.capacityFit!;
const capacityLine = const capacityLine =
@@ -264,14 +269,19 @@ export default function FramePanel({
</span> </span>
)} )}
{/* V4 label badge */} {/* V4 label badge */}
{/* IMP-41 u5 — tooltip delegated to pure helper {candidate.label && (() => {
`buildBadgeTitle` (services/applicationMode.ts). // IMP-41 u3 — applicationMode-keyed Korean consequence
applicationMode is forwarded by designAgentApi.ts // tooltip with legacy fallback. applicationMode is
(u4) from Step 9 unit.application_candidates[]; // forwarded by designAgentApi.ts (u2) from Step 9
helper falls back to the raw V4 label when the // unit.application_candidates[]; undefined when the
mode is undefined or unknown. Badge color mapping // backend did not emit a mapping for this candidate.
is intentionally untouched per Stage 2 scope. */} const consequence = candidate.applicationMode
{candidate.label && ( ? APPLICATION_MODE_TOOLTIP_KR[candidate.applicationMode]
: undefined;
const badgeTitle = consequence
? `${consequence} (${candidate.applicationMode})`
: `V4 label: ${candidate.label}`;
return (
<span <span
className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${ className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${
candidate.label === "use_as_is" candidate.label === "use_as_is"
@@ -282,33 +292,12 @@ export default function FramePanel({
? "bg-amber-100 text-amber-700" ? "bg-amber-100 text-amber-700"
: "bg-red-100 text-red-700" : "bg-red-100 text-red-700"
}`} }`}
title={buildBadgeTitle(candidate.label, candidate.applicationMode)} title={badgeTitle}
> >
{candidate.label} {candidate.label}
</span> </span>
)} );
{showCoverageChip && ( })()}
<span
className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${
candidate.coverageState === "covered_native"
? "bg-emerald-50 text-emerald-700"
: candidate.coverageState === "covered_via_expand"
? "bg-cyan-50 text-cyan-700"
: candidate.coverageState === "requires_adaptation"
? "bg-amber-50 text-amber-700"
: "bg-slate-100 text-slate-600"
}`}
title={`coverage_state: ${candidate.coverageState}`}
>
{candidate.coverageState === "covered_native"
? "native"
: candidate.coverageState === "covered_via_expand"
? "expand"
: candidate.coverageState === "requires_adaptation"
? "adapt"
: "unsup"}
</span>
)}
{/* IMP-29 u3 — route hint chip (skip when direct_render = default). */} {/* IMP-29 u3 — route hint chip (skip when direct_render = default). */}
{showRouteChip && ( {showRouteChip && (
<span <span
+5 -7
View File
@@ -34,14 +34,12 @@ interface LeftMdxPanelProps {
onFileUpload: (file: File) => void; onFileUpload: (file: File) => void;
onGenerate: () => void; onGenerate: () => void;
onSectionClick: (sectionId: string) => void; onSectionClick: (sectionId: string) => void;
/** 사용자 lock 2026-05-14 — 좌측 패널에 01~05 fix 고정 list. 클릭 시 callback. */ /** 사용자 lock 2026-05-14 — 좌측 패널에 03/04/05 fix 고정 list. 클릭 시 callback. */
onSelectSample?: (which: "01" | "02" | "03" | "04" | "05") => void; onSelectSample?: (which: "03" | "04" | "05") => void;
selectedSample?: "01" | "02" | "03" | "04" | "05" | null; selectedSample?: "03" | "04" | "05" | null;
} }
const SAMPLE_MDX_LIST: { key: "01" | "02" | "03" | "04" | "05"; label: string; subtitle: string }[] = [ const SAMPLE_MDX_LIST: { key: "03" | "04" | "05"; label: string; subtitle: string }[] = [
{ key: "01", label: "01. 건설산업 DX의 올바른 이해", subtitle: "DX 개념 + 산업 전환 관점" },
{ key: "02", label: "02. DX의 시행 목표 및 기대효과", subtitle: "시행 목표 + 기대효과" },
{ key: "03", label: "03. DX 시행을 위한 필수 요건", subtitle: "필수 요건 + Process/Product 혁신" }, { key: "03", label: "03. DX 시행을 위한 필수 요건", subtitle: "필수 요건 + Process/Product 혁신" },
{ key: "04", label: "04. DX 지연 요인", subtitle: "DX 인식 + 정책/조직 실태" }, { key: "04", label: "04. DX 지연 요인", subtitle: "DX 인식 + 정책/조직 실태" },
{ key: "05", label: "05. 설계 방식의 왜곡", subtitle: "설계 자동화 오용 + S/W 한계" }, { key: "05", label: "05. 설계 방식의 왜곡", subtitle: "설계 자동화 오용 + S/W 한계" },
@@ -76,7 +74,7 @@ export default function LeftMdxPanel({
MDX Source MDX Source
</h2> </h2>
{/* 2026-05-14 — 01~05 fix 고정 list. 클릭 시 해당 mdx 자동 fetch + 분석. {/* 2026-05-14 — 03/04/05 fix 고정 list. 클릭 시 해당 mdx 자동 fetch + 분석.
frame/layout override 는 분석 후 우측 패널에서 가능. */} frame/layout override 는 분석 후 우측 패널에서 가능. */}
{onSelectSample && ( {onSelectSample && (
<div className="space-y-1 mb-3"> <div className="space-y-1 mb-3">
@@ -1,216 +0,0 @@
import { AlertTriangle, CheckCircle2, GitBranch, Layers3 } from "lucide-react";
import type React from "react";
import type { AiTraceSummary, PipelineTraceSummary } from "../services/designAgentApi";
interface PipelineTracePanelProps {
trace: PipelineTraceSummary | null | undefined;
aiTrace?: AiTraceSummary | null;
}
const stateClass: Record<string, string> = {
rendered: "bg-emerald-50 text-emerald-700 border-emerald-200",
render_blocked: "bg-red-50 text-red-700 border-red-200",
filtered: "bg-amber-50 text-amber-700 border-amber-200",
covered_not_rendered: "bg-orange-50 text-orange-700 border-orange-200",
uncovered: "bg-slate-50 text-slate-500 border-slate-200",
};
function Chip({ children, tone = "slate" }: { children: React.ReactNode; tone?: "slate" | "red" | "green" | "amber" }) {
const tones = {
slate: "bg-slate-100 text-slate-600",
red: "bg-red-100 text-red-700",
green: "bg-emerald-100 text-emerald-700",
amber: "bg-amber-100 text-amber-700",
};
return (
<span className={`inline-flex items-center rounded px-1.5 py-0.5 text-[9px] font-black uppercase ${tones[tone]}`}>
{children}
</span>
);
}
export default function PipelineTracePanel({ trace, aiTrace }: PipelineTracePanelProps) {
if (!trace) {
return (
<div className="h-full flex items-center justify-center bg-slate-50 p-8 text-center text-slate-400">
<div>
<GitBranch className="mx-auto mb-3 h-10 w-10 opacity-20" />
<p className="text-[10px] font-black uppercase tracking-[0.18em]">No Trace Loaded</p>
</div>
</div>
);
}
return (
<div className="h-full overflow-y-auto bg-white p-4 text-slate-700">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-blue-500" />
<h3 className="text-[10px] font-black uppercase tracking-widest text-slate-500">Pipeline Trace</h3>
</div>
<Chip tone={trace.warnings.length > 0 ? "amber" : "green"}>{trace.status}</Chip>
</div>
{trace.warnings.length > 0 && (
<div className="mb-4 rounded border border-amber-200 bg-amber-50 p-3">
<div className="mb-1 flex items-center gap-1.5 text-[10px] font-black uppercase text-amber-700">
<AlertTriangle className="h-3.5 w-3.5" />
Warnings
</div>
<div className="flex flex-wrap gap-1">
{trace.warnings.map((warning) => (
<Chip key={warning} tone="amber">{warning}</Chip>
))}
</div>
</div>
)}
<section className="mb-5">
<div className="mb-2 flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest text-slate-400">
<Layers3 className="h-3.5 w-3.5" />
Sections
</div>
<div className="space-y-2">
{trace.sections.map((section) => (
<div key={section.id} className={`rounded border p-2 ${stateClass[section.state] ?? stateClass.uncovered}`}>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-[11px] font-bold">{section.id}</span>
<span className="text-[9px] font-black uppercase">{section.state}</span>
</div>
{section.title && <div className="mt-1 text-[11px] leading-snug">{section.title}</div>}
{section.child_ids.length > 0 && (
<div className="mt-1 text-[10px] text-slate-500">children: {section.child_ids.join(", ")}</div>
)}
{section.reasons.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{section.reasons.map((reason) => (
<Chip key={reason} tone={section.state === "render_blocked" ? "red" : "amber"}>
{reason}
</Chip>
))}
</div>
)}
</div>
))}
</div>
</section>
<section className="mb-5">
<div className="mb-2 text-[10px] font-black uppercase tracking-widest text-slate-400">Units</div>
<div className="space-y-2">
{trace.units.map((unit) => (
<div key={unit.unit_id} className="rounded border border-slate-200 bg-slate-50 p-2">
<div className="font-mono text-[11px] font-bold text-slate-700">{unit.unit_id || "(empty unit)"}</div>
<div className="mt-1 grid grid-cols-2 gap-x-2 gap-y-1 text-[10px]">
<span>frame</span><span className="font-mono">{unit.selected_frame ?? "-"}</span>
<span>candidates</span><span className="font-mono">{unit.candidate_count}</span>
<span>path</span><span className="font-mono">{unit.selection_path ?? "-"}</span>
<span>label</span><span className="font-mono">{unit.label ?? "-"}</span>
</div>
{unit.warnings.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{unit.warnings.map((warning) => <Chip key={warning} tone="amber">{warning}</Chip>)}
</div>
)}
</div>
))}
</div>
</section>
<section>
<div className="mb-2 text-[10px] font-black uppercase tracking-widest text-slate-400">Zones / Render</div>
<div className="space-y-2">
{trace.zones.map((zone) => {
const filled = zone.slot_status === "filled";
return (
<div key={zone.position} className="rounded border border-slate-200 bg-white p-2">
<div className="flex items-center justify-between">
<span className="font-mono text-[11px] font-bold">{zone.position}</span>
<Chip tone={filled ? "green" : "red"}>{zone.slot_status}</Chip>
</div>
<div className="mt-1 text-[10px] text-slate-500">sections: {zone.source_section_ids.join(", ") || "-"}</div>
<div className="mt-1 text-[10px] text-slate-500">template: <span className="font-mono">{zone.template_id ?? "-"}</span></div>
<div className="mt-1 text-[10px] text-slate-500">slot keys: {zone.slot_key_count}</div>
{zone.warnings.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{zone.warnings.map((warning) => <Chip key={warning} tone="red">{warning}</Chip>)}
</div>
) : (
<div className="mt-2 flex items-center gap-1 text-[10px] text-emerald-600">
<CheckCircle2 className="h-3 w-3" />
render payload ready
</div>
)}
</div>
);
})}
</div>
</section>
<section className="mt-5">
<div className="mb-2 text-[10px] font-black uppercase tracking-widest text-slate-400">AI Status</div>
{!aiTrace ? (
<div className="rounded border border-slate-200 bg-slate-50 p-3 text-[11px] text-slate-500">
AI trace not loaded.
</div>
) : (
<div className="rounded border border-slate-200 bg-white p-3">
<div className="mb-2 flex flex-wrap gap-1">
<Chip tone={aiTrace.artifact_present ? "green" : "red"}>
{aiTrace.artifact_present ? "artifact" : "missing"}
</Chip>
<Chip tone={aiTrace.ai_enabled === false ? "amber" : aiTrace.ai_enabled ? "green" : "slate"}>
enabled {aiTrace.ai_enabled === null ? "unknown" : String(aiTrace.ai_enabled)}
</Chip>
<Chip tone={aiTrace.ai_called_count > 0 ? "green" : "slate"}>
called {aiTrace.ai_called_count}
</Chip>
<Chip tone={aiTrace.eligible_count > 0 ? "amber" : "slate"}>
eligible {aiTrace.eligible_count}
</Chip>
<Chip tone={aiTrace.error_count > 0 ? "red" : "slate"}>
errors {aiTrace.error_count}
</Chip>
</div>
<div className="grid grid-cols-2 gap-x-2 gap-y-1 text-[10px] text-slate-500">
<span>status</span><span className="font-mono">{aiTrace.status}</span>
<span>coverage</span><span className="font-mono">{aiTrace.coverage_status ?? "-"}</span>
<span>provider</span><span className="font-mono">{aiTrace.provider ?? "-"}</span>
<span>human review</span><span className="font-mono">{String(aiTrace.human_review_required)}</span>
</div>
{Object.keys(aiTrace.skip_reasons).length > 0 && (
<div className="mt-3">
<div className="mb-1 text-[10px] font-black uppercase text-slate-400">Skip Reasons</div>
<div className="flex flex-wrap gap-1">
{Object.entries(aiTrace.skip_reasons).map(([reason, count]) => (
<Chip key={reason} tone={reason === "called" ? "green" : "slate"}>
{reason}: {count}
</Chip>
))}
</div>
</div>
)}
{aiTrace.units.length > 0 && (
<div className="mt-3 space-y-2">
{aiTrace.units.map((unit) => (
<div key={`${unit.unit_index}-${unit.source_section_ids.join("+")}`} className="rounded bg-slate-50 p-2 text-[10px]">
<div className="flex items-center justify-between">
<span className="font-mono font-bold">{unit.source_section_ids.join("+") || `unit-${unit.unit_index}`}</span>
<Chip tone={unit.ai_called ? "green" : "slate"}>{unit.ai_called ? "called" : "not called"}</Chip>
</div>
<div className="mt-1 grid grid-cols-2 gap-x-2 gap-y-1 text-slate-500">
<span>route</span><span className="font-mono">{unit.route_hint ?? "-"}</span>
<span>skip</span><span className="font-mono">{unit.skip_reason ?? "-"}</span>
<span>apply</span><span className="font-mono">{unit.apply_status ?? "-"}</span>
<span>error</span><span className="font-mono">{unit.error ?? "-"}</span>
</div>
</div>
))}
</div>
)}
</div>
)}
</section>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,165 +0,0 @@
/**
* IMP-90 (#90) u14 — Structure edit overlay.
*
* React component + pure helpers that present a per-zone slot list with
* reorder (↑ / ↓) and hide (👁 / 🚫) affordances. Mounted by SlideCanvas
* when `editMode === "structure"`. Emits a `StructureOverridePerZone`
* tuple `{slot_order, hidden_slots}` through `onChange`; u15 will debounce
* + PUT this to `/api/user-overrides` (NOT u14 scope), and u16 reads the
* persisted axis at the next CLI generate run.
*
* SCOPE LOCK (binding contract):
* - inner shape = `{slot_order, hidden_slots}` ONLY.
* - frame swap stays on the existing `frames` axis (u6 backend resolver
* rejects frame-swap-shaped inner keys).
* - per-slot text content NEVER mutated here — `text_overrides` axis
* (u4/u5/u13) handles that exclusively.
*
* The exported pure helpers (`resolveEffectiveSlotOrder`, `moveItem`) are
* the unit's vitest surface; React rendering is NOT tested because the
* Front package devDependencies do not include jsdom / @testing-library
* (verified by u11/u12/u13 test pattern).
*/
import type {
StructureOverridePerZone,
} from "../services/userOverridesApi";
export interface StructureEditOverlayProps {
zoneId: string;
/** Discovered slot keys for this zone (e.g. from iframe DOM
* `data-text-path` prefixes). Order = backend default. */
slotKeys: ReadonlyArray<string>;
/** Current persisted override (or undefined). `slot_order` reorders the
* discovered keys; missing keys keep backend order at the tail. */
current?: StructureOverridePerZone;
/** Emitted on every user mutation. u15 wires this to autosave. */
onChange?: (zoneId: string, next: StructureOverridePerZone) => void;
}
/** Apply `slot_order` override to the discovered slot list. Unknown
* override entries are dropped; missing discovered keys are appended in
* backend order so the user never loses a slot by partial-override. */
export function resolveEffectiveSlotOrder(
slotKeys: ReadonlyArray<string>,
slotOrder?: ReadonlyArray<string> | null,
): string[] {
if (!slotOrder || slotOrder.length === 0) return [...slotKeys];
const allowed = new Set(slotKeys);
const seen = new Set<string>();
const ordered: string[] = [];
for (const k of slotOrder) {
if (typeof k === "string" && allowed.has(k) && !seen.has(k)) {
ordered.push(k);
seen.add(k);
}
}
for (const k of slotKeys) {
if (!seen.has(k)) ordered.push(k);
}
return ordered;
}
/** Move `arr[index]` by `delta` positions. Out-of-range returns a fresh
* copy of the input (defensive: caller can always treat the result as a
* new reference). */
export function moveItem<T>(
arr: ReadonlyArray<T>,
index: number,
delta: number,
): T[] {
const next = arr.slice();
const target = index + delta;
if (
index < 0 ||
index >= next.length ||
target < 0 ||
target >= next.length
) {
return next;
}
const tmp = next[index];
next[index] = next[target];
next[target] = tmp;
return next;
}
export default function StructureEditOverlay({
zoneId,
slotKeys,
current,
onChange,
}: StructureEditOverlayProps) {
const effective = resolveEffectiveSlotOrder(slotKeys, current?.slot_order);
const hidden = new Set(current?.hidden_slots ?? []);
const emit = (nextOrder: string[], nextHidden: Set<string>) => {
onChange?.(zoneId, {
slot_order: nextOrder,
hidden_slots: Array.from(nextHidden),
});
};
return (
<div
data-testid={`structure-overlay-${zoneId}`}
className="bg-white/95 border border-emerald-300 rounded shadow p-2 flex flex-col gap-1 text-[10px]"
style={{ pointerEvents: "auto" }}
>
<div className="font-bold uppercase tracking-wider text-emerald-700 mb-1">
{zoneId}
</div>
{effective.length === 0 ? (
<div className="text-slate-400 italic">slot </div>
) : (
effective.map((key, i) => (
<div
key={key}
data-testid={`slot-${zoneId}-${key}`}
className="flex items-center gap-1"
>
<span
className={`flex-1 truncate ${
hidden.has(key) ? "text-slate-400 line-through" : "text-slate-700"
}`}
>
{key}
</span>
<button
type="button"
data-testid={`slot-up-${zoneId}-${key}`}
disabled={i === 0}
onClick={() => emit(moveItem(effective, i, -1), hidden)}
className="px-1 rounded border border-slate-200 disabled:opacity-30 hover:bg-slate-100"
title="위로"
>
</button>
<button
type="button"
data-testid={`slot-down-${zoneId}-${key}`}
disabled={i === effective.length - 1}
onClick={() => emit(moveItem(effective, i, 1), hidden)}
className="px-1 rounded border border-slate-200 disabled:opacity-30 hover:bg-slate-100"
title="아래로"
>
</button>
<button
type="button"
data-testid={`slot-hide-${zoneId}-${key}`}
aria-pressed={hidden.has(key)}
onClick={() => {
const nh = new Set(hidden);
if (nh.has(key)) nh.delete(key);
else nh.add(key);
emit(effective, nh);
}}
className="px-1 rounded border border-slate-200 hover:bg-slate-100"
title={hidden.has(key) ? "표시" : "숨김"}
>
{hidden.has(key) ? "🚫" : "👁"}
</button>
</div>
))
)}
</div>
);
}
@@ -1,225 +0,0 @@
// IMP-54 u4 — vitest coverage for the pure drag-math helpers extracted in u3
// (`Front/client/src/components/slideCanvasDragMath.ts`).
//
// Stage 2 contract (`Stage 2 Exit Report → implementation_units → u4`):
// • Threshold pass/fail at 5 px (strict `Math.hypot > 5`).
// • Clamp negative delta to 0 on both axes.
// • Clamp max-edge delta to `1 - startGeom.w` (x) and `1 - startGeom.h` (y).
//
// The helpers are pure (no React, no DOM) so we drive them directly with
// numeric inputs — no fake timers, no fetch stubs, no component mount.
import { describe, expect, it } from "vitest";
import {
DRAG_THRESHOLD_PX,
IMAGE_RESIZE_MIN_SIZE_PERCENT,
clampImagePercentGeometry,
clampZoneMove,
crossedDragThreshold,
type ImagePercentGeom,
type ZoneFracGeom,
} from "./slideCanvasDragMath";
describe("DRAG_THRESHOLD_PX", () => {
it("is 5", () => {
expect(DRAG_THRESHOLD_PX).toBe(5);
});
});
describe("crossedDragThreshold", () => {
it("returns false for zero movement (still a click)", () => {
expect(crossedDragThreshold(0, 0)).toBe(false);
});
it("returns false just below threshold — 3,4 → hypot 5 with strict >", () => {
expect(crossedDragThreshold(3, 4)).toBe(false);
});
it("returns false at exactly the threshold along each axis", () => {
// strict inequality: Math.hypot(5, 0) === 5, not > 5
expect(crossedDragThreshold(5, 0)).toBe(false);
expect(crossedDragThreshold(0, 5)).toBe(false);
});
it("returns true once distance exceeds threshold", () => {
expect(crossedDragThreshold(4, 4)).toBe(true); // hypot ≈ 5.6568
expect(crossedDragThreshold(6, 0)).toBe(true);
expect(crossedDragThreshold(0, 6)).toBe(true);
});
it("treats negative deltas symmetrically (Euclidean distance)", () => {
expect(crossedDragThreshold(-3, -4)).toBe(false);
expect(crossedDragThreshold(-4, -4)).toBe(true);
expect(crossedDragThreshold(-6, 0)).toBe(true);
});
});
describe("clampZoneMove", () => {
// 1000 × 1000 slide body so 1 px == 0.001 frac — keeps the arithmetic
// exact and the boundary deltas (1000 px) round-trip back to `1 - w/h`.
const W = 1000;
const H = 1000;
const baseGeom: ZoneFracGeom = { x: 0.1, y: 0.2, w: 0.3, h: 0.4 };
it("applies in-bounds delta as startGeom + (dPx / slideBodySize)", () => {
expect(clampZoneMove(baseGeom, 100, 50, W, H)).toEqual({
x: 0.2,
y: 0.25,
});
});
it("clamps negative delta to 0 on both axes", () => {
expect(clampZoneMove(baseGeom, -1000, -1000, W, H)).toEqual({
x: 0,
y: 0,
});
});
it("clamps max-edge delta to (1 - w) on x and (1 - h) on y", () => {
expect(clampZoneMove(baseGeom, 1000, 1000, W, H)).toEqual({
x: 1 - baseGeom.w, // 0.7
y: 1 - baseGeom.h, // 0.6
});
});
it("clamps the two axes independently (negative x, in-bounds y)", () => {
expect(clampZoneMove(baseGeom, -1000, 50, W, H)).toEqual({
x: 0,
y: 0.25,
});
});
it("honours non-square slide bodies via per-axis division", () => {
// dxPx 100 / 500 = 0.2 fr; dyPx 100 / 250 = 0.4 fr (hits the y boundary).
// x is checked with toBeCloseTo because 0.1 + 0.2 is the canonical IEEE-754
// floating-point trap (0.30000000000000004) — the clamp logic is correct,
// it just inherits JS number precision. y stays exact since it clamps to
// the boundary `1 - h`.
const result = clampZoneMove(baseGeom, 100, 100, 500, 250);
expect(result.x).toBeCloseTo(0.3, 10);
expect(result.y).toBe(1 - baseGeom.h); // 0.6
});
it("returns only { x, y } — width / height are preserved by the caller", () => {
const out = clampZoneMove(baseGeom, 0, 0, W, H);
expect(out).toEqual({ x: 0.1, y: 0.2 });
expect("w" in out).toBe(false);
expect("h" in out).toBe(false);
});
});
// IMP-51 (#79) u9 — image overlay resize / move math.
// Boundary contract (must match the inline u8 math Codex #16 verified):
// • slide-bound invariant — x+w ≤ 100 ∧ y+h ≤ 100 for ALL valid inputs,
// including small-near-edge geoms where the existing minSize floor
// would otherwise have pushed past the slide bound.
// • aspect-locked corner — baseAspect = startGeom.w / startGeom.h is
// preserved exactly; the wFloor uses `min(minSize, maxW, maxH*baseAspect)`
// so a floor application never violates either axis.
// The two concrete Codex #15 reproductions are encoded explicitly below
// so a future regression on the boundary math fails this suite directly.
describe("IMAGE_RESIZE_MIN_SIZE_PERCENT", () => {
it("is 2 (percent of slide bbox)", () => {
expect(IMAGE_RESIZE_MIN_SIZE_PERCENT).toBe(2);
});
});
describe("clampImagePercentGeometry", () => {
const baseGeom: ImagePercentGeom = { x: 10, y: 10, w: 20, h: 10 };
describe("direction = 'move'", () => {
it("translates and clamps both axes; preserves w/h", () => {
expect(
clampImagePercentGeometry(baseGeom, 5, 7, "move", false),
).toEqual({ x: 15, y: 17, w: 20, h: 10 });
});
it("clamps negative deltas to (0, 0)", () => {
expect(
clampImagePercentGeometry(baseGeom, -1000, -1000, "move", false),
).toEqual({ x: 0, y: 0, w: 20, h: 10 });
});
it("clamps max-edge deltas to (100 - w, 100 - h)", () => {
expect(
clampImagePercentGeometry(baseGeom, 1000, 1000, "move", false),
).toEqual({ x: 80, y: 90, w: 20, h: 10 });
});
});
describe("edge resize — independent per-axis clamp", () => {
it("right edge clamps width to 100 - startGeom.x", () => {
const out = clampImagePercentGeometry(baseGeom, 1000, 0, "right", false);
expect(out).toEqual({ x: 10, y: 10, w: 90, h: 10 });
expect(out.x + out.w).toBeLessThanOrEqual(100);
});
it("left drag dx=-100 emits {x:0,y:10,w:30,h:10} (Codex regression)", () => {
// From Codex #15 / #16 verification — ordinary left drag past the
// slide edge should pin x at 0 and grow w by the original x amount.
expect(
clampImagePercentGeometry(baseGeom, -100, 0, "left", false),
).toEqual({ x: 0, y: 10, w: 30, h: 10 });
});
it("near-edge right resize keeps x + w ≤ 100 (Codex #15 reproduction)", () => {
// Pre-fix: minSize=2 floor applied AFTER span clamp would emit
// {x:99, w:2} so x+w=101. Post-fix: floor caps at maxW=1.
const start: ImagePercentGeom = { x: 99, y: 10, w: 0.5, h: 10 };
const out = clampImagePercentGeometry(start, 1, 0, "right", false);
expect(out).toEqual({ x: 99, y: 10, w: 1, h: 10 });
expect(out.x + out.w).toBe(100);
});
it("top/bottom edges are symmetric to left/right", () => {
const bottom = clampImagePercentGeometry(baseGeom, 0, 1000, "bottom", false);
expect(bottom).toEqual({ x: 10, y: 10, w: 20, h: 90 });
const top = clampImagePercentGeometry(baseGeom, 0, -100, "top", false);
expect(top).toEqual({ x: 10, y: 0, w: 20, h: 20 });
});
});
describe("corner resize — aspect locked (default Shift-off)", () => {
it("NW drag dx=-100,dy=-100 emits {x:0,y:5,w:30,h:15} (Codex regression)", () => {
// From Codex #16 verification — aspect-locked NW past the slide
// edge: rightEdge=30, bottomEdge=20, baseAspect=2. Independent
// clamps give x=0,w=30,y=0,h=20. Aspect block then picks the
// limiting axis: newH = 30/2 = 15 (≤20). Re-anchor: y = 20 - 15 = 5.
expect(
clampImagePercentGeometry(baseGeom, -100, -100, "nw", true),
).toEqual({ x: 0, y: 5, w: 30, h: 15 });
});
it("tiny near-corner NE resize stays within bounds (Codex #15 reproduction)", () => {
// Pre-fix: dual-axis minSize floor would emit w=2, h=2 with
// re-anchor pushing x+w past 100. Post-fix: wFloor caps at
// min(2, maxW=1, maxH*baseAspect=1) = 1, so newW=1, newH=1.
const start: ImagePercentGeom = { x: 99, y: 99, w: 0.5, h: 0.5 };
const out = clampImagePercentGeometry(start, 1, -1, "ne", true);
expect(out).toEqual({ x: 99, y: 98.5, w: 1, h: 1 });
expect(out.x + out.w).toBeLessThanOrEqual(100);
expect(out.y + out.h).toBeLessThanOrEqual(100);
});
it("preserves baseAspect exactly when the floor is hit", () => {
// 2:1 aspect ratio (w=20, h=10); large negative drag past edges
// hits wFloor. newW/newH ratio must equal baseAspect.
const out = clampImagePercentGeometry(
baseGeom, -1000, -1000, "nw", true,
);
expect(out.w / out.h).toBeCloseTo(baseGeom.w / baseGeom.h, 10);
});
});
describe("corner resize — Shift unlock (independent edges)", () => {
it("SE without aspect lock degenerates to right + bottom edges", () => {
const corner = clampImagePercentGeometry(baseGeom, 1000, 1000, "se", false);
const sides = clampImagePercentGeometry(
clampImagePercentGeometry(baseGeom, 1000, 0, "right", false),
0, 1000, "bottom", false,
);
expect(corner).toEqual(sides);
});
});
});
@@ -1,201 +0,0 @@
// IMP-54 u3 — pure drag math extracted from SlideCanvas.tsx
// `handleZoneMouseDown` (`Front/client/src/components/SlideCanvas.tsx:537-598`).
//
// Resize math (`makeResizeHandler` at SlideCanvas.tsx:465-523) is intentionally
// NOT touched — it has its own independent geometry model (per-side
// `affectsLeft/Right/Top/Bottom`, `minSize`, `1 - startGeom.x/y` cap) that
// must not regress.
//
// Two responsibilities live here:
//
// 1. Drag-vs-click classification — a pointer must travel more than
// `DRAG_THRESHOLD_PX` (Euclidean distance from the mousedown origin)
// before mousedown→mousemove is treated as a drag. Below the
// threshold the gesture stays a click, which the caller surfaces as
// `onZoneClick(zone.id)` in `onUp`.
//
// 2. Pixel-delta → slide-body fraction conversion plus clamp to keep the
// moved zone fully inside the slide body. Width/height are preserved
// verbatim by this helper — only `x` and `y` move.
//
// Both helpers are pure (no React, no DOM, no side effects) so vitest can
// drive them directly. The numeric contract is the inline behavior that
// existed before the extraction; this file is a relocation, not a behavior
// change.
export const DRAG_THRESHOLD_PX = 5;
// IMP-51 (#79) u9 — image overlay resize / move math extracted from
// SlideCanvas.tsx `beginDrag` onMove (lines 10921219 of the u8 patch).
// Slide-absolute percent coordinate space (0100 on both axes), matching
// the persisted `image_overrides` axis (`src/user_overrides_io.py` u1
// KNOWN_AXES) and the typed client `ImageOverride` shape (`userOverridesApi.ts`
// u3). The math is the contract Codex #16 verified post-u8 — this file
// is a relocation, not a behavior change. SlideCanvas calls it from a
// single hook so future tweaks need to update one place + the vitest
// suite alongside.
export const IMAGE_RESIZE_MIN_SIZE_PERCENT = 2;
/** Image overlay geometry in slide-absolute percent (each component ∈ [0, 100]).
* Mirrors `ImageOverride` from `services/userOverridesApi.ts` (u3) so this
* shape moves end-to-end through stamper → overlay → persisted axis. */
export interface ImagePercentGeom {
x: number;
y: number;
w: number;
h: number;
}
export type ImageDragDirection =
| "move"
| "left"
| "right"
| "top"
| "bottom"
| "nw"
| "ne"
| "sw"
| "se";
/** Apply a percent-space drag delta to `startGeom` per `direction` and clamp.
*
* Contract (must match the inline u8 math Codex #16 verified):
* • `direction === "move"` → translate only; w/h preserved verbatim;
* x/y clamped to `[0, 100 - w]` and `[0, 100 - h]`.
* • Edge handle (`left|right|top|bottom`) → one axis only; opposite
* edge pinned so x+w ≤ 100 and y+h ≤ 100 hold.
* • Corner handle (`nw|ne|sw|se`) with `aspectLocked=false` → two
* independent edges (same per-edge clamp as above).
* • Corner handle with `aspectLocked=true` → preserves
* `baseAspect = startGeom.w / startGeom.h`; the pinned-opposite-corner
* stays fixed; the floored axis is `w` and `h` is re-derived so the
* aspect ratio is exact even at the minSize floor.
*
* `minSize` is best-effort: when the available span (e.g. `100 - startGeom.x`
* for `affectsRight`) is below `minSize`, the floor caps at the span itself
* so the slide-bound invariant (x+w ≤ 100 ∧ y+h ≤ 100) is never violated.
* Pure / deterministic / no DOM access — vitest drives it directly. */
export function clampImagePercentGeometry(
startGeom: ImagePercentGeom,
dxPercent: number,
dyPercent: number,
direction: ImageDragDirection,
aspectLocked: boolean,
minSize: number = IMAGE_RESIZE_MIN_SIZE_PERCENT,
): ImagePercentGeom {
if (direction === "move") {
const x = Math.max(0, Math.min(100 - startGeom.w, startGeom.x + dxPercent));
const y = Math.max(0, Math.min(100 - startGeom.h, startGeom.y + dyPercent));
return { x, y, w: startGeom.w, h: startGeom.h };
}
const affectsLeft =
direction === "left" || direction === "nw" || direction === "sw";
const affectsRight =
direction === "right" || direction === "ne" || direction === "se";
const affectsTop =
direction === "top" || direction === "nw" || direction === "ne";
const affectsBottom =
direction === "bottom" || direction === "sw" || direction === "se";
const isCorner =
direction === "nw" ||
direction === "ne" ||
direction === "sw" ||
direction === "se";
const rightEdge = startGeom.x + startGeom.w;
const bottomEdge = startGeom.y + startGeom.h;
let x = startGeom.x;
let y = startGeom.y;
let w = startGeom.w;
let h = startGeom.h;
if (affectsRight) {
const maxW = 100 - startGeom.x;
const floor = Math.min(minSize, maxW);
w = Math.max(floor, Math.min(maxW, startGeom.w + dxPercent));
}
if (affectsBottom) {
const maxH = 100 - startGeom.y;
const floor = Math.min(minSize, maxH);
h = Math.max(floor, Math.min(maxH, startGeom.h + dyPercent));
}
if (affectsLeft) {
const floor = Math.min(minSize, rightEdge);
x = Math.max(0, Math.min(rightEdge - floor, startGeom.x + dxPercent));
w = rightEdge - x;
}
if (affectsTop) {
const floor = Math.min(minSize, bottomEdge);
y = Math.max(0, Math.min(bottomEdge - floor, startGeom.y + dyPercent));
h = bottomEdge - y;
}
if (isCorner && aspectLocked) {
const baseAspect =
startGeom.w > 0 && startGeom.h > 0 ? startGeom.w / startGeom.h : 1;
if (baseAspect > 0) {
const maxW = affectsLeft ? rightEdge : 100 - startGeom.x;
const maxH = affectsTop ? bottomEdge : 100 - startGeom.y;
let newW = w;
let newH = newW / baseAspect;
if (newH > maxH) {
newH = maxH;
newW = newH * baseAspect;
}
if (newW > maxW) {
newW = maxW;
newH = newW / baseAspect;
}
const wFloor = Math.min(minSize, maxW, maxH * baseAspect);
if (newW < wFloor) {
newW = wFloor;
newH = newW / baseAspect;
}
w = newW;
h = newH;
x = affectsLeft ? rightEdge - w : startGeom.x;
y = affectsTop ? bottomEdge - h : startGeom.y;
}
}
return { x, y, w, h };
}
/** Returns true once the pointer has travelled far enough from the mousedown
* origin to be treated as a drag rather than a click. */
export function crossedDragThreshold(dxPx: number, dyPx: number): boolean {
return Math.hypot(dxPx, dyPx) > DRAG_THRESHOLD_PX;
}
/** Zone geometry in slide-body fraction space (each component ∈ [0, 1]).
* Mirrors the shape the SlideCanvas pipeline already uses for
* `localGeom` / `overrideGeom` / `onZoneResize` payloads. */
export interface ZoneFracGeom {
x: number;
y: number;
w: number;
h: number;
}
/** Convert a pixel-space drag delta into a slide-body fraction delta, apply
* it to `startGeom.{x, y}`, and clamp so the zone never escapes the slide
* body (`x ∈ [0, 1 - w]`, `y ∈ [0, 1 - h]`). `w` and `h` are not modified.
*
* The caller (`SlideCanvas.tsx` `handleZoneMouseDown` onMove) guarantees
* `slideBodyWidthPx > 0` and `slideBodyHeightPx > 0` via the
* `measuredSlideBody` precondition, so this helper does not re-guard
* divide-by-zero. */
export function clampZoneMove(
startGeom: ZoneFracGeom,
dxPx: number,
dyPx: number,
slideBodyWidthPx: number,
slideBodyHeightPx: number,
): { x: number; y: number } {
const dx = dxPx / slideBodyWidthPx;
const dy = dyPx / slideBodyHeightPx;
const x = Math.max(0, Math.min(1 - startGeom.w, startGeom.x + dx));
const y = Math.max(0, Math.min(1 - startGeom.h, startGeom.y + dy));
return { x, y };
}
@@ -1,181 +0,0 @@
/**
* Design Agent - Mock Data
*
* 이 파일은 백엔드 파이프라인이 연결되기 전까지 사용하는 mock 데이터입니다.
* Phase Z 설계 규격을 따릅니다.
*/
import type {
NormalizedContent,
SlidePlan,
LayoutCandidate,
FrameCandidate,
} from "../types/designAgent";
// ─────────────────────────────────────────────────────────────────────────────
// Layout Candidates
// ─────────────────────────────────────────────────────────────────────────────
// 8 preset = backend templates/phase_z2/layouts/layouts.yaml 1:1 매칭.
// id / 순서 / 의미 모두 backend 와 동일해야 함.
export const MOCK_LAYOUT_CANDIDATES: LayoutCandidate[] = [
{ id: "single", name: "단일 본문", type: "full", description: "전체 영역 1 zone (primary)" },
{ id: "horizontal-2", name: "상/하 2단", type: "top-bottom", description: "위/아래 2 zone (top, bottom) — rows topology" },
{ id: "vertical-2", name: "좌/우 2단", type: "left-right", description: "좌/우 2 zone (left, right) — cols topology" },
{ id: "top-1-bottom-2", name: "상 1 : 하 2", type: "asymmetric", description: "위 1 zone + 아래 좌/우 2 zone — T topology" },
{ id: "top-2-bottom-1", name: "상 2 : 하 1", type: "asymmetric", description: "위 좌/우 2 zone + 아래 1 zone — inverted-T topology" },
{ id: "left-1-right-2", name: "좌 1 : 우 2", type: "asymmetric", description: "좌 1 zone + 우 상/하 2 zone — side-T-left topology" },
{ id: "left-2-right-1", name: "좌 2 : 우 1", type: "asymmetric", description: "좌 상/하 2 zone + 우 1 zone — side-T-right topology" },
{ id: "grid-2x2", name: "2x2 그리드", type: "grid", description: "4 zone 균등 배치 — 2x2 topology" },
];
// ─────────────────────────────────────────────────────────────────────────────
// Frame Candidates (샘플 이미지 포함 - 8종 세트)
// ─────────────────────────────────────────────────────────────────────────────
const SAMPLE_FRAMES: FrameCandidate[] = [
{
id: "frame-001",
name: "3-Card 프로세스",
score: 0.98,
confidence: "high",
label: "use_as_is",
thumbnailUrl: "https://images.unsplash.com/photo-1614850523296-d8c1af93d400?w=300&h=200&fit=crop",
},
{
id: "frame-002",
name: "비교 테이블 (Light)",
score: 0.85,
confidence: "medium",
label: "light_edit",
thumbnailUrl: "https://images.unsplash.com/photo-1508615039623-a25651266b91?w=300&h=200&fit=crop",
},
{
id: "frame-003",
name: "계층형 트리",
score: 0.72,
confidence: "medium",
label: "restructure",
thumbnailUrl: "https://images.unsplash.com/photo-1551288049-bbda03a24d5d?w=300&h=200&fit=crop",
},
{
id: "frame-004",
name: "4-Grid 핵심 요약",
score: 0.65,
confidence: "low",
label: "reject",
thumbnailUrl: "https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=300&h=200&fit=crop",
},
{
id: "frame-005",
name: "타임라인 로드맵",
score: 0.91,
confidence: "high",
label: "use_as_is",
thumbnailUrl: "https://images.unsplash.com/photo-1531403009284-440f080d1e12?w=300&h=200&fit=crop",
},
{
id: "frame-006",
name: "순환형 다이어그램",
score: 0.78,
confidence: "medium",
label: "light_edit",
thumbnailUrl: "https://images.unsplash.com/photo-1557804506-669a67965ba0?w=300&h=200&fit=crop",
},
{
id: "frame-007",
name: "수치 강조 카드",
score: 0.88,
confidence: "high",
label: "use_as_is",
thumbnailUrl: "https://images.unsplash.com/photo-1551288049-bbda03a24d5d?w=300&h=200&fit=crop",
},
{
id: "frame-008",
name: "좌우 대비 분석",
score: 0.62,
confidence: "low",
label: "restructure",
thumbnailUrl: "https://images.unsplash.com/photo-1454165833767-02a6ed8a687a?w=300&h=200&fit=crop",
},
];
export const MOCK_FRAME_CANDIDATES_SECTION1 = SAMPLE_FRAMES;
// ─────────────────────────────────────────────────────────────────────────────
// Normalized Content
// ─────────────────────────────────────────────────────────────────────────────
export const MOCK_NORMALIZED_CONTENT: NormalizedContent = {
title: "DX 실행 체계 구축 방안",
sections: [
{
id: "section-1",
index: 1,
level: 2,
title: "DX 실행 필수 요건",
content_objects: [
{ id: "co-1", type: "text_block", role: "summary", raw_payload: "조직 전반의 DX 역량 강화 필요", size_estimate: { line_count: 2 } },
{ id: "co-2", type: "text_block", role: "detail", raw_payload: "전담 조직 구성 및 KPI 설정", size_estimate: { line_count: 3 } }
]
}
]
};
// ─────────────────────────────────────────────────────────────────────────────
// Slide Plan (Phase Z 구조)
// ─────────────────────────────────────────────────────────────────────────────
export const MOCK_SLIDE_PLAN: SlidePlan = {
id: "slide-001",
title: "DX 실행 체계 구축 방안",
layout_preset: "horizontal-2",
zones: [
{
id: "zone-left",
zone_id: "left",
section_ids: ["section-1"],
position: { x: 0, y: 0, width: 0.5, height: 1 },
internal_regions: [
{
id: "region-l1",
region_id: "content-1",
role: "primary",
content_type: "mixed",
ratio_estimate: 1,
content_unit_ids: ["co-1", "co-2"],
frame_match_strategy: {
kind: "frame_match",
frame_id: "frame-001",
display_strategy: "inline_full"
},
frame_candidates: SAMPLE_FRAMES
}
]
},
{
id: "zone-right",
zone_id: "right",
section_ids: ["section-1"],
position: { x: 0.5, y: 0, width: 0.5, height: 1 },
internal_regions: [
{
id: "region-r1",
region_id: "content-2",
role: "secondary",
content_type: "text_block",
ratio_estimate: 1,
content_unit_ids: [],
frame_match_strategy: {
kind: "frame_match",
frame_id: "frame-002",
display_strategy: "inline_full"
},
frame_candidates: SAMPLE_FRAMES
}
]
}
],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
+100 -438
View File
@@ -2,7 +2,7 @@
* Home - 메인 페이지 (Zone-Centric 슬라이드 빌더) * Home - 메인 페이지 (Zone-Centric 슬라이드 빌더)
*/ */
import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { useState, useCallback, useMemo, useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { DesignAgentState, LayoutPresetId, Zone } from "../types/designAgent"; import type { DesignAgentState, LayoutPresetId, Zone } from "../types/designAgent";
import { import {
@@ -15,14 +15,6 @@ import {
getSelectedRegion, getSelectedRegion,
moveSectionToZone, moveSectionToZone,
saveZoneSizes, saveZoneSizes,
saveImageOverride,
saveTextOverride,
saveStructureOverride,
deriveUserOverridesKey,
applyPersistedNonFrameOverrides,
mergeSubmittedPipelineOverridesForRestore,
remapPersistedFramesToZoneFrames,
validateZoneGeometriesAgainstLayout,
} from "../utils/slidePlanUtils"; } from "../utils/slidePlanUtils";
import { import {
parseMdxFile, parseMdxFile,
@@ -33,21 +25,13 @@ import {
type RunMeta, type RunMeta,
type PipelineOverrides, type PipelineOverrides,
} from "../services/designAgentApi"; } from "../services/designAgentApi";
import {
flushUserOverrides,
getUserOverrides,
saveUserOverrides,
type UserOverrides,
} from "../services/userOverridesApi";
import LeftMdxPanel from "../components/LeftMdxPanel"; import LeftMdxPanel from "../components/LeftMdxPanel";
import SlideCanvas from "../components/SlideCanvas"; import SlideCanvas from "../components/SlideCanvas";
import LayoutPanel from "../components/LayoutPanel"; import LayoutPanel from "../components/LayoutPanel";
import FramePanel from "../components/FramePanel"; import FramePanel from "../components/FramePanel";
import PipelineTracePanel from "../components/PipelineTracePanel";
import BottomActions from "../components/BottomActions";
import { import {
Sparkles, Loader2, Sparkles, Download, Link2, Loader2,
CheckCircle2, HelpCircle, CheckCircle2, HelpCircle,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -62,7 +46,7 @@ const INITIAL_STATE: DesignAgentState = {
error: null, error: null,
}; };
type RightPanelTab = "layout" | "frame" | "trace"; type RightPanelTab = "layout" | "frame";
export default function Home() { export default function Home() {
const [state, setState] = useState<DesignAgentState>(INITIAL_STATE); const [state, setState] = useState<DesignAgentState>(INITIAL_STATE);
@@ -79,14 +63,6 @@ export default function Home() {
// section drag drop + frame 선택). null 이면 평소 모드 (final.html 표시). // section drag drop + frame 선택). null 이면 평소 모드 (final.html 표시).
const [pendingLayout, setPendingLayout] = useState<LayoutPresetId | null>(null); const [pendingLayout, setPendingLayout] = useState<LayoutPresetId | null>(null);
// IMP-52 u6 — restore-on-reopen: persisted user_overrides.json fetched at
// handleFileUpload time. layout / zone_geometries / zone_sections are
// seeded into userSelection immediately (so handleGenerate forwards them
// as CLI args). frames are stashed here because their on-disk key
// (unit_id = section_ids joined by "+") only maps to region.id after
// loadRun rebuilds the slidePlan — see handleGenerate post-loadRun.
const persistedOverridesRef = useRef<Partial<UserOverrides>>({});
// pendingLayout 활성 시 effective slidePlan = pendingZones 가 swap 된 plan. // pendingLayout 활성 시 effective slidePlan = pendingZones 가 swap 된 plan.
// 그 외 = default state.slidePlan. 모든 zone / region lookup (handleFrameSelect / // 그 외 = default state.slidePlan. 모든 zone / region lookup (handleFrameSelect /
// getSelectedZone / SlideCanvas) 이 일관되게 이 effectiveSlidePlan 사용. // getSelectedZone / SlideCanvas) 이 일관되게 이 effectiveSlidePlan 사용.
@@ -160,31 +136,6 @@ export default function Home() {
} }
carriedZoneSections[targetPos].push(...zone.section_ids); carriedZoneSections[targetPos].push(...zone.section_ids);
}); });
// IMP-44 (#73) u4 — clear in-memory zone_geometries on layout flip.
// The persisted keys were valid for the *prior* preset; carrying them
// forward into the new preset would either trip the u1/u2 backend
// [override-warning] guards (foreign keys dropped, override_applied
// forced back to None) or partially apply on shared keys. Drop them
// up-front so the new layout starts from a clean even-split baseline,
// and persist a clear sentinel (null) so a subsequent reopen does not
// resurrect the stale snapshot from user_overrides.json.
const priorGeoms = p.userSelection.overrides.zone_geometries;
const hadPriorGeoms =
priorGeoms && typeof priorGeoms === "object" && Object.keys(priorGeoms).length > 0;
if (p.uploadedFile && hadPriorGeoms) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { zone_geometries: null });
}
// IMP-55 (#93) u12 — persist the marker reset to disk so a stale
// `manual_section_assignment: true` from a prior drag (written via
// u6's co-PUT) cannot survive the layout apply. The in-memory reset
// on line 192 protects the current session, but a page reload would
// re-seed from disk via u3's restore branch and re-arm the u7 gate.
// Unconditional — apply always resets, independent of hadPriorGeoms.
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { manual_section_assignment: false });
}
return { return {
...p, ...p,
userSelection: { userSelection: {
@@ -193,18 +144,6 @@ export default function Home() {
...p.userSelection.overrides, ...p.userSelection.overrides,
layout_preset: layoutId, layout_preset: layoutId,
zone_sections: carriedZoneSections, zone_sections: carriedZoneSections,
zone_geometries: {},
// IMP-55 (#93) u5 — reset the bool intent marker to `false` on
// layout apply. `carriedZoneSections` above is auto-carry (old
// zone.section_ids → new layout positions), NOT user drag-drop
// intent. Without this explicit reset the spread of
// `...p.userSelection.overrides` would carry a prior-drag `true`
// into the new layout, causing handleGenerate (u7) to forward
// auto-carried assignments as user overrides and re-trigger the
// PARTIAL_COVERAGE regression. The marker flips back to `true`
// only when the user actually drag-drops a section in the new
// layout (u6 handleSectionDrop).
manual_section_assignment: false,
}, },
selectedZoneId: null, selectedZoneId: null,
selectedRegionId: null, selectedRegionId: null,
@@ -219,27 +158,10 @@ export default function Home() {
// pending 모드 취소 → 평소 (final.html iframe) 모드 복귀. // pending 모드 취소 → 평소 (final.html iframe) 모드 복귀.
const handleCancelPendingLayout = useCallback(() => { const handleCancelPendingLayout = useCallback(() => {
setPendingLayout(null); setPendingLayout(null);
setState((p) => { setState((p) => ({
// IMP-55 (#93) u12 — persist marker=false to disk on cancel. In-memory
// the u3 seed via createInitialUserSelection already pins false (u5
// contract), but if a prior drag-drop wrote `true` to disk via u6's
// co-PUT, that value would survive a reopen and re-arm the u7
// forwarding gate on the next page load. Symmetric with the apply
// path's disk PUT above.
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { manual_section_assignment: false });
}
return {
...p, ...p,
// IMP-55 (#93) u5 — cancel discards all pending overrides via
// `createInitialUserSelection`, whose u3 seed pins
// `manual_section_assignment: false`. In-memory reset is implicit
// via the seed; u12 adds the disk-side PUT above to keep persisted
// state consistent so a reopen does not re-arm the marker.
userSelection: createInitialUserSelection(p.slidePlan), userSelection: createInitialUserSelection(p.slidePlan),
}; }));
});
setHasPendingChanges(false); setHasPendingChanges(false);
}, []); }, []);
@@ -258,22 +180,7 @@ export default function Home() {
try { try {
const content = await parseMdxFile(file); const content = await parseMdxFile(file);
// IMP-52 u6 — restore-on-reopen. Key = MDX stem (matches backend setState((p) => ({ ...p, normalizedContent: content, isLoading: false }));
// u2 fallback's Path(args.mdx_path).stem). getUserOverrides returns
// {} on miss / corrupt / network failure (u5 contract) so the upload
// path never fails on a fresh MDX.
const overridesKey = deriveUserOverridesKey(file.name);
const persisted = await getUserOverrides(overridesKey);
persistedOverridesRef.current = persisted;
setState((p) => ({
...p,
normalizedContent: content,
// #98 Task 1 — selecting/uploading a MDX starts from a clean analysis
// state. Persisted overrides stay cached for explicit edit/regenerate
// paths, but they must not pre-seed the first "Generate" run.
userSelection: createInitialUserSelection(),
isLoading: false,
}));
toast.success(`"${file.name}" 분석 완료 — 하단 버튼으로 슬라이드 생성하세요.`); toast.success(`"${file.name}" 분석 완료 — 하단 버튼으로 슬라이드 생성하세요.`);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -284,11 +191,27 @@ export default function Home() {
} }
}, []); }, []);
// 2026-05-14 — 좌측 패널의 01~05 fix list 클릭 또는 URL `?mdx=04` 변경 시 // 2026-05-14 — 좌측 패널의 03/04/05 fix list 클릭 또는 URL `?mdx=04` 변경 시
// 호출되는 단일 callback. handleFileUpload 가 자동 분석 trigger. // 호출되는 단일 callback. handleFileUpload 가 자동 분석 trigger.
const [selectedSample, setSelectedSample] = useState<"01" | "02" | "03" | "04" | "05" | null>(null); const [selectedSample, setSelectedSample] = useState<"03" | "04" | "05" | null>(null);
const handleSelectSample = useCallback(async (which: "01" | "02" | "03" | "04" | "05") => { // 2026-05-14 — mdx 별 slide-level CSS override (catalog/template 무변, frontend layer only).
// SlideCanvas 의 iframe onLoad 에서 동적 inject. 사용자 룰 : "보고용 슬라이드 결과물 단위"
// 변경. mdx04 의 default (rank 1 = process_product_two_way) 일 때만 적용 — 사용자 frame
// override 후 (rank 2 = bim_dx_comparison_table 등) 다른 frame 시 무적용.
const MDX04_DEFAULT_OVERRIDE_CSS = `
.slide-body {
grid-template-rows: 0.38fr 0.60fr !important;
gap: 1.5% !important;
}
.f29b__cell .text-line + .text-line { margin-top: 1px !important; }
.f29b__cell:nth-child(n+3) {
padding-top: 3px !important;
margin-top: 2px !important;
}
`.trim();
const handleSelectSample = useCallback(async (which: "03" | "04" | "05") => {
try { try {
const res = await fetch(`/api/sample-mdx?mdx=${encodeURIComponent(which)}`); const res = await fetch(`/api/sample-mdx?mdx=${encodeURIComponent(which)}`);
if (!res.ok) return; if (!res.ok) return;
@@ -309,11 +232,11 @@ export default function Home() {
}, []); }, []);
// 페이지 첫 로드 시 데모용 mdx 자동 로드 — 상대방에게 mdx 파일 공유 안 해도 되게. // 페이지 첫 로드 시 데모용 mdx 자동 로드 — 상대방에게 mdx 파일 공유 안 해도 되게.
// URL query `?mdx=01`~`?mdx=05` 로 다른 sample 선택 가능. default = 03. // URL query `?mdx=04` / `?mdx=05` 로 다른 sample 선택 가능. default = 03.
// 사용자가 다른 파일을 직접 업로드하면 그것이 override 됨. // 사용자가 다른 파일을 직접 업로드하면 그것이 override 됨.
useEffect(() => { useEffect(() => {
const which = (new URLSearchParams(window.location.search).get("mdx") as "01" | "02" | "03" | "04" | "05" | null) || "03"; const which = (new URLSearchParams(window.location.search).get("mdx") as "03" | "04" | "05" | null) || "03";
if (!["01", "02", "03", "04", "05"].includes(which)) return; if (!["03", "04", "05"].includes(which)) return;
handleSelectSample(which); handleSelectSample(which);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -331,15 +254,12 @@ export default function Home() {
// - layout : userSelection.overrides.layout_preset (default 와 다를 때만) // - layout : userSelection.overrides.layout_preset (default 와 다를 때만)
// - frames : zone.section_ids → unit_id ("+".join). region.id 별 zone_frames lookup. // - frames : zone.section_ids → unit_id ("+".join). region.id 별 zone_frames lookup.
// pendingZones 의 region 도 동일 — 그 region 의 zone 의 sections 가 unit_id 결정. // pendingZones 의 region 도 동일 — 그 region 의 zone 의 sections 가 unit_id 결정.
const shouldUseUserOverrides = hasPendingChanges;
const overrides: PipelineOverrides = {}; const overrides: PipelineOverrides = {};
const sourcePlan = effectiveSlidePlan; const sourcePlan = effectiveSlidePlan;
if (shouldUseUserOverrides && sourcePlan && state.slidePlan) { if (sourcePlan && state.slidePlan) {
// 2026-05-22 demo hot-fix — 이전 비교 가드 (default !== override) 제거. const defaultLayout = state.slidePlan.layout_preset;
// restore loop 이 default = override 로 sync 시 override 안 보내고 backend
// default fallback 발생. user 가 명시한 layout 이 있으면 무조건 보냄.
const overrideLayout = state.userSelection.overrides.layout_preset; const overrideLayout = state.userSelection.overrides.layout_preset;
if (overrideLayout) { if (overrideLayout && overrideLayout !== defaultLayout) {
overrides.layout = overrideLayout; overrides.layout = overrideLayout;
} }
const frames: Record<string, string> = {}; const frames: Record<string, string> = {};
@@ -377,78 +297,41 @@ export default function Home() {
// zone-geometry override — backend 의 build_layout_css 에 전달 (horizontal-2 / // zone-geometry override — backend 의 build_layout_css 에 전달 (horizontal-2 /
// vertical-2 만 적용). zone_id (top/bottom/...) → slide-body 내부 0~1 비율. // vertical-2 만 적용). zone_id (top/bottom/...) → slide-body 내부 0~1 비율.
// IMP-44 (#73) u4 — validate against the active layout *before* the
// round-trip so foreign-preset keys never reach the backend. Mirrors
// the u1/u2 WARN+DROP guards on the frontend side: dropped keys surface
// as a toast (so the user knows why their resize "vanished"), and only
// the `kept` subset is forwarded. The active layout = the layout the
// backend will use, which is `overrides.layout` when the user has set
// one, else the default slidePlan preset (mirrors backend resolution).
const zoneGeometries = state.userSelection.overrides.zone_geometries; const zoneGeometries = state.userSelection.overrides.zone_geometries;
if (zoneGeometries && Object.keys(zoneGeometries).length > 0) { if (zoneGeometries && Object.keys(zoneGeometries).length > 0) {
const activeLayout = overrides.layout ?? sourcePlan.layout_preset; overrides.zoneGeometries = zoneGeometries;
const validation = validateZoneGeometriesAgainstLayout(
zoneGeometries,
activeLayout,
);
if (Object.keys(validation.dropped).length > 0) {
toast.error(
`zone_geometries layout-mismatch: dropped ${Object.keys(validation.dropped).join(", ")} (expected ${validation.expectedPositions.join(", ") || "—"}; layout=${activeLayout}).`,
);
}
if (Object.keys(validation.kept).length > 0) {
overrides.zoneGeometries = validation.kept;
}
} }
// IMP-55 (#93) u7 — Replace the IMP-08 B-3 self-compare with the bool // IMP-08 B-3 : zoneSections forward only when the user diverged from
// `manual_section_assignment` intent marker gate. The prior code built // the auto plan. Codex Stage 3 R3 B3 fix : `createInitialUserSelection`
// `defaultByZone` from `sourcePlan.zones` and compared against the // seeds `zone_sections` with the default placement, so a literal copy
// user's `overrides.zone_sections`, but `sourcePlan === effectiveSlidePlan` // would pollute backend assignment-source provenance even on a fresh
// (Home.tsx:305) and `effectiveSlidePlan.zones === pendingZones` // re-render. Diff against `sourcePlan.zones[].section_ids` per zone and
// (Home.tsx:649), which is itself derived from // only emit zones whose section list differs.
// `state.userSelection.overrides.zone_sections` via slidePlanUtils.ts.
// The comparison was degenerate (user input vs itself), so real drag-drop
// swaps were classified `sameAsDefault` and silently dropped from
// `overrides.zoneSections` — the exact regression IMP-55 fixes.
// - true → forward `zone_sections` filtered to zone_ids that exist in
// `sourcePlan.zones` (cross-layout safety so foreign zone keys from a
// stale persisted layout never reach backend `--override-section-
// assignment`). u6 is the SOLE setter of true (real drag-drop).
// - false → skip. Backend determines assignment from its own default
// policy. u3 seeds false on first load, u5 resets false on layout
// apply auto-carry, u12 persists false so a stale disk `true` cannot
// survive a reopen-after-apply window.
// No `sameAsDefault` heuristic — the marker is the source of intent.
const manualMarker =
state.userSelection.overrides.manual_section_assignment;
if (manualMarker === true) {
const userZoneSections = state.userSelection.overrides.zone_sections; const userZoneSections = state.userSelection.overrides.zone_sections;
if (userZoneSections) { if (userZoneSections) {
const validZoneIds = new Set( const defaultByZone = new Map<string, string[]>();
sourcePlan.zones.map((z) => z.zone_id), sourcePlan.zones.forEach((z) => {
); defaultByZone.set(z.zone_id, z.section_ids);
const zoneSectionsForward: Record<string, string[]> = {}; });
const zoneSectionsDiff: Record<string, string[]> = {};
for (const [zoneId, sids] of Object.entries(userZoneSections)) { for (const [zoneId, sids] of Object.entries(userZoneSections)) {
if (!validZoneIds.has(zoneId)) continue;
if (!Array.isArray(sids)) continue; if (!Array.isArray(sids)) continue;
const cleaned = sids.filter( const cleaned = sids.filter((s) => typeof s === "string" && s.trim());
(s) => typeof s === "string" && s.trim(), const defaults = defaultByZone.get(zoneId) ?? [];
); const sameAsDefault =
zoneSectionsForward[zoneId] = cleaned; cleaned.length === defaults.length &&
cleaned.every((sid, i) => sid === defaults[i]);
if (!sameAsDefault) {
zoneSectionsDiff[zoneId] = cleaned;
} }
if (Object.keys(zoneSectionsForward).length > 0) {
overrides.zoneSections = zoneSectionsForward;
} }
if (Object.keys(zoneSectionsDiff).length > 0) {
overrides.zoneSections = zoneSectionsDiff;
} }
} }
} }
// #98 Task 4 — stale render guard. Once a new generate/regenerate starts,
// the previous final.html must no longer be treated as the current result.
// If the backend run later fails, keeping the old runMeta would resurrect
// the old iframe and make it look as if the new selection rendered.
setRunMeta(null);
setState((p) => ({ ...p, isLoading: true })); setState((p) => ({ ...p, isLoading: true }));
setHasPendingChanges(false); // 재생성 트리거 시 override pending flag reset setHasPendingChanges(false); // 재생성 트리거 시 override pending flag reset
setPendingLayout(null); // pending layout 모드 종료 setPendingLayout(null); // pending layout 모드 종료
@@ -466,36 +349,7 @@ export default function Home() {
toast.info(`Phase Z 파이프라인 실행 중... ${overrideSummary}`); toast.info(`Phase Z 파이프라인 실행 중... ${overrideSummary}`);
try { try {
// IMP-52 u10 — Force-commit any pending debounced PUTs before backend const result = await runPipeline(state.uploadedFile, overrides);
// reads user_overrides.json on pipeline entry. Without this, a user
// who changes an override (300ms debounce window) and immediately
// clicks Generate would race the PUT against /api/run; the u2
// fallback could then load a stale persisted document.
await flushUserOverrides();
const restoreOverrides = shouldUseUserOverrides
? mergeSubmittedPipelineOverridesForRestore(
await getUserOverrides(deriveUserOverridesKey(state.uploadedFile.name)),
overrides,
state.userSelection.overrides.manual_section_assignment,
)
: {};
if (shouldUseUserOverrides) {
persistedOverridesRef.current = restoreOverrides;
}
// IMP-42 u4 — unconditional DIAG console.log on the handleGenerate
// entry-to-backend boundary. Surfaces the override payload + uploaded
// file name so the user can see exactly what crossed the wire when
// the pipeline fails silently. No env gate (silence is the bug).
console.log("[DIAG raw overrides]", {
file: state.uploadedFile.name,
overrides,
});
const result = await runPipeline(
state.uploadedFile,
shouldUseUserOverrides ? overrides : undefined,
undefined,
{ ignoreUserOverrides: !shouldUseUserOverrides },
);
if (!result.success || !result.final_html_exists) { if (!result.success || !result.final_html_exists) {
const detail = const detail =
@@ -508,49 +362,20 @@ export default function Home() {
} }
const { normalizedContent, slidePlan, runMeta } = await loadRun(result.run_id); const { normalizedContent, slidePlan, runMeta } = await loadRun(result.run_id);
// IMP-52 u6 — post-loadRun frame remap. persistedOverridesRef holds setState((p) => ({
// the user_overrides.json read at handleFileUpload time. Frames there
// are keyed by unit_id (section_ids joined by "+"); the in-memory
// zone_frames is keyed by region.id. Remap against the new slidePlan
// zones so SlideCanvas's override-vs-default preview indicator shows
// the user's persisted choice without forcing them to re-click.
const restoredZoneFrames = shouldUseUserOverrides
? remapPersistedFramesToZoneFrames(
slidePlan,
restoreOverrides.frames as Record<string, string> | undefined,
)
: {};
setState((p) => {
// IMP-52 u6 — restore-on-reopen: re-layer the persisted non-frame
// axes (layout / zone_geometries / zone_sections) onto the post-load
// `base`. `createInitialUserSelection` rebuilds from slidePlan and
// drops anything the backend fallback could not round-trip through
// a CLI arg — `zone_geometries` in particular has no slidePlan
// representation, so without this merge the user would see their
// resized zones revert on every Generate.
const cleanBase = createInitialUserSelection(slidePlan);
const base = shouldUseUserOverrides
? applyPersistedNonFrameOverrides(cleanBase, restoreOverrides)
: cleanBase;
return {
...p, ...p,
normalizedContent, normalizedContent,
slidePlan, slidePlan,
userSelection: { userSelection: createInitialUserSelection(slidePlan),
...base,
overrides: {
...base.overrides,
zone_frames: { ...base.overrides.zone_frames, ...restoredZoneFrames },
},
},
isLoading: false, isLoading: false,
}; }));
});
setRunMeta(runMeta); setRunMeta(runMeta);
if (runMeta.status !== "PASS" || (runMeta.pipeline_trace?.warnings.length ?? 0) > 0) {
setRightTab("trace");
}
toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`); toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`);
// IMP-47B u11 — surface Step 12 AI repair failure axes (error /
// coverage_violated / unsupported_kind) as a human_review notification.
// Auto-pipeline first ([[feedback_auto_pipeline_first]]): no review_queue
// insertion — just an explicit error toast directing the user to pick
// another frame or edit manually. Helper returns null on success path.
const aiReviewMsg = formatAiRepairHumanReviewMessage(runMeta.ai_repair_status); const aiReviewMsg = formatAiRepairHumanReviewMessage(runMeta.ai_repair_status);
if (aiReviewMsg) toast.error(aiReviewMsg); if (aiReviewMsg) toast.error(aiReviewMsg);
} catch (err) { } catch (err) {
@@ -560,46 +385,16 @@ export default function Home() {
); );
setState((p) => ({ ...p, isLoading: false })); setState((p) => ({ ...p, isLoading: false }));
} }
}, [state.uploadedFile, state.slidePlan, state.userSelection, pendingZones, pendingLayout, hasPendingChanges]); }, [state.uploadedFile, state.slidePlan, state.userSelection, pendingZones, pendingLayout]);
// ── 섹션 드래그 앤 드롭 (Zone으로 재배치) ── // ── 섹션 드래그 앤 드롭 (Zone으로 재배치) ──
const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => { const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => {
setState((p) => { setState((p) => {
const newSelection = moveSectionToZone(p.userSelection, sectionId, zoneId); const newSelection = moveSectionToZone(p.userSelection, sectionId, zoneId);
const zoneSelected = selectZone(newSelection, zoneId); // 이동된 존 자동 선택 return {
// IMP-55 (#93) u6 — flip the bool intent marker to `true` on real ...p,
// user drag-drop. Inverse of the u5 reset (layout apply/cancel userSelection: selectZone(newSelection, zoneId) // 이동된 존 자동 선택
// auto-carry → false). handleGenerate (u7) gates `overrides.zoneSections`
// forwarding on this marker, so an unflipped drop would never reach
// the backend (the IMP-55 self-compare regression). The marker is
// flipped BEFORE persistence so the in-memory selection and the
// co-PUT body stay in sync atomically.
const finalSelection = {
...zoneSelected,
overrides: {
...zoneSelected.overrides,
manual_section_assignment: true,
},
}; };
// IMP-52 u7 — persist the post-drop zone_sections snapshot. The on-disk
// schema axis (`zone_sections`) shares the in-memory shape (zone_id →
// section_ids), so we forward the full mutated value; the u4 PUT path
// replaces this axis atomically while preserving the foreign axes.
// p.uploadedFile gate skips persistence before any MDX is loaded —
// the demo-mode initial render path would otherwise PUT to the empty
// key. saveUserOverrides is debounced (300ms) and per-key coalesced.
// IMP-55 (#93) u6 — co-PUT `manual_section_assignment: true` in the
// SAME body so the disk file never has the post-drop zone_sections
// without the marker (would otherwise look like an unmotivated
// IMP-52 zone_sections write to the u9 backend fallback).
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, {
zone_sections: finalSelection.overrides.zone_sections,
manual_section_assignment: true,
});
}
return { ...p, userSelection: finalSelection };
}); });
setRightTab("frame"); setRightTab("frame");
setHasPendingChanges(true); setHasPendingChanges(true);
@@ -625,18 +420,10 @@ export default function Home() {
// ── Layout 선택 ── // ── Layout 선택 ──
const handleLayoutSelect = useCallback((layoutId: string) => { const handleLayoutSelect = useCallback((layoutId: string) => {
setState((p) => { setState((p) => ({
const newSelection = applyLayout(p.userSelection, layoutId as LayoutPresetId); ...p,
// IMP-52 u7 — persist the selected layout preset id. The on-disk userSelection: applyLayout(p.userSelection, layoutId as LayoutPresetId)
// `layout` axis is a single string; `applyLayout` validates the }));
// preset id before mutating the selection, so the value here is
// already the LayoutPresetId we want to round-trip.
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { layout: layoutId });
}
return { ...p, userSelection: newSelection };
});
setHasPendingChanges(true); setHasPendingChanges(true);
}, []); }, []);
@@ -649,64 +436,22 @@ export default function Home() {
}, []); }, []);
const handleZoneResize = useCallback((geometries: Record<string, { x: number; y: number; w: number; h: number }>) => { const handleZoneResize = useCallback((geometries: Record<string, { x: number; y: number; w: number; h: number }>) => {
setState((p) => { setState((p) => ({
const mergedGeometries = {
...p.userSelection.overrides.zone_geometries,
...geometries,
};
// IMP-52 u7 — persist the merged zone_geometries snapshot. Resize
// gestures fire repeatedly during a drag; the 300ms u5 debounce
// collapses them into a single PUT at gesture-end, so we don't
// need to gate on resize-finished here.
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { zone_geometries: mergedGeometries });
}
return {
...p, ...p,
userSelection: { userSelection: {
...p.userSelection, ...p.userSelection,
overrides: { overrides: {
...p.userSelection.overrides, ...p.userSelection.overrides,
zone_geometries: mergedGeometries, zone_geometries: {
}, ...p.userSelection.overrides.zone_geometries,
}, ...geometries
}; }
}); }
}
}));
setHasPendingChanges(true); setHasPendingChanges(true);
}, []); }, []);
// IMP-51 (#79) u10 — wire SlideCanvas's user-content image drag/resize
// emit into the 5th persisted axis. Mirrors handleZoneResize exactly:
// • merge the single (imageId → {x,y,w,h}) tick onto the prior
// in-memory `image_overrides` map via the u11 `saveImageOverride`
// helper so the immutable update path is shared with the test suite,
// • forward the full merged snapshot through `saveUserOverrides`
// (the u3 typed client) under the `image_overrides` key — the 300ms
// debounce defined alongside `zone_geometries` collapses the
// per-mousemove emits into one PUT at gesture-end,
// • flip `hasPendingChanges` so the "선택대로 재생성하기" CTA appears.
// Coordinates are slide-absolute percent (0100) from u8/u9 — passed
// through unchanged so the on-disk schema matches the SlideCanvas
// overlay, the stamper selector (u4), and the render-time CSS
// injector (u7) without any per-zone transform.
const handleImageResize = useCallback(
(imageId: string, geometry: { x: number; y: number; w: number; h: number }) => {
setState((p) => {
const nextSelection = saveImageOverride(p.userSelection, imageId, geometry);
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, {
image_overrides: nextSelection.overrides.image_overrides,
});
}
return { ...p, userSelection: nextSelection };
});
setHasPendingChanges(true);
},
[],
);
// 편집 모드 텍스트 변경 시 hasPendingChanges 활성. useCallback 으로 reference 안정화 — // 편집 모드 텍스트 변경 시 hasPendingChanges 활성. useCallback 으로 reference 안정화 —
// SlideCanvas 의 useEffect 가 매번 rerun 안 하도록 (resize drag 매 mousemove 마다 // SlideCanvas 의 useEffect 가 매번 rerun 안 하도록 (resize drag 매 mousemove 마다
// re-render 시 useEffect retrigger → iframe contentEditable 재설정 = 매우 느림). // re-render 시 useEffect retrigger → iframe contentEditable 재설정 = 매우 느림).
@@ -714,51 +459,6 @@ export default function Home() {
setHasPendingChanges(true); setHasPendingChanges(true);
}, []); }, []);
// IMP-56 (#90) u15 — wire SlideCanvas u13 focusout capture into the new
// `text_overrides` persist axis. Mirrors handleImageResize: merge the
// (zoneId, textPath, value) tick via `saveTextOverride` (u15 pure helper)
// and schedule the 300ms-debounced PUT under the `text_overrides` axis.
// Per-axis coalescing in `saveUserOverrides` collapses rapid edits in
// the same line into a single PUT; per-key buckets isolate cross-MDX.
const handleTextEdit = useCallback(
(capture: { zoneId: string; textPath: string; value: string }) => {
setState((p) => {
const nextSelection = saveTextOverride(
p.userSelection, capture.zoneId, capture.textPath, capture.value,
);
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, {
text_overrides: nextSelection.overrides.text_overrides,
});
}
return { ...p, userSelection: nextSelection };
});
setHasPendingChanges(true);
},
[],
);
// IMP-56 (#90) u15 — wire SlideCanvas u14 structure overlay capture into
// the `structure_overrides` axis. Scope-locked to {slot_order,
// hidden_slots} — frame swap stays on the existing `frames` axis.
const handleStructureEdit = useCallback(
(zoneId: string, perZone: { slot_order?: string[]; hidden_slots?: string[] }) => {
setState((p) => {
const nextSelection = saveStructureOverride(p.userSelection, zoneId, perZone);
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, {
structure_overrides: nextSelection.overrides.structure_overrides,
});
}
return { ...p, userSelection: nextSelection };
});
setHasPendingChanges(true);
},
[],
);
// pending mode 일 때 effectiveSlidePlan = pendingZones 가 swap 된 plan. // pending mode 일 때 effectiveSlidePlan = pendingZones 가 swap 된 plan.
// 그 외 = state.slidePlan. 모든 zone / region lookup 이 일관되게 이걸 사용 → // 그 외 = state.slidePlan. 모든 zone / region lookup 이 일관되게 이걸 사용 →
// pending mode 의 region.id ("pending-region-N") 가 zone_frames key 로 들어가 // pending mode 의 region.id ("pending-region-N") 가 zone_frames key 로 들어가
@@ -770,6 +470,17 @@ export default function Home() {
return state.slidePlan; return state.slidePlan;
}, [pendingZones, state.slidePlan, pendingLayout]); }, [pendingZones, state.slidePlan, pendingLayout]);
// 2026-05-14 — slide-level CSS override 계산. mdx04 default (rank 1 = process_product_two_way)
// 일 때만 적용 (catalog 무변, slide 결과물에만 inject). 사용자 frame override 후 다른
// frame 시 무적용 (rank 2 의 frame visual 유지).
const slideOverrideCss = useMemo<string | undefined>(() => {
if (selectedSample !== "04") return undefined;
const zone04_2 = state.slidePlan?.zones.find((z) => z.zone_id === "bottom");
const frameId = zone04_2?.internal_regions[0]?.frame_match_strategy.frame_id;
if (frameId !== "process_product_two_way") return undefined;
return MDX04_DEFAULT_OVERRIDE_CSS;
}, [selectedSample, state.slidePlan]);
// ── Frame 선택 ── // ── Frame 선택 ──
const handleFrameSelect = useCallback((frameId: string) => { const handleFrameSelect = useCallback((frameId: string) => {
const zone = getSelectedZone(effectiveSlidePlan, state.userSelection); const zone = getSelectedZone(effectiveSlidePlan, state.userSelection);
@@ -780,40 +491,10 @@ export default function Home() {
return; return;
} }
setState((p) => { setState((p) => ({
const newSelection = applyFrame(p.userSelection, region.id, frameId); ...p,
// IMP-52 u7 — persist frames keyed by `unit_id`. The on-disk schema userSelection: applyFrame(p.userSelection, region.id, frameId)
// uses `unit_id = zone.section_ids.join("+")` (the same convention }));
// handleGenerate uses when forwarding `overrides.frames` to the
// backend CLI). `zone_frames` is keyed by region.id, so we walk
// the effectiveSlidePlan zones to translate. Only true user
// overrides are persisted — `createInitialUserSelection` pre-fills
// `zone_frames[region.id]` with `region.frame_match_strategy.frame_id`
// (backend default) for every region, so we mirror handleGenerate's
// `overrideFrameId !== defaultFrameId` gate to avoid leaking defaults
// into user_overrides.json. Zones with no sections are skipped.
if (p.uploadedFile && effectiveSlidePlan) {
const framesByUnitId: Record<string, string> = {};
for (const z of effectiveSlidePlan.zones) {
const r = z.internal_regions[0];
if (!r) continue;
if (!Array.isArray(z.section_ids) || z.section_ids.length === 0) continue;
const unitId = z.section_ids.join("+");
const overrideId = newSelection.overrides.zone_frames?.[r.id];
const defaultFrameId = r.frame_match_strategy.frame_id;
if (
typeof overrideId === "string" &&
overrideId.length > 0 &&
overrideId !== defaultFrameId
) {
framesByUnitId[unitId] = overrideId;
}
}
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { frames: framesByUnitId });
}
return { ...p, userSelection: newSelection };
});
setHasPendingChanges(true); setHasPendingChanges(true);
}, [effectiveSlidePlan, state.userSelection]); }, [effectiveSlidePlan, state.userSelection]);
@@ -879,16 +560,6 @@ export default function Home() {
</div> </div>
</details> </details>
)} )}
{(runMeta.pipeline_trace?.warnings.length ?? 0) > 0 && (
<button
type="button"
className="text-[10px] font-bold px-1.5 py-0.5 bg-red-100 text-red-700 rounded uppercase tracking-wider"
onClick={() => setRightTab("trace")}
title={runMeta.pipeline_trace?.warnings.join(" / ")}
>
Trace: {runMeta.pipeline_trace?.warnings.length}
</button>
)}
</> </>
)} )}
</div> </div>
@@ -939,7 +610,8 @@ export default function Home() {
slidePlan={effectiveSlidePlan} slidePlan={effectiveSlidePlan}
normalizedContent={state.normalizedContent} normalizedContent={state.normalizedContent}
userSelection={state.userSelection} userSelection={state.userSelection}
finalHtmlUrl={state.isLoading ? undefined : runMeta?.final_html_url} finalHtmlUrl={runMeta?.final_html_url}
slideOverrideCss={slideOverrideCss}
isPipelineRunning={state.isLoading} isPipelineRunning={state.isLoading}
isPendingLayout={!!pendingLayout} isPendingLayout={!!pendingLayout}
pendingLayoutId={pendingLayout} pendingLayoutId={pendingLayout}
@@ -955,11 +627,6 @@ export default function Home() {
onSectionDrop={handleSectionDrop} onSectionDrop={handleSectionDrop}
onLayoutResize={handleLayoutResize} onLayoutResize={handleLayoutResize}
onZoneResize={handleZoneResize} onZoneResize={handleZoneResize}
imageOverrides={state.userSelection.overrides.image_overrides}
onImageResize={handleImageResize}
onTextEdit={handleTextEdit}
structureOverrides={state.userSelection.overrides.structure_overrides}
onStructureEdit={handleStructureEdit}
/> />
</main> </main>
@@ -968,7 +635,6 @@ export default function Home() {
<div className="flex-shrink-0 flex p-1 bg-slate-50 border-b border-slate-200 m-2 rounded-lg"> <div className="flex-shrink-0 flex p-1 bg-slate-50 border-b border-slate-200 m-2 rounded-lg">
<button className={`flex-1 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-md transition-all ${rightTab === "frame" ? "bg-white text-blue-600 shadow-sm" : "text-slate-400"}`} onClick={() => setRightTab("frame")}>Frame</button> <button className={`flex-1 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-md transition-all ${rightTab === "frame" ? "bg-white text-blue-600 shadow-sm" : "text-slate-400"}`} onClick={() => setRightTab("frame")}>Frame</button>
<button className={`flex-1 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-md transition-all ${rightTab === "layout" ? "bg-white text-blue-600 shadow-sm" : "text-slate-400"}`} onClick={() => setRightTab("layout")}>Layout</button> <button className={`flex-1 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-md transition-all ${rightTab === "layout" ? "bg-white text-blue-600 shadow-sm" : "text-slate-400"}`} onClick={() => setRightTab("layout")}>Layout</button>
<button className={`flex-1 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-md transition-all ${rightTab === "trace" ? "bg-white text-blue-600 shadow-sm" : "text-slate-400"}`} onClick={() => setRightTab("trace")}>Trace</button>
</div> </div>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
{rightTab === "frame" ? ( {rightTab === "frame" ? (
@@ -980,7 +646,7 @@ export default function Home() {
onFrameSelect={handleFrameSelect} onFrameSelect={handleFrameSelect}
onNoDesignToggle={() => {}} onNoDesignToggle={() => {}}
/> />
) : rightTab === "layout" ? ( ) : (
<LayoutPanel <LayoutPanel
selectedZone={selectedZone} selectedZone={selectedZone}
userSelection={state.userSelection} userSelection={state.userSelection}
@@ -990,8 +656,6 @@ export default function Home() {
pipelineSelectedLayoutId={state.slidePlan?.layout_preset} pipelineSelectedLayoutId={state.slidePlan?.layout_preset}
pendingLayoutId={pendingLayout} pendingLayoutId={pendingLayout}
/> />
) : (
<PipelineTracePanel trace={runMeta?.pipeline_trace} aiTrace={runMeta?.ai_trace} />
)} )}
</div> </div>
</aside> </aside>
@@ -1005,13 +669,11 @@ export default function Home() {
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-tighter">Phase Z Engine Active</span> <span className="text-[10px] font-bold text-slate-500 uppercase tracking-tighter">Phase Z Engine Active</span>
</div> </div>
</div> </div>
<BottomActions <div className="flex items-center gap-3">
slidePlan={state.slidePlan} <Button variant="outline" onClick={() => toast.info("연동하기 기능은 준비 중입니다.")} className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"><Link2 className="w-3.5 h-3.5" />Connect</Button>
runMeta={runMeta} <Button variant="outline" onClick={() => toast.info("다운로드 기능은 준비 중입니다.")} disabled={!state.slidePlan} className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"><Download className="w-3.5 h-3.5" />Download</Button>
uploadedFile={state.uploadedFile} <Button onClick={() => toast.success("슬라이드 설정이 확정되었습니다.")} disabled={!state.slidePlan || state.isLoading} className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest bg-slate-900 hover:bg-slate-800"><Sparkles className="w-3.5 h-3.5" />Finalize Slide</Button>
isLoading={state.isLoading} </div>
onGenerate={handleGenerate}
/>
</footer> </footer>
</div> </div>
); );
@@ -1,64 +0,0 @@
// ─── IMP-41 u2 — application_mode helper (issue #70) ────────────────────────
// Pure deterministic helpers for forwarding backend Step 9
// `unit.application_candidates[]` to the FramePanel V4-label badge tooltip.
//
// Keyed by backend `application_mode` VALUE (NOT V4 label) — preserves the
// AI-isolation contract: tooltip text is a read-only display of backend
// authority, never re-derived on the frontend from V4 label.
//
// Source of truth = src/phase_z2_pipeline.py APPLICATION_MODE_BY_V4_LABEL
// (:107-112) emitted via _application_candidates_for_unit() (:3071-3092)
// onto unit.application_candidates[] in step09_application_plan.json.
/** Backend application_mode enumeration (verbatim from APPLICATION_MODE_BY_V4_LABEL). */
export type ApplicationMode =
| 'direct_insert'
| 'same_frame_with_adjustment'
| 'layout_or_region_change'
| 'exclude';
/** Korean consequence phrases per issue #70 spec item #2. Keyed by mode VALUE. */
export const APPLICATION_MODE_TOOLTIP_KR: Record<ApplicationMode, string> = {
direct_insert: '코드 직접 적용',
same_frame_with_adjustment: 'AI 보강 필요',
layout_or_region_change: 'AI restructure 필요',
exclude: 'render path 제외',
};
/**
* Compose the V4-label badge tooltip title. When `applicationMode` resolves
* to a known mode the title shows the Korean consequence + raw mode token;
* otherwise (undefined or unknown — legacy fixtures pre-IMP-32) it falls
* back to the raw V4 label string per Stage 2 contract.
*/
export function buildBadgeTitle(
label: string,
applicationMode: string | undefined,
): string {
const consequence = applicationMode
? APPLICATION_MODE_TOOLTIP_KR[applicationMode as ApplicationMode]
: undefined;
return consequence
? `${consequence} (${applicationMode})`
: `V4 label: ${label}`;
}
/**
* Build a Map<template_id, applicationCandidate> from a Step 9
* `unit.application_candidates[]` array. Entries with a non-string or empty
* `template_id` are skipped. First occurrence wins on duplicate keys.
* Pure — does NOT sort, slice, or filter by label/confidence.
*/
export function mergeApplicationCandidates(
applicationCandidates: unknown,
): Map<string, any> {
const out = new Map<string, any>();
if (!Array.isArray(applicationCandidates)) return out;
for (const ac of applicationCandidates) {
const key = (ac as any)?.template_id;
if (typeof key === 'string' && key.length > 0 && !out.has(key)) {
out.set(key, ac);
}
}
return out;
}
+50 -640
View File
@@ -20,8 +20,6 @@ import {
MOCK_FRAME_CANDIDATES_SECTION1, MOCK_FRAME_CANDIDATES_SECTION1,
} from "../data/mockDesignAgentData"; } from "../data/mockDesignAgentData";
import { mergeApplicationCandidates } from "./applicationMode";
/** 네트워크 지연 시뮬레이션 */ /** 네트워크 지연 시뮬레이션 */
const simulateDelay = (ms: number = 800) => const simulateDelay = (ms: number = 800) =>
new Promise((resolve) => setTimeout(resolve, ms)); new Promise((resolve) => setTimeout(resolve, ms));
@@ -225,6 +223,10 @@ export interface FilteredSectionReason {
position?: string | null; position?: string | null;
} }
// IMP-47B u11 — verbatim mirror of step20_slide_status.ai_repair_status (u8 schema).
// Surfaces Step 12 AI repair outcomes so the frontend can render a
// human_review notification when AI proposal validation, coverage, or call
// itself failed. Enum / field names kept verbatim — no frontend redefinition.
export interface AiRepairStatus { export interface AiRepairStatus {
status: "ok" | "applied" | "unsupported_kind" | "coverage_violated" | "error" | string; status: "ok" | "applied" | "unsupported_kind" | "coverage_violated" | "error" | string;
counts: { counts: {
@@ -235,15 +237,6 @@ export interface AiRepairStatus {
unsupported_kind: number; unsupported_kind: number;
error: number; error: number;
}; };
// IMP-92 u3 — per-kind operational error aggregates plumbed from Step 12
// (u2 classify_operational_error). Optional for backward compatibility
// with pre-u3 payloads — u5 formatter treats absence as silent.
api_error_kinds?: {
quota: number;
billing: number;
auth: number;
other: number;
};
unsupported_kind_records: Array<{ unsupported_kind_records: Array<{
unit_index?: number | null; unit_index?: number | null;
source_section_ids: string[]; source_section_ids: string[];
@@ -253,8 +246,6 @@ export interface AiRepairStatus {
unit_index?: number | null; unit_index?: number | null;
source_section_ids: string[]; source_section_ids: string[];
error: string; error: string;
// IMP-92 u3 — per-record operational error kind (quota|billing|auth|other|null).
api_error_kind?: string | null;
}>; }>;
coverage_status: string; coverage_status: string;
dropped_section_ids: string[]; dropped_section_ids: string[];
@@ -275,103 +266,38 @@ export interface RunMeta {
layout_candidates: string[]; // step07 layout_candidates list layout_candidates: string[]; // step07 layout_candidates list
region_layout_candidates_by_zone: Record<string, string[]>; // step08 placeholder region_layout_candidates_by_zone: Record<string, string[]>; // step08 placeholder
display_strategy_candidates_by_zone: Record<string, string[]>; // step08 placeholder display_strategy_candidates_by_zone: Record<string, string[]>; // step08 placeholder
/** IMP-47B u11 — Step 12 AI repair outcome (u8 surfacing). null when
* step20 omits the field (legacy runs / pipeline aborted before Step 12). */
ai_repair_status: AiRepairStatus | null; ai_repair_status: AiRepairStatus | null;
pipeline_trace: PipelineTraceSummary | null;
ai_trace: AiTraceSummary | null;
} }
export interface PipelineTraceSection { /**
id: string; * IMP-47B u11 — Build the human_review notification text when Step 12 AI repair
title: string; * reports a failure axis. Returns null when no notification is needed (success,
child_ids: string[]; * no AI invocation, or human_review_required=false). Pure function — no DOM, no
state: "rendered" | "render_blocked" | "filtered" | "covered_not_rendered" | "uncovered"; * toast side-effect — so it can be unit-tested without React Testing Library.
reasons: string[]; *
} * Failure axes mapped to user-facing text (verbatim policy from
* IMP-47B #76 guardrail: "AI 호출 실패 / proposal validation 실패 / coverage 미달
export interface PipelineTraceUnit { * → frontend 에 명확한 notification").
unit_id: string; */
source_section_ids: string[];
merge_type: string | null;
selected_frame: string | null;
label: string | null;
candidate_count: number;
selection_path: string | null;
warnings: string[];
}
export interface PipelineTraceZone {
position: string;
source_section_ids: string[];
template_id: string | null;
slot_status: "filled" | "empty";
slot_key_count: number;
warnings: string[];
}
export interface PipelineTraceSummary {
sections: PipelineTraceSection[];
units: PipelineTraceUnit[];
zones: PipelineTraceZone[];
status: string;
warnings: string[];
}
export interface AiTraceUnit {
unit_index: number | null;
source_section_ids: string[];
frame_template_id: string | null;
route_hint: string | null;
provisional: boolean;
ai_called: boolean;
skip_reason: string | null;
apply_status: string | null;
api_error_kind: string | null;
error: string | null;
}
export interface AiTraceSummary {
artifact_present: boolean;
ai_enabled: boolean | null;
status: string;
ai_called_count: number;
eligible_count: number;
skipped_count: number;
error_count: number;
skip_reasons: Record<string, number>;
provider: string | null;
model: string | null;
coverage_status: string | null;
human_review_required: boolean;
units: AiTraceUnit[];
warnings: string[];
}
// IMP-92 u5 — Operational-only AI repair message formatter.
//
// Per the #84 operational-vs-non-operational replacement-plan contract, this
// returns a user-visible toast string ONLY when ai_repair_status carries one
// of the three actionable Anthropic API error kinds plumbed by u3
// (quota / billing / auth). Non-operational AI failures (validation,
// coverage_violated, unsupported_kind, or generic "other" API errors) return
// null so the auto-pipeline stays silent per feedback_auto_pipeline_first.
// Messages mirror the issue body copy contract exactly (429/402/401 →
// quota/billing/auth Korean strings).
export function formatAiRepairHumanReviewMessage( export function formatAiRepairHumanReviewMessage(
ai: AiRepairStatus | null | undefined, ai: AiRepairStatus | null | undefined,
): string | null { ): string | null {
if (!ai) return null; if (!ai || !ai.human_review_required) return null;
const kinds = ai.api_error_kinds; if (ai.status === "error") {
if (!kinds) return null; const n = ai.counts?.error ?? ai.error_records?.length ?? 0;
if (kinds.quota > 0) { return `AI 재구성 호출 실패 (${n}건) — 다른 frame 선택 또는 수동 편집 필요`;
return `API quota 부족 — 충전 필요 (${kinds.quota}건)`;
} }
if (kinds.billing > 0) { if (ai.status === "coverage_violated") {
return `API billing 문제 — 결제 정보 확인 (${kinds.billing}건)`; const dropped = (ai.dropped_section_ids || []).join(", ");
return `AI 재구성 후 콘텐츠 누락 (dropped: ${dropped || "?"}) — 다른 frame 선택 또는 수동 편집 필요`;
} }
if (kinds.auth > 0) { if (ai.status === "unsupported_kind") {
return `API key 무효 — .env 확인 (${kinds.auth}건)`; const n = ai.counts?.unsupported_kind ?? ai.unsupported_kind_records?.length ?? 0;
return `AI 제안 형식 미지원 (${n}건) — 다른 frame 선택 또는 수동 편집 필요`;
} }
return null; return `AI 재구성 human_review 필요 (status: ${ai.status})`;
} }
export interface LoadRunResult { export interface LoadRunResult {
@@ -413,27 +339,13 @@ export interface PipelineOverrides {
export async function runPipeline( export async function runPipeline(
file: File, file: File,
overrides?: PipelineOverrides, overrides?: PipelineOverrides
// IMP-43 (#72) u6 — optional prev RUN_ID for incremental rerun. When set,
// the vite plugin forwards `--reuse-from <PREV_RUN_ID>` to the backend
// and the pipeline resumes at Step 7 (Step 0/1/2/5/6 artifacts copied
// from the prior run). When omitted / empty, the POST body is
// byte-identical to pre-u6 (no reuseFromRunId key → no flag forwarded).
reuseFromRunId?: string,
options?: { ignoreUserOverrides?: boolean },
): Promise<RunPipelineResult> { ): Promise<RunPipelineResult> {
const content = await file.text(); const content = await file.text();
const body: Record<string, unknown> = {
filename: file.name,
content,
overrides,
};
if (reuseFromRunId) body.reuseFromRunId = reuseFromRunId;
if (options?.ignoreUserOverrides) body.ignoreUserOverrides = true;
const res = await fetch("/api/run", { const res = await fetch("/api/run", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify({ filename: file.name, content, overrides }),
}); });
const data = (await res.json()) as RunPipelineResult; const data = (await res.json()) as RunPipelineResult;
if (!res.ok && !data.run_id) { if (!res.ok && !data.run_id) {
@@ -509,417 +421,12 @@ function classifyConfidence(score: number): "high" | "medium" | "low" {
return "low"; return "low";
} }
const TOP_N_FRAMES = 6;
function getCandidateTemplateId(c: any): string | null {
const id = c?.template_id ?? c?.frame_template_id ?? c?.id;
return typeof id === "string" && id.length > 0 ? id : null;
}
function normalizeRawCandidate(c: any): any | null {
const templateId = getCandidateTemplateId(c);
if (!templateId) return null;
return {
...c,
template_id: templateId,
label: c.label ?? c.v4_label,
confidence: c.confidence ?? c.score,
rank: c.rank ?? c.v4_rank,
};
}
function mergeMissingCandidateFields(existing: any, incoming: any): any {
const merged = { ...existing };
for (const [key, value] of Object.entries(incoming)) {
if (value === undefined || value === null || value === "") continue;
if (merged[key] === undefined || merged[key] === null || merged[key] === "") {
merged[key] = value;
}
}
return merged;
}
function pushCandidate(candidateMap: Map<string, any>, c: any): void {
const normalized = normalizeRawCandidate(c);
if (!normalized) return;
const key = normalized.template_id;
const prior = candidateMap.get(key);
candidateMap.set(key, prior ? mergeMissingCandidateFields(prior, normalized) : normalized);
}
function candidatesMatchingTemplate(unit: any, templateId: string | null): any[] {
if (!templateId) return [];
const sources = [
unit?.sorted_candidate_evidence,
unit?.candidate_evidence,
unit?.v4_candidates,
unit?.v4_all_judgments,
unit?.application_candidates,
];
return sources
.flatMap((source) => (Array.isArray(source) ? source : []))
.filter((c) => getCandidateTemplateId(c) === templateId);
}
function compositionUnitToCandidate(compositionUnit: any | null | undefined): any | null {
if (!compositionUnit) return null;
const templateId = getCandidateTemplateId(compositionUnit);
if (!templateId) return null;
return {
template_id: templateId,
frame_id: compositionUnit.frame_id,
frame_number: compositionUnit.frame_number,
confidence: compositionUnit.confidence ?? compositionUnit.score ?? 0,
label: compositionUnit.label ?? "reject",
v4_label: compositionUnit.label,
phase_z_status: compositionUnit.phase_z_status,
candidate_status: compositionUnit.candidate_status,
coverage_state:
compositionUnit.coverage_state ??
(compositionUnit.rationale?.ai_adaptation_required === true
? "requires_adaptation"
: undefined),
route_hint:
compositionUnit.rationale?.ai_adaptation_required === true
? "ai_adaptation_required"
: undefined,
decision: "selected",
reason: compositionUnit.selection_path ?? compositionUnit.fallback_reason,
capacity_fit: compositionUnit.rationale?.capacity_fit,
};
}
export function buildFrameCandidatesForUnit(
unit: any,
compositionUnit?: any | null,
): FrameCandidate[] {
const candidateMap = new Map<string, any>();
const currentDefault =
typeof unit?.current_default_candidate === "string"
? unit.current_default_candidate
: null;
candidatesMatchingTemplate(unit, currentDefault).forEach((c) => pushCandidate(candidateMap, c));
pushCandidate(candidateMap, compositionUnitToCandidate(compositionUnit));
const sortedCandidateEvidence = Array.isArray(unit?.sorted_candidate_evidence)
? unit.sorted_candidate_evidence
: [];
const candidateEvidence = Array.isArray(unit?.candidate_evidence)
? unit.candidate_evidence
: [];
const primaryEvidence =
sortedCandidateEvidence.length > 0 ? sortedCandidateEvidence : candidateEvidence;
primaryEvidence.forEach((c: any) => pushCandidate(candidateMap, c));
(Array.isArray(unit?.v4_candidates) ? unit.v4_candidates : []).forEach((c: any) => pushCandidate(candidateMap, c));
(Array.isArray(unit?.v4_all_judgments) ? unit.v4_all_judgments : []).forEach((c: any) => pushCandidate(candidateMap, c));
(Array.isArray(unit?.application_candidates) ? unit.application_candidates : []).forEach((c: any) => pushCandidate(candidateMap, c));
const applicationModeMap = mergeApplicationCandidates(unit?.application_candidates);
return Array.from(candidateMap.values())
.slice(0, TOP_N_FRAMES)
.map((c: any) => {
const appMatch = applicationModeMap.get(c.template_id);
const numericScore = Number(c.confidence ?? c.score ?? 0);
const frameNumber = Number(c.frame_number);
return {
id: c.template_id,
name: c.template_id,
score: Number.isFinite(numericScore) ? numericScore : 0,
confidence: classifyConfidence(Number.isFinite(numericScore) ? numericScore : 0),
label: (c.label ?? c.v4_label ?? "reject") as FrameCandidate["label"],
thumbnailUrl:
Number.isFinite(frameNumber) && frameNumber > 0
? `/frame-preview/${String(frameNumber).padStart(2, "0")}`
: undefined,
catalogRegistered: c.catalog_registered,
minHeightPx: c.min_height_px ?? undefined,
rank: c.rank,
frameId: c.frame_id,
v4Label: c.v4_label,
phaseZStatus: c.phase_z_status,
coverageState: c.coverage_state ?? c.coverageState,
candidateStatus: c.candidate_status ?? c.candidateStatus,
filteredForDirectExecution: c.filtered_for_direct_execution,
routeHint: c.route_hint,
decision: c.decision,
reason: c.reason,
capacityFit: c.capacity_fit,
applicationMode: appMatch?.application_mode ?? c.application_mode,
autoApplicable: appMatch?.auto_applicable ?? c.auto_applicable,
delegatedTo: appMatch?.delegated_to ?? c.delegated_to ?? null,
};
});
}
/** /**
* 실제 Phase Z run 산출물을 로드하여 frontend type 으로 변환. * 실제 Phase Z run 산출물을 로드하여 frontend type 으로 변환.
* *
* @example * @example
* const { normalizedContent, slidePlan, runMeta } = await loadRun("mdx03_f29_fix_check"); * const { normalizedContent, slidePlan, runMeta } = await loadRun("mdx03_f29_fix_check");
*/ */
function arrayOfStrings(value: unknown): string[] {
return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : [];
}
function slotPayloadStatus(zone: any): { status: "filled" | "empty"; keyCount: number } {
const payload = zone?.slot_payload;
const keys = payload && typeof payload === "object" && !Array.isArray(payload)
? Object.keys(payload).filter((key) => key !== "_truncated_count")
: [];
const templateId = zone?.template_id ?? zone?.v4_template_id ?? null;
return {
status: templateId === "__empty__" || keys.length === 0 ? "empty" : "filled",
keyCount: keys.length,
};
}
function pushReason(map: Map<string, string[]>, id: string, reason: string): void {
if (!id || !reason) return;
const reasons = map.get(id) ?? [];
if (!reasons.includes(reason)) reasons.push(reason);
map.set(id, reasons);
}
function formatFilteredReason(reason: any): string {
const parts = [
reason?.selection_state,
reason?.merge_type,
reason?.template_id,
reason?.v4_label,
reason?.phase_z_status,
...(Array.isArray(reason?.filter_reasons) ? reason.filter_reasons : []),
].filter((value): value is string => typeof value === "string" && value.length > 0);
return parts.join(" / ") || "filtered";
}
export function buildPipelineTraceSummary(args: {
normalized: any;
compositionPlan: any;
applicationPlan: any;
slotPayload: any;
slideStatus: any;
}): PipelineTraceSummary {
const slideData = args.slideStatus?.data ?? {};
const rendered = new Set(arrayOfStrings(slideData.content_rendered_section_ids));
const blocked = new Set(arrayOfStrings(slideData.render_blocked_section_ids));
const filtered = new Set(arrayOfStrings(slideData.filtered_section_ids));
const covered = new Set(arrayOfStrings(slideData.covered_section_ids));
const sectionReasons = new Map<string, string[]>();
(Array.isArray(slideData.filtered_section_reasons) ? slideData.filtered_section_reasons : [])
.forEach((reason: any) => {
arrayOfStrings(reason?.section_ids).forEach((id) => {
pushReason(sectionReasons, id, formatFilteredReason(reason));
});
});
(Array.isArray(slideData.adapter_needed_units) ? slideData.adapter_needed_units : [])
.forEach((unit: any) => {
const reason = unit?.reason ?? "adapter_needed";
arrayOfStrings(unit?.source_section_ids).forEach((id) => {
pushReason(sectionReasons, id, String(reason));
});
});
filtered.forEach((id) => pushReason(sectionReasons, id, "filtered"));
const sections = (Array.isArray(args.normalized?.data?.sections) ? args.normalized.data.sections : [])
.map((section: any, index: number): PipelineTraceSection => {
const id = section.section_id ?? `section-${index + 1}`;
const childIds = Array.isArray(section.sub_sections)
? section.sub_sections
.map((sub: any, subIndex: number) => sub.section_id ?? `${id}-sub-${subIndex + 1}`)
.filter(Boolean)
: [];
let state: PipelineTraceSection["state"] = "uncovered";
if (rendered.has(id)) state = "rendered";
else if (blocked.has(id)) state = "render_blocked";
else if (filtered.has(id)) state = "filtered";
else if (covered.has(id)) state = "covered_not_rendered";
return {
id,
title: section.title ?? "",
child_ids: childIds,
state,
reasons: sectionReasons.get(id) ?? [],
};
});
const compositionByUnit = new Map<string, any>();
const selectedUnits = Array.isArray(args.compositionPlan?.data?.selected_units)
? args.compositionPlan.data.selected_units
: [];
selectedUnits.forEach((unit: any) => {
const unitId = arrayOfStrings(unit.source_section_ids).join("+");
if (unitId) compositionByUnit.set(unitId, unit);
});
const appUnits = Array.isArray(args.applicationPlan?.data?.units)
? args.applicationPlan.data.units
: [];
const units = appUnits.map((unit: any): PipelineTraceUnit => {
const sectionIds = typeof unit.unit_id === "string"
? unit.unit_id.split("+").filter(Boolean)
: arrayOfStrings(unit.source_section_ids);
const compositionUnit = compositionByUnit.get(sectionIds.join("+"));
const warnings: string[] = [];
if (sectionIds.length > 1) warnings.push("merged_sections");
const selectionPath = unit.selection_path ?? compositionUnit?.selection_path ?? null;
if (typeof selectionPath === "string" && selectionPath.includes("fallback")) warnings.push(selectionPath);
const candidateCount = buildFrameCandidatesForUnit(unit, compositionUnit).length;
if (candidateCount === 0) warnings.push("no_frame_candidates");
return {
unit_id: sectionIds.join("+") || unit.unit_id || "",
source_section_ids: sectionIds,
merge_type: unit.merge_type ?? compositionUnit?.merge_type ?? null,
selected_frame:
unit.current_default_candidate ??
unit.template_id ??
compositionUnit?.frame_template_id ??
null,
label: unit.label ?? compositionUnit?.label ?? null,
candidate_count: candidateCount,
selection_path: selectionPath,
warnings,
};
});
const adapterByPosition = new Map<string, any>();
(Array.isArray(slideData.adapter_needed_units) ? slideData.adapter_needed_units : [])
.forEach((unit: any) => {
if (unit.position) adapterByPosition.set(unit.position, unit);
});
const zones = (Array.isArray(args.slotPayload?.data?.per_zone) ? args.slotPayload.data.per_zone : [])
.map((zone: any, index: number): PipelineTraceZone => {
const { status, keyCount } = slotPayloadStatus(zone);
const adapterNeeded = adapterByPosition.get(zone.position);
const appUnit = appUnits[index] ?? {};
const sectionIds = arrayOfStrings(adapterNeeded?.source_section_ids).length > 0
? arrayOfStrings(adapterNeeded.source_section_ids)
: typeof appUnit.unit_id === "string"
? appUnit.unit_id.split("+").filter(Boolean)
: [];
const warnings: string[] = [];
if (zone.template_id === "__empty__") warnings.push("__empty__");
if (status === "empty") warnings.push("empty_slot_payload");
if (adapterNeeded) warnings.push(adapterNeeded.reason ?? "adapter_needed");
return {
position: zone.position ?? `zone-${index}`,
source_section_ids: sectionIds,
template_id: zone.template_id ?? null,
slot_status: status,
slot_key_count: keyCount,
warnings,
};
});
const warnings = [
...arrayOfStrings(slideData.filtered_section_ids).map((id) => `filtered:${id}`),
...arrayOfStrings(slideData.render_blocked_section_ids).map((id) => `render_blocked:${id}`),
];
if ((slideData.adapter_needed_count ?? 0) > 0) {
warnings.push(`adapter_needed:${slideData.adapter_needed_count}`);
}
return {
sections,
units,
zones,
status: slideData.overall ?? "UNKNOWN",
warnings,
};
}
export function buildAiTraceSummary(
aiRepairArtifact: any | null,
aiRepairStatus: AiRepairStatus | null | undefined,
): AiTraceSummary {
if (!aiRepairArtifact?.data) {
return {
artifact_present: false,
ai_enabled: null,
status: aiRepairStatus?.status ?? "missing_artifact",
ai_called_count: 0,
eligible_count: 0,
skipped_count: 0,
error_count: 0,
skip_reasons: {},
provider: null,
model: null,
coverage_status: aiRepairStatus?.coverage_status ?? null,
human_review_required: aiRepairStatus?.human_review_required ?? false,
units: [],
warnings: ["step12_ai_repair.json missing"],
};
}
const rawUnits = Array.isArray(aiRepairArtifact.data.per_unit)
? aiRepairArtifact.data.per_unit
: [];
const units: AiTraceUnit[] = rawUnits.map((unit: any) => ({
unit_index: typeof unit.unit_index === "number" ? unit.unit_index : null,
source_section_ids: arrayOfStrings(unit.source_section_ids),
frame_template_id: unit.frame_template_id ?? null,
route_hint: unit.route_hint ?? null,
provisional: unit.provisional === true,
ai_called: unit.ai_called === true,
skip_reason: unit.skip_reason ?? null,
apply_status: unit.apply_status ?? null,
api_error_kind: unit.api_error_kind ?? null,
error: unit.error ?? null,
}));
const skipReasons: Record<string, number> = {};
units.forEach((unit) => {
const key = unit.skip_reason ?? (unit.ai_called ? "called" : "unknown");
skipReasons[key] = (skipReasons[key] ?? 0) + 1;
});
const warnings: string[] = [];
units
.filter((unit) => unit.error)
.forEach((unit) => warnings.push(`error:${unit.unit_index ?? "?"}`));
units
.filter((unit) => unit.api_error_kind)
.forEach((unit) => warnings.push(`api_${unit.api_error_kind}:${unit.unit_index ?? "?"}`));
if (units.some((unit) => unit.skip_reason === "router_short_circuit")) {
warnings.push("router_short_circuit");
}
const eligibleUnits = units.filter(
(unit) => unit.provisional && unit.route_hint === "ai_adaptation_required",
);
const aiEnabled =
units.some((unit) => unit.ai_called)
? true
: eligibleUnits.length > 0 && eligibleUnits.every((unit) => unit.skip_reason === "router_short_circuit")
? false
: null;
return {
artifact_present: true,
ai_enabled: aiEnabled,
status: aiRepairStatus?.status ?? aiRepairArtifact.step_status ?? "unknown",
ai_called_count: units.filter((unit) => unit.ai_called).length,
eligible_count: units.filter((unit) => unit.provisional && unit.route_hint === "ai_adaptation_required").length,
skipped_count: units.filter((unit) => !unit.ai_called).length,
error_count: units.filter((unit) => !!unit.error).length,
skip_reasons: skipReasons,
provider: "anthropic",
model: null,
coverage_status:
aiRepairStatus?.coverage_status ??
aiRepairArtifact.data.coverage_invariant?.status ??
null,
human_review_required: aiRepairStatus?.human_review_required ?? false,
units,
warnings,
};
}
export async function loadRun(runId: string): Promise<LoadRunResult> { export async function loadRun(runId: string): Promise<LoadRunResult> {
console.log(`[Phase Z] loadRun: ${runId}`); console.log(`[Phase Z] loadRun: ${runId}`);
const base = `/data/runs/${runId}`; const base = `/data/runs/${runId}`;
@@ -931,14 +438,6 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
} }
return res.json(); return res.json();
}; };
const fetchJsonOptional = async (path: string) => {
const res = await fetch(`${base}/${path}`);
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(`Failed to fetch ${path}: ${res.status} ${res.statusText}`);
}
return res.json();
};
const fetchText = async (path: string) => { const fetchText = async (path: string) => {
const res = await fetch(`${base}/${path}`); const res = await fetch(`${base}/${path}`);
if (!res.ok) { if (!res.ok) {
@@ -951,28 +450,21 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
mdxSource, mdxSource,
upload, upload,
normalized, normalized,
compositionPlan,
layout, layout,
zoneRegion, zoneRegion,
applicationPlan, applicationPlan,
slotPayload,
slideStatus, slideStatus,
aiRepair,
] = await Promise.all([ ] = await Promise.all([
fetchText("steps/step01_mdx_source.md"), fetchText("steps/step01_mdx_source.md"),
fetchJson("steps/step01_mdx_upload.json"), fetchJson("steps/step01_mdx_upload.json"),
fetchJson("steps/step02_normalized.json"), fetchJson("steps/step02_normalized.json"),
fetchJson("steps/step06_composition_plan.json"),
fetchJson("steps/step07_layout.json"), fetchJson("steps/step07_layout.json"),
fetchJson("steps/step08_zone_region_ratios.json"), fetchJson("steps/step08_zone_region_ratios.json"),
fetchJson("steps/step09_application_plan.json"), fetchJson("steps/step09_application_plan.json"),
fetchJson("steps/step12_slot_payload.json"),
fetchJson("steps/step20_slide_status.json"), fetchJson("steps/step20_slide_status.json"),
fetchJsonOptional("steps/step12_ai_repair.json"),
]); ]);
// ── RunMeta ── // ── RunMeta ──
const aiRepairStatus = (slideStatus.data?.ai_repair_status ?? null) as AiRepairStatus | null;
const runMeta: RunMeta = { const runMeta: RunMeta = {
run_id: upload.data?.run_id ?? runId, run_id: upload.data?.run_id ?? runId,
mdx_path: upload.data?.mdx_path ?? "", mdx_path: upload.data?.mdx_path ?? "",
@@ -997,15 +489,7 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
z.display_strategy_candidates ?? [], z.display_strategy_candidates ?? [],
]) ])
), ),
ai_repair_status: aiRepairStatus, ai_repair_status: (slideStatus.data?.ai_repair_status ?? null) as AiRepairStatus | null,
pipeline_trace: buildPipelineTraceSummary({
normalized,
compositionPlan,
applicationPlan,
slotPayload,
slideStatus,
}),
ai_trace: buildAiTraceSummary(aiRepair, aiRepairStatus),
}; };
// ── NormalizedContent ── // ── NormalizedContent ──
@@ -1060,16 +544,6 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
const layoutPreset = (layout.data?.layout_preset ?? "single") as LayoutPresetId; const layoutPreset = (layout.data?.layout_preset ?? "single") as LayoutPresetId;
const positions = computeZonePositions(layoutPreset); const positions = computeZonePositions(layoutPreset);
const units: any[] = applicationPlan.data?.units ?? []; const units: any[] = applicationPlan.data?.units ?? [];
const compositionUnitsById = new Map<string, any>();
const selectedUnits = compositionPlan.data?.selected_units;
if (Array.isArray(selectedUnits)) {
selectedUnits.forEach((unit: any) => {
const unitId = Array.isArray(unit.source_section_ids)
? unit.source_section_ids.join("+")
: "";
if (unitId) compositionUnitsById.set(unitId, unit);
});
}
const zones = units.map((unit: any, idx: number) => { const zones = units.map((unit: any, idx: number) => {
const posEntry = positions[idx] ?? { const posEntry = positions[idx] ?? {
@@ -1085,13 +559,6 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
// sort 우선순위 = label (use_as_is > light_edit > restructure > reject) + confidence desc. // sort 우선순위 = label (use_as_is > light_edit > restructure > reject) + confidence desc.
// 모두 reject 인 경우 confidence desc 만 적용 (사용자 명시). // 모두 reject 인 경우 confidence desc 만 적용 (사용자 명시).
const TOP_N_FRAMES = 6; const TOP_N_FRAMES = 6;
// IMP-39 u4 (issue #68) — local LABEL_PRIORITY is now a documentation
// mirror of templates/phase_z2/catalog/ranking_sort_policy.yaml (u1).
// Primary ordering arrives pre-sorted from the backend selector
// (src/phase_z2_pipeline.py lookup_v4_match_with_fallback :1186-1196 +
// _build_application_plan_unit u3 payload fields). This constant is read
// ONLY on the warn-fallback path below (legacy fixtures pre-u3 / payload
// missing). Kept verbatim so the fallback ordering matches u1/u2 contract.
const LABEL_PRIORITY: Record<string, number> = { const LABEL_PRIORITY: Record<string, number> = {
use_as_is: 0, use_as_is: 0,
light_edit: 1, light_edit: 1,
@@ -1103,6 +570,9 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
// 2) unit.v4_all_judgments (pre-IMP-05 audit array) // 2) unit.v4_all_judgments (pre-IMP-05 audit array)
// 3) unit.v4_candidates (legacy minimal) // 3) unit.v4_candidates (legacy minimal)
// fallback_chain alias is intentionally NOT read (Stage 2 guardrail). // fallback_chain alias is intentionally NOT read (Stage 2 guardrail).
const candidateEvidence = Array.isArray(unit.candidate_evidence)
? unit.candidate_evidence
: [];
const candidateMap = new Map<string, any>(); const candidateMap = new Map<string, any>();
const pushCandidate = (c: any) => { const pushCandidate = (c: any) => {
if (!c) return; if (!c) return;
@@ -1110,80 +580,31 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
if (!key) return; if (!key) return;
if (!candidateMap.has(key)) candidateMap.set(key, c); if (!candidateMap.has(key)) candidateMap.set(key, c);
}; };
// IMP-39 u4 (issue #68) — primary path: consume the backend Step 9
// payload as the single source of ordering truth.
// • ``unit.sorted_candidate_evidence`` = policy-sorted selector trace
// (src/phase_z2_pipeline.py :4163, alias of selection_trace[
// "candidates"] sorted by u2 at :1186-1196). Same IMP-05 L2 schema
// consumed below (template_id, label, confidence, frame_number,
// frame_id, rank, catalog_registered, capacity_fit, route_hint, ...).
// • ``unit.ranking_sort_policy`` = full single-source policy dict
// (policy_type / label_priority / unknown_label_priority /
// tie_break_axes) forwarded for telemetry + fallback parity check.
// When both are present we feed sorted_candidate_evidence through the
// existing dedup map (first occurrence wins, mirrors backend
// ``seen_template_ids`` semantics at :1204-1236) and SKIP the local
// re-sort — backend "rank 1" then equals frontend frame_candidates[0]
// by construction (Stage 1 root-cause fix).
const sortedCandidateEvidence: any[] | null = Array.isArray(
unit.sorted_candidate_evidence,
)
? unit.sorted_candidate_evidence
: null;
const rankingSortPolicy = unit.ranking_sort_policy ?? null;
const backendPolicyPayloadPresent =
sortedCandidateEvidence !== null &&
sortedCandidateEvidence.length > 0 &&
rankingSortPolicy !== null;
let v4Source: any[];
if (backendPolicyPayloadPresent) {
// Emergency fix (2026-05-26) — sorted_candidate_evidence 는 primary (rank order)
// 로 먼저 push (first-wins dedup map 이 순서 보존). 그 후 v4_all_judgments +
// v4_candidates 도 push 해서 후보 pool 채움 (reject 포함, max TOP_N_FRAMES).
// 이전 버그: sorted_candidate_evidence 만 사용 → "선택된 후보 1개" 만 frontend panel
// 에 surface, 나머지 reject/light_edit/restructure 후보 사라짐.
sortedCandidateEvidence!.forEach(pushCandidate);
(unit.v4_all_judgments ?? []).forEach(pushCandidate);
(unit.v4_candidates ?? []).forEach(pushCandidate);
v4Source = Array.from(candidateMap.values());
} else {
// IMP-39 u4 — warn-fallback path. Legacy fixtures predating u3 (or
// any code path that strips the payload) lack the backend-sorted
// evidence; ordering then derives from local LABEL_PRIORITY mirror.
// Warning surfaces drift in dev console without hard-failing the UI
// (graceful: production sample audit deck remains renderable).
if (typeof console !== "undefined" && typeof console.warn === "function") {
console.warn(
`[IMP-39 u4] unit ${unit.unit_id ?? "<unknown>"}: backend payload ` +
"missing ranking_sort_policy / sorted_candidate_evidence — " +
"falling back to local LABEL_PRIORITY (legacy fixture path).",
);
}
const candidateEvidence = Array.isArray(unit.candidate_evidence)
? unit.candidate_evidence
: [];
candidateEvidence.forEach(pushCandidate); candidateEvidence.forEach(pushCandidate);
(unit.v4_all_judgments ?? []).forEach(pushCandidate); (unit.v4_all_judgments ?? []).forEach(pushCandidate);
(unit.v4_candidates ?? []).forEach(pushCandidate); (unit.v4_candidates ?? []).forEach(pushCandidate);
const rawSource = Array.from(candidateMap.values()); const rawSource = Array.from(candidateMap.values());
v4Source = [...rawSource].sort((a: any, b: any) => { const v4Source = [...rawSource].sort((a: any, b: any) => {
const lp = const lp = (LABEL_PRIORITY[a.label] ?? 99) - (LABEL_PRIORITY[b.label] ?? 99);
(LABEL_PRIORITY[a.label] ?? 99) - (LABEL_PRIORITY[b.label] ?? 99);
if (lp !== 0) return lp; if (lp !== 0) return lp;
return (b.confidence ?? 0) - (a.confidence ?? 0); return (b.confidence ?? 0) - (a.confidence ?? 0);
}); });
} // ─── IMP-41 u2 — application_candidates enrichment (issue #70) ───────────
// ─── IMP-41 u4 — application_candidates enrichment (issue #70) ───────────
// Backend Step 9 emits `unit.application_candidates[]` (src/phase_z2_pipeline.py // Backend Step 9 emits `unit.application_candidates[]` (src/phase_z2_pipeline.py
// _application_candidates_for_unit, :3071-3092) one entry per v4 candidate with // _application_candidates_for_unit, :3071-3092) one entry per v4 candidate with
// application_mode / auto_applicable / delegated_to derived from // application_mode / auto_applicable / delegated_to derived from
// APPLICATION_MODE_BY_V4_LABEL (:107-112). Indexing delegated to the pure // APPLICATION_MODE_BY_V4_LABEL (:107-112). Enrichment ONLY — does NOT alter
// helper `mergeApplicationCandidates` (services/applicationMode.ts) keyed // candidate source priority, sorting, or TOP_N_FRAMES slicing.
// by template_id. Enrichment ONLY — does NOT alter candidate source const applicationCandidates: any[] = Array.isArray(unit.application_candidates)
// priority, sorting, or TOP_N_FRAMES slicing. ? unit.application_candidates
const applicationModeMap = mergeApplicationCandidates(unit.application_candidates); : [];
const applicationModeMap = new Map<string, any>();
applicationCandidates.forEach((ac: any) => {
const key = ac?.template_id;
if (typeof key === "string" && key.length > 0) {
applicationModeMap.set(key, ac);
}
});
const frameCandidates: FrameCandidate[] = v4Source const frameCandidates: FrameCandidate[] = v4Source
.slice(0, TOP_N_FRAMES) .slice(0, TOP_N_FRAMES)
.map((c: any) => { .map((c: any) => {
@@ -1213,8 +634,6 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
frameId: c.frame_id, frameId: c.frame_id,
v4Label: c.v4_label, v4Label: c.v4_label,
phaseZStatus: c.phase_z_status, phaseZStatus: c.phase_z_status,
coverageState: c.coverage_state ?? c.coverageState,
candidateStatus: c.candidate_status ?? c.candidateStatus,
filteredForDirectExecution: c.filtered_for_direct_execution, filteredForDirectExecution: c.filtered_for_direct_execution,
routeHint: c.route_hint, routeHint: c.route_hint,
decision: c.decision, decision: c.decision,
@@ -1230,15 +649,6 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
}); });
}); });
const mergedFrameCandidates = buildFrameCandidatesForUnit(
unit,
compositionUnitsById.get(unit.unit_id),
);
const effectiveFrameCandidates =
mergedFrameCandidates.length > 0 ? mergedFrameCandidates : frameCandidates;
const defaultFrameId =
unit.current_default_candidate ?? effectiveFrameCandidates[0]?.id ?? null;
const displayStrategy = ( const displayStrategy = (
runMeta.display_strategy_candidates_by_zone[posEntry.name]?.[0] ?? runMeta.display_strategy_candidates_by_zone[posEntry.name]?.[0] ??
"inline_full" "inline_full"
@@ -1263,10 +673,10 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
content_unit_ids: [], content_unit_ids: [],
frame_match_strategy: { frame_match_strategy: {
kind: "frame_match" as const, kind: "frame_match" as const,
frame_id: defaultFrameId, frame_id: unit.current_default_candidate ?? null,
display_strategy: displayStrategy, display_strategy: displayStrategy,
}, },
frame_candidates: effectiveFrameCandidates, frame_candidates: frameCandidates,
}, },
], ],
region_layout_type: regionId, region_layout_type: regionId,
@@ -1,287 +0,0 @@
// IMP-52 u5 — typed frontend client for `/api/user-overrides/:key` (GET + PUT).
//
// The on-disk schema (KNOWN_AXES) and endpoint contract are owned by:
// • src/user_overrides_io.py (Python — backend pipeline fallback, u1/u2)
// • Front/vite.config.ts (handleGet/PutUserOverrides, u3/u4)
// This module is the typed view used by Home.tsx restore-on-reopen (u6) and
// the four mutation handlers (u7). It does NOT own the schema — any change
// to KNOWN_AXES must land in u1/u4 first, then reflect here.
//
// IMP-51 (#79) u3 — added `image_overrides` (5th axis). `image_id` → percent-
// of-slide {x,y,w,h}. Mirrors src/user_overrides_io.py KNOWN_AXES (u1) and
// Front/vite.config.ts KNOWN_USER_OVERRIDES_AXES (u2). Backend stamper +
// render-time CSS injection ride on u4~u7; the SlideCanvas drag/resize
// handles that drive this axis ride on u8~u11.
//
// Contract (Stage 2 unit u5 summary):
// • Typed `getUserOverrides(key)` → returns `Partial<UserOverrides>` from
// the GET endpoint. Missing / corrupt / non-object payloads degrade to
// `{}` so the frontend reopen flow never crashes on a fresh MDX.
// • Typed `saveUserOverrides(key, partial)` → schedules a 300ms-debounced
// PUT carrying ONLY the axes the user has mutated since the last flush.
// Per-axis coalescing: a later call overwrites the same axis in the
// pending payload; axes the user did not mutate are NOT sent (the
// server-side merge in u4 preserves them on disk).
// • Per-key debounce buckets — rapid edits to MDX "03" do not delay the
// flush for MDX "04".
// • Explicit clear sentinel: `partial[axis] = null` forwards to the PUT
// body verbatim so u4 `mergeUserOverrides` can `delete` the axis on disk.
// • `flushUserOverrides()` / `flushUserOverrides(key)` force an immediate
// PUT (used by tests + Home.tsx Generate flow to ensure outstanding
// writes commit before pipeline run).
const ENDPOINT_BASE = "/api/user-overrides";
const DEBOUNCE_MS = 300;
// ── Schema (mirror of backend KNOWN_AXES; see header comment) ───────────────
/** unit_id → template_id. unit_id = source_section_ids joined by "+". */
export type FramesOverride = Record<string, string>;
/** zone_id → 0-1 normalized geometry inside slide-body. */
export type ZoneGeometryOverride = {
x: number;
y: number;
w: number;
h: number;
};
export type ZoneGeometriesOverride = Record<string, ZoneGeometryOverride>;
/** zone_id → ordered list of section_ids assigned to that zone. */
export type ZoneSectionsOverride = Record<string, string[]>;
/**
* IMP-51 #79 u3 — image_id → percent-of-slide geometry. Matches the user-
* content image selector `.slide img[data-image-role="user-content"]`
* (stamper in u4) and the render-time CSS injection map (u7). Coordinates
* are slide-absolute percent (0100) so SlideCanvas drag handles (u8~u11)
* map 1:1 with the persisted axis without per-zone transforms.
*/
export type ImageOverride = {
x: number;
y: number;
w: number;
h: number;
};
export type ImageOverridesOverride = Record<string, ImageOverride>;
/**
* IMP-55 #93 u1 — bool intent marker that gates whether persisted
* `zone_sections` are consumed by the backend pipeline. Frontend sets
* `true` only on a real user drag-drop (Home.tsx handleSectionDrop, u6)
* and `false` on layout apply/cancel auto-carry (u5/u12). Mirrors the
* Python KNOWN_AXES (`manual_section_assignment`) added in u1 and the
* Vite KNOWN_USER_OVERRIDES_AXES allowlist entry added in u1.
*/
export type ManualSectionAssignmentOverride = boolean;
/**
* IMP-56 #90 u10 — Step-22 text-edit persist axis. Keyed by `zone_id`; the
* inner mapping is `text_path` (= `{slot_key}.{line_index}`) → line value.
* The `text_path` stamp is emitted by `src/text_path_stamper.py` (u8) and
* applied at Step 13 (u9); the value is consumed by `text_override_resolver`
* (u4) and applied at Step 12 (u5). Stale paths (frame swap / layout
* regression between sessions) are tolerated by the backend resolver as
* `skipped`, NOT raised — so the on-disk axis is forward-compat with layout
* and frame churn. Mirrors Python `KNOWN_AXES` entry (u1) and Vite
* `KNOWN_USER_OVERRIDES_AXES` allowlist entry (u3).
*/
export type TextOverridesPerZone = Record<string, string>;
export type TextOverridesOverride = Record<string, TextOverridesPerZone>;
/**
* IMP-56 #90 u10 — Step-22 structure-edit persist axis. Keyed by `zone_id`;
* the inner mapping is SCOPE-LOCKED to `{slot_order, hidden_slots}` — slot
* reorder + slot hide only. Frame swap stays on the existing `frames` axis;
* the `structure_override_resolver` (u6) rejects frame-swap-shaped inner
* keys at the validate gate so Phase Z's no-AI-HTML-structure invariant
* holds across this persisted axis too. Per-slot `list[str]` line content
* is NEVER mutated by the u7 Step-12 apply — that is the `text_overrides`
* axis above. Mirrors Python `KNOWN_AXES` entry (u2) and Vite
* `KNOWN_USER_OVERRIDES_AXES` allowlist entry (u3).
*/
export type StructureOverridePerZone = {
slot_order?: string[];
hidden_slots?: string[];
};
export type StructureOverridesOverride = Record<string, StructureOverridePerZone>;
/** Full on-disk schema. All axes optional — file may carry any subset. */
export interface UserOverrides {
layout: string;
frames: FramesOverride;
zone_geometries: ZoneGeometriesOverride;
zone_sections: ZoneSectionsOverride;
image_overrides: ImageOverridesOverride;
manual_section_assignment: ManualSectionAssignmentOverride;
text_overrides: TextOverridesOverride;
structure_overrides: StructureOverridesOverride;
}
/** Partial-mutation payload. `null` is the explicit clear sentinel (mirrors u4). */
export type UserOverridesPartial = {
[K in keyof UserOverrides]?: UserOverrides[K] | null;
};
// ── Per-key debounce buckets ────────────────────────────────────────────────
type PendingBucket = {
partial: UserOverridesPartial;
timer: ReturnType<typeof setTimeout> | null;
waiters: Array<{
resolve: (merged: Partial<UserOverrides>) => void;
reject: (err: unknown) => void;
}>;
};
const buckets = new Map<string, PendingBucket>();
function getBucket(key: string): PendingBucket {
let b = buckets.get(key);
if (!b) {
b = { partial: {}, timer: null, waiters: [] };
buckets.set(key, b);
}
return b;
}
// ── GET ─────────────────────────────────────────────────────────────────────
/**
* Fetch the persisted user_overrides for `key` (MDX stem). Returns `{}` on
* any failure mode (network error, 4xx/5xx, non-object body) so the caller
* can use it unconditionally during MDX reopen without branching on
* error paths.
*/
export async function getUserOverrides(
key: string,
): Promise<Partial<UserOverrides>> {
let res: Response;
try {
res = await fetch(`${ENDPOINT_BASE}/${encodeURIComponent(key)}`, {
method: "GET",
headers: { Accept: "application/json" },
});
} catch {
return {};
}
if (!res.ok) return {};
let parsed: unknown;
try {
parsed = await res.json();
} catch {
return {};
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return {};
}
return parsed as Partial<UserOverrides>;
}
// ── PUT (debounced) ─────────────────────────────────────────────────────────
async function flushBucket(
key: string,
bucket: PendingBucket,
): Promise<void> {
const payload = bucket.partial;
const waiters = bucket.waiters;
bucket.partial = {};
bucket.timer = null;
bucket.waiters = [];
let merged: Partial<UserOverrides> = {};
try {
const res = await fetch(`${ENDPOINT_BASE}/${encodeURIComponent(key)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.ok) {
try {
const parsed = (await res.json()) as unknown;
if (
typeof parsed === "object" &&
parsed !== null &&
!Array.isArray(parsed)
) {
merged = parsed as Partial<UserOverrides>;
}
} catch {
// server returned 200 with non-JSON body → treat as empty merged
}
} else {
const err = new Error(`PUT ${ENDPOINT_BASE}/${key}${res.status}`);
waiters.forEach((w) => w.reject(err));
return;
}
} catch (err) {
waiters.forEach((w) => w.reject(err));
return;
}
waiters.forEach((w) => w.resolve(merged));
}
/**
* Schedule a debounced PUT to persist the mutated axes. Resolves with the
* server-side merged document when the debounced PUT eventually fires.
* Multiple rapid calls for the same `key` coalesce into a single PUT;
* a later call's value for a given axis overrides an earlier pending value.
* Calls for different `key`s are isolated.
*/
export function saveUserOverrides(
key: string,
partial: UserOverridesPartial,
): Promise<Partial<UserOverrides>> {
const bucket = getBucket(key);
// Per-axis coalescing — later mutations replace earlier pending values.
for (const axis of Object.keys(partial) as Array<keyof UserOverridesPartial>) {
bucket.partial[axis] = partial[axis] as never;
}
const p = new Promise<Partial<UserOverrides>>((resolve, reject) => {
bucket.waiters.push({ resolve, reject });
});
if (bucket.timer !== null) clearTimeout(bucket.timer);
bucket.timer = setTimeout(() => {
void flushBucket(key, bucket);
}, DEBOUNCE_MS);
return p;
}
/**
* Force-flush pending debounced writes. With no arg, flushes ALL pending
* keys (used before pipeline runs so the backend reads the latest file).
* With a key, flushes only that key's bucket.
*
* Resolves after every flushed bucket's PUT completes. Per-bucket errors
* are swallowed at the flush level — the original caller's
* saveUserOverrides() promise still rejects to its owner via the waiter.
*/
export async function flushUserOverrides(key?: string): Promise<void> {
const targets: Array<[string, PendingBucket]> = [];
if (key !== undefined) {
const b = buckets.get(key);
if (b && b.timer !== null) targets.push([key, b]);
} else {
buckets.forEach((b, k) => {
if (b.timer !== null) targets.push([k, b]);
});
}
const flushPromises = targets.map(([k, b]) => {
if (b.timer !== null) {
clearTimeout(b.timer);
b.timer = null;
}
return flushBucket(k, b);
});
await Promise.all(flushPromises);
}
/** Test-only — clears all pending buckets without firing PUTs. */
export function __resetUserOverridesBuckets_FOR_TEST(): void {
buckets.forEach((b) => {
if (b.timer !== null) clearTimeout(b.timer);
});
buckets.clear();
}
-34
View File
@@ -162,10 +162,6 @@ export interface FrameCandidate {
v4Label?: 'use_as_is' | 'light_edit' | 'restructure' | 'reject'; v4Label?: 'use_as_is' | 'light_edit' | 'restructure' | 'reject';
/** Phase Z status enum (e.g. "auto_renderable", "fallback_candidate"). Open vocabulary. */ /** Phase Z status enum (e.g. "auto_renderable", "fallback_candidate"). Open vocabulary. */
phaseZStatus?: string; phaseZStatus?: string;
/** T21.6 coverage-state contract for candidate UX. */
coverageState?: 'covered_native' | 'covered_via_expand' | 'requires_adaptation' | 'unsupported' | string;
/** Backend 3-status pool classification (auto/adaptation/blocked). */
candidateStatus?: string;
/** True when status is outside MVP1_ALLOWED_STATUSES (= excluded from direct render path). */ /** True when status is outside MVP1_ALLOWED_STATUSES (= excluded from direct render path). */
filteredForDirectExecution?: boolean; filteredForDirectExecution?: boolean;
/** Execution route mapped from `label` (direct_render / deterministic_minor_adjustment / /** Execution route mapped from `label` (direct_render / deterministic_minor_adjustment /
@@ -210,36 +206,6 @@ export interface UserSelection {
zone_sections: Record<string, string[]>; // zoneId -> sectionIds[] zone_sections: Record<string, string[]>; // zoneId -> sectionIds[]
zone_sizes: Record<string, number[]>; // layoutGroupId -> [size1, size2, ...] zone_sizes: Record<string, number[]>; // layoutGroupId -> [size1, size2, ...]
zone_geometries: Record<string, { x: number; y: number; w: number; h: number }>; // zone_id -> geometry zone_geometries: Record<string, { x: number; y: number; w: number; h: number }>; // zone_id -> geometry
// IMP-51 (#79) u11 — image_id → slide-absolute percent geometry (0100
// on each axis). image_id is stamped by `src/image_id_stamper.py` (u4)
// on user-content `<img>` tags; the same key is consumed by the u7 CSS
// injector and the SlideCanvas u8 overlay. Shape mirrors the on-disk
// `image_overrides` axis (KNOWN_AXES, src/user_overrides_io.py u1) and
// the typed-client `ImageOverridesOverride` (services/userOverridesApi.ts u3).
image_overrides: Record<string, { x: number; y: number; w: number; h: number }>;
// IMP-55 (#93) u3 — bool intent marker gating whether the backend
// consumes persisted `zone_sections` as a user override. Set to `true`
// only by the real drag-drop path (Home.tsx handleSectionDrop, u6); set
// back to `false` by the layout apply/cancel auto-carry path (u5/u12).
// handleGenerate (u7) reads this flag to decide whether to forward
// `overrides.zoneSections` to the backend, replacing the pre-IMP-55
// self-compare against `effectiveSlidePlan`. Seeded `false` in
// `createInitialUserSelection` and only restored on reopen when the
// persisted value is a real boolean (slidePlanUtils.ts u3 layering).
// Mirrors the on-disk axis added in u1 — Python KNOWN_AXES
// (src/user_overrides_io.py), Vite KNOWN_USER_OVERRIDES_AXES
// (Front/vite.config.ts), and `ManualSectionAssignmentOverride`
// (services/userOverridesApi.ts).
manual_section_assignment: boolean;
// IMP-56 #90 u10/u15 — Step-22 text + structure persist axes. Mirrors
// services/userOverridesApi.ts (`TextOverridesOverride` /
// `StructureOverridesOverride`). `text_overrides[zoneId][textPath] = value`
// is fed by SlideCanvas u13 focusout capture + Home u15 autosave;
// `structure_overrides[zoneId] = {slot_order, hidden_slots}` is fed by
// u14 overlay + u15 autosave. Both seeded `{}` in createInitialUserSelection
// and restored on reopen via applyPersistedNonFrameOverrides.
text_overrides: Record<string, Record<string, string>>;
structure_overrides: Record<string, { slot_order?: string[]; hidden_slots?: string[] }>;
}; };
} }
+19 -487
View File
@@ -1,304 +1,8 @@
import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent"; import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent";
import type {
StructureOverridePerZone,
StructureOverridesOverride,
TextOverridesOverride,
TextOverridesPerZone,
UserOverrides,
} from "../services/userOverridesApi";
import { computeZonePositions } from "../services/designAgentApi";
// ?€?€?€ IMP-52 u6 ??restore-on-reopen helpers (pure, exported for testing) ?€?€?€?€
// These helpers compose persisted `user_overrides.json` payloads (typed by
// the u5 service) onto the in-memory `UserSelection`. They live here rather
// than inline in Home.tsx so vitest can drive them in a node environment
// without booting React or pulling in the radix-ui / lucide UI deps that
// Home.tsx requires. Home.tsx wires these into:
// ??handleFileUpload (pre-Generate layout / zone_geometries / zone_sections
// seed so handleGenerate's CLI-args build picks them up)
// ??handleGenerate post-loadRun (frame remap unit_id ??region.id over the
// freshly built slidePlan)
// The on-disk schema and clear-sentinel semantics are owned by:
// ??src/user_overrides_io.py (KNOWN_AXES, u1)
// ??Front/vite.config.ts mergeUserOverrides (u4)
// ??Front/client/src/services/userOverridesApi.ts (UserOverrides type, u5)
// Any KNOWN_AXES drift must land in those files first.
/** /**
* Derive the `/api/user-overrides/:key` MDX-stem key from a filename. * Phase Z 초기 선택 상태 생성
* Strips a trailing `.mdx` (case-insensitive). The key matches the Python * SlidePlan의 결과를 초기 값으로 사용 (Step 11까지의 결과 반영)
* `Path(args.mdx_path).stem` derivation used by the backend fallback (u2),
* so the same persisted file is read from both ends without translation.
*/
export function deriveUserOverridesKey(filename: string): string {
if (filename === "") return "";
const basename = filename.split(/[\\/]/).pop() ?? filename;
const noExt = basename.replace(/\.mdx$/i, "");
return noExt.replace(/[^A-Za-z0-9_.-]/g, "_");
}
export interface SubmittedPipelineOverridesForRestore {
layout?: string;
frames?: Record<string, string>;
zoneGeometries?: Record<string, ZoneGeometryValue>;
zoneSections?: Record<string, string[]>;
}
function _copyStringMap(raw: unknown): Record<string, string> | undefined {
if (!_isPlainObject(raw)) return undefined;
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(raw)) {
if (k && typeof v === "string" && v.length > 0) out[k] = v;
}
return Object.keys(out).length > 0 ? out : undefined;
}
function _copyZoneSections(raw: unknown): Record<string, string[]> | undefined {
if (!_isPlainObject(raw)) return undefined;
const out: Record<string, string[]> = {};
for (const [zoneId, sids] of Object.entries(raw)) {
if (!zoneId || !Array.isArray(sids)) continue;
const cleaned = sids.filter((sid) => typeof sid === "string" && sid.trim());
if (cleaned.length > 0) out[zoneId] = cleaned;
}
return Object.keys(out).length > 0 ? out : undefined;
}
function _copyZoneGeometries(raw: unknown): Record<string, ZoneGeometryValue> | undefined {
if (!_isPlainObject(raw)) return undefined;
const out: Record<string, ZoneGeometryValue> = {};
for (const [zoneId, geom] of Object.entries(raw)) {
if (!zoneId || !_isPlainObject(geom)) continue;
const { x, y, w, h } = geom;
if (
typeof x === "number" &&
typeof y === "number" &&
typeof w === "number" &&
typeof h === "number"
) {
out[zoneId] = { x, y, w, h };
}
}
return Object.keys(out).length > 0 ? out : undefined;
}
/**
* Build the post-run restore document from the latest persisted payload plus
* the exact override payload submitted to `/api/run`.
*
* The generated HTML is driven by the submitted override payload, so the
* frontend state rebuilt after `loadRun()` must prefer that same payload over
* any stale layout/frame/zone axes that were read at file-upload time. Other
* persisted axes (text/image/structure edits) are preserved.
*/
export function mergeSubmittedPipelineOverridesForRestore(
persisted: Partial<UserOverrides> | null | undefined,
submitted: SubmittedPipelineOverridesForRestore | null | undefined,
manualSectionAssignment: boolean,
): Partial<UserOverrides> {
const next: Partial<UserOverrides> = { ...(persisted ?? {}) };
// These axes are owned by the current Generate payload. Clear stale values
// first so an older user_overrides file cannot reappear in the UI after a
// successful render with a different explicit override.
delete next.layout;
delete next.frames;
delete next.zone_geometries;
delete next.zone_sections;
next.manual_section_assignment = false;
if (!submitted || typeof submitted !== "object") return next;
if (typeof submitted.layout === "string" && submitted.layout.length > 0) {
next.layout = submitted.layout;
}
const frames = _copyStringMap(submitted.frames);
if (frames) next.frames = frames;
const zoneGeometries = _copyZoneGeometries(submitted.zoneGeometries);
if (zoneGeometries) next.zone_geometries = zoneGeometries;
if (manualSectionAssignment === true) {
const zoneSections = _copyZoneSections(submitted.zoneSections);
next.zone_sections = zoneSections ?? {};
next.manual_section_assignment = true;
}
return next;
}
const LAYOUT_PRESET_IDS = new Set<string>([
"single",
"horizontal-2",
"vertical-2",
"top-1-bottom-2",
"top-2-bottom-1",
"left-1-right-2",
"left-2-right-1",
"grid-2x2",
]);
/**
* Layer the three non-frame axes from a persisted `user_overrides.json`
* payload onto an existing `UserSelection`. Foreign / unrecognized payload
* shapes are silently ignored ??the u5 GET path already returns `{}` on
* corrupt files, but we revalidate here so hand-edited files or future
* forward-compat axes cannot poison the in-memory state.
*
* Frames are NOT layered here because the on-disk key (`unit_id` =
* section_ids joined by `+`) only resolves after the slidePlan zones are
* known. Use `remapPersistedFramesToZoneFrames` in the post-loadRun step.
*/
export function applyPersistedNonFrameOverrides(
selection: UserSelection,
persisted: Partial<UserOverrides> | null | undefined,
): UserSelection {
if (!persisted || typeof persisted !== "object") return selection;
const next = { ...selection.overrides };
if (typeof persisted.layout === "string" && LAYOUT_PRESET_IDS.has(persisted.layout)) {
next.layout_preset = persisted.layout as LayoutPresetId;
}
if (
persisted.zone_geometries &&
typeof persisted.zone_geometries === "object" &&
!Array.isArray(persisted.zone_geometries)
) {
next.zone_geometries = { ...persisted.zone_geometries };
}
if (
persisted.zone_sections &&
typeof persisted.zone_sections === "object" &&
!Array.isArray(persisted.zone_sections)
) {
next.zone_sections = { ...persisted.zone_sections };
}
// IMP-51 (#79) u11 ??layer the 5th persisted axis (`image_overrides`) by
// the same array / non-object guard the zone_geometries branch uses. The
// u3 typed client (services/userOverridesApi.ts) shape and the on-disk
// KNOWN_AXES entry (src/user_overrides_io.py u1) are both flat dicts
// (image_id ??{x,y,w,h} percent-of-slide), so a shallow copy is enough.
if (
persisted.image_overrides &&
typeof persisted.image_overrides === "object" &&
!Array.isArray(persisted.image_overrides)
) {
next.image_overrides = { ...persisted.image_overrides };
}
// IMP-55 (#93) u3 ??restore the bool intent marker only when the persisted
// value is a real `boolean`. A missing axis, `null` (the u4 clear sentinel
// observed post-flush), or any non-boolean shape (string "true", 1, {})
// intentionally falls through to the `createInitialUserSelection` seed of
// `false`. This is the fail-closed half of the marker contract: the
// backend pipeline (u9) consumes persisted `zone_sections` only when
// `manual_section_assignment is True`, so anything other than a real
// `true` MUST end up as `false` in memory to avoid resurrecting stale
// auto-carry assignments as user intent. Both `true` and `false` are
// restored verbatim (the explicit `false` from u12's apply/cancel write
// is meaningful ??it pins the marker off across reopens).
if (typeof persisted.manual_section_assignment === "boolean") {
next.manual_section_assignment = persisted.manual_section_assignment;
}
// IMP-56 (#90) u15 ??layer the two Step-22 persist axes through the
// u10 extract helpers; their `_isPlainObject` + dedupe gates already
// sanitize foreign / hand-edited payloads, so reopen never poisons
// memory with non-string values or non-list slot_order entries.
next.text_overrides = extractPersistedTextOverrides(persisted);
next.structure_overrides = extractPersistedStructureOverrides(persisted);
return { ...selection, overrides: next };
}
// ?€?€?€ IMP-56 #90 u10 ??typed extract helpers for the two new persist axes ?€?€?€
// Pure helpers that defensively sanitize Step-22 text_overrides and
// structure_overrides payloads off a `Partial<UserOverrides>` (typed by u10's
// userOverridesApi extension). They mirror the backend validation gates
// (`text_override_resolver` u4 / `structure_override_resolver` u6) on the
// frontend so a hand-edited or schema-drift payload cannot poison memory.
// Layering onto `UserSelection.overrides` arrives in u14~u16; until then
// capture / autosave / restore wiring units consume these as typed.
function _isPlainObject(x: unknown): x is Record<string, unknown> {
return !!x && typeof x === "object" && !Array.isArray(x);
}
function _dedupeStringList(arr: unknown): string[] {
if (!Array.isArray(arr)) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const k of arr) {
if (typeof k === "string" && k.length > 0 && !seen.has(k)) {
seen.add(k);
out.push(k);
}
}
return out;
}
export function extractPersistedTextOverrides(
persisted: Partial<UserOverrides> | null | undefined,
): TextOverridesOverride {
const raw = persisted?.text_overrides;
if (!_isPlainObject(raw)) return {};
const out: TextOverridesOverride = {};
for (const [zoneId, perZone] of Object.entries(raw)) {
if (!zoneId || !_isPlainObject(perZone)) continue;
const safe: TextOverridesPerZone = {};
for (const [textPath, value] of Object.entries(perZone)) {
if (textPath && typeof value === "string") safe[textPath] = value;
}
out[zoneId] = safe;
}
return out;
}
export function extractPersistedStructureOverrides(
persisted: Partial<UserOverrides> | null | undefined,
): StructureOverridesOverride {
const raw = persisted?.structure_overrides;
if (!_isPlainObject(raw)) return {};
const out: StructureOverridesOverride = {};
for (const [zoneId, perZone] of Object.entries(raw)) {
if (!zoneId || !_isPlainObject(perZone)) continue;
const safe: StructureOverridePerZone = {};
if (Array.isArray(perZone.slot_order)) safe.slot_order = _dedupeStringList(perZone.slot_order);
if (Array.isArray(perZone.hidden_slots)) safe.hidden_slots = _dedupeStringList(perZone.hidden_slots);
out[zoneId] = safe;
}
return out;
}
/**
* Remap persisted frames (`unit_id` ??template_id) to the in-memory
* `zone_frames` (region.id ??template_id) using the freshly built
* slidePlan zones. `unit_id` follows handleGenerate's convention:
* `zone.section_ids.join("+")`. Persisted entries whose unit_id no longer
* matches any zone (e.g. user changed zone_sections between sessions) are
* silently dropped.
*/
export function remapPersistedFramesToZoneFrames(
slidePlan: SlidePlan | null | undefined,
framesByUnitId: Record<string, string> | null | undefined,
): Record<string, string> {
if (!slidePlan || !framesByUnitId || typeof framesByUnitId !== "object") {
return {};
}
const out: Record<string, string> = {};
for (const zone of slidePlan.zones) {
const region = zone.internal_regions[0];
if (!region) continue;
if (!Array.isArray(zone.section_ids) || zone.section_ids.length === 0) continue;
const unitId = zone.section_ids.join("+");
const templateId = framesByUnitId[unitId];
if (typeof templateId === "string" && templateId.length > 0) {
out[region.id] = templateId;
}
}
return out;
}
/**
* Phase Z 珥덇린 ?좏깮 ?곹깭 ?앹꽦
* SlidePlan??寃곌낵瑜?珥덇린 媛믪쑝濡??ъ슜 (Step 11源뚯???寃곌낵 諛섏쁺)
*/ */
export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSelection { export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSelection {
const initialSections: Record<string, string[]> = {}; const initialSections: Record<string, string[]> = {};
@@ -306,16 +10,16 @@ export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSe
if (slidePlan) { if (slidePlan) {
slidePlan.zones.forEach(zone => { slidePlan.zones.forEach(zone => {
// 1. 紐⑤뱺 ?뱀뀡??媛곸옄??吏€?뺣맂 議댁뿉 ?좊떦 (珥덉븞 諛곗튂) // 1. 모든 섹션을 각자의 지정된 존에 할당 (초안 배치)
initialSections[zone.zone_id] = [...zone.section_ids]; initialSections[zone.zone_id] = [...zone.section_ids];
// 2. 媛?由ъ쟾??湲곕낯 frame. // 2. 각 리전의 기본 frame.
// 2026-05-14 ??backend frame_match_strategy.frame_id 媛€ ?덉쓣 ?뚮쭔 init. // 2026-05-14 backend frame_match_strategy.frame_id 가 있을 때만 init.
// null ??寃쎌슦 (backend current_default_candidate=None ?? frame_candidates[0] // null 인 경우 (backend current_default_candidate=None 등) frame_candidates[0]
// 濡??먮룞 梨꾩슦吏€ ?딆쓬 ??SlideCanvas ??preview overlay ?몃━嫄?議곌굔 // 로 자동 채우지 않음 → SlideCanvas preview overlay 트리거 조건
// (override !== default) ??諛쒕룞. ?ъ슜?먭? 吏곸젒 frame ?대┃?댁빞 preview 蹂댁엫. // (override !== default) 안 발동. 사용자가 직접 frame 클릭해야 preview 보임.
// 諛곌꼍 : 04-1 媛숈? case ?먯꽌 backend selection_path=rank_1 (env toggle ?듦낵) // 배경 : 04-1 같은 case 에서 backend selection_path=rank_1 (env toggle 통과)
// ?댁뼱??current_default=None ?대㈃ default override mismatch 濡?preview 媛뺤젣 諛쒕룞. // 이어도 current_default=None 이면 default override mismatch preview 강제 발동.
zone.internal_regions.forEach(region => { zone.internal_regions.forEach(region => {
const topFrameId = region.frame_match_strategy.frame_id; const topFrameId = region.frame_match_strategy.frame_id;
if (topFrameId) { if (topFrameId) {
@@ -335,24 +39,6 @@ export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSe
zone_sections: initialSections, zone_sections: initialSections,
zone_sizes: {}, zone_sizes: {},
zone_geometries: {}, zone_geometries: {},
// IMP-51 (#79) u11 ??image_overrides axis starts empty; entries land
// here via `saveImageOverride` (SlideCanvas drag/resize handler) and
// are seeded on reopen via `applyPersistedNonFrameOverrides`.
image_overrides: {},
// IMP-56 (#90) u15 ??Step-22 axes seeded empty. Entries land here
// via `saveTextOverride` (u13 focusout capture) and
// `saveStructureOverride` (u14 overlay) and are restored on reopen
// via `applyPersistedNonFrameOverrides`.
text_overrides: {},
structure_overrides: {},
// IMP-55 (#93) u3 ??bool intent marker seeded `false` so a fresh
// MDX open (no persisted file, or persisted file with axis absent)
// never forwards `overrides.zoneSections` to the backend. The marker
// flips to `true` only via the real drag-drop path (Home.tsx u6) and
// is reset to `false` by layout apply/cancel auto-carry (u5/u12).
// `applyPersistedNonFrameOverrides` may restore a persisted boolean
// verbatim on reopen ??see the bool-only guard there.
manual_section_assignment: false,
}, },
}; };
} }
@@ -374,86 +60,6 @@ export function saveZoneGeometry(
}; };
} }
/**
* IMP-51 (#79) u11 ??record a single `image_id` ??slide-absolute percent
* geometry on the in-memory selection. Mirrors `saveZoneGeometry` but on
* the 5th persisted axis (`image_overrides`); the SlideCanvas drag/resize
* handler (u8) emits one entry per pointer move, and u10's Home wiring
* funnels each emit through this helper before scheduling the debounced
* PUT. Pure / immutable ??returns a fresh `UserSelection`; the input is
* never mutated. Existing entries for the same `imageId` are replaced.
*/
export function saveImageOverride(
selection: UserSelection,
imageId: string,
geometry: { x: number; y: number; w: number; h: number },
): UserSelection {
return {
...selection,
overrides: {
...selection.overrides,
image_overrides: {
...selection.overrides.image_overrides,
[imageId]: geometry,
},
},
};
}
/**
* IMP-56 (#90) u15 ??record a single text-line capture (zone_id, text_path,
* value) onto the in-memory selection's `text_overrides` axis. Mirrors
* `saveImageOverride` (pure / immutable). u13's focusout capture emits one
* entry per finished edit; Home u15's handler funnels each emit through this
* helper before scheduling the debounced PUT (`saveUserOverrides` 300ms).
*/
export function saveTextOverride(
selection: UserSelection,
zoneId: string,
textPath: string,
value: string,
): UserSelection {
const prevZone = selection.overrides.text_overrides[zoneId] ?? {};
return {
...selection,
overrides: {
...selection.overrides,
text_overrides: {
...selection.overrides.text_overrides,
[zoneId]: { ...prevZone, [textPath]: value },
},
},
};
}
/**
* IMP-56 (#90) u15 ??record a single structure capture (zone_id ??
* {slot_order, hidden_slots}) onto the in-memory selection's
* `structure_overrides` axis. Scope-locked to slot reorder + hide (frame
* swap stays on the `frames` axis). u14's overlay emits one entry per
* user mutation; Home u15's handler funnels each emit through this
* helper before scheduling the debounced PUT.
*/
export function saveStructureOverride(
selection: UserSelection,
zoneId: string,
perZone: StructureOverridePerZone,
): UserSelection {
return {
...selection,
overrides: {
...selection.overrides,
structure_overrides: {
...selection.overrides.structure_overrides,
[zoneId]: {
...(perZone.slot_order !== undefined && { slot_order: [...perZone.slot_order] }),
...(perZone.hidden_slots !== undefined && { hidden_slots: [...perZone.hidden_slots] }),
},
},
},
};
}
export function saveZoneSizes(selection: UserSelection, groupId: string, sizes: number[]): UserSelection { export function saveZoneSizes(selection: UserSelection, groupId: string, sizes: number[]): UserSelection {
return { return {
...selection, ...selection,
@@ -468,7 +74,7 @@ export function saveZoneSizes(selection: UserSelection, groupId: string, sizes:
} }
/** /**
* ?뱀젙 ?뱀뀡???덈줈??議댁쑝濡??대룞 (Drag & Drop) * 특정 섹션을 새로운 존으로 이동 (Drag & Drop)
*/ */
export function moveSectionToZone( export function moveSectionToZone(
selection: UserSelection, selection: UserSelection,
@@ -477,12 +83,12 @@ export function moveSectionToZone(
): UserSelection { ): UserSelection {
const newZoneSections = { ...selection.overrides.zone_sections }; const newZoneSections = { ...selection.overrides.zone_sections };
// 1. 紐⑤뱺 議댁뿉???대떦 ?뱀뀡 ?쒓굅 (?대룞 ???꾩튂 ?대━?? // 1. 모든 존에서 해당 섹션 제거 (이동 전 위치 클리어)
Object.keys(newZoneSections).forEach(zid => { Object.keys(newZoneSections).forEach(zid => {
newZoneSections[zid] = newZoneSections[zid].filter(id => id !== sectionId); newZoneSections[zid] = newZoneSections[zid].filter(id => id !== sectionId);
}); });
// 2. ?€寃?議댁뿉 ?뱀뀡 異붽? // 2. 타겟 존에 섹션 추가
if (!newZoneSections[targetZoneId]) { if (!newZoneSections[targetZoneId]) {
newZoneSections[targetZoneId] = []; newZoneSections[targetZoneId] = [];
} }
@@ -503,7 +109,7 @@ export function selectZone(selection: UserSelection, zoneId: string | null): Use
return { return {
...selection, ...selection,
selectedZoneId: zoneId, selectedZoneId: zoneId,
selectedRegionId: null, // Zone??諛붾€뚮㈃ Region ?좏깮 ?댁젣 selectedRegionId: null, // Zone이 바뀌면 Region 선택 해제
}; };
} }
@@ -538,16 +144,16 @@ export function applyFrame(selection: UserSelection, regionId: string, frameId:
} }
/** /**
* ?꾩옱 ?좏깮??Zone 媛앹껜 諛섑솚 * 현재 선택된 Zone 객체 반환
*/ */
export function getSelectedZone(slidePlan: SlidePlan | null, selection: UserSelection): Zone | null { export function getSelectedZone(slidePlan: SlidePlan | null, selection: UserSelection): Zone | null {
if (!slidePlan || !selection.selectedZoneId) return null; if (!slidePlan || !selection.selectedZoneId) return null;
// id ?먮뒗 zone_id 留ㅼ묶 // id 또는 zone_id 매칭
return slidePlan.zones.find(z => z.id === selection.selectedZoneId || z.zone_id === selection.selectedZoneId) || null; return slidePlan.zones.find(z => z.id === selection.selectedZoneId || z.zone_id === selection.selectedZoneId) || null;
} }
/** /**
* ?꾩옱 ?좏깮??Region 媛앹껜 諛섑솚 * 현재 선택된 Region 객체 반환
*/ */
export function getSelectedRegion(zone: Zone | null, selection: UserSelection): InternalRegion | null { export function getSelectedRegion(zone: Zone | null, selection: UserSelection): InternalRegion | null {
if (!zone || !selection.selectedRegionId) return null; if (!zone || !selection.selectedRegionId) return null;
@@ -555,90 +161,16 @@ export function getSelectedRegion(zone: Zone | null, selection: UserSelection):
} }
/** /**
* ?뱀젙 Zone???좊떦???뱀뀡 ID 紐⑸줉 諛섑솚 (?ㅻ쾭?쇱씠???곗꽑) * 특정 Zone에 할당된 섹션 ID 목록 반환 (오버라이드 우선)
*/ */
export function getSectionsForZone(zone: Zone, selection: UserSelection): string[] { export function getSectionsForZone(zone: Zone, selection: UserSelection): string[] {
return selection.overrides.zone_sections[zone.zone_id] || zone.section_ids; return selection.overrides.zone_sections[zone.zone_id] || zone.section_ids;
} }
/** /**
* 理쒖쥌 ?좏슚 ?덉씠?꾩썐 ID 諛섑솚 * 최종 유효 레이아웃 ID 반환
*/ */
export function getEffectiveLayoutId(slidePlan: SlidePlan | null, selection: UserSelection): LayoutPresetId { export function getEffectiveLayoutId(slidePlan: SlidePlan | null, selection: UserSelection): LayoutPresetId {
if (selection.overrides.layout_preset) return selection.overrides.layout_preset; if (selection.overrides.layout_preset) return selection.overrides.layout_preset;
return slidePlan?.layout_preset || 'single'; return slidePlan?.layout_preset || 'single';
} }
// ?€?€?€ IMP-44 (#73) u3 ??zone_geometries layout-mismatch validation ?€?€?€?€?€?€?€?€?€?€?€
// Pure helper paired with the backend [override-warning] guards added in u1
// (1-D horizontal-2 / vertical-2 branches of `build_layout_css`) and u2 (2-D
// `_override_to_grid_tracks` call site). Same WARN+DROP / KEEP-known contract,
// but expressed on the frontend so handleGenerate (u4) can validate against
// the active layout *before* forwarding and surface a toast on dropped keys.
//
// Source of truth for expected positions = `computeZonePositions(layoutPreset)`
// (designAgentApi.ts), which mirrors backend `layouts.yaml` (positions field).
// Unknown layout (null / undefined / not in LAYOUT_PRESET_IDS) ??fail-safe
// drop-all: caller has no contract for projecting geometries onto an unknown
// preset, so we keep zero keys rather than passing them through verbatim.
export interface ZoneGeometryValue {
x: number;
y: number;
w: number;
h: number;
}
export interface ZoneGeometriesValidationResult {
kept: Record<string, ZoneGeometryValue>;
dropped: Record<string, ZoneGeometryValue>;
expectedPositions: string[];
valid: boolean;
}
export function validateZoneGeometriesAgainstLayout(
geoms: Record<string, ZoneGeometryValue> | null | undefined,
layoutPreset: LayoutPresetId | string | null | undefined,
): ZoneGeometriesValidationResult {
const kept: Record<string, ZoneGeometryValue> = {};
const dropped: Record<string, ZoneGeometryValue> = {};
const safeGeoms =
geoms && typeof geoms === "object" && !Array.isArray(geoms) ? geoms : null;
// Unknown-layout fail-safe ??drop everything; no expected positions known.
if (typeof layoutPreset !== "string" || !LAYOUT_PRESET_IDS.has(layoutPreset)) {
if (safeGeoms) {
for (const [k, v] of Object.entries(safeGeoms)) {
dropped[k] = v;
}
}
return {
kept,
dropped,
expectedPositions: [],
valid: Object.keys(dropped).length === 0,
};
}
const expectedPositions = computeZonePositions(
layoutPreset as LayoutPresetId,
).map((p) => p.name);
const expectedSet = new Set(expectedPositions);
if (safeGeoms) {
for (const [k, v] of Object.entries(safeGeoms)) {
if (expectedSet.has(k)) {
kept[k] = v;
} else {
dropped[k] = v;
}
}
}
return {
kept,
dropped,
expectedPositions,
valid: Object.keys(dropped).length === 0,
};
}
@@ -1,75 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildFrameCandidatesForUnit } from "../src/services/designAgentApi";
describe("buildFrameCandidatesForUnit (#98 Task 2)", () => {
it("merges sorted evidence, v4 candidates, all judgments, and app candidates up to six", () => {
const unit = {
unit_id: "03-1",
current_default_candidate: "frame_a",
sorted_candidate_evidence: [
{ template_id: "frame_a", frame_number: 1, label: "use_as_is", confidence: 0.9, rank: 1, coverage_state: "covered_native", candidate_status: "auto_renderable" },
],
v4_candidates: [
{ template_id: "frame_b", frame_number: 2, label: "light_edit", confidence: 0.8 },
],
v4_all_judgments: [
{ template_id: "frame_c", frame_number: 3, label: "reject", confidence: 0.7, coverage_state: "requires_adaptation", candidate_status: "ai_adaptation_required" },
{ template_id: "frame_d", frame_number: 4, label: "reject", confidence: 0.6 },
{ template_id: "frame_e", frame_number: 5, label: "reject", confidence: 0.5 },
{ template_id: "frame_f", frame_number: 6, label: "reject", confidence: 0.4 },
{ template_id: "frame_g", frame_number: 7, label: "reject", confidence: 0.3 },
],
application_candidates: [
{ template_id: "frame_b", application_mode: "same_frame_with_adjustment", auto_applicable: true },
],
};
const candidates = buildFrameCandidatesForUnit(unit);
expect(candidates.map((c) => c.id)).toEqual([
"frame_a",
"frame_b",
"frame_c",
"frame_d",
"frame_e",
"frame_f",
]);
expect(candidates).toHaveLength(6);
expect(candidates[1].applicationMode).toBe("same_frame_with_adjustment");
expect(candidates[0].coverageState).toBe("covered_native");
expect(candidates[2].coverageState).toBe("requires_adaptation");
expect(candidates[2].candidateStatus).toBe("ai_adaptation_required");
});
it("surfaces generic fallback from composition when Step 9 has no V4 sources", () => {
const unit = {
unit_id: "05-1+05-2",
current_default_candidate: null,
sorted_candidate_evidence: [],
candidate_evidence: [],
v4_candidates: [],
v4_all_judgments: [],
application_candidates: [],
};
const compositionUnit = {
source_section_ids: ["05-1", "05-2"],
frame_template_id: "three_parallel_requirements",
frame_id: "1171281190",
label: "reject",
score: 0,
phase_z_status: "fallback_candidate",
selection_path: "generic_fallback",
rationale: { ai_adaptation_required: true },
};
const candidates = buildFrameCandidatesForUnit(unit, compositionUnit);
expect(candidates).toHaveLength(1);
expect(candidates[0]).toMatchObject({
id: "three_parallel_requirements",
label: "reject",
coverageState: "requires_adaptation",
routeHint: "ai_adaptation_required",
decision: "selected",
reason: "generic_fallback",
});
});
});
@@ -1,117 +0,0 @@
// IMP-42 u4 — Source-slice coverage for the unconditional handleGenerate
// DIAG console.log on the frontend → backend boundary (issue #71).
//
// Scope (Stage 2 unit u4 contract):
// 1) A single `console.log("[DIAG raw overrides]", ...)` call exists
// inside handleGenerate and precedes the runPipeline call site.
// 2) The DIAG call is unconditional — not wrapped in `if (...)` / `?:` /
// env-var gate / `__DEV__`-style guard. "Silence is the bug" per
// Stage 1 scope-lock (Codex #3) and the Step 13 backend mirror
// already landed in u3.
// 3) The DIAG payload carries shape-only metadata — uploaded file name
// and the override payload object — without referencing raw MDX
// content or any other sample-specific identifier (RULE 0).
//
// Why source-slice (per Stage 2 plan): Home.tsx handleGenerate is wired to
// React state, toast, and a 700-line component tree; the cheapest way to
// pin a single-line surface and prove placement relative to runPipeline is
// to read the source and assert ordering. No React rendering, no fetch
// mock, no DOM. Mirrors the existing pure-helper pattern in
// tests/imp41_application_mode.test.ts.
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const HOME_TSX_PATH = resolve(__dirname, "..", "src", "pages", "Home.tsx");
const HOME_TSX_SOURCE = readFileSync(HOME_TSX_PATH, "utf-8");
// Locate the handleGenerate callback body. The closing brace of
// useCallback's `async () => { ... }` is the next line whose indent matches
// the opening `useCallback(async () => {` exactly — but a simpler proxy is
// "from the handleGenerate keyword to the next useCallback declaration or
// the end-of-file." This is sufficient to scope every assertion below to
// the right function body.
function sliceHandleGenerateBody(source: string): string {
const startMarker = "const handleGenerate = useCallback(async () =>";
const startIdx = source.indexOf(startMarker);
if (startIdx === -1) {
throw new Error("handleGenerate declaration not found in Home.tsx");
}
// End at the next top-level `const ` that begins a new useCallback /
// useMemo / hook binding. handleGenerate is followed by additional
// hooks (handleFileUpload sibling pattern); slicing to the next
// declaration is more than enough to capture the full body.
const afterStart = source.slice(startIdx + startMarker.length);
const nextDeclIdx = afterStart.search(/\n {2}const [A-Za-z]/);
return nextDeclIdx === -1 ? afterStart : afterStart.slice(0, nextDeclIdx);
}
const HANDLE_GENERATE_BODY = sliceHandleGenerateBody(HOME_TSX_SOURCE);
describe("handleGenerate [DIAG raw overrides] (IMP-42 u4)", () => {
it("emits exactly one console.log labelled '[DIAG raw overrides]' inside handleGenerate", () => {
const matches = HANDLE_GENERATE_BODY.match(
/console\.log\(\s*"\[DIAG raw overrides\]"/g,
);
expect(matches).not.toBeNull();
// Exactly one DIAG site per Stage 2 contract — multiple calls would
// either be a copy-paste regression or evidence that the helper
// moved without removing the old site.
expect(matches?.length).toBe(1);
});
it("places the DIAG console.log before the runPipeline call site", () => {
const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
const runPipelineIdx = HANDLE_GENERATE_BODY.indexOf(
"runPipeline(",
);
expect(diagIdx).toBeGreaterThan(-1);
expect(runPipelineIdx).toBeGreaterThan(-1);
expect(diagIdx).toBeLessThan(runPipelineIdx);
});
it("is unconditional — no env-var gate or if-guard wraps the DIAG call", () => {
// Slice the 80 chars immediately preceding the DIAG console.log and
// confirm none of the common gating patterns appear directly above.
const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
const preface = HANDLE_GENERATE_BODY.slice(Math.max(0, diagIdx - 200), diagIdx);
// Stage 1 contract: silence is the bug. Any gate here is a regression.
expect(preface).not.toMatch(/if\s*\([^)]*\)\s*$/m);
expect(preface).not.toMatch(/process\.env/);
expect(preface).not.toMatch(/import\.meta\.env/);
expect(preface).not.toMatch(/__DEV__/);
expect(preface).not.toMatch(/DIAG_VERBOSE/i);
expect(preface).not.toMatch(/DEBUG/);
});
it("forwards the file name and overrides object as shape-only payload", () => {
// The DIAG payload must include the uploaded file name (so the user
// can correlate the log line with the MDX they uploaded) and the
// overrides object (so the user can see what crossed the wire).
// It must NOT spread MDX text content or any other large blob —
// sample-agnostic and reviewable in a single log line.
const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
const window = HANDLE_GENERATE_BODY.slice(diagIdx, diagIdx + 300);
// Both fields appear in the payload object literal.
expect(window).toMatch(/file:\s*state\.uploadedFile\.name/);
expect(window).toMatch(/\boverrides\b/);
// Sanity: the payload does not pass MDX raw content / a File blob.
expect(window).not.toMatch(/mdxContent|rawMdx|normalizedContent/);
});
it("runs after flushUserOverrides() so the persisted PUT is already committed", () => {
// Ordering invariant from IMP-52 u10 (already in place):
// flushUserOverrides() → DIAG → runPipeline
// Asserts the DIAG sits between the flush and the network call so the
// logged overrides match what backend reads from disk.
const flushIdx = HANDLE_GENERATE_BODY.indexOf("await flushUserOverrides()");
const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
const runPipelineIdx = HANDLE_GENERATE_BODY.indexOf(
"runPipeline(",
);
expect(flushIdx).toBeGreaterThan(-1);
expect(diagIdx).toBeGreaterThan(flushIdx);
expect(diagIdx).toBeLessThan(runPipelineIdx);
});
});
@@ -1,123 +0,0 @@
// IMP-41 u3 — Vitest coverage for application_mode helper (issue #70).
//
// Scope (Stage 2 unit u3 contract):
// 1) buildBadgeTitle: composite output for each known mode + legacy fallback
// (undefined applicationMode) + unknown fallback (string not in
// APPLICATION_MODE_TOOLTIP_KR).
// 2) mergeApplicationCandidates: array → Map<template_id, candidate>
// semantics, including skip-missing-key and empty-input.
//
// Pure helper unit test — no React, no DOM, no fetch. Aligns with the
// AI-isolation contract: assertions key by backend application_mode VALUE,
// never by V4 label.
import { describe, it, expect } from "vitest";
import {
buildBadgeTitle,
mergeApplicationCandidates,
APPLICATION_MODE_TOOLTIP_KR,
} from "../src/services/applicationMode";
describe("buildBadgeTitle (IMP-41 u3)", () => {
it("returns composite '<consequence> (<mode>)' for direct_insert", () => {
expect(buildBadgeTitle("use_as_is", "direct_insert")).toBe(
`${APPLICATION_MODE_TOOLTIP_KR.direct_insert} (direct_insert)`,
);
});
it("returns composite output for same_frame_with_adjustment", () => {
expect(
buildBadgeTitle("light_edit", "same_frame_with_adjustment"),
).toBe(
`${APPLICATION_MODE_TOOLTIP_KR.same_frame_with_adjustment} (same_frame_with_adjustment)`,
);
});
it("returns composite output for layout_or_region_change", () => {
expect(
buildBadgeTitle("restructure", "layout_or_region_change"),
).toBe(
`${APPLICATION_MODE_TOOLTIP_KR.layout_or_region_change} (layout_or_region_change)`,
);
});
it("returns composite output for exclude", () => {
expect(buildBadgeTitle("reject", "exclude")).toBe(
`${APPLICATION_MODE_TOOLTIP_KR.exclude} (exclude)`,
);
});
it("falls back to 'V4 label: <label>' when applicationMode is undefined (legacy fixtures pre-IMP-32)", () => {
expect(buildBadgeTitle("use_as_is", undefined)).toBe("V4 label: use_as_is");
});
it("falls back to 'V4 label: <label>' when applicationMode is an unknown string", () => {
expect(buildBadgeTitle("light_edit", "some_future_mode")).toBe(
"V4 label: light_edit",
);
});
});
describe("mergeApplicationCandidates (IMP-41 u3)", () => {
it("returns empty Map when input is undefined", () => {
const result = mergeApplicationCandidates(undefined);
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(0);
});
it("returns empty Map when input is null", () => {
const result = mergeApplicationCandidates(null);
expect(result.size).toBe(0);
});
it("returns empty Map when input is not an array", () => {
expect(mergeApplicationCandidates({ template_id: "f01" }).size).toBe(0);
expect(mergeApplicationCandidates("f01").size).toBe(0);
expect(mergeApplicationCandidates(42).size).toBe(0);
});
it("returns empty Map when input is an empty array", () => {
expect(mergeApplicationCandidates([]).size).toBe(0);
});
it("keys entries by template_id and preserves the candidate payload", () => {
const ac1 = {
template_id: "f01",
label: "use_as_is",
application_mode: "direct_insert",
auto_applicable: true,
delegated_to: null,
};
const ac2 = {
template_id: "f17",
label: "light_edit",
application_mode: "same_frame_with_adjustment",
auto_applicable: false,
delegated_to: "step10_contract_check",
};
const result = mergeApplicationCandidates([ac1, ac2]);
expect(result.size).toBe(2);
expect(result.get("f01")).toBe(ac1);
expect(result.get("f17")).toBe(ac2);
});
it("skips entries with missing or non-string template_id", () => {
const result = mergeApplicationCandidates([
{ label: "use_as_is" }, // missing template_id
{ template_id: "", label: "light_edit" }, // empty string
{ template_id: 17, label: "restructure" }, // non-string
{ template_id: "f29", label: "reject" }, // valid
]);
expect(result.size).toBe(1);
expect(result.has("f29")).toBe(true);
expect(result.has("")).toBe(false);
});
it("keeps the first occurrence on duplicate template_id keys (deterministic)", () => {
const first = { template_id: "f01", label: "use_as_is" };
const second = { template_id: "f01", label: "reject" };
const result = mergeApplicationCandidates([first, second]);
expect(result.size).toBe(1);
expect(result.get("f01")).toBe(first);
});
});
@@ -1,257 +0,0 @@
// IMP-92 u5 — Frontend AI repair operational-only formatter test surface.
//
// Scope (Stage 2 unit u5 contract):
// 1) formatAiRepairHumanReviewMessage(...) surfaces a user-facing toast
// ONLY on the three operational Anthropic API error kinds (quota /
// billing / auth) classified by Step 12 u2
// (classify_operational_error) and aggregated through u3
// ai_repair_status.api_error_kinds.
// 2) Non-operational AI failures (validation / coverage_violated /
// unsupported_kind / generic "other") return null so the
// auto-pipeline stays silent per feedback_auto_pipeline_first and
// the #84 operational-vs-non-operational replacement-plan contract.
// 3) Replaces the prior IMP-47B u11 surface — previously rendered toasts
// for error / coverage_violated / unsupported_kind. After IMP-92 the
// ONLY operational reaches the user; non-operational stays silent.
//
// Pure-function unit test (no React Testing Library required — vitest is
// already in devDependencies; @testing-library/* is NOT installed). The
// Home.tsx wiring is a 2-line site (`Home.tsx:438`) that calls this helper
// after `setRunMeta(...)`; covering the helper covers the user-visible
// message text directly without DOM rendering.
//
// The test file path is preserved from IMP-47B u11 (Stage 2 plan
// `Front/client/tests/imp47b_human_review_toast.test.tsx`); the assertions
// inside reflect the IMP-92 u5 operational-only contract.
import { describe, it, expect } from "vitest";
import {
formatAiRepairHumanReviewMessage,
type AiRepairStatus,
} from "../src/services/designAgentApi";
const baseCounts = {
total: 0,
applied: 0,
no_proposal: 0,
no_zone_match: 0,
unsupported_kind: 0,
error: 0,
};
const zeroKinds = { quota: 0, billing: 0, auth: 0, other: 0 };
describe("formatAiRepairHumanReviewMessage (IMP-92 u5 — operational-only)", () => {
it("returns null when ai_repair_status is null / undefined", () => {
expect(formatAiRepairHumanReviewMessage(null)).toBeNull();
expect(formatAiRepairHumanReviewMessage(undefined)).toBeNull();
});
it("returns null on success / no-AI path (no operational kind present)", () => {
const ok: AiRepairStatus = {
status: "ok",
counts: { ...baseCounts },
api_error_kinds: { ...zeroKinds },
unsupported_kind_records: [],
error_records: [],
coverage_status: "ok",
dropped_section_ids: [],
human_review_required: false,
};
expect(formatAiRepairHumanReviewMessage(ok)).toBeNull();
const applied: AiRepairStatus = {
...ok,
status: "applied",
counts: { ...baseCounts, total: 1, applied: 1 },
};
expect(formatAiRepairHumanReviewMessage(applied)).toBeNull();
});
it("surfaces quota operational alert (Anthropic 429 / RateLimitError)", () => {
const ai: AiRepairStatus = {
status: "error",
counts: { ...baseCounts, total: 2, error: 2 },
api_error_kinds: { quota: 2, billing: 0, auth: 0, other: 0 },
unsupported_kind_records: [],
error_records: [
{
unit_index: 0,
source_section_ids: ["03-1"],
error: "RateLimitError: rate_limit_exceeded",
api_error_kind: "quota",
},
{
unit_index: 1,
source_section_ids: ["03-2"],
error: "RateLimitError: rate_limit_exceeded",
api_error_kind: "quota",
},
],
coverage_status: "ok",
dropped_section_ids: [],
human_review_required: true,
};
const msg = formatAiRepairHumanReviewMessage(ai);
expect(msg).not.toBeNull();
expect(msg).toContain("API quota");
expect(msg).toContain("충전 필요");
expect(msg).toContain("2");
});
it("surfaces billing operational alert (Anthropic 402 / PermissionDeniedError)", () => {
const ai: AiRepairStatus = {
status: "error",
counts: { ...baseCounts, total: 1, error: 1 },
api_error_kinds: { quota: 0, billing: 1, auth: 0, other: 0 },
unsupported_kind_records: [],
error_records: [
{
unit_index: 0,
source_section_ids: ["03-1"],
error: "PermissionDeniedError: insufficient credits",
api_error_kind: "billing",
},
],
coverage_status: "ok",
dropped_section_ids: [],
human_review_required: true,
};
const msg = formatAiRepairHumanReviewMessage(ai);
expect(msg).not.toBeNull();
expect(msg).toContain("API billing");
expect(msg).toContain("결제 정보 확인");
expect(msg).toContain("1");
});
it("surfaces auth operational alert (Anthropic 401 / AuthenticationError)", () => {
const ai: AiRepairStatus = {
status: "error",
counts: { ...baseCounts, total: 1, error: 1 },
api_error_kinds: { quota: 0, billing: 0, auth: 1, other: 0 },
unsupported_kind_records: [],
error_records: [
{
unit_index: 0,
source_section_ids: ["03-1"],
error: "AuthenticationError: invalid x-api-key",
api_error_kind: "auth",
},
],
coverage_status: "ok",
dropped_section_ids: [],
human_review_required: true,
};
const msg = formatAiRepairHumanReviewMessage(ai);
expect(msg).not.toBeNull();
expect(msg).toContain("API key 무효");
expect(msg).toContain(".env");
expect(msg).toContain("1");
});
it("returns null on generic non-operational 'other' API error (silent)", () => {
const ai: AiRepairStatus = {
status: "error",
counts: { ...baseCounts, total: 1, error: 1 },
api_error_kinds: { quota: 0, billing: 0, auth: 0, other: 1 },
unsupported_kind_records: [],
error_records: [
{
unit_index: 0,
source_section_ids: ["03-1"],
error: "ValidationError: proposal failed schema",
api_error_kind: "other",
},
],
coverage_status: "ok",
dropped_section_ids: [],
human_review_required: true,
};
expect(formatAiRepairHumanReviewMessage(ai)).toBeNull();
});
it("returns null on coverage_violated (non-operational, silent)", () => {
const ai: AiRepairStatus = {
status: "coverage_violated",
counts: { ...baseCounts, total: 1, applied: 1 },
api_error_kinds: { ...zeroKinds },
unsupported_kind_records: [],
error_records: [],
coverage_status: "violated",
dropped_section_ids: ["03-2"],
human_review_required: true,
};
expect(formatAiRepairHumanReviewMessage(ai)).toBeNull();
});
it("returns null on unsupported_kind (non-operational, silent)", () => {
const ai: AiRepairStatus = {
status: "unsupported_kind",
counts: { ...baseCounts, total: 1, unsupported_kind: 1 },
api_error_kinds: { ...zeroKinds },
unsupported_kind_records: [
{
unit_index: 0,
source_section_ids: ["03-1"],
apply_status: "unsupported_kind_for_reject_route:builder_options_patch",
},
],
error_records: [],
coverage_status: "ok",
dropped_section_ids: [],
human_review_required: true,
};
expect(formatAiRepairHumanReviewMessage(ai)).toBeNull();
});
it("returns null on legacy ai_repair_status without api_error_kinds (pre-u3 runs)", () => {
// Backward-compat: payloads emitted before u3 plumbing landed don't
// carry api_error_kinds. Operational-only contract treats the absence
// as "no operational signal" → silent (no toast).
const legacy: AiRepairStatus = {
status: "error",
counts: { ...baseCounts, total: 1, error: 1 },
// api_error_kinds intentionally omitted
unsupported_kind_records: [],
error_records: [
{ unit_index: 0, source_section_ids: ["03-1"], error: "timeout" },
],
coverage_status: "ok",
dropped_section_ids: [],
human_review_required: true,
};
expect(formatAiRepairHumanReviewMessage(legacy)).toBeNull();
});
it("prioritises quota when multiple operational kinds co-occur", () => {
// Defensive: a run that accumulated quota + billing errors across
// multiple AI repair attempts surfaces the quota line first (the
// most-frequently actionable per the issue body ordering).
const ai: AiRepairStatus = {
status: "error",
counts: { ...baseCounts, total: 2, error: 2 },
api_error_kinds: { quota: 1, billing: 1, auth: 0, other: 0 },
unsupported_kind_records: [],
error_records: [
{
unit_index: 0,
source_section_ids: ["03-1"],
error: "RateLimitError",
api_error_kind: "quota",
},
{
unit_index: 1,
source_section_ids: ["03-2"],
error: "PermissionDeniedError",
api_error_kind: "billing",
},
],
coverage_status: "ok",
dropped_section_ids: [],
human_review_required: true,
};
const msg = formatAiRepairHumanReviewMessage(ai);
expect(msg).not.toBeNull();
expect(msg).toContain("API quota");
});
});
@@ -1,122 +0,0 @@
// IMP-#84 u1 — FramePanel reject silent-automation contract.
//
// Stage 2 unit u1 scope:
// 1) `applyFrameSelection(candidate, onFrameSelect)` invokes onFrameSelect
// with candidate.id verbatim for EVERY V4 label
// (use_as_is / light_edit / restructure / reject) — no window.confirm
// gate, no label-conditional branch, no frame swap.
// 2) Source-presence checks pin the FramePanel.tsx wiring so the runtime
// button → handler → helper chain stays intact even though we cannot
// mount React (no jsdom / RTL / happy-dom in Front devDependencies —
// verified against the IMP-56 u20 `imp90_bottom_actions.test.ts` and
// IMP-92 u5 `imp47b_human_review_toast.test.tsx` precedent that
// explicitly skip DOM mounting).
// 3) No `window.confirm` substring remains in FramePanel.tsx after u1.
//
// Out of scope (Stage 2 exit-report contract):
// - Home.tsx:523-524 `toast.error(aiReviewMsg)` (#92 operational-only).
// - FramePanel reject badge/tooltip read-only labels at L102/L147/L156
// (no popup trigger; preserved as silent operator hint).
// - Backend `zone.provisional` emission (handled by u2 template-only).
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, it, expect, vi } from "vitest";
import { applyFrameSelection } from "../src/components/FramePanel";
import type { FrameCandidate } from "../src/types/designAgent";
const __dirname = dirname(fileURLToPath(import.meta.url));
const FRAME_PANEL_SOURCE = readFileSync(
resolve(__dirname, "../src/components/FramePanel.tsx"),
"utf-8",
);
function makeCandidate(
label: FrameCandidate["label"],
id: string,
): FrameCandidate {
return {
id,
name: `Frame ${id}`,
score: 0.5,
confidence: "medium",
label,
};
}
describe("applyFrameSelection (IMP-#84 u1 — silent-automation contract)", () => {
it("forwards candidate.id to onFrameSelect for use_as_is label", () => {
const onFrameSelect = vi.fn();
applyFrameSelection(makeCandidate("use_as_is", "frame_a"), onFrameSelect);
expect(onFrameSelect).toHaveBeenCalledTimes(1);
expect(onFrameSelect).toHaveBeenCalledWith("frame_a");
});
it("forwards candidate.id to onFrameSelect for light_edit label", () => {
const onFrameSelect = vi.fn();
applyFrameSelection(makeCandidate("light_edit", "frame_b"), onFrameSelect);
expect(onFrameSelect).toHaveBeenCalledTimes(1);
expect(onFrameSelect).toHaveBeenCalledWith("frame_b");
});
it("forwards candidate.id to onFrameSelect for restructure label", () => {
const onFrameSelect = vi.fn();
applyFrameSelection(makeCandidate("restructure", "frame_c"), onFrameSelect);
expect(onFrameSelect).toHaveBeenCalledTimes(1);
expect(onFrameSelect).toHaveBeenCalledWith("frame_c");
});
it("forwards candidate.id to onFrameSelect for reject label — no popup, no frame swap", () => {
// Reject is the silent-automation pivot case: prior IMP-47B u11 gated
// this path with window.confirm; post-IMP-#84 the helper invokes
// onFrameSelect with the reject frame.id directly. Backend / AI 격리
// contract handles AI 재구성 (content-only, frame preserved).
const onFrameSelect = vi.fn();
applyFrameSelection(makeCandidate("reject", "frame_d"), onFrameSelect);
expect(onFrameSelect).toHaveBeenCalledTimes(1);
expect(onFrameSelect).toHaveBeenCalledWith("frame_d");
});
it("does not call onFrameSelect more than once per invocation", () => {
const onFrameSelect = vi.fn();
applyFrameSelection(makeCandidate("reject", "frame_e"), onFrameSelect);
applyFrameSelection(makeCandidate("use_as_is", "frame_f"), onFrameSelect);
expect(onFrameSelect).toHaveBeenCalledTimes(2);
expect(onFrameSelect).toHaveBeenNthCalledWith(1, "frame_e");
expect(onFrameSelect).toHaveBeenNthCalledWith(2, "frame_f");
});
});
describe("FramePanel.tsx source — silent-automation wiring pins (IMP-#84 u1)", () => {
it("has no window.confirm(...) call (popup removed; narrative mentions in comments are allowed)", () => {
// Match the call form `window.confirm(` rather than the bare substring
// so that explanatory comments documenting the removed popup are not
// flagged. A re-introduced call would carry an opening paren.
expect(FRAME_PANEL_SOURCE).not.toMatch(/\bwindow\.confirm\s*\(/);
});
it("does not embed the legacy reject-confirm Korean prompt body", () => {
// Prior IMP-47B u11 string fragment; absence guards against re-introduction.
expect(FRAME_PANEL_SOURCE).not.toContain("V4 reject 라벨입니다");
expect(FRAME_PANEL_SOURCE).not.toContain("계속하시겠습니까?");
});
it("wires the button onClick to handleFrameSelect(candidate)", () => {
expect(FRAME_PANEL_SOURCE).toContain(
"onClick={() => handleFrameSelect(candidate)}",
);
});
it("delegates handleFrameSelect body to applyFrameSelection", () => {
expect(FRAME_PANEL_SOURCE).toContain(
"applyFrameSelection(candidate, onFrameSelect)",
);
});
it("exports applyFrameSelection as a named export for caller-independent reuse", () => {
expect(FRAME_PANEL_SOURCE).toMatch(
/export function applyFrameSelection\(/,
);
});
});
@@ -1,90 +0,0 @@
// IMP-56 (#90) u20 — vitest coverage for the pure request builders exported
// by `BottomActions`. The React component itself is not rendered (jsdom /
// @testing-library NOT in Front devDependencies — verified against the prior
// u14 `imp90_structure_overlay.test.tsx` pattern); we test the deterministic
// pieces that drive the network payload sent to the u18 / u19 middlewares.
//
// Upstream / downstream contracts (verified by prior units):
// - u18 /api/connect : body shape = { run_id, slug } (Front/vite.config.ts
// handleConnectMirror — `imp90_connect_endpoint.test.ts`).
// - u19 /api/export : body shape = { run_id }; response = raw text/html
// with `Content-Disposition: attachment; filename="<run_id>.html"`
// (Front/vite.config.ts handleExportStandalone —
// `imp90_export_endpoint.test.ts`).
//
// u20 scope: builders only. Any drift in URL or JSON shape fails here before
// the request leaves the client. Toast / fetch / blob plumbing is not tested
// (it would require jsdom + a fetch mock; the existing server-side tests
// already pin the wire contract).
import { describe, it, expect } from "vitest";
import {
buildConnectRequest,
buildExportRequest,
buildDownloadFilename,
} from "../src/components/BottomActions";
describe("buildConnectRequest", () => {
it("targets /api/connect", () => {
const { url } = buildConnectRequest("run_42", "mdx_03");
expect(url).toBe("/api/connect");
});
it("emits { run_id, slug } JSON body — matches u18 middleware shape", () => {
const { body } = buildConnectRequest("run_42", "mdx_03");
expect(JSON.parse(body)).toEqual({ run_id: "run_42", slug: "mdx_03" });
});
it("preserves zero-length and unicode run_id verbatim (server validates)", () => {
const { body } = buildConnectRequest("", "x");
expect(JSON.parse(body)).toEqual({ run_id: "", slug: "x" });
const { body: uni } = buildConnectRequest("런", "슬러그");
expect(JSON.parse(uni)).toEqual({ run_id: "런", slug: "슬러그" });
});
it("does not leak extra keys (frame swap / overrides etc.)", () => {
const { body } = buildConnectRequest("r", "s");
expect(Object.keys(JSON.parse(body)).sort()).toEqual(["run_id", "slug"]);
});
});
describe("buildExportRequest", () => {
it("targets /api/export", () => {
const { url } = buildExportRequest("run_42");
expect(url).toBe("/api/export");
});
it("emits { run_id } JSON body — matches u19 middleware shape", () => {
const { body } = buildExportRequest("run_42");
expect(JSON.parse(body)).toEqual({ run_id: "run_42" });
});
it("does not leak extra keys (slug / format etc.)", () => {
const { body } = buildExportRequest("r");
expect(Object.keys(JSON.parse(body))).toEqual(["run_id"]);
});
it("preserves zero-length and unicode run_id verbatim (server validates)", () => {
expect(JSON.parse(buildExportRequest("").body)).toEqual({ run_id: "" });
expect(JSON.parse(buildExportRequest("런").body)).toEqual({ run_id: "런" });
});
});
describe("buildDownloadFilename", () => {
it("returns <run_id>.html for the a[download] click chain", () => {
expect(buildDownloadFilename("run_42")).toBe("run_42.html");
});
it("appends exactly one .html suffix even when run_id already ends in .html", () => {
// The server-side `Content-Disposition` already carries the same
// filename; we mirror it verbatim so browser default behavior wins.
// We intentionally do NOT strip a trailing `.html` — run_id is the
// backend's `Path(args.mdx_path).stem`-style key, which never contains
// a dot suffix (validated by `isValidUserOverridesKey` at u18/u19).
expect(buildDownloadFilename("foo.html")).toBe("foo.html.html");
});
it("returns just .html for empty run_id (server rejects upstream)", () => {
expect(buildDownloadFilename("")).toBe(".html");
});
});
@@ -1,282 +0,0 @@
// IMP-56 (#90) u18 — vitest coverage for the vite POST /api/connect
// middleware and its supporting mirrorDirRecursive helper.
//
// Scope:
// 1) mirrorDirRecursive (pure helper):
// - absent src → returns 0 (no-throw, no dst creation).
// - file-only src → flat copy + count.
// - nested src → recursive copy + count.
// - overwrites pre-existing dst files (cel mirror semantics).
// 2) handleConnectMirror (POST):
// - method != POST → false (chain continues; next middleware may handle).
// - invalid JSON / non-object body → 400.
// - missing run_id or slug → 400.
// - invalid run_id or slug (key gate / path traversal) → 400.
// - final.html missing → 404.
// - success without run-assets dir → 200, assets_copied: 0, html copy ok.
// - success with run-assets dir → 200, assets_copied = file count, dst dir
// populated.
// - dstSlidesDir auto-created when celRoot/public/slides missing.
//
// Tests exercise the pure handler with mock req/res — no real vite server.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
handleConnectMirror,
mirrorDirRecursive,
} from "../../vite.config";
function makeMockRes() {
const state = {
statusCode: 0,
headers: {} as Record<string, string>,
body: "",
ended: false,
};
return {
state,
res: {
writeHead(status: number, headers?: Record<string, string>) {
state.statusCode = status;
if (headers) state.headers = headers;
},
end(body?: string) {
state.body = body ?? "";
state.ended = true;
},
},
};
}
function makeMockReq(opts: {
method?: string;
}): EventEmitter & { method?: string; send: (body: string) => void } {
const ee = new EventEmitter() as EventEmitter & {
method?: string;
send: (body: string) => void;
};
ee.method = opts.method;
ee.send = (body: string) => {
if (body.length > 0) ee.emit("data", Buffer.from(body, "utf-8"));
ee.emit("end");
};
return ee;
}
function seedRun(daRoot: string, runId: string, htmlBody: string): string {
const runDir = path.join(daRoot, "data", "runs", runId, "phase_z2");
fs.mkdirSync(runDir, { recursive: true });
const html = path.join(runDir, "final.html");
fs.writeFileSync(html, htmlBody, "utf-8");
return runDir;
}
describe("mirrorDirRecursive (IMP-56 #90 u18)", () => {
let tmp: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u18-mirror-"));
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it("returns 0 and does not throw when src absent", () => {
const dst = path.join(tmp, "dst");
const n = mirrorDirRecursive(path.join(tmp, "missing"), dst);
expect(n).toBe(0);
expect(fs.existsSync(dst)).toBe(false);
});
it("returns 0 when src exists but is a file (not a directory)", () => {
const srcFile = path.join(tmp, "src.txt");
fs.writeFileSync(srcFile, "x", "utf-8");
const dst = path.join(tmp, "dst");
const n = mirrorDirRecursive(srcFile, dst);
expect(n).toBe(0);
expect(fs.existsSync(dst)).toBe(false);
});
it("flat-copies file entries and returns the file count", () => {
const src = path.join(tmp, "src");
fs.mkdirSync(src);
fs.writeFileSync(path.join(src, "a.css"), "/*a*/", "utf-8");
fs.writeFileSync(path.join(src, "b.png"), "PNG", "utf-8");
const dst = path.join(tmp, "dst");
const n = mirrorDirRecursive(src, dst);
expect(n).toBe(2);
expect(fs.readFileSync(path.join(dst, "a.css"), "utf-8")).toBe("/*a*/");
expect(fs.readFileSync(path.join(dst, "b.png"), "utf-8")).toBe("PNG");
});
it("recurses into nested directories and counts only files", () => {
const src = path.join(tmp, "src");
fs.mkdirSync(path.join(src, "nested", "deep"), { recursive: true });
fs.writeFileSync(path.join(src, "root.txt"), "r", "utf-8");
fs.writeFileSync(path.join(src, "nested", "n.txt"), "n", "utf-8");
fs.writeFileSync(path.join(src, "nested", "deep", "d.txt"), "d", "utf-8");
const dst = path.join(tmp, "dst");
const n = mirrorDirRecursive(src, dst);
expect(n).toBe(3);
expect(fs.readFileSync(path.join(dst, "nested", "deep", "d.txt"), "utf-8"))
.toBe("d");
});
it("overwrites pre-existing files in dst (cel mirror semantics)", () => {
const src = path.join(tmp, "src");
fs.mkdirSync(src);
fs.writeFileSync(path.join(src, "a.css"), "NEW", "utf-8");
const dst = path.join(tmp, "dst");
fs.mkdirSync(dst);
fs.writeFileSync(path.join(dst, "a.css"), "OLD", "utf-8");
mirrorDirRecursive(src, dst);
expect(fs.readFileSync(path.join(dst, "a.css"), "utf-8")).toBe("NEW");
});
});
describe("handleConnectMirror (IMP-56 #90 u18)", () => {
let daRoot: string;
let celRoot: string;
beforeEach(() => {
daRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u18-da-"));
celRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u18-cel-"));
});
afterEach(() => {
fs.rmSync(daRoot, { recursive: true, force: true });
fs.rmSync(celRoot, { recursive: true, force: true });
});
it("returns false (next chained) when method != POST", () => {
const req = makeMockReq({ method: "GET" });
const { res, state } = makeMockRes();
const handled = handleConnectMirror(req, res, daRoot, celRoot);
expect(handled).toBe(false);
expect(state.ended).toBe(false);
});
it("returns 400 on invalid JSON body", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
const handled = handleConnectMirror(req, res, daRoot, celRoot);
expect(handled).toBe(true);
req.send("{not-json}");
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("invalid JSON");
});
it("returns 400 when body is not a JSON object (array root)", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify(["not", "an", "object"]));
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("body must be a JSON object");
});
it("returns 400 when run_id or slug is missing", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "abc" })); // slug missing
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("missing run_id or slug");
});
it("returns 400 when run_id contains path traversal", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "../escape", slug: "03" }));
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("invalid run_id or slug");
});
it("returns 400 when slug contains a forward slash", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "valid_id", slug: "03/etc" }));
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("invalid run_id or slug");
});
it("returns 404 when final.html does not exist for run_id", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "ghost_run", slug: "03" }));
expect(state.statusCode).toBe(404);
expect(JSON.parse(state.body).error).toBe("final.html not found");
});
it("copies final.html to cel/public/slides/<slug>.html on success", () => {
seedRun(daRoot, "mdx03_run", "<html>03</html>");
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "mdx03_run", slug: "03" }));
expect(state.statusCode).toBe(200);
const dstHtml = path.join(celRoot, "public", "slides", "03.html");
expect(fs.existsSync(dstHtml)).toBe(true);
expect(fs.readFileSync(dstHtml, "utf-8")).toBe("<html>03</html>");
const body = JSON.parse(state.body);
expect(body.success).toBe(true);
expect(body.run_id).toBe("mdx03_run");
expect(body.slug).toBe("03");
expect(body.assets_copied).toBe(0);
expect(body.html_target).toBe(dstHtml);
});
it("auto-creates cel/public/slides when missing", () => {
seedRun(daRoot, "mdx04_run", "<html>04</html>");
expect(fs.existsSync(path.join(celRoot, "public", "slides"))).toBe(false);
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "mdx04_run", slug: "04" }));
expect(state.statusCode).toBe(200);
expect(fs.existsSync(path.join(celRoot, "public", "slides", "04.html"))).toBe(true);
});
it("mirrors assets/ recursively when present in the run dir", () => {
const runDir = seedRun(daRoot, "mdx05_run", "<html>05</html>");
fs.mkdirSync(path.join(runDir, "assets", "css"), { recursive: true });
fs.writeFileSync(path.join(runDir, "assets", "main.css"), "*{}", "utf-8");
fs.writeFileSync(path.join(runDir, "assets", "css", "extra.css"), "p{}", "utf-8");
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "mdx05_run", slug: "05" }));
expect(state.statusCode).toBe(200);
expect(JSON.parse(state.body).assets_copied).toBe(2);
expect(fs.readFileSync(path.join(celRoot, "public", "slides", "assets", "main.css"), "utf-8"))
.toBe("*{}");
expect(fs.readFileSync(path.join(celRoot, "public", "slides", "assets", "css", "extra.css"), "utf-8"))
.toBe("p{}");
});
it("overwrites pre-existing cel slide html (re-Connect semantics)", () => {
seedRun(daRoot, "mdx03_run", "NEW");
const dstSlidesDir = path.join(celRoot, "public", "slides");
fs.mkdirSync(dstSlidesDir, { recursive: true });
fs.writeFileSync(path.join(dstSlidesDir, "03.html"), "OLD", "utf-8");
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "mdx03_run", slug: "03" }));
expect(state.statusCode).toBe(200);
expect(fs.readFileSync(path.join(dstSlidesDir, "03.html"), "utf-8")).toBe("NEW");
});
});
@@ -1,219 +0,0 @@
// IMP-90 (#90) u12 — vitest coverage for `computeEditModeGates`, the pure
// helper that drives SlideCanvas's mutually-exclusive gesture gating.
// u11 introduced the `EditMode` enum + toolbar; u12 splits the prior
// `isEditMode` shim (which fired ALL gates whenever any edit mode was
// active) into 5 per-gate booleans:
// textEditing — designMode + contentEditable (text mode only).
// imageSelection — in-iframe user-content image click listener
// (image-zone mode only).
// iframePointerAuto — iframe pointer-events:auto so in-iframe gestures
// (text caret OR image click) can reach the doc.
// text mode + image-zone mode; structure stays
// pe:none because u14 will overlay React controls.
// zoneGestures — zone resize 8-handle ring + drag perimeter strips
// + canDrag in handleZoneMouseDown
// (image-zone mode only).
// imageOverlay — React-side image edit overlay (image-zone only).
//
// Mutually-exclusive contract (from the issue body's "discriminated edit
// mode"): no editMode value enables both `textEditing` and either
// `imageSelection` or `zoneGestures` simultaneously. structure mode is
// the no-op placeholder — u14 will plant the structure overlay there.
// pendingLayout fully suppresses every gate (mirrors the existing
// useEffect that forces editMode='off' on pendingLayout entry).
//
// Scope guard: this test exercises the pure helper only — no React
// rendering, no DOM. testing-library/react is NOT in devDependencies
// (verified in Front/package.json); helper-level coverage is the
// established u11 pattern.
import { describe, it, expect } from "vitest";
import {
computeEditModeGates,
type EditMode,
type EditModeGates,
} from "../src/components/SlideCanvas";
const ALL_MODES: EditMode[] = ["off", "text", "structure", "image-zone"];
describe("computeEditModeGates (IMP-90 u12) — pendingLayout suppression", () => {
it.each<EditMode>(ALL_MODES)(
"pendingLayout=true forces every gate false (editMode=%s)",
(mode) => {
const g = computeEditModeGates(mode, true);
expect(g).toEqual<EditModeGates>({
textEditing: false,
imageSelection: false,
iframePointerAuto: false,
zoneGestures: false,
imageOverlay: false,
});
}
);
});
describe("computeEditModeGates (IMP-90 u12) — off baseline", () => {
it("editMode=off pendingLayout=false: every gate false", () => {
expect(computeEditModeGates("off", false)).toEqual<EditModeGates>({
textEditing: false,
imageSelection: false,
iframePointerAuto: false,
zoneGestures: false,
imageOverlay: false,
});
});
});
describe("computeEditModeGates (IMP-90 u12) — text mode", () => {
const g = computeEditModeGates("text", false);
it("textEditing = true (designMode + contentEditable activate)", () => {
expect(g.textEditing).toBe(true);
});
it("iframePointerAuto = true (caret needs to reach the doc)", () => {
expect(g.iframePointerAuto).toBe(true);
});
it("imageSelection = false (no in-iframe image click listener)", () => {
expect(g.imageSelection).toBe(false);
});
it("zoneGestures = false (no zone resize / drag affordances)", () => {
expect(g.zoneGestures).toBe(false);
});
it("imageOverlay = false (no React-side image overlay)", () => {
expect(g.imageOverlay).toBe(false);
});
});
describe("computeEditModeGates (IMP-90 u12) — structure mode", () => {
const g = computeEditModeGates("structure", false);
// structure mode is the u14 placeholder — no gestures here yet. All five
// gates stay false so the iframe and React overlays remain quiescent
// until u14 plants the structure overlay on the React layer.
it("every gate false (u14 will plant the structure overlay later)", () => {
expect(g).toEqual<EditModeGates>({
textEditing: false,
imageSelection: false,
iframePointerAuto: false,
zoneGestures: false,
imageOverlay: false,
});
});
});
describe("computeEditModeGates (IMP-90 u12) — image-zone mode", () => {
const g = computeEditModeGates("image-zone", false);
it("textEditing = false (contentEditable would steal image clicks)", () => {
expect(g.textEditing).toBe(false);
});
it("imageSelection = true (in-iframe img click → selectedImageId)", () => {
expect(g.imageSelection).toBe(true);
});
it("iframePointerAuto = true (so image clicks reach the doc)", () => {
expect(g.iframePointerAuto).toBe(true);
});
it("zoneGestures = true (zone resize + drag affordances visible)", () => {
expect(g.zoneGestures).toBe(true);
});
it("imageOverlay = true (React-side overlay renders the drag handles)", () => {
expect(g.imageOverlay).toBe(true);
});
});
describe("computeEditModeGates (IMP-90 u12) — mutually exclusive contract", () => {
it("text mode never co-activates image-zone gates (imageSelection / zoneGestures / imageOverlay)", () => {
const g = computeEditModeGates("text", false);
expect(g.textEditing).toBe(true);
expect(g.imageSelection).toBe(false);
expect(g.zoneGestures).toBe(false);
expect(g.imageOverlay).toBe(false);
});
it("image-zone mode never co-activates text gates (textEditing)", () => {
const g = computeEditModeGates("image-zone", false);
expect(g.imageSelection).toBe(true);
expect(g.textEditing).toBe(false);
});
it.each<EditMode>(ALL_MODES)(
"for every editMode (%s), textEditing AND zoneGestures are NEVER both true",
(mode) => {
const g = computeEditModeGates(mode, false);
expect(g.textEditing && g.zoneGestures).toBe(false);
}
);
it.each<EditMode>(ALL_MODES)(
"for every editMode (%s), textEditing AND imageOverlay are NEVER both true",
(mode) => {
const g = computeEditModeGates(mode, false);
expect(g.textEditing && g.imageOverlay).toBe(false);
}
);
it.each<EditMode>(ALL_MODES)(
"for every editMode (%s), textEditing AND imageSelection are NEVER both true",
(mode) => {
const g = computeEditModeGates(mode, false);
expect(g.textEditing && g.imageSelection).toBe(false);
}
);
});
describe("computeEditModeGates (IMP-90 u12) — iframePointerAuto coupling", () => {
// pe:auto is the iframe-side prerequisite for ANY in-iframe gesture
// (text caret OR image click). The helper must NOT advertise an
// in-iframe gate as active while pe is none, or those gestures would
// be silently swallowed by the wrapper.
it.each<EditMode>(ALL_MODES)(
"textEditing → iframePointerAuto (editMode=%s)",
(mode) => {
const g = computeEditModeGates(mode, false);
if (g.textEditing) expect(g.iframePointerAuto).toBe(true);
}
);
it.each<EditMode>(ALL_MODES)(
"imageSelection → iframePointerAuto (editMode=%s)",
(mode) => {
const g = computeEditModeGates(mode, false);
if (g.imageSelection) expect(g.iframePointerAuto).toBe(true);
}
);
});
describe("computeEditModeGates (IMP-90 u12) — referential transparency", () => {
it("multiple calls with the same inputs return equal output", () => {
const a = computeEditModeGates("image-zone", false);
const b = computeEditModeGates("image-zone", false);
const c = computeEditModeGates("image-zone", false);
expect(a).toEqual(b);
expect(b).toEqual(c);
});
it("does not mutate captured state across calls (independent invocations)", () => {
const a = computeEditModeGates("text", false);
const _b = computeEditModeGates("image-zone", false);
// a must still reflect text mode after b's call.
expect(a.textEditing).toBe(true);
expect(a.imageSelection).toBe(false);
});
});
describe("computeEditModeGates (IMP-90 u12) — gate truthtable snapshot", () => {
// Snapshot for human-readable inspection — the per-mode flag layout
// is the contract u13 (text capture) and u14 (structure overlay)
// will build against. Any change requires updating both this test
// AND the consuming gates in SlideCanvas.tsx.
it("non-pendingLayout truthtable matches the u12 contract", () => {
const rows = (["off", "text", "structure", "image-zone"] as EditMode[]).map(
(m) => ({ mode: m, ...computeEditModeGates(m, false) })
);
expect(rows).toEqual([
{ mode: "off", textEditing: false, imageSelection: false, iframePointerAuto: false, zoneGestures: false, imageOverlay: false },
{ mode: "text", textEditing: true, imageSelection: false, iframePointerAuto: true, zoneGestures: false, imageOverlay: false },
{ mode: "structure", textEditing: false, imageSelection: false, iframePointerAuto: false, zoneGestures: false, imageOverlay: false },
{ mode: "image-zone", textEditing: false, imageSelection: true, iframePointerAuto: true, zoneGestures: true, imageOverlay: true },
]);
});
});
@@ -1,133 +0,0 @@
// IMP-90 (#90) u11 — vitest coverage for the discriminated EditMode enum
// and its pure transition helper `nextEditMode`. Replaces the prior single
// `isEditMode` boolean state. u11 introduces ONLY the state surface + the
// toolbar UI; gesture gating per mode is u12 (mutually exclusive) and must
// not regress this contract.
//
// Scope (Stage 2 unit u11 contract):
// 1) EDIT_MODES is the canonical ['text','structure','image-zone'] list
// in toolbar render order. 'off' is intentionally excluded from the
// iterable because it is the implicit baseline (no button); the
// toolbar only renders the three active modes per the u11 design.
// 2) nextEditMode is a pure (current, requested) -> EditMode mapping
// with three rules:
// - requested === 'off' -> 'off' (explicit exit)
// - requested === current -> 'off' (toggle exit)
// - requested !== current && != 'off'-> requested (mode switch)
// 3) The helper is referentially transparent — no side effects, no
// React, no useState, no DOM. SlideCanvas wires it as the useState
// updater callback (`setEditMode((prev) => nextEditMode(prev, m))`),
// so covering the helper here covers every toolbar click outcome
// directly without DOM rendering. (@testing-library/react is NOT in
// devDependencies; this mirrors the imp47b_human_review_toast pattern.)
// 4) The exported EditMode type union must contain exactly the four
// members 'off' | 'text' | 'structure' | 'image-zone'. The runtime
// EDIT_MODES list intentionally excludes 'off' (see (1) above).
//
// Forward-compat note: u12 will discriminate per-mode gating but MUST NOT
// alter the (current, requested) -> next contract verified here. Any
// change to the toggle/switch/exit semantics is a scope-violation against
// the u11 binding contract.
import { describe, it, expect } from "vitest";
import {
EDIT_MODES,
nextEditMode,
type EditMode,
} from "../src/components/SlideCanvas";
describe("EDIT_MODES (IMP-90 u11 — toolbar render order)", () => {
it("contains exactly the three active modes in toolbar order", () => {
expect(EDIT_MODES).toEqual(["text", "structure", "image-zone"]);
});
it("excludes 'off' — baseline is implicit, no toolbar button", () => {
expect(EDIT_MODES).not.toContain("off" as EditMode);
});
it("has length 3", () => {
expect(EDIT_MODES.length).toBe(3);
});
});
describe("nextEditMode (IMP-90 u11 — pure transition helper)", () => {
describe("explicit 'off' request always exits", () => {
it.each<EditMode>(["off", "text", "structure", "image-zone"])(
"current=%s, requested=off -> off",
(current) => {
expect(nextEditMode(current, "off")).toBe("off");
}
);
});
describe("clicking the active mode toggles back to 'off'", () => {
it.each<EditMode>(["text", "structure", "image-zone"])(
"current=%s, requested=%s -> off",
(mode) => {
expect(nextEditMode(mode, mode)).toBe("off");
}
);
});
describe("clicking a different mode switches", () => {
const cases: Array<[EditMode, EditMode]> = [
["off", "text"],
["off", "structure"],
["off", "image-zone"],
["text", "structure"],
["text", "image-zone"],
["structure", "text"],
["structure", "image-zone"],
["image-zone", "text"],
["image-zone", "structure"],
];
it.each(cases)("current=%s, requested=%s -> requested", (current, requested) => {
expect(nextEditMode(current, requested)).toBe(requested);
});
});
it("is referentially transparent — multiple calls with same inputs return same output", () => {
const a = nextEditMode("text", "structure");
const b = nextEditMode("text", "structure");
const c = nextEditMode("text", "structure");
expect(a).toBe("structure");
expect(b).toBe("structure");
expect(c).toBe("structure");
});
it("never returns a value outside the EditMode union", () => {
const all: EditMode[] = ["off", "text", "structure", "image-zone"];
for (const current of all) {
for (const requested of all) {
const result = nextEditMode(current, requested);
expect(all).toContain(result);
}
}
});
it("preserves toggle semantics under repeated identical clicks", () => {
// off -> text -> off -> text -> off (toggle behavior)
let m: EditMode = "off";
m = nextEditMode(m, "text");
expect(m).toBe("text");
m = nextEditMode(m, "text");
expect(m).toBe("off");
m = nextEditMode(m, "text");
expect(m).toBe("text");
m = nextEditMode(m, "text");
expect(m).toBe("off");
});
it("preserves switch semantics across distinct mode clicks", () => {
// off -> text -> structure -> image-zone -> off (via toggle)
let m: EditMode = "off";
m = nextEditMode(m, "text");
expect(m).toBe("text");
m = nextEditMode(m, "structure");
expect(m).toBe("structure");
m = nextEditMode(m, "image-zone");
expect(m).toBe("image-zone");
m = nextEditMode(m, "image-zone");
expect(m).toBe("off");
});
});
@@ -1,255 +0,0 @@
// IMP-56 (#90) u19 — vitest coverage for the vite POST /api/export
// middleware and its supporting inlineAssetsAsDataUrls helper.
//
// Scope:
// 1) inlineAssetsAsDataUrls (pure helper):
// - no url(assets/...) refs → passthrough.
// - single PNG ref → inlined as base64 data: URL with image/png mime.
// - multiple refs → all inlined.
// - SVG ref → image/svg+xml mime.
// - missing asset file → left as-is (no throw, no rewrite).
// - data:/http:/ URLs (non-asset) → untouched.
// 2) handleExportStandalone (POST):
// - method != POST → false (chain continues; next middleware may handle).
// - invalid JSON / non-object body → 400.
// - missing run_id → 400.
// - invalid run_id (key gate / path traversal) → 400.
// - final.html missing → 404.
// - success → 200 with Content-Disposition: attachment; filename=...,
// Content-Type: text/html; charset=utf-8, body = inlined HTML.
//
// Tests exercise the pure handler with mock req/res — no real vite server.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
handleExportStandalone,
inlineAssetsAsDataUrls,
} from "../../vite.config";
function makeMockRes() {
const state = {
statusCode: 0,
headers: {} as Record<string, string>,
body: "",
ended: false,
};
return {
state,
res: {
writeHead(status: number, headers?: Record<string, string>) {
state.statusCode = status;
if (headers) state.headers = headers;
},
end(body?: string) {
state.body = body ?? "";
state.ended = true;
},
},
};
}
function makeMockReq(opts: {
method?: string;
}): EventEmitter & { method?: string; send: (body: string) => void } {
const ee = new EventEmitter() as EventEmitter & {
method?: string;
send: (body: string) => void;
};
ee.method = opts.method;
ee.send = (body: string) => {
if (body.length > 0) ee.emit("data", Buffer.from(body, "utf-8"));
ee.emit("end");
};
return ee;
}
function seedRun(
daRoot: string,
runId: string,
htmlBody: string,
assets?: Record<string, Buffer | string>,
): string {
const runDir = path.join(daRoot, "data", "runs", runId, "phase_z2");
fs.mkdirSync(runDir, { recursive: true });
const html = path.join(runDir, "final.html");
fs.writeFileSync(html, htmlBody, "utf-8");
if (assets) {
for (const [rel, buf] of Object.entries(assets)) {
const dst = path.join(runDir, "assets", rel);
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.writeFileSync(dst, buf);
}
}
return runDir;
}
describe("inlineAssetsAsDataUrls (IMP-56 #90 u19)", () => {
let tmp: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u19-inline-"));
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it("returns html unchanged when no url(assets/...) refs are present", () => {
const html = "<html><style>body{color:red;}</style><body>hi</body></html>";
expect(inlineAssetsAsDataUrls(html, tmp)).toBe(html);
});
it("inlines a single PNG asset as a base64 data: URL with image/png mime", () => {
fs.mkdirSync(path.join(tmp, "frame_x"), { recursive: true });
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
fs.writeFileSync(path.join(tmp, "frame_x", "a.png"), pngBytes);
const html = "background: url(assets/frame_x/a.png);";
const out = inlineAssetsAsDataUrls(html, tmp);
expect(out).toContain(`url("data:image/png;base64,${pngBytes.toString("base64")}")`);
expect(out).not.toContain("url(assets/frame_x/a.png)");
});
it("inlines multiple refs across the same HTML body", () => {
fs.mkdirSync(path.join(tmp, "f"), { recursive: true });
fs.writeFileSync(path.join(tmp, "f", "one.png"), Buffer.from("ONE"));
fs.writeFileSync(path.join(tmp, "f", "two.png"), Buffer.from("TWO"));
const html = "a{background:url(assets/f/one.png)} b{background:url(assets/f/two.png)}";
const out = inlineAssetsAsDataUrls(html, tmp);
expect(out).toContain(`data:image/png;base64,${Buffer.from("ONE").toString("base64")}`);
expect(out).toContain(`data:image/png;base64,${Buffer.from("TWO").toString("base64")}`);
});
it("uses image/svg+xml mime for .svg refs", () => {
fs.mkdirSync(path.join(tmp, "f"), { recursive: true });
fs.writeFileSync(path.join(tmp, "f", "icon.svg"), "<svg/>", "utf-8");
const html = "url(assets/f/icon.svg)";
const out = inlineAssetsAsDataUrls(html, tmp);
expect(out).toContain("data:image/svg+xml;base64,");
});
it("leaves the ref untouched when the asset file is missing", () => {
const html = "url(assets/missing/file.png)";
const out = inlineAssetsAsDataUrls(html, tmp);
expect(out).toBe(html);
});
it("does not touch data: or http(s): url() values (only matches assets/...)", () => {
const html =
"x{background:url(data:image/png;base64,AAA)} " +
"y{background:url(https://cdn.x/a.png)}";
expect(inlineAssetsAsDataUrls(html, tmp)).toBe(html);
});
it("handles quoted url(...) refs (single and double quotes)", () => {
fs.mkdirSync(path.join(tmp, "q"), { recursive: true });
fs.writeFileSync(path.join(tmp, "q", "k.png"), Buffer.from("K"));
const html =
"a{background:url('assets/q/k.png')} b{background:url(\"assets/q/k.png\")}";
const out = inlineAssetsAsDataUrls(html, tmp);
const data = `data:image/png;base64,${Buffer.from("K").toString("base64")}`;
expect(out.split(data).length - 1).toBe(2);
});
});
describe("handleExportStandalone (IMP-56 #90 u19)", () => {
let daRoot: string;
beforeEach(() => {
daRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u19-da-"));
});
afterEach(() => {
fs.rmSync(daRoot, { recursive: true, force: true });
});
it("returns false (next chained) when method != POST", () => {
const req = makeMockReq({ method: "GET" });
const { res, state } = makeMockRes();
const handled = handleExportStandalone(req, res, daRoot);
expect(handled).toBe(false);
expect(state.ended).toBe(false);
});
it("returns 400 on invalid JSON body", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
const handled = handleExportStandalone(req, res, daRoot);
expect(handled).toBe(true);
req.send("{nope");
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("invalid JSON");
});
it("returns 400 when body is not a JSON object (array root)", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleExportStandalone(req, res, daRoot);
req.send(JSON.stringify(["x"]));
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("body must be a JSON object");
});
it("returns 400 when run_id is missing", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleExportStandalone(req, res, daRoot);
req.send(JSON.stringify({}));
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("missing run_id");
});
it("returns 400 when run_id contains path traversal", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleExportStandalone(req, res, daRoot);
req.send(JSON.stringify({ run_id: "../escape" }));
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("invalid run_id");
});
it("returns 404 when final.html does not exist for run_id", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleExportStandalone(req, res, daRoot);
req.send(JSON.stringify({ run_id: "ghost_run" }));
expect(state.statusCode).toBe(404);
expect(JSON.parse(state.body).error).toBe("final.html not found");
});
it("returns 200 with text/html body + Content-Disposition on success", () => {
seedRun(daRoot, "mdx03_run", "<html><body>03</body></html>");
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleExportStandalone(req, res, daRoot);
req.send(JSON.stringify({ run_id: "mdx03_run" }));
expect(state.statusCode).toBe(200);
expect(state.headers["Content-Type"]).toBe("text/html; charset=utf-8");
expect(state.headers["Content-Disposition"]).toBe(
'attachment; filename="mdx03_run.html"',
);
expect(state.body).toBe("<html><body>03</body></html>");
});
it("inlines assets in final.html when run dir has assets/", () => {
const pngBytes = Buffer.from("PNGDATA");
seedRun(
daRoot,
"mdx05_run",
"<html><body><div style=\"background: url(assets/f/x.png)\"></div></body></html>",
{ "f/x.png": pngBytes },
);
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleExportStandalone(req, res, daRoot);
req.send(JSON.stringify({ run_id: "mdx05_run" }));
expect(state.statusCode).toBe(200);
expect(state.body).toContain(
`data:image/png;base64,${pngBytes.toString("base64")}`,
);
expect(state.body).not.toContain("url(assets/f/x.png)");
});
});
@@ -1,150 +0,0 @@
// IMP-90 (#90) u14 — vitest coverage for the pure helpers exported by
// `StructureEditOverlay`. The React component itself is not rendered
// (jsdom / @testing-library NOT in Front devDependencies — verified in
// `Front/package.json`); we test the deterministic pieces that drive its
// JSX: `resolveEffectiveSlotOrder` (effective-order resolution under
// override) and `moveItem` (immutable reorder primitive).
//
// Upstream / downstream contracts (verified by prior units):
// - u2 KNOWN_AXES += structure_overrides (Python backend).
// - u3 vite allowlist += structure_overrides.
// - u6 structure_override_resolver — inner shape locked to
// {slot_order, hidden_slots}; frame swap REJECTED to existing
// frames axis.
// - u10 typed-client `StructureOverridePerZone` + extract helper.
// - u15 (next) will debounce + PUT the emitted capture.
//
// u14 scope: pure helpers only. React render path is verified by Codex
// auditor via static read of the JSX (no runtime test possible without
// jsdom). Tests below are intentionally side-effect-free.
import { describe, it, expect } from "vitest";
import {
resolveEffectiveSlotOrder,
moveItem,
} from "../src/components/StructureEditOverlay";
// ─────────────────────────────────────────────────────────────────────
// resolveEffectiveSlotOrder
// ─────────────────────────────────────────────────────────────────────
describe("resolveEffectiveSlotOrder — no override", () => {
it("returns a fresh copy of the discovered keys when slotOrder is undefined", () => {
const discovered = ["a", "b", "c"];
const out = resolveEffectiveSlotOrder(discovered, undefined);
expect(out).toEqual(["a", "b", "c"]);
expect(out).not.toBe(discovered);
});
it("returns a fresh copy when slotOrder is null", () => {
const out = resolveEffectiveSlotOrder(["a", "b"], null);
expect(out).toEqual(["a", "b"]);
});
it("returns a fresh copy when slotOrder is empty []", () => {
const out = resolveEffectiveSlotOrder(["a", "b"], []);
expect(out).toEqual(["a", "b"]);
});
it("handles empty discovered list (no slots in zone)", () => {
expect(resolveEffectiveSlotOrder([], undefined)).toEqual([]);
expect(resolveEffectiveSlotOrder([], ["x"])).toEqual([]);
});
});
describe("resolveEffectiveSlotOrder — full override", () => {
it("reorders all discovered keys per slotOrder", () => {
expect(
resolveEffectiveSlotOrder(["a", "b", "c"], ["c", "a", "b"]),
).toEqual(["c", "a", "b"]);
});
it("is idempotent when slotOrder matches discovered order", () => {
expect(
resolveEffectiveSlotOrder(["a", "b", "c"], ["a", "b", "c"]),
).toEqual(["a", "b", "c"]);
});
});
describe("resolveEffectiveSlotOrder — partial / drift override", () => {
it("appends missing discovered keys in backend order at the tail", () => {
// user reordered b -> first, but c was added later by backend.
expect(
resolveEffectiveSlotOrder(["a", "b", "c"], ["b", "a"]),
).toEqual(["b", "a", "c"]);
});
it("drops override entries that no longer exist in discovered keys", () => {
// user had slot 'x' before; backend dropped it.
expect(
resolveEffectiveSlotOrder(["a", "b"], ["x", "a", "b"]),
).toEqual(["a", "b"]);
});
it("dedupes duplicate entries within slotOrder", () => {
expect(
resolveEffectiveSlotOrder(["a", "b", "c"], ["a", "a", "b"]),
).toEqual(["a", "b", "c"]);
});
it("dedupe + drop + append all together (stress)", () => {
expect(
resolveEffectiveSlotOrder(
["a", "b", "c", "d"],
["d", "x", "d", "a", "ghost"],
),
).toEqual(["d", "a", "b", "c"]);
});
it("ignores non-string entries in slotOrder", () => {
const bogus = ["a", null as unknown as string, undefined as unknown as string, "b"];
expect(resolveEffectiveSlotOrder(["a", "b"], bogus)).toEqual(["a", "b"]);
});
});
// ─────────────────────────────────────────────────────────────────────
// moveItem
// ─────────────────────────────────────────────────────────────────────
describe("moveItem — happy paths", () => {
it("moves index 0 down by 1 (swap with index 1)", () => {
expect(moveItem(["a", "b", "c"], 0, 1)).toEqual(["b", "a", "c"]);
});
it("moves index 2 up by 1 (swap with index 1)", () => {
expect(moveItem(["a", "b", "c"], 2, -1)).toEqual(["a", "c", "b"]);
});
it("moves across larger delta (swap with target)", () => {
expect(moveItem(["a", "b", "c", "d"], 0, 2)).toEqual(["c", "b", "a", "d"]);
});
});
describe("moveItem — bounds", () => {
it("no-op (fresh copy) when moving first up", () => {
const src = ["a", "b", "c"];
const out = moveItem(src, 0, -1);
expect(out).toEqual(["a", "b", "c"]);
expect(out).not.toBe(src);
});
it("no-op when moving last down", () => {
expect(moveItem(["a", "b", "c"], 2, 1)).toEqual(["a", "b", "c"]);
});
it("no-op when index negative", () => {
expect(moveItem(["a", "b"], -1, 1)).toEqual(["a", "b"]);
});
it("no-op when index past end", () => {
expect(moveItem(["a", "b"], 5, -1)).toEqual(["a", "b"]);
});
it("no-op when target falls out of range from large delta", () => {
expect(moveItem(["a", "b", "c"], 1, 99)).toEqual(["a", "b", "c"]);
});
it("no-op on empty array (any index)", () => {
expect(moveItem<string>([], 0, 1)).toEqual([]);
});
});
describe("moveItem — immutability", () => {
it("never mutates the input array", () => {
const src = ["a", "b", "c"];
moveItem(src, 0, 1);
expect(src).toEqual(["a", "b", "c"]);
});
it("returns a new reference even when no-op", () => {
const src = ["a", "b"];
expect(moveItem(src, 0, -1)).not.toBe(src);
});
it("preserves T-typed values (number array)", () => {
expect(moveItem([1, 2, 3], 0, 1)).toEqual([2, 1, 3]);
});
});
@@ -1,259 +0,0 @@
// IMP-90 (#90) u13 — vitest coverage for `deriveTextEditCapture`, the pure
// helper that resolves a contentEditable focusout target into the
// (zone_id, text_path, value) capture tuple emitted by SlideCanvas.
//
// Upstream contract (verified by prior units):
// - u8 `src/text_path_stamper.py` stamps `data-text-path="{slot_key}.{
// line_index}"` on every rendered text-line opening tag at Step 13.
// - u9 wires the stamper into `render_slide` so the final.html consumed
// by SlideCanvas's iframe carries those attributes.
// - Phase Z slide-base wraps every zone in `.zone[data-zone-position]`
// (verified at SlideCanvas.tsx onLoad measure block).
//
// u13 scope: derive the capture tuple from any descendant of a stamped
// line, OR the stamped line itself. Non-stamped targets (slide-base
// title/footer, decorative spans outside the zone tree) return null so
// the focusout handler silently skips them — never crashes.
//
// Forward-compat note: u15 will debounce + PUT the capture; u15 MUST NOT
// alter the (target) -> {zoneId, textPath, value} | null contract verified
// here. Any change to the resolution semantics is a scope-violation
// against the u13 binding contract.
//
// jsdom is NOT in devDependencies (verified in Front/package.json); this
// test mocks `TextEditCaptureTarget` with structurally-typed objects per
// the established u11/u12 pure-helper pattern.
import { describe, it, expect } from "vitest";
import {
deriveTextEditCapture,
type TextEditCapture,
type TextEditCaptureTarget,
} from "../src/components/SlideCanvas";
// --- minimal closest-aware mock builders -----------------------------
// Each node only needs to know which selectors it matches and its
// parent chain — `closest` is implemented by walking parent pointers.
interface MockNodeSpec {
matches: string[];
attrs?: Record<string, string>;
text?: string | null;
parent?: MockNode | null;
}
interface MockNode extends TextEditCaptureTarget {
matches(sel: string): boolean;
parent: MockNode | null;
}
function makeNode(spec: MockNodeSpec): MockNode {
const node: MockNode = {
parent: spec.parent ?? null,
matches(sel: string) {
return spec.matches.includes(sel);
},
closest(sel: string): TextEditCaptureTarget | null {
let cur: MockNode | null = node;
while (cur) {
if (cur.matches(sel)) return cur;
cur = cur.parent;
}
return null;
},
getAttribute(name: string): string | null {
return spec.attrs?.[name] ?? null;
},
textContent: spec.text === undefined ? null : spec.text,
};
return node;
}
// Canonical zone + line scaffold used across happy-path tests.
// `null` for any field is preserved verbatim so edge cases (missing attr /
// null textContent) can exercise the helper's defensive branches.
function makeZoneLineScaffold(opts: {
zoneId?: string | null;
textPath?: string | null;
lineText?: string | null;
}) {
const zone = makeNode({
matches: [".zone[data-zone-position]"],
attrs: opts.zoneId === null ? {} : { "data-zone-position": opts.zoneId ?? "top" },
});
const line = makeNode({
matches: ["[data-text-path]"],
attrs:
opts.textPath === null
? {}
: { "data-text-path": opts.textPath ?? "row_1_left_body.0" },
text: opts.lineText === undefined ? "hello world" : opts.lineText,
parent: zone,
});
return { zone, line };
}
describe("deriveTextEditCapture (IMP-90 u13) — null inputs / non-stamped", () => {
it("returns null when target is null", () => {
expect(deriveTextEditCapture(null)).toBeNull();
});
it("returns null when no ancestor has data-text-path (e.g., slide title)", () => {
const title = makeNode({
matches: [".slide-title"],
text: "Phase Z 슬라이드",
});
expect(deriveTextEditCapture(title)).toBeNull();
});
it("returns null when the stamped line has no enclosing zone", () => {
// Decorative line stamped by the future u8 but rendered outside a
// zone (e.g., footer pill). u13 silently skips — caller never sees
// a half-resolved capture.
const orphanLine = makeNode({
matches: ["[data-text-path]"],
attrs: { "data-text-path": "footer.0" },
text: "결론",
});
expect(deriveTextEditCapture(orphanLine)).toBeNull();
});
});
describe("deriveTextEditCapture (IMP-90 u13) — happy path", () => {
it("resolves (zoneId, textPath, value) when target IS the stamped line", () => {
const { line } = makeZoneLineScaffold({
zoneId: "top",
textPath: "row_1_left_body.0",
lineText: "분석 결과",
});
expect(deriveTextEditCapture(line)).toEqual<TextEditCapture>({
zoneId: "top",
textPath: "row_1_left_body.0",
value: "분석 결과",
});
});
it("walks up to the stamped line when target is a nested descendant", () => {
const { zone, line } = makeZoneLineScaffold({
zoneId: "bottom_l",
textPath: "left_body.2",
lineText: "wrapped",
});
// emulate a SPAN inside the stamped line (e.g., bold inline span)
const innerSpan = makeNode({
matches: ["span.highlight"],
text: "ignored — closest walks to the line",
parent: line,
});
void zone;
expect(deriveTextEditCapture(innerSpan)).toEqual<TextEditCapture>({
zoneId: "bottom_l",
textPath: "left_body.2",
value: "wrapped",
});
});
it("preserves the line's textContent without HTML normalization", () => {
const { line } = makeZoneLineScaffold({
zoneId: "primary",
textPath: "headline.0",
lineText: " spaced inner words ",
});
// u13 trims outer whitespace but does NOT collapse interior whitespace
// — value mirrors what user typed, modulo blur-edge trim.
expect(deriveTextEditCapture(line)?.value).toBe("spaced inner words");
});
it("returns empty string when textContent is null (edge: empty line)", () => {
const { line } = makeZoneLineScaffold({
zoneId: "top",
textPath: "row_1_left_body.0",
lineText: null,
});
expect(deriveTextEditCapture(line)?.value).toBe("");
});
it("returns empty string when textContent is whitespace-only", () => {
const { line } = makeZoneLineScaffold({
zoneId: "top",
textPath: "row_1_left_body.0",
lineText: " \n \t ",
});
expect(deriveTextEditCapture(line)?.value).toBe("");
});
});
describe("deriveTextEditCapture (IMP-90 u13) — missing attribute defensiveness", () => {
it("returns null when data-text-path attribute is absent on the matched line", () => {
// Should not happen with the u8 stamper, but a downstream mutation
// (e.g., user pasting a fresh element) could create a stamped-class
// node without the actual attribute. u13 stays defensive.
const zone = makeNode({
matches: [".zone[data-zone-position]"],
attrs: { "data-zone-position": "top" },
});
const lineNoPath = makeNode({
matches: ["[data-text-path]"],
attrs: {},
text: "hello",
parent: zone,
});
expect(deriveTextEditCapture(lineNoPath)).toBeNull();
});
it("returns null when data-zone-position attribute is absent on the matched zone", () => {
const zoneNoId = makeNode({
matches: [".zone[data-zone-position]"],
attrs: {},
});
const line = makeNode({
matches: ["[data-text-path]"],
attrs: { "data-text-path": "row_1_left_body.0" },
text: "hello",
parent: zoneNoId,
});
expect(deriveTextEditCapture(line)).toBeNull();
});
});
describe("deriveTextEditCapture (IMP-90 u13) — referential transparency", () => {
it("multiple calls with the same target return equal captures", () => {
const { line } = makeZoneLineScaffold({
zoneId: "top",
textPath: "row_1_left_body.0",
lineText: "stable",
});
const a = deriveTextEditCapture(line);
const b = deriveTextEditCapture(line);
expect(a).toEqual(b);
expect(a).not.toBe(b); // fresh objects each call (caller-friendly)
});
it("does not mutate the target element (attrs / parent / textContent unchanged)", () => {
const { line, zone } = makeZoneLineScaffold({
zoneId: "top",
textPath: "row_1_left_body.0",
lineText: "immutable",
});
deriveTextEditCapture(line);
expect(line.getAttribute("data-text-path")).toBe("row_1_left_body.0");
expect(line.textContent).toBe("immutable");
expect(zone.getAttribute("data-zone-position")).toBe("top");
});
});
describe("deriveTextEditCapture (IMP-90 u13) — zone id pass-through", () => {
// u13 does not validate the zone id shape — Phase Z slide-base owns the
// canonical zone position vocabulary, and u15 / pipeline-side resolver
// (u4) re-validate downstream. u13 just forwards whatever the stamped
// DOM declared.
const ZONE_IDS = ["top", "bottom_l", "bottom_r", "primary", "secondary"];
it.each(ZONE_IDS)("preserves zone id '%s' verbatim", (zid) => {
const { line } = makeZoneLineScaffold({
zoneId: zid,
textPath: `${zid}.0`,
lineText: "x",
});
const cap = deriveTextEditCapture(line);
expect(cap?.zoneId).toBe(zid);
expect(cap?.textPath).toBe(`${zid}.0`);
});
});
@@ -1,176 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildAiTraceSummary, buildPipelineTraceSummary } from "../src/services/designAgentApi";
describe("#98 Task 6 pipeline trace summary", () => {
it("connects sections, units, zones, and render-blocked warnings", () => {
const trace = buildPipelineTraceSummary({
normalized: {
data: {
sections: [
{ section_id: "01-1", title: "S1" },
{ section_id: "01-2", title: "S2" },
],
},
},
compositionPlan: {
data: {
selected_units: [
{
source_section_ids: ["01-1"],
frame_template_id: "frame_a",
label: "use_as_is",
selection_path: "rank_1",
},
{
source_section_ids: ["01-2"],
frame_template_id: "frame_b",
label: "use_as_is",
selection_path: "provisional_rank_1",
},
],
},
},
applicationPlan: {
data: {
units: [
{
unit_id: "01-1",
current_default_candidate: "frame_a",
sorted_candidate_evidence: [
{ template_id: "frame_a", label: "use_as_is", confidence: 0.9 },
],
},
{
unit_id: "01-2",
current_default_candidate: "frame_b",
sorted_candidate_evidence: [
{ template_id: "frame_b", label: "use_as_is", confidence: 0.8 },
],
},
],
},
},
slotPayload: {
data: {
per_zone: [
{ position: "top", template_id: "frame_a", slot_payload: { title: "S1" } },
{ position: "bottom", template_id: "__empty__", slot_payload: {} },
],
},
},
slideStatus: {
data: {
overall: "PARTIAL_COVERAGE",
covered_section_ids: ["01-1", "01-2"],
content_rendered_section_ids: ["01-1"],
filtered_section_ids: ["01-2"],
render_blocked_section_ids: ["01-2"],
adapter_needed_count: 1,
adapter_needed_units: [
{
position: "bottom",
source_section_ids: ["01-2"],
reason: "fit_error",
},
],
},
},
});
expect(trace.sections.map((section) => [section.id, section.state])).toEqual([
["01-1", "rendered"],
["01-2", "render_blocked"],
]);
expect(trace.sections[1].reasons).toEqual([
"fit_error",
"filtered",
]);
expect(trace.units.map((unit) => [unit.unit_id, unit.candidate_count])).toEqual([
["01-1", 1],
["01-2", 1],
]);
expect(trace.zones[1]).toMatchObject({
position: "bottom",
source_section_ids: ["01-2"],
template_id: "__empty__",
slot_status: "empty",
warnings: ["__empty__", "empty_slot_payload", "fit_error"],
});
expect(trace.warnings).toContain("render_blocked:01-2");
expect(trace.warnings).toContain("adapter_needed:1");
});
});
describe("#98 Task 7 AI trace summary", () => {
it("summarizes AI called/skipped/error state from step12_ai_repair", () => {
const trace = buildAiTraceSummary(
{
step_status: "done",
data: {
per_unit: [
{
unit_index: 0,
source_section_ids: ["04-1"],
frame_template_id: "frame_reject",
route_hint: "ai_adaptation_required",
provisional: true,
ai_called: false,
skip_reason: "router_short_circuit",
apply_status: "no_proposal",
api_error_kind: null,
error: null,
},
{
unit_index: 1,
source_section_ids: ["04-2"],
frame_template_id: "frame_auto",
route_hint: "direct_render",
provisional: false,
ai_called: false,
skip_reason: "not_provisional",
apply_status: "no_proposal",
api_error_kind: null,
error: null,
},
],
coverage_invariant: { status: "ok" },
},
},
{
status: "ok",
counts: { total: 2, applied: 0, no_proposal: 2, no_zone_match: 0, unsupported_kind: 0, error: 0 },
unsupported_kind_records: [],
error_records: [],
coverage_status: "ok",
dropped_section_ids: [],
human_review_required: false,
},
);
expect(trace.artifact_present).toBe(true);
expect(trace.ai_enabled).toBe(false);
expect(trace.ai_called_count).toBe(0);
expect(trace.eligible_count).toBe(1);
expect(trace.skip_reasons).toEqual({
router_short_circuit: 1,
not_provisional: 1,
});
expect(trace.warnings).toContain("router_short_circuit");
expect(trace.units[0]).toMatchObject({
source_section_ids: ["04-1"],
route_hint: "ai_adaptation_required",
provisional: true,
ai_called: false,
skip_reason: "router_short_circuit",
});
});
it("surfaces missing step12 AI artifact as an explicit warning", () => {
const trace = buildAiTraceSummary(null, null);
expect(trace.artifact_present).toBe(false);
expect(trace.ai_enabled).toBeNull();
expect(trace.status).toBe("missing_artifact");
expect(trace.warnings).toEqual(["step12_ai_repair.json missing"]);
});
});
@@ -1,58 +0,0 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const HOME_TSX = readFileSync(
resolve(__dirname, "..", "src", "pages", "Home.tsx"),
"utf-8",
);
const SLIDE_CANVAS_TSX = readFileSync(
resolve(__dirname, "..", "src", "components", "SlideCanvas.tsx"),
"utf-8",
);
function sliceHandleGenerateBody(source: string): string {
const startMarker = "const handleGenerate = useCallback(async () =>";
const startIdx = source.indexOf(startMarker);
if (startIdx === -1) throw new Error("handleGenerate declaration not found");
const afterStart = source.slice(startIdx + startMarker.length);
const nextDeclIdx = afterStart.search(/\n {2}const [A-Za-z]/);
return nextDeclIdx === -1 ? afterStart : afterStart.slice(0, nextDeclIdx);
}
const HANDLE_GENERATE_BODY = sliceHandleGenerateBody(HOME_TSX);
describe("#98 Task 4 render stale guard", () => {
it("clears prior runMeta before invoking runPipeline", () => {
const clearIdx = HANDLE_GENERATE_BODY.indexOf("setRunMeta(null)");
const loadingIdx = HANDLE_GENERATE_BODY.indexOf("isLoading: true");
const runPipelineIdx = HANDLE_GENERATE_BODY.indexOf("runPipeline(");
expect(clearIdx).toBeGreaterThan(-1);
expect(loadingIdx).toBeGreaterThan(-1);
expect(runPipelineIdx).toBeGreaterThan(-1);
expect(clearIdx).toBeLessThan(runPipelineIdx);
expect(loadingIdx).toBeLessThan(runPipelineIdx);
});
it("does not pass a finalHtmlUrl to SlideCanvas while a pipeline run is loading", () => {
expect(HOME_TSX).toContain(
"finalHtmlUrl={state.isLoading ? undefined : runMeta?.final_html_url}",
);
});
it("forces iframe remounts by keying the iframe on embeddedSrc", () => {
expect(SLIDE_CANVAS_TSX).toContain("key={embeddedSrc}");
});
it("resets iframe-derived overlay state on render url, loading, or pending-layout changes", () => {
expect(SLIDE_CANVAS_TSX).toContain(
"}, [finalHtmlUrl, isPipelineRunning, isPendingLayout]);",
);
expect(SLIDE_CANVAS_TSX).toContain("setMeasuredZones({});");
expect(SLIDE_CANVAS_TSX).toContain("setMeasuredImages({});");
expect(SLIDE_CANVAS_TSX).toContain("setSelectedImageId(null);");
expect(SLIDE_CANVAS_TSX).toContain("setDragOverZoneId(null);");
expect(SLIDE_CANVAS_TSX).toContain("setEditMode(\"off\");");
});
});
@@ -1,278 +0,0 @@
// IMP-43 (#72) u6 — /api/run reuseFromRunId forwarding coverage.
//
// Stage 2 unit scope:
// 1) Front/client/src/services/designAgentApi.ts `runPipeline`:
// • accepts an optional 3rd arg `reuseFromRunId: string`.
// • includes `reuseFromRunId` in the POST body when truthy.
// • OMITS `reuseFromRunId` from the body when absent / empty / undefined
// → byte-identical to the pre-u6 POST contract (absent flag = full
// pipeline; backend u1 guard never sees an empty PREV_RUN_ID).
// • leaves `filename`, `content`, and `overrides` untouched alongside
// the new field (no payload-shape regression).
// 2) Front/vite.config.ts `/api/run` handler:
// • declares `reuseFromRunId?: string` in the payload type so a typed
// client cannot send a payload the server silently drops.
// • destructures `reuseFromRunId` from `payload` (sibling of
// `overrides`, NOT nested under it — the backend u1 post-merge
// guard treats reuse as a pipeline mode, not an override).
// • forwards `--reuse-from <PREV_RUN_ID>` to spawn cliArgs guarded by
// a truthy check (empty string / undefined ⇒ no flag, per Stage 2
// contract: invalid CLI args must never reach argparse).
// • places the forward block AFTER the `--override-section-assignment`
// loop so the spawn argv preserves backend argparse's no-positional-
// before-flag expectation and so `--override-frame` (still allowed
// by the u1 guard) is positioned ahead of `--reuse-from`.
//
// runPipeline is exercised with a duck-typed `File` plus a `vi.stubGlobal`
// fetch mock — mirrors the user_overrides_service.test.ts pattern. The
// vite handler is source-sliced (mirrors handle_generate_diag.test.ts)
// because the handler spawns python and a real /api/run round-trip is
// out of unit-test scope.
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { runPipeline } from "../src/services/designAgentApi";
// ---------------------------------------------------------------------------
// vite.config.ts source — read once for the handler source-slice assertions.
// Path: Front/client/tests/ → Front/vite.config.ts (two levels up).
// ---------------------------------------------------------------------------
const VITE_CONFIG_PATH = resolve(__dirname, "..", "..", "vite.config.ts");
const VITE_CONFIG_SOURCE = readFileSync(VITE_CONFIG_PATH, "utf-8");
// ---------------------------------------------------------------------------
// fetch mock — minimal Response stub mirroring runPipeline's `.ok` + `.json()`
// + `.status` surface. Same shape as the user_overrides_service.test.ts
// helper so the two test files stay drift-free.
// ---------------------------------------------------------------------------
type MockResponse = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
};
function mockResponse(body: unknown, ok = true, status = 200): MockResponse {
return { ok, status, json: async () => body };
}
const SUCCESS_BODY = {
success: true,
run_id: "test_run_id_20260524",
exit_code: 0,
final_html_exists: true,
preview_exists: true,
stdout: "",
stderr: "",
};
// Duck-typed File — runPipeline reads only `.name` and `.text()`. Avoids a
// hard dependency on the global File constructor (varies across node /
// jsdom / happy-dom test environments).
function makeFakeFile(name: string, content: string): File {
return {
name,
text: async () => content,
} as unknown as File;
}
let fetchMock: Mock;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function lastPostBody(): Record<string, unknown> {
const lastCall = fetchMock.mock.calls.at(-1);
if (!lastCall) throw new Error("fetch was not called");
const init = lastCall[1] as RequestInit | undefined;
if (!init?.body) throw new Error("fetch was called without a body");
return JSON.parse(String(init.body));
}
// ============================================================================
// runPipeline (designAgentApi.ts) — forwarding/omission coverage
// ============================================================================
describe("runPipeline reuseFromRunId forwarding (IMP-43 #72 u6)", () => {
it("posts to /api/run via POST with JSON content-type", async () => {
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
await runPipeline(makeFakeFile("03.mdx", "# title"));
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("/api/run");
expect((init as RequestInit).method).toBe("POST");
expect((init as RequestInit).headers).toMatchObject({
"Content-Type": "application/json",
});
});
it("includes reuseFromRunId in the POST body when provided", async () => {
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
await runPipeline(
makeFakeFile("03.mdx", "# title"),
undefined,
"mdx03_20260524080000",
);
const body = lastPostBody();
expect(body.reuseFromRunId).toBe("mdx03_20260524080000");
expect(body.filename).toBe("03.mdx");
expect(body.content).toBe("# title");
});
it("omits reuseFromRunId when 3rd arg is undefined (pre-u6 byte-identical)", async () => {
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
await runPipeline(makeFakeFile("03.mdx", "# title"));
const body = lastPostBody();
expect("reuseFromRunId" in body).toBe(false);
// Pre-u6 contract: filename/content are the only keys when overrides
// is undefined (JSON.stringify drops undefined values; pre-u6 emitted
// `JSON.stringify({filename, content, overrides})` with the same
// drop-undefined behaviour, so the wire body is byte-identical).
expect(Object.keys(body).sort()).toEqual(["content", "filename"]);
});
it("omits reuseFromRunId but keeps overrides when only overrides provided", async () => {
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
await runPipeline(makeFakeFile("03.mdx", "# title"), {
frames: { "03-1": "frame_07" },
});
const body = lastPostBody();
expect("reuseFromRunId" in body).toBe(false);
expect(Object.keys(body).sort()).toEqual([
"content",
"filename",
"overrides",
]);
expect(body.overrides).toEqual({ frames: { "03-1": "frame_07" } });
});
it("omits reuseFromRunId when passed an empty string (truthy guard)", async () => {
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
await runPipeline(makeFakeFile("03.mdx", "# title"), undefined, "");
const body = lastPostBody();
expect("reuseFromRunId" in body).toBe(false);
});
it("forwards reuseFromRunId alongside frame overrides (the only u1-permitted combo)", async () => {
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
await runPipeline(
makeFakeFile("03.mdx", "# title"),
{ frames: { "03-1+03-2": "frame_07" } },
"mdx03_20260524080000",
);
const body = lastPostBody();
expect(body.overrides).toEqual({ frames: { "03-1+03-2": "frame_07" } });
expect(body.reuseFromRunId).toBe("mdx03_20260524080000");
});
it("returns the parsed RunPipelineResult on success", async () => {
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
const res = await runPipeline(
makeFakeFile("03.mdx", "# title"),
undefined,
"mdx03_20260524080000",
);
expect(res.success).toBe(true);
expect(res.run_id).toBe("test_run_id_20260524");
});
it("includes ignoreUserOverrides only when clean generate requests it", async () => {
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
await runPipeline(
makeFakeFile("03.mdx", "# title"),
undefined,
undefined,
{ ignoreUserOverrides: true },
);
const cleanBody = lastPostBody();
expect(cleanBody.ignoreUserOverrides).toBe(true);
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
await runPipeline(makeFakeFile("03.mdx", "# title"));
const normalBody = lastPostBody();
expect("ignoreUserOverrides" in normalBody).toBe(false);
});
});
// ============================================================================
// /api/run handler (vite.config.ts) — source-slice forwarding contract
// ============================================================================
describe("/api/run handler reuseFromRunId source-slice (IMP-43 #72 u6)", () => {
it("declares reuseFromRunId?: string on the /api/run payload type", () => {
// Payload type at the top of the /api/run handler body. The
// optional-string declaration is the single source-of-truth for what
// shape the handler accepts; a typed frontend client (u5 saveUserOverrides
// sibling pattern) cannot silently send a payload the server drops.
expect(VITE_CONFIG_SOURCE).toMatch(/reuseFromRunId\?:\s*string\s*;/);
});
it("declares ignoreUserOverrides?: boolean on the /api/run payload type", () => {
expect(VITE_CONFIG_SOURCE).toMatch(/ignoreUserOverrides\?:\s*boolean\s*;/);
});
it("destructures reuseFromRunId from payload alongside filename/content/overrides", () => {
expect(VITE_CONFIG_SOURCE).toMatch(
/const\s*\{\s*filename\s*,\s*content\s*,\s*overrides\s*,\s*reuseFromRunId\s*,\s*ignoreUserOverrides\s*\}\s*=\s*payload\s*;/,
);
});
it("forwards --reuse-from <PREV_RUN_ID> after the override-section-assignment loop", () => {
// Stage 2 contract: reuse_from is a pipeline mode, not an override.
// The forward block must sit AFTER the last override loop so the spawn
// argv preserves the order documented in the u1 backend post-merge
// guard (overrides parsed first; reuse_from precondition runs against
// the merged overrides view).
const reuseFromIdx = VITE_CONFIG_SOURCE.indexOf('"--reuse-from"');
const zoneSectionsIdx = VITE_CONFIG_SOURCE.indexOf(
'"--override-section-assignment"',
);
expect(reuseFromIdx).toBeGreaterThan(-1);
expect(zoneSectionsIdx).toBeGreaterThan(-1);
expect(reuseFromIdx).toBeGreaterThan(zoneSectionsIdx);
});
it("guards the forward with a truthy check on reuseFromRunId", () => {
// Empty string / undefined ⇒ no flag pushed (Stage 2 contract: invalid
// CLI args must never reach argparse — the backend u1 guard would
// fail-closed with `reuse_artifact_missing` on the empty PREV_RUN_ID).
const reuseFromIdx = VITE_CONFIG_SOURCE.indexOf('"--reuse-from"');
expect(reuseFromIdx).toBeGreaterThan(-1);
const preface = VITE_CONFIG_SOURCE.slice(
Math.max(0, reuseFromIdx - 200),
reuseFromIdx,
);
expect(preface).toMatch(/if\s*\(\s*reuseFromRunId/);
expect(preface).toMatch(/typeof\s+reuseFromRunId\s*===\s*"string"/);
});
it("pushes reuseFromRunId as the --reuse-from argument value (no string interpolation)", () => {
// The CLI value must be the raw PREV_RUN_ID — no `=` join, no quoting
// (spawn is shell:false). Mirrors the `--override-layout` shape.
const reuseFromIdx = VITE_CONFIG_SOURCE.indexOf('"--reuse-from"');
expect(reuseFromIdx).toBeGreaterThan(-1);
// Window spans both before (`cliArgs.push(`) and after
// (`reuseFromRunId)`) the literal so the full push expression is
// captured.
const window = VITE_CONFIG_SOURCE.slice(
Math.max(0, reuseFromIdx - 100),
reuseFromIdx + 200,
);
expect(window).toMatch(
/cliArgs\.push\(\s*"--reuse-from"\s*,\s*reuseFromRunId\s*\)/,
);
});
it("forwards --ignore-user-overrides only for clean generate requests", () => {
const ignoreIdx = VITE_CONFIG_SOURCE.indexOf('"--ignore-user-overrides"');
expect(ignoreIdx).toBeGreaterThan(-1);
const guardWindow = VITE_CONFIG_SOURCE.slice(Math.max(0, ignoreIdx - 160), ignoreIdx);
expect(guardWindow).toMatch(/ignoreUserOverrides\s*===\s*true/);
});
});
@@ -1,851 +0,0 @@
// IMP-52 u3/u4 — vitest coverage for the vite `/api/user-overrides/:key`
// GET and PUT endpoints and their supporting helpers.
//
// Scope:
// u3 (read path):
// 1) isValidUserOverridesKey: accept MDX-stem keys (03, 03__DX_BIM,
// a-b.c), reject empty / leading-dot / `..` / `/` / `\` /
// disallowed chars. Mirrors src/user_overrides_io.validate_key so
// backend (u2) and frontend endpoint (u3) agree on every key.
// 2) userOverridesPath: returns <root>/data/user_overrides/<key>.json.
// 3) handleGetUserOverrides: method != GET → false (next chained for
// PUT); invalid key → 400; missing file → 200 {}; corrupt JSON /
// non-object root → 200 {} (graceful degrade per u1 load contract);
// valid object JSON → 200 with parsed payload echoed back.
//
// u4 (write path):
// 4) mergeUserOverrides: only KNOWN_USER_OVERRIDES_AXES mutated;
// foreign top-level keys preserved; null clears axis; non-axis
// partial keys dropped (allowlist).
// 5) atomicWriteUserOverrides: tmp + rename; parent dir auto-created.
// 6) handlePutUserOverrides: method != PUT → false (next chained);
// invalid key → 400; invalid JSON → 400; non-object body → 400;
// success → 200 with merged result; partial-merge preserves axes
// not in payload; foreign-key preserve on disk; allowlist drops
// unknown payload keys; explicit null clears; corrupt existing →
// recover to clean state.
//
// Tests exercise the pure handlers with mock req/res — no real vite server.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
KNOWN_USER_OVERRIDES_AXES,
USER_OVERRIDES_KEY_RE,
atomicWriteUserOverrides,
handleGetUserOverrides,
handlePutUserOverrides,
isValidUserOverridesKey,
mergeUserOverrides,
userOverridesPath,
} from "../../vite.config";
// ---------------------------------------------------------------------------
// mock res helper — captures writeHead(status, headers) + end(body) so the
// handler can be invoked synchronously without spawning a TCP socket.
// ---------------------------------------------------------------------------
function makeMockRes() {
const state = {
statusCode: 0,
headers: {} as Record<string, string>,
body: "",
ended: false,
};
return {
state,
res: {
writeHead(status: number, headers?: Record<string, string>) {
state.statusCode = status;
if (headers) state.headers = headers;
},
end(body?: string) {
state.body = body ?? "";
state.ended = true;
},
},
};
}
describe("USER_OVERRIDES_KEY_RE (IMP-52 u3)", () => {
it("matches Python validate_key regex literally", () => {
// The pattern locked in src/user_overrides_io.py:_KEY_RE — any drift here
// means backend pipeline fallback (u2) and the vite endpoint disagree on
// which keys are routable, which is the single failure mode that would
// silently lose persisted overrides.
expect(USER_OVERRIDES_KEY_RE.source).toBe(
"^[A-Za-z0-9_][A-Za-z0-9_.\\-]*$",
);
});
});
describe("isValidUserOverridesKey (IMP-52 u3)", () => {
it("accepts MDX-stem-style keys actually used in samples/mdx/", () => {
// 03 / 04 / 05 are the wired sample MDXs (vite.config.ts:SAMPLE_MDX_MAP).
expect(isValidUserOverridesKey("03")).toBe(true);
expect(isValidUserOverridesKey("04")).toBe(true);
expect(isValidUserOverridesKey("05")).toBe(true);
// Stage 1 EVIDENCE references 03__DX_BIM... — must round-trip.
expect(isValidUserOverridesKey("03__DX_BIM")).toBe(true);
expect(isValidUserOverridesKey("a-b.c")).toBe(true);
expect(isValidUserOverridesKey("a")).toBe(true);
expect(isValidUserOverridesKey("_leading_underscore")).toBe(true);
expect(isValidUserOverridesKey("9starts_with_digit")).toBe(true);
});
it("rejects empty and whitespace-only keys", () => {
expect(isValidUserOverridesKey("")).toBe(false);
});
it("rejects path-traversal substrings", () => {
// `..` rejected explicitly even if the rest of the regex would allow it
// — `a..b` would otherwise pass the char class.
expect(isValidUserOverridesKey("..")).toBe(false);
expect(isValidUserOverridesKey("a..b")).toBe(false);
expect(isValidUserOverridesKey("../escape")).toBe(false);
});
it("rejects path separators", () => {
expect(isValidUserOverridesKey("a/b")).toBe(false);
expect(isValidUserOverridesKey("a\\b")).toBe(false);
expect(isValidUserOverridesKey("/")).toBe(false);
expect(isValidUserOverridesKey("\\")).toBe(false);
});
it("rejects keys starting with a non-word character", () => {
expect(isValidUserOverridesKey(".hidden")).toBe(false);
expect(isValidUserOverridesKey("-leading-dash")).toBe(false);
});
it("rejects characters outside [A-Za-z0-9_.-]", () => {
expect(isValidUserOverridesKey("a b")).toBe(false);
expect(isValidUserOverridesKey("a:b")).toBe(false);
expect(isValidUserOverridesKey("a*b")).toBe(false);
expect(isValidUserOverridesKey("a%2Fb")).toBe(false);
});
});
describe("userOverridesPath (IMP-52 u3)", () => {
it("resolves <root>/data/user_overrides/<key>.json regardless of OS sep", () => {
const root = path.join("X:", "design_agent");
const got = userOverridesPath(root, "03");
expect(got).toBe(path.join(root, "data", "user_overrides", "03.json"));
});
});
describe("handleGetUserOverrides (IMP-52 u3)", () => {
let tmpRoot: string;
let overridesDir: string;
beforeEach(() => {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp52-u3-"));
overridesDir = path.join(tmpRoot, "data", "user_overrides");
fs.mkdirSync(overridesDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
it("returns false (next chained) when method != GET", () => {
const { res, state } = makeMockRes();
const handled = handleGetUserOverrides(
{ method: "PUT", url: "/03" },
res,
tmpRoot,
);
expect(handled).toBe(false);
// Crucial for u4: PUT must reach its own middleware unobstructed.
expect(state.ended).toBe(false);
expect(state.statusCode).toBe(0);
});
it("returns 400 on invalid key (path traversal)", () => {
const { res, state } = makeMockRes();
const handled = handleGetUserOverrides(
{ method: "GET", url: "/../escape" },
res,
tmpRoot,
);
expect(handled).toBe(true);
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body)).toEqual({ error: "invalid key" });
});
it("returns 400 on invalid key (missing key segment)", () => {
const { res, state } = makeMockRes();
const handled = handleGetUserOverrides(
{ method: "GET", url: "/" },
res,
tmpRoot,
);
expect(handled).toBe(true);
expect(state.statusCode).toBe(400);
});
it("returns 200 {} on missing file (graceful degrade)", () => {
const { res, state } = makeMockRes();
const handled = handleGetUserOverrides(
{ method: "GET", url: "/03" },
res,
tmpRoot,
);
expect(handled).toBe(true);
expect(state.statusCode).toBe(200);
expect(state.body).toBe("{}");
});
it("returns 200 {} on corrupt JSON (graceful degrade)", () => {
fs.writeFileSync(path.join(overridesDir, "03.json"), "{not json", "utf-8");
const { res, state } = makeMockRes();
const handled = handleGetUserOverrides(
{ method: "GET", url: "/03" },
res,
tmpRoot,
);
expect(handled).toBe(true);
expect(state.statusCode).toBe(200);
expect(state.body).toBe("{}");
});
it("returns 200 {} when JSON root is not an object", () => {
// Mirrors u1 load() which treats non-object roots as corrupt — covers
// both arrays and primitives so the frontend never receives a shape
// the typed service (u5) can't deserialize.
fs.writeFileSync(
path.join(overridesDir, "arr.json"),
JSON.stringify([1, 2, 3]),
"utf-8",
);
const { res, state } = makeMockRes();
const handled = handleGetUserOverrides(
{ method: "GET", url: "/arr" },
res,
tmpRoot,
);
expect(handled).toBe(true);
expect(state.statusCode).toBe(200);
expect(state.body).toBe("{}");
fs.writeFileSync(path.join(overridesDir, "num.json"), "42", "utf-8");
const { res: res2, state: state2 } = makeMockRes();
handleGetUserOverrides({ method: "GET", url: "/num" }, res2, tmpRoot);
expect(state2.statusCode).toBe(200);
expect(state2.body).toBe("{}");
});
it("returns 200 with parsed JSON object on hit", () => {
const payload = {
layout: "two_zone_split",
frames: { "03-1+03-2": "frame_07" },
zone_geometries: {
top: { x: 0.05, y: 0.1, w: 0.9, h: 0.3 },
},
zone_sections: { top: ["03-1", "03-2"] },
};
fs.writeFileSync(
path.join(overridesDir, "03.json"),
JSON.stringify(payload),
"utf-8",
);
const { res, state } = makeMockRes();
const handled = handleGetUserOverrides(
{ method: "GET", url: "/03" },
res,
tmpRoot,
);
expect(handled).toBe(true);
expect(state.statusCode).toBe(200);
expect(state.headers["Content-Type"]).toBe(
"application/json; charset=utf-8",
);
expect(JSON.parse(state.body)).toEqual(payload);
});
it("preserves foreign top-level keys in the response", () => {
// Forward-compat with future axes (e.g., zone_sizes, image_overrides).
// u1 save() preserves them on the disk side; u3 GET must surface them
// so the frontend service (u5) can decide whether to act on them.
const payload = {
layout: "single_zone",
zone_sizes: { top: 0.42 }, // not part of KNOWN_AXES yet
custom_extension: { foo: "bar" },
};
fs.writeFileSync(
path.join(overridesDir, "future.json"),
JSON.stringify(payload),
"utf-8",
);
const { res, state } = makeMockRes();
handleGetUserOverrides({ method: "GET", url: "/future" }, res, tmpRoot);
expect(state.statusCode).toBe(200);
expect(JSON.parse(state.body)).toEqual(payload);
});
it("strips the leading slash and ignores query string when keying", () => {
fs.writeFileSync(
path.join(overridesDir, "03.json"),
JSON.stringify({ layout: "x" }),
"utf-8",
);
const { res, state } = makeMockRes();
handleGetUserOverrides(
{ method: "GET", url: "/03?ts=1747884800" },
res,
tmpRoot,
);
expect(state.statusCode).toBe(200);
expect(JSON.parse(state.body)).toEqual({ layout: "x" });
});
});
// ---------------------------------------------------------------------------
// IMP-52 u4 — PUT endpoint coverage
// ---------------------------------------------------------------------------
describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4 + IMP-56 #90 u3 allowlist sync)", () => {
it("matches the Python KNOWN_AXES tuple in src/user_overrides_io.py", () => {
// The on-disk schema is shared with backend pipeline fallback (u2).
// Any drift here means a PUT could write an axis that the Python
// load() ignores, or vice-versa, silently losing user overrides.
// IMP-56 #90 u3 closes the prior `slide_css` gap (IMP-45 #74) and
// pre-wires `text_overrides` (IMP-56 #90 u1) +
// `structure_overrides` (IMP-56 #90 u2) — full 9-axis mirror of the
// Python tuple, same order.
expect(KNOWN_USER_OVERRIDES_AXES).toEqual([
"layout",
"zone_geometries",
"zone_sections",
"frames",
"image_overrides",
"slide_css",
"manual_section_assignment",
"text_overrides",
"structure_overrides",
]);
});
it("includes the 3 axes added by IMP-56 #90 u3 (allowlist sync)", () => {
// Spot-check the diff in addition to the full-equality assertion so a
// future edit that drops one of the new axes fails with a localized
// error rather than a 9-vs-N tuple-diff that obscures intent.
expect(KNOWN_USER_OVERRIDES_AXES).toContain("slide_css");
expect(KNOWN_USER_OVERRIDES_AXES).toContain("text_overrides");
expect(KNOWN_USER_OVERRIDES_AXES).toContain("structure_overrides");
expect(KNOWN_USER_OVERRIDES_AXES.length).toBe(9);
});
});
describe("mergeUserOverrides (IMP-55 #93 u1) — manual_section_assignment bool axis", () => {
it("merges bool true / false literally and clears on null", () => {
// The PUT handler must treat the bool axis like any other allowlisted
// axis: replace on write, preserve when absent, delete on null. Tests
// both true→false flip and explicit null-clear so the backend (u9)
// sees the exact frontend intent.
let merged = mergeUserOverrides({}, { manual_section_assignment: true });
expect(merged.manual_section_assignment).toBe(true);
merged = mergeUserOverrides(merged, { manual_section_assignment: false });
expect(merged.manual_section_assignment).toBe(false);
merged = mergeUserOverrides(merged, { manual_section_assignment: null });
expect("manual_section_assignment" in merged).toBe(false);
});
it("preserves bool axis when partial touches only a sibling axis", () => {
const existing = { manual_section_assignment: true, layout: "old" };
const merged = mergeUserOverrides(existing, { layout: "new" });
expect(merged.manual_section_assignment).toBe(true);
expect(merged.layout).toBe("new");
});
});
describe("mergeUserOverrides (IMP-52 u4)", () => {
it("only mutates KNOWN_AXES present in partial", () => {
const existing = {
layout: "old",
frames: { "03-1": "frame_01" },
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
zone_sections: { top: ["03-1"] },
};
const merged = mergeUserOverrides(existing, { layout: "new" });
expect(merged.layout).toBe("new");
// axes not in partial are preserved
expect(merged.frames).toEqual({ "03-1": "frame_01" });
expect(merged.zone_geometries).toEqual({
top: { x: 0, y: 0, w: 1, h: 0.5 },
});
expect(merged.zone_sections).toEqual({ top: ["03-1"] });
});
it("preserves foreign top-level keys in existing", () => {
// Forward-compat: future axes (zone_sizes, schema_version, etc.) on
// disk must survive PUT writes that only touch the 5 in-scope axes.
// `image_overrides` is no longer a foreign key after IMP-51 #79 u2 —
// it joined KNOWN_USER_OVERRIDES_AXES — so we probe with axes that
// are still NOT in the allowlist.
const existing = {
layout: "old",
zone_sizes: { top: 0.42 },
schema_version: 2,
};
const merged = mergeUserOverrides(existing, { layout: "new" });
expect(merged.zone_sizes).toEqual({ top: 0.42 });
expect(merged.schema_version).toBe(2);
});
it("clears axis when partial value is null (explicit clear)", () => {
const existing = { layout: "x", frames: { "03-1": "f01" } };
const merged = mergeUserOverrides(existing, { layout: null });
expect("layout" in merged).toBe(false);
expect(merged.frames).toEqual({ "03-1": "f01" });
});
it("drops non-axis keys in partial (allowlist)", () => {
// PUT payload may carry junk fields (typo, malicious key); allowlist
// ensures only the 5 axes can be written to disk.
const merged = mergeUserOverrides(
{},
{ layout: "x", random_key: "evil", __proto__: "x" } as Record<
string,
unknown
>,
);
expect(merged.layout).toBe("x");
expect("random_key" in merged).toBe(false);
});
it("merges all 5 axes when present in partial", () => {
const merged = mergeUserOverrides(
{},
{
layout: "two_zone_split",
frames: { "03-1+03-2": "frame_07" },
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
zone_sections: { top: ["03-1", "03-2"] },
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
},
);
expect(Object.keys(merged).sort()).toEqual([
"frames",
"image_overrides",
"layout",
"zone_geometries",
"zone_sections",
]);
});
it("preserves image_overrides when absent from partial (5th axis IMP-51 #79 u2)", () => {
// Sibling axis of layout/frames/zone_geometries/zone_sections: a PUT
// that touches only layout must NOT erase the image_overrides map
// already on disk. Mirrors the partial-merge invariant for the 4
// pre-existing axes.
const existing = {
layout: "old",
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
};
const merged = mergeUserOverrides(existing, { layout: "new" });
expect(merged.image_overrides).toEqual({
"img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 },
});
expect(merged.layout).toBe("new");
});
it("clears image_overrides when partial value is null (explicit clear)", () => {
// Same null-sentinel contract as the 4 sibling axes — `null` removes
// the axis from disk so the next render reverts to baseline (no
// user image position/size override).
const existing = {
layout: "x",
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
};
const merged = mergeUserOverrides(existing, { image_overrides: null });
expect("image_overrides" in merged).toBe(false);
expect(merged.layout).toBe("x");
});
it("does not mutate the existing input", () => {
const existing = { layout: "old", frames: { a: "b" } };
const snapshot = JSON.parse(JSON.stringify(existing));
mergeUserOverrides(existing, { layout: "new", layout_evil: "x" } as Record<
string,
unknown
>);
expect(existing).toEqual(snapshot);
});
});
describe("atomicWriteUserOverrides (IMP-52 u4)", () => {
let tmpRoot: string;
beforeEach(() => {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp52-u4-aw-"));
});
afterEach(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
it("creates parent dir if missing and writes JSON content", () => {
const filePath = path.join(tmpRoot, "data", "user_overrides", "03.json");
expect(fs.existsSync(path.dirname(filePath))).toBe(false);
atomicWriteUserOverrides(filePath, { layout: "x" });
expect(fs.existsSync(filePath)).toBe(true);
expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual({
layout: "x",
});
});
it("leaves no .tmp residue after a successful write", () => {
const filePath = path.join(tmpRoot, "data", "user_overrides", "03.json");
atomicWriteUserOverrides(filePath, { layout: "x" });
const dirContents = fs.readdirSync(path.dirname(filePath));
expect(dirContents).toEqual(["03.json"]);
});
it("overwrites an existing file atomically", () => {
const filePath = path.join(tmpRoot, "data", "user_overrides", "03.json");
atomicWriteUserOverrides(filePath, { layout: "v1" });
atomicWriteUserOverrides(filePath, { layout: "v2" });
expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual({
layout: "v2",
});
});
});
// req mock — EventEmitter with method/url + a `send(body)` helper that
// emits the data chunk and then `end`, mirroring the node IncomingMessage
// flow used by vite's dev middlewares.
function makeMockReq(opts: {
method?: string;
url?: string;
}): EventEmitter & { method?: string; url?: string; send: (body: string) => void } {
const ee = new EventEmitter() as EventEmitter & {
method?: string;
url?: string;
send: (body: string) => void;
};
ee.method = opts.method;
ee.url = opts.url;
ee.send = (body: string) => {
if (body.length > 0) ee.emit("data", Buffer.from(body, "utf-8"));
ee.emit("end");
};
return ee;
}
describe("handlePutUserOverrides (IMP-52 u4)", () => {
let tmpRoot: string;
let overridesDir: string;
beforeEach(() => {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp52-u4-"));
overridesDir = path.join(tmpRoot, "data", "user_overrides");
});
afterEach(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
it("returns false (next chained) when method != PUT", () => {
const req = makeMockReq({ method: "GET", url: "/03" });
const { res, state } = makeMockRes();
const handled = handlePutUserOverrides(req, res, tmpRoot);
expect(handled).toBe(false);
expect(state.ended).toBe(false);
});
it("returns 400 on invalid key", () => {
const req = makeMockReq({ method: "PUT", url: "/../escape" });
const { res, state } = makeMockRes();
const handled = handlePutUserOverrides(req, res, tmpRoot);
expect(handled).toBe(true);
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body)).toEqual({ error: "invalid key" });
});
it("returns 400 on invalid JSON body", () => {
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send("{not json");
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body)).toEqual({ error: "invalid JSON" });
// file MUST NOT have been created on parse failure
expect(fs.existsSync(path.join(overridesDir, "03.json"))).toBe(false);
});
it("returns 400 when JSON body is an array", () => {
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send(JSON.stringify([1, 2, 3]));
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body)).toEqual({
error: "body must be a JSON object",
});
});
it("returns 400 when JSON body is a primitive", () => {
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send("42");
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body)).toEqual({
error: "body must be a JSON object",
});
});
it("creates the override file on first PUT and returns merged body", () => {
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
const payload = { layout: "two_zone_split" };
req.send(JSON.stringify(payload));
expect(state.statusCode).toBe(200);
expect(state.headers["Content-Type"]).toBe(
"application/json; charset=utf-8",
);
expect(JSON.parse(state.body)).toEqual({ layout: "two_zone_split" });
const filePath = path.join(overridesDir, "03.json");
expect(fs.existsSync(filePath)).toBe(true);
expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual({
layout: "two_zone_split",
});
});
it("partial-merges: axes absent from payload are preserved on disk", () => {
fs.mkdirSync(overridesDir, { recursive: true });
fs.writeFileSync(
path.join(overridesDir, "03.json"),
JSON.stringify({
layout: "old",
frames: { "03-1": "frame_01" },
zone_sections: { top: ["03-1"] },
}),
"utf-8",
);
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send(JSON.stringify({ layout: "new" }));
expect(state.statusCode).toBe(200);
const onDisk = JSON.parse(
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
);
expect(onDisk).toEqual({
layout: "new",
frames: { "03-1": "frame_01" },
zone_sections: { top: ["03-1"] },
});
});
it("preserves foreign top-level keys on disk (forward-compat)", () => {
// `image_overrides` is no longer a foreign key after IMP-51 #79 u2;
// probe with axes that are still NOT in KNOWN_USER_OVERRIDES_AXES.
fs.mkdirSync(overridesDir, { recursive: true });
fs.writeFileSync(
path.join(overridesDir, "future.json"),
JSON.stringify({
layout: "old",
zone_sizes: { top: 0.42 },
schema_version: 2,
}),
"utf-8",
);
const req = makeMockReq({ method: "PUT", url: "/future" });
const { res } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send(JSON.stringify({ layout: "new" }));
const onDisk = JSON.parse(
fs.readFileSync(path.join(overridesDir, "future.json"), "utf-8"),
);
expect(onDisk.zone_sizes).toEqual({ top: 0.42 });
expect(onDisk.schema_version).toBe(2);
expect(onDisk.layout).toBe("new");
});
it("persists image_overrides partial-merge and preserves sibling axes (IMP-51 #79 u2)", () => {
// 5th axis end-to-end PUT round-trip: writing only image_overrides
// must NOT touch the 4 sibling axes already on disk. Mirrors the
// existing partial-merge test for layout above.
fs.mkdirSync(overridesDir, { recursive: true });
fs.writeFileSync(
path.join(overridesDir, "03.json"),
JSON.stringify({
layout: "two_zone_split",
frames: { "03-1": "frame_01" },
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
zone_sections: { top: ["03-1"] },
}),
"utf-8",
);
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send(
JSON.stringify({
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
}),
);
expect(state.statusCode).toBe(200);
const onDisk = JSON.parse(
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
);
expect(onDisk).toEqual({
layout: "two_zone_split",
frames: { "03-1": "frame_01" },
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
zone_sections: { top: ["03-1"] },
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
});
});
it("drops non-axis payload keys (allowlist enforced at write)", () => {
fs.mkdirSync(overridesDir, { recursive: true });
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send(
JSON.stringify({
layout: "two_zone_split",
random_evil_key: "should not persist",
}),
);
expect(state.statusCode).toBe(200);
const onDisk = JSON.parse(
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
);
expect(onDisk).toEqual({ layout: "two_zone_split" });
expect("random_evil_key" in onDisk).toBe(false);
});
it("clears an axis when payload sets it to null", () => {
fs.mkdirSync(overridesDir, { recursive: true });
fs.writeFileSync(
path.join(overridesDir, "03.json"),
JSON.stringify({ layout: "old", frames: { "03-1": "f01" } }),
"utf-8",
);
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send(JSON.stringify({ layout: null }));
const onDisk = JSON.parse(
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
);
expect("layout" in onDisk).toBe(false);
expect(onDisk.frames).toEqual({ "03-1": "f01" });
});
it("recovers from corrupt existing file (graceful degrade)", () => {
fs.mkdirSync(overridesDir, { recursive: true });
fs.writeFileSync(
path.join(overridesDir, "03.json"),
"{this is not JSON",
"utf-8",
);
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send(JSON.stringify({ layout: "recovered" }));
expect(state.statusCode).toBe(200);
const onDisk = JSON.parse(
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
);
expect(onDisk).toEqual({ layout: "recovered" });
});
it("treats array-rooted existing file as empty (graceful degrade)", () => {
fs.mkdirSync(overridesDir, { recursive: true });
fs.writeFileSync(
path.join(overridesDir, "03.json"),
JSON.stringify(["not", "an", "object"]),
"utf-8",
);
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send(JSON.stringify({ layout: "recovered" }));
expect(state.statusCode).toBe(200);
const onDisk = JSON.parse(
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
);
expect(onDisk).toEqual({ layout: "recovered" });
});
it("strips the leading slash and ignores query string when keying", () => {
const req = makeMockReq({
method: "PUT",
url: "/03?ts=1747884800",
});
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send(JSON.stringify({ layout: "x" }));
expect(state.statusCode).toBe(200);
expect(fs.existsSync(path.join(overridesDir, "03.json"))).toBe(true);
});
it("accepts an empty body as a no-op partial (no axes mutated)", () => {
fs.mkdirSync(overridesDir, { recursive: true });
fs.writeFileSync(
path.join(overridesDir, "03.json"),
JSON.stringify({ layout: "kept" }),
"utf-8",
);
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send("");
expect(state.statusCode).toBe(200);
const onDisk = JSON.parse(
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
);
expect(onDisk).toEqual({ layout: "kept" });
});
it("accepts a chunked PUT body (concatenates data events)", () => {
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
const body = JSON.stringify({
layout: "two_zone_split",
frames: { "03-1": "frame_01" },
});
// Emit in two halves to simulate a fragmented HTTP body.
const half = Math.floor(body.length / 2);
req.emit("data", Buffer.from(body.slice(0, half), "utf-8"));
req.emit("data", Buffer.from(body.slice(half), "utf-8"));
req.emit("end");
expect(state.statusCode).toBe(200);
expect(JSON.parse(state.body)).toEqual({
layout: "two_zone_split",
frames: { "03-1": "frame_01" },
});
});
});
@@ -1,706 +0,0 @@
// IMP-52 u6 — vitest coverage for restore-on-reopen helpers used by
// `Home.tsx` to layer persisted `user_overrides.json` payloads onto the
// in-memory `UserSelection` and `slidePlan`.
//
// Scope (Stage 2 unit u6 contract):
// 1) deriveUserOverridesKey(filename) — MDX-stem key derivation that
// matches backend u2 fallback's `Path(args.mdx_path).stem`. Strips
// `.mdx` case-insensitively; preserves everything else.
// 2) applyPersistedNonFrameOverrides(selection, persisted) — layers
// layout / zone_geometries / zone_sections onto an existing selection.
// Frames are NOT layered here (unit_id key requires slidePlan).
// Foreign / unrecognized payloads degrade silently (no throw, no
// partial mutation).
// 3) remapPersistedFramesToZoneFrames(slidePlan, framesByUnitId) —
// remaps frames (unit_id → template_id) to zone_frames (region.id →
// template_id). Stale unit_ids (no matching zone) drop silently;
// zones without internal_regions[0] or without section_ids are
// skipped without throwing.
//
// All helpers are pure; tests run in vitest's default node environment
// without RTL / jsdom. Home.tsx wiring sites (handleFileUpload pre-Generate
// seed + handleGenerate post-loadRun frame remap) are 1-line call sites that
// these helpers cover end-to-end.
import { describe, it, expect } from "vitest";
import type {
LayoutPresetId,
SlidePlan,
UserSelection,
Zone,
} from "../src/types/designAgent";
import {
applyPersistedNonFrameOverrides,
createInitialUserSelection,
deriveUserOverridesKey,
remapPersistedFramesToZoneFrames,
saveImageOverride,
saveTextOverride,
saveStructureOverride,
} from "../src/utils/slidePlanUtils";
// ─── Fixtures ───────────────────────────────────────────────────────────────
function makeSelection(overrides?: Partial<UserSelection["overrides"]>): UserSelection {
return {
selectedSectionId: null,
selectedZoneId: null,
selectedRegionId: null,
overrides: {
layout_preset: undefined,
zone_frames: {},
zone_sections: {},
zone_sizes: {},
zone_geometries: {},
// IMP-51 (#79) u11 — keep the fixture in sync with the 5th persisted
// axis declared on `UserSelection.overrides`. Empty by default so the
// existing IMP-52 cases remain unchanged in shape.
image_overrides: {},
// IMP-55 (#93) u3 — bool intent marker is REQUIRED on
// `UserSelection.overrides` (not optional). Default to `false` so every
// pre-existing fixture matches the `createInitialUserSelection` seed
// and stays compile-clean after u3 widened the type.
manual_section_assignment: false,
// IMP-56 (#90) u15 — keep the fixture in sync with the two Step-22
// persist axes declared on `UserSelection.overrides`. Empty by
// default so pre-existing cases retain their shape.
text_overrides: {},
structure_overrides: {},
...overrides,
},
};
}
function makeZone(
partial: { id: string; zone_id: string; section_ids: string[]; region_id?: string },
): Zone {
return {
id: partial.id,
zone_id: partial.zone_id,
section_ids: partial.section_ids,
position: { x: 0, y: 0, width: 1, height: 1 },
internal_regions: [
{
id: partial.region_id ?? `${partial.id}-r0`,
region_id: "region-single",
role: "primary",
content_type: "text_block",
ratio_estimate: 1,
content_unit_ids: [],
frame_match_strategy: {
kind: "frame_match",
frame_id: null,
display_strategy: "inline_full",
},
frame_candidates: [],
},
],
};
}
function makeSlidePlan(zones: Zone[], layout: LayoutPresetId = "single"): SlidePlan {
return {
id: "plan-1",
title: "test plan",
layout_preset: layout,
zones,
};
}
// ─── deriveUserOverridesKey ─────────────────────────────────────────────────
describe("deriveUserOverridesKey (IMP-52 u6)", () => {
it("strips trailing .mdx", () => {
expect(deriveUserOverridesKey("03__DX_BIM_value_chain.mdx")).toBe(
"03__DX_BIM_value_chain",
);
});
it("strips .MDX case-insensitively", () => {
expect(deriveUserOverridesKey("04_demo.MDX")).toBe("04_demo");
expect(deriveUserOverridesKey("05_intro.Mdx")).toBe("05_intro");
});
it("returns the filename unchanged when no .mdx suffix", () => {
expect(deriveUserOverridesKey("03__DX_BIM_value_chain")).toBe(
"03__DX_BIM_value_chain",
);
expect(deriveUserOverridesKey("notes.txt")).toBe("notes.txt");
});
it("only strips the final .mdx, preserves dots inside the stem", () => {
expect(deriveUserOverridesKey("05.2_layer.mdx")).toBe("05.2_layer");
});
it("returns empty string for empty input", () => {
expect(deriveUserOverridesKey("")).toBe("");
});
it("matches backend Path(args.mdx_path).stem for the canonical demo MDXs", () => {
// These are the three canonical samples loaded by /api/sample-mdx; the
// key on both ends must agree so a write from frontend (PUT) is found
// by backend (u2 fallback on next pipeline run).
expect(deriveUserOverridesKey("03_demo.mdx")).toBe("03_demo");
expect(deriveUserOverridesKey("04_demo.mdx")).toBe("04_demo");
expect(deriveUserOverridesKey("05_demo.mdx")).toBe("05_demo");
});
});
// ─── applyPersistedNonFrameOverrides ────────────────────────────────────────
describe("applyPersistedNonFrameOverrides (IMP-52 u6)", () => {
it("layers layout / zone_geometries / zone_sections", () => {
const sel = makeSelection();
const persisted = {
layout: "horizontal-2",
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.4 } },
zone_sections: { top: ["03-1"], bottom: ["03-2"] },
} as const;
const next = applyPersistedNonFrameOverrides(sel, persisted);
expect(next.overrides.layout_preset).toBe("horizontal-2");
expect(next.overrides.zone_geometries).toEqual({
top: { x: 0, y: 0, w: 1, h: 0.4 },
});
expect(next.overrides.zone_sections).toEqual({
top: ["03-1"],
bottom: ["03-2"],
});
});
it("does NOT layer frames (frames need post-loadRun remap)", () => {
const sel = makeSelection({ zone_frames: { "r-existing": "tpl-existing" } });
const persisted = {
frames: { "03-1+03-2": "tpl-persisted" },
};
const next = applyPersistedNonFrameOverrides(sel, persisted);
// zone_frames is untouched here; the post-loadRun remap step owns it.
expect(next.overrides.zone_frames).toEqual({ "r-existing": "tpl-existing" });
});
it("rejects layout values outside the 8 known preset ids", () => {
const sel = makeSelection({ layout_preset: "single" });
const next = applyPersistedNonFrameOverrides(sel, {
layout: "rogue-layout" as unknown as string,
});
// Stays at the original — preset whitelist guards against hand-edited
// files or future schema drift.
expect(next.overrides.layout_preset).toBe("single");
});
it("ignores zone_geometries when the payload axis is an array", () => {
const sel = makeSelection({ zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } } });
const next = applyPersistedNonFrameOverrides(sel, {
zone_geometries: [] as unknown as Record<string, { x: number; y: number; w: number; h: number }>,
});
expect(next.overrides.zone_geometries).toEqual({
top: { x: 0, y: 0, w: 1, h: 0.5 },
});
});
it("returns the selection unchanged when persisted is null / undefined / non-object", () => {
const sel = makeSelection({ layout_preset: "single" });
expect(applyPersistedNonFrameOverrides(sel, null)).toEqual(sel);
expect(applyPersistedNonFrameOverrides(sel, undefined)).toEqual(sel);
});
it("returns the selection unchanged when persisted is empty {}", () => {
const sel = makeSelection({ layout_preset: "single" });
const next = applyPersistedNonFrameOverrides(sel, {});
expect(next.overrides.layout_preset).toBe("single");
expect(next.overrides.zone_geometries).toEqual({});
expect(next.overrides.zone_sections).toEqual({});
});
it("returns a NEW selection object (no mutation of input)", () => {
const sel = makeSelection();
const next = applyPersistedNonFrameOverrides(sel, { layout: "vertical-2" });
expect(next).not.toBe(sel);
expect(next.overrides).not.toBe(sel.overrides);
// Input still pristine.
expect(sel.overrides.layout_preset).toBeUndefined();
});
});
// ─── remapPersistedFramesToZoneFrames ───────────────────────────────────────
describe("remapPersistedFramesToZoneFrames (IMP-52 u6)", () => {
it("maps unit_id (section_ids joined by +) to region.id", () => {
const plan = makeSlidePlan([
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
makeZone({ id: "z-bot", zone_id: "bottom", section_ids: ["03-2", "03-3"], region_id: "r-bot" }),
]);
const remapped = remapPersistedFramesToZoneFrames(plan, {
"03-1": "tpl-a",
"03-2+03-3": "tpl-b",
});
expect(remapped).toEqual({
"r-top": "tpl-a",
"r-bot": "tpl-b",
});
});
it("silently drops persisted entries whose unit_id matches no zone", () => {
const plan = makeSlidePlan([
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
]);
const remapped = remapPersistedFramesToZoneFrames(plan, {
"03-1": "tpl-a",
"stale-section-id": "tpl-stale", // user changed zone_sections between sessions
});
expect(remapped).toEqual({ "r-top": "tpl-a" });
});
it("returns {} when slidePlan is null / undefined", () => {
expect(remapPersistedFramesToZoneFrames(null, { "03-1": "tpl-a" })).toEqual({});
expect(remapPersistedFramesToZoneFrames(undefined, { "03-1": "tpl-a" })).toEqual({});
});
it("returns {} when framesByUnitId is null / undefined / {}", () => {
const plan = makeSlidePlan([
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
]);
expect(remapPersistedFramesToZoneFrames(plan, null)).toEqual({});
expect(remapPersistedFramesToZoneFrames(plan, undefined)).toEqual({});
expect(remapPersistedFramesToZoneFrames(plan, {})).toEqual({});
});
it("skips zones with empty section_ids (no unit_id to derive)", () => {
const plan = makeSlidePlan([
makeZone({ id: "z-empty", zone_id: "empty", section_ids: [], region_id: "r-empty" }),
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
]);
const remapped = remapPersistedFramesToZoneFrames(plan, {
"": "tpl-should-not-match-empty-join",
"03-1": "tpl-a",
});
expect(remapped).toEqual({ "r-top": "tpl-a" });
});
it("skips zones without internal_regions[0]", () => {
const plan: SlidePlan = {
id: "plan-x",
title: "no regions",
layout_preset: "single",
zones: [
{
id: "z-bare",
zone_id: "bare",
section_ids: ["03-1"],
position: { x: 0, y: 0, width: 1, height: 1 },
internal_regions: [],
},
],
};
expect(remapPersistedFramesToZoneFrames(plan, { "03-1": "tpl-a" })).toEqual({});
});
it("ignores persisted entries with empty / non-string template_id", () => {
const plan = makeSlidePlan([
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
]);
const remapped = remapPersistedFramesToZoneFrames(plan, {
"03-1": "" as unknown as string,
});
expect(remapped).toEqual({});
});
it("preserves the user-selected template even when slidePlan layout would imply a different default", () => {
// Backend u2 fallback should already have applied the user's frame
// override via CLI args, but if the plan's default frame_match_strategy
// disagrees, the post-loadRun remap still surfaces the user's choice
// for the SlideCanvas override-vs-default preview indicator.
const plan = makeSlidePlan([
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
]);
const remapped = remapPersistedFramesToZoneFrames(plan, {
"03-1": "user-chosen-tpl",
});
expect(remapped["r-top"]).toBe("user-chosen-tpl");
});
});
// ─── IMP-51 (#79) u11 — image_overrides axis ────────────────────────────────
// New 5th persisted axis. The on-disk schema (KNOWN_AXES,
// src/user_overrides_io.py u1), the typed client
// (services/userOverridesApi.ts u3 ImageOverridesOverride), the Vite
// allowlist (vite.config.ts u2), and the backend CLI flag (--override-image
// in src/phase_z2_pipeline.py u5) all expect `image_id` → percent-of-slide
// geometry. u11 owns the in-memory mirror on `UserSelection.overrides`
// (declared in types/designAgent.ts) plus the three pure helpers that
// Home.tsx (u10) wires:
// • applyPersistedNonFrameOverrides — restore-on-reopen layer.
// • createInitialUserSelection — fresh-slide initializer.
// • saveImageOverride — single-image record helper invoked by the
// SlideCanvas u8 drag/resize handler.
describe("image_overrides axis — applyPersistedNonFrameOverrides (IMP-51 u11)", () => {
it("layers a flat image_overrides dict onto the selection", () => {
const sel = makeSelection();
const persisted = {
image_overrides: {
"img-abc1234567": { x: 10, y: 15, w: 30.5, h: 25 },
"img-deadbeef00": { x: 50, y: 50, w: 40, h: 40 },
},
};
const next = applyPersistedNonFrameOverrides(sel, persisted);
expect(next.overrides.image_overrides).toEqual({
"img-abc1234567": { x: 10, y: 15, w: 30.5, h: 25 },
"img-deadbeef00": { x: 50, y: 50, w: 40, h: 40 },
});
// Untouched axes stay at their fixture defaults so the round-trip is
// safe to interleave with the other four axes.
expect(next.overrides.zone_geometries).toEqual({});
expect(next.overrides.zone_sections).toEqual({});
expect(next.overrides.layout_preset).toBeUndefined();
});
it("ignores image_overrides when the payload axis is an array", () => {
const sel = makeSelection({
image_overrides: { "img-existing00": { x: 1, y: 2, w: 30, h: 40 } },
});
const next = applyPersistedNonFrameOverrides(sel, {
image_overrides: [] as unknown as Record<
string,
{ x: number; y: number; w: number; h: number }
>,
});
// Same guard the zone_geometries branch uses — array payloads from a
// hand-edited file are rejected and the prior in-memory value stays.
expect(next.overrides.image_overrides).toEqual({
"img-existing00": { x: 1, y: 2, w: 30, h: 40 },
});
});
it("ignores image_overrides when the payload axis is null", () => {
const sel = makeSelection({
image_overrides: { "img-existing00": { x: 0, y: 0, w: 100, h: 100 } },
});
const next = applyPersistedNonFrameOverrides(sel, {
image_overrides: null,
});
expect(next.overrides.image_overrides).toEqual({
"img-existing00": { x: 0, y: 0, w: 100, h: 100 },
});
});
it("layers image_overrides alongside the four IMP-52 axes in one call", () => {
const sel = makeSelection();
const next = applyPersistedNonFrameOverrides(sel, {
layout: "horizontal-2",
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.4 } },
zone_sections: { top: ["03-1"] },
image_overrides: { "img-abc1234567": { x: 25, y: 25, w: 50, h: 50 } },
});
expect(next.overrides.layout_preset).toBe("horizontal-2");
expect(next.overrides.zone_geometries).toEqual({
top: { x: 0, y: 0, w: 1, h: 0.4 },
});
expect(next.overrides.zone_sections).toEqual({ top: ["03-1"] });
expect(next.overrides.image_overrides).toEqual({
"img-abc1234567": { x: 25, y: 25, w: 50, h: 50 },
});
});
it("seeds an empty image_overrides on a fresh selection (createInitialUserSelection)", () => {
const sel = createInitialUserSelection();
expect(sel.overrides.image_overrides).toEqual({});
// Mirrors the shape Home.tsx receives before any user interaction —
// SlideCanvas u8 expects the axis to exist (not undefined) so its
// `Object.entries(measured + persisted)` merge never crashes.
});
});
describe("image_overrides axis — saveImageOverride (IMP-51 u11)", () => {
const ID_A = "img-abc1234567";
const ID_B = "img-deadbeef00";
it("adds a new image_id entry on an empty axis", () => {
const sel = makeSelection();
const next = saveImageOverride(sel, ID_A, { x: 10, y: 15, w: 30.5, h: 25 });
expect(next.overrides.image_overrides).toEqual({
[ID_A]: { x: 10, y: 15, w: 30.5, h: 25 },
});
});
it("replaces an existing entry under the same image_id (most recent drag wins)", () => {
const sel = makeSelection({
image_overrides: { [ID_A]: { x: 0, y: 0, w: 20, h: 20 } },
});
const next = saveImageOverride(sel, ID_A, { x: 50, y: 50, w: 30, h: 30 });
expect(next.overrides.image_overrides).toEqual({
[ID_A]: { x: 50, y: 50, w: 30, h: 30 },
});
});
it("preserves sibling image_id entries when adding a new one", () => {
const sel = makeSelection({
image_overrides: { [ID_A]: { x: 10, y: 10, w: 20, h: 20 } },
});
const next = saveImageOverride(sel, ID_B, { x: 60, y: 60, w: 30, h: 30 });
expect(next.overrides.image_overrides).toEqual({
[ID_A]: { x: 10, y: 10, w: 20, h: 20 },
[ID_B]: { x: 60, y: 60, w: 30, h: 30 },
});
});
it("does NOT touch the other four override axes", () => {
const sel = makeSelection({
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
zone_sections: { top: ["03-1"] },
zone_frames: { "r-top": "tpl-a" },
layout_preset: "horizontal-2",
});
const next = saveImageOverride(sel, ID_A, { x: 10, y: 10, w: 20, h: 20 });
expect(next.overrides.zone_geometries).toEqual({
top: { x: 0, y: 0, w: 1, h: 0.5 },
});
expect(next.overrides.zone_sections).toEqual({ top: ["03-1"] });
expect(next.overrides.zone_frames).toEqual({ "r-top": "tpl-a" });
expect(next.overrides.layout_preset).toBe("horizontal-2");
});
it("returns a NEW selection object (no input mutation)", () => {
const sel = makeSelection({
image_overrides: { [ID_A]: { x: 0, y: 0, w: 10, h: 10 } },
});
const before = { ...sel.overrides.image_overrides };
const next = saveImageOverride(sel, ID_B, { x: 30, y: 30, w: 20, h: 20 });
expect(next).not.toBe(sel);
expect(next.overrides).not.toBe(sel.overrides);
expect(next.overrides.image_overrides).not.toBe(sel.overrides.image_overrides);
// Input still pristine.
expect(sel.overrides.image_overrides).toEqual(before);
});
});
// ─── IMP-55 (#93) u3 — manual_section_assignment bool axis ──────────────────
// Restore-on-reopen / seed coverage for the bool intent marker. Production
// branch lives at `slidePlanUtils.ts` — `applyPersistedNonFrameOverrides`
// guards with `typeof persisted.manual_section_assignment === "boolean"`,
// and `createInitialUserSelection` seeds the axis to `false`. The marker
// gates whether `handleGenerate` (u7) forwards `overrides.zoneSections`
// to the backend; the pipeline (u9) consumes persisted `zone_sections`
// only when the marker is exactly `true`, so any non-boolean payload MUST
// end up `false` in memory (fail-closed).
describe("manual_section_assignment axis — applyPersistedNonFrameOverrides (IMP-55 #93 u3)", () => {
it("restores literal true verbatim", () => {
const sel = makeSelection();
const next = applyPersistedNonFrameOverrides(sel, {
manual_section_assignment: true,
});
expect(next.overrides.manual_section_assignment).toBe(true);
});
it("restores literal false verbatim (u12 apply/cancel write must survive reopen)", () => {
// Seed `true` so the assertion proves `false` overwrites; a truthiness
// check instead of `typeof === \"boolean\"` would silently keep `true`
// and resurrect stale auto-carry assignments as user intent.
const sel = makeSelection({ manual_section_assignment: true });
const next = applyPersistedNonFrameOverrides(sel, {
manual_section_assignment: false,
});
expect(next.overrides.manual_section_assignment).toBe(false);
});
it("leaves the in-memory marker unchanged when the persisted axis is absent", () => {
const sel = makeSelection({ manual_section_assignment: true });
const next = applyPersistedNonFrameOverrides(sel, { layout: "horizontal-2" });
expect(next.overrides.manual_section_assignment).toBe(true);
expect(next.overrides.layout_preset).toBe("horizontal-2");
});
it.each([
["null clear sentinel", null],
['string "true"', "true"],
['string "false"', "false"],
["number 1", 1],
["number 0", 0],
["object {}", {}],
["array []", []],
])("ignores non-boolean payload (%s) — keeps prior in-memory value", (_label, payload) => {
const sel = makeSelection({ manual_section_assignment: true });
const next = applyPersistedNonFrameOverrides(sel, {
manual_section_assignment: payload as unknown as boolean,
});
expect(next.overrides.manual_section_assignment).toBe(true);
});
it("seeds an empty selection with manual_section_assignment=false (createInitialUserSelection)", () => {
const sel = createInitialUserSelection();
expect(sel.overrides.manual_section_assignment).toBe(false);
});
it("returns a NEW selection object (no input mutation) when restoring the marker", () => {
const sel = makeSelection({ manual_section_assignment: false });
const next = applyPersistedNonFrameOverrides(sel, {
manual_section_assignment: true,
});
expect(next).not.toBe(sel);
expect(next.overrides).not.toBe(sel.overrides);
// Input still pristine — proves the helper does not flip the fixture.
expect(sel.overrides.manual_section_assignment).toBe(false);
});
it("layers the bool axis alongside other persisted axes in a single call", () => {
const sel = makeSelection();
const next = applyPersistedNonFrameOverrides(sel, {
layout: "vertical-2",
zone_sections: { top: ["03-1"], bottom: ["03-2"] },
manual_section_assignment: true,
});
expect(next.overrides.layout_preset).toBe("vertical-2");
expect(next.overrides.zone_sections).toEqual({
top: ["03-1"],
bottom: ["03-2"],
});
expect(next.overrides.manual_section_assignment).toBe(true);
});
});
// ─── IMP-56 (#90) u15 — text_overrides + structure_overrides axes ───────────
// Pure helpers wired by Home.tsx into the SlideCanvas u13 focusout capture
// (text) and u14 structure overlay emit (structure). Tests cover:
// • saveTextOverride / saveStructureOverride immutability + merge semantics
// • createInitialUserSelection seeding the two new axes empty
// • applyPersistedNonFrameOverrides layering via the u10 extract helpers
describe("text_overrides axis — saveTextOverride (IMP-56 u15)", () => {
it("records a fresh (zoneId, textPath, value) tuple", () => {
const sel = makeSelection();
const next = saveTextOverride(sel, "top", "row_1_left_body.0", "분석 결과");
expect(next.overrides.text_overrides).toEqual({
top: { "row_1_left_body.0": "분석 결과" },
});
});
it("merges within the same zone without erasing prior text_paths", () => {
const sel = makeSelection({
text_overrides: { top: { "row_1_left_body.0": "기존" } },
});
const next = saveTextOverride(sel, "top", "row_1_left_body.1", "신규");
expect(next.overrides.text_overrides.top).toEqual({
"row_1_left_body.0": "기존",
"row_1_left_body.1": "신규",
});
});
it("overwrites the same textPath value within a zone", () => {
const sel = makeSelection({
text_overrides: { top: { "headline.0": "v1" } },
});
const next = saveTextOverride(sel, "top", "headline.0", "v2");
expect(next.overrides.text_overrides.top).toEqual({ "headline.0": "v2" });
});
it("does not mutate the input selection (immutable contract)", () => {
const sel = makeSelection({
text_overrides: { top: { "headline.0": "before" } },
});
saveTextOverride(sel, "top", "headline.0", "after");
expect(sel.overrides.text_overrides).toEqual({
top: { "headline.0": "before" },
});
});
it("seeds an empty text_overrides on a fresh selection", () => {
const sel = createInitialUserSelection();
expect(sel.overrides.text_overrides).toEqual({});
});
});
describe("structure_overrides axis — saveStructureOverride (IMP-56 u15)", () => {
it("records a fresh (zoneId → {slot_order, hidden_slots}) tuple", () => {
const sel = makeSelection();
const next = saveStructureOverride(sel, "top", {
slot_order: ["b", "a"],
hidden_slots: ["c"],
});
expect(next.overrides.structure_overrides).toEqual({
top: { slot_order: ["b", "a"], hidden_slots: ["c"] },
});
});
it("replaces an existing zone entry verbatim (no merge within zone)", () => {
const sel = makeSelection({
structure_overrides: { top: { slot_order: ["a", "b"], hidden_slots: [] } },
});
const next = saveStructureOverride(sel, "top", {
slot_order: ["b", "a"],
hidden_slots: ["a"],
});
expect(next.overrides.structure_overrides.top).toEqual({
slot_order: ["b", "a"],
hidden_slots: ["a"],
});
});
it("keeps unrelated zones intact when updating one zone", () => {
const sel = makeSelection({
structure_overrides: {
top: { slot_order: ["x"], hidden_slots: [] },
bottom_l: { slot_order: ["y"], hidden_slots: ["z"] },
},
});
const next = saveStructureOverride(sel, "top", {
slot_order: ["x", "x2"],
hidden_slots: [],
});
expect(next.overrides.structure_overrides.bottom_l).toEqual({
slot_order: ["y"],
hidden_slots: ["z"],
});
});
it("does not mutate the input perZone object after save", () => {
const sel = makeSelection();
const perZone = { slot_order: ["a"], hidden_slots: ["b"] };
const next = saveStructureOverride(sel, "top", perZone);
perZone.slot_order.push("MUTATED");
expect(next.overrides.structure_overrides.top.slot_order).toEqual(["a"]);
});
it("seeds an empty structure_overrides on a fresh selection", () => {
const sel = createInitialUserSelection();
expect(sel.overrides.structure_overrides).toEqual({});
});
});
describe("Step-22 axes — applyPersistedNonFrameOverrides restore (IMP-56 u15)", () => {
it("layers persisted text_overrides through the u10 extract helper", () => {
const sel = makeSelection();
const next = applyPersistedNonFrameOverrides(sel, {
text_overrides: {
top: { "row_1_left_body.0": "복원" },
},
});
expect(next.overrides.text_overrides).toEqual({
top: { "row_1_left_body.0": "복원" },
});
});
it("layers persisted structure_overrides through the u10 extract helper", () => {
const sel = makeSelection();
const next = applyPersistedNonFrameOverrides(sel, {
structure_overrides: {
top: { slot_order: ["b", "a"], hidden_slots: ["c"] },
},
});
expect(next.overrides.structure_overrides).toEqual({
top: { slot_order: ["b", "a"], hidden_slots: ["c"] },
});
});
it("drops non-object payloads silently (no throw, axis stays empty)", () => {
const sel = makeSelection();
const next = applyPersistedNonFrameOverrides(sel, {
text_overrides: "garbage" as unknown as Record<string, Record<string, string>>,
structure_overrides: ["bad"] as unknown as Record<
string,
{ slot_order?: string[]; hidden_slots?: string[] }
>,
});
expect(next.overrides.text_overrides).toEqual({});
expect(next.overrides.structure_overrides).toEqual({});
});
});
@@ -1,625 +0,0 @@
// IMP-52 u5 — vitest coverage for the typed frontend client at
// `Front/client/src/services/userOverridesApi.ts`.
//
// Scope (Stage 2 unit u5 contract):
// 1) getUserOverrides:
// • 200 with object body → typed payload echoed.
// • 200 with array / primitive / non-JSON body → {} (graceful).
// • 4xx / 5xx → {}.
// • fetch reject (network) → {} (no throw to caller).
// 2) saveUserOverrides:
// • Single call: PUT fires after exactly 300 ms with the mutated-axis
// partial as body (NOT a full snapshot of UserOverrides).
// • Rapid coalescing: N calls in <300 ms window collapse to ONE PUT
// carrying the union of mutated axes.
// • Per-axis later-wins: later call's value replaces earlier pending
// value for the same axis; axes the user did not touch stay absent.
// • null sentinel: forwarded verbatim so u4 mergeUserOverrides can
// `delete` the axis on disk.
// • Per-key isolation: rapid edits to "03" do not delay flush of "04".
// • Promise resolves with the server-side merged document.
// • Promise rejects on 4xx/5xx and on fetch reject.
// 3) flushUserOverrides:
// • No arg → flushes all pending buckets immediately (no 300 ms wait).
// • Specific key → flushes only that bucket; other buckets stay
// pending.
// • No-op when no buckets are pending.
//
// All tests mock `fetch` and use `vi.useFakeTimers()` to make the 300 ms
// debounce deterministic — no real wall-clock waits.
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
type Mock,
} from "vitest";
import {
__resetUserOverridesBuckets_FOR_TEST,
flushUserOverrides,
getUserOverrides,
saveUserOverrides,
type UserOverridesPartial,
} from "../src/services/userOverridesApi";
// ---------------------------------------------------------------------------
// fetch mock — minimal Response stub with the two methods the service uses
// (.ok / .status / .json()). We track the call log so debounce + coalescing
// can be asserted by counting PUTs and inspecting their bodies.
// ---------------------------------------------------------------------------
type MockResponse = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
};
function mockResponse(body: unknown, ok = true, status = 200): MockResponse {
return {
ok,
status,
json: async () => body,
};
}
let fetchMock: Mock;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
vi.useFakeTimers();
__resetUserOverridesBuckets_FOR_TEST();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
__resetUserOverridesBuckets_FOR_TEST();
});
// Microtask-flushing helper. vi.advanceTimersByTime fires timers, but the
// promise chain inside flushBucket (await fetch → await res.json() → resolve
// waiters) needs the microtask queue to drain before assertions run.
async function drainMicrotasks(): Promise<void> {
// Multiple ticks because each `await` in flushBucket adds another tick.
for (let i = 0; i < 4; i++) {
await Promise.resolve();
}
}
function lastPutBody(): unknown {
const lastCall = fetchMock.mock.calls.at(-1);
if (!lastCall) throw new Error("fetch was not called");
const init = lastCall[1] as RequestInit | undefined;
if (!init?.body) throw new Error("fetch was called without a body");
return JSON.parse(String(init.body));
}
function putCallsCount(): number {
return fetchMock.mock.calls.filter(
(call) => (call[1] as RequestInit | undefined)?.method === "PUT",
).length;
}
// ============================================================================
// getUserOverrides
// ============================================================================
describe("getUserOverrides (IMP-52 u5)", () => {
it("issues GET against /api/user-overrides/<key>", async () => {
fetchMock.mockResolvedValueOnce(mockResponse({ layout: "x" }));
await getUserOverrides("03");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("/api/user-overrides/03");
expect((init as RequestInit).method).toBe("GET");
});
it("returns the parsed object on 200 with object body", async () => {
const payload = {
layout: "two_zone_split",
frames: { "03-1+03-2": "frame_07" },
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
zone_sections: { top: ["03-1", "03-2"] },
};
fetchMock.mockResolvedValueOnce(mockResponse(payload));
const got = await getUserOverrides("03");
expect(got).toEqual(payload);
});
it("returns {} when JSON root is an array (mirrors u3 graceful degrade)", async () => {
fetchMock.mockResolvedValueOnce(mockResponse([1, 2, 3]));
expect(await getUserOverrides("03")).toEqual({});
});
it("returns {} when JSON root is a primitive", async () => {
fetchMock.mockResolvedValueOnce(mockResponse(42));
expect(await getUserOverrides("03")).toEqual({});
});
it("returns {} when JSON root is null", async () => {
fetchMock.mockResolvedValueOnce(mockResponse(null));
expect(await getUserOverrides("03")).toEqual({});
});
it("returns {} on 4xx (invalid key path from u3)", async () => {
fetchMock.mockResolvedValueOnce(
mockResponse({ error: "invalid key" }, false, 400),
);
expect(await getUserOverrides("..")).toEqual({});
});
it("returns {} on 5xx", async () => {
fetchMock.mockResolvedValueOnce(
mockResponse({ error: "boom" }, false, 500),
);
expect(await getUserOverrides("03")).toEqual({});
});
it("returns {} when response.json() throws (non-JSON body)", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => {
throw new SyntaxError("Unexpected token");
},
});
expect(await getUserOverrides("03")).toEqual({});
});
it("returns {} when fetch rejects (network error) — does NOT throw", async () => {
fetchMock.mockRejectedValueOnce(new Error("network down"));
await expect(getUserOverrides("03")).resolves.toEqual({});
});
});
// ============================================================================
// saveUserOverrides — debounce + coalescing
// ============================================================================
describe("saveUserOverrides (IMP-52 u5) — debounce", () => {
it("does NOT fire fetch before 300 ms have elapsed", async () => {
fetchMock.mockResolvedValue(mockResponse({ layout: "two_zone_split" }));
void saveUserOverrides("03", { layout: "two_zone_split" });
vi.advanceTimersByTime(299);
await drainMicrotasks();
expect(putCallsCount()).toBe(0);
});
it("fires exactly one PUT at the 300 ms boundary", async () => {
fetchMock.mockResolvedValue(mockResponse({ layout: "two_zone_split" }));
void saveUserOverrides("03", { layout: "two_zone_split" });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(1);
const lastCall = fetchMock.mock.calls.at(-1)!;
expect(lastCall[0]).toBe("/api/user-overrides/03");
expect((lastCall[1] as RequestInit).method).toBe("PUT");
expect((lastCall[1] as RequestInit).headers).toMatchObject({
"Content-Type": "application/json",
});
expect(lastPutBody()).toEqual({ layout: "two_zone_split" });
});
it("PUT body contains ONLY the mutated axis (not a full snapshot)", async () => {
// The frontend handler only knows the axis it just mutated; the server
// is responsible for partial-merge against axes already on disk.
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", {
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
});
vi.advanceTimersByTime(300);
await drainMicrotasks();
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["zone_geometries"]);
expect("layout" in body).toBe(false);
expect("frames" in body).toBe(false);
expect("zone_sections" in body).toBe(false);
});
it("coalesces N rapid calls into a SINGLE PUT after the debounce", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "old" });
vi.advanceTimersByTime(100);
void saveUserOverrides("03", { frames: { "03-1": "frame_01" } });
vi.advanceTimersByTime(100);
void saveUserOverrides("03", { zone_sections: { top: ["03-1"] } });
vi.advanceTimersByTime(100);
// After 300 ms total (but the timer was reset each call to start the
// 300 ms window over), so we need one more 300 ms to fire.
expect(putCallsCount()).toBe(0);
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(1);
// All three axes accumulated.
const body = lastPutBody() as Record<string, unknown>;
expect(body).toEqual({
layout: "old",
frames: { "03-1": "frame_01" },
zone_sections: { top: ["03-1"] },
});
});
it("per-axis later-wins: same axis mutated twice keeps the LAST value", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "first" });
void saveUserOverrides("03", { layout: "second" });
void saveUserOverrides("03", { layout: "final" });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(1);
expect(lastPutBody()).toEqual({ layout: "final" });
});
it("forwards null sentinel verbatim (explicit clear)", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: null });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(lastPutBody()).toEqual({ layout: null });
});
it("null can override a prior non-null pending value for the same axis", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "two_zone_split" });
void saveUserOverrides("03", { layout: null });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(lastPutBody()).toEqual({ layout: null });
});
it("resolves the caller promise with the server-merged document", async () => {
fetchMock.mockResolvedValueOnce(
mockResponse({
layout: "two_zone_split",
// server's view includes axes preserved on disk that the partial
// PUT did NOT carry — confirms we surface the full merged state.
frames: { "03-1": "frame_01" },
}),
);
const p = saveUserOverrides("03", { layout: "two_zone_split" });
vi.advanceTimersByTime(300);
await drainMicrotasks();
await expect(p).resolves.toEqual({
layout: "two_zone_split",
frames: { "03-1": "frame_01" },
});
});
it("rejects all coalesced waiters on 5xx response", async () => {
fetchMock.mockResolvedValueOnce(
mockResponse({ error: "write failed" }, false, 500),
);
const p1 = saveUserOverrides("03", { layout: "x" });
const p2 = saveUserOverrides("03", { frames: { "03-1": "f01" } });
vi.advanceTimersByTime(300);
await drainMicrotasks();
await expect(p1).rejects.toThrow(/500/);
await expect(p2).rejects.toThrow(/500/);
});
it("rejects waiters on fetch network error", async () => {
fetchMock.mockRejectedValueOnce(new Error("ECONNRESET"));
const p = saveUserOverrides("03", { layout: "x" });
vi.advanceTimersByTime(300);
await drainMicrotasks();
await expect(p).rejects.toThrow("ECONNRESET");
});
it("after a successful flush, a new save starts a fresh debounce window", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "first" });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(1);
expect(lastPutBody()).toEqual({ layout: "first" });
void saveUserOverrides("03", { layout: "second" });
vi.advanceTimersByTime(299);
await drainMicrotasks();
expect(putCallsCount()).toBe(1); // not fired yet
vi.advanceTimersByTime(1);
await drainMicrotasks();
expect(putCallsCount()).toBe(2);
expect(lastPutBody()).toEqual({ layout: "second" });
});
});
// ============================================================================
// saveUserOverrides — per-key isolation
// ============================================================================
describe("saveUserOverrides (IMP-52 u5) — per-key isolation", () => {
it("rapid edits to key A do not delay key B's flush", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
// Schedule a save on "03"
void saveUserOverrides("03", { layout: "x" });
// Schedule a save on "04" at t=0
void saveUserOverrides("04", { layout: "y" });
vi.advanceTimersByTime(150);
// Keep extending "03"'s window
void saveUserOverrides("03", { layout: "x2" });
// "04" should still fire at t=300 (untouched after first call)
vi.advanceTimersByTime(150); // t=300
await drainMicrotasks();
const puts = fetchMock.mock.calls.filter(
(c) => (c[1] as RequestInit).method === "PUT",
);
expect(puts.length).toBe(1);
expect(puts[0][0]).toBe("/api/user-overrides/04");
expect(JSON.parse(String((puts[0][1] as RequestInit).body))).toEqual({
layout: "y",
});
});
it("each key's PUT carries only that key's mutated axes", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "for-03" });
void saveUserOverrides("04", { frames: { "04-1": "frame_05" } });
vi.advanceTimersByTime(300);
await drainMicrotasks();
const puts = fetchMock.mock.calls.filter(
(c) => (c[1] as RequestInit).method === "PUT",
);
expect(puts.length).toBe(2);
const byUrl = new Map(
puts.map((c) => [
c[0],
JSON.parse(String((c[1] as RequestInit).body)) as Record<
string,
unknown
>,
]),
);
expect(byUrl.get("/api/user-overrides/03")).toEqual({ layout: "for-03" });
expect(byUrl.get("/api/user-overrides/04")).toEqual({
frames: { "04-1": "frame_05" },
});
});
});
// ============================================================================
// flushUserOverrides
// ============================================================================
describe("flushUserOverrides (IMP-52 u5)", () => {
it("with no arg, flushes ALL pending buckets immediately (no 300 ms wait)", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "x" });
void saveUserOverrides("04", { layout: "y" });
expect(putCallsCount()).toBe(0);
const flushP = flushUserOverrides();
await drainMicrotasks();
await flushP;
expect(putCallsCount()).toBe(2);
});
it("with a key arg, flushes only that bucket; others stay pending", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "x" });
void saveUserOverrides("04", { layout: "y" });
await flushUserOverrides("03");
await drainMicrotasks();
const puts = fetchMock.mock.calls.filter(
(c) => (c[1] as RequestInit).method === "PUT",
);
expect(puts.length).toBe(1);
expect(puts[0][0]).toBe("/api/user-overrides/03");
// "04" should still fire at the regular 300 ms boundary.
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(2);
});
it("is a no-op when no buckets are pending", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
await flushUserOverrides();
expect(fetchMock).not.toHaveBeenCalled();
});
it("resolves the original saveUserOverrides promise via the in-flight PUT", async () => {
fetchMock.mockResolvedValueOnce(mockResponse({ layout: "flushed" }));
const savePromise = saveUserOverrides("03", { layout: "flushed" });
const flushPromise = flushUserOverrides();
await drainMicrotasks();
await flushPromise;
await expect(savePromise).resolves.toEqual({ layout: "flushed" });
});
it("propagates PUT failure as caller rejection (flush itself swallows)", async () => {
fetchMock.mockResolvedValueOnce(
mockResponse({ error: "boom" }, false, 500),
);
const savePromise = saveUserOverrides("03", { layout: "x" });
// flush itself should not throw — the original waiter takes the rejection.
const flushPromise = flushUserOverrides();
await drainMicrotasks();
await expect(flushPromise).resolves.toBeUndefined();
await expect(savePromise).rejects.toThrow(/500/);
});
});
// ============================================================================
// type-level export sanity check (compile-time evidence; runtime no-op)
// ============================================================================
describe("UserOverridesPartial type (IMP-52 u5)", () => {
it("permits per-axis null sentinels and partial keys", () => {
// Compile-time only — if any of these stops being a valid assignment,
// the test suite fails at build with a TS error before this assertion
// runs. The expect() is a placebo to keep vitest happy.
const a: UserOverridesPartial = { layout: "x" };
const b: UserOverridesPartial = { layout: null };
const c: UserOverridesPartial = { frames: { unit: "tmpl" } };
const d: UserOverridesPartial = {};
const e: UserOverridesPartial = {
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
};
const f: UserOverridesPartial = { image_overrides: null };
expect([a, b, c, d, e, f]).toHaveLength(6);
});
});
// ============================================================================
// IMP-51 #79 u3 — image_overrides axis (5th axis) parity coverage
//
// Same debounce / coalescing / clear / per-key isolation guarantees as the
// 4 sibling axes (layout / frames / zone_geometries / zone_sections), but
// asserted explicitly so a regression in the type or the runtime allowlist
// fails here instead of in a downstream u8~u11 handler.
// ============================================================================
describe("saveUserOverrides (IMP-51 #79 u3) — image_overrides axis", () => {
it("PUT body carries only image_overrides when that is the sole mutated axis", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", {
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
});
vi.advanceTimersByTime(300);
await drainMicrotasks();
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["image_overrides"]);
expect(body.image_overrides).toEqual({
"img-1": { x: 10, y: 20, w: 30, h: 25 },
});
expect("layout" in body).toBe(false);
expect("frames" in body).toBe(false);
expect("zone_geometries" in body).toBe(false);
expect("zone_sections" in body).toBe(false);
});
it("per-axis later-wins: same image_id mutated twice keeps the LAST value", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", {
image_overrides: { "img-1": { x: 0, y: 0, w: 50, h: 50 } },
});
void saveUserOverrides("03", {
image_overrides: { "img-1": { x: 25, y: 25, w: 30, h: 30 } },
});
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(1);
expect(lastPutBody()).toEqual({
image_overrides: { "img-1": { x: 25, y: 25, w: 30, h: 30 } },
});
});
it("forwards null sentinel verbatim (clear all image_overrides on disk)", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { image_overrides: null });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(lastPutBody()).toEqual({ image_overrides: null });
});
it("coalesces with sibling axes in a single PUT", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "two_zone_split" });
void saveUserOverrides("03", {
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
});
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(1);
expect(lastPutBody()).toEqual({
layout: "two_zone_split",
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
});
});
});
// ============================================================================
// IMP-55 #93 u1 — manual_section_assignment axis (7th axis) parity coverage
//
// The bool intent marker rides on the same per-axis coalescing rails as the
// 6 sibling axes. These tests lock the typed client behavior so a regression
// in the boolean serialization (e.g., coercion to "true" string, dropped
// `false` due to truthy filtering) fails here instead of in Home.tsx (u6/u7)
// or the backend gate (u9~u11).
// ============================================================================
describe("saveUserOverrides (IMP-55 #93 u1) — manual_section_assignment axis", () => {
it("PUT body carries only manual_section_assignment when it is the sole mutated axis", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { manual_section_assignment: true });
vi.advanceTimersByTime(300);
await drainMicrotasks();
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["manual_section_assignment"]);
expect(body.manual_section_assignment).toBe(true);
});
it("later-wins coalesces true → false within a single debounce window", async () => {
// Drag-then-cancel inside 300 ms — server must see only the final
// `false`, not a transient `true` that would re-enable backend
// consumption of stale zone_sections.
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { manual_section_assignment: true });
void saveUserOverrides("03", { manual_section_assignment: false });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(1);
expect(lastPutBody()).toEqual({ manual_section_assignment: false });
});
it("forwards null sentinel verbatim (explicit clear)", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { manual_section_assignment: null });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(lastPutBody()).toEqual({ manual_section_assignment: null });
});
it("coalesces with zone_sections sibling into a single PUT (drag-drop pair)", async () => {
// Real-world drag flow (u6): one save() sets the bool + zone_sections
// together. Asserts both axes survive coalescing as a single PUT body.
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", {
zone_sections: { left: ["03-2"], right: ["03-1"] },
manual_section_assignment: true,
});
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(1);
expect(lastPutBody()).toEqual({
zone_sections: { left: ["03-2"], right: ["03-1"] },
manual_section_assignment: true,
});
});
});
@@ -1,862 +0,0 @@
// IMP-52 u10 — Frontend write-side regression coverage.
//
// Stage 2 unit u10 contract:
// 1) All 4 in-scope mutation handlers persist their axis.
// 2) zone_sizes is NOT persisted (handleLayoutResize stays in-memory).
// 3) Write-before-Generate ordering — flushUserOverrides forces pending
// PUTs to commit before the pipeline run begins.
// 4) Restore-on-reopen end-to-end — getUserOverrides → non-frame layering
// and post-loadRun frame remap compose into a single restored state.
//
// React Testing Library is NOT installed in this repo (devDependencies has
// vitest only). Home.tsx's mutation handlers live inside `useCallback`
// closures so they cannot be invoked from a test without mounting the
// component. We cover them with two complementary tactics:
// • Source-pattern grep on Home.tsx that pins the exact wiring shape per
// handler. A regression that drops or rewires a `saveUserOverrides`
// call fails here loudly.
// • End-to-end mocked-fetch tests on the `userOverridesApi` flow with the
// payload shapes that Home.tsx produces — proves the contract the
// handlers depend on still holds.
//
// File extension is `.ts` (no JSX). All tests run in vitest's default node
// environment; fetch is stubbed with vi.stubGlobal and timers are faked so
// the 300ms debounce in `saveUserOverrides` is deterministic.
import * as fs from "node:fs";
import * as path from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
type Mock,
} from "vitest";
import {
__resetUserOverridesBuckets_FOR_TEST,
flushUserOverrides,
getUserOverrides,
saveUserOverrides,
type UserOverridesPartial,
} from "../src/services/userOverridesApi";
import {
applyPersistedNonFrameOverrides,
createInitialUserSelection,
deriveUserOverridesKey,
mergeSubmittedPipelineOverridesForRestore,
remapPersistedFramesToZoneFrames,
} from "../src/utils/slidePlanUtils";
import type { SlidePlan, Zone } from "../src/types/designAgent";
// ─── Source-pattern regression ─────────────────────────────────────────────
// Without RTL we can't dispatch a click and read `fetch.mock.calls`. Instead
// we read Home.tsx as text and assert each in-scope handler closure contains
// the exact wiring that Stage 2 u7 specified. This is brittle in a good way:
// if a handler is renamed or its `saveUserOverrides` call is moved/removed,
// the assertion fires with a clear "X handler does not persist Y axis"
// message instead of silently regressing in prod.
const HOME_TSX_PATH = path.resolve(
__dirname,
"..",
"src",
"pages",
"Home.tsx",
);
const HOME_TSX = fs.readFileSync(HOME_TSX_PATH, "utf-8");
/**
* Slice the `const <name> = useCallback(...)` block out of Home.tsx. The
* handlers are well-formed and end either at the next `const handle...`
* declaration or at the next top-level `const ` at 2-space indent.
*/
function sliceHandler(source: string, name: string): string {
const start = source.indexOf(`const ${name} = useCallback(`);
if (start === -1) {
throw new Error(`handler "${name}" not found in Home.tsx`);
}
// Find the next handler / top-level const after `start`.
const nextHandler = source.indexOf("\n const handle", start + 1);
const nextConst = source.indexOf("\n const ", start + 1);
const candidates = [nextHandler, nextConst].filter((i) => i > start);
const end = candidates.length > 0 ? Math.min(...candidates) : source.length;
return source.slice(start, end);
}
/**
* IMP-55 #93 u8 — strip JS/TS line + block comments so source-pattern
* regex checks assert against LIVE code only. The u5 / u7 docblocks in
* Home.tsx intentionally reference removed identifiers (e.g. `defaultByZone`,
* `sameAsDefault`, `zoneSectionsDiff`) and the marker axis name in prose to
* document the Stage 1 root cause for future readers — those references are
* documentation, not behavior, and must not trigger negative-match guards.
* Strips `// ...` to EOL and `/* ... */` (incl. multi-line) — keeps string
* literals intact because we only consume the result for regex-match tests.
*/
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\/\/.*$/gm, "");
}
describe("Home.tsx write-side wiring (IMP-52 u10) — source pattern", () => {
it("handleSectionDrop persists zone_sections behind uploadedFile gate", () => {
const block = sliceHandler(HOME_TSX, "handleSectionDrop");
// gate
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*\)/);
// axis key + value source
expect(block).toMatch(
/saveUserOverrides\([\s\S]*?zone_sections:\s*finalSelection\.overrides\.zone_sections/,
);
// key derivation
expect(block).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
});
it("handleLayoutSelect persists `layout` axis behind uploadedFile gate", () => {
const block = sliceHandler(HOME_TSX, "handleLayoutSelect");
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*\)/);
expect(block).toMatch(
/saveUserOverrides\([\s\S]*?layout:\s*layoutId\s*\}/,
);
expect(block).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
});
it("handleZoneResize persists merged zone_geometries behind uploadedFile gate", () => {
const block = sliceHandler(HOME_TSX, "handleZoneResize");
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*\)/);
// merged geometry (not the partial delta) is persisted so the on-disk
// axis is a complete snapshot of all currently-resized zones.
expect(block).toMatch(
/saveUserOverrides\([\s\S]*?zone_geometries:\s*mergedGeometries/,
);
expect(block).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
});
it("handleFrameSelect persists frames-by-unit_id with default-frame gate", () => {
const block = sliceHandler(HOME_TSX, "handleFrameSelect");
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*&&\s*effectiveSlidePlan\s*\)/);
// unit_id derivation matches handleGenerate's CLI-forwarding contract
expect(block).toMatch(/z\.section_ids\.join\(\s*"\+"\s*\)/);
// default-frame gate (rewind fix from Codex #17 / Claude #18)
expect(block).toMatch(/overrideId\s*!==\s*defaultFrameId/);
// axis key
expect(block).toMatch(
/saveUserOverrides\([\s\S]*?frames:\s*framesByUnitId/,
);
});
it("handleLayoutResize does NOT call saveUserOverrides (zone_sizes excluded)", () => {
const block = sliceHandler(HOME_TSX, "handleLayoutResize");
expect(block).not.toMatch(/saveUserOverrides/);
// Sanity: handleLayoutResize still writes zone_sizes in-memory.
expect(block).toMatch(/saveZoneSizes/);
});
it("handleGenerate does NOT call saveUserOverrides (read-only re: persistence layer)", () => {
const block = sliceHandler(HOME_TSX, "handleGenerate");
// handleGenerate forwards overrides through runPipeline → /api/run, not
// through /api/user-overrides. The persistence layer is owned by the
// four mutation handlers; Generate must not introduce a competing
// write path that could clobber a partially-edited bucket.
expect(block).not.toMatch(/saveUserOverrides\(/);
});
it("no handler in Home.tsx persists the zone_sizes axis", () => {
// Top-level regression: searching the whole file rules out a future
// accidental wiring inside a new handler we forgot to enumerate above.
expect(HOME_TSX).not.toMatch(
/saveUserOverrides\([\s\S]{0,200}?zone_sizes\s*:/,
);
});
});
// ─── Payload-shape contract via mocked fetch ───────────────────────────────
// Drive `saveUserOverrides` with the exact payload shapes each in-scope
// handler produces in Home.tsx. Asserts that (a) the PUT body matches what
// the on-disk schema (u1 / u4) accepts and (b) the partial-axis contract
// holds — only the mutated axis is sent, never a full snapshot.
type MockResponse = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
};
function mockResponse(body: unknown, ok = true, status = 200): MockResponse {
return { ok, status, json: async () => body };
}
let fetchMock: Mock;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
vi.useFakeTimers();
__resetUserOverridesBuckets_FOR_TEST();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
__resetUserOverridesBuckets_FOR_TEST();
});
async function drainMicrotasks(): Promise<void> {
for (let i = 0; i < 4; i++) {
await Promise.resolve();
}
}
function lastPutBody(): unknown {
const lastCall = fetchMock.mock.calls.at(-1);
if (!lastCall) throw new Error("fetch was not called");
const init = lastCall[1] as RequestInit | undefined;
if (!init?.body) throw new Error("fetch called without a body");
return JSON.parse(String(init.body));
}
describe("save payload contract per axis (IMP-52 u10)", () => {
it("section-drop payload: PUT body carries only zone_sections", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
// Shape produced by handleSectionDrop after moveSectionToZone.
const payload: UserOverridesPartial = {
zone_sections: {
top: ["03-1", "03-2"],
bottom: ["03-3"],
},
};
void saveUserOverrides("03_demo", payload);
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["zone_sections"]);
expect(body.zone_sections).toEqual(payload.zone_sections);
});
it("layout-select payload: PUT body carries only `layout` (string)", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03_demo", { layout: "two-column" });
vi.advanceTimersByTime(300);
await drainMicrotasks();
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["layout"]);
expect(body.layout).toBe("two-column");
});
it("zone-resize payload: PUT body carries only zone_geometries (merged snapshot)", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
const merged = {
top: { x: 0, y: 0, w: 1, h: 0.42 },
bottom_l: { x: 0, y: 0.42, w: 0.5, h: 0.58 },
bottom_r: { x: 0.5, y: 0.42, w: 0.5, h: 0.58 },
};
void saveUserOverrides("03_demo", { zone_geometries: merged });
vi.advanceTimersByTime(300);
await drainMicrotasks();
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["zone_geometries"]);
expect(body.zone_geometries).toEqual(merged);
});
it("frame-select payload: PUT body carries only frames (unit_id → template_id)", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
// Shape produced by handleFrameSelect after the default-frame gate:
// only zones the user explicitly chose a non-default frame for.
const framesByUnitId = {
"03-1": "process_product_two_way",
"03-2+03-3": "three_parallel_requirements",
};
void saveUserOverrides("03_demo", { frames: framesByUnitId });
vi.advanceTimersByTime(300);
await drainMicrotasks();
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["frames"]);
expect(body.frames).toEqual(framesByUnitId);
});
it("frame-select payload with empty framesByUnitId still PUTs (replaces axis with {})", async () => {
// When the user reverts the last frame override back to the backend
// default, handleFrameSelect computes `framesByUnitId = {}`. The PUT
// path still fires so the on-disk `frames` axis is cleared to the empty
// object via u4's partial-merge replace semantics. Foreign axes
// (layout / zone_geometries / zone_sections) remain on disk.
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03_demo", { frames: {} });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(lastPutBody()).toEqual({ frames: {} });
});
});
// ─── zone_sizes axis is not part of the on-disk schema ─────────────────────
describe("zone_sizes axis exclusion (IMP-52 u10)", () => {
it("UserOverridesPartial type does not include zone_sizes at compile time", () => {
// Compile-time check: this assignment must be a TS error. The runtime
// assertion below is a placebo; the meaningful evidence is that the
// suite *builds*. If a future schema bump adds zone_sizes to
// UserOverrides, this comment serves as the migration touchpoint.
// @ts-expect-error — zone_sizes is intentionally not part of UserOverridesPartial
const _bad: UserOverridesPartial = { zone_sizes: { layout_group_1: [0.5, 0.5] } };
void _bad;
expect(true).toBe(true);
});
it("Home.tsx never imports a write helper that would persist zone_sizes", () => {
// handleLayoutResize delegates to saveZoneSizes (in-memory), not
// saveUserOverrides. Cross-check the import line and the handler body.
expect(HOME_TSX).toMatch(/import\s*\{[^}]*\bsaveZoneSizes\b[^}]*\}\s*from\s*"\.\.\/utils\/slidePlanUtils"/);
const block = sliceHandler(HOME_TSX, "handleLayoutResize");
expect(block).toMatch(/saveZoneSizes\(/);
expect(block).not.toMatch(/saveUserOverrides/);
});
});
// ─── Write-before-Generate ordering ────────────────────────────────────────
// The four mutation handlers schedule debounced PUTs (300ms). If the user
// hits Generate before the debounce fires, the persistence layer must not
// drop the pending writes. `flushUserOverrides` is the contract: callers can
// force-commit pending buckets before pipeline kickoff so the backend u2
// fallback reads the latest file.
describe("write-before-Generate ordering (IMP-52 u10)", () => {
// The service-level tests below prove the `flushUserOverrides` contract in
// isolation. The two source-pattern checks here pin the *real* Generate
// call site so a future refactor that drops the flush — re-exposing the
// 300ms debounce race against `runPipeline` / the u2 backend fallback —
// fails loudly. Without React Testing Library we cannot dispatch a click
// on the Generate button, so we read Home.tsx as text and assert (a) the
// import names `flushUserOverrides`, (b) the `handleGenerate` closure
// awaits the flush before it awaits `runPipeline`.
it("Home.tsx imports flushUserOverrides from userOverridesApi", () => {
expect(HOME_TSX).toMatch(
/import\s*\{[^}]*\bflushUserOverrides\b[^}]*\}\s*from\s*"\.\.\/services\/userOverridesApi"/,
);
});
it("handleGenerate awaits flushUserOverrides before awaiting runPipeline", () => {
const block = sliceHandler(HOME_TSX, "handleGenerate");
expect(block).toMatch(/await\s+flushUserOverrides\s*\(\s*\)/);
expect(block).toMatch(/await\s+runPipeline\s*\(/);
const flushIdx = block.search(/await\s+flushUserOverrides\s*\(/);
const runIdx = block.search(/await\s+runPipeline\s*\(/);
expect(flushIdx).toBeGreaterThan(-1);
expect(runIdx).toBeGreaterThan(-1);
expect(flushIdx).toBeLessThan(runIdx);
});
it("flushUserOverrides commits a pending PUT before its 300ms debounce fires", async () => {
fetchMock.mockResolvedValue(mockResponse({ layout: "two-column" }));
const savePromise = saveUserOverrides("03_demo", { layout: "two-column" });
// Without flush, the PUT would not fire for another 300ms.
expect(fetchMock).not.toHaveBeenCalled();
const flushPromise = flushUserOverrides();
await drainMicrotasks();
await flushPromise;
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("/api/user-overrides/03_demo");
expect((init as RequestInit).method).toBe("PUT");
// Caller's promise resolves with the server-merged document — so a
// pre-Generate `await flushUserOverrides()` can be paired with
// `await savePromise` for stronger ordering if needed.
await expect(savePromise).resolves.toEqual({ layout: "two-column" });
});
it("flushUserOverrides (no arg) flushes pending writes across multiple MDX keys", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03_demo", { layout: "two-column" });
void saveUserOverrides("04_demo", { frames: { "04-1": "tpl_a" } });
void saveUserOverrides("05_demo", {
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
});
await flushUserOverrides();
await drainMicrotasks();
const putUrls = fetchMock.mock.calls
.filter((c) => (c[1] as RequestInit).method === "PUT")
.map((c) => c[0]);
expect(putUrls).toEqual(
expect.arrayContaining([
"/api/user-overrides/03_demo",
"/api/user-overrides/04_demo",
"/api/user-overrides/05_demo",
]),
);
expect(putUrls).toHaveLength(3);
});
it("flushUserOverrides is a no-op when no writes are pending", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
await flushUserOverrides();
expect(fetchMock).not.toHaveBeenCalled();
});
it("post-flush, a new save schedules a fresh 300ms debounce window", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03_demo", { layout: "two-column" });
await flushUserOverrides();
await drainMicrotasks();
expect(
fetchMock.mock.calls.filter(
(c) => (c[1] as RequestInit).method === "PUT",
),
).toHaveLength(1);
// Second save after Generate completes — must not piggy-back on the
// already-flushed bucket; must re-arm a fresh debounce.
void saveUserOverrides("03_demo", { layout: "hero-detail" });
vi.advanceTimersByTime(299);
await drainMicrotasks();
expect(
fetchMock.mock.calls.filter(
(c) => (c[1] as RequestInit).method === "PUT",
),
).toHaveLength(1);
vi.advanceTimersByTime(1);
await drainMicrotasks();
expect(
fetchMock.mock.calls.filter(
(c) => (c[1] as RequestInit).method === "PUT",
),
).toHaveLength(2);
});
});
// ─── Restore-on-reopen — end-to-end compose ────────────────────────────────
// u6 covers the helpers in isolation. This test wires them together with a
// mocked GET response in the order Home.tsx invokes them at file-upload
// time (key derive → fetch persisted → layer non-frame axes pre-loadRun →
// remap frames post-loadRun) to pin the integration contract.
function makeZone(partial: {
id: string;
zone_id: string;
section_ids: string[];
default_frame_id?: string | null;
}): Zone {
return {
id: partial.id,
zone_id: partial.zone_id,
section_ids: partial.section_ids,
position: { x: 0, y: 0, width: 1, height: 1 },
internal_regions: [
{
id: `${partial.id}-r0`,
region_id: "region-single",
role: "primary",
content_type: "text_block",
ratio_estimate: 1,
content_unit_ids: [],
frame_match_strategy: {
kind: "frame_match",
frame_id: partial.default_frame_id ?? null,
display_strategy: "inline_full",
},
frame_candidates: [],
},
],
};
}
describe("restore-on-reopen end-to-end (IMP-52 u10)", () => {
it("getUserOverrides → non-frame layer + post-load frame remap composes a restored selection", async () => {
// GET returns the persisted file for "03_demo". The `layout` value
// must be a real LayoutPresetId — applyPersistedNonFrameOverrides
// validates against the 8-preset whitelist (slidePlanUtils.ts:30).
fetchMock.mockResolvedValueOnce(
mockResponse({
layout: "horizontal-2",
frames: { "03-1": "process_product_two_way" },
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.42 } },
zone_sections: { top: ["03-1"], bottom: ["03-2", "03-3"] },
}),
);
const key = deriveUserOverridesKey("03_demo.mdx");
expect(key).toBe("03_demo");
// Step 1: Home.tsx fetches at handleFileUpload time.
const persisted = await getUserOverrides(key);
expect(persisted.layout).toBe("horizontal-2");
// Step 2: pre-loadRun layering applies layout / zone_geometries /
// zone_sections onto a fresh selection. Frames are deferred because
// the unit_id key cannot be remapped without a slidePlan yet.
const seededSelection = applyPersistedNonFrameOverrides(
createInitialUserSelection(null),
persisted,
);
expect(seededSelection.overrides.layout_preset).toBe("horizontal-2");
expect(seededSelection.overrides.zone_geometries).toEqual({
top: { x: 0, y: 0, w: 1, h: 0.42 },
});
expect(seededSelection.overrides.zone_sections).toEqual({
top: ["03-1"],
bottom: ["03-2", "03-3"],
});
// Frames must NOT have been layered at this stage.
expect(seededSelection.overrides.zone_frames).toEqual({});
// Step 3: post-loadRun, Home.tsx has a slidePlan. Remap unit_id-keyed
// frames to region.id-keyed frames against the rebuilt plan.
const plan: SlidePlan = {
id: "plan-3",
title: "demo",
layout_preset: "horizontal-2",
zones: [
makeZone({
id: "z-top",
zone_id: "top",
section_ids: ["03-1"],
default_frame_id: "some_default_frame",
}),
makeZone({
id: "z-bot",
zone_id: "bottom",
section_ids: ["03-2", "03-3"],
default_frame_id: null,
}),
],
};
const remapped = remapPersistedFramesToZoneFrames(
plan,
persisted.frames,
);
expect(remapped).toEqual({
"z-top-r0": "process_product_two_way",
});
// Step 4: post-loadRun merge — Home.tsx layers `remapped` onto
// `createInitialUserSelection(slidePlan)` so the SlideCanvas
// override-vs-default preview indicator surfaces the restored choice.
const finalSelection = {
...applyPersistedNonFrameOverrides(
createInitialUserSelection(plan),
persisted,
),
};
finalSelection.overrides = {
...finalSelection.overrides,
zone_frames: { ...finalSelection.overrides.zone_frames, ...remapped },
};
expect(finalSelection.overrides.zone_frames["z-top-r0"]).toBe(
"process_product_two_way",
);
expect(finalSelection.overrides.layout_preset).toBe("horizontal-2");
expect(finalSelection.overrides.zone_sections).toEqual({
top: ["03-1"],
bottom: ["03-2", "03-3"],
});
});
it("post-run restore prefers the submitted Generate payload over stale persisted axes", () => {
const restoreDoc = mergeSubmittedPipelineOverridesForRestore(
{
layout: "vertical-2",
frames: { "03-1": "stale_frame" },
zone_geometries: { left: { x: 0, y: 0, w: 0.4, h: 1 } },
zone_sections: { left: ["03-2"], right: ["03-1"] },
manual_section_assignment: true,
text_overrides: { top: { "title.0": "keep text edit" } },
},
{
frames: { "03-1": "current_frame" },
},
false,
);
expect(restoreDoc.layout).toBeUndefined();
expect(restoreDoc.frames).toEqual({ "03-1": "current_frame" });
expect(restoreDoc.zone_geometries).toBeUndefined();
expect(restoreDoc.zone_sections).toBeUndefined();
expect(restoreDoc.manual_section_assignment).toBe(false);
expect(restoreDoc.text_overrides).toEqual({
top: { "title.0": "keep text edit" },
});
});
it("converts the submitted Generate payload back to persisted-axis names for restore", () => {
const restoreDoc = mergeSubmittedPipelineOverridesForRestore(
{},
{
layout: "top-1-bottom-2",
frames: { "01-intro": "three_parallel_requirements" },
zoneGeometries: {
top: { x: 0, y: 0, w: 1, h: 0.35 },
bottom: { x: 0, y: 0.35, w: 1, h: 0.65 },
},
zoneSections: {
top: ["01-intro"],
bottom: ["01-1", "01-2"],
},
},
true,
);
expect(restoreDoc.layout).toBe("top-1-bottom-2");
expect(restoreDoc.frames).toEqual({
"01-intro": "three_parallel_requirements",
});
expect(restoreDoc.zone_geometries).toEqual({
top: { x: 0, y: 0, w: 1, h: 0.35 },
bottom: { x: 0, y: 0.35, w: 1, h: 0.65 },
});
expect(restoreDoc.zone_sections).toEqual({
top: ["01-intro"],
bottom: ["01-1", "01-2"],
});
expect(restoreDoc.manual_section_assignment).toBe(true);
});
it("missing persisted file (GET returns {}) leaves the selection at backend defaults", async () => {
fetchMock.mockResolvedValueOnce(mockResponse({}));
const persisted = await getUserOverrides(deriveUserOverridesKey("new_file.mdx"));
expect(persisted).toEqual({});
const plan: SlidePlan = {
id: "plan-x",
title: "fresh",
layout_preset: "single",
zones: [
makeZone({ id: "z-only", zone_id: "main", section_ids: ["x-1"] }),
],
};
const seeded = applyPersistedNonFrameOverrides(
createInitialUserSelection(plan),
persisted,
);
// No override applied → layout_preset, geometries, sections all from
// the slidePlan defaults; remap yields {} so no frames layered.
expect(seeded.overrides.layout_preset).toBe("single");
expect(seeded.overrides.zone_geometries).toEqual({});
expect(remapPersistedFramesToZoneFrames(plan, persisted.frames)).toEqual({});
});
});
// ─── IMP-55 #93 u8 — manual_section_assignment intent marker contract ─────
// Verifies four axes of the marker contract introduced in u3 (type) / u5
// (apply reset) / u6 (drag flip + co-PUT) / u7 (generate gate):
// 1) Drag dual-axis persistence — handleSectionDrop persists BOTH
// `zone_sections` AND `manual_section_assignment: true` in the SAME
// PUT body (co-PUT atomicity — disk never sees post-drop zone_sections
// without the marker).
// 2) Apply / cancel reset — handleApplyPendingLayout writes explicit
// `manual_section_assignment: false` after the `...overrides` spread,
// and handleCancelPendingLayout relies on createInitialUserSelection
// (which u3 seeds to `false`) to drop a prior `true`.
// 3) Marker-gated forwarding — handleGenerate gates `overrides.zoneSections`
// forwarding strictly on `manualMarker === true` (NOT truthiness, NOT
// `!= null`, NOT presence). u3-seeded `false` and absent values both
// skip forwarding.
// 4) sameAsDefault NOT required — the Stage 1 anti-pattern (defaultByZone
// / sameAsDefault / zoneSectionsDiff self-compare loop) is gone from
// `handleGenerate` entirely; the marker is the source of intent.
describe("IMP-55 #93 u8 — manual_section_assignment marker contract", () => {
it("handleSectionDrop sets marker true in-memory before persistence", () => {
const block = sliceHandler(HOME_TSX, "handleSectionDrop");
// finalSelection literal (built from zoneSelected, then marker = true)
// must occur BEFORE the saveUserOverrides call so the in-memory state
// and the PUT body source from the same overrides shape.
const markerIdx = block.search(/manual_section_assignment:\s*true/);
const saveIdx = block.search(/saveUserOverrides\(/);
expect(markerIdx).toBeGreaterThan(-1);
expect(saveIdx).toBeGreaterThan(-1);
expect(markerIdx).toBeLessThan(saveIdx);
});
it("handleSectionDrop co-PUTs zone_sections + manual_section_assignment:true (single body)", () => {
const block = sliceHandler(HOME_TSX, "handleSectionDrop");
// Single saveUserOverrides call carrying BOTH axes. The regex spans the
// call body to prove the two keys live in the same object literal — a
// future split into two PUTs would race the 300ms debounce and re-open
// the IMP-55 stale-disk window.
expect(block).toMatch(
/saveUserOverrides\([\s\S]*?zone_sections:[\s\S]*?manual_section_assignment:\s*true[\s\S]*?\)/,
);
// Exactly ONE saveUserOverrides call in the handler.
const calls = block.match(/saveUserOverrides\(/g) ?? [];
expect(calls.length).toBe(1);
});
it("handleApplyPendingLayout resets the marker to false in overrides literal", () => {
const block = sliceHandler(HOME_TSX, "handleApplyPendingLayout");
// After spreading `...p.userSelection.overrides`, the explicit
// `manual_section_assignment: false` overrides any prior-drag `true`.
// Without this the layout flip would carry the marker through, and u7
// would forward auto-carried assignments as user overrides → the
// PARTIAL_COVERAGE regression that motivated IMP-55.
expect(block).toMatch(/\.\.\.p\.userSelection\.overrides[\s\S]*?manual_section_assignment:\s*false/);
});
it("handleCancelPendingLayout uses createInitialUserSelection (u3 seeds false)", () => {
const block = sliceHandler(HOME_TSX, "handleCancelPendingLayout");
// Cancel discards all pending in-memory edits via the fresh-selection
// helper — the seed (u3) is the single source of truth for the
// in-memory marker on this path. u12 adds a separate disk-side
// saveUserOverrides PUT (covered by the u12 describe block below);
// the in-memory userSelection literal still has no explicit marker
// field — the seed handles it.
expect(block).toMatch(/createInitialUserSelection\(p\.slidePlan\)/);
// In-memory contract: no `manual_section_assignment` property appears
// inside the userSelection assignment. The only marker reference in
// live code lives inside the u12 saveUserOverrides(...) call body.
const codeOnly = stripComments(block);
expect(codeOnly).not.toMatch(
/userSelection:[\s\S]*?manual_section_assignment/,
);
});
it("handleGenerate gates overrides.zoneSections on manualMarker === true (strict bool)", () => {
const block = sliceHandler(HOME_TSX, "handleGenerate");
// Marker read AND strict-equality gate. `===` not `==`, not truthiness,
// not presence — so `false` / absent both skip forwarding (fail-closed).
expect(block).toMatch(/state\.userSelection\.overrides\.manual_section_assignment/);
expect(block).toMatch(/manualMarker\s*===\s*true/);
// The assignment to `overrides.zoneSections` must live INSIDE the
// marker-true branch.
const gateIdx = block.search(/if\s*\(\s*manualMarker\s*===\s*true\s*\)/);
const assignIdx = block.search(/overrides\.zoneSections\s*=/);
expect(gateIdx).toBeGreaterThan(-1);
expect(assignIdx).toBeGreaterThan(gateIdx);
});
it("handleGenerate filters forwarded zone_sections to valid zone_ids only (cross-layout safety)", () => {
const block = sliceHandler(HOME_TSX, "handleGenerate");
// A stale persisted layout could carry zone_ids that do not exist in
// the current sourcePlan (e.g. horizontal-2 `top`/`bottom` while the
// current layout is vertical-2 `left`/`right`). Those foreign keys
// must be dropped before reaching the backend `--override-section-
// assignment` so they cannot trigger PARTIAL_COVERAGE.
expect(block).toMatch(/validZoneIds\s*=\s*new Set\(\s*sourcePlan\.zones\.map\(\(z\)\s*=>\s*z\.zone_id\)/);
expect(block).toMatch(/if\s*\(!validZoneIds\.has\(zoneId\)\)\s*continue/);
});
it("handleGenerate no longer contains the IMP-08 B-3 self-compare anti-pattern", () => {
// Strip comments — the u7 docblock intentionally references the removed
// identifiers (`defaultByZone` / `sameAsDefault` / `zoneSectionsDiff`)
// in prose to explain the Stage 1 root cause for future readers; the
// regression we guard against is the LIVE code re-emerging.
const block = stripComments(sliceHandler(HOME_TSX, "handleGenerate"));
// The Stage 1 root cause: these identifiers compared user input against
// itself (sourcePlan === effectiveSlidePlan → zones === pendingZones,
// both derived from the same overrides.zone_sections). u7 deleted the
// entire block.
expect(block).not.toMatch(/\bdefaultByZone\b/);
expect(block).not.toMatch(/\bsameAsDefault\b/);
expect(block).not.toMatch(/\bzoneSectionsDiff\b/);
});
it("co-PUT payload contract: marker=true + zone_sections land in a single PUT body", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
// Shape produced by handleSectionDrop after the u6 marker flip.
void saveUserOverrides("03_demo", {
zone_sections: { left: ["03-2"], right: ["03-1"] },
manual_section_assignment: true,
});
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = lastPutBody() as Record<string, unknown>;
// Both axes in the same PUT body — exact equality, not arrayContaining,
// because any extra axis would mean a foreign mutation leaked through.
expect(Object.keys(body).sort()).toEqual(
["manual_section_assignment", "zone_sections"].sort(),
);
expect(body.manual_section_assignment).toBe(true);
expect(body.zone_sections).toEqual({ left: ["03-2"], right: ["03-1"] });
});
it("co-PUT payload contract: marker=false carries explicitly through saveUserOverrides", async () => {
// u12 will add the apply/cancel explicit `false` PUT; the typed client
// must already propagate the literal `false` through the debounce
// bucket. A truthiness-based coalesce in the bucket merge would drop
// the value and re-open the stale-disk window. This locks the wire
// contract independently of the u12 caller-site write.
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03_demo", { manual_section_assignment: false });
vi.advanceTimersByTime(300);
await drainMicrotasks();
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["manual_section_assignment"]);
expect(body.manual_section_assignment).toBe(false);
});
});
// ─── IMP-55 #93 u12 — stale-disk marker reset on apply / cancel ───────────
// u5 resets the in-memory marker on layout apply, and u3's seed via
// `createInitialUserSelection` resets it on cancel. But the disk persists
// independently — a prior drag wrote `true` via u6's co-PUT, so after a
// page reload the u3 restore branch would re-seed `true` and the u7 gate
// would forward auto-carried section assignments → PARTIAL_COVERAGE
// regression. u12 closes that window by writing `manual_section_assignment:
// false` to disk via saveUserOverrides on both apply and cancel paths.
describe("IMP-55 #93 u12 — stale-disk marker reset on layout apply/cancel", () => {
it("handleApplyPendingLayout source contains a marker=false saveUserOverrides PUT", () => {
const block = sliceHandler(HOME_TSX, "handleApplyPendingLayout");
// Stripped-comment source so the u5 docblock prose doesn't satisfy the
// assertion — must be a real call expression.
const code = stripComments(block);
// Uploaded-file gate (mirrors the u6 / other handler pattern — the
// demo-mode initial render path must not PUT to an empty key).
expect(code).toMatch(
/if\s*\(\s*p\.uploadedFile\s*\)[\s\S]*?saveUserOverrides\([\s\S]*?manual_section_assignment:\s*false[\s\S]*?\)/,
);
expect(code).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
});
it("handleCancelPendingLayout source contains a marker=false saveUserOverrides PUT", () => {
const block = sliceHandler(HOME_TSX, "handleCancelPendingLayout");
const code = stripComments(block);
// Cancel handler converts from arrow-body to function-body for the
// disk PUT; the in-memory reset still comes from createInitialUserSelection.
expect(code).toMatch(
/if\s*\(\s*p\.uploadedFile\s*\)[\s\S]*?saveUserOverrides\([\s\S]*?manual_section_assignment:\s*false[\s\S]*?\)/,
);
expect(code).toMatch(/createInitialUserSelection\(p\.slidePlan\)/);
});
it("apply path PUT payload: marker=false carries alone (no auto-carry leakage)", async () => {
// The apply handler issues a dedicated PUT for the marker reset that is
// independent of the (conditional) zone_geometries PUT and of the
// in-memory zone_sections rewrite. The wire contract for this PUT must
// contain only the marker — if zone_sections leaked into the same body
// it would re-arm the u9 backend fallback gate against u12's intent.
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03_demo", { manual_section_assignment: false });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["manual_section_assignment"]);
expect(body.manual_section_assignment).toBe(false);
});
it("apply path PUT is unconditional (does NOT gate on hadPriorGeoms)", () => {
// The u4 zone_geometries PUT inside handleApplyPendingLayout is
// conditional (`p.uploadedFile && hadPriorGeoms`). The u12 marker PUT
// must NOT inherit that gate — a stale disk `true` can exist without
// any prior zone_geometries, so the reset must always fire.
const code = stripComments(sliceHandler(HOME_TSX, "handleApplyPendingLayout"));
// Locate the marker PUT and verify its enclosing `if` clause is just
// `p.uploadedFile`, not the compound `... && hadPriorGeoms` guard.
const markerCallMatch = code.match(
/if\s*\(([^)]*)\)\s*\{[^}]*saveUserOverrides\([^)]*manual_section_assignment:\s*false[^)]*\)/,
);
expect(markerCallMatch).not.toBeNull();
if (markerCallMatch) {
expect(markerCallMatch[1].trim()).toBe("p.uploadedFile");
}
});
});
@@ -1,222 +0,0 @@
// IMP-44 (#73) u3 — vitest coverage for `validateZoneGeometriesAgainstLayout`.
//
// Pairs with the backend [override-warning] guards added in u1 (1-D
// horizontal-2 / vertical-2 branches of `build_layout_css`) and u2 (2-D
// `_override_to_grid_tracks` call site). Same WARN+DROP unknown / KEEP known
// contract; this helper lets handleGenerate (u4) validate against the active
// layout before forwarding so the user sees a toast on dropped keys rather
// than the backend silently even-splitting non-overridden zones with a false
// `computation=user_override_geometry` signal.
//
// Cases (Stage 2 scope-lock):
// 1) horizontal-2 → vertical-2 mismatch (all keys dropped)
// 2) passthrough (all keys recognized)
// 3) partial mix (some kept, some dropped)
// 4) empty input ({} on a known layout)
// 5) unknown-layout fail-safe (preset null / undefined / unknown string)
import { describe, it, expect } from "vitest";
import { validateZoneGeometriesAgainstLayout } from "../src/utils/slidePlanUtils";
const g = (x: number, y: number, w: number, h: number) => ({ x, y, w, h });
describe("validateZoneGeometriesAgainstLayout (IMP-44 u3)", () => {
// ── 1. mismatch ──────────────────────────────────────────────────────────
it("drops horizontal-2 keys when the active layout is vertical-2", () => {
const result = validateZoneGeometriesAgainstLayout(
{ top: g(0, 0, 1, 0.4), bottom: g(0, 0.4, 1, 0.6) },
"vertical-2",
);
expect(result.kept).toEqual({});
expect(result.dropped).toEqual({
top: g(0, 0, 1, 0.4),
bottom: g(0, 0.4, 1, 0.6),
});
expect(result.expectedPositions).toEqual(["left", "right"]);
expect(result.valid).toBe(false);
});
it("drops vertical-2 keys when the active layout is horizontal-2", () => {
const result = validateZoneGeometriesAgainstLayout(
{ left: g(0, 0, 0.5, 1), right: g(0.5, 0, 0.5, 1) },
"horizontal-2",
);
expect(result.kept).toEqual({});
expect(Object.keys(result.dropped).sort()).toEqual(["left", "right"]);
expect(result.expectedPositions).toEqual(["top", "bottom"]);
expect(result.valid).toBe(false);
});
// ── 2. passthrough ───────────────────────────────────────────────────────
it("keeps all keys when every input key is in the active layout positions", () => {
const input = {
top: g(0, 0, 1, 0.4),
bottom: g(0, 0.4, 1, 0.6),
};
const result = validateZoneGeometriesAgainstLayout(input, "horizontal-2");
expect(result.kept).toEqual(input);
expect(result.dropped).toEqual({});
expect(result.expectedPositions).toEqual(["top", "bottom"]);
expect(result.valid).toBe(true);
});
it("passes a single 'primary' key through on the 'single' preset", () => {
const result = validateZoneGeometriesAgainstLayout(
{ primary: g(0, 0, 1, 1) },
"single",
);
expect(result.kept).toEqual({ primary: g(0, 0, 1, 1) });
expect(result.dropped).toEqual({});
expect(result.expectedPositions).toEqual(["primary"]);
expect(result.valid).toBe(true);
});
it("recognizes the 2-D preset positions reported by computeZonePositions (top-1-bottom-2)", () => {
const input = {
top: g(0, 0, 1, 0.5),
"bottom-left": g(0, 0.5, 0.5, 0.5),
"bottom-right": g(0.5, 0.5, 0.5, 0.5),
};
const result = validateZoneGeometriesAgainstLayout(input, "top-1-bottom-2");
expect(result.kept).toEqual(input);
expect(result.dropped).toEqual({});
expect(result.expectedPositions).toEqual([
"top",
"bottom-left",
"bottom-right",
]);
expect(result.valid).toBe(true);
});
// ── 3. partial mix ───────────────────────────────────────────────────────
it("keeps known keys and drops unknown keys on a partial-mix input", () => {
const result = validateZoneGeometriesAgainstLayout(
{ top: g(0, 0, 1, 0.4), foo: g(0, 0, 1, 1) },
"horizontal-2",
);
expect(result.kept).toEqual({ top: g(0, 0, 1, 0.4) });
expect(result.dropped).toEqual({ foo: g(0, 0, 1, 1) });
expect(result.expectedPositions).toEqual(["top", "bottom"]);
expect(result.valid).toBe(false);
});
it("on a 2-D preset, keeps known 2-D track keys and drops legacy 1-D keys", () => {
// Simulates the user resizing under top-1-bottom-2, then flipping to
// grid-2x2 — legacy `bottom-left` stays valid; `top` (no longer a 2x2
// position) gets dropped.
const result = validateZoneGeometriesAgainstLayout(
{
top: g(0, 0, 1, 0.5),
"bottom-left": g(0, 0.5, 0.5, 0.5),
"top-left": g(0, 0, 0.5, 0.5),
},
"grid-2x2",
);
expect(result.kept).toEqual({
"bottom-left": g(0, 0.5, 0.5, 0.5),
"top-left": g(0, 0, 0.5, 0.5),
});
expect(result.dropped).toEqual({ top: g(0, 0, 1, 0.5) });
expect(result.expectedPositions).toEqual([
"top-left",
"top-right",
"bottom-left",
"bottom-right",
]);
expect(result.valid).toBe(false);
});
// ── 4. empty input ───────────────────────────────────────────────────────
it("returns empty kept/dropped and valid=true on an empty {} input", () => {
const result = validateZoneGeometriesAgainstLayout({}, "horizontal-2");
expect(result.kept).toEqual({});
expect(result.dropped).toEqual({});
expect(result.expectedPositions).toEqual(["top", "bottom"]);
expect(result.valid).toBe(true);
});
it("treats null / undefined geoms as empty input (no throw, valid=true on a known layout)", () => {
const nullResult = validateZoneGeometriesAgainstLayout(null, "vertical-2");
expect(nullResult.kept).toEqual({});
expect(nullResult.dropped).toEqual({});
expect(nullResult.expectedPositions).toEqual(["left", "right"]);
expect(nullResult.valid).toBe(true);
const undefResult = validateZoneGeometriesAgainstLayout(
undefined,
"vertical-2",
);
expect(undefResult.kept).toEqual({});
expect(undefResult.dropped).toEqual({});
expect(undefResult.expectedPositions).toEqual(["left", "right"]);
expect(undefResult.valid).toBe(true);
});
it("ignores array payloads (defensive against hand-edited persisted files)", () => {
const result = validateZoneGeometriesAgainstLayout(
[] as unknown as Record<string, { x: number; y: number; w: number; h: number }>,
"horizontal-2",
);
expect(result.kept).toEqual({});
expect(result.dropped).toEqual({});
expect(result.expectedPositions).toEqual(["top", "bottom"]);
expect(result.valid).toBe(true);
});
// ── 5. unknown-layout fail-safe ──────────────────────────────────────────
it("drops every input key when layout is null (fail-safe)", () => {
const result = validateZoneGeometriesAgainstLayout(
{ top: g(0, 0, 1, 0.4), bottom: g(0, 0.4, 1, 0.6) },
null,
);
expect(result.kept).toEqual({});
expect(result.dropped).toEqual({
top: g(0, 0, 1, 0.4),
bottom: g(0, 0.4, 1, 0.6),
});
expect(result.expectedPositions).toEqual([]);
expect(result.valid).toBe(false);
});
it("drops every input key when layout is undefined (fail-safe)", () => {
const result = validateZoneGeometriesAgainstLayout(
{ primary: g(0, 0, 1, 1) },
undefined,
);
expect(result.kept).toEqual({});
expect(result.dropped).toEqual({ primary: g(0, 0, 1, 1) });
expect(result.expectedPositions).toEqual([]);
expect(result.valid).toBe(false);
});
it("drops every input key when layout is an unknown preset string (fail-safe)", () => {
const result = validateZoneGeometriesAgainstLayout(
{ top: g(0, 0, 1, 0.4) },
"rogue-preset" as unknown as string,
);
expect(result.kept).toEqual({});
expect(result.dropped).toEqual({ top: g(0, 0, 1, 0.4) });
expect(result.expectedPositions).toEqual([]);
expect(result.valid).toBe(false);
});
it("returns empty kept/dropped/expectedPositions when layout is unknown AND geoms is empty", () => {
const result = validateZoneGeometriesAgainstLayout({}, null);
expect(result.kept).toEqual({});
expect(result.dropped).toEqual({});
expect(result.expectedPositions).toEqual([]);
// No keys to drop ⇒ vacuously valid; handleGenerate (u4) gates the toast
// on `Object.keys(dropped).length > 0`, not `valid`, so this is safe.
expect(result.valid).toBe(true);
});
// ── purity / mutation safety ─────────────────────────────────────────────
it("does not mutate the input geometries object", () => {
const input = { top: g(0, 0, 1, 0.4), foo: g(0, 0, 1, 1) };
const inputKeysBefore = Object.keys(input).sort();
validateZoneGeometriesAgainstLayout(input, "horizontal-2");
expect(Object.keys(input).sort()).toEqual(inputKeysBefore);
// Sample value still pristine.
expect(input.top).toEqual(g(0, 0, 1, 0.4));
});
});
+11 -595
View File
@@ -204,545 +204,20 @@ function vitePluginStorageProxy(): Plugin {
}; };
} }
// =============================================================================
// IMP-52 u3/u4 — user_overrides.json persistence (MDX-stem keyed store).
//
// On-disk layout: <DESIGN_AGENT_ROOT>/data/user_overrides/<key>.json. Mirrors
// the Python contract in src/user_overrides_io.py — same validate_key regex,
// same graceful-degrade (corrupt → {}) so backend pipeline entry fallback
// (u2) and the vite endpoints (u3 GET, u4 PUT) agree on every file.
//
// Helpers are named exports so vitest can drive handleGetUserOverrides /
// handlePutUserOverrides with mock req/res without booting a real dev
// server. vite still consumes the default `defineConfig` export below.
// =============================================================================
export const USER_OVERRIDES_KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\-]*$/;
// The nine in-scope axes — full mirror of KNOWN_AXES in
// src/user_overrides_io.py. Order matches the Python tuple verbatim so
// a side-by-side audit reads as a no-op. Any payload key outside this
// allowlist is silently dropped by the PUT handler (u4) so the on-disk
// schema cannot drift from the backend pipeline (u2) contract. Foreign
// top-level keys already on disk are preserved verbatim (see
// mergeUserOverrides).
// IMP-51 (#79) u2: added `image_overrides` (image_id → {x,y,w,h}
// percent-of-slide coordinates).
// IMP-55 (#93) u1: added `manual_section_assignment` (bool intent marker
// — drag-drop sets true, layout apply/cancel sets false).
// IMP-56 (#90) u3: allowlist sync — closes the prior `slide_css` gap
// (IMP-45 #74; the Step-22 slide CSS edit path will write it from the
// frontend) and pre-wires `text_overrides` (IMP-56 #90 u1, keyed by
// {zone_id: {text_path: value}}) + `structure_overrides` (IMP-56 #90 u2,
// keyed by {zone_id: {slot_order, hidden_slots}} — scope LOCKED to slot
// reorder + hide; frame swap stays on the existing `frames` axis to
// preserve Phase Z's no-AI-HTML-structure invariant) so the Step-22
// capture path (u10~u17) can PUT either axis without a follow-on
// allowlist edit.
export const KNOWN_USER_OVERRIDES_AXES = [
"layout",
"zone_geometries",
"zone_sections",
"frames",
"image_overrides",
"slide_css",
"manual_section_assignment",
"text_overrides",
"structure_overrides",
] as const;
export type KnownUserOverridesAxis = (typeof KNOWN_USER_OVERRIDES_AXES)[number];
// 1MB cap on PUT bodies. Override files in practice are < 10KB (5 axes,
// each a small dict). The cap is a safety net against runaway client
// loops, not a real schema constraint.
const USER_OVERRIDES_PUT_MAX_BYTES = 1_000_000;
export function isValidUserOverridesKey(key: string): boolean {
if (!key) return false;
if (key.includes("..")) return false;
if (key.includes("/") || key.includes("\\")) return false;
return USER_OVERRIDES_KEY_RE.test(key);
}
export function userOverridesPath(root: string, key: string): string {
return path.join(root, "data", "user_overrides", `${key}.json`);
}
// Minimal req/res shapes — node IncomingMessage / ServerResponse have many
// fields the handler does not touch, so we accept a structural subset for
// testability.
type GetReqLike = { method?: string; url?: string };
type PutReqLike = {
method?: string;
url?: string;
on(event: "data" | "end" | "error", cb: (...args: any[]) => void): unknown;
};
type ResLike = {
writeHead: (status: number, headers?: Record<string, string>) => void;
end: (body?: string) => void;
};
// IMP-52 u3 — GET /api/user-overrides/:key handler. Returns true when the
// handler took over the response, false when the caller should `next()`.
// Invariants:
// • method != GET → false (chain continues; u4 PUT may handle)
// • invalid key → 400 {"error":"invalid key"}
// • file missing → 200 {}
// • file unreadable/corrupt → 200 {} (graceful degrade, mirrors u1 load)
// • non-object JSON root → 200 {} (mirrors u1 load)
// • valid object JSON → 200 with parsed JSON body
export function handleGetUserOverrides(
req: GetReqLike,
res: ResLike,
root: string,
): boolean {
if (req.method !== "GET") return false;
const url = req.url || "";
const key = url.split("?")[0].replace(/^\//, "");
if (!isValidUserOverridesKey(key)) {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "invalid key" }));
return true;
}
const filePath = userOverridesPath(root, key);
if (!fs.existsSync(filePath)) {
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
res.end("{}");
return true;
}
let parsed: unknown;
try {
const raw = fs.readFileSync(filePath, "utf-8");
parsed = JSON.parse(raw);
} catch {
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
res.end("{}");
return true;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
res.end("{}");
return true;
}
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(parsed));
return true;
}
// IMP-52 u4 — pure merge function. Mirrors src/user_overrides_io.save():
// • Only KNOWN_USER_OVERRIDES_AXES present in `partial` are mutated.
// • Axes absent from `partial` are preserved verbatim from `existing`.
// • Foreign top-level keys in `existing` (future axes like zone_sizes)
// are preserved verbatim — allowlist guards what the PUT writes, NOT
// what the file already holds.
// • `partial[axis] = null` is the explicit clear sentinel (remove key).
// • Any non-axis keys in `partial` are silently dropped (allowlist).
export function mergeUserOverrides(
existing: Record<string, unknown>,
partial: Record<string, unknown>,
): Record<string, unknown> {
const merged: Record<string, unknown> = { ...existing };
for (const axis of KNOWN_USER_OVERRIDES_AXES) {
if (!(axis in partial)) continue;
const value = partial[axis];
if (value === null) {
delete merged[axis];
} else {
merged[axis] = value;
}
}
return merged;
}
// IMP-52 u4 — atomic file write via tmp + rename. Mirrors the
// `_atomic_write_json` semantics in src/user_overrides_io.py so a
// crashed/interrupted PUT cannot leave a half-written .json on disk
// (the next GET / pipeline-entry read would otherwise return {} via
// graceful degrade, silently losing the user's prior overrides).
export function atomicWriteUserOverrides(
filePath: string,
data: Record<string, unknown>,
): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const tmpName = path.join(
dir,
`.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`,
);
try {
fs.writeFileSync(
tmpName,
JSON.stringify(data, null, 2) + "\n",
"utf-8",
);
fs.renameSync(tmpName, filePath);
} catch (err) {
try {
fs.unlinkSync(tmpName);
} catch {
// best-effort cleanup; the rename source may not exist on early failure
}
throw err;
}
}
// IMP-52 u4 — PUT /api/user-overrides/:key handler. Returns true when the
// handler took over the response, false when the caller should `next()`.
// Invariants:
// • method != PUT → false (chain continues; GET runs first)
// • invalid key → 400 {"error":"invalid key"}
// • body > 1MB → 413 {"error":"payload too large"}
// • invalid JSON → 400 {"error":"invalid JSON"}
// • non-object JSON root → 400 {"error":"body must be a JSON object"}
// • write failure → 500 {"error":"write failed: ..."}
// • success → 200 with merged JSON body
//
// Existing-file read uses the same graceful-degrade rules as GET (corrupt
// JSON / non-object root → treat as empty {}) so a PUT cannot fail solely
// because a prior file is unparseable — the new payload replaces it.
export function handlePutUserOverrides(
req: PutReqLike,
res: ResLike,
root: string,
): boolean {
if (req.method !== "PUT") return false;
const url = req.url || "";
const key = url.split("?")[0].replace(/^\//, "");
if (!isValidUserOverridesKey(key)) {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "invalid key" }));
return true;
}
let body = "";
let aborted = false;
req.on("data", (chunk: Buffer | string) => {
if (aborted) return;
body += typeof chunk === "string" ? chunk : chunk.toString();
if (body.length > USER_OVERRIDES_PUT_MAX_BYTES) {
aborted = true;
res.writeHead(413, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify({ error: "payload too large" }));
}
});
req.on("end", () => {
if (aborted) return;
let parsed: unknown;
try {
parsed = body.length > 0 ? JSON.parse(body) : {};
} catch {
res.writeHead(400, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify({ error: "invalid JSON" }));
return;
}
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed)
) {
res.writeHead(400, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify({ error: "body must be a JSON object" }));
return;
}
const partial = parsed as Record<string, unknown>;
const filePath = userOverridesPath(root, key);
// Load existing — corrupt / non-object → {} so the PUT still succeeds
// and recovers the file to a clean state. Mirrors u1 load() graceful
// degrade.
let existing: Record<string, unknown> = {};
if (fs.existsSync(filePath)) {
try {
const raw = fs.readFileSync(filePath, "utf-8");
const ex = JSON.parse(raw);
if (
typeof ex === "object" &&
ex !== null &&
!Array.isArray(ex)
) {
existing = ex as Record<string, unknown>;
}
} catch {
// corrupt → treat as empty
}
}
const merged = mergeUserOverrides(existing, partial);
try {
atomicWriteUserOverrides(filePath, merged);
} catch (err) {
res.writeHead(500, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify({ error: `write failed: ${String(err)}` }));
return;
}
res.writeHead(200, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify(merged));
});
req.on("error", () => {
if (aborted) return;
aborted = true;
res.writeHead(500, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify({ error: "request error" }));
});
return true;
}
// =============================================================================
// IMP-56 (#90) u18 — POST /api/connect : cel astro dev mirror copy.
//
// Body: {"run_id": "<id>", "slug": "<mdx-stem>"}.
// • Copies <DESIGN_AGENT_ROOT>/data/runs/<run_id>/phase_z2/final.html →
// <CEL_PROJECT_ROOT>/public/slides/<slug>.html (overwrite).
// • If <run_dir>/phase_z2/assets/ exists, mirrors its contents into
// <CEL_PROJECT_ROOT>/public/slides/assets/ (overwrite copy, recursive).
// • run_id and slug are validated through the existing
// isValidUserOverridesKey gate so path-traversal payloads are rejected.
// =============================================================================
export function mirrorDirRecursive(srcDir: string, dstDir: string): number {
if (!fs.existsSync(srcDir) || !fs.statSync(srcDir).isDirectory()) return 0;
if (!fs.existsSync(dstDir)) fs.mkdirSync(dstDir, { recursive: true });
let count = 0;
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
const srcPath = path.join(srcDir, entry.name);
const dstPath = path.join(dstDir, entry.name);
if (entry.isDirectory()) {
count += mirrorDirRecursive(srcPath, dstPath);
} else if (entry.isFile()) {
fs.copyFileSync(srcPath, dstPath);
count += 1;
}
}
return count;
}
export function handleConnectMirror(
req: PutReqLike,
res: ResLike,
designAgentRoot: string,
celRoot: string,
): boolean {
if (req.method !== "POST") return false;
let body = "";
req.on("data", (chunk: Buffer | string) => {
body += typeof chunk === "string" ? chunk : chunk.toString();
});
req.on("end", () => {
let parsed: unknown;
try {
parsed = body.length > 0 ? JSON.parse(body) : {};
} catch {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "invalid JSON" }));
return;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "body must be a JSON object" }));
return;
}
const { run_id, slug } = parsed as { run_id?: unknown; slug?: unknown };
if (typeof run_id !== "string" || typeof slug !== "string") {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "missing run_id or slug" }));
return;
}
if (!isValidUserOverridesKey(run_id) || !isValidUserOverridesKey(slug)) {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "invalid run_id or slug" }));
return;
}
const runDir = path.join(designAgentRoot, "data", "runs", run_id, "phase_z2");
const srcHtml = path.join(runDir, "final.html");
if (!fs.existsSync(srcHtml)) {
res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "final.html not found" }));
return;
}
const dstSlidesDir = path.join(celRoot, "public", "slides");
if (!fs.existsSync(dstSlidesDir)) fs.mkdirSync(dstSlidesDir, { recursive: true });
const dstHtml = path.join(dstSlidesDir, `${slug}.html`);
try {
fs.copyFileSync(srcHtml, dstHtml);
} catch (err) {
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: `copy failed: ${String(err)}` }));
return;
}
const assetsCopied = mirrorDirRecursive(
path.join(runDir, "assets"),
path.join(dstSlidesDir, "assets"),
);
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ success: true, run_id, slug, html_target: dstHtml, assets_copied: assetsCopied }));
});
req.on("error", () => {
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "request error" }));
});
return true;
}
// =============================================================================
// IMP-56 (#90) u19 — POST /api/export : standalone HTML download.
//
// Body: {"run_id": "<id>"}.
// • Reads <DESIGN_AGENT_ROOT>/data/runs/<run_id>/phase_z2/final.html.
// • Inlines every `url(assets/<frame>/<file>)` reference (the only
// external dep emitted by the Phase Z2 render path — verified by grep
// against templates/phase_z2/slide_base.html and a representative run)
// as a base64 data URL so the emitted HTML is portable (file:// open
// or any external host, no co-located assets/ dir required). Mirrors
// u18 validation: isValidUserOverridesKey gate for path-traversal
// rejection; final.html missing → 404.
// • Response: 200 text/html with Content-Disposition: attachment so the
// browser triggers a download with `<run_id>.html` filename. Raw HTML
// body (NOT JSON-wrapped) — the BottomActions wiring (u20) will pipe
// the response body straight into a Blob → a[download] click chain
// mirroring the existing serializeSlidePlan JSON download flow.
// =============================================================================
export function inlineAssetsAsDataUrls(html: string, assetsRoot: string): string {
// Match `url(assets/<rel-path>)` (with optional single/double quotes,
// optional surrounding whitespace). The Phase Z2 render path emits
// `url(assets/<frame>/<file>.png)` verbatim into inline `style="..."`
// custom-property declarations (see slide_base.html `--card-frame-bg`
// etc.) — there is no `<link rel="stylesheet">` or `<img src>` external
// ref to handle. Keeping the matcher narrow avoids accidentally
// rewriting `data:` / `http(s):` / sibling-path URLs that the render
// path does not produce.
const URL_RE = /url\(\s*(['"]?)assets\/([^)'"]+)\1\s*\)/g;
return html.replace(URL_RE, (match, _quote: string, rel: string) => {
const filePath = path.join(assetsRoot, rel);
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return match;
const ext = path.extname(filePath).toLowerCase().slice(1);
const mime =
ext === "png" ? "image/png" :
ext === "jpg" || ext === "jpeg" ? "image/jpeg" :
ext === "svg" ? "image/svg+xml" :
ext === "webp" ? "image/webp" :
ext === "gif" ? "image/gif" :
"application/octet-stream";
const buf = fs.readFileSync(filePath);
return `url("data:${mime};base64,${buf.toString("base64")}")`;
});
}
export function handleExportStandalone(
req: PutReqLike,
res: ResLike,
designAgentRoot: string,
): boolean {
if (req.method !== "POST") return false;
let body = "";
req.on("data", (chunk: Buffer | string) => {
body += typeof chunk === "string" ? chunk : chunk.toString();
});
req.on("end", () => {
let parsed: unknown;
try {
parsed = body.length > 0 ? JSON.parse(body) : {};
} catch {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "invalid JSON" }));
return;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "body must be a JSON object" }));
return;
}
const { run_id } = parsed as { run_id?: unknown };
if (typeof run_id !== "string") {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "missing run_id" }));
return;
}
if (!isValidUserOverridesKey(run_id)) {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "invalid run_id" }));
return;
}
const runDir = path.join(designAgentRoot, "data", "runs", run_id, "phase_z2");
const srcHtml = path.join(runDir, "final.html");
if (!fs.existsSync(srcHtml)) {
res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "final.html not found" }));
return;
}
let html: string;
try {
html = fs.readFileSync(srcHtml, "utf-8");
} catch (err) {
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: `read failed: ${String(err)}` }));
return;
}
const inlined = inlineAssetsAsDataUrls(html, path.join(runDir, "assets"));
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Content-Disposition": `attachment; filename="${run_id}.html"`,
});
res.end(inlined);
});
req.on("error", () => {
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "request error" }));
});
return true;
}
// ============================================================================= // =============================================================================
// Phase Z API Plugin — MDX 업로드 → 파이프라인 실행 → 결과 노출 // Phase Z API Plugin — MDX 업로드 → 파이프라인 실행 → 결과 노출
// //
// Endpoints (vite dev middleware) : // Endpoints (vite dev middleware) :
// POST /api/run multipart/JSON body {filename, content} → run_id // POST /api/run multipart/JSON body {filename, content} → run_id
// GET /data/runs/{run_id}/{path} → {DESIGN_AGENT_ROOT}/data/runs/{run_id}/phase_z2/{path} // GET /data/runs/{run_id}/{path} → {DESIGN_AGENT_ROOT}/data/runs/{run_id}/phase_z2/{path}
// GET /api/user-overrides/{key} → data/user_overrides/{key}.json (IMP-52 u3)
// PUT /api/user-overrides/{key} → partial-merge save (IMP-52 u4)
// POST /api/connect → cel mirror (IMP-56 #90 u18)
// POST /api/export → standalone HTML download (IMP-56 #90 u19)
// //
// 환경 변수 (선택) : // 환경 변수 (선택) :
// DESIGN_AGENT_ROOT python pipeline 실행 cwd. default = D:/ad-hoc/kei/design_agent // DESIGN_AGENT_ROOT python pipeline 실행 cwd. default = D:/ad-hoc/kei/design_agent
// CEL_PROJECT_ROOT cel astro dev repo root. default = D:/ad-hoc/cel
// ============================================================================= // =============================================================================
function vitePluginPhaseZApi(): Plugin { function vitePluginPhaseZApi(): Plugin {
const DESIGN_AGENT_ROOT = const DESIGN_AGENT_ROOT =
process.env.DESIGN_AGENT_ROOT || "D:\\ad-hoc\\kei\\design_agent"; process.env.DESIGN_AGENT_ROOT || "D:\\ad-hoc\\kei\\design_agent";
const CEL_PROJECT_ROOT =
process.env.CEL_PROJECT_ROOT || "D:\\ad-hoc\\cel";
const UPLOADS_DIR = path.join(DESIGN_AGENT_ROOT, "samples", "uploads"); const UPLOADS_DIR = path.join(DESIGN_AGENT_ROOT, "samples", "uploads");
const RUNS_DIR = path.join(DESIGN_AGENT_ROOT, "data", "runs"); const RUNS_DIR = path.join(DESIGN_AGENT_ROOT, "data", "runs");
@@ -770,16 +245,6 @@ function vitePluginPhaseZApi(): Plugin {
// (e.g., "top": ["03-1-sub-1"]). Forwarded as --override-section-assignment. // (e.g., "top": ["03-1-sub-1"]). Forwarded as --override-section-assignment.
zoneSections?: Record<string, string[]>; zoneSections?: Record<string, string[]>;
}; };
// IMP-43 (#72) u6 — optional PREV_RUN_ID to reuse Step 0/1/2/5/6
// artifacts from a prior run and resume execution at Step 7.
// Lives at the payload root (NOT under `overrides`) because the
// backend u1 post-merge guard rejects most override axes when
// --reuse-from is supplied. Absent / empty = full pipeline
// (byte-identical to pre-u6 spawn).
reuseFromRunId?: string;
// #98 Task 1 — clean generate / no-change regenerate must not
// resurrect stale data/user_overrides/<key>.json fallback axes.
ignoreUserOverrides?: boolean;
}; };
try { try {
payload = JSON.parse(body); payload = JSON.parse(body);
@@ -791,7 +256,7 @@ function vitePluginPhaseZApi(): Plugin {
return; return;
} }
const { filename, content, overrides, reuseFromRunId, ignoreUserOverrides } = payload; const { filename, content, overrides } = payload;
if (!filename || typeof content !== "string") { if (!filename || typeof content !== "string") {
res.writeHead(400, { "Content-Type": "application/json" }); res.writeHead(400, { "Content-Type": "application/json" });
res.end( res.end(
@@ -875,37 +340,24 @@ function vitePluginPhaseZApi(): Plugin {
); );
} }
} }
// IMP-43 (#72) u6 — --reuse-from <PREV_RUN_ID> forward. Backend
// (u1) parses this flag, validates the snapshot, copies Step
// 0/1/2/5/6 artifacts from data/runs/<PREV_RUN_ID>/phase_z2 into
// the new run_dir, and resumes execution at Step 7. The post-merge
// guard at the same site rejects --override-layout /
// --override-zone-geometry / --override-section-assignment /
// --override-image with axis-named fail-closed exit; only
// --override-frame (above) is preserved. Truthy check excludes
// empty string + undefined so an invalid argument never reaches
// argparse.
if (reuseFromRunId && typeof reuseFromRunId === "string") {
cliArgs.push("--reuse-from", reuseFromRunId);
}
if (ignoreUserOverrides === true) {
cliArgs.push("--ignore-user-overrides");
}
console.log( console.log(
`[phase-z-api] spawn pipeline: run_id=${runId}, mdx=${mdxPath}, args=${JSON.stringify(cliArgs.slice(2))}` `[phase-z-api] spawn pipeline: run_id=${runId}, mdx=${mdxPath}, args=${JSON.stringify(cliArgs.slice(2))}`
); );
const pythonExe = process.platform === "win32" ? "python.exe" : "python"; const pythonExe = process.platform === "win32" ? "python.exe" : "python";
// 2026-05-14 — env toggle forward (보고용 일회성).
// PHASE_Z_ALLOW_RESTRUCTURE / PHASE_Z_ALLOW_REJECT : status 통과
// 2026-05-21 — IMP-38 retire PHASE_Z_MAX_RANK env (never read by backend). // 2026-05-21 — IMP-38 retire PHASE_Z_MAX_RANK env (never read by backend).
// v4 fallback chain max_rank 는 templates/phase_z2/catalog/v4_fallback_policy.yaml 의 // v4 fallback chain max_rank 는 templates/phase_z2/catalog/v4_fallback_policy.yaml 의
// 정식 정책 (dynamic_usable_count_based) 으로 결정 — backend src/phase_z2_pipeline.py // 정식 정책 (dynamic_usable_count_based) 으로 결정 — backend src/phase_z2_pipeline.py
// 의 lookup_v4_match_with_fallback() 가 load_v4_fallback_policy() 로 적용. // 의 lookup_v4_match_with_fallback() 가 load_v4_fallback_policy() 로 적용.
// 2026-07-02 — issue #9 quick fix: PHASE_Z_ALLOW_REJECT / PHASE_Z_ALLOW_RESTRUCTURE
// retire (dead env — src 에서 read 0곳. Emergency P2 가 reject 보존을 기본
// 정책으로 만들어 env toggle 불필요 — emergency.md §4 Quick fix 결정 B).
const proc = spawn(pythonExe, cliArgs, { const proc = spawn(pythonExe, cliArgs, {
cwd: DESIGN_AGENT_ROOT, cwd: DESIGN_AGENT_ROOT,
shell: false, shell: false,
env: { ...process.env }, env: {
...process.env,
PHASE_Z_ALLOW_RESTRUCTURE: "1",
PHASE_Z_ALLOW_REJECT: "1",
},
}); });
let stdout = ""; let stdout = "";
@@ -957,13 +409,11 @@ function vitePluginPhaseZApi(): Plugin {
}); });
}); });
// ── GET /api/sample-mdx → samples/mdx/{01|02|03|04|05} (데모) ── // ── GET /api/sample-mdx → samples/mdx/{03|04|05} (데모) ──
// 페이지 로드 시 frontend 가 자동 fetch — 상대방에게 mdx 파일 공유 안 해도 되게. // 페이지 로드 시 frontend 가 자동 fetch — 상대방에게 mdx 파일 공유 안 해도 되게.
// 2026-05-14 — query param `?mdx=01`~`?mdx=05` 추가 (default = 03). // 2026-05-14 — query param `?mdx=04` / `?mdx=05` 추가 (default = 03).
// 01~05 모두 frontend simulation 가능하도록 frame/layout override 적용 path 유지. // 04 / 05 frontend simulation 가능하도록 frame/layout override 적용 path 유지.
const SAMPLE_MDX_MAP: Record<string, string> = { const SAMPLE_MDX_MAP: Record<string, string> = {
"01": "01. 건설산업 DX의 올바른 이해(0127).mdx",
"02": "02. DX의 시행 목표 및 기대효과.mdx",
"03": "03. DX 시행을 위한 필수 요건 및 혁신 방안.mdx", "03": "03. DX 시행을 위한 필수 요건 및 혁신 방안.mdx",
"04": "04. DX 지연 요인.mdx", "04": "04. DX 지연 요인.mdx",
"05": "05. 설계 방식의 왜곡.mdx", "05": "05. 설계 방식의 왜곡.mdx",
@@ -1014,40 +464,6 @@ function vitePluginPhaseZApi(): Plugin {
fs.createReadStream(previewPath).pipe(res); fs.createReadStream(previewPath).pipe(res);
}); });
// ── GET / PUT /api/user-overrides/{key} → data/user_overrides/{key}.json ──
// IMP-52 u3 (GET) + u4 (PUT) — MDX-stem keyed user overrides. Logic
// lives in the pure helpers (handleGetUserOverrides / handlePutUserOverrides)
// so vitest can exercise them without booting vite. Both handlers
// return false when the HTTP method does not match, so they chain
// cleanly: GET first, then PUT, then next() for everything else
// (e.g., OPTIONS / preflight handled by upstream middleware).
server.middlewares.use("/api/user-overrides", (req, res, next) => {
if (handleGetUserOverrides(req, res, DESIGN_AGENT_ROOT)) return;
if (handlePutUserOverrides(req, res, DESIGN_AGENT_ROOT)) return;
next();
});
// ── POST /api/connect → cel astro public/slides mirror ──
// IMP-56 (#90) u18 — see handleConnectMirror docblock for body shape +
// copy semantics. Logic lives in the pure helper so vitest can drive
// it without booting vite.
server.middlewares.use("/api/connect", (req, res, next) => {
if (handleConnectMirror(req, res, DESIGN_AGENT_ROOT, CEL_PROJECT_ROOT)) return;
next();
});
// ── POST /api/export → standalone HTML download ──
// IMP-56 (#90) u19 — see handleExportStandalone docblock for body
// shape + inline-asset semantics. Logic lives in the pure helper
// (handleExportStandalone + inlineAssetsAsDataUrls) so vitest can
// drive it without booting vite. The response is raw text/html
// (Content-Disposition: attachment); the u20 BottomActions wiring
// will turn the response body into a Blob → a[download] click.
server.middlewares.use("/api/export", (req, res, next) => {
if (handleExportStandalone(req, res, DESIGN_AGENT_ROOT)) return;
next();
});
// ── GET /data/runs/{run_id}/{path} → {RUNS_DIR}/{run_id}/phase_z2/{path} ── // ── GET /data/runs/{run_id}/{path} → {RUNS_DIR}/{run_id}/phase_z2/{path} ──
server.middlewares.use("/data/runs", (req, res, next) => { server.middlewares.use("/data/runs", (req, res, next) => {
if (req.method !== "GET") return next(); if (req.method !== "GET") return next();
-209
View File
@@ -1,209 +0,0 @@
# 이슈 초안 — 2026-07-02 스냅샷(b836e79) 전수 검토 결과
> 검토 범위: PLAN/PROGRESS/emergency.md 등 계획 문서, tests/matching 진단 리포트,
> b836e79 커밋 코드 diff, figma_to_html_agent(미통합) 전체.
> 목표: ① Figma→HTML 변환이 정확히 되고 ② 그 HTML에 콘텐츠 내용·의미가 반영되어 정리되는 상태.
---
## 그룹 A — Figma→HTML 변환 정확도
### A-1. figma_to_html_agent를 메인 repo로 통합 (유실 위험 제거)
**배경**
- Figma→HTML 변환기 본체(MCP 추출 + 수학 변환 + 검증 프로세스 문서 PROCESS.md/MATH.md/RULES.md)가 `Front_test_v515/figma_to_html_agent/`에만 존재하며, 이 디렉토리는 gitignore 처리되어 **어디에도 백업이 없음**.
- 메인 repo에는 사본 스크립트(`scripts/figma_to_html.py`)와 메타데이터만 있음.
- 32개 frame 변환 산출물(blocks/{frame_id}/index.html, texts.md, flat.md)도 같은 위치에 미보존 상태.
**작업**
1. `figma_to_html_agent/`를 repo 최상위로 이동 (문서 + blocks + scripts).
2. `scripts/figma_to_html.py` 중복 제거, gradient_math.py import 경로 정리.
3. MCP(Figma Desktop Dev Mode) 의존성·.mcp.json 샘플 문서화.
4. .gitignore에서 해당 경로 제외 확인 후 커밋.
**완료 조건**: 변환기 전체가 origin/main에 push되어 로컬 디스크 유실 시에도 복구 가능.
---
### A-2. 잔여 Figma frame 프로모션 완료 (8/32 → 32/32)
**배경**
- Figma frame 32개 중 **promoted(본체 편입) 8개**, static(1:1 변환만) 6개+, 나머지는 staged 미작성.
- placement_planner에 "contract-only 템플릿 skip" 로직(`b4_partial_missing_skip`, IMP-95 u6)이 있다는 것은 contract는 있으나 partial HTML이 없는 frame이 존재한다는 뜻 — 매칭이 맞아도 렌더링 불가.
**작업**
1. frame_contracts.yaml 기준 32개 frame의 상태 매트릭스 작성 (contract 유무 / partial HTML 유무 / promoted 여부).
2. static 6개(process-products-2col, scene-with-numbered-list-4, framework-3section-label-body, bordered-3paragraphs, cards-3-category-bim-diagram, dx-banner-3perspective)부터 staged → promoted 진행.
3. partial 없는 contract-only frame 목록을 문서화하고 순차 해소.
**완료 조건**: V4가 선택한 어떤 frame도 "partial 없음"으로 skip되지 않음.
---
### A-3. Figma 원본 추적성(provenance) 강화
**배경**
- promoted 블록은 Jinja2 파라미터화 과정에서 Figma 원본 좌표·구조 추적성이 손실됨. 현재 provenance 테스트(test_imp49_partial_figma_provenance.py)는 **색상 literal만** 비교.
- `get_screenshot` 결과(Figma 원본 PNG)가 캐시되지 않아 사후 육안 비교 기준이 없음.
**작업**
1. blocks/{frame_id}/에 Figma 원본 스크린샷 캐시 저장 (fetch_figma_screenshots.py 활용).
2. promoted 블록마다 source frame_id를 frame_contracts.yaml에 정식 필드로 기록.
3. provenance 테스트를 색상 외 구조(슬롯 수, 주요 영역 배치)까지 확장.
**완료 조건**: 임의의 promoted 블록에서 "원본 Figma frame → HTML 요소" 대응을 문서·테스트로 확인 가능.
---
## 그룹 B — 콘텐츠 의미 → HTML 반영 (매칭 품질)
### B-1. 02-2.2 매칭 실패 교정 — TARGET 4/4 달성
**배경**
- V4 종합 판정 TARGET 3/4 (75%). 실패 케이스 02-2.2: 정답 Frame 14가 V4 순위 7위, 모든 후보 reject.
- 근본 원인: Frame 14의 anchor_sets 키워드가 MDX 02-2.2 본문과 거의 안 겹침 (tests/PIPELINE.md:230-232).
- 문서상 예상 공수 1~2일 — 가장 빠르게 정확도를 올릴 수 있는 지점.
**작업**
1. Frame 14 anchor_sets 재라벨링 + 도메인 키워드 보강.
2. holdout(02-2.1 등)으로 과적합 여부 교차 확인.
**완료 조건**: TARGET 4/4, holdout 정확도 하락 없음.
---
### B-2. MDX 콘텐츠 성격·시각 의도 분류를 키워드 사전 → LLM 판정으로 대체
**배경**
- V3 구조 매칭의 "콘텐츠 성격(12 카테고리)·시각 의도(9 카테고리)"가 키워드 사전 매칭이라 오분류 발생 (예: 01-2가 policy_requirements로 오분류). tests/PROGRESS.md 약점 #2·#3, 예상 공수 3~5일.
- 일반 표현을 못 잡는 구조적 한계 — 새 MDX가 들어올수록 사전 유지비 증가.
**작업**
1. 분류 프롬프트 설계 + 기존 4 TARGET/holdout으로 정확도 비교 (사전 vs LLM vs 하이브리드).
2. 비용 고려해 "사전 1차 + 저신뢰 시 LLM" 하이브리드 우선 검토.
3. 판정 근거를 pipeline_trace에 기록해 사후 검증 가능하게.
**완료 조건**: 01-2 오분류 해소 + TARGET/holdout 분류 정확도 리포트 갱신.
---
### B-3. V4 스코어링 재조정 — ablation 결과 반영
**배경**
- ablation으로 확인된 사실: slot 축(가중 0.15)은 frame 선별에 무영향(Top-1 7/7 동일), cardinality는 axis(0.20)+slot.within(0.075)로 같은 신호가 중복 가중됨 (tests/PROGRESS.md 약점 #5·#6, V4_SLOT_ABLATION.md).
**작업**
1. slot 축을 frame 선별 가중에서 제거하고 label 게이트 역할로만 유지.
2. cardinality 중복 가중 해소 후 가중치 재정규화.
3. TARGET/holdout 재실행으로 회귀 없음 확인.
**완료 조건**: 재조정된 가중치로 TARGET ≥ 기존, confidence 분포 문서화.
---
### B-4. 슬롯 의미 매핑 계약(T25) 실제 배선 + 컴포넌트 확장(T23) 커버리지
**배경**
- "BIM → col_a_label" 같은 편집 슬롯 의미 매핑 부재가 약점 #4로 기록됨.
- 이번 스냅샷에서 node_slot_mapping.yaml(11 node × 9 slot 매트릭스)이 **정의됐으나 runtime에서 읽는 배선 미확인** (주석: "T25 will wire builders/mappers to validate").
- component_expansion_registry.yaml은 DxEffect 1개 entry뿐이고 status: planned. persona role label도 "DxEffect" 하드코딩 (src/phase_z2_pipeline.py L281-284).
- AI schema hints도 three_parallel_requirements 1개 frame만 정의 (L216-241).
**작업**
1. mapper/builder가 node_slot_mapping.yaml을 실제로 로드·검증하도록 배선 (T25).
2. DxEffect expansion end-to-end 테스트 후 status: planned 해제.
3. AI schema hints를 주요 frame(three_persona_benefits, quadrant 등)으로 확장.
4. component_name 하드코딩 제거.
**완료 조건**: node→slot 매핑 위반이 파이프라인에서 검출되고, DxEffect 외 컴포넌트 1개 이상 추가 등록.
---
### B-5. 표 전체 행 추출 + 이미지 frame 매칭 (약점 #7·#8)
**배경**
- 4행 표에서 첫 행만 본문으로 추출, 나머지 행 무시 (DECK 정직성 점검에서 발견).
- 그림/이미지 frame은 본문 텍스트 패턴만 분석해 매칭이 약함.
**작업**
1. `_extract_markdown_table` 및 관련 파서가 전 행을 atom으로 보존하도록 수정, check_phase_z2_render_text_coverage.py로 검증.
2. image_ref node type의 매칭 신호 설계 (alt 텍스트, 주변 문맥).
**완료 조건**: 표 포함 MDX의 render_text_coverage에서 missing 행 0.
---
## 그룹 C — 파이프라인 신뢰성 (empty shell 제거·배선 완성)
### C-1. Emergency P2~P5 완수 — empty shell 완전 제거
**배경**
- mdx 01~05 rescue 세션(emergency.md): root cause 3개(C1 catalog gap all-reject / C2 section key mismatch / C3 V4 source 누락) 식별 후 **P1 진단만 완료, P2~P5 미실행**.
- 정책은 이미 확정됨: reject → `ai_adaptation_required` status, empty_shell terminal 제거, generic_fallback은 기존 catalog 활용.
**작업** (emergency.md의 priority 순서 그대로)
1. P2: V4 후보 보존 + 3-status 분류 (auto_renderable / ai_adaptation_required / blocked_runtime).
2. P2.5: Section key alignment (child → parent pool merge universal policy).
3. P3: empty_shell terminal 제거 + generic_fallback mandatory. — generic fallback frame(three_parallel_requirements, 1171281190 하드코딩)이 모든 MDX 타입을 커버하는지 검증 포함.
4. P4: AI fallback 호출 trace 검증.
5. P5: Frontend candidate panel status surface.
**완료 조건**: mdx 01~05 전부 empty shell 없이 슬라이드 출력.
---
### C-2. V4 evidence 경로(PHASE_Z_B4_V4_EVIDENCE) end-to-end 배선
**배경**
- `_select_frame_v4_aware()`(placement_planner L180-285)와 selection_trace 필드까지 구현됐으나:
- env flag 기본 OFF에 call site에서 v4_candidates 전달 여부 미확인
- Step 11 trace 직렬화 wiring은 "u4 out of scope"로 명시적 미구현
- `partial_exists()` callback의 실제 호출처 없음
**작업**
1. plan_placement() 호출부에 v4_candidates 전달 + flag ON 경로 통합 테스트.
2. Step 11에서 selection_trace JSON 직렬화·응답 포함.
3. flag ON/OFF SHA parity 회귀 테스트(기존 test_b4_v4_evidence_off_sha_parity.py) 통과 유지.
**완료 조건**: flag ON 실행에서 V4 rank 기반 frame 선택이 trace로 확인되고, OFF 시 기존 출력과 SHA 동일.
---
### C-3. pipeline_trace / ai_trace 백엔드-프론트 계약 검증
**배경**
- 프론트 PipelineTracePanel(신규 216줄)이 `RunMeta.pipeline_trace`, `ai_trace` 스키마(sections/units/zones, skip_reasons 등)를 기대하지만, **백엔드가 이 JSON을 실제 생성·응답에 포함하는지 미확인**.
- `ignoreUserOverrides` 플래그, `coverage_state`, `rationale.ai_adaptation_required` 등 신규 필드도 동일.
**작업**
1. 백엔드 응답과 프론트 타입(designAgentApi.ts) 필드 대조표 작성.
2. 누락 필드는 백엔드에서 생성 추가, 계약 테스트(스키마 검증) 1본 작성.
**완료 조건**: 실제 run에서 PipelineTracePanel이 전 섹션 상태를 표시.
---
### C-4. SlideCanvas.tsx 한글 주석 인코딩 손상 복구 (소형)
**배경**
- Front/client/src/components/SlideCanvas.tsx L1~80 부근 한글 주석이 깨진 채 커밋됨 ("?ㅼ젣", "?쒖떆" — CP949↔UTF-8 오변환 패턴). 기능 코드는 정상.
**작업**: 깨진 주석 복원(git 히스토리의 정상 버전 참조) 후 UTF-8로 재저장. 에디터/훅에 인코딩 고정 설정 검토.
**완료 조건**: 파일 전체 UTF-8 정상, diff에 주석 복원만 포함.
---
## 제안 우선순위 (의존 관계 기준)
| 순위 | 이슈 | 이유 |
|---|---|---|
| 1 | A-1 (변환기 통합) | 백업 없는 유실 위험 — 즉시 |
| 2 | C-1 (Emergency P2~P5) | 진행 중 작업, 정책 확정 상태 |
| 3 | B-1 (02-2.2 교정) | 1~2일로 TARGET 4/4 |
| 4 | C-2 (V4 evidence 배선) | 이미 절반 구현된 코드 완성 |
| 5 | C-3 (trace 계약) + C-4 (인코딩) | 프론트 검증 기반 확보 |
| 6 | B-4 (슬롯 매핑 배선) | "의미 반영" 목표의 핵심 |
| 7 | A-2 (frame 32개 완성) | 매칭 성공해도 렌더 불가인 갭 해소 |
| 8 | B-2, B-3 (분류 LLM화, V4 재조정) | 정확도 상한 돌파 |
| 9 | B-5 (표/이미지), A-3 (추적성) | 커버리지·품질 보강 |
-85
View File
@@ -1,85 +0,0 @@
# 프로젝트 전체 그림 — 2026-07-03 종합
> 근거: Gitea C.E.L_Slide_test2 이슈 #43~#98 (56건) + #98 코멘트 95개 전문 (5/27~6/4)
> + repo 문서/코드 실측 + GitHub keimin86/design_agent 이슈 #1~#12 처리 결과.
## 1. 연대기 (무엇이 언제 일어났나)
| 시기 | 축 | 내용 |
|---|---|---|
| ~4월 | 기반 구축 | Phase 1~5/T/V: 블록 46개, renderer, 적합성 검증 (31/31). figma_to_html_agent 완성 (32 frame 변환, 8개 promoted) |
| 4월 말 | 매칭 시스템 | tests/matching V1~V4 구축. **TARGET 4/4** (v4_full32, 4/29) — 문서의 "3/4"는 낡은 기록 |
| 5월 | IMP wave | Gitea #43~#95: overflow chain(IMP-34/35/36), AI fallback 실선화(IMP-33/46/47B), 사용자 편집·영속(IMP-51/52/54/90), Layer A(#89→94/95/96 분할) 등 50건 closed |
| 5/26 | Emergency | mdx 01~05 empty shell 발생 → root cause 3개(C1/C2/C3) 진단, P2~P5 정책 확정 (emergency.md) |
| 5/27~6/4 | **#98 E2E 스퍼트** | Task 0~13으로 사용자 체감 품질 복구 (아래 §2). 로컬 수정만, 커밋 없이 중단 |
| 6/4~7/2 | 공백 | 미커밋 상태로 방치 (다른 프로젝트 작업) |
| 7/2~3 | 보존+정리 | 스냅샷 커밋 b836e79 push. GitHub 이슈 12건 등록, 5건 처리 (#1,#4,#9,#10,#12) |
## 2. #98 스퍼트가 이룬 것 (Task 0~13, 5/27~6/4)
달성 (모든 mdx): 텍스트 손실 0 (missing_atoms=0) / section 순서·분리 보존 /
`__empty__` zone 제거 / markdown 문법 노출 제거 / clean generate (--ignore-user-overrides) /
false PASS 차단 / stale overlay 제거 / AI trace 관측성.
**6/4 최종 5-MDX presentation readiness** (마지막 코멘트 30028):
| MDX | 기계 판정 | 사람 판정 | 남은 문제 |
|---|---|---|---|
| 01 | PASS | needs_review | generic fallback (intro), bottom dense |
| 02 | PASS | **needs_review** | **false-positive 최악** — 카드 cramped, density gate 없음 |
| 03 | PASS | ready | 유일한 design-matched ✓ |
| 04 | VISUAL_REGRESSION | not_ready | overflow 실재 (honest fail) — height-fit 필요 |
| 05 | PASS | needs_review | 양쪽 zone generic fallback — design matching 필요 |
## 3. 7/2~3 세션이 추가로 해결한 것
- **#12**: SlideCanvas.tsx 한글 손상 복구 (ad10657)
- **#9-P4**: AI proposal envelope 버그 — #98 Task 12가 남긴 "AI 호출되나 no_proposal" 문제의 근본 원인이 envelope 미명시였음을 확인·수정 (9717898). 이제 design_adaptation_plan이 **검증 통과** (apply 계층만 미구현)
- **#10**: V4 evidence trace가 P4b 복구 경로에서 유실되던 것 수정 (d75c063) — Gitea #95(89-c)의 실질 마무리
- 전제가 낡은 이슈 정정: #1 (변환기 이미 push됨), #4 (TARGET 이미 4/4)
- 발견: tests/phase_z2 기존 실패 11건 (layout cols / fixtures / imp88 salvage / step17 — #98 스퍼트의 WIP 잔재로 추정)
## 4. 남은 갭 — 목표 축별 정리
### 목표 A: Figma→HTML 정확 변환
- frame 커버리지 8/32 promoted — 매칭 성공해도 partial 없어 못 그리는 frame 존재 (GitHub #2)
- **MDX01/05 generic fallback 탈출이 이 축의 실전 과제** — catalog matching 개선 또는 AI adaptation (#98 추천 4번)
- provenance 손실 (GitHub #3, P3)
### 목표 B: 콘텐츠 의미 → HTML 반영
- **design_adaptation_plan apply 계층 미구현** — proposal 검증까지 도달, 적용만 남음 (GitHub #7과 통합, #98 Task 12의 완결)
- T25 node_slot_mapping runtime 배선 (GitHub #7)
- 분류 LLM화 (GitHub #5), V4 가중치 재조정 (GitHub #6) — 방법론 개선, 후순위
### 목표 C: 품질 게이트 신뢰성 (#98이 남긴 최우선)
- **density/readability gate** — MDX02 false-positive 차단 (#98 추천 1·2번)
- **MDX04 height-fit escalation** — overflow 해소 (#98 추천 3번)
- readiness 신호 통합 (text coverage + design_readiness + density)
- popup/details live fixture (#98 추천 5번)
- 기존 테스트 실패 11건 수복
### 인프라 큐 (Gitea #97 Queue Tracker, paused 5/26)
- P0: #91 multi-sample regression CI → #71 진단 도구 → #73 mismatch warning → …
- 열린 축: #55 (contract validator, deferred), #66 (IMP-37 stage0 normalizer ★), #83 (AI 결과 자산화, Future), #96 (89-d marker)
## 5. 권장 다음 스퍼트 (우선순위)
1. **기존 실패 11건 수복** — 이후 작업의 회귀 판별 기반
2. **design_adaptation_plan apply 계층** (GitHub #7 + #98 Task 12 완결) — AI 재배치가 실제로 동작해야 MDX01/05 generic fallback 탈출과 MDX04 height-fit에 모두 쓰임
3. **density/readability gate** (MDX02) + **MDX04 height-fit** — 사람 판정과 기계 판정의 간극 해소
4. **MDX05/01 design matching** — frame 커버리지 확장(#2)과 연동
5. Gitea #91 (regression CI) — 위 작업들의 안전망 자동화
## 6. 이슈 트래커 대응표
| GitHub (7/2 등록) | Gitea 대응 | 상태 |
|---|---|---|
| #1 변환기 통합 | — | closed (전제 낡음) |
| #4 02-2.2 교정 | — | closed (이미 4/4) |
| #9 Emergency P2~P5 | emergency.md, #98 Task 10 | closed (P4 수정 완료) |
| #10 V4 evidence 배선 | #95 (89-c) | closed (trace 유실 수정) |
| #12 인코딩 복구 | — | closed |
| #7 슬롯 매핑+apply | #98 Task 12, #83 선행 | **open — 다음 핵심** |
| #2 frame 32개 완성 | #98 추천 4 (design matching) | open |
| #11 trace 계약 검증 | #98 Task 6/7 (구현됨 — 검증만) | open (경량화 가능) |
| #5, #6, #8, #3 | 방법론/보강 | open (후순위) |
-5
View File
@@ -1,5 +0,0 @@
# IMP-46 u6 — persistent frame_cache root marker.
#
# Tracked so the directory exists on a fresh checkout; cached JSON payloads
# under data/frame_cache/{frame_id}/{signature_hash}.json remain git-ignored
# via the .gitignore exception block.
@@ -167,66 +167,6 @@ Step 0 (사전 준비) 의 Figma → HTML 변환은 *precondition phase 의 작
--- ---
## 7. Multi-MDX regression markers (IMP-91)
> CI workflow `.github/workflows/multi-mdx-regression.yml` rewrites these via `scripts/update_status_board.py` after each push / PR. Initial value `?` = not yet observed. `PASS` / `FAIL` / `ERR` / `SKIP` = last CI run outcome per axis × mdx. Untouched markers remain `?` so collection failures are loud, not silent.
| axis | mdx 01 | mdx 02 | mdx 03 | mdx 04 | mdx 05 |
|---|---|---|---|---|---|
| F0 normalize | <!-- IMP-91:F0:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F0:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F0:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F0:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F0:05 -->?<!-- /IMP-91 --> |
| F1 V4 ranking | <!-- IMP-91:F1:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F1:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F1:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F1:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F1:05 -->?<!-- /IMP-91 --> |
| F2 slot_payload | <!-- IMP-91:F2:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F2:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F2:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F2:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F2:05 -->?<!-- /IMP-91 --> |
| F3 classifier-only AI | <!-- IMP-91:F3:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F3:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F3:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F3:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F3:05 -->?<!-- /IMP-91 --> |
| F4 layout | <!-- IMP-91:F4:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F4:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F4:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F4:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F4:05 -->?<!-- /IMP-91 --> |
| F5 final.html | <!-- IMP-91:F5:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F5:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F5:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F5:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F5:05 -->?<!-- /IMP-91 --> |
---
## 8. IMP-43 (#72) `--reuse-from` measured savings
> Stage 2 §u8 binding contract: the issue-body 5070% / 1020s → 38s claim is **unverified** and is **not** mirrored here. Numbers below come from `scripts/measure_reuse_savings.py` on the project reference host; until that script is run and the values committed, every cell stays `TBD`.
| axis | value |
|---|---|
| measurement script | `scripts/measure_reuse_savings.py` |
| reuse boundary (Stage 1 lock) | Step 0 / 1 / 2 / 5 / 6 only; Step 7+ re-executes |
| full rerun seconds (p50) | TBD |
| full rerun seconds (p95) | TBD |
| reuse seconds (p50) | TBD |
| reuse seconds (p95) | TBD |
| reuse / full ratio (p50) | TBD |
| last measured | TBD (date / host / mdx / iterations) |
Run protocol (per iteration): `(A)` seed → `(B)` full rerun with one self-discovered `--override-frame` pin → `(C)` `--reuse-from <seed>` with the same pin. The `(A)` seed time is reported separately and **not** included in the B-vs-C comparison — the reuse path's whole point is that the seed already exists from a prior interactive run.
Invocation: `python -m scripts.measure_reuse_savings samples/mdx_batch/02.mdx --iterations 5` (mdx is argv-driven; the script does not pin a sample internally).
---
## 9. IMP-95 (V4 evidence → B4 `_select_frame` integration) sub-axis markers
> Sub-axis carve-out of section 3 item (j) for IMP-95. Pair-comment markers
> `<!-- IMP-95:<axis> -->VALUE<!-- /IMP-95 -->`. Closing tag `<!-- /IMP-95 -->`
> is intentionally distinct from IMP-91's `<!-- /IMP-91 -->` so the IMP-91
> updater (`scripts/update_status_board.py`) cannot rewrite IMP-95 cells.
> Allowed values: `pending` (not implemented), `trace-only` (default-OFF flag
> `PHASE_Z_B4_V4_EVIDENCE`, additive telemetry only — no render-path change),
> `guarded` (default-OFF regression harness landed and runs locally), `active`
> (default-ON — not the current IMP-95 target).
| sub-axis | status |
|---|---|
| j1 V4-aware selector under `accepted_content_types ⊇` (u2) | <!-- IMP-95:j1 -->trace-only<!-- /IMP-95 --> |
| j2 `plan_placement` v4_candidates kwarg + selection_trace (u3) | <!-- IMP-95:j2 -->trace-only<!-- /IMP-95 --> |
| j3 Step 11 `placement_trace` hoist (u4) | <!-- IMP-95:j3 -->trace-only<!-- /IMP-95 --> |
| j4 Gatekeeper `v4_short_circuit` telemetry (u5) | <!-- IMP-95:j4 -->trace-only<!-- /IMP-95 --> |
| j5 `partial_exists` precheck (u6) | <!-- IMP-95:j5 -->trace-only<!-- /IMP-95 --> |
| j6 Flag-OFF SHA parity regression on mdx 01/02/04/05 (u8) | <!-- IMP-95:j6 -->guarded<!-- /IMP-95 --> |
| j7 Flag-ON adapter_needed monotone regression (u9) | <!-- IMP-95:j7 -->guarded<!-- /IMP-95 --> |
| j8 Flag-ON `placement_trace` field presence regression (u10) | <!-- IMP-95:j8 -->guarded<!-- /IMP-95 --> |
---
## 사용 방법 ## 사용 방법
- 새 작업 들어오면 → 본 board 의 *어느 step* 의 status 를 바꾸는 작업인지 식별 - 새 작업 들어오면 → 본 board 의 *어느 step* 의 status 를 바꾸는 작업인지 식별
@@ -1,119 +0,0 @@
# Five-MDX Structure Inventory
Issue: #98
Purpose: establish the empirical ground truth for T22-T30 before changing
the parser, matcher, layout planner, slot mapper, frontend override path, or
AI adaptation path.
This inventory intentionally compares three layers:
| Column | Meaning | Gap type exposed |
|---|---|---|
| A. Original MDX contains | What the source file actually expresses | Source structure |
| B. Current pipeline sees | What the current `task21d_*` runs expose in Step 02 / Step 06 / Step 20 | Parser, normalizer, component, and planner behavior |
| C. Expected result | What the slide pipeline should preserve and render | Layout, matching, slot mapping, override, and adaptation behavior |
Source files inspected:
- `samples/mdx/01. ... .mdx`
- `samples/mdx/02. ... .mdx`
- `samples/mdx/03. ... .mdx`
- `samples/mdx/04. ... .mdx`
- `samples/mdx/05. ... .mdx`
Observed pipeline runs:
- `data/runs/task21d_01`
- `data/runs/task21d_02`
- `data/runs/task21d_03`
- `data/runs/task21d_04`
- `data/runs/task21d_05`
## MDX 01
| A. Original MDX contains | B. Current pipeline sees | C. Expected result | Gap owner |
|---|---|---|---|
| A pre-intro block before the first numbered `##` heading. It includes a details/popup block and is user-visible content. | Step 02 only emits `01-1` and `01-2`. The pre-intro has no unit id. Existing `data/user_overrides/01.json` references `01-3`, but that id is orphaned. | Promote the pre-intro into a first-class unit such as `01-intro`, or define a documented alias/migration path for the existing `01-3` override. | T22 parser/normalizer and override migration |
| `## 1` and `## 2` numbered sections. | Step 06 selects two units: `01-1 -> construction_bim_three_usage`, `01-2 -> bim_dx_comparison_table`. | Body zones should be based on the normalized section tree after the pre-intro policy is decided. | T24 layout policy |
| An image reference in section 2 and details/popup content. | Recent asset handling removes broken image icons, but popup/details semantics remain only partially represented. | Image and popup/details nodes must be explicit node types, not raw markdown leftovers. | T22 node taxonomy and T25 slot mapping |
## MDX 02
| A. Original MDX contains | B. Current pipeline sees | C. Expected result | Gap owner |
|---|---|---|---|
| `## 1` with goal content and one image. | Step 02 emits `02-1`. Step 06 selects `construction_goals_three_circle_intersection`. A user override exists for `02-1`, but frontend/runtime override behavior is not proven end-to-end. | Section `02-1` must remain independently addressable, and selected/overridden frames must render without stale preview or silent fallback. | T28 frontend override E2E |
| `## 2`, with `### 2.1` and `### 2.2`. | Step 02 emits only parent `02-2`; child headings are raw content inside the section. | `2.1` and `2.2` should become frame-internal groups under the `02-2` unit, not separate slide zones unless a layout policy explicitly asks for that. | T22/T25 |
| `<DxEffect />`, which represents the stakeholder/persona effect table in `samples/src/components/dx.astro`. | The pipeline sees an opaque component tag; persona columns are not expanded into semantic nodes. | Register `DxEffect` in a component expansion registry and expand it into persona/table nodes before matching and slot mapping. | T23 component expansion |
## MDX 03
| A. Original MDX contains | B. Current pipeline sees | C. Expected result | Gap owner |
|---|---|---|---|
| `## 1` with three conceptual groups: technology, people, and environment/nature. | Step 06 selects `three_parallel_requirements` for `03-1`. Technical text coverage is currently PASS. | Child groups should populate the internal pillar labels and body slots of the selected frame. They must not become external slide titles. | T25 slot mapping |
| `## 2` with `### 2.1` process and `### 2.2` product, including table-like content. | Step 06 selects `process_product_two_way` for `03-2`. | Process/product child groups should map to the selected two-way frame with all source atoms preserved. | T25/T27 |
| Dense content in two zones. | Presentation can still become visually dense even when technical coverage is PASS. | Fit must be solved through zone sizing, frame expansion, or layout choice, not font shrinking or text truncation. | T27 and T30 |
## MDX 04
| A. Original MDX contains | B. Current pipeline sees | C. Expected result | Gap owner |
|---|---|---|---|
| `## 1` with several perception/problem groups. | Step 06 selects `pre_construction_model_info_stacked` with a reject/provisional path. Step 20 marks design readiness as `needs_review`. | Reject/provisional candidates must remain visible as candidates, but the slide should surface that adaptation is required. | T26 candidate policy |
| `## 2` with `### 2.1` and `### 2.2`, plus details blocks. | Step 06 keeps parent `04-2` and selects `bim_issues_quadrant_four`; earlier runs showed child handling instability. | Child groups under `04-2` should become internal quadrant/list items in source order. | T25 slot mapping |
| Content is larger than the currently chosen zones in some layouts. | Technical PASS does not guarantee presentation readiness. | The system should resize/replan zones or expand frame structure. It must not shrink font size to hide overflow. | T24/T27/T30 |
## MDX 05
| A. Original MDX contains | B. Current pipeline sees | C. Expected result | Gap owner |
|---|---|---|---|
| Two top-level numbered sections, `05-1` and `05-2`. | Step 06 now emits two units, but both use `three_parallel_requirements` through `generic_fallback`. Step 20 marks design readiness as `not_ready`. | Sections must stay separated, and fallback/generic frames must be visible as not ready unless a real matching/adaptation path is used. | T24/T26 |
| `05-2` uses numeric `### 1` and `### 2` headings, not decimal `### 2.1` style headings. | These child headings are raw section content. | The contract must decide whether numeric child headings are allowed input or require MDX standardization. | T21.6/T22 |
| User-selected alternative frames can work through CLI override, but UI selection has not been proven. | `data/user_overrides/05.json` contains zone assignment but no `frames` override. | Frontend frame selection must persist `frames` and backend must apply the selected frame without stale preview or silent no-op. | T28 frontend override E2E |
## Aggregate Node Inventory
| Node type | Found in | Current state | Contract decision needed |
|---|---|---|---|
| `pre_intro` | MDX 01 | Not promoted to a unit | Allow as first-class unit or require source standardization |
| `numbered_section` | MDX 01-05 | Parsed as top-level units | Keep as the main slide-zone unit type |
| `subsection_decimal` | MDX 02, 03, 04 | Usually raw content under parent | Map to frame-internal groups unless explicitly split |
| `subsection_numeric` | MDX 05 | Raw content under parent | Decide whether allowed or normalized to decimal form |
| `component_ref` | MDX 02 `<DxEffect />` | Opaque tag | Registry-based component expansion |
| `image_ref` | MDX 01, MDX 02 | Asset copy now works for known frame images; content images still need typed handling | Explicit image node and image slot policy |
| `details_popup` | MDX 01, MDX 04 | Raw markdown/html-like content | Explicit popup/details node and presentation policy |
| `table_like_content` | MDX 01 details, MDX 02 component, MDX 03 table | Partly raw content | Explicit table/comparison nodes |
| `summary_footer` | All five MDX samples conceptually | Must stay outside body frame zones | Fixed base-slide region, not a body layout zone |
## Aggregate Gap List
| Gap | Evidence | Next task |
|---|---|---|
| Pre-intro content lacks stable unit identity | MDX 01 source has visible content before the first `##`; Step 02 emits only `01-1` and `01-2`; override references orphan `01-3` | T22 |
| Registered component expansion does not exist | MDX 02 uses `<DxEffect />`; current parser sees only the component tag | T23 |
| Child headings need frame-internal mapping | MDX 02/03/04/05 child headings remain raw content or map inconsistently | T25 |
| Generic fallback can be technically PASS but design-not-ready | MDX 05 uses `generic_fallback` for both units | T26/T30 |
| Frontend override persistence is not proven | MDX 05 override JSON has no `frames`; CLI override works separately | T28 |
| Fit is still a structure/layout problem, not a font problem | MDX 03/04 can be dense; policy forbids font shrinking | T24/T27/T30 |
## Immediate Contract Decisions For T21.6
1. Unit identity policy:
- Prefer stable explicit ids such as `01-intro`, not implicit renumbering.
- Existing user override aliases such as `01-3` need a tested migration or compatibility rule.
2. Component registry:
- Start with `DxEffect`.
- New components must be added through registry entries, not one-off parser branches.
3. Child-section policy:
- Top-level `## n.` sections map to body zones.
- Child headings map to internal frame slots by default.
4. Allowed input policy:
- `## n. Title` is allowed.
- Registered component tags are allowed.
- Unregistered component tags are unsupported.
- Raw JSX/style blobs are disallowed in rendered text and must be sanitized or rejected.
5. Coverage-state policy:
- `covered_native`: current frame and builder can render source atoms verbatim.
- `covered_via_expand`: current frame can render after deterministic repeat/expand.
- `requires_adaptation`: visual motif is usable, but an adapted schema is needed.
- `unsupported`: no current contract path; must be surfaced honestly.
@@ -1,189 +0,0 @@
# MDX-to-Frame Structure Contract
Issue: #98
Status: draft contract for T22-T30.
This document converts `5_MDX_STRUCTURE_INVENTORY.md` into a shared
contract for parser normalization, component expansion, layout selection,
frame candidate ranking, slot mapping, frontend override behavior, and AI
adaptation.
The contract is deliberately process-first. It must not introduce MDX-id
special cases, generated text, font shrinking, or silent fallback.
## 1. Non-Negotiable Policies
| Policy | Rule |
|---|---|
| Source text preservation | MDX source atoms are copied verbatim into rendered slots. No omission, summary, inference, or rewriting. |
| AI isolation | AI may propose adapted frame structure only. AI must not output new or modified MDX text. |
| Unit separation | Top-level content units must not be silently merged. |
| Child mapping | Child headings and groups usually map to frame-internal labels/body slots, not independent slide titles. |
| Summary/footer | Summary/footer content belongs to the fixed base-slide region, not body layout zones. |
| Fit policy | Do not reduce font size to force fit. Use layout, zone sizing, frame expansion, or needs-review state. |
| Candidate honesty | Reject/provisional candidates remain visible with status. They are not deleted and not treated as ready. |
| No silent swap | If selected frame and rendered frame differ, record a failure or explicit adaptation state. |
| No debug leakage | Internal ids, counters, and pipeline labels must not appear in presentation output. |
## 2. Node Taxonomy
| Node type | Definition | Unit role | Examples from inventory |
|---|---|---|---|
| `pre_intro` | Visible content before the first numbered `##` heading | Optional top-level unit | MDX 01 intro/details before `## 1` |
| `numbered_section` | `## n. Title` top-level section | Default slide body unit | MDX 01-05 |
| `subsection_decimal` | `### n.m Title` child section | Frame-internal group by default | MDX 02, 03, 04 |
| `subsection_numeric` | `### n Title` child section without parent decimal prefix | Allowed only if normalized to parent-scoped child id | MDX 05 |
| `bullet_group` | Consecutive bullet lines under a unit or child heading | Slot body group | MDX 01-05 |
| `table_like_content` | Markdown table, component-expanded table, or details table | Table/comparison slot group | MDX 01 details, MDX 02 `DxEffect`, MDX 03 |
| `image_ref` | Markdown image reference with alt text and src | Image slot | MDX 01, MDX 02 |
| `component_ref` | Registered MDX/Astro component tag | Expanded node tree | MDX 02 `<DxEffect />` |
| `details_popup` | `<details>` block or equivalent collapsible detail source | Popup/detail body slot or auxiliary note | MDX 01, MDX 04 |
| `summary_footer` | Closing summary or key message outside body frame zones | Fixed footer/base-slide region | All five samples conceptually |
## 3. Unit Identity Rules
| Input pattern | Unit id policy | Notes |
|---|---|---|
| `pre_intro` before first numbered section | `<mdx_id>-intro` | Existing orphan overrides such as `01-3` need alias migration to this id. |
| `## n. Title` | `<mdx_id>-<n>` | This remains the stable primary unit id. |
| `### n.m Title` under parent `n` | `<mdx_id>-<n>-sub-<m>` if materialized, otherwise child node under parent | Materialization must be explicit; default is internal child node. |
| `### k Title` under parent `n` | `<mdx_id>-<n>-sub-<k>` after parent-scoped normalization | This covers MDX 05 numeric child headings. |
| Registered component | Stays under containing unit unless registry declares child units | `DxEffect` should expand under `02-2`. |
Override compatibility:
- Existing overrides that reference missing ids must not be silently ignored.
- A compatibility alias table must be emitted during T22 when a prior id can be
mapped safely, such as `01-3 -> 01-intro` if that is confirmed as user
intent.
- If an override id cannot be resolved, the frontend and pipeline trace must
show `override_unresolved`.
## 4. Slot Taxonomy
| Slot type | Meaning | Text source |
|---|---|---|
| `title` | Top-level frame title | Unit title only |
| `label` | Short internal group label | Child heading or source label atom only |
| `body_text_lines` | Paragraph or bullet text | Source atoms only |
| `pillar` | Generic repeated vertical group with label/body | Child heading plus child body |
| `persona_column` | Stakeholder/persona column | Component-expanded table/persona node |
| `comparison_cell` | Cell in two-column or multi-column comparison | Source table cell or bullet group |
| `table_row` | Repeated row in a table-like frame | Source row or key/value atom |
| `image_target` | Frame image slot | Source image_ref, never decorative generated image |
| `popup_body` | Detail/popup auxiliary content | Source details content |
| `footer_summary` | Fixed summary/footer region | Summary source only |
`pillar` is a generic repeated group concept. `three_parallel_requirements`
uses it as one concrete family implementation, but the slot type itself is not
limited to that frame.
## 5. Node-to-Slot Mapping Matrix
| Node type | Allowed target slots | Default behavior | Disallowed behavior |
|---|---|---|---|
| `pre_intro` | `title`, `label`, `body_text_lines`, `popup_body` | Treat as a top-level unit when visible and substantial | Dropping it because it precedes `## 1` |
| `numbered_section` | `title`, `body_text_lines`, `image_target`, `table_row` | One body zone unit by default | Merging with another top-level section without explicit user action |
| `subsection_decimal` | `label`, `body_text_lines`, `pillar`, `comparison_cell`, `table_row` | Internal frame group | Using child title as the whole slide title when parent is the unit |
| `subsection_numeric` | `label`, `body_text_lines`, `pillar` | Normalize under current parent | Treating it as a new top-level section |
| `bullet_group` | `body_text_lines`, `comparison_cell`, `table_row` | Preserve bullet text verbatim | Rewriting or summarizing |
| `table_like_content` | `table_row`, `comparison_cell`, `persona_column` | Preserve cells verbatim | Flattening into prose without trace |
| `image_ref` | `image_target` | Resolve source image path and alt text | Broken image icon or decorative replacement |
| `component_ref` | Registry-defined expanded nodes | Expand before matching | Passing opaque tag into rendered text |
| `details_popup` | `popup_body`, optional note slot | Preserve hidden/detail text in trace and supported frames | Leaking raw `<details>` markup as prose |
| `summary_footer` | `footer_summary` | Fixed base-slide region | Consuming a body layout zone |
## 6. Component Expansion Registry Contract
Registry file:
- `templates/phase_z2/catalog/component_expansion_registry.yaml`
Initial required entry:
- `DxEffect`
Rules:
- Registered components expand into typed nodes before matching.
- Unregistered component tags are `unsupported`, not silently rendered as text.
- Component expansion is data-driven through the registry, not a one-off parser
branch.
- Expanded text still follows source preservation: copied from component source,
not generated.
## 7. Coverage-State Taxonomy
The same state labels must be used by Step 05/06/20, frontend candidate panel,
and AI adaptation routing.
| State | Meaning | User-facing behavior | AI behavior |
|---|---|---|---|
| `covered_native` | Existing frame and builder can render all required source atoms verbatim | Candidate is selectable and renderable | No AI |
| `covered_via_expand` | Existing frame can render after deterministic repeat/expand | Candidate is selectable with expansion note | No AI |
| `requires_adaptation` | Visual motif is usable, but slot schema/repeat structure must be adapted | Candidate is selectable with AI-adaptation badge | AI may propose adapted schema only |
| `unsupported` | No current contract path | Show unsupported / needs design asset | No automatic AI text or hidden fallback |
Reject/provisional V4 labels are not deleted. They map to
`requires_adaptation` or `unsupported` depending on whether the frame motif can
be structurally reused.
## 8. Allowed And Disallowed Input Rules
| Input | Contract status | Action |
|---|---|---|
| `## n. Title` | Allowed | Parse as `numbered_section`. |
| Visible content before first `##` | Allowed if non-empty | Parse as `pre_intro`. |
| `### n.m Title` under current parent | Allowed | Parse as `subsection_decimal`. |
| `### k Title` under current parent | Allowed with normalization | Parse as `subsection_numeric`; emit normalized id. |
| Registered component tag | Allowed | Expand through registry. |
| Unregistered component tag | Unsupported | Surface unsupported with trace. |
| Raw JSX style object or event handler in source text | Disallowed in rendered text | Sanitize or fail coverage gate. |
| Raw HTML details block | Allowed with typed handling | Parse as `details_popup`. |
## 9. Frame Coverage Priority
T27 must not attempt to migrate all 32 contracts at once. The priority is:
1. Frames actually selected or requested by the five MDX samples.
2. Frames with existing family partials under `templates/phase_z2/families`.
3. Remaining catalog-only frames, tracked separately as a family/variant mapping
axis. This remains separate from the #98 immediate readiness work.
Initial frame families in scope:
- `construction_bim_three_usage`
- `bim_dx_comparison_table`
- `construction_goals_three_circle_intersection`
- `three_persona_benefits`
- `three_parallel_requirements`
- `process_product_two_way`
- `pre_construction_model_info_stacked`
- `bim_issues_quadrant_four`
## 10. Task Mapping
| Task | Contract responsibility |
|---|---|
| T22 | Implement node taxonomy and unit identity rules. |
| T23 | Implement component expansion registry, starting with `DxEffect`. |
| T24 | Select layout from normalized top-level units and fixed summary/footer policy. |
| T25 | Implement node-to-slot mapping and builder coverage checks. |
| T26 | Carry candidate pools and coverage states through backend and frontend. |
| T27 | Add deterministic repeat/expand rules without font shrinking. |
| T28 | Verify frontend override persistence, stale-preview prevention, and backend application. |
| T29 | Implement AI structure-plan path only for `requires_adaptation`. |
| T30 | Run five-MDX technical, design-readiness, and presentation-readiness gates. |
## 11. Acceptance Checks For This Contract
- The contract defines node taxonomy, slot taxonomy, node-to-slot mapping,
coverage states, allowed/disallowed input rules, and task mapping.
- The contract explicitly covers all five MDX samples from the inventory.
- The contract does not permit AI text generation or font shrinking.
- The contract defines how reject/provisional candidates are surfaced without
silent deletion.
- The contract identifies registry files that later implementation tasks can
read.
-319
View File
@@ -1,319 +0,0 @@
# Emergency Session — mdx 01~05 visible-result rescue
**시작**: 2026-05-26
**목표 시간**: ~1 시간
**모드**: 로컬 코드 개선 (no Gitea push, no orchestrator, no issue 생성)
---
## 1. 목표
프론트에서 mdx 01~05 **5 개 모두 반드시 슬라이드 출력** (empty shell 금지) + 사용자가 frame 선택/변경 가능 + 선택된 frame 으로 AI 가 원문 보존하며 재배치.
### 정책 lock (한 줄)
> **empty shell 금지. 어떤 경우든 원문 텍스트 보존 + 선택 가능 frame 또는 fallback frame 에 AI 가 재배치.**
**현재 상태**:
| mdx | 결과 |
|---|---|
| 03 | ✅ direct path — 정상 |
| 04 | ❌ 04-1 reject → provisional, 04-2 plan 에서 누락. 1 slide 만 |
| 05 | ❌ 05-1 + 05-2 empty_shell. 빈 placeholder |
| 01, 02 | unknown (이번 세션 sanity check 필요) |
**기대 (정책 적용 후)**: 모든 mdx 의 모든 section 이 슬라이드로 렌더 — reject-only 여도 AI 재배치 path 로 surface.
---
## 2. 운영 원칙 (lock)
- ❌ git push 금지
- ❌ Gitea issue 생성 / orchestrator 실행 금지
- ❌ 결과물 하드코딩 금지 (mdx 별 특수 처리 X)
- ❌ MDX 원문 텍스트 변경 금지
- ❌ 후보 수 강제 floor (3~6 padding) 금지
-**empty_shell 종결 금지** — 후보 reject-only 라도 AI adaptation route 필수
-**reject 후보 자동 렌더 금지** — 항상 ai_adaptation_required 로 status 부여
- ✅ 코드 path / 정책 / fallback 수정
- ✅ 각 edit 후 로컬 run 으로 검증
- ✅ Codex + 사용자 + Claude consensus 매 step
---
## 3. Root cause 진단 (확정)
3-layer contradiction:
```
Layer 1: vite.config.ts spawn env
PHASE_Z_ALLOW_REJECT="1" ← 의도: reject 허용
← 실제: src/ 에서 read 안 됨 (dead env var)
Layer 2: src/phase_z2_pipeline.py:1481-1520
lookup_v4_non_reject_candidates()
if j.get("label") == "reject": continue ← reject 강제 drop (May 8 lock)
Layer 3: composition + IMP-30 u4
selection_path = "empty_shell"
imp30_u4 = "terminal_first_render_empty_shell" ← AI fallback 도달 차단
```
**결과**: reject 후보가 있어도 → filter 에서 drop → empty_shell → terminal → AI fallback 미호출.
**Cross-reference**:
- `.env: AI_FALLBACK_ENABLED=true` ✅ 활성
- 사용자 정책 ([feedback_ai_isolation_contract]): "reject = AI 재구성 대상 (frame 유지 + AI 재배치)"
- IMP-47B (#76): reject 후보 surface = override path 만 (자동 path 미포함)
### Resolution — 통합 정책
3-layer contradiction 의 해소 = **§1 의 정책 lock ("empty shell 금지")** 적용 :
1. Layer 2 의 reject filter → **status 분류로 대체** (제거 X, 모두 보존 + status 부여)
2. Layer 3 의 empty_shell terminal → **제거** (어떤 후보 상태든 AI adaptation route 로 흐름)
3. Layer 1 의 dead env → wire-up 또는 retire (§4 Quick fix)
### P1 결과 (2026-05-26) — 3 종류 root cause 동시 존재 (단순 reject filter X)
P1 검증으로 처음 가정 ("주로 reject filter 문제") 와 실제 가 다름 발견 :
| 분류 | 영향 section | 처리 priority |
|---|---|---|
| **C1. All-reject (catalog gap)** | `02-2.1`, `04-1` (raw 32 모두 reject) | **P2** — reject = `ai_adaptation_required` (status 분류) |
| **C2. Section key mismatch** | `02-2``02-2.1/02-2.2`, `04-2``04-2.1/04-2.2` (V4 source 가 split) | **P2.5** — parent ↔ child key alignment policy (universal) |
| **C3. V4 source 자체 누락** | `mdx 05 전체` (entry 0) | **P3** — generic fallback frame mandatory (raw 후보 0 일반 정책) |
**fixture 주의** : `tests/matching/v4_full32_result.yaml` = 2026-04-29 stale snapshot. 이번 세션 = fallback policy 강화로 fixture 의존 약화. runtime V4 evaluator migrate = 별 axis (이번 세션 후).
### Candidate status 3 분류 (정책 기반)
| status | 후보 label | 처리 |
|---|---|---|
| `auto_renderable` | use_as_is / light_edit / restructure (일부) | 즉시 직접 렌더 가능 |
| `ai_adaptation_required` | needs_adaptation / reject / fallback_candidate | 자동 렌더 X, AI 재배치 필요 |
| `blocked_runtime` | visual_pending / catalog scaffolding 등 | 현재 시스템상 렌더 불가 (별 axis 로 추적) |
**규칙**:
- `auto_renderable` 1+ → 그 중 rank-1 으로 직접 렌더
- `auto_renderable` 0 + `ai_adaptation_required` 1+ → top 후보 AI adaptation route
- 둘 다 0 → generic_fallback frame + AI adaptation (별 axis — 이번 세션 후순위, §4 결정 보류 참조)
- **어떤 경우든 empty_shell 종결 X**
---
## 4. 작업 순서 (5 priority + 1 quick fix)
### Priority 1 — Raw 후보 분포 확인 (5~10분, read-only)
**목적**: catalog gap (후보 없음) vs matching gap (후보 있지만 reject) 구분.
**파일**:
- `tests/matching/v4_full32_result.yaml` (raw 32 candidates source)
- `data/runs/mdx04_clean_check_v2/phase_z2/steps/step05_v4_evidence.json`
- `data/runs/mdx05_clean_check_v2/phase_z2/steps/step05_v4_evidence.json`
**검증 질문**:
- 04-1, 04-2, 05-1, 05-2 각각의 raw 32 candidates 중 reject 수 / non-reject 수?
- 어떤 frame_template_id 들이 reject 됐나? (catalog 의 가능성)
- candidate_status="no_non_reject_v4_candidate" 가 정말 모든 4 섹션?
### Priority 2 — V4 후보 보존 + status 분류 (15~20분, edit)
**목적**: reject 후보 삭제 X, 모두 보존 + `candidate_status` 부여 (auto_renderable / ai_adaptation_required / blocked_runtime).
**파일 + 함수**:
- `src/phase_z2_pipeline.py:1481-1520` `lookup_v4_non_reject_candidates`
- rename → `lookup_v4_candidates(v4, section_id, max_n=6, include_reject=True, alias_keys=None)`
- **default = `include_reject=True`** (정책 lock 반영)
- 모든 후보 반환 + 각 V4Match 에 `candidate_status` field 추가
- reject filter 제거 — visual_pending 만 `blocked_runtime` 로 표시 (drop X)
**V4Match dataclass / dict 확장**:
- `candidate_status: str` field 추가
- 분류 규칙 (단일 source of truth):
- label ∈ {use_as_is, light_edit} → `auto_renderable`
- label = restructure → `auto_renderable` (단, capacity_fit 통과 시) / `ai_adaptation_required` (mismatch 시)
- label ∈ {needs_adaptation, reject, fallback_candidate} → `ai_adaptation_required`
- visual_pending = True → `blocked_runtime`
**호출처 검색 필요**:
- `grep -n lookup_v4_non_reject_candidates src/`
- 호출처 마다 status field 사용 여부 확인 + 필요 시 분기 추가
**composition 변경**:
- `src/phase_z2_composition.py` (line ~681 부근)
- 후보 retrieve → status 별 분류:
- `auto_renderable` 1+ → rank-1 으로 직접 렌더
- 그 외 → `ai_adaptation_required` route (P3 의 empty_shell 우회 와 연결)
### Priority 2.5 — Section key alignment policy (10~15분, edit) [NEW — C2 처리]
**목적**: pipeline normalize 의 section_id (예: `04-2`) 와 V4 source 의 split child keys (예: `04-2.1`, `04-2.2`) 매칭. **universal policy** — 02-2 / 04-2 모두 cover, mdx 별 dispatch X.
**접근 — "child → parent pool merge" (Codex 안)** :
parent 를 child 로 split 하는 방식 (composition planner 변경) 보다 빠르고 안전 :
```
section_id = X (e.g., "04-2")
lookup_v4_candidates(X) → V4 에 X 직접 매칭 시도
hit → return as-is
miss → fallback: child keys 검색 (X.1, X.2, X.3, ...)
hit → 모든 child candidates 를 X 의 candidate pool 로 merge
(origin: child_id 보존 — debug)
miss → return [] (P3 의 generic fallback 으로 흐름)
```
**파일**:
- `src/phase_z2_pipeline.py:1505` `_resolve_v4_section_key` — alias 기능 이미 일부 있음 (alias_keys param). 확장 필요.
- `lookup_v4_candidates` (P2 에서 신설) — alias miss 시 child key scan 추가
**검증 sample**:
- `04-2` lookup → V4 miss → `04-2.1` + `04-2.2` 찾음 → 5 candidates pool 로 merge (04-2.1=2, 04-2.2=3)
- `02-2` lookup → V4 miss → `02-2.1` + `02-2.2` 찾음 → 5 candidates pool
### Priority 3 — empty_shell terminal 제거 + AI adaptation + generic fallback mandatory (15~20분, edit)
**목적**: `terminal_first_render_empty_shell` (IMP-30 u4) **제거** — 어떤 후보 상태든 AI adaptation 으로 분기. **empty_shell 종결 = 0 보장**. mdx 05 같은 raw 후보 0 케이스도 cover (C3).
**파일**:
- `src/phase_z2_composition.py` 또는 `src/phase_z2_pipeline.py` 의 IMP-30 u4 분기점
- 검색: `terminal_first_render_empty_shell`, `empty_shell`, `__empty__` literal
**정책 결정 (lock — universal, mdx 별 X)**:
| condition | route |
|---|---|
| `auto_renderable` 1+ | rank-1 으로 직접 렌더 |
| `auto_renderable` 0 + `ai_adaptation_required` 1+ | top 후보 AI adaptation route. selection_path = `"ai_adaptation_required"`, frame_template_id = 실제 frame |
| `auto_renderable` 0 + `ai_adaptation_required` 0 (raw 후보 0 — C3) | **generic_fallback frame + AI adaptation** (mandatory, no longer deferred) |
**generic_fallback frame 정책**:
- catalog 의 가장 universal frame 1 개 지정 (예: simple bullet + title 형)
- **신설 frame X — 기존 catalog 활용**
- hardcode mdx 별 dispatch X — "raw 후보 0 일 때" 통일 트리거
- selection_path = `"generic_fallback"`, candidate_status = `"ai_adaptation_required"` (AI 가 원문 재배치)
- catalog_gap metric 기록 (운영 시그널 — terminal X)
**무엇이 더 이상 존재하지 않는가**:
- `merge_type: "empty_shell"`
- `selection_path: "empty_shell"`
- `frame_template_id: "__empty__"`
- `imp30_u4: "terminal_first_render_empty_shell"` (이게 terminal 이라는 의미)
**기록은 유지** (운영 시그널):
- `candidate_status_summary` 같은 metric 으로 catalog_gap 비율 추적 — 단 terminal 아님
### Priority 4 — AI fallback 호출 trace (5~10분, read + verify)
**목적**: .env 가 `AI_FALLBACK_ENABLED=true` 인데 실제 호출되는지 확인. empty_shell 전에 끊기는지.
**파일**:
- `src/phase_z2_ai_fallback/router.py:67` (`if not settings.ai_fallback_enabled:` gate)
- `src/phase_z2_pipeline.py:4914` (`if not _settings.ai_fallback_enabled:`)
**검증**:
- mdx 04/05 로컬 run 후 `step12_ai_repair.json` 확인 — AI 호출 attempt 있나?
- 없으면 Priority 3 fix 후 다시 확인
### Priority 5 — Frontend candidate panel 검증 (5~10분, read + edit)
**목적**: backend 후보 살아도 frontend 숨기면 사용자 못 씀. reject / restructure / needs_adaptation 후보가 우측 panel 에 나와야.
**파일**:
- `Front/client/src/components/FramePanel.tsx` (candidate rendering 로직)
- `Front/client/src/components/SlideCanvas.tsx` (candidate selection 흐름)
- API: `/api/run` response 의 candidate 필드 확인
**검증**:
- mdx 04/05 로컬 run 결과 → frontend 가 reject 후보를 표시 / 선택 가능한지
### Quick fix (5분) — PHASE_Z_ALLOW_REJECT wire-up 또는 retire
**파일**:
- `Front/vite.config.ts:903-904``PHASE_Z_ALLOW_REJECT: "1"` / `PHASE_Z_ALLOW_RESTRUCTURE: "1"`
- `src/` — 현재 read 0 매치
**결정 안**:
- **A. Wire-up**: `lookup_v4_candidates``include_reject` 가 이 env 도 source 로 read
- **B. Retire**: vite spawn env 에서 제거 (dead var 제거)
선택은 작업 진행 중 결정.
---
## 5. 검증 — Acceptance criteria
### 각 priority 완료 후 sanity
| priority | local run | 기대 |
|---|---|---|
| 1 | (read-only) | ✅ done — 04/05 raw 후보 분포 + C1/C2/C3 root cause 식별 |
| 2 | mdx04/05 local pipeline | step05_v4_evidence 의 각 candidate 에 `candidate_status` field 존재 (auto / ai_adaptation / blocked), reject 후보 surface — **C1 처리** |
| 2.5 | mdx02/04 local pipeline | `04-2` lookup → child `04-2.1` + `04-2.2` 후보 5 개 merge 됨 (debug log 에 origin child_id 보존), `02-2` 도 동일 — **C2 처리** |
| 3 | mdx04/05 local pipeline | step06_composition_plan 의 `selection_path` ∈ {`auto_renderable_rank_1`, `ai_adaptation_required`, `generic_fallback`}, **`"empty_shell"` 0 회**, `frame_template_id != "__empty__"`, mdx 05 도 generic_fallback 으로 슬라이드 — **C1/C3 처리** |
| 4 | mdx04/05 local pipeline | step12_ai_repair.json 에 AI 호출 trace 존재 (ai_adaptation route 도달) |
| 5 | frontend 새로고침 | mdx 04/05 의 우측 panel 에 후보 surface + status 별 시각 구분 (예: 배지) |
### 최종 demo readiness
- [ ] mdx 01: 의미 있는 슬라이드 나옴
- [ ] mdx 02: 의미 있는 슬라이드 나옴 (02-2 = C2 alignment 검증)
- [ ] mdx 03: 정상 (회귀 X 검증)
- [ ] mdx 04: 04-1 (C1 reject→AI), 04-2 (C2 alignment) 모두 슬라이드 나옴
- [ ] mdx 05: 05-1 + 05-2 generic_fallback 으로 슬라이드 나옴 (C3 처리)
- [ ] **5 mdx 통합: empty_shell 결과 = 0** (어떤 mdx 도 empty 안 됨 — 정책 lock 검증)
- [ ] **C2 검증**: 04-2 가 04-2.1/04-2.2 child candidates 받아옴 (parent ↔ child alignment universal)
- [ ] **C3 검증**: mdx 05 가 V4 source 없음에도 generic_fallback frame + AI adaptation 으로 슬라이드 (raw 후보 0 일반 정책)
- [ ] 각 슬라이드 frame 변경 가능 (panel UI, status 분류 surface)
- [ ] AI 적용/재배치 동작 (mdx 텍스트 보존)
- [ ] reject-only mdx (mdx 04-1, mdx 02-2.1) 가 AI adaptation 으로 슬라이드 생성 — 정책 lock end-to-end 검증
---
## 6. 수정 대상 파일 — 최종 inventory
**Edit 대상**:
- `src/phase_z2_pipeline.py` — lookup_v4 함수 + Step 5/9 caller + empty_shell 분기
- `src/phase_z2_composition.py` — empty_shell terminal disposition 우회 + reject 후보 routing
- (조건부) `src/phase_z2_ai_fallback/router.py` — gate 확인 후 필요 시
- (조건부) `Front/vite.config.ts``PHASE_Z_ALLOW_REJECT` wire-up 또는 retire
- (조건부) `Front/client/src/components/FramePanel.tsx` — reject 후보 surface 검증/추가
**Read-only 진단**:
- `tests/matching/v4_full32_result.yaml`
- `data/runs/mdx04_clean_check_v2/phase_z2/steps/step05_v4_evidence.json`
- `data/runs/mdx04_clean_check_v2/phase_z2/steps/step06_composition_plan.json`
- `data/runs/mdx05_clean_check_v2/phase_z2/steps/step05_v4_evidence.json`
- `data/runs/mdx05_clean_check_v2/phase_z2/steps/step06_composition_plan.json`
- `data/runs/mdx05_clean_check_v2/phase_z2/steps/step12_ai_repair.json`
- (priority 4 진행 후) 새 run 의 동일 step files
**MDX 원문 (절대 변경 X)**:
- `samples/mdx_batch/01.mdx` ~ `05.mdx`
---
## 7. 진행 추적
각 priority 완료 후 이 섹션에 결과 한 줄 추가 — 작업 끝나면 정리해서 후속 Gitea issue / commit 본문 reference.
- [x] P1: Raw 후보 분포 확인 — C1 (02-2.1, 04-1 all-reject) / C2 (04-2 ↔ 04-2.1/04-2.2 split) / C3 (mdx 05 source 누락) 식별
- [x] P2: V4 후보 보존 + 3-status 분류 (C1 처리 — reject = ai_adaptation_required) — 2026-07-02 실측 검증: mdx04 04-1 reject 후보 `ai_adaptation_required` 로 보존 (GitHub issue #9)
- [x] P2.5: Section key alignment policy (C2 처리 — child → parent pool merge, universal) — 2026-07-02 실측: 02-2 가 parent merge 로 frame 14 획득
- [x] P3: empty_shell terminal 제거 + AI adaptation + generic_fallback frame mandatory (C1/C3 처리) — 2026-07-02 실측: mdx05 GENERIC_FALLBACK 렌더, 5/5 mdx empty shell 0
- [x] P4: AI fallback 호출 trace — 2026-07-02: 호출은 됐으나 proposal envelope 불일치로 전량 ValidationError 유실 발견 → 수정 (커밋 9717898: 프롬프트 envelope 명시 + client 코어싱). 잔여: design_adaptation_plan apply 계층 미구현 (unsupported_kind — issue #7 axis)
- [x] P5: Frontend candidate panel 검증 (status surface) — FramePanel candidate_status 툴팁 + route 배지 구현 확인
- [ ] Quick fix: PHASE_Z_ALLOW_REJECT wire-up 또는 retire (미확인 — dead env var 여부 재점검 필요)
---
## 8. 후속 정리 (이 세션 후)
작업 안정화 되면 별도 task 로:
- 새 Gitea issue 생성 (P1~P5 결과 evidence 기반)
- 분리된 commits 으로 push
- 23 suspect pool deep-dive (별 axis)
- queue tracker #97 update
이 단계는 **이번 세션 scope 외**. 데모 우선.
-25
View File
@@ -163,28 +163,3 @@ STEP 10 인덱스 업데이트 — blocks_index.md 1줄 추가
### 고정 슬라이드 배경 ### 고정 슬라이드 배경
`templates/blocks/slide-base.html` — 16:9 (1280×720), 상단 제목 + 구분선 + 본문 영역 + 하단 결론 pill. `templates/blocks/slide-base.html` — 16:9 (1280×720), 상단 제목 + 구분선 + 본문 영역 + 하단 결론 pill.
---
## MCP 의존성 (통합 시 추가 — 2026-07-02)
이 에이전트는 **Figma Desktop Dev Mode MCP 서버**가 필요하다.
1. Figma Desktop 앱 실행 → Dev Mode MCP 서버 활성화 (기본: `http://127.0.0.1:3845/mcp`)
2. 프로젝트 루트 또는 이 디렉토리의 `mcp.sample.json`을 `.mcp.json`으로 복사:
```json
{
"mcpServers": {
"figma-desktop": {
"type": "http",
"url": "http://127.0.0.1:3845/mcp"
}
}
}
```
3. Python 도구: Selenium(headless Chrome), Pillow — 렌더 검증용 (선택)
> 참고: `Front_test_v515/figma_to_html_agent/`(gitignore)에 동일 내용의 작업 사본이 존재한다.
> 2026-07-02 점검 결과 본 디렉토리는 이미 main에 추적·push되어 있음을 확인 (issue #1).
-8
View File
@@ -1,8 +0,0 @@
{
"mcpServers": {
"figma-desktop": {
"type": "http",
"url": "http://127.0.0.1:3845/mcp"
}
}
}
File diff suppressed because it is too large Load Diff
+4 -132
View File
@@ -1009,9 +1009,6 @@ def build_context_pack(n, title, body, sid, agent, rnd, start_cnt, compact=None)
# 검증 실패 보고서 (rewind 시 이전 실패 맥락 전달). # 검증 실패 보고서 (rewind 시 이전 실패 맥락 전달).
# 2026-05-16 — issue state 의 failure_report_path 를 source-of-truth 로. # 2026-05-16 — issue state 의 failure_report_path 를 source-of-truth 로.
# 모든 stage NO (test-verify/final-close 뿐 아니라 code-edit 등) 의 from_stage 캐치. # 모든 stage NO (test-verify/final-close 뿐 아니라 code-edit 등) 의 from_stage 캐치.
# P7 (2026-05-26) — banned approaches injection (Codex CLI helper consensus).
# failure_report 본문에서 known anti-pattern keyword 추출 → BANNED_APPROACHES block 생성
# → 다음 round prompt 에 strong-marker 로 inject. 동일 방식 재제안 방지 (#84 round loop).
failure_ctx = "" failure_ctx = ""
ist_fc = get_issue_state(n) ist_fc = get_issue_state(n)
fr_path_str = ist_fc.get("failure_report_path") fr_path_str = ist_fc.get("failure_report_path")
@@ -1019,42 +1016,9 @@ def build_context_pack(n, title, body, sid, agent, rnd, start_cnt, compact=None)
fail_path = Path(fr_path_str) fail_path = Path(fr_path_str)
if fail_path.exists(): if fail_path.exists():
from_sid = ist_fc.get("failure_from_stage", "?") from_sid = ist_fc.get("failure_from_stage", "?")
fail_body = fail_path.read_text(encoding='utf-8')
# P7 — extract banned approach signals (deterministic keyword scan).
# 각 entry: (regex, label, why). escape_hatch 는 future patch 의 JSON 구조 에서 형식화.
# 현재 단계 = prompt-injection 만 (Codex 단계화 안의 "즉시 patch" layer).
banned_signals = [
(r"tests:\s*\[\s*\]",
"tests: [] empty test list per implementation unit",
"Orchestrator strict rule — 1 unit = impl + test inseparable. NOT allowed to defer tests to later units."),
(r"@testing-library|jsdom|render\s*\(|screen\.",
"DOM mount-based vitest (render() / screen / @testing-library)",
"Front/package.json devDependencies has no jsdom / @testing-library/react. Mount-based tests cannot run."),
(r"toast\.error\s*\(\s*formatAiRepairHumanReviewMessage",
"Home.tsx formatAiRepairHumanReviewMessage toast.error removal",
"Post-#92 commit 896f273 rewrote the formatter to operational-only channel. Removing toast call = operational alert regression."),
(r"git\s+add\s+(-A|--all|\.)\b",
"git add -A / git add . / git add --all",
"Untracked artifact pollution risk. Stage 5 must add only files in unit's declared `files:` list explicitly."),
]
hits = []
for pat, label, why in banned_signals:
if re.search(pat, fail_body, re.IGNORECASE):
hits.append((label, why))
banned_block = ""
if hits:
banned_block = "\n=== BANNED APPROACHES (previously rejected — DO NOT REUSE) ===\n"
for i, (label, why) in enumerate(hits, 1):
banned_block += f"{i}. {label}\n reason: {why}\n"
banned_block += (
"BINDING: re-proposing any banned approach above = automatic FINAL_CONSENSUS: NO. "
"If environment/preconditions changed (e.g., new package install), state the EVIDENCE "
"of the change BEFORE re-proposal.\n"
)
failure_ctx = ( failure_ctx = (
f"\n\n=== REWIND: FAILURE REPORT (from {from_sid}) ===\n" f"\n\n=== REWIND: FAILURE REPORT (from {from_sid}) ===\n"
f"{fail_body[:1500]}\n" f"{fail_path.read_text(encoding='utf-8')[:1500]}\n"
f"{banned_block}"
f"Fix the issues above before re-attempting.\n" f"Fix the issues above before re-attempting.\n"
) )
@@ -1483,22 +1447,10 @@ def run_stage(n, title, body, sid):
return (False, "unit with `tests: []` (forbidden — implementation + tests = same unit)") return (False, "unit with `tests: []` (forbidden — implementation + tests = same unit)")
return (True, "") return (True, "")
ok, reason = _iu_valid(last) ok, reason = _iu_valid(last)
# P7 (2026-05-26) — fallback skip when last YES body itself is invalid. if not ok:
# 이전: last invalid → comments[start_cnt:] 에서 valid block 찾아 구제 → # current stage 의 comments 만 검색 (start_cnt 이후)
# orchestrator 자기 supplement comment 의 Example block 이 valid 로 통과 (#84 round 5 슬립).
# 변경: last 가 진짜 invalid 면 fallback 자체 skip. 단 last 의 _iu_valid 실패가
# "block missing" 인 경우만 (Codex 가 YAML block 을 안 echo 한 경우) 이전 round 의
# Claude plan 으로 fallback — 단 orchestrator-authored supplement 는 제외.
if not ok and reason == "block missing":
for c in comments[start_cnt:]: for c in comments[start_cnt:]:
body = c.get("body", "") or "" ok2, _ = _iu_valid(c.get("body", ""))
# exclude orchestrator-authored supplement comments (own example block trap)
ls = body.lstrip()
if ls.startswith("⚠️ **[Orchestrator]**") or \
ls.startswith("📌 **[오케스트레이터]**") or \
ls.startswith("️ **[Orchestrator]**"):
continue
ok2, _ = _iu_valid(body)
if ok2: if ok2:
ok = True; break ok = True; break
if not ok: if not ok:
@@ -1596,45 +1548,6 @@ def run_stage(n, title, body, sid):
except: pass except: pass
# Never `continue` — checker is informational only (Stage 1 guardrail). # Never `continue` — checker is informational only (Stage 1 guardrail).
# P7 (2026-05-26) — final-close YES casual self-contradiction inline guard.
# parse_consensus 는 건드리지 않음 (다른 caller 영향 차단). YES 처리 block 안에서
# sid == "final-close" 인 경우만 casual contradiction 검사.
#
# 설계 의도 분기 (Patch B 와 분담) :
# - explicit `disposition: KEEP_OPEN_*` line 이 있으면 = 의도된 keep-open
# → 이 guard 통과 → Patch B (close PATCH skip) 가 처리.
# - explicit disposition line 없이 "NO close signal" 또는 "DO NOT CLOSE"
# casual 표현 만 있으면 = self-contradiction → supplement + continue.
#
# cf. #83 IMP-83 case = YES + explicit `disposition: KEEP_OPEN_AS_UMBRELLA_ANCHOR`
# → 통과 (Patch B 가 close skip).
if sid == "final-close":
has_explicit_disposition = bool(re.search(
r"^\s*disposition\s*:\s*KEEP_OPEN",
last, re.IGNORECASE | re.MULTILINE))
if not has_explicit_disposition:
casual_contradiction_patterns = [
(r"NO\s+close\s+signal", "NO close signal"),
(r"DO\s*NOT\s*CLOSE", "DO NOT CLOSE"),
]
hit = None
for p, label in casual_contradiction_patterns:
if re.search(p, last, re.IGNORECASE):
hit = label; break
if hit:
log(f"⚠️ Stage 6 YES casual self-contradiction ({hit}) — supplement requested")
try: gitea(f"issues/{n}/comments", "POST", {"body":
f"⚠️ **[Orchestrator]** Stage 6 FINAL_CONSENSUS: YES rejected — casual self-contradiction.\n\n"
f"YES marker 와 동시에 본문에 `{hit}` 등장 — 명시적 `disposition:` line 없음.\n\n"
"Resolution:\n"
" (a) If close intended → remove `{hit}` and re-state YES with close evidence.\n"
" (b) If keep-open intended → add explicit line:\n"
" `disposition: KEEP_OPEN_AS_UMBRELLA_ANCHOR` (or similar)\n"
" then orchestrator will honor keep-open at close PATCH (Patch B).\n"
" (c) Or switch to `FINAL_CONSENSUS: NO` with appropriate rewind_target."})
except: pass
continue
log(f"{si['label']} — YES (evidence verified)") log(f"{si['label']} — YES (evidence verified)")
# stage 완료 = unit counter + remaining tracker 모두 reset # stage 완료 = unit counter + remaining tracker 모두 reset
update_issue_state(n, continue_same_count=0, last_remaining_units=None) update_issue_state(n, continue_same_count=0, last_remaining_units=None)
@@ -1852,47 +1765,6 @@ def run_issue(n, until=None):
continue_same_count=0, last_remaining_units=None) continue_same_count=0, last_remaining_units=None)
if s["id"] == "final-close": if s["id"] == "final-close":
# P7 (2026-05-26) — KEEP_OPEN guard. Stage 6 exit body / last YES body 가 명시적
# keep-open / no-close 신호 내면 close PATCH skip. body-level lock 이 있는 umbrella
# anchor (#83 IMP-83 등) 보호 — Stage 6 성공 = "올바른 disposition 확정" 이며,
# 그 disposition 이 KEEP_OPEN 일 수 있음.
keep_open_patterns = [
r"KEEP_OPEN_AS_UMBRELLA_ANCHOR",
r"DO\s*NOT\s*CLOSE",
r"disposition\s*:\s*KEEP_OPEN",
r"^\s*action\s*:\s*NONE",
r"^\s*state_after\s*:\s*open",
r"NO\s+close\s+signal",
]
keep_open = False
# P7a (2026-05-26) — fetch comments fresh; `comments` is loop-local in stage block
# and not in scope at run_issue post-stage update. NameError fix.
try:
_cs = get_comments(n)
last_body = _cs[-1].get("body", "") if _cs else ""
except: last_body = ""
for p in keep_open_patterns:
if re.search(p, last_body, re.IGNORECASE | re.MULTILINE):
keep_open = True; break
if not keep_open:
exit_path = _erp(n, "final-close")
if exit_path.exists():
try:
exit_body = exit_path.read_text(encoding="utf-8", errors="ignore")
for p in keep_open_patterns:
if re.search(p, exit_body, re.IGNORECASE | re.MULTILINE):
keep_open = True; break
except: pass
if keep_open:
log(f"Stage 6 KEEP_OPEN signal — issue #{n} NOT closed (umbrella/governance anchor honored)")
try: gitea(f"issues/{n}/comments", "POST", {"body":
"️ **[Orchestrator]** Stage 6 KEEP_OPEN signal honored — issue not closed.\n\n"
"Detected one of: `KEEP_OPEN_AS_UMBRELLA_ANCHOR`, `DO NOT CLOSE`, "
"`disposition: KEEP_OPEN`, `action: NONE`, `state_after: open`, `NO close signal`.\n\n"
"Orchestrator abstains from `PATCH state=closed` per user-decision-first lock. "
"Final-close stage marked done; issue state preserved as `open`."})
except: pass
else:
try: gitea(f"issues/{n}", "PATCH", {"state": "closed"}); log("Closed") try: gitea(f"issues/{n}", "PATCH", {"state": "closed"}); log("Closed")
except: pass except: pass
-2
View File
@@ -22,7 +22,6 @@ dependencies = [
dev = [ dev = [
"pytest>=8.0", "pytest>=8.0",
"pytest-asyncio>=0.24", "pytest-asyncio>=0.24",
"pytest-json-report>=1.5",
"ruff>=0.8", "ruff>=0.8",
] ]
@@ -34,5 +33,4 @@ target-version = "py310"
asyncio_mode = "auto" asyncio_mode = "auto"
markers = [ markers = [
"integration: end-to-end pipeline integration tests (heavy; invoke Selenium)", "integration: end-to-end pipeline integration tests (heavy; invoke Selenium)",
"sweep: opt-in heavyweight sweep tests (IMP-43 u7b: 3 layouts × 3 mdx × frame-pin coverage). Invoke explicitly via `pytest -m sweep`; default CI must use `-m 'not sweep'`.",
] ]
@@ -1,120 +0,0 @@
---
title: 건설산업 DX의 올바른 이해
sidebar:
order: 00
---
* **용어의 혼용**
* 건설산업의 디지털 전환 논의에서 DX(Digital Transformation)와 BIM(Building Information Modeling)이 개념적으로 명확히 정립되지 않은채 혼용되어 사용되고 있음
* 이로인해 BIM기술의 도입을 DX의 완성으로 오인하거나, DX를 BIM 기술 도입 수준으로 한정하는 인식 확산
<details>
<summary style={{cursor: 'pointer', fontWeight: 'bold', color: '#555'}}>혼용 대표 사례</summary>
<div style={{marginTop: '10px', paddingLeft: '15px', borderLeft: '3px solid #ddd', fontSize: '0.9rem', color: '#666'}}>
* **[스마트 건설 활성화 방안(2022.07)]**
* 추진과제 : 건설산업 디지털화
* 실행과제 : BIM 전면 도입, BIM 전문인력 양성
* **[제7차 건설기술진흥 기본계획(2023.12)]**
* 추진방향 : 디지털 전환을 통한 스마트 건설 확산
* 추진과제 : BIM 도입으로 건설산업 디지털화
</div>
</details>
* 건설산업의 DX를 올바르게 이해하기 위해 각 용어의 정의, 역할, 상호관계에 대한 체계적 정립 필요
<br/>
---
## 1. 용어 정의
<br/>
* **건설산업**
* 다양한 시설물을 각 산업마다의 광범위한 기술을 통합 및 융합하여 만들어내는 종합산업
* 목적 시설물의 품질 욕구를 충족시키면서 최단기간내에 최소 비용으로 편리하고 안전하며 우수한 성능의 시설물 완성을 목표로 함
<br/>
* **BIM(Building Information Modeling) : 디지털 전환을 위한 핵심 기술**
* 시설물의 생애주기동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구
* 건설 정보와 절차를 표준화된 방식으로 연계하고 디지털 협업이 가능하도록 하는 핵심 인프라 기술
<div style={{
fontSize: '0.8rem',
color: '#999',
marginTop: '5px',
lineHeight: '1.4',
paddingLeft: '0px' }}>
*건설산업 BIM 기본지침, 국토교통부, 2020*
</div>
<br/>
* **DX(Digital Transformation) : 산업 패러다임의 변화**
* 디지털 기술을 기반으로 산업 전반의 업무방식과 가치 창출 구조를 전환하는 과정 및 결과
* 단순한 기술 도입이 아닌, 고객 가치와 의사결정 방식의 근본적인 변화로 산업의 새로운 방향을 정립하는 것을 의미함
<div style={{
fontSize: '0.8rem',
color: '#999',
marginTop: '5px',
lineHeight: '1.4',
paddingLeft: '0px' }}>
*Digital Transformation, IBM Institute for Business Value, 2011 / What is Digital Transformation?, Agile Elephant, 2015*
</div>
---
<br/>
## 2. 용어간 상호관계
* DX는 BIM과 같은 디지털기술을 기반으로 산업 전반의 프로세스를 혁신하는 상위개념
* 건설산업의 DX는 GIS(공간정보), BIM, 디지털 트윈(가상환경)의 기술융합을 통해서만 실현 또는 구현 가능
* GIS의 역할 : 지리적 데이터를 공간 분석하여 시각적으로 표현, 위치기반 정보 제공
* BIM의 역할 : 형상정보와 내용정보가 포함된 3D모델로, 건설 정보 기반의 Process와 Product를 제공
![DX와 핵심기술간 상호관계](/assets/images/DX1.png)
<div style={{
fontSize: '0.8rem',
color: '#999',
marginTop: '5px',
lineHeight: '1.4',
paddingLeft: '0px' }}>
*[그림 1] DX와 핵심기술간 상호관계*
</div>
<br/>
<br/>
<details>
<summary style={{cursor: 'pointer', fontWeight: 'bold', color: '#555'}}>DX와 BIM의 구분</summary>
<div style={{marginTop: '10px', paddingLeft: '15px', borderLeft: '3px solid #ddd', fontSize: '0.9rem', color: '#666'}}>
| DX | 구분 | BIM |
| :--- | :---: | ---: |
| **BIM &lt;&lt; DX**<br/>(Engineering + Management 통합) | **범위** | **Only 3D**<br/>(형상 구현 중심) |
| **제작 및 운영**(상용 + 전용 40~80개)<br/>[Rhino, Sketchup, Blender..] + [EG-BIM 등] | **S/W** | **모델 제작용 상용 SW**<br/>[Revit, Civil 3D, Navisworks, Autocad] |
| **근본적 문제의식을 통한 개선** | **프로세스** | **기존 2D 설계 방식 유지** |
| **공학 정보 및 콘텐츠 연계에 집중**<br/>**도면, 수량, 시공계획 등 일식** | **성과품** | **3D 모델 중심**<br/>**기존 성과품 유지** |
| **설계/시공 생산성 혁신**(개념의 재정립) | **활용** | **3D 모델에 의한 일반적 이해 향상** |
| **전 생애주기 활용 시스템** | **확장성** | **(설계/시공/운영) 분야별 단절** |
| **구체화(복잡) - 적극적/구체적 실현 방안** | **수행 개념** | **단순화(오류) - 수동적/집단적 동질화** |
| **적극적, 주체적인 기술 접목/융합** | **CIVIL + IT** | **소극적, 상용 기술에 의존** |
| **자체 수행 능력 - 지속가능성 확보** | **주체** | **S/W 제작사 판매 정책에 의존** |
| **차별화 및 경쟁력 확보, 해외 진출** | **발주처** | **평준화, 국내 중심** |
| **IT + CIVIL ENG 220명 운영 + 기술 개발** | **설계사** | **소규모 BIM팀 운영 + 단순교육에 집중** |
| **분야 확장 모델 및 시스템** | **시공사** | **국내 토목 소극적/해외 토목증가** |
</div>
</details>
<br/>
---
:::note[핵심 요약]
* BIM은 건설산업의 디지털전환(DX)을 수행하는 과정에서 **가장 기초가 되는 일부분**이다
:::
@@ -1,42 +0,0 @@
---
title: DX의 시행 목표 및 기대효과
sidebar:
order: 1
---
import DxEffect from '../../../../components/dx.astro';
## 1. DX의 궁극적 목표
- **안전과 품질**
- 시설물의 요구 성능을 설계-시공-운영 전 과정에서 **디지털로 검증**하여 **안전성 확보**
- Copy & Paste로 하향 평준화된 성과물의 **하자 최소화**로 **고품질 성과물 제공**
<br/>
- **생산성 향상**
- Analogue 기반 업무를 Digital 기반 프로세스로 전환하여 **업무 속도·정확성·일관성 향상**
- 건설 비용 및 유지관리비 절감, 건설 기간 단축, 인력투입 최소화를 통해 **부가가치 제고**
<br/>
- **소통과 신뢰**
- 성과품과 Solution을 통한 협업 강화로 **의사소통 효율 및 운영·유지관리**의 **편리성 증진**
- 3D 모델 및 데이터 기반 검증을 통한 **오류 최소화 및 Claim 예방**으로 **신뢰성 확보**
![DX의 궁극적 목표](/assets/images/궁극적목표.png)
<br/>
## 2. DX 기반 Process 혁신에 따른 주체별 기대효과
<br/>
### 2.1 업무 수행 과정(Process)의 변화
- **생산 방식**: 수작업 의존의 반복 업무에서 벗어나, **SW를 활용한 체계화된 방식**으로 전환
- **인지·검토**: 2D 도면 해석 중심에서 **3D 모델 기반의 직관적 인지·검토 체계**로 전환
- **협업 구조**: 개별 문서 중심 협업에서 **데이터 통합 기반의 정보 공유·관리 협업 환경**으로 전환
- **검증·대응**: 사후 대응 중심의 문제 처리에서 **사전 검증 중심의 예방적 업무 방식**으로 전환
<br/>
### 2.2 DX 시행 주체별 기대효과
<DxEffect />
<br/>
<br/>
:::note[핵심 요약]
* 고품질의 성과품, 비용 절감, 시간 단축, 의사소통에 도움이 안 되면 DX가 아니다.
:::
<br/>
@@ -1,262 +0,0 @@
---
title: DX 지연 요인
sidebar:
order: 03
---
## 1. DX에 대한 인식
<br/>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '20px', justifycontent: 'center' }}>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
position: 'relative',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>기술 및 소프트웨어 이해도</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #1a73e8',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"무슨 말인지 잘 모르겠다, 어디까지 어떻게 해야 하는지 모르겠다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>기본지침, 시행지침 등 새롭게 알아야 할 게 너무 많다.</li>
<li>3D 모델과 별 차이점을 모르겠다. S/W마다 사용법이 다르다.</li>
<li>필요한 것은 쉽고 간단한 건데, 왜 이렇게 복잡하게 만들까?</li>
</ul>
</div>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>효과와 효율성</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #d93025',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"2D 설계 후 전환설계를 수행하는데 효과는 모르겠고, 효율은 낮다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>성과품 작성은 기존과 같게 하고, 추가 업무만 발생해 효율이 낮다.</li>
<li>BIM으로 인해 가중되는 업무 대비 효과가 거의 없어 보인다.</li>
<li>결과적으로 큰 차이를 못 느끼겠고, 이런 노력이 정말 가치 있는 일이야?</li>
</ul>
</div>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>인력 및 교육</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #f9ab00',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"수행 인력이 부족하고, 기존 직원들은 어떻게 교육해야 하나"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>교육시간 손실로 일손이 더 필요해지고, 적응하는데도 시간이 걸린다.</li>
<li>신입사원은 없고 BIM 수행을 할 수 있는 기술자가 부족하다.</li>
<li>여러 회사의 S/W별 사용법이 달라 새로운 S/W에 적용에 시간이 필요하다.</li>
</ul>
</div>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>경제적 부담</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #34a853',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"S/W 구독료만 크게 발생되고, 비용 보전은 안 된다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>사용해야 할 S/W의 종류가 너무 많고 복잡한데 모두 필요한가?</li>
<li>모든 Project에 적용되는 것도 아닌데, 다수의 S/W 구독료를 내야 한다.</li>
<li>디지털전환 하기 위해 이 비용을 쓰는게 정말 경제적인 이득이 있는 거야?</li>
</ul>
</div>
<div style={{
width: '100%',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default',
marginTop: '10px'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>실무 및 적용성</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #8e24aa',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"실무적 사용에 의한 효율성 증진보다는 홍보, PQ용으로 사용한다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>구체적 적용에 의한 비용, 시간, 품질 등의 효과 사례가 없다.</li>
<li>지형, 선형, 도로, 교량 Model을 만드는 S/W가 모두 달라 적용이 어렵다.</li>
<li>속성정보를 반영하지 않은 형상 위주의 3D 모델 제작에만 초점이 맞춰져 있다.</li>
</ul>
</div>
</div>
<br/>
---
<br/>
## 2. DX 추진의 실태
<br/>
### 2.1 정책 및 발주 체계
- **실질적 기술 경쟁을 저해하는 정책 집행**
- 모든 설계사가 수행 능력을 갖추었다는 전제하에 정책 시행
- 수행 능력이 없는 업체 선정 후 성과품의 수준을 낮추어 시행
<br/>
- **적용 효과가 있는 사례도 없이 방침부터 도입**
- DX/BIM 적용에 따른 실무적 이득이 있다고 판단된 사례 부족
- BIM 지침/방침 등을 시행 경험과 효과 검증도 없이 남발
<br/>
- **엔지니어링 S/W에 대한 개념 부재**
- 다양한 엔지니어링 S/W의 특성에 대한 깊은 이해 없이 범용 S/W 선택
- 대형 Global S/W 회사에 과도한 의존과 이에 예속되는 방침 남발로 전용 S/W 소멸
<br/>
- **기술투자(R&D) 없는 성과 창출 기대**
- 단순 BIM S/W만 구입하면 될 것이라는 안일한 생각
- 실질적 기술 개발 투자 노력 없이 남들이 하는 대로 하면 된다는 착각
<br/>
<details>
<summary style={{cursor: 'pointer', fontWeight: 'bold', color: '#555'}}>발주처 반응</summary>
<img src="/assets/images/발주처반응.png" />
</details>
### 2.2 조직 및 수행 역량
- **공학적 개념 정립 부재**
- DX와 BIM의 차이점을 명확히 구분하지 못하고 접근 방식과 기술적 도구 사이의 혼란만 가중
- 단순히 기술적 도구의 사용에 초점을 맞추느라 3D모델 제작 S/W에 과도하게 의존
<br/>
- **‘본업 기술력 확보’ 우선의 개념 부재**
- 고도의 전문지식과 현장 경험이 축적된 Manual의 중요성과 필요성에 대한 이해 부족
- 국가·발주처의 지침·방침에만 의존한 업체의 기술력
<br/>
- **DX/BIM의 근본 취지와 목표의 이해 부족**
- DX에 의한 과정의 혁신과 결과물의 변화에 대한 고민 부재
- 기술자가 직접 3D모델을 만들고 수정하며 설계를 수행하지 않고, 별도로 외주 처리하여 본질 회피
<br/>
- **과거의 타성에 머무르고 있는 기술자 집단**
- 설계/감리/시공 임직원들의 Digital 무지와 전략적 무지
- 교육과 학습을 통한 인재 양성보다 당장 실무 활용이 가능한 타사 인력 빼오기에 집중
<br/>
<details>
<summary style={{cursor: 'pointer', fontWeight: 'bold', color: '#555'}}>설계·시공업계 반응</summary>
<img src="/assets/images/설계시공업계반응.png" />
</details>
<br/>
---
:::note[핵심 요약]
* 검증 없는 정책의 일방적 추진과 조직의 회피, 이해 부족은 DX 지연을 반복시키고 있다.
:::
@@ -1,48 +0,0 @@
---
title: 설계 방식의 왜곡
sidebar:
order: 0
---
## 1. 설계의 자동화
- **설계의 개념**
- **개념설계**: 건설사업의 타당성 조사, 환경영향평가 등을 수행하는 목적물과 방향성을 수립하는 단계
- **실시설계**: 공학적 지식을 바탕으로 건설에 필요한 목적물의 형상과 규격, 공사비를 결정하는 단계
- **시공상세설계**: 시공에 필요한 정보를 반영하여 목적물과 공정을 단계별로 구체화하는 단계
<br/>
- **설계의 특성**
- 계획 단계부터 엔지니어의 경험과 공학적 판단에 기반한 의사결정이 필수적인 과정
- 사업마다 고유한 환경 및 지역 조건이 다르므로 매번 일회성 작업으로 수행
- 평면 및 종단 등 선형의 일부 변경만으로도 시설물 전체의 구조적 해석이 변화
<br/>
- **'설계 자동화'라는 용어의 오용과 모순**
- **기능적 대체의 오류**: '설계 자동화' S/W는 설계 전체 프로세스가 아닌, 단순 반복적인 제도(Drafting)나 수량 산출 등 단순 기능만을 대체하는 수준
- **인식의 왜곡**: '자동화'라는 용어의 남용으로 엔지니어의 업무가 단순 반복 작업인 것으로 저평가되고 있으며, 가장 중요한 전문 지식과 경험 기반의 의사결정 단계가 경시
- **완전 자동화의 불가능**: 설계는 각 단계가 유기적으로 연결된 지식 집약적 활동이므로, 엔지니어의 판단을 배제한 전체 프로세스의 완전 자동화는 불가능
<br/>
---
<br/>
## 2. S/W 중심 설계 방식
### 1 기능·구조적 한계
- 시간 단축과 오류 감소 등 '도구적 효율성'에만 집중하여, 설계 품질 향상이나 고부가가치를 창출하는 핵심 기술로 발전하지 못함
- 대부분의 S/W가 2D 기반의 설계에 머물러 있어, 엔지니어의 입체적인 아이디어와 복잡한 현장 <br/>특수성을 충분히 표현하지 못함
### 2 기술 역량의 왜곡
- S/W 개발사: 기술 혁신보다 기존 방식의 반복과 성과물 생산에만 치중하며, 수익성 중심의 개발 지속
- 기업 및 기술자: 기존 방식의 한계를 분석하고 새로운 부가가치를 찾기보다는, 단순 S/W 활용 능력을 엔지니어의 핵심 역량으로 오인
- 종속성 심화: 시간이 흐를수록 기술자는 S/W에 예속된 기능인으로 전락하고, 산업 전체는 저부가가치의 굴레를 벗어나지 못함
<br/>
---
:::note[핵심 요약]
* 잘못된 자동화 인식과 도구 중심의 설계는 기술자를 S/W에 의존하는 기능인으로 전락시키며, <br/>설계의 본질과 경쟁력을 동시에 훼손한다.
:::
@@ -1,48 +0,0 @@
---
title: 설계 방식의 왜곡
sidebar:
order: 0
---
## 1. 설계의 자동화
- **설계의 개념**
- **개념설계**: 건설사업의 타당성 조사, 환경영향평가 등을 수행하는 목적물과 방향성을 수립하는 단계
- **실시설계**: 공학적 지식을 바탕으로 건설에 필요한 목적물의 형상과 규격, 공사비를 결정하는 단계
- **시공상세설계**: 시공에 필요한 정보를 반영하여 목적물과 공정을 단계별로 구체화하는 단계
<br/>
- **설계의 특성**
- 계획 단계부터 엔지니어의 경험과 공학적 판단에 기반한 의사결정이 필수적인 과정
- 사업마다 고유한 환경 및 지역 조건이 다르므로 매번 일회성 작업으로 수행
- 평면 및 종단 등 선형의 일부 변경만으로도 시설물 전체의 구조적 해석이 변화
<br/>
- **'설계 자동화'라는 용어의 오용과 모순**
- **기능적 대체의 오류**: '설계 자동화' S/W는 설계 전체 프로세스가 아닌, 단순 반복적인 제도(Drafting)나 수량 산출 등 단순 기능만을 대체하는 수준
- **인식의 왜곡**: '자동화'라는 용어의 남용으로 엔지니어의 업무가 단순 반복 작업인 것으로 저평가되고 있으며, 가장 중요한 전문 지식과 경험 기반의 의사결정 단계가 경시
- **완전 자동화의 불가능**: 설계는 각 단계가 유기적으로 연결된 지식 집약적 활동이므로, 엔지니어의 판단을 배제한 전체 프로세스의 완전 자동화는 불가능
<br/>
---
<br/>
## 2. S/W 중심 설계 방식
### 1 기능·구조적 한계
- 시간 단축과 오류 감소 등 '도구적 효율성'에만 집중하여, 설계 품질 향상이나 고부가가치를 창출하는 핵심 기술로 발전하지 못함
- 대부분의 S/W가 2D 기반의 설계에 머물러 있어, 엔지니어의 입체적인 아이디어와 복잡한 현장 <br/>특수성을 충분히 표현하지 못함
### 2 기술 역량의 왜곡
- S/W 개발사: 기술 혁신보다 기존 방식의 반복과 성과물 생산에만 치중하며, 수익성 중심의 개발 지속
- 기업 및 기술자: 기존 방식의 한계를 분석하고 새로운 부가가치를 찾기보다는, 단순 S/W 활용 능력을 엔지니어의 핵심 역량으로 오인
- 종속성 심화: 시간이 흐를수록 기술자는 S/W에 예속된 기능인으로 전락하고, 산업 전체는 저부가가치의 굴레를 벗어나지 못함
<br/>
---
:::note[핵심 요약]
* 잘못된 자동화 인식과 도구 중심의 설계는 기술자를 S/W에 의존하는 기능인으로 전락시키며, <br/>설계의 본질과 경쟁력을 동시에 훼손한다.
:::
-11
View File
@@ -2,17 +2,6 @@
title: DX 지연 요인 title: DX 지연 요인
sidebar: sidebar:
order: 03 order: 03
slide_overrides:
css: |
.slide-body {
grid-template-rows: 0.38fr 0.60fr !important;
gap: 1.5% !important;
}
.f29b__cell .text-line + .text-line { margin-top: 1px !important; }
.f29b__cell:nth-child(n+3) {
padding-top: 3px !important;
margin-top: 2px !important;
}
--- ---
## 1. DX에 대한 인식 ## 1. DX에 대한 인식
-48
View File
@@ -1,48 +0,0 @@
---
title: 설계 방식의 왜곡
sidebar:
order: 0
---
## 1. 설계의 자동화
- **설계의 개념**
- **개념설계**: 건설사업의 타당성 조사, 환경영향평가 등을 수행하는 목적물과 방향성을 수립하는 단계
- **실시설계**: 공학적 지식을 바탕으로 건설에 필요한 목적물의 형상과 규격, 공사비를 결정하는 단계
- **시공상세설계**: 시공에 필요한 정보를 반영하여 목적물과 공정을 단계별로 구체화하는 단계
<br/>
- **설계의 특성**
- 계획 단계부터 엔지니어의 경험과 공학적 판단에 기반한 의사결정이 필수적인 과정
- 사업마다 고유한 환경 및 지역 조건이 다르므로 매번 일회성 작업으로 수행
- 평면 및 종단 등 선형의 일부 변경만으로도 시설물 전체의 구조적 해석이 변화
<br/>
- **'설계 자동화'라는 용어의 오용과 모순**
- **기능적 대체의 오류**: '설계 자동화' S/W는 설계 전체 프로세스가 아닌, 단순 반복적인 제도(Drafting)나 수량 산출 등 단순 기능만을 대체하는 수준
- **인식의 왜곡**: '자동화'라는 용어의 남용으로 엔지니어의 업무가 단순 반복 작업인 것으로 저평가되고 있으며, 가장 중요한 전문 지식과 경험 기반의 의사결정 단계가 경시
- **완전 자동화의 불가능**: 설계는 각 단계가 유기적으로 연결된 지식 집약적 활동이므로, 엔지니어의 판단을 배제한 전체 프로세스의 완전 자동화는 불가능
<br/>
---
<br/>
## 2. S/W 중심 설계 방식
### 1 기능·구조적 한계
- 시간 단축과 오류 감소 등 '도구적 효율성'에만 집중하여, 설계 품질 향상이나 고부가가치를 창출하는 핵심 기술로 발전하지 못함
- 대부분의 S/W가 2D 기반의 설계에 머물러 있어, 엔지니어의 입체적인 아이디어와 복잡한 현장 <br/>특수성을 충분히 표현하지 못함
### 2 기술 역량의 왜곡
- S/W 개발사: 기술 혁신보다 기존 방식의 반복과 성과물 생산에만 치중하며, 수익성 중심의 개발 지속
- 기업 및 기술자: 기존 방식의 한계를 분석하고 새로운 부가가치를 찾기보다는, 단순 S/W 활용 능력을 엔지니어의 핵심 역량으로 오인
- 종속성 심화: 시간이 흐를수록 기술자는 S/W에 예속된 기능인으로 전락하고, 산업 전체는 저부가가치의 굴레를 벗어나지 못함
<br/>
---
:::note[핵심 요약]
* 잘못된 자동화 인식과 도구 중심의 설계는 기술자를 S/W에 의존하는 기능인으로 전락시키며, <br/>설계의 본질과 경쟁력을 동시에 훼손한다.
:::
@@ -1,120 +0,0 @@
---
title: 건설산업 DX의 올바른 이해
sidebar:
order: 00
---
* **용어의 혼용**
* 건설산업의 디지털 전환 논의에서 DX(Digital Transformation)와 BIM(Building Information Modeling)이 개념적으로 명확히 정립되지 않은채 혼용되어 사용되고 있음
* 이로인해 BIM기술의 도입을 DX의 완성으로 오인하거나, DX를 BIM 기술 도입 수준으로 한정하는 인식 확산
<details>
<summary style={{cursor: 'pointer', fontWeight: 'bold', color: '#555'}}>혼용 대표 사례</summary>
<div style={{marginTop: '10px', paddingLeft: '15px', borderLeft: '3px solid #ddd', fontSize: '0.9rem', color: '#666'}}>
* **[스마트 건설 활성화 방안(2022.07)]**
* 추진과제 : 건설산업 디지털화
* 실행과제 : BIM 전면 도입, BIM 전문인력 양성
* **[제7차 건설기술진흥 기본계획(2023.12)]**
* 추진방향 : 디지털 전환을 통한 스마트 건설 확산
* 추진과제 : BIM 도입으로 건설산업 디지털화
</div>
</details>
* 건설산업의 DX를 올바르게 이해하기 위해 각 용어의 정의, 역할, 상호관계에 대한 체계적 정립 필요
<br/>
---
## 1. 용어 정의
<br/>
* **건설산업**
* 다양한 시설물을 각 산업마다의 광범위한 기술을 통합 및 융합하여 만들어내는 종합산업
* 목적 시설물의 품질 욕구를 충족시키면서 최단기간내에 최소 비용으로 편리하고 안전하며 우수한 성능의 시설물 완성을 목표로 함
<br/>
* **BIM(Building Information Modeling) : 디지털 전환을 위한 핵심 기술**
* 시설물의 생애주기동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구
* 건설 정보와 절차를 표준화된 방식으로 연계하고 디지털 협업이 가능하도록 하는 핵심 인프라 기술
<div style={{
fontSize: '0.8rem',
color: '#999',
marginTop: '5px',
lineHeight: '1.4',
paddingLeft: '0px' }}>
*건설산업 BIM 기본지침, 국토교통부, 2020*
</div>
<br/>
* **DX(Digital Transformation) : 산업 패러다임의 변화**
* 디지털 기술을 기반으로 산업 전반의 업무방식과 가치 창출 구조를 전환하는 과정 및 결과
* 단순한 기술 도입이 아닌, 고객 가치와 의사결정 방식의 근본적인 변화로 산업의 새로운 방향을 정립하는 것을 의미함
<div style={{
fontSize: '0.8rem',
color: '#999',
marginTop: '5px',
lineHeight: '1.4',
paddingLeft: '0px' }}>
*Digital Transformation, IBM Institute for Business Value, 2011 / What is Digital Transformation?, Agile Elephant, 2015*
</div>
---
<br/>
## 2. 용어간 상호관계
* DX는 BIM과 같은 디지털기술을 기반으로 산업 전반의 프로세스를 혁신하는 상위개념
* 건설산업의 DX는 GIS(공간정보), BIM, 디지털 트윈(가상환경)의 기술융합을 통해서만 실현 또는 구현 가능
* GIS의 역할 : 지리적 데이터를 공간 분석하여 시각적으로 표현, 위치기반 정보 제공
* BIM의 역할 : 형상정보와 내용정보가 포함된 3D모델로, 건설 정보 기반의 Process와 Product를 제공
![DX와 핵심기술간 상호관계](/assets/images/DX1.png)
<div style={{
fontSize: '0.8rem',
color: '#999',
marginTop: '5px',
lineHeight: '1.4',
paddingLeft: '0px' }}>
*[그림 1] DX와 핵심기술간 상호관계*
</div>
<br/>
<br/>
<details>
<summary style={{cursor: 'pointer', fontWeight: 'bold', color: '#555'}}>DX와 BIM의 구분</summary>
<div style={{marginTop: '10px', paddingLeft: '15px', borderLeft: '3px solid #ddd', fontSize: '0.9rem', color: '#666'}}>
| DX | 구분 | BIM |
| :--- | :---: | ---: |
| **BIM &lt;&lt; DX**<br/>(Engineering + Management 통합) | **범위** | **Only 3D**<br/>(형상 구현 중심) |
| **제작 및 운영**(상용 + 전용 40~80개)<br/>[Rhino, Sketchup, Blender..] + [EG-BIM 등] | **S/W** | **모델 제작용 상용 SW**<br/>[Revit, Civil 3D, Navisworks, Autocad] |
| **근본적 문제의식을 통한 개선** | **프로세스** | **기존 2D 설계 방식 유지** |
| **공학 정보 및 콘텐츠 연계에 집중**<br/>**도면, 수량, 시공계획 등 일식** | **성과품** | **3D 모델 중심**<br/>**기존 성과품 유지** |
| **설계/시공 생산성 혁신**(개념의 재정립) | **활용** | **3D 모델에 의한 일반적 이해 향상** |
| **전 생애주기 활용 시스템** | **확장성** | **(설계/시공/운영) 분야별 단절** |
| **구체화(복잡) - 적극적/구체적 실현 방안** | **수행 개념** | **단순화(오류) - 수동적/집단적 동질화** |
| **적극적, 주체적인 기술 접목/융합** | **CIVIL + IT** | **소극적, 상용 기술에 의존** |
| **자체 수행 능력 - 지속가능성 확보** | **주체** | **S/W 제작사 판매 정책에 의존** |
| **차별화 및 경쟁력 확보, 해외 진출** | **발주처** | **평준화, 국내 중심** |
| **IT + CIVIL ENG 220명 운영 + 기술 개발** | **설계사** | **소규모 BIM팀 운영 + 단순교육에 집중** |
| **분야 확장 모델 및 시스템** | **시공사** | **국내 토목 소극적/해외 토목증가** |
</div>
</details>
<br/>
---
:::note[핵심 요약]
* BIM은 건설산업의 디지털전환(DX)을 수행하는 과정에서 **가장 기초가 되는 일부분**이다
:::
@@ -1,42 +0,0 @@
---
title: DX의 시행 목표 및 기대효과
sidebar:
order: 1
---
import DxEffect from '../../../../components/dx.astro';
## 1. DX의 궁극적 목표
- **안전과 품질**
- 시설물의 요구 성능을 설계-시공-운영 전 과정에서 **디지털로 검증**하여 **안전성 확보**
- Copy & Paste로 하향 평준화된 성과물의 **하자 최소화**로 **고품질 성과물 제공**
<br/>
- **생산성 향상**
- Analogue 기반 업무를 Digital 기반 프로세스로 전환하여 **업무 속도·정확성·일관성 향상**
- 건설 비용 및 유지관리비 절감, 건설 기간 단축, 인력투입 최소화를 통해 **부가가치 제고**
<br/>
- **소통과 신뢰**
- 성과품과 Solution을 통한 협업 강화로 **의사소통 효율 및 운영·유지관리**의 **편리성 증진**
- 3D 모델 및 데이터 기반 검증을 통한 **오류 최소화 및 Claim 예방**으로 **신뢰성 확보**
![DX의 궁극적 목표](/assets/images/궁극적목표.png)
<br/>
## 2. DX 기반 Process 혁신에 따른 주체별 기대효과
<br/>
### 2.1 업무 수행 과정(Process)의 변화
- **생산 방식**: 수작업 의존의 반복 업무에서 벗어나, **SW를 활용한 체계화된 방식**으로 전환
- **인지·검토**: 2D 도면 해석 중심에서 **3D 모델 기반의 직관적 인지·검토 체계**로 전환
- **협업 구조**: 개별 문서 중심 협업에서 **데이터 통합 기반의 정보 공유·관리 협업 환경**으로 전환
- **검증·대응**: 사후 대응 중심의 문제 처리에서 **사전 검증 중심의 예방적 업무 방식**으로 전환
<br/>
### 2.2 DX 시행 주체별 기대효과
<DxEffect />
<br/>
<br/>
:::note[핵심 요약]
* 고품질의 성과품, 비용 절감, 시간 단축, 의사소통에 도움이 안 되면 DX가 아니다.
:::
<br/>
@@ -1,89 +0,0 @@
---
title: DX 실행 체계 구축 방안
sidebar:
order: 02
---
## 1. DX 시행을 위한 필수 요건
<br/>
* **기술(디지털)**
* **Digital 기술(S/W, H/W)과 업무 Process의 통합**
* 기존 업무 프로세스에 다양한 디지털 기술을 접목하여 업무 수행
* 프로젝트 전반에 걸친 업무 프로세스의 연결 및 조율
* **분야별 전문 지식(설계, 시공, 유지관리 등) 보유**
* 건설 전 단계에 대한 근본적인 이해와 지식 및 경험
* 최신 토목 기술 트랜드 및 표준 기준 등에 대한 높은 지식
<br/>
* **사람(역량)**
* **혁신적 사고방식과 창의적 문제 해결 능력**
* 기존 수행 방식과 관습적 사고 등에 의한 접근 방식 탈피
* 디지털 기술을 활용한 창의적, 혁신적인 솔루션 제시
* **사용자 중심 사고와 DX 수행 경험**
* 사용자의 요구와 기대를 충족시키는 설계 및 구현
* 시행착오를 포함한 수행 경험과 사용자 경험(UX)을 반영한 해결 방안 제시
<br/>
* **자연(여건)**
* **지속적인 투자 및 실행 의지**
* 기술 도입 초기 단계에 필요한 인력·기간·비용 등의 대규모 투자
* 기술 고도화를 위한 지속적인 개선 및 투자 체계 구축
* 변화와 혁신을 통해 부가가치를 창출하려는 실행 의지와 추진력
<br/>
---
## 2. Process의 혁신과 Product의 변화
<br/>
### 2.1 과정(Process)의 혁신
* **Analogue 기반 업무의 Digital화**
| As-is [Analogue] | 구분 | To-be [Digital] |
| :--- | :---: | :--- |
| **개념·문서·행정 절차 중심** | ➠ | **시각화된 목적물, 소통, 투명성 중심** |
| **2D 도면, 전문가, 규정** | ➠ | **3D 모델, 참여자, 실체** |
| **업무 구분(단절), 책임** | ➠ | **협업(융·복합), 창의성** |
<br/>
* **GIS + BIM의 연계**
* 지리·지형·지반 등 위치정보(GIS)와 3D모델(형상, 속성정보) 기반의 건설 정보를 포함하는 BIM의 연계를 통한 업무 프로세스의 혁신
<br/>
* **사용자 중심의 Solution 제공**
* 서로 다른 S/W로 작성되어 분절화된 Analogue 방식의 성과물과 정보물을 연계할 수 있는 설계·시공 Solution 제공
<br/>
<br/>
### 2.2 결과(Product)의 변화
* **Copy & Paste로 인해 하향 평준화된 기존 성과물의 품질 향상**
* 과거 수작업으로 시행하면서 발생하던 오류 등의 최소화
* 정확한 Data에 기반한 계획으로 고품질 성과물 도출
<br/>
* **Analogue 기반 도서 외 Digital 기반 정보물 추가**
* 기존 성과물(도면, 수량, 계산서, 시방서 등)에 3D 모델, Simulation 등의 Digital 기반 정보물 추가
<br/>
* **Solution을 활용한 업무 효율화**
* Engn. Solution을 통해 성과물에 관한 이슈를 함께 검토·논의하는 협업 환경 조성
* 건설 단계별 정보를 디지털 데이터로 축적하여, 건설 전 과정을 통합관리
<br/>
---
:::note[핵심 요약]
* **DX는 필요한 요건과 체계를 갖춘 후 시행해야만 그 효과를 기대할 수 있다.**
:::
@@ -1,147 +0,0 @@
---
title: DX 실행 체계 구축 방안
sidebar:
order: 02
slide: /slides/03.html
---
## 1. DX 시행을 위한 필수 요건
<br/>
<div style="width: 100%; max-width: 800px; margin: 0 auto;">
<table style="width: 80%; table-layout: fixed; border-collapse: collapse; border-spacing: 0; margin-bottom: 30px; font-family: 'Malgun Gothic', sans-serif; border-top: 1px solid #333; border-bottom: 1px solid #333;">
<tr>
<td rowspan="2" style="width: 40px; background: linear-gradient(to bottom, #0055aa, #003366); color: white; text-align: center; vertical-align: middle; padding: 10px; border: none; border-right: 1px dashed rgba(255, 255, 255, 0.5);">
<div style="writing-mode: vertical-rl; font-weight: bold; font-size: 18px; letter-spacing: 2px; margin: 0 auto;">기술 (디지털)</div>
</td>
<td style="width: 45px; background-color: #0055aa; color: white; text-align: center; vertical-align: middle; font-size: 22px; font-weight: bold; border: none; padding: 0;">技</td>
<td style="padding: 15px; background-color: white; border: none; vertical-align: top;">
<div style="font-size: 17px; font-weight: bold; margin-bottom: 8px;">분야별 전문 지식(설계, 시공, 유지관리 등) 보유</div>
<ul style="margin: 0; padding-left: 20px; color: #333; line-height: 1.5; font-size: 14px;">
<li>건설 전 단계에 대한 근본적인 이해와 지식 및 경험</li>
<li>최신 토목 기술 트랜드 및 표준 기준 등에 대한 높은 지식</li>
</ul>
</td>
</tr>
<tr>
<td style="width: 45px; background-color: #003366; color: white; text-align: center; vertical-align: middle; font-size: 22px; font-weight: bold; border: none; border-top: 1px dashed rgba(255,255,255,0.3); padding: 0;">術</td>
<td style="padding: 15px 15px 15px 15px; background-color: white; border: none; border-top: 1px dotted #ccc; vertical-align: top;">
<div style="font-size: 17px; font-weight: bold; margin-bottom: 8px;">Digital 기술(S/W, H/W)과 업무 Process의 통합</div>
<ul style="margin: 0; padding-left: 20px; color: #333; line-height: 1.5; font-size: 14px; margin-bottom: 0;">
<li>기존 업무 프로세스에 다양한 디지털 기술을 접목하여 업무 수행</li>
<li style="margin-bottom: 0;">프로젝트 전반에 걸친 업무 프로세스의 연결 및 조율</li>
</ul>
</td>
</tr>
</table>
<table style="width: 80%; table-layout: fixed; border-collapse: collapse; border-spacing: 0; margin-bottom: 30px; font-family: 'Malgun Gothic', sans-serif; border-top: 1px solid #333; border-bottom: 1px solid #333;">
<tr>
<td rowspan="2" style="width: 40px; background: linear-gradient(to bottom, #e67e22, #d35400); color: white; text-align: center; vertical-align: middle; padding: 10px; border: none; border-right: 1px dashed rgba(255, 255, 255, 0.5);">
<div style="writing-mode: vertical-rl; font-weight: bold; font-size: 18px; letter-spacing: 2px; margin: 0 auto;">사람 (역량)</div>
</td>
<td style="width: 45px; background-color: #e67e22; color: white; text-align: center; vertical-align: middle; font-size: 22px; font-weight: bold; border: none; padding: 0;">人</td>
<td style="padding: 15px; background-color: white; border: none; vertical-align: top;">
<div style="font-size: 17px; font-weight: bold; margin-bottom: 8px;">혁신적 사고방식과 문제 해결 능력을 갖춘 기술자</div>
<ul style="margin: 0; padding-left: 20px; color: #333; line-height: 1.5; font-size: 14px;">
<li>기존 수행 방식과 관습적 사고 등에 의한 접근 방식 탈피</li>
<li>디지털 기술을 활용한 창의적, 혁신적인 솔루션 제시</li>
</ul>
</td>
</tr>
<tr>
<td style="width: 45px; background-color: #d35400; color: white; text-align: center; vertical-align: middle; font-size: 22px; font-weight: bold; border: none; border-top: 1px dashed rgba(255,255,255,0.3); padding: 0;">材</td>
<td style="padding: 15px 15px 15px 15px; background-color: white; border: none; border-top: 1px dotted #ccc; vertical-align: top;">
<div style="font-size: 17px; font-weight: bold; margin-bottom: 8px;">디지털화 역량 및 개발경험 많은 개발자</div>
<ul style="margin: 0; padding-left: 20px; color: #333; line-height: 1.5; font-size: 14px; margin-bottom: 0;">
<li>다양한 Solution S/W를 개발할 수 있는 역량을 갖춘 Programmer </li>
<li style="margin-bottom: 0;">사용자 중심의 생각과 시행 착오를 포함한 DX 수행 경험</li>
</ul>
</td>
</tr>
</table>
<table style="width: 80%; table-layout: fixed; border-collapse: collapse; border-spacing: 0; margin-bottom: 30px; font-family: 'Malgun Gothic', sans-serif; border-top: 1px solid #333; border-bottom: 1px solid #333;">
<tr>
<td rowspan="2" style="width: 40px; background: linear-gradient(to bottom, #27ae60, #1e8449); color: white; text-align: center; vertical-align: middle; padding: 10px; border: none; border-right: 1px dashed rgba(255, 255, 255, 0.5);">
<div style="writing-mode: vertical-rl; font-weight: bold; font-size: 18px; letter-spacing: 2px; margin: 0 auto;">자연 (여건)</div>
</td>
<td style="width: 45px; background-color: #27ae60; color: white; text-align: center; vertical-align: middle; font-size: 22px; font-weight: bold; border: none; padding: 0;">天</td>
<td style="padding: 15px; background-color: white; border: none; vertical-align: top;">
<div style="font-size: 17px; font-weight: bold; margin-bottom: 8px;">수용 가능한 사회 · 기업 · 제도 등의 여건</div>
<ul style="margin: 0; padding-left: 20px; color: #333; line-height: 1.5; font-size: 14px;">
<li>목표만 제시하는 지침이 아닌, 수행사례와 효과검증 기반의 정책 시행</li>
<li>단순 기술 도입이 아닌, 실제 활용 중심의 업무 환경 구축</li>
</ul>
</td>
</tr>
<tr>
<td style="width: 45px; background-color: #1e8449; color: white; text-align: center; vertical-align: middle; font-size: 22px; font-weight: bold; border: none; border-top: 1px dashed rgba(255,255,255,0.3); padding: 0;">地</td>
<td style="padding: 15px 15px 15px 15px; background-color: white; border: none; border-top: 1px dotted #ccc; vertical-align: top;">
<div style="font-size: 17px; font-weight: bold; margin-bottom: 8px;">지속적인 투자 및 실행 의지</div>
<ul style="margin: 0; padding-left: 20px; color: #333; line-height: 1.5; font-size: 14px; margin-bottom: 0;">
<li>기술 개발 및 고도화를 위한 대규모 투자와 지속적 개선</li>
<li style="margin-bottom: 0;">부가가치 창출을 위한 실행 의지와 추진력</li>
</ul>
</td>
</tr>
</table>
</div>
<br/>
---
<br/>
## 2. Process의 혁신과 Product의 변화
<br/>
### 2.1 과정(Process)의 혁신
* **Analogue 기반 업무의 Digital Transformation**
| As-is [Analogue] | 구분 | To-be [Digital] |
| :--- | :---: | :--- |
| 개념·문서·행정 절차 중심 | ➠ | 시각화된 목적물, 소통, 투명성 중심 |
| 2D 도면, 전문가, 규정 | ➠ | 3D 모델, 참여자, 실체 |
| 업무 구분(단절), 책임 | ➠ | 협업(융·복합), 창의성 |
<br/>
* **GIS + BIM의 연계**
* GIS(지리·지형·지반 등 위치정보)와 BIM(3D 형상, 속성정보)의 연계를 통한 업무 프로세스의 혁신
<br/>
* **사용자 중심의 Solution 제공 (S/W)**
* 서로 다른 S/W로 작성되어 분절화된 아날로그식 성과물과 정보물의 연계가 가능하도록 설계·시공 Solution 제공
<br/>
### 2.2 결과(Product)의 변화
* **Copy & Paste로 인해 하향 평준화된 기존 성과물의 품질 향상**
* 과거 수작업으로 시행하면서 발생하던 오류 등의 최소화
* 정확한 Data에 기반한 계획으로 고품질 성과물 도출
<br/>
* **Analogue 기반 도서 외 Digital 기반 정보물 추가**
* 기존 성과물(도면, 수량, 계산서, 시방서 등)에 3D 모델, Simulation 등의 Digital 기반 정보물 추가
<br/>
* **Solution을 활용한 업무 효율화**
* Engn. Solution을 통해 성과물에 관한 이슈를 함께 검토·논의하는 협업 환경 조성
* 건설 단계별 정보를 디지털 데이터로 축적하여, 건설 전 과정을 통합관리
<br/>
![결과의 변화](/assets/images/결과의변화.png)
<br/>
---
:::note[핵심 요약]
* DX는 필요한 요건과 체계를 갖춘 후 시행해야만 그 효과를 기대할 수 있다.
:::
-262
View File
@@ -1,262 +0,0 @@
---
title: DX 지연 요인
sidebar:
order: 03
---
## 1. DX에 대한 인식
<br/>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '20px', justifycontent: 'center' }}>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
position: 'relative',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>기술 및 소프트웨어 이해도</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #1a73e8',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"무슨 말인지 잘 모르겠다, 어디까지 어떻게 해야 하는지 모르겠다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>기본지침, 시행지침 등 새롭게 알아야 할 게 너무 많다.</li>
<li>3D 모델과 별 차이점을 모르겠다. S/W마다 사용법이 다르다.</li>
<li>필요한 것은 쉽고 간단한 건데, 왜 이렇게 복잡하게 만들까?</li>
</ul>
</div>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>효과와 효율성</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #d93025',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"2D 설계 후 전환설계를 수행하는데 효과는 모르겠고, 효율은 낮다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>성과품 작성은 기존과 같게 하고, 추가 업무만 발생해 효율이 낮다.</li>
<li>BIM으로 인해 가중되는 업무 대비 효과가 거의 없어 보인다.</li>
<li>결과적으로 큰 차이를 못 느끼겠고, 이런 노력이 정말 가치 있는 일이야?</li>
</ul>
</div>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>인력 및 교육</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #f9ab00',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"수행 인력이 부족하고, 기존 직원들은 어떻게 교육해야 하나"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>교육시간 손실로 일손이 더 필요해지고, 적응하는데도 시간이 걸린다.</li>
<li>신입사원은 없고 BIM 수행을 할 수 있는 기술자가 부족하다.</li>
<li>여러 회사의 S/W별 사용법이 달라 새로운 S/W에 적용에 시간이 필요하다.</li>
</ul>
</div>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>경제적 부담</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #34a853',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"S/W 구독료만 크게 발생되고, 비용 보전은 안 된다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>사용해야 할 S/W의 종류가 너무 많고 복잡한데 모두 필요한가?</li>
<li>모든 Project에 적용되는 것도 아닌데, 다수의 S/W 구독료를 내야 한다.</li>
<li>디지털전환 하기 위해 이 비용을 쓰는게 정말 경제적인 이득이 있는 거야?</li>
</ul>
</div>
<div style={{
width: '100%',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default',
marginTop: '10px'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>실무 및 적용성</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #8e24aa',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"실무적 사용에 의한 효율성 증진보다는 홍보, PQ용으로 사용한다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>구체적 적용에 의한 비용, 시간, 품질 등의 효과 사례가 없다.</li>
<li>지형, 선형, 도로, 교량 Model을 만드는 S/W가 모두 달라 적용이 어렵다.</li>
<li>속성정보를 반영하지 않은 형상 위주의 3D 모델 제작에만 초점이 맞춰져 있다.</li>
</ul>
</div>
</div>
<br/>
---
<br/>
## 2. DX 추진의 실태
<br/>
### 2.1 정책 및 발주 체계
- **실질적 기술 경쟁을 저해하는 정책 집행**
- 모든 설계사가 수행 능력을 갖추었다는 전제하에 정책 시행
- 수행 능력이 없는 업체 선정 후 성과품의 수준을 낮추어 시행
<br/>
- **적용 효과가 있는 사례도 없이 방침부터 도입**
- DX/BIM 적용에 따른 실무적 이득이 있다고 판단된 사례 부족
- BIM 지침/방침 등을 시행 경험과 효과 검증도 없이 남발
<br/>
- **엔지니어링 S/W에 대한 개념 부재**
- 다양한 엔지니어링 S/W의 특성에 대한 깊은 이해 없이 범용 S/W 선택
- 대형 Global S/W 회사에 과도한 의존과 이에 예속되는 방침 남발로 전용 S/W 소멸
<br/>
- **기술투자(R&D) 없는 성과 창출 기대**
- 단순 BIM S/W만 구입하면 될 것이라는 안일한 생각
- 실질적 기술 개발 투자 노력 없이 남들이 하는 대로 하면 된다는 착각
<br/>
<details>
<summary style={{cursor: 'pointer', fontWeight: 'bold', color: '#555'}}>발주처 반응</summary>
<img src="/assets/images/발주처반응.png" />
</details>
### 2.2 조직 및 수행 역량
- **공학적 개념 정립 부재**
- DX와 BIM의 차이점을 명확히 구분하지 못하고 접근 방식과 기술적 도구 사이의 혼란만 가중
- 단순히 기술적 도구의 사용에 초점을 맞추느라 3D모델 제작 S/W에 과도하게 의존
<br/>
- **‘본업 기술력 확보’ 우선의 개념 부재**
- 고도의 전문지식과 현장 경험이 축적된 Manual의 중요성과 필요성에 대한 이해 부족
- 국가·발주처의 지침·방침에만 의존한 업체의 기술력
<br/>
- **DX/BIM의 근본 취지와 목표의 이해 부족**
- DX에 의한 과정의 혁신과 결과물의 변화에 대한 고민 부재
- 기술자가 직접 3D모델을 만들고 수정하며 설계를 수행하지 않고, 별도로 외주 처리하여 본질 회피
<br/>
- **과거의 타성에 머무르고 있는 기술자 집단**
- 설계/감리/시공 임직원들의 Digital 무지와 전략적 무지
- 교육과 학습을 통한 인재 양성보다 당장 실무 활용이 가능한 타사 인력 빼오기에 집중
<br/>
<details>
<summary style={{cursor: 'pointer', fontWeight: 'bold', color: '#555'}}>설계·시공업계 반응</summary>
<img src="/assets/images/설계시공업계반응.png" />
</details>
<br/>
---
:::note[핵심 요약]
* 검증 없는 정책의 일방적 추진과 조직의 회피, 이해 부족은 DX 지연을 반복시키고 있다.
:::
@@ -1,48 +0,0 @@
---
title: 설계 방식의 왜곡
sidebar:
order: 0
---
## 1. 설계의 자동화
- **설계의 개념**
- **개념설계**: 건설사업의 타당성 조사, 환경영향평가 등을 수행하는 목적물과 방향성을 수립하는 단계
- **실시설계**: 공학적 지식을 바탕으로 건설에 필요한 목적물의 형상과 규격, 공사비를 결정하는 단계
- **시공상세설계**: 시공에 필요한 정보를 반영하여 목적물과 공정을 단계별로 구체화하는 단계
<br/>
- **설계의 특성**
- 계획 단계부터 엔지니어의 경험과 공학적 판단에 기반한 의사결정이 필수적인 과정
- 사업마다 고유한 환경 및 지역 조건이 다르므로 매번 일회성 작업으로 수행
- 평면 및 종단 등 선형의 일부 변경만으로도 시설물 전체의 구조적 해석이 변화
<br/>
- **'설계 자동화'라는 용어의 오용과 모순**
- **기능적 대체의 오류**: '설계 자동화' S/W는 설계 전체 프로세스가 아닌, 단순 반복적인 제도(Drafting)나 수량 산출 등 단순 기능만을 대체하는 수준
- **인식의 왜곡**: '자동화'라는 용어의 남용으로 엔지니어의 업무가 단순 반복 작업인 것으로 저평가되고 있으며, 가장 중요한 전문 지식과 경험 기반의 의사결정 단계가 경시
- **완전 자동화의 불가능**: 설계는 각 단계가 유기적으로 연결된 지식 집약적 활동이므로, 엔지니어의 판단을 배제한 전체 프로세스의 완전 자동화는 불가능
<br/>
---
<br/>
## 2. S/W 중심 설계 방식
### 1 기능·구조적 한계
- 시간 단축과 오류 감소 등 '도구적 효율성'에만 집중하여, 설계 품질 향상이나 고부가가치를 창출하는 핵심 기술로 발전하지 못함
- 대부분의 S/W가 2D 기반의 설계에 머물러 있어, 엔지니어의 입체적인 아이디어와 복잡한 현장 <br/>특수성을 충분히 표현하지 못함
### 2 기술 역량의 왜곡
- S/W 개발사: 기술 혁신보다 기존 방식의 반복과 성과물 생산에만 치중하며, 수익성 중심의 개발 지속
- 기업 및 기술자: 기존 방식의 한계를 분석하고 새로운 부가가치를 찾기보다는, 단순 S/W 활용 능력을 엔지니어의 핵심 역량으로 오인
- 종속성 심화: 시간이 흐를수록 기술자는 S/W에 예속된 기능인으로 전락하고, 산업 전체는 저부가가치의 굴레를 벗어나지 못함
<br/>
---
:::note[핵심 요약]
* 잘못된 자동화 인식과 도구 중심의 설계는 기술자를 S/W에 의존하는 기능인으로 전락시키며, <br/>설계의 본질과 경쟁력을 동시에 훼손한다.
:::
-299
View File
@@ -1,299 +0,0 @@
"""Catalog ↔ partial ↔ builder invariant audit CLI (IMP-#85 u3a / u3b).
Offline audit of `templates/phase_z2/catalog/frame_contracts.yaml` against
the on-disk frame partials and the runtime `PAYLOAD_BUILDERS` registry.
Reports diff surface so first-fix iteration sees the entire catalog drift,
not just the first failure (matches the boot-time invariant's aggregation
behavior in `_check_catalog_builder_invariant`).
Invariants (scope-locked per Stage 2):
I1 partial existence — `templates/phase_z2/families/{template_id}.html`
must exist for live (non-VP) contracts.
I2 builder declared — live contracts must declare a non-empty
`payload.builder`.
I3 builder registered — declared builders must be members of
`src.phase_z2_mapper.PAYLOAD_BUILDERS`.
I4 slot_payload refs — every key generated by the contract's builder
must appear as a `slot_payload.<key>` reference in
the partial. Direction A only (dead generated key).
Skipped when the partial uses dynamic bracket
access (`slot_payload[...]`) — those refs cannot be
resolved statically; the relevant generated keys
are presumed reachable via the dynamic form.
`visual_pending: true` contracts are skipped for I1I4 (data-driven from
catalog, no hard-coded frame allow-list; matches u2 invariant scope).
Exit codes:
0 — all invariants pass on live (non-VP) contracts.
1 — one or more violations reported.
Usage::
python scripts/audit_frame_invariants.py
python scripts/audit_frame_invariants.py --catalog <path> --partials-dir <path>
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
from typing import Iterable
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
import yaml
DEFAULT_CATALOG_PATH = (
REPO_ROOT / "templates" / "phase_z2" / "catalog" / "frame_contracts.yaml"
)
DEFAULT_PARTIALS_DIR = REPO_ROOT / "templates" / "phase_z2" / "families"
def _format_path(path: Path) -> str:
try:
return str(path.relative_to(REPO_ROOT))
except ValueError:
return str(path)
def _is_visual_pending(contract: dict) -> bool:
return contract.get("visual_pending") is True
def _iter_live_contracts(catalog: dict) -> Iterable[tuple[str, dict]]:
for template_id, contract in catalog.items():
if not isinstance(contract, dict):
continue
if _is_visual_pending(contract):
continue
yield template_id, contract
def check_i1_partial_existence(
catalog: dict, partials_dir: Path
) -> list[str]:
"""I1 — Live contracts must have `families/{template_id}.html` on disk."""
violations: list[str] = []
for template_id, _contract in _iter_live_contracts(catalog):
partial_path = partials_dir / f"{template_id}.html"
if not partial_path.is_file():
violations.append(
f"I1 partial-missing: contract '{template_id}' has no "
f"partial file at {_format_path(partial_path)}."
)
return violations
def check_i2_builder_declared(catalog: dict) -> list[str]:
"""I2 — Live contracts must declare a non-empty `payload.builder`."""
violations: list[str] = []
for template_id, contract in _iter_live_contracts(catalog):
payload = contract.get("payload") or {}
if not isinstance(payload, dict):
violations.append(
f"I2 builder-undeclared: contract '{template_id}' has "
f"non-dict payload (type={type(payload).__name__})."
)
continue
builder_name = payload.get("builder")
if not builder_name:
violations.append(
f"I2 builder-undeclared: contract '{template_id}' is "
f"missing payload.builder."
)
return violations
def check_i3_builder_registered(
catalog: dict, registered_builders: set[str]
) -> list[str]:
"""I3 — Declared builders must be members of PAYLOAD_BUILDERS registry."""
violations: list[str] = []
for template_id, contract in _iter_live_contracts(catalog):
payload = contract.get("payload") or {}
if not isinstance(payload, dict):
continue
builder_name = payload.get("builder")
if not builder_name:
continue
if builder_name not in registered_builders:
violations.append(
f"I3 builder-unregistered: contract '{template_id}' "
f"references payload.builder='{builder_name}' not in "
f"PAYLOAD_BUILDERS."
)
return violations
_SLOT_PAYLOAD_DOT_RE = re.compile(r"slot_payload\.([A-Za-z_][A-Za-z0-9_]*)")
_SLOT_PAYLOAD_BRACKET_RE = re.compile(r"slot_payload\s*\[")
def extract_static_slot_refs(partial_text: str) -> set[str]:
"""Return the set of `slot_payload.<key>` dot-access references."""
return set(_SLOT_PAYLOAD_DOT_RE.findall(partial_text))
def partial_uses_dynamic_slot_access(partial_text: str) -> bool:
"""True if the partial dereferences `slot_payload[...]` (dynamic key)."""
return bool(_SLOT_PAYLOAD_BRACKET_RE.search(partial_text))
def expected_payload_keys(contract: dict) -> set[str]:
"""Statically compute the set of payload keys the contract's builder produces.
Mirrors `src.phase_z2_mapper`'s registered builders (IMP-#85 u3b). Returns
an empty set when the builder is unknown — I3 already flags that drift.
"""
payload = contract.get("payload") or {}
if not isinstance(payload, dict):
return set()
keys: set[str] = set()
title_spec = payload.get("title")
if isinstance(title_spec, dict) and title_spec.get("source"):
keys.add("title")
builder = payload.get("builder")
options = payload.get("builder_options") or {}
if not isinstance(options, dict):
options = {}
if builder == "items_with_role":
array_root = options.get("array_root")
if array_root:
keys.add(array_root)
elif builder == "process_product_pair":
for col in options.get("columns") or []:
if not isinstance(col, dict):
continue
if col.get("title_to"):
keys.add(col["title_to"])
if col.get("body_to"):
keys.add(col["body_to"])
elif builder == "quadrant_flat_slots":
pad_to = int(options.get("pad_to", 4))
label_key = options.get("label_key_pattern", "quadrant_{n}_label")
body_key = options.get("body_key_pattern", "quadrant_{n}_body")
for n in range(1, pad_to + 1):
keys.add(label_key.format(n=n))
keys.add(body_key.format(n=n))
elif builder == "cycle_intersect_3":
pad_to = int(options.get("pad_to", 3))
label_key = options.get("label_key_pattern", "circle_{n}_label")
for n in range(1, pad_to + 1):
keys.add(label_key.format(n=n))
keys.add("intersection")
elif builder == "compare_table_2col":
keys.update({"col_a_label", "col_b_label", "rows"})
elif builder == "paired_rows_4x2_slots":
label_key = options.get("label_key_pattern", "row_{r}_{side}_label")
body_key = options.get("body_key_pattern", "row_{r}_{side}_body")
rows = int(options.get("rows", 4))
sides = options.get("sides", ["left", "right"]) or []
for r in range(1, rows + 1):
for side in sides:
keys.add(label_key.format(r=r, side=side))
keys.add(body_key.format(r=r, side=side))
return keys
def check_i4_slot_payload_refs(
catalog: dict,
partials_dir: Path,
registered_builders: set[str],
) -> list[str]:
"""I4 — every generated payload key must be referenced by the partial.
Direction A only (dead key). Skipped when the partial uses dynamic
bracket access (`slot_payload[...]`) — generated keys are presumed
reached via the dynamic form and cannot be resolved statically.
Contracts already failing I1 (missing partial) or I3 (unregistered
builder) are skipped so the same drift is not double-reported.
"""
violations: list[str] = []
for template_id, contract in _iter_live_contracts(catalog):
payload = contract.get("payload") or {}
if not isinstance(payload, dict):
continue
builder_name = payload.get("builder")
if not builder_name or builder_name not in registered_builders:
continue
partial_path = partials_dir / f"{template_id}.html"
if not partial_path.is_file():
continue
partial_text = partial_path.read_text(encoding="utf-8")
if partial_uses_dynamic_slot_access(partial_text):
continue
static_refs = extract_static_slot_refs(partial_text)
expected = expected_payload_keys(contract)
orphans = sorted(expected - static_refs)
for key in orphans:
violations.append(
f"I4 generated-key-orphan: contract '{template_id}' builder "
f"'{builder_name}' produces payload key '{key}' but partial "
f"never references slot_payload.{key}."
)
return violations
def run_audit(
catalog_path: Path = DEFAULT_CATALOG_PATH,
partials_dir: Path = DEFAULT_PARTIALS_DIR,
) -> list[str]:
"""Load catalog + registry and aggregate I1-I4 violations.
Registry is imported here (not at module import) so the script can be
inspected without triggering the boot-time catalog invariant.
"""
from src.phase_z2_mapper import PAYLOAD_BUILDERS
catalog = yaml.safe_load(catalog_path.read_text(encoding="utf-8")) or {}
registered = set(PAYLOAD_BUILDERS.keys())
violations: list[str] = []
violations.extend(check_i1_partial_existence(catalog, partials_dir))
violations.extend(check_i2_builder_declared(catalog))
violations.extend(check_i3_builder_registered(catalog, registered))
violations.extend(check_i4_slot_payload_refs(catalog, partials_dir, registered))
return violations
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Audit Phase Z-2 catalog ↔ partials ↔ builder registry."
)
parser.add_argument(
"--catalog",
type=Path,
default=DEFAULT_CATALOG_PATH,
help="Path to frame_contracts.yaml",
)
parser.add_argument(
"--partials-dir",
type=Path,
default=DEFAULT_PARTIALS_DIR,
help="Directory containing families/{template_id}.html partials",
)
args = parser.parse_args(argv)
violations = run_audit(args.catalog, args.partials_dir)
if not violations:
print("audit_frame_invariants: PASS (I1-I4 clean on live contracts).")
return 0
print(
f"audit_frame_invariants: FAIL ({len(violations)} violation(s)):"
)
for v in violations:
print(f" - {v}")
return 1
if __name__ == "__main__":
sys.exit(main())
-150
View File
@@ -1,150 +0,0 @@
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from src.phase_z2_mapper import _extract_bold_or_plain, split_source
from src.phase_z2_pipeline import parse_mdx
MDX_ROOT = ROOT / "samples" / "mdx"
CONTRACT: dict[str, dict[str, Any]] = {
"01. 건설산업 DX의 올바른 이해(0127).mdx": {
"top_sections": ["1. 용어 정의", "2. 용어간 상호관계"],
"internal_labels": {
"01-1": ["건설산업", "BIM(Building Information Modeling) : 디지털 전환을 위한 핵심 기술", "DX(Digital Transformation) : 산업 패러다임의 변화"],
"01-2": ["DX는 BIM과 같은 디지털기술을 기반으로 산업 전반의 프로세스를 혁신하는 상위개념", "DX와 BIM의 구분"],
},
},
"02. DX의 시행 목표 및 기대효과.mdx": {
"top_sections": ["1. DX의 궁극적 목표", "2. DX 기반 Process 혁신에 따른 주체별 기대효과"],
"h3": {
"02-2": ["2.1 업무 수행 과정(Process)의 변화", "2.2 DX 시행 주체별 기대효과"],
},
"internal_labels": {
"02-1": ["안전과 품질", "생산성 향상", "소통과 신뢰"],
},
},
"03. DX 시행을 위한 필수 요건 및 혁신 방안.mdx": {
"top_sections": ["1. DX 시행을 위한 필수 요건", "2. Process의 혁신과 Product의 변화"],
"h3": {
"03-2": ["2.1 과정(Process)의 혁신", "2.2 결과(Product)의 변화"],
},
"internal_labels": {
"03-1": ["기술(디지털)", "사람(역량)", "자연(여건)"],
},
},
"04. DX 지연 요인.mdx": {
"top_sections": ["1. DX에 대한 인식", "2. DX 추진의 실태"],
"h3": {
"04-1": ["기술 및 소프트웨어 이해도", "효과와 효율성", "인력 및 교육", "경제적 부담", "실무 및 적용성"],
"04-2": ["2.1 정책 및 발주 체계", "2.2 조직 및 수행 역량"],
},
},
"05. 설계 방식의 왜곡.mdx": {
"top_sections": ["1. 설계의 자동화", "2. S/W 중심 설계 방식"],
"h3": {
"05-2": ["1 기능·구조적 한계", "2 기술 역량의 왜곡"],
},
"internal_labels": {
"05-1": ["설계의 개념", "설계의 특성", "'설계 자동화'라는 용어의 오용과 모순"],
},
},
}
def _h3_titles(raw_content: str) -> list[str]:
return [
m.group(1).strip()
for m in re.finditer(r"(?m)^###\s+(.+?)\s*$", raw_content or "")
]
def _top_bullet_labels(raw_content: str) -> list[str]:
return [
_extract_bold_or_plain(top_line)
for top_line, _nested in split_source("top_bullets", raw_content or "")
]
def _contains_label(labels: list[str], expected: str) -> bool:
return any(expected == label or expected in label for label in labels)
def check_one(filename: str, spec: dict[str, Any]) -> dict[str, Any]:
path = MDX_ROOT / filename
title, sections, footer = parse_mdx(path)
section_titles = [s.title for s in sections]
errors: list[str] = []
expected_top = spec.get("top_sections", [])
if section_titles != expected_top:
errors.append(
f"top_sections mismatch: expected={expected_top!r}, actual={section_titles!r}"
)
section_reports = []
for section in sections:
h3_titles = _h3_titles(section.raw_content)
bullet_labels = _top_bullet_labels(section.raw_content)
expected_h3 = (spec.get("h3") or {}).get(section.section_id)
if expected_h3 is not None and h3_titles != expected_h3:
errors.append(
f"{section.section_id} h3 mismatch: expected={expected_h3!r}, actual={h3_titles!r}"
)
expected_labels = (spec.get("internal_labels") or {}).get(section.section_id, [])
missing_labels = [
label for label in expected_labels if not _contains_label(bullet_labels, label)
]
if missing_labels:
errors.append(
f"{section.section_id} internal labels missing: {missing_labels!r}"
)
section_reports.append({
"section_id": section.section_id,
"title": section.title,
"h3_titles": h3_titles,
"top_bullet_labels_count": len(bullet_labels),
"top_bullet_labels_sample": bullet_labels[:12],
})
return {
"filename": filename,
"title": title,
"has_footer": bool(footer),
"passed": not errors,
"errors": errors,
"sections": section_reports,
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--write-report", type=Path)
args = parser.parse_args()
reports = [check_one(filename, spec) for filename, spec in CONTRACT.items()]
result = {
"passed": all(report["passed"] for report in reports),
"reports": reports,
}
text = json.dumps(result, ensure_ascii=False, indent=2)
print(text)
if args.write_report:
args.write_report.parent.mkdir(parents=True, exist_ok=True)
args.write_report.write_text(text + "\n", encoding="utf-8")
return 0 if result["passed"] else 1
if __name__ == "__main__":
raise SystemExit(main())
-82
View File
@@ -1,82 +0,0 @@
from __future__ import annotations
import argparse
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.mdx_text_atoms import compare_atom_sets, extract_text_atoms
DEFAULT_PAIRS = [
(
"samples/mdx/01. 건설산업 DX의 올바른 이해(0127)(원본).mdx",
"samples/mdx/01. 건설산업 DX의 올바른 이해(0127).mdx",
),
(
"samples/mdx/02. DX의 시행 목표 및 기대효과(원본).mdx",
"samples/mdx/02. DX의 시행 목표 및 기대효과.mdx",
),
(
"samples/mdx/04. DX 지연 요인(원본).mdx",
"samples/mdx/04. DX 지연 요인.mdx",
),
(
"samples/mdx/05. 설계 방식의 왜곡(원본).mdx",
"samples/mdx/05. 설계 방식의 왜곡.mdx",
),
]
def main() -> int:
parser = argparse.ArgumentParser(description="Compare original MDX text atoms with standardized MDX text atoms.")
parser.add_argument("--root", default=".", help="Repository root. Defaults to current directory.")
parser.add_argument("--max-items", type=int, default=8, help="Max examples per mismatch category.")
args = parser.parse_args()
root = Path(args.root).resolve()
any_failed = False
for original_rel, standardized_rel in DEFAULT_PAIRS:
original = root / original_rel
standardized = root / standardized_rel
print(f"=== {standardized.name} ===")
if not original.exists():
print(f" ERROR original missing: {original_rel}")
any_failed = True
continue
if not standardized.exists():
print(f" ERROR standardized missing: {standardized_rel}")
any_failed = True
continue
original_atoms = extract_text_atoms(original)
standardized_atoms = extract_text_atoms(standardized)
comparison = compare_atom_sets(original_atoms, standardized_atoms)
missing = comparison["missing_from_standardized"]
added = comparison["added_in_standardized"]
print(f" original_atoms : {len(original_atoms)}")
print(f" standardized_atoms : {len(standardized_atoms)}")
print(f" missing : {len(missing)}")
print(f" added : {len(added)}")
if missing or added:
any_failed = True
_print_examples("missing_from_standardized", missing, args.max_items)
_print_examples("added_in_standardized", added, args.max_items)
else:
print(" OK text atom parity")
print()
return 1 if any_failed else 0
def _print_examples(label: str, atoms, max_items: int) -> None:
if not atoms:
return
print(f" {label} examples:")
for atom in atoms[:max_items]:
print(f" L{atom.line_no} {atom.kind}: {atom.normalized}")
if __name__ == "__main__":
sys.exit(main())
@@ -1,92 +0,0 @@
import argparse
import json
import re
import sys
from html import unescape
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from src.mdx_text_atoms import compare_atom_sets, extract_text_atoms, normalize_text_atom
def _compact(text: str) -> str:
normalized = normalize_text_atom(text or "")
return re.sub(r"[^0-9A-Za-z가-힣]+", "", normalized)
def _strip_html_to_text(html: str) -> str:
html = re.sub(r"(?is)<script.*?</script>", "\n", html)
html = re.sub(r"(?is)<style.*?</style>", "\n", html)
html = re.sub(r"(?is)<[^>]+>", "\n", html)
return unescape(html)
def _load_source_text(run_dir: Path) -> str:
step02 = run_dir / "phase_z2" / "steps" / "step02_normalized.json"
data = json.loads(step02.read_text(encoding="utf-8"))["data"]
parts: list[str] = []
if data.get("slide_title"):
parts.append(str(data["slide_title"]))
for section in data.get("sections") or []:
if section.get("title"):
parts.append(str(section["title"]))
if section.get("raw_content"):
parts.append(str(section["raw_content"]))
if data.get("slide_footer"):
parts.append(str(data["slide_footer"]))
return "\n\n".join(parts)
def check_run(run_dir: Path) -> dict:
final_html = run_dir / "phase_z2" / "final.html"
source_text = _load_source_text(run_dir)
rendered_text = _strip_html_to_text(final_html.read_text(encoding="utf-8"))
source_atoms = extract_text_atoms(source_text)
rendered_atoms = extract_text_atoms(rendered_text)
diff = compare_atom_sets(source_atoms, rendered_atoms)
rendered_compact = _compact(" ".join(a.normalized for a in rendered_atoms))
compact_missing = [
atom for atom in source_atoms
if _compact(atom.normalized) and _compact(atom.normalized) not in rendered_compact
]
return {
"run_id": run_dir.name,
"source_atoms": len(source_atoms),
"rendered_atoms": len(rendered_atoms),
"missing_count": len(compact_missing),
"added_count": len(diff["added_in_standardized"]),
"missing": compact_missing,
"added": diff["added_in_standardized"],
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("run_ids", nargs="+")
parser.add_argument("--runs-root", default="data/runs")
parser.add_argument("--max-items", type=int, default=8)
args = parser.parse_args()
ok = True
for run_id in args.run_ids:
result = check_run(Path(args.runs_root) / run_id)
if result["missing_count"]:
ok = False
print(f"=== {result['run_id']} ===")
print(f" source_atoms : {result['source_atoms']}")
print(f" rendered_atoms : {result['rendered_atoms']}")
print(f" missing : {result['missing_count']}")
print(f" added : {result['added_count']}")
for item in result["missing"][: args.max_items]:
print(f" - missing: {item.text}")
for item in result["added"][: args.max_items]:
print(f" + added: {item.text}")
print()
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,179 +0,0 @@
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def _open_driver(width: int, height: int):
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
options = Options()
options.add_argument("--headless=new")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument(f"--window-size={width},{height}")
candidates = [
PROJECT_ROOT / "chromedriver",
PROJECT_ROOT / "chromedriver.exe",
]
last_err: Exception | None = None
for path in candidates:
if not path.is_file():
continue
try:
driver = webdriver.Chrome(service=Service(str(path)), options=options)
break
except Exception as exc: # pragma: no cover - environment dependent
last_err = exc
else:
try:
driver = webdriver.Chrome(options=options)
except Exception as exc: # pragma: no cover - environment dependent
raise RuntimeError(f"selenium init failed: {last_err or exc}") from exc
driver.execute_cdp_cmd(
"Emulation.setDeviceMetricsOverride",
{
"width": width,
"height": height,
"deviceScaleFactor": 1,
"mobile": False,
},
)
return driver
def check_final_html(path: Path, *, width: int, height: int, tolerance: int) -> dict:
driver = _open_driver(width, height)
try:
driver.get(path.resolve().as_uri())
result = driver.execute_script(
r"""
const de = document.documentElement;
const body = document.body;
const slide = document.querySelector('.slide');
const slideRect = slide ? slide.getBoundingClientRect() : null;
const measure = (el) => ({
clientWidth: el.clientWidth,
clientHeight: el.clientHeight,
scrollWidth: el.scrollWidth,
scrollHeight: el.scrollHeight,
});
return {
viewport: {
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
},
documentElement: measure(de),
body: measure(body),
slide: slideRect ? {
x: slideRect.x,
y: slideRect.y,
width: slideRect.width,
height: slideRect.height,
right: slideRect.right,
bottom: slideRect.bottom,
} : null,
};
"""
)
finally:
driver.quit()
failures: list[str] = []
vp = result["viewport"]
doc = result["documentElement"]
body = result["body"]
slide = result.get("slide")
if doc["scrollWidth"] > vp["innerWidth"] + tolerance:
failures.append(
f"document horizontal scroll: {doc['scrollWidth']} > {vp['innerWidth']}"
)
if doc["scrollHeight"] > vp["innerHeight"] + tolerance:
failures.append(
f"document vertical scroll: {doc['scrollHeight']} > {vp['innerHeight']}"
)
if body["scrollWidth"] > vp["innerWidth"] + tolerance:
failures.append(f"body horizontal scroll: {body['scrollWidth']} > {vp['innerWidth']}")
if body["scrollHeight"] > vp["innerHeight"] + tolerance:
failures.append(f"body vertical scroll: {body['scrollHeight']} > {vp['innerHeight']}")
if not slide:
failures.append(".slide not found")
else:
if abs(slide["width"] - width) > tolerance or abs(slide["height"] - height) > tolerance:
failures.append(
f"slide size is {slide['width']}x{slide['height']}, expected {width}x{height}"
)
if slide["x"] < -tolerance or slide["y"] < -tolerance:
failures.append(f"slide origin out of viewport: x={slide['x']}, y={slide['y']}")
if slide["right"] > vp["innerWidth"] + tolerance:
failures.append(f"slide right edge exceeds viewport: {slide['right']} > {vp['innerWidth']}")
if slide["bottom"] > vp["innerHeight"] + tolerance:
failures.append(f"slide bottom edge exceeds viewport: {slide['bottom']} > {vp['innerHeight']}")
return {
"path": str(path),
"passed": not failures,
"failures": failures,
"metrics": result,
}
def _run_id_to_final_html(run_id: str) -> Path:
return PROJECT_ROOT / "data" / "runs" / run_id / "phase_z2" / "final.html"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("run_ids", nargs="+")
parser.add_argument("--width", type=int, default=1280)
parser.add_argument("--height", type=int, default=720)
parser.add_argument("--tolerance", type=int, default=1)
parser.add_argument("--write-report", type=Path)
args = parser.parse_args(argv)
reports = []
exit_code = 0
for run_id in args.run_ids:
path = _run_id_to_final_html(run_id)
if not path.is_file():
report = {
"path": str(path),
"passed": False,
"failures": ["final.html not found"],
"metrics": None,
}
else:
report = check_final_html(
path,
width=args.width,
height=args.height,
tolerance=args.tolerance,
)
reports.append({"run_id": run_id, **report})
status = "PASS" if report["passed"] else "FAIL"
print(f"{run_id}: {status}")
for failure in report["failures"]:
print(f" - {failure}")
if not report["passed"]:
exit_code = 1
if args.write_report:
args.write_report.parent.mkdir(parents=True, exist_ok=True)
args.write_report.write_text(
json.dumps({"reports": reports}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return exit_code
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
-178
View File
@@ -1,178 +0,0 @@
"""IMP-43 (#72) u8 — measure ``--reuse-from`` wall-clock savings.
Argv-driven measurement helper for the Stage 2 §u8 binding contract:
re-derive a realistic savings target instead of mirroring the
unverified issue-body 5070% / 1020s → 38s claim.
Per-iteration measurement protocol (mirrors the u7a equivalence
harness, ``tests/test_phase_z2_reuse_from_equivalence_unit.py``):
(A) baseline full run, no overrides — reuse seed
(B) full rerun full run + one --override-frame pin — control path
(C) reuse --reuse-from <seed> + same pin — reuse path
Wall-clock = ``time.perf_counter()`` around the subprocess.run call.
The (A) seed run time is reported separately and NOT included in the
B-vs-C comparison (the reuse path's whole point is that the seed
already exists from a prior interactive run).
For each iteration the frame pin is self-discovered from the seed
run's ``step06_composition_plan.json``: the first unit's
``frame_template_id`` is re-pinned to itself, exercising the
``--override-frame`` CLI surface end-to-end without changing the
semantic frame assignment (same approach the u7a/u7b equivalence
tests already lock).
Output: a JSON document to stdout with per-iteration timings,
B/C p50 + p95, and the ratio C/B. Stderr carries the subprocess
stdout/stderr tails on non-zero exits.
Guardrails (Stage 2):
* argv-driven, no hardcoded mdx — caller picks the sample
* no hardcoded savings target — TBD until measured
* value + path + upstream provenance lives in the printed JSON
* does NOT mutate prev_run_dir; new runs land under fresh run_ids
"""
from __future__ import annotations
import argparse
import json
import statistics
import subprocess
import sys
import time
import uuid
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
RUNS_DIR = REPO_ROOT / "data" / "runs"
def _unique_run_id(prefix: str) -> str:
return f"{prefix}_imp43_u8_{uuid.uuid4().hex[:8]}"
def _spawn(extra_args: list[str], timeout: int) -> tuple[subprocess.CompletedProcess, float]:
start = time.perf_counter()
cp = subprocess.run(
[sys.executable, "-m", "src.phase_z2_pipeline", *extra_args],
capture_output=True,
text=True,
timeout=timeout,
cwd=str(REPO_ROOT),
)
return cp, time.perf_counter() - start
def _assert_ok(label: str, cp: subprocess.CompletedProcess) -> None:
if cp.returncode != 0:
sys.stderr.write(
f"[measure_reuse_savings] {label} failed rc={cp.returncode}\n"
f"--- stderr tail ---\n{cp.stderr[-2000:]}\n"
f"--- stdout tail ---\n{cp.stdout[-2000:]}\n"
)
raise SystemExit(2)
def _discover_first_frame_pin(seed_run_id: str) -> tuple[str, str]:
p = RUNS_DIR / seed_run_id / "phase_z2" / "steps" / "step06_composition_plan.json"
payload = json.loads(p.read_text(encoding="utf-8"))
for u in payload.get("data", {}).get("selected_units") or []:
sids = u.get("source_section_ids") or []
tpl = u.get("frame_template_id")
if isinstance(sids, list) and sids and isinstance(tpl, str) and tpl:
return ("+".join(str(s) for s in sids), tpl)
raise SystemExit(
f"[measure_reuse_savings] seed {seed_run_id} step06 has no pinnable "
f"(unit_id, frame_template_id); path={p}"
)
def _percentile(values: list[float], pct: float) -> float:
if not values:
return float("nan")
if len(values) == 1:
return values[0]
s = sorted(values)
k = (len(s) - 1) * pct
lo = int(k)
hi = min(lo + 1, len(s) - 1)
return s[lo] + (s[hi] - s[lo]) * (k - lo)
def main() -> int:
ap = argparse.ArgumentParser(
prog="python -m scripts.measure_reuse_savings",
description="Measure IMP-43 --reuse-from wall-clock savings.",
)
ap.add_argument("mdx_path", type=Path, help="MDX sample to measure against")
ap.add_argument("--iterations", type=int, default=3, help="trials (default 3)")
ap.add_argument("--timeout", type=int, default=900, help="per-run timeout seconds")
args = ap.parse_args()
if not args.mdx_path.is_file():
sys.stderr.write(f"[measure_reuse_savings] mdx not found: {args.mdx_path}\n")
return 2
iterations: list[dict] = []
for i in range(args.iterations):
seed_id = _unique_run_id(f"seed{i}")
cp_a, t_a = _spawn([str(args.mdx_path), seed_id], args.timeout)
_assert_ok(f"(A) seed iter={i}", cp_a)
unit_id, tpl_id = _discover_first_frame_pin(seed_id)
override = ["--override-frame", f"{unit_id}={tpl_id}"]
full_id = _unique_run_id(f"full{i}")
cp_b, t_b = _spawn([str(args.mdx_path), full_id, *override], args.timeout)
_assert_ok(f"(B) full rerun iter={i}", cp_b)
reuse_id = _unique_run_id(f"reuse{i}")
cp_c, t_c = _spawn(
[str(args.mdx_path), reuse_id, "--reuse-from", seed_id, *override],
args.timeout,
)
_assert_ok(f"(C) reuse iter={i}", cp_c)
iterations.append({
"iter": i,
"seed_run_id": seed_id,
"full_run_id": full_id,
"reuse_run_id": reuse_id,
"override_frame": f"{unit_id}={tpl_id}",
"seed_seconds": t_a,
"full_rerun_seconds": t_b,
"reuse_seconds": t_c,
})
full_times = [it["full_rerun_seconds"] for it in iterations]
reuse_times = [it["reuse_seconds"] for it in iterations]
summary = {
"mdx_path": str(args.mdx_path),
"iterations_count": len(iterations),
"full_rerun_seconds_p50": _percentile(full_times, 0.50),
"full_rerun_seconds_p95": _percentile(full_times, 0.95),
"reuse_seconds_p50": _percentile(reuse_times, 0.50),
"reuse_seconds_p95": _percentile(reuse_times, 0.95),
"reuse_over_full_ratio_p50": (
_percentile(reuse_times, 0.50) / _percentile(full_times, 0.50)
if full_times and statistics.median(full_times) > 0
else float("nan")
),
"iterations": iterations,
"note": (
"IMP-43 (#72) u8 measurement. Issue-body 5070% / 1020s → 38s "
"claim is NOT honored here — actual numbers depend on host, "
"Selenium cold-start, and AI cache state. Update "
"docs/architecture/PHASE-Z-PIPELINE-STATUS-BOARD.md §8 with the "
"p50/p95 reported here when run on the project's reference host."
),
}
sys.stdout.write(json.dumps(summary, ensure_ascii=False, indent=2))
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-16
View File
@@ -1,16 +0,0 @@
import os
import requests
token = os.environ.get("GITEA_TOKEN")
url = "https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/35/comments"
response = requests.post(
url,
headers={"Authorization": f"token {token}"},
json={"body": "Codex Test Hello from CLI"},
timeout=30,
)
print(response.status_code)
-179
View File
@@ -1,179 +0,0 @@
#!/usr/bin/env python3
"""
Phase Z run 산출물을 frontend prototype 의 client/public/data/ 로 정적 export.
Usage:
python scripts/sync_phase_z_run_to_frontend.py [run_id]
python scripts/sync_phase_z_run_to_frontend.py mdx03_f29_fix_check
python scripts/sync_phase_z_run_to_frontend.py --list # 사용 가능한 runs 목록
성격:
- Static export (Phase 1 — 보고용 read-only viewer prototype).
- 향후 Phase 2 에서 Express endpoint (server/index.ts) 로 대체 가능.
- Frontend 의 designAgentApi.ts 의 loadRun(runId) 가 본 산출물을 fetch.
복사 대상 (Phase Z runtime 결과 → frontend type 매핑에 필요한 최소 set):
root: final.html / preview.png / debug.json
steps: step01_mdx_source.md (MDX 원문)
step01_mdx_upload.json (run metadata)
step02_normalized.json (sections)
step05_v4_evidence.json (V4 후보 list)
step06_composition_plan.json (composition units)
step07_layout.json (layout preset + candidates)
step08_zone_region_ratios.json (zone/region/display)
step09_application_plan.json (적용 계획 — 핵심)
step20_slide_status.json (최종 상태)
"""
from __future__ import annotations
import argparse
import shutil
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
RUNS_DIR = PROJECT_ROOT / "data" / "runs"
FRONTEND_DATA_DIR = Path(
"D:/ad-hoc/kei/design_agent_front/design-agent/client/public/data/runs"
)
ROOT_FILES = ["final.html", "preview.png", "debug.json"]
STEP_FILES = [
"step01_mdx_source.md",
"step01_mdx_upload.json",
"step02_normalized.json",
"step05_v4_evidence.json",
"step06_composition_plan.json",
"step07_layout.json",
"step08_zone_region_ratios.json",
"step09_application_plan.json",
"step20_slide_status.json",
]
def list_runs() -> list[Path]:
"""data/runs/*/phase_z2 가 있는 run dir 목록."""
if not RUNS_DIR.exists():
return []
return sorted(
p for p in RUNS_DIR.iterdir()
if p.is_dir() and (p / "phase_z2").exists()
)
def sync_run(run_id: str, *, verbose: bool = True) -> int:
"""run_id 의 phase_z2 산출물을 frontend 로 복사. 복사된 파일 수 반환."""
src = RUNS_DIR / run_id / "phase_z2"
if not src.exists():
print(f"\n[error] source not found: {src}", file=sys.stderr)
return 0
dst = FRONTEND_DATA_DIR / run_id
dst.mkdir(parents=True, exist_ok=True)
(dst / "steps").mkdir(exist_ok=True)
copied = 0
missing: list[str] = []
for fname in ROOT_FILES:
src_f = src / fname
if src_f.exists():
shutil.copy2(src_f, dst / fname)
copied += 1
else:
missing.append(fname)
for fname in STEP_FILES:
src_f = src / "steps" / fname
if src_f.exists():
shutil.copy2(src_f, dst / "steps" / fname)
copied += 1
else:
missing.append(f"steps/{fname}")
if verbose:
print(f"\n[ok] {run_id}")
print(f" src : {src}")
print(f" dst : {dst}")
print(f" copied : {copied} files")
if missing:
print(f" missing: {len(missing)} files (산출물 없음 — 정상일 수 있음)")
for m in missing:
print(f" - {m}")
return copied
def main() -> None:
parser = argparse.ArgumentParser(
description="Phase Z run → frontend prototype 정적 export"
)
parser.add_argument(
"run_id",
nargs="?",
default=None,
help="복사할 run id. 미지정 시 가장 최근 mdx03_* run 사용.",
)
parser.add_argument(
"--list",
action="store_true",
help="사용 가능한 run 목록만 출력 후 종료.",
)
parser.add_argument(
"--all-mdx03",
action="store_true",
help="mdx03_* 로 시작하는 모든 run 복사.",
)
args = parser.parse_args()
if args.list:
runs = list_runs()
print(f"\nAvailable runs ({len(runs)}):")
for r in runs:
print(f" - {r.name}")
return
# client/public 자체는 존재해야 (frontend repo 가 정상이라는 sanity check)
frontend_public = FRONTEND_DATA_DIR.parent.parent # = client/public
if not frontend_public.exists():
print(
f"\n[error] frontend client/public 폴더 없음: {frontend_public}",
file=sys.stderr,
)
print(
" FRONTEND_DATA_DIR 경로 본 script 안에서 확인 필요.",
file=sys.stderr,
)
sys.exit(1)
# data/runs 가 없으면 자동 생성 (정적 export 첫 실행 시)
FRONTEND_DATA_DIR.mkdir(parents=True, exist_ok=True)
if args.all_mdx03:
runs = [r for r in list_runs() if r.name.startswith("mdx03_")]
print(f"\nSyncing {len(runs)} mdx03_* runs ...")
total = 0
for r in runs:
total += sync_run(r.name, verbose=True)
print(f"\n[done] total: {total} files across {len(runs)} runs")
return
if args.run_id is None:
# default: 가장 최근 mdx03_* run
candidates = [r for r in list_runs() if r.name.startswith("mdx03_")]
if not candidates:
print(
"\n[error] mdx03_* run 없음. 명시적으로 run_id 지정 필요.",
file=sys.stderr,
)
sys.exit(1)
# mtime 기준 최신
latest = max(candidates, key=lambda p: p.stat().st_mtime)
args.run_id = latest.name
print(f"\n[default] 가장 최근 mdx03_* run = {args.run_id}")
sync_run(args.run_id, verbose=True)
if __name__ == "__main__":
main()
-75
View File
@@ -1,75 +0,0 @@
"""IMP-#91 u14 — idempotent status-board marker updater.
Reads a pytest-json-report artifact emitted by the IMP-91 CI workflow and
rewrites paired ``<!-- IMP-91:<axis>:<mdx> -->...<!-- /IMP-91 -->`` markers
inside the Phase Z status board with a single-character outcome symbol.
Pure functions (``parse_outcomes`` / ``update_board_text``) are exposed so
``tests/scripts/test_update_status_board.py`` can exercise the contract
without invoking pytest. The CLI just wires file IO around them so the
GitHub Actions step in u15 can call it deterministically. The updater is
additive: untouched markers stay; missing outcomes render ``?`` so a
collection failure is loud, not silent. [[feedback_auto_pipeline_first]]
[[feedback_artifact_status_naming]]
"""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
from typing import Dict, Mapping, Tuple
AXIS_FROM_TEST = {
"test_normalize_snapshot_matches": "F0",
"test_v4_ranking_snapshot_matches": "F1",
"test_slot_payload_snapshot_matches": "F2",
"test_ai_classifier_snapshot_matches": "F3",
"test_layout_snapshot_matches": "F4",
"test_final_html_snapshot_matches": "F5",
}
SYMBOL = {"passed": "PASS", "failed": "FAIL", "error": "ERR", "skipped": "SKIP"}
NODEID_RE = re.compile(r"::(test_[a-z0-9_]+)\[(\d{2})\]$")
MARKER_RE = re.compile(
r"(<!-- IMP-91:(F[0-5]):(\d{2}) -->)(.*?)(<!-- /IMP-91 -->)", re.DOTALL
)
def parse_outcomes(report: Mapping[str, object]) -> Dict[Tuple[str, str], str]:
out: Dict[Tuple[str, str], str] = {}
for test in report.get("tests", []) or []:
m = NODEID_RE.search(str(test.get("nodeid", "")))
if not m:
continue
axis = AXIS_FROM_TEST.get(m.group(1))
if not axis:
continue
out[(axis, m.group(2))] = SYMBOL.get(str(test.get("outcome")), "?")
return out
def update_board_text(board: str, outcomes: Mapping[Tuple[str, str], str]) -> str:
def repl(match: "re.Match[str]") -> str:
key = (match.group(2), match.group(3))
symbol = outcomes.get(key, "?")
return f"{match.group(1)}{symbol}{match.group(5)}"
return MARKER_RE.sub(repl, board)
def main() -> int:
parser = argparse.ArgumentParser(description="IMP-91 status-board updater")
parser.add_argument("--report", required=True, type=Path)
parser.add_argument("--board", required=True, type=Path)
args = parser.parse_args()
report = json.loads(args.report.read_text(encoding="utf-8"))
outcomes = parse_outcomes(report)
args.board.write_text(
update_board_text(args.board.read_text(encoding="utf-8"), outcomes),
encoding="utf-8",
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -17,7 +17,7 @@ class Settings(BaseSettings):
# IMP-33 u1 — AI fallback policy. Fallback-path only; normal path AI=0. # IMP-33 u1 — AI fallback policy. Fallback-path only; normal path AI=0.
# Defaults locked by Stage 2 plan; do NOT inline literals downstream. # Defaults locked by Stage 2 plan; do NOT inline literals downstream.
ai_fallback_enabled: bool = False ai_fallback_enabled: bool = False
ai_fallback_model: str = "claude-opus-4-7" ai_fallback_model: str = "claude-opus-4-6-20250415"
ai_fallback_timeout_s: float = 60.0 ai_fallback_timeout_s: float = 60.0
ai_fallback_max_retries: int = 3 ai_fallback_max_retries: int = 3
ai_fallback_backoff_base_s: float = 1.0 ai_fallback_backoff_base_s: float = 1.0
-264
View File
@@ -1,264 +0,0 @@
"""IMP-51 (#79) u4 — user-content image stamper for Phase Z final.html.
Annotates user-content ``<img>`` elements with a stable id + role
attribute so the frontend SlideCanvas (u8~u11) can attach drag/resize
handles and the backend CSS injector (u7) can re-apply persisted geometry
on the next render.
DOM selector contract (single point of truth shared across the axis) :
.slide img[data-image-role="user-content"]
This selector is mirrored verbatim in :
- ``Front/client/src/components/SlideCanvas.tsx`` (u8 handle attach target)
- ``Front/client/src/services/userOverridesApi.ts`` (u3 doc reference)
- ``src/phase_z2_pipeline.py`` u7 hook (CSS injector — pending unit)
Decorative imgs (frame backgrounds, figma assets, dx-figures, decorative
icons) are NOT stamped, so they are NOT matched by the selector and remain
unaffected. The allowlist that decides "what counts as user-content" is
passed in by the caller (typically ``stage0_normalized_assets["images"]``);
this module does not encode the source-of-truth itself.
Stable id contract :
image_id = "img-" + sha1(src)[:10]
Deterministic across renders so persisted ``image_overrides`` entries
(keyed on ``image_id`` per ``src/user_overrides_io.py`` u1) re-apply
automatically. Duplicate srcs in the same slide get an ordinal suffix
("-1", "-2", ...) appended in DOM order; the first occurrence has no
suffix.
Forward-compat : current Phase Z final.html emits zero user-content
``<img>`` elements (``stage0_normalized_assets["images"]`` is empty across
all recent verify runs). ``stamp_user_content_images(html, sources=())``
is a pure no-op in that case — returns ``(html, [])`` without scanning.
Guardrails :
- No-hardcoding : the allowlist is caller-supplied, never inferred from
sample filenames or path heuristics.
- Idempotent : stamping a previously-stamped tag is a no-op (the
``data-image-role`` probe short-circuits before re-injecting).
- AI-isolation : this module is pure deterministic Python; no LLM calls.
- Carve-out (IMP-46 #62) : brand-new module, does not touch the
#76 commit ``1186ad8`` cache region.
"""
from __future__ import annotations
import hashlib
import re
from typing import Iterable
USER_CONTENT_IMAGE_SELECTOR: str = '.slide img[data-image-role="user-content"]'
IMAGE_ROLE_ATTR: str = "data-image-role"
IMAGE_ROLE_VALUE: str = "user-content"
IMAGE_ID_ATTR: str = "data-image-id"
# Matches a single ``<img ...>`` tag. Permissive on attribute order and
# whitespace; captures the inner attribute string + an optional XHTML
# self-close slash. Phase Z renders well-formed Jinja2 output (no inline
# ``<`` in attribute values), so a regex is safe here without pulling in
# an HTML parser.
_IMG_TAG_RE = re.compile(
r"<img\b([^>]*?)(/?)>",
flags=re.IGNORECASE | re.DOTALL,
)
# Matches the ``src="..."`` or ``src='...'`` attribute. Group 1 = double,
# group 2 = single. Quote style is preserved by callers that re-emit the
# tag verbatim.
_SRC_ATTR_RE = re.compile(
r"""\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')""",
flags=re.IGNORECASE,
)
# Probe for an existing ``data-image-role`` attribute (any value, any
# quote) so re-stamping is idempotent.
_ROLE_ATTR_RE = re.compile(r"""\bdata-image-role\s*=""", flags=re.IGNORECASE)
def stable_image_id(src: str, ordinal: int = 0) -> str:
"""Return the deterministic ``image_id`` for ``src``.
``ordinal`` disambiguates repeated occurrences of the same ``src`` in
the same slide (0 = first occurrence, no suffix; 1 → ``-1``; ...).
"""
if not isinstance(src, str):
raise TypeError(f"src must be a string, got {type(src).__name__}: {src!r}")
if ordinal < 0:
raise ValueError(f"ordinal must be >= 0, got {ordinal}")
digest = hashlib.sha1(src.encode("utf-8")).hexdigest()[:10]
base = f"img-{digest}"
return base if ordinal == 0 else f"{base}-{ordinal}"
def stamp_user_content_images(
html: str,
sources: Iterable[str] = (),
) -> tuple[str, list[str]]:
"""Stamp user-content ``<img>`` tags in ``html`` with role + stable id.
``sources`` is the allowlist of ``src`` attribute values that count as
user-content (typically ``stage0_normalized_assets["images"]``). Any
``<img>`` whose ``src`` value is in ``sources`` is rewritten to include
``data-image-role="user-content"`` and ``data-image-id="<stable_id>"``.
Other ``<img>`` tags (decorative, figma, frame-internal) are left
unchanged byte-for-byte.
Returns ``(modified_html, stamped_image_ids)`` where the id list is
in DOM (left-to-right) order. The list may contain duplicates only
via the ordinal-suffix path (``img-<hash>``, ``img-<hash>-1``, ...);
ordering is what the caller persists as the canonical key sequence.
Forward-compat : empty / all-non-string ``sources`` → pure no-op
(``html`` returned unchanged, empty list). This is the current Phase
Z state since ``stage0_normalized_assets["images"]`` is empty.
"""
allow = {s for s in sources if isinstance(s, str) and s}
if not allow:
return html, []
stamped: list[str] = []
seen_ordinal: dict[str, int] = {}
def _replace(match: re.Match[str]) -> str:
attrs = match.group(1) or ""
self_close = match.group(2) or ""
src_match = _SRC_ATTR_RE.search(attrs)
if src_match is None:
return match.group(0)
src = src_match.group(1) if src_match.group(1) is not None else src_match.group(2)
if src not in allow:
return match.group(0)
if _ROLE_ATTR_RE.search(attrs):
return match.group(0)
ordinal = seen_ordinal.get(src, 0)
seen_ordinal[src] = ordinal + 1
image_id = stable_image_id(src, ordinal=ordinal)
stamped.append(image_id)
injected = (
f' {IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"'
f' {IMAGE_ID_ATTR}="{image_id}"'
)
return f"<img{injected}{attrs}{self_close}>"
new_html = _IMG_TAG_RE.sub(_replace, html)
return new_html, stamped
# ─── IMP-51 (#79) u7 — render-time CSS injection ──────────────────────────
# Marker comments wrap the injected ``<style>`` block so re-injection on a
# previously-injected document is idempotent (the wrapper is found by a
# simple substring probe and the inner CSS is replaced in place).
_IMP51_STYLE_MARKER_OPEN: str = "<!-- IMP-51 image_overrides start -->"
_IMP51_STYLE_MARKER_CLOSE: str = "<!-- IMP-51 image_overrides end -->"
_IMP51_STYLE_BLOCK_RE = re.compile(
re.escape(_IMP51_STYLE_MARKER_OPEN) + r".*?" + re.escape(_IMP51_STYLE_MARKER_CLOSE),
flags=re.DOTALL,
)
_HEAD_CLOSE_RE = re.compile(r"</head\s*>", flags=re.IGNORECASE)
_BODY_OPEN_RE = re.compile(r"<body\b[^>]*>", flags=re.IGNORECASE)
def build_image_overrides_style(
image_overrides: dict,
stamped_ids: Iterable[str],
) -> str:
"""Build CSS rule text for persisted ``image_overrides``.
For every ``image_id`` that appears in BOTH ``stamped_ids`` (the DOM
order of stamps returned by :func:`stamp_user_content_images`) AND
``image_overrides`` (the persisted geometry mapping from ``u1``
``user_overrides_io``), emit one absolute-position rule of the form ::
.slide img[data-image-role="user-content"][data-image-id="<id>"] {
position: absolute;
left: <x>%; top: <y>%;
width: <w>%; height: <h>%;
}
Coordinates are ``%`` of the slide bounding box (slide-absolute, per
Stage 2 scope-lock). ``.slide`` already declares ``position: relative``
in ``templates/phase_z2/slide_base.html`` so the absolute coordinates
resolve against the slide frame.
Rules are emitted in ``stamped_ids`` order so the output is
byte-deterministic across renders (critical for diff-based verifiers).
Override entries for ids NOT in ``stamped_ids`` are silently dropped —
those keys cannot be produced via the SlideCanvas pathway (the
frontend only knows the ids actually present in the DOM). Per-entry
malformed geometries (non-dict / missing axis / non-coercible value)
are dropped silently; the whole batch is never rejected.
Returns ``""`` when no rules are emitted so the caller can skip
``<style>`` injection entirely (forward-compat no-op when Phase Z
final.html still emits zero user-content imgs).
"""
if not image_overrides:
return ""
rules: list[str] = []
for iid in stamped_ids:
geom = image_overrides.get(iid)
if not isinstance(geom, dict):
continue
try:
x = float(geom["x"])
y = float(geom["y"])
w = float(geom["w"])
h = float(geom["h"])
except (KeyError, TypeError, ValueError):
continue
rules.append(
f'.slide img[{IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"]'
f'[{IMAGE_ID_ATTR}="{iid}"] {{ '
f"position: absolute; "
f"left: {x}%; top: {y}%; "
f"width: {w}%; height: {h}%; "
f"}}"
)
return "\n".join(rules)
def inject_image_overrides_style(html: str, css: str) -> str:
"""Inject a marker-wrapped ``<style>`` block carrying ``css`` into ``html``.
Empty ``css`` → ``html`` returned unchanged (no DOM mutation). This
preserves the byte-for-byte identity of forward-compat renders where
no overrides apply.
When a previously-injected marker block is present, its inner CSS is
replaced in place (idempotent re-injection — second call with the
same overrides produces an identical document).
Injection precedence when no existing marker is found :
1. Before the first ``</head>`` (case-insensitive)
2. Immediately after the first ``<body ...>`` open tag
3. At the start of the document
Phase Z ``slide_base.html`` always emits ``</head>`` so path 1 wins
for production renders; paths 2/3 are defensive fallbacks for
unusual fragment inputs (tests, partials).
"""
if not css:
return html
block = (
f"{_IMP51_STYLE_MARKER_OPEN}\n"
f"<style>\n{css}\n</style>\n"
f"{_IMP51_STYLE_MARKER_CLOSE}"
)
if _IMP51_STYLE_MARKER_OPEN in html:
return _IMP51_STYLE_BLOCK_RE.sub(lambda _m: block, html, count=1)
head_close = _HEAD_CLOSE_RE.search(html)
if head_close is not None:
idx = head_close.start()
return html[:idx] + block + "\n" + html[idx:]
body_open = _BODY_OPEN_RE.search(html)
if body_open is not None:
idx = body_open.end()
return html[:idx] + "\n" + block + html[idx:]
return block + "\n" + html
-29
View File
@@ -392,32 +392,6 @@ def _clean_text(text: str) -> str:
# 메인 함수 # 메인 함수
# ══════════════════════════════════════ # ══════════════════════════════════════
def _extract_slide_overrides(metadata: dict[str, Any]) -> dict[str, Any]:
"""Surface the nested ``slide_overrides`` mapping from frontmatter.
IMP-45 (#74) u2 — slide-level CSS override axis intake. Returns a
plain ``dict`` so callers (Step 13 injector) can read
``slide_overrides.get("css")`` without re-parsing frontmatter.
Rules:
- Absent or non-mapping → ``{}``.
- Inside the mapping, ``css`` is kept only when it is a ``str``
(non-string values dropped to fail-closed against typo'd YAML
shapes such as ``css: [".x{}"]``).
- Unknown sibling keys (e.g., future ``slide_overrides.js``) are
preserved verbatim — generalization deferred per Stage 2 scope.
"""
raw = metadata.get("slide_overrides")
if not isinstance(raw, dict):
return {}
out: dict[str, Any] = {}
for k, v in raw.items():
if k == "css" and not isinstance(v, str):
continue
out[k] = v
return out
def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]: def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
"""MDX 원본을 4-Layer 파서로 정규화. """MDX 원본을 4-Layer 파서로 정규화.
@@ -431,13 +405,11 @@ def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
"popups": [{"title": str, "content": str}], "popups": [{"title": str, "content": str}],
"tables": [{"headers": list, "rows": list}], "tables": [{"headers": list, "rows": list}],
"sections": [{"level": int, "title": str, "content": str}], "sections": [{"level": int, "title": str, "content": str}],
"slide_overrides": {"css": str, ...} | {},
} }
""" """
# ── Layer 1: frontmatter 분리 ── # ── Layer 1: frontmatter 분리 ──
metadata, body = frontmatter.parse(raw_mdx) metadata, body = frontmatter.parse(raw_mdx)
title = metadata.get("title", "") title = metadata.get("title", "")
slide_overrides = _extract_slide_overrides(metadata)
logger.info(f"[Layer 1] title='{title}', metadata keys={list(metadata.keys())}") logger.info(f"[Layer 1] title='{title}', metadata keys={list(metadata.keys())}")
# ── Layer 2: 코드블록 보호 → MDX 패턴 처리 ── # ── Layer 2: 코드블록 보호 → MDX 패턴 처리 ──
@@ -465,7 +437,6 @@ def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
"popups": popups, "popups": popups,
"tables": tables, "tables": tables,
"sections": sections, "sections": sections,
"slide_overrides": slide_overrides,
} }

Some files were not shown because too many files have changed in this diff Show More