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
52 changes: 52 additions & 0 deletions scripts/inspect-retrospective-v2.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
-- :retrospective_id를 조회할 회고의 UUID 문자열로 바꾼 뒤 실행합니다.
SET @retrospective_id = UUID_TO_BIN(:retrospective_id);

SELECT
BIN_TO_UUID(id) AS retrospective_id,
flow_version,
status,
conversation_status,
conversation_finished_at,
input_tokens,
output_tokens
FROM retrospectives
WHERE id = @retrospective_id;

SELECT
BIN_TO_UUID(id) AS message_id,
sender,
message_type,
question_type,
content,
supporting_content,
relevance,
included_in_result,
created_at
FROM chat_messages
WHERE retrospective_id = @retrospective_id
ORDER BY created_at, id;

SELECT
turn_number,
BIN_TO_UUID(client_message_id) AS client_message_id,
status,
attempt_count,
question_target,
error_code,
input_tokens,
output_tokens,
completed_at
FROM retrospective_conversation_turns
WHERE retrospective_id = @retrospective_id
ORDER BY turn_number;

SELECT
item_type,
status,
summary,
GROUP_CONCAT(BIN_TO_UUID(e.message_id) ORDER BY e.created_at) AS evidence_message_ids
FROM retrospective_analysis_items i
LEFT JOIN retrospective_analysis_evidences e ON e.analysis_item_id = i.id
WHERE i.retrospective_id = @retrospective_id
GROUP BY i.id, i.item_type, i.status, i.summary
ORDER BY i.item_type;
62 changes: 62 additions & 0 deletions scripts/test-retrospective-v2.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
param(
[Parameter(Mandatory = $true)]
[string]$AccessToken,

[Parameter(Mandatory = $true)]
[string]$BaseUrl,

[string[]]$Messages = @(
"오늘 배포 자동화 작업을 마쳤는데 예상보다 설정 충돌이 많았습니다.",
"로그를 단계별로 확인해서 환경 변수 우선순위가 원인이라는 걸 찾았습니다."
),

[switch]$Finish
)

$ErrorActionPreference = "Stop"
$headers = @{ Authorization = "Bearer $AccessToken" }
$jsonHeaders = $headers + @{ "Content-Type" = "application/json" }

function Write-Response {
param(
[string]$Label,
[object]$Response
)

Write-Host "`n[$Label]"
$Response | ConvertTo-Json -Depth 10
}

$started = Invoke-RestMethod -Method Post -Uri "$BaseUrl/api/v2/retrospectives" -Headers $headers
$retrospectiveId = $started.data.retrospectiveId
Write-Response -Label "START $retrospectiveId" -Response $started

foreach ($message in $Messages) {
$body = @{
clientMessageId = [guid]::NewGuid().ToString()
content = $message
} | ConvertTo-Json

$reply = Invoke-RestMethod `
-Method Post `
-Uri "$BaseUrl/api/v2/retrospectives/$retrospectiveId/messages" `
-Headers $jsonHeaders `
-Body $body
Write-Response -Label "MESSAGE" -Response $reply
}

$conversation = Invoke-RestMethod `
-Method Get `
-Uri "$BaseUrl/api/v2/retrospectives/$retrospectiveId/conversation" `
-Headers $headers
Write-Response -Label "CONVERSATION" -Response $conversation

if ($Finish) {
$finished = Invoke-RestMethod `
-Method Post `
-Uri "$BaseUrl/api/v2/retrospectives/$retrospectiveId/finish" `
-Headers $headers
Write-Response -Label "FINISH" -Response $finished
}

Write-Host "`nRetrospective ID: $retrospectiveId"
3 changes: 2 additions & 1 deletion src/docs/asciidoc/app/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ include::notification.adoc[]
include::auth.adoc[]
include::user.adoc[]
include::retrospect.adoc[]
include::retrospect-v2-conversation.adoc[]
include::badge.adoc[]
include::mission.adoc[]
include::notice.adoc[]
include::inquiry.adoc[]
include::project.adoc[]
include::tag.adoc[]
include::tag.adoc[]
59 changes: 59 additions & 0 deletions src/docs/asciidoc/app/retrospect-v2-conversation.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
= 회고 V2 대화 API

