From cccc96b898416d74ad4ba2e4346ab0512b2d0ba6 Mon Sep 17 00:00:00 2001 From: 1024andrew <1024andrew@naver.com> Date: Sun, 3 May 2026 18:04:00 +0900 Subject: [PATCH 1/5] fix: add room floor resolver --- app/services/chat_service.py | 92 ++++++++++++++--------- app/services/generator.py | 4 +- app/services/query_rewriter.py | 4 +- app/services/room_floor_resolver.py | 110 ++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 39 deletions(-) create mode 100644 app/services/room_floor_resolver.py diff --git a/app/services/chat_service.py b/app/services/chat_service.py index e143695..ab3c3b6 100644 --- a/app/services/chat_service.py +++ b/app/services/chat_service.py @@ -27,12 +27,14 @@ from app.schemas.chat import ChatRequest from app.schemas.chat import ChatResponse from app.services.embeddings import create_query_embedding +from app.services.generator import AnswerGenerationResult from app.services.generator import generate_answer from app.services.validator import validate_question from app.services.query_rewriter import expand_query_for_retrieval from app.repositories.regulation_chunk_repository import search_similar_chunks_all_dormitories +from app.services.room_floor_resolver import resolve_room_floor_question ERROR_TYPE_TIMEOUT = "TIMEOUT" ERROR_TYPE_LLM_API = "LLM_API_ERROR" @@ -107,6 +109,26 @@ def answer_chat_question(db: Session, payload: ChatRequest) -> ChatResponse: retrieval_version=None, response_time_ms=_elapsed_ms(started_at), ) + + room_floor_result = resolve_room_floor_question( + normalized_question, + payload.dormitory, + ) + + if room_floor_result is not None: + return _finalize_chat_log( + db, + chat_log_id=chat_log_id, + session_id=payload.session_id, + answer_status=ChatAnswerStatus.SUCCESS, + answer=room_floor_result.answer, + source_url=room_floor_result.source_url, + rewritten_query=normalized_question, + model_name=None, + prompt_version=None, + retrieval_version="room-floor-rule-v1", + response_time_ms=_elapsed_ms(started_at), + ) if payload.dormitory: return _answer_single_dormitory_chat( @@ -399,23 +421,17 @@ def _answer_unspecified_dormitory_chat( ) raise - if not chunks: - return _finalize_chat_log( - db, - chat_log_id=chat_log_id, - session_id=session_id, - answer_status=ChatAnswerStatus.NO_ANSWER, - answer=settings.chat_no_answer_message, - source_url="", - rewritten_query=rewritten_query, - model_name=None, - prompt_version=None, - retrieval_version=retrieval_version, - response_time_ms=_elapsed_ms(started_at), - ) + try: - answer_result = generate_answer(question, chunks) + if chunks: + answer_result = generate_answer(question, chunks) + else: + answer_result = AnswerGenerationResult( + answer=settings.chat_no_answer_message, + source_url="", + cited_regulation_chunk_ids=[], + ) # 비로그인/생활관 미지정 상태에서 원문 검색으로 답변을 못 만들면 # query expansion으로 검색용 질의를 확장한 뒤 전체 생활관 대상으로 재검색 @@ -424,6 +440,7 @@ def _answer_unspecified_dormitory_chat( question=question, dormitory=None, ) + if expanded_query != question: expanded_query_embedding = create_query_embedding(expanded_query) @@ -682,21 +699,23 @@ def _get_query_expansion_rerank_keywords(question: str, expanded_query: str) -> cooking_triggers = [ "라면끓", + "라면먹", + "라면먹어", + "라면먹어도", + "방에서라면", "끓여먹", "끓여", "취사", "조리", "요리", "해먹", - "해먹어", - "해먹어도", "음식해", "음식해먹", "전기포트", "라면포트", "에어프라이어", "커피포트", - ] +] if any(trigger in text for trigger in cooking_triggers): return [ @@ -787,25 +806,26 @@ def _should_pre_expand_query(question: str) -> bool: if any(trigger in compact_question for trigger in microwave_triggers): return True + cooking_triggers = [ - "라면끓", - "라면먹", - "끓여먹", - "끓여", - "방에서라면", - "취사", - "조리", - "요리", - "해먹", - "해먹어", - "해먹어도", - "음식해", - "음식해먹", - "전기포트", - "라면포트", - "에어프라이어", - "커피포트", - ] + "라면끓", + "라면먹", + "라면먹어", + "라면먹어도", + "방에서라면", + "끓여먹", + "끓여", + "취사", + "조리", + "요리", + "해먹", + "음식해", + "음식해먹", + "전기포트", + "라면포트", + "에어프라이어", + "커피포트", +] if any(trigger in compact_question for trigger in cooking_triggers): return True diff --git a/app/services/generator.py b/app/services/generator.py index 0dfd33b..3b8ef61 100644 --- a/app/services/generator.py +++ b/app/services/generator.py @@ -60,7 +60,9 @@ def generate_answer( 아래 제공된 정보를 기반으로만 질문에 답변해라. 모르는 내용은 추측하지 말고 모른다고 말해라. 참고 정보에 없는 일반 상식이나 추측으로 답하지 마라. -"일반적으로", "가능성이 높습니다", "확인하는 것이 좋습니다"처럼 근거 없는 표현을 사용하지 마라. +"일반적으로", "가능성이 높습니다", "가능할 수 있습니다", "허용되지 않을 것으로 보입니다", "확인하는 것이 좋습니다"처럼 근거 없는 추측 표현을 사용하지 마라. +사용자가 "방에서 라면 먹어도 돼?"처럼 질문한 경우, 참고 정보에 방 안 취식 금지 규정이 없으면 라면을 먹는 행위 자체를 금지한다고 단정하지 마라. +다만 참고 정보에 라면포트, 전기포트, 전열기구, 취사행위 금지 내용이 있으면 "방에서 라면을 조리해 먹는 것은 허용되지 않는다"라고 안내해라. 질문에서 생활관을 특정하지 않았고 참고 정보가 특정 생활관에만 해당하면, 해당 생활관 기준 답변임을 명확히 밝혀라. 질문에서 생활관을 특정하지 않았더라도 생활관별 구분을 강제로 만들지 말고, 가장 관련 있는 정보 중심으로 간결하게 답변해라. 참고 정보에 생활관 구분이 없거나 공통 규정으로 보이면 일반 답변으로 안내해라. diff --git a/app/services/query_rewriter.py b/app/services/query_rewriter.py index 3463da2..aab4fc7 100644 --- a/app/services/query_rewriter.py +++ b/app/services/query_rewriter.py @@ -56,7 +56,7 @@ def expand_query_for_retrieval( 단, 사용자가 휴게실을 직접 언급하지 않았다면 휴게실 통금보다 생활관 출입 통금으로 해석해라. 12.사용자가 "먹을 거", "간단하게 먹을 곳", "먹을거 해결", "사먹을 곳"처럼 식사/간식 해결 장소를 물으면 학생식당, 학식, 편의점, 매점, 배달음식 수령 키워드를 함께 포함해라. 13.사용자가 전자레인지, 음식 데우기, 데워먹기, 휴게실 전자레인지 위치를 물으면 휴게실, 공용시설, 전자레인지, 음식 데우기, 정수기, 싱크대 키워드를 포함해라. - +14.사용자가 방에서 라면을 먹어도 되는지, 라면을 끓여 먹어도 되는지, 조리, 취사, 라면포트, 전기포트, 전열기구 사용 가능 여부를 물으면 반입금지 물품, 취사행위, 전열기구, 라면포트, 전기포트, 조리 금지, 화재위험 키워드를 포함해라. 예시: 사용자 질문: 새벽 2시에 들어가도 돼? @@ -66,7 +66,7 @@ def expand_query_for_retrieval( 검색용 질의: 음료수 살만한 곳 있어? 검색 키워드: 편의점, 매점, 편의시설, 음료 구매, 간식 구매, 생활용품 구매 위치 사용자 질문: 방에서 라면 먹어도 돼? -검색용 질의: 방에서 라면 먹어도 돼? 검색 키워드: 호실 내 취사, 라면포트, 전열기구, 반입금지 물품, 취사 금지 +검색용 질의: 방에서 라면 먹어도 돼? 검색 키워드: 호실 내 취사, 라면포트, 전기포트, 전열기구, 반입금지 물품, 조리 금지 사용자 질문: 방에서 라면 끓여 먹어도 돼? 검색용 질의: 방에서 라면 끓여 먹어도 돼? 검색 키워드: 호실 내 취사, 취사행위, 반입금지 물품, 전열기구, 라면포트, 전기포트, 화재위험 diff --git a/app/services/room_floor_resolver.py b/app/services/room_floor_resolver.py new file mode 100644 index 0000000..29f492f --- /dev/null +++ b/app/services/room_floor_resolver.py @@ -0,0 +1,110 @@ +"""호실 번호 기반 층수 질문을 규칙 기반으로 처리하는 서비스입니다.""" + +import re +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class RoomFloorResult: + answer: str + source_url: str = "" + + +ROOM_FLOOR_RANGES: dict[str, list[tuple[int, int, int]]] = { + "제1학생생활관": [ + (101, 117, 1), + (201, 217, 2), + (301, 317, 3), + (401, 417, 4), + (501, 504, 5), + (121, 144, 1), + (221, 244, 2), + (321, 344, 3), + (421, 444, 4), + (521, 536, 5), + ], + "제2학생생활관": [ + (101, 141, 1), + (201, 246, 2), + (301, 346, 3), + (401, 446, 4), + (501, 546, 5), + (151, 170, 1), + (251, 276, 2), + (351, 384, 3), + (451, 484, 4), + (551, 584, 5), + ], + "제3학생생활관": [ + (201, 236, 2), + (301, 336, 3), + (401, 436, 4), + (501, 536, 5), + (251, 275, 2), + (351, 375, 3), + (451, 475, 4), + (551, 575, 5), + ], +} + + +def resolve_room_floor_question( + question: str, + dormitory: Optional[str], +) -> Optional[RoomFloorResult]: + """ + '401호 몇 층이야?'처럼 호실 번호로 층수를 묻는 질문이면 규칙 기반으로 답변한다. + 해당 질문이 아니면 None을 반환한다. + """ + + if not dormitory: + return None + + if not _looks_like_room_floor_question(question): + return None + + room_number = _extract_room_number(question) + if room_number is None: + return None + + ranges = ROOM_FLOOR_RANGES.get(dormitory) + if not ranges: + return RoomFloorResult( + answer=f"{dormitory}의 호실 층수 정보를 확인할 수 없습니다." + ) + + if room_number >= 1000: + return RoomFloorResult( + answer=f"{dormitory}에서 {room_number}호에 대한 층수 정보는 확인되지 않습니다." + ) + + for start, end, floor in ranges: + if start <= room_number <= end: + return RoomFloorResult( + answer=f"{room_number}호는 {dormitory} 지상 {floor}층에 위치합니다." + ) + + return RoomFloorResult( + answer=f"{dormitory}에서 {room_number}호에 대한 층수 정보는 확인되지 않습니다." + ) + + +def _looks_like_room_floor_question(question: str) -> bool: + compact_question = question.replace(" ", "") + + has_room_number = re.search(r"\d{3,4}호", compact_question) is not None + asks_floor = any( + keyword in compact_question + for keyword in ["몇층", "몇층이야", "층이야", "층인가", "어디층"] + ) + + return has_room_number and asks_floor + + +def _extract_room_number(question: str) -> Optional[int]: + match = re.search(r"(\d{3,4})\s*호", question) + if not match: + return None + + return int(match.group(1)) \ No newline at end of file From 2368acf3f64409ce35cee8d6719cafe994794cb3 Mon Sep 17 00:00:00 2001 From: 1024andrew <1024andrew@naver.com> Date: Sun, 3 May 2026 18:45:49 +0900 Subject: [PATCH 2/5] fix: add room floor resolver --- app/core/config.py | 2 +- app/services/chat_service.py | 29 +++++++++++++++++++++++++++-- app/services/room_floor_resolver.py | 9 +++------ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/app/core/config.py b/app/core/config.py index 6e7d09d..682eaf8 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -27,7 +27,7 @@ class Settings(BaseSettings): chat_no_answer_message: str = "관련 정보를 찾을 수 없습니다." chat_invalid_question_message: str = "기숙사 관련 질문을 입력해주세요." chat_single_dormitory_top_k: int = 3 - chat_grouped_dormitory_top_k: int = 2 + chat_grouped_dormitory_top_k: int = 3 chat_grouped_dormitories: list[str] = ["제1학생생활관", "제2학생생활관", "제3학생생활관"] regulation_chunk_max_length: int = 400 chat_session_timeout_minutes: int = 30 diff --git a/app/services/chat_service.py b/app/services/chat_service.py index ab3c3b6..850352a 100644 --- a/app/services/chat_service.py +++ b/app/services/chat_service.py @@ -413,6 +413,21 @@ def _answer_unspecified_dormitory_chat( dormitories=settings.chat_grouped_dormitories, top_k=settings.chat_grouped_dormitory_top_k, ) + + print("===== GROUPED SEARCH DEBUG =====") + print("question:", question) + print("chunks_count:", len(chunks)) + for index, chunk in enumerate(chunks, start=1): + print( + index, + chunk.get("document_id"), + chunk.get("dormitory"), + chunk.get("similarity"), + chunk.get("source"), + (chunk.get("content") or "")[:300], + ) + print("================================") + except Exception as exc: _attach_chat_error_metadata( exc, @@ -702,7 +717,11 @@ def _get_query_expansion_rerank_keywords(question: str, expanded_query: str) -> "라면먹", "라면먹어", "라면먹어도", + "라면 먹어", + "라면 먹어도" "방에서라면", + "방에서 라면", + "끓여 먹" "끓여먹", "끓여", "취사", @@ -711,11 +730,12 @@ def _get_query_expansion_rerank_keywords(question: str, expanded_query: str) -> "해먹", "음식해", "음식해먹", + "음식 해먹" "전기포트", "라면포트", "에어프라이어", "커피포트", -] + ] if any(trigger in text for trigger in cooking_triggers): return [ @@ -812,7 +832,11 @@ def _should_pre_expand_query(question: str) -> bool: "라면먹", "라면먹어", "라면먹어도", + "라면 먹어", + "라면 먹어도" "방에서라면", + "방에서 라면", + "끓여 먹" "끓여먹", "끓여", "취사", @@ -821,11 +845,12 @@ def _should_pre_expand_query(question: str) -> bool: "해먹", "음식해", "음식해먹", + "음식 해먹" "전기포트", "라면포트", "에어프라이어", "커피포트", -] + ] if any(trigger in compact_question for trigger in cooking_triggers): return True diff --git a/app/services/room_floor_resolver.py b/app/services/room_floor_resolver.py index 29f492f..ce4bc0b 100644 --- a/app/services/room_floor_resolver.py +++ b/app/services/room_floor_resolver.py @@ -74,10 +74,6 @@ def resolve_room_floor_question( answer=f"{dormitory}의 호실 층수 정보를 확인할 수 없습니다." ) - if room_number >= 1000: - return RoomFloorResult( - answer=f"{dormitory}에서 {room_number}호에 대한 층수 정보는 확인되지 않습니다." - ) for start, end, floor in ranges: if start <= room_number <= end: @@ -103,8 +99,9 @@ def _looks_like_room_floor_question(question: str) -> bool: def _extract_room_number(question: str) -> Optional[int]: - match = re.search(r"(\d{3,4})\s*호", question) + compact_question = question.replace(" ", "") + match = re.search(r"(? Date: Sun, 3 May 2026 19:00:33 +0900 Subject: [PATCH 3/5] chore: update chat prompt versions --- app/core/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/core/config.py b/app/core/config.py index 682eaf8..c3a8ac5 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -18,8 +18,8 @@ class Settings(BaseSettings): openai_timeout_seconds: float = 10.0 chat_answer_model: str = "gpt-4o-mini" notice_summary_model: str = "gpt-4o-mini" - chat_prompt_version_single: str = "chat-answer-source-v1" - chat_prompt_version_grouped: str = "chat-answer-unspecified-dormitory-source-v1" + chat_prompt_version_single: str = "chat-answer-citation-v2" + chat_prompt_version_grouped: str = "chat-answer-grouped-citation-v2" chat_retrieval_version_single: str = "dormitory-search-v1" chat_retrieval_version_grouped: str = "dormitory-search-unspecified-v1" chat_retrieval_method_single: str = "vector_dormitory_top_k" From 9bb12df0fe6ed234a8bb657c593bb3738010f451 Mon Sep 17 00:00:00 2001 From: kimssirr Date: Sun, 3 May 2026 22:34:26 +0900 Subject: [PATCH 4/5] =?UTF-8?q?test:=20=ED=98=B8=EC=8B=A4=20=EC=B8=B5?= =?UTF-8?q?=EC=88=98=20=EC=9D=91=EB=8B=B5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_chat_service.py | 60 +++++++++++++++++++++++++++++++ tests/test_config.py | 2 +- tests/test_room_floor_resolver.py | 46 ++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 tests/test_room_floor_resolver.py diff --git a/tests/test_chat_service.py b/tests/test_chat_service.py index d8c06d3..ca77a21 100644 --- a/tests/test_chat_service.py +++ b/tests/test_chat_service.py @@ -270,6 +270,66 @@ def fake_generate_answer(_question, chunks): assert chat_log.retrieval_version == settings.chat_retrieval_version_grouped +def test_answer_chat_question_returns_room_floor_without_retrieval(monkeypatch: pytest.MonkeyPatch) -> None: + db = FakeSession() + chat_session = _build_chat_session() + chat_log = _build_chat_log() + + monkeypatch.setattr(chat_service, "get_chat_session", lambda *_args, **_kwargs: chat_session) + monkeypatch.setattr(chat_service, "create_chat_log", lambda *_args, **_kwargs: chat_log) + monkeypatch.setattr(chat_service, "get_chat_log_by_id", lambda *_args, **_kwargs: chat_log) + monkeypatch.setattr(chat_service, "touch_chat_session_activity", lambda *_args, **_kwargs: chat_session) + monkeypatch.setattr(chat_service, "validate_question", lambda *_args, **_kwargs: (True, "401호 몇 층이야?")) + monkeypatch.setattr( + chat_service, + "create_query_embedding", + lambda *_args, **_kwargs: pytest.fail("room floor question should not create embeddings"), + ) + monkeypatch.setattr( + chat_service, + "search_similar_chunks", + lambda *_args, **_kwargs: pytest.fail("room floor question should not search chunks"), + ) + monkeypatch.setattr( + chat_service, + "search_similar_chunks_for_dormitories", + lambda *_args, **_kwargs: pytest.fail("room floor question should not search grouped chunks"), + ) + monkeypatch.setattr( + chat_service, + "generate_answer", + lambda *_args, **_kwargs: pytest.fail("room floor question should not call answer generation"), + ) + monkeypatch.setattr( + chat_service, + "create_chat_retrieval_results", + lambda *_args, **_kwargs: pytest.fail("room floor question should not save retrieval results"), + ) + + response = chat_service.answer_chat_question( + db, + ChatRequest( + session_id="session-123", + question="401호 몇 층이야?", + dormitory="제1학생생활관", + ), + ) + + assert response.chat_log_id == 501 + assert response.session_id == "session-123" + assert response.answer == "401호는 제1학생생활관 지상 4층에 위치합니다." + assert response.answer_status == "SUCCESS" + assert response.source_url == "" + assert chat_log.answer_status == ChatAnswerStatus.SUCCESS + assert chat_log.rewritten_query == "401호 몇 층이야?" + assert chat_log.model_name is None + assert chat_log.prompt_version is None + assert chat_log.retrieval_version == "room-floor-rule-v1" + assert db.commit_count == 2 + assert db.close_count == 0 + assert db.rollback_count == 0 + + def test_answer_chat_question_returns_no_answer_for_invalid_question(monkeypatch: pytest.MonkeyPatch) -> None: db = FakeSession() chat_session = _build_chat_session() diff --git a/tests/test_config.py b/tests/test_config.py index c32b25f..9106088 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -10,6 +10,6 @@ def test_settings_from_environment() -> None: assert settings.database_url == "sqlite+pysqlite:///:memory:" assert settings.chat_answer_model == "gpt-4o-mini" assert settings.chat_single_dormitory_top_k == 3 - assert settings.chat_grouped_dormitory_top_k == 2 + assert settings.chat_grouped_dormitory_top_k == 3 assert settings.chat_grouped_dormitories == ["제1학생생활관", "제2학생생활관", "제3학생생활관"] assert settings.regulation_chunk_max_length == 400 diff --git a/tests/test_room_floor_resolver.py b/tests/test_room_floor_resolver.py new file mode 100644 index 0000000..faf208e --- /dev/null +++ b/tests/test_room_floor_resolver.py @@ -0,0 +1,46 @@ +from app.services.room_floor_resolver import RoomFloorResult +from app.services.room_floor_resolver import resolve_room_floor_question + + +def test_resolve_room_floor_question_returns_floor_for_known_room() -> None: + result = resolve_room_floor_question("401호 몇 층이야?", "제1학생생활관") + + assert result == RoomFloorResult( + answer="401호는 제1학생생활관 지상 4층에 위치합니다." + ) + + +def test_resolve_room_floor_question_accepts_spaced_floor_question() -> None: + result = resolve_room_floor_question("251호는 어디 층인가요?", "제2학생생활관") + + assert result == RoomFloorResult( + answer="251호는 제2학생생활관 지상 2층에 위치합니다." + ) + + +def test_resolve_room_floor_question_ignores_non_floor_question() -> None: + result = resolve_room_floor_question("401호에 택배 받을 수 있어?", "제1학생생활관") + + assert result is None + + +def test_resolve_room_floor_question_requires_dormitory() -> None: + result = resolve_room_floor_question("401호 몇 층이야?", None) + + assert result is None + + +def test_resolve_room_floor_question_returns_message_for_unknown_dormitory() -> None: + result = resolve_room_floor_question("401호 몇 층이야?", "행복기숙사") + + assert result == RoomFloorResult( + answer="행복기숙사의 호실 층수 정보를 확인할 수 없습니다." + ) + + +def test_resolve_room_floor_question_returns_message_for_unknown_room() -> None: + result = resolve_room_floor_question("999호 몇 층이야?", "제1학생생활관") + + assert result == RoomFloorResult( + answer="제1학생생활관에서 999호에 대한 층수 정보는 확인되지 않습니다." + ) From 9370abd60678f92620081cab49c66435d046547c Mon Sep 17 00:00:00 2001 From: 1024andrew <1024andrew@naver.com> Date: Mon, 4 May 2026 09:22:42 +0900 Subject: [PATCH 5/5] fix: add room floor resolver --- app/services/chat_service.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/app/services/chat_service.py b/app/services/chat_service.py index 850352a..ac2fabb 100644 --- a/app/services/chat_service.py +++ b/app/services/chat_service.py @@ -414,19 +414,6 @@ def _answer_unspecified_dormitory_chat( top_k=settings.chat_grouped_dormitory_top_k, ) - print("===== GROUPED SEARCH DEBUG =====") - print("question:", question) - print("chunks_count:", len(chunks)) - for index, chunk in enumerate(chunks, start=1): - print( - index, - chunk.get("document_id"), - chunk.get("dormitory"), - chunk.get("similarity"), - chunk.get("source"), - (chunk.get("content") or "")[:300], - ) - print("================================") except Exception as exc: _attach_chat_error_metadata( @@ -833,7 +820,7 @@ def _should_pre_expand_query(question: str) -> bool: "라면먹어", "라면먹어도", "라면 먹어", - "라면 먹어도" + "라면 먹어도", "방에서라면", "방에서 라면", "끓여 먹"