sozsoft-platform/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain/Services/McpDevGuide.cs

218 lines
7.8 KiB
C#
Raw Permalink Normal View History

2026-09-18 13:47:23 +00:00
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;
/// <summary>
/// Modelin ekran ve bilesen uretirken okudugu sozlesme: depodaki
/// <c>.github/instructions/lowcode*.instructions.md</c> dosyalari. Claude Code ayni dosyalari
/// depodan okur; MCP istemcisi ise bu servisten bolum bolum alir, boylece iki yolun bildigi
/// sozlesme ayrisamaz.
/// </summary>
public interface IMcpDevGuide
{
/// <summary>Okunabilen belgelerin baslik agaci; klasor bulunamazsa bos.</summary>
Task<IReadOnlyList<McpDevGuideHeading>> GetHeadingsAsync();
/// <summary>
/// Bir bolumun metni. <paramref name="section"/> bolum numarasi (<c>4.3</c>) ya da baslik
/// metninin bir parcasidir; bulunamazsa null.
/// </summary>
Task<McpDevGuideSection?> GetSectionAsync(string document, string section);
2026-10-02 06:07:01 +00:00
/// <summary>
/// Metninde <paramref name="term"/> gecen bolumler, gecme sayisina gore. Baslik olmayan bir
/// terim (editor adi, alan adi) istendiginde modele nereye bakacagini soyler.
/// </summary>
Task<IReadOnlyList<McpDevGuideHeading>> SearchAsync(string term, int maxResults);
2026-09-18 13:47:23 +00:00
}
/// <param name="Document">Belgenin kisa adi (<see cref="McpDevGuide.Documents"/>).</param>
/// <param name="Id">Bolum numarasi; numarasiz basliklarda baslik metni.</param>
public sealed record McpDevGuideHeading(string Document, string Id, string Title, int Level);
public sealed record McpDevGuideSection(McpDevGuideHeading Heading, string Content, IReadOnlyList<McpDevGuideHeading> Children);
public partial class McpDevGuide(IConfiguration configuration) : DomainService, IMcpDevGuide
{
/// <summary>Konteynerde mount noktasi; bos ise depo klasoru calisma dizininden yukari aranir.</summary>
public const string ConfigurationKey = "App:DevGuidePath";
/// <summary>Kisa ad → dosya. Yalnizca uretim sozlesmesi sunulur; kod standardi ve karar kurallari degil.</summary>
public static readonly IReadOnlyDictionary<string, string> Documents = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["lowcode"] = "lowcode.instructions.md",
["reference"] = "lowcode-reference.instructions.md",
};
/// <summary>
/// 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.
/// </summary>
private const int MaxSectionLength = 60_000;
[GeneratedRegex(@"^(#{2,4})\s+(?:(\d+(?:\.\d+)*)\.?\s+)?(.+?)\s*$")]
private static partial Regex HeadingPattern();
public async Task<IReadOnlyList<McpDevGuideHeading>> GetHeadingsAsync()
{
var headings = new List<McpDevGuideHeading>();
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<McpDevGuideSection?> 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)]);
}
2026-10-02 06:07:01 +00:00
public async Task<IReadOnlyList<McpDevGuideHeading>> 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)];
}
2026-09-18 13:47:23 +00:00
private async Task<string[]> 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;
}
/// <summary>
/// Kod bloklari icindeki <c>##</c> ile baslayan satirlar (ornegin SQL yorumlari) baslik
/// sayilmaz; aksi halde bolum sinirlari ornek kodun ortasinda kesilirdi.
/// </summary>
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;
}
}