Skip to content
Merged
7 changes: 6 additions & 1 deletion app/routers/chat.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from fastapi import APIRouter, HTTPException, status

from app.schemas import ChatRequest, ChatResponse
from app.services.chat_service import chat
from app.services.chat_service import ChatDocumentMissingError, chat
from app.services.openai_adapter import OpenAIAdapterError, OpenAIConfigurationError

router = APIRouter(prefix="/ai/chat", tags=["chat"])
Expand All @@ -11,6 +11,11 @@
def send_message(req: ChatRequest) -> ChatResponse:
try:
return chat(req)
except ChatDocumentMissingError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
except OpenAIConfigurationError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
Expand Down
18 changes: 18 additions & 0 deletions app/routers/newsletters.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from fastapi import APIRouter, HTTPException, status

from app.schemas import (
CulturalGuideRequest,
CulturalGuideResponse,
NewsletterAnalysisRequest,
NewsletterAnalysisResponse,
NewsletterExtractionRequest,
Expand All @@ -9,6 +11,7 @@
TranslationRefineRequest,
TranslationRefineResponse,
)
from app.services.cultural_guide_service import select_cultural_guides
from app.services.newsletter_extractor import (
analyze_newsletter,
extract_newsletter_items,
Expand Down Expand Up @@ -61,3 +64,18 @@ def refine_translation_endpoint(req: TranslationRefineRequest) -> TranslationRef
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc),
) from exc

@router.post("/cultural-guides", response_model=CulturalGuideResponse)
def cultural_guides(req: CulturalGuideRequest) -> CulturalGuideResponse:
try:
return select_cultural_guides(req)
except OpenAIConfigurationError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
) from exc
except OpenAIAdapterError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc),
) from exc
Comment thread
Hminkyung marked this conversation as resolved.
50 changes: 48 additions & 2 deletions app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,19 +130,30 @@ class ChatLanguage(StrEnum):

class ChatType(StrEnum):
GENERAL = "GENERAL"
DOCUMENT = "DOCUMENT" # 추후 문서 챗봇
DOCUMENT = "DOCUMENT" # 문서 챗봇


class ChatMessageItem(BaseModel):
role: ChatMessageRole
content: str

# 문서 챗봇에서 BE가 매 요청마다 전달하는 문서 컨텍스트.
class ChatDocumentContext(BaseModel):
model_config = ConfigDict(populate_by_name=True)

newsletter_id: int | None = Field(default=None, alias="newsletterId")
title: str | None = None
summary: str | None = None
original_text: str = Field(alias="originalText")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated


class ChatRequest(BaseModel):
model_config = ConfigDict(populate_by_name=True)
message: str
history: list[ChatMessageItem] = []
language: ChatLanguage = ChatLanguage.KO
chat_type: ChatType = ChatType.GENERAL
chat_type: ChatType = Field(default=ChatType.GENERAL, alias="chatType")
document: ChatDocumentContext | None = None


class ChatResponse(BaseModel):
Expand Down Expand Up @@ -172,3 +183,38 @@ class RefineFieldOutput(BaseModel):

class TranslationRefineResponse(BaseModel):
fields: list[RefineFieldOutput] = Field(default_factory=list)


# 문화 맥락 안내 (Cultural Guide)
class CulturalGuideFaqCandidate(BaseModel):
model_config = ConfigDict(populate_by_name=True)

faq_id: int = Field(alias="faqId")
category: str
question: str = Field(min_length=1)


class CulturalGuideRequest(BaseModel):
model_config = ConfigDict(populate_by_name=True)

original_text: str = Field(alias="originalText")
title: str | None = None
summary: str | None = None
faq_candidates: list[CulturalGuideFaqCandidate] = Field(
default_factory=list, alias="faqCandidates"
)


class SelectedCulturalGuide(BaseModel):
model_config = ConfigDict(populate_by_name=True)

faq_id: int = Field(alias="faqId")
# relevanceReason은 화면에 노출X. 프롬프트 품질 점검/로깅용.
relevance_reason: str = Field(default="", alias="relevanceReason")


class CulturalGuideResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True)
selected_faqs: list[SelectedCulturalGuide] = Field(
default_factory=list, alias="selectedFaqs"
)
82 changes: 78 additions & 4 deletions app/services/chat_prompt.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from app.schemas import ChatRequest
from app.schemas import ChatDocumentContext, ChatRequest, ChatType

_LANGUAGE_NAME: dict[str, str] = {
"KO": "한국어",
Expand All @@ -7,6 +7,8 @@
"VI": "베트남어(Tiếng Việt)",
}

MAX_DOCUMENT_TEXT_LENGTH = 6000


def build_chat_messages(request: ChatRequest) -> list[dict[str, str]]:
messages: list[dict[str, str]] = []
Expand All @@ -15,7 +17,7 @@ def build_chat_messages(request: ChatRequest) -> list[dict[str, str]]:
messages.append(
{
"role": "system",
"content": _build_system_prompt(request.language, request.chat_type),
"content": _build_system_prompt(request),
}
)

Expand All @@ -39,9 +41,15 @@ def build_chat_messages(request: ChatRequest) -> list[dict[str, str]]:
return messages


def _build_system_prompt(language: str, chat_type: str) -> str:
language_name = _LANGUAGE_NAME.get(language, "한국어")
def _build_system_prompt(request: ChatRequest) -> str:
language_name = _LANGUAGE_NAME.get(request.language, "한국어")

if request.chat_type == ChatType.DOCUMENT and request.document is not None:
return _build_document_system_prompt(language_name, request.document)

return _build_general_system_prompt(language_name)

def _build_general_system_prompt(language_name: str) -> str:
return f"""
당신은 한국 초등학교에 자녀를 둔 다문화 가정 학부모를 돕는 AI 도우미 '까치'입니다.

Expand All @@ -67,3 +75,69 @@ def _build_system_prompt(language: str, chat_type: str) -> str:
- 준비물 및 제출 서류 관련 일반 안내
- 학부모 참여 활동 (공개수업, 학부모회 등)
""".strip()

#문서 챗봇
def _build_document_system_prompt(language_name: str, document: ChatDocumentContext) -> str:
document_block = _format_document_block(document)

return f"""
당신은 한국 초등학교에 자녀를 둔 다문화 가정 학부모를 돕는 AI 도우미 '까치'입니다.
지금은 [문서 챗봇 모드]입니다. 학부모가 방금 스캔한 가정통신문에 대해 질문합니다.

아래 <document>가 학부모가 스캔한 가정통신문의 전체 내용입니다.
본문은 한국어 원문이지만, 답변은 반드시 {language_name}로만 작성합니다.

{document_block}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

답변 원칙 (반드시 지킬 것):

1. 근거 우선순위
- 1순위는 <document> 안의 내용입니다.
- <document> 내용만으로 답할 수 있으면 그것만으로 답하고, 다른 설명을 덧붙이지 않습니다.
- 문서에 적힌 날짜, 시간, 금액, 장소, 준비물, 제출처는 문서에 쓰인 그대로 인용합니다.

2. 문서에 없는 내용을 설명해야 할 때 (보충 설명)
- 한국 초등학교의 일반적인 문화, 용어, 절차에 대한 보충 설명은 할 수 있습니다.
- 단, 반드시 아래 두 가지를 모두 지킵니다.
(a) 보충 설명을 시작하기 전에 문서 내용이 아님을 먼저 밝힙니다.
예: "이 가정통신문에는 나와 있지 않지만, 한국 초등학교에서는 보통 ~"
(b) 보충 설명이 포함된 답변의 마지막에는 반드시 아래 취지의 안내 문구를 붙입니다.
"더 확실한 내용은 담임 선생님이나 담당 선생님, 또는 학교에 직접 문의해 주세요."
→ 이 문구는 {language_name}로 자연스럽게 번역해서 작성합니다.
- 문서 내용만으로 답한 경우에는 이 안내 문구를 붙이지 않습니다.

3. 절대 하면 안 되는 것
- 문서에 없는 날짜, 시간, 금액, 장소, 준비물, 담당자, 연락처를 지어내지 않습니다.
- 문서에 있는 날짜나 금액을 임의로 계산·환산·추론하지 않습니다.
- 문서 내용을 확대 해석하거나, 문서에 없는 조건을 있는 것처럼 말하지 않습니다.
- 확실하지 않으면 "이 가정통신문에서는 확인할 수 없어요"라고 솔직하게 말합니다.

4. 문서에도 없고 일반적인 지식으로도 확실하지 않은 경우
- 모른다고 솔직히 말하고, 담임 선생님이나 학교에 문의하도록 안내합니다.
- 절대 추측해서 답하지 않습니다.

5. 범위를 벗어난 질문
- 이 가정통신문이나 학교 생활과 전혀 관련 없는 질문에는,
이 문서에 대한 질문만 도와드릴 수 있다고 정중하게 안내합니다.

6. 표현 방식
- 반드시 {language_name}로만 답변합니다.
- 외국인 학부모가 이해하기 쉬운 표현을 사용하고, 어려운 한국어 용어는 풀어서 설명합니다.
- 3~5문장 정도로 간결하게, 친근하고 따뜻한 톤을 유지합니다.
""".strip()


