🚨 재난안전 AI API

Build with DisasterGPT

재난 대응 매뉴얼 기반 RAG + LLM 파이프라인 API.
Guard → Analyzer → Executor → LLM 구조. 기상상황보고서 자동 생성 포함. Spring WebFlux 코드 예제를 바로 복사해서 쓰세요.

Spring 예제 보기 Swagger UI openapi.json
8
엔드포인트
SSE
스트리밍 방식
RAG
PDF 기반 검색
🔑
X-API-KEY 인증
서버 확인 중...
API 상태

인증 (API Key)

모든 /api/* 엔드포인트는 X-API-KEY 헤더가 필요합니다. /health, /docs는 인증 불필요.

모든 요청에 아래 헤더를 포함하세요:
X-API-KEY: {발급받은_키}
💡 Swagger에서 테스트하려면/docs 접속 후 우측 상단 Authorize 버튼 클릭 → API Key 입력

채팅 Endpoints

베이스 URL: https://sw-kim.com

GET /health 서버 상태 확인

서버와 LangGraph 파이프라인 준비 여부를 반환합니다.

curl https://sw-kim.com/health

응답 예시:

{"status": "ok", "graph_ready": true}
GET /api/documents PDF 문서 목록 조회

서버에 등록된 재난 매뉴얼 PDF 목록을 반환합니다. selected_pdf 파라미터에 사용할 filename을 확인하세요.

curl https://sw-kim.com/api/documents \
  -H "X-API-KEY: {발급받은_키}"

응답 예시:

{
  "documents": [
    {"id": 0, "name": "풍수해재난현장조치행동매뉴얼", "filename": "풍수해 재난 현장조치 행동매뉴얼.pdf", "total_pages": 120},
    {"id": 1, "name": "군산지방해양수산청현장조치매뉴얼", "filename": "현장조치 행동매뉴얼(해양수산부 군산지방해양수산청).pdf", "total_pages": 85}
  ]
}
GET /api/documents/{doc_id}/page/{page} PDF 페이지 이미지

특정 PDF의 페이지를 base64 PNG로 반환합니다.

파라미터타입위치설명
doc_id 필수integerpath문서 ID (/api/documents 에서 확인)
page 필수integerpath페이지 번호 (0-based)
curl https://sw-kim.com/api/documents/0/page/0 \
  -H "X-API-KEY: {발급받은_키}"
POST /api/chat 단일 응답 채팅

질문을 보내고 완성된 답변을 한 번에 받습니다. 간단한 테스트나 배치 처리에 적합합니다.

필드타입기본값설명
question 필수string사용자 질문
history 선택array[]이전 대화 기록 [{role, content}]
selected_pdf 선택string"all"특정 PDF 파일명 또는 "all"
curl -X POST https://sw-kim.com/api/chat \
  -H "Content-Type: application/json" \
  -H "X-API-KEY: {발급받은_키}" \
  -d '{
    "question": "호우 특보 시 대피 요령은?",
    "history": [],
    "selected_pdf": "all"
  }'
POST /api/chat HTTP/1.1
Host: sw-kim.com
Content-Type: application/json

{
  "question": "호우 특보 시 대피 요령은?",
  "history": [
    {"role": "user",      "content": "이전 질문"},
    {"role": "assistant", "content": "이전 답변"}
  ],
  "selected_pdf": "풍수해 재난 현장조치 행동매뉴얼.pdf"
}

응답 (ChatResponse):

{
  "answer": "호우 특보 발령 시 즉시 저지대 침수 취약지역을 확인하고...",
  "reference_docs": "풍수해 재난 현장조치 행동매뉴얼.pdf",
  "thinking_content": ""
}
POST /api/chat/stream SSE 스트리밍 채팅 ← 권장

Spring에서 사용하는 메인 엔드포인트입니다.
요청 필드는 /api/chat과 동일하며, 응답을 Server-Sent Events(SSE)로 스트리밍합니다. 이벤트 타입은 아래 SSE 이벤트 섹션을 참조하세요.

curl -X POST https://sw-kim.com/api/chat/stream \
  -H "Content-Type: application/json" \
  -H "X-API-KEY: {발급받은_키}" \
  -d '{"question": "화재 대피 방법은?"}' \
  --no-buffer

기상상황보고서 Endpoints

기상청 API를 직접 호출해 DOCX/PDF 보고서를 자동 생성합니다. LangGraph 파이프라인을 우회하는 독립 파이프라인입니다.

POST /api/report/generate 보고서 생성 SSE

기상특보·기온·조석·적설·기상전망 데이터를 수집하고 DOCX/PDF 보고서를 생성합니다. 진행 상황을 SSE로 스트리밍합니다.

필드타입기본값설명
base_datetime 필수string보고서 기준 일시 "YYYY-MM-DD HH:MM"
template_id 선택string"기상상황보고"보고서 템플릿 ID
airforce_forecast 선택string""공군 기상대 전망 텍스트 (API 없음, UI 직접 입력)
curl -X POST https://sw-kim.com/api/report/generate \
  -H "Content-Type: application/json" \
  -H "X-API-KEY: {발급받은_키}" \
  -d '{
    "base_datetime": "2026-03-30 06:00",
    "airforce_forecast": "전라북도는 내일 오전까지 흐리겠습니다."
  }' --no-buffer

SSE 이벤트 흐름:

data: {"type":"step","step":"기상특보 수집","status":"running"}
data: {"type":"step","step":"기상특보 수집","status":"done","summary":"군산 기상특보 없음"}
data: {"type":"step","step":"조치사항 생성","status":"done","summary":"1. 저지대 침수...","rag_sources":[...]}
data: {"type":"done","report_id":"abc12345","report_json":{...},"docx_url":"/api/report/download/abc12345?format=docx","pdf_url":"/api/report/download/abc12345?format=pdf","review_needed":true}
data: [DONE]
POST /api/report/edit-stream 조치사항 AI 편집 SSE

보고서 조치사항 텍스트를 LLM으로 직접 편집합니다. LangGraph 파이프라인을 우회하고 LLM을 직접 호출합니다.
이벤트: chunk (누적 텍스트) → [DONE]

필드타입설명
current_plan 필수string현재 조치사항 텍스트
weather_context 선택string기상 컨텍스트 (보고서 데이터 요약)
instruction 필수string편집 지시사항 (예: "더 간결하게 다듬어줘")
curl -X POST https://sw-kim.com/api/report/edit-stream \
  -H "Content-Type: application/json" \
  -H "X-API-KEY: {발급받은_키}" \
  -d '{
    "current_plan": "1. 저지대 주민 대피 조치\n2. 배수펌프 가동 준비",
    "weather_context": "기상특보: 호우주의보\n기온: 최고 12도",
    "instruction": "더 구체적인 시간 기준을 추가해줘"
  }' --no-buffer
GET /api/report/download/{report_id} 보고서 파일 다운로드

생성된 보고서를 DOCX 또는 PDF로 다운로드합니다. report_id/api/report/generatedone 이벤트에서 반환됩니다.

파라미터타입위치설명
report_id 필수stringpath보고서 ID (done 이벤트에서 수신)
format 선택stringquerydocx 또는 pdf (기본: docx)
# DOCX 다운로드
curl "https://sw-kim.com/api/report/download/abc12345?format=docx" \
  -H "X-API-KEY: {발급받은_키}" -O

# PDF 다운로드
curl "https://sw-kim.com/api/report/download/abc12345?format=pdf" \
  -H "X-API-KEY: {발급받은_키}" -O

SSE 이벤트 포맷

채팅 스트림 (/api/chat/stream)

type데이터 예시설명
status {"type":"status","content":"질문을 분석하고 있습니다..."} 파이프라인 단계 진행 알림 (로딩 UI용)
chunk {"type":"chunk","content":"지금까지 누적된 답변...","node":"final"} 누적 답변 텍스트 (매 토큰마다 전체 누적값). EXAONE thinking은 서버에서 필터링
refs {"type":"refs","refs":[{"docId":0,"name":"군산지방해양수산청","page":5}]} RAG 참고 문서 및 페이지 번호 (chunk 이전에 도착). docId로 /api/documents/{docId}/page/{page} 호출 가능
tool_status {"type":"tool_status","tools":[{"tool":"kma_get_wthr_wrn_msg","data_source":"real"}]} 기상청 API 도구 실행 결과. data_source: "real" | "mock"
error {"type":"error","content":"오류 메시지"} 처리 중 오류 발생
[DONE] data: [DONE] 스트림 종료 신호

보고서 스트림 (/api/report/generate)

type데이터 예시설명
step {"type":"step","step":"기상특보 수집","status":"running"|"done"|"error","summary":"..."} 수집/생성 단계 진행. 조치사항 생성 단계에서는 rag_sources 필드 포함
done {"type":"done","report_id":"abc12345","report_json":{...},"docx_url":"...","pdf_url":"...","review_needed":true} 보고서 생성 완료. review_neededtrue이면 기상특보(주의보/경보/태풍) 상황으로 조치사항 검토 권장
error {"type":"error","content":"오류 메시지"} 처리 중 오류 발생
[DONE] data: [DONE] 스트림 종료 신호

Spring 코드 예제

복사 후 바로 사용 가능한 Spring WebFlux 코드입니다.

ChatRequest.java — 채팅 요청 DTO
package com.disastergpt.dto;

import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import java.util.Map;

public class ChatRequest {

    private String question;
    private List<Map<String, String>> history;

    @JsonProperty("selected_pdf")
    private String selectedPdf = "all";

    public ChatRequest() {}

    public String getQuestion() { return question; }
    public void setQuestion(String question) { this.question = question; }

    public List<Map<String, String>> getHistory() { return history; }
    public void setHistory(List<Map<String, String>> history) { this.history = history; }

    public String getSelectedPdf() { return selectedPdf; }
    public void setSelectedPdf(String selectedPdf) { this.selectedPdf = selectedPdf; }
}
StreamChunk.java — SSE 이벤트 DTO (채팅 + 보고서 공용)
package com.disastergpt.dto;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import java.util.Map;

@JsonInclude(JsonInclude.Include.NON_NULL)
public class StreamChunk {

    // chat: "status" | "chunk" | "refs" | "tool_status" | "error"
    // report: "step" | "done" | "error"
    private String type;

    // ── 채팅 필드 ──────────────────────────────────────
    private String content;                      // status·chunk·error
    private String node;                         // chunk 일 때 "final"
    private List<Map<String, Object>> refs;      // refs: [{docId, name, page}]
    private List<Map<String, Object>> tools;     // tool_status: [{tool, data_source}]

    // ── 보고서 step 이벤트 ──────────────────────────────
    private String step;
    private String status;    // "running" | "done" | "error"
    private String summary;

    // ── 보고서 done 이벤트 ─────────────────────────────
    @JsonProperty("review_needed")
    private Boolean reviewNeeded;

    @JsonProperty("report_id")
    private String reportId;

    @JsonProperty("report_json")
    private Map<String, Object> reportJson;

    @JsonProperty("docx_url")
    private String docxUrl;

    @JsonProperty("pdf_url")
    private String pdfUrl;

    // ── getters / setters ──────────────────────────────
    public String getType() { return type; }
    public void setType(String type) { this.type = type; }
    public String getContent() { return content; }
    public void setContent(String content) { this.content = content; }
    public String getNode() { return node; }
    public void setNode(String node) { this.node = node; }
    public List<Map<String, Object>> getRefs() { return refs; }
    public void setRefs(List<Map<String, Object>> refs) { this.refs = refs; }
    public List<Map<String, Object>> getTools() { return tools; }
    public void setTools(List<Map<String, Object>> tools) { this.tools = tools; }
    public String getStep() { return step; }
    public void setStep(String step) { this.step = step; }
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
    public String getSummary() { return summary; }
    public void setSummary(String summary) { this.summary = summary; }

    @JsonProperty("review_needed")
    public Boolean getReviewNeeded() { return reviewNeeded; }
    public void setReviewNeeded(Boolean reviewNeeded) { this.reviewNeeded = reviewNeeded; }

    @JsonProperty("report_id")
    public String getReportId() { return reportId; }
    public void setReportId(String reportId) { this.reportId = reportId; }

    @JsonProperty("report_json")
    public Map<String, Object> getReportJson() { return reportJson; }
    public void setReportJson(Map<String, Object> reportJson) { this.reportJson = reportJson; }

    @JsonProperty("docx_url")
    public String getDocxUrl() { return docxUrl; }
    public void setDocxUrl(String docxUrl) { this.docxUrl = docxUrl; }

    @JsonProperty("pdf_url")
    public String getPdfUrl() { return pdfUrl; }
    public void setPdfUrl(String pdfUrl) { this.pdfUrl = pdfUrl; }
}
ReportRequest.java — 보고서 생성 요청 DTO
package com.disastergpt.dto;

import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;

public class ReportRequest {

    private String templateId = "기상상황보고";
    private String baseDatetime;   // "2026-03-29 22:00"
    private String airforceForecast = "";

    @JsonProperty("template_id") @JsonAlias("templateId")
    public String getTemplateId() { return templateId; }
    public void setTemplateId(String v) { this.templateId = v; }

    @JsonProperty("base_datetime") @JsonAlias("baseDatetime")
    public String getBaseDatetime() { return baseDatetime; }
    public void setBaseDatetime(String v) { this.baseDatetime = v; }

    @JsonProperty("airforce_forecast") @JsonAlias("airforceForecast")
    public String getAirforceForecast() { return airforceForecast; }
    public void setAirforceForecast(String v) { this.airforceForecast = v; }
}
EditRequest.java — 조치사항 AI 편집 요청 DTO
package com.disastergpt.dto;

import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonProperty;

public class EditRequest {

    private String currentPlan = "";
    private String weatherContext = "";
    private String instruction = "";

    @JsonProperty("current_plan") @JsonAlias("currentPlan")
    public String getCurrentPlan() { return currentPlan; }
    public void setCurrentPlan(String v) { this.currentPlan = v; }

    @JsonProperty("weather_context") @JsonAlias("weatherContext")
    public String getWeatherContext() { return weatherContext; }
    public void setWeatherContext(String v) { this.weatherContext = v; }

    @JsonProperty("instruction")
    public String getInstruction() { return instruction; }
    public void setInstruction(String v) { this.instruction = v; }
}
application.yml
server:
  port: 8080

disastergpt:
  api:
    base-url: https://sw-kim.com
    key: ${DISASTER_API_KEY:}   # 환경변수로 주입
WebClientConfig.java
package com.disastergpt.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;

@Configuration
public class WebClientConfig {

    @Value("${disastergpt.api.base-url}")
    private String baseUrl;

    @Value("${disastergpt.api.key}")
    private String apiKey;

    @Bean
    public WebClient disasterGptWebClient(WebClient.Builder builder) {
        return builder
                .baseUrl(baseUrl)
                .defaultHeader("X-API-KEY", apiKey)
                // X-Trace-ID는 ChatService에서 요청 시 직접 주입 (Reactor Context 기반)
                .codecs(cfg -> cfg.defaultCodecs().maxInMemorySize(10 * 1024 * 1024)) // 10MB
                .build();
    }
}
ChatService.java — WebClient SSE 스트리밍 (실제 구현)
package com.disastergpt.service;

import com.disastergpt.dto.ChatRequest;
import com.disastergpt.dto.ChatResponse;
import com.disastergpt.dto.StreamChunk;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;

@Service
public class ChatService {

    private static final Logger log = LoggerFactory.getLogger(ChatService.class);

    private final WebClient webClient;
    private final ObjectMapper objectMapper;

    public ChatService(WebClient disasterGptWebClient, ObjectMapper objectMapper) {
        this.webClient = disasterGptWebClient;
        this.objectMapper = objectMapper;
    }

    /** 단일 응답 (테스트·배치용) */
    public Mono<ChatResponse> chat(ChatRequest request, String traceId) {
        log.info("[TRACE {}][FASTAPI ][CALL    ] POST /api/chat", traceId);
        long start = System.currentTimeMillis();
        return webClient.post()
                .uri("/api/chat")
                .header("X-Trace-ID", traceId)
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(request)
                .retrieve()
                .bodyToMono(ChatResponse.class)
                .doOnSuccess(r -> log.info("[TRACE {}][FASTAPI ][DONE    ] elapsed={}ms answer={}ch",
                        traceId, System.currentTimeMillis() - start,
                        r != null && r.getAnswer() != null ? r.getAnswer().length() : 0));
    }

    /** SSE 스트리밍 — StreamChunk Flux 반환 */
    public Flux<StreamChunk> chatStream(ChatRequest request, String traceId) {
        log.info("[TRACE {}][FASTAPI ][STREAM  ] POST /api/chat/stream", traceId);
        AtomicInteger chunkNo = new AtomicInteger(0);
        AtomicLong start = new AtomicLong(System.currentTimeMillis());

        return webClient.post()
                .uri("/api/chat/stream")
                .header("X-Trace-ID", traceId)
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(request)
                .retrieve()
                .bodyToFlux(new ParameterizedTypeReference<ServerSentEvent<String>>() {})
                .filter(event -> event.data() != null && !event.data().isEmpty())
                .map(ServerSentEvent::data)
                .takeUntil("[DONE]"::equals)
                .filter(data -> !"[DONE]".equals(data))
                .flatMap(data -> {
                    try {
                        StreamChunk chunk = objectMapper.readValue(data, StreamChunk.class);
                        int no = chunkNo.incrementAndGet();
                        if ("refs".equals(chunk.getType())) {
                            log.info("[TRACE {}][SSE     ][REFS    ] refs={}docs",
                                    traceId, chunk.getRefs() != null ? chunk.getRefs().size() : 0);
                        } else if ("status".equals(chunk.getType())) {
                            log.info("[TRACE {}][SSE     ][STATUS  ] {}", traceId, chunk.getContent());
                        } else if ("chunk".equals(chunk.getType()) && (no == 1 || no % 20 == 0)) {
                            log.info("[TRACE {}][SSE     ][CHUNK   ] #{} content={}ch",
                                    traceId, no,
                                    chunk.getContent() != null ? chunk.getContent().length() : 0);
                        } else if ("error".equals(chunk.getType())) {
                            log.error("[TRACE {}][SSE     ][ERROR   ] {}", traceId, chunk.getContent());
                        }
                        return Flux.just(chunk);
                    } catch (Exception e) {
                        return Flux.empty();
                    }
                })
                .doOnComplete(() -> log.info("[TRACE {}][SSE     ][DONE    ] total_chunks={} elapsed={}ms",
                        traceId, chunkNo.get(), System.currentTimeMillis() - start.get()));
    }
}
ReportService.java — 보고서 SSE 프록시
package com.disastergpt.service;

