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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ q-asker/api/
│ ├── quiz-make/(api + impl) # 퀴즈 생성 흐름 (파일업로드, SSE, 생성결과)
│ ├── quiz-set/ (api + impl) # 퀴즈 세트 CRUD
│ ├── quiz-history/(api + impl) # 풀이 히스토리
│ └── util/ (api + impl) # 유틸리티 (문서변환)
│ ├── document/ (api + impl) # 문서 변환 (PPT/DOCX → PDF)
│ └── admin/ # 관리자 전용 API
├── infra/
│ ├── monitoring/ # Grafana Alloy 설정
│ ├── mysql/ # MySQL Docker 설정
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ private static String remapText(String text, List<Integer> sourcePages) {

if (index >= 0 && index < sourcePages.size()) {
// [원본p] > 형태로 교체하여 추가
log.info("페이지 교체 수행-슬라이싱: {}p, 원본: {}p", index, sourcePages.get(index));
sb.append("[").append(sourcePages.get(index)).append("p] >");
} else {
// 범위를 벗어나면 원본 매칭 텍스트(예: [1p] >) 그대로 유지
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.icc.qasker.ai.service.QuizTypeOrchestrator;
import com.icc.qasker.ai.service.support.GeminiMetricsRecorder;
import com.icc.qasker.ai.service.support.StreamingQuestionExtractor;
import com.icc.qasker.ai.structure.GeminiResponseSchema;
import com.icc.qasker.global.error.CustomException;
import java.net.URI;
import java.util.List;
Expand All @@ -23,7 +24,6 @@
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.content.Media;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.ai.google.genai.GoogleGenAiChatOptions;
import org.springframework.ai.google.genai.metadata.GoogleGenAiUsage;
import org.springframework.stereotype.Component;
Expand All @@ -40,8 +40,6 @@
public class BlankQuizOrchestrator implements QuizTypeOrchestrator {

private static final int MAX_SELECTION_COUNT = 4;
private static final String RESPONSE_JSON_SCHEMA =
new BeanOutputConverter<>(com.icc.qasker.ai.structure.GeminiResponse.class).getJsonSchema();

private final GeminiFileService geminiFileService;
private final ChatModel chatModel;
Expand Down Expand Up @@ -96,10 +94,11 @@ public int generateQuiz(GenerationRequestToAI request) {
new Media(MimeTypeUtils.parseMimeType("application/pdf"), URI.create(metadata.uri()));
UserMessage userMessage = UserMessage.builder().text(userPrompt).media(pdfMedia).build();

String responseSchema = GeminiResponseSchema.forInstruction(request.customInstruction());
var options =
GoogleGenAiChatOptions.builder()
.responseMimeType("application/json")
.responseSchema(RESPONSE_JSON_SCHEMA)
.responseSchema(responseSchema)
.build();

Prompt prompt = new Prompt(List.of(systemMessage, userMessage), options);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.icc.qasker.ai.service.QuizTypeOrchestrator;
import com.icc.qasker.ai.service.support.GeminiMetricsRecorder;
import com.icc.qasker.ai.service.support.StreamingQuestionExtractor;
import com.icc.qasker.ai.structure.GeminiResponseSchema;
import com.icc.qasker.global.error.CustomException;
import java.net.URI;
import java.util.List;
Expand All @@ -23,7 +24,6 @@
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.content.Media;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.ai.google.genai.GoogleGenAiChatOptions;
import org.springframework.ai.google.genai.metadata.GoogleGenAiUsage;
import org.springframework.stereotype.Component;
Expand All @@ -40,8 +40,6 @@
public class MultipleQuizOrchestrator implements QuizTypeOrchestrator {

private static final int MAX_SELECTION_COUNT = 4;
private static final String RESPONSE_JSON_SCHEMA =
new BeanOutputConverter<>(com.icc.qasker.ai.structure.GeminiResponse.class).getJsonSchema();

private final GeminiFileService geminiFileService;
private final ChatModel chatModel;
Expand Down Expand Up @@ -96,10 +94,11 @@ public int generateQuiz(GenerationRequestToAI request) {
var userMessage = UserMessage.builder().text(userPrompt).media(pdfMedia).build();
var systemMessage = new SystemMessage(systemPrompt);

String responseSchema = GeminiResponseSchema.forInstruction(request.customInstruction());
var options =
GoogleGenAiChatOptions.builder()
.responseMimeType("application/json")
.responseSchema(RESPONSE_JSON_SCHEMA)
.responseSchema(responseSchema)
.build();

Prompt prompt = new Prompt(List.of(systemMessage, userMessage), options);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ public class MultipleGuideLine {
- 타겟 분할: 논리적 오류를 발생시키는 2개의 상호 배타적/대칭적 변수나 연산 방향(예: left 조작 vs right 조작, 증가 vs 감소)을 타겟으로 삼는다.
- 구성: [방향 A(예: left)를 수정하는 선지 2개] + [방향 B(예: right)를 수정하는 선지 2개]
**문제 본문 및 선택지 예시**
- content: 다음은 정렬된 배열에서 목표 값을 찾는 이진 탐색 코드이다.\\n\\n```python\\ndef binary_search(arr, target):\\n left, right = 0, len(arr) - 1\\n while left <= right:\\n mid = (left + right) // 2\\n if arr[mid] == target:\\n return mid\\n elif arr[mid] < target:\\n left = mid # ← 이 줄에 주목\\n else:\\n right = mid - 1\\n return -1\\n```\\n\\n위 코드에서 **논리적 오류**를 찾아 수정한 것으로 가장 적절한 것은?
- content: 다음은 정렬된 배열에서 목표 값을 찾는 이진 탐색 코드이다.\\n\\n```python\\ndef binary_search(arr, target):\\n left, right = 0, len(arr) - 1\\n while left <= right:\\n mid = (left + right) // 2\\n if arr[mid] == target:\\n return mid\\n elif arr[mid] < target:\\n left = mid\\n else:\\n right = mid - 1\\n return -1\\n```\\n\\n위 코드에서 **논리적 오류**를 찾아 수정한 것으로 가장 적절한 것은?
- selection.content 1 (정답): `left = mid`를 `left = mid + 1`로 수정한다 — mid는 정답이 될 수 없으므로 탐색 범위에서 제외해야 한다
- selection.content 2 (오답): `left = mid`를 `left = mid - 1`로 수정한다 — 탐색 방향을 왼쪽으로 역전시켜 누락된 값을 재탐색해야 한다
- selection.content 3 (오답): `right = mid - 1`을 `right = mid`로 수정한다 — mid 값이 정답일 가능성을 대비해 탐색 범위에 남겨두어야 한다
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.icc.qasker.ai.service.QuizTypeOrchestrator;
import com.icc.qasker.ai.service.support.GeminiMetricsRecorder;
import com.icc.qasker.ai.service.support.StreamingQuestionExtractor;
import com.icc.qasker.ai.structure.GeminiResponseSchema;
import com.icc.qasker.global.error.CustomException;
import java.net.URI;
import java.util.List;
Expand All @@ -22,7 +23,6 @@
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.content.Media;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.ai.google.genai.GoogleGenAiChatOptions;
import org.springframework.ai.google.genai.metadata.GoogleGenAiUsage;
import org.springframework.stereotype.Component;
Expand All @@ -39,8 +39,6 @@
public class OXQuizOrchestrator implements QuizTypeOrchestrator {

private static final int MAX_SELECTION_COUNT = 2;
private static final String RESPONSE_JSON_SCHEMA =
new BeanOutputConverter<>(com.icc.qasker.ai.structure.GeminiResponse.class).getJsonSchema();

private final GeminiFileService geminiFileService;
private final ChatModel chatModel;
Expand Down Expand Up @@ -95,10 +93,11 @@ public int generateQuiz(GenerationRequestToAI request) {
new Media(MimeTypeUtils.parseMimeType("application/pdf"), URI.create(metadata.uri()));
UserMessage userMessage = UserMessage.builder().text(userPrompt).media(pdfMedia).build();

String responseSchema = GeminiResponseSchema.forInstruction(request.customInstruction());
var options =
GoogleGenAiChatOptions.builder()
.responseMimeType("application/json")
.responseSchema(RESPONSE_JSON_SCHEMA)
.responseSchema(responseSchema)
.build();

Prompt prompt = new Prompt(List.of(systemMessage, userMessage), options);
Expand Down Expand Up @@ -133,14 +132,13 @@ public int generateQuiz(GenerationRequestToAI request) {
stream
.doOnNext(
response -> {
if (response.getResult() != null
&& response.getResult().getOutput() != null
response.getResult();
if (response.getResult().getOutput() != null
&& response.getResult().getOutput().getText() != null) {
extractor.feed(response.getResult().getOutput().getText());
}

if (response.getMetadata() != null
&& response.getMetadata().getUsage() != null
if (response.getMetadata().getUsage() != null
&& response.getMetadata().getUsage().getCompletionTokens() > 0) {
var usage = response.getMetadata().getUsage();
long elapsedMs = (System.nanoTime() - startNanos) / 1_000_000;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
import com.icc.qasker.ai.dto.ChunkInfo;
import com.icc.qasker.ai.prompt.strategy.QuizType;
import com.icc.qasker.ai.structure.GeminiResponse;
import com.icc.qasker.ai.structure.GeminiResponseSchema;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.ai.google.genai.GoogleGenAiChatOptions;
import org.springframework.ai.google.genai.common.GoogleGenAiThinkingLevel;
import org.springframework.ai.google.genai.metadata.GoogleGenAiUsage;
Expand All @@ -26,9 +26,6 @@ public class GeminiChatService {
/** 청크 처리 결과: 파싱된 응답 + 추정 비용 */
public record ParsedResult(GeminiResponse response, double cost) {}

private static final String RESPONSE_JSON_SCHEMA =
new BeanOutputConverter<>(GeminiResponse.class).getJsonSchema();

private final ChatModel chatModel;
private final ObjectMapper objectMapper;
private final GeminiMetricsRecorder metricsRecorder;
Expand Down Expand Up @@ -74,12 +71,13 @@ public ParsedResult callAndParse(

String userPrompt = quizType.generateRequestPrompt(pages, chunk.quizCount(), planExtra);

String responseSchema = GeminiResponseSchema.forInstruction(planExtra);
var optionsBuilder =
GoogleGenAiChatOptions.builder()
.useCachedContent(true)
.cachedContentName(cacheName)
.responseMimeType("application/json")
.responseSchema(RESPONSE_JSON_SCHEMA);
.responseSchema(responseSchema);
if (thinkingLevel != null) {
optionsBuilder.thinkingLevel(thinkingLevel);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.icc.qasker.ai.structure;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.springframework.ai.converter.BeanOutputConverter;

/**
* customInstruction 유무에 따라 적절한 JSON 스키마를 제공한다.
*
* <p>지시사항이 없으면 appliedInstruction 필드를 스키마에서 제외하여 Gemini가 불필요한 값을 생성하지 않도록 한다. GeminiResponse 스키마
* 하나에서 파생하므로 필드 정의 중복이 없다.
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class GeminiResponseSchema {

/** appliedInstruction 포함 스키마 (사용자 지시사항 있을 때) */
private static final String WITH_INSTRUCTION =
new BeanOutputConverter<>(GeminiResponse.class).getJsonSchema();

/** appliedInstruction 제외 스키마 (사용자 지시사항 없을 때) */
private static final String WITHOUT_INSTRUCTION = stripAppliedInstruction(WITH_INSTRUCTION);

/** customInstruction 유무에 따라 적절한 스키마를 반환한다. */
public static String forInstruction(String customInstruction) {
if (customInstruction == null || customInstruction.isBlank()) {
return WITHOUT_INSTRUCTION;
}
return WITH_INSTRUCTION;
}

/** JSON 스키마에서 appliedInstruction 프로퍼티와 required 항목을 제거한다. */
private static String stripAppliedInstruction(String schema) {
try {
ObjectMapper om = new ObjectMapper();
JsonNode root = om.readTree(schema);
stripFieldRecursive(root, "appliedInstruction");
return om.writeValueAsString(root);
} catch (JsonProcessingException e) {
// 스키마 조작 실패 시 원본 반환 (안전 폴백)
return schema;
}
}

/** 모든 $defs와 properties를 재귀 탐색하여 대상 필드를 제거한다. */
private static void stripFieldRecursive(JsonNode node, String fieldName) {
if (!node.isObject()) return;

ObjectNode obj = (ObjectNode) node;

// properties에서 필드 제거
if (obj.has("properties") && obj.get("properties").has(fieldName)) {
((ObjectNode) obj.get("properties")).remove(fieldName);

// required 배열에서도 제거
if (obj.has("required") && obj.get("required").isArray()) {
ArrayNode required = (ArrayNode) obj.get("required");
ArrayNode filtered = required.arrayNode();
for (JsonNode item : required) {
if (!fieldName.equals(item.asText())) {
filtered.add(item);
}
}
obj.set("required", filtered);
}
}

// 하위 노드 재귀 탐색 ($defs, properties 내부 등)
obj.fields()
.forEachRemaining(
entry -> {
if (entry.getValue().isObject()) {
stripFieldRecursive(entry.getValue(), fieldName);
}
});
}
}
Loading