멈추지 않고 끈질기게

[Unreal] 최종 프로젝트 8일차 - 대화 로직 재구성 / 페르소나 생성 자동화 본문

포트폴리오

[Unreal] 최종 프로젝트 8일차 - 대화 로직 재구성 / 페르소나 생성 자동화

sam0308 2024. 5. 22. 17:30

※ 해당 포스팅은 개인의 공부 정리용 글입니다. 틀린 내용이 있다면 추후 수정될 수 있습니다.

※ 해당 포스팅은 청년취업사관학교 교육 과정의 최종 프로젝트에 관한 내용을 포함하고 있습니다.

 

※ 해당 포스팅은 Unreal 5.4.1 버전을 기준으로 작성되었습니다.

 

 

 

0. 서론

 오늘은 대화 로직을 재구성하면서 웹서버 통신을 세분화하고, 팀원분이 제공해주신 NPC 페르소나 생성 코드를 자동화하였습니다. 특히 웹서버 통신 세분화 과정이 어렵지는 않은데 코드가 길어지니 헷갈리는 순간이 좀 많아서, 작업 시간을 많이 잡아먹었습니다. 

 

 

1. 대화 로직 재구성(웹서버 통신 세분화)

 어제 대화 로직을 수정하면서 녹음된 플레이어 목소리를 다시 입력하도록 했는데, 회의에서 별로인것 같다는 의견이 있어 로직을 다음과 같이 수정하기로 했습니다.

  1. 마이크 입력 완료 후 통신 / 동시에 대화 UI 노출(플레이어 텍스트 처리중입니다)
  2. STT 완료 후 결과 텍스트 받아서 UI에 출력(한 글자씩) 
  3. 플레이어 텍스트 출력 완료 후 2초 대기하고, 대화 UI 갱신 후 2초 대기([NPC이름]이 답변을 고민하고 있습니다) 
  4. 3번 과정중에 온 챗봇 답변은 다음 텍스트로 저장하고 있다가, 3번 과정이 끝나면 출력(한 글자씩)
  5. TTS는 완성되는 대로 출력

이에 따라 기존의 웹서버 통신에서 STT -> 챗봇 답변 -> TTS까지 한번에 진행하던 것을 세분화하여 각 단계별로 결과를 받아오도록 수정하게 되었습니다.

 

#pragma region NPC conversation
void AHttpActor::SendSpeech(const FString& SpeechFileName, const FString& SpeechFilePath)
{
	const FString& FullURL = BaseURL + EndPoint_SendSpeech;

	// HTTP Request
	TSharedRef<IHttpRequest> HttpRequest = FHttpModule::Get().CreateRequest();
	HttpRequest->SetVerb(TEXT("POST"));
	HttpRequest->SetURL(FullURL);
	HttpRequest->SetHeader(TEXT("Content-Type"), TEXT("application/json"));
	HttpRequest->OnProcessRequestComplete().BindUObject(this, &AHttpActor::SendSpeechComplete);

	// 양식 주의할 것(웹 서버쪽의 양식과 정확하게 일치해야 함)
	FString JsonBody = FString::Printf(TEXT("{\"file_name\": \"%s\",\"file_path\" : \"%s\"}"), *SpeechFileName, *SpeechFilePath);
	HttpRequest->SetContentAsString(JsonBody);

	HttpRequest->ProcessRequest();

	UE_LOG(LogTemp, Warning, TEXT("Send speech to %s"), *FullURL);
}

void AHttpActor::SendSpeechComplete(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bConnectedSuccessfully)
{
	if (bConnectedSuccessfully)
	{
		UE_LOG(LogTemp, Warning, TEXT("Send Speech Complete"));
		ReqTextFromSpeech();
	}
	else
	{
		if (Request->GetStatus() == EHttpRequestStatus::Succeeded)
		{
			UE_LOG(LogTemp, Warning, TEXT("Response Failed...%d"), Response->GetResponseCode());
		}
	}
}

