From 20e25e646f449e2809bcc8e849227c4f8b439225 Mon Sep 17 00:00:00 2001 From: minkyung Date: Fri, 12 Jun 2026 02:54:02 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=AC=ED=94=84?= =?UTF-8?q?=ED=8A=B8=20=EC=99=84=EC=A0=84=ED=9E=88=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/newsletter_prompt.py | 116 ++++++++++++++++++++++++++---- 1 file changed, 104 insertions(+), 12 deletions(-) diff --git a/app/services/newsletter_prompt.py b/app/services/newsletter_prompt.py index 285aec3..7ce2b1f 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,89 @@ 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]]: + """파파고로 1차 번역된 title/summary/checklist/topic 등을 교정하기 위한 프롬프트. + + fields: [{"id": "...", "koText": "...", "translatedText": "..."}, ...] + """ + 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.get('koText', '')}\n" + f" translatedText: {field.get('translatedText', '')}" + ) + + sections = [ + "", + original_text.strip(), + "", + "", + "", + "\n".join(field_lines), + "", + ] + return "\n".join(sections) + From 936bc6c6b1159338eb40ecfefa6b3da72a5c1f3a Mon Sep 17 00:00:00 2001 From: minkyung Date: Fri, 12 Jun 2026 02:55:10 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20=EC=8A=A4=ED=82=A4=EB=A7=88=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/app/schemas.py b/app/schemas.py index 70699e0..6ea83e5 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(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 + + +class TranslationRefineResponse(BaseModel): + fields: list[RefineFieldOutput] = Field(default_factory=list) From 096ca68da113c198ce4b0f9f6e5a27071b7d22af Mon Sep 17 00:00:00 2001 From: minkyung Date: Fri, 12 Jun 2026 02:56:36 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20adapter=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/openai_adapter.py | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/app/services/openai_adapter.py b/app/services/openai_adapter.py index d0d8009..6075bfa 100644 --- a/app/services/openai_adapter.py +++ b/app/services/openai_adapter.py @@ -50,6 +50,47 @@ 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") From d8451f3522a66033b7830088d19cc4f8a236b8dc Mon Sep 17 00:00:00 2001 From: minkyung Date: Fri, 12 Jun 2026 02:58:11 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat:=202=EC=B0=A8=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/newsletter_extractor.py | 46 ++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/app/services/newsletter_extractor.py b/app/services/newsletter_extractor.py index c2dd821..5bb0217 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,46 @@ 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) From ab3d4baf75f203ad2edfb89d92bc212efc09e220 Mon Sep 17 00:00:00 2001 From: minkyung Date: Fri, 12 Jun 2026 03:00:22 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat:=20=ED=8C=8C=ED=8C=8C=EA=B3=A0=201?= =?UTF-8?q?=EC=B0=A8=20=EB=B2=88=EC=97=AD=20=EA=B2=B0=EA=B3=BC=20=EA=B5=90?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/routers/newsletters.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/app/routers/newsletters.py b/app/routers/newsletters.py index d3f0b9a..9ee1414 100644 --- a/app/routers/newsletters.py +++ b/app/routers/newsletters.py @@ -6,8 +6,10 @@ NewsletterExtractionRequest, NewsletterExtractionResponse, PromptPreviewResponse, + TranslationRefineRequest, + TranslationRefineResponse, ) -from app.services.newsletter_extractor import analyze_newsletter, extract_newsletter_items +from app.services.newsletter_extractor import (analyze_newsletter, extract_newsletter_items, refine_translation,) from app.services.newsletter_prompt import ANALYSIS_RESPONSE_SCHEMA, build_prompt_messages from app.services.openai_adapter import OpenAIAdapterError, OpenAIConfigurationError @@ -39,3 +41,18 @@ 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 From a31882a45f218beae8c3e68f4e42dfbf7f847a2e Mon Sep 17 00:00:00 2001 From: minkyung Date: Fri, 12 Jun 2026 03:02:31 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat:=20=EC=82=AD=EC=A0=9C=EB=90=9C=20imp?= =?UTF-8?q?ort=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/routers/newsletters.py | 6 +++++- app/services/openai_adapter.py | 15 ++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/app/routers/newsletters.py b/app/routers/newsletters.py index 9ee1414..d32d1c6 100644 --- a/app/routers/newsletters.py +++ b/app/routers/newsletters.py @@ -9,7 +9,11 @@ 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, + refine_translation, +) from app.services.newsletter_prompt import ANALYSIS_RESPONSE_SCHEMA, build_prompt_messages from app.services.openai_adapter import OpenAIAdapterError, OpenAIConfigurationError diff --git a/app/services/openai_adapter.py b/app/services/openai_adapter.py index 6075bfa..78b4e11 100644 --- a/app/services/openai_adapter.py +++ b/app/services/openai_adapter.py @@ -7,9 +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__) From 7493354cc4a051cfa35aec6bcd4969653e316ab1 Mon Sep 17 00:00:00 2001 From: minkyung Date: Fri, 12 Jun 2026 03:02:56 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20format=20=ED=98=95=EC=8B=9D?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/routers/newsletters.py | 1 + app/schemas.py | 2 +- app/services/newsletter_extractor.py | 5 ++++- app/services/newsletter_prompt.py | 2 +- app/services/openai_adapter.py | 5 ++--- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/routers/newsletters.py b/app/routers/newsletters.py index d32d1c6..4669f06 100644 --- a/app/routers/newsletters.py +++ b/app/routers/newsletters.py @@ -46,6 +46,7 @@ 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: diff --git a/app/schemas.py b/app/schemas.py index 6ea83e5..924eb59 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -147,8 +147,8 @@ class ChatRequest(BaseModel): class ChatResponse(BaseModel): reply: str -class RefineFieldInput(BaseModel): +class RefineFieldInput(BaseModel): model_config = ConfigDict(populate_by_name=True) id: str diff --git a/app/services/newsletter_extractor.py b/app/services/newsletter_extractor.py index 5bb0217..85483ca 100644 --- a/app/services/newsletter_extractor.py +++ b/app/services/newsletter_extractor.py @@ -336,6 +336,7 @@ 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() @@ -343,7 +344,9 @@ def refine_translation(request: TranslationRefineRequest) -> TranslationRefineRe return TranslationRefineResponse(fields=[]) if not settings.enabled: - logger.info("[TranslationRefine] OpenAI 비활성화. 파파고 1차 번역 결과를 그대로 사용합니다.") + logger.info( + "[TranslationRefine] OpenAI 비활성화. 파파고 1차 번역 결과를 그대로 사용합니다." + ) return TranslationRefineResponse( fields=[ RefineFieldOutput(id=field.id, text=field.translated_text) diff --git a/app/services/newsletter_prompt.py b/app/services/newsletter_prompt.py index 7ce2b1f..c16c891 100644 --- a/app/services/newsletter_prompt.py +++ b/app/services/newsletter_prompt.py @@ -289,6 +289,7 @@ def _format_candidates(request: NewsletterAnalysisRequest) -> str: ) return "\n".join(lines) + REFINE_FIELD_SCHEMA = { "type": "string", "minLength": 1, @@ -373,4 +374,3 @@ def _build_refine_user_prompt(original_text: str, fields: list[dict[str, str]]) "", ] return "\n".join(sections) - diff --git a/app/services/openai_adapter.py b/app/services/openai_adapter.py index 78b4e11..c1d062b 100644 --- a/app/services/openai_adapter.py +++ b/app/services/openai_adapter.py @@ -19,6 +19,7 @@ build_prompt_messages, build_refine_prompt_messages, ) + logger = logging.getLogger(__name__) @@ -59,9 +60,7 @@ def analyze(self, request: NewsletterAnalysisRequest) -> NewsletterAnalysisRespo logger.warning("[OpenAIAdapter] 응답 스키마 검증 실패. error=%s", exc) raise OpenAIAdapterError("OpenAI 응답이 분석 스키마와 일치하지 않습니다.") from exc - def refine_translation( - self, request: TranslationRefineRequest - ) -> TranslationRefineResponse: + def refine_translation(self, request: TranslationRefineRequest) -> TranslationRefineResponse: if not self.settings.api_key: raise OpenAIConfigurationError("OPENAI_API_KEY가 설정되어 있지 않습니다.") From 63ffdad8469e95a376ff0c1d56be7067519aff95 Mon Sep 17 00:00:00 2001 From: minkyung Date: Fri, 12 Jun 2026 03:11:03 +0900 Subject: [PATCH 08/12] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_newsletter_multilingual.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/test_newsletter_multilingual.py b/tests/test_newsletter_multilingual.py index 1fc63d5..4ead07b 100644 --- a/tests/test_newsletter_multilingual.py +++ b/tests/test_newsletter_multilingual.py @@ -21,13 +21,24 @@ 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) + notification_us_phrase = ( + "사용자의 현재 언어(미국 영어)와\n 무관하게 알림(notification)에서 사용된다" + ) + self.assertIn(notification_us_phrase, system_prompt) + self.assertIn( + "conversationTopics[].topic은 사용자 언어(미국 영어)와 무관하게 항상 한국어로 작성한다", + system_prompt, + ) self.assertIn("체크리스트 문구는 BE에서 다시 번역하지 않고 바로 저장/표시", system_prompt) - self.assertIn("topic도 최종 사용자 노출 문구이므로 반드시 미국 영어로 작성", system_prompt) - self.assertIn("translated_text가 있으면 초벌 번역/참고자료로만 사용", 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 + "아래 translated_text는 기계 번역 초안입니다.", user_prompt ) self.assertIn("알림용 다국어 map 생성 원칙", system_prompt) self.assertIn( From 58bef03cab4da6026035bd46394a03c9dffb6bb8 Mon Sep 17 00:00:00 2001 From: minkyung Date: Fri, 12 Jun 2026 03:11:23 +0900 Subject: [PATCH 09/12] =?UTF-8?q?feat:=20format=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_newsletter_multilingual.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_newsletter_multilingual.py b/tests/test_newsletter_multilingual.py index 4ead07b..8c22fc7 100644 --- a/tests/test_newsletter_multilingual.py +++ b/tests/test_newsletter_multilingual.py @@ -37,9 +37,7 @@ def test_prompt_requires_final_user_text_in_target_language(self): ) self.assertIn(translated_text_skip_phrase, system_prompt) self.assertIn("targetLanguageName: 미국 영어", user_prompt) - self.assertIn( - "아래 translated_text는 기계 번역 초안입니다.", user_prompt - ) + self.assertIn("아래 translated_text는 기계 번역 초안입니다.", user_prompt) self.assertIn("알림용 다국어 map 생성 원칙", system_prompt) self.assertIn( "conversationTopics는 알림에 쓰지 않으므로 다국어 map을 만들지 않는다", system_prompt From 4660dc98e8dd1f2489c35d1dcfab0150d5d9180f Mon Sep 17 00:00:00 2001 From: minkyung Date: Fri, 12 Jun 2026 03:18:17 +0900 Subject: [PATCH 10/12] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_newsletter_multilingual.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_newsletter_multilingual.py b/tests/test_newsletter_multilingual.py index 8c22fc7..1da7458 100644 --- a/tests/test_newsletter_multilingual.py +++ b/tests/test_newsletter_multilingual.py @@ -37,7 +37,9 @@ def test_prompt_requires_final_user_text_in_target_language(self): ) self.assertIn(translated_text_skip_phrase, system_prompt) self.assertIn("targetLanguageName: 미국 영어", user_prompt) - self.assertIn("아래 translated_text는 기계 번역 초안입니다.", user_prompt) + self.assertIn( + "아래 translated_text는 기계 번역 초안입니다.", user_prompt + ) self.assertIn("알림용 다국어 map 생성 원칙", system_prompt) self.assertIn( "conversationTopics는 알림에 쓰지 않으므로 다국어 map을 만들지 않는다", system_prompt @@ -64,7 +66,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"]) From 045c1a4149f2622f2f2c46579924dff89f24b0e7 Mon Sep 17 00:00:00 2001 From: minkyung Date: Fri, 12 Jun 2026 03:18:35 +0900 Subject: [PATCH 11/12] =?UTF-8?q?feat:=20format=20=ED=98=95=EC=8B=9D=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_newsletter_multilingual.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_newsletter_multilingual.py b/tests/test_newsletter_multilingual.py index 1da7458..c42a731 100644 --- a/tests/test_newsletter_multilingual.py +++ b/tests/test_newsletter_multilingual.py @@ -37,9 +37,7 @@ def test_prompt_requires_final_user_text_in_target_language(self): ) self.assertIn(translated_text_skip_phrase, system_prompt) self.assertIn("targetLanguageName: 미국 영어", user_prompt) - self.assertIn( - "아래 translated_text는 기계 번역 초안입니다.", user_prompt - ) + self.assertIn("아래 translated_text는 기계 번역 초안입니다.", user_prompt) self.assertIn("알림용 다국어 map 생성 원칙", system_prompt) self.assertIn( "conversationTopics는 알림에 쓰지 않으므로 다국어 map을 만들지 않는다", system_prompt From 9bd7a58becea7d9f94dbffe3bc7bb7d3c8533871 Mon Sep 17 00:00:00 2001 From: minkyung Date: Fri, 12 Jun 2026 03:22:07 +0900 Subject: [PATCH 12/12] =?UTF-8?q?feat:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas.py | 4 ++-- app/services/newsletter_prompt.py | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/app/schemas.py b/app/schemas.py index 924eb59..71b3d34 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -153,7 +153,7 @@ class RefineFieldInput(BaseModel): id: str ko_text: str = Field(alias="koText") - translated_text: str = Field(alias="translatedText") + translated_text: str = Field(min_length=1, alias="translatedText") class TranslationRefineRequest(BaseModel): @@ -166,7 +166,7 @@ class TranslationRefineRequest(BaseModel): class RefineFieldOutput(BaseModel): id: str - text: str + text: str = Field(min_length=1) class TranslationRefineResponse(BaseModel): diff --git a/app/services/newsletter_prompt.py b/app/services/newsletter_prompt.py index c16c891..151258a 100644 --- a/app/services/newsletter_prompt.py +++ b/app/services/newsletter_prompt.py @@ -321,10 +321,7 @@ def build_refine_prompt_messages( language: str, fields: list[dict[str, str]], ) -> list[dict[str, str]]: - """파파고로 1차 번역된 title/summary/checklist/topic 등을 교정하기 위한 프롬프트. - fields: [{"id": "...", "koText": "...", "translatedText": "..."}, ...] - """ return [ {"role": "system", "content": _build_refine_system_prompt(language)}, {"role": "user", "content": _build_refine_user_prompt(original_text, fields)}, @@ -360,8 +357,8 @@ def _build_refine_user_prompt(original_text: str, fields: list[dict[str, str]]) for field in fields: field_lines.append( f"- id: {field['id']}\n" - f" koText: {field.get('koText', '')}\n" - f" translatedText: {field.get('translatedText', '')}" + f" koText: {field['koText']}\n" + f" translatedText: {field['translatedText']}" ) sections = [