첫 스트림 요청
채팅은 POST /api/chat/stream을 권장합니다. 응답은 text/event-stream이며 마지막에 data: [DONE]이 옵니다.
curl -N https://sw-kim.com/api/chat/stream \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-H "X-API-KEY: YOUR_API_KEY" \
-d '{
"question": "호우 특보 시 지하공간 대피 절차는?",
"history": [],
"selected_pdf": "all",
"conversation_id": "browser-session-uuid"
}'
운영 환경은 서버 프록시를 권장합니다
API 키를 브라우저 번들에 넣지 않도록 Spring 등 서버가 DisasterGPT API를 호출하고 SSE를 그대로 전달합니다.
API Key 인증
인증이 활성화된 환경의 모든 /api/* 요청에는 아래 헤더가 필요합니다. /health, /docs, /openapi.json은 공개 경로입니다.
X-API-KEY: YOUR_API_KEY핵심 엔드포인트
전체 보고서·HWPX API는 Swagger UI에서 확인할 수 있습니다. 아래는 챗 UI의 1순위 기능에 필요한 계약입니다.
POST/api/chat/stream채팅 SSE 스트림
| 요청 필드 | 타입 | 설명 |
|---|---|---|
question 필수 | string | 사용자 질문 |
history | array | {role, content} 대화 목록. 기본값 [] |
selected_pdf | string | 문서의 filename 또는 all |
conversation_id | string | 최대 128자의 비식별 세션 UUID |
성공 응답: 200 text/event-stream. chunk.content는 delta가 아닌 누적 완성 텍스트입니다.
GET/api/documentsPDF 문서 목록
{
"documents": [
{ "id": 0, "name": "풍수해 매뉴얼", "filename": "manual.pdf", "total_pages": 120 }
]
}id는 페이지 이미지 API와 refs[].docId에 사용합니다. filename은 채팅 요청의 selected_pdf에 사용합니다.
GET/api/documents/{doc_id}/page/{page}PDF 페이지 PNG
| Path | 타입 | 기준 |
|---|---|---|
doc_id | integer | /api/documents의 id |
page | integer | 0부터 시작 |
{
"image": "iVBORw0KGgoAAA...",
"page": 37,
"total_pages": 120
}문서·페이지 범위를 벗어나면 404, 렌더링 실패는 500입니다.
SSE 이벤트 규약
| type | 용도 | 클라이언트 처리 |
|---|---|---|
status | 분석·검색·생성 단계 안내 | 임시 상태 문구 표시 |
tool_status | 기상청 등 도구 상태 | 선택적으로 배지 표시 |
refs | PDF 근거와 선택적 tables | 참조 버튼·구조화 표 보관 |
chunk | LLM 최종 답변 | content 전체를 교체 |
error | 스트림 내부 오류 | 오류 UI 표시 |
[DONE] | 스트림 종료 센티널 | 최종 Markdown 렌더링 |
data: {
"type": "refs",
"refs": [{ "docId": 0, "name": "풍수해 매뉴얼", "page": 37 }],
"tables": [{
"table_id": "TABLE_p038_0",
"page": 38,
"title": "재난대응 단계",
"grade": "A",
"rows": [["구분", "상황"], ["비상 1단계", "호우주의보 발표"]],
"markdown": "| 구분 | 상황 |\n|---|---|\n| 비상 1단계 | 호우주의보 발표 |"
}]
}Markdown 렌더링
chunk.content는 누적값이므로 매 이벤트마다 기존 내용을 교체합니다. 스트림 종료 후 GFM으로 변환하고 반드시 sanitizer를 통과시키세요.
function renderMarkdown(target, markdown) {
const source = (markdown ?? '').replace(/^[\u200B-\u200F\uFEFF]/, '');
const unsafeHtml = marked.parse(source, { gfm: true, breaks: true });
target.innerHTML = DOMPurify.sanitize(unsafeHtml, {
USE_PROFILES: { html: true }
});
target.classList.add('markdown-body');
}
// chunk.content는 누적값: append가 아니라 replace
if (event.type === 'chunk') answer = event.content;
if (streamFinished) renderMarkdown(answerElement, answer);marked.parse() 결과를 그대로 innerHTML에 넣으면 안 됩니다. 렌더러와 sanitizer는 서로 다른 역할입니다.근거 표 첨부
refs.tables[].rows가 정본입니다. 셀은 textContent로 넣고 첫 행을 헤더로 렌더링하면 별도 HTML sanitizer 없이 원문 표를 안전하게 표시할 수 있습니다.
function renderEvidenceTable(table) {
const figure = document.createElement('figure');
const caption = document.createElement('figcaption');
caption.textContent = `${table.title || '근거 표'} · p.${table.page}`;
const grid = document.createElement('table');
const head = grid.createTHead();
const body = grid.createTBody();
table.rows.forEach((row, rowIndex) => {
const tr = document.createElement('tr');
row.forEach(value => {
const cell = document.createElement(rowIndex === 0 ? 'th' : 'td');
cell.textContent = String(value ?? '');
tr.appendChild(cell);
});
(rowIndex === 0 ? head : body).appendChild(tr);
});
figure.append(caption, grid);
return figure;
}
function stripAttachedTableMarkdown(answer, tables) {
if (!tables.length) return answer;
const marker = '\n\n── 근거: ';
const markerIndex = answer.indexOf(marker);
return markerIndex >= 0 ? answer.slice(0, markerIndex) : answer;
}
if (event.type === 'refs') {
refs = event.refs ?? [];
tables = event.tables ?? [];
}rows를 우선 사용하고markdown은 간편 렌더링용 fallback으로만 사용합니다.- 표 기능이 비활성화됐거나 근거 페이지에 유효한 표가 없으면
tables필드 자체가 없을 수 있습니다. - 답변 끝에도 표 Markdown이 붙을 수 있으므로 구조화 표를 별도 렌더링할 때는 중복 표시를 제거합니다.
PDF 페이지 이동 버튼
근거 버튼을 누르면 refs[].docId와 refs[].page를 페이지 이미지 API에 그대로 넘깁니다. 이전·다음 버튼은 현재 페이지 경계에서 비활성화합니다.
refs[].page · 0-based페이지 API에 그대로 전달. 화면 표시는 page + 1.tables[].page · 1-based사람이 읽는 원본 페이지. 페이지 API에는 직접 전달하지 않음.<button id="prev" type="button">이전</button>
<span id="pageInfo">— / —</span>
<button id="next" type="button">다음</button>
<img id="pdfPage" alt="PDF 근거 페이지">
let docId = null, page = 0, totalPages = 0;
async function loadPage(nextDocId, nextPage) {
const res = await fetch(`/api/documents/${nextDocId}/page/${nextPage}`, {
headers: { 'X-API-KEY': API_KEY }
});
if (!res.ok) throw new Error(`PDF page: ${res.status}`);
const data = await res.json();
docId = nextDocId;
page = data.page;
totalPages = data.total_pages;
pdfPage.src = `data:image/png;base64,${data.image}`;
pageInfo.textContent = `${page + 1} / ${totalPages}`;
prev.disabled = page === 0;
next.disabled = page >= totalPages - 1;
}
prev.onclick = () => loadPage(docId, page - 1);
next.onclick = () => loadPage(docId, page + 1);
function jumpToRef(ref) { return loadPage(ref.docId, ref.page); }function makeReferenceButton(ref) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = `${ref.name} · p.${ref.page + 1}`;
button.onclick = () => jumpToRef(ref);
return button;
}브라우저 직접 연동 · 빠른 검증용
SSE 파싱과 누적 chunk 처리의 최소 예시입니다. 브라우저 기본 EventSource는 POST body를 보낼 수 없으므로 fetch() 스트림을 사용합니다.
async function streamChat(question) {
const response = await fetch('https://sw-kim.com/api/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
'X-API-KEY': API_KEY
},
body: JSON.stringify({ question, history: [], selected_pdf: 'all' })
});
if (!response.ok) throw new Error(`Chat API: ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '', answer = '', refs = [], tables = [];
while (true) {
const { value, done } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const raw = line.slice(5).trim();
if (!raw || raw === '[DONE]') continue;
const event = JSON.parse(raw);
if (event.type === 'chunk') answer = event.content; // 누적값 교체
if (event.type === 'refs') {
refs = event.refs || [];
tables = event.tables || [];
}
if (event.type === 'error') throw new Error(event.content);
}
if (done) break;
}
// 구조화 표를 그릴 때 답변 끝의 동일한 Markdown 표는 제거한다.
renderMarkdown(answerElement, stripAttachedTableMarkdown(answer, tables));
tableArea.replaceChildren();
referenceArea.replaceChildren();
tables.forEach(table => tableArea.append(renderEvidenceTable(table)));
refs.forEach(ref => referenceArea.append(makeReferenceButton(ref)));
}Spring WebFlux 프록시 · 운영 권장
API Key는 서버 환경변수에만 보관합니다. WebClient가 FastAPI SSE를 읽고 타입화한 뒤 브라우저에 다시 SSE로 전달합니다.
disastergpt:
api:
base-url: https://sw-kim.com
key: ${DISASTERGPT_API_KEY:}@Bean
WebClient disasterGptWebClient(WebClient.Builder builder,
@Value("${disastergpt.api.base-url}") String baseUrl,
@Value("${disastergpt.api.key:}") String apiKey) {
var client = builder.baseUrl(baseUrl);
if (!apiKey.isBlank()) client.defaultHeader("X-API-KEY", apiKey);
return client.build();
}public Flux<StreamChunk> stream(ChatRequest request) {
return webClient.post()
.uri("/api/chat/stream")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.TEXT_EVENT_STREAM)
.bodyValue(request)
.retrieve()
.bodyToFlux(new ParameterizedTypeReference<ServerSentEvent<String>>() {})
.mapNotNull(ServerSentEvent::data)
.takeUntil("[DONE]"::equals)
.filter(data -> !"[DONE]".equals(data))
.concatMap(data -> Mono.fromCallable(
() -> objectMapper.readValue(data, StreamChunk.class)));
}@PostMapping(value = "/chat/stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<StreamChunk>> stream(
@RequestBody ChatRequest request) {
return chatService.stream(request)
.map(chunk -> ServerSentEvent.builder(chunk).build());
}type, content, node, refs, tables, tools. 특히 tables를 누락하면 프록시 역직렬화 과정에서 표 데이터가 사라집니다.배포 전 확인
- API Key는 서버 환경변수나 Secret Manager에만 저장합니다.
- SSE 프록시·로드밸런서의 response buffering을 끄고 충분한 idle timeout을 둡니다.
chunk.content를 delta처럼 이어 붙이지 않습니다.- Markdown HTML은 DOMPurify 등으로 정화합니다.
- 구조화 표는
rows를 정본으로 사용하고 셀을textContent로 삽입합니다. refs.page와 페이지 이미지 API는 0-based임을 테스트합니다.403,404, SSEerror, 네트워크 중단 상태를 각각 처리합니다.