diff --git a/app/api/admin_chat.py b/app/api/admin_chat.py index ef1ce79..d21268a 100644 --- a/app/api/admin_chat.py +++ b/app/api/admin_chat.py @@ -10,14 +10,17 @@ from app.api.dependencies.admin_auth import require_admin_token from app.db.session import get_db +from app.schemas.admin_chat import AdminChatAdminReview from app.schemas.admin_chat import AdminChatLogDetail from app.schemas.admin_chat import AdminChatReviewQueueReason from app.schemas.admin_chat import AdminChatReviewQueueResult +from app.schemas.admin_chat import AdminChatReviewSaveRequest from app.schemas.admin_chat import AdminChatSessionsByDateResult from app.schemas.common import ApiResponse from app.services.admin_chat_service import get_admin_chat_review_queue from app.services.admin_chat_service import get_admin_chat_sessions_by_date from app.services.admin_chat_service import get_admin_chat_log_detail +from app.services.admin_chat_service import save_admin_chat_review router = APIRouter( prefix="/admin/chat", @@ -72,6 +75,29 @@ def get_admin_chat_log_api( ) +@router.put( + "/logs/{chat_log_id}/review", + response_model=ApiResponse[AdminChatAdminReview], + status_code=status.HTTP_200_OK, + summary="관리자 채팅 검수 저장", + description=( + "관리자가 특정 채팅 로그에 대한 검수 결과를 저장하거나 수정합니다.\n\n" + "`chat_log_id`에 기존 검수 결과가 있으면 최신 검수 row를 수정하고, 없으면 새로 생성합니다." + ), +) +def save_admin_chat_review_api( + request: AdminChatReviewSaveRequest, + chat_log_id: int = Path(ge=1), + db: Session = Depends(get_db), +) -> ApiResponse[AdminChatAdminReview]: + result = save_admin_chat_review(db, chat_log_id=chat_log_id, request=request) + return ApiResponse( + status=status.HTTP_200_OK, + message="chat admin review saved", + data=result, + ) + + @router.get( "/sessions", response_model=ApiResponse[AdminChatSessionsByDateResult], diff --git a/app/repositories/admin_chat_repository.py b/app/repositories/admin_chat_repository.py index 1d5818d..eed77f8 100644 --- a/app/repositories/admin_chat_repository.py +++ b/app/repositories/admin_chat_repository.py @@ -83,6 +83,74 @@ def list_chat_error_logs_by_chat_log_id( return list(db.execute(statement).scalars().all()) +def list_chat_feedbacks_by_chat_log_id( + db: Session, + chat_log_id: int, +) -> list[ChatFeedback]: + statement = ( + select(ChatFeedback) + .where(ChatFeedback.chat_log_id == chat_log_id) + .order_by(ChatFeedback.created_at.desc(), ChatFeedback.feedback_id.desc()) + ) + return list(db.execute(statement).scalars().all()) + + +def list_chat_admin_reviews_by_chat_log_id( + db: Session, + chat_log_id: int, +) -> list[ChatAdminReview]: + statement = ( + select(ChatAdminReview) + .where(ChatAdminReview.chat_log_id == chat_log_id) + .order_by(ChatAdminReview.created_at.desc(), ChatAdminReview.review_id.desc()) + ) + return list(db.execute(statement).scalars().all()) + + +def get_latest_chat_admin_review_by_chat_log_id( + db: Session, + chat_log_id: int, +) -> Optional[ChatAdminReview]: + statement = ( + select(ChatAdminReview) + .where(ChatAdminReview.chat_log_id == chat_log_id) + .order_by(ChatAdminReview.created_at.desc(), ChatAdminReview.review_id.desc()) + .limit(1) + ) + return db.execute(statement).scalar_one_or_none() + + +def save_chat_admin_review( + db: Session, + *, + chat_log_id: int, + reviewer_id: int, + correctness_label: str, + citation_label: Optional[str], + root_cause: Optional[str], + correction_required: bool, + corrected_answer: Optional[str], + review_note: Optional[str], +) -> ChatAdminReview: + admin_review = get_latest_chat_admin_review_by_chat_log_id(db, chat_log_id) + if admin_review is None: + admin_review = ChatAdminReview( + chat_log_id=chat_log_id, + reviewer_id=reviewer_id, + ) + db.add(admin_review) + + admin_review.reviewer_id = reviewer_id + admin_review.correctness_label = correctness_label + admin_review.citation_label = citation_label + admin_review.root_cause = root_cause + admin_review.correction_required = correction_required + admin_review.corrected_answer = corrected_answer + admin_review.review_note = review_note + db.flush() + return admin_review + + def count_chat_review_queue_items(db: Session, reason: Optional[str] = None) -> int: statement = select(func.count(ChatLog.chat_log_id)).where(*_build_chat_review_queue_conditions(reason)) return int(db.execute(statement).scalar_one()) diff --git a/app/schemas/admin_chat.py b/app/schemas/admin_chat.py index e41a8e7..b86c913 100644 --- a/app/schemas/admin_chat.py +++ b/app/schemas/admin_chat.py @@ -8,6 +8,16 @@ AdminChatReviewQueueReason = Literal["ERROR", "NO_ANSWER", "NEGATIVE_FEEDBACK"] +AdminChatCorrectnessLabel = Literal["CORRECT", "PARTIAL", "INCORRECT"] +AdminChatCitationLabel = Literal["APPROPRIATE", "WEAK", "WRONG", "NONE"] +AdminChatRootCause = Literal[ + "RETRIEVAL_FAIL", + "DOC_OUTDATED", + "NO_RELEVANT_DOC", + "PROMPT_OVERGENERATION", + "QUESTION_AMBIGUOUS", + "MODEL_HALLUCINATION", +] class AdminChatLogDetail(BaseModel): @@ -25,6 +35,8 @@ class AdminChatLogDetail(BaseModel): created_at: datetime retrieval_results: list["AdminChatRetrievalResult"] = Field(default_factory=list) error_logs: list["AdminChatErrorLog"] = Field(default_factory=list) + feedbacks: list["AdminChatFeedback"] = Field(default_factory=list) + admin_reviews: list["AdminChatAdminReview"] = Field(default_factory=list) class AdminChatRetrievalResult(BaseModel): @@ -52,6 +64,40 @@ class AdminChatErrorLog(BaseModel): created_at: datetime +class AdminChatFeedback(BaseModel): + feedback_id: int + user_id: Optional[int] = None + feedback_type: str + is_helpful: Optional[bool] = None + rating: Optional[int] = None + reason_code: Optional[str] = None + feedback_comment: Optional[str] = None + feature_type: Optional[str] = None + created_at: datetime + + +class AdminChatAdminReview(BaseModel): + review_id: int + admin_id: int + correctness_label: str + citation_label: Optional[str] = None + root_cause: Optional[str] = None + correction_required: bool + corrected_answer: Optional[str] = None + review_note: Optional[str] = None + created_at: datetime + + +class AdminChatReviewSaveRequest(BaseModel): + admin_id: int = Field(ge=1) + correctness_label: AdminChatCorrectnessLabel + citation_label: Optional[AdminChatCitationLabel] = None + root_cause: Optional[AdminChatRootCause] = None + correction_required: bool = False + corrected_answer: Optional[str] = Field(default=None, max_length=5000) + review_note: Optional[str] = Field(default=None, max_length=2000) + + class AdminChatSessionSummary(BaseModel): session_id: str user_id: Optional[int] = None diff --git a/app/services/admin_chat_service.py b/app/services/admin_chat_service.py index 203403b..7fe4c82 100644 --- a/app/services/admin_chat_service.py +++ b/app/services/admin_chat_service.py @@ -13,13 +13,19 @@ from app.repositories.admin_chat_repository import count_chat_review_queue_items from app.repositories.admin_chat_repository import count_chat_sessions_by_started_date from app.repositories.admin_chat_repository import list_chat_error_logs_by_chat_log_id +from app.repositories.admin_chat_repository import list_chat_feedbacks_by_chat_log_id +from app.repositories.admin_chat_repository import list_chat_admin_reviews_by_chat_log_id from app.repositories.admin_chat_repository import list_chat_retrieval_results_by_chat_log_id from app.repositories.admin_chat_repository import list_chat_review_queue_items from app.repositories.admin_chat_repository import list_chat_sessions_by_started_date +from app.repositories.admin_chat_repository import save_chat_admin_review from app.schemas.admin_chat import AdminChatReviewQueueReason from app.schemas.admin_chat import AdminChatReviewQueueResult from app.schemas.admin_chat import AdminChatReviewQueueItem from app.schemas.admin_chat import AdminChatErrorLog +from app.schemas.admin_chat import AdminChatFeedback +from app.schemas.admin_chat import AdminChatAdminReview +from app.schemas.admin_chat import AdminChatReviewSaveRequest from app.schemas.admin_chat import AdminChatSessionsByDateResult from app.schemas.admin_chat import AdminChatLogDetail from app.schemas.admin_chat import AdminChatRetrievalResult @@ -34,6 +40,8 @@ def get_admin_chat_log_detail(db: Session, chat_log_id: int) -> AdminChatLogDeta raise AppException(CHAT_LOG_NOT_FOUND) retrieval_results = list_chat_retrieval_results_by_chat_log_id(db, chat_log_id) error_logs = list_chat_error_logs_by_chat_log_id(db, chat_log_id) + feedbacks = list_chat_feedbacks_by_chat_log_id(db, chat_log_id) + admin_reviews = list_chat_admin_reviews_by_chat_log_id(db, chat_log_id) return AdminChatLogDetail( chat_log_id=chat_log.chat_log_id, @@ -77,6 +85,34 @@ def get_admin_chat_log_detail(db: Session, chat_log_id: int) -> AdminChatLogDeta ) for item in error_logs ], + feedbacks=[ + AdminChatFeedback( + feedback_id=item.feedback_id, + user_id=item.user_id, + feedback_type=item.feedback_type, + is_helpful=item.is_helpful, + rating=item.rating, + reason_code=item.reason_code, + feedback_comment=item.feedback_comment, + feature_type=item.feature_type, + created_at=item.created_at, + ) + for item in feedbacks + ], + admin_reviews=[ + AdminChatAdminReview( + review_id=item.review_id, + admin_id=item.reviewer_id, + correctness_label=item.correctness_label, + citation_label=item.citation_label, + root_cause=item.root_cause, + correction_required=item.correction_required, + corrected_answer=item.corrected_answer, + review_note=item.review_note, + created_at=item.created_at, + ) + for item in admin_reviews + ], ) @@ -147,6 +183,32 @@ def get_admin_chat_review_queue( ) +def save_admin_chat_review( + db: Session, + *, + chat_log_id: int, + request: AdminChatReviewSaveRequest, +) -> AdminChatAdminReview: + chat_log = get_chat_log_by_id(db, chat_log_id) + if chat_log is None: + raise AppException(CHAT_LOG_NOT_FOUND) + + admin_review = save_chat_admin_review( + db, + chat_log_id=chat_log_id, + reviewer_id=request.admin_id, + correctness_label=request.correctness_label, + citation_label=request.citation_label, + root_cause=request.root_cause, + correction_required=request.correction_required, + corrected_answer=request.corrected_answer, + review_note=request.review_note, + ) + db.commit() + db.refresh(admin_review) + return _build_admin_review_schema(admin_review) + + def _is_chat_session_expired(last_activity_at: datetime, expiration_threshold: datetime) -> bool: return last_activity_at < expiration_threshold @@ -176,3 +238,17 @@ def _resolve_review_reason(answer_status) -> AdminChatReviewQueueReason: def _get_answer_status_value(answer_status) -> str: return getattr(answer_status, "value", answer_status) + + +def _build_admin_review_schema(item) -> AdminChatAdminReview: + return AdminChatAdminReview( + review_id=item.review_id, + admin_id=item.reviewer_id, + correctness_label=item.correctness_label, + citation_label=item.citation_label, + root_cause=item.root_cause, + correction_required=item.correction_required, + corrected_answer=item.corrected_answer, + review_note=item.review_note, + created_at=item.created_at, + ) diff --git a/tests/test_admin_chat_api.py b/tests/test_admin_chat_api.py index d7efc6e..3dd5e5f 100644 --- a/tests/test_admin_chat_api.py +++ b/tests/test_admin_chat_api.py @@ -4,6 +4,7 @@ from app.api import admin_chat as admin_chat_module from app.core.error_codes import CHAT_LOG_NOT_FOUND from app.core.exceptions import AppException +from app.schemas.admin_chat import AdminChatAdminReview from app.schemas.admin_chat import AdminChatLogDetail from app.schemas.admin_chat import AdminChatReviewQueueItem from app.schemas.admin_chat import AdminChatReviewQueueResult @@ -58,6 +59,32 @@ def test_get_admin_chat_log_api_returns_chat_log_detail( "created_at": datetime(2026, 4, 27, 10, 0, 2), } ], + feedbacks=[ + { + "feedback_id": 41, + "user_id": 7, + "feedback_type": "DISLIKE", + "is_helpful": False, + "rating": None, + "reason_code": "INCORRECT_ANSWER", + "feedback_comment": "질문과 다른 답변입니다.", + "feature_type": "FAQ_CHAT", + "created_at": datetime(2026, 4, 27, 10, 0, 3), + } + ], + admin_reviews=[ + { + "review_id": 51, + "admin_id": 1, + "correctness_label": "INCORRECT", + "citation_label": "WRONG", + "root_cause": "RETRIEVAL_FAIL", + "correction_required": True, + "corrected_answer": "외박은 포털에서 신청합니다.", + "review_note": "검색 후보가 잘못 선택됨", + "created_at": datetime(2026, 4, 27, 10, 0, 4), + } + ], ), ) @@ -110,6 +137,32 @@ def test_get_admin_chat_log_api_returns_chat_log_detail( "created_at": "2026-04-27T10:00:02", } ], + "feedbacks": [ + { + "feedback_id": 41, + "user_id": 7, + "feedback_type": "DISLIKE", + "is_helpful": False, + "rating": None, + "reason_code": "INCORRECT_ANSWER", + "feedback_comment": "질문과 다른 답변입니다.", + "feature_type": "FAQ_CHAT", + "created_at": "2026-04-27T10:00:03", + } + ], + "admin_reviews": [ + { + "review_id": 51, + "admin_id": 1, + "correctness_label": "INCORRECT", + "citation_label": "WRONG", + "root_cause": "RETRIEVAL_FAIL", + "correction_required": True, + "corrected_answer": "외박은 포털에서 신청합니다.", + "review_note": "검색 후보가 잘못 선택됨", + "created_at": "2026-04-27T10:00:04", + } + ], }, "error_code": None, } @@ -139,6 +192,87 @@ def test_get_admin_chat_log_api_returns_not_found_error( } +def test_save_admin_chat_review_api_returns_saved_review( + client: TestClient, + monkeypatch, +) -> None: + calls = {} + + def fake_save_admin_chat_review(_db, **kwargs): + calls.update(kwargs) + return AdminChatAdminReview( + review_id=51, + admin_id=1, + correctness_label="INCORRECT", + citation_label="WRONG", + root_cause="RETRIEVAL_FAIL", + correction_required=True, + corrected_answer="택배는 각 생활관 행정실에서 수령할 수 있습니다.", + review_note="택배 질문에 외박 문서가 검색됨", + created_at="2026-04-30T10:30:00", + ) + + monkeypatch.setattr(admin_chat_module, "save_admin_chat_review", fake_save_admin_chat_review) + + response = client.put( + "/api/v1/admin/chat/logs/101/review", + headers={"X-Admin-Token": "test-admin-token"}, + json={ + "admin_id": 1, + "correctness_label": "INCORRECT", + "citation_label": "WRONG", + "root_cause": "RETRIEVAL_FAIL", + "correction_required": True, + "corrected_answer": "택배는 각 생활관 행정실에서 수령할 수 있습니다.", + "review_note": "택배 질문에 외박 문서가 검색됨", + }, + ) + + assert response.status_code == 200 + assert calls["chat_log_id"] == 101 + assert calls["request"].admin_id == 1 + assert calls["request"].correctness_label == "INCORRECT" + assert response.json() == { + "status": 200, + "message": "chat admin review saved", + "data": { + "review_id": 51, + "admin_id": 1, + "correctness_label": "INCORRECT", + "citation_label": "WRONG", + "root_cause": "RETRIEVAL_FAIL", + "correction_required": True, + "corrected_answer": "택배는 각 생활관 행정실에서 수령할 수 있습니다.", + "review_note": "택배 질문에 외박 문서가 검색됨", + "created_at": "2026-04-30T10:30:00", + }, + "error_code": None, + } + + +def test_save_admin_chat_review_api_returns_not_found_error( + client: TestClient, + monkeypatch, +) -> None: + monkeypatch.setattr( + admin_chat_module, + "save_admin_chat_review", + lambda *_args, **_kwargs: (_ for _ in ()).throw(AppException(CHAT_LOG_NOT_FOUND)), + ) + + response = client.put( + "/api/v1/admin/chat/logs/999/review", + headers={"X-Admin-Token": "test-admin-token"}, + json={ + "admin_id": 1, + "correctness_label": "INCORRECT", + }, + ) + + assert response.status_code == 404 + assert response.json()["error_code"] == "CHAT_LOG_NOT_FOUND" + + def test_get_admin_chat_review_queue_api_returns_paginated_queue( client: TestClient, monkeypatch, diff --git a/tests/test_admin_chat_service.py b/tests/test_admin_chat_service.py index b5a1214..2ed114b 100644 --- a/tests/test_admin_chat_service.py +++ b/tests/test_admin_chat_service.py @@ -2,7 +2,9 @@ import pytest +from app.core.error_codes import CHAT_LOG_NOT_FOUND from app.core.exceptions import AppException +from app.schemas.admin_chat import AdminChatReviewSaveRequest from app.services import admin_chat_service @@ -69,6 +71,48 @@ def test_get_admin_chat_log_detail_returns_mapped_schema(monkeypatch: pytest.Mon )() ], ) + monkeypatch.setattr( + admin_chat_service, + "list_chat_feedbacks_by_chat_log_id", + lambda *_args, **_kwargs: [ + type( + "ChatFeedbackStub", + (), + { + "feedback_id": 41, + "user_id": 7, + "feedback_type": "DISLIKE", + "is_helpful": False, + "rating": None, + "reason_code": "INCORRECT_ANSWER", + "feedback_comment": "질문과 다른 답변입니다.", + "feature_type": "FAQ_CHAT", + "created_at": datetime(2026, 4, 27, 10, 0, 3), + }, + )() + ], + ) + monkeypatch.setattr( + admin_chat_service, + "list_chat_admin_reviews_by_chat_log_id", + lambda *_args, **_kwargs: [ + type( + "ChatAdminReviewStub", + (), + { + "review_id": 51, + "reviewer_id": 1, + "correctness_label": "INCORRECT", + "citation_label": "WRONG", + "root_cause": "RETRIEVAL_FAIL", + "correction_required": True, + "corrected_answer": "외박은 포털에서 신청합니다.", + "review_note": "검색 후보가 잘못 선택됨", + "created_at": datetime(2026, 4, 27, 10, 0, 4), + }, + )() + ], + ) result = admin_chat_service.get_admin_chat_log_detail(object(), 11) @@ -79,6 +123,12 @@ def test_get_admin_chat_log_detail_returns_mapped_schema(monkeypatch: pytest.Mon assert result.retrieval_results[0].regulation_chunk_id == 1001 assert len(result.error_logs) == 1 assert result.error_logs[0].error_type == "LLM_API_ERROR" + assert len(result.feedbacks) == 1 + assert result.feedbacks[0].reason_code == "INCORRECT_ANSWER" + assert result.feedbacks[0].is_helpful is False + assert len(result.admin_reviews) == 1 + assert result.admin_reviews[0].root_cause == "RETRIEVAL_FAIL" + assert result.admin_reviews[0].correction_required is True def test_get_admin_chat_log_detail_raises_not_found(monkeypatch: pytest.MonkeyPatch) -> None: @@ -90,6 +140,91 @@ def test_get_admin_chat_log_detail_raises_not_found(monkeypatch: pytest.MonkeyPa assert exc_info.value.error_code.code == "CHAT_LOG_NOT_FOUND" +def test_save_admin_chat_review_saves_review(monkeypatch: pytest.MonkeyPatch) -> None: + calls = {} + chat_log = type("ChatLogStub", (), {"chat_log_id": 101})() + admin_review = type( + "ChatAdminReviewStub", + (), + { + "review_id": 51, + "reviewer_id": 1, + "correctness_label": "INCORRECT", + "citation_label": "WRONG", + "root_cause": "RETRIEVAL_FAIL", + "correction_required": True, + "corrected_answer": "택배는 각 생활관 행정실에서 수령할 수 있습니다.", + "review_note": "택배 질문에 외박 문서가 검색됨", + "created_at": datetime(2026, 4, 30, 10, 30, 0), + }, + )() + + class FakeDb: + def commit(self): + calls["committed"] = True + + def refresh(self, item): + calls["refreshed"] = item + + def fake_save_chat_admin_review(_db, **kwargs): + calls.update(kwargs) + return admin_review + + monkeypatch.setattr(admin_chat_service, "get_chat_log_by_id", lambda *_args, **_kwargs: chat_log) + monkeypatch.setattr(admin_chat_service, "save_chat_admin_review", fake_save_chat_admin_review) + + result = admin_chat_service.save_admin_chat_review( + FakeDb(), + chat_log_id=101, + request=AdminChatReviewSaveRequest( + admin_id=1, + correctness_label="INCORRECT", + citation_label="WRONG", + root_cause="RETRIEVAL_FAIL", + correction_required=True, + corrected_answer="택배는 각 생활관 행정실에서 수령할 수 있습니다.", + review_note="택배 질문에 외박 문서가 검색됨", + ), + ) + + assert calls["chat_log_id"] == 101 + assert calls["reviewer_id"] == 1 + assert calls["correctness_label"] == "INCORRECT" + assert calls["citation_label"] == "WRONG" + assert calls["root_cause"] == "RETRIEVAL_FAIL" + assert calls["correction_required"] is True + assert calls["corrected_answer"] == "택배는 각 생활관 행정실에서 수령할 수 있습니다." + assert calls["review_note"] == "택배 질문에 외박 문서가 검색됨" + assert calls["committed"] is True + assert calls["refreshed"] is admin_review + assert result.review_id == 51 + assert result.root_cause == "RETRIEVAL_FAIL" + assert result.correction_required is True + + +def test_save_admin_chat_review_raises_when_chat_log_is_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(admin_chat_service, "get_chat_log_by_id", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + admin_chat_service, + "save_chat_admin_review", + lambda *_args, **_kwargs: pytest.fail("missing chat log should not save admin review"), + ) + + with pytest.raises(AppException) as exc_info: + admin_chat_service.save_admin_chat_review( + object(), + chat_log_id=999, + request=AdminChatReviewSaveRequest( + admin_id=1, + correctness_label="INCORRECT", + ), + ) + + assert exc_info.value.error_code == CHAT_LOG_NOT_FOUND + + def test_get_admin_chat_sessions_by_date_returns_paginated_sessions(monkeypatch: pytest.MonkeyPatch) -> None: sessions = [ type(