< Summary

Information
Class: Chronicis.Api.Services.SourceContent
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/SummaryService.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 4
Coverable lines: 4
Total lines: 792
Line coverage: 0%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Type()100%210%
get_Title()100%210%
get_Content()100%210%
get_ArticleId()100%210%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/SummaryService.cs

#LineLine coverage
 1using Azure;
 2using Azure.AI.OpenAI;
 3using Chronicis.Api.Data;
 4using Chronicis.Shared.DTOs;
 5using Chronicis.Shared.Enums;
 6using Microsoft.EntityFrameworkCore;
 7using OpenAI.Chat;
 8
 9namespace Chronicis.Api.Services;
 10
 11/// <summary>
 12/// Azure OpenAI implementation of AI summary generation service.
 13/// Supports articles, campaigns, and arcs with customizable templates.
 14/// </summary>
 15public class SummaryService : ISummaryService
 16{
 17    private readonly ChronicisDbContext _context;
 18    private readonly IConfiguration _configuration;
 19    private readonly ILogger<SummaryService> _logger;
 20    private readonly AzureOpenAIClient _openAIClient;
 21    private readonly ChatClient _chatClient;
 22
 23    private const decimal InputTokenCostPer1K = 0.00040m;
 24    private const decimal OutputTokenCostPer1K = 0.00176m;
 25    private const int CharsPerToken = 4;
 26
 27    // Well-known template IDs (from seed data)
 28    private static readonly Guid DefaultTemplateId = Guid.Parse("00000000-0000-0000-0000-000000000001");
 29    private static readonly Guid CampaignRecapTemplateId = Guid.Parse("00000000-0000-0000-0000-000000000006");
 30
 31    public SummaryService(
 32        ChronicisDbContext context,
 33        IConfiguration configuration,
 34        ILogger<SummaryService> logger)
 35    {
 36        _context = context;
 37        _configuration = configuration;
 38        _logger = logger;
 39
 40        var endpoint = _configuration["AzureOpenAI:Endpoint"];
 41        var apiKey = _configuration["AzureOpenAI:ApiKey"];
 42        var deploymentName = _configuration["AzureOpenAI:DeploymentName"];
 43
 44        if (string.IsNullOrEmpty(endpoint))
 45            throw new InvalidOperationException("AzureOpenAI:Endpoint not configured");
 46        if (string.IsNullOrEmpty(apiKey))
 47            throw new InvalidOperationException("AzureOpenAI:ApiKey not configured");
 48        if (string.IsNullOrEmpty(deploymentName))
 49            throw new InvalidOperationException("AzureOpenAI:DeploymentName not configured");
 50
 51        _openAIClient = new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(apiKey));
 52        _chatClient = _openAIClient.GetChatClient(deploymentName);
 53    }
 54
 55    #region Templates
 56
 57    public async Task<List<SummaryTemplateDto>> GetTemplatesAsync()
 58    {
 59        return await _context.SummaryTemplates
 60            .AsNoTracking()
 61            .Where(t => t.IsSystem) // For now, only system templates
 62            .OrderBy(t => t.Name)
 63            .Select(t => new SummaryTemplateDto
 64            {
 65                Id = t.Id,
 66                Name = t.Name,
 67                Description = t.Description,
 68                IsSystem = t.IsSystem
 69            })
 70            .ToListAsync();
 71    }
 72
 73    #endregion
 74
 75    #region Article Summary
 76
 77    public async Task<SummaryEstimateDto> EstimateArticleSummaryAsync(Guid articleId)
 78    {
 79        var article = await _context.Articles
 80            .AsNoTracking()
 81            .Include(a => a.SummaryTemplate)
 82            .FirstOrDefaultAsync(a => a.Id == articleId)
 83            ?? throw new InvalidOperationException($"Article {articleId} not found");
 84
 85        var (primary, backlinks) = await GetArticleSourcesAsync(articleId);
 86        var promptTemplate = await GetEffectivePromptAsync(
 87            article.SummaryTemplateId,
 88            article.SummaryCustomPrompt,
 89            DefaultTemplateId);
 90
 91        var sourceContent = FormatArticleSources(primary, backlinks);
 92        var fullPrompt = BuildPrompt(promptTemplate, article.Title, sourceContent, "");
 93
 94        int estimatedInputTokens = fullPrompt.Length / CharsPerToken;
 95        int estimatedOutputTokens = int.Parse(_configuration["AzureOpenAI:MaxOutputTokens"] ?? "1500");
 96
 97        decimal estimatedCost =
 98            (estimatedInputTokens / 1000m * InputTokenCostPer1K) +
 99            (estimatedOutputTokens / 1000m * OutputTokenCostPer1K);
 100
 101        // Count sources: 1 for primary (if exists) + backlink count
 102        var sourceCount = (primary != null ? 1 : 0) + backlinks.Count;
 103
 104        return new SummaryEstimateDto
 105        {
 106            EntityId = articleId,
 107            EntityType = "Article",
 108            EntityName = article.Title,
 109            SourceCount = sourceCount,
 110            EstimatedInputTokens = estimatedInputTokens,
 111            EstimatedOutputTokens = estimatedOutputTokens,
 112            EstimatedCostUSD = Math.Round(estimatedCost, 4),
 113            HasExistingSummary = !string.IsNullOrEmpty(article.AISummary),
 114            ExistingSummaryDate = article.AISummaryGeneratedAt,
 115            TemplateId = article.SummaryTemplateId,
 116            TemplateName = article.SummaryTemplate?.Name,
 117            CustomPrompt = article.SummaryCustomPrompt,
 118            IncludeWebSources = article.SummaryIncludeWebSources
 119        };
 120    }
 121
 122    public async Task<SummaryGenerationDto> GenerateArticleSummaryAsync(Guid articleId, GenerateSummaryRequestDto? reque
 123    {
 124        try
 125        {
 126            var article = await _context.Articles
 127                .Include(a => a.SummaryTemplate)
 128                .FirstOrDefaultAsync(a => a.Id == articleId);
 129
 130            if (article == null)
 131            {
 132                return new SummaryGenerationDto
 133                {
 134                    Success = false,
 135                    ErrorMessage = $"Article {articleId} not found"
 136                };
 137            }
 138
 139            var (primary, backlinks) = await GetArticleSourcesAsync(articleId);
 140
 141            // Need at least the article's own content OR backlinks
 142            if (primary == null && backlinks.Count == 0)
 143            {
 144                return new SummaryGenerationDto
 145                {
 146                    Success = false,
 147                    ErrorMessage = "No content available. Add content to this article or create links from other article
 148                };
 149            }
 150
 151            // Determine effective configuration
 152            var templateId = request?.TemplateId ?? article.SummaryTemplateId;
 153            var customPrompt = request?.CustomPrompt ?? article.SummaryCustomPrompt;
 154            var includeWeb = request?.IncludeWebSources ?? article.SummaryIncludeWebSources;
 155
 156            // Save configuration if requested
 157            if (request?.SaveConfiguration == true)
 158            {
 159                article.SummaryTemplateId = request.TemplateId;
 160                article.SummaryCustomPrompt = request.CustomPrompt;
 161                article.SummaryIncludeWebSources = request.IncludeWebSources;
 162            }
 163
 164            var promptTemplate = await GetEffectivePromptAsync(templateId, customPrompt, DefaultTemplateId);
 165            var sourceContent = FormatArticleSources(primary, backlinks);
 166
 167            // Build sources list for response
 168            var allSources = new List<SourceContent>();
 169            if (primary != null)
 170                allSources.Add(primary);
 171            allSources.AddRange(backlinks);
 172
 173            // TODO: Implement web search when includeWeb is true
 174            var webContent = "";
 175
 176            var result = await GenerateSummaryInternalAsync(
 177                article.Title,
 178                promptTemplate,
 179                sourceContent,
 180                webContent,
 181                allSources,
 182                request?.MaxOutputTokens ?? 1500);
 183
 184            if (result.Success)
 185            {
 186                article.AISummary = result.Summary;
 187                article.AISummaryGeneratedAt = DateTime.UtcNow;
 188                await _context.SaveChangesAsync();
 189                result.GeneratedDate = article.AISummaryGeneratedAt.Value;
 190            }
 191
 192            return result;
 193        }
 194        catch (Exception ex)
 195        {
 196            _logger.LogError(ex, "Error generating AI summary for article {ArticleId}", articleId);
 197            return new SummaryGenerationDto
 198            {
 199                Success = false,
 200                ErrorMessage = $"Error generating summary: {ex.Message}"
 201            };
 202        }
 203    }
 204
 205    public async Task<ArticleSummaryDto?> GetArticleSummaryAsync(Guid articleId)
 206    {
 207        var article = await _context.Articles
 208            .AsNoTracking()
 209            .Include(a => a.SummaryTemplate)
 210            .FirstOrDefaultAsync(a => a.Id == articleId);
 211
 212        if (article == null)
 213            return null;
 214
 215        return new ArticleSummaryDto
 216        {
 217            ArticleId = articleId,
 218            Summary = article.AISummary,
 219            GeneratedAt = article.AISummaryGeneratedAt,
 220            TemplateId = article.SummaryTemplateId,
 221            TemplateName = article.SummaryTemplate?.Name,
 222            CustomPrompt = article.SummaryCustomPrompt,
 223            IncludeWebSources = article.SummaryIncludeWebSources
 224        };
 225    }
 226
 227    public async Task<SummaryPreviewDto?> GetArticleSummaryPreviewAsync(Guid articleId)
 228    {
 229        var article = await _context.Articles
 230            .AsNoTracking()
 231            .Include(a => a.SummaryTemplate)
 232            .Where(a => a.Id == articleId)
 233            .Select(a => new SummaryPreviewDto
 234            {
 235                Title = a.Title,
 236                Summary = a.AISummary,
 237                TemplateName = a.SummaryTemplate != null ? a.SummaryTemplate.Name : null
 238            })
 239            .FirstOrDefaultAsync();
 240
 241        return article;
 242    }
 243
 244    public async Task<bool> ClearArticleSummaryAsync(Guid articleId)
 245    {
 246        var article = await _context.Articles.FirstOrDefaultAsync(a => a.Id == articleId);
 247        if (article == null)
 248            return false;
 249
 250        article.AISummary = null;
 251        article.AISummaryGeneratedAt = null;
 252        await _context.SaveChangesAsync();
 253        return true;
 254    }
 255
 256    private async Task<(SourceContent? Primary, List<SourceContent> Backlinks)> GetArticleSourcesAsync(Guid articleId)
 257    {
 258        // Get the article's own content as the primary/canonical source
 259        var article = await _context.Articles
 260            .AsNoTracking()
 261            .FirstOrDefaultAsync(a => a.Id == articleId);
 262
 263        SourceContent? primarySource = null;
 264        if (article != null && !string.IsNullOrEmpty(article.Body))
 265        {
 266            primarySource = new SourceContent
 267            {
 268                Type = "Primary",
 269                Title = article.Title,
 270                Content = article.Body,
 271                ArticleId = article.Id
 272            };
 273        }
 274
 275        // Get all articles that link TO this article (backlinks)
 276        var backlinks = await _context.ArticleLinks
 277            .AsNoTracking()
 278            .Where(al => al.TargetArticleId == articleId)
 279            .Select(al => al.SourceArticle)
 280            .Distinct()
 281            .Where(a => !string.IsNullOrEmpty(a.Body) && a.Visibility == ArticleVisibility.Public)
 282            .Select(a => new SourceContent
 283            {
 284                Type = "Backlink",
 285                Title = a.Title,
 286                Content = a.Body!,
 287                ArticleId = a.Id
 288            })
 289            .ToListAsync();
 290
 291        return (primarySource, backlinks);
 292    }
 293
 294    #endregion
 295
 296    #region Campaign Summary
 297
 298    public async Task<SummaryEstimateDto> EstimateCampaignSummaryAsync(Guid campaignId)
 299    {
 300        var campaign = await _context.Campaigns
 301            .AsNoTracking()
 302            .Include(c => c.SummaryTemplate)
 303            .FirstOrDefaultAsync(c => c.Id == campaignId)
 304            ?? throw new InvalidOperationException($"Campaign {campaignId} not found");
 305
 306        var sources = await GetCampaignSourcesAsync(campaignId);
 307        var promptTemplate = await GetEffectivePromptAsync(
 308            campaign.SummaryTemplateId,
 309            campaign.SummaryCustomPrompt,
 310            CampaignRecapTemplateId);
 311
 312        var sourceContent = FormatSources(sources);
 313        var fullPrompt = BuildPrompt(promptTemplate, campaign.Name, sourceContent, "");
 314
 315        int estimatedInputTokens = fullPrompt.Length / CharsPerToken;
 316        int estimatedOutputTokens = int.Parse(_configuration["AzureOpenAI:MaxOutputTokens"] ?? "1500");
 317
 318        decimal estimatedCost =
 319            (estimatedInputTokens / 1000m * InputTokenCostPer1K) +
 320            (estimatedOutputTokens / 1000m * OutputTokenCostPer1K);
 321
 322        return new SummaryEstimateDto
 323        {
 324            EntityId = campaignId,
 325            EntityType = "Campaign",
 326            EntityName = campaign.Name,
 327            SourceCount = sources.Count,
 328            EstimatedInputTokens = estimatedInputTokens,
 329            EstimatedOutputTokens = estimatedOutputTokens,
 330            EstimatedCostUSD = Math.Round(estimatedCost, 4),
 331            HasExistingSummary = !string.IsNullOrEmpty(campaign.AISummary),
 332            ExistingSummaryDate = campaign.AISummaryGeneratedAt,
 333            TemplateId = campaign.SummaryTemplateId,
 334            TemplateName = campaign.SummaryTemplate?.Name,
 335            CustomPrompt = campaign.SummaryCustomPrompt,
 336            IncludeWebSources = campaign.SummaryIncludeWebSources
 337        };
 338    }
 339
 340    public async Task<SummaryGenerationDto> GenerateCampaignSummaryAsync(Guid campaignId, GenerateSummaryRequestDto? req
 341    {
 342        try
 343        {
 344            var campaign = await _context.Campaigns
 345                .Include(c => c.SummaryTemplate)
 346                .FirstOrDefaultAsync(c => c.Id == campaignId);
 347
 348            if (campaign == null)
 349            {
 350                return new SummaryGenerationDto
 351                {
 352                    Success = false,
 353                    ErrorMessage = $"Campaign {campaignId} not found"
 354                };
 355            }
 356
 357            var sources = await GetCampaignSourcesAsync(campaignId);
 358
 359            if (sources.Count == 0)
 360            {
 361                return new SummaryGenerationDto
 362                {
 363                    Success = false,
 364                    ErrorMessage = "No public session notes found in this campaign."
 365                };
 366            }
 367
 368            var templateId = request?.TemplateId ?? campaign.SummaryTemplateId;
 369            var customPrompt = request?.CustomPrompt ?? campaign.SummaryCustomPrompt;
 370            var includeWeb = request?.IncludeWebSources ?? campaign.SummaryIncludeWebSources;
 371
 372            if (request?.SaveConfiguration == true)
 373            {
 374                campaign.SummaryTemplateId = request.TemplateId;
 375                campaign.SummaryCustomPrompt = request.CustomPrompt;
 376                campaign.SummaryIncludeWebSources = request.IncludeWebSources;
 377            }
 378
 379            var promptTemplate = await GetEffectivePromptAsync(templateId, customPrompt, CampaignRecapTemplateId);
 380            var sourceContent = FormatSources(sources);
 381            var webContent = "";
 382
 383            var result = await GenerateSummaryInternalAsync(
 384                campaign.Name,
 385                promptTemplate,
 386                sourceContent,
 387                webContent,
 388                sources,
 389                request?.MaxOutputTokens ?? 1500);
 390
 391            if (result.Success)
 392            {
 393                campaign.AISummary = result.Summary;
 394                campaign.AISummaryGeneratedAt = DateTime.UtcNow;
 395                await _context.SaveChangesAsync();
 396                result.GeneratedDate = campaign.AISummaryGeneratedAt.Value;
 397            }
 398
 399            return result;
 400        }
 401        catch (Exception ex)
 402        {
 403            _logger.LogError(ex, "Error generating AI summary for campaign {CampaignId}", campaignId);
 404            return new SummaryGenerationDto
 405            {
 406                Success = false,
 407                ErrorMessage = $"Error generating summary: {ex.Message}"
 408            };
 409        }
 410    }
 411
 412    public async Task<EntitySummaryDto?> GetCampaignSummaryAsync(Guid campaignId)
 413    {
 414        var campaign = await _context.Campaigns
 415            .AsNoTracking()
 416            .Include(c => c.SummaryTemplate)
 417            .FirstOrDefaultAsync(c => c.Id == campaignId);
 418
 419        if (campaign == null)
 420            return null;
 421
 422        return new EntitySummaryDto
 423        {
 424            EntityId = campaignId,
 425            EntityType = "Campaign",
 426            Summary = campaign.AISummary,
 427            GeneratedAt = campaign.AISummaryGeneratedAt,
 428            TemplateId = campaign.SummaryTemplateId,
 429            TemplateName = campaign.SummaryTemplate?.Name,
 430            CustomPrompt = campaign.SummaryCustomPrompt,
 431            IncludeWebSources = campaign.SummaryIncludeWebSources
 432        };
 433    }
 434
 435    public async Task<bool> ClearCampaignSummaryAsync(Guid campaignId)
 436    {
 437        var campaign = await _context.Campaigns.FirstOrDefaultAsync(c => c.Id == campaignId);
 438        if (campaign == null)
 439            return false;
 440
 441        campaign.AISummary = null;
 442        campaign.AISummaryGeneratedAt = null;
 443        await _context.SaveChangesAsync();
 444        return true;
 445    }
 446
 447    private async Task<List<SourceContent>> GetCampaignSourcesAsync(Guid campaignId)
 448    {
 449        // Get all public session articles in this campaign
 450        var sessions = await _context.Articles
 451            .AsNoTracking()
 452            .Where(a => a.CampaignId == campaignId
 453                && a.Type == ArticleType.Session
 454                && a.Visibility == ArticleVisibility.Public
 455                && !string.IsNullOrEmpty(a.Body))
 456            .OrderBy(a => a.SessionDate ?? a.CreatedAt)
 457            .Select(a => new SourceContent
 458            {
 459                Type = "Session",
 460                Title = a.Title,
 461                Content = a.Body!,
 462                ArticleId = a.Id
 463            })
 464            .ToListAsync();
 465
 466        return sessions;
 467    }
 468
 469    #endregion
 470
 471    #region Arc Summary
 472
 473    public async Task<SummaryEstimateDto> EstimateArcSummaryAsync(Guid arcId)
 474    {
 475        var arc = await _context.Arcs
 476            .AsNoTracking()
 477            .Include(a => a.SummaryTemplate)
 478            .FirstOrDefaultAsync(a => a.Id == arcId)
 479            ?? throw new InvalidOperationException($"Arc {arcId} not found");
 480
 481        var sources = await GetArcSourcesAsync(arcId);
 482        var promptTemplate = await GetEffectivePromptAsync(
 483            arc.SummaryTemplateId,
 484            arc.SummaryCustomPrompt,
 485            CampaignRecapTemplateId);
 486
 487        var sourceContent = FormatSources(sources);
 488        var fullPrompt = BuildPrompt(promptTemplate, arc.Name, sourceContent, "");
 489
 490        int estimatedInputTokens = fullPrompt.Length / CharsPerToken;
 491        int estimatedOutputTokens = int.Parse(_configuration["AzureOpenAI:MaxOutputTokens"] ?? "1500");
 492
 493        decimal estimatedCost =
 494            (estimatedInputTokens / 1000m * InputTokenCostPer1K) +
 495            (estimatedOutputTokens / 1000m * OutputTokenCostPer1K);
 496
 497        return new SummaryEstimateDto
 498        {
 499            EntityId = arcId,
 500            EntityType = "Arc",
 501            EntityName = arc.Name,
 502            SourceCount = sources.Count,
 503            EstimatedInputTokens = estimatedInputTokens,
 504            EstimatedOutputTokens = estimatedOutputTokens,
 505            EstimatedCostUSD = Math.Round(estimatedCost, 4),
 506            HasExistingSummary = !string.IsNullOrEmpty(arc.AISummary),
 507            ExistingSummaryDate = arc.AISummaryGeneratedAt,
 508            TemplateId = arc.SummaryTemplateId,
 509            TemplateName = arc.SummaryTemplate?.Name,
 510            CustomPrompt = arc.SummaryCustomPrompt,
 511            IncludeWebSources = arc.SummaryIncludeWebSources
 512        };
 513    }
 514
 515    public async Task<SummaryGenerationDto> GenerateArcSummaryAsync(Guid arcId, GenerateSummaryRequestDto? request = nul
 516    {
 517        try
 518        {
 519            var arc = await _context.Arcs
 520                .Include(a => a.SummaryTemplate)
 521                .FirstOrDefaultAsync(a => a.Id == arcId);
 522
 523            if (arc == null)
 524            {
 525                return new SummaryGenerationDto
 526                {
 527                    Success = false,
 528                    ErrorMessage = $"Arc {arcId} not found"
 529                };
 530            }
 531
 532            var sources = await GetArcSourcesAsync(arcId);
 533
 534            if (sources.Count == 0)
 535            {
 536                return new SummaryGenerationDto
 537                {
 538                    Success = false,
 539                    ErrorMessage = "No public session notes found in this arc."
 540                };
 541            }
 542
 543            var templateId = request?.TemplateId ?? arc.SummaryTemplateId;
 544            var customPrompt = request?.CustomPrompt ?? arc.SummaryCustomPrompt;
 545            var includeWeb = request?.IncludeWebSources ?? arc.SummaryIncludeWebSources;
 546
 547            if (request?.SaveConfiguration == true)
 548            {
 549                arc.SummaryTemplateId = request.TemplateId;
 550                arc.SummaryCustomPrompt = request.CustomPrompt;
 551                arc.SummaryIncludeWebSources = request.IncludeWebSources;
 552            }
 553
 554            var promptTemplate = await GetEffectivePromptAsync(templateId, customPrompt, CampaignRecapTemplateId);
 555            var sourceContent = FormatSources(sources);
 556            var webContent = "";
 557
 558            var result = await GenerateSummaryInternalAsync(
 559                arc.Name,
 560                promptTemplate,
 561                sourceContent,
 562                webContent,
 563                sources,
 564                request?.MaxOutputTokens ?? 1500);
 565
 566            if (result.Success)
 567            {
 568                arc.AISummary = result.Summary;
 569                arc.AISummaryGeneratedAt = DateTime.UtcNow;
 570                await _context.SaveChangesAsync();
 571                result.GeneratedDate = arc.AISummaryGeneratedAt.Value;
 572            }
 573
 574            return result;
 575        }
 576        catch (Exception ex)
 577        {
 578            _logger.LogError(ex, "Error generating AI summary for arc {ArcId}", arcId);
 579            return new SummaryGenerationDto
 580            {
 581                Success = false,
 582                ErrorMessage = $"Error generating summary: {ex.Message}"
 583            };
 584        }
 585    }
 586
 587    public async Task<EntitySummaryDto?> GetArcSummaryAsync(Guid arcId)
 588    {
 589        var arc = await _context.Arcs
 590            .AsNoTracking()
 591            .Include(a => a.SummaryTemplate)
 592            .FirstOrDefaultAsync(a => a.Id == arcId);
 593
 594        if (arc == null)
 595            return null;
 596
 597        return new EntitySummaryDto
 598        {
 599            EntityId = arcId,
 600            EntityType = "Arc",
 601            Summary = arc.AISummary,
 602            GeneratedAt = arc.AISummaryGeneratedAt,
 603            TemplateId = arc.SummaryTemplateId,
 604            TemplateName = arc.SummaryTemplate?.Name,
 605            CustomPrompt = arc.SummaryCustomPrompt,
 606            IncludeWebSources = arc.SummaryIncludeWebSources
 607        };
 608    }
 609
 610    public async Task<bool> ClearArcSummaryAsync(Guid arcId)
 611    {
 612        var arc = await _context.Arcs.FirstOrDefaultAsync(a => a.Id == arcId);
 613        if (arc == null)
 614            return false;
 615
 616        arc.AISummary = null;
 617        arc.AISummaryGeneratedAt = null;
 618        await _context.SaveChangesAsync();
 619        return true;
 620    }
 621
 622    private async Task<List<SourceContent>> GetArcSourcesAsync(Guid arcId)
 623    {
 624        // Get all public session articles in this arc
 625        var sessions = await _context.Articles
 626            .AsNoTracking()
 627            .Where(a => a.ArcId == arcId
 628                && a.Type == ArticleType.Session
 629                && a.Visibility == ArticleVisibility.Public
 630                && !string.IsNullOrEmpty(a.Body))
 631            .OrderBy(a => a.SessionDate ?? a.CreatedAt)
 632            .Select(a => new SourceContent
 633            {
 634                Type = "Session",
 635                Title = a.Title,
 636                Content = a.Body!,
 637                ArticleId = a.Id
 638            })
 639            .ToListAsync();
 640
 641        return sessions;
 642    }
 643
 644    #endregion
 645
 646    #region Internal Helpers
 647
 648    private async Task<string> GetEffectivePromptAsync(Guid? templateId, string? customPrompt, Guid defaultTemplateId)
 649    {
 650        // Custom prompt is used as additional instructions, not a full replacement
 651        if (!string.IsNullOrWhiteSpace(customPrompt))
 652        {
 653            // Wrap custom prompt with source content structure
 654            return $@"You are analyzing tabletop RPG campaign notes about: {{EntityName}}
 655
 656Here are the source materials. The CANONICAL CONTENT is from the article itself and should be treated as authoritative f
 657
 658{{SourceContent}}
 659
 660{{WebContent}}
 661
 662Custom instructions from the user:
 663{customPrompt}
 664
 665Based on the source materials above and following the custom instructions, provide a comprehensive summary. Treat the ca
 666        }
 667
 668        // Use specified template or default
 669        var effectiveTemplateId = templateId ?? defaultTemplateId;
 670
 671        var template = await _context.SummaryTemplates
 672            .AsNoTracking()
 673            .FirstOrDefaultAsync(t => t.Id == effectiveTemplateId);
 674
 675        if (template != null)
 676        {
 677            return template.PromptTemplate;
 678        }
 679
 680        // Fallback to default template
 681        template = await _context.SummaryTemplates
 682            .AsNoTracking()
 683            .FirstOrDefaultAsync(t => t.Id == defaultTemplateId);
 684
 685        return template?.PromptTemplate ?? throw new InvalidOperationException("Default template not found");
 686    }
 687
 688    private static string BuildPrompt(string template, string entityName, string sourceContent, string webContent)
 689    {
 690        return template
 691            .Replace("{EntityName}", entityName)
 692            .Replace("{SourceContent}", sourceContent)
 693            .Replace("{WebContent}", string.IsNullOrEmpty(webContent) ? "" : $"\n\nAdditional context from external sour
 694    }
 695
 696    private static string FormatSources(List<SourceContent> sources)
 697    {
 698        return string.Join("\n\n", sources.Select(s =>
 699            $"--- From: {s.Title} ({s.Type}) ---\n{s.Content}\n---"));
 700    }
 701
 702    private static string FormatArticleSources(SourceContent? primary, List<SourceContent> backlinks)
 703    {
 704        var parts = new List<string>();
 705
 706        if (primary != null)
 707        {
 708            parts.Add($"=== CANONICAL CONTENT (from the article itself) ===\n{primary.Content}\n===");
 709        }
 710
 711        if (backlinks.Any())
 712        {
 713            parts.Add("=== REFERENCES FROM OTHER ARTICLES ===");
 714            foreach (var backlink in backlinks)
 715            {
 716                parts.Add($"--- From: {backlink.Title} ---\n{backlink.Content}\n---");
 717            }
 718        }
 719
 720        return string.Join("\n\n", parts);
 721    }
 722
 723
 724    private async Task<SummaryGenerationDto> GenerateSummaryInternalAsync(
 725        string entityName,
 726        string promptTemplate,
 727        string sourceContent,
 728        string webContent,
 729        List<SourceContent> sources,
 730        int maxOutputTokens)
 731    {
 732        var prompt = BuildPrompt(promptTemplate, entityName, sourceContent, webContent);
 733
 734        var maxInputTokens = int.Parse(_configuration["AzureOpenAI:MaxInputTokens"] ?? "8000");
 735        if (prompt.Length / CharsPerToken > maxInputTokens)
 736        {
 737            _logger.LogWarning("Prompt exceeds max input tokens, truncating content");
 738            var maxContentLength = maxInputTokens * CharsPerToken - (promptTemplate.Length + entityName.Length + 200);
 739            var truncatedSourceContent = sourceContent.Substring(0, Math.Min(sourceContent.Length, maxContentLength));
 740            prompt = BuildPrompt(promptTemplate, entityName, truncatedSourceContent, webContent);
 741        }
 742
 743        var messages = new List<ChatMessage>
 744        {
 745            new SystemChatMessage("You are a helpful assistant that summarizes tabletop RPG campaign notes."),
 746            new UserChatMessage(prompt)
 747        };
 748
 749        var chatOptions = new ChatCompletionOptions
 750        {
 751            MaxOutputTokenCount = maxOutputTokens,
 752            Temperature = 0.7f
 753        };
 754
 755        var completion = await _chatClient.CompleteChatAsync(messages, chatOptions);
 756
 757        var summary = completion.Value.Content[0].Text;
 758        var inputTokens = completion.Value.Usage.InputTokenCount;
 759        var outputTokens = completion.Value.Usage.OutputTokenCount;
 760
 761        var actualCost =
 762            (inputTokens / 1000m * InputTokenCostPer1K) +
 763            (outputTokens / 1000m * OutputTokenCostPer1K);
 764
 765        return new SummaryGenerationDto
 766        {
 767            Success = true,
 768            Summary = summary,
 769            TokensUsed = completion.Value.Usage.TotalTokenCount,
 770            ActualCostUSD = Math.Round(actualCost, 4),
 771            Sources = sources.Select(s => new SummarySourceDto
 772            {
 773                Type = s.Type,
 774                Title = s.Title,
 775                ArticleId = s.ArticleId
 776            }).ToList()
 777        };
 778    }
 779
 780    #endregion
 781}
 782
 783/// <summary>
 784/// Internal class for holding source content during processing
 785/// </summary>
 786internal class SourceContent
 787{
 0788    public string Type { get; set; } = string.Empty;
 0789    public string Title { get; set; } = string.Empty;
 0790    public string Content { get; set; } = string.Empty;
 0791    public Guid? ArticleId { get; set; }
 792}