diff --git a/app/routers/newsletters.py b/app/routers/newsletters.py index d3f0b9a..4669f06 100644 --- a/app/routers/newsletters.py +++ b/app/routers/newsletters.py @@ -6,8 +6,14 @@ NewsletterExtractionRequest, NewsletterExtractionResponse, PromptPreviewResponse, + TranslationRefineRequest, + TranslationRefineResponse, +) +from app.services.newsletter_extractor import ( + analyze_newsletter, + extract_newsletter_items, + refine_translation, ) -from app.services.newsletter_extractor import analyze_newsletter, extract_newsletter_items from app.services.newsletter_prompt import ANALYSIS_RESPONSE_SCHEMA, build_prompt_messages from app.services.openai_adapter import OpenAIAdapterError, OpenAIConfigurationError @@ -39,3 +45,19 @@ def extract_items(req: NewsletterExtractionRequest) -> NewsletterExtractionRespo def prompt_preview(req: NewsletterExtractionRequest) -> PromptPreviewResponse: messages = build_prompt_messages(req) return PromptPreviewResponse(messages=messages, responseSchema=ANALYSIS_RESPONSE_SCHEMA) + + +@router.post("/refine-translation", response_model=TranslationRefineResponse) +def refine_translation_endpoint(req: TranslationRefineRequest) -> TranslationRefineResponse: + try: + return refine_translation(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 diff --git a/app/schemas.py b/app/schemas.py index 70699e0..71b3d34 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -146,3 +146,28 @@ class ChatRequest(BaseModel): class ChatResponse(BaseModel): reply: str + + +class RefineFieldInput(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + id: str + ko_text: str = Field(alias="koText") + translated_text: str = Field(min_length=1, alias="translatedText") + + +class TranslationRefineRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + original_text: str = Field(alias="originalText") + language: str = "KO" + fields: list[RefineFieldInput] = Field(default_factory=list) + + +class RefineFieldOutput(BaseModel): + id: str + text: str = Field(min_length=1) + + +class TranslationRefineResponse(BaseModel): + fields: list[RefineFieldOutput] = Field(default_factory=list) diff --git a/app/services/newsletter_extractor.py b/app/services/newsletter_extractor.py index c2dd821..85483ca 100644 --- a/app/services/newsletter_extractor.py +++ b/app/services/newsletter_extractor.py @@ -14,7 +14,10 @@ NewsletterAnalysisResponse, NewsletterExtractionRequest, NewsletterExtractionResponse, + RefineFieldOutput, SelectedDateCandidate, + TranslationRefineRequest, + TranslationRefineResponse, ) from app.services.openai_adapter import OpenAINewsletterAdapter @@ -332,3 +335,49 @@ def _summarize_document( def _normalized_language(language: str | None) -> str: return (language or "KO").strip().upper() + + +def refine_translation(request: TranslationRefineRequest) -> TranslationRefineResponse: + settings = get_openai_settings() + + if not request.fields: + return TranslationRefineResponse(fields=[]) + + if not settings.enabled: + logger.info( + "[TranslationRefine] OpenAI 비활성화. 파파고 1차 번역 결과를 그대로 사용합니다." + ) + return TranslationRefineResponse( + fields=[ + RefineFieldOutput(id=field.id, text=field.translated_text) + for field in request.fields + ] + ) + + logger.info( + "[TranslationRefine] OpenAI 2차 검증 모드로 실행합니다. model=%s, fieldCount=%s", + settings.model, + len(request.fields), + ) + response = OpenAINewsletterAdapter(settings).refine_translation(request) + + # 응답 fields의 id가 요청 fields의 id 집합과 일치하는지 검증. + # 누락되거나 알 수 없는 id가 있으면 해당 필드는 파파고 1차 번역 결과로 대체한다. + requested_by_id = {field.id: field for field in request.fields} + refined_by_id = {field.id: field.text for field in response.fields} + + result_fields = [] + for field in request.fields: + text = refined_by_id.get(field.id) + if text is None or not text.strip(): + logger.warning( + "[TranslationRefine] id=%s 응답 누락. 파파고 1차 번역 결과로 대체합니다.", field.id + ) + text = field.translated_text + result_fields.append(RefineFieldOutput(id=field.id, text=text)) + + unknown_ids = set(refined_by_id.keys()) - set(requested_by_id.keys()) + if unknown_ids: + logger.warning("[TranslationRefine] 알 수 없는 id 응답 무시. unknownIds=%s", unknown_ids) + + return TranslationRefineResponse(fields=result_fields) diff --git a/app/services/newsletter_prompt.py b/app/services/newsletter_prompt.py index 285aec3..151258a 100644 --- a/app/services/newsletter_prompt.py +++ b/app/services/newsletter_prompt.py @@ -138,10 +138,14 @@ def _build_system_prompt(language: str) -> str: 역할: 학교 가정통신문 원문을 분석해서 저장 가능한 제목, 요약, 주요 일정/마감/체크리스트 항목을 JSON으로 반환한다. -응답 원칙: - response schema에 맞는 JSON만 반환한다. - AI 서버는 DB 저장을 직접 알지 않는다. 저장 판단은 BE가 하며, AI 서버는 분석 결과만 반환한다. -- 최종 사용자 노출 문구는 반드시 {language_name}로 작성한다. +- title, summary, items[].title, checklistItems[].content, checklistItems[].detail, + conversationTopics[].topic은 사용자 언어({language_name})와 무관하게 항상 한국어로 작성한다. + (이 값들은 이후 단계에서 번역 및 검수를 거쳐 사용자 언어로 변환된다.) +- 단, titleI18n과 checklistItems[].contentI18n은 기존과 동일하게 KO/US/ZH/VI + 네 언어 값을 모두 채운다. 이 값들은 사용자의 현재 언어({language_name})와 + 무관하게 알림(notification)에서 사용된다. - title은 문서 제목으로 사용할 수 있는 짧은 문자열로 작성한다. - titleI18n은 알림에서 사용할 문서 제목이며 KO/US/ZH/VI 네 언어 값을 모두 채운다. - summary는 보호자나 학생이 빠르게 확인할 수 있는 1~2문장으로 작성한다. @@ -172,12 +176,12 @@ def _build_system_prompt(language: str) -> str: 표현하는 항목을 여러 개 만들지 않는다. - content는 다문화 학부모가 실제로 수행할 수 있는 구체적 행동 단위로, "OO 제출하기", "OO 준비하기", "OO 동의서 작성하기"처럼 행동 지향적인 - 짧은 문구로 작성하되 최종 응답 언어는 {language_name}로 맞춘다. + 짧은 문구로 작성하되, 항상 한국어로 작성한다. (사용자 언어로의 번역은 + 이후 단계에서 별도로 처리한다.) - contentI18n은 알림에서 사용할 체크리스트/할 일 이름이며 KO/US/ZH/VI 값을 모두 채운다. - detail은 그 항목에 대한 부가 설명을 원문 근거에 기반해 1줄로 작성한다. (특별한 부가 설명이 없으면 null 가능) - 체크리스트 문구는 BE에서 다시 번역하지 않고 바로 저장/표시할 수 있어야 한다. - 대화 주제(conversationTopics) 추출 원칙: - 다문화 가정 학부모가 자녀(초등학생)와 나눌 수 있는 대화 주제를 최대 3개 추출한다. - 아래 두 조건을 모두 만족하는 주제만 포함한다. @@ -206,17 +210,16 @@ def _build_system_prompt(language: str) -> str: - 위 4가지 관점 중 적합한 게 3개 미만이면 그 수만큼만 반환한다. - 위 조건을 만족하는 주제가 없으면 빈 배열([])을 반환한다. - topic은 학부모가 자녀에게 바로 말할 수 있는 자연스러운 구어체 문장으로 작성한다. -- topic도 최종 사용자 노출 문구이므로 반드시 {language_name}로 작성한다. +- topic은 항상 한국어로 작성한다. (사용자 언어로의 번역은 이후 단계에서 별도로 처리한다.) -다국어 생성 원칙: +한국어 작성 원칙 (title/summary/items[].title/checklistItems/conversationTopics): - original_text는 사실 판단의 기준이다. -- translated_text가 있으면 초벌 번역/참고자료로만 사용하고, 어색하거나 문맥이 틀리면 - original_text를 기준으로 바로잡는다. -- 학교명, 기관명, 행사명, 고유명사는 무리하게 의역하지 말고 필요하면 원문을 보존한다. +- translated_text는 참고하지 않는다. (단일 필드는 항상 한국어로 작성하므로 번역 초안이 필요 없다.) +- 학교명, 기관명, 행사명, 고유명사는 원문 표기를 그대로 사용한다. - 날짜, 시간, 금액, 준비물, 제출 대상 같은 핵심 정보는 빠뜨리지 않는다. - enum 값(type, dateStatus), datetime, timezone, selectedDateCandidate.originalText는 - schema와 원문 추적을 위해 번역하지 않는다. -- confirmationQuestion이 필요한 경우에도 {language_name}로 작성한다. + schema와 원문 추적을 위해 그대로 둔다 (원래도 번역 대상 아님). +- evidenceText, confirmationQuestion도 한국어로 작성한다. 알림용 다국어 map 생성 원칙: - titleI18n, items[].titleI18n, checklistItems[].contentI18n은 반드시 @@ -253,7 +256,10 @@ def _build_user_prompt(request: NewsletterAnalysisRequest) -> str: [ "", "", - "아래 translated_text는 기계 번역 초안이며 최종 문구가 아닙니다.", + "아래 translated_text는 기계 번역 초안입니다. title/summary/items[].title/", + "checklistItems/conversationTopics(한국어 고정 필드)에는 사용하지 않는다.", + "titleI18n/checklistItems[].contentI18n(알림용 다국어 map) 생성 시에만", + "참고자료로 활용한다.", translated_text, "", ] @@ -282,3 +288,86 @@ def _format_candidates(request: NewsletterAnalysisRequest) -> str: f"extractionType: {candidate.extraction_type or 'null'}" ) return "\n".join(lines) + + +REFINE_FIELD_SCHEMA = { + "type": "string", + "minLength": 1, +} + +REFINE_RESPONSE_SCHEMA = { + "type": "object", + "additionalProperties": False, + "required": ["fields"], + "properties": { + "fields": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "required": ["id", "text"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "text": REFINE_FIELD_SCHEMA, + }, + }, + }, + }, +} + + +def build_refine_prompt_messages( + original_text: str, + language: str, + fields: list[dict[str, str]], +) -> list[dict[str, str]]: + + return [ + {"role": "system", "content": _build_refine_system_prompt(language)}, + {"role": "user", "content": _build_refine_user_prompt(original_text, fields)}, + ] + + +def _build_refine_system_prompt(language: str) -> str: + language_name = _language_name(language) + return f""" +역할: 가정통신문 원문(한국어)과, 그 일부 문구를 한국어 → {language_name}로 +기계번역(파파고)한 결과 목록을 받아서 교정한다. + +원칙: +- response schema에 맞는 JSON만 반환한다 (fields[] 배열). +- fields[]의 각 원소는 입력으로 받은 fields와 동일한 id를 가져야 하며, + 누락되거나 새로운 id를 추가하지 않는다. 입력 순서와 개수를 그대로 유지한다. +- 입력으로 받은 translatedText(파파고 번역 결과)를 기본 베이스로 삼는다. +- translatedText가 자연스럽고 원문(originalText, koText)의 의미와 일치하면 + 그대로 text에 반환한다 (불필요한 재작성 금지). +- translatedText가 어색하거나, 원문 의미와 다르거나, 숫자/날짜/금액 등 핵심 + 정보가 누락·왜곡된 경우에만 {language_name}로 자연스럽게 다듬어서 반환한다. +- 새로운 정보를 추가하거나 임의로 의역을 확장하지 않는다. 어디까지나 "교정"이지 + "재작성"이 아니다. +- text는 항상 {language_name}로 작성한다 (translatedText의 언어를 유지). +- 학교명, 기관명, 행사명, 고유명사도 모두 {language_name}로 번역한다. + 원문 한국어 표기를 그대로 남기지 않는다 (예: "서울노원초등학교"를 한국어 + 그대로 두지 않고 {language_name} 표기/음역으로 변환한다). +""".strip() + + +def _build_refine_user_prompt(original_text: str, fields: list[dict[str, str]]) -> str: + field_lines = [] + for field in fields: + field_lines.append( + f"- id: {field['id']}\n" + f" koText: {field['koText']}\n" + f" translatedText: {field['translatedText']}" + ) + + sections = [ + "", + original_text.strip(), + "", + "", + "", + "\n".join(field_lines), + "", + ] + return "\n".join(sections) diff --git a/app/services/openai_adapter.py b/app/services/openai_adapter.py index d0d8009..c1d062b 100644 --- a/app/services/openai_adapter.py +++ b/app/services/openai_adapter.py @@ -7,8 +7,18 @@ from pydantic import ValidationError from app.config import OpenAISettings -from app.schemas import NewsletterAnalysisRequest, NewsletterAnalysisResponse -from app.services.newsletter_prompt import ANALYSIS_RESPONSE_SCHEMA, build_prompt_messages +from app.schemas import ( + NewsletterAnalysisRequest, + NewsletterAnalysisResponse, + TranslationRefineRequest, + TranslationRefineResponse, +) +from app.services.newsletter_prompt import ( + ANALYSIS_RESPONSE_SCHEMA, + REFINE_RESPONSE_SCHEMA, + build_prompt_messages, + build_refine_prompt_messages, +) logger = logging.getLogger(__name__) @@ -50,6 +60,45 @@ def analyze(self, request: NewsletterAnalysisRequest) -> NewsletterAnalysisRespo logger.warning("[OpenAIAdapter] 응답 스키마 검증 실패. error=%s", exc) raise OpenAIAdapterError("OpenAI 응답이 분석 스키마와 일치하지 않습니다.") from exc + def refine_translation(self, request: TranslationRefineRequest) -> TranslationRefineResponse: + if not self.settings.api_key: + raise OpenAIConfigurationError("OPENAI_API_KEY가 설정되어 있지 않습니다.") + + if not request.fields: + return TranslationRefineResponse(fields=[]) + + payload = { + "model": self.settings.model, + "input": build_refine_prompt_messages( + request.original_text, + request.language, + [ + { + "id": field.id, + "koText": field.ko_text, + "translatedText": field.translated_text, + } + for field in request.fields + ], + ), + "text": { + "format": { + "type": "json_schema", + "name": "translation_refine", + "schema": REFINE_RESPONSE_SCHEMA, + "strict": False, + } + }, + } + + response_body = self._post_json("/responses", payload) + parsed = self._extract_output_json(response_body) + try: + return TranslationRefineResponse.model_validate(parsed) + except ValidationError as exc: + logger.warning("[OpenAIAdapter] 2차 검증 응답 스키마 검증 실패. error=%s", exc) + raise OpenAIAdapterError("OpenAI 응답이 검증 스키마와 일치하지 않습니다.") from exc + def _post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: url = self.settings.base_url.rstrip("/") + path body = json.dumps(payload, ensure_ascii=False).encode("utf-8") diff --git a/tests/test_newsletter_multilingual.py b/tests/test_newsletter_multilingual.py index 1fc63d5..c42a731 100644 --- a/tests/test_newsletter_multilingual.py +++ b/tests/test_newsletter_multilingual.py @@ -21,14 +21,23 @@ def test_prompt_requires_final_user_text_in_target_language(self): system_prompt = messages[0]["content"] user_prompt = messages[1]["content"] - self.assertIn("최종 사용자 노출 문구는 반드시 미국 영어로 작성", system_prompt) - self.assertIn("체크리스트 문구는 BE에서 다시 번역하지 않고 바로 저장/표시", system_prompt) - self.assertIn("topic도 최종 사용자 노출 문구이므로 반드시 미국 영어로 작성", system_prompt) - self.assertIn("translated_text가 있으면 초벌 번역/참고자료로만 사용", system_prompt) - self.assertIn("targetLanguageName: 미국 영어", user_prompt) + notification_us_phrase = ( + "사용자의 현재 언어(미국 영어)와\n 무관하게 알림(notification)에서 사용된다" + ) + self.assertIn(notification_us_phrase, system_prompt) self.assertIn( - "아래 translated_text는 기계 번역 초안이며 최종 문구가 아닙니다.", user_prompt + "conversationTopics[].topic은 사용자 언어(미국 영어)와 무관하게 항상 한국어로 작성한다", + system_prompt, ) + self.assertIn("체크리스트 문구는 BE에서 다시 번역하지 않고 바로 저장/표시", system_prompt) + self.assertIn("topic은 항상 한국어로 작성한다", system_prompt) + translated_text_skip_phrase = ( + "translated_text는 참고하지 않는다. " + "(단일 필드는 항상 한국어로 작성하므로 번역 초안이 필요 없다.)" + ) + self.assertIn(translated_text_skip_phrase, system_prompt) + self.assertIn("targetLanguageName: 미국 영어", user_prompt) + self.assertIn("아래 translated_text는 기계 번역 초안입니다.", user_prompt) self.assertIn("알림용 다국어 map 생성 원칙", system_prompt) self.assertIn( "conversationTopics는 알림에 쓰지 않으므로 다국어 map을 만들지 않는다", system_prompt @@ -55,7 +64,10 @@ def test_prompt_falls_back_to_korean_for_unknown_language(self): messages = build_prompt_messages(request) - self.assertIn("최종 사용자 노출 문구는 반드시 한국어로 작성", messages[0]["content"]) + notification_i18n_phrase = ( + "사용자의 현재 언어(한국어)와\n 무관하게 알림(notification)에서 사용된다" + ) + self.assertIn(notification_i18n_phrase, messages[0]["content"]) self.assertIn("targetLanguageName: 한국어", messages[1]["content"])