def _format_document_block(document: ChatDocumentContext) -> str:
original_text = (document.original_text or "").strip()
if len(original_text) > MAX_DOCUMENT_TEXT_LENGTH:
original_text = original_text[:MAX_DOCUMENT_TEXT_LENGTH]

lines = ["<document>"]
if document.title and document.title.strip():
lines.append(f"제목: {document.title.strip()}")
if document.summary and document.summary.strip():
lines.append(f"요약: {document.summary.strip()}")
lines.append("본문:")
lines.append(original_text)
Comment thread
Hminkyung marked this conversation as resolved.
lines.append("</document>")
return "\n".join(lines)
14 changes: 12 additions & 2 deletions app/services/chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
import urllib.request

from app.config import OpenAISettings, get_openai_settings
from app.schemas import ChatRequest, ChatResponse
from app.schemas import ChatRequest, ChatResponse, ChatType
from app.services.chat_prompt import build_chat_messages
from app.services.openai_adapter import OpenAIAdapterError, OpenAIConfigurationError

logger = logging.getLogger(__name__)

class ChatDocumentMissingError(ValueError):
pass

def chat(request: ChatRequest) -> ChatResponse:
settings = get_openai_settings()
Expand All @@ -19,13 +21,21 @@ def chat(request: ChatRequest) -> ChatResponse:

if not settings.api_key:
raise OpenAIConfigurationError("OPENAI_API_KEY가 설정되어 있지 않습니다.")

if request.chat_type == ChatType.DOCUMENT:
if request.document is None or not request.document.original_text.strip():
raise ChatDocumentMissingError(
"chatType=DOCUMENT 요청에는 document.originalText가 필요합니다."
)

messages = build_chat_messages(request)

logger.info(
"[ChatService] OpenAI 호출. language=%s, chat_type=%s, history_size=%d",
"[ChatService] OpenAI 호출. language=%s, chat_type=%s, history_size=%d, newsletter_id=%s",
request.language,
request.chat_type,
len(request.history),
request.document.newsletter_id if request.document else None,
)

reply = _call_openai_chat(settings, messages)
Expand Down
109 changes: 109 additions & 0 deletions app/services/cultural_guide_prompt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
from app.schemas import CulturalGuideRequest

# AI는 faqId만 고른다. 답변(answer) 본문은 절대 생성하지 않는다.
# (BE가 school_guide 테이블의 answer / answerI18n을 그대로 사용한다)
CULTURAL_GUIDE_RESPONSE_SCHEMA = {
"type": "object",
"additionalProperties": False,
"required": ["selectedFaqs"],
"properties": {
"selectedFaqs": {
"type": "array",
"minItems": 0,
"maxItems": 2,
"items": {
"type": "object",
"additionalProperties": False,
"required": ["faqId", "relevanceReason"],
"properties": {
"faqId": {"type": "integer", "minimum": 1},
"relevanceReason": {
"type": "string",
"minLength": 1,
"maxLength": 200,
},
},
},
},
},
}

