diff --git a/app/core/config.py b/app/core/config.py index 6e7d09d..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" @@ -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 e143695..ac2fabb 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( @@ -391,6 +413,8 @@ def _answer_unspecified_dormitory_chat( dormitories=settings.chat_grouped_dormitories, top_k=settings.chat_grouped_dormitory_top_k, ) + + except Exception as exc: _attach_chat_error_metadata( exc, @@ -399,23 +423,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 +442,7 @@ def _answer_unspecified_dormitory_chat( question=question, dormitory=None, ) + if expanded_query != question: expanded_query_embedding = create_query_embedding(expanded_query) @@ -682,16 +701,23 @@ def _get_query_expansion_rerank_keywords(question: str, expanded_query: str) -> cooking_triggers = [ "라면끓", + "라면먹", + "라면먹어", + "라면먹어도", + "라면 먹어", + "라면 먹어도" + "방에서라면", + "방에서 라면", + "끓여 먹" "끓여먹", "끓여", "취사", "조리", "요리", "해먹", - "해먹어", - "해먹어도", "음식해", "음식해먹", + "음식 해먹" "전기포트", "라면포트", "에어프라이어", @@ -787,24 +813,30 @@ 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): 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..ce4bc0b --- /dev/null +++ b/app/services/room_floor_resolver.py @@ -0,0 +1,107 @@ +"""호실 번호 기반 층수 질문을 규칙 기반으로 처리하는 서비스입니다.""" + +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}의 호실 층수 정보를 확인할 수 없습니다." + ) + + + 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]: + compact_question = question.replace(" ", "") + match = re.search(r"(? 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호에 대한 층수 정보는 확인되지 않습니다." + )