import com.disastergpt.dto.EditRequest;
import com.disastergpt.dto.ReportRequest;
import com.disastergpt.dto.StreamChunk;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Service
public class ReportService {

    private static final Logger log = LoggerFactory.getLogger(ReportService.class);

    private final WebClient webClient;
    private final ObjectMapper objectMapper;

    public ReportService(WebClient disasterGptWebClient, ObjectMapper objectMapper) {
        this.webClient = disasterGptWebClient;
        this.objectMapper = objectMapper;
    }

    /** FastAPI /api/report/generate SSE 스트림 프록시 */
    public Flux<StreamChunk> generateStream(ReportRequest request, String traceId) {
        log.info("[TRACE {}][REPORT  ][GENERATE] base_datetime={}",
                traceId, request.getBaseDatetime());

        return webClient.post()
                .uri("/api/report/generate")
                .header("X-Trace-ID", traceId)
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(request)
                .retrieve()
                .bodyToFlux(new ParameterizedTypeReference<ServerSentEvent<String>>() {})
                .filter(e -> e.data() != null && !e.data().isEmpty())
                .map(ServerSentEvent::data)
                .takeUntil("[DONE]"::equals)
                .filter(data -> !"[DONE]".equals(data))
                .flatMap(data -> {
                    try {
                        return Flux.just(objectMapper.readValue(data, StreamChunk.class));
                    } catch (Exception e) {
                        return Flux.empty();
                    }
                });
    }

