From aa30ed685d24f87d09a1cb24992474303ab092fc Mon Sep 17 00:00:00 2001 From: Peng Cheng Date: Tue, 16 Jun 2026 13:53:48 +0800 Subject: [PATCH 1/3] refactor(repo): converge Windows and macOS into a monorepo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two platform apps lived on permanently diverged branches (master = Windows, macos = macOS with Windows code deleted), making merges impossible and letting shared docs/benchmark/LICENSE drift. Converge into a single trunk with platform subdirectories: - windows/ — the .NET app (moved from repo root) - macos/ — the Swift app (brought in from the macos branch) - benchmark/, docs/ shared at root; LICENSE now covers the whole repo - new root README is a cross-platform overview CI: each workflow path-filters its platform and builds from its subdir (dotnet-build / build-and-release use working-directory: windows; macos-build filters macos/**). Windows CI path changes need validation on a real run — .NET can't be built on macOS here. Drops translation_history.db, a runtime SQLite artifact mistakenly tracked. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build-and-release.yml | 11 +- .github/workflows/dotnet-build.yml | 12 +- .github/workflows/macos-build.yml | 65 + .gitignore | 6 +- README.md | 213 +- benchmark/benchmark_raw.json | 14106 ++++++++++++++++ benchmark/benchmark_report.md | 305 + benchmark/run_benchmark.py | 574 + benchmark/test_data.json | 281 + docs/USER_GUIDE.md | 52 + docs/VIDEO_SCRIPT.md | 78 + macos/IMPROVEMENT_PLAN.md | 383 + macos/LCTMac/App/AppDelegate.swift | 176 + macos/LCTMac/App/LCTMacApp.swift | 105 + macos/LCTMac/Info.plist | 38 + macos/LCTMac/LCTMac.entitlements | 21 + macos/LCTMac/Models/AppNotice.swift | 73 + macos/LCTMac/Models/AppSettings.swift | 356 + macos/LCTMac/Models/Caption.swift | 177 + macos/LCTMac/Models/CaptionSegment.swift | 48 + macos/LCTMac/Models/Speaker.swift | 86 + macos/LCTMac/Models/TranscriptionResult.swift | 74 + macos/LCTMac/Models/TranslationEntry.swift | 94 + macos/LCTMac/Models/TranslationSegment.swift | 29 + macos/LCTMac/Resources/AppIcon.icns | Bin 0 -> 114370 bytes .../LCTMac/Services/AudioCaptureService.swift | 519 + macos/LCTMac/Services/HistoryService.swift | 432 + macos/LCTMac/Services/OllamaGuardian.swift | 422 + .../LCTMac/Services/OllamaModelManager.swift | 436 + macos/LCTMac/Services/OllamaService.swift | 478 + .../Services/SpeechAnalyzerService.swift | 359 + macos/LCTMac/Services/TranslationQueue.swift | 458 + macos/LCTMac/Utils/CaptionSegmenter.swift | 180 + macos/LCTMac/Utils/TextUtils.swift | 199 + macos/LCTMac/ViewModels/TranscriptionVM.swift | 758 + macos/LCTMac/Views/HistoryView.swift | 250 + macos/LCTMac/Views/MainView.swift | 630 + macos/LCTMac/Views/OverlayView.swift | 388 + macos/LCTMac/Views/SettingsView.swift | 508 + macos/LCTMac/Views/WelcomeView.swift | 973 ++ macos/Package.resolved | 15 + macos/Package.swift | 41 + macos/QUICK_START.md | 216 + macos/README.md | 47 + macos/Scripts/build-app.sh | 72 + macos/Scripts/build_signed.sh | 68 + macos/Scripts/diagnose_permissions.sh | 86 + macos/Scripts/run_tests.sh | 45 + macos/Scripts/setup.sh | 147 + macos/TESTING.md | 489 + macos/Tests/LCTMacTests/AppNoticeTests.swift | 87 + .../LCTMacTests/CaptionSegmenterTests.swift | 163 + .../LCTMacTests/HistoryServiceTests.swift | 221 + macos/Tests/LCTMacTests/ModelsTests.swift | 336 + .../OllamaServiceIntegrationTests.swift | 125 + .../LCTMacTests/OllamaServiceTests.swift | 137 + .../Tests/LCTMacTests/PauseResumeTests.swift | 75 + macos/Tests/LCTMacTests/TextUtilsTests.swift | 188 + .../LCTMacTests/TranslationQueueTests.swift | 114 + macos/USER_GUIDE.md | 931 + macos/USER_MANUAL.md | 217 + macos/make-icon.swift | 103 + macos/package-app.sh | 26 + translation_history.db | Bin 208896 -> 0 bytes .../LiveCaptionsTranslator.csproj | 0 .../LiveCaptionsTranslator.sln | 0 windows/README.md | 194 + README_zh-CN.md => windows/README_zh-CN.md | 0 {config => windows/config}/setting_new.json | 0 {docs => windows/docs}/README.md | 0 {docs => windows/docs}/dev/PRD.md | 0 {docs => windows/docs}/dev/RELEASE_NOTES.md | 0 .../docs}/dev/VERSION_MANAGEMENT.md | 0 .../docs}/troubleshooting/DELL_PATH_UPDATE.md | 0 .../troubleshooting/FIX_DOWNLOAD_ISSUE.md | 0 .../troubleshooting/INITIALIZATION_LOGGING.md | 0 .../docs}/troubleshooting/OLLAMA_LOGGING.md | 0 .../docs}/user/MAINTENANCE_GUIDE.md | 0 {docs => windows/docs}/user/USER_MANUAL.md | 0 {images => windows/images}/AppIcon.ico | Bin {images => windows/images}/LCT_logo.ico | Bin {images => windows/images}/LCT_logo.png | Bin {images => windows/images}/Logo.png | Bin {images => windows/images}/preview.png | Bin .../images}/show_livecaptions.png | Bin .../package-lock.json | 0 package.json => windows/package.json | 0 .../scripts}/build/build-installer.bat | 0 .../scripts}/build/build-version.ps1 | 0 {scripts => windows/scripts}/build/build.ps1 | 0 .../scripts}/build/create-release.ps1 | 0 .../scripts}/deployment/LCT-v1.0.1-Setup.zip | Bin .../scripts}/deployment/VC_redist.x64.exe | Bin .../scripts}/deployment/deploy.ps1 | 0 .../scripts}/deployment/installer.nsi | 0 .../scripts}/deployment/offline-setup.ps1 | 0 .../scripts}/deployment/startup.bat | 0 .../scripts}/diagnostics/clean-old-model.ps1 | 0 .../scripts}/diagnostics/complete-fix.ps1 | 0 .../diagnostics/diagnose-startup-issue-en.ps1 | 0 .../diagnostics/fix-startup-issue.ps1 | 0 {src => windows/src}/App.xaml | 0 {src => windows/src}/App.xaml.cs | 0 {src => windows/src}/AssemblyInfo.cs | 0 .../src}/LiveCaptions-Translator.ico | Bin {src => windows/src}/Program.cs | 0 .../Services/Caption/LiveCaptionsHandler.cs | 0 .../src}/Services/Caption/RegexPatterns.cs | 0 .../src}/Services/Caption/TextUtil.cs | 0 .../Services/History/DataMigrationManager.cs | 0 .../src}/Services/History/HistoryLogger.cs | 0 .../src}/Services/Ollama/OllamaDownloader.cs | 0 .../src}/Services/Ollama/OllamaGuardian.cs | 0 .../Services/Platform/TelemetryService.cs | 0 .../src}/Services/Platform/WindowHandler.cs | 0 .../src}/Services/Platform/WindowsAPI.cs | 0 .../src}/Services/Startup/ApplicationSetup.cs | 0 .../src}/Services/Startup/StartupManager.cs | 0 .../src}/Services/Translation/TranslateAPI.cs | 0 .../Translation/TranslationTaskQueue.cs | 0 .../src}/Services/Update/AppVersionInfo.cs | 0 .../src}/Services/Update/AutoUpdateService.cs | 0 .../src}/Services/Update/UpdateUtil.cs | 0 .../Update/VersionCompatibilityChecker.cs | 0 .../src}/Services/Update/VersionManager.cs | 0 {src => windows/src}/Translator.cs | 0 .../Controls/VersionSettingsControl.xaml | 0 .../Controls/VersionSettingsControl.xaml.cs | 0 .../src}/Views/Pages/CaptionPage.xaml | 0 .../src}/Views/Pages/CaptionPage.xaml.cs | 0 .../src}/Views/Pages/HistoryPage.xaml | 0 .../src}/Views/Pages/HistoryPage.xaml.cs | 0 .../src}/Views/Pages/InfoPage.xaml | 0 .../src}/Views/Pages/InfoPage.xaml.cs | 0 .../src}/Views/Pages/SettingPage.xaml | 0 .../src}/Views/Pages/SettingPage.xaml.cs | 0 .../Windows/EnterpriseWelcomeWindow.xaml | 0 .../Windows/EnterpriseWelcomeWindow.xaml.cs | 0 .../src}/Views/Windows/MainWindow.xaml | 0 .../src}/Views/Windows/MainWindow.xaml.cs | 0 .../src}/Views/Windows/OverlayWindow.xaml | 0 .../src}/Views/Windows/OverlayWindow.xaml.cs | 0 .../src}/Views/Windows/SettingWindow.xaml | 0 .../src}/Views/Windows/SettingWindow.xaml.cs | 0 .../src}/Views/Windows/SplashWindow.xaml | 0 .../src}/Views/Windows/SplashWindow.xaml.cs | 0 .../src}/Views/Windows/UpdateWindow.xaml | 0 .../src}/Views/Windows/UpdateWindow.xaml.cs | 0 .../src}/Views/Windows/WelcomeWindow.xaml | 0 .../src}/Views/Windows/WelcomeWindow.xaml.cs | 0 {src => windows/src}/models/Caption.cs | 0 .../src}/models/InitializationStep.cs | 0 {src => windows/src}/models/ReleaseInfo.cs | 0 {src => windows/src}/models/Setting.cs | 0 .../src}/models/TranslateAPIConfig.cs | 0 .../src}/models/TranslationHistoryEntry.cs | 0 {src => windows/src}/models/VersionConfig.cs | 0 {src => windows/src}/models/VersionInfo.cs | 0 {src => windows/src}/models/WindowState.cs | 0 .../version-info.json | 0 160 files changed, 28305 insertions(+), 191 deletions(-) create mode 100644 .github/workflows/macos-build.yml create mode 100644 benchmark/benchmark_raw.json create mode 100644 benchmark/benchmark_report.md create mode 100644 benchmark/run_benchmark.py create mode 100644 benchmark/test_data.json create mode 100644 docs/USER_GUIDE.md create mode 100644 docs/VIDEO_SCRIPT.md create mode 100644 macos/IMPROVEMENT_PLAN.md create mode 100644 macos/LCTMac/App/AppDelegate.swift create mode 100644 macos/LCTMac/App/LCTMacApp.swift create mode 100644 macos/LCTMac/Info.plist create mode 100644 macos/LCTMac/LCTMac.entitlements create mode 100644 macos/LCTMac/Models/AppNotice.swift create mode 100644 macos/LCTMac/Models/AppSettings.swift create mode 100644 macos/LCTMac/Models/Caption.swift create mode 100644 macos/LCTMac/Models/CaptionSegment.swift create mode 100644 macos/LCTMac/Models/Speaker.swift create mode 100644 macos/LCTMac/Models/TranscriptionResult.swift create mode 100644 macos/LCTMac/Models/TranslationEntry.swift create mode 100644 macos/LCTMac/Models/TranslationSegment.swift create mode 100644 macos/LCTMac/Resources/AppIcon.icns create mode 100644 macos/LCTMac/Services/AudioCaptureService.swift create mode 100644 macos/LCTMac/Services/HistoryService.swift create mode 100644 macos/LCTMac/Services/OllamaGuardian.swift create mode 100644 macos/LCTMac/Services/OllamaModelManager.swift create mode 100644 macos/LCTMac/Services/OllamaService.swift create mode 100644 macos/LCTMac/Services/SpeechAnalyzerService.swift create mode 100644 macos/LCTMac/Services/TranslationQueue.swift create mode 100644 macos/LCTMac/Utils/CaptionSegmenter.swift create mode 100644 macos/LCTMac/Utils/TextUtils.swift create mode 100644 macos/LCTMac/ViewModels/TranscriptionVM.swift create mode 100644 macos/LCTMac/Views/HistoryView.swift create mode 100644 macos/LCTMac/Views/MainView.swift create mode 100644 macos/LCTMac/Views/OverlayView.swift create mode 100644 macos/LCTMac/Views/SettingsView.swift create mode 100644 macos/LCTMac/Views/WelcomeView.swift create mode 100644 macos/Package.resolved create mode 100644 macos/Package.swift create mode 100644 macos/QUICK_START.md create mode 100644 macos/README.md create mode 100755 macos/Scripts/build-app.sh create mode 100755 macos/Scripts/build_signed.sh create mode 100755 macos/Scripts/diagnose_permissions.sh create mode 100755 macos/Scripts/run_tests.sh create mode 100755 macos/Scripts/setup.sh create mode 100644 macos/TESTING.md create mode 100644 macos/Tests/LCTMacTests/AppNoticeTests.swift create mode 100644 macos/Tests/LCTMacTests/CaptionSegmenterTests.swift create mode 100644 macos/Tests/LCTMacTests/HistoryServiceTests.swift create mode 100644 macos/Tests/LCTMacTests/ModelsTests.swift create mode 100644 macos/Tests/LCTMacTests/OllamaServiceIntegrationTests.swift create mode 100644 macos/Tests/LCTMacTests/OllamaServiceTests.swift create mode 100644 macos/Tests/LCTMacTests/PauseResumeTests.swift create mode 100644 macos/Tests/LCTMacTests/TextUtilsTests.swift create mode 100644 macos/Tests/LCTMacTests/TranslationQueueTests.swift create mode 100644 macos/USER_GUIDE.md create mode 100644 macos/USER_MANUAL.md create mode 100644 macos/make-icon.swift create mode 100755 macos/package-app.sh delete mode 100644 translation_history.db rename LiveCaptionsTranslator.csproj => windows/LiveCaptionsTranslator.csproj (100%) rename LiveCaptionsTranslator.sln => windows/LiveCaptionsTranslator.sln (100%) create mode 100644 windows/README.md rename README_zh-CN.md => windows/README_zh-CN.md (100%) rename {config => windows/config}/setting_new.json (100%) rename {docs => windows/docs}/README.md (100%) rename {docs => windows/docs}/dev/PRD.md (100%) rename {docs => windows/docs}/dev/RELEASE_NOTES.md (100%) rename {docs => windows/docs}/dev/VERSION_MANAGEMENT.md (100%) rename {docs => windows/docs}/troubleshooting/DELL_PATH_UPDATE.md (100%) rename {docs => windows/docs}/troubleshooting/FIX_DOWNLOAD_ISSUE.md (100%) rename {docs => windows/docs}/troubleshooting/INITIALIZATION_LOGGING.md (100%) rename {docs => windows/docs}/troubleshooting/OLLAMA_LOGGING.md (100%) rename {docs => windows/docs}/user/MAINTENANCE_GUIDE.md (100%) rename {docs => windows/docs}/user/USER_MANUAL.md (100%) rename {images => windows/images}/AppIcon.ico (100%) rename {images => windows/images}/LCT_logo.ico (100%) rename {images => windows/images}/LCT_logo.png (100%) rename {images => windows/images}/Logo.png (100%) rename {images => windows/images}/preview.png (100%) rename {images => windows/images}/show_livecaptions.png (100%) rename package-lock.json => windows/package-lock.json (100%) rename package.json => windows/package.json (100%) rename {scripts => windows/scripts}/build/build-installer.bat (100%) rename {scripts => windows/scripts}/build/build-version.ps1 (100%) rename {scripts => windows/scripts}/build/build.ps1 (100%) rename {scripts => windows/scripts}/build/create-release.ps1 (100%) rename {scripts => windows/scripts}/deployment/LCT-v1.0.1-Setup.zip (100%) rename {scripts => windows/scripts}/deployment/VC_redist.x64.exe (100%) rename {scripts => windows/scripts}/deployment/deploy.ps1 (100%) rename {scripts => windows/scripts}/deployment/installer.nsi (100%) rename {scripts => windows/scripts}/deployment/offline-setup.ps1 (100%) rename {scripts => windows/scripts}/deployment/startup.bat (100%) rename {scripts => windows/scripts}/diagnostics/clean-old-model.ps1 (100%) rename {scripts => windows/scripts}/diagnostics/complete-fix.ps1 (100%) rename {scripts => windows/scripts}/diagnostics/diagnose-startup-issue-en.ps1 (100%) rename {scripts => windows/scripts}/diagnostics/fix-startup-issue.ps1 (100%) rename {src => windows/src}/App.xaml (100%) rename {src => windows/src}/App.xaml.cs (100%) rename {src => windows/src}/AssemblyInfo.cs (100%) rename {src => windows/src}/LiveCaptions-Translator.ico (100%) rename {src => windows/src}/Program.cs (100%) rename {src => windows/src}/Services/Caption/LiveCaptionsHandler.cs (100%) rename {src => windows/src}/Services/Caption/RegexPatterns.cs (100%) rename {src => windows/src}/Services/Caption/TextUtil.cs (100%) rename {src => windows/src}/Services/History/DataMigrationManager.cs (100%) rename {src => windows/src}/Services/History/HistoryLogger.cs (100%) rename {src => windows/src}/Services/Ollama/OllamaDownloader.cs (100%) rename {src => windows/src}/Services/Ollama/OllamaGuardian.cs (100%) rename {src => windows/src}/Services/Platform/TelemetryService.cs (100%) rename {src => windows/src}/Services/Platform/WindowHandler.cs (100%) rename {src => windows/src}/Services/Platform/WindowsAPI.cs (100%) rename {src => windows/src}/Services/Startup/ApplicationSetup.cs (100%) rename {src => windows/src}/Services/Startup/StartupManager.cs (100%) rename {src => windows/src}/Services/Translation/TranslateAPI.cs (100%) rename {src => windows/src}/Services/Translation/TranslationTaskQueue.cs (100%) rename {src => windows/src}/Services/Update/AppVersionInfo.cs (100%) rename {src => windows/src}/Services/Update/AutoUpdateService.cs (100%) rename {src => windows/src}/Services/Update/UpdateUtil.cs (100%) rename {src => windows/src}/Services/Update/VersionCompatibilityChecker.cs (100%) rename {src => windows/src}/Services/Update/VersionManager.cs (100%) rename {src => windows/src}/Translator.cs (100%) rename {src => windows/src}/Views/Controls/VersionSettingsControl.xaml (100%) rename {src => windows/src}/Views/Controls/VersionSettingsControl.xaml.cs (100%) rename {src => windows/src}/Views/Pages/CaptionPage.xaml (100%) rename {src => windows/src}/Views/Pages/CaptionPage.xaml.cs (100%) rename {src => windows/src}/Views/Pages/HistoryPage.xaml (100%) rename {src => windows/src}/Views/Pages/HistoryPage.xaml.cs (100%) rename {src => windows/src}/Views/Pages/InfoPage.xaml (100%) rename {src => windows/src}/Views/Pages/InfoPage.xaml.cs (100%) rename {src => windows/src}/Views/Pages/SettingPage.xaml (100%) rename {src => windows/src}/Views/Pages/SettingPage.xaml.cs (100%) rename {src => windows/src}/Views/Windows/EnterpriseWelcomeWindow.xaml (100%) rename {src => windows/src}/Views/Windows/EnterpriseWelcomeWindow.xaml.cs (100%) rename {src => windows/src}/Views/Windows/MainWindow.xaml (100%) rename {src => windows/src}/Views/Windows/MainWindow.xaml.cs (100%) rename {src => windows/src}/Views/Windows/OverlayWindow.xaml (100%) rename {src => windows/src}/Views/Windows/OverlayWindow.xaml.cs (100%) rename {src => windows/src}/Views/Windows/SettingWindow.xaml (100%) rename {src => windows/src}/Views/Windows/SettingWindow.xaml.cs (100%) rename {src => windows/src}/Views/Windows/SplashWindow.xaml (100%) rename {src => windows/src}/Views/Windows/SplashWindow.xaml.cs (100%) rename {src => windows/src}/Views/Windows/UpdateWindow.xaml (100%) rename {src => windows/src}/Views/Windows/UpdateWindow.xaml.cs (100%) rename {src => windows/src}/Views/Windows/WelcomeWindow.xaml (100%) rename {src => windows/src}/Views/Windows/WelcomeWindow.xaml.cs (100%) rename {src => windows/src}/models/Caption.cs (100%) rename {src => windows/src}/models/InitializationStep.cs (100%) rename {src => windows/src}/models/ReleaseInfo.cs (100%) rename {src => windows/src}/models/Setting.cs (100%) rename {src => windows/src}/models/TranslateAPIConfig.cs (100%) rename {src => windows/src}/models/TranslationHistoryEntry.cs (100%) rename {src => windows/src}/models/VersionConfig.cs (100%) rename {src => windows/src}/models/VersionInfo.cs (100%) rename {src => windows/src}/models/WindowState.cs (100%) rename version-info.json => windows/version-info.json (100%) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index da39905..04d2762 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -28,7 +28,10 @@ jobs: build: name: Build Application runs-on: windows-latest - + defaults: + run: + working-directory: windows + outputs: version: ${{ steps.version.outputs.version }} version-suffix: ${{ steps.version.outputs.version-suffix }} @@ -157,7 +160,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: installer-${{ steps.version.outputs.version }} - path: ${{ steps.version.outputs.installer-name }} + path: windows/${{ steps.version.outputs.installer-name }} retention-days: 30 - name: Upload version info @@ -165,8 +168,8 @@ jobs: with: name: version-info-${{ steps.version.outputs.version }} path: | - version-info.json - release-info-*.txt + windows/version-info.json + windows/release-info-*.txt retention-days: 30 security-scan: diff --git a/.github/workflows/dotnet-build.yml b/.github/workflows/dotnet-build.yml index 69826ae..8313e97 100644 --- a/.github/workflows/dotnet-build.yml +++ b/.github/workflows/dotnet-build.yml @@ -7,9 +7,12 @@ on: tags: - 'v*' paths: - - 'src/**' - - 'Properties/**' + - 'windows/**' + - '.github/workflows/dotnet-build.yml' pull_request: + paths: + - 'windows/**' + - '.github/workflows/dotnet-build.yml' workflow_dispatch: jobs: @@ -23,6 +26,9 @@ jobs: contains(github.event.head_commit.message, 'build') name: Build and Test runs-on: windows-latest + defaults: + run: + working-directory: windows steps: - name: Checkout Repository @@ -57,7 +63,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: LiveCaptionsTranslator - path: ./publish + path: windows/publish release: name: Create GitHub Release diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml new file mode 100644 index 0000000..d165285 --- /dev/null +++ b/.github/workflows/macos-build.yml @@ -0,0 +1,65 @@ +name: macOS Build and Release + +on: + push: + branches: [ master, main, develop, 'release/*' ] + tags: [ 'v*' ] + paths: + - 'macos/**' + - '.github/workflows/macos-build.yml' + pull_request: + branches: [ master, main, develop ] + paths: + - 'macos/**' + - '.github/workflows/macos-build.yml' + workflow_dispatch: + +jobs: + build: + name: Build macOS App + runs-on: macos-14 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history so git describe / rev-list can stamp versions + + - name: Setup Swift + uses: swift-actions/setup-swift@v2 + with: + swift-version: "6.0" # Match the swift-tools-version in Package.swift + + - name: Build and Package App Bundle + run: | + cd macos + ./package-app.sh + zip -r LCTMac-macOS.zip LCTMac.app + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: LCTMac-macOS + path: macos/LCTMac-macOS.zip + retention-days: 7 + + release: + name: Create Release + runs-on: ubuntu-latest + needs: build + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Download Artifact + uses: actions/download-artifact@v4 + with: + name: LCTMac-macOS + path: ./release + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: ./release/LCTMac-macOS.zip + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 1366306..1610129 100644 --- a/.gitignore +++ b/.gitignore @@ -396,4 +396,8 @@ test-nsis.bat # Dell Local Edition specific DellLiveCaptionsTranslator*.exe -LiveCaptionsTranslator-Setup*.exe \ No newline at end of file +LiveCaptionsTranslator-Setup*.exe +# macOS App Build files +macos/.build/ +macos/.swiftpm/ +macos/LCTMac.app/ diff --git a/README.md b/README.md index 82ac46d..f29f0eb 100644 --- a/README.md +++ b/README.md @@ -1,194 +1,41 @@ -# LCT (LiveCaptions Translator) - Professional Edition +# LCT — LiveCaptions Translator -## Overview +Real-time speech-to-text captioning with on-the-fly AI translation, available as +native apps for **Windows** and **macOS**. -**✨ LCT (LiveCaptions Translator) = Windows Live Captions + Local AI Translation ✨** +LCT listens to live audio (system playback or microphone), transcribes it, and +streams a translation alongside the original — useful for following talks, +meetings, videos, or calls across a language barrier. -A professional real-time speech translation solution that combines Windows' built-in Live Captions with local AI models via Ollama for private, secure, and efficient translation. Developed by Ai-All-You-Need-Platform Pte. Ltd. +## Platforms -**🚀 Quick Start:** Download from releases and launch instantly! +| Platform | Stack | Status | Docs | +|----------|-------|--------|------| +| [Windows](windows/) | C# / .NET 8 (WPF) + LiveCaptions | Stable | [windows/README.md](windows/README.md) | +| [macOS](macos/) | SwiftUI + `SFSpeechRecognizer` + local LLM (Ollama) | Active development | [macos/README.md](macos/README.md) | -**🔒 Privacy First:** All translation happens locally on your device - no data leaves your machine. +The two apps are independent codebases tuned to each platform's native speech and +UI frameworks. They share this repository for common documentation, translation +benchmarks, and a single issue/release home. -## Key Features +## Repository layout -### 🔄 **Seamless Integration** -- Automatically invokes Windows Live Captions without opening a separate window -- Provides unified experience for real-time audio/speech translation -- Live Captions are hidden by default after first use (configurable in settings) -- Enable ***Include microphone audio*** in Windows Live Captions settings for real-time speech translation +``` +. +├── windows/ # Windows app (.NET solution, source, installer scripts) +├── macos/ # macOS app (Swift Package, SwiftUI) +├── benchmark/ # Cross-platform translation-quality benchmarks +├── docs/ # Shared / macOS user documentation +└── .github/ # CI workflows (per-platform, path-filtered) +``` -### 🤖 **Local AI Translation** -- **Ollama Engine**: Exclusive use of local AI models for translation -- **Privacy Focused**: All processing happens on your device -- **Multiple Models**: Support for various language models (qwen2.5:3b default) -- **Intel IPEX-LLM**: Optimized for Intel hardware acceleration -- **No Internet Required**: Works completely offline after initial setup +## Building -### 🎨 **Modern Interface** -- Clean Fluent UI that matches modern Windows aesthetics -- Automatic light/dark theme switching based on system settings -- Professional branding and user experience -- Multiple viewing modes: standard and compact overlay +- **Windows** — open `windows/LiveCaptionsTranslator.sln` in Visual Studio, or + `dotnet build` inside `windows/`. See [windows/README.md](windows/README.md). +- **macOS** — `cd macos && ./package-app.sh` (or `swift build`). Requires macOS 15+ + and a local [Ollama](https://ollama.com) install. See [macos/README.md](macos/README.md). -### 🪟 **Overlay Window** -- Borderless, transparent floating window for immersive experience -- Perfect for gaming, videos, and live streaming -- Fully customizable appearance: background, text color, font size, transparency -- Can be embedded seamlessly into screen without interfering with other operations -- Adjustable sentence count display +## License -### ⚙️ **Flexible Control** -- Window always-on-top functionality -- Convenient translation pause/resume -- One-click text copying for quick sharing -- Comprehensive settings for Ollama configuration - -### 📒 **History Management** -- Records both original and translated text -- Perfect for meetings, lectures, and important discussions -- Export all records to CSV format -- Search and filter capabilities - -### 🎞️ **Log Cards** -- Recent transcription records displayed as log cards -- Helps maintain context during conversations -- Configurable card count in settings - -## System Requirements - -| Requirement | Details | -|-------------|----------| -| **Windows** | Windows 11 (22H2+) with Live Captions support | -| **.NET** | .NET 8.0+ Runtime (included in installer) | -| **Hardware** | Intel CPU recommended for IPEX-LLM optimization | -| **Memory** | 8GB+ RAM for optimal AI model performance | -| **Storage** | 2GB+ free space for application and models | - -> ⚠️ **Important**: This tool is based on Windows Live Captions, available since **Windows 11 22H2**. - -## Getting Started - -> ⚠️ **IMPORTANT:** Complete the following setup before running LCT for the first time. -> -> For more details, see Microsoft's guide: [Using live captions to better understand audio](https://support.microsoft.com/en-us/windows/use-live-captions-to-better-understand-audio-b52da59c-14b8-4031-aeeb-f6a47e6055df) - -### Step 1: Verify Windows Live Captions Availability - -Ensure Live Captions is available on your system through any of these methods: - -- Toggle **Live captions** in the Quick Settings panel -- Use the keyboard shortcut **Win + Ctrl + L** -- Navigate to **Quick Settings** > **Accessibility** > **Live captions** -- Access from **Start** > **All apps** > **Accessibility** > **Live captions** -- Go to **Settings** > **Accessibility** > **Captions** and toggle on **Live captions** - -### Step 2: Initial Live Captions Configuration - -When you first launch Windows Live Captions, it will: -1. Request permission to process voice data locally on your device -2. Prompt you to download the necessary language files for on-device speech recognition - -After the initial setup, configure the following settings: -1. Click the **⚙️ gear** icon in Live Captions to open settings -2. In **Change language**, select your **source language** (the language to be recognized) -3. Under **Position**, select **Overlaid on screen** - -> ⚠️ **Critical:** The position setting is required to prevent display issues when Live Captions runs in the background. - -### Step 3: Enable Microphone Audio (Optional) - -To enable real-time translation of your own speech through the microphone: - -1. In Live Captions, click the **⚙️ gear** icon -2. Go to **Preferences** -3. Check **Include microphone audio** - -This allows LCT to capture and translate both system audio and your microphone input simultaneously. - -### Step 4: Launch LCT - -After completing the configuration, close Windows Live Captions and start LiveCaptions Translator! 🎉 - -The application will automatically: -- Download and install Ollama if not present -- Pull the default translation model (qwen2.5:3b) -- Configure optimal settings for your hardware - -## Configuration - -Customize the following options in the **Settings** view: - -### Display Options -- **LiveCaptions**: Show/hide original caption text in Home view -- **Log Cards**: Number of recent translation cards displayed -- **Overlay Sentences**: Number of sentences shown in overlay mode -- **Show Latency**: Display translation processing time - -### Ollama Configuration -- **API Endpoint**: Local Ollama server URL (default: http://localhost:11434) -- **Model Name**: AI model for translation (default: qwen2.5:3b) -- **System Prompt**: Custom instructions for translation behavior -- **Temperature**: Control translation creativity/consistency -- **Max Tokens**: Maximum response length - -### Performance Tuning -- **API Interval**: Delay between translation requests (balance speed vs. resource usage) -- **Target Language**: Destination language for translations -- **Model Loading**: Automatic model management and optimization - -## Architecture - -### Core Components -- **Translation Engine**: Exclusive Ollama integration with Intel IPEX-LLM -- **UI Framework**: WPF with Fluent UI components -- **Data Layer**: SQLite for history storage -- **System Integration**: Windows Live Captions automation -- **Model Management**: Automatic download and optimization - -### Build Information -- **Version**: 1.0.1 (Build 279) -- **Target Framework**: .NET 8.0-windows -- **Runtime**: Self-contained win-x64 -- **Installer Size**: ~97MB -- **Configuration**: Release with ReadyToRun optimization - -## Privacy & Security - -✅ **Complete Local Processing**: All translation happens on your device -✅ **No Internet Required**: Works offline after initial setup -✅ **No Data Collection**: Your conversations never leave your machine -✅ **Secure by Design**: No external API calls or cloud dependencies -✅ **Enterprise Ready**: Suitable for confidential business communications - -## Support and Contact - -### Company Information -**Ai-All-You-Need-Platform Pte. Ltd.** -Official Website: [https://aiallyouneed.dev](https://aiallyouneed.dev) - -### Troubleshooting - -#### Common Issues -- **Model Download Fails**: Check internet connection during initial setup -- **Performance Issues**: Ensure adequate RAM and close unnecessary applications -- **Live Captions Not Working**: Verify Windows version and accessibility settings -- **Translation Delays**: Adjust API interval in settings for better performance - -#### Support Resources -- Check logs in application data folder -- Verify Ollama service status in Task Manager -- Ensure Windows Live Captions is properly configured -- Visit our official website for documentation and support - -## Technical Notes - -- Built with .NET 8.0 and WPF-UI framework -- Uses Intel IPEX-LLM for hardware acceleration -- Supports ARM64 architecture for future compatibility -- Implements SQLite for efficient local data storage -- Features automatic error recovery and model management - ---- - -**Copyright © 2024 Ai-All-You-Need-Platform Pte. Ltd. All rights reserved.** \ No newline at end of file +See [LICENSE](LICENSE). Applies to both platform apps. diff --git a/benchmark/benchmark_raw.json b/benchmark/benchmark_raw.json new file mode 100644 index 0000000..6c61e03 --- /dev/null +++ b/benchmark/benchmark_raw.json @@ -0,0 +1,14106 @@ +{ + "gemma4:12b-mlx": [ + { + "case_id": "A01", + "run": 1, + "input": "Let's get started.", + "output_raw": "让我们开始吧。", + "output_clean": "让我们开始吧。", + "reference": "让我们开始吧。", + "ttft_s": 3.2024, + "total_s": 3.4988, + "tps": 13.44, + "prompt_tps": 43.12, + "eval_tokens": 4, + "prompt_tokens": 136, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A01", + "run": 2, + "input": "Let's get started.", + "output_raw": "让我们开始吧。", + "output_clean": "让我们开始吧。", + "reference": "让我们开始吧。", + "ttft_s": 0.1825, + "total_s": 0.5658, + "tps": 10.43, + "prompt_tps": 952.06, + "eval_tokens": 4, + "prompt_tokens": 136, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A01", + "run": 3, + "input": "Let's get started.", + "output_raw": "让我们开始吧。", + "output_clean": "让我们开始吧。", + "reference": "让我们开始吧。", + "ttft_s": 0.2436, + "total_s": 0.748, + "tps": 7.98, + "prompt_tps": 656.34, + "eval_tokens": 4, + "prompt_tokens": 136, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A02", + "run": 1, + "input": "Can you hear me?", + "output_raw": "您能听到我说话吗?", + "output_clean": "您能听到我说话吗?", + "reference": "你能听到我吗?", + "ttft_s": 1.0243, + "total_s": 1.7829, + "tps": 9.21, + "prompt_tps": 137.35, + "eval_tokens": 7, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A02", + "run": 2, + "input": "Can you hear me?", + "output_raw": "您能听到我说话吗?", + "output_clean": "您能听到我说话吗?", + "reference": "你能听到我吗?", + "ttft_s": 0.2325, + "total_s": 1.0465, + "tps": 8.6, + "prompt_tps": 697.29, + "eval_tokens": 7, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A02", + "run": 3, + "input": "Can you hear me?", + "output_raw": "您能听到我说话吗?", + "output_clean": "您能听到我说话吗?", + "reference": "你能听到我吗?", + "ttft_s": 0.2381, + "total_s": 1.1231, + "tps": 7.91, + "prompt_tps": 679.6, + "eval_tokens": 7, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A03", + "run": 1, + "input": "That's a great point.", + "output_raw": "那是一个非常好的观点。", + "output_clean": "那是一个非常好的观点。", + "reference": "说得很好。", + "ttft_s": 1.0681, + "total_s": 1.7423, + "tps": 8.9, + "prompt_tps": 133.47, + "eval_tokens": 6, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A03", + "run": 2, + "input": "That's a great point.", + "output_raw": "那是个很好的观点。", + "output_clean": "那是个很好的观点。", + "reference": "说得很好。", + "ttft_s": 0.2501, + "total_s": 0.8632, + "tps": 8.15, + "prompt_tps": 647.15, + "eval_tokens": 5, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A03", + "run": 3, + "input": "That's a great point.", + "output_raw": "那是个很好的观点。", + "output_clean": "那是个很好的观点。", + "reference": "说得很好。", + "ttft_s": 0.253, + "total_s": 0.884, + "tps": 7.92, + "prompt_tps": 648.32, + "eval_tokens": 5, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A04", + "run": 1, + "input": "I'll share my screen.", + "output_raw": "我将共享我的屏幕。", + "output_clean": "我将共享我的屏幕。", + "reference": "我来共享一下屏幕。", + "ttft_s": 1.0479, + "total_s": 1.7113, + "tps": 9.04, + "prompt_tps": 136.02, + "eval_tokens": 6, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A04", + "run": 2, + "input": "I'll share my screen.", + "output_raw": "我将共享屏幕。", + "output_clean": "我将共享屏幕。", + "reference": "我来共享一下屏幕。", + "ttft_s": 0.2615, + "total_s": 0.9184, + "tps": 7.69, + "prompt_tps": 612.21, + "eval_tokens": 5, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A04", + "run": 3, + "input": "I'll share my screen.", + "output_raw": "我将共享我的屏幕。", + "output_clean": "我将共享我的屏幕。", + "reference": "我来共享一下屏幕。", + "ttft_s": 0.2549, + "total_s": 1.0092, + "tps": 7.96, + "prompt_tps": 636.92, + "eval_tokens": 6, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A05", + "run": 1, + "input": "Any questions so far?", + "output_raw": "到目前为止有什么问题吗?", + "output_clean": "到目前为止有什么问题吗?", + "reference": "到目前为止有什么问题吗?", + "ttft_s": 1.0658, + "total_s": 1.8799, + "tps": 8.57, + "prompt_tps": 132.43, + "eval_tokens": 7, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A05", + "run": 2, + "input": "Any questions so far?", + "output_raw": "到目前为止有什么问题吗?", + "output_clean": "到目前为止有什么问题吗?", + "reference": "到目前为止有什么问题吗?", + "ttft_s": 0.313, + "total_s": 1.2826, + "tps": 7.22, + "prompt_tps": 538.12, + "eval_tokens": 7, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A05", + "run": 3, + "input": "Any questions so far?", + "output_raw": "到目前为止有什么问题吗?", + "output_clean": "到目前为止有什么问题吗?", + "reference": "到目前为止有什么问题吗?", + "ttft_s": 0.26, + "total_s": 1.1507, + "tps": 7.86, + "prompt_tps": 617.08, + "eval_tokens": 7, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "B01", + "run": 1, + "input": "I think we should postpone the launch to next quarter because the QA team hasn't finished testing yet.", + "output_raw": "我认为我们应该将发布推迟到下个季度,因为质量保证团队尚未完成测试。", + "output_clean": "我认为我们应该将发布推迟到下个季度,因为质量保证团队尚未完成测试。", + "reference": "我认为我们应该把发布推迟到下个季度,因为QA团队还没有完成测试。", + "ttft_s": 1.0935, + "total_s": 3.623, + "tps": 7.89, + "prompt_tps": 148.55, + "eval_tokens": 20, + "prompt_tokens": 151, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B01", + "run": 2, + "input": "I think we should postpone the launch to next quarter because the QA team hasn't finished testing yet.", + "output_raw": "我认为我们应该将发布推迟到下个季度,因为质量保证团队尚未完成测试。", + "output_clean": "我认为我们应该将发布推迟到下个季度,因为质量保证团队尚未完成测试。", + "reference": "我认为我们应该把发布推迟到下个季度,因为QA团队还没有完成测试。", + "ttft_s": 0.3603, + "total_s": 2.6054, + "tps": 8.9, + "prompt_tps": 476.38, + "eval_tokens": 20, + "prompt_tokens": 151, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B01", + "run": 3, + "input": "I think we should postpone the launch to next quarter because the QA team hasn't finished testing yet.", + "output_raw": "我认为我们应该将发布推迟到下个季度,因为质检团队尚未完成测试。", + "output_clean": "我认为我们应该将发布推迟到下个季度,因为质检团队尚未完成测试。", + "reference": "我认为我们应该把发布推迟到下个季度,因为QA团队还没有完成测试。", + "ttft_s": 0.2479, + "total_s": 2.5833, + "tps": 8.56, + "prompt_tps": 728.87, + "eval_tokens": 20, + "prompt_tokens": 151, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B02", + "run": 1, + "input": "The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why.", + "output_raw": "用户留存率较上月下降了百分之十五,我们需要找出原因。", + "output_clean": "用户留存率较上月下降了百分之十五,我们需要找出原因。", + "reference": "用户留存率与上个月相比下降了百分之十五,我们需要找出原因。", + "ttft_s": 1.1863, + "total_s": 3.3502, + "tps": 7.83, + "prompt_tps": 135.99, + "eval_tokens": 17, + "prompt_tokens": 151, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B02", + "run": 2, + "input": "The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why.", + "output_raw": "用户留存率比上个月下降了百分之十五,我们需要找出原因。", + "output_clean": "用户留存率比上个月下降了百分之十五,我们需要找出原因。", + "reference": "用户留存率与上个月相比下降了百分之十五,我们需要找出原因。", + "ttft_s": 0.2951, + "total_s": 2.5292, + "tps": 7.61, + "prompt_tps": 641.11, + "eval_tokens": 17, + "prompt_tokens": 151, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B02", + "run": 3, + "input": "The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why.", + "output_raw": "用户留存率较上月下降了15%,我们需要找出原因。", + "output_clean": "用户留存率较上月下降了15%,我们需要找出原因。", + "reference": "用户留存率与上个月相比下降了百分之十五,我们需要找出原因。", + "ttft_s": 0.2781, + "total_s": 2.3109, + "tps": 7.87, + "prompt_tps": 685.63, + "eval_tokens": 16, + "prompt_tokens": 151, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B03", + "run": 1, + "input": "We're planning to migrate our infrastructure from AWS to Google Cloud by the end of this year.", + "output_raw": "我们计划在今年年底前将基础设施从 AWS 迁移到 Google Cloud。", + "output_clean": "我们计划在今年年底前将基础设施从 AWS 迁移到 Google Cloud。", + "reference": "我们计划在今年年底前将基础设施从AWS迁移到Google Cloud。", + "ttft_s": 1.0636, + "total_s": 2.996, + "tps": 8.27, + "prompt_tps": 151.69, + "eval_tokens": 16, + "prompt_tokens": 150, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B03", + "run": 2, + "input": "We're planning to migrate our infrastructure from AWS to Google Cloud by the end of this year.", + "output_raw": "我们计划在今年年底前将基础设施从 AWS 迁移到 Google Cloud。", + "output_clean": "我们计划在今年年底前将基础设施从 AWS 迁移到 Google Cloud。", + "reference": "我们计划在今年年底前将基础设施从AWS迁移到Google Cloud。", + "ttft_s": 0.2736, + "total_s": 2.1883, + "tps": 8.34, + "prompt_tps": 664.66, + "eval_tokens": 16, + "prompt_tokens": 150, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B03", + "run": 3, + "input": "We're planning to migrate our infrastructure from AWS to Google Cloud by the end of this year.", + "output_raw": "我们计划在今年年底前将基础设施从 AWS 迁移到 Google Cloud。", + "output_clean": "我们计划在今年年底前将基础设施从 AWS 迁移到 Google Cloud。", + "reference": "我们计划在今年年底前将基础设施从AWS迁移到Google Cloud。", + "ttft_s": 0.2506, + "total_s": 2.2424, + "tps": 8.03, + "prompt_tps": 738.21, + "eval_tokens": 16, + "prompt_tokens": 150, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B04", + "run": 1, + "input": "The new privacy policy requires us to obtain explicit user consent before collecting any personal data.", + "output_raw": "新的隐私政策要求我们在收集任何个人数据之前,必须获得用户的明确同意。", + "output_clean": "新的隐私政策要求我们在收集任何个人数据之前,必须获得用户的明确同意。", + "reference": "新的隐私政策要求我们在收集任何个人数据之前获得用户的明确同意。", + "ttft_s": 1.0772, + "total_s": 3.0878, + "tps": 8.45, + "prompt_tps": 144.12, + "eval_tokens": 17, + "prompt_tokens": 147, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B04", + "run": 2, + "input": "The new privacy policy requires us to obtain explicit user consent before collecting any personal data.", + "output_raw": "新的隐私政策要求我们在收集任何个人数据之前,必须获得用户的明确同意。", + "output_clean": "新的隐私政策要求我们在收集任何个人数据之前,必须获得用户的明确同意。", + "reference": "新的隐私政策要求我们在收集任何个人数据之前获得用户的明确同意。", + "ttft_s": 0.2851, + "total_s": 2.4652, + "tps": 7.8, + "prompt_tps": 650.06, + "eval_tokens": 17, + "prompt_tokens": 147, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B04", + "run": 3, + "input": "The new privacy policy requires us to obtain explicit user consent before collecting any personal data.", + "output_raw": "新的隐私政策要求我们在收集任何个人数据之前,必须获得用户的明确同意。", + "output_clean": "新的隐私政策要求我们在收集任何个人数据之前,必须获得用户的明确同意。", + "reference": "新的隐私政策要求我们在收集任何个人数据之前获得用户的明确同意。", + "ttft_s": 0.2617, + "total_s": 2.3616, + "tps": 8.09, + "prompt_tps": 680.78, + "eval_tokens": 17, + "prompt_tokens": 147, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B05", + "run": 1, + "input": "Could you walk us through the architecture diagram? I want to make sure everyone is on the same page.", + "output_raw": "您能带我们过一下架构图吗?我想确保大家的理解是一致的。", + "output_clean": "您能带我们过一下架构图吗?我想确保大家的理解是一致的。", + "reference": "你能给我们讲一下架构图吗?我想确保大家的理解是一致的。", + "ttft_s": 1.0957, + "total_s": 3.2994, + "tps": 8.14, + "prompt_tps": 143.87, + "eval_tokens": 18, + "prompt_tokens": 151, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B05", + "run": 2, + "input": "Could you walk us through the architecture diagram? I want to make sure everyone is on the same page.", + "output_raw": "您能带我们过一遍架构图吗?我想确保大家的理解一致。", + "output_clean": "您能带我们过一遍架构图吗?我想确保大家的理解一致。", + "reference": "你能给我们讲一下架构图吗?我想确保大家的理解是一致的。", + "ttft_s": 0.2736, + "total_s": 2.5038, + "tps": 7.17, + "prompt_tps": 665.83, + "eval_tokens": 16, + "prompt_tokens": 151, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B05", + "run": 3, + "input": "Could you walk us through the architecture diagram? I want to make sure everyone is on the same page.", + "output_raw": "您能带我们过一遍架构图吗?我想确保大家的理解一致。", + "output_clean": "您能带我们过一遍架构图吗?我想确保大家的理解一致。", + "reference": "你能给我们讲一下架构图吗?我想确保大家的理解是一致的。", + "ttft_s": 0.3026, + "total_s": 2.3023, + "tps": 8.0, + "prompt_tps": 625.87, + "eval_tokens": 16, + "prompt_tokens": 151, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "C01", + "run": 1, + "input": "So let me summarize today's discussion. We agreed to move forward with option B, which involves rebuilding the payment module from scratch. The timeline is eight weeks, with the first milestone due in three weeks. John will lead the backend team, and Sarah will handle the frontend integration. We also need to coordinate with the compliance team to ensure we meet all regulatory requirements before going live.", + "output_raw": "让我总结一下今天的讨论内容。我们同意采用方案 B,即从零开始重构支付模块。项目周期为八周,第一阶段的里程碑节点定在三周后。John 将带领后端团队,Sarah 将负责前端整合。此外,我们还需要与合规团队协调,确保在上线前满足所有监管要求。", + "output_clean": "让我总结一下今天的讨论内容。我们同意采用方案 B,即从零开始重构支付模块。项目周期为八周,第一阶段的里程碑节点定在三周后。John 将带领后端团队,Sarah 将负责前端整合。此外,我们还需要与合规团队协调,确保在上线前满足所有监管要求。", + "reference": "让我来总结一下今天的讨论。我们同意推进方案B,即从零开始重建支付模块。时间线是八周,第一个里程碑在三周内到期。John将带领后端团队,Sarah负责前端集成。我们还需要与合规团队协调,确保上线前满足所有监管要求。", + "ttft_s": 1.8108, + "total_s": 13.0655, + "tps": 6.31, + "prompt_tps": 116.73, + "eval_tokens": 71, + "prompt_tokens": 207, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C01", + "run": 2, + "input": "So let me summarize today's discussion. We agreed to move forward with option B, which involves rebuilding the payment module from scratch. The timeline is eight weeks, with the first milestone due in three weeks. John will lead the backend team, and Sarah will handle the frontend integration. We also need to coordinate with the compliance team to ensure we meet all regulatory requirements before going live.", + "output_raw": "那么,让我总结一下今天的讨论内容。我们同意采用方案 B,即从零开始重构支付模块。项目周期为八周,首个里程碑节点定在三周后。John 将负责后端团队的领导工作,Sarah 将负责前端整合。我们还需要与合规团队进行协调,以确保在上线前满足所有监管要求。", + "output_clean": "那么,让我总结一下今天的讨论内容。我们同意采用方案 B,即从零开始重构支付模块。项目周期为八周,首个里程碑节点定在三周后。John 将负责后端团队的领导工作,Sarah 将负责前端整合。我们还需要与合规团队进行协调,以确保在上线前满足所有监管要求。", + "reference": "让我来总结一下今天的讨论。我们同意推进方案B,即从零开始重建支付模块。时间线是八周,第一个里程碑在三周内到期。John将带领后端团队,Sarah负责前端集成。我们还需要与合规团队协调,确保上线前满足所有监管要求。", + "ttft_s": 0.4712, + "total_s": 10.0314, + "tps": 7.82, + "prompt_tps": 541.29, + "eval_tokens": 75, + "prompt_tokens": 207, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C01", + "run": 3, + "input": "So let me summarize today's discussion. We agreed to move forward with option B, which involves rebuilding the payment module from scratch. The timeline is eight weeks, with the first milestone due in three weeks. John will lead the backend team, and Sarah will handle the frontend integration. We also need to coordinate with the compliance team to ensure we meet all regulatory requirements before going live.", + "output_raw": "让我总结一下今天的讨论内容。我们同意采用方案 B,即从零开始重构支付模块。项目周期为八周,首个里程碑节点在三周内截止。John 将领导后端团队,Sarah 将负责前端集成。我们还需要与合规团队进行协调,确保在上线前满足所有监管要求。", + "output_clean": "让我总结一下今天的讨论内容。我们同意采用方案 B,即从零开始重构支付模块。项目周期为八周,首个里程碑节点在三周内截止。John 将领导后端团队,Sarah 将负责前端集成。我们还需要与合规团队进行协调,确保在上线前满足所有监管要求。", + "reference": "让我来总结一下今天的讨论。我们同意推进方案B,即从零开始重建支付模块。时间线是八周,第一个里程碑在三周内到期。John将带领后端团队,Sarah负责前端集成。我们还需要与合规团队协调,确保上线前满足所有监管要求。", + "ttft_s": 0.332, + "total_s": 10.2148, + "tps": 6.98, + "prompt_tps": 825.83, + "eval_tokens": 69, + "prompt_tokens": 207, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C02", + "run": 1, + "input": "Artificial intelligence is transforming the healthcare industry in ways we never imagined. From early disease detection using medical imaging to personalized treatment plans based on genetic data, AI is helping doctors make better decisions faster. However, we must also address the ethical concerns around patient privacy, algorithmic bias, and the potential displacement of healthcare workers.", + "output_raw": "人工智能正以我们从未想象过的方式改变医疗保健行业。从利用医学影像进行早期疾病检测,到基于遗传数据制定个性化治疗方案,人工智能正在帮助医生更快地做出更好的决策。然而,我们还必须解决与患者隐私、算法偏见以及可能导致的医疗从业人员被取代等相关的伦理问题。", + "output_clean": "人工智能正以我们从未想象过的方式改变医疗保健行业。从利用医学影像进行早期疾病检测,到基于遗传数据制定个性化治疗方案,人工智能正在帮助医生更快地做出更好的决策。然而,我们还必须解决与患者隐私、算法偏见以及可能导致的医疗从业人员被取代等相关的伦理问题。", + "reference": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据的个性化治疗方案,AI正在帮助医生更快地做出更好的决策。然而,我们也必须解决围绕患者隐私、算法偏见以及医疗工作者可能被取代等伦理问题。", + "ttft_s": 2.6744, + "total_s": 12.0976, + "tps": 7.32, + "prompt_tps": 76.18, + "eval_tokens": 69, + "prompt_tokens": 192, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C02", + "run": 2, + "input": "Artificial intelligence is transforming the healthcare industry in ways we never imagined. From early disease detection using medical imaging to personalized treatment plans based on genetic data, AI is helping doctors make better decisions faster. However, we must also address the ethical concerns around patient privacy, algorithmic bias, and the potential displacement of healthcare workers.", + "output_raw": "人工智能正在以我们从未想象过的方式改变医疗保健行业。从利用医学影像进行早期疾病检测,到基于遗传数据制定个性化治疗方案,人工智能正帮助医生做出更快速、更明智的决策。然而,我们也必须应对围绕患者隐私、算法偏见以及可能导致的医疗从业人员岗位流失等伦理问题。", + "output_clean": "人工智能正在以我们从未想象过的方式改变医疗保健行业。从利用医学影像进行早期疾病检测,到基于遗传数据制定个性化治疗方案,人工智能正帮助医生做出更快速、更明智的决策。然而,我们也必须应对围绕患者隐私、算法偏见以及可能导致的医疗从业人员岗位流失等伦理问题。", + "reference": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据的个性化治疗方案,AI正在帮助医生更快地做出更好的决策。然而,我们也必须解决围绕患者隐私、算法偏见以及医疗工作者可能被取代等伦理问题。", + "ttft_s": 0.4597, + "total_s": 11.1156, + "tps": 6.85, + "prompt_tps": 507.15, + "eval_tokens": 73, + "prompt_tokens": 192, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C02", + "run": 3, + "input": "Artificial intelligence is transforming the healthcare industry in ways we never imagined. From early disease detection using medical imaging to personalized treatment plans based on genetic data, AI is helping doctors make better decisions faster. However, we must also address the ethical concerns around patient privacy, algorithmic bias, and the potential displacement of healthcare workers.", + "output_raw": "人工智能正在以我们从未想象过的方式改变医疗保健行业。从利用医学影像进行早期疾病检测,到基于基因数据的个性化治疗方案,人工智能正帮助医生做出更明智的决策并提高效率。然而,我们还必须解决围绕患者隐私、算法偏见以及可能导致医护人员被取代等相关的伦理问题。", + "output_clean": "人工智能正在以我们从未想象过的方式改变医疗保健行业。从利用医学影像进行早期疾病检测,到基于基因数据的个性化治疗方案,人工智能正帮助医生做出更明智的决策并提高效率。然而,我们还必须解决围绕患者隐私、算法偏见以及可能导致医护人员被取代等相关的伦理问题。", + "reference": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据的个性化治疗方案,AI正在帮助医生更快地做出更好的决策。然而,我们也必须解决围绕患者隐私、算法偏见以及医疗工作者可能被取代等伦理问题。", + "ttft_s": 0.4556, + "total_s": 10.7321, + "tps": 7.01, + "prompt_tps": 777.04, + "eval_tokens": 72, + "prompt_tokens": 192, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "D01", + "run": 1, + "input": "So basically what I'm trying to say is", + "output_raw": "所以基本上我想表达的是", + "output_clean": "所以基本上我想表达的是", + "reference": "所以基本上我想说的是", + "ttft_s": 1.3294, + "total_s": 2.1793, + "tps": 5.8, + "prompt_tps": 124.89, + "eval_tokens": 5, + "prompt_tokens": 140, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D01", + "run": 2, + "input": "So basically what I'm trying to say is", + "output_raw": "所以,基本上我想表达的是", + "output_clean": "所以,基本上我想表达的是", + "reference": "所以基本上我想说的是", + "ttft_s": 0.3598, + "total_s": 1.4336, + "tps": 5.58, + "prompt_tps": 542.36, + "eval_tokens": 6, + "prompt_tokens": 140, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D01", + "run": 3, + "input": "So basically what I'm trying to say is", + "output_raw": "所以基本上我想表达的是", + "output_clean": "所以基本上我想表达的是", + "reference": "所以基本上我想说的是", + "ttft_s": 0.2961, + "total_s": 0.9991, + "tps": 7.12, + "prompt_tps": 562.59, + "eval_tokens": 5, + "prompt_tokens": 140, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D02", + "run": 1, + "input": "The reason we can't do that is because the", + "output_raw": "我们之所以无法这样做,是因为", + "output_clean": "我们之所以无法这样做,是因为", + "reference": "我们不能那样做的原因是因为", + "ttft_s": 1.4947, + "total_s": 2.3168, + "tps": 7.24, + "prompt_tps": 100.88, + "eval_tokens": 6, + "prompt_tokens": 141, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D02", + "run": 2, + "input": "The reason we can't do that is because the", + "output_raw": "我们之所以无法这样做,是因为", + "output_clean": "我们之所以无法这样做,是因为", + "reference": "我们不能那样做的原因是因为", + "ttft_s": 0.3661, + "total_s": 1.1924, + "tps": 7.22, + "prompt_tps": 461.13, + "eval_tokens": 6, + "prompt_tokens": 141, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D02", + "run": 3, + "input": "The reason we can't do that is because the", + "output_raw": "我们之所以无法这样做,是因为", + "output_clean": "我们之所以无法这样做,是因为", + "reference": "我们不能那样做的原因是因为", + "ttft_s": 0.2669, + "total_s": 1.0561, + "tps": 7.61, + "prompt_tps": 619.87, + "eval_tokens": 6, + "prompt_tokens": 141, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D03", + "run": 1, + "input": "If we look at the data from last", + "output_raw": "如果我们看一下过去的数据", + "output_clean": "如果我们看一下过去的数据", + "reference": "如果我们看一下上", + "ttft_s": 1.1392, + "total_s": 1.6145, + "tps": 8.39, + "prompt_tps": 126.28, + "eval_tokens": 4, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D03", + "run": 2, + "input": "If we look at the data from last", + "output_raw": "如果我们看一下上一次的数据", + "output_clean": "如果我们看一下上一次的数据", + "reference": "如果我们看一下上", + "ttft_s": 0.3004, + "total_s": 1.2473, + "tps": 5.28, + "prompt_tps": 543.54, + "eval_tokens": 5, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D03", + "run": 3, + "input": "If we look at the data from last", + "output_raw": "如果我们看过去一段时间的数据", + "output_clean": "如果我们看过去一段时间的数据", + "reference": "如果我们看一下上", + "ttft_s": 0.4454, + "total_s": 1.1854, + "tps": 6.84, + "prompt_tps": 356.6, + "eval_tokens": 5, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D04", + "run": 1, + "input": "I mean it's not that we don't want to but", + "output_raw": "我的意思是,并不是我们不想做,而是...", + "output_clean": "我的意思是,并不是我们不想做,而是...", + "reference": "我的意思是,不是我们不想,但是", + "ttft_s": 1.1886, + "total_s": 2.4978, + "tps": 7.56, + "prompt_tps": 131.55, + "eval_tokens": 10, + "prompt_tokens": 144, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D04", + "run": 2, + "input": "I mean it's not that we don't want to but", + "output_raw": "我的意思是,并不是我们不想做,而是...", + "output_clean": "我的意思是,并不是我们不想做,而是...", + "reference": "我的意思是,不是我们不想,但是", + "ttft_s": 0.4136, + "total_s": 1.5321, + "tps": 8.94, + "prompt_tps": 389.76, + "eval_tokens": 10, + "prompt_tokens": 144, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D04", + "run": 3, + "input": "I mean it's not that we don't want to but", + "output_raw": "我的意思是,并不是我们不想,而是...", + "output_clean": "我的意思是,并不是我们不想,而是...", + "reference": "我的意思是,不是我们不想,但是", + "ttft_s": 0.3008, + "total_s": 1.3505, + "tps": 8.58, + "prompt_tps": 539.94, + "eval_tokens": 9, + "prompt_tokens": 144, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D05", + "run": 1, + "input": "Well the thing is um we actually", + "output_raw": "嗯,其实我们是...", + "output_clean": "嗯,其实我们是...", + "reference": "嗯,事情是这样的,实际上我们", + "ttft_s": 0.8481, + "total_s": 1.4727, + "tps": 9.6, + "prompt_tps": 168.78, + "eval_tokens": 6, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D05", + "run": 2, + "input": "Well the thing is um we actually", + "output_raw": "嗯,其实情况是,我们确实...", + "output_clean": "嗯,其实情况是,我们确实...", + "reference": "嗯,事情是这样的,实际上我们", + "ttft_s": 0.4329, + "total_s": 1.5631, + "tps": 7.96, + "prompt_tps": 353.53, + "eval_tokens": 9, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D05", + "run": 3, + "input": "Well the thing is um we actually", + "output_raw": "嗯,问题是,我们实际上...", + "output_clean": "嗯,问题是,我们实际上...", + "reference": "嗯,事情是这样的,实际上我们", + "ttft_s": 0.4598, + "total_s": 1.4725, + "tps": 7.88, + "prompt_tps": 325.2, + "eval_tokens": 8, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "E01", + "run": 1, + "input": "We deployed the new Kubernetes cluster on AWS EKS last Friday.", + "output_raw": "我们上周五在 AWS EKS 上部署了新的 Kubernetes 集群。", + "output_clean": "我们上周五在 AWS EKS 上部署了新的 Kubernetes 集群。", + "reference": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "ttft_s": 1.6873, + "total_s": 3.8488, + "tps": 7.41, + "prompt_tps": 87.35, + "eval_tokens": 16, + "prompt_tokens": 143, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 2, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E01", + "run": 2, + "input": "We deployed the new Kubernetes cluster on AWS EKS last Friday.", + "output_raw": "我们上周五在 AWS EKS 上部署了新的 Kubernetes 集群。", + "output_clean": "我们上周五在 AWS EKS 上部署了新的 Kubernetes 集群。", + "reference": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "ttft_s": 0.4372, + "total_s": 2.3986, + "tps": 8.16, + "prompt_tps": 368.35, + "eval_tokens": 16, + "prompt_tokens": 143, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 2, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E01", + "run": 3, + "input": "We deployed the new Kubernetes cluster on AWS EKS last Friday.", + "output_raw": "我们上周五在 AWS EKS 上部署了新的 Kubernetes 集群。", + "output_clean": "我们上周五在 AWS EKS 上部署了新的 Kubernetes 集群。", + "reference": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "ttft_s": 0.381, + "total_s": 2.6999, + "tps": 6.9, + "prompt_tps": 414.74, + "eval_tokens": 16, + "prompt_tokens": 143, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 2, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E02", + "run": 1, + "input": "The React Native app is crashing on iOS seventeen because of a WebSocket timeout issue.", + "output_raw": "由于 WebSocket 超时问题,React Native 应用在 iOS 17 上崩溃了。", + "output_clean": "由于 WebSocket 超时问题,React Native 应用在 iOS 17 上崩溃了。", + "reference": "React Native应用在iOS 17上因为WebSocket超时问题而崩溃。", + "ttft_s": 1.1359, + "total_s": 3.7125, + "tps": 6.98, + "prompt_tps": 135.3, + "eval_tokens": 18, + "prompt_tokens": 146, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E02", + "run": 2, + "input": "The React Native app is crashing on iOS seventeen because of a WebSocket timeout issue.", + "output_raw": "由于 WebSocket 超时问题,React Native 应用在 iOS 17 上崩溃了。", + "output_clean": "由于 WebSocket 超时问题,React Native 应用在 iOS 17 上崩溃了。", + "reference": "React Native应用在iOS 17上因为WebSocket超时问题而崩溃。", + "ttft_s": 0.4312, + "total_s": 3.0088, + "tps": 6.98, + "prompt_tps": 370.17, + "eval_tokens": 18, + "prompt_tokens": 146, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E02", + "run": 3, + "input": "The React Native app is crashing on iOS seventeen because of a WebSocket timeout issue.", + "output_raw": "由于 WebSocket 超时问题,React Native 应用在 iOS 17 上崩溃。", + "output_clean": "由于 WebSocket 超时问题,React Native 应用在 iOS 17 上崩溃。", + "reference": "React Native应用在iOS 17上因为WebSocket超时问题而崩溃。", + "ttft_s": 0.3201, + "total_s": 2.4124, + "tps": 8.12, + "prompt_tps": 519.72, + "eval_tokens": 17, + "prompt_tokens": 146, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E03", + "run": 1, + "input": "Apple announced the new M5 chip with improved Neural Engine performance at WWDC.", + "output_raw": "苹果在 WWDC 上发布了搭载改进型神经网络引擎性能的新款 M5 芯片。", + "output_clean": "苹果在 WWDC 上发布了搭载改进型神经网络引擎性能的新款 M5 芯片。", + "reference": "Apple在WWDC上发布了新的M5芯片,改进了Neural Engine性能。", + "ttft_s": 0.9416, + "total_s": 3.4291, + "tps": 8.04, + "prompt_tps": 164.34, + "eval_tokens": 20, + "prompt_tokens": 146, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 4, + "missing_terms": [ + "Apple", + "Neural Engine" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E03", + "run": 2, + "input": "Apple announced the new M5 chip with improved Neural Engine performance at WWDC.", + "output_raw": "苹果在 WWDC 上发布了搭载性能更强的神经网络引擎的新款 M5 芯片。", + "output_clean": "苹果在 WWDC 上发布了搭载性能更强的神经网络引擎的新款 M5 芯片。", + "reference": "Apple在WWDC上发布了新的M5芯片,改进了Neural Engine性能。", + "ttft_s": 0.4139, + "total_s": 2.9255, + "tps": 7.96, + "prompt_tps": 387.57, + "eval_tokens": 20, + "prompt_tokens": 146, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 4, + "missing_terms": [ + "Apple", + "Neural Engine" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E03", + "run": 3, + "input": "Apple announced the new M5 chip with improved Neural Engine performance at WWDC.", + "output_raw": "苹果在 WWDC 上发布了搭载改进型神经网络引擎性能的新款 M5 芯片。", + "output_clean": "苹果在 WWDC 上发布了搭载改进型神经网络引擎性能的新款 M5 芯片。", + "reference": "Apple在WWDC上发布了新的M5芯片,改进了Neural Engine性能。", + "ttft_s": 0.4043, + "total_s": 2.9149, + "tps": 7.97, + "prompt_tps": 401.4, + "eval_tokens": 20, + "prompt_tokens": 146, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 4, + "missing_terms": [ + "Apple", + "Neural Engine" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E04", + "run": 1, + "input": "We need to implement OAuth two point zero authentication before integrating with the Stripe payment API.", + "output_raw": "在与 Stripe 付款 API 集成之前,我们需要实现 OAuth 2.0 认证。", + "output_clean": "在与 Stripe 付款 API 集成之前,我们需要实现 OAuth 2.0 认证。", + "reference": "在集成Stripe支付API之前,我们需要实现OAuth 2.0认证。", + "ttft_s": 0.9989, + "total_s": 3.3661, + "tps": 8.45, + "prompt_tps": 155.93, + "eval_tokens": 20, + "prompt_tokens": 147, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E04", + "run": 2, + "input": "We need to implement OAuth two point zero authentication before integrating with the Stripe payment API.", + "output_raw": "在集成 Stripe 付款 API 之前,我们需要实现 OAuth 2.0 认证。", + "output_clean": "在集成 Stripe 付款 API 之前,我们需要实现 OAuth 2.0 认证。", + "reference": "在集成Stripe支付API之前,我们需要实现OAuth 2.0认证。", + "ttft_s": 0.3858, + "total_s": 2.5163, + "tps": 8.92, + "prompt_tps": 422.33, + "eval_tokens": 19, + "prompt_tokens": 147, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E04", + "run": 3, + "input": "We need to implement OAuth two point zero authentication before integrating with the Stripe payment API.", + "output_raw": "在与 Stripe 付款 API 集成之前,我们需要实现 OAuth 2.0 认证。", + "output_clean": "在与 Stripe 付款 API 集成之前,我们需要实现 OAuth 2.0 认证。", + "reference": "在集成Stripe支付API之前,我们需要实现OAuth 2.0认证。", + "ttft_s": 0.4111, + "total_s": 2.9861, + "tps": 7.77, + "prompt_tps": 396.96, + "eval_tokens": 20, + "prompt_tokens": 147, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E05", + "run": 1, + "input": "The transformer model uses multi-head attention and layer normalization to process the input embeddings.", + "output_raw": "Transformer 模型采用多头注意力机制和层归一化来处理输入嵌入。", + "output_clean": "Transformer 模型采用多头注意力机制和层归一化来处理输入嵌入。", + "reference": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。", + "ttft_s": 1.0428, + "total_s": 3.2705, + "tps": 7.61, + "prompt_tps": 148.54, + "eval_tokens": 17, + "prompt_tokens": 147, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 1, + "term_total": 2, + "missing_terms": [ + "multi-head attention" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E05", + "run": 2, + "input": "The transformer model uses multi-head attention and layer normalization to process the input embeddings.", + "output_raw": "Transformer 模型利用多头注意力机制和层归一化来处理输入的嵌入向量。", + "output_clean": "Transformer 模型利用多头注意力机制和层归一化来处理输入的嵌入向量。", + "reference": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。", + "ttft_s": 0.4354, + "total_s": 2.7031, + "tps": 7.92, + "prompt_tps": 382.93, + "eval_tokens": 18, + "prompt_tokens": 147, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 1, + "term_total": 2, + "missing_terms": [ + "multi-head attention" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E05", + "run": 3, + "input": "The transformer model uses multi-head attention and layer normalization to process the input embeddings.", + "output_raw": "Transformer 模型使用多头注意力机制和层归一化来处理输入的嵌入表示。", + "output_clean": "Transformer 模型使用多头注意力机制和层归一化来处理输入的嵌入表示。", + "reference": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。", + "ttft_s": 0.3925, + "total_s": 2.5248, + "tps": 8.44, + "prompt_tps": 413.17, + "eval_tokens": 18, + "prompt_tokens": 147, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 1, + "term_total": 2, + "missing_terms": [ + "multi-head attention" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "F01", + "run": 1, + "input": "Let's start with the first one. How is it going so far?", + "output_raw": "让我们从第一个开始。目前进展如何?", + "output_clean": "让我们从第一个开始。目前进展如何?", + "reference": "让我们从第一个开始。目前进展如何?", + "ttft_s": 3.7322, + "total_s": 4.8507, + "tps": 8.04, + "prompt_tps": 63.08, + "eval_tokens": 9, + "prompt_tokens": 233, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F01", + "run": 2, + "input": "Let's start with the first one. How is it going so far?", + "output_raw": "让我们从第一个开始。目前的进展如何?", + "output_clean": "让我们从第一个开始。目前的进展如何?", + "reference": "让我们从第一个开始。目前进展如何?", + "ttft_s": 0.4485, + "total_s": 1.5043, + "tps": 8.54, + "prompt_tps": 563.68, + "eval_tokens": 9, + "prompt_tokens": 233, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F01", + "run": 3, + "input": "Let's start with the first one. How is it going so far?", + "output_raw": "让我们从第一个开始。目前进展如何?", + "output_clean": "让我们从第一个开始。目前进展如何?", + "reference": "让我们从第一个开始。目前进展如何?", + "ttft_s": 0.4016, + "total_s": 1.4952, + "tps": 8.19, + "prompt_tps": 670.56, + "eval_tokens": 9, + "prompt_tokens": 233, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F02", + "run": 1, + "input": "And what about the second item? When is the design review scheduled?", + "output_raw": "那第二项呢?设计审查安排在什么时候?", + "output_clean": "那第二项呢?设计审查安排在什么时候?", + "reference": "第二项呢?设计评审安排在什么时候?", + "ttft_s": 0.9138, + "total_s": 2.2272, + "tps": 8.37, + "prompt_tps": 263.11, + "eval_tokens": 11, + "prompt_tokens": 231, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F02", + "run": 2, + "input": "And what about the second item? When is the design review scheduled?", + "output_raw": "那第二项呢?设计评审安排在什么时候?", + "output_clean": "那第二项呢?设计评审安排在什么时候?", + "reference": "第二项呢?设计评审安排在什么时候?", + "ttft_s": 0.4145, + "total_s": 1.8524, + "tps": 7.66, + "prompt_tps": 609.44, + "eval_tokens": 11, + "prompt_tokens": 231, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F02", + "run": 3, + "input": "And what about the second item? When is the design review scheduled?", + "output_raw": "那第二项呢?设计评审安排在什么时候?", + "output_clean": "那第二项呢?设计评审安排在什么时候?", + "reference": "第二项呢?设计评审安排在什么时候?", + "ttft_s": 0.4068, + "total_s": 1.8046, + "tps": 7.87, + "prompt_tps": 639.19, + "eval_tokens": 11, + "prompt_tokens": 231, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "G01", + "run": 1, + "input": "这个方案的可行性还需要进一步评估。", + "output_raw": "The feasibility of this plan still requires further evaluation.", + "output_clean": "The feasibility of this plan still requires further evaluation.", + "reference": "The feasibility of this plan still needs further evaluation.", + "ttft_s": 1.5949, + "total_s": 2.4285, + "tps": 11.96, + "prompt_tps": 89.19, + "eval_tokens": 10, + "prompt_tokens": 139, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G01", + "run": 2, + "input": "这个方案的可行性还需要进一步评估。", + "output_raw": "The feasibility of this plan requires further assessment.", + "output_clean": "The feasibility of this plan requires further assessment.", + "reference": "The feasibility of this plan still needs further evaluation.", + "ttft_s": 0.3472, + "total_s": 1.472, + "tps": 8.0, + "prompt_tps": 445.2, + "eval_tokens": 9, + "prompt_tokens": 139, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G01", + "run": 3, + "input": "这个方案的可行性还需要进一步评估。", + "output_raw": "The feasibility of this plan still requires further evaluation.", + "output_clean": "The feasibility of this plan still requires further evaluation.", + "reference": "The feasibility of this plan still needs further evaluation.", + "ttft_s": 0.4041, + "total_s": 1.7754, + "tps": 7.29, + "prompt_tps": 392.6, + "eval_tokens": 10, + "prompt_tokens": 139, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G02", + "run": 1, + "input": "我们的日活用户已经突破了五百万。", + "output_raw": "Our daily active users have surpassed five million.", + "output_clean": "Our daily active users have surpassed five million.", + "reference": "Our daily active users have exceeded five million.", + "ttft_s": 0.9031, + "total_s": 1.9918, + "tps": 8.26, + "prompt_tps": 162.02, + "eval_tokens": 9, + "prompt_tokens": 141, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G02", + "run": 2, + "input": "我们的日活用户已经突破了五百万。", + "output_raw": "Our daily active users have exceeded five million.", + "output_clean": "Our daily active users have exceeded five million.", + "reference": "Our daily active users have exceeded five million.", + "ttft_s": 0.3925, + "total_s": 1.3888, + "tps": 9.03, + "prompt_tps": 408.95, + "eval_tokens": 9, + "prompt_tokens": 141, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G02", + "run": 3, + "input": "我们的日活用户已经突破了五百万。", + "output_raw": "Our daily active users have surpassed five million.", + "output_clean": "Our daily active users have surpassed five million.", + "reference": "Our daily active users have exceeded five million.", + "ttft_s": 0.4152, + "total_s": 1.4692, + "tps": 8.5, + "prompt_tps": 376.64, + "eval_tokens": 9, + "prompt_tokens": 141, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G03", + "run": 1, + "input": "请大家在周五之前提交各自负责模块的测试报告。", + "output_raw": "Please submit the test reports for your respective modules by Friday.", + "output_clean": "Please submit the test reports for your respective modules by Friday.", + "reference": "Please submit the test reports for your respective modules by Friday.", + "ttft_s": 0.8332, + "total_s": 2.3405, + "tps": 7.96, + "prompt_tps": 182.02, + "eval_tokens": 12, + "prompt_tokens": 144, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G03", + "run": 2, + "input": "请大家在周五之前提交各自负责模块的测试报告。", + "output_raw": "Please submit the test reports for your respective modules by Friday.", + "output_clean": "Please submit the test reports for your respective modules by Friday.", + "reference": "Please submit the test reports for your respective modules by Friday.", + "ttft_s": 0.3548, + "total_s": 1.9938, + "tps": 7.32, + "prompt_tps": 454.93, + "eval_tokens": 12, + "prompt_tokens": 144, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G03", + "run": 3, + "input": "请大家在周五之前提交各自负责模块的测试报告。", + "output_raw": "Please submit the test reports for your respective modules by Friday.", + "output_clean": "Please submit the test reports for your respective modules by Friday.", + "reference": "Please submit the test reports for your respective modules by Friday.", + "ttft_s": 0.2978, + "total_s": 2.0148, + "tps": 6.99, + "prompt_tps": 546.09, + "eval_tokens": 12, + "prompt_tokens": 144, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G04", + "run": 1, + "input": "下一步我们要重点关注用户体验的优化,尤其是首屏加载速度。", + "output_raw": "Next, we need to focus on optimizing the user experience, particularly the initial screen loading speed.", + "output_clean": "Next, we need to focus on optimizing the user experience, particularly the initial screen loading speed.", + "reference": "Next, we need to focus on optimizing user experience, especially the initial screen loading speed.", + "ttft_s": 1.0817, + "total_s": 3.8335, + "tps": 6.9, + "prompt_tps": 139.47, + "eval_tokens": 19, + "prompt_tokens": 146, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G04", + "run": 2, + "input": "下一步我们要重点关注用户体验的优化,尤其是首屏加载速度。", + "output_raw": "Our next step will be to focus on optimizing the user experience, particularly the initial screen loading speed.", + "output_clean": "Our next step will be to focus on optimizing the user experience, particularly the initial screen loading speed.", + "reference": "Next, we need to focus on optimizing user experience, especially the initial screen loading speed.", + "ttft_s": 0.5902, + "total_s": 3.4631, + "tps": 6.96, + "prompt_tps": 270.9, + "eval_tokens": 20, + "prompt_tokens": 146, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G04", + "run": 3, + "input": "下一步我们要重点关注用户体验的优化,尤其是首屏加载速度。", + "output_raw": "Our next priority will be to focus on optimizing user experience, particularly the initial screen loading speed.", + "output_clean": "Our next priority will be to focus on optimizing user experience, particularly the initial screen loading speed.", + "reference": "Next, we need to focus on optimizing user experience, especially the initial screen loading speed.", + "ttft_s": 0.6488, + "total_s": 3.0422, + "tps": 7.94, + "prompt_tps": 276.01, + "eval_tokens": 19, + "prompt_tokens": 146, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G05", + "run": 1, + "input": "这个bug的根本原因是内存泄漏导致的,已经在最新版本中修复了。", + "output_raw": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "output_clean": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "reference": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "ttft_s": 1.8734, + "total_s": 4.7681, + "tps": 6.85, + "prompt_tps": 82.5, + "eval_tokens": 20, + "prompt_tokens": 146, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G05", + "run": 2, + "input": "这个bug的根本原因是内存泄漏导致的,已经在最新版本中修复了。", + "output_raw": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "output_clean": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "reference": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "ttft_s": 0.582, + "total_s": 3.4635, + "tps": 6.93, + "prompt_tps": 296.99, + "eval_tokens": 20, + "prompt_tokens": 146, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G05", + "run": 3, + "input": "这个bug的根本原因是内存泄漏导致的,已经在最新版本中修复了。", + "output_raw": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "output_clean": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "reference": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "ttft_s": 0.4046, + "total_s": 3.1718, + "tps": 7.23, + "prompt_tps": 414.54, + "eval_tokens": 20, + "prompt_tokens": 146, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "H01", + "run": 1, + "input": "Thank you for joining today's meeting.", + "output_raw": "本日の会議にご参加いただき、ありがとうございます。", + "output_clean": "本日の会議にご参加いただき、ありがとうございます。", + "reference": "本日の会議にご参加いただきありがとうございます。", + "ttft_s": 1.6792, + "total_s": 2.6408, + "tps": 9.32, + "prompt_tps": 85.21, + "eval_tokens": 9, + "prompt_tokens": 139, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H01", + "run": 2, + "input": "Thank you for joining today's meeting.", + "output_raw": "本日は会議にご参加いただき、ありがとうございます。", + "output_clean": "本日は会議にご参加いただき、ありがとうございます。", + "reference": "本日の会議にご参加いただきありがとうございます。", + "ttft_s": 0.4206, + "total_s": 1.6087, + "tps": 7.57, + "prompt_tps": 373.57, + "eval_tokens": 9, + "prompt_tokens": 139, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H01", + "run": 3, + "input": "Thank you for joining today's meeting.", + "output_raw": "本日の会議にご参加いただき、ありがとうございます。", + "output_clean": "本日の会議にご参加いただき、ありがとうございます。", + "reference": "本日の会議にご参加いただきありがとうございます。", + "ttft_s": 0.4828, + "total_s": 1.5845, + "tps": 8.16, + "prompt_tps": 329.29, + "eval_tokens": 9, + "prompt_tokens": 139, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H02", + "run": 1, + "input": "The deadline for this project is the end of next month.", + "output_raw": "このプロジェクトの締め切りは来月末です。", + "output_clean": "このプロジェクトの締め切りは来月末です。", + "reference": "このプロジェクトの締め切りは来月末です。", + "ttft_s": 0.8401, + "total_s": 2.0902, + "tps": 8.0, + "prompt_tps": 179.42, + "eval_tokens": 10, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H02", + "run": 2, + "input": "The deadline for this project is the end of next month.", + "output_raw": "このプロジェクトの締め切りは来月末です。", + "output_clean": "このプロジェクトの締め切りは来月末です。", + "reference": "このプロジェクトの締め切りは来月末です。", + "ttft_s": 0.4411, + "total_s": 1.7489, + "tps": 7.64, + "prompt_tps": 351.62, + "eval_tokens": 10, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H02", + "run": 3, + "input": "The deadline for this project is the end of next month.", + "output_raw": "このプロジェクトの締め切りは来月末です。", + "output_clean": "このプロジェクトの締め切りは来月末です。", + "reference": "このプロジェクトの締め切りは来月末です。", + "ttft_s": 0.3856, + "total_s": 1.8047, + "tps": 7.05, + "prompt_tps": 404.54, + "eval_tokens": 10, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H03", + "run": 1, + "input": "We need to reconsider our marketing strategy for the Asian market.", + "output_raw": "アジア市場向けのマーケティング戦略を再考する必要があります。", + "output_clean": "アジア市場向けのマーケティング戦略を再考する必要があります。", + "reference": "アジア市場向けのマーケティング戦略を再検討する必要があります。", + "ttft_s": 0.9061, + "total_s": 2.3145, + "tps": 7.8, + "prompt_tps": 163.95, + "eval_tokens": 11, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H03", + "run": 2, + "input": "We need to reconsider our marketing strategy for the Asian market.", + "output_raw": "アジア市場向けのマーケティング戦略を再考する必要があります。", + "output_clean": "アジア市場向けのマーケティング戦略を再考する必要があります。", + "reference": "アジア市場向けのマーケティング戦略を再検討する必要があります。", + "ttft_s": 0.4491, + "total_s": 2.1124, + "tps": 6.61, + "prompt_tps": 348.07, + "eval_tokens": 11, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H03", + "run": 3, + "input": "We need to reconsider our marketing strategy for the Asian market.", + "output_raw": "アジア市場向けのマーケティング戦略を再考する必要があります。", + "output_clean": "アジア市場向けのマーケティング戦略を再考する必要があります。", + "reference": "アジア市場向けのマーケティング戦略を再検討する必要があります。", + "ttft_s": 0.3337, + "total_s": 1.7553, + "tps": 7.74, + "prompt_tps": 483.06, + "eval_tokens": 11, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "I01", + "run": 1, + "input": "Hello", + "output_raw": "你好", + "output_clean": "你好", + "reference": "你好", + "ttft_s": 1.6759, + "total_s": 1.7544, + "tps": 12.46, + "prompt_tps": 79.87, + "eval_tokens": 1, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I01", + "run": 2, + "input": "Hello", + "output_raw": "您好", + "output_clean": "您好", + "reference": "你好", + "ttft_s": 0.2439, + "total_s": 0.4313, + "tps": 10.69, + "prompt_tps": 634.02, + "eval_tokens": 2, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I01", + "run": 3, + "input": "Hello", + "output_raw": "您好", + "output_clean": "您好", + "reference": "你好", + "ttft_s": 0.347, + "total_s": 0.6089, + "tps": 7.59, + "prompt_tps": 423.06, + "eval_tokens": 2, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I02", + "run": 1, + "input": "Thank you", + "output_raw": "谢谢", + "output_clean": "谢谢", + "reference": "谢谢", + "ttft_s": 1.4337, + "total_s": 1.5345, + "tps": 9.89, + "prompt_tps": 94.99, + "eval_tokens": 1, + "prompt_tokens": 132, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I02", + "run": 2, + "input": "Thank you", + "output_raw": "谢谢", + "output_clean": "谢谢", + "reference": "谢谢", + "ttft_s": 0.3695, + "total_s": 0.5368, + "tps": 5.98, + "prompt_tps": 396.33, + "eval_tokens": 1, + "prompt_tokens": 132, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I02", + "run": 3, + "input": "Thank you", + "output_raw": "谢谢", + "output_clean": "谢谢", + "reference": "谢谢", + "ttft_s": 0.3446, + "total_s": 0.4942, + "tps": 6.66, + "prompt_tps": 426.81, + "eval_tokens": 1, + "prompt_tokens": 132, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I03", + "run": 1, + "input": "I don't understand", + "output_raw": "我不明白", + "output_clean": "我不明白", + "reference": "我不明白", + "ttft_s": 1.7359, + "total_s": 2.0374, + "tps": 6.56, + "prompt_tps": 79.62, + "eval_tokens": 2, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I03", + "run": 2, + "input": "I don't understand", + "output_raw": "我不明白", + "output_clean": "我不明白", + "reference": "我不明白", + "ttft_s": 0.4031, + "total_s": 0.6797, + "tps": 7.22, + "prompt_tps": 372.49, + "eval_tokens": 2, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I03", + "run": 3, + "input": "I don't understand", + "output_raw": "我不明白", + "output_clean": "我不明白", + "reference": "我不明白", + "ttft_s": 0.2695, + "total_s": 0.5479, + "tps": 7.18, + "prompt_tps": 574.38, + "eval_tokens": 2, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I04", + "run": 1, + "input": "Can you help me?", + "output_raw": "您能帮帮我吗?", + "output_clean": "您能帮帮我吗?", + "reference": "你能帮我吗?", + "ttft_s": 0.9249, + "total_s": 1.6803, + "tps": 9.26, + "prompt_tps": 151.56, + "eval_tokens": 7, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I04", + "run": 2, + "input": "Can you help me?", + "output_raw": "您能帮帮我吗?", + "output_clean": "您能帮帮我吗?", + "reference": "你能帮我吗?", + "ttft_s": 0.4443, + "total_s": 1.3998, + "tps": 7.32, + "prompt_tps": 333.21, + "eval_tokens": 7, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I04", + "run": 3, + "input": "Can you help me?", + "output_raw": "您能帮帮我吗?", + "output_clean": "您能帮帮我吗?", + "reference": "你能帮我吗?", + "ttft_s": 0.3557, + "total_s": 1.2694, + "tps": 7.66, + "prompt_tps": 422.97, + "eval_tokens": 7, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I05", + "run": 1, + "input": "What is machine learning?", + "output_raw": "什么是机器学习?", + "output_clean": "什么是机器学习?", + "reference": "什么是机器学习?", + "ttft_s": 0.8902, + "total_s": 1.1544, + "tps": 11.35, + "prompt_tps": 157.67, + "eval_tokens": 3, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I05", + "run": 2, + "input": "What is machine learning?", + "output_raw": "什么是机器学习?", + "output_clean": "什么是机器学习?", + "reference": "什么是机器学习?", + "ttft_s": 0.396, + "total_s": 0.8276, + "tps": 6.94, + "prompt_tps": 386.61, + "eval_tokens": 3, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I05", + "run": 3, + "input": "What is machine learning?", + "output_raw": "什么是机器学习?", + "output_clean": "什么是机器学习?", + "reference": "什么是机器学习?", + "ttft_s": 0.3677, + "total_s": 0.7915, + "tps": 7.07, + "prompt_tps": 407.97, + "eval_tokens": 3, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + } + ], + "qwen3.5:0.8b": [ + { + "case_id": "A01", + "run": 1, + "input": "Let's get started.", + "output_raw": "让我们开始吧。", + "output_clean": "让我们开始吧。", + "reference": "让我们开始吧。", + "ttft_s": 0.5707, + "total_s": 0.6204, + "tps": 100.36, + "prompt_tps": 450.31, + "eval_tokens": 5, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A01", + "run": 2, + "input": "Let's get started.", + "output_raw": "太好了,我们开始吧。", + "output_clean": "太好了,我们开始吧。", + "reference": "让我们开始吧。", + "ttft_s": 0.3059, + "total_s": 0.3773, + "tps": 96.22, + "prompt_tps": 6147.84, + "eval_tokens": 7, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A01", + "run": 3, + "input": "Let's get started.", + "output_raw": "让我们开始。", + "output_clean": "让我们开始。", + "reference": "让我们开始吧。", + "ttft_s": 0.3036, + "total_s": 0.3396, + "tps": 111.12, + "prompt_tps": 7193.42, + "eval_tokens": 4, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A02", + "run": 1, + "input": "Can you hear me?", + "output_raw": "您能听见吗?", + "output_clean": "您能听见吗?", + "reference": "你能听到我吗?", + "ttft_s": 0.368, + "total_s": 0.4304, + "tps": 96.12, + "prompt_tps": 1600.61, + "eval_tokens": 6, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A02", + "run": 2, + "input": "Can you hear me?", + "output_raw": "我能听到您的声音吗?", + "output_clean": "我能听到您的声音吗?", + "reference": "你能听到我吗?", + "ttft_s": 0.3056, + "total_s": 0.3833, + "tps": 90.12, + "prompt_tps": 7773.46, + "eval_tokens": 7, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A02", + "run": 3, + "input": "Can you hear me?", + "output_raw": "你能听见我说话吗?", + "output_clean": "你能听见我说话吗?", + "reference": "你能听到我吗?", + "ttft_s": 0.3276, + "total_s": 0.3993, + "tps": 97.56, + "prompt_tps": 3234.58, + "eval_tokens": 7, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A03", + "run": 1, + "input": "That's a great point.", + "output_raw": "那是一个很好的点。", + "output_clean": "那是一个很好的点。", + "reference": "说得很好。", + "ttft_s": 0.3709, + "total_s": 0.432, + "tps": 98.29, + "prompt_tps": 1473.66, + "eval_tokens": 6, + "prompt_tokens": 127, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A03", + "run": 2, + "input": "That's a great point.", + "output_raw": "这是一个非常好的观点。", + "output_clean": "这是一个非常好的观点。", + "reference": "说得很好。", + "ttft_s": 0.3057, + "total_s": 0.354, + "tps": 103.68, + "prompt_tps": 6736.69, + "eval_tokens": 5, + "prompt_tokens": 127, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A03", + "run": 3, + "input": "That's a great point.", + "output_raw": "这是一个很好的观点。", + "output_clean": "这是一个很好的观点。", + "reference": "说得很好。", + "ttft_s": 0.315, + "total_s": 0.3609, + "tps": 108.38, + "prompt_tps": 4672.38, + "eval_tokens": 5, + "prompt_tokens": 127, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A04", + "run": 1, + "input": "I'll share my screen.", + "output_raw": "我会分享我的屏幕。", + "output_clean": "我会分享我的屏幕。", + "reference": "我来共享一下屏幕。", + "ttft_s": 0.3735, + "total_s": 0.4355, + "tps": 96.47, + "prompt_tps": 1458.19, + "eval_tokens": 6, + "prompt_tokens": 127, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A04", + "run": 2, + "input": "I'll share my screen.", + "output_raw": "我会分享我的屏幕。", + "output_clean": "我会分享我的屏幕。", + "reference": "我来共享一下屏幕。", + "ttft_s": 0.3164, + "total_s": 0.3775, + "tps": 97.99, + "prompt_tps": 4197.24, + "eval_tokens": 6, + "prompt_tokens": 127, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A04", + "run": 3, + "input": "I'll share my screen.", + "output_raw": "我会分享我的屏幕。", + "output_clean": "我会分享我的屏幕。", + "reference": "我来共享一下屏幕。", + "ttft_s": 0.3177, + "total_s": 0.38, + "tps": 97.16, + "prompt_tps": 3845.22, + "eval_tokens": 6, + "prompt_tokens": 127, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A05", + "run": 1, + "input": "Any questions so far?", + "output_raw": "那么您现在有什么问题吗?", + "output_clean": "那么您现在有什么问题吗?", + "reference": "到目前为止有什么问题吗?", + "ttft_s": 0.3704, + "total_s": 0.4541, + "tps": 95.51, + "prompt_tps": 1486.95, + "eval_tokens": 8, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A05", + "run": 2, + "input": "Any questions so far?", + "output_raw": "目前有任何问题吗?", + "output_clean": "目前有任何问题吗?", + "reference": "到目前为止有什么问题吗?", + "ttft_s": 0.3058, + "total_s": 0.3834, + "tps": 77.4, + "prompt_tps": 6713.2, + "eval_tokens": 6, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A05", + "run": 3, + "input": "Any questions so far?", + "output_raw": "目前有相关问题吗?", + "output_clean": "目前有相关问题吗?", + "reference": "到目前为止有什么问题吗?", + "ttft_s": 0.3182, + "total_s": 0.373, + "tps": 91.1, + "prompt_tps": 3994.55, + "eval_tokens": 5, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "B01", + "run": 1, + "input": "I think we should postpone the launch to next quarter because the QA team hasn't finished testing yet.", + "output_raw": "我认为应推迟本次发布的启动,因为测试团队尚未完成工作。", + "output_clean": "我认为应推迟本次发布的启动,因为测试团队尚未完成工作。", + "reference": "我认为我们应该把发布推迟到下个季度,因为QA团队还没有完成测试。", + "ttft_s": 0.411, + "total_s": 0.6013, + "tps": 78.79, + "prompt_tps": 1179.84, + "eval_tokens": 15, + "prompt_tokens": 141, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B01", + "run": 2, + "input": "I think we should postpone the launch to next quarter because the QA team hasn't finished testing yet.", + "output_raw": "我认为我们应该推迟到下一个季度,因为测试团队目前仍未完成。", + "output_clean": "我认为我们应该推迟到下一个季度,因为测试团队目前仍未完成。", + "reference": "我认为我们应该把发布推迟到下个季度,因为QA团队还没有完成测试。", + "ttft_s": 0.3003, + "total_s": 0.4941, + "tps": 77.47, + "prompt_tps": 10212.21, + "eval_tokens": 15, + "prompt_tokens": 141, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B01", + "run": 3, + "input": "I think we should postpone the launch to next quarter because the QA team hasn't finished testing yet.", + "output_raw": "我认为推迟上线是在第三季度发布更合适,因为测试团队尚未完成工作。", + "output_clean": "我认为推迟上线是在第三季度发布更合适,因为测试团队尚未完成工作。", + "reference": "我认为我们应该把发布推迟到下个季度,因为QA团队还没有完成测试。", + "ttft_s": 0.3123, + "total_s": 0.5308, + "tps": 77.83, + "prompt_tps": 5457.5, + "eval_tokens": 17, + "prompt_tokens": 141, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B02", + "run": 1, + "input": "The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why.", + "output_raw": "用户留存率相比上个月下降了 15%。我们需要查明这一现象的原因,并且还需要继续讨论这个议题。", + "output_clean": "用户留存率相比上个月下降了 15%。我们需要查明这一现象的原因,并且还需要继续讨论这个议题。", + "reference": "用户留存率与上个月相比下降了百分之十五,我们需要找出原因。", + "ttft_s": 0.3905, + "total_s": 0.7181, + "tps": 76.35, + "prompt_tps": 1408.24, + "eval_tokens": 25, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B02", + "run": 2, + "input": "The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why.", + "output_raw": "用户留存率较上个月下降了 15%,我们需要分析原因。", + "output_clean": "用户留存率较上个月下降了 15%,我们需要分析原因。", + "reference": "用户留存率与上个月相比下降了百分之十五,我们需要找出原因。", + "ttft_s": 0.307, + "total_s": 0.4996, + "tps": 78.03, + "prompt_tps": 7852.25, + "eval_tokens": 15, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B02", + "run": 3, + "input": "The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why.", + "output_raw": "用户留存率较上月下降了 15%,我们需要查明原因。", + "output_clean": "用户留存率较上月下降了 15%,我们需要查明原因。", + "reference": "用户留存率与上个月相比下降了百分之十五,我们需要找出原因。", + "ttft_s": 0.3089, + "total_s": 0.4726, + "tps": 91.23, + "prompt_tps": 5988.28, + "eval_tokens": 15, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B03", + "run": 1, + "input": "We're planning to migrate our infrastructure from AWS to Google Cloud by the end of this year.", + "output_raw": "我们计划在年底前将我们的基础设施迁移至谷歌云。", + "output_clean": "我们计划在年底前将我们的基础设施迁移至谷歌云。", + "reference": "我们计划在今年年底前将基础设施从AWS迁移到Google Cloud。", + "ttft_s": 0.5619, + "total_s": 0.6937, + "tps": 90.99, + "prompt_tps": 504.57, + "eval_tokens": 12, + "prompt_tokens": 140, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B03", + "run": 2, + "input": "We're planning to migrate our infrastructure from AWS to Google Cloud by the end of this year.", + "output_raw": "我们正在计划于今年年底将基础设施从亚马逊云服务(AWS)迁移到谷歌云计算。", + "output_clean": "我们正在计划于今年年底将基础设施从亚马逊云服务(AWS)迁移到谷歌云计算。", + "reference": "我们计划在今年年底前将基础设施从AWS迁移到Google Cloud。", + "ttft_s": 0.3066, + "total_s": 0.5361, + "tps": 87.15, + "prompt_tps": 6602.84, + "eval_tokens": 20, + "prompt_tokens": 140, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B03", + "run": 3, + "input": "We're planning to migrate our infrastructure from AWS to Google Cloud by the end of this year.", + "output_raw": "我们正在于年内完成从AWS迁移至Google Cloud基础设施的规划。", + "output_clean": "我们正在于年内完成从AWS迁移至Google Cloud基础设施的规划。", + "reference": "我们计划在今年年底前将基础设施从AWS迁移到Google Cloud。", + "ttft_s": 0.3199, + "total_s": 0.4944, + "tps": 86.0, + "prompt_tps": 3992.24, + "eval_tokens": 15, + "prompt_tokens": 140, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B04", + "run": 1, + "input": "The new privacy policy requires us to obtain explicit user consent before collecting any personal data.", + "output_raw": "新的隐私政策要求我们在收集任何个人数据之前必须获得用户的明确同意。", + "output_clean": "新的隐私政策要求我们在收集任何个人数据之前必须获得用户的明确同意。", + "reference": "新的隐私政策要求我们在收集任何个人数据之前获得用户的明确同意。", + "ttft_s": 0.3837, + "total_s": 0.6021, + "tps": 77.79, + "prompt_tps": 1416.08, + "eval_tokens": 17, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B04", + "run": 2, + "input": "The new privacy policy requires us to obtain explicit user consent before collecting any personal data.", + "output_raw": "新的隐私政策要求在获取任何个人数据前取得用户明确同意。", + "output_clean": "新的隐私政策要求在获取任何个人数据前取得用户明确同意。", + "reference": "新的隐私政策要求我们在收集任何个人数据之前获得用户的明确同意。", + "ttft_s": 0.3051, + "total_s": 0.4994, + "tps": 77.23, + "prompt_tps": 7612.95, + "eval_tokens": 15, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B04", + "run": 3, + "input": "The new privacy policy requires us to obtain explicit user consent before collecting any personal data.", + "output_raw": "新隐私政策要求我们在获取任何个人数据之前必须获得明确用户的同意。", + "output_clean": "新隐私政策要求我们在获取任何个人数据之前必须获得明确用户的同意。", + "reference": "新的隐私政策要求我们在收集任何个人数据之前获得用户的明确同意。", + "ttft_s": 0.3228, + "total_s": 0.5463, + "tps": 75.98, + "prompt_tps": 3766.38, + "eval_tokens": 17, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B05", + "run": 1, + "input": "Could you walk us through the architecture diagram? I want to make sure everyone is on the same page.", + "output_raw": "请帮我解释一下架构图。我想确保大家理解一致起来。", + "output_clean": "请帮我解释一下架构图。我想确保大家理解一致起来。", + "reference": "你能给我们讲一下架构图吗?我想确保大家的理解是一致的。", + "ttft_s": 0.3923, + "total_s": 0.5849, + "tps": 77.85, + "prompt_tps": 1408.37, + "eval_tokens": 15, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B05", + "run": 2, + "input": "Could you walk us through the architecture diagram? I want to make sure everyone is on the same page.", + "output_raw": "能否请您详细介绍一下架构图?我希望大家能够站在同一个频道上。", + "output_clean": "能否请您详细介绍一下架构图?我希望大家能够站在同一个频道上。", + "reference": "你能给我们讲一下架构图吗?我想确保大家的理解是一致的。", + "ttft_s": 0.3049, + "total_s": 0.5083, + "tps": 78.75, + "prompt_tps": 8093.01, + "eval_tokens": 16, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B05", + "run": 3, + "input": "Could you walk us through the architecture diagram? I want to make sure everyone is on the same page.", + "output_raw": "当然可以,请您带领我看看架构图。我想让大家都保持一致的理解。", + "output_clean": "当然可以,请您带领我看看架构图。我想让大家都保持一致的理解。", + "reference": "你能给我们讲一下架构图吗?我想确保大家的理解是一致的。", + "ttft_s": 0.3136, + "total_s": 0.5481, + "tps": 76.7, + "prompt_tps": 6348.35, + "eval_tokens": 18, + "prompt_tokens": 142, + "instruction_compliant": false, + "violation_detail": "包含违规前缀: '当然...'", + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "C01", + "run": 1, + "input": "So let me summarize today's discussion. We agreed to move forward with option B, which involves rebuilding the payment module from scratch. The timeline is eight weeks, with the first milestone due in three weeks. John will lead the backend team, and Sarah will handle the frontend integration. We also need to coordinate with the compliance team to ensure we meet all regulatory requirements before going live.", + "output_raw": "我们将汇总今天的讨论。我们已经同意推进选项 B,即从头重新构建支付模块。时间表为八周,第一个里程碑在第三周到内举行。约翰将领导后端团队,萨拉负责前端集成。此外,我们还需与合规部门协调以确保持续符合所有监管要求后才能上线。", + "output_clean": "我们将汇总今天的讨论。我们已经同意推进选项 B,即从头重新构建支付模块。时间表为八周,第一个里程碑在第三周到内举行。约翰将领导后端团队,萨拉负责前端集成。此外,我们还需与合规部门协调以确保持续符合所有监管要求后才能上线。", + "reference": "让我来总结一下今天的讨论。我们同意推进方案B,即从零开始重建支付模块。时间线是八周,第一个里程碑在三周内到期。John将带领后端团队,Sarah负责前端集成。我们还需要与合规团队协调,确保上线前满足所有监管要求。", + "ttft_s": 0.4113, + "total_s": 1.2451, + "tps": 74.36, + "prompt_tps": 1599.14, + "eval_tokens": 62, + "prompt_tokens": 197, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C01", + "run": 2, + "input": "So let me summarize today's discussion. We agreed to move forward with option B, which involves rebuilding the payment module from scratch. The timeline is eight weeks, with the first milestone due in three weeks. John will lead the backend team, and Sarah will handle the frontend integration. We also need to coordinate with the compliance team to ensure we meet all regulatory requirements before going live.", + "output_raw": "好的,今天我将简要总结我们的讨论。我们同意继续推进选项 B,该方案涉及从零重构支付模块。时间窗口为八周,其中第一个里程碑定于三周内完成。约翰将担任后端团队成员负责人,萨拉将负责前端集成工作。此外,我们必须与合规团队协调,确保在全面上线前满足所有监管要求。“", + "output_clean": "好的,今天我将简要总结我们的讨论。我们同意继续推进选项 B,该方案涉及从零重构支付模块。时间窗口为八周,其中第一个里程碑定于三周内完成。约翰将担任后端团队成员负责人,萨拉将负责前端集成工作。此外,我们必须与合规团队协调,确保在全面上线前满足所有监管要求。“", + "reference": "让我来总结一下今天的讨论。我们同意推进方案B,即从零开始重建支付模块。时间线是八周,第一个里程碑在三周内到期。John将带领后端团队,Sarah负责前端集成。我们还需要与合规团队协调,确保上线前满足所有监管要求。", + "ttft_s": 0.3052, + "total_s": 1.1777, + "tps": 81.37, + "prompt_tps": 11274.54, + "eval_tokens": 71, + "prompt_tokens": 197, + "instruction_compliant": false, + "violation_detail": "包含违规前缀: '好的...'", + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C01", + "run": 3, + "input": "So let me summarize today's discussion. We agreed to move forward with option B, which involves rebuilding the payment module from scratch. The timeline is eight weeks, with the first milestone due in three weeks. John will lead the backend team, and Sarah will handle the frontend integration. We also need to coordinate with the compliance team to ensure we meet all regulatory requirements before going live.", + "output_raw": "因此,让我总结一下今天的讨论。我们已经同意进入选项 B 阶段,该计划包括从零开始重建支付模块。预计为期八周,其中第一项里程碑在三个月前即可完成。约翰将负责后端团队,而萨拉则专注于前端集成工作。此外,我们还需与合规部门协调各方以确保所有监管要求均能满足并正式发布。", + "output_clean": "因此,让我总结一下今天的讨论。我们已经同意进入选项 B 阶段,该计划包括从零开始重建支付模块。预计为期八周,其中第一项里程碑在三个月前即可完成。约翰将负责后端团队,而萨拉则专注于前端集成工作。此外,我们还需与合规部门协调各方以确保所有监管要求均能满足并正式发布。", + "reference": "让我来总结一下今天的讨论。我们同意推进方案B,即从零开始重建支付模块。时间线是八周,第一个里程碑在三周内到期。John将带领后端团队,Sarah负责前端集成。我们还需要与合规团队协调,确保上线前满足所有监管要求。", + "ttft_s": 0.3027, + "total_s": 1.1421, + "tps": 85.76, + "prompt_tps": 13405.01, + "eval_tokens": 72, + "prompt_tokens": 197, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C02", + "run": 1, + "input": "Artificial intelligence is transforming the healthcare industry in ways we never imagined. From early disease detection using medical imaging to personalized treatment plans based on genetic data, AI is helping doctors make better decisions faster. However, we must also address the ethical concerns around patient privacy, algorithmic bias, and the potential displacement of healthcare workers.", + "output_raw": "人工智能正在以我们未曾设想的方式 transforming 医疗保健行业。它从基于医学影像的早期疾病诊断发展到利用基因组数据的个性化治疗方案,帮助医生更快做出最佳决定。然而,我们必须正视关于患者隐私的保护、算法偏差以及可能影响医疗服务人员的伦理问题。🔤", + "output_clean": "人工智能正在以我们未曾设想的方式 transforming 医疗保健行业。它从基于医学影像的早期疾病诊断发展到利用基因组数据的个性化治疗方案,帮助医生更快做出最佳决定。然而,我们必须正视关于患者隐私的保护、算法偏差以及可能影响医疗服务人员的伦理问题。", + "reference": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据的个性化治疗方案,AI正在帮助医生更快地做出更好的决策。然而,我们也必须解决围绕患者隐私、算法偏见以及医疗工作者可能被取代等伦理问题。", + "ttft_s": 0.3939, + "total_s": 1.0669, + "tps": 86.16, + "prompt_tps": 1774.85, + "eval_tokens": 58, + "prompt_tokens": 184, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C02", + "run": 2, + "input": "Artificial intelligence is transforming the healthcare industry in ways we never imagined. From early disease detection using medical imaging to personalized treatment plans based on genetic data, AI is helping doctors make better decisions faster. However, we must also address the ethical concerns around patient privacy, algorithmic bias, and the potential displacement of healthcare workers.", + "output_raw": "人工智能正在将医疗保健行业推向前所未预见的方向。它通过结合医学影像诊断早期疾病,以及基于基因组学制定个性化治疗方案来实现这一目标。然而,我们必须正视患者隐私、算法偏见以及对医疗专业人员潜在排挤的伦理问题。🔤", + "output_clean": "人工智能正在将医疗保健行业推向前所未预见的方向。它通过结合医学影像诊断早期疾病,以及基于基因组学制定个性化治疗方案来实现这一目标。然而,我们必须正视患者隐私、算法偏见以及对医疗专业人员潜在排挤的伦理问题。", + "reference": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据的个性化治疗方案,AI正在帮助医生更快地做出更好的决策。然而,我们也必须解决围绕患者隐私、算法偏见以及医疗工作者可能被取代等伦理问题。", + "ttft_s": 0.3091, + "total_s": 0.9518, + "tps": 85.58, + "prompt_tps": 9330.16, + "eval_tokens": 55, + "prompt_tokens": 184, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C02", + "run": 3, + "input": "Artificial intelligence is transforming the healthcare industry in ways we never imagined. From early disease detection using medical imaging to personalized treatment plans based on genetic data, AI is helping doctors make better decisions faster. However, we must also address the ethical concerns around patient privacy, algorithmic bias, and the potential displacement of healthcare workers.", + "output_raw": "人工智能正在以我们未曾设想的方式变革医疗保健行业。从利用医学影像进行早期疾病检测,到基于基因组数据的个性化治疗方案推荐,AI 帮助医生更快做出更好的决策。然而,我们必须直面伦理问题:患者隐私保护、算法偏见以及可能影响医疗从业者就业的风险。", + "output_clean": "人工智能正在以我们未曾设想的方式变革医疗保健行业。从利用医学影像进行早期疾病检测,到基于基因组数据的个性化治疗方案推荐,AI 帮助医生更快做出更好的决策。然而,我们必须直面伦理问题:患者隐私保护、算法偏见以及可能影响医疗从业者就业的风险。", + "reference": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据的个性化治疗方案,AI正在帮助医生更快地做出更好的决策。然而,我们也必须解决围绕患者隐私、算法偏见以及医疗工作者可能被取代等伦理问题。", + "ttft_s": 0.3034, + "total_s": 0.9943, + "tps": 85.29, + "prompt_tps": 11831.28, + "eval_tokens": 59, + "prompt_tokens": 184, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "D01", + "run": 1, + "input": "So basically what I'm trying to say is", + "output_raw": "实际上,我想说的是……。 (tíng) 所以基本是想说什么了?", + "output_clean": "实际上,我想说的是……。 (tíng) 所以基本是想说什么了?", + "reference": "所以基本上我想说的是", + "ttft_s": 0.3757, + "total_s": 0.5906, + "tps": 88.4, + "prompt_tps": 1536.79, + "eval_tokens": 19, + "prompt_tokens": 130, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D01", + "run": 2, + "input": "So basically what I'm trying to say is", + "output_raw": "基本上我想说的是……", + "output_clean": "基本上我想说的是……", + "reference": "所以基本上我想说的是", + "ttft_s": 0.3179, + "total_s": 0.3696, + "tps": 96.1, + "prompt_tps": 4251.28, + "eval_tokens": 5, + "prompt_tokens": 130, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D01", + "run": 3, + "input": "So basically what I'm trying to say is", + "output_raw": "基本上,我正在表达的意思是什么呢。", + "output_clean": "基本上,我正在表达的意思是什么呢。", + "reference": "所以基本上我想说的是", + "ttft_s": 0.315, + "total_s": 0.4218, + "tps": 93.49, + "prompt_tps": 4376.96, + "eval_tokens": 10, + "prompt_tokens": 130, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D02", + "run": 1, + "input": "The reason we can't do that is because the", + "output_raw": "原因是我们无法实施这一措施,是因为“因为”", + "output_clean": "原因是我们无法实施这一措施,是因为“因为”", + "reference": "我们不能那样做的原因是因为", + "ttft_s": 0.3788, + "total_s": 0.5116, + "tps": 90.31, + "prompt_tps": 1397.23, + "eval_tokens": 12, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D02", + "run": 2, + "input": "The reason we can't do that is because the", + "output_raw": "无法实现该计划的原因在于……", + "output_clean": "无法实现该计划的原因在于……", + "reference": "我们不能那样做的原因是因为", + "ttft_s": 0.3171, + "total_s": 0.4047, + "tps": 91.38, + "prompt_tps": 4396.12, + "eval_tokens": 8, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D02", + "run": 3, + "input": "The reason we can't do that is because the", + "output_raw": "原因是我们无法做到的是,因为……", + "output_clean": "原因是我们无法做到的是,因为……", + "reference": "我们不能那样做的原因是因为", + "ttft_s": 0.3068, + "total_s": 0.4075, + "tps": 89.12, + "prompt_tps": 6259.86, + "eval_tokens": 9, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D03", + "run": 1, + "input": "If we look at the data from last", + "output_raw": "如果您能查看最新的统计数据。", + "output_clean": "如果您能查看最新的统计数据。", + "reference": "如果我们看一下上", + "ttft_s": 0.3723, + "total_s": 0.4441, + "tps": 97.35, + "prompt_tps": 1496.94, + "eval_tokens": 7, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D03", + "run": 2, + "input": "If we look at the data from last", + "output_raw": "如果我们查看最近的统计数据,那么... 您提到的数据来自...\"最后一个\"时刻。", + "output_clean": "如果我们查看最近的统计数据,那么... 您提到的数据来自...\"最后一个\"时刻。", + "reference": "如果我们看一下上", + "ttft_s": 0.3021, + "total_s": 0.5092, + "tps": 86.99, + "prompt_tps": 7765.0, + "eval_tokens": 18, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D03", + "run": 3, + "input": "If we look at the data from last", + "output_raw": "如果您想看去年的数据。", + "output_clean": "如果您想看去年的数据。", + "reference": "如果我们看一下上", + "ttft_s": 0.323, + "total_s": 0.39, + "tps": 89.49, + "prompt_tps": 3515.18, + "eval_tokens": 6, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D04", + "run": 1, + "input": "I mean it's not that we don't want to but", + "output_raw": "我想说的是,这并不是我们不愿的原因吧。", + "output_clean": "我想说的是,这并不是我们不愿的原因吧。", + "reference": "我的意思是,不是我们不想,但是", + "ttft_s": 0.3928, + "total_s": 0.5124, + "tps": 83.81, + "prompt_tps": 1243.87, + "eval_tokens": 10, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D04", + "run": 2, + "input": "I mean it's not that we don't want to but", + "output_raw": "实际上,我们并不是不想。", + "output_clean": "实际上,我们并不是不想。", + "reference": "我的意思是,不是我们不想,但是", + "ttft_s": 0.3055, + "total_s": 0.3918, + "tps": 81.22, + "prompt_tps": 7184.53, + "eval_tokens": 7, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D04", + "run": 3, + "input": "I mean it's not that we don't want to but", + "output_raw": "我意思是说这并不是我们的意愿,而是……", + "output_clean": "我意思是说这并不是我们的意愿,而是……", + "reference": "我的意思是,不是我们不想,但是", + "ttft_s": 0.3223, + "total_s": 0.443, + "tps": 82.9, + "prompt_tps": 3774.87, + "eval_tokens": 10, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D05", + "run": 1, + "input": "Well the thing is um we actually", + "output_raw": "好的,不过实际上是这样嘛。", + "output_clean": "好的,不过实际上是这样嘛。", + "reference": "嗯,事情是这样的,实际上我们", + "ttft_s": 0.3702, + "total_s": 0.4522, + "tps": 97.41, + "prompt_tps": 1529.67, + "eval_tokens": 8, + "prompt_tokens": 128, + "instruction_compliant": false, + "violation_detail": "包含违规前缀: '好的...'", + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D05", + "run": 2, + "input": "Well the thing is um we actually", + "output_raw": "当然,情况是这样的:实际上是这样……", + "output_clean": "当然,情况是这样的:实际上是这样……", + "reference": "嗯,事情是这样的,实际上我们", + "ttft_s": 0.3019, + "total_s": 0.4003, + "tps": 91.44, + "prompt_tps": 8453.87, + "eval_tokens": 9, + "prompt_tokens": 128, + "instruction_compliant": false, + "violation_detail": "包含违规前缀: '当然...'", + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D05", + "run": 3, + "input": "Well the thing is um we actually", + "output_raw": "实际上,好吧。不过呢?", + "output_clean": "实际上,好吧。不过呢?", + "reference": "嗯,事情是这样的,实际上我们", + "ttft_s": 0.3208, + "total_s": 0.4029, + "tps": 97.08, + "prompt_tps": 3634.09, + "eval_tokens": 8, + "prompt_tokens": 128, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "E01", + "run": 1, + "input": "We deployed the new Kubernetes cluster on AWS EKS last Friday.", + "output_raw": "我們於星期五將新構建的Kubernetes集群部署在AWS提供的EKS上。", + "output_clean": "我們於星期五將新構建的Kubernetes集群部署在AWS提供的EKS上。", + "reference": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "ttft_s": 0.3804, + "total_s": 0.6285, + "tps": 76.51, + "prompt_tps": 1457.3, + "eval_tokens": 19, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 1, + "term_total": 2, + "missing_terms": [ + "AWS EKS" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E01", + "run": 2, + "input": "We deployed the new Kubernetes cluster on AWS EKS last Friday.", + "output_raw": "我们在上周五将新版本的 K8s 集群部署在 Azure Active Directory (E)K,请注意。我确认了这些是最新的版本吗?", + "output_clean": "我们在上周五将新版本的 K8s 集群部署在 Azure Active Directory (E)K,请注意。我确认了这些是最新的版本吗?", + "reference": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "ttft_s": 0.301, + "total_s": 0.733, + "tps": 76.39, + "prompt_tps": 8678.76, + "eval_tokens": 33, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 2, + "missing_terms": [ + "Kubernetes", + "AWS EKS" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E01", + "run": 3, + "input": "We deployed the new Kubernetes cluster on AWS EKS last Friday.", + "output_raw": "我们上周五部署了新的Kubernetes集群至AWS EC2。", + "output_clean": "我们上周五部署了新的Kubernetes集群至AWS EC2。", + "reference": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "ttft_s": 0.3007, + "total_s": 0.4775, + "tps": 79.21, + "prompt_tps": 8837.3, + "eval_tokens": 14, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 1, + "term_total": 2, + "missing_terms": [ + "AWS EKS" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E02", + "run": 1, + "input": "The React Native app is crashing on iOS seventeen because of a WebSocket timeout issue.", + "output_raw": "应用在 17+ ios上出现崩溃,原因是 WebSocket 超时问题。 🔥", + "output_clean": "应用在 17+ ios上出现崩溃,原因是 WebSocket 超时问题。 🔥", + "reference": "React Native应用在iOS 17上因为WebSocket超时问题而崩溃。", + "ttft_s": 0.3979, + "total_s": 0.6388, + "tps": 83.0, + "prompt_tps": 1241.56, + "eval_tokens": 20, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 3, + "missing_terms": [ + "React Native" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E02", + "run": 2, + "input": "The React Native app is crashing on iOS seventeen because of a WebSocket timeout issue.", + "output_raw": "该基于 React Native 的应用在iOS 17上因WebSocket超时问题而崩溃。", + "output_clean": "该基于 React Native 的应用在iOS 17上因WebSocket超时问题而崩溃。", + "reference": "React Native应用在iOS 17上因为WebSocket超时问题而崩溃。", + "ttft_s": 0.3019, + "total_s": 0.529, + "tps": 88.04, + "prompt_tps": 8872.48, + "eval_tokens": 20, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E02", + "run": 3, + "input": "The React Native app is crashing on iOS seventeen because of a WebSocket timeout issue.", + "output_raw": "React Native 应用程序在 iPhone 17 版(iOS Sierra)上崩溃,原因是一个WebSocket超时问题。", + "output_clean": "React Native 应用程序在 iPhone 17 版(iOS Sierra)上崩溃,原因是一个WebSocket超时问题。", + "reference": "React Native应用在iOS 17上因为WebSocket超时问题而崩溃。", + "ttft_s": 0.3228, + "total_s": 0.6121, + "tps": 86.28, + "prompt_tps": 3718.99, + "eval_tokens": 25, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E03", + "run": 1, + "input": "Apple announced the new M5 chip with improved Neural Engine performance at WWDC.", + "output_raw": "苹果在WWDC会议上宣布了新的M5处理器,以提升神经网络引擎的性能。", + "output_clean": "苹果在WWDC会议上宣布了新的M5处理器,以提升神经网络引擎的性能。", + "reference": "Apple在WWDC上发布了新的M5芯片,改进了Neural Engine性能。", + "ttft_s": 0.3726, + "total_s": 0.5639, + "tps": 89.01, + "prompt_tps": 1575.13, + "eval_tokens": 17, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 4, + "missing_terms": [ + "Apple", + "Neural Engine" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E03", + "run": 2, + "input": "Apple announced the new M5 chip with improved Neural Engine performance at WWDC.", + "output_raw": "Apple 宣布新 M5 芯片在 WWDC 上具有更好的神经引擎性能。", + "output_clean": "Apple 宣布新 M5 芯片在 WWDC 上具有更好的神经引擎性能。", + "reference": "Apple在WWDC上发布了新的M5芯片,改进了Neural Engine性能。", + "ttft_s": 0.3012, + "total_s": 0.527, + "tps": 88.58, + "prompt_tps": 8481.92, + "eval_tokens": 20, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 4, + "missing_terms": [ + "Neural Engine" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E03", + "run": 3, + "input": "Apple announced the new M5 chip with improved Neural Engine performance at WWDC.", + "output_raw": "苹果在WWDC发布了新的M5芯片,该设计增强了Neural引擎的性能。", + "output_clean": "苹果在WWDC发布了新的M5芯片,该设计增强了Neural引擎的性能。", + "reference": "Apple在WWDC上发布了新的M5芯片,改进了Neural Engine性能。", + "ttft_s": 0.314, + "total_s": 0.5287, + "tps": 88.51, + "prompt_tps": 4815.3, + "eval_tokens": 19, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 4, + "missing_terms": [ + "Apple", + "Neural Engine" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E04", + "run": 1, + "input": "We need to implement OAuth two point zero authentication before integrating with the Stripe payment API.", + "output_raw": "我们需要在集成到 Stripe 支付API之前实施OAuth两个令牌认证。请翻译以下文本:\nWe must implement OAuth Two-Factor Authentication (2FA) authorization code exchange prior to connecting our application to the Stripe Payment Service. 🔍 Please check for any technical implementation details or security requirements specific to this integration process, and if you encounter issues with payment gateway protocols before implementing two-factor authentication steps", + "output_clean": "我们需要在集成到 Stripe 支付API之前实施OAuth两个令牌认证。请翻译以下文本:\nWe must implement OAuth Two-Factor Authentication (2FA) authorization code exchange prior to connecting our application to the Stripe Payment Service. 🔍 Please check for any technical implementation details or security requirements specific to this integration process, and if you encounter issues with payment gateway protocols before implementing two-factor authentication steps", + "reference": "在集成Stripe支付API之前,我们需要实现OAuth 2.0认证。", + "ttft_s": 0.3879, + "total_s": 1.469, + "tps": 74.92, + "prompt_tps": 1361.59, + "eval_tokens": 81, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E04", + "run": 2, + "input": "We need to implement OAuth two point zero authentication before integrating with the Stripe payment API.", + "output_raw": "我们需要在集成到 Stripe 支付API之前实施OAuth v2认证。", + "output_clean": "我们需要在集成到 Stripe 支付API之前实施OAuth v2认证。", + "reference": "在集成Stripe支付API之前,我们需要实现OAuth 2.0认证。", + "ttft_s": 0.3028, + "total_s": 0.5113, + "tps": 76.75, + "prompt_tps": 8022.32, + "eval_tokens": 16, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E04", + "run": 3, + "input": "We need to implement OAuth two point zero authentication before integrating with the Stripe payment API.", + "output_raw": "我们需要在将 Paytm 支付集成前实现 OAuth v2 认证。", + "output_clean": "我们需要在将 Paytm 支付集成前实现 OAuth v2 认证。", + "reference": "在集成Stripe支付API之前,我们需要实现OAuth 2.0认证。", + "ttft_s": 0.3189, + "total_s": 0.5372, + "tps": 77.81, + "prompt_tps": 4710.06, + "eval_tokens": 17, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 1, + "term_total": 3, + "missing_terms": [ + "Stripe", + "API" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E05", + "run": 1, + "input": "The transformer model uses multi-head attention and layer normalization to process the input embeddings.", + "output_raw": "Transformer 模型使用多头注意力机制和层归一化来处理嵌入表示。", + "output_clean": "Transformer 模型使用多头注意力机制和层归一化来处理嵌入表示。", + "reference": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。", + "ttft_s": 0.3965, + "total_s": 0.6301, + "tps": 77.07, + "prompt_tps": 1306.07, + "eval_tokens": 18, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 1, + "term_total": 2, + "missing_terms": [ + "multi-head attention" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E05", + "run": 2, + "input": "The transformer model uses multi-head attention and layer normalization to process the input embeddings.", + "output_raw": "转储模型通过使用多头注意力机制和层归一化处理来处理输入嵌入。", + "output_clean": "转储模型通过使用多头注意力机制和层归一化处理来处理输入嵌入。", + "reference": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。", + "ttft_s": 0.3079, + "total_s": 0.5422, + "tps": 76.68, + "prompt_tps": 8097.41, + "eval_tokens": 18, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 2, + "missing_terms": [ + "transformer", + "multi-head attention" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E05", + "run": 3, + "input": "The transformer model uses multi-head attention and layer normalization to process the input embeddings.", + "output_raw": "该转换器模型通过将嵌入数据输入到多头注意力机制和层归一化中,进行处理。", + "output_clean": "该转换器模型通过将嵌入数据输入到多头注意力机制和层归一化中,进行处理。", + "reference": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。", + "ttft_s": 0.3196, + "total_s": 0.6041, + "tps": 77.31, + "prompt_tps": 4564.69, + "eval_tokens": 22, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 2, + "missing_terms": [ + "transformer", + "multi-head attention" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "F01", + "run": 1, + "input": "Let's start with the first one. How is it going so far?", + "output_raw": "我们正在开始讨论第一个重点问题,进度如何?", + "output_clean": "我们正在开始讨论第一个重点问题,进度如何?", + "reference": "让我们从第一个开始。目前进展如何?", + "ttft_s": 0.4086, + "total_s": 0.5431, + "tps": 81.75, + "prompt_tps": 1741.05, + "eval_tokens": 11, + "prompt_tokens": 211, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F01", + "run": 2, + "input": "Let's start with the first one. How is it going so far?", + "output_raw": "我们开始讨论第一项,进度如何?", + "output_clean": "我们开始讨论第一项,进度如何?", + "reference": "让我们从第一个开始。目前进展如何?", + "ttft_s": 0.3062, + "total_s": 0.4155, + "tps": 82.17, + "prompt_tps": 12318.29, + "eval_tokens": 9, + "prompt_tokens": 211, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F01", + "run": 3, + "input": "Let's start with the first one. How is it going so far?", + "output_raw": "我们将开始讨论第一个方面,它进展如何?", + "output_clean": "我们将开始讨论第一个方面,它进展如何?", + "reference": "让我们从第一个开始。目前进展如何?", + "ttft_s": 0.3103, + "total_s": 0.4487, + "tps": 79.51, + "prompt_tps": 9462.73, + "eval_tokens": 11, + "prompt_tokens": 211, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F02", + "run": 1, + "input": "And what about the second item? When is the design review scheduled?", + "output_raw": "另外一项是什么呢?设计评审是在什么时候安排的吗?", + "output_clean": "另外一项是什么呢?设计评审是在什么时候安排的吗?", + "reference": "第二项呢?设计评审安排在什么时候?", + "ttft_s": 0.418, + "total_s": 0.5826, + "tps": 78.98, + "prompt_tps": 1665.44, + "eval_tokens": 13, + "prompt_tokens": 210, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F02", + "run": 2, + "input": "And what about the second item? When is the design review scheduled?", + "output_raw": "另外关于第二个项目,设计评审的具体时间是什么时候?", + "output_clean": "另外关于第二个项目,设计评审的具体时间是什么时候?", + "reference": "第二项呢?设计评审安排在什么时候?", + "ttft_s": 0.3022, + "total_s": 0.4533, + "tps": 79.43, + "prompt_tps": 15745.67, + "eval_tokens": 12, + "prompt_tokens": 210, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F02", + "run": 3, + "input": "And what about the second item? When is the design review scheduled?", + "output_raw": "还有第二项呢?设计审查的日期是什么时候?", + "output_clean": "还有第二项呢?设计审查的日期是什么时候?", + "reference": "第二项呢?设计评审安排在什么时候?", + "ttft_s": 0.3501, + "total_s": 0.4683, + "tps": 101.61, + "prompt_tps": 7875.49, + "eval_tokens": 12, + "prompt_tokens": 210, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "G01", + "run": 1, + "input": "这个方案的可行性还需要进一步评估。", + "output_raw": "This approach is still to be evaluated further.", + "output_clean": "This approach is still to be evaluated further.", + "reference": "The feasibility of this plan still needs further evaluation.", + "ttft_s": 0.3686, + "total_s": 0.4666, + "tps": 102.3, + "prompt_tps": 1655.33, + "eval_tokens": 10, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G01", + "run": 2, + "input": "这个方案的可行性还需要进一步评估。", + "output_raw": "This plan's feasibility will also require further assessment.", + "output_clean": "This plan's feasibility will also require further assessment.", + "reference": "The feasibility of this plan still needs further evaluation.", + "ttft_s": 0.3104, + "total_s": 0.4255, + "tps": 95.8, + "prompt_tps": 6384.56, + "eval_tokens": 11, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G01", + "run": 3, + "input": "这个方案的可行性还需要进一步评估。", + "output_raw": "This scheme is still being further assessed for its feasibility. 📋*", + "output_clean": "This scheme is still being further assessed for its feasibility. 📋*", + "reference": "The feasibility of this plan still needs further evaluation.", + "ttft_s": 0.3575, + "total_s": 0.5246, + "tps": 95.57, + "prompt_tps": 5421.99, + "eval_tokens": 16, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G02", + "run": 1, + "input": "我们的日活用户已经突破了五百万。", + "output_raw": "Our daily active users have surpassed five million. 🔤", + "output_clean": "Our daily active users have surpassed five million.", + "reference": "Our daily active users have exceeded five million.", + "ttft_s": 0.4307, + "total_s": 0.6007, + "tps": 70.52, + "prompt_tps": 951.43, + "eval_tokens": 12, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G02", + "run": 2, + "input": "我们的日活用户已经突破了五百万。", + "output_raw": "Our active daily users have already exceeded five million.", + "output_clean": "Our active daily users have already exceeded five million.", + "reference": "Our daily active users have exceeded five million.", + "ttft_s": 0.3063, + "total_s": 0.4188, + "tps": 97.9, + "prompt_tps": 8917.02, + "eval_tokens": 11, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G02", + "run": 3, + "input": "我们的日活用户已经突破了五百万。", + "output_raw": "Our daily active user base has crossed five million marks. 🚀", + "output_clean": "Our daily active user base has crossed five million marks. 🚀", + "reference": "Our daily active users have exceeded five million.", + "ttft_s": 0.317, + "total_s": 0.4728, + "tps": 96.26, + "prompt_tps": 5439.75, + "eval_tokens": 15, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G03", + "run": 1, + "input": "请大家在周五之前提交各自负责模块的测试报告。", + "output_raw": "Please submit a test report for each module responsible by Friday prior to the next day. 🔤", + "output_clean": "Please submit a test report for each module responsible by Friday prior to the next day.", + "reference": "Please submit the test reports for your respective modules by Friday.", + "ttft_s": 0.3809, + "total_s": 0.5959, + "tps": 93.07, + "prompt_tps": 1495.62, + "eval_tokens": 20, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G03", + "run": 2, + "input": "请大家在周五之前提交各自负责模块的测试报告。", + "output_raw": "Please submit test reports for each module you are responsible of by this Friday morning before the deadline to ensure we can meet our targets efficiently. 📋✨", + "output_clean": "Please submit test reports for each module you are responsible of by this Friday morning before the deadline to ensure we can meet our targets efficiently. 📋✨", + "reference": "Please submit the test reports for your respective modules by Friday.", + "ttft_s": 0.3046, + "total_s": 0.6552, + "tps": 91.25, + "prompt_tps": 9231.83, + "eval_tokens": 32, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G03", + "run": 3, + "input": "请大家在周五之前提交各自负责模块的测试报告。", + "output_raw": "Please submit a test report for each module you are responsible of by Friday. 📄", + "output_clean": "Please submit a test report for each module you are responsible of by Friday. 📄", + "reference": "Please submit the test reports for your respective modules by Friday.", + "ttft_s": 0.3143, + "total_s": 0.5277, + "tps": 89.04, + "prompt_tps": 7667.66, + "eval_tokens": 19, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G04", + "run": 1, + "input": "下一步我们要重点关注用户体验的优化,尤其是首屏加载速度。", + "output_raw": "Next step involves prioritizing user experience optimization, particularly in improving the loading speed of our first screen.", + "output_clean": "Next step involves prioritizing user experience optimization, particularly in improving the loading speed of our first screen.", + "reference": "Next, we need to focus on optimizing user experience, especially the initial screen loading speed.", + "ttft_s": 0.4685, + "total_s": 0.6989, + "tps": 91.05, + "prompt_tps": 1489.51, + "eval_tokens": 21, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G04", + "run": 2, + "input": "下一步我们要重点关注用户体验的优化,尤其是首屏加载速度。", + "output_raw": "We will now focus on optimizing user experience, particularly with regards to the loading speed of the first screen.", + "output_clean": "We will now focus on optimizing user experience, particularly with regards to the loading speed of the first screen.", + "reference": "Next, we need to focus on optimizing user experience, especially the initial screen loading speed.", + "ttft_s": 0.3217, + "total_s": 0.5763, + "tps": 86.58, + "prompt_tps": 7312.32, + "eval_tokens": 22, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G04", + "run": 3, + "input": "下一步我们要重点关注用户体验的优化,尤其是首屏加载速度。", + "output_raw": "In the coming steps we must place greater emphasis on optimizing user experience, particularly in terms of load speed for the first screen (faster-than-average page loading).", + "output_clean": "In the coming steps we must place greater emphasis on optimizing user experience, particularly in terms of load speed for the first screen (faster-than-average page loading).", + "reference": "Next, we need to focus on optimizing user experience, especially the initial screen loading speed.", + "ttft_s": 0.3296, + "total_s": 0.6885, + "tps": 91.99, + "prompt_tps": 4589.34, + "eval_tokens": 33, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G05", + "run": 1, + "input": "这个bug的根本原因是内存泄漏导致的,已经在最新版本中修复了。", + "output_raw": "The root cause of this bug was a memory leak that is already being fixed in the latest version.", + "output_clean": "The root cause of this bug was a memory leak that is already being fixed in the latest version.", + "reference": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "ttft_s": 0.4009, + "total_s": 0.6329, + "tps": 90.46, + "prompt_tps": 1392.64, + "eval_tokens": 21, + "prompt_tokens": 136, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G05", + "run": 2, + "input": "这个bug的根本原因是内存泄漏导致的,已经在最新版本中修复了。", + "output_raw": "This bug's root cause was a memory leak; it has been fixed in the latest version.", + "output_clean": "This bug's root cause was a memory leak; it has been fixed in the latest version.", + "reference": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "ttft_s": 0.3048, + "total_s": 0.5231, + "tps": 91.61, + "prompt_tps": 8681.22, + "eval_tokens": 20, + "prompt_tokens": 136, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G05", + "run": 3, + "input": "这个bug的根本原因是内存泄漏导致的,已经在最新版本中修复了。", + "output_raw": "The root cause of this bug was a memory leak; it has already been fixed in the latest version. 🧩", + "output_clean": "The root cause of this bug was a memory leak; it has already been fixed in the latest version. 🧩", + "reference": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "ttft_s": 0.3058, + "total_s": 0.5779, + "tps": 91.93, + "prompt_tps": 8745.98, + "eval_tokens": 25, + "prompt_tokens": 136, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "H01", + "run": 1, + "input": "Thank you for joining today's meeting.", + "output_raw": "こんにちは、ご参加いただきました。ありがとうございます。(Konnichiwa, go taikei jitai de arawaruite; aru to ga atayasu.)", + "output_clean": "こんにちは、ご参加いただきました。ありがとうございます。(Konnichiwa, go taikei jitai de arawaruite; aru to ga atayasu.)", + "reference": "本日の会議にご参加いただきありがとうございます。", + "ttft_s": 0.3727, + "total_s": 0.7502, + "tps": 90.11, + "prompt_tps": 1651.96, + "eval_tokens": 34, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H01", + "run": 2, + "input": "Thank you for joining today's meeting.", + "output_raw": "お世話になっております。今日のご会議に参加した際、誠にありがとうございます。", + "output_clean": "お世話になっております。今日のご会議に参加した際、誠にありがとうございます。", + "reference": "本日の会議にご参加いただきありがとうございます。", + "ttft_s": 0.306, + "total_s": 0.4746, + "tps": 95.04, + "prompt_tps": 8976.41, + "eval_tokens": 16, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H01", + "run": 3, + "input": "Thank you for joining today's meeting.", + "output_raw": "ご挨拶いたしました。", + "output_clean": "ご挨拶いたしました。", + "reference": "本日の会議にご参加いただきありがとうございます。", + "ttft_s": 0.3102, + "total_s": 0.3561, + "tps": 109.08, + "prompt_tps": 6617.08, + "eval_tokens": 5, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H02", + "run": 1, + "input": "The deadline for this project is the end of next month.", + "output_raw": "このプロジェクトの締約期限は次の月末日です。", + "output_clean": "このプロジェクトの締約期限は次の月末日です。", + "reference": "このプロジェクトの締め切りは来月末です。", + "ttft_s": 0.3804, + "total_s": 0.5151, + "tps": 96.53, + "prompt_tps": 1493.06, + "eval_tokens": 13, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H02", + "run": 2, + "input": "The deadline for this project is the end of next month.", + "output_raw": "プロジェクトの提出期限は、来月の末日になります。", + "output_clean": "プロジェクトの提出期限は、来月の末日になります。", + "reference": "このプロジェクトの締め切りは来月末です。", + "ttft_s": 0.3058, + "total_s": 0.4322, + "tps": 94.84, + "prompt_tps": 8056.21, + "eval_tokens": 12, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H02", + "run": 3, + "input": "The deadline for this project is the end of next month.", + "output_raw": "このプロジェクトの締め日として、次月末であることとなります。", + "output_clean": "このプロジェクトの締め日として、次月末であることとなります。", + "reference": "このプロジェクトの締め切りは来月末です。", + "ttft_s": 0.3139, + "total_s": 0.4703, + "tps": 95.94, + "prompt_tps": 5658.61, + "eval_tokens": 15, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H03", + "run": 1, + "input": "We need to reconsider our marketing strategy for the Asian market.", + "output_raw": "私たちはアジア市場でのマーケティング戦略を検討すべきだと考え直す必要があります。", + "output_clean": "私たちはアジア市場でのマーケティング戦略を検討すべきだと考え直す必要があります。", + "reference": "アジア市場向けのマーケティング戦略を再検討する必要があります。", + "ttft_s": 0.4528, + "total_s": 0.6003, + "tps": 94.85, + "prompt_tps": 1608.92, + "eval_tokens": 14, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H03", + "run": 2, + "input": "We need to reconsider our marketing strategy for the Asian market.", + "output_raw": "このアジア地域におけるマーケティング戦略を再考する必要があります。", + "output_clean": "このアジア地域におけるマーケティング戦略を再考する必要があります。", + "reference": "アジア市場向けのマーケティング戦略を再検討する必要があります。", + "ttft_s": 0.3129, + "total_s": 0.4416, + "tps": 93.27, + "prompt_tps": 7324.6, + "eval_tokens": 12, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H03", + "run": 3, + "input": "We need to reconsider our marketing strategy for the Asian market.", + "output_raw": "私たちはアジア市場でのマーケティング戦略の見直しが必要です。 🔨", + "output_clean": "私たちはアジア市場でのマーケティング戦略の見直しが必要です。 🔨", + "reference": "アジア市場向けのマーケティング戦略を再検討する必要があります。", + "ttft_s": 0.3131, + "total_s": 0.4603, + "tps": 88.63, + "prompt_tps": 6132.14, + "eval_tokens": 13, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "I01", + "run": 1, + "input": "Hello", + "output_raw": "早上好~(Hello)", + "output_clean": "早上好~(Hello)", + "reference": "你好", + "ttft_s": 0.4152, + "total_s": 0.472, + "tps": 106.76, + "prompt_tps": 1538.97, + "eval_tokens": 6, + "prompt_tokens": 122, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I01", + "run": 2, + "input": "Hello", + "output_raw": "你好(Hello)", + "output_clean": "你好(Hello)", + "reference": "你好", + "ttft_s": 0.3111, + "total_s": 0.3597, + "tps": 102.95, + "prompt_tps": 7163.83, + "eval_tokens": 5, + "prompt_tokens": 122, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I01", + "run": 3, + "input": "Hello", + "output_raw": "你好", + "output_clean": "你好", + "reference": "你好", + "ttft_s": 0.3135, + "total_s": 0.3292, + "tps": 149.76, + "prompt_tps": 5291.46, + "eval_tokens": 2, + "prompt_tokens": 122, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I02", + "run": 1, + "input": "Thank you", + "output_raw": "非常感谢。", + "output_clean": "非常感谢。", + "reference": "谢谢", + "ttft_s": 0.3973, + "total_s": 0.4208, + "tps": 127.43, + "prompt_tps": 1536.73, + "eval_tokens": 3, + "prompt_tokens": 123, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I02", + "run": 2, + "input": "Thank you", + "output_raw": "谢谢!", + "output_clean": "谢谢!", + "reference": "谢谢", + "ttft_s": 0.3104, + "total_s": 0.3343, + "tps": 125.23, + "prompt_tps": 6205.85, + "eval_tokens": 3, + "prompt_tokens": 123, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I02", + "run": 3, + "input": "Thank you", + "output_raw": "谢谢。", + "output_clean": "谢谢。", + "reference": "谢谢", + "ttft_s": 0.3196, + "total_s": 0.3434, + "tps": 123.99, + "prompt_tps": 4215.07, + "eval_tokens": 3, + "prompt_tokens": 123, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I03", + "run": 1, + "input": "I don't understand", + "output_raw": "我不明白。", + "output_clean": "我不明白。", + "reference": "我不明白", + "ttft_s": 0.3703, + "total_s": 0.4049, + "tps": 115.69, + "prompt_tps": 1563.99, + "eval_tokens": 4, + "prompt_tokens": 125, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I03", + "run": 2, + "input": "I don't understand", + "output_raw": "我听不懂。", + "output_clean": "我听不懂。", + "reference": "我不明白", + "ttft_s": 0.3022, + "total_s": 0.34, + "tps": 114.27, + "prompt_tps": 8149.69, + "eval_tokens": 4, + "prompt_tokens": 125, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I03", + "run": 3, + "input": "I don't understand", + "output_raw": "我不明白 🔍", + "output_clean": "我不明白 🔍", + "reference": "我不明白", + "ttft_s": 0.316, + "total_s": 0.3598, + "tps": 110.16, + "prompt_tps": 4632.55, + "eval_tokens": 5, + "prompt_tokens": 125, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I04", + "run": 1, + "input": "Can you help me?", + "output_raw": "当然可以,需要我帮您吗? 没问题,请告诉我您具体需要什么帮助。 不客气!", + "output_clean": "当然可以,需要我帮您吗? 没问题,请告诉我您具体需要什么帮助。 不客气!", + "reference": "你能帮我吗?", + "ttft_s": 0.3779, + "total_s": 0.626, + "tps": 92.77, + "prompt_tps": 1468.86, + "eval_tokens": 23, + "prompt_tokens": 126, + "instruction_compliant": false, + "violation_detail": "包含违规前缀: '当然...'", + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I04", + "run": 2, + "input": "Can you help me?", + "output_raw": "能不能帮我一下?", + "output_clean": "能不能帮我一下?", + "reference": "你能帮我吗?", + "ttft_s": 0.3184, + "total_s": 0.365, + "tps": 107.74, + "prompt_tps": 5936.4, + "eval_tokens": 5, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I04", + "run": 3, + "input": "Can you help me?", + "output_raw": "当然可以帮你吗?", + "output_clean": "当然可以帮你吗?", + "reference": "你能帮我吗?", + "ttft_s": 0.3322, + "total_s": 0.3851, + "tps": 104.92, + "prompt_tps": 4016.32, + "eval_tokens": 6, + "prompt_tokens": 126, + "instruction_compliant": false, + "violation_detail": "包含违规前缀: '当然...'", + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I05", + "run": 1, + "input": "What is machine learning?", + "output_raw": "机器学习是什么?", + "output_clean": "机器学习是什么?", + "reference": "什么是机器学习?", + "ttft_s": 0.4729, + "total_s": 0.5124, + "tps": 102.93, + "prompt_tps": 1404.01, + "eval_tokens": 4, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I05", + "run": 2, + "input": "What is machine learning?", + "output_raw": "人工智能是什么?(Machine Learning 是什么?)机器学习和机器学习有什么不同呢?", + "output_clean": "人工智能是什么?(Machine Learning 是什么?)机器学习和机器学习有什么不同呢?", + "reference": "什么是机器学习?", + "ttft_s": 0.324, + "total_s": 0.4932, + "tps": 94.04, + "prompt_tps": 7008.18, + "eval_tokens": 16, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I05", + "run": 3, + "input": "What is machine learning?", + "output_raw": "机器学习是什么?它是指计算机如何从大量数据中学习并自我改进。这种能力通常来自于训练算法,例如通过神经网络或逻辑回归模型来识别模式并预测结果。简单来说,机器学习让系统能够像人一样根据数据和经验做出判断和决策的能力。作为人工智能领域的关键概念之一,该领域正在不断发展和进化。", + "output_clean": "机器学习是什么?它是指计算机如何从大量数据中学习并自我改进。这种能力通常来自于训练算法,例如通过神经网络或逻辑回归模型来识别模式并预测结果。简单来说,机器学习让系统能够像人一样根据数据和经验做出判断和决策的能力。作为人工智能领域的关键概念之一,该领域正在不断发展和进化。", + "reference": "什么是机器学习?", + "ttft_s": 0.3509, + "total_s": 1.1181, + "tps": 91.23, + "prompt_tps": 4342.13, + "eval_tokens": 70, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + } + ], + "qwen3.5:9b-mlx": [ + { + "case_id": "A01", + "run": 1, + "input": "Let's get started.", + "output_raw": "开始吧。", + "output_clean": "开始吧。", + "reference": "让我们开始吧。", + "ttft_s": 1.6071, + "total_s": 1.7186, + "tps": 26.0, + "prompt_tps": 83.74, + "eval_tokens": 3, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A01", + "run": 2, + "input": "Let's get started.", + "output_raw": "我们开始吧。", + "output_clean": "我们开始吧。", + "reference": "让我们开始吧。", + "ttft_s": 0.1796, + "total_s": 0.3342, + "tps": 25.76, + "prompt_tps": 935.43, + "eval_tokens": 4, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A01", + "run": 3, + "input": "Let's get started.", + "output_raw": "让我们开始吧。", + "output_clean": "让我们开始吧。", + "reference": "让我们开始吧。", + "ttft_s": 0.0995, + "total_s": 0.2587, + "tps": 25.04, + "prompt_tps": 2057.06, + "eval_tokens": 4, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A02", + "run": 1, + "input": "Can you hear me?", + "output_raw": "您能听到我吗?", + "output_clean": "您能听到我吗?", + "reference": "你能听到我吗?", + "ttft_s": 1.4467, + "total_s": 1.6363, + "tps": 25.0, + "prompt_tps": 90.35, + "eval_tokens": 5, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A02", + "run": 2, + "input": "Can you hear me?", + "output_raw": "您能听到我吗?", + "output_clean": "您能听到我吗?", + "reference": "你能听到我吗?", + "ttft_s": 0.2095, + "total_s": 0.409, + "tps": 25.06, + "prompt_tps": 922.06, + "eval_tokens": 5, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A02", + "run": 3, + "input": "Can you hear me?", + "output_raw": "能听到吗?", + "output_clean": "能听到吗?", + "reference": "你能听到我吗?", + "ttft_s": 0.108, + "total_s": 0.2817, + "tps": 23.02, + "prompt_tps": 1945.95, + "eval_tokens": 4, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A03", + "run": 1, + "input": "That's a great point.", + "output_raw": "说得好。", + "output_clean": "说得好。", + "reference": "说得很好。", + "ttft_s": 0.5069, + "total_s": 0.6556, + "tps": 20.23, + "prompt_tps": 272.26, + "eval_tokens": 3, + "prompt_tokens": 127, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A03", + "run": 2, + "input": "That's a great point.", + "output_raw": "说得太好了。", + "output_clean": "说得太好了。", + "reference": "说得很好。", + "ttft_s": 0.2237, + "total_s": 0.4128, + "tps": 21.06, + "prompt_tps": 729.79, + "eval_tokens": 4, + "prompt_tokens": 127, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A03", + "run": 3, + "input": "That's a great point.", + "output_raw": "说得好。", + "output_clean": "说得好。", + "reference": "说得很好。", + "ttft_s": 0.1185, + "total_s": 0.2608, + "tps": 21.12, + "prompt_tps": 1779.86, + "eval_tokens": 3, + "prompt_tokens": 127, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A04", + "run": 1, + "input": "I'll share my screen.", + "output_raw": "我将共享屏幕。", + "output_clean": "我将共享屏幕。", + "reference": "我来共享一下屏幕。", + "ttft_s": 0.4869, + "total_s": 0.6564, + "tps": 23.47, + "prompt_tps": 288.69, + "eval_tokens": 4, + "prompt_tokens": 127, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A04", + "run": 2, + "input": "I'll share my screen.", + "output_raw": "我将分享我的屏幕。", + "output_clean": "我将分享我的屏幕。", + "reference": "我来共享一下屏幕。", + "ttft_s": 0.204, + "total_s": 0.4098, + "tps": 24.29, + "prompt_tps": 838.79, + "eval_tokens": 5, + "prompt_tokens": 127, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A04", + "run": 3, + "input": "I'll share my screen.", + "output_raw": "我将共享我的屏幕。", + "output_clean": "我将共享我的屏幕。", + "reference": "我来共享一下屏幕。", + "ttft_s": 0.1033, + "total_s": 0.3211, + "tps": 22.93, + "prompt_tps": 1962.88, + "eval_tokens": 5, + "prompt_tokens": 127, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A05", + "run": 1, + "input": "Any questions so far?", + "output_raw": "到目前为止有什么问题吗?", + "output_clean": "到目前为止有什么问题吗?", + "reference": "到目前为止有什么问题吗?", + "ttft_s": 0.5067, + "total_s": 0.6965, + "tps": 20.99, + "prompt_tps": 272.88, + "eval_tokens": 4, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A05", + "run": 2, + "input": "Any questions so far?", + "output_raw": "到目前为止,大家有什么问题吗?", + "output_clean": "到目前为止,大家有什么问题吗?", + "reference": "到目前为止有什么问题吗?", + "ttft_s": 0.2096, + "total_s": 0.4813, + "tps": 22.05, + "prompt_tps": 754.65, + "eval_tokens": 6, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A05", + "run": 3, + "input": "Any questions so far?", + "output_raw": "目前有任何问题吗?", + "output_clean": "目前有任何问题吗?", + "reference": "到目前为止有什么问题吗?", + "ttft_s": 0.1168, + "total_s": 0.3469, + "tps": 21.67, + "prompt_tps": 1786.08, + "eval_tokens": 5, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "B01", + "run": 1, + "input": "I think we should postpone the launch to next quarter because the QA team hasn't finished testing yet.", + "output_raw": "我认为应将产品发布推迟至下一季度,因为质量保证团队尚未完成测试。", + "output_clean": "我认为应将产品发布推迟至下一季度,因为质量保证团队尚未完成测试。", + "reference": "我认为我们应该把发布推迟到下个季度,因为QA团队还没有完成测试。", + "ttft_s": 0.5552, + "total_s": 1.3348, + "tps": 20.5, + "prompt_tps": 282.29, + "eval_tokens": 16, + "prompt_tokens": 141, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B01", + "run": 2, + "input": "I think we should postpone the launch to next quarter because the QA team hasn't finished testing yet.", + "output_raw": "我认为应将产品发布推迟至下一季度,因为质量保证团队尚未完成测试。", + "output_clean": "我认为应将产品发布推迟至下一季度,因为质量保证团队尚未完成测试。", + "reference": "我认为我们应该把发布推迟到下个季度,因为QA团队还没有完成测试。", + "ttft_s": 0.2391, + "total_s": 0.9801, + "tps": 21.6, + "prompt_tps": 788.06, + "eval_tokens": 16, + "prompt_tokens": 141, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B01", + "run": 3, + "input": "I think we should postpone the launch to next quarter because the QA team hasn't finished testing yet.", + "output_raw": "我认为应将产品发布推迟到下个季度,因为质量保证团队尚未完成测试。", + "output_clean": "我认为应将产品发布推迟到下个季度,因为质量保证团队尚未完成测试。", + "reference": "我认为我们应该把发布推迟到下个季度,因为QA团队还没有完成测试。", + "ttft_s": 0.1246, + "total_s": 0.8545, + "tps": 21.94, + "prompt_tps": 2040.82, + "eval_tokens": 16, + "prompt_tokens": 141, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B02", + "run": 1, + "input": "The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why.", + "output_raw": "用户留存率较上月下降了百分之十五,我们需要找出原因。", + "output_clean": "用户留存率较上月下降了百分之十五,我们需要找出原因。", + "reference": "用户留存率与上个月相比下降了百分之十五,我们需要找出原因。", + "ttft_s": 0.5776, + "total_s": 1.302, + "tps": 17.87, + "prompt_tps": 277.63, + "eval_tokens": 13, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B02", + "run": 2, + "input": "The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why.", + "output_raw": "与上月相比,用户留存率下降了 15%,我们需要查明原因。", + "output_clean": "与上月相比,用户留存率下降了 15%,我们需要查明原因。", + "reference": "用户留存率与上个月相比下降了百分之十五,我们需要找出原因。", + "ttft_s": 0.3124, + "total_s": 2.8448, + "tps": 6.32, + "prompt_tps": 545.15, + "eval_tokens": 16, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B02", + "run": 3, + "input": "The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why.", + "output_raw": "与上个月相比,用户留存率下降了 15%,我们需要找出原因。", + "output_clean": "与上个月相比,用户留存率下降了 15%,我们需要找出原因。", + "reference": "用户留存率与上个月相比下降了百分之十五,我们需要找出原因。", + "ttft_s": 0.8169, + "total_s": 7.0116, + "tps": 2.59, + "prompt_tps": 198.5, + "eval_tokens": 16, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B03", + "run": 1, + "input": "We're planning to migrate our infrastructure from AWS to Google Cloud by the end of this year.", + "output_raw": "我们计划在今年年底完成从 AWS 到 Google Cloud 的基础设施迁移。", + "output_clean": "我们计划在今年年底完成从 AWS 到 Google Cloud 的基础设施迁移。", + "reference": "我们计划在今年年底前将基础设施从AWS迁移到Google Cloud。", + "ttft_s": 10.1041, + "total_s": 17.0976, + "tps": 6.51, + "prompt_tps": 14.82, + "eval_tokens": 16, + "prompt_tokens": 140, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B03", + "run": 2, + "input": "We're planning to migrate our infrastructure from AWS to Google Cloud by the end of this year.", + "output_raw": "我们计划在今年年底前将基础设施从 AWS 迁移到 Google Cloud。", + "output_clean": "我们计划在今年年底前将基础设施从 AWS 迁移到 Google Cloud。", + "reference": "我们计划在今年年底前将基础设施从AWS迁移到Google Cloud。", + "ttft_s": 38.4549, + "total_s": 90.0166, + "tps": 0.27, + "prompt_tps": 3.83, + "eval_tokens": 14, + "prompt_tokens": 140, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B03", + "run": 3, + "input": "We're planning to migrate our infrastructure from AWS to Google Cloud by the end of this year.", + "output_raw": "我们计划在本年底前将基础设施从 AWS 迁移至 Google Cloud。", + "output_clean": "我们计划在本年底前将基础设施从 AWS 迁移至 Google Cloud。", + "reference": "我们计划在今年年底前将基础设施从AWS迁移到Google Cloud。", + "ttft_s": 0.4349, + "total_s": 1.2061, + "tps": 18.1, + "prompt_tps": 561.35, + "eval_tokens": 14, + "prompt_tokens": 140, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B04", + "run": 1, + "input": "The new privacy policy requires us to obtain explicit user consent before collecting any personal data.", + "output_raw": "新隐私政策规定,我们在收集任何个人数据之前必须获得用户的明确同意。", + "output_clean": "新隐私政策规定,我们在收集任何个人数据之前必须获得用户的明确同意。", + "reference": "新的隐私政策要求我们在收集任何个人数据之前获得用户的明确同意。", + "ttft_s": 1.6449, + "total_s": 6.3078, + "tps": 3.65, + "prompt_tps": 87.35, + "eval_tokens": 17, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B04", + "run": 2, + "input": "The new privacy policy requires us to obtain explicit user consent before collecting any personal data.", + "output_raw": "新隐私政策规定,我们在收集任何个人数据前必须获得用户的明确同意。", + "output_clean": "新隐私政策规定,我们在收集任何个人数据前必须获得用户的明确同意。", + "reference": "新的隐私政策要求我们在收集任何个人数据之前获得用户的明确同意。", + "ttft_s": 1.3026, + "total_s": 5.9986, + "tps": 3.62, + "prompt_tps": 119.33, + "eval_tokens": 17, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B04", + "run": 3, + "input": "The new privacy policy requires us to obtain explicit user consent before collecting any personal data.", + "output_raw": "新隐私政策规定,我们在收集任何个人数据之前必须获得用户的明确同意。", + "output_clean": "新隐私政策规定,我们在收集任何个人数据之前必须获得用户的明确同意。", + "reference": "新的隐私政策要求我们在收集任何个人数据之前获得用户的明确同意。", + "ttft_s": 0.6713, + "total_s": 6.0834, + "tps": 3.14, + "prompt_tps": 310.28, + "eval_tokens": 17, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B05", + "run": 1, + "input": "Could you walk us through the architecture diagram? I want to make sure everyone is on the same page.", + "output_raw": "大家能否一起看一下架构示意图,确保我们达成共识?", + "output_clean": "大家能否一起看一下架构示意图,确保我们达成共识?", + "reference": "你能给我们讲一下架构图吗?我想确保大家的理解是一致的。", + "ttft_s": 6.6036, + "total_s": 10.1067, + "tps": 3.14, + "prompt_tps": 22.04, + "eval_tokens": 11, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B05", + "run": 2, + "input": "Could you walk us through the architecture diagram? I want to make sure everyone is on the same page.", + "output_raw": "请大家过一遍架构图,确保大家思路一致。", + "output_clean": "请大家过一遍架构图,确保大家思路一致。", + "reference": "你能给我们讲一下架构图吗?我想确保大家的理解是一致的。", + "ttft_s": 2.1764, + "total_s": 5.7674, + "tps": 2.78, + "prompt_tps": 72.35, + "eval_tokens": 10, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B05", + "run": 3, + "input": "Could you walk us through the architecture diagram? I want to make sure everyone is on the same page.", + "output_raw": "能否带我们过一遍架构图,以确保大家思路一致?", + "output_clean": "能否带我们过一遍架构图,以确保大家思路一致?", + "reference": "你能给我们讲一下架构图吗?我想确保大家的理解是一致的。", + "ttft_s": 1.2338, + "total_s": 4.7367, + "tps": 3.42, + "prompt_tps": 244.32, + "eval_tokens": 12, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "C01", + "run": 1, + "input": "So let me summarize today's discussion. We agreed to move forward with option B, which involves rebuilding the payment module from scratch. The timeline is eight weeks, with the first milestone due in three weeks. John will lead the backend team, and Sarah will handle the frontend integration. We also need to coordinate with the compliance team to ensure we meet all regulatory requirements before going live.", + "output_raw": "总结一下今天的讨论:我们决定推进方案 B,即从头重建支付模块。项目周期为八周,首个里程碑将于三周内交付。由约翰负责后端团队,莎拉负责前端集成。此外,我们还需与合规团队协作,确保上线前满足所有监管要求。", + "output_clean": "总结一下今天的讨论:我们决定推进方案 B,即从头重建支付模块。项目周期为八周,首个里程碑将于三周内交付。由约翰负责后端团队,莎拉负责前端集成。此外,我们还需与合规团队协作,确保上线前满足所有监管要求。", + "reference": "让我来总结一下今天的讨论。我们同意推进方案B,即从零开始重建支付模块。时间线是八周,第一个里程碑在三周内到期。John将带领后端团队,Sarah负责前端集成。我们还需要与合规团队协调,确保上线前满足所有监管要求。", + "ttft_s": 29.3378, + "total_s": 100.3884, + "tps": 0.79, + "prompt_tps": 6.79, + "eval_tokens": 56, + "prompt_tokens": 197, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C01", + "run": 2, + "input": "So let me summarize today's discussion. We agreed to move forward with option B, which involves rebuilding the payment module from scratch. The timeline is eight weeks, with the first milestone due in three weeks. John will lead the backend team, and Sarah will handle the frontend integration. We also need to coordinate with the compliance team to ensure we meet all regulatory requirements before going live.", + "output_raw": "因此,我对今天的讨论总结如下:我们决定推进方案 B,即完全重构支付模块。项目周期为八周,首个里程碑需在三周内完成。约翰将负责后端团队,莎拉将主导前端集成。此外,我们需要与合规团队协作,确保正式上线前满足所有监管要求。", + "output_clean": "因此,我对今天的讨论总结如下:我们决定推进方案 B,即完全重构支付模块。项目周期为八周,首个里程碑需在三周内完成。约翰将负责后端团队,莎拉将主导前端集成。此外,我们需要与合规团队协作,确保正式上线前满足所有监管要求。", + "reference": "让我来总结一下今天的讨论。我们同意推进方案B,即从零开始重建支付模块。时间线是八周,第一个里程碑在三周内到期。John将带领后端团队,Sarah负责前端集成。我们还需要与合规团队协调,确保上线前满足所有监管要求。", + "ttft_s": 1.9661, + "total_s": 44.5326, + "tps": 1.38, + "prompt_tps": 128.16, + "eval_tokens": 59, + "prompt_tokens": 197, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C01", + "run": 3, + "input": "So let me summarize today's discussion. We agreed to move forward with option B, which involves rebuilding the payment module from scratch. The timeline is eight weeks, with the first milestone due in three weeks. John will lead the backend team, and Sarah will handle the frontend integration. We also need to coordinate with the compliance team to ensure we meet all regulatory requirements before going live.", + "output_raw": "让我们总结今天的讨论:我们决定推进 B 方案,即从零开始重建支付模块。时间表为八周,第一阶段里程碑需在三周内完成。约翰将领导后端团队,萨拉负责前端集成。此外,我们需要与合规团队协作,确保上线前满足所有监管要求。", + "output_clean": "让我们总结今天的讨论:我们决定推进 B 方案,即从零开始重建支付模块。时间表为八周,第一阶段里程碑需在三周内完成。约翰将领导后端团队,萨拉负责前端集成。此外,我们需要与合规团队协作,确保上线前满足所有监管要求。", + "reference": "让我来总结一下今天的讨论。我们同意推进方案B,即从零开始重建支付模块。时间线是八周,第一个里程碑在三周内到期。John将带领后端团队,Sarah负责前端集成。我们还需要与合规团队协调,确保上线前满足所有监管要求。", + "ttft_s": 0.63, + "total_s": 12.0118, + "tps": 5.01, + "prompt_tps": 442.98, + "eval_tokens": 57, + "prompt_tokens": 197, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C02", + "run": 1, + "input": "Artificial intelligence is transforming the healthcare industry in ways we never imagined. From early disease detection using medical imaging to personalized treatment plans based on genetic data, AI is helping doctors make better decisions faster. However, we must also address the ethical concerns around patient privacy, algorithmic bias, and the potential displacement of healthcare workers.", + "output_raw": "人工智能正以我们未曾想象的方式重塑医疗行业,从基于医学影像的早期疾病检测,到依据基因数据制定的个性化治疗方案,AI 正帮助医生更快做出更优决策。然而,我们也必须正视患者隐私保护、算法偏见以及医疗从业人员岗位可能受到冲击等伦理问题。", + "output_clean": "人工智能正以我们未曾想象的方式重塑医疗行业,从基于医学影像的早期疾病检测,到依据基因数据制定的个性化治疗方案,AI 正帮助医生更快做出更优决策。然而,我们也必须正视患者隐私保护、算法偏见以及医疗从业人员岗位可能受到冲击等伦理问题。", + "reference": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据的个性化治疗方案,AI正在帮助医生更快地做出更好的决策。然而,我们也必须解决围绕患者隐私、算法偏见以及医疗工作者可能被取代等伦理问题。", + "ttft_s": 1.2031, + "total_s": 4.2723, + "tps": 19.85, + "prompt_tps": 180.37, + "eval_tokens": 61, + "prompt_tokens": 184, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C02", + "run": 2, + "input": "Artificial intelligence is transforming the healthcare industry in ways we never imagined. From early disease detection using medical imaging to personalized treatment plans based on genetic data, AI is helping doctors make better decisions faster. However, we must also address the ethical concerns around patient privacy, algorithmic bias, and the potential displacement of healthcare workers.", + "output_raw": "人工智能正以前所未有的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据制定个性化治疗方案,人工智能帮助医生更快、更准确地做出决策。然而,我们也必须正视患者隐私、算法偏见以及可能导致医务人员被替代等伦理问题。", + "output_clean": "人工智能正以前所未有的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据制定个性化治疗方案,人工智能帮助医生更快、更准确地做出决策。然而,我们也必须正视患者隐私、算法偏见以及可能导致医务人员被替代等伦理问题。", + "reference": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据的个性化治疗方案,AI正在帮助医生更快地做出更好的决策。然而,我们也必须解决围绕患者隐私、算法偏见以及医疗工作者可能被取代等伦理问题。", + "ttft_s": 0.3829, + "total_s": 3.4394, + "tps": 18.41, + "prompt_tps": 637.86, + "eval_tokens": 56, + "prompt_tokens": 184, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C02", + "run": 3, + "input": "Artificial intelligence is transforming the healthcare industry in ways we never imagined. From early disease detection using medical imaging to personalized treatment plans based on genetic data, AI is helping doctors make better decisions faster. However, we must also address the ethical concerns around patient privacy, algorithmic bias, and the potential displacement of healthcare workers.", + "output_raw": "人工智能正以前所未有的方式变革医疗行业。从利用医学影像进行早期疾病诊断,到基于基因数据制定个性化治疗方案,AI 正帮助医生更快做出更优决策。然而,我们同样必须应对患者隐私、算法偏见以及医疗人员岗位可能受到冲击等伦理问题。", + "output_clean": "人工智能正以前所未有的方式变革医疗行业。从利用医学影像进行早期疾病诊断,到基于基因数据制定个性化治疗方案,AI 正帮助医生更快做出更优决策。然而,我们同样必须应对患者隐私、算法偏见以及医疗人员岗位可能受到冲击等伦理问题。", + "reference": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据的个性化治疗方案,AI正在帮助医生更快地做出更好的决策。然而,我们也必须解决围绕患者隐私、算法偏见以及医疗工作者可能被取代等伦理问题。", + "ttft_s": 0.2064, + "total_s": 3.2092, + "tps": 19.95, + "prompt_tps": 1819.81, + "eval_tokens": 60, + "prompt_tokens": 184, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "D01", + "run": 1, + "input": "So basically what I'm trying to say is", + "output_raw": "所以,我想表达的是...", + "output_clean": "所以,我想表达的是...", + "reference": "所以基本上我想说的是", + "ttft_s": 0.5721, + "total_s": 0.8501, + "tps": 21.57, + "prompt_tps": 258.31, + "eval_tokens": 6, + "prompt_tokens": 130, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D01", + "run": 2, + "input": "So basically what I'm trying to say is", + "output_raw": "所以,我试图说明的是:", + "output_clean": "所以,我试图说明的是:", + "reference": "所以基本上我想说的是", + "ttft_s": 0.2072, + "total_s": 0.5365, + "tps": 21.3, + "prompt_tps": 786.88, + "eval_tokens": 7, + "prompt_tokens": 130, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D01", + "run": 3, + "input": "So basically what I'm trying to say is", + "output_raw": "总之,我试图表达的是。", + "output_clean": "总之,我试图表达的是。", + "reference": "所以基本上我想说的是", + "ttft_s": 0.1149, + "total_s": 0.4736, + "tps": 19.55, + "prompt_tps": 1726.27, + "eval_tokens": 7, + "prompt_tokens": 130, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D02", + "run": 1, + "input": "The reason we can't do that is because the", + "output_raw": "我们无法这样做的原因是,因为该...", + "output_clean": "我们无法这样做的原因是,因为该...", + "reference": "我们不能那样做的原因是因为", + "ttft_s": 0.5311, + "total_s": 0.8426, + "tps": 22.45, + "prompt_tps": 270.46, + "eval_tokens": 7, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D02", + "run": 2, + "input": "The reason we can't do that is because the", + "output_raw": "我们无法那样做的原因是因为——", + "output_clean": "我们无法那样做的原因是因为——", + "reference": "我们不能那样做的原因是因为", + "ttft_s": 0.2113, + "total_s": 0.4914, + "tps": 21.4, + "prompt_tps": 786.38, + "eval_tokens": 6, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D02", + "run": 3, + "input": "The reason we can't do that is because the", + "output_raw": "我们无法那样做的原因,是因为该……", + "output_clean": "我们无法那样做的原因,是因为该……", + "reference": "我们不能那样做的原因是因为", + "ttft_s": 0.117, + "total_s": 0.5127, + "tps": 20.2, + "prompt_tps": 1776.16, + "eval_tokens": 8, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D03", + "run": 1, + "input": "If we look at the data from last", + "output_raw": "如果我们查看去年的数据,", + "output_clean": "如果我们查看去年的数据,", + "reference": "如果我们看一下上", + "ttft_s": 0.5299, + "total_s": 0.7573, + "tps": 21.95, + "prompt_tps": 264.44, + "eval_tokens": 5, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D03", + "run": 2, + "input": "If we look at the data from last", + "output_raw": "如果我们查看上期的数据,", + "output_clean": "如果我们查看上期的数据,", + "reference": "如果我们看一下上", + "ttft_s": 0.2008, + "total_s": 0.4613, + "tps": 23.02, + "prompt_tps": 818.89, + "eval_tokens": 6, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D03", + "run": 3, + "input": "If we look at the data from last", + "output_raw": "如果查看上一轮的数据,", + "output_clean": "如果查看上一轮的数据,", + "reference": "如果我们看一下上", + "ttft_s": 0.1081, + "total_s": 0.3911, + "tps": 21.18, + "prompt_tps": 1841.81, + "eval_tokens": 6, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D04", + "run": 1, + "input": "I mean it's not that we don't want to but", + "output_raw": "我是说,并不是我们不想这样做。", + "output_clean": "我是说,并不是我们不想这样做。", + "reference": "我的意思是,不是我们不想,但是", + "ttft_s": 0.5359, + "total_s": 0.9133, + "tps": 21.18, + "prompt_tps": 267.59, + "eval_tokens": 8, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D04", + "run": 2, + "input": "I mean it's not that we don't want to but", + "output_raw": "我的意思是,并非我们不想这样做,只是……", + "output_clean": "我的意思是,并非我们不想这样做,只是……", + "reference": "我的意思是,不是我们不想,但是", + "ttft_s": 0.1983, + "total_s": 0.6643, + "tps": 21.46, + "prompt_tps": 841.62, + "eval_tokens": 10, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D04", + "run": 3, + "input": "I mean it's not that we don't want to but", + "output_raw": "我的意思是,不是我们不想要,但是……", + "output_clean": "我的意思是,不是我们不想要,但是……", + "reference": "我的意思是,不是我们不想,但是", + "ttft_s": 0.1669, + "total_s": 0.6946, + "tps": 16.92, + "prompt_tps": 1255.4, + "eval_tokens": 9, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D05", + "run": 1, + "input": "Well the thing is um we actually", + "output_raw": "嗯,其实我们确实……", + "output_clean": "嗯,其实我们确实……", + "reference": "嗯,事情是这样的,实际上我们", + "ttft_s": 0.5753, + "total_s": 0.8615, + "tps": 20.95, + "prompt_tps": 241.63, + "eval_tokens": 6, + "prompt_tokens": 128, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D05", + "run": 2, + "input": "Well the thing is um we actually", + "output_raw": "不过实际情况是,我们实际上……", + "output_clean": "不过实际情况是,我们实际上……", + "reference": "嗯,事情是这样的,实际上我们", + "ttft_s": 0.2728, + "total_s": 0.5865, + "tps": 22.29, + "prompt_tps": 661.22, + "eval_tokens": 7, + "prompt_tokens": 128, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D05", + "run": 3, + "input": "Well the thing is um we actually", + "output_raw": "其实我们实际上……", + "output_clean": "其实我们实际上……", + "reference": "嗯,事情是这样的,实际上我们", + "ttft_s": 0.1272, + "total_s": 0.3134, + "tps": 21.51, + "prompt_tps": 1735.41, + "eval_tokens": 4, + "prompt_tokens": 128, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "E01", + "run": 1, + "input": "We deployed the new Kubernetes cluster on AWS EKS last Friday.", + "output_raw": "上周五我们在 AWS EKS 上部署了新的 Kubernetes 集群。", + "output_clean": "上周五我们在 AWS EKS 上部署了新的 Kubernetes 集群。", + "reference": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "ttft_s": 0.5539, + "total_s": 1.2714, + "tps": 20.89, + "prompt_tps": 268.78, + "eval_tokens": 15, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 2, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E01", + "run": 2, + "input": "We deployed the new Kubernetes cluster on AWS EKS last Friday.", + "output_raw": "上周五,我们在 AWS EKS 上部署了新的 Kubernetes 集群。", + "output_clean": "上周五,我们在 AWS EKS 上部署了新的 Kubernetes 集群。", + "reference": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "ttft_s": 0.2266, + "total_s": 1.0557, + "tps": 19.29, + "prompt_tps": 732.82, + "eval_tokens": 16, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 2, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E01", + "run": 3, + "input": "We deployed the new Kubernetes cluster on AWS EKS last Friday.", + "output_raw": "上周五,我们在 AWS EKS 上部署了新的 Kubernetes 集群。", + "output_clean": "上周五,我们在 AWS EKS 上部署了新的 Kubernetes 集群。", + "reference": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "ttft_s": 0.1264, + "total_s": 0.9112, + "tps": 19.11, + "prompt_tps": 1609.86, + "eval_tokens": 15, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 2, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E02", + "run": 1, + "input": "The React Native app is crashing on iOS seventeen because of a WebSocket timeout issue.", + "output_raw": "React Native 应用在 iOS 十七上因 WebSocket 超时而崩溃。", + "output_clean": "React Native 应用在 iOS 十七上因 WebSocket 超时而崩溃。", + "reference": "React Native应用在iOS 17上因为WebSocket超时问题而崩溃。", + "ttft_s": 0.5586, + "total_s": 1.2658, + "tps": 21.18, + "prompt_tps": 266.5, + "eval_tokens": 15, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E02", + "run": 2, + "input": "The React Native app is crashing on iOS seventeen because of a WebSocket timeout issue.", + "output_raw": "React Native 应用在 iOS 17 上因 WebSocket 超时问题而崩溃。", + "output_clean": "React Native 应用在 iOS 17 上因 WebSocket 超时问题而崩溃。", + "reference": "React Native应用在iOS 17上因为WebSocket超时问题而崩溃。", + "ttft_s": 0.2326, + "total_s": 1.1538, + "tps": 19.54, + "prompt_tps": 733.08, + "eval_tokens": 18, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E02", + "run": 3, + "input": "The React Native app is crashing on iOS seventeen because of a WebSocket timeout issue.", + "output_raw": "React Native 应用在 iOS 17 上因 WebSocket 超时问题而崩溃。", + "output_clean": "React Native 应用在 iOS 17 上因 WebSocket 超时问题而崩溃。", + "reference": "React Native应用在iOS 17上因为WebSocket超时问题而崩溃。", + "ttft_s": 0.1183, + "total_s": 1.0741, + "tps": 18.83, + "prompt_tps": 1791.69, + "eval_tokens": 18, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E03", + "run": 1, + "input": "Apple announced the new M5 chip with improved Neural Engine performance at WWDC.", + "output_raw": "苹果在 WWDC 上发布了新款 M5 芯片,其神经网络引擎性能得到提升。", + "output_clean": "苹果在 WWDC 上发布了新款 M5 芯片,其神经网络引擎性能得到提升。", + "reference": "Apple在WWDC上发布了新的M5芯片,改进了Neural Engine性能。", + "ttft_s": 0.5583, + "total_s": 1.4539, + "tps": 22.33, + "prompt_tps": 264.62, + "eval_tokens": 20, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 4, + "missing_terms": [ + "Apple", + "Neural Engine" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E03", + "run": 2, + "input": "Apple announced the new M5 chip with improved Neural Engine performance at WWDC.", + "output_raw": "苹果在 WWDC 上发布了新款 M5 芯片,其神经网络引擎性能得到提升。", + "output_clean": "苹果在 WWDC 上发布了新款 M5 芯片,其神经网络引擎性能得到提升。", + "reference": "Apple在WWDC上发布了新的M5芯片,改进了Neural Engine性能。", + "ttft_s": 0.2285, + "total_s": 1.3734, + "tps": 17.47, + "prompt_tps": 766.29, + "eval_tokens": 20, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 4, + "missing_terms": [ + "Apple", + "Neural Engine" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E03", + "run": 3, + "input": "Apple announced the new M5 chip with improved Neural Engine performance at WWDC.", + "output_raw": "苹果在WWDC上发布了性能更强大的神经引擎的M5芯片。", + "output_clean": "苹果在WWDC上发布了性能更强大的神经引擎的M5芯片。", + "reference": "Apple在WWDC上发布了新的M5芯片,改进了Neural Engine性能。", + "ttft_s": 0.1295, + "total_s": 1.0086, + "tps": 18.2, + "prompt_tps": 1553.79, + "eval_tokens": 16, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 4, + "missing_terms": [ + "Apple", + "Neural Engine" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E04", + "run": 1, + "input": "We need to implement OAuth two point zero authentication before integrating with the Stripe payment API.", + "output_raw": "在集成 Stripe 支付 API 之前,我们需要实施 OAuth 2.0 身份验证。", + "output_clean": "在集成 Stripe 支付 API 之前,我们需要实施 OAuth 2.0 身份验证。", + "reference": "在集成Stripe支付API之前,我们需要实现OAuth 2.0认证。", + "ttft_s": 0.624, + "total_s": 1.5206, + "tps": 22.31, + "prompt_tps": 239.89, + "eval_tokens": 20, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E04", + "run": 2, + "input": "We need to implement OAuth two point zero authentication before integrating with the Stripe payment API.", + "output_raw": "在集成 Stripe 支付 API 之前,我们需要实施 OAuth 2.0 身份验证。", + "output_clean": "在集成 Stripe 支付 API 之前,我们需要实施 OAuth 2.0 身份验证。", + "reference": "在集成Stripe支付API之前,我们需要实现OAuth 2.0认证。", + "ttft_s": 0.2664, + "total_s": 1.3481, + "tps": 18.53, + "prompt_tps": 685.38, + "eval_tokens": 20, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E04", + "run": 3, + "input": "We need to implement OAuth two point zero authentication before integrating with the Stripe payment API.", + "output_raw": "在集成 Stripe 支付 API 之前,我们需要实现 OAuth 2.0 身份验证。", + "output_clean": "在集成 Stripe 支付 API 之前,我们需要实现 OAuth 2.0 身份验证。", + "reference": "在集成Stripe支付API之前,我们需要实现OAuth 2.0认证。", + "ttft_s": 0.1768, + "total_s": 1.2242, + "tps": 19.1, + "prompt_tps": 1115.42, + "eval_tokens": 20, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E05", + "run": 1, + "input": "The transformer model uses multi-head attention and layer normalization to process the input embeddings.", + "output_raw": "该 Transformer 模型采用多头注意力和层归一化来处理输入嵌入。", + "output_clean": "该 Transformer 模型采用多头注意力和层归一化来处理输入嵌入。", + "reference": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。", + "ttft_s": 0.6181, + "total_s": 1.3153, + "tps": 23.02, + "prompt_tps": 261.48, + "eval_tokens": 16, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 1, + "term_total": 2, + "missing_terms": [ + "multi-head attention" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E05", + "run": 2, + "input": "The transformer model uses multi-head attention and layer normalization to process the input embeddings.", + "output_raw": "Transformer 模型使用多头注意力和层归一化来处理输入嵌入。", + "output_clean": "Transformer 模型使用多头注意力和层归一化来处理输入嵌入。", + "reference": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。", + "ttft_s": 0.2321, + "total_s": 1.0236, + "tps": 18.94, + "prompt_tps": 750.22, + "eval_tokens": 15, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 1, + "term_total": 2, + "missing_terms": [ + "multi-head attention" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E05", + "run": 3, + "input": "The transformer model uses multi-head attention and layer normalization to process the input embeddings.", + "output_raw": "Transformer 模型采用多头注意力机制和层归一化方法对输入嵌入进行处理。", + "output_clean": "Transformer 模型采用多头注意力机制和层归一化方法对输入嵌入进行处理。", + "reference": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。", + "ttft_s": 0.1244, + "total_s": 1.0951, + "tps": 18.54, + "prompt_tps": 1626.99, + "eval_tokens": 18, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 1, + "term_total": 2, + "missing_terms": [ + "multi-head attention" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "F01", + "run": 1, + "input": "Let's start with the first one. How is it going so far?", + "output_raw": "我们首先从第一点开始,目前进展如何?", + "output_clean": "我们首先从第一点开始,目前进展如何?", + "reference": "让我们从第一个开始。目前进展如何?", + "ttft_s": 1.2479, + "total_s": 1.7064, + "tps": 23.94, + "prompt_tps": 175.36, + "eval_tokens": 11, + "prompt_tokens": 211, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F01", + "run": 2, + "input": "Let's start with the first one. How is it going so far?", + "output_raw": "让我们从第一个话题开始,目前的进展如何?", + "output_clean": "让我们从第一个话题开始,目前的进展如何?", + "reference": "让我们从第一个开始。目前进展如何?", + "ttft_s": 0.1976, + "total_s": 0.6804, + "tps": 20.72, + "prompt_tps": 1327.17, + "eval_tokens": 10, + "prompt_tokens": 211, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F01", + "run": 3, + "input": "Let's start with the first one. How is it going so far?", + "output_raw": "让我们先谈谈第一点。目前进展如何?", + "output_clean": "让我们先谈谈第一点。目前进展如何?", + "reference": "让我们从第一个开始。目前进展如何?", + "ttft_s": 0.1303, + "total_s": 0.6273, + "tps": 20.11, + "prompt_tps": 2581.8, + "eval_tokens": 10, + "prompt_tokens": 211, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F02", + "run": 1, + "input": "And what about the second item? When is the design review scheduled?", + "output_raw": "第二项呢?设计评审安排什么时候?", + "output_clean": "第二项呢?设计评审安排什么时候?", + "reference": "第二项呢?设计评审安排在什么时候?", + "ttft_s": 1.3219, + "total_s": 1.6902, + "tps": 24.4, + "prompt_tps": 165.12, + "eval_tokens": 9, + "prompt_tokens": 210, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F02", + "run": 2, + "input": "And what about the second item? When is the design review scheduled?", + "output_raw": "第二项内容呢?设计评审安排在什么时候?", + "output_clean": "第二项内容呢?设计评审安排在什么时候?", + "reference": "第二项呢?设计评审安排在什么时候?", + "ttft_s": 0.1948, + "total_s": 0.6781, + "tps": 20.69, + "prompt_tps": 1356.63, + "eval_tokens": 10, + "prompt_tokens": 210, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F02", + "run": 3, + "input": "And what about the second item? When is the design review scheduled?", + "output_raw": "那第二项呢?设计评审安排在什么时候?", + "output_clean": "那第二项呢?设计评审安排在什么时候?", + "reference": "第二项呢?设计评审安排在什么时候?", + "ttft_s": 0.1274, + "total_s": 0.6847, + "tps": 17.95, + "prompt_tps": 2505.81, + "eval_tokens": 10, + "prompt_tokens": 210, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "G01", + "run": 1, + "input": "这个方案的可行性还需要进一步评估。", + "output_raw": "The feasibility of this proposal needs further evaluation.", + "output_clean": "The feasibility of this proposal needs further evaluation.", + "reference": "The feasibility of this plan still needs further evaluation.", + "ttft_s": 1.5785, + "total_s": 1.9349, + "tps": 25.2, + "prompt_tps": 84.26, + "eval_tokens": 9, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G01", + "run": 2, + "input": "这个方案的可行性还需要进一步评估。", + "output_raw": "The feasibility of this plan still needs to be assessed further.", + "output_clean": "The feasibility of this plan still needs to be assessed further.", + "reference": "The feasibility of this plan still needs further evaluation.", + "ttft_s": 0.1964, + "total_s": 0.7598, + "tps": 21.28, + "prompt_tps": 889.47, + "eval_tokens": 12, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G01", + "run": 3, + "input": "这个方案的可行性还需要进一步评估。", + "output_raw": "The feasibility of this plan still requires further evaluation.", + "output_clean": "The feasibility of this plan still requires further evaluation.", + "reference": "The feasibility of this plan still needs further evaluation.", + "ttft_s": 0.172, + "total_s": 0.6786, + "tps": 19.73, + "prompt_tps": 1454.94, + "eval_tokens": 10, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G02", + "run": 1, + "input": "我们的日活用户已经突破了五百万。", + "output_raw": "Our daily active user base has surpassed five million.", + "output_clean": "Our daily active user base has surpassed five million.", + "reference": "Our daily active users have exceeded five million.", + "ttft_s": 1.3412, + "total_s": 1.7866, + "tps": 22.44, + "prompt_tps": 103.5, + "eval_tokens": 10, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G02", + "run": 2, + "input": "我们的日活用户已经突破了五百万。", + "output_raw": "Our daily active users have surpassed five million.", + "output_clean": "Our daily active users have surpassed five million.", + "reference": "Our daily active users have exceeded five million.", + "ttft_s": 0.2848, + "total_s": 0.7842, + "tps": 18.02, + "prompt_tps": 632.47, + "eval_tokens": 9, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G02", + "run": 3, + "input": "我们的日活用户已经突破了五百万。", + "output_raw": "Our daily active users have surpassed five million.", + "output_clean": "Our daily active users have surpassed five million.", + "reference": "Our daily active users have exceeded five million.", + "ttft_s": 0.1264, + "total_s": 0.6289, + "tps": 17.91, + "prompt_tps": 1498.7, + "eval_tokens": 9, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G03", + "run": 1, + "input": "请大家在周五之前提交各自负责模块的测试报告。", + "output_raw": "Please submit your respective module test reports by Friday.", + "output_clean": "Please submit your respective module test reports by Friday.", + "reference": "Please submit the test reports for your respective modules by Friday.", + "ttft_s": 0.554, + "total_s": 1.0044, + "tps": 22.2, + "prompt_tps": 267.29, + "eval_tokens": 10, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G03", + "run": 2, + "input": "请大家在周五之前提交各自负责模块的测试报告。", + "output_raw": "Please submit test reports for your respective modules by this Friday.", + "output_clean": "Please submit test reports for your respective modules by this Friday.", + "reference": "Please submit the test reports for your respective modules by Friday.", + "ttft_s": 0.2133, + "total_s": 0.8346, + "tps": 19.32, + "prompt_tps": 776.1, + "eval_tokens": 12, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G03", + "run": 3, + "input": "请大家在周五之前提交各自负责模块的测试报告。", + "output_raw": "Please submit the test reports for your respective modules by Friday.", + "output_clean": "Please submit the test reports for your respective modules by Friday.", + "reference": "Please submit the test reports for your respective modules by Friday.", + "ttft_s": 0.125, + "total_s": 0.7881, + "tps": 18.1, + "prompt_tps": 1563.18, + "eval_tokens": 12, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G04", + "run": 1, + "input": "下一步我们要重点关注用户体验的优化,尤其是首屏加载速度。", + "output_raw": "Our next key focus is optimizing user experience, particularly the initial screen load time.", + "output_clean": "Our next key focus is optimizing user experience, particularly the initial screen load time.", + "reference": "Next, we need to focus on optimizing user experience, especially the initial screen loading speed.", + "ttft_s": 0.5558, + "total_s": 1.3187, + "tps": 20.94, + "prompt_tps": 262.96, + "eval_tokens": 16, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G04", + "run": 2, + "input": "下一步我们要重点关注用户体验的优化,尤其是首屏加载速度。", + "output_raw": "The next focus will be on optimizing the user experience, particularly first-screen loading speed.", + "output_clean": "The next focus will be on optimizing the user experience, particularly first-screen loading speed.", + "reference": "Next, we need to focus on optimizing user experience, especially the initial screen loading speed.", + "ttft_s": 0.2236, + "total_s": 1.0966, + "tps": 19.48, + "prompt_tps": 763.6, + "eval_tokens": 17, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G04", + "run": 3, + "input": "下一步我们要重点关注用户体验的优化,尤其是首屏加载速度。", + "output_raw": "Next, our focus will shift to optimizing user experience, particularly the loading speed of the first screen.", + "output_clean": "Next, our focus will shift to optimizing user experience, particularly the loading speed of the first screen.", + "reference": "Next, we need to focus on optimizing user experience, especially the initial screen loading speed.", + "ttft_s": 0.1275, + "total_s": 1.2247, + "tps": 18.22, + "prompt_tps": 1567.38, + "eval_tokens": 20, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G05", + "run": 1, + "input": "这个bug的根本原因是内存泄漏导致的,已经在最新版本中修复了。", + "output_raw": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "output_clean": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "reference": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "ttft_s": 0.596, + "total_s": 1.5528, + "tps": 20.92, + "prompt_tps": 245.34, + "eval_tokens": 20, + "prompt_tokens": 136, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G05", + "run": 2, + "input": "这个bug的根本原因是内存泄漏导致的,已经在最新版本中修复了。", + "output_raw": "The root cause of this bug is a memory leak, which has been fixed in the latest version.", + "output_clean": "The root cause of this bug is a memory leak, which has been fixed in the latest version.", + "reference": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "ttft_s": 0.2352, + "total_s": 1.2853, + "tps": 19.05, + "prompt_tps": 705.76, + "eval_tokens": 20, + "prompt_tokens": 136, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G05", + "run": 3, + "input": "这个bug的根本原因是内存泄漏导致的,已经在最新版本中修复了。", + "output_raw": "The root cause of this bug is a memory leak, which has been fixed in the latest version.", + "output_clean": "The root cause of this bug is a memory leak, which has been fixed in the latest version.", + "reference": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "ttft_s": 0.1285, + "total_s": 1.2259, + "tps": 18.22, + "prompt_tps": 1566.9, + "eval_tokens": 20, + "prompt_tokens": 136, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "H01", + "run": 1, + "input": "Thank you for joining today's meeting.", + "output_raw": "本日のお打ち合わせに皆様ご参加ください、ありがとうございます。", + "output_clean": "本日のお打ち合わせに皆様ご参加ください、ありがとうございます。", + "reference": "本日の会議にご参加いただきありがとうございます。", + "ttft_s": 1.2994, + "total_s": 1.7858, + "tps": 24.63, + "prompt_tps": 102.71, + "eval_tokens": 12, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H01", + "run": 2, + "input": "Thank you for joining today's meeting.", + "output_raw": "本日の会議にご参加いただき、ありがとうございます。", + "output_clean": "本日の会議にご参加いただき、ありがとうございます。", + "reference": "本日の会議にご参加いただきありがとうございます。", + "ttft_s": 0.2027, + "total_s": 0.6564, + "tps": 19.84, + "prompt_tps": 803.3, + "eval_tokens": 9, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H01", + "run": 3, + "input": "Thank you for joining today's meeting.", + "output_raw": "本日の会議に参列していただき、ありがとうございます。", + "output_clean": "本日の会議に参列していただき、ありがとうございます。", + "reference": "本日の会議にご参加いただきありがとうございます。", + "ttft_s": 0.1267, + "total_s": 0.7686, + "tps": 17.13, + "prompt_tps": 1471.86, + "eval_tokens": 11, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H02", + "run": 1, + "input": "The deadline for this project is the end of next month.", + "output_raw": "このプロジェクトの締切は来月末です。", + "output_clean": "このプロジェクトの締切は来月末です。", + "reference": "このプロジェクトの締め切りは来月末です。", + "ttft_s": 1.284, + "total_s": 1.7061, + "tps": 23.69, + "prompt_tps": 106.82, + "eval_tokens": 10, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H02", + "run": 2, + "input": "The deadline for this project is the end of next month.", + "output_raw": "本プロジェクトの期限は来月末です。", + "output_clean": "本プロジェクトの期限は来月末です。", + "reference": "このプロジェクトの締め切りは来月末です。", + "ttft_s": 0.2653, + "total_s": 0.77, + "tps": 17.83, + "prompt_tps": 604.39, + "eval_tokens": 9, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H02", + "run": 3, + "input": "The deadline for this project is the end of next month.", + "output_raw": "本プロジェクトの期限は来月末です。", + "output_clean": "本プロジェクトの期限は来月末です。", + "reference": "このプロジェクトの締め切りは来月末です。", + "ttft_s": 0.1631, + "total_s": 0.8036, + "tps": 14.05, + "prompt_tps": 1110.09, + "eval_tokens": 9, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H03", + "run": 1, + "input": "We need to reconsider our marketing strategy for the Asian market.", + "output_raw": "アジア市場向けのマーケティング戦略を見直すべきです。", + "output_clean": "アジア市場向けのマーケティング戦略を見直すべきです。", + "reference": "アジア市場向けのマーケティング戦略を再検討する必要があります。", + "ttft_s": 0.5557, + "total_s": 1.2537, + "tps": 14.33, + "prompt_tps": 255.49, + "eval_tokens": 10, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H03", + "run": 2, + "input": "We need to reconsider our marketing strategy for the Asian market.", + "output_raw": "アジア市場向けのマーケティング戦略の見直しが必要です。", + "output_clean": "アジア市場向けのマーケティング戦略の見直しが必要です。", + "reference": "アジア市場向けのマーケティング戦略を再検討する必要があります。", + "ttft_s": 0.3665, + "total_s": 1.1127, + "tps": 12.05, + "prompt_tps": 405.5, + "eval_tokens": 9, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H03", + "run": 3, + "input": "We need to reconsider our marketing strategy for the Asian market.", + "output_raw": "アジア市場向けのマーケティング戦略を見直す必要があります。", + "output_clean": "アジア市場向けのマーケティング戦略を見直す必要があります。", + "reference": "アジア市場向けのマーケティング戦略を再検討する必要があります。", + "ttft_s": 0.194, + "total_s": 0.9466, + "tps": 11.96, + "prompt_tps": 854.71, + "eval_tokens": 9, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "I01", + "run": 1, + "input": "Hello", + "output_raw": "大家好。", + "output_clean": "大家好。", + "reference": "你好", + "ttft_s": 0.6667, + "total_s": 0.774, + "tps": 18.6, + "prompt_tps": 197.42, + "eval_tokens": 2, + "prompt_tokens": 122, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I01", + "run": 2, + "input": "Hello", + "output_raw": "您好", + "output_clean": "您好", + "reference": "你好", + "ttft_s": 0.2861, + "total_s": 0.3566, + "tps": 14.16, + "prompt_tps": 493.44, + "eval_tokens": 1, + "prompt_tokens": 122, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I01", + "run": 3, + "input": "Hello", + "output_raw": "大家好。", + "output_clean": "大家好。", + "reference": "你好", + "ttft_s": 0.2536, + "total_s": 0.3906, + "tps": 14.56, + "prompt_tps": 569.71, + "eval_tokens": 2, + "prompt_tokens": 122, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I02", + "run": 1, + "input": "Thank you", + "output_raw": "谢谢", + "output_clean": "谢谢", + "reference": "谢谢", + "ttft_s": 0.5543, + "total_s": 0.6091, + "tps": 18.12, + "prompt_tps": 237.43, + "eval_tokens": 1, + "prompt_tokens": 123, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I02", + "run": 2, + "input": "Thank you", + "output_raw": "谢谢。", + "output_clean": "谢谢。", + "reference": "谢谢", + "ttft_s": 0.2753, + "total_s": 0.4263, + "tps": 13.23, + "prompt_tps": 519.62, + "eval_tokens": 2, + "prompt_tokens": 123, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I02", + "run": 3, + "input": "Thank you", + "output_raw": "谢谢", + "output_clean": "谢谢", + "reference": "谢谢", + "ttft_s": 0.2081, + "total_s": 0.2742, + "tps": 15.09, + "prompt_tps": 731.59, + "eval_tokens": 1, + "prompt_tokens": 123, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I03", + "run": 1, + "input": "I don't understand", + "output_raw": "我不明白。", + "output_clean": "我不明白。", + "reference": "我不明白", + "ttft_s": 0.9788, + "total_s": 1.1608, + "tps": 16.46, + "prompt_tps": 132.48, + "eval_tokens": 3, + "prompt_tokens": 125, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I03", + "run": 2, + "input": "I don't understand", + "output_raw": "我不理解。", + "output_clean": "我不理解。", + "reference": "我不明白", + "ttft_s": 0.3929, + "total_s": 0.635, + "tps": 12.4, + "prompt_tps": 353.94, + "eval_tokens": 3, + "prompt_tokens": 125, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I03", + "run": 3, + "input": "I don't understand", + "output_raw": "我不明白。", + "output_clean": "我不明白。", + "reference": "我不明白", + "ttft_s": 0.1998, + "total_s": 0.502, + "tps": 9.92, + "prompt_tps": 783.93, + "eval_tokens": 3, + "prompt_tokens": 125, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I04", + "run": 1, + "input": "Can you help me?", + "output_raw": "您能帮我吗?", + "output_clean": "您能帮我吗?", + "reference": "你能帮我吗?", + "ttft_s": 0.6723, + "total_s": 0.9478, + "tps": 18.14, + "prompt_tps": 197.64, + "eval_tokens": 5, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I04", + "run": 2, + "input": "Can you help me?", + "output_raw": "您能帮帮我吗?", + "output_clean": "您能帮帮我吗?", + "reference": "你能帮我吗?", + "ttft_s": 0.3859, + "total_s": 0.8424, + "tps": 13.15, + "prompt_tps": 363.62, + "eval_tokens": 6, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I04", + "run": 3, + "input": "Can you help me?", + "output_raw": "您能帮帮我吗?", + "output_clean": "您能帮帮我吗?", + "reference": "你能帮我吗?", + "ttft_s": 0.1791, + "total_s": 0.6883, + "tps": 11.78, + "prompt_tps": 911.6, + "eval_tokens": 6, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I05", + "run": 1, + "input": "What is machine learning?", + "output_raw": "机器学习是什么?", + "output_clean": "机器学习是什么?", + "reference": "什么是机器学习?", + "ttft_s": 0.5806, + "total_s": 0.7612, + "tps": 16.56, + "prompt_tps": 231.91, + "eval_tokens": 3, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I05", + "run": 2, + "input": "What is machine learning?", + "output_raw": "什么是机器学习?", + "output_clean": "什么是机器学习?", + "reference": "什么是机器学习?", + "ttft_s": 0.3937, + "total_s": 0.5987, + "tps": 14.64, + "prompt_tps": 354.85, + "eval_tokens": 3, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I05", + "run": 3, + "input": "What is machine learning?", + "output_raw": "机器学习是什么?", + "output_clean": "机器学习是什么?", + "reference": "什么是机器学习?", + "ttft_s": 0.2266, + "total_s": 0.4313, + "tps": 14.63, + "prompt_tps": 669.39, + "eval_tokens": 3, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + } + ], + "qwen3.5:4b-mlx": [ + { + "case_id": "A01", + "run": 1, + "input": "Let's get started.", + "output_raw": "让我们开始吧。", + "output_clean": "让我们开始吧。", + "reference": "让我们开始吧。", + "ttft_s": 0.7335, + "total_s": 0.8373, + "tps": 38.5, + "prompt_tps": 181.05, + "eval_tokens": 4, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A01", + "run": 2, + "input": "Let's get started.", + "output_raw": "让我们开始吧。", + "output_clean": "让我们开始吧。", + "reference": "让我们开始吧。", + "ttft_s": 0.1237, + "total_s": 0.223, + "tps": 40.24, + "prompt_tps": 1446.44, + "eval_tokens": 4, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A01", + "run": 3, + "input": "Let's get started.", + "output_raw": "让我们开始吧。", + "output_clean": "让我们开始吧。", + "reference": "让我们开始吧。", + "ttft_s": 0.072, + "total_s": 0.1721, + "tps": 40.0, + "prompt_tps": 3314.31, + "eval_tokens": 4, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A02", + "run": 1, + "input": "Can you hear me?", + "output_raw": "你能听见我吗?", + "output_clean": "你能听见我吗?", + "reference": "你能听到我吗?", + "ttft_s": 0.7794, + "total_s": 0.881, + "tps": 39.33, + "prompt_tps": 169.06, + "eval_tokens": 4, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A02", + "run": 2, + "input": "Can you hear me?", + "output_raw": "能听到我吗?", + "output_clean": "能听到我吗?", + "reference": "你能听到我吗?", + "ttft_s": 0.1195, + "total_s": 0.2178, + "tps": 40.62, + "prompt_tps": 1462.45, + "eval_tokens": 4, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A02", + "run": 3, + "input": "Can you hear me?", + "output_raw": "你听得见吗?", + "output_clean": "你听得见吗?", + "reference": "你能听到我吗?", + "ttft_s": 0.0699, + "total_s": 0.1916, + "tps": 41.05, + "prompt_tps": 3477.59, + "eval_tokens": 5, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A03", + "run": 1, + "input": "That's a great point.", + "output_raw": "这是一个很好的观点。", + "output_clean": "这是一个很好的观点。", + "reference": "说得很好。", + "ttft_s": 0.2484, + "total_s": 0.3494, + "tps": 39.28, + "prompt_tps": 594.92, + "eval_tokens": 4, + "prompt_tokens": 127, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A03", + "run": 2, + "input": "That's a great point.", + "output_raw": "这是个极好的观点。", + "output_clean": "这是个极好的观点。", + "reference": "说得很好。", + "ttft_s": 0.1254, + "total_s": 0.2292, + "tps": 38.48, + "prompt_tps": 1417.25, + "eval_tokens": 4, + "prompt_tokens": 127, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A03", + "run": 3, + "input": "That's a great point.", + "output_raw": "这是个极好的观点。", + "output_clean": "这是个极好的观点。", + "reference": "说得很好。", + "ttft_s": 0.0741, + "total_s": 0.1809, + "tps": 37.38, + "prompt_tps": 3151.12, + "eval_tokens": 4, + "prompt_tokens": 127, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A04", + "run": 1, + "input": "I'll share my screen.", + "output_raw": "我会共享我的屏幕。", + "output_clean": "我会共享我的屏幕。", + "reference": "我来共享一下屏幕。", + "ttft_s": 0.2806, + "total_s": 0.4217, + "tps": 35.39, + "prompt_tps": 514.4, + "eval_tokens": 5, + "prompt_tokens": 127, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A04", + "run": 2, + "input": "I'll share my screen.", + "output_raw": "我将分享我的屏幕。", + "output_clean": "我将分享我的屏幕。", + "reference": "我来共享一下屏幕。", + "ttft_s": 0.133, + "total_s": 0.2718, + "tps": 35.99, + "prompt_tps": 1288.87, + "eval_tokens": 5, + "prompt_tokens": 127, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A04", + "run": 3, + "input": "I'll share my screen.", + "output_raw": "我会共享我的屏幕。", + "output_clean": "我会共享我的屏幕。", + "reference": "我来共享一下屏幕。", + "ttft_s": 0.0765, + "total_s": 0.2149, + "tps": 36.09, + "prompt_tps": 3056.52, + "eval_tokens": 5, + "prompt_tokens": 127, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A05", + "run": 1, + "input": "Any questions so far?", + "output_raw": "目前有什么问题吗?", + "output_clean": "目前有什么问题吗?", + "reference": "到目前为止有什么问题吗?", + "ttft_s": 0.2882, + "total_s": 0.4002, + "tps": 35.71, + "prompt_tps": 497.95, + "eval_tokens": 4, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A05", + "run": 2, + "input": "Any questions so far?", + "output_raw": "到目前为止有任何问题吗?", + "output_clean": "到目前为止有任何问题吗?", + "reference": "到目前为止有什么问题吗?", + "ttft_s": 0.1319, + "total_s": 0.2682, + "tps": 36.69, + "prompt_tps": 1303.6, + "eval_tokens": 5, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A05", + "run": 3, + "input": "Any questions so far?", + "output_raw": "到目前为止有什么问题吗?", + "output_clean": "到目前为止有什么问题吗?", + "reference": "到目前为止有什么问题吗?", + "ttft_s": 0.0748, + "total_s": 0.1824, + "tps": 37.15, + "prompt_tps": 3159.03, + "eval_tokens": 4, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "B01", + "run": 1, + "input": "I think we should postpone the launch to next quarter because the QA team hasn't finished testing yet.", + "output_raw": "我认为我们应该将启动推迟到下个季度,因为测试团队尚未完成测试。", + "output_clean": "我认为我们应该将启动推迟到下个季度,因为测试团队尚未完成测试。", + "reference": "我认为我们应该把发布推迟到下个季度,因为QA团队还没有完成测试。", + "ttft_s": 0.3063, + "total_s": 0.7536, + "tps": 35.76, + "prompt_tps": 519.33, + "eval_tokens": 16, + "prompt_tokens": 141, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B01", + "run": 2, + "input": "I think we should postpone the launch to next quarter because the QA team hasn't finished testing yet.", + "output_raw": "我认为我们应该将发布日期推迟到下个季度,因为 QA 团队尚未完成测试。", + "output_clean": "我认为我们应该将发布日期推迟到下个季度,因为 QA 团队尚未完成测试。", + "reference": "我认为我们应该把发布推迟到下个季度,因为QA团队还没有完成测试。", + "ttft_s": 0.134, + "total_s": 0.6323, + "tps": 34.1, + "prompt_tps": 1410.32, + "eval_tokens": 17, + "prompt_tokens": 141, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B01", + "run": 3, + "input": "I think we should postpone the launch to next quarter because the QA team hasn't finished testing yet.", + "output_raw": "我认为我们应该将发布推迟到下下个季度,因为质量保证团队尚未完成测试。", + "output_clean": "我认为我们应该将发布推迟到下下个季度,因为质量保证团队尚未完成测试。", + "reference": "我认为我们应该把发布推迟到下个季度,因为QA团队还没有完成测试。", + "ttft_s": 0.0786, + "total_s": 0.5657, + "tps": 32.84, + "prompt_tps": 3169.56, + "eval_tokens": 16, + "prompt_tokens": 141, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B02", + "run": 1, + "input": "The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why.", + "output_raw": "用户留存率较去年同期下降了百分之十五,我们需要找出原因。", + "output_clean": "用户留存率较去年同期下降了百分之十五,我们需要找出原因。", + "reference": "用户留存率与上个月相比下降了百分之十五,我们需要找出原因。", + "ttft_s": 0.3286, + "total_s": 0.6619, + "tps": 35.98, + "prompt_tps": 489.95, + "eval_tokens": 12, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B02", + "run": 2, + "input": "The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why.", + "output_raw": "用户留存率较上月下降了十五%,我们需要找出原因。", + "output_clean": "用户留存率较上月下降了十五%,我们需要找出原因。", + "reference": "用户留存率与上个月相比下降了百分之十五,我们需要找出原因。", + "ttft_s": 0.1309, + "total_s": 0.4635, + "tps": 36.07, + "prompt_tps": 1468.21, + "eval_tokens": 12, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B02", + "run": 3, + "input": "The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why.", + "output_raw": "用户留存率较上个月下降了百分之十五,我们需要查明原因。", + "output_clean": "用户留存率较上个月下降了百分之十五,我们需要查明原因。", + "reference": "用户留存率与上个月相比下降了百分之十五,我们需要找出原因。", + "ttft_s": 0.0782, + "total_s": 0.464, + "tps": 33.68, + "prompt_tps": 3295.14, + "eval_tokens": 13, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B03", + "run": 1, + "input": "We're planning to migrate our infrastructure from AWS to Google Cloud by the end of this year.", + "output_raw": "我们计划在本年底将基础设施从 AWS 迁移至 Google Cloud。", + "output_clean": "我们计划在本年底将基础设施从 AWS 迁移至 Google Cloud。", + "reference": "我们计划在今年年底前将基础设施从AWS迁移到Google Cloud。", + "ttft_s": 0.3191, + "total_s": 0.7208, + "tps": 34.84, + "prompt_tps": 494.54, + "eval_tokens": 14, + "prompt_tokens": 140, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B03", + "run": 2, + "input": "We're planning to migrate our infrastructure from AWS to Google Cloud by the end of this year.", + "output_raw": "我们计划在今年年底之前将基础设施从 AWS 迁移到谷歌云。", + "output_clean": "我们计划在今年年底之前将基础设施从 AWS 迁移到谷歌云。", + "reference": "我们计划在今年年底前将基础设施从AWS迁移到Google Cloud。", + "ttft_s": 0.1379, + "total_s": 0.5714, + "tps": 34.51, + "prompt_tps": 1388.78, + "eval_tokens": 15, + "prompt_tokens": 140, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B03", + "run": 3, + "input": "We're planning to migrate our infrastructure from AWS to Google Cloud by the end of this year.", + "output_raw": "我们计划在今年年底前将我们的基础设施从 AWS 迁移到 Google Cloud。", + "output_clean": "我们计划在今年年底前将我们的基础设施从 AWS 迁移到 Google Cloud。", + "reference": "我们计划在今年年底前将基础设施从AWS迁移到Google Cloud。", + "ttft_s": 0.0791, + "total_s": 0.525, + "tps": 33.66, + "prompt_tps": 3148.32, + "eval_tokens": 15, + "prompt_tokens": 140, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B04", + "run": 1, + "input": "The new privacy policy requires us to obtain explicit user consent before collecting any personal data.", + "output_raw": "新隐私政策要求我们在收集任何个人数据前必须获得用户的明确同意。", + "output_clean": "新隐私政策要求我们在收集任何个人数据前必须获得用户的明确同意。", + "reference": "新的隐私政策要求我们在收集任何个人数据之前获得用户的明确同意。", + "ttft_s": 0.334, + "total_s": 0.7851, + "tps": 35.45, + "prompt_tps": 460.48, + "eval_tokens": 16, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B04", + "run": 2, + "input": "The new privacy policy requires us to obtain explicit user consent before collecting any personal data.", + "output_raw": "新隐私政策要求我们在收集任何个人数据前获得用户的明确同意。", + "output_clean": "新隐私政策要求我们在收集任何个人数据前获得用户的明确同意。", + "reference": "新的隐私政策要求我们在收集任何个人数据之前获得用户的明确同意。", + "ttft_s": 0.1392, + "total_s": 0.5709, + "tps": 34.74, + "prompt_tps": 1373.47, + "eval_tokens": 15, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B04", + "run": 3, + "input": "The new privacy policy requires us to obtain explicit user consent before collecting any personal data.", + "output_raw": "新隐私政策规定,在收集任何个人数据前,我们必须获得用户的明确同意。", + "output_clean": "新隐私政策规定,在收集任何个人数据前,我们必须获得用户的明确同意。", + "reference": "新的隐私政策要求我们在收集任何个人数据之前获得用户的明确同意。", + "ttft_s": 0.0786, + "total_s": 0.618, + "tps": 33.36, + "prompt_tps": 3125.27, + "eval_tokens": 18, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B05", + "run": 1, + "input": "Could you walk us through the architecture diagram? I want to make sure everyone is on the same page.", + "output_raw": "能否向大家简述一下架构图?我想确保我们大家都理解一致。", + "output_clean": "能否向大家简述一下架构图?我想确保我们大家都理解一致。", + "reference": "你能给我们讲一下架构图吗?我想确保大家的理解是一致的。", + "ttft_s": 0.315, + "total_s": 0.6824, + "tps": 35.37, + "prompt_tps": 504.93, + "eval_tokens": 13, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B05", + "run": 2, + "input": "Could you walk us through the architecture diagram? I want to make sure everyone is on the same page.", + "output_raw": "您能为我们展示架构图吗?我想确保大家都处于同一理解水平。", + "output_clean": "您能为我们展示架构图吗?我想确保大家都处于同一理解水平。", + "reference": "你能给我们讲一下架构图吗?我想确保大家的理解是一致的。", + "ttft_s": 0.1326, + "total_s": 0.5592, + "tps": 35.16, + "prompt_tps": 1443.4, + "eval_tokens": 15, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B05", + "run": 3, + "input": "Could you walk us through the architecture diagram? I want to make sure everyone is on the same page.", + "output_raw": "能为您梳理一下架构示意图吗?我想确保大家对此有一致的理解。", + "output_clean": "能为您梳理一下架构示意图吗?我想确保大家对此有一致的理解。", + "reference": "你能给我们讲一下架构图吗?我想确保大家的理解是一致的。", + "ttft_s": 0.0778, + "total_s": 0.5711, + "tps": 32.43, + "prompt_tps": 3284.52, + "eval_tokens": 16, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "C01", + "run": 1, + "input": "So let me summarize today's discussion. We agreed to move forward with option B, which involves rebuilding the payment module from scratch. The timeline is eight weeks, with the first milestone due in three weeks. John will lead the backend team, and Sarah will handle the frontend integration. We also need to coordinate with the compliance team to ensure we meet all regulatory requirements before going live.", + "output_raw": "让我总结一下今天的讨论:我们同意推进 B 方案,即从头开始重构支付模块。时间周期为八周,首个里程碑将于三周内完成。约翰将负责后端团队,萨拉负责前端集成。此外,我们需要与合规团队协调,确保在上线前满足所有法规要求。", + "output_clean": "让我总结一下今天的讨论:我们同意推进 B 方案,即从头开始重构支付模块。时间周期为八周,首个里程碑将于三周内完成。约翰将负责后端团队,萨拉负责前端集成。此外,我们需要与合规团队协调,确保在上线前满足所有法规要求。", + "reference": "让我来总结一下今天的讨论。我们同意推进方案B,即从零开始重建支付模块。时间线是八周,第一个里程碑在三周内到期。John将带领后端团队,Sarah负责前端集成。我们还需要与合规团队协调,确保上线前满足所有监管要求。", + "ttft_s": 0.633, + "total_s": 2.3282, + "tps": 35.39, + "prompt_tps": 330.38, + "eval_tokens": 60, + "prompt_tokens": 197, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C01", + "run": 2, + "input": "So let me summarize today's discussion. We agreed to move forward with option B, which involves rebuilding the payment module from scratch. The timeline is eight weeks, with the first milestone due in three weeks. John will lead the backend team, and Sarah will handle the frontend integration. We also need to coordinate with the compliance team to ensure we meet all regulatory requirements before going live.", + "output_raw": "让我总结一下今天的讨论:我们同意推进方案 B,即从头开始重建支付模块。时间为八周,第一个里程碑将在三周内达成。约翰将领导后端团队,萨奇负责前端集成。此外,我们需要与合规团队合作,确保在上线前满足所有监管要求。", + "output_clean": "让我总结一下今天的讨论:我们同意推进方案 B,即从头开始重建支付模块。时间为八周,第一个里程碑将在三周内达成。约翰将领导后端团队,萨奇负责前端集成。此外,我们需要与合规团队合作,确保在上线前满足所有监管要求。", + "reference": "让我来总结一下今天的讨论。我们同意推进方案B,即从零开始重建支付模块。时间线是八周,第一个里程碑在三周内到期。John将带领后端团队,Sarah负责前端集成。我们还需要与合规团队协调,确保上线前满足所有监管要求。", + "ttft_s": 0.1539, + "total_s": 1.9065, + "tps": 32.52, + "prompt_tps": 1678.07, + "eval_tokens": 57, + "prompt_tokens": 197, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C01", + "run": 3, + "input": "So let me summarize today's discussion. We agreed to move forward with option B, which involves rebuilding the payment module from scratch. The timeline is eight weeks, with the first milestone due in three weeks. John will lead the backend team, and Sarah will handle the frontend integration. We also need to coordinate with the compliance team to ensure we meet all regulatory requirements before going live.", + "output_raw": "让我总结一下今天的讨论。我们同意推进选项 B,即从零开始重建支付模块。时间表为八周,第一个里程碑定于三周后完成。John 将负责后端团队,Sarah 将处理前端集成。此外,我们需要与合规团队协作,确保在上线前满足所有法规要求。", + "output_clean": "让我总结一下今天的讨论。我们同意推进选项 B,即从零开始重建支付模块。时间表为八周,第一个里程碑定于三周后完成。John 将负责后端团队,Sarah 将处理前端集成。此外,我们需要与合规团队协作,确保在上线前满足所有法规要求。", + "reference": "让我来总结一下今天的讨论。我们同意推进方案B,即从零开始重建支付模块。时间线是八周,第一个里程碑在三周内到期。John将带领后端团队,Sarah负责前端集成。我们还需要与合规团队协调,确保上线前满足所有监管要求。", + "ttft_s": 0.0806, + "total_s": 2.007, + "tps": 31.66, + "prompt_tps": 4360.82, + "eval_tokens": 61, + "prompt_tokens": 197, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C02", + "run": 1, + "input": "Artificial intelligence is transforming the healthcare industry in ways we never imagined. From early disease detection using medical imaging to personalized treatment plans based on genetic data, AI is helping doctors make better decisions faster. However, we must also address the ethical concerns around patient privacy, algorithmic bias, and the potential displacement of healthcare workers.", + "output_raw": "人工智能正在改变医疗保健行业,其程度远超我们曾经的想象。从利用医学影像进行早期疾病检测到基于基因数据的个性化治疗方案,人工智能正帮助医生更快、更明智地做出决策。然而,我们也必须正视患者隐私、算法偏见以及医疗从业者可能被替代等伦理挑战。", + "output_clean": "人工智能正在改变医疗保健行业,其程度远超我们曾经的想象。从利用医学影像进行早期疾病检测到基于基因数据的个性化治疗方案,人工智能正帮助医生更快、更明智地做出决策。然而,我们也必须正视患者隐私、算法偏见以及医疗从业者可能被替代等伦理挑战。", + "reference": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据的个性化治疗方案,AI正在帮助医生更快地做出更好的决策。然而,我们也必须解决围绕患者隐私、算法偏见以及医疗工作者可能被取代等伦理问题。", + "ttft_s": 0.5986, + "total_s": 2.2206, + "tps": 35.76, + "prompt_tps": 328.38, + "eval_tokens": 58, + "prompt_tokens": 184, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C02", + "run": 2, + "input": "Artificial intelligence is transforming the healthcare industry in ways we never imagined. From early disease detection using medical imaging to personalized treatment plans based on genetic data, AI is helping doctors make better decisions faster. However, we must also address the ethical concerns around patient privacy, algorithmic bias, and the potential displacement of healthcare workers.", + "output_raw": "人工智能正以前所未见的方式变革医疗保健行业。从利用医学成像进行早期疾病检测,到基于遗传数据制定个性化治疗方案,AI 帮助医生更快做出更优的决策。然而,我们也必须正视患者隐私、算法偏见以及可能替代医疗从业者的伦理挑战。", + "output_clean": "人工智能正以前所未见的方式变革医疗保健行业。从利用医学成像进行早期疾病检测,到基于遗传数据制定个性化治疗方案,AI 帮助医生更快做出更优的决策。然而,我们也必须正视患者隐私、算法偏见以及可能替代医疗从业者的伦理挑战。", + "reference": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据的个性化治疗方案,AI正在帮助医生更快地做出更好的决策。然而,我们也必须解决围绕患者隐私、算法偏见以及医疗工作者可能被取代等伦理问题。", + "ttft_s": 0.1487, + "total_s": 1.8936, + "tps": 32.66, + "prompt_tps": 1632.05, + "eval_tokens": 57, + "prompt_tokens": 184, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C02", + "run": 3, + "input": "Artificial intelligence is transforming the healthcare industry in ways we never imagined. From early disease detection using medical imaging to personalized treatment plans based on genetic data, AI is helping doctors make better decisions faster. However, we must also address the ethical concerns around patient privacy, algorithmic bias, and the potential displacement of healthcare workers.", + "output_raw": "人工智能正以我们从未想象的方式变革医疗行业:从利用医学影像早期发现疾病,到基于基因数据制定个性化治疗方案,AI 正在帮助医生更快做出更优决策。然而,我们同样必须正视患者隐私保护、算法偏见以及可能取代医疗从业人员的伦理挑战。", + "output_clean": "人工智能正以我们从未想象的方式变革医疗行业:从利用医学影像早期发现疾病,到基于基因数据制定个性化治疗方案,AI 正在帮助医生更快做出更优决策。然而,我们同样必须正视患者隐私保护、算法偏见以及可能取代医疗从业人员的伦理挑战。", + "reference": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据的个性化治疗方案,AI正在帮助医生更快地做出更好的决策。然而,我们也必须解决围绕患者隐私、算法偏见以及医疗工作者可能被取代等伦理问题。", + "ttft_s": 0.0804, + "total_s": 1.8816, + "tps": 32.75, + "prompt_tps": 4051.51, + "eval_tokens": 59, + "prompt_tokens": 184, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "D01", + "run": 1, + "input": "So basically what I'm trying to say is", + "output_raw": "So basically 我想表达的是", + "output_clean": "So basically 我想表达的是", + "reference": "所以基本上我想说的是", + "ttft_s": 0.3323, + "total_s": 0.5009, + "tps": 35.55, + "prompt_tps": 438.22, + "eval_tokens": 6, + "prompt_tokens": 130, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D01", + "run": 2, + "input": "So basically what I'm trying to say is", + "output_raw": "So basically what I'm trying to say is 基本上我要说的是", + "output_clean": "So basically what I'm trying to say is 基本上我要说的是", + "reference": "所以基本上我想说的是", + "ttft_s": 0.1318, + "total_s": 0.4874, + "tps": 36.57, + "prompt_tps": 1331.83, + "eval_tokens": 13, + "prompt_tokens": 130, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D01", + "run": 3, + "input": "So basically what I'm trying to say is", + "output_raw": "所以基本上我想说的是🔤", + "output_clean": "所以基本上我想说的是", + "reference": "所以基本上我想说的是", + "ttft_s": 0.077, + "total_s": 0.2707, + "tps": 36.13, + "prompt_tps": 3119.05, + "eval_tokens": 7, + "prompt_tokens": 130, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D02", + "run": 1, + "input": "The reason we can't do that is because the", + "output_raw": "我们不能这么做的原因是因为", + "output_clean": "我们不能这么做的原因是因为", + "reference": "我们不能那样做的原因是因为", + "ttft_s": 0.2845, + "total_s": 0.4221, + "tps": 36.28, + "prompt_tps": 522.69, + "eval_tokens": 5, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D02", + "run": 2, + "input": "The reason we can't do that is because the", + "output_raw": "我们之所以不能那样做,是因为...", + "output_clean": "我们之所以不能那样做,是因为...", + "reference": "我们不能那样做的原因是因为", + "ttft_s": 0.1294, + "total_s": 0.3448, + "tps": 37.13, + "prompt_tps": 1394.19, + "eval_tokens": 8, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D02", + "run": 3, + "input": "The reason we can't do that is because the", + "output_raw": "我们之所以不能那样做,是因为……", + "output_clean": "我们之所以不能那样做,是因为……", + "reference": "我们不能那样做的原因是因为", + "ttft_s": 0.0743, + "total_s": 0.2927, + "tps": 36.61, + "prompt_tps": 3277.32, + "eval_tokens": 8, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D03", + "run": 1, + "input": "If we look at the data from last", + "output_raw": "如果从最近的数据来看", + "output_clean": "如果从最近的数据来看", + "reference": "如果我们看一下上", + "ttft_s": 0.2966, + "total_s": 0.4372, + "tps": 35.55, + "prompt_tps": 495.17, + "eval_tokens": 5, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D03", + "run": 2, + "input": "If we look at the data from last", + "output_raw": "如果我们看最近的数据", + "output_clean": "如果我们看最近的数据", + "reference": "如果我们看一下上", + "ttft_s": 0.1303, + "total_s": 0.2387, + "tps": 36.89, + "prompt_tps": 1327.27, + "eval_tokens": 4, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D03", + "run": 3, + "input": "If we look at the data from last", + "output_raw": "如果我们查看上次的数据的话", + "output_clean": "如果我们查看上次的数据的话", + "reference": "如果我们看一下上", + "ttft_s": 0.0764, + "total_s": 0.211, + "tps": 37.09, + "prompt_tps": 3134.58, + "eval_tokens": 5, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D04", + "run": 1, + "input": "I mean it's not that we don't want to but", + "output_raw": "我的意思是,并不是我们不想(但)...", + "output_clean": "我的意思是,并不是我们不想(但)...", + "reference": "我的意思是,不是我们不想,但是", + "ttft_s": 0.2814, + "total_s": 0.5543, + "tps": 36.62, + "prompt_tps": 538.16, + "eval_tokens": 10, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D04", + "run": 2, + "input": "I mean it's not that we don't want to but", + "output_raw": "我的意思是,不是我们不想要,而是", + "output_clean": "我的意思是,不是我们不想要,而是", + "reference": "我的意思是,不是我们不想,但是", + "ttft_s": 0.1349, + "total_s": 0.3553, + "tps": 36.37, + "prompt_tps": 1359.42, + "eval_tokens": 8, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D04", + "run": 3, + "input": "I mean it's not that we don't want to but", + "output_raw": "我的意思是,我们并非不愿,而是🔤。", + "output_clean": "我的意思是,我们并非不愿,而是。", + "reference": "我的意思是,不是我们不想,但是", + "ttft_s": 0.0764, + "total_s": 0.4121, + "tps": 35.75, + "prompt_tps": 3152.09, + "eval_tokens": 12, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D05", + "run": 1, + "input": "Well the thing is um we actually", + "output_raw": "嗯,实际上是这样", + "output_clean": "嗯,实际上是这样", + "reference": "嗯,事情是这样的,实际上我们", + "ttft_s": 0.2946, + "total_s": 0.4067, + "tps": 35.6, + "prompt_tps": 494.45, + "eval_tokens": 4, + "prompt_tokens": 128, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D05", + "run": 2, + "input": "Well the thing is um we actually", + "output_raw": "嗯,问题是,我们实际上——", + "output_clean": "嗯,问题是,我们实际上——", + "reference": "嗯,事情是这样的,实际上我们", + "ttft_s": 0.1327, + "total_s": 0.3248, + "tps": 36.43, + "prompt_tps": 1298.55, + "eval_tokens": 7, + "prompt_tokens": 128, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D05", + "run": 3, + "input": "Well the thing is um we actually", + "output_raw": "嗯,其实咱们……", + "output_clean": "嗯,其实咱们……", + "reference": "嗯,事情是这样的,实际上我们", + "ttft_s": 0.0816, + "total_s": 0.2181, + "tps": 36.6, + "prompt_tps": 3044.19, + "eval_tokens": 5, + "prompt_tokens": 128, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "E01", + "run": 1, + "input": "We deployed the new Kubernetes cluster on AWS EKS last Friday.", + "output_raw": "我们于上星期五在 AWS EKS 上部署了新的 Kubernetes 集群。", + "output_clean": "我们于上星期五在 AWS EKS 上部署了新的 Kubernetes 集群。", + "reference": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "ttft_s": 0.309, + "total_s": 0.7831, + "tps": 35.84, + "prompt_tps": 487.03, + "eval_tokens": 17, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 2, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E01", + "run": 2, + "input": "We deployed the new Kubernetes cluster on AWS EKS last Friday.", + "output_raw": "上周五我们已在 AWS EKS 上部署了新的 Kubernetes 集群。", + "output_clean": "上周五我们已在 AWS EKS 上部署了新的 Kubernetes 集群。", + "reference": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "ttft_s": 0.1385, + "total_s": 0.5776, + "tps": 34.15, + "prompt_tps": 1304.91, + "eval_tokens": 15, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 2, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E01", + "run": 3, + "input": "We deployed the new Kubernetes cluster on AWS EKS last Friday.", + "output_raw": "我们在上周五于 AWS EKS 部署了新的 Kubernetes 集群。", + "output_clean": "我们在上周五于 AWS EKS 部署了新的 Kubernetes 集群。", + "reference": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "ttft_s": 0.0805, + "total_s": 0.5011, + "tps": 33.45, + "prompt_tps": 2924.11, + "eval_tokens": 14, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 2, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E02", + "run": 1, + "input": "The React Native app is crashing on iOS seventeen because of a WebSocket timeout issue.", + "output_raw": "React Native 应用因 Web Socket 超时问题在 iOS 17 上崩溃。", + "output_clean": "React Native 应用因 Web Socket 超时问题在 iOS 17 上崩溃。", + "reference": "React Native应用在iOS 17上因为WebSocket超时问题而崩溃。", + "ttft_s": 0.311, + "total_s": 0.8405, + "tps": 35.88, + "prompt_tps": 494.99, + "eval_tokens": 19, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 3, + "missing_terms": [ + "WebSocket" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E02", + "run": 2, + "input": "The React Native app is crashing on iOS seventeen because of a WebSocket timeout issue.", + "output_raw": "React Native 应用在 iOS 17 上因 WebSocket 超时问题而崩溃。", + "output_clean": "React Native 应用在 iOS 17 上因 WebSocket 超时问题而崩溃。", + "reference": "React Native应用在iOS 17上因为WebSocket超时问题而崩溃。", + "ttft_s": 0.1344, + "total_s": 0.6611, + "tps": 34.17, + "prompt_tps": 1367.15, + "eval_tokens": 18, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E02", + "run": 3, + "input": "The React Native app is crashing on iOS seventeen because of a WebSocket timeout issue.", + "output_raw": "React Native 应用在 iOS 17 上因 WebSocket 超时问题而崩溃。", + "output_clean": "React Native 应用在 iOS 17 上因 WebSocket 超时问题而崩溃。", + "reference": "React Native应用在iOS 17上因为WebSocket超时问题而崩溃。", + "ttft_s": 0.0846, + "total_s": 0.6346, + "tps": 32.73, + "prompt_tps": 3086.07, + "eval_tokens": 18, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E03", + "run": 1, + "input": "Apple announced the new M5 chip with improved Neural Engine performance at WWDC.", + "output_raw": "Apple 在 WWDC 上发布了具有改进神经引擎性能的新 M5 芯片。", + "output_clean": "Apple 在 WWDC 上发布了具有改进神经引擎性能的新 M5 芯片。", + "reference": "Apple在WWDC上发布了新的M5芯片,改进了Neural Engine性能。", + "ttft_s": 0.3198, + "total_s": 0.8519, + "tps": 35.69, + "prompt_tps": 481.02, + "eval_tokens": 19, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 4, + "missing_terms": [ + "Neural Engine" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E03", + "run": 2, + "input": "Apple announced the new M5 chip with improved Neural Engine performance at WWDC.", + "output_raw": "Apple 在 WWDC 上发布了新款 M5 芯片,其神经网络引擎性能得到显著提升。", + "output_clean": "Apple 在 WWDC 上发布了新款 M5 芯片,其神经网络引擎性能得到显著提升。", + "reference": "Apple在WWDC上发布了新的M5芯片,改进了Neural Engine性能。", + "ttft_s": 0.1324, + "total_s": 0.7281, + "tps": 35.25, + "prompt_tps": 1399.41, + "eval_tokens": 21, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 4, + "missing_terms": [ + "Neural Engine" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E03", + "run": 3, + "input": "Apple announced the new M5 chip with improved Neural Engine performance at WWDC.", + "output_raw": "苹果在 WWDC 上发布了性能更优的新 M5 芯片,其神经网络引擎得到显著提升。", + "output_clean": "苹果在 WWDC 上发布了性能更优的新 M5 芯片,其神经网络引擎得到显著提升。", + "reference": "Apple在WWDC上发布了新的M5芯片,改进了Neural Engine性能。", + "ttft_s": 0.0793, + "total_s": 0.7463, + "tps": 32.98, + "prompt_tps": 3093.39, + "eval_tokens": 22, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 4, + "missing_terms": [ + "Apple", + "Neural Engine" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E04", + "run": 1, + "input": "We need to implement OAuth two point zero authentication before integrating with the Stripe payment API.", + "output_raw": "我们需要在集成 Stripe 支付 API 之前实施 OAuth 2.0 认证。", + "output_clean": "我们需要在集成 Stripe 支付 API 之前实施 OAuth 2.0 认证。", + "reference": "在集成Stripe支付API之前,我们需要实现OAuth 2.0认证。", + "ttft_s": 0.3169, + "total_s": 0.828, + "tps": 35.2, + "prompt_tps": 489.89, + "eval_tokens": 18, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E04", + "run": 2, + "input": "We need to implement OAuth two point zero authentication before integrating with the Stripe payment API.", + "output_raw": "我们需要在集成 Stripe 支付 API 之前实现 OAuth 2.0 身份验证。", + "output_clean": "我们需要在集成 Stripe 支付 API 之前实现 OAuth 2.0 身份验证。", + "reference": "在集成Stripe支付API之前,我们需要实现OAuth 2.0认证。", + "ttft_s": 0.1341, + "total_s": 0.6797, + "tps": 34.81, + "prompt_tps": 1378.87, + "eval_tokens": 19, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E04", + "run": 3, + "input": "We need to implement OAuth two point zero authentication before integrating with the Stripe payment API.", + "output_raw": "我们需要在集成 Stripe 支付 API 之前实施 OAuth 2.0 身份验证。", + "output_clean": "我们需要在集成 Stripe 支付 API 之前实施 OAuth 2.0 身份验证。", + "reference": "在集成Stripe支付API之前,我们需要实现OAuth 2.0认证。", + "ttft_s": 0.0784, + "total_s": 0.6512, + "tps": 33.17, + "prompt_tps": 3150.1, + "eval_tokens": 19, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E05", + "run": 1, + "input": "The transformer model uses multi-head attention and layer normalization to process the input embeddings.", + "output_raw": "Transformer 模型利用多头注意力和层归一化处理输入嵌入。", + "output_clean": "Transformer 模型利用多头注意力和层归一化处理输入嵌入。", + "reference": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。", + "ttft_s": 0.3111, + "total_s": 0.7307, + "tps": 35.71, + "prompt_tps": 496.46, + "eval_tokens": 15, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 1, + "term_total": 2, + "missing_terms": [ + "multi-head attention" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E05", + "run": 2, + "input": "The transformer model uses multi-head attention and layer normalization to process the input embeddings.", + "output_raw": "Transformer 模型利用多头注意力机制和层归一化处理输入嵌入。", + "output_clean": "Transformer 模型利用多头注意力机制和层归一化处理输入嵌入。", + "reference": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。", + "ttft_s": 0.1355, + "total_s": 0.5927, + "tps": 35.02, + "prompt_tps": 1385.25, + "eval_tokens": 16, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 1, + "term_total": 2, + "missing_terms": [ + "multi-head attention" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E05", + "run": 3, + "input": "The transformer model uses multi-head attention and layer normalization to process the input embeddings.", + "output_raw": "Transformer 模型利用多头注意力和层归一化来处理输入嵌入。", + "output_clean": "Transformer 模型利用多头注意力和层归一化来处理输入嵌入。", + "reference": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。", + "ttft_s": 0.0796, + "total_s": 0.5312, + "tps": 33.21, + "prompt_tps": 3038.53, + "eval_tokens": 15, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 1, + "term_total": 2, + "missing_terms": [ + "multi-head attention" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "F01", + "run": 1, + "input": "Let's start with the first one. How is it going so far?", + "output_raw": "我们首先从第一项开始,目前的进展如何?", + "output_clean": "我们首先从第一项开始,目前的进展如何?", + "reference": "让我们从第一个开始。目前进展如何?", + "ttft_s": 0.7331, + "total_s": 0.987, + "tps": 39.37, + "prompt_tps": 301.88, + "eval_tokens": 10, + "prompt_tokens": 211, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F01", + "run": 2, + "input": "Let's start with the first one. How is it going so far?", + "output_raw": "我们先从第一项开始,目前进展如何?", + "output_clean": "我们先从第一项开始,目前进展如何?", + "reference": "让我们从第一个开始。目前进展如何?", + "ttft_s": 0.1203, + "total_s": 0.3407, + "tps": 40.8, + "prompt_tps": 2508.22, + "eval_tokens": 9, + "prompt_tokens": 211, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F01", + "run": 3, + "input": "Let's start with the first one. How is it going so far?", + "output_raw": "让我们从第一项开始。目前进展如何?", + "output_clean": "让我们从第一项开始。目前进展如何?", + "reference": "让我们从第一个开始。目前进展如何?", + "ttft_s": 0.0719, + "total_s": 0.3036, + "tps": 38.84, + "prompt_tps": 5533.34, + "eval_tokens": 9, + "prompt_tokens": 211, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F02", + "run": 1, + "input": "And what about the second item? When is the design review scheduled?", + "output_raw": "那第二项呢?设计审查定在什么时候?", + "output_clean": "那第二项呢?设计审查定在什么时候?", + "reference": "第二项呢?设计评审安排在什么时候?", + "ttft_s": 0.761, + "total_s": 1.0356, + "tps": 40.01, + "prompt_tps": 289.09, + "eval_tokens": 11, + "prompt_tokens": 210, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F02", + "run": 2, + "input": "And what about the second item? When is the design review scheduled?", + "output_raw": "第二项呢?设计审查定在什么时候?", + "output_clean": "第二项呢?设计审查定在什么时候?", + "reference": "第二项呢?设计评审安排在什么时候?", + "ttft_s": 0.1204, + "total_s": 0.3715, + "tps": 39.8, + "prompt_tps": 2473.42, + "eval_tokens": 10, + "prompt_tokens": 210, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F02", + "run": 3, + "input": "And what about the second item? When is the design review scheduled?", + "output_raw": "那第二项呢?设计审查安排在什么时候?", + "output_clean": "那第二项呢?设计审查安排在什么时候?", + "reference": "第二项呢?设计评审安排在什么时候?", + "ttft_s": 0.0734, + "total_s": 0.3439, + "tps": 36.96, + "prompt_tps": 5306.03, + "eval_tokens": 10, + "prompt_tokens": 210, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "G01", + "run": 1, + "input": "这个方案的可行性还需要进一步评估。", + "output_raw": "The feasibility of this plan requires further evaluation.", + "output_clean": "The feasibility of this plan requires further evaluation.", + "reference": "The feasibility of this plan still needs further evaluation.", + "ttft_s": 0.9595, + "total_s": 1.1854, + "tps": 39.72, + "prompt_tps": 140.01, + "eval_tokens": 9, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G01", + "run": 2, + "input": "这个方案的可行性还需要进一步评估。", + "output_raw": "The feasibility of this proposal still requires further evaluation.", + "output_clean": "The feasibility of this proposal still requires further evaluation.", + "reference": "The feasibility of this plan still needs further evaluation.", + "ttft_s": 0.1203, + "total_s": 0.371, + "tps": 39.92, + "prompt_tps": 1512.65, + "eval_tokens": 10, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G01", + "run": 3, + "input": "这个方案的可行性还需要进一步评估。", + "output_raw": "The feasibility of this proposal still needs further evaluation.", + "output_clean": "The feasibility of this proposal still needs further evaluation.", + "reference": "The feasibility of this plan still needs further evaluation.", + "ttft_s": 0.0757, + "total_s": 0.339, + "tps": 37.79, + "prompt_tps": 3325.0, + "eval_tokens": 10, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G02", + "run": 1, + "input": "我们的日活用户已经突破了五百万。", + "output_raw": "Our daily active users have surpassed five million.", + "output_clean": "Our daily active users have surpassed five million.", + "reference": "Our daily active users have exceeded five million.", + "ttft_s": 0.734, + "total_s": 0.9645, + "tps": 39.03, + "prompt_tps": 187.89, + "eval_tokens": 9, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G02", + "run": 2, + "input": "我们的日活用户已经突破了五百万。", + "output_raw": "Our daily active users have now surpassed five million.", + "output_clean": "Our daily active users have now surpassed five million.", + "reference": "Our daily active users have exceeded five million.", + "ttft_s": 0.123, + "total_s": 0.3772, + "tps": 39.31, + "prompt_tps": 1474.42, + "eval_tokens": 10, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G02", + "run": 3, + "input": "我们的日活用户已经突破了五百万。", + "output_raw": "Our daily active users have surpassed five million.", + "output_clean": "Our daily active users have surpassed five million.", + "reference": "Our daily active users have exceeded five million.", + "ttft_s": 0.0736, + "total_s": 0.3113, + "tps": 37.84, + "prompt_tps": 3338.15, + "eval_tokens": 9, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G03", + "run": 1, + "input": "请大家在周五之前提交各自负责模块的测试报告。", + "output_raw": "Please submit your respective module's test reports before Friday.", + "output_clean": "Please submit your respective module's test reports before Friday.", + "reference": "Please submit the test reports for your respective modules by Friday.", + "ttft_s": 0.2849, + "total_s": 0.5879, + "tps": 36.28, + "prompt_tps": 537.39, + "eval_tokens": 11, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G03", + "run": 2, + "input": "请大家在周五之前提交各自负责模块的测试报告。", + "output_raw": "Please submit your module test reports before Friday.", + "output_clean": "Please submit your module test reports before Friday.", + "reference": "Please submit the test reports for your respective modules by Friday.", + "ttft_s": 0.137, + "total_s": 0.3902, + "tps": 35.62, + "prompt_tps": 1295.32, + "eval_tokens": 9, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G03", + "run": 3, + "input": "请大家在周五之前提交各自负责模块的测试报告。", + "output_raw": "Please submit your test reports for the respective modules you are responsible for before Friday.", + "output_clean": "Please submit your test reports for the respective modules you are responsible for before Friday.", + "reference": "Please submit the test reports for your respective modules by Friday.", + "ttft_s": 0.0785, + "total_s": 0.5491, + "tps": 33.99, + "prompt_tps": 3070.98, + "eval_tokens": 16, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G04", + "run": 1, + "input": "下一步我们要重点关注用户体验的优化,尤其是首屏加载速度。", + "output_raw": "Next, we will focus on optimizing user experience, particularly the initial page load speed.", + "output_clean": "Next, we will focus on optimizing user experience, particularly the initial page load speed.", + "reference": "Next, we need to focus on optimizing user experience, especially the initial screen loading speed.", + "ttft_s": 0.3114, + "total_s": 0.7879, + "tps": 35.67, + "prompt_tps": 492.6, + "eval_tokens": 17, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G04", + "run": 2, + "input": "下一步我们要重点关注用户体验的优化,尤其是首屏加载速度。", + "output_raw": "Next, we will focus on optimizing user experience, especially first-screen loading speed.", + "output_clean": "Next, we will focus on optimizing user experience, especially first-screen loading speed.", + "reference": "Next, we need to focus on optimizing user experience, especially the initial screen loading speed.", + "ttft_s": 0.1358, + "total_s": 0.5942, + "tps": 34.89, + "prompt_tps": 1356.85, + "eval_tokens": 16, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G04", + "run": 3, + "input": "下一步我们要重点关注用户体验的优化,尤其是首屏加载速度。", + "output_raw": "Next, we will focus on improving user experience, especially first-page load speed.", + "output_clean": "Next, we will focus on improving user experience, especially first-page load speed.", + "reference": "Next, we need to focus on optimizing user experience, especially the initial screen loading speed.", + "ttft_s": 0.0799, + "total_s": 0.5672, + "tps": 32.83, + "prompt_tps": 3033.13, + "eval_tokens": 16, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G05", + "run": 1, + "input": "这个bug的根本原因是内存泄漏导致的,已经在最新版本中修复了。", + "output_raw": "The fundamental cause of this bug is a memory leak, which has been resolved in the latest version.", + "output_clean": "The fundamental cause of this bug is a memory leak, which has been resolved in the latest version.", + "reference": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "ttft_s": 0.3501, + "total_s": 0.9286, + "tps": 34.57, + "prompt_tps": 429.95, + "eval_tokens": 20, + "prompt_tokens": 136, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G05", + "run": 2, + "input": "这个bug的根本原因是内存泄漏导致的,已经在最新版本中修复了。", + "output_raw": "The root cause of this bug is a memory leak, which has been fixed in the latest version.", + "output_clean": "The root cause of this bug is a memory leak, which has been fixed in the latest version.", + "reference": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "ttft_s": 0.1403, + "total_s": 0.7224, + "tps": 34.35, + "prompt_tps": 1310.89, + "eval_tokens": 20, + "prompt_tokens": 136, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G05", + "run": 3, + "input": "这个bug的根本原因是内存泄漏导致的,已经在最新版本中修复了。", + "output_raw": "The root cause of this bug is a memory leak, which has been fixed in the latest release.", + "output_clean": "The root cause of this bug is a memory leak, which has been fixed in the latest release.", + "reference": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "ttft_s": 0.0792, + "total_s": 0.6717, + "tps": 33.75, + "prompt_tps": 3066.33, + "eval_tokens": 20, + "prompt_tokens": 136, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "H01", + "run": 1, + "input": "Thank you for joining today's meeting.", + "output_raw": "今日の会議に参加していただき、ありがとうございます。", + "output_clean": "今日の会議に参加していただき、ありがとうございます。", + "reference": "本日の会議にご参加いただきありがとうございます。", + "ttft_s": 0.7499, + "total_s": 0.9581, + "tps": 38.41, + "prompt_tps": 180.42, + "eval_tokens": 8, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H01", + "run": 2, + "input": "Thank you for joining today's meeting.", + "output_raw": "今日はご出席いただきありがとうございます。", + "output_clean": "今日はご出席いただきありがとうございます。", + "reference": "本日の会議にご参加いただきありがとうございます。", + "ttft_s": 0.1226, + "total_s": 0.272, + "tps": 40.07, + "prompt_tps": 1465.88, + "eval_tokens": 6, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H01", + "run": 3, + "input": "Thank you for joining today's meeting.", + "output_raw": "今日の会議に御出席いただき、ありがとうございます。", + "output_clean": "今日の会議に御出席いただき、ありがとうございます。", + "reference": "本日の会議にご参加いただきありがとうございます。", + "ttft_s": 0.0706, + "total_s": 0.3238, + "tps": 39.46, + "prompt_tps": 3478.25, + "eval_tokens": 10, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H02", + "run": 1, + "input": "The deadline for this project is the end of next month.", + "output_raw": "このプロジェクトの期限は、来月の終わりでございます。", + "output_clean": "このプロジェクトの期限は、来月の終わりでございます。", + "reference": "このプロジェクトの締め切りは来月末です。", + "ttft_s": 0.7109, + "total_s": 1.0191, + "tps": 38.92, + "prompt_tps": 196.95, + "eval_tokens": 12, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H02", + "run": 2, + "input": "The deadline for this project is the end of next month.", + "output_raw": "このプロジェクトの期限は来月末です。", + "output_clean": "このプロジェクトの期限は来月末です。", + "reference": "このプロジェクトの締め切りは来月末です。", + "ttft_s": 0.1256, + "total_s": 0.3839, + "tps": 38.7, + "prompt_tps": 1497.31, + "eval_tokens": 10, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H02", + "run": 3, + "input": "The deadline for this project is the end of next month.", + "output_raw": "このプロジェクトのデッドラインは来月の末です。", + "output_clean": "このプロジェクトのデッドラインは来月の末です。", + "reference": "このプロジェクトの締め切りは来月末です。", + "ttft_s": 0.0837, + "total_s": 0.4105, + "tps": 36.71, + "prompt_tps": 3307.67, + "eval_tokens": 12, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H03", + "run": 1, + "input": "We need to reconsider our marketing strategy for the Asian market.", + "output_raw": "アジア市場におけるマーケティング戦略を見直し直す必要があります。", + "output_clean": "アジア市場におけるマーケティング戦略を見直し直す必要があります。", + "reference": "アジア市場向けのマーケティング戦略を再検討する必要があります。", + "ttft_s": 0.3019, + "total_s": 0.6115, + "tps": 35.53, + "prompt_tps": 500.37, + "eval_tokens": 11, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H03", + "run": 2, + "input": "We need to reconsider our marketing strategy for the Asian market.", + "output_raw": "アジア市場向けのマーケティング戦略を見直す必要があります。", + "output_clean": "アジア市場向けのマーケティング戦略を見直す必要があります。", + "reference": "アジア市場向けのマーケティング戦略を再検討する必要があります。", + "ttft_s": 0.1429, + "total_s": 0.3937, + "tps": 35.87, + "prompt_tps": 1263.57, + "eval_tokens": 9, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H03", + "run": 3, + "input": "We need to reconsider our marketing strategy for the Asian market.", + "output_raw": "アジア市場向けのマーケティング戦略を見直す必要があります。", + "output_clean": "アジア市場向けのマーケティング戦略を見直す必要があります。", + "reference": "アジア市場向けのマーケティング戦略を再検討する必要があります。", + "ttft_s": 0.079, + "total_s": 0.3346, + "tps": 35.21, + "prompt_tps": 3139.43, + "eval_tokens": 9, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "I01", + "run": 1, + "input": "Hello", + "output_raw": "你好", + "output_clean": "你好", + "reference": "你好", + "ttft_s": 0.2975, + "total_s": 0.3261, + "tps": 34.6, + "prompt_tps": 462.75, + "eval_tokens": 1, + "prompt_tokens": 122, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I01", + "run": 2, + "input": "Hello", + "output_raw": "你好", + "output_clean": "你好", + "reference": "你好", + "ttft_s": 0.1389, + "total_s": 0.1656, + "tps": 37.22, + "prompt_tps": 1182.33, + "eval_tokens": 1, + "prompt_tokens": 122, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I01", + "run": 3, + "input": "Hello", + "output_raw": "你好。", + "output_clean": "你好。", + "reference": "你好", + "ttft_s": 0.0757, + "total_s": 0.1277, + "tps": 37.98, + "prompt_tps": 2955.76, + "eval_tokens": 2, + "prompt_tokens": 122, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I02", + "run": 1, + "input": "Thank you", + "output_raw": "谢谢", + "output_clean": "谢谢", + "reference": "谢谢", + "ttft_s": 0.2722, + "total_s": 0.2992, + "tps": 36.87, + "prompt_tps": 520.49, + "eval_tokens": 1, + "prompt_tokens": 123, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I02", + "run": 2, + "input": "Thank you", + "output_raw": "谢谢。", + "output_clean": "谢谢。", + "reference": "谢谢", + "ttft_s": 0.1349, + "total_s": 0.1886, + "tps": 37.13, + "prompt_tps": 1245.99, + "eval_tokens": 2, + "prompt_tokens": 123, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I02", + "run": 3, + "input": "Thank you", + "output_raw": "谢谢。", + "output_clean": "谢谢。", + "reference": "谢谢", + "ttft_s": 0.0772, + "total_s": 0.1283, + "tps": 38.97, + "prompt_tps": 3144.52, + "eval_tokens": 2, + "prompt_tokens": 123, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I03", + "run": 1, + "input": "I don't understand", + "output_raw": "我不明白", + "output_clean": "我不明白", + "reference": "我不明白", + "ttft_s": 0.2776, + "total_s": 0.3299, + "tps": 38.15, + "prompt_tps": 511.86, + "eval_tokens": 2, + "prompt_tokens": 125, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I03", + "run": 2, + "input": "I don't understand", + "output_raw": "我不明白", + "output_clean": "我不明白", + "reference": "我不明白", + "ttft_s": 0.1353, + "total_s": 0.1862, + "tps": 39.11, + "prompt_tps": 1262.82, + "eval_tokens": 2, + "prompt_tokens": 125, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I03", + "run": 3, + "input": "I don't understand", + "output_raw": "我不懂。", + "output_clean": "我不懂。", + "reference": "我不明白", + "ttft_s": 0.0724, + "total_s": 0.1493, + "tps": 38.93, + "prompt_tps": 3207.26, + "eval_tokens": 3, + "prompt_tokens": 125, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I04", + "run": 1, + "input": "Can you help me?", + "output_raw": "能帮帮我吗?", + "output_clean": "能帮帮我吗?", + "reference": "你能帮我吗?", + "ttft_s": 0.2702, + "total_s": 0.4042, + "tps": 37.28, + "prompt_tps": 538.83, + "eval_tokens": 5, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I04", + "run": 2, + "input": "Can you help me?", + "output_raw": "需要帮助吗?", + "output_clean": "需要帮助吗?", + "reference": "你能帮我吗?", + "ttft_s": 0.1409, + "total_s": 0.2471, + "tps": 37.62, + "prompt_tps": 1216.42, + "eval_tokens": 4, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I04", + "run": 3, + "input": "Can you help me?", + "output_raw": "能帮我吗?", + "output_clean": "能帮我吗?", + "reference": "你能帮我吗?", + "ttft_s": 0.077, + "total_s": 0.1816, + "tps": 38.17, + "prompt_tps": 3183.5, + "eval_tokens": 4, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I05", + "run": 1, + "input": "What is machine learning?", + "output_raw": "什么是机器学习?", + "output_clean": "什么是机器学习?", + "reference": "什么是机器学习?", + "ttft_s": 0.2744, + "total_s": 0.3533, + "tps": 37.96, + "prompt_tps": 531.06, + "eval_tokens": 3, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I05", + "run": 2, + "input": "What is machine learning?", + "output_raw": "什么是机器学习?", + "output_clean": "什么是机器学习?", + "reference": "什么是机器学习?", + "ttft_s": 0.1369, + "total_s": 0.2153, + "tps": 38.22, + "prompt_tps": 1260.63, + "eval_tokens": 3, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I05", + "run": 3, + "input": "What is machine learning?", + "output_raw": "机器学习是什么?", + "output_clean": "机器学习是什么?", + "reference": "什么是机器学习?", + "ttft_s": 0.0735, + "total_s": 0.1521, + "tps": 38.13, + "prompt_tps": 3142.73, + "eval_tokens": 3, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + } + ], + "translategemma:4b-it-q4_K_M": [ + { + "case_id": "A01", + "run": 1, + "input": "Let's get started.", + "output_raw": "让我们开始吧。", + "output_clean": "让我们开始吧。", + "reference": "让我们开始吧。", + "ttft_s": 0.8462, + "total_s": 0.9313, + "tps": 58.76, + "prompt_tps": 285.78, + "eval_tokens": 5, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A01", + "run": 2, + "input": "Let's get started.", + "output_raw": "让我们开始吧。\n", + "output_clean": "让我们开始吧。", + "reference": "让我们开始吧。", + "ttft_s": 0.6564, + "total_s": 0.7588, + "tps": 58.55, + "prompt_tps": 476.2, + "eval_tokens": 6, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A01", + "run": 3, + "input": "Let's get started.", + "output_raw": "我们现在开始吧。\n", + "output_clean": "我们现在开始吧。", + "reference": "让我们开始吧。", + "ttft_s": 0.6606, + "total_s": 0.7849, + "tps": 56.29, + "prompt_tps": 473.83, + "eval_tokens": 7, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A02", + "run": 1, + "input": "Can you hear me?", + "output_raw": "您好吗?\n", + "output_clean": "您好吗?", + "reference": "你能听到我吗?", + "ttft_s": 0.6559, + "total_s": 0.7576, + "tps": 58.95, + "prompt_tps": 474.75, + "eval_tokens": 6, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A02", + "run": 2, + "input": "Can you hear me?", + "output_raw": "您能听到我吗?\n", + "output_clean": "您能听到我吗?", + "reference": "你能听到我吗?", + "ttft_s": 0.6624, + "total_s": 0.806, + "tps": 55.68, + "prompt_tps": 464.97, + "eval_tokens": 8, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A02", + "run": 3, + "input": "Can you hear me?", + "output_raw": "您好吗?\n", + "output_clean": "您好吗?", + "reference": "你能听到我吗?", + "ttft_s": 0.6586, + "total_s": 0.7605, + "tps": 58.79, + "prompt_tps": 471.45, + "eval_tokens": 6, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A03", + "run": 1, + "input": "That's a great point.", + "output_raw": "确实是一个重要观点。", + "output_clean": "确实是一个重要观点。", + "reference": "说得很好。", + "ttft_s": 0.9027, + "total_s": 1.004, + "tps": 59.07, + "prompt_tps": 256.28, + "eval_tokens": 6, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A03", + "run": 2, + "input": "That's a great point.", + "output_raw": "这确实是个重要观点。\n", + "output_clean": "这确实是个重要观点。", + "reference": "说得很好。", + "ttft_s": 0.6527, + "total_s": 0.7948, + "tps": 56.28, + "prompt_tps": 489.66, + "eval_tokens": 8, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A03", + "run": 3, + "input": "That's a great point.", + "output_raw": "这确实是一个重要观点。\n", + "output_clean": "这确实是一个重要观点。", + "reference": "说得很好。", + "ttft_s": 0.6561, + "total_s": 0.7992, + "tps": 55.88, + "prompt_tps": 484.36, + "eval_tokens": 8, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A04", + "run": 1, + "input": "I'll share my screen.", + "output_raw": "我将分享我的屏幕。", + "output_clean": "我将分享我的屏幕。", + "reference": "我来共享一下屏幕。", + "ttft_s": 0.656, + "total_s": 0.7789, + "tps": 56.89, + "prompt_tps": 482.13, + "eval_tokens": 7, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A04", + "run": 2, + "input": "I'll share my screen.", + "output_raw": "我将分享我的屏幕。\n", + "output_clean": "我将分享我的屏幕。", + "reference": "我来共享一下屏幕。", + "ttft_s": 0.6614, + "total_s": 0.8047, + "tps": 55.79, + "prompt_tps": 480.13, + "eval_tokens": 8, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A04", + "run": 3, + "input": "I'll share my screen.", + "output_raw": "我将分享我的屏幕。", + "output_clean": "我将分享我的屏幕。", + "reference": "我来共享一下屏幕。", + "ttft_s": 0.6569, + "total_s": 0.7796, + "tps": 57.1, + "prompt_tps": 487.05, + "eval_tokens": 7, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A05", + "run": 1, + "input": "Any questions so far?", + "output_raw": "大家有什么问题吗?", + "output_clean": "大家有什么问题吗?", + "reference": "到目前为止有什么问题吗?", + "ttft_s": 0.6496, + "total_s": 0.7511, + "tps": 59.06, + "prompt_tps": 487.44, + "eval_tokens": 6, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A05", + "run": 2, + "input": "Any questions so far?", + "output_raw": "大家有什么问题吗?", + "output_clean": "大家有什么问题吗?", + "reference": "到目前为止有什么问题吗?", + "ttft_s": 0.6504, + "total_s": 0.7518, + "tps": 59.16, + "prompt_tps": 485.65, + "eval_tokens": 6, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A05", + "run": 3, + "input": "Any questions so far?", + "output_raw": "大家有什么问题吗?", + "output_clean": "大家有什么问题吗?", + "reference": "到目前为止有什么问题吗?", + "ttft_s": 0.6612, + "total_s": 0.7638, + "tps": 58.42, + "prompt_tps": 473.11, + "eval_tokens": 6, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "B01", + "run": 1, + "input": "I think we should postpone the launch to next quarter because the QA team hasn't finished testing yet.", + "output_raw": "我认为我们应该推迟发布到下个季度,因为质量保证团队还没有完成测试。\n", + "output_clean": "我认为我们应该推迟发布到下个季度,因为质量保证团队还没有完成测试。", + "reference": "我认为我们应该把发布推迟到下个季度,因为QA团队还没有完成测试。", + "ttft_s": 0.7171, + "total_s": 1.1192, + "tps": 52.22, + "prompt_tps": 441.87, + "eval_tokens": 21, + "prompt_tokens": 149, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B01", + "run": 2, + "input": "I think we should postpone the launch to next quarter because the QA team hasn't finished testing yet.", + "output_raw": "我认为我们应该推迟发布至下个季度,因为质量保证团队尚未完成测试。\n", + "output_clean": "我认为我们应该推迟发布至下个季度,因为质量保证团队尚未完成测试。", + "reference": "我认为我们应该把发布推迟到下个季度,因为QA团队还没有完成测试。", + "ttft_s": 0.7232, + "total_s": 1.135, + "tps": 51.0, + "prompt_tps": 429.59, + "eval_tokens": 21, + "prompt_tokens": 149, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B01", + "run": 3, + "input": "I think we should postpone the launch to next quarter because the QA team hasn't finished testing yet.", + "output_raw": "我认为我们应该推迟发布到下个季度,因为质量保证团队尚未完成测试。", + "output_clean": "我认为我们应该推迟发布到下个季度,因为质量保证团队尚未完成测试。", + "reference": "我认为我们应该把发布推迟到下个季度,因为QA团队还没有完成测试。", + "ttft_s": 0.7516, + "total_s": 1.1686, + "tps": 47.95, + "prompt_tps": 397.48, + "eval_tokens": 20, + "prompt_tokens": 149, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B02", + "run": 1, + "input": "The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why.", + "output_raw": "用户留存率相较上个月下降了百分之十五,我们需要找出原因。\n", + "output_clean": "用户留存率相较上个月下降了百分之十五,我们需要找出原因。", + "reference": "用户留存率与上个月相比下降了百分之十五,我们需要找出原因。", + "ttft_s": 0.7715, + "total_s": 1.1985, + "tps": 46.84, + "prompt_tps": 377.26, + "eval_tokens": 20, + "prompt_tokens": 149, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B02", + "run": 2, + "input": "The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why.", + "output_raw": "用户留存率相较上个月下降了百分之十五,我们需要找出原因。", + "output_clean": "用户留存率相较上个月下降了百分之十五,我们需要找出原因。", + "reference": "用户留存率与上个月相比下降了百分之十五,我们需要找出原因。", + "ttft_s": 0.7744, + "total_s": 1.175, + "tps": 47.41, + "prompt_tps": 375.48, + "eval_tokens": 19, + "prompt_tokens": 149, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B02", + "run": 3, + "input": "The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why.", + "output_raw": "用户留存率与上个月相比下降了百分之十五,我们需要找出原因。\n", + "output_clean": "用户留存率与上个月相比下降了百分之十五,我们需要找出原因。", + "reference": "用户留存率与上个月相比下降了百分之十五,我们需要找出原因。", + "ttft_s": 0.7636, + "total_s": 1.1844, + "tps": 47.52, + "prompt_tps": 384.32, + "eval_tokens": 20, + "prompt_tokens": 149, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B03", + "run": 1, + "input": "We're planning to migrate our infrastructure from AWS to Google Cloud by the end of this year.", + "output_raw": "我们计划在今年年底将我们的基础设施从AWS迁移到Google Cloud。\n", + "output_clean": "我们计划在今年年底将我们的基础设施从AWS迁移到Google Cloud。", + "reference": "我们计划在今年年底前将基础设施从AWS迁移到Google Cloud。", + "ttft_s": 0.7711, + "total_s": 1.1272, + "tps": 47.73, + "prompt_tps": 375.88, + "eval_tokens": 17, + "prompt_tokens": 148, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B03", + "run": 2, + "input": "We're planning to migrate our infrastructure from AWS to Google Cloud by the end of this year.", + "output_raw": "我们计划在今年年底将我们的基础设施从AWS迁移到Google Cloud。\n", + "output_clean": "我们计划在今年年底将我们的基础设施从AWS迁移到Google Cloud。", + "reference": "我们计划在今年年底前将基础设施从AWS迁移到Google Cloud。", + "ttft_s": 0.761, + "total_s": 1.1109, + "tps": 48.59, + "prompt_tps": 385.39, + "eval_tokens": 17, + "prompt_tokens": 148, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B03", + "run": 3, + "input": "We're planning to migrate our infrastructure from AWS to Google Cloud by the end of this year.", + "output_raw": "我们计划在今年年底将我们的基础设施从AWS迁移到谷歌云。\n", + "output_clean": "我们计划在今年年底将我们的基础设施从AWS迁移到谷歌云。", + "reference": "我们计划在今年年底前将基础设施从AWS迁移到Google Cloud。", + "ttft_s": 0.7604, + "total_s": 1.115, + "tps": 47.94, + "prompt_tps": 387.59, + "eval_tokens": 17, + "prompt_tokens": 148, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B04", + "run": 1, + "input": "The new privacy policy requires us to obtain explicit user consent before collecting any personal data.", + "output_raw": "新隐私政策要求我们在使用任何个人数据之前,必须明确获得用户的同意。", + "output_clean": "新隐私政策要求我们在使用任何个人数据之前,必须明确获得用户的同意。", + "reference": "新的隐私政策要求我们在收集任何个人数据之前获得用户的明确同意。", + "ttft_s": 0.7623, + "total_s": 1.1336, + "tps": 48.48, + "prompt_tps": 373.94, + "eval_tokens": 18, + "prompt_tokens": 145, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B04", + "run": 2, + "input": "The new privacy policy requires us to obtain explicit user consent before collecting any personal data.", + "output_raw": "新的隐私政策要求我们在使用任何个人数据之前,必须获得明确的用户同意。", + "output_clean": "新的隐私政策要求我们在使用任何个人数据之前,必须获得明确的用户同意。", + "reference": "新的隐私政策要求我们在收集任何个人数据之前获得用户的明确同意。", + "ttft_s": 0.7593, + "total_s": 1.1301, + "tps": 48.54, + "prompt_tps": 379.2, + "eval_tokens": 18, + "prompt_tokens": 145, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B04", + "run": 3, + "input": "The new privacy policy requires us to obtain explicit user consent before collecting any personal data.", + "output_raw": "新的隐私政策要求我们在使用任何个人数据之前,必须明确获得用户的同意。", + "output_clean": "新的隐私政策要求我们在使用任何个人数据之前,必须明确获得用户的同意。", + "reference": "新的隐私政策要求我们在收集任何个人数据之前获得用户的明确同意。", + "ttft_s": 0.7561, + "total_s": 1.1363, + "tps": 47.34, + "prompt_tps": 381.19, + "eval_tokens": 18, + "prompt_tokens": 145, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B05", + "run": 1, + "input": "Could you walk us through the architecture diagram? I want to make sure everyone is on the same page.", + "output_raw": "您能向我们介绍一下架构图吗?我想确保大家对这个架构有相同的理解。", + "output_clean": "您能向我们介绍一下架构图吗?我想确保大家对这个架构有相同的理解。", + "reference": "你能给我们讲一下架构图吗?我想确保大家的理解是一致的。", + "ttft_s": 0.772, + "total_s": 1.2116, + "tps": 47.78, + "prompt_tps": 379.17, + "eval_tokens": 21, + "prompt_tokens": 149, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B05", + "run": 2, + "input": "Could you walk us through the architecture diagram? I want to make sure everyone is on the same page.", + "output_raw": "您能向大家介绍一下架构图吗?我想确保大家理解一致。", + "output_clean": "您能向大家介绍一下架构图吗?我想确保大家理解一致。", + "reference": "你能给我们讲一下架构图吗?我想确保大家的理解是一致的。", + "ttft_s": 0.7662, + "total_s": 1.1212, + "tps": 47.89, + "prompt_tps": 380.95, + "eval_tokens": 17, + "prompt_tokens": 149, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B05", + "run": 3, + "input": "Could you walk us through the architecture diagram? I want to make sure everyone is on the same page.", + "output_raw": "您能向我们介绍一下架构图吗?我希望确保大家对架构都有相同的理解。\n", + "output_clean": "您能向我们介绍一下架构图吗?我希望确保大家对架构都有相同的理解。", + "reference": "你能给我们讲一下架构图吗?我想确保大家的理解是一致的。", + "ttft_s": 0.7657, + "total_s": 1.2374, + "tps": 46.63, + "prompt_tps": 382.3, + "eval_tokens": 22, + "prompt_tokens": 149, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "C01", + "run": 1, + "input": "So let me summarize today's discussion. We agreed to move forward with option B, which involves rebuilding the payment module from scratch. The timeline is eight weeks, with the first milestone due in three weeks. John will lead the backend team, and Sarah will handle the frontend integration. We also need to coordinate with the compliance team to ensure we meet all regulatory requirements before going live.", + "output_raw": "所以,我想总结一下今天的讨论。我们同意采用方案 B,即从头开始重建支付模块。时间表为八周,第一个里程碑将在三周后完成。约翰将负责后端团队,莎拉将负责前端集成。此外,我们需要与合规团队协调,以确保在上线前满足所有监管要求。", + "output_clean": "所以,我想总结一下今天的讨论。我们同意采用方案 B,即从头开始重建支付模块。时间表为八周,第一个里程碑将在三周后完成。约翰将负责后端团队,莎拉将负责前端集成。此外,我们需要与合规团队协调,以确保在上线前满足所有监管要求。", + "reference": "让我来总结一下今天的讨论。我们同意推进方案B,即从零开始重建支付模块。时间线是八周,第一个里程碑在三周内到期。John将带领后端团队,Sarah负责前端集成。我们还需要与合规团队协调,确保上线前满足所有监管要求。", + "ttft_s": 0.9204, + "total_s": 2.8419, + "tps": 36.43, + "prompt_tps": 378.53, + "eval_tokens": 70, + "prompt_tokens": 205, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C01", + "run": 2, + "input": "So let me summarize today's discussion. We agreed to move forward with option B, which involves rebuilding the payment module from scratch. The timeline is eight weeks, with the first milestone due in three weeks. John will lead the backend team, and Sarah will handle the frontend integration. We also need to coordinate with the compliance team to ensure we meet all regulatory requirements before going live.", + "output_raw": "好的,我来总结一下今天的讨论。我们一致同意采用方案 B,即从头开始重构支付模块。时间表为八周,第一阶段的里程碑将在三周内完成。约翰将负责后端团队,莎拉负责前端集成。此外,我们也需要与合规团队协调,以确保在上线前满足所有监管要求。\n", + "output_clean": "好的,我来总结一下今天的讨论。我们一致同意采用方案 B,即从头开始重构支付模块。时间表为八周,第一阶段的里程碑将在三周内完成。约翰将负责后端团队,莎拉负责前端集成。此外,我们也需要与合规团队协调,以确保在上线前满足所有监管要求。", + "reference": "让我来总结一下今天的讨论。我们同意推进方案B,即从零开始重建支付模块。时间线是八周,第一个里程碑在三周内到期。John将带领后端团队,Sarah负责前端集成。我们还需要与合规团队协调,确保上线前满足所有监管要求。", + "ttft_s": 0.9575, + "total_s": 3.0544, + "tps": 36.25, + "prompt_tps": 354.8, + "eval_tokens": 76, + "prompt_tokens": 205, + "instruction_compliant": false, + "violation_detail": "包含违规前缀: '好的...'", + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C01", + "run": 3, + "input": "So let me summarize today's discussion. We agreed to move forward with option B, which involves rebuilding the payment module from scratch. The timeline is eight weeks, with the first milestone due in three weeks. John will lead the backend team, and Sarah will handle the frontend integration. We also need to coordinate with the compliance team to ensure we meet all regulatory requirements before going live.", + "output_raw": "所以,我想总结一下今天的讨论。我们同意采用方案 B,即从头开始重构支付模块。时间表为八周,第一个里程碑将在三周后完成。约翰将负责后端团队,莎拉则负责前端集成。此外,我们也需要与合规团队协调,以确保在上线前满足所有监管要求。", + "output_clean": "所以,我想总结一下今天的讨论。我们同意采用方案 B,即从头开始重构支付模块。时间表为八周,第一个里程碑将在三周后完成。约翰将负责后端团队,莎拉则负责前端集成。此外,我们也需要与合规团队协调,以确保在上线前满足所有监管要求。", + "reference": "让我来总结一下今天的讨论。我们同意推进方案B,即从零开始重建支付模块。时间线是八周,第一个里程碑在三周内到期。John将带领后端团队,Sarah负责前端集成。我们还需要与合规团队协调,确保上线前满足所有监管要求。", + "ttft_s": 0.9476, + "total_s": 2.911, + "tps": 36.67, + "prompt_tps": 361.02, + "eval_tokens": 72, + "prompt_tokens": 205, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C02", + "run": 1, + "input": "Artificial intelligence is transforming the healthcare industry in ways we never imagined. From early disease detection using medical imaging to personalized treatment plans based on genetic data, AI is helping doctors make better decisions faster. However, we must also address the ethical concerns around patient privacy, algorithmic bias, and the potential displacement of healthcare workers.", + "output_raw": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测到基于基因数据的个性化治疗方案,人工智能正在帮助医生更快地做出更好的决策。然而,我们也必须解决患者隐私、算法偏见以及潜在的医疗保健工作者失业等伦理问题。\n", + "output_clean": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测到基于基因数据的个性化治疗方案,人工智能正在帮助医生更快地做出更好的决策。然而,我们也必须解决患者隐私、算法偏见以及潜在的医疗保健工作者失业等伦理问题。", + "reference": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据的个性化治疗方案,AI正在帮助医生更快地做出更好的决策。然而,我们也必须解决围绕患者隐私、算法偏见以及医疗工作者可能被取代等伦理问题。", + "ttft_s": 0.8904, + "total_s": 2.648, + "tps": 37.55, + "prompt_tps": 374.19, + "eval_tokens": 66, + "prompt_tokens": 190, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C02", + "run": 2, + "input": "Artificial intelligence is transforming the healthcare industry in ways we never imagined. From early disease detection using medical imaging to personalized treatment plans based on genetic data, AI is helping doctors make better decisions faster. However, we must also address the ethical concerns around patient privacy, algorithmic bias, and the potential displacement of healthcare workers.", + "output_raw": "人工智能正在以我们从未想象过的方式改变医疗行业。从使用医学影像进行早期疾病检测到基于基因数据的个性化治疗方案,人工智能帮助医生更快地做出更好的决策。然而,我们也必须解决与患者隐私、算法偏见以及可能导致医护人员失业相关的伦理问题。", + "output_clean": "人工智能正在以我们从未想象过的方式改变医疗行业。从使用医学影像进行早期疾病检测到基于基因数据的个性化治疗方案,人工智能帮助医生更快地做出更好的决策。然而,我们也必须解决与患者隐私、算法偏见以及可能导致医护人员失业相关的伦理问题。", + "reference": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据的个性化治疗方案,AI正在帮助医生更快地做出更好的决策。然而,我们也必须解决围绕患者隐私、算法偏见以及医疗工作者可能被取代等伦理问题。", + "ttft_s": 0.8899, + "total_s": 2.5871, + "tps": 37.71, + "prompt_tps": 373.41, + "eval_tokens": 64, + "prompt_tokens": 190, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C02", + "run": 3, + "input": "Artificial intelligence is transforming the healthcare industry in ways we never imagined. From early disease detection using medical imaging to personalized treatment plans based on genetic data, AI is helping doctors make better decisions faster. However, we must also address the ethical concerns around patient privacy, algorithmic bias, and the potential displacement of healthcare workers.", + "output_raw": "人工智能正在以我们从未想象过的方式改变医疗行业。从使用医学影像进行早期疾病检测到基于基因数据的个性化治疗方案,人工智能帮助医生更快地做出更好的决策。然而,我们也必须解决关于患者隐私、算法偏见以及医疗保健人员可能被取代的伦理问题。", + "output_clean": "人工智能正在以我们从未想象过的方式改变医疗行业。从使用医学影像进行早期疾病检测到基于基因数据的个性化治疗方案,人工智能帮助医生更快地做出更好的决策。然而,我们也必须解决关于患者隐私、算法偏见以及医疗保健人员可能被取代的伦理问题。", + "reference": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据的个性化治疗方案,AI正在帮助医生更快地做出更好的决策。然而,我们也必须解决围绕患者隐私、算法偏见以及医疗工作者可能被取代等伦理问题。", + "ttft_s": 0.827, + "total_s": 2.5985, + "tps": 35.57, + "prompt_tps": 425.26, + "eval_tokens": 63, + "prompt_tokens": 190, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "D01", + "run": 1, + "input": "So basically what I'm trying to say is", + "output_raw": "基本上,我想表达的是:\n", + "output_clean": "基本上,我想表达的是:", + "reference": "所以基本上我想说的是", + "ttft_s": 0.808, + "total_s": 0.964, + "tps": 51.26, + "prompt_tps": 325.92, + "eval_tokens": 8, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D01", + "run": 2, + "input": "So basically what I'm trying to say is", + "output_raw": "基本上,我想说的是:", + "output_clean": "基本上,我想说的是:", + "reference": "所以基本上我想说的是", + "ttft_s": 0.7307, + "total_s": 0.8591, + "tps": 54.49, + "prompt_tps": 390.68, + "eval_tokens": 7, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D01", + "run": 3, + "input": "So basically what I'm trying to say is", + "output_raw": "总而言之,我想表达的是:", + "output_clean": "总而言之,我想表达的是:", + "reference": "所以基本上我想说的是", + "ttft_s": 0.7283, + "total_s": 0.8965, + "tps": 53.51, + "prompt_tps": 394.77, + "eval_tokens": 9, + "prompt_tokens": 138, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D02", + "run": 1, + "input": "The reason we can't do that is because the", + "output_raw": "原因是,我们无法那样做。\n", + "output_clean": "原因是,我们无法那样做。", + "reference": "我们不能那样做的原因是因为", + "ttft_s": 0.7272, + "total_s": 0.8943, + "tps": 53.86, + "prompt_tps": 394.44, + "eval_tokens": 9, + "prompt_tokens": 139, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D02", + "run": 2, + "input": "The reason we can't do that is because the", + "output_raw": "原因是,我们无法那样做", + "output_clean": "原因是,我们无法那样做", + "reference": "我们不能那样做的原因是因为", + "ttft_s": 0.7703, + "total_s": 0.8945, + "tps": 56.25, + "prompt_tps": 355.21, + "eval_tokens": 7, + "prompt_tokens": 139, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D02", + "run": 3, + "input": "The reason we can't do that is because the", + "output_raw": "之所以我们无法这样做,是因为……", + "output_clean": "之所以我们无法这样做,是因为……", + "reference": "我们不能那样做的原因是因为", + "ttft_s": 0.7131, + "total_s": 0.8552, + "tps": 56.28, + "prompt_tps": 413.05, + "eval_tokens": 8, + "prompt_tokens": 139, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D03", + "run": 1, + "input": "If we look at the data from last", + "output_raw": "如果我们看一下上一次的数据", + "output_clean": "如果我们看一下上一次的数据", + "reference": "如果我们看一下上", + "ttft_s": 0.7115, + "total_s": 0.8141, + "tps": 58.46, + "prompt_tps": 404.57, + "eval_tokens": 6, + "prompt_tokens": 136, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D03", + "run": 2, + "input": "If we look at the data from last", + "output_raw": "如果我们回顾一下上一次的数据", + "output_clean": "如果我们回顾一下上一次的数据", + "reference": "如果我们看一下上", + "ttft_s": 0.7136, + "total_s": 0.837, + "tps": 56.74, + "prompt_tps": 405.76, + "eval_tokens": 7, + "prompt_tokens": 136, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D03", + "run": 3, + "input": "If we look at the data from last", + "output_raw": "如果我们回顾一下上一次的数据", + "output_clean": "如果我们回顾一下上一次的数据", + "reference": "如果我们看一下上", + "ttft_s": 0.7217, + "total_s": 0.8447, + "tps": 56.89, + "prompt_tps": 395.58, + "eval_tokens": 7, + "prompt_tokens": 136, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D04", + "run": 1, + "input": "I mean it's not that we don't want to but", + "output_raw": "我意思是,我们并非完全不想这样做,", + "output_clean": "我意思是,我们并非完全不想这样做,", + "reference": "我的意思是,不是我们不想,但是", + "ttft_s": 0.7145, + "total_s": 0.8989, + "tps": 54.2, + "prompt_tps": 418.16, + "eval_tokens": 10, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D04", + "run": 2, + "input": "I mean it's not that we don't want to but", + "output_raw": "我意思是,我们并不想这样做。", + "output_clean": "我意思是,我们并不想这样做。", + "reference": "我的意思是,不是我们不想,但是", + "ttft_s": 0.7633, + "total_s": 0.9296, + "tps": 54.11, + "prompt_tps": 367.49, + "eval_tokens": 9, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D04", + "run": 3, + "input": "I mean it's not that we don't want to but", + "output_raw": "我意思是,我们并不想这样做", + "output_clean": "我意思是,我们并不想这样做", + "reference": "我的意思是,不是我们不想,但是", + "ttft_s": 0.7169, + "total_s": 0.8614, + "tps": 55.33, + "prompt_tps": 416.63, + "eval_tokens": 8, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D05", + "run": 1, + "input": "Well the thing is um we actually", + "output_raw": "嗯,实际上,我们……", + "output_clean": "嗯,实际上,我们……", + "reference": "嗯,事情是这样的,实际上我们", + "ttft_s": 0.6625, + "total_s": 0.7858, + "tps": 56.75, + "prompt_tps": 471.78, + "eval_tokens": 7, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D05", + "run": 2, + "input": "Well the thing is um we actually", + "output_raw": "嗯,实际上,我们...\n", + "output_clean": "嗯,实际上,我们...", + "reference": "嗯,事情是这样的,实际上我们", + "ttft_s": 0.6606, + "total_s": 0.8032, + "tps": 56.06, + "prompt_tps": 479.49, + "eval_tokens": 8, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D05", + "run": 3, + "input": "Well the thing is um we actually", + "output_raw": "嗯,实际上,我们...", + "output_clean": "嗯,实际上,我们...", + "reference": "嗯,事情是这样的,实际上我们", + "ttft_s": 0.6581, + "total_s": 0.781, + "tps": 56.87, + "prompt_tps": 482.05, + "eval_tokens": 7, + "prompt_tokens": 135, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "E01", + "run": 1, + "input": "We deployed the new Kubernetes cluster on AWS EKS last Friday.", + "output_raw": "我们上周五在 AWS EKS 上部署了新的 Kubernetes 集群。", + "output_clean": "我们上周五在 AWS EKS 上部署了新的 Kubernetes 集群。", + "reference": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "ttft_s": 0.7166, + "total_s": 1.0408, + "tps": 52.42, + "prompt_tps": 416.18, + "eval_tokens": 17, + "prompt_tokens": 141, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 2, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E01", + "run": 2, + "input": "We deployed the new Kubernetes cluster on AWS EKS last Friday.", + "output_raw": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "output_clean": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "reference": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "ttft_s": 0.7845, + "total_s": 1.1165, + "tps": 51.2, + "prompt_tps": 346.18, + "eval_tokens": 17, + "prompt_tokens": 141, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 2, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E01", + "run": 3, + "input": "We deployed the new Kubernetes cluster on AWS EKS last Friday.", + "output_raw": "我们上周五成功部署了新的 Kubernetes 集群,该集群运行在 AWS EKS 上。", + "output_clean": "我们上周五成功部署了新的 Kubernetes 集群,该集群运行在 AWS EKS 上。", + "reference": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "ttft_s": 0.7291, + "total_s": 1.1796, + "tps": 48.82, + "prompt_tps": 399.84, + "eval_tokens": 22, + "prompt_tokens": 141, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 2, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E02", + "run": 1, + "input": "The React Native app is crashing on iOS seventeen because of a WebSocket timeout issue.", + "output_raw": "React Native 应用在 iOS 17 上因 WebSocket 超时问题而崩溃。", + "output_clean": "React Native 应用在 iOS 17 上因 WebSocket 超时问题而崩溃。", + "reference": "React Native应用在iOS 17上因为WebSocket超时问题而崩溃。", + "ttft_s": 0.7779, + "total_s": 1.1655, + "tps": 46.45, + "prompt_tps": 359.84, + "eval_tokens": 18, + "prompt_tokens": 144, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E02", + "run": 2, + "input": "The React Native app is crashing on iOS seventeen because of a WebSocket timeout issue.", + "output_raw": "React Native 应用在 iOS 17 上由于 WebSocket 超时问题而崩溃。", + "output_clean": "React Native 应用在 iOS 17 上由于 WebSocket 超时问题而崩溃。", + "reference": "React Native应用在iOS 17上因为WebSocket超时问题而崩溃。", + "ttft_s": 0.7718, + "total_s": 1.1509, + "tps": 47.47, + "prompt_tps": 366.19, + "eval_tokens": 18, + "prompt_tokens": 144, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E02", + "run": 3, + "input": "The React Native app is crashing on iOS seventeen because of a WebSocket timeout issue.", + "output_raw": "React Native 应用在 iOS 17 上由于 WebSocket 超时问题而崩溃。", + "output_clean": "React Native 应用在 iOS 17 上由于 WebSocket 超时问题而崩溃。", + "reference": "React Native应用在iOS 17上因为WebSocket超时问题而崩溃。", + "ttft_s": 0.7618, + "total_s": 1.1322, + "tps": 48.58, + "prompt_tps": 373.51, + "eval_tokens": 18, + "prompt_tokens": 144, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E03", + "run": 1, + "input": "Apple announced the new M5 chip with improved Neural Engine performance at WWDC.", + "output_raw": "苹果在WWDC上宣布了搭载改进型神经网络引擎的M5芯片。", + "output_clean": "苹果在WWDC上宣布了搭载改进型神经网络引擎的M5芯片。", + "reference": "Apple在WWDC上发布了新的M5芯片,改进了Neural Engine性能。", + "ttft_s": 0.7566, + "total_s": 1.1341, + "tps": 47.68, + "prompt_tps": 377.5, + "eval_tokens": 18, + "prompt_tokens": 144, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 4, + "missing_terms": [ + "Apple", + "Neural Engine" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E03", + "run": 2, + "input": "Apple announced the new M5 chip with improved Neural Engine performance at WWDC.", + "output_raw": "苹果在WWDC上宣布了搭载改进型神经网络引擎的M5芯片。", + "output_clean": "苹果在WWDC上宣布了搭载改进型神经网络引擎的M5芯片。", + "reference": "Apple在WWDC上发布了新的M5芯片,改进了Neural Engine性能。", + "ttft_s": 0.764, + "total_s": 1.1443, + "tps": 47.32, + "prompt_tps": 373.44, + "eval_tokens": 18, + "prompt_tokens": 144, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 4, + "missing_terms": [ + "Apple", + "Neural Engine" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E03", + "run": 3, + "input": "Apple announced the new M5 chip with improved Neural Engine performance at WWDC.", + "output_raw": "苹果在WWDC上宣布推出搭载改进型神经网络引擎的M5芯片。", + "output_clean": "苹果在WWDC上宣布推出搭载改进型神经网络引擎的M5芯片。", + "reference": "Apple在WWDC上发布了新的M5芯片,改进了Neural Engine性能。", + "ttft_s": 0.772, + "total_s": 1.1496, + "tps": 47.68, + "prompt_tps": 366.28, + "eval_tokens": 18, + "prompt_tokens": 144, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 4, + "missing_terms": [ + "Apple", + "Neural Engine" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E04", + "run": 1, + "input": "We need to implement OAuth two point zero authentication before integrating with the Stripe payment API.", + "output_raw": "我们需要在与Stripe支付API集成之前,实施OAuth 2.0认证。\n", + "output_clean": "我们需要在与Stripe支付API集成之前,实施OAuth 2.0认证。", + "reference": "在集成Stripe支付API之前,我们需要实现OAuth 2.0认证。", + "ttft_s": 0.7728, + "total_s": 1.204, + "tps": 46.38, + "prompt_tps": 369.08, + "eval_tokens": 20, + "prompt_tokens": 145, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E04", + "run": 2, + "input": "We need to implement OAuth two point zero authentication before integrating with the Stripe payment API.", + "output_raw": "我们需要在与Stripe支付API集成之前,先实施OAuth 2.0认证。\n", + "output_clean": "我们需要在与Stripe支付API集成之前,先实施OAuth 2.0认证。", + "reference": "在集成Stripe支付API之前,我们需要实现OAuth 2.0认证。", + "ttft_s": 0.7812, + "total_s": 1.2338, + "tps": 46.41, + "prompt_tps": 360.49, + "eval_tokens": 21, + "prompt_tokens": 145, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E04", + "run": 3, + "input": "We need to implement OAuth two point zero authentication before integrating with the Stripe payment API.", + "output_raw": "我们需要在与Stripe支付API集成之前,先实施OAuth 2.0认证。", + "output_clean": "我们需要在与Stripe支付API集成之前,先实施OAuth 2.0认证。", + "reference": "在集成Stripe支付API之前,我们需要实现OAuth 2.0认证。", + "ttft_s": 0.7851, + "total_s": 1.2274, + "tps": 45.22, + "prompt_tps": 355.75, + "eval_tokens": 20, + "prompt_tokens": 145, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E05", + "run": 1, + "input": "The transformer model uses multi-head attention and layer normalization to process the input embeddings.", + "output_raw": "该 Transformer 模型使用多头注意力机制和层归一化来处理输入嵌入。", + "output_clean": "该 Transformer 模型使用多头注意力机制和层归一化来处理输入嵌入。", + "reference": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。", + "ttft_s": 0.7896, + "total_s": 1.1974, + "tps": 46.58, + "prompt_tps": 352.47, + "eval_tokens": 19, + "prompt_tokens": 145, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 1, + "term_total": 2, + "missing_terms": [ + "multi-head attention" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E05", + "run": 2, + "input": "The transformer model uses multi-head attention and layer normalization to process the input embeddings.", + "output_raw": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。\n", + "output_clean": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。", + "reference": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。", + "ttft_s": 0.7709, + "total_s": 1.1691, + "tps": 47.72, + "prompt_tps": 370.52, + "eval_tokens": 19, + "prompt_tokens": 145, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 1, + "term_total": 2, + "missing_terms": [ + "multi-head attention" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E05", + "run": 3, + "input": "The transformer model uses multi-head attention and layer normalization to process the input embeddings.", + "output_raw": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。", + "output_clean": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。", + "reference": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。", + "ttft_s": 0.7716, + "total_s": 1.1495, + "tps": 47.61, + "prompt_tps": 367.31, + "eval_tokens": 18, + "prompt_tokens": 145, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 1, + "term_total": 2, + "missing_terms": [ + "multi-head attention" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "F01", + "run": 1, + "input": "Let's start with the first one. How is it going so far?", + "output_raw": "我们先从第一个开始。目前进展如何?", + "output_clean": "我们先从第一个开始。目前进展如何?", + "reference": "让我们从第一个开始。目前进展如何?", + "ttft_s": 0.9677, + "total_s": 1.1951, + "tps": 48.34, + "prompt_tps": 397.26, + "eval_tokens": 11, + "prompt_tokens": 235, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F01", + "run": 2, + "input": "Let's start with the first one. How is it going so far?", + "output_raw": "我们先从第一个开始。目前进展如何?", + "output_clean": "我们先从第一个开始。目前进展如何?", + "reference": "让我们从第一个开始。目前进展如何?", + "ttft_s": 0.9527, + "total_s": 1.1757, + "tps": 49.31, + "prompt_tps": 410.72, + "eval_tokens": 11, + "prompt_tokens": 235, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F01", + "run": 3, + "input": "Let's start with the first one. How is it going so far?", + "output_raw": "我们先从第一个方面开始。目前进展如何?", + "output_clean": "我们先从第一个方面开始。目前进展如何?", + "reference": "让我们从第一个开始。目前进展如何?", + "ttft_s": 0.9358, + "total_s": 1.1768, + "tps": 49.8, + "prompt_tps": 420.18, + "eval_tokens": 12, + "prompt_tokens": 235, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F02", + "run": 1, + "input": "And what about the second item? When is the design review scheduled?", + "output_raw": "至于第二个项目,设计评审何时安排?", + "output_clean": "至于第二个项目,设计评审何时安排?", + "reference": "第二项呢?设计评审安排在什么时候?", + "ttft_s": 0.943, + "total_s": 1.1459, + "tps": 49.27, + "prompt_tps": 411.67, + "eval_tokens": 10, + "prompt_tokens": 233, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F02", + "run": 2, + "input": "And what about the second item? When is the design review scheduled?", + "output_raw": "至于第二个项目,设计评审何时安排?", + "output_clean": "至于第二个项目,设计评审何时安排?", + "reference": "第二项呢?设计评审安排在什么时候?", + "ttft_s": 0.941, + "total_s": 1.1387, + "tps": 50.57, + "prompt_tps": 412.48, + "eval_tokens": 10, + "prompt_tokens": 233, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F02", + "run": 3, + "input": "And what about the second item? When is the design review scheduled?", + "output_raw": "至于第二个项目,设计评审何时安排?", + "output_clean": "至于第二个项目,设计评审何时安排?", + "reference": "第二项呢?设计评审安排在什么时候?", + "ttft_s": 0.9329, + "total_s": 1.1325, + "tps": 50.11, + "prompt_tps": 420.78, + "eval_tokens": 10, + "prompt_tokens": 233, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "G01", + "run": 1, + "input": "这个方案的可行性还需要进一步评估。", + "output_raw": "The feasibility of this plan still needs further evaluation.", + "output_clean": "The feasibility of this plan still needs further evaluation.", + "reference": "The feasibility of this plan still needs further evaluation.", + "ttft_s": 0.7465, + "total_s": 0.9595, + "tps": 51.64, + "prompt_tps": 374.26, + "eval_tokens": 11, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G01", + "run": 2, + "input": "这个方案的可行性还需要进一步评估。", + "output_raw": "The feasibility of this plan still needs further evaluation.", + "output_clean": "The feasibility of this plan still needs further evaluation.", + "reference": "The feasibility of this plan still needs further evaluation.", + "ttft_s": 0.741, + "total_s": 0.9531, + "tps": 51.84, + "prompt_tps": 378.47, + "eval_tokens": 11, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G01", + "run": 3, + "input": "这个方案的可行性还需要进一步评估。", + "output_raw": "The feasibility of this plan still needs further evaluation.", + "output_clean": "The feasibility of this plan still needs further evaluation.", + "reference": "The feasibility of this plan still needs further evaluation.", + "ttft_s": 0.7396, + "total_s": 0.9526, + "tps": 51.64, + "prompt_tps": 379.59, + "eval_tokens": 11, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G02", + "run": 1, + "input": "我们的日活用户已经突破了五百万。", + "output_raw": "Our daily active users have exceeded five million.", + "output_clean": "Our daily active users have exceeded five million.", + "reference": "Our daily active users have exceeded five million.", + "ttft_s": 0.7352, + "total_s": 0.9249, + "tps": 52.71, + "prompt_tps": 388.23, + "eval_tokens": 10, + "prompt_tokens": 139, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G02", + "run": 2, + "input": "我们的日活用户已经突破了五百万。", + "output_raw": "Our daily active users have surpassed five million.", + "output_clean": "Our daily active users have surpassed five million.", + "reference": "Our daily active users have exceeded five million.", + "ttft_s": 0.7373, + "total_s": 0.927, + "tps": 52.73, + "prompt_tps": 386.76, + "eval_tokens": 10, + "prompt_tokens": 139, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G02", + "run": 3, + "input": "我们的日活用户已经突破了五百万。", + "output_raw": "Our daily active users have exceeded five million.", + "output_clean": "Our daily active users have exceeded five million.", + "reference": "Our daily active users have exceeded five million.", + "ttft_s": 0.7283, + "total_s": 0.9199, + "tps": 52.15, + "prompt_tps": 394.15, + "eval_tokens": 10, + "prompt_tokens": 139, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G03", + "run": 1, + "input": "请大家在周五之前提交各自负责模块的测试报告。", + "output_raw": "Please submit test reports for your respective modules by Friday.", + "output_clean": "Please submit test reports for your respective modules by Friday.", + "reference": "Please submit the test reports for your respective modules by Friday.", + "ttft_s": 0.7275, + "total_s": 0.9606, + "tps": 51.45, + "prompt_tps": 401.14, + "eval_tokens": 12, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G03", + "run": 2, + "input": "请大家在周五之前提交各自负责模块的测试报告。", + "output_raw": "Please submit test reports for your respective modules by Friday.", + "output_clean": "Please submit test reports for your respective modules by Friday.", + "reference": "Please submit the test reports for your respective modules by Friday.", + "ttft_s": 0.7393, + "total_s": 0.9747, + "tps": 50.97, + "prompt_tps": 392.52, + "eval_tokens": 12, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G03", + "run": 3, + "input": "请大家在周五之前提交各自负责模块的测试报告。", + "output_raw": "Please submit the test reports for your respective modules before Friday.", + "output_clean": "Please submit the test reports for your respective modules before Friday.", + "reference": "Please submit the test reports for your respective modules by Friday.", + "ttft_s": 0.7419, + "total_s": 0.9971, + "tps": 50.92, + "prompt_tps": 389.11, + "eval_tokens": 13, + "prompt_tokens": 142, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G04", + "run": 1, + "input": "下一步我们要重点关注用户体验的优化,尤其是首屏加载速度。", + "output_raw": "Next, we will focus on optimizing the user experience, especially the initial page loading speed.", + "output_clean": "Next, we will focus on optimizing the user experience, especially the initial page loading speed.", + "reference": "Next, we need to focus on optimizing user experience, especially the initial screen loading speed.", + "ttft_s": 0.7457, + "total_s": 1.1295, + "tps": 49.5, + "prompt_tps": 392.63, + "eval_tokens": 19, + "prompt_tokens": 144, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G04", + "run": 2, + "input": "下一步我们要重点关注用户体验的优化,尤其是首屏加载速度。", + "output_raw": "Next, we will focus on optimizing the user experience, especially the initial page loading speed.", + "output_clean": "Next, we will focus on optimizing the user experience, especially the initial page loading speed.", + "reference": "Next, we need to focus on optimizing user experience, especially the initial screen loading speed.", + "ttft_s": 0.7624, + "total_s": 1.1639, + "tps": 47.32, + "prompt_tps": 374.85, + "eval_tokens": 19, + "prompt_tokens": 144, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G04", + "run": 3, + "input": "下一步我们要重点关注用户体验的优化,尤其是首屏加载速度。", + "output_raw": "Next, we will focus on optimizing the user experience, especially the first-page loading speed.", + "output_clean": "Next, we will focus on optimizing the user experience, especially the first-page loading speed.", + "reference": "Next, we need to focus on optimizing user experience, especially the initial screen loading speed.", + "ttft_s": 0.7746, + "total_s": 1.2003, + "tps": 46.98, + "prompt_tps": 362.03, + "eval_tokens": 20, + "prompt_tokens": 144, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G05", + "run": 1, + "input": "这个bug的根本原因是内存泄漏导致的,已经在最新版本中修复了。", + "output_raw": "The root cause of this bug is a memory leak, which has been fixed in the latest version.", + "output_clean": "The root cause of this bug is a memory leak, which has been fixed in the latest version.", + "reference": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "ttft_s": 0.7701, + "total_s": 1.2231, + "tps": 46.35, + "prompt_tps": 366.07, + "eval_tokens": 21, + "prompt_tokens": 144, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G05", + "run": 2, + "input": "这个bug的根本原因是内存泄漏导致的,已经在最新版本中修复了。", + "output_raw": "The root cause of this bug is due to memory leaks, and it has been fixed in the latest version.\n", + "output_clean": "The root cause of this bug is due to memory leaks, and it has been fixed in the latest version.", + "reference": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "ttft_s": 0.782, + "total_s": 1.3057, + "tps": 45.82, + "prompt_tps": 355.23, + "eval_tokens": 24, + "prompt_tokens": 144, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G05", + "run": 3, + "input": "这个bug的根本原因是内存泄漏导致的,已经在最新版本中修复了。", + "output_raw": "The root cause of this bug is a memory leak, which has been fixed in the latest version.", + "output_clean": "The root cause of this bug is a memory leak, which has been fixed in the latest version.", + "reference": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "ttft_s": 0.779, + "total_s": 1.2375, + "tps": 45.79, + "prompt_tps": 357.63, + "eval_tokens": 21, + "prompt_tokens": 144, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "H01", + "run": 1, + "input": "Thank you for joining today's meeting.", + "output_raw": "本日ミーティングにご参加いただきありがとうございます。\n", + "output_clean": "本日ミーティングにご参加いただきありがとうございます。", + "reference": "本日の会議にご参加いただきありがとうございます。", + "ttft_s": 0.772, + "total_s": 0.9796, + "tps": 48.16, + "prompt_tps": 350.0, + "eval_tokens": 10, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H01", + "run": 2, + "input": "Thank you for joining today's meeting.", + "output_raw": "本日、会議にご参加いただきありがとうございます。", + "output_clean": "本日、会議にご参加いただきありがとうございます。", + "reference": "本日の会議にご参加いただきありがとうございます。", + "ttft_s": 0.7699, + "total_s": 0.9523, + "tps": 49.34, + "prompt_tps": 348.43, + "eval_tokens": 9, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H01", + "run": 3, + "input": "Thank you for joining today's meeting.", + "output_raw": "本日会議にご参加いただき、ありがとうございます。", + "output_clean": "本日会議にご参加いただき、ありがとうございます。", + "reference": "本日の会議にご参加いただきありがとうございます。", + "ttft_s": 0.7623, + "total_s": 0.9429, + "tps": 49.84, + "prompt_tps": 356.37, + "eval_tokens": 9, + "prompt_tokens": 137, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H02", + "run": 1, + "input": "The deadline for this project is the end of next month.", + "output_raw": "このプロジェクトの締め切りは、来月の末です。", + "output_clean": "このプロジェクトの締め切りは、来月の末です。", + "reference": "このプロジェクトの締め切りは来月末です。", + "ttft_s": 0.7592, + "total_s": 1.0273, + "tps": 48.49, + "prompt_tps": 366.67, + "eval_tokens": 13, + "prompt_tokens": 140, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H02", + "run": 2, + "input": "The deadline for this project is the end of next month.", + "output_raw": "このプロジェクトの締め切りは、来月の末です。", + "output_clean": "このプロジェクトの締め切りは、来月の末です。", + "reference": "このプロジェクトの締め切りは来月末です。", + "ttft_s": 0.7426, + "total_s": 1.0046, + "tps": 49.63, + "prompt_tps": 386.22, + "eval_tokens": 13, + "prompt_tokens": 140, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H02", + "run": 3, + "input": "The deadline for this project is the end of next month.", + "output_raw": "このプロジェクトの締め切りは来月の末です。", + "output_clean": "このプロジェクトの締め切りは来月の末です。", + "reference": "このプロジェクトの締め切りは来月末です。", + "ttft_s": 0.7482, + "total_s": 0.9869, + "tps": 50.27, + "prompt_tps": 376.5, + "eval_tokens": 12, + "prompt_tokens": 140, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H03", + "run": 1, + "input": "We need to reconsider our marketing strategy for the Asian market.", + "output_raw": "アジア市場向けのマーケティング戦略を見直す必要があります。\n", + "output_clean": "アジア市場向けのマーケティング戦略を見直す必要があります。", + "reference": "アジア市場向けのマーケティング戦略を再検討する必要があります。", + "ttft_s": 0.7416, + "total_s": 0.9998, + "tps": 50.33, + "prompt_tps": 382.7, + "eval_tokens": 13, + "prompt_tokens": 140, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H03", + "run": 2, + "input": "We need to reconsider our marketing strategy for the Asian market.", + "output_raw": "アジア市場に向けたマーケティング戦略を改めて見直す必要があります。\n", + "output_clean": "アジア市場に向けたマーケティング戦略を改めて見直す必要があります。", + "reference": "アジア市場向けのマーケティング戦略を再検討する必要があります。", + "ttft_s": 0.7385, + "total_s": 1.0591, + "tps": 49.9, + "prompt_tps": 384.4, + "eval_tokens": 16, + "prompt_tokens": 140, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H03", + "run": 3, + "input": "We need to reconsider our marketing strategy for the Asian market.", + "output_raw": "アジア市場におけるマーケティング戦略を見直す必要があります。", + "output_clean": "アジア市場におけるマーケティング戦略を見直す必要があります。", + "reference": "アジア市場向けのマーケティング戦略を再検討する必要があります。", + "ttft_s": 0.743, + "total_s": 0.9796, + "tps": 50.71, + "prompt_tps": 383.15, + "eval_tokens": 12, + "prompt_tokens": 140, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "I01", + "run": 1, + "input": "Hello", + "output_raw": "您好\n", + "output_clean": "您好", + "reference": "你好", + "ttft_s": 0.6834, + "total_s": 0.7479, + "tps": 61.88, + "prompt_tps": 427.35, + "eval_tokens": 4, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I01", + "run": 2, + "input": "Hello", + "output_raw": "你好", + "output_clean": "你好", + "reference": "你好", + "ttft_s": 0.6732, + "total_s": 0.6953, + "tps": 89.97, + "prompt_tps": 433.65, + "eval_tokens": 2, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I01", + "run": 3, + "input": "Hello", + "output_raw": "您好", + "output_clean": "您好", + "reference": "你好", + "ttft_s": 0.6737, + "total_s": 0.7165, + "tps": 69.91, + "prompt_tps": 437.05, + "eval_tokens": 3, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I02", + "run": 1, + "input": "Thank you", + "output_raw": "谢谢", + "output_clean": "谢谢", + "reference": "谢谢", + "ttft_s": 0.67, + "total_s": 0.6921, + "tps": 90.29, + "prompt_tps": 444.46, + "eval_tokens": 2, + "prompt_tokens": 130, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I02", + "run": 2, + "input": "Thank you", + "output_raw": "谢谢", + "output_clean": "谢谢", + "reference": "谢谢", + "ttft_s": 0.6608, + "total_s": 0.6824, + "tps": 91.76, + "prompt_tps": 458.49, + "eval_tokens": 2, + "prompt_tokens": 130, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I02", + "run": 3, + "input": "Thank you", + "output_raw": "谢谢", + "output_clean": "谢谢", + "reference": "谢谢", + "ttft_s": 0.6585, + "total_s": 0.681, + "tps": 89.52, + "prompt_tps": 462.36, + "eval_tokens": 2, + "prompt_tokens": 130, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I03", + "run": 1, + "input": "I don't understand", + "output_raw": "我不明白\n", + "output_clean": "我不明白", + "reference": "我不明白", + "ttft_s": 0.6586, + "total_s": 0.721, + "tps": 64.07, + "prompt_tps": 476.95, + "eval_tokens": 4, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I03", + "run": 2, + "input": "I don't understand", + "output_raw": "我不明白\n", + "output_clean": "我不明白", + "reference": "我不明白", + "ttft_s": 0.6595, + "total_s": 0.7216, + "tps": 64.34, + "prompt_tps": 468.96, + "eval_tokens": 4, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I03", + "run": 3, + "input": "I don't understand", + "output_raw": "我不明白\n", + "output_clean": "我不明白", + "reference": "我不明白", + "ttft_s": 0.6594, + "total_s": 0.7214, + "tps": 64.51, + "prompt_tps": 468.01, + "eval_tokens": 4, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I04", + "run": 1, + "input": "Can you help me?", + "output_raw": "您需要帮助吗?\n", + "output_clean": "您需要帮助吗?", + "reference": "你能帮我吗?", + "ttft_s": 0.6554, + "total_s": 0.7783, + "tps": 56.85, + "prompt_tps": 479.68, + "eval_tokens": 7, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I04", + "run": 2, + "input": "Can you help me?", + "output_raw": "您需要帮助吗?\n", + "output_clean": "您需要帮助吗?", + "reference": "你能帮我吗?", + "ttft_s": 0.6591, + "total_s": 0.7819, + "tps": 56.96, + "prompt_tps": 471.56, + "eval_tokens": 7, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I04", + "run": 3, + "input": "Can you help me?", + "output_raw": "您需要帮助吗?\n", + "output_clean": "您需要帮助吗?", + "reference": "你能帮我吗?", + "ttft_s": 0.6602, + "total_s": 0.7834, + "tps": 56.81, + "prompt_tps": 469.25, + "eval_tokens": 7, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I05", + "run": 1, + "input": "What is machine learning?", + "output_raw": "什么是机器学习?", + "output_clean": "什么是机器学习?", + "reference": "什么是机器学习?", + "ttft_s": 0.6616, + "total_s": 0.724, + "tps": 64.1, + "prompt_tps": 470.23, + "eval_tokens": 4, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I05", + "run": 2, + "input": "What is machine learning?", + "output_raw": "什么是机器学习?", + "output_clean": "什么是机器学习?", + "reference": "什么是机器学习?", + "ttft_s": 0.6577, + "total_s": 0.7201, + "tps": 63.92, + "prompt_tps": 477.65, + "eval_tokens": 4, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I05", + "run": 3, + "input": "What is machine learning?", + "output_raw": "什么是机器学习?\n", + "output_clean": "什么是机器学习?", + "reference": "什么是机器学习?", + "ttft_s": 0.6614, + "total_s": 0.7444, + "tps": 60.23, + "prompt_tps": 466.87, + "eval_tokens": 5, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + } + ], + "qwen3:4b-instruct-2507-q4_K_M": [ + { + "case_id": "A01", + "run": 1, + "input": "Let's get started.", + "output_raw": "我们开始吧。", + "output_clean": "我们开始吧。", + "reference": "让我们开始吧。", + "ttft_s": 1.6813, + "total_s": 1.9163, + "tps": 21.28, + "prompt_tps": 87.6, + "eval_tokens": 5, + "prompt_tokens": 118, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A01", + "run": 2, + "input": "Let's get started.", + "output_raw": "我们开始吧。", + "output_clean": "我们开始吧。", + "reference": "让我们开始吧。", + "ttft_s": 0.3903, + "total_s": 0.6224, + "tps": 21.53, + "prompt_tps": 2016.4, + "eval_tokens": 5, + "prompt_tokens": 118, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A01", + "run": 3, + "input": "Let's get started.", + "output_raw": "我们开始吧。", + "output_clean": "我们开始吧。", + "reference": "让我们开始吧。", + "ttft_s": 0.3898, + "total_s": 0.6229, + "tps": 21.46, + "prompt_tps": 2011.46, + "eval_tokens": 5, + "prompt_tokens": 118, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A02", + "run": 1, + "input": "Can you hear me?", + "output_raw": "你们能听到我吗?", + "output_clean": "你们能听到我吗?", + "reference": "你能听到我吗?", + "ttft_s": 0.6127, + "total_s": 0.9638, + "tps": 19.95, + "prompt_tps": 422.79, + "eval_tokens": 7, + "prompt_tokens": 118, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A02", + "run": 2, + "input": "Can you hear me?", + "output_raw": "你们能听到我吗?", + "output_clean": "你们能听到我吗?", + "reference": "你能听到我吗?", + "ttft_s": 0.3886, + "total_s": 0.7374, + "tps": 20.06, + "prompt_tps": 2018.3, + "eval_tokens": 7, + "prompt_tokens": 118, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A02", + "run": 3, + "input": "Can you hear me?", + "output_raw": "你能听到我吗?", + "output_clean": "你能听到我吗?", + "reference": "你能听到我吗?", + "ttft_s": 0.3892, + "total_s": 0.6813, + "tps": 20.53, + "prompt_tps": 1984.76, + "eval_tokens": 6, + "prompt_tokens": 118, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A03", + "run": 1, + "input": "That's a great point.", + "output_raw": "那是个很好的观点。", + "output_clean": "那是个很好的观点。", + "reference": "说得很好。", + "ttft_s": 0.6147, + "total_s": 0.9071, + "tps": 20.51, + "prompt_tps": 427.6, + "eval_tokens": 6, + "prompt_tokens": 119, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A03", + "run": 2, + "input": "That's a great point.", + "output_raw": "那是个很好的观点。", + "output_clean": "那是个很好的观点。", + "reference": "说得很好。", + "ttft_s": 0.3867, + "total_s": 0.6771, + "tps": 20.67, + "prompt_tps": 2040.43, + "eval_tokens": 6, + "prompt_tokens": 119, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A03", + "run": 3, + "input": "That's a great point.", + "output_raw": "这点非常到位。", + "output_clean": "这点非常到位。", + "reference": "说得很好。", + "ttft_s": 0.3888, + "total_s": 0.6213, + "tps": 21.51, + "prompt_tps": 2005.43, + "eval_tokens": 5, + "prompt_tokens": 119, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A04", + "run": 1, + "input": "I'll share my screen.", + "output_raw": "我将共享我的屏幕。", + "output_clean": "我将共享我的屏幕。", + "reference": "我来共享一下屏幕。", + "ttft_s": 0.6054, + "total_s": 0.9573, + "tps": 19.91, + "prompt_tps": 429.33, + "eval_tokens": 7, + "prompt_tokens": 119, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A04", + "run": 2, + "input": "I'll share my screen.", + "output_raw": "我将共享我的屏幕。", + "output_clean": "我将共享我的屏幕。", + "reference": "我来共享一下屏幕。", + "ttft_s": 0.3881, + "total_s": 0.7393, + "tps": 19.94, + "prompt_tps": 2040.26, + "eval_tokens": 7, + "prompt_tokens": 119, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A04", + "run": 3, + "input": "I'll share my screen.", + "output_raw": "我将共享我的屏幕。", + "output_clean": "我将共享我的屏幕。", + "reference": "我来共享一下屏幕。", + "ttft_s": 0.3893, + "total_s": 0.7379, + "tps": 20.08, + "prompt_tps": 2020.13, + "eval_tokens": 7, + "prompt_tokens": 119, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A05", + "run": 1, + "input": "Any questions so far?", + "output_raw": "到目前为止有任何问题吗?", + "output_clean": "到目前为止有任何问题吗?", + "reference": "到目前为止有什么问题吗?", + "ttft_s": 0.6057, + "total_s": 0.9561, + "tps": 19.98, + "prompt_tps": 428.32, + "eval_tokens": 7, + "prompt_tokens": 118, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A05", + "run": 2, + "input": "Any questions so far?", + "output_raw": "到现在为止有任何问题吗?", + "output_clean": "到现在为止有任何问题吗?", + "reference": "到目前为止有什么问题吗?", + "ttft_s": 0.3872, + "total_s": 0.7355, + "tps": 20.1, + "prompt_tps": 2020.58, + "eval_tokens": 7, + "prompt_tokens": 118, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "A05", + "run": 3, + "input": "Any questions so far?", + "output_raw": "到目前为止有任何问题吗?", + "output_clean": "到目前为止有任何问题吗?", + "reference": "到目前为止有什么问题吗?", + "ttft_s": 0.3919, + "total_s": 0.7404, + "tps": 20.08, + "prompt_tps": 2000.0, + "eval_tokens": 7, + "prompt_tokens": 118, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "A_short_utterances", + "direction": "en->zh" + }, + { + "case_id": "B01", + "run": 1, + "input": "I think we should postpone the launch to next quarter because the QA team hasn't finished testing yet.", + "output_raw": "我认为我们应该将发布推迟到下一季度,因为质检团队还没有完成测试。", + "output_clean": "我认为我们应该将发布推迟到下一季度,因为质检团队还没有完成测试。", + "reference": "我认为我们应该把发布推迟到下个季度,因为QA团队还没有完成测试。", + "ttft_s": 0.6069, + "total_s": 1.5399, + "tps": 18.22, + "prompt_tps": 487.45, + "eval_tokens": 17, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B01", + "run": 2, + "input": "I think we should postpone the launch to next quarter because the QA team hasn't finished testing yet.", + "output_raw": "我认为我们应该将发布推迟到下一季度,因为质检团队尚未完成测试。", + "output_clean": "我认为我们应该将发布推迟到下一季度,因为质检团队尚未完成测试。", + "reference": "我认为我们应该把发布推迟到下个季度,因为QA团队还没有完成测试。", + "ttft_s": 0.3909, + "total_s": 1.3229, + "tps": 18.23, + "prompt_tps": 2255.96, + "eval_tokens": 17, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B01", + "run": 3, + "input": "I think we should postpone the launch to next quarter because the QA team hasn't finished testing yet.", + "output_raw": "我认为我们应该将发布推迟到下一季度,因为质量保证团队的测试还没有完成。", + "output_clean": "我认为我们应该将发布推迟到下一季度,因为质量保证团队的测试还没有完成。", + "reference": "我认为我们应该把发布推迟到下个季度,因为QA团队还没有完成测试。", + "ttft_s": 0.3886, + "total_s": 1.4348, + "tps": 18.16, + "prompt_tps": 2271.52, + "eval_tokens": 19, + "prompt_tokens": 133, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B02", + "run": 1, + "input": "The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why.", + "output_raw": "与上个月相比,用户留存率下降了百分之十五,我们需要找出原因。", + "output_clean": "与上个月相比,用户留存率下降了百分之十五,我们需要找出原因。", + "reference": "用户留存率与上个月相比下降了百分之十五,我们需要找出原因。", + "ttft_s": 0.6059, + "total_s": 1.5972, + "tps": 18.16, + "prompt_tps": 489.11, + "eval_tokens": 18, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B02", + "run": 2, + "input": "The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why.", + "output_raw": "与上个月相比,用户留存率下降了百分之十五,我们需要找出原因。", + "output_clean": "与上个月相比,用户留存率下降了百分之十五,我们需要找出原因。", + "reference": "用户留存率与上个月相比下降了百分之十五,我们需要找出原因。", + "ttft_s": 0.3893, + "total_s": 1.3775, + "tps": 18.22, + "prompt_tps": 2294.83, + "eval_tokens": 18, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B02", + "run": 3, + "input": "The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why.", + "output_raw": "与上个月相比,用户留存率下降了百分之十五,我们需要找出原因。", + "output_clean": "与上个月相比,用户留存率下降了百分之十五,我们需要找出原因。", + "reference": "用户留存率与上个月相比下降了百分之十五,我们需要找出原因。", + "ttft_s": 0.3915, + "total_s": 1.3809, + "tps": 18.19, + "prompt_tps": 2235.98, + "eval_tokens": 18, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B03", + "run": 1, + "input": "We're planning to migrate our infrastructure from AWS to Google Cloud by the end of this year.", + "output_raw": "我们计划在今年年底之前将我们的基础设施从AWS迁移到谷歌云。", + "output_clean": "我们计划在今年年底之前将我们的基础设施从AWS迁移到谷歌云。", + "reference": "我们计划在今年年底前将基础设施从AWS迁移到Google Cloud。", + "ttft_s": 0.601, + "total_s": 1.4755, + "tps": 18.3, + "prompt_tps": 485.69, + "eval_tokens": 16, + "prompt_tokens": 132, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B03", + "run": 2, + "input": "We're planning to migrate our infrastructure from AWS to Google Cloud by the end of this year.", + "output_raw": "我们计划在本年内结束前将我们的基础设施从AWS迁移到谷歌云。", + "output_clean": "我们计划在本年内结束前将我们的基础设施从AWS迁移到谷歌云。", + "reference": "我们计划在今年年底前将基础设施从AWS迁移到Google Cloud。", + "ttft_s": 0.3904, + "total_s": 1.379, + "tps": 18.21, + "prompt_tps": 2229.92, + "eval_tokens": 18, + "prompt_tokens": 132, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B03", + "run": 3, + "input": "We're planning to migrate our infrastructure from AWS to Google Cloud by the end of this year.", + "output_raw": "我们计划在今年年底前将我们的基础设施从AWS迁移到Google云平台。", + "output_clean": "我们计划在今年年底前将我们的基础设施从AWS迁移到Google云平台。", + "reference": "我们计划在今年年底前将基础设施从AWS迁移到Google Cloud。", + "ttft_s": 0.3913, + "total_s": 1.2646, + "tps": 18.32, + "prompt_tps": 2249.07, + "eval_tokens": 16, + "prompt_tokens": 132, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B04", + "run": 1, + "input": "The new privacy policy requires us to obtain explicit user consent before collecting any personal data.", + "output_raw": "新的隐私政策要求我们在收集任何个人数据之前,必须获得用户的明确同意。", + "output_clean": "新的隐私政策要求我们在收集任何个人数据之前,必须获得用户的明确同意。", + "reference": "新的隐私政策要求我们在收集任何个人数据之前获得用户的明确同意。", + "ttft_s": 0.7933, + "total_s": 1.7859, + "tps": 18.14, + "prompt_tps": 282.62, + "eval_tokens": 18, + "prompt_tokens": 130, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B04", + "run": 2, + "input": "The new privacy policy requires us to obtain explicit user consent before collecting any personal data.", + "output_raw": "新的隐私政策要求我们在收集任何个人数据之前,必须获得用户的明确同意。", + "output_clean": "新的隐私政策要求我们在收集任何个人数据之前,必须获得用户的明确同意。", + "reference": "新的隐私政策要求我们在收集任何个人数据之前获得用户的明确同意。", + "ttft_s": 0.3803, + "total_s": 1.3693, + "tps": 18.2, + "prompt_tps": 2564.51, + "eval_tokens": 18, + "prompt_tokens": 130, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B04", + "run": 3, + "input": "The new privacy policy requires us to obtain explicit user consent before collecting any personal data.", + "output_raw": "新的隐私政策要求我们在收集任何个人数据之前,必须获得用户的明确同意。", + "output_clean": "新的隐私政策要求我们在收集任何个人数据之前,必须获得用户的明确同意。", + "reference": "新的隐私政策要求我们在收集任何个人数据之前获得用户的明确同意。", + "ttft_s": 0.3869, + "total_s": 1.3761, + "tps": 18.2, + "prompt_tps": 2205.93, + "eval_tokens": 18, + "prompt_tokens": 130, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B05", + "run": 1, + "input": "Could you walk us through the architecture diagram? I want to make sure everyone is on the same page.", + "output_raw": "能否为我们详细讲解一下架构图?我想确保每个人都能理解一致。", + "output_clean": "能否为我们详细讲解一下架构图?我想确保每个人都能理解一致。", + "reference": "你能给我们讲一下架构图吗?我想确保大家的理解是一致的。", + "ttft_s": 0.6055, + "total_s": 1.4806, + "tps": 18.28, + "prompt_tps": 491.37, + "eval_tokens": 16, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B05", + "run": 2, + "input": "Could you walk us through the architecture diagram? I want to make sure everyone is on the same page.", + "output_raw": "能否为我们详细讲解一下架构图?我想确保每个人对整体情况有相同的理解。", + "output_clean": "能否为我们详细讲解一下架构图?我想确保每个人对整体情况有相同的理解。", + "reference": "你能给我们讲一下架构图吗?我想确保大家的理解是一致的。", + "ttft_s": 0.39, + "total_s": 1.437, + "tps": 18.15, + "prompt_tps": 2271.19, + "eval_tokens": 19, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "B05", + "run": 3, + "input": "Could you walk us through the architecture diagram? I want to make sure everyone is on the same page.", + "output_raw": "能否为我们详细讲解一下架构图?我想确保每个人都能理解一致。", + "output_clean": "能否为我们详细讲解一下架构图?我想确保每个人都能理解一致。", + "reference": "你能给我们讲一下架构图吗?我想确保大家的理解是一致的。", + "ttft_s": 0.3912, + "total_s": 1.2636, + "tps": 18.34, + "prompt_tps": 2271.07, + "eval_tokens": 16, + "prompt_tokens": 134, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "B_medium_sentences", + "direction": "en->zh" + }, + { + "case_id": "C01", + "run": 1, + "input": "So let me summarize today's discussion. We agreed to move forward with option B, which involves rebuilding the payment module from scratch. The timeline is eight weeks, with the first milestone due in three weeks. John will lead the backend team, and Sarah will handle the frontend integration. We also need to coordinate with the compliance team to ensure we meet all regulatory requirements before going live.", + "output_raw": "那么让我总结一下今天讨论的内容。我们一致同意推进B方案,即从零开始重建支付模块。整体周期为八周,第一个里程碑将在三周内完成。John将带领后端团队,Sarah负责前端集成工作。我们还需要与合规团队协调,确保在上线前满足所有监管要求。", + "output_clean": "那么让我总结一下今天讨论的内容。我们一致同意推进B方案,即从零开始重建支付模块。整体周期为八周,第一个里程碑将在三周内完成。John将带领后端团队,Sarah负责前端集成工作。我们还需要与合规团队协调,确保在上线前满足所有监管要求。", + "reference": "让我来总结一下今天的讨论。我们同意推进方案B,即从零开始重建支付模块。时间线是八周,第一个里程碑在三周内到期。John将带领后端团队,Sarah负责前端集成。我们还需要与合规团队协调,确保上线前满足所有监管要求。", + "ttft_s": 1.0181, + "total_s": 4.8698, + "tps": 17.39, + "prompt_tps": 276.21, + "eval_tokens": 67, + "prompt_tokens": 189, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C01", + "run": 2, + "input": "So let me summarize today's discussion. We agreed to move forward with option B, which involves rebuilding the payment module from scratch. The timeline is eight weeks, with the first milestone due in three weeks. John will lead the backend team, and Sarah will handle the frontend integration. We also need to coordinate with the compliance team to ensure we meet all regulatory requirements before going live.", + "output_raw": "那么让我总结一下今天的讨论。我们一致决定推进B方案,即从零开始重新构建支付模块。整体周期为八周,第一个里程碑将在三周内完成。John将带领后端团队,Sarah负责前端集成工作。我们还需要与合规团队协调,确保在上线前满足所有监管要求。", + "output_clean": "那么让我总结一下今天的讨论。我们一致决定推进B方案,即从零开始重新构建支付模块。整体周期为八周,第一个里程碑将在三周内完成。John将带领后端团队,Sarah负责前端集成工作。我们还需要与合规团队协调,确保在上线前满足所有监管要求。", + "reference": "让我来总结一下今天的讨论。我们同意推进方案B,即从零开始重建支付模块。时间线是八周,第一个里程碑在三周内到期。John将带领后端团队,Sarah负责前端集成。我们还需要与合规团队协调,确保上线前满足所有监管要求。", + "ttft_s": 0.3926, + "total_s": 4.2444, + "tps": 17.39, + "prompt_tps": 3232.04, + "eval_tokens": 67, + "prompt_tokens": 189, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C01", + "run": 3, + "input": "So let me summarize today's discussion. We agreed to move forward with option B, which involves rebuilding the payment module from scratch. The timeline is eight weeks, with the first milestone due in three weeks. John will lead the backend team, and Sarah will handle the frontend integration. We also need to coordinate with the compliance team to ensure we meet all regulatory requirements before going live.", + "output_raw": "那么让我总结一下今天的讨论。我们一致同意采用方案B,即从零开始重新构建支付模块。整个周期为八周,第一个里程碑将在三周内完成。John将带领后端团队,Sarah负责前端集成工作。我们还需要与合规团队协调,确保在上线前满足所有监管要求。", + "output_clean": "那么让我总结一下今天的讨论。我们一致同意采用方案B,即从零开始重新构建支付模块。整个周期为八周,第一个里程碑将在三周内完成。John将带领后端团队,Sarah负责前端集成工作。我们还需要与合规团队协调,确保在上线前满足所有监管要求。", + "reference": "让我来总结一下今天的讨论。我们同意推进方案B,即从零开始重建支付模块。时间线是八周,第一个里程碑在三周内到期。John将带领后端团队,Sarah负责前端集成。我们还需要与合规团队协调,确保上线前满足所有监管要求。", + "ttft_s": 0.3886, + "total_s": 4.2424, + "tps": 17.38, + "prompt_tps": 3190.63, + "eval_tokens": 67, + "prompt_tokens": 189, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C02", + "run": 1, + "input": "Artificial intelligence is transforming the healthcare industry in ways we never imagined. From early disease detection using medical imaging to personalized treatment plans based on genetic data, AI is helping doctors make better decisions faster. However, we must also address the ethical concerns around patient privacy, algorithmic bias, and the potential displacement of healthcare workers.", + "output_raw": "人工智能正以我们从未想象过的方式重塑医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据制定个性化治疗方案,人工智能正帮助医生更快地做出更优决策。然而,我们也必须正视患者隐私、算法偏见以及医疗人员可能被替代等伦理问题。", + "output_clean": "人工智能正以我们从未想象过的方式重塑医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据制定个性化治疗方案,人工智能正帮助医生更快地做出更优决策。然而,我们也必须正视患者隐私、算法偏见以及医疗人员可能被替代等伦理问题。", + "reference": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据的个性化治疗方案,AI正在帮助医生更快地做出更好的决策。然而,我们也必须解决围绕患者隐私、算法偏见以及医疗工作者可能被取代等伦理问题。", + "ttft_s": 1.0175, + "total_s": 4.691, + "tps": 17.42, + "prompt_tps": 260.51, + "eval_tokens": 64, + "prompt_tokens": 176, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C02", + "run": 2, + "input": "Artificial intelligence is transforming the healthcare industry in ways we never imagined. From early disease detection using medical imaging to personalized treatment plans based on genetic data, AI is helping doctors make better decisions faster. However, we must also address the ethical concerns around patient privacy, algorithmic bias, and the potential displacement of healthcare workers.", + "output_raw": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据制定个性化治疗方案,人工智能正帮助医生更快地做出更优决策。然而,我们也必须关注患者隐私、算法偏见以及医疗人员可能被替代等伦理问题。", + "output_clean": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据制定个性化治疗方案,人工智能正帮助医生更快地做出更优决策。然而,我们也必须关注患者隐私、算法偏见以及医疗人员可能被替代等伦理问题。", + "reference": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据的个性化治疗方案,AI正在帮助医生更快地做出更好的决策。然而,我们也必须解决围绕患者隐私、算法偏见以及医疗工作者可能被取代等伦理问题。", + "ttft_s": 0.3916, + "total_s": 4.0034, + "tps": 17.44, + "prompt_tps": 2984.62, + "eval_tokens": 63, + "prompt_tokens": 176, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "C02", + "run": 3, + "input": "Artificial intelligence is transforming the healthcare industry in ways we never imagined. From early disease detection using medical imaging to personalized treatment plans based on genetic data, AI is helping doctors make better decisions faster. However, we must also address the ethical concerns around patient privacy, algorithmic bias, and the potential displacement of healthcare workers.", + "output_raw": "人工智能正以我们从未想象的方式重塑医疗行业。从利用医学影像进行早期疾病筛查,到基于基因数据制定个性化治疗方案,人工智能正帮助医生更快地做出更优决策。然而,我们同样必须应对患者隐私、算法偏见以及医疗人员可能被取代等伦理问题。", + "output_clean": "人工智能正以我们从未想象的方式重塑医疗行业。从利用医学影像进行早期疾病筛查,到基于基因数据制定个性化治疗方案,人工智能正帮助医生更快地做出更优决策。然而,我们同样必须应对患者隐私、算法偏见以及医疗人员可能被取代等伦理问题。", + "reference": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据的个性化治疗方案,AI正在帮助医生更快地做出更好的决策。然而,我们也必须解决围绕患者隐私、算法偏见以及医疗工作者可能被取代等伦理问题。", + "ttft_s": 0.3914, + "total_s": 4.0067, + "tps": 17.43, + "prompt_tps": 2982.09, + "eval_tokens": 63, + "prompt_tokens": 176, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "C_long_paragraphs", + "direction": "en->zh" + }, + { + "case_id": "D01", + "run": 1, + "input": "So basically what I'm trying to say is", + "output_raw": "所以我实际上想表达的是", + "output_clean": "所以我实际上想表达的是", + "reference": "所以基本上我想说的是", + "ttft_s": 0.616, + "total_s": 0.9085, + "tps": 20.51, + "prompt_tps": 434.03, + "eval_tokens": 6, + "prompt_tokens": 122, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D01", + "run": 2, + "input": "So basically what I'm trying to say is", + "output_raw": "所以基本上我想表达的是", + "output_clean": "所以基本上我想表达的是", + "reference": "所以基本上我想说的是", + "ttft_s": 0.3913, + "total_s": 0.6819, + "tps": 20.64, + "prompt_tps": 2081.06, + "eval_tokens": 6, + "prompt_tokens": 122, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D01", + "run": 3, + "input": "So basically what I'm trying to say is", + "output_raw": "所以我其实想表达的是", + "output_clean": "所以我其实想表达的是", + "reference": "所以基本上我想说的是", + "ttft_s": 0.3881, + "total_s": 0.6793, + "tps": 20.61, + "prompt_tps": 2069.13, + "eval_tokens": 6, + "prompt_tokens": 122, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D02", + "run": 1, + "input": "The reason we can't do that is because the", + "output_raw": "我们不能那样做的原因是因为", + "output_clean": "我们不能那样做的原因是因为", + "reference": "我们不能那样做的原因是因为", + "ttft_s": 0.6177, + "total_s": 0.9691, + "tps": 19.91, + "prompt_tps": 434.03, + "eval_tokens": 7, + "prompt_tokens": 123, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D02", + "run": 2, + "input": "The reason we can't do that is because the", + "output_raw": "我们无法做到这一点的原因是因为", + "output_clean": "我们无法做到这一点的原因是因为", + "reference": "我们不能那样做的原因是因为", + "ttft_s": 0.3886, + "total_s": 0.7968, + "tps": 19.6, + "prompt_tps": 2104.69, + "eval_tokens": 8, + "prompt_tokens": 123, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D02", + "run": 3, + "input": "The reason we can't do that is because the", + "output_raw": "我们无法做到这一点的原因是因为", + "output_clean": "我们无法做到这一点的原因是因为", + "reference": "我们不能那样做的原因是因为", + "ttft_s": 0.3895, + "total_s": 0.7965, + "tps": 19.65, + "prompt_tps": 2086.9, + "eval_tokens": 8, + "prompt_tokens": 123, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D03", + "run": 1, + "input": "If we look at the data from last", + "output_raw": "如果我们回顾一下上次的数据", + "output_clean": "如果我们回顾一下上次的数据", + "reference": "如果我们看一下上", + "ttft_s": 0.6117, + "total_s": 0.9044, + "tps": 20.49, + "prompt_tps": 432.37, + "eval_tokens": 6, + "prompt_tokens": 121, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D03", + "run": 2, + "input": "If we look at the data from last", + "output_raw": "如果我们看一下上次的数据", + "output_clean": "如果我们看一下上次的数据", + "reference": "如果我们看一下上", + "ttft_s": 0.391, + "total_s": 0.6235, + "tps": 21.52, + "prompt_tps": 2042.61, + "eval_tokens": 5, + "prompt_tokens": 121, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D03", + "run": 3, + "input": "If we look at the data from last", + "output_raw": "如果我们查看上次的数据", + "output_clean": "如果我们查看上次的数据", + "reference": "如果我们看一下上", + "ttft_s": 0.3888, + "total_s": 0.6214, + "tps": 21.5, + "prompt_tps": 2057.3, + "eval_tokens": 5, + "prompt_tokens": 121, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D04", + "run": 1, + "input": "I mean it's not that we don't want to but", + "output_raw": "我是说,我们并不是不想,而是", + "output_clean": "我是说,我们并不是不想,而是", + "reference": "我的意思是,不是我们不想,但是", + "ttft_s": 0.6228, + "total_s": 1.0901, + "tps": 19.26, + "prompt_tps": 431.99, + "eval_tokens": 9, + "prompt_tokens": 125, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D04", + "run": 2, + "input": "I mean it's not that we don't want to but", + "output_raw": "我是说,并不是我们不想,而是", + "output_clean": "我是说,并不是我们不想,而是", + "reference": "我的意思是,不是我们不想,但是", + "ttft_s": 0.3919, + "total_s": 0.8568, + "tps": 19.36, + "prompt_tps": 2114.95, + "eval_tokens": 9, + "prompt_tokens": 125, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D04", + "run": 3, + "input": "I mean it's not that we don't want to but", + "output_raw": "我的意思是,并不是我们不想,而是", + "output_clean": "我的意思是,并不是我们不想,而是", + "reference": "我的意思是,不是我们不想,但是", + "ttft_s": 0.3905, + "total_s": 0.856, + "tps": 19.33, + "prompt_tps": 2138.58, + "eval_tokens": 9, + "prompt_tokens": 125, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D05", + "run": 1, + "input": "Well the thing is um we actually", + "output_raw": "嗯,问题是这样的,实际上我们", + "output_clean": "嗯,问题是这样的,实际上我们", + "reference": "嗯,事情是这样的,实际上我们", + "ttft_s": 0.6083, + "total_s": 1.0172, + "tps": 19.56, + "prompt_tps": 431.35, + "eval_tokens": 8, + "prompt_tokens": 120, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D05", + "run": 2, + "input": "Well the thing is um we actually", + "output_raw": "嗯,问题是这样的,其实我们", + "output_clean": "嗯,问题是这样的,其实我们", + "reference": "嗯,事情是这样的,实际上我们", + "ttft_s": 0.3868, + "total_s": 0.7935, + "tps": 19.67, + "prompt_tps": 2040.78, + "eval_tokens": 8, + "prompt_tokens": 120, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "D05", + "run": 3, + "input": "Well the thing is um we actually", + "output_raw": "好吧,事实上,我们实际上", + "output_clean": "好吧,事实上,我们实际上", + "reference": "嗯,事情是这样的,实际上我们", + "ttft_s": 0.3914, + "total_s": 0.7409, + "tps": 20.02, + "prompt_tps": 2030.11, + "eval_tokens": 7, + "prompt_tokens": 120, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "D_incomplete_fragments", + "direction": "en->zh" + }, + { + "case_id": "E01", + "run": 1, + "input": "We deployed the new Kubernetes cluster on AWS EKS last Friday.", + "output_raw": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "output_clean": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "reference": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "ttft_s": 0.6004, + "total_s": 1.4755, + "tps": 18.29, + "prompt_tps": 468.7, + "eval_tokens": 16, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 2, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E01", + "run": 2, + "input": "We deployed the new Kubernetes cluster on AWS EKS last Friday.", + "output_raw": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "output_clean": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "reference": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "ttft_s": 0.3926, + "total_s": 1.2647, + "tps": 18.35, + "prompt_tps": 2142.86, + "eval_tokens": 16, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 2, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E01", + "run": 3, + "input": "We deployed the new Kubernetes cluster on AWS EKS last Friday.", + "output_raw": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "output_clean": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "reference": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "ttft_s": 0.3856, + "total_s": 1.2574, + "tps": 18.35, + "prompt_tps": 2157.31, + "eval_tokens": 16, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 2, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E02", + "run": 1, + "input": "The React Native app is crashing on iOS seventeen because of a WebSocket timeout issue.", + "output_raw": "由于WebSocket超时问题,React Native应用在iOS 17上崩溃了。", + "output_clean": "由于WebSocket超时问题,React Native应用在iOS 17上崩溃了。", + "reference": "React Native应用在iOS 17上因为WebSocket超时问题而崩溃。", + "ttft_s": 0.6007, + "total_s": 1.65, + "tps": 18.11, + "prompt_tps": 476.89, + "eval_tokens": 19, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E02", + "run": 2, + "input": "The React Native app is crashing on iOS seventeen because of a WebSocket timeout issue.", + "output_raw": "由于WebSocket超时问题,React Native应用在iOS 17上崩溃了。", + "output_clean": "由于WebSocket超时问题,React Native应用在iOS 17上崩溃了。", + "reference": "React Native应用在iOS 17上因为WebSocket超时问题而崩溃。", + "ttft_s": 0.3924, + "total_s": 1.4389, + "tps": 18.15, + "prompt_tps": 2176.81, + "eval_tokens": 19, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E02", + "run": 3, + "input": "The React Native app is crashing on iOS seventeen because of a WebSocket timeout issue.", + "output_raw": "由于WebSocket超时问题,React Native应用在iOS 17上崩溃了。", + "output_clean": "由于WebSocket超时问题,React Native应用在iOS 17上崩溃了。", + "reference": "React Native应用在iOS 17上因为WebSocket超时问题而崩溃。", + "ttft_s": 0.3895, + "total_s": 1.4353, + "tps": 18.17, + "prompt_tps": 2184.59, + "eval_tokens": 19, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E03", + "run": 1, + "input": "Apple announced the new M5 chip with improved Neural Engine performance at WWDC.", + "output_raw": "苹果公司在WWDC上宣布了新款M5芯片,其神经网络引擎性能得到提升。", + "output_clean": "苹果公司在WWDC上宣布了新款M5芯片,其神经网络引擎性能得到提升。", + "reference": "Apple在WWDC上发布了新的M5芯片,改进了Neural Engine性能。", + "ttft_s": 0.6008, + "total_s": 1.7657, + "tps": 18.03, + "prompt_tps": 475.39, + "eval_tokens": 21, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 4, + "missing_terms": [ + "Apple", + "Neural Engine" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E03", + "run": 2, + "input": "Apple announced the new M5 chip with improved Neural Engine performance at WWDC.", + "output_raw": "苹果公司在WWDC上发布了新款M5芯片,其神经网络引擎性能得到提升。", + "output_clean": "苹果公司在WWDC上发布了新款M5芯片,其神经网络引擎性能得到提升。", + "reference": "Apple在WWDC上发布了新的M5芯片,改进了Neural Engine性能。", + "ttft_s": 0.3882, + "total_s": 1.4948, + "tps": 18.07, + "prompt_tps": 2200.01, + "eval_tokens": 20, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 4, + "missing_terms": [ + "Apple", + "Neural Engine" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E03", + "run": 3, + "input": "Apple announced the new M5 chip with improved Neural Engine performance at WWDC.", + "output_raw": "苹果公司在WWDC上宣布了新款M5芯片,其神经引擎性能得到提升。", + "output_clean": "苹果公司在WWDC上宣布了新款M5芯片,其神经引擎性能得到提升。", + "reference": "Apple在WWDC上发布了新的M5芯片,改进了Neural Engine性能。", + "ttft_s": 0.3879, + "total_s": 1.491, + "tps": 18.13, + "prompt_tps": 2207.54, + "eval_tokens": 20, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 2, + "term_total": 4, + "missing_terms": [ + "Apple", + "Neural Engine" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E04", + "run": 1, + "input": "We need to implement OAuth two point zero authentication before integrating with the Stripe payment API.", + "output_raw": "在与Stripe支付API集成之前,我们需要实现OAuth 2.0身份验证。", + "output_clean": "在与Stripe支付API集成之前,我们需要实现OAuth 2.0身份验证。", + "reference": "在集成Stripe支付API之前,我们需要实现OAuth 2.0认证。", + "ttft_s": 0.6042, + "total_s": 1.6566, + "tps": 18.05, + "prompt_tps": 478.57, + "eval_tokens": 19, + "prompt_tokens": 130, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E04", + "run": 2, + "input": "We need to implement OAuth two point zero authentication before integrating with the Stripe payment API.", + "output_raw": "在与Stripe支付API集成之前,我们需要实施OAuth 2.0身份验证。", + "output_clean": "在与Stripe支付API集成之前,我们需要实施OAuth 2.0身份验证。", + "reference": "在集成Stripe支付API之前,我们需要实现OAuth 2.0认证。", + "ttft_s": 0.3563, + "total_s": 1.3624, + "tps": 18.89, + "prompt_tps": 4741.93, + "eval_tokens": 19, + "prompt_tokens": 130, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E04", + "run": 3, + "input": "We need to implement OAuth two point zero authentication before integrating with the Stripe payment API.", + "output_raw": "在与Stripe支付API集成之前,我们需要实现OAuth 2.0认证。", + "output_clean": "在与Stripe支付API集成之前,我们需要实现OAuth 2.0认证。", + "reference": "在集成Stripe支付API之前,我们需要实现OAuth 2.0认证。", + "ttft_s": 0.389, + "total_s": 1.3762, + "tps": 18.23, + "prompt_tps": 2202.12, + "eval_tokens": 18, + "prompt_tokens": 130, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 3, + "term_total": 3, + "missing_terms": [], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E05", + "run": 1, + "input": "The transformer model uses multi-head attention and layer normalization to process the input embeddings.", + "output_raw": "变换器模型通过多头注意力机制和层归一化来处理输入嵌入。", + "output_clean": "变换器模型通过多头注意力机制和层归一化来处理输入嵌入。", + "reference": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。", + "ttft_s": 0.602, + "total_s": 1.7085, + "tps": 18.07, + "prompt_tps": 476.74, + "eval_tokens": 20, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 2, + "missing_terms": [ + "transformer", + "multi-head attention" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E05", + "run": 2, + "input": "The transformer model uses multi-head attention and layer normalization to process the input embeddings.", + "output_raw": "变换模型使用多头注意力和层归一化来处理输入嵌入。", + "output_clean": "变换模型使用多头注意力和层归一化来处理输入嵌入。", + "reference": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。", + "ttft_s": 0.3886, + "total_s": 1.3759, + "tps": 18.23, + "prompt_tps": 2206.34, + "eval_tokens": 18, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 2, + "missing_terms": [ + "transformer", + "multi-head attention" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "E05", + "run": 3, + "input": "The transformer model uses multi-head attention and layer normalization to process the input embeddings.", + "output_raw": "变换模型通过多头注意力机制和层归一化来处理输入嵌入。", + "output_clean": "变换模型通过多头注意力机制和层归一化来处理输入嵌入。", + "reference": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。", + "ttft_s": 0.3889, + "total_s": 1.4364, + "tps": 18.13, + "prompt_tps": 2195.74, + "eval_tokens": 19, + "prompt_tokens": 129, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 2, + "missing_terms": [ + "transformer", + "multi-head attention" + ], + "category": "E_technical_terms", + "direction": "en->zh" + }, + { + "case_id": "F01", + "run": 1, + "input": "Let's start with the first one. How is it going so far?", + "output_raw": "我们先从第一个开始。目前进展如何?", + "output_clean": "我们先从第一个开始。目前进展如何?", + "reference": "让我们从第一个开始。目前进展如何?", + "ttft_s": 1.2718, + "total_s": 1.8571, + "tps": 18.79, + "prompt_tps": 214.09, + "eval_tokens": 11, + "prompt_tokens": 201, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F01", + "run": 2, + "input": "Let's start with the first one. How is it going so far?", + "output_raw": "我们先从第一个开始。目前进展如何?", + "output_clean": "我们先从第一个开始。目前进展如何?", + "reference": "让我们从第一个开始。目前进展如何?", + "ttft_s": 0.3941, + "total_s": 0.9772, + "tps": 18.86, + "prompt_tps": 3394.7, + "eval_tokens": 11, + "prompt_tokens": 201, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F01", + "run": 3, + "input": "Let's start with the first one. How is it going so far?", + "output_raw": "我们先从第一个开始。目前进展如何?", + "output_clean": "我们先从第一个开始。目前进展如何?", + "reference": "让我们从第一个开始。目前进展如何?", + "ttft_s": 0.3936, + "total_s": 0.9777, + "tps": 18.83, + "prompt_tps": 3426.99, + "eval_tokens": 11, + "prompt_tokens": 201, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F02", + "run": 1, + "input": "And what about the second item? When is the design review scheduled?", + "output_raw": "第二个项目是什么?设计评审什么时候安排?", + "output_clean": "第二个项目是什么?设计评审什么时候安排?", + "reference": "第二项呢?设计评审安排在什么时候?", + "ttft_s": 0.6069, + "total_s": 1.1342, + "tps": 18.96, + "prompt_tps": 737.17, + "eval_tokens": 10, + "prompt_tokens": 200, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F02", + "run": 2, + "input": "And what about the second item? When is the design review scheduled?", + "output_raw": "那么第二项呢?设计评审什么时候安排?", + "output_clean": "那么第二项呢?设计评审什么时候安排?", + "reference": "第二项呢?设计评审安排在什么时候?", + "ttft_s": 0.3908, + "total_s": 0.973, + "tps": 18.9, + "prompt_tps": 3399.97, + "eval_tokens": 11, + "prompt_tokens": 200, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "F02", + "run": 3, + "input": "And what about the second item? When is the design review scheduled?", + "output_raw": "第二个项目是什么?设计评审什么时候安排?", + "output_clean": "第二个项目是什么?设计评审什么时候安排?", + "reference": "第二项呢?设计评审安排在什么时候?", + "ttft_s": 0.3951, + "total_s": 0.9196, + "tps": 19.05, + "prompt_tps": 3377.47, + "eval_tokens": 10, + "prompt_tokens": 200, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "F_context_aware", + "direction": "en->zh" + }, + { + "case_id": "G01", + "run": 1, + "input": "这个方案的可行性还需要进一步评估。", + "output_raw": "The feasibility of this proposal still needs further assessment.", + "output_clean": "The feasibility of this proposal still needs further assessment.", + "reference": "The feasibility of this plan still needs further evaluation.", + "ttft_s": 1.2782, + "total_s": 1.8621, + "tps": 18.84, + "prompt_tps": 131.45, + "eval_tokens": 11, + "prompt_tokens": 123, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G01", + "run": 2, + "input": "这个方案的可行性还需要进一步评估。", + "output_raw": "The feasibility of this proposal still needs further assessment.", + "output_clean": "The feasibility of this proposal still needs further assessment.", + "reference": "The feasibility of this plan still needs further evaluation.", + "ttft_s": 0.3898, + "total_s": 0.9712, + "tps": 18.92, + "prompt_tps": 2087.68, + "eval_tokens": 11, + "prompt_tokens": 123, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G01", + "run": 3, + "input": "这个方案的可行性还需要进一步评估。", + "output_raw": "The feasibility of this proposal still needs further assessment.", + "output_clean": "The feasibility of this proposal still needs further assessment.", + "reference": "The feasibility of this plan still needs further evaluation.", + "ttft_s": 0.39, + "total_s": 0.9705, + "tps": 18.94, + "prompt_tps": 2088.89, + "eval_tokens": 11, + "prompt_tokens": 123, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G02", + "run": 1, + "input": "我们的日活用户已经突破了五百万。", + "output_raw": "Our daily active users have surpassed five million.", + "output_clean": "Our daily active users have surpassed five million.", + "reference": "Our daily active users have exceeded five million.", + "ttft_s": 0.62, + "total_s": 1.1456, + "tps": 19.02, + "prompt_tps": 434.43, + "eval_tokens": 10, + "prompt_tokens": 125, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G02", + "run": 2, + "input": "我们的日活用户已经突破了五百万。", + "output_raw": "Our daily active users have surpassed five million.", + "output_clean": "Our daily active users have surpassed five million.", + "reference": "Our daily active users have exceeded five million.", + "ttft_s": 0.3901, + "total_s": 0.9128, + "tps": 19.12, + "prompt_tps": 2118.97, + "eval_tokens": 10, + "prompt_tokens": 125, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G02", + "run": 3, + "input": "我们的日活用户已经突破了五百万。", + "output_raw": "Our daily active users have surpassed five million.", + "output_clean": "Our daily active users have surpassed five million.", + "reference": "Our daily active users have exceeded five million.", + "ttft_s": 0.3903, + "total_s": 0.9133, + "tps": 19.12, + "prompt_tps": 2123.36, + "eval_tokens": 10, + "prompt_tokens": 125, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G03", + "run": 1, + "input": "请大家在周五之前提交各自负责模块的测试报告。", + "output_raw": "Please submit your module's test report before Friday.", + "output_clean": "Please submit your module's test report before Friday.", + "reference": "Please submit the test reports for your respective modules by Friday.", + "ttft_s": 0.6024, + "total_s": 1.1866, + "tps": 18.83, + "prompt_tps": 469.27, + "eval_tokens": 11, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G03", + "run": 2, + "input": "请大家在周五之前提交各自负责模块的测试报告。", + "output_raw": "Please submit your module's test report before Friday.", + "output_clean": "Please submit your module's test report before Friday.", + "reference": "Please submit the test reports for your respective modules by Friday.", + "ttft_s": 0.3867, + "total_s": 0.9674, + "tps": 18.94, + "prompt_tps": 2150.54, + "eval_tokens": 11, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G03", + "run": 3, + "input": "请大家在周五之前提交各自负责模块的测试报告。", + "output_raw": "Please submit your module's test report before Friday.", + "output_clean": "Please submit your module's test report before Friday.", + "reference": "Please submit the test reports for your respective modules by Friday.", + "ttft_s": 0.3939, + "total_s": 0.9753, + "tps": 18.91, + "prompt_tps": 2129.89, + "eval_tokens": 11, + "prompt_tokens": 126, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G04", + "run": 1, + "input": "下一步我们要重点关注用户体验的优化,尤其是首屏加载速度。", + "output_raw": "Our next focus should be on optimizing the user experience, particularly the initial page load speed.", + "output_clean": "Our next focus should be on optimizing the user experience, particularly the initial page load speed.", + "reference": "Next, we need to focus on optimizing user experience, especially the initial screen loading speed.", + "ttft_s": 0.6036, + "total_s": 1.6528, + "tps": 18.11, + "prompt_tps": 470.95, + "eval_tokens": 19, + "prompt_tokens": 127, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G04", + "run": 2, + "input": "下一步我们要重点关注用户体验的优化,尤其是首屏加载速度。", + "output_raw": "Our next focus should be on optimizing the user experience, particularly the first-screen loading speed.", + "output_clean": "Our next focus should be on optimizing the user experience, particularly the first-screen loading speed.", + "reference": "Next, we need to focus on optimizing user experience, especially the initial screen loading speed.", + "ttft_s": 0.3928, + "total_s": 1.4393, + "tps": 18.15, + "prompt_tps": 2143.35, + "eval_tokens": 19, + "prompt_tokens": 127, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G04", + "run": 3, + "input": "下一步我们要重点关注用户体验的优化,尤其是首屏加载速度。", + "output_raw": "Our next focus should be on optimizing the user experience, particularly the first-screen load speed.", + "output_clean": "Our next focus should be on optimizing the user experience, particularly the first-screen load speed.", + "reference": "Next, we need to focus on optimizing user experience, especially the initial screen loading speed.", + "ttft_s": 0.3862, + "total_s": 1.4318, + "tps": 18.17, + "prompt_tps": 2168.83, + "eval_tokens": 19, + "prompt_tokens": 127, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G05", + "run": 1, + "input": "这个bug的根本原因是内存泄漏导致的,已经在最新版本中修复了。", + "output_raw": "The root cause of this bug is a memory leak, which has been fixed in the latest version.", + "output_clean": "The root cause of this bug is a memory leak, which has been fixed in the latest version.", + "reference": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "ttft_s": 0.6055, + "total_s": 1.7708, + "tps": 18.0, + "prompt_tps": 479.29, + "eval_tokens": 21, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G05", + "run": 2, + "input": "这个bug的根本原因是内存泄漏导致的,已经在最新版本中修复了。", + "output_raw": "The root cause of this bug is memory leakage, which has been fixed in the latest version.", + "output_clean": "The root cause of this bug is memory leakage, which has been fixed in the latest version.", + "reference": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "ttft_s": 0.3905, + "total_s": 1.4957, + "tps": 18.1, + "prompt_tps": 2240.62, + "eval_tokens": 20, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "G05", + "run": 3, + "input": "这个bug的根本原因是内存泄漏导致的,已经在最新版本中修复了。", + "output_raw": "The root cause of this bug is a memory leak, which has been fixed in the latest version.", + "output_clean": "The root cause of this bug is a memory leak, which has been fixed in the latest version.", + "reference": "The root cause of this bug was a memory leak, which has been fixed in the latest version.", + "ttft_s": 0.3905, + "total_s": 1.5519, + "tps": 18.08, + "prompt_tps": 2243.53, + "eval_tokens": 21, + "prompt_tokens": 131, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "G_reverse_direction", + "direction": "zh->en" + }, + { + "case_id": "H01", + "run": 1, + "input": "Thank you for joining today's meeting.", + "output_raw": "今日の会議にご参加いただき、ありがとうございます。", + "output_clean": "今日の会議にご参加いただき、ありがとうございます。", + "reference": "本日の会議にご参加いただきありがとうございます。", + "ttft_s": 1.2908, + "total_s": 1.9328, + "tps": 18.69, + "prompt_tps": 126.77, + "eval_tokens": 12, + "prompt_tokens": 121, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H01", + "run": 2, + "input": "Thank you for joining today's meeting.", + "output_raw": "今日の会議にご参加いただき、ありがとうございます。", + "output_clean": "今日の会議にご参加いただき、ありがとうございます。", + "reference": "本日の会議にご参加いただきありがとうございます。", + "ttft_s": 0.3875, + "total_s": 1.0277, + "tps": 18.74, + "prompt_tps": 2074.05, + "eval_tokens": 12, + "prompt_tokens": 121, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H01", + "run": 3, + "input": "Thank you for joining today's meeting.", + "output_raw": "今日の会議にご参加いただき、ありがとうございます。", + "output_clean": "今日の会議にご参加いただき、ありがとうございます。", + "reference": "本日の会議にご参加いただきありがとうございます。", + "ttft_s": 0.3863, + "total_s": 1.0249, + "tps": 18.78, + "prompt_tps": 2070.54, + "eval_tokens": 12, + "prompt_tokens": 121, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H02", + "run": 1, + "input": "The deadline for this project is the end of next month.", + "output_raw": "このプロジェクトの期限は来月末です。", + "output_clean": "このプロジェクトの期限は来月末です。", + "reference": "このプロジェクトの締め切りは来月末です。", + "ttft_s": 0.6177, + "total_s": 1.2016, + "tps": 18.83, + "prompt_tps": 436.11, + "eval_tokens": 11, + "prompt_tokens": 125, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H02", + "run": 2, + "input": "The deadline for this project is the end of next month.", + "output_raw": "このプロジェクトの期限は来月の終わりです。", + "output_clean": "このプロジェクトの期限は来月の終わりです。", + "reference": "このプロジェクトの締め切りは来月末です。", + "ttft_s": 0.392, + "total_s": 1.0316, + "tps": 18.75, + "prompt_tps": 2104.16, + "eval_tokens": 12, + "prompt_tokens": 125, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H02", + "run": 3, + "input": "The deadline for this project is the end of next month.", + "output_raw": "このプロジェクトの期限は来月末です。", + "output_clean": "このプロジェクトの期限は来月末です。", + "reference": "このプロジェクトの締め切りは来月末です。", + "ttft_s": 0.3889, + "total_s": 0.971, + "tps": 18.89, + "prompt_tps": 2142.21, + "eval_tokens": 11, + "prompt_tokens": 125, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H03", + "run": 1, + "input": "We need to reconsider our marketing strategy for the Asian market.", + "output_raw": "アジア市場におけるマーケティング戦略を再検討する必要がある。", + "output_clean": "アジア市場におけるマーケティング戦略を再検討する必要がある。", + "reference": "アジア市場向けのマーケティング戦略を再検討する必要があります。", + "ttft_s": 0.6237, + "total_s": 1.7292, + "tps": 18.09, + "prompt_tps": 429.31, + "eval_tokens": 20, + "prompt_tokens": 125, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H03", + "run": 2, + "input": "We need to reconsider our marketing strategy for the Asian market.", + "output_raw": "我々はアジア市場におけるマーケティング戦略を再検討する必要がある。", + "output_clean": "我々はアジア市場におけるマーケティング戦略を再検討する必要がある。", + "reference": "アジア市場向けのマーケティング戦略を再検討する必要があります。", + "ttft_s": 0.3897, + "total_s": 1.6139, + "tps": 17.97, + "prompt_tps": 2126.75, + "eval_tokens": 22, + "prompt_tokens": 125, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "H03", + "run": 3, + "input": "We need to reconsider our marketing strategy for the Asian market.", + "output_raw": "我々はアジア市場向けのマーケティング戦略を再検討する必要があります。", + "output_clean": "我々はアジア市場向けのマーケティング戦略を再検討する必要があります。", + "reference": "アジア市場向けのマーケティング戦略を再検討する必要があります。", + "ttft_s": 0.3943, + "total_s": 1.6159, + "tps": 18.01, + "prompt_tps": 2133.36, + "eval_tokens": 22, + "prompt_tokens": 125, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "H_japanese", + "direction": "en->ja" + }, + { + "case_id": "I01", + "run": 1, + "input": "Hello", + "output_raw": "你好", + "output_clean": "你好", + "reference": "你好", + "ttft_s": 0.7147, + "total_s": 0.775, + "tps": 33.12, + "prompt_tps": 306.74, + "eval_tokens": 2, + "prompt_tokens": 114, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I01", + "run": 2, + "input": "Hello", + "output_raw": "你好", + "output_clean": "你好", + "reference": "你好", + "ttft_s": 0.3894, + "total_s": 0.4475, + "tps": 34.29, + "prompt_tps": 1949.95, + "eval_tokens": 2, + "prompt_tokens": 114, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I01", + "run": 3, + "input": "Hello", + "output_raw": "你好", + "output_clean": "你好", + "reference": "你好", + "ttft_s": 0.3859, + "total_s": 0.444, + "tps": 34.28, + "prompt_tps": 1944.26, + "eval_tokens": 2, + "prompt_tokens": 114, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I02", + "run": 1, + "input": "Thank you", + "output_raw": "谢谢", + "output_clean": "谢谢", + "reference": "谢谢", + "ttft_s": 0.6028, + "total_s": 0.6631, + "tps": 33.05, + "prompt_tps": 421.48, + "eval_tokens": 2, + "prompt_tokens": 115, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I02", + "run": 2, + "input": "Thank you", + "output_raw": "谢谢", + "output_clean": "谢谢", + "reference": "谢谢", + "ttft_s": 0.3918, + "total_s": 0.45, + "tps": 34.33, + "prompt_tps": 1942.83, + "eval_tokens": 2, + "prompt_tokens": 115, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I02", + "run": 3, + "input": "Thank you", + "output_raw": "谢谢", + "output_clean": "谢谢", + "reference": "谢谢", + "ttft_s": 0.3922, + "total_s": 0.4502, + "tps": 34.19, + "prompt_tps": 1947.14, + "eval_tokens": 2, + "prompt_tokens": 115, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I03", + "run": 1, + "input": "I don't understand", + "output_raw": "我不明白", + "output_clean": "我不明白", + "reference": "我不明白", + "ttft_s": 0.6089, + "total_s": 0.7273, + "tps": 25.33, + "prompt_tps": 426.1, + "eval_tokens": 3, + "prompt_tokens": 117, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I03", + "run": 2, + "input": "I don't understand", + "output_raw": "我不明白", + "output_clean": "我不明白", + "reference": "我不明白", + "ttft_s": 0.3888, + "total_s": 0.505, + "tps": 25.82, + "prompt_tps": 1991.49, + "eval_tokens": 3, + "prompt_tokens": 117, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I03", + "run": 3, + "input": "I don't understand", + "output_raw": "我不明白", + "output_clean": "我不明白", + "reference": "我不明白", + "ttft_s": 0.3924, + "total_s": 0.5085, + "tps": 25.83, + "prompt_tps": 1986.59, + "eval_tokens": 3, + "prompt_tokens": 117, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I04", + "run": 1, + "input": "Can you help me?", + "output_raw": "你可以帮我吗?", + "output_clean": "你可以帮我吗?", + "reference": "你能帮我吗?", + "ttft_s": 0.6111, + "total_s": 0.846, + "tps": 21.28, + "prompt_tps": 421.37, + "eval_tokens": 5, + "prompt_tokens": 118, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I04", + "run": 2, + "input": "Can you help me?", + "output_raw": "你能帮我吗?", + "output_clean": "你能帮我吗?", + "reference": "你能帮我吗?", + "ttft_s": 0.3896, + "total_s": 0.6217, + "tps": 21.52, + "prompt_tps": 2020.1, + "eval_tokens": 5, + "prompt_tokens": 118, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I04", + "run": 3, + "input": "Can you help me?", + "output_raw": "你能帮我吗?", + "output_clean": "你能帮我吗?", + "reference": "你能帮我吗?", + "ttft_s": 0.3862, + "total_s": 0.6191, + "tps": 21.48, + "prompt_tps": 2018.65, + "eval_tokens": 5, + "prompt_tokens": 118, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I05", + "run": 1, + "input": "What is machine learning?", + "output_raw": "什么是机器学习?", + "output_clean": "什么是机器学习?", + "reference": "什么是机器学习?", + "ttft_s": 0.6078, + "total_s": 0.8425, + "tps": 21.25, + "prompt_tps": 428.78, + "eval_tokens": 5, + "prompt_tokens": 118, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I05", + "run": 2, + "input": "What is machine learning?", + "output_raw": "什么是机器学习?", + "output_clean": "什么是机器学习?", + "reference": "什么是机器学习?", + "ttft_s": 0.3896, + "total_s": 0.6226, + "tps": 21.45, + "prompt_tps": 1985.76, + "eval_tokens": 5, + "prompt_tokens": 118, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + }, + { + "case_id": "I05", + "run": 3, + "input": "What is machine learning?", + "output_raw": "什么是机器学习?", + "output_clean": "什么是机器学习?", + "reference": "什么是机器学习?", + "ttft_s": 0.3905, + "total_s": 0.6226, + "tps": 21.54, + "prompt_tps": 2012.04, + "eval_tokens": 5, + "prompt_tokens": 118, + "instruction_compliant": true, + "violation_detail": null, + "term_preserved": 0, + "term_total": 0, + "missing_terms": [], + "category": "I_instruction_compliance", + "direction": "en->zh" + } + ] +} \ No newline at end of file diff --git a/benchmark/benchmark_report.md b/benchmark/benchmark_report.md new file mode 100644 index 0000000..c4c7801 --- /dev/null +++ b/benchmark/benchmark_report.md @@ -0,0 +1,305 @@ +# 🏆 LCT Translation Benchmark Report +**生成时间**: 2026-06-11 18:38:55 + +## 📊 总体对比 + +| 指标 | gemma4:12b-mlx | qwen3.5:0.8b | qwen3.5:9b-mlx | qwen3.5:4b-mlx | translategemma:4b-it-q4_K_M | qwen3:4b-instruct-2507-q4_K_M | +| :--- | ---: | ---: | ---: | ---: | ---: | ---: | +| **首字延迟 (TTFT) 平均** | 694ms | 343ms | 1214ms | 210ms | 748ms | 500ms | +| **首字延迟 (TTFT) P50** | 415ms | 318ms | 273ms | 134ms | 743ms | 391ms | +| **首字延迟 (TTFT) P95** | 1736ms | 431ms | 1966ms | 734ms | 941ms | 1018ms | +| **生成速度 (TPS) 平均** | 8.0 t/s | 91.4 t/s | 17.7 t/s | 36.4 t/s | 53.1 t/s | 20.1 t/s | +| **生成速度 (TPS) 最低** | 5.3 t/s | 70.5 t/s | 0.3 t/s | 31.7 t/s | 35.6 t/s | 17.4 t/s | +| **端到端耗时 平均** | 2.54s | 0.54s | 3.68s | 0.57s | 1.07s | 1.28s | +| **端到端耗时 P50** | 2.01s | 0.50s | 0.91s | 0.46s | 0.98s | 1.03s | +| **端到端耗时 P95** | 10.03s | 1.07s | 10.11s | 1.88s | 2.59s | 4.00s | +| **指令遵循率** | 100% | 95% | 100% | 100% | 99% | 100% | +| **专有名词保留率** | 79% | 60% | 79% | 81% | 79% | 71% | + +## 📋 分类别详细数据 + +### 类别 A_short_utterances + +| 模型 | TTFT(avg) | TPS(avg) | 端到端(avg) | 指令遵循 | +| :--- | ---: | ---: | ---: | ---: | +| gemma4:12b-mlx | 660ms | 8.7 t/s | 1.35s | 15/15 | +| qwen3.5:0.8b | 345ms | 97.2 t/s | 0.41s | 15/15 | +| qwen3.5:9b-mlx | 408ms | 23.2 t/s | 0.59s | 15/15 | +| qwen3.5:4b-mlx | 222ms | 38.1 t/s | 0.34s | 15/15 | +| translategemma:4b-it-q4_K_M | 686ms | 57.6 t/s | 0.80s | 15/15 | +| qwen3:4b-instruct-2507-q4_K_M | 534ms | 20.5 t/s | 0.84s | 15/15 | + +### 类别 B_medium_sentences + +| 模型 | TTFT(avg) | TPS(avg) | 端到端(avg) | 指令遵循 | +| :--- | ---: | ---: | ---: | ---: | +| gemma4:12b-mlx | 556ms | 8.1 t/s | 2.70s | 15/15 | +| qwen3.5:0.8b | 349ms | 80.5 t/s | 0.56s | 14/15 | +| qwen3.5:9b-mlx | 4350ms | 9.0 t/s | 10.78s | 15/15 | +| qwen3.5:4b-mlx | 178ms | 34.5 t/s | 0.61s | 15/15 | +| translategemma:4b-it-q4_K_M | 758ms | 48.3 t/s | 1.15s | 15/15 | +| qwen3:4b-instruct-2507-q4_K_M | 474ms | 18.2 t/s | 1.43s | 15/15 | + +### 类别 C_long_paragraphs + +| 模型 | TTFT(avg) | TPS(avg) | 端到端(avg) | 指令遵循 | +| :--- | ---: | ---: | ---: | ---: | +| gemma4:12b-mlx | 1034ms | 7.0 t/s | 11.21s | 6/6 | +| qwen3.5:0.8b | 338ms | 83.1 t/s | 1.10s | 5/6 | +| qwen3.5:9b-mlx | 5621ms | 10.9 t/s | 27.98s | 6/6 | +| qwen3.5:4b-mlx | 283ms | 33.5 t/s | 2.04s | 6/6 | +| translategemma:4b-it-q4_K_M | 905ms | 36.7 t/s | 2.77s | 5/6 | +| qwen3:4b-instruct-2507-q4_K_M | 600ms | 17.4 t/s | 4.34s | 6/6 | + +### 类别 D_incomplete_fragments + +| 模型 | TTFT(avg) | TPS(avg) | 端到端(avg) | 指令遵循 | +| :--- | ---: | ---: | ---: | ---: | +| gemma4:12b-mlx | 643ms | 7.4 t/s | 1.54s | 15/15 | +| qwen3.5:0.8b | 335ms | 90.4 t/s | 0.44s | 13/15 | +| qwen3.5:9b-mlx | 298ms | 21.1 t/s | 0.62s | 15/15 | +| qwen3.5:4b-mlx | 169ms | 36.3 t/s | 0.37s | 15/15 | +| translategemma:4b-it-q4_K_M | 720ms | 55.4 t/s | 0.86s | 15/15 | +| qwen3:4b-instruct-2507-q4_K_M | 465ms | 20.1 t/s | 0.82s | 15/15 | + +### 类别 E_technical_terms + +| 模型 | TTFT(avg) | TPS(avg) | 端到端(avg) | 指令遵循 | +| :--- | ---: | ---: | ---: | ---: | +| gemma4:12b-mlx | 655ms | 7.8 t/s | 2.98s | 15/15 | +| qwen3.5:0.8b | 335ms | 81.1 t/s | 0.64s | 15/15 | +| qwen3.5:9b-mlx | 318ms | 19.8 t/s | 1.21s | 15/15 | +| qwen3.5:4b-mlx | 176ms | 34.5 t/s | 0.69s | 15/15 | +| translategemma:4b-it-q4_K_M | 767ms | 47.8 t/s | 1.16s | 15/15 | +| qwen3:4b-instruct-2507-q4_K_M | 458ms | 18.2 t/s | 1.48s | 15/15 | + +### 类别 F_context_aware + +| 模型 | TTFT(avg) | TPS(avg) | 端到端(avg) | 指令遵循 | +| :--- | ---: | ---: | ---: | ---: | +| gemma4:12b-mlx | 1053ms | 8.1 t/s | 2.29s | 6/6 | +| qwen3.5:0.8b | 349ms | 83.9 t/s | 0.49s | 6/6 | +| qwen3.5:9b-mlx | 537ms | 21.3 t/s | 1.01s | 6/6 | +| qwen3.5:4b-mlx | 313ms | 39.3 t/s | 0.56s | 6/6 | +| translategemma:4b-it-q4_K_M | 946ms | 49.6 t/s | 1.16s | 6/6 | +| qwen3:4b-instruct-2507-q4_K_M | 575ms | 18.9 t/s | 1.14s | 6/6 | + +### 类别 G_reverse_direction + +| 模型 | TTFT(avg) | TPS(avg) | 端到端(avg) | 指令遵循 | +| :--- | ---: | ---: | ---: | ---: | +| gemma4:12b-mlx | 715ms | 7.9 t/s | 2.57s | 15/15 | +| qwen3.5:0.8b | 348ms | 91.7 t/s | 0.56s | 15/15 | +| qwen3.5:9b-mlx | 431ms | 20.1 t/s | 1.13s | 15/15 | +| qwen3.5:4b-mlx | 246ms | 36.4 t/s | 0.62s | 15/15 | +| translategemma:4b-it-q4_K_M | 750ms | 49.9 t/s | 1.06s | 15/15 | +| qwen3:4b-instruct-2507-q4_K_M | 507ms | 18.6 t/s | 1.28s | 15/15 | + +### 类别 H_japanese + +| 模型 | TTFT(avg) | TPS(avg) | 端到端(avg) | 指令遵循 | +| :--- | ---: | ---: | ---: | ---: | +| gemma4:12b-mlx | 660ms | 7.8 t/s | 1.96s | 9/9 | +| qwen3.5:0.8b | 341ms | 95.4 t/s | 0.50s | 9/9 | +| qwen3.5:9b-mlx | 495ms | 17.3 t/s | 1.09s | 9/9 | +| qwen3.5:4b-mlx | 265ms | 37.7 t/s | 0.52s | 9/9 | +| translategemma:4b-it-q4_K_M | 753ms | 49.6 t/s | 0.99s | 9/9 | +| qwen3:4b-instruct-2507-q4_K_M | 541ms | 18.5 t/s | 1.35s | 9/9 | + +### 类别 I_instruction_compliance + +| 模型 | TTFT(avg) | TPS(avg) | 端到端(avg) | 指令遵循 | +| :--- | ---: | ---: | ---: | ---: | +| gemma4:12b-mlx | 680ms | 8.3 t/s | 1.05s | 15/15 | +| qwen3.5:0.8b | 349ms | 111.3 t/s | 0.46s | 13/15 | +| qwen3.5:9b-mlx | 417ms | 14.8 t/s | 0.63s | 15/15 | +| qwen3.5:4b-mlx | 164ms | 37.8 t/s | 0.23s | 15/15 | +| translategemma:4b-it-q4_K_M | 664ms | 69.7 t/s | 0.73s | 15/15 | +| qwen3:4b-instruct-2507-q4_K_M | 469ms | 27.3 t/s | 0.61s | 15/15 | + +## ⚠️ 指令违规详情 + +| 模型 | 用例 | 违规原因 | 模型原始输出 | +| :--- | :--- | :--- | :--- | +| qwen3.5:0.8b | B05 | 包含违规前缀: '当然...' | `当然可以,请您带领我看看架构图。我想让大家都保持一致的理解。` | +| qwen3.5:0.8b | C01 | 包含违规前缀: '好的...' | `好的,今天我将简要总结我们的讨论。我们同意继续推进选项 B,该方案涉及从零重构支付模块。时间窗口为八周,其中第一个里程碑` | +| qwen3.5:0.8b | D05 | 包含违规前缀: '好的...' | `好的,不过实际上是这样嘛。` | +| qwen3.5:0.8b | D05 | 包含违规前缀: '当然...' | `当然,情况是这样的:实际上是这样……` | +| qwen3.5:0.8b | I04 | 包含违规前缀: '当然...' | `当然可以,需要我帮您吗? 没问题,请告诉我您具体需要什么帮助。 不客气!` | +| qwen3.5:0.8b | I04 | 包含违规前缀: '当然...' | `当然可以帮你吗?` | +| translategemma:4b-it-q4_K_M | C01 | 包含违规前缀: '好的...' | `好的,我来总结一下今天的讨论。我们一致同意采用方案 B,即从头开始重构支付模块。时间表为八周,第一阶段的里程碑将在三周内` | + +## 🔍 专有名词丢失详情 + +| 模型 | 用例 | 丢失的术语 | 模型输出 | +| :--- | :--- | :--- | :--- | +| gemma4:12b-mlx | E03 | Apple, Neural Engine | `苹果在 WWDC 上发布了搭载改进型神经网络引擎性能的新款 M5 芯片。` | +| gemma4:12b-mlx | E03 | Apple, Neural Engine | `苹果在 WWDC 上发布了搭载性能更强的神经网络引擎的新款 M5 芯片。` | +| gemma4:12b-mlx | E03 | Apple, Neural Engine | `苹果在 WWDC 上发布了搭载改进型神经网络引擎性能的新款 M5 芯片。` | +| gemma4:12b-mlx | E05 | multi-head attention | `Transformer 模型采用多头注意力机制和层归一化来处理输入嵌入。` | +| gemma4:12b-mlx | E05 | multi-head attention | `Transformer 模型利用多头注意力机制和层归一化来处理输入的嵌入向量。` | +| gemma4:12b-mlx | E05 | multi-head attention | `Transformer 模型使用多头注意力机制和层归一化来处理输入的嵌入表示。` | +| qwen3.5:0.8b | E01 | AWS EKS | `我們於星期五將新構建的Kubernetes集群部署在AWS提供的EKS上。` | +| qwen3.5:0.8b | E01 | Kubernetes, AWS EKS | `我们在上周五将新版本的 K8s 集群部署在 Azure Active Directory (E)K,请注意。我确认了这些` | +| qwen3.5:0.8b | E01 | AWS EKS | `我们上周五部署了新的Kubernetes集群至AWS EC2。` | +| qwen3.5:0.8b | E02 | React Native | `应用在 17+ ios上出现崩溃,原因是 WebSocket 超时问题。 🔥` | +| qwen3.5:0.8b | E03 | Apple, Neural Engine | `苹果在WWDC会议上宣布了新的M5处理器,以提升神经网络引擎的性能。` | +| qwen3.5:0.8b | E03 | Neural Engine | `Apple 宣布新 M5 芯片在 WWDC 上具有更好的神经引擎性能。` | +| qwen3.5:0.8b | E03 | Apple, Neural Engine | `苹果在WWDC发布了新的M5芯片,该设计增强了Neural引擎的性能。` | +| qwen3.5:0.8b | E04 | Stripe, API | `我们需要在将 Paytm 支付集成前实现 OAuth v2 认证。` | +| qwen3.5:0.8b | E05 | multi-head attention | `Transformer 模型使用多头注意力机制和层归一化来处理嵌入表示。` | +| qwen3.5:0.8b | E05 | transformer, multi-head attention | `转储模型通过使用多头注意力机制和层归一化处理来处理输入嵌入。` | +| qwen3.5:0.8b | E05 | transformer, multi-head attention | `该转换器模型通过将嵌入数据输入到多头注意力机制和层归一化中,进行处理。` | +| qwen3.5:9b-mlx | E03 | Apple, Neural Engine | `苹果在 WWDC 上发布了新款 M5 芯片,其神经网络引擎性能得到提升。` | +| qwen3.5:9b-mlx | E03 | Apple, Neural Engine | `苹果在 WWDC 上发布了新款 M5 芯片,其神经网络引擎性能得到提升。` | +| qwen3.5:9b-mlx | E03 | Apple, Neural Engine | `苹果在WWDC上发布了性能更强大的神经引擎的M5芯片。` | +| qwen3.5:9b-mlx | E05 | multi-head attention | `该 Transformer 模型采用多头注意力和层归一化来处理输入嵌入。` | +| qwen3.5:9b-mlx | E05 | multi-head attention | `Transformer 模型使用多头注意力和层归一化来处理输入嵌入。` | +| qwen3.5:9b-mlx | E05 | multi-head attention | `Transformer 模型采用多头注意力机制和层归一化方法对输入嵌入进行处理。` | +| qwen3.5:4b-mlx | E02 | WebSocket | `React Native 应用因 Web Socket 超时问题在 iOS 17 上崩溃。` | +| qwen3.5:4b-mlx | E03 | Neural Engine | `Apple 在 WWDC 上发布了具有改进神经引擎性能的新 M5 芯片。` | +| qwen3.5:4b-mlx | E03 | Neural Engine | `Apple 在 WWDC 上发布了新款 M5 芯片,其神经网络引擎性能得到显著提升。` | +| qwen3.5:4b-mlx | E03 | Apple, Neural Engine | `苹果在 WWDC 上发布了性能更优的新 M5 芯片,其神经网络引擎得到显著提升。` | +| qwen3.5:4b-mlx | E05 | multi-head attention | `Transformer 模型利用多头注意力和层归一化处理输入嵌入。` | +| qwen3.5:4b-mlx | E05 | multi-head attention | `Transformer 模型利用多头注意力机制和层归一化处理输入嵌入。` | +| qwen3.5:4b-mlx | E05 | multi-head attention | `Transformer 模型利用多头注意力和层归一化来处理输入嵌入。` | +| translategemma:4b-it-q4_K_M | E03 | Apple, Neural Engine | `苹果在WWDC上宣布了搭载改进型神经网络引擎的M5芯片。` | +| translategemma:4b-it-q4_K_M | E03 | Apple, Neural Engine | `苹果在WWDC上宣布了搭载改进型神经网络引擎的M5芯片。` | +| translategemma:4b-it-q4_K_M | E03 | Apple, Neural Engine | `苹果在WWDC上宣布推出搭载改进型神经网络引擎的M5芯片。` | +| translategemma:4b-it-q4_K_M | E05 | multi-head attention | `该 Transformer 模型使用多头注意力机制和层归一化来处理输入嵌入。` | +| translategemma:4b-it-q4_K_M | E05 | multi-head attention | `Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。` | +| translategemma:4b-it-q4_K_M | E05 | multi-head attention | `Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。` | +| qwen3:4b-instruct-2507-q4_K_M | E03 | Apple, Neural Engine | `苹果公司在WWDC上宣布了新款M5芯片,其神经网络引擎性能得到提升。` | +| qwen3:4b-instruct-2507-q4_K_M | E03 | Apple, Neural Engine | `苹果公司在WWDC上发布了新款M5芯片,其神经网络引擎性能得到提升。` | +| qwen3:4b-instruct-2507-q4_K_M | E03 | Apple, Neural Engine | `苹果公司在WWDC上宣布了新款M5芯片,其神经引擎性能得到提升。` | +| qwen3:4b-instruct-2507-q4_K_M | E05 | transformer, multi-head attention | `变换器模型通过多头注意力机制和层归一化来处理输入嵌入。` | +| qwen3:4b-instruct-2507-q4_K_M | E05 | transformer, multi-head attention | `变换模型使用多头注意力和层归一化来处理输入嵌入。` | +| qwen3:4b-instruct-2507-q4_K_M | E05 | transformer, multi-head attention | `变换模型通过多头注意力机制和层归一化来处理输入嵌入。` | + +## 📝 翻译样本对比 + + +#### 用例 A01 +- **原文**: Let's get started. +- **参考**: 让我们开始吧。 +- **gemma4:12b-mlx**: 让我们开始吧。 +- **原文**: Let's get started. +- **参考**: 让我们开始吧。 +- **qwen3.5:0.8b**: 让我们开始吧。 +- **原文**: Let's get started. +- **参考**: 让我们开始吧。 +- **qwen3.5:9b-mlx**: 开始吧。 +- **原文**: Let's get started. +- **参考**: 让我们开始吧。 +- **qwen3.5:4b-mlx**: 让我们开始吧。 +- **原文**: Let's get started. +- **参考**: 让我们开始吧。 +- **translategemma:4b-it-q4_K_M**: 让我们开始吧。 +- **原文**: Let's get started. +- **参考**: 让我们开始吧。 +- **qwen3:4b-instruct-2507-q4_K_M**: 我们开始吧。 + + +#### 用例 B02 +- **原文**: The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why. +- **参考**: 用户留存率与上个月相比下降了百分之十五,我们需要找出原因。 +- **gemma4:12b-mlx**: 用户留存率较上月下降了百分之十五,我们需要找出原因。 +- **原文**: The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why. +- **参考**: 用户留存率与上个月相比下降了百分之十五,我们需要找出原因。 +- **qwen3.5:0.8b**: 用户留存率相比上个月下降了 15%。我们需要查明这一现象的原因,并且还需要继续讨论这个议题。 +- **原文**: The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why. +- **参考**: 用户留存率与上个月相比下降了百分之十五,我们需要找出原因。 +- **qwen3.5:9b-mlx**: 用户留存率较上月下降了百分之十五,我们需要找出原因。 +- **原文**: The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why. +- **参考**: 用户留存率与上个月相比下降了百分之十五,我们需要找出原因。 +- **qwen3.5:4b-mlx**: 用户留存率较去年同期下降了百分之十五,我们需要找出原因。 +- **原文**: The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why. +- **参考**: 用户留存率与上个月相比下降了百分之十五,我们需要找出原因。 +- **translategemma:4b-it-q4_K_M**: 用户留存率相较上个月下降了百分之十五,我们需要找出原因。 +- **原文**: The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why. +- **参考**: 用户留存率与上个月相比下降了百分之十五,我们需要找出原因。 +- **qwen3:4b-instruct-2507-q4_K_M**: 与上个月相比,用户留存率下降了百分之十五,我们需要找出原因。 + + +#### 用例 D03 +- **原文**: If we look at the data from last +- **参考**: 如果我们看一下上 +- **gemma4:12b-mlx**: 如果我们看一下过去的数据 +- **原文**: If we look at the data from last +- **参考**: 如果我们看一下上 +- **qwen3.5:0.8b**: 如果您能查看最新的统计数据。 +- **原文**: If we look at the data from last +- **参考**: 如果我们看一下上 +- **qwen3.5:9b-mlx**: 如果我们查看去年的数据, +- **原文**: If we look at the data from last +- **参考**: 如果我们看一下上 +- **qwen3.5:4b-mlx**: 如果从最近的数据来看 +- **原文**: If we look at the data from last +- **参考**: 如果我们看一下上 +- **translategemma:4b-it-q4_K_M**: 如果我们看一下上一次的数据 +- **原文**: If we look at the data from last +- **参考**: 如果我们看一下上 +- **qwen3:4b-instruct-2507-q4_K_M**: 如果我们回顾一下上次的数据 + + +#### 用例 E01 +- **原文**: We deployed the new Kubernetes cluster on AWS EKS last Friday. +- **参考**: 我们上周五在AWS EKS上部署了新的Kubernetes集群。 +- **gemma4:12b-mlx**: 我们上周五在 AWS EKS 上部署了新的 Kubernetes 集群。 +- **原文**: We deployed the new Kubernetes cluster on AWS EKS last Friday. +- **参考**: 我们上周五在AWS EKS上部署了新的Kubernetes集群。 +- **qwen3.5:0.8b**: 我們於星期五將新構建的Kubernetes集群部署在AWS提供的EKS上。 +- **原文**: We deployed the new Kubernetes cluster on AWS EKS last Friday. +- **参考**: 我们上周五在AWS EKS上部署了新的Kubernetes集群。 +- **qwen3.5:9b-mlx**: 上周五我们在 AWS EKS 上部署了新的 Kubernetes 集群。 +- **原文**: We deployed the new Kubernetes cluster on AWS EKS last Friday. +- **参考**: 我们上周五在AWS EKS上部署了新的Kubernetes集群。 +- **qwen3.5:4b-mlx**: 我们于上星期五在 AWS EKS 上部署了新的 Kubernetes 集群。 +- **原文**: We deployed the new Kubernetes cluster on AWS EKS last Friday. +- **参考**: 我们上周五在AWS EKS上部署了新的Kubernetes集群。 +- **translategemma:4b-it-q4_K_M**: 我们上周五在 AWS EKS 上部署了新的 Kubernetes 集群。 +- **原文**: We deployed the new Kubernetes cluster on AWS EKS last Friday. +- **参考**: 我们上周五在AWS EKS上部署了新的Kubernetes集群。 +- **qwen3:4b-instruct-2507-q4_K_M**: 我们上周五在AWS EKS上部署了新的Kubernetes集群。 + + +#### 用例 F01 +- **原文**: Let's start with the first one. How is it going so far? +- **参考**: 让我们从第一个开始。目前进展如何? +- **gemma4:12b-mlx**: 让我们从第一个开始。目前进展如何? +- **原文**: Let's start with the first one. How is it going so far? +- **参考**: 让我们从第一个开始。目前进展如何? +- **qwen3.5:0.8b**: 我们正在开始讨论第一个重点问题,进度如何? +- **原文**: Let's start with the first one. How is it going so far? +- **参考**: 让我们从第一个开始。目前进展如何? +- **qwen3.5:9b-mlx**: 我们首先从第一点开始,目前进展如何? +- **原文**: Let's start with the first one. How is it going so far? +- **参考**: 让我们从第一个开始。目前进展如何? +- **qwen3.5:4b-mlx**: 我们首先从第一项开始,目前的进展如何? +- **原文**: Let's start with the first one. How is it going so far? +- **参考**: 让我们从第一个开始。目前进展如何? +- **translategemma:4b-it-q4_K_M**: 我们先从第一个开始。目前进展如何? +- **原文**: Let's start with the first one. How is it going so far? +- **参考**: 让我们从第一个开始。目前进展如何? +- **qwen3:4b-instruct-2507-q4_K_M**: 我们先从第一个开始。目前进展如何? + + +#### 用例 I05 +- **原文**: What is machine learning? +- **参考**: 什么是机器学习? +- **gemma4:12b-mlx**: 什么是机器学习? +- **原文**: What is machine learning? +- **参考**: 什么是机器学习? +- **qwen3.5:0.8b**: 机器学习是什么? +- **原文**: What is machine learning? +- **参考**: 什么是机器学习? +- **qwen3.5:9b-mlx**: 机器学习是什么? +- **原文**: What is machine learning? +- **参考**: 什么是机器学习? +- **qwen3.5:4b-mlx**: 什么是机器学习? +- **原文**: What is machine learning? +- **参考**: 什么是机器学习? +- **translategemma:4b-it-q4_K_M**: 什么是机器学习? +- **原文**: What is machine learning? +- **参考**: 什么是机器学习? +- **qwen3:4b-instruct-2507-q4_K_M**: 什么是机器学习? diff --git a/benchmark/run_benchmark.py b/benchmark/run_benchmark.py new file mode 100644 index 0000000..ad68955 --- /dev/null +++ b/benchmark/run_benchmark.py @@ -0,0 +1,574 @@ +#!/usr/bin/env python3 +""" +LCT Translation Benchmark v1.0 +================================ +专为 LCT (LiveCaptions Translator) 项目设计的本地翻译模型性能测试工具。 +模拟真实同声传译场景,覆盖:短句、中句、长段落、不完整片段、技术术语、 +上下文连贯性、反向翻译、多语言、指令遵循等维度。 + +测试指标: + - TTFT (Time To First Token): 首字延迟 + - TPS (Tokens Per Second): 生成速度 + - Prompt Eval Speed: 输入处理速度 + - Total Latency: 端到端总耗时 + - Instruction Compliance: 指令遵循率 + - Term Preservation: 专有名词保留率 + +Usage: + python3 run_benchmark.py + python3 run_benchmark.py --models qwen3.5:4b-mlx qwen3:4b-instruct-2507-q4_K_M + python3 run_benchmark.py --categories A B E --runs 5 + python3 run_benchmark.py --output results.json +""" + +import urllib.request +import json +import time +import sys +import os +import argparse +import statistics +from datetime import datetime + +# ─── Configuration ─────────────────────────────────────────────────────────── + +OLLAMA_BASE_URL = "http://localhost:11434" +OLLAMA_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) + +DEFAULT_MODELS = [ + "qwen3.5:4b-mlx", + "qwen3:4b-instruct-2507-q4_K_M", +] + +TEMPERATURE = 0.3 +KEEP_ALIVE = "5m" + +# 指令遵循检测:如果翻译结果包含这些前缀,说明模型没有严格遵循指令 +INSTRUCTION_VIOLATION_PATTERNS = [ + "好的", "当然", "以下是", "翻译如下", "翻译:", "Translation:", + "Here is", "Sure", "Of course", "The translation", + "译文:", "翻译结果", "I'll translate", "Let me translate", + "```", "", "[thinking]", "🔤", +] + +# ─── Ollama API Calls ──────────────────────────────────────────────────────── + +def ollama_chat(model: str, messages: list, stream: bool = False) -> dict | None: + """Call Ollama /api/chat endpoint.""" + url = f"{OLLAMA_BASE_URL}/api/chat" + payload = { + "model": model, + "messages": messages, + "stream": stream, + "temperature": TEMPERATURE, + "keep_alive": KEEP_ALIVE, + "think": False, + } + req = urllib.request.Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + try: + with OLLAMA_OPENER.open(req, timeout=120) as resp: + return json.loads(resp.read().decode("utf-8")) + except Exception as e: + print(f" ❌ API Error ({model}): {e}") + return None + + +def ollama_chat_streaming(model: str, messages: list) -> dict | None: + """Call Ollama /api/chat with streaming to measure real TTFT.""" + url = f"{OLLAMA_BASE_URL}/api/chat" + payload = { + "model": model, + "messages": messages, + "stream": True, + "temperature": TEMPERATURE, + "keep_alive": KEEP_ALIVE, + "think": False, + } + req = urllib.request.Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + try: + t_start = time.perf_counter() + t_first_token = None + full_response = "" + total_eval_count = 0 + final_chunk = {} + + with OLLAMA_OPENER.open(req, timeout=120) as resp: + for line in resp: + chunk = json.loads(line.decode("utf-8")) + content = chunk.get("message", {}).get("content", "") + if content and t_first_token is None: + t_first_token = time.perf_counter() + full_response += content + if chunk.get("done", False): + final_chunk = chunk + + t_end = time.perf_counter() + + return { + "response": full_response, + "ttft_real": (t_first_token - t_start) if t_first_token else None, + "total_real": t_end - t_start, + "eval_count": final_chunk.get("eval_count", 0), + "eval_duration": final_chunk.get("eval_duration", 0), + "prompt_eval_count": final_chunk.get("prompt_eval_count", 0), + "prompt_eval_duration": final_chunk.get("prompt_eval_duration", 0), + "load_duration": final_chunk.get("load_duration", 0), + } + except Exception as e: + print(f" ❌ Streaming API Error ({model}): {e}") + return None + + +def warmup_model(model: str): + """Pre-load model into memory.""" + print(f" ⏳ 预热模型(加载到内存)...") + t0 = time.perf_counter() + ollama_chat(model, [{"role": "user", "content": "hi"}]) + # Send a second warmup to ensure JIT caches are populated for MLX + ollama_chat(model, [{"role": "user", "content": "hello"}]) + t1 = time.perf_counter() + print(f" ✅ 模型已就绪 (预热耗时: {t1 - t0:.1f}s)") + + +# ─── Test Data Loading ─────────────────────────────────────────────────────── + +def load_test_data(filepath: str) -> dict: + """Load test cases from JSON file.""" + with open(filepath, "r", encoding="utf-8") as f: + return json.load(f) + + +def build_messages(system_prompt: str, text: str, target_lang: str, + context_history: list | None = None) -> list: + """Build the chat message array matching LCT's real format.""" + prompt = system_prompt.replace("{TARGET_LANGUAGE}", target_lang) + messages = [{"role": "system", "content": prompt}] + + if context_history: + for ctx in context_history: + messages.append({"role": "user", "content": ctx["user"]}) + messages.append({"role": "assistant", "content": ctx["assistant"]}) + + messages.append({"role": "user", "content": f"🔤 {text} 🔤"}) + return messages + + +# ─── Quality Checks ───────────────────────────────────────────────────────── + +def clean_response(text: str) -> str: + """Clean model response the same way LCT does.""" + import re + # Remove think tags + text = re.sub(r".*?", "", text, flags=re.DOTALL) + text = re.sub(r"\[thinking\].*?\[/thinking\]", "", text, flags=re.DOTALL) + # Remove 🔤 markers + text = text.replace("🔤", "") + # Remove common prefixes + for prefix in ["Translation:", "翻译:", "译文:", "Here is the translation:", + "Here's the translation:", "翻译结果:"]: + if text.strip().startswith(prefix): + text = text.strip()[len(prefix):] + return text.strip() + + +def check_instruction_compliance(response: str) -> tuple[bool, str | None]: + """Check if the model strictly followed instructions (no extra chatter).""" + raw = response.strip() + for pattern in INSTRUCTION_VIOLATION_PATTERNS: + if raw.startswith(pattern): + return False, f"包含违规前缀: '{pattern}...'" + # Check for multi-line output (should be single line) + lines = [l for l in raw.split("\n") if l.strip()] + if len(lines) > 2: + return False, f"输出了 {len(lines)} 行(应为单行)" + return True, None + + +def check_term_preservation(response: str, terms: list[str]) -> tuple[int, int, list[str]]: + """Check how many technical terms were preserved in the translation.""" + preserved = 0 + missing = [] + for term in terms: + if term.lower() in response.lower(): + preserved += 1 + else: + missing.append(term) + return preserved, len(terms), missing + + +# ─── Core Benchmark Runner ─────────────────────────────────────────────────── + +def run_single_case(model: str, messages: list, case: dict, + run_idx: int) -> dict | None: + """Run a single test case and collect all metrics.""" + result = ollama_chat_streaming(model, messages) + if not result: + return None + + response_raw = result["response"] + response_clean = clean_response(response_raw) + + # --- Performance metrics --- + ttft = result["ttft_real"] + total_time = result["total_real"] + eval_count = result["eval_count"] + eval_duration_s = result["eval_duration"] / 1e9 if result["eval_duration"] else 0 + prompt_eval_count = result["prompt_eval_count"] + prompt_eval_duration_s = result["prompt_eval_duration"] / 1e9 if result["prompt_eval_duration"] else 0 + + tps = eval_count / eval_duration_s if eval_duration_s > 0 else 0 + prompt_tps = prompt_eval_count / prompt_eval_duration_s if prompt_eval_duration_s > 0 else 0 + + # --- Quality metrics --- + compliant, violation = check_instruction_compliance(response_raw) + + term_preserved = term_total = 0 + missing_terms = [] + if "preserve_terms" in case: + term_preserved, term_total, missing_terms = check_term_preservation( + response_clean, case["preserve_terms"] + ) + + return { + "case_id": case["id"], + "run": run_idx, + "input": case["input"], + "output_raw": response_raw, + "output_clean": response_clean, + "reference": case.get("reference", ""), + # Performance + "ttft_s": round(ttft, 4) if ttft else None, + "total_s": round(total_time, 4), + "tps": round(tps, 2), + "prompt_tps": round(prompt_tps, 2), + "eval_tokens": eval_count, + "prompt_tokens": prompt_eval_count, + # Quality + "instruction_compliant": compliant, + "violation_detail": violation, + "term_preserved": term_preserved, + "term_total": term_total, + "missing_terms": missing_terms, + } + + +def run_category(model: str, category_key: str, category: dict, + system_prompt: str, num_runs: int) -> list[dict]: + """Run all test cases in a category.""" + direction = category.get("direction", "en->zh") + _, target = direction.split("->") + target_lang_map = { + "zh": "Chinese", "en": "English", "ja": "Japanese", + "ko": "Korean", "es": "Spanish", "fr": "French", + "de": "German", "ru": "Russian", + } + target_lang = target_lang_map.get(target, "Chinese") + + context_history = category.get("context_history", None) + results = [] + + desc = category.get("description", "") + print(f"\n 📋 类别 {category_key}: {desc}") + print(f" 方向: {direction} | 用例数: {len(category['cases'])} | 重复: {num_runs}次") + + for case in category["cases"]: + messages = build_messages(system_prompt, case["input"], target_lang, context_history) + + for run_idx in range(1, num_runs + 1): + r = run_single_case(model, messages, case, run_idx) + if r: + r["category"] = category_key + r["direction"] = direction + results.append(r) + + # Print concise progress + status = "✅" if r["instruction_compliant"] else "⚠️" + ttft_str = f"{r['ttft_s']*1000:.0f}ms" if r["ttft_s"] else "N/A" + print(f" {status} {r['case_id']}(run{run_idx}): " + f"TTFT={ttft_str} | TPS={r['tps']:.1f} | " + f"Total={r['total_s']:.2f}s | " + f"「{r['output_clean'][:40]}{'...' if len(r['output_clean'])>40 else ''}」") + + return results + + +# ─── Report Generation ─────────────────────────────────────────────────────── + +def generate_report(all_results: dict[str, list[dict]]) -> str: + """Generate a comprehensive markdown report.""" + lines = [] + lines.append("# 🏆 LCT Translation Benchmark Report") + lines.append(f"**生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") + + # ── Per-model summary ── + model_summaries = {} + + for model, results in all_results.items(): + if not results: + continue + + ttfts = [r["ttft_s"] * 1000 for r in results if r["ttft_s"] is not None] + tpss = [r["tps"] for r in results if r["tps"] > 0] + totals = [r["total_s"] for r in results] + compliant_count = sum(1 for r in results if r["instruction_compliant"]) + total_terms = sum(r["term_total"] for r in results) + preserved_terms = sum(r["term_preserved"] for r in results) + + model_summaries[model] = { + "ttft_avg": statistics.mean(ttfts) if ttfts else 0, + "ttft_p50": statistics.median(ttfts) if ttfts else 0, + "ttft_p95": sorted(ttfts)[int(len(ttfts) * 0.95)] if ttfts else 0, + "ttft_min": min(ttfts) if ttfts else 0, + "ttft_max": max(ttfts) if ttfts else 0, + "tps_avg": statistics.mean(tpss) if tpss else 0, + "tps_min": min(tpss) if tpss else 0, + "tps_max": max(tpss) if tpss else 0, + "total_avg": statistics.mean(totals), + "total_p50": statistics.median(totals), + "total_p95": sorted(totals)[int(len(totals) * 0.95)], + "compliance_rate": compliant_count / len(results) * 100, + "compliance_detail": f"{compliant_count}/{len(results)}", + "term_rate": preserved_terms / total_terms * 100 if total_terms > 0 else 100, + "term_detail": f"{preserved_terms}/{total_terms}", + "total_cases": len(results), + } + + # ── Overall comparison table ── + lines.append("## 📊 总体对比") + lines.append("") + lines.append("| 指标 | " + " | ".join(all_results.keys()) + " |") + lines.append("| :--- | " + " | ".join(["---:" for _ in all_results]) + " |") + + metrics = [ + ("首字延迟 (TTFT) 平均", "ttft_avg", "ms", "{:.0f}ms"), + ("首字延迟 (TTFT) P50", "ttft_p50", "ms", "{:.0f}ms"), + ("首字延迟 (TTFT) P95", "ttft_p95", "ms", "{:.0f}ms"), + ("生成速度 (TPS) 平均", "tps_avg", "", "{:.1f} t/s"), + ("生成速度 (TPS) 最低", "tps_min", "", "{:.1f} t/s"), + ("端到端耗时 平均", "total_avg", "s", "{:.2f}s"), + ("端到端耗时 P50", "total_p50", "s", "{:.2f}s"), + ("端到端耗时 P95", "total_p95", "s", "{:.2f}s"), + ("指令遵循率", "compliance_rate", "%", "{:.0f}%"), + ("专有名词保留率", "term_rate", "%", "{:.0f}%"), + ] + + for label, key, _, fmt in metrics: + row = f"| **{label}** |" + vals = [] + for model in all_results: + s = model_summaries.get(model, {}) + val = s.get(key, 0) + vals.append(val) + row += f" {fmt.format(val)} |" + lines.append(row) + + lines.append("") + + # ── Per-category breakdown ── + lines.append("## 📋 分类别详细数据") + + categories_seen = [] + for results in all_results.values(): + for r in results: + if r["category"] not in categories_seen: + categories_seen.append(r["category"]) + + for cat in categories_seen: + lines.append(f"\n### 类别 {cat}") + lines.append("") + lines.append("| 模型 | TTFT(avg) | TPS(avg) | 端到端(avg) | 指令遵循 |") + lines.append("| :--- | ---: | ---: | ---: | ---: |") + + for model, results in all_results.items(): + cat_results = [r for r in results if r["category"] == cat] + if not cat_results: + continue + ttfts = [r["ttft_s"] * 1000 for r in cat_results if r["ttft_s"]] + tpss = [r["tps"] for r in cat_results if r["tps"] > 0] + totals = [r["total_s"] for r in cat_results] + comp = sum(1 for r in cat_results if r["instruction_compliant"]) + lines.append( + f"| {model} | " + f"{statistics.mean(ttfts):.0f}ms | " + f"{statistics.mean(tpss):.1f} t/s | " + f"{statistics.mean(totals):.2f}s | " + f"{comp}/{len(cat_results)} |" + ) + + # ── Instruction violations ── + violations = [] + for model, results in all_results.items(): + for r in results: + if not r["instruction_compliant"]: + violations.append((model, r)) + + if violations: + lines.append("\n## ⚠️ 指令违规详情") + lines.append("") + lines.append("| 模型 | 用例 | 违规原因 | 模型原始输出 |") + lines.append("| :--- | :--- | :--- | :--- |") + for model, r in violations: + output_preview = r["output_raw"][:60].replace("\n", "↵").replace("|", "\\|") + lines.append( + f"| {model} | {r['case_id']} | {r['violation_detail']} | `{output_preview}` |" + ) + + # ── Missing terms ── + term_issues = [] + for model, results in all_results.items(): + for r in results: + if r["missing_terms"]: + term_issues.append((model, r)) + + if term_issues: + lines.append("\n## 🔍 专有名词丢失详情") + lines.append("") + lines.append("| 模型 | 用例 | 丢失的术语 | 模型输出 |") + lines.append("| :--- | :--- | :--- | :--- |") + for model, r in term_issues: + output_preview = r["output_clean"][:60].replace("|", "\\|") + lines.append( + f"| {model} | {r['case_id']} | {', '.join(r['missing_terms'])} | `{output_preview}` |" + ) + + # ── Translation samples ── + lines.append("\n## 📝 翻译样本对比") + lines.append("") + + sample_ids = ["A01", "B02", "D03", "E01", "F01", "I05"] + for sid in sample_ids: + lines.append(f"\n#### 用例 {sid}") + for model, results in all_results.items(): + for r in results: + if r["case_id"] == sid and r["run"] == 1: + lines.append(f"- **原文**: {r['input']}") + lines.append(f"- **参考**: {r['reference']}") + break + for r in results: + if r["case_id"] == sid and r["run"] == 1: + lines.append(f"- **{model}**: {r['output_clean']}") + break + lines.append("") + + return "\n".join(lines) + + +# ─── Main ───────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="LCT Translation Benchmark") + parser.add_argument("--models", nargs="+", default=DEFAULT_MODELS, + help="Models to benchmark") + parser.add_argument("--categories", nargs="+", default=None, + help="Category prefixes to run (e.g., A B E). Default: all") + parser.add_argument("--runs", type=int, default=3, + help="Number of repetitions per test case (default: 3)") + parser.add_argument("--data", type=str, + default=os.path.join(os.path.dirname(__file__), "test_data.json"), + help="Path to test data JSON file") + parser.add_argument("--output", type=str, default=None, + help="Path to save raw results JSON") + parser.add_argument("--report", type=str, default=None, + help="Path to save markdown report") + args = parser.parse_args() + + # Load test data + print("=" * 60) + print(" LCT Translation Benchmark v1.0") + print("=" * 60) + print(f"\n📂 加载测试数据: {args.data}") + data = load_test_data(args.data) + system_prompt = data["system_prompt"] + test_cases = data["test_cases"] + + # Filter categories if specified + if args.categories: + filtered = {} + for key, val in test_cases.items(): + prefix = key.split("_")[0] + if prefix in args.categories: + filtered[key] = val + test_cases = filtered + + total_cases = sum(len(cat["cases"]) for cat in test_cases.values()) + print(f"📊 测试类别: {len(test_cases)} 个") + print(f"📝 测试用例: {total_cases} 个 × {args.runs} 次 = {total_cases * args.runs} 次调用") + print(f"🤖 测试模型: {', '.join(args.models)}") + print(f" 总调用次数: {total_cases * args.runs * len(args.models)} 次") + print(f" (预计耗时取决于模型加载和每次调用延迟)\n") + + # Check Ollama connectivity + try: + req = urllib.request.Request(f"{OLLAMA_BASE_URL}/api/tags") + with OLLAMA_OPENER.open(req, timeout=5) as resp: + tags = json.loads(resp.read().decode("utf-8")) + available = [m["name"] for m in tags.get("models", [])] + print(f"✅ Ollama 已连接,可用模型: {', '.join(available)}") + except Exception as e: + print(f"❌ 无法连接 Ollama ({OLLAMA_BASE_URL}): {e}") + sys.exit(1) + + # Verify requested models exist + for model in args.models: + if model not in available: + print(f"⚠️ 警告: 模型 '{model}' 不在 Ollama 已安装列表中") + + # Run benchmarks + all_results = {} + + for model_idx, model in enumerate(args.models, 1): + print(f"\n{'='*60}") + print(f"🤖 [{model_idx}/{len(args.models)}] 测试模型: {model}") + print(f"{'='*60}") + + warmup_model(model) + + model_results = [] + for cat_key, cat_data in test_cases.items(): + cat_results = run_category(model, cat_key, cat_data, + system_prompt, args.runs) + model_results.extend(cat_results) + + all_results[model] = model_results + print(f"\n 📊 模型 {model} 完成: {len(model_results)} 个结果") + + # Generate report + print(f"\n{'='*60}") + print("📊 生成测试报告...") + print(f"{'='*60}\n") + + report = generate_report(all_results) + + # Save report + report_path = args.report or os.path.join(os.path.dirname(__file__), "benchmark_report.md") + with open(report_path, "w", encoding="utf-8") as f: + f.write(report) + print(f"📄 报告已保存: {report_path}") + + # Save raw results + if args.output: + with open(args.output, "w", encoding="utf-8") as f: + json.dump(all_results, f, ensure_ascii=False, indent=2) + print(f"💾 原始数据已保存: {args.output}") + + # Also save raw results by default + raw_path = os.path.join(os.path.dirname(__file__), "benchmark_raw.json") + with open(raw_path, "w", encoding="utf-8") as f: + json.dump(all_results, f, ensure_ascii=False, indent=2) + print(f"💾 原始数据已保存: {raw_path}") + + # Print summary to terminal + print("\n" + report) + print("\n✅ Benchmark 完成!") + + +if __name__ == "__main__": + main() diff --git a/benchmark/test_data.json b/benchmark/test_data.json new file mode 100644 index 0000000..3bff639 --- /dev/null +++ b/benchmark/test_data.json @@ -0,0 +1,281 @@ +{ + "metadata": { + "version": "1.0", + "description": "LCT Translation Benchmark Test Data", + "created": "2026-06-04", + "note": "Simulates real-time speech-to-translation scenarios" + }, + "system_prompt": "You are a professional simultaneous interpreter. Translate the text enclosed in 🔤 markers to {TARGET_LANGUAGE}.\n\nCRITICAL RULES:\n1. Output ONLY the translated text, never the original text\n2. Handle incomplete sentences naturally and professionally\n3. Preserve technical terms, company names, and proper nouns accurately\n4. Maintain appropriate tone and formality\n5. For unclear speech, provide the most likely interpretation\n\nOUTPUT FORMAT: Single line translation only, remove all 🔤 markers, no explanations.", + + "test_cases": { + "A_short_utterances": { + "description": "短句/口语片段 — 模拟会议中的简短发言", + "direction": "en->zh", + "cases": [ + { + "id": "A01", + "input": "Let's get started.", + "reference": "让我们开始吧。" + }, + { + "id": "A02", + "input": "Can you hear me?", + "reference": "你能听到我吗?" + }, + { + "id": "A03", + "input": "That's a great point.", + "reference": "说得很好。" + }, + { + "id": "A04", + "input": "I'll share my screen.", + "reference": "我来共享一下屏幕。" + }, + { + "id": "A05", + "input": "Any questions so far?", + "reference": "到目前为止有什么问题吗?" + } + ] + }, + + "B_medium_sentences": { + "description": "中等长度句子 — 模拟会议中的正常发言", + "direction": "en->zh", + "cases": [ + { + "id": "B01", + "input": "I think we should postpone the launch to next quarter because the QA team hasn't finished testing yet.", + "reference": "我认为我们应该把发布推迟到下个季度,因为QA团队还没有完成测试。" + }, + { + "id": "B02", + "input": "The user retention rate dropped by fifteen percent compared to last month, and we need to figure out why.", + "reference": "用户留存率与上个月相比下降了百分之十五,我们需要找出原因。" + }, + { + "id": "B03", + "input": "We're planning to migrate our infrastructure from AWS to Google Cloud by the end of this year.", + "reference": "我们计划在今年年底前将基础设施从AWS迁移到Google Cloud。" + }, + { + "id": "B04", + "input": "The new privacy policy requires us to obtain explicit user consent before collecting any personal data.", + "reference": "新的隐私政策要求我们在收集任何个人数据之前获得用户的明确同意。" + }, + { + "id": "B05", + "input": "Could you walk us through the architecture diagram? I want to make sure everyone is on the same page.", + "reference": "你能给我们讲一下架构图吗?我想确保大家的理解是一致的。" + } + ] + }, + + "C_long_paragraphs": { + "description": "长段落 — 模拟演讲、课程讲座的连续发言", + "direction": "en->zh", + "cases": [ + { + "id": "C01", + "input": "So let me summarize today's discussion. We agreed to move forward with option B, which involves rebuilding the payment module from scratch. The timeline is eight weeks, with the first milestone due in three weeks. John will lead the backend team, and Sarah will handle the frontend integration. We also need to coordinate with the compliance team to ensure we meet all regulatory requirements before going live.", + "reference": "让我来总结一下今天的讨论。我们同意推进方案B,即从零开始重建支付模块。时间线是八周,第一个里程碑在三周内到期。John将带领后端团队,Sarah负责前端集成。我们还需要与合规团队协调,确保上线前满足所有监管要求。" + }, + { + "id": "C02", + "input": "Artificial intelligence is transforming the healthcare industry in ways we never imagined. From early disease detection using medical imaging to personalized treatment plans based on genetic data, AI is helping doctors make better decisions faster. However, we must also address the ethical concerns around patient privacy, algorithmic bias, and the potential displacement of healthcare workers.", + "reference": "人工智能正在以我们从未想象过的方式改变医疗行业。从利用医学影像进行早期疾病检测,到基于基因数据的个性化治疗方案,AI正在帮助医生更快地做出更好的决策。然而,我们也必须解决围绕患者隐私、算法偏见以及医疗工作者可能被取代等伦理问题。" + } + ] + }, + + "D_incomplete_fragments": { + "description": "不完整句子/语音片段 — 模拟实时语音识别的中间状态", + "direction": "en->zh", + "cases": [ + { + "id": "D01", + "input": "So basically what I'm trying to say is", + "reference": "所以基本上我想说的是" + }, + { + "id": "D02", + "input": "The reason we can't do that is because the", + "reference": "我们不能那样做的原因是因为" + }, + { + "id": "D03", + "input": "If we look at the data from last", + "reference": "如果我们看一下上" + }, + { + "id": "D04", + "input": "I mean it's not that we don't want to but", + "reference": "我的意思是,不是我们不想,但是" + }, + { + "id": "D05", + "input": "Well the thing is um we actually", + "reference": "嗯,事情是这样的,实际上我们" + } + ] + }, + + "E_technical_terms": { + "description": "技术术语保留 — 验证模型不会错误翻译专有名词和技术词汇", + "direction": "en->zh", + "cases": [ + { + "id": "E01", + "input": "We deployed the new Kubernetes cluster on AWS EKS last Friday.", + "reference": "我们上周五在AWS EKS上部署了新的Kubernetes集群。", + "preserve_terms": ["Kubernetes", "AWS EKS"] + }, + { + "id": "E02", + "input": "The React Native app is crashing on iOS seventeen because of a WebSocket timeout issue.", + "reference": "React Native应用在iOS 17上因为WebSocket超时问题而崩溃。", + "preserve_terms": ["React Native", "iOS", "WebSocket"] + }, + { + "id": "E03", + "input": "Apple announced the new M5 chip with improved Neural Engine performance at WWDC.", + "reference": "Apple在WWDC上发布了新的M5芯片,改进了Neural Engine性能。", + "preserve_terms": ["Apple", "M5", "Neural Engine", "WWDC"] + }, + { + "id": "E04", + "input": "We need to implement OAuth two point zero authentication before integrating with the Stripe payment API.", + "reference": "在集成Stripe支付API之前,我们需要实现OAuth 2.0认证。", + "preserve_terms": ["OAuth", "Stripe", "API"] + }, + { + "id": "E05", + "input": "The transformer model uses multi-head attention and layer normalization to process the input embeddings.", + "reference": "Transformer模型使用多头注意力机制和层归一化来处理输入嵌入。", + "preserve_terms": ["transformer", "multi-head attention"] + } + ] + }, + + "F_context_aware": { + "description": "上下文连贯性 — 模拟带历史对话的连续翻译(LCT支持最多5轮上下文)", + "direction": "en->zh", + "context_history": [ + { + "user": "🔤 Today we're going to discuss the Q3 roadmap for Project Phoenix. 🔤", + "assistant": "今天我们将讨论Project Phoenix的第三季度路线图。" + }, + { + "user": "🔤 The main focus areas are performance optimization and the new dashboard. 🔤", + "assistant": "主要关注领域是性能优化和新的仪表盘。" + } + ], + "cases": [ + { + "id": "F01", + "input": "Let's start with the first one. How is it going so far?", + "reference": "让我们从第一个开始。目前进展如何?", + "note": "模型应该理解 'the first one' 指的是 '性能优化'" + }, + { + "id": "F02", + "input": "And what about the second item? When is the design review scheduled?", + "reference": "第二项呢?设计评审安排在什么时候?", + "note": "模型应该理解 'the second item' 指的是 '新的仪表盘'" + } + ] + }, + + "G_reverse_direction": { + "description": "反向翻译 zh->en — 验证模型的双向翻译能力", + "direction": "zh->en", + "cases": [ + { + "id": "G01", + "input": "这个方案的可行性还需要进一步评估。", + "reference": "The feasibility of this plan still needs further evaluation." + }, + { + "id": "G02", + "input": "我们的日活用户已经突破了五百万。", + "reference": "Our daily active users have exceeded five million." + }, + { + "id": "G03", + "input": "请大家在周五之前提交各自负责模块的测试报告。", + "reference": "Please submit the test reports for your respective modules by Friday." + }, + { + "id": "G04", + "input": "下一步我们要重点关注用户体验的优化,尤其是首屏加载速度。", + "reference": "Next, we need to focus on optimizing user experience, especially the initial screen loading speed." + }, + { + "id": "G05", + "input": "这个bug的根本原因是内存泄漏导致的,已经在最新版本中修复了。", + "reference": "The root cause of this bug was a memory leak, which has been fixed in the latest version." + } + ] + }, + + "H_japanese": { + "description": "日语翻译 en->ja — 验证非中文目标语言", + "direction": "en->ja", + "cases": [ + { + "id": "H01", + "input": "Thank you for joining today's meeting.", + "reference": "本日の会議にご参加いただきありがとうございます。" + }, + { + "id": "H02", + "input": "The deadline for this project is the end of next month.", + "reference": "このプロジェクトの締め切りは来月末です。" + }, + { + "id": "H03", + "input": "We need to reconsider our marketing strategy for the Asian market.", + "reference": "アジア市場向けのマーケティング戦略を再検討する必要があります。" + } + ] + }, + + "I_instruction_compliance": { + "description": "指令遵循压力测试 — 验证模型是否严格只输出翻译,不加废话", + "direction": "en->zh", + "cases": [ + { + "id": "I01", + "input": "Hello", + "reference": "你好", + "check": "模型不应输出'你好!有什么可以帮助您的?'之类的对话回复" + }, + { + "id": "I02", + "input": "Thank you", + "reference": "谢谢", + "check": "模型不应输出'不客气'之类的回复" + }, + { + "id": "I03", + "input": "I don't understand", + "reference": "我不明白", + "check": "模型不应试图去解释什么" + }, + { + "id": "I04", + "input": "Can you help me?", + "reference": "你能帮我吗?", + "check": "模型应翻译这句话,而不是真的去'帮忙'" + }, + { + "id": "I05", + "input": "What is machine learning?", + "reference": "什么是机器学习?", + "check": "模型应翻译这个问句,而不是去回答这个问题" + } + ] + } + } +} diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md new file mode 100644 index 0000000..05af973 --- /dev/null +++ b/docs/USER_GUIDE.md @@ -0,0 +1,52 @@ +# 用户使用手册:LCTMac 实时语音翻译助手 🎧 + +欢迎使用 LCTMac!这是一款能够“听到”并“翻译”你的视频会议、播客、视频的实时翻译悬浮窗工具。 + +## 📥 准备工作与初次启动 + +1. **安装大模型底层 (Ollama)** + - 本应用基于本地大语言模型进行翻译,因此请先前往 [Ollama 官网](https://ollama.com) 下载并安装 Ollama。 + - 打开终端,输入命令下载推荐翻译模型: + ```bash + ollama run qwen3.5:4b-mlx + ``` + 等待下载完成后,保持 Ollama 在后台运行。 + +2. **启动 LCTMac** + - 首次启动 LCTMac 时,系统可能会请求 **麦克风权限** 与 **语音识别权限**。请点击“允许”,以便 LCT 能捕获声音并将其转写为文字。 + +--- + +## 🎮 界面与交互指南 + +LCTMac 采用了全新的 **“极简毛玻璃悬浮条” (Glassmorphism HUD)** 交互模式,它就像一条精致的翻译字幕带,不阻挡任何有效内容。 + +### 1. 悬停展开控制栏 (渐进式交互) +- 默认情况下,LCTMac 只有一块半透明的毛玻璃区域。它默默滚动显示翻译字幕,不会打扰你的视线。 +- 当你需要进行设置或者开关录制时,**将鼠标移动(悬停)进入窗口上方**,你会看到顶部的功能栏和底部的控制栏平滑地展开。 +- 移开鼠标后,它们会自动隐身。 + +### 2. 核心操作介绍 +当鼠标悬停并展开面板后: +- **Start / Stop (底部绿色/红色按钮)**:点击开始捕获音频并进行识别翻译,再次点击停止。 +- **音量指示条 (底部绿条)**:实时反馈它正在听到的声音大小。 +- **设置按钮 (右上角 ⚙️)**:打开配置中心。你可以在这里修改大语言模型、更改要翻译的源语言(支持英、日、中等 14 种语言)和目标语言。 +- **历史记录 (右上角 🕒)**:查看并复制之前所有的翻译原文和翻译文稿。 +- **Scroll to Bottom**:如果不小心向上滚动了历史记录,翻译自动追踪会暂停。点击浮现的“⬇️ Scroll to Bottom”按钮可以一键回到底部并恢复自动滚屏跟进。 + +--- + +## ⚙️ 核心设置(推荐配置) + +进入设置页面 (⚙️) 后,请确认以下高级选项,以获取最佳体验: + +- **Ollama Model**:推荐输入 `qwen3.5:4b-mlx`。这是一个 40 亿参数量的极速模型,能在保证翻译和润色(去除“啊、嗯”等口语)质量的同时,极大地降低延迟。 +- **Context Aware (上下文感知)**:建议开启。开启后,遇到难以拆分的长难句时,AI 会结合你上一句说过的话综合翻译,不会显得断章取义。 + +## 📝 字幕排版说明 + +为了你能在繁忙的工作或者视频中快速捕获重点,我们在字幕卡片上做了清晰的层级区分: +- **较小、浅灰色的字体**:这是原音识别下来的口语草稿(带有语气词,供你参考)。 +- **较大、加粗的彩色字体**:这是经过大模型提炼、润色并翻译出来的最终完美版文本。如果文本呈现青色(Cyan),代表模型“正在极速思考并生成中”;呈现主色(黑色/白色,视系统外观而定)则代表生成已完成并固化。 + +准备好了吗?点击底部的 **Start** 按钮,开始你的无语言障碍交流之旅吧! diff --git a/docs/VIDEO_SCRIPT.md b/docs/VIDEO_SCRIPT.md new file mode 100644 index 0000000..933e81a --- /dev/null +++ b/docs/VIDEO_SCRIPT.md @@ -0,0 +1,78 @@ +# 🎬 LiveCaptions Translator (LCTMac) 项目介绍视频脚本 + +**视频时长**:约 3 分钟 +**视频风格**:极客风、快节奏、B-Roll展示与屏幕录制结合 +**背景音乐**:节奏轻快、科技感的电子节拍器(Synthwave 或 Lofi Chill) + +--- + +### [00:00 - 00:30] 第一部分:痛点引入与破局 + +**【画面】** +- (B-Roll)一个用户戴着耳机,正在看一段无字幕的外语技术讲座视频,或者在开跨国 Zoom 视频会议。眉头紧锁,显然听得有些吃力。 +- 突然,镜头推近屏幕边缘。一个拥有高级“毛玻璃”质感的窄边框透明悬浮窗优雅地滑入画面。 +- 视频里的英文原声一出,悬浮窗上立刻以肉眼可见的极快速度,流式吐出了“经过精美排版和口语去噪”的中文翻译。 + +**【旁白(Voiceover)】** +> “跨国会议、无字幕外网生肉、外语连麦……语言障碍总是打破信息获取的流畅感。传统的语音翻译软件,要么网络延迟让人抓狂,要么把‘嗯、啊、那个’全翻出来,词不达意,并且那巨大厚重的窗口总是遮挡你的屏幕。” +> +> “今天,为你介绍专为 macOS 打造的原生实时翻译神器:**LiveCaptions Translator (LCTMac)**。极致轻量、零延迟感知、而且——完全本地化。” + +--- + +### [00:30 - 01:20] 第二部分:核心卖点展示 (UI与原生性能) + +**【画面】** +- (屏幕录制)展示 LCTMac 的“渐进式毛玻璃 HUD”。鼠标不在窗口时,它就像融化在桌面壁纸里,只有滚动的翻译字幕浮现在空中。 +- 鼠标移入(Hover),顶部的设置栏和底部的音频跳动条顺滑地淡入展现。 +- 开启 Activity Monitor (活动监视器),特写 LCTMac 极低的内存(十几 MB)与 CPU 占用。 + +**【旁白】** +> “LCTMac 摒弃了臃肿的跨平台框架。纯净的 Swift 与 SwiftUI 铸就了它无以伦比的性能与与生俱来的 macOS 基因。它引入了极简的毛玻璃悬浮交互设计。当你专注工作时,它是一条透明的滚动画框,绝不喧宾夺主;当鼠标悬停,控制面板如魔法般浮现,掌控一切。” +> +> “它不仅长得好看,更轻得不可思议。基于苹果原生的 SFSpeechRecognizer 框架捕获系统级音频与语音识别,几乎零负担。” + +--- + +### [01:20 - 02:10] 第三部分:真正的“魔法”——单路流式翻译管道与大模型 + +**【画面】** +- (架构演示动画 / 示意图)麦克风图标 -> 生成了一段杂乱带有“uh, you know”的英文草稿。 +- 草稿进入一个发光的方块(Ollama 图标)。 +- 输出分为两层文本:原句被浓缩成了小字注脚,而精炼的完美中文用大字加粗流式打在屏幕上。 +- (屏幕录制)打开设置面板,展示填写了 `qwen3.5:4b-mlx` 模型的过程。 + +**【旁白】** +> “但 LCTMac 最迷人的魔法,在它的‘大脑’里。我们重构了字幕的流式管道处理引擎(Caption Segmenter)。对于随口而出的破碎语音,它并非死板地直翻。” +> +> “我们无缝对接了 Ollama 本地大语言模型。只需要部署一个像 Qwen 4B 这样的甜点级本地模型,LCTMac 就能在后台执行高难度的‘单次提问双向输出’指令:它会一秒去除你听到的口头禅、纠正语病,再流式输出最高质量的本土化翻译。” +> +> “而且别忘了,这一切都在你的 Mac 本地沙盒里进行。**你的会议机密,一个字节都不会传到云端。**” + +--- + +### [02:10 - 02:40] 第四部分:极客最爱的开源工作流 + +**【画面】** +- (屏幕录制)打开 GitHub 页面,展示 LCTMac 的代码仓库。 +- 切换到 Actions 页面,展示一个绿色的 ✅ “macOS Build and Release” 正在打出一个 `LCTMac-macOS.zip` 的下载包。 + +**【旁白】** +> “作为一个开源项目,我们为你铺平了所有道路。LCT 内部集成了完整的自动化 CI/CD GitHub Actions 工作流,只要提交代码,立刻在云端打包生成 Mac 应用程序分发包。” +> +> “下载、解压、双击即用。” + +--- + +### [02:40 - 03:00] 第五部分:Outro 与 Call to Action + +**【画面】** +- 快速蒙太奇(Montage):在看 YouTube 视频时使用 LCTMac、在开 Zoom 会议时使用、在看无字日漫时使用。 +- 屏幕中央出现 GitHub 项目链接地址与项目 Logo。 + +**【旁白】** +> “打破语言的高墙,把隐私留在本地,用最优雅的姿态体验跨语言的魅力。” +> +> “这就是 LiveCaptions Translator。即刻前往 GitHub 下载体验,如果你觉得好用,别忘了给我们的项目点一个 Star 🌟。让我们一起,重新定义本地 AI 翻译!” + +*(视频渐暗,淡出,音乐结尾)* diff --git a/macos/IMPROVEMENT_PLAN.md b/macos/IMPROVEMENT_PLAN.md new file mode 100644 index 0000000..0aa51b2 --- /dev/null +++ b/macos/IMPROVEMENT_PLAN.md @@ -0,0 +1,383 @@ +# LCT macOS 详细改进计划 + +## 目标 +将 macOS 版本的 LCT 打造成与 Windows 版本功能对等的实时字幕翻译应用,使用 Apple SFSpeechRecognizer 作为语音识别引擎,Ollama 作为本地翻译引擎。 + +--- + +## 一、架构对比与改进策略 + +### 当前架构问题 + +| 组件 | Windows 端 | macOS 当前实现 | 问题 | +|------|-----------|---------------|------| +| 语音识别 | Windows Live Captions (系统级) | SFSpeechRecognizer + Whisper (混合) | 方向分散,需要专注 SFSpeechRecognizer | +| 翻译服务 | Ollama (完整集成) | Ollama (基本实现) | 缺少模型管理和翻译队列 | +| 历史记录 | SQLite + DataMigration | SQLite (基本实现) | 缺少 CSV 导出 | +| UI 窗口 | Main + Overlay + Settings + Splash | Main + Overlay + Settings | 缺少启动画面 | + +### 目标架构 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ LCT macOS │ +├─────────────────────────────────────────────────────────────────┤ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │ +│ │ AudioCapture │───>│ SFSpeechRecognizer│───>│ Caption │ │ +│ │ (System/Mic) │ │ (语音转文字) │ │ Model │ │ +│ └─────────────────┘ └─────────────────┘ └──────┬──────┘ │ +│ │ │ +│ ┌─────────────────┐ ┌─────────────────┐ │ │ +│ │ Translation │<───│ TranslationQueue│<──────────┘ │ +│ │ (Ollama) │ │ (防抖/队列) │ │ +│ └────────┬────────┘ └─────────────────┘ │ +│ │ │ +│ v │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │ +│ │ History │ │ MainView │ │ OverlayView │ │ +│ │ (SQLite) │ │ (主窗口) │ │ (浮动窗口) │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 二、改进计划(按优先级排序) + +### 阶段一:核心功能完善 (高优先级) ✅ 已完成 + +#### 1.1 专注 SFSpeechRecognizer 实现 ✅ +**目标**: 移除 Whisper 相关代码,专注优化 Apple 原生语音识别 + +**已完成**: +- [x] 移除 `WhisperBridgeService.swift` +- [x] 移除 `WhisperEngine/` Python 目录 +- [x] 优化 `SpeechAnalyzerService.swift`: + - 添加多语言支持 + - 添加语言动态切换 + - 改进部分结果处理逻辑 + - 添加识别置信度计算 + - 添加连续识别支持 +- [x] 更新 `AppSettings.swift`: + - 移除 Whisper 相关设置 + - 添加 SourceLanguage 源语言选择 + - 添加 customPrompt 自定义 Prompt 支持 + +**修改/新建文件**: +``` +Services/SpeechAnalyzerService.swift - 重写优化 ✅ +Services/WhisperBridgeService.swift - 已删除 ✅ +Models/AppSettings.swift - 已简化 ✅ +Views/SettingsView.swift - 已更新 UI ✅ +``` + +#### 1.2 翻译队列系统 ✅ +**目标**: 实现类似 Windows 端的 `TranslationTaskQueue` + +**已完成**: +- [x] 创建 `TranslationQueue.swift`: + - 防抖机制(避免频繁翻译) + - 任务取消机制 + - 优先级处理 + - 失败重试(指数退避) +- [x] 更新 `TranscriptionVM.swift`: + - 集成翻译队列 + - 改进文本同步逻辑 + +**新建文件**: +``` +Services/TranslationQueue.swift - 已创建 ✅ +``` + +#### 1.3 Caption 模型完善 ✅ +**目标**: 实现类似 Windows 端的 `Caption` 模型,管理原文/译文和上下文 + +**已完成**: +- [x] 创建 `Caption.swift` 模型: + - 原文显示文本 + - 译文显示文本 + - 历史上下文队列 + - 上下文管理方法 +- [x] 创建 `TextUtils.swift` 工具类: + - 标点符号处理 + - CJK 字符检测 + - 句末标点添加 + - 翻译输出清理 + +**新建文件**: +``` +Models/Caption.swift - 已创建 ✅ +Utils/TextUtils.swift - 已创建 ✅ +``` + +--- + +### 阶段二:Ollama 集成增强 (中优先级) ✅ 已完成 + +#### 2.1 Ollama 服务管理 ✅ +**目标**: 自动检测和管理 Ollama 服务状态 + +**已完成**: +- [x] 创建 `OllamaGuardian.swift`: + - 检测 Ollama 是否安装 + - 检测 Ollama 服务状态 + - 自动启动 Ollama 服务 + - 监控服务健康状态 + - 获取 Ollama 版本 +- [x] 创建 `OllamaModelManager.swift`: + - 列出已安装模型 + - 下载推荐模型(带进度) + - 模型预热/卸载 + - 删除模型 +- [x] 更新 UI: + - 添加 Ollama 状态指示器 + - 添加首次运行引导 + +**新建文件**: +``` +Services/OllamaGuardian.swift - 已创建 ✅ +Services/OllamaModelManager.swift - 已创建 ✅ +Views/WelcomeView.swift - 已创建 ✅ +``` + +#### 2.2 翻译 Prompt 优化 ✅ +**目标**: 提供与 Windows 端一致的翻译质量 + +**已完成**: +- [x] 更新默认翻译 Prompt(专业同声传译风格) +- [x] 添加可自定义 Prompt 设置 +- [x] 添加 Prompt 编辑器 UI + +**修改文件**: +``` +Models/AppSettings.swift - 已添加 customPrompt ✅ +Views/SettingsView.swift - 已添加 Prompt 编辑器 ✅ +``` + +--- + +### 阶段三:UI/UX 改进 (中优先级) + +#### 3.1 Overlay 窗口增强 +**目标**: 实现与 Windows 端一致的浮动窗口体验 + +**任务列表**: +- [ ] 增强 `OverlayView.swift`: + - 可调整大小 + - 可拖拽移动 + - 记忆窗口位置 + - 背景颜色/透明度自定义 + - 字体颜色自定义 + - 显示句数控制 +- [ ] 创建 `OverlayWindowController.swift` 改进: + - 置顶功能 + - 点击穿透选项 + - 多显示器支持 + +**修改文件**: +``` +Views/OverlayView.swift - 增强 +Views/OverlayWindowController.swift - 新建/改进 +Models/AppSettings.swift - 添加 Overlay 设置 +``` + +#### 3.2 主窗口改进 +**目标**: 提供更好的用户体验 + +**任务列表**: +- [ ] 添加 Log Cards 功能: + - 显示最近的识别/翻译记录 + - 可配置显示数量 + - 支持复制 +- [ ] 改进工具栏: + - 添加一键复制 + - 添加暂停/恢复按钮 + - 添加清除按钮 +- [ ] 状态栏改进: + - 显示延迟 + - 显示 Ollama 状态 + - 显示识别语言 + +**修改文件**: +``` +Views/MainView.swift - 改进 +Views/Components/LogCard.swift - 新建 +Views/Components/StatusBar.swift - 新建 +``` + +#### 3.3 菜单栏集成 +**目标**: 添加系统菜单栏图标和快捷操作 + +**任务列表**: +- [ ] 创建菜单栏图标 +- [ ] 添加快捷菜单: + - 开始/停止 + - 暂停/恢复 + - 显示/隐藏 Overlay + - 打开设置 + - 退出应用 +- [ ] 添加全局快捷键支持 + +**新建文件**: +``` +App/MenuBarController.swift - 新建 +``` + +--- + +### 阶段四:历史记录与数据管理 (低优先级) + +#### 4.1 历史记录增强 +**目标**: 完善历史记录功能 + +**任务列表**: +- [ ] 增强 `HistoryService.swift`: + - 添加搜索功能 + - 添加日期过滤 + - 添加批量删除 +- [ ] 添加 CSV 导出功能 +- [ ] 添加数据迁移支持 +- [ ] 改进 `HistoryView.swift`: + - 添加搜索栏 + - 添加日期选择器 + - 添加导出按钮 + - 改进列表性能 + +**修改文件**: +``` +Services/HistoryService.swift - 增强 +Views/HistoryView.swift - 改进 +``` + +#### 4.2 设置持久化改进 +**目标**: 使用 JSON 文件存储设置,便于备份和同步 + +**任务列表**: +- [ ] 将设置从 UserDefaults 迁移到 JSON 文件 +- [ ] 添加设置导入/导出 +- [ ] 添加设置重置功能 + +**修改文件**: +``` +Models/AppSettings.swift - 改进持久化 +``` + +--- + +### 阶段五:高级功能 (低优先级) + +#### 5.1 应用更新检查 +**任务列表**: +- [ ] 创建 `UpdateService.swift`: + - 检查 GitHub releases + - 显示更新对话框 + - 下载更新 + +#### 5.2 启动引导 +**任务列表**: +- [ ] 创建 `SplashView.swift`: + - 显示启动画面 + - 检查权限 + - 检查 Ollama 状态 + - 首次运行引导 + +--- + +## 三、文件结构规划 + +``` +LCTMac/ +├── App/ +│ ├── AppDelegate.swift +│ ├── LCTMacApp.swift +│ └── MenuBarController.swift # 新建 +├── Models/ +│ ├── AppSettings.swift # 修改 +│ ├── Caption.swift # 新建 +│ ├── Speaker.swift +│ ├── TranscriptionResult.swift +│ └── TranslationEntry.swift +├── Services/ +│ ├── AudioCaptureService.swift +│ ├── HistoryService.swift # 增强 +│ ├── OllamaService.swift # 增强 +│ ├── OllamaGuardian.swift # 新建 +│ ├── OllamaModelManager.swift # 新建 +│ ├── SpeechAnalyzerService.swift # 重写 +│ ├── TranslationQueue.swift # 新建 +│ └── UpdateService.swift # 新建 +├── Utils/ +│ └── TextUtils.swift # 新建 +├── ViewModels/ +│ └── TranscriptionVM.swift # 改进 +├── Views/ +│ ├── Components/ +│ │ ├── LogCard.swift # 新建 +│ │ └── StatusBar.swift # 新建 +│ ├── HistoryView.swift # 改进 +│ ├── MainView.swift # 改进 +│ ├── OverlayView.swift # 增强 +│ ├── SettingsView.swift # 更新 +│ ├── SplashView.swift # 新建 +│ └── WelcomeView.swift # 新建 +└── Resources/ + └── Localizable.strings # 新建(国际化) +``` + +--- + +## 四、时间估算 + +| 阶段 | 预估时间 | 优先级 | +|------|---------|--------| +| 阶段一:核心功能完善 | 2-3 天 | 高 | +| 阶段二:Ollama 集成增强 | 1-2 天 | 中 | +| 阶段三:UI/UX 改进 | 2-3 天 | 中 | +| 阶段四:历史记录与数据管理 | 1 天 | 低 | +| 阶段五:高级功能 | 1-2 天 | 低 | +| **总计** | **7-11 天** | - | + +--- + +## 五、立即可开始的任务 + +### 优先级 1: 清理代码和专注 SFSpeechRecognizer +1. 删除 Whisper 相关代码 +2. 优化 SpeechAnalyzerService +3. 简化 AppSettings + +### 优先级 2: 实现翻译队列 +1. 创建 TranslationQueue +2. 添加防抖机制 +3. 集成到 ViewModel + +### 优先级 3: 改进 Overlay +1. 添加拖拽/调整大小 +2. 添加设置持久化 +3. 改进视觉效果 + +--- + +## 六、依赖项 + +### 现有依赖 +- SQLite.swift (历史记录) + +### 建议添加 +- 无需额外依赖,使用 Apple 原生框架即可 + +--- + +## 七、权限需求 + +确保 `Info.plist` 和 `entitlements` 包含: +- `NSMicrophoneUsageDescription` - 麦克风权限 +- `NSSpeechRecognitionUsageDescription` - 语音识别权限 +- `NSAppleEventsUsageDescription` - Apple Events 权限(如果需要) +- `com.apple.security.audio.capture` - 音频捕获 + +--- + +**文档版本**: 1.0 +**创建日期**: 2026-02-03 +**作者**: AI Assistant diff --git a/macos/LCTMac/App/AppDelegate.swift b/macos/LCTMac/App/AppDelegate.swift new file mode 100644 index 0000000..c31e311 --- /dev/null +++ b/macos/LCTMac/App/AppDelegate.swift @@ -0,0 +1,176 @@ +import SwiftUI +import AppKit +import CoreGraphics +@preconcurrency import ScreenCaptureKit +import os + +/// Custom output stream that writes to a log file +private final class FileLogStream { + let fileHandle: FileHandle + + init(fileHandle: FileHandle) { + self.fileHandle = fileHandle + } + + func write(_ string: String) { + if let data = string.data(using: .utf8) { + fileHandle.write(data) + } + } +} + +nonisolated(unsafe) private var _logStream: FileLogStream? +private let _logLock = NSLock() + +/// Global log function that writes to both console and file +func appLog(_ message: String) { + let line = "\(message)\n" + _logLock.lock() + defer { _logLock.unlock() } + + _logStream?.write(line) + fputs(line, stderr) +} + +/// Application delegate for handling app lifecycle events +@MainActor +class AppDelegate: NSObject, NSApplicationDelegate { + private var statusItem: NSStatusItem? + private var overlayWindow: NSWindow? + + func applicationDidFinishLaunching(_ notification: Notification) { + // Setup log file + let logPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Logs/LCTMac.log") + FileManager.default.createFile(atPath: logPath.path, contents: nil) + if let fh = FileHandle(forWritingAtPath: logPath.path) { + fh.truncateFile(atOffset: 0) + _logStream = FileLogStream(fileHandle: fh) + } + + appLog("LCT for macOS launched successfully — log: \(logPath.path)") + + // Ensure the app is recognized as a foreground GUI application + // This is critical for SPM-built executables that aren't inside a .app bundle + NSApp.setActivationPolicy(.regular) + + // Setup status bar item (optional) + setupStatusBarItem() + + // Request necessary permissions + requestPermissions() + + // Activate the app and bring window to front + DispatchQueue.main.async { + NSApp.activate(ignoringOtherApps: true) + // Ensure the main window is visible + if let window = NSApp.windows.first(where: { $0.canBecomeMain }) { + window.makeKeyAndOrderFront(nil) + } + } + } + + func applicationWillTerminate(_ notification: Notification) { + print("LCT for macOS terminating...") + + // Cleanup + cleanup() + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + // Keep app running in background with status bar + return false + } + + // MARK: - Status Bar + + private func setupStatusBarItem() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + + if let button = statusItem?.button { + button.image = NSImage(systemSymbolName: "waveform.circle", accessibilityDescription: "LCT") + button.action = #selector(statusBarClicked) + button.target = self + } + + // Create menu + let menu = NSMenu() + + menu.addItem(NSMenuItem(title: "Show Main Window", action: #selector(showMainWindow), keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "Toggle Overlay", action: #selector(toggleOverlay), keyEquivalent: "o")) + menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: "Start Capture", action: #selector(startCapture), keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "Stop Capture", action: #selector(stopCapture), keyEquivalent: "")) + menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: "Preferences...", action: #selector(showPreferences), keyEquivalent: ",")) + menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: "Quit LCT", action: #selector(quitApp), keyEquivalent: "q")) + + statusItem?.menu = menu + } + + // MARK: - Permissions + + private func requestPermissions() { + // 使用 CGPreflightScreenCaptureAccess 检查权限 + // 这比 SCShareableContent 更稳定,不会触发 RPDaemonProxy 错误 + let hasScreenCapturePermission = CGPreflightScreenCaptureAccess() + + if hasScreenCapturePermission { + print("Screen capture permission granted") + } else { + print("Screen capture permission not yet granted. Will request when needed.") + // 不要在启动时请求权限,让用户点击 Start 时再请求 + } + + // Microphone permission will be requested when first used + } + + // MARK: - Cleanup + + private func cleanup() { + // Stop any running services + // Close overlay window + overlayWindow?.close() + overlayWindow = nil + + // Remove status item + if let statusItem = statusItem { + NSStatusBar.system.removeStatusItem(statusItem) + } + } + + // MARK: - Actions + + @objc private func statusBarClicked() { + showMainWindow() + } + + @objc private func showMainWindow() { + NSApplication.shared.activate(ignoringOtherApps: true) + if let window = NSApplication.shared.windows.first { + window.makeKeyAndOrderFront(nil) + } + } + + @objc private func toggleOverlay() { + NotificationCenter.default.post(name: .toggleOverlay, object: nil) + } + + @objc private func startCapture() { + NotificationCenter.default.post(name: .toggleCapture, object: true) + } + + @objc private func stopCapture() { + NotificationCenter.default.post(name: .toggleCapture, object: false) + } + + @objc private func showPreferences() { + // Open settings window + NSApplication.shared.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + } + + @objc private func quitApp() { + NSApplication.shared.terminate(nil) + } +} diff --git a/macos/LCTMac/App/LCTMacApp.swift b/macos/LCTMac/App/LCTMacApp.swift new file mode 100644 index 0000000..d2419eb --- /dev/null +++ b/macos/LCTMac/App/LCTMacApp.swift @@ -0,0 +1,105 @@ +import SwiftUI + +/// LCT for macOS - Main Application Entry Point +@main +struct LCTMacApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + @State private var showWelcome: Bool = { + return !AppSettings.hasCompletedSetup + }() + + var body: some Scene { + // Main Window + WindowGroup { + ZStack { + if showWelcome { + WelcomeView { + AppSettings.markSetupComplete() + print("[LCTMacApp] Setup completed, showing MainView") + DispatchQueue.main.async { + showWelcome = false + } + } + .transition(.opacity) + } else { + MainView() + .transition(.opacity) + .onAppear { + print("[LCTMacApp] MainView appeared") + } + } + } + .animation(.easeInOut(duration: 0.3), value: showWelcome) + } + .windowStyle(.hiddenTitleBar) + .windowResizability(.contentMinSize) + .defaultPosition(.center) + .commands { + + // Edit commands + CommandGroup(after: .pasteboard) { + Button("Copy Translation") { + NotificationCenter.default.post(name: .copyTranslation, object: nil) + } + .keyboardShortcut("C", modifiers: [.command, .shift]) + } + + // View commands + CommandGroup(after: .toolbar) { + Button("Toggle Overlay") { + NotificationCenter.default.post(name: .toggleOverlay, object: nil) + } + .keyboardShortcut("O", modifiers: [.command]) + + Divider() + + Button("Show History") { + NotificationCenter.default.post(name: .showHistory, object: nil) + } + .keyboardShortcut("H", modifiers: [.command, .shift]) + } + + // Control commands + CommandGroup(after: .windowArrangement) { + Button("Start/Stop Capture") { + NotificationCenter.default.post(name: .toggleCapture, object: nil) + } + .keyboardShortcut(.space, modifiers: [.command]) + + Button("Pause/Resume") { + NotificationCenter.default.post(name: .togglePause, object: nil) + } + .keyboardShortcut("P", modifiers: [.command]) + } + } + + // Settings Window + #if os(macOS) + Settings { + SettingsWindowView() + } + #endif + } +} + +/// Settings window view wrapper +struct SettingsWindowView: View { + @State private var settings = AppSettings.load() + + var body: some View { + SettingsView(settings: $settings) { newSettings in + newSettings.save() + settings = newSettings + } + } +} + +// MARK: - Notification Names + +extension Notification.Name { + static let copyTranslation = Notification.Name("LCT.copyTranslation") + static let toggleOverlay = Notification.Name("LCT.toggleOverlay") + static let showHistory = Notification.Name("LCT.showHistory") + static let toggleCapture = Notification.Name("LCT.toggleCapture") + static let togglePause = Notification.Name("LCT.togglePause") +} \ No newline at end of file diff --git a/macos/LCTMac/Info.plist b/macos/LCTMac/Info.plist new file mode 100644 index 0000000..950b98b --- /dev/null +++ b/macos/LCTMac/Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleName + LCT + CFBundleDisplayName + LCT for macOS + CFBundleIdentifier + com.lct.mac + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + CFBundlePackageType + APPL + CFBundleExecutable + LCTMac + LSMinimumSystemVersion + 15.0 + LSApplicationCategoryType + public.app-category.productivity + CFBundleIconFile + AppIcon + + + NSMicrophoneUsageDescription + LCT needs microphone access to capture and transcribe your speech in real-time. + NSSpeechRecognitionUsageDescription + LCT uses speech recognition to convert your speech into text for translation. + NSAppleEventsUsageDescription + LCT may need to interact with other apps for automation features. + + + com.apple.security.device.audio-input + + + diff --git a/macos/LCTMac/LCTMac.entitlements b/macos/LCTMac/LCTMac.entitlements new file mode 100644 index 0000000..26a8c5f --- /dev/null +++ b/macos/LCTMac/LCTMac.entitlements @@ -0,0 +1,21 @@ + + + + + + com.apple.security.device.audio-input + + + + com.apple.security.app-sandbox + + + + com.apple.security.network.client + + + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/macos/LCTMac/Models/AppNotice.swift b/macos/LCTMac/Models/AppNotice.swift new file mode 100644 index 0000000..ad317ba --- /dev/null +++ b/macos/LCTMac/Models/AppNotice.swift @@ -0,0 +1,73 @@ +import Foundation + +/// Severity of a user-facing notice. Drives icon, color, and auto-dismiss. +enum NoticeSeverity: Equatable { + case info // transient progress / FYI, auto-dismisses + case warning // non-blocking issue the user should know about + case error // something failed; usually actionable +} + +/// A discrete remediation the user can take from a notice. Represented as data +/// (not closures) so AppNotice stays Equatable and the ViewModel owns behavior. +enum NoticeAction: Equatable, Identifiable { + case openScreenRecordingSettings + case openMicrophoneSettings + case openSpeechRecognitionSettings + case startOllama + case openAppSettings + case retryCapture + + var id: String { + switch self { + case .openScreenRecordingSettings: return "openScreenRecordingSettings" + case .openMicrophoneSettings: return "openMicrophoneSettings" + case .openSpeechRecognitionSettings: return "openSpeechRecognitionSettings" + case .startOllama: return "startOllama" + case .openAppSettings: return "openAppSettings" + case .retryCapture: return "retryCapture" + } + } + + var label: String { + switch self { + case .openScreenRecordingSettings, + .openMicrophoneSettings, + .openSpeechRecognitionSettings: + return "Open System Settings" + case .startOllama: return "Start Ollama" + case .openAppSettings: return "Settings" + case .retryCapture: return "Retry" + } + } +} + +/// A structured, user-facing notice. Replaces ad-hoc error strings so the UI can +/// style by severity and surface remediation buttons instead of guessing. +struct AppNotice: Equatable, Identifiable { + let id = UUID() + var severity: NoticeSeverity + var message: String + var actions: [NoticeAction] + var autoDismiss: Bool + + init(severity: NoticeSeverity, message: String, actions: [NoticeAction] = [], autoDismiss: Bool = false) { + self.severity = severity + self.message = message + self.actions = actions + self.autoDismiss = autoDismiss + } + + // MARK: - Convenience builders + + static func info(_ message: String) -> AppNotice { + AppNotice(severity: .info, message: message, autoDismiss: true) + } + + static func warning(_ message: String, autoDismiss: Bool = true) -> AppNotice { + AppNotice(severity: .warning, message: message, autoDismiss: autoDismiss) + } + + static func error(_ message: String, actions: [NoticeAction] = []) -> AppNotice { + AppNotice(severity: .error, message: message, actions: actions) + } +} diff --git a/macos/LCTMac/Models/AppSettings.swift b/macos/LCTMac/Models/AppSettings.swift new file mode 100644 index 0000000..f8c0070 --- /dev/null +++ b/macos/LCTMac/Models/AppSettings.swift @@ -0,0 +1,356 @@ +import Foundation +import AppKit + +/// Supported source languages for speech recognition +enum SourceLanguage: String, Codable, CaseIterable, Identifiable { + case english = "en-US" + case englishUK = "en-GB" + case chinese = "zh-CN" + case chineseTW = "zh-TW" + case japanese = "ja-JP" + case korean = "ko-KR" + case spanish = "es-ES" + case french = "fr-FR" + case german = "de-DE" + case russian = "ru-RU" + case portuguese = "pt-BR" + case italian = "it-IT" + case arabic = "ar-SA" + case hindi = "hi-IN" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .english: return "English (US)" + case .englishUK: return "English (UK)" + case .chinese: return "Chinese (Simplified)" + case .chineseTW: return "Chinese (Traditional)" + case .japanese: return "Japanese" + case .korean: return "Korean" + case .spanish: return "Spanish" + case .french: return "French" + case .german: return "German" + case .russian: return "Russian" + case .portuguese: return "Portuguese (Brazil)" + case .italian: return "Italian" + case .arabic: return "Arabic" + case .hindi: return "Hindi" + } + } + + var locale: Locale { + Locale(identifier: rawValue) + } + + /// ISO 639-1 language code for TranslateGemma + var isoCode: String { + switch self { + case .english, .englishUK: return "en" + case .chinese, .chineseTW: return "zh" + case .japanese: return "ja" + case .korean: return "ko" + case .spanish: return "es" + case .french: return "fr" + case .german: return "de" + case .russian: return "ru" + case .portuguese: return "pt" + case .italian: return "it" + case .arabic: return "ar" + case .hindi: return "hi" + } + } +} + +/// Supported target languages for translation +enum TargetLanguage: String, Codable, CaseIterable, Identifiable { + case chinese = "Chinese" + case english = "English" + case japanese = "Japanese" + case korean = "Korean" + case spanish = "Spanish" + case french = "French" + case german = "German" + case russian = "Russian" + case arabic = "Arabic" + case portuguese = "Portuguese" + + var id: String { rawValue } + + var displayName: String { rawValue } + + var nativeName: String { + switch self { + case .chinese: return "中文" + case .english: return "English" + case .japanese: return "日本語" + case .korean: return "한국어" + case .spanish: return "Español" + case .french: return "Français" + case .german: return "Deutsch" + case .russian: return "Русский" + case .arabic: return "العربية" + case .portuguese: return "Português" + } + } + + /// ISO 639-1 language code for TranslateGemma + var isoCode: String { + switch self { + case .chinese: return "zh" + case .english: return "en" + case .japanese: return "ja" + case .korean: return "ko" + case .spanish: return "es" + case .french: return "fr" + case .german: return "de" + case .russian: return "ru" + case .arabic: return "ar" + case .portuguese: return "pt" + } + } +} + +/// Translation model type +enum TranslationModelType: String, Codable, CaseIterable, Identifiable { + case standard = "standard" // Standard chat models + case translateGemma = "translategemma" // Google TranslateGemma model + + var id: String { rawValue } + + var displayName: String { + switch self { + case .standard: return "Standard (Chat)" + case .translateGemma: return "TranslateGemma" + } + } + + var description: String { + switch self { + case .standard: return "Use general chat models with translation prompts" + case .translateGemma: return "Google's specialized translation model (55 languages)" + } + } +} + +enum AppSettingsPersistenceError: LocalizedError { + case saveFailed(Error) + case loadFailed(Error) + + var errorDescription: String? { + switch self { + case .saveFailed(let error): + return "Settings save failed: \(error.localizedDescription)" + case .loadFailed(let error): + return "Settings load failed. Defaults were restored: \(error.localizedDescription)" + } + } +} + +/// Application settings model +struct AppSettings: Codable, Equatable { + // MARK: - Audio Settings + var captureSystemAudio: Bool = true + var captureMicrophone: Bool = true + + // MARK: - Speech Recognition Settings + var sourceLanguage: SourceLanguage = .english + + // MARK: - Ollama Settings + var ollamaHost: String = "localhost" + var ollamaPort: Int = 11434 + var ollamaModel: String = "qwen3.5:4b-mlx" + var ollamaTimeout: Int = 30 + var ollamaTemperature: Double = 0.3 + + // MARK: - Translation Settings + var targetLanguage: TargetLanguage = .chinese + var translationModelType: TranslationModelType = .standard + var contextAware: Bool = true + var maxContextEntries: Int = 5 + var customPrompt: String = "" + /// Translate the in-progress draft (before a sentence finalizes) for lower + /// perceived latency. Draft translations are transient and shown in the live + /// area only — they never enter history or translation context. + var liveDraftTranslation: Bool = true + + // MARK: - History Settings + var historyRetentionDays: Int = 30 + var historyMaxEntries: Int = 5000 + + // MARK: - UI Settings + var overlayOpacity: Double = 0.85 + var overlayFontSize: Double = 14.0 + var showLatency: Bool = true + var maxDisplayCards: Int = 5 + + // MARK: - Overlay Advanced Settings + var overlayPositionX: Double = 0.0 + var overlayPositionY: Double = 0.0 + var overlayWidth: Double = 400.0 + var overlayHeight: Double = 200.0 + var overlayClickThrough: Bool = false + var overlayStayOnTop: Bool = true + + // MARK: - Computed Properties + + var ollamaURL: String { + "http://\(ollamaHost):\(ollamaPort)" + } + + var ollamaAPIEndpoint: String { + "\(ollamaURL)/api/chat" + } + + var isLocalOllama: Bool { + let host = ollamaHost + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + + return host == "localhost" + || host == "127.0.0.1" + || host == "::1" + || host == "[::1]" + } + + // MARK: - Persistence + + private static let settingsKey = "LCTMacSettings" + private static let setupCompleteKey = "LCTMacSetupComplete" + nonisolated(unsafe) private static var lastPersistenceError: String? + + /// Check if initial setup has been completed + static var hasCompletedSetup: Bool { + UserDefaults.standard.bool(forKey: setupCompleteKey) + } + + /// Mark initial setup as complete + static func markSetupComplete() { + UserDefaults.standard.set(true, forKey: setupCompleteKey) + } + + /// Reset setup flag (for testing) + static func resetSetupFlag() { + UserDefaults.standard.removeObject(forKey: setupCompleteKey) + } + + /// Save settings to UserDefaults + @discardableResult + func save() -> Bool { + do { + let encoded = try JSONEncoder().encode(self) + UserDefaults.standard.set(encoded, forKey: Self.settingsKey) + Self.lastPersistenceError = nil + return true + } catch { + let message = AppSettingsPersistenceError.saveFailed(error).localizedDescription + Self.lastPersistenceError = message + appLog("[AppSettings] \(message)") + return false + } + } + + /// Load settings from UserDefaults + static func load() -> AppSettings { + guard let data = UserDefaults.standard.data(forKey: settingsKey) else { + lastPersistenceError = nil + return AppSettings() + } + + do { + let settings = try JSONDecoder().decode(AppSettings.self, from: data) + lastPersistenceError = nil + return settings + } catch { + let message = AppSettingsPersistenceError.loadFailed(error).localizedDescription + lastPersistenceError = message + appLog("[AppSettings] \(message)") + return AppSettings() + } + } + + /// Consume the latest load/save persistence error for UI presentation. + static func consumeLastPersistenceError() -> String? { + let message = lastPersistenceError + lastPersistenceError = nil + return message + } + + /// Reset to default settings + static func reset() -> AppSettings { + let defaultSettings = AppSettings() + defaultSettings.save() + return defaultSettings + } + +} + +// MARK: - Hex Color Extension + +extension NSColor { + /// Create NSColor from hex string + convenience init?(hex: String) { + var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) + hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") + + var rgb: UInt64 = 0 + + guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil } + + let length = hexSanitized.count + let r, g, b, a: Double + + if length == 6 { + r = Double((rgb & 0xFF0000) >> 16) / 255.0 + g = Double((rgb & 0x00FF00) >> 8) / 255.0 + b = Double(rgb & 0x0000FF) / 255.0 + a = 1.0 + } else if length == 8 { + r = Double((rgb & 0xFF000000) >> 24) / 255.0 + g = Double((rgb & 0x00FF0000) >> 16) / 255.0 + b = Double((rgb & 0x0000FF00) >> 8) / 255.0 + a = Double(rgb & 0x000000FF) / 255.0 + } else { + return nil + } + + self.init(red: r, green: g, blue: b, alpha: a) + } + + /// Convert NSColor to hex string + func toHexString() -> String { + guard let components = cgColor.components, components.count >= 3 else { + return "#FFFFFF" + } + + let r = Int(round(components[0] * 255)) + let g = Int(round(components[1] * 255)) + let b = Int(round(components[2] * 255)) + + return String(format: "#%02X%02X%02X", r, g, b) + } +} + +// MARK: - Translation Prompt Template + +extension AppSettings { + /// Default translation prompt + static let defaultPrompt = """ + You are a professional simultaneous interpreter. Translate the text enclosed in 🔤 markers to {TARGET_LANGUAGE}. + + CRITICAL RULES: + 1. Output ONLY the translated text, never the original text + 2. Handle incomplete sentences naturally and professionally + 3. Preserve technical terms, company names, and proper nouns accurately + 4. Maintain appropriate tone and formality + 5. For unclear speech, provide the most likely interpretation + + OUTPUT FORMAT: Single line translation only, remove all 🔤 markers, no explanations. + """ + + /// Get the system prompt for Ollama translation + var translationPrompt: String { + let template = customPrompt.isEmpty ? Self.defaultPrompt : customPrompt + return template.replacingOccurrences(of: "{TARGET_LANGUAGE}", with: targetLanguage.displayName) + } +} diff --git a/macos/LCTMac/Models/Caption.swift b/macos/LCTMac/Models/Caption.swift new file mode 100644 index 0000000..10d8c5a --- /dev/null +++ b/macos/LCTMac/Models/Caption.swift @@ -0,0 +1,177 @@ +import Foundation +import Combine + +/// Caption model for managing original and translated text +/// Similar to Windows version's Caption.cs +@MainActor +class Caption: ObservableObject { + // MARK: - Singleton + private static var _instance: Caption? + + static var shared: Caption { + if _instance == nil { + _instance = Caption() + } + return _instance! + } + + // MARK: - Published Properties + + /// Original caption text (full text) + @Published var originalCaption: String = "" + + /// Translated caption text (full text) + @Published var translatedCaption: String = "" + + /// Display version of original caption (may be truncated) + @Published var displayOriginalCaption: String = "" + + /// Display version of translated caption + @Published var displayTranslatedCaption: String = "" + + /// Overlay version of original caption + @Published var overlayOriginalCaption: String = "" + + /// Overlay version of translated caption + @Published var overlayTranslatedCaption: String = "" + + // MARK: - Context Management + + /// Recent translation history for context-aware translation + private(set) var contexts: [TranslationEntry] = [] + + /// Maximum context entries to keep + var maxContextEntries: Int = 6 + + /// Get contexts in reverse order (most recent first) for display + var displayContexts: [TranslationEntry] { + contexts.reversed() + } + + // MARK: - Computed Properties + + /// Get previous captions concatenated (for context display) + func getPreviousCaption(count: Int) -> String { + guard count > 0 else { return "" } + + let entries = Array(displayContexts.prefix(count).reversed()) + guard !entries.isEmpty else { return "" } + + var result = entries + .map { $0.sourceText } + .reduce("") { accumulated, current in + var acc = accumulated + if !acc.isEmpty { + // Add appropriate punctuation based on language + if !TextUtils.hasEndPunctuation(acc) { + acc += TextUtils.isCJK(acc.last) ? "。" : ". " + } + } + return acc + current + } + + // Ensure ends with punctuation + if !result.isEmpty && !TextUtils.hasEndPunctuation(result) { + result += TextUtils.isCJK(result.last) ? "。" : "." + } + + // Add space for non-CJK text + if !result.isEmpty, let lastChar = result.last, !TextUtils.isCJK(lastChar) { + result += " " + } + + return result + } + + /// Get previous translations concatenated (for overlay display) + func getPreviousTranslation(count: Int) -> String { + guard count > 0 else { return "" } + + let entries = Array(displayContexts.prefix(count).reversed()) + guard !entries.isEmpty else { return "" } + + var result = entries + .map { entry -> String in + // Skip error messages + if entry.translatedText.contains("[ERROR]") || entry.translatedText.contains("[WARNING]") { + return "" + } + return entry.translatedText + } + .filter { !$0.isEmpty } + .reduce("") { accumulated, current in + var acc = accumulated + if !acc.isEmpty { + if !TextUtils.hasEndPunctuation(acc) { + acc += TextUtils.isCJK(acc.last) ? "。" : ". " + } + } + return acc + current + } + + // Ensure ends with punctuation + if !result.isEmpty && !TextUtils.hasEndPunctuation(result) { + result += TextUtils.isCJK(result.last) ? "。" : "." + } + + // Add space for non-CJK text + if !result.isEmpty, let lastChar = result.last, !TextUtils.isCJK(lastChar) { + result += " " + } + + return result + } + + // MARK: - Methods + + /// Update the original caption + func updateOriginal(_ text: String) { + originalCaption = text + displayOriginalCaption = text + overlayOriginalCaption = text + } + + /// Update the translated caption + func updateTranslation(_ text: String) { + translatedCaption = text + displayTranslatedCaption = text + overlayTranslatedCaption = text + } + + /// Add a completed translation to context + func addToContext(_ entry: TranslationEntry) { + contexts.append(entry) + + // Keep context size limited + while contexts.count > maxContextEntries { + contexts.removeFirst() + } + } + + /// Remove context entries that were created from rolled-back ASR text. + func removeContextEntries(withIds ids: Set) { + guard !ids.isEmpty else { return } + contexts.removeAll { ids.contains($0.id) } + } + + /// Get context entries for translation (oldest first) + func getContextForTranslation() -> [TranslationEntry] { + return contexts + } + + /// Clear all captions and context + func clear() { + originalCaption = "" + translatedCaption = "" + displayOriginalCaption = "" + displayTranslatedCaption = "" + overlayOriginalCaption = "" + overlayTranslatedCaption = "" + contexts.removeAll() + } + + /// Reset singleton (for testing) + static func reset() { + _instance = nil + } +} diff --git a/macos/LCTMac/Models/CaptionSegment.swift b/macos/LCTMac/Models/CaptionSegment.swift new file mode 100644 index 0000000..073e0e5 --- /dev/null +++ b/macos/LCTMac/Models/CaptionSegment.swift @@ -0,0 +1,48 @@ +import Foundation +import SwiftUI + +/// State of a caption segment +enum SegmentState: String, Codable, Equatable { + case listening = "listening" // Currently receiving audio/interim results + case translating = "translating" // ASR is stable, waiting for translation + case stable = "stable" // Translation complete + case error = "error" // Error during processing +} + +/// Represents a single spoken sentence/segment and its translation lifecycle +struct CaptionSegment: Identifiable, Codable, Equatable { + let id: UUID + var timestamp: Date + + // ASR Source + var interimSource: String = "" + var stableSource: String = "" + + // Translation + var draftTranslation: String = "" + var finalTranslation: String = "" + + var state: SegmentState = .listening + var latencyMs: Int = 0 + var errorMessage: String? = nil + + init(id: UUID = UUID(), timestamp: Date = Date()) { + self.id = id + self.timestamp = timestamp + } + + /// Display text for the original source + var displaySource: String { + return stableSource.isEmpty ? interimSource : stableSource + } + + /// Display text for the translation + var displayTranslation: String { + return finalTranslation.isEmpty ? draftTranslation : finalTranslation + } + + /// True if there is no meaningful text to display + var isEmpty: Bool { + return displaySource.isEmpty && displayTranslation.isEmpty + } +} diff --git a/macos/LCTMac/Models/Speaker.swift b/macos/LCTMac/Models/Speaker.swift new file mode 100644 index 0000000..e20a1ce --- /dev/null +++ b/macos/LCTMac/Models/Speaker.swift @@ -0,0 +1,86 @@ +import Foundation +import SwiftUI + +/// Represents a speaker identified during diarization +struct Speaker: Identifiable, Codable, Equatable, Hashable { + let id: String + let label: String + var color: SpeakerColor + + init(id: String = UUID().uuidString, label: String, color: SpeakerColor = .blue) { + self.id = id + self.label = label + self.color = color + } + + /// Create speaker from pyannote label (e.g., "SPEAKER_00") + static func from(pyannoteLabel: String) -> Speaker { + let speakerNumber = pyannoteLabel.replacingOccurrences(of: "SPEAKER_", with: "") + let displayLabel = "Speaker \((Int(speakerNumber) ?? 0) + 1)" + let colorIndex = (Int(speakerNumber) ?? 0) % SpeakerColor.allCases.count + return Speaker( + id: pyannoteLabel, + label: displayLabel, + color: SpeakerColor.allCases[colorIndex] + ) + } +} + +/// Color options for speaker identification +enum SpeakerColor: String, Codable, CaseIterable, Identifiable { + case blue, green, orange, purple, red, teal, pink, yellow + + var id: String { rawValue } + + var color: Color { + switch self { + case .blue: return .blue + case .green: return .green + case .orange: return .orange + case .purple: return .purple + case .red: return .red + case .teal: return .teal + case .pink: return .pink + case .yellow: return .yellow + } + } +} + +/// Manages speakers in a transcription session +class SpeakerManager: ObservableObject { + @Published private(set) var speakers: [String: Speaker] = [:] + + /// Get or create a speaker for the given label + func speaker(for label: String) -> Speaker { + if let existing = speakers[label] { + return existing + } + let newSpeaker = Speaker.from(pyannoteLabel: label) + speakers[label] = newSpeaker + return newSpeaker + } + + /// Update speaker label + func updateLabel(for id: String, newLabel: String) { + if let speaker = speakers[id] { + speakers[id] = Speaker(id: id, label: newLabel, color: speaker.color) + } + } + + /// Update speaker color + func updateColor(for id: String, newColor: SpeakerColor) { + if let speaker = speakers[id] { + speakers[id] = Speaker(id: id, label: speaker.label, color: newColor) + } + } + + /// Clear all speakers + func clear() { + speakers.removeAll() + } + + /// Get all speakers sorted by label + var sortedSpeakers: [Speaker] { + speakers.values.sorted { $0.label < $1.label } + } +} diff --git a/macos/LCTMac/Models/TranscriptionResult.swift b/macos/LCTMac/Models/TranscriptionResult.swift new file mode 100644 index 0000000..3f72345 --- /dev/null +++ b/macos/LCTMac/Models/TranscriptionResult.swift @@ -0,0 +1,74 @@ +import Foundation + +/// Represents a single transcription segment from speech recognition +struct TranscriptionResult: Identifiable, Codable, Equatable { + let id: UUID + let text: String + let speaker: String? // Speaker identifier (e.g., "Speaker 1") + let startTime: TimeInterval + let endTime: TimeInterval + let isVolatile: Bool // Whether this is a temporary/interim result + let confidence: Float? // Recognition confidence (0.0 - 1.0) + + init( + id: UUID = UUID(), + text: String, + speaker: String? = nil, + startTime: TimeInterval = 0, + endTime: TimeInterval = 0, + isVolatile: Bool = false, + confidence: Float? = nil + ) { + self.id = id + self.text = text + self.speaker = speaker + self.startTime = startTime + self.endTime = endTime + self.isVolatile = isVolatile + self.confidence = confidence + } + + /// Duration of this segment in seconds + var duration: TimeInterval { + endTime - startTime + } + + /// Formatted speaker label for display + var speakerLabel: String { + speaker ?? "Unknown" + } +} + +/// Collection of transcription results with utility methods +struct TranscriptionSession: Identifiable { + let id: UUID + var segments: [TranscriptionResult] + let startedAt: Date + var endedAt: Date? + + init(id: UUID = UUID(), segments: [TranscriptionResult] = [], startedAt: Date = Date()) { + self.id = id + self.segments = segments + self.startedAt = startedAt + } + + /// Get all unique speakers in this session + var speakers: [String] { + Array(Set(segments.compactMap { $0.speaker })).sorted() + } + + /// Get full transcript as a single string + var fullTranscript: String { + segments.map { $0.text }.joined(separator: " ") + } + + /// Get transcript with speaker labels + var formattedTranscript: String { + segments.map { segment in + if let speaker = segment.speaker { + return "[\(speaker)] \(segment.text)" + } + return segment.text + }.joined(separator: "\n") + } +} diff --git a/macos/LCTMac/Models/TranslationEntry.swift b/macos/LCTMac/Models/TranslationEntry.swift new file mode 100644 index 0000000..44a1e5a --- /dev/null +++ b/macos/LCTMac/Models/TranslationEntry.swift @@ -0,0 +1,94 @@ +import Foundation + +/// Represents a translation entry with source and translated text +struct TranslationEntry: Identifiable, Codable, Equatable, Hashable { + let id: UUID + let sourceText: String + let translatedText: String + let speaker: String? + let targetLanguage: String + let timestamp: Date + let latencyMs: Int + + init( + id: UUID = UUID(), + sourceText: String, + translatedText: String, + speaker: String? = nil, + targetLanguage: String, + timestamp: Date = Date(), + latencyMs: Int = 0 + ) { + self.id = id + self.sourceText = sourceText + self.translatedText = translatedText + self.speaker = speaker + self.targetLanguage = targetLanguage + self.timestamp = timestamp + self.latencyMs = latencyMs + } + + /// Formatted latency for display + var formattedLatency: String { + "\(latencyMs) ms" + } + + /// Formatted timestamp for display + var formattedTimestamp: String { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .medium + return formatter.string(from: timestamp) + } +} + +/// Translation history container +struct TranslationHistory: Codable { + var entries: [TranslationEntry] + + init(entries: [TranslationEntry] = []) { + self.entries = entries + } + + /// Add a new entry to history + mutating func add(_ entry: TranslationEntry) { + entries.append(entry) + } + + /// Get entries for a specific date range + func entries(from startDate: Date, to endDate: Date) -> [TranslationEntry] { + entries.filter { $0.timestamp >= startDate && $0.timestamp <= endDate } + } + + /// Search entries by text + func search(_ query: String) -> [TranslationEntry] { + let lowercasedQuery = query.lowercased() + return entries.filter { + $0.sourceText.lowercased().contains(lowercasedQuery) || + $0.translatedText.lowercased().contains(lowercasedQuery) + } + } + + /// Export to CSV format + func exportToCSV() -> String { + var csv = "ID,Source Text,Translated Text,Speaker,Target Language,Timestamp,Latency (ms)\n" + for entry in entries { + let row = [ + csvEscape(entry.id.uuidString), + csvEscape(entry.sourceText), + csvEscape(entry.translatedText), + csvEscape(entry.speaker ?? ""), + csvEscape(entry.targetLanguage), + csvEscape(entry.formattedTimestamp), + String(entry.latencyMs) + ].joined(separator: ",") + csv += "\(row)\n" + } + return csv + } + + private func csvEscape(_ value: String) -> String { + let escaped = value.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" + } +} diff --git a/macos/LCTMac/Models/TranslationSegment.swift b/macos/LCTMac/Models/TranslationSegment.swift new file mode 100644 index 0000000..b66b5f7 --- /dev/null +++ b/macos/LCTMac/Models/TranslationSegment.swift @@ -0,0 +1,29 @@ +import Foundation + +/// State of a translation segment +enum TranslationState: String, Codable, Equatable { + case recognizing = "recognizing" // (Not used directly if we only create it when finalized, but useful) + case pending = "pending" // Finalized while paused; will be enqueued on resume + case translating = "translating" + case translated = "translated" + case failed = "failed" +} + +/// A finalized transcript segment and its translation +struct TranslationSegment: Identifiable, Codable, Equatable { + let id: UUID + let timestamp: Date + let sourceText: String + var translatedText: String + var state: TranslationState + var latencyMs: Int + + init(id: UUID = UUID(), timestamp: Date = Date(), sourceText: String, translatedText: String = "", state: TranslationState = .translating, latencyMs: Int = 0) { + self.id = id + self.timestamp = timestamp + self.sourceText = sourceText + self.translatedText = translatedText + self.state = state + self.latencyMs = latencyMs + } +} diff --git a/macos/LCTMac/Resources/AppIcon.icns b/macos/LCTMac/Resources/AppIcon.icns new file mode 100644 index 0000000000000000000000000000000000000000..2997379bc79248bb79f0abfa7e4e3ed896f73e74 GIT binary patch literal 114370 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 literal 0 HcmV?d00001 diff --git a/macos/LCTMac/Services/AudioCaptureService.swift b/macos/LCTMac/Services/AudioCaptureService.swift new file mode 100644 index 0000000..a001753 --- /dev/null +++ b/macos/LCTMac/Services/AudioCaptureService.swift @@ -0,0 +1,519 @@ +import Foundation +@preconcurrency import ScreenCaptureKit +import AVFoundation +import Combine +import CoreGraphics + +/// Audio capture error types +enum AudioCaptureError: Error, LocalizedError { + case noPermission + case noDisplaysAvailable + case captureSetupFailed(String) + case audioProcessingFailed(String) + case streamInterrupted(String) + + var errorDescription: String? { + switch self { + case .noPermission: + return "Screen recording permission not granted" + case .noDisplaysAvailable: + return "No displays available for capture" + case .captureSetupFailed(let message): + return "Capture setup failed: \(message)" + case .audioProcessingFailed(let message): + return "Audio processing failed: \(message)" + case .streamInterrupted(let message): + return "Audio stream interrupted: \(message)" + } + } +} + +/// Audio capture configuration +struct AudioCaptureConfig { + var captureSystemAudio: Bool = true + var captureMicrophone: Bool = true + var sampleRate: Double = 16000 // Whisper expects 16kHz + var channelCount: Int = 1 // Mono for speech recognition +} + +/// Service for capturing system audio and microphone input using ScreenCaptureKit +/// Service for capturing system audio and microphone input using ScreenCaptureKit +class AudioCaptureService: NSObject, ObservableObject, @unchecked Sendable { + // MARK: - Published Properties + @MainActor @Published private(set) var isCapturing: Bool = false + @MainActor @Published private(set) var hasPermission: Bool = false + @MainActor @Published private(set) var audioLevel: Float = 0 + @MainActor @Published private(set) var lastError: String? + + // MARK: - Configuration + var config: AudioCaptureConfig + + // MARK: - Audio Callback + // These callbacks are marked nonisolated(unsafe) because they are called from background threads + // The callbacks themselves must be thread-safe (e.g., SFSpeechAudioBufferRecognitionRequest.append is thread-safe) + nonisolated(unsafe) var onAudioBuffer: (@Sendable (AVAudioPCMBuffer) -> Void)? + nonisolated(unsafe) var onAudioData: (@Sendable (Data) -> Void)? + + /// Callback when the SCStream is interrupted (e.g., display disconnected). + /// Called on MainActor so the ViewModel can react (show error, attempt restart). + var onStreamInterrupted: ((Error) -> Void)? + + // MARK: - Private Properties + private var stream: SCStream? + private var streamOutput: AudioStreamOutput? + private var videoOutput: VideoStreamOutput? + private let streamOutputQueue = DispatchQueue(label: "com.lct.audioCapture.streamOutput", qos: .userInitiated) + private var audioEngine: AVAudioEngine? + private var cancellables = Set() + private var isStarting = false + private var isStopping = false + + // MARK: - Initialization + + init(config: AudioCaptureConfig = AudioCaptureConfig()) { + self.config = config + super.init() + } + + // MARK: - Permission Management + + /// Open System Settings to Screen Recording permissions + static func openScreenRecordingSettings() { + openPrivacySettings(pane: "Privacy_ScreenCapture") + } + + /// Open System Settings to Microphone permissions + static func openMicrophoneSettings() { + openPrivacySettings(pane: "Privacy_Microphone") + } + + /// Open System Settings to Speech Recognition permissions + static func openSpeechRecognitionSettings() { + openPrivacySettings(pane: "Privacy_SpeechRecognition") + } + + private static func openPrivacySettings(pane: String) { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)") { + NSWorkspace.shared.open(url) + } + } + + /// Check and request screen capture permission + func checkPermission() async -> Bool { + appLog("[AudioCaptureService] --------- Permission Check Start ---------") + + // Method 1: Check standard macOS 15 API + let hasAccess = CGPreflightScreenCaptureAccess() + appLog("[AudioCaptureService] 1. CGPreflightScreenCaptureAccess: \(hasAccess)") + + if hasAccess { + Task { @MainActor in self.hasPermission = true } + appLog("[AudioCaptureService] ✅ Permission already granted via CGPreflight") + return true + } + + // Method 2: Fallback to SCShareableContent + // CGPreflightScreenCaptureAccess sometimes caches 'false' if the user granted + // permission in System Settings without restarting the app. + // SCShareableContent actually queries the display server. + do { + appLog("[AudioCaptureService] 2. Testing SCShareableContent fallback...") + let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) + if !content.displays.isEmpty { + appLog("[AudioCaptureService] ✅ SCShareableContent returned \(content.displays.count) displays. Permission is actually granted.") + Task { @MainActor in self.hasPermission = true } + return true + } else { + appLog("[AudioCaptureService] ⚠️ SCShareableContent succeeded but returned 0 displays (could be headless mac, but usually means no perm).") + } + } catch { + appLog("[AudioCaptureService] ❌ SCShareableContent test failed: \(error.localizedDescription)") + } + + // Method 3: Request Access (will show prompt or return false if previously denied) + let requestedAccess = CGRequestScreenCaptureAccess() + appLog("[AudioCaptureService] 3. CGRequestScreenCaptureAccess: \(requestedAccess)") + + if requestedAccess { + Task { @MainActor in self.hasPermission = true } + appLog("[AudioCaptureService] ✅ Permission granted after CGRequest") + return true + } + + appLog("[AudioCaptureService] --------- Permission Check Failed ---------") + appLog("[AudioCaptureService] Showing error and opening settings.") + + Task { @MainActor in + self.hasPermission = false + self.lastError = "Screen recording permission denied. Please:\n1. Open System Settings > Privacy & Security > Screen & System Audio Recording\n2. Enable LCTMac\n3. Restart the application" + } + + AudioCaptureService.openScreenRecordingSettings() + + return false + } + + // MARK: - Capture Control + + /// Start capturing audio + func startCapture() async throws { + let isCapturingCurrently = await MainActor.run { isCapturing } + guard !isCapturingCurrently else { return } + guard !isStarting else { return } + isStarting = true + defer { isStarting = false } + + // Check permission first + guard await checkPermission() else { + throw AudioCaptureError.noPermission + } + + // Get shareable content + let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) + + guard let display = content.displays.first else { + throw AudioCaptureError.noDisplaysAvailable + } + + // Create content filter for the display + let filter = SCContentFilter(display: display, excludingWindows: []) + + // Configure stream for audio capture + let streamConfig = SCStreamConfiguration() + + // We only need audio, not video + streamConfig.capturesAudio = config.captureSystemAudio + streamConfig.excludesCurrentProcessAudio = true // Don't capture our own audio + + // Audio configuration + streamConfig.sampleRate = Int(config.sampleRate) + streamConfig.channelCount = config.channelCount + + // Microphone capture (macOS 15+) + // Disabled for now as dual-stream appending breaks SFSpeechRecognizer + // if #available(macOS 15.0, *) { + // streamConfig.captureMicrophone = config.captureMicrophone + // } + + // Minimal video config (required even for audio-only) + streamConfig.width = 2 + streamConfig.height = 2 + streamConfig.minimumFrameInterval = CMTime(value: 1, timescale: 1) // 1 FPS minimum + + // Create stream + // Create stream with delegate for error handling (e.g., display disconnect) + let stream = SCStream(filter: filter, configuration: streamConfig, delegate: self) + + // Create and add output handler + let output = AudioStreamOutput( + sampleRate: config.sampleRate, + channelCount: config.channelCount + ) + output.onAudioBuffer = { [weak self] buffer in + self?.processAudioBufferBackground(buffer) + } + + try stream.addStreamOutput(output, type: .audio, sampleHandlerQueue: streamOutputQueue) + + // Register a screen output to avoid ScreenCaptureKit dropping frames when video is configured. + let screenOutput = VideoStreamOutput() + try stream.addStreamOutput(screenOutput, type: .screen, sampleHandlerQueue: streamOutputQueue) + + // If microphone is enabled and available (macOS 15+) + // Disabled for now as dual-stream appending breaks SFSpeechRecognizer + // if #available(macOS 15.0, *), config.captureMicrophone { + // do { + // try stream.addStreamOutput(output, type: .microphone, sampleHandlerQueue: streamOutputQueue) + // } catch { + // print("Warning: Could not add microphone stream output: \(error)") + // // Continue without microphone - system audio will still work + // } + // } + + // Start the stream only after all outputs have been registered. + try await stream.startCapture() + + self.stream = stream + self.streamOutput = output + self.videoOutput = screenOutput + + Task { @MainActor in + self.isCapturing = true + self.lastError = nil + } + + appLog("Audio capture started successfully") + } + + /// Start capturing audio from microphone only (no screen capture permission needed) + func startMicrophoneOnlyCapture() async throws { + let isCapturingCurrently = await MainActor.run { isCapturing } + guard !isCapturingCurrently else { return } + + appLog("Starting microphone-only capture mode...") + + // Use AVAudioEngine for microphone capture + let audioEngine = AVAudioEngine() + let inputNode = audioEngine.inputNode + let inputFormat = inputNode.outputFormat(forBus: 0) + + appLog("Microphone native format: \(inputFormat.sampleRate)Hz, \(inputFormat.channelCount) channels") + + // Target format for SFSpeechRecognizer (16kHz Mono PCM) + let targetFormat = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: config.sampleRate, + channels: AVAudioChannelCount(config.channelCount), + interleaved: false + )! + + // Use an AVAudioMixerNode to perform safe sample rate conversion + let mixer = AVAudioMixerNode() + audioEngine.attach(mixer) + + // Connect input -> mixer (native format) + audioEngine.connect(inputNode, to: mixer, format: inputFormat) + + // Connect mixer -> mainMixerNode (target format) to ensure the graph runs + // Mute the mixer so we don't get audio feedback through speakers + audioEngine.connect(mixer, to: audioEngine.mainMixerNode, format: targetFormat) + mixer.outputVolume = 0.0 + + // Install tap on the mixer's output to get the correctly converted 16kHz buffers + mixer.installTap(onBus: 0, bufferSize: 4096, format: targetFormat) { [weak self] buffer, _ in + self?.processAudioBufferBackground(buffer) + } + + // Start audio engine + do { + audioEngine.prepare() + try audioEngine.start() + } catch { + throw AudioCaptureError.captureSetupFailed("Could not start audio engine: \(error.localizedDescription)") + } + + self.audioEngine = audioEngine + + Task { @MainActor in + self.isCapturing = true + self.lastError = nil + } + + appLog("Microphone-only capture started successfully (format: \(targetFormat.sampleRate)Hz, \(targetFormat.channelCount) channels)") + } + + /// Stop capturing audio + func stopCapture() async { + let isCapturingCurrently = await MainActor.run { isCapturing } + guard isCapturingCurrently else { return } + guard !isStopping else { return } + isStopping = true + defer { isStopping = false } + + // Stop ScreenCaptureKit stream if active + if let stream = stream { + do { + try await stream.stopCapture() + } catch { + print("Error stopping capture: \(error)") + } + self.stream = nil + self.streamOutput = nil + self.videoOutput = nil + } + + // Stop AVAudioEngine if active + if let audioEngine = audioEngine { + audioEngine.inputNode.removeTap(onBus: 0) + audioEngine.stop() + self.audioEngine = nil + } + + Task { @MainActor in + self.isCapturing = false + } + + appLog("Audio capture stopped") + } + + // MARK: - Handlers + + /// Process audio buffer - this is called from background thread, so we use nonisolated + private func processAudioBufferBackground(_ buffer: AVAudioPCMBuffer) { + // Calculate audio level for visualization + if let channelData = buffer.floatChannelData { + let frames = buffer.frameLength + var sum: Float = 0 + for i in 0.. Data? { + guard let channelData = buffer.floatChannelData else { return nil } + + let frames = Int(buffer.frameLength) + var data = Data(capacity: frames * 2) // 16-bit = 2 bytes per sample + + for i in 0.. Void)? + + init(sampleRate: Double, channelCount: Int) { + self.sampleRate = sampleRate + self.channelCount = channelCount + super.init() + } + + func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { + // Process system audio samples + // Note: We ignore .microphone samples here because appending two overlapping parallel streams + // (system audio + microphone) to a single SFSpeechAudioBufferRecognitionRequest causes + // the audio to be sequentially concatenated, resulting in a stuttering mess that fails recognition. + // To support both, they must be mixed into a single buffer first. + if type == .audio { + guard let buffer = convertToPCMBuffer(sampleBuffer) else { return } + onAudioBuffer?(buffer) + } + } + + private func convertToPCMBuffer(_ sampleBuffer: CMSampleBuffer) -> AVAudioPCMBuffer? { + guard let formatDescription = CMSampleBufferGetFormatDescription(sampleBuffer) else { + return nil + } + + let audioStreamBasicDescription = CMAudioFormatDescriptionGetStreamBasicDescription(formatDescription) + guard let asbd = audioStreamBasicDescription?.pointee else { + return nil + } + + guard let format = AVAudioFormat(streamDescription: &UnsafeMutablePointer(mutating: audioStreamBasicDescription)!.pointee) else { + return nil + } + + let frameCount = CMSampleBufferGetNumSamples(sampleBuffer) + guard let pcmBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(frameCount)) else { + return nil + } + pcmBuffer.frameLength = AVAudioFrameCount(frameCount) + + // Get audio buffer list + var bufferList = AudioBufferList() + var blockBuffer: CMBlockBuffer? + + let status = CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer( + sampleBuffer, + bufferListSizeNeededOut: nil, + bufferListOut: &bufferList, + bufferListSize: MemoryLayout.size, + blockBufferAllocator: nil, + blockBufferMemoryAllocator: nil, + flags: 0, + blockBufferOut: &blockBuffer + ) + + guard status == noErr else { + return nil + } + + // Copy audio data to PCM buffer + if let audioData = bufferList.mBuffers.mData, + let pcmData = pcmBuffer.floatChannelData?[0] { + let byteCount = Int(bufferList.mBuffers.mDataByteSize) + + // Convert based on format + if asbd.mBitsPerChannel == 32 && asbd.mFormatFlags & kAudioFormatFlagIsFloat != 0 { + // Already float + memcpy(pcmData, audioData, byteCount) + } else if asbd.mBitsPerChannel == 16 { + // Convert 16-bit integer to float + let int16Data = audioData.bindMemory(to: Int16.self, capacity: frameCount) + for i in 0..("id") + private let sourceText = Expression("source_text") + private let translatedText = Expression("translated_text") + private let speaker = Expression("speaker") + private let targetLanguage = Expression("target_language") + private let timestamp = Expression("timestamp") + private let latencyMs = Expression("latency_ms") + + // MARK: - Initialization + + init(databasePath: String? = nil) { + self.databasePath = databasePath ?? Self.defaultDBPath() + do { + db = try createConnection() + try createTables() + } catch { + print("Failed to initialize HistoryService: \(error)") + } + } + + private func createConnection() throws -> Connection { + let path = databasePath + + // Ensure directory exists + let directory = (path as NSString).deletingLastPathComponent + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + + return try Connection(path) + } + + private static func defaultDBPath() -> String { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let appDirectory = appSupport.appendingPathComponent("LCT") + return appDirectory.appendingPathComponent("history.sqlite").path + } + + private func createTables() throws { + guard let db = db else { return } + + // Enable WAL mode for better concurrency (readers don't block writers) + _ = try db.scalar("PRAGMA journal_mode = WAL") + // Set busy timeout to gracefully handle concurrency + _ = try db.scalar("PRAGMA busy_timeout = 5000") + + try db.run(translationsTable.create(ifNotExists: true) { t in + t.column(id, primaryKey: true) + t.column(sourceText) + t.column(translatedText) + t.column(speaker) + t.column(targetLanguage) + t.column(timestamp) + t.column(latencyMs) + }) + + // Create index for faster queries + try db.run(translationsTable.createIndex(timestamp, ifNotExists: true)) + } + + // MARK: - CRUD Operations + + /// Log a new translation + func logTranslation(_ entry: TranslationEntry) throws { + try dbQueue.sync { + guard let db = db else { return } + + try db.run(translationsTable.insert( + id <- entry.id.uuidString, + sourceText <- entry.sourceText, + translatedText <- entry.translatedText, + speaker <- entry.speaker, + targetLanguage <- entry.targetLanguage, + timestamp <- entry.timestamp, + latencyMs <- entry.latencyMs + )) + } + } + + /// Load all translations + func loadAllTranslations() throws -> [TranslationEntry] { + try dbQueue.sync { + guard let db = db else { return [] } + + var entries: [TranslationEntry] = [] + + for row in try db.prepare(translationsTable.order(timestamp.desc)) { + let entry = TranslationEntry( + id: UUID(uuidString: row[id]) ?? UUID(), + sourceText: row[sourceText], + translatedText: row[translatedText], + speaker: row[speaker], + targetLanguage: row[targetLanguage], + timestamp: row[timestamp], + latencyMs: row[latencyMs] + ) + entries.append(entry) + } + + return entries + } + } + + /// Load recent translations + func loadRecentTranslations(limit: Int = 100) throws -> [TranslationEntry] { + try dbQueue.sync { + guard let db = db else { return [] } + + var entries: [TranslationEntry] = [] + + let query = translationsTable + .order(timestamp.desc) + .limit(limit) + + for row in try db.prepare(query) { + let entry = TranslationEntry( + id: UUID(uuidString: row[id]) ?? UUID(), + sourceText: row[sourceText], + translatedText: row[translatedText], + speaker: row[speaker], + targetLanguage: row[targetLanguage], + timestamp: row[timestamp], + latencyMs: row[latencyMs] + ) + entries.append(entry) + } + + return entries + } + } + + /// Load translations within date range + func loadTranslations(from startDate: Date, to endDate: Date) throws -> [TranslationEntry] { + try dbQueue.sync { + guard let db = db else { return [] } + + var entries: [TranslationEntry] = [] + + let query = translationsTable + .filter(timestamp >= startDate && timestamp <= endDate) + .order(timestamp.desc) + + for row in try db.prepare(query) { + let entry = TranslationEntry( + id: UUID(uuidString: row[id]) ?? UUID(), + sourceText: row[sourceText], + translatedText: row[translatedText], + speaker: row[speaker], + targetLanguage: row[targetLanguage], + timestamp: row[timestamp], + latencyMs: row[latencyMs] + ) + entries.append(entry) + } + + return entries + } + } + + /// Search translations + func searchTranslations(query searchQuery: String) throws -> [TranslationEntry] { + try dbQueue.sync { + guard let db = db else { return [] } + + var entries: [TranslationEntry] = [] + let pattern = "%\(searchQuery)%" + + let query = translationsTable + .filter(sourceText.like(pattern) || translatedText.like(pattern)) + .order(timestamp.desc) + + for row in try db.prepare(query) { + let entry = TranslationEntry( + id: UUID(uuidString: row[id]) ?? UUID(), + sourceText: row[sourceText], + translatedText: row[translatedText], + speaker: row[speaker], + targetLanguage: row[targetLanguage], + timestamp: row[timestamp], + latencyMs: row[latencyMs] + ) + entries.append(entry) + } + + return entries + } + } + + /// Delete a translation + func deleteTranslation(withId entryId: UUID) throws { + try dbQueue.sync { + guard let db = db else { return } + let entry = translationsTable.filter(id == entryId.uuidString) + try db.run(entry.delete()) + } + } + + /// Delete the last translation + func deleteLastTranslation() throws { + try dbQueue.sync { + guard let db = db else { return } + let lastEntry = translationsTable.order(timestamp.desc).limit(1) + if let row = try db.pluck(lastEntry) { + let entryId = row[id] + try db.run(translationsTable.filter(id == entryId).delete()) + } + } + } + + /// Get the last translation + func getLastTranslation() throws -> TranslationEntry? { + try dbQueue.sync { + guard let db = db else { return nil } + let query = translationsTable.order(timestamp.desc).limit(1) + guard let row = try db.pluck(query) else { return nil } + return TranslationEntry( + id: UUID(uuidString: row[id]) ?? UUID(), + sourceText: row[sourceText], + translatedText: row[translatedText], + speaker: row[speaker], + targetLanguage: row[targetLanguage], + timestamp: row[timestamp], + latencyMs: row[latencyMs] + ) + } + } + + /// Get last source text + func getLastSourceText() throws -> String? { + try dbQueue.sync { + guard let db = db else { return nil } + let query = translationsTable.select(sourceText).order(timestamp.desc).limit(1) + guard let row = try db.pluck(query) else { return nil } + return row[sourceText] + } + } + + /// Clear all history + func clearHistory() throws { + try dbQueue.sync { + guard let db = db else { return } + try db.run(translationsTable.delete()) + } + } + + /// Apply retention limits to prevent unbounded history growth. + func pruneHistory(retentionDays: Int, maxEntries: Int) throws { + try dbQueue.sync { + guard let db = db else { return } + + if retentionDays > 0, + let cutoffDate = Calendar.current.date(byAdding: .day, value: -retentionDays, to: Date()) { + try db.run(translationsTable.filter(timestamp < cutoffDate).delete()) + } + + if maxEntries > 0 { + let orderedIds = try db.prepare( + translationsTable + .select(id) + .order(timestamp.desc) + ).map { $0[id] } + + guard orderedIds.count > maxEntries else { return } + + for entryId in orderedIds.dropFirst(maxEntries) { + try db.run(translationsTable.filter(id == entryId).delete()) + } + } + } + } + + /// Get total count + func getCount() throws -> Int { + try dbQueue.sync { + guard let db = db else { return 0 } + return try db.scalar(translationsTable.count) + } + } + + /// Export to CSV + func exportToCSV() throws -> String { + let entries = try loadAllTranslations() + let history = TranslationHistory(entries: entries) + return history.exportToCSV() + } +} + +// MARK: - Async Wrapper + +extension HistoryService { + /// Async version of logTranslation + func logTranslationAsync(_ entry: TranslationEntry) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + // Dispatch to robust dedicated DB queue asynchronously to not block the caller + dbQueue.async { [weak self] in + guard let self = self else { + continuation.resume(throwing: NSError(domain: "HistoryService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Service deallocated"])) + return + } + do { + // Do not call self.logTranslation here (which uses sync), do the raw commit directly to avoid deadlock + guard let db = self.db else { + continuation.resume() + return + } + try db.run(self.translationsTable.insert( + self.id <- entry.id.uuidString, + self.sourceText <- entry.sourceText, + self.translatedText <- entry.translatedText, + self.speaker <- entry.speaker, + self.targetLanguage <- entry.targetLanguage, + self.timestamp <- entry.timestamp, + self.latencyMs <- entry.latencyMs + )) + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + } + + /// Async version of loadRecentTranslations + func loadRecentTranslationsAsync(limit: Int = 100) async throws -> [TranslationEntry] { + try await withCheckedThrowingContinuation { continuation in + dbQueue.async { [weak self] in + guard let self = self else { + continuation.resume(throwing: NSError(domain: "HistoryService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Service deallocated"])) + return + } + do { + guard let db = self.db else { + continuation.resume(returning: []) + return + } + var entries: [TranslationEntry] = [] + let query = self.translationsTable.order(self.timestamp.desc).limit(limit) + for row in try db.prepare(query) { + let entry = TranslationEntry( + id: UUID(uuidString: row[self.id]) ?? UUID(), + sourceText: row[self.sourceText], + translatedText: row[self.translatedText], + speaker: row[self.speaker], + targetLanguage: row[self.targetLanguage], + timestamp: row[self.timestamp], + latencyMs: row[self.latencyMs] + ) + entries.append(entry) + } + continuation.resume(returning: entries) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + /// Async version of pruneHistory + func pruneHistoryAsync(retentionDays: Int, maxEntries: Int) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + dbQueue.async { [weak self] in + guard let self = self else { + continuation.resume(throwing: NSError(domain: "HistoryService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Service deallocated"])) + return + } + do { + guard let db = self.db else { + continuation.resume() + return + } + + if retentionDays > 0, + let cutoffDate = Calendar.current.date(byAdding: .day, value: -retentionDays, to: Date()) { + try db.run(self.translationsTable.filter(self.timestamp < cutoffDate).delete()) + } + + if maxEntries > 0 { + let orderedIds = try db.prepare( + self.translationsTable + .select(self.id) + .order(self.timestamp.desc) + ).map { $0[self.id] } + + if orderedIds.count > maxEntries { + for entryId in orderedIds.dropFirst(maxEntries) { + try db.run(self.translationsTable.filter(self.id == entryId).delete()) + } + } + } + + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + } + + /// Async version of deleteTranslation + func deleteTranslationAsync(withId entryId: UUID) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + dbQueue.async { [weak self] in + guard let self = self else { + continuation.resume(throwing: NSError(domain: "HistoryService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Service deallocated"])) + return + } + do { + guard let db = self.db else { + continuation.resume() + return + } + let entry = self.translationsTable.filter(self.id == entryId.uuidString) + try db.run(entry.delete()) + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + } +} diff --git a/macos/LCTMac/Services/OllamaGuardian.swift b/macos/LCTMac/Services/OllamaGuardian.swift new file mode 100644 index 0000000..4fd9512 --- /dev/null +++ b/macos/LCTMac/Services/OllamaGuardian.swift @@ -0,0 +1,422 @@ +import Foundation +import Combine +import AppKit + +/// Ollama installation and service status +enum OllamaStatus: Equatable { + case notInstalled + case installed + case running + case starting + case stopped + case error(String) + + var isAvailable: Bool { + self == .running + } + + var displayText: String { + switch self { + case .notInstalled: + return "Not Installed" + case .installed: + return "Installed (Not Running)" + case .running: + return "Running" + case .starting: + return "Starting..." + case .stopped: + return "Stopped" + case .error(let message): + return "Error: \(message)" + } + } + + var statusColor: String { + switch self { + case .running: + return "green" + case .starting: + return "yellow" + case .notInstalled, .stopped, .error: + return "red" + case .installed: + return "orange" + } + } +} + +enum OllamaInstallation: Equatable { + case none + case app(URL) + case cli(String) + + var isInstalled: Bool { + self != .none + } +} + +/// Service for managing Ollama installation and lifecycle +@MainActor +class OllamaGuardian: ObservableObject { + // MARK: - Published Properties + + @Published private(set) var status: OllamaStatus = .stopped + @Published private(set) var isChecking: Bool = false + @Published private(set) var lastError: String? + @Published private(set) var ollamaVersion: String? + + // MARK: - Configuration + + private let ollamaPath: String + private let ollamaURL: String + private var healthCheckTask: Task? + private var startupTask: Task? + + // MARK: - Singleton + + static let shared = OllamaGuardian() + + // MARK: - Initialization + + init(ollamaPath: String? = nil, ollamaURL: String = "http://localhost:11434") { + // Dynamically resolve path if not explicitly provided + self.ollamaPath = ollamaPath ?? OllamaGuardian.findOllamaPath() ?? "/usr/local/bin/ollama" + self.ollamaURL = ollamaURL + } + + /// Find Ollama executable path from common install locations + static func findOllamaPath() -> String? { + let paths = [ + "/usr/local/bin/ollama", + "/opt/homebrew/bin/ollama", + "/usr/bin/ollama", + "\(NSHomeDirectory())/bin/ollama" + ] + + for path in paths { + if FileManager.default.fileExists(atPath: path) { + return path + } + } + + return nil + } + + /// Find Ollama.app from common macOS application locations. + static func findOllamaAppURL() -> URL? { + let urls = [ + URL(fileURLWithPath: "/Applications/Ollama.app"), + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Applications/Ollama.app") + ] + + return urls.first { FileManager.default.fileExists(atPath: $0.path) } + } + + /// Detect whether Ollama is installed as a native app or CLI. + func detectInstallation() async -> OllamaInstallation { + if let appURL = Self.findOllamaAppURL() { + return .app(appURL) + } + + if let cliPath = getOllamaPath() { + return .cli(cliPath) + } + + return await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/which") + process.arguments = ["ollama"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + do { + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + continuation.resume(returning: .none) + return + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let path = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + + if let path, !path.isEmpty { + continuation.resume(returning: .cli(path)) + } else { + continuation.resume(returning: .none) + } + } catch { + continuation.resume(returning: .none) + } + } + } + } + + // MARK: - Installation Check + + /// Check if Ollama is installed + func checkInstallation() async -> Bool { + await detectInstallation().isInstalled + } + + /// Get the Ollama executable path + func getOllamaPath() -> String? { + let paths = [ + "/usr/local/bin/ollama", + "/opt/homebrew/bin/ollama", + "/usr/bin/ollama", + "\(NSHomeDirectory())/bin/ollama" + ] + + for path in paths { + if FileManager.default.fileExists(atPath: path) { + return path + } + } + + return nil + } + + // MARK: - Service Status + + /// Check if Ollama service is running + func checkServiceStatus() async -> Bool { + guard let url = URL(string: "\(ollamaURL)/api/tags") else { + return false + } + + do { + let (_, response) = try await URLSession.shared.data(from: url) + if let httpResponse = response as? HTTPURLResponse { + return httpResponse.statusCode == 200 + } + return false + } catch { + return false + } + } + + /// Get Ollama version + func getVersion() async -> String? { + guard let url = URL(string: "\(ollamaURL)/api/version") else { + return nil + } + + do { + let (data, response) = try await URLSession.shared.data(from: url) + if let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 { + if let json = try? JSONDecoder().decode([String: String].self, from: data) { + return json["version"] + } + } + return nil + } catch { + return nil + } + } + + // MARK: - Full Status Check + + /// Perform a complete status check + func checkStatus() async { + isChecking = true + defer { isChecking = false } + + // A running local service is usable even when the CLI is not on PATH. + if await checkServiceStatus() { + status = .running + ollamaVersion = await getVersion() + } else { + let installation = await detectInstallation() + status = installation.isInstalled ? .installed : .notInstalled + } + } + + // MARK: - Service Control + + /// Start Ollama service + func startService() async throws { + print("[OllamaGuardian] startService() called") + + let installation = await detectInstallation() + guard installation.isInstalled else { + print("[OllamaGuardian] ❌ Ollama not installed") + throw OllamaGuardianError.notInstalled + } + + // Check if already running + print("[OllamaGuardian] Checking if service is already running...") + if await checkServiceStatus() { + print("[OllamaGuardian] ✅ Service already running") + status = .running + return + } + + status = .starting + + switch installation { + case .app(let appURL): + print("[OllamaGuardian] Opening Ollama.app from: \(appURL.path)") + NSWorkspace.shared.open(appURL) + try await waitForServiceAfterLaunch() + case .cli(let ollamaPath): + try await startCLIService(at: ollamaPath) + case .none: + print("[OllamaGuardian] ❌ Cannot find Ollama path") + throw OllamaGuardianError.notInstalled + } + } + + private func startCLIService(at ollamaPath: String) async throws { + print("[OllamaGuardian] Starting Ollama from: \(ollamaPath)") + + // Start Ollama serve in background + let process = Process() + process.executableURL = URL(fileURLWithPath: ollamaPath) + process.arguments = ["serve"] + + // Redirect output to prevent terminal spam + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + + do { + try process.run() + print("[OllamaGuardian] Process started, waiting for service...") + + try await waitForServiceAfterLaunch() + } catch let error as OllamaGuardianError { + throw error + } catch { + print("[OllamaGuardian] ❌ Startup failed: \(error)") + status = .error(error.localizedDescription) + throw OllamaGuardianError.startupFailed(error.localizedDescription) + } + } + + private func waitForServiceAfterLaunch() async throws { + let maxAttempts = 20 + for attempt in 1...maxAttempts { + try await Task.sleep(nanoseconds: 1_000_000_000) + + if await checkServiceStatus() { + print("[OllamaGuardian] ✅ Service started successfully after \(attempt)s") + status = .running + ollamaVersion = await getVersion() + return + } + + print("[OllamaGuardian] Waiting for Ollama to start... (\(attempt)s)") + } + + print("[OllamaGuardian] ❌ Startup timeout after \(maxAttempts)s") + status = .error("Startup timeout") + throw OllamaGuardianError.startupTimeout + } + + /// Stop Ollama service (if we started it) + func stopService() async { + // Ollama doesn't have a direct stop command via API + // The service will stop when the process is terminated + status = .stopped + } + + // MARK: - Auto-Start + + /// Ensure Ollama is running, starting it if necessary + func ensureRunning() async throws { + await checkStatus() + + switch status { + case .running: + return // Already running + case .installed, .stopped: + try await startService() + case .notInstalled: + throw OllamaGuardianError.notInstalled + case .starting: + // Wait for startup to complete + try await waitForStartup() + case .error(let message): + throw OllamaGuardianError.serviceError(message) + } + } + + private func waitForStartup() async throws { + let maxAttempts = 30 + for _ in 1...maxAttempts { + try await Task.sleep(nanoseconds: 1_000_000_000) + + if status == .running { + return + } + + if case .error(let message) = status { + throw OllamaGuardianError.serviceError(message) + } + } + + throw OllamaGuardianError.startupTimeout + } + + // MARK: - Health Monitoring + + /// Start periodic health checks + func startHealthMonitoring(interval: TimeInterval = 30) { + stopHealthMonitoring() + + healthCheckTask = Task { + while !Task.isCancelled { + await checkStatus() + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + } + } + + /// Stop health monitoring + func stopHealthMonitoring() { + healthCheckTask?.cancel() + healthCheckTask = nil + } + + // MARK: - Installation Help + + /// Get installation instructions URL + var installationURL: URL { + URL(string: "https://ollama.com/download")! + } + + /// Open Ollama download page + func openInstallationPage() { + NSWorkspace.shared.open(installationURL) + } +} + +// MARK: - Errors + +enum OllamaGuardianError: Error, LocalizedError { + case notInstalled + case startupFailed(String) + case startupTimeout + case serviceError(String) + + var errorDescription: String? { + switch self { + case .notInstalled: + return "Ollama is not installed. Please install it from https://ollama.com/download or configure a remote Ollama server." + case .startupFailed(let message): + return "Failed to start Ollama: \(message)" + case .startupTimeout: + return "Ollama startup timed out" + case .serviceError(let message): + return "Ollama service error: \(message)" + } + } +} diff --git a/macos/LCTMac/Services/OllamaModelManager.swift b/macos/LCTMac/Services/OllamaModelManager.swift new file mode 100644 index 0000000..3698034 --- /dev/null +++ b/macos/LCTMac/Services/OllamaModelManager.swift @@ -0,0 +1,436 @@ +import Foundation +import Combine + +/// Model information from Ollama +struct OllamaModel: Identifiable, Codable, Equatable { + var id: String { name } + let name: String + let modifiedAt: String? + let size: Int64? + let digest: String? + let details: OllamaModelDetails? + + enum CodingKeys: String, CodingKey { + case name + case modifiedAt = "modified_at" + case size + case digest + case details + } + + /// Formatted size string + var formattedSize: String { + guard let size = size else { return "Unknown" } + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useGB, .useMB] + formatter.countStyle = .file + return formatter.string(fromByteCount: size) + } + + /// Short name (without tag) + var shortName: String { + name.components(separatedBy: ":").first ?? name + } + + /// Tag (version) + var tag: String { + let parts = name.components(separatedBy: ":") + return parts.count > 1 ? parts[1] : "latest" + } +} + +struct OllamaModelDetails: Codable, Equatable { + let format: String? + let family: String? + let parameterSize: String? + let quantizationLevel: String? + + enum CodingKeys: String, CodingKey { + case format + case family + case parameterSize = "parameter_size" + case quantizationLevel = "quantization_level" + } +} + +/// Response for listing models +struct OllamaModelsResponse: Codable { + let models: [OllamaModel] +} + +/// Pull progress information +struct OllamaPullProgress: Codable { + let status: String + let digest: String? + let total: Int64? + let completed: Int64? + + var progress: Double { + guard let total = total, let completed = completed, total > 0 else { + return 0 + } + return Double(completed) / Double(total) + } + + var isComplete: Bool { + status == "success" + } +} + +/// Recommended models for translation +struct RecommendedModel { + let name: String + let displayName: String + let description: String + let size: String + let sizeBytes: Int64 + let isDefault: Bool + + /// Default model for translation - MLX-optimized Qwen 4B + static let defaultModel = RecommendedModel( + name: "qwen3.5:4b-mlx", + displayName: "Qwen3.5 4B MLX", + description: "MLX-optimized 4B model for low-latency local translation", + size: "~4.0GB", + sizeBytes: 4_000_000_000, + isDefault: true + ) + + static let all: [RecommendedModel] = [ + defaultModel, + RecommendedModel( + name: "translategemma:4b-it-q4_K_M", + displayName: "TranslateGemma 4B", + description: "Google's specialized translation model (55 languages)", + size: "~3.3GB", + sizeBytes: 3_300_000_000, + isDefault: false + ), + RecommendedModel( + name: "qwen2.5:3b", + displayName: "Qwen 2.5 3B", + description: "Fast and efficient, great for translation", + size: "~2GB", + sizeBytes: 2_000_000_000, + isDefault: false + ), + RecommendedModel( + name: "llama3.2:3b", + displayName: "Llama 3.2 3B", + description: "Meta's latest small model", + size: "~2GB", + sizeBytes: 2_000_000_000, + isDefault: false + ) + ] +} + +/// Service for managing Ollama models +@MainActor +class OllamaModelManager: ObservableObject { + // MARK: - Published Properties + + @Published private(set) var installedModels: [OllamaModel] = [] + @Published private(set) var isLoading: Bool = false + @Published private(set) var isPulling: Bool = false + @Published private(set) var pullProgress: Double = 0 + @Published private(set) var pullStatus: String = "" + @Published private(set) var currentPullingModel: String? + @Published private(set) var lastError: String? + + // MARK: - Configuration + + private let baseURL: String + private var pullTask: Task? + private static let modelDownloadFreeSpaceBufferBytes: Int64 = 2_000_000_000 + + // MARK: - Initialization + + init(baseURL: String = "http://localhost:11434") { + self.baseURL = baseURL + } + + // MARK: - Model Listing + + /// Fetch list of installed models + func fetchInstalledModels() async { + isLoading = true + defer { isLoading = false } + + guard let url = URL(string: "\(baseURL)/api/tags") else { + lastError = "Invalid API URL" + return + } + + do { + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + lastError = "Failed to fetch models" + return + } + + let modelsResponse = try JSONDecoder().decode(OllamaModelsResponse.self, from: data) + installedModels = modelsResponse.models.sorted { $0.name < $1.name } + lastError = nil + + } catch { + lastError = error.localizedDescription + print("[OllamaModelManager] Error fetching models: \(error)") + } + } + + /// Check if a specific model is installed + func isModelInstalled(_ modelName: String) -> Bool { + installedModels.contains { $0.name == modelName || $0.name.hasPrefix("\(modelName):") } + } + + /// Get installed model by name + func getModel(_ modelName: String) -> OllamaModel? { + installedModels.first { $0.name == modelName } ?? + installedModels.first { $0.name.hasPrefix("\(modelName):") } + } + + // MARK: - Model Pulling + + /// Pull (download) a model + func pullModel(_ modelName: String) async throws { + guard !isPulling else { + throw OllamaModelError.alreadyPulling + } + + if let recommendedModel = RecommendedModel.all.first(where: { $0.name == modelName }) { + try ensureSufficientDiskSpace(for: recommendedModel) + } + + isPulling = true + pullProgress = 0 + pullStatus = "Starting download..." + currentPullingModel = modelName + lastError = nil + + defer { + isPulling = false + currentPullingModel = nil + } + + guard let url = URL(string: "\(baseURL)/api/pull") else { + throw OllamaModelError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = ["name": modelName] + request.httpBody = try JSONEncoder().encode(body) + + // Use streaming to get progress updates + let (asyncBytes, response) = try await URLSession.shared.bytes(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw OllamaModelError.pullFailed("Server returned error") + } + + // Process streaming response + for try await line in asyncBytes.lines { + guard !Task.isCancelled else { + throw CancellationError() + } + + guard let data = line.data(using: .utf8), + let progress = try? JSONDecoder().decode(OllamaPullProgress.self, from: data) else { + continue + } + + pullStatus = progress.status + pullProgress = progress.progress + + if progress.isComplete { + pullProgress = 1.0 + pullStatus = "Download complete!" + + // Refresh model list + await fetchInstalledModels() + return + } + } + } + + /// Cancel ongoing model pull + func cancelPull() { + pullTask?.cancel() + pullTask = nil + isPulling = false + pullProgress = 0 + pullStatus = "Cancelled" + currentPullingModel = nil + } + + // MARK: - Model Deletion + + /// Delete a model + func deleteModel(_ modelName: String) async throws { + guard let url = URL(string: "\(baseURL)/api/delete") else { + throw OllamaModelError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = ["name": modelName] + request.httpBody = try JSONEncoder().encode(body) + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw OllamaModelError.deleteFailed("Server returned error") + } + + // Refresh model list + await fetchInstalledModels() + } + + // MARK: - Model Loading + + /// Preload a model into memory + func loadModel(_ modelName: String) async throws { + guard let url = URL(string: "\(baseURL)/api/generate") else { + throw OllamaModelError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = 120 // Loading can take time + + // Send empty prompt to just load the model + let body: [String: Any] = [ + "model": modelName, + "prompt": "", + "keep_alive": "5m" + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw OllamaModelError.loadFailed("Failed to load model") + } + } + + /// Unload a model from memory + func unloadModel(_ modelName: String) async throws { + guard let url = URL(string: "\(baseURL)/api/generate") else { + throw OllamaModelError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + // Set keep_alive to 0 to unload immediately + let body: [String: Any] = [ + "model": modelName, + "prompt": "", + "keep_alive": 0 + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw OllamaModelError.unloadFailed("Failed to unload model") + } + } + + // MARK: - Recommendations + + /// Get recommended models that are not yet installed + func getRecommendedModelsToInstall() -> [RecommendedModel] { + RecommendedModel.all.filter { !isModelInstalled($0.name) } + } + + /// Check if any recommended model is installed + func hasAnyRecommendedModel() -> Bool { + RecommendedModel.all.contains { isModelInstalled($0.name) } + } + + /// Check available disk capacity for the volume that stores the user's Ollama models. + func availableDiskSpaceBytes() throws -> Int64 { + let homeURL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + let values = try homeURL.resourceValues(forKeys: [ + .volumeAvailableCapacityForImportantUsageKey, + .volumeAvailableCapacityKey + ]) + + if let importantUsage = values.volumeAvailableCapacityForImportantUsage { + return importantUsage + } + + if let availableCapacity = values.volumeAvailableCapacity { + return Int64(availableCapacity) + } + + throw OllamaModelError.diskSpaceUnavailable + } + + func ensureSufficientDiskSpace(for model: RecommendedModel) throws { + let availableBytes = try availableDiskSpaceBytes() + let requiredBytes = model.sizeBytes + Self.modelDownloadFreeSpaceBufferBytes + + guard availableBytes >= requiredBytes else { + throw OllamaModelError.insufficientDiskSpace(required: requiredBytes, available: availableBytes) + } + } +} + +// MARK: - Errors + +enum OllamaModelError: Error, LocalizedError { + case invalidURL + case pullFailed(String) + case deleteFailed(String) + case loadFailed(String) + case unloadFailed(String) + case alreadyPulling + case modelNotFound + case diskSpaceUnavailable + case insufficientDiskSpace(required: Int64, available: Int64) + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid Ollama API URL" + case .pullFailed(let message): + return "Failed to pull model: \(message)" + case .deleteFailed(let message): + return "Failed to delete model: \(message)" + case .loadFailed(let message): + return "Failed to load model: \(message)" + case .unloadFailed(let message): + return "Failed to unload model: \(message)" + case .alreadyPulling: + return "Already pulling a model" + case .modelNotFound: + return "Model not found" + case .diskSpaceUnavailable: + return "Could not determine available disk space" + case .insufficientDiskSpace(let required, let available): + return "Insufficient disk space. Required: \(Self.formatBytes(required)), available: \(Self.formatBytes(available))" + } + } + + private static func formatBytes(_ bytes: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useGB, .useMB] + formatter.countStyle = .file + return formatter.string(fromByteCount: bytes) + } +} diff --git a/macos/LCTMac/Services/OllamaService.swift b/macos/LCTMac/Services/OllamaService.swift new file mode 100644 index 0000000..203b93d --- /dev/null +++ b/macos/LCTMac/Services/OllamaService.swift @@ -0,0 +1,478 @@ +import Foundation + +/// Ollama API error types +enum OllamaError: Error, LocalizedError { + case invalidURL + case networkError(Error) + case invalidResponse + case httpError(Int) + case decodingError(Error) + case timeout + case serverNotRunning + case modelNotLoaded + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid Ollama API URL" + case .networkError(let error): + return "Network error: \(error.localizedDescription)" + case .invalidResponse: + return "Invalid response from Ollama" + case .httpError(let code): + return "HTTP error: \(code)" + case .decodingError(let error): + return "Failed to decode response: \(error.localizedDescription)" + case .timeout: + return "Request timed out" + case .serverNotRunning: + return "Ollama server is not running" + case .modelNotLoaded: + return "Model not loaded" + } + } +} + +/// Ollama API response structures +struct OllamaMessage: Codable { + let role: String + let content: String +} + +struct OllamaChatRequest: Codable { + let model: String + let messages: [OllamaMessage] + let stream: Bool + let temperature: Double? + let keepAlive: String? + let think: Bool? + + enum CodingKeys: String, CodingKey { + case model, messages, stream, temperature + case keepAlive = "keep_alive" + case think + } +} + +struct OllamaChatResponse: Codable { + let message: OllamaMessage? + let done: Bool + let totalDuration: Int64? + let loadDuration: Int64? + let promptEvalCount: Int? + let promptEvalDuration: Int64? + let evalCount: Int? + let evalDuration: Int64? + + enum CodingKeys: String, CodingKey { + case message, done + case totalDuration = "total_duration" + case loadDuration = "load_duration" + case promptEvalCount = "prompt_eval_count" + case promptEvalDuration = "prompt_eval_duration" + case evalCount = "eval_count" + case evalDuration = "eval_duration" + } +} + +struct OllamaHealthResponse: Codable { + let status: String? +} + +/// Service for interacting with local Ollama API +@MainActor +class OllamaService: ObservableObject { + @Published private(set) var isConnected: Bool = false + @Published private(set) var isTranslating: Bool = false + @Published private(set) var lastError: String? + + private var settings: AppSettings + private var session: URLSession + private let injectedSession: URLSession? + + init(settings: AppSettings = .load(), session: URLSession? = nil) { + self.settings = settings + self.injectedSession = session + self.session = session ?? Self.makeSession(for: settings) + } + + private static func makeSession(for settings: AppSettings) -> URLSession { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = TimeInterval(settings.ollamaTimeout) + config.timeoutIntervalForResource = TimeInterval(settings.ollamaTimeout * 2) + return URLSession(configuration: config) + } + + /// Update settings + func updateSettings(_ newSettings: AppSettings) { + let shouldRebuildSession = settings.ollamaTimeout != newSettings.ollamaTimeout + self.settings = newSettings + + guard shouldRebuildSession, injectedSession == nil else { return } + + session.invalidateAndCancel() + session = Self.makeSession(for: newSettings) + } + + /// Load the configured model before the first translation so the UI can show explicit progress. + func prewarmModel() async throws -> Int { + let startTime = Date() + + guard let url = URL(string: settings.ollamaAPIEndpoint) else { + throw OllamaError.invalidURL + } + + let request = OllamaChatRequest( + model: settings.ollamaModel, + messages: [OllamaMessage(role: "user", content: "hi")], + stream: false, + temperature: 0.1, + keepAlive: "5m", + think: false + ) + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.httpBody = try JSONEncoder().encode(request) + urlRequest.timeoutInterval = TimeInterval(max(settings.ollamaTimeout * 2, 60)) + + do { + let (_, response) = try await session.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + throw OllamaError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + if httpResponse.statusCode == 404 { + throw OllamaError.modelNotLoaded + } + throw OllamaError.httpError(httpResponse.statusCode) + } + + lastError = nil + return Int(Date().timeIntervalSince(startTime) * 1000) + } catch let error as OllamaError { + lastError = error.localizedDescription + throw error + } catch let error as URLError { + let mappedError = mapURLError(error) + lastError = mappedError.localizedDescription + throw mappedError + } catch { + lastError = error.localizedDescription + throw OllamaError.networkError(error) + } + } + + /// Check if Ollama server is running + func checkHealth() async -> Bool { + guard let url = URL(string: "\(settings.ollamaURL)/api/tags") else { + return false + } + + do { + let (_, response) = try await session.data(from: url) + if let httpResponse = response as? HTTPURLResponse { + isConnected = httpResponse.statusCode == 200 + return isConnected + } + return false + } catch { + isConnected = false + lastError = "Cannot connect to Ollama: \(error.localizedDescription)" + return false + } + } + + /// Build messages for TranslateGemma model (uses ISO language codes, different format) + private func buildTranslateGemmaMessages(text: String, context: [TranslationEntry]) -> [OllamaMessage] { + // TranslateGemma expects a specific format: <2xx> where xx is the target language ISO code + let targetCode = settings.targetLanguage.isoCode + let prompt = "<2\(targetCode)> \(text)" + return [OllamaMessage(role: "user", content: prompt)] + } + + /// Build messages for standard chat models + private func buildStandardMessages(text: String, context: [TranslationEntry]) -> [OllamaMessage] { + var messages: [OllamaMessage] = [ + OllamaMessage(role: "system", content: settings.translationPrompt) + ] + + // Add context from previous translations (if context-aware) + if settings.contextAware { + for entry in context.suffix(settings.maxContextEntries) { + messages.append(OllamaMessage(role: "user", content: "🔤 \(entry.sourceText) 🔤")) + messages.append(OllamaMessage(role: "assistant", content: entry.translatedText)) + } + } + + // Add current text to translate + messages.append(OllamaMessage(role: "user", content: "🔤 \(text) 🔤")) + return messages + } + + /// Translate text using Ollama + func translate(text: String, context: [TranslationEntry] = []) async throws -> (String, Int) { + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return ("", 0) + } + + let startTime = Date() + isTranslating = true + defer { isTranslating = false } + + guard let url = URL(string: settings.ollamaAPIEndpoint) else { + throw OllamaError.invalidURL + } + + // Build messages based on model type + let messages: [OllamaMessage] + let isTranslateGemma = settings.ollamaModel.lowercased().contains("translategemma") + let modelType = isTranslateGemma ? TranslationModelType.translateGemma : settings.translationModelType + + switch modelType { + case .translateGemma: + messages = buildTranslateGemmaMessages(text: text, context: context) + case .standard: + messages = buildStandardMessages(text: text, context: context) + } + + let request = OllamaChatRequest( + model: settings.ollamaModel, + messages: messages, + stream: false, + temperature: settings.translationModelType == .translateGemma ? 0.1 : settings.ollamaTemperature, + keepAlive: "5m", + think: false + ) + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.httpBody = try JSONEncoder().encode(request) + + do { + let (data, response) = try await session.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + throw OllamaError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + if httpResponse.statusCode == 404 { + throw OllamaError.modelNotLoaded + } + throw OllamaError.httpError(httpResponse.statusCode) + } + + let ollamaResponse = try JSONDecoder().decode(OllamaChatResponse.self, from: data) + + let latencyMs = Int(Date().timeIntervalSince(startTime) * 1000) + var translatedText = ollamaResponse.message?.content ?? "" + + // Clean up the response - remove emoji markers if present + translatedText = translatedText.replacingOccurrences(of: "🔤", with: "").trimmingCharacters(in: .whitespacesAndNewlines) + + // Remove any thinking/reasoning tags + translatedText = removeThinkingTags(from: translatedText) + + lastError = nil + return (translatedText, latencyMs) + + } catch is CancellationError { + // Deliberate cancellation (pause, ASR rollback, preemption) — not a network failure + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch let error as OllamaError { + appLog("[OllamaService] ❌ OllamaError in translate: \(error.localizedDescription)") + lastError = error.localizedDescription + throw error + } catch let error as URLError { + let mappedError = mapURLError(error) + appLog("[OllamaService] ❌ URLError in translate: \(mappedError.localizedDescription)") + lastError = mappedError.localizedDescription + throw mappedError + } catch { + appLog("[OllamaService] ❌ Unknown error in translate: \(error.localizedDescription)") + lastError = error.localizedDescription + throw OllamaError.networkError(error) + } + } + + /// Translate text using streaming for better UX (lower perceived latency) + func translateStreaming(text: String, context: [TranslationEntry] = [], onToken: @escaping (String) -> Void) async throws -> Int { + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return 0 + } + + let startTime = Date() + isTranslating = true + defer { isTranslating = false } + + guard let url = URL(string: settings.ollamaAPIEndpoint) else { + throw OllamaError.invalidURL + } + + // Build messages based on model type + let messages: [OllamaMessage] + let isTranslateGemma = settings.ollamaModel.lowercased().contains("translategemma") + let modelType = isTranslateGemma ? TranslationModelType.translateGemma : settings.translationModelType + + switch modelType { + case .translateGemma: + messages = buildTranslateGemmaMessages(text: text, context: context) + case .standard: + messages = buildStandardMessages(text: text, context: context) + } + + let request = OllamaChatRequest( + model: settings.ollamaModel, + messages: messages, + stream: true, // Enable streaming + temperature: settings.translationModelType == .translateGemma ? 0.1 : settings.ollamaTemperature, + keepAlive: "5m", + think: false + ) + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.httpBody = try JSONEncoder().encode(request) + + do { + let (asyncBytes, response) = try await session.bytes(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw OllamaError.invalidResponse + } + + // Process streaming response + for try await line in asyncBytes.lines { + guard !Task.isCancelled else { break } + + guard let data = line.data(using: .utf8), + let chunk = try? JSONDecoder().decode(OllamaChatResponse.self, from: data) else { + continue + } + + if let content = chunk.message?.content, !content.isEmpty { + // Clean and emit token + let cleanContent = content.replacingOccurrences(of: "🔤", with: "") + if !cleanContent.isEmpty { + onToken(cleanContent) + } + } + + if chunk.done { + break + } + } + + let latencyMs = Int(Date().timeIntervalSince(startTime) * 1000) + lastError = nil + return latencyMs + + } catch is CancellationError { + // Deliberate cancellation (pause, ASR rollback, preemption) — not a network failure + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch let error as OllamaError { + appLog("[OllamaService] ❌ OllamaError in translateStreaming: \(error.localizedDescription)") + lastError = error.localizedDescription + throw error + } catch let error as URLError { + let mappedError = mapURLError(error) + appLog("[OllamaService] ❌ URLError in translateStreaming: \(mappedError.localizedDescription)") + lastError = mappedError.localizedDescription + throw mappedError + } catch { + appLog("[OllamaService] ❌ Unknown error in translateStreaming: \(error.localizedDescription)") + lastError = error.localizedDescription + throw OllamaError.networkError(error) + } + } + + /// Unload the model from memory + func unloadModel() async throws { + guard let url = URL(string: settings.ollamaAPIEndpoint) else { + throw OllamaError.invalidURL + } + + let request = OllamaChatRequest( + model: settings.ollamaModel, + messages: [OllamaMessage(role: "user", content: "exit")], + stream: false, + temperature: nil, + keepAlive: "0", // Immediately unload + think: false + ) + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.httpBody = try JSONEncoder().encode(request) + urlRequest.timeoutInterval = 10 + + _ = try await session.data(for: urlRequest) + print("Model unloaded successfully") + } + + /// Remove thinking/reasoning tags from model output + private func removeThinkingTags(from text: String) -> String { + var result = text + + // Remove ... tags + let thinkPattern = #".*?"# + if let regex = try? NSRegularExpression(pattern: thinkPattern, options: [.dotMatchesLineSeparators]) { + let range = NSRange(result.startIndex..., in: result) + result = regex.stringByReplacingMatches(in: result, options: [], range: range, withTemplate: "") + } + + // Remove [thinking]...[/thinking] tags + let bracketPattern = #"\[thinking\].*?\[/thinking\]"# + if let regex = try? NSRegularExpression(pattern: bracketPattern, options: [.caseInsensitive, .dotMatchesLineSeparators]) { + let range = NSRange(result.startIndex..., in: result) + result = regex.stringByReplacingMatches(in: result, options: [], range: range, withTemplate: "") + } + + return result.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func mapURLError(_ error: URLError) -> OllamaError { + switch error.code { + case .timedOut: + return .timeout + case .cannotConnectToHost, .networkConnectionLost, .notConnectedToInternet: + return .serverNotRunning + default: + return .networkError(error) + } + } + + /// Get available models from Ollama + func getAvailableModels() async throws -> [String] { + guard let url = URL(string: "\(settings.ollamaURL)/api/tags") else { + throw OllamaError.invalidURL + } + + let (data, response) = try await session.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw OllamaError.invalidResponse + } + + struct TagsResponse: Codable { + struct Model: Codable { + let name: String + } + let models: [Model] + } + + let tagsResponse = try JSONDecoder().decode(TagsResponse.self, from: data) + return tagsResponse.models.map { $0.name } + } +} diff --git a/macos/LCTMac/Services/SpeechAnalyzerService.swift b/macos/LCTMac/Services/SpeechAnalyzerService.swift new file mode 100644 index 0000000..554d884 --- /dev/null +++ b/macos/LCTMac/Services/SpeechAnalyzerService.swift @@ -0,0 +1,359 @@ +import Foundation +import Speech +import AVFoundation +import os + +/// Thread-safe container for state shared between @MainActor and audio threads. +/// Uses os_unfair_lock for low-overhead synchronization. +private final class SharedSpeechState: @unchecked Sendable { + private var _lock = os_unfair_lock() + private var _isRunning: Bool = false + private var _bufferCount: Int = 0 + private var _request: SFSpeechAudioBufferRecognitionRequest? + + var isRunning: Bool { + get { os_unfair_lock_lock(&_lock); defer { os_unfair_lock_unlock(&_lock) }; return _isRunning } + set { os_unfair_lock_lock(&_lock); defer { os_unfair_lock_unlock(&_lock) }; _isRunning = newValue } + } + + var bufferCount: Int { + get { os_unfair_lock_lock(&_lock); defer { os_unfair_lock_unlock(&_lock) }; return _bufferCount } + set { os_unfair_lock_lock(&_lock); defer { os_unfair_lock_unlock(&_lock) }; _bufferCount = newValue } + } + + /// Atomically increment buffer count and return new value + func incrementBufferCount() -> Int { + os_unfair_lock_lock(&_lock) + defer { os_unfair_lock_unlock(&_lock) } + _bufferCount += 1 + return _bufferCount + } + + var request: SFSpeechAudioBufferRecognitionRequest? { + get { os_unfair_lock_lock(&_lock); defer { os_unfair_lock_unlock(&_lock) }; return _request } + set { os_unfair_lock_lock(&_lock); defer { os_unfair_lock_unlock(&_lock) }; _request = newValue } + } +} + +/// Apple speech recognition service using SFSpeechRecognizer +@MainActor +class SpeechAnalyzerService: ObservableObject { + // MARK: - Published Properties + @Published private(set) var isRunning: Bool = false + @Published private(set) var lastError: String? + @Published private(set) var currentLanguage: SourceLanguage = .english + + // MARK: - Callback + var onTranscription: ((TranscriptionResult) -> Void)? + + // MARK: - Private Properties + private var speechRecognizer: SFSpeechRecognizer? + private var recognitionTask: SFSpeechRecognitionTask? + private var lastTranscript: String = "" + private var lastFinalTranscript: String = "" + private var sessionStartTime: Date = Date() + private var currentSegmentId: UUID = UUID() + + // Thread-safe shared state accessible from both @MainActor and audio threads + private let sharedState = SharedSpeechState() + + // MARK: - Initialization + + init(language: SourceLanguage = .english) { + self.currentLanguage = language + // Note: SFSpeechRecognizer is NOT created here to avoid triggering + // a TCC privacy check at app launch before the UI is ready. + // It will be created lazily when start() or setLanguage() is called. + } + + /// Lazily create the speech recognizer when actually needed + private func ensureRecognizer() { + if speechRecognizer == nil { + speechRecognizer = SFSpeechRecognizer(locale: currentLanguage.locale) + } + } + + // MARK: - Language Management + + /// Update the recognition language + func setLanguage(_ language: SourceLanguage) { + // Only change if different + guard language != currentLanguage else { return } + + let wasRunning = isRunning + if wasRunning { + stop() + } + + currentLanguage = language + speechRecognizer = SFSpeechRecognizer(locale: language.locale) + + // Note: We don't auto-restart here anymore to avoid race conditions + // The caller should restart if needed + } + + /// Check if a language is available on this device + func isLanguageAvailable(_ language: SourceLanguage) -> Bool { + let recognizer = SFSpeechRecognizer(locale: language.locale) + return recognizer?.isAvailable ?? false + } + + /// Get all available languages on this device + func availableLanguages() -> [SourceLanguage] { + SourceLanguage.allCases.filter { isLanguageAvailable($0) } + } + + // MARK: - Authorization + + nonisolated func requestAuthorization() async -> SFSpeechRecognizerAuthorizationStatus { + appLog("[SpeechAnalyzerService] requestAuthorization() - checking current status...") + let currentStatus = SFSpeechRecognizer.authorizationStatus() + appLog("[SpeechAnalyzerService] Current authorization status: \(currentStatus.rawValue)") + + if currentStatus == .notDetermined { + appLog("[SpeechAnalyzerService] Status is notDetermined, requesting authorization from system...") + return await withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { status in + continuation.resume(returning: status) + } + } + } + + return currentStatus + } + + // MARK: - Recognition Control + + func start() async throws { + appLog("[SpeechAnalyzerService] start() called") + + appLog("[SpeechAnalyzerService] Requesting/checking authorization status...") + let status = await requestAuthorization() + appLog("[SpeechAnalyzerService] Authorization status: \(status.rawValue)") + + // If not authorized, fail immediately + if status != .authorized { + lastError = "Speech recognition permission not granted. Please enable it in System Settings." + appLog("[SpeechAnalyzerService] ❌ Not authorized (status: \(status.rawValue))") + throw SpeechAnalyzerError.notAuthorized + } + + // Lazily create recognizer now that we have authorization + ensureRecognizer() + + // Check recognizer availability + appLog("[SpeechAnalyzerService] Checking recognizer availability...") + guard let recognizer = speechRecognizer, recognizer.isAvailable else { + lastError = "Speech recognizer is unavailable for \(currentLanguage.displayName)." + appLog("[SpeechAnalyzerService] ❌ Recognizer unavailable") + throw SpeechAnalyzerError.recognizerUnavailable + } + appLog("[SpeechAnalyzerService] Recognizer available: \(recognizer.isAvailable)") + + // Stop any existing recognition + // Stop any existing recognition + appLog("[SpeechAnalyzerService] Stopping any existing recognition...") + stop() + + // Create new recognition request + appLog("[SpeechAnalyzerService] Creating recognition request...") + let request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + request.requiresOnDeviceRecognition = false // Allow network if needed for better quality + + // Configure for real-time transcription + if #available(macOS 13.0, *) { + request.addsPunctuation = true + } + + // Log the native audio format expected by the recognizer + let nativeFormat = request.nativeAudioFormat + appLog("[SpeechAnalyzerService] Recognition request native format: \(nativeFormat.sampleRate)Hz, \(nativeFormat.channelCount)ch") + + sharedState.request = request + sessionStartTime = Date() + currentSegmentId = UUID() + lastTranscript = "" + lastFinalTranscript = "" + + // Start recognition task + appLog("[SpeechAnalyzerService] Starting recognition task on main thread...") + + recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in + Task { @MainActor in + self?.handleRecognitionResult(result: result, error: error) + } + } + + isRunning = true + sharedState.isRunning = true + sharedState.bufferCount = 0 + lastError = nil + appLog("[SpeechAnalyzerService] ✅ Recognition started successfully") + } + + /// Append audio buffer to recognition request - can be called from any thread + /// Thread safety is ensured by SharedSpeechState's os_unfair_lock. + nonisolated func appendAudioBuffer(_ buffer: AVAudioPCMBuffer) { + guard sharedState.isRunning, let request = sharedState.request else { return } + let count = sharedState.incrementBufferCount() + if count % 50 == 1 { + appLog("[SpeechAnalyzerService] 🎤 Audio buffer #\(count) appended (format: \(buffer.format.sampleRate)Hz, \(buffer.format.channelCount)ch, frames: \(buffer.frameLength))") + } + request.append(buffer) + } + + func stop() { + sharedState.request?.endAudio() + recognitionTask?.cancel() + recognitionTask = nil + sharedState.request = nil + isRunning = false + sharedState.isRunning = false + lastTranscript = "" + } + + // MARK: - Private Methods + + private func handleRecognitionResult(result: SFSpeechRecognitionResult?, error: Error?) { + // Handle errors + if let error = error { + let nsError = error as NSError + let description = error.localizedDescription + appLog("[SpeechAnalyzerService] ⚠️ Recognition error: domain=\(nsError.domain) code=\(nsError.code) - \(description)") + // Ignore cancellation errors + if nsError.code == 216 || nsError.code == 1 || description.localizedCaseInsensitiveContains("cancel") { + return + } + lastError = description + return + } + + guard let result = result else { + appLog("[SpeechAnalyzerService] ⚠️ handleRecognitionResult called with nil result and nil error") + return + } + appLog("[SpeechAnalyzerService] 📝 Recognition result: isFinal=\(result.isFinal), text=\"\(result.bestTranscription.formattedString.prefix(80))\"") + + let transcript = result.bestTranscription.formattedString + + // Skip if empty or unchanged + if transcript.isEmpty || transcript == lastTranscript { + return + } + + lastTranscript = transcript + + // Calculate timing + let (startTime, endTime) = segmentTiming(from: result) + + // Calculate confidence + let confidence = calculateConfidence(from: result) + + // Create transcription result + let transcription = TranscriptionResult( + id: currentSegmentId, + text: transcript, + speaker: nil, + startTime: startTime, + endTime: endTime, + isVolatile: !result.isFinal, + confidence: confidence + ) + + // Notify callback + onTranscription?(transcription) + + // Update last final transcript + if result.isFinal { + lastFinalTranscript = transcript + + // Restart recognition for continuous transcription + Task { + await restartRecognitionForContinuous() + } + } + } + + private func segmentTiming(from result: SFSpeechRecognitionResult) -> (TimeInterval, TimeInterval) { + guard let lastSegment = result.bestTranscription.segments.last else { + let elapsed = Date().timeIntervalSince(sessionStartTime) + return (elapsed - 1, elapsed) + } + let start = lastSegment.timestamp + let end = lastSegment.timestamp + lastSegment.duration + return (start, end) + } + + private func calculateConfidence(from result: SFSpeechRecognitionResult) -> Float { + let segments = result.bestTranscription.segments + guard !segments.isEmpty else { return 0.0 } + + let totalConfidence = segments.reduce(0.0) { $0 + $1.confidence } + return totalConfidence / Float(segments.count) + } + + private func restartRecognitionForContinuous() async { + guard isRunning else { return } + + // Brief delay to prevent rapid restart loops if Apple fires isFinal in quick succession. + // Reduced from 100ms to 50ms to minimize audio loss during the gap. + try? await Task.sleep(nanoseconds: 50_000_000) // 0.05 seconds + + guard isRunning else { return } + + guard let recognizer = speechRecognizer, recognizer.isAvailable else { return } + + // === Buffer gap minimization strategy === + // Create the NEW request BEFORE tearing down the old one. + // This way, when we swap `recognitionRequest`, `appendAudioBuffer()` immediately + // starts feeding buffers to the new request with no gap. + + let newRequest = SFSpeechAudioBufferRecognitionRequest() + newRequest.shouldReportPartialResults = true + if #available(macOS 13.0, *) { + newRequest.addsPunctuation = true + } + + // Capture old references before swapping + let oldRequest = sharedState.request + let oldTask = recognitionTask + + // Atomically swap the request pointer — appendAudioBuffer() will immediately + // start appending to the new request from this point forward. + sharedState.request = newRequest + currentSegmentId = UUID() + lastTranscript = "" + + // Now tear down the old request/task. Any buffers that were appended to oldRequest + // after endAudio() are silently discarded by Apple (documented behavior). + oldRequest?.endAudio() + oldTask?.cancel() + + // Start the new recognition task + recognitionTask = recognizer.recognitionTask(with: newRequest) { [weak self] result, error in + Task { @MainActor in + self?.handleRecognitionResult(result: result, error: error) + } + } + } +} + +// MARK: - Error Types + +enum SpeechAnalyzerError: Error, LocalizedError { + case notAuthorized + case recognizerUnavailable + case audioSessionFailed + + var errorDescription: String? { + switch self { + case .notAuthorized: + return "Speech recognition is not authorized" + case .recognizerUnavailable: + return "Speech recognizer is unavailable" + case .audioSessionFailed: + return "Failed to configure audio session" + } + } +} diff --git a/macos/LCTMac/Services/TranslationQueue.swift b/macos/LCTMac/Services/TranslationQueue.swift new file mode 100644 index 0000000..a9fdbbb --- /dev/null +++ b/macos/LCTMac/Services/TranslationQueue.swift @@ -0,0 +1,458 @@ +import Foundation + +/// Translation task priority +enum TranslationPriority: Int, Comparable { + case low = 0 + case normal = 1 + case high = 2 + + static func < (lhs: TranslationPriority, rhs: TranslationPriority) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +/// Translation task +struct TranslationTask: Identifiable { + let id: UUID = UUID() + let segmentId: UUID + let text: String + let context: [TranslationEntry] + let priority: TranslationPriority + let timestamp: Date = Date() + let isFinal: Bool // Whether this is a final (non-volatile) transcription + + init(segmentId: UUID, text: String, context: [TranslationEntry] = [], priority: TranslationPriority = .normal, isFinal: Bool = true) { + self.segmentId = segmentId + self.text = text + self.context = context + self.priority = priority + self.isFinal = isFinal + } +} + +/// Translation result +struct TranslationQueueResult { + let taskId: UUID + let segmentId: UUID + let originalText: String + let translatedText: String + let latencyMs: Int + let success: Bool + let error: Error? + + init(taskId: UUID = UUID(), segmentId: UUID, originalText: String, translatedText: String, latencyMs: Int, success: Bool, error: Error? = nil) { + self.taskId = taskId + self.segmentId = segmentId + self.originalText = originalText + self.translatedText = translatedText + self.latencyMs = latencyMs + self.success = success + self.error = error + } +} + +/// Translation queue service with debouncing, streaming, and task management +@MainActor +class TranslationQueue: ObservableObject { + // MARK: - Published Properties + @Published private(set) var isProcessing: Bool = false + @Published private(set) var pendingCount: Int = 0 + @Published private(set) var droppedCount: Int = 0 + @Published private(set) var lastResult: TranslationQueueResult? + @Published private(set) var streamingText: String = "" // Current streaming output + + // MARK: - Callbacks + var onTranslationComplete: ((TranslationQueueResult) -> Void)? + var onStreamingUpdate: ((UUID, String) -> Void)? // Called on each token during streaming + + // MARK: - Configuration + private let debounceInterval: TimeInterval + private let maxRetries: Int + private let maxPendingTasks: Int + private let maxTaskAge: TimeInterval + private let shortTextMergeCharacterLimit: Int + private let shortTextMergeWindow: TimeInterval + private let ollamaService: OllamaService + var useStreaming: Bool = true // Enable streaming by default + + // MARK: - Private Properties + private var pendingTasks: [TranslationTask] = [] + private var currentTask: TranslationTask? + private var processingTask: Task? + private var debounceTask: Task? + private var lastProcessedText: String = "" + + /// Generation counter to prevent stale streaming tokens from polluting the UI. + /// Incremented on each new task. Captured by the onToken closure so that + /// tokens arriving after a cancellation/new-task are silently discarded. + private var generation: UInt64 = 0 + + // MARK: - Initialization + + init( + ollamaService: OllamaService, + debounceInterval: TimeInterval = 0.3, + maxRetries: Int = 2, + maxPendingTasks: Int = 20, + maxTaskAge: TimeInterval = 12, + shortTextMergeCharacterLimit: Int = 24, + shortTextMergeWindow: TimeInterval = 0.8 + ) { + self.ollamaService = ollamaService + self.debounceInterval = debounceInterval + self.maxRetries = maxRetries + self.maxPendingTasks = max(1, maxPendingTasks) + self.maxTaskAge = maxTaskAge + self.shortTextMergeCharacterLimit = shortTextMergeCharacterLimit + self.shortTextMergeWindow = shortTextMergeWindow + } + + // MARK: - Public Methods + + /// Enqueue a translation task + /// If the text is volatile (partial result), it will be debounced + /// If the text is final, it will be processed immediately + func enqueue(segmentId: UUID, text: String, context: [TranslationEntry] = [], priority: TranslationPriority = .normal, isFinal: Bool = true) { + let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedText.isEmpty else { return } + + // Skip if same as last processed text + if trimmedText == lastProcessedText && isFinal { + return + } + + let task = TranslationTask(segmentId: segmentId, text: trimmedText, context: context, priority: priority, isFinal: isFinal) + + if isFinal { + // Final results are processed immediately with high priority + cancelPendingDebounce() + + // Preempt in-progress volatile translation: if we're currently processing + // a non-final (volatile) task, cancel it immediately so the final text + // gets translated with minimal delay instead of waiting. + if let current = currentTask, !current.isFinal, isProcessing { + processingTask?.cancel() + processingTask = nil + currentTask = nil + isProcessing = false + streamingText = "" + } + + enqueueTask(task) + } else { + // Volatile results are debounced + debounceTask?.cancel() + debounceTask = Task { + try? await Task.sleep(nanoseconds: UInt64(debounceInterval * 1_000_000_000)) + guard !Task.isCancelled else { return } + self.enqueueTask(task) + } + } + } + + /// Cancel all pending tasks + func cancelAll() { + debounceTask?.cancel() + processingTask?.cancel() + pendingTasks.removeAll() + currentTask = nil + pendingCount = 0 + isProcessing = false + streamingText = "" + // Reset the dedup baseline: canceled work may be re-enqueued with the + // same text later (e.g. resume after pause) and must not be skipped + lastProcessedText = "" + // Bump generation so any in-flight token dispatches are discarded + generation &+= 1 + } + + /// Cancel queued or in-flight work for specific UI segments. + func cancel(segmentIds: Set) { + guard !segmentIds.isEmpty else { return } + + pendingTasks.removeAll { segmentIds.contains($0.segmentId) } + pendingCount = pendingTasks.count + + if let currentTask, segmentIds.contains(currentTask.segmentId) { + processingTask?.cancel() + processingTask = nil + self.currentTask = nil + isProcessing = false + streamingText = "" + generation &+= 1 + } + + processNextIfNeeded() + } + + /// Cancel pending debounce (for immediate processing) + func cancelPendingDebounce() { + debounceTask?.cancel() + debounceTask = nil + } + + // MARK: - Private Methods + + private func enqueueTask(_ task: TranslationTask) { + pruneStalePendingTasks() + let taskToQueue = coalesceShortPendingTask(task) + + // Remove any pending task with the same text (deduplication) + pendingTasks.removeAll { $0.text == taskToQueue.text } + + // Add new task + pendingTasks.append(taskToQueue) + + // Sort by priority (higher priority first) and timestamp (older first for same priority) + pendingTasks.sort { + if $0.priority != $1.priority { + return $0.priority > $1.priority + } + return $0.timestamp < $1.timestamp + } + enforcePendingLimit() + + pendingCount = pendingTasks.count + + // Start processing if not already running + processNextIfNeeded() + } + + private func processNextIfNeeded() { + pruneStalePendingTasks() + pendingCount = pendingTasks.count + + guard !isProcessing, !pendingTasks.isEmpty else { return } + + guard let task = pendingTasks.first else { return } + pendingTasks.removeFirst() + pendingCount = pendingTasks.count + + currentTask = task + isProcessing = true + streamingText = "" + + // Bump generation for this new task + generation &+= 1 + let taskGeneration = generation + + processingTask = Task { + if useStreaming { + await processTaskStreaming(task, generation: taskGeneration) + } else { + await processTask(task, generation: taskGeneration) + } + } + } + + private func pruneStalePendingTasks() { + guard maxTaskAge > 0 else { return } + + let now = Date() + let beforeCount = pendingTasks.count + pendingTasks.removeAll { now.timeIntervalSince($0.timestamp) > maxTaskAge } + let removedCount = beforeCount - pendingTasks.count + if removedCount > 0 { + droppedCount += removedCount + } + } + + private func enforcePendingLimit() { + while pendingTasks.count > maxPendingTasks { + guard let dropIndex = pendingTasks.indices.min(by: { lhs, rhs in + let lhsTask = pendingTasks[lhs] + let rhsTask = pendingTasks[rhs] + + if lhsTask.priority != rhsTask.priority { + return lhsTask.priority < rhsTask.priority + } + + return lhsTask.timestamp < rhsTask.timestamp + }) else { + return + } + + pendingTasks.remove(at: dropIndex) + droppedCount += 1 + } + } + + private func coalesceShortPendingTask(_ task: TranslationTask) -> TranslationTask { + guard !task.isFinal, + shortTextMergeCharacterLimit > 0, + task.text.count <= shortTextMergeCharacterLimit, + let existingIndex = pendingTasks.lastIndex(where: { + !$0.isFinal + && $0.segmentId == task.segmentId + && $0.priority == task.priority + && $0.text.count <= shortTextMergeCharacterLimit + && Date().timeIntervalSince($0.timestamp) <= shortTextMergeWindow + }) else { + return task + } + + let existingTask = pendingTasks.remove(at: existingIndex) + let mergedText: String + if task.text.hasPrefix(existingTask.text) { + mergedText = task.text + } else { + mergedText = TextUtils.concatenateSentences([existingTask.text, task.text]) + } + + return TranslationTask( + segmentId: task.segmentId, + text: mergedText, + context: task.context.isEmpty ? existingTask.context : task.context, + priority: task.priority, + isFinal: false + ) + } + + /// Process task with streaming for better UX + private func processTaskStreaming(_ task: TranslationTask, generation taskGeneration: UInt64, retryCount: Int = 0) async { + let startTime = Date() + streamingText = "" + + do { + let latencyMs = try await ollamaService.translateStreaming( + text: task.text, + context: task.context + ) { [weak self] token in + guard let self = self else { return } + Task { @MainActor in + // Check generation: if a newer task has started, discard this stale token + guard self.generation == taskGeneration else { return } + self.streamingText += token + self.onStreamingUpdate?(task.segmentId, self.streamingText) + } + } + + guard !Task.isCancelled else { return } + // Double-check generation hasn't changed during processing + guard generation == taskGeneration else { return } + + // Only final translations update the dedup baseline. Volatile draft + // text is transient and must not suppress the final segment that + // happens to share the same text. + if task.isFinal { + lastProcessedText = task.text + } + + let result = TranslationQueueResult( + taskId: task.id, + segmentId: task.segmentId, + originalText: task.text, + translatedText: streamingText, + latencyMs: latencyMs, + success: true, + error: nil + ) + + lastResult = result + onTranslationComplete?(result) + + } catch { + guard !Task.isCancelled else { return } + guard generation == taskGeneration else { return } + + // Retry if possible + if retryCount < maxRetries { + let delay = pow(2.0, Double(retryCount)) * 0.5 + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + + guard !Task.isCancelled else { return } + guard generation == taskGeneration else { return } + await processTaskStreaming(task, generation: taskGeneration, retryCount: retryCount + 1) + return + } + + // Report failure + let latencyMs = Int(Date().timeIntervalSince(startTime) * 1000) + let result = TranslationQueueResult( + taskId: task.id, + segmentId: task.segmentId, + originalText: task.text, + translatedText: "[ERROR] \(error.localizedDescription)", + latencyMs: latencyMs, + success: false, + error: error + ) + + lastResult = result + onTranslationComplete?(result) + } + + // Mark as done and process next + isProcessing = false + currentTask = nil + processNextIfNeeded() + } + + /// Process task without streaming (fallback) + private func processTask(_ task: TranslationTask, generation taskGeneration: UInt64, retryCount: Int = 0) async { + let startTime = Date() + + do { + let (translatedText, latencyMs) = try await ollamaService.translate( + text: task.text, + context: task.context + ) + + guard !Task.isCancelled else { return } + guard generation == taskGeneration else { return } + + if task.isFinal { + lastProcessedText = task.text + } + + let result = TranslationQueueResult( + taskId: task.id, + segmentId: task.segmentId, + originalText: task.text, + translatedText: translatedText, + latencyMs: latencyMs, + success: true, + error: nil + ) + + lastResult = result + onTranslationComplete?(result) + + } catch { + guard !Task.isCancelled else { return } + guard generation == taskGeneration else { return } + + // Retry if possible + if retryCount < maxRetries { + // Exponential backoff + let delay = pow(2.0, Double(retryCount)) * 0.5 + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + + guard !Task.isCancelled else { return } + guard generation == taskGeneration else { return } + await processTask(task, generation: taskGeneration, retryCount: retryCount + 1) + return + } + + // Report failure + let latencyMs = Int(Date().timeIntervalSince(startTime) * 1000) + let result = TranslationQueueResult( + taskId: task.id, + segmentId: task.segmentId, + originalText: task.text, + translatedText: "[ERROR] \(error.localizedDescription)", + latencyMs: latencyMs, + success: false, + error: error + ) + + lastResult = result + onTranslationComplete?(result) + } + + // Mark as done and process next + isProcessing = false + currentTask = nil + processNextIfNeeded() + } +} diff --git a/macos/LCTMac/Utils/CaptionSegmenter.swift b/macos/LCTMac/Utils/CaptionSegmenter.swift new file mode 100644 index 0000000..90cd47c --- /dev/null +++ b/macos/LCTMac/Utils/CaptionSegmenter.swift @@ -0,0 +1,180 @@ +import Foundation + +class CaptionSegmenter { + private static let sentenceEnders: Set = [".", "!", "?", "。", "!", "?"] + + /// Force a cut once the draft exceeds this many characters, even mid-sentence + private let forceCutLength: Int + /// Minimum completed-sentence length required to cut while speech continues + private let eagerCutMinLength: Int + /// Cut at a trailing sentence end once the draft has been quiet this long + private let quietCutInterval: TimeInterval + /// Fallback wrap point when force-cutting text that has no punctuation + private let hardWrapLength: Int + + private var currentTaskId: UUID? + private var committedLength: Int = 0 + private var committedText: String = "" + /// End offset (in characters of the full ASR text) of each emitted segment, + /// in order. Parallel to the segments the caller created for this task. + private var committedSegmentEnds: [Int] = [] + private var lastText: String = "" + private var lastUpdateTime: Date = Date() + + init( + forceCutLength: Int = 120, + eagerCutMinLength: Int = 40, + quietCutInterval: TimeInterval = 0.8, + hardWrapLength: Int = 100 + ) { + self.forceCutLength = forceCutLength + self.eagerCutMinLength = eagerCutMinLength + self.quietCutInterval = quietCutInterval + self.hardWrapLength = hardWrapLength + } + + /// Process incoming ASR text. Returns newly finalized segments, the current + /// live draft, and how many trailing previously-emitted segments were + /// invalidated by an ASR revision (0 when no rollback happened). + func process(result: TranscriptionResult) -> (finalized: [String], liveDraft: String, invalidatedTailCount: Int) { + if currentTaskId != result.id { + currentTaskId = result.id + committedLength = 0 + committedText = "" + committedSegmentEnds = [] + lastText = "" + lastUpdateTime = Date() + } + + let text = result.text + + if !committedText.isEmpty && !text.hasPrefix(committedText) { + // SFSpeech revised already-committed text. Roll back only the + // segments past the surviving common prefix instead of everything. + return handleRollback(newText: text) + } + + let startIndex = text.index(text.startIndex, offsetBy: committedLength) + let uncommittedText = String(text[startIndex...]) + + var newlyFinalized: [String] = [] + var liveDraft = uncommittedText + + if result.isVolatile { + if shouldCut(uncommittedText), let cutPos = findCutPosition(in: uncommittedText) { + let segment = String(uncommittedText.prefix(upTo: cutPos)).trimmingCharacters(in: .whitespaces) + + let offset = uncommittedText.distance(from: uncommittedText.startIndex, to: cutPos) + committedLength += offset + let committedEnd = text.index(text.startIndex, offsetBy: committedLength) + committedText = String(text[.. Bool { + if uncommitted.count > forceCutLength { + return true + } + + guard let lastPunct = uncommitted.lastIndex(where: { Self.sentenceEnders.contains($0) }) else { + return false + } + + let afterPunct = uncommitted.index(after: lastPunct) + let trailing = uncommitted[afterPunct...].trimmingCharacters(in: .whitespaces) + + if !trailing.isEmpty { + // Speech has moved past a completed sentence — cut as soon as the + // completed part is substantial, without waiting for a pause. + let completedCount = uncommitted.distance(from: uncommitted.startIndex, to: afterPunct) + if completedCount >= eagerCutMinLength { + return true + } + } + + // Trailing or short sentence: wait for a brief quiet period so the ASR + // has a chance to revise it before we commit. + return Date().timeIntervalSince(lastUpdateTime) > quietCutInterval + } + + private func findCutPosition(in text: String) -> String.Index? { + if let lastPunc = text.lastIndex(where: { Self.sentenceEnders.contains($0) }) { + return text.index(after: lastPunc) + } + if text.count > hardWrapLength { + if let lastSpace = text.lastIndex(where: { $0.isWhitespace }) { + return text.index(after: lastSpace) + } + if TextUtils.containsCJK(text) { + return text.index(text.startIndex, offsetBy: min(hardWrapLength, text.count)) + } + } + return nil + } + + // MARK: - Rollback + + private func handleRollback(newText text: String) -> (finalized: [String], liveDraft: String, invalidatedTailCount: Int) { + let prefixLength = commonPrefixLength(committedText, text) + + // Segments fully inside the unchanged prefix survive; the rest are stale + let survivingEnds = committedSegmentEnds.prefix(while: { $0 <= prefixLength }) + let staleCount = committedSegmentEnds.count - survivingEnds.count + + committedSegmentEnds = Array(survivingEnds) + committedLength = survivingEnds.last ?? 0 + let committedEnd = text.index(text.startIndex, offsetBy: committedLength) + committedText = String(text[.. Int { + var count = 0 + for (ca, cb) in zip(a, b) { + if ca != cb { break } + count += 1 + } + return count + } +} diff --git a/macos/LCTMac/Utils/TextUtils.swift b/macos/LCTMac/Utils/TextUtils.swift new file mode 100644 index 0000000..c02dd10 --- /dev/null +++ b/macos/LCTMac/Utils/TextUtils.swift @@ -0,0 +1,199 @@ +import Foundation + +/// Text utility functions for caption processing +/// Similar to Windows version's TextUtil.cs +enum TextUtils { + // MARK: - Punctuation + + /// End-of-sentence punctuation marks + static let endOfSentencePunctuation: Set = [ + ".", "!", "?", + "\u{3002}", "\u{FF01}", "\u{FF1F}", // 。!? + "\u{FF0E}", "\u{FF61}", // .。 + "\u{2026}", "\u{2025}", // … ‥ + ] + + /// All punctuation marks + static let allPunctuation: Set = [ + ".", ",", "!", "?", ";", ":", "\"", + "\u{3002}", "\u{FF0C}", "\u{FF01}", "\u{FF1F}", "\u{FF1B}", "\u{FF1A}", // 。,!?;: + "\u{3001}", // 、 + "\u{FF0E}", "\u{FF61}", "\u{FF64}", // .。、 + "\u{2026}", "\u{2025}", "\u{2014}", "\u{2013}", // … ‥ — – + ] + + // MARK: - CJK Detection + + /// Check if a character is a CJK character + static func isCJK(_ char: Character?) -> Bool { + guard let char = char else { return false } + + for scalar in char.unicodeScalars { + let value = scalar.value + + // CJK Unified Ideographs + if (0x4E00...0x9FFF).contains(value) { return true } + // CJK Extension A + if (0x3400...0x4DBF).contains(value) { return true } + // CJK Extension B + if (0x20000...0x2A6DF).contains(value) { return true } + // CJK Compatibility Ideographs + if (0xF900...0xFAFF).contains(value) { return true } + // Hiragana + if (0x3040...0x309F).contains(value) { return true } + // Katakana + if (0x30A0...0x30FF).contains(value) { return true } + // Hangul Syllables + if (0xAC00...0xD7AF).contains(value) { return true } + // Hangul Jamo + if (0x1100...0x11FF).contains(value) { return true } + } + + return false + } + + /// Check if a string contains CJK characters + static func containsCJK(_ text: String) -> Bool { + text.contains { isCJK($0) } + } + + /// Check if a string is primarily CJK + static func isPrimarilyCJK(_ text: String) -> Bool { + let cjkCount = text.filter { isCJK($0) }.count + let totalChars = text.filter { !$0.isWhitespace }.count + guard totalChars > 0 else { return false } + return Double(cjkCount) / Double(totalChars) > 0.5 + } + + // MARK: - Punctuation Processing + + /// Check if a string ends with sentence-ending punctuation + static func hasEndPunctuation(_ text: String) -> Bool { + guard let lastChar = text.last else { return false } + return endOfSentencePunctuation.contains(lastChar) + } + + /// Add appropriate end punctuation if missing + static func ensureEndPunctuation(_ text: String) -> String { + guard !text.isEmpty else { return text } + guard !hasEndPunctuation(text) else { return text } + + if let lastChar = text.last, isCJK(lastChar) { + return text + "\u{3002}" // 。 + } else { + return text + "." + } + } + + /// Get appropriate sentence separator based on text content + static func getSeparator(for text: String) -> String { + if isPrimarilyCJK(text) { + return "\u{3002}" // 。 + } else { + return ". " + } + } + + // MARK: - Text Processing + + /// Clean up translation output + /// Removes markers and extra whitespace + static func cleanTranslationOutput(_ text: String) -> String { + var result = text + + // Remove markers if present + result = result.replacingOccurrences(of: "\u{1F524}", with: "") // 🔤 + + // Remove common prefixes that models sometimes add + let prefixesToRemove = [ + "Translation:", + "翻译:", + "译文:", + "Here is the translation:", + "The translation is:", + ] + + for prefix in prefixesToRemove { + if result.lowercased().hasPrefix(prefix.lowercased()) { + result = String(result.dropFirst(prefix.count)) + } + } + + // Trim whitespace + result = result.trimmingCharacters(in: .whitespacesAndNewlines) + + return result + } + + /// Format text for display (handles long text) + static func formatForDisplay(_ text: String, maxLength: Int = 500) -> String { + guard text.count > maxLength else { return text } + + let truncated = String(text.prefix(maxLength)) + + // Try to truncate at a sentence boundary + if let lastSentenceEnd = truncated.lastIndex(where: { endOfSentencePunctuation.contains($0) }) { + return String(truncated[...lastSentenceEnd]) + } + + // Or at a word boundary + if let lastSpace = truncated.lastIndex(of: " ") { + return String(truncated[.. String { + guard !sentences.isEmpty else { return "" } + + return sentences.reduce("") { accumulated, current in + guard !current.isEmpty else { return accumulated } + + var result = accumulated + + if !result.isEmpty { + // Add separator if needed + if !hasEndPunctuation(result) { + result += getSeparator(for: result) + } else if !isCJK(result.last) { + result += " " + } + } + + return result + current + } + } + + /// Check if text appears to be a complete sentence + static func isCompleteSentence(_ text: String) -> Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + return hasEndPunctuation(trimmed) + } + + /// Extract the last complete sentence from text + static func extractLastSentence(_ text: String) -> String? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + // Find sentence boundaries + var lastBoundary: String.Index? + var secondLastBoundary: String.Index? + + for i in trimmed.indices { + if endOfSentencePunctuation.contains(trimmed[i]) { + secondLastBoundary = lastBoundary + lastBoundary = i + } + } + + if let last = lastBoundary { + let startIndex = secondLastBoundary.map { trimmed.index(after: $0) } ?? trimmed.startIndex + return String(trimmed[startIndex...last]).trimmingCharacters(in: .whitespaces) + } + + return nil + } +} \ No newline at end of file diff --git a/macos/LCTMac/ViewModels/TranscriptionVM.swift b/macos/LCTMac/ViewModels/TranscriptionVM.swift new file mode 100644 index 0000000..5f7c8e1 --- /dev/null +++ b/macos/LCTMac/ViewModels/TranscriptionVM.swift @@ -0,0 +1,758 @@ +import SwiftUI +import Combine + +/// Main transcription and translation view model +@MainActor +class TranscriptionViewModel: ObservableObject { + // MARK: - Published Properties + + /// All finalized and currently translating segments in the current session + @Published var segments: [TranslationSegment] = [] + + /// The current unfinalized live draft text from ASR + @Published var liveSourceText: String = "" + + /// Streaming translation of the current draft (transient; not persisted) + @Published var liveTranslation: String = "" + + /// Translation history for context + @Published var translationHistory: [TranslationEntry] = [] + + /// Is currently capturing audio + @Published var isCapturing: Bool = false + + /// Is currently translating + @Published var isTranslating: Bool = false + + /// Is paused + @Published var isPaused: Bool = false + + /// Audio level for visualization + @Published var audioLevel: Float = 0 + + /// Current user-facing notice (info / warning / error with optional actions) + @Published var notice: AppNotice? + + /// Last translation latency + @Published var lastLatencyMs: Int = 0 + + /// Ollama connection status + @Published var isOllamaConnected: Bool = false + + /// When the current capture session started (nil when stopped) + @Published var captureStartedAt: Date? + + // MARK: - Services + + private let audioCaptureService: AudioCaptureService + private let ollamaService: OllamaService + private let translationQueue: TranslationQueue + private let speakerManager: SpeakerManager + private let speechAnalyzerService: SpeechAnalyzerService + private let caption: Caption + private let captionSegmenter = CaptionSegmenter() + private let ollamaGuardian = OllamaGuardian.shared + private let historyService = HistoryService() + + // MARK: - Settings + + @Published var settings: AppSettings + + // MARK: - Private Properties + + private var cancellables = Set() + private var activeTranscriptionTaskId: UUID? + /// UI segment ids emitted for the active ASR task, in emission order. + /// Parallel to CaptionSegmenter's committed segments so tail rollbacks align. + private var activeTaskSegmentIds: [UUID] = [] + private var historyEntryIdsBySegmentId: [UUID: UUID] = [:] + /// Stable id for the volatile draft translation task, kept out of `segments` + /// so its streaming/complete callbacks route to `liveTranslation` instead. + private let liveDraftSegmentId = UUID() + + // MARK: - Initialization + + init() { + let loadedSettings = AppSettings.load() + self.settings = loadedSettings + self.audioCaptureService = AudioCaptureService( + config: AudioCaptureConfig( + captureSystemAudio: loadedSettings.captureSystemAudio, + captureMicrophone: loadedSettings.captureMicrophone + ) + ) + self.ollamaService = OllamaService(settings: loadedSettings) + self.translationQueue = TranslationQueue(ollamaService: ollamaService) + self.speakerManager = SpeakerManager() + self.speechAnalyzerService = SpeechAnalyzerService(language: loadedSettings.sourceLanguage) + self.caption = Caption.shared + + caption.maxContextEntries = loadedSettings.maxContextEntries + + setupBindings() + + if let persistenceError = AppSettings.consumeLastPersistenceError() { + notice = .warning(persistenceError, autoDismiss: false) + } + } + + // MARK: - Setup + + private func setupBindings() { + // Bind audio level + audioCaptureService.$audioLevel + .receive(on: DispatchQueue.main) + .assign(to: &$audioLevel) + + // Bind capture state + audioCaptureService.$isCapturing + .receive(on: DispatchQueue.main) + .assign(to: &$isCapturing) + + // Bind translation queue state + translationQueue.$isProcessing + .receive(on: DispatchQueue.main) + .assign(to: &$isTranslating) + + // Bind Ollama connection state + ollamaService.$isConnected + .receive(on: DispatchQueue.main) + .assign(to: &$isOllamaConnected) + + // Bind errors. These are runtime failures surfaced mid-session; offer a + // retry since the user's intent was to keep capturing. + audioCaptureService.$lastError + .receive(on: DispatchQueue.main) + .compactMap { $0 } + .sink { [weak self] error in + self?.notice = .error(error, actions: [.retryCapture]) + } + .store(in: &cancellables) + + ollamaService.$lastError + .receive(on: DispatchQueue.main) + .compactMap { $0 } + .sink { [weak self] error in + self?.notice = .error(error, actions: [.retryCapture]) + } + .store(in: &cancellables) + + speechAnalyzerService.$lastError + .receive(on: DispatchQueue.main) + .compactMap { $0 } + .sink { [weak self] error in + self?.notice = .error(error, actions: [.retryCapture]) + } + .store(in: &cancellables) + + // Handle speech recognition results + speechAnalyzerService.onTranscription = { [weak self] result in + self?.handleTranscriptionResult(result) + } + + // Handle translation results + translationQueue.onTranslationComplete = { [weak self] result in + self?.handleTranslationResult(result) + } + + translationQueue.onStreamingUpdate = { [weak self] segmentId, streamingText in + guard let self = self else { return } + + if segmentId == self.liveDraftSegmentId { + self.liveTranslation = streamingText + return + } + + if let idx = self.segments.firstIndex(where: { $0.id == segmentId }) { + self.segments[idx].translatedText = streamingText + self.segments[idx].state = .translating + } + } + + // Handle SCStream interruption (e.g., display disconnected, system error) + audioCaptureService.onStreamInterrupted = { [weak self] error in + guard let self = self else { return } + appLog("[TranscriptionVM] ⚠️ Audio stream interrupted: \(error.localizedDescription)") + // Stop speech recognition and translation queue since audio is gone + self.speechAnalyzerService.stop() + self.translationQueue.cancelAll() + self.notice = .error("Audio capture interrupted: \(error.localizedDescription)", actions: [.retryCapture]) + } + + } + + // MARK: - Actions + + /// Start capturing and translating + func start() async { + appLog("[TranscriptionVM] ▶️ start() called") + + var micOnlyFallback = false + + do { + notice = nil + + // Check if we need screen capture (system audio) or just microphone + let needsScreenCapture = settings.captureSystemAudio + appLog("[TranscriptionVM] needsScreenCapture: \(needsScreenCapture)") + appLog("[TranscriptionVM] captureMicrophone: \(settings.captureMicrophone)") + + if needsScreenCapture { + appLog("[TranscriptionVM] Checking screen capture permission...") + // Check screen capture permission first + let hasPermission = await audioCaptureService.checkPermission() + appLog("[TranscriptionVM] Screen capture permission: \(hasPermission)") + + if !hasPermission { + // If microphone is also enabled, offer to continue with microphone only + if settings.captureMicrophone { + appLog("[TranscriptionVM] Screen capture denied, will fall back to microphone-only mode") + micOnlyFallback = true + // Continue with microphone only - don't return + } else { + appLog("[TranscriptionVM] ❌ No permission and no microphone fallback") + notice = .error( + "Screen recording permission is required to capture system audio.", + actions: [.openScreenRecordingSettings] + ) + return + } + } + } + + if settings.isLocalOllama { + // Ensure local Ollama is running (will start it if needed). + appLog("[TranscriptionVM] Ensuring local Ollama is running...") + do { + try await ollamaGuardian.ensureRunning() + appLog("[TranscriptionVM] ✅ Local Ollama is running") + } catch let error as OllamaGuardianError { + appLog("[TranscriptionVM] ❌ Local Ollama error: \(error)") + notice = .error(error.localizedDescription, actions: [.startOllama]) + return + } catch { + appLog("[TranscriptionVM] ❌ Local Ollama error: \(error)") + notice = .error("Cannot start Ollama: \(error.localizedDescription)", actions: [.startOllama]) + return + } + } else { + appLog("[TranscriptionVM] Using remote Ollama at \(settings.ollamaURL); skipping local startup") + } + + // Check Ollama connection + appLog("[TranscriptionVM] Checking Ollama connection...") + let isConnected = await ollamaService.checkHealth() + appLog("[TranscriptionVM] Ollama connected: \(isConnected)") + if !isConnected { + if settings.isLocalOllama { + notice = .error( + "Cannot connect to local Ollama at \(settings.ollamaURL).", + actions: [.startOllama] + ) + } else { + notice = .error( + "Cannot connect to remote Ollama at \(settings.ollamaURL). Check the address in Settings.", + actions: [.openAppSettings] + ) + } + return + } + + do { + let availableModels = try await ollamaService.getAvailableModels() + guard isModelInstalled(settings.ollamaModel, in: availableModels) else { + let installHint = settings.isLocalOllama + ? "Run: ollama pull \(settings.ollamaModel)" + : "Install it on the configured remote Ollama server." + notice = .error( + "Model '\(settings.ollamaModel)' is not installed. \(installHint)", + actions: [.openAppSettings] + ) + return + } + } catch { + notice = .error("Cannot list Ollama models: \(error.localizedDescription)", actions: [.retryCapture]) + return + } + + notice = .info("Loading translation model — the first run may take 5–30 seconds…") + do { + let latencyMs = try await ollamaService.prewarmModel() + appLog("[TranscriptionVM] ✅ Ollama model prewarmed in \(latencyMs)ms") + notice = nil + } catch let error as OllamaError { + notice = .error("Cannot load model '\(settings.ollamaModel)': \(error.localizedDescription)", actions: [.retryCapture]) + return + } catch { + notice = .error("Cannot load model '\(settings.ollamaModel)': \(error.localizedDescription)", actions: [.retryCapture]) + return + } + + // Update speech recognizer language + appLog("[TranscriptionVM] Setting speech language: \(settings.sourceLanguage.displayName)") + speechAnalyzerService.setLanguage(settings.sourceLanguage) + + // Start speech recognition + appLog("[TranscriptionVM] Starting speech recognition...") + try await speechAnalyzerService.start() + appLog("[TranscriptionVM] ✅ Speech recognition started") + + // Connect audio capture to speech recognizer + let analyzer = self.speechAnalyzerService + audioCaptureService.onAudioBuffer = { [weak analyzer] buffer in + analyzer?.appendAudioBuffer(buffer) + } + audioCaptureService.onAudioData = nil + + // Start audio capture (will use microphone if screen capture fails) + appLog("[TranscriptionVM] Starting audio capture...") + do { + try await audioCaptureService.startCapture() + appLog("[TranscriptionVM] ✅ Audio capture started (screen + mic)") + } catch AudioCaptureError.noPermission where settings.captureMicrophone { + // Fall back to microphone-only mode + appLog("[TranscriptionVM] Screen capture failed, falling back to microphone-only mode") + micOnlyFallback = true + try await audioCaptureService.startMicrophoneOnlyCapture() + appLog("[TranscriptionVM] ✅ Microphone-only capture started") + } + + appLog("[TranscriptionVM] ✅ start() completed successfully") + captureStartedAt = Date() + + // Surface mic-only mode now that capture is running (earlier notices + // were overwritten by the model-loading progress message). + if micOnlyFallback { + notice = .warning("No screen recording permission — capturing microphone only.") + } + + // Start local health monitoring only for local Ollama. + if settings.isLocalOllama { + ollamaGuardian.startHealthMonitoring(interval: 30) + } + + } catch let error as SpeechAnalyzerError { + switch error { + case .notAuthorized: + notice = .error( + "Speech recognition permission is required.", + actions: [.openSpeechRecognitionSettings] + ) + case .recognizerUnavailable: + notice = .error( + "Speech recognition is unavailable for \(settings.sourceLanguage.displayName). Try another language.", + actions: [.openAppSettings] + ) + case .audioSessionFailed: + notice = .error("Failed to configure audio session.", actions: [.retryCapture]) + } + } catch let error as AudioCaptureError { + switch error { + case .noPermission: + notice = .error( + "Screen recording permission is required to capture system audio.", + actions: [.openScreenRecordingSettings] + ) + case .noDisplaysAvailable: + notice = .error("No displays available for audio capture.") + case .captureSetupFailed(let message): + notice = .error("Capture setup failed: \(message)", actions: [.retryCapture]) + case .audioProcessingFailed(let message): + notice = .error("Audio processing failed: \(message)", actions: [.retryCapture]) + case .streamInterrupted(let message): + notice = .error("Audio stream interrupted: \(message)", actions: [.retryCapture]) + } + } catch { + notice = .error(error.localizedDescription, actions: [.retryCapture]) + } + } + + // MARK: - Notice Handling + + /// Dismiss the current notice. + func dismissNotice() { + notice = nil + } + + /// Perform a notice's remediation action. `.openAppSettings` is handled by + /// the view (it owns the settings sheet) and is a no-op here. + func perform(_ action: NoticeAction) { + switch action { + case .openScreenRecordingSettings: + AudioCaptureService.openScreenRecordingSettings() + case .openMicrophoneSettings: + AudioCaptureService.openMicrophoneSettings() + case .openSpeechRecognitionSettings: + AudioCaptureService.openSpeechRecognitionSettings() + case .startOllama: + notice = .info("Starting Ollama…") + Task { + do { + try await ollamaGuardian.ensureRunning() + await start() + } catch { + notice = .error("Could not start Ollama: \(error.localizedDescription)") + } + } + case .retryCapture: + notice = nil + Task { await start() } + case .openAppSettings: + break // handled by the view + } + } + + /// Stop capturing + func stop() async { + captureStartedAt = nil + await audioCaptureService.stopCapture() + translationQueue.cancelAll() + speechAnalyzerService.stop() + liveTranslation = "" + clearTransientSegmentBookkeeping() + + // Stop health monitoring + ollamaGuardian.stopHealthMonitoring() + + // Unload model to free memory + try? await ollamaService.unloadModel() + } + + /// Toggle pause state + func togglePause() { + isPaused.toggle() + if isPaused { + translationQueue.cancelAll() + liveTranslation = "" + // cancelAll() drops queued and in-flight work; mark those segments + // as pending (and discard partial streaming output) so they are + // re-enqueued on resume instead of dangling in .translating forever. + for idx in segments.indices where segments[idx].state == .translating { + segments[idx].state = .pending + segments[idx].translatedText = "" + } + } else { + resumePendingTranslations() + } + } + + /// Re-enqueue segments that finalized while paused or were canceled by pausing + private func resumePendingTranslations() { + for idx in segments.indices where segments[idx].state == .pending { + segments[idx].state = .translating + let context = settings.contextAware ? caption.getContextForTranslation() : [] + translationQueue.enqueue( + segmentId: segments[idx].id, + text: segments[idx].sourceText, + context: context, + priority: .high, + isFinal: true + ) + } + } + + /// Clear all transcriptions and translations + func clear() { + segments.removeAll() + liveSourceText = "" + liveTranslation = "" + captionSegmenter.reset() + translationHistory.removeAll() + speakerManager.clear() + caption.clear() + clearTransientSegmentBookkeeping() + } + + /// Clear all persistent history from SQLite + func clearPersistentHistory() { + do { + try historyService.clearHistory() + } catch { + appLog("[TranscriptionVM] Failed to clear history: \(error)") + } + } + + /// Load persistent history from SQLite + func loadPersistentHistory(limit: Int = 200) async -> [TranslationEntry] { + do { + return try await historyService.loadRecentTranslationsAsync(limit: limit) + } catch { + appLog("[TranscriptionVM] Failed to load history: \(error)") + return [] + } + } + + /// Search persistent history + func searchPersistentHistory(query: String) -> [TranslationEntry] { + do { + return try historyService.searchTranslations(query: query) + } catch { + appLog("[TranscriptionVM] Failed to search history: \(error)") + return [] + } + } + + /// Delete a single history entry from SQLite + func deletePersistentEntry(_ entry: TranslationEntry) { + do { + try historyService.deleteTranslation(withId: entry.id) + } catch { + appLog("[TranscriptionVM] Failed to delete entry: \(error)") + } + } + + /// Export history to CSV string + func exportHistoryCSV() -> String? { + do { + return try historyService.exportToCSV() + } catch { + appLog("[TranscriptionVM] Failed to export: \(error)") + return nil + } + } + + /// Update settings + func updateSettings(_ newSettings: AppSettings) { + let oldSettings = self.settings + self.settings = newSettings + let didSaveSettings = newSettings.save() + let saveError = didSaveSettings ? nil : AppSettings.consumeLastPersistenceError() + + // Update services + audioCaptureService.config = AudioCaptureConfig( + captureSystemAudio: newSettings.captureSystemAudio, + captureMicrophone: newSettings.captureMicrophone + ) + ollamaService.updateSettings(newSettings) + caption.maxContextEntries = newSettings.maxContextEntries + trimSegmentsIfNeeded() + + // Update speech recognizer language if changed + if speechAnalyzerService.currentLanguage != newSettings.sourceLanguage { + speechAnalyzerService.setLanguage(newSettings.sourceLanguage) + } + + // Notify user if a restart is needed for certain settings + if isCapturing { + let needsRestart = oldSettings.captureSystemAudio != newSettings.captureSystemAudio + || oldSettings.captureMicrophone != newSettings.captureMicrophone + || oldSettings.sourceLanguage != newSettings.sourceLanguage + || oldSettings.ollamaModel != newSettings.ollamaModel + if needsRestart { + notice = .warning("Some settings need a restart — click Stop then Start to apply.") + } + } + + if !didSaveSettings { + notice = .warning( + saveError ?? "Settings save failed. Changes are active this session but may not persist.", + autoDismiss: false + ) + } + + // Unload old model if model changed + if oldSettings.ollamaModel != newSettings.ollamaModel { + Task { + try? await ollamaService.unloadModel() + } + } + } + + // MARK: - Transcription Handling + + /// Handle a new transcription result from speech recognizer + private func handleTranscriptionResult(_ result: TranscriptionResult) { + if activeTranscriptionTaskId != result.id { + activeTranscriptionTaskId = result.id + // Segments from a finished ASR task can no longer be rolled back + activeTaskSegmentIds.removeAll() + } + + let (newlyFinalized, draft, invalidatedTailCount) = captionSegmenter.process(result: result) + liveSourceText = draft + + if invalidatedTailCount > 0 { + rollbackTailSegments(count: invalidatedTailCount) + caption.updateOriginal(draft) + } + + for text in newlyFinalized { + let newSegment = TranslationSegment(sourceText: text, state: isPaused ? .pending : .translating) + activeTaskSegmentIds.append(newSegment.id) + segments.append(newSegment) + trimSegmentsIfNeeded() + + caption.updateOriginal(text) + + if !isPaused { + let context = settings.contextAware ? caption.getContextForTranslation() : [] + translationQueue.enqueue( + segmentId: newSegment.id, + text: text, + context: context, + priority: .high, + isFinal: true + ) + } + } + + updateLiveDraftTranslation(draft: draft, didFinalize: !newlyFinalized.isEmpty) + } + + /// Translate the in-progress draft for lower perceived latency. The result is + /// volatile (debounced, preemptible by final segments) and shown only in the + /// live area — it never enters `segments`, history, or translation context. + private func updateLiveDraftTranslation(draft: String, didFinalize: Bool) { + // A finalized cut means the previous draft's translation is now stale: + // its text became a real segment that gets its own final translation. + if didFinalize { + liveTranslation = "" + } + + guard !isPaused, settings.liveDraftTranslation else { + liveTranslation = "" + return + } + + if draft.isEmpty { + liveTranslation = "" + return + } + + let context = settings.contextAware ? caption.getContextForTranslation() : [] + translationQueue.enqueue( + segmentId: liveDraftSegmentId, + text: draft, + context: context, + priority: .normal, // below .high finals so a finalized sentence preempts the draft + isFinal: false + ) + } + + // MARK: - Translation Handling + + /// Handle translation result from queue + private func handleTranslationResult(_ result: TranslationQueueResult) { + let cleanedText = TextUtils.cleanTranslationOutput(result.translatedText) + + // Draft translations are transient: update the live area and stop. They + // must not touch segments, history, or translation context. + if result.segmentId == liveDraftSegmentId { + if result.success { + liveTranslation = cleanedText + lastLatencyMs = result.latencyMs + } + return + } + + let idx = segments.firstIndex(where: { $0.id == result.segmentId }) + let sourceText = idx.map { segments[$0].sourceText } ?? result.originalText + + if let idx { + segments[idx].latencyMs = result.latencyMs + } + + if result.success { + if let idx { + segments[idx].translatedText = cleanedText + segments[idx].state = .translated + } + + // Log history + let entry = TranslationEntry( + sourceText: sourceText, + translatedText: cleanedText, + speaker: nil, + targetLanguage: settings.targetLanguage.displayName, + latencyMs: result.latencyMs + ) + translationHistory.append(entry) + if activeTaskSegmentIds.contains(result.segmentId) { + historyEntryIdsBySegmentId[result.segmentId] = entry.id + } + caption.addToContext(entry) + + Task { try? await historyService.logTranslationAsync(entry) } + Task { + try? await historyService.pruneHistoryAsync( + retentionDays: settings.historyRetentionDays, + maxEntries: settings.historyMaxEntries + ) + } + + // Keep history limited + if translationHistory.count > 100 { + translationHistory.removeFirst() + } + } else { + if let idx { + segments[idx].state = .failed + segments[idx].translatedText = "Error: \(cleanedText)" + } + } + + lastLatencyMs = result.latencyMs + caption.updateTranslation(cleanedText) + } + + private func trimSegmentsIfNeeded() { + let maxCards = max(settings.maxDisplayCards, 1) + guard segments.count > maxCards else { return } + segments.removeFirst(segments.count - maxCards) + } + + /// Revoke the most recent `count` segments of the active ASR task after the + /// recognizer revised text they were cut from. Earlier segments survive. + private func rollbackTailSegments(count: Int) { + let staleSegmentIds = Set(activeTaskSegmentIds.suffix(count)) + activeTaskSegmentIds.removeLast(min(count, activeTaskSegmentIds.count)) + + guard !staleSegmentIds.isEmpty else { return } + + appLog("[TranscriptionVM] ASR rollback detected; revoking \(staleSegmentIds.count) stale tail segment(s)") + translationQueue.cancel(segmentIds: staleSegmentIds) + segments.removeAll { staleSegmentIds.contains($0.id) } + + let staleHistoryIds = Set(staleSegmentIds.compactMap { historyEntryIdsBySegmentId[$0] }) + if !staleHistoryIds.isEmpty { + translationHistory.removeAll { staleHistoryIds.contains($0.id) } + caption.removeContextEntries(withIds: staleHistoryIds) + + for entryId in staleHistoryIds { + Task { + try? await historyService.deleteTranslationAsync(withId: entryId) + } + } + } + + for segmentId in staleSegmentIds { + historyEntryIdsBySegmentId.removeValue(forKey: segmentId) + } + } + + private func clearTransientSegmentBookkeeping() { + activeTranscriptionTaskId = nil + activeTaskSegmentIds.removeAll() + historyEntryIdsBySegmentId.removeAll() + } + + private func isModelInstalled(_ modelName: String, in availableModels: [String]) -> Bool { + if availableModels.contains(modelName) { + return true + } + + guard !modelName.contains(":") else { + return false + } + + return availableModels.contains { $0.hasPrefix("\(modelName):") } + } + + /// Copy current translation to clipboard + func copyToClipboard() { + guard let last = segments.last else { return } + let text = "\(last.sourceText)\n\(last.translatedText)" + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } +} diff --git a/macos/LCTMac/Views/HistoryView.swift b/macos/LCTMac/Views/HistoryView.swift new file mode 100644 index 0000000..96ea67e --- /dev/null +++ b/macos/LCTMac/Views/HistoryView.swift @@ -0,0 +1,250 @@ +import SwiftUI + +/// History view displaying past translations from SQLite persistence +struct HistoryView: View { + @ObservedObject var viewModel: TranscriptionViewModel + + @Environment(\.dismiss) private var dismiss + @State private var searchText = "" + @State private var selectedEntry: TranslationEntry? + @State private var persistentEntries: [TranslationEntry] = [] + @State private var isLoading = true + @State private var showDeleteConfirmation = false + + private var displayEntries: [TranslationEntry] { + if searchText.isEmpty { + return persistentEntries + } + let lowercasedSearch = searchText.lowercased() + return persistentEntries.filter { + $0.sourceText.lowercased().contains(lowercasedSearch) || + $0.translatedText.lowercased().contains(lowercasedSearch) + } + } + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Translation History") + .font(.title2) + .fontWeight(.semibold) + + Spacer() + + Text("\(persistentEntries.count) entries") + .foregroundStyle(.secondary) + + Button("Done") { + dismiss() + } + .buttonStyle(.borderedProminent) + } + .padding() + + Divider() + + // Search bar + HStack { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + + TextField("Search...", text: $searchText) + .textFieldStyle(.plain) + + if !searchText.isEmpty { + Button(action: { searchText = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(8) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(8) + .padding() + + Divider() + + // Entry list + if isLoading { + VStack(spacing: 12) { + ProgressView() + Text("Loading history...") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if displayEntries.isEmpty { + emptyState + } else { + List(displayEntries, selection: $selectedEntry) { entry in + HistoryEntryRow(entry: entry) + .tag(entry) + .contextMenu { + Button("Copy") { copyEntry(entry) } + Button("Delete", role: .destructive) { + deleteEntry(entry) + } + } + } + .listStyle(.inset) + } + + Divider() + + // Footer with export and clear buttons + HStack { + Button(action: exportToCSV) { + Label("Export CSV", systemImage: "square.and.arrow.up") + } + .disabled(persistentEntries.isEmpty) + + Button(role: .destructive, action: { showDeleteConfirmation = true }) { + Label("Clear All", systemImage: "trash") + } + .disabled(persistentEntries.isEmpty) + + Spacer() + + if let selected = selectedEntry { + Button(action: { copyEntry(selected) }) { + Label("Copy Selected", systemImage: "doc.on.doc") + } + + Button(role: .destructive, action: { deleteEntry(selected) }) { + Label("Delete", systemImage: "trash") + } + } + } + .padding() + } + .frame(width: 650, height: 550) + .task { + await loadHistory() + } + .alert("Clear All History?", isPresented: $showDeleteConfirmation) { + Button("Cancel", role: .cancel) {} + Button("Clear All", role: .destructive) { + viewModel.clearPersistentHistory() + persistentEntries.removeAll() + } + } message: { + Text("This will permanently delete all translation history. This action cannot be undone.") + } + } + + private var emptyState: some View { + VStack(spacing: 12) { + Image(systemName: "clock.arrow.circlepath") + .font(.system(size: 40)) + .foregroundStyle(.secondary) + + Text(searchText.isEmpty ? "No history yet" : "No results found") + .font(.headline) + .foregroundStyle(.secondary) + + Text(searchText.isEmpty ? "Translations will appear here" : "Try a different search term") + .font(.caption) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func loadHistory() async { + isLoading = true + persistentEntries = await viewModel.loadPersistentHistory(limit: 500) + isLoading = false + } + + private func deleteEntry(_ entry: TranslationEntry) { + viewModel.deletePersistentEntry(entry) + persistentEntries.removeAll { $0.id == entry.id } + if selectedEntry?.id == entry.id { + selectedEntry = nil + } + } + + private func exportToCSV() { + guard let csv = viewModel.exportHistoryCSV() else { return } + + let panel = NSSavePanel() + panel.allowedContentTypes = [.commaSeparatedText] + panel.nameFieldStringValue = "lct_history.csv" + + if panel.runModal() == .OK, let url = panel.url { + do { + try csv.write(to: url, atomically: true, encoding: .utf8) + } catch { + print("Failed to save CSV: \(error)") + } + } + } + + private func copyEntry(_ entry: TranslationEntry) { + let text = """ + Source: \(entry.sourceText) + Translation: \(entry.translatedText) + Language: \(entry.targetLanguage) + Time: \(entry.formattedTimestamp) + """ + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } +} + +/// Individual history entry row +struct HistoryEntryRow: View { + let entry: TranslationEntry + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Header + HStack { + if let speaker = entry.speaker { + Label(speaker, systemImage: "person.fill") + .font(.caption) + .foregroundStyle(.blue) + } + + Spacer() + + Text(entry.formattedTimestamp) + .font(.caption2) + .foregroundStyle(.tertiary) + + Text(entry.formattedLatency) + .font(.caption2) + .foregroundStyle(.secondary) + } + + // Source text + Text(entry.sourceText) + .font(.body) + .foregroundStyle(.primary) + .lineLimit(2) + + // Translated text + Text(entry.translatedText) + .font(.body) + .foregroundStyle(.blue) + .lineLimit(2) + + // Language badge + HStack { + Text(entry.targetLanguage) + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule() + .fill(Color.blue.opacity(0.1)) + ) + + Spacer() + } + } + .padding(.vertical, 4) + } +} diff --git a/macos/LCTMac/Views/MainView.swift b/macos/LCTMac/Views/MainView.swift new file mode 100644 index 0000000..1e77a67 --- /dev/null +++ b/macos/LCTMac/Views/MainView.swift @@ -0,0 +1,630 @@ +import SwiftUI +import Combine + +/// Terminal-HUD theme tokens for the main window +enum HUD { + static let accent = Color(red: 0.20, green: 0.83, blue: 0.60) + static let background = Color(red: 0.078, green: 0.078, blue: 0.086) + static let surface = Color.white.opacity(0.04) + static let hairline = Color.white.opacity(0.08) + + static func mono(_ style: Font.TextStyle) -> Font { + .system(style, design: .monospaced) + } +} + +/// Main application view with transcription and translation display +struct MainView: View { + @StateObject private var viewModel = TranscriptionViewModel() + @StateObject private var overlayController = OverlayWindowController() + @State private var showSettings = false + @State private var showHistory = false + @State private var autoScroll = true + + var body: some View { + VStack(spacing: 0) { + hudBar + Rectangle().fill(HUD.hairline).frame(height: 1) + + transcriptFeed + + Rectangle().fill(HUD.hairline).frame(height: 1) + bottomBar + } + .frame(minWidth: 480, minHeight: 400) + .background(HUD.background) + .preferredColorScheme(.dark) + .sheet(isPresented: $showSettings) { + SettingsView(settings: $viewModel.settings) { newSettings in + viewModel.updateSettings(newSettings) + } + } + .sheet(isPresented: $showHistory) { + HistoryView(viewModel: viewModel) + } + .overlay(alignment: .top) { + errorBanner + } + .animation(.easeInOut(duration: 0.3), value: viewModel.notice) + .onReceive(NotificationCenter.default.publisher(for: .toggleCapture)) { notification in + Task { + if let shouldStart = notification.object as? Bool { + if shouldStart { + await viewModel.start() + } else { + await viewModel.stop() + } + } else { + // Toggle + if viewModel.isCapturing { + await viewModel.stop() + } else { + await viewModel.start() + } + } + } + } + .onReceive(NotificationCenter.default.publisher(for: .togglePause)) { _ in + viewModel.togglePause() + } + .onReceive(NotificationCenter.default.publisher(for: .copyTranslation)) { _ in + viewModel.copyToClipboard() + } + .onReceive(NotificationCenter.default.publisher(for: .toggleOverlay)) { _ in + overlayController.toggle(with: viewModel) + } + .onReceive(NotificationCenter.default.publisher(for: .showHistory)) { _ in + showHistory = true + } + } + + // MARK: - HUD Bar (always visible) + + private var hudBar: some View { + HStack(spacing: 16) { + // Recording status + elapsed time + HStack(spacing: 6) { + Circle() + .fill(viewModel.isCapturing ? (viewModel.isPaused ? Color.orange : HUD.accent) : Color.secondary.opacity(0.5)) + .frame(width: 7, height: 7) + + if viewModel.isCapturing, let startedAt = viewModel.captureStartedAt { + TimelineView(.periodic(from: .now, by: 1)) { timeline in + Text(viewModel.isPaused ? "paused" : "rec \(elapsedString(since: startedAt, now: timeline.date))") + .font(HUD.mono(.caption)) + .foregroundStyle(viewModel.isPaused ? Color.orange : .secondary) + } + } else { + Text("idle") + .font(HUD.mono(.caption)) + .foregroundStyle(.secondary) + } + } + + Spacer() + + // Language direction + model identity + latency + HStack(spacing: 8) { + Text("\(viewModel.settings.sourceLanguage.isoCode.uppercased()) → \(viewModel.settings.targetLanguage.isoCode.uppercased())") + .font(HUD.mono(.caption)) + .foregroundStyle(.secondary) + + OllamaStatusIndicator(isConnected: viewModel.isOllamaConnected) + + Text(viewModel.settings.ollamaModel) + .font(HUD.mono(.caption)) + .foregroundStyle(.secondary) + + if viewModel.lastLatencyMs > 0 { + Text("· \(viewModel.lastLatencyMs) ms") + .font(HUD.mono(.caption)) + .foregroundStyle(viewModel.isTranslating ? HUD.accent : Color.secondary.opacity(0.6)) + } + } + + Spacer() + + // Toolbar buttons + HStack(spacing: 14) { + Button(action: { autoScroll.toggle() }) { + Image(systemName: autoScroll ? "arrow.down.circle.fill" : "arrow.down.circle") + .foregroundStyle(autoScroll ? HUD.accent : .secondary) + } + .help(autoScroll ? "Auto-scroll ON" : "Auto-scroll OFF") + + Button(action: { showHistory = true }) { + Image(systemName: "clock.arrow.circlepath") + .foregroundStyle(.secondary) + } + .help("History (⇧⌘H)") + + Button(action: { showSettings = true }) { + Image(systemName: "gear") + .foregroundStyle(.secondary) + } + .help("Settings") + } + .buttonStyle(.borderless) + } + .padding(.leading, 78) // Clear the traffic-light buttons (hidden title bar) + .padding(.trailing, 16) + .padding(.vertical, 10) + } + + // MARK: - Transcript Feed + + private var transcriptFeed: some View { + ScrollViewReader { proxy in + ZStack(alignment: .bottomTrailing) { + ScrollView { + LazyVStack(alignment: .leading, spacing: 18) { + if viewModel.segments.isEmpty && viewModel.liveSourceText.isEmpty { + emptyState + } else { + ForEach(viewModel.segments) { segment in + TranscriptSegmentRow(segment: segment) + .id(segment.id) + } + } + + if !viewModel.liveSourceText.isEmpty { + liveDraftLine + .id("liveDraft") + } + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + } + .onChange(of: viewModel.segments.count) { _, _ in + if autoScroll { + scrollToBottom(proxy: proxy) + } + } + .onChange(of: viewModel.liveSourceText) { _, _ in + if autoScroll { + scrollToBottom(proxy: proxy) + } + } + .onChange(of: viewModel.liveTranslation) { _, _ in + if autoScroll { + scrollToBottom(proxy: proxy) + } + } + + if !autoScroll { + Button(action: { + autoScroll = true + scrollToBottom(proxy: proxy) + }) { + HStack(spacing: 6) { + Image(systemName: "arrow.down.to.line") + Text("latest") + .font(HUD.mono(.caption)) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(HUD.surface) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(HUD.hairline, lineWidth: 1)) + ) + } + .buttonStyle(.plain) + .padding(12) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var emptyState: some View { + VStack(alignment: .leading, spacing: 6) { + Text("// no transcriptions yet") + .font(HUD.mono(.body)) + .foregroundStyle(.secondary) + Text("// press ⌘space or hit start to begin capturing") + .font(HUD.mono(.body)) + .foregroundStyle(.tertiary) + } + .padding(.top, 24) + } + + private var liveDraftLine: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text("❯") + .font(HUD.mono(.body)) + .foregroundStyle(HUD.accent) + + Text(viewModel.liveSourceText) + .font(.callout) + .foregroundStyle(.secondary) + + if viewModel.liveTranslation.isEmpty { + BlinkingCursor() + } + } + + if !viewModel.liveTranslation.isEmpty { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text("↳") + .font(HUD.mono(.body)) + .foregroundStyle(HUD.accent.opacity(0.7)) + + Text(viewModel.liveTranslation) + .font(.body.weight(.medium)) + .foregroundStyle(.primary.opacity(0.85)) + + BlinkingCursor() + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(HUD.surface) + ) + } + + // MARK: - Bottom Bar (always visible) + + private var bottomBar: some View { + HStack(spacing: 16) { + AudioBarsView(level: viewModel.audioLevel, isActive: viewModel.isCapturing) + + Text("⌘␣ start · ⌘P pause · ⇧⌘C copy · ⌘O overlay") + .font(HUD.mono(.caption2)) + .foregroundStyle(.tertiary) + .lineLimit(1) + + Spacer() + + HStack(spacing: 10) { + Button(action: { viewModel.clear() }) { + Image(systemName: "trash") + } + .buttonStyle(.bordered) + .disabled(viewModel.isCapturing) + .help("Clear transcript") + + Button(action: { viewModel.togglePause() }) { + Image(systemName: viewModel.isPaused ? "play.fill" : "pause.fill") + .frame(width: 16) + } + .buttonStyle(.bordered) + .disabled(!viewModel.isCapturing) + .help(viewModel.isPaused ? "Resume (⌘P)" : "Pause (⌘P)") + + Button(action: { + Task { + if viewModel.isCapturing { + await viewModel.stop() + } else { + await viewModel.start() + } + } + }) { + Label( + viewModel.isCapturing ? "stop" : "start", + systemImage: viewModel.isCapturing ? "stop.fill" : "play.fill" + ) + .font(HUD.mono(.body)) + .frame(minWidth: 56) + } + .buttonStyle(.borderedProminent) + .tint(viewModel.isCapturing ? .red : HUD.accent) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + // MARK: - Error Banner + + @ViewBuilder + private var errorBanner: some View { + if let notice = viewModel.notice { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: noticeIcon(notice.severity)) + .foregroundStyle(noticeColor(notice.severity)) + + Text(notice.message) + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 8) + + Button(action: { viewModel.dismissNotice() }) { + Image(systemName: "xmark") + .font(.caption) + } + .buttonStyle(.plain) + } + + if !notice.actions.isEmpty { + HStack(spacing: 8) { + Spacer() + ForEach(notice.actions) { action in + Button(action.label) { + handleNoticeAction(action) + } + .controlSize(.small) + .buttonStyle(.bordered) + } + } + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(.regularMaterial) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(noticeColor(notice.severity).opacity(0.5), lineWidth: 1) + ) + ) + .padding(.horizontal, 16) + .padding(.top, 44) + .transition(.move(edge: .top).combined(with: .opacity)) + .onAppear { + guard notice.autoDismiss else { return } + let id = notice.id + Task { + try? await Task.sleep(nanoseconds: 8_000_000_000) + await MainActor.run { + if viewModel.notice?.id == id { + withAnimation { viewModel.dismissNotice() } + } + } + } + } + } + } + + private func handleNoticeAction(_ action: NoticeAction) { + if action == .openAppSettings { + viewModel.dismissNotice() + showSettings = true + } else { + viewModel.perform(action) + } + } + + private func noticeIcon(_ severity: NoticeSeverity) -> String { + switch severity { + case .info: return "info.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .error: return "xmark.circle.fill" + } + } + + private func noticeColor(_ severity: NoticeSeverity) -> Color { + switch severity { + case .info: return HUD.accent + case .warning: return .orange + case .error: return .red + } + } + + // MARK: - Helpers + + private func scrollToBottom(proxy: ScrollViewProxy) { + withAnimation { + if !viewModel.liveSourceText.isEmpty { + proxy.scrollTo("liveDraft", anchor: .bottom) + } else if let last = viewModel.segments.last { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } + + private func elapsedString(since start: Date, now: Date) -> String { + let seconds = max(0, Int(now.timeIntervalSince(start))) + let h = seconds / 3600 + let m = (seconds % 3600) / 60 + let s = seconds % 60 + return h > 0 + ? String(format: "%02d:%02d:%02d", h, m, s) + : String(format: "%02d:%02d", m, s) + } +} + +// MARK: - Transcript Segment Row + +struct TranscriptSegmentRow: View { + let segment: TranslationSegment + + private static let timeFormat = Date.FormatStyle(date: .omitted, time: .standard) + + var body: some View { + HStack(alignment: .top, spacing: 0) { + Text(segment.timestamp.formatted(Self.timeFormat)) + .font(HUD.mono(.caption2)) + .foregroundStyle(.tertiary) + .frame(width: 64, alignment: .leading) + .padding(.top, 3) + + VStack(alignment: .leading, spacing: 5) { + if !segment.sourceText.isEmpty { + Text(segment.sourceText) + .font(.footnote) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + + if !segment.translatedText.isEmpty && segment.state != .failed { + HStack(alignment: .lastTextBaseline, spacing: 4) { + Text(segment.translatedText) + .font(.body.weight(.medium)) + .foregroundStyle(.primary) + .textSelection(.enabled) + + if segment.state == .translating { + BlinkingCursor() + } + } + } + + statusLine + } + .padding(.leading, 14) + .overlay(alignment: .leading) { + Rectangle() + .fill(borderColor) + .frame(width: 2) + } + + Spacer(minLength: 0) + } + } + + @ViewBuilder + private var statusLine: some View { + switch segment.state { + case .translating where segment.translatedText.isEmpty: + Text("streaming…") + .font(HUD.mono(.caption2)) + .foregroundStyle(HUD.accent.opacity(0.7)) + case .pending: + Text("paused — translates on resume") + .font(HUD.mono(.caption2)) + .foregroundStyle(.orange) + case .failed: + Text(segment.translatedText) + .font(HUD.mono(.caption2)) + .foregroundStyle(.red) + default: + EmptyView() + } + } + + private var borderColor: Color { + switch segment.state { + case .translating: return HUD.accent + case .pending: return .orange + case .failed: return .red + default: return Color.white.opacity(0.12) + } + } +} + +// MARK: - Blinking Cursor + +struct BlinkingCursor: View { + @State private var isOn = false + + var body: some View { + Rectangle() + .fill(HUD.accent) + .frame(width: 7, height: 14) + .opacity(isOn ? 0.9 : 0.15) + .onAppear { + withAnimation(.easeInOut(duration: 0.5).repeatForever()) { + isOn = true + } + } + } +} + +// MARK: - Audio Bars View + +struct AudioBarsView: View { + let level: Float + let isActive: Bool + + private static let barHeights: [CGFloat] = [5, 9, 13, 7, 11, 15, 8, 12, 6, 10, 14, 7] + + var body: some View { + HStack(alignment: .center, spacing: 3) { + ForEach(Self.barHeights.indices, id: \.self) { index in + RoundedRectangle(cornerRadius: 1) + .fill(barColor(index: index)) + .frame(width: 3, height: Self.barHeights[index]) + } + } + .frame(height: 16) + .animation(.linear(duration: 0.1), value: level) + } + + private func barColor(index: Int) -> Color { + guard isActive else { return Color.white.opacity(0.1) } + let threshold = Float(index + 1) / Float(Self.barHeights.count) + return level >= threshold ? HUD.accent : Color.white.opacity(0.12) + } +} + +// MARK: - Ollama Status Indicator + +struct OllamaStatusIndicator: View { + let isConnected: Bool + @State private var isHovering = false + @StateObject private var guardian = OllamaGuardian.shared + + var body: some View { + Button(action: { + if !isConnected { + Task { + try? await guardian.ensureRunning() + } + } + }) { + HStack(spacing: 6) { + Circle() + .fill(statusColor) + .frame(width: 7, height: 7) + + if guardian.status != .running && !isConnected { + Text(statusText) + .font(HUD.mono(.caption)) + .foregroundStyle(.secondary) + } + + if guardian.isChecking { + ProgressView() + .scaleEffect(0.5) + .frame(width: 12, height: 12) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isHovering ? Color.gray.opacity(0.15) : Color.clear) + ) + } + .buttonStyle(.plain) + .onHover { hovering in + isHovering = hovering + } + .help((guardian.status == .running || isConnected) ? "Ollama is running" : "Click to start Ollama") + } + + private var statusColor: Color { + if guardian.isChecking { + return .yellow + } + return (guardian.status == .running || isConnected) ? HUD.accent : .red + } + + private var statusText: String { + if guardian.isChecking { + return "checking…" + } + + switch guardian.status { + case .running: + return "ollama up" + case .starting: + return "starting…" + case .notInstalled: + return "ollama not installed" + case .installed, .stopped: + return "ollama stopped" + case .error(let message): + return "error: \(message)" + } + } +} diff --git a/macos/LCTMac/Views/OverlayView.swift b/macos/LCTMac/Views/OverlayView.swift new file mode 100644 index 0000000..3407cfa --- /dev/null +++ b/macos/LCTMac/Views/OverlayView.swift @@ -0,0 +1,388 @@ +import SwiftUI +import AppKit +import Foundation + +/// Overlay window for floating transcription display +struct OverlayView: View { + @ObservedObject var viewModel: TranscriptionViewModel + @State private var isHovering = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Header (visible on hover) + if isHovering { + overlayHeader + } + + // Last Segment + if let last = viewModel.segments.last, !last.sourceText.isEmpty { + OverlayTranslationPair( + original: last.sourceText, + translated: last.translatedText, + fontSize: viewModel.settings.overlayFontSize, + isOld: !viewModel.liveSourceText.isEmpty + ) + } + + // Live Draft + if !viewModel.liveSourceText.isEmpty { + OverlayTranslationPair( + original: viewModel.liveSourceText, + translated: "", + fontSize: viewModel.settings.overlayFontSize, + isOld: false + ) + } + + // Status and Latency + HStack { + if !viewModel.liveSourceText.isEmpty { + statusBadge(text: "Listening", color: .green) + } else if let last = viewModel.segments.last, last.state == .translating { + statusBadge(text: "Translating", color: .yellow, isTranslating: true) + } else if let last = viewModel.segments.last, last.state == .failed { + statusBadge(text: "Error", color: .red) + } else if viewModel.isPaused { + statusBadge(text: "Paused", color: .orange) + } + + Spacer() + + if viewModel.settings.showLatency && viewModel.lastLatencyMs > 0 { + Text("\(viewModel.lastLatencyMs) ms") + .font(.caption2) + .foregroundStyle(.white.opacity(0.6)) + } + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(.ultraThinMaterial) + .opacity(viewModel.settings.overlayOpacity) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.2), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.3), radius: 10, y: 5) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + isHovering = hovering + } + } + } + + private var overlayHeader: some View { + HStack { + // Status indicator + Circle() + .fill(viewModel.isCapturing ? .green : .red) + .frame(width: 6, height: 6) + + Text(viewModel.isCapturing ? "Listening" : "Stopped") + .font(.caption2) + .foregroundStyle(.white.opacity(0.8)) + + Spacer() + + // Control buttons + HStack(spacing: 8) { + Button(action: { viewModel.togglePause() }) { + Image(systemName: viewModel.isPaused ? "play.fill" : "pause.fill") + .font(.caption) + } + .buttonStyle(.plain) + .foregroundStyle(.white.opacity(0.8)) + + Button(action: { viewModel.copyToClipboard() }) { + Image(systemName: "doc.on.doc") + .font(.caption) + } + .buttonStyle(.plain) + .foregroundStyle(.white.opacity(0.8)) + } + } + .padding(.bottom, 4) + } + + private func statusBadge(text: String, color: Color, isTranslating: Bool = false) -> some View { + HStack(spacing: 4) { + Circle() + .fill(color) + .frame(width: 6, height: 6) + Text(text) + .font(.caption2) + .foregroundStyle(.white.opacity(0.8)) + if isTranslating { + OverlayTypingDots() + } + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.black.opacity(0.4)) + .cornerRadius(4) + } +} + +/// Translation pair in overlay +struct OverlayTranslationPair: View { + let original: String + let translated: String + let fontSize: Double + let isOld: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + if !original.isEmpty { + Text(original) + .font(.system(size: fontSize - 1)) + .foregroundStyle(.white.opacity(isOld ? 0.4 : 0.6)) + .lineLimit(2) + } + + if !translated.isEmpty { + Text(translated) + .font(.system(size: fontSize, weight: .medium)) + .foregroundStyle(isOld ? .white.opacity(0.7) : .white) + .lineLimit(3) + } + } + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.black.opacity(isOld ? 0.2 : 0.4)) + ) + } +} + +/// Typing dots animation for overlay +struct OverlayTypingDots: View { + @State private var dotIndex = 0 + @State private var timer: Timer? + + var body: some View { + HStack(spacing: 2) { + ForEach(0..<3, id: \.self) { index in + Circle() + .fill(Color.cyan) + .frame(width: 3, height: 3) + .opacity(dotIndex == index ? 1.0 : 0.3) + } + } + .onAppear { + timer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [self] _ in + Task { @MainActor in + dotIndex = (dotIndex + 1) % 3 + } + } + } + .onDisappear { + timer?.invalidate() + timer = nil + } + } +} + +// MARK: - Overlay Window Controller + +/// Controller for managing the overlay window +@MainActor +class OverlayWindowController: NSObject, ObservableObject { + private var overlayWindow: NSPanel? + private var viewModel: TranscriptionViewModel? + private var dragStartLocation: NSPoint? + private var resizeStartLocation: NSPoint? + private var startFrame: NSRect? + + @Published var isVisible: Bool = false + + /// Show the overlay window + func show(with viewModel: TranscriptionViewModel) { + self.viewModel = viewModel + + if overlayWindow == nil { + createWindow() + } + + overlayWindow?.orderFront(nil) + isVisible = true + } + + /// Hide the overlay window + func hide() { + saveWindowPosition() + overlayWindow?.orderOut(nil) + isVisible = false + } + + /// Toggle overlay visibility + func toggle(with viewModel: TranscriptionViewModel) { + if isVisible { + hide() + } else { + show(with: viewModel) + } + } + + private func createWindow() { + guard let viewModel = viewModel else { return } + + // Create window + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: viewModel.settings.overlayWidth, height: viewModel.settings.overlayHeight), + styleMask: [.nonactivatingPanel, .resizable], + backing: .buffered, + defer: false + ) + + // Configure window + window.isFloatingPanel = true + window.level = .floating + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.backgroundColor = .clear + window.isOpaque = false + window.hasShadow = true + window.isMovable = true + window.acceptsMouseMovedEvents = true + + // Set click-through + updateClickThrough(window) + + // Set content + let overlayView = OverlayView(viewModel: viewModel) + window.contentView = NSHostingView(rootView: overlayView) + + // Position window + restoreWindowPosition(window) + + // Track window movements + setupWindowTracking(window) + + self.overlayWindow = window + } + + /// Update click-through setting + private func updateClickThrough(_ window: NSPanel) { + guard let viewModel = viewModel else { return } + window.ignoresMouseEvents = viewModel.settings.overlayClickThrough + } + + /// Restore saved window position + private func restoreWindowPosition(_ window: NSPanel) { + guard let viewModel = viewModel else { return } + let settings = viewModel.settings + + // Use saved position if available and valid + if settings.overlayPositionX != 0.0 && settings.overlayPositionY != 0.0 { + if NSScreen.screens.contains(where: { screen in + screen.visibleFrame.contains(NSPoint(x: settings.overlayPositionX, y: settings.overlayPositionY)) + }) { + window.setFrameOrigin(NSPoint(x: settings.overlayPositionX, y: settings.overlayPositionY)) + return + } + } + + // Default position: top right corner + if let screen = NSScreen.main { + let screenRect = screen.visibleFrame + let windowRect = window.frame + let x = screenRect.maxX - windowRect.width - 20 + let y = screenRect.maxY - windowRect.height - 20 + window.setFrameOrigin(NSPoint(x: x, y: y)) + } + } + + /// Save current window position + @MainActor + private func saveWindowPosition() { + guard let window = overlayWindow, + let viewModel = viewModel else { return } + + let frame = window.frame + viewModel.settings.overlayPositionX = frame.origin.x + viewModel.settings.overlayPositionY = frame.origin.y + viewModel.settings.overlayWidth = frame.size.width + viewModel.settings.overlayHeight = frame.size.height + viewModel.settings.save() + } + + /// Setup window tracking for drag and resize + private func setupWindowTracking(_ window: NSPanel) { + // Track window moves + let moveObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didMoveNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.saveWindowPosition() + } + } + + // Track window resizes + let resizeObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResizeNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.saveWindowPosition() + } + } + + // Store observers (need to be released properly in production) + objc_setAssociatedObject(window, "moveObserver", moveObserver, .OBJC_ASSOCIATION_RETAIN) + objc_setAssociatedObject(window, "resizeObserver", resizeObserver, .OBJC_ASSOCIATION_RETAIN) + } + + /// Update window position + func setPosition(_ position: NSPoint) { + overlayWindow?.setFrameOrigin(position) + } + + /// Update window size + func setSize(_ size: NSSize) { + overlayWindow?.setContentSize(size) + saveWindowPosition() + } + + /// Reload overlay settings (call when settings change) + func reloadSettings() { + guard let window = overlayWindow, + let viewModel = viewModel else { return } + + // Update click-through + updateClickThrough(window) + + // Update window level + window.level = viewModel.settings.overlayStayOnTop ? .floating : .normal + + // Update position if settings changed + let currentFrame = window.frame + if currentFrame.size.width != viewModel.settings.overlayWidth || + currentFrame.size.height != viewModel.settings.overlayHeight { + window.setContentSize(NSSize(width: viewModel.settings.overlayWidth, height: viewModel.settings.overlayHeight)) + } + } + + /// Close and release the window + func close() { + saveWindowPosition() + + // Remove observers + if let moveObserver = objc_getAssociatedObject(overlayWindow as Any, "moveObserver") { + NotificationCenter.default.removeObserver(moveObserver) + } + if let resizeObserver = objc_getAssociatedObject(overlayWindow as Any, "resizeObserver") { + NotificationCenter.default.removeObserver(resizeObserver) + } + + overlayWindow?.close() + overlayWindow = nil + isVisible = false + } +} diff --git a/macos/LCTMac/Views/SettingsView.swift b/macos/LCTMac/Views/SettingsView.swift new file mode 100644 index 0000000..a728f08 --- /dev/null +++ b/macos/LCTMac/Views/SettingsView.swift @@ -0,0 +1,508 @@ +import SwiftUI +import Speech +import AVFoundation + +/// Settings view for configuring the application +struct SettingsView: View { + @Binding var settings: AppSettings + let onSave: (AppSettings) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var localSettings: AppSettings + @State private var showingPromptEditor = false + @State private var installedModels: [String] = [] + @State private var modelListError: String? + @State private var isLoadingModels = false + @State private var diagnosticsMessage: String? + + init(settings: Binding, onSave: @escaping (AppSettings) -> Void) { + self._settings = settings + self.onSave = onSave + self._localSettings = State(initialValue: settings.wrappedValue) + } + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Settings") + .font(.title2) + .fontWeight(.semibold) + + Spacer() + + Button("Done") { + onSave(localSettings) + dismiss() + } + .buttonStyle(.borderedProminent) + } + .padding() + + Divider() + + // Settings form + Form { + basicSections + advancedSections + diagnosticsSection + } + .formStyle(.grouped) + + Divider() + + // Footer with reset button + HStack { + Button("Reset to Defaults") { + localSettings = AppSettings() + } + .foregroundStyle(.red) + + Spacer() + + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.escape) + } + .padding() + } + .frame(width: 550, height: 620) + .sheet(isPresented: $showingPromptEditor) { + PromptEditorView(prompt: $localSettings.customPrompt) + } + .task { + await refreshInstalledModels() + } + } + + // MARK: - Basic Sections + + @ViewBuilder + private var basicSections: some View { + Section("Audio Capture") { + Toggle("Capture System Audio", isOn: $localSettings.captureSystemAudio) + Toggle("Capture Microphone", isOn: $localSettings.captureMicrophone) + } + + Section("Language") { + Picker("Source Language", selection: $localSettings.sourceLanguage) { + ForEach(SourceLanguage.allCases) { language in + HStack { + Text(language.displayName) + if !isLanguageAvailable(language) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption2) + .foregroundStyle(.orange) + } + } + .tag(language) + } + } + .help("The language being spoken that you want to recognize") + + if !isLanguageAvailable(localSettings.sourceLanguage) { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("This language may not be available on your device") + .font(.caption) + .foregroundStyle(.orange) + } + } + + Picker("Target Language", selection: $localSettings.targetLanguage) { + ForEach(TargetLanguage.allCases) { language in + Text("\(language.displayName) (\(language.nativeName))").tag(language) + } + } + .help("The language to translate into") + } + + Section("Translation Model") { + if installedModels.isEmpty { + HStack { + Text("Model") + TextField("Model Name", text: $localSettings.ollamaModel) + .textFieldStyle(.roundedBorder) + } + .help("e.g., qwen3.5:4b-mlx, qwen2.5:3b, gemma2:2b") + } else { + Picker("Model", selection: $localSettings.ollamaModel) { + ForEach(installedModels, id: \.self) { model in + Text(model).tag(model) + } + if !installedModels.contains(localSettings.ollamaModel) { + Text("\(localSettings.ollamaModel) (not installed)") + .tag(localSettings.ollamaModel) + } + } + .help("Models currently installed in Ollama") + } + + HStack { + if isLoadingModels { + ProgressView() + .scaleEffect(0.5) + .frame(width: 16, height: 16) + Text("Checking installed models…") + .font(.caption) + .foregroundStyle(.secondary) + } else if let error = modelListError { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(error) + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("\(installedModels.count) model(s) installed") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Button("Refresh") { + Task { await refreshInstalledModels() } + } + .controlSize(.small) + } + } + } + + // MARK: - Advanced Sections + + @ViewBuilder + private var advancedSections: some View { + Section("Advanced") { + DisclosureGroup("Ollama Connection") { + HStack { + Text("Host") + TextField("Host", text: $localSettings.ollamaHost) + .textFieldStyle(.roundedBorder) + } + + HStack { + Text("Port") + TextField("Port", value: $localSettings.ollamaPort, format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 100) + Spacer() + } + + HStack { + Text("Timeout") + TextField("Timeout", value: $localSettings.ollamaTimeout, format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 80) + Text("seconds") + .foregroundStyle(.secondary) + Spacer() + } + } + + DisclosureGroup("Translation Tuning") { + Picker("Model Type", selection: $localSettings.translationModelType) { + ForEach(TranslationModelType.allCases) { type in + Text(type.displayName).tag(type) + } + } + .help("Standard uses chat prompts; TranslateGemma uses specialized translation format") + + HStack { + Text("Temperature") + Slider(value: $localSettings.ollamaTemperature, in: 0...1, step: 0.1) + Text(String(format: "%.1f", localSettings.ollamaTemperature)) + .frame(width: 40) + .monospacedDigit() + } + .help("Lower = more consistent, Higher = more creative") + + Toggle("Live Draft Translation", isOn: $localSettings.liveDraftTranslation) + .help("Translate speech as it's being spoken for lower latency, instead of waiting for each sentence to finish") + + Toggle("Context-Aware Translation", isOn: $localSettings.contextAware) + .help("Use previous translations for better context") + + if localSettings.contextAware { + HStack { + Text("Max Context Entries") + Stepper("\(localSettings.maxContextEntries)", value: $localSettings.maxContextEntries, in: 1...10) + } + } + + HStack { + Text("Custom Prompt") + Spacer() + Button(localSettings.customPrompt.isEmpty ? "Use Default" : "Customized") { + showingPromptEditor = true + } + .buttonStyle(.bordered) + } + } + + DisclosureGroup("Display & Overlay") { + Toggle("Show Latency", isOn: $localSettings.showLatency) + + HStack { + Text("Max Display Cards") + Stepper("\(localSettings.maxDisplayCards)", value: $localSettings.maxDisplayCards, in: 1...20) + } + + HStack { + Text("Overlay Opacity") + Slider(value: $localSettings.overlayOpacity, in: 0.3...1.0, step: 0.05) + Text(String(format: "%.0f%%", localSettings.overlayOpacity * 100)) + .frame(width: 50) + .monospacedDigit() + } + + HStack { + Text("Overlay Font Size") + Slider(value: $localSettings.overlayFontSize, in: 10...24, step: 1) + Text(String(format: "%.0f pt", localSettings.overlayFontSize)) + .frame(width: 50) + .monospacedDigit() + } + + Toggle("Overlay Stays on Top", isOn: $localSettings.overlayStayOnTop) + .help("Keep overlay window above all other windows") + + Toggle("Overlay Click Through", isOn: $localSettings.overlayClickThrough) + .help("Allow clicks to pass through the overlay window") + } + + DisclosureGroup("History") { + HStack { + Text("Retention") + Stepper("\(localSettings.historyRetentionDays) days", value: $localSettings.historyRetentionDays, in: 1...365) + } + .help("Automatically remove history entries older than this many days") + + HStack { + Text("Max Entries") + Stepper("\(localSettings.historyMaxEntries)", value: $localSettings.historyMaxEntries, in: 100...50000, step: 100) + } + .help("Keep only the newest history entries after each successful translation") + } + } + } + + // MARK: - Diagnostics Section + + private var diagnosticsSection: some View { + Section("Diagnostics") { + HStack { + Button("Export Diagnostics…") { + exportDiagnostics() + } + + Button("Show Log File") { + let logURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Logs/LCTMac.log") + NSWorkspace.shared.activateFileViewerSelecting([logURL]) + } + + Spacer() + } + + if let message = diagnosticsMessage { + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Installed Models + + private func refreshInstalledModels() async { + isLoadingModels = true + modelListError = nil + defer { isLoadingModels = false } + + guard let url = URL(string: "\(localSettings.ollamaURL)/api/tags") else { + modelListError = "Invalid Ollama address" + return + } + + struct TagsResponse: Decodable { + struct Model: Decodable { let name: String } + let models: [Model] + } + + do { + var request = URLRequest(url: url) + request.timeoutInterval = 5 + let (data, _) = try await URLSession.shared.data(for: request) + let response = try JSONDecoder().decode(TagsResponse.self, from: data) + installedModels = response.models.map(\.name).sorted() + if installedModels.isEmpty { + modelListError = "No models installed — run: ollama pull \(RecommendedModel.defaultModel.name)" + } + } catch { + installedModels = [] + modelListError = "Cannot reach Ollama — start it or check Advanced › Ollama Connection" + } + } + + // MARK: - Diagnostics Export + + private func exportDiagnostics() { + let report = buildDiagnosticsReport() + + let panel = NSSavePanel() + panel.nameFieldStringValue = "LCT-diagnostics.txt" + panel.title = "Export Diagnostics" + + guard panel.runModal() == .OK, let url = panel.url else { return } + + do { + try report.write(to: url, atomically: true, encoding: .utf8) + diagnosticsMessage = "Diagnostics saved. Attach this file when reporting issues." + } catch { + diagnosticsMessage = "Export failed: \(error.localizedDescription)" + } + } + + private func buildDiagnosticsReport() -> String { + let bundle = Bundle.main + let appVersion = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" + let buildNumber = bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown" + let osVersion = ProcessInfo.processInfo.operatingSystemVersionString + + let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) + let speechStatus = SFSpeechRecognizer.authorizationStatus() + let screenStatus = CGPreflightScreenCaptureAccess() + + let guardian = OllamaGuardian.shared + + var lines: [String] = [] + lines.append("LCT Diagnostics Report") + lines.append("Generated: \(Date().formatted(.iso8601))") + lines.append("") + lines.append("== App ==") + lines.append("Version: \(appVersion) (\(buildNumber))") + lines.append("macOS: \(osVersion)") + lines.append("") + lines.append("== Permissions ==") + lines.append("Microphone: \(describe(micStatus))") + lines.append("Speech Recognition: \(describe(speechStatus))") + lines.append("Screen Recording: \(screenStatus ? "granted" : "not granted")") + lines.append("") + lines.append("== Configuration ==") + lines.append("Ollama: \(localSettings.ollamaURL) (\(localSettings.isLocalOllama ? "local" : "remote"))") + lines.append("Model: \(localSettings.ollamaModel) [\(localSettings.translationModelType.displayName)]") + lines.append("Languages: \(localSettings.sourceLanguage.displayName) → \(localSettings.targetLanguage.displayName)") + lines.append("Capture: systemAudio=\(localSettings.captureSystemAudio) microphone=\(localSettings.captureMicrophone)") + lines.append("") + lines.append("== Ollama ==") + lines.append("Status: \(guardian.status.displayText)") + lines.append("Version: \(guardian.ollamaVersion ?? "unknown")") + lines.append("Installed models: \(installedModels.isEmpty ? "(none / unreachable)" : installedModels.joined(separator: ", "))") + lines.append("") + lines.append("== Recent Log ==") + lines.append(recentLogLines(count: 50)) + + return lines.joined(separator: "\n") + } + + private func describe(_ status: AVAuthorizationStatus) -> String { + switch status { + case .authorized: return "granted" + case .denied: return "denied" + case .restricted: return "restricted" + case .notDetermined: return "not determined" + @unknown default: return "unknown" + } + } + + private func describe(_ status: SFSpeechRecognizerAuthorizationStatus) -> String { + switch status { + case .authorized: return "granted" + case .denied: return "denied" + case .restricted: return "restricted" + case .notDetermined: return "not determined" + @unknown default: return "unknown" + } + } + + private func recentLogLines(count: Int) -> String { + let logURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Logs/LCTMac.log") + guard let content = try? String(contentsOf: logURL, encoding: .utf8) else { + return "(log file not found)" + } + return content + .split(separator: "\n") + .suffix(count) + .joined(separator: "\n") + } + + // MARK: - Helpers + + private func isLanguageAvailable(_ language: SourceLanguage) -> Bool { + let locale = Locale(identifier: language.isoCode) + return SFSpeechRecognizer(locale: locale) != nil + } +} + +/// Prompt editor sheet +struct PromptEditorView: View { + @Binding var prompt: String + @Environment(\.dismiss) private var dismiss + @State private var localPrompt: String = "" + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Translation Prompt") + .font(.headline) + + Spacer() + + Button("Done") { + prompt = localPrompt + dismiss() + } + .buttonStyle(.borderedProminent) + } + .padding() + + Divider() + + VStack(alignment: .leading, spacing: 12) { + Text("Customize the system prompt for translation. Use {TARGET_LANGUAGE} as a placeholder for the target language.") + .font(.caption) + .foregroundStyle(.secondary) + + TextEditor(text: $localPrompt) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 200) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.secondary.opacity(0.3), lineWidth: 1) + ) + + HStack { + Button("Reset to Default") { + localPrompt = "" + } + + Spacer() + + Button("Preview Default") { + localPrompt = AppSettings.defaultPrompt + } + .buttonStyle(.bordered) + } + } + .padding() + } + .frame(width: 500, height: 400) + .onAppear { + localPrompt = prompt + } + } +} diff --git a/macos/LCTMac/Views/WelcomeView.swift b/macos/LCTMac/Views/WelcomeView.swift new file mode 100644 index 0000000..a9cc31d --- /dev/null +++ b/macos/LCTMac/Views/WelcomeView.swift @@ -0,0 +1,973 @@ +import SwiftUI +import Speech +import AVFoundation +@preconcurrency import ScreenCaptureKit + +/// Welcome/Setup view for first-time users +struct WelcomeView: View { + @StateObject private var guardian = OllamaGuardian.shared + @StateObject private var modelManager = OllamaModelManager() + + @State private var currentStep: SetupStep = .welcome + @State private var isSettingUp = false + @State private var setupError: String? + @State private var remoteHost = "" + @State private var remotePortText = "11434" + @State private var remoteModel = RecommendedModel.defaultModel.name + @State private var readyModelName = RecommendedModel.defaultModel.name + @State private var setupSkipped = false + + // Permission states + @State private var hasScreenCapturePermission = false + @State private var hasMicrophonePermission = false + @State private var hasSpeechRecognitionPermission = false + @State private var isCheckingPermissions = false + + /// Default model to use + private let defaultModel = RecommendedModel.defaultModel + + let onComplete: () -> Void + + enum SetupStep { + case welcome + case permissions + case checkingOllama + case ollamaNotInstalled + case modelMissing + case remoteOllama + case downloadingModel + case complete + } + + private struct RemoteOllamaConfig { + let host: String + let port: Int + let baseURL: String + let model: String + } + + var body: some View { + VStack(spacing: 0) { + // Header + headerView + + Divider() + + // Content + ScrollView { + VStack(spacing: 24) { + switch currentStep { + case .welcome: + welcomeContent + case .permissions: + permissionsContent + case .checkingOllama: + checkingOllamaContent + case .ollamaNotInstalled: + ollamaNotInstalledContent + case .modelMissing: + modelMissingContent + case .remoteOllama: + remoteOllamaContent + case .downloadingModel: + downloadingModelContent + case .complete: + completeContent + } + } + .padding(32) + } + + Divider() + + // Footer + footerView + } + .frame(width: 600, height: 560) + } + + // MARK: - Header + + private var headerView: some View { + HStack { + Image(systemName: "waveform.circle.fill") + .font(.largeTitle) + .foregroundStyle(.blue) + + VStack(alignment: .leading) { + Text("LCT for macOS") + .font(.title2) + .fontWeight(.semibold) + + Text("Live Captions Translator") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + + // Step indicator + stepIndicator + } + .padding() + } + + private var stepIndicator: some View { + HStack(spacing: 8) { + ForEach(0..<4) { index in + Circle() + .fill(stepColor(for: index)) + .frame(width: 8, height: 8) + } + } + } + + private func stepColor(for index: Int) -> Color { + let currentIndex = stepIndex + if index < currentIndex { + return .green + } else if index == currentIndex { + return .blue + } else { + return .gray.opacity(0.3) + } + } + + private var stepIndex: Int { + switch currentStep { + case .welcome: return 0 + case .permissions: return 1 + case .checkingOllama, .ollamaNotInstalled, .modelMissing, .remoteOllama, .downloadingModel: return 2 + case .complete: return 3 + } + } + + // MARK: - Welcome Content + + private var welcomeContent: some View { + VStack(spacing: 24) { + Image(systemName: "hand.wave.fill") + .font(.system(size: 60)) + .foregroundStyle(.blue) + + Text("Welcome to LCT") + .font(.largeTitle) + .fontWeight(.bold) + + Text("Real-time speech recognition and translation powered by local AI") + .font(.title3) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + VStack(alignment: .leading, spacing: 16) { + FeatureRow(icon: "waveform", title: "Speech Recognition", description: "Apple's native speech recognition") + FeatureRow(icon: "globe", title: "Local Translation", description: "Private, offline translation with Ollama") + FeatureRow(icon: "lock.shield", title: "Privacy First", description: "All processing happens on your device") + } + .padding(.top, 16) + } + } + + // MARK: - Permissions Content + + private var permissionsContent: some View { + VStack(spacing: 24) { + Image(systemName: "lock.shield.fill") + .font(.system(size: 60)) + .foregroundStyle(.blue) + + Text("Permissions Required") + .font(.title) + .fontWeight(.bold) + + Text("LCT needs the following permissions to capture and translate audio") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + VStack(spacing: 16) { + // Screen Recording Permission + PermissionRow( + icon: "rectangle.dashed.badge.record", + title: "Screen Recording", + description: "Required to capture system audio", + isGranted: hasScreenCapturePermission, + isChecking: isCheckingPermissions, + action: requestScreenCapturePermission + ) + + // Microphone Permission + PermissionRow( + icon: "mic.fill", + title: "Microphone", + description: "Required to capture your voice", + isGranted: hasMicrophonePermission, + isChecking: isCheckingPermissions, + action: requestMicrophonePermission + ) + + // Speech Recognition Permission + PermissionRow( + icon: "waveform", + title: "Speech Recognition", + description: "Required for speech-to-text", + isGranted: hasSpeechRecognitionPermission, + isChecking: isCheckingPermissions, + action: requestSpeechRecognitionPermission + ) + } + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + + if !allPermissionsGranted { + Text("Click each permission to grant access, or open System Settings manually") + .font(.caption) + .foregroundStyle(.secondary) + + Button(action: openSystemSettings) { + Label("Open System Settings", systemImage: "gear") + } + .buttonStyle(.bordered) + } + } + .onAppear { + Task { + await checkAllPermissions() + } + } + // Auto-refresh when coming back from System Settings + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + if currentStep == .permissions && !allPermissionsGranted { + print("[WelcomeView] App became active. Auto-checking permissions...") + Task { + await checkAllPermissions() + } + } + } + } + + private var allPermissionsGranted: Bool { + hasScreenCapturePermission && hasMicrophonePermission && hasSpeechRecognitionPermission + } + + // MARK: - Checking Ollama Content + + private var checkingOllamaContent: some View { + VStack(spacing: 24) { + ProgressView() + .scaleEffect(1.5) + + Text("Setting up...") + .font(.title2) + + Text("Checking Ollama and preparing translation model") + .foregroundStyle(.secondary) + } + } + + // MARK: - Ollama Not Installed Content + + private var ollamaNotInstalledContent: some View { + VStack(spacing: 24) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 60)) + .foregroundStyle(.orange) + + Text("Ollama Required") + .font(.title) + .fontWeight(.bold) + + Text("Install Ollama locally or connect LCT to an existing Ollama server.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + VStack(spacing: 12) { + Button(action: { + guardian.openInstallationPage() + }) { + Label("Download Ollama", systemImage: "arrow.down.circle") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + + Button(action: { + remoteHost = "" + remotePortText = "11434" + remoteModel = defaultModel.name + setupError = nil + currentStep = .remoteOllama + }) { + Label("Use Remote Ollama Server", systemImage: "network") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.large) + + Text("After installing Ollama locally, click 'Check Again' below.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.top, 8) + } + } + + // MARK: - Model Missing Content + + private var modelMissingContent: some View { + VStack(spacing: 24) { + Image(systemName: "square.and.arrow.down") + .font(.system(size: 60)) + .foregroundStyle(.blue) + + Text("Translation Model Required") + .font(.title) + .fontWeight(.bold) + + Text("Ollama is running, but \(defaultModel.displayName) is not installed.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Model") + .foregroundStyle(.secondary) + Spacer() + Text(defaultModel.name) + .monospaced() + } + + HStack { + Text("Download Size") + .foregroundStyle(.secondary) + Spacer() + Text(defaultModel.size) + } + + Divider() + + Text("Command") + .font(.caption) + .foregroundStyle(.secondary) + + Text("ollama pull \(defaultModel.name)") + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + } + .padding() + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(8) + } + } + + // MARK: - Remote Ollama Content + + private var remoteOllamaContent: some View { + VStack(spacing: 24) { + Image(systemName: "network") + .font(.system(size: 60)) + .foregroundStyle(.blue) + + Text("Remote Ollama Server") + .font(.title) + .fontWeight(.bold) + + Text("Connect to a reachable Ollama API that already has the translation model installed.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + VStack(alignment: .leading, spacing: 14) { + Text("Host") + .font(.caption) + .foregroundStyle(.secondary) + TextField("192.168.1.20", text: $remoteHost) + .textFieldStyle(.roundedBorder) + + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("Port") + .font(.caption) + .foregroundStyle(.secondary) + TextField("11434", text: $remotePortText) + .textFieldStyle(.roundedBorder) + .frame(width: 120) + } + + VStack(alignment: .leading, spacing: 6) { + Text("Model") + .font(.caption) + .foregroundStyle(.secondary) + TextField(defaultModel.name, text: $remoteModel) + .textFieldStyle(.roundedBorder) + } + } + + Text("LCT will verify /api/tags and save this endpoint if the model is present.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(8) + } + } + + // MARK: - Downloading Model Content + + private var downloadingModelContent: some View { + VStack(spacing: 24) { + if modelManager.pullProgress < 1.0 { + ProgressView(value: modelManager.pullProgress) { + Text("Downloading \(defaultModel.displayName)") + .font(.headline) + } currentValueLabel: { + Text(modelManager.pullStatus) + .font(.caption) + .foregroundStyle(.secondary) + } + .progressViewStyle(.linear) + + Text("\(Int(modelManager.pullProgress * 100))%") + .font(.largeTitle) + .fontWeight(.bold) + .monospacedDigit() + + Text("Size: \(defaultModel.size)") + .font(.caption) + .foregroundStyle(.tertiary) + } else { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 60)) + .foregroundStyle(.green) + + Text("Download Complete!") + .font(.title) + .fontWeight(.bold) + } + + if modelManager.isPulling { + Button("Cancel") { + modelManager.cancelPull() + currentStep = .modelMissing + } + .buttonStyle(.bordered) + } + } + } + + // MARK: - Complete Content + + private var completeContent: some View { + VStack(spacing: 24) { + Image(systemName: setupSkipped ? "exclamationmark.circle.fill" : "checkmark.circle.fill") + .font(.system(size: 60)) + .foregroundStyle(setupSkipped ? .orange : .green) + + Text(setupSkipped ? "Setup Skipped" : "You're All Set!") + .font(.title) + .fontWeight(.bold) + + Text(setupSkipped ? "Configure Ollama in Settings before starting translation." : "LCT is ready to use. Click 'Get Started' to begin translating.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + if !setupSkipped { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("Ollama connection is ready") + } + + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("Model '\(readyModelName)' is ready") + } + } + .padding() + .background(Color.green.opacity(0.1)) + .cornerRadius(12) + } + } + } + + // MARK: - Footer + + private var footerView: some View { + HStack { + if currentStep == .ollamaNotInstalled || currentStep == .modelMissing { + Button("Check Again") { + Task { + await checkOllama() + } + } + } + + if currentStep == .remoteOllama { + Button("Back") { + setupError = nil + currentStep = .ollamaNotInstalled + } + } + + Spacer() + + if let error = setupError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .lineLimit(2) + } + + Spacer() + + Button(action: nextStep) { + Text(nextButtonTitle) + } + .buttonStyle(.borderedProminent) + .disabled(isSettingUp || (currentStep == .downloadingModel && modelManager.isPulling)) + } + .padding() + } + + private var nextButtonTitle: String { + switch currentStep { + case .welcome: + return "Get Started" + case .permissions: + return allPermissionsGranted ? "Continue" : "Skip for Now" + case .checkingOllama: + return "Setting up..." + case .ollamaNotInstalled: + return "Skip for Now" + case .modelMissing: + return "Install Model" + case .remoteOllama: + return isSettingUp ? "Testing..." : "Test Connection" + case .downloadingModel: + return modelManager.isPulling ? "Downloading..." : "Continue" + case .complete: + return "Get Started" + } + } + + // MARK: - Actions + + private func nextStep() { + setupError = nil + + switch currentStep { + case .welcome: + currentStep = .permissions + Task { + await checkAllPermissions() + } + + case .permissions: + currentStep = .checkingOllama + Task { + await checkOllama() + } + + case .checkingOllama: + // Wait for check to complete + break + + case .ollamaNotInstalled: + // Skip and continue anyway (user can set up later) + setupSkipped = true + currentStep = .complete + + case .modelMissing: + currentStep = .downloadingModel + Task { + await downloadModel() + } + + case .remoteOllama: + Task { + await testRemoteOllama() + } + + case .downloadingModel: + if !modelManager.isPulling { + currentStep = .complete + } + + case .complete: + onComplete() + } + } + + private func checkOllama() async { + isSettingUp = true + setupError = nil + defer { isSettingUp = false } + + await guardian.checkStatus() + + switch guardian.status { + case .notInstalled: + currentStep = .ollamaNotInstalled + + case .running: + await modelManager.fetchInstalledModels() + + // Check if default model is installed + if modelManager.isModelInstalled(defaultModel.name) { + setupSkipped = false + readyModelName = defaultModel.name + currentStep = .complete + } else { + currentStep = .modelMissing + } + + case .installed, .stopped: + // Try to start Ollama + do { + try await guardian.startService() + await modelManager.fetchInstalledModels() + + if modelManager.isModelInstalled(defaultModel.name) { + setupSkipped = false + readyModelName = defaultModel.name + currentStep = .complete + } else { + currentStep = .modelMissing + } + } catch { + setupError = "Failed to start Ollama: \(error.localizedDescription)" + currentStep = .ollamaNotInstalled + } + + case .starting: + // Wait for it + try? await Task.sleep(nanoseconds: 2_000_000_000) + await checkOllama() + + case .error(let message): + setupError = message + currentStep = .ollamaNotInstalled + } + } + + private func downloadModel() async { + do { + try await modelManager.pullModel(defaultModel.name) + setupSkipped = false + readyModelName = defaultModel.name + currentStep = .complete + } catch { + setupError = error.localizedDescription + currentStep = .modelMissing + } + } + + private func testRemoteOllama() async { + isSettingUp = true + setupError = nil + defer { isSettingUp = false } + + guard let config = normalizedRemoteOllamaConfig() else { + setupError = "Enter a valid HTTP host, port, and model." + return + } + + guard let url = URL(string: "\(config.baseURL)/api/tags") else { + setupError = "Invalid remote Ollama URL." + return + } + + var request = URLRequest(url: url) + request.timeoutInterval = 8 + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + setupError = "Remote Ollama did not return a healthy /api/tags response." + return + } + + let modelsResponse = try JSONDecoder().decode(OllamaModelsResponse.self, from: data) + guard modelsResponse.models.contains(where: { isModelName($0.name, matching: config.model) }) else { + setupError = "Remote Ollama connected, but '\(config.model)' is not installed." + return + } + + var settings = AppSettings.load() + settings.ollamaHost = config.host + settings.ollamaPort = config.port + settings.ollamaModel = config.model + + guard settings.save() else { + setupError = AppSettings.consumeLastPersistenceError() ?? "Failed to save remote Ollama settings." + return + } + + setupSkipped = false + readyModelName = config.model + currentStep = .complete + } catch { + setupError = "Cannot connect to remote Ollama: \(error.localizedDescription)" + } + } + + private func normalizedRemoteOllamaConfig() -> RemoteOllamaConfig? { + let rawHost = remoteHost.trimmingCharacters(in: .whitespacesAndNewlines) + let rawPort = remotePortText.trimmingCharacters(in: .whitespacesAndNewlines) + let model = remoteModel.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !rawHost.isEmpty, !model.isEmpty else { + return nil + } + + var port = 11434 + if !rawPort.isEmpty { + guard let parsedPort = Int(rawPort) else { + return nil + } + port = parsedPort + } + + guard (1...65_535).contains(port) else { + return nil + } + + let lowercasedHost = rawHost.lowercased() + if lowercasedHost.hasPrefix("http://") || lowercasedHost.hasPrefix("https://") { + guard var components = URLComponents(string: rawHost), + components.scheme?.lowercased() == "http", + let parsedHost = components.host else { + return nil + } + + if let parsedPort = components.port { + port = parsedPort + } + + components.scheme = "http" + components.host = parsedHost + components.port = port + components.path = "" + components.query = nil + components.fragment = nil + + guard let absoluteURL = components.url?.absoluteString else { + return nil + } + let baseURL = absoluteURL.hasSuffix("/") ? String(absoluteURL.dropLast()) : absoluteURL + + return RemoteOllamaConfig( + host: normalizedHostForSettings(parsedHost), + port: port, + baseURL: baseURL, + model: model + ) + } + + let hostInput = rawHost.hasSuffix("/") ? String(rawHost.dropLast()) : rawHost + + if let components = URLComponents(string: "http://\(hostInput)"), + let parsedHost = components.host { + if let parsedPort = components.port { + port = parsedPort + } + + guard (1...65_535).contains(port) else { + return nil + } + + let host = normalizedHostForSettings(parsedHost) + return RemoteOllamaConfig( + host: host, + port: port, + baseURL: "http://\(host):\(port)", + model: model + ) + } + + guard hostInput.filter({ $0 == ":" }).count > 1 else { + return nil + } + + let host = normalizedHostForSettings(hostInput) + return RemoteOllamaConfig( + host: host, + port: port, + baseURL: "http://\(host):\(port)", + model: model + ) + } + + private func normalizedHostForSettings(_ host: String) -> String { + if host.contains(":") && !host.hasPrefix("[") { + return "[\(host)]" + } + + return host + } + + private func isModelName(_ installedName: String, matching requestedName: String) -> Bool { + installedName == requestedName || installedName.hasPrefix("\(requestedName):") + } + + // MARK: - Permission Methods + + private func checkAllPermissions() async { + isCheckingPermissions = true + defer { isCheckingPermissions = false } + + // Check Screen Capture permission + hasScreenCapturePermission = await checkScreenCapturePermission() + + // Check Microphone permission + hasMicrophonePermission = await checkMicrophonePermission() + + // Check Speech Recognition permission + hasSpeechRecognitionPermission = await checkSpeechRecognitionPermission() + } + + private func checkScreenCapturePermission() async -> Bool { + print("[WelcomeView] Checking Screen Capture Permission...") + let hasAccess = CGPreflightScreenCaptureAccess() + if hasAccess { + print("[WelcomeView] CGPreflight returned TRUE") + return true + } + + // Fallback to SCShareableContent to bypass cache + do { + let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) + let granted = !content.displays.isEmpty + print("[WelcomeView] SCShareableContent check completed. Displays > 0? \(granted)") + return granted + } catch { + print("[WelcomeView] SCShareableContent threw: \(error.localizedDescription)") + return false + } + } + + private func checkMicrophonePermission() async -> Bool { + let status = AVCaptureDevice.authorizationStatus(for: .audio) + return status == .authorized + } + + private func checkSpeechRecognitionPermission() async -> Bool { + let status = SFSpeechRecognizer.authorizationStatus() + return status == .authorized + } + + private func requestScreenCapturePermission() { + Task { + // Requesting screen capture will trigger the system dialog + do { + _ = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) + hasScreenCapturePermission = true + } catch { + // Permission denied or not yet granted + hasScreenCapturePermission = false + // Open System Settings for Screen Recording + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { + NSWorkspace.shared.open(url) + } + } + } + } + + private func requestMicrophonePermission() { + Task { + let granted = await AVCaptureDevice.requestAccess(for: .audio) + await MainActor.run { + hasMicrophonePermission = granted + } + } + } + + private func requestSpeechRecognitionPermission() { + SFSpeechRecognizer.requestAuthorization { @Sendable status in + DispatchQueue.main.async { [self] in + hasSpeechRecognitionPermission = (status == .authorized) + } + } + } + + private func openSystemSettings() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy") { + NSWorkspace.shared.open(url) + } + } +} + +// MARK: - Supporting Views + +struct FeatureRow: View { + let icon: String + let title: String + let description: String + + var body: some View { + HStack(spacing: 16) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(.blue) + .frame(width: 40) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.headline) + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + } + } +} + +struct PermissionRow: View { + let icon: String + let title: String + let description: String + let isGranted: Bool + let isChecking: Bool + let action: () -> Void + + var body: some View { + HStack(spacing: 16) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(isGranted ? .green : .orange) + .frame(width: 40) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.headline) + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + + if isChecking { + ProgressView() + .scaleEffect(0.8) + } else if isGranted { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.title2) + } else { + Button("Grant") { + action() + } + .buttonStyle(.bordered) + } + } + } +} diff --git a/macos/Package.resolved b/macos/Package.resolved new file mode 100644 index 0000000..1dc6e04 --- /dev/null +++ b/macos/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "3ae310a722c7cc102481c22c9a01294316aeaedb290e020578f8e5b73d26e20e", + "pins" : [ + { + "identity" : "sqlite.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/stephencelis/SQLite.swift.git", + "state" : { + "revision" : "0c08856385fe24f7b76d8c51842d78a196e8e817", + "version" : "0.15.5" + } + } + ], + "version" : 3 +} diff --git a/macos/Package.swift b/macos/Package.swift new file mode 100644 index 0000000..7343989 --- /dev/null +++ b/macos/Package.swift @@ -0,0 +1,41 @@ +// swift-tools-version: 6.0 +// LCT for macOS - Swift Package Configuration + +import PackageDescription + +let package = Package( + name: "LCTMac", + platforms: [ + .macOS(.v15) + ], + products: [ + .executable(name: "LCTMac", targets: ["LCTMac"]) + ], + dependencies: [ + // SQLite for data persistence + .package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.0"), + ], + targets: [ + .executableTarget( + name: "LCTMac", + dependencies: [ + .product(name: "SQLite", package: "SQLite.swift") + ], + path: "LCTMac", + exclude: ["LCTMac.entitlements", "Info.plist", "Resources"], + linkerSettings: [ + .unsafeFlags([ + "-Xlinker", "-sectcreate", + "-Xlinker", "__TEXT", + "-Xlinker", "__info_plist", + "-Xlinker", "LCTMac/Info.plist" + ]) + ] + ), + .testTarget( + name: "LCTMacTests", + dependencies: ["LCTMac"], + path: "Tests/LCTMacTests" + ) + ] +) diff --git a/macos/QUICK_START.md b/macos/QUICK_START.md new file mode 100644 index 0000000..73d7056 --- /dev/null +++ b/macos/QUICK_START.md @@ -0,0 +1,216 @@ +# LCT macOS 快速开始指南 + +**5 分钟快速上手 LiveCaptions Translator** + +--- + +## 📦 安装(只需 3 步) + +### 1️⃣ 下载 LCT +从 [Releases](https://github.com/your-repo/LCT/releases) 下载最新的 `.dmg` 文件 + +### 2️⃣ 安装应用 +- 双击 `.dmg` 文件 +- 拖拽 LCT 到"应用程序"文件夹 +- 从启动台打开 LCT + +### 3️⃣ 首次设置 +应用会自动引导你完成: +- ✅ 授予必要权限(麦克风、屏幕录制、语音识别) +- ✅ 安装 Ollama(AI 翻译引擎) +- ✅ 下载默认 AI 模型(qwen3.5:4b-mlx,约 2.5GB) + +💡 **提示**:整个过程需要 5-10 分钟,取决于网络速度 + +--- + +## 🚀 基本使用 + +### 开始翻译 +1. 点击左上角的 **"Start"** 按钮(或按 `⌘ + Space`) +2. 开始播放音频或说话 +3. 字幕和翻译会实时显示 + +### 停止翻译 +- 再次点击 **"Stop"** 按钮(或按 `⌘ + Space`) + +### 查看悬浮窗口 +- 悬浮窗口会自动显示在屏幕上方 +- 可以拖动到任意位置 +- 按 `⌘ + O` 可以切换显示/隐藏 + +### 复制翻译 +- 点击翻译文本旁的复制按钮 +- 或按 `⌘ + ⇧ + C` + +--- + +## ⚙️ 常用设置 + +### 打开设置 +- 点击主窗口的 **"设置"** 按钮 +- 或按 `⌘ + ,` + +### 必改设置 + +#### 1. 选择识别语言 +``` +设置 > Speech Recognition > Source Language +``` +选择你要识别的语音语言(如"中文"识别中文讲话) + +#### 2. 选择翻译语言 +``` +设置 > Translation > Target Language +``` +选择要翻译成的语言(如"English"翻译成英文) + +#### 3. 调整悬浮窗口 +``` +设置 > Display > Overlay Opacity +``` +调整悬浮窗口的透明度(0.7-0.9 比较合适) + +--- + +## 🎯 使用场景 + +### 场景 1:翻译在线会议 +1. **设置**: + - ✅ 启用"Capture System Audio"(捕获会议音频) + - ✅ 源语言设为会议使用的语言 + - ✅ 目标语言设为你想翻译成的语言 + +2. **使用**: + - 进入会议前点击"Start" + - 悬浮窗口会显示实时翻译 + - 可以把悬浮窗口放在会议窗口旁边 + +### 场景 2:翻译视频内容 +1. **设置**: + - ✅ 启用"Capture System Audio" + - ✅ 选择视频的语言作为源语言 + +2. **使用**: + - 播放视频前点击"Start" + - 字幕会自动出现 + +### 场景 3:翻译自己的讲话 +1. **设置**: + - ✅ 启用"Capture Microphone" + - ✅ 禁用"Capture System Audio"(避免干扰) + - ✅ 源语言设为你说话的语言 + +2. **使用**: + - 点击"Start"后开始说话 + - 可以用于练习外语或准备演讲 + +--- + +## 🔑 快捷键 + +| 快捷键 | 功能 | +|--------|------| +| `⌘ + Space` | 开始/停止 | +| `⌘ + P` | 暂停/继续 | +| `⌘ + O` | 显示/隐藏悬浮窗口 | +| `⌘ + ⇧ + C` | 复制翻译 | +| `⌘ + ⇧ + H` | 查看历史记录 | +| `⌘ + ,` | 打开设置 | + +--- + +## ❓ 常见问题 + +### Q: 为什么没有声音被识别? +**A**: 检查: +- ✅ 是否点击了"Start"按钮 +- ✅ 音频电平指示器是否有波动 +- ✅ 是否选择了正确的源语言 +- ✅ 是否授予了相应权限 + +### Q: 识别了但没有翻译? +**A**: 检查: +- ✅ Ollama 是否在运行:打开终端运行 `ollama serve` +- ✅ AI 模型是否已下载:运行 `ollama list` +- ✅ 查看主窗口是否有错误提示 + +### Q: 翻译速度太慢? +**A**: 优化: +- 使用更小的模型(如 `gemma2:2b`) +- 在设置中降低"Max Context Entries" +- 降低 Temperature 到 0.2 + +### Q: 悬浮窗口不见了? +**A**: 按 `⌘ + O` 重新显示 + +--- + +## 🎨 推荐设置 + +### 日常会议翻译 +``` +✅ Capture System Audio: ON +❌ Capture Microphone: OFF +📝 Source Language: English (US) +🌍 Target Language: Chinese +🎯 Context-Aware: ON +⚡ Temperature: 0.3 +``` + +### 视频字幕翻译 +``` +✅ Capture System Audio: ON +❌ Capture Microphone: OFF +📺 Overlay Window: ON +🔍 Show Latency: OFF(避免干扰) +``` + +### 语言学习助手 +``` +✅ Capture Microphone: ON +✅ Capture System Audio: ON +📚 Max Context Entries: 7 +💾 保留历史记录用于复习 +``` + +--- + +## 🔧 故障排除 + +### 重启 Ollama +```bash +# 停止 +killall ollama + +# 启动 +ollama serve +``` + +### 重新授予权限 +1. 系统设置 > 隐私与安全性 +2. 点击"麦克风"或"屏幕录制" +3. 取消勾选 LCT,再重新勾选 +4. 重启 LCT + +### 清除缓存 +```bash +# 清除历史记录 +rm ~/Library/Application\ Support/LCT/history.sqlite + +# 重启应用 +``` + +--- + +## 📚 进一步学习 + +- 📖 [完整用户文档](USER_GUIDE.md) +- 🔧 [技术改进计划](IMPROVEMENT_PLAN.md) +- 🐛 [报告问题](https://github.com/your-repo/LCT/issues) + +--- + +**祝你使用愉快!🎉** + +有任何问题,欢迎在 GitHub 上提 Issue! diff --git a/macos/README.md b/macos/README.md new file mode 100644 index 0000000..a3c7aac --- /dev/null +++ b/macos/README.md @@ -0,0 +1,47 @@ +# LiveCaptions Translator (LCT) for macOS 🎙️✨ + +LiveCaptions Translator (LCT) 是一款专为 macOS 设计的原生级实时语音识别与 AI 翻译桌面工具。它通过无缝结合苹果原生的 `SFSpeechRecognizer` 框架与本地化部署的 LLM(Ollama大模型),提供真正的“零延迟感知”与“隐私安全”的实时跨语言字幕与口语润色服务。 + +## 🌟 核心特性 (Features) + +- **原生级体验**:完全使用 Swift & SwiftUI 编写,极低的内存与 CPU 占用。 +- **毛玻璃悬浮窗 (Glassmorphism HUD)**:沉浸式窄边框字幕条,采用 `.ultraThinMaterial` 毛玻璃材质,与系统无缝融合。 +- **渐进式交互 (Progressive Disclosure)**:悬停时优雅淡入控制栏,移开后恢复纯净阅读体验。 +- **双路流式翻译管道 (Translation Pipeline)**:独创的 `CaptionSegmenter` 文本切分器,实现本地 ASR “边听边修补” 与后台 “单次提问双重输出(原句去噪改写 + 目标语言翻译)” 完美结合。 +- **全面本地化支持**:直接对接本地 Ollama 服务(默认推荐模型 `qwen3.5:4b-mlx`),让你的数据不出本机,保证极致的隐私和离线可用性。 +- **全自动 CI/CD 流程**:已配置完整的 GitHub Actions 工作流,代码合并自动拉起 macOS Runner 完成 `.app` 编译与压缩归档分发。 + +## 📁 本地代码架构 (Architecture) + +我们的项目将复杂逻辑分离到高内聚的模型中: +- **`LCTMacApp.swift`**:程序入口,管理欢迎向导和主窗口样式。 +- **`MainView.swift`**:核心视图,采用基于 ZStack 和 `ScrollView` 的非完全透明悬浮面板设计,包含自动滚动的流式翻译卡片。 +- **`TranscriptionVM.swift`**:控制大局的 ViewModel,负责协调语音录制、断句切片、请求翻译和数据同步。 +- **`Utils/CaptionSegmenter.swift`**:负责把源源不断的实时 ASR 草稿流文本(Interim Text)基于停顿和标点进行切段(Segment)定型。 +- **`Models/TranslationSegment.swift`**:每一个定型的段落对应一个 `TranslationSegment` 卡片,追踪自己处于识别中、翻译中或已完成状态。 +- **`Services/OllamaService.swift`**:与本地大模型交互的网络层,解析特殊的指令模板进行翻译。 + +## 📜 历史变更概要 (Changelog & Commits) + +最近的重大架构演进与特性更新: +* `c402aca` - 优化:默认关闭 Ollama 大模型的思考过程 (`think: false`),降低首字延迟。 +* `de99eea` & `ee42dde` - CI/CD:新增并修复了基于 Swift 6.0 的 `macOS` GitHub Actions 构建和打包流程。 +* `fa328de` - 特性:实现了真正的流式 Transcript Stream,由单一全量字符串重构为分段式管道 (`Segmented Translation Pipeline`)。 +* `a47a5e6` - 架构:采用 Overlay-first 架构重新设计,并加入 CaptionSegment 状态机。 +* `094ba7c` - 特性:彻底移除基于 Python 的桥接代码,全盘换成性能更加优越的 Swift Native 原生组件。 +* `8d2fec6` - 架构:精简代码库,移除过时的 Windows 分支依赖,专注于打造 macOS 最佳体验。 + +## 🚀 开发者构建指南 + +本项目需要 **Xcode 16 / Swift 6.0** 环境: + +```bash +# 进入项目目录 +cd macos +# 启动 Release 编译 +swift build -c release +# 运行编译后的程序 +./.build/release/LCTMac +``` + +(注:你也可以直接利用已经配置好的 GitHub Actions 自动获取每日构建的 `LCTMac-macOS.zip` 包) diff --git a/macos/Scripts/build-app.sh b/macos/Scripts/build-app.sh new file mode 100755 index 0000000..d50523d --- /dev/null +++ b/macos/Scripts/build-app.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# build-app.sh - Build LCTMac and package as a proper .app bundle +# This is necessary because macOS TCC requires Info.plist in a proper +# .app bundle structure to read privacy usage descriptions. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +APP_NAME="LCTMac" +APP_BUNDLE="${PROJECT_DIR}/${APP_NAME}.app" +BUILD_CONFIG="${1:-debug}" + +echo "🔨 Building ${APP_NAME} (${BUILD_CONFIG})..." + +cd "$PROJECT_DIR" + +if [ "$BUILD_CONFIG" = "release" ]; then + swift build -c release 2>&1 + EXECUTABLE_PATH=".build/release/${APP_NAME}" +else + swift build 2>&1 + EXECUTABLE_PATH=".build/debug/${APP_NAME}" +fi + +if [ ! -f "$EXECUTABLE_PATH" ]; then + echo "❌ Build failed: executable not found at ${EXECUTABLE_PATH}" + exit 1 +fi + +echo "📦 Creating app bundle: ${APP_BUNDLE}" + +# Clean previous bundle +rm -rf "$APP_BUNDLE" + +# Create bundle structure +mkdir -p "${APP_BUNDLE}/Contents/MacOS" +mkdir -p "${APP_BUNDLE}/Contents/Resources" + +# Copy executable +cp "$EXECUTABLE_PATH" "${APP_BUNDLE}/Contents/MacOS/${APP_NAME}" + +# Copy Info.plist +cp "${PROJECT_DIR}/LCTMac/Info.plist" "${APP_BUNDLE}/Contents/Info.plist" + +# Copy entitlements (for reference, used during codesigning) +if [ -f "${PROJECT_DIR}/LCTMac/LCTMac.entitlements" ]; then + cp "${PROJECT_DIR}/LCTMac/LCTMac.entitlements" "${APP_BUNDLE}/Contents/Resources/" +fi + +# Create PkgInfo +echo -n "APPL????" > "${APP_BUNDLE}/Contents/PkgInfo" + +# Ad-hoc code sign with entitlements to enable TCC permissions +echo "🔏 Code signing..." +if [ -f "${PROJECT_DIR}/LCTMac/LCTMac.entitlements" ]; then + codesign --force --sign - \ + --entitlements "${PROJECT_DIR}/LCTMac/LCTMac.entitlements" \ + --deep \ + "${APP_BUNDLE}" 2>&1 +else + codesign --force --sign - --deep "${APP_BUNDLE}" 2>&1 +fi + +echo "" +echo "✅ Build complete: ${APP_BUNDLE}" +echo "" +echo "To run:" +echo " open ${APP_BUNDLE}" +echo "" +echo "Or from terminal:" +echo " ${APP_BUNDLE}/Contents/MacOS/${APP_NAME}" diff --git a/macos/Scripts/build_signed.sh b/macos/Scripts/build_signed.sh new file mode 100755 index 0000000..0bbac6a --- /dev/null +++ b/macos/Scripts/build_signed.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# 构建并签名 LCTMac 应用 +# 这个脚本会使用本地开发者签名来签名应用,使其能获得屏幕录制权限 + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +echo "🔨 构建 LCTMac..." +cd "$PROJECT_DIR" + +# 构建应用 +swift build -c debug + +# 获取构建产物路径 +BUILD_PATH="$PROJECT_DIR/.build/debug/LCTMac" + +if [ ! -f "$BUILD_PATH" ]; then + BUILD_PATH="$PROJECT_DIR/.build/arm64-apple-macosx/debug/LCTMac" +fi + +if [ ! -f "$BUILD_PATH" ]; then + echo "❌ 找不到构建产物" + exit 1 +fi + +echo "📦 应用路径: $BUILD_PATH" + +# 查找可用的签名身份 +echo "" +echo "🔍 查找可用的代码签名身份..." +IDENTITY=$(security find-identity -v -p codesigning | grep "Apple Development" | head -1 | awk -F'"' '{print $2}') + +if [ -z "$IDENTITY" ]; then + IDENTITY=$(security find-identity -v -p codesigning | grep "Developer ID" | head -1 | awk -F'"' '{print $2}') +fi + +if [ -z "$IDENTITY" ]; then + echo "⚠️ 未找到开发者证书,使用 adhoc 签名" + echo " 要获得屏幕录制权限,建议使用 Xcode 运行应用" + echo "" + echo "🔐 使用 adhoc 签名并添加 entitlements..." + codesign --force --sign - \ + --entitlements "$PROJECT_DIR/LCTMac/LCTMac.entitlements" \ + --options runtime \ + "$BUILD_PATH" +else + echo "✅ 找到签名身份: $IDENTITY" + echo "" + echo "🔐 签名应用..." + codesign --force --sign "$IDENTITY" \ + --entitlements "$PROJECT_DIR/LCTMac/LCTMac.entitlements" \ + --options runtime \ + "$BUILD_PATH" +fi + +echo "" +echo "📋 验证签名..." +codesign -dv "$BUILD_PATH" 2>&1 + +echo "" +echo "✅ 构建和签名完成!" +echo "" +echo "🚀 运行应用:" +echo " $BUILD_PATH" +echo "" +echo "⚠️ 首次运行时,请在系统弹出的对话框中允许屏幕录制权限" diff --git a/macos/Scripts/diagnose_permissions.sh b/macos/Scripts/diagnose_permissions.sh new file mode 100755 index 0000000..4d18e2b --- /dev/null +++ b/macos/Scripts/diagnose_permissions.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# 诊断 macOS 屏幕录制权限问题 + +echo "🔍 LCT macOS 权限诊断" +echo "====================" +echo "" + +# 系统信息 +echo "📱 系统信息:" +sw_vers +echo "" + +# 检查应用签名 +echo "🔐 应用签名信息:" +APP_PATH="$HOME/MyProject/LCT/macos/.build/debug/LCTMac" +if [ -f "$APP_PATH" ]; then + codesign -dv --verbose=4 "$APP_PATH" 2>&1 | head -20 +else + echo "找不到应用: $APP_PATH" + echo "尝试其他路径..." + find "$HOME/MyProject/LCT/macos/.build" -name "LCTMac" -type f 2>/dev/null | head -5 +fi +echo "" + +# 检查 TCC 数据库 +echo "🔒 TCC 权限数据库检查:" +echo "(注意: 这需要关闭 SIP 或使用完全磁盘访问权限才能读取)" +echo "" + +# 用户 TCC 数据库 +USER_TCC="$HOME/Library/Application Support/com.apple.TCC/TCC.db" +if [ -f "$USER_TCC" ]; then + echo "用户 TCC 数据库存在: $USER_TCC" + echo "" + echo "屏幕录制权限条目:" + sqlite3 "$USER_TCC" "SELECT client, auth_value, auth_reason FROM access WHERE service='kTCCServiceScreenCapture'" 2>/dev/null || echo "无法读取 (可能需要完全磁盘访问权限)" +else + echo "用户 TCC 数据库不存在" +fi +echo "" + +# 检查当前运行的进程 +echo "📋 当前运行的 LCT 相关进程:" +ps aux | grep -i "LCTMac\|swift" | grep -v grep +echo "" + +# 建议 +echo "💡 诊断建议:" +echo "============" +echo "" +echo "1. 重置所有屏幕录制权限:" +echo " tccutil reset ScreenCapture" +echo "" +echo "2. 重新运行应用,当系统弹出权限请求时点击'允许'" +echo "" +echo "3. 如果权限请求没有弹出,手动添加:" +echo " 打开 系统设置 > 隐私与安全性 > 屏幕与系统音频录制" +echo " 点击 + 添加应用" +echo "" +echo "4. 如果使用 Xcode 运行,确保 Xcode 也有屏幕录制权限" +echo "" +echo "5. 如果权限已添加但仍不工作:" +echo " - 先关闭权限开关" +echo " - 完全退出应用 (Cmd+Q)" +echo " - 重新打开权限开关" +echo " - 重新启动应用" +echo "" +echo "6. 最后手段 - 删除旧的权限条目并重新授权:" +echo " tccutil reset ScreenCapture com.apple.dt.Xcode" +echo " tccutil reset ScreenCapture" +echo "" + +# 检查 Xcode 权限 +echo "🔧 检查 Xcode 运行时权限:" +if pgrep -x "Xcode" > /dev/null; then + echo "Xcode 正在运行" +else + echo "Xcode 未运行" +fi + +# 实时日志 +echo "" +echo "📝 实时监控权限请求 (按 Ctrl+C 停止):" +echo "log stream --predicate 'subsystem == \"com.apple.TCC\"' --level debug" +echo "" +echo "运行上面的命令可以看到实时的 TCC 权限请求日志" diff --git a/macos/Scripts/run_tests.sh b/macos/Scripts/run_tests.sh new file mode 100755 index 0000000..ddf9d76 --- /dev/null +++ b/macos/Scripts/run_tests.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# LCT macOS test runner. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +cd "$PROJECT_DIR" + +if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then + export DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" +fi + +echo "LCT macOS test runner" +echo "====================" +echo "Project: $PROJECT_DIR" +echo "Developer dir: ${DEVELOPER_DIR:-$(xcode-select -p 2>/dev/null || echo unknown)}" +echo "" + +run_step() { + local name="$1" + shift + + echo -e "${YELLOW}==>${NC} $name" + "$@" + echo -e "${GREEN}✓${NC} $name" + echo "" +} + +run_step "swift build" swift build +run_step "swift build -c release" swift build -c release +run_step "swift build with warnings as errors" swift build -Xswiftc -warnings-as-errors +run_step "swift test" swift test + +if curl -fsS --max-time 3 http://localhost:11434/api/tags >/dev/null 2>&1; then + echo -e "${GREEN}✓${NC} Ollama service reachable at http://localhost:11434" +else + echo -e "${YELLOW}!${NC} Ollama service not reachable. App can auto-start it, but live integration tests were skipped." +fi diff --git a/macos/Scripts/setup.sh b/macos/Scripts/setup.sh new file mode 100755 index 0000000..d4c9e1a --- /dev/null +++ b/macos/Scripts/setup.sh @@ -0,0 +1,147 @@ +#!/bin/bash +# LCT for macOS - Setup Script +# This script sets up the development environment + +set -e + +echo "======================================" +echo " LCT for macOS - Environment Setup" +echo "======================================" +echo "" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Check macOS version +echo "Checking macOS version..." +OS_VERSION=$(sw_vers -productVersion) +MAJOR_VERSION=$(echo $OS_VERSION | cut -d. -f1) + +if [ "$MAJOR_VERSION" -lt "15" ]; then + echo -e "${RED}Error: macOS 15 (Sequoia) or later is required${NC}" + echo "Current version: $OS_VERSION" + exit 1 +fi +echo -e "${GREEN}✓ macOS $OS_VERSION${NC}" + +# Check Xcode +echo "" +echo "Checking Xcode..." +if ! command -v xcodebuild &> /dev/null; then + echo -e "${RED}Error: Xcode is not installed${NC}" + echo "Please install Xcode from the App Store" + exit 1 +fi +XCODE_VERSION=$(xcodebuild -version | head -1) +echo -e "${GREEN}✓ $XCODE_VERSION${NC}" + +# Check Python +echo "" +echo "Checking Python..." +if command -v python3 &> /dev/null; then + PYTHON_VERSION=$(python3 --version) + echo -e "${GREEN}✓ $PYTHON_VERSION${NC}" + PYTHON_PATH=$(which python3) +else + echo -e "${YELLOW}Warning: Python 3 not found${NC}" + echo "Python is required for Whisper speech recognition" + echo "Install via: brew install python@3.11" + PYTHON_PATH="" +fi + +# Check pip +echo "" +echo "Checking pip..." +if command -v pip3 &> /dev/null; then + PIP_VERSION=$(pip3 --version | cut -d' ' -f1-2) + echo -e "${GREEN}✓ $PIP_VERSION${NC}" +else + echo -e "${YELLOW}Warning: pip not found${NC}" +fi + +# Check Ollama +echo "" +echo "Checking Ollama..." +if command -v ollama &> /dev/null; then + OLLAMA_VERSION=$(ollama --version 2>&1 | head -1) + echo -e "${GREEN}✓ Ollama installed${NC}" + + # Check if Ollama is running + if curl -s http://localhost:11434/api/tags > /dev/null 2>&1; then + echo -e "${GREEN}✓ Ollama is running${NC}" + else + echo -e "${YELLOW}! Ollama is not running${NC}" + echo " Start with: ollama serve" + fi +else + echo -e "${YELLOW}Warning: Ollama not installed${NC}" + echo "Install via: brew install ollama" + echo "Or download from: https://ollama.ai" +fi + +# Setup Python environment +echo "" +echo "======================================" +echo "Setting up Python environment..." +echo "======================================" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WHISPER_DIR="$SCRIPT_DIR/../WhisperEngine" + +if [ -d "$WHISPER_DIR" ]; then + cd "$WHISPER_DIR" + + # Create virtual environment + if [ ! -d "venv" ]; then + echo "Creating virtual environment..." + python3 -m venv venv + fi + + # Activate and install dependencies + echo "Installing Python dependencies..." + source venv/bin/activate + pip install --upgrade pip + pip install -r requirements.txt + deactivate + + echo -e "${GREEN}✓ Python environment ready${NC}" +else + echo -e "${YELLOW}Warning: WhisperEngine directory not found${NC}" +fi + +# Download default Whisper model +echo "" +echo "======================================" +echo "Downloading Whisper model..." +echo "======================================" + +if [ -d "$WHISPER_DIR" ]; then + cd "$WHISPER_DIR" + source venv/bin/activate + python model_manager.py download base + deactivate + echo -e "${GREEN}✓ Whisper base model downloaded${NC}" +fi + +# Summary +echo "" +echo "======================================" +echo " Setup Complete!" +echo "======================================" +echo "" +echo "Next steps:" +echo "1. Open macos/LCTMac in Xcode" +echo "2. Build and run the app" +echo "3. Grant screen capture and microphone permissions when prompted" +echo "" +echo "To start the WhisperEngine manually:" +echo " cd $WHISPER_DIR" +echo " source venv/bin/activate" +echo " python main.py --model base" +echo "" +echo "To pull a translation model in Ollama:" +echo " ollama pull qwen3.5:4b-mlx" +echo "" diff --git a/macos/TESTING.md b/macos/TESTING.md new file mode 100644 index 0000000..7d9d3da --- /dev/null +++ b/macos/TESTING.md @@ -0,0 +1,489 @@ +# LCT macOS 测试指南 + +本文档描述如何测试 LCT (LiveCaptions Translator) macOS 应用程序。 + +--- + +## 📑 目录 + +1. [测试概述](#测试概述) +2. [自动化单元测试](#自动化单元测试) +3. [手动功能测试](#手动功能测试) +4. [集成测试](#集成测试) +5. [性能测试](#性能测试) +6. [测试检查清单](#测试检查清单) + +--- + +## 测试概述 + +LCT 应用的测试分为以下几个层次: + +| 测试类型 | 目的 | 运行方式 | +|---------|------|---------| +| 单元测试 | 测试独立的函数和类 | 自动化 (swift test) | +| 集成测试 | 测试组件间的交互 | 手动 + 部分自动化 | +| 功能测试 | 验证用户可见的功能 | 手动测试 | +| 性能测试 | 确保性能达标 | 手动监控 | + +--- + +## 自动化单元测试 + +### 方法一:使用 Xcode 运行测试(推荐) + +1. 用 Xcode 打开项目: + ```bash + cd macos + open Package.swift + ``` + +2. 在 Xcode 中添加测试目标: + - 选择 `File` > `New` > `Target` + - 选择 `Unit Testing Bundle` + - 命名为 `LCTMacTests` + +3. 将测试文件添加到测试目标: + - 拖拽 `Tests/LCTMacTests/*.swift` 到测试目标 + +4. 使用快捷键 `⌘ + U` 运行所有测试 + +5. 或者在 Test Navigator (`⌘ + 6`) 中选择特定测试运行 + +### 方法二:创建独立测试脚本 + +```bash +# 运行快速测试验证脚本 +./Scripts/run_tests.sh +``` + +### 方法三:手动验证核心逻辑 + +在 Swift REPL 中测试核心函数: +```bash +swift repl +``` + +```swift +// 测试 CJK 检测 +let text = "Hello 世界" +text.contains { char in + for scalar in char.unicodeScalars { + if (0x4E00...0x9FFF).contains(scalar.value) { return true } + } + return false +} +// 预期输出: true +``` + +### 测试覆盖的模块 + +| 模块 | 测试文件 | 测试内容 | +|------|---------|---------| +| TextUtils | `TextUtilsTests.swift` | CJK 检测、标点处理、文本处理 | +| Models | `ModelsTests.swift` | TranslationEntry、AppSettings、语言枚举 | +| OllamaService | `OllamaServiceTests.swift` | 错误类型、API 结构体 | + +### 编写新测试 + +测试文件位于 `Tests/LCTMacTests/` 目录。遵循以下命名约定: + +```swift +func test<功能>_<场景>_<预期结果>() { + // Given (准备) + let input = "测试数据" + + // When (执行) + let result = functionToTest(input) + + // Then (断言) + XCTAssertEqual(result, expectedValue) +} +``` + +--- + +## 手动功能测试 + +### 1. 首次启动测试 + +**测试目标**:验证首次使用向导正常工作 + +**步骤**: +1. 删除所有 LCT 数据(模拟首次安装): + ```bash + rm -rf ~/Library/Application\ Support/LCT + rm ~/Library/Preferences/com.yourcompany.LCT.plist + ``` + +2. 启动应用 + +3. **验证**: + - [ ] 显示欢迎界面 + - [ ] 权限请求对话框正确弹出 + - [ ] Ollama 检测正常工作 + - [ ] 可以下载/选择 AI 模型 + - [ ] 完成设置后进入主界面 + +### 2. 权限测试 + +**测试目标**:验证权限请求和权限缺失处理 + +**步骤**: +1. 在系统设置中撤销 LCT 的麦克风权限 +2. 启动应用并尝试捕获麦克风音频 +3. **验证**: + - [ ] 应用显示权限缺失提示 + - [ ] 提供引导用户授权的说明 + +重复测试屏幕录制权限和语音识别权限。 + +### 3. 语音识别测试 + +**测试目标**:验证语音识别功能 + +**步骤**: +1. 启动应用 +2. 选择源语言(如 English) +3. 点击 "Start" 按钮 +4. 播放或说出一段英语 + +**验证**: +- [ ] 音频电平指示器有反应 +- [ ] 识别结果显示在主窗口 +- [ ] 识别准确度可接受(>80%) +- [ ] 实时更新识别内容 + +**多语言测试矩阵**: + +| 源语言 | 测试内容 | 预期准确率 | +|--------|---------|-----------| +| en-US | "Hello, how are you today?" | >90% | +| zh-CN | "今天天气怎么样?" | >85% | +| ja-JP | "こんにちは" | >85% | +| ko-KR | "안녕하세요" | >80% | + +### 4. 翻译测试 + +**测试目标**:验证 Ollama 翻译功能 + +**前置条件**: +- Ollama 服务正在运行 +- AI 模型已下载 + +**步骤**: +1. 确认 Ollama 状态(绿色指示器) +2. 设置目标语言为中文 +3. 开始捕获并说英语 +4. 等待翻译结果 + +**验证**: +- [ ] 翻译结果显示在识别文本下方 +- [ ] 延迟时间显示正确 +- [ ] 翻译质量可接受 +- [ ] 上下文感知翻译工作正常(连续句子翻译更准确) + +**翻译质量测试**: + +| 原文 | 目标语言 | 预期翻译(示例) | +|------|---------|-----------------| +| "Good morning" | 中文 | "早上好" | +| "Thank you very much" | 日语 | "ありがとうございます" | +| "How can I help you?" | 中文 | "我能帮你什么?" | + +### 5. 悬浮窗口测试 + +**测试目标**:验证悬浮窗口功能 + +**步骤**: +1. 启用悬浮窗口(`⌘ + O` 或设置) +2. 开始实时翻译 +3. 测试以下功能: + +**验证**: +- [ ] 悬浮窗口始终置顶 +- [ ] 可以拖动到任意位置 +- [ ] 可以调整大小 +- [ ] 显示最近的翻译内容 +- [ ] 透明度设置生效 +- [ ] 字体大小设置生效 +- [ ] 在全屏应用中仍然可见 + +### 6. 历史记录测试 + +**测试目标**:验证历史记录功能 + +**步骤**: +1. 进行一些翻译操作 +2. 打开历史窗口(`⌘ + ⇧ + H`) + +**验证**: +- [ ] 历史记录正确保存 +- [ ] 搜索功能正常工作 +- [ ] 可以按日期筛选 +- [ ] 可以复制单条记录 +- [ ] CSV 导出功能正常 +- [ ] 清除历史功能正常 + +### 7. 设置测试 + +**测试目标**:验证所有设置选项 + +**测试设置项**: + +| 设置项 | 测试方法 | 预期行为 | +|--------|---------|---------| +| 源语言 | 切换语言后识别 | 识别对应语言 | +| 目标语言 | 切换后翻译 | 翻译到新语言 | +| Ollama 模型 | 切换模型 | 使用新模型翻译 | +| 温度 | 调整后翻译 | 翻译风格变化 | +| 悬浮窗口透明度 | 拖动滑块 | 实时预览透明度变化 | +| 字体大小 | 调整后查看 | 字体大小正确变化 | +| 上下文条目数 | 调整后翻译 | 翻译连贯性变化 | + +### 8. 快捷键测试 + +**测试目标**:验证所有快捷键功能 + +| 快捷键 | 预期功能 | 测试结果 | +|--------|---------|---------| +| `⌘ + Space` | 开始/停止捕获 | [ ] | +| `⌘ + P` | 暂停/继续 | [ ] | +| `⌘ + ,` | 打开设置 | [ ] | +| `⌘ + ⇧ + C` | 复制翻译 | [ ] | +| `⌘ + O` | 切换悬浮窗口 | [ ] | +| `⌘ + ⇧ + H` | 查看历史 | [ ] | +| `⌘ + W` | 关闭窗口 | [ ] | +| `⌘ + Q` | 退出应用 | [ ] | + +--- + +## 集成测试 + +### 1. Ollama 集成测试 + +**测试 Ollama 连接**: + +```bash +# 检查 Ollama 是否运行 +curl http://localhost:11434/api/tags + +# 测试模型响应 +curl http://localhost:11434/api/generate -d '{ + "model": "qwen3.5:4b-mlx", + "prompt": "Hello", + "stream": false +}' +``` + +**在应用中测试**: +1. 停止 Ollama 服务,验证应用显示"未连接" +2. 启动 Ollama 服务,验证应用自动重连 +3. 切换不同模型,验证翻译正常 + +### 2. 系统音频捕获测试 + +**步骤**: +1. 启用系统音频捕获 +2. 播放 YouTube 视频或 Zoom 会议 +3. 验证音频被正确捕获和识别 + +**注意**:需要屏幕录制权限 + +### 3. 麦克风捕获测试 + +**步骤**: +1. 启用麦克风捕获 +2. 对着麦克风说话 +3. 验证音频被正确捕获和识别 + +### 4. 同时捕获测试 + +**步骤**: +1. 同时启用系统音频和麦克风 +2. 播放音频同时说话 +3. 验证两个音频源都被正确处理 + +--- + +## 性能测试 + +### 1. 内存使用监控 + +**工具**:Activity Monitor 或 Instruments + +**测试场景**: +- 空闲状态:< 100 MB +- 正常使用(识别+翻译):< 500 MB +- 长时间运行(1小时):无明显内存增长 + +**检查内存泄漏**: +```bash +# 使用 Instruments 运行泄漏检测 +leaks -atExit -- /path/to/LCT +``` + +### 2. CPU 使用监控 + +**测试场景**: +- 空闲状态:< 5% +- 正常识别:< 30% +- 翻译处理:短暂峰值可接受 + +### 3. 翻译延迟测试 + +**测试目标**:验证翻译延迟在可接受范围内 + +| 模型 | 期望延迟 | +|------|---------| +| gemma2:2b | < 500ms | +| qwen3.5:4b-mlx | < 1000ms | +| qwen2.5:7b | < 2000ms | + +### 4. 长时间运行测试 + +**步骤**: +1. 启动应用并开始捕获 +2. 持续运行 1-2 小时 +3. 监控内存、CPU、功能稳定性 + +**验证**: +- [ ] 无内存泄漏 +- [ ] 无崩溃 +- [ ] 功能正常 + +--- + +## 测试检查清单 + +### 发布前测试清单 + +#### 核心功能 +- [ ] 首次启动向导完整运行 +- [ ] 麦克风音频捕获正常 +- [ ] 系统音频捕获正常 +- [ ] 语音识别准确(主要语言) +- [ ] 翻译功能正常 +- [ ] 悬浮窗口显示正确 +- [ ] 历史记录保存和查询正常 + +#### 设置功能 +- [ ] 所有设置项可保存和加载 +- [ ] 设置更改立即生效 +- [ ] 默认设置合理 + +#### 用户界面 +- [ ] 主窗口布局正确 +- [ ] 悬浮窗口可拖动和调整大小 +- [ ] 所有快捷键工作正常 +- [ ] 错误提示清晰易懂 + +#### 错误处理 +- [ ] Ollama 未运行时有提示 +- [ ] 权限缺失时有引导 +- [ ] 网络错误有适当处理 + +#### 性能 +- [ ] 内存使用合理 +- [ ] CPU 使用合理 +- [ ] 翻译延迟可接受 +- [ ] 长时间运行稳定 + +#### 兼容性 +- [ ] macOS 15 (Sequoia) 测试通过 +- [ ] Apple Silicon 测试通过 +- [ ] Intel Mac 测试通过 + +--- + +## 问题报告模板 + +发现问题时,请使用以下模板记录: + +```markdown +### 问题标题 +简短描述问题 + +### 环境 +- macOS 版本: +- LCT 版本: +- 硬件:Apple Silicon / Intel + +### 复现步骤 +1. 第一步 +2. 第二步 +3. ... + +### 预期行为 +应该发生什么 + +### 实际行为 +实际发生了什么 + +### 截图/日志 +附加相关截图或日志 + +### 备注 +其他相关信息 +``` + +--- + +## 常见测试问题 + +### 测试编译失败 + +```bash +# 清理并重新构建 +swift package clean +swift build +swift test +``` + +### 权限问题 + +确保在系统设置中授予 Terminal/Xcode 相应权限进行测试。 + +### Ollama 相关测试失败 + +确保 Ollama 服务正在运行: +```bash +ollama serve +``` + +并且测试所需的模型已下载: +```bash +ollama pull qwen3.5:4b-mlx +``` + +--- + +## 持续集成 (CI) + +### GitHub Actions 配置示例 + +```yaml +name: Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Build + run: swift build -c release + working-directory: macos + + - name: Run Tests + run: swift test + working-directory: macos +``` + +**注意**:由于 LCT 依赖系统权限(麦克风、屏幕录制),完整的集成测试需要在本地运行。CI 主要运行单元测试。 + +--- + +**Happy Testing! 🧪** diff --git a/macos/Tests/LCTMacTests/AppNoticeTests.swift b/macos/Tests/LCTMacTests/AppNoticeTests.swift new file mode 100644 index 0000000..e5633d1 --- /dev/null +++ b/macos/Tests/LCTMacTests/AppNoticeTests.swift @@ -0,0 +1,87 @@ +import XCTest +@testable import LCTMac + +final class AppNoticeTests: XCTestCase { + + // MARK: - Builders + + func testInfoBuilderAutoDismisses() { + let notice = AppNotice.info("Loading…") + XCTAssertEqual(notice.severity, .info) + XCTAssertTrue(notice.autoDismiss) + XCTAssertTrue(notice.actions.isEmpty) + } + + func testWarningDefaultsToAutoDismiss() { + XCTAssertTrue(AppNotice.warning("heads up").autoDismiss) + XCTAssertFalse(AppNotice.warning("persist", autoDismiss: false).autoDismiss) + } + + func testErrorDoesNotAutoDismissAndCarriesActions() { + let notice = AppNotice.error("boom", actions: [.retryCapture, .openAppSettings]) + XCTAssertEqual(notice.severity, .error) + XCTAssertFalse(notice.autoDismiss) + XCTAssertEqual(notice.actions, [.retryCapture, .openAppSettings]) + } + + // MARK: - Identity + + func testEachNoticeHasUniqueID() { + let a = AppNotice.error("same") + let b = AppNotice.error("same") + XCTAssertNotEqual(a, b, "Distinct constructions must differ so auto-dismiss targets the right instance") + XCTAssertEqual(a, a) + } + + // MARK: - Action labels + + func testPermissionActionsShareSystemSettingsLabel() { + XCTAssertEqual(NoticeAction.openScreenRecordingSettings.label, "Open System Settings") + XCTAssertEqual(NoticeAction.openMicrophoneSettings.label, "Open System Settings") + XCTAssertEqual(NoticeAction.openSpeechRecognitionSettings.label, "Open System Settings") + } + + func testActionLabelsAreDistinctWhereExpected() { + XCTAssertEqual(NoticeAction.startOllama.label, "Start Ollama") + XCTAssertEqual(NoticeAction.retryCapture.label, "Retry") + XCTAssertEqual(NoticeAction.openAppSettings.label, "Settings") + } + + func testActionIDsAreUnique() { + let actions: [NoticeAction] = [ + .openScreenRecordingSettings, .openMicrophoneSettings, + .openSpeechRecognitionSettings, .startOllama, + .openAppSettings, .retryCapture, + ] + XCTAssertEqual(Set(actions.map(\.id)).count, actions.count) + } + + // MARK: - ViewModel dispatch + + @MainActor + func testDismissNoticeClearsIt() { + let viewModel = TranscriptionViewModel() + viewModel.notice = .error("boom") + viewModel.dismissNotice() + XCTAssertNil(viewModel.notice) + } + + @MainActor + func testRetryCaptureClearsNoticeImmediately() { + let viewModel = TranscriptionViewModel() + viewModel.notice = .error("boom", actions: [.retryCapture]) + // retryCapture clears the notice synchronously before the async start() + viewModel.perform(.retryCapture) + XCTAssertNil(viewModel.notice) + } + + @MainActor + func testOpenAppSettingsIsNoOpInViewModel() { + let viewModel = TranscriptionViewModel() + let original = AppNotice.error("config", actions: [.openAppSettings]) + viewModel.notice = original + // The view intercepts .openAppSettings; the VM must leave state untouched + viewModel.perform(.openAppSettings) + XCTAssertEqual(viewModel.notice, original) + } +} diff --git a/macos/Tests/LCTMacTests/CaptionSegmenterTests.swift b/macos/Tests/LCTMacTests/CaptionSegmenterTests.swift new file mode 100644 index 0000000..dd7f56e --- /dev/null +++ b/macos/Tests/LCTMacTests/CaptionSegmenterTests.swift @@ -0,0 +1,163 @@ +import XCTest +@testable import LCTMac + +final class CaptionSegmenterTests: XCTestCase { + + // MARK: - Force cut (no punctuation) + + func testCJKLongVolatileTextCutsAtCharacterBoundary() { + let segmenter = CaptionSegmenter() + let text = String(repeating: "中", count: 121) + + let result = segmenter.process(result: TranscriptionResult( + text: text, + isVolatile: true + )) + + XCTAssertEqual(result.finalized.count, 1) + XCTAssertEqual(result.finalized.first?.count, 100) + XCTAssertEqual(result.liveDraft.count, 21) + XCTAssertEqual(result.invalidatedTailCount, 0) + } + + func testCJKLongVolatileTextDoesNotSplitExtendedGraphemeClusters() { + let segmenter = CaptionSegmenter() + let text = String(repeating: "中", count: 99) + "👨‍👩‍👧‍👦" + String(repeating: "文", count: 60) + + let result = segmenter.process(result: TranscriptionResult( + text: text, + isVolatile: true + )) + + XCTAssertEqual(result.finalized.count, 1) + XCTAssertEqual(result.finalized.first?.count, 100) + XCTAssertTrue(result.finalized.first?.hasSuffix("👨‍👩‍👧‍👦") == true) + XCTAssertEqual(result.liveDraft.count, 60) + } + + // MARK: - Eager cut (completed sentence while speech continues) + + func testEagerCutAfterCompletedSentenceWithTrailingSpeech() { + let segmenter = CaptionSegmenter() + let sentence = "This is a complete sentence that is clearly over forty characters." + let text = sentence + " And the speaker keeps" + + let result = segmenter.process(result: TranscriptionResult( + text: text, + isVolatile: true + )) + + XCTAssertEqual(result.finalized, [sentence], + "A completed sentence followed by new speech must be cut immediately") + XCTAssertEqual(result.liveDraft, "And the speaker keeps") + } + + func testNoEagerCutForShortSentence() { + // Quiet interval set high so only the eager path could trigger + let segmenter = CaptionSegmenter(quietCutInterval: 999) + let text = "OK. And then we" + + let result = segmenter.process(result: TranscriptionResult( + text: text, + isVolatile: true + )) + + XCTAssertTrue(result.finalized.isEmpty, + "Short fragments must accumulate instead of becoming tiny segments") + XCTAssertEqual(result.liveDraft, text) + } + + func testNoCutWhilePunctuationIsTrailingAndDraftIsFresh() { + let segmenter = CaptionSegmenter() + let text = "This sentence just ended and might still be revised by the recognizer." + + let result = segmenter.process(result: TranscriptionResult( + text: text, + isVolatile: true + )) + + XCTAssertTrue(result.finalized.isEmpty, + "A sentence with trailing punctuation needs a quiet period before committing") + } + + // MARK: - Quiet cut (pause in recognition updates) + + func testQuietCutAfterRecognitionPause() { + let segmenter = CaptionSegmenter(quietCutInterval: 0.05) + let taskId = UUID() + let text = "This sentence ended and the speaker paused." + + let first = segmenter.process(result: TranscriptionResult(id: taskId, text: text, isVolatile: true)) + XCTAssertTrue(first.finalized.isEmpty) + + Thread.sleep(forTimeInterval: 0.1) + + let second = segmenter.process(result: TranscriptionResult(id: taskId, text: text, isVolatile: true)) + XCTAssertEqual(second.finalized, [text]) + XCTAssertEqual(second.liveDraft, "") + } + + // MARK: - Rollback + + func testFullRollbackWhenAllCommittedTextRevised() { + let segmenter = CaptionSegmenter() + let taskId = UUID() + let originalText = String(repeating: "中", count: 121) + + let firstResult = segmenter.process(result: TranscriptionResult( + id: taskId, + text: originalText, + isVolatile: true + )) + XCTAssertEqual(firstResult.finalized.count, 1) + XCTAssertEqual(firstResult.invalidatedTailCount, 0) + + let correctedText = String(repeating: "文", count: 80) + let rollbackResult = segmenter.process(result: TranscriptionResult( + id: taskId, + text: correctedText, + isVolatile: true + )) + + XCTAssertEqual(rollbackResult.invalidatedTailCount, 1) + XCTAssertTrue(rollbackResult.finalized.isEmpty) + XCTAssertEqual(rollbackResult.liveDraft, correctedText) + } + + func testPartialRollbackKeepsSegmentsInsideUnchangedPrefix() { + let segmenter = CaptionSegmenter() + let taskId = UUID() + let s1 = "The first sentence is long enough to pass the eager threshold." + let s2 = "The second sentence is also long enough to pass the threshold." + + // Commit S1 (eager cut: completed sentence + trailing speech) + let r1 = segmenter.process(result: TranscriptionResult( + id: taskId, text: s1 + " More", isVolatile: true + )) + XCTAssertEqual(r1.finalized, [s1]) + + // Commit S2 the same way + let r2 = segmenter.process(result: TranscriptionResult( + id: taskId, text: s1 + " " + s2 + " Tail", isVolatile: true + )) + XCTAssertEqual(r2.finalized, [s2]) + + // ASR revises only S2 — S1 must survive, exactly one segment revoked + let r3 = segmenter.process(result: TranscriptionResult( + id: taskId, text: s1 + " Completely different revision", isVolatile: true + )) + XCTAssertEqual(r3.invalidatedTailCount, 1, + "Only the segment containing the revision may be rolled back") + XCTAssertTrue(r3.finalized.isEmpty) + XCTAssertEqual(r3.liveDraft, "Completely different revision") + + // Pipeline must keep working after a partial rollback + let r4 = segmenter.process(result: TranscriptionResult( + id: taskId, + text: s1 + " Completely different revision that has grown long enough to end. Next", + isVolatile: true + )) + XCTAssertEqual(r4.finalized, ["Completely different revision that has grown long enough to end."]) + XCTAssertEqual(r4.invalidatedTailCount, 0) + } +} diff --git a/macos/Tests/LCTMacTests/HistoryServiceTests.swift b/macos/Tests/LCTMacTests/HistoryServiceTests.swift new file mode 100644 index 0000000..98e3ea5 --- /dev/null +++ b/macos/Tests/LCTMacTests/HistoryServiceTests.swift @@ -0,0 +1,221 @@ +import XCTest +@testable import LCTMac + +/// Tests for HistoryService SQLite CRUD operations +final class HistoryServiceTests: XCTestCase { + + var historyService: HistoryService! + var databaseDirectory: URL! + + override func setUpWithError() throws { + try super.setUpWithError() + databaseDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("LCTMacTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: databaseDirectory, withIntermediateDirectories: true) + let databasePath = databaseDirectory.appendingPathComponent("history.sqlite").path + historyService = HistoryService(databasePath: databasePath) + } + + override func tearDownWithError() throws { + historyService = nil + if let databaseDirectory { + try? FileManager.default.removeItem(at: databaseDirectory) + } + try super.tearDownWithError() + } + + // MARK: - Create Tests + + func testLogTranslation() throws { + let entry = makeEntry(source: "Hello", translated: "你好") + + try historyService.logTranslation(entry) + + let count = try historyService.getCount() + XCTAssertGreaterThanOrEqual(count, 1) + } + + func testLogMultipleTranslations() throws { + let entries = [ + makeEntry(source: "Hello", translated: "你好"), + makeEntry(source: "World", translated: "世界"), + makeEntry(source: "Good morning", translated: "早上好"), + ] + + for entry in entries { + try historyService.logTranslation(entry) + } + + let count = try historyService.getCount() + XCTAssertGreaterThanOrEqual(count, 3) + } + + // MARK: - Read Tests + + func testLoadRecentTranslations() throws { + try historyService.clearHistory() + + let entries = (1...5).map { i in + makeEntry(source: "Text \(i)", translated: "翻译 \(i)") + } + for entry in entries { + try historyService.logTranslation(entry) + } + + let loaded = try historyService.loadRecentTranslations(limit: 3) + XCTAssertEqual(loaded.count, 3) + } + + func testLoadAllTranslations() throws { + try historyService.clearHistory() + + let entries = (1...3).map { i in + makeEntry(source: "Text \(i)", translated: "翻译 \(i)") + } + for entry in entries { + try historyService.logTranslation(entry) + } + + let loaded = try historyService.loadAllTranslations() + XCTAssertEqual(loaded.count, 3) + } + + // MARK: - Search Tests + + func testSearchBySourceText() throws { + try historyService.clearHistory() + + try historyService.logTranslation(makeEntry(source: "Hello world", translated: "你好世界")) + try historyService.logTranslation(makeEntry(source: "Goodbye", translated: "再见")) + + let results = try historyService.searchTranslations(query: "Hello") + XCTAssertEqual(results.count, 1) + XCTAssertEqual(results.first?.sourceText, "Hello world") + } + + func testSearchByTranslatedText() throws { + try historyService.clearHistory() + + try historyService.logTranslation(makeEntry(source: "Hello", translated: "你好")) + try historyService.logTranslation(makeEntry(source: "World", translated: "世界")) + + let results = try historyService.searchTranslations(query: "世界") + XCTAssertEqual(results.count, 1) + XCTAssertEqual(results.first?.translatedText, "世界") + } + + func testSearchNoResults() throws { + try historyService.clearHistory() + + try historyService.logTranslation(makeEntry(source: "Hello", translated: "你好")) + + let results = try historyService.searchTranslations(query: "nonexistent") + XCTAssertTrue(results.isEmpty) + } + + // MARK: - Delete Tests + + func testDeleteById() throws { + try historyService.clearHistory() + + let entry = makeEntry(source: "Delete me", translated: "删除我") + try historyService.logTranslation(entry) + + let countBefore = try historyService.getCount() + XCTAssertEqual(countBefore, 1) + + try historyService.deleteTranslation(withId: entry.id) + + let countAfter = try historyService.getCount() + XCTAssertEqual(countAfter, 0) + } + + func testClearHistory() throws { + for i in 1...5 { + try historyService.logTranslation(makeEntry(source: "Text \(i)", translated: "翻译 \(i)")) + } + + try historyService.clearHistory() + + let count = try historyService.getCount() + XCTAssertEqual(count, 0) + } + + func testPruneHistory_RemovesOldEntriesAndKeepsNewestMaxEntries() throws { + let oldDate = Calendar.current.date(byAdding: .day, value: -10, to: Date())! + + try historyService.logTranslation(makeEntry(source: "Old", translated: "旧", timestamp: oldDate)) + for i in 1...5 { + try historyService.logTranslation(makeEntry(source: "Recent \(i)", translated: "最近 \(i)")) + } + + try historyService.pruneHistory(retentionDays: 7, maxEntries: 3) + + let loaded = try historyService.loadAllTranslations() + XCTAssertEqual(loaded.count, 3) + XCTAssertFalse(loaded.contains { $0.sourceText == "Old" }) + } + + func testGetLastTranslation() throws { + try historyService.clearHistory() + + try historyService.logTranslation(makeEntry(source: "First", translated: "第一")) + try historyService.logTranslation(makeEntry(source: "Last", translated: "最后")) + + let last = try historyService.getLastTranslation() + XCTAssertNotNil(last) + XCTAssertEqual(last?.sourceText, "Last") + } + + // MARK: - Export Tests + + func testExportToCSV() throws { + try historyService.clearHistory() + + try historyService.logTranslation(makeEntry(source: "Hello", translated: "你好")) + + let csv = try historyService.exportToCSV() + + // Should contain header + XCTAssertTrue(csv.contains("Source Text")) + XCTAssertTrue(csv.contains("Translated Text")) + // Should contain data + XCTAssertTrue(csv.contains("Hello")) + XCTAssertTrue(csv.contains("你好")) + } + + // MARK: - Async Tests + + func testLogTranslationAsync() async throws { + let entry = makeEntry(source: "Async hello", translated: "异步你好") + + try await historyService.logTranslationAsync(entry) + + let count = try historyService.getCount() + XCTAssertGreaterThanOrEqual(count, 1) + } + + func testLoadRecentTranslationsAsync() async throws { + try historyService.clearHistory() + + for i in 1...3 { + try historyService.logTranslation(makeEntry(source: "Async \(i)", translated: "异步 \(i)")) + } + + let loaded = try await historyService.loadRecentTranslationsAsync(limit: 10) + XCTAssertGreaterThanOrEqual(loaded.count, 3) + } + + // MARK: - Helpers + + private func makeEntry(source: String, translated: String, timestamp: Date = Date()) -> TranslationEntry { + TranslationEntry( + sourceText: source, + translatedText: translated, + speaker: nil, + targetLanguage: "Chinese (Simplified)", + timestamp: timestamp, + latencyMs: 100 + ) + } +} diff --git a/macos/Tests/LCTMacTests/ModelsTests.swift b/macos/Tests/LCTMacTests/ModelsTests.swift new file mode 100644 index 0000000..c7191d7 --- /dev/null +++ b/macos/Tests/LCTMacTests/ModelsTests.swift @@ -0,0 +1,336 @@ +import XCTest +@testable import LCTMac + +/// Tests for Models +final class ModelsTests: XCTestCase { + + // MARK: - TranslationEntry Tests + + func testTranslationEntry_Initialization() { + let entry = TranslationEntry( + sourceText: "Hello", + translatedText: "你好", + speaker: "Speaker 1", + targetLanguage: "Chinese", + latencyMs: 150 + ) + + XCTAssertEqual(entry.sourceText, "Hello") + XCTAssertEqual(entry.translatedText, "你好") + XCTAssertEqual(entry.speaker, "Speaker 1") + XCTAssertEqual(entry.targetLanguage, "Chinese") + XCTAssertEqual(entry.latencyMs, 150) + XCTAssertNotNil(entry.id) + } + + func testTranslationEntry_FormattedLatency() { + let entry = TranslationEntry( + sourceText: "Test", + translatedText: "测试", + targetLanguage: "Chinese", + latencyMs: 250 + ) + + XCTAssertEqual(entry.formattedLatency, "250 ms") + } + + func testTranslationEntry_FormattedTimestamp() { + let entry = TranslationEntry( + sourceText: "Test", + translatedText: "测试", + targetLanguage: "Chinese" + ) + + // Just verify it returns a non-empty string + XCTAssertFalse(entry.formattedTimestamp.isEmpty) + } + + func testTranslationEntry_Equatable() { + let id = UUID() + let date = Date() + + let entry1 = TranslationEntry( + id: id, + sourceText: "Hello", + translatedText: "你好", + targetLanguage: "Chinese", + timestamp: date, + latencyMs: 100 + ) + + let entry2 = TranslationEntry( + id: id, + sourceText: "Hello", + translatedText: "你好", + targetLanguage: "Chinese", + timestamp: date, + latencyMs: 100 + ) + + XCTAssertEqual(entry1, entry2) + } + + func testTranslationEntry_Codable() throws { + let entry = TranslationEntry( + sourceText: "Hello", + translatedText: "你好", + speaker: "Test Speaker", + targetLanguage: "Chinese", + latencyMs: 200 + ) + + // Encode + let encoder = JSONEncoder() + let data = try encoder.encode(entry) + + // Decode + let decoder = JSONDecoder() + let decoded = try decoder.decode(TranslationEntry.self, from: data) + + XCTAssertEqual(entry.id, decoded.id) + XCTAssertEqual(entry.sourceText, decoded.sourceText) + XCTAssertEqual(entry.translatedText, decoded.translatedText) + XCTAssertEqual(entry.speaker, decoded.speaker) + XCTAssertEqual(entry.targetLanguage, decoded.targetLanguage) + XCTAssertEqual(entry.latencyMs, decoded.latencyMs) + } + + // MARK: - TranslationHistory Tests + + func testTranslationHistory_Add() { + var history = TranslationHistory() + + let entry = TranslationEntry( + sourceText: "Test", + translatedText: "测试", + targetLanguage: "Chinese" + ) + + history.add(entry) + + XCTAssertEqual(history.entries.count, 1) + XCTAssertEqual(history.entries.first?.sourceText, "Test") + } + + func testTranslationHistory_Search() { + var history = TranslationHistory() + + history.add(TranslationEntry( + sourceText: "Hello world", + translatedText: "你好世界", + targetLanguage: "Chinese" + )) + + history.add(TranslationEntry( + sourceText: "Good morning", + translatedText: "早上好", + targetLanguage: "Chinese" + )) + + let results = history.search("hello") + XCTAssertEqual(results.count, 1) + XCTAssertEqual(results.first?.sourceText, "Hello world") + } + + func testTranslationHistory_SearchTranslatedText() { + var history = TranslationHistory() + + history.add(TranslationEntry( + sourceText: "Hello", + translatedText: "你好世界", + targetLanguage: "Chinese" + )) + + let results = history.search("世界") + XCTAssertEqual(results.count, 1) + } + + func testTranslationHistory_DateRange() { + var history = TranslationHistory() + let now = Date() + let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now)! + let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: now)! + + history.add(TranslationEntry( + sourceText: "Today's entry", + translatedText: "今天的条目", + targetLanguage: "Chinese", + timestamp: now + )) + + let results = history.entries(from: yesterday, to: tomorrow) + XCTAssertEqual(results.count, 1) + } + + func testTranslationHistory_ExportToCSV() { + var history = TranslationHistory() + + history.add(TranslationEntry( + sourceText: "Hello", + translatedText: "你好", + speaker: "Speaker 1", + targetLanguage: "Chinese", + latencyMs: 100 + )) + + let csv = history.exportToCSV() + + XCTAssertTrue(csv.contains("Source Text")) + XCTAssertTrue(csv.contains("Translated Text")) + XCTAssertTrue(csv.contains("Hello")) + XCTAssertTrue(csv.contains("你好")) + } + + func testTranslationHistory_ExportToCSV_EscapesAllTextFields() { + var history = TranslationHistory() + + history.add(TranslationEntry( + sourceText: "Hello, \"team\"", + translatedText: "你好\n团队", + speaker: "Speaker \"A\"", + targetLanguage: "Chinese, Simplified", + latencyMs: 100 + )) + + let csv = history.exportToCSV() + + XCTAssertTrue(csv.contains("\"Hello, \"\"team\"\"\"")) + XCTAssertTrue(csv.contains("\"你好\n团队\"")) + XCTAssertTrue(csv.contains("\"Speaker \"\"A\"\"\"")) + XCTAssertTrue(csv.contains("\"Chinese, Simplified\"")) + } + + // MARK: - SourceLanguage Tests + + func testSourceLanguage_DisplayName() { + XCTAssertEqual(SourceLanguage.english.displayName, "English (US)") + XCTAssertEqual(SourceLanguage.chinese.displayName, "Chinese (Simplified)") + XCTAssertEqual(SourceLanguage.japanese.displayName, "Japanese") + } + + func testSourceLanguage_RawValue() { + XCTAssertEqual(SourceLanguage.english.rawValue, "en-US") + XCTAssertEqual(SourceLanguage.chinese.rawValue, "zh-CN") + XCTAssertEqual(SourceLanguage.japanese.rawValue, "ja-JP") + } + + func testSourceLanguage_AllCases() { + XCTAssertEqual(SourceLanguage.allCases.count, 14) + } + + // MARK: - TargetLanguage Tests + + func testTargetLanguage_DisplayName() { + XCTAssertEqual(TargetLanguage.chinese.displayName, "Chinese") + XCTAssertEqual(TargetLanguage.english.displayName, "English") + } + + func testTargetLanguage_NativeName() { + XCTAssertEqual(TargetLanguage.chinese.nativeName, "中文") + XCTAssertEqual(TargetLanguage.japanese.nativeName, "日本語") + XCTAssertEqual(TargetLanguage.korean.nativeName, "한국어") + } + + func testTargetLanguage_AllCases() { + XCTAssertEqual(TargetLanguage.allCases.count, 10) + } + + // MARK: - AppSettings Tests + + func testAppSettings_DefaultValues() { + let settings = AppSettings() + + XCTAssertTrue(settings.captureSystemAudio) + XCTAssertTrue(settings.captureMicrophone) + XCTAssertEqual(settings.sourceLanguage, .english) + XCTAssertEqual(settings.ollamaHost, "localhost") + XCTAssertEqual(settings.ollamaPort, 11434) + XCTAssertEqual(settings.ollamaModel, "qwen3.5:4b-mlx") + XCTAssertEqual(settings.ollamaTimeout, 30) + XCTAssertEqual(settings.ollamaTemperature, 0.3) + XCTAssertEqual(settings.targetLanguage, .chinese) + XCTAssertTrue(settings.contextAware) + XCTAssertTrue(settings.liveDraftTranslation) + XCTAssertEqual(settings.maxContextEntries, 5) + XCTAssertEqual(settings.historyRetentionDays, 30) + XCTAssertEqual(settings.historyMaxEntries, 5000) + } + + func testAppSettings_OllamaURL() { + let settings = AppSettings() + + XCTAssertEqual(settings.ollamaURL, "http://localhost:11434") + XCTAssertEqual(settings.ollamaAPIEndpoint, "http://localhost:11434/api/chat") + } + + func testAppSettings_OllamaURL_CustomHostPort() { + var settings = AppSettings() + settings.ollamaHost = "192.168.1.100" + settings.ollamaPort = 8080 + + XCTAssertEqual(settings.ollamaURL, "http://192.168.1.100:8080") + XCTAssertEqual(settings.ollamaAPIEndpoint, "http://192.168.1.100:8080/api/chat") + } + + func testAppSettings_IsLocalOllama() { + var settings = AppSettings() + + settings.ollamaHost = "localhost" + XCTAssertTrue(settings.isLocalOllama) + + settings.ollamaHost = " 127.0.0.1 " + XCTAssertTrue(settings.isLocalOllama) + + settings.ollamaHost = "::1" + XCTAssertTrue(settings.isLocalOllama) + + settings.ollamaHost = "[::1]" + XCTAssertTrue(settings.isLocalOllama) + + settings.ollamaHost = "192.168.1.20" + XCTAssertFalse(settings.isLocalOllama) + } + + func testAppSettings_TranslationPrompt_Default() { + let settings = AppSettings() + let prompt = settings.translationPrompt + + XCTAssertTrue(prompt.contains("Chinese")) + XCTAssertTrue(prompt.contains("professional")) + } + + func testAppSettings_TranslationPrompt_CustomLanguage() { + var settings = AppSettings() + settings.targetLanguage = .japanese + + let prompt = settings.translationPrompt + XCTAssertTrue(prompt.contains("Japanese")) + } + + func testAppSettings_TranslationPrompt_Custom() { + var settings = AppSettings() + settings.customPrompt = "Custom prompt for {TARGET_LANGUAGE}" + + let prompt = settings.translationPrompt + XCTAssertEqual(prompt, "Custom prompt for Chinese") + } + + func testAppSettings_Codable() throws { + let settings = AppSettings() + + let encoder = JSONEncoder() + let data = try encoder.encode(settings) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(AppSettings.self, from: data) + + XCTAssertEqual(settings, decoded) + } + + func testAppSettings_Equatable() { + let settings1 = AppSettings() + let settings2 = AppSettings() + + XCTAssertEqual(settings1, settings2) + } +} diff --git a/macos/Tests/LCTMacTests/OllamaServiceIntegrationTests.swift b/macos/Tests/LCTMacTests/OllamaServiceIntegrationTests.swift new file mode 100644 index 0000000..84e3d7e --- /dev/null +++ b/macos/Tests/LCTMacTests/OllamaServiceIntegrationTests.swift @@ -0,0 +1,125 @@ +import Foundation +import XCTest +@testable import LCTMac + +@MainActor +final class OllamaServiceIntegrationTests: XCTestCase { + + override func tearDown() { + MockURLProtocol.requestHandler = nil + super.tearDown() + } + + func testTranslate_ModelMissing_ThrowsModelNotLoaded() async throws { + let service = makeService { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 404, + httpVersion: nil, + headerFields: nil + )! + return (response, Data()) + } + + do { + _ = try await service.translate(text: "Hello") + XCTFail("Expected modelNotLoaded") + } catch OllamaError.modelNotLoaded { + // Expected + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testTranslate_Timeout_ThrowsTimeout() async throws { + let service = makeService { _ in + throw URLError(.timedOut) + } + + do { + _ = try await service.translate(text: "Hello") + XCTFail("Expected timeout") + } catch OllamaError.timeout { + // Expected + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testTranslateStreaming_ConnectionLost_ThrowsServerNotRunning() async throws { + let service = makeService { _ in + throw URLError(.networkConnectionLost) + } + + do { + _ = try await service.translateStreaming(text: "Hello") { _ in } + XCTFail("Expected serverNotRunning") + } catch OllamaError.serverNotRunning { + // Expected + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testPrewarmModel_Success_ReturnsLatency() async throws { + let service = makeService { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + let body = #"{"message":{"role":"assistant","content":"ok"},"done":true}"# + return (response, Data(body.utf8)) + } + + let latency = try await service.prewarmModel() + XCTAssertGreaterThanOrEqual(latency, 0) + } + + private func makeService(handler: @escaping (URLRequest) throws -> (HTTPURLResponse, Data)) -> OllamaService { + MockURLProtocol.requestHandler = handler + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + + var settings = AppSettings() + settings.ollamaHost = "mock.local" + settings.ollamaPort = 80 + settings.ollamaModel = "missing-model" + settings.ollamaTimeout = 1 + + let session = URLSession(configuration: config) + return OllamaService(settings: settings, session: session) + } +} + +private final class MockURLProtocol: URLProtocol { + nonisolated(unsafe) static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let requestHandler = Self.requestHandler else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try requestHandler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/macos/Tests/LCTMacTests/OllamaServiceTests.swift b/macos/Tests/LCTMacTests/OllamaServiceTests.swift new file mode 100644 index 0000000..51978c9 --- /dev/null +++ b/macos/Tests/LCTMacTests/OllamaServiceTests.swift @@ -0,0 +1,137 @@ +import XCTest +@testable import LCTMac + +/// Tests for OllamaService error types +final class OllamaErrorTests: XCTestCase { + + func testOllamaError_InvalidURL_ErrorDescription() { + let error = OllamaError.invalidURL + XCTAssertEqual(error.errorDescription, "Invalid Ollama API URL") + } + + func testOllamaError_NetworkError_ErrorDescription() { + let underlyingError = NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connection refused"]) + let error = OllamaError.networkError(underlyingError) + + XCTAssertTrue(error.errorDescription?.contains("Network error") ?? false) + XCTAssertTrue(error.errorDescription?.contains("Connection refused") ?? false) + } + + func testOllamaError_InvalidResponse_ErrorDescription() { + let error = OllamaError.invalidResponse + XCTAssertEqual(error.errorDescription, "Invalid response from Ollama") + } + + func testOllamaError_HTTPError_ErrorDescription() { + let error = OllamaError.httpError(404) + XCTAssertEqual(error.errorDescription, "HTTP error: 404") + } + + func testOllamaError_DecodingError_ErrorDescription() { + let underlyingError = NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON"]) + let error = OllamaError.decodingError(underlyingError) + + XCTAssertTrue(error.errorDescription?.contains("Failed to decode") ?? false) + } + + func testOllamaError_Timeout_ErrorDescription() { + let error = OllamaError.timeout + XCTAssertEqual(error.errorDescription, "Request timed out") + } + + func testOllamaError_ServerNotRunning_ErrorDescription() { + let error = OllamaError.serverNotRunning + XCTAssertEqual(error.errorDescription, "Ollama server is not running") + } + + func testOllamaError_ModelNotLoaded_ErrorDescription() { + let error = OllamaError.modelNotLoaded + XCTAssertEqual(error.errorDescription, "Model not loaded") + } +} + +/// Tests for Ollama API Codable structures +final class OllamaAPIStructsTests: XCTestCase { + + func testOllamaMessage_Codable() throws { + let message = OllamaMessage(role: "user", content: "Hello") + + let encoder = JSONEncoder() + let data = try encoder.encode(message) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(OllamaMessage.self, from: data) + + XCTAssertEqual(decoded.role, "user") + XCTAssertEqual(decoded.content, "Hello") + } + + func testOllamaChatRequest_Codable() throws { + let request = OllamaChatRequest( + model: "qwen2.5:3b", + messages: [OllamaMessage(role: "user", content: "Test")], + stream: false, + temperature: 0.3, + keepAlive: "5m", + think: false + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(request) + + let jsonString = String(data: data, encoding: .utf8)! + + // Verify snake_case encoding + XCTAssertTrue(jsonString.contains("keep_alive")) + + // Verify values + let decoder = JSONDecoder() + let decoded = try decoder.decode(OllamaChatRequest.self, from: data) + + XCTAssertEqual(decoded.model, "qwen2.5:3b") + XCTAssertEqual(decoded.stream, false) + XCTAssertEqual(decoded.temperature, 0.3) + XCTAssertEqual(decoded.keepAlive, "5m") + XCTAssertEqual(decoded.think, false) + } + + func testOllamaChatResponse_Codable() throws { + let json = """ + { + "message": {"role": "assistant", "content": "Hello!"}, + "done": true, + "total_duration": 1000000000, + "load_duration": 100000000, + "prompt_eval_count": 10, + "prompt_eval_duration": 200000000, + "eval_count": 20, + "eval_duration": 500000000 + } + """ + + let data = json.data(using: .utf8)! + let decoder = JSONDecoder() + let response = try decoder.decode(OllamaChatResponse.self, from: data) + + XCTAssertEqual(response.message?.role, "assistant") + XCTAssertEqual(response.message?.content, "Hello!") + XCTAssertTrue(response.done) + XCTAssertEqual(response.totalDuration, 1000000000) + XCTAssertEqual(response.evalCount, 20) + } + + func testOllamaChatResponse_PartialResponse() throws { + let json = """ + { + "done": false + } + """ + + let data = json.data(using: .utf8)! + let decoder = JSONDecoder() + let response = try decoder.decode(OllamaChatResponse.self, from: data) + + XCTAssertNil(response.message) + XCTAssertFalse(response.done) + } +} diff --git a/macos/Tests/LCTMacTests/PauseResumeTests.swift b/macos/Tests/LCTMacTests/PauseResumeTests.swift new file mode 100644 index 0000000..ac2eb86 --- /dev/null +++ b/macos/Tests/LCTMacTests/PauseResumeTests.swift @@ -0,0 +1,75 @@ +import XCTest +@testable import LCTMac + +/// Tests for pause/resume translation lifecycle: +/// segments must never dangle in .translating after a pause. +@MainActor +final class PauseResumeTests: XCTestCase { + + // MARK: - TranslationState.pending model tests + + func testPendingStateCodableRoundTrip() throws { + let segment = TranslationSegment(sourceText: "Hello", state: .pending) + + let data = try JSONEncoder().encode(segment) + let decoded = try JSONDecoder().decode(TranslationSegment.self, from: data) + + XCTAssertEqual(decoded.state, .pending) + XCTAssertEqual(decoded.sourceText, "Hello") + } + + // MARK: - ViewModel pause/resume tests + + func testPauseMarksTranslatingSegmentsAsPending() { + let viewModel = TranscriptionViewModel() + viewModel.segments = [ + TranslationSegment(sourceText: "Done", translatedText: "完成", state: .translated), + TranslationSegment(sourceText: "In flight", translatedText: "部分流式输出", state: .translating), + TranslationSegment(sourceText: "Queued", state: .translating), + TranslationSegment(sourceText: "Failed", translatedText: "Error: x", state: .failed), + ] + + XCTAssertFalse(viewModel.isPaused) + viewModel.togglePause() + XCTAssertTrue(viewModel.isPaused) + + XCTAssertEqual(viewModel.segments[0].state, .translated, "Completed segments must be untouched") + XCTAssertEqual(viewModel.segments[1].state, .pending, "In-flight segment must become pending") + XCTAssertEqual(viewModel.segments[1].translatedText, "", "Partial streaming output must be discarded") + XCTAssertEqual(viewModel.segments[2].state, .pending, "Queued segment must become pending") + XCTAssertEqual(viewModel.segments[3].state, .failed, "Failed segments must be untouched") + } + + func testResumeReEnqueuesPendingSegments() { + let viewModel = TranscriptionViewModel() + viewModel.segments = [ + TranslationSegment(sourceText: "Hello world", state: .translating), + ] + + viewModel.togglePause() + XCTAssertEqual(viewModel.segments[0].state, .pending) + + viewModel.togglePause() + XCTAssertFalse(viewModel.isPaused) + XCTAssertEqual( + viewModel.segments[0].state, .translating, + "Pending segment must return to translating (re-enqueued) on resume" + ) + } + + func testNoSegmentLeftPendingAfterResume() { + let viewModel = TranscriptionViewModel() + viewModel.segments = (0..<5).map { + TranslationSegment(sourceText: "Segment \($0)", state: .translating) + } + + viewModel.togglePause() + XCTAssertTrue(viewModel.segments.allSatisfy { $0.state == .pending }) + + viewModel.togglePause() + XCTAssertFalse( + viewModel.segments.contains { $0.state == .pending }, + "No segment may remain pending after resume" + ) + } +} diff --git a/macos/Tests/LCTMacTests/TextUtilsTests.swift b/macos/Tests/LCTMacTests/TextUtilsTests.swift new file mode 100644 index 0000000..679e42e --- /dev/null +++ b/macos/Tests/LCTMacTests/TextUtilsTests.swift @@ -0,0 +1,188 @@ + +import XCTest +@testable import LCTMac + +/// Tests for TextUtils +final class TextUtilsTests: XCTestCase { + + // MARK: - CJK Detection Tests + + func testIsCJK_ChineseCharacter_ReturnsTrue() { + XCTAssertTrue(TextUtils.isCJK("中")) + XCTAssertTrue(TextUtils.isCJK("文")) + XCTAssertTrue(TextUtils.isCJK("国")) + } + + func testIsCJK_JapaneseHiragana_ReturnsTrue() { + XCTAssertTrue(TextUtils.isCJK("あ")) + XCTAssertTrue(TextUtils.isCJK("い")) + } + + func testIsCJK_JapaneseKatakana_ReturnsTrue() { + XCTAssertTrue(TextUtils.isCJK("ア")) + XCTAssertTrue(TextUtils.isCJK("イ")) + } + + func testIsCJK_KoreanCharacter_ReturnsTrue() { + XCTAssertTrue(TextUtils.isCJK("한")) + XCTAssertTrue(TextUtils.isCJK("국")) + } + + func testIsCJK_EnglishCharacter_ReturnsFalse() { + XCTAssertFalse(TextUtils.isCJK("a")) + XCTAssertFalse(TextUtils.isCJK("Z")) + } + + func testIsCJK_Nil_ReturnsFalse() { + XCTAssertFalse(TextUtils.isCJK(nil)) + } + + func testContainsCJK_MixedText_ReturnsTrue() { + XCTAssertTrue(TextUtils.containsCJK("Hello 世界")) + XCTAssertTrue(TextUtils.containsCJK("Test中文Test")) + } + + func testContainsCJK_PureEnglish_ReturnsFalse() { + XCTAssertFalse(TextUtils.containsCJK("Hello World")) + XCTAssertFalse(TextUtils.containsCJK("123456")) + } + + func testIsPrimarilyCJK_MostlyCJK_ReturnsTrue() { + XCTAssertTrue(TextUtils.isPrimarilyCJK("今天天气很好")) + XCTAssertTrue(TextUtils.isPrimarilyCJK("中文测试 ok")) + } + + func testIsPrimarilyCJK_MostlyEnglish_ReturnsFalse() { + XCTAssertFalse(TextUtils.isPrimarilyCJK("Hello World 你好")) + XCTAssertFalse(TextUtils.isPrimarilyCJK("This is a test")) + } + + func testIsPrimarilyCJK_EmptyString_ReturnsFalse() { + XCTAssertFalse(TextUtils.isPrimarilyCJK("")) + XCTAssertFalse(TextUtils.isPrimarilyCJK(" ")) + } + + // MARK: - Punctuation Tests + + func testHasEndPunctuation_EnglishPeriod_ReturnsTrue() { + XCTAssertTrue(TextUtils.hasEndPunctuation("Hello world.")) + XCTAssertTrue(TextUtils.hasEndPunctuation("Question?")) + XCTAssertTrue(TextUtils.hasEndPunctuation("Exclamation!")) + } + + func testHasEndPunctuation_ChinesePunctuation_ReturnsTrue() { + XCTAssertTrue(TextUtils.hasEndPunctuation("你好。")) + XCTAssertTrue(TextUtils.hasEndPunctuation("什么?")) + XCTAssertTrue(TextUtils.hasEndPunctuation("好的!")) + } + + func testHasEndPunctuation_NoPunctuation_ReturnsFalse() { + XCTAssertFalse(TextUtils.hasEndPunctuation("Hello world")) + XCTAssertFalse(TextUtils.hasEndPunctuation("你好")) + } + + func testHasEndPunctuation_EmptyString_ReturnsFalse() { + XCTAssertFalse(TextUtils.hasEndPunctuation("")) + } + + func testEnsureEndPunctuation_AddsPeriodToEnglish() { + XCTAssertEqual(TextUtils.ensureEndPunctuation("Hello world"), "Hello world.") + } + + func testEnsureEndPunctuation_AddsChinesePeriodToCJK() { + XCTAssertEqual(TextUtils.ensureEndPunctuation("你好"), "你好。") + } + + func testEnsureEndPunctuation_DoesNotDuplicatePunctuation() { + XCTAssertEqual(TextUtils.ensureEndPunctuation("Hello world."), "Hello world.") + XCTAssertEqual(TextUtils.ensureEndPunctuation("你好。"), "你好。") + } + + func testEnsureEndPunctuation_EmptyString_ReturnsEmpty() { + XCTAssertEqual(TextUtils.ensureEndPunctuation(""), "") + } + + func testGetSeparator_ForCJKText_ReturnsChinesePeriod() { + XCTAssertEqual(TextUtils.getSeparator(for: "今天天气很好"), "。") + } + + func testGetSeparator_ForEnglishText_ReturnsPeriodSpace() { + XCTAssertEqual(TextUtils.getSeparator(for: "Hello world"), ". ") + } + + // MARK: - Text Processing Tests + + func testCleanTranslationOutput_RemovesMarkers() { + let input = "🔤Hello World🔤" + let expected = "Hello World" + XCTAssertEqual(TextUtils.cleanTranslationOutput(input), expected) + } + + func testCleanTranslationOutput_RemovesPrefixes() { + XCTAssertEqual(TextUtils.cleanTranslationOutput("Translation: Hello"), "Hello") + XCTAssertEqual(TextUtils.cleanTranslationOutput("翻译:你好"), "你好") + XCTAssertEqual(TextUtils.cleanTranslationOutput("译文:世界"), "世界") + } + + func testCleanTranslationOutput_TrimsWhitespace() { + XCTAssertEqual(TextUtils.cleanTranslationOutput(" Hello "), "Hello") + XCTAssertEqual(TextUtils.cleanTranslationOutput("\n你好\n"), "你好") + } + + func testFormatForDisplay_ShortText_ReturnsUnchanged() { + let text = "Short text" + XCTAssertEqual(TextUtils.formatForDisplay(text), text) + } + + func testFormatForDisplay_LongText_Truncates() { + let longText = String(repeating: "a", count: 600) + let result = TextUtils.formatForDisplay(longText, maxLength: 500) + XCTAssertTrue(result.count <= 503) // 500 + "..." + } + + func testConcatenateSentences_EmptyArray_ReturnsEmpty() { + XCTAssertEqual(TextUtils.concatenateSentences([]), "") + } + + func testConcatenateSentences_SingleSentence_ReturnsAsIs() { + XCTAssertEqual(TextUtils.concatenateSentences(["Hello"]), "Hello") + } + + func testConcatenateSentences_MultipleSentences_AddsSeparators() { + let result = TextUtils.concatenateSentences(["Hello", "World"]) + XCTAssertTrue(result.contains("Hello")) + XCTAssertTrue(result.contains("World")) + } + + func testIsCompleteSentence_WithPunctuation_ReturnsTrue() { + XCTAssertTrue(TextUtils.isCompleteSentence("Hello world.")) + XCTAssertTrue(TextUtils.isCompleteSentence("你好。")) + } + + func testIsCompleteSentence_WithoutPunctuation_ReturnsFalse() { + XCTAssertFalse(TextUtils.isCompleteSentence("Hello world")) + XCTAssertFalse(TextUtils.isCompleteSentence("你好")) + } + + func testIsCompleteSentence_EmptyString_ReturnsFalse() { + XCTAssertFalse(TextUtils.isCompleteSentence("")) + XCTAssertFalse(TextUtils.isCompleteSentence(" ")) + } + + func testExtractLastSentence_SingleSentence_ReturnsIt() { + XCTAssertEqual(TextUtils.extractLastSentence("Hello world."), "Hello world.") + } + + func testExtractLastSentence_MultipleSentences_ReturnsLast() { + let result = TextUtils.extractLastSentence("First sentence. Second sentence.") + XCTAssertEqual(result, "Second sentence.") + } + + func testExtractLastSentence_NoPunctuation_ReturnsNil() { + XCTAssertNil(TextUtils.extractLastSentence("No punctuation here")) + } + + func testExtractLastSentence_EmptyString_ReturnsNil() { + XCTAssertNil(TextUtils.extractLastSentence("")) + } +} diff --git a/macos/Tests/LCTMacTests/TranslationQueueTests.swift b/macos/Tests/LCTMacTests/TranslationQueueTests.swift new file mode 100644 index 0000000..762ab99 --- /dev/null +++ b/macos/Tests/LCTMacTests/TranslationQueueTests.swift @@ -0,0 +1,114 @@ +import XCTest +@testable import LCTMac + +/// Tests for TranslationQueue debouncing and priority logic +final class TranslationQueueTests: XCTestCase { + + // MARK: - TranslationTask Priority Tests + + func testPriorityOrdering() { + let highTask = makeTask(text: "Hello", priority: .high, isFinal: true) + let normalTask = makeTask(text: "World", priority: .normal, isFinal: false) + let lowTask = makeTask(text: "Test", priority: .low, isFinal: false) + + // High priority should be processed first + XCTAssertTrue(highTask.priority.rawValue > normalTask.priority.rawValue) + XCTAssertTrue(normalTask.priority.rawValue > lowTask.priority.rawValue) + } + + func testTaskTimestampOrdering() { + // Create tasks with a slight delay to ensure different timestamps + let task1 = makeTask(text: "First", priority: .normal, isFinal: false) + // task2 is created later, so task1.timestamp < task2.timestamp + let task2 = makeTask(text: "Second", priority: .normal, isFinal: false) + + // Same priority: earlier timestamp should be first (FIFO) + // task1 was created first, so its timestamp should be <= task2's + XCTAssertTrue(task1.timestamp <= task2.timestamp) + } + + // MARK: - Deduplication Tests + + func testSameTextDeduplication() { + // Tasks with the same text should be considered duplicates + let task1 = makeTask(text: "Hello world", priority: .low, isFinal: false) + let task2 = makeTask(text: "Hello world", priority: .high, isFinal: true) + + // Same text = same dedup key + XCTAssertEqual(task1.text, task2.text) + } + + func testDifferentTextNotDeduplicated() { + let task1 = makeTask(text: "Hello", priority: .normal, isFinal: false) + let task2 = makeTask(text: "World", priority: .normal, isFinal: false) + + XCTAssertNotEqual(task1.text, task2.text) + } + + // MARK: - TranslationQueueResult Tests + + func testSuccessfulResult() { + let result = TranslationQueueResult( + segmentId: UUID(), + originalText: "Hello", + translatedText: "你好", + latencyMs: 150, + success: true + ) + + XCTAssertTrue(result.success) + XCTAssertEqual(result.originalText, "Hello") + XCTAssertEqual(result.translatedText, "你好") + XCTAssertEqual(result.latencyMs, 150) + XCTAssertNil(result.error) + } + + func testFailedResult() { + let result = TranslationQueueResult( + segmentId: UUID(), + originalText: "Hello", + translatedText: "", + latencyMs: 0, + success: false + ) + + XCTAssertFalse(result.success) + XCTAssertTrue(result.translatedText.isEmpty) + } + + // MARK: - Priority Enum Tests + + func testPriorityRawValues() { + XCTAssertEqual(TranslationPriority.low.rawValue, 0) + XCTAssertEqual(TranslationPriority.normal.rawValue, 1) + XCTAssertEqual(TranslationPriority.high.rawValue, 2) + } + + // MARK: - Generation Counter Tests + + func testTranslationTaskIsFinal() { + let volatileTask = makeTask(text: "partial", isFinal: false) + let finalTask = makeTask(text: "final", isFinal: true) + + XCTAssertFalse(volatileTask.isFinal) + XCTAssertTrue(finalTask.isFinal) + } + + func testTranslationTaskDefaultPriority() { + let task = makeTask(text: "test") + XCTAssertEqual(task.priority, .normal) + XCTAssertTrue(task.isFinal) // default is true + } + + // MARK: - Helpers + + private func makeTask(text: String, priority: TranslationPriority = .normal, isFinal: Bool = true) -> TranslationTask { + TranslationTask( + segmentId: UUID(), + text: text, + context: [], + priority: priority, + isFinal: isFinal + ) + } +} diff --git a/macos/USER_GUIDE.md b/macos/USER_GUIDE.md new file mode 100644 index 0000000..8e9a1e6 --- /dev/null +++ b/macos/USER_GUIDE.md @@ -0,0 +1,931 @@ +# LCT macOS 用户文档 + +**LiveCaptions Translator for macOS - 实时字幕翻译助手** + +版本:1.0.0 +更新日期:2026年2月 + +--- + +## 📖 目录 + +1. [简介](#简介) +2. [系统要求](#系统要求) +3. [安装指南](#安装指南) +4. [快速开始](#快速开始) +5. [功能详解](#功能详解) +6. [设置选项](#设置选项) +7. [快捷键](#快捷键) +8. [常见问题](#常见问题) +9. [故障排除](#故障排除) +10. [隐私与安全](#隐私与安全) + +--- + +## 简介 + +### 什么是 LCT? + +LCT (LiveCaptions Translator) 是一款专为 macOS 设计的实时字幕翻译应用程序。它能够: + +- 🎤 **实时捕获音频**:支持系统音频和麦克风输入 +- 🗣️ **精准语音识别**:使用 Apple 的 SFSpeechRecognizer 技术,支持 14 种语言 +- 🌍 **智能翻译**:通过本地 Ollama AI 模型实现高质量翻译 +- 📊 **实时显示**:主窗口和悬浮窗口同步显示字幕和翻译 +- 📝 **历史记录**:自动保存所有翻译历史,支持搜索和导出 +- 🎨 **可定制界面**:灵活的窗口设置和显示选项 + +### 应用场景 + +- 📹 **在线会议翻译**:实时翻译 Zoom、Teams、Meet 等会议内容 +- 🎬 **视频内容翻译**:观看外语视频时提供实时字幕翻译 +- 🎓 **在线课程学习**:帮助理解外语教学内容 +- 💼 **跨语言沟通**:商务会议中的实时翻译助手 +- 🎮 **游戏交流**:多人游戏中的语音交流翻译 + +--- + +## 系统要求 + +### 最低配置 + +- **操作系统**:macOS 15.0 (Sequoia) 或更高版本 +- **处理器**:Apple Silicon (M1/M2/M3) 或 Intel Core i5 及以上 +- **内存**:8 GB RAM(推荐 16 GB) +- **存储空间**:至少 8 GB 可用空间(用于应用和默认 Ollama 模型) +- **网络**:首次安装时需要网络连接下载 AI 模型 + +### 推荐配置 + +- **处理器**:Apple Silicon (M1 Pro/Max/Ultra, M2 Pro/Max, M3 Pro/Max) +- **内存**:16 GB RAM 或更多 +- **存储空间**:10 GB 可用空间(用于多个 AI 模型) + +### 必需的系统权限 + +- ✅ 麦克风访问权限 +- ✅ 屏幕录制权限(用于捕获系统音频) +- ✅ 语音识别权限 + +--- + +## 安装指南 + +### 第一步:下载 LCT + +1. 从 [GitHub Releases](https://github.com/your-repo/LCT/releases) 页面下载最新版本 +2. 下载 `LCT-macOS-{version}.dmg` 文件 + +### 第二步:安装 Ollama + +LCT 需要 Ollama 作为本地 AI 翻译引擎。 + +#### 自动安装(推荐) + +首次启动 LCT 时,应用会自动检测 Ollama 是否安装: +- 如果未安装,应用会提供一键安装按钮 +- 点击"Install Ollama"即可自动下载并安装 + +#### 手动安装 + +如果自动安装失败,可以手动安装: + +```bash +# 使用 Homebrew 安装 +brew install ollama + +# 启动 Ollama 服务 +ollama serve +``` + +或访问 [Ollama 官网](https://ollama.ai) 下载安装包。 + +### 第三步:安装 LCT + +1. 双击下载的 `.dmg` 文件 +2. 将 LCT 图标拖拽到"应用程序"文件夹 +3. 从启动台或应用程序文件夹启动 LCT + +### 第四步:首次设置 + +首次启动时,LCT 会引导你完成初始设置: + +#### 1. 欢迎界面 +- 了解 LCT 的主要功能 +- 点击"开始设置"继续 + +#### 2. 权限请求 +LCT 需要以下权限才能正常工作: + +- **麦克风权限**:用于捕获你的语音输入 + - 系统会弹出权限对话框,点击"好"授予权限 + +- **屏幕录制权限**:用于捕获系统音频(如会议、视频音频) + - 前往"系统设置 > 隐私与安全性 > 屏幕录制" + - 勾选 LCT + +- **语音识别权限**:用于语音转文字 + - 系统会弹出权限对话框,点击"好"授予权限 + +#### 3. Ollama 检测 +- LCT 会自动检测 Ollama 是否已安装并运行 +- 如果 Ollama 未安装,会提供安装选项 +- 如果 Ollama 已安装但未运行,会提示启动服务 + +#### 4. AI 模型下载 +- LCT 会推荐并下载默认 AI 模型(qwen3.5:4b-mlx) +- 下载过程可能需要几分钟,取决于网络速度 +- 也可以选择其他推荐模型: + - `qwen3.5:4b-mlx` - 平衡性能(推荐,约 2.5GB) + - `llama3.2:3b` - Meta 官方模型(2GB) + - `gemma2:2b` - Google 轻量模型(1.6GB) + +#### 5. 完成设置 +- 设置完成后,LCT 会进入主界面 +- 你现在可以开始使用实时翻译功能了! + +--- + +## 快速开始 + +### 基本使用流程 + +#### 1. 启动捕获 + +点击主窗口左上角的 **"Start"** 按钮,或使用快捷键 `⌘ + Space`。 + +- 🎤 麦克风指示器会变为绿色(如果启用麦克风) +- 🔊 系统音频指示器会变为绿色(如果启用系统音频) +- 应用会开始监听音频并进行实时识别 + +#### 2. 查看实时字幕 + +- **主窗口**:显示最近的多条转录记录 +- **悬浮窗口**:轻量级的浮动窗口,可放置在屏幕任意位置 + +#### 3. 查看翻译结果 + +- 原文识别完成后,会自动进行翻译 +- 翻译结果显示在字幕下方 +- 延迟时间(毫秒)会显示在右侧 + +#### 4. 复制翻译 + +- 点击翻译文本右侧的"复制"按钮 +- 或使用快捷键 `⌘ + ⇧ + C` + +#### 5. 停止捕获 + +再次点击 **"Stop"** 按钮,或使用快捷键 `⌘ + Space`。 + +--- + +## 功能详解 + +### 1. 音频捕获 + +#### 系统音频捕获 +- **用途**:捕获应用程序播放的所有音频 +- **适用场景**:会议软件、视频播放器、浏览器音频等 +- **注意**:需要"屏幕录制"权限 + +#### 麦克风捕获 +- **用途**:捕获你的语音输入 +- **适用场景**:需要翻译自己说话内容 +- **注意**:需要"麦克风"权限 + +#### 音量指示器 +- 主窗口底部显示实时音频电平 +- 绿色波形表示正在接收音频 +- 可以用于调试音频输入是否正常 + +### 2. 语音识别 + +#### 支持的语言 + +LCT 使用 Apple 的 SFSpeechRecognizer,支持以下语言: + +| 语言 | 识别代码 | 支持质量 | +|------|---------|---------| +| 英语(美国) | en-US | ⭐⭐⭐⭐⭐ | +| 英语(英国) | en-GB | ⭐⭐⭐⭐⭐ | +| 中文(简体) | zh-CN | ⭐⭐⭐⭐⭐ | +| 中文(繁体) | zh-TW | ⭐⭐⭐⭐ | +| 日语 | ja-JP | ⭐⭐⭐⭐⭐ | +| 韩语 | ko-KR | ⭐⭐⭐⭐ | +| 西班牙语 | es-ES | ⭐⭐⭐⭐⭐ | +| 法语 | fr-FR | ⭐⭐⭐⭐ | +| 德语 | de-DE | ⭐⭐⭐⭐ | +| 俄语 | ru-RU | ⭐⭐⭐⭐ | +| 葡萄牙语 | pt-BR | ⭐⭐⭐⭐ | +| 意大利语 | it-IT | ⭐⭐⭐⭐ | +| 阿拉伯语 | ar-SA | ⭐⭐⭐ | +| 印地语 | hi-IN | ⭐⭐⭐ | + +#### 识别特性 + +- **实时识别**:边说边显示 +- **自动标点**:智能添加标点符号 +- **置信度显示**:显示识别准确度 +- **说话人识别**:未来版本将支持 + +### 3. AI 翻译 + +#### 支持的目标语言 + +- 🇨🇳 中文 (Chinese) +- 🇺🇸 英语 (English) +- 🇯🇵 日语 (Japanese) +- 🇰🇷 韩语 (Korean) +- 🇪🇸 西班牙语 (Spanish) +- 🇫🇷 法语 (French) +- 🇩🇪 德语 (German) +- 🇷🇺 俄语 (Russian) +- 🇸🇦 阿拉伯语 (Arabic) +- 🇧🇷 葡萄牙语 (Portuguese) + +#### 翻译特性 + +- **上下文感知**:基于之前的翻译历史提供更准确的翻译 +- **术语保留**:自动识别并保留专有名词、公司名称等 +- **不完整句处理**:智能处理未完成的句子 +- **自定义提示词**:可以自定义翻译风格和要求 + +#### 翻译质量优化 + +- **防抖队列**:避免频繁的翻译请求 +- **智能合并**:自动合并连续的短句 +- **延迟显示**:显示每次翻译的耗时 + +### 4. 悬浮窗口 + +#### 基本功能 + +- **始终置顶**:窗口始终显示在最前面 +- **半透明背景**:不遮挡后面的内容 +- **可拖动**:可以移动到屏幕任意位置 +- **可调整大小**:拖动边角调整窗口大小 +- **点击穿透**:可选择是否允许点击穿透 + +#### 显示内容 + +- 最近 3 条转录记录 +- 当前翻译结果 +- 可选择性显示延迟时间 + +#### 快捷操作 + +- 鼠标悬停显示控制按钮 +- 快速关闭/最小化 + +### 5. 历史记录 + +#### 查看历史 + +1. 点击主窗口的"历史"按钮,或使用快捷键 `⌘ + ⇧ + H` +2. 历史窗口会显示所有翻译记录 + +#### 历史功能 + +- **时间排序**:最新记录在最前 +- **搜索功能**:搜索原文或译文 +- **日期筛选**:按日期范围筛选 +- **详细信息**: + - 原始文本 + - 翻译文本 + - 目标语言 + - 翻译时间 + - 延迟时间(毫秒) + +#### 导出历史 + +- **CSV 导出**:可导出为 CSV 文件用于 Excel 分析 +- **复制单条**:复制单条翻译记录 +- **清除历史**:清除所有历史记录 + +#### 数据存储 + +- 历史记录使用 SQLite 数据库存储 +- 存储位置:`~/Library/Application Support/LCT/history.sqlite` +- 自动建立索引以提高查询速度 + +--- + +## 设置选项 + +### 打开设置 + +- 点击主窗口的"设置"按钮 +- 或使用快捷键 `⌘ + ,` +- 或从菜单栏选择 "LCT > Settings..." + +### 音频设置 + +#### Capture System Audio(捕获系统音频) +- **默认**:开启 +- **说明**:捕获应用程序播放的音频 +- **用途**:会议、视频等 + +#### Capture Microphone(捕获麦克风) +- **默认**:开启 +- **说明**:捕获麦克风输入的音频 +- **用途**:自己的语音输入 + +💡 **提示**:可以同时启用两者,或根据需要单独启用 + +### 语音识别设置 + +#### Source Language(源语言) +- **默认**:English (US) +- **说明**:选择要识别的语音语言 +- **建议**:选择与说话内容最匹配的语言 + +### Ollama 配置 + +#### Host(主机地址) +- **默认**:`localhost` +- **说明**:Ollama 服务的主机地址 +- **高级**:如果 Ollama 运行在其他机器上,可以填写对应 IP + +#### Port(端口) +- **默认**:`11434` +- **说明**:Ollama 服务的端口号 +- **注意**:通常不需要修改 + +#### Model(模型) +- **默认**:`qwen3.5:4b-mlx` +- **说明**:用于翻译的 AI 模型 +- **推荐选项**: + - `qwen3.5:4b-mlx` - 平衡性能(推荐) + - `qwen2.5:7b` - 更高质量(需要更多内存) + - `llama3.2:3b` - Meta 官方模型 + - `gemma2:2b` - 轻量快速 + +💡 **提示**:点击模型名称旁的"管理模型"按钮可以下载新模型 + +#### Timeout(超时时间) +- **默认**:30 秒 +- **说明**:翻译请求的最大等待时间 +- **建议**:15-60 秒之间 + +#### Temperature(温度) +- **默认**:0.3 +- **范围**:0.0 - 1.0 +- **说明**: + - 低温度(0-0.3):更准确、一致的翻译 + - 中温度(0.3-0.7):平衡创造性和准确性 + - 高温度(0.7-1.0):更有创造性,但可能不太准确 +- **推荐**:翻译场景使用 0.2-0.4 + +### 翻译设置 + +#### Target Language(目标语言) +- **默认**:Chinese(中文) +- **说明**:翻译的目标语言 +- **注意**:显示语言的英文名和本地名称 + +#### Context-Aware Translation(上下文感知翻译) +- **默认**:开启 +- **说明**:使用之前的翻译历史提供更好的上下文理解 +- **效果**:提高翻译连贯性和准确性 + +#### Max Context Entries(最大上下文条目) +- **默认**:5 +- **范围**:1-10 +- **说明**:翻译时参考的历史条目数量 +- **建议**:3-7 条,更多可能影响速度 + +#### Custom Prompt(自定义提示词) +- **默认**:使用内置专业翻译提示词 +- **说明**:自定义翻译的风格和要求 +- **用途**: + - 指定特定领域术语处理 + - 调整翻译风格(正式/非正式) + - 添加特殊要求 + +**默认提示词示例**: +``` +You are a professional simultaneous interpreter. +Translate the text enclosed in 🔤 markers to {TARGET_LANGUAGE}. + +CRITICAL RULES: +1. Output ONLY the translated text, never the original text +2. Handle incomplete sentences naturally and professionally +3. Preserve technical terms, company names, and proper nouns accurately +4. Maintain appropriate tone and formality +5. For unclear speech, provide the most likely interpretation +``` + +### 显示设置 + +#### Show Overlay Window(显示悬浮窗口) +- **默认**:开启 +- **说明**:启动时是否显示悬浮窗口 +- **快捷键**:`⌘ + O` 可随时切换 + +#### Overlay Opacity(悬浮窗口透明度) +- **默认**:0.85 +- **范围**:0.0 - 1.0 +- **说明**:0 完全透明,1 完全不透明 + +#### Overlay Font Size(悬浮窗口字体大小) +- **默认**:14.0 +- **范围**:10.0 - 24.0 +- **说明**:悬浮窗口中字幕的字体大小 + +#### Show Latency(显示延迟) +- **默认**:开启 +- **说明**:是否显示翻译延迟时间 + +#### Max Display Cards(最大显示卡片数) +- **默认**:5 +- **范围**:3-10 +- **说明**:主窗口中显示的最大转录卡片数量 + +#### Click-Through Overlay(点击穿透悬浮窗口) +- **默认**:关闭 +- **说明**:启用后,可以点击悬浮窗口下方的内容 +- **用途**:当需要与悬浮窗口下方的应用交互时启用 + +### 悬浮窗口外观 + +#### Text Color(文本颜色) +- **默认**:白色 (#FFFFFF) +- **说明**:悬浮窗口中的文字颜色 +- **建议**:根据背景选择对比度高的颜色 + +#### Background Color(背景颜色) +- **默认**:黑色 (#000000) +- **说明**:悬浮窗口的背景颜色 +- **提示**:配合透明度使用 + +### 保存设置 + +- 点击"Done"按钮保存所有更改 +- 大部分设置会立即生效 +- 某些设置(如模型选择)可能需要重启捕获 + +--- + +## 快捷键 + +### 全局快捷键 + +| 快捷键 | 功能 | 说明 | +|--------|------|------| +| `⌘ + Space` | 开始/停止捕获 | 切换音频捕获状态 | +| `⌘ + P` | 暂停/继续 | 暂停或继续识别和翻译 | +| `⌘ + ,` | 打开设置 | 打开设置窗口 | +| `⌘ + ⇧ + C` | 复制翻译 | 复制当前翻译内容 | +| `⌘ + O` | 切换悬浮窗口 | 显示/隐藏悬浮窗口 | +| `⌘ + ⇧ + H` | 查看历史 | 打开历史记录窗口 | +| `⌘ + W` | 关闭窗口 | 关闭当前窗口 | +| `⌘ + Q` | 退出应用 | 完全退出 LCT | +| `⌘ + U` | 检查更新 | 检查应用更新(待实现) | + +### 窗口内快捷键 + +#### 主窗口 +- `⌘ + C`:复制选中的文本 +- `⌘ + A`:全选 +- `⌘ + F`:搜索(历史窗口) + +#### 悬浮窗口 +- 鼠标悬停:显示控制按钮 +- 拖动:移动窗口位置 +- 拖动边角:调整窗口大小 + +--- + +## 常见问题 + +### 安装与设置 + +**Q1: 首次启动时提示"无法打开应用"怎么办?** + +A: 这是 macOS 的安全机制。解决方法: +1. 右键点击 LCT 应用 +2. 选择"打开" +3. 在弹出的对话框中点击"打开" + +或者在"系统设置 > 隐私与安全性"中允许运行。 + +**Q2: Ollama 安装失败怎么办?** + +A: 尝试手动安装: +```bash +# 使用 Homebrew +brew install ollama + +# 启动服务 +ollama serve +``` + +如果还是失败,访问 [Ollama 官网](https://ollama.ai) 下载官方安装包。 + +**Q3: 如何验证 Ollama 是否正常运行?** + +A: 打开终端,运行: +```bash +curl http://localhost:11434/api/tags +``` + +如果返回 JSON 数据,说明 Ollama 正在运行。 + +### 权限问题 + +**Q4: 授予了屏幕录制权限,但还是无法捕获系统音频?** + +A: 授予权限后需要重启应用: +1. 完全退出 LCT(`⌘ + Q`) +2. 重新启动 LCT +3. 再次尝试捕获 + +**Q5: 如何重新授予权限?** + +A: 前往"系统设置": +1. 点击"隐私与安全性" +2. 分别点击"麦克风"、"屏幕录制"、"语音识别" +3. 取消勾选 LCT +4. 重新勾选 LCT +5. 重启应用 + +### 使用问题 + +**Q6: 为什么没有声音识别出来?** + +A: 检查以下几点: +- ✅ 确认"开始"按钮已点击(显示绿色) +- ✅ 确认音频电平指示器有波动 +- ✅ 确认选择了正确的源语言 +- ✅ 确认音量足够大 +- ✅ 确认授予了相应权限(麦克风或屏幕录制) + +**Q7: 识别出来了,但没有翻译?** + +A: 检查以下几点: +- ✅ Ollama 服务是否在运行:在终端运行 `ollama serve` +- ✅ AI 模型是否已下载:在终端运行 `ollama list` +- ✅ Ollama 设置是否正确(主机、端口、模型名称) +- ✅ 查看主窗口是否有错误提示 + +**Q8: 翻译速度太慢怎么办?** + +A: 尝试以下优化: +- 使用更小的模型(如 `gemma2:2b`) +- 减少上下文条目数量(设置中的"Max Context Entries") +- 降低 Temperature 值 +- 确保 Mac 没有在省电模式下运行 + +**Q9: 悬浮窗口不见了怎么办?** + +A: +- 使用快捷键 `⌘ + O` 切换显示 +- 或在设置中重新启用"Show Overlay Window" +- 检查窗口是否被移到了屏幕外(重启应用会重置位置) + +**Q10: 如何在全屏应用中使用悬浮窗口?** + +A: 悬浮窗口默认会显示在所有窗口之上,包括全屏应用。如果看不到: +- 确认悬浮窗口已启用 +- 尝试调整透明度(如果太透明可能看不清) +- 某些全屏游戏可能会覆盖悬浮窗口,这是系统限制 + +### 性能问题 + +**Q11: 应用占用内存太大?** + +A: 正常情况下,LCT 占用 100-500 MB 内存,取决于: +- Ollama 模型大小 +- 历史记录数量 +- 音频缓冲区大小 + +优化建议: +- 定期清理历史记录 +- 使用更小的 AI 模型 +- 不使用时停止捕获 + +**Q12: CPU 占用过高?** + +A: 实时识别和翻译会占用一定 CPU,但通常不应超过 30%。如果过高: +- 检查是否有其他应用同时占用 CPU +- 尝试降低音频采样率(需要修改代码) +- 更换更强大的模型可能会占用更多 CPU + +### AI 模型问题 + +**Q13: 如何下载其他模型?** + +A: 在终端中运行: +```bash +# 下载默认模型 +ollama pull qwen3.5:4b-mlx + +# 下载 llama3.2:3b +ollama pull llama3.2:3b + +# 下载 gemma2:2b +ollama pull gemma2:2b +``` + +或在 LCT 设置中点击"管理模型"。 + +**Q14: 哪个模型最好?** + +A: 取决于你的需求: +- **最佳平衡**:`qwen3.5:4b-mlx` - 推荐日常使用 +- **最快速度**:`gemma2:2b` - 适合低配置 Mac +- **最高质量**:`qwen2.5:7b` - 需要 16GB+ 内存 +- **兼容性**:`llama3.2:3b` - Meta 官方模型 + +**Q15: 如何删除不用的模型?** + +A: 在终端中运行: +```bash +ollama rm + +# 例如 +ollama rm qwen2.5:7b +``` + +--- + +## 故障排除 + +### 应用无法启动 + +**症状**:双击应用图标后没有反应 + +**解决方法**: +1. 检查系统要求(macOS 15.0+) +2. 查看"控制台"应用中的错误日志 +3. 尝试从终端启动: + ```bash + /Applications/LCT.app/Contents/MacOS/LCT + ``` +4. 重新下载并安装应用 + +### Ollama 连接失败 + +**症状**:应用显示"Ollama 未连接" + +**解决方法**: +1. 确认 Ollama 已安装: + ```bash + which ollama + ``` + +2. 启动 Ollama 服务: + ```bash + ollama serve + ``` + +3. 检查端口是否被占用: + ```bash + lsof -i :11434 + ``` + +4. 验证连接: + ```bash + curl http://localhost:11434/api/tags + ``` + +5. 如果使用自定义端口,在设置中修改 + +### 语音识别不准确 + +**症状**:识别结果错误或缺字 + +**解决方法**: +1. **确认语言选择正确**:源语言要与说话语言一致 +2. **改善音频质量**: + - 减少背景噪音 + - 靠近麦克风说话 + - 提高音量 +3. **检查网络连接**:Apple 的语音识别可能需要网络 +4. **重启语音识别服务**:停止后重新开始捕获 + +### 翻译质量不佳 + +**症状**:翻译不准确或不自然 + +**优化方法**: +1. **启用上下文感知翻译**:提供更好的连贯性 +2. **调整 Temperature**:降低到 0.2-0.3 +3. **使用更大的模型**:如 `qwen2.5:7b` +4. **自定义提示词**:添加特定领域的要求 +5. **增加上下文条目**:提高到 5-7 条 + +### 历史记录丢失 + +**症状**:之前的翻译历史找不到了 + +**解决方法**: +1. 检查数据库文件是否存在: + ```bash + ls ~/Library/Application\ Support/LCT/history.sqlite + ``` + +2. 如果文件存在,尝试在历史窗口中搜索 + +3. 如果文件损坏,LCT 会自动创建新的数据库 + +4. 定期导出历史记录作为备份 + +### 应用崩溃或卡死 + +**紧急处理**: +1. 使用 `⌘ + Q` 尝试正常退出 +2. 如果无响应,强制退出: + - `⌘ + ⌥ + Esc` + - 选择 LCT + - 点击"强制退出" + +3. 或在终端中: + ```bash + killall LCT + ``` + +**预防措施**: +- 定期重启应用 +- 清理历史记录 +- 监控内存使用 +- 更新到最新版本 + +### 获取详细日志 + +如果需要报告问题,可以收集日志: + +1. **应用日志**: + - 打开"控制台"应用 + - 搜索"LCT"或"com.yourcompany.LCT" + - 导出相关日志 + +2. **Ollama 日志**: + ```bash + # 查看 Ollama 服务日志 + journalctl -u ollama + ``` + +3. **系统日志**: + ```bash + # 查看系统日志 + log show --predicate 'process == "LCT"' --last 1h + ``` + +--- + +## 隐私与安全 + +### 数据隐私 + +LCT 承诺保护你的隐私: + +#### ✅ 本地处理 +- **语音识别**:使用 Apple 的 SFSpeechRecognizer,可能会发送音频到 Apple 服务器进行处理 +- **AI 翻译**:完全在本地运行(通过 Ollama),不会发送到任何服务器 +- **历史记录**:存储在本地 SQLite 数据库,不会上传 + +#### ✅ 不收集个人信息 +- 不收集用户身份信息 +- 不收集使用统计数据 +- 不包含广告或追踪器 + +#### ✅ 你的数据由你控制 +- 可以随时清除历史记录 +- 可以导出数据 +- 卸载应用时可以手动删除数据文件 + +### 数据存储位置 + +- **历史数据库**:`~/Library/Application Support/LCT/history.sqlite` +- **设置文件**:`~/Library/Preferences/com.yourcompany.LCT.plist` +- **临时音频缓冲**:内存中,不会保存到磁盘 + +### 完全删除数据 + +如果需要完全删除 LCT 的所有数据: + +```bash +# 删除应用 +rm -rf /Applications/LCT.app + +# 删除数据文件 +rm -rf ~/Library/Application\ Support/LCT + +# 删除设置 +rm ~/Library/Preferences/com.yourcompany.LCT.plist + +# 删除缓存(如果有) +rm -rf ~/Library/Caches/com.yourcompany.LCT +``` + +### 权限使用说明 + +#### 麦克风权限 +- **用途**:仅用于捕获你的语音进行识别 +- **何时使用**:仅在启用"Capture Microphone"并点击开始时 +- **数据处理**:音频会发送到 Apple 进行识别,识别结果保存在本地 + +#### 屏幕录制权限 +- **用途**:仅用于捕获系统音频(应用程序的声音) +- **何时使用**:仅在启用"Capture System Audio"并点击开始时 +- **不会录制**:不会录制屏幕画面,只捕获音频 +- **数据处理**:与麦克风相同 + +#### 语音识别权限 +- **用途**:使用 Apple 的语音识别 API +- **数据处理**:Apple 可能会保存语音数据用于改进服务 +- **隐私政策**:参考 [Apple 隐私政策](https://www.apple.com/legal/privacy/) + +### 网络连接 + +LCT 在以下情况下需要网络连接: + +- **首次安装**:下载 AI 模型 +- **语音识别**:Apple 的语音识别可能需要网络连接(取决于语言) +- **检查更新**:手动检查应用更新时 + +### 安全建议 + +1. **保持更新**:及时安装应用更新和安全补丁 +2. **密码保护**:如果翻译内容包含敏感信息,使用 Mac 的屏幕锁定功能 +3. **定期清理**:定期清除历史记录中的敏感内容 +4. **谨慎分享**:导出的历史记录文件包含所有翻译内容,分享时注意 + +--- + +## 技术支持 + +### 获取帮助 + +- **GitHub Issues**:[报告问题或建议](https://github.com/your-repo/LCT/issues) +- **文档**:[在线文档](https://github.com/your-repo/LCT/wiki) +- **Email**:support@example.com + +### 报告问题 + +报告问题时,请提供: +1. macOS 版本 +2. LCT 版本 +3. 问题描述 +4. 复现步骤 +5. 错误日志(如果有) + +### 贡献 + +LCT 是开源项目,欢迎贡献: +- 报告 Bug +- 提出新功能建议 +- 提交代码改进 +- 改进文档 + +--- + +## 更新日志 + +### v1.0.0 (2026-02) + +**首次发布** 🎉 + +**核心功能**: +- ✅ 实时语音识别(支持 14 种语言) +- ✅ 实时 AI 翻译(支持 10 种目标语言) +- ✅ 主窗口和悬浮窗口显示 +- ✅ 历史记录管理和搜索 +- ✅ 上下文感知翻译 +- ✅ 自定义设置和提示词 +- ✅ 首次使用向导 +- ✅ Ollama 自动检测和模型管理 + +**技术特性**: +- 使用 Apple SFSpeechRecognizer +- 集成 Ollama 本地 AI +- SQLite 历史数据库 +- SwiftUI 原生界面 +- 支持 Apple Silicon 和 Intel + +--- + +## 致谢 + +LCT 基于以下优秀的开源项目和技术: + +- **Apple SFSpeech**:语音识别框架 +- **Ollama**:本地 AI 模型运行引擎 +- **SQLite.swift**:SQLite 数据库封装 +- **SwiftUI**:现代化的 UI 框架 + +感谢所有贡献者和用户的支持! + +--- + +## 许可证 + +LCT 使用 MIT 许可证发布。详见 [LICENSE](LICENSE) 文件。 + +--- + +**Enjoy real-time translation with LCT! 🚀** + +如有任何问题或建议,欢迎联系我们! diff --git a/macos/USER_MANUAL.md b/macos/USER_MANUAL.md new file mode 100644 index 0000000..695ff8d --- /dev/null +++ b/macos/USER_MANUAL.md @@ -0,0 +1,217 @@ +# LCT 使用说明 / User Manual + +**中英双语版本 / Bilingual Version** + +--- + +## 🇨🇳 中文版本 + +### 什么是 LCT? +LCT (LiveCaptions Translator) 是一款 macOS 实时字幕翻译工具。它可以捕获音频、识别语音并实时翻译成目标语言。 + +### 主要功能 +- **实时语音识别**:支持 14 种语言 +- **AI 智能翻译**:支持翻译成 10 种语言 +- **悬浮字幕窗口**:可拖动的半透明字幕 +- **历史记录**:自动保存所有翻译内容 + +### 快速开始 +1. **下载安装**:从 Releases 下载 .dmg 文件 +2. **首次设置**:授予权限、安装 Ollama、下载 AI 模型 +3. **开始使用**:点击 Start 按钮开始翻译 + +### 基本操作 +- 开始/停止:`⌘ + Space` +- 打开设置:`⌘ + ,` +- 显示/隐藏悬浮窗口:`⌘ + O` +- 复制翻译:`⌘ + ⇧ + C` + +### 常用设置 +- **源语言**:设置 > Speech Recognition > Source Language +- **目标语言**:设置 > Translation > Target Language +- **音频捕获**:设置 > Audio Capture > 选择系统音频或麦克风 + +### 获取帮助 +- 完整文档:查看 [USER_GUIDE.md](USER_GUIDE.md) +- 快速指南:查看 [QUICK_START.md](QUICK_START.md) +- 报告问题:[GitHub Issues](https://github.com/your-repo/LCT/issues) + +--- + +## 🇺🇸 English Version + +### What is LCT? +LCT (LiveCaptions Translator) is a real-time caption translation tool for macOS. It captures audio, recognizes speech, and translates it to your target language in real-time. + +### Main Features +- **Real-time Speech Recognition**: Supports 14 languages +- **AI Translation**: Translates to 10 languages +- **Floating Caption Window**: Draggable, semi-transparent captions +- **History**: Automatically saves all translations + +### Quick Start +1. **Download & Install**: Download the .dmg file from Releases +2. **First-time Setup**: Grant permissions, install Ollama, download AI model +3. **Start Using**: Click the Start button to begin translation + +### Basic Operations +- Start/Stop: `⌘ + Space` +- Open Settings: `⌘ + ,` +- Toggle Overlay: `⌘ + O` +- Copy Translation: `⌘ + ⇧ + C` + +### Common Settings +- **Source Language**: Settings > Speech Recognition > Source Language +- **Target Language**: Settings > Translation > Target Language +- **Audio Capture**: Settings > Audio Capture > Choose system audio or microphone + +### Get Help +- Full Documentation: See [USER_GUIDE.md](USER_GUIDE.md) +- Quick Guide: See [QUICK_START.md](QUICK_START.md) +- Report Issues: [GitHub Issues](https://github.com/your-repo/LCT/issues) + +--- + +## 支持的语言 / Supported Languages + +### 语音识别源语言 / Speech Recognition (Source) + +| 语言 / Language | 代码 / Code | 质量 / Quality | +|----------------|-------------|---------------| +| 英语(美国)/ English (US) | en-US | ⭐⭐⭐⭐⭐ | +| 英语(英国)/ English (UK) | en-GB | ⭐⭐⭐⭐⭐ | +| 中文(简体)/ Chinese (Simplified) | zh-CN | ⭐⭐⭐⭐⭐ | +| 中文(繁体)/ Chinese (Traditional) | zh-TW | ⭐⭐⭐⭐ | +| 日语 / Japanese | ja-JP | ⭐⭐⭐⭐⭐ | +| 韩语 / Korean | ko-KR | ⭐⭐⭐⭐ | +| 西班牙语 / Spanish | es-ES | ⭐⭐⭐⭐⭐ | +| 法语 / French | fr-FR | ⭐⭐⭐⭐ | +| 德语 / German | de-DE | ⭐⭐⭐⭐ | +| 俄语 / Russian | ru-RU | ⭐⭐⭐⭐ | +| 葡萄牙语 / Portuguese | pt-BR | ⭐⭐⭐⭐ | +| 意大利语 / Italian | it-IT | ⭐⭐⭐⭐ | +| 阿拉伯语 / Arabic | ar-SA | ⭐⭐⭐ | +| 印地语 / Hindi | hi-IN | ⭐⭐⭐ | + +### 翻译目标语言 / Translation (Target) + +- 🇨🇳 中文 / Chinese +- 🇺🇸 英语 / English +- 🇯🇵 日语 / Japanese +- 🇰🇷 韩语 / Korean +- 🇪🇸 西班牙语 / Spanish +- 🇫🇷 法语 / French +- 🇩🇪 德语 / German +- 🇷🇺 俄语 / Russian +- 🇸🇦 阿拉伯语 / Arabic +- 🇧🇷 葡萄牙语 / Portuguese + +--- + +## 系统要求 / System Requirements + +### 最低配置 / Minimum +- **系统 / OS**: macOS 12.0+ (Monterey or later) +- **处理器 / CPU**: Apple Silicon (M1) or Intel Core i5 +- **内存 / RAM**: 8 GB +- **存储 / Storage**: 2 GB available + +### 推荐配置 / Recommended +- **处理器 / CPU**: Apple Silicon (M1 Pro/Max, M2, M3) +- **内存 / RAM**: 16 GB or more +- **存储 / Storage**: 10 GB available + +--- + +## 使用场景 / Use Cases + +### 🎥 在线会议 / Online Meetings +**中文**: 实时翻译 Zoom、Teams、Google Meet 等会议内容 +**English**: Real-time translation for Zoom, Teams, Google Meet meetings + +### 📺 视频字幕 / Video Subtitles +**中文**: 为 YouTube、Netflix 等视频提供实时翻译字幕 +**English**: Provides real-time translated subtitles for YouTube, Netflix videos + +### 📚 语言学习 / Language Learning +**中文**: 练习外语时获得实时翻译反馈 +**English**: Get real-time translation feedback when practicing foreign languages + +### 💼 商务沟通 / Business Communication +**中文**: 跨语言商务会议的实时翻译助手 +**English**: Real-time translation assistant for cross-language business meetings + +--- + +## 常见问题 / FAQ + +### 🇨🇳 中文 + +**Q: 为什么没有声音被识别?** +A: 检查是否点击了 Start 按钮、音频权限是否授予、源语言是否选择正确。 + +**Q: 翻译速度慢怎么办?** +A: 尝试使用更小的 AI 模型(如 gemma2:2b),或降低上下文条目数量。 + +**Q: 如何授予权限?** +A: 系统设置 > 隐私与安全性 > 分别授予麦克风、屏幕录制、语音识别权限。 + +**Q: Ollama 是什么?** +A: Ollama 是本地 AI 引擎,用于运行翻译模型。首次设置时会自动安装。 + +### 🇺🇸 English + +**Q: Why is no sound being recognized?** +A: Check if you clicked the Start button, audio permissions are granted, and the source language is correctly selected. + +**Q: Translation is slow, what should I do?** +A: Try using a smaller AI model (like gemma2:2b), or reduce the number of context entries. + +**Q: How do I grant permissions?** +A: System Settings > Privacy & Security > Grant microphone, screen recording, and speech recognition permissions separately. + +**Q: What is Ollama?** +A: Ollama is a local AI engine used to run translation models. It will be automatically installed during first-time setup. + +--- + +## 快捷键 / Keyboard Shortcuts + +| 功能 / Function | 快捷键 / Shortcut | +|----------------|------------------| +| 开始/停止 / Start/Stop | `⌘ + Space` | +| 暂停/继续 / Pause/Resume | `⌘ + P` | +| 设置 / Settings | `⌘ + ,` | +| 复制翻译 / Copy Translation | `⌘ + ⇧ + C` | +| 悬浮窗口 / Toggle Overlay | `⌘ + O` | +| 历史记录 / History | `⌘ + ⇧ + H` | + +--- + +## 技术支持 / Support + +### 中文支持 +- **文档**: [完整用户指南](USER_GUIDE.md) +- **问题报告**: [GitHub Issues](https://github.com/your-repo/LCT/issues) +- **邮箱**: support@example.com + +### English Support +- **Documentation**: [Full User Guide](USER_GUIDE.md) +- **Report Issues**: [GitHub Issues](https://github.com/your-repo/LCT/issues) +- **Email**: support@example.com + +--- + +## 许可证 / License + +MIT License - 详见 / See [LICENSE](LICENSE) 文件 / file + +--- + +
+ +**感谢使用 LCT!/ Thank you for using LCT!** + +⭐ 如果喜欢请给个 Star / Please give us a star if you like it ⭐ + +
diff --git a/macos/make-icon.swift b/macos/make-icon.swift new file mode 100644 index 0000000..79abb26 --- /dev/null +++ b/macos/make-icon.swift @@ -0,0 +1,103 @@ +// Generates AppIcon.iconset PNGs for LCTMac. +// Run: swift make-icon.swift && iconutil -c icns AppIcon.iconset -o LCTMac/Resources/AppIcon.icns +// Design: terminal-HUD aesthetic — dark squircle, emerald waveform, caption bars. +import AppKit +import CoreGraphics +import ImageIO +import UniformTypeIdentifiers + +let emerald = CGColor(red: 0.20, green: 0.83, blue: 0.60, alpha: 1) +let emeraldDim = CGColor(red: 0.20, green: 0.83, blue: 0.60, alpha: 0.45) +let background = CGColor(red: 0.078, green: 0.078, blue: 0.086, alpha: 1) +let dimGray = CGColor(red: 0.43, green: 0.43, blue: 0.46, alpha: 1) +let hairline = CGColor(red: 1, green: 1, blue: 1, alpha: 0.10) + +func draw(into ctx: CGContext, pixelSize: Int) { + let s = CGFloat(pixelSize) / 1024.0 + ctx.scaleBy(x: s, y: s) + + // Apple macOS icon grid: 824x824 squircle centered in 1024 canvas + let iconRect = CGRect(x: 100, y: 100, width: 824, height: 824) + let squircle = CGPath(roundedRect: iconRect, cornerWidth: 185, cornerHeight: 185, transform: nil) + + ctx.addPath(squircle) + ctx.setFillColor(background) + ctx.fillPath() + + ctx.addPath(squircle) + ctx.setStrokeColor(hairline) + ctx.setLineWidth(8) + ctx.strokePath() + + // Waveform bars (upper half) + let barHeights: [CGFloat] = [72, 130, 200, 260, 200, 130, 72] + let barWidth: CGFloat = 44 + let gap: CGFloat = 34 + let totalWidth = CGFloat(barHeights.count) * barWidth + CGFloat(barHeights.count - 1) * gap + var x = (1024 - totalWidth) / 2 + let waveCenterY: CGFloat = 620 + + ctx.setFillColor(emerald) + for h in barHeights { + let bar = CGRect(x: x, y: waveCenterY - h / 2, width: barWidth, height: h) + ctx.addPath(CGPath(roundedRect: bar, cornerWidth: barWidth / 2, cornerHeight: barWidth / 2, transform: nil)) + ctx.fillPath() + x += barWidth + gap + } + + // Caption bars (lower half): dim source line above bright translation line + let lineHeight: CGFloat = 44 + let lineRadius = lineHeight / 2 + + let sourceRect = CGRect(x: 252, y: 332, width: 460, height: lineHeight) + ctx.addPath(CGPath(roundedRect: sourceRect, cornerWidth: lineRadius, cornerHeight: lineRadius, transform: nil)) + ctx.setFillColor(dimGray) + ctx.fillPath() + + let translationRect = CGRect(x: 252, y: 244, width: 380, height: lineHeight) + ctx.addPath(CGPath(roundedRect: translationRect, cornerWidth: lineRadius, cornerHeight: lineRadius, transform: nil)) + ctx.setFillColor(emerald) + ctx.fillPath() + + // Streaming cursor block at the end of the translation line + let cursorRect = CGRect(x: 664, y: 244, width: 34, height: lineHeight) + ctx.addPath(CGPath(roundedRect: cursorRect, cornerWidth: 8, cornerHeight: 8, transform: nil)) + ctx.setFillColor(emeraldDim) + ctx.fillPath() +} + +func renderPNG(pixelSize: Int, to url: URL) { + let space = CGColorSpace(name: CGColorSpace.sRGB)! + guard let ctx = CGContext( + data: nil, width: pixelSize, height: pixelSize, + bitsPerComponent: 8, bytesPerRow: 0, space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { fatalError("Cannot create context") } + + draw(into: ctx, pixelSize: pixelSize) + + guard let image = ctx.makeImage(), + let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil) else { + fatalError("Cannot create image destination") + } + CGImageDestinationAddImage(dest, image, nil) + CGImageDestinationFinalize(dest) +} + +let iconsetURL = URL(fileURLWithPath: "AppIcon.iconset") +try? FileManager.default.removeItem(at: iconsetURL) +try! FileManager.default.createDirectory(at: iconsetURL, withIntermediateDirectories: true) + +let entries: [(name: String, size: Int)] = [ + ("icon_16x16", 16), ("icon_16x16@2x", 32), + ("icon_32x32", 32), ("icon_32x32@2x", 64), + ("icon_128x128", 128), ("icon_128x128@2x", 256), + ("icon_256x256", 256), ("icon_256x256@2x", 512), + ("icon_512x512", 512), ("icon_512x512@2x", 1024), +] + +for entry in entries { + renderPNG(pixelSize: entry.size, to: iconsetURL.appendingPathComponent("\(entry.name).png")) +} + +print("Generated AppIcon.iconset (\(entries.count) sizes)") diff --git a/macos/package-app.sh b/macos/package-app.sh new file mode 100755 index 0000000..e1741d0 --- /dev/null +++ b/macos/package-app.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Package LCTMac as a proper .app bundle. +# Running the bare SPM executable makes TCC attribute permission requests to the +# launching app (Terminal/Claude/etc.), which crashes with SIGABRT when that app +# lacks the usage descriptions. A real bundle owns its own TCC identity. +set -euo pipefail +cd "$(dirname "$0")" + +swift build -c release +BIN_PATH=$(swift build -c release --show-bin-path) + +APP=LCTMac.app +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp "$BIN_PATH/LCTMac" "$APP/Contents/MacOS/LCTMac" +cp LCTMac/Info.plist "$APP/Contents/Info.plist" +cp LCTMac/Resources/AppIcon.icns "$APP/Contents/Resources/AppIcon.icns" + +# Stamp version from git so builds are distinguishable +VERSION=$(git describe --tags 2>/dev/null || echo "0.1.0") +BUILD=$(git rev-list --count HEAD 2>/dev/null || echo "1") +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$APP/Contents/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD" "$APP/Contents/Info.plist" + +codesign --force --sign - "$APP" +echo "Packaged $APP $VERSION ($BUILD) — launch with: open $APP" diff --git a/translation_history.db b/translation_history.db deleted file mode 100644 index 4ede7b89a2f824e80521068f0f5d541f0af59a1c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 208896 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 B8pZ$s diff --git a/LiveCaptionsTranslator.csproj b/windows/LiveCaptionsTranslator.csproj similarity index 100% rename from LiveCaptionsTranslator.csproj rename to windows/LiveCaptionsTranslator.csproj diff --git a/LiveCaptionsTranslator.sln b/windows/LiveCaptionsTranslator.sln similarity index 100% rename from LiveCaptionsTranslator.sln rename to windows/LiveCaptionsTranslator.sln diff --git a/windows/README.md b/windows/README.md new file mode 100644 index 0000000..82ac46d --- /dev/null +++ b/windows/README.md @@ -0,0 +1,194 @@ +# LCT (LiveCaptions Translator) - Professional Edition + +## Overview + +**✨ LCT (LiveCaptions Translator) = Windows Live Captions + Local AI Translation ✨** + +A professional real-time speech translation solution that combines Windows' built-in Live Captions with local AI models via Ollama for private, secure, and efficient translation. Developed by Ai-All-You-Need-Platform Pte. Ltd. + +**🚀 Quick Start:** Download from releases and launch instantly! + +**🔒 Privacy First:** All translation happens locally on your device - no data leaves your machine. + +## Key Features + +### 🔄 **Seamless Integration** +- Automatically invokes Windows Live Captions without opening a separate window +- Provides unified experience for real-time audio/speech translation +- Live Captions are hidden by default after first use (configurable in settings) +- Enable ***Include microphone audio*** in Windows Live Captions settings for real-time speech translation + +### 🤖 **Local AI Translation** +- **Ollama Engine**: Exclusive use of local AI models for translation +- **Privacy Focused**: All processing happens on your device +- **Multiple Models**: Support for various language models (qwen2.5:3b default) +- **Intel IPEX-LLM**: Optimized for Intel hardware acceleration +- **No Internet Required**: Works completely offline after initial setup + +### 🎨 **Modern Interface** +- Clean Fluent UI that matches modern Windows aesthetics +- Automatic light/dark theme switching based on system settings +- Professional branding and user experience +- Multiple viewing modes: standard and compact overlay + +### 🪟 **Overlay Window** +- Borderless, transparent floating window for immersive experience +- Perfect for gaming, videos, and live streaming +- Fully customizable appearance: background, text color, font size, transparency +- Can be embedded seamlessly into screen without interfering with other operations +- Adjustable sentence count display + +### ⚙️ **Flexible Control** +- Window always-on-top functionality +- Convenient translation pause/resume +- One-click text copying for quick sharing +- Comprehensive settings for Ollama configuration + +### 📒 **History Management** +- Records both original and translated text +- Perfect for meetings, lectures, and important discussions +- Export all records to CSV format +- Search and filter capabilities + +### 🎞️ **Log Cards** +- Recent transcription records displayed as log cards +- Helps maintain context during conversations +- Configurable card count in settings + +## System Requirements + +| Requirement | Details | +|-------------|----------| +| **Windows** | Windows 11 (22H2+) with Live Captions support | +| **.NET** | .NET 8.0+ Runtime (included in installer) | +| **Hardware** | Intel CPU recommended for IPEX-LLM optimization | +| **Memory** | 8GB+ RAM for optimal AI model performance | +| **Storage** | 2GB+ free space for application and models | + +> ⚠️ **Important**: This tool is based on Windows Live Captions, available since **Windows 11 22H2**. + +## Getting Started + +> ⚠️ **IMPORTANT:** Complete the following setup before running LCT for the first time. +> +> For more details, see Microsoft's guide: [Using live captions to better understand audio](https://support.microsoft.com/en-us/windows/use-live-captions-to-better-understand-audio-b52da59c-14b8-4031-aeeb-f6a47e6055df) + +### Step 1: Verify Windows Live Captions Availability + +Ensure Live Captions is available on your system through any of these methods: + +- Toggle **Live captions** in the Quick Settings panel +- Use the keyboard shortcut **Win + Ctrl + L** +- Navigate to **Quick Settings** > **Accessibility** > **Live captions** +- Access from **Start** > **All apps** > **Accessibility** > **Live captions** +- Go to **Settings** > **Accessibility** > **Captions** and toggle on **Live captions** + +### Step 2: Initial Live Captions Configuration + +When you first launch Windows Live Captions, it will: +1. Request permission to process voice data locally on your device +2. Prompt you to download the necessary language files for on-device speech recognition + +After the initial setup, configure the following settings: +1. Click the **⚙️ gear** icon in Live Captions to open settings +2. In **Change language**, select your **source language** (the language to be recognized) +3. Under **Position**, select **Overlaid on screen** + +> ⚠️ **Critical:** The position setting is required to prevent display issues when Live Captions runs in the background. + +### Step 3: Enable Microphone Audio (Optional) + +To enable real-time translation of your own speech through the microphone: + +1. In Live Captions, click the **⚙️ gear** icon +2. Go to **Preferences** +3. Check **Include microphone audio** + +This allows LCT to capture and translate both system audio and your microphone input simultaneously. + +### Step 4: Launch LCT + +After completing the configuration, close Windows Live Captions and start LiveCaptions Translator! 🎉 + +The application will automatically: +- Download and install Ollama if not present +- Pull the default translation model (qwen2.5:3b) +- Configure optimal settings for your hardware + +## Configuration + +Customize the following options in the **Settings** view: + +### Display Options +- **LiveCaptions**: Show/hide original caption text in Home view +- **Log Cards**: Number of recent translation cards displayed +- **Overlay Sentences**: Number of sentences shown in overlay mode +- **Show Latency**: Display translation processing time + +### Ollama Configuration +- **API Endpoint**: Local Ollama server URL (default: http://localhost:11434) +- **Model Name**: AI model for translation (default: qwen2.5:3b) +- **System Prompt**: Custom instructions for translation behavior +- **Temperature**: Control translation creativity/consistency +- **Max Tokens**: Maximum response length + +### Performance Tuning +- **API Interval**: Delay between translation requests (balance speed vs. resource usage) +- **Target Language**: Destination language for translations +- **Model Loading**: Automatic model management and optimization + +## Architecture + +### Core Components +- **Translation Engine**: Exclusive Ollama integration with Intel IPEX-LLM +- **UI Framework**: WPF with Fluent UI components +- **Data Layer**: SQLite for history storage +- **System Integration**: Windows Live Captions automation +- **Model Management**: Automatic download and optimization + +### Build Information +- **Version**: 1.0.1 (Build 279) +- **Target Framework**: .NET 8.0-windows +- **Runtime**: Self-contained win-x64 +- **Installer Size**: ~97MB +- **Configuration**: Release with ReadyToRun optimization + +## Privacy & Security + +✅ **Complete Local Processing**: All translation happens on your device +✅ **No Internet Required**: Works offline after initial setup +✅ **No Data Collection**: Your conversations never leave your machine +✅ **Secure by Design**: No external API calls or cloud dependencies +✅ **Enterprise Ready**: Suitable for confidential business communications + +## Support and Contact + +### Company Information +**Ai-All-You-Need-Platform Pte. Ltd.** +Official Website: [https://aiallyouneed.dev](https://aiallyouneed.dev) + +### Troubleshooting + +#### Common Issues +- **Model Download Fails**: Check internet connection during initial setup +- **Performance Issues**: Ensure adequate RAM and close unnecessary applications +- **Live Captions Not Working**: Verify Windows version and accessibility settings +- **Translation Delays**: Adjust API interval in settings for better performance + +#### Support Resources +- Check logs in application data folder +- Verify Ollama service status in Task Manager +- Ensure Windows Live Captions is properly configured +- Visit our official website for documentation and support + +## Technical Notes + +- Built with .NET 8.0 and WPF-UI framework +- Uses Intel IPEX-LLM for hardware acceleration +- Supports ARM64 architecture for future compatibility +- Implements SQLite for efficient local data storage +- Features automatic error recovery and model management + +--- + +**Copyright © 2024 Ai-All-You-Need-Platform Pte. Ltd. All rights reserved.** \ No newline at end of file diff --git a/README_zh-CN.md b/windows/README_zh-CN.md similarity index 100% rename from README_zh-CN.md rename to windows/README_zh-CN.md diff --git a/config/setting_new.json b/windows/config/setting_new.json similarity index 100% rename from config/setting_new.json rename to windows/config/setting_new.json diff --git a/docs/README.md b/windows/docs/README.md similarity index 100% rename from docs/README.md rename to windows/docs/README.md diff --git a/docs/dev/PRD.md b/windows/docs/dev/PRD.md similarity index 100% rename from docs/dev/PRD.md rename to windows/docs/dev/PRD.md diff --git a/docs/dev/RELEASE_NOTES.md b/windows/docs/dev/RELEASE_NOTES.md similarity index 100% rename from docs/dev/RELEASE_NOTES.md rename to windows/docs/dev/RELEASE_NOTES.md diff --git a/docs/dev/VERSION_MANAGEMENT.md b/windows/docs/dev/VERSION_MANAGEMENT.md similarity index 100% rename from docs/dev/VERSION_MANAGEMENT.md rename to windows/docs/dev/VERSION_MANAGEMENT.md diff --git a/docs/troubleshooting/DELL_PATH_UPDATE.md b/windows/docs/troubleshooting/DELL_PATH_UPDATE.md similarity index 100% rename from docs/troubleshooting/DELL_PATH_UPDATE.md rename to windows/docs/troubleshooting/DELL_PATH_UPDATE.md diff --git a/docs/troubleshooting/FIX_DOWNLOAD_ISSUE.md b/windows/docs/troubleshooting/FIX_DOWNLOAD_ISSUE.md similarity index 100% rename from docs/troubleshooting/FIX_DOWNLOAD_ISSUE.md rename to windows/docs/troubleshooting/FIX_DOWNLOAD_ISSUE.md diff --git a/docs/troubleshooting/INITIALIZATION_LOGGING.md b/windows/docs/troubleshooting/INITIALIZATION_LOGGING.md similarity index 100% rename from docs/troubleshooting/INITIALIZATION_LOGGING.md rename to windows/docs/troubleshooting/INITIALIZATION_LOGGING.md diff --git a/docs/troubleshooting/OLLAMA_LOGGING.md b/windows/docs/troubleshooting/OLLAMA_LOGGING.md similarity index 100% rename from docs/troubleshooting/OLLAMA_LOGGING.md rename to windows/docs/troubleshooting/OLLAMA_LOGGING.md diff --git a/docs/user/MAINTENANCE_GUIDE.md b/windows/docs/user/MAINTENANCE_GUIDE.md similarity index 100% rename from docs/user/MAINTENANCE_GUIDE.md rename to windows/docs/user/MAINTENANCE_GUIDE.md diff --git a/docs/user/USER_MANUAL.md b/windows/docs/user/USER_MANUAL.md similarity index 100% rename from docs/user/USER_MANUAL.md rename to windows/docs/user/USER_MANUAL.md diff --git a/images/AppIcon.ico b/windows/images/AppIcon.ico similarity index 100% rename from images/AppIcon.ico rename to windows/images/AppIcon.ico diff --git a/images/LCT_logo.ico b/windows/images/LCT_logo.ico similarity index 100% rename from images/LCT_logo.ico rename to windows/images/LCT_logo.ico diff --git a/images/LCT_logo.png b/windows/images/LCT_logo.png similarity index 100% rename from images/LCT_logo.png rename to windows/images/LCT_logo.png diff --git a/images/Logo.png b/windows/images/Logo.png similarity index 100% rename from images/Logo.png rename to windows/images/Logo.png diff --git a/images/preview.png b/windows/images/preview.png similarity index 100% rename from images/preview.png rename to windows/images/preview.png diff --git a/images/show_livecaptions.png b/windows/images/show_livecaptions.png similarity index 100% rename from images/show_livecaptions.png rename to windows/images/show_livecaptions.png diff --git a/package-lock.json b/windows/package-lock.json similarity index 100% rename from package-lock.json rename to windows/package-lock.json diff --git a/package.json b/windows/package.json similarity index 100% rename from package.json rename to windows/package.json diff --git a/scripts/build/build-installer.bat b/windows/scripts/build/build-installer.bat similarity index 100% rename from scripts/build/build-installer.bat rename to windows/scripts/build/build-installer.bat diff --git a/scripts/build/build-version.ps1 b/windows/scripts/build/build-version.ps1 similarity index 100% rename from scripts/build/build-version.ps1 rename to windows/scripts/build/build-version.ps1 diff --git a/scripts/build/build.ps1 b/windows/scripts/build/build.ps1 similarity index 100% rename from scripts/build/build.ps1 rename to windows/scripts/build/build.ps1 diff --git a/scripts/build/create-release.ps1 b/windows/scripts/build/create-release.ps1 similarity index 100% rename from scripts/build/create-release.ps1 rename to windows/scripts/build/create-release.ps1 diff --git a/scripts/deployment/LCT-v1.0.1-Setup.zip b/windows/scripts/deployment/LCT-v1.0.1-Setup.zip similarity index 100% rename from scripts/deployment/LCT-v1.0.1-Setup.zip rename to windows/scripts/deployment/LCT-v1.0.1-Setup.zip diff --git a/scripts/deployment/VC_redist.x64.exe b/windows/scripts/deployment/VC_redist.x64.exe similarity index 100% rename from scripts/deployment/VC_redist.x64.exe rename to windows/scripts/deployment/VC_redist.x64.exe diff --git a/scripts/deployment/deploy.ps1 b/windows/scripts/deployment/deploy.ps1 similarity index 100% rename from scripts/deployment/deploy.ps1 rename to windows/scripts/deployment/deploy.ps1 diff --git a/scripts/deployment/installer.nsi b/windows/scripts/deployment/installer.nsi similarity index 100% rename from scripts/deployment/installer.nsi rename to windows/scripts/deployment/installer.nsi diff --git a/scripts/deployment/offline-setup.ps1 b/windows/scripts/deployment/offline-setup.ps1 similarity index 100% rename from scripts/deployment/offline-setup.ps1 rename to windows/scripts/deployment/offline-setup.ps1 diff --git a/scripts/deployment/startup.bat b/windows/scripts/deployment/startup.bat similarity index 100% rename from scripts/deployment/startup.bat rename to windows/scripts/deployment/startup.bat diff --git a/scripts/diagnostics/clean-old-model.ps1 b/windows/scripts/diagnostics/clean-old-model.ps1 similarity index 100% rename from scripts/diagnostics/clean-old-model.ps1 rename to windows/scripts/diagnostics/clean-old-model.ps1 diff --git a/scripts/diagnostics/complete-fix.ps1 b/windows/scripts/diagnostics/complete-fix.ps1 similarity index 100% rename from scripts/diagnostics/complete-fix.ps1 rename to windows/scripts/diagnostics/complete-fix.ps1 diff --git a/scripts/diagnostics/diagnose-startup-issue-en.ps1 b/windows/scripts/diagnostics/diagnose-startup-issue-en.ps1 similarity index 100% rename from scripts/diagnostics/diagnose-startup-issue-en.ps1 rename to windows/scripts/diagnostics/diagnose-startup-issue-en.ps1 diff --git a/scripts/diagnostics/fix-startup-issue.ps1 b/windows/scripts/diagnostics/fix-startup-issue.ps1 similarity index 100% rename from scripts/diagnostics/fix-startup-issue.ps1 rename to windows/scripts/diagnostics/fix-startup-issue.ps1 diff --git a/src/App.xaml b/windows/src/App.xaml similarity index 100% rename from src/App.xaml rename to windows/src/App.xaml diff --git a/src/App.xaml.cs b/windows/src/App.xaml.cs similarity index 100% rename from src/App.xaml.cs rename to windows/src/App.xaml.cs diff --git a/src/AssemblyInfo.cs b/windows/src/AssemblyInfo.cs similarity index 100% rename from src/AssemblyInfo.cs rename to windows/src/AssemblyInfo.cs diff --git a/src/LiveCaptions-Translator.ico b/windows/src/LiveCaptions-Translator.ico similarity index 100% rename from src/LiveCaptions-Translator.ico rename to windows/src/LiveCaptions-Translator.ico diff --git a/src/Program.cs b/windows/src/Program.cs similarity index 100% rename from src/Program.cs rename to windows/src/Program.cs diff --git a/src/Services/Caption/LiveCaptionsHandler.cs b/windows/src/Services/Caption/LiveCaptionsHandler.cs similarity index 100% rename from src/Services/Caption/LiveCaptionsHandler.cs rename to windows/src/Services/Caption/LiveCaptionsHandler.cs diff --git a/src/Services/Caption/RegexPatterns.cs b/windows/src/Services/Caption/RegexPatterns.cs similarity index 100% rename from src/Services/Caption/RegexPatterns.cs rename to windows/src/Services/Caption/RegexPatterns.cs diff --git a/src/Services/Caption/TextUtil.cs b/windows/src/Services/Caption/TextUtil.cs similarity index 100% rename from src/Services/Caption/TextUtil.cs rename to windows/src/Services/Caption/TextUtil.cs diff --git a/src/Services/History/DataMigrationManager.cs b/windows/src/Services/History/DataMigrationManager.cs similarity index 100% rename from src/Services/History/DataMigrationManager.cs rename to windows/src/Services/History/DataMigrationManager.cs diff --git a/src/Services/History/HistoryLogger.cs b/windows/src/Services/History/HistoryLogger.cs similarity index 100% rename from src/Services/History/HistoryLogger.cs rename to windows/src/Services/History/HistoryLogger.cs diff --git a/src/Services/Ollama/OllamaDownloader.cs b/windows/src/Services/Ollama/OllamaDownloader.cs similarity index 100% rename from src/Services/Ollama/OllamaDownloader.cs rename to windows/src/Services/Ollama/OllamaDownloader.cs diff --git a/src/Services/Ollama/OllamaGuardian.cs b/windows/src/Services/Ollama/OllamaGuardian.cs similarity index 100% rename from src/Services/Ollama/OllamaGuardian.cs rename to windows/src/Services/Ollama/OllamaGuardian.cs diff --git a/src/Services/Platform/TelemetryService.cs b/windows/src/Services/Platform/TelemetryService.cs similarity index 100% rename from src/Services/Platform/TelemetryService.cs rename to windows/src/Services/Platform/TelemetryService.cs diff --git a/src/Services/Platform/WindowHandler.cs b/windows/src/Services/Platform/WindowHandler.cs similarity index 100% rename from src/Services/Platform/WindowHandler.cs rename to windows/src/Services/Platform/WindowHandler.cs diff --git a/src/Services/Platform/WindowsAPI.cs b/windows/src/Services/Platform/WindowsAPI.cs similarity index 100% rename from src/Services/Platform/WindowsAPI.cs rename to windows/src/Services/Platform/WindowsAPI.cs diff --git a/src/Services/Startup/ApplicationSetup.cs b/windows/src/Services/Startup/ApplicationSetup.cs similarity index 100% rename from src/Services/Startup/ApplicationSetup.cs rename to windows/src/Services/Startup/ApplicationSetup.cs diff --git a/src/Services/Startup/StartupManager.cs b/windows/src/Services/Startup/StartupManager.cs similarity index 100% rename from src/Services/Startup/StartupManager.cs rename to windows/src/Services/Startup/StartupManager.cs diff --git a/src/Services/Translation/TranslateAPI.cs b/windows/src/Services/Translation/TranslateAPI.cs similarity index 100% rename from src/Services/Translation/TranslateAPI.cs rename to windows/src/Services/Translation/TranslateAPI.cs diff --git a/src/Services/Translation/TranslationTaskQueue.cs b/windows/src/Services/Translation/TranslationTaskQueue.cs similarity index 100% rename from src/Services/Translation/TranslationTaskQueue.cs rename to windows/src/Services/Translation/TranslationTaskQueue.cs diff --git a/src/Services/Update/AppVersionInfo.cs b/windows/src/Services/Update/AppVersionInfo.cs similarity index 100% rename from src/Services/Update/AppVersionInfo.cs rename to windows/src/Services/Update/AppVersionInfo.cs diff --git a/src/Services/Update/AutoUpdateService.cs b/windows/src/Services/Update/AutoUpdateService.cs similarity index 100% rename from src/Services/Update/AutoUpdateService.cs rename to windows/src/Services/Update/AutoUpdateService.cs diff --git a/src/Services/Update/UpdateUtil.cs b/windows/src/Services/Update/UpdateUtil.cs similarity index 100% rename from src/Services/Update/UpdateUtil.cs rename to windows/src/Services/Update/UpdateUtil.cs diff --git a/src/Services/Update/VersionCompatibilityChecker.cs b/windows/src/Services/Update/VersionCompatibilityChecker.cs similarity index 100% rename from src/Services/Update/VersionCompatibilityChecker.cs rename to windows/src/Services/Update/VersionCompatibilityChecker.cs diff --git a/src/Services/Update/VersionManager.cs b/windows/src/Services/Update/VersionManager.cs similarity index 100% rename from src/Services/Update/VersionManager.cs rename to windows/src/Services/Update/VersionManager.cs diff --git a/src/Translator.cs b/windows/src/Translator.cs similarity index 100% rename from src/Translator.cs rename to windows/src/Translator.cs diff --git a/src/Views/Controls/VersionSettingsControl.xaml b/windows/src/Views/Controls/VersionSettingsControl.xaml similarity index 100% rename from src/Views/Controls/VersionSettingsControl.xaml rename to windows/src/Views/Controls/VersionSettingsControl.xaml diff --git a/src/Views/Controls/VersionSettingsControl.xaml.cs b/windows/src/Views/Controls/VersionSettingsControl.xaml.cs similarity index 100% rename from src/Views/Controls/VersionSettingsControl.xaml.cs rename to windows/src/Views/Controls/VersionSettingsControl.xaml.cs diff --git a/src/Views/Pages/CaptionPage.xaml b/windows/src/Views/Pages/CaptionPage.xaml similarity index 100% rename from src/Views/Pages/CaptionPage.xaml rename to windows/src/Views/Pages/CaptionPage.xaml diff --git a/src/Views/Pages/CaptionPage.xaml.cs b/windows/src/Views/Pages/CaptionPage.xaml.cs similarity index 100% rename from src/Views/Pages/CaptionPage.xaml.cs rename to windows/src/Views/Pages/CaptionPage.xaml.cs diff --git a/src/Views/Pages/HistoryPage.xaml b/windows/src/Views/Pages/HistoryPage.xaml similarity index 100% rename from src/Views/Pages/HistoryPage.xaml rename to windows/src/Views/Pages/HistoryPage.xaml diff --git a/src/Views/Pages/HistoryPage.xaml.cs b/windows/src/Views/Pages/HistoryPage.xaml.cs similarity index 100% rename from src/Views/Pages/HistoryPage.xaml.cs rename to windows/src/Views/Pages/HistoryPage.xaml.cs diff --git a/src/Views/Pages/InfoPage.xaml b/windows/src/Views/Pages/InfoPage.xaml similarity index 100% rename from src/Views/Pages/InfoPage.xaml rename to windows/src/Views/Pages/InfoPage.xaml diff --git a/src/Views/Pages/InfoPage.xaml.cs b/windows/src/Views/Pages/InfoPage.xaml.cs similarity index 100% rename from src/Views/Pages/InfoPage.xaml.cs rename to windows/src/Views/Pages/InfoPage.xaml.cs diff --git a/src/Views/Pages/SettingPage.xaml b/windows/src/Views/Pages/SettingPage.xaml similarity index 100% rename from src/Views/Pages/SettingPage.xaml rename to windows/src/Views/Pages/SettingPage.xaml diff --git a/src/Views/Pages/SettingPage.xaml.cs b/windows/src/Views/Pages/SettingPage.xaml.cs similarity index 100% rename from src/Views/Pages/SettingPage.xaml.cs rename to windows/src/Views/Pages/SettingPage.xaml.cs diff --git a/src/Views/Windows/EnterpriseWelcomeWindow.xaml b/windows/src/Views/Windows/EnterpriseWelcomeWindow.xaml similarity index 100% rename from src/Views/Windows/EnterpriseWelcomeWindow.xaml rename to windows/src/Views/Windows/EnterpriseWelcomeWindow.xaml diff --git a/src/Views/Windows/EnterpriseWelcomeWindow.xaml.cs b/windows/src/Views/Windows/EnterpriseWelcomeWindow.xaml.cs similarity index 100% rename from src/Views/Windows/EnterpriseWelcomeWindow.xaml.cs rename to windows/src/Views/Windows/EnterpriseWelcomeWindow.xaml.cs diff --git a/src/Views/Windows/MainWindow.xaml b/windows/src/Views/Windows/MainWindow.xaml similarity index 100% rename from src/Views/Windows/MainWindow.xaml rename to windows/src/Views/Windows/MainWindow.xaml diff --git a/src/Views/Windows/MainWindow.xaml.cs b/windows/src/Views/Windows/MainWindow.xaml.cs similarity index 100% rename from src/Views/Windows/MainWindow.xaml.cs rename to windows/src/Views/Windows/MainWindow.xaml.cs diff --git a/src/Views/Windows/OverlayWindow.xaml b/windows/src/Views/Windows/OverlayWindow.xaml similarity index 100% rename from src/Views/Windows/OverlayWindow.xaml rename to windows/src/Views/Windows/OverlayWindow.xaml diff --git a/src/Views/Windows/OverlayWindow.xaml.cs b/windows/src/Views/Windows/OverlayWindow.xaml.cs similarity index 100% rename from src/Views/Windows/OverlayWindow.xaml.cs rename to windows/src/Views/Windows/OverlayWindow.xaml.cs diff --git a/src/Views/Windows/SettingWindow.xaml b/windows/src/Views/Windows/SettingWindow.xaml similarity index 100% rename from src/Views/Windows/SettingWindow.xaml rename to windows/src/Views/Windows/SettingWindow.xaml diff --git a/src/Views/Windows/SettingWindow.xaml.cs b/windows/src/Views/Windows/SettingWindow.xaml.cs similarity index 100% rename from src/Views/Windows/SettingWindow.xaml.cs rename to windows/src/Views/Windows/SettingWindow.xaml.cs diff --git a/src/Views/Windows/SplashWindow.xaml b/windows/src/Views/Windows/SplashWindow.xaml similarity index 100% rename from src/Views/Windows/SplashWindow.xaml rename to windows/src/Views/Windows/SplashWindow.xaml diff --git a/src/Views/Windows/SplashWindow.xaml.cs b/windows/src/Views/Windows/SplashWindow.xaml.cs similarity index 100% rename from src/Views/Windows/SplashWindow.xaml.cs rename to windows/src/Views/Windows/SplashWindow.xaml.cs diff --git a/src/Views/Windows/UpdateWindow.xaml b/windows/src/Views/Windows/UpdateWindow.xaml similarity index 100% rename from src/Views/Windows/UpdateWindow.xaml rename to windows/src/Views/Windows/UpdateWindow.xaml diff --git a/src/Views/Windows/UpdateWindow.xaml.cs b/windows/src/Views/Windows/UpdateWindow.xaml.cs similarity index 100% rename from src/Views/Windows/UpdateWindow.xaml.cs rename to windows/src/Views/Windows/UpdateWindow.xaml.cs diff --git a/src/Views/Windows/WelcomeWindow.xaml b/windows/src/Views/Windows/WelcomeWindow.xaml similarity index 100% rename from src/Views/Windows/WelcomeWindow.xaml rename to windows/src/Views/Windows/WelcomeWindow.xaml diff --git a/src/Views/Windows/WelcomeWindow.xaml.cs b/windows/src/Views/Windows/WelcomeWindow.xaml.cs similarity index 100% rename from src/Views/Windows/WelcomeWindow.xaml.cs rename to windows/src/Views/Windows/WelcomeWindow.xaml.cs diff --git a/src/models/Caption.cs b/windows/src/models/Caption.cs similarity index 100% rename from src/models/Caption.cs rename to windows/src/models/Caption.cs diff --git a/src/models/InitializationStep.cs b/windows/src/models/InitializationStep.cs similarity index 100% rename from src/models/InitializationStep.cs rename to windows/src/models/InitializationStep.cs diff --git a/src/models/ReleaseInfo.cs b/windows/src/models/ReleaseInfo.cs similarity index 100% rename from src/models/ReleaseInfo.cs rename to windows/src/models/ReleaseInfo.cs diff --git a/src/models/Setting.cs b/windows/src/models/Setting.cs similarity index 100% rename from src/models/Setting.cs rename to windows/src/models/Setting.cs diff --git a/src/models/TranslateAPIConfig.cs b/windows/src/models/TranslateAPIConfig.cs similarity index 100% rename from src/models/TranslateAPIConfig.cs rename to windows/src/models/TranslateAPIConfig.cs diff --git a/src/models/TranslationHistoryEntry.cs b/windows/src/models/TranslationHistoryEntry.cs similarity index 100% rename from src/models/TranslationHistoryEntry.cs rename to windows/src/models/TranslationHistoryEntry.cs diff --git a/src/models/VersionConfig.cs b/windows/src/models/VersionConfig.cs similarity index 100% rename from src/models/VersionConfig.cs rename to windows/src/models/VersionConfig.cs diff --git a/src/models/VersionInfo.cs b/windows/src/models/VersionInfo.cs similarity index 100% rename from src/models/VersionInfo.cs rename to windows/src/models/VersionInfo.cs diff --git a/src/models/WindowState.cs b/windows/src/models/WindowState.cs similarity index 100% rename from src/models/WindowState.cs rename to windows/src/models/WindowState.cs diff --git a/version-info.json b/windows/version-info.json similarity index 100% rename from version-info.json rename to windows/version-info.json From c5c85ce91f079af77398a2972549714dd0e828a3 Mon Sep 17 00:00:00 2001 From: Peng Cheng Date: Tue, 16 Jun 2026 14:11:15 +0800 Subject: [PATCH 2/3] fix(macos): annotate WelcomeView @MainActor for Swift 6 concurrency The setup view's async methods called the @MainActor OllamaModelManager across an actor boundary. Swift 6.3 (local Xcode) inferred isolation and let it pass, but the CI toolchain (swift 6.0) rejected it, failing the macOS build. Mark the SwiftUI view @MainActor and modernize the speech permission callback to Task { @MainActor in }. Co-Authored-By: Claude Opus 4.8 --- macos/LCTMac/Views/WelcomeView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/macos/LCTMac/Views/WelcomeView.swift b/macos/LCTMac/Views/WelcomeView.swift index a9cc31d..3ce5317 100644 --- a/macos/LCTMac/Views/WelcomeView.swift +++ b/macos/LCTMac/Views/WelcomeView.swift @@ -4,6 +4,7 @@ import AVFoundation @preconcurrency import ScreenCaptureKit /// Welcome/Setup view for first-time users +@MainActor struct WelcomeView: View { @StateObject private var guardian = OllamaGuardian.shared @StateObject private var modelManager = OllamaModelManager() @@ -889,8 +890,8 @@ struct WelcomeView: View { } private func requestSpeechRecognitionPermission() { - SFSpeechRecognizer.requestAuthorization { @Sendable status in - DispatchQueue.main.async { [self] in + SFSpeechRecognizer.requestAuthorization { status in + Task { @MainActor in hasSpeechRecognitionPermission = (status == .authorized) } } From 5c9feca9447fbb441efdf6bbeee4d40bb51b0d6e Mon Sep 17 00:00:00 2001 From: Peng Cheng Date: Tue, 16 Jun 2026 14:16:46 +0800 Subject: [PATCH 3/3] fix(macos): annotate all SwiftUI views @MainActor for Swift 6.0 The CI toolchain (swift 6.0) does not infer MainActor isolation for SwiftUI App/View types the way local Xcode (swift 6.3) does, so every view touching @MainActor state (view models, OllamaGuardian, NSSavePanel) failed to compile. Annotate the views and app explicitly so the build is correct on any Swift 6.x toolchain. Co-Authored-By: Claude Opus 4.8 --- macos/LCTMac/App/LCTMacApp.swift | 2 ++ macos/LCTMac/Views/HistoryView.swift | 2 ++ macos/LCTMac/Views/MainView.swift | 5 +++++ macos/LCTMac/Views/OverlayView.swift | 3 +++ macos/LCTMac/Views/SettingsView.swift | 2 ++ 5 files changed, 14 insertions(+) diff --git a/macos/LCTMac/App/LCTMacApp.swift b/macos/LCTMac/App/LCTMacApp.swift index d2419eb..fc78182 100644 --- a/macos/LCTMac/App/LCTMacApp.swift +++ b/macos/LCTMac/App/LCTMacApp.swift @@ -2,6 +2,7 @@ import SwiftUI /// LCT for macOS - Main Application Entry Point @main +@MainActor struct LCTMacApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @State private var showWelcome: Bool = { @@ -83,6 +84,7 @@ struct LCTMacApp: App { } /// Settings window view wrapper +@MainActor struct SettingsWindowView: View { @State private var settings = AppSettings.load() diff --git a/macos/LCTMac/Views/HistoryView.swift b/macos/LCTMac/Views/HistoryView.swift index 96ea67e..0a4c38a 100644 --- a/macos/LCTMac/Views/HistoryView.swift +++ b/macos/LCTMac/Views/HistoryView.swift @@ -1,6 +1,7 @@ import SwiftUI /// History view displaying past translations from SQLite persistence +@MainActor struct HistoryView: View { @ObservedObject var viewModel: TranscriptionViewModel @@ -195,6 +196,7 @@ struct HistoryView: View { } /// Individual history entry row +@MainActor struct HistoryEntryRow: View { let entry: TranslationEntry diff --git a/macos/LCTMac/Views/MainView.swift b/macos/LCTMac/Views/MainView.swift index 1e77a67..8ab5e70 100644 --- a/macos/LCTMac/Views/MainView.swift +++ b/macos/LCTMac/Views/MainView.swift @@ -14,6 +14,7 @@ enum HUD { } /// Main application view with transcription and translation display +@MainActor struct MainView: View { @StateObject private var viewModel = TranscriptionViewModel() @StateObject private var overlayController = OverlayWindowController() @@ -434,6 +435,7 @@ struct MainView: View { // MARK: - Transcript Segment Row +@MainActor struct TranscriptSegmentRow: View { let segment: TranslationSegment @@ -513,6 +515,7 @@ struct TranscriptSegmentRow: View { // MARK: - Blinking Cursor +@MainActor struct BlinkingCursor: View { @State private var isOn = false @@ -531,6 +534,7 @@ struct BlinkingCursor: View { // MARK: - Audio Bars View +@MainActor struct AudioBarsView: View { let level: Float let isActive: Bool @@ -558,6 +562,7 @@ struct AudioBarsView: View { // MARK: - Ollama Status Indicator +@MainActor struct OllamaStatusIndicator: View { let isConnected: Bool @State private var isHovering = false diff --git a/macos/LCTMac/Views/OverlayView.swift b/macos/LCTMac/Views/OverlayView.swift index 3407cfa..cc05d16 100644 --- a/macos/LCTMac/Views/OverlayView.swift +++ b/macos/LCTMac/Views/OverlayView.swift @@ -3,6 +3,7 @@ import AppKit import Foundation /// Overlay window for floating transcription display +@MainActor struct OverlayView: View { @ObservedObject var viewModel: TranscriptionViewModel @State private var isHovering = false @@ -126,6 +127,7 @@ struct OverlayView: View { } /// Translation pair in overlay +@MainActor struct OverlayTranslationPair: View { let original: String let translated: String @@ -158,6 +160,7 @@ struct OverlayTranslationPair: View { } /// Typing dots animation for overlay +@MainActor struct OverlayTypingDots: View { @State private var dotIndex = 0 @State private var timer: Timer? diff --git a/macos/LCTMac/Views/SettingsView.swift b/macos/LCTMac/Views/SettingsView.swift index a728f08..126e886 100644 --- a/macos/LCTMac/Views/SettingsView.swift +++ b/macos/LCTMac/Views/SettingsView.swift @@ -3,6 +3,7 @@ import Speech import AVFoundation /// Settings view for configuring the application +@MainActor struct SettingsView: View { @Binding var settings: AppSettings let onSave: (AppSettings) -> Void @@ -448,6 +449,7 @@ struct SettingsView: View { } /// Prompt editor sheet +@MainActor struct PromptEditorView: View { @Binding var prompt: String @Environment(\.dismiss) private var dismiss