Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/arabidopsisdev_recitehelper.json
T

1 line
104 KiB
JSON
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "arabidopsisdev/recitehelper", "files": {"ReciteHelper.Infrastructure/Services/KnowledgeBaseService.cs": {"21288821229eb8a1da7f5a38d248fdfd10104f80": {"t": 1785746215.5563538, "n": 19499, "h": "b55de41d91f179bc", "c": "using OpenAI;\nusing OpenAI.Chat;\nusing ReciteHelper.Core.Entities;\nusing ReciteHelper.Core.Interfaces.Configuration;\nusing ReciteHelper.Core.Interfaces.Services;\nusing System.ClientModel;\nusing System.Collections.Concurrent;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing OpenAI.Embeddings;\nusing ReciteHelper.Core.ValueObjects;\nusing ReciteHelper.Core.DTOs;\n\nnamespace ReciteHelper.Infrastructure.Services\n{\n public class KnowledgeBaseService(\n IConfigService cfgService,\n IAiChatService aiChatService,\n HostedModelService hostedModelService) : IKnowledgeBaseService\n {\n private readonly int _chunkSize = 1500;\n private readonly int _overlap = 50;\n\n private ChatClient? _chatClient;\n private EmbeddingClient? _embedClient;\n\n private const int MaxConcurrentRequests = 4;\n private const int BatchSize = 10;\n\n private IConfigService configService = cfgService;\n\n public async Task<FileVectorStore> Build(string projectPath, string text)\n {\n if (string.IsNullOrWhiteSpace(text))\n throw new InvalidOperationException(\"知识库构建失败:题库文本为空。\");\n\n var slices = Slice(text);\n if (slices.Count == 0)\n throw new InvalidOperationException(\"知识库构建失败:未切分出有效文本。\");\n\n var cfg = await configService.LoadAsync();\n var src = new CancellationTokenSource();\n\n if (!string.IsNullOrWhiteSpace(cfg.DeepSeekKey))\n {\n _chatClient = new OpenAIClient(new ApiKeyCredential(cfg.DeepSeekKey!), new OpenAIClientOptions\n {\n Endpoint = new Uri(\"https://api.deepseek.com\")\n }).GetChatClient(\"deepseek-v4-flash\");\n }\n\n if (!string.IsNullOrWhiteSpace(cfg.QwenKey))\n _embedClient = CreateQwenEmbeddingClient(cfg.QwenKey!);\n\n var cluster = await ClusterAsync(slices, src.Token);\n var embed = await EmbedAsync(cluster, src.Token);\n var elements = BuildVectorEntries(cluster, embed, src.Token);\n var fvs = BuildVectorStore(projectPath, elements);\n\n return fvs;\n }\n\n public async Task<IReadOnlyList<KnowledgeBaseMatch>> SearchAsync(\n FileVectorStore store,\n string query,\n int topK,\n CancellationToken cancellationToken = default)\n {\n if (store.Entries.Count == 0 || string.IsNullOrWhiteSpace(query) || topK <= 0)\n return [];\n\n var cfg = await configService.LoadAsync();\n var queryVector = !string.IsNullOrWhiteSpace(cfg.QwenKey)\n ? await GenerateQwenEmbeddingAsync(cfg.QwenKey, query.Trim(), cancellationToken)\n : (await hostedModelService.EmbedTextsAsync([query.Trim()], cancellationToken))[0];\n\n return store.Search(queryVector, topK)\n .Select(result => new KnowledgeBaseMatch(\n CreateMatchTitle(result.Entry),\n result.Entry.Text,\n result.Score))\n .ToList();\n }\n\n public async Task<IReadOnlyList<float[]>> EmbedTextsAsync(\n IReadOnlyList<string> texts,\n CancellationToken cancellationToken = default)\n {\n var normalizedTexts = texts\n .Select(text => text?.Trim() ?? string.Empty)\n .ToList();\n if (normalizedTexts.Count == 0)\n return [];\n if (normalizedTexts.Any(string.IsNullOrWhiteSpace))\n throw new ArgumentException(\"待向量化文本不能为空。\", nameof(texts));\n\n var cfg = await configService.LoadAsync();\n if (string.IsNullOrWhiteSpace(cfg.QwenKey))\n return await hostedModelService.EmbedTextsAsync(normalizedTexts, cancellationToken);\n\n var embedClient = CreateQwenEmbeddingClient(cfg.QwenKey);\n var results = new float[normalizedTexts.Count][];\n var batches = normalizedTexts\n .Select((text, index) => new { Text = text, Index = index })\n .Chunk(BatchSize)\n .ToList();\n\n using var semaphore = new SemaphoreSlim(MaxConcurrentRequests);\n var tasks = batches.Select(async batch =>\n {\n await semaphore.WaitAsync(cancellationToken);\n try\n {\n var response = await embedClient.GenerateEmbeddingsAsync(\n batch.Select(item => item.Text).ToList(),\n options: null,\n cancellationToken);\n\n for (var index = 0; index < batch.Length; index++)\n results[batch[index].Index] = response.Value[index].ToFloats().ToArray();\n }\n finally\n {\n semaphore.Release();\n }\n });\n\n await Task.WhenAll(tasks);\n return results;\n }\n\n private static EmbeddingClient CreateQwenEmbeddingClient(string qwenKey)\n {\n return new OpenAIClient(\n new ApiKeyCredential(qwenKey),\n new OpenAIClientOptions\n {\n Endpoint = new Uri(\"https://dashscope.aliyuncs.com/compatible-mode/v1\")\n }).GetEmbeddingClient(\"text-embedding-v4\");\n }\n\n private static async Task<float[]> GenerateQwenEmbeddingAsync(\n string qwenKey,\n string text,\n CancellationToken cancellationToken)\n {\n var embedClient = CreateQwenEmbeddingClient(qwenKey);\n var response = await embedClient.GenerateEmbeddingsAsync(\n [text],\n options: null,\n cancellationToken);\n return response.Value[0].ToFloats().ToArray();\n }\n\n private static string CreateMatchTitle(VectorEntry entry)\n {\n if (!string.IsNullOrWhiteSpace(entry.Semantics.Summary))\n return entry.Semantics.Summary;\n\n var tags = string.Join(\"、\", entry.Semantics.Tags.Where(x => !string.IsNullOrWhiteSpace(x)));\n if (!string.IsNullOrWhiteSpace(tags))\n return tags;\n\n const int maxLength = 24;\n return entry.Text.Length <= maxLength\n ? entry.Text\n : $\"{entry.Text[..maxLength]}...\";\n }\n\n public List<string> Slice(string text)\n {\n var slices = SplitText(text);\n\n return slices.Select(x => x.Replace(\"\\n\", \"\").Trim()).ToList();\n }\n\n\n public List<string> SplitText(string text)\n {\n if (string.IsNullOrWhiteSpace(text))\n return new List<string>();\n\n text = CleanText(text);\n var paragraphs = SplitByParagraphs(text);\n\n var chunks = new List<string>();\n foreach (var para in paragraphs)\n {\n chunks.AddRange(SplitParagraph(para));\n }\n\n return chunks;\n }\n\n private string CleanText(string text)\n {\n text = Regex.Replace(text, @\"\\n\\s*\\n\", \"\\n\\n\");\n text = text.Trim();\n return text;\n }\n\n private List<string> SplitByParagraphs(string text)\n {\n var paragraphs = new List<string>();\n var currentPara = new StringBuilder();\n\n using var reader = new StringReader(text);\n string? line;\n\n while ((line = reader.ReadLine()) != null)\n {\n if (string.IsNullOrWhiteSpace(line))\n {\n if (currentPara.Length > 0)\n {\n paragraphs.Add(currentPara.ToString().Trim());\n currentPara.Clear();\n }\n }\n else\n {\n currentPara.AppendLine(line);\n }\n }\n\n if (currentPara.Length > 0)\n {\n paragraphs.Add(currentPara.ToString().Trim());\n }\n\n return paragraphs;\n }\n\n private List<string> SplitParagraph(string paragraph)\n {\n if (paragraph.Length <= _chunkSize)\n {\n return new List<string> { paragraph };\n }\n\n var chunks = new List<string>();\n var sentences = SplitIntoSentences(paragraph);\n\n var currentChunk = new StringBuilder();\n\n foreach (var sentence in sentences)\n {\n if (currentChunk.Length + sentence.Length + 1 > _chunkSize && currentChunk.Length > 0)\n {\n chunks.Add(currentChunk.ToString().Trim());\n\n string overlap = GetOverlap(currentChunk.ToString());\n currentChunk.Clear();\n currentChunk.Append(overlap);\n }\n\n currentChunk.Append(sentence);\n }\n\n if (currentChunk.Length > 0)\n {\n chunks.Add(currentChunk.ToString().Trim());\n }\n\n return chunks;\n }\n\n private List<string> SplitIntoSentences(string text)\n {\n var separators = new[] { \"。\", \"!\", \"?\", \";\", \".\", \"!\", \"?\", \";\" };\n\n var sentences = new List<string>();\n var currentSentence = new StringBuilder();\n\n for (int i = 0; i < text.Length; i++)\n {\n currentSentence.Append(text[i]);\n\n foreach (var sep in separators)\n {\n if (text[i].ToString() == sep)\n {\n sentences.Add(currentSentence.ToString());\n currentSentence.Clear();\n break;\n }\n }\n }\n\n if (currentSentence.Length > 0)\n {\n sentences.Add(currentSentence.ToString());\n }\n\n return sentences;\n }\n\n private string GetOverlap(string chunk)\n {\n if (chunk.Length <= _overlap)\n return chunk;\n\n int start = chunk.Length - _overlap;\n\n int actualStart = chunk.Length;\n for (int i = start; i < chunk.Length; i++)\n {\n if (i > 0 && (chunk[i - 1] == '。' || chunk[i - 1] == '!' || chunk[i - 1] == '?'))\n {\n actualStart = i;\n break;\n }\n }\n\n if (actualStart >= chunk.Length)\n actualStart = start;\n\n return chunk.Substring(actualStart);\n }\n\n public async Task<Dictionary<Semantics, string>> ClusterAsync(List<string> chunks, CancellationToken cts = default)\n {\n var results = new ConcurrentDictionary<Semantics, string>();\n\n await Parallel.ForEachAsync(chunks,\n new ParallelOptions { MaxDegreeOfParallelism = MaxConcurrentRequests, CancellationToken = cts },\n async (chunk, _) =>\n {\n var segmentedSemantics = await ProcessChunkAsync(chunk);\n foreach (var item in segmentedSemantics)\n {\n results.TryAdd(item.Item1, item.Item2);\n }\n });\n\n return results.OrderBy(x => x.Key.Id).ToDictionary(x => x.Key, x => x.Value);\n }\n\n /// <summary>\n /// Process single chunk: segment by atomic knowledge points and generate semantics\n /// </summary>\n private async Task<List<(Semantics, string)>> ProcessChunkAsync(string chunk)\n {\n var prompt = BuildPrompt(chunk);\n\n const string instructions = \"你是一个专业的文本分析专家。请完成以下任务:\\n\" +\n \"1. 将输入的文本块按照知识点的原子性进行分割\\n\" +\n \"2. 确保每个分割后的段落只表达一个独立、完整的知识点\\n\" +\n \"3. 为每个知识点段落生成2-3个关键词标签\\n\" +\n \"4. 生成不超过20字的单句摘要\\n\" +\n \"5. 对于被截断的知识点,如果有明确的语义信息进行补全,否则直接丢弃\" +\n \"6. 返回JSON数组,每个元素包含text、tags、summary字段,无其他内容,不要添加markdown代码块结构\";\n\n if (_chatClient is null)\n {\n var hostedResponse = await aiChatService.RunAsync(string.Empty, prompt, instructions);\n return ParseResponse(ExtractJsonContent(hostedResponse));\n }\n\n var messages = new List<ChatMessage>\n {\n new SystemChatMessage(instructions),\n new UserChatMessage(prompt)\n };\n\n var response = await _chatClient.CompleteChatAsync(messages, new ChatCompletionOptions\n {\n Temperature = 0.3f,\n MaxOutputTokenCount = 66666\n });\n\n var json = ExtractJsonContent(response.Value.Content[0].Text);\n return ParseResponse(json);\n }\n\n private static string BuildPrompt(string chunk)\n {\n var sb = new StringBuilder();\n sb.AppendLine(\"请处理以下文本块:\");\n sb.AppendLine();\n sb.AppendLine(chunk);\n sb.AppendLine();\n sb.AppendLine(\"返回JSON数组格式,示例:\");\n sb.AppendLine(\"[\");\n sb.AppendLine(\" {\\\"text\\\": \\\"分割后的知识点1\\\", \\\"tags\\\": [\\\"标签1\\\", \\\"标签2\\\"], \\\"summary\\\": \\\"摘要\\\"},\");\n sb.AppendLine(\" {\\\"text\\\": \\\"分割后的知识点2\\\", \\\"tags\\\": [\\\"标签1\\\", \\\"标签2\\\"], \\\"summary\\\": \\\"摘要\\\"}\");\n sb.AppendLine(\"]\");\n\n return sb.ToString();\n }\n\n /// <summary>\n /// Parse response and return semantics-text pairs with auto-incremented IDs\n /// </summary>\n private static List<(Semantics, string)> ParseResponse(string json)\n {\n var result = new List<(Semantics, string)>();\n var idCounter = 0;\n\n using var doc = JsonDocument.Parse(json);\n var array = doc.RootElement.EnumerateArray().ToList();\n\n foreach (var item in array)\n {\n var text = item.TryGetProperty(\"text\", out var textProp)\n ? textProp.GetString() ?? \"\"\n : \"\";\n\n if (string.IsNullOrWhiteSpace(text))\n continue;\n\n var semantics = new Semantics\n {\n Id = idCounter++,\n Tags = item.TryGetProperty(\"tags\", out var tags)\n ? tags.EnumerateArray()\n .Select(t => t.GetString() ?? \"\")\n .Where(t => !string.IsNullOrWhiteSpace(t))\n .ToList()\n : [],\n Summary = item.TryGetProperty(\"summary\", out var summary)\n ? (summary.GetString() ?? \"\").Trim()\n : \"\"\n };\n\n result.Add((semantics, text.Trim()));\n }\n\n\n return result;\n }\n\n private static string ExtractJsonContent(string content)\n {\n if (string.IsNullOrWhiteSpace(content))\n return \"[]\";\n\n var trimmed = content.Trim();\n var fenceStart = trimmed.IndexOf(\"```\", StringComparison.Ordinal);\n if (fenceStart >= 0)\n {\n var jsonStart = trimmed.IndexOf('\\n', fenceStart);\n if (jsonStart >= 0)\n {\n var fenceEnd = trimmed.LastIndexOf(\"```\", StringComparison.Ordinal);\n trimmed = fenceEnd > jsonStart\n ? trimmed[(jsonStart + 1)..fenceEnd].Trim()\n : trimmed[(jsonStart + 1)..].Trim();\n }\n }\n\n var arrayStart = trimmed.IndexOf('[');\n var arrayEnd = trimmed.LastIndexOf(']');\n return arrayStart >= 0 && arrayEnd > arrayStart\n ? trimmed[arrayStart..(arrayEnd + 1)]\n : trimmed;\n }\n\n public async Task<Dictionary<Semantics, float[]>> EmbedAsync(\n Dictionary<Semantics, string> semanticChunks,\n CancellationToken cts)\n {\n if (semanticChunks.Count == 0) return [];\n\n var semanticsList = semanticChunks.Keys.ToList();\n var textsList = semanticChunks.Values.ToList();\n\n // Results array maintains index correspondence with input order\n var results = new float[textsList.Count][];\n\n var batches = textsList\n .Select((text, index) => new { text, index })\n .Chunk(BatchSize)\n .ToList();\n\n if (_embedClient is null)\n {\n var vectors = await hostedModelService.EmbedTextsAsync(textsList, cts);\n var hostedResult = new Dictionary<Semantics, float[]>();\n for (var index = 0; index < semanticsList.Count; index++)\n hostedResult[semanticsList[index]] = vectors[index];\n\n return hostedResult;\n }\n\n using var semaphore = new SemaphoreSlim(MaxConcurrentRequests);\n\n var tasks = batches.Select(async batch =>\n {\n await semaphore.WaitAsync(cts);\n try\n {\n var batchTexts = batch.Select(b => b.text).ToList();\n var response = await _embedClient.GenerateEmbeddingsAsync(batchTexts, options: null, cts);\n\n for (int i = 0; i < batch.Length; i++)\n results[batch[i].index] = response.Value[i].ToFloats().ToArray();\n }\n finally\n {\n semaphore.Release();\n }\n });\n\n await Task.WhenAll(tasks);\n\n // Reconstruct the result dictionary maintaining Semantics-Embedding pairing\n var resultDictionary = new Dictionary<Semantics, float[]>();\n for (int i = 0; i < semanticsList.Count; i++)\n {\n resultDictionary[semanticsList[i]] = results[i];\n }\n\n return resultDictionary;\n }\n\n public List<VectorEntry> BuildVectorEntries(\n Dictionary<Semantics, string> semanticChunks,\n Dictionary<Semantics, float[]> vectors,\n CancellationToken cts)\n {\n var entries = semanticChunks\n .Select((kvp, idx) => new VectorEntry\n {\n Id = idx,\n Semantics = new Semantics\n {\n Id = idx,\n Tags = kvp.Key.Tags,\n Summary = kvp.Key.Summary\n },\n Text = kvp.Value,\n Vector = vectors[kvp.Key],\n SourceFile = \"question-bank\",\n CreatedAt = DateTime.UtcNow\n })\n .ToList();\n\n Console.WriteLine($\"知识库已构建:{entries.Count} 个向量。\");\n return entries;\n }\n\n public FileVectorStore BuildVectorStore(string projectPath, List<VectorEntry> entries)\n {\n var fvs = new FileVectorStore(projectPath);\n fvs.AddRange(entries);\n return fvs;\n }\n\n }\n}\n"}, "1d90a68d9b606d170ee38ab0151fb3be6af5785a": {"t": 1785760672.0380714, "n": 18458, "h": "c0ea3c0c9a228782", "c": "using OpenAI;\nusing OpenAI.Chat;\nusing ReciteHelper.Core.Entities;\nusing ReciteHelper.Core.Interfaces.Configuration;\nusing ReciteHelper.Core.Interfaces.Services;\nusing System.ClientModel;\nusing System.Collections.Concurrent;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing OpenAI.Embeddings;\nusing ReciteHelper.Core.ValueObjects;\nusing ReciteHelper.Core.DTOs;\n\nnamespace ReciteHelper.Infrastructure.Services\n{\n public class KnowledgeBaseService(IConfigService cfgService) : IKnowledgeBaseService\n {\n private readonly int _chunkSize = 1500;\n private readonly int _overlap = 50;\n\n private ChatClient _chatClient;\n private EmbeddingClient _embedClient;\n\n private const int MaxConcurrentRequests = 4;\n private const int BatchSize = 10;\n\n private IConfigService configService = cfgService;\n\n public async Task<FileVectorStore> Build(string projectPath, string text)\n {\n if (string.IsNullOrWhiteSpace(text))\n throw new InvalidOperationException(\"知识库构建失败:题库文本为空。\");\n\n var slices = Slice(text);\n if (slices.Count == 0)\n throw new InvalidOperationException(\"知识库构建失败:未切分出有效文本。\");\n\n var cfg = await configService.LoadAsync();\n if (string.IsNullOrWhiteSpace(cfg.DeepSeekKey))\n throw new InvalidOperationException(\"知识库构建失败:未配置 DeepSeek Key。请在 Config.xml 中配置 DeepSeekKey。\");\n\n if (string.IsNullOrWhiteSpace(cfg.QwenKey))\n throw new InvalidOperationException(\"知识库构建失败:未配置 Qwen Key。\");\n\n var src = new CancellationTokenSource();\n\n _chatClient = new OpenAIClient(new ApiKeyCredential(cfg.DeepSeekKey!), new OpenAIClientOptions\n {\n Endpoint = new Uri(\"https://api.deepseek.com\")\n }).GetChatClient(\"deepseek-v4-flash\");\n _embedClient = CreateQwenEmbeddingClient(cfg.QwenKey!);\n\n var cluster = await ClusterAsync(slices, src.Token);\n var embed = await EmbedAsync(cluster, src.Token);\n var elements = BuildVectorEntries(cluster, embed, src.Token);\n var fvs = BuildVectorStore(projectPath, elements);\n\n return fvs;\n }\n\n public async Task<IReadOnlyList<KnowledgeBaseMatch>> SearchAsync(\n FileVectorStore store,\n string query,\n int topK,\n CancellationToken cancellationToken = default)\n {\n if (store.Entries.Count == 0 || string.IsNullOrWhiteSpace(query) || topK <= 0)\n return [];\n\n var cfg = await configService.LoadAsync();\n if (string.IsNullOrWhiteSpace(cfg.QwenKey))\n throw new InvalidOperationException(\"知识库查询失败:未配置 Qwen Key。\");\n\n var embedClient = CreateQwenEmbeddingClient(cfg.QwenKey);\n\n var response = await embedClient.GenerateEmbeddingsAsync(\n [query.Trim()],\n options: null,\n cancellationToken);\n var queryVector = response.Value[0].ToFloats().ToArray();\n\n return store.Search(queryVector, topK)\n .Select(result => new KnowledgeBaseMatch(\n CreateMatchTitle(result.Entry),\n result.Entry.Text,\n result.Score))\n .ToList();\n }\n\n public async Task<IReadOnlyList<float[]>> EmbedTextsAsync(\n IReadOnlyList<string> texts,\n CancellationToken cancellationToken = default)\n {\n var normalizedTexts = texts\n .Select(text => text?.Trim() ?? string.Empty)\n .ToList();\n if (normalizedTexts.Count == 0)\n return [];\n if (normalizedTexts.Any(string.IsNullOrWhiteSpace))\n throw new ArgumentException(\"待向量化文本不能为空。\", nameof(texts));\n\n var cfg = await configService.LoadAsync();\n if (string.IsNullOrWhiteSpace(cfg.QwenKey))\n throw new InvalidOperationException(\"语义相似度计算失败:未配置 Qwen Key。\");\n\n var embedClient = CreateQwenEmbeddingClient(cfg.QwenKey);\n var results = new float[normalizedTexts.Count][];\n var batches = normalizedTexts\n .Select((text, index) => new { Text = text, Index = index })\n .Chunk(BatchSize)\n .ToList();\n\n using var semaphore = new SemaphoreSlim(MaxConcurrentRequests);\n var tasks = batches.Select(async batch =>\n {\n await semaphore.WaitAsync(cancellationToken);\n try\n {\n var response = await embedClient.GenerateEmbeddingsAsync(\n batch.Select(item => item.Text).ToList(),\n options: null,\n cancellationToken);\n\n for (var index = 0; index < batch.Length; index++)\n results[batch[index].Index] = response.Value[index].ToFloats().ToArray();\n }\n finally\n {\n semaphore.Release();\n }\n });\n\n await Task.WhenAll(tasks);\n return results;\n }\n\n private static EmbeddingClient CreateQwenEmbeddingClient(string qwenKey)\n {\n return new OpenAIClient(\n new ApiKeyCredential(qwenKey),\n new OpenAIClientOptions\n {\n Endpoint = new Uri(\"https://dashscope.aliyuncs.com/compatible-mode/v1\")\n }).GetEmbeddingClient(\"text-embedding-v4\");\n }\n\n private static string CreateMatchTitle(VectorEntry entry)\n {\n if (!string.IsNullOrWhiteSpace(entry.Semantics.Summary))\n return entry.Semantics.Summary;\n\n var tags = string.Join(\"、\", entry.Semantics.Tags.Where(x => !string.IsNullOrWhiteSpace(x)));\n if (!string.IsNullOrWhiteSpace(tags))\n return tags;\n\n const int maxLength = 24;\n return entry.Text.Length <= maxLength\n ? entry.Text\n : $\"{entry.Text[..maxLength]}...\";\n }\n\n public List<string> Slice(string text)\n {\n var slices = SplitText(text);\n\n return slices.Select(x => x.Replace(\"\\n\", \"\").Trim()).ToList();\n }\n\n\n public List<string> SplitText(string text)\n {\n if (string.IsNullOrWhiteSpace(text))\n return new List<string>();\n\n text = CleanText(text);\n var paragraphs = SplitByParagraphs(text);\n\n var chunks = new List<string>();\n foreach (var para in paragraphs)\n {\n chunks.AddRange(SplitParagraph(para));\n }\n\n return chunks;\n }\n\n private string CleanText(string text)\n {\n text = Regex.Replace(text, @\"\\n\\s*\\n\", \"\\n\\n\");\n text = text.Trim();\n return text;\n }\n\n private List<string> SplitByParagraphs(string text)\n {\n var paragraphs = new List<string>();\n var currentPara = new StringBuilder();\n\n using var reader = new StringReader(text);\n string? line;\n\n while ((line = reader.ReadLine()) != null)\n {\n if (string.IsNullOrWhiteSpace(line))\n {\n if (currentPara.Length > 0)\n {\n paragraphs.Add(currentPara.ToString().Trim());\n currentPara.Clear();\n }\n }\n else\n {\n currentPara.AppendLine(line);\n }\n }\n\n if (currentPara.Length > 0)\n {\n paragraphs.Add(currentPara.ToString().Trim());\n }\n\n return paragraphs;\n }\n\n private List<string> SplitParagraph(string paragraph)\n {\n if (paragraph.Length <= _chunkSize)\n {\n return new List<string> { paragraph };\n }\n\n var chunks = new List<string>();\n var sentences = SplitIntoSentences(paragraph);\n\n var currentChunk = new StringBuilder();\n\n foreach (var sentence in sentences)\n {\n if (currentChunk.Length + sentence.Length + 1 > _chunkSize && currentChunk.Length > 0)\n {\n chunks.Add(currentChunk.ToString().Trim());\n\n string overlap = GetOverlap(currentChunk.ToString());\n currentChunk.Clear();\n currentChunk.Append(overlap);\n }\n\n currentChunk.Append(sentence);\n }\n\n if (currentChunk.Length > 0)\n {\n chunks.Add(currentChunk.ToString().Trim());\n }\n\n return chunks;\n }\n\n private List<string> SplitIntoSentences(string text)\n {\n var separators = new[] { \"。\", \"!\", \"?\", \";\", \".\", \"!\", \"?\", \";\" };\n\n var sentences = new List<string>();\n var currentSentence = new StringBuilder();\n\n for (int i = 0; i < text.Length; i++)\n {\n currentSentence.Append(text[i]);\n\n foreach (var sep in separators)\n {\n if (text[i].ToString() == sep)\n {\n sentences.Add(currentSentence.ToString());\n currentSentence.Clear();\n break;\n }\n }\n }\n\n if (currentSentence.Length > 0)\n {\n sentences.Add(currentSentence.ToString());\n }\n\n return sentences;\n }\n\n private string GetOverlap(string chunk)\n {\n if (chunk.Length <= _overlap)\n return chunk;\n\n int start = chunk.Length - _overlap;\n\n int actualStart = chunk.Length;\n for (int i = start; i < chunk.Length; i++)\n {\n if (i > 0 && (chunk[i - 1] == '。' || chunk[i - 1] == '!' || chunk[i - 1] == '?'))\n {\n actualStart = i;\n break;\n }\n }\n\n if (actualStart >= chunk.Length)\n actualStart = start;\n\n return chunk.Substring(actualStart);\n }\n\n public async Task<Dictionary<Semantics, string>> ClusterAsync(List<string> chunks, CancellationToken cts = default)\n {\n var results = new ConcurrentDictionary<Semantics, string>();\n\n await Parallel.ForEachAsync(chunks,\n new ParallelOptions { MaxDegreeOfParallelism = MaxConcurrentRequests, CancellationToken = cts },\n async (chunk, _) =>\n {\n var segmentedSemantics = await ProcessChunkAsync(chunk);\n foreach (var item in segmentedSemantics)\n {\n results.TryAdd(item.Item1, item.Item2);\n }\n });\n\n return results.OrderBy(x => x.Key.Id).ToDictionary(x => x.Key, x => x.Value);\n }\n\n /// <summary>\n /// Process single chunk: segment by atomic knowledge points and generate semantics\n /// </summary>\n private async Task<List<(Semantics, string)>> ProcessChunkAsync(string chunk)\n {\n var prompt = BuildPrompt(chunk);\n\n var messages = new List<ChatMessage>\n {\n new SystemChatMessage(\"你是一个专业的文本分析专家。请完成以下任务:\\n\" +\n \"1. 将输入的文本块按照知识点的原子性进行分割\\n\" +\n \"2. 确保每个分割后的段落只表达一个独立、完整的知识点\\n\" +\n \"3. 为每个知识点段落生成2-3个关键词标签\\n\" +\n \"4. 生成不超过20字的单句摘要\\n\" +\n \"5. 对于被截断的知识点,如果有明确的语义信息进行补全,否则直接丢弃\" +\n \"6. 返回JSON数组,每个元素包含text、tags、summary字段,无其他内容,不要添加markdown代码块结构\"),\n new UserChatMessage(prompt)\n };\n\n var response = await _chatClient.CompleteChatAsync(messages, new ChatCompletionOptions\n {\n Temperature = 0.3f,\n MaxOutputTokenCount = 66666\n });\n\n var json = ExtractJsonContent(response.Value.Content[0].Text);\n return ParseResponse(json);\n }\n\n private static string BuildPrompt(string chunk)\n {\n var sb = new StringBuilder();\n sb.AppendLine(\"请处理以下文本块:\");\n sb.AppendLine();\n sb.AppendLine(chunk);\n sb.AppendLine();\n sb.AppendLine(\"返回JSON数组格式,示例:\");\n sb.AppendLine(\"[\");\n sb.AppendLine(\" {\\\"text\\\": \\\"分割后的知识点1\\\", \\\"tags\\\": [\\\"标签1\\\", \\\"标签2\\\"], \\\"summary\\\": \\\"摘要\\\"},\");\n sb.AppendLine(\" {\\\"text\\\": \\\"分割后的知识点2\\\", \\\"tags\\\": [\\\"标签1\\\", \\\"标签2\\\"], \\\"summary\\\": \\\"摘要\\\"}\");\n sb.AppendLine(\"]\");\n\n return sb.ToString();\n }\n\n /// <summary>\n /// Parse response and return semantics-text pairs with auto-incremented IDs\n /// </summary>\n private static List<(Semantics, string)> ParseResponse(string json)\n {\n var result = new List<(Semantics, string)>();\n var idCounter = 0;\n\n using var doc = JsonDocument.Parse(json);\n var array = doc.RootElement.EnumerateArray().ToList();\n\n foreach (var item in array)\n {\n var text = item.TryGetProperty(\"text\", out var textProp)\n ? textProp.GetString() ?? \"\"\n : \"\";\n\n if (string.IsNullOrWhiteSpace(text))\n continue;\n\n var semantics = new Semantics\n {\n Id = idCounter++,\n Tags = item.TryGetProperty(\"tags\", out var tags)\n ? tags.EnumerateArray()\n .Select(t => t.GetString() ?? \"\")\n .Where(t => !string.IsNullOrWhiteSpace(t))\n .ToList()\n : [],\n Summary = item.TryGetProperty(\"summary\", out var summary)\n ? (summary.GetString() ?? \"\").Trim()\n : \"\"\n };\n\n result.Add((semantics, text.Trim()));\n }\n\n\n return result;\n }\n\n private static string ExtractJsonContent(string content)\n {\n if (string.IsNullOrWhiteSpace(content))\n return \"[]\";\n\n var trimmed = content.Trim();\n var fenceStart = trimmed.IndexOf(\"```\", StringComparison.Ordinal);\n if (fenceStart >= 0)\n {\n var jsonStart = trimmed.IndexOf('\\n', fenceStart);\n if (jsonStart >= 0)\n {\n var fenceEnd = trimmed.LastIndexOf(\"```\", StringComparison.Ordinal);\n trimmed = fenceEnd > jsonStart\n ? trimmed[(jsonStart + 1)..fenceEnd].Trim()\n : trimmed[(jsonStart + 1)..].Trim();\n }\n }\n\n var arrayStart = trimmed.IndexOf('[');\n var arrayEnd = trimmed.LastIndexOf(']');\n return arrayStart >= 0 && arrayEnd > arrayStart\n ? trimmed[arrayStart..(arrayEnd + 1)]\n : trimmed;\n }\n\n public async Task<Dictionary<Semantics, float[]>> EmbedAsync(\n Dictionary<Semantics, string> semanticChunks,\n CancellationToken cts)\n {\n if (semanticChunks.Count == 0) return [];\n\n var semanticsList = semanticChunks.Keys.ToList();\n var textsList = semanticChunks.Values.ToList();\n\n // Results array maintains index correspondence with input order\n var results = new float[textsList.Count][];\n\n var batches = textsList\n .Select((text, index) => new { text, index })\n .Chunk(BatchSize)\n .ToList();\n\n using var semaphore = new SemaphoreSlim(MaxConcurrentRequests);\n\n var tasks = batches.Select(async batch =>\n {\n await semaphore.WaitAsync(cts);\n try\n {\n var batchTexts = batch.Select(b => b.text).ToList();\n var response = await _embedClient.GenerateEmbeddingsAsync(batchTexts, options: null, cts);\n\n for (int i = 0; i < batch.Length; i++)\n results[batch[i].index] = response.Value[i].ToFloats().ToArray();\n }\n finally\n {\n semaphore.Release();\n }\n });\n\n await Task.WhenAll(tasks);\n\n // Reconstruct the result dictionary maintaining Semantics-Embedding pairing\n var resultDictionary = new Dictionary<Semantics, float[]>();\n for (int i = 0; i < semanticsList.Count; i++)\n {\n resultDictionary[semanticsList[i]] = results[i];\n }\n\n return resultDictionary;\n }\n\n public List<VectorEntry> BuildVectorEntries(\n Dictionary<Semantics, string> semanticChunks,\n Dictionary<Semantics, float[]> vectors,\n CancellationToken cts)\n {\n var entries = semanticChunks\n .Select((kvp, idx) => new VectorEntry\n {\n Id = idx,\n Semantics = new Semantics\n {\n Id = idx,\n Tags = kvp.Key.Tags,\n Summary = kvp.Key.Summary\n },\n Text = kvp.Value,\n Vector = vectors[kvp.Key],\n SourceFile = \"question-bank\",\n CreatedAt = DateTime.UtcNow\n })\n .ToList();\n\n Console.WriteLine($\"知识库已构建:{entries.Count} 个向量。\");\n return entries;\n }\n\n public FileVectorStore BuildVectorStore(string projectPath, List<VectorEntry> entries)\n {\n var fvs = new FileVectorStore(projectPath);\n fvs.AddRange(entries);\n return fvs;\n }\n\n }\n}\n"}, "8cc55d133d0be99b98e6b8c1a05ec6dada13c4dd": {"t": 1785760673.7215776, "n": 16618, "h": "9c81b2d22fe8af60", "c": "using OpenAI;\nusing OpenAI.Chat;\nusing ReciteHelper.Core.Entities;\nusing ReciteHelper.Core.Interfaces.Configuration;\nusing ReciteHelper.Core.Interfaces.Services;\nusing System.ClientModel;\nusing System.Collections.Concurrent;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing OpenAI.Embeddings;\nusing ReciteHelper.Core.ValueObjects;\nusing ReciteHelper.Core.DTOs;\n\nnamespace ReciteHelper.Infrastructure.Services\n{\n public class KnowledgeBaseService(IConfigService cfgService) : IKnowledgeBaseService\n {\n private readonly int _chunkSize = 1500;\n private readonly int _overlap = 50;\n\n private ChatClient _chatClient;\n private EmbeddingClient _embedClient;\n\n private const int MaxConcurrentRequests = 4;\n private const int BatchSize = 10;\n\n private IConfigService configService = cfgService;\n\n public async Task<FileVectorStore> Build(string projectPath, string text)\n {\n if (string.IsNullOrWhiteSpace(text))\n throw new InvalidOperationException(\"知识库构建失败:题库文本为空。\");\n\n var slices = Slice(text);\n if (slices.Count == 0)\n throw new InvalidOperationException(\"知识库构建失败:未切分出有效文本。\");\n\n var cfg = await configService.LoadAsync();\n if (string.IsNullOrWhiteSpace(cfg.DeepSeekKey))\n throw new InvalidOperationException(\"知识库构建失败:未配置 DeepSeek Key。请在 Config.xml 中配置 DeepSeekKey。\");\n\n if (string.IsNullOrWhiteSpace(cfg.QwenKey))\n throw new InvalidOperationException(\"知识库构建失败:未配置 Qwen Key。\");\n\n var src = new CancellationTokenSource();\n\n _chatClient = new OpenAIClient(new ApiKeyCredential(cfg.DeepSeekKey!), new OpenAIClientOptions\n {\n Endpoint = new Uri(\"https://api.deepseek.com\")\n }).GetChatClient(\"deepseek-v4-flash\");\n _embedClient = new OpenAIClient(new ApiKeyCredential(cfg.QwenKey!), new OpenAIClientOptions\n {\n Endpoint = new Uri(\"https://dashscope.aliyuncs.com/compatible-mode/v1\")\n }).GetEmbeddingClient(\"text-embedding-v4\");\n\n var cluster = await ClusterAsync(slices, src.Token);\n var embed = await EmbedAsync(cluster, src.Token);\n var elements = BuildVectorEntries(cluster, embed, src.Token);\n var fvs = BuildVectorStore(projectPath, elements);\n\n return fvs;\n }\n\n public async Task<IReadOnlyList<KnowledgeBaseMatch>> SearchAsync(\n FileVectorStore store,\n string query,\n int topK,\n CancellationToken cancellationToken = default)\n {\n if (store.Entries.Count == 0 || string.IsNullOrWhiteSpace(query) || topK <= 0)\n return [];\n\n var cfg = await configService.LoadAsync();\n if (string.IsNullOrWhiteSpace(cfg.QwenKey))\n throw new InvalidOperationException(\"知识库查询失败:未配置 Qwen Key。\");\n\n var embedClient = new OpenAIClient(\n new ApiKeyCredential(cfg.QwenKey),\n new OpenAIClientOptions\n {\n Endpoint = new Uri(\"https://dashscope.aliyuncs.com/compatible-mode/v1\")\n }).GetEmbeddingClient(\"text-embedding-v4\");\n\n var response = await embedClient.GenerateEmbeddingsAsync(\n [query.Trim()],\n options: null,\n cancellationToken);\n var queryVector = response.Value[0].ToFloats().ToArray();\n\n return store.Search(queryVector, topK)\n .Select(result => new KnowledgeBaseMatch(\n CreateMatchTitle(result.Entry),\n result.Entry.Text,\n result.Score))\n .ToList();\n }\n\n private static string CreateMatchTitle(VectorEntry entry)\n {\n if (!string.IsNullOrWhiteSpace(entry.Semantics.Summary))\n return entry.Semantics.Summary;\n\n var tags = string.Join(\"、\", entry.Semantics.Tags.Where(x => !string.IsNullOrWhiteSpace(x)));\n if (!string.IsNullOrWhiteSpace(tags))\n return tags;\n\n const int maxLength = 24;\n return entry.Text.Length <= maxLength\n ? entry.Text\n : $\"{entry.Text[..maxLength]}...\";\n }\n\n public List<string> Slice(string text)\n {\n var slices = SplitText(text);\n\n return slices.Select(x => x.Replace(\"\\n\", \"\").Trim()).ToList();\n }\n\n\n public List<string> SplitText(string text)\n {\n if (string.IsNullOrWhiteSpace(text))\n return new List<string>();\n\n text = CleanText(text);\n var paragraphs = SplitByParagraphs(text);\n\n var chunks = new List<string>();\n foreach (var para in paragraphs)\n {\n chunks.AddRange(SplitParagraph(para));\n }\n\n return chunks;\n }\n\n private string CleanText(string text)\n {\n text = Regex.Replace(text, @\"\\n\\s*\\n\", \"\\n\\n\");\n text = text.Trim();\n return text;\n }\n\n private List<string> SplitByParagraphs(string text)\n {\n var paragraphs = new List<string>();\n var currentPara = new StringBuilder();\n\n using var reader = new StringReader(text);\n string? line;\n\n while ((line = reader.ReadLine()) != null)\n {\n if (string.IsNullOrWhiteSpace(line))\n {\n if (currentPara.Length > 0)\n {\n paragraphs.Add(currentPara.ToString().Trim());\n currentPara.Clear();\n }\n }\n else\n {\n currentPara.AppendLine(line);\n }\n }\n\n if (currentPara.Length > 0)\n {\n paragraphs.Add(currentPara.ToString().Trim());\n }\n\n return paragraphs;\n }\n\n private List<string> SplitParagraph(string paragraph)\n {\n if (paragraph.Length <= _chunkSize)\n {\n return new List<string> { paragraph };\n }\n\n var chunks = new List<string>();\n var sentences = SplitIntoSentences(paragraph);\n\n var currentChunk = new StringBuilder();\n\n foreach (var sentence in sentences)\n {\n if (currentChunk.Length + sentence.Length + 1 > _chunkSize && currentChunk.Length > 0)\n {\n chunks.Add(currentChunk.ToString().Trim());\n\n string overlap = GetOverlap(currentChunk.ToString());\n currentChunk.Clear();\n currentChunk.Append(overlap);\n }\n\n currentChunk.Append(sentence);\n }\n\n if (currentChunk.Length > 0)\n {\n chunks.Add(currentChunk.ToString().Trim());\n }\n\n return chunks;\n }\n\n private List<string> SplitIntoSentences(string text)\n {\n var separators = new[] { \"。\", \"!\", \"?\", \";\", \".\", \"!\", \"?\", \";\" };\n\n var sentences = new List<string>();\n var currentSentence = new StringBuilder();\n\n for (int i = 0; i < text.Length; i++)\n {\n currentSentence.Append(text[i]);\n\n foreach (var sep in separators)\n {\n if (text[i].ToString() == sep)\n {\n sentences.Add(currentSentence.ToString());\n currentSentence.Clear();\n break;\n }\n }\n }\n\n if (currentSentence.Length > 0)\n {\n sentences.Add(currentSentence.ToString());\n }\n\n return sentences;\n }\n\n private string GetOverlap(string chunk)\n {\n if (chunk.Length <= _overlap)\n return chunk;\n\n int start = chunk.Length - _overlap;\n\n int actualStart = chunk.Length;\n for (int i = start; i < chunk.Length; i++)\n {\n if (i > 0 && (chunk[i - 1] == '。' || chunk[i - 1] == '!' || chunk[i - 1] == '?'))\n {\n actualStart = i;\n break;\n }\n }\n\n if (actualStart >= chunk.Length)\n actualStart = start;\n\n return chunk.Substring(actualStart);\n }\n\n public async Task<Dictionary<Semantics, string>> ClusterAsync(List<string> chunks, CancellationToken cts = default)\n {\n var results = new ConcurrentDictionary<Semantics, string>();\n\n await Parallel.ForEachAsync(chunks,\n new ParallelOptions { MaxDegreeOfParallelism = MaxConcurrentRequests, CancellationToken = cts },\n async (chunk, _) =>\n {\n var segmentedSemantics = await ProcessChunkAsync(chunk);\n foreach (var item in segmentedSemantics)\n {\n results.TryAdd(item.Item1, item.Item2);\n }\n });\n\n return results.OrderBy(x => x.Key.Id).ToDictionary(x => x.Key, x => x.Value);\n }\n\n /// <summary>\n /// Process single chunk: segment by atomic knowledge points and generate semantics\n /// </summary>\n private async Task<List<(Semantics, string)>> ProcessChunkAsync(string chunk)\n {\n var prompt = BuildPrompt(chunk);\n\n var messages = new List<ChatMessage>\n {\n new SystemChatMessage(\"你是一个专业的文本分析专家。请完成以下任务:\\n\" +\n \"1. 将输入的文本块按照知识点的原子性进行分割\\n\" +\n \"2. 确保每个分割后的段落只表达一个独立、完整的知识点\\n\" +\n \"3. 为每个知识点段落生成2-3个关键词标签\\n\" +\n \"4. 生成不超过20字的单句摘要\\n\" +\n \"5. 对于被截断的知识点,如果有明确的语义信息进行补全,否则直接丢弃\" +\n \"6. 返回JSON数组,每个元素包含text、tags、summary字段,无其他内容,不要添加markdown代码块结构\"),\n new UserChatMessage(prompt)\n };\n\n var response = await _chatClient.CompleteChatAsync(messages, new ChatCompletionOptions\n {\n Temperature = 0.3f,\n MaxOutputTokenCount = 66666\n });\n\n var json = ExtractJsonContent(response.Value.Content[0].Text);\n return ParseResponse(json);\n }\n\n private static string BuildPrompt(string chunk)\n {\n var sb = new StringBuilder();\n sb.AppendLine(\"请处理以下文本块:\");\n sb.AppendLine();\n sb.AppendLine(chunk);\n sb.AppendLine();\n sb.AppendLine(\"返回JSON数组格式,示例:\");\n sb.AppendLine(\"[\");\n sb.AppendLine(\" {\\\"text\\\": \\\"分割后的知识点1\\\", \\\"tags\\\": [\\\"标签1\\\", \\\"标签2\\\"], \\\"summary\\\": \\\"摘要\\\"},\");\n sb.AppendLine(\" {\\\"text\\\": \\\"分割后的知识点2\\\", \\\"tags\\\": [\\\"标签1\\\", \\\"标签2\\\"], \\\"summary\\\": \\\"摘要\\\"}\");\n sb.AppendLine(\"]\");\n\n return sb.ToString();\n }\n\n /// <summary>\n /// Parse response and return semantics-text pairs with auto-incremented IDs\n /// </summary>\n private static List<(Semantics, string)> ParseResponse(string json)\n {\n var result = new List<(Semantics, string)>();\n var idCounter = 0;\n\n using var doc = JsonDocument.Parse(json);\n var array = doc.RootElement.EnumerateArray().ToList();\n\n foreach (var item in array)\n {\n var text = item.TryGetProperty(\"text\", out var textProp)\n ? textProp.GetString() ?? \"\"\n : \"\";\n\n if (string.IsNullOrWhiteSpace(text))\n continue;\n\n var semantics = new Semantics\n {\n Id = idCounter++,\n Tags = item.TryGetProperty(\"tags\", out var tags)\n ? tags.EnumerateArray()\n .Select(t => t.GetString() ?? \"\")\n .Where(t => !string.IsNullOrWhiteSpace(t))\n .ToList()\n : [],\n Summary = item.TryGetProperty(\"summary\", out var summary)\n ? (summary.GetString() ?? \"\").Trim()\n : \"\"\n };\n\n result.Add((semantics, text.Trim()));\n }\n\n\n return result;\n }\n\n private static string ExtractJsonContent(string content)\n {\n if (string.IsNullOrWhiteSpace(content))\n return \"[]\";\n\n var trimmed = content.Trim();\n var fenceStart = trimmed.IndexOf(\"```\", StringComparison.Ordinal);\n if (fenceStart >= 0)\n {\n var jsonStart = trimmed.IndexOf('\\n', fenceStart);\n if (jsonStart >= 0)\n {\n var fenceEnd = trimmed.LastIndexOf(\"```\", StringComparison.Ordinal);\n trimmed = fenceEnd > jsonStart\n ? trimmed[(jsonStart + 1)..fenceEnd].Trim()\n : trimmed[(jsonStart + 1)..].Trim();\n }\n }\n\n var arrayStart = trimmed.IndexOf('[');\n var arrayEnd = trimmed.LastIndexOf(']');\n return arrayStart >= 0 && arrayEnd > arrayStart\n ? trimmed[arrayStart..(arrayEnd + 1)]\n : trimmed;\n }\n\n public async Task<Dictionary<Semantics, float[]>> EmbedAsync(\n Dictionary<Semantics, string> semanticChunks,\n CancellationToken cts)\n {\n if (semanticChunks.Count == 0) return [];\n\n var semanticsList = semanticChunks.Keys.ToList();\n var textsList = semanticChunks.Values.ToList();\n\n // Results array maintains index correspondence with input order\n var results = new float[textsList.Count][];\n\n var batches = textsList\n .Select((text, index) => new { text, index })\n .Chunk(BatchSize)\n .ToList();\n\n using var semaphore = new SemaphoreSlim(MaxConcurrentRequests);\n\n var tasks = batches.Select(async batch =>\n {\n await semaphore.WaitAsync(cts);\n try\n {\n var batchTexts = batch.Select(b => b.text).ToList();\n var response = await _embedClient.GenerateEmbeddingsAsync(batchTexts, options: null, cts);\n\n for (int i = 0; i < batch.Length; i++)\n results[batch[i].index] = response.Value[i].ToFloats().ToArray();\n }\n finally\n {\n semaphore.Release();\n }\n });\n\n await Task.WhenAll(tasks);\n\n // Reconstruct the result dictionary maintaining Semantics-Embedding pairing\n var resultDictionary = new Dictionary<Semantics, float[]>();\n for (int i = 0; i < semanticsList.Count; i++)\n {\n resultDictionary[semanticsList[i]] = results[i];\n }\n\n return resultDictionary;\n }\n\n public List<VectorEntry> BuildVectorEntries(\n Dictionary<Semantics, string> semanticChunks,\n Dictionary<Semantics, float[]> vectors,\n CancellationToken cts)\n {\n var entries = semanticChunks\n .Select((kvp, idx) => new VectorEntry\n {\n Id = idx,\n Semantics = new Semantics\n {\n Id = idx,\n Tags = kvp.Key.Tags,\n Summary = kvp.Key.Summary\n },\n Text = kvp.Value,\n Vector = vectors[kvp.Key],\n SourceFile = \"question-bank\",\n CreatedAt = DateTime.UtcNow\n })\n .ToList();\n\n Console.WriteLine($\"知识库已构建:{entries.Count} 个向量。\");\n return entries;\n }\n\n public FileVectorStore BuildVectorStore(string projectPath, List<VectorEntry> entries)\n {\n var fvs = new FileVectorStore(projectPath);\n fvs.AddRange(entries);\n return fvs;\n }\n\n }\n}\n"}, "b08be962bb60251384f0260d4f85ebd0c02821b5": {"t": 1785760686.8974004, "n": 16588, "h": "f9778429b3d7a221", "c": "using OpenAI;\nusing OpenAI.Chat;\nusing ReciteHelper.Core.Entities;\nusing ReciteHelper.Core.Interfaces.Configuration;\nusing ReciteHelper.Core.Interfaces.Services;\nusing System.ClientModel;\nusing System.Collections.Concurrent;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing OpenAI.Embeddings;\nusing ReciteHelper.Core.ValueObjects;\nusing ReciteHelper.Core.DTOs;\n\nnamespace ReciteHelper.Infrastructure.Services\n{\n public class KnowledgeBaseService(IConfigService cfgService) : IKnowledgeBaseService\n {\n private readonly int _chunkSize = 1500;\n private readonly int _overlap = 50;\n\n private ChatClient _chatClient;\n private EmbeddingClient _embedClient;\n\n private const int MaxConcurrentRequests = 4;\n private const int BatchSize = 10;\n\n private IConfigService configService = cfgService;\n\n public async Task<FileVectorStore> Build(string projectPath, string text)\n {\n if (string.IsNullOrWhiteSpace(text))\n throw new InvalidOperationException(\"知识库构建失败:题库文本为空。\");\n\n var slices = Slice(text);\n if (slices.Count == 0)\n throw new InvalidOperationException(\"知识库构建失败:未切分出有效文本。\");\n\n var cfg = await configService.LoadAsync();\n if (string.IsNullOrWhiteSpace(cfg.DeepSeekKey))\n throw new InvalidOperationException(\"知识库构建失败:未配置 DeepSeek Key。\");\n\n if (string.IsNullOrWhiteSpace(cfg.QwenKey))\n throw new InvalidOperationException(\"知识库构建失败:未配置 Qwen Key。\");\n\n var src = new CancellationTokenSource();\n\n _chatClient = new OpenAIClient(new ApiKeyCredential(cfg.DeepSeekKey!), new OpenAIClientOptions\n {\n Endpoint = new Uri(\"https://api.deepseek.com\")\n }).GetChatClient(\"deepseek-v4-flash\");\n _embedClient = new OpenAIClient(new ApiKeyCredential(cfg.QwenKey!), new OpenAIClientOptions\n {\n Endpoint = new Uri(\"https://dashscope.aliyuncs.com/compatible-mode/v1\")\n }).GetEmbeddingClient(\"text-embedding-v4\");\n\n var cluster = await ClusterAsync(slices, src.Token);\n var embed = await EmbedAsync(cluster, src.Token);\n var elements = BuildVectorEntries(cluster, embed, src.Token);\n var fvs = BuildVectorStore(projectPath, elements);\n\n return fvs;\n }\n\n public async Task<IReadOnlyList<KnowledgeBaseMatch>> SearchAsync(\n FileVectorStore store,\n string query,\n int topK,\n CancellationToken cancellationToken = default)\n {\n if (store.Entries.Count == 0 || string.IsNullOrWhiteSpace(query) || topK <= 0)\n return [];\n\n var cfg = await configService.LoadAsync();\n if (string.IsNullOrWhiteSpace(cfg.QwenKey))\n throw new InvalidOperationException(\"知识库查询失败:未配置 Qwen Key。\");\n\n var embedClient = new OpenAIClient(\n new ApiKeyCredential(cfg.QwenKey),\n new OpenAIClientOptions\n {\n Endpoint = new Uri(\"https://dashscope.aliyuncs.com/compatible-mode/v1\")\n }).GetEmbeddingClient(\"text-embedding-v4\");\n\n var response = await embedClient.GenerateEmbeddingsAsync(\n [query.Trim()],\n options: null,\n cancellationToken);\n var queryVector = response.Value[0].ToFloats().ToArray();\n\n return store.Search(queryVector, topK)\n .Select(result => new KnowledgeBaseMatch(\n CreateMatchTitle(result.Entry),\n result.Entry.Text,\n result.Score))\n .ToList();\n }\n\n private static string CreateMatchTitle(VectorEntry entry)\n {\n if (!string.IsNullOrWhiteSpace(entry.Semantics.Summary))\n return entry.Semantics.Summary;\n\n var tags = string.Join(\"、\", entry.Semantics.Tags.Where(x => !string.IsNullOrWhiteSpace(x)));\n if (!string.IsNullOrWhiteSpace(tags))\n return tags;\n\n const int maxLength = 24;\n return entry.Text.Length <= maxLength\n ? entry.Text\n : $\"{entry.Text[..maxLength]}...\";\n }\n\n public List<string> Slice(string text)\n {\n var slices = SplitText(text);\n\n return slices.Select(x => x.Replace(\"\\n\", \"\").Trim()).ToList();\n }\n\n\n public List<string> SplitText(string text)\n {\n if (string.IsNullOrWhiteSpace(text))\n return new List<string>();\n\n text = CleanText(text);\n var paragraphs = SplitByParagraphs(text);\n\n var chunks = new List<string>();\n foreach (var para in paragraphs)\n {\n chunks.AddRange(SplitParagraph(para));\n }\n\n return chunks;\n }\n\n private string CleanText(string text)\n {\n text = Regex.Replace(text, @\"\\n\\s*\\n\", \"\\n\\n\");\n text = text.Trim();\n return text;\n }\n\n private List<string> SplitByParagraphs(string text)\n {\n var paragraphs = new List<string>();\n var currentPara = new StringBuilder();\n\n using var reader = new StringReader(text);\n string? line;\n\n while ((line = reader.ReadLine()) != null)\n {\n if (string.IsNullOrWhiteSpace(line))\n {\n if (currentPara.Length > 0)\n {\n paragraphs.Add(currentPara.ToString().Trim());\n currentPara.Clear();\n }\n }\n else\n {\n currentPara.AppendLine(line);\n }\n }\n\n if (currentPara.Length > 0)\n {\n paragraphs.Add(currentPara.ToString().Trim());\n }\n\n return paragraphs;\n }\n\n private List<string> SplitParagraph(string paragraph)\n {\n if (paragraph.Length <= _chunkSize)\n {\n return new List<string> { paragraph };\n }\n\n var chunks = new List<string>();\n var sentences = SplitIntoSentences(paragraph);\n\n var currentChunk = new StringBuilder();\n\n foreach (var sentence in sentences)\n {\n if (currentChunk.Length + sentence.Length + 1 > _chunkSize && currentChunk.Length > 0)\n {\n chunks.Add(currentChunk.ToString().Trim());\n\n string overlap = GetOverlap(currentChunk.ToString());\n currentChunk.Clear();\n currentChunk.Append(overlap);\n }\n\n currentChunk.Append(sentence);\n }\n\n if (currentChunk.Length > 0)\n {\n chunks.Add(currentChunk.ToString().Trim());\n }\n\n return chunks;\n }\n\n private List<string> SplitIntoSentences(string text)\n {\n var separators = new[] { \"。\", \"!\", \"?\", \";\", \".\", \"!\", \"?\", \";\" };\n\n var sentences = new List<string>();\n var currentSentence = new StringBuilder();\n\n for (int i = 0; i < text.Length; i++)\n {\n currentSentence.Append(text[i]);\n\n foreach (var sep in separators)\n {\n if (text[i].ToString() == sep)\n {\n sentences.Add(currentSentence.ToString());\n currentSentence.Clear();\n break;\n }\n }\n }\n\n if (currentSentence.Length > 0)\n {\n sentences.Add(currentSentence.ToString());\n }\n\n return sentences;\n }\n\n private string GetOverlap(string chunk)\n {\n if (chunk.Length <= _overlap)\n return chunk;\n\n int start = chunk.Length - _overlap;\n\n int actualStart = chunk.Length;\n for (int i = start; i < chunk.Length; i++)\n {\n if (i > 0 && (chunk[i - 1] == '。' || chunk[i - 1] == '!' || chunk[i - 1] == '?'))\n {\n actualStart = i;\n break;\n }\n }\n\n if (actualStart >= chunk.Length)\n actualStart = start;\n\n return chunk.Substring(actualStart);\n }\n\n public async Task<Dictionary<Semantics, string>> ClusterAsync(List<string> chunks, CancellationToken cts = default)\n {\n var results = new ConcurrentDictionary<Semantics, string>();\n\n await Parallel.ForEachAsync(chunks,\n new ParallelOptions { MaxDegreeOfParallelism = MaxConcurrentRequests, CancellationToken = cts },\n async (chunk, _) =>\n {\n var segmentedSemantics = await ProcessChunkAsync(chunk);\n foreach (var item in segmentedSemantics)\n {\n results.TryAdd(item.Item1, item.Item2);\n }\n });\n\n return results.OrderBy(x => x.Key.Id).ToDictionary(x => x.Key, x => x.Value);\n }\n\n /// <summary>\n /// Process single chunk: segment by atomic knowledge points and generate semantics\n /// </summary>\n private async Task<List<(Semantics, string)>> ProcessChunkAsync(string chunk)\n {\n var prompt = BuildPrompt(chunk);\n\n var messages = new List<ChatMessage>\n {\n new SystemChatMessage(\"你是一个专业的文本分析专家。请完成以下任务:\\n\" +\n \"1. 将输入的文本块按照知识点的原子性进行分割\\n\" +\n \"2. 确保每个分割后的段落只表达一个独立、完整的知识点\\n\" +\n \"3. 为每个知识点段落生成2-3个关键词标签\\n\" +\n \"4. 生成不超过20字的单句摘要\\n\" +\n \"5. 对于被截断的知识点,如果有明确的语义信息进行补全,否则直接丢弃\" +\n \"6. 返回JSON数组,每个元素包含text、tags、summary字段,无其他内容,不要添加markdown代码块结构\"),\n new UserChatMessage(prompt)\n };\n\n var response = await _chatClient.CompleteChatAsync(messages, new ChatCompletionOptions\n {\n Temperature = 0.3f,\n MaxOutputTokenCount = 66666\n });\n\n var json = ExtractJsonContent(response.Value.Content[0].Text);\n return ParseResponse(json);\n }\n\n private static string BuildPrompt(string chunk)\n {\n var sb = new StringBuilder();\n sb.AppendLine(\"请处理以下文本块:\");\n sb.AppendLine();\n sb.AppendLine(chunk);\n sb.AppendLine();\n sb.AppendLine(\"返回JSON数组格式,示例:\");\n sb.AppendLine(\"[\");\n sb.AppendLine(\" {\\\"text\\\": \\\"分割后的知识点1\\\", \\\"tags\\\": [\\\"标签1\\\", \\\"标签2\\\"], \\\"summary\\\": \\\"摘要\\\"},\");\n sb.AppendLine(\" {\\\"text\\\": \\\"分割后的知识点2\\\", \\\"tags\\\": [\\\"标签1\\\", \\\"标签2\\\"], \\\"summary\\\": \\\"摘要\\\"}\");\n sb.AppendLine(\"]\");\n\n return sb.ToString();\n }\n\n /// <summary>\n /// Parse response and return semantics-text pairs with auto-incremented IDs\n /// </summary>\n private static List<(Semantics, string)> ParseResponse(string json)\n {\n var result = new List<(Semantics, string)>();\n var idCounter = 0;\n\n using var doc = JsonDocument.Parse(json);\n var array = doc.RootElement.EnumerateArray().ToList();\n\n foreach (var item in array)\n {\n var text = item.TryGetProperty(\"text\", out var textProp)\n ? textProp.GetString() ?? \"\"\n : \"\";\n\n if (string.IsNullOrWhiteSpace(text))\n continue;\n\n var semantics = new Semantics\n {\n Id = idCounter++,\n Tags = item.TryGetProperty(\"tags\", out var tags)\n ? tags.EnumerateArray()\n .Select(t => t.GetString() ?? \"\")\n .Where(t => !string.IsNullOrWhiteSpace(t))\n .ToList()\n : [],\n Summary = item.TryGetProperty(\"summary\", out var summary)\n ? (summary.GetString() ?? \"\").Trim()\n : \"\"\n };\n\n result.Add((semantics, text.Trim()));\n }\n\n\n return result;\n }\n\n private static string ExtractJsonContent(string content)\n {\n if (string.IsNullOrWhiteSpace(content))\n return \"[]\";\n\n var trimmed = content.Trim();\n var fenceStart = trimmed.IndexOf(\"```\", StringComparison.Ordinal);\n if (fenceStart >= 0)\n {\n var jsonStart = trimmed.IndexOf('\\n', fenceStart);\n if (jsonStart >= 0)\n {\n var fenceEnd = trimmed.LastIndexOf(\"```\", StringComparison.Ordinal);\n trimmed = fenceEnd > jsonStart\n ? trimmed[(jsonStart + 1)..fenceEnd].Trim()\n : trimmed[(jsonStart + 1)..].Trim();\n }\n }\n\n var arrayStart = trimmed.IndexOf('[');\n var arrayEnd = trimmed.LastIndexOf(']');\n return arrayStart >= 0 && arrayEnd > arrayStart\n ? trimmed[arrayStart..(arrayEnd + 1)]\n : trimmed;\n }\n\n public async Task<Dictionary<Semantics, float[]>> EmbedAsync(\n Dictionary<Semantics, string> semanticChunks,\n CancellationToken cts)\n {\n if (semanticChunks.Count == 0) return [];\n\n var semanticsList = semanticChunks.Keys.ToList();\n var textsList = semanticChunks.Values.ToList();\n\n // Results array maintains index correspondence with input order\n var results = new float[textsList.Count][];\n\n var batches = textsList\n .Select((text, index) => new { text, index })\n .Chunk(BatchSize)\n .ToList();\n\n using var semaphore = new SemaphoreSlim(MaxConcurrentRequests);\n\n var tasks = batches.Select(async batch =>\n {\n await semaphore.WaitAsync(cts);\n try\n {\n var batchTexts = batch.Select(b => b.text).ToList();\n var response = await _embedClient.GenerateEmbeddingsAsync(batchTexts, options: null, cts);\n\n for (int i = 0; i < batch.Length; i++)\n results[batch[i].index] = response.Value[i].ToFloats().ToArray();\n }\n finally\n {\n semaphore.Release();\n }\n });\n\n await Task.WhenAll(tasks);\n\n // Reconstruct the result dictionary maintaining Semantics-Embedding pairing\n var resultDictionary = new Dictionary<Semantics, float[]>();\n for (int i = 0; i < semanticsList.Count; i++)\n {\n resultDictionary[semanticsList[i]] = results[i];\n }\n\n return resultDictionary;\n }\n\n public List<VectorEntry> BuildVectorEntries(\n Dictionary<Semantics, string> semanticChunks,\n Dictionary<Semantics, float[]> vectors,\n CancellationToken cts)\n {\n var entries = semanticChunks\n .Select((kvp, idx) => new VectorEntry\n {\n Id = idx,\n Semantics = new Semantics\n {\n Id = idx,\n Tags = kvp.Key.Tags,\n Summary = kvp.Key.Summary\n },\n Text = kvp.Value,\n Vector = vectors[kvp.Key],\n SourceFile = \"question-bank\",\n CreatedAt = DateTime.UtcNow\n })\n .ToList();\n\n Console.WriteLine($\"知识库已构建:{entries.Count} 个向量。\");\n return entries;\n }\n\n public FileVectorStore BuildVectorStore(string projectPath, List<VectorEntry> entries)\n {\n var fvs = new FileVectorStore(projectPath);\n fvs.AddRange(entries);\n return fvs;\n }\n\n }\n}\n"}, "59405879fe1154c28324fbb7be767592fa3db0e2": {"t": 1785760688.296928, "n": 14642, "h": "6be44b5aa969c08f", "c": "using OpenAI;\nusing OpenAI.Chat;\nusing ReciteHelper.Core.Entities;\nusing ReciteHelper.Core.Interfaces.Configuration;\nusing ReciteHelper.Core.Interfaces.Services;\nusing System.ClientModel;\nusing System.Collections.Concurrent;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing OpenAI.Embeddings;\nusing ReciteHelper.Core.ValueObjects;\n\nnamespace ReciteHelper.Infrastructure.Services\n{\n public class KnowledgeBaseService(IConfigService cfgService) : IKnowledgeBaseService\n {\n private readonly int _chunkSize = 1500;\n private readonly int _overlap = 50;\n\n private ChatClient _chatClient;\n private EmbeddingClient _embedClient;\n\n private const int MaxConcurrentRequests = 4;\n private const int BatchSize = 10;\n\n private IConfigService configService = cfgService;\n\n public async Task<FileVectorStore> Build(string projectPath, string text)\n {\n if (string.IsNullOrWhiteSpace(text))\n throw new InvalidOperationException(\"知识库构建失败:题库文本为空。\");\n\n var slices = Slice(text);\n if (slices.Count == 0)\n throw new InvalidOperationException(\"知识库构建失败:未切分出有效文本。\");\n\n var cfg = await configService.LoadAsync();\n if (string.IsNullOrWhiteSpace(cfg.DeepSeekKey))\n throw new InvalidOperationException(\"知识库构建失败:未配置 DeepSeek Key。\");\n\n if (string.IsNullOrWhiteSpace(cfg.QwenKey))\n throw new InvalidOperationException(\"知识库构建失败:未配置 Qwen Key。\");\n\n var src = new CancellationTokenSource();\n\n _chatClient = new OpenAIClient(new ApiKeyCredential(cfg.DeepSeekKey!), new OpenAIClientOptions\n {\n Endpoint = new Uri(\"https://api.deepseek.com\")\n }).GetChatClient(\"deepseek-v4-flash\");\n _embedClient = new OpenAIClient(new ApiKeyCredential(cfg.QwenKey!), new OpenAIClientOptions\n {\n Endpoint = new Uri(\"https://dashscope.aliyuncs.com/compatible-mode/v1\")\n }).GetEmbeddingClient(\"text-embedding-v4\");\n\n var cluster = await ClusterAsync(slices, src.Token);\n var embed = await EmbedAsync(cluster, src.Token);\n var elements = BuildVectorEntries(cluster, embed, src.Token);\n var fvs = BuildVectorStore(projectPath, elements);\n\n return fvs;\n }\n\n public List<string> Slice(string text)\n {\n var slices = SplitText(text);\n\n return slices.Select(x => x.Replace(\"\\n\", \"\").Trim()).ToList();\n }\n\n\n public List<string> SplitText(string text)\n {\n if (string.IsNullOrWhiteSpace(text))\n return new List<string>();\n\n text = CleanText(text);\n var paragraphs = SplitByParagraphs(text);\n\n var chunks = new List<string>();\n foreach (var para in paragraphs)\n {\n chunks.AddRange(SplitParagraph(para));\n }\n\n return chunks;\n }\n\n private string CleanText(string text)\n {\n text = Regex.Replace(text, @\"\\n\\s*\\n\", \"\\n\\n\");\n text = text.Trim();\n return text;\n }\n\n private List<string> SplitByParagraphs(string text)\n {\n var paragraphs = new List<string>();\n var currentPara = new StringBuilder();\n\n using var reader = new StringReader(text);\n string? line;\n\n while ((line = reader.ReadLine()) != null)\n {\n if (string.IsNullOrWhiteSpace(line))\n {\n if (currentPara.Length > 0)\n {\n paragraphs.Add(currentPara.ToString().Trim());\n currentPara.Clear();\n }\n }\n else\n {\n currentPara.AppendLine(line);\n }\n }\n\n if (currentPara.Length > 0)\n {\n paragraphs.Add(currentPara.ToString().Trim());\n }\n\n return paragraphs;\n }\n\n private List<string> SplitParagraph(string paragraph)\n {\n if (paragraph.Length <= _chunkSize)\n {\n return new List<string> { paragraph };\n }\n\n var chunks = new List<string>();\n var sentences = SplitIntoSentences(paragraph);\n\n var currentChunk = new StringBuilder();\n\n foreach (var sentence in sentences)\n {\n if (currentChunk.Length + sentence.Length + 1 > _chunkSize && currentChunk.Length > 0)\n {\n chunks.Add(currentChunk.ToString().Trim());\n\n string overlap = GetOverlap(currentChunk.ToString());\n currentChunk.Clear();\n currentChunk.Append(overlap);\n }\n\n currentChunk.Append(sentence);\n }\n\n if (currentChunk.Length > 0)\n {\n chunks.Add(currentChunk.ToString().Trim());\n }\n\n return chunks;\n }\n\n private List<string> SplitIntoSentences(string text)\n {\n var separators = new[] { \"。\", \"!\", \"?\", \";\", \".\", \"!\", \"?\", \";\" };\n\n var sentences = new List<string>();\n var currentSentence = new StringBuilder();\n\n for (int i = 0; i < text.Length; i++)\n {\n currentSentence.Append(text[i]);\n\n foreach (var sep in separators)\n {\n if (text[i].ToString() == sep)\n {\n sentences.Add(currentSentence.ToString());\n currentSentence.Clear();\n break;\n }\n }\n }\n\n if (currentSentence.Length > 0)\n {\n sentences.Add(currentSentence.ToString());\n }\n\n return sentences;\n }\n\n private string GetOverlap(string chunk)\n {\n if (chunk.Length <= _overlap)\n return chunk;\n\n int start = chunk.Length - _overlap;\n\n int actualStart = chunk.Length;\n for (int i = start; i < chunk.Length; i++)\n {\n if (i > 0 && (chunk[i - 1] == '。' || chunk[i - 1] == '!' || chunk[i - 1] == '?'))\n {\n actualStart = i;\n break;\n }\n }\n\n if (actualStart >= chunk.Length)\n actualStart = start;\n\n return chunk.Substring(actualStart);\n }\n\n public async Task<Dictionary<Semantics, string>> ClusterAsync(List<string> chunks, CancellationToken cts = default)\n {\n var results = new ConcurrentDictionary<Semantics, string>();\n\n await Parallel.ForEachAsync(chunks,\n new ParallelOptions { MaxDegreeOfParallelism = MaxConcurrentRequests, CancellationToken = cts },\n async (chunk, _) =>\n {\n var segmentedSemantics = await ProcessChunkAsync(chunk);\n foreach (var item in segmentedSemantics)\n {\n results.TryAdd(item.Item1, item.Item2);\n }\n });\n\n return results.OrderBy(x => x.Key.Id).ToDictionary(x => x.Key, x => x.Value);\n }\n\n /// <summary>\n /// Process single chunk: segment by atomic knowledge points and generate semantics\n /// </summary>\n private async Task<List<(Semantics, string)>> ProcessChunkAsync(string chunk)\n {\n var prompt = BuildPrompt(chunk);\n\n var messages = new List<ChatMessage>\n {\n new SystemChatMessage(\"你是一个专业的文本分析专家。请完成以下任务:\\n\" +\n \"1. 将输入的文本块按照知识点的原子性进行分割\\n\" +\n \"2. 确保每个分割后的段落只表达一个独立、完整的知识点\\n\" +\n \"3. 为每个知识点段落生成2-3个关键词标签\\n\" +\n \"4. 生成不超过20字的单句摘要\\n\" +\n \"5. 对于被截断的知识点,如果有明确的语义信息进行补全,否则直接丢弃\" +\n \"6. 返回JSON数组,每个元素包含text、tags、summary字段,无其他内容,不要添加markdown代码块结构\"),\n new UserChatMessage(prompt)\n };\n\n var response = await _chatClient.CompleteChatAsync(messages, new ChatCompletionOptions\n {\n Temperature = 0.3f,\n MaxOutputTokenCount = 66666\n });\n\n var json = ExtractJsonContent(response.Value.Content[0].Text);\n return ParseResponse(json);\n }\n\n private static string BuildPrompt(string chunk)\n {\n var sb = new StringBuilder();\n sb.AppendLine(\"请处理以下文本块:\");\n sb.AppendLine();\n sb.AppendLine(chunk);\n sb.AppendLine();\n sb.AppendLine(\"返回JSON数组格式,示例:\");\n sb.AppendLine(\"[\");\n sb.AppendLine(\" {\\\"text\\\": \\\"分割后的知识点1\\\", \\\"tags\\\": [\\\"标签1\\\", \\\"标签2\\\"], \\\"summary\\\": \\\"摘要\\\"},\");\n sb.AppendLine(\" {\\\"text\\\": \\\"分割后的知识点2\\\", \\\"tags\\\": [\\\"标签1\\\", \\\"标签2\\\"], \\\"summary\\\": \\\"摘要\\\"}\");\n sb.AppendLine(\"]\");\n\n return sb.ToString();\n }\n\n /// <summary>\n /// Parse response and return semantics-text pairs with auto-incremented IDs\n /// </summary>\n private static List<(Semantics, string)> ParseResponse(string json)\n {\n var result = new List<(Semantics, string)>();\n var idCounter = 0;\n\n using var doc = JsonDocument.Parse(json);\n var array = doc.RootElement.EnumerateArray().ToList();\n\n foreach (var item in array)\n {\n var text = item.TryGetProperty(\"text\", out var textProp)\n ? textProp.GetString() ?? \"\"\n : \"\";\n\n if (string.IsNullOrWhiteSpace(text))\n continue;\n\n var semantics = new Semantics\n {\n Id = idCounter++,\n Tags = item.TryGetProperty(\"tags\", out var tags)\n ? tags.EnumerateArray()\n .Select(t => t.GetString() ?? \"\")\n .Where(t => !string.IsNullOrWhiteSpace(t))\n .ToList()\n : [],\n Summary = item.TryGetProperty(\"summary\", out var summary)\n ? (summary.GetString() ?? \"\").Trim()\n : \"\"\n };\n\n result.Add((semantics, text.Trim()));\n }\n\n\n return result;\n }\n\n private static string ExtractJsonContent(string content)\n {\n if (string.IsNullOrWhiteSpace(content))\n return \"[]\";\n\n var trimmed = content.Trim();\n var fenceStart = trimmed.IndexOf(\"```\", StringComparison.Ordinal);\n if (fenceStart >= 0)\n {\n var jsonStart = trimmed.IndexOf('\\n', fenceStart);\n if (jsonStart >= 0)\n {\n var fenceEnd = trimmed.LastIndexOf(\"```\", StringComparison.Ordinal);\n trimmed = fenceEnd > jsonStart\n ? trimmed[(jsonStart + 1)..fenceEnd].Trim()\n : trimmed[(jsonStart + 1)..].Trim();\n }\n }\n\n var arrayStart = trimmed.IndexOf('[');\n var arrayEnd = trimmed.LastIndexOf(']');\n return arrayStart >= 0 && arrayEnd > arrayStart\n ? trimmed[arrayStart..(arrayEnd + 1)]\n : trimmed;\n }\n\n public async Task<Dictionary<Semantics, float[]>> EmbedAsync(\n Dictionary<Semantics, string> semanticChunks,\n CancellationToken cts)\n {\n if (semanticChunks.Count == 0) return [];\n\n var semanticsList = semanticChunks.Keys.ToList();\n var textsList = semanticChunks.Values.ToList();\n\n // Results array maintains index correspondence with input order\n var results = new float[textsList.Count][];\n\n var batches = textsList\n .Select((text, index) => new { text, index })\n .Chunk(BatchSize)\n .ToList();\n\n using var semaphore = new SemaphoreSlim(MaxConcurrentRequests);\n\n var tasks = batches.Select(async batch =>\n {\n await semaphore.WaitAsync(cts);\n try\n {\n var batchTexts = batch.Select(b => b.text).ToList();\n var response = await _embedClient.GenerateEmbeddingsAsync(batchTexts, options: null, cts);\n\n for (int i = 0; i < batch.Length; i++)\n results[batch[i].index] = response.Value[i].ToFloats().ToArray();\n }\n finally\n {\n semaphore.Release();\n }\n });\n\n await Task.WhenAll(tasks);\n\n // Reconstruct the result dictionary maintaining Semantics-Embedding pairing\n var resultDictionary = new Dictionary<Semantics, float[]>();\n for (int i = 0; i < semanticsList.Count; i++)\n {\n resultDictionary[semanticsList[i]] = results[i];\n }\n\n return resultDictionary;\n }\n\n public List<VectorEntry> BuildVectorEntries(\n Dictionary<Semantics, string> semanticChunks,\n Dictionary<Semantics, float[]> vectors,\n CancellationToken cts)\n {\n var entries = semanticChunks\n .Select((kvp, idx) => new VectorEntry\n {\n Id = idx,\n Semantics = new Semantics\n {\n Id = idx,\n Tags = kvp.Key.Tags,\n Summary = kvp.Key.Summary\n },\n Text = kvp.Value,\n Vector = vectors[kvp.Key],\n SourceFile = \"question-bank\",\n CreatedAt = DateTime.UtcNow\n })\n .ToList();\n\n Console.WriteLine($\"知识库已构建:{entries.Count} 个向量。\");\n return entries;\n }\n\n public FileVectorStore BuildVectorStore(string projectPath, List<VectorEntry> entries)\n {\n var fvs = new FileVectorStore(projectPath);\n fvs.AddRange(entries);\n return fvs;\n }\n\n }\n}\n"}, "59e9fda6e2ab2d6af88aa7b19eb6980dae251c8b": {"t": 1785760689.7222912, "n": 12967, "h": "f7582fd4ee2e9491", "c": "using OpenAI;\nusing OpenAI.Chat;\nusing ReciteHelper.Core.Entities;\nusing ReciteHelper.Core.Interfaces.Configuration;\nusing ReciteHelper.Core.Interfaces.Services;\nusing System.ClientModel;\nusing System.Collections.Concurrent;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing OpenAI.Embeddings;\nusing ReciteHelper.Core.ValueObjects;\n\nnamespace ReciteHelper.Infrastructure.Services\n{\n internal class KnowledgeBaseService(IConfigService cfgService) : IKnowledgeBaseService\n {\n private readonly int _chunkSize = 1500;\n private readonly int _overlap = 50;\n\n private ChatClient _chatClient;\n private EmbeddingClient _embedClient;\n\n private const int MaxConcurrentRequests = 16;\n private const int BatchSize = 10;\n\n private IConfigService configService = cfgService;\n\n public async Task<FileVectorStore> Build(string projectPath, string text)\n {\n // Wow, such a magic pill!\n\n var slices = Slice(text);\n\n var cfg = await configService.LoadAsync();\n var src = new CancellationTokenSource();\n\n _chatClient = new OpenAIClient(new ApiKeyCredential(cfg.DeepSeekKey!), new OpenAIClientOptions\n {\n Endpoint = new Uri(\"https://api.deepseek.com\")\n }).GetChatClient(\"deepseek-v4-flash\");\n _embedClient = new OpenAIClient(new ApiKeyCredential(cfg.QwenKey!), new OpenAIClientOptions\n {\n Endpoint = new Uri(\"https://dashscope.aliyuncs.com/compatible-mode/v1\")\n }).GetEmbeddingClient(\"text-embedding-v4\");\n\n var cluster = await ClusterAsync(slices, src.Token);\n var embed = await EmbedAsync(cluster, src.Token);\n var elements = BuildVectorEntries(cluster, embed, src.Token);\n var fvs = BuildVectorStore(projectPath, elements);\n\n return fvs;\n }\n\n public List<string> Slice(string text)\n {\n var slices = SplitText(text);\n\n return slices.Select(x => x.Replace(\"\\n\", \"\").Trim()).ToList();\n }\n\n\n public List<string> SplitText(string text)\n {\n if (string.IsNullOrWhiteSpace(text))\n return new List<string>();\n\n text = CleanText(text);\n var paragraphs = SplitByParagraphs(text);\n\n var chunks = new List<string>();\n foreach (var para in paragraphs)\n {\n chunks.AddRange(SplitParagraph(para));\n }\n\n return chunks;\n }\n\n private string CleanText(string text)\n {\n text = Regex.Replace(text, @\"\\n\\s*\\n\", \"\\n\\n\");\n text = text.Trim();\n return text;\n }\n\n private List<string> SplitByParagraphs(string text)\n {\n var paragraphs = new List<string>();\n var currentPara = new StringBuilder();\n\n using var reader = new StringReader(text);\n string? line;\n\n while ((line = reader.ReadLine()) != null)\n {\n if (string.IsNullOrWhiteSpace(line))\n {\n if (currentPara.Length > 0)\n {\n paragraphs.Add(currentPara.ToString().Trim());\n currentPara.Clear();\n }\n }\n else\n {\n currentPara.AppendLine(line);\n }\n }\n\n if (currentPara.Length > 0)\n {\n paragraphs.Add(currentPara.ToString().Trim());\n }\n\n return paragraphs;\n }\n\n private List<string> SplitParagraph(string paragraph)\n {\n if (paragraph.Length <= _chunkSize)\n {\n return new List<string> { paragraph };\n }\n\n var chunks = new List<string>();\n var sentences = SplitIntoSentences(paragraph);\n\n var currentChunk = new StringBuilder();\n\n foreach (var sentence in sentences)\n {\n if (currentChunk.Length + sentence.Length + 1 > _chunkSize && currentChunk.Length > 0)\n {\n chunks.Add(currentChunk.ToString().Trim());\n\n string overlap = GetOverlap(currentChunk.ToString());\n currentChunk.Clear();\n currentChunk.Append(overlap);\n }\n\n currentChunk.Append(sentence);\n }\n\n if (currentChunk.Length > 0)\n {\n chunks.Add(currentChunk.ToString().Trim());\n }\n\n return chunks;\n }\n\n private List<string> SplitIntoSentences(string text)\n {\n var separators = new[] { \"。\", \"!\", \"?\", \";\", \".\", \"!\", \"?\", \";\" };\n\n var sentences = new List<string>();\n var currentSentence = new StringBuilder();\n\n for (int i = 0; i < text.Length; i++)\n {\n currentSentence.Append(text[i]);\n\n foreach (var sep in separators)\n {\n if (text[i].ToString() == sep)\n {\n sentences.Add(currentSentence.ToString());\n currentSentence.Clear();\n break;\n }\n }\n }\n\n if (currentSentence.Length > 0)\n {\n sentences.Add(currentSentence.ToString());\n }\n\n return sentences;\n }\n\n private string GetOverlap(string chunk)\n {\n if (chunk.Length <= _overlap)\n return chunk;\n\n int start = chunk.Length - _overlap;\n\n int actualStart = chunk.Length;\n for (int i = start; i < chunk.Length; i++)\n {\n if (i > 0 && (chunk[i - 1] == '。' || chunk[i - 1] == '!' || chunk[i - 1] == '?'))\n {\n actualStart = i;\n break;\n }\n }\n\n if (actualStart >= chunk.Length)\n actualStart = start;\n\n return chunk.Substring(actualStart);\n }\n\n public async Task<Dictionary<Semantics, string>> ClusterAsync(List<string> chunks, CancellationToken cts = default)\n {\n var results = new ConcurrentDictionary<Semantics, string>();\n\n await Parallel.ForEachAsync(chunks,\n new ParallelOptions { MaxDegreeOfParallelism = MaxConcurrentRequests, CancellationToken = cts },\n async (chunk, _) =>\n {\n var segmentedSemantics = await ProcessChunkAsync(chunk);\n foreach (var item in segmentedSemantics)\n {\n results.TryAdd(item.Item1, item.Item2);\n }\n });\n\n return results.OrderBy(x => x.Key.Id).ToDictionary(x => x.Key, x => x.Value);\n }\n\n /// <summary>\n /// Process single chunk: segment by atomic knowledge points and generate semantics\n /// </summary>\n private async Task<List<(Semantics, string)>> ProcessChunkAsync(string chunk)\n {\n var prompt = BuildPrompt(chunk);\n\n var messages = new List<ChatMessage>\n {\n new SystemChatMessage(\"你是一个专业的文本分析专家。请完成以下任务:\\n\" +\n \"1. 将输入的文本块按照知识点的原子性进行分割\\n\" +\n \"2. 确保每个分割后的段落只表达一个独立、完整的知识点\\n\" +\n \"3. 为每个知识点段落生成2-3个关键词标签\\n\" +\n \"4. 生成不超过20字的单句摘要\\n\" +\n \"5. 对于被截断的知识点,如果有明确的语义信息进行补全,否则直接丢弃\" +\n \"6. 返回JSON数组,每个元素包含text、tags、summary字段,无其他内容,不要添加markdown代码块结构\"),\n new UserChatMessage(prompt)\n };\n\n var response = await _chatClient.CompleteChatAsync(messages, new ChatCompletionOptions\n {\n Temperature = 0.3f,\n MaxOutputTokenCount = 66666\n });\n\n var json = response.Value.Content[0].Text;\n return ParseResponse(json);\n }\n\n private static string BuildPrompt(string chunk)\n {\n var sb = new StringBuilder();\n sb.AppendLine(\"请处理以下文本块:\");\n sb.AppendLine();\n sb.AppendLine(chunk);\n sb.AppendLine();\n sb.AppendLine(\"返回JSON数组格式,示例:\");\n sb.AppendLine(\"[\");\n sb.AppendLine(\" {\\\"text\\\": \\\"分割后的知识点1\\\", \\\"tags\\\": [\\\"标签1\\\", \\\"标签2\\\"], \\\"summary\\\": \\\"摘要\\\"},\");\n sb.AppendLine(\" {\\\"text\\\": \\\"分割后的知识点2\\\", \\\"tags\\\": [\\\"标签1\\\", \\\"标签2\\\"], \\\"summary\\\": \\\"摘要\\\"}\");\n sb.AppendLine(\"]\");\n\n return sb.ToString();\n }\n\n /// <summary>\n /// Parse response and return semantics-text pairs with auto-incremented IDs\n /// </summary>\n private static List<(Semantics, string)> ParseResponse(string json)\n {\n var result = new List<(Semantics, string)>();\n var idCounter = 0;\n\n using var doc = JsonDocument.Parse(json);\n var array = doc.RootElement.EnumerateArray().ToList();\n\n foreach (var item in array)\n {\n var text = item.TryGetProperty(\"text\", out var textProp)\n ? textProp.GetString() ?? \"\"\n : \"\";\n\n if (string.IsNullOrWhiteSpace(text))\n continue;\n\n var semantics = new Semantics\n {\n Id = idCounter++,\n Tags = item.TryGetProperty(\"tags\", out var tags)\n ? tags.EnumerateArray()\n .Select(t => t.GetString() ?? \"\")\n .Where(t => !string.IsNullOrWhiteSpace(t))\n .ToList()\n : [],\n Summary = item.TryGetProperty(\"summary\", out var summary)\n ? (summary.GetString() ?? \"\").Trim()\n : \"\"\n };\n\n result.Add((semantics, text.Trim()));\n }\n\n\n return result;\n }\n\n public async Task<Dictionary<Semantics, float[]>> EmbedAsync(\n Dictionary<Semantics, string> semanticChunks,\n CancellationToken cts)\n {\n if (semanticChunks.Count == 0) return [];\n\n var semanticsList = semanticChunks.Keys.ToList();\n var textsList = semanticChunks.Values.ToList();\n\n // Results array maintains index correspondence with input order\n var results = new float[textsList.Count][];\n\n var batches = textsList\n .Select((text, index) => new { text, index })\n .Chunk(BatchSize)\n .ToList();\n\n using var semaphore = new SemaphoreSlim(MaxConcurrentRequests);\n\n var tasks = batches.Select(async batch =>\n {\n await semaphore.WaitAsync(cts);\n try\n {\n var batchTexts = batch.Select(b => b.text).ToList();\n var response = await _embedClient.GenerateEmbeddingsAsync(batchTexts, options: null, cts);\n\n for (int i = 0; i < batch.Length; i++)\n results[batch[i].index] = response.Value[i].ToFloats().ToArray();\n }\n finally\n {\n semaphore.Release();\n }\n });\n\n await Task.WhenAll(tasks);\n\n // Reconstruct the result dictionary maintaining Semantics-Embedding pairing\n var resultDictionary = new Dictionary<Semantics, float[]>();\n for (int i = 0; i < semanticsList.Count; i++)\n {\n resultDictionary[semanticsList[i]] = results[i];\n }\n\n return resultDictionary;\n }\n\n public List<VectorEntry> BuildVectorEntries(\n Dictionary<Semantics, string> semanticChunks,\n Dictionary<Semantics, float[]> vectors,\n CancellationToken cts)\n {\n var entries = semanticChunks\n .Select((kvp, idx) => new VectorEntry\n {\n Id = idx,\n Semantics = kvp.Key,\n Text = kvp.Value,\n Vector = vectors[kvp.Key],\n SourceFile = \"srcFile\",\n CreatedAt = DateTime.UtcNow\n })\n .ToList();\n\n Console.WriteLine($\"知识库已构建:{entries.Count} 个向量。\");\n return entries;\n }\n\n public FileVectorStore BuildVectorStore(string projectPath, List<VectorEntry> entries)\n {\n var fvs = new FileVectorStore(projectPath);\n fvs.AddRange(entries);\n return fvs;\n }\n\n }\n}\n"}}}}