DDisasterGPT API v3.1
Developer documentation

DisasterGPT API

SSE 채팅, 근거 표, PDF 페이지 연동을 위한 명세와 예제입니다.

API 상태 확인 중 Base URL · https://sw-kim.com Protocol · REST + SSE
Quickstart

첫 스트림 요청

채팅은 POST /api/chat/stream을 권장합니다. 응답은 text/event-stream이며 마지막에 data: [DONE]이 옵니다.

cURL
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"
  }'
직접 확인: 코드 작성 전 Swagger UI에서 Authorize 후 요청 구조를 확인하고, 완성된 화면은 ds.sw-kim.com에서 테스트할 수 있습니다.
Architecture

운영 환경은 서버 프록시를 권장합니다

API 키를 브라우저 번들에 넣지 않도록 Spring 등 서버가 DisasterGPT API를 호출하고 SSE를 그대로 전달합니다.

Authentication

API Key 인증

인증이 활성화된 환경의 모든 /api/* 요청에는 아래 헤더가 필요합니다. /health, /docs, /openapi.json은 공개 경로입니다.

HTTP header
X-API-KEY: YOUR_API_KEY
보안: 운영 웹앱에서 키를 JavaScript에 하드코딩하지 마세요. 예시 1은 빠른 검증용이고, 실제 배포에는 예시 2의 서버 프록시 구성을 사용하세요.
API Reference

핵심 엔드포인트

전체 보고서·HWPX API는 Swagger UI에서 확인할 수 있습니다. 아래는 챗 UI의 1순위 기능에 필요한 계약입니다.

POST/api/chat/stream채팅 SSE 스트림
요청 필드타입설명
question 필수string사용자 질문
historyarray{role, content} 대화 목록. 기본값 []
selected_pdfstring문서의 filename 또는 all
conversation_idstring최대 128자의 비식별 세션 UUID

성공 응답: 200 text/event-stream. chunk.content는 delta가 아닌 누적 완성 텍스트입니다.

GET/api/documentsPDF 문서 목록
200 application/json
{
  "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_idinteger/api/documents의 id
pageinteger0부터 시작
200 application/json
{
  "image": "iVBORw0KGgoAAA...",
  "page": 37,
  "total_pages": 120
}

문서·페이지 범위를 벗어나면 404, 렌더링 실패는 500입니다.

Streaming contract

SSE 이벤트 규약

type용도클라이언트 처리
status분석·검색·생성 단계 안내임시 상태 문구 표시
tool_status기상청 등 도구 상태선택적으로 배지 표시
refsPDF 근거와 선택적 tables참조 버튼·구조화 표 보관
chunkLLM 최종 답변content 전체를 교체
error스트림 내부 오류오류 UI 표시
[DONE]스트림 종료 센티널최종 Markdown 렌더링
refs event
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단계 | 호우주의보 발표 |"
  }]
}
Priority 01

Markdown 렌더링

chunk.content는 누적값이므로 매 이벤트마다 기존 내용을 교체합니다. 스트림 종료 후 GFM으로 변환하고 반드시 sanitizer를 통과시키세요.

JavaScript · marked + DOMPurify
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);
XSS 주의: marked.parse() 결과를 그대로 innerHTML에 넣으면 안 됩니다. 렌더러와 sanitizer는 서로 다른 역할입니다.
Priority 02

근거 표 첨부

refs.tables[].rows가 정본입니다. 셀은 textContent로 넣고 첫 행을 헤더로 렌더링하면 별도 HTML sanitizer 없이 원문 표를 안전하게 표시할 수 있습니다.

JavaScript · structured rows
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이 붙을 수 있으므로 구조화 표를 별도 렌더링할 때는 중복 표시를 제거합니다.
Priority 03

PDF 페이지 이동 버튼

근거 버튼을 누르면 refs[].docIdrefs[].page를 페이지 이미지 API에 그대로 넘깁니다. 이전·다음 버튼은 현재 페이지 경계에서 비활성화합니다.

refs[].page · 0-based페이지 API에 그대로 전달. 화면 표시는 page + 1.
tables[].page · 1-based사람이 읽는 원본 페이지. 페이지 API에는 직접 전달하지 않음.
HTML + JavaScript
<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;
}
Implementation example 01

브라우저 직접 연동 · 빠른 검증용

SSE 파싱과 누적 chunk 처리의 최소 예시입니다. 브라우저 기본 EventSource는 POST body를 보낼 수 없으므로 fetch() 스트림을 사용합니다.

JavaScript
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)));
}
Implementation example 02

Spring WebFlux 프록시 · 운영 권장

API Key는 서버 환경변수에만 보관합니다. WebClient가 FastAPI SSE를 읽고 타입화한 뒤 브라우저에 다시 SSE로 전달합니다.

application.yml
disastergpt:
  api:
    base-url: https://sw-kim.com
    key: ${DISASTERGPT_API_KEY:}
WebClientConfig.java
@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();
}
ChatService.java
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)));
}
ChatController.java
@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());
}
DTO 필수 필드: type, content, node, refs, tables, tools. 특히 tables를 누락하면 프록시 역직렬화 과정에서 표 데이터가 사라집니다.
Production checklist

배포 전 확인

  • API Key는 서버 환경변수나 Secret Manager에만 저장합니다.
  • SSE 프록시·로드밸런서의 response buffering을 끄고 충분한 idle timeout을 둡니다.
  • chunk.content를 delta처럼 이어 붙이지 않습니다.
  • Markdown HTML은 DOMPurify 등으로 정화합니다.
  • 구조화 표는 rows를 정본으로 사용하고 셀을 textContent로 삽입합니다.
  • refs.page와 페이지 이미지 API는 0-based임을 테스트합니다.
  • 403, 404, SSE error, 네트워크 중단 상태를 각각 처리합니다.