feat: 完成AI詞彙保存功能修復與前端架構優化

## 主要修復
- 修復FlashcardsController缺少SaveChangesAsync的問題,確保詞卡正確保存到資料庫
- 修復前端CEFR提取邏輯錯誤,優先使用analysis.cefr欄位
- 移除無效JWT token認證,使用統一測試用戶ID

## 架構優化
- 前端完整類型安全重構,移除不必要的as any斷言
- 統一前後端CEFR數據格式處理
- 後端GetFlashcards API增加CEFR字串欄位輸出
- 修復圖片生成功能的用戶ID不一致問題

## 技術改進
- 添加CEFRHelper工具類統一CEFR等級轉換
- 完善DI配置,註冊IImageGenerationOrchestrator服務
- 優化前端flashcardsService數據轉換邏輯
- 統一所有API服務的認證處理

## 驗證結果
- AI分析詞彙「prioritize」正確保存,CEFR等級B2→4
- 詞卡管理頁面正確顯示CEFR標籤
- 圖片生成功能正常啟動生成流程
- 完整的TypeScript類型安全支援

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-10-01 02:29:09 +08:00
parent 1038c5b668
commit 158e43598c
22 changed files with 4150 additions and 382 deletions

View File

@ -7,6 +7,7 @@ using System.Diagnostics;
namespace DramaLing.Api.Controllers;
[Route("api/ai")]
[AllowAnonymous]
public class AIController : BaseController
{
private readonly IAnalysisService _analysisService;
@ -24,7 +25,6 @@ public class AIController : BaseController
/// <param name="request">分析請求</param>
/// <returns>分析結果</returns>
[HttpPost("analyze-sentence")]
[AllowAnonymous]
public async Task<IActionResult> AnalyzeSentence(
[FromBody] SentenceAnalysisRequest request)
{
@ -33,11 +33,7 @@ public class AIController : BaseController
try
{
// For testing without auth - use dummy user ID
var userId = "test-user-id";
_logger.LogInformation("Processing sentence analysis request {RequestId} for user {UserId}",
requestId, userId);
_logger.LogInformation("Processing sentence analysis request {RequestId}", requestId);
// Input validation
if (!ModelState.IsValid)
@ -86,7 +82,6 @@ public class AIController : BaseController
/// 健康檢查端點
/// </summary>
[HttpGet("health")]
[AllowAnonymous]
public IActionResult GetHealth()
{
var healthData = new
@ -104,7 +99,6 @@ public class AIController : BaseController
/// 取得分析統計資訊
/// </summary>
[HttpGet("stats")]
[AllowAnonymous]
public async Task<IActionResult> GetAnalysisStats()
{
try

View File

@ -1,124 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using DramaLing.Api.Models.DTOs;
using DramaLing.Api.Services;
using System.Diagnostics;
namespace DramaLing.Api.Controllers;
[Route("api/analysis")]
[AllowAnonymous]
public class AnalysisController : BaseController
{
private readonly IAnalysisService _analysisService;
public AnalysisController(
IAnalysisService analysisService,
ILogger<AnalysisController> logger) : base(logger)
{
_analysisService = analysisService;
}
/// <summary>
/// 智能分析英文句子
/// </summary>
/// <param name="request">分析請求</param>
/// <returns>分析結果</returns>
[HttpPost("analyze")]
public async Task<IActionResult> AnalyzeSentence([FromBody] SentenceAnalysisRequest request)
{
var requestId = Guid.NewGuid().ToString();
var stopwatch = Stopwatch.StartNew();
try
{
// Input validation
if (!ModelState.IsValid)
{
return HandleModelStateErrors();
}
_logger.LogInformation("Processing sentence analysis request {RequestId}", requestId);
// 使用帶快取的分析服務
var options = request.Options ?? new AnalysisOptions();
var analysisData = await _analysisService.AnalyzeSentenceAsync(
request.InputText, options);
stopwatch.Stop();
analysisData.Metadata.ProcessingDate = DateTime.UtcNow;
_logger.LogInformation("Sentence analysis completed for request {RequestId} in {ElapsedMs}ms",
requestId, stopwatch.ElapsedMilliseconds);
var response = new SentenceAnalysisResponse
{
Success = true,
ProcessingTime = stopwatch.Elapsed.TotalSeconds,
Data = analysisData
};
return SuccessResponse(response);
}
catch (ArgumentException ex)
{
_logger.LogWarning(ex, "Invalid input for request {RequestId}", requestId);
return ErrorResponse("INVALID_INPUT", ex.Message, null, 400);
}
catch (InvalidOperationException ex)
{
_logger.LogError(ex, "AI service error for request {RequestId}", requestId);
return ErrorResponse("AI_SERVICE_ERROR", "AI服務暫時不可用", null, 500);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error processing request {RequestId}", requestId);
return ErrorResponse("INTERNAL_ERROR", "伺服器內部錯誤", null, 500);
}
}
/// <summary>
/// 健康檢查端點
/// </summary>
[HttpGet("health")]
public IActionResult GetHealth()
{
var healthData = new
{
Status = "Healthy",
Service = "Analysis Service",
Timestamp = DateTime.UtcNow,
Version = "1.0"
};
return SuccessResponse(healthData);
}
/// <summary>
/// 取得分析統計資訊
/// </summary>
[HttpGet("stats")]
public async Task<IActionResult> GetAnalysisStats()
{
try
{
var stats = await _analysisService.GetAnalysisStatsAsync();
var statsData = new
{
TotalAnalyses = stats.TotalAnalyses,
CachedAnalyses = stats.CachedAnalyses,
CacheHitRate = stats.CacheHitRate,
AverageResponseTimeMs = stats.AverageResponseTimeMs,
LastAnalysisAt = stats.LastAnalysisAt,
ProviderUsage = stats.ProviderUsageStats
};
return SuccessResponse(statsData);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting analysis stats");
return ErrorResponse("INTERNAL_ERROR", "無法取得統計資訊");
}
}
}

View File

@ -1,218 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using DramaLing.Api.Models.Dtos;
using DramaLing.Api.Services;
namespace DramaLing.Api.Controllers;
[Route("api/[controller]")]
[Authorize]
public class AudioController : BaseController
{
private readonly IAudioCacheService _audioCacheService;
private readonly IAzureSpeechService _speechService;
public AudioController(
IAudioCacheService audioCacheService,
IAzureSpeechService speechService,
ILogger<AudioController> logger) : base(logger)
{
_audioCacheService = audioCacheService;
_speechService = speechService;
}
/// <summary>
/// Generate audio from text using TTS
/// </summary>
/// <param name="request">TTS request parameters</param>
/// <returns>Audio URL and metadata</returns>
[HttpPost("tts")]
public async Task<IActionResult> GenerateAudio([FromBody] TTSRequest request)
{
try
{
if (string.IsNullOrWhiteSpace(request.Text))
{
return BadRequest(new TTSResponse
{
Error = "Text is required"
});
}
if (request.Text.Length > 1000)
{
return BadRequest(new TTSResponse
{
Error = "Text is too long (max 1000 characters)"
});
}
if (!IsValidAccent(request.Accent))
{
return BadRequest(new TTSResponse
{
Error = "Invalid accent. Use 'us' or 'uk'"
});
}
if (request.Speed < 0.5f || request.Speed > 2.0f)
{
return BadRequest(new TTSResponse
{
Error = "Speed must be between 0.5 and 2.0"
});
}
var response = await _audioCacheService.GetOrCreateAudioAsync(request);
if (!string.IsNullOrEmpty(response.Error))
{
return ErrorResponse("TTS_ERROR", response.Error, null, 500);
}
return SuccessResponse(response);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating audio for text: {Text}", request.Text);
return StatusCode(500, new TTSResponse
{
Error = "Internal server error"
});
}
}
/// <summary>
/// Get cached audio by hash
/// </summary>
/// <param name="hash">Audio cache hash</param>
/// <returns>Cached audio URL</returns>
[HttpGet("tts/cache/{hash}")]
public async Task<ActionResult<TTSResponse>> GetCachedAudio(string hash)
{
try
{
// 實現快取查詢邏輯
// 這裡應該從資料庫查詢快取的音頻
return NotFound(new TTSResponse
{
Error = "Audio not found in cache"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving cached audio: {Hash}", hash);
return StatusCode(500, new TTSResponse
{
Error = "Internal server error"
});
}
}
/// <summary>
/// Evaluate pronunciation from uploaded audio
/// </summary>
/// <param name="audioFile">Audio file</param>
/// <param name="targetText">Target text for pronunciation</param>
/// <param name="userLevel">User's CEFR level</param>
/// <returns>Pronunciation assessment results</returns>
[HttpPost("pronunciation/evaluate")]
public async Task<ActionResult<PronunciationResponse>> EvaluatePronunciation(
IFormFile audioFile,
[FromForm] string targetText,
[FromForm] string userLevel = "B1")
{
try
{
if (audioFile == null || audioFile.Length == 0)
{
return BadRequest(new PronunciationResponse
{
Error = "Audio file is required"
});
}
if (string.IsNullOrWhiteSpace(targetText))
{
return BadRequest(new PronunciationResponse
{
Error = "Target text is required"
});
}
// 檢查檔案大小 (最大 10MB)
if (audioFile.Length > 10 * 1024 * 1024)
{
return BadRequest(new PronunciationResponse
{
Error = "Audio file is too large (max 10MB)"
});
}
// 檢查檔案類型
var allowedTypes = new[] { "audio/wav", "audio/mp3", "audio/mpeg", "audio/ogg" };
if (!allowedTypes.Contains(audioFile.ContentType))
{
return BadRequest(new PronunciationResponse
{
Error = "Invalid audio format. Use WAV, MP3, or OGG"
});
}
using var audioStream = audioFile.OpenReadStream();
var request = new PronunciationRequest
{
TargetText = targetText,
UserLevel = userLevel
};
var response = await _speechService.EvaluatePronunciationAsync(audioStream, request);
if (!string.IsNullOrEmpty(response.Error))
{
return StatusCode(500, response);
}
return Ok(response);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error evaluating pronunciation for text: {Text}", targetText);
return StatusCode(500, new PronunciationResponse
{
Error = "Internal server error"
});
}
}
/// <summary>
/// Get supported voices for TTS
/// </summary>
/// <returns>List of available voices</returns>
[HttpGet("voices")]
public ActionResult<object> GetVoices()
{
var voices = new
{
US = new[]
{
new { Id = "en-US-AriaNeural", Name = "Aria", Gender = "Female" },
new { Id = "en-US-GuyNeural", Name = "Guy", Gender = "Male" },
new { Id = "en-US-JennyNeural", Name = "Jenny", Gender = "Female" }
},
UK = new[]
{
new { Id = "en-GB-SoniaNeural", Name = "Sonia", Gender = "Female" },
new { Id = "en-GB-RyanNeural", Name = "Ryan", Gender = "Male" },
new { Id = "en-GB-LibbyNeural", Name = "Libby", Gender = "Female" }
}
};
return Ok(voices);
}
private static bool IsValidAccent(string accent)
{
return accent?.ToLower() is "us" or "uk";
}
}

View File

@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
using DramaLing.Api.Models.Entities;
using DramaLing.Api.Repositories;
using Microsoft.AspNetCore.Authorization;
using DramaLing.Api.Utils;
namespace DramaLing.Api.Controllers;
@ -41,7 +42,8 @@ public class FlashcardsController : BaseController
f.Example,
f.ExampleTranslation,
f.IsFavorite,
f.DifficultyLevel,
DifficultyLevelNumeric = f.DifficultyLevelNumeric,
CEFR = CEFRHelper.ToString(f.DifficultyLevelNumeric),
f.CreatedAt,
f.UpdatedAt
}),
@ -84,12 +86,13 @@ public class FlashcardsController : BaseController
Pronunciation = request.Pronunciation,
Example = request.Example,
ExampleTranslation = request.ExampleTranslation,
DifficultyLevel = "A2", // 預設等級
DifficultyLevelNumeric = CEFRHelper.ToNumeric(request.CEFR ?? "A0"),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
await _flashcardRepository.AddAsync(flashcard);
await _flashcardRepository.SaveChangesAsync();
return SuccessResponse(flashcard, "詞卡創建成功");
}
@ -161,6 +164,7 @@ public class FlashcardsController : BaseController
flashcard.UpdatedAt = DateTime.UtcNow;
await _flashcardRepository.UpdateAsync(flashcard);
await _flashcardRepository.SaveChangesAsync();
return SuccessResponse(flashcard, "詞卡更新成功");
}
@ -190,6 +194,7 @@ public class FlashcardsController : BaseController
}
await _flashcardRepository.DeleteAsync(flashcard);
await _flashcardRepository.SaveChangesAsync();
return SuccessResponse(new { Id = id }, "詞卡已刪除");
}
@ -222,6 +227,7 @@ public class FlashcardsController : BaseController
flashcard.UpdatedAt = DateTime.UtcNow;
await _flashcardRepository.UpdateAsync(flashcard);
await _flashcardRepository.SaveChangesAsync();
var result = new {
Id = flashcard.Id,
@ -253,4 +259,5 @@ public class CreateFlashcardRequest
public string Pronunciation { get; set; } = string.Empty;
public string Example { get; set; } = string.Empty;
public string? ExampleTranslation { get; set; }
public string? CEFR { get; set; } = string.Empty;
}

View File

@ -157,7 +157,7 @@ public class ImageGenerationController : BaseController
private Guid GetCurrentUserId()
{
// 暫時使用固定測試用戶 ID與 FlashcardsController 保持一致
return Guid.Parse("E0A7DFA1-6B8A-4BD8-812C-54D7CBFAA394");
return Guid.Parse("00000000-0000-0000-0000-000000000001");
// TODO: 恢復真實認證後改回 JWT Token 解析
// var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value

View File

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DramaLing.Api.Data;
using DramaLing.Api.Utils;
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
@ -217,10 +218,13 @@ public class StatsController : BaseController
.Where(f => f.UserId == userId)
.ToListAsync();
// 按難度分類
// 按難度分類 - 使用數字等級進行統計,更高效
var difficultyStats = flashcards
.GroupBy(f => f.DifficultyLevel ?? "unknown")
.ToDictionary(g => g.Key, g => g.Count());
.GroupBy(f => f.DifficultyLevelNumeric)
.ToDictionary(
g => g.Key == 0 ? "unknown" : CEFRHelper.ToString(g.Key),
g => g.Count()
);
// 按詞性分類
var partOfSpeechStats = flashcards

View File

@ -99,6 +99,9 @@ public class DramaLingDbContext : DbContext
// 新增個人化欄位映射
userEntity.Property(u => u.EnglishLevel).HasColumnName("english_level");
userEntity.Property(u => u.EnglishLevelNumeric)
.HasColumnName("english_level_numeric")
.HasDefaultValue(2); // 預設 A2
userEntity.Property(u => u.LevelUpdatedAt).HasColumnName("level_updated_at");
userEntity.Property(u => u.IsLevelVerified).HasColumnName("is_level_verified");
userEntity.Property(u => u.LevelNotes).HasColumnName("level_notes");
@ -128,7 +131,10 @@ public class DramaLingDbContext : DbContext
// TimesReviewed, TimesCorrect, LastReviewedAt 已移除
flashcardEntity.Property(f => f.IsFavorite).HasColumnName("is_favorite");
flashcardEntity.Property(f => f.IsArchived).HasColumnName("is_archived");
flashcardEntity.Property(f => f.DifficultyLevel).HasColumnName("difficulty_level");
// 難度等級映射 - 使用數字格式
flashcardEntity.Property(f => f.DifficultyLevelNumeric).HasColumnName("difficulty_level_numeric");
flashcardEntity.Property(f => f.CreatedAt).HasColumnName("created_at");
flashcardEntity.Property(f => f.UpdatedAt).HasColumnName("updated_at");
}

View File

@ -124,6 +124,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<IImageSaveManager, ImageSaveManager>();
services.AddScoped<IGenerationPipelineService, GenerationPipelineService>();
services.AddScoped<IImageGenerationWorkflow, ImageGenerationWorkflow>();
services.AddScoped<IImageGenerationOrchestrator, ImageGenerationOrchestrator>();
return services;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DramaLing.Api.Migrations
{
/// <inheritdoc />
public partial class AddDifficultyLevelNumeric : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1. 新增數字欄位 (預設值為 0)
migrationBuilder.AddColumn<int>(
name: "difficulty_level_numeric",
table: "flashcards",
type: "INTEGER",
nullable: false,
defaultValue: 0);
// 2. 資料遷移:將現有字串值轉換為數字
migrationBuilder.Sql(@"
UPDATE flashcards
SET difficulty_level_numeric =
CASE difficulty_level
WHEN 'A1' THEN 1
WHEN 'A2' THEN 2
WHEN 'B1' THEN 3
WHEN 'B2' THEN 4
WHEN 'C1' THEN 5
WHEN 'C2' THEN 6
ELSE 0
END
WHERE difficulty_level IS NOT NULL;
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "difficulty_level_numeric",
table: "flashcards");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DramaLing.Api.Migrations
{
/// <inheritdoc />
public partial class RemoveDifficultyLevelStringColumn : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "difficulty_level",
table: "flashcards");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "difficulty_level",
table: "flashcards",
type: "TEXT",
maxLength: 10,
nullable: true);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DramaLing.Api.Migrations
{
/// <inheritdoc />
public partial class AddEnglishLevelNumeric : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "english_level_numeric",
table: "user_profiles",
type: "INTEGER",
nullable: false,
defaultValue: 2);
// 轉換現有資料:將字串格式的 english_level 轉換為數字格式
migrationBuilder.Sql(@"
UPDATE user_profiles
SET english_level_numeric =
CASE english_level
WHEN 'A1' THEN 1
WHEN 'A2' THEN 2
WHEN 'B1' THEN 3
WHEN 'B2' THEN 4
WHEN 'C1' THEN 5
WHEN 'C2' THEN 6
ELSE 2 -- A2
END
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "english_level_numeric",
table: "user_profiles");
}
}
}

