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
4 changes: 4 additions & 0 deletions docs/API-CHANGES-CHAT-PROMPT-PERSONALITY-TYPE-LOVETYPE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ UNKNOWN, 사용자와의 대화로부터 유추할 것

- `otherPersonalityType`가 있는 경우에만 `- 상대방 성향 프롬프트:` 항목을 추가합니다.
- `partnerLoveTypeCategory`가 `UNKNOWN` 또는 `null`이면 DB 조회 없이 바로 폴백 문구를 삽입합니다.
- 다만 1단계 완료 후 생성되는 **2단계 첫 분석 메시지 직후** 내부 추론이 성공하면, 이후부터는 저장된 확정값으로 조회됩니다.
- `otherPersonalityType`와 확정된 `partnerLoveTypeCategory`가 모두 있으면 `(otherPersonalityType, partnerLoveTypeCategory)` 조합으로 조회합니다.
- 조합 row가 없으면 채팅은 실패시키지 않고 동일한 폴백 문구를 삽입합니다.

Expand Down Expand Up @@ -115,6 +116,9 @@ UNKNOWN, 사용자와의 대화로부터 유추할 것
- `GET /members`의 `otherPersonalityType`
- `GET /members`의 `partnerLoveTypeCategory`

추가로, 사용자가 상대방 애착유형을 입력하지 않았더라도 1단계 완료 후 2단계 첫 분석 메시지 직후 내부 추론 결과가 `partnerLoveTypeCategory`에 저장될 수 있습니다.
관련 상세 내용은 `docs/API-CHANGES-PARTNER-LOVETYPE-INFERENCE-PERSISTENCE.md`를 참고합니다.

---

## 테스트
Expand Down
5 changes: 4 additions & 1 deletion docs/API-CHANGES-LOVETYPE-DATA.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
otherPersonalityType: string, // 상대 MBTI
partnerLoveTypeCategory: 'STABLE_TYPE' | 'ANXIETY_TYPE' | 'AVOIDANCE_TYPE' | 'CONFUSION_TYPE' | 'UNKNOWN'
// undefined = 미입력 / UNKNOWN = "모르겠어요" 선택됨
// 또는 1단계 완료 후 2단계 첫 분석 메시지 직후 내부 추론값이 저장될 수 있음
}
```

Expand All @@ -71,6 +72,7 @@
- 상대방
- `otherPersonalityType`가 있을 때만 상대방 성향 프롬프트 항목이 추가됩니다.
- `partnerLoveTypeCategory`가 `UNKNOWN` 또는 `null`이면 DB 조회 없이 `UNKNOWN, 사용자와의 대화로부터 유추할 것`을 사용합니다.
- 이후 1단계 완료 후 생성되는 2단계 첫 분석 메시지 직후 내부 추론이 성공하면, 저장된 확정값으로 프롬프트를 조회합니다.
- `otherPersonalityType`와 확정된 `partnerLoveTypeCategory`가 모두 있으면 `(otherPersonalityType, partnerLoveTypeCategory)` 조합으로 상세 프롬프트를 조회합니다.
- 매칭 row가 없으면 채팅은 실패하지 않고 동일한 폴백 문구를 사용합니다.

Expand All @@ -81,4 +83,5 @@
- `lovetype`: `STABLE_TYPE | ANXIETY_TYPE | AVOIDANCE_TYPE | CONFUSION_TYPE`
- `prompts`: 실제 채팅 메타데이터에 삽입할 프롬프트 전문

상세 동작 예시는 `docs/API-CHANGES-CHAT-PROMPT-PERSONALITY-TYPE-LOVETYPE.md`, 실제 전달 예시는 `docs/CHAT-PROMPT-PERSONALITY-TYPE-LOVETYPE-EXAMPLE.md`를 참고합니다.
상세 동작 예시는 `docs/API-CHANGES-CHAT-PROMPT-PERSONALITY-TYPE-LOVETYPE.md`, 실제 전달 예시는 `docs/CHAT-PROMPT-PERSONALITY-TYPE-LOVETYPE-EXAMPLE.md`,
영속화 동작은 `docs/API-CHANGES-PARTNER-LOVETYPE-INFERENCE-PERSISTENCE.md`를 참고합니다.
2 changes: 1 addition & 1 deletion docs/API-CHANGES-ONBOARDING-PROFILE.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@
| `personalityType` | String | **필수** | 상대방 MBTI (영문 4자리, 대소문자 무관) |
| `loveTypeCategory` | Enum | 선택 | 상대방 애착 유형 |

> **Note:** `loveTypeCategory`를 생략하면 `null`로 저장됩니다. `UNKNOWN`은 사용자가 명시적으로 선택한 경우에만 설정됩니다.
> **Note:** `loveTypeCategory`를 생략하면 `null`로 저장됩니다. `UNKNOWN`은 사용자가 명시적으로 선택한 경우에 설정됩니다. 다만 이후 채팅에서 1단계 완료 후 2단계 첫 분석 메시지 직후 내부 추론을 통해 확정값으로 갱신될 수 있습니다.

**예시 - MBTI만 등록 (loveTypeCategory는 나중에 PATCH로 설정 가능):**
```json
Expand Down
105 changes: 105 additions & 0 deletions docs/API-CHANGES-PARTNER-LOVETYPE-INFERENCE-PERSISTENCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# 상대방 애착유형 내부 추론 영속화 변경 사항

