< 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
0%
Covered lines: 0
Uncovered lines: 512
Coverable lines: 512
Total lines: 792
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 136
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

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
 023    private const decimal InputTokenCostPer1K = 0.00040m;
 024    private const decimal OutputTokenCostPer1K = 0.00176m;
 25    private const int CharsPerToken = 4;
 26
 27    // Well-known template IDs (from seed data)
 028    private static readonly Guid DefaultTemplateId = Guid.Parse("00000000-0000-0000-0000-000000000001");
 029    private static readonly Guid CampaignRecapTemplateId = Guid.Parse("00000000-0000-0000-0000-000000000006");
 30
 031    public SummaryService(
 032        ChronicisDbContext context,
 033        IConfiguration configuration,
 034        ILogger<SummaryService> logger)
 35    {
 036        _context = context;
 037        _configuration = configuration;
 038        _logger = logger;
 39
 040        var endpoint = _configuration["AzureOpenAI:Endpoint"];
 041        var apiKey = _configuration["AzureOpenAI:ApiKey"];
 042        var deploymentName = _configuration["AzureOpenAI:DeploymentName"];
 43
 044        if (string.IsNullOrEmpty(endpoint))
 045            throw new InvalidOperationException("AzureOpenAI:Endpoint not configured");
 046        if (string.IsNullOrEmpty(apiKey))
 047            throw new InvalidOperationException("AzureOpenAI:ApiKey not configured");
 048        if (string.IsNullOrEmpty(deploymentName))
 049            throw new InvalidOperationException("AzureOpenAI:DeploymentName not configured");
 50
 051        _openAIClient = new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(apiKey));
 052        _chatClient = _openAIClient.GetChatClient(deploymentName);
 053    }
 54
 55    #region Templates
 56
 57    public async Task<List<SummaryTemplateDto>> GetTemplatesAsync()
 58    {
 059        return await _context.SummaryTemplates
 060            .AsNoTracking()
 061            .Where(t => t.IsSystem) // For now, only system templates
 062            .OrderBy(t => t.Name)
 063            .Select(t => new SummaryTemplateDto
 064            {
 065                Id = t.Id,
 066                Name = t.Name,
 067                Description = t.Description,
 068                IsSystem = t.IsSystem
 069            })
 070            .ToListAsync();
 071    }
 72
 73    #endregion
 74
 75    #region Article Summary
 76
 77    public async Task<SummaryEstimateDto> EstimateArticleSummaryAsync(Guid articleId)
 78    {
 079        var article = await _context.Articles
 080            .AsNoTracking()
 081            .Include(a => a.SummaryTemplate)
 082            .FirstOrDefaultAsync(a => a.Id == articleId)
 083            ?? throw new InvalidOperationException($"Article {articleId} not found");
 84
 085        var (primary, backlinks) = await GetArticleSourcesAsync(articleId);
 086        var promptTemplate = await GetEffectivePromptAsync(
 087            article.SummaryTemplateId,
 088            article.SummaryCustomPrompt,
 089            DefaultTemplateId);
 90
 091        var sourceContent = FormatArticleSources(primary, backlinks);
 092        var fullPrompt = BuildPrompt(promptTemplate, article.Title, sourceContent, "");
 93
 094        int estimatedInputTokens = fullPrompt.Length / CharsPerToken;
 095        int estimatedOutputTokens = int.Parse(_configuration["AzureOpenAI:MaxOutputTokens"] ?? "1500");
 96
 097        decimal estimatedCost =
 098            (estimatedInputTokens / 1000m * InputTokenCostPer1K) +
 099            (estimatedOutputTokens / 1000m * OutputTokenCostPer1K);
 100
 101        // Count sources: 1 for primary (if exists) + backlink count
 0102        var sourceCount = (primary != null ? 1 : 0) + backlinks.Count;
 103
 0104        return new SummaryEstimateDto
 0105        {
 0106            EntityId = articleId,
 0107            EntityType = "Article",
 0108            EntityName = article.Title,
 0109            SourceCount = sourceCount,
 0110            EstimatedInputTokens = estimatedInputTokens,
 0111            EstimatedOutputTokens = estimatedOutputTokens,
 0112            EstimatedCostUSD = Math.Round(estimatedCost, 4),
 0113            HasExistingSummary = !string.IsNullOrEmpty(article.AISummary),
 0114            ExistingSummaryDate = article.AISummaryGeneratedAt,
 0115            TemplateId = article.SummaryTemplateId,
 0116            TemplateName = article.SummaryTemplate?.Name,
 0117            CustomPrompt = article.SummaryCustomPrompt,
 0118            IncludeWebSources = article.SummaryIncludeWebSources
 0119        };
 0120    }
 121
 122    public async Task<SummaryGenerationDto> GenerateArticleSummaryAsync(Guid articleId, GenerateSummaryRequestDto? reque
 123    {
 124        try
 125        {
 0126            var article = await _context.Articles
 0127                .Include(a => a.SummaryTemplate)
 0128                .FirstOrDefaultAsync(a => a.Id == articleId);
 129
 0130            if (article == null)
 131            {
 0132                return new SummaryGenerationDto
 0133                {
 0134                    Success = false,
 0135                    ErrorMessage = $"Article {articleId} not found"
 0136                };
 137            }
 138
 0139            var (primary, backlinks) = await GetArticleSourcesAsync(articleId);
 140
 141            // Need at least the article's own content OR backlinks
 0142            if (primary == null && backlinks.Count == 0)
 143            {
 0144                return new SummaryGenerationDto
 0145                {
 0146                    Success = false,
 0147                    ErrorMessage = "No content available. Add content to this article or create links from other article
 0148                };
 149            }
 150
 151            // Determine effective configuration
 0152            var templateId = request?.TemplateId ?? article.SummaryTemplateId;
 0153            var customPrompt = request?.CustomPrompt ?? article.SummaryCustomPrompt;
 0154            var includeWeb = request?.IncludeWebSources ?? article.SummaryIncludeWebSources;
 155
 156            // Save configuration if requested
 0157            if (request?.SaveConfiguration == true)
 158            {
 0159                article.SummaryTemplateId = request.TemplateId;
 0160                article.SummaryCustomPrompt = request.CustomPrompt;
 0161                article.SummaryIncludeWebSources = request.IncludeWebSources;
 162            }
 163
 0164            var promptTemplate = await GetEffectivePromptAsync(templateId, customPrompt, DefaultTemplateId);
 0165            var sourceContent = FormatArticleSources(primary, backlinks);
 166
 167            // Build sources list for response
 0168            var allSources = new List<SourceContent>();
 0169            if (primary != null)
 0170                allSources.Add(primary);
 0171            allSources.AddRange(backlinks);
 172
 173            // TODO: Implement web search when includeWeb is true
 0174            var webContent = "";
 175
 0176            var result = await GenerateSummaryInternalAsync(
 0177                article.Title,
 0178                promptTemplate,
 0179                sourceContent,
 0180                webContent,
 0181                allSources,
 0182                request?.MaxOutputTokens ?? 1500);
 183
 0184            if (result.Success)
 185            {
 0186                article.AISummary = result.Summary;
 0187                article.AISummaryGeneratedAt = DateTime.UtcNow;
 0188                await _context.SaveChangesAsync();
 0189                result.GeneratedDate = article.AISummaryGeneratedAt.Value;
 190            }
 191
 0192            return result;
 193        }
 0194        catch (Exception ex)
 195        {
 0196            _logger.LogError(ex, "Error generating AI summary for article {ArticleId}", articleId);
 0197            return new SummaryGenerationDto
 0198            {
 0199                Success = false,
 0200                ErrorMessage = $"Error generating summary: {ex.Message}"
 0201            };
 202        }
 0203    }
 204
 205    public async Task<ArticleSummaryDto?> GetArticleSummaryAsync(Guid articleId)
 206    {
 0207        var article = await _context.Articles
 0208            .AsNoTracking()
 0209            .Include(a => a.SummaryTemplate)
 0210            .FirstOrDefaultAsync(a => a.Id == articleId);
 211
 0212        if (article == null)
 0213            return null;
 214
 0215        return new ArticleSummaryDto
 0216        {
 0217            ArticleId = articleId,
 0218            Summary = article.AISummary,
 0219            GeneratedAt = article.AISummaryGeneratedAt,
 0220            TemplateId = article.SummaryTemplateId,
 0221            TemplateName = article.SummaryTemplate?.Name,
 0222            CustomPrompt = article.SummaryCustomPrompt,
 0223            IncludeWebSources = article.SummaryIncludeWebSources
 0224        };
 0225    }
 226
 227    public async Task<SummaryPreviewDto?> GetArticleSummaryPreviewAsync(Guid articleId)
 228    {
 0229        var article = await _context.Articles
 0230            .AsNoTracking()
 0231            .Include(a => a.SummaryTemplate)
 0232            .Where(a => a.Id == articleId)
 0233            .Select(a => new SummaryPreviewDto
 0234            {
 0235                Title = a.Title,
 0236                Summary = a.AISummary,
 0237                TemplateName = a.SummaryTemplate != null ? a.SummaryTemplate.Name : null
 0238            })
 0239            .FirstOrDefaultAsync();
 240
 0241        return article;
 0242    }
 243
 244    public async Task<bool> ClearArticleSummaryAsync(Guid articleId)
 245    {
 0246        var article = await _context.Articles.FirstOrDefaultAsync(a => a.Id == articleId);
 0247        if (article == null)
 0248            return false;
 249
 0250        article.AISummary = null;
 0251        article.AISummaryGeneratedAt = null;
 0252        await _context.SaveChangesAsync();
 0253        return true;
 0254    }
 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
 0259        var article = await _context.Articles
 0260            .AsNoTracking()
 0261            .FirstOrDefaultAsync(a => a.Id == articleId);
 262
 0263        SourceContent? primarySource = null;
 0264        if (article != null && !string.IsNullOrEmpty(article.Body))
 265        {
 0266            primarySource = new SourceContent
 0267            {
 0268                Type = "Primary",
 0269                Title = article.Title,
 0270                Content = article.Body,
 0271                ArticleId = article.Id
 0272            };
 273        }
 274
 275        // Get all articles that link TO this article (backlinks)
 0276        var backlinks = await _context.ArticleLinks
 0277            .AsNoTracking()
 0278            .Where(al => al.TargetArticleId == articleId)
 0279            .Select(al => al.SourceArticle)
 0280            .Distinct()
 0281            .Where(a => !string.IsNullOrEmpty(a.Body) && a.Visibility == ArticleVisibility.Public)
 0282            .Select(a => new SourceContent
 0283            {
 0284                Type = "Backlink",
 0285                Title = a.Title,
 0286                Content = a.Body!,
 0287                ArticleId = a.Id
 0288            })
 0289            .ToListAsync();
 290
 0291        return (primarySource, backlinks);
 0292    }
 293
 294    #endregion
 295
 296    #region Campaign Summary
 297
 298    public async Task<SummaryEstimateDto> EstimateCampaignSummaryAsync(Guid campaignId)
 299    {
 0300        var campaign = await _context.Campaigns
 0301            .AsNoTracking()
 0302            .Include(c => c.SummaryTemplate)
 0303            .FirstOrDefaultAsync(c => c.Id == campaignId)
 0304            ?? throw new InvalidOperationException($"Campaign {campaignId} not found");
 305
 0306        var sources = await GetCampaignSourcesAsync(campaignId);
 0307        var promptTemplate = await GetEffectivePromptAsync(
 0308            campaign.SummaryTemplateId,
 0309            campaign.SummaryCustomPrompt,
 0310            CampaignRecapTemplateId);
 311
 0312        var sourceContent = FormatSources(sources);
 0313        var fullPrompt = BuildPrompt(promptTemplate, campaign.Name, sourceContent, "");
 314
 0315        int estimatedInputTokens = fullPrompt.Length / CharsPerToken;
 0316        int estimatedOutputTokens = int.Parse(_configuration["AzureOpenAI:MaxOutputTokens"] ?? "1500");
 317
 0318        decimal estimatedCost =
 0319            (estimatedInputTokens / 1000m * InputTokenCostPer1K) +
 0320            (estimatedOutputTokens / 1000m * OutputTokenCostPer1K);
 321
 0322        return new SummaryEstimateDto
 0323        {
 0324            EntityId = campaignId,
 0325            EntityType = "Campaign",
 0326            EntityName = campaign.Name,
 0327            SourceCount = sources.Count,
 0328            EstimatedInputTokens = estimatedInputTokens,
 0329            EstimatedOutputTokens = estimatedOutputTokens,
 0330            EstimatedCostUSD = Math.Round(estimatedCost, 4),
 0331            HasExistingSummary = !string.IsNullOrEmpty(campaign.AISummary),
 0332            ExistingSummaryDate = campaign.AISummaryGeneratedAt,
 0333            TemplateId = campaign.SummaryTemplateId,
 0334            TemplateName = campaign.SummaryTemplate?.Name,
 0335            CustomPrompt = campaign.SummaryCustomPrompt,
 0336            IncludeWebSources = campaign.SummaryIncludeWebSources
 0337        };
 0338    }
 339
 340    public async Task<SummaryGenerationDto> GenerateCampaignSummaryAsync(Guid campaignId, GenerateSummaryRequestDto? req
 341    {
 342        try
 343        {
 0344            var campaign = await _context.Campaigns
 0345                .Include(c => c.SummaryTemplate)
 0346                .FirstOrDefaultAsync(c => c.Id == campaignId);
 347
 0348            if (campaign == null)
 349            {
 0350                return new SummaryGenerationDto
 0351                {
 0352                    Success = false,
 0353                    ErrorMessage = $"Campaign {campaignId} not found"
 0354                };
 355            }
 356
 0357            var sources = await GetCampaignSourcesAsync(campaignId);
 358
 0359            if (sources.Count == 0)
 360            {
 0361                return new SummaryGenerationDto
 0362                {
 0363                    Success = false,
 0364                    ErrorMessage = "No public session notes found in this campaign."
 0365                };
 366            }
 367
 0368            var templateId = request?.TemplateId ?? campaign.SummaryTemplateId;
 0369            var customPrompt = request?.CustomPrompt ?? campaign.SummaryCustomPrompt;
 0370            var includeWeb = request?.IncludeWebSources ?? campaign.SummaryIncludeWebSources;
 371
 0372            if (request?.SaveConfiguration == true)
 373            {
 0374                campaign.SummaryTemplateId = request.TemplateId;
 0375                campaign.SummaryCustomPrompt = request.CustomPrompt;
 0376                campaign.SummaryIncludeWebSources = request.IncludeWebSources;
 377            }
 378
 0379            var promptTemplate = await GetEffectivePromptAsync(templateId, customPrompt, CampaignRecapTemplateId);
 0380            var sourceContent = FormatSources(sources);
 0381            var webContent = "";
 382
 0383            var result = await GenerateSummaryInternalAsync(
 0384                campaign.Name,
 0385                promptTemplate,
 0386                sourceContent,
 0387                webContent,
 0388                sources,
 0389                request?.MaxOutputTokens ?? 1500);
 390
 0391            if (result.Success)
 392            {
 0393                campaign.AISummary = result.Summary;
 0394                campaign.AISummaryGeneratedAt = DateTime.UtcNow;
 0395                await _context.SaveChangesAsync();
 0396                result.GeneratedDate = campaign.AISummaryGeneratedAt.Value;
 397            }
 398
 0399            return result;
 400        }
 0401        catch (Exception ex)
 402        {
 0403            _logger.LogError(ex, "Error generating AI summary for campaign {CampaignId}", campaignId);
 0404            return new SummaryGenerationDto
 0405            {
 0406                Success = false,
 0407                ErrorMessage = $"Error generating summary: {ex.Message}"
 0408            };
 409        }
 0410    }
 411
 412    public async Task<EntitySummaryDto?> GetCampaignSummaryAsync(Guid campaignId)
 413    {
 0414        var campaign = await _context.Campaigns
 0415            .AsNoTracking()
 0416            .Include(c => c.SummaryTemplate)
 0417            .FirstOrDefaultAsync(c => c.Id == campaignId);
 418
 0419        if (campaign == null)
 0420            return null;
 421
 0422        return new EntitySummaryDto
 0423        {
 0424            EntityId = campaignId,
 0425            EntityType = "Campaign",
 0426            Summary = campaign.AISummary,
 0427            GeneratedAt = campaign.AISummaryGeneratedAt,
 0428            TemplateId = campaign.SummaryTemplateId,
 0429            TemplateName = campaign.SummaryTemplate?.Name,
 0430            CustomPrompt = campaign.SummaryCustomPrompt,
 0431            IncludeWebSources = campaign.SummaryIncludeWebSources
 0432        };
 0433    }
 434
 435    public async Task<bool> ClearCampaignSummaryAsync(Guid campaignId)
 436    {
 0437        var campaign = await _context.Campaigns.FirstOrDefaultAsync(c => c.Id == campaignId);
 0438        if (campaign == null)
 0439            return false;
 440
 0441        campaign.AISummary = null;
 0442        campaign.AISummaryGeneratedAt = null;
 0443        await _context.SaveChangesAsync();
 0444        return true;
 0445    }
 446
 447    private async Task<List<SourceContent>> GetCampaignSourcesAsync(Guid campaignId)
 448    {
 449        // Get all public session articles in this campaign
 0450        var sessions = await _context.Articles
 0451            .AsNoTracking()
 0452            .Where(a => a.CampaignId == campaignId
 0453                && a.Type == ArticleType.Session
 0454                && a.Visibility == ArticleVisibility.Public
 0455                && !string.IsNullOrEmpty(a.Body))
 0456            .OrderBy(a => a.SessionDate ?? a.CreatedAt)
 0457            .Select(a => new SourceContent
 0458            {
 0459                Type = "Session",
 0460                Title = a.Title,
 0461                Content = a.Body!,
 0462                ArticleId = a.Id
 0463            })
 0464            .ToListAsync();
 465
 0466        return sessions;
 0467    }
 468
 469    #endregion
 470
 471    #region Arc Summary
 472
 473    public async Task<SummaryEstimateDto> EstimateArcSummaryAsync(Guid arcId)
 474    {
 0475        var arc = await _context.Arcs
 0476            .AsNoTracking()
 0477            .Include(a => a.SummaryTemplate)
 0478            .FirstOrDefaultAsync(a => a.Id == arcId)
 0479            ?? throw new InvalidOperationException($"Arc {arcId} not found");
 480
 0481        var sources = await GetArcSourcesAsync(arcId);
 0482        var promptTemplate = await GetEffectivePromptAsync(
 0483            arc.SummaryTemplateId,
 0484            arc.SummaryCustomPrompt,
 0485            CampaignRecapTemplateId);
 486
 0487        var sourceContent = FormatSources(sources);
 0488        var fullPrompt = BuildPrompt(promptTemplate, arc.Name, sourceContent, "");
 489
 0490        int estimatedInputTokens = fullPrompt.Length / CharsPerToken;
 0491        int estimatedOutputTokens = int.Parse(_configuration["AzureOpenAI:MaxOutputTokens"] ?? "1500");
 492
 0493        decimal estimatedCost =
 0494            (estimatedInputTokens / 1000m * InputTokenCostPer1K) +
 0495            (estimatedOutputTokens / 1000m * OutputTokenCostPer1K);
 496
 0497        return new SummaryEstimateDto
 0498        {
 0499            EntityId = arcId,
 0500            EntityType = "Arc",
 0501            EntityName = arc.Name,
 0502            SourceCount = sources.Count,
 0503            EstimatedInputTokens = estimatedInputTokens,
 0504            EstimatedOutputTokens = estimatedOutputTokens,
 0505            EstimatedCostUSD = Math.Round(estimatedCost, 4),
 0506            HasExistingSummary = !string.IsNullOrEmpty(arc.AISummary),
 0507            ExistingSummaryDate = arc.AISummaryGeneratedAt,
 0508            TemplateId = arc.SummaryTemplateId,
 0509            TemplateName = arc.SummaryTemplate?.Name,
 0510            CustomPrompt = arc.SummaryCustomPrompt,
 0511            IncludeWebSources = arc.SummaryIncludeWebSources
 0512        };
 0513    }
 514
 515    public async Task<SummaryGenerationDto> GenerateArcSummaryAsync(Guid arcId, GenerateSummaryRequestDto? request = nul
 516    {
 517        try
 518        {
 0519            var arc = await _context.Arcs
 0520                .Include(a => a.SummaryTemplate)
 0521                .FirstOrDefaultAsync(a => a.Id == arcId);
 522
 0523            if (arc == null)
 524            {
 0525                return new SummaryGenerationDto
 0526                {
 0527                    Success = false,
 0528                    ErrorMessage = $"Arc {arcId} not found"
 0529                };
 530            }
 531
 0532            var sources = await GetArcSourcesAsync(arcId);
 533
 0534            if (sources.Count == 0)
 535            {
 0536                return new SummaryGenerationDto
 0537                {
 0538                    Success = false,
 0539                    ErrorMessage = "No public session notes found in this arc."
 0540                };
 541            }
 542
 0543            var templateId = request?.TemplateId ?? arc.SummaryTemplateId;
 0544            var customPrompt = request?.CustomPrompt ?? arc.SummaryCustomPrompt;
 0545            var includeWeb = request?.IncludeWebSources ?? arc.SummaryIncludeWebSources;
 546
 0547            if (request?.SaveConfiguration == true)
 548            {
 0549                arc.SummaryTemplateId = request.TemplateId;
 0550                arc.SummaryCustomPrompt = request.CustomPrompt;
 0551                arc.SummaryIncludeWebSources = request.IncludeWebSources;
 552            }
 553
 0554            var promptTemplate = await GetEffectivePromptAsync(templateId, customPrompt, CampaignRecapTemplateId);
 0555            var sourceContent = FormatSources(sources);
 0556            var webContent = "";
 557
 0558            var result = await GenerateSummaryInternalAsync(
 0559                arc.Name,
 0560                promptTemplate,
 0561                sourceContent,
 0562                webContent,
 0563                sources,
 0564                request?.MaxOutputTokens ?? 1500);
 565
 0566            if (result.Success)
 567            {
 0568                arc.AISummary = result.Summary;
 0569                arc.AISummaryGeneratedAt = DateTime.UtcNow;
 0570                await _context.SaveChangesAsync();
 0571                result.GeneratedDate = arc.AISummaryGeneratedAt.Value;
 572            }
 573
 0574            return result;
 575        }
 0576        catch (Exception ex)
 577        {
 0578            _logger.LogError(ex, "Error generating AI summary for arc {ArcId}", arcId);
 0579            return new SummaryGenerationDto
 0580            {
 0581                Success = false,
 0582                ErrorMessage = $"Error generating summary: {ex.Message}"
 0583            };
 584        }
 0585    }
 586
 587    public async Task<EntitySummaryDto?> GetArcSummaryAsync(Guid arcId)
 588    {
 0589        var arc = await _context.Arcs
 0590            .AsNoTracking()
 0591            .Include(a => a.SummaryTemplate)
 0592            .FirstOrDefaultAsync(a => a.Id == arcId);
 593
 0594        if (arc == null)
 0595            return null;
 596
 0597        return new EntitySummaryDto
 0598        {
 0599            EntityId = arcId,
 0600            EntityType = "Arc",
 0601            Summary = arc.AISummary,
 0602            GeneratedAt = arc.AISummaryGeneratedAt,
 0603            TemplateId = arc.SummaryTemplateId,
 0604            TemplateName = arc.SummaryTemplate?.Name,
 0605            CustomPrompt = arc.SummaryCustomPrompt,
 0606            IncludeWebSources = arc.SummaryIncludeWebSources
 0607        };
 0608    }
 609
 610    public async Task<bool> ClearArcSummaryAsync(Guid arcId)
 611    {
 0612        var arc = await _context.Arcs.FirstOrDefaultAsync(a => a.Id == arcId);
 0613        if (arc == null)
 0614            return false;
 615
 0616        arc.AISummary = null;
 0617        arc.AISummaryGeneratedAt = null;
 0618        await _context.SaveChangesAsync();
 0619        return true;
 0620    }
 621
 622    private async Task<List<SourceContent>> GetArcSourcesAsync(Guid arcId)
 623    {
 624        // Get all public session articles in this arc
 0625        var sessions = await _context.Articles
 0626            .AsNoTracking()
 0627            .Where(a => a.ArcId == arcId
 0628                && a.Type == ArticleType.Session
 0629                && a.Visibility == ArticleVisibility.Public
 0630                && !string.IsNullOrEmpty(a.Body))
 0631            .OrderBy(a => a.SessionDate ?? a.CreatedAt)
 0632            .Select(a => new SourceContent
 0633            {
 0634                Type = "Session",
 0635                Title = a.Title,
 0636                Content = a.Body!,
 0637                ArticleId = a.Id
 0638            })
 0639            .ToListAsync();
 640
 0641        return sessions;
 0642    }
 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
 0651        if (!string.IsNullOrWhiteSpace(customPrompt))
 652        {
 653            // Wrap custom prompt with source content structure
 0654            return $@"You are analyzing tabletop RPG campaign notes about: {{EntityName}}
 0655
 0656Here are the source materials. The CANONICAL CONTENT is from the article itself and should be treated as authoritative f
 0657
 0658{{SourceContent}}
 0659
 0660{{WebContent}}
 0661
 0662Custom instructions from the user:
 0663{customPrompt}
 0664
 0665Based 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
 0669        var effectiveTemplateId = templateId ?? defaultTemplateId;
 670
 0671        var template = await _context.SummaryTemplates
 0672            .AsNoTracking()
 0673            .FirstOrDefaultAsync(t => t.Id == effectiveTemplateId);
 674
 0675        if (template != null)
 676        {
 0677            return template.PromptTemplate;
 678        }
 679
 680        // Fallback to default template
 0681        template = await _context.SummaryTemplates
 0682            .AsNoTracking()
 0683            .FirstOrDefaultAsync(t => t.Id == defaultTemplateId);
 684
 0685        return template?.PromptTemplate ?? throw new InvalidOperationException("Default template not found");
 0686    }
 687
 688    private static string BuildPrompt(string template, string entityName, string sourceContent, string webContent)
 689    {
 0690        return template
 0691            .Replace("{EntityName}", entityName)
 0692            .Replace("{SourceContent}", sourceContent)
 0693            .Replace("{WebContent}", string.IsNullOrEmpty(webContent) ? "" : $"\n\nAdditional context from external sour
 694    }
 695
 696    private static string FormatSources(List<SourceContent> sources)
 697    {
 0698        return string.Join("\n\n", sources.Select(s =>
 0699            $"--- From: {s.Title} ({s.Type}) ---\n{s.Content}\n---"));
 700    }
 701
 702    private static string FormatArticleSources(SourceContent? primary, List<SourceContent> backlinks)
 703    {
 0704        var parts = new List<string>();
 705
 0706        if (primary != null)
 707        {
 0708            parts.Add($"=== CANONICAL CONTENT (from the article itself) ===\n{primary.Content}\n===");
 709        }
 710
 0711        if (backlinks.Any())
 712        {
 0713            parts.Add("=== REFERENCES FROM OTHER ARTICLES ===");
 0714            foreach (var backlink in backlinks)
 715            {
 0716                parts.Add($"--- From: {backlink.Title} ---\n{backlink.Content}\n---");
 717            }
 718        }
 719
 0720        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    {
 0732        var prompt = BuildPrompt(promptTemplate, entityName, sourceContent, webContent);
 733
 0734        var maxInputTokens = int.Parse(_configuration["AzureOpenAI:MaxInputTokens"] ?? "8000");
 0735        if (prompt.Length / CharsPerToken > maxInputTokens)
 736        {
 0737            _logger.LogWarning("Prompt exceeds max input tokens, truncating content");
 0738            var maxContentLength = maxInputTokens * CharsPerToken - (promptTemplate.Length + entityName.Length + 200);
 0739            var truncatedSourceContent = sourceContent.Substring(0, Math.Min(sourceContent.Length, maxContentLength));
 0740            prompt = BuildPrompt(promptTemplate, entityName, truncatedSourceContent, webContent);
 741        }
 742
 0743        var messages = new List<ChatMessage>
 0744        {
 0745            new SystemChatMessage("You are a helpful assistant that summarizes tabletop RPG campaign notes."),
 0746            new UserChatMessage(prompt)
 0747        };
 748
 0749        var chatOptions = new ChatCompletionOptions
 0750        {
 0751            MaxOutputTokenCount = maxOutputTokens,
 0752            Temperature = 0.7f
 0753        };
 754
 0755        var completion = await _chatClient.CompleteChatAsync(messages, chatOptions);
 756
 0757        var summary = completion.Value.Content[0].Text;
 0758        var inputTokens = completion.Value.Usage.InputTokenCount;
 0759        var outputTokens = completion.Value.Usage.OutputTokenCount;
 760
 0761        var actualCost =
 0762            (inputTokens / 1000m * InputTokenCostPer1K) +
 0763            (outputTokens / 1000m * OutputTokenCostPer1K);
 764
 0765        return new SummaryGenerationDto
 0766        {
 0767            Success = true,
 0768            Summary = summary,
 0769            TokensUsed = completion.Value.Usage.TotalTokenCount,
 0770            ActualCostUSD = Math.Round(actualCost, 4),
 0771            Sources = sources.Select(s => new SummarySourceDto
 0772            {
 0773                Type = s.Type,
 0774                Title = s.Title,
 0775                ArticleId = s.ArticleId
 0776            }).ToList()
 0777        };
 0778    }
 779
 780    #endregion
 781}
 782
 783/// <summary>
 784/// Internal class for holding source content during processing
 785/// </summary>
 786internal class SourceContent
 787{
 788    public string Type { get; set; } = string.Empty;
 789    public string Title { get; set; } = string.Empty;
 790    public string Content { get; set; } = string.Empty;
 791    public Guid? ArticleId { get; set; }
 792}