    /** FastAPI /api/report/edit-stream 프록시 — LangGraph 우회 직접 LLM 편집 */
    public Flux<StreamChunk> editStream(EditRequest request, String traceId) {
        log.info("[TRACE {}][REPORT  ][EDIT    ] POST /api/report/edit-stream", traceId);
        return webClient.post()
                .uri("/api/report/edit-stream")
                .header("X-Trace-ID", traceId)
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(request)
                .retrieve()
                .bodyToFlux(new ParameterizedTypeReference<ServerSentEvent<String>>() {})
                .filter(e -> e.data() != null && !e.data().isEmpty())
                .map(ServerSentEvent::data)
                .takeUntil("[DONE]"::equals)
                .filter(data -> !"[DONE]".equals(data))
                .flatMap(data -> {
                    try {
                        return Flux.just(objectMapper.readValue(data, StreamChunk.class));
                    } catch (Exception e) {
                        return Flux.empty();
                    }
                });
    }

    /** FastAPI /api/report/download/{reportId}?format=docx|pdf 프록시 */
    public Mono<byte[]> downloadFile(String reportId, String format, String traceId) {
        log.info("[TRACE {}][REPORT  ][DOWNLOAD] id={} format={}", traceId, reportId, format);
        return webClient.get()
                .uri(u -> u.path("/api/report/download/{id}").queryParam("format", format).build(reportId))
                .header("X-Trace-ID", traceId)
                .retrieve()
                .bodyToMono(byte[].class);
    }
}
ChatController.java — Spring 채팅 엔드포인트 (실제 구현)
package com.disastergpt.controller;