회고 V2는 고정 질문 순서 없이 사용자의 업무 경험을 바탕으로 AI가 후속 질문 하나를 생성합니다.
첫 안내 메시지만 `title`과 `body`를 사용하며, 이후 대화 메시지는 `content`만 사용합니다.
`readyToComplete`는 내부 완료 준비도이며 현재 화면의 종료 노출 조건으로 사용하지 않습니다.

== 대화 시작

=== Request

include::{snippets}/retrospect-v2/start/http-request.adoc[]

=== Response

include::{snippets}/retrospect-v2/start/http-response.adoc[]
include::{snippets}/retrospect-v2/start/response-fields.adoc[]

== 메시지 전송

동일한 `clientMessageId`와 내용으로 재전송하면 이미 완료된 응답을 반환합니다.

=== Request

include::{snippets}/retrospect-v2/submit-message/http-request.adoc[]
include::{snippets}/retrospect-v2/submit-message/path-parameters.adoc[]
include::{snippets}/retrospect-v2/submit-message/request-fields.adoc[]

=== Response

include::{snippets}/retrospect-v2/submit-message/http-response.adoc[]
include::{snippets}/retrospect-v2/submit-message/response-fields.adoc[]

== 대화 조회

앱 재진입이나 AI 응답 실패 후 현재 메시지와 턴 상태를 복구할 때 사용합니다.

=== Request

include::{snippets}/retrospect-v2/get-conversation/http-request.adoc[]
include::{snippets}/retrospect-v2/get-conversation/path-parameters.adoc[]

=== Response

include::{snippets}/retrospect-v2/get-conversation/http-response.adoc[]
include::{snippets}/retrospect-v2/get-conversation/response-fields.adoc[]

== 대화 종료

대화 종료와 회고 결과 생성은 분리됩니다. 이 API는 대화만 종료하고 결과 생성 상태는 `NOT_STARTED`로 반환합니다.

=== Request

include::{snippets}/retrospect-v2/finish/http-request.adoc[]
include::{snippets}/retrospect-v2/finish/path-parameters.adoc[]

=== Response

include::{snippets}/retrospect-v2/finish/http-response.adoc[]
include::{snippets}/retrospect-v2/finish/response-fields.adoc[]
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.didit.adapter.integration.ai

import com.didit.application.prompt.required.PromptRepository
import com.didit.application.retrospect.required.ConversationTurnAIRequest
import com.didit.domain.prompt.PromptJobType
import com.didit.domain.prompt.PromptType
import com.didit.domain.shared.Job
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.core.io.ClassPathResource
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional

