55
This commit is contained in:
@@ -1 +1 @@
|
|||||||
d098e6fd836c808a8a51f1d4f66e26d9
|
19019f00457c1fd7dea1654686ad406d
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
595353d17d943f79f25c683007ba302c
|
b4345212d1427ec0e476a5f95c172c6e
|
||||||
|
|||||||
@@ -1,2 +1,29 @@
|
|||||||
# opic
|
# opic
|
||||||
|
|
||||||
|
Wails 3 + Vue TypeScript 기반 AI OPIc 말하기 연습 앱입니다.
|
||||||
|
|
||||||
|
## AI 공급자
|
||||||
|
|
||||||
|
설정에서 OpenAI 또는 Google Gemini를 선택할 수 있습니다. 키는 앱 프로세스 메모리에만 보관됩니다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:OPENAI_API_KEY="sk-..."
|
||||||
|
$env:GEMINI_API_KEY="AIza..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Gemini는 평가, 음성 전사, 질문 TTS에 사용할 모델을 각각 여러 개 지정할 수 있습니다. 위에서부터 호출하며 HTTP 오류, 기능 미지원, 빈 응답, 잘못된 평가 JSON, 전사 또는 오디오 누락 시 다음 모델을 자동으로 시도합니다.
|
||||||
|
|
||||||
|
기본 Gemini 우선순위:
|
||||||
|
|
||||||
|
- 평가·전사: `gemini-3.5-flash` → `gemini-2.5-flash` → `gemini-2.5-flash-lite`
|
||||||
|
- TTS: `gemini-3.1-flash-tts-preview` → `gemini-2.5-flash-preview-tts`
|
||||||
|
|
||||||
|
## 빌드
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
cd ..
|
||||||
|
wails3 generate bindings -clean -ts -i
|
||||||
|
wails3 build
|
||||||
|
```
|
||||||
|
|||||||
+2
-2
@@ -85,7 +85,8 @@ Realtime 모델이 대화를 자연스럽게 진행하더라도 시험 문항
|
|||||||
## 7. 인증과 보안
|
## 7. 인증과 보안
|
||||||
|
|
||||||
- ChatGPT 웹 로그인/구독은 API 인증으로 재사용하지 않는다.
|
- ChatGPT 웹 로그인/구독은 API 인증으로 재사용하지 않는다.
|
||||||
- 개인용 MVP: 사용자가 Platform API 키를 입력하며 메모리에만 유지한다.
|
- 개인용 MVP: 사용자가 OpenAI Platform 또는 Google Gemini API 키를 입력하며 메모리에만 유지한다.
|
||||||
|
- Gemini는 기능별 모델 우선순위를 두고 오류 또는 검증 불가능한 응답이면 다음 모델로 폴백한다.
|
||||||
- 배포용: 앱에 공용 API 키를 포함하지 않는다. 자체 백엔드에서 사용자 로그인, 사용량 제한, ephemeral Realtime 인증을 처리한다.
|
- 배포용: 앱에 공용 API 키를 포함하지 않는다. 자체 백엔드에서 사용자 로그인, 사용량 제한, ephemeral Realtime 인증을 처리한다.
|
||||||
- 장기 로컬 저장이 필요하면 Windows Credential Manager/macOS Keychain/libsecret을 사용한다.
|
- 장기 로컬 저장이 필요하면 Windows Credential Manager/macOS Keychain/libsecret을 사용한다.
|
||||||
- 오디오는 기본적으로 평가 후 폐기하고, 사용자가 명시적으로 저장을 선택한 경우에만 로컬 암호화 저장한다.
|
- 오디오는 기본적으로 평가 후 폐기하고, 사용자가 명시적으로 저장을 선택한 경우에만 로컬 암호화 저장한다.
|
||||||
@@ -129,4 +130,3 @@ Realtime 모델이 대화를 자연스럽게 진행하더라도 시험 문항
|
|||||||
- 25MB 오디오 업로드 제한 전 사전 차단
|
- 25MB 오디오 업로드 제한 전 사전 차단
|
||||||
- 세션 완료 전 보고서 접근 차단
|
- 세션 완료 전 보고서 접근 차단
|
||||||
- 난이도별 대표 transcript를 사용한 평가 회귀 테스트
|
- 난이도별 대표 transcript를 사용한 평가 회귀 테스트
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ export function Configure(request: $models.ConfigureRequest): $CancellablePromis
|
|||||||
return $Call.ByID(1065098514, request);
|
return $Call.ByID(1065098514, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FinalizeSession(sessionID: string): $CancellablePromise<$models.ExamReport> {
|
||||||
|
return $Call.ByID(3685135372, sessionID);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GenerateSpeech(text: string): $CancellablePromise<$models.SpeechResponse> {
|
||||||
|
return $Call.ByID(2776553183, text);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetReport(sessionID: string): $CancellablePromise<$models.ExamReport> {
|
export function GetReport(sessionID: string): $CancellablePromise<$models.ExamReport> {
|
||||||
return $Call.ByID(704064164, sessionID);
|
return $Call.ByID(704064164, sessionID);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ export type {
|
|||||||
ConfigureRequest,
|
ConfigureRequest,
|
||||||
ExamConfig,
|
ExamConfig,
|
||||||
ExamReport,
|
ExamReport,
|
||||||
|
GradePrediction,
|
||||||
OverallReport,
|
OverallReport,
|
||||||
Question,
|
Question,
|
||||||
|
SpeechResponse,
|
||||||
StartSessionResponse,
|
StartSessionResponse,
|
||||||
SubmitAnswerRequest,
|
SubmitAnswerRequest,
|
||||||
SubmitAnswerResponse
|
SubmitAnswerResponse
|
||||||
|
|||||||
@@ -23,19 +23,36 @@ export interface AnswerRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
|
"provider": string;
|
||||||
"hasApiKey": boolean;
|
"hasApiKey": boolean;
|
||||||
|
"hasOpenAIKey": boolean;
|
||||||
|
"hasGeminiKey": boolean;
|
||||||
"demoMode": boolean;
|
"demoMode": boolean;
|
||||||
"evaluationModel": string;
|
"evaluationModel": string;
|
||||||
"transcribeModel": string;
|
"transcribeModel": string;
|
||||||
"realtimeModel": string;
|
"realtimeModel": string;
|
||||||
|
"speechModel": string;
|
||||||
|
"speechVoice": string;
|
||||||
|
"geminiEvaluationModels": string[] | null;
|
||||||
|
"geminiTranscriptionModels": string[] | null;
|
||||||
|
"geminiSpeechModels": string[] | null;
|
||||||
|
"geminiSpeechVoice": string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigureRequest {
|
export interface ConfigureRequest {
|
||||||
|
"provider": string;
|
||||||
"apiKey": string;
|
"apiKey": string;
|
||||||
|
"geminiApiKey": string;
|
||||||
"demoMode": boolean;
|
"demoMode": boolean;
|
||||||
"evaluationModel": string;
|
"evaluationModel": string;
|
||||||
"transcribeModel": string;
|
"transcribeModel": string;
|
||||||
"realtimeModel": string;
|
"realtimeModel": string;
|
||||||
|
"speechModel": string;
|
||||||
|
"speechVoice": string;
|
||||||
|
"geminiEvaluationModels": string[] | null;
|
||||||
|
"geminiTranscriptionModels": string[] | null;
|
||||||
|
"geminiSpeechModels": string[] | null;
|
||||||
|
"geminiSpeechVoice": string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExamConfig {
|
export interface ExamConfig {
|
||||||
@@ -49,15 +66,30 @@ export interface ExamReport {
|
|||||||
"config": ExamConfig;
|
"config": ExamConfig;
|
||||||
"overall": OverallReport;
|
"overall": OverallReport;
|
||||||
"answers": AnswerRecord[] | null;
|
"answers": AnswerRecord[] | null;
|
||||||
|
"answeredCount": number;
|
||||||
|
"totalCount": number;
|
||||||
|
"partial": boolean;
|
||||||
"generatedAt": time$0.Time;
|
"generatedAt": time$0.Time;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GradePrediction {
|
||||||
|
"grade": string;
|
||||||
|
"probability": number;
|
||||||
|
"status": string;
|
||||||
|
"description": string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OverallReport {
|
export interface OverallReport {
|
||||||
"score": number;
|
"score": number;
|
||||||
"estimatedBand": string;
|
"estimatedBand": string;
|
||||||
"summary": string;
|
"summary": string;
|
||||||
"strengths": string[] | null;
|
"strengths": string[] | null;
|
||||||
|
"weaknesses": string[] | null;
|
||||||
"priorities": string[] | null;
|
"priorities": string[] | null;
|
||||||
|
"targetGrade": string;
|
||||||
|
"targetStatus": string;
|
||||||
|
"targetProbability": number;
|
||||||
|
"gradePredictions": GradePrediction[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Question {
|
export interface Question {
|
||||||
@@ -68,6 +100,11 @@ export interface Question {
|
|||||||
"intent": string;
|
"intent": string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SpeechResponse {
|
||||||
|
"audioBase64": string;
|
||||||
|
"mimeType": string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StartSessionResponse {
|
export interface StartSessionResponse {
|
||||||
"sessionId": string;
|
"sessionId": string;
|
||||||
"question": Question;
|
"question": Question;
|
||||||
|
|||||||
+395
-45
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
import type { AppSettings, Evaluation, ExamReport, Question } from "./types";
|
import type { AnswerRecord, AppSettings, Evaluation, ExamReport, Question } from "./types";
|
||||||
|
|
||||||
type Screen = "setup" | "exam" | "report";
|
type Screen = "setup" | "exam" | "report";
|
||||||
|
|
||||||
@@ -16,13 +16,23 @@ const selectedDifficulty = ref("IM2");
|
|||||||
const selectedTopics = ref<string[]>(["영화", "여행", "카페"]);
|
const selectedTopics = ref<string[]>(["영화", "여행", "카페"]);
|
||||||
const settingsOpen = ref(false);
|
const settingsOpen = ref(false);
|
||||||
const settings = ref<AppSettings>({
|
const settings = ref<AppSettings>({
|
||||||
|
provider: "openai",
|
||||||
hasApiKey: false,
|
hasApiKey: false,
|
||||||
|
hasOpenAIKey: false,
|
||||||
|
hasGeminiKey: false,
|
||||||
demoMode: true,
|
demoMode: true,
|
||||||
evaluationModel: "gpt-5-mini",
|
evaluationModel: "gpt-5-mini",
|
||||||
transcribeModel: "gpt-4o-transcribe",
|
transcribeModel: "gpt-4o-transcribe",
|
||||||
realtimeModel: "gpt-realtime"
|
realtimeModel: "gpt-realtime",
|
||||||
|
speechModel: "gpt-4o-mini-tts",
|
||||||
|
speechVoice: "marin",
|
||||||
|
geminiEvaluationModels: ["gemini-3.5-flash", "gemini-2.5-flash", "gemini-2.5-flash-lite"],
|
||||||
|
geminiTranscriptionModels: ["gemini-3.5-flash", "gemini-2.5-flash", "gemini-2.5-flash-lite"],
|
||||||
|
geminiSpeechModels: ["gemini-3.1-flash-tts-preview", "gemini-2.5-flash-preview-tts"],
|
||||||
|
geminiSpeechVoice: "Kore"
|
||||||
});
|
});
|
||||||
const apiKey = ref("");
|
const openAIAPIKey = ref("");
|
||||||
|
const geminiAPIKey = ref("");
|
||||||
const connectionMessage = ref("");
|
const connectionMessage = ref("");
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
@@ -33,17 +43,50 @@ const totalCount = ref(15);
|
|||||||
const completedCount = ref(0);
|
const completedCount = ref(0);
|
||||||
const transcript = ref("");
|
const transcript = ref("");
|
||||||
const latestEvaluation = ref<Evaluation | null>(null);
|
const latestEvaluation = ref<Evaluation | null>(null);
|
||||||
|
const answeredRecords = ref<AnswerRecord[]>([]);
|
||||||
|
const pendingNextQuestion = ref<Question | null>(null);
|
||||||
const report = ref<ExamReport | null>(null);
|
const report = ref<ExamReport | null>(null);
|
||||||
const recording = ref(false);
|
const recording = ref(false);
|
||||||
|
const hasRecording = ref(false);
|
||||||
|
const autoSubmitting = ref(false);
|
||||||
|
const speaking = ref(false);
|
||||||
const elapsed = ref(0);
|
const elapsed = ref(0);
|
||||||
let timer: number | undefined;
|
let timer: number | undefined;
|
||||||
let recorder: MediaRecorder | null = null;
|
let recorder: MediaRecorder | null = null;
|
||||||
let stream: MediaStream | null = null;
|
let stream: MediaStream | null = null;
|
||||||
let chunks: Blob[] = [];
|
let chunks: Blob[] = [];
|
||||||
|
let recordedBlob: Blob | null = null;
|
||||||
|
let stopRecordingPromise: Promise<Blob | null> | null = null;
|
||||||
|
let resolveStopRecording: ((blob: Blob | null) => void) | null = null;
|
||||||
let englishVoice: SpeechSynthesisVoice | null = null;
|
let englishVoice: SpeechSynthesisVoice | null = null;
|
||||||
|
let questionAudio: HTMLAudioElement | null = null;
|
||||||
|
let questionAudioURL = "";
|
||||||
|
|
||||||
const progress = computed(() => Math.round((completedCount.value / totalCount.value) * 100));
|
const progress = computed(() => Math.round((completedCount.value / totalCount.value) * 100));
|
||||||
const topicLabel = computed(() => selectedTopics.value.join(" · "));
|
const topicLabel = computed(() => selectedTopics.value.join(" · "));
|
||||||
|
const currentAverage = computed(() => {
|
||||||
|
if (!answeredRecords.value.length) return 0;
|
||||||
|
const total = answeredRecords.value.reduce((sum, answer) => sum + answer.evaluation.score, 0);
|
||||||
|
return Math.round(total / answeredRecords.value.length);
|
||||||
|
});
|
||||||
|
const canRestartRecording = computed(() =>
|
||||||
|
(recording.value || hasRecording.value) && elapsed.value <= 20 && !busy.value
|
||||||
|
);
|
||||||
|
const remainingSeconds = computed(() => Math.max(0, 120 - elapsed.value));
|
||||||
|
const viewedAnswer = computed(() =>
|
||||||
|
answeredRecords.value.find((answer) => answer.question.index === currentQuestion.value?.index) ?? null
|
||||||
|
);
|
||||||
|
const isReviewingAnswer = computed(() => viewedAnswer.value !== null);
|
||||||
|
const canGoPrevious = computed(() =>
|
||||||
|
Boolean(currentQuestion.value && currentQuestion.value.index > 1 &&
|
||||||
|
answeredRecords.value.some((answer) => answer.question.index === currentQuestion.value!.index - 1))
|
||||||
|
);
|
||||||
|
const canGoNext = computed(() => {
|
||||||
|
if (!currentQuestion.value) return false;
|
||||||
|
const nextIndex = currentQuestion.value.index + 1;
|
||||||
|
return answeredRecords.value.some((answer) => answer.question.index === nextIndex) ||
|
||||||
|
pendingNextQuestion.value?.index === nextIndex;
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadEnglishVoice();
|
await loadEnglishVoice();
|
||||||
@@ -55,6 +98,7 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
if (recording.value && recorder?.state !== "inactive") recorder?.stop();
|
||||||
stopTracks();
|
stopTracks();
|
||||||
if (timer) window.clearInterval(timer);
|
if (timer) window.clearInterval(timer);
|
||||||
});
|
});
|
||||||
@@ -70,13 +114,22 @@ async function saveSettings() {
|
|||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
try {
|
||||||
settings.value = await api.configure({
|
settings.value = await api.configure({
|
||||||
apiKey: apiKey.value,
|
provider: settings.value.provider,
|
||||||
|
apiKey: openAIAPIKey.value,
|
||||||
|
geminiApiKey: geminiAPIKey.value,
|
||||||
demoMode: settings.value.demoMode,
|
demoMode: settings.value.demoMode,
|
||||||
evaluationModel: settings.value.evaluationModel,
|
evaluationModel: settings.value.evaluationModel,
|
||||||
transcribeModel: settings.value.transcribeModel,
|
transcribeModel: settings.value.transcribeModel,
|
||||||
realtimeModel: settings.value.realtimeModel
|
realtimeModel: settings.value.realtimeModel,
|
||||||
|
speechModel: settings.value.speechModel,
|
||||||
|
speechVoice: settings.value.speechVoice,
|
||||||
|
geminiEvaluationModels: settings.value.geminiEvaluationModels,
|
||||||
|
geminiTranscriptionModels: settings.value.geminiTranscriptionModels,
|
||||||
|
geminiSpeechModels: settings.value.geminiSpeechModels,
|
||||||
|
geminiSpeechVoice: settings.value.geminiSpeechVoice
|
||||||
});
|
});
|
||||||
apiKey.value = "";
|
openAIAPIKey.value = "";
|
||||||
|
geminiAPIKey.value = "";
|
||||||
settingsOpen.value = false;
|
settingsOpen.value = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = readableError(e);
|
error.value = readableError(e);
|
||||||
@@ -88,13 +141,21 @@ async function saveSettings() {
|
|||||||
async function testConnection() {
|
async function testConnection() {
|
||||||
connectionMessage.value = "연결 확인 중…";
|
connectionMessage.value = "연결 확인 중…";
|
||||||
try {
|
try {
|
||||||
if (apiKey.value) {
|
if (openAIAPIKey.value || geminiAPIKey.value) {
|
||||||
settings.value = await api.configure({
|
settings.value = await api.configure({
|
||||||
apiKey: apiKey.value,
|
provider: settings.value.provider,
|
||||||
|
apiKey: openAIAPIKey.value,
|
||||||
|
geminiApiKey: geminiAPIKey.value,
|
||||||
demoMode: false,
|
demoMode: false,
|
||||||
evaluationModel: settings.value.evaluationModel,
|
evaluationModel: settings.value.evaluationModel,
|
||||||
transcribeModel: settings.value.transcribeModel,
|
transcribeModel: settings.value.transcribeModel,
|
||||||
realtimeModel: settings.value.realtimeModel
|
realtimeModel: settings.value.realtimeModel,
|
||||||
|
speechModel: settings.value.speechModel,
|
||||||
|
speechVoice: settings.value.speechVoice,
|
||||||
|
geminiEvaluationModels: settings.value.geminiEvaluationModels,
|
||||||
|
geminiTranscriptionModels: settings.value.geminiTranscriptionModels,
|
||||||
|
geminiSpeechModels: settings.value.geminiSpeechModels,
|
||||||
|
geminiSpeechVoice: settings.value.geminiSpeechVoice
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await api.testConnection();
|
await api.testConnection();
|
||||||
@@ -121,6 +182,8 @@ async function startExam() {
|
|||||||
currentQuestion.value = result.question;
|
currentQuestion.value = result.question;
|
||||||
totalCount.value = result.totalCount;
|
totalCount.value = result.totalCount;
|
||||||
completedCount.value = 0;
|
completedCount.value = 0;
|
||||||
|
answeredRecords.value = [];
|
||||||
|
pendingNextQuestion.value = null;
|
||||||
transcript.value = "";
|
transcript.value = "";
|
||||||
latestEvaluation.value = null;
|
latestEvaluation.value = null;
|
||||||
screen.value = "exam";
|
screen.value = "exam";
|
||||||
@@ -169,9 +232,32 @@ async function loadEnglishVoice(): Promise<SpeechSynthesisVoice | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function speakQuestion() {
|
async function speakQuestion() {
|
||||||
if (!currentQuestion.value || !("speechSynthesis" in window)) return;
|
if (!currentQuestion.value) return;
|
||||||
|
stopQuestionAudio();
|
||||||
speechSynthesis.cancel();
|
speechSynthesis.cancel();
|
||||||
|
|
||||||
|
if (settings.value.hasApiKey && !settings.value.demoMode) {
|
||||||
|
speaking.value = true;
|
||||||
|
try {
|
||||||
|
const result = await api.generateSpeech(currentQuestion.value.text);
|
||||||
|
const bytes = Uint8Array.from(atob(result.audioBase64), (char) => char.charCodeAt(0));
|
||||||
|
const blob = new Blob([bytes], { type: result.mimeType });
|
||||||
|
questionAudioURL = URL.createObjectURL(blob);
|
||||||
|
questionAudio = new Audio(questionAudioURL);
|
||||||
|
questionAudio.onended = () => {
|
||||||
|
stopQuestionAudio();
|
||||||
|
void startRecording();
|
||||||
|
};
|
||||||
|
questionAudio.onerror = stopQuestionAudio;
|
||||||
|
await questionAudio.play();
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = `${readableError(e)} 시스템 영어 음성으로 재생합니다.`;
|
||||||
|
stopQuestionAudio();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!("speechSynthesis" in window)) return;
|
||||||
const voice = englishVoice ?? await loadEnglishVoice();
|
const voice = englishVoice ?? await loadEnglishVoice();
|
||||||
const naturalEnglish = currentQuestion.value.text
|
const naturalEnglish = currentQuestion.value.text
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
@@ -183,14 +269,31 @@ async function speakQuestion() {
|
|||||||
utterance.rate = 0.88;
|
utterance.rate = 0.88;
|
||||||
utterance.pitch = 0.98;
|
utterance.pitch = 0.98;
|
||||||
utterance.volume = 1;
|
utterance.volume = 1;
|
||||||
|
utterance.onend = () => {
|
||||||
|
speaking.value = false;
|
||||||
|
void startRecording();
|
||||||
|
};
|
||||||
|
utterance.onerror = () => { speaking.value = false; };
|
||||||
|
speaking.value = true;
|
||||||
speechSynthesis.speak(utterance);
|
speechSynthesis.speak(utterance);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleRecording() {
|
function stopQuestionAudio() {
|
||||||
if (recording.value) {
|
if (questionAudio) {
|
||||||
recorder?.stop();
|
questionAudio.pause();
|
||||||
return;
|
questionAudio.onended = null;
|
||||||
|
questionAudio.onerror = null;
|
||||||
|
questionAudio = null;
|
||||||
}
|
}
|
||||||
|
if (questionAudioURL) {
|
||||||
|
URL.revokeObjectURL(questionAudioURL);
|
||||||
|
questionAudioURL = "";
|
||||||
|
}
|
||||||
|
speaking.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRecording() {
|
||||||
|
if (recording.value || hasRecording.value || busy.value || isReviewingAnswer.value || !currentQuestion.value) return;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
try {
|
||||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
@@ -199,35 +302,93 @@ async function toggleRecording() {
|
|||||||
: "audio/webm";
|
: "audio/webm";
|
||||||
recorder = new MediaRecorder(stream, { mimeType: preferred });
|
recorder = new MediaRecorder(stream, { mimeType: preferred });
|
||||||
chunks = [];
|
chunks = [];
|
||||||
|
recordedBlob = null;
|
||||||
|
hasRecording.value = false;
|
||||||
|
stopRecordingPromise = new Promise<Blob | null>((resolve) => {
|
||||||
|
resolveStopRecording = resolve;
|
||||||
|
});
|
||||||
recorder.ondataavailable = (event) => {
|
recorder.ondataavailable = (event) => {
|
||||||
if (event.data.size) chunks.push(event.data);
|
if (event.data.size) chunks.push(event.data);
|
||||||
};
|
};
|
||||||
recorder.onstop = () => {
|
recorder.onstop = () => {
|
||||||
|
recordedBlob = chunks.length
|
||||||
|
? new Blob(chunks, { type: recorder?.mimeType || preferred })
|
||||||
|
: null;
|
||||||
|
hasRecording.value = Boolean(recordedBlob?.size);
|
||||||
recording.value = false;
|
recording.value = false;
|
||||||
if (timer) window.clearInterval(timer);
|
if (timer) window.clearInterval(timer);
|
||||||
|
timer = undefined;
|
||||||
stopTracks();
|
stopTracks();
|
||||||
|
resolveStopRecording?.(recordedBlob);
|
||||||
|
resolveStopRecording = null;
|
||||||
};
|
};
|
||||||
recorder.start(250);
|
recorder.start(250);
|
||||||
elapsed.value = 0;
|
elapsed.value = 0;
|
||||||
recording.value = true;
|
recording.value = true;
|
||||||
timer = window.setInterval(() => elapsed.value++, 1000);
|
timer = window.setInterval(() => {
|
||||||
|
elapsed.value += 1;
|
||||||
|
if (elapsed.value >= 120) {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
timer = undefined;
|
||||||
|
void forceSubmitAtLimit();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
} catch {
|
} catch {
|
||||||
error.value = "마이크 권한을 확인하세요. 텍스트 답변으로도 진행할 수 있습니다.";
|
error.value = "마이크 권한을 확인하세요. 텍스트 답변으로도 진행할 수 있습니다.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function stopRecording(): Promise<Blob | null> {
|
||||||
|
if (!recording.value || !recorder || recorder.state === "inactive") {
|
||||||
|
return recordedBlob;
|
||||||
|
}
|
||||||
|
const pending = stopRecordingPromise;
|
||||||
|
recorder.stop();
|
||||||
|
return pending ?? recordedBlob;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRecording() {
|
||||||
|
if (recording.value) {
|
||||||
|
await stopRecording();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasRecording.value) return;
|
||||||
|
await startRecording();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restartRecording() {
|
||||||
|
if (!canRestartRecording.value) return;
|
||||||
|
await stopRecording();
|
||||||
|
chunks = [];
|
||||||
|
recordedBlob = null;
|
||||||
|
hasRecording.value = false;
|
||||||
|
elapsed.value = 0;
|
||||||
|
await startRecording();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceSubmitAtLimit() {
|
||||||
|
if (autoSubmitting.value || busy.value || !recording.value) return;
|
||||||
|
autoSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
await stopRecording();
|
||||||
|
await submitAnswer(true);
|
||||||
|
} finally {
|
||||||
|
autoSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function stopTracks() {
|
function stopTracks() {
|
||||||
stream?.getTracks().forEach((track) => track.stop());
|
stream?.getTracks().forEach((track) => track.stop());
|
||||||
stream = null;
|
stream = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitAnswer() {
|
async function submitAnswer(forced = false) {
|
||||||
if (!currentQuestion.value || busy.value) return;
|
if (!currentQuestion.value || busy.value || isReviewingAnswer.value) return;
|
||||||
if (recording.value) recorder?.stop();
|
const blob = recording.value ? await stopRecording() : recordedBlob;
|
||||||
|
if (!forced && !transcript.value.trim() && !blob) return;
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
try {
|
||||||
const blob = chunks.length ? new Blob(chunks, { type: recorder?.mimeType || "audio/webm" }) : null;
|
|
||||||
const audioBase64 = blob ? await blobToBase64(blob) : "";
|
const audioBase64 = blob ? await blobToBase64(blob) : "";
|
||||||
const response = await api.submitAnswer({
|
const response = await api.submitAnswer({
|
||||||
sessionId: sessionId.value,
|
sessionId: sessionId.value,
|
||||||
@@ -240,19 +401,71 @@ async function submitAnswer() {
|
|||||||
transcript.value = response.transcript;
|
transcript.value = response.transcript;
|
||||||
latestEvaluation.value = response.evaluation;
|
latestEvaluation.value = response.evaluation;
|
||||||
completedCount.value = response.progress;
|
completedCount.value = response.progress;
|
||||||
|
answeredRecords.value.push({
|
||||||
|
question: currentQuestion.value,
|
||||||
|
transcript: response.transcript,
|
||||||
|
durationSec: elapsed.value,
|
||||||
|
evaluation: response.evaluation
|
||||||
|
});
|
||||||
|
pendingNextQuestion.value = response.next || null;
|
||||||
chunks = [];
|
chunks = [];
|
||||||
if (response.completed) {
|
recordedBlob = null;
|
||||||
report.value = await api.getReport(sessionId.value);
|
hasRecording.value = false;
|
||||||
screen.value = "report";
|
} catch (e) {
|
||||||
|
error.value = readableError(e);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showQuestion(question: Question, answer?: AnswerRecord) {
|
||||||
|
stopQuestionAudio();
|
||||||
|
speechSynthesis?.cancel();
|
||||||
|
currentQuestion.value = question;
|
||||||
|
transcript.value = answer?.transcript ?? "";
|
||||||
|
latestEvaluation.value = answer?.evaluation ?? null;
|
||||||
|
elapsed.value = answer?.durationSec ?? 0;
|
||||||
|
chunks = [];
|
||||||
|
recordedBlob = null;
|
||||||
|
hasRecording.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPreviousQuestion() {
|
||||||
|
if (!currentQuestion.value) return;
|
||||||
|
const answer = answeredRecords.value.find(
|
||||||
|
(item) => item.question.index === currentQuestion.value!.index - 1
|
||||||
|
);
|
||||||
|
if (answer) showQuestion(answer.question, answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToNextQuestion() {
|
||||||
|
if (!currentQuestion.value) return;
|
||||||
|
const nextIndex = currentQuestion.value.index + 1;
|
||||||
|
const answered = answeredRecords.value.find((item) => item.question.index === nextIndex);
|
||||||
|
if (answered) {
|
||||||
|
showQuestion(answered.question, answered);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.setTimeout(() => {
|
if (pendingNextQuestion.value?.index === nextIndex) {
|
||||||
currentQuestion.value = response.next || null;
|
const next = pendingNextQuestion.value;
|
||||||
transcript.value = "";
|
showQuestion(next);
|
||||||
latestEvaluation.value = null;
|
|
||||||
elapsed.value = 0;
|
|
||||||
window.setTimeout(speakQuestion, 350);
|
window.setTimeout(speakQuestion, 350);
|
||||||
}, 900);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToAnsweredQuestion(answer: AnswerRecord) {
|
||||||
|
showQuestion(answer.question, answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalizeExam() {
|
||||||
|
if (!sessionId.value || completedCount.value === 0 || busy.value) return;
|
||||||
|
busy.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
stopQuestionAudio();
|
||||||
|
speechSynthesis?.cancel();
|
||||||
|
report.value = await api.finalizeSession(sessionId.value);
|
||||||
|
screen.value = "report";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = readableError(e);
|
error.value = readableError(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -261,13 +474,18 @@ async function submitAnswer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetExam() {
|
function resetExam() {
|
||||||
|
stopQuestionAudio();
|
||||||
speechSynthesis?.cancel();
|
speechSynthesis?.cancel();
|
||||||
screen.value = "setup";
|
screen.value = "setup";
|
||||||
report.value = null;
|
report.value = null;
|
||||||
currentQuestion.value = null;
|
currentQuestion.value = null;
|
||||||
latestEvaluation.value = null;
|
latestEvaluation.value = null;
|
||||||
|
answeredRecords.value = [];
|
||||||
|
pendingNextQuestion.value = null;
|
||||||
transcript.value = "";
|
transcript.value = "";
|
||||||
completedCount.value = 0;
|
completedCount.value = 0;
|
||||||
|
recordedBlob = null;
|
||||||
|
hasRecording.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function printReport() {
|
function printReport() {
|
||||||
@@ -287,6 +505,16 @@ function readableError(value: unknown) {
|
|||||||
if (value instanceof Error) return value.message;
|
if (value instanceof Error) return value.message;
|
||||||
return String(value).replace(/^.*?: /, "");
|
return String(value).replace(/^.*?: /, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateGeminiModels(
|
||||||
|
field: "geminiEvaluationModels" | "geminiTranscriptionModels" | "geminiSpeechModels",
|
||||||
|
event: Event
|
||||||
|
) {
|
||||||
|
settings.value[field] = (event.target as HTMLInputElement).value
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.map((model) => model.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -298,7 +526,7 @@ function readableError(value: unknown) {
|
|||||||
</button>
|
</button>
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
<span class="connection-pill" :class="{ live: settings.hasApiKey && !settings.demoMode }">
|
<span class="connection-pill" :class="{ live: settings.hasApiKey && !settings.demoMode }">
|
||||||
<i></i>{{ settings.demoMode ? "DEMO MODE" : "OPENAI CONNECTED" }}
|
<i></i>{{ settings.demoMode ? "DEMO MODE" : `${settings.provider.toUpperCase()} CONNECTED` }}
|
||||||
</span>
|
</span>
|
||||||
<button class="icon-button" aria-label="설정" @click="settingsOpen = true">⚙</button>
|
<button class="icon-button" aria-label="설정" @click="settingsOpen = true">⚙</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,37 +575,101 @@ function readableError(value: unknown) {
|
|||||||
<div class="progress-ring" :style="{ '--progress': `${progress * 3.6}deg` }"><span><strong>{{ completedCount }}</strong>/ {{ totalCount }}</span></div>
|
<div class="progress-ring" :style="{ '--progress': `${progress * 3.6}deg` }"><span><strong>{{ completedCount }}</strong>/ {{ totalCount }}</span></div>
|
||||||
<div class="progress-meta"><span>PROGRESS</span><b>{{ progress }}%</b></div>
|
<div class="progress-meta"><span>PROGRESS</span><b>{{ progress }}%</b></div>
|
||||||
<div class="progress-bar"><i :style="{ width: `${progress}%` }"></i></div>
|
<div class="progress-bar"><i :style="{ width: `${progress}%` }"></i></div>
|
||||||
|
<div v-if="answeredRecords.length" class="current-average">
|
||||||
|
<div><small>CURRENT AVERAGE</small><strong>{{ currentAverage }}</strong><span>/ 100</span></div>
|
||||||
|
<p>{{ completedCount }}개 문항 기준 평균</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="answeredRecords.length" class="question-history">
|
||||||
|
<small>ANSWER HISTORY</small>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
v-for="answer in answeredRecords"
|
||||||
|
:key="answer.question.index"
|
||||||
|
:class="{ active: currentQuestion?.index === answer.question.index }"
|
||||||
|
@click="goToAnsweredQuestion(answer)"
|
||||||
|
>
|
||||||
|
<span class="history-number">Q{{ answer.question.index.toString().padStart(2, "0") }}</span>
|
||||||
|
<span class="history-category">{{ answer.question.category }}</span>
|
||||||
|
<strong>{{ answer.evaluation.score }}</strong>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="exam-tip"><small>COACH'S NOTE</small><p>완벽한 문법보다 구체적인 경험과 자연스러운 이야기 흐름에 집중하세요.</p></div>
|
<div class="exam-tip"><small>COACH'S NOTE</small><p>완벽한 문법보다 구체적인 경험과 자연스러운 이야기 흐름에 집중하세요.</p></div>
|
||||||
|
<button class="finish-early" :disabled="busy || completedCount === 0" @click="finalizeExam">
|
||||||
|
{{ completedCount === totalCount ? "최종 결과 보기" : "현재까지 최종 제출" }}
|
||||||
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="conversation">
|
<section class="conversation">
|
||||||
|
<div class="question-navigation">
|
||||||
|
<button class="secondary nav-button" :disabled="!canGoPrevious" @click="goToPreviousQuestion">← 이전 문제</button>
|
||||||
|
<span v-if="isReviewingAnswer">제출 완료 · 평가 다시 보기</span>
|
||||||
|
<span v-else>답변 대기 중</span>
|
||||||
|
<button class="secondary nav-button" :disabled="!canGoNext" @click="goToNextQuestion">다음 문제 →</button>
|
||||||
|
</div>
|
||||||
<div class="question-meta">
|
<div class="question-meta">
|
||||||
<span>QUESTION {{ currentQuestion?.index?.toString().padStart(2, "0") }}</span>
|
<span>QUESTION {{ currentQuestion?.index?.toString().padStart(2, "0") }}</span>
|
||||||
<b>{{ currentQuestion?.category }}</b>
|
<b>{{ currentQuestion?.category }}</b>
|
||||||
<span>{{ currentQuestion?.topic }}</span>
|
<span>{{ currentQuestion?.topic }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="question-card">
|
<div class="question-card">
|
||||||
<button class="speaker" @click="speakQuestion">◖))</button>
|
<button class="speaker" :class="{ active: speaking }" :disabled="speaking || recording" @click="speakQuestion">{{ speaking ? "•••" : "◖))" }}</button>
|
||||||
<div><small>INTERVIEWER</small><h1>{{ currentQuestion?.text }}</h1><p>{{ currentQuestion?.intent }}</p></div>
|
<div><small>AI INTERVIEWER · AI-GENERATED VOICE</small><h1>{{ currentQuestion?.text }}</h1><p>{{ currentQuestion?.intent }}</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="answer-area">
|
<div v-if="!isReviewingAnswer" class="answer-area">
|
||||||
<div class="record-row">
|
<div class="record-row">
|
||||||
<button class="record-button" :class="{ active: recording }" @click="toggleRecording"><i></i>{{ recording ? "녹음 중지" : "답변 녹음" }}</button>
|
<button class="record-button" :class="{ active: recording }" :disabled="hasRecording && !recording" @click="toggleRecording">
|
||||||
<span class="timer">{{ Math.floor(elapsed / 60).toString().padStart(2, "0") }}:{{ (elapsed % 60).toString().padStart(2, "0") }}</span>
|
<i></i>{{ recording ? "녹음 중지" : hasRecording ? "녹음 완료" : "답변 녹음" }}
|
||||||
|
</button>
|
||||||
|
<button v-if="canRestartRecording" class="restart-button" @click="restartRecording">↻ 처음부터 다시</button>
|
||||||
|
<span class="timer" :class="{ warning: remainingSeconds <= 20 }">
|
||||||
|
{{ Math.floor(elapsed / 60).toString().padStart(2, "0") }}:{{ (elapsed % 60).toString().padStart(2, "0") }}
|
||||||
|
<small>/ 02:00</small>
|
||||||
|
</span>
|
||||||
<span class="audio-bars" :class="{ active: recording }"><i v-for="n in 18" :key="n"></i></span>
|
<span class="audio-bars" :class="{ active: recording }"><i v-for="n in 18" :key="n"></i></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="recording-guide">
|
||||||
|
<span v-if="speaking">질문 재생이 끝나면 녹음이 자동 시작됩니다.</span>
|
||||||
|
<span v-else-if="recording && elapsed <= 20">자동 녹음 중 · {{ 20 - elapsed }}초 동안 다시 시작 가능</span>
|
||||||
|
<span v-else-if="recording">자동 녹음 중 · {{ remainingSeconds }}초 후 자동 제출</span>
|
||||||
|
<span v-else-if="hasRecording">녹음 완료 · 답변을 제출하거나 다시 녹음할 수 있습니다.</span>
|
||||||
|
<span v-else>질문을 다시 들으면 종료 후 녹음이 자동 시작됩니다.</span>
|
||||||
|
</div>
|
||||||
<label for="transcript">답변 스크립트 <small>녹음만 제출하면 AI가 자동 전사합니다. 직접 입력하거나 수정할 수도 있습니다.</small></label>
|
<label for="transcript">답변 스크립트 <small>녹음만 제출하면 AI가 자동 전사합니다. 직접 입력하거나 수정할 수도 있습니다.</small></label>
|
||||||
<textarea id="transcript" v-model="transcript" placeholder="Start speaking, or type your answer here…"></textarea>
|
<textarea id="transcript" v-model="transcript" placeholder="Start speaking, or type your answer here…"></textarea>
|
||||||
<div class="submit-row">
|
<div class="submit-row">
|
||||||
<span>{{ transcript.trim().split(/\s+/).filter(Boolean).length }} words</span>
|
<span>{{ transcript.trim().split(/\s+/).filter(Boolean).length }} words</span>
|
||||||
<button class="primary" :disabled="busy || (!transcript.trim() && !chunks.length)" @click="submitAnswer">{{ busy ? "AI 분석 중…" : "답변 제출" }} <span>→</span></button>
|
<button class="primary" :disabled="busy || (!transcript.trim() && !hasRecording && !recording)" @click="submitAnswer(false)">
|
||||||
|
{{ autoSubmitting ? "시간 종료 · 자동 제출 중…" : busy ? "AI 분석 중…" : "답변 제출" }} <span>→</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="latestEvaluation" class="instant-feedback">
|
<div v-else class="submitted-answer">
|
||||||
|
<small>YOUR ANSWER · {{ viewedAnswer?.durationSec }} SEC</small>
|
||||||
|
<p>{{ viewedAnswer?.transcript }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="latestEvaluation" class="evaluation-panel">
|
||||||
|
<header>
|
||||||
<div class="score">{{ latestEvaluation.score }}</div>
|
<div class="score">{{ latestEvaluation.score }}</div>
|
||||||
<div><small>INSTANT FEEDBACK</small><p>{{ latestEvaluation.feedback }}</p></div>
|
<div><small>AI ANALYSIS COMPLETE</small><h2>답변 분석 결과</h2><p>{{ latestEvaluation.feedback }}</p></div>
|
||||||
|
</header>
|
||||||
|
<div class="keyword-row">
|
||||||
|
<span v-for="keyword in latestEvaluation.keywords" :key="keyword">{{ keyword }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="evaluation-grid">
|
||||||
|
<article><small>잘한 점</small><ul><li v-for="item in latestEvaluation.strengths" :key="item">{{ item }}</li></ul></article>
|
||||||
|
<article><small>개선할 점</small><ul><li v-for="item in latestEvaluation.improvements" :key="item">{{ item }}</li></ul></article>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<button class="secondary" :disabled="!canGoPrevious" @click="goToPreviousQuestion">← 이전 문제 보기</button>
|
||||||
|
<button v-if="canGoNext" class="primary" @click="goToNextQuestion">다음 문제로 <span>→</span></button>
|
||||||
|
<button v-else class="primary" :disabled="busy" @click="finalizeExam">
|
||||||
|
{{ busy ? "총평 생성 중…" : "최종 결과 보기" }} <span>→</span>
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -388,18 +680,39 @@ function readableError(value: unknown) {
|
|||||||
<p class="eyebrow">SESSION COMPLETE</p>
|
<p class="eyebrow">SESSION COMPLETE</p>
|
||||||
<h1>Speaking<br />Performance<br /><em>Report</em></h1>
|
<h1>Speaking<br />Performance<br /><em>Report</em></h1>
|
||||||
<p>{{ new Date(report.generatedAt).toLocaleDateString("ko-KR") }} · {{ report.config.difficulty }} · {{ report.config.topics.join(", ") }}</p>
|
<p>{{ new Date(report.generatedAt).toLocaleDateString("ko-KR") }} · {{ report.config.difficulty }} · {{ report.config.topics.join(", ") }}</p>
|
||||||
|
<p class="completion-label">{{ report.partial ? `조기 제출 · ${report.answeredCount}/${report.totalCount} 문항 완료` : `전체 ${report.totalCount} 문항 완료` }}</p>
|
||||||
<div class="overall-score">
|
<div class="overall-score">
|
||||||
<div><strong>{{ report.overall.score }}</strong><span>/ 100</span></div>
|
<div><strong>{{ report.overall.score }}</strong><span>/ 100</span></div>
|
||||||
<aside><small>ESTIMATED BAND</small><b>{{ report.overall.estimatedBand }}</b></aside>
|
<aside><small>ESTIMATED BAND</small><b>{{ report.overall.estimatedBand }}</b></aside>
|
||||||
</div>
|
</div>
|
||||||
<p class="report-summary">{{ report.overall.summary }}</p>
|
<p class="report-summary">{{ report.overall.summary }}</p>
|
||||||
<div class="report-actions"><button class="secondary" @click="printReport">보고서 인쇄</button><button class="primary" @click="resetExam">새 연습 시작 →</button></div>
|
<div class="target-result">
|
||||||
|
<small>TARGET GRADE · {{ report.overall.targetGrade }}</small>
|
||||||
|
<strong>{{ report.overall.targetStatus }}</strong>
|
||||||
|
<span>{{ report.overall.targetProbability }}% estimated likelihood</span>
|
||||||
|
</div>
|
||||||
|
<div class="report-actions"><button class="secondary" @click="printReport">Print / Save as PDF</button><button class="primary" @click="resetExam">Start New Session →</button></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="report-content">
|
<section class="report-content">
|
||||||
<div class="overview-grid">
|
<div class="overview-grid">
|
||||||
<article><small>STRENGTHS</small><ul><li v-for="item in report.overall.strengths" :key="item">{{ item }}</li></ul></article>
|
<article><small>STRENGTHS</small><ul><li v-for="item in report.overall.strengths" :key="item">{{ item }}</li></ul></article>
|
||||||
<article class="accent"><small>NEXT PRIORITIES</small><ul><li v-for="item in report.overall.priorities" :key="item">{{ item }}</li></ul></article>
|
<article><small>WEAKNESSES</small><ul><li v-for="item in report.overall.weaknesses" :key="item">{{ item }}</li></ul></article>
|
||||||
|
<article class="accent"><small>PRIORITY IMPROVEMENTS</small><ul><li v-for="item in report.overall.priorities" :key="item">{{ item }}</li></ul></article>
|
||||||
|
</div>
|
||||||
|
<section class="grade-predictions">
|
||||||
|
<header><div><small>OPIc GRADE FORECAST</small><h2>Estimated grade attainment</h2></div><p>Practice estimate, not an official OPIc score.</p></header>
|
||||||
|
<div class="grade-table">
|
||||||
|
<article v-for="grade in report.overall.gradePredictions" :key="grade.grade" :class="grade.status.toLowerCase()">
|
||||||
|
<strong>{{ grade.grade }}</strong>
|
||||||
|
<div><span>{{ grade.status }}</span><p>{{ grade.description }}</p></div>
|
||||||
|
<aside><b>{{ grade.probability }}%</b><i><em :style="{ width: `${grade.probability}%` }"></em></i></aside>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div class="section-heading">
|
||||||
|
<small>QUESTION-BY-QUESTION REVIEW</small>
|
||||||
|
<h2>Detailed response analysis</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="answer-list">
|
<div class="answer-list">
|
||||||
<article v-for="answer in report.answers" :key="answer.question.index" class="answer-report">
|
<article v-for="answer in report.answers" :key="answer.question.index" class="answer-report">
|
||||||
@@ -407,8 +720,8 @@ function readableError(value: unknown) {
|
|||||||
<blockquote>{{ answer.transcript }}</blockquote>
|
<blockquote>{{ answer.transcript }}</blockquote>
|
||||||
<div class="keyword-row"><span v-for="keyword in answer.evaluation.keywords" :key="keyword">{{ keyword }}</span></div>
|
<div class="keyword-row"><span v-for="keyword in answer.evaluation.keywords" :key="keyword">{{ keyword }}</span></div>
|
||||||
<div class="feedback-grid">
|
<div class="feedback-grid">
|
||||||
<div><small>잘한 점</small><p>{{ answer.evaluation.strengths.join(" ") }}</p></div>
|
<div><small>STRENGTHS</small><p>{{ answer.evaluation.strengths.join(" ") }}</p></div>
|
||||||
<div><small>개선점</small><p>{{ answer.evaluation.improvements.join(" ") }}</p></div>
|
<div><small>AREAS TO IMPROVE</small><p>{{ answer.evaluation.improvements.join(" ") }}</p></div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
@@ -419,14 +732,51 @@ function readableError(value: unknown) {
|
|||||||
<section class="settings-modal">
|
<section class="settings-modal">
|
||||||
<button class="modal-close" @click="settingsOpen = false">×</button>
|
<button class="modal-close" @click="settingsOpen = false">×</button>
|
||||||
<p class="eyebrow">AI CONNECTION</p>
|
<p class="eyebrow">AI CONNECTION</p>
|
||||||
<h2>OpenAI 연결 설정</h2>
|
<h2>AI 연결 설정</h2>
|
||||||
<p class="modal-copy">ChatGPT 웹 로그인은 서드파티 데스크톱 앱의 API 인증으로 사용할 수 없습니다. OpenAI Platform API 키를 사용하세요. 키는 현재 앱 프로세스 메모리에만 보관됩니다.</p>
|
<p class="modal-copy">OpenAI 또는 Google Gemini API를 선택할 수 있습니다. API 키는 현재 앱 프로세스 메모리에만 보관됩니다.</p>
|
||||||
<label>API key<input v-model="apiKey" type="password" placeholder="sk-…" autocomplete="off" /></label>
|
<div class="provider-tabs">
|
||||||
|
<button :class="{ selected: settings.provider === 'openai' }" @click="settings.provider = 'openai'">OpenAI</button>
|
||||||
|
<button :class="{ selected: settings.provider === 'gemini' }" @click="settings.provider = 'gemini'">Google Gemini</button>
|
||||||
|
</div>
|
||||||
|
<label v-if="settings.provider === 'openai'">OpenAI API key<input v-model="openAIAPIKey" type="password" placeholder="sk-…" autocomplete="off" /></label>
|
||||||
|
<label v-else>Gemini API key<input v-model="geminiAPIKey" type="password" placeholder="AIza…" autocomplete="off" /></label>
|
||||||
<label class="switch-row"><span><b>데모 모드</b><small>API 비용 없이 전체 흐름을 시험합니다.</small></span><input v-model="settings.demoMode" type="checkbox" /></label>
|
<label class="switch-row"><span><b>데모 모드</b><small>API 비용 없이 전체 흐름을 시험합니다.</small></span><input v-model="settings.demoMode" type="checkbox" /></label>
|
||||||
<div class="model-grid">
|
<div v-if="settings.provider === 'openai'" class="model-grid">
|
||||||
<label>평가 모델<input v-model="settings.evaluationModel" /></label>
|
<label>평가 모델<input v-model="settings.evaluationModel" /></label>
|
||||||
<label>전사 모델<input v-model="settings.transcribeModel" /></label>
|
<label>전사 모델<input v-model="settings.transcribeModel" /></label>
|
||||||
<label>Realtime 모델<input v-model="settings.realtimeModel" /></label>
|
<label>Realtime 모델<input v-model="settings.realtimeModel" /></label>
|
||||||
|
<label>TTS 모델<input v-model="settings.speechModel" /></label>
|
||||||
|
<label>TTS 음성
|
||||||
|
<select v-model="settings.speechVoice">
|
||||||
|
<option value="marin">Marin — 자연스럽고 선명함</option>
|
||||||
|
<option value="cedar">Cedar — 차분하고 안정적</option>
|
||||||
|
<option value="coral">Coral — 밝고 친근함</option>
|
||||||
|
<option value="sage">Sage — 중립적</option>
|
||||||
|
<option value="onyx">Onyx — 낮고 묵직함</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-else class="gemini-models">
|
||||||
|
<p class="fallback-note">모델을 쉼표 또는 줄바꿈으로 입력하세요. 위에서부터 시도하고 실패하면 다음 모델로 자동 전환합니다.</p>
|
||||||
|
<label>평가 모델 우선순위
|
||||||
|
<textarea class="model-list" :value="settings.geminiEvaluationModels.join('\n')" @input="updateGeminiModels('geminiEvaluationModels', $event)"></textarea>
|
||||||
|
</label>
|
||||||
|
<label>음성 전사 모델 우선순위
|
||||||
|
<textarea class="model-list" :value="settings.geminiTranscriptionModels.join('\n')" @input="updateGeminiModels('geminiTranscriptionModels', $event)"></textarea>
|
||||||
|
</label>
|
||||||
|
<label>TTS 모델 우선순위
|
||||||
|
<textarea class="model-list" :value="settings.geminiSpeechModels.join('\n')" @input="updateGeminiModels('geminiSpeechModels', $event)"></textarea>
|
||||||
|
</label>
|
||||||
|
<label>Gemini TTS 음성
|
||||||
|
<select v-model="settings.geminiSpeechVoice">
|
||||||
|
<option value="Kore">Kore — 단정하고 힘 있음</option>
|
||||||
|
<option value="Iapetus">Iapetus — 선명함</option>
|
||||||
|
<option value="Schedar">Schedar — 균형 잡힘</option>
|
||||||
|
<option value="Gacrux">Gacrux — 성숙함</option>
|
||||||
|
<option value="Charon">Charon — 설명형</option>
|
||||||
|
<option value="Aoede">Aoede — 부드러움</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="connectionMessage" class="connection-message">{{ connectionMessage }}</p>
|
<p v-if="connectionMessage" class="connection-message">{{ connectionMessage }}</p>
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export const api = {
|
|||||||
configure: (settings: Record<string, unknown>) =>
|
configure: (settings: Record<string, unknown>) =>
|
||||||
AppService.Configure(settings as never) as Promise<AppSettings>,
|
AppService.Configure(settings as never) as Promise<AppSettings>,
|
||||||
testConnection: () => AppService.TestConnection() as Promise<void>,
|
testConnection: () => AppService.TestConnection() as Promise<void>,
|
||||||
|
generateSpeech: (text: string) =>
|
||||||
|
AppService.GenerateSpeech(text) as Promise<{ audioBase64: string; mimeType: string }>,
|
||||||
startSession: (config: Record<string, unknown>) =>
|
startSession: (config: Record<string, unknown>) =>
|
||||||
AppService.StartSession(config as never) as Promise<{
|
AppService.StartSession(config as never) as Promise<{
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -20,5 +22,7 @@ export const api = {
|
|||||||
completed: boolean;
|
completed: boolean;
|
||||||
progress: number;
|
progress: number;
|
||||||
}>,
|
}>,
|
||||||
|
finalizeSession: (sessionId: string) =>
|
||||||
|
AppService.FinalizeSession(sessionId) as Promise<ExamReport>,
|
||||||
getReport: (sessionId: string) => AppService.GetReport(sessionId) as Promise<ExamReport>
|
getReport: (sessionId: string) => AppService.GetReport(sessionId) as Promise<ExamReport>
|
||||||
};
|
};
|
||||||
|
|||||||
+84
-4
@@ -83,11 +83,35 @@ button { color: inherit; }
|
|||||||
.exam-tip { margin-top: 60px; padding: 18px; border: 1px solid #ffffff18; background: #ffffff08; }
|
.exam-tip { margin-top: 60px; padding: 18px; border: 1px solid #ffffff18; background: #ffffff08; }
|
||||||
.exam-tip small { color: var(--lime); font-size: 8px; letter-spacing: 1.4px; }
|
.exam-tip small { color: var(--lime); font-size: 8px; letter-spacing: 1.4px; }
|
||||||
.exam-tip p { margin: 9px 0 0; color: #b8c6bd; font-size: 11px; line-height: 1.7; }
|
.exam-tip p { margin: 9px 0 0; color: #b8c6bd; font-size: 11px; line-height: 1.7; }
|
||||||
|
.current-average { margin-top: 24px; padding: 16px 17px; border: 1px solid #ffffff1f; background: #ffffff08; }
|
||||||
|
.current-average > div { display: flex; align-items: baseline; }
|
||||||
|
.current-average small { margin-right: auto; color: #91a39a; font-size: 8px; letter-spacing: 1.3px; }
|
||||||
|
.current-average strong { color: var(--lime); font: 700 30px Manrope; }
|
||||||
|
.current-average span { margin-left: 3px; color: #8fa097; font-size: 9px; }
|
||||||
|
.current-average p { margin: 4px 0 0; color: #a9b7ae; font-size: 9px; }
|
||||||
|
.question-history { margin-top: 22px; }
|
||||||
|
.question-history > small { color: #8fa298; font-size: 8px; letter-spacing: 1.4px; }
|
||||||
|
.question-history > div { display: grid; gap: 6px; margin-top: 10px; max-height: 215px; overflow-y: auto; padding-right: 3px; }
|
||||||
|
.question-history button { min-height: 43px; padding: 7px 9px; display: grid; grid-template-columns: 30px 1fr 30px; align-items: center; gap: 7px; border: 1px solid #ffffff20; color: #dce5df; background: #ffffff08; cursor: pointer; text-align: left; }
|
||||||
|
.question-history .history-number { color: #a4b4aa; font-size: 8px; font-weight: 700; }
|
||||||
|
.question-history .history-category { overflow: hidden; color: #dce5df; font-size: 9px; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
|
.question-history button strong { color: var(--lime); font: 700 13px Manrope; text-align: right; }
|
||||||
|
.question-history button.active { border-color: var(--lime); color: var(--green); background: var(--lime); }
|
||||||
|
.question-history button.active span, .question-history button.active strong { color: var(--green); }
|
||||||
|
.finish-early { width: 100%; min-height: 42px; margin-top: 16px; border: 1px solid #ffffff40; color: white; background: transparent; cursor: pointer; font-size: 10px; font-weight: 700; }
|
||||||
|
.finish-early:hover:not(:disabled) { border-color: var(--lime); color: var(--lime); }
|
||||||
|
.finish-early:disabled { opacity: .35; cursor: not-allowed; }
|
||||||
.conversation { padding: 5vh 7vw 8vh; background: var(--paper); }
|
.conversation { padding: 5vh 7vw 8vh; background: var(--paper); }
|
||||||
|
.question-navigation { display: flex; align-items: center; justify-content: space-between; margin-bottom: 26px; }
|
||||||
|
.question-navigation > span { color: #7c8880; font-size: 9px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; }
|
||||||
|
.nav-button { min-height: 35px; padding: 0 13px; }
|
||||||
|
.nav-button:disabled { opacity: .3; cursor: not-allowed; }
|
||||||
.question-meta { display: flex; align-items: center; gap: 13px; color: #8a948d; font-size: 9px; letter-spacing: 1.3px; }
|
.question-meta { display: flex; align-items: center; gap: 13px; color: #8a948d; font-size: 9px; letter-spacing: 1.3px; }
|
||||||
.question-meta b { padding: 6px 9px; color: var(--green); background: #e9f0e9; letter-spacing: 0; }
|
.question-meta b { padding: 6px 9px; color: var(--green); background: #e9f0e9; letter-spacing: 0; }
|
||||||
.question-card { display: grid; grid-template-columns: 58px 1fr; gap: 22px; margin-top: 30px; padding-bottom: 38px; border-bottom: 1px solid var(--line); }
|
.question-card { display: grid; grid-template-columns: 58px 1fr; gap: 22px; margin-top: 30px; padding-bottom: 38px; border-bottom: 1px solid var(--line); }
|
||||||
.speaker { width: 54px; height: 54px; border-radius: 50%; border: 0; color: var(--green); background: var(--lime); cursor: pointer; font-weight: 800; }
|
.speaker { width: 54px; height: 54px; border-radius: 50%; border: 0; color: var(--green); background: var(--lime); cursor: pointer; font-weight: 800; }
|
||||||
|
.speaker.active { animation: speaker-pulse 1.2s ease-in-out infinite; cursor: wait; }
|
||||||
|
@keyframes speaker-pulse { 50% { transform: scale(.94); box-shadow: 0 0 0 10px #c9f36a33; } }
|
||||||
.question-card small, .instant-feedback small { color: var(--orange); font-size: 8px; font-weight: 700; letter-spacing: 1.7px; }
|
.question-card small, .instant-feedback small { color: var(--orange); font-size: 8px; font-weight: 700; letter-spacing: 1.7px; }
|
||||||
.question-card h1 { max-width: 770px; margin: 9px 0 12px; font: 500 clamp(26px, 3vw, 42px)/1.22 Manrope; letter-spacing: -1.8px; }
|
.question-card h1 { max-width: 770px; margin: 9px 0 12px; font: 500 clamp(26px, 3vw, 42px)/1.22 Manrope; letter-spacing: -1.8px; }
|
||||||
.question-card p { color: var(--muted); font-size: 11px; }
|
.question-card p { color: var(--muted); font-size: 11px; }
|
||||||
@@ -96,7 +120,12 @@ button { color: inherit; }
|
|||||||
.record-button { height: 40px; padding: 0 15px; display: flex; align-items: center; gap: 8px; border: 1px solid #dfd4ce; background: #fff8f4; color: #a7482a; cursor: pointer; font-size: 11px; font-weight: 700; }
|
.record-button { height: 40px; padding: 0 15px; display: flex; align-items: center; gap: 8px; border: 1px solid #dfd4ce; background: #fff8f4; color: #a7482a; cursor: pointer; font-size: 11px; font-weight: 700; }
|
||||||
.record-button i { width: 9px; height: 9px; border-radius: 50%; background: var(--orange); }
|
.record-button i { width: 9px; height: 9px; border-radius: 50%; background: var(--orange); }
|
||||||
.record-button.active i { animation: pulse 1s infinite; }
|
.record-button.active i { animation: pulse 1s infinite; }
|
||||||
|
.record-button:disabled { opacity: .55; cursor: default; }
|
||||||
|
.restart-button { height: 40px; padding: 0 12px; border: 1px solid #b9c9bd; color: var(--green); background: #edf5e8; cursor: pointer; font-size: 10px; font-weight: 700; }
|
||||||
.timer { font: 600 13px Manrope; font-variant-numeric: tabular-nums; }
|
.timer { font: 600 13px Manrope; font-variant-numeric: tabular-nums; }
|
||||||
|
.timer small { color: #9ba39e; font-size: 8px; }
|
||||||
|
.timer.warning { color: var(--orange); }
|
||||||
|
.recording-guide { margin-top: 9px; color: #7b8780; font-size: 9px; }
|
||||||
.audio-bars { height: 26px; flex: 1; display: flex; align-items: center; gap: 3px; overflow: hidden; }
|
.audio-bars { height: 26px; flex: 1; display: flex; align-items: center; gap: 3px; overflow: hidden; }
|
||||||
.audio-bars i { width: 3px; height: 4px; background: #d4d9d2; }
|
.audio-bars i { width: 3px; height: 4px; background: #d4d9d2; }
|
||||||
.audio-bars.active i { background: #7fa18b; animation: wave .7s ease-in-out infinite alternate; }
|
.audio-bars.active i { background: #7fa18b; animation: wave .7s ease-in-out infinite alternate; }
|
||||||
@@ -111,6 +140,22 @@ textarea:focus, input:focus { border-color: #7e9b88; box-shadow: 0 0 0 3px #7e9b
|
|||||||
.instant-feedback { margin-top: 18px; padding: 15px; display: flex; gap: 15px; align-items: center; background: #edf6d9; border-left: 3px solid #9dc74b; }
|
.instant-feedback { margin-top: 18px; padding: 15px; display: flex; gap: 15px; align-items: center; background: #edf6d9; border-left: 3px solid #9dc74b; }
|
||||||
.instant-feedback .score { width: 45px; height: 45px; display: grid; place-items: center; border-radius: 50%; color: white; background: var(--green); font: 700 16px Manrope; }
|
.instant-feedback .score { width: 45px; height: 45px; display: grid; place-items: center; border-radius: 50%; color: white; background: var(--green); font: 700 16px Manrope; }
|
||||||
.instant-feedback p { margin: 4px 0 0; font-size: 11px; }
|
.instant-feedback p { margin: 4px 0 0; font-size: 11px; }
|
||||||
|
.submitted-answer { margin-top: 28px; padding: 20px; border: 1px solid var(--line); background: #f2f3ee; }
|
||||||
|
.submitted-answer small { color: #89938c; font-size: 8px; font-weight: 700; letter-spacing: 1.4px; }
|
||||||
|
.submitted-answer p { margin: 10px 0 0; color: #48574e; font-size: 12px; line-height: 1.8; white-space: pre-wrap; }
|
||||||
|
.evaluation-panel { margin-top: 22px; padding: 25px; border: 1px solid #cfdbc2; background: #f6faec; }
|
||||||
|
.evaluation-panel header { display: grid; grid-template-columns: 64px 1fr; gap: 18px; align-items: center; }
|
||||||
|
.evaluation-panel .score { width: 62px; height: 62px; display: grid; place-items: center; border-radius: 50%; color: var(--lime); background: var(--green); font: 700 22px Manrope; }
|
||||||
|
.evaluation-panel header small { color: var(--orange); font-size: 8px; font-weight: 700; letter-spacing: 1.6px; }
|
||||||
|
.evaluation-panel h2 { margin: 4px 0; font: 700 20px Manrope; }
|
||||||
|
.evaluation-panel header p { margin: 0; color: #5d6a61; font-size: 11px; line-height: 1.65; }
|
||||||
|
.evaluation-panel .keyword-row { margin-top: 18px; }
|
||||||
|
.evaluation-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 15px; }
|
||||||
|
.evaluation-grid article { padding: 16px; border: 1px solid #dce5d2; background: white; }
|
||||||
|
.evaluation-grid small { color: var(--orange); font-size: 8px; font-weight: 700; letter-spacing: 1.3px; }
|
||||||
|
.evaluation-grid ul { margin: 10px 0 0; padding-left: 16px; color: #536158; font-size: 10px; line-height: 1.75; }
|
||||||
|
.evaluation-panel footer { display: flex; justify-content: space-between; gap: 10px; margin-top: 20px; padding-top: 17px; border-top: 1px solid #dce5d2; }
|
||||||
|
.completion-label { display: inline-block; padding: 6px 9px; color: var(--lime) !important; border: 1px solid #ffffff25; font-weight: 700; letter-spacing: .5px; }
|
||||||
.report-layout { display: grid; grid-template-columns: 38% 62%; min-height: calc(100vh - 76px); }
|
.report-layout { display: grid; grid-template-columns: 38% 62%; min-height: calc(100vh - 76px); }
|
||||||
.report-cover { position: sticky; top: 0; height: calc(100vh - 76px); padding: 7vh 4vw; color: white; background: var(--green); }
|
.report-cover { position: sticky; top: 0; height: calc(100vh - 76px); padding: 7vh 4vw; color: white; background: var(--green); }
|
||||||
.report-cover h1 { font-size: clamp(43px, 4.5vw, 68px); }
|
.report-cover h1 { font-size: clamp(43px, 4.5vw, 68px); }
|
||||||
@@ -122,15 +167,36 @@ textarea:focus, input:focus { border-color: #7e9b88; box-shadow: 0 0 0 3px #7e9b
|
|||||||
.overall-score small { display: block; color: #8ea096; font-size: 8px; letter-spacing: 1.5px; }
|
.overall-score small { display: block; color: #8ea096; font-size: 8px; letter-spacing: 1.5px; }
|
||||||
.overall-score b { display: block; margin-top: 7px; font-size: 16px; }
|
.overall-score b { display: block; margin-top: 7px; font-size: 16px; }
|
||||||
.report-summary { color: #b9c6bd; font-size: 12px; line-height: 1.7; }
|
.report-summary { color: #b9c6bd; font-size: 12px; line-height: 1.7; }
|
||||||
|
.target-result { margin-top: 18px; padding: 15px 17px; border: 1px solid #ffffff22; background: #ffffff08; }
|
||||||
|
.target-result small { display: block; color: #91a39a; font-size: 8px; letter-spacing: 1.4px; }
|
||||||
|
.target-result strong { display: inline-block; margin-top: 7px; color: var(--lime); font: 700 19px Manrope; }
|
||||||
|
.target-result span { margin-left: 10px; color: #b6c3bb; font-size: 9px; }
|
||||||
.report-actions { display: flex; gap: 10px; margin-top: 25px; }
|
.report-actions { display: flex; gap: 10px; margin-top: 25px; }
|
||||||
.report-cover .secondary { border-color: #ffffff55; color: white; }
|
.report-cover .secondary { border-color: #ffffff55; color: white; }
|
||||||
.report-cover .primary { color: var(--green); background: var(--lime); border-color: var(--lime); }
|
.report-cover .primary { color: var(--green); background: var(--lime); border-color: var(--lime); }
|
||||||
.report-content { padding: 5vh 5vw; background: var(--paper); }
|
.report-content { padding: 5vh 5vw; background: var(--paper); }
|
||||||
.overview-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
.overview-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
||||||
.overview-grid article { padding: 25px; border: 1px solid var(--line); background: white; }
|
.overview-grid article { padding: 25px; border: 1px solid var(--line); background: white; }
|
||||||
.overview-grid article.accent { background: #eef4dd; }
|
.overview-grid article.accent { background: #eef4dd; }
|
||||||
.overview-grid small, .answer-report small { color: var(--orange); font-size: 8px; font-weight: 700; letter-spacing: 1.4px; }
|
.overview-grid small, .answer-report small { color: var(--orange); font-size: 8px; font-weight: 700; letter-spacing: 1.4px; }
|
||||||
.overview-grid ul { margin: 17px 0 0; padding-left: 18px; font-size: 12px; line-height: 2; }
|
.overview-grid ul { margin: 17px 0 0; padding-left: 18px; font-size: 12px; line-height: 2; }
|
||||||
|
.grade-predictions { margin-top: 32px; padding-top: 28px; border-top: 1px solid var(--line); }
|
||||||
|
.grade-predictions > header { display: flex; justify-content: space-between; align-items: end; gap: 20px; }
|
||||||
|
.grade-predictions header small, .section-heading small { color: var(--orange); font-size: 8px; font-weight: 700; letter-spacing: 1.5px; }
|
||||||
|
.grade-predictions h2, .section-heading h2 { margin: 6px 0 0; font: 700 22px Manrope; }
|
||||||
|
.grade-predictions > header p { max-width: 230px; margin: 0; color: var(--muted); font-size: 9px; text-align: right; }
|
||||||
|
.grade-table { margin-top: 18px; border-top: 1px solid var(--line); }
|
||||||
|
.grade-table article { min-height: 67px; display: grid; grid-template-columns: 50px 1fr 120px; gap: 14px; align-items: center; padding: 11px 8px; border-bottom: 1px solid var(--line); }
|
||||||
|
.grade-table article > strong { font: 700 17px Manrope; }
|
||||||
|
.grade-table article div span { display: inline-block; padding: 3px 7px; border-radius: 99px; font-size: 8px; font-weight: 700; text-transform: uppercase; }
|
||||||
|
.grade-table article.likely div span { color: #285b3e; background: #ddf0d8; }
|
||||||
|
.grade-table article.possible div span { color: #79591e; background: #f5e9c9; }
|
||||||
|
.grade-table article.unlikely div span { color: #8b4935; background: #f7ded5; }
|
||||||
|
.grade-table article div p { margin: 5px 0 0; color: #657168; font-size: 9px; }
|
||||||
|
.grade-table aside b { display: block; font: 700 12px Manrope; text-align: right; }
|
||||||
|
.grade-table aside i { display: block; height: 4px; margin-top: 6px; background: #e2e5de; }
|
||||||
|
.grade-table aside em { display: block; height: 100%; background: var(--green); }
|
||||||
|
.section-heading { margin-top: 38px; padding-top: 28px; border-top: 1px solid var(--line); }
|
||||||
.answer-list { margin-top: 32px; }
|
.answer-list { margin-top: 32px; }
|
||||||
.answer-report { padding: 28px 0; border-top: 1px solid var(--line); }
|
.answer-report { padding: 28px 0; border-top: 1px solid var(--line); }
|
||||||
.answer-report header { display: grid; grid-template-columns: 42px 1fr 55px; gap: 15px; align-items: start; }
|
.answer-report header { display: grid; grid-template-columns: 42px 1fr 55px; gap: 15px; align-items: start; }
|
||||||
@@ -146,9 +212,16 @@ blockquote { margin: 18px 0; padding: 16px 18px; color: #526057; background: #f1
|
|||||||
.modal-backdrop { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; padding: 30px; background: #102019aa; backdrop-filter: blur(6px); }
|
.modal-backdrop { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; padding: 30px; background: #102019aa; backdrop-filter: blur(6px); }
|
||||||
.settings-modal { position: relative; width: min(580px, 90vw); max-height: 90vh; overflow: auto; padding: 38px; background: var(--paper); box-shadow: 0 25px 80px #07140d66; }
|
.settings-modal { position: relative; width: min(580px, 90vw); max-height: 90vh; overflow: auto; padding: 38px; background: var(--paper); box-shadow: 0 25px 80px #07140d66; }
|
||||||
.settings-modal h2 { margin: -15px 0 12px; font: 700 28px Manrope; }
|
.settings-modal h2 { margin: -15px 0 12px; font: 700 28px Manrope; }
|
||||||
|
.provider-tabs { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin: 22px 0 8px; }
|
||||||
|
.provider-tabs button { height: 42px; border: 1px solid var(--line); background: white; cursor: pointer; font-size: 11px; font-weight: 700; }
|
||||||
|
.provider-tabs button.selected { color: white; border-color: var(--green); background: var(--green); box-shadow: inset 0 -3px var(--lime); }
|
||||||
.modal-copy { color: var(--muted); font-size: 11px; line-height: 1.7; }
|
.modal-copy { color: var(--muted); font-size: 11px; line-height: 1.7; }
|
||||||
.modal-close { position: absolute; top: 14px; right: 18px; border: 0; background: none; font-size: 25px; cursor: pointer; }
|
.modal-close { position: absolute; top: 14px; right: 18px; border: 0; background: none; font-size: 25px; cursor: pointer; }
|
||||||
.settings-modal input:not([type="checkbox"]) { width: 100%; height: 42px; margin-top: 7px; padding: 0 12px; border: 1px solid var(--line); outline: none; background: white; }
|
.settings-modal input:not([type="checkbox"]) { width: 100%; height: 42px; margin-top: 7px; padding: 0 12px; border: 1px solid var(--line); outline: none; background: white; }
|
||||||
|
.settings-modal select { width: 100%; height: 42px; margin-top: 7px; padding: 0 12px; border: 1px solid var(--line); outline: none; background: white; }
|
||||||
|
.gemini-models { margin-top: 18px; }
|
||||||
|
.fallback-note { padding: 11px 13px; color: #59665e; background: #eef4dd; font-size: 10px; line-height: 1.6; }
|
||||||
|
.settings-modal .model-list { height: 82px; margin-top: 7px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; line-height: 1.55; }
|
||||||
.switch-row { display: flex !important; justify-content: space-between; align-items: center; padding: 14px; background: #eef1ea; }
|
.switch-row { display: flex !important; justify-content: space-between; align-items: center; padding: 14px; background: #eef1ea; }
|
||||||
.switch-row b, .switch-row small { display: block; }
|
.switch-row b, .switch-row small { display: block; }
|
||||||
.switch-row small { margin-top: 4px; color: var(--muted); font-weight: 400; text-transform: none; letter-spacing: 0; }
|
.switch-row small { margin-top: 4px; color: var(--muted); font-weight: 400; text-transform: none; letter-spacing: 0; }
|
||||||
@@ -166,11 +239,18 @@ blockquote { margin: 18px 0; padding: 16px 18px; color: #526057; background: #f1
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
|
@page { size: A4; margin: 13mm; }
|
||||||
|
html, body { background: white; }
|
||||||
.topbar, .report-actions { display: none; }
|
.topbar, .report-actions { display: none; }
|
||||||
.report-layout { display: block; }
|
.report-layout { display: block; }
|
||||||
.report-cover { position: static; height: auto; color: #14251c; background: white; }
|
.report-cover { position: static; height: auto; min-height: 255mm; padding: 18mm 12mm; color: #14251c; background: white; break-after: page; }
|
||||||
.report-cover h1 em, .overall-score strong { color: #14251c; }
|
.report-cover h1 em, .overall-score strong { color: #14251c; }
|
||||||
.report-content { padding: 20px; }
|
.report-cover > p:not(.eyebrow):not(.report-summary), .report-summary, .target-result span { color: #526057; }
|
||||||
|
.overall-score, .target-result { border-color: #ccd2cc; }
|
||||||
|
.target-result strong { color: #14251c; }
|
||||||
|
.report-content { padding: 0; background: white; }
|
||||||
|
.overview-grid, .grade-predictions { break-inside: avoid; }
|
||||||
|
.overview-grid article { padding: 14px; }
|
||||||
|
.grade-table article { min-height: 56px; }
|
||||||
.answer-report { break-inside: avoid; }
|
.answer-report { break-inside: avoid; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-1
@@ -1,9 +1,18 @@
|
|||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
|
provider: "openai" | "gemini";
|
||||||
hasApiKey: boolean;
|
hasApiKey: boolean;
|
||||||
|
hasOpenAIKey: boolean;
|
||||||
|
hasGeminiKey: boolean;
|
||||||
demoMode: boolean;
|
demoMode: boolean;
|
||||||
evaluationModel: string;
|
evaluationModel: string;
|
||||||
transcribeModel: string;
|
transcribeModel: string;
|
||||||
realtimeModel: string;
|
realtimeModel: string;
|
||||||
|
speechModel: string;
|
||||||
|
speechVoice: string;
|
||||||
|
geminiEvaluationModels: string[];
|
||||||
|
geminiTranscriptionModels: string[];
|
||||||
|
geminiSpeechModels: string[];
|
||||||
|
geminiSpeechVoice: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Question {
|
export interface Question {
|
||||||
@@ -37,9 +46,21 @@ export interface ExamReport {
|
|||||||
estimatedBand: string;
|
estimatedBand: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
strengths: string[];
|
strengths: string[];
|
||||||
|
weaknesses: string[];
|
||||||
priorities: string[];
|
priorities: string[];
|
||||||
|
targetGrade: string;
|
||||||
|
targetStatus: string;
|
||||||
|
targetProbability: number;
|
||||||
|
gradePredictions: {
|
||||||
|
grade: string;
|
||||||
|
probability: number;
|
||||||
|
status: string;
|
||||||
|
description: string;
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
answers: AnswerRecord[];
|
answers: AnswerRecord[];
|
||||||
|
answeredCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
partial: boolean;
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,19 +5,41 @@ import "time"
|
|||||||
const questionCount = 15
|
const questionCount = 15
|
||||||
|
|
||||||
type AppSettings struct {
|
type AppSettings struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
HasAPIKey bool `json:"hasApiKey"`
|
HasAPIKey bool `json:"hasApiKey"`
|
||||||
|
HasOpenAIKey bool `json:"hasOpenAIKey"`
|
||||||
|
HasGeminiKey bool `json:"hasGeminiKey"`
|
||||||
DemoMode bool `json:"demoMode"`
|
DemoMode bool `json:"demoMode"`
|
||||||
EvaluationModel string `json:"evaluationModel"`
|
EvaluationModel string `json:"evaluationModel"`
|
||||||
TranscribeModel string `json:"transcribeModel"`
|
TranscribeModel string `json:"transcribeModel"`
|
||||||
RealtimeModel string `json:"realtimeModel"`
|
RealtimeModel string `json:"realtimeModel"`
|
||||||
|
SpeechModel string `json:"speechModel"`
|
||||||
|
SpeechVoice string `json:"speechVoice"`
|
||||||
|
GeminiEvaluationModels []string `json:"geminiEvaluationModels"`
|
||||||
|
GeminiTranscriptionModels []string `json:"geminiTranscriptionModels"`
|
||||||
|
GeminiSpeechModels []string `json:"geminiSpeechModels"`
|
||||||
|
GeminiSpeechVoice string `json:"geminiSpeechVoice"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConfigureRequest struct {
|
type ConfigureRequest struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
APIKey string `json:"apiKey"`
|
APIKey string `json:"apiKey"`
|
||||||
|
GeminiAPIKey string `json:"geminiApiKey"`
|
||||||
DemoMode bool `json:"demoMode"`
|
DemoMode bool `json:"demoMode"`
|
||||||
EvaluationModel string `json:"evaluationModel"`
|
EvaluationModel string `json:"evaluationModel"`
|
||||||
TranscribeModel string `json:"transcribeModel"`
|
TranscribeModel string `json:"transcribeModel"`
|
||||||
RealtimeModel string `json:"realtimeModel"`
|
RealtimeModel string `json:"realtimeModel"`
|
||||||
|
SpeechModel string `json:"speechModel"`
|
||||||
|
SpeechVoice string `json:"speechVoice"`
|
||||||
|
GeminiEvaluationModels []string `json:"geminiEvaluationModels"`
|
||||||
|
GeminiTranscriptionModels []string `json:"geminiTranscriptionModels"`
|
||||||
|
GeminiSpeechModels []string `json:"geminiSpeechModels"`
|
||||||
|
GeminiSpeechVoice string `json:"geminiSpeechVoice"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpeechResponse struct {
|
||||||
|
AudioBase64 string `json:"audioBase64"`
|
||||||
|
MimeType string `json:"mimeType"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExamConfig struct {
|
type ExamConfig struct {
|
||||||
@@ -88,7 +110,19 @@ type OverallReport struct {
|
|||||||
EstimatedBand string `json:"estimatedBand"`
|
EstimatedBand string `json:"estimatedBand"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
Strengths []string `json:"strengths"`
|
Strengths []string `json:"strengths"`
|
||||||
|
Weaknesses []string `json:"weaknesses"`
|
||||||
Priorities []string `json:"priorities"`
|
Priorities []string `json:"priorities"`
|
||||||
|
TargetGrade string `json:"targetGrade"`
|
||||||
|
TargetStatus string `json:"targetStatus"`
|
||||||
|
TargetProbability int `json:"targetProbability"`
|
||||||
|
GradePredictions []GradePrediction `json:"gradePredictions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GradePrediction struct {
|
||||||
|
Grade string `json:"grade"`
|
||||||
|
Probability int `json:"probability"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExamReport struct {
|
type ExamReport struct {
|
||||||
@@ -96,6 +130,8 @@ type ExamReport struct {
|
|||||||
Config ExamConfig `json:"config"`
|
Config ExamConfig `json:"config"`
|
||||||
Overall OverallReport `json:"overall"`
|
Overall OverallReport `json:"overall"`
|
||||||
Answers []AnswerRecord `json:"answers"`
|
Answers []AnswerRecord `json:"answers"`
|
||||||
|
AnsweredCount int `json:"answeredCount"`
|
||||||
|
TotalCount int `json:"totalCount"`
|
||||||
|
Partial bool `json:"partial"`
|
||||||
GeneratedAt time.Time `json:"generatedAt"`
|
GeneratedAt time.Time `json:"generatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+618
-24
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -23,23 +24,41 @@ import (
|
|||||||
type AppService struct {
|
type AppService struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
apiKey string
|
apiKey string
|
||||||
|
geminiKey string
|
||||||
settings AppSettings
|
settings AppSettings
|
||||||
sessions map[string]*ExamSession
|
sessions map[string]*ExamSession
|
||||||
|
speech map[string]SpeechResponse
|
||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAppService() *AppService {
|
func NewAppService() *AppService {
|
||||||
key := strings.TrimSpace(os.Getenv("OPENAI_API_KEY"))
|
key := strings.TrimSpace(os.Getenv("OPENAI_API_KEY"))
|
||||||
|
geminiKey := strings.TrimSpace(os.Getenv("GEMINI_API_KEY"))
|
||||||
|
provider := "openai"
|
||||||
|
if key == "" && geminiKey != "" {
|
||||||
|
provider = "gemini"
|
||||||
|
}
|
||||||
return &AppService{
|
return &AppService{
|
||||||
apiKey: key,
|
apiKey: key,
|
||||||
|
geminiKey: geminiKey,
|
||||||
settings: AppSettings{
|
settings: AppSettings{
|
||||||
HasAPIKey: key != "",
|
Provider: provider,
|
||||||
DemoMode: key == "",
|
HasAPIKey: key != "" || geminiKey != "",
|
||||||
|
HasOpenAIKey: key != "",
|
||||||
|
HasGeminiKey: geminiKey != "",
|
||||||
|
DemoMode: key == "" && geminiKey == "",
|
||||||
EvaluationModel: "gpt-5-mini",
|
EvaluationModel: "gpt-5-mini",
|
||||||
TranscribeModel: "gpt-4o-transcribe",
|
TranscribeModel: "gpt-4o-transcribe",
|
||||||
RealtimeModel: "gpt-realtime",
|
RealtimeModel: "gpt-realtime",
|
||||||
|
SpeechModel: "gpt-4o-mini-tts",
|
||||||
|
SpeechVoice: "marin",
|
||||||
|
GeminiEvaluationModels: []string{"gemini-3.5-flash", "gemini-2.5-flash", "gemini-2.5-flash-lite"},
|
||||||
|
GeminiTranscriptionModels: []string{"gemini-3.5-flash", "gemini-2.5-flash", "gemini-2.5-flash-lite"},
|
||||||
|
GeminiSpeechModels: []string{"gemini-3.1-flash-tts-preview", "gemini-2.5-flash-preview-tts"},
|
||||||
|
GeminiSpeechVoice: "Kore",
|
||||||
},
|
},
|
||||||
sessions: make(map[string]*ExamSession),
|
sessions: make(map[string]*ExamSession),
|
||||||
|
speech: make(map[string]SpeechResponse),
|
||||||
client: &http.Client{Timeout: 90 * time.Second},
|
client: &http.Client{Timeout: 90 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,6 +75,12 @@ func (s *AppService) Configure(request ConfigureRequest) (AppSettings, error) {
|
|||||||
if strings.TrimSpace(request.APIKey) != "" {
|
if strings.TrimSpace(request.APIKey) != "" {
|
||||||
s.apiKey = strings.TrimSpace(request.APIKey)
|
s.apiKey = strings.TrimSpace(request.APIKey)
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(request.GeminiAPIKey) != "" {
|
||||||
|
s.geminiKey = strings.TrimSpace(request.GeminiAPIKey)
|
||||||
|
}
|
||||||
|
if request.Provider == "openai" || request.Provider == "gemini" {
|
||||||
|
s.settings.Provider = request.Provider
|
||||||
|
}
|
||||||
if request.EvaluationModel != "" {
|
if request.EvaluationModel != "" {
|
||||||
s.settings.EvaluationModel = request.EvaluationModel
|
s.settings.EvaluationModel = request.EvaluationModel
|
||||||
}
|
}
|
||||||
@@ -65,15 +90,55 @@ func (s *AppService) Configure(request ConfigureRequest) (AppSettings, error) {
|
|||||||
if request.RealtimeModel != "" {
|
if request.RealtimeModel != "" {
|
||||||
s.settings.RealtimeModel = request.RealtimeModel
|
s.settings.RealtimeModel = request.RealtimeModel
|
||||||
}
|
}
|
||||||
|
if request.SpeechModel != "" {
|
||||||
|
s.settings.SpeechModel = request.SpeechModel
|
||||||
|
}
|
||||||
|
if request.SpeechVoice != "" {
|
||||||
|
s.settings.SpeechVoice = request.SpeechVoice
|
||||||
|
}
|
||||||
|
if models := cleanModels(request.GeminiEvaluationModels); len(models) > 0 {
|
||||||
|
s.settings.GeminiEvaluationModels = models
|
||||||
|
}
|
||||||
|
if models := cleanModels(request.GeminiTranscriptionModels); len(models) > 0 {
|
||||||
|
s.settings.GeminiTranscriptionModels = models
|
||||||
|
}
|
||||||
|
if models := cleanModels(request.GeminiSpeechModels); len(models) > 0 {
|
||||||
|
s.settings.GeminiSpeechModels = models
|
||||||
|
}
|
||||||
|
if request.GeminiSpeechVoice != "" {
|
||||||
|
s.settings.GeminiSpeechVoice = request.GeminiSpeechVoice
|
||||||
|
}
|
||||||
s.settings.DemoMode = request.DemoMode
|
s.settings.DemoMode = request.DemoMode
|
||||||
s.settings.HasAPIKey = s.apiKey != ""
|
s.settings.HasOpenAIKey = s.apiKey != ""
|
||||||
|
s.settings.HasGeminiKey = s.geminiKey != ""
|
||||||
|
s.settings.HasAPIKey = (s.settings.Provider == "openai" && s.settings.HasOpenAIKey) ||
|
||||||
|
(s.settings.Provider == "gemini" && s.settings.HasGeminiKey)
|
||||||
return s.settings, nil
|
return s.settings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AppService) TestConnection() error {
|
func (s *AppService) TestConnection() error {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
key := s.apiKey
|
key := s.apiKey
|
||||||
|
geminiKey := s.geminiKey
|
||||||
|
provider := s.settings.Provider
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
|
if provider == "gemini" {
|
||||||
|
if geminiKey == "" {
|
||||||
|
return errors.New("Gemini API 키가 설정되지 않았습니다")
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "https://generativelanguage.googleapis.com/v1beta/models", nil)
|
||||||
|
req.Header.Set("x-goog-api-key", geminiKey)
|
||||||
|
res, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Gemini 연결 실패: %w", err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode >= 300 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(res.Body, 2048))
|
||||||
|
return fmt.Errorf("Gemini 연결 실패 (%d): %s", res.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return errors.New("OpenAI API 키가 설정되지 않았습니다")
|
return errors.New("OpenAI API 키가 설정되지 않았습니다")
|
||||||
}
|
}
|
||||||
@@ -91,6 +156,73 @@ func (s *AppService) TestConnection() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AppService) GenerateSpeech(text string) (SpeechResponse, error) {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return SpeechResponse{}, errors.New("읽을 질문이 없습니다")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
key := s.apiKey
|
||||||
|
geminiKey := s.geminiKey
|
||||||
|
settings := s.settings
|
||||||
|
cacheKey := settings.Provider + "|" + settings.SpeechModel + "|" + settings.SpeechVoice + "|" +
|
||||||
|
strings.Join(settings.GeminiSpeechModels, ",") + "|" + settings.GeminiSpeechVoice + "|" + text
|
||||||
|
cached, ok := s.speech[cacheKey]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if ok {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
if settings.DemoMode {
|
||||||
|
return SpeechResponse{}, errors.New("자연스러운 AI 음성을 사용하려면 API 연결과 데모 모드 해제가 필요합니다")
|
||||||
|
}
|
||||||
|
if settings.Provider == "gemini" {
|
||||||
|
if geminiKey == "" {
|
||||||
|
return SpeechResponse{}, errors.New("Gemini API 키가 설정되지 않았습니다")
|
||||||
|
}
|
||||||
|
result, err := s.generateGeminiSpeech(text, geminiKey, settings)
|
||||||
|
if err != nil {
|
||||||
|
return SpeechResponse{}, err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.speech[cacheKey] = result
|
||||||
|
s.mu.Unlock()
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
return SpeechResponse{}, errors.New("OpenAI API 키가 설정되지 않았습니다")
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"model": settings.SpeechModel,
|
||||||
|
"voice": settings.SpeechVoice,
|
||||||
|
"input": text,
|
||||||
|
"instructions": "Speak as a professional American English speaking-test interviewer. Use a natural, warm, neutral conversational tone. Pronounce every word clearly without sounding robotic. Use subtle American English intonation, natural reductions, and brief pauses at commas and between ideas. Do not sound dramatic, cheerful, or like an advertisement. Ask the question once at a measured interview pace.",
|
||||||
|
"response_format": "mp3",
|
||||||
|
})
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, "https://api.openai.com/v1/audio/speech", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+key)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return SpeechResponse{}, fmt.Errorf("AI 음성 생성 요청 실패: %w", err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
audio, _ := io.ReadAll(res.Body)
|
||||||
|
if res.StatusCode >= 300 {
|
||||||
|
return SpeechResponse{}, fmt.Errorf("AI 음성 생성 실패 (%d): %s", res.StatusCode, string(audio))
|
||||||
|
}
|
||||||
|
|
||||||
|
result := SpeechResponse{
|
||||||
|
AudioBase64: base64.StdEncoding.EncodeToString(audio),
|
||||||
|
MimeType: "audio/mpeg",
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.speech[cacheKey] = result
|
||||||
|
s.mu.Unlock()
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AppService) StartSession(config ExamConfig) (StartSessionResponse, error) {
|
func (s *AppService) StartSession(config ExamConfig) (StartSessionResponse, error) {
|
||||||
if len(config.Topics) == 0 {
|
if len(config.Topics) == 0 {
|
||||||
return StartSessionResponse{}, errors.New("주제를 하나 이상 선택하세요")
|
return StartSessionResponse{}, errors.New("주제를 하나 이상 선택하세요")
|
||||||
@@ -130,7 +262,7 @@ func (s *AppService) SubmitAnswer(request SubmitAnswerRequest) (SubmitAnswerResp
|
|||||||
transcript = demoTranscript(session.Questions[request.QuestionIdx])
|
transcript = demoTranscript(session.Questions[request.QuestionIdx])
|
||||||
} else {
|
} else {
|
||||||
var err error
|
var err error
|
||||||
transcript, err = s.transcribe(request.AudioBase64, request.AudioMime)
|
transcript, err = s.transcribe(request.AudioBase64, request.AudioMime, settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return SubmitAnswerResponse{}, err
|
return SubmitAnswerResponse{}, err
|
||||||
}
|
}
|
||||||
@@ -145,8 +277,12 @@ func (s *AppService) SubmitAnswer(request SubmitAnswerRequest) (SubmitAnswerResp
|
|||||||
var err error
|
var err error
|
||||||
if settings.DemoMode {
|
if settings.DemoMode {
|
||||||
evaluation = evaluateDemo(question, transcript, request.DurationSec)
|
evaluation = evaluateDemo(question, transcript, request.DurationSec)
|
||||||
|
} else {
|
||||||
|
if settings.Provider == "gemini" {
|
||||||
|
evaluation, err = s.evaluateWithGemini(session, question, transcript, request.DurationSec, settings)
|
||||||
} else {
|
} else {
|
||||||
evaluation, err = s.evaluateWithOpenAI(session, question, transcript, request.DurationSec)
|
evaluation, err = s.evaluateWithOpenAI(session, question, transcript, request.DurationSec)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return SubmitAnswerResponse{}, err
|
return SubmitAnswerResponse{}, err
|
||||||
}
|
}
|
||||||
@@ -186,7 +322,26 @@ func (s *AppService) GetReport(sessionID string) (ExamReport, error) {
|
|||||||
return buildReport(session), nil
|
return buildReport(session), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AppService) transcribe(encoded, mimeType string) (string, error) {
|
func (s *AppService) FinalizeSession(sessionID string) (ExamReport, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
session, ok := s.sessions[sessionID]
|
||||||
|
if !ok {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return ExamReport{}, errors.New("시험 세션을 찾을 수 없습니다")
|
||||||
|
}
|
||||||
|
if len(session.Answers) == 0 {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return ExamReport{}, errors.New("총평을 생성하려면 한 문항 이상 답변해야 합니다")
|
||||||
|
}
|
||||||
|
session.Completed = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
return buildReport(session), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppService) transcribe(encoded, mimeType string, settings AppSettings) (string, error) {
|
||||||
|
if settings.Provider == "gemini" {
|
||||||
|
return s.transcribeWithGemini(encoded, mimeType, settings)
|
||||||
|
}
|
||||||
raw, err := base64.StdEncoding.DecodeString(encoded)
|
raw, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", errors.New("녹음 데이터를 읽을 수 없습니다")
|
return "", errors.New("녹음 데이터를 읽을 수 없습니다")
|
||||||
@@ -238,7 +393,7 @@ func (s *AppService) evaluateWithOpenAI(session *ExamSession, question Question,
|
|||||||
inputJSON, _ := json.Marshal(input)
|
inputJSON, _ := json.Marshal(input)
|
||||||
prompt := `You are a rigorous OPIc speaking coach. Evaluate the learner's English answer from 0 to 100.
|
prompt := `You are a rigorous OPIc speaking coach. Evaluate the learner's English answer from 0 to 100.
|
||||||
Judge task completion, detail, organization, vocabulary, grammar, fluency inferred from transcript and duration, and natural spoken style.
|
Judge task completion, detail, organization, vocabulary, grammar, fluency inferred from transcript and duration, and natural spoken style.
|
||||||
Do not inflate the score. Return concise Korean coaching feedback. Keywords must be useful English words or phrases from the answer.
|
Do not inflate the score. Return all coaching feedback, strengths, and improvements in concise natural English. Keywords must be useful English words or phrases from the answer.
|
||||||
The next question is already controlled by the application, so do not generate one.
|
The next question is already controlled by the application, so do not generate one.
|
||||||
|
|
||||||
INPUT:
|
INPUT:
|
||||||
@@ -308,6 +463,344 @@ func extractResponseText(payload []byte) (string, error) {
|
|||||||
return "", errors.New("OpenAI 응답에 평가 텍스트가 없습니다")
|
return "", errors.New("OpenAI 응답에 평가 텍스트가 없습니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AppService) evaluateWithGemini(session *ExamSession, question Question, transcript string, duration int, settings AppSettings) (AnswerEvaluation, error) {
|
||||||
|
history := make([]map[string]any, 0, len(session.Answers))
|
||||||
|
for _, answer := range session.Answers {
|
||||||
|
history = append(history, map[string]any{
|
||||||
|
"question": answer.Question.Text, "answer": answer.Transcript, "score": answer.Evaluation.Score,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
input := map[string]any{
|
||||||
|
"difficulty": session.Config.Difficulty, "topics": session.Config.Topics,
|
||||||
|
"question": question, "answer": transcript, "duration_seconds": duration, "previous_turns": history,
|
||||||
|
}
|
||||||
|
inputJSON, _ := json.Marshal(input)
|
||||||
|
prompt := `You are a rigorous OPIc speaking coach. Evaluate the learner's English answer from 0 to 100.
|
||||||
|
Judge task completion, detail, organization, vocabulary, grammar, fluency inferred from transcript and duration, and natural spoken style.
|
||||||
|
Do not inflate the score. Return all coaching feedback, strengths, and improvements in concise natural English. Keywords must be useful English words or phrases from the answer.
|
||||||
|
The next question is already controlled by the application, so do not generate one.
|
||||||
|
|
||||||
|
INPUT:
|
||||||
|
` + string(inputJSON)
|
||||||
|
schema := evaluationSchema()
|
||||||
|
payload, _, err := s.callGeminiValidatedWithFallback(settings.GeminiEvaluationModels, func(model string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"model": model,
|
||||||
|
"input": prompt,
|
||||||
|
"response_format": map[string]any{
|
||||||
|
"type": "text", "mime_type": "application/json", "schema": schema,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}, func(payload []byte) error {
|
||||||
|
text, err := extractGeminiText(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var evaluation AnswerEvaluation
|
||||||
|
if err := json.Unmarshal([]byte(text), &evaluation); err != nil {
|
||||||
|
return fmt.Errorf("평가 JSON 오류: %w", err)
|
||||||
|
}
|
||||||
|
if evaluation.Score < 0 || evaluation.Score > 100 {
|
||||||
|
return errors.New("평가 점수가 0~100 범위를 벗어남")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return AnswerEvaluation{}, fmt.Errorf("Gemini 답변 평가 실패: %w", err)
|
||||||
|
}
|
||||||
|
text, err := extractGeminiText(payload)
|
||||||
|
if err != nil {
|
||||||
|
return AnswerEvaluation{}, err
|
||||||
|
}
|
||||||
|
var evaluation AnswerEvaluation
|
||||||
|
if err := json.Unmarshal([]byte(text), &evaluation); err != nil {
|
||||||
|
return AnswerEvaluation{}, fmt.Errorf("Gemini 평가 결과를 해석할 수 없습니다: %w", err)
|
||||||
|
}
|
||||||
|
if evaluation.Score < 0 || evaluation.Score > 100 {
|
||||||
|
return AnswerEvaluation{}, errors.New("Gemini 평가 점수가 0~100 범위를 벗어났습니다")
|
||||||
|
}
|
||||||
|
return evaluation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppService) transcribeWithGemini(encoded, mimeType string, settings AppSettings) (string, error) {
|
||||||
|
if strings.TrimSpace(encoded) == "" {
|
||||||
|
return "", errors.New("녹음 데이터가 없습니다")
|
||||||
|
}
|
||||||
|
if mimeType == "" {
|
||||||
|
mimeType = "audio/webm"
|
||||||
|
}
|
||||||
|
payload, _, err := s.callGeminiValidatedWithFallback(settings.GeminiTranscriptionModels, func(model string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"model": model,
|
||||||
|
"input": []map[string]any{
|
||||||
|
{"type": "text", "text": "Transcribe only the spoken English in this audio accurately. Return plain text only. Preserve natural punctuation and do not add commentary."},
|
||||||
|
{"type": "audio", "data": encoded, "mime_type": mimeType},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}, func(payload []byte) error {
|
||||||
|
text, err := extractGeminiText(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
return errors.New("빈 전사 결과")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("Gemini 음성 전사 실패: %w", err)
|
||||||
|
}
|
||||||
|
text, err := extractGeminiText(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(text), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppService) generateGeminiSpeech(text, key string, settings AppSettings) (SpeechResponse, error) {
|
||||||
|
prompt := `Speak as a professional American English speaking-test interviewer. Use a natural, warm, neutral conversational tone, clear pronunciation, subtle American intonation, and brief pauses between ideas. Do not sound dramatic or like an advertisement. Ask this question exactly once:
|
||||||
|
|
||||||
|
` + text
|
||||||
|
payload, model, err := s.callGeminiWithKeyValidatedFallback(key, settings.GeminiSpeechModels, func(model string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"model": model,
|
||||||
|
"input": prompt,
|
||||||
|
"response_format": map[string]any{"type": "audio"},
|
||||||
|
"generation_config": map[string]any{
|
||||||
|
"speech_config": []map[string]any{{"voice": settings.GeminiSpeechVoice}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}, func(payload []byte) error {
|
||||||
|
audio, err := extractGeminiAudio(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(audio) == 0 {
|
||||||
|
return errors.New("빈 오디오 결과")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return SpeechResponse{}, fmt.Errorf("Gemini TTS 실패: %w", err)
|
||||||
|
}
|
||||||
|
audio, err := extractGeminiAudio(payload)
|
||||||
|
if err != nil {
|
||||||
|
return SpeechResponse{}, fmt.Errorf("%s 응답의 음성을 해석할 수 없습니다: %w", model, err)
|
||||||
|
}
|
||||||
|
wav := pcmToWAV(audio, 24000, 1, 16)
|
||||||
|
return SpeechResponse{AudioBase64: base64.StdEncoding.EncodeToString(wav), MimeType: "audio/wav"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppService) callGeminiWithFallback(models []string, bodyForModel func(string) map[string]any) ([]byte, string, error) {
|
||||||
|
return s.callGeminiValidatedWithFallback(models, bodyForModel, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppService) callGeminiValidatedWithFallback(models []string, bodyForModel func(string) map[string]any, validate func([]byte) error) ([]byte, string, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
key := s.geminiKey
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if key == "" {
|
||||||
|
return nil, "", errors.New("Gemini API 키가 설정되지 않았습니다")
|
||||||
|
}
|
||||||
|
return s.callGeminiWithKeyValidatedFallback(key, models, bodyForModel, validate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppService) callGeminiWithKeyFallback(key string, models []string, bodyForModel func(string) map[string]any) ([]byte, string, error) {
|
||||||
|
return s.callGeminiWithKeyValidatedFallback(key, models, bodyForModel, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppService) callGeminiWithKeyValidatedFallback(key string, models []string, bodyForModel func(string) map[string]any, validate func([]byte) error) ([]byte, string, error) {
|
||||||
|
models = cleanModels(models)
|
||||||
|
if len(models) == 0 {
|
||||||
|
return nil, "", errors.New("시도할 Gemini 모델이 없습니다")
|
||||||
|
}
|
||||||
|
failures := make([]string, 0, len(models))
|
||||||
|
for _, model := range models {
|
||||||
|
encoded, _ := json.Marshal(bodyForModel(model))
|
||||||
|
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://generativelanguage.googleapis.com/v1beta/interactions", bytes.NewReader(encoded))
|
||||||
|
req.Header.Set("x-goog-api-key", key)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
failures = append(failures, model+": "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payload, readErr := io.ReadAll(res.Body)
|
||||||
|
res.Body.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
failures = append(failures, model+": "+readErr.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if res.StatusCode >= 300 {
|
||||||
|
failures = append(failures, fmt.Sprintf("%s: HTTP %d %s", model, res.StatusCode, compactError(payload)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(payload) == 0 {
|
||||||
|
failures = append(failures, model+": 빈 응답")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if validate != nil {
|
||||||
|
if err := validate(payload); err != nil {
|
||||||
|
failures = append(failures, model+": 응답 검증 실패: "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payload, model, nil
|
||||||
|
}
|
||||||
|
return nil, "", errors.New(strings.Join(failures, " | "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluationSchema() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"score": map[string]any{"type": "integer", "minimum": 0, "maximum": 100},
|
||||||
|
"keywords": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||||
|
"strengths": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||||
|
"improvements": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||||
|
"feedback": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
"required": []string{"score", "keywords", "strengths", "improvements", "feedback"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractGeminiText(payload []byte) (string, error) {
|
||||||
|
var root any
|
||||||
|
if err := json.Unmarshal(payload, &root); err != nil {
|
||||||
|
return "", fmt.Errorf("Gemini 응답 JSON 오류: %w", err)
|
||||||
|
}
|
||||||
|
if text := findStringField(root, "output_text"); text != "" {
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
if text := findTypedData(root, "text", "text"); text != "" {
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
return "", errors.New("Gemini 응답에 텍스트가 없습니다")
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractGeminiAudio(payload []byte) ([]byte, error) {
|
||||||
|
var root any
|
||||||
|
if err := json.Unmarshal(payload, &root); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
encoded := findNestedData(root, "output_audio")
|
||||||
|
if encoded == "" {
|
||||||
|
encoded = findTypedData(root, "audio", "data")
|
||||||
|
}
|
||||||
|
if encoded == "" {
|
||||||
|
return nil, errors.New("audio data 필드가 없습니다")
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.DecodeString(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findStringField(value any, key string) string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
if text, ok := typed[key].(string); ok && text != "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
for _, child := range typed {
|
||||||
|
if result := findStringField(child, key); result != "" {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range typed {
|
||||||
|
if result := findStringField(child, key); result != "" {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func findTypedData(value any, wantedType, field string) string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
if kind, _ := typed["type"].(string); kind == wantedType {
|
||||||
|
if result, _ := typed[field].(string); result != "" {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, child := range typed {
|
||||||
|
if result := findTypedData(child, wantedType, field); result != "" {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range typed {
|
||||||
|
if result := findTypedData(child, wantedType, field); result != "" {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func findNestedData(value any, key string) string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
if nested, ok := typed[key].(map[string]any); ok {
|
||||||
|
if data, _ := nested["data"].(string); data != "" {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, child := range typed {
|
||||||
|
if result := findNestedData(child, key); result != "" {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range typed {
|
||||||
|
if result := findNestedData(child, key); result != "" {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func pcmToWAV(pcm []byte, sampleRate, channels, bitsPerSample int) []byte {
|
||||||
|
var output bytes.Buffer
|
||||||
|
byteRate := sampleRate * channels * bitsPerSample / 8
|
||||||
|
blockAlign := channels * bitsPerSample / 8
|
||||||
|
output.WriteString("RIFF")
|
||||||
|
_ = binary.Write(&output, binary.LittleEndian, uint32(36+len(pcm)))
|
||||||
|
output.WriteString("WAVEfmt ")
|
||||||
|
_ = binary.Write(&output, binary.LittleEndian, uint32(16))
|
||||||
|
_ = binary.Write(&output, binary.LittleEndian, uint16(1))
|
||||||
|
_ = binary.Write(&output, binary.LittleEndian, uint16(channels))
|
||||||
|
_ = binary.Write(&output, binary.LittleEndian, uint32(sampleRate))
|
||||||
|
_ = binary.Write(&output, binary.LittleEndian, uint32(byteRate))
|
||||||
|
_ = binary.Write(&output, binary.LittleEndian, uint16(blockAlign))
|
||||||
|
_ = binary.Write(&output, binary.LittleEndian, uint16(bitsPerSample))
|
||||||
|
output.WriteString("data")
|
||||||
|
_ = binary.Write(&output, binary.LittleEndian, uint32(len(pcm)))
|
||||||
|
output.Write(pcm)
|
||||||
|
return output.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanModels(models []string) []string {
|
||||||
|
result := make([]string, 0, len(models))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, model := range models {
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
if model != "" && !seen[model] {
|
||||||
|
result = append(result, model)
|
||||||
|
seen[model] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func compactError(payload []byte) string {
|
||||||
|
text := strings.Join(strings.Fields(string(payload)), " ")
|
||||||
|
if len(text) > 350 {
|
||||||
|
return text[:350] + "…"
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
func evaluateDemo(question Question, transcript string, duration int) AnswerEvaluation {
|
func evaluateDemo(question Question, transcript string, duration int) AnswerEvaluation {
|
||||||
words := strings.Fields(transcript)
|
words := strings.Fields(transcript)
|
||||||
score := 38 + min(len(words)/2, 30) + min(duration/10, 10)
|
score := 38 + min(len(words)/2, 30) + min(duration/10, 10)
|
||||||
@@ -325,23 +818,23 @@ func evaluateDemo(question Question, transcript string, duration int) AnswerEval
|
|||||||
}
|
}
|
||||||
score = min(score, 92)
|
score = min(score, 92)
|
||||||
keywords := extractKeywords(transcript)
|
keywords := extractKeywords(transcript)
|
||||||
strengths := []string{"질문의 핵심 주제를 벗어나지 않았습니다.", "개인 경험을 중심으로 답변을 구성했습니다."}
|
strengths := []string{"The response stayed relevant to the main task.", "The answer was organized around a personal experience."}
|
||||||
improvements := []string{}
|
improvements := []string{}
|
||||||
if len(words) < 60 {
|
if len(words) < 60 {
|
||||||
improvements = append(improvements, "구체적인 장소·인물·감정 묘사를 추가해 답변을 60단어 이상으로 확장하세요.")
|
improvements = append(improvements, "Add specific details about places, people, and feelings, and develop the answer beyond 60 words.")
|
||||||
}
|
}
|
||||||
if len(used) < 2 {
|
if len(used) < 2 {
|
||||||
improvements = append(improvements, "because, for example, however 같은 연결 표현으로 문장 관계를 분명히 하세요.")
|
improvements = append(improvements, "Use connectors such as because, for example, and however to make relationships between ideas clear.")
|
||||||
}
|
}
|
||||||
if duration < 40 {
|
if duration < 40 {
|
||||||
improvements = append(improvements, "한 문항에 40~70초 정도 말할 수 있도록 이유와 사례를 하나씩 더하세요.")
|
improvements = append(improvements, "Add one reason and one example so the response can naturally continue for about 40 to 70 seconds.")
|
||||||
}
|
}
|
||||||
if len(improvements) == 0 {
|
if len(improvements) == 0 {
|
||||||
improvements = append(improvements, "같은 의미의 단어 반복을 줄이고 더 자연스러운 구어 표현을 사용하세요.")
|
improvements = append(improvements, "Reduce repeated vocabulary and use a wider range of natural spoken expressions.")
|
||||||
}
|
}
|
||||||
return AnswerEvaluation{
|
return AnswerEvaluation{
|
||||||
Score: score, Keywords: keywords, Strengths: strengths, Improvements: improvements,
|
Score: score, Keywords: keywords, Strengths: strengths, Improvements: improvements,
|
||||||
Feedback: "핵심 답변은 전달되었습니다. 배경 설명 → 구체적 사건 → 느낌과 결과 순서로 확장하면 더 높은 등급에 가까워집니다.",
|
Feedback: "The core message was clear. Develop the response through background, a specific event, and a final reaction or result to move toward a higher proficiency level.",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,7 +848,10 @@ func extractKeywords(text string) []string {
|
|||||||
counts[word]++
|
counts[word]++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
type pair struct{ word string; count int }
|
type pair struct {
|
||||||
|
word string
|
||||||
|
count int
|
||||||
|
}
|
||||||
pairs := []pair{}
|
pairs := []pair{}
|
||||||
for word, count := range counts {
|
for word, count := range counts {
|
||||||
pairs = append(pairs, pair{word, count})
|
pairs = append(pairs, pair{word, count})
|
||||||
@@ -380,28 +876,127 @@ func buildReport(session *ExamSession) ExamReport {
|
|||||||
if len(session.Answers) > 0 {
|
if len(session.Answers) > 0 {
|
||||||
score = total / len(session.Answers)
|
score = total / len(session.Answers)
|
||||||
}
|
}
|
||||||
band := "NL–NM"
|
band := "NL"
|
||||||
switch {
|
switch {
|
||||||
case score >= 88:
|
case score >= 88:
|
||||||
band = "AL 가능권"
|
band = "AL"
|
||||||
case score >= 78:
|
case score >= 78:
|
||||||
band = "IH 가능권"
|
band = "IH"
|
||||||
|
case score >= 70:
|
||||||
|
band = "IM3"
|
||||||
case score >= 65:
|
case score >= 65:
|
||||||
band = "IM2–IM3 가능권"
|
band = "IM2"
|
||||||
case score >= 52:
|
case score >= 52:
|
||||||
band = "IM1 가능권"
|
band = "IM1"
|
||||||
|
case score >= 45:
|
||||||
|
band = "IL"
|
||||||
|
case score >= 37:
|
||||||
|
band = "NH"
|
||||||
|
case score >= 28:
|
||||||
|
band = "NM"
|
||||||
}
|
}
|
||||||
|
partial := len(session.Answers) < len(session.Questions)
|
||||||
|
summary := fmt.Sprintf("This estimate is based on %d completed responses and considers task completion, detail, organization, vocabulary, grammar, and response length.", len(session.Answers))
|
||||||
|
strengths := []string{
|
||||||
|
"Responses generally remain connected to the assigned speaking task.",
|
||||||
|
"Personal experiences are used to support the main message.",
|
||||||
|
"The completed responses show a consistent attempt to develop ideas.",
|
||||||
|
}
|
||||||
|
weaknesses := []string{
|
||||||
|
"Some responses may not include enough concrete supporting detail.",
|
||||||
|
"Organization can weaken when transitions between ideas are limited.",
|
||||||
|
"Vocabulary and sentence patterns may become repetitive under time pressure.",
|
||||||
|
}
|
||||||
|
priorities := []string{
|
||||||
|
"Use a clear structure: background, specific example, reaction, and result.",
|
||||||
|
"Add at least one concrete detail and one reason to every response.",
|
||||||
|
"Practice flexible connectors and replace repeated words with natural alternatives.",
|
||||||
|
}
|
||||||
|
if !partial {
|
||||||
|
summary = "This estimate reflects all completed questions and considers task completion, detail, organization, vocabulary, grammar, and response length."
|
||||||
|
strengths[2] = "The full mock interview was completed across multiple OPIc task types."
|
||||||
|
}
|
||||||
|
predictions := buildGradePredictions(score)
|
||||||
|
target := normalizeTargetGrade(session.Config.Difficulty)
|
||||||
|
targetProbability := gradeProbability(predictions, target)
|
||||||
|
targetStatus := likelihoodStatus(targetProbability)
|
||||||
return ExamReport{
|
return ExamReport{
|
||||||
SessionID: session.ID, Config: session.Config, Answers: session.Answers, GeneratedAt: time.Now(),
|
SessionID: session.ID, Config: session.Config, Answers: session.Answers,
|
||||||
|
AnsweredCount: len(session.Answers), TotalCount: len(session.Questions), Partial: partial, GeneratedAt: time.Now(),
|
||||||
Overall: OverallReport{
|
Overall: OverallReport{
|
||||||
Score: score, EstimatedBand: band,
|
Score: score, EstimatedBand: band, Summary: summary,
|
||||||
Summary: "전 문항의 과업 수행, 구체성, 구성, 어휘·문법, 답변 길이를 종합한 연습용 추정치입니다.",
|
Strengths: strengths, Weaknesses: weaknesses, Priorities: priorities,
|
||||||
Strengths: []string{"개인 경험 중심의 응답", "질문별 핵심 과업 수행", "시험 전 범위 완주"},
|
TargetGrade: target, TargetStatus: targetStatus, TargetProbability: targetProbability,
|
||||||
Priorities: []string{"답변마다 구체적 사례 1개 추가", "연결어로 이야기 구조 강화", "반복 어휘를 자연스러운 동의어로 교체"},
|
GradePredictions: predictions,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildGradePredictions(score int) []GradePrediction {
|
||||||
|
levels := []struct {
|
||||||
|
grade string
|
||||||
|
threshold int
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{"AL", 88, "Sustained, well-organized narration and discussion of complex topics."},
|
||||||
|
{"IH", 78, "Detailed connected speech with effective handling of most situations."},
|
||||||
|
{"IM3", 70, "Consistent paragraph-length responses with supporting details."},
|
||||||
|
{"IM2", 65, "Generally connected responses with some detail and control."},
|
||||||
|
{"IM1", 52, "Simple connected responses on familiar topics."},
|
||||||
|
{"IL", 45, "Short sentence-level responses with limited development."},
|
||||||
|
{"NH", 37, "Basic sentences and phrases for familiar situations."},
|
||||||
|
{"NM", 28, "Memorized phrases and simple personal information."},
|
||||||
|
{"NL", 0, "Isolated words and highly memorized expressions."},
|
||||||
|
}
|
||||||
|
result := make([]GradePrediction, 0, len(levels))
|
||||||
|
for _, level := range levels {
|
||||||
|
probability := 50 + (score-level.threshold)*5
|
||||||
|
if level.grade == "NL" {
|
||||||
|
probability = 100
|
||||||
|
}
|
||||||
|
if probability < 2 {
|
||||||
|
probability = 2
|
||||||
|
}
|
||||||
|
if probability > 98 {
|
||||||
|
probability = 98
|
||||||
|
}
|
||||||
|
result = append(result, GradePrediction{
|
||||||
|
Grade: level.grade, Probability: probability,
|
||||||
|
Status: likelihoodStatus(probability), Description: level.description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTargetGrade(target string) string {
|
||||||
|
target = strings.ToUpper(strings.TrimSpace(target))
|
||||||
|
valid := map[string]bool{"AL": true, "IH": true, "IM3": true, "IM2": true, "IM1": true, "IL": true, "NH": true, "NM": true, "NL": true}
|
||||||
|
if valid[target] {
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
return "IM2"
|
||||||
|
}
|
||||||
|
|
||||||
|
func gradeProbability(predictions []GradePrediction, grade string) int {
|
||||||
|
for _, prediction := range predictions {
|
||||||
|
if prediction.Grade == grade {
|
||||||
|
return prediction.Probability
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func likelihoodStatus(probability int) string {
|
||||||
|
switch {
|
||||||
|
case probability >= 70:
|
||||||
|
return "Likely"
|
||||||
|
case probability >= 35:
|
||||||
|
return "Possible"
|
||||||
|
default:
|
||||||
|
return "Unlikely"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func demoTranscript(question Question) string {
|
func demoTranscript(question Question) string {
|
||||||
return "I would like to talk about " + question.Topic + ". I have been interested in it for several years because it gives me a chance to relax and spend meaningful time with people close to me. For example, last weekend I made a detailed plan and tried something new. At first, there was a small problem, but I changed the plan and everything worked out well. I felt proud and satisfied, so it became a memorable experience for me."
|
return "I would like to talk about " + question.Topic + ". I have been interested in it for several years because it gives me a chance to relax and spend meaningful time with people close to me. For example, last weekend I made a detailed plan and tried something new. At first, there was a small problem, but I changed the plan and everything worked out well. I felt proud and satisfied, so it became a memorable experience for me."
|
||||||
}
|
}
|
||||||
@@ -418,4 +1013,3 @@ func min(a, b int) int {
|
|||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user