From 84ea06419b17181cf88c7ca5198727880f48080b Mon Sep 17 00:00:00 2001 From: minju Date: Wed, 20 May 2026 01:25:55 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20=EA=B0=80=EC=A0=95=ED=86=B5?= =?UTF-8?q?=EC=8B=A0=EB=AC=B8=20=EB=B6=84=EC=84=9D=20API=20=EC=8A=A4?= =?UTF-8?q?=ED=8E=99=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 ++-- app/routers/newsletters.py | 13 ++- app/schemas.py | 13 ++- app/services/newsletter_extractor.py | 71 ++++++++++++-- app/services/newsletter_prompt.py | 138 +++++++++++++++++---------- docs/newsletter-extraction.md | 133 ++++++++++++++++++++++---- 6 files changed, 292 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index ee0601a..cc9cb60 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # GACHI-AI -GACHI 프로젝트의 AI 서버입니다. 백엔드와 분리된 FastAPI 애플리케이션으로 운영하며, 기존 EC2 `docker-compose`의 `ai` 서비스로 배포합니다. +GACHI 프로젝트의 AI 서버입니다. BE와 분리된 FastAPI 애플리케이션으로 운영하며, 기존 EC2 `docker-compose`의 `ai` 서비스로 배포합니다. ## 역할 -- 가정통신문 원문과 날짜 후보를 기반으로 일정, 마감, 체크리스트, 알림 항목을 추출합니다. -- OpenAI API 호출 전에도 검증할 수 있도록 비용 없는 rule-based baseline을 제공합니다. -- 실제 LLM에 전달할 prompt-preview API를 제공합니다. +- 가정통신문 원문과 날짜 후보를 기반으로 제목, 요약, 일정/마감/체크리스트 항목을 분석합니다. +- AI 서버는 분석 결과 JSON만 반환하고, DB 저장은 BE가 담당합니다. +- OpenAI API 연결 전에도 검증할 수 있도록 비용 없는 rule-based baseline과 prompt-preview API를 제공합니다. ## 로컬 실행 @@ -27,19 +27,20 @@ Windows PowerShell에서는 다음처럼 가상환경을 활성화합니다. - `GET /ai/health`: 헬스체크 - `GET /ai/docs`: Swagger UI -- `POST /ai/newsletters/extract-items`: 날짜 후보 기반 baseline 추출 -- `POST /ai/newsletters/prompt-preview`: LLM 입력용 prompt와 response schema 미리보기 +- `POST /ai/newsletters/analyze`: 제목, 요약, 항목을 함께 반환하는 가정통신문 전체 분석 +- `POST /ai/newsletters/extract-items`: 날짜 후보 기반 baseline 항목 추출 +- `POST /ai/newsletters/prompt-preview`: LLM 입력용 prompt와 최종 분석 response schema 미리보기 ## 작업 규칙 - 기본 브랜치: `develop` - 브랜치 예시: `feat/#1-feature-name`, `chore/#1-ci-cd-setup` - 커밋 타입: `feat`, `fix`, `refactor`, `docs`, `style`, `chore` -- `main`, `develop` 직접 커밋은 피하고 PR로 병합합니다. +- `main`, `develop` 직접 커밋은 지양하고 PR로 병합합니다. ## 문서 - `docs/env.md`: 환경변수 - `docs/deploy.md`: Docker image와 EC2 배포 방식 -- `docs/newsletter-extraction.md`: 가정통신문 추출 API와 프롬프트 흐름 +- `docs/newsletter-extraction.md`: 가정통신문 분석 API 스펙과 프롬프트 흐름 - `docs/newsletter-labeling-guide.md`: 정답 데이터 라벨링 기준 diff --git a/app/routers/newsletters.py b/app/routers/newsletters.py index 6c34554..39cb56b 100644 --- a/app/routers/newsletters.py +++ b/app/routers/newsletters.py @@ -1,16 +1,23 @@ from fastapi import APIRouter from app.schemas import ( + NewsletterAnalysisRequest, + NewsletterAnalysisResponse, NewsletterExtractionRequest, NewsletterExtractionResponse, PromptPreviewResponse, ) -from app.services.newsletter_extractor import extract_newsletter_items -from app.services.newsletter_prompt import EXTRACTION_RESPONSE_SCHEMA, build_prompt_messages +from app.services.newsletter_extractor import analyze_newsletter, extract_newsletter_items +from app.services.newsletter_prompt import ANALYSIS_RESPONSE_SCHEMA, build_prompt_messages router = APIRouter(prefix="/ai/newsletters", tags=["newsletters"]) +@router.post("/analyze", response_model=NewsletterAnalysisResponse) +def analyze(req: NewsletterAnalysisRequest) -> NewsletterAnalysisResponse: + return analyze_newsletter(req) + + @router.post("/extract-items", response_model=NewsletterExtractionResponse) def extract_items(req: NewsletterExtractionRequest) -> NewsletterExtractionResponse: return extract_newsletter_items(req) @@ -19,4 +26,4 @@ def extract_items(req: NewsletterExtractionRequest) -> NewsletterExtractionRespo @router.post("/prompt-preview", response_model=PromptPreviewResponse) def prompt_preview(req: NewsletterExtractionRequest) -> PromptPreviewResponse: messages = build_prompt_messages(req) - return PromptPreviewResponse(messages=messages, responseSchema=EXTRACTION_RESPONSE_SCHEMA) + return PromptPreviewResponse(messages=messages, responseSchema=ANALYSIS_RESPONSE_SCHEMA) diff --git a/app/schemas.py b/app/schemas.py index 867c90c..4a341f9 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -35,7 +35,7 @@ def validate_offsets(self) -> "DateCandidate": return self -class NewsletterExtractionRequest(BaseModel): +class NewsletterAnalysisRequest(BaseModel): model_config = ConfigDict(populate_by_name=True) original_text: str = Field(alias="originalText") @@ -46,6 +46,10 @@ class NewsletterExtractionRequest(BaseModel): date_candidates: list[DateCandidate] = Field(default_factory=list, alias="dateCandidates") +class NewsletterExtractionRequest(NewsletterAnalysisRequest): + pass + + class SelectedDateCandidate(BaseModel): model_config = ConfigDict(populate_by_name=True) @@ -77,6 +81,13 @@ class NewsletterExtractionResponse(BaseModel): meta: dict[str, Any] = Field(default_factory=dict) +class NewsletterAnalysisResponse(BaseModel): + title: str + summary: str + items: list[ExtractedItem] + meta: dict[str, Any] = Field(default_factory=dict) + + class PromptMessage(BaseModel): role: str content: str diff --git a/app/services/newsletter_extractor.py b/app/services/newsletter_extractor.py index 7899eb4..7f71bd0 100644 --- a/app/services/newsletter_extractor.py +++ b/app/services/newsletter_extractor.py @@ -6,6 +6,8 @@ DateStatus, ExtractedItem, ExtractedItemType, + NewsletterAnalysisRequest, + NewsletterAnalysisResponse, NewsletterExtractionRequest, NewsletterExtractionResponse, SelectedDateCandidate, @@ -50,22 +52,47 @@ def extract_newsletter_items( request: NewsletterExtractionRequest, ) -> NewsletterExtractionResponse: + items = _extract_items(request) + return NewsletterExtractionResponse( + items=items, + meta=_build_meta(request), + ) + + +def analyze_newsletter( + request: NewsletterAnalysisRequest, +) -> NewsletterAnalysisResponse: + items = _extract_items(request) + return NewsletterAnalysisResponse( + title=_extract_document_title(request), + summary=_summarize_document(request, items), + items=items, + meta=_build_meta(request), + ) + + +def _extract_items( + request: NewsletterAnalysisRequest, +) -> list[ExtractedItem]: text = request.original_text or "" items = _extract_candidate_backed_items(text, request) items.extend(_extract_missing_date_checklists(text, request)) - return NewsletterExtractionResponse( - items=_dedupe_items(items), - meta={ - "mode": "rule_based_baseline", - "dateCandidateCount": len(request.date_candidates), - "requiresLLMReview": True, - }, - ) + return _dedupe_items(items) + + +def _build_meta(request: NewsletterAnalysisRequest) -> dict[str, object]: + return { + "mode": "rule_based_baseline", + "dateCandidateCount": len(request.date_candidates), + "requiresLLMReview": True, + "retainedDateCandidateInput": True, + "retainedItemResponse": True, + } def _extract_candidate_backed_items( text: str, - request: NewsletterExtractionRequest, + request: NewsletterAnalysisRequest, ) -> list[ExtractedItem]: items = [] for index, candidate in enumerate(request.date_candidates): @@ -96,7 +123,7 @@ def _extract_candidate_backed_items( def _extract_missing_date_checklists( text: str, - request: NewsletterExtractionRequest, + request: NewsletterAnalysisRequest, ) -> list[ExtractedItem]: items = [] for sentence in _split_sentences(text): @@ -193,3 +220,27 @@ def _dedupe_items(items: list[ExtractedItem]) -> list[ExtractedItem]: seen.add(key) result.append(item) return result + + +def _extract_document_title(request: NewsletterAnalysisRequest) -> str: + for line in request.original_text.splitlines(): + title = re.sub(r"\s+", " ", line).strip(" -:\t") + if title: + return title[:80].rstrip() + return "가정통신문" + + +def _summarize_document( + request: NewsletterAnalysisRequest, + items: list[ExtractedItem], +) -> str: + text = request.translated_text or request.original_text + sentences = list(_split_sentences(text)) + if sentences: + summary = " ".join(sentences[:2]) + if len(summary) > 180: + return summary[:179].rstrip() + "..." + return summary + if items: + return f"추출된 주요 항목 {len(items)}건을 확인해야 합니다." + return "분석할 본문 내용이 충분하지 않습니다." diff --git a/app/services/newsletter_prompt.py b/app/services/newsletter_prompt.py index f35be02..b6a4917 100644 --- a/app/services/newsletter_prompt.py +++ b/app/services/newsletter_prompt.py @@ -1,52 +1,84 @@ -from app.schemas import NewsletterExtractionRequest +from app.schemas import NewsletterAnalysisRequest + +SELECTED_DATE_CANDIDATE_SCHEMA = { + "type": ["object", "null"], + "additionalProperties": False, + "required": ["index", "candidateId", "originalText", "normalizedDate"], + "properties": { + "index": {"type": "integer", "minimum": 0}, + "candidateId": {"type": ["string", "null"]}, + "originalText": {"type": "string"}, + "normalizedDate": {"type": "string", "format": "date"}, + }, +} + +ITEM_RESPONSE_SCHEMA = { + "type": "object", + "additionalProperties": False, + "required": [ + "type", + "title", + "selectedDateCandidate", + "dateStatus", + "datetime", + "timezone", + "evidenceText", + "confidence", + "needsUserConfirmation", + "confirmationQuestion", + ], + "properties": { + "type": { + "type": "string", + "enum": ["schedule", "deadline", "checklist", "reminder"], + }, + "title": {"type": "string"}, + "selectedDateCandidate": SELECTED_DATE_CANDIDATE_SCHEMA, + "dateStatus": { + "type": "string", + "enum": ["confirmed", "ambiguous", "missing"], + }, + "datetime": {"type": ["string", "null"]}, + "timezone": {"type": "string"}, + "evidenceText": {"type": "string"}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "needsUserConfirmation": {"type": "boolean"}, + "confirmationQuestion": {"type": ["string", "null"]}, + }, +} EXTRACTION_RESPONSE_SCHEMA = { "type": "object", "additionalProperties": False, "required": ["items"], "properties": { - "items": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": False, - "required": [ - "type", - "title", - "selectedDateCandidate", - "dateStatus", - "datetime", - "timezone", - "evidenceText", - "confidence", - "needsUserConfirmation", - "confirmationQuestion", - ], - "properties": { - "type": { - "type": "string", - "enum": ["schedule", "deadline", "checklist", "reminder"], - }, - "title": {"type": "string"}, - "selectedDateCandidate": {"type": ["object", "null"]}, - "dateStatus": { - "type": "string", - "enum": ["confirmed", "ambiguous", "missing"], - }, - "datetime": {"type": ["string", "null"]}, - "timezone": {"type": "string"}, - "evidenceText": {"type": "string"}, - "confidence": {"type": "number", "minimum": 0, "maximum": 1}, - "needsUserConfirmation": {"type": "boolean"}, - "confirmationQuestion": {"type": ["string", "null"]}, - }, + "items": {"type": "array", "items": ITEM_RESPONSE_SCHEMA}, + "meta": {"type": "object"}, + }, +} + +ANALYSIS_RESPONSE_SCHEMA = { + "type": "object", + "additionalProperties": False, + "required": ["title", "summary", "items", "meta"], + "properties": { + "title": {"type": "string"}, + "summary": {"type": "string"}, + "items": {"type": "array", "items": ITEM_RESPONSE_SCHEMA}, + "meta": { + "type": "object", + "additionalProperties": True, + "properties": { + "mode": {"type": "string"}, + "dateCandidateCount": {"type": "integer", "minimum": 0}, + "requiresLLMReview": {"type": "boolean"}, }, - } + }, }, } -def build_prompt_messages(request: NewsletterExtractionRequest) -> list[dict[str, str]]: +def build_prompt_messages(request: NewsletterAnalysisRequest) -> list[dict[str, str]]: return [ {"role": "system", "content": _build_system_prompt()}, {"role": "user", "content": _build_user_prompt(request)}, @@ -55,25 +87,30 @@ def build_prompt_messages(request: NewsletterExtractionRequest) -> list[dict[str def _build_system_prompt() -> str: return """ -역할: 학교 가정통신문에서 캘린더, 알림, 체크리스트로 만들 항목을 추출한다. +역할: 학교 가정통신문 원문을 분석해서 저장 가능한 제목, 요약, +주요 일정/마감/체크리스트 항목을 JSON으로 반환한다. -핵심 규칙: -- 구체적인 날짜는 반드시 제공된 date candidates 중 하나만 선택할 것. -- date candidates에 없는 날짜를 새로 만들거나 추론하지 말 것. -- 날짜 근거가 명확할 때만 dateStatus를 "confirmed"로 설정할 것. -- 날짜 후보가 없거나 근거가 약하면 "ambiguous" 또는 "missing"을 사용할 것. -- evidenceText는 원문에서 짧게 가져올 것. -- response schema에 맞는 JSON만 반환할 것. +응답 원칙: +- response schema에 맞는 JSON만 반환한다. +- AI 서버는 DB 저장을 직접 알지 않는다. 저장 판단은 BE가 하며, AI 서버는 분석 결과만 반환한다. +- title은 문서 제목으로 사용할 수 있는 짧은 문자열로 작성한다. +- summary는 보호자나 학생이 빠르게 확인할 수 있는 1~2문장으로 작성한다. +- items의 구조는 /ai/newsletters/extract-items 응답 형식을 유지한다. +- 구체적인 날짜는 제공된 dateCandidates 중 하나만 선택한다. +- dateCandidates에 없는 날짜를 새로 만들거나 추론해서 confirmed로 반환하지 않는다. +- 날짜 근거가 명확할 때만 dateStatus를 confirmed로 설정한다. +- 날짜 정보가 없거나 근거가 약하면 ambiguous 또는 missing을 사용한다. +- evidenceText는 원문에서 직접 가져온 근거 문장이나 구절로 작성한다. 항목 분류 기준: - deadline: 제출, 신청, 납부, 등록, 동의, 회신, 마감 행동 - schedule: 행사, 수업, 상담, 체험학습, 설명회, 운영일 -- checklist: 준비물, 지참물, 확인 문서, 보호자나 학생이 해야 할 행동 +- checklist: 준비물, 지참물, 확인 문서, 보호자나 학생이 해야 하는 행동 - reminder: deadline이나 schedule은 아니지만 알림으로 보여줄 가치가 있는 항목 """.strip() -def _build_user_prompt(request: NewsletterExtractionRequest) -> str: +def _build_user_prompt(request: NewsletterAnalysisRequest) -> str: translated_text = request.translated_text.strip() if request.translated_text else "" reference_date = request.reference_date.isoformat() if request.reference_date else "null" sections = [ @@ -94,7 +131,7 @@ def _build_user_prompt(request: NewsletterExtractionRequest) -> str: return "\n".join(sections) -def _format_candidates(request: NewsletterExtractionRequest) -> str: +def _format_candidates(request: NewsletterAnalysisRequest) -> str: if not request.date_candidates: return "[]" @@ -106,6 +143,7 @@ def _format_candidates(request: NewsletterExtractionRequest) -> str: f"originalText: {candidate.original_text}, " f"normalizedDate: {candidate.normalized_date.isoformat()}, " f"startOffset: {candidate.start_offset}, " - f"endOffset: {candidate.end_offset}" + f"endOffset: {candidate.end_offset}, " + f"extractionType: {candidate.extraction_type or 'null'}" ) return "\n".join(lines) diff --git a/docs/newsletter-extraction.md b/docs/newsletter-extraction.md index b626a5a..50c51ea 100644 --- a/docs/newsletter-extraction.md +++ b/docs/newsletter-extraction.md @@ -1,26 +1,52 @@ -# 가정통신문 항목 추출 설계 +# 가정통신문 분석 API 스펙 ## 목적 -가정통신문 본문에서 일정, 마감, 체크리스트, 알림 항목을 추출합니다. +BE가 가정통신문 저장에 필요한 `title`, `summary`, `items`를 AI 서버에서 한 번에 받을 수 있도록 최종 분석 API 계약을 정의한다. -핵심 원칙은 날짜를 AI가 새로 만들지 않게 하는 것입니다. 구체적인 날짜는 백엔드나 전처리 단계에서 만든 `dateCandidates` 중 하나만 선택해야 합니다. +AI 서버의 책임은 원문과 날짜 후보를 분석해 JSON을 반환하는 것이다. DB 저장 여부, 저장 모델 매핑, 사용자 확인 플로우는 BE가 담당한다. ## 엔드포인트 +### `POST /ai/newsletters/analyze` + +가정통신문 전체 분석 API다. 제목, 요약, 일정/마감/체크리스트 항목, 분석 메타데이터를 반환한다. + ### `POST /ai/newsletters/extract-items` -비용 없이 실행되는 rule-based baseline입니다. OpenAI API를 붙이기 전에도 스키마, 날짜 후보 매칭, 샘플 케이스를 확인할 수 있습니다. +기존 항목 추출 API다. `items` 응답 형식 검증과 비용 없는 rule-based baseline 확인 용도로 유지한다. ### `POST /ai/newsletters/prompt-preview` -LLM에 전달할 system/user prompt와 응답 JSON schema를 생성합니다. ChatGPT Plus에서 수동 실험하거나, 추후 OpenAI API 호출에 그대로 사용할 수 있습니다. +LLM 호출 전 system/user prompt와 최종 분석 응답 JSON schema를 확인하는 API다. `responseSchema`는 `/ai/newsletters/analyze` 응답 구조를 기준으로 한다. + +## Analyze 요청 스키마 + +`/ai/newsletters/analyze`는 기존 `extract-items` 입력 형식을 그대로 사용한다. + +| 필드 | 타입 | 필수 | 설명 | +| --- | --- | --- | --- | +| `originalText` | `string` | 예 | 가정통신문 원문 | +| `translatedText` | `string \| null` | 아니오 | 번역 또는 OCR 보정 텍스트. 없으면 `null` | +| `language` | `string` | 아니오 | 원문 언어. 기본값은 `KO` | +| `referenceDate` | `date \| null` | 아니오 | 상대 날짜 해석 기준일. ISO-8601 날짜 | +| `timezone` | `string` | 아니오 | 날짜/시간 기준 타임존. 기본값은 `Asia/Seoul` | +| `dateCandidates` | `DateCandidate[]` | 아니오 | BE 또는 전처리 단계가 추출한 날짜 후보 목록 | -## 요청 예시 +### `DateCandidate` + +| 필드 | 타입 | 필수 | 설명 | +| --- | --- | --- | --- | +| `candidateId` | `string \| null` | 아니오 | BE가 후보 추적에 사용할 수 있는 식별자 | +| `originalText` | `string` | 예 | 원문에 등장한 날짜 표현 | +| `normalizedDate` | `date` | 예 | 정규화된 날짜 | +| `startOffset` | `integer` | 예 | 원문 기준 시작 offset | +| `endOffset` | `integer` | 예 | 원문 기준 종료 offset | +| `extractionType` | `string \| null` | 아니오 | 후보 추출 방식. 예: `REGEX`, `OCR`, `MANUAL` | ```json { - "originalText": "5월 10일까지 참가 신청서를 제출해주세요.", + "originalText": "2026학년도 체험학습 신청 안내\n5월 10일까지 참가 신청서를 제출해 주세요.", "translatedText": null, "language": "KO", "referenceDate": "2026-05-06", @@ -30,26 +56,91 @@ LLM에 전달할 system/user prompt와 응답 JSON schema를 생성합니다. Ch "candidateId": "dc_1", "originalText": "5월 10일", "normalizedDate": "2026-05-10", - "startOffset": 0, - "endOffset": 6, + "startOffset": 18, + "endOffset": 24, "extractionType": "REGEX" } ] } ``` -## 추출 규칙 +## Analyze 응답 스키마 + +| 필드 | 타입 | 필수 | 설명 | +| --- | --- | --- | --- | +| `title` | `string` | 예 | 문서 제목. BE의 가정통신문 제목 저장값으로 사용 가능 | +| `summary` | `string` | 예 | 문서 요약. BE의 요약 저장값으로 사용 가능 | +| `items` | `ExtractedItem[]` | 예 | 일정, 마감, 체크리스트, 알림 항목 | +| `meta` | `object` | 예 | 분석 모드, 후보 개수 등 저장 비대상 메타데이터 | + +### `ExtractedItem` + +기존 `extract-items` 응답 형식을 유지한다. + +| 필드 | 타입 | 필수 | 설명 | +| --- | --- | --- | --- | +| `type` | `schedule \| deadline \| checklist \| reminder` | 예 | 항목 분류 | +| `title` | `string` | 예 | 항목 제목. 문서 제목인 top-level `title`과 다름 | +| `selectedDateCandidate` | `SelectedDateCandidate \| null` | 예 | 선택된 날짜 후보. 날짜가 없으면 `null` | +| `dateStatus` | `confirmed \| ambiguous \| missing` | 예 | 날짜 신뢰 상태 | +| `datetime` | `string \| null` | 예 | 저장 가능한 날짜/시간 문자열. 없으면 `null` | +| `timezone` | `string` | 예 | 항목 날짜/시간 기준 타임존 | +| `evidenceText` | `string` | 예 | 원문 근거 | +| `confidence` | `number` | 예 | 0~1 범위 신뢰도 | +| `needsUserConfirmation` | `boolean` | 예 | 사용자 확인 필요 여부 | +| `confirmationQuestion` | `string \| null` | 예 | 사용자에게 물어볼 확인 질문 | + +### `SelectedDateCandidate` + +| 필드 | 타입 | 필수 | 설명 | +| --- | --- | --- | --- | +| `index` | `integer` | 예 | 요청 `dateCandidates` 배열의 index | +| `candidateId` | `string \| null` | 예 | 요청 후보의 `candidateId` | +| `originalText` | `string` | 예 | 요청 후보의 `originalText` | +| `normalizedDate` | `date` | 예 | 요청 후보의 `normalizedDate` | + +```json +{ + "title": "2026학년도 체험학습 신청 안내", + "summary": "체험학습 참가 신청서를 5월 10일까지 제출해야 합니다.", + "items": [ + { + "type": "deadline", + "title": "체험학습 참가 신청서 제출", + "selectedDateCandidate": { + "index": 0, + "candidateId": "dc_1", + "originalText": "5월 10일", + "normalizedDate": "2026-05-10" + }, + "dateStatus": "confirmed", + "datetime": "2026-05-10", + "timezone": "Asia/Seoul", + "evidenceText": "5월 10일까지 참가 신청서를 제출해 주세요.", + "confidence": 0.86, + "needsUserConfirmation": false, + "confirmationQuestion": null + } + ], + "meta": { + "mode": "rule_based_baseline", + "dateCandidateCount": 1, + "requiresLLMReview": true + } +} +``` + +## 유지 여부 검토 -- `confirmed`는 항목이 제공된 date candidate 중 하나를 사용할 때만 부여합니다. -- 날짜 표현이 있지만 후보 매칭이 불확실하면 `ambiguous`로 둡니다. -- 실행 가능한 체크리스트인데 날짜가 없으면 `missing`으로 둡니다. -- `evidenceText`는 원문 근거를 짧게 담습니다. -- 캘린더와 알림 생성은 `confirmed` 항목만 대상으로 삼습니다. +- `dateCandidates` 입력 형식은 유지한다. 날짜 정규화 책임을 BE 또는 전처리 단계에 두고, AI 서버는 후보 중 하나를 선택한다. +- `items` 응답 형식은 유지한다. 기존 `extract-items` 소비 코드가 항목 구조를 그대로 검증할 수 있어야 한다. +- `items[].title`은 항목 제목이고, top-level `title`은 문서 제목이다. BE 매핑 시 두 필드를 구분해야 한다. +- `meta`는 저장 모델과 직접 매핑하지 않는다. 디버깅, 분석 모드 표시, 후보 개수 확인용이다. +- `selectedDateCandidate`, `evidenceText`, `confidence`, `needsUserConfirmation`, `confirmationQuestion`은 BE 저장 정책에 따라 저장하거나 무시할 수 있는 분석 보조 필드다. +- `datetime`은 `dateStatus`가 `confirmed`일 때만 캘린더/알림 저장 대상으로 보는 것을 권장한다. `ambiguous` 또는 `missing`은 사용자 확인 플로우로 넘긴다. -## 작업 흐름 +## 경계 -1. 백엔드 또는 전처리 단계에서 날짜 후보를 만듭니다. -2. AI 서버에 원문과 `dateCandidates`를 전달합니다. -3. `extract-items`로 비용 없는 baseline 결과를 먼저 확인합니다. -4. 부족한 케이스는 `prompt-preview` 결과를 ChatGPT Plus에 넣어 비교합니다. -5. 충분히 안정화된 뒤 OpenAI API 호출을 AI 서버 내부에 붙입니다. +- BE: 원문 저장, 날짜 후보 생성 또는 전달, 분석 결과 저장, 사용자 확인 처리. +- AI 서버: 원문 분석, 제목/요약/항목 추출, 날짜 후보 매칭, JSON 반환. +- AI 서버는 DB 테이블명, 저장 ID, ORM 모델을 요청이나 응답 스키마에 포함하지 않는다. From 6249fa81e3401f8633a556a7ea7c88226f7c4982 Mon Sep 17 00:00:00 2001 From: minju Date: Wed, 20 May 2026 01:53:35 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EA=B0=80=EC=A0=95=ED=86=B5=EC=8B=A0?= =?UTF-8?q?=EB=AC=B8=20=EB=B6=84=EC=84=9D=20=ED=94=BC=EB=93=9C=EB=B0=B1=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 --- app/services/newsletter_extractor.py | 3 ++- docs/newsletter-extraction.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/services/newsletter_extractor.py b/app/services/newsletter_extractor.py index 7f71bd0..8b234a8 100644 --- a/app/services/newsletter_extractor.py +++ b/app/services/newsletter_extractor.py @@ -234,7 +234,8 @@ def _summarize_document( request: NewsletterAnalysisRequest, items: list[ExtractedItem], ) -> str: - text = request.translated_text or request.original_text + translated = (request.translated_text or "").strip() + text = translated or request.original_text sentences = list(_split_sentences(text)) if sentences: summary = " ".join(sentences[:2]) diff --git a/docs/newsletter-extraction.md b/docs/newsletter-extraction.md index 50c51ea..5384732 100644 --- a/docs/newsletter-extraction.md +++ b/docs/newsletter-extraction.md @@ -22,7 +22,7 @@ LLM 호출 전 system/user prompt와 최종 분석 응답 JSON schema를 확인 ## Analyze 요청 스키마 -`/ai/newsletters/analyze`는 기존 `extract-items` 입력 형식을 그대로 사용한다. +`/ai/newsletters/analyze`는 기존 `extract-items` 입력 계약을 그대로 사용한다. 따라서 `dateCandidates`의 `startOffset`, `endOffset`도 기존과 동일하게 필수다. | 필드 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | @@ -133,6 +133,7 @@ LLM 호출 전 system/user prompt와 최종 분석 응답 JSON schema를 확인 ## 유지 여부 검토 - `dateCandidates` 입력 형식은 유지한다. 날짜 정규화 책임을 BE 또는 전처리 단계에 두고, AI 서버는 후보 중 하나를 선택한다. +- `startOffset`, `endOffset`은 기존 `extract-items` 입력 계약과 동일하게 필수다. AI 서버가 원문 근거 범위를 안정적으로 찾기 위해 사용한다. - `items` 응답 형식은 유지한다. 기존 `extract-items` 소비 코드가 항목 구조를 그대로 검증할 수 있어야 한다. - `items[].title`은 항목 제목이고, top-level `title`은 문서 제목이다. BE 매핑 시 두 필드를 구분해야 한다. - `meta`는 저장 모델과 직접 매핑하지 않는다. 디버깅, 분석 모드 표시, 후보 개수 확인용이다.