void AHttpActor::ReqTextFromSpeech()
{
	const FString& FullURL = BaseURL + EndPoint_GetSpeech;

	// HTTP Request
	TSharedRef<IHttpRequest> HttpRequest = FHttpModule::Get().CreateRequest();
	HttpRequest->SetVerb(TEXT("GET"));
	HttpRequest->SetURL(FullURL);
	HttpRequest->SetHeader(TEXT("Content-Type"), TEXT("application/json"));
	HttpRequest->OnProcessRequestComplete().BindUObject(this, &AHttpActor::ReqTextFromSpeechComplete);

	HttpRequest->ProcessRequest();
	UE_LOG(LogTemp, Warning, TEXT("Req to %s"), *FullURL);
}

void AHttpActor::ReqTextFromSpeechComplete(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bConnectedSuccessfully)
{
	if (bConnectedSuccessfully)
	{
		const FString& ResultText = Response->GetContentAsString();

		// STT 결과를 UI에 전달하고, 챗봇 답변 요청
		MyGameMode->ShowPlayerText(ResultText.Mid(1, ResultText.Len() - 2));
		SendConv(MyGameMode->GetCurNPC()->GetNPCName(), MyGameMode->GetCurNPC()->GetLikeability());
	}
	else
	{
		if (Request->GetStatus() == EHttpRequestStatus::Succeeded)
		{
			UE_LOG(LogTemp, Warning, TEXT("Response Failed...%d"), Response->GetResponseCode());
		}
	}
}
#pragma endregion

#pragma region ChatBot Response
void AHttpActor::SendConv(const FString& NPCName, int32 Preference)
{
	const FString& FullURL = BaseURL + EndPoint_SendConv;

	// HTTP Request
	TSharedRef<IHttpRequest> HttpRequest = FHttpModule::Get().CreateRequest();
	HttpRequest->SetVerb(TEXT("POST"));
	HttpRequest->SetURL(FullURL);
	HttpRequest->SetHeader(TEXT("Content-Type"), TEXT("application/json"));
	HttpRequest->OnProcessRequestComplete().BindUObject(this, &AHttpActor::SendConvComplete);

	// 양식 주의할 것(웹 서버쪽의 양식과 정확하게 일치해야 함)
	FString JsonBody = FString::Printf(TEXT("{\"npc_name\": \"%s\",\"preference\": \"%d\"}"), *NPCName, Preference);
	HttpRequest->SetContentAsString(JsonBody);

	HttpRequest->ProcessRequest();

	UE_LOG(LogTemp, Warning, TEXT("Send speech to %s"), *FullURL);
}

void AHttpActor::SendConvComplete(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bConnectedSuccessfully)
{
	if (bConnectedSuccessfully)
	{
		UE_LOG(LogTemp, Warning, TEXT("Send Conv Complete"));
		GetConv();
	}
	else
	{
		if (Request->GetStatus() == EHttpRequestStatus::Succeeded)
		{
			UE_LOG(LogTemp, Warning, TEXT("Response Failed...%d"), Response->GetResponseCode());
		}
	}
}

void AHttpActor::GetConv()
{
	const FString& FullURL = BaseURL + EndPoint_GetConv;

	// HTTP Request
	TSharedRef<IHttpRequest> HttpRequest = FHttpModule::Get().CreateRequest();
	HttpRequest->SetVerb(TEXT("GET"));
	HttpRequest->SetURL(FullURL);
	HttpRequest->SetHeader(TEXT("Content-Type"), TEXT("application/json"));
	HttpRequest->OnProcessRequestComplete().BindUObject(this, &AHttpActor::GetConvComplete);

	HttpRequest->ProcessRequest();
	UE_LOG(LogTemp, Warning, TEXT("Req to %s"), *FullURL);
}

void AHttpActor::GetConvComplete(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bConnectedSuccessfully)
{
	if (bConnectedSuccessfully)
	{
		const FString& ResultText = Response->GetContentAsString();
		FNPCResponse NPCResponse;
		UJsonParserLibrary::ParseNPCResponse(ResultText, NPCResponse);

		// 챗봇 답변을 UI에 전달하고, TTS 요청
		MyGameMode->SetLatestSpeech(NPCResponse);
		GetTTS();
	}
	else
	{
		if (Request->GetStatus() == EHttpRequestStatus::Succeeded)
		{
			UE_LOG(LogTemp, Warning, TEXT("Response Failed...%d"), Response->GetResponseCode());
		}
	}
}
#pragma endregion