@Component
class ConversationV2Prompts(
private val promptRepository: PromptRepository,
private val objectMapper: ObjectMapper,
) {
@Transactional(readOnly = true)
fun build(request: ConversationTurnAIRequest): String {
val jobType = request.job.toPromptJobType()
val template =
promptRepository.findByJobTypeAndPromptType(jobType, PromptType.CONVERSATION_V2)?.content
?: ClassPathResource("prompts/conversation-v2.txt").inputStream.bufferedReader().readText()
return template.replace("{{context}}", objectMapper.writeValueAsString(request))
}

private fun Job?.toPromptJobType(): PromptJobType =
when (this) {
Job.PLANNER -> PromptJobType.PLANNER
Job.DESIGNER -> PromptJobType.DESIGNER
Job.DEVELOPER, null -> PromptJobType.DEVELOPER
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ class FeedbackPrompts(
when (this) {
PromptType.DEEP_QUESTION -> "deep-question"
PromptType.SUMMARY -> "summary"
PromptType.CONVERSATION_V2 -> "conversation-v2"
}

private fun Job.toPromptJobType() =
Expand Down
94 changes: 93 additions & 1 deletion src/main/kotlin/com/didit/adapter/integration/ai/OpenAiClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ package com.didit.adapter.integration.ai

import com.didit.application.retrospect.dto.AISummaryResponse
import com.didit.application.retrospect.required.AIClient
import com.didit.application.retrospect.required.ConversationAnalysisUpdate
import com.didit.application.retrospect.required.ConversationTurnAIRequest
import com.didit.application.retrospect.required.ConversationV2AIClient
import com.didit.application.retrospect.required.GeneratedConversationTurn
import com.didit.application.retrospect.required.GeneratedDeepQuestion
import com.didit.domain.retrospect.MessageRelevance
import com.didit.domain.retrospect.RetrospectiveItemType
import com.didit.domain.shared.Job
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
Expand All @@ -21,10 +27,12 @@ class OpenAiClient(
private val restClient: RestClient,
private val objectMapper: ObjectMapper,
private val feedbackPrompts: FeedbackPrompts,
private val conversationV2Prompts: ConversationV2Prompts,
private val metrics: OpenAiMetrics,
@param:Value("\${openai.api-key}") private val apiKey: String,
@param:Value("\${openai.chat.model}") private val model: String,
) : AIClient {
) : AIClient,
ConversationV2AIClient {
companion object {
private const val URL = "https://api.openai.com/v1/responses"
private val logger = LoggerFactory.getLogger(OpenAiClient::class.java)
Expand Down Expand Up @@ -58,6 +66,12 @@ class OpenAiClient(
return parseSummary(result)
}

override fun generateConversationTurn(request: ConversationTurnAIRequest): GeneratedConversationTurn {
val prompt = conversationV2Prompts.build(request)
val result = callWithResult(prompt, "conversation_v2", "retrospective_conversation_turn", conversationV2Schema())
return parseConversationTurn(result)
}

private fun callWithResult(
prompt: String,
operation: String,
Expand Down Expand Up @@ -140,6 +154,20 @@ class OpenAiClient(
throw RuntimeException("회고 요약 파싱에 실패했습니다. response: ${response.outputText}")
}

private fun parseConversationTurn(response: OpenAiResponse): GeneratedConversationTurn {
val parsed = objectMapper.readValue<ConversationTurnDto>(response.outputText)
return GeneratedConversationTurn(
acknowledgement = parsed.acknowledgement,
interpretation = parsed.interpretation,
question = parsed.question,
questionTarget = parsed.questionTarget,
relevance = parsed.relevance,
analysisUpdates = parsed.analysisUpdates,
inputTokens = response.usage?.inputTokens ?: 0,
outputTokens = response.usage?.outputTokens ?: 0,
)
}

private fun deepQuestionSchema() =
mapOf(
"type" to "object",
Expand Down Expand Up @@ -198,12 +226,76 @@ class OpenAiClient(
"required" to listOf("title", "description"),
"additionalProperties" to false,
)

private fun conversationV2Schema() =
mapOf(
"type" to "object",
"properties" to
mapOf(
"acknowledgement" to mapOf("type" to "string"),
"interpretation" to mapOf("type" to "string"),
"question" to mapOf("type" to "string"),
"questionTarget" to
mapOf(
"type" to listOf("string", "null"),
"enum" to RetrospectiveItemType.entries.map { it.name } + null,
),
"relevance" to mapOf("type" to "string", "enum" to MessageRelevance.entries.map { it.name }),
"analysisUpdates" to
mapOf(
"type" to "array",
"items" to
mapOf(
"type" to "object",
"properties" to
mapOf(
"itemType" to
mapOf("type" to "string", "enum" to RetrospectiveItemType.entries.map { it.name }),
"status" to
mapOf(
"type" to "string",
"enum" to
com.didit.domain.retrospect.RetrospectiveItemStatus.entries
.map { it.name },
),
"summary" to mapOf("type" to "string"),
"evidenceMessageIds" to
mapOf(
"type" to "array",
"items" to mapOf("type" to "string", "format" to "uuid"),
),
),
"required" to listOf("itemType", "status", "summary", "evidenceMessageIds"),
"additionalProperties" to false,
),
),
),
"required" to
listOf(
"acknowledgement",
"interpretation",
"question",
"questionTarget",
"relevance",
"analysisUpdates",
),
"additionalProperties" to false,
)
}

private data class DeepQuestionDto(
val question: String,
)

private data class ConversationTurnDto(
val acknowledgement: String,
val interpretation: String,
val question: String,
val questionTarget: RetrospectiveItemType?,
val relevance: MessageRelevance,
val analysisUpdates: List<ConversationAnalysisUpdate>,
)

private data class OpenAiRequest(
val model: String,
val instructions: String,
Expand Down
Loading
Loading