import com.disastergpt.dto.ChatRequest;
import com.disastergpt.dto.ChatResponse;
import com.disastergpt.dto.StreamChunk;
import com.disastergpt.filter.TraceFilter;
import com.disastergpt.service.ChatService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@RestController
@RequestMapping("/chat")
public class ChatController {

    private static final Logger log = LoggerFactory.getLogger(ChatController.class);
    private final ChatService chatService;

    public ChatController(ChatService chatService) {
        this.chatService = chatService;
    }

    /** POST /chat — 단일 응답 */
    @PostMapping
    public Mono<ChatResponse> chat(@RequestBody ChatRequest request,
                                    ServerWebExchange exchange) {
        return Mono.deferContextual(ctx -> {
            String tid = ctx.getOrDefault(TraceFilter.TRACE_ID_KEY, "--------");
            log.info("[TRACE {}][REQUEST ][CHAT    ] question=\"{}\"", tid,
                    request.getQuestion() != null ? request.getQuestion() : "");
            return chatService.chat(request, tid);
        });
    }

    /**
     * POST /chat/stream — SSE 스트리밍
     *
     * curl -X POST http://localhost:8080/chat/stream \
     *   -H "Content-Type: application/json" \
     *   -H "Accept: text/event-stream" \
     *   -d '{"question":"화재 시 대피 요령은?"}'
     */
    @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<StreamChunk>> chatStream(@RequestBody ChatRequest request,
                                                            ServerWebExchange exchange) {
        return Flux.deferContextual(ctx -> {
            String tid = ctx.getOrDefault(TraceFilter.TRACE_ID_KEY, "--------");
            log.info("[TRACE {}][REQUEST ][STREAM  ] question=\"{}\" pdf={}",
                    tid, request.getQuestion(), request.getSelectedPdf());
            return chatService.chatStream(request, tid)
                    .map(chunk -> ServerSentEvent.<StreamChunk>builder()
                            .data(chunk)
                            .build());
        });
    }
}
ReportController.java — Spring 보고서 엔드포인트 (실제 구현)
package com.disastergpt.controller;