#pragma region TTS
void AHttpActor::GetTTS()
{
	const FString& FullURL = BaseURL + EndPoint_GetTTS;

	// HTTP Request
	TSharedRef<IHttpRequest> HttpRequest = FHttpModule::Get().CreateRequest();
	HttpRequest->SetVerb(TEXT("GET"));
	HttpRequest->SetURL(FullURL);
	HttpRequest->SetHeader(TEXT("Content-Type"), TEXT("application/json"));
	HttpRequest->OnProcessRequestComplete().BindUObject(this, &AHttpActor::GetTTSComplete);

	HttpRequest->ProcessRequest();
	UE_LOG(LogTemp, Warning, TEXT("Req to %s"), *FullURL);
}

void AHttpActor::GetTTSComplete(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bConnectedSuccessfully)
{
	if (bConnectedSuccessfully)
	{
		const FString& ResultText = Response->GetContentAsString();

		// TTS 출력
		MyGameMode->PlayTTS(ResultText.Mid(1, ResultText.Len() - 2));
	}
	else
	{
		if (Request->GetStatus() == EHttpRequestStatus::Succeeded)
		{
			UE_LOG(LogTemp, Warning, TEXT("Response Failed...%d"), Response->GetResponseCode());
		}
	}
}
#pragma endregion

 

 기존에 TTS까지 처리가 끝난 다음에 결과물을 받던 통신 과정을 STT 결과 요청 -> 완료되면 챗봇 답변 요청 -> 챗봇 답변이 오면 TTS 요청 의 3단계로 구분하여 작성하게 되었습니다. 엔드포인트도 늘어나고 다소 코드가 복잡해진 감이 있긴 하지만 덕분에 로딩 시간 체감은 좀 줄일 수 있었습니다.

 

 UI 쪽에서는 단순히 텍스트를 갱신하던 함수에 매개변수를 달아 한글자씩 출력할 텍스트인지 여부를 구분하고, 한글자씩 출력할 함수를 타이머로 반복 실행하게 구현하였습니다. 한글자씩 출력하는 StreamText() 함수에서는 현재 상태에 따라 한글자씩 텍스트 출력 or 일정 시간만큼 대기하도록 했고, 상태를 표현하기 위해 열거형을 선언하여 사용했습니다.

// 헤더 
UENUM()
enum class EConvState : uint8
{
	None = 0,
	Player,
	Wait,
	NPC
};


// cpp
void UNPCConversationWidget::UpdateConversationUI(const FString& NPCName, const FString& NewConversation, bool DoStream)
{
	Txt_NPCName->SetText(FText::FromString(NPCName)); UE_LOG(LogTemp, Error, TEXT("[UpdateConversationUI]  %s"), *NewConversation);

	if(DoStream)
	{
		if(CurConvState != EConvState::None)
		{
			NextText = NewConversation;
		}
		else
		{
			CurText = NewConversation;
			CurLen = 1;
			CurConvState = EConvState::Player; 
			GetWorld()->GetTimerManager().SetTimer(StreamHandle, this, &UNPCConversationWidget::StreamText, StreamDeltaTime, true);
		}
	}
	else
	{
		Txt_Conversation->SetText(FText::FromString(NewConversation));
		CurConvState = EConvState::None;
	}
}

void UNPCConversationWidget::StreamText()
{
	if(CurConvState != EConvState::Wait && CurLen <= CurText.Len())
	{
		Txt_Conversation->SetText(FText::FromString(CurText.Mid(0, CurLen)));
		++CurLen;
		return;
	}
	
    // 한글자씩 출력이 완료되면 MaxWaitTime만큼 대기
	CurWaitTime += StreamDeltaTime;
	if (CurWaitTime < MaxWaitTime)
	{
		return;
	}

	CurWaitTime = 0;

	switch(CurConvState)
	{
	case EConvState::Player: // STT 결과 출력 완료 시, 답변 고민 텍스트 노출 후 대기
		CurConvState = EConvState::Wait;
		Txt_Conversation->SetText(FText::FromString(FString::Printf(TEXT("%s(이)가 답변을 고민중입니다."), *MyGameMode->GetCurNPC()->GetNPCName() )));
		break;
	case EConvState::Wait: // 대기 후 챗봇 답변을 CurText로 설정하고 한글자씩 출력 준비 
		CurConvState = EConvState::NPC;
		MyGameMode->PlayNPCEmotion();
		CurText = NextText;
		NextText = TEXT("");
		CurLen = 1;
		break;
	case EConvState::NPC: // 챗봇 답변까지 모두 출력 완료했다면 타이머 종료
		CurConvState = EConvState::None;
		GetWorld()->GetTimerManager().ClearTimer(StreamHandle);
		break;
	default:
		break;
	}
}

 

 

 

