|
| 1 | +using System.ComponentModel; |
| 2 | +using ModelContextProtocol.Server; |
| 3 | +using SignsOfAI.Core.Originality; |
| 4 | + |
| 5 | +namespace SignsOfAI.Mcp.Tools; |
| 6 | + |
| 7 | +/// <summary>Copy/originality tools: compare documents to each other, and extract phrases worth a web check. Offline.</summary> |
| 8 | +[McpServerToolType] |
| 9 | +public static class OriginalityTools |
| 10 | +{ |
| 11 | + private static readonly OriginalityChecker Checker = new(); |
| 12 | + |
| 13 | + [McpServerTool(Name = "check_originality", ReadOnly = true), |
| 14 | + Description(""" |
| 15 | + Compares two or more documents AGAINST EACH OTHER to find copied passages — a cohort of student |
| 16 | + submissions, a draft against its sources. For each document pair it returns the overlap percentage |
| 17 | + (case- and accent-insensitive) and the actual shared passages as evidence. This is NOT a whole-internet |
| 18 | + index like Turnitin; it only compares the documents you provide, fully offline. It surfaces evidence and |
| 19 | + lets a human judge — it never accuses. |
| 20 | + """)] |
| 21 | + public static OriginalityResult CheckOriginality( |
| 22 | + [Description("Two or more documents to compare. Each has an optional title and its text.")] DocumentInput[] documents) |
| 23 | + { |
| 24 | + if (documents is null || documents.Length < 2) |
| 25 | + throw new ArgumentException("Provide at least two documents to compare."); |
| 26 | + |
| 27 | + var inputs = documents |
| 28 | + .Select((d, i) => new OriginalityInput( |
| 29 | + (i + 1).ToString(), |
| 30 | + string.IsNullOrWhiteSpace(d.Title) ? $"Document {i + 1}" : d.Title!, |
| 31 | + d.Text ?? string.Empty)) |
| 32 | + .ToList(); |
| 33 | + |
| 34 | + var report = Checker.Check(inputs); |
| 35 | + |
| 36 | + var pairs = report.Pairs |
| 37 | + .Where(p => p.Overlap > 0) |
| 38 | + .Select(p => new PairOverlap( |
| 39 | + p.TitleA, p.TitleB, |
| 40 | + Math.Round(p.Overlap * 100, 1), |
| 41 | + Math.Round(p.Jaccard * 100, 1), |
| 42 | + p.SharedWords, |
| 43 | + p.LongestRunWords, |
| 44 | + p.Passages |
| 45 | + .OrderByDescending(x => x.WordLength) |
| 46 | + .Take(15) |
| 47 | + .Select(x => Slice(inputs[p.IndexA].Text, x.SpanA.Start, x.SpanA.Length)) |
| 48 | + .Where(s => s.Length > 0) |
| 49 | + .ToList())) |
| 50 | + .ToList(); |
| 51 | + |
| 52 | + return new OriginalityResult(inputs.Count, pairs.Count, pairs); |
| 53 | + } |
| 54 | + |
| 55 | + [McpServerTool(Name = "extract_distinctive_phrases", ReadOnly = true), |
| 56 | + Description(""" |
| 57 | + Extracts the most DISTINCTIVE phrases from a document — long, specific, proper-noun- or number-bearing |
| 58 | + wording most worth checking on the web — and returns each with ready-made exact-phrase search links |
| 59 | + (Google, Bing, DuckDuckGo). It does NOT search the web itself; it hands you the searches to run. Offline. |
| 60 | + """)] |
| 61 | + public static PhrasesResult ExtractDistinctivePhrases( |
| 62 | + [Description("The document text.")] string text, |
| 63 | + [Description("Maximum phrases to return. Default 8.")] int maxPhrases = 8) |
| 64 | + { |
| 65 | + var phrases = DistinctivePhraseExtractor.Extract(text ?? string.Empty, Math.Clamp(maxPhrases, 1, 25), 10); |
| 66 | + return new PhrasesResult(phrases.Select(p => new PhraseHit(p.Phrase, MakeSearchLinks(p.Phrase))).ToList()); |
| 67 | + } |
| 68 | + |
| 69 | + private static string Slice(string s, int start, int len) => |
| 70 | + start >= 0 && len >= 0 && start + len <= s.Length ? s.Substring(start, len) : string.Empty; |
| 71 | + |
| 72 | + private static SearchLinks MakeSearchLinks(string phrase) |
| 73 | + { |
| 74 | + var q = Uri.EscapeDataString("\"" + phrase + "\""); |
| 75 | + return new SearchLinks( |
| 76 | + $"https://www.google.com/search?q={q}", |
| 77 | + $"https://www.bing.com/search?q={q}", |
| 78 | + $"https://duckduckgo.com/?q={q}"); |
| 79 | + } |
| 80 | +} |
| 81 | + |
| 82 | +public sealed record DocumentInput(string? Title, string Text); |
| 83 | + |
| 84 | +public sealed record OriginalityResult(int DocumentCount, int OverlappingPairs, IReadOnlyList<PairOverlap> Pairs); |
| 85 | + |
| 86 | +public sealed record PairOverlap( |
| 87 | + string DocumentA, |
| 88 | + string DocumentB, |
| 89 | + double OverlapPercent, |
| 90 | + double JaccardPercent, |
| 91 | + int SharedWords, |
| 92 | + int LongestSharedRunWords, |
| 93 | + IReadOnlyList<string> SharedPassages); |
| 94 | + |
| 95 | +public sealed record PhrasesResult(IReadOnlyList<PhraseHit> Phrases); |
| 96 | + |
| 97 | +public sealed record PhraseHit(string Phrase, SearchLinks Search); |
| 98 | + |
| 99 | +public sealed record SearchLinks(string Google, string Bing, string DuckDuckGo); |
0 commit comments