## 개요

사용자가 상대방 애착유형(`partnerLoveTypeCategory`)을 직접 입력하지 않았거나 `UNKNOWN`으로 남겨둔 경우,
1단계 상황 수집이 완료된 뒤 생성되는 **2단계 첫 분석 메시지 직후**에 챗봇이 상대방 애착유형을 내부적으로 추론하고 `member.partnerLoveTypeCategory`에 저장합니다.

이번 변경의 목적은 다음과 같습니다.

1. 1단계에서 수집된 갈등 상황 정보를 바탕으로 더 이른 시점에 상대방 애착유형을 확정하기
2. 2단계 분석 메시지와 동일한 맥락을 사용해 추론 결과의 일관성을 높이기
3. 3단계 이후 프롬프트에서 확정된 상대방 애착유형을 재사용해 상담 품질을 높이기

---

## 동작 시점

추론은 다음 조건을 모두 만족할 때 실행됩니다.

- 현재 완료된 단계가 `1단계`의 마지막 세부 단계임
- 1단계 완료 후 시스템이 `2단계`의 첫 분석 메시지를 생성함
- `partnerLoveTypeCategory`가 `null` 또는 `UNKNOWN`임

즉, **2단계 종료 시점이 아니라 2단계 첫 분석 메시지가 생성된 직후**에 실행됩니다.

---

## 추론 규칙

추론은 별도 내부 프롬프트(`GlobalConstants.PARTNER_LOVE_TYPE_INFERENCE_PROMPT`)를 사용하며,
입력 컨텍스트에는 아래 내용이 포함됩니다.

- 사용자 메타데이터
- 이전 단계 요약 메타데이터
- 2단계 레벨의 대화 메시지들
- 여기에는 방금 생성된 챗봇의 2단계 첫 분석 메시지가 포함됩니다.

반환값은 아래 4개 enum 중 하나만 허용됩니다.

- `STABLE_TYPE`
- `ANXIETY_TYPE`
- `AVOIDANCE_TYPE`
- `CONFUSION_TYPE`

`UNKNOWN`은 추론 결과로 허용하지 않습니다.

응답 형식은 JSON 객체로 고정됩니다.

```json
{
"partnerLoveTypeCategory": "STABLE_TYPE"
}
```

---

## 저장 규칙

- 저장 대상 필드: `member.partnerLoveTypeCategory`
- 저장 조건:
- 현재 DB 값이 `null` 또는 `UNKNOWN`일 때만 반영
- 이미 사용자가 직접 입력한 확정값이 있으면 덮어쓰지 않음
- 저장 직전에는 `Member`를 다시 조회해 최신 DB 상태를 확인합니다.

### 실패 처리

아래 경우에는 저장하지 않고 상담 흐름만 계속 진행합니다.

