feat: establish formal relationship between KnowledgeUnit and Ebook (#35) #43

Merged
mjasin merged 2 commits from fix/issue-35-knowledge-unit-relationship into develop 2026-05-14 18:17:17 +00:00
11 changed files with 860 additions and 41 deletions
Showing only changes of commit 82a4d29598 - Show all commits
@@ -5,10 +5,10 @@ namespace NexusReader.Application.Abstractions.Services;
public interface IKnowledgeService public interface IKnowledgeService
{ {
Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, string tenantId, CancellationToken cancellationToken = default); Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default);
Review

Because KnowledgeUnit now strictly requires an EbookId at the database level, ebookId should NOT be an optional parameter (Guid? ebookId = null) in the IKnowledgeService contract. By making it optional, callers like KnowledgeCoordinator are failing to provide it, which leads to Guid.Empty being inserted and crashing the application with a Foreign Key constraint violation. Please make this a required Guid or nullable in the database.

Because `KnowledgeUnit` now strictly requires an `EbookId` at the database level, `ebookId` should NOT be an optional parameter (`Guid? ebookId = null`) in the `IKnowledgeService` contract. By making it optional, callers like `KnowledgeCoordinator` are failing to provide it, which leads to `Guid.Empty` being inserted and crashing the application with a Foreign Key constraint violation. Please make this a required `Guid` or nullable in the database.
Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, string tenantId, CancellationToken cancellationToken = default); Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default);
Task<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, string tenantId, CancellationToken cancellationToken = default); Task<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default);
Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, string tenantId, CancellationToken cancellationToken = default); Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default);
Task<Result<List<RelevantContext>>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default); Task<Result<List<RelevantContext>>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default);
Task<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default); Task<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default);
Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default); Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default);
@@ -4,5 +4,6 @@ namespace NexusReader.Application.Queries.Graph;
/// <param name="Text">Chapter or page content to extract the graph from.</param> /// <param name="Text">Chapter or page content to extract the graph from.</param>
/// <param name="TenantId">Tenant scope for knowledge extraction and caching.</param> /// <param name="TenantId">Tenant scope for knowledge extraction and caching.</param>
public record GetKnowledgeGraphQuery(string Text, string TenantId) : IQuery<GraphDataDto>; /// <param name="EbookId">Optional Ebook ID to link the knowledge units to.</param>
public record GetKnowledgeGraphQuery(string Text, string TenantId, Guid? EbookId = null) : IQuery<GraphDataDto>;
@@ -18,7 +18,11 @@ internal sealed class GetKnowledgeGraphQueryHandler : IQueryHandler<GetKnowledge
if (string.IsNullOrWhiteSpace(request.Text)) if (string.IsNullOrWhiteSpace(request.Text))
return Result.Ok(new GraphDataDto()); return Result.Ok(new GraphDataDto());
var result = await _knowledgeService.GetGraphDataAsync(request.Text, request.TenantId, cancellationToken); var result = await _knowledgeService.GetGraphDataAsync(
request.Text,
request.TenantId,
ebookId: request.EbookId,
cancellationToken: cancellationToken);
if (result.IsFailed) if (result.IsFailed)
return Result.Fail<GraphDataDto>(result.Errors); return Result.Fail<GraphDataDto>(result.Errors);
@@ -247,14 +247,12 @@ namespace NexusReader.Data.Migrations
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<Guid>("EbookId")
.HasColumnType("uuid");
b.Property<string>("MetadataJson") b.Property<string>("MetadataJson")
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("SourceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("TenantId") b.Property<string>("TenantId")
.IsRequired() .IsRequired()
.HasMaxLength(128) .HasMaxLength(128)
@@ -273,7 +271,7 @@ namespace NexusReader.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("SourceId"); b.HasIndex("EbookId");
b.HasIndex("TenantId"); b.HasIndex("TenantId");
@@ -635,6 +633,17 @@ namespace NexusReader.Data.Migrations
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
{
b.HasOne("NexusReader.Domain.Entities.Ebook", "Ebook")
.WithMany()
.HasForeignKey("EbookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Ebook");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b => modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
{ {
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit") b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit")
@@ -65,8 +65,13 @@ public class AppDbContext : IdentityDbContext<NexusUser>
{ {
entity.HasKey(e => e.Id); entity.HasKey(e => e.Id);
entity.HasIndex(e => e.TenantId); entity.HasIndex(e => e.TenantId);
entity.HasIndex(e => e.SourceId); entity.HasIndex(e => e.EbookId);
entity.Property(e => e.Vector).HasColumnType("vector(768)"); entity.Property(e => e.Vector).HasColumnType("vector(768)");
entity.HasOne(e => e.Ebook)
.WithMany()
.HasForeignKey(e => e.EbookId)
.OnDelete(DeleteBehavior.Cascade);
}); });
modelBuilder.Entity<KnowledgeUnitLink>(entity => modelBuilder.Entity<KnowledgeUnitLink>(entity =>
@@ -0,0 +1,712 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Data.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Pgvector;
#nullable disable
namespace NexusReader.Data.Persistence.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260513183726_FixKnowledgeUnitEbookId")]
partial class FixKnowledgeUnitEbookId
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("Authors");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("AddedDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("AuthorId")
.HasColumnType("integer");
b.Property<string>("CoverUrl")
.HasColumnType("text");
b.Property<string>("FilePath")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsReadyForReading")
.HasColumnType("boolean");
b.Property<string>("LastChapter")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<int>("LastChapterIndex")
.HasColumnType("integer");
b.Property<DateTime?>("LastReadDate")
.HasColumnType("timestamp with time zone");
b.Property<double>("Progress")
.HasColumnType("double precision");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AuthorId");
b.HasIndex("TenantId");
b.HasIndex("UserId");
b.ToTable("Ebooks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
{
b.Property<string>("Id")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("EbookId")
.HasColumnType("uuid");
b.Property<string>("MetadataJson")
.HasColumnType("text");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<Vector>("Vector")
.HasColumnType("vector(768)");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("EbookId");
b.HasIndex("TenantId");
b.ToTable("KnowledgeUnits");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("RelationType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("SourceUnitId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("TargetUnitId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("SourceUnitId");
b.HasIndex("TargetUnitId");
b.ToTable("KnowledgeUnitLinks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AITokenLimit")
.HasColumnType("integer");
b.Property<int>("AITokensUsed")
.HasColumnType("integer");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("DisplayName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<DateTime?>("LastAiActionDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("LastReadAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastReadPageId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<int>("SubscriptionPlanId")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1);
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.HasIndex("SubscriptionPlanId");
b.HasIndex("TenantId");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CompletedDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("Score")
.HasColumnType("integer");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Topic")
.IsRequired()
.HasColumnType("text");
b.Property<int>("TotalQuestions")
.HasColumnType("integer");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("TenantId");
b.HasIndex("UserId");
b.ToTable("QuizResults");
});
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
{
b.Property<string>("ContentHash")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("JsonData")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ModelId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("OriginalText")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PromptVersion")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Vector>("Vector")
.HasColumnType("vector(1536)");
b.HasKey("ContentHash");
b.HasIndex("ContentHash")
.IsUnique();
b.HasIndex("TenantId");
b.ToTable("SemanticKnowledgeCache");
});
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AITokenLimit")
.HasColumnType("integer");
b.Property<bool>("IsUnlimitedTokens")
.HasColumnType("boolean");
b.Property<decimal>("MonthlyPrice")
.HasColumnType("numeric");
b.Property<string>("PlanName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("StripeProductId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("PlanName")
.IsUnique();
b.ToTable("SubscriptionPlans");
b.HasData(
new
{
Id = 1,
AITokenLimit = 5000,
IsUnlimitedTokens = false,
MonthlyPrice = 0m,
PlanName = "Free",
StripeProductId = "prod_Free789"
},
new
{
Id = 2,
AITokenLimit = 10000,
IsUnlimitedTokens = false,
MonthlyPrice = 9.99m,
PlanName = "Basic",
StripeProductId = "prod_basic_placeholder"
},
new
{
Id = 3,
AITokenLimit = 50000,
IsUnlimitedTokens = false,
MonthlyPrice = 19.99m,
PlanName = "Pro",
StripeProductId = "prod_pro_placeholder"
},
new
{
Id = 4,
AITokenLimit = 1000000000,
IsUnlimitedTokens = true,
MonthlyPrice = 99.99m,
PlanName = "Enterprise",
StripeProductId = "prod_enterprise_placeholder"
});
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
{
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
.WithMany("Ebooks")
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
.WithMany("Ebooks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Author");
b.Navigation("User");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
{
b.HasOne("NexusReader.Domain.Entities.Ebook", "Ebook")
.WithMany()
.HasForeignKey("EbookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Ebook");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
{
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit")
.WithMany("OutgoingLinks")
.HasForeignKey("SourceUnitId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit")
.WithMany("IncomingLinks")
.HasForeignKey("TargetUnitId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("SourceUnit");
b.Navigation("TargetUnit");
});
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{
b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan")
.WithMany()
.HasForeignKey("SubscriptionPlanId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("SubscriptionPlan");
});
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
.WithMany("QuizResults")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
{
b.Navigation("Ebooks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
{
b.Navigation("IncomingLinks");
b.Navigation("OutgoingLinks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{
b.Navigation("Ebooks");
b.Navigation("QuizResults");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,72 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace NexusReader.Data.Persistence.Migrations
{
/// <inheritdoc />
public partial class FixKnowledgeUnitEbookId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_KnowledgeUnits_SourceId",
table: "KnowledgeUnits");
migrationBuilder.DropColumn(
name: "SourceId",
table: "KnowledgeUnits");
migrationBuilder.AddColumn<Guid>(
name: "EbookId",
table: "KnowledgeUnits",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
migrationBuilder.CreateIndex(
name: "IX_KnowledgeUnits_EbookId",
table: "KnowledgeUnits",
column: "EbookId");
migrationBuilder.AddForeignKey(
name: "FK_KnowledgeUnits_Ebooks_EbookId",
table: "KnowledgeUnits",
column: "EbookId",
principalTable: "Ebooks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_KnowledgeUnits_Ebooks_EbookId",
table: "KnowledgeUnits");
migrationBuilder.DropIndex(
name: "IX_KnowledgeUnits_EbookId",
table: "KnowledgeUnits");
migrationBuilder.DropColumn(
name: "EbookId",
table: "KnowledgeUnits");
migrationBuilder.AddColumn<string>(
name: "SourceId",
table: "KnowledgeUnits",
type: "character varying(128)",
maxLength: 128,
nullable: false,
defaultValue: "");
migrationBuilder.CreateIndex(
name: "IX_KnowledgeUnits_SourceId",
table: "KnowledgeUnits",
column: "SourceId");
}
}
}
@@ -12,8 +12,10 @@ public class KnowledgeUnit
public string Id { get; set; } = string.Empty; // Hash(Source + Content + Version) public string Id { get; set; } = string.Empty; // Hash(Source + Content + Version)
[Required] [Required]
[MaxLength(128)] public Guid EbookId { get; set; }
mjasin marked this conversation as resolved Outdated
Outdated
Review

If the business requirement is that a KnowledgeUnit must always belong to an Ebook, this [Required] constraint is correct. However, if 'Global' knowledge units exist (as noted in your service comments), then EbookId must be a nullable Guid?. Please clarify the domain requirement and align the Database Schema, Domain Entity, and Service Interface to either strictly require the ID or explicitly allow nulls.

If the business requirement is that a `KnowledgeUnit` must always belong to an `Ebook`, this `[Required]` constraint is correct. However, if 'Global' knowledge units exist (as noted in your service comments), then `EbookId` must be a nullable `Guid?`. Please clarify the domain requirement and align the Database Schema, Domain Entity, and Service Interface to either strictly require the ID or explicitly allow nulls.
public string SourceId { get; set; } = string.Empty;
[ForeignKey(nameof(EbookId))]
public virtual Ebook Ebook { get; set; } = null!;
[Required] [Required]
[MaxLength(50)] [MaxLength(50)]
@@ -50,27 +50,27 @@ public class KnowledgeService : IKnowledgeService
_tokenizer = TiktokenTokenizer.CreateForModel("gpt-4"); _tokenizer = TiktokenTokenizer.CreateForModel("gpt-4");
} }
public async Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, string tenantId, CancellationToken cancellationToken = default) public async Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default)
{ {
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.KnowledgeExtractionSystemPrompt, "full", cancellationToken); return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.KnowledgeExtractionSystemPrompt, "full", ebookId, cancellationToken);
} }
public async Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, string tenantId, CancellationToken cancellationToken = default) public async Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default)
{ {
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.GraphExtractionPrompt, "graph", cancellationToken); return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.GraphExtractionPrompt, "graph", ebookId, cancellationToken);
} }
public async Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, string tenantId, CancellationToken cancellationToken = default) public async Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default)
{ {
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.SummaryAndQuizPrompt, "summary_quiz", cancellationToken); return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.SummaryAndQuizPrompt, "summary_quiz", ebookId, cancellationToken);
} }
public async Task<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, string tenantId, CancellationToken cancellationToken = default) public async Task<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default)
{ {
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.KM_ExtractionPrompt, "km_map", cancellationToken); return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.KM_ExtractionPrompt, "km_map", ebookId, cancellationToken);
} }
private async Task<Result<KnowledgePacket>> GetKnowledgeInternalAsync(string text, string tenantId, string systemPrompt, string traceType, CancellationToken cancellationToken) private async Task<Result<KnowledgePacket>> GetKnowledgeInternalAsync(string text, string tenantId, string systemPrompt, string traceType, Guid? ebookId, CancellationToken cancellationToken)
{ {
if (string.IsNullOrWhiteSpace(text)) return Result.Fail("Input text is empty."); if (string.IsNullOrWhiteSpace(text)) return Result.Fail("Input text is empty.");
@@ -161,7 +161,7 @@ public class KnowledgeService : IKnowledgeService
} }
// 5. Process structured KnowledgeUnits (Graph Expansion) // 5. Process structured KnowledgeUnits (Graph Expansion)
await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, dbContext, cancellationToken); await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, ebookId, dbContext, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
return Result.Ok(knowledgePacket); return Result.Ok(knowledgePacket);
@@ -178,7 +178,7 @@ public class KnowledgeService : IKnowledgeService
} }
} }
private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, AppDbContext dbContext, CancellationToken cancellationToken) private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, Guid? ebookId, AppDbContext dbContext, CancellationToken cancellationToken)
{ {
var unitIds = packet.Units.Select(u => u.Id).ToList(); var unitIds = packet.Units.Select(u => u.Id).ToList();
var linkSourceIds = packet.Links.Select(l => l.Source).ToList(); var linkSourceIds = packet.Links.Select(l => l.Source).ToList();
@@ -207,7 +207,13 @@ public class KnowledgeService : IKnowledgeService
unit.Type = Enum.TryParse<NexusReader.Domain.Enums.KnowledgeUnitType>(unitDto.Type, true, out var type) ? type : NexusReader.Domain.Enums.KnowledgeUnitType.Snippet; unit.Type = Enum.TryParse<NexusReader.Domain.Enums.KnowledgeUnitType>(unitDto.Type, true, out var type) ? type : NexusReader.Domain.Enums.KnowledgeUnitType.Snippet;
unit.Content = unitDto.Content; unit.Content = unitDto.Content;
unit.SourceId = "extracted";
// Link to the specific ebook if provided.
// If not provided, we should ideally have a "Global" ebook or make it nullable.
// For now, we use Guid.Empty which will likely fail FK if not present,
// or we make the property nullable in the entity.
unit.EbookId = ebookId ?? Guid.Empty;
mjasin marked this conversation as resolved Outdated
Outdated
Review

Assigning Guid.Empty as a fallback for a required Foreign Key is a dangerous anti-pattern that circumvents compiler safety only to fail at runtime with a database exception. This violates our "Result Pattern / Zero exceptions for flow control" guideline. If a Knowledge Unit must be linked to an Ebook, require a valid Guid in the method signature. If it can be unlinked, update the Domain Entity and EF Core configuration to support a nullable Guid? EbookId.

Assigning `Guid.Empty` as a fallback for a required Foreign Key is a dangerous anti-pattern that circumvents compiler safety only to fail at runtime with a database exception. This violates our "Result Pattern / Zero exceptions for flow control" guideline. If a Knowledge Unit must be linked to an Ebook, require a valid `Guid` in the method signature. If it can be unlinked, update the Domain Entity and EF Core configuration to support a nullable `Guid? EbookId`.
unit.MetadataJson = JsonSerializer.Serialize(unitDto.Metadata); unit.MetadataJson = JsonSerializer.Serialize(unitDto.Metadata);
try try
@@ -14,24 +14,24 @@ public class WasmKnowledgeService : IKnowledgeService
_httpClient = httpClient; _httpClient = httpClient;
} }
public async Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, string tenantId, CancellationToken cancellationToken = default) public async Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default)
{ {
return await CallKnowledgeApiAsync("/api/knowledge", text, cancellationToken); return await CallKnowledgeApiAsync("/api/knowledge", text, ebookId, cancellationToken);
} }
public async Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, string tenantId, CancellationToken cancellationToken = default) public async Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default)
{ {
return await CallKnowledgeApiAsync("/api/knowledge/graph", text, cancellationToken); return await CallKnowledgeApiAsync("/api/knowledge/graph", text, ebookId, cancellationToken);
} }
public async Task<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, string tenantId, CancellationToken cancellationToken = default) public async Task<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default)
{ {
return await CallKnowledgeApiAsync("/api/knowledge/map", text, cancellationToken); return await CallKnowledgeApiAsync("/api/knowledge/map", text, ebookId, cancellationToken);
} }
public async Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, string tenantId, CancellationToken cancellationToken = default) public async Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default)
{ {
return await CallKnowledgeApiAsync("/api/knowledge/summary", text, cancellationToken); return await CallKnowledgeApiAsync("/api/knowledge/summary", text, ebookId, cancellationToken);
} }
public async Task<Result<List<RelevantContext>>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default) public async Task<Result<List<RelevantContext>>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default)
@@ -73,11 +73,11 @@ public class WasmKnowledgeService : IKnowledgeService
} }
} }
private async Task<Result<KnowledgePacket>> CallKnowledgeApiAsync(string endpoint, string text, CancellationToken cancellationToken) private async Task<Result<KnowledgePacket>> CallKnowledgeApiAsync(string endpoint, string text, Guid? ebookId, CancellationToken cancellationToken)
{ {
try try
{ {
var response = await _httpClient.PostAsJsonAsync(endpoint, new { text }, cancellationToken); var response = await _httpClient.PostAsJsonAsync(endpoint, new { text, ebookId }, cancellationToken);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var packet = await response.Content.ReadFromJsonAsync<KnowledgePacket>(cancellationToken: cancellationToken); var packet = await response.Content.ReadFromJsonAsync<KnowledgePacket>(cancellationToken: cancellationToken);
+12 -4
View File
@@ -258,7 +258,7 @@ var knowledgeApi = app.MapGroup("/api/knowledge").RequireAuthorization("HasAvail
knowledgeApi.MapPost("/", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => knowledgeApi.MapPost("/", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) =>
{ {
var tenantId = user.FindFirstValue("TenantId") ?? "global"; var tenantId = user.FindFirstValue("TenantId") ?? "global";
var result = await knowledgeService.GetKnowledgeAsync(request.Text, tenantId); var result = await knowledgeService.GetKnowledgeAsync(request.Text, tenantId, request.EbookId);
if (result.IsSuccess) return Results.Ok(result.Value); if (result.IsSuccess) return Results.Ok(result.Value);
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error");
}); });
@@ -266,7 +266,7 @@ knowledgeApi.MapPost("/", async (KnowledgeRequest request, ClaimsPrincipal user,
knowledgeApi.MapPost("/graph", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => knowledgeApi.MapPost("/graph", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) =>
{ {
var tenantId = user.FindFirstValue("TenantId") ?? "global"; var tenantId = user.FindFirstValue("TenantId") ?? "global";
var result = await knowledgeService.GetGraphDataAsync(request.Text, tenantId); var result = await knowledgeService.GetGraphDataAsync(request.Text, tenantId, request.EbookId);
if (result.IsSuccess) return Results.Ok(result.Value); if (result.IsSuccess) return Results.Ok(result.Value);
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error");
}); });
@@ -274,7 +274,15 @@ knowledgeApi.MapPost("/graph", async (KnowledgeRequest request, ClaimsPrincipal
knowledgeApi.MapPost("/summary", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => knowledgeApi.MapPost("/summary", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) =>
{ {
var tenantId = user.FindFirstValue("TenantId") ?? "global"; var tenantId = user.FindFirstValue("TenantId") ?? "global";
var result = await knowledgeService.GetSummaryAndQuizAsync(request.Text, tenantId); var result = await knowledgeService.GetSummaryAndQuizAsync(request.Text, tenantId, request.EbookId);
if (result.IsSuccess) return Results.Ok(result.Value);
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error");
});
knowledgeApi.MapPost("/map", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) =>
{
var tenantId = user.FindFirstValue("TenantId") ?? "global";
var result = await knowledgeService.GetKnowledgeMapAsync(request.Text, tenantId, request.EbookId);
if (result.IsSuccess) return Results.Ok(result.Value); if (result.IsSuccess) return Results.Ok(result.Value);
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error");
}); });
@@ -519,5 +527,5 @@ app.MapRazorComponents<App>()
app.Run(); app.Run();
public record KnowledgeRequest(string Text); public record KnowledgeRequest(string Text, Guid? EbookId = null);
public record GroundednessRequest(string Answer, string Context); public record GroundednessRequest(string Answer, string Context);