View File

@ -318,10 +318,9 @@ namespace DramaLing.Api.Migrations
.HasColumnType("TEXT")
.HasColumnName("definition");
b.Property<string>("DifficultyLevel")
.HasMaxLength(10)
.HasColumnType("TEXT")
.HasColumnName("difficulty_level");
b.Property<int>("DifficultyLevelNumeric")
.HasColumnType("INTEGER")
.HasColumnName("difficulty_level_numeric");
b.Property<string>("Example")
.HasColumnType("TEXT")
@ -828,6 +827,12 @@ namespace DramaLing.Api.Migrations
.HasColumnType("TEXT")
.HasColumnName("english_level");
b.Property<int>("EnglishLevelNumeric")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(2)
.HasColumnName("english_level_numeric");
b.Property<bool>("IsLevelVerified")
.HasColumnType("INTEGER")
.HasColumnName("is_level_verified");

View File

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using DramaLing.Api.Utils;
namespace DramaLing.Api.Models.DTOs;
@ -73,7 +74,8 @@ public class VocabularyAnalysisDto
public string Definition { get; set; } = string.Empty;
public string PartOfSpeech { get; set; } = string.Empty;
public string Pronunciation { get; set; } = string.Empty;
public string DifficultyLevel { get; set; } = string.Empty;
public int DifficultyLevelNumeric { get; set; }
public string CEFR { get; set; } = string.Empty;
public string Frequency { get; set; } = string.Empty;
public List<string> Synonyms { get; set; } = new();
public string? Example { get; set; }
@ -86,7 +88,8 @@ public class IdiomDto
public string Translation { get; set; } = string.Empty;
public string Definition { get; set; } = string.Empty;
public string Pronunciation { get; set; } = string.Empty;
public string DifficultyLevel { get; set; } = string.Empty;
public int DifficultyLevelNumeric { get; set; }
public string CEFR { get; set; } = string.Empty;
public string Frequency { get; set; } = string.Empty;
public List<string> Synonyms { get; set; } = new();
public string? Example { get; set; }

