using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.Extensions.Configuration; using Volo.Abp.Domain.Services; namespace Sozsoft.Mcp.Services; /// /// Modelin ekran ve bilesen uretirken okudugu sozlesme: depodaki /// .github/instructions/lowcode*.instructions.md dosyalari. Claude Code ayni dosyalari /// depodan okur; MCP istemcisi ise bu servisten bolum bolum alir, boylece iki yolun bildigi /// sozlesme ayrisamaz. /// public interface IMcpDevGuide { /// Okunabilen belgelerin baslik agaci; klasor bulunamazsa bos. Task> GetHeadingsAsync(); /// /// Bir bolumun metni. bolum numarasi (4.3) ya da baslik /// metninin bir parcasidir; bulunamazsa null. /// Task GetSectionAsync(string document, string section); /// /// Metninde gecen bolumler, gecme sayisina gore. Baslik olmayan bir /// terim (editor adi, alan adi) istendiginde modele nereye bakacagini soyler. /// Task> SearchAsync(string term, int maxResults); } /// Belgenin kisa adi (). /// Bolum numarasi; numarasiz basliklarda baslik metni. public sealed record McpDevGuideHeading(string Document, string Id, string Title, int Level); public sealed record McpDevGuideSection(McpDevGuideHeading Heading, string Content, IReadOnlyList Children); public partial class McpDevGuide(IConfiguration configuration) : DomainService, IMcpDevGuide { /// Konteynerde mount noktasi; bos ise depo klasoru calisma dizininden yukari aranir. public const string ConfigurationKey = "App:DevGuidePath"; /// Kisa ad → dosya. Yalnizca uretim sozlesmesi sunulur; kod standardi ve karar kurallari degil. public static readonly IReadOnlyDictionary Documents = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["lowcode"] = "lowcode.instructions.md", ["reference"] = "lowcode-reference.instructions.md", }; /// /// Tek bir yanitta donen metnin tavani. Ust seviye bolumlerin bazilari istemcilerin tek /// arac cevabi icin kabul ettigi boyutu asar; bu durumda alt basliklar ayrica istenir. /// private const int MaxSectionLength = 60_000; [GeneratedRegex(@"^(#{2,4})\s+(?:(\d+(?:\.\d+)*)\.?\s+)?(.+?)\s*$")] private static partial Regex HeadingPattern(); public async Task> GetHeadingsAsync() { var headings = new List(); foreach (var document in Documents.Keys) { var lines = await ReadLinesAsync(document); headings.AddRange(ParseHeadings(document, lines).Select(heading => heading.Heading)); } return headings; } public async Task GetSectionAsync(string document, string section) { var lines = await ReadLinesAsync(document); var headings = ParseHeadings(document, lines); var wanted = section.Trim().TrimEnd('.'); var index = headings.FindIndex(h => string.Equals(h.Heading.Id, wanted, StringComparison.OrdinalIgnoreCase)); if (index < 0) { index = headings.FindIndex(h => h.Heading.Title.Contains(wanted, StringComparison.OrdinalIgnoreCase)); } if (index < 0) { return null; } var match = headings[index]; var next = headings.FindIndex(index + 1, h => h.Heading.Level <= match.Heading.Level); var end = next < 0 ? lines.Length : headings[next].Line; var children = headings .Skip(index + 1) .TakeWhile(h => h.Line < end) .Where(h => h.Heading.Level == match.Heading.Level + 1) .ToList(); var content = string.Join('\n', lines[match.Line..end]); if (content.Length > MaxSectionLength && children.Count > 0) { // Bolumun giris metni tek basina anlamlidir; geri kalani alt baslik olarak istenir. content = string.Join('\n', lines[match.Line..children[0].Line]); } return new McpDevGuideSection(match.Heading, content, [.. children.Select(child => child.Heading)]); } public async Task> SearchAsync(string term, int maxResults) { var wanted = term.Trim(); if (wanted.Length == 0) { return []; } var hits = new List<(McpDevGuideHeading Heading, int Count, int Order)>(); foreach (var document in Documents.Keys) { var lines = await ReadLinesAsync(document); var headings = ParseHeadings(document, lines); // Her satir kendisinden onceki en yakin basliga sayilir; boylece en dar bolum doner. for (var i = 0; i < headings.Count; i++) { var end = i + 1 < headings.Count ? headings[i + 1].Line : lines.Length; var count = lines[headings[i].Line..end] .Count(line => line.Contains(wanted, StringComparison.OrdinalIgnoreCase)); if (count > 0) { hits.Add((headings[i].Heading, count, hits.Count)); } } } return [.. hits .OrderByDescending(hit => hit.Count) .ThenBy(hit => hit.Order) .Take(maxResults) .Select(hit => hit.Heading)]; } private async Task ReadLinesAsync(string document) { if (!Documents.TryGetValue(document, out var fileName)) { return []; } var directory = ResolveDirectory(); var path = directory is null ? null : Path.Combine(directory, fileName); if (path is null || !File.Exists(path)) { return []; } return (await File.ReadAllTextAsync(path)).Replace("\r\n", "\n").Split('\n'); } private string? ResolveDirectory() { var configured = configuration[ConfigurationKey]; if (!string.IsNullOrWhiteSpace(configured)) { return Path.GetFullPath(configured); } for (var dir = new DirectoryInfo(Directory.GetCurrentDirectory()); dir != null; dir = dir.Parent) { var candidate = Path.Combine(dir.FullName, ".github", "instructions"); if (Directory.Exists(candidate)) { return candidate; } } return null; } /// /// Kod bloklari icindeki ## ile baslayan satirlar (ornegin SQL yorumlari) baslik /// sayilmaz; aksi halde bolum sinirlari ornek kodun ortasinda kesilirdi. /// private static List<(McpDevGuideHeading Heading, int Line)> ParseHeadings(string document, string[] lines) { var headings = new List<(McpDevGuideHeading, int)>(); var inFence = false; for (var i = 0; i < lines.Length; i++) { if (lines[i].TrimStart().StartsWith("```", StringComparison.Ordinal)) { inFence = !inFence; continue; } if (inFence) { continue; } var match = HeadingPattern().Match(lines[i]); if (!match.Success) { continue; } var title = match.Groups[3].Value; var id = match.Groups[2].Success ? match.Groups[2].Value : title; headings.Add((new McpDevGuideHeading(document, id, title, match.Groups[1].Length), i)); } return headings; } }