// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.Teams.Apps.Schema.Entities; namespace ExtAIBot; // Parses MCP search results as they are returned by tools and accumulates citation metadata. // After streaming completes, BuildEntities() returns Teams CitationEntity objects for any // [N] references that appear in the final response text. internal sealed class CitationCollector { private readonly ILogger _logger; private readonly Dictionary _citations = []; public CitationCollector(ILogger logger) { _logger = logger; } public void TryExtract(string result) { try { using JsonDocument doc = JsonDocument.Parse(result); if (!TryFindResults(doc.RootElement, out JsonElement results)) return; foreach (JsonElement item in results.EnumerateArray()) { string? url = GetString(item, "contentUrl") ?? GetString(item, "link"); if (url is null || _citations.ContainsKey(url)) continue; string snippet = GetString(item, "content") ?? GetString(item, "description") ?? ""; _citations[url] = new CitationEntry( Position: _citations.Count + 1, Url: url, Title: GetString(item, "title") ?? "", Snippet: snippet.Length > 160 ? snippet[..160] : snippet); } } catch (JsonException ex) { _logger.LogDebug(ex, "Skipped citation extraction because the tool result was not valid JSON."); } catch (FormatException ex) { _logger.LogDebug(ex, "Skipped citation extraction because the tool result had an unexpected format."); } } public IList BuildEntities(string fullText) { HashSet used = []; foreach (Match match in Regex.Matches(fullText, @"\[(\d+)\]")) { if (int.TryParse(match.Groups[1].Value, out int position)) used.Add(position); } List claims = [.. _citations.Values .Where(e => used.Contains(e.Position)) .Select(e => new CitationClaim { Position = e.Position, Appearance = new CitationAppearance { Name = string.IsNullOrEmpty(e.Title) ? $"Source {e.Position}" : e.Title[..Math.Min(80, e.Title.Length)], Abstract = string.IsNullOrEmpty(e.Snippet) ? "No description available." : e.Snippet, Url = Uri.TryCreate(e.Url, UriKind.Absolute, out Uri? uri) ? uri : null }.ToDocument() })]; // TODO : work on Add Citations/feedback/AI label etc in builder return claims.Count == 0 ? [new OMessageEntity { AdditionalType = ["AIGeneratedContent"] }] : [new CitationEntity { AdditionalType = ["AIGeneratedContent"], Citation = claims }]; } // MCP InvokeAsync returns a JsonElement of CallToolResult, not the raw server JSON. // Results may be at root or nested one level deep (e.g. CallToolResult.structuredContent.results). private static bool TryFindResults(JsonElement element, out JsonElement results) { if (element.TryGetProperty("results", out results) && results.ValueKind == JsonValueKind.Array) return true; foreach (JsonProperty prop in element.EnumerateObject()) { if (prop.Value.ValueKind == JsonValueKind.Object && prop.Value.TryGetProperty("results", out results) && results.ValueKind == JsonValueKind.Array) return true; } results = default; return false; } private static string? GetString(JsonElement el, string property) => el.TryGetProperty(property, out JsonElement v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; } internal sealed record CitationEntry(int Position, string Url, string Title, string Snippet);