- JSON 파싱 실패
- enum 값 검증 실패
- `UNKNOWN` 반환
- 저장 직전 재조회 결과 이미 확정값이 존재함
- 영속화 과정 예외 발생

---

## 외부 API 영향

외부 요청/응답 스펙 변경은 없습니다.

다만 `GET /members`의 `partnerLoveTypeCategory`는 다음과 같은 값이 들어올 수 있습니다.

- 사용자가 직접 입력한 값
- 사용자가 `UNKNOWN`으로 설정한 값
- 내부 2단계 분석 직후 추론되어 저장된 값

즉, 클라이언트는 `partnerLoveTypeCategory`가 이전보다 더 이른 시점에 확정될 수 있음을 고려해야 합니다.

---

## 테스트

검증한 항목:

- `AUXILIARY_EXTRACTION` 시나리오로 JSON 추론 호출
- `UNKNOWN` 및 비정상 enum 값 거부
- 1단계 완료 후 2단계 첫 분석 메시지 직후 추론 및 저장
- 이미 확정된 상대방 애착유형이 있으면 추론/저장 생략
- 추론 실패 시 저장 없이 다음 단계 진행
- 2단계 종료 시점에는 더 이상 추론하지 않음

관련 테스트:

- `src/test/java/makeus/cmc/malmo/application/service/chat/ChatProcessorTest.java`
- `src/test/java/makeus/cmc/malmo/application/service/chat/ChatMessageServiceTest.java`
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import makeus.cmc.malmo.application.helper.chat_room.PromptQueryHelper;
import makeus.cmc.malmo.application.helper.chat_room.DetailedPromptQueryHelper;
import makeus.cmc.malmo.application.helper.chat_room.MemberChatRoomMetadataCommandHelper;
import makeus.cmc.malmo.application.helper.member.MemberCommandHelper;
import makeus.cmc.malmo.application.helper.member.MemberMemoryCommandHelper;
import makeus.cmc.malmo.application.helper.member.MemberQueryHelper;
import makeus.cmc.malmo.application.helper.question.CoupleQuestionQueryHelper;
Expand All @@ -30,7 +31,9 @@
import makeus.cmc.malmo.domain.value.id.CoupleId;
import makeus.cmc.malmo.domain.value.id.CoupleQuestionId;
import makeus.cmc.malmo.domain.value.id.MemberId;
import makeus.cmc.malmo.domain.value.type.PartnerLoveTypeCategory;
import makeus.cmc.malmo.util.ChatMessageSplitter;
import makeus.cmc.malmo.util.GlobalConstants;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
Expand Down Expand Up @@ -60,6 +63,7 @@ public class ChatMessageService implements ProcessMessageUseCase {

private final CoupleQuestionQueryHelper coupleQuestionQueryHelper;

private final MemberCommandHelper memberCommandHelper;
private final MemberMemoryCommandHelper memberMemoryCommandHelper;

private final makeus.cmc.malmo.application.helper.outbox.OutboxHelper outboxHelper;
Expand Down Expand Up @@ -105,16 +109,7 @@ public CompletableFuture<Void> processStreamChatMessage(ProcessMessageCommand co
}

// 마지막 충분성 조건인 경우
// 1단계 종료 시 제목 생성 요청
if (command.getPromptLevel() == 1) {
requestTitleGenerationAsync(chatRoom);
}

// 다음 단계 오프닝 생성 성공 후 단계 전이
return requestNextStageOpening(member, chatRoom, command)
.thenRun(() ->
chatRoomCommandHelper.upgradeChatRoomLevel(chatRoom.getId(), command.getPromptLevel() + 1, 1)
);
return handleCompletedStage(member, chatRoom, command);
});
}

Expand Down Expand Up @@ -281,6 +276,61 @@ private CompletableFuture<Void> requestNextStageOpening(Member member, ChatRoom
).toFuture();
}

