SeemGen/Services/ContentEditorService.cs

1077 lines
46 KiB
C#

using BLAIzor.Data;
using BLAIzor.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Identity.Client;
using BLAIzor.Helpers;
using Qdrant.Client.Grpc;
using System.Collections;
namespace BLAIzor.Services
{
public class ContentEditorService
{
#region Private Fields
private readonly OpenAIApiService _openAIApiService;
private readonly QDrantService _qDrantService;
private readonly HtmlSnippetProcessor _htmlSnippetProcessor;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ScopedContentService _scopedContentService;
private readonly OpenAIEmbeddingService _openAIEmbeddingService;
private readonly LocalEmbeddingService _localEmbeddingService;
private readonly ISimpleLogger _logger;
public static IConfiguration? _configuration;
#endregion
#region Constructor
/// <summary>
/// Initializes a new instance of the <see cref="ContentEditorService"/> class.
/// </summary>
/// <param name="openAIApiService">The OpenAI API service.</param>
/// <param name="qDrantService">The Qdrant service.</param>
/// <param name="openAIEmbeddingService">The OpenAI embedding service.</param>
/// <param name="localEmbeddingService">The local embedding service.</param>
/// <param name="htmlSnippetProcessor">The HTML snippet processor.</param>
/// <param name="serviceScopeFactory">The service scope factory for creating database contexts.</param>
/// <param name="scopedContentService">The scoped content service.</param>
/// <param name="configuration">The application configuration.</param>
public ContentEditorService(OpenAIApiService openAIApiService,
QDrantService qDrantService,
OpenAIEmbeddingService openAIEmbeddingService,
LocalEmbeddingService localEmbeddingService,
HtmlSnippetProcessor htmlSnippetProcessor,
IServiceScopeFactory serviceScopeFactory,
ScopedContentService scopedContentService,
IConfiguration? configuration,
ISimpleLogger logger)
{
_openAIApiService = openAIApiService;
_qDrantService = qDrantService;
_openAIEmbeddingService = openAIEmbeddingService;
_localEmbeddingService = localEmbeddingService;
_htmlSnippetProcessor = htmlSnippetProcessor;
_serviceScopeFactory = serviceScopeFactory;
_scopedContentService = scopedContentService;
_configuration = configuration;
_logger = logger;
}
#endregion
#region Private Helper Methods
/// <summary>
/// Retrieves the AI embedding service setting from the configuration.
/// </summary>
/// <returns>The name of the embedding service (e.g., "local", "openai").</returns>
private string GetAiEmbeddingSettings() =>
_configuration?.GetSection("AiSettings")?.GetValue<string>("EmbeddingService") ?? string.Empty;
/// <summary>
/// Removes content chunks and their corresponding Qdrant entries by their IDs.
/// </summary>
/// <param name="chunkIds">A list of content chunk IDs to remove.</param>
/// <param name="collectionName">The name of the Qdrant collection.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
private async Task RemoveChunksAndQdrantEntriesByIdsAsync(List<int> chunkIds, string collectionName)
{
using var scope = _serviceScopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var chunks = await db.ContentChunks
.Where(c => chunkIds.Contains(c.Id))
.ToListAsync();
var pointIds = chunks.Select(c => Guid.Parse(c.QdrantPointId)).ToArray();
await _qDrantService.DeletePointsAsync(pointIds, collectionName);
db.ContentChunks.RemoveRange(chunks);
await db.SaveChangesAsync();
}
/// <summary>
/// Generates a unique vector collection name for a given site.
/// </summary>
/// <param name="siteInfo">The site information.</param>
/// <returns>A generated vector collection name.</returns>
public string GetGeneratedVectorCollectionName(SiteInfo siteInfo)
{
var safeName = siteInfo.SiteName?.ToLower().Replace(" ", "_").Replace("-", "_");
return $"site_{safeName}_{Guid.NewGuid().ToString().Substring(0, 8)}";
}
#endregion
#region MenuItem CRUD Operations
/// <summary>
/// Adds a new menu item to the database.
/// </summary>
/// <param name="menuItem">The menu item to add.</param>
/// <returns>The added menu item with its generated ID.</returns>
public async Task<MenuItem> AddMenuItemAsync(MenuItem menuItem)
{
using (var scope = _serviceScopeFactory.CreateScope())
{
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var result = await _context.MenuItems.AddAsync(menuItem);
await _context.SaveChangesAsync();
return result.Entity;
}
}
/// <summary>
/// Adds multiple menu items to the database.
/// </summary>
/// <param name="menuItems">A list of menu items to add.</param>
/// <returns>The number of state entries written to the database.</returns>
public async Task<int> AddMenuItemsAsync(List<MenuItem> menuItems)
{
using (var scope = _serviceScopeFactory.CreateScope())
{
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
_context.MenuItems.AddRange(menuItems);
var result = await _context.SaveChangesAsync();
return result;
}
}
/// <summary>
/// Retrieves all menu items for a specific site.
/// </summary>
/// <param name="siteInfoId">The ID of the site.</param>
/// <returns>A list of menu items belonging to the specified site.</returns>
public async Task<List<MenuItem>> GetMenuItemsBySiteIdAsync(int siteInfoId)
{
await _logger.InfoAsync($"GetMenuItemsBySiteIdAsync: method called", siteInfoId.ToString());
using (var scope = _serviceScopeFactory.CreateScope())
{
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var result = await _context.MenuItems
.Where(m => m.SiteInfoId == siteInfoId)
.ToListAsync();
return result;
}
}
/// <summary>
/// Retrieves all top-level menu items for a specific site, including their children.
/// </summary>
/// <param name="siteInfoId">The ID of the site.</param>
/// <returns>A list of top-level menu items with their associated children.</returns>
public async Task<List<MenuItem>> GetMenuItemsBySiteIdWithChildrenAsync(int siteInfoId)
{
await _logger.InfoAsync($"GetMenuItemsBySiteIdWithChildrenAsync: method called", siteInfoId.ToString());
using (var scope = _serviceScopeFactory.CreateScope())
{
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var result = await _context.MenuItems
.Where(m => m.SiteInfoId == siteInfoId && m.ParentId == null)
.Include(m => m.Children)
.ToListAsync();
return result;
}
}
/// <summary>
/// Updates an existing menu item in the database.
/// </summary>
/// <param name="menuItem">The menu item with updated information.</param>
/// <returns>The updated menu item.</returns>
/// <exception cref="Exception">Thrown if the menu item is not found.</exception>
public async Task<MenuItem> UpdateMenuItemAsync(MenuItem menuItem)
{
using var scope = _serviceScopeFactory.CreateScope();
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var existingMenuItem = await _context.MenuItems.FindAsync(menuItem.Id);
if (existingMenuItem == null)
{
throw new Exception("MenuItem not found.");
}
existingMenuItem.Name = menuItem.Name;
existingMenuItem.Slug = menuItem.Slug;
existingMenuItem.ParentId = menuItem.ParentId;
existingMenuItem.StoredHtml = menuItem.StoredHtml;
existingMenuItem.SortOrder = menuItem.SortOrder;
existingMenuItem.ShowInMainMenu = menuItem.ShowInMainMenu;
await _context.SaveChangesAsync();
return existingMenuItem;
}
/// <summary>
/// Deletes a menu item from the database.
/// </summary>
/// <param name="menuItemId">The ID of the menu item to delete.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
/// <exception cref="Exception">Thrown if the menu item is not found.</exception>
public async Task DeleteMenuItemAsync(int menuItemId)
{
using (var scope = _serviceScopeFactory.CreateScope())
{
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var menuItem = await _context.MenuItems.FindAsync(menuItemId);
if (menuItem == null)
{
throw new Exception("MenuItem not found.");
}
_context.MenuItems.Remove(menuItem);
await _context.SaveChangesAsync();
}
}
#endregion
#region ContentGroup Operations
/// <summary>
/// Retrieves the first ContentGroup associated with the given SiteInfoId.
/// Returns null if not found.
/// </summary>
/// <param name="siteInfoId">The ID of the site information.</param>
/// <returns>The first ContentGroup found, or null if none exists.</returns>
public async Task<ContentGroup?> GetContentGroupBySiteInfoIdAsync(int siteInfoId)
{
using (var scope = _serviceScopeFactory.CreateScope())
{
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var result = await _context.ContentGroups
.Where(cg => cg.SiteInfoId == siteInfoId)
.OrderBy(cg => cg.Id)
.FirstOrDefaultAsync();
return result;
}
}
/// <summary>
/// Returns a list of ContentGroups for a site, with optional filtering by type or slug.
/// </summary>
/// <param name="siteInfoId">The ID of the site information.</param>
/// <param name="type">Optional. The type of the content group to filter by.</param>
/// <param name="slug">Optional. The slug of the content group to filter by.</param>
/// <returns>A list of matching ContentGroups.</returns>
public async Task<List<ContentGroup>> GetContentGroupsBySiteInfoIdAsync(int siteInfoId, string? type = null, string? slug = null)
{
using (var scope = _serviceScopeFactory.CreateScope())
{
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var query = _context.ContentGroups
.Include(cg => cg.SiteInfo)
.Include(cg => cg.Items)
.ThenInclude(ci => ci.Chunks)
.AsQueryable();
query = query.Where(cg => cg.SiteInfoId == siteInfoId);
if (!string.IsNullOrWhiteSpace(type))
query = query.Where(cg => cg.Type == type);
if (!string.IsNullOrWhiteSpace(slug))
query = query.Where(cg => cg.Slug == slug);
return query.ToList();
}
}
/// <summary>
/// Updates an existing content group in the database.
/// </summary>
/// <param name="updatedGroup">The content group with updated information.</param>
/// <returns>The updated content group.</returns>
/// <exception cref="Exception">Thrown if the content group is not found.</exception>
public async Task<ContentGroup> UpdateContentGroupByIdAsync(ContentGroup updatedGroup)
{
using var scope = _serviceScopeFactory.CreateScope();
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var existingGroup = await _context.ContentGroups.FindAsync(updatedGroup.Id);
if (existingGroup == null)
throw new Exception("ContentGroup not found.");
existingGroup.Name = updatedGroup.Name;
existingGroup.Slug = updatedGroup.Slug;
existingGroup.Type = updatedGroup.Type;
existingGroup.VectorSize = updatedGroup.VectorSize;
existingGroup.EmbeddingModel = updatedGroup.EmbeddingModel;
existingGroup.LastUpdated = DateTime.UtcNow;
existingGroup.Version = updatedGroup.Version;
_context.ContentGroups.Update(existingGroup);
await _context.SaveChangesAsync();
return existingGroup;
}
/// <summary>
/// Deletes a content group by its ID.
/// </summary>
/// <param name="contentGroupId">The ID of the content group to delete.</param>
/// <returns><c>true</c> if the content group was deleted, <c>false</c> otherwise.</returns>
public async Task<bool> DeleteContentGroupByIdAsync(int contentGroupId)
{
using var scope = _serviceScopeFactory.CreateScope();
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var group = await _context.ContentGroups.FindAsync(contentGroupId);
if (group == null)
return false;
_context.ContentGroups.Remove(group);
await _context.SaveChangesAsync();
return true;
}
/// <summary>
/// Creates a new content group in the database.
/// </summary>
/// <param name="newGroup">The new content group to create.</param>
/// <returns>The newly created content group with its generated ID and timestamps.</returns>
public async Task<ContentGroup> CreateContentGroupAsync(ContentGroup newGroup)
{
using var scope = _serviceScopeFactory.CreateScope();
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
newGroup.CreatedAt = DateTime.UtcNow;
newGroup.LastUpdated = DateTime.UtcNow;
_context.ContentGroups.Add(newGroup);
await _context.SaveChangesAsync();
return newGroup;
}
/// <summary>
/// Returns all ContentGroups for a site, with optional filtering by type.
/// </summary>
/// <param name="siteInfoId">The ID of the site information.</param>
/// <param name="type">Optional. The type of the content group to filter by.</param>
/// <returns>A list of matching ContentGroups.</returns>
public async Task<List<ContentGroup>> GetAllContentGroupsBySiteInfoIdAsync(int siteInfoId, string? type = null)
{
using (var scope = _serviceScopeFactory.CreateScope())
{
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var query = _context.ContentGroups
.Where(cg => cg.SiteInfoId == siteInfoId);
if (!string.IsNullOrWhiteSpace(type))
query = query.Where(cg => cg.Type == type);
return await query
.OrderBy(cg => cg.Name)
.ToListAsync();
}
}
#endregion
#region ContentItem Operations
/// <summary>
/// Retrieves a content item by its ID, optionally filtered by type.
/// </summary>
/// <param name="contentItemId">The ID of the content item.</param>
/// <param name="type">Optional. The type of the content item to filter by.</param>
/// <returns>The matching content item with its chunks, or null if not found.</returns>
public async Task<ContentItem?> GetContentItemByIdAsync(int contentItemId, string? type = null)
{
using (var scope = _serviceScopeFactory.CreateScope())
{
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var query = _context.ContentItems
.Where(cg => cg.Id == contentItemId).Include(ci => ci.Chunks);
return query.FirstOrDefault();
}
}
/// <summary>
/// Retrieves a content item by its ID, including related ContentGroup, Chunks, and SiteInfo.
/// </summary>
/// <param name="id">The ID of the content item.</param>
/// <returns>The matching content item, or null if not found.</returns>
public async Task<ContentItem?> GetContentItemByIdAsync(int id)
{
using var scope = _serviceScopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var query = db.ContentItems.Where(x => x.Id == id).Include(ci => ci.ContentGroup).Include(ci => ci.Chunks).Include(ci => ci.ContentGroup.SiteInfo);
return query.FirstOrDefault();
}
/// <summary>
/// Creates a new content item, chunks its content, generates embeddings, and inserts them into Qdrant.
/// </summary>
/// <param name="item">The content item to create.</param>
/// <param name="collectionName">The name of the Qdrant collection to insert into.</param>
/// <returns>The created content item.</returns>
/// <exception cref="Exception">Thrown if the saved ContentItem cannot be retrieved.</exception>
public async Task<ContentItem> CreateContentItemAsync(ContentItem item, string collectionName)
{
await _logger.InfoAsync($"CreateContentItemAsync: method called", item.Id.ToString());
using var scope = _serviceScopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
item.CreatedAt = DateTime.UtcNow;
item.LastUpdated = DateTime.UtcNow;
db.ContentItems.Add(item);
await db.SaveChangesAsync();
var fullItem = await db.ContentItems
.Include(ci => ci.ContentGroup)
.FirstOrDefaultAsync(ci => ci.Id == item.Id);
if (fullItem == null)
throw new Exception("Failed to retrieve saved ContentItem.");
if (!string.IsNullOrEmpty(item.Content))
{
var chunks = ChunkingHelper.SplitStructuredText(item.Content, 3000);
var vectorizedChunks = await VectorizeChunksWithGuidsAsync(chunks, fullItem, collectionName);
var chunkEntities = vectorizedChunks.Select((chunk, index) => new ContentChunk
{
ContentItemId = item.Id,
QdrantPointId = chunk.UId,
ChunkIndex = index,
CreatedAt = DateTime.UtcNow
}).ToList();
db.ContentChunks.AddRange(chunkEntities);
}
await db.SaveChangesAsync();
return item;
}
/// <summary>
/// Vectorizes a list of content chunks, generates unique GUIDs for each, and inserts them into Qdrant.
/// </summary>
/// <param name="chunks">The list of text chunks to vectorize.</param>
/// <param name="item">The content item associated with these chunks.</param>
/// <param name="collectionName">The name of the Qdrant collection.</param>
/// <returns>A list of <see cref="WebPageContent"/> representing the vectorized chunks.</returns>
public async Task<List<WebPageContent>> VectorizeChunksWithGuidsAsync(List<string> chunks, ContentItem item, string collectionName)
{
var result = new List<WebPageContent>();
foreach (var (chunk, index) in chunks.Select((c, i) => (c, i)))
{
var combinedText = chunk;
float[] embedding = [];
var embeddingServiceProvider = GetAiEmbeddingSettings();
if (embeddingServiceProvider == "local")
{
embedding = await _localEmbeddingService.GenerateEmbeddingAsync(combinedText);
}
else
{
embedding = await _openAIEmbeddingService.GenerateEmbeddingAsync(combinedText);
}
var uid = Guid.NewGuid().ToString();
result.Add(new WebPageContent
{
Id = Guid.Parse(uid),
UId = uid,
SiteId = item.ContentGroup.SiteInfoId,
Type = "content-item",
Name = item.Title,
Description = item.Description,
Content = chunk,
Vectors = embedding,
LastUpdated = DateTime.UtcNow
});
}
await _qDrantService.QDrantInsertManyAsync(result, collectionName);
return result;
}
/// <summary>
/// Vectorizes a list of content chunks and inserts them into Qdrant.
/// </summary>
/// <param name="chunks">The list of text chunks to vectorize.</param>
/// <param name="item">The content item associated with these chunks.</param>
/// <param name="collectionName">The name of the Qdrant collection.</param>
/// <returns>A list of <see cref="WebPageContent"/> representing the vectorized chunks.</returns>
public async Task<List<WebPageContent>> VectorizeChunksAsync(List<string> chunks, ContentItem item, string collectionName)
{
var result = new List<WebPageContent>();
foreach (var (chunk, index) in chunks.Select((c, i) => (c, i)))
{
var combinedText = $"{chunk}";
float[] embedding = [];
var embeddingServiceProvider = GetAiEmbeddingSettings();
if (embeddingServiceProvider == "local")
{
embedding = await _localEmbeddingService.GenerateEmbeddingAsync(combinedText);
}
else
{
embedding = await _openAIEmbeddingService.GenerateEmbeddingAsync(combinedText);
}
var uid = Guid.NewGuid();
var webChunk = new WebPageContent
{
Id = uid,
UId = uid.ToString(),
SiteId = item.ContentGroup.SiteInfoId,
Type = "content-item",
Name = item.Title,
Description = item.Description,
Content = chunk,
Vectors = embedding,
LastUpdated = DateTime.UtcNow
};
result.Add(webChunk);
}
await _qDrantService.QDrantInsertManyAsync(result, collectionName);
return result;
}
/// <summary>
/// Forces a re-chunking and re-embedding of all content items within a specified content group.
/// </summary>
/// <param name="contentGroupId">The ID of the content group to re-chunk.</param>
/// <param name="collectionName">The name of the Qdrant collection. If null, the existing collection name will be used.</param>
/// <returns><c>true</c> if the operation was successful, <c>false</c> otherwise.</returns>
public async Task<bool> ForceRechunkContentGroupAsync(int contentGroupId, string collectionName = null)
{
using var scope = _serviceScopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var group = await db.ContentGroups
.Include(g => g.Items)
.ThenInclude(ci => ci.Chunks)
.Include(g => g.SiteInfo)
.FirstOrDefaultAsync(g => g.Id == contentGroupId);
if (group == null)
return false;
foreach (var item in group.Items)
{
await SaveAndSyncContentItemAsync(item, collectionName, forceRechunk: true);
}
return true;
}
/// <summary>
/// Forces the recreation of a Qdrant collection for a given site, including re-chunking and re-embedding all content.
/// </summary>
/// <param name="siteInfoId">The ID of the site for which to recreate the collection.</param>
/// <returns><c>true</c> if the collection was successfully recreated and content re-synced, <c>false</c> otherwise.</returns>
/// <exception cref="Exception">Thrown if the new Qdrant collection cannot be created.</exception>
public async Task<bool> ForceRecreateQdrantCollectionAsync(int siteInfoId)
{
using var scope = _serviceScopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var site = await db.SiteInfos
.Include(s => s.ContentGroups)
.ThenInclude(g => g.Items)
.ThenInclude(i => i.Chunks)
.FirstOrDefaultAsync(s => s.Id == siteInfoId);
if (site == null)
return false;
string oldCollectionName = site.VectorCollectionName;
await _qDrantService.DeleteCollectionAsync(oldCollectionName);
string newCollectionName = GetGeneratedVectorCollectionName(site);
bool created = await _qDrantService.CreateQdrantCollectionAsync(newCollectionName);
if (!created)
throw new Exception($"Failed to create Qdrant collection '{newCollectionName}'.");
site.VectorCollectionName = newCollectionName;
await UpdateSiteInfoAsync(site);
foreach (var group in site.ContentGroups)
{
foreach (var item in group.Items.ToList())
{
var dto = new ContentItem
{
Id = item.Id,
Title = item.Title,
Description = item.Description,
Content = item.Content,
Language = item.Language,
Tags = item.Tags,
IsPublished = item.IsPublished,
Version = item.Version,
LastUpdated = item.LastUpdated,
ContentGroupId = item.ContentGroupId
};
await SaveAndSyncContentItemAsync(dto, newCollectionName, forceRechunk: true);
}
}
return true;
}
/// <summary>
/// Retrieves all content items belonging to a specific content group.
/// </summary>
/// <param name="contentGroupId">The ID of the content group.</param>
/// <returns>A list of content items in the specified group, ordered by last updated date descending.</returns>
public async Task<List<ContentItem>> GetContentItemsByGroupIdAsync(int contentGroupId)
{
using (var scope = _serviceScopeFactory.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
return await context.ContentItems
.Where(ci => ci.ContentGroupId == contentGroupId)
.OrderByDescending(ci => ci.LastUpdated)
.ToListAsync();
}
}
/// <summary>
/// Retrieves all content items belonging to a specific content group.
/// </summary>
/// <param name="contentGroupId">The ID of the content group.</param>
/// <returns>A list of content items in the specified group, ordered by last updated date descending.</returns>
public async Task<List<ContentItem>> GetAllContentItemsBySiteIdAsync(int siteId)
{
using (var scope = _serviceScopeFactory.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
//var site = await GetSiteInfoByIdAsync(siteId);
//List<ContentItem> contentList = new List<ContentItem>();
//foreach (var item in site.ContentGroups)
//{
// var contentItems = await GetContentItemsByGroupIdAsync(item.Id);
// contentList.AddRange(contentItems);
//}
//return contentList;
return await context.ContentItems
.Where(ci => ci.ContentGroup.SiteInfoId == siteId)
.Include(ci => ci.Chunks)
.ToListAsync();
}
}
/// <summary>
/// Creates a new content item in the database.
/// </summary>
/// <param name="newItem">The new content item to create.</param>
/// <returns>The newly created content item with its generated ID and timestamps.</returns>
public async Task<ContentItem?> CreateContentItemAsync(ContentItem newItem)
{
using var scope = _serviceScopeFactory.CreateScope();
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
newItem.CreatedAt = DateTime.UtcNow;
newItem.LastUpdated = DateTime.UtcNow;
_context.ContentItems.Add(newItem);
await _context.SaveChangesAsync();
return newItem;
}
/// <summary>
/// Saves changes to a content item and synchronizes its chunks and embeddings with Qdrant.
/// If content has changed or re-chunking is forced, old chunks are removed and new ones are created and embedded.
/// </summary>
/// <param name="dto">The content item data transfer object with updated information.</param>
/// <param name="collectionName">The name of the Qdrant collection to sync with.</param>
/// <param name="forceRechunk">If set to <c>true</c>, forces re-chunking and re-embedding even if content hasn't changed.</param>
/// <returns>The updated and synchronized content item.</returns>
/// <exception cref="Exception">Thrown if the content item is not found.</exception>
public async Task<ContentItem> SaveAndSyncContentItemAsync(ContentItem dto, string collectionName, bool forceRechunk = false)
{
using var scope = _serviceScopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var existing = await db.ContentItems
.Include(ci => ci.Chunks)
.Include(ci => ci.ContentGroup)
.FirstOrDefaultAsync(ci => ci.Id == dto.Id);
if (existing == null)
throw new Exception("ContentItem not found.");
bool contentChanged = existing.Content?.Trim() != dto.Content?.Trim();
existing.Title = dto.Title;
existing.Description = dto.Description;
existing.Content = dto.Content;
existing.Language = dto.Language;
existing.Tags = dto.Tags;
existing.IsPublished = dto.IsPublished;
existing.LastUpdated = dto.LastUpdated;
if (contentChanged || forceRechunk)
{
existing.Version++;
var chunkIds = existing.Chunks.Select(c => c.Id).ToList();
if (chunkIds.Any())
{
await RemoveChunksAndQdrantEntriesByIdsAsync(chunkIds, collectionName);
foreach (var chunk in existing.Chunks.ToList())
{
db.Entry(chunk).State = EntityState.Detached;
}
existing.Chunks.Clear();
}
var newChunks = ChunkingHelper.SplitStructuredText(existing.Content, 3000);
var webChunks = await VectorizeChunksAsync(newChunks, existing, collectionName);
var chunkEntities = webChunks.Select((w, i) => new ContentChunk
{
ContentItemId = existing.Id,
ChunkIndex = i,
QdrantPointId = w.Id.Uuid,
CreatedAt = DateTime.UtcNow
}).ToList();
db.ContentChunks.AddRange(chunkEntities);
}
await db.SaveChangesAsync();
return existing;
}
/// <summary>
/// Groups content items by their content group type.
/// </summary>
/// <param name="model">The website content model containing content items.</param>
/// <returns>A dictionary where the key is the content group type and the value is a list of content item models.</returns>
public static Dictionary<string, List<ContentItemModel>> GroupContentItemsByType(WebsiteContentModel model)
{
return model.ContentItems
.GroupBy(ci => ci.ContentItem.ContentGroup.Type)
.ToDictionary(g => g.Key, g => g.ToList());
}
/// <summary>
/// Deletes a content item by its ID.
/// </summary>
/// <param name="id">The ID of the content item to delete.</param>
/// <returns><c>true</c> if the content item was deleted, <c>false</c> otherwise.</returns>
public async Task<bool> DeleteContentItemByIdAsync(int id)
{
using var scope = _serviceScopeFactory.CreateScope();
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var item = await _context.ContentItems.FindAsync(id);
if (item != null)
{
_context.ContentItems.Remove(item);
await _context.SaveChangesAsync();
return true;
}
return false;
}
/// <summary>
/// Retrieves the Qdrant point IDs associated with content chunks belonging to a specific content group.
/// </summary>
/// <param name="contentGroupId">The ID of the content group.</param>
/// <returns>An array of Qdrant point IDs (represented as chunk indices).</returns>
public async Task<int[]> GetPointIdsByContentGroupIdAsync(int contentGroupId)
{
using (var scope = _serviceScopeFactory.CreateScope())
{
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
return await _context.ContentChunks
.Where(chunk => chunk.ContentItem.ContentGroupId == contentGroupId)
.Select(chunk => chunk.ChunkIndex)
.ToArrayAsync();
}
}
#endregion
#region SiteInfo Operations
/// <summary>
/// Retrieves site information by its ID.
/// </summary>
/// <param name="SiteInfoId">The ID of the site information.</param>
/// <returns>The matching site information, or the first site information if not found.</returns>
public async Task<SiteInfo> GetSiteInfoByIdAsync(int SiteInfoId)
{
using (var scope = _serviceScopeFactory.CreateScope())
{
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var result = await _context.SiteInfos.Where(x => x.Id == SiteInfoId).FirstOrDefaultAsync();
if (result == null)
{
return await _context.SiteInfos.FirstOrDefaultAsync();
}
else
{
return result;
}
}
}
/// <summary>
/// Retrieves site information by its ID, including associated form definitions.
/// </summary>
/// <param name="siteId">The ID of the site.</param>
/// <returns>The matching site information with form definitions, or a new <see cref="SiteInfo"/> if not found.</returns>
public async Task<SiteInfo?> GetSiteInfoWithFormsByIdAsync(int siteId)
{
using (var scope = _serviceScopeFactory.CreateScope())
{
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var result = await _context.SiteInfos.Include(s => s.FormDefinitions).FirstOrDefaultAsync(s => s.Id == siteId);
if (result == null)
{
return new SiteInfo();
}
else
{
return result;
}
}
}
/// <summary>
/// Retrieves site information by its name.
/// </summary>
/// <param name="siteName">The name of the site.</param>
/// <returns>The matching site information, or the first site information if not found.</returns>
public async Task<SiteInfo> GetSiteInfoByNameAsync(string siteName)
{
using (var scope = _serviceScopeFactory.CreateScope())
{
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var result = await _context.SiteInfos.Where(x => x.SiteName == siteName).FirstOrDefaultAsync();
if (result == null)
{
return await _context.SiteInfos.FirstOrDefaultAsync();
}
else
{
return result;
}
}
}
/// <summary>
/// Retrieves site information by its default URL.
/// </summary>
/// <param name="url">The default URL of the site.</param>
/// <returns>The matching site information, or the first site information if not found.</returns>
public async Task<SiteInfo> GetSiteInfoByUrlAsync(string url)
{
using (var scope = _serviceScopeFactory.CreateScope())
{
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var result = await _context.SiteInfos.Where(x => x.DefaultUrl == url).FirstOrDefaultAsync();
if (result == null)
{
return await _context.SiteInfos.FirstOrDefaultAsync();
}
else
{
return result;
}
}
}
/// <summary>
/// Updates existing site information in the database.
/// </summary>
/// <param name="siteInfo">The site information with updated details.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task UpdateSiteInfoAsync(SiteInfo siteInfo)
{
using (var scope = _serviceScopeFactory.CreateScope())
{
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
_context.SiteInfos.Update(siteInfo);
await _context.SaveChangesAsync();
}
}
/// <summary>
/// Retrieves all sites associated with a specific user.
/// </summary>
/// <param name="userId">The ID of the user.</param>
/// <returns>A list of sites belonging to the specified user.</returns>
public async Task<List<SiteInfo>> GetUserSitesAsync(string userId)
{
using (var scope = _serviceScopeFactory.CreateScope())
{
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
return await _context.SiteInfos
.Where(s => s.UserId == userId)
.ToListAsync();
}
}
/// <summary>
/// Retrieves all site information records from the database.
/// </summary>
/// <returns>A list of all site information records.</returns>
public async Task<List<SiteInfo>> GetAllSitesAsync()
{
using (var scope = _serviceScopeFactory.CreateScope())
{
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
return await _context.SiteInfos.ToListAsync();
}
}
/// <summary>
/// Adds new site information to the database and creates a corresponding Qdrant collection.
/// </summary>
/// <param name="siteInfo">The site information to add.</param>
/// <returns>The added site information.</returns>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="siteInfo"/> is null.</exception>
/// <exception cref="InvalidOperationException">Thrown if an error occurs during the site creation or Qdrant collection creation.</exception>
public async Task<SiteInfo> AddSiteInfoAsync(SiteInfo siteInfo)
{
using (var scope = _serviceScopeFactory.CreateScope())
{
var _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
if (siteInfo == null)
throw new ArgumentNullException(nameof(siteInfo), "SiteInfo cannot be null.");
try
{
if (string.IsNullOrEmpty(siteInfo.VectorCollectionName))
{
siteInfo.VectorCollectionName = GetGeneratedVectorCollectionName(siteInfo);
}
await _context.SiteInfos.AddAsync(siteInfo);
var result = await _context.SaveChangesAsync();
if (result > 0)
{
bool checkResult = await _qDrantService.CollectionExistsAsync(siteInfo.VectorCollectionName);
if (checkResult)
{
siteInfo.VectorCollectionName = GetGeneratedVectorCollectionName(siteInfo);
_context.SiteInfos.Update(siteInfo);
await _context.SaveChangesAsync();
bool qresult = await _qDrantService.CreateQdrantCollectionAsync(siteInfo.VectorCollectionName);
if (!qresult)
{
throw new Exception("Failed to create Qdrant collection for the site.");
}
}
else
{
bool qresult = await _qDrantService.CreateQdrantCollectionAsync(siteInfo.VectorCollectionName);
if (!qresult)
{
throw new Exception("Failed to create Qdrant collection for the site.");
}
}
}
return siteInfo;
}
catch (Exception ex)
{
throw new InvalidOperationException("An error occurred while adding the site info.", ex);
}
}
}
#endregion
///TEMPORARY
///
public async Task<bool> MigrateQdrantToContentItemsAsync(int siteId, string collectionName)
{
using var scope = _serviceScopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var site = await GetSiteInfoByIdAsync(siteId);
// 1. Get menu items
var menuItems = await GetMenuItemsBySiteIdAsync(siteId);
PointId[] pointIds = new PointId[menuItems.Count];
for (int i = 0; i < menuItems.Count; i++)
{
// Ensure SortOrder is set to i+1 (1-based index)
pointIds[i] = Convert.ToUInt64(menuItems[i].SortOrder);
}
if (!pointIds.Any())
return false;
// 2. Get points from Qdrant
var qdrantPoints = await _qDrantService.GetPointsFromQdrantAsyncByIntegerPointIds(collectionName, pointIds);
// 3. Create new content group
var contentGroup = new ContentGroup
{
Name = "Pages",
Type = "page",
SiteInfoId = siteId,
CreatedAt = DateTime.UtcNow,
EmbeddingModel = "openai",
Slug = $"group-{DateTime.UtcNow.Ticks}",
Version = 0,
LastUpdated = DateTime.UtcNow
};
db.ContentGroups.Add(contentGroup);
await db.SaveChangesAsync(); // Save to get ID
// 4. Create ContentItems from Qdrant points
foreach (var point in qdrantPoints)
{
var item = new ContentItem
{
ContentGroupId = contentGroup.Id,
Title = point.Name,
Description = point.Description,
Content = point.Content,
Language = "en",
Tags = "",
IsPublished = true,
Version = 1,
CreatedAt = DateTime.UtcNow,
LastUpdated = DateTime.UtcNow
};
db.ContentItems.Add(item);
}
await db.SaveChangesAsync();
return true;
}
}
}