From 314d185b17dba8b8ef8f81d6576aabc84427534b Mon Sep 17 00:00:00 2001 From: devmrko Date: Thu, 25 Jun 2026 13:49:09 +0900 Subject: [PATCH] [Developer] #424 render VPD LLM answer as markdown --- .../service/VpdPolicyService.java | 21 ++- src/main/resources/static/css/app.css | 71 +++++++++- src/main/resources/static/js/app.js | 124 ++++++++++++++++++ .../fragments/vpd-policy-explanation.html | 4 +- 4 files changed, 211 insertions(+), 9 deletions(-) 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(cell)}`).join(''); + const bodyHtml = rows.map((row) => { + const cells = headers.map((_, index) => `${renderInlineMarkdown(row[index] || '')}`).join(''); + return `${cells}`; + }).join(''); + return { + html: `${headerHtml}${bodyHtml}
`, + next: cursor + }; +} + +function renderMarkdownText(markdown) { + const lines = markdown.replace(/\r\n/g, '\n').split('\n'); + const html = []; + let listType = null; + const closeList = () => { + if (listType) { + html.push(``); + listType = null; + } + }; + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + const trimmed = line.trim(); + if (!trimmed) { + closeList(); + continue; + } + if (isMarkdownTable(lines, i)) { + closeList(); + const table = renderMarkdownTable(lines, i); + html.push(table.html); + i = table.next - 1; + continue; + } + const heading = trimmed.match(/^(#{2,4})\s+(.+)$/); + if (heading) { + closeList(); + const level = Math.min(heading[1].length, 3); + html.push(`${renderInlineMarkdown(heading[2])}`); + continue; + } + const unordered = trimmed.match(/^[-*]\s+(.+)$/); + if (unordered) { + if (listType !== 'ul') { + closeList(); + listType = 'ul'; + html.push('