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"])