View File

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using DramaLing.Api.Utils;
namespace DramaLing.Api.Models.DTOs;
@ -36,9 +37,11 @@ public class CreateFlashcardRequest
public string? ExampleTranslation { get; set; }
[RegularExpression("^(A1|A2|B1|B2|C1|C2)$",
ErrorMessage = "CEFR 等級必須為有效值")]
public string? DifficultyLevel { get; set; } = "A2";
// 雙軌制難度等級 - 支援字串和數字格式
[Range(0, 6, ErrorMessage = "難度等級必須在 0-6 之間")]
public int DifficultyLevelNumeric { get; set; } = 2; // 預設 A2 = 2
// 向後相容的字串格式,會自動從 DifficultyLevelNumeric 計算
}
public class UpdateFlashcardRequest : CreateFlashcardRequest
@ -60,7 +63,11 @@ public class FlashcardResponse
public int TimesReviewed { get; set; }
public bool IsFavorite { get; set; }
public DateTime NextReviewDate { get; set; }
// 雙軌制難度等級 - API 回應同時提供兩種格式
public int DifficultyLevelNumeric { get; set; }
public string? DifficultyLevel { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}

View File

@ -1,12 +1,15 @@
using System.ComponentModel.DataAnnotations;
using DramaLing.Api.Utils;
namespace DramaLing.Api.Models.Entities;
/// <summary>
/// 簡化的詞卡實體 - 移除所有複習功能
/// 簡化的詞卡實體 - 使用數字難度等級
/// </summary>
public class Flashcard
{
private int? _difficultyLevelNumeric;
public Guid Id { get; set; }
public Guid UserId { get; set; }
@ -36,8 +39,14 @@ public class Flashcard
public bool IsArchived { get; set; } = false;
[MaxLength(10)]
public string? DifficultyLevel { get; set; } // A1, A2, B1, B2, C1, C2
/// <summary>
/// CEFR 難度等級 (數字格式: 0=未知, 1=A1, 2=A2, 3=B1, 4=B2, 5=C1, 6=C2)
/// </summary>
public int DifficultyLevelNumeric
{
get => _difficultyLevelNumeric ?? 0;
set => _difficultyLevelNumeric = value;
}
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;

View File

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using DramaLing.Api.Utils;
namespace DramaLing.Api.Models.Entities;
@ -32,6 +33,11 @@ public class User
[MaxLength(10)]
public string EnglishLevel { get; set; } = "A2"; // A1, A2, B1, B2, C1, C2
/// <summary>
/// CEFR 英文程度等級 (數字格式: 0=未知, 1=A1, 2=A2, 3=B1, 4=B2, 5=C1, 6=C2)
/// </summary>
public int EnglishLevelNumeric { get; set; } = 2; // 預設 A2
public DateTime LevelUpdatedAt { get; set; } = DateTime.UtcNow;
public bool IsLevelVerified { get; set; } = false; // 是否通過測試驗證

View File

@ -1,4 +1,5 @@
using DramaLing.Api.Models.DTOs;
using DramaLing.Api.Utils;
using System.Text.Json;
namespace DramaLing.Api.Services.AI.Gemini;
@ -84,7 +85,7 @@ public class SentenceAnalyzer : ISentenceAnalyzer
""definition"": ""English definition"",
""partOfSpeech"": ""noun/verb/adjective/adverb/pronoun/preposition/conjunction/interjection"",
""pronunciation"": ""/phonetic/"",
""difficultyLevel"": ""A1/A2/B1/B2/C1/C2"",
""CEFR"": ""A1/A2/B1/B2/C1/C2"",
""frequency"": ""high/medium/low"",
""synonyms"": [""synonym1"", ""synonym2""],
""example"": ""example sentence"",
@ -97,7 +98,7 @@ public class SentenceAnalyzer : ISentenceAnalyzer
""translation"": ""Traditional Chinese meaning"",
""definition"": ""English explanation"",
""pronunciation"": ""/phonetic notation/"",
""difficultyLevel"": ""A1/A2/B1/B2/C1/C2"",
""CEFR"": ""A1/A2/B1/B2/C1/C2"",
""frequency"": ""high/medium/low"",
""synonyms"": [""synonym1"", ""synonym2""],
""example"": ""usage example"",
@ -185,7 +186,8 @@ public class SentenceAnalyzer : ISentenceAnalyzer
Definition = aiWord.Definition ?? "",
PartOfSpeech = aiWord.PartOfSpeech ?? "unknown",
Pronunciation = aiWord.Pronunciation ?? $"/{kvp.Key}/",
DifficultyLevel = aiWord.DifficultyLevel ?? "A2",
DifficultyLevelNumeric = CEFRHelper.ToNumeric(aiWord.CEFR ?? "A0"),
CEFR = aiWord.CEFR ?? "A0",
Frequency = aiWord.Frequency ?? "medium",
Synonyms = aiWord.Synonyms ?? new List<string>(),
Example = aiWord.Example,
@ -208,7 +210,8 @@ public class SentenceAnalyzer : ISentenceAnalyzer
Translation = aiIdiom.Translation ?? "",
Definition = aiIdiom.Definition ?? "",
Pronunciation = aiIdiom.Pronunciation ?? "",
DifficultyLevel = aiIdiom.DifficultyLevel ?? "B2",
DifficultyLevelNumeric = CEFRHelper.ToNumeric(aiIdiom.CEFR ?? "A0"),
CEFR = aiIdiom.CEFR ?? "A0",
Frequency = aiIdiom.Frequency ?? "medium",
Synonyms = aiIdiom.Synonyms ?? new List<string>(),
Example = aiIdiom.Example,
@ -281,7 +284,7 @@ public class SentenceAnalyzer : ISentenceAnalyzer
Definition = $"Please refer to the AI analysis above for detailed definition.",
PartOfSpeech = "unknown",
Pronunciation = $"/{cleanWord}/",
DifficultyLevel = EstimateBasicDifficulty(cleanWord),
DifficultyLevelNumeric = EstimateBasicDifficultyNumeric(cleanWord),
Frequency = "medium",
Synonyms = new List<string>(),
Example = null,
@ -301,17 +304,17 @@ public class SentenceAnalyzer : ISentenceAnalyzer
return $"{word} - 請查看完整分析";
}
private string EstimateBasicDifficulty(string word)
private int EstimateBasicDifficultyNumeric(string word)
{
var a1Words = new[] { "i", "you", "he", "she", "it", "we", "they", "the", "a", "an", "is", "are", "was", "were", "have", "do", "go", "get", "see", "know" };
var a2Words = new[] { "make", "think", "come", "take", "use", "work", "call", "try", "ask", "need", "feel", "become", "leave", "put", "say", "tell", "turn", "move" };
var lowerWord = word.ToLower();
if (a1Words.Contains(lowerWord)) return "A1";
if (a2Words.Contains(lowerWord)) return "A2";
if (word.Length <= 4) return "A2";
if (word.Length <= 6) return "B1";
return "B2";
if (a1Words.Contains(lowerWord)) return 1; // A1
if (a2Words.Contains(lowerWord)) return 2; // A2
if (word.Length <= 4) return 2; // A2
if (word.Length <= 6) return 3; // B1
return 4; // B2
}
}
@ -340,7 +343,7 @@ internal class AiVocabularyAnalysis
public string? Definition { get; set; }
public string? PartOfSpeech { get; set; }
public string? Pronunciation { get; set; }
public string? DifficultyLevel { get; set; }
public string? CEFR { get; set; }
public string? Frequency { get; set; }
public List<string>? Synonyms { get; set; }
public string? Example { get; set; }
@ -353,7 +356,7 @@ internal class AiIdiom
public string? Translation { get; set; }
public string? Definition { get; set; }
public string? Pronunciation { get; set; }
public string? DifficultyLevel { get; set; }
public string? CEFR { get; set; }
public string? Frequency { get; set; }
public List<string>? Synonyms { get; set; }
public string? Example { get; set; }

