< Summary

Information
Class: Chronicis.Api.Services.SummaryService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/SummaryService.cs
Line coverage
100%
Covered lines: 33
Uncovered lines: 0
Coverable lines: 33
Total lines: 923
Line coverage: 100%
Branch coverage
100%
Covered branches: 14
Total branches: 14
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%66100%
BuildPrompt(...)100%22100%
FormatSources(...)100%11100%
FormatArticleSources(...)100%66100%

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