private CompletableFuture<Void> handleCompletedStage(
Member member,
ChatRoom chatRoom,
ProcessMessageCommand command
) {
if (command.getPromptLevel() == 1) {
requestTitleGenerationAsync(chatRoom);
}

return requestNextStageOpening(member, chatRoom, command)
.thenCompose(ignored -> inferAndPersistPartnerLoveTypeIfNeeded(member, chatRoom, command))
.thenRun(() ->
chatRoomCommandHelper.upgradeChatRoomLevel(chatRoom.getId(), command.getPromptLevel() + 1, 1)
);
}

private CompletableFuture<Void> inferAndPersistPartnerLoveTypeIfNeeded(
Member member,
ChatRoom chatRoom,
ProcessMessageCommand command
) {
if (!shouldInferPartnerLoveType(member, command)) {
return CompletableFuture.completedFuture(null);
}

List<Map<String, String>> messages = chatPromptBuilder.createForPartnerLoveTypeInference(
member, chatRoom, command.getPromptLevel() + 1);

return chatProcessor.requestPartnerLoveTypeCategoryInference(
messages,
GlobalConstants.PARTNER_LOVE_TYPE_INFERENCE_PROMPT
)
.thenAccept(inferredType -> persistPartnerLoveTypeIfStillUnknown(command.getMemberId(), inferredType))
.exceptionally(throwable -> {
log.warn("Failed to infer partner love type for memberId={}, chatRoomId={}",
command.getMemberId(), chatRoom.getId(), throwable);
return null;
});
}

private boolean shouldInferPartnerLoveType(Member member, ProcessMessageCommand command) {
return command.getPromptLevel() == 1
&& (member.getPartnerLoveTypeCategory() == null
|| member.getPartnerLoveTypeCategory() == PartnerLoveTypeCategory.UNKNOWN);
}

private void persistPartnerLoveTypeIfStillUnknown(Long memberId, PartnerLoveTypeCategory inferredType) {
Member refreshedMember = memberQueryHelper.getMemberByIdOrThrow(MemberId.of(memberId));
boolean updated = refreshedMember.updatePartnerLoveTypeCategoryIfUnknown(inferredType);
if (!updated) {
return;
}
memberCommandHelper.saveMember(refreshedMember);
}

