diff --git a/.gitignore b/.gitignore index f87574a..2c1af07 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ docs/private # AI 도구 로컬 설정 (팀 공유는 .claude/settings.json) .claude/settings.local.json CLAUDE.local.md + +# scripts/measure-latency.sh 결과 (원자료는 PR에 요약만) +latency-*.csv diff --git a/Paxo/AI/GeminiService.swift b/Paxo/AI/GeminiService.swift index 22784d5..8a9da3f 100644 --- a/Paxo/AI/GeminiService.swift +++ b/Paxo/AI/GeminiService.swift @@ -100,7 +100,7 @@ struct GeminiService { do { (data, response) = try await Self.session.data(for: request) } catch let error as URLError { - throw GeminiError.network(error) + throw Self.classify(error) } guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { @@ -127,6 +127,11 @@ struct GeminiService { return text } + /// 사용자가 취소한 요청은 네트워크 오류가 아니다. 오류 화면으로 보내지 않도록 취소로 구분한다. + static func classify(_ error: URLError) -> Error { + error.code == .cancelled ? CancellationError() : GeminiError.network(error) + } + private static func serverMessage(from data: Data) -> String? { struct ErrorEnvelope: Decodable { struct Inner: Decodable { let message: String? } diff --git a/Paxo/AppState.swift b/Paxo/AppState.swift index d856963..1ed1b4a 100644 --- a/Paxo/AppState.swift +++ b/Paxo/AppState.swift @@ -1,5 +1,6 @@ import AppKit import Combine +import ImageIO import ServiceManagement @MainActor @@ -20,6 +21,8 @@ final class AppState: ObservableObject { @Published private(set) var phase: SolvePhase = .idle @Published private(set) var current: SolveResult? @Published private(set) var history: [SolveResult] = [] + /// 지금 기다리는 요청이 시작된 시각. 대기 화면이 경과 시간으로 안내 단계를 고른다. + @Published private(set) var waitingSince: Date? // MARK: - 설정 @@ -46,6 +49,10 @@ final class AppState: ObservableObject { @Published var toastDuration: Double { didSet { UserDefaults.standard.set(toastDuration, forKey: "toastDuration") } } + /// 토스트가 기다리는 동안의 모습 (단계 안내 / 간단히) + @Published var toastWaitingStyle: ToastWaitingStyle { + didSet { UserDefaults.standard.set(toastWaitingStyle.rawValue, forKey: "toastWaitingStyle") } + } /// 전역 단축키 (설정에서 변경 가능) @Published var hotkey: HotkeySpec { didSet { @@ -117,6 +124,11 @@ final class AppState: ObservableObject { return imageCache[current.id] != nil } + /// 대기 화면에 보여줄 캡처 미리보기. 결과 ID로 찾으므로 기록을 바꾸면 따라 바뀐다. + var currentThumbnail: NSImage? { + current.flatMap { thumbnailCache[$0.id] } + } + private let hotkeyManager = HotkeyManager() private let capturer = ScreenCapturer() private let resultPanel = ResultPanelController() @@ -127,7 +139,10 @@ final class AppState: ObservableObject { private let usage = UsageTracker() /// 세션 내 캡처 이미지 캐시 (SolveResult.id 키). 해설 재생성용. 디스크 저장 안 함. private var imageCache: [UUID: Data] = [:] + private var thumbnailCache: [UUID: NSImage] = [:] private var imageCacheOrder: [UUID] = [] + private let requestGate = SolveRequestGate() + private var activeTask: Task? private static let imageCacheLimit = 8 /// 마지막으로 캡처한 화면 — 결과 패널/토스트를 같은 화면에 띄우기 위함 private var lastCaptureScreen: NSScreen? @@ -142,6 +157,7 @@ final class AppState: ObservableObject { resultDisplayMode = ResultDisplayMode(rawValue: UserDefaults.standard.string(forKey: "resultDisplayMode") ?? "") ?? .panel toastDuration = UserDefaults.standard.object(forKey: "toastDuration") as? Double ?? 4.0 + toastWaitingStyle = ToastWaitingStyle.restored(from: UserDefaults.standard.string(forKey: "toastWaitingStyle")) if let data = UserDefaults.standard.data(forKey: "hotkeySpec"), let spec = try? JSONDecoder().decode(HotkeySpec.self, from: data) { @@ -202,7 +218,7 @@ final class AppState: ObservableObject { return } phase = .capturing - Task { await runSolve() } + activeTask = Task { await runSolve() } } func showPaywall() { @@ -217,33 +233,82 @@ final class AppState: ObservableObject { func showFromHistory(_ item: SolveResult) { // 기록 다시 보기는 항상 패널로 (해설 확인·재생성 가능). // 같은 세션에 캡처 이미지가 캐시에 남아 있으면 "해설 보기"로 재생성 가능. + // 진행 중인 요청은 먼저 끊는다. 두면 늦은 응답이 방금 연 기록을 덮어쓴다. + cancelSolve() toastDismissTask?.cancel() - toast.dismiss() + toast.dismiss(animated: false) current = item phase = item.explanation == nil ? .answerReady : .done resultPanel.show(appState: self, on: Self.screenUnderMouse()) } func requestExplanation() { - Task { await runExplanation() } + // 이미 날아가는 요청이 있으면 연타를 무시한다 + guard !requestGate.isActive else { return } + let requestID = requestGate.begin() + activeTask = Task { await runExplanation(requestID: requestID) } + } + + /// 대기 중인 요청을 취소한다. + /// 정답 대기 중이면 풀이 자체를 접는다 — 정답을 확정하지 않았으므로 무료 횟수는 그대로다. + /// 해설 대기 중이면 정답은 남기고 "해설 보기"로 다시 요청할 수 있게 둔다. + func cancelSolve() { + guard requestGate.isActive else { return } + requestGate.invalidate() + activeTask?.cancel() + activeTask = nil + waitingSince = nil + switch phase { + case .solvingAnswer: + phase = .idle + resultPanel.hide() + toast.dismiss(animated: false) + case .solvingExplanation: + phase = .answerReady + default: + break + } } private func runSolve() async { toastDismissTask?.cancel() + // 작은 정답 토스트가 남은 채 넓은 대기 화면으로 바뀌면 잘린다. 대기 토스트는 요청 시작 때 새 크기로 다시 뜬다. + toast.dismiss(animated: false) + // 영역을 고르는 동안 기록으로 화면을 바꿀 수 있으므로 소유권은 캡처 전부터 잡는다 + let requestID = requestGate.begin() + let capture: ScreenCapturer.Capture do { - guard let capture = try await capturer.captureInteractive(mode: captureMode) else { + let captured = try await capturer.captureInteractive(mode: captureMode) + guard requestGate.isCurrent(requestID) else { return } + guard let captured else { + requestGate.finish(requestID) phase = .idle return } - lastCaptureScreen = capture.screen - let result = SolveResult(preset: preset) - cacheImage(capture.data, for: result.id) - current = result - phase = .solvingAnswer - presentSolving() + capture = captured + } catch { + guard requestGate.isCurrent(requestID) else { return } + requestGate.finish(requestID) + toast.dismiss(animated: false) + phase = .failedAnswer(errorMessage(from: error)) + resultPanel.show(appState: self, on: lastCaptureScreen) + return + } + + lastCaptureScreen = capture.screen + let result = SolveResult(preset: preset) + cacheImage(capture.data, for: result.id) + current = result + waitingSince = Date() + phase = .solvingAnswer + presentSolving() + do { let answer = try await makeService() - .answer(imageData: capture.data, preset: preset) + .answer(imageData: capture.data, preset: result.preset) + // 취소가 먼저 처리됐으면 응답을 버린다. 정답을 확정하지 않았으니 차감도 하지 않는다. + guard requestGate.isCurrent(requestID) else { return } + waitingSince = nil current?.answer = answer phase = .answerReady upsertHistory() @@ -255,13 +320,24 @@ final class AppState: ObservableObject { } if resultDisplayMode == .toast { + requestGate.finish(requestID) toast.show(appState: self, on: lastCaptureScreen) scheduleToastDismiss() } else if !quickCheckMode { - await runExplanation() + await runExplanation(requestID: requestID) + } else { + requestGate.finish(requestID) } } catch { - toast.dismiss() + guard requestGate.isCurrent(requestID) else { return } + requestGate.finish(requestID) + waitingSince = nil + toast.dismiss(animated: false) + if error is CancellationError { + phase = .idle + resultPanel.hide() + return + } phase = .failedAnswer(errorMessage(from: error)) resultPanel.show(appState: self, on: lastCaptureScreen) } @@ -270,7 +346,7 @@ final class AppState: ObservableObject { private func presentSolving() { switch resultDisplayMode { case .panel: - toast.dismiss() + toast.dismiss(animated: false) resultPanel.show(appState: self, on: lastCaptureScreen) case .toast: resultPanel.hide() @@ -281,41 +357,70 @@ final class AppState: ObservableObject { private func scheduleToastDismiss() { toastDismissTask?.cancel() let seconds = toastDuration + let resultID = current?.id toastDismissTask = Task { [weak self] in try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - guard !Task.isCancelled else { return } - self?.toast.dismiss() - self?.phase = .idle + // 그 사이 새 풀이가 시작됐으면 오래된 타이머가 새 화면을 지우지 않게 한다 + guard !Task.isCancelled, let self, self.current?.id == resultID else { return } + self.toast.dismiss() + self.phase = .idle } } - private func runExplanation() async { + private func runExplanation(requestID: UUID) async { + // 예약만 되고 실행 전에 취소된 작업이 새 화면을 건드리지 않게 한다 + guard requestGate.isCurrent(requestID), !Task.isCancelled else { return } guard let current, let image = imageCache[current.id], let answer = current.answer, current.explanation == nil - else { return } + else { + requestGate.finish(requestID) + return + } + waitingSince = Date() phase = .solvingExplanation do { let explanation = try await makeService() - .explain(imageData: image, answer: answer, preset: preset) + .explain(imageData: image, answer: answer, preset: current.preset) + guard requestGate.isCurrent(requestID) else { return } + requestGate.finish(requestID) + waitingSince = nil + guard self.current?.id == current.id else { return } self.current?.explanation = explanation phase = .done upsertHistory() } catch { - phase = .failedExplanation(errorMessage(from: error)) + guard requestGate.isCurrent(requestID) else { return } + requestGate.finish(requestID) + waitingSince = nil + phase = error is CancellationError ? .answerReady : .failedExplanation(errorMessage(from: error)) } } private func cacheImage(_ data: Data, for id: UUID) { imageCache[id] = data + thumbnailCache[id] = Self.makeThumbnail(from: data) imageCacheOrder.append(id) while imageCacheOrder.count > Self.imageCacheLimit { let old = imageCacheOrder.removeFirst() imageCache[old] = nil + thumbnailCache[old] = nil } } + /// 대기 화면용 작은 미리보기. 원본(최대 2000px)을 그대로 쥐면 캐시 8장이 메모리를 크게 먹는다. + private static func makeThumbnail(from data: Data) -> NSImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 240, + ] + guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil } + return NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height)) + } + static func screenUnderMouse() -> NSScreen? { let location = NSEvent.mouseLocation return NSScreen.screens.first { $0.frame.contains(location) } diff --git a/Paxo/Models.swift b/Paxo/Models.swift index a839868..74a15b1 100644 --- a/Paxo/Models.swift +++ b/Paxo/Models.swift @@ -111,3 +111,96 @@ struct SolveResult: Identifiable, Codable, Equatable { self.preset = preset } } + +/// 응답을 기다리는 동안의 안내 단계. +/// 스피너만 돌면 멈춘 것처럼 느껴져서, 기다린 시간에 맞춰 문구를 바꾸고 오래 걸리면 취소를 연다. +enum WaitingStage: CaseIterable, Equatable { + case reading + case thinking + case finishing + case slow + case explaining + + /// 이 시간을 넘기면 오래 걸린다고 알린다 + static let slowThreshold: TimeInterval = 15 + + /// 취소를 여는 시점. 캡처 직후 실수로 눌러 멀쩡한 요청을 끊는 것만 막는다. + static let cancelThreshold: TimeInterval = 3 + + /// 정답 대기. 시계가 뒤로 가서 음수가 나와도 첫 단계로 본다. + static func answerStage(elapsed: TimeInterval) -> WaitingStage { + switch elapsed { + case ..<3: return .reading + case ..<8: return .thinking + case .. WaitingStage { + elapsed < slowThreshold ? .explaining : .slow + } + + static func allowsCancel(elapsed: TimeInterval) -> Bool { + elapsed >= cancelThreshold + } + + /// 실제 진행률을 모르므로 "곧 끝난다"고 약속하는 문구는 쓰지 않는다 + var message: String { + switch self { + case .reading: return "문제를 읽고 있어요" + case .thinking: return "풀이를 떠올리는 중이에요" + case .finishing: return "답을 정리하고 있어요" + case .slow: return "조금 오래 걸리고 있어요" + case .explaining: return "해설을 정리하고 있어요" + } + } + + var detail: String? { + self == .slow ? "복잡한 문제일수록 시간이 더 걸려요." : nil + } + + var symbolName: String { + switch self { + case .reading: return "eye" + case .thinking: return "lightbulb" + case .finishing: return "pencil" + case .slow: return "hourglass" + case .explaining: return "book" + } + } +} + +/// 토스트가 기다리는 동안 보여줄 모습 +enum ToastWaitingStyle: String, CaseIterable, Codable, Identifiable { + case detailed + case simple + + var id: String { rawValue } + + var displayName: String { + switch self { + case .detailed: return "단계 안내 (기본)" + case .simple: return "간단히" + } + } + + /// 저장된 값이 없거나 알 수 없으면 개선된 안내를 기본으로 쓴다 + static func restored(from rawValue: String?) -> ToastWaitingStyle { + rawValue.flatMap(ToastWaitingStyle.init(rawValue:)) ?? .detailed + } +} + +/// 토스트의 원형 진행 표시에 쓰는 추정 진행률. +/// 서버는 답을 한 번에 보내서 실제 진행률을 알 수 없다. 기다린 시간으로 처음엔 빠르게, 점점 느리게 채우고 +/// 답이 오기 전에는 가득 차지 않도록 90%에서 멈춘다. +enum WaitingProgress { + static let ceiling = 0.9 + /// 곡선이 차오르는 속도. 측정 스크립트로 정답 응답 시간을 잰 뒤 조정한다. + static let timeConstant: TimeInterval = 5 + + static func estimatedFraction(elapsed: TimeInterval) -> Double { + guard elapsed > 0 else { return 0 } + return ceiling * (1 - exp(-elapsed / timeConstant)) + } +} diff --git a/Paxo/SolveRequestGate.swift b/Paxo/SolveRequestGate.swift new file mode 100644 index 0000000..857289f --- /dev/null +++ b/Paxo/SolveRequestGate.swift @@ -0,0 +1,35 @@ +import Foundation + +/// 진행 중인 AI 요청의 소유권. +/// +/// 취소하거나 기록으로 화면을 바꾼 뒤에 도착한 이전 응답이 화면 · 기록 · 무료 사용량을 바꾸지 못하게 한다. +/// 모든 `await` 직후 `isCurrent`로 확인한다. 정답 확정과 취소는 MainActor에서 먼저 처리된 쪽이 이긴다. +@MainActor +final class SolveRequestGate { + private var currentID: UUID? + + var isActive: Bool { currentID != nil } + + /// 새 요청을 시작한다. 이전 요청은 이 시점부터 무효다. + func begin() -> UUID { + let id = UUID() + currentID = id + return id + } + + func isCurrent(_ id: UUID) -> Bool { + currentID == id + } + + /// 요청이 끝났을 때 부른다. 늦게 끝난 이전 작업이 새 요청을 지우지 않도록 ID가 같을 때만 비운다. + func finish(_ id: UUID) { + if currentID == id { + currentID = nil + } + } + + /// 사용자가 취소했다. 지금 날아가는 응답은 도착해도 버려진다. + func invalidate() { + currentID = nil + } +} diff --git a/Paxo/UI/ResultView.swift b/Paxo/UI/ResultView.swift index 2a19bec..2675258 100644 --- a/Paxo/UI/ResultView.swift +++ b/Paxo/UI/ResultView.swift @@ -26,7 +26,7 @@ struct ResultView: View { : "풀이할 영역을 드래그하세요. (Esc 취소)" ) case .solvingAnswer: - loading("문제를 푸는 중…") + AnswerWaitingView() case .answerReady, .solvingExplanation, .done, .failedExplanation: // 해설만 실패한 경우에도 정답은 보존해서 보여준다 resultBody @@ -96,7 +96,7 @@ struct ResultView: View { } } } else if appState.phase == .solvingExplanation { - loading("해설을 작성하는 중…") + ExplanationWaitingView() } else if case .failedExplanation(let message) = appState.phase { // 정답은 위에 그대로 있고, 해설만 재시도 VStack(alignment: .leading, spacing: 6) { @@ -143,14 +143,4 @@ struct ResultView: View { .font(.callout) .foregroundStyle(.secondary) } - - private func loading(_ text: String) -> some View { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text(text) - .font(.callout) - .foregroundStyle(.secondary) - } - } } diff --git a/Paxo/UI/SettingsView.swift b/Paxo/UI/SettingsView.swift index 48c91aa..7792ffc 100644 --- a/Paxo/UI/SettingsView.swift +++ b/Paxo/UI/SettingsView.swift @@ -35,6 +35,20 @@ struct SettingsView: View { .monospacedDigit() } Slider(value: $appState.toastDuration, in: 2...10, step: 1) + + Picker("기다리는 동안", selection: $appState.toastWaitingStyle) { + ForEach(ToastWaitingStyle.allCases) { style in + Text(style.displayName).tag(style) + } + } + Text( + (appState.toastWaitingStyle == .detailed + ? "지금 무엇을 하는지 문구로 알려줍니다." + : "'푸는 중…'만 보여줍니다.") + + " 진행 원은 기다린 시간으로 그린 표시예요. 실제 남은 시간과는 다를 수 있어요." + ) + .font(.caption) + .foregroundStyle(.secondary) } } diff --git a/Paxo/UI/ToastController.swift b/Paxo/UI/ToastController.swift index 052f117..5d38abe 100644 --- a/Paxo/UI/ToastController.swift +++ b/Paxo/UI/ToastController.swift @@ -9,8 +9,9 @@ final class ToastController { func show(appState: AppState, on screen: NSScreen? = nil) { let host = ensurePanel(appState: appState) + // 대기 모습은 띄우는 순간에 고정한다. 대기 중 설정을 바꿔도 이미 잰 토스트 크기와 어긋나지 않게. host.rootView = AnyView( - ToastView().environmentObject(appState).fixedSize() + ToastView(waitingStyle: appState.toastWaitingStyle).environmentObject(appState).fixedSize() ) guard let panel, let screen = screen ?? NSScreen.main else { return } @@ -37,9 +38,15 @@ final class ToastController { } } - func dismiss() { + /// 정답을 보여준 뒤에만 페이드로 닫는다. 취소 · 오류처럼 정답 없이 닫을 때 페이드하면 + /// 그 사이 내용이 빈 자리("—")로 바뀌고 창도 그 크기로 줄어 작은 말풍선이 번쩍인다. + func dismiss(animated: Bool = true) { guard let panel, isVisible else { return } isVisible = false + guard animated else { + panel.orderOut(nil) + return + } NSAnimationContext.runAnimationGroup( { ctx in ctx.duration = 0.3 @@ -79,6 +86,7 @@ final class ToastController { private struct ToastView: View { @EnvironmentObject private var appState: AppState + let waitingStyle: ToastWaitingStyle var body: some View { content @@ -95,12 +103,7 @@ private struct ToastView: View { private var content: some View { switch appState.phase { case .capturing, .solvingAnswer: - HStack(spacing: 10) { - ProgressView().controlSize(.small) - Text("푸는 중…") - .font(.title3) - .foregroundStyle(.secondary) - } + ToastWaitingView(style: waitingStyle) default: if let answer = appState.current?.answer { Text(answer) diff --git a/Paxo/UI/WaitingView.swift b/Paxo/UI/WaitingView.swift new file mode 100644 index 0000000..a5de760 --- /dev/null +++ b/Paxo/UI/WaitingView.swift @@ -0,0 +1,256 @@ +import AppKit +import SwiftUI + +/// 정답을 기다리는 동안 결과 패널에 보여주는 화면. +/// 무엇을 캡처했는지 먼저 보여주고, 기다린 시간에 맞춰 안내 문구를 바꾼다. +struct AnswerWaitingView: View { + @EnvironmentObject private var appState: AppState + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + if let thumbnail = appState.currentThumbnail { + CaptureThumbnailCard(image: thumbnail, preset: appState.current?.preset ?? appState.preset) + } + WaitingTimeline(since: appState.waitingSince, stage: WaitingStage.answerStage) { stage, elapsed in + WaitingStageRow(stage: stage, showsCancel: elapsed.map(WaitingStage.allowsCancel) ?? false) + } + } + } +} + +/// 정답은 이미 보이고 해설만 기다릴 때. 해설이 들어설 자리를 미리 잡아 두어 화면이 튀지 않게 한다. +struct ExplanationWaitingView: View { + @EnvironmentObject private var appState: AppState + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var dimmed = false + + var body: some View { + WaitingTimeline(since: appState.waitingSince, stage: WaitingStage.explanationStage) { stage, elapsed in + VStack(alignment: .leading, spacing: 10) { + WaitingStageRow(stage: stage, showsCancel: elapsed.map(WaitingStage.allowsCancel) ?? false) + VStack(alignment: .leading, spacing: 7) { + skeletonLine(width: 290) + skeletonLine(width: 240) + skeletonLine(width: 265) + } + .opacity(dimmed ? 0.45 : 1) + } + } + .onAppear { updatePulse() } + .onChange(of: reduceMotion) { updatePulse() } + } + + /// 대기 도중 "동작 줄이기"를 켜도 반복 애니메이션이 바로 멈추도록, 애니메이션 없이 값을 되돌린다 + private func updatePulse() { + var still = Transaction() + still.disablesAnimations = true + withTransaction(still) { dimmed = false } + guard !reduceMotion else { return } + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { + dimmed = true + } + } + + private func skeletonLine(width: CGFloat) -> some View { + RoundedRectangle(cornerRadius: 4) + .fill(.quaternary) + .frame(maxWidth: width, minHeight: 9, maxHeight: 9) + } +} + +/// 토스트용 한 줄 대기 표시. 스피너 대신 기다린 시간으로 채우는 원을 쓴다. +/// 토스트는 뜰 때 한 번만 크기를 재므로, 가장 긴 문구와 취소 자리가 들어가는 고정 폭을 쓴다. +struct ToastWaitingView: View { + @EnvironmentObject private var appState: AppState + let style: ToastWaitingStyle + + var body: some View { + WaitingTimeline(since: appState.waitingSince, stage: WaitingStage.answerStage) { stage, elapsed in + let showsCancel = elapsed.map(WaitingStage.allowsCancel) ?? false + switch style { + case .detailed: + HStack(spacing: 10) { + ProgressRing(since: appState.waitingSince, symbol: stage.symbolName, description: stage.message) + Text(stage.message) + .font(.title3) + .foregroundStyle(.secondary) + Spacer(minLength: 8) + CancelSlot(visible: showsCancel, compact: false) + } + .frame(width: 320, alignment: .leading) + case .simple: + // 문구가 없으면 오래 걸릴 때 원만 멈춰 보이므로, 이때만 이유를 한 줄 알려준다 + let message = stage == .slow ? stage.message : "푸는 중…" + HStack(spacing: 10) { + ProgressRing(since: appState.waitingSince, symbol: nil, description: message) + Text(message) + .font(.title3) + .foregroundStyle(.secondary) + Spacer(minLength: 8) + CancelSlot(visible: showsCancel, compact: true) + } + .frame(width: 250, alignment: .leading) + } + } + } +} + +/// 단계 아이콘 · 문구 · 취소 버튼. 취소 자리는 처음부터 잡아 두어 버튼이 생겨도 화면이 튀지 않는다. +struct WaitingStageRow: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + let stage: WaitingStage + let showsCancel: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Image(systemName: stage.symbolName) + .font(.title2) + .foregroundStyle(.tint) + .symbolEffect(.pulse, isActive: !reduceMotion) + .contentTransition(.symbolEffect(.replace)) + .frame(width: 28) + Text(stage.message) + .font(.callout.weight(.medium)) + Spacer(minLength: 8) + CancelSlot(visible: showsCancel, compact: false) + } + if let detail = stage.detail { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .animation(reduceMotion ? nil : .easeInOut(duration: 0.25), value: stage) + } +} + +/// 3초 전에는 자리만 차지하고 클릭 · 키보드 · VoiceOver 어느 쪽으로도 실행되지 않는 취소 버튼 +private struct CancelSlot: View { + @EnvironmentObject private var appState: AppState + @Environment(\.accessibilityReduceMotion) private var reduceMotion + let visible: Bool + let compact: Bool + + var body: some View { + Group { + if compact { + Button { + appState.cancelSolve() + } label: { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.secondary) + .frame(width: 22, height: 22) + .background(Circle().fill(Color.secondary.opacity(0.15))) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .help("풀이 취소") + .accessibilityLabel("풀이 취소") + } else { + Button("취소") { + appState.cancelSolve() + } + .controlSize(.small) + } + } + .opacity(visible ? 1 : 0) + .disabled(!visible) + .allowsHitTesting(visible) + .accessibilityHidden(!visible) + .animation(reduceMotion ? nil : .easeInOut(duration: 0.25), value: visible) + } +} + +/// 기다린 시간으로 채우는 원. 실제 진행률이 아니라 추정치라 90%를 넘지 않는다. +/// 원호만 자주(30Hz) 다시 그리고, 가운데 아이콘은 밖에 두어 펄스 효과가 매 프레임 다시 시작되지 않게 한다. +private struct ProgressRing: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + let since: Date? + let symbol: String? + let description: String + + var body: some View { + ZStack { + Circle() + .stroke(Color.secondary.opacity(0.25), lineWidth: 3) + if reduceMotion { + TimelineView(.periodic(from: since ?? .now, by: 1)) { context in + arc(at: context.date) + } + } else { + TimelineView(.animation(minimumInterval: 1.0 / 30)) { context in + arc(at: context.date) + } + } + if let symbol { + Image(systemName: symbol) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.tint) + .symbolEffect(.pulse, isActive: !reduceMotion) + .contentTransition(.symbolEffect(.replace)) + } + } + .frame(width: 26, height: 26) + .accessibilityElement(children: .ignore) + .accessibilityLabel("진행 중") + .accessibilityValue(description) + } + + @ViewBuilder + private func arc(at date: Date) -> some View { + let fraction = since.map { WaitingProgress.estimatedFraction(elapsed: date.timeIntervalSince($0)) } ?? 0 + if fraction > 0 { + Circle() + .trim(from: 0, to: fraction) + .stroke(Color.accentColor, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + } +} + +/// 1초마다 경과 시간을 다시 계산해 단계를 넘긴다. 틱 수가 아니라 시작 시각 기준이라 갱신이 밀려도 어긋나지 않는다. +/// 요청이 시작되기 전(영역 선택 중)에는 경과 시간을 넘기지 않아 취소가 열리지 않는다. +private struct WaitingTimeline: View { + let since: Date? + let stage: (TimeInterval) -> WaitingStage + @ViewBuilder let content: (WaitingStage, TimeInterval?) -> Content + + var body: some View { + TimelineView(.periodic(from: since ?? .now, by: 1)) { context in + let elapsed = since.map { context.date.timeIntervalSince($0) } + content(stage(elapsed ?? 0), elapsed) + } + } +} + +/// "이 문제를 보고 있어요"를 눈으로 확인시켜 주는 캡처 미리보기 +private struct CaptureThumbnailCard: View { + let image: NSImage + let preset: SubjectPreset + + var body: some View { + HStack(spacing: 10) { + Image(nsImage: image) + .resizable() + .scaledToFit() + .frame(maxWidth: 72, maxHeight: 48) + .clipShape(RoundedRectangle(cornerRadius: 4)) + VStack(alignment: .leading, spacing: 2) { + Text("캡처한 문제") + .font(.caption) + .foregroundStyle(.secondary) + Text("\(preset.displayName) · 방금") + .font(.caption) + .foregroundStyle(.tertiary) + } + Spacer(minLength: 0) + } + .padding(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.secondary.opacity(0.25), lineWidth: 0.5) + ) + } +} diff --git a/PaxoTests/CodableRoundTripTests.swift b/PaxoTests/CodableRoundTripTests.swift index becb281..474c857 100644 --- a/PaxoTests/CodableRoundTripTests.swift +++ b/PaxoTests/CodableRoundTripTests.swift @@ -53,5 +53,17 @@ struct CodableRoundTripTests { #expect(CaptureMode.region.rawValue == "region") #expect(ResultDisplayMode.panel.rawValue == "panel") #expect(PanelPosition.topRight.rawValue == "topRight") + #expect(ToastWaitingStyle.detailed.rawValue == "detailed") + #expect(ToastWaitingStyle.simple.rawValue == "simple") + } + + /// 설정이 없던 기존 사용자와 알 수 없는 값은 개선된 안내를 받는다. + @Test(arguments: [nil, "", "unknown"] as [String?]) + func 토스트_대기_방식은_모르면_단계_안내로_돌아간다(rawValue: String?) { + #expect(ToastWaitingStyle.restored(from: rawValue) == .detailed) + } + + @Test func 저장된_토스트_대기_방식을_그대로_읽는다() { + #expect(ToastWaitingStyle.restored(from: "simple") == .simple) } } diff --git a/PaxoTests/GeminiServiceTests.swift b/PaxoTests/GeminiServiceTests.swift index a83d3f1..59cd287 100644 --- a/PaxoTests/GeminiServiceTests.swift +++ b/PaxoTests/GeminiServiceTests.swift @@ -22,6 +22,21 @@ struct GeminiServiceTests { #expect(request.value(forHTTPHeaderField: "x-goog-api-key") == nil) } + /// 취소를 네트워크 오류로 감싸면 사용자가 누른 취소가 "네트워크 오류" 화면으로 보인다. + @Test func 취소된_요청은_취소로_분류된다() { + let error = GeminiService.classify(URLError(.cancelled)) + #expect(error is CancellationError) + } + + @Test func 다른_네트워크_오류는_그대로_네트워크_오류다() { + let error = GeminiService.classify(URLError(.timedOut)) + guard case GeminiError.network(let underlying)? = error as? GeminiError else { + Issue.record("GeminiError.network가 아니다: \(error)") + return + } + #expect(underlying.code == .timedOut) + } + #if DEBUG @Test func 직접_호출은_프록시를_우회하고_Gemini_키를_사용한다() throws { let service = GeminiService( diff --git a/PaxoTests/SolveRequestGateTests.swift b/PaxoTests/SolveRequestGateTests.swift new file mode 100644 index 0000000..6d2eb4c --- /dev/null +++ b/PaxoTests/SolveRequestGateTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing + +@testable import Paxo + +/// 게이트가 늦은 응답을 통과시키면 취소한 풀이가 무료 횟수를 깎거나 다른 문제의 결과를 덮어쓴다. +@MainActor +struct SolveRequestGateTests { + @Test func 새_요청을_시작하면_이전_요청은_무효가_된다() { + let gate = SolveRequestGate() + let first = gate.begin() + let second = gate.begin() + + #expect(!gate.isCurrent(first)) + #expect(gate.isCurrent(second)) + } + + @Test func 취소하면_날아가던_응답은_버려진다() { + let gate = SolveRequestGate() + let id = gate.begin() + + gate.invalidate() + + #expect(!gate.isCurrent(id)) + #expect(!gate.isActive) + } + + @Test func 늦게_끝난_이전_요청은_새_요청을_지우지_못한다() { + let gate = SolveRequestGate() + let old = gate.begin() + let new = gate.begin() + + gate.finish(old) + + #expect(gate.isCurrent(new)) + #expect(gate.isActive) + } + + @Test func 자기_요청을_끝내면_비활성이_된다() { + let gate = SolveRequestGate() + let id = gate.begin() + + gate.finish(id) + + #expect(!gate.isActive) + } +} diff --git a/PaxoTests/WaitingProgressTests.swift b/PaxoTests/WaitingProgressTests.swift new file mode 100644 index 0000000..eaea9e2 --- /dev/null +++ b/PaxoTests/WaitingProgressTests.swift @@ -0,0 +1,36 @@ +import Foundation +import Testing + +@testable import Paxo + +/// 토스트의 원은 실제 진행률이 아니라 추정치다. 답이 오기 전에 가득 차거나 뒤로 가면 +/// "다 됐는데 왜 안 나오지"라는 오해를 만든다. +struct WaitingProgressTests { + @Test(arguments: [0.0, -1, -100]) + func 시작_전에는_비어_있다(elapsed: TimeInterval) { + #expect(WaitingProgress.estimatedFraction(elapsed: elapsed) == 0) + } + + /// 아주 긴 시간에서는 Double 반올림으로 정확히 상한과 같아질 수 있어 이하로 본다. + @Test(arguments: [0.1, 1, 3, 8, 15, 60, 600, 1_000_000]) + func 상한을_넘지_않는다(elapsed: TimeInterval) { + let fraction = WaitingProgress.estimatedFraction(elapsed: elapsed) + #expect(fraction >= 0) + #expect(fraction <= WaitingProgress.ceiling) + } + + @Test func 시간이_지날수록_줄지_않는다() { + let samples = stride(from: 0.0, through: 120, by: 0.5).map { + WaitingProgress.estimatedFraction(elapsed: $0) + } + for (earlier, later) in zip(samples, samples.dropFirst()) { + #expect(later >= earlier) + } + } + + /// 처음엔 빠르게, 점점 느리게 찬다. 곡선을 바꾸면 이 값도 함께 바꾼다. + @Test(arguments: [(3.0, 0.41), (8, 0.72), (15, 0.86)]) + func 주요_시점의_진행률(elapsed: TimeInterval, expected: Double) { + #expect(abs(WaitingProgress.estimatedFraction(elapsed: elapsed) - expected) < 0.01) + } +} diff --git a/PaxoTests/WaitingStageTests.swift b/PaxoTests/WaitingStageTests.swift new file mode 100644 index 0000000..db9e0af --- /dev/null +++ b/PaxoTests/WaitingStageTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing + +@testable import Paxo + +/// 단계 경계가 조용히 바뀌면 "멈춘 것 같은" 대기 화면이 되거나 취소 버튼이 너무 늦게 뜬다. +struct WaitingStageTests { + @Test(arguments: [ + (-1.0, WaitingStage.reading), + (0, .reading), + (2.9, .reading), + (3, .thinking), + (7.9, .thinking), + (8, .finishing), + (14.9, .finishing), + (15, .slow), + (60, .slow), + ]) + func 정답_대기는_경과_시간에_따라_단계가_바뀐다(elapsed: TimeInterval, expected: WaitingStage) { + #expect(WaitingStage.answerStage(elapsed: elapsed) == expected) + } + + @Test(arguments: [ + (0.0, WaitingStage.explaining), + (14.9, .explaining), + (15, .slow), + (90, .slow), + ]) + func 해설_대기는_오래_걸릴_때만_느림_단계가_된다(elapsed: TimeInterval, expected: WaitingStage) { + #expect(WaitingStage.explanationStage(elapsed: elapsed) == expected) + } + + @Test(arguments: WaitingStage.allCases) + func 모든_단계에_문구와_아이콘이_있다(stage: WaitingStage) { + #expect(!stage.message.isEmpty) + #expect(!stage.symbolName.isEmpty) + } + + /// 취소는 3초부터 연다. 캡처 직후 바로 보이면 실수로 눌러 멀쩡한 요청이 끊긴다. + /// 정답 대기와 해설 대기가 같은 기준을 쓴다. + @Test(arguments: [ + (-1.0, false), + (0, false), + (2.9, false), + (3, true), + (15, true), + (60, true), + ]) + func 취소는_3초부터_열린다(elapsed: TimeInterval, expected: Bool) { + #expect(WaitingStage.allowsCancel(elapsed: elapsed) == expected) + } + + /// 실제 진행률을 모르므로 곧 끝난다고 약속하는 문구를 쓰지 않는다. + @Test(arguments: WaitingStage.allCases) + func 완료를_약속하는_문구가_없다(stage: WaitingStage) { + #expect(!stage.message.contains("다 됐")) + } + + /// 오래 걸린다는 설명은 취소와 별개로 15초부터만 보여준다. + @Test(arguments: WaitingStage.allCases) + func 설명은_느림_단계에서만_있다(stage: WaitingStage) { + #expect((stage.detail != nil) == (stage == .slow)) + } +} diff --git a/README.md b/README.md index ba1c754..9e11649 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ 2. **영역 선택 (Selection)**: 문제 영역을 드래그하여 지정 (`Esc` 취소). 설정에서 **전체 화면** 모드 활성화 시 드래그 없이 즉시 캡처. 3. **결과 노출 (Result)**: 정답이 먼저 화면에 표시되고, 이어서 해설이 스트리밍된다. 4. **빠른 채점 모드 (Fast Grading)**: 설정에서 켤 수 있으며, 정답만 크게 강조 표시하고 해설은 "해설 보기" 클릭 시 Lazy-load 방식으로 생성한다 (문제집 셀프 채점용). -5. **토스트 모드 (Toast Mode)**: 정답만 토스트 팝업으로 2~10초간 노출된 후 사라지며 해설은 아예 생성하지 않는다. +5. **토스트 모드 (Toast Mode)**: 정답만 토스트 팝업으로 2~10초간 노출된 후 사라지며 해설은 아예 생성하지 않는다. 기다리는 동안의 모습은 설정에서 **단계 안내(기본)** 또는 **간단히**로 고를 수 있다. 6. **결과 패널 위치**: 화면 내 7가지 프리셋 중 원하는 위치를 지정할 수 있다. ## 2. 프로젝트 구조 diff --git a/docs/architecture.md b/docs/architecture.md index 2cac371..8dae31a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,6 +26,7 @@ - 정답과 해설은 각각 별도의 API 요청으로 처리한다. - 무료 횟수는 '정답 요청'이 성공했을 때만 차감한다. 해설 요청 실패 시에는 추가로 차감하지 않는다. - 토스트 모드에서는 해설을 요청하지 않는다. +- 정답 · 해설 요청 3초 후부터 취소할 수 있다. 정답 확정 전에 취소하면 무료 횟수를 차감하지 않으며, 취소나 기록 전환 뒤에 도착한 응답은 `SolveRequestGate`가 버린다. ## 앱 ↔ 프록시 API 스펙 (Contract) diff --git a/docs/decisions.md b/docs/decisions.md index de91249..7b11b18 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -12,3 +12,7 @@ | 2026-09-13 | 저장소를 공개로 전환했다 | GitHub Actions 무료 · 보호 규칙 · 시크릿 스캔을 쓰기 위해 | 완료 | | 2026-09-13 | 브랜치는 develop(기본, Squash 머지)에서 통합하고 main은 릴리즈 PR(Merge 머지)만 받는다 | 출시본과 개발 중인 코드를 분리한다. 릴리즈 PR을 squash하면 다음 릴리즈에 이미 나간 변경이 다시 뜨므로 main은 Merge만 쓴다 | 채택 | | 2026-09-13 | 공개할 수 없는 문서(운영안 · 제출 절차)는 비공개 저장소 `paxo-app/internal`에 두고 `docs/private/`에 clone한다 | 공개 저장소에 둘 수 없는 내용도 이력 관리가 필요하다 | 채택 | +| 2026-09-25 | 응답 대기 화면은 단계별 안내 문구 · 캡처 미리보기 · 해설 자리 스켈레톤으로 보여주고, 요청 3초 후부터 취소를 열며 15초부터 오래 걸린다고 알린다 | 스피너 한 줄만으로는 멈춘 것처럼 보였고 진행 중인 요청을 끊을 방법이 없었다 | 제안 (9/29 회의) | +| 2026-09-25 | 해설 속도 개선은 두 단계로 나눈다. 1단계는 대기 UX와 API 기준선 측정, 2단계는 해설 스트리밍 | 스트리밍은 앱 · 프록시 계약을 함께 바꿔야 해서 프록시 선배포와 담당자 승인이 필요하다. 측정 근거를 먼저 남긴다 | 제안 (9/29 회의) | +| 2026-09-25 | 대기 중 취소는 정답 확정과 먼저 처리된 쪽을 따른다. 확정 전 취소는 무료 횟수를 차감하지 않고 늦은 응답은 버린다. 확정 후 취소는 해설에만 적용된다 | 정답을 못 받은 사용자에게 차감하지 않는다는 기존 규칙을 취소에도 그대로 적용한다 | 제안 (과금 담당 승인 필요) | +| 2026-09-29 | 토스트 대기는 기다린 시간으로 채우는 원형 진행 표시(추정치, 최대 90%)를 쓰고, 단계 안내(기본)와 간단히 중에서 고를 수 있다 | 스피너보다 기다리는 느낌이 덜하고 토스트 크기를 늘리지 않는다. 서버가 답을 한 번에 보내 실제 진행률은 알 수 없으므로 가득 채우지 않는다 | 제안 | diff --git a/scripts/fixtures/sample-problem.jpg b/scripts/fixtures/sample-problem.jpg new file mode 100644 index 0000000..6901d0e Binary files /dev/null and b/scripts/fixtures/sample-problem.jpg differ diff --git a/scripts/measure-latency.sh b/scripts/measure-latency.sh new file mode 100755 index 0000000..57f3321 --- /dev/null +++ b/scripts/measure-latency.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# 풀이 속도 개선 전에 "지금 어디서 얼마나 걸리는지"를 숫자로 남기기 위한 API 기준선 측정. +# 앱에는 로깅을 넣지 않기로 했으므로(AGENTS.md) 프록시를 직접 호출해 잰다. +# 캡처 · 인코딩 · 화면 렌더링 시간은 포함되지 않는다. +# +# 측정 항목 +# cold : 새 연결로 정답 요청 → 받은 정답을 넣어 해설 요청 (앱과 같은 2단계) +# warm : 같은 연결에서 GET으로 먼저 핸드셰이크 → 정답 요청 (연결 예열 실험) +# GET은 프록시가 토큰 · 사용량 검사 전에 405로 돌려보내므로 Gemini를 호출하지 않는다. +# +# 주의 +# - 현재 프록시는 Gemini 응답을 전부 받은 뒤 보내므로 TTFB는 "첫 토큰" 시간이 아니다. +# - 실제 Gemini를 호출한다. 기본 5회 = POST 15회. 프록시 기기당 하루 60회(UTC, best-effort) 안에서 쓴다. +# - 반복 5회의 p90은 탐색용이다. 원자료 CSV를 함께 본다. +# +# 사용법 +# PAXO_PROXY_URL=https://api.paxo.co.kr PAXO_TOKEN=... scripts/measure-latency.sh [반복 횟수] +# 결과: 현재 디렉터리의 latency-<시각>.csv (토큰은 기록하지 않는다. 커밋하지 않는다) +set -euo pipefail + +: "${PAXO_PROXY_URL:?PAXO_PROXY_URL을 지정하세요}" +: "${PAXO_TOKEN:?PAXO_TOKEN을 지정하세요}" + +RUNS="${1:-5}" +BASE="${PAXO_PROXY_URL%/}" +URL="$BASE/generate" +# 실제 사용자와 섞이지 않도록 측정 전용 고정 기기 ID를 쓴다 +DEVICE="00000000-0000-4000-8000-00000000a11c" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +IMAGE="$ROOT/scripts/fixtures/sample-problem.jpg" +OUT="$PWD/latency-$(date +%Y%m%d-%H%M%S).csv" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +chmod 700 "$WORK" + +# 토큰이 프로세스 목록(ps)에 드러나지 않도록 헤더는 파일로 넘긴다 +HEADERS="$WORK/headers" +printf 'content-type: application/json\nx-paxo-device: %s\nx-paxo-token: %s\n' "$DEVICE" "$PAXO_TOKEN" >"$HEADERS" +chmod 600 "$HEADERS" + +# 프롬프트는 Paxo/AI/Prompts.swift(일반 프리셋)와 같게 유지한다. 바뀌면 여기도 고친다. +build_body() { + python3 - "$IMAGE" "$1" "${2:-}" <<'PY' +import base64, json, sys +image, kind, answer = sys.argv[1], sys.argv[2], sys.argv[3] +if kind == "answer": + prompt = ( + "당신은 한국어 학습 도우미입니다. 이미지에 있는 문제를 정확히 풀어주세요.\n\n" + "출력 형식: 정답만 한 줄로 간결하게 출력하세요. 풀이 과정은 다음 단계에서 별도로 요청됩니다.\n" + "- 객관식이면 번호만 (예: ③)\n" + "- 단답형이면 답만\n" + "- 구해야 하는 답이 여러 개면 순서대로 쉼표로 구분 (예: 첫번째답, 두번째답)" + ) +else: + prompt = ( + f"당신은 친절한 한국어 과외 선생님입니다. 이미지에 있는 문제의 정답은 \"{answer}\"입니다.\n\n" + "학생이 이해할 수 있도록 왜 이 답이 되는지 설명해주세요:\n" + "1. 문제가 묻는 핵심 개념\n" + "2. 단계별 풀이 과정\n" + "3. 헷갈리기 쉬운 포인트 (오답 선택지가 있다면 왜 틀렸는지)\n" + "간결하되 핵심이 빠지지 않게, 읽기 쉬운 짧은 문단으로 작성하세요. " + "간단한 마크다운(굵게, 목록)을 사용해도 됩니다." + ) +with open(image, "rb") as f: + data = base64.b64encode(f.read()).decode() +body = {"contents": [{"parts": [{"text": prompt}, {"inline_data": {"mime_type": "image/jpeg", "data": data}}]}]} +print(json.dumps(body, ensure_ascii=False)) +PY +} + +extract_text() { + python3 -c ' +import json, sys +try: + parts = json.load(open(sys.argv[1]))["candidates"][0]["content"]["parts"] + print("\n".join(p.get("text", "") for p in parts).strip()) +except Exception: + print("") +' "$1" +} + +FORMAT='%{http_code},%{http_version},%{num_connects},%{time_namelookup},%{time_connect},%{time_appconnect},%{time_starttransfer},%{time_total},%{size_upload},%{size_download}\n' +echo "run,mode,request,http_code,http_version,new_connections,dns_s,tcp_s,tls_s,ttfb_s,total_s,bytes_up,bytes_down" >"$OUT" + +build_body answer >"$WORK/answer.json" +echo "측정 대상: $BASE · 반복 ${RUNS}회 · 예상 POST $((RUNS * 3))회" + +for run in $(seq 1 "$RUNS"); do + # cold: 새 프로세스 = 새 연결 + line=$(curl -sS -o "$WORK/answer.out" -w "$FORMAT" -H @"$HEADERS" --data-binary @"$WORK/answer.json" "$URL") + echo "$run,cold,answer,$line" >>"$OUT" + answer="$(extract_text "$WORK/answer.out")" + if [ -z "$answer" ]; then + echo " #$run 정답 요청 실패 ($(cut -d, -f1 <<<"$line")) — 해설 측정 건너뜀" + else + build_body explanation "$answer" >"$WORK/explanation.json" + line=$(curl -sS -o "$WORK/explanation.out" -w "$FORMAT" -H @"$HEADERS" --data-binary @"$WORK/explanation.json" "$URL") + echo "$run,cold,explanation,$line" >>"$OUT" + fi + + # warm: 같은 curl 실행 안에서 GET으로 연결을 맺고 그 연결로 정답 요청 + curl -sS -o /dev/null -w "GET,$FORMAT" "$URL" \ + --next -o /dev/null -w "POST,$FORMAT" -H @"$HEADERS" --data-binary @"$WORK/answer.json" "$URL" \ + | while IFS= read -r row; do + kind="${row%%,*}" + rest="${row#*,}" + [ "$kind" = "GET" ] && echo "$run,warm,prewarm_get,$rest" >>"$OUT" + [ "$kind" = "POST" ] && echo "$run,warm,answer,$rest" >>"$OUT" + done + echo " #$run 완료 (정답: ${answer:-없음})" +done + +echo +python3 - "$OUT" <<'PY' +import csv, statistics, sys +rows = list(csv.DictReader(open(sys.argv[1]))) +def pct(values, p): + values = sorted(values) + if not values: + return float("nan") + k = (len(values) - 1) * p + lo, hi = int(k), min(int(k) + 1, len(values) - 1) + return values[lo] + (values[hi] - values[lo]) * (k - lo) +def sec(value, digits): + return "-" if value != value else f"{value:.{digits}f}s" +print(f"{'구분':<22}{'성공/전체':>10}{'새 연결':>8}{'TLS까지 p50':>13}{'총 p50':>9}{'총 p90':>9}") +for mode, req in [("cold", "answer"), ("warm", "answer"), ("cold", "explanation"), ("warm", "prewarm_get")]: + group = [r for r in rows if r["mode"] == mode and r["request"] == req] + # 예열 GET은 405가 정상이다. 정답 · 해설 POST에서 405는 실패다. + expected = ("405",) if req == "prewarm_get" else ("200",) + ok = [r for r in group if r["http_code"] in expected] + total = [float(r["total_s"]) for r in ok] + tls = [float(r["tls_s"]) for r in ok] + conns = sum(int(r["new_connections"]) for r in group) + print( + f"{mode + ' ' + req:<22}{len(ok):>6}/{len(group):<3}{conns:>8}" + f"{sec(pct(tls, .5), 3):>13}{sec(pct(total, .5), 2):>9}{sec(pct(total, .9), 2):>9}" + ) +print("\nwarm answer의 새 연결이 0이면 예열 연결을 재사용한 것이다. 원자료:", sys.argv[1]) +PY