Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -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를 제공합니다.

## 로컬 실행

Expand All @@ -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`: 정답 데이터 라벨링 기준
13 changes: 10 additions & 3 deletions app/routers/newsletters.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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)
13 changes: 12 additions & 1 deletion app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand Down
72 changes: 62 additions & 10 deletions app/services/newsletter_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
DateStatus,
ExtractedItem,
ExtractedItemType,
NewsletterAnalysisRequest,
NewsletterAnalysisResponse,
NewsletterExtractionRequest,
NewsletterExtractionResponse,
SelectedDateCandidate,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -193,3 +220,28 @@ 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:
translated = (request.translated_text or "").strip()
text = translated 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 "분석할 본문 내용이 충분하지 않습니다."
138 changes: 88 additions & 50 deletions app/services/newsletter_prompt.py
Original file line number Diff line number Diff line change
@@ -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)},
Expand All @@ -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 = [
Expand All @@ -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 "[]"

Expand All @@ -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)
Loading