/**
* 비동기 제목 생성 요청
* Redis Stream을 통해 제목 생성 워커에 전달
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package makeus.cmc.malmo.application.service.chat;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -9,6 +10,7 @@
import makeus.cmc.malmo.application.port.in.chat.SufficiencyCheckResult;
import makeus.cmc.malmo.domain.model.chat.DetailedPrompt;
import makeus.cmc.malmo.domain.model.chat.Prompt;
import makeus.cmc.malmo.domain.value.type.PartnerLoveTypeCategory;
import makeus.cmc.malmo.domain.value.type.SenderType;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
Expand Down Expand Up @@ -78,6 +80,32 @@ public CompletableFuture<String> requestMetaData(String question,
return requestChatApiPort.requestResponse(messages, LlmReasoningScenario.AUXILIARY_EXTRACTION);
}

public CompletableFuture<PartnerLoveTypeCategory> requestPartnerLoveTypeCategoryInference(
List<Map<String, String>> messages,
String inferencePrompt
) {
messages.add(createMessageMap(SenderType.SYSTEM, inferencePrompt));

return requestChatApiPort.requestJsonResponse(messages, LlmReasoningScenario.AUXILIARY_EXTRACTION)
.thenApply(jsonResponse -> {
try {
JsonNode node = objectMapper.readTree(jsonResponse);
String rawValue = node.path("partnerLoveTypeCategory").asText(null);
if (rawValue == null || rawValue.isBlank()) {
throw new IllegalArgumentException("partnerLoveTypeCategory is required");
}
PartnerLoveTypeCategory partnerLoveTypeCategory = PartnerLoveTypeCategory.valueOf(rawValue);
if (partnerLoveTypeCategory == PartnerLoveTypeCategory.UNKNOWN) {
throw new IllegalArgumentException("UNKNOWN is not allowed for inferred partner love type");
}
return partnerLoveTypeCategory;
} catch (JsonProcessingException e) {
log.error("Failed to parse partner love type inference JSON: {}", jsonResponse, e);
throw new RuntimeException("Failed to parse partner love type inference JSON", e);
}
});
}

public CompletableFuture<SufficiencyCheckResult> requestSufficiencyCheck(List<Map<String, String>> messages,
DetailedPrompt validationPrompt) {
messages.add(createMessageMap(SenderType.SYSTEM, validationPrompt.getContent()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,27 @@ public List<Map<String, String>> createForNextStage(Member member, ChatRoom chat
return messages;
}

public List<Map<String, String>> createForPartnerLoveTypeInference(Member member, ChatRoom chatRoom, int targetLevel) {
List<Map<String, String>> messages = new ArrayList<>();
ChatRoomId chatRoomId = ChatRoomId.of(chatRoom.getId());

String metaDataContent = getMetaDataContent(member);
messages.add(createMessageMap(SenderType.SYSTEM, metaDataContent));

List<MemberChatRoomMetadata> metadataList = memberChatRoomMetadataQueryHelper.getMemberChatRoomMetadata(chatRoomId);
if (!metadataList.isEmpty()) {
String metadataContent = getMemberChatRoomMetadataContent(metadataList);
messages.add(createMessageMap(SenderType.SYSTEM, metadataContent));
}

List<ChatMessage> stageMessages = chatRoomQueryHelper.getChatRoomLevelMessages(chatRoomId, targetLevel);
for (ChatMessage chatMessage : stageMessages) {
messages.add(createMessageMap(chatMessage.getSenderType(), chatMessage.getContent()));
}

return messages;
}

private String getMemberChatRoomMetadataContent(List<MemberChatRoomMetadata> metadataList) {
if (metadataList == null || metadataList.isEmpty()) {
return "";
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/makeus/cmc/malmo/domain/model/member/Member.java
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,17 @@ public void updatePartnerProfile(String otherPersonalityType, PartnerLoveTypeCat
}
}

public boolean updatePartnerLoveTypeCategoryIfUnknown(PartnerLoveTypeCategory partnerLoveTypeCategory) {
if (partnerLoveTypeCategory == null) {
return false;
}
if (this.partnerLoveTypeCategory != null && this.partnerLoveTypeCategory != PartnerLoveTypeCategory.UNKNOWN) {
return false;
}
this.partnerLoveTypeCategory = partnerLoveTypeCategory;
return true;
}

public void updateLoveType(LoveTypeCategory loveTypeCategory, float avoidanceRate, float anxietyRate) {
this.loveTypeCategory = loveTypeCategory;
this.avoidanceRate = avoidanceRate;
Expand Down
18 changes: 18 additions & 0 deletions src/main/java/makeus/cmc/malmo/util/GlobalConstants.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,24 @@ public class GlobalConstants {

public static final String ATTACHMENT_TYPE_PROMPT_MESSAGE =
"잠깐! 애착유형 테스트를 하면, 더 정확한 상담이 가능해! 그대로 진행하면 바로 상담해줄게";

public static final String PARTNER_LOVE_TYPE_INFERENCE_PROMPT = """
너는 연애 상담 대화의 2단계 분석 내용을 바탕으로 상대방의 애착유형을 추론하는 분류기야.
지금 주어지는 컨텍스트는 2단계 상담 맥락, 2단계 대화 내용, 그리고 챗봇이 수행한 2단계 분석을 포함한다.
반드시 이 2단계 분석과 동일한 맥락만 사용해서 상대방의 애착유형을 추론해라.

[분류 규칙]
- partnerLoveTypeCategory는 반드시 다음 4개 중 하나만 선택한다.
STABLE_TYPE
ANXIETY_TYPE
AVOIDANCE_TYPE
CONFUSION_TYPE
- UNKNOWN은 절대 반환하지 않는다.
- 설명, 이유, 추가 텍스트 없이 JSON 객체만 반환한다.

[응답 형식]
{"partnerLoveTypeCategory":"STABLE_TYPE"}
""";
// 커플 복구 관련 상수
public static final int COUPLE_RECOVERY_LIMIT_DAYS = 30;

Expand Down
Loading
Loading