diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java
index 4b76f99..7170363 100644
--- a/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java
+++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java
@@ -104,27 +104,36 @@ public class VpdPolicyService {
%s
[출력 형식]
- 1. 정책 적용 범위
+ ## 요약
+ - 이 policy가 무엇을 허용/차단하는지 3줄 이내로 먼저 설명한다.
+ - fail-closed 조건이 있으면 요약에 포함한다.
+ - 컬럼 마스킹/NULL 처리 판단 가능 여부를 요약에 포함한다.
+
+ ## 상세
+
+ ### 1. 정책 적용 범위
- 대상 객체, 적용 SQL, enabled 여부를 한 문단으로 설명한다.
- 2. 필터 함수 입력과 조회 값
+ ### 2. 필터 함수 입력과 조회 값
- source에서 읽어오는 context/user/role/permission/rule 값을 bullet로 정리한다.
- 각 항목 옆에 source 근거를 짧게 적는다. 예: "v_user_id: SYS_CONTEXT(...)"
- 3. Predicate 생성 분기
- - source의 RETURN 값을 기준으로 분기별 표를 만든다.
+ ### 3. Predicate 생성 분기
+ - source의 RETURN 값을 기준으로 Markdown 표를 만든다.
- 컬럼은 "조건", "반환 predicate", "의미", "보이는 행/차단되는 행"으로 한다.
+ - 표는 반드시 Markdown pipe table 형식으로 작성한다.
- '1=0' 같은 fail-closed predicate가 있으면 반드시 명시한다.
- 4. 실제 접근 결과 해석
+ ### 4. 실제 접근 결과 해석
- 이 policy가 행(row)을 허용하는 조건과 제외하는 조건을 구분한다.
- 컬럼 마스킹/NULL 처리는 source에 직접 있지 않으면 "이 policy source만으로는 판단 불가"라고 쓴다.
- 5. 운영 확인 포인트
+ ### 5. 운영 확인 포인트
- 운영자가 DB에서 확인할 테이블/컬럼/컨텍스트 값을 5개 이하로 적는다.
[엄격한 규칙]
- 한국어로 답변한다.
+ - Markdown 형식으로 답변한다.
- source에 없는 테이블, 컬럼, role 이름을 만들지 않는다.
- source 근거가 부족하면 추측하지 말고 "source만으로는 판단 불가"라고 쓴다.
- "일반적으로", "보통", "아마" 같은 표현을 쓰지 않는다.
diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css
index 2425723..8e7b894 100644
--- a/src/main/resources/static/css/app.css
+++ b/src/main/resources/static/css/app.css
@@ -550,7 +550,8 @@ body {
padding: .6rem .8rem;
}
-.ai-answer pre {
+.ai-answer pre,
+.markdown-view {
color: var(--rw-text);
font-family: inherit;
font-size: .95rem;
@@ -559,6 +560,74 @@ body {
white-space: pre-wrap;
}
+.markdown-view {
+ line-height: 1.6;
+ overflow-x: auto;
+}
+
+.markdown-view h2,
+.markdown-view h3 {
+ border: 0;
+ color: var(--rw-text);
+ margin: .9rem 0 .45rem;
+ padding: 0;
+}
+
+.markdown-view h2:first-child,
+.markdown-view h3:first-child {
+ margin-top: 0;
+}
+
+.markdown-view h2 {
+ font-size: 1.05rem;
+}
+
+.markdown-view h3 {
+ font-size: .98rem;
+}
+
+.markdown-view p {
+ margin: .45rem 0;
+}
+
+.markdown-view ul,
+.markdown-view ol {
+ margin: .45rem 0 .65rem 1.25rem;
+ padding: 0;
+}
+
+.markdown-view code {
+ background: rgba(49, 45, 42, .08);
+ border: 1px solid var(--rw-border);
+ border-radius: 4px;
+ color: var(--rw-text);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
+ font-size: .88em;
+ padding: .08rem .28rem;
+}
+
+.markdown-view table {
+ background: var(--rw-surface);
+ border-collapse: collapse;
+ font-size: .86rem;
+ margin: .7rem 0;
+ min-width: 760px;
+ width: 100%;
+}
+
+.markdown-view th,
+.markdown-view td {
+ border: 1px solid var(--rw-border);
+ padding: .55rem .65rem;
+ text-align: left;
+ vertical-align: top;
+}
+
+.markdown-view th {
+ background: var(--rw-surface-muted);
+ font-weight: 800;
+}
+
@media (max-width: 640px) {
.form-grid .span-2 {
grid-column: span 1;
diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js
index 8ae8e8b..929b655 100644
--- a/src/main/resources/static/js/app.js
+++ b/src/main/resources/static/js/app.js
@@ -5,6 +5,125 @@ document.body.addEventListener('htmx:responseError', (event) => {
}
});
+function escapeHtml(value) {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+function renderInlineMarkdown(value) {
+ return escapeHtml(value)
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
+ .replace(/`([^`]+)`/g, '$1');
+}
+
+function isMarkdownTable(lines, index) {
+ if (index + 1 >= lines.length) {
+ return false;
+ }
+ return lines[index].trim().startsWith('|')
+ && lines[index + 1].trim().startsWith('|')
+ && /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/.test(lines[index + 1].trim());
+}
+
+function splitMarkdownTableRow(line) {
+ return line.trim()
+ .replace(/^\|/, '')
+ .replace(/\|$/, '')
+ .split('|')
+ .map((cell) => cell.trim());
+}
+
+function renderMarkdownTable(lines, start) {
+ const headers = splitMarkdownTableRow(lines[start]);
+ let cursor = start + 2;
+ const rows = [];
+ while (cursor < lines.length && lines[cursor].trim().startsWith('|')) {
+ rows.push(splitMarkdownTableRow(lines[cursor]));
+ cursor += 1;
+ }
+ const headerHtml = headers.map((cell) => `
${renderInlineMarkdown(trimmed)}
`); + } + closeList(); + return html.join(''); +} + +function renderMarkdownViews(root = document) { + root.querySelectorAll('[data-markdown-view]').forEach((view) => { + if (view.dataset.rendered === 'true') { + return; + } + view.innerHTML = renderMarkdownText(view.textContent || ''); + view.dataset.rendered = 'true'; + }); +} + function filterUserRoleDetail() { const master = document.getElementById('userRoleMaster'); const table = document.getElementById('userRoleDetail'); @@ -98,6 +217,7 @@ async function syncObjectCatalogSelection() { } document.addEventListener('DOMContentLoaded', () => { + renderMarkdownViews(); const master = document.getElementById('userRoleMaster'); if (master) { master.addEventListener('change', filterUserRoleDetail); @@ -129,3 +249,7 @@ document.addEventListener('DOMContentLoaded', () => { }); }); }); + +document.body.addEventListener('htmx:afterSwap', (event) => { + renderMarkdownViews(event.detail.target || document); +}); diff --git a/src/main/resources/templates/fragments/vpd-policy-explanation.html b/src/main/resources/templates/fragments/vpd-policy-explanation.html index c0dbb87..6021db6 100644 --- a/src/main/resources/templates/fragments/vpd-policy-explanation.html +++ b/src/main/resources/templates/fragments/vpd-policy-explanation.html @@ -14,8 +14,8 @@answer+