diff --git a/build.gradle b/build.gradle
index 7fd7d46..efbf5da 100644
--- a/build.gradle
+++ b/build.gradle
@@ -41,7 +41,6 @@ dependencies {
implementation 'software.amazon.awssdk:s3'
implementation 'com.drewnoakes:metadata-extractor:2.19.0'
implementation 'com.fasterxml.jackson.core:jackson-databind'
- implementation 'org.apache.pdfbox:pdfbox:3.0.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
compileOnly 'org.projectlombok:lombok'
diff --git a/deploy/.env.example b/deploy/.env.example
index 0c89bde..8d0f866 100644
--- a/deploy/.env.example
+++ b/deploy/.env.example
@@ -31,7 +31,12 @@ SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=true
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=true
SPRING_MAIL_USERNAME=
+AI_SERVER_BASE_URL=http://ai:8000
+OPENAI_ENABLED=false
OPENAI_API_KEY=
+OPENAI_MODEL=gpt-4.1-mini
+OPENAI_BASE_URL=https://api.openai.com/v1
+OPENAI_TIMEOUT_SECONDS=60
AUTH_EMAIL_FROM_ADDRESS=
AUTH_EMAIL_NOOP_ALLOWED=false
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index 78c79f7..c357c30 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -97,9 +97,7 @@ services:
CLOVA_OCR_SECRET_KEY: ${CLOVA_OCR_SECRET_KEY}
PAPAGO_CLIENT_ID: ${PAPAGO_CLIENT_ID}
PAPAGO_CLIENT_SECRET: ${PAPAGO_CLIENT_SECRET}
- OPENAI_API_KEY: ${OPENAI_API_KEY}
- OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4.1-mini}
- OPENAI_MAX_TOKENS: ${OPENAI_MAX_TOKENS:-2000}
+ AI_SERVER_BASE_URL: ${AI_SERVER_BASE_URL:-http://ai:8000}
secrets:
- source: spring_mail_password
target: spring_mail_password
@@ -108,6 +106,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
+ ai:
+ condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/actuator/health || exit 1"]
interval: 30s
@@ -120,7 +120,11 @@ services:
image: ${AI_IMAGE:-gachi-ai:latest}
container_name: gachi-ai
environment:
- OPENAI_API_KEY: ${OPENAI_API_KEY}
+ OPENAI_ENABLED: ${OPENAI_ENABLED:-false}
+ OPENAI_API_KEY: ${OPENAI_API_KEY:-}
+ OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4.1-mini}
+ OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1}
+ OPENAI_TIMEOUT_SECONDS: ${OPENAI_TIMEOUT_SECONDS:-60}
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8000/ai/health || exit 1"]
interval: 30s
diff --git a/deploy/nginx/nginx.conf b/deploy/nginx/nginx.conf
index 8be026d..819aed4 100644
--- a/deploy/nginx/nginx.conf
+++ b/deploy/nginx/nginx.conf
@@ -100,6 +100,36 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
+ location /ai/docs {
+ auth_basic "GACHI Swagger";
+ auth_basic_user_file /etc/nginx/secrets/swagger_htpasswd.txt;
+ proxy_pass http://ai_upstream;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ location = /ai/openapi.json {
+ auth_basic "GACHI Swagger";
+ auth_basic_user_file /etc/nginx/secrets/swagger_htpasswd.txt;
+ proxy_pass http://ai_upstream;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ location /ai/redoc {
+ auth_basic "GACHI Swagger";
+ auth_basic_user_file /etc/nginx/secrets/swagger_htpasswd.txt;
+ proxy_pass http://ai_upstream;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
location /ai/ {
proxy_pass http://ai_upstream;
proxy_set_header Host $host;
diff --git a/deploy/nginx/nginx.https.template.conf b/deploy/nginx/nginx.https.template.conf
index 929b4fe..8e02ded 100644
--- a/deploy/nginx/nginx.https.template.conf
+++ b/deploy/nginx/nginx.https.template.conf
@@ -80,6 +80,36 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
+ location /ai/docs {
+ auth_basic "GACHI Swagger";
+ auth_basic_user_file /etc/nginx/secrets/swagger_htpasswd.txt;
+ proxy_pass http://ai_upstream;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ location = /ai/openapi.json {
+ auth_basic "GACHI Swagger";
+ auth_basic_user_file /etc/nginx/secrets/swagger_htpasswd.txt;
+ proxy_pass http://ai_upstream;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ location /ai/redoc {
+ auth_basic "GACHI Swagger";
+ auth_basic_user_file /etc/nginx/secrets/swagger_htpasswd.txt;
+ proxy_pass http://ai_upstream;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
location /ai/ {
proxy_pass http://ai_upstream;
proxy_set_header Host $host;
@@ -93,4 +123,4 @@ http {
add_header Content-Type text/plain;
}
}
-}
\ No newline at end of file
+}
diff --git a/docs/error-code.md b/docs/error-code.md
index 1471203..890315f 100644
--- a/docs/error-code.md
+++ b/docs/error-code.md
@@ -2,7 +2,7 @@
상세 에러 코드는 아래 Google Sheets를 단일 원본으로 관리합니다.
-- Google Sheets: [Error Code Single Source](https://docs.google.com/spreadsheets/d/1sUG_JJsVl6a8nR6k8jO_b7UrIEZzuvQdPB6S2S6fqgo/edit?gid=2097363290#gid=2097363290)
+- Google Sheets: [Error Code Single Source](https://docs.google.com/spreadsheets/d/1sUG_JJsVl6a8nR6k8jO_b7UrIEZzuvQdPB6S2S6fqgo/edit?gid=1519705696#gid=1519705696)
## 운영 원칙
diff --git a/docs/newsletter-ai-analyze-integration.md b/docs/newsletter-ai-analyze-integration.md
new file mode 100644
index 0000000..46043be
--- /dev/null
+++ b/docs/newsletter-ai-analyze-integration.md
@@ -0,0 +1,28 @@
+# 가정통신문 AI 전체 분석 응답 저장 연동
+
+이 문서는 BE가 AI 서버의 가정통신문 전체 분석 응답을 저장 모델에 반영하는 구현 결정을 정리한다. 상세 API 명세는 노션을 기준으로 관리한다.
+
+## 결정 사항
+
+- BE는 가정통신문 파이프라인의 AI 분석 단계에서 `POST /ai/newsletters/analyze`를 호출한다.
+- AI 서버 응답의 top-level `title`, `summary`를 `newsletter.title`, `newsletter.summary`에 저장한다.
+- AI 서버 응답의 `items`는 기존 checklist 저장 흐름에 연결한다.
+- `dateCandidates` 요청 형식과 `items` 응답 형식은 기존 `extract-items` 계약을 유지한다.
+- `meta`는 운영 보조 정보로 받고, 현재 BE 저장 모델에는 직접 저장하지 않는다.
+- AI 서버 호출 실패 정책은 `docs/newsletter-ai-failure-policy.md`를 따른다.
+
+## 저장 정책
+
+- `title`: AI 응답 제목을 우선 저장한다. 빈 값이면 기존 BE fallback 제목을 사용한다.
+- `summary`: AI 응답 요약을 우선 저장한다. 빈 값이면 기존 BE fallback 요약을 사용한다.
+- `items`: `title`이 비어 있지 않은 항목만 checklist로 저장한다.
+- `datetime`: TODO 저장 시 `targetDate`, `targetDateLabel` 생성에 사용한다. 파싱 실패 시 날짜 없이 저장한다.
+- `dateStatus`: checklist 저장 모델에는 별도 컬럼이 없으므로 직접 저장하지 않는다. 단, `confirmed`이고 `datetime`이 파싱 가능한 항목은 calendar preview 생성 대상으로 사용한다.
+- `calendar preview`: AI 분석으로 저장된 checklist/TODO ID와 확정 날짜를 묶어 Redis preview에 저장한다. `ambiguous`, `missing`, 날짜 파싱 실패 항목은 사용자가 확인해야 하므로 preview에 자동 포함하지 않는다.
+
+## 호환 정책
+
+- AI 서버의 `extract-items` baseline API는 유지하지만, BE 파이프라인은 `analyze`를 우선 사용한다.
+- AI 서버가 `items`를 빈 배열로 반환해도 `title`, `summary`는 저장할 수 있다.
+- 확정 날짜가 없는 분석 결과는 기존 Redis preview를 비워, 재분석 후 오래된 일정 후보가 남지 않게 한다.
+- AI 서버 장애 시에는 OCR/번역/dateCandidates 스냅샷을 보존하고 newsletter 상태를 `FAILED`로 둔다.
diff --git a/docs/newsletter-ai-failure-policy.md b/docs/newsletter-ai-failure-policy.md
new file mode 100644
index 0000000..e1f2739
--- /dev/null
+++ b/docs/newsletter-ai-failure-policy.md
@@ -0,0 +1,43 @@
+# AI 서버 장애 시 가정통신문 처리 정책
+
+이 문서는 API 명세서가 아니라 BE가 장애 상황에서 어떤 상태와 데이터를 남길지 정한 운영 정책이다.
+상세 API 명세는 노션을 기준으로 관리한다.
+
+## 결정 사항
+
+- AI 서버 호출 실패 시 newsletter 상태는 `FAILED`로 둔다.
+- 별도 부분 성공 상태는 이번 범위에서 추가하지 않는다.
+- OCR, 정제 원문, 번역 결과, 날짜 후보는 가능한 범위까지 저장한다.
+- AI 분석 결과에서 파생되는 제목, 요약, checklist, calendar event는 생성하지 않는다.
+- 실패 원인 추적을 위해 `failureStage`, `failureReason`을 저장한다.
+- 상태 조회 응답은 `FAILED`일 때 `canRetry=true`와 실패 단계를 반환한다.
+- 사용자는 `POST /api/v1/newsletters/{newsletterId}/analysis/retry`로 같은 문서를 다시 분석할 수 있다.
+
+## 실패 시 저장 범위
+
+AI 서버 단계에서 실패하면 다음 데이터는 남긴다.
+
+- `status = FAILED`
+- `ocrText`
+- `originalText`
+- `translatedText`
+- `dateCandidates`
+- `failureStage = AI_SERVER`
+- `failureReason`
+
+다음 데이터는 비워 둔다.
+
+- `title`
+- `summary`
+- checklist
+- calendar event
+
+## 재시도 정책
+
+재시도는 `FAILED` 상태에서만 허용한다.
+
+재시도 요청이 들어오면 기존 checklist와 calendar event 파생 데이터를 삭제하고, newsletter 상태를 `PENDING`으로 되돌린 뒤 파이프라인을 다시 실행한다.
+
+자동 재시도 큐는 이번 범위에서 만들지 않는다. AI 서버 장애와 문서 입력 문제를 자동으로 구분하기 어렵고, 외부 API 비용과 장애 확산 위험이 있기 때문이다.
+
+추후 필요하면 `failureStage=AI_SERVER`인 건만 백오프 큐에 넣는 방식으로 확장한다.
diff --git a/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java b/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java
index f66f39c..31de87c 100644
--- a/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java
+++ b/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java
@@ -17,4 +17,9 @@ public record SignupRequest(
@NotBlank
@Pattern(regexp = PhoneNumberValidation.REGEXP, message = PhoneNumberValidation.MESSAGE)
String phoneNumber,
- @NotNull Boolean consentAgreed) {}
+ @NotNull Boolean consentAgreed,
+ @NotNull(message = "languageCode는 필수입니다.")
+ @Pattern(
+ regexp = "^(KO|US|ZH|VI)$",
+ message = "지원하지 않는 언어 코드입니다. KO, US, ZH, VI 중 하나여야 합니다.")
+ String languageCode) {}
diff --git a/src/main/java/com/gachi/be/domain/auth/service/AuthenticatedUserResolver.java b/src/main/java/com/gachi/be/domain/auth/service/AuthenticatedUserResolver.java
index cea4fe0..c44f98a 100644
--- a/src/main/java/com/gachi/be/domain/auth/service/AuthenticatedUserResolver.java
+++ b/src/main/java/com/gachi/be/domain/auth/service/AuthenticatedUserResolver.java
@@ -1,7 +1,7 @@
package com.gachi.be.domain.auth.service;
import com.gachi.be.domain.user.entity.User;
-import com.gachi.be.domain.user.entity.UserStatus;
+import com.gachi.be.domain.user.entity.enums.UserStatus;
import com.gachi.be.domain.user.repository.UserRepository;
import com.gachi.be.global.code.ErrorCode;
import com.gachi.be.global.exception.BusinessException;
diff --git a/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java b/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java
index 5fb22d5..21c9b9b 100644
--- a/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java
+++ b/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java
@@ -22,7 +22,7 @@
import com.gachi.be.domain.auth.service.TokenHashService;
import com.gachi.be.domain.auth.service.password.PasswordStrengthEvaluator;
import com.gachi.be.domain.user.entity.User;
-import com.gachi.be.domain.user.entity.UserStatus;
+import com.gachi.be.domain.user.entity.enums.UserStatus;
import com.gachi.be.domain.user.repository.UserRepository;
import com.gachi.be.global.code.ErrorCode;
import com.gachi.be.global.exception.AppException;
@@ -138,6 +138,7 @@ public SignupResponse signup(SignupRequest request) {
.name(name)
.phoneNumber(phoneNumber)
.status(UserStatus.ACTIVE)
+ .languageCode(request.languageCode())
.emailVerifiedAt(now)
.consentAgreedAt(now)
.consentVersion(authProperties.getConsentVersion())
diff --git a/src/main/java/com/gachi/be/domain/calendar/service/CalendarPreviewRedisService.java b/src/main/java/com/gachi/be/domain/calendar/service/CalendarPreviewRedisService.java
index 515f40d..31a81ba 100644
--- a/src/main/java/com/gachi/be/domain/calendar/service/CalendarPreviewRedisService.java
+++ b/src/main/java/com/gachi/be/domain/calendar/service/CalendarPreviewRedisService.java
@@ -13,9 +13,9 @@
/**
* 캘린더 일정 등록 플로우에서 사용하는 Redis 임시 데이터 관리 서비스.
*
- *
흐름 요약: 1. 가정통신문 AI 분석 완료 → AI 파이프라인에서 Redis에 preview 데이터 저장 (추후 연결) 2. GET /calendar/preview →
- * Redis에서 읽어 팝업에 표시 3. PATCH /calendar/dates → Redis에서 날짜 수정 후 다시 저장 4. POST /calendar → Redis 데이터
- * 기반으로 calendar_events insert → Redis 키 삭제
+ *
흐름 요약: 1. 가정통신문 AI 분석 완료 → AI 파이프라인에서 Redis에 preview 데이터 저장 2. GET /calendar/preview → Redis에서
+ * 읽어 팝업에 표시 3. PATCH /calendar/dates → Redis에서 날짜 수정 후 다시 저장 4. POST /calendar → Redis 데이터 기반으로
+ * calendar_events insert → Redis 키 삭제
*
*
Redis 키 형식: newsletter:preview:{newsletterId} - TTL: 1시간 (사용자가 팝업을 열고 이탈해도 자동 만료)
*/
diff --git a/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java b/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java
index 7fcf38b..7d3f98b 100644
--- a/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java
+++ b/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java
@@ -65,13 +65,10 @@ public ApiResponse upload(
schema = @Schema(type = "string", format = "binary")))
@RequestPart("file")
MultipartFile file,
- @Parameter(description = "연결할 자녀 ID. 미선택 시 생략") @RequestParam(required = false) Long childId,
- @Parameter(description = "언어 코드 (KO/US/ZH/VI). 기본값 KO")
- @RequestParam(defaultValue = "KO")
- @Pattern(regexp = "KO|US|ZH|VI", message = "language는 KO/US/ZH/VI 중 하나여야 합니다.")
- String language) {
+ @Parameter(description = "연결할 자녀 ID. 미선택 시 생략") @RequestParam(required = false)
+ Long childId) {
- NewsletterUploadResponse response = newsletterService.upload(userId, file, childId, language);
+ NewsletterUploadResponse response = newsletterService.upload(userId, file, childId);
return ApiResponse.success(SuccessCode.NEWSLETTER_UPLOAD_SUCCESS, response);
}
@@ -92,6 +89,24 @@ public ApiResponse getStatus(
return ApiResponse.success(SuccessCode.NEWSLETTER_STATUS_SUCCESS, response);
}
+ /** 실패한 가정통신문 분석 재시도 API */
+ @Operation(
+ summary = "가정통신문 분석 재시도",
+ description =
+ """
+ FAILED 상태의 가정통신문 분석을 다시 시작합니다.
+ AI 서버 장애로 실패한 경우 기존 OCR/번역 결과는 보존되어 있고, 재시도 시 파이프라인이 다시 실행됩니다.
+ """)
+ @PostMapping("/{newsletterId}/analysis/retry")
+ @ResponseStatus(HttpStatus.ACCEPTED)
+ public ApiResponse retryAnalysis(
+ @AuthenticationPrincipal Long userId,
+ @Parameter(description = "가정통신문 ID", required = true) @PathVariable Long newsletterId) {
+
+ NewsletterUploadResponse response = newsletterService.retryAnalysis(userId, newsletterId);
+ return ApiResponse.success(SuccessCode.NEWSLETTER_RETRY_ACCEPTED, response);
+ }
+
/** 번역 결과 조회 API. */
@Operation(
summary = "번역 결과 조회",
diff --git a/src/main/java/com/gachi/be/domain/newsletter/dto/response/NewsletterStatusResponse.java b/src/main/java/com/gachi/be/domain/newsletter/dto/response/NewsletterStatusResponse.java
index a117099..758bf1b 100644
--- a/src/main/java/com/gachi/be/domain/newsletter/dto/response/NewsletterStatusResponse.java
+++ b/src/main/java/com/gachi/be/domain/newsletter/dto/response/NewsletterStatusResponse.java
@@ -1,6 +1,7 @@
package com.gachi.be.domain.newsletter.dto.response;
import com.fasterxml.jackson.annotation.JsonInclude;
+import com.gachi.be.domain.newsletter.entity.Newsletter;
import com.gachi.be.domain.newsletter.entity.enums.NewsletterStatus;
/**
@@ -10,19 +11,31 @@
*/
@JsonInclude(JsonInclude.Include.NON_NULL) // null인 필드는 JSON 응답에서 제외
public record NewsletterStatusResponse(
- NewsletterStatus status, int progressPercent, String progressMessage, String errorMessage) {
+ NewsletterStatus status,
+ int progressPercent,
+ String progressMessage,
+ String errorMessage,
+ String failureStage,
+ boolean canRetry) {
/**
* 분석 상태에 따라 적절한 진행률과 에러메시지를 자동 계산하는 팩토리 메서드. TODO: 현재는 고정값으로 처리, 추후 AI 서버에서 단계별 진행률을 받아 세분화 예정
*/
- public static NewsletterStatusResponse of(NewsletterStatus status) {
- String safeErrorMessage = "분석 중 오류가 발생했어요. 잠시 후 다시 시도해 주세요.";
+ public static NewsletterStatusResponse of(Newsletter newsletter) {
+ NewsletterStatus status = newsletter.getStatus();
return switch (status) {
- case PENDING -> new NewsletterStatusResponse(status, 0, "문서를 준비하고 있어요", null);
- case PROCESSING -> new NewsletterStatusResponse(status, 60, "텍스트를 인식하고 번역하고 있어요", null);
- case COMPLETED -> new NewsletterStatusResponse(status, 100, "분석이 완료되었어요", null);
+ case PENDING -> new NewsletterStatusResponse(status, 0, "문서를 준비하고 있어요", null, null, false);
+ case PROCESSING ->
+ new NewsletterStatusResponse(status, 60, "텍스트를 인식하고 번역하고 있어요", null, null, false);
+ case COMPLETED -> new NewsletterStatusResponse(status, 100, "분석이 완료되었어요", null, null, false);
case FAILED ->
- new NewsletterStatusResponse(status, 0, null, "분석 중 오류가 발생했어요. 잠시 후 다시 시도해 주세요.");
+ new NewsletterStatusResponse(
+ status,
+ 0,
+ null,
+ "분석 중 오류가 발생했어요. 다시 분석을 시도할 수 있어요.",
+ newsletter.getFailureStage(),
+ true);
};
}
}
diff --git a/src/main/java/com/gachi/be/domain/newsletter/entity/Newsletter.java b/src/main/java/com/gachi/be/domain/newsletter/entity/Newsletter.java
index b19132e..fffda45 100644
--- a/src/main/java/com/gachi/be/domain/newsletter/entity/Newsletter.java
+++ b/src/main/java/com/gachi/be/domain/newsletter/entity/Newsletter.java
@@ -72,6 +72,12 @@ public class Newsletter {
@Column(name = "summary", columnDefinition = "TEXT")
private String summary;
+ @Column(name = "failure_stage", length = 50)
+ private String failureStage;
+
+ @Column(name = "failure_reason", columnDefinition = "TEXT")
+ private String failureReason;
+
/** 날짜 후보는 최종 일정이 아니라 후속 AI 매칭을 위한 중간 재료이므로 JSON으로 보관합니다. */
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "date_candidates", columnDefinition = "jsonb")
@@ -125,6 +131,8 @@ protected void onUpdate() {
/** AI 분석 시작 시 PROCESSING 상태로 전환합니다. */
public void startProcessing() {
this.status = NewsletterStatus.PROCESSING;
+ this.failureStage = null;
+ this.failureReason = null;
}
/** AI 분석 결과를 저장하고 COMPLETED 상태로 전환합니다. */
@@ -136,11 +144,37 @@ public void complete(
this.title = title;
this.summary = summary;
this.status = NewsletterStatus.COMPLETED;
+ this.failureStage = null;
+ this.failureReason = null;
}
- /** AI 분석 실패 시 FAILED 상태로 전환합니다. */
- public void fail() {
+ /** 분석 실패 시 원인 추적을 위해 실패 단계와 사유를 함께 저장합니다. */
+ public void fail(String failureStage, String failureReason) {
this.status = NewsletterStatus.FAILED;
+ this.failureStage = normalizeFailureStage(failureStage);
+ this.failureReason = normalizeFailureReason(failureReason);
+ }
+
+ /** OCR/번역 이후 AI 서버 장애가 나도 사용자가 원문 결과를 확인할 수 있도록 중간 산출물을 보존합니다. */
+ public void failWithSnapshot(
+ String ocrText,
+ String originalText,
+ String translatedText,
+ String failureStage,
+ String failureReason) {
+ this.ocrText = ocrText;
+ this.originalText = originalText;
+ this.translatedText = translatedText;
+ fail(failureStage, failureReason);
+ }
+
+ /** 실패한 분석을 사용자가 다시 시도할 때 이전 실패 사유를 비우고 대기 상태로 되돌립니다. */
+ public void prepareRetry() {
+ this.status = NewsletterStatus.PENDING;
+ this.failureStage = null;
+ this.failureReason = null;
+ this.title = null;
+ this.summary = null;
}
/** 날짜 후보 목록을 교체합니다. 후보가 없으면 빈 목록으로 저장합니다. */
@@ -153,4 +187,18 @@ public void replaceDateCandidates(List dateCandidates)
public void updateChildColor(String newColor) {
this.childColor = newColor;
}
+
+ private String normalizeFailureStage(String failureStage) {
+ if (failureStage == null || failureStage.isBlank()) {
+ return "UNKNOWN";
+ }
+ return failureStage.length() <= 50 ? failureStage : failureStage.substring(0, 50);
+ }
+
+ private String normalizeFailureReason(String failureReason) {
+ if (failureReason == null || failureReason.isBlank()) {
+ return null;
+ }
+ return failureReason.length() <= 1000 ? failureReason : failureReason.substring(0, 1000);
+ }
}
diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java
new file mode 100644
index 0000000..4ec5791
--- /dev/null
+++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java
@@ -0,0 +1,166 @@
+package com.gachi.be.domain.newsletter.pipeline;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.gachi.be.domain.newsletter.entity.NewsletterDateCandidate;
+import com.gachi.be.global.code.ErrorCode;
+import com.gachi.be.global.config.external.AiServerProperties;
+import com.gachi.be.global.exception.ExternalApiException;
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+public class AiNewsletterClient {
+
+ private static final ZoneId DEFAULT_ZONE = ZoneId.of("Asia/Seoul");
+ private static final String ANALYZE_PATH = "/ai/newsletters/analyze";
+
+ private final AiServerProperties aiServerProperties;
+ private final ObjectMapper objectMapper;
+ private final HttpClient httpClient;
+
+ public AiNewsletterClient(AiServerProperties aiServerProperties, ObjectMapper objectMapper) {
+ this.aiServerProperties = aiServerProperties;
+ this.objectMapper = objectMapper;
+ this.httpClient =
+ HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(aiServerProperties.getConnectTimeoutSeconds()))
+ .version(HttpClient.Version.HTTP_1_1)
+ .build();
+ }
+
+ public AnalysisResponse analyze(
+ String originalText,
+ String translatedText,
+ String language,
+ List dateCandidates) {
+ try {
+ String requestBody =
+ objectMapper.writeValueAsString(
+ new AnalysisRequest(
+ originalText,
+ translatedText,
+ language != null ? language : "KO",
+ LocalDate.now(DEFAULT_ZONE),
+ DEFAULT_ZONE.getId(),
+ toDateCandidateRequests(dateCandidates)));
+ log.info("[AiNewsletterClient] 요청 body: {}", requestBody);
+
+ HttpRequest request =
+ HttpRequest.newBuilder()
+ .uri(URI.create(normalizedBaseUrl() + ANALYZE_PATH))
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .timeout(Duration.ofSeconds(aiServerProperties.getReadTimeoutSeconds()))
+ .POST(HttpRequest.BodyPublishers.ofString(requestBody))
+ .build();
+
+ HttpResponse response =
+ httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+
+ if (response.statusCode() < 200 || response.statusCode() >= 300) {
+ log.error(
+ "[AiNewsletterClient] AI 서버 분석 실패. status={}, body={}",
+ response.statusCode(),
+ response.body());
+ // response.body() != null ? response.body().length() : 0);
+ throw new ExternalApiException(
+ ErrorCode.EXTERNAL_API_ERROR, "AI 서버 분석 실패. status=" + response.statusCode());
+ }
+
+ return objectMapper.readValue(response.body(), AnalysisResponse.class).normalized();
+ } catch (ExternalApiException e) {
+ throw e;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new ExternalApiException(
+ ErrorCode.EXTERNAL_API_ERROR, "AI 서버 통신 인터럽트: " + e.getMessage(), e);
+ } catch (IOException e) {
+ throw new ExternalApiException(
+ ErrorCode.EXTERNAL_API_ERROR, "AI 서버 통신 오류: " + e.getMessage(), e);
+ }
+ }
+
+ private String normalizedBaseUrl() {
+ String baseUrl = aiServerProperties.getBaseUrl();
+ if (baseUrl == null || baseUrl.isBlank()) {
+ throw new ExternalApiException(ErrorCode.EXTERNAL_API_ERROR, "AI 서버 base-url이 비어 있습니다.");
+ }
+ return baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
+ }
+
+ private List toDateCandidateRequests(
+ List dateCandidates) {
+ if (dateCandidates == null || dateCandidates.isEmpty()) {
+ return List.of();
+ }
+
+ List requests = new ArrayList<>();
+ for (int i = 0; i < dateCandidates.size(); i++) {
+ NewsletterDateCandidate candidate = dateCandidates.get(i);
+ requests.add(
+ new DateCandidateRequest(
+ "dc_" + (i + 1),
+ candidate.originalText(),
+ candidate.normalizedDate(),
+ candidate.startOffset(),
+ candidate.endOffset(),
+ candidate.extractionType() != null ? candidate.extractionType().name() : null));
+ }
+ return requests;
+ }
+
+ record AnalysisRequest(
+ String originalText,
+ String translatedText,
+ String language,
+ LocalDate referenceDate,
+ String timezone,
+ List dateCandidates) {}
+
+ record DateCandidateRequest(
+ String candidateId,
+ String originalText,
+ LocalDate normalizedDate,
+ int startOffset,
+ int endOffset,
+ String extractionType) {}
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record AnalysisResponse(
+ String title, String summary, List items, Map meta) {
+
+ AnalysisResponse normalized() {
+ return new AnalysisResponse(title, summary, items != null ? items : List.of(), meta);
+ }
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record SelectedDateCandidate(
+ Integer index, String candidateId, String originalText, LocalDate normalizedDate) {}
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record ExtractedItem(
+ String type,
+ String title,
+ SelectedDateCandidate selectedDateCandidate,
+ String datetime,
+ String timezone,
+ String evidenceText,
+ String dateStatus,
+ Double confidence,
+ Boolean needsUserConfirmation,
+ String confirmationQuestion) {}
+}
diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java
index 50febbd..7958e14 100644
--- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java
+++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java
@@ -1,84 +1,41 @@
package com.gachi.be.domain.newsletter.pipeline;
-import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.ObjectMapper;
+import com.gachi.be.domain.calendar.dto.CalendarPreviewEvent;
+import com.gachi.be.domain.calendar.service.CalendarPreviewRedisService;
import com.gachi.be.domain.checklist.entity.Checklist;
import com.gachi.be.domain.checklist.entity.enums.ChecklistType;
import com.gachi.be.domain.checklist.repository.ChecklistRepository;
import com.gachi.be.domain.newsletter.entity.Newsletter;
+import com.gachi.be.domain.newsletter.pipeline.AiNewsletterClient.AnalysisResponse;
+import com.gachi.be.domain.newsletter.pipeline.AiNewsletterClient.ExtractedItem;
import com.gachi.be.domain.newsletter.repository.NewsletterRepository;
-import com.gachi.be.file.config.S3Properties;
-import com.gachi.be.global.code.ErrorCode;
-import com.gachi.be.global.config.external.OpenAiProperties;
-import com.gachi.be.global.exception.ExternalApiException;
-import java.io.IOException;
-import java.net.URI;
-import java.net.http.HttpClient;
-import java.net.http.HttpRequest;
-import java.net.http.HttpResponse;
-import java.time.Duration;
import java.time.LocalDate;
+import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
-import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
-import software.amazon.awssdk.services.s3.model.GetObjectRequest;
-import software.amazon.awssdk.services.s3.presigner.S3Presigner;
-import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
-
-/**
- * OpenAI를 이용한 가정통신문 AI 분석 컴포넌트. 모든 분석은 OpenAI Chat Completions API를 호출하여 수행. 모델은 application.yml의
- * app.openai.model 값으로 설정 (기본: gpt-4o-mini). -> TODO: 추후 결과 보고 일반 모델로 변경할 수도
- *
- * 프롬프트 설계 원칙: - 시스템 프롬프트: AI의 역할과 출력 형식을 명확히 지정 - 사용자 프롬프트: 실제 가정통신문 텍스트 - temperature=0.3: 낮은
- * 값으로 설정하여 일관된 결과 보장 (0에 가까울수록 결정적, 1에 가까울수록 창의적)
- */
+
@Slf4j
@Component
@RequiredArgsConstructor
public class NewsletterAiAnalyzer {
- private final OpenAiProperties openAiProperties;
- private final ObjectMapper objectMapper;
+ private static final String DEFAULT_TITLE = "가정통신문 안내";
+ private static final int TITLE_MAX_LENGTH = 80;
+ private static final int SUMMARY_MAX_LENGTH = 300;
+ private static final int CHECKLIST_TEXT_MAX_LENGTH = 500;
+
+ private final AiNewsletterClient aiNewsletterClient;
private final ChecklistRepository checklistRepository;
+ private final CalendarPreviewRedisService calendarPreviewRedisService;
private final NewsletterRepository newsletterRepository;
- private final S3Presigner s3Presigner;
- private final S3Properties s3Properties;
- // vision 용 presigned URL 유효시간 -> DB에는 S3 key 만
- private static final int VISION_PRESIGNED_URL_MINUTES = 15;
-
- // Vision 입력을 담는 내부 DTO -> analyze 진입 시 1회만 구성하고 제목 추출에만 전달
- private record VisionInput(String imagePresignedUrl, List pdfPageBase64Images) {
-
- /** Vision 입력이 있는지 여부. */
- boolean hasVision() {
- return imagePresignedUrl != null
- || (pdfPageBase64Images != null && !pdfPageBase64Images.isEmpty());
- }
-
- /** Vision 없는 빈 입력 생성 (텍스트 전용 모드용). */
- static VisionInput none() {
- return new VisionInput(null, null);
- }
- }
+ private final PapagoTranslateClient papagoTranslateClient;
- /**
- * 가정통신문 전체 AI 분석을 수행하고 결과를 DB에 저장.
- *
- * 분석 기준 텍스트: - 제목/체크리스트/해야할일: originalText (한국어 원문 기준) → 번역 텍스트보다 원문이 날짜, 고유명사 등을 더 정확하게 포함하기
- * 때문 - 요약: language=KO면 originalText, 그 외 translatedText 기준 → 사용자 언어로 읽기 편한 요약을 제공하기 위해
- */
public AiAnalysisResult analyze(
- Long newsletterId,
- String originalText,
- String translatedText,
- String language,
- List pdfPageBase64Images) {
-
- log.info("[AiAnalyzer] 분석 시작. newsletterId={}, language={}", newsletterId, language);
+ Long newsletterId, String originalText, String translatedText, String language) {
+ log.info("[AiAnalyzer] AI 서버 분석 시작. newsletterId={}, language={}", newsletterId, language);
Newsletter newsletter =
newsletterRepository
@@ -88,436 +45,240 @@ public AiAnalysisResult analyze(
new IllegalStateException(
"[AiAnalyzer] newsletter를 찾을 수 없습니다. newsletterId=" + newsletterId));
- Long userId = newsletter.getUserId();
- String fileKey = newsletter.getFileKey();
-
- boolean isImage = isImageFile(fileKey);
- log.debug("[AiAnalyzer] 파일 타입 판단. fileKey={}, isImage={}", fileKey, isImage);
-
- VisionInput visionInput;
- if (isImage) {
- String presignedUrl = generatePresignedUrl(fileKey);
- visionInput = new VisionInput(presignedUrl, null);
- log.debug("[AiAnalyzer] 이미지 Vision 입력 구성 완료.");
- } else if (pdfPageBase64Images != null && !pdfPageBase64Images.isEmpty()) {
- visionInput = new VisionInput(null, pdfPageBase64Images);
- log.debug("[AiAnalyzer] PDF Vision 입력 구성 완료. pages={}", pdfPageBase64Images.size());
- } else {
- visionInput = VisionInput.none();
- log.debug("[AiAnalyzer] Vision 입력 없음. 텍스트 전용 모드.");
+ AnalysisResponse analysisResponse =
+ aiNewsletterClient.analyze(
+ originalText, translatedText, language, newsletter.getDateCandidates());
+ List items = analysisResponse.items();
+
+ List savedItems =
+ saveExtractedItems(newsletterId, newsletter.getUserId(), items, language);
+
+ try {
+ saveCalendarPreview(newsletterId, savedItems);
+ } catch (RuntimeException e) {
+ log.warn(
+ "[AiAnalyzer] 캘린더 preview 저장 실패. 분석 결과 저장은 계속 진행합니다. newsletterId={}", newsletterId, e);
}
- // 요약 기준 텍스트 결정
- String summarySourceText =
- (translatedText != null && !translatedText.isBlank()) ? translatedText : originalText;
+ String rawTitle = normalizeTitle(analysisResponse.title(), originalText);
+ String title = translateIfNeeded(rawTitle, language, "title", newsletterId);
+ String summary = normalizeSummary(analysisResponse.summary(), translatedText, originalText);
- // 제목 추출
- String title = extractTitle(originalText, visionInput);
- log.debug("[AiAnalyzer] 제목 추출 완료. title={}", title);
+ log.info(
+ "[AiAnalyzer] AI 서버 분석 완료. newsletterId={}, extractedItems={}", newsletterId, items.size());
+ return new AiAnalysisResult(title, summary);
+ }
- // AI 요약
- String summary = generateSummary(summarySourceText, language, VisionInput.none());
- log.debug("[AiAnalyzer] 요약 완료. length={}chars", summary.length());
+ private List saveExtractedItems(
+ Long newsletterId, Long userId, List items, String language) {
+ if (items == null || items.isEmpty()) {
+ log.warn("[AiAnalyzer] AI 서버 항목 추출 결과 없음. newsletterId={}", newsletterId);
+ return List.of();
+ }
- // 체크리스트 추출 + DB 저장
- extractAndSaveChecklist(newsletterId, userId, originalText, VisionInput.none());
- log.debug("[AiAnalyzer] 체크리스트 저장 완료.");
+ List validItems =
+ items.stream().filter(item -> item.title() != null && !item.title().isBlank()).toList();
+ List entities =
+ validItems.stream().map(item -> toChecklist(newsletterId, userId, item, language)).toList();
- // 해야 할 일 추출 + DB 저장
- extractAndSaveTodos(newsletterId, userId, originalText, VisionInput.none());
- log.debug("[AiAnalyzer] 해야 할 일 저장 완료.");
+ if (entities.isEmpty()) {
+ log.warn("[AiAnalyzer] 저장 가능한 항목 없음. newsletterId={}", newsletterId);
+ return List.of();
+ }
- log.info("[AiAnalyzer] 분석 완료. newsletterId={}", newsletterId);
- return new AiAnalysisResult(title, summary);
+ List savedEntities = checklistRepository.saveAll(entities);
+ log.debug("[AiAnalyzer] AI 서버 추출 항목 {}개 저장 완료.", entities.size());
+
+ List savedItems = new ArrayList<>();
+ for (int i = 0; i < validItems.size(); i++) {
+ savedItems.add(new SavedExtractedItem(validItems.get(i), savedEntities.get(i)));
+ }
+ return savedItems;
}
- private boolean isImageFile(String fileKey) {
- if (fileKey == null) return false;
- String lower = fileKey.toLowerCase();
- return lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png");
+ private Checklist toChecklist(
+ Long newsletterId, Long userId, ExtractedItem item, String language) {
+ ChecklistType checklistType =
+ "checklist".equalsIgnoreCase(item.type()) ? ChecklistType.CHECKLIST : ChecklistType.TODO;
+
+ LocalDate targetDate = parseTargetDate(item.datetime());
+
+ String targetDateLabel = null;
+ if (targetDate != null) {
+ String koreanLabel = targetDate.getMonthValue() + "월 " + targetDate.getDayOfMonth() + "일";
+ targetDateLabel = translateIfNeeded(koreanLabel, language, "targetDateLabel", newsletterId);
+ }
+
+ // content: AI 서버가 한국어로 추출한 항목명을 파파고로 번역
+ String content =
+ translateIfNeeded(
+ trimToMax(item.title().trim(), CHECKLIST_TEXT_MAX_LENGTH),
+ language,
+ "content",
+ newsletterId);
+
+ // detail: AI 서버가 한국어로 추출한 상세 설명을 파파고로 번역
+ String detail = null;
+ if (item.evidenceText() != null && !item.evidenceText().isBlank()) {
+ String trimmedDetail = trimNullable(item.evidenceText(), CHECKLIST_TEXT_MAX_LENGTH);
+ detail = translateIfNeeded(trimmedDetail, language, "detail", newsletterId);
+ }
+
+ return Checklist.builder()
+ .newsletterId(newsletterId)
+ .calendarEventId(null)
+ .userId(userId)
+ .type(checklistType)
+ .content(content)
+ .detail(detail)
+ .targetDate(checklistType == ChecklistType.TODO ? targetDate : null)
+ .targetDateLabel(checklistType == ChecklistType.TODO ? targetDateLabel : null)
+ .build();
}
- private String generatePresignedUrl(String fileKey) {
+ /** KO가 아닌 경우 파파고로 번역. KO이거나 번역 실패 시 원문 그대로 반환. */
+ private String translateIfNeeded(
+ String text, String language, String fieldName, Long newsletterId) {
+ if (text == null || text.isBlank()) {
+ return text;
+ }
+ if ("KO".equals(language)) {
+ // KO는 번역 스킵, 원문 그대로 반환
+ return text;
+ }
try {
- GetObjectRequest getObjectRequest =
- GetObjectRequest.builder().bucket(s3Properties.getBucket()).key(fileKey).build();
+ String translated = papagoTranslateClient.translate(text, language);
+ if (translated != null && !translated.isBlank()) {
+ log.debug(
+ "[AiAnalyzer] {} 번역 완료. newsletterId={}, language={}",
+ fieldName,
+ newsletterId,
+ language);
+ return translated;
+ }
+ } catch (Exception e) {
+ // 번역 실패 시 원문 유지 (파이프라인 전체를 중단하지 않음)
+ log.warn(
+ "[AiAnalyzer] {} 번역 실패. 원문 유지. newsletterId={}, language={}, error={}",
+ fieldName,
+ newsletterId,
+ language,
+ e.getMessage());
+ }
+ return text;
+ }
- GetObjectPresignRequest presignRequest =
- GetObjectPresignRequest.builder()
- .signatureDuration(Duration.ofMinutes(VISION_PRESIGNED_URL_MINUTES))
- .getObjectRequest(getObjectRequest)
- .build();
+ private LocalDate parseTargetDate(String value) {
+ if (value == null || value.isBlank()) {
+ return null;
+ }
- return s3Presigner.presignGetObject(presignRequest).url().toString();
- } catch (Exception e) {
- // Presigned URL 생성 실패 시 Vision 없이 텍스트만으로 분석 진행 (서비스 중단 방지)
- log.warn("[AiAnalyzer] Presigned URL 생성 실패. 텍스트만으로 분석 진행. error={}", e.getMessage());
+ try {
+ return LocalDate.parse(value.length() >= 10 ? value.substring(0, 10) : value);
+ } catch (DateTimeParseException e) {
+ log.warn("[AiAnalyzer] AI 서버 날짜 파싱 실패. value={}", value);
return null;
}
}
- /** 가정통신문 원문에서 제목을 추출. */
- private String extractTitle(String originalText, VisionInput visionInput) {
- // 시스템 프롬프트 (AI 역할 + 출력 형식 지정)
- String systemPrompt =
- """
- 당신은 한국 초등학교 가정통신문을 분석하는 전문가입니다.
- 가정통신문 텍스트에서 공식 제목을 추출하는 것이 당신의 역할입니다.
-
- 규칙:
- - 가정통신문의 공식 제목만 추출하세요.
- - 30자 이내로 작성하세요.
- - 제목 텍스트만 반환하고, 설명이나 다른 텍스트는 절대 포함하지 마세요.
- - 앞뒤에 마크다운 코드블록(```), 따옴표, 설명을 붙이지 마세요.
- - 제목을 찾을 수 없으면 "가정통신문 안내"를 반환하세요.
- """;
- String response = callOpenAi(systemPrompt, originalText, visionInput, 100);
- return response.trim();
- }
+ private void saveCalendarPreview(Long newsletterId, List savedItems) {
+ List previewEvents = new ArrayList<>();
- /**
- * 가정통신문 핵심 내용을 다문화 학부모를 위해 요약 프롬프트 설명: 시스템: 다문화 학부모 친화적 요약을 위한 지침 제공. - 언어 코드에 따라 응답 언어 지정 - 날짜,
- * 장소, 준비물, 제출 마감 등 핵심 정보 우선 포함 - 쉬운 표현 사용 (전문 용어 지양) - 3~5문장 제한으로 핵심만 전달 사용자: KO이면 원문, 그 외이면 번역문을
- * 전달 (사용자 모국어 텍스트 기준으로 요약해야 더 자연스러움)
- */
- private String generateSummary(String sourceText, String language, VisionInput visionInput) {
- String responseLanguage =
- switch (language == null ? "KO" : language) {
- case "US" -> "영어(English)";
- case "ZH" -> "중국어 간체(简体中文)";
- case "VI" -> "베트남어(Tiếng Việt)";
- default -> "한국어";
- };
-
- // 시스템 프롬프트
- String systemPrompt =
- String.format(
- """
- 당신은 한국 초등학교 가정통신문을 다문화 가정 학부모에게 쉽게 설명해주는 전문가입니다.
- 반드시 %s로만 답변하세요.
-
- 요약 규칙:
- - 3~5문장으로 핵심 내용을 요약하세요.
- - 날짜, 장소, 준비물, 제출 마감일을 반드시 포함하세요.
- - 학부모가 바로 이해하고 행동할 수 있도록 명확하게 작성하세요.
- - 어려운 교육 전문 용어는 쉬운 표현으로 바꿔 쓰세요.
- - 요약 텍스트만 반환하고, 제목이나 설명은 포함하지 마세요.
- - 앞뒤에 마크다운 코드블록(```)을 붙이지 마세요.
- """,
- responseLanguage);
-
- String response = callOpenAi(systemPrompt, sourceText, visionInput, 500);
- return response.trim();
- }
+ for (SavedExtractedItem savedItem : savedItems) {
+ ExtractedItem item = savedItem.item();
+ String extractedDate = normalizePreviewDate(item.datetime());
+ if (!"confirmed".equalsIgnoreCase(item.dateStatus()) || extractedDate == null) {
+ continue;
+ }
- /**
- * 가정통신문에서 체크리스트 항목을 추출하고 DB에 저장. 프롬프트 설명: 시스템: JSON 배열만 반환하도록 엄격히 지정. - content: 체크리스트 주요 항목
- * (UI에서 굵게 표시) - detail: 한 줄 상세 설명 (UI에서 작게 표시) - 최대 10개로 제한 (너무 많으면 사용자가 압도됨) TODO: 이거 더 얘기해보기 -
- * JSON 외 다른 텍스트 절대 금지 (파싱 실패 방지)
- */
- private void extractAndSaveChecklist(
- Long newsletterId, Long userId, String originalText, VisionInput visionInput) {
- // 시스템 프롬프트
- String systemPrompt =
- """
- 당신은 한국 초등학교 가정통신문에서 학부모가 해야 할 행동 목록을 추출하는 전문가입니다.
-
- 추출 규칙:
- - 학부모가 직접 해야 할 준비물, 제출물, 확인 사항만 추출하세요.
- - 단순 안내 정보(학교 일정, 공지 등)는 제외하세요.
- - 최대 10개까지만 추출하세요.
- - content: 20자 이내의 핵심 항목명 (예: "현장학습 동의서 제출")
- - detail: 30자 이내의 한 줄 상세 설명 (예: "담임 선생님께 원본 직접 제출")
- - detail이 없으면 문자열 "null"이 아닌 JSON null로 설정하세요.
-
- 출력 규칙:
- - JSON 배열만 반환하세요.
- - 앞뒤에 설명, 제목, 마크다운 코드블록(```json)을 절대 붙이지 마세요.
- - 괄호와 따옴표가 올바르게 닫혔는지 출력 전에 확인하세요.
- 형식: [{"content": "항목명", "detail": null}]
- """;
-
- String response = callOpenAi(systemPrompt, originalText, visionInput, 800);
- List items = parseJsonList(response, new TypeReference<>() {});
+ previewEvents.add(
+ new CalendarPreviewEvent(
+ "ai_evt_" + (previewEvents.size() + 1),
+ trimToMax(item.title().trim(), CHECKLIST_TEXT_MAX_LENGTH),
+ extractedDate,
+ true,
+ checklistIdList(savedItem.checklist())));
+ }
- if (items == null || items.isEmpty()) {
- log.warn("[AiAnalyzer] 체크리스트 추출 결과 없음. newsletterId={}", newsletterId);
+ if (previewEvents.isEmpty()) {
+ // 재분석 결과에 확정 날짜가 없으면 이전 미리보기 데이터가 남아 잘못 등록될 수 있어 비운다.
+ calendarPreviewRedisService.deletePreview(newsletterId);
+ log.debug("[AiAnalyzer] 캘린더 preview 생성 대상 없음. newsletterId={}", newsletterId);
return;
}
- List entities =
- items.stream()
- .filter(dto -> dto.content() != null && !dto.content().isBlank())
- .map(
- dto ->
- Checklist.builder()
- .newsletterId(newsletterId)
- .calendarEventId(null)
- .userId(userId)
- .type(ChecklistType.CHECKLIST)
- .content(dto.content().trim())
- .detail(dto.detail() != null ? dto.detail().trim() : null)
- .targetDate(null)
- .targetDateLabel(null)
- .build())
- .toList();
-
- checklistRepository.saveAll(entities);
- log.debug("[AiAnalyzer] 체크리스트 {}개 저장 완료.", entities.size());
+ calendarPreviewRedisService.savePreview(newsletterId, previewEvents);
+ log.debug(
+ "[AiAnalyzer] 캘린더 preview {}개 저장 완료. newsletterId={}", previewEvents.size(), newsletterId);
}
- /**
- * 가정통신문에서 날짜 기반 해야 할 일을 추출하고 DB에 저장. 프롬프트 설명: 시스템: 날짜 맥락이 중요한 행동 계획 추출. - targetDate: 명확한 날짜가 있으면
- * YYYY-MM-DD, 없으면 null - targetDateLabel: 사용자에게 보여줄 문구 → 날짜 있으면 "5월 15일", 없으면 "지금 바로" / "행사 전날" 등
- * 맥락에 맞게 - 오늘 날짜를 프롬프트에 주입하여 "내일", "이번 주" 등 상대적 표현을 절대 날짜로 변환 - 최대 5개로 제한 (너무 많으면 핵심이 희석됨) TODO:
- * 이거 더 얘기해보기
- */
- private void extractAndSaveTodos(
- Long newsletterId, Long userId, String originalText, VisionInput visionInput) {
- // 오늘 날짜를 프롬프트에 주입 (상대적 날짜 표현 변환을 위해)
- String today = LocalDate.now().toString(); // 예: "2026-04-13"
-
- // 시스템 프롬프트
- String systemPrompt =
- String.format(
- """
- 당신은 한국 초등학교 가정통신문을 분석하여 학부모의 실행 계획을 짜주는 전문가입니다.
- 오늘 날짜는 %s입니다.
-
- 계획 수립 원칙:
- - 가정통신문에서 마감일/행사일을 먼저 파악하세요.
- - 마감일을 기준으로 역산하여 언제 무엇을 해야 하는지 계획을 세우세요.
- - 예: 현장학습이 5월 21일이면 -> 전날(5월 20일) 준비물 챙기기, 이틀 전 동의서 서명 등
- - 준비물 구매처럼 시간이 필요한 것은 마감일보다 여유 있게 배치하세요.
- - 즉시 해야 할 것(동의서 출력 등)은 "지금 바로"로 배치하세요.
- - 마감일이 명시되지 않은 준비물은 "지금 바로" 또는 가장 가까운 관련 일정 전날로 배치하세요.
-
- 추출 규칙:
- - 최대 5개까지만 추출하고, 시간 순서대로 정렬하세요.
- - content: 40자 이내의 구체적인 행동 (예: "담임 선생님께 동의서 직접 제출")
- - targetDate: 명확한 날짜가 있으면 YYYY-MM-DD 형식, 즉시 행동이면 null
- - targetDateLabel: 사용자에게 보여줄 날짜 문구
- → 날짜 있으면 "N월 N일", 오늘이면 "오늘", 즉시 해야 하면 "지금 바로", 행사 전날이면 "행사 전날" 등
-
- 출력 규칙:
- - JSON 배열만 반환하세요.
- - 앞뒤에 설명, 제목, 마크다운 코드블록(```json)을 절대 붙이지 마세요.
- - 괄호와 따옴표가 올바르게 닫혔는지 출력 전에 확인하세요.
- 형식: [{"content": "할 일", "targetDate": "2026-05-15 또는 null", "targetDateLabel": "표시 문구"}]
- """,
- today);
-
- String response = callOpenAi(systemPrompt, originalText, visionInput, 800);
-
- List items = parseJsonList(response, new TypeReference<>() {});
- if (items == null || items.isEmpty()) {
- log.warn("[AiAnalyzer] 해야 할 일 추출 결과 없음. newsletterId={}", newsletterId);
- return;
- }
-
- List entities =
- items.stream()
- .filter(dto -> dto.content() != null && !dto.content().isBlank())
- .map(
- dto -> {
- // targetDate 문자열 파싱 (null 또는 "null" 문자열 모두 처리)
- LocalDate targetDate = null;
- if (dto.targetDate() != null
- && !dto.targetDate().isBlank()
- && !"null".equals(dto.targetDate())) {
- try {
- targetDate = LocalDate.parse(dto.targetDate());
- } catch (Exception e) {
- log.warn("[AiAnalyzer] targetDate 파싱 실패. value={}", dto.targetDate());
- }
- }
- return Checklist.builder()
- .newsletterId(newsletterId)
- .calendarEventId(null)
- .userId(userId)
- .type(ChecklistType.TODO)
- .content(dto.content().trim())
- .detail(null)
- .targetDate(targetDate)
- .targetDateLabel(dto.targetDateLabel())
- .build();
- })
- .toList();
-
- checklistRepository.saveAll(entities);
- log.debug("[AiAnalyzer] 해야 할 일 {}개 저장 완료.", entities.size());
+ private String normalizePreviewDate(String value) {
+ LocalDate targetDate = parseTargetDate(value);
+ return targetDate != null ? targetDate.toString() : null;
}
- /**
- * OpenAI Chat Completions API 호출.
- *
- * @param systemPrompt AI 역할과 출력 형식을 지정하는 시스템 메시지
- * @param userContent 분석할 가정통신문 텍스트 (사용자 메시지)
- * @param maxTokens 응답 최대 토큰 수 (작업마다 다르게 설정)
- * @return AI 응답 텍스트
- */
- private String callOpenAi(
- String systemPrompt, String userContent, VisionInput visionInput, int maxTokens) {
- try {
- Object userMessageContent;
-
- if (visionInput.imagePresignedUrl() != null) {
- // 이미지 파일: S3 Presigned URL로 Vision 전달 (detail:high)
- List