Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,16 @@ 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"
chat_retrieval_method_grouped: str = "vector_unspecified_dormitory_top_k"
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
Expand Down
100 changes: 66 additions & 34 deletions app/services/chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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으로 검색용 질의를 확장한 뒤 전체 생활관 대상으로 재검색
Expand All @@ -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)
Expand Down Expand Up @@ -682,16 +701,23 @@ def _get_query_expansion_rerank_keywords(question: str, expanded_query: str) ->

cooking_triggers = [
"라면끓",
"라면먹",
"라면먹어",
"라면먹어도",
"라면 먹어",
"라면 먹어도"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

쉼표가 누락된 부분이 있어서 한 단어로 취급될 수 있을 거 같은데 의도된건지 궁금합니다

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제가 빼먹었네요..수정했습니다

"방에서라면",
"방에서 라면",
"끓여 먹"
"끓여먹",
"끓여",
"취사",
"조리",
"요리",
"해먹",
"해먹어",
"해먹어도",
"음식해",
"음식해먹",
"음식 해먹"
"전기포트",
"라면포트",
"에어프라이어",
Expand Down Expand Up @@ -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):
Expand Down
4 changes: 3 additions & 1 deletion app/services/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ def generate_answer(
아래 제공된 정보를 기반으로만 질문에 답변해라.
모르는 내용은 추측하지 말고 모른다고 말해라.
참고 정보에 없는 일반 상식이나 추측으로 답하지 마라.
"일반적으로", "가능성이 높습니다", "확인하는 것이 좋습니다"처럼 근거 없는 표현을 사용하지 마라.
"일반적으로", "가능성이 높습니다", "가능할 수 있습니다", "허용되지 않을 것으로 보입니다", "확인하는 것이 좋습니다"처럼 근거 없는 추측 표현을 사용하지 마라.
사용자가 "방에서 라면 먹어도 돼?"처럼 질문한 경우, 참고 정보에 방 안 취식 금지 규정이 없으면 라면을 먹는 행위 자체를 금지한다고 단정하지 마라.
다만 참고 정보에 라면포트, 전기포트, 전열기구, 취사행위 금지 내용이 있으면 "방에서 라면을 조리해 먹는 것은 허용되지 않는다"라고 안내해라.
질문에서 생활관을 특정하지 않았고 참고 정보가 특정 생활관에만 해당하면, 해당 생활관 기준 답변임을 명확히 밝혀라.
질문에서 생활관을 특정하지 않았더라도 생활관별 구분을 강제로 만들지 말고, 가장 관련 있는 정보 중심으로 간결하게 답변해라.
참고 정보에 생활관 구분이 없거나 공통 규정으로 보이면 일반 답변으로 안내해라.
Expand Down
4 changes: 2 additions & 2 deletions app/services/query_rewriter.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def expand_query_for_retrieval(
단, 사용자가 휴게실을 직접 언급하지 않았다면 휴게실 통금보다 생활관 출입 통금으로 해석해라.
12.사용자가 "먹을 거", "간단하게 먹을 곳", "먹을거 해결", "사먹을 곳"처럼 식사/간식 해결 장소를 물으면 학생식당, 학식, 편의점, 매점, 배달음식 수령 키워드를 함께 포함해라.
13.사용자가 전자레인지, 음식 데우기, 데워먹기, 휴게실 전자레인지 위치를 물으면 휴게실, 공용시설, 전자레인지, 음식 데우기, 정수기, 싱크대 키워드를 포함해라.

14.사용자가 방에서 라면을 먹어도 되는지, 라면을 끓여 먹어도 되는지, 조리, 취사, 라면포트, 전기포트, 전열기구 사용 가능 여부를 물으면 반입금지 물품, 취사행위, 전열기구, 라면포트, 전기포트, 조리 금지, 화재위험 키워드를 포함해라.

예시:
사용자 질문: 새벽 2시에 들어가도 돼?
Expand All @@ -66,7 +66,7 @@ def expand_query_for_retrieval(
검색용 질의: 음료수 살만한 곳 있어? 검색 키워드: 편의점, 매점, 편의시설, 음료 구매, 간식 구매, 생활용품 구매 위치

사용자 질문: 방에서 라면 먹어도 돼?
검색용 질의: 방에서 라면 먹어도 돼? 검색 키워드: 호실 내 취사, 라면포트, 전열기구, 반입금지 물품, 취사 금지
검색용 질의: 방에서 라면 먹어도 돼? 검색 키워드: 호실 내 취사, 라면포트, 전기포트, 전열기구, 반입금지 물품, 조리 금지

사용자 질문: 방에서 라면 끓여 먹어도 돼?
검색용 질의: 방에서 라면 끓여 먹어도 돼? 검색 키워드: 호실 내 취사, 취사행위, 반입금지 물품, 전열기구, 라면포트, 전기포트, 화재위험
Expand Down
107 changes: 107 additions & 0 deletions app/services/room_floor_resolver.py
Original file line number Diff line number Diff line change
@@ -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"(?<!\d)(\d{3,4})호", compact_question)
if not match:
return None

return int(match.group(1))
60 changes: 60 additions & 0 deletions tests/test_chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading