diff --git a/src/main/java/com/onebridge/ouch/controller/hospital/HospitalController.java b/src/main/java/com/onebridge/ouch/controller/hospital/HospitalController.java index 8ef46a2..5969bd0 100644 --- a/src/main/java/com/onebridge/ouch/controller/hospital/HospitalController.java +++ b/src/main/java/com/onebridge/ouch/controller/hospital/HospitalController.java @@ -7,9 +7,11 @@ import com.onebridge.ouch.dto.hospital.response.AllDepartmentResponse; import com.onebridge.ouch.dto.hospital.response.HospitalDetailResponse; import com.onebridge.ouch.dto.hospital.response.HospitalDistanceResponse; +import com.onebridge.ouch.dto.hospital.response.RegionMappingDto; import com.onebridge.ouch.service.department.DepartmentService; import com.onebridge.ouch.service.hospital.HospitalDetailService; import com.onebridge.ouch.service.hospital.HospitalSearchService; +import com.onebridge.ouch.service.hospital.RegionService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -23,6 +25,7 @@ public class HospitalController { private final HospitalSearchService hospitalSearchService; private final HospitalDetailService hospitalDetailService; private final DepartmentService departmentService; + private final RegionService regionService; @Operation(summary = "거리 순 병원 조회 API", description = "입력된 진료과(department), 종별코드명(type) 위도(lat), 경도(lng)를 기준으로 병원 목록을 거리 순으로 조회합니다. " + "진료과나 종별코드명를 입력하지 않으면 입력된 위도, 경도를 기준으로 모든 병원 목록을 거리 순으로 조회합니다. 위도, 경도는 필수로 입력해야 합니다. " @@ -32,12 +35,13 @@ public class HospitalController { public List searchHospitals( @RequestParam(required = false) String department, @RequestParam(required = false) String type, // 종별코드명 + @RequestParam(required = false) String sido, @RequestParam Double lat, @RequestParam Double lng, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { - return hospitalSearchService.searchHospitals(department, type, lat, lng, page, size); + return hospitalSearchService.searchHospitals(department, type, sido, lat, lng, page, size); } @Operation(summary = "병원 상세 조회 API", description = "입력된 병원 고유ID를 통해 병원 상세 정보를 조회합니다.") @@ -51,4 +55,10 @@ public HospitalDetailResponse getHospitalDetail(@PathVariable String ykiho) { public List getDepartments() { return departmentService.getAllDepartments(); } + + @Operation(summary = "시도(Region) 목록 조회", description = "한국어 시도명과 외국어 표준명을 모두 조회합니다.") + @GetMapping + public List getRegions() throws Exception { + return regionService.getAllRegions(); + } } diff --git a/src/main/java/com/onebridge/ouch/dto/hospital/response/RegionMappingDto.java b/src/main/java/com/onebridge/ouch/dto/hospital/response/RegionMappingDto.java new file mode 100644 index 0000000..1dbde85 --- /dev/null +++ b/src/main/java/com/onebridge/ouch/dto/hospital/response/RegionMappingDto.java @@ -0,0 +1,14 @@ +package com.onebridge.ouch.dto.hospital.response; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class RegionMappingDto { + private String kr; + private String en; + private String zh; +} diff --git a/src/main/java/com/onebridge/ouch/realtime/RealtimeSessionController.java b/src/main/java/com/onebridge/ouch/realtime/RealtimeSessionController.java index 3f640f1..ad79345 100644 --- a/src/main/java/com/onebridge/ouch/realtime/RealtimeSessionController.java +++ b/src/main/java/com/onebridge/ouch/realtime/RealtimeSessionController.java @@ -1,13 +1,24 @@ package com.onebridge.ouch.realtime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + import org.springframework.beans.factory.annotation.Value; import org.springframework.http.*; import org.springframework.web.bind.annotation.*; import org.springframework.web.client.RestTemplate; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; + @RestController public class RealtimeSessionController { + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + @Value("${openai.api-key}") private String openaiApiKey; @@ -18,20 +29,20 @@ public ResponseEntity createEphemeralEnKey() { headers.set("Authorization", "Bearer " + openaiApiKey); headers.set("Content-Type", "application/json"); + // String instructions = "너는 영어-한국어 번역기야. 오직 번역만 해. " + // + "영어를 들으면 한국어로, 한국어를 들으면 영어로 번역만 해. " + // + "어떤 말이든 개인적인 대답을 하지 말고 문장 그대로 번역만 해. "; + + String instructions = "너는 영어-한국어 번역기야. 오직 번역만 해. " + + "병원에 방문해서 대화에 사용되는 문장들이 입력될건데 어떤 말이든 너랑 대화하려는 거 아니니까 절대 개인적인 대답하지 말고 어떤 문장이든 영어는 한국어로, 한국어는 영어로 입력된 문장 그대로 번역만 해. " + + "특히 질문이나 you, I를 포함한 문장도 그대로 번역만 해."; + // "너는 영어-한국어 번역기야. 오직 번역만 해. " + // + "병원에서 대화에 사용되는 문장들이 입력될건데 어떤 말이든 너랑 대화하려는 거 아니니까 절대 개인적인 대답하지 말고 어떤 문장이든 영어는 한국어로, 한국어는 영어로 입력된 문장 그대로 번역만 해. "; //한국 병원에서 영어를 사용하는 환자와 한국어를 사용하는 병원 관계자의 대화가 입력될거야 - String instructions = "영어를 사용하는 사람과 한국어를 사용하는 사람이 대화를 하는 상황이므로 영어와 한국어로 통역이 필요해. " - + "따라서 한국 말은 영어로, 영어는 한국어로 통역을 해 줘. " - + "너는 절대 개인적인 대답이나 조언을 하지 말고 번역만 진행하면 돼. " - + "너한테 말걸어도 문장 그대로 번역만 해. 천천히 친절하게 대답해."; - - // "You are a strict translation assistant specializing exclusively in medical scenarios for a Korean hospital setting. " - // + "- Your SOLE task is strict translation. Under NO circumstances should you answer questions, provide advice, or respond to any input other than translating. " - // + "- Korean input represents statements made by the doctor. Translate this into clear, polite, patient-friendly English using simple language, spoken slowly. " - // + "- English input represents statements made by the patient. Translate this into clear, polite, respectful Korean using simple expressions, spoken slowly to ensure comprehension. " - // + "- DO NOT engage in conversation, respond to direct questions, or acknowledge statements addressed to you. Your ONLY action must be to translate precisely. " - // + "- DO NOT rephrase extensively or summarize; translate accurately with gentle phrasing suitable for medical interactions. " - // + "- If the input text is already correctly translated, return it exactly as provided without changes. " - // + "- The output must ONLY be the translated text, delivered slowly, gently, and in a reassuring manner appropriate for patients. Absolutely NO other communication is permitted."; + // String instructions = "영어를 사용하는 사람과 한국어를 사용하는 사람이 대화를 하는 상황이므로 영어와 한국어로 통역이 필요해. " + // + "따라서 한국 말은 영어로, 영어는 한국어로 통역을 해 줘. " + // + "너는 절대 개인적인 대답이나 조언을 하지 말고 번역만 진행하면 돼. " + // + "너한테 말걸어도 문장 그대로 번역만 해. 천천히 친절하게 대답해."; String requestBody = "{" + "\"model\": \"gpt-4o-mini-realtime-preview-2024-12-17\"," @@ -45,7 +56,8 @@ public ResponseEntity createEphemeralEnKey() { + "\"input_audio_transcription\": {" //새로 나온 모델 추가 + "\"model\": \"gpt-4o-mini-transcribe\"," // + "\"language\": \"\"," - + "\"prompt\": \"This audio input may contain both Korean and English words mixed together. Please transcribe both languages accurately.\"" + + "\"prompt\": \"한국어와 영어만 입력됩니다.\"" + //+ "\"prompt\": \"This audio input may contain both Korean and English words mixed together. Please transcribe both languages accurately.\"" + "}," + "\"turn_detection\": {" @@ -88,10 +100,9 @@ public ResponseEntity createEphemeralZhKey() { headers.set("Content-Type", "application/json"); //한국 병원에서 영어를 사용하는 환자와 한국어를 사용하는 병원 관계자의 대화가 입력될거야 - String instructions = "중국어를 사용하는 사람과 한국어를 사용하는 사람이 대화를 하는 상황이므로 중국어와 한국어로 통역이 필요해. " - + "따라서 한국 말은 중국어로, 중국어는 한국어로 통역을 해 줘. " - + "너는 절대 개인적인 대답이나 조언을 하지 말고 번역만 진행하면 돼. " - + "너한테 말걸어도 문장 그대로 번역만 해. 천천히 친절하게 대답해."; + String instructions = "너는 중국어-한국어 번역기야. 오직 번역만 해. " + + "병원에 방문해서 대화에 사용되는 문장들이 입력될건데 어떤 말이든 너랑 대화하려는 거 아니니까 절대 개인적인 대답하지 말고 어떤 문장이든 중국어는 한국어로, 한국어는 중국어로 입력된 문장 그대로 번역만 해. " + + "특히 질문이나 you, I를 포함한 문장도 그대로 번역만 해."; String requestBody = "{" + "\"model\": \"gpt-4o-mini-realtime-preview-2024-12-17\"," @@ -105,7 +116,7 @@ public ResponseEntity createEphemeralZhKey() { + "\"input_audio_transcription\": {" //새로 나온 모델 추가 + "\"model\": \"gpt-4o-mini-transcribe\"," // + "\"language\": \"\"," - + "\"prompt\": \"This audio input may contain both Korean and Chinese words mixed together. Please transcribe both languages accurately.\"" + + "\"prompt\": \"중국어와 한국어만 입력됩니다.\"" + "}," + "\"turn_detection\": {" @@ -126,5 +137,162 @@ public ResponseEntity createEphemeralZhKey() { ); return response; + + } + @PostMapping("/summarize/en") + public ResponseEntity> summarizeConversation( + @RequestBody ConversationRequest requestBody + ) { + try { + // 1) 원문 문자열 배열을 합쳐서 fullConversation 만들기 + StringBuilder sb = new StringBuilder(); + for (String orig : requestBody.getMessages()) { + sb.append("[원문] ").append(orig.trim()).append("\n"); + } + String fullConversation = sb.toString(); + + // 2) OpenAI Chat Completions API 호출을 위한 메시지 구성 + List> messages = new ArrayList<>(); + + // system 메시지: 요약 역할 지시 + messages.add(Map.of( + "role", "system", + "content", + "병원에서 환자가 나눈 대화입니다. 진료 내용을 파악해서 100자 이내로 영어로 요약해주세요." + )); + // user 메시지: 실제 대화 내용 + messages.add(Map.of( + "role", "user", + "content", fullConversation + )); + + Map openAiRequest = new HashMap<>(); + openAiRequest.put("model", "gpt-4o-mini"); // 필요에 따라 변경 가능 + openAiRequest.put("messages", messages); + openAiRequest.put("temperature", 0.5); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(openaiApiKey); + + HttpEntity> entity = new HttpEntity<>(openAiRequest, headers); + ResponseEntity aiResponse = restTemplate.postForEntity( + "https://api.openai.com/v1/chat/completions", + entity, + String.class + ); + + if (!aiResponse.getStatusCode().is2xxSuccessful()) { + return ResponseEntity + .status(aiResponse.getStatusCode()) + .body(Map.of("summary", "요약 생성 중 오류가 발생했습니다.")); + } + + // 3) OpenAI 응답 JSON에서 summary 추출 + Map responseMap = objectMapper.readValue(aiResponse.getBody(), Map.class); + List choices = (List) responseMap.get("choices"); + if (choices == null || choices.isEmpty()) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("summary", "AI 응답 형식이 예상과 다릅니다.")); + } + Map firstChoice = (Map) choices.get(0); + Map message = (Map) firstChoice.get("message"); + String summary = message.get("content").toString().trim(); + + // 4) 요약 결과 반환 + return ResponseEntity.ok(Map.of("summary", summary)); + + } catch (Exception ex) { + ex.printStackTrace(); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("summary", "서버 내부 오류로 요약에 실패했습니다.")); + } + } + + @PostMapping("/summarize/zh") + public ResponseEntity> summarizeConversationZh( + @RequestBody ConversationRequest requestBody + ) { + try { + // 1) 원문 문자열 배열을 합쳐서 fullConversation 만들기 + StringBuilder sb = new StringBuilder(); + for (String orig : requestBody.getMessages()) { + sb.append("[원문] ").append(orig.trim()).append("\n"); + } + String fullConversation = sb.toString(); + + // 2) OpenAI Chat Completions API 호출을 위한 메시지 구성 + List> messages = new ArrayList<>(); + + // system 메시지: 요약 역할 지시 + messages.add(Map.of( + "role", "system", + "content", + "병원에서 환자가 나눈 대화입니다. 진료 내용을 파악해서 중국어로 100자 이내로 요약해주세요." + )); + // user 메시지: 실제 대화 내용 + messages.add(Map.of( + "role", "user", + "content", fullConversation + )); + + Map openAiRequest = new HashMap<>(); + openAiRequest.put("model", "gpt-4o-mini"); // 필요에 따라 변경 가능 + openAiRequest.put("messages", messages); + openAiRequest.put("temperature", 0.5); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(openaiApiKey); + + HttpEntity> entity = new HttpEntity<>(openAiRequest, headers); + ResponseEntity aiResponse = restTemplate.postForEntity( + "https://api.openai.com/v1/chat/completions", + entity, + String.class + ); + + if (!aiResponse.getStatusCode().is2xxSuccessful()) { + return ResponseEntity + .status(aiResponse.getStatusCode()) + .body(Map.of("summary", "요약 생성 중 오류가 발생했습니다.")); + } + + // 3) OpenAI 응답 JSON에서 summary 추출 + Map responseMap = objectMapper.readValue(aiResponse.getBody(), Map.class); + List choices = (List) responseMap.get("choices"); + if (choices == null || choices.isEmpty()) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("summary", "AI 응답 형식이 예상과 다릅니다.")); + } + Map firstChoice = (Map) choices.get(0); + Map message = (Map) firstChoice.get("message"); + String summary = message.get("content").toString().trim(); + + // 4) 요약 결과 반환 + return ResponseEntity.ok(Map.of("summary", summary)); + + } catch (Exception ex) { + ex.printStackTrace(); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("summary", "서버 내부 오류로 요약에 실패했습니다.")); + } + } + + /** 클라이언트가 보내는 JSON 구조를 매핑하기 위한 DTO */ + public static class ConversationRequest { + @JsonProperty("messages") + private List messages; + + public List getMessages() { + return messages; + } + public void setMessages(List messages) { + this.messages = messages; + } } } \ No newline at end of file diff --git a/src/main/java/com/onebridge/ouch/repository/hospital/HospitalRepository.java b/src/main/java/com/onebridge/ouch/repository/hospital/HospitalRepository.java index 9caca43..e071948 100644 --- a/src/main/java/com/onebridge/ouch/repository/hospital/HospitalRepository.java +++ b/src/main/java/com/onebridge/ouch/repository/hospital/HospitalRepository.java @@ -49,6 +49,7 @@ List findAllOrderByDistance( "AND h.type != '요양병원' " + "AND (:department1 IS NULL OR hd.department_name IN (:department1, :department2)) " + // 내과+가정의학과 포함 "AND h.lat IS NOT NULL AND h.lng IS NOT NULL " + + "AND (:sidoKr IS NULL OR h.sido = :sidoKr) " + "GROUP BY h.ykiho " + "ORDER BY distance ASC " + "LIMIT :limit OFFSET :offset", @@ -59,6 +60,7 @@ List findWithConditionsOrderByDistance( @Param("type") String type, @Param("department1") String department1, @Param("department2") String department2, + @Param("sidoKr") String sidoKr, @Param("limit") int limit, @Param("offset") int offset ); diff --git a/src/main/java/com/onebridge/ouch/service/hospital/HospitalSearchService.java b/src/main/java/com/onebridge/ouch/service/hospital/HospitalSearchService.java index d84f570..31894f1 100644 --- a/src/main/java/com/onebridge/ouch/service/hospital/HospitalSearchService.java +++ b/src/main/java/com/onebridge/ouch/service/hospital/HospitalSearchService.java @@ -14,10 +14,12 @@ @RequiredArgsConstructor public class HospitalSearchService { private final HospitalRepository hospitalRepository; + private final RegionService regionService; public List searchHospitals( String department, String type, // 종별코드명 ex. '약국', '병원' + String sido, Double lat, Double lng, int page, @@ -25,6 +27,9 @@ public List searchHospitals( ) { if (lat == null || lng == null) throw new IllegalArgumentException("좌표(lat, lng)는 필수입니다."); + // 1) 전달받은 sido를 한국어로 변환 + String sidoKr = regionService.getKrSidoName(sido); + int offset = page * size; List rawList; @@ -42,10 +47,10 @@ public List searchHospitals( } // type(종별코드명)도 null/입력값에 따라 처리 - if ((department1 != null && department2 != null) || (type != null && !type.isBlank())) { + if ((department1 != null && department2 != null) || (type != null && !type.isBlank()) || (sidoKr != null)) { rawList = hospitalRepository.findWithConditionsOrderByDistance( lat, lng, - type, department1, department2, size, offset + type, department1, department2, sidoKr, size, offset ); } else { rawList = hospitalRepository.findAllOrderByDistance(lat, lng, size, offset); diff --git a/src/main/java/com/onebridge/ouch/service/hospital/RegionService.java b/src/main/java/com/onebridge/ouch/service/hospital/RegionService.java new file mode 100644 index 0000000..01b4d78 --- /dev/null +++ b/src/main/java/com/onebridge/ouch/service/hospital/RegionService.java @@ -0,0 +1,72 @@ +package com.onebridge.ouch.service.hospital; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.onebridge.ouch.dto.hospital.response.RegionMappingDto; + +import jakarta.annotation.PostConstruct; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; + +import java.io.InputStream; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class RegionService { + // 언어별 키(소문자) → 한국어 시도명 맵 + private final Map enToKr = new HashMap<>(); + private final Map zhToKr = new HashMap<>(); + private final Map krToKr = new HashMap<>(); + + @PostConstruct + public void init() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + InputStream is = new ClassPathResource("data/regions.json").getInputStream(); + List list = mapper.readValue(is, new TypeReference<>() {}); + + for (RegionMappingDto rm : list) { + String krName = rm.getKr().trim(); + String enName = rm.getEn().trim().toLowerCase(); + String zhName = rm.getZh().trim(); + + krToKr.put(krName, krName); + enToKr.put(enName, krName); + zhToKr.put(zhName, krName); + } + } + + /** + * 전달받은 시도명(kr / en / zh 중 하나)으로 한국어 시도명 반환. + * 매칭이 안 되면 null 리턴. + */ + public String getKrSidoName(String input) { + if (input == null || input.isBlank()) { + return null; + } + String key = input.trim(); + // 1) 한글로 입력된 경우 + if (krToKr.containsKey(key)) { + return krToKr.get(key); + } + // 2) 영어(소문자)로 입력된 경우 + String lower = key.toLowerCase(); + if (enToKr.containsKey(lower)) { + return enToKr.get(lower); + } + // 3) 중국어로 입력된 경우 + if (zhToKr.containsKey(key)) { + return zhToKr.get(key); + } + return null; + } + + /** 전체 Region 목록 반환 (영어·한국어·중국어 모두 포함) */ + public List getAllRegions() throws Exception { + // 이후에도 동일 JSON을 읽거나, 위 init()에서 리스트를 필드에 따로 저장해두고 반환해도 됩니다. + ObjectMapper mapper = new ObjectMapper(); + InputStream is = new ClassPathResource("data/regions.json").getInputStream(); + return mapper.readValue(is, new TypeReference<>() {}); + } +} diff --git a/src/main/resources/data/regions.json b/src/main/resources/data/regions.json new file mode 100644 index 0000000..923a622 --- /dev/null +++ b/src/main/resources/data/regions.json @@ -0,0 +1,19 @@ +[ + { "kr": "서울", "en": "seoul", "zh": "首尔" }, + { "kr": "부산", "en": "busan", "zh": "釜山" }, + { "kr": "대구", "en": "daegu", "zh": "大邱" }, + { "kr": "인천", "en": "incheon", "zh": "仁川" }, + { "kr": "광주", "en": "gwangju", "zh": "光州" }, + { "kr": "대전", "en": "daejeon", "zh": "大田" }, + { "kr": "울산", "en": "ulsan", "zh": "蔚山" }, + { "kr": "세종", "en": "sejong", "zh": "世宗" }, + { "kr": "경기", "en": "gyeonggi", "zh": "京畿" }, + { "kr": "강원", "en": "gangwon", "zh": "江原" }, + { "kr": "충북", "en": "chungbuk", "zh": "忠北" }, + { "kr": "충남", "en": "chungnam", "zh": "忠南" }, + { "kr": "전북", "en": "jeonbuk", "zh": "全北" }, + { "kr": "전남", "en": "jeonnam", "zh": "全南" }, + { "kr": "경북", "en": "gyeongbuk", "zh": "庆北" }, + { "kr": "경남", "en": "gyeongnam", "zh": "庆南" }, + { "kr": "제주", "en": "jeju", "zh": "济州" } +]