2. 페르소나 생성 자동화

 NPC의 페르소나를 생성하는 파이썬 코드는 팀원분께서 제공해주셨습니다. 다만 복사 붙여넣기가 조금 필요했고, 생성된 페르소나가 맘에 안들어 여러번 반복하려다보니 약간 불편하여 파일 생성까지 자동으로 되도록 수정해보았습니다.

### 팀원분 작업 코드 
### npc 페르소나 및 호감도에 따른 답변 예시를 반환받기 위한 프롬프트



####################### 만들고자 하는 npc 컨셉 적기 #######################
npc_name = "이춘식"
persona = f"말수가 적고 무뚝뚝하며, 질문에 반말로 답변하며 이름이 {npc_name}인 중년 대장장이 아저씨 npc의 페르소나를 만드시오."
#########################################################################

####################### persona 파일 만들기 #######################
p_begin = str(completion.choices[0].message).find('=') + 2
p_end = str(completion.choices[0].message).find('role=') -3
p_result = str(completion.choices[0].message)[p_begin:p_end]

persona_path = f'./Personas/persona_{npc_name}.txt'

with open(persona_path, 'w', encoding='UTF-8-sig') as file:
    file.write(p_result)
    print(f'persona_{npc_name}.txt 생성 완료')
###################################################################

###################### 대화 예시 파일 만들기 ######################
low = []
middle = []
high = []

idx = 0
while idx < len(result_text):
    if result_text[idx] != '\"':
        idx += 1
        continue
    # 대화 긁어오기
    begin, end = idx, idx + 1
    while result_text[end] != '\"':
        end += 1
    text = result_text[begin:end + 1]
    if len(low) < 5: 
        low.append(text)
    elif len(middle) < 5: 
        middle.append(text)
    else:
        high.append(text)
        if len(high) == 5:
            break
    
    idx = end + 1

template = f"""{{
    "호감도 낮은 경우 (낯선 사람 또는 처음 만난 사람)": [
    {low[0]},
    {low[1]},
    {low[2]},
    {low[3]},
    {low[4]}
    ],
    "호감도 중간 (친해지고 있는 이웃 또는 지인)": [
    {middle[0]},
    {middle[1]},
    {middle[2]},
    {middle[3]},
    {middle[4]}
    ],
    "호감도 높은 경우 (친한 친구 또는 가까운 사람)": [
    {high[0]},
    {high[1]},
    {high[2]},
    {high[3]},
    {high[4]}
    ],
}}"""

sentence_path = f'./Personas/sentence_{npc_name}.json'

with open(sentence_path, 'w', encoding='UTF-8-sig') as file:
    file.write(template)
    print(f'sentence_{npc_name}.json 생성 완료')

print('페르소나 생성 완료')
###################################################################

 

 NPC 이름을 미리 지정하고 페르소나를 생성하도록 했으며, 답변 결과에서 앞뒤로 문자를 좀 잘라낸 뒤 txt 파일로 저장하도록 했습니다. 또한 대화 예시 파일의 경우 기존에 팀원분이 제작하신 답변 예시를 참조하여, 해당 파일의 양식을 템플릿으로 만들어 똑같은 방식으로 저장하도록 했습니다. 예상 답변은 결과 텍스트에서 큰따옴표(")를 찾은 뒤, 다음 큰따옴표까지 이동하여 해당 구간안의 텍스트를 저장하도록 했습니다. 호감도가 낮은 경우부터 높은 경우까지 총 5개씩이므로 리스트에 저장하다가 5개가 차면 다음 리스트에 저장하고, 모두 완료되면 json 파일로 저장하도록 했습니다. 

 

 참고로 encoding= 설정을 빼먹어서 json 파일을 읽지 못하는 문제가 있었습니다. 파이썬에서 파일 쓰기를 사용할때는 꼭 인코딩 옵션을 주의해야겠습니다.