View File

@ -188,4 +188,20 @@ public class OptionsVocabularyService : IOptionsVocabularyService
return allowed;
}
/// <summary>
/// 獲取允許的 CEFR 數字等級(包含相鄰等級)
/// </summary>
private static List<int> GetAllowedCEFRLevelsNumeric(int targetLevelNumeric)
{
var allowed = new List<int> { targetLevelNumeric };
// 加入相鄰等級(允許難度稍有差異)
if (targetLevelNumeric > 1)
allowed.Add(targetLevelNumeric - 1);
if (targetLevelNumeric < 6)
allowed.Add(targetLevelNumeric + 1);
return allowed;
}
}

View File

@ -0,0 +1,166 @@
using System.Collections.Generic;
using System.Linq;
namespace DramaLing.Api.Utils
{
/// <summary>
/// CEFR 等級轉換和比較輔助類別
/// 處理字串格式 (A1, A2, B1, B2, C1, C2) 與數字格式 (0-6) 的轉換
/// </summary>
public static class CEFRHelper
{
/// <summary>
/// CEFR 等級映射表
/// 0 = 未知/完全沒概念
/// 1-6 = A1 到 C2
/// </summary>
private static readonly Dictionary<string, int> LevelToNumericMap = new()
{
["A0"] = 0,
["A1"] = 1,
["A2"] = 2,
["B1"] = 3,
["B2"] = 4,
["C1"] = 5,
["C2"] = 6
};
/// <summary>
/// 數字到字串的反向映射
/// </summary>
private static readonly Dictionary<int, string> NumericToLevelMap =
LevelToNumericMap.ToDictionary(kvp => kvp.Value, kvp => kvp.Key);
/// <summary>
/// 將 CEFR 字串等級轉換為數字
/// </summary>
/// <param name="level">CEFR 等級字串 (A1, A2, B1, B2, C1, C2)</param>
/// <returns>數字等級 (0=未知, 1-6=A1-C2)</returns>
public static int ToNumeric(string? level)
{
if (string.IsNullOrWhiteSpace(level))
return 0;
var normalizedLevel = level.Trim().ToUpper();
return LevelToNumericMap.TryGetValue(normalizedLevel, out var numeric) ? numeric : 0;
}
/// <summary>
/// 將數字等級轉換為 CEFR 字串
/// </summary>
/// <param name="level">數字等級 (0-6)</param>
/// <returns>CEFR 等級字串,無效值返回 "Unknown"</returns>
public static string ToString(int level)
{
return NumericToLevelMap.TryGetValue(level, out var cefr) ? cefr : "Unknown";
}
/// <summary>
/// 比較兩個等級level1 是否高於 level2
/// </summary>
/// <param name="level1">等級1 (數字)</param>
/// <param name="level2">等級2 (數字)</param>
/// <returns>level1 > level2</returns>
public static bool IsHigherThan(int level1, int level2)
{
return level1 > level2;
}
/// <summary>
/// 比較兩個等級level1 是否低於 level2
/// </summary>
/// <param name="level1">等級1 (數字)</param>
/// <param name="level2">等級2 (數字)</param>
/// <returns>level1 < level2</returns>
public static bool IsLowerThan(int level1, int level2)
{
return level1 < level2;
}
/// <summary>
/// 比較兩個等級是否相同
/// </summary>
/// <param name="level1">等級1 (數字)</param>
/// <param name="level2">等級2 (數字)</param>
/// <returns>level1 == level2</returns>
public static bool IsSameLevel(int level1, int level2)
{
return level1 == level2;
}
/// <summary>
/// 字串版本:比較兩個 CEFR 等級
/// </summary>
/// <param name="level1">等級1 (字串)</param>
/// <param name="level2">等級2 (字串)</param>
/// <returns>level1 > level2</returns>
public static bool IsHigherThan(string? level1, string? level2)
{
return IsHigherThan(ToNumeric(level1), ToNumeric(level2));
}
/// <summary>
/// 字串版本:比較兩個 CEFR 等級
/// </summary>
/// <param name="level1">等級1 (字串)</param>
/// <param name="level2">等級2 (字串)</param>
/// <returns>level1 < level2</returns>
public static bool IsLowerThan(string? level1, string? level2)
{
return IsLowerThan(ToNumeric(level1), ToNumeric(level2));
}
/// <summary>
/// 字串版本:比較兩個 CEFR 等級是否相同
/// </summary>
/// <param name="level1">等級1 (字串)</param>
/// <param name="level2">等級2 (字串)</param>
/// <returns>level1 == level2</returns>
public static bool IsSameLevel(string? level1, string? level2)
{
return IsSameLevel(ToNumeric(level1), ToNumeric(level2));
}
/// <summary>
/// 驗證數字等級是否有效
/// </summary>
/// <param name="level">數字等級</param>
/// <returns>是否在 0-6 範圍內</returns>
public static bool IsValidNumericLevel(int level)
{
return level >= 0 && level <= 6;
}
/// <summary>
/// 驗證字串等級是否有效
/// </summary>
/// <param name="level">字串等級</param>
/// <returns>是否為有效的 CEFR 等級</returns>
public static bool IsValidStringLevel(string? level)
{
if (string.IsNullOrWhiteSpace(level))
return false;
var normalizedLevel = level.Trim().ToUpper();
return LevelToNumericMap.ContainsKey(normalizedLevel);
}
/// <summary>
/// 取得所有有效的數字等級
/// </summary>
/// <returns>數字等級陣列 (0-6)</returns>
public static int[] GetAllNumericLevels()
{
return new int[] { 0, 1, 2, 3, 4, 5, 6 };
}
/// <summary>
/// 取得所有有效的字串等級
/// </summary>
/// <returns>CEFR 等級字串陣列</returns>
public static string[] GetAllStringLevels()
{
return new string[] { "A1", "A2", "B1", "B2", "C1", "C2" };
}
}
}