[Developer] #424 render VPD LLM answer as markdown

This commit is contained in:
devmrko
2026-06-25 13:49:09 +09:00
parent f0843957fa
commit 314d185b17
4 changed files with 211 additions and 9 deletions

View File

@@ -104,27 +104,36 @@ public class VpdPolicyService {
%s %s
[출력 형식] [출력 형식]
1. 정책 적용 범위 ## 요약
- 이 policy가 무엇을 허용/차단하는지 3줄 이내로 먼저 설명한다.
- fail-closed 조건이 있으면 요약에 포함한다.
- 컬럼 마스킹/NULL 처리 판단 가능 여부를 요약에 포함한다.
## 상세
### 1. 정책 적용 범위
- 대상 객체, 적용 SQL, enabled 여부를 한 문단으로 설명한다. - 대상 객체, 적용 SQL, enabled 여부를 한 문단으로 설명한다.
2. 필터 함수 입력과 조회 값 ### 2. 필터 함수 입력과 조회 값
- source에서 읽어오는 context/user/role/permission/rule 값을 bullet로 정리한다. - source에서 읽어오는 context/user/role/permission/rule 값을 bullet로 정리한다.
- 각 항목 옆에 source 근거를 짧게 적는다. 예: "v_user_id: SYS_CONTEXT(...)" - 각 항목 옆에 source 근거를 짧게 적는다. 예: "v_user_id: SYS_CONTEXT(...)"
3. Predicate 생성 분기 ### 3. Predicate 생성 분기
- source의 RETURN 값을 기준으로 분기별 표를 만든다. - source의 RETURN 값을 기준으로 Markdown 표를 만든다.
- 컬럼은 "조건", "반환 predicate", "의미", "보이는 행/차단되는 행"으로 한다. - 컬럼은 "조건", "반환 predicate", "의미", "보이는 행/차단되는 행"으로 한다.
- 표는 반드시 Markdown pipe table 형식으로 작성한다.
- '1=0' 같은 fail-closed predicate가 있으면 반드시 명시한다. - '1=0' 같은 fail-closed predicate가 있으면 반드시 명시한다.
4. 실제 접근 결과 해석 ### 4. 실제 접근 결과 해석
- 이 policy가 행(row)을 허용하는 조건과 제외하는 조건을 구분한다. - 이 policy가 행(row)을 허용하는 조건과 제외하는 조건을 구분한다.
- 컬럼 마스킹/NULL 처리는 source에 직접 있지 않으면 "이 policy source만으로는 판단 불가"라고 쓴다. - 컬럼 마스킹/NULL 처리는 source에 직접 있지 않으면 "이 policy source만으로는 판단 불가"라고 쓴다.
5. 운영 확인 포인트 ### 5. 운영 확인 포인트
- 운영자가 DB에서 확인할 테이블/컬럼/컨텍스트 값을 5개 이하로 적는다. - 운영자가 DB에서 확인할 테이블/컬럼/컨텍스트 값을 5개 이하로 적는다.
[엄격한 규칙] [엄격한 규칙]
- 한국어로 답변한다. - 한국어로 답변한다.
- Markdown 형식으로 답변한다.
- source에 없는 테이블, 컬럼, role 이름을 만들지 않는다. - source에 없는 테이블, 컬럼, role 이름을 만들지 않는다.
- source 근거가 부족하면 추측하지 말고 "source만으로는 판단 불가"라고 쓴다. - source 근거가 부족하면 추측하지 말고 "source만으로는 판단 불가"라고 쓴다.
- "일반적으로", "보통", "아마" 같은 표현을 쓰지 않는다. - "일반적으로", "보통", "아마" 같은 표현을 쓰지 않는다.

View File

@@ -550,7 +550,8 @@ body {
padding: .6rem .8rem; padding: .6rem .8rem;
} }
.ai-answer pre { .ai-answer pre,
.markdown-view {
color: var(--rw-text); color: var(--rw-text);
font-family: inherit; font-family: inherit;
font-size: .95rem; font-size: .95rem;
@@ -559,6 +560,74 @@ body {
white-space: pre-wrap; 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) { @media (max-width: 640px) {
.form-grid .span-2 { .form-grid .span-2 {
grid-column: span 1; grid-column: span 1;

View File

@@ -5,6 +5,125 @@ document.body.addEventListener('htmx:responseError', (event) => {
} }
}); });
function escapeHtml(value) {
return value
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function renderInlineMarkdown(value) {
return escapeHtml(value)
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>');
}
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) => `<th>${renderInlineMarkdown(cell)}</th>`).join('');
const bodyHtml = rows.map((row) => {
const cells = headers.map((_, index) => `<td>${renderInlineMarkdown(row[index] || '')}</td>`).join('');
return `<tr>${cells}</tr>`;
}).join('');
return {
html: `<table><thead><tr>${headerHtml}</tr></thead><tbody>${bodyHtml}</tbody></table>`,
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}>`);
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(`<h${level}>${renderInlineMarkdown(heading[2])}</h${level}>`);
continue;
}
const unordered = trimmed.match(/^[-*]\s+(.+)$/);
if (unordered) {
if (listType !== 'ul') {
closeList();
listType = 'ul';
html.push('<ul>');
}
html.push(`<li>${renderInlineMarkdown(unordered[1])}</li>`);
continue;
}
const ordered = trimmed.match(/^\d+\.\s+(.+)$/);
if (ordered) {
if (listType !== 'ol') {
closeList();
listType = 'ol';
html.push('<ol>');
}
html.push(`<li>${renderInlineMarkdown(ordered[1])}</li>`);
continue;
}
closeList();
html.push(`<p>${renderInlineMarkdown(trimmed)}</p>`);
}
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() { function filterUserRoleDetail() {
const master = document.getElementById('userRoleMaster'); const master = document.getElementById('userRoleMaster');
const table = document.getElementById('userRoleDetail'); const table = document.getElementById('userRoleDetail');
@@ -98,6 +217,7 @@ async function syncObjectCatalogSelection() {
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
renderMarkdownViews();
const master = document.getElementById('userRoleMaster'); const master = document.getElementById('userRoleMaster');
if (master) { if (master) {
master.addEventListener('change', filterUserRoleDetail); master.addEventListener('change', filterUserRoleDetail);
@@ -129,3 +249,7 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
}); });
}); });
document.body.addEventListener('htmx:afterSwap', (event) => {
renderMarkdownViews(event.detail.target || document);
});

View File

@@ -14,8 +14,8 @@
</div> </div>
</div> </div>
<section class="ai-answer"> <section class="ai-answer">
<h3>Answer</h3> <h3>분석 결과</h3>
<pre th:text="${explanation.answer()}">answer</pre> <div class="markdown-view" data-markdown-view th:text="${explanation.answer()}">answer</div>
</section> </section>
<div class="probe-exchange-grid"> <div class="probe-exchange-grid">
<section class="probe-exchange"> <section class="probe-exchange">