MAX_SELECTED_FAQ_COUNT = 2


def build_cultural_guide_prompt_messages(
request: CulturalGuideRequest,
) -> list[dict[str, str]]:
return [
{"role": "system", "content": _build_system_prompt()},
{"role": "user", "content": _build_user_prompt(request)},
]


def _build_system_prompt() -> str:
return f"""
역할: 다문화 가정 학부모가 방금 스캔한 가정통신문 원문을 읽고,
미리 준비된 '학교 생활 가이드 FAQ' 후보 목록에서 이 문서와 직접 관련 있는 질문을 고른다.

이 기능의 목적:
- 한국 학교 문화에 익숙하지 않은 다문화 학부모가 이 가정통신문을 받았을 때
"이건 왜 이렇게 하는 거지?", "이거 안 하면 어떻게 되지?" 하고 실제로 궁금해할 만한
배경 설명을 미리 짚어주는 것이다.

출력 원칙:
- response schema에 맞는 JSON만 반환한다.
- faqCandidates에 실제로 존재하는 faqId만 사용한다. 새로운 id를 만들지 않는다.
- 최대 {MAX_SELECTED_FAQ_COUNT}개까지만 선택한다.
- 조건을 만족하는 FAQ가 하나도 없으면 반드시 빈 배열([])을 반환한다.
억지로 개수를 채우지 않는다. 0개는 정상적인 결과다.
- 질문(question) 문구를 수정하거나 새로 쓰지 않는다. 선택만 한다.
- 답변(answer)은 절대 생성하지 않는다. 답변은 시스템이 DB에서 그대로 가져다 쓴다.

선택 기준 (아래를 모두 만족해야 선택한다):
1. 문서에서 실제로 다루는 상황과 직접 연결될 것.
- 예: 동의서 제출을 요구하는 문서 → 동의서 제출 관련 FAQ (O)
- 예: 급식 식단표 안내 문서 → 급식 알레르기/식단표 확인 FAQ (O)
2. 다문화 학부모가 이 문서를 받았을 때 실제로 궁금해할 내용일 것.
- 한국인 학부모에게는 당연하지만 외국 배경 학부모에게는 낯선 절차·관행을 우선한다.
3. 단순히 같은 단어가 겹친다는 이유로 선택하지 않는다.
- 예: 문서에 '학교'라는 단어가 있다고 해서 '학교'가 들어간 아무 FAQ나 고르지 않는다.
- 예: 문서에 '신청'이 있다고 해서 관련 없는 '방과후학교 신청' FAQ를 고르지 않는다.
4. {MAX_SELECTED_FAQ_COUNT}개를 선택할 경우, 서로 다른 관점이나 서로 다른 category를 우선한다.
- 같은 내용을 반복하는 두 FAQ를 함께 고르지 않는다.
5. 확신이 서지 않으면 선택하지 않는다.
- 애매한 것을 2개 고르는 것보다, 확실한 것 1개만 고르거나 0개를 반환하는 것이 낫다.

relevanceReason 작성 원칙:
- 이 문서의 어떤 내용 때문에 해당 FAQ를 골랐는지 한국어 한 문장으로 짧게 적는다.
- 사용자 화면에는 노출되지 않는 내부 확인용 값이다.
""".strip()


def _build_user_prompt(request: CulturalGuideRequest) -> str:
sections = [
"<newsletter>",
f"제목: {request.title.strip() if request.title else '(없음)'}",
f"요약: {request.summary.strip() if request.summary else '(없음)'}",
"본문:",
request.original_text.strip(),
"</newsletter>",
"",
"<faq_candidates>",
_format_faq_candidates(request),
"</faq_candidates>",
]
return "\n".join(sections)


def _format_faq_candidates(request: CulturalGuideRequest) -> str:
if not request.faq_candidates:
return "(후보 없음)"

lines = []
for candidate in request.faq_candidates:
lines.append(
f"- faqId: {candidate.faq_id}, "
f"category: {candidate.category}, "
f"question: {candidate.question}"
)
return "\n".join(lines)
Comment thread
Hminkyung marked this conversation as resolved.
Loading