Compare commits
37
Commits
13a8d752d3
..
main
+82
-1
@@ -400,7 +400,88 @@
|
||||
"Read(//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_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": [
|
||||
"d:\\ad-hoc\\kei\\design_agent\\templates\\blocks\\new",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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
@@ -16,3 +16,16 @@ data/frame_cache/*
|
||||
|
||||
# session workspace (push X — 작업 흐름 trace, 사용자 결정 2026-05-08)
|
||||
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/
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,927 @@
|
||||
<!-- 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.
|
After Width: | Height: | Size: 280 KiB |
@@ -0,0 +1,89 @@
|
||||
---
|
||||
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는 필요한 요건과 체계를 갖춘 후 시행해야만 그 효과를 기대할 수 있다.**
|
||||
:::
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"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)."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"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
@@ -0,0 +1,992 @@
|
||||
<!-- 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.
|
After Width: | Height: | Size: 299 KiB |
@@ -0,0 +1,89 @@
|
||||
---
|
||||
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 지연을 반복시키고 있다.
|
||||
:::
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,893 @@
|
||||
{
|
||||
"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)."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"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 됐지만 일부 콘텐츠 손실)."
|
||||
}
|
||||
}
|
||||
@@ -1,99 +1,200 @@
|
||||
/**
|
||||
* BottomActions - 하단 액션 버튼 영역
|
||||
* BottomActions — Step 22 footer wire-up (IMP-56 #90 u20).
|
||||
*
|
||||
* 생성하기, 다운로드, 연동하기 버튼 컴포넌트
|
||||
* 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 { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import type { SlidePlan, UserSelection } from "../types/designAgent";
|
||||
import { serializeSlidePlan } from "../utils/slidePlanUtils";
|
||||
import type { SlidePlan } from "../types/designAgent";
|
||||
import type { RunMeta } from "../services/designAgentApi";
|
||||
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 {
|
||||
slidePlan: SlidePlan | null;
|
||||
userSelection: UserSelection;
|
||||
runMeta: RunMeta | null;
|
||||
uploadedFile: File | null;
|
||||
isLoading: boolean;
|
||||
onGenerate: () => void;
|
||||
}
|
||||
|
||||
export default function BottomActions({
|
||||
slidePlan,
|
||||
userSelection,
|
||||
runMeta,
|
||||
uploadedFile,
|
||||
isLoading,
|
||||
onGenerate,
|
||||
}: BottomActionsProps) {
|
||||
const handleDownload = () => {
|
||||
if (!slidePlan) {
|
||||
toast.error("슬라이드 플랜이 없습니다. 먼저 생성하기를 눌러주세요.");
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const runReady = !!runMeta && !!slidePlan;
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!runMeta) {
|
||||
toast.error("Run 산출물이 없습니다. 먼저 생성하기를 눌러주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const json = serializeSlidePlan(slidePlan, userSelection);
|
||||
console.log("[Download] SlidePlan JSON:", json);
|
||||
|
||||
// JSON 파일 다운로드
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `slide-plan-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success("SlidePlan JSON이 다운로드되었습니다.");
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const exportReq = buildExportRequest(runMeta.run_id);
|
||||
const resp = await fetch(exportReq.url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: exportReq.body,
|
||||
});
|
||||
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");
|
||||
a.href = objectUrl;
|
||||
a.download = buildDownloadFilename(runMeta.run_id);
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
toast.success(`standalone HTML 다운로드 — ${runMeta.run_id}.html`);
|
||||
} catch (err) {
|
||||
toast.error(`Export 네트워크 오류: ${(err as Error).message}`);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = () => {
|
||||
toast.info("연동하기 기능은 파이프라인 연결 후 활성화됩니다.");
|
||||
const handleConnect = async () => {
|
||||
if (!runMeta) {
|
||||
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 (
|
||||
<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>
|
||||
{/* 생성하기 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={onGenerate}
|
||||
disabled={isLoading}
|
||||
className="gap-2 min-w-[120px]"
|
||||
className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest bg-slate-900 hover:bg-slate-800"
|
||||
size="default"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
생성 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
생성하기
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 다운로드 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDownload}
|
||||
disabled={!slidePlan || isLoading}
|
||||
className="gap-2 min-w-[120px]"
|
||||
onClick={handleExport}
|
||||
disabled={!runReady || isExporting || isLoading}
|
||||
className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"
|
||||
size="default"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{isExporting ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
)}
|
||||
다운로드
|
||||
</Button>
|
||||
|
||||
{/* 연동하기 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleConnect}
|
||||
className="gap-2 min-w-[120px] text-slate-500"
|
||||
disabled={!runReady || isConnecting || isLoading}
|
||||
className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"
|
||||
size="default"
|
||||
>
|
||||
<Link2 className="w-4 h-4" />
|
||||
{isConnecting ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Link2 className="w-3.5 h-3.5" />
|
||||
)}
|
||||
연동하기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,19 @@ interface FramePanelProps {
|
||||
onNoDesignToggle: () => void;
|
||||
}
|
||||
|
||||
// IMP-#84 u1 — silent-automation contract: frame selection delegates directly
|
||||
// to onFrameSelect for every V4 label (use_as_is / light_edit / restructure /
|
||||
// reject). Prior IMP-47B u11 surfaced a window.confirm popup on reject; that
|
||||
// popup is informational UI noise per `feedback_auto_pipeline_first` and is
|
||||
// removed. Frame identity is preserved on reject (AI 재구성 = content-only,
|
||||
// per AI 격리 contract); the popup never gated that contract.
|
||||
export function applyFrameSelection(
|
||||
candidate: FrameCandidate,
|
||||
onFrameSelect: (frameId: string) => void,
|
||||
): void {
|
||||
onFrameSelect(candidate.id);
|
||||
}
|
||||
|
||||
export default function FramePanel({
|
||||
slidePlan,
|
||||
selectedZone,
|
||||
@@ -49,17 +62,9 @@ export default function FramePanel({
|
||||
|
||||
const handleFrameSelect = React.useCallback(
|
||||
(candidate: FrameCandidate) => {
|
||||
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);
|
||||
applyFrameSelection(candidate, onFrameSelect);
|
||||
},
|
||||
[currentFrameId, onFrameSelect],
|
||||
[onFrameSelect],
|
||||
);
|
||||
|
||||
if (!selectedZone) {
|
||||
@@ -94,6 +99,9 @@ export default function FramePanel({
|
||||
{candidates.length === 0 ? (
|
||||
<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="mt-2 px-4 text-[10px] leading-relaxed text-slate-400">
|
||||
backend candidate pool is empty for {assignedSectionIds.join(", ") || selectedZone.zone_id}.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
candidates.map((candidate, index) => {
|
||||
@@ -115,6 +123,7 @@ export default function FramePanel({
|
||||
candidate.routeHint && candidate.routeHint !== "direct_render";
|
||||
const showStatusChip =
|
||||
candidate.phaseZStatus && candidate.phaseZStatus !== "auto_renderable";
|
||||
const showCoverageChip = Boolean(candidate.coverageState);
|
||||
const hasCapacityFit =
|
||||
candidate.capacityFit && candidate.capacityFit.fit_status;
|
||||
const capacityMismatch =
|
||||
@@ -127,6 +136,10 @@ export default function FramePanel({
|
||||
if (candidate.routeHint) evidenceLines.push(`route: ${candidate.routeHint}`);
|
||||
if (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) {
|
||||
const cf = candidate.capacityFit!;
|
||||
const capacityLine =
|
||||
@@ -274,6 +287,28 @@ export default function FramePanel({
|
||||
{candidate.label}
|
||||
</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). */}
|
||||
{showRouteChip && (
|
||||
<span
|
||||
|
||||
@@ -34,12 +34,14 @@ interface LeftMdxPanelProps {
|
||||
onFileUpload: (file: File) => void;
|
||||
onGenerate: () => void;
|
||||
onSectionClick: (sectionId: string) => void;
|
||||
/** 사용자 lock 2026-05-14 — 좌측 패널에 03/04/05 fix 고정 list. 클릭 시 callback. */
|
||||
onSelectSample?: (which: "03" | "04" | "05") => void;
|
||||
selectedSample?: "03" | "04" | "05" | null;
|
||||
/** 사용자 lock 2026-05-14 — 좌측 패널에 01~05 fix 고정 list. 클릭 시 callback. */
|
||||
onSelectSample?: (which: "01" | "02" | "03" | "04" | "05") => void;
|
||||
selectedSample?: "01" | "02" | "03" | "04" | "05" | null;
|
||||
}
|
||||
|
||||
const SAMPLE_MDX_LIST: { key: "03" | "04" | "05"; label: string; subtitle: string }[] = [
|
||||
const SAMPLE_MDX_LIST: { key: "01" | "02" | "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: "04", label: "04. DX 지연 요인", subtitle: "DX 인식 + 정책/조직 실태" },
|
||||
{ key: "05", label: "05. 설계 방식의 왜곡", subtitle: "설계 자동화 오용 + S/W 한계" },
|
||||
@@ -74,7 +76,7 @@ export default function LeftMdxPanel({
|
||||
MDX Source
|
||||
</h2>
|
||||
|
||||
{/* 2026-05-14 — 03/04/05 fix 고정 list. 클릭 시 해당 mdx 자동 fetch + 분석.
|
||||
{/* 2026-05-14 — 01~05 fix 고정 list. 클릭 시 해당 mdx 자동 fetch + 분석.
|
||||
frame/layout override 는 분석 후 우측 패널에서 가능. */}
|
||||
{onSelectSample && (
|
||||
<div className="space-y-1 mb-3">
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,12 @@ import {
|
||||
crossedDragThreshold,
|
||||
type ImageDragDirection,
|
||||
} from "./slideCanvasDragMath";
|
||||
import type { ImageOverridesOverride } from "../services/userOverridesApi";
|
||||
import type {
|
||||
ImageOverridesOverride,
|
||||
StructureOverridesOverride,
|
||||
StructureOverridePerZone,
|
||||
} from "../services/userOverridesApi";
|
||||
import StructureEditOverlay from "./StructureEditOverlay";
|
||||
|
||||
interface SlideCanvasProps {
|
||||
slidePlan: SlidePlan | null;
|
||||
@@ -36,10 +41,6 @@ interface SlideCanvasProps {
|
||||
userSelection: UserSelection;
|
||||
/** Phase Z 가 만든 final.html URL (iframe 으로 표시). */
|
||||
finalHtmlUrl?: string;
|
||||
/** 슬라이드 단위 inline CSS override (catalog/template 무변, iframe contentDocument 에
|
||||
* 동적 inject). Home 이 mdx 별 default visual 보완 등을 지정. 빈 문자열/undefined =
|
||||
* inject 안 함. 사용자 lock 2026-05-14 — slide-level only. */
|
||||
slideOverrideCss?: string;
|
||||
/** 파이프라인 실행 중 표시 (loading state). */
|
||||
isPipelineRunning?: boolean;
|
||||
/** Phase 2 : pending layout 모드 — final.html iframe 숨기고 빈 layout zone 만 표시. */
|
||||
@@ -59,16 +60,16 @@ interface SlideCanvasProps {
|
||||
onZoneResize?: (
|
||||
geometries: Record<string, { x: number; y: number; w: number; h: number }>
|
||||
) => void;
|
||||
/** IMP-51 (#79) u8 — persisted slide-absolute image geometries
|
||||
* (image_id → {x,y,w,h} as percent of 1280×720, range 0–100). Mirrors
|
||||
/** IMP-51 (#79) u8 ??persisted slide-absolute image geometries
|
||||
* (image_id ??{x,y,w,h} as percent of 1280×720, range 0??00). Mirrors
|
||||
* the u3 typed-client `ImageOverride` contract and the u7 stamper that
|
||||
* emits CSS `left/top/width/height: {value}%`. Forward-compat optional;
|
||||
* u11 wires this from `userSelection.overrides.image_overrides`. When
|
||||
* present, SlideCanvas displays the persisted geometry instead of the
|
||||
* iframe-measured baseline. */
|
||||
imageOverrides?: ImageOverridesOverride;
|
||||
/** IMP-51 (#79) u8 — emitted when the user drags or resizes a stamped
|
||||
* user-content image. Geometry is slide-absolute percent (0–100 of
|
||||
/** IMP-51 (#79) u8 ??emitted when the user drags or resizes a stamped
|
||||
* user-content image. Geometry is slide-absolute percent (0??00 of
|
||||
* 1280×720), matching the persisted axis schema (u3 typed client) and
|
||||
* the u7 CSS injection that writes the values directly into
|
||||
* `left/top/width/height: {value}%`. u10 wires this to a persistence
|
||||
@@ -77,16 +78,116 @@ interface SlideCanvasProps {
|
||||
imageId: string,
|
||||
geometry: { x: number; y: number; w: number; h: number }
|
||||
) => void;
|
||||
/** IMP-90 (#90) u13 ??focusout-emitted capture; u15 debounces + PUTs. */
|
||||
onTextEdit?: (capture: TextEditCapture) => void;
|
||||
/** IMP-90 (#90) u14 ??persisted structure overrides per zone
|
||||
* (slot_order + hidden_slots). When `editMode === "structure"` the
|
||||
* StructureEditOverlay reads from this to render the current state. */
|
||||
structureOverrides?: StructureOverridesOverride;
|
||||
/** IMP-90 (#90) u14 ??emitted whenever the user reorders or hides a
|
||||
* slot in structure-mode. u15 will debounce + PUT to /api/user-
|
||||
* overrides; u14 only exposes the capture. SCOPE LOCK: inner shape is
|
||||
* `{slot_order, hidden_slots}` only (frame swap stays on `frames` axis). */
|
||||
onStructureEdit?: (zoneId: string, capture: StructureOverridePerZone) => void;
|
||||
}
|
||||
|
||||
const SLIDE_W = 1280;
|
||||
const SLIDE_H = 720;
|
||||
|
||||
// IMP-90 (#90) u11 ??discriminated edit mode. Replaces the prior single
|
||||
// `isEditMode` boolean. u11 introduces the enum + the toolbar UI surface;
|
||||
// gesture gating (text contentEditable vs structure reorder vs image-zone
|
||||
// drag/resize) stays unified behind `isEditMode = editMode !== 'off'` so
|
||||
// existing behavior is preserved byte-identical. u12 will discriminate the
|
||||
// gestures per mode (mutually exclusive). The 'off' state is the no-edit
|
||||
// baseline; 'image-zone' bundles image edit (#79) + zone resize (#81)
|
||||
// because both are pointer-driven canvas gestures on slide geometry.
|
||||
export type EditMode = "off" | "text" | "structure" | "image-zone";
|
||||
export const EDIT_MODES: ReadonlyArray<EditMode> = ["text", "structure", "image-zone"];
|
||||
/** Pure helper ??given the current edit mode and the user's requested mode,
|
||||
* return the next mode. Clicking the active mode toggles back to 'off';
|
||||
* clicking a different mode switches; explicit 'off' always exits. */
|
||||
export function nextEditMode(current: EditMode, requested: EditMode): EditMode {
|
||||
if (requested === "off") return "off";
|
||||
return current === requested ? "off" : requested;
|
||||
}
|
||||
|
||||
// IMP-90 (#90) u12 ??per-mode gesture gating. Pure helper deriving the
|
||||
// boolean gates that drive SlideCanvas's useEffect branches (designMode
|
||||
// + iframe-side image click listener) and JSX conditionals (iframe
|
||||
// pointer-events, zone resize/drag affordances, image overlay). The
|
||||
// mapping enforces the mutually-exclusive contract from the issue body:
|
||||
// text -> contentEditable + iframe pointer-events:auto only.
|
||||
// structure -> nothing here; u14 will plant the structure overlay.
|
||||
// image-zone -> zone resize/drag + image overlay; iframe pe:auto so
|
||||
// in-iframe user-content images can be click-selected.
|
||||
// off -> every gate false (baseline).
|
||||
// pendingLayout fully suppresses every gate ??mirrors the existing
|
||||
// useEffect (line ~248) that forces editMode='off' on pendingLayout
|
||||
// entry. The helper still defensively returns all-false so a stray
|
||||
// pendingLayout=true with a non-'off' editMode never leaks gestures.
|
||||
export interface EditModeGates {
|
||||
textEditing: boolean;
|
||||
imageSelection: boolean;
|
||||
iframePointerAuto: boolean;
|
||||
zoneGestures: boolean;
|
||||
imageOverlay: boolean;
|
||||
}
|
||||
export function computeEditModeGates(
|
||||
editMode: EditMode,
|
||||
isPendingLayout: boolean
|
||||
): EditModeGates {
|
||||
if (isPendingLayout) {
|
||||
return {
|
||||
textEditing: false,
|
||||
imageSelection: false,
|
||||
iframePointerAuto: false,
|
||||
zoneGestures: false,
|
||||
imageOverlay: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
textEditing: editMode === "text",
|
||||
imageSelection: editMode === "image-zone",
|
||||
iframePointerAuto: editMode === "text" || editMode === "image-zone",
|
||||
zoneGestures: editMode === "image-zone",
|
||||
imageOverlay: editMode === "image-zone",
|
||||
};
|
||||
}
|
||||
|
||||
// IMP-90 (#90) u13 ??pure helper resolving a contentEditable focusout
|
||||
// target into (zoneId, textPath, value). data-text-path stamped by u8 at
|
||||
// Step 13; .zone[data-zone-position] from Phase Z slide-base. Non-stamped
|
||||
// targets return null so capture silently skips. u15 will debounce + PUT.
|
||||
export interface TextEditCaptureTarget {
|
||||
closest(selector: string): TextEditCaptureTarget | null;
|
||||
getAttribute(name: string): string | null;
|
||||
textContent: string | null;
|
||||
}
|
||||
export interface TextEditCapture {
|
||||
zoneId: string;
|
||||
textPath: string;
|
||||
value: string;
|
||||
}
|
||||
export function deriveTextEditCapture(
|
||||
target: TextEditCaptureTarget | null
|
||||
): TextEditCapture | null {
|
||||
if (!target) return null;
|
||||
const lineEl = target.closest("[data-text-path]");
|
||||
if (!lineEl) return null;
|
||||
const textPath = lineEl.getAttribute("data-text-path");
|
||||
if (!textPath) return null;
|
||||
const zoneEl = lineEl.closest(".zone[data-zone-position]");
|
||||
if (!zoneEl) return null;
|
||||
const zoneId = zoneEl.getAttribute("data-zone-position");
|
||||
if (!zoneId) return null;
|
||||
return { zoneId, textPath, value: (lineEl.textContent ?? "").trim() };
|
||||
}
|
||||
|
||||
export default function SlideCanvas({
|
||||
slidePlan,
|
||||
userSelection,
|
||||
finalHtmlUrl,
|
||||
slideOverrideCss,
|
||||
isPipelineRunning,
|
||||
isPendingLayout,
|
||||
pendingLayoutId,
|
||||
@@ -98,6 +199,9 @@ export default function SlideCanvas({
|
||||
onZoneResize,
|
||||
imageOverrides,
|
||||
onImageResize,
|
||||
onTextEdit,
|
||||
structureOverrides,
|
||||
onStructureEdit,
|
||||
}: SlideCanvasProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
@@ -119,8 +223,8 @@ export default function SlideCanvas({
|
||||
// Step B : section drag-drop drop target. 사용자가 LeftMdxPanel 의 section 카드
|
||||
// 를 drag 해서 zone 에 drop 시 그 zone 에 section 할당. dragOver 시 강조 표시.
|
||||
const [dragOverZoneId, setDragOverZoneId] = useState<string | null>(null);
|
||||
// IMP-51 (#79) u8 — measured user-content image bboxes inside iframe
|
||||
// (slide-absolute percent of 1280×720, range 0–100). key = data-image-id
|
||||
// IMP-51 (#79) u8 ??measured user-content image bboxes inside iframe
|
||||
// (slide-absolute percent of 1280×720, range 0??00). key = data-image-id
|
||||
// stamped by u4 (`src/image_id_stamper.py`). Populated in the iframe
|
||||
// onLoad measure block alongside measuredZones / measuredSlideBody.
|
||||
// Units intentionally match the persisted `image_overrides` axis (u3
|
||||
@@ -131,7 +235,7 @@ export default function SlideCanvas({
|
||||
const [measuredImages, setMeasuredImages] = useState<
|
||||
Record<string, { x: number; y: number; w: number; h: number }>
|
||||
>({});
|
||||
// IMP-51 (#79) u8 — currently selected user-content image id (= the one
|
||||
// IMP-51 (#79) u8 ??currently selected user-content image id (= the one
|
||||
// whose drag/resize handles are shown). Set by the click-listener
|
||||
// installed inside the iframe contentDocument when edit mode is active.
|
||||
// Reset on finalHtmlUrl change and on edit-mode exit so stale ids never
|
||||
@@ -140,7 +244,15 @@ export default function SlideCanvas({
|
||||
// HTML 편집 모드 — 글벗 패턴 (designMode + contentEditable + outline CSS) 차용.
|
||||
// 활성 시 iframe 안 텍스트 element 직접 클릭하여 수정 가능. backend 반영은 별 작업.
|
||||
// pendingLayout 과 배타적 (충돌 방지).
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
// IMP-90 (#90) u11 ??`editMode` enum replaces the prior boolean. The
|
||||
// `isEditMode` shim is kept ONLY for the pendingLayout coupling +
|
||||
// zone-wrapper visual cues (border / hover / selected styling) that
|
||||
// fire whenever any edit mode is active. u12 routes gesture-activating
|
||||
// gates through `editGates` so text / structure / image-zone gestures
|
||||
// are mutually exclusive.
|
||||
const [editMode, setEditMode] = useState<EditMode>("off");
|
||||
const isEditMode = editMode !== "off";
|
||||
const editGates = computeEditModeGates(editMode, !!isPendingLayout);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// 편집 모드 toggle 시 iframe contentDocument 에 글벗 패턴 적용 / 해제.
|
||||
@@ -164,11 +276,22 @@ export default function SlideCanvas({
|
||||
|
||||
const editableTags = ["DIV", "P", "H1", "H2", "H3", "H4", "SPAN", "LI", "TD", "TH", "FIGCAPTION"];
|
||||
let inputHandler: ((e: Event) => void) | null = null;
|
||||
// IMP-51 (#79) u8 — user-content image click listeners installed
|
||||
// IMP-90 (#90) u13 ??focusout (= bubbling blur) emits one capture per
|
||||
// finished line edit; u15 will debounce + PUT.
|
||||
let textEditCaptureHandler: ((e: Event) => void) | null = null;
|
||||
// IMP-51 (#79) u8 ??user-content image click listeners installed
|
||||
// inside the iframe contentDocument. Tracked here so the cleanup
|
||||
// callback can remove them when edit mode exits (or iframe reloads).
|
||||
const imageClickBindings: Array<{ el: HTMLImageElement; handler: (e: Event) => void; prevCursor: string; prevOutline: string }> = [];
|
||||
if (isEditMode) {
|
||||
|
||||
// IMP-90 (#90) u12 ??text-editing gate: only the 'text' editMode
|
||||
// turns designMode on + makes the editable tags contentEditable.
|
||||
// The else branch tears the prior state down so leaving text mode
|
||||
// (to structure / image-zone / off) immediately disables in-place
|
||||
// text editing ??required for mutual exclusivity vs the image-zone
|
||||
// overlay's drag/resize gestures (a contentEditable cursor would
|
||||
// otherwise be placed by every image click).
|
||||
if (editGates.textEditing) {
|
||||
doc.designMode = "on";
|
||||
doc.querySelectorAll(".slide *").forEach((el) => {
|
||||
if (editableTags.includes((el as HTMLElement).tagName)) {
|
||||
@@ -181,11 +304,28 @@ export default function SlideCanvas({
|
||||
};
|
||||
doc.addEventListener("input", inputHandler);
|
||||
|
||||
// IMP-51 (#79) u8 — wire click → selectedImageId on every stamped
|
||||
// user-content image. Selector mirrors USER_CONTENT_IMAGE_SELECTOR
|
||||
// in src/image_id_stamper.py (+ requires data-image-id which the
|
||||
// stamper always emits). Decorative / frame imgs lacking the role
|
||||
// attribute are intentionally NOT clickable here.
|
||||
textEditCaptureHandler = (ev: Event) => {
|
||||
const cap = deriveTextEditCapture(
|
||||
ev.target as unknown as TextEditCaptureTarget | null
|
||||
);
|
||||
if (cap) onTextEdit?.(cap);
|
||||
};
|
||||
doc.addEventListener("focusout", textEditCaptureHandler);
|
||||
} else {
|
||||
doc.designMode = "off";
|
||||
doc.querySelectorAll("[contenteditable]").forEach((el) => {
|
||||
(el as HTMLElement).removeAttribute("contenteditable");
|
||||
});
|
||||
}
|
||||
|
||||
// IMP-90 (#90) u12 ??image-selection gate: only the 'image-zone'
|
||||
// editMode wires the in-iframe user-content image click ??selection.
|
||||
// Selector mirrors USER_CONTENT_IMAGE_SELECTOR in image_id_stamper.py
|
||||
// (requires data-image-id which the stamper always emits). Decorative
|
||||
// / frame imgs lacking the role attribute are NOT clickable. The
|
||||
// else branch clears `selectedImageId` so the React-side overlay
|
||||
// never lingers on a non-image-zone edit mode.
|
||||
if (editGates.imageSelection) {
|
||||
const imgEls = doc.querySelectorAll<HTMLImageElement>(
|
||||
'.slide img[data-image-role="user-content"][data-image-id]'
|
||||
);
|
||||
@@ -205,12 +345,6 @@ export default function SlideCanvas({
|
||||
imageClickBindings.push({ el: imgEl, handler, prevCursor, prevOutline });
|
||||
});
|
||||
} else {
|
||||
doc.designMode = "off";
|
||||
doc.querySelectorAll("[contenteditable]").forEach((el) => {
|
||||
(el as HTMLElement).removeAttribute("contenteditable");
|
||||
});
|
||||
// edit-mode exit also clears stale image selection so the handle
|
||||
// overlay never lingers on a non-editable iframe.
|
||||
setSelectedImageId(null);
|
||||
}
|
||||
|
||||
@@ -218,29 +352,72 @@ export default function SlideCanvas({
|
||||
if (inputHandler && doc) {
|
||||
doc.removeEventListener("input", inputHandler);
|
||||
}
|
||||
if (textEditCaptureHandler && doc) {
|
||||
doc.removeEventListener("focusout", textEditCaptureHandler);
|
||||
}
|
||||
imageClickBindings.forEach(({ el, handler, prevCursor, prevOutline }) => {
|
||||
el.removeEventListener("click", handler);
|
||||
el.style.cursor = prevCursor;
|
||||
el.style.outline = prevOutline;
|
||||
});
|
||||
};
|
||||
}, [isEditMode, finalHtmlUrl, onContentEdit]);
|
||||
}, [editGates.textEditing, editGates.imageSelection, finalHtmlUrl, onContentEdit, onTextEdit]);
|
||||
|
||||
// pendingLayout 진입 시 편집 모드 자동 OFF (충돌 방지).
|
||||
useEffect(() => {
|
||||
if (isPendingLayout && isEditMode) setIsEditMode(false);
|
||||
if (isPendingLayout && isEditMode) setEditMode("off");
|
||||
}, [isPendingLayout, isEditMode]);
|
||||
|
||||
// finalHtmlUrl 이 바뀌면 (= 다른 run / 재실행) stale 측정값 reset.
|
||||
// 새 iframe 의 onLoad 가 발화하면서 measuredZones 다시 채움.
|
||||
// IMP-90 (#90) u14 ??discover slot keys per zone for the structure
|
||||
// overlay. Source = iframe DOM `data-text-path="{slot_key}.{line_index}"`
|
||||
// attributes stamped by u8 (`src/text_path_stamper.py`). Unique slot_key
|
||||
// prefixes per `.zone[data-zone-position]` form the overlay's slot list.
|
||||
// Discovery runs only when entering structure mode (and resets on exit
|
||||
// or iframe reload) so off / text / image-zone modes never pay this
|
||||
// traversal cost.
|
||||
const [slotKeysByZone, setSlotKeysByZone] = useState<
|
||||
Record<string, string[]>
|
||||
>({});
|
||||
useEffect(() => {
|
||||
if (editMode !== "structure" || isPendingLayout) {
|
||||
setSlotKeysByZone({});
|
||||
return;
|
||||
}
|
||||
const doc = iframeRef.current?.contentDocument;
|
||||
if (!doc) return;
|
||||
const next: Record<string, string[]> = {};
|
||||
doc.querySelectorAll(".zone[data-zone-position]").forEach((zEl) => {
|
||||
const zoneId = (zEl as HTMLElement).getAttribute("data-zone-position");
|
||||
if (!zoneId) return;
|
||||
const seen = new Set<string>();
|
||||
const keys: string[] = [];
|
||||
zEl.querySelectorAll("[data-text-path]").forEach((lineEl) => {
|
||||
const path = (lineEl as HTMLElement).getAttribute("data-text-path");
|
||||
if (!path) return;
|
||||
const lastDot = path.lastIndexOf(".");
|
||||
const slotKey = lastDot > 0 ? path.slice(0, lastDot) : path;
|
||||
if (slotKey && !seen.has(slotKey)) {
|
||||
seen.add(slotKey);
|
||||
keys.push(slotKey);
|
||||
}
|
||||
});
|
||||
next[zoneId] = keys;
|
||||
});
|
||||
setSlotKeysByZone(next);
|
||||
}, [editMode, isPendingLayout, finalHtmlUrl]);
|
||||
|
||||
// #98 Task 4 ??reset iframe-derived UI state whenever a render is replaced,
|
||||
// cleared, or hidden by a new pipeline run. This prevents stale zone
|
||||
// measurements / edit overlays from lingering over the next final.html.
|
||||
useEffect(() => {
|
||||
setMeasuredZones({});
|
||||
setMeasuredSlideBody(null);
|
||||
// IMP-51 (#79) u8 — image measurements + selection are per-render;
|
||||
// drop both so the new iframe's onLoad starts clean.
|
||||
setMeasuredImages({});
|
||||
setSelectedImageId(null);
|
||||
}, [finalHtmlUrl]);
|
||||
setDragOverZoneId(null);
|
||||
setSlotKeysByZone({});
|
||||
setEditMode("off");
|
||||
}, [finalHtmlUrl, isPipelineRunning, isPendingLayout]);
|
||||
|
||||
// 16:9 비율 유지하며 컨테이너에 통째로 fit (스크롤 X).
|
||||
useEffect(() => {
|
||||
@@ -269,12 +446,19 @@ export default function SlideCanvas({
|
||||
|
||||
// Empty state — MDX 업로드 전.
|
||||
const isEmpty = !finalHtmlUrl && !slidePlan && !isPipelineRunning && !isPendingLayout;
|
||||
const hasNoCurrentRender =
|
||||
!finalHtmlUrl && !!slidePlan && !isPipelineRunning && !isPendingLayout;
|
||||
// 슬라이드 박스 표시 조건 — final.html 있거나 pendingLayout 모드.
|
||||
const showSlideBox = (finalHtmlUrl || isPendingLayout) && !isPipelineRunning;
|
||||
// Task 13b: pending layout is a draft canvas, not a final.html preview.
|
||||
// Hide the iframe entirely while pending so the prior committed render
|
||||
// cannot visually overlap the pending zones.
|
||||
const renderIframe = !!finalHtmlUrl && !isPendingLayout;
|
||||
const showSlideBox = (renderIframe || isPendingLayout) && !isPipelineRunning;
|
||||
const pendingSlideBody = measuredSlideBody ?? { x: 0, y: 0, w: 1, h: 1 };
|
||||
|
||||
// IMP-14 (Step 13 A-4) — backend slide_base.html 가 embedded vs standalone CSS
|
||||
// contract 를 `?embedded=1` query 로 소유. 기존 query string 보존하면서 flag 만 추가.
|
||||
const embeddedSrc = finalHtmlUrl
|
||||
const embeddedSrc = renderIframe && finalHtmlUrl
|
||||
? `${finalHtmlUrl}${finalHtmlUrl.includes("?") ? "&" : "?"}embedded=1`
|
||||
: undefined;
|
||||
|
||||
@@ -291,9 +475,9 @@ export default function SlideCanvas({
|
||||
{isEmpty && (
|
||||
<div className="text-center text-slate-400 select-none">
|
||||
<UploadIcon className="w-12 h-12 mx-auto mb-3 text-slate-300" />
|
||||
<p className="text-sm font-medium">왼쪽 패널에서 MDX 파일을 업로드하세요.</p>
|
||||
<p className="text-sm font-medium">Upload an MDX file from the left panel.</p>
|
||||
<p className="text-xs text-slate-300 mt-1">
|
||||
업로드 후 하단 "슬라이드 플랜 생성하기" 버튼을 눌러주세요.
|
||||
After upload, run the slide generation action from the footer.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -301,9 +485,18 @@ export default function SlideCanvas({
|
||||
{isPipelineRunning && (
|
||||
<div className="text-center text-slate-500 select-none">
|
||||
<Loader2 className="w-10 h-10 mx-auto mb-3 text-blue-500 animate-spin" />
|
||||
<p className="text-sm font-bold">Phase Z 파이프라인 실행 중...</p>
|
||||
<p className="text-sm font-bold">Phase Z pipeline is running...</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
MDX 분석 → V4 매칭 → 레이아웃 / 프레임 결정 → 렌더
|
||||
Parsing MDX, matching frames, choosing layout, and rendering.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasNoCurrentRender && (
|
||||
<div className="text-center text-slate-500 select-none max-w-sm">
|
||||
<p className="text-sm font-bold">No current render</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Run the pipeline to display a fresh final.html result.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -319,8 +512,8 @@ export default function SlideCanvas({
|
||||
}}
|
||||
title={
|
||||
isPendingLayout
|
||||
? `Pending layout: ${pendingLayoutId} — slide-body 만 변경. section 카드 → zone drop → frame 선택 → 재생성`
|
||||
: "슬라이드 전체 클릭 → 우측 Layout 후보"
|
||||
? `Pending layout: ${pendingLayoutId} - assign sections, choose frames, then regenerate`
|
||||
: "Open layout panel"
|
||||
}
|
||||
>
|
||||
{/* pendingLayout 일 때만 노출되는 우상단 취소 버튼 (slide 박스 외곽 유지) */}
|
||||
@@ -337,28 +530,50 @@ export default function SlideCanvas({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 편집 모드 toggle 버튼 — normal mode + final.html 있을 때만.
|
||||
글벗 패턴 차용 — designMode + contentEditable. backend 반영은 별 작업. */}
|
||||
{/* IMP-90 (#90) u11 ??discriminated edit-mode toolbar.
|
||||
Replaces the prior single ??toggle. Three modes (text /
|
||||
structure / image-zone) are mutually exclusive; clicking the
|
||||
active mode toggles back to 'off'. Gesture gating per mode is
|
||||
u12 ??u11 only plants the state + UI surface, so all three
|
||||
modes currently share the same `isEditMode` shim behavior. */}
|
||||
{!isPendingLayout && finalHtmlUrl && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditMode((p) => !p);
|
||||
}}
|
||||
className={`absolute top-2 right-2 z-30 text-[10px] font-bold uppercase tracking-tighter px-2.5 py-1 rounded shadow transition ${
|
||||
isEditMode
|
||||
? "bg-emerald-500 text-white hover:bg-emerald-600 ring-2 ring-emerald-200"
|
||||
: "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
|
||||
}`}
|
||||
<div
|
||||
data-testid="edit-mode-toolbar"
|
||||
className="absolute top-2 right-2 z-30 flex gap-1"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title={
|
||||
isEditMode
|
||||
? "편집 모드 — 텍스트 클릭하여 수정. 다시 클릭하여 종료. (변경은 frontend 만, backend 반영 미구현)"
|
||||
: "텍스트 직접 편집 모드 진입"
|
||||
}
|
||||
>
|
||||
{isEditMode ? "✏ 편집 중 (클릭 종료)" : "✏ 편집"}
|
||||
</button>
|
||||
{EDIT_MODES.map((mode) => {
|
||||
const active = editMode === mode;
|
||||
const label =
|
||||
mode === "text" ? "Text" : mode === "structure" ? "Structure" : "Image/Zone";
|
||||
const title =
|
||||
mode === "text"
|
||||
? "Edit text in the rendered slide"
|
||||
: mode === "structure"
|
||||
? "Edit slot order and visibility"
|
||||
: "Move or resize images and zones";
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
data-testid={`edit-mode-${mode}`}
|
||||
aria-pressed={active}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditMode((prev) => nextEditMode(prev, mode));
|
||||
}}
|
||||
className={`text-[10px] font-bold uppercase tracking-tighter px-2.5 py-1 rounded shadow transition ${
|
||||
active
|
||||
? "bg-emerald-500 text-white hover:bg-emerald-600 ring-2 ring-emerald-200"
|
||||
: "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
|
||||
}`}
|
||||
title={title}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
@@ -373,16 +588,24 @@ export default function SlideCanvas({
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{renderIframe && embeddedSrc && (
|
||||
<iframe
|
||||
key={embeddedSrc}
|
||||
ref={iframeRef}
|
||||
src={embeddedSrc}
|
||||
title="Phase Z 렌더 결과"
|
||||
className="w-full h-full border-0 block"
|
||||
scrolling="no"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
style={{ pointerEvents: isEditMode ? "auto" : "none" }}
|
||||
// IMP-90 (#90) u12 ??iframe pointer-events gate. 'text' needs
|
||||
// pe:auto so the user can click into text fields; 'image-zone'
|
||||
// needs pe:auto so user-content image clicks can reach the
|
||||
// in-iframe click handler that drives `selectedImageId`.
|
||||
// 'structure' and 'off' keep pe:none ??structure has no
|
||||
// in-iframe gesture (u14 will overlay React-side controls).
|
||||
style={{ pointerEvents: editGates.iframePointerAuto ? "auto" : "none" }}
|
||||
onLoad={(e) => {
|
||||
// IMP-14 (Step 13 A-4) — embedded vs standalone CSS reset 은 backend
|
||||
// IMP-14 (Step 13 A-4) ??embedded vs standalone CSS reset ?? backend
|
||||
// slide_base.html 가 `?embedded=1` query 로 소유. frontend 가 더 이상
|
||||
// reset CSS 를 contentDocument 에 inject 하지 않음. embedded query 가
|
||||
// backend auto-mode detection script 를 trigger 해서 html.embedded
|
||||
@@ -391,16 +614,7 @@ export default function SlideCanvas({
|
||||
const doc = (e.currentTarget as HTMLIFrameElement).contentDocument;
|
||||
if (!doc) return;
|
||||
|
||||
// 2026-05-14 — slide-level override CSS (catalog/template 무변).
|
||||
// Home 이 mdx 별 default visual 보완 (bullet 간격 / zone 비율 등) 지정.
|
||||
if (slideOverrideCss && slideOverrideCss.trim()) {
|
||||
const overrideStyle = doc.createElement("style");
|
||||
overrideStyle.setAttribute("data-purpose", "slide-level-override");
|
||||
overrideStyle.textContent = slideOverrideCss;
|
||||
doc.head.appendChild(overrideStyle);
|
||||
}
|
||||
|
||||
// ── Zone DOM 측정 ──
|
||||
// ???? Zone DOM 측정 ????
|
||||
// backend final.html 의 .zone[data-zone-position="..."] 요소를
|
||||
// 찾아서 boundingClientRect 측정 → 1280×720 기준 정규화.
|
||||
// overlay 가 이 측정값으로 그려지므로 backend 의 layout (slide-base
|
||||
@@ -438,11 +652,11 @@ export default function SlideCanvas({
|
||||
});
|
||||
}
|
||||
|
||||
// ── IMP-51 (#79) u8 — user-content image bbox 측정 ──
|
||||
// ???? IMP-51 (#79) u8 ??user-content image bbox 측정 ????
|
||||
// u4 stamper 가 부착한 data-image-id 가 있는 img 만 잡음
|
||||
// (decorative / frame img 제외). 측정 결과는 1280×720 기준
|
||||
// 슬라이드-절대 percent (0–100) — image_overrides axis (u3
|
||||
// 타입 + u7 CSS `left/top/width/height: {value}%` 주입) 와
|
||||
// ????+ u7 CSS `left/top/width/height: {value}%` 주입) ??
|
||||
// 동일한 좌표계라서 측정 / 영구 저장 / emit 가 1:1 매칭됨.
|
||||
const imageEls = doc.querySelectorAll<HTMLImageElement>(
|
||||
'.slide img[data-image-role="user-content"][data-image-id]'
|
||||
@@ -469,20 +683,21 @@ export default function SlideCanvas({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── pendingLayout 시 slide-body 영역 overlay ──
|
||||
slide-base (title / divider / footer) 는 final.html 그대로 유지.
|
||||
slide-body 영역에만 흰 dashed overlay 깔아서 기존 .zone 들 가리고
|
||||
새 layout zone 안내 표시. */}
|
||||
{isPendingLayout && measuredSlideBody && (
|
||||
{isPendingLayout && (
|
||||
<div
|
||||
className="absolute bg-white border-2 border-dashed border-amber-400 z-10"
|
||||
style={{
|
||||
left: `${measuredSlideBody.x * 100}%`,
|
||||
top: `${measuredSlideBody.y * 100}%`,
|
||||
width: `${measuredSlideBody.w * 100}%`,
|
||||
height: `${measuredSlideBody.h * 100}%`,
|
||||
left: `${pendingSlideBody.x * 100}%`,
|
||||
top: `${pendingSlideBody.y * 100}%`,
|
||||
width: `${pendingSlideBody.w * 100}%`,
|
||||
height: `${pendingSlideBody.h * 100}%`,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
@@ -502,7 +717,7 @@ export default function SlideCanvas({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Zone overlay layer ──
|
||||
{/* ???? Zone overlay layer ????
|
||||
normal mode : measuredZones (= iframe 안 .zone DOM bbox) 우선.
|
||||
pendingLayout : zone.position (computeZonePositions 결과 = slide-body
|
||||
내부 0~1) 을 measuredSlideBody 안 절대 정규화로 변환.
|
||||
@@ -543,17 +758,17 @@ export default function SlideCanvas({
|
||||
h: zone.position.height,
|
||||
};
|
||||
let x: number, y: number, width: number, height: number;
|
||||
if (overrideGeom && measuredSlideBody) {
|
||||
if (overrideGeom && (measuredSlideBody || isPendingLayout)) {
|
||||
// 사용자 override → slide-body 기준
|
||||
x = measuredSlideBody.x + overrideGeom.x * measuredSlideBody.w;
|
||||
y = measuredSlideBody.y + overrideGeom.y * measuredSlideBody.h;
|
||||
width = overrideGeom.w * measuredSlideBody.w;
|
||||
height = overrideGeom.h * measuredSlideBody.h;
|
||||
} else if (isPendingLayout && measuredSlideBody) {
|
||||
x = measuredSlideBody.x + zone.position.x * measuredSlideBody.w;
|
||||
y = measuredSlideBody.y + zone.position.y * measuredSlideBody.h;
|
||||
width = zone.position.width * measuredSlideBody.w;
|
||||
height = zone.position.height * measuredSlideBody.h;
|
||||
x = pendingSlideBody.x + overrideGeom.x * pendingSlideBody.w;
|
||||
y = pendingSlideBody.y + overrideGeom.y * pendingSlideBody.h;
|
||||
width = overrideGeom.w * pendingSlideBody.w;
|
||||
height = overrideGeom.h * pendingSlideBody.h;
|
||||
} else if (isPendingLayout) {
|
||||
x = pendingSlideBody.x + zone.position.x * pendingSlideBody.w;
|
||||
y = pendingSlideBody.y + zone.position.y * pendingSlideBody.h;
|
||||
width = zone.position.width * pendingSlideBody.w;
|
||||
height = zone.position.height * pendingSlideBody.h;
|
||||
} else {
|
||||
const measured = measuredZones[zone.zone_id];
|
||||
x = measured?.x ?? zone.position.x;
|
||||
@@ -562,7 +777,7 @@ export default function SlideCanvas({
|
||||
height = measured?.h ?? zone.position.height;
|
||||
}
|
||||
|
||||
// Step C : pending mode 의 zone overlay resize handles — independent 모델.
|
||||
// Step C : pending mode ??zone overlay resize handles ??independent 모델.
|
||||
// 각 zone 이 독립 박스 — 인접 zone 영향 X. 자유 배치 (gap / 겹침 사용자 책임).
|
||||
// boundary drag 모델 (인접 자동 조정) 은 grid 같은 묶음 layout 에 적합한데,
|
||||
// 사용자 의도 = zone position drag 까지 자유롭게 → independent 가 자연.
|
||||
@@ -578,18 +793,19 @@ export default function SlideCanvas({
|
||||
const makeResizeHandler = (
|
||||
direction: ResizeDir
|
||||
) => (ev: React.MouseEvent<HTMLDivElement>) => {
|
||||
// resize 는 pendingLayout OR 편집 모드 활성. 2026-05-22 demo hot-fix —
|
||||
// frame partial 에 @container aspect-ratio 회전이 들어가서 fixed px 제약 사라짐.
|
||||
if ((!isPendingLayout && !isEditMode) || !onZoneResize) return;
|
||||
if (!measuredSlideBody) return;
|
||||
// resize 는 pendingLayout OR image-zone 편집 모드 활성. 2026-05-22
|
||||
// demo hot-fix — frame partial 에 @container aspect-ratio 회전이
|
||||
// 들어가서 fixed px 제약 사라짐. IMP-90 u12: text/structure 모드
|
||||
// 에서는 zone resize 비활성 (mutually exclusive per editGates).
|
||||
if ((!isPendingLayout && !editGates.zoneGestures) || !onZoneResize) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const startMouseX = ev.clientX;
|
||||
const startMouseY = ev.clientY;
|
||||
const startGeom: { x: number; y: number; w: number; h: number } =
|
||||
overrideGeom ? { ...overrideGeom } : { ...localGeom };
|
||||
const slideBodyWidthPx = W_SCALED * measuredSlideBody.w;
|
||||
const slideBodyHeightPx = H_SCALED * measuredSlideBody.h;
|
||||
const slideBodyWidthPx = W_SCALED * pendingSlideBody.w;
|
||||
const slideBodyHeightPx = H_SCALED * pendingSlideBody.h;
|
||||
const minSize = 0.05;
|
||||
|
||||
const affectsLeft = direction === "left" || direction === "nw" || direction === "sw";
|
||||
@@ -651,19 +867,22 @@ export default function SlideCanvas({
|
||||
ev: React.MouseEvent<HTMLDivElement>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
const canDrag = !!((isPendingLayout || isEditMode) && measuredSlideBody && onZoneResize);
|
||||
// IMP-90 u12: zone drag is image-zone-mode-only (text /
|
||||
// structure suppress canDrag; non-zoneGestures click still
|
||||
// triggers onZoneClick via the !dragged branch on mouse-up).
|
||||
const canDrag = !!((isPendingLayout || editGates.zoneGestures) && onZoneResize);
|
||||
const startMouseX = ev.clientX;
|
||||
const startMouseY = ev.clientY;
|
||||
const startGeom = { ...localGeom };
|
||||
const slideBodyWidthPx = canDrag
|
||||
? W_SCALED * measuredSlideBody!.w
|
||||
? W_SCALED * pendingSlideBody.w
|
||||
: 1;
|
||||
const slideBodyHeightPx = canDrag
|
||||
? H_SCALED * measuredSlideBody!.h
|
||||
? H_SCALED * pendingSlideBody.h
|
||||
: 1;
|
||||
let dragged = false;
|
||||
|
||||
// 2026-05-22 demo hot-fix — same iframe pointer-events fix as makeResizeHandler.
|
||||
// 2026-05-22 demo hot-fix ??same iframe pointer-events fix as makeResizeHandler.
|
||||
const iframeEl = iframeRef.current;
|
||||
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
|
||||
if (iframeEl) iframeEl.style.pointerEvents = "none";
|
||||
@@ -723,7 +942,7 @@ export default function SlideCanvas({
|
||||
: null;
|
||||
const previewUrl = previewCandidate?.thumbnailUrl ?? null;
|
||||
|
||||
// IMP-11 u4: active frame lookup — distinct axis from preview.
|
||||
// IMP-11 u4: active frame lookup ??distinct axis from preview.
|
||||
// preview is shown only when override differs from default; active is
|
||||
// always defined as override-if-present-else-default. Used by u5 to
|
||||
// compare the active frame's catalog min_height_px against zone height.
|
||||
@@ -811,8 +1030,8 @@ export default function SlideCanvas({
|
||||
}}
|
||||
title={
|
||||
previewUrl
|
||||
? `${sectionLabel} (zone: ${zone.zone_id}) → preview frame ${previewCandidate?.name} (재생성 시 적용)`
|
||||
: `${sectionLabel} (zone: ${zone.zone_id}) — 클릭 시 frame 후보 / section 카드 drop 가능`
|
||||
? `${sectionLabel} (zone: ${zone.zone_id}) - preview frame ${previewCandidate?.name}`
|
||||
: `${sectionLabel} (zone: ${zone.zone_id}) - open frame panel or drop a section`
|
||||
}
|
||||
>
|
||||
{/* Step A : 선택 frame preview 미리보기 (반투명, iframe 과 중복 X) */}
|
||||
@@ -870,12 +1089,14 @@ export default function SlideCanvas({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step C : zone resize handles — 8 방향. pendingLayout OR 편집 모드 활성.
|
||||
2026-05-22 demo hot-fix — frame partial 에 @container aspect-ratio 회전
|
||||
들어간 후 fixed px 제약 사라져 편집 모드 resize 도 의미 있음.
|
||||
{/* Step C : zone resize handles ??8 방향. pendingLayout OR image-zone
|
||||
편집 모드 활성. 2026-05-22 demo hot-fix — frame partial 에 @container
|
||||
aspect-ratio 회전 들어간 후 fixed px 제약 사라져 image-zone 모드 resize
|
||||
도 의미 있음. IMP-90 u12: text / structure 모드에서는 zone resize
|
||||
affordance 미노출 (editGates.zoneGestures = image-zone only).
|
||||
edge handle (top/bottom/left/right) : 한 boundary 이동
|
||||
corner handle (nw/ne/sw/se) : 두 boundary 동시. */}
|
||||
{(isPendingLayout || isEditMode) && onZoneResize && (
|
||||
{(isPendingLayout || editGates.zoneGestures) && onZoneResize && (
|
||||
<>
|
||||
{/* top edge */}
|
||||
<div
|
||||
@@ -891,7 +1112,7 @@ export default function SlideCanvas({
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -bottom-1 left-1/4 w-1/2 h-2 bg-blue-500/70 hover:bg-blue-500 rounded cursor-ns-resize z-30 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="하단 boundary 조절"
|
||||
title="상단 boundary 조절"
|
||||
/>
|
||||
{/* left edge */}
|
||||
<div
|
||||
@@ -907,7 +1128,7 @@ export default function SlideCanvas({
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-1/4 -right-1 h-1/2 w-2 bg-blue-500/70 hover:bg-blue-500 rounded cursor-ew-resize z-30 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="우측 boundary 조절"
|
||||
title="좌측 boundary 조절"
|
||||
/>
|
||||
{/* corner NW (좌상단) */}
|
||||
<div
|
||||
@@ -923,7 +1144,7 @@ export default function SlideCanvas({
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -top-1 -right-1 w-3 h-3 bg-white border-2 border-blue-500 rounded-sm cursor-nesw-resize z-30 hover:scale-125 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="우상단 동시 조절"
|
||||
title="좌상단 동시 조절"
|
||||
/>
|
||||
{/* corner SW (좌하단) */}
|
||||
<div
|
||||
@@ -939,7 +1160,7 @@ export default function SlideCanvas({
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -bottom-1 -right-1 w-4 h-4 bg-white border-2 border-blue-500 rounded-sm cursor-nwse-resize z-30 hover:scale-125 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="우하단 동시 조절"
|
||||
title="좌하단 동시 조절"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -955,45 +1176,47 @@ export default function SlideCanvas({
|
||||
strips intercept the perimeter while the un-covered iframe
|
||||
interior keeps text-edit reachability intact.
|
||||
pendingLayout mode already has wrapper pointerEvents:auto,
|
||||
so these surfaces are only needed in edit mode. */}
|
||||
{isEditMode && !isPendingLayout && onZoneResize && (
|
||||
so these surfaces are only needed in edit mode.
|
||||
IMP-90 u12: image-zone-mode-only — text / structure 모드는
|
||||
zone drag 안 함 (editGates.zoneGestures = false 두 모드 모두). */}
|
||||
{editGates.zoneGestures && !isPendingLayout && onZoneResize && (
|
||||
<>
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-0 left-0 right-0 h-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
||||
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||
title="zone 이동 — 드래그"
|
||||
title="Move zone"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute bottom-0 left-0 right-0 h-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
||||
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||
title="zone 이동 — 드래그"
|
||||
title="Move zone"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-0 left-0 bottom-0 w-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
||||
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||
title="zone 이동 — 드래그"
|
||||
title="Move zone"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-0 right-0 bottom-0 w-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
||||
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||
title="zone 이동 — 드래그"
|
||||
title="Move zone"
|
||||
/>
|
||||
{/* visible grip affordance — placed below the section label
|
||||
{/* visible grip affordance ??placed below the section label
|
||||
(top-1 left-1 container) so the two don't overlap. */}
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-7 left-1 w-3 h-3 bg-emerald-500/70 border border-emerald-700 rounded-full cursor-grab active:cursor-grabbing shadow hover:scale-125 transition"
|
||||
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||
title="zone 이동 — 드래그하여 위치 변경"
|
||||
title="Drag to move zone"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -1001,7 +1224,40 @@ export default function SlideCanvas({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* ── IMP-51 (#79) u8 — user-content image edit overlay ──
|
||||
{/* IMP-90 (#90) u14 ??structure edit overlay (slot reorder +
|
||||
hide). Renders only in `editMode === "structure"` over each
|
||||
measured zone, positioned at the zone's top-right inside the
|
||||
slide-absolute coord space. Slot keys come from u14 iframe
|
||||
traversal (`slotKeysByZone`). Mutations emit through
|
||||
onStructureEdit; u15 will debounce + PUT. */}
|
||||
{!isPendingLayout && editMode === "structure" && finalHtmlUrl &&
|
||||
slidePlan?.zones.map((zone) => {
|
||||
const m = measuredZones[zone.zone_id];
|
||||
if (!m) return null;
|
||||
const slotKeys = slotKeysByZone[zone.zone_id] ?? [];
|
||||
const current = structureOverrides?.[zone.zone_id];
|
||||
return (
|
||||
<div
|
||||
key={`struct-${zone.id}`}
|
||||
className="absolute z-30"
|
||||
style={{
|
||||
left: m.x * W_SCALED,
|
||||
top: m.y * H_SCALED,
|
||||
width: m.w * W_SCALED,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<StructureEditOverlay
|
||||
zoneId={zone.zone_id}
|
||||
slotKeys={slotKeys}
|
||||
current={current}
|
||||
onChange={onStructureEdit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* ???? IMP-51 (#79) u8 ??user-content image edit overlay ????
|
||||
Activates only in edit mode when an image_id appears in either
|
||||
`imageOverrides` (u11-fed persisted axis) or `measuredImages`
|
||||
(iframe-measured baseline). pendingLayout suppresses the image
|
||||
@@ -1016,19 +1272,23 @@ export default function SlideCanvas({
|
||||
default; holding Shift during the drag unlocks it (matches the
|
||||
issue contract "corner_resize_ratio_default_locked_shift_unlock").
|
||||
|
||||
Coordinate space: slide-absolute percent (0–100) throughout —
|
||||
Coordinate space: slide-absolute percent (0??00) throughout ??
|
||||
measured / persisted / emitted values share the same units as
|
||||
the u7 CSS injector (`left/top/width/height: {value}%`) and the
|
||||
u3 typed-client `ImageOverride` contract. CSS values are
|
||||
written verbatim ({geom.x}%, no scale factor) and pixel deltas
|
||||
from MouseEvent are converted to percent via
|
||||
`(dx_px / W_SCALED) * 100` so the round-trip drag → save →
|
||||
`(dx_px / W_SCALED) * 100` so the round-trip drag ??save ??
|
||||
re-render produces identical geometry. IMP-51 (#79) u9 moved
|
||||
the resize / move math to `clampImagePercentGeometry` in
|
||||
`slideCanvasDragMath.ts` so the boundary contract Codex #16
|
||||
verified is exercised directly by vitest (mirror of how IMP-54
|
||||
u3 split the zone math out of SlideCanvas). */}
|
||||
{!isPendingLayout && isEditMode && finalHtmlUrl && onImageResize &&
|
||||
{/* IMP-90 u12: image overlay is image-zone-mode-only. text /
|
||||
structure 모드에서는 image drag/resize affordance 미노출
|
||||
(editGates.imageOverlay = false). pendingLayout 도 동일하게
|
||||
suppress (computeEditModeGates 가 모두 false 반환). */}
|
||||
{!isPendingLayout && editGates.imageOverlay && finalHtmlUrl && onImageResize &&
|
||||
Object.entries({ ...measuredImages, ...(imageOverrides ?? {}) }).map(
|
||||
([imageId]) => {
|
||||
const persisted = imageOverrides?.[imageId];
|
||||
@@ -1070,7 +1330,7 @@ export default function SlideCanvas({
|
||||
// value regardless of viewport zoom.
|
||||
const dx = ((mv.clientX - startMouseX) / W_SCALED) * 100;
|
||||
const dy = ((mv.clientY - startMouseY) / H_SCALED) * 100;
|
||||
// IMP-51 (#79) u9 — boundary contract lives in the
|
||||
// IMP-51 (#79) u9 ??boundary contract lives in the
|
||||
// pure helper so vitest can verify it directly.
|
||||
// Aspect lock is default on for corner handles and
|
||||
// released when Shift is held.
|
||||
@@ -1138,7 +1398,7 @@ export default function SlideCanvas({
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -bottom-1 left-1/4 w-1/2 h-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ns-resize z-40 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="하단"
|
||||
title="상단"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "left")}
|
||||
@@ -1152,9 +1412,9 @@ export default function SlideCanvas({
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-1/4 -right-1 h-1/2 w-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ew-resize z-40 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="우측"
|
||||
title="좌측"
|
||||
/>
|
||||
{/* corners — aspect locked by default, Shift unlocks */}
|
||||
{/* corners ??aspect locked by default, Shift unlocks */}
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "nw")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
@@ -1167,7 +1427,7 @@ export default function SlideCanvas({
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -top-1 -right-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nesw-resize z-40 hover:scale-125 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="우상단 (Shift = 비율 해제)"
|
||||
title="좌상단 (Shift = 비율 해제)"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "sw")}
|
||||
@@ -1181,7 +1441,7 @@ export default function SlideCanvas({
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -bottom-1 -right-1 w-4 h-4 bg-white border-2 border-emerald-500 rounded-sm cursor-nwse-resize z-40 hover:scale-125 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="우하단 (Shift = 비율 해제)"
|
||||
title="좌하단 (Shift = 비율 해제)"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 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()
|
||||
};
|
||||
+269
-83
@@ -16,9 +16,13 @@ import {
|
||||
moveSectionToZone,
|
||||
saveZoneSizes,
|
||||
saveImageOverride,
|
||||
saveTextOverride,
|
||||
saveStructureOverride,
|
||||
deriveUserOverridesKey,
|
||||
applyPersistedNonFrameOverrides,
|
||||
mergeSubmittedPipelineOverridesForRestore,
|
||||
remapPersistedFramesToZoneFrames,
|
||||
validateZoneGeometriesAgainstLayout,
|
||||
} from "../utils/slidePlanUtils";
|
||||
import {
|
||||
parseMdxFile,
|
||||
@@ -40,8 +44,10 @@ import LeftMdxPanel from "../components/LeftMdxPanel";
|
||||
import SlideCanvas from "../components/SlideCanvas";
|
||||
import LayoutPanel from "../components/LayoutPanel";
|
||||
import FramePanel from "../components/FramePanel";
|
||||
import PipelineTracePanel from "../components/PipelineTracePanel";
|
||||
import BottomActions from "../components/BottomActions";
|
||||
import {
|
||||
Sparkles, Download, Link2, Loader2,
|
||||
Sparkles, Loader2,
|
||||
CheckCircle2, HelpCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -56,7 +62,7 @@ const INITIAL_STATE: DesignAgentState = {
|
||||
error: null,
|
||||
};
|
||||
|
||||
type RightPanelTab = "layout" | "frame";
|
||||
type RightPanelTab = "layout" | "frame" | "trace";
|
||||
|
||||
export default function Home() {
|
||||
const [state, setState] = useState<DesignAgentState>(INITIAL_STATE);
|
||||
@@ -154,6 +160,31 @@ export default function Home() {
|
||||
}
|
||||
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 {
|
||||
...p,
|
||||
userSelection: {
|
||||
@@ -162,6 +193,18 @@ export default function Home() {
|
||||
...p.userSelection.overrides,
|
||||
layout_preset: layoutId,
|
||||
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,
|
||||
selectedRegionId: null,
|
||||
@@ -176,10 +219,27 @@ export default function Home() {
|
||||
// pending 모드 취소 → 평소 (final.html iframe) 모드 복귀.
|
||||
const handleCancelPendingLayout = useCallback(() => {
|
||||
setPendingLayout(null);
|
||||
setState((p) => ({
|
||||
...p,
|
||||
userSelection: createInitialUserSelection(p.slidePlan),
|
||||
}));
|
||||
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,
|
||||
// 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),
|
||||
};
|
||||
});
|
||||
setHasPendingChanges(false);
|
||||
}, []);
|
||||
|
||||
@@ -208,7 +268,10 @@ export default function Home() {
|
||||
setState((p) => ({
|
||||
...p,
|
||||
normalizedContent: content,
|
||||
userSelection: applyPersistedNonFrameOverrides(p.userSelection, persisted),
|
||||
// #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}" 분석 완료 — 하단 버튼으로 슬라이드 생성하세요.`);
|
||||
@@ -221,27 +284,11 @@ export default function Home() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 2026-05-14 — 좌측 패널의 03/04/05 fix list 클릭 또는 URL `?mdx=04` 변경 시
|
||||
// 2026-05-14 — 좌측 패널의 01~05 fix list 클릭 또는 URL `?mdx=04` 변경 시
|
||||
// 호출되는 단일 callback. handleFileUpload 가 자동 분석 trigger.
|
||||
const [selectedSample, setSelectedSample] = useState<"03" | "04" | "05" | null>(null);
|
||||
const [selectedSample, setSelectedSample] = useState<"01" | "02" | "03" | "04" | "05" | null>(null);
|
||||
|
||||
// 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") => {
|
||||
const handleSelectSample = useCallback(async (which: "01" | "02" | "03" | "04" | "05") => {
|
||||
try {
|
||||
const res = await fetch(`/api/sample-mdx?mdx=${encodeURIComponent(which)}`);
|
||||
if (!res.ok) return;
|
||||
@@ -262,11 +309,11 @@ export default function Home() {
|
||||
}, []);
|
||||
|
||||
// 페이지 첫 로드 시 데모용 mdx 자동 로드 — 상대방에게 mdx 파일 공유 안 해도 되게.
|
||||
// URL query `?mdx=04` / `?mdx=05` 로 다른 sample 선택 가능. default = 03.
|
||||
// URL query `?mdx=01`~`?mdx=05` 로 다른 sample 선택 가능. default = 03.
|
||||
// 사용자가 다른 파일을 직접 업로드하면 그것이 override 됨.
|
||||
useEffect(() => {
|
||||
const which = (new URLSearchParams(window.location.search).get("mdx") as "03" | "04" | "05" | null) || "03";
|
||||
if (!["03", "04", "05"].includes(which)) return;
|
||||
const which = (new URLSearchParams(window.location.search).get("mdx") as "01" | "02" | "03" | "04" | "05" | null) || "03";
|
||||
if (!["01", "02", "03", "04", "05"].includes(which)) return;
|
||||
handleSelectSample(which);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -284,9 +331,10 @@ export default function Home() {
|
||||
// - layout : userSelection.overrides.layout_preset (default 와 다를 때만)
|
||||
// - frames : zone.section_ids → unit_id ("+".join). region.id 별 zone_frames lookup.
|
||||
// pendingZones 의 region 도 동일 — 그 region 의 zone 의 sections 가 unit_id 결정.
|
||||
const shouldUseUserOverrides = hasPendingChanges;
|
||||
const overrides: PipelineOverrides = {};
|
||||
const sourcePlan = effectiveSlidePlan;
|
||||
if (sourcePlan && state.slidePlan) {
|
||||
if (shouldUseUserOverrides && sourcePlan && state.slidePlan) {
|
||||
// 2026-05-22 demo hot-fix — 이전 비교 가드 (default !== override) 제거.
|
||||
// restore loop 이 default = override 로 sync 시 override 안 보내고 backend
|
||||
// default fallback 발생. user 가 명시한 layout 이 있으면 무조건 보냄.
|
||||
@@ -329,38 +377,78 @@ export default function Home() {
|
||||
|
||||
// zone-geometry override — backend 의 build_layout_css 에 전달 (horizontal-2 /
|
||||
// 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;
|
||||
if (zoneGeometries && Object.keys(zoneGeometries).length > 0) {
|
||||
overrides.zoneGeometries = zoneGeometries;
|
||||
const activeLayout = overrides.layout ?? sourcePlan.layout_preset;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 2026-05-22 — IMP-08 B-3 원래 동작 (sameAsDefault with effectiveSlidePlan) 복귀.
|
||||
// 시연 안정성 우선. section swap 은 별 path (수동 drag detection) 로 풀어야 함.
|
||||
// 임시 over-aggressive fix 가 default flow 깨뜨려 PARTIAL_COVERAGE 발생했음.
|
||||
const userZoneSections = state.userSelection.overrides.zone_sections;
|
||||
if (userZoneSections) {
|
||||
const defaultByZone = new Map<string, string[]>();
|
||||
sourcePlan.zones.forEach((z) => {
|
||||
defaultByZone.set(z.zone_id, z.section_ids);
|
||||
});
|
||||
const zoneSectionsDiff: Record<string, string[]> = {};
|
||||
for (const [zoneId, sids] of Object.entries(userZoneSections)) {
|
||||
if (!Array.isArray(sids)) continue;
|
||||
const cleaned = sids.filter((s) => typeof s === "string" && s.trim());
|
||||
const defaults = defaultByZone.get(zoneId) ?? [];
|
||||
const sameAsDefault =
|
||||
cleaned.length === defaults.length &&
|
||||
cleaned.every((sid, i) => sid === defaults[i]);
|
||||
if (!sameAsDefault) {
|
||||
zoneSectionsDiff[zoneId] = cleaned;
|
||||
// IMP-55 (#93) u7 — Replace the IMP-08 B-3 self-compare with the bool
|
||||
// `manual_section_assignment` intent marker gate. The prior code built
|
||||
// `defaultByZone` from `sourcePlan.zones` and compared against the
|
||||
// user's `overrides.zone_sections`, but `sourcePlan === effectiveSlidePlan`
|
||||
// (Home.tsx:305) and `effectiveSlidePlan.zones === pendingZones`
|
||||
// (Home.tsx:649), which is itself derived from
|
||||
// `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;
|
||||
if (userZoneSections) {
|
||||
const validZoneIds = new Set(
|
||||
sourcePlan.zones.map((z) => z.zone_id),
|
||||
);
|
||||
const zoneSectionsForward: Record<string, string[]> = {};
|
||||
for (const [zoneId, sids] of Object.entries(userZoneSections)) {
|
||||
if (!validZoneIds.has(zoneId)) continue;
|
||||
if (!Array.isArray(sids)) continue;
|
||||
const cleaned = sids.filter(
|
||||
(s) => typeof s === "string" && s.trim(),
|
||||
);
|
||||
zoneSectionsForward[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 }));
|
||||
setHasPendingChanges(false); // 재생성 트리거 시 override pending flag reset
|
||||
setPendingLayout(null); // pending layout 모드 종료
|
||||
@@ -384,7 +472,30 @@ export default function Home() {
|
||||
// clicks Generate would race the PUT against /api/run; the u2
|
||||
// fallback could then load a stale persisted document.
|
||||
await flushUserOverrides();
|
||||
const result = await runPipeline(state.uploadedFile, overrides);
|
||||
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) {
|
||||
const detail =
|
||||
@@ -403,10 +514,12 @@ export default function Home() {
|
||||
// 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 = remapPersistedFramesToZoneFrames(
|
||||
slidePlan,
|
||||
persistedOverridesRef.current.frames as Record<string, string> | undefined,
|
||||
);
|
||||
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
|
||||
@@ -415,10 +528,10 @@ export default function Home() {
|
||||
// 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 base = applyPersistedNonFrameOverrides(
|
||||
createInitialUserSelection(slidePlan),
|
||||
persistedOverridesRef.current,
|
||||
);
|
||||
const cleanBase = createInitialUserSelection(slidePlan);
|
||||
const base = shouldUseUserOverrides
|
||||
? applyPersistedNonFrameOverrides(cleanBase, restoreOverrides)
|
||||
: cleanBase;
|
||||
return {
|
||||
...p,
|
||||
normalizedContent,
|
||||
@@ -434,6 +547,9 @@ export default function Home() {
|
||||
};
|
||||
});
|
||||
setRunMeta(runMeta);
|
||||
if (runMeta.status !== "PASS" || (runMeta.pipeline_trace?.warnings.length ?? 0) > 0) {
|
||||
setRightTab("trace");
|
||||
}
|
||||
toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`);
|
||||
const aiReviewMsg = formatAiRepairHumanReviewMessage(runMeta.ai_repair_status);
|
||||
if (aiReviewMsg) toast.error(aiReviewMsg);
|
||||
@@ -444,13 +560,27 @@ export default function Home() {
|
||||
);
|
||||
setState((p) => ({ ...p, isLoading: false }));
|
||||
}
|
||||
}, [state.uploadedFile, state.slidePlan, state.userSelection, pendingZones, pendingLayout]);
|
||||
}, [state.uploadedFile, state.slidePlan, state.userSelection, pendingZones, pendingLayout, hasPendingChanges]);
|
||||
|
||||
// ── 섹션 드래그 앤 드롭 (Zone으로 재배치) ──
|
||||
const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => {
|
||||
setState((p) => {
|
||||
const newSelection = moveSectionToZone(p.userSelection, sectionId, zoneId);
|
||||
const finalSelection = selectZone(newSelection, zoneId); // 이동된 존 자동 선택
|
||||
const zoneSelected = selectZone(newSelection, zoneId); // 이동된 존 자동 선택
|
||||
// IMP-55 (#93) u6 — flip the bool intent marker to `true` on real
|
||||
// user drag-drop. Inverse of the u5 reset (layout apply/cancel
|
||||
// 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
|
||||
@@ -458,10 +588,15 @@ export default function Home() {
|
||||
// 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 };
|
||||
@@ -579,6 +714,51 @@ export default function Home() {
|
||||
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.
|
||||
// 그 외 = state.slidePlan. 모든 zone / region lookup 이 일관되게 이걸 사용 →
|
||||
// pending mode 의 region.id ("pending-region-N") 가 zone_frames key 로 들어가
|
||||
@@ -590,17 +770,6 @@ export default function Home() {
|
||||
return state.slidePlan;
|
||||
}, [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 선택 ──
|
||||
const handleFrameSelect = useCallback((frameId: string) => {
|
||||
const zone = getSelectedZone(effectiveSlidePlan, state.userSelection);
|
||||
@@ -710,6 +879,16 @@ export default function Home() {
|
||||
</div>
|
||||
</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>
|
||||
@@ -760,8 +939,7 @@ export default function Home() {
|
||||
slidePlan={effectiveSlidePlan}
|
||||
normalizedContent={state.normalizedContent}
|
||||
userSelection={state.userSelection}
|
||||
finalHtmlUrl={runMeta?.final_html_url}
|
||||
slideOverrideCss={slideOverrideCss}
|
||||
finalHtmlUrl={state.isLoading ? undefined : runMeta?.final_html_url}
|
||||
isPipelineRunning={state.isLoading}
|
||||
isPendingLayout={!!pendingLayout}
|
||||
pendingLayoutId={pendingLayout}
|
||||
@@ -779,6 +957,9 @@ export default function Home() {
|
||||
onZoneResize={handleZoneResize}
|
||||
imageOverrides={state.userSelection.overrides.image_overrides}
|
||||
onImageResize={handleImageResize}
|
||||
onTextEdit={handleTextEdit}
|
||||
structureOverrides={state.userSelection.overrides.structure_overrides}
|
||||
onStructureEdit={handleStructureEdit}
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -787,6 +968,7 @@ export default function Home() {
|
||||
<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 === "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 className="flex-1 overflow-hidden">
|
||||
{rightTab === "frame" ? (
|
||||
@@ -798,7 +980,7 @@ export default function Home() {
|
||||
onFrameSelect={handleFrameSelect}
|
||||
onNoDesignToggle={() => {}}
|
||||
/>
|
||||
) : (
|
||||
) : rightTab === "layout" ? (
|
||||
<LayoutPanel
|
||||
selectedZone={selectedZone}
|
||||
userSelection={state.userSelection}
|
||||
@@ -808,6 +990,8 @@ export default function Home() {
|
||||
pipelineSelectedLayoutId={state.slidePlan?.layout_preset}
|
||||
pendingLayoutId={pendingLayout}
|
||||
/>
|
||||
) : (
|
||||
<PipelineTracePanel trace={runMeta?.pipeline_trace} aiTrace={runMeta?.ai_trace} />
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
@@ -821,11 +1005,13 @@ export default function Home() {
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-tighter">Phase Z Engine Active</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<BottomActions
|
||||
slidePlan={state.slidePlan}
|
||||
runMeta={runMeta}
|
||||
uploadedFile={state.uploadedFile}
|
||||
isLoading={state.isLoading}
|
||||
onGenerate={handleGenerate}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -235,6 +235,15 @@ export interface AiRepairStatus {
|
||||
unsupported_kind: 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<{
|
||||
unit_index?: number | null;
|
||||
source_section_ids: string[];
|
||||
@@ -244,6 +253,8 @@ export interface AiRepairStatus {
|
||||
unit_index?: number | null;
|
||||
source_section_ids: string[];
|
||||
error: string;
|
||||
// IMP-92 u3 — per-record operational error kind (quota|billing|auth|other|null).
|
||||
api_error_kind?: string | null;
|
||||
}>;
|
||||
coverage_status: string;
|
||||
dropped_section_ids: string[];
|
||||
@@ -265,25 +276,102 @@ export interface RunMeta {
|
||||
region_layout_candidates_by_zone: Record<string, string[]>; // step08 placeholder
|
||||
display_strategy_candidates_by_zone: Record<string, string[]>; // step08 placeholder
|
||||
ai_repair_status: AiRepairStatus | null;
|
||||
pipeline_trace: PipelineTraceSummary | null;
|
||||
ai_trace: AiTraceSummary | null;
|
||||
}
|
||||
|
||||
export interface PipelineTraceSection {
|
||||
id: string;
|
||||
title: string;
|
||||
child_ids: string[];
|
||||
state: "rendered" | "render_blocked" | "filtered" | "covered_not_rendered" | "uncovered";
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface PipelineTraceUnit {
|
||||
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(
|
||||
ai: AiRepairStatus | null | undefined,
|
||||
): string | null {
|
||||
if (!ai || !ai.human_review_required) return null;
|
||||
if (ai.status === "error") {
|
||||
const n = ai.counts?.error ?? ai.error_records?.length ?? 0;
|
||||
return `AI 재구성 호출 실패 (${n}건) — 다른 frame 선택 또는 수동 편집 필요`;
|
||||
if (!ai) return null;
|
||||
const kinds = ai.api_error_kinds;
|
||||
if (!kinds) return null;
|
||||
if (kinds.quota > 0) {
|
||||
return `API quota 부족 — 충전 필요 (${kinds.quota}건)`;
|
||||
}
|
||||
if (ai.status === "coverage_violated") {
|
||||
const dropped = (ai.dropped_section_ids || []).join(", ");
|
||||
return `AI 재구성 후 콘텐츠 누락 (dropped: ${dropped || "?"}) — 다른 frame 선택 또는 수동 편집 필요`;
|
||||
if (kinds.billing > 0) {
|
||||
return `API billing 문제 — 결제 정보 확인 (${kinds.billing}건)`;
|
||||
}
|
||||
if (ai.status === "unsupported_kind") {
|
||||
const n = ai.counts?.unsupported_kind ?? ai.unsupported_kind_records?.length ?? 0;
|
||||
return `AI 제안 형식 미지원 (${n}건) — 다른 frame 선택 또는 수동 편집 필요`;
|
||||
if (kinds.auth > 0) {
|
||||
return `API key 무효 — .env 확인 (${kinds.auth}건)`;
|
||||
}
|
||||
return `AI 재구성 human_review 필요 (status: ${ai.status})`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface LoadRunResult {
|
||||
@@ -325,13 +413,27 @@ export interface PipelineOverrides {
|
||||
|
||||
export async function runPipeline(
|
||||
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> {
|
||||
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", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename: file.name, content, overrides }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = (await res.json()) as RunPipelineResult;
|
||||
if (!res.ok && !data.run_id) {
|
||||
@@ -407,12 +509,417 @@ function classifyConfidence(score: number): "high" | "medium" | "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 으로 변환.
|
||||
*
|
||||
* @example
|
||||
* 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> {
|
||||
console.log(`[Phase Z] loadRun: ${runId}`);
|
||||
const base = `/data/runs/${runId}`;
|
||||
@@ -424,6 +931,14 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
||||
}
|
||||
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 res = await fetch(`${base}/${path}`);
|
||||
if (!res.ok) {
|
||||
@@ -436,21 +951,28 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
||||
mdxSource,
|
||||
upload,
|
||||
normalized,
|
||||
compositionPlan,
|
||||
layout,
|
||||
zoneRegion,
|
||||
applicationPlan,
|
||||
slotPayload,
|
||||
slideStatus,
|
||||
aiRepair,
|
||||
] = await Promise.all([
|
||||
fetchText("steps/step01_mdx_source.md"),
|
||||
fetchJson("steps/step01_mdx_upload.json"),
|
||||
fetchJson("steps/step02_normalized.json"),
|
||||
fetchJson("steps/step06_composition_plan.json"),
|
||||
fetchJson("steps/step07_layout.json"),
|
||||
fetchJson("steps/step08_zone_region_ratios.json"),
|
||||
fetchJson("steps/step09_application_plan.json"),
|
||||
fetchJson("steps/step12_slot_payload.json"),
|
||||
fetchJson("steps/step20_slide_status.json"),
|
||||
fetchJsonOptional("steps/step12_ai_repair.json"),
|
||||
]);
|
||||
|
||||
// ── RunMeta ──
|
||||
const aiRepairStatus = (slideStatus.data?.ai_repair_status ?? null) as AiRepairStatus | null;
|
||||
const runMeta: RunMeta = {
|
||||
run_id: upload.data?.run_id ?? runId,
|
||||
mdx_path: upload.data?.mdx_path ?? "",
|
||||
@@ -475,7 +997,15 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
||||
z.display_strategy_candidates ?? [],
|
||||
])
|
||||
),
|
||||
ai_repair_status: (slideStatus.data?.ai_repair_status ?? null) as AiRepairStatus | null,
|
||||
ai_repair_status: aiRepairStatus,
|
||||
pipeline_trace: buildPipelineTraceSummary({
|
||||
normalized,
|
||||
compositionPlan,
|
||||
applicationPlan,
|
||||
slotPayload,
|
||||
slideStatus,
|
||||
}),
|
||||
ai_trace: buildAiTraceSummary(aiRepair, aiRepairStatus),
|
||||
};
|
||||
|
||||
// ── NormalizedContent ──
|
||||
@@ -530,6 +1060,16 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
||||
const layoutPreset = (layout.data?.layout_preset ?? "single") as LayoutPresetId;
|
||||
const positions = computeZonePositions(layoutPreset);
|
||||
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 posEntry = positions[idx] ?? {
|
||||
@@ -545,6 +1085,13 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
||||
// sort 우선순위 = label (use_as_is > light_edit > restructure > reject) + confidence desc.
|
||||
// 모두 reject 인 경우 confidence desc 만 적용 (사용자 명시).
|
||||
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> = {
|
||||
use_as_is: 0,
|
||||
light_edit: 1,
|
||||
@@ -556,9 +1103,6 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
||||
// 2) unit.v4_all_judgments (pre-IMP-05 audit array)
|
||||
// 3) unit.v4_candidates (legacy minimal)
|
||||
// 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 pushCandidate = (c: any) => {
|
||||
if (!c) return;
|
||||
@@ -566,15 +1110,71 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
||||
if (!key) return;
|
||||
if (!candidateMap.has(key)) candidateMap.set(key, c);
|
||||
};
|
||||
candidateEvidence.forEach(pushCandidate);
|
||||
(unit.v4_all_judgments ?? []).forEach(pushCandidate);
|
||||
(unit.v4_candidates ?? []).forEach(pushCandidate);
|
||||
const rawSource = Array.from(candidateMap.values());
|
||||
const v4Source = [...rawSource].sort((a: any, b: any) => {
|
||||
const lp = (LABEL_PRIORITY[a.label] ?? 99) - (LABEL_PRIORITY[b.label] ?? 99);
|
||||
if (lp !== 0) return lp;
|
||||
return (b.confidence ?? 0) - (a.confidence ?? 0);
|
||||
});
|
||||
|
||||
// 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);
|
||||
(unit.v4_all_judgments ?? []).forEach(pushCandidate);
|
||||
(unit.v4_candidates ?? []).forEach(pushCandidate);
|
||||
const rawSource = Array.from(candidateMap.values());
|
||||
v4Source = [...rawSource].sort((a: any, b: any) => {
|
||||
const lp =
|
||||
(LABEL_PRIORITY[a.label] ?? 99) - (LABEL_PRIORITY[b.label] ?? 99);
|
||||
if (lp !== 0) return lp;
|
||||
return (b.confidence ?? 0) - (a.confidence ?? 0);
|
||||
});
|
||||
}
|
||||
// ─── IMP-41 u4 — application_candidates enrichment (issue #70) ───────────
|
||||
// Backend Step 9 emits `unit.application_candidates[]` (src/phase_z2_pipeline.py
|
||||
// _application_candidates_for_unit, :3071-3092) one entry per v4 candidate with
|
||||
@@ -613,6 +1213,8 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
||||
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,
|
||||
@@ -628,6 +1230,15 @@ 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 = (
|
||||
runMeta.display_strategy_candidates_by_zone[posEntry.name]?.[0] ??
|
||||
"inline_full"
|
||||
@@ -652,10 +1263,10 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
||||
content_unit_ids: [],
|
||||
frame_match_strategy: {
|
||||
kind: "frame_match" as const,
|
||||
frame_id: unit.current_default_candidate ?? null,
|
||||
frame_id: defaultFrameId,
|
||||
display_strategy: displayStrategy,
|
||||
},
|
||||
frame_candidates: frameCandidates,
|
||||
frame_candidates: effectiveFrameCandidates,
|
||||
},
|
||||
],
|
||||
region_layout_type: regionId,
|
||||
|
||||
@@ -65,6 +65,47 @@ export type ImageOverride = {
|
||||
};
|
||||
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;
|
||||
@@ -72,6 +113,9 @@ export interface UserOverrides {
|
||||
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). */
|
||||
|
||||
@@ -162,6 +162,10 @@ export interface FrameCandidate {
|
||||
v4Label?: 'use_as_is' | 'light_edit' | 'restructure' | 'reject';
|
||||
/** Phase Z status enum (e.g. "auto_renderable", "fallback_candidate"). Open vocabulary. */
|
||||
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). */
|
||||
filteredForDirectExecution?: boolean;
|
||||
/** Execution route mapped from `label` (direct_render / deterministic_minor_adjustment /
|
||||
@@ -213,6 +217,29 @@ export interface UserSelection {
|
||||
// `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[] }>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent";
|
||||
import type { UserOverrides } from "../services/userOverridesApi";
|
||||
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) ────
|
||||
// ??? 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
|
||||
// ??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
|
||||
// ??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)
|
||||
// ??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.
|
||||
|
||||
/**
|
||||
@@ -24,7 +31,101 @@ import type { UserOverrides } from "../services/userOverridesApi";
|
||||
* so the same persisted file is read from both ends without translation.
|
||||
*/
|
||||
export function deriveUserOverridesKey(filename: string): string {
|
||||
return filename.replace(/\.mdx$/i, "");
|
||||
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>([
|
||||
@@ -41,7 +142,7 @@ const LAYOUT_PRESET_IDS = new Set<string>([
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
@@ -72,11 +173,11 @@ export function applyPersistedNonFrameOverrides(
|
||||
) {
|
||||
next.zone_sections = { ...persisted.zone_sections };
|
||||
}
|
||||
// IMP-51 (#79) u11 — layer the 5th persisted axis (`image_overrides`) by
|
||||
// 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.
|
||||
// (image_id ??{x,y,w,h} percent-of-slide), so a shallow copy is enough.
|
||||
if (
|
||||
persisted.image_overrides &&
|
||||
typeof persisted.image_overrides === "object" &&
|
||||
@@ -84,12 +185,91 @@ export function applyPersistedNonFrameOverrides(
|
||||
) {
|
||||
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
|
||||
* 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
|
||||
@@ -117,8 +297,8 @@ export function remapPersistedFramesToZoneFrames(
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase Z 초기 선택 상태 생성
|
||||
* SlidePlan의 결과를 초기 값으로 사용 (Step 11까지의 결과 반영)
|
||||
* Phase Z 珥덇린 ?좏깮 ?곹깭 ?앹꽦
|
||||
* SlidePlan??寃곌낵瑜?珥덇린 媛믪쑝濡??ъ슜 (Step 11源뚯???寃곌낵 諛섏쁺)
|
||||
*/
|
||||
export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSelection {
|
||||
const initialSections: Record<string, string[]> = {};
|
||||
@@ -126,16 +306,16 @@ export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSe
|
||||
|
||||
if (slidePlan) {
|
||||
slidePlan.zones.forEach(zone => {
|
||||
// 1. 모든 섹션을 각자의 지정된 존에 할당 (초안 배치)
|
||||
// 1. 紐⑤뱺 ?뱀뀡??媛곸옄??吏?뺣맂 議댁뿉 ?좊떦 (珥덉븞 諛곗튂)
|
||||
initialSections[zone.zone_id] = [...zone.section_ids];
|
||||
|
||||
// 2. 각 리전의 기본 frame.
|
||||
// 2026-05-14 — backend frame_match_strategy.frame_id 가 있을 때만 init.
|
||||
// null 인 경우 (backend current_default_candidate=None 등) frame_candidates[0]
|
||||
// 로 자동 채우지 않음 → SlideCanvas 의 preview overlay 트리거 조건
|
||||
// (override !== default) 안 발동. 사용자가 직접 frame 클릭해야 preview 보임.
|
||||
// 배경 : 04-1 같은 case 에서 backend selection_path=rank_1 (env toggle 통과)
|
||||
// 이어도 current_default=None 이면 default 와 override mismatch 로 preview 강제 발동.
|
||||
// 2. 媛?由ъ쟾??湲곕낯 frame.
|
||||
// 2026-05-14 ??backend frame_match_strategy.frame_id 媛 ?덉쓣 ?뚮쭔 init.
|
||||
// null ??寃쎌슦 (backend current_default_candidate=None ?? frame_candidates[0]
|
||||
// 濡??먮룞 梨꾩슦吏 ?딆쓬 ??SlideCanvas ??preview overlay ?몃━嫄?議곌굔
|
||||
// (override !== default) ??諛쒕룞. ?ъ슜?먭? 吏곸젒 frame ?대┃?댁빞 preview 蹂댁엫.
|
||||
// 諛곌꼍 : 04-1 媛숈? case ?먯꽌 backend selection_path=rank_1 (env toggle ?듦낵)
|
||||
// ?댁뼱??current_default=None ?대㈃ default ? override mismatch 濡?preview 媛뺤젣 諛쒕룞.
|
||||
zone.internal_regions.forEach(region => {
|
||||
const topFrameId = region.frame_match_strategy.frame_id;
|
||||
if (topFrameId) {
|
||||
@@ -155,10 +335,24 @@ export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSe
|
||||
zone_sections: initialSections,
|
||||
zone_sizes: {},
|
||||
zone_geometries: {},
|
||||
// IMP-51 (#79) u11 — image_overrides axis starts empty; entries land
|
||||
// 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -181,12 +375,12 @@ export function saveZoneGeometry(
|
||||
}
|
||||
|
||||
/**
|
||||
* IMP-51 (#79) u11 — record a single `image_id` → slide-absolute percent
|
||||
* 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
|
||||
* PUT. Pure / immutable ??returns a fresh `UserSelection`; the input is
|
||||
* never mutated. Existing entries for the same `imageId` are replaced.
|
||||
*/
|
||||
export function saveImageOverride(
|
||||
@@ -206,6 +400,60 @@ export function saveImageOverride(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
return {
|
||||
...selection,
|
||||
@@ -220,7 +468,7 @@ export function saveZoneSizes(selection: UserSelection, groupId: string, sizes:
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 섹션을 새로운 존으로 이동 (Drag & Drop)
|
||||
* ?뱀젙 ?뱀뀡???덈줈??議댁쑝濡??대룞 (Drag & Drop)
|
||||
*/
|
||||
export function moveSectionToZone(
|
||||
selection: UserSelection,
|
||||
@@ -229,12 +477,12 @@ export function moveSectionToZone(
|
||||
): UserSelection {
|
||||
const newZoneSections = { ...selection.overrides.zone_sections };
|
||||
|
||||
// 1. 모든 존에서 해당 섹션 제거 (이동 전 위치 클리어)
|
||||
// 1. 紐⑤뱺 議댁뿉???대떦 ?뱀뀡 ?쒓굅 (?대룞 ???꾩튂 ?대━??
|
||||
Object.keys(newZoneSections).forEach(zid => {
|
||||
newZoneSections[zid] = newZoneSections[zid].filter(id => id !== sectionId);
|
||||
});
|
||||
|
||||
// 2. 타겟 존에 섹션 추가
|
||||
// 2. ?寃?議댁뿉 ?뱀뀡 異붽?
|
||||
if (!newZoneSections[targetZoneId]) {
|
||||
newZoneSections[targetZoneId] = [];
|
||||
}
|
||||
@@ -255,7 +503,7 @@ export function selectZone(selection: UserSelection, zoneId: string | null): Use
|
||||
return {
|
||||
...selection,
|
||||
selectedZoneId: zoneId,
|
||||
selectedRegionId: null, // Zone이 바뀌면 Region 선택 해제
|
||||
selectedRegionId: null, // Zone??諛붾뚮㈃ Region ?좏깮 ?댁젣
|
||||
};
|
||||
}
|
||||
|
||||
@@ -290,16 +538,16 @@ export function applyFrame(selection: UserSelection, regionId: string, frameId:
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 선택된 Zone 객체 반환
|
||||
* ?꾩옱 ?좏깮??Zone 媛앹껜 諛섑솚
|
||||
*/
|
||||
export function getSelectedZone(slidePlan: SlidePlan | null, selection: UserSelection): Zone | 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 선택된 Region 객체 반환
|
||||
* ?꾩옱 ?좏깮??Region 媛앹껜 諛섑솚
|
||||
*/
|
||||
export function getSelectedRegion(zone: Zone | null, selection: UserSelection): InternalRegion | null {
|
||||
if (!zone || !selection.selectedRegionId) return null;
|
||||
@@ -307,16 +555,90 @@ export function getSelectedRegion(zone: Zone | null, selection: UserSelection):
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 Zone에 할당된 섹션 ID 목록 반환 (오버라이드 우선)
|
||||
* ?뱀젙 Zone???좊떦???뱀뀡 ID 紐⑸줉 諛섑솚 (?ㅻ쾭?쇱씠???곗꽑)
|
||||
*/
|
||||
export function getSectionsForZone(zone: Zone, selection: UserSelection): string[] {
|
||||
return selection.overrides.zone_sections[zone.zone_id] || zone.section_ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최종 유효 레이아웃 ID 반환
|
||||
* 理쒖쥌 ?좏슚 ?덉씠?꾩썐 ID 諛섑솚
|
||||
*/
|
||||
export function getEffectiveLayoutId(slidePlan: SlidePlan | null, selection: UserSelection): LayoutPresetId {
|
||||
if (selection.overrides.layout_preset) return selection.overrides.layout_preset;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
// 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,20 +1,28 @@
|
||||
// IMP-47B u11 — Frontend ai_repair_status notification surfacing.
|
||||
// IMP-92 u5 — Frontend AI repair operational-only formatter test surface.
|
||||
//
|
||||
// Scope (Stage 2 unit u11 contract):
|
||||
// 1) loadRun → RunMeta.ai_repair_status exposes the u8 step20 payload.
|
||||
// 2) formatAiRepairHumanReviewMessage(...) returns user-facing notification
|
||||
// text on the three failure axes (error / coverage_violated /
|
||||
// unsupported_kind) and returns null on success / no-AI paths.
|
||||
// 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 that calls this helper after
|
||||
// setRunMeta(...); covering the helper covers the user-visible message text
|
||||
// directly without DOM rendering.
|
||||
// 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.
|
||||
//
|
||||
// File extension is `.tsx` per Stage 2 unit contract path; no JSX is required
|
||||
// for these assertions but the extension allows future RTL-based tests to
|
||||
// land here without renaming.
|
||||
// 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 {
|
||||
@@ -23,7 +31,7 @@ import {
|
||||
} from "../src/services/designAgentApi";
|
||||
|
||||
const baseCounts = {
|
||||
total: 1,
|
||||
total: 0,
|
||||
applied: 0,
|
||||
no_proposal: 0,
|
||||
no_zone_match: 0,
|
||||
@@ -31,16 +39,19 @@ const baseCounts = {
|
||||
error: 0,
|
||||
};
|
||||
|
||||
describe("formatAiRepairHumanReviewMessage (IMP-47B u11)", () => {
|
||||
it("returns null when ai_repair_status is null (legacy / pre-Step12 abort)", () => {
|
||||
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 when human_review_required=false (success / no-AI path)", () => {
|
||||
it("returns null on success / no-AI path (no operational kind present)", () => {
|
||||
const ok: AiRepairStatus = {
|
||||
status: "ok",
|
||||
counts: { ...baseCounts, total: 0 },
|
||||
counts: { ...baseCounts },
|
||||
api_error_kinds: { ...zeroKinds },
|
||||
unsupported_kind_records: [],
|
||||
error_records: [],
|
||||
coverage_status: "ok",
|
||||
@@ -57,47 +68,127 @@ describe("formatAiRepairHumanReviewMessage (IMP-47B u11)", () => {
|
||||
expect(formatAiRepairHumanReviewMessage(applied)).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces AI call failures with count + frame/manual guidance", () => {
|
||||
const errored: AiRepairStatus = {
|
||||
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: "timeout" },
|
||||
{ unit_index: 1, source_section_ids: ["03-2"], error: "validation" },
|
||||
{
|
||||
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(errored);
|
||||
const msg = formatAiRepairHumanReviewMessage(ai);
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg).toContain("AI 재구성 호출 실패");
|
||||
expect(msg).toContain("API quota");
|
||||
expect(msg).toContain("충전 필요");
|
||||
expect(msg).toContain("2");
|
||||
expect(msg).toContain("다른 frame 선택 또는 수동 편집 필요");
|
||||
});
|
||||
|
||||
it("surfaces coverage violations with the dropped section ids", () => {
|
||||
const dropped: AiRepairStatus = {
|
||||
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,
|
||||
};
|
||||
const msg = formatAiRepairHumanReviewMessage(dropped);
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg).toContain("콘텐츠 누락");
|
||||
expect(msg).toContain("03-2");
|
||||
expect(msg).toContain("다른 frame 선택 또는 수동 편집 필요");
|
||||
expect(formatAiRepairHumanReviewMessage(ai)).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces unsupported proposal kinds with the unsupported count", () => {
|
||||
const unsupported: AiRepairStatus = {
|
||||
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,
|
||||
@@ -110,26 +201,57 @@ describe("formatAiRepairHumanReviewMessage (IMP-47B u11)", () => {
|
||||
dropped_section_ids: [],
|
||||
human_review_required: true,
|
||||
};
|
||||
const msg = formatAiRepairHumanReviewMessage(unsupported);
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg).toContain("AI 제안 형식 미지원");
|
||||
expect(msg).toContain("1");
|
||||
expect(msg).toContain("다른 frame 선택 또는 수동 편집 필요");
|
||||
expect(formatAiRepairHumanReviewMessage(ai)).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to a generic human_review message on unknown status enums", () => {
|
||||
const future: AiRepairStatus = {
|
||||
status: "future_axis_not_yet_mapped",
|
||||
counts: { ...baseCounts, total: 0 },
|
||||
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: [],
|
||||
error_records: [
|
||||
{ unit_index: 0, source_section_ids: ["03-1"], error: "timeout" },
|
||||
],
|
||||
coverage_status: "ok",
|
||||
dropped_section_ids: [],
|
||||
human_review_required: true,
|
||||
};
|
||||
const msg = formatAiRepairHumanReviewMessage(future);
|
||||
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("human_review");
|
||||
expect(msg).toContain("future_axis_not_yet_mapped");
|
||||
expect(msg).toContain("API quota");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// 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\(/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
// 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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
// 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)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
// 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]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
// 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`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
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\");");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
// 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/);
|
||||
});
|
||||
});
|
||||
@@ -305,19 +305,61 @@ describe("handleGetUserOverrides (IMP-52 u3)", () => {
|
||||
// IMP-52 u4 — PUT endpoint coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4)", () => {
|
||||
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)", () => {
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
deriveUserOverridesKey,
|
||||
remapPersistedFramesToZoneFrames,
|
||||
saveImageOverride,
|
||||
saveTextOverride,
|
||||
saveStructureOverride,
|
||||
} from "../src/utils/slidePlanUtils";
|
||||
|
||||
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
||||
@@ -54,6 +56,16 @@ function makeSelection(overrides?: Partial<UserSelection["overrides"]>): UserSel
|
||||
// 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,
|
||||
},
|
||||
};
|
||||
@@ -460,3 +472,235 @@ describe("image_overrides axis — saveImageOverride (IMP-51 u11)", () => {
|
||||
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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -559,3 +559,67 @@ describe("saveUserOverrides (IMP-51 #79 u3) — image_overrides axis", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
applyPersistedNonFrameOverrides,
|
||||
createInitialUserSelection,
|
||||
deriveUserOverridesKey,
|
||||
mergeSubmittedPipelineOverridesForRestore,
|
||||
remapPersistedFramesToZoneFrames,
|
||||
} from "../src/utils/slidePlanUtils";
|
||||
import type { SlidePlan, Zone } from "../src/types/designAgent";
|
||||
@@ -85,6 +86,22 @@ function sliceHandler(source: string, name: string): string {
|
||||
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");
|
||||
@@ -543,6 +560,65 @@ describe("restore-on-reopen end-to-end (IMP-52 u10)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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"));
|
||||
@@ -567,3 +643,220 @@ describe("restore-on-reopen end-to-end (IMP-52 u10)", () => {
|
||||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// 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));
|
||||
});
|
||||
});
|
||||
+289
-16
@@ -219,19 +219,36 @@ function vitePluginStorageProxy(): Plugin {
|
||||
|
||||
export const USER_OVERRIDES_KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\-]*$/;
|
||||
|
||||
// The five in-scope axes — exact mirror of KNOWN_AXES in
|
||||
// src/user_overrides_io.py. 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).
|
||||
// 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];
|
||||
|
||||
@@ -500,6 +517,211 @@ export function handlePutUserOverrides(
|
||||
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 업로드 → 파이프라인 실행 → 결과 노출
|
||||
//
|
||||
@@ -508,14 +730,19 @@ export function handlePutUserOverrides(
|
||||
// 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
|
||||
// CEL_PROJECT_ROOT cel astro dev repo root. default = D:/ad-hoc/cel
|
||||
// =============================================================================
|
||||
|
||||
function vitePluginPhaseZApi(): Plugin {
|
||||
const DESIGN_AGENT_ROOT =
|
||||
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 RUNS_DIR = path.join(DESIGN_AGENT_ROOT, "data", "runs");
|
||||
|
||||
@@ -543,6 +770,16 @@ function vitePluginPhaseZApi(): Plugin {
|
||||
// (e.g., "top": ["03-1-sub-1"]). Forwarded as --override-section-assignment.
|
||||
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 {
|
||||
payload = JSON.parse(body);
|
||||
@@ -554,7 +791,7 @@ function vitePluginPhaseZApi(): Plugin {
|
||||
return;
|
||||
}
|
||||
|
||||
const { filename, content, overrides } = payload;
|
||||
const { filename, content, overrides, reuseFromRunId, ignoreUserOverrides } = payload;
|
||||
if (!filename || typeof content !== "string") {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
@@ -638,24 +875,37 @@ 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(
|
||||
`[phase-z-api] spawn pipeline: run_id=${runId}, mdx=${mdxPath}, args=${JSON.stringify(cliArgs.slice(2))}`
|
||||
);
|
||||
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).
|
||||
// v4 fallback chain max_rank 는 templates/phase_z2/catalog/v4_fallback_policy.yaml 의
|
||||
// 정식 정책 (dynamic_usable_count_based) 으로 결정 — backend src/phase_z2_pipeline.py
|
||||
// 의 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, {
|
||||
cwd: DESIGN_AGENT_ROOT,
|
||||
shell: false,
|
||||
env: {
|
||||
...process.env,
|
||||
PHASE_Z_ALLOW_RESTRUCTURE: "1",
|
||||
PHASE_Z_ALLOW_REJECT: "1",
|
||||
},
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
@@ -707,11 +957,13 @@ function vitePluginPhaseZApi(): Plugin {
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /api/sample-mdx → samples/mdx/{03|04|05} (데모) ──
|
||||
// ── GET /api/sample-mdx → samples/mdx/{01|02|03|04|05} (데모) ──
|
||||
// 페이지 로드 시 frontend 가 자동 fetch — 상대방에게 mdx 파일 공유 안 해도 되게.
|
||||
// 2026-05-14 — query param `?mdx=04` / `?mdx=05` 추가 (default = 03).
|
||||
// 04 / 05 도 frontend simulation 가능하도록 frame/layout override 적용 path 유지.
|
||||
// 2026-05-14 — query param `?mdx=01`~`?mdx=05` 추가 (default = 03).
|
||||
// 01~05 모두 frontend simulation 가능하도록 frame/layout override 적용 path 유지.
|
||||
const SAMPLE_MDX_MAP: Record<string, string> = {
|
||||
"01": "01. 건설산업 DX의 올바른 이해(0127).mdx",
|
||||
"02": "02. DX의 시행 목표 및 기대효과.mdx",
|
||||
"03": "03. DX 시행을 위한 필수 요건 및 혁신 방안.mdx",
|
||||
"04": "04. DX 지연 요인.mdx",
|
||||
"05": "05. 설계 방식의 왜곡.mdx",
|
||||
@@ -775,6 +1027,27 @@ function vitePluginPhaseZApi(): Plugin {
|
||||
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} ──
|
||||
server.middlewares.use("/data/runs", (req, res, next) => {
|
||||
if (req.method !== "GET") return next();
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# 이슈 초안 — 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 (추적성) | 커버리지·품질 보강 |
|
||||
@@ -0,0 +1,85 @@
|
||||
# 프로젝트 전체 그림 — 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 (후순위) |
|
||||
@@ -0,0 +1,5 @@
|
||||
# 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,6 +167,66 @@ 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 50–70% / 10–20s → 3–8s 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 를 바꾸는 작업인지 식별
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# 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.
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
# 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
@@ -0,0 +1,319 @@
|
||||
# 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 외**. 데모 우선.
|
||||
@@ -163,3 +163,28 @@ STEP 10 인덱스 업데이트 — blocks_index.md 1줄 추가
|
||||
### 고정 슬라이드 배경
|
||||
|
||||
`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).
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"figma-desktop": {
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:3845/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
+1190
File diff suppressed because it is too large
Load Diff
+134
-6
@@ -1009,6 +1009,9 @@ def build_context_pack(n, title, body, sid, agent, rnd, start_cnt, compact=None)
|
||||
# 검증 실패 보고서 (rewind 시 이전 실패 맥락 전달).
|
||||
# 2026-05-16 — issue state 의 failure_report_path 를 source-of-truth 로.
|
||||
# 모든 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 = ""
|
||||
ist_fc = get_issue_state(n)
|
||||
fr_path_str = ist_fc.get("failure_report_path")
|
||||
@@ -1016,9 +1019,42 @@ def build_context_pack(n, title, body, sid, agent, rnd, start_cnt, compact=None)
|
||||
fail_path = Path(fr_path_str)
|
||||
if fail_path.exists():
|
||||
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 = (
|
||||
f"\n\n=== REWIND: FAILURE REPORT (from {from_sid}) ===\n"
|
||||
f"{fail_path.read_text(encoding='utf-8')[:1500]}\n"
|
||||
f"{fail_body[:1500]}\n"
|
||||
f"{banned_block}"
|
||||
f"Fix the issues above before re-attempting.\n"
|
||||
)
|
||||
|
||||
@@ -1447,10 +1483,22 @@ def run_stage(n, title, body, sid):
|
||||
return (False, "unit with `tests: []` (forbidden — implementation + tests = same unit)")
|
||||
return (True, "")
|
||||
ok, reason = _iu_valid(last)
|
||||
if not ok:
|
||||
# current stage 의 comments 만 검색 (start_cnt 이후)
|
||||
# P7 (2026-05-26) — fallback skip when last YES body itself is invalid.
|
||||
# 이전: last invalid → comments[start_cnt:] 에서 valid block 찾아 구제 →
|
||||
# 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:]:
|
||||
ok2, _ = _iu_valid(c.get("body", ""))
|
||||
body = c.get("body", "") or ""
|
||||
# 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:
|
||||
ok = True; break
|
||||
if not ok:
|
||||
@@ -1548,6 +1596,45 @@ def run_stage(n, title, body, sid):
|
||||
except: pass
|
||||
# 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)")
|
||||
# stage 완료 = unit counter + remaining tracker 모두 reset
|
||||
update_issue_state(n, continue_same_count=0, last_remaining_units=None)
|
||||
@@ -1765,8 +1852,49 @@ def run_issue(n, until=None):
|
||||
continue_same_count=0, last_remaining_units=None)
|
||||
|
||||
if s["id"] == "final-close":
|
||||
try: gitea(f"issues/{n}", "PATCH", {"state": "closed"}); log("Closed")
|
||||
except: pass
|
||||
# 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")
|
||||
except: pass
|
||||
|
||||
i += 1
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ dependencies = [
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pytest-json-report>=1.5",
|
||||
"ruff>=0.8",
|
||||
]
|
||||
|
||||
@@ -33,4 +34,5 @@ target-version = "py310"
|
||||
asyncio_mode = "auto"
|
||||
markers = [
|
||||
"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'`.",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
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를 제공
|
||||

|
||||
<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 << 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)을 수행하는 과정에서 **가장 기초가 되는 일부분**이다
|
||||
:::
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
title: DX의 시행 목표 및 기대효과
|
||||
sidebar:
|
||||
order: 1
|
||||
---
|
||||
|
||||
import DxEffect from '../../../../components/dx.astro';
|
||||
|
||||
|
||||
## 1. DX의 궁극적 목표
|
||||
|
||||
- **안전과 품질**
|
||||
- 시설물의 요구 성능을 설계-시공-운영 전 과정에서 **디지털로 검증**하여 **안전성 확보**
|
||||
- Copy & Paste로 하향 평준화된 성과물의 **하자 최소화**로 **고품질 성과물 제공**
|
||||
<br/>
|
||||
- **생산성 향상**
|
||||
- Analogue 기반 업무를 Digital 기반 프로세스로 전환하여 **업무 속도·정확성·일관성 향상**
|
||||
- 건설 비용 및 유지관리비 절감, 건설 기간 단축, 인력투입 최소화를 통해 **부가가치 제고**
|
||||
<br/>
|
||||
- **소통과 신뢰**
|
||||
- 성과품과 Solution을 통한 협업 강화로 **의사소통 효율 및 운영·유지관리**의 **편리성 증진**
|
||||
- 3D 모델 및 데이터 기반 검증을 통한 **오류 최소화 및 Claim 예방**으로 **신뢰성 확보**
|
||||

|
||||
<br/>
|
||||
|
||||
## 2. DX 기반 Process 혁신에 따른 주체별 기대효과
|
||||
<br/>
|
||||
### 2.1 업무 수행 과정(Process)의 변화
|
||||
- **생산 방식**: 수작업 의존의 반복 업무에서 벗어나, **SW를 활용한 체계화된 방식**으로 전환
|
||||
- **인지·검토**: 2D 도면 해석 중심에서 **3D 모델 기반의 직관적 인지·검토 체계**로 전환
|
||||
- **협업 구조**: 개별 문서 중심 협업에서 **데이터 통합 기반의 정보 공유·관리 협업 환경**으로 전환
|
||||
- **검증·대응**: 사후 대응 중심의 문제 처리에서 **사전 검증 중심의 예방적 업무 방식**으로 전환
|
||||
<br/>
|
||||
### 2.2 DX 시행 주체별 기대효과
|
||||
|
||||
<DxEffect />
|
||||
<br/>
|
||||
<br/>
|
||||
:::note[핵심 요약]
|
||||
* 고품질의 성과품, 비용 절감, 시간 단축, 의사소통에 도움이 안 되면 DX가 아니다.
|
||||
:::
|
||||
<br/>
|
||||
@@ -0,0 +1,262 @@
|
||||
---
|
||||
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 지연을 반복시키고 있다.
|
||||
:::
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
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/>설계의 본질과 경쟁력을 동시에 훼손한다.
|
||||
:::
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
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/>설계의 본질과 경쟁력을 동시에 훼손한다.
|
||||
:::
|
||||
@@ -2,6 +2,17 @@
|
||||
title: DX 지연 요인
|
||||
sidebar:
|
||||
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에 대한 인식
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
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/>설계의 본질과 경쟁력을 동시에 훼손한다.
|
||||
:::
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
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를 제공
|
||||

|
||||
<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 << 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)을 수행하는 과정에서 **가장 기초가 되는 일부분**이다
|
||||
:::
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
title: DX의 시행 목표 및 기대효과
|
||||
sidebar:
|
||||
order: 1
|
||||
---
|
||||
|
||||
import DxEffect from '../../../../components/dx.astro';
|
||||
|
||||
|
||||
## 1. DX의 궁극적 목표
|
||||
|
||||
- **안전과 품질**
|
||||
- 시설물의 요구 성능을 설계-시공-운영 전 과정에서 **디지털로 검증**하여 **안전성 확보**
|
||||
- Copy & Paste로 하향 평준화된 성과물의 **하자 최소화**로 **고품질 성과물 제공**
|
||||
<br/>
|
||||
- **생산성 향상**
|
||||
- Analogue 기반 업무를 Digital 기반 프로세스로 전환하여 **업무 속도·정확성·일관성 향상**
|
||||
- 건설 비용 및 유지관리비 절감, 건설 기간 단축, 인력투입 최소화를 통해 **부가가치 제고**
|
||||
<br/>
|
||||
- **소통과 신뢰**
|
||||
- 성과품과 Solution을 통한 협업 강화로 **의사소통 효율 및 운영·유지관리**의 **편리성 증진**
|
||||
- 3D 모델 및 데이터 기반 검증을 통한 **오류 최소화 및 Claim 예방**으로 **신뢰성 확보**
|
||||

|
||||
<br/>
|
||||
|
||||
## 2. DX 기반 Process 혁신에 따른 주체별 기대효과
|
||||
<br/>
|
||||
### 2.1 업무 수행 과정(Process)의 변화
|
||||
- **생산 방식**: 수작업 의존의 반복 업무에서 벗어나, **SW를 활용한 체계화된 방식**으로 전환
|
||||
- **인지·검토**: 2D 도면 해석 중심에서 **3D 모델 기반의 직관적 인지·검토 체계**로 전환
|
||||
- **협업 구조**: 개별 문서 중심 협업에서 **데이터 통합 기반의 정보 공유·관리 협업 환경**으로 전환
|
||||
- **검증·대응**: 사후 대응 중심의 문제 처리에서 **사전 검증 중심의 예방적 업무 방식**으로 전환
|
||||
<br/>
|
||||
### 2.2 DX 시행 주체별 기대효과
|
||||
|
||||
<DxEffect />
|
||||
<br/>
|
||||
<br/>
|
||||
:::note[핵심 요약]
|
||||
* 고품질의 성과품, 비용 절감, 시간 단축, 의사소통에 도움이 안 되면 DX가 아니다.
|
||||
:::
|
||||
<br/>
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
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는 필요한 요건과 체계를 갖춘 후 시행해야만 그 효과를 기대할 수 있다.**
|
||||
:::
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
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/>
|
||||

|
||||
<br/>
|
||||
---
|
||||
|
||||
:::note[핵심 요약]
|
||||
* DX는 필요한 요건과 체계를 갖춘 후 시행해야만 그 효과를 기대할 수 있다.
|
||||
:::
|
||||
@@ -0,0 +1,262 @@
|
||||
---
|
||||
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 지연을 반복시키고 있다.
|
||||
:::
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
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/>설계의 본질과 경쟁력을 동시에 훼손한다.
|
||||
:::
|
||||
@@ -0,0 +1,150 @@
|
||||
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())
|
||||
@@ -0,0 +1,82 @@
|
||||
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())
|
||||
@@ -0,0 +1,92 @@
|
||||
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())
|
||||
@@ -0,0 +1,179 @@
|
||||
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:]))
|
||||
@@ -0,0 +1,178 @@
|
||||
"""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 50–70% / 10–20s → 3–8s 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 50–70% / 10–20s → 3–8s "
|
||||
"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())
|
||||
@@ -0,0 +1,16 @@
|
||||
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)
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""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
@@ -17,7 +17,7 @@ class Settings(BaseSettings):
|
||||
# IMP-33 u1 — AI fallback policy. Fallback-path only; normal path AI=0.
|
||||
# Defaults locked by Stage 2 plan; do NOT inline literals downstream.
|
||||
ai_fallback_enabled: bool = False
|
||||
ai_fallback_model: str = "claude-opus-4-6-20250415"
|
||||
ai_fallback_model: str = "claude-opus-4-7"
|
||||
ai_fallback_timeout_s: float = 60.0
|
||||
ai_fallback_max_retries: int = 3
|
||||
ai_fallback_backoff_base_s: float = 1.0
|
||||
|
||||
@@ -392,6 +392,32 @@ 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]:
|
||||
"""MDX 원본을 4-Layer 파서로 정규화.
|
||||
|
||||
@@ -405,11 +431,13 @@ def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
|
||||
"popups": [{"title": str, "content": str}],
|
||||
"tables": [{"headers": list, "rows": list}],
|
||||
"sections": [{"level": int, "title": str, "content": str}],
|
||||
"slide_overrides": {"css": str, ...} | {},
|
||||
}
|
||||
"""
|
||||
# ── Layer 1: frontmatter 분리 ──
|
||||
metadata, body = frontmatter.parse(raw_mdx)
|
||||
title = metadata.get("title", "")
|
||||
slide_overrides = _extract_slide_overrides(metadata)
|
||||
logger.info(f"[Layer 1] title='{title}', metadata keys={list(metadata.keys())}")
|
||||
|
||||
# ── Layer 2: 코드블록 보호 → MDX 패턴 처리 ──
|
||||
@@ -437,6 +465,7 @@ def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
|
||||
"popups": popups,
|
||||
"tables": tables,
|
||||
"sections": sections,
|
||||
"slide_overrides": slide_overrides,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Extract content-preserving text atoms from MDX-like source.
|
||||
|
||||
The extractor is intentionally conservative: it ignores markdown/HTML/JSX
|
||||
syntax, style declarations, imports, and frontmatter, but preserves semantic
|
||||
text that a slide conversion pipeline must not add, drop, or rewrite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import html
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
_FRONTMATTER_RE = re.compile(r"\A---\s*\n.*?\n---\s*\n", re.DOTALL)
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\([^)]+\)")
|
||||
_SUMMARY_TAG_RE = re.compile(r"<summary\b[^>]*>(.*?)</summary>", re.IGNORECASE)
|
||||
_NOTE_RE = re.compile(r"^:::\s*(?:note|tip|info|warning|danger)(?:\[(.*?)\])?\s*$", re.IGNORECASE)
|
||||
_TABLE_SEPARATOR_RE = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(?:\|\s*:?-{2,}:?\s*)+\|?\s*$")
|
||||
_HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s+")
|
||||
_LIST_MARKER_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+")
|
||||
_EMPHASIS_RE = re.compile(r"[*_`~]+")
|
||||
_BR_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
|
||||
_STYLE_PROP_RE = re.compile(r"^\s*[A-Za-z][A-Za-z0-9_-]*\s*:\s*['\"#]?[A-Za-z0-9#().,% -]+['\"]?\s*,?\s*$")
|
||||
_JS_EVENT_RE = re.compile(r"^\s*(?:on[A-Z][A-Za-z]+|style|className)\s*=", re.ASCII)
|
||||
_IMPORT_EXPORT_RE = re.compile(r"^\s*(?:import|export)\s+")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextAtom:
|
||||
source: str
|
||||
line_no: int
|
||||
kind: str
|
||||
text: str
|
||||
normalized: str
|
||||
digest: str
|
||||
|
||||
|
||||
def normalize_text_atom(text: str) -> str:
|
||||
"""Normalize syntax-only variation while preserving semantic content."""
|
||||
|
||||
text = html.unescape(text)
|
||||
text = text.replace("\u00a0", " ")
|
||||
text = text.replace("<br/>", " ").replace("<br />", " ").replace("<br>", " ")
|
||||
text = _TAG_RE.sub(" ", text)
|
||||
text = _EMPHASIS_RE.sub("", text)
|
||||
text = text.replace("<", "<").replace(">", ">").replace("&", "&")
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def atom_digest(normalized: str) -> str:
|
||||
return hashlib.sha1(normalized.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def extract_text_atoms(path_or_text: str | Path, *, source_name: str | None = None) -> list[TextAtom]:
|
||||
if isinstance(path_or_text, Path):
|
||||
text = path_or_text.read_text(encoding="utf-8")
|
||||
source = source_name or path_or_text.name
|
||||
else:
|
||||
maybe_path = Path(path_or_text)
|
||||
if "\n" not in path_or_text and maybe_path.exists():
|
||||
text = maybe_path.read_text(encoding="utf-8")
|
||||
source = source_name or maybe_path.name
|
||||
else:
|
||||
text = path_or_text
|
||||
source = source_name or "<memory>"
|
||||
|
||||
text = _FRONTMATTER_RE.sub("", text)
|
||||
atoms: list[TextAtom] = []
|
||||
in_fenced_code = False
|
||||
|
||||
for line_no, raw_line in enumerate(text.splitlines(), start=1):
|
||||
line = raw_line.rstrip()
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if stripped.startswith("```"):
|
||||
in_fenced_code = not in_fenced_code
|
||||
continue
|
||||
if in_fenced_code:
|
||||
continue
|
||||
if _IMPORT_EXPORT_RE.match(stripped):
|
||||
continue
|
||||
if stripped in {"---", "<br/>", "<br />", "<br>", "</div>", "</details>"}:
|
||||
continue
|
||||
if stripped in {"{", "}", "});", "}}", "}}>", ">" }:
|
||||
continue
|
||||
if stripped.startswith(("e.currentTarget.", "<div style", "<p style", "<ul style", "<li style", "<h3 style")):
|
||||
continue
|
||||
if _JS_EVENT_RE.match(stripped) or _STYLE_PROP_RE.match(stripped):
|
||||
continue
|
||||
if stripped.startswith(("<div", "<p", "<ul", "<li", "<h")) and stripped.endswith(">") and not _TAG_RE.sub("", stripped).strip():
|
||||
continue
|
||||
if stripped.startswith("</"):
|
||||
continue
|
||||
|
||||
for alt in _IMAGE_RE.findall(stripped):
|
||||
_append_atom(atoms, source, line_no, "image_alt", alt)
|
||||
stripped = _IMAGE_RE.sub("", stripped).strip()
|
||||
if not stripped:
|
||||
continue
|
||||
|
||||
summary_match = _SUMMARY_TAG_RE.search(stripped)
|
||||
if summary_match:
|
||||
_append_atom(atoms, source, line_no, "details_summary", summary_match.group(1))
|
||||
stripped = _SUMMARY_TAG_RE.sub("", stripped).strip()
|
||||
if not stripped:
|
||||
continue
|
||||
|
||||
note_match = _NOTE_RE.match(stripped)
|
||||
if note_match:
|
||||
if note_match.group(1):
|
||||
_append_atom(atoms, source, line_no, "admonition_title", note_match.group(1))
|
||||
continue
|
||||
if stripped.startswith(":::"):
|
||||
continue
|
||||
|
||||
if "|" in stripped and stripped.count("|") >= 2:
|
||||
if _TABLE_SEPARATOR_RE.match(stripped):
|
||||
continue
|
||||
cells = [normalize_text_atom(c) for c in stripped.strip("|").split("|")]
|
||||
for cell in cells:
|
||||
if cell:
|
||||
_append_atom(atoms, source, line_no, "table_cell", cell)
|
||||
continue
|
||||
|
||||
kind = "paragraph"
|
||||
if _HEADING_RE.match(stripped):
|
||||
kind = "heading"
|
||||
stripped = _HEADING_RE.sub("", stripped)
|
||||
elif _LIST_MARKER_RE.match(stripped):
|
||||
kind = "list_item"
|
||||
stripped = _LIST_MARKER_RE.sub("", stripped)
|
||||
|
||||
stripped = re.sub(r"^\s*}+\s*>?\s*", "", stripped)
|
||||
stripped = _BR_RE.sub(" ", stripped)
|
||||
stripped = _TAG_RE.sub(" ", stripped)
|
||||
_append_atom(atoms, source, line_no, kind, stripped)
|
||||
|
||||
return atoms
|
||||
|
||||
|
||||
def compare_atom_sets(original_atoms: Iterable[TextAtom], standardized_atoms: Iterable[TextAtom]) -> dict[str, list[TextAtom]]:
|
||||
original_by_digest = {a.digest: a for a in original_atoms}
|
||||
standardized_by_digest = {a.digest: a for a in standardized_atoms}
|
||||
missing = [a for digest, a in original_by_digest.items() if digest not in standardized_by_digest]
|
||||
added = [a for digest, a in standardized_by_digest.items() if digest not in original_by_digest]
|
||||
return {"missing_from_standardized": missing, "added_in_standardized": added}
|
||||
|
||||
|
||||
def _append_atom(atoms: list[TextAtom], source: str, line_no: int, kind: str, text: str) -> None:
|
||||
normalized = normalize_text_atom(text)
|
||||
if not normalized:
|
||||
return
|
||||
if _looks_like_syntax_only(normalized):
|
||||
return
|
||||
atoms.append(
|
||||
TextAtom(
|
||||
source=source,
|
||||
line_no=line_no,
|
||||
kind=kind,
|
||||
text=text.strip(),
|
||||
normalized=normalized,
|
||||
digest=atom_digest(normalized),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _looks_like_syntax_only(text: str) -> bool:
|
||||
if text in {"/", "/>", ">", "{", "}", "});"}:
|
||||
return True
|
||||
if text.startswith(("style=", "onMouse", "cursor:", "fontWeight:", "color:", "margin", "padding")):
|
||||
return True
|
||||
if re.fullmatch(r"[{}(),;:'\"#.\-\s]+", text):
|
||||
return True
|
||||
return False
|
||||
@@ -31,6 +31,94 @@ _TRANSIENT_ERRORS: tuple[type[BaseException], ...] = (
|
||||
# Output cap is an Anthropic API requirement, not a policy knob (u1).
|
||||
_MAX_OUTPUT_TOKENS = 4096
|
||||
|
||||
# Issue #9 (Emergency P4 후속) — AiFallbackProposal envelope 키.
|
||||
# 모델이 plan 필드(operations / slot_axis_plan / ...)를 top-level 에 평평하게
|
||||
# 출력하는 envelope 위반이 관측됨 (extra="forbid" 전멸 → proposal 유실).
|
||||
# 프롬프트에 envelope 예시를 명시(u3)했고, 방어층으로 non-envelope 키를
|
||||
# payload 로 하향 이동하는 코어싱을 둔다. proposal_kind 추론은 하지 않음
|
||||
# (kind 없는 출력은 그대로 ValidationError — 정책 위반을 침묵 통과시키지 않음).
|
||||
_ENVELOPE_KEYS: frozenset[str] = frozenset({"proposal_kind", "payload", "rationale"})
|
||||
|
||||
|
||||
def _coerce_proposal_envelope(data: Any) -> Any:
|
||||
"""Move non-envelope top-level keys into ``payload`` (defence-in-depth).
|
||||
|
||||
Only activates when the parsed output is a dict that already carries
|
||||
``proposal_kind`` — i.e. the model understood the contract but flattened
|
||||
the plan fields. Existing ``payload`` entries win on key collision so a
|
||||
well-formed proposal is never mutated.
|
||||
"""
|
||||
if not isinstance(data, dict) or "proposal_kind" not in data:
|
||||
return data
|
||||
extras = [k for k in data.keys() if k not in _ENVELOPE_KEYS]
|
||||
if not extras:
|
||||
return data
|
||||
payload = data.get("payload")
|
||||
payload = dict(payload) if isinstance(payload, dict) else {}
|
||||
for key in extras:
|
||||
payload.setdefault(key, data[key])
|
||||
coerced = {
|
||||
"proposal_kind": data["proposal_kind"],
|
||||
"payload": payload,
|
||||
"rationale": data.get("rationale", ""),
|
||||
}
|
||||
# stdout diag — IMP-33 AST isolation whitelist 상 sys/logging import 금지,
|
||||
# pipeline [DIAG] 관례에 맞춰 stdout 사용.
|
||||
print(
|
||||
" [ai-fallback-client] envelope coercion: moved top-level keys "
|
||||
f"{sorted(extras)} into payload (prompt contract drift)."
|
||||
)
|
||||
return coerced
|
||||
|
||||
# IMP-92 u2 — Anthropic SDK exception → operational error kind classifier.
|
||||
# Stamped onto Step 12 AI repair records (api_error_kind) so the frontend
|
||||
# operational alert formatter can surface quota / billing / auth to users
|
||||
# while keeping non-operational ("other") failures silent. The classifier
|
||||
# is type-based (not string parsing) and the four kinds are the only
|
||||
# values frontend operational formatter is allowed to render.
|
||||
_OPERATIONAL_ERROR_KIND_QUOTA = "quota"
|
||||
_OPERATIONAL_ERROR_KIND_BILLING = "billing"
|
||||
_OPERATIONAL_ERROR_KIND_AUTH = "auth"
|
||||
_OPERATIONAL_ERROR_KIND_OTHER = "other"
|
||||
|
||||
|
||||
def classify_operational_error(exc: BaseException) -> str:
|
||||
"""Return the operational error kind for an Anthropic SDK exception.
|
||||
|
||||
Dispatch combines SDK exception type with the HTTP status code so the
|
||||
issue body's explicit operational contract (429 quota / 402 billing /
|
||||
401 auth) is honoured even when the SDK surfaces a 402 as the generic
|
||||
``anthropic.APIStatusError`` rather than a typed subclass:
|
||||
|
||||
* ``anthropic.RateLimitError`` OR HTTP 429 → ``"quota"``
|
||||
* ``anthropic.PermissionDeniedError`` OR HTTP 402 → ``"billing"``
|
||||
(Anthropic Payment Required surfaces as 402; PermissionDenied/403
|
||||
is the SDK-typed billing/permission surface)
|
||||
* ``anthropic.AuthenticationError`` OR HTTP 401 → ``"auth"``
|
||||
* everything else → ``"other"`` (silent on UI)
|
||||
|
||||
The frontend formatter renders quota / billing / auth and returns
|
||||
``None`` for ``"other"`` so non-operational AI failures stay silent
|
||||
per the #84 replacement-plan contract.
|
||||
"""
|
||||
if isinstance(exc, anthropic.RateLimitError):
|
||||
return _OPERATIONAL_ERROR_KIND_QUOTA
|
||||
if isinstance(exc, anthropic.PermissionDeniedError):
|
||||
return _OPERATIONAL_ERROR_KIND_BILLING
|
||||
if isinstance(exc, anthropic.AuthenticationError):
|
||||
return _OPERATIONAL_ERROR_KIND_AUTH
|
||||
if isinstance(exc, anthropic.APIStatusError):
|
||||
status_code = getattr(exc, "status_code", None)
|
||||
if status_code is None:
|
||||
status_code = getattr(getattr(exc, "response", None), "status_code", None)
|
||||
if status_code == 429:
|
||||
return _OPERATIONAL_ERROR_KIND_QUOTA
|
||||
if status_code == 402:
|
||||
return _OPERATIONAL_ERROR_KIND_BILLING
|
||||
if status_code == 401:
|
||||
return _OPERATIONAL_ERROR_KIND_AUTH
|
||||
return _OPERATIONAL_ERROR_KIND_OTHER
|
||||
|
||||
|
||||
class AiFallbackBudgetExceeded(RuntimeError):
|
||||
"""Per-run AI call budget (u1 ai_fallback_budget_per_run) exhausted."""
|
||||
@@ -78,7 +166,9 @@ class AiFallbackClient:
|
||||
block.text for block in response.content if hasattr(block, "text")
|
||||
)
|
||||
self._consecutive_failures = 0
|
||||
return AiFallbackProposal.model_validate(json.loads(text))
|
||||
return AiFallbackProposal.model_validate(
|
||||
_coerce_proposal_envelope(json.loads(text))
|
||||
)
|
||||
except _TRANSIENT_ERRORS as err:
|
||||
last_error = err
|
||||
if attempt >= settings.ai_fallback_max_retries:
|
||||
|
||||
@@ -25,12 +25,21 @@ SYSTEM_PROMPT = (
|
||||
"STRICT RULES:\n"
|
||||
" 1. MDX text in the user payload is READ-ONLY. Do NOT rewrite, "
|
||||
"compress, or paraphrase MDX.\n"
|
||||
" 2. Output MUST be a single JSON object conforming to AiFallbackProposal.\n"
|
||||
" 2. Output MUST be a single JSON object with EXACTLY this envelope — "
|
||||
"top-level keys are proposal_kind / payload / rationale ONLY; every "
|
||||
"plan detail (operations, slot_axis_plan, cardinality_result, ...) "
|
||||
"goes INSIDE payload:\n"
|
||||
' {"proposal_kind": "design_adaptation_plan", '
|
||||
'"payload": {"operations": [...]}, "rationale": "..."}\n'
|
||||
f" 3. proposal_kind MUST be one of: {_ALLOWED_KINDS}.\n"
|
||||
f" 4. Do NOT propose any of: {_FORBIDDEN_KINDS}.\n"
|
||||
" 5. Do NOT change frame_id — V4 rank-1 frame is locked.\n"
|
||||
" 6. Keep declared frame slots (text/table/image/details) populated.\n"
|
||||
" 7. Respect Internal Region containment; place content units within "
|
||||
" 6. Preferred Task-12 output is proposal_kind='design_adaptation_plan': "
|
||||
"plan frame/layout/repeat changes only; do NOT output final text slots.\n"
|
||||
" 7. Do NOT create titles, labels, summaries, body text, or slot content. "
|
||||
"The code layer will place MDX verbatim text.\n"
|
||||
" 8. Keep declared frame slots (text/table/image/details) accounted for.\n"
|
||||
" 9. Respect Internal Region containment; place content units within "
|
||||
"the declared region only."
|
||||
)
|
||||
|
||||
@@ -73,6 +82,34 @@ def build_ai_fallback_prompt(
|
||||
"figma_partial_json": figma_partial_json,
|
||||
"internal_region": internal_region,
|
||||
"mdx_text_READ_ONLY": mdx_text,
|
||||
"task12_output_contract": {
|
||||
"preferred_proposal_kind": ProposalKind.DESIGN_ADAPTATION_PLAN.value,
|
||||
"allowed_operation_examples": [
|
||||
"increase_repeat_count",
|
||||
"split_group_across_repeat_blocks",
|
||||
"rebalance_zone_ratio",
|
||||
"compact_spacing",
|
||||
"use_repeatable_frame",
|
||||
],
|
||||
# issue #14 — repeat-count op 의 정확한 스키마 (code 가 적용하는
|
||||
# 유일한 op 계열). count 는 top-level 정수 필드 "count" 사용.
|
||||
"operation_schema_example": {
|
||||
"op": "set_repeat_count",
|
||||
"target": "<repeat_axis_name>",
|
||||
"count": 5,
|
||||
},
|
||||
"forbidden_content_fields": [
|
||||
"text",
|
||||
"title",
|
||||
"label",
|
||||
"body",
|
||||
"slots",
|
||||
"mdx_text",
|
||||
"summary",
|
||||
"raw_html",
|
||||
"raw_css",
|
||||
],
|
||||
},
|
||||
}
|
||||
return {
|
||||
"system": SYSTEM_PROMPT,
|
||||
|
||||
@@ -4,6 +4,7 @@ Whitelisted proposal kinds (Stage 2 plan):
|
||||
- builder_options_patch : zone/frame builder option overrides
|
||||
- partial_overrides : Internal Region / Frame Slot content overrides
|
||||
- slot_mapping_proposal : restructuring proposal (content unit mapping)
|
||||
- design_adaptation_plan : structure-only plan; code applies verbatim MDX
|
||||
|
||||
Forbidden output forms (rejected by validator):
|
||||
- mdx_text (MDX read-only — `feedback_ai_isolation_contract`)
|
||||
@@ -23,6 +24,7 @@ class ProposalKind(str, Enum):
|
||||
BUILDER_OPTIONS_PATCH = "builder_options_patch"
|
||||
PARTIAL_OVERRIDES = "partial_overrides"
|
||||
SLOT_MAPPING_PROPOSAL = "slot_mapping_proposal"
|
||||
DESIGN_ADAPTATION_PLAN = "design_adaptation_plan"
|
||||
|
||||
|
||||
FORBIDDEN_KINDS: frozenset[str] = frozenset(
|
||||
|
||||
@@ -56,6 +56,7 @@ import hashlib
|
||||
import json
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
from src.phase_z2_ai_fallback.client import classify_operational_error
|
||||
from src.phase_z2_ai_fallback.router import route_ai_fallback
|
||||
from src.phase_z2_ai_fallback.signature import bucket_char_count, build_signature
|
||||
|
||||
@@ -96,6 +97,7 @@ def gather_step12_ai_repair_proposals(
|
||||
"skip_reason": str | None,
|
||||
"proposal": dict | None,
|
||||
"error": str | None,
|
||||
"api_error_kind": str | None, # IMP-92 u2 (quota|billing|auth|other)
|
||||
"cache_key": str | None, # IMP-46 u4
|
||||
"fingerprints": dict | None, # IMP-46 u4
|
||||
}
|
||||
@@ -130,6 +132,7 @@ def gather_step12_ai_repair_proposals(
|
||||
"skip_reason": None,
|
||||
"proposal": None,
|
||||
"error": None,
|
||||
"api_error_kind": None,
|
||||
"cache_key": None,
|
||||
"fingerprints": None,
|
||||
}
|
||||
@@ -205,6 +208,7 @@ def gather_step12_ai_repair_proposals(
|
||||
except Exception as exc: # noqa: BLE001 — record + continue, no AI re-raise
|
||||
record["ai_called"] = True
|
||||
record["error"] = f"{type(exc).__name__}: {exc}"
|
||||
record["api_error_kind"] = classify_operational_error(exc)
|
||||
records.append(record)
|
||||
continue
|
||||
if proposal is None:
|
||||
|
||||
@@ -27,6 +27,43 @@ class AiFallbackValidationError(ValueError):
|
||||
|
||||
_SLOT_KINDS = (ProposalKind.PARTIAL_OVERRIDES, ProposalKind.SLOT_MAPPING_PROPOSAL)
|
||||
|
||||
_DESIGN_PLAN_FORBIDDEN_CONTENT_KEYS: frozenset[str] = frozenset(
|
||||
{
|
||||
"body",
|
||||
"label",
|
||||
"labels",
|
||||
"mdx_text",
|
||||
"new_text",
|
||||
"raw_css",
|
||||
"raw_html",
|
||||
"slot_payload",
|
||||
"slots",
|
||||
"summary",
|
||||
"text",
|
||||
"title",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _find_forbidden_design_plan_keys(value: Any, path: str = "payload") -> list[str]:
|
||||
"""Return content-bearing keys that would let AI generate text.
|
||||
|
||||
Task 12 contract: design_adaptation_plan may describe structure changes,
|
||||
but code remains the sole layer that maps MDX verbatim text into slots.
|
||||
"""
|
||||
hits: list[str] = []
|
||||
if isinstance(value, dict):
|
||||
for key, nested in value.items():
|
||||
key_str = str(key)
|
||||
nested_path = f"{path}.{key_str}"
|
||||
if key_str in _DESIGN_PLAN_FORBIDDEN_CONTENT_KEYS:
|
||||
hits.append(nested_path)
|
||||
hits.extend(_find_forbidden_design_plan_keys(nested, nested_path))
|
||||
elif isinstance(value, list):
|
||||
for idx, nested in enumerate(value):
|
||||
hits.extend(_find_forbidden_design_plan_keys(nested, f"{path}[{idx}]"))
|
||||
return hits
|
||||
|
||||
|
||||
def validate_proposal(
|
||||
proposal: AiFallbackProposal,
|
||||
@@ -73,6 +110,19 @@ def validate_proposal(
|
||||
"from payload.slots (text/table/image/details must remain populated)."
|
||||
)
|
||||
|
||||
if proposal.proposal_kind is ProposalKind.DESIGN_ADAPTATION_PLAN:
|
||||
operations = payload.get("operations")
|
||||
if not isinstance(operations, list) or not operations:
|
||||
raise AiFallbackValidationError(
|
||||
"design adaptation plan: payload.operations must be a non-empty list."
|
||||
)
|
||||
forbidden_paths = _find_forbidden_design_plan_keys(payload)
|
||||
if forbidden_paths:
|
||||
raise AiFallbackValidationError(
|
||||
"design adaptation plan: AI must not emit content-bearing fields "
|
||||
f"{forbidden_paths}; code maps MDX verbatim text."
|
||||
)
|
||||
|
||||
region_id = payload.get("region_id")
|
||||
if region_id is not None and internal_region is not None:
|
||||
declared_region_id = internal_region.get("id")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user