import com.disastergpt.dto.EditRequest;
import com.disastergpt.dto.ReportRequest;
import com.disastergpt.dto.StreamChunk;
import com.disastergpt.filter.TraceFilter;
import com.disastergpt.service.ReportService;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.nio.charset.StandardCharsets;

@RestController
@RequestMapping("/report")
public class ReportController {

    private final ReportService reportService;

    public ReportController(ReportService reportService) {
        this.reportService = reportService;
    }

    /** POST /report/generate — SSE 스트리밍 프록시 */
    @PostMapping(value = "/generate", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<StreamChunk>> generate(
            @RequestBody ReportRequest request,
            ServerWebExchange exchange) {
        return Flux.deferContextual(ctx -> {
            String tid = ctx.getOrDefault(TraceFilter.TRACE_ID_KEY, "--------");
            return reportService.generateStream(request, tid)
                    .map(chunk -> ServerSentEvent.<StreamChunk>builder()
                            .data(chunk)
                            .build());
        });
    }

    /** POST /report/edit-stream — 조치사항 AI 편집 SSE (LangGraph 우회) */
    @PostMapping(value = "/edit-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<StreamChunk>> editStream(
            @RequestBody EditRequest request,
            ServerWebExchange exchange) {
        return Flux.deferContextual(ctx -> {
            String tid = ctx.getOrDefault(TraceFilter.TRACE_ID_KEY, "--------");
            return reportService.editStream(request, tid)
                    .map(chunk -> ServerSentEvent.<StreamChunk>builder()
                            .data(chunk)
                            .build());
        });
    }

    /** GET /report/download/{reportId}?format=docx|pdf — 파일 다운로드 */
    @GetMapping("/download/{reportId}")
    public Mono<ResponseEntity<byte[]>> download(
            @PathVariable String reportId,
            @RequestParam(defaultValue = "docx") String format,
            ServerWebExchange exchange) {
        return Flux.deferContextual(ctx -> {
            String tid = ctx.getOrDefault(TraceFilter.TRACE_ID_KEY, "--------");
            return reportService.downloadFile(reportId, format, tid).flux();
        }).next().map(bytes -> {
            String ext = format.toLowerCase();
            MediaType mediaType = "pdf".equals(ext) ? MediaType.APPLICATION_PDF
                    : MediaType.parseMediaType(
                    "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
            String filename = "기상상황보고_" + reportId + "." + ext;
            HttpHeaders headers = new HttpHeaders();
            headers.setContentDisposition(
                    ContentDisposition.attachment()
                            .filename(filename, StandardCharsets.UTF_8)
                            .build());
            return ResponseEntity.ok()
                    .headers(headers)
                    .contentType(mediaType)
                    .body(bytes);
        });
    }
}
DocumentController.java — PDF 문서 목록 및 페이지 이미지
package com.disastergpt.controller;

import org.springframework.web.bind.annotation.*;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;

@RestController
@RequestMapping("/documents")
public class DocumentController {

    private final WebClient webClient;

    public DocumentController(WebClient disasterGptWebClient) {
        this.webClient = disasterGptWebClient;
    }

    /**
     * GET /documents
     * 응답: {"documents":[{"id":0,"name":"...","filename":"...","total_pages":42},...]}
     */
    @GetMapping
    public Mono<String> listDocuments() {
        return webClient.get()
                .uri("/api/documents")
                .retrieve()
                .bodyToMono(String.class);
    }

    /**
     * GET /documents/{docId}/page/{page}
     * 응답: {"image":"base64_png","page":0,"total_pages":42}
     * - docId: /documents 응답의 id 값 (0-based)
     * - page:  0-based 페이지 번호, 범위 초과 시 404
     */
    @GetMapping("/{docId}/page/{page}")
    public Mono<String> getPage(@PathVariable int docId, @PathVariable int page) {
        return webClient.get()
                .uri("/api/documents/{docId}/page/{page}", docId, page)
                .retrieve()
                .bodyToMono(String.class);
    }
}
TraceFilter.java — 모든 요청에 Trace ID 자동 부여
package com.disastergpt.filter;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;

import java.util.UUID;

/**
 * 모든 요청에 trace_id를 부여하고 응답 헤더(X-Trace-ID)에 추가.
 * - 인바운드 X-Trace-ID 헤더가 있으면 재사용
 * - 없으면 UUID 앞 8자리 신규 생성
 * traceId는 Reactor Context에 저장 → Service에서 꺼내 FastAPI로 전달
 */
@Component
public class TraceFilter implements WebFilter {

    private static final Logger log = LoggerFactory.getLogger(TraceFilter.class);
    public static final String TRACE_ID_KEY = "traceId";
    public static final String TRACE_HEADER = "X-Trace-ID";

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        String traceId = exchange.getRequest().getHeaders().getFirst(TRACE_HEADER);
        if (traceId == null || traceId.isBlank()) {
            traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
        }
        final String tid = traceId;

        exchange.getResponse().getHeaders().add(TRACE_HEADER, tid);
        log.info("[TRACE {}][FILTER  ][ASSIGN  ] method={} uri={}",
                tid, exchange.getRequest().getMethod(),
                exchange.getRequest().getURI().getPath());

        return chain.filter(exchange)
                .contextWrite(ctx -> ctx.put(TRACE_ID_KEY, tid));
    }
}