Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f79eb0b2e | |||
| 4432c901f0 | |||
| c94e8f0acb | |||
| ec3fc52a73 | |||
| 9fddafa423 | |||
| 9291bde531 | |||
| 1d6862016d | |||
| bcd5daa3a0 | |||
| f6819d50b7 | |||
| f18663426b | |||
| 081c6f7940 | |||
| 00004ce433 | |||
| 711480f8f6 | |||
| 72905aa119 | |||
| a0bf6c15f4 |
@@ -30,4 +30,14 @@ When conducting or receiving a code review for NexusReader, ensure the implement
|
|||||||
- [ ] **AI Prompts**: Ensure changes to AI logic do not bypass the `PromptRegistry` or token estimation limits defined in `AiSettings`.
|
- [ ] **AI Prompts**: Ensure changes to AI logic do not bypass the `PromptRegistry` or token estimation limits defined in `AiSettings`.
|
||||||
|
|
||||||
## 6. Code Review Comments
|
## 6. Code Review Comments
|
||||||
- [ ] **Specific Linking**: Comments should be linked to specific code. Try to avoid general comments about the entire pull request.
|
|
||||||
|
### 6.1 Posting Comments
|
||||||
|
- [ ] **Code-Linked Comments**: Every review comment **must** be anchored to a specific file and line range using the Gitea inline comment API (`path` + `new_line_num`/`old_line_num`). Free-floating general comments are only acceptable for summary notes that cannot be attributed to a single location.
|
||||||
|
- [ ] **Severity Prefix**: Prefix each comment with its severity so the author can prioritize: `🔴 Blocking`, `🟡 Design/Architecture`, or `🟢 Minor/Suggestion`.
|
||||||
|
- [ ] **Actionable Guidance**: Each comment must include a concrete, actionable suggestion — not just a description of the problem. Where applicable, provide a corrected code snippet.
|
||||||
|
|
||||||
|
### 6.2 Resolving Comments (Author Responsibility)
|
||||||
|
- [ ] **Reply Before Resolving**: When a review comment has been addressed, the author **must** reply to the specific thread explaining *how* the issue was resolved (e.g., commit SHA, approach taken, or a reasoned rejection with justification). Do not close a thread without a reply.
|
||||||
|
- [ ] **Link to Fix**: If the resolution is a code change, include the commit SHA or a reference to the changed line in the reply (e.g., `Fixed in abc1234 — moved the guard before CTS allocation`).
|
||||||
|
- [ ] **Close Only After Reply**: Mark a thread as **Resolved** only after posting the reply. A thread with no reply must remain open, even if the underlying code has changed.
|
||||||
|
- [ ] **Rejection Must Be Justified**: If the author disagrees with a comment and chooses not to act on it, they must reply with a clear technical justification. The reviewer then decides whether to accept the reasoning and close the thread, or escalate it.
|
||||||
|
|||||||
@@ -12,14 +12,22 @@ description: Design System & Component rules for Blazor
|
|||||||
|
|
||||||
- **Styling & Isolation:**
|
- **Styling & Isolation:**
|
||||||
- Mandatory use of scoped CSS (`.razor.css`).
|
- Mandatory use of scoped CSS (`.razor.css`).
|
||||||
|
- Strict compliance: Zero inline `<style>` tags are allowed in `.razor` files.
|
||||||
- No global CSS except for design tokens in `app.css`.
|
- No global CSS except for design tokens in `app.css`.
|
||||||
- Use `::deep` only when absolutely necessary to style child components.
|
- Use `::deep` only when absolutely necessary to style child components.
|
||||||
|
|
||||||
- **Design System (Nexus Neon):**
|
- **Design System (Nexus Neon):**
|
||||||
- **Color Palette:**
|
- **Color Palette:**
|
||||||
- Primary Accent: `--nexus-neon` (`#00ff99`) - Used for borders, highlights, and icons.
|
- Primary Accent: `--nexus-neon` (`#00ff99`) - Used for borders, highlights, and icons.
|
||||||
|
- Neon Glow: `--nexus-neon-glow` / `--nexus-primary-glow` (`rgba(0, 255, 153, 0.3)`).
|
||||||
- Dark Mode: `--nexus-bg` (`#0a0a0a`), `--nexus-card` (`#141414`).
|
- Dark Mode: `--nexus-bg` (`#0a0a0a`), `--nexus-card` (`#141414`).
|
||||||
- Light Mode: `--nexus-bg` (`#f8f9fa`), `--nexus-card` (`#ffffff`).
|
- Light Mode: `--nexus-bg` (`#f8f9fa`), `--nexus-card` (`#ffffff`).
|
||||||
|
- **No Brand Splitting:** Strict ban on custom purple/indigo/cyan elements or hardcoded accent colors (like `#7c3aed`, `#4c1d95`, `#00b3ff`) on dashboard pages. Neutral/glass surfaces (`rgba(255, 255, 255, 0.05)`) must be used for secondary elements to preserve contrast and ensure the AI/neon-green elements are the focal point.
|
||||||
|
- **Buttons:**
|
||||||
|
- Must inherit from the global `.btn-nexus` class in `app.css`.
|
||||||
|
- Primary Button: `.btn-nexus-primary` (background: `var(--nexus-neon)`, text color: `#000`).
|
||||||
|
- Secondary Button: `.btn-nexus-secondary` (background: `rgba(255, 255, 255, 0.05)`, border: `1px solid rgba(255, 255, 255, 0.1)`, text color: `#fff`).
|
||||||
|
- Hover Interaction: `transform: translateY(-2px)`, increased brightness, and a signature primary neon glow shadow.
|
||||||
- **Typography:**
|
- **Typography:**
|
||||||
- UI Elements: `Inter` (Sans-Serif) for controls, menus, and labels.
|
- UI Elements: `Inter` (Sans-Serif) for controls, menus, and labels.
|
||||||
- Reading Content: `Merriweather` (Serif) with `line-height: 1.65` and `letter-spacing: -0.01em` for high readability.
|
- Reading Content: `Merriweather` (Serif) with `line-height: 1.65` and `letter-spacing: -0.01em` for high readability.
|
||||||
@@ -44,6 +52,9 @@ description: Design System & Component rules for Blazor
|
|||||||
- **Interactive Flow:**
|
- **Interactive Flow:**
|
||||||
- AI Assistant interactions must be non-blocking and smoothly transition using CSS animations.
|
- AI Assistant interactions must be non-blocking and smoothly transition using CSS animations.
|
||||||
- Interactive elements must have clear `:hover`, `:active`, and `:focus` states.
|
- Interactive elements must have clear `:hover`, `:active`, and `:focus` states.
|
||||||
- **Glass Panel Standard:** All primary data panels (`.glass-panel`) must implement the following interaction signature:
|
- **Glass Panel Standard:** All primary data panels (`.glass-panel`) must implement the following global parameters from `app.css` (only local modifiers like padding and hover offsets should live in scoped CSS):
|
||||||
- `transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)`
|
- Background: `rgba(20, 20, 20, 0.85)` (fallback) / `rgba(255, 255, 255, 0.03)` with `backdrop-filter: blur(10px)` when supported.
|
||||||
- `:hover` state must include: `transform: translateY(-4px)`, increased background opacity, and a subtle `--nexus-neon` border highlight (e.g., `rgba(0, 255, 153, 0.2)`).
|
- Border: `1px solid rgba(255, 255, 255, 0.05)`.
|
||||||
|
- Border Radius: `var(--radius-xl)`.
|
||||||
|
- Transition: `all 0.3s cubic-bezier(0.4, 0, 0.2, 1)`.
|
||||||
|
- `:hover` state: `transform: translateY(-4px)` (or local offset) and highlight accent `border-color: rgba(0, 255, 153, 0.2)`.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# ===================================================================
|
||||||
|
# NexusReader — Staging (Stage) Environment Variables
|
||||||
|
# ===================================================================
|
||||||
|
# Copy this file to `.env.stage` and fill in the values before deployment:
|
||||||
|
# cp .env.stage.template .env.stage
|
||||||
|
#
|
||||||
|
# Then deploy with:
|
||||||
|
# docker compose -f docker-compose.stage.yml --env-file .env.stage up -d --build
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
# === PostgreSQL ===
|
||||||
|
POSTGRES_USER=nexus_user_stage
|
||||||
|
POSTGRES_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
||||||
|
POSTGRES_DB=nexus_stage_db
|
||||||
|
POSTGRES_PORT=5438
|
||||||
|
|
||||||
|
# === Neo4j ===
|
||||||
|
NEO4J_USERNAME=neo4j
|
||||||
|
NEO4J_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
||||||
|
|
||||||
|
# === Qdrant (leave empty to disable API key auth in staging) ===
|
||||||
|
QDRANT_API_KEY=
|
||||||
|
|
||||||
|
# === Web App ===
|
||||||
|
WEB_PORT=5080
|
||||||
|
|
||||||
|
# === Google OAuth (Staging credentials) ===
|
||||||
|
GOOGLE_CLIENT_ID=placeholder_google_client_id_stage
|
||||||
|
GOOGLE_CLIENT_SECRET=placeholder_google_client_secret_stage
|
||||||
|
|
||||||
|
# === Gemini AI ===
|
||||||
|
GOOGLE_AI_API_KEY=placeholder_gemini_api_key_stage
|
||||||
|
|
||||||
|
# === Secure Admin Seed Password (MANDATORY in Staging environment) ===
|
||||||
|
# This password is used by DbInitializer during startup. Cannot be empty or 'Admin123!'.
|
||||||
|
NEXUS_ADMIN_PASSWORD=CHANGE_ME_TO_SECURE_ADMIN_PASSWORD
|
||||||
|
|
||||||
|
# === Non-standard ports for auxiliary services ===
|
||||||
|
QDRANT_HTTP_PORT=6383
|
||||||
|
QDRANT_GRPC_PORT=6384
|
||||||
|
NEO4J_HTTP_PORT=7488
|
||||||
|
NEO4J_BOLT_PORT=7688
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# ===================================================================
|
||||||
|
# NexusReader — Test Environment Variables
|
||||||
|
# ===================================================================
|
||||||
|
# Copy this file to `.env` and fill in the values before deployment:
|
||||||
|
# cp .env.test.template .env
|
||||||
|
#
|
||||||
|
# Then deploy with:
|
||||||
|
# docker compose -f docker-compose.test.yml up -d --build
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
# === PostgreSQL ===
|
||||||
|
POSTGRES_USER=nexus_user
|
||||||
|
POSTGRES_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
||||||
|
POSTGRES_DB=nexus_test_db
|
||||||
|
POSTGRES_PORT=5433
|
||||||
|
|
||||||
|
# === Neo4j ===
|
||||||
|
NEO4J_USERNAME=neo4j
|
||||||
|
NEO4J_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
||||||
|
|
||||||
|
# === Qdrant (leave empty to disable API key auth) ===
|
||||||
|
QDRANT_API_KEY=
|
||||||
|
|
||||||
|
# === Web App ===
|
||||||
|
WEB_PORT=5050
|
||||||
|
|
||||||
|
# === Google OAuth (placeholder for test) ===
|
||||||
|
GOOGLE_CLIENT_ID=placeholder
|
||||||
|
GOOGLE_CLIENT_SECRET=placeholder
|
||||||
|
|
||||||
|
# === Gemini AI (placeholder for test) ===
|
||||||
|
GOOGLE_AI_API_KEY=placeholder
|
||||||
|
|
||||||
|
# === Admin Seed Password ===
|
||||||
|
NEXUS_ADMIN_PASSWORD=CHANGE_ME
|
||||||
|
|
||||||
|
# === Non-standard ports for auxiliary services ===
|
||||||
|
QDRANT_HTTP_PORT=6343
|
||||||
|
QDRANT_GRPC_PORT=6344
|
||||||
|
NEO4J_HTTP_PORT=7484
|
||||||
|
NEO4J_BOLT_PORT=7697
|
||||||
@@ -29,6 +29,8 @@ Thumbs.db
|
|||||||
*.epub
|
*.epub
|
||||||
|
|
||||||
.fake
|
.fake
|
||||||
|
.env
|
||||||
|
.env.stage
|
||||||
src/NexusReader.Web/nexus.db
|
src/NexusReader.Web/nexus.db
|
||||||
src/NexusReader.Web/wwwroot/covers/
|
src/NexusReader.Web/wwwroot/covers/
|
||||||
src/NexusReader.Web/wwwroot/uploads/
|
src/NexusReader.Web/wwwroot/uploads/
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
||||||
|
<PackageVersion Include="HtmlSanitizer" Version="9.0.892" />
|
||||||
|
<PackageVersion Include="Markdig" Version="0.38.0" />
|
||||||
<PackageVersion Include="Mapster" Version="10.0.7" />
|
<PackageVersion Include="Mapster" Version="10.0.7" />
|
||||||
<PackageVersion Include="Mapster.DependencyInjection" Version="10.0.7" />
|
<PackageVersion Include="Mapster.DependencyInjection" Version="10.0.7" />
|
||||||
<PackageVersion Include="MediatR" Version="12.1.1" />
|
<PackageVersion Include="MediatR" Version="12.1.1" />
|
||||||
|
|||||||
@@ -2,12 +2,17 @@
|
|||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Copy props files and solution-level configurations for Central Package Management
|
||||||
|
COPY ["Directory.Build.props", "./"]
|
||||||
|
COPY ["Directory.Packages.props", "./"]
|
||||||
|
|
||||||
# Copy csproj files and restore dependencies
|
# Copy csproj files and restore dependencies
|
||||||
COPY ["src/NexusReader.Web/NexusReader.Web.csproj", "src/NexusReader.Web/"]
|
COPY ["src/NexusReader.Web/NexusReader.Web.csproj", "src/NexusReader.Web/"]
|
||||||
COPY ["src/NexusReader.Web.Client/NexusReader.Web.Client.csproj", "src/NexusReader.Web.Client/"]
|
COPY ["src/NexusReader.Web.Client/NexusReader.Web.Client.csproj", "src/NexusReader.Web.Client/"]
|
||||||
COPY ["src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj", "src/NexusReader.UI.Shared/"]
|
COPY ["src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj", "src/NexusReader.UI.Shared/"]
|
||||||
COPY ["src/NexusReader.Application/NexusReader.Application.csproj", "src/NexusReader.Application/"]
|
COPY ["src/NexusReader.Application/NexusReader.Application.csproj", "src/NexusReader.Application/"]
|
||||||
COPY ["src/NexusReader.Domain/NexusReader.Domain.csproj", "src/NexusReader.Domain/"]
|
COPY ["src/NexusReader.Domain/NexusReader.Domain.csproj", "src/NexusReader.Domain/"]
|
||||||
|
COPY ["src/NexusReader.Data/NexusReader.Data.csproj", "src/NexusReader.Data/"]
|
||||||
COPY ["src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj", "src/NexusReader.Infrastructure/"]
|
COPY ["src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj", "src/NexusReader.Infrastructure/"]
|
||||||
|
|
||||||
RUN dotnet restore "src/NexusReader.Web/NexusReader.Web.csproj"
|
RUN dotnet restore "src/NexusReader.Web/NexusReader.Web.csproj"
|
||||||
@@ -21,6 +26,7 @@ RUN dotnet publish "NexusReader.Web.csproj" -c Release -o /app/publish /p:UseApp
|
|||||||
|
|
||||||
# Stage 2: Runtime
|
# Stage 2: Runtime
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends libgssapi-krb5-2 && rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/publish .
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
|
|||||||
@@ -46,4 +46,9 @@ version: 1.0
|
|||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> **Git Workflow & Integration**
|
> **Git Workflow & Integration**
|
||||||
> All tasks originating from the repository must be performed on a separate branch. To connect to the Git repository, use the `gitea` MCP server.
|
> All tasks originating from the repository must be performed on a separate branch. Every new chat must be launched from the `develop` branch. To connect to the Git repository, use the `gitea` MCP server.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Docker Lifecycle Management**
|
||||||
|
> Before starting work, only the web (nexus) container needs to be stopped to prevent port/application conflicts (e.g., `./run-stage.sh --stop --nexus-only` or `-s -n`); database containers (PostgreSQL, Neo4j, Qdrant) should continue to run to support local development/debugging. After finishing work, a new version of the web container from the current branch should be rebuilt and restarted via `./run-stage.sh --nexus-only` (or `-n`).
|
||||||
|
|
||||||
|
|||||||
@@ -36,3 +36,13 @@ Run test suite:
|
|||||||
```bash
|
```bash
|
||||||
dotnet test --no-restore
|
dotnet test --no-restore
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 🗄️ Database Migrations
|
||||||
|
|
||||||
|
Automatic database migrations at startup (`MigrateAsync()`) have been disabled to ensure compatibility with Native AOT compilation and prevent locking issues in multi-instance environments.
|
||||||
|
|
||||||
|
To apply database migrations locally, run the EF Core migration command from the solution root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet ef database update --project src/NexusReader.Infrastructure --startup-project src/NexusReader.Web
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: pgvector/pgvector:pg17
|
||||||
|
container_name: nexus-db-stage
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-nexus_user_stage}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-nexus_stage_db}
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-5438}:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata_stage:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-nexus_user_stage} -d $${POSTGRES_DB:-nexus_stage_db}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- nexus-stage
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: nexus-web-stage
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-5080}:5000"
|
||||||
|
environment:
|
||||||
|
- ASPNETCORE_ENVIRONMENT=Staging
|
||||||
|
- ConnectionStrings__PostgresConnection=Host=db;Database=${POSTGRES_DB:-nexus_stage_db};Username=${POSTGRES_USER:-nexus_user_stage};Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
|
- ConnectionStrings__QdrantConnection=http://qdrant:6334
|
||||||
|
- Qdrant__ApiKey=${QDRANT_API_KEY:-}
|
||||||
|
- ConnectionStrings__Neo4jConnection=bolt://neo4j:7687
|
||||||
|
- Neo4j__Username=${NEO4J_USERNAME:-neo4j}
|
||||||
|
- Neo4j__Password=${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
|
||||||
|
- Authentication__Google__ClientId=${GOOGLE_CLIENT_ID:-placeholder_google_client_id_stage}
|
||||||
|
- Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET:-placeholder_google_client_secret_stage}
|
||||||
|
- Ai__Google__ApiKey=${GOOGLE_AI_API_KEY:-placeholder_gemini_api_key_stage}
|
||||||
|
- NEXUS_ADMIN_PASSWORD=${NEXUS_ADMIN_PASSWORD:?NEXUS_ADMIN_PASSWORD is required}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
qdrant:
|
||||||
|
condition: service_healthy
|
||||||
|
neo4j:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- stage_www_uploads:/app/wwwroot/uploads
|
||||||
|
- stage_www_covers:/app/wwwroot/covers
|
||||||
|
networks:
|
||||||
|
- nexus-stage
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
qdrant:
|
||||||
|
image: qdrant/qdrant:latest
|
||||||
|
container_name: nexus-qdrant-stage
|
||||||
|
environment:
|
||||||
|
- QDRANT__SERVICE__API_KEY=${QDRANT_API_KEY:-}
|
||||||
|
ports:
|
||||||
|
- "${QDRANT_HTTP_PORT:-6383}:6333"
|
||||||
|
- "${QDRANT_GRPC_PORT:-6384}:6334"
|
||||||
|
volumes:
|
||||||
|
- qdrant_stage_data:/qdrant/storage
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/6333'"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- nexus-stage
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
neo4j:
|
||||||
|
image: neo4j:5-community
|
||||||
|
container_name: nexus-neo4j-stage
|
||||||
|
environment:
|
||||||
|
- NEO4J_AUTH=${NEO4J_USERNAME:-neo4j}/${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
|
||||||
|
ports:
|
||||||
|
- "${NEO4J_HTTP_PORT:-7488}:7474"
|
||||||
|
- "${NEO4J_BOLT_PORT:-7688}:7687"
|
||||||
|
volumes:
|
||||||
|
- neo4j_stage_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:7474 || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- nexus-stage
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata_stage:
|
||||||
|
qdrant_stage_data:
|
||||||
|
neo4j_stage_data:
|
||||||
|
stage_www_uploads:
|
||||||
|
stage_www_covers:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
nexus-stage:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: pgvector/pgvector:pg17
|
||||||
|
container_name: nexus-db-test
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-nexus_user}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-nexus_test_db}
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-5433}:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata_test:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-nexus_user} -d ${POSTGRES_DB:-nexus_test_db}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- nexus-test
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: nexus-web-test
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-5050}:5000"
|
||||||
|
environment:
|
||||||
|
- ASPNETCORE_ENVIRONMENT=Test
|
||||||
|
- ConnectionStrings__PostgresConnection=Host=db;Database=${POSTGRES_DB:-nexus_test_db};Username=${POSTGRES_USER:-nexus_user};Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
|
- ConnectionStrings__QdrantConnection=http://qdrant:6334
|
||||||
|
- ConnectionStrings__Neo4jConnection=bolt://neo4j:7687
|
||||||
|
- Neo4j__Username=${NEO4J_USERNAME:-neo4j}
|
||||||
|
- Neo4j__Password=${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
|
||||||
|
- Authentication__Google__ClientId=${GOOGLE_CLIENT_ID:-placeholder}
|
||||||
|
- Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET:-placeholder}
|
||||||
|
- Ai__Google__ApiKey=${GOOGLE_AI_API_KEY:-placeholder}
|
||||||
|
- NEXUS_ADMIN_PASSWORD=${NEXUS_ADMIN_PASSWORD:?NEXUS_ADMIN_PASSWORD is required}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
qdrant:
|
||||||
|
condition: service_healthy
|
||||||
|
neo4j:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- nexus-test
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
qdrant:
|
||||||
|
image: qdrant/qdrant:latest
|
||||||
|
container_name: nexus-qdrant-test
|
||||||
|
environment:
|
||||||
|
- QDRANT__SERVICE__API_KEY=${QDRANT_API_KEY:-}
|
||||||
|
ports:
|
||||||
|
- "${QDRANT_HTTP_PORT:-6343}:6333"
|
||||||
|
- "${QDRANT_GRPC_PORT:-6344}:6334"
|
||||||
|
volumes:
|
||||||
|
- qdrant_test_data:/qdrant/storage
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/6333'"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- nexus-test
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
neo4j:
|
||||||
|
image: neo4j:5-community
|
||||||
|
container_name: nexus-neo4j-test
|
||||||
|
environment:
|
||||||
|
- NEO4J_AUTH=${NEO4J_USERNAME:-neo4j}/${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
|
||||||
|
ports:
|
||||||
|
- "${NEO4J_HTTP_PORT:-7484}:7474"
|
||||||
|
- "${NEO4J_BOLT_PORT:-7697}:7687"
|
||||||
|
volumes:
|
||||||
|
- neo4j_test_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:7474 || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- nexus-test
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata_test:
|
||||||
|
qdrant_test_data:
|
||||||
|
neo4j_test_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
nexus-test:
|
||||||
|
driver: bridge
|
||||||
Executable
+154
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
# Staging Deploy & Orchestration Helper for NexusReader
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
set -e
|
||||||
|
|
||||||
|
NEXUS_ONLY=false
|
||||||
|
STOP=false
|
||||||
|
for arg in "$@"; do
|
||||||
|
case $arg in
|
||||||
|
--nexus-only|-n)
|
||||||
|
NEXUS_ONLY=true
|
||||||
|
;;
|
||||||
|
--stop|-s)
|
||||||
|
STOP=true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
ENV_FILE=".env.stage"
|
||||||
|
TEMPLATE_FILE=".env.stage.template"
|
||||||
|
COMPOSE_FILE="docker-compose.stage.yml"
|
||||||
|
|
||||||
|
if [ "$STOP" = true ]; then
|
||||||
|
echo "🛑 Stopping staging environment..."
|
||||||
|
if [ ! -f "$ENV_FILE" ] && [ -f "$TEMPLATE_FILE" ]; then
|
||||||
|
cp "$TEMPLATE_FILE" "$ENV_FILE"
|
||||||
|
fi
|
||||||
|
if [ "$NEXUS_ONLY" = true ]; then
|
||||||
|
echo "🧹 Stopping and removing only the web (nexus) container..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" stop web || true
|
||||||
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" rm -f web || true
|
||||||
|
else
|
||||||
|
echo "🧹 Stopping all containers..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" down --remove-orphans || true
|
||||||
|
docker compose down --remove-orphans 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
echo "✅ Staging environment stopped."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🏁 Starting staging environment orchestration..."
|
||||||
|
if [ "$NEXUS_ONLY" = true ]; then
|
||||||
|
echo "ℹ️ Mode: --nexus-only (only the web/nexus application container will be modified)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. Create .env.stage if it doesn't exist
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
if [ -f "$TEMPLATE_FILE" ]; then
|
||||||
|
echo "📄 Creating $ENV_FILE from $TEMPLATE_FILE..."
|
||||||
|
cp "$TEMPLATE_FILE" "$ENV_FILE"
|
||||||
|
else
|
||||||
|
echo "❌ Error: Template file $TEMPLATE_FILE not found."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Check and generate secure random passwords for placeholders
|
||||||
|
if grep -q "CHANGE_ME_TO_STRONG_PASSWORD" "$ENV_FILE"; then
|
||||||
|
echo "🔐 Generating secure random passwords in $ENV_FILE..."
|
||||||
|
PG_PASS=$(openssl rand -hex 16)
|
||||||
|
NEO_PASS=$(openssl rand -hex 16)
|
||||||
|
# Use standard sed compatible with Linux
|
||||||
|
sed -i "s/POSTGRES_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD/POSTGRES_PASSWORD=$PG_PASS/g" "$ENV_FILE"
|
||||||
|
sed -i "s/NEO4J_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD/NEO4J_PASSWORD=$NEO_PASS/g" "$ENV_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -q "CHANGE_ME_TO_SECURE_ADMIN_PASSWORD" "$ENV_FILE"; then
|
||||||
|
echo "🔐 Generating secure admin seed password in $ENV_FILE..."
|
||||||
|
ADMIN_PASS=$(openssl rand -hex 16)
|
||||||
|
sed -i "s/NEXUS_ADMIN_PASSWORD=CHANGE_ME_TO_SECURE_ADMIN_PASSWORD/NEXUS_ADMIN_PASSWORD=$ADMIN_PASS/g" "$ENV_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -q "^QDRANT_API_KEY=$" "$ENV_FILE" || grep -q "^QDRANT_API_KEY=[[:space:]]*$" "$ENV_FILE"; then
|
||||||
|
echo "🔐 Generating secure random Qdrant API key in $ENV_FILE..."
|
||||||
|
QD_KEY=$(openssl rand -hex 16)
|
||||||
|
sed -i "s/^QDRANT_API_KEY=.*/QDRANT_API_KEY=$QD_KEY/g" "$ENV_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Load staging variables for local execution context (needed for ports/migrations)
|
||||||
|
# Clean up carriage returns just in case
|
||||||
|
POSTGRES_USER=$(grep "^POSTGRES_USER=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
||||||
|
POSTGRES_PASSWORD=$(grep "^POSTGRES_PASSWORD=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
||||||
|
POSTGRES_DB=$(grep "^POSTGRES_DB=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
||||||
|
POSTGRES_PORT=$(grep "^POSTGRES_PORT=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
||||||
|
WEB_PORT=$(grep "^WEB_PORT=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
||||||
|
QDRANT_HTTP_PORT=$(grep "^QDRANT_HTTP_PORT=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
||||||
|
NEO4J_HTTP_PORT=$(grep "^NEO4J_HTTP_PORT=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
||||||
|
|
||||||
|
# Fallbacks in case env parsing is empty
|
||||||
|
POSTGRES_PORT=${POSTGRES_PORT:-5438}
|
||||||
|
WEB_PORT=${WEB_PORT:-5080}
|
||||||
|
|
||||||
|
# 3. Stop any conflicting Docker Compose environments
|
||||||
|
if [ "$NEXUS_ONLY" = true ]; then
|
||||||
|
echo "🧹 Stopping and removing only the web (nexus) container..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" stop web || true
|
||||||
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" rm -f web || true
|
||||||
|
else
|
||||||
|
echo "🧹 Stopping existing containers..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" down --remove-orphans || true
|
||||||
|
docker compose down --remove-orphans 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Build and start containers
|
||||||
|
if [ "$NEXUS_ONLY" = true ]; then
|
||||||
|
echo "🚀 Building and restarting only the web (nexus) container..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --build web
|
||||||
|
else
|
||||||
|
echo "🚀 Building and starting staging containers..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --build
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Wait for Database to be healthy
|
||||||
|
echo "⏳ Waiting for database (nexus-db-stage) to become healthy..."
|
||||||
|
MAX_ATTEMPTS=30
|
||||||
|
attempt=0
|
||||||
|
until [ "$(docker inspect --format='{{json .State.Health.Status}}' nexus-db-stage 2>/dev/null)" == "\"healthy\"" ]; do
|
||||||
|
sleep 2
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
if [ $attempt -ge $MAX_ATTEMPTS ]; then
|
||||||
|
echo "❌ Timeout: Database container never became healthy."
|
||||||
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" logs db
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "✅ Database is healthy!"
|
||||||
|
|
||||||
|
# 6. Apply Entity Framework migrations
|
||||||
|
echo "🔄 Applying EF Core migrations to staging database on port $POSTGRES_PORT..."
|
||||||
|
export ConnectionStrings__PostgresConnection="Host=127.0.0.1;Port=$POSTGRES_PORT;Database=$POSTGRES_DB;Username=$POSTGRES_USER;Password=$POSTGRES_PASSWORD"
|
||||||
|
dotnet ef database update --project src/NexusReader.Data --startup-project src/NexusReader.Web --no-build
|
||||||
|
|
||||||
|
# 7. Wait for Web Application to respond
|
||||||
|
echo "⏳ Waiting for Web Application to start on http://localhost:$WEB_PORT/health..."
|
||||||
|
MAX_WEB_ATTEMPTS=30
|
||||||
|
web_attempt=0
|
||||||
|
until curl -s -f "http://localhost:$WEB_PORT/health" >/dev/null; do
|
||||||
|
sleep 2
|
||||||
|
web_attempt=$((web_attempt + 1))
|
||||||
|
if [ $web_attempt -ge $MAX_WEB_ATTEMPTS ]; then
|
||||||
|
echo "⚠️ Warning: Web app is not responding yet on http://localhost:$WEB_PORT/health, but let's check logs..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" logs web
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "🎉 Staging environment is ready!"
|
||||||
|
echo "--------------------------------------------------------"
|
||||||
|
echo "🌐 Web Application: http://localhost:$WEB_PORT"
|
||||||
|
echo "🗄️ PostgreSQL Port: $POSTGRES_PORT"
|
||||||
|
echo "🔎 Neo4j Console: http://localhost:$NEO4J_HTTP_PORT"
|
||||||
|
echo "📊 Qdrant Service: http://localhost:$QDRANT_HTTP_PORT"
|
||||||
|
echo "--------------------------------------------------------"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using NexusReader.Domain.Entities;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only abstraction for fetching concepts map data.
|
||||||
|
/// Defined in the Application layer to avoid a direct dependency on EF Core / NexusReader.Data.
|
||||||
|
/// </summary>
|
||||||
|
public interface IConceptsMapReadRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the last read page ID for the specified user.
|
||||||
|
/// </summary>
|
||||||
|
Task<string?> GetLastReadPageIdAsync(string userId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all knowledge units associated with a book, scoped by tenant.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<KnowledgeUnit>> GetKnowledgeUnitsForBookAsync(
|
||||||
|
Guid bookId,
|
||||||
|
string tenantId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -23,6 +23,11 @@ public interface IEbookRepository
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
void AddEbook(Ebook ebook);
|
void AddEbook(Ebook ebook);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds an ebook by its unique identifier.
|
||||||
|
/// </summary>
|
||||||
|
Task<Ebook?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Persists all staged changes to the underlying store.
|
/// Persists all staged changes to the underlying store.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using NexusReader.Domain.Entities;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abstraction for QuizResult and related User entity lookup.
|
||||||
|
/// Defined in the Application layer to maintain Clean Architecture isolation.
|
||||||
|
/// </summary>
|
||||||
|
public interface IQuizResultRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Finds a user by ID to extract tenant context.
|
||||||
|
/// </summary>
|
||||||
|
Task<NexusUser?> FindUserByIdAsync(string userId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a new quiz result to the database.
|
||||||
|
/// </summary>
|
||||||
|
void AddQuizResult(QuizResult quizResult);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists all staged changes to the repository.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides access to user library ownership details, decoupling the relational database
|
||||||
|
/// structures from vector search and intelligence query operations.
|
||||||
|
/// </summary>
|
||||||
|
public interface IUserLibraryStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a list of book IDs that are owned by or uploaded for the specified user.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<Guid>> GetOwnedBookIdsAsync(string userId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a dictionary mapping book IDs to their titles.
|
||||||
|
/// </summary>
|
||||||
|
Task<Dictionary<Guid, string>> GetBookTitlesAsync(List<Guid> bookIds, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decoupled database store to retrieve active user reading states and chapter content.
|
||||||
|
/// </summary>
|
||||||
|
public interface IUserReadingStateStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the user's active reading state: last read ebook ID, last opened chapter/page ID, and tenant ID.
|
||||||
|
/// </summary>
|
||||||
|
Task<(Guid? EbookId, string? ChapterId, string? TenantId)> GetActiveReadingStateAsync(string userId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the text content of a specific chapter/page by its ID.
|
||||||
|
/// </summary>
|
||||||
|
Task<string?> GetChapterContentAsync(string chapterId, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a chunk of text retrieved from the semantic vector database.
|
||||||
|
/// </summary>
|
||||||
|
public record VectorChunk(string Content, string EbookId, double Score, string MetadataJson = "", string BookTitle = "", string ChapterTitle = "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abstraction for performing semantic vector searches, isolating Qdrant gRPC dependencies from the Application layer.
|
||||||
|
/// </summary>
|
||||||
|
public interface IVectorSearchStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Searches the entire global catalog (filtered by tenant) for the best semantic matches.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<VectorChunk>> SearchGlobalAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches within a whitelist of owned book IDs for the best semantic matches.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<VectorChunk>> SearchLocalAsync(string queryText, string tenantId, List<Guid> whitelistedBookIds, int limit, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches the entire global catalog (filtered by tenant) for the best semantic matches, excluding a specific book ID.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<VectorChunk>> SearchGlobalExcludeAsync(string queryText, string tenantId, Guid excludeBookId, int limit, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using NexusReader.Application.Queries.Concepts;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
|
public interface IConceptsMapService
|
||||||
|
{
|
||||||
|
Task<Result<BookConceptsMapResultDto>> GetConceptsMapAsync(Guid bookId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using FluentResults;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service abstraction to extract raw text content from EPUB chapters.
|
||||||
|
/// </summary>
|
||||||
|
public interface IEpubExtractor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the sanitized, plain-text content of each chapter in the EPUB file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="relativePath">The relative storage path of the EPUB file.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A list of plain-text chapters, or a failure result.</returns>
|
||||||
|
Task<Result<List<string>>> ExtractChaptersTextAsync(string relativePath, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -20,4 +20,17 @@ public interface IEpubReader
|
|||||||
int chapterIndex,
|
int chapterIndex,
|
||||||
string? userId = null,
|
string? userId = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a resource (like an image) from the EPUB as a byte array.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ebookId">The unique ID of the ebook to read.</param>
|
||||||
|
/// <param name="resourcePath">The path of the resource within the EPUB archive.</param>
|
||||||
|
/// <param name="userId">The authenticated user's ID (used for tenant isolation).</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
Task<Result<byte[]>> GetEpubResourceAsync(
|
||||||
|
Guid ebookId,
|
||||||
|
string resourcePath,
|
||||||
|
string? userId = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ public interface IIdentityService
|
|||||||
Task<Result> LogoutAsync();
|
Task<Result> LogoutAsync();
|
||||||
Task<Result<UserProfileDto>> GetProfileAsync();
|
Task<Result<UserProfileDto>> GetProfileAsync();
|
||||||
Task<Result> RefreshTokenAsync();
|
Task<Result> RefreshTokenAsync();
|
||||||
|
void ClearCache();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using FluentResults;
|
using FluentResults;
|
||||||
using NexusReader.Application.DTOs.AI;
|
using NexusReader.Application.DTOs.AI;
|
||||||
|
using NexusReader.Application.Queries.Intelligence;
|
||||||
|
|
||||||
namespace NexusReader.Application.Abstractions.Services;
|
namespace NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ public interface IKnowledgeService
|
|||||||
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<List<SemanticSearchResultDto>>> SearchLibrarySemanticallyAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default);
|
Task<Result<List<SemanticSearchResultDto>>> SearchLibrarySemanticallyAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default);
|
||||||
Task<Result<GroundedResponseDto>> AskQuestionAsync(string question, string tenantId, Guid? ebookId = null, int limit = 5, CancellationToken cancellationToken = default);
|
Task<Result<GroundedResponseDto>> AskQuestionAsync(string question, string tenantId, Guid? ebookId = null, int limit = 5, CancellationToken cancellationToken = default);
|
||||||
|
Task<Result<IntelligenceResponse>> GetGlobalIntelligenceAsync(string queryText, string userId, string tenantId, CancellationToken cancellationToken = default);
|
||||||
Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default);
|
Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for sanitizing raw input text (e.g. Markdown/HTML) to protect against XSS injection.
|
||||||
|
/// Intended to have a Singleton lifetime.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISanitizerService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sanitizes the input string and returns a clean, safe version.
|
||||||
|
/// </summary>
|
||||||
|
string Sanitize(string input);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// General file storage service interface for handling media uploads.
|
||||||
|
/// Intended to have a Scoped lifetime.
|
||||||
|
/// </summary>
|
||||||
|
public interface IStorageService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Uploads a file stream and returns its public URL/path.
|
||||||
|
/// </summary>
|
||||||
|
Task<string> UploadFileAsync(Stream fileStream, string fileName, string contentType);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uploads file bytes and returns its public URL/path.
|
||||||
|
/// </summary>
|
||||||
|
Task<string> UploadFileAsync(byte[] fileBytes, string fileName, string contentType);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using NexusReader.Domain.Enums;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
|
public interface IUserPreferenceStore
|
||||||
|
{
|
||||||
|
Task<Result> SaveThemePreferenceAsync(ThemeMode mode);
|
||||||
|
Task<Result<ThemeMode>> GetThemePreferenceAsync();
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
using FluentResults;
|
using FluentResults;
|
||||||
|
using System.Linq;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using NexusReader.Application.Abstractions.Messaging;
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
using NexusReader.Application.Abstractions.Persistence;
|
using NexusReader.Application.Abstractions.Persistence;
|
||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
@@ -11,13 +14,16 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
|
|||||||
{
|
{
|
||||||
private readonly IEbookRepository _ebookRepository;
|
private readonly IEbookRepository _ebookRepository;
|
||||||
private readonly IBookStorageService _storageService;
|
private readonly IBookStorageService _storageService;
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
|
||||||
public IngestEbookCommandHandler(
|
public IngestEbookCommandHandler(
|
||||||
IEbookRepository ebookRepository,
|
IEbookRepository ebookRepository,
|
||||||
IBookStorageService storageService)
|
IBookStorageService storageService,
|
||||||
|
IServiceScopeFactory scopeFactory)
|
||||||
{
|
{
|
||||||
_ebookRepository = ebookRepository;
|
_ebookRepository = ebookRepository;
|
||||||
_storageService = storageService;
|
_storageService = storageService;
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<Guid>> Handle(IngestEbookCommand request, CancellationToken cancellationToken)
|
public async Task<Result<Guid>> Handle(IngestEbookCommand request, CancellationToken cancellationToken)
|
||||||
@@ -72,6 +78,43 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
|
|||||||
_ebookRepository.AddEbook(ebook);
|
_ebookRepository.AddEbook(ebook);
|
||||||
await _ebookRepository.SaveChangesAsync(cancellationToken);
|
await _ebookRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// 4. Trigger asynchronous background processing and vector indexing
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<ILogger<IngestEbookCommandHandler>>();
|
||||||
|
var broadcaster = scope.ServiceProvider.GetRequiredService<ISyncBroadcaster>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||||
|
var result = await mediator.Send(new ProcessEbookCommand(ebook.Id, request.UserId, request.TenantId));
|
||||||
|
if (result.IsFailed)
|
||||||
|
{
|
||||||
|
var errorMsg = string.Join("; ", result.Errors.Select(e => e.Message));
|
||||||
|
logger.LogError("[IngestEbook] Background ebook processing failed for Ebook {EbookId}: {Error}", ebook.Id, errorMsg);
|
||||||
|
await broadcaster.BroadcastIngestionProgressAsync(
|
||||||
|
request.UserId,
|
||||||
|
$"Błąd indeksowania: {errorMsg}",
|
||||||
|
1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "[IngestEbook] Exception during background ebook processing for Ebook {EbookId}", ebook.Id);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await broadcaster.BroadcastIngestionProgressAsync(
|
||||||
|
request.UserId,
|
||||||
|
$"Błąd krytyczny podczas przetwarzania e-booka: {ex.Message}",
|
||||||
|
1.0);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore broadcast failures to prevent crashes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return Result.Ok(ebook.Id);
|
return Result.Ok(ebook.Id);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using FluentResults;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
using NexusReader.Application.Abstractions.Persistence;
|
||||||
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Commands.Library;
|
||||||
|
|
||||||
|
public record ProcessEbookCommand(
|
||||||
|
Guid EbookId,
|
||||||
|
string UserId,
|
||||||
|
string TenantId
|
||||||
|
) : ICommand<bool>;
|
||||||
|
|
||||||
|
public class ProcessEbookCommandHandler : IRequestHandler<ProcessEbookCommand, Result<bool>>
|
||||||
|
{
|
||||||
|
private readonly IEbookRepository _ebookRepository;
|
||||||
|
private readonly IKnowledgeService _knowledgeService;
|
||||||
|
private readonly IEpubExtractor _epubExtractor;
|
||||||
|
private readonly ISyncBroadcaster _broadcaster;
|
||||||
|
private readonly ILogger<ProcessEbookCommandHandler> _logger;
|
||||||
|
|
||||||
|
public ProcessEbookCommandHandler(
|
||||||
|
IEbookRepository ebookRepository,
|
||||||
|
IKnowledgeService knowledgeService,
|
||||||
|
IEpubExtractor epubExtractor,
|
||||||
|
ISyncBroadcaster broadcaster,
|
||||||
|
ILogger<ProcessEbookCommandHandler> logger)
|
||||||
|
{
|
||||||
|
_ebookRepository = ebookRepository;
|
||||||
|
_knowledgeService = knowledgeService;
|
||||||
|
_epubExtractor = epubExtractor;
|
||||||
|
_broadcaster = broadcaster;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<bool>> Handle(ProcessEbookCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[ProcessEbook] Starting background processing for Ebook: {EbookId}", request.EbookId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, "Wyszukiwanie e-booka w bazie danych...", 0.05, cancellationToken);
|
||||||
|
|
||||||
|
var ebook = await _ebookRepository.FindByIdAsync(request.EbookId, cancellationToken);
|
||||||
|
if (ebook == null)
|
||||||
|
{
|
||||||
|
_logger.LogError("[ProcessEbook] Ebook not found in database: {EbookId}", request.EbookId);
|
||||||
|
return Result.Fail<bool>($"Ebook nie znaleziony w bazie danych: {request.EbookId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("[ProcessEbook] Extracting chapters text for Ebook: {Title} ({FilePath})", ebook.Title, ebook.FilePath);
|
||||||
|
await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, "Otwieranie i parsowanie pliku EPUB...", 0.1, cancellationToken);
|
||||||
|
|
||||||
|
var extractionResult = await _epubExtractor.ExtractChaptersTextAsync(ebook.FilePath, cancellationToken);
|
||||||
|
if (extractionResult.IsFailed)
|
||||||
|
{
|
||||||
|
var errorMsg = extractionResult.Errors.FirstOrDefault()?.Message ?? "Failed to extract text chapters.";
|
||||||
|
_logger.LogError("[ProcessEbook] Extraction failed: {Error}", errorMsg);
|
||||||
|
return Result.Fail<bool>(extractionResult.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
var chapters = extractionResult.Value;
|
||||||
|
if (chapters == null || !chapters.Any())
|
||||||
|
{
|
||||||
|
_logger.LogWarning("[ProcessEbook] EPUB has no readable content files: {EbookId}", request.EbookId);
|
||||||
|
return Result.Fail<bool>("EPUB nie zawiera czytelnych rozdziałów.");
|
||||||
|
}
|
||||||
|
|
||||||
|
int totalChapters = chapters.Count;
|
||||||
|
_logger.LogInformation("[ProcessEbook] Processing {Count} chapters for Ebook: {Title}", totalChapters, ebook.Title);
|
||||||
|
|
||||||
|
await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, $"Analizowanie struktury ({totalChapters} rozdziałów)...", 0.15, cancellationToken);
|
||||||
|
|
||||||
|
int processedChapters = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < totalChapters; i++)
|
||||||
|
{
|
||||||
|
var cleanText = chapters[i];
|
||||||
|
|
||||||
|
if (cleanText.Length < 100)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[ProcessEbook] Skipping chapter {Index} (text too short: {Length} chars)", i, cleanText.Length);
|
||||||
|
processedChapters++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunk the text to maintain granular Knowledge Units
|
||||||
|
var chunks = ChunkText(cleanText, 3000);
|
||||||
|
_logger.LogInformation("[ProcessEbook] Chapter {Index} split into {ChunkCount} chunk(s)", i, chunks.Count);
|
||||||
|
|
||||||
|
foreach (var chunk in chunks)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Invoke GetKnowledgeMapAsync to extract, embed, and upsert knowledge units
|
||||||
|
var result = await _knowledgeService.GetKnowledgeMapAsync(chunk, request.TenantId, request.EbookId, cancellationToken);
|
||||||
|
if (result.IsFailed)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("[ProcessEbook] Failed to generate knowledge map for a chunk of chapter {Index}: {Error}", i, result.Errors.FirstOrDefault()?.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[ProcessEbook] Exception during AI vectorization of chapter {Index} chunk", i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processedChapters++;
|
||||||
|
double progress = 0.15 + (0.75 * processedChapters / totalChapters);
|
||||||
|
await _broadcaster.BroadcastIngestionProgressAsync(
|
||||||
|
request.UserId,
|
||||||
|
$"Przetwarzanie rozdziału {processedChapters} z {totalChapters} przez AI...",
|
||||||
|
progress,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the ebook as ready
|
||||||
|
ebook.IsReadyForReading = true;
|
||||||
|
await _ebookRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation("[ProcessEbook] Ingestion and vector indexing completed for: {Title}", ebook.Title);
|
||||||
|
|
||||||
|
await _broadcaster.BroadcastIngestionProgressAsync(
|
||||||
|
request.UserId,
|
||||||
|
"Indeksowanie wektorowe e-booka przez Nexus AI zakończone pomyślnie!",
|
||||||
|
1.0,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[ProcessEbook] Critical error during background EPUB vectorization of ebook {EbookId}", request.EbookId);
|
||||||
|
await _broadcaster.BroadcastIngestionProgressAsync(
|
||||||
|
request.UserId,
|
||||||
|
$"Błąd indeksowania: {ex.Message}",
|
||||||
|
1.0,
|
||||||
|
cancellationToken);
|
||||||
|
return Result.Fail<bool>(new Error("Wystąpił błąd podczas indeksowania e-booka przez AI").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> ChunkText(string text, int maxWords = 3000)
|
||||||
|
{
|
||||||
|
var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var chunks = new List<string>();
|
||||||
|
if (words.Length <= maxWords)
|
||||||
|
{
|
||||||
|
chunks.Add(text);
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
var currentChunk = new List<string>();
|
||||||
|
int count = 0;
|
||||||
|
foreach (var word in words)
|
||||||
|
{
|
||||||
|
currentChunk.Add(word);
|
||||||
|
count++;
|
||||||
|
if (count >= maxWords)
|
||||||
|
{
|
||||||
|
chunks.Add(string.Join(" ", currentChunk));
|
||||||
|
currentChunk.Clear();
|
||||||
|
count = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentChunk.Any())
|
||||||
|
{
|
||||||
|
chunks.Add(string.Join(" ", currentChunk));
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Commands.Quiz;
|
||||||
|
|
||||||
|
public record SubmitQuizResultCommand(
|
||||||
|
string UserId,
|
||||||
|
string Topic,
|
||||||
|
int Score,
|
||||||
|
int TotalQuestions) : ICommand;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
using NexusReader.Application.Abstractions.Persistence;
|
||||||
|
using NexusReader.Domain.Entities;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Commands.Quiz;
|
||||||
|
|
||||||
|
public sealed class SubmitQuizResultCommandHandler : ICommandHandler<SubmitQuizResultCommand>
|
||||||
|
{
|
||||||
|
private readonly IQuizResultRepository _quizResultRepository;
|
||||||
|
|
||||||
|
public SubmitQuizResultCommandHandler(IQuizResultRepository quizResultRepository)
|
||||||
|
{
|
||||||
|
_quizResultRepository = quizResultRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> Handle(SubmitQuizResultCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var user = await _quizResultRepository.FindUserByIdAsync(request.UserId, cancellationToken);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return Result.Fail("User not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var quizResult = new QuizResult
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = request.UserId,
|
||||||
|
TenantId = string.IsNullOrEmpty(user.TenantId) ? "global" : user.TenantId,
|
||||||
|
Topic = request.Topic,
|
||||||
|
Score = request.Score,
|
||||||
|
TotalQuestions = request.TotalQuestions,
|
||||||
|
CompletedDate = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
_quizResultRepository.AddQuizResult(quizResult);
|
||||||
|
await _quizResultRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
using NexusReader.Domain.Enums;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Commands.User;
|
||||||
|
|
||||||
|
public record UpdateThemeCommand(string UserId, ThemeMode Mode) : ICommand;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
using NexusReader.Data.Persistence;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Commands.User;
|
||||||
|
|
||||||
|
public class UpdateThemeCommandHandler : ICommandHandler<UpdateThemeCommand>
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||||
|
|
||||||
|
public UpdateThemeCommandHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||||
|
{
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> Handle(UpdateThemeCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
var user = await dbContext.Users
|
||||||
|
.AsTracking()
|
||||||
|
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return Result.Fail("User not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
user.ThemePreference = request.Mode;
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error("Failed to save theme preference in database.").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Collections.Generic;
|
||||||
using NexusReader.Application.Queries.Graph;
|
using NexusReader.Application.Queries.Graph;
|
||||||
|
using NexusReader.Application.Queries.Intelligence;
|
||||||
|
|
||||||
namespace NexusReader.Application.Common;
|
namespace NexusReader.Application.Common;
|
||||||
|
|
||||||
@@ -9,6 +11,28 @@ namespace NexusReader.Application.Common;
|
|||||||
[JsonSerializable(typeof(GraphDataDto))]
|
[JsonSerializable(typeof(GraphDataDto))]
|
||||||
[JsonSerializable(typeof(List<GraphNodeDto>))]
|
[JsonSerializable(typeof(List<GraphNodeDto>))]
|
||||||
[JsonSerializable(typeof(List<GraphLinkDto>))]
|
[JsonSerializable(typeof(List<GraphLinkDto>))]
|
||||||
|
[JsonSerializable(typeof(GetGlobalIntelligenceRequest))]
|
||||||
|
[JsonSerializable(typeof(IntelligenceResponse))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.ContextualRecommendationResponse))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.RecommendationDto))]
|
||||||
|
[JsonSerializable(typeof(List<NexusReader.Application.Queries.Recommendations.RecommendationDto>))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.User.UpdateThemeRequest))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Domain.Enums.ThemeMode))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.ValidateChapterRequest))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.ValidateChapterResponse))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.UploadResultDto))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.LocalBackupEnvelope))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.AutosaveChapterRequest))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.Features.Books.Commands.PublishBookVersionCommand))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.Features.Books.Commands.CreateBookCommand))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreateBookRequestDto))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreateBookResponseDto))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreatorDashboardDataDto))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.DashboardMetricsDto))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreatorBookDto))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreatorBookRevisionDto))]
|
||||||
|
[JsonSerializable(typeof(List<NexusReader.Application.DTOs.Creator.CreatorBookDto>))]
|
||||||
|
[JsonSerializable(typeof(List<NexusReader.Application.DTOs.Creator.CreatorBookRevisionDto>))]
|
||||||
public partial class AppJsonContext : JsonSerializerContext
|
public partial class AppJsonContext : JsonSerializerContext
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace NexusReader.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configurations for the monetization engine, controlling the thresholds at which
|
||||||
|
/// search queries trigger paywalls.
|
||||||
|
/// </summary>
|
||||||
|
public class RagMonetizationOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "RagMonetization";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The baseline score threshold above which global content might trigger a paywall if there is no local content.
|
||||||
|
/// Default: 0.45.
|
||||||
|
/// </summary>
|
||||||
|
public double BaselineThreshold { get; set; } = 0.45;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The similarity gap (Delta) required between global and local content to trigger an upgrade paywall.
|
||||||
|
/// Default: 0.15.
|
||||||
|
/// </summary>
|
||||||
|
public double DeltaThreshold { get; set; } = 0.15;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The absolute score required from global content to trigger an upgrade paywall.
|
||||||
|
/// Default: 0.70.
|
||||||
|
/// </summary>
|
||||||
|
public double UpgradeThreshold { get; set; } = 0.70;
|
||||||
|
}
|
||||||
@@ -13,4 +13,6 @@ public class CitationDto
|
|||||||
public string CitationId { get; set; } = string.Empty; // e.g., chunk hash/ID
|
public string CitationId { get; set; } = string.Empty; // e.g., chunk hash/ID
|
||||||
public string Snippet { get; set; } = string.Empty; // Verified text snippet from context
|
public string Snippet { get; set; } = string.Empty; // Verified text snippet from context
|
||||||
public string SourceBook { get; set; } = string.Empty; // Book title or description
|
public string SourceBook { get; set; } = string.Empty; // Book title or description
|
||||||
|
public string? Author { get; set; }
|
||||||
|
public int? PageNumber { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.DTOs.Creator;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Telemetry metrics for the Creator Dashboard.
|
||||||
|
/// </summary>
|
||||||
|
public record DashboardMetricsDto(
|
||||||
|
int TotalReads,
|
||||||
|
double AvgReadTimeMinutes,
|
||||||
|
int ActiveReaders,
|
||||||
|
decimal GrossRevenue
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lightweight revision details for the Creator Dashboard.
|
||||||
|
/// </summary>
|
||||||
|
public record CreatorBookRevisionDto(
|
||||||
|
Guid Id,
|
||||||
|
string VersionString,
|
||||||
|
bool IsPublished,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
DateTime? PublishedAt
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lightweight book publication details for the Creator Dashboard.
|
||||||
|
/// </summary>
|
||||||
|
public record CreatorBookDto(
|
||||||
|
Guid Id,
|
||||||
|
string Title,
|
||||||
|
int WordCount,
|
||||||
|
int AggregatedReads,
|
||||||
|
Guid? FirstChapterId,
|
||||||
|
CreatorBookRevisionDto? LivePublishedRevision,
|
||||||
|
CreatorBookRevisionDto? CurrentDraftRevision
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Root data envelope for Creator Dashboard loading.
|
||||||
|
/// </summary>
|
||||||
|
public record CreatorDashboardDataDto(
|
||||||
|
DashboardMetricsDto Metrics,
|
||||||
|
List<CreatorBookDto> Books
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request DTO for creating a new Book.
|
||||||
|
/// </summary>
|
||||||
|
public record CreateBookRequestDto(
|
||||||
|
string Title,
|
||||||
|
string? Description
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response DTO for creating a new Book.
|
||||||
|
/// </summary>
|
||||||
|
public record CreateBookResponseDto(
|
||||||
|
Guid BookId
|
||||||
|
);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
namespace NexusReader.Application.DTOs.Media;
|
||||||
|
|
||||||
|
// Note: These DTOs are registered in AppJsonContext.cs for JSON source generation.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request DTO for chapter validation/sanitization.
|
||||||
|
/// </summary>
|
||||||
|
public record ValidateChapterRequest(string Content);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response DTO containing sanitized chapter content.
|
||||||
|
/// </summary>
|
||||||
|
public record ValidateChapterResponse(string SanitizedContent);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response DTO containing the uploaded media file URL.
|
||||||
|
/// </summary>
|
||||||
|
public record UploadResultDto(string Url);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a structured JSON backup envelope stored in LocalStorage.
|
||||||
|
/// </summary>
|
||||||
|
public class LocalBackupEnvelope
|
||||||
|
{
|
||||||
|
public Guid ChapterId { get; set; }
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
public string MarkdownContent { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request DTO for chapter autosaving.
|
||||||
|
/// </summary>
|
||||||
|
public record AutosaveChapterRequest(string MarkdownContent);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using NexusReader.Domain.Enums;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.DTOs.User;
|
||||||
|
|
||||||
|
public record UpdateThemeRequest(ThemeMode Mode);
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
using NexusReader.Application.Constants;
|
using NexusReader.Application.Constants;
|
||||||
|
using NexusReader.Domain.Enums;
|
||||||
|
|
||||||
namespace NexusReader.Application.DTOs.User;
|
namespace NexusReader.Application.DTOs.User;
|
||||||
|
|
||||||
public record UserProfileDto
|
public record UserProfileDto
|
||||||
{
|
{
|
||||||
public string Email { get; init; } = string.Empty;
|
public string Email { get; init; } = string.Empty;
|
||||||
|
public string UserId { get; init; } = string.Empty;
|
||||||
public int AITokensUsed { get; init; }
|
public int AITokensUsed { get; init; }
|
||||||
public Guid TenantId { get; init; }
|
public Guid TenantId { get; init; }
|
||||||
|
public ThemeMode ThemePreference { get; init; } = ThemeMode.System;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Relational data for the current subscription plan.
|
/// Relational data for the current subscription plan.
|
||||||
@@ -15,11 +18,12 @@ public record UserProfileDto
|
|||||||
|
|
||||||
public int AverageQuizScore { get; init; }
|
public int AverageQuizScore { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
public string? DisplayName { get; init; }
|
||||||
/// Summary of the last read book.
|
public int BooksReadCount { get; init; }
|
||||||
/// </summary>
|
public int ConceptsMappedCount { get; init; }
|
||||||
public LastReadBookDto? LastReadBook { get; init; }
|
public LastReadBookDto? LastReadBook { get; init; }
|
||||||
|
public IReadOnlyList<QuizResultDto> RecentQuizzes { get; init; } = Array.Empty<QuizResultDto>();
|
||||||
|
public IReadOnlyList<MappedConceptDto> MappedConcepts { get; init; } = Array.Empty<MappedConceptDto>();
|
||||||
public string[] Roles { get; init; } = Array.Empty<string>();
|
public string[] Roles { get; init; } = Array.Empty<string>();
|
||||||
|
|
||||||
// Helper properties for UI compatibility
|
// Helper properties for UI compatibility
|
||||||
@@ -28,6 +32,14 @@ public record UserProfileDto
|
|||||||
public string LastReadBookTitle => LastReadBook?.Title ?? PlanConstants.DefaultActivityLabel;
|
public string LastReadBookTitle => LastReadBook?.Title ?? PlanConstants.DefaultActivityLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record MappedConceptDto
|
||||||
|
{
|
||||||
|
public string Id { get; init; } = string.Empty;
|
||||||
|
public string Type { get; init; } = string.Empty;
|
||||||
|
public string Content { get; init; } = string.Empty;
|
||||||
|
public string DisplayLabel => Content.Length > 25 ? Content.Substring(0, 22) + "..." : Content;
|
||||||
|
}
|
||||||
|
|
||||||
public record LastReadBookDto
|
public record LastReadBookDto
|
||||||
{
|
{
|
||||||
public Guid Id { get; init; }
|
public Guid Id { get; init; }
|
||||||
@@ -38,4 +50,15 @@ public record LastReadBookDto
|
|||||||
public string? LastChapter { get; init; }
|
public string? LastChapter { get; init; }
|
||||||
public int LastChapterIndex { get; init; }
|
public int LastChapterIndex { get; init; }
|
||||||
public string? Description { get; init; }
|
public string? Description { get; init; }
|
||||||
|
public bool IsReadyForReading { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record QuizResultDto
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
public string Topic { get; init; } = string.Empty;
|
||||||
|
public int Score { get; init; }
|
||||||
|
public int TotalQuestions { get; init; }
|
||||||
|
public double Percentage { get; init; }
|
||||||
|
public DateTime CompletedDate { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System;
|
||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Features.Books.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Command to create a new Book, initialize its first Working Draft revision, and seed it with a default Introduction chapter.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Title">The title of the new book.</param>
|
||||||
|
/// <param name="Description">An optional description of the book.</param>
|
||||||
|
/// <param name="UserId">The ID of the creator user.</param>
|
||||||
|
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
|
||||||
|
public record CreateBookCommand(
|
||||||
|
string Title,
|
||||||
|
string? Description,
|
||||||
|
string UserId,
|
||||||
|
string TenantId
|
||||||
|
) : ICommand<Guid>;
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FluentResults;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
using NexusReader.Data.Persistence;
|
||||||
|
using NexusReader.Domain.Entities;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Features.Books.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MediatR handler for creating a Book, creating its initial Working Draft revision,
|
||||||
|
/// and seeding a default first chapter ("Introduction") in an atomic database transaction.
|
||||||
|
/// </summary>
|
||||||
|
public class CreateBookCommandHandler : ICommandHandler<CreateBookCommand, Guid>
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||||
|
|
||||||
|
public CreateBookCommandHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||||
|
{
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<Guid>> Handle(CreateBookCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Title))
|
||||||
|
{
|
||||||
|
return Result.Fail<Guid>(new Error("Book title is required."));
|
||||||
|
}
|
||||||
|
|
||||||
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 1. Instantiate the Book record mapping Title, UserId, and TenantId
|
||||||
|
var book = new Book
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Title = request.Title.Trim(),
|
||||||
|
UserId = request.UserId,
|
||||||
|
TenantId = request.TenantId,
|
||||||
|
CurrentDraftRevisionId = null,
|
||||||
|
LivePublishedRevisionId = null
|
||||||
|
};
|
||||||
|
|
||||||
|
dbContext.Books.Add(book);
|
||||||
|
|
||||||
|
// 2. Instantiate the initial BookRevision designated as "Working Draft"
|
||||||
|
var draftRevision = new BookRevision
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
BookId = book.Id,
|
||||||
|
VersionString = "Working Draft",
|
||||||
|
IsPublished = false,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
dbContext.BookRevisions.Add(draftRevision);
|
||||||
|
|
||||||
|
// 3. Automatically instantiate and append a default first Chapter to this new revision
|
||||||
|
var introChapter = new Chapter
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
BookRevisionId = draftRevision.Id,
|
||||||
|
Title = "Introduction",
|
||||||
|
MarkdownContent = "# Introduction\nStart writing here...",
|
||||||
|
SortOrder = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
dbContext.Chapters.Add(introChapter);
|
||||||
|
|
||||||
|
// Save first to generate DB references
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// 4. Inject the newly instantiated draft revision ID back into Book.CurrentDraftRevisionId
|
||||||
|
book.CurrentDraftRevisionId = draftRevision.Id;
|
||||||
|
|
||||||
|
// Save the updated Book link
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Commit transaction
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(book.Id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception rollbackEx)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[CreateBook] Transaction rollback failed: {rollbackEx.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Fail<Guid>(new Error($"Failed to create book: {ex.Message}").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Features.Books.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Command to publish a new frozen version of a Book, and create a new Working Draft.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="BookId">The unique identifier of the Book to publish.</param>
|
||||||
|
/// <param name="CustomVersionString">The custom version string to apply (e.g. "v1.0").</param>
|
||||||
|
/// <param name="UserId">The ID of the user requesting the action.</param>
|
||||||
|
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
|
||||||
|
public record PublishBookVersionCommand(Guid BookId, string CustomVersionString, string UserId, string TenantId) : ICommand;
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FluentResults;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
using NexusReader.Data.Persistence;
|
||||||
|
using NexusReader.Domain.Entities;
|
||||||
|
using NexusReader.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Features.Books.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MediatR handler for publishing a Book version and setting up the next Working Draft.
|
||||||
|
/// </summary>
|
||||||
|
public class PublishBookVersionCommandHandler : ICommandHandler<PublishBookVersionCommand>
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||||
|
|
||||||
|
public PublishBookVersionCommandHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||||
|
{
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> Handle(PublishBookVersionCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Fetch the Book including its CurrentDraftRevision and all associated Chapters,
|
||||||
|
// enforcing that the book belongs to the requested TenantId and UserId to prevent cross-tenant data leaks.
|
||||||
|
var book = await dbContext.Books
|
||||||
|
.Include(b => b.CurrentDraftRevision)
|
||||||
|
.ThenInclude(r => r!.Chapters)
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
b => b.Id == request.BookId && b.UserId == request.UserId && b.TenantId == request.TenantId,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (book == null)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error($"Book with ID '{request.BookId}' was not found."));
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldDraftRevision = book.CurrentDraftRevision;
|
||||||
|
if (oldDraftRevision == null)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error("The book does not have an active draft revision to publish."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start ACID transaction
|
||||||
|
using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 1. Update the current draft revision: Set IsPublished = true, PublishedAt = now, VersionString = custom
|
||||||
|
oldDraftRevision.IsPublished = true;
|
||||||
|
oldDraftRevision.PublishedAt = DateTime.UtcNow;
|
||||||
|
oldDraftRevision.VersionString = request.CustomVersionString;
|
||||||
|
|
||||||
|
// 2. Point the Book.LivePublishedRevisionId to this newly frozen revision ID
|
||||||
|
book.LivePublishedRevisionId = oldDraftRevision.Id;
|
||||||
|
|
||||||
|
// 3. Execute Deep Snapshot: Instantiate a brand new BookRevision representing the next "Working Draft"
|
||||||
|
var newDraftRevision = new BookRevision
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
BookId = book.Id,
|
||||||
|
VersionString = "Working Draft",
|
||||||
|
IsPublished = false,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
dbContext.BookRevisions.Add(newDraftRevision);
|
||||||
|
|
||||||
|
// Replicate/clone chapters into new Chapter objects associated with the new draft revision.
|
||||||
|
// Reset identities by explicitly instantiating completely new Chapter objects with Guid.NewGuid().
|
||||||
|
foreach (var oldChapter in oldDraftRevision.Chapters)
|
||||||
|
{
|
||||||
|
var newChapter = new Chapter
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
BookRevisionId = newDraftRevision.Id,
|
||||||
|
Title = oldChapter.Title,
|
||||||
|
MarkdownContent = oldChapter.MarkdownContent,
|
||||||
|
SortOrder = oldChapter.SortOrder
|
||||||
|
};
|
||||||
|
dbContext.Chapters.Add(newChapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Assign the new draft revision ID to Book.CurrentDraftRevisionId
|
||||||
|
book.CurrentDraftRevisionId = newDraftRevision.Id;
|
||||||
|
|
||||||
|
// Save changes and commit transaction
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception rollbackEx)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[PublishBookVersion] Transaction rollback failed: {rollbackEx.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Fail(new Error($"Failed to publish book version: {ex.Message}").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using NexusReader.Application.Queries.Graph;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Queries.Concepts;
|
||||||
|
|
||||||
|
public record BookConceptsMapResultDto(
|
||||||
|
[property: JsonPropertyName("nodes")] List<GraphNodeDto> Nodes,
|
||||||
|
[property: JsonPropertyName("lastReadBlockId")] string LastReadBlockId
|
||||||
|
);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Queries.Concepts;
|
||||||
|
|
||||||
|
public record GetBookConceptsMapQuery(
|
||||||
|
Guid BookId,
|
||||||
|
string UserId,
|
||||||
|
string TenantId
|
||||||
|
) : IQuery<BookConceptsMapResultDto>;
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FluentResults;
|
||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
using NexusReader.Application.Abstractions.Persistence;
|
||||||
|
using NexusReader.Application.Queries.Graph;
|
||||||
|
using NexusReader.Application.Utilities;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Queries.Concepts;
|
||||||
|
|
||||||
|
internal sealed class GetBookConceptsMapQueryHandler : IQueryHandler<GetBookConceptsMapQuery, BookConceptsMapResultDto>
|
||||||
|
{
|
||||||
|
private readonly IConceptsMapReadRepository _repository;
|
||||||
|
|
||||||
|
public GetBookConceptsMapQueryHandler(IConceptsMapReadRepository repository)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<BookConceptsMapResultDto>> Handle(GetBookConceptsMapQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. Fetch user to extract reading progress (LastReadPageId)
|
||||||
|
var lastReadPageId = await _repository.GetLastReadPageIdAsync(request.UserId, cancellationToken);
|
||||||
|
var lastReadBlockId = lastReadPageId ?? string.Empty;
|
||||||
|
|
||||||
|
// 2. Fetch all KnowledgeUnits associated with the ebook and user's tenant
|
||||||
|
var units = await _repository.GetKnowledgeUnitsForBookAsync(request.BookId, request.TenantId, cancellationToken);
|
||||||
|
|
||||||
|
var nodes = new List<GraphNodeDto>();
|
||||||
|
|
||||||
|
foreach (var unit in units)
|
||||||
|
{
|
||||||
|
// Only process units representing sections or conceptual milestones (usually starting with "seg-")
|
||||||
|
if (string.IsNullOrEmpty(unit.Id) || !unit.Id.StartsWith("seg-"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string label = unit.Id;
|
||||||
|
string group = "concept";
|
||||||
|
string summary = unit.Content;
|
||||||
|
var keyTerms = new List<string>();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(unit.MetadataJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(unit.MetadataJson);
|
||||||
|
if (doc.RootElement.TryGetProperty("label", out var labelProp))
|
||||||
|
label = labelProp.GetString() ?? label;
|
||||||
|
if (doc.RootElement.TryGetProperty("group", out var groupProp))
|
||||||
|
group = groupProp.GetString() ?? group;
|
||||||
|
if (doc.RootElement.TryGetProperty("summary", out var summaryProp))
|
||||||
|
summary = summaryProp.GetString() ?? summary;
|
||||||
|
if (doc.RootElement.TryGetProperty("key_terms", out var ktProp) && ktProp.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var term in ktProp.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (term.GetString() is string s)
|
||||||
|
keyTerms.Add(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Fallback to defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.Add(new GraphNodeDto(
|
||||||
|
Id: unit.Id,
|
||||||
|
Label: label,
|
||||||
|
Group: group,
|
||||||
|
Description: unit.Content,
|
||||||
|
Type: unit.Type.ToString(),
|
||||||
|
Summary: summary,
|
||||||
|
KeyTerms: keyTerms
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return sorted by the numeric value in the seg-ID to ensure topdown vertical alignment
|
||||||
|
var sortedNodes = nodes
|
||||||
|
.OrderBy(n => SegmentIdParser.Parse(n.Id))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Result.Ok(new BookConceptsMapResultDto(sortedNodes, lastReadBlockId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
using NexusReader.Application.DTOs.Creator;
|
||||||
|
using NexusReader.Data.Persistence;
|
||||||
|
using NexusReader.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Queries.Creator;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query to load all revisions for a specific Book, checking multi-tenant ownership boundaries.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="BookId">The unique identifier of the target Book.</param>
|
||||||
|
/// <param name="UserId">The ID of the creator requesting revision data.</param>
|
||||||
|
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
|
||||||
|
public record GetBookRevisionsQuery(Guid BookId, string UserId, string TenantId) : IQuery<List<CreatorBookRevisionDto>>;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handler that lists past revisions of a Book, verifying ownership to prevent cross-tenant leakages.
|
||||||
|
/// </summary>
|
||||||
|
public class GetBookRevisionsQueryHandler : IQueryHandler<GetBookRevisionsQuery, List<CreatorBookRevisionDto>>
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||||
|
|
||||||
|
public GetBookRevisionsQueryHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||||
|
{
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<FluentResults.Result<List<CreatorBookRevisionDto>>> Handle(GetBookRevisionsQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Verify the book exists and belongs to this tenant/user to prevent cross-tenant data leaks
|
||||||
|
var bookExists = await dbContext.Books
|
||||||
|
.AnyAsync(b => b.Id == request.BookId && b.UserId == request.UserId && b.TenantId == request.TenantId, cancellationToken);
|
||||||
|
|
||||||
|
if (!bookExists)
|
||||||
|
{
|
||||||
|
return FluentResults.Result.Fail<List<CreatorBookRevisionDto>>(new FluentResults.Error($"Book with ID '{request.BookId}' was not found."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all revisions sorted chronologically
|
||||||
|
var revisions = await dbContext.BookRevisions
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(r => r.BookId == request.BookId)
|
||||||
|
.OrderByDescending(r => r.CreatedAt)
|
||||||
|
.Select(r => new CreatorBookRevisionDto(
|
||||||
|
r.Id,
|
||||||
|
r.VersionString,
|
||||||
|
r.IsPublished,
|
||||||
|
r.CreatedAt,
|
||||||
|
r.PublishedAt
|
||||||
|
))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return FluentResults.Result.Ok(revisions);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
using NexusReader.Application.DTOs.Creator;
|
||||||
|
using NexusReader.Data.Persistence;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Queries.Creator;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query to load aggregated Creator Dashboard telemetry metrics and book listings.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="UserId">The ID of the creator requesting dashboard data.</param>
|
||||||
|
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
|
||||||
|
public record GetCreatorDashboardDataQuery(string UserId, string TenantId) : IQuery<CreatorDashboardDataDto>;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handler that executes projection-only LINQ queries to aggregate metrics and compute word counts
|
||||||
|
/// without loading raw chapter content into memory or tracking them in the EF Core Change Tracker.
|
||||||
|
/// </summary>
|
||||||
|
public class GetCreatorDashboardDataQueryHandler : IQueryHandler<GetCreatorDashboardDataQuery, CreatorDashboardDataDto>
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||||
|
|
||||||
|
public GetCreatorDashboardDataQueryHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||||
|
{
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<FluentResults.Result<CreatorDashboardDataDto>> Handle(GetCreatorDashboardDataQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Execute projection-only LINQ query. The heavy MarkdownContent is projected only as integer lengths.
|
||||||
|
var projectedBooks = await dbContext.Books
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(b => b.UserId == request.UserId && b.TenantId == request.TenantId)
|
||||||
|
.Select(b => new
|
||||||
|
{
|
||||||
|
b.Id,
|
||||||
|
b.Title,
|
||||||
|
LivePublishedRevision = b.LivePublishedRevision == null ? null : new CreatorBookRevisionDto(
|
||||||
|
b.LivePublishedRevision.Id,
|
||||||
|
b.LivePublishedRevision.VersionString,
|
||||||
|
b.LivePublishedRevision.IsPublished,
|
||||||
|
b.LivePublishedRevision.CreatedAt,
|
||||||
|
b.LivePublishedRevision.PublishedAt
|
||||||
|
),
|
||||||
|
CurrentDraftRevision = b.CurrentDraftRevision == null ? null : new CreatorBookRevisionDto(
|
||||||
|
b.CurrentDraftRevision.Id,
|
||||||
|
b.CurrentDraftRevision.VersionString,
|
||||||
|
b.CurrentDraftRevision.IsPublished,
|
||||||
|
b.CurrentDraftRevision.CreatedAt,
|
||||||
|
b.CurrentDraftRevision.PublishedAt
|
||||||
|
),
|
||||||
|
FirstChapterId = b.CurrentDraftRevision == null
|
||||||
|
? (Guid?)null
|
||||||
|
: b.CurrentDraftRevision.Chapters.OrderBy(c => c.SortOrder).Select(c => c.Id).FirstOrDefault(),
|
||||||
|
ChapterContentLengths = b.CurrentDraftRevision == null
|
||||||
|
? new List<int>()
|
||||||
|
: b.CurrentDraftRevision.Chapters.Select(c => c.MarkdownContent.Length).ToList()
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var booksList = new List<CreatorBookDto>();
|
||||||
|
int totalReads = 0;
|
||||||
|
int totalWords = 0;
|
||||||
|
|
||||||
|
foreach (var pBook in projectedBooks)
|
||||||
|
{
|
||||||
|
// Estimate word count (approx. 6 characters per word as a database-friendly standard length)
|
||||||
|
int wordCount = pBook.ChapterContentLengths.Sum(len => len / 6);
|
||||||
|
totalWords += wordCount;
|
||||||
|
|
||||||
|
// Generate deterministic simulated telemetry metrics scoped to this Book
|
||||||
|
int bookReads = Math.Abs(pBook.Id.GetHashCode() % 1000) + 120;
|
||||||
|
totalReads += bookReads;
|
||||||
|
|
||||||
|
var bookDto = new CreatorBookDto(
|
||||||
|
pBook.Id,
|
||||||
|
pBook.Title,
|
||||||
|
wordCount,
|
||||||
|
bookReads,
|
||||||
|
pBook.FirstChapterId,
|
||||||
|
pBook.LivePublishedRevision,
|
||||||
|
pBook.CurrentDraftRevision
|
||||||
|
);
|
||||||
|
|
||||||
|
booksList.Add(bookDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate aggregate dashboard metrics based on projected stats
|
||||||
|
int activeReaders = projectedBooks.Count == 0 ? 0 : Math.Abs(request.UserId.GetHashCode() % 15) + 3;
|
||||||
|
decimal grossRevenue = totalReads * 1.49m;
|
||||||
|
double avgReadTime = projectedBooks.Count == 0 ? 0 : Math.Round(totalWords / 250.0, 1); // standard 250 words per minute reading speed
|
||||||
|
|
||||||
|
var metrics = new DashboardMetricsDto(
|
||||||
|
totalReads,
|
||||||
|
avgReadTime,
|
||||||
|
activeReaders,
|
||||||
|
grossRevenue
|
||||||
|
);
|
||||||
|
|
||||||
|
return FluentResults.Result.Ok(new CreatorDashboardDataDto(metrics, booksList));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,27 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace NexusReader.Application.Queries.Graph;
|
namespace NexusReader.Application.Queries.Graph;
|
||||||
|
|
||||||
public record GraphNodeDto(string Id, string Label, string Group, string? Type = null);
|
public record GraphNodeDto(
|
||||||
public record GraphLinkDto(string Source, string Target, string RelationType, int Value = 1);
|
[property: JsonPropertyName("id")] string Id,
|
||||||
|
[property: JsonPropertyName("label")] string Label,
|
||||||
|
[property: JsonPropertyName("group")] string Group,
|
||||||
|
[property: JsonPropertyName("description")] string? Description = null,
|
||||||
|
[property: JsonPropertyName("type")] string? Type = null,
|
||||||
|
[property: JsonPropertyName("summary")] string? Summary = null,
|
||||||
|
[property: JsonPropertyName("key_terms")] List<string>? KeyTerms = null
|
||||||
|
);
|
||||||
|
|
||||||
|
public record GraphLinkDto(
|
||||||
|
[property: JsonPropertyName("source")] string Source,
|
||||||
|
[property: JsonPropertyName("target")] string Target,
|
||||||
|
[property: JsonPropertyName("type")] string RelationType,
|
||||||
|
[property: JsonPropertyName("value")] int Value = 1
|
||||||
|
);
|
||||||
|
|
||||||
public record GraphDataDto
|
public record GraphDataDto
|
||||||
{
|
{
|
||||||
public List<GraphNodeDto> Nodes { get; init; } = new();
|
[JsonPropertyName("nodes")] public List<GraphNodeDto> Nodes { get; init; } = new();
|
||||||
public List<GraphLinkDto> Links { get; init; } = new();
|
[JsonPropertyName("links")] public List<GraphLinkDto> Links { get; init; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FluentResults;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NexusReader.Application.Abstractions.Persistence;
|
||||||
|
using NexusReader.Application.Common;
|
||||||
|
using NexusReader.Application.DTOs.AI;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Queries.Intelligence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MediatR query to request global intelligence hybrid Q&A context.
|
||||||
|
/// </summary>
|
||||||
|
public record GetGlobalIntelligenceQuery(string QueryText, string UserId, string TenantId = "global")
|
||||||
|
: IRequest<Result<IntelligenceResponse>>;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request schema for global hybrid search queries.
|
||||||
|
/// </summary>
|
||||||
|
public record GetGlobalIntelligenceRequest(string QueryText);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response schema returning generated AI text, paywall status, and locked publishing details.
|
||||||
|
/// </summary>
|
||||||
|
public record IntelligenceResponse(
|
||||||
|
string ResponseText,
|
||||||
|
bool HasPaywall,
|
||||||
|
Guid? LockedBookId,
|
||||||
|
string? LockedBookTitle,
|
||||||
|
List<CitationDto>? Citations = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles <see cref="GetGlobalIntelligenceQuery"/> by performing local/global dual searches,
|
||||||
|
/// executing monetization rules, and invoking Chat AI with appropriate gating logic.
|
||||||
|
/// </summary>
|
||||||
|
public class GetGlobalIntelligenceQueryHandler : IRequestHandler<GetGlobalIntelligenceQuery, Result<IntelligenceResponse>>
|
||||||
|
{
|
||||||
|
private readonly IUserLibraryStore _userLibraryStore;
|
||||||
|
private readonly IVectorSearchStore _vectorSearchStore;
|
||||||
|
private readonly IChatClient _chatClient;
|
||||||
|
private readonly RagMonetizationOptions _options;
|
||||||
|
|
||||||
|
public GetGlobalIntelligenceQueryHandler(
|
||||||
|
IUserLibraryStore userLibraryStore,
|
||||||
|
IVectorSearchStore vectorSearchStore,
|
||||||
|
IChatClient chatClient,
|
||||||
|
IOptions<RagMonetizationOptions> options)
|
||||||
|
{
|
||||||
|
_userLibraryStore = userLibraryStore;
|
||||||
|
_vectorSearchStore = vectorSearchStore;
|
||||||
|
_chatClient = chatClient;
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<IntelligenceResponse>> Handle(GetGlobalIntelligenceQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.QueryText))
|
||||||
|
{
|
||||||
|
return Result.Fail("Question cannot be empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Step A: Fetch whitelisted BookIds
|
||||||
|
var whitelistedBookIds = await _userLibraryStore.GetOwnedBookIdsAsync(request.UserId, cancellationToken);
|
||||||
|
|
||||||
|
// Step B & C: Vector Dual-Search with Resilient Trapping
|
||||||
|
List<VectorChunk> globalChunks = new();
|
||||||
|
List<VectorChunk> localChunks = new();
|
||||||
|
double globalScore = 0.0;
|
||||||
|
double localScore = 0.0;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Execute searches
|
||||||
|
globalChunks = await _vectorSearchStore.SearchGlobalAsync(request.QueryText, request.TenantId, limit: 3, cancellationToken);
|
||||||
|
globalScore = globalChunks.Any() ? Math.Max(0.0, globalChunks.Max(c => c.Score)) : 0.0;
|
||||||
|
|
||||||
|
if (whitelistedBookIds.Any())
|
||||||
|
{
|
||||||
|
localChunks = await _vectorSearchStore.SearchLocalAsync(request.QueryText, request.TenantId, whitelistedBookIds, limit: 3, cancellationToken);
|
||||||
|
localScore = localChunks.Any() ? Math.Max(0.0, localChunks.Max(c => c.Score)) : 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Resilient Error Trapping: transform connectivity anomalies into domain-friendly errors
|
||||||
|
return Result.Fail(new Error("Serwer wyszukiwania semantycznego jest tymczasowo niedostępny. Spróbuj ponownie później.").CausedBy(ex));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step D: Evaluate Monetization Thresholds
|
||||||
|
bool triggerPaywall = false;
|
||||||
|
|
||||||
|
if (localScore == 0.0 && globalScore > _options.BaselineThreshold)
|
||||||
|
{
|
||||||
|
triggerPaywall = true;
|
||||||
|
}
|
||||||
|
else if ((globalScore - localScore) > _options.DeltaThreshold && globalScore > _options.UpgradeThreshold)
|
||||||
|
{
|
||||||
|
triggerPaywall = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var chosenChunks = triggerPaywall ? globalChunks : localChunks;
|
||||||
|
|
||||||
|
// Fetch book titles for citations/paywall metadata
|
||||||
|
var chunkEbookIds = chosenChunks
|
||||||
|
.Where(c => Guid.TryParse(c.EbookId, out _))
|
||||||
|
.Select(c => Guid.Parse(c.EbookId))
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var bookTitles = await _userLibraryStore.GetBookTitlesAsync(chunkEbookIds, cancellationToken);
|
||||||
|
|
||||||
|
// Step E: Identify locked book if paywall triggered
|
||||||
|
Guid? lockedBookId = null;
|
||||||
|
string? lockedBookTitle = null;
|
||||||
|
|
||||||
|
if (triggerPaywall && globalChunks.Any())
|
||||||
|
{
|
||||||
|
var topGlobalChunk = globalChunks.OrderByDescending(c => c.Score).First();
|
||||||
|
if (Guid.TryParse(topGlobalChunk.EbookId, out var parsedLockedId))
|
||||||
|
{
|
||||||
|
lockedBookId = parsedLockedId;
|
||||||
|
bookTitles.TryGetValue(parsedLockedId, out lockedBookTitle);
|
||||||
|
if (string.IsNullOrEmpty(lockedBookTitle))
|
||||||
|
{
|
||||||
|
lockedBookTitle = "Nieznana książka";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format context blocks for LLM
|
||||||
|
var relatedContexts = new List<string>();
|
||||||
|
foreach (var chunk in chosenChunks)
|
||||||
|
{
|
||||||
|
var sourceId = chunk.EbookId;
|
||||||
|
relatedContexts.Add($"[Source ID: {sourceId}] {chunk.Content}");
|
||||||
|
}
|
||||||
|
var contextBlocksText = string.Join("\n\n", relatedContexts);
|
||||||
|
|
||||||
|
// Build LLM prompts
|
||||||
|
var systemPrompt = "You are an advanced, extremely precise Fact-Checking AI assistant. Your task is to answer the user's question using ONLY the provided context blocks.\n" +
|
||||||
|
"Rely EXCLUSIVELY on the provided context. Do NOT use any pre-existing external knowledge, facts, or assumptions.\n" +
|
||||||
|
"If the context does not contain the answer, say: 'I cannot answer this based on the provided book context.'";
|
||||||
|
|
||||||
|
if (triggerPaywall)
|
||||||
|
{
|
||||||
|
var localScorePercent = (int)Math.Round(localScore * 100);
|
||||||
|
var globalScorePercent = (int)Math.Round(globalScore * 100);
|
||||||
|
var resolvedTitle = lockedBookTitle ?? "Nieznana książka";
|
||||||
|
|
||||||
|
systemPrompt += $"\n\nCRITICAL: You are operating in TEASER mode. The user does not own the source document named '{resolvedTitle}'. You are strictly allowed to provide only a 1-sentence foundational definition or answer based on the context to prove the system knows the solution. DO NOT output code blocks, implementation details, or bullet points. You must immediately terminate your response with this exact token format: [PAYWALL_TRIGGER:{lockedBookId}:{resolvedTitle}:{localScorePercent}:{globalScorePercent}].";
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages = new List<Microsoft.Extensions.AI.ChatMessage>
|
||||||
|
{
|
||||||
|
new(Microsoft.Extensions.AI.ChatRole.System, systemPrompt),
|
||||||
|
new(Microsoft.Extensions.AI.ChatRole.User, $"Context:\n{contextBlocksText}\n\nQuestion: {request.QueryText}")
|
||||||
|
};
|
||||||
|
|
||||||
|
var chatOptions = new ChatOptions
|
||||||
|
{
|
||||||
|
Temperature = 0.0f,
|
||||||
|
MaxOutputTokens = 1000
|
||||||
|
};
|
||||||
|
|
||||||
|
var chatResponse = await _chatClient.GetResponseAsync(messages, chatOptions, cancellationToken);
|
||||||
|
var responseText = chatResponse.Text?.Trim() ?? string.Empty;
|
||||||
|
|
||||||
|
// Ensure the paywall token is appended if LLM misses it in teaser mode
|
||||||
|
if (triggerPaywall)
|
||||||
|
{
|
||||||
|
var localScorePercent = (int)Math.Round(localScore * 100);
|
||||||
|
var globalScorePercent = (int)Math.Round(globalScore * 100);
|
||||||
|
var resolvedTitle = lockedBookTitle ?? "Nieznana książka";
|
||||||
|
var paywallToken = $"[PAYWALL_TRIGGER:{lockedBookId}:{resolvedTitle}:{localScorePercent}:{globalScorePercent}]";
|
||||||
|
|
||||||
|
if (!responseText.Contains("[PAYWALL_TRIGGER:"))
|
||||||
|
{
|
||||||
|
responseText = responseText.Trim() + " " + paywallToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build citations list
|
||||||
|
var citations = new List<CitationDto>();
|
||||||
|
foreach (var chunk in chosenChunks)
|
||||||
|
{
|
||||||
|
var sourceBookName = "Unknown";
|
||||||
|
if (Guid.TryParse(chunk.EbookId, out var parsedId) && bookTitles.TryGetValue(parsedId, out var title))
|
||||||
|
{
|
||||||
|
sourceBookName = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
citations.Add(new CitationDto
|
||||||
|
{
|
||||||
|
CitationId = chunk.EbookId,
|
||||||
|
Snippet = chunk.Content,
|
||||||
|
SourceBook = sourceBookName,
|
||||||
|
Author = null,
|
||||||
|
PageNumber = null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Ok(new IntelligenceResponse(
|
||||||
|
ResponseText: responseText,
|
||||||
|
HasPaywall: triggerPaywall,
|
||||||
|
LockedBookId: lockedBookId,
|
||||||
|
LockedBookTitle: lockedBookTitle,
|
||||||
|
Citations: citations
|
||||||
|
));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error("Nieoczekiwany błąd serwera podczas przetwarzania zapytania.").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,8 @@ public class GetMyEbooksQueryHandler : IRequestHandler<GetMyEbooksQuery, Result<
|
|||||||
Progress = e.Progress,
|
Progress = e.Progress,
|
||||||
LastChapter = e.LastChapter ?? "Rozpoczynanie...",
|
LastChapter = e.LastChapter ?? "Rozpoczynanie...",
|
||||||
LastChapterIndex = e.LastChapterIndex,
|
LastChapterIndex = e.LastChapterIndex,
|
||||||
Description = e.Description
|
Description = e.Description,
|
||||||
|
IsReadyForReading = e.IsReadyForReading
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,7 @@
|
|||||||
using FluentResults;
|
using FluentResults;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Pgvector;
|
|
||||||
using Pgvector.EntityFrameworkCore;
|
|
||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
using NexusReader.Application.DTOs.AI;
|
using NexusReader.Application.DTOs.AI;
|
||||||
using Microsoft.Extensions.AI;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Resilience;
|
|
||||||
using Polly;
|
|
||||||
using Polly.Registry;
|
|
||||||
using Mapster;
|
|
||||||
using MapsterMapper;
|
|
||||||
|
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Queries.Library;
|
namespace NexusReader.Application.Queries.Library;
|
||||||
|
|
||||||
@@ -21,21 +10,11 @@ public record SearchLibrarySemanticallyQuery(string QueryText, string TenantId,
|
|||||||
|
|
||||||
public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibrarySemanticallyQuery, Result<List<SemanticSearchResultDto>>>
|
public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibrarySemanticallyQuery, Result<List<SemanticSearchResultDto>>>
|
||||||
{
|
{
|
||||||
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
|
private readonly IKnowledgeService _knowledgeService;
|
||||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
|
||||||
private readonly ResiliencePipeline _retryPipeline;
|
|
||||||
private readonly IMapper _mapper;
|
|
||||||
|
|
||||||
public SearchLibrarySemanticallyQueryHandler(
|
public SearchLibrarySemanticallyQueryHandler(IKnowledgeService knowledgeService)
|
||||||
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator,
|
|
||||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
|
||||||
ResiliencePipelineProvider<string> pipelineProvider,
|
|
||||||
IMapper mapper)
|
|
||||||
{
|
{
|
||||||
_embeddingGenerator = embeddingGenerator;
|
_knowledgeService = knowledgeService;
|
||||||
_dbContextFactory = dbContextFactory;
|
|
||||||
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
|
|
||||||
_mapper = mapper;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<List<SemanticSearchResultDto>>> Handle(SearchLibrarySemanticallyQuery request, CancellationToken cancellationToken)
|
public async Task<Result<List<SemanticSearchResultDto>>> Handle(SearchLibrarySemanticallyQuery request, CancellationToken cancellationToken)
|
||||||
@@ -45,19 +24,10 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
|
|||||||
return Result.Fail("Query text cannot be empty.");
|
return Result.Fail("Query text cannot be empty.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate embedding with retry
|
return await _knowledgeService.SearchLibrarySemanticallyAsync(
|
||||||
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
|
request.QueryText,
|
||||||
await _embeddingGenerator.GenerateAsync(new[] { request.QueryText }, cancellationToken: ct), cancellationToken);
|
request.TenantId,
|
||||||
var queryVector = new Vector(embeddingResponse.First().Vector.ToArray());
|
request.Limit,
|
||||||
|
cancellationToken);
|
||||||
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
||||||
var cacheEntries = await dbContext.SemanticKnowledgeCache
|
|
||||||
.Where(c => c.TenantId == request.TenantId && c.Embedding != null)
|
|
||||||
.OrderBy(c => c.Embedding!.CosineDistance(queryVector))
|
|
||||||
.Take(request.Limit)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var dtos = _mapper.Map<List<SemanticSearchResultDto>>(cacheEntries);
|
|
||||||
return Result.Ok(dtos);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using FluentResults;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Queries.Recommendations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MediatR query to fetch contextual recommendations based on the user's active reading state.
|
||||||
|
/// </summary>
|
||||||
|
public record GetContextualRecommendationsQuery(string UserId)
|
||||||
|
: IRequest<Result<ContextualRecommendationResponse>>;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response DTO containing contextual recommendations.
|
||||||
|
/// </summary>
|
||||||
|
public record ContextualRecommendationResponse(List<RecommendationDto> Recommendations);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Individual contextual recommendation details.
|
||||||
|
/// </summary>
|
||||||
|
public record RecommendationDto(
|
||||||
|
string BookTitle,
|
||||||
|
string ChapterTitle,
|
||||||
|
int MatchPercentage,
|
||||||
|
bool IsPremiumUpsell,
|
||||||
|
Guid TargetBookId
|
||||||
|
);
|
||||||
@@ -18,13 +18,16 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
|
|||||||
public async Task<Result<UserProfileDto>> Handle(GetUserProfileQuery request, CancellationToken cancellationToken)
|
public async Task<Result<UserProfileDto>> Handle(GetUserProfileQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
var profile = await dbContext.Users
|
|
||||||
|
var userRaw = await dbContext.Users
|
||||||
.Where(u => u.Id == request.UserId)
|
.Where(u => u.Id == request.UserId)
|
||||||
.Select(u => new UserProfileDto
|
.Select(u => new
|
||||||
{
|
{
|
||||||
Email = u.Email ?? string.Empty,
|
Email = u.Email ?? string.Empty,
|
||||||
|
UserId = u.Id,
|
||||||
AITokensUsed = u.AITokensUsed,
|
AITokensUsed = u.AITokensUsed,
|
||||||
TenantId = u.TenantId != null && u.TenantId.Length == 36 ? new Guid(u.TenantId) : Guid.Empty,
|
TenantIdString = u.TenantId,
|
||||||
|
ThemePreference = u.ThemePreference,
|
||||||
Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto
|
Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto
|
||||||
{
|
{
|
||||||
Id = u.SubscriptionPlan.Id,
|
Id = u.SubscriptionPlan.Id,
|
||||||
@@ -32,9 +35,17 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
|
|||||||
AITokenLimit = u.SubscriptionPlan.AITokenLimit,
|
AITokenLimit = u.SubscriptionPlan.AITokenLimit,
|
||||||
MonthlyPrice = u.SubscriptionPlan.MonthlyPrice
|
MonthlyPrice = u.SubscriptionPlan.MonthlyPrice
|
||||||
} : new SubscriptionPlanDto(),
|
} : new SubscriptionPlanDto(),
|
||||||
AverageQuizScore = u.QuizResults.Any(q => q.TotalQuestions > 0)
|
QuizResults = u.QuizResults.Select(q => new
|
||||||
? (int)u.QuizResults.Where(q => q.TotalQuestions > 0).Average(q => (double)q.Score / q.TotalQuestions * 100)
|
{
|
||||||
: 0,
|
q.Score,
|
||||||
|
q.TotalQuestions,
|
||||||
|
q.Id,
|
||||||
|
q.Topic,
|
||||||
|
q.Percentage,
|
||||||
|
q.CompletedDate
|
||||||
|
}).ToList(),
|
||||||
|
DisplayName = u.DisplayName,
|
||||||
|
BooksReadCount = u.Ebooks.Count(),
|
||||||
LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto
|
LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto
|
||||||
{
|
{
|
||||||
Id = e.Id,
|
Id = e.Id,
|
||||||
@@ -48,7 +59,8 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
|
|||||||
Progress = e.Progress,
|
Progress = e.Progress,
|
||||||
LastChapter = e.LastChapter ?? "Rozpoczynanie...",
|
LastChapter = e.LastChapter ?? "Rozpoczynanie...",
|
||||||
LastChapterIndex = e.LastChapterIndex,
|
LastChapterIndex = e.LastChapterIndex,
|
||||||
Description = e.Description
|
Description = e.Description,
|
||||||
|
IsReadyForReading = e.IsReadyForReading
|
||||||
}).FirstOrDefault(),
|
}).FirstOrDefault(),
|
||||||
Roles = dbContext.UserRoles
|
Roles = dbContext.UserRoles
|
||||||
.Where(ur => ur.UserId == u.Id)
|
.Where(ur => ur.UserId == u.Id)
|
||||||
@@ -57,11 +69,60 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
|
|||||||
})
|
})
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (profile == null)
|
if (userRaw == null)
|
||||||
{
|
{
|
||||||
return Result.Fail("Profile not found.");
|
return Result.Fail("Profile not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var tenantId = userRaw.TenantIdString;
|
||||||
|
var mappedConcepts = await dbContext.KnowledgeUnits
|
||||||
|
.Where(k => k.TenantId == tenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId))
|
||||||
|
.OrderByDescending(k => k.CreatedAt)
|
||||||
|
.Take(6)
|
||||||
|
.Select(k => new MappedConceptDto
|
||||||
|
{
|
||||||
|
Id = k.Id,
|
||||||
|
Type = k.Type.ToString(),
|
||||||
|
Content = k.Content
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var conceptsMappedCount = await dbContext.KnowledgeUnits
|
||||||
|
.CountAsync(k => k.TenantId == tenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId), cancellationToken);
|
||||||
|
|
||||||
|
int averageQuizScore = 0;
|
||||||
|
var validQuizzes = userRaw.QuizResults.Where(q => q.TotalQuestions > 0).ToList();
|
||||||
|
if (validQuizzes.Count > 0)
|
||||||
|
{
|
||||||
|
averageQuizScore = (int)(validQuizzes.Average(q => (double)q.Score / q.TotalQuestions) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile = new UserProfileDto
|
||||||
|
{
|
||||||
|
Email = userRaw.Email,
|
||||||
|
UserId = userRaw.UserId,
|
||||||
|
AITokensUsed = userRaw.AITokensUsed,
|
||||||
|
TenantId = userRaw.TenantIdString != null && userRaw.TenantIdString.Length == 36 ? new Guid(userRaw.TenantIdString) : Guid.Empty,
|
||||||
|
Plan = userRaw.Plan,
|
||||||
|
AverageQuizScore = averageQuizScore,
|
||||||
|
DisplayName = userRaw.DisplayName,
|
||||||
|
BooksReadCount = userRaw.BooksReadCount,
|
||||||
|
ThemePreference = userRaw.ThemePreference,
|
||||||
|
ConceptsMappedCount = conceptsMappedCount,
|
||||||
|
LastReadBook = userRaw.LastReadBook,
|
||||||
|
RecentQuizzes = userRaw.QuizResults.OrderByDescending(q => q.CompletedDate).Take(5).Select(q => new QuizResultDto
|
||||||
|
{
|
||||||
|
Id = q.Id,
|
||||||
|
Topic = q.Topic,
|
||||||
|
Score = q.Score,
|
||||||
|
TotalQuestions = q.TotalQuestions,
|
||||||
|
Percentage = q.Percentage,
|
||||||
|
CompletedDate = q.CompletedDate
|
||||||
|
}).ToList(),
|
||||||
|
MappedConcepts = mappedConcepts,
|
||||||
|
Roles = userRaw.Roles
|
||||||
|
};
|
||||||
|
|
||||||
return Result.Ok(profile);
|
return Result.Ok(profile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace NexusReader.Application.Utilities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared utility for parsing numeric segment identifiers from IDs like "seg-42".
|
||||||
|
/// Centralizes the parsing contract to avoid duplication across handlers and UI components.
|
||||||
|
/// </summary>
|
||||||
|
public static class SegmentIdParser
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the numeric portion from a segment identifier string (e.g., "seg-42" → 42).
|
||||||
|
/// Returns 0 if the string is null, empty, or contains no digits.
|
||||||
|
/// </summary>
|
||||||
|
public static int Parse(string? id)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(id)) return 0;
|
||||||
|
var digits = new string(id.Where(char.IsDigit).ToArray());
|
||||||
|
return int.TryParse(digits, out var val) ? val : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
+711
@@ -0,0 +1,711 @@
|
|||||||
|
// <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;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace NexusReader.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260607104453_AddThemePreference")]
|
||||||
|
partial class AddThemePreference
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.7")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
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>("Description")
|
||||||
|
.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<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<int>("ThemePreference")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
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.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);
|
||||||
|
|
||||||
|
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,56 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Pgvector;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace NexusReader.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddThemePreference : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Vector",
|
||||||
|
table: "SemanticKnowledgeCache");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Vector",
|
||||||
|
table: "KnowledgeUnits");
|
||||||
|
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.OldAnnotation("Npgsql:PostgresExtension:vector", ",,");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ThemePreference",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ThemePreference",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.Annotation("Npgsql:PostgresExtension:vector", ",,");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Vector>(
|
||||||
|
name: "Vector",
|
||||||
|
table: "SemanticKnowledgeCache",
|
||||||
|
type: "vector(1536)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Vector>(
|
||||||
|
name: "Vector",
|
||||||
|
table: "KnowledgeUnits",
|
||||||
|
type: "vector(768)",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+865
@@ -0,0 +1,865 @@
|
|||||||
|
// <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;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace NexusReader.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260611183927_AddBookVersioningSupport")]
|
||||||
|
partial class AddBookVersioningSupport
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.7")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
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.Book", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CurrentDraftRevisionId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("LivePublishedRevisionId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
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("CurrentDraftRevisionId");
|
||||||
|
|
||||||
|
b.HasIndex("LivePublishedRevisionId");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("Books");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("BookId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublished")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("PublishedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("VersionString")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.ToTable("BookRevisions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("BookRevisionId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("MarkdownContent")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("BookRevisionId");
|
||||||
|
|
||||||
|
b.ToTable("Chapters");
|
||||||
|
});
|
||||||
|
|
||||||
|
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>("Description")
|
||||||
|
.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<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<int>("ThemePreference")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
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.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.Book", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.BookRevision", "CurrentDraftRevision")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CurrentDraftRevisionId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.BookRevision", "LivePublishedRevision")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LivePublishedRevisionId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("CurrentDraftRevision");
|
||||||
|
|
||||||
|
b.Navigation("LivePublishedRevision");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.Book", "Book")
|
||||||
|
.WithMany("Revisions")
|
||||||
|
.HasForeignKey("BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.BookRevision", "BookRevision")
|
||||||
|
.WithMany("Chapters")
|
||||||
|
.HasForeignKey("BookRevisionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("BookRevision");
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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.Book", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Revisions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Chapters");
|
||||||
|
});
|
||||||
|
|
||||||
|
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,141 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace NexusReader.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddBookVersioningSupport : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "BookRevisions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
BookId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
VersionString = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
IsPublished = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
PublishedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_BookRevisions", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Books",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
|
||||||
|
TenantId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
UserId = table.Column<string>(type: "text", nullable: false),
|
||||||
|
CurrentDraftRevisionId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
LivePublishedRevisionId = table.Column<Guid>(type: "uuid", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Books", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Books_AspNetUsers_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Books_BookRevisions_CurrentDraftRevisionId",
|
||||||
|
column: x => x.CurrentDraftRevisionId,
|
||||||
|
principalTable: "BookRevisions",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Books_BookRevisions_LivePublishedRevisionId",
|
||||||
|
column: x => x.LivePublishedRevisionId,
|
||||||
|
principalTable: "BookRevisions",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Chapters",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
BookRevisionId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
|
||||||
|
MarkdownContent = table.Column<string>(type: "text", nullable: false),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Chapters", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Chapters_BookRevisions_BookRevisionId",
|
||||||
|
column: x => x.BookRevisionId,
|
||||||
|
principalTable: "BookRevisions",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_BookRevisions_BookId",
|
||||||
|
table: "BookRevisions",
|
||||||
|
column: "BookId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Books_CurrentDraftRevisionId",
|
||||||
|
table: "Books",
|
||||||
|
column: "CurrentDraftRevisionId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Books_LivePublishedRevisionId",
|
||||||
|
table: "Books",
|
||||||
|
column: "LivePublishedRevisionId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Books_TenantId",
|
||||||
|
table: "Books",
|
||||||
|
column: "TenantId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Books_UserId",
|
||||||
|
table: "Books",
|
||||||
|
column: "UserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Chapters_BookRevisionId",
|
||||||
|
table: "Chapters",
|
||||||
|
column: "BookRevisionId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_BookRevisions_Books_BookId",
|
||||||
|
table: "BookRevisions",
|
||||||
|
column: "BookId",
|
||||||
|
principalTable: "Books",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_BookRevisions_Books_BookId",
|
||||||
|
table: "BookRevisions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Chapters");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Books");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "BookRevisions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
|
|||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
using NexusReader.Data.Persistence;
|
using NexusReader.Data.Persistence;
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
using Pgvector;
|
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
@@ -21,7 +20,6 @@ namespace NexusReader.Data.Migrations
|
|||||||
.HasAnnotation("ProductVersion", "10.0.7")
|
.HasAnnotation("ProductVersion", "10.0.7")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||||
@@ -174,6 +172,103 @@ namespace NexusReader.Data.Migrations
|
|||||||
b.ToTable("Authors");
|
b.ToTable("Authors");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CurrentDraftRevisionId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("LivePublishedRevisionId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
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("CurrentDraftRevisionId");
|
||||||
|
|
||||||
|
b.HasIndex("LivePublishedRevisionId");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("Books");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("BookId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublished")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("PublishedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("VersionString")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.ToTable("BookRevisions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("BookRevisionId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("MarkdownContent")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("BookRevisionId");
|
||||||
|
|
||||||
|
b.ToTable("Chapters");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -264,9 +359,6 @@ namespace NexusReader.Data.Migrations
|
|||||||
b.Property<int>("Type")
|
b.Property<int>("Type")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<Vector>("Vector")
|
|
||||||
.HasColumnType("vector(768)");
|
|
||||||
|
|
||||||
b.Property<string>("Version")
|
b.Property<string>("Version")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
@@ -388,6 +480,11 @@ namespace NexusReader.Data.Migrations
|
|||||||
.HasMaxLength(128)
|
.HasMaxLength(128)
|
||||||
.HasColumnType("character varying(128)");
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<int>("ThemePreference")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
b.Property<bool>("TwoFactorEnabled")
|
b.Property<bool>("TwoFactorEnabled")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
@@ -480,9 +577,6 @@ namespace NexusReader.Data.Migrations
|
|||||||
.HasMaxLength(128)
|
.HasMaxLength(128)
|
||||||
.HasColumnType("character varying(128)");
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
b.Property<Vector>("Vector")
|
|
||||||
.HasColumnType("vector(1536)");
|
|
||||||
|
|
||||||
b.HasKey("ContentHash");
|
b.HasKey("ContentHash");
|
||||||
|
|
||||||
b.HasIndex("ContentHash")
|
b.HasIndex("ContentHash")
|
||||||
@@ -617,6 +711,53 @@ namespace NexusReader.Data.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.BookRevision", "CurrentDraftRevision")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CurrentDraftRevisionId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.BookRevision", "LivePublishedRevision")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LivePublishedRevisionId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("CurrentDraftRevision");
|
||||||
|
|
||||||
|
b.Navigation("LivePublishedRevision");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.Book", "Book")
|
||||||
|
.WithMany("Revisions")
|
||||||
|
.HasForeignKey("BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.BookRevision", "BookRevision")
|
||||||
|
.WithMany("Chapters")
|
||||||
|
.HasForeignKey("BookRevisionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("BookRevision");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
||||||
@@ -692,6 +833,16 @@ namespace NexusReader.Data.Migrations
|
|||||||
b.Navigation("Ebooks");
|
b.Navigation("Ebooks");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Revisions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Chapters");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("IncomingLinks");
|
b.Navigation("IncomingLinks");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NexusReader.Domain.Entities;
|
using NexusReader.Domain.Entities;
|
||||||
|
using NexusReader.Domain.Enums;
|
||||||
|
|
||||||
namespace NexusReader.Data.Persistence;
|
namespace NexusReader.Data.Persistence;
|
||||||
|
|
||||||
@@ -24,6 +25,9 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
|||||||
public DbSet<QuizResult> QuizResults => Set<QuizResult>();
|
public DbSet<QuizResult> QuizResults => Set<QuizResult>();
|
||||||
public DbSet<SubscriptionPlan> SubscriptionPlans => Set<SubscriptionPlan>();
|
public DbSet<SubscriptionPlan> SubscriptionPlans => Set<SubscriptionPlan>();
|
||||||
public DbSet<Author> Authors => Set<Author>();
|
public DbSet<Author> Authors => Set<Author>();
|
||||||
|
public DbSet<Book> Books => Set<Book>();
|
||||||
|
public DbSet<BookRevision> BookRevisions => Set<BookRevision>();
|
||||||
|
public DbSet<Chapter> Chapters => Set<Chapter>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -43,6 +47,10 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
|||||||
// Note: DefaultValue for int is 1 (which corresponds to 'Free' in our seed)
|
// Note: DefaultValue for int is 1 (which corresponds to 'Free' in our seed)
|
||||||
entity.Property(u => u.SubscriptionPlanId)
|
entity.Property(u => u.SubscriptionPlanId)
|
||||||
.HasDefaultValue(1);
|
.HasDefaultValue(1);
|
||||||
|
|
||||||
|
entity.Property(u => u.ThemePreference)
|
||||||
|
.HasConversion<int>()
|
||||||
|
.HasDefaultValue(ThemeMode.System);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<SubscriptionPlan>(entity =>
|
modelBuilder.Entity<SubscriptionPlan>(entity =>
|
||||||
@@ -55,16 +63,7 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
|||||||
entity.HasKey(e => e.ContentHash);
|
entity.HasKey(e => e.ContentHash);
|
||||||
entity.HasIndex(e => e.ContentHash).IsUnique();
|
entity.HasIndex(e => e.ContentHash).IsUnique();
|
||||||
entity.HasIndex(e => e.TenantId);
|
entity.HasIndex(e => e.TenantId);
|
||||||
if (Database.IsNpgsql())
|
|
||||||
{
|
|
||||||
// Configure vector column (768 dims) and HNSW index for cosine similarity
|
|
||||||
entity.Property(e => e.Embedding).HasColumnType("vector(768)");
|
|
||||||
entity.HasIndex(e => e.Embedding).HasMethod("hnsw").HasOperators("vector_cosine_ops");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
entity.Ignore(e => e.Embedding);
|
entity.Ignore(e => e.Embedding);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<KnowledgeUnit>(entity =>
|
modelBuilder.Entity<KnowledgeUnit>(entity =>
|
||||||
@@ -118,6 +117,48 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
|||||||
entity.HasIndex(e => e.TenantId);
|
entity.HasIndex(e => e.TenantId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Book>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(b => b.Id);
|
||||||
|
entity.HasIndex(b => b.TenantId);
|
||||||
|
entity.HasIndex(b => b.UserId);
|
||||||
|
|
||||||
|
entity.HasOne(e => e.User)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(e => e.UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasMany(b => b.Revisions)
|
||||||
|
.WithOne(r => r.Book)
|
||||||
|
.HasForeignKey(r => r.BookId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasOne(b => b.CurrentDraftRevision)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(b => b.CurrentDraftRevisionId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
entity.HasOne(b => b.LivePublishedRevision)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(b => b.LivePublishedRevisionId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<BookRevision>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(r => r.Id);
|
||||||
|
|
||||||
|
entity.HasMany(r => r.Chapters)
|
||||||
|
.WithOne(c => c.BookRevision)
|
||||||
|
.HasForeignKey(c => c.BookRevisionId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Chapter>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(c => c.Id);
|
||||||
|
});
|
||||||
|
|
||||||
// Seed Subscription Plans with deterministic IDs
|
// Seed Subscription Plans with deterministic IDs
|
||||||
modelBuilder.Entity<SubscriptionPlan>().HasData(
|
modelBuilder.Entity<SubscriptionPlan>().HasData(
|
||||||
new SubscriptionPlan { Id = 1, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, IsUnlimitedTokens = false, MonthlyPrice = 0m, StripeProductId = "prod_Free789" },
|
new SubscriptionPlan { Id = 1, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, IsUnlimitedTokens = false, MonthlyPrice = 0m, StripeProductId = "prod_Free789" },
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Design;
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Pgvector.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace NexusReader.Data.Persistence;
|
namespace NexusReader.Data.Persistence;
|
||||||
|
|
||||||
@@ -38,7 +37,7 @@ public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
|
|||||||
connectionString = "Host=localhost;Database=nexus_reader;Username=postgres;Password=postgres";
|
connectionString = "Host=localhost;Database=nexus_reader;Username=postgres;Password=postgres";
|
||||||
}
|
}
|
||||||
|
|
||||||
optionsBuilder.UseNpgsql(connectionString, x => x.UseVector());
|
optionsBuilder.UseNpgsql(connectionString);
|
||||||
|
|
||||||
return new AppDbContext(optionsBuilder.Options);
|
return new AppDbContext(optionsBuilder.Options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using NexusReader.Domain.Entities;
|
using NexusReader.Domain.Entities;
|
||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -16,6 +17,7 @@ public static class DbInitializer
|
|||||||
using var scope = serviceProvider.CreateScope();
|
using var scope = serviceProvider.CreateScope();
|
||||||
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<NexusUser>>();
|
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<NexusUser>>();
|
||||||
var dbContextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
|
var dbContextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
|
||||||
|
var configuration = scope.ServiceProvider.GetService<IConfiguration>();
|
||||||
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -68,7 +70,31 @@ public static class DbInitializer
|
|||||||
SecurityStamp = Guid.NewGuid().ToString()
|
SecurityStamp = Guid.NewGuid().ToString()
|
||||||
};
|
};
|
||||||
|
|
||||||
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, "Admin123!");
|
var adminPassword = configuration?["Nexus:AdminPassword"]
|
||||||
|
?? configuration?["NEXUS_ADMIN_PASSWORD"]
|
||||||
|
?? Environment.GetEnvironmentVariable("NEXUS_ADMIN_PASSWORD");
|
||||||
|
|
||||||
|
var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")
|
||||||
|
?? Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT")
|
||||||
|
?? "Development";
|
||||||
|
var isDevelopment = string.Equals(env, "Development", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(adminPassword))
|
||||||
|
{
|
||||||
|
if (!isDevelopment)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"CRITICAL SECURITY ERROR: Admin password is NOT configured! " +
|
||||||
|
"In non-Development environments (e.g. Test/Production), the admin password must be explicitly set " +
|
||||||
|
"via configuration ('Nexus:AdminPassword' or 'NEXUS_ADMIN_PASSWORD') or environment variables. " +
|
||||||
|
"Seeding aborted to prevent insecure credentials fallback.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine("[Seeder] WARNING: Admin password is not set. Falling back to default weak password 'Admin123!' in Development environment.");
|
||||||
|
adminPassword = "Admin123!";
|
||||||
|
}
|
||||||
|
|
||||||
|
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, adminPassword);
|
||||||
|
|
||||||
dbContext.Users.Add(adminUser);
|
dbContext.Users.Add(adminUser);
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
@@ -110,6 +136,72 @@ public static class DbInitializer
|
|||||||
{
|
{
|
||||||
Console.WriteLine("[Seeder] Admin user already exists.");
|
Console.WriteLine("[Seeder] Admin user already exists.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seed Sample Authored Book for Creator Dashboard
|
||||||
|
var activeAdmin = await dbContext.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail);
|
||||||
|
if (activeAdmin != null)
|
||||||
|
{
|
||||||
|
if (!dbContext.Books.Any(b => b.UserId == activeAdmin.Id))
|
||||||
|
{
|
||||||
|
var sampleBookId = Guid.NewGuid();
|
||||||
|
var sampleBook = new Book
|
||||||
|
{
|
||||||
|
Id = sampleBookId,
|
||||||
|
Title = "Przewodnik po platformie Nexus",
|
||||||
|
UserId = activeAdmin.Id,
|
||||||
|
TenantId = activeAdmin.TenantId ?? "global"
|
||||||
|
};
|
||||||
|
dbContext.Books.Add(sampleBook);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var sampleRevisionId = Guid.NewGuid();
|
||||||
|
var sampleRevision = new BookRevision
|
||||||
|
{
|
||||||
|
Id = sampleRevisionId,
|
||||||
|
BookId = sampleBookId,
|
||||||
|
VersionString = "Working Draft",
|
||||||
|
IsPublished = false,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
dbContext.BookRevisions.Add(sampleRevision);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var sampleChapter1 = new Chapter
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
BookRevisionId = sampleRevisionId,
|
||||||
|
Title = "Rozdział 1: Wprowadzenie do Zen Mode",
|
||||||
|
MarkdownContent = @"# Zen Mode Editor
|
||||||
|
|
||||||
|
Welcome to your dedicated workspace. This premium panel supports Notion-like WYSIWYG editing.
|
||||||
|
|
||||||
|
## Features:
|
||||||
|
- **Zero Distraction**: Simple elevation and border framing.
|
||||||
|
- **GFM Tables**: Consistent cell padding and hover striping.
|
||||||
|
- **Clean Code Blocks**: Pre-rendered base64 font-loaded code-preview blocks.",
|
||||||
|
SortOrder = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
var sampleChapter2 = new Chapter
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
BookRevisionId = sampleRevisionId,
|
||||||
|
Title = "Rozdział 2: Zabezpieczenia i XSS",
|
||||||
|
MarkdownContent = @"# Security Overview
|
||||||
|
|
||||||
|
This module provides Magic Number image signature checking and HtmlSanitizer filters.",
|
||||||
|
SortOrder = 2
|
||||||
|
};
|
||||||
|
|
||||||
|
dbContext.Chapters.Add(sampleChapter1);
|
||||||
|
dbContext.Chapters.Add(sampleChapter2);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
sampleBook.CurrentDraftRevisionId = sampleRevisionId;
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
Console.WriteLine("[Seeder] Sample authored book and chapters seeded for admin.");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace NexusReader.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a Book metadata entry that references its decoupled revisions.
|
||||||
|
/// </summary>
|
||||||
|
public class Book
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(255)]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string TenantId { get; set; } = "global";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[ForeignKey(nameof(UserId))]
|
||||||
|
public virtual NexusUser? User { get; set; }
|
||||||
|
|
||||||
|
public Guid? CurrentDraftRevisionId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(CurrentDraftRevisionId))]
|
||||||
|
public virtual BookRevision? CurrentDraftRevision { get; set; }
|
||||||
|
|
||||||
|
public Guid? LivePublishedRevisionId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(LivePublishedRevisionId))]
|
||||||
|
public virtual BookRevision? LivePublishedRevision { get; set; }
|
||||||
|
|
||||||
|
public virtual ICollection<BookRevision> Revisions { get; set; } = new List<BookRevision>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace NexusReader.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encapsulates a snapshot or draft version of a Book's chapters.
|
||||||
|
/// </summary>
|
||||||
|
public class BookRevision
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public Guid BookId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(BookId))]
|
||||||
|
public virtual Book Book { get; set; } = null!;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string VersionString { get; set; } = "Working Draft";
|
||||||
|
|
||||||
|
public bool IsPublished { get; set; } = false;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public DateTime? PublishedAt { get; set; }
|
||||||
|
|
||||||
|
public virtual ICollection<Chapter> Chapters { get; set; } = new List<Chapter>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace NexusReader.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a chapter belonging strictly to a specific BookRevision.
|
||||||
|
/// </summary>
|
||||||
|
public class Chapter
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public Guid BookRevisionId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(BookRevisionId))]
|
||||||
|
public virtual BookRevision BookRevision { get; set; } = null!;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(255)]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string MarkdownContent { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using NexusReader.Domain.Enums;
|
||||||
|
|
||||||
namespace NexusReader.Domain.Entities;
|
namespace NexusReader.Domain.Entities;
|
||||||
|
|
||||||
@@ -65,4 +66,9 @@ public class NexusUser : IdentityUser
|
|||||||
/// Last read timestamp.
|
/// Last read timestamp.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime? LastReadAt { get; set; }
|
public DateTime? LastReadAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User's visual theme preference.
|
||||||
|
/// </summary>
|
||||||
|
public ThemeMode ThemePreference { get; set; } = ThemeMode.System;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace NexusReader.Domain.Enums;
|
||||||
|
|
||||||
|
public enum ThemeMode
|
||||||
|
{
|
||||||
|
System = 0,
|
||||||
|
Dark = 1,
|
||||||
|
LightSepia = 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace NexusReader.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom domain exception thrown when a Book cannot be found by its ID.
|
||||||
|
/// </summary>
|
||||||
|
public class BookNotFoundException : Exception
|
||||||
|
{
|
||||||
|
public BookNotFoundException(Guid bookId)
|
||||||
|
: base($"Book with ID '{bookId}' was not found.")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace NexusReader.Infrastructure.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Settings for configuring allowed tags, attributes, CSS properties, and schemes in HtmlSanitizerService.
|
||||||
|
/// </summary>
|
||||||
|
public class HtmlSanitizerSettings
|
||||||
|
{
|
||||||
|
public const string SectionName = "HtmlSanitizer";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of HTML tags that are allowed.
|
||||||
|
/// If null or empty, the default allowed tags list is used.
|
||||||
|
/// </summary>
|
||||||
|
public List<string>? AllowedTags { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of HTML attributes that are allowed.
|
||||||
|
/// If null or empty, the default allowed attributes list is used.
|
||||||
|
/// </summary>
|
||||||
|
public List<string>? AllowedAttributes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of CSS properties that are allowed.
|
||||||
|
/// If null or empty, the default allowed CSS properties list is used.
|
||||||
|
/// </summary>
|
||||||
|
public List<string>? AllowedCssProperties { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of URI schemes that are allowed (e.g. "http", "https").
|
||||||
|
/// If null or empty, the default allowed schemes list is used.
|
||||||
|
/// </summary>
|
||||||
|
public List<string>? AllowedSchemes { get; set; }
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
|
using NexusReader.Application.Common;
|
||||||
using GeminiDotnet;
|
using GeminiDotnet;
|
||||||
using GeminiDotnet.Extensions.AI;
|
using GeminiDotnet.Extensions.AI;
|
||||||
using NexusReader.Data.Persistence;
|
using NexusReader.Data.Persistence;
|
||||||
@@ -54,11 +55,24 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
// Qdrant Client registration
|
// Qdrant Client registration
|
||||||
var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334";
|
var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334";
|
||||||
services.AddSingleton<QdrantClient>(sp => new QdrantClient(new Uri(qdrantUrl)));
|
var qdrantApiKey = configuration["Qdrant:ApiKey"];
|
||||||
|
services.AddSingleton<QdrantClient>(sp =>
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(qdrantApiKey))
|
||||||
|
{
|
||||||
|
return new QdrantClient(new Uri(qdrantUrl), apiKey: qdrantApiKey);
|
||||||
|
}
|
||||||
|
return new QdrantClient(new Uri(qdrantUrl));
|
||||||
|
});
|
||||||
|
|
||||||
// Neo4j Driver registration
|
// Neo4j Driver registration (supports optional authentication)
|
||||||
var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687";
|
var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687";
|
||||||
services.AddSingleton<IDriver>(sp => GraphDatabase.Driver(neo4jUrl, AuthTokens.None));
|
var neo4jUser = configuration["Neo4j:Username"];
|
||||||
|
var neo4jPass = configuration["Neo4j:Password"];
|
||||||
|
var neo4jAuth = !string.IsNullOrEmpty(neo4jUser)
|
||||||
|
? AuthTokens.Basic(neo4jUser, neo4jPass ?? string.Empty)
|
||||||
|
: AuthTokens.None;
|
||||||
|
services.AddSingleton<IDriver>(sp => GraphDatabase.Driver(neo4jUrl, neo4jAuth));
|
||||||
|
|
||||||
// Hangfire registration
|
// Hangfire registration
|
||||||
if (!string.IsNullOrEmpty(pgConnectionString))
|
if (!string.IsNullOrEmpty(pgConnectionString))
|
||||||
@@ -71,6 +85,8 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName));
|
services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName));
|
||||||
services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.SectionName));
|
services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.SectionName));
|
||||||
|
services.Configure<RagMonetizationOptions>(configuration.GetSection(RagMonetizationOptions.SectionName));
|
||||||
|
services.Configure<HtmlSanitizerSettings>(configuration.GetSection(HtmlSanitizerSettings.SectionName));
|
||||||
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
|
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
|
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
|
||||||
@@ -112,13 +128,21 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IKnowledgeService, KnowledgeService>();
|
services.AddScoped<IKnowledgeService, KnowledgeService>();
|
||||||
services.AddTransient<IEpubReader, EpubReaderService>();
|
services.AddTransient<IEpubReader, EpubReaderService>();
|
||||||
services.AddTransient<IEpubMetadataExtractor, EpubMetadataExtractor>();
|
services.AddTransient<IEpubMetadataExtractor, EpubMetadataExtractor>();
|
||||||
|
services.AddTransient<IEpubExtractor, EpubExtractor>();
|
||||||
|
|
||||||
// Fix #4: Scoped instead of Singleton — BookStorageService uses path resolution
|
// Fix #4: Scoped instead of Singleton — BookStorageService uses path resolution
|
||||||
// that is environment-specific and incompatible with Singleton lifetime in MAUI.
|
// that is environment-specific and incompatible with Singleton lifetime in MAUI.
|
||||||
services.AddScoped<IBookStorageService, BookStorageService>();
|
services.AddScoped<IBookStorageService, BookStorageService>();
|
||||||
|
services.AddScoped<IStorageService, LocalStorageService>();
|
||||||
|
services.AddSingleton<ISanitizerService, HtmlSanitizerService>();
|
||||||
|
|
||||||
// Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
|
// Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
|
||||||
services.AddScoped<IEbookRepository, EbookRepository>();
|
services.AddScoped<IEbookRepository, EbookRepository>();
|
||||||
|
services.AddScoped<IQuizResultRepository, QuizResultRepository>();
|
||||||
|
services.AddScoped<IConceptsMapReadRepository, ConceptsMapReadRepository>();
|
||||||
|
services.AddScoped<IUserLibraryStore, UserLibraryStore>();
|
||||||
|
services.AddScoped<IUserReadingStateStore, UserReadingStateStore>();
|
||||||
|
services.AddScoped<IVectorSearchStore, VectorSearchStore>();
|
||||||
|
|
||||||
// Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper)
|
// Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper)
|
||||||
services.AddScoped<ISyncBroadcaster, SignalRSyncBroadcaster>();
|
services.AddScoped<ISyncBroadcaster, SignalRSyncBroadcaster>();
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||||
<PackageReference Include="Polly" />
|
<PackageReference Include="Polly" />
|
||||||
<PackageReference Include="Polly.Extensions.Http" />
|
<PackageReference Include="Polly.Extensions.Http" />
|
||||||
|
<PackageReference Include="HtmlSanitizer" />
|
||||||
|
<PackageReference Include="Markdig" />
|
||||||
<PackageReference Include="Qdrant.Client" />
|
<PackageReference Include="Qdrant.Client" />
|
||||||
<PackageReference Include="Stripe.net" />
|
<PackageReference Include="Stripe.net" />
|
||||||
<PackageReference Include="VersOne.Epub" />
|
<PackageReference Include="VersOne.Epub" />
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NexusReader.Application.Abstractions.Persistence;
|
||||||
|
using NexusReader.Data.Persistence;
|
||||||
|
using NexusReader.Domain.Entities;
|
||||||
|
|
||||||
|
namespace NexusReader.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EF Core implementation of <see cref="IConceptsMapReadRepository"/>.
|
||||||
|
/// Uses <see cref="IDbContextFactory{TContext}"/> for Blazor-safe scoped context creation.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ConceptsMapReadRepository : IConceptsMapReadRepository
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||||
|
|
||||||
|
public ConceptsMapReadRepository(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||||
|
{
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<string?> GetLastReadPageIdAsync(string userId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var user = await dbContext.Users
|
||||||
|
.Where(u => u.Id == userId)
|
||||||
|
.Select(u => new { u.LastReadPageId })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
return user?.LastReadPageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<List<KnowledgeUnit>> GetKnowledgeUnitsForBookAsync(
|
||||||
|
Guid bookId,
|
||||||
|
string tenantId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
return await dbContext.KnowledgeUnits
|
||||||
|
.Where(k => k.EbookId == bookId &&
|
||||||
|
(k.TenantId == tenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId)))
|
||||||
|
.OrderBy(k => k.CreatedAt)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,12 @@ internal sealed class EbookRepository : IEbookRepository
|
|||||||
_context.Ebooks.Add(ebook);
|
_context.Ebooks.Add(ebook);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Ebook?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.Ebooks.FindAsync(new object[] { id }, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
=> _context.SaveChangesAsync(cancellationToken);
|
=> _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NexusReader.Application.Abstractions.Persistence;
|
||||||
|
using NexusReader.Data.Persistence;
|
||||||
|
using NexusReader.Domain.Entities;
|
||||||
|
|
||||||
|
namespace NexusReader.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EF Core implementation of <see cref="IQuizResultRepository"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class QuizResultRepository : IQuizResultRepository
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _context;
|
||||||
|
|
||||||
|
public QuizResultRepository(AppDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<NexusUser?> FindUserByIdAsync(string userId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void AddQuizResult(QuizResult quizResult)
|
||||||
|
{
|
||||||
|
_context.QuizResults.Add(quizResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NexusReader.Application.Abstractions.Persistence;
|
||||||
|
using NexusReader.Data.Persistence;
|
||||||
|
|
||||||
|
namespace NexusReader.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EF Core implementation of <see cref="IUserLibraryStore"/> using <see cref="AppDbContext"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class UserLibraryStore : IUserLibraryStore
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _context;
|
||||||
|
|
||||||
|
public UserLibraryStore(AppDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<List<Guid>> GetOwnedBookIdsAsync(string userId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.Ebooks
|
||||||
|
.Where(e => e.UserId == userId)
|
||||||
|
.Select(e => e.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Dictionary<Guid, string>> GetBookTitlesAsync(List<Guid> bookIds, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (bookIds == null || !bookIds.Any())
|
||||||
|
{
|
||||||
|
return new Dictionary<Guid, string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _context.Ebooks
|
||||||
|
.Where(e => bookIds.Contains(e.Id))
|
||||||
|
.ToDictionaryAsync(e => e.Id, e => e.Title, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NexusReader.Application.Abstractions.Persistence;
|
||||||
|
using NexusReader.Data.Persistence;
|
||||||
|
|
||||||
|
namespace NexusReader.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EF Core implementation of <see cref="IUserReadingStateStore"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class UserReadingStateStore : IUserReadingStateStore
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||||
|
|
||||||
|
public UserReadingStateStore(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||||
|
{
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<(Guid? EbookId, string? ChapterId, string? TenantId)> GetActiveReadingStateAsync(string userId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var userState = await dbContext.Users
|
||||||
|
.Where(u => u.Id == userId)
|
||||||
|
.Select(u => new
|
||||||
|
{
|
||||||
|
u.TenantId,
|
||||||
|
u.LastReadPageId,
|
||||||
|
LastReadBookId = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => (Guid?)e.Id).FirstOrDefault()
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (userState == null)
|
||||||
|
{
|
||||||
|
return (null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (userState.LastReadBookId, userState.LastReadPageId, userState.TenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<string?> GetChapterContentAsync(string chapterId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
return await dbContext.KnowledgeUnits
|
||||||
|
.Where(ku => ku.Id == chapterId)
|
||||||
|
.Select(ku => ku.Content)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Qdrant.Client;
|
||||||
|
using Qdrant.Client.Grpc;
|
||||||
|
using Polly;
|
||||||
|
using Polly.Registry;
|
||||||
|
using NexusReader.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
namespace NexusReader.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Infrastructure implementation of <see cref="IVectorSearchStore"/> utilizing <see cref="QdrantClient"/>
|
||||||
|
/// and <see cref="IEmbeddingGenerator{TInput, TEmbedding}"/> to execute semantic vector queries.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class VectorSearchStore : IVectorSearchStore
|
||||||
|
{
|
||||||
|
private readonly QdrantClient _qdrantClient;
|
||||||
|
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
|
||||||
|
private readonly ResiliencePipeline _retryPipeline;
|
||||||
|
private readonly ILogger<VectorSearchStore> _logger;
|
||||||
|
|
||||||
|
public VectorSearchStore(
|
||||||
|
QdrantClient qdrantClient,
|
||||||
|
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator,
|
||||||
|
ResiliencePipelineProvider<string> pipelineProvider,
|
||||||
|
ILogger<VectorSearchStore> logger)
|
||||||
|
{
|
||||||
|
_qdrantClient = qdrantClient;
|
||||||
|
_embeddingGenerator = embeddingGenerator;
|
||||||
|
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<List<VectorChunk>> SearchGlobalAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var queryVector = await GenerateEmbeddingAsync(queryText, cancellationToken);
|
||||||
|
var filter = BuildTenantFilter(tenantId);
|
||||||
|
|
||||||
|
return await ExecuteSearchAsync(queryVector, filter, limit, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<List<VectorChunk>> SearchLocalAsync(string queryText, string tenantId, List<Guid> whitelistedBookIds, int limit, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (whitelistedBookIds == null || !whitelistedBookIds.Any())
|
||||||
|
{
|
||||||
|
return new List<VectorChunk>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var queryVector = await GenerateEmbeddingAsync(queryText, cancellationToken);
|
||||||
|
var filter = BuildTenantFilter(tenantId);
|
||||||
|
|
||||||
|
var whitelistFilter = new Qdrant.Client.Grpc.Filter();
|
||||||
|
foreach (var bookId in whitelistedBookIds)
|
||||||
|
{
|
||||||
|
whitelistFilter.Should.Add(new Qdrant.Client.Grpc.Condition
|
||||||
|
{
|
||||||
|
Field = new Qdrant.Client.Grpc.FieldCondition
|
||||||
|
{
|
||||||
|
Key = "ebookId",
|
||||||
|
Match = new Qdrant.Client.Grpc.Match { Text = bookId.ToString() }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
filter.Must.Add(new Qdrant.Client.Grpc.Condition { Filter = whitelistFilter });
|
||||||
|
|
||||||
|
return await ExecuteSearchAsync(queryVector, filter, limit, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<List<VectorChunk>> SearchGlobalExcludeAsync(string queryText, string tenantId, Guid excludeBookId, int limit, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var queryVector = await GenerateEmbeddingAsync(queryText, cancellationToken);
|
||||||
|
var filter = BuildTenantFilter(tenantId);
|
||||||
|
|
||||||
|
// Exclude current book
|
||||||
|
filter.MustNot.Add(new Qdrant.Client.Grpc.Condition
|
||||||
|
{
|
||||||
|
Field = new Qdrant.Client.Grpc.FieldCondition
|
||||||
|
{
|
||||||
|
Key = "ebookId",
|
||||||
|
Match = new Qdrant.Client.Grpc.Match { Text = excludeBookId.ToString() }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return await ExecuteSearchAsync(queryVector, filter, limit, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<float[]> GenerateEmbeddingAsync(string text, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("[VectorSearchStore] Attempted to generate embedding from empty text. Returning zero vector.");
|
||||||
|
return Array.Empty<float>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
var response = await _retryPipeline.ExecuteAsync(async ct =>
|
||||||
|
await _embeddingGenerator.GenerateAsync(
|
||||||
|
new[] { text },
|
||||||
|
new EmbeddingGenerationOptions { Dimensions = 768 },
|
||||||
|
cancellationToken: ct), cancellationToken);
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
|
_logger.LogDebug("[VectorSearchStore] Embedding generated in {ElapsedMs}ms for text of {Length} chars.", sw.ElapsedMilliseconds, text.Length);
|
||||||
|
return response.First().Vector.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Qdrant.Client.Grpc.Filter BuildTenantFilter(string tenantId)
|
||||||
|
{
|
||||||
|
var filter = new Qdrant.Client.Grpc.Filter();
|
||||||
|
var tenantFilter = new Qdrant.Client.Grpc.Filter();
|
||||||
|
|
||||||
|
tenantFilter.Should.Add(new Qdrant.Client.Grpc.Condition
|
||||||
|
{
|
||||||
|
Field = new Qdrant.Client.Grpc.FieldCondition
|
||||||
|
{
|
||||||
|
Key = "tenantId",
|
||||||
|
Match = new Qdrant.Client.Grpc.Match { Text = tenantId }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tenantFilter.Should.Add(new Qdrant.Client.Grpc.Condition
|
||||||
|
{
|
||||||
|
Field = new Qdrant.Client.Grpc.FieldCondition
|
||||||
|
{
|
||||||
|
Key = "tenantId",
|
||||||
|
Match = new Qdrant.Client.Grpc.Match { Text = "global" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
filter.Must.Add(new Qdrant.Client.Grpc.Condition { Filter = tenantFilter });
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<VectorChunk>> ExecuteSearchAsync(float[] queryVector, Qdrant.Client.Grpc.Filter filter, int limit, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (queryVector.Length == 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("[VectorSearchStore] Empty query vector — skipping Qdrant search.");
|
||||||
|
return new List<VectorChunk>();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
|
||||||
|
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
var response = await _qdrantClient.SearchAsync(
|
||||||
|
collectionName: "knowledge_units",
|
||||||
|
vector: queryVector,
|
||||||
|
filter: filter,
|
||||||
|
limit: (ulong)limit,
|
||||||
|
cancellationToken: cancellationToken
|
||||||
|
);
|
||||||
|
sw.Stop();
|
||||||
|
_logger.LogInformation("[VectorSearchStore] Qdrant search returned {Count} results in {ElapsedMs}ms.", response.Count, sw.ElapsedMilliseconds);
|
||||||
|
|
||||||
|
return response.Select(point =>
|
||||||
|
{
|
||||||
|
var content = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty;
|
||||||
|
var ebookId = point.Payload.TryGetValue("ebookId", out var ev) ? ev.StringValue : string.Empty;
|
||||||
|
var metadataJson = point.Payload.TryGetValue("metadataJson", out var mv) ? mv.StringValue : string.Empty;
|
||||||
|
var bookTitle = point.Payload.TryGetValue("bookTitle", out var btv) ? btv.StringValue : string.Empty;
|
||||||
|
var chapterTitle = point.Payload.TryGetValue("chapterTitle", out var ctv) ? ctv.StringValue : string.Empty;
|
||||||
|
|
||||||
|
return new VectorChunk(content, ebookId, point.Score, metadataJson, bookTitle, chapterTitle);
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[VectorSearchStore] Qdrant search execution failed.");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureCollectionExistsAsync(string collectionName, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken);
|
||||||
|
if (!exists)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[VectorSearchStore] Collection '{CollectionName}' does not exist — creating.", collectionName);
|
||||||
|
await _qdrantClient.CreateCollectionAsync(
|
||||||
|
collectionName: collectionName,
|
||||||
|
vectorsConfig: new Qdrant.Client.Grpc.VectorParams
|
||||||
|
{
|
||||||
|
Size = 768,
|
||||||
|
Distance = Distance.Cosine
|
||||||
|
},
|
||||||
|
cancellationToken: cancellationToken
|
||||||
|
);
|
||||||
|
_logger.LogInformation("[VectorSearchStore] Collection '{CollectionName}' created successfully.", collectionName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Log concurrent creation conflicts (e.g., AlreadyExists gRPC status) but do not propagate.
|
||||||
|
_logger.LogWarning(ex, "[VectorSearchStore] Non-fatal error while ensuring collection '{CollectionName}' exists. Possible concurrent creation.", collectionName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FluentResults;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using NexusReader.Application.Abstractions.Persistence;
|
||||||
|
using NexusReader.Application.Queries.Recommendations;
|
||||||
|
|
||||||
|
namespace NexusReader.Infrastructure.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles <see cref="GetContextualRecommendationsQuery"/> by discovering the active reading state,
|
||||||
|
/// performing semantic search using <see cref="IVectorSearchStore"/> with book exclusion, and mapping upsells.
|
||||||
|
/// </summary>
|
||||||
|
public class GetContextualRecommendationsQueryHandler : IRequestHandler<GetContextualRecommendationsQuery, Result<ContextualRecommendationResponse>>
|
||||||
|
{
|
||||||
|
private readonly IUserReadingStateStore _readingStateStore;
|
||||||
|
private readonly IUserLibraryStore _libraryStore;
|
||||||
|
private readonly IVectorSearchStore _vectorSearchStore;
|
||||||
|
private readonly ILogger<GetContextualRecommendationsQueryHandler> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of <see cref="GetContextualRecommendationsQueryHandler"/>.
|
||||||
|
/// </summary>
|
||||||
|
public GetContextualRecommendationsQueryHandler(
|
||||||
|
IUserReadingStateStore readingStateStore,
|
||||||
|
IUserLibraryStore libraryStore,
|
||||||
|
IVectorSearchStore vectorSearchStore,
|
||||||
|
ILogger<GetContextualRecommendationsQueryHandler> logger)
|
||||||
|
{
|
||||||
|
_readingStateStore = readingStateStore;
|
||||||
|
_libraryStore = libraryStore;
|
||||||
|
_vectorSearchStore = vectorSearchStore;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<ContextualRecommendationResponse>> Handle(GetContextualRecommendationsQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(request.UserId))
|
||||||
|
{
|
||||||
|
return Result.Fail("UserId cannot be empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Step 1: Discover active reading state
|
||||||
|
var (ebookId, chapterId, tenantId) = await _readingStateStore.GetActiveReadingStateAsync(request.UserId, cancellationToken);
|
||||||
|
if (ebookId == null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[Recommendations] No active reading state for user {UserId}. Returning empty list.", request.UserId);
|
||||||
|
return Result.Ok(new ContextualRecommendationResponse(new List<RecommendationDto>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Fetch specific content associated with active ChapterId
|
||||||
|
string? chapterContent = null;
|
||||||
|
if (!string.IsNullOrEmpty(chapterId))
|
||||||
|
{
|
||||||
|
chapterContent = await _readingStateStore.GetChapterContentAsync(chapterId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard: empty chapter content cannot produce a meaningful embedding
|
||||||
|
if (string.IsNullOrWhiteSpace(chapterContent))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("[Recommendations] Chapter content is empty for chapterId={ChapterId}. Returning empty list.", chapterId);
|
||||||
|
return Result.Ok(new ContextualRecommendationResponse(new List<RecommendationDto>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Perform similarity search using IVectorSearchStore
|
||||||
|
var resolvedTenantId = tenantId ?? "global";
|
||||||
|
_logger.LogDebug("[Recommendations] Performing vector search for user {UserId}, book {EbookId}, tenant {TenantId}.", request.UserId, ebookId, resolvedTenantId);
|
||||||
|
|
||||||
|
var searchResults = await _vectorSearchStore.SearchGlobalExcludeAsync(
|
||||||
|
chapterContent,
|
||||||
|
resolvedTenantId,
|
||||||
|
ebookId.Value,
|
||||||
|
limit: 2,
|
||||||
|
cancellationToken: cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 4: Process recommendations and cross-reference owned books
|
||||||
|
var ownedBookIds = await _libraryStore.GetOwnedBookIdsAsync(request.UserId, cancellationToken);
|
||||||
|
var recommendations = new List<RecommendationDto>();
|
||||||
|
|
||||||
|
foreach (var point in searchResults)
|
||||||
|
{
|
||||||
|
var targetEbookIdStr = point.EbookId;
|
||||||
|
if (!Guid.TryParse(targetEbookIdStr, out var targetEbookId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Load bookTitle from point
|
||||||
|
var bookTitle = point.BookTitle;
|
||||||
|
if (string.IsNullOrEmpty(bookTitle))
|
||||||
|
{
|
||||||
|
bookTitle = "Nieznana książka";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load chapterTitle from point or metadataJson
|
||||||
|
var chapterTitle = point.ChapterTitle;
|
||||||
|
if (string.IsNullOrEmpty(chapterTitle))
|
||||||
|
{
|
||||||
|
chapterTitle = "Wiedza z rozdziału";
|
||||||
|
if (!string.IsNullOrEmpty(point.MetadataJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(point.MetadataJson);
|
||||||
|
if (doc.RootElement.TryGetProperty("label", out var labelProp))
|
||||||
|
{
|
||||||
|
chapterTitle = labelProp.GetString() ?? chapterTitle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException jsonEx)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(jsonEx, "[Recommendations] Failed to parse metadataJson for chunk with ebookId={EbookId}.", targetEbookIdStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPremiumUpsell = !ownedBookIds.Contains(targetEbookId);
|
||||||
|
var matchPercentage = (int)Math.Round(point.Score * 100);
|
||||||
|
|
||||||
|
recommendations.Add(new RecommendationDto(
|
||||||
|
BookTitle: bookTitle,
|
||||||
|
ChapterTitle: chapterTitle,
|
||||||
|
MatchPercentage: matchPercentage,
|
||||||
|
IsPremiumUpsell: isPremiumUpsell,
|
||||||
|
TargetBookId: targetEbookId
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("[Recommendations] Returning {Count} recommendations for user {UserId}.", recommendations.Count, request.UserId);
|
||||||
|
return Result.Ok(new ContextualRecommendationResponse(recommendations));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[Recommendations] Downstream vector database or state query failed for user {UserId}.", request.UserId);
|
||||||
|
return Result.Fail(new Error("Downstream vector database or state query failed.").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ public class BookStorageService : IBookStorageService
|
|||||||
var uploadsFolder = Path.Combine(_environment.WebRootPath, "uploads");
|
var uploadsFolder = Path.Combine(_environment.WebRootPath, "uploads");
|
||||||
EnsureDirectoryExists(uploadsFolder);
|
EnsureDirectoryExists(uploadsFolder);
|
||||||
|
|
||||||
|
fileName = SanitizeFileName(fileName);
|
||||||
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
|
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
|
||||||
var filePath = Path.Combine(uploadsFolder, uniqueFileName);
|
var filePath = Path.Combine(uploadsFolder, uniqueFileName);
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ public class BookStorageService : IBookStorageService
|
|||||||
var coversFolder = Path.Combine(_environment.WebRootPath, "covers");
|
var coversFolder = Path.Combine(_environment.WebRootPath, "covers");
|
||||||
EnsureDirectoryExists(coversFolder);
|
EnsureDirectoryExists(coversFolder);
|
||||||
|
|
||||||
|
fileName = SanitizeFileName(fileName);
|
||||||
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
|
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
|
||||||
var filePath = Path.Combine(coversFolder, uniqueFileName);
|
var filePath = Path.Combine(coversFolder, uniqueFileName);
|
||||||
|
|
||||||
@@ -63,6 +65,25 @@ public class BookStorageService : IBookStorageService
|
|||||||
return $"covers/{uniqueFileName}";
|
return $"covers/{uniqueFileName}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string SanitizeFileName(string fileName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(fileName)) return fileName;
|
||||||
|
|
||||||
|
var sanitized = fileName
|
||||||
|
.Replace('\u00A0', ' ')
|
||||||
|
.Replace('\u2007', ' ')
|
||||||
|
.Replace('\u200B', ' ')
|
||||||
|
.Replace('\u202F', ' ');
|
||||||
|
|
||||||
|
var invalidChars = Path.GetInvalidFileNameChars();
|
||||||
|
foreach (var c in invalidChars)
|
||||||
|
{
|
||||||
|
sanitized = sanitized.Replace(c, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
private void EnsureDirectoryExists(string path)
|
private void EnsureDirectoryExists(string path)
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(path))
|
if (!Directory.Exists(path))
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using FluentResults;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
using VersOne.Epub;
|
||||||
|
|
||||||
|
namespace NexusReader.Infrastructure.Services;
|
||||||
|
|
||||||
|
public class EpubExtractor : IEpubExtractor
|
||||||
|
{
|
||||||
|
private readonly ILogger<EpubExtractor> _logger;
|
||||||
|
|
||||||
|
public EpubExtractor(ILogger<EpubExtractor> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<List<string>>> ExtractChaptersTextAsync(string relativePath, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fullPath = ResolvePath(relativePath);
|
||||||
|
if (string.IsNullOrEmpty(fullPath) || !File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
_logger.LogError("[EpubExtractor] EPUB file not found at path: {FilePath}", relativePath);
|
||||||
|
return Result.Fail<List<string>>($"Plik EPUB nie został znaleziony na dysku: {relativePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var bookRef = await EpubReader.OpenBookAsync(fullPath);
|
||||||
|
var readingOrder = bookRef.GetReadingOrder();
|
||||||
|
|
||||||
|
if (readingOrder == null || !readingOrder.Any())
|
||||||
|
{
|
||||||
|
return Result.Fail<List<string>>("EPUB nie zawiera czytelnych rozdziałów.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var chapters = new List<string>();
|
||||||
|
foreach (var chapterRef in readingOrder)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawContent = await chapterRef.ReadContentAsTextAsync();
|
||||||
|
var cleanText = StripHtml(rawContent);
|
||||||
|
chapters.Add(cleanText);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Ok(chapters);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[EpubExtractor] Error extracting chapters from EPUB: {FilePath}", relativePath);
|
||||||
|
return Result.Fail<List<string>>(new Error("Failed to parse and extract text from EPUB").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolvePath(string relativePath)
|
||||||
|
{
|
||||||
|
var normalized = relativePath.Replace('/', Path.DirectorySeparatorChar);
|
||||||
|
var currentDir = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory);
|
||||||
|
while (currentDir != null)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(currentDir.FullName, "wwwroot", normalized);
|
||||||
|
if (File.Exists(candidate)) return candidate;
|
||||||
|
|
||||||
|
var devCandidate = Path.Combine(currentDir.FullName, "src", "NexusReader.Web", "wwwroot", normalized);
|
||||||
|
if (File.Exists(devCandidate)) return devCandidate;
|
||||||
|
|
||||||
|
currentDir = currentDir.Parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripHtml(string html)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(html)) return string.Empty;
|
||||||
|
var clean = Regex.Replace(html, @"<(style|script)\b[^>]*>.*?</\1>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||||
|
clean = Regex.Replace(clean, @"<[^>]*>", " ");
|
||||||
|
clean = System.Net.WebUtility.HtmlDecode(clean);
|
||||||
|
clean = Regex.Replace(clean, @"\s+", " ").Trim();
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,19 @@ public class EpubReaderService : IEpubReader
|
|||||||
private readonly ILogger<EpubReaderService> _logger;
|
private readonly ILogger<EpubReaderService> _logger;
|
||||||
private const int WordThreshold = 1000;
|
private const int WordThreshold = 1000;
|
||||||
|
|
||||||
|
private static readonly Regex ImageTagRegex = new(@"(?<before><img\b[^>]*?\bsrc=[""'])(?<src>[^""']*?)(?<after>[""'][^>]*?>)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex BodyMatchRegex = new(@"<body\b[^>]*>(.*?)</body>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex ParagraphMatchRegex = new(@"<(p|h[1-6]|ul|ol|blockquote|pre)\b[^>]*>.*?</\1>|<hr\b[^>]*>|<img\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex StyleScriptRegex = new(@"<(style|script)\b[^>]*>.*?</\1>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex WhitelistTagsRegex = new(@"<(?!/?(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr|img)\b)[^>]+>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex StripAttributesRegex = new(@"<(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex ImgTagSanitizerRegex = new(@"<img\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex SrcAttributeRegex = new(@"\bsrc=[""'](?<src>[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex AltAttributeRegex = new(@"\balt=[""'](?<alt>[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex SvgImageTagRegex = new(@"<image\b(?<attrs>[^>]*?)>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex HrefAttributeRegex = new(@"\b(xlink:)?href=[""'](?<href>[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex EmptyBlockRegex = new(@"^(</?(p|h[1-6]|ul|ol|li|blockquote|pre|div|span|br)\b[^>]*>| |\s)*$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
public EpubReaderService(
|
public EpubReaderService(
|
||||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||||
ILogger<EpubReaderService> logger)
|
ILogger<EpubReaderService> logger)
|
||||||
@@ -80,6 +93,9 @@ public class EpubReaderService : IEpubReader
|
|||||||
|
|
||||||
var chapterContent = await chapterRef.ReadContentAsTextAsync();
|
var chapterContent = await chapterRef.ReadContentAsTextAsync();
|
||||||
|
|
||||||
|
// Rewrite relative image src URLs to use the server-side API endpoint
|
||||||
|
chapterContent = RewriteImageUrls(chapterContent, ebookId, chapterRef.FilePath);
|
||||||
|
|
||||||
// 3. Build content blocks
|
// 3. Build content blocks
|
||||||
var blocks = new List<ContentBlock>();
|
var blocks = new List<ContentBlock>();
|
||||||
int totalWordCount = 0;
|
int totalWordCount = 0;
|
||||||
@@ -89,7 +105,7 @@ public class EpubReaderService : IEpubReader
|
|||||||
foreach (var p in paragraphs)
|
foreach (var p in paragraphs)
|
||||||
{
|
{
|
||||||
var sanitizedContent = SanitizeParagraph(p);
|
var sanitizedContent = SanitizeParagraph(p);
|
||||||
if (string.IsNullOrWhiteSpace(sanitizedContent)) continue;
|
if (string.IsNullOrWhiteSpace(sanitizedContent) || EmptyBlockRegex.IsMatch(sanitizedContent)) continue;
|
||||||
|
|
||||||
blocks.Add(new TextSegmentBlock($"seg-{blockCounter++}", sanitizedContent));
|
blocks.Add(new TextSegmentBlock($"seg-{blockCounter++}", sanitizedContent));
|
||||||
|
|
||||||
@@ -142,13 +158,177 @@ public class EpubReaderService : IEpubReader
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<byte[]>> GetEpubResourceAsync(
|
||||||
|
Guid ebookId,
|
||||||
|
string resourcePath,
|
||||||
|
string? userId = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
var ebook = await context.Ebooks
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
e => e.Id == ebookId && (userId == null || e.UserId == userId),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (ebook == null)
|
||||||
|
{
|
||||||
|
return Result.Fail($"Ebook '{ebookId}' not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullPath = ResolvePath(ebook.FilePath);
|
||||||
|
if (fullPath == null || !File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
return Result.Fail("EPUB file not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var bookRef = await EpubReader.OpenBookAsync(fullPath);
|
||||||
|
|
||||||
|
var decodedPath = System.Net.WebUtility.UrlDecode(resourcePath);
|
||||||
|
if (decodedPath.Contains("..") || decodedPath.Contains(":") || decodedPath.StartsWith("/") || decodedPath.StartsWith("\\"))
|
||||||
|
{
|
||||||
|
return Result.Fail("Invalid resource path.");
|
||||||
|
}
|
||||||
|
|
||||||
|
decodedPath = decodedPath.Replace('\\', '/').TrimStart('/');
|
||||||
|
|
||||||
|
EpubLocalContentFileRef? targetFile = null;
|
||||||
|
if (bookRef.Content?.AllFiles?.Local != null)
|
||||||
|
{
|
||||||
|
foreach (var file in bookRef.Content.AllFiles.Local)
|
||||||
|
{
|
||||||
|
var filePath = file.FilePath?.Replace('\\', '/').TrimStart('/') ?? "";
|
||||||
|
var fileKey = file.Key?.Replace('\\', '/').TrimStart('/') ?? "";
|
||||||
|
if (filePath.Equals(decodedPath, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
fileKey.Equals(decodedPath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
targetFile = file;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetFile != null)
|
||||||
|
{
|
||||||
|
if (targetFile is EpubLocalByteContentFileRef byteFile)
|
||||||
|
{
|
||||||
|
byte[] bytes = await byteFile.ReadContentAsync();
|
||||||
|
return Result.Ok(bytes);
|
||||||
|
}
|
||||||
|
else if (targetFile is EpubLocalTextContentFileRef textFile)
|
||||||
|
{
|
||||||
|
string text = await textFile.ReadContentAsync();
|
||||||
|
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(text);
|
||||||
|
return Result.Ok(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Fail($"Resource '{resourcePath}' not found in EPUB.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to retrieve EPUB resource '{ResourcePath}' for ebook {EbookId}.", resourcePath, ebookId);
|
||||||
|
return Result.Fail(new Error($"Failed to retrieve EPUB resource: {ex.Message}").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RewriteImageUrls(string html, Guid ebookId, string chapterPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(html)) return html;
|
||||||
|
|
||||||
|
var normalizedHtml = NormalizeSvgImageTags(html);
|
||||||
|
|
||||||
|
return ImageTagRegex.Replace(normalizedHtml, match =>
|
||||||
|
{
|
||||||
|
var rawSrc = match.Groups["src"].Value;
|
||||||
|
|
||||||
|
if (rawSrc.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return ""; // Completely block script execution in image src
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawSrc.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
rawSrc.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
rawSrc.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return match.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedPath = ResolveRelativePath(chapterPath, rawSrc);
|
||||||
|
var rewrittenSrc = $"/api/epub/{ebookId}/resource?path={System.Net.WebUtility.UrlEncode(resolvedPath)}";
|
||||||
|
return $"{match.Groups["before"].Value}{rewrittenSrc}{match.Groups["after"].Value}";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeSvgImageTags(string html)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(html)) return html;
|
||||||
|
|
||||||
|
return SvgImageTagRegex.Replace(html, match =>
|
||||||
|
{
|
||||||
|
var attrs = match.Groups["attrs"].Value;
|
||||||
|
|
||||||
|
if (SrcAttributeRegex.IsMatch(attrs))
|
||||||
|
{
|
||||||
|
return $"<img {attrs}>";
|
||||||
|
}
|
||||||
|
|
||||||
|
var hrefMatch = HrefAttributeRegex.Match(attrs);
|
||||||
|
if (hrefMatch.Success)
|
||||||
|
{
|
||||||
|
var hrefVal = hrefMatch.Groups["href"].Value;
|
||||||
|
var cleanedAttrs = HrefAttributeRegex.Replace(attrs, "");
|
||||||
|
return $"<img src=\"{hrefVal}\" {cleanedAttrs}>";
|
||||||
|
}
|
||||||
|
|
||||||
|
return match.Value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveRelativePath(string basePath, string relativePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(relativePath)) return string.Empty;
|
||||||
|
|
||||||
|
var decodedRelative = System.Net.WebUtility.UrlDecode(relativePath);
|
||||||
|
var baseDir = Path.GetDirectoryName(basePath) ?? "";
|
||||||
|
baseDir = baseDir.Replace('\\', '/');
|
||||||
|
|
||||||
|
var combined = Path.Combine(baseDir, decodedRelative).Replace('\\', '/');
|
||||||
|
var segments = combined.Split('/');
|
||||||
|
var stack = new Stack<string>();
|
||||||
|
|
||||||
|
foreach (var segment in segments)
|
||||||
|
{
|
||||||
|
if (segment == "." || string.IsNullOrEmpty(segment))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (segment == "..")
|
||||||
|
{
|
||||||
|
if (stack.Count > 0)
|
||||||
|
{
|
||||||
|
stack.Pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stack.Push(segment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join("/", stack.Reverse());
|
||||||
|
}
|
||||||
|
|
||||||
private static List<string> ExtractParagraphs(string html)
|
private static List<string> ExtractParagraphs(string html)
|
||||||
{
|
{
|
||||||
var bodyMatch = Regex.Match(html, @"<body\b[^>]*>(.*?)</body>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
var bodyMatch = BodyMatchRegex.Match(html);
|
||||||
var content = bodyMatch.Success ? bodyMatch.Groups[1].Value : html;
|
var content = bodyMatch.Success ? bodyMatch.Groups[1].Value : html;
|
||||||
|
|
||||||
var paragraphs = new List<string>();
|
var paragraphs = new List<string>();
|
||||||
var matches = Regex.Matches(content, @"<(p|h[1-6]|ul|ol|blockquote|pre)\b[^>]*>.*?</\1>|<hr\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
var matches = ParagraphMatchRegex.Matches(content);
|
||||||
|
|
||||||
foreach (Match match in matches)
|
foreach (Match match in matches)
|
||||||
{
|
{
|
||||||
@@ -165,9 +345,20 @@ public class EpubReaderService : IEpubReader
|
|||||||
|
|
||||||
private static string SanitizeParagraph(string html)
|
private static string SanitizeParagraph(string html)
|
||||||
{
|
{
|
||||||
var clean = Regex.Replace(html, @"<(style|script)\b[^>]*>.*?</\1>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
var clean = StyleScriptRegex.Replace(html, "");
|
||||||
clean = Regex.Replace(clean, @"<(?!/?(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b)[^>]+>", "", RegexOptions.IgnoreCase);
|
clean = WhitelistTagsRegex.Replace(clean, "");
|
||||||
clean = Regex.Replace(clean, @"<(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b[^>]*>", "<$1>", RegexOptions.IgnoreCase);
|
clean = StripAttributesRegex.Replace(clean, "<$1>");
|
||||||
|
|
||||||
|
// Securely sanitize img tags by keeping ONLY src and alt attributes to prevent XSS (onerror, onload, style, etc.)
|
||||||
|
clean = ImgTagSanitizerRegex.Replace(clean, m =>
|
||||||
|
{
|
||||||
|
var srcMatch = SrcAttributeRegex.Match(m.Value);
|
||||||
|
var altMatch = AltAttributeRegex.Match(m.Value);
|
||||||
|
var srcAttr = srcMatch.Success ? $" src=\"{srcMatch.Groups["src"].Value}\"" : "";
|
||||||
|
var altAttr = altMatch.Success ? $" alt=\"{altMatch.Groups["alt"].Value}\"" : "";
|
||||||
|
return $"<img{srcAttr}{altAttr} />";
|
||||||
|
});
|
||||||
|
|
||||||
clean = System.Net.WebUtility.HtmlDecode(clean);
|
clean = System.Net.WebUtility.HtmlDecode(clean);
|
||||||
return clean.Trim();
|
return clean.Trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using Ganss.Xss;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
using NexusReader.Infrastructure.Configuration;
|
||||||
|
using Markdig;
|
||||||
|
|
||||||
|
namespace NexusReader.Infrastructure.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Infrastructure implementation of ISanitizerService using the Ganss.Xss HtmlSanitizer library.
|
||||||
|
/// </summary>
|
||||||
|
public class HtmlSanitizerService : ISanitizerService
|
||||||
|
{
|
||||||
|
private readonly HtmlSanitizer _sanitizer;
|
||||||
|
private readonly MarkdownPipeline _pipeline;
|
||||||
|
|
||||||
|
public HtmlSanitizerService(IOptions<HtmlSanitizerSettings>? options = null)
|
||||||
|
{
|
||||||
|
_sanitizer = new HtmlSanitizer();
|
||||||
|
_pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
|
||||||
|
|
||||||
|
if (options?.Value != null)
|
||||||
|
{
|
||||||
|
var settings = options.Value;
|
||||||
|
|
||||||
|
if (settings.AllowedTags != null && settings.AllowedTags.Count > 0)
|
||||||
|
{
|
||||||
|
_sanitizer.AllowedTags.Clear();
|
||||||
|
foreach (var tag in settings.AllowedTags)
|
||||||
|
{
|
||||||
|
_sanitizer.AllowedTags.Add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.AllowedAttributes != null && settings.AllowedAttributes.Count > 0)
|
||||||
|
{
|
||||||
|
_sanitizer.AllowedAttributes.Clear();
|
||||||
|
foreach (var attr in settings.AllowedAttributes)
|
||||||
|
{
|
||||||
|
_sanitizer.AllowedAttributes.Add(attr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.AllowedCssProperties != null && settings.AllowedCssProperties.Count > 0)
|
||||||
|
{
|
||||||
|
_sanitizer.AllowedCssProperties.Clear();
|
||||||
|
foreach (var prop in settings.AllowedCssProperties)
|
||||||
|
{
|
||||||
|
_sanitizer.AllowedCssProperties.Add(prop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.AllowedSchemes != null && settings.AllowedSchemes.Count > 0)
|
||||||
|
{
|
||||||
|
_sanitizer.AllowedSchemes.Clear();
|
||||||
|
foreach (var scheme in settings.AllowedSchemes)
|
||||||
|
{
|
||||||
|
_sanitizer.AllowedSchemes.Add(scheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Sanitize(string input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(input))
|
||||||
|
{
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate raw Markdown input to HTML strictly before running HtmlSanitizer
|
||||||
|
var html = Markdown.ToHtml(input, _pipeline);
|
||||||
|
|
||||||
|
return _sanitizer.Sanitize(html).Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ using FluentResults;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using MediatR;
|
||||||
|
using NexusReader.Application.Queries.Intelligence;
|
||||||
using Microsoft.ML.Tokenizers;
|
using Microsoft.ML.Tokenizers;
|
||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
using NexusReader.Application.DTOs.AI;
|
using NexusReader.Application.DTOs.AI;
|
||||||
@@ -15,6 +17,7 @@ using Polly.Registry;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using NexusReader.Infrastructure.Configuration;
|
using NexusReader.Infrastructure.Configuration;
|
||||||
using Qdrant.Client;
|
using Qdrant.Client;
|
||||||
|
using Qdrant.Client.Grpc;
|
||||||
using Neo4j.Driver;
|
using Neo4j.Driver;
|
||||||
|
|
||||||
namespace NexusReader.Infrastructure.Services;
|
namespace NexusReader.Infrastructure.Services;
|
||||||
@@ -32,8 +35,10 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
private readonly ILogger<KnowledgeService> _logger;
|
private readonly ILogger<KnowledgeService> _logger;
|
||||||
private readonly QdrantClient _qdrantClient;
|
private readonly QdrantClient _qdrantClient;
|
||||||
private readonly IDriver _neo4jDriver;
|
private readonly IDriver _neo4jDriver;
|
||||||
private const string PromptVersion = "1.3";
|
private readonly IMediator _mediator;
|
||||||
|
private const string PromptVersion = "1.7";
|
||||||
private static readonly ConcurrentDictionary<string, Lazy<Task<Result<KnowledgePacket>>>> _activeRequests = new();
|
private static readonly ConcurrentDictionary<string, Lazy<Task<Result<KnowledgePacket>>>> _activeRequests = new();
|
||||||
|
private static readonly SemaphoreSlim _collectionSemaphore = new(1, 1);
|
||||||
|
|
||||||
public KnowledgeService(
|
public KnowledgeService(
|
||||||
IChatClient chatClient,
|
IChatClient chatClient,
|
||||||
@@ -43,7 +48,8 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
IOptions<AiSettings> settings,
|
IOptions<AiSettings> settings,
|
||||||
ILogger<KnowledgeService> logger,
|
ILogger<KnowledgeService> logger,
|
||||||
QdrantClient qdrantClient,
|
QdrantClient qdrantClient,
|
||||||
IDriver neo4jDriver)
|
IDriver neo4jDriver,
|
||||||
|
IMediator mediator)
|
||||||
{
|
{
|
||||||
_chatClient = chatClient;
|
_chatClient = chatClient;
|
||||||
_embeddingGenerator = embeddingGenerator;
|
_embeddingGenerator = embeddingGenerator;
|
||||||
@@ -53,6 +59,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
_qdrantClient = qdrantClient;
|
_qdrantClient = qdrantClient;
|
||||||
_neo4jDriver = neo4jDriver;
|
_neo4jDriver = neo4jDriver;
|
||||||
|
_mediator = mediator;
|
||||||
// Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides
|
// Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides
|
||||||
// a very reliable estimation for token usage in Gemini-based workloads.
|
// a very reliable estimation for token usage in Gemini-based workloads.
|
||||||
_tokenizer = TiktokenTokenizer.CreateForModel("gpt-4");
|
_tokenizer = TiktokenTokenizer.CreateForModel("gpt-4");
|
||||||
@@ -84,11 +91,12 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
|
|
||||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
var normalizedText = text.Trim();
|
var normalizedText = text.Trim();
|
||||||
var hash = ContentHasher.ComputeHash(normalizedText);
|
var hashInput = $"{normalizedText}:{traceType}:{PromptVersion}";
|
||||||
|
var hash = ContentHasher.ComputeHash(hashInput);
|
||||||
|
|
||||||
// 1. Check Cache
|
// 1. Check Cache
|
||||||
var cached = await dbContext.SemanticKnowledgeCache
|
var cached = await dbContext.SemanticKnowledgeCache
|
||||||
.FirstOrDefaultAsync(c => c.ContentHash == hash && c.TenantId == tenantId, cancellationToken);
|
.FirstOrDefaultAsync(c => c.ContentHash == hash, cancellationToken);
|
||||||
|
|
||||||
if (cached != null && cached.PromptVersion == PromptVersion)
|
if (cached != null && cached.PromptVersion == PromptVersion)
|
||||||
{
|
{
|
||||||
@@ -96,7 +104,12 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var packet = JsonSerializer.Deserialize<KnowledgePacket>(cached.JsonData, JsonOptions);
|
var packet = JsonSerializer.Deserialize<KnowledgePacket>(cached.JsonData, JsonOptions);
|
||||||
if (packet != null) return Result.Ok(packet);
|
if (packet != null)
|
||||||
|
{
|
||||||
|
await ProcessKnowledgeUnitsAsync(packet, tenantId, ebookId, dbContext, cancellationToken);
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
return Result.Ok(packet);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch (JsonException ex)
|
||||||
{
|
{
|
||||||
@@ -105,7 +118,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Deduplicate concurrent active requests for the exact same hash
|
// Deduplicate concurrent active requests for the exact same hash
|
||||||
var requestKey = $"{tenantId}:{hash}:{traceType}";
|
var requestKey = $"{hash}:{traceType}";
|
||||||
|
|
||||||
var lazyTask = _activeRequests.GetOrAdd(requestKey, k =>
|
var lazyTask = _activeRequests.GetOrAdd(requestKey, k =>
|
||||||
new Lazy<Task<Result<KnowledgePacket>>>(
|
new Lazy<Task<Result<KnowledgePacket>>>(
|
||||||
@@ -177,7 +190,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
|
|
||||||
// 4. Save to Cache
|
// 4. Save to Cache
|
||||||
var cached = await dbContext.SemanticKnowledgeCache
|
var cached = await dbContext.SemanticKnowledgeCache
|
||||||
.FirstOrDefaultAsync(c => c.ContentHash == hash && c.TenantId == tenantId);
|
.FirstOrDefaultAsync(c => c.ContentHash == hash);
|
||||||
|
|
||||||
var cacheEntry = new SemanticKnowledgeCache
|
var cacheEntry = new SemanticKnowledgeCache
|
||||||
{
|
{
|
||||||
@@ -201,7 +214,14 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
// 5. Process structured KnowledgeUnits (Graph Expansion)
|
// 5. Process structured KnowledgeUnits (Graph Expansion)
|
||||||
await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, ebookId, dbContext, default);
|
await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, ebookId, dbContext, default);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pgEx && pgEx.SqlState == "23505")
|
||||||
|
{
|
||||||
|
_logger.LogWarning("[KnowledgeService] Concurrency collision on SemanticKnowledgeCache for {Hash}; another process saved it first. Swallowing.", hash);
|
||||||
|
}
|
||||||
return Result.Ok(knowledgePacket);
|
return Result.Ok(knowledgePacket);
|
||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch (JsonException ex)
|
||||||
@@ -224,6 +244,30 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
|
|
||||||
private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, Guid? ebookId, AppDbContext dbContext, CancellationToken cancellationToken)
|
private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, Guid? ebookId, AppDbContext dbContext, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (packet.Graph != null && (packet.Units == null || !packet.Units.Any()))
|
||||||
|
{
|
||||||
|
var graphUnits = packet.Graph.Nodes.Select(node => new KnowledgeUnitDto(
|
||||||
|
node.Id,
|
||||||
|
node.Type ?? "concept",
|
||||||
|
node.Description ?? node.Label,
|
||||||
|
new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["label"] = node.Label,
|
||||||
|
["group"] = node.Group,
|
||||||
|
["summary"] = node.Summary ?? "",
|
||||||
|
["key_terms"] = node.KeyTerms ?? new List<string>()
|
||||||
|
}
|
||||||
|
)).ToList();
|
||||||
|
|
||||||
|
var graphLinks = packet.Graph.Links.Select(link => new KnowledgeLinkDto(
|
||||||
|
link.Source,
|
||||||
|
link.Target,
|
||||||
|
link.RelationType
|
||||||
|
)).ToList();
|
||||||
|
|
||||||
|
packet = packet with { Units = graphUnits, Links = graphLinks };
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
||||||
var linkTargetIds = packet.Links.Select(l => l.Target).ToList();
|
var linkTargetIds = packet.Links.Select(l => l.Target).ToList();
|
||||||
@@ -285,6 +329,211 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
_logger.LogWarning("[KnowledgeService] Skipping invalid link {Source} -> {Target}: one or both units are missing.", linkDto.Source, linkDto.Target);
|
_logger.LogWarning("[KnowledgeService] Skipping invalid link {Source} -> {Target}: one or both units are missing.", linkDto.Source, linkDto.Target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate and upsert vectors to Qdrant in batch
|
||||||
|
var unitsToEmbed = packet.Units
|
||||||
|
.Where(u => !string.IsNullOrEmpty(u.Content))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (unitsToEmbed.Any())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Retrieve the book's title from the database using EF Core
|
||||||
|
string bookTitle = "Nieznana książka";
|
||||||
|
if (ebookId.HasValue)
|
||||||
|
{
|
||||||
|
var ebook = await dbContext.Ebooks.FindAsync(new object[] { ebookId.Value }, cancellationToken);
|
||||||
|
if (ebook != null)
|
||||||
|
{
|
||||||
|
bookTitle = ebook.Title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var contents = unitsToEmbed.Select(u => u.Content).ToList();
|
||||||
|
|
||||||
|
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
|
||||||
|
await _embeddingGenerator.GenerateAsync(
|
||||||
|
contents,
|
||||||
|
new EmbeddingGenerationOptions { Dimensions = 768 },
|
||||||
|
cancellationToken: ct), cancellationToken);
|
||||||
|
|
||||||
|
var embeddings = embeddingResponse.ToList();
|
||||||
|
var points = new List<PointStruct>();
|
||||||
|
|
||||||
|
for (int i = 0; i < unitsToEmbed.Count; i++)
|
||||||
|
{
|
||||||
|
var unitDto = unitsToEmbed[i];
|
||||||
|
var vector = embeddings[i].Vector.ToArray();
|
||||||
|
|
||||||
|
string chapterTitle = "Wiedza z rozdziału";
|
||||||
|
if (unitDto.Metadata != null && unitDto.Metadata.TryGetValue("label", out var labelVal) && labelVal is string labelStr)
|
||||||
|
{
|
||||||
|
chapterTitle = labelStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
var point = new PointStruct
|
||||||
|
{
|
||||||
|
Id = GetDeterministicGuid(unitDto.Id),
|
||||||
|
Vectors = vector,
|
||||||
|
Payload =
|
||||||
|
{
|
||||||
|
["content"] = unitDto.Content,
|
||||||
|
["type"] = unitDto.Type ?? string.Empty,
|
||||||
|
["tenantId"] = tenantId,
|
||||||
|
["ebookId"] = ebookId?.ToString() ?? string.Empty,
|
||||||
|
["bookTitle"] = bookTitle,
|
||||||
|
["chapterTitle"] = chapterTitle,
|
||||||
|
["metadataJson"] = JsonSerializer.Serialize(unitDto.Metadata)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
points.Add(point);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (points.Any())
|
||||||
|
{
|
||||||
|
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
|
||||||
|
await _qdrantClient.UpsertAsync("knowledge_units", points, cancellationToken: cancellationToken);
|
||||||
|
_logger.LogInformation("[KnowledgeService] Successfully upserted {Count} points to Qdrant collection 'knowledge_units'.", points.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[KnowledgeService] Failed to generate and upsert embeddings for knowledge units to Qdrant.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Synchronize to Neo4j graph database
|
||||||
|
await SyncToNeo4jAsync(packet, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SyncToNeo4jAsync(KnowledgePacket packet, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (packet.Units == null || !packet.Units.Any()) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var session = _neo4jDriver.AsyncSession();
|
||||||
|
|
||||||
|
// 1. Merge nodes in a transaction
|
||||||
|
await session.ExecuteWriteAsync(async tx =>
|
||||||
|
{
|
||||||
|
foreach (var unit in packet.Units)
|
||||||
|
{
|
||||||
|
var cypher = @"
|
||||||
|
MERGE (u:KnowledgeUnit {id: $id})
|
||||||
|
ON CREATE SET u.content = $content, u.type = $type
|
||||||
|
ON MATCH SET u.content = $content, u.type = $type";
|
||||||
|
|
||||||
|
var guidStr = GetDeterministicGuid(unit.Id).ToString();
|
||||||
|
await tx.RunAsync(cypher, new
|
||||||
|
{
|
||||||
|
id = guidStr,
|
||||||
|
content = unit.Content ?? string.Empty,
|
||||||
|
type = unit.Type ?? "concept"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Merge links in a transaction
|
||||||
|
if (packet.Links != null && packet.Links.Any())
|
||||||
|
{
|
||||||
|
await session.ExecuteWriteAsync(async tx =>
|
||||||
|
{
|
||||||
|
foreach (var link in packet.Links)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(link.Source) || string.IsNullOrWhiteSpace(link.Target))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var relationType = string.IsNullOrWhiteSpace(link.Relation) ? "RELATED_TO" : link.Relation.Trim().ToUpperInvariant();
|
||||||
|
relationType = System.Text.RegularExpressions.Regex.Replace(relationType, @"[^A-Z0-9_]", "_");
|
||||||
|
if (string.IsNullOrEmpty(relationType) || relationType == "_")
|
||||||
|
{
|
||||||
|
relationType = "RELATED_TO";
|
||||||
|
}
|
||||||
|
|
||||||
|
var cypher = $@"
|
||||||
|
MATCH (source:KnowledgeUnit {{id: $sourceId}})
|
||||||
|
MATCH (target:KnowledgeUnit {{id: $targetId}})
|
||||||
|
MERGE (source)-[r:{relationType}]->(target)";
|
||||||
|
|
||||||
|
var sourceGuidStr = GetDeterministicGuid(link.Source).ToString();
|
||||||
|
var targetGuidStr = GetDeterministicGuid(link.Target).ToString();
|
||||||
|
|
||||||
|
await tx.RunAsync(cypher, new
|
||||||
|
{
|
||||||
|
sourceId = sourceGuidStr,
|
||||||
|
targetId = targetGuidStr
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("[KnowledgeService] Successfully synchronized {NodeCount} nodes and {LinkCount} links to Neo4j.", packet.Units.Count, packet.Links?.Count ?? 0);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[KnowledgeService] Failed to synchronize knowledge graph to Neo4j.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureCollectionExistsAsync(string collectionName, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await _collectionSemaphore.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken);
|
||||||
|
if (!exists)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[KnowledgeService] Creating Qdrant collection '{CollectionName}'...", collectionName);
|
||||||
|
await _qdrantClient.CreateCollectionAsync(
|
||||||
|
collectionName: collectionName,
|
||||||
|
vectorsConfig: new VectorParams
|
||||||
|
{
|
||||||
|
Size = 768,
|
||||||
|
Distance = Distance.Cosine
|
||||||
|
},
|
||||||
|
cancellationToken: cancellationToken
|
||||||
|
);
|
||||||
|
_logger.LogInformation("[KnowledgeService] Qdrant collection '{CollectionName}' created successfully.", collectionName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
(ex.InnerException != null && ex.InnerException.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[KnowledgeService] Qdrant collection '{CollectionName}' was already created by another thread.", collectionName);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[KnowledgeService] Error ensuring Qdrant collection '{CollectionName}' exists.", collectionName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_collectionSemaphore.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid GetDeterministicGuid(string input)
|
||||||
|
{
|
||||||
|
if (Guid.TryParse(input, out var guid))
|
||||||
|
{
|
||||||
|
return guid;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var md5 = System.Security.Cryptography.MD5.Create();
|
||||||
|
byte[] hash = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input));
|
||||||
|
return new Guid(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetPointIdString(PointId pointId)
|
||||||
|
{
|
||||||
|
if (pointId == null) return string.Empty;
|
||||||
|
return pointId.PointIdOptionsCase == PointId.PointIdOptionsOneofCase.Uuid
|
||||||
|
? pointId.Uuid
|
||||||
|
: pointId.Num.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default)
|
public async Task<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default)
|
||||||
@@ -354,6 +603,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
List<Qdrant.Client.Grpc.ScoredPoint> searchResult;
|
List<Qdrant.Client.Grpc.ScoredPoint> searchResult;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
|
||||||
var response = await _qdrantClient.SearchAsync(
|
var response = await _qdrantClient.SearchAsync(
|
||||||
collectionName: "knowledge_units",
|
collectionName: "knowledge_units",
|
||||||
vector: queryVector,
|
vector: queryVector,
|
||||||
@@ -363,15 +613,37 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
);
|
);
|
||||||
searchResult = response.ToList();
|
searchResult = response.ToList();
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
_logger.LogWarning(ex, "[KnowledgeService] Qdrant search failed during GetRelevantContextAsync. Returning empty search results.");
|
||||||
searchResult = new List<Qdrant.Client.Grpc.ScoredPoint>();
|
searchResult = new List<Qdrant.Client.Grpc.ScoredPoint>();
|
||||||
}
|
}
|
||||||
|
|
||||||
var contexts = searchResult.Select(point => new RelevantContext
|
var contexts = searchResult.Select(point =>
|
||||||
{
|
{
|
||||||
Text = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty,
|
var content = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty;
|
||||||
|
var summary = string.Empty;
|
||||||
|
if (point.Payload.TryGetValue("metadataJson", out var metaVal) && !string.IsNullOrEmpty(metaVal.StringValue))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var meta = JsonSerializer.Deserialize<Dictionary<string, object>>(metaVal.StringValue);
|
||||||
|
if (meta != null && meta.TryGetValue("summary", out var sumObj))
|
||||||
|
{
|
||||||
|
summary = sumObj?.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "[KnowledgeService] Failed to deserialize metadata JSON in RelevantContext mapping.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var text = string.IsNullOrEmpty(summary) ? content : $"{content}: {summary}";
|
||||||
|
return new RelevantContext
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
Confidence = point.Score
|
Confidence = point.Score
|
||||||
|
};
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
return Result.Ok(contexts);
|
return Result.Ok(contexts);
|
||||||
@@ -417,6 +689,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
List<Qdrant.Client.Grpc.ScoredPoint> searchResult;
|
List<Qdrant.Client.Grpc.ScoredPoint> searchResult;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
|
||||||
var response = await _qdrantClient.SearchAsync(
|
var response = await _qdrantClient.SearchAsync(
|
||||||
collectionName: "knowledge_units",
|
collectionName: "knowledge_units",
|
||||||
vector: queryVector,
|
vector: queryVector,
|
||||||
@@ -438,7 +711,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Graph Expansion via Neo4j
|
// 3. Graph Expansion via Neo4j
|
||||||
var candidateIds = searchResult.Select(r => r.Id.ToString()).ToList();
|
var candidateIds = searchResult.Select(r => GetPointIdString(r.Id)).ToList();
|
||||||
var definitions = new Dictionary<string, List<string>>();
|
var definitions = new Dictionary<string, List<string>>();
|
||||||
|
|
||||||
if (candidateIds.Any())
|
if (candidateIds.Any())
|
||||||
@@ -447,7 +720,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
{
|
{
|
||||||
await using var session = _neo4jDriver.AsyncSession();
|
await using var session = _neo4jDriver.AsyncSession();
|
||||||
var cypher = @"
|
var cypher = @"
|
||||||
MATCH (source:KnowledgeUnit)-[r:DEFINES]->(target:KnowledgeUnit)
|
MATCH (source:KnowledgeUnit)-[r]->(target:KnowledgeUnit)
|
||||||
WHERE source.id IN $candidateIds
|
WHERE source.id IN $candidateIds
|
||||||
RETURN source.id AS sourceId, target.content AS targetContent";
|
RETURN source.id AS sourceId, target.content AS targetContent";
|
||||||
|
|
||||||
@@ -516,12 +789,15 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
{
|
{
|
||||||
metadata = JsonSerializer.Deserialize<Dictionary<string, object>>(metaVal.StringValue);
|
metadata = JsonSerializer.Deserialize<Dictionary<string, object>>(metaVal.StringValue);
|
||||||
}
|
}
|
||||||
catch {}
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "[KnowledgeService] Failed to deserialize metadata JSON in search library mapping.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var dto = new SemanticSearchResultDto
|
var dto = new SemanticSearchResultDto
|
||||||
{
|
{
|
||||||
ContentHash = point.Id.ToString(),
|
ContentHash = GetPointIdString(point.Id),
|
||||||
Snippet = content,
|
Snippet = content,
|
||||||
UnitType = type,
|
UnitType = type,
|
||||||
RelevanceScore = point.Score,
|
RelevanceScore = point.Score,
|
||||||
@@ -529,7 +805,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
Metadata = metadata
|
Metadata = metadata
|
||||||
};
|
};
|
||||||
|
|
||||||
var pointIdStr = point.Id.ToString();
|
var pointIdStr = GetPointIdString(point.Id);
|
||||||
if (definitions.TryGetValue(pointIdStr, out var pointDefs) && pointDefs.Any())
|
if (definitions.TryGetValue(pointIdStr, out var pointDefs) && pointDefs.Any())
|
||||||
{
|
{
|
||||||
dto.Snippet = $"[Context: {string.Join("; ", pointDefs)}]\n{dto.Snippet}";
|
dto.Snippet = $"[Context: {string.Join("; ", pointDefs)}]\n{dto.Snippet}";
|
||||||
@@ -602,6 +878,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
List<Qdrant.Client.Grpc.ScoredPoint> searchResult;
|
List<Qdrant.Client.Grpc.ScoredPoint> searchResult;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
|
||||||
var response = await _qdrantClient.SearchAsync(
|
var response = await _qdrantClient.SearchAsync(
|
||||||
collectionName: "knowledge_units",
|
collectionName: "knowledge_units",
|
||||||
vector: queryVector,
|
vector: queryVector,
|
||||||
@@ -627,11 +904,28 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Graph Expansion via Neo4j
|
// 3. Graph Expansion via Neo4j
|
||||||
var candidateIds = searchResult.Select(r => r.Id.ToString()).ToList();
|
var candidateIds = searchResult.Select(r => GetPointIdString(r.Id)).ToList();
|
||||||
var relatedContexts = new List<string>();
|
var relatedContexts = new List<string>();
|
||||||
|
|
||||||
// Keep map of point ID -> payload data for fast mapping later
|
// Keep map of point ID -> payload data for fast mapping later
|
||||||
var pointMap = searchResult.ToDictionary(r => r.Id.ToString(), r => r);
|
var pointMap = searchResult.ToDictionary(r => GetPointIdString(r.Id), r => r);
|
||||||
|
|
||||||
|
// Fetch knowledge units from PostgreSQL to map Guids back to rich metadata summaries
|
||||||
|
var guidMap = new Dictionary<string, KnowledgeUnit>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
var units = await dbContext.KnowledgeUnits
|
||||||
|
.Include(u => u.Ebook)
|
||||||
|
.ThenInclude(e => e.Author)
|
||||||
|
.Where(u => u.TenantId == tenantId && (ebookId == null || u.EbookId == ebookId))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
guidMap = units.ToDictionary(u => GetDeterministicGuid(u.Id).ToString(), u => u);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "[KnowledgeService] Failed to load KnowledgeUnits from PostgreSQL for Guid mapping.");
|
||||||
|
}
|
||||||
|
|
||||||
if (candidateIds.Any())
|
if (candidateIds.Any())
|
||||||
{
|
{
|
||||||
@@ -641,7 +935,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
var cypher = @"
|
var cypher = @"
|
||||||
MATCH (source:KnowledgeUnit)
|
MATCH (source:KnowledgeUnit)
|
||||||
WHERE source.id IN $candidateIds
|
WHERE source.id IN $candidateIds
|
||||||
OPTIONAL MATCH (source)-[r:DEFINES|RELATED_TO]->(target:KnowledgeUnit)
|
OPTIONAL MATCH (source)-[r]->(target:KnowledgeUnit)
|
||||||
RETURN source.id AS sourceId, source.content AS sourceContent,
|
RETURN source.id AS sourceId, source.content AS sourceContent,
|
||||||
collect({ targetId: target.id, targetContent: target.content, relation: type(r) }) AS relations";
|
collect({ targetId: target.id, targetContent: target.content, relation: type(r) }) AS relations";
|
||||||
|
|
||||||
@@ -654,23 +948,70 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
foreach (var record in neoResult)
|
foreach (var record in neoResult)
|
||||||
{
|
{
|
||||||
var sourceId = record["sourceId"].As<string>();
|
var sourceId = record["sourceId"].As<string>();
|
||||||
var sourceContent = record["sourceContent"].As<string>();
|
|
||||||
|
|
||||||
relatedContexts.Add($"[Source ID: {sourceId}] {sourceContent}");
|
var sourceText = string.Empty;
|
||||||
|
if (guidMap.TryGetValue(sourceId, out var sourceUnit))
|
||||||
|
{
|
||||||
|
var summary = string.Empty;
|
||||||
|
if (!string.IsNullOrEmpty(sourceUnit.MetadataJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var meta = JsonSerializer.Deserialize<Dictionary<string, object>>(sourceUnit.MetadataJson);
|
||||||
|
if (meta != null && meta.TryGetValue("summary", out var sumObj))
|
||||||
|
{
|
||||||
|
summary = sumObj?.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException jsonEx)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(jsonEx, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in AskQuestionAsync source hydration.", sourceUnit.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sourceText = string.IsNullOrEmpty(summary) ? sourceUnit.Content : $"{sourceUnit.Content}: {summary}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sourceText = record["sourceContent"].As<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
relatedContexts.Add($"[Source ID: {sourceId}] {sourceText}");
|
||||||
|
|
||||||
var relations = record["relations"].As<List<object>>();
|
var relations = record["relations"].As<List<object>>();
|
||||||
if (relations != null)
|
if (relations != null)
|
||||||
{
|
{
|
||||||
foreach (var relObj in relations)
|
foreach (var relObj in relations)
|
||||||
{
|
{
|
||||||
if (relObj is Dictionary<string, object> relDict &&
|
if (relObj is System.Collections.IDictionary relDict)
|
||||||
relDict.TryGetValue("targetId", out var targetIdVal) && targetIdVal is string targetId &&
|
|
||||||
relDict.TryGetValue("targetContent", out var targetContentVal) && targetContentVal is string targetContent &&
|
|
||||||
relDict.TryGetValue("relation", out var relationVal) && relationVal is string relation)
|
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(targetContent))
|
var targetId = relDict["targetId"]?.ToString();
|
||||||
|
var targetContent = relDict["targetContent"]?.ToString();
|
||||||
|
var relation = relDict["relation"]?.ToString();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(targetContent) && !string.IsNullOrEmpty(relation))
|
||||||
{
|
{
|
||||||
relatedContexts.Add($"[Related Context ({relation}) to {sourceId}] {targetContent}");
|
var targetText = targetContent;
|
||||||
|
if (!string.IsNullOrEmpty(targetId) && guidMap.TryGetValue(targetId, out var targetUnit))
|
||||||
|
{
|
||||||
|
var summary = string.Empty;
|
||||||
|
if (!string.IsNullOrEmpty(targetUnit.MetadataJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var meta = JsonSerializer.Deserialize<Dictionary<string, object>>(targetUnit.MetadataJson);
|
||||||
|
if (meta != null && meta.TryGetValue("summary", out var sumObj))
|
||||||
|
{
|
||||||
|
summary = sumObj?.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException jsonEx)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(jsonEx, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in AskQuestionAsync target hydration.", targetUnit.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
targetText = string.IsNullOrEmpty(summary) ? targetUnit.Content : $"{targetUnit.Content}: {summary}";
|
||||||
|
}
|
||||||
|
relatedContexts.Add($"[Related Context ({relation}) to {sourceId}] {targetText}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -682,9 +1023,35 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
_logger.LogWarning(ex, "[KnowledgeService] Neo4j graph expansion failed. Falling back to direct Qdrant points.");
|
_logger.LogWarning(ex, "[KnowledgeService] Neo4j graph expansion failed. Falling back to direct Qdrant points.");
|
||||||
foreach (var point in searchResult)
|
foreach (var point in searchResult)
|
||||||
{
|
{
|
||||||
var sourceId = point.Id.ToString();
|
var sourceId = GetPointIdString(point.Id);
|
||||||
var content = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty;
|
|
||||||
relatedContexts.Add($"[Source ID: {sourceId}] {content}");
|
var sourceText = string.Empty;
|
||||||
|
if (guidMap.TryGetValue(sourceId, out var sourceUnit))
|
||||||
|
{
|
||||||
|
var summary = string.Empty;
|
||||||
|
if (!string.IsNullOrEmpty(sourceUnit.MetadataJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var meta = JsonSerializer.Deserialize<Dictionary<string, object>>(sourceUnit.MetadataJson);
|
||||||
|
if (meta != null && meta.TryGetValue("summary", out var sumObj))
|
||||||
|
{
|
||||||
|
summary = sumObj?.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException jsonEx)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(jsonEx, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in fallback AskQuestionAsync.", sourceUnit.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sourceText = string.IsNullOrEmpty(summary) ? sourceUnit.Content : $"{sourceUnit.Content}: {summary}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sourceText = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
relatedContexts.Add($"[Source ID: {sourceId}] {sourceText}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -708,33 +1075,14 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
// 5. Build prompt and invoke Gemini with structured JSON formatting
|
// 5. Build prompt and invoke Gemini with structured JSON formatting
|
||||||
var contextBlocksText = string.Join("\n\n", relatedContexts);
|
var contextBlocksText = string.Join("\n\n", relatedContexts);
|
||||||
|
|
||||||
var systemPrompt = @"
|
var systemPrompt = PromptRegistry.GroundedRAGSystemPrompt;
|
||||||
You are an advanced, extremely precise Fact-Checking AI assistant. Your task is to answer the user's question using ONLY the provided context blocks.
|
|
||||||
|
|
||||||
Strict Grounding Rules:
|
|
||||||
1. Rely EXCLUSIVELY on the provided context. Do NOT use any pre-existing external knowledge, facts, or assumptions.
|
|
||||||
2. If the context does not contain the answer, you must state exactly: 'I cannot answer this based on the provided book context.'
|
|
||||||
3. For every statement or claim you make in your answer, you must cite the specific source IDs (e.g., source chunk ID or hash) from the context.
|
|
||||||
4. You must format your response ONLY as a JSON object matching the following structure:
|
|
||||||
{
|
|
||||||
""answer"": ""The answer text goes here, referencing [Source ID] as citations."",
|
|
||||||
""citations"": [
|
|
||||||
{
|
|
||||||
""citationId"": ""The exact source ID cited (e.g., chunk hash/ID)"",
|
|
||||||
""snippet"": ""The precise sentence or phrase from the context that supports this statement."",
|
|
||||||
""sourceBook"": ""The book title or 'Unknown'""
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
";
|
|
||||||
|
|
||||||
var userPrompt = $"Context:\n{contextBlocksText}\n\nQuestion: {question}";
|
var userPrompt = $"Context:\n{contextBlocksText}\n\nQuestion: {question}";
|
||||||
|
|
||||||
var options = new ChatOptions
|
var options = new ChatOptions
|
||||||
{
|
{
|
||||||
Temperature = 0.0f,
|
Temperature = 0.0f,
|
||||||
MaxOutputTokens = 1500,
|
MaxOutputTokens = 1500
|
||||||
ResponseFormat = ChatResponseFormat.Json
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var chatResponse = await _retryPipeline.ExecuteAsync(async ct =>
|
var chatResponse = await _retryPipeline.ExecuteAsync(async ct =>
|
||||||
@@ -746,6 +1094,20 @@ Strict Grounding Rules:
|
|||||||
|
|
||||||
var rawJson = chatResponse.Text?.Trim() ?? string.Empty;
|
var rawJson = chatResponse.Text?.Trim() ?? string.Empty;
|
||||||
rawJson = rawJson.Replace("```json", "").Replace("```", "").Trim();
|
rawJson = rawJson.Replace("```json", "").Replace("```", "").Trim();
|
||||||
|
|
||||||
|
// Handle direct text fallback when model bypasses JSON format
|
||||||
|
if (!rawJson.StartsWith("{") &&
|
||||||
|
(rawJson.Contains("cannot answer", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
rawJson.Contains("context does not contain", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
rawJson.Contains("provided book context", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return Result.Ok(new GroundedResponseDto
|
||||||
|
{
|
||||||
|
Answer = "I cannot answer this based on the provided book context.",
|
||||||
|
Citations = new List<CitationDto>()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
rawJson = JsonRepairHelper.Repair(rawJson);
|
rawJson = JsonRepairHelper.Repair(rawJson);
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -756,18 +1118,45 @@ Strict Grounding Rules:
|
|||||||
return Result.Fail("Failed to deserialize grounded RAG response.");
|
return Result.Fail("Failed to deserialize grounded RAG response.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hydrate book titles for citations if unknown
|
// Hydrate book titles, author, and page number for citations if unknown
|
||||||
foreach (var citation in groundedResult.Citations)
|
foreach (var citation in groundedResult.Citations)
|
||||||
{
|
{
|
||||||
if (pointMap.TryGetValue(citation.CitationId, out var point) &&
|
if (pointMap.TryGetValue(citation.CitationId, out var point) &&
|
||||||
point.Payload.TryGetValue("ebookId", out var ev) &&
|
point.Payload.TryGetValue("ebookId", out var ev) &&
|
||||||
Guid.TryParse(ev.StringValue, out var ebId) &&
|
Guid.TryParse(ev.StringValue, out var ebId))
|
||||||
ebookTitles.TryGetValue(ebId, out var title))
|
{
|
||||||
|
if (ebookTitles.TryGetValue(ebId, out var title))
|
||||||
{
|
{
|
||||||
citation.SourceBook = title;
|
citation.SourceBook = title;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Look up from guidMap to get exact page number and author
|
||||||
|
if (guidMap.TryGetValue(citation.CitationId, out var unit))
|
||||||
|
{
|
||||||
|
if (unit.Ebook?.Author != null)
|
||||||
|
{
|
||||||
|
citation.Author = unit.Ebook.Author.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(unit.MetadataJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var meta = JsonSerializer.Deserialize<Dictionary<string, object>>(unit.MetadataJson);
|
||||||
|
if (meta != null && meta.TryGetValue("page", out var pageObj) && int.TryParse(pageObj?.ToString(), out var pageVal))
|
||||||
|
{
|
||||||
|
citation.PageNumber = pageVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in AskQuestionAsync citation mapping.", unit.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Result.Ok(groundedResult);
|
return Result.Ok(groundedResult);
|
||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch (JsonException ex)
|
||||||
@@ -790,6 +1179,30 @@ Strict Grounding Rules:
|
|||||||
await dbContext.SemanticKnowledgeCache.ExecuteDeleteAsync(cancellationToken);
|
await dbContext.SemanticKnowledgeCache.ExecuteDeleteAsync(cancellationToken);
|
||||||
await dbContext.KnowledgeUnits.ExecuteDeleteAsync(cancellationToken);
|
await dbContext.KnowledgeUnits.ExecuteDeleteAsync(cancellationToken);
|
||||||
await dbContext.KnowledgeUnitLinks.ExecuteDeleteAsync(cancellationToken);
|
await dbContext.KnowledgeUnitLinks.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _qdrantClient.DeleteCollectionAsync("knowledge_units", cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "[KnowledgeService] Failed to drop Qdrant collection 'knowledge_units' during cache clear.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var session = _neo4jDriver.AsyncSession();
|
||||||
|
await session.ExecuteWriteAsync(async tx =>
|
||||||
|
{
|
||||||
|
await tx.RunAsync("MATCH (n:KnowledgeUnit) DETACH DELETE n");
|
||||||
|
});
|
||||||
|
_logger.LogInformation("[KnowledgeService] Successfully wiped Neo4j 'KnowledgeUnit' nodes.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "[KnowledgeService] Failed to wipe Neo4j graph during cache clear.");
|
||||||
|
}
|
||||||
|
|
||||||
return Result.Ok();
|
return Result.Ok();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -798,6 +1211,12 @@ Strict Grounding Rules:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<IntelligenceResponse>> GetGlobalIntelligenceAsync(string queryText, string userId, string tenantId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _mediator.Send(new GetGlobalIntelligenceQuery(queryText, userId, tenantId), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
private int EstimateTokenCount(string text)
|
private int EstimateTokenCount(string text)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(text)) return 0;
|
if (string.IsNullOrEmpty(text)) return 0;
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace NexusReader.Infrastructure.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Infrastructure implementation of general storage utilizing local filesystem.
|
||||||
|
/// Files are saved in wwwroot/uploads/media.
|
||||||
|
/// </summary>
|
||||||
|
public class LocalStorageService : IStorageService
|
||||||
|
{
|
||||||
|
private readonly IWebHostEnvironment _environment;
|
||||||
|
|
||||||
|
public LocalStorageService(IWebHostEnvironment environment)
|
||||||
|
{
|
||||||
|
_environment = environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> UploadFileAsync(byte[] fileBytes, string fileName, string contentType)
|
||||||
|
{
|
||||||
|
using var stream = new MemoryStream(fileBytes);
|
||||||
|
return await UploadFileAsync(stream, fileName, contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> UploadFileAsync(Stream fileStream, string fileName, string contentType)
|
||||||
|
{
|
||||||
|
var mediaFolder = Path.Combine(_environment.WebRootPath, "uploads");
|
||||||
|
var resolvedMediaFolder = Path.GetFullPath(mediaFolder);
|
||||||
|
var folderWithSeparator = resolvedMediaFolder.EndsWith(Path.DirectorySeparatorChar)
|
||||||
|
? resolvedMediaFolder
|
||||||
|
: resolvedMediaFolder + Path.DirectorySeparatorChar;
|
||||||
|
|
||||||
|
if (!Directory.Exists(resolvedMediaFolder))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(resolvedMediaFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean file name to prevent path traversal issues
|
||||||
|
var safeFileName = Path.GetFileName(fileName);
|
||||||
|
var uniqueFileName = $"{Guid.NewGuid()}_{safeFileName}";
|
||||||
|
var filePath = Path.Combine(resolvedMediaFolder, uniqueFileName);
|
||||||
|
|
||||||
|
// Guard against path traversal
|
||||||
|
var fullPath = Path.GetFullPath(filePath);
|
||||||
|
if (!fullPath.StartsWith(folderWithSeparator, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Path traversal detected.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var outputStream = new FileStream(fullPath, FileMode.Create))
|
||||||
|
{
|
||||||
|
await fileStream.CopyToAsync(outputStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the public web-relative URL
|
||||||
|
return $"/uploads/{uniqueFileName}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,10 @@ public static class PromptRegistry
|
|||||||
{
|
{
|
||||||
public const string KnowledgeExtractionSystemPrompt =
|
public const string KnowledgeExtractionSystemPrompt =
|
||||||
"You are an expert educator. Analyze the provided text to extract key concepts, generate relevant quizzes, and construct a knowledge graph. " +
|
"You are an expert educator. Analyze the provided text to extract key concepts, generate relevant quizzes, and construct a knowledge graph. " +
|
||||||
"CRITICAL: Restrict 'concept.label' to a maximum of 3 words (e.g., 'Dependency Injection' instead of full sentences). " +
|
"**LANGUAGE CRITICAL**: Detect the language of the provided text. You MUST generate all human-readable fields ('title', 'description', 'question', 'options', 'label') in the EXACT SAME LANGUAGE as the source text. Do NOT translate them to English unless the source text is in English. " +
|
||||||
|
"CRITICAL: Restrict 'concept.label' to a maximum of 3 words (e.g., 'Dependency Injection' or its exact foreign equivalent, never full sentences). " +
|
||||||
"CRITICAL: Extract a MAXIMUM of 15 key concepts/plot points from the text. " +
|
"CRITICAL: Extract a MAXIMUM of 15 key concepts/plot points from the text. " +
|
||||||
"CRITICAL: Code blocks (e.g., markdown code snippets) must be excluded from the relationship graph, or summarized as a single node (e.g., 'Code Example'). Do NOT create nodes for variables, functions, namespaces, or individual lines of code. " +
|
"CRITICAL: Code blocks (e.g., markdown code snippets) must be excluded from the relationship graph, or summarized as a single node with the label 'Code Example' translated to the detected language. Do NOT create nodes for variables, functions, namespaces, or individual lines of code. " +
|
||||||
"CRITICAL: Return ONLY a minified JSON object. Do NOT include markdown formatting like ```json or ```. Do NOT include explanations. " +
|
"CRITICAL: Return ONLY a minified JSON object. Do NOT include markdown formatting like ```json or ```. Do NOT include explanations. " +
|
||||||
"Schema: { " +
|
"Schema: { " +
|
||||||
"\"concepts\": [ { \"title\": \"string\", \"description\": \"string\" } ], " +
|
"\"concepts\": [ { \"title\": \"string\", \"description\": \"string\" } ], " +
|
||||||
@@ -15,28 +16,66 @@ public static class PromptRegistry
|
|||||||
"}.";
|
"}.";
|
||||||
|
|
||||||
public const string GraphExtractionPrompt =
|
public const string GraphExtractionPrompt =
|
||||||
"You are an expert at information architecture. Extract key concepts and paragraph mappings from the text to build a unified knowledge graph. " +
|
"You are a strict Minimalist Information Architect. Your sole job is to build a high-level, sparse linear backbone for a textbook chapter. " +
|
||||||
"The input text consists of several paragraphs, each starting with its unique block ID in the format '[ID: seg-X]'. " +
|
"**LANGUAGE CRITICAL**: Detect the language of the provided text. The 'label', 'summary', and 'key_terms' fields MUST be in the EXACT SAME LANGUAGE as the source text. " +
|
||||||
"Extract two types of nodes: " +
|
"The input text consists of sections starting with block IDs (e.g., '[ID: seg-4]'). " +
|
||||||
"1. Concept Nodes (group: 'concept'): Extract the main technical concepts discussed (e.g., ID: 'dependency-injection', label: 'Dependency Injection'). Max 10 concepts. Labels must be at most 3 words. " +
|
"CRITICAL TOPOLOGY RULES (ZERO TOLERANCE FOR CLUTTER): " +
|
||||||
"2. Block Nodes (group: 'current'): For each paragraph in the input, create a node representing that paragraph where 'id' is the exact block ID (e.g., 'seg-1'), and 'label' is a brief summary of that paragraph's content (max 3 words). " +
|
"1. HARD NODE LIMIT: You are strictly forbidden from extracting more than 4 to 5 nodes IN TOTAL for the entire text. If there are more sections, select ONLY the 4-5 absolute most critical, high-level structural pillars. " +
|
||||||
"CRITICAL: If a paragraph is a code block, represent it as a single block node with label 'Code Example' (group: 'current'). Do NOT extract low-level code elements (like variables, classes, methods, or namespaces) as separate concept nodes. " +
|
"2. NO CONCEPT CLOUDS: Do NOT create nodes for individual technologies, files, terms, or phrases (e.g., 'Kestrel', 'appsettings.json', 'DI', 'Blazor Server' must NEVER be nodes). They must ONLY exist as text strings inside the 'key_terms' array of a major node. " +
|
||||||
"CRITICAL: Connect related concept nodes together, and connect each concept node to the block nodes ('seg-X') where it is discussed. " +
|
"3. LINEAR SPINE PATTERN: Nodes must form a clear, clean path or simple tree representing the chronological reading journey (e.g., Node 1 -> Node 2 -> Node 3). Do NOT create complex web loops or interconnect every node. Limit total links in the entire JSON to maximum 4 or 5 links. " +
|
||||||
"Limit connections to a MAXIMUM of 15 most relevant links. " +
|
"4. NODE DATA STRUCTURE: " +
|
||||||
"Return ONLY minified JSON. Schema: { \"graph\": { \"nodes\": [ { \"id\": \"string\", \"label\": \"string\", \"group\": \"concept|current\" } ], \"links\": [ { \"source\": \"string\", \"target\": \"string\", \"value\": 1 } ] } }";
|
" - 'id': must be the exact block ID (e.g., 'seg-16'). " +
|
||||||
|
" - 'label': clear technical title (Max 3 words, e.g., 'Blazor Hosting Models'). " +
|
||||||
|
" - 'group': strictly either 'bridge' (if it compares legacy vs modern) or 'concept' (for standalone core pillars). " +
|
||||||
|
" - 'summary': exact 2-sentence distillation for the Contextual Panel. " +
|
||||||
|
" - 'key_terms': array of max 5 short strings representing the micro-concepts hidden inside this section. " +
|
||||||
|
"System keys configuration: All JSON keys ('nodes', 'links', 'id', 'label', 'group', 'summary', 'key_terms', 'source', 'target', 'type') must remain strictly in English. " +
|
||||||
|
"Return ONLY minified JSON. Schema: " +
|
||||||
|
"{ " +
|
||||||
|
" \"graph\": { " +
|
||||||
|
" \"nodes\": [ " +
|
||||||
|
" { \"id\": \"seg-X\", \"label\": \"string\", \"group\": \"concept|bridge\", \"summary\": \"string\", \"key_terms\": [ \"string\" ] } " +
|
||||||
|
" ], " +
|
||||||
|
" \"links\": [ " +
|
||||||
|
" { \"source\": \"seg-X\", \"target\": \"seg-Y\", \"type\": \"maps_to|contains\" } " +
|
||||||
|
" ] " +
|
||||||
|
" } " +
|
||||||
|
"}";
|
||||||
|
|
||||||
public const string SummaryAndQuizPrompt =
|
public const string SummaryAndQuizPrompt =
|
||||||
"You are an expert educator. Provide a concise summary of the text and generate a challenging quiz (3-5 questions). " +
|
"You are an expert educator. Provide a concise summary of the text and generate a challenging quiz (3-5 questions). " +
|
||||||
|
"**LANGUAGE CRITICAL**: Detect the language of the provided text. The generated 'summary', 'question', and 'options' MUST be in the EXACT SAME LANGUAGE as the source text. " +
|
||||||
"Return ONLY minified JSON. Schema: { \"summary\": \"string\", \"quizzes\": [ { \"question\": \"string\", \"options\": [ \"string\" ], \"correct_index\": 0 } ] }";
|
"Return ONLY minified JSON. Schema: { \"summary\": \"string\", \"quizzes\": [ { \"question\": \"string\", \"options\": [ \"string\" ], \"correct_index\": 0 } ] }";
|
||||||
|
|
||||||
public const string KM_ExtractionPrompt =
|
public const string KM_ExtractionPrompt =
|
||||||
"You are an expert at Knowledge Engineering. Segment the provided text into discrete Knowledge Units. " +
|
"You are an expert at Knowledge Engineering. Segment the provided text into discrete Knowledge Units. " +
|
||||||
|
"**LANGUAGE CRITICAL**: Detect the language of the provided text. The 'content' field MUST be in the EXACT SAME LANGUAGE as the source text. " +
|
||||||
"Identify 'units' (sections, tables, definitions, rules) and 'links' (how they relate). " +
|
"Identify 'units' (sections, tables, definitions, rules) and 'links' (how they relate). " +
|
||||||
"CRITICAL: Units must be granular. " +
|
"CRITICAL: Units must be granular. " +
|
||||||
"CRITICAL: Code blocks must be summarized under the parent unit or represented as a single 'Code Example' unit. Do NOT segment code blocks into granular low-level code details (e.g., classes, variables, parameters). " +
|
"CRITICAL: Code blocks must be summarized under the parent unit or represented as a single 'Code Example' unit (translate the name to the detected language). Do NOT segment code blocks into granular low-level code details (e.g., classes, variables, parameters). " +
|
||||||
|
"CRITICAL SYSTEM VALUES: The fields 'type' (strictly: 'Section', 'Table', 'Definition', or 'Rule') and 'relation' (strictly: 'Next', 'Defines', 'Contains', or 'References') are system keys and MUST remain in English as specified. " +
|
||||||
"Schema: { " +
|
"Schema: { " +
|
||||||
"\"units\": [ { \"id\": \"string\", \"type\": \"Section|Table|Definition|Rule\", \"content\": \"string\", \"metadata\": { \"page\": 0 } } ], " +
|
"\"units\": [ { \"id\": \"string\", \"type\": \"Section|Table|Definition|Rule\", \"content\": \"string\", \"metadata\": { \"page\": 0 } } ], " +
|
||||||
"\"links\": [ { \"source\": \"string\", \"target\": \"string\", \"relation\": \"Next|Defines|Contains|References\" } ] " +
|
"\"links\": [ { \"source\": \"string\", \"target\": \"string\", \"relation\": \"Next|Defines|Contains|References\" } ] " +
|
||||||
"}.";
|
"}.";
|
||||||
|
|
||||||
|
public const string GroundedRAGSystemPrompt = """
|
||||||
|
You are an advanced, extremely precise Fact-Checking AI assistant. Your task is to answer the user's question using ONLY the provided context blocks.
|
||||||
|
|
||||||
|
Strict Grounding Rules:
|
||||||
|
1. Rely EXCLUSIVELY on the provided context. Do NOT use any pre-existing external knowledge, facts, or assumptions.
|
||||||
|
2. If the context does not contain the answer, you must set the "answer" property in the JSON object exactly to: 'I cannot answer this based on the provided book context.' and the "citations" array must be empty.
|
||||||
|
3. For every statement or claim you make in your answer, you must cite the specific source IDs (e.g., source chunk ID or hash) from the context.
|
||||||
|
4. You must format your response ONLY as a JSON object matching the following structure:
|
||||||
|
{
|
||||||
|
"answer": "The answer text goes here, referencing [Source ID] as citations.",
|
||||||
|
"citations": [
|
||||||
|
{
|
||||||
|
"citationId": "The exact source ID cited (e.g., chunk hash/ID)",
|
||||||
|
"snippet": "The precise sentence or phrase from the context that supports this statement.",
|
||||||
|
"sourceBook": "The book title or 'Unknown'"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,22 @@ public partial class App : Microsoft.Maui.Controls.Application
|
|||||||
|
|
||||||
MainPage = new MainPage();
|
MainPage = new MainPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override Window CreateWindow(IActivationState? activationState)
|
||||||
|
{
|
||||||
|
var window = base.CreateWindow(activationState);
|
||||||
|
|
||||||
|
// Hook into native MAUI lifecycle events to cleanly flush and close Serilog buffers
|
||||||
|
window.Stopped += (s, e) =>
|
||||||
|
{
|
||||||
|
Serilog.Log.CloseAndFlush();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Destroying += (s, e) =>
|
||||||
|
{
|
||||||
|
Serilog.Log.CloseAndFlush();
|
||||||
|
};
|
||||||
|
|
||||||
|
return window;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Threading;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
using NexusReader.UI.Shared.Services;
|
||||||
|
|
||||||
|
namespace NexusReader.Maui.Infrastructure.Identity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A secure HTTP message delegating handler for MAUI that automatically appends JWT tokens
|
||||||
|
/// to trusted origin requests and transparently refreshes expired tokens in a thread-safe manner.
|
||||||
|
/// </summary>
|
||||||
|
public class MobileAuthenticationHeaderHandler : DelegatingHandler
|
||||||
|
{
|
||||||
|
private readonly INativeStorageService _storageService;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
private const string TokenKey = "nexus_auth_token";
|
||||||
|
private static readonly SemaphoreSlim _refreshSemaphore = new(1, 1);
|
||||||
|
|
||||||
|
public MobileAuthenticationHeaderHandler(INativeStorageService storageService, IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
_storageService = storageService;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var path = request.RequestUri?.AbsolutePath ?? "";
|
||||||
|
bool isAuthEndpoint = path.Contains("identity/login") ||
|
||||||
|
path.Contains("identity/register") ||
|
||||||
|
path.Contains("identity/refresh");
|
||||||
|
|
||||||
|
// Resolve configured API host dynamically to avoid hardcoded IP addresses
|
||||||
|
var config = _serviceProvider.GetRequiredService<IConfiguration>();
|
||||||
|
var apiBaseUrlString = config["ApiSettings:BaseUrl"];
|
||||||
|
string? apiHost = null;
|
||||||
|
if (!string.IsNullOrEmpty(apiBaseUrlString) && Uri.TryCreate(apiBaseUrlString, UriKind.Absolute, out var apiUri))
|
||||||
|
{
|
||||||
|
apiHost = apiUri.Host;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In MAUI, since we only call our own local or staging APIs, we trust local IP/localhost/configured API host.
|
||||||
|
// We ensure we don't accidentally leak tokens to third-party endpoints.
|
||||||
|
bool isTrustedHost = request.RequestUri != null &&
|
||||||
|
(request.RequestUri.Host == "localhost" ||
|
||||||
|
request.RequestUri.Host == "127.0.0.1" ||
|
||||||
|
(apiHost != null && request.RequestUri.Host == apiHost) ||
|
||||||
|
request.RequestUri.Host.EndsWith("nexusreader.com")); // Or staging domains
|
||||||
|
|
||||||
|
string? originalToken = null;
|
||||||
|
|
||||||
|
if (!isAuthEndpoint && isTrustedHost)
|
||||||
|
{
|
||||||
|
var tokenResult = await _storageService.GetSecureString(TokenKey);
|
||||||
|
if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value))
|
||||||
|
{
|
||||||
|
originalToken = tokenResult.Value;
|
||||||
|
|
||||||
|
// Only attach the Bearer token if it is not expired
|
||||||
|
if (!JwtTokenValidator.IsExpired(originalToken))
|
||||||
|
{
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await base.SendAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
// Transparent JWT Auto-Refresh on 401 Unauthorized
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized && !isAuthEndpoint)
|
||||||
|
{
|
||||||
|
await _refreshSemaphore.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Re-read token to verify if another concurrent request already refreshed it
|
||||||
|
var tokenResult = await _storageService.GetSecureString(TokenKey);
|
||||||
|
var currentToken = tokenResult.IsSuccess ? tokenResult.Value : null;
|
||||||
|
|
||||||
|
bool refreshed = false;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(currentToken) && currentToken != originalToken)
|
||||||
|
{
|
||||||
|
refreshed = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var identityService = scope.ServiceProvider.GetRequiredService<IIdentityService>();
|
||||||
|
var refreshResult = await identityService.RefreshTokenAsync();
|
||||||
|
if (refreshResult.IsSuccess)
|
||||||
|
{
|
||||||
|
var newTokenResult = await _storageService.GetSecureString(TokenKey);
|
||||||
|
currentToken = newTokenResult.IsSuccess ? newTokenResult.Value : null;
|
||||||
|
refreshed = !string.IsNullOrEmpty(currentToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await identityService.LogoutAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshed && !string.IsNullOrEmpty(currentToken))
|
||||||
|
{
|
||||||
|
var newRequest = await CloneHttpRequestMessageAsync(request);
|
||||||
|
newRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", currentToken);
|
||||||
|
return await base.SendAsync(newRequest, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Error(ex, "[MobileAuthHandler] Automated token renewal failed");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_refreshSemaphore.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HttpRequestMessage> CloneHttpRequestMessageAsync(HttpRequestMessage req)
|
||||||
|
{
|
||||||
|
var clone = new HttpRequestMessage(req.Method, req.RequestUri)
|
||||||
|
{
|
||||||
|
Version = req.Version
|
||||||
|
};
|
||||||
|
|
||||||
|
if (req.Content != null)
|
||||||
|
{
|
||||||
|
var ms = new System.IO.MemoryStream();
|
||||||
|
await req.Content.CopyToAsync(ms);
|
||||||
|
ms.Position = 0;
|
||||||
|
clone.Content = new StreamContent(ms);
|
||||||
|
|
||||||
|
foreach (var h in req.Content.Headers)
|
||||||
|
{
|
||||||
|
clone.Content.Headers.TryAddWithoutValidation(h.Key, h.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var h in req.Headers)
|
||||||
|
{
|
||||||
|
clone.Headers.TryAddWithoutValidation(h.Key, h.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
|
|
||||||
|
namespace NexusReader.Maui.Infrastructure.Logging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lightweight bridge service that intercepts logs, console outputs, and uncaught exceptions
|
||||||
|
/// from the Blazor WebView/JS side, and routes them directly to Serilog under "BlazorWebView" context.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BlazorLoggingBridge
|
||||||
|
{
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
|
public BlazorLoggingBridge(ILoggerFactory loggerFactory)
|
||||||
|
{
|
||||||
|
_logger = loggerFactory.CreateLogger("BlazorWebView");
|
||||||
|
}
|
||||||
|
|
||||||
|
[JSInvokable("LogJsMessage")]
|
||||||
|
public void LogJsMessage(string level, string message, string? stackTrace = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(message))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (level.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "error":
|
||||||
|
case "exception":
|
||||||
|
if (!string.IsNullOrWhiteSpace(stackTrace))
|
||||||
|
{
|
||||||
|
_logger.LogError("JS Unhandled Exception: {Message}\nStack Trace:\n{StackTrace}", message, stackTrace);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError("JS Error: {Message}", message);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "warning":
|
||||||
|
case "warn":
|
||||||
|
_logger.LogWarning("JS Warning: {Message}", message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "info":
|
||||||
|
case "log":
|
||||||
|
default:
|
||||||
|
_logger.LogInformation("JS Log: {Message}", message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Serilog;
|
||||||
|
using Serilog.Core;
|
||||||
|
using Serilog.Events;
|
||||||
|
using Serilog.Formatting;
|
||||||
|
using Serilog.Formatting.Display;
|
||||||
|
|
||||||
|
namespace NexusReader.Maui.Infrastructure.Logging;
|
||||||
|
|
||||||
|
public static class SerilogConfiguration
|
||||||
|
{
|
||||||
|
private const string OutputTemplate =
|
||||||
|
"[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [ThreadId: {ThreadId}] [{SourceContext}] {Message:lj}{NewLine}{Exception}";
|
||||||
|
|
||||||
|
public static MauiAppBuilder RegisterLogging(this MauiAppBuilder builder)
|
||||||
|
{
|
||||||
|
// 1. Ensure logs directory exists in secure sandbox
|
||||||
|
var logDir = Path.Combine(Microsoft.Maui.Storage.FileSystem.AppDataDirectory, "logs");
|
||||||
|
if (!Directory.Exists(logDir))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(logDir);
|
||||||
|
}
|
||||||
|
var logPath = Path.Combine(logDir, "log-.txt");
|
||||||
|
|
||||||
|
// 2. Inject sandboxed log path dynamically into configuration provider
|
||||||
|
builder.Configuration["Serilog:WriteTo:0:Args:configure:0:Args:path"] = logPath;
|
||||||
|
|
||||||
|
// 3. Configure Serilog Logger Configuration using App Configuration settings
|
||||||
|
var loggerConfig = new LoggerConfiguration()
|
||||||
|
.ReadFrom.Configuration(builder.Configuration)
|
||||||
|
.Enrich.With(new ThreadIdEnricher());
|
||||||
|
|
||||||
|
// 4. Platform-specific and environment-specific sinks
|
||||||
|
#if ANDROID
|
||||||
|
// Direct Native Android Logcat Sink (JNI bindings for native diagnostics)
|
||||||
|
loggerConfig.WriteTo.Sink(
|
||||||
|
new AndroidLogcatSink(new MessageTemplateTextFormatter(OutputTemplate, null)),
|
||||||
|
restrictedToMinimumLevel: LogEventLevel.Debug);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// 5. Initialize the static Serilog Log
|
||||||
|
Log.Logger = loggerConfig.CreateLogger();
|
||||||
|
|
||||||
|
// 6. Connect Serilog to Microsoft.Extensions.Logging
|
||||||
|
builder.Logging.ClearProviders();
|
||||||
|
builder.Logging.AddSerilog(dispose: true);
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A custom self-contained thread enricher to avoid unnecessary NuGet packages.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ThreadIdEnricher : ILogEventEnricher
|
||||||
|
{
|
||||||
|
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
|
||||||
|
{
|
||||||
|
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("ThreadId", Environment.CurrentManagedThreadId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if ANDROID
|
||||||
|
/// <summary>
|
||||||
|
/// A high-performance, direct Android Logcat Sink utilizing native Android APIs.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class AndroidLogcatSink : ILogEventSink
|
||||||
|
{
|
||||||
|
private readonly ITextFormatter _formatter;
|
||||||
|
private const string Tag = "NexusReader";
|
||||||
|
|
||||||
|
public AndroidLogcatSink(ITextFormatter formatter)
|
||||||
|
{
|
||||||
|
_formatter = formatter ?? throw new ArgumentNullException(nameof(formatter));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Emit(LogEvent logEvent)
|
||||||
|
{
|
||||||
|
using var writer = new StringWriter();
|
||||||
|
_formatter.Format(logEvent, writer);
|
||||||
|
var message = writer.ToString().Trim();
|
||||||
|
|
||||||
|
switch (logEvent.Level)
|
||||||
|
{
|
||||||
|
case LogEventLevel.Verbose:
|
||||||
|
Android.Util.Log.Verbose(Tag, message);
|
||||||
|
break;
|
||||||
|
case LogEventLevel.Debug:
|
||||||
|
Android.Util.Log.Debug(Tag, message);
|
||||||
|
break;
|
||||||
|
case LogEventLevel.Information:
|
||||||
|
Android.Util.Log.Info(Tag, message);
|
||||||
|
break;
|
||||||
|
case LogEventLevel.Warning:
|
||||||
|
Android.Util.Log.Warn(Tag, message);
|
||||||
|
break;
|
||||||
|
case LogEventLevel.Error:
|
||||||
|
Android.Util.Log.Error(Tag, message);
|
||||||
|
break;
|
||||||
|
case LogEventLevel.Fatal:
|
||||||
|
Android.Util.Log.Wtf(Tag, message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Routing
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
@using NexusReader.UI.Shared
|
@using NexusReader.UI.Shared
|
||||||
|
@using NexusReader.Maui.Infrastructure.Logging
|
||||||
|
@inject IJSRuntime JSRuntime
|
||||||
|
@inject BlazorLoggingBridge LoggingBridge
|
||||||
|
|
||||||
<Router AppAssembly="@typeof(NexusReader.UI.Shared._Imports).Assembly">
|
<Router AppAssembly="@typeof(NexusReader.UI.Shared._Imports).Assembly">
|
||||||
<Found Context="routeData">
|
<Found Context="routeData">
|
||||||
@@ -16,3 +19,21 @@
|
|||||||
</LayoutView>
|
</LayoutView>
|
||||||
</NotFound>
|
</NotFound>
|
||||||
</Router>
|
</Router>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dotNetRef = DotNetObjectReference.Create(LoggingBridge);
|
||||||
|
await JSRuntime.InvokeVoidAsync("NexusLogging.initializeBridge", dotNetRef);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
System.Diagnostics.Debug.WriteLine($"[SerilogBridge] Failed to initialize Blazor/JS Bridge: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
using NexusReader.Infrastructure.Mobile.Services;
|
using NexusReader.Infrastructure.Mobile.Services;
|
||||||
using NexusReader.UI.Shared.Services;
|
using NexusReader.UI.Shared.Services;
|
||||||
using NexusReader.Application;
|
using NexusReader.Application;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
using NexusReader.Maui.Infrastructure.Logging;
|
||||||
|
using NexusReader.Maui.Infrastructure.Identity;
|
||||||
|
using NexusReader.Maui.Services;
|
||||||
|
|
||||||
namespace NexusReader.Maui;
|
namespace NexusReader.Maui;
|
||||||
|
|
||||||
@@ -14,19 +19,33 @@ public static class MauiProgram
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var builder = MauiApp.CreateBuilder();
|
var builder = MauiApp.CreateBuilder();
|
||||||
|
|
||||||
|
// Load embedded appsettings.json configuration
|
||||||
|
var assembly = typeof(App).Assembly;
|
||||||
|
using (var stream = assembly.GetManifestResourceStream("NexusReader.Maui.appsettings.json"))
|
||||||
|
{
|
||||||
|
if (stream != null)
|
||||||
|
{
|
||||||
|
((IConfigurationBuilder)builder.Configuration).AddJsonStream(stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
builder
|
builder
|
||||||
.UseMauiApp<App>();
|
.UseMauiApp<App>()
|
||||||
|
.RegisterLogging();
|
||||||
|
|
||||||
builder.Services.AddMauiBlazorWebView();
|
builder.Services.AddMauiBlazorWebView();
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
builder.Services.AddBlazorWebViewDeveloperTools();
|
builder.Services.AddBlazorWebViewDeveloperTools();
|
||||||
builder.Logging.AddDebug();
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Interception bridge for JS/Blazor WebView logs
|
||||||
|
builder.Services.AddSingleton<BlazorLoggingBridge>();
|
||||||
|
|
||||||
// Minimal Infrastructure
|
// Minimal Infrastructure
|
||||||
builder.Services.AddSingleton<IPlatformService, MauiPlatformService>();
|
builder.Services.AddSingleton<IPlatformService, MauiPlatformService>();
|
||||||
builder.Services.AddSingleton<INativeStorageService, MauiStorageService>();
|
builder.Services.AddSingleton<INativeStorageService, NexusReader.Infrastructure.Mobile.Services.MauiStorageService>();
|
||||||
|
|
||||||
// Minimal Identity (Safe Mode)
|
// Minimal Identity (Safe Mode)
|
||||||
builder.Services.AddScoped<NexusAuthenticationStateProvider>();
|
builder.Services.AddScoped<NexusAuthenticationStateProvider>();
|
||||||
@@ -34,16 +53,30 @@ public static class MauiProgram
|
|||||||
sp.GetRequiredService<NexusAuthenticationStateProvider>());
|
sp.GetRequiredService<NexusAuthenticationStateProvider>());
|
||||||
builder.Services.AddAuthorizationCore();
|
builder.Services.AddAuthorizationCore();
|
||||||
|
|
||||||
// Basic Network
|
// Basic Network with Secure Token Handler
|
||||||
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri("http://10.0.2.2:5000") });
|
builder.Services.AddTransient<MobileAuthenticationHeaderHandler>();
|
||||||
|
builder.Services.AddHttpClient("NexusAPI", client =>
|
||||||
|
{
|
||||||
|
var apiBaseUrl = builder.Configuration["ApiSettings:BaseUrl"] ?? "http://localhost:5104";
|
||||||
|
client.BaseAddress = new Uri(apiBaseUrl);
|
||||||
|
}).AddHttpMessageHandler<MobileAuthenticationHeaderHandler>();
|
||||||
|
|
||||||
|
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
// Feature settings (avoiding direct raw IConfiguration injection in client pages)
|
||||||
|
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
|
||||||
|
builder.Services.AddSingleton(featureSettings);
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<IUserPreferenceStore, MauiUserPreferenceStore>();
|
||||||
|
builder.Services.AddSingleton<IThemeService, ThemeService>();
|
||||||
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
||||||
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
||||||
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
||||||
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
||||||
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
||||||
|
builder.Services.AddScoped<IReaderStateService, ReaderStateService>();
|
||||||
|
builder.Services.AddScoped<ILibraryStateService, LibraryStateService>();
|
||||||
builder.Services.AddScoped<KnowledgeCoordinator>();
|
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||||
builder.Services.AddScoped<ISyncService, SyncService>();
|
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||||
builder.Services.AddScoped<IIdentityService, IdentityService>();
|
builder.Services.AddScoped<IIdentityService, IdentityService>();
|
||||||
|
|||||||
@@ -27,6 +27,14 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="10.0.20" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="10.0.20" />
|
||||||
<PackageReference Include="Microsoft.Maui.Essentials" Version="10.0.20" />
|
<PackageReference Include="Microsoft.Maui.Essentials" Version="10.0.20" />
|
||||||
|
<PackageReference Include="Serilog" Version="4.1.0" />
|
||||||
|
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Async" Version="2.1.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -34,4 +42,8 @@
|
|||||||
<ProjectReference Include="..\NexusReader.Infrastructure.Mobile\NexusReader.Infrastructure.Mobile.csproj" />
|
<ProjectReference Include="..\NexusReader.Infrastructure.Mobile\NexusReader.Infrastructure.Mobile.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="appsettings.json" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using FluentResults;
|
||||||
|
using NexusReader.Application.DTOs.User;
|
||||||
|
using NexusReader.Domain.Enums;
|
||||||
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace NexusReader.Maui.Services;
|
||||||
|
|
||||||
|
public class MauiUserPreferenceStore : IUserPreferenceStore
|
||||||
|
{
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
|
||||||
|
public MauiUserPreferenceStore(IHttpClientFactory httpClientFactory)
|
||||||
|
{
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient CreateClient() => _httpClientFactory.CreateClient("NexusAPI");
|
||||||
|
|
||||||
|
public async Task<Result> SaveThemePreferenceAsync(ThemeMode mode)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = CreateClient();
|
||||||
|
var response = await client.PostAsJsonAsync("identity/theme", new UpdateThemeRequest(mode));
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
var error = await response.Content.ReadAsStringAsync();
|
||||||
|
return Result.Fail($"Failed to save cloud theme preference on mobile: {error}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error("Network error saving mobile theme preference to cloud.").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<ThemeMode>> GetThemePreferenceAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = CreateClient();
|
||||||
|
var response = await client.GetAsync("identity/profile");
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var profile = await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
||||||
|
return profile != null
|
||||||
|
? Result.Ok(profile.ThemePreference)
|
||||||
|
: Result.Fail("Failed to deserialize mobile profile response.");
|
||||||
|
}
|
||||||
|
return Result.Fail($"Failed to fetch theme preference from cloud on mobile: {response.ReasonPhrase}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error("Network error retrieving theme preference on mobile.").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"ApiSettings": {
|
||||||
|
"BaseUrl": "http://localhost:5104"
|
||||||
|
},
|
||||||
|
"Serilog": {
|
||||||
|
"Using": [
|
||||||
|
"Serilog.Sinks.File",
|
||||||
|
"Serilog.Sinks.Debug",
|
||||||
|
"Serilog.Sinks.Async"
|
||||||
|
],
|
||||||
|
"MinimumLevel": {
|
||||||
|
"Default": "Debug",
|
||||||
|
"Override": {
|
||||||
|
"Microsoft": "Warning",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"System": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"WriteTo": [
|
||||||
|
{
|
||||||
|
"Name": "Async",
|
||||||
|
"Args": {
|
||||||
|
"configure": [
|
||||||
|
{
|
||||||
|
"Name": "File",
|
||||||
|
"Args": {
|
||||||
|
"path": "LOG_PATH_PLACEHOLDER",
|
||||||
|
"rollingInterval": "Day",
|
||||||
|
"retainedFileCountLimit": 7,
|
||||||
|
"outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [ThreadId: {ThreadId}] [{SourceContext}] {Message:lj}{NewLine}{Exception}",
|
||||||
|
"shared": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Debug",
|
||||||
|
"Args": {
|
||||||
|
"outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [ThreadId: {ThreadId}] [{SourceContext}] {Message:lj}{NewLine}{Exception}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Enrich": [
|
||||||
|
"FromLogContext"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,33 @@
|
|||||||
<base href="/" />
|
<base href="/" />
|
||||||
<link rel="stylesheet" href="_content/NexusReader.UI.Shared/app.css" />
|
<link rel="stylesheet" href="_content/NexusReader.UI.Shared/app.css" />
|
||||||
<link rel="icon" type="image/png" href="favicon.png" />
|
<link rel="icon" type="image/png" href="favicon.png" />
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var themeMode = localStorage.getItem('theme-mode');
|
||||||
|
var savedTheme = localStorage.getItem('theme');
|
||||||
|
var isLight = false;
|
||||||
|
|
||||||
|
if (themeMode === '2' || savedTheme === 'light') {
|
||||||
|
isLight = true;
|
||||||
|
} else if (themeMode === '1' || savedTheme === 'dark') {
|
||||||
|
isLight = false;
|
||||||
|
} else {
|
||||||
|
isLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLight) {
|
||||||
|
document.documentElement.classList.add('theme-light');
|
||||||
|
document.documentElement.classList.remove('theme-dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.add('theme-dark');
|
||||||
|
document.documentElement.classList.remove('theme-light');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fail silently
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -26,7 +53,54 @@
|
|||||||
|
|
||||||
<script src="_framework/blazor.webview.js" autostart="false"></script>
|
<script src="_framework/blazor.webview.js" autostart="false"></script>
|
||||||
<script src="_content/NexusReader.UI.Shared/js/d3.v7.min.js"></script>
|
<script src="_content/NexusReader.UI.Shared/js/d3.v7.min.js"></script>
|
||||||
|
<script>
|
||||||
|
window.NexusLogging = {
|
||||||
|
initializeBridge: function (dotNetHelper) {
|
||||||
|
const originalLog = console.log;
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
const originalError = console.error;
|
||||||
|
|
||||||
|
console.log = function (...args) {
|
||||||
|
originalLog.apply(console, args);
|
||||||
|
try {
|
||||||
|
dotNetHelper.invokeMethodAsync('LogJsMessage', 'info', args.map(x => typeof x === 'object' ? JSON.stringify(x) : String(x)).join(' '));
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.warn = function (...args) {
|
||||||
|
originalWarn.apply(console, args);
|
||||||
|
try {
|
||||||
|
dotNetHelper.invokeMethodAsync('LogJsMessage', 'warn', args.map(x => typeof x === 'object' ? JSON.stringify(x) : String(x)).join(' '));
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error = function (...args) {
|
||||||
|
originalError.apply(console, args);
|
||||||
|
try {
|
||||||
|
dotNetHelper.invokeMethodAsync('LogJsMessage', 'error', args.map(x => typeof x === 'object' ? JSON.stringify(x) : String(x)).join(' '));
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.onerror = function (message, source, lineno, colno, error) {
|
||||||
|
const stack = error ? error.stack : '';
|
||||||
|
try {
|
||||||
|
dotNetHelper.invokeMethodAsync('LogJsMessage', 'error', `${message} at ${source}:${lineno}:${colno}`, stack);
|
||||||
|
} catch (e) {}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', function (event) {
|
||||||
|
const reason = event.reason;
|
||||||
|
const message = reason instanceof Error ? reason.message : String(reason);
|
||||||
|
const stack = reason instanceof Error ? reason.stack : '';
|
||||||
|
try {
|
||||||
|
dotNetHelper.invokeMethodAsync('LogJsMessage', 'error', `Unhandled Promise Rejection: ${message}`, stack);
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="_content/NexusReader.UI.Shared/js/theme.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
@using NexusReader.Application.DTOs.AI
|
||||||
|
|
||||||
|
<div class="nexus-citation-container" @onmouseenter="ShowPopup" @onmouseleave="HidePopup">
|
||||||
|
<button type="button" class="nexus-citation-trigger" aria-label="Citation source">
|
||||||
|
<!-- Circular Neon SVG Radar Ping / Stylized Book Icon -->
|
||||||
|
<svg class="neon-radar-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<circle cx="12" cy="12" r="6"></circle>
|
||||||
|
<circle cx="12" cy="12" r="2"></circle>
|
||||||
|
</svg>
|
||||||
|
<span class="pulse-ring"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (_isHovered && _citation != null)
|
||||||
|
{
|
||||||
|
<div class="nexus-citation-popup">
|
||||||
|
<div class="popup-header">
|
||||||
|
<span class="book-title"><i class="bi bi-book-half"></i> @_citation.SourceBook</span>
|
||||||
|
@if (!string.IsNullOrEmpty(_citation.Author))
|
||||||
|
{
|
||||||
|
<span class="separator">•</span>
|
||||||
|
<span class="book-author">@_citation.Author</span>
|
||||||
|
}
|
||||||
|
@if (_citation.PageNumber.HasValue)
|
||||||
|
{
|
||||||
|
<span class="separator">•</span>
|
||||||
|
<span class="page-number">Page @_citation.PageNumber.Value</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="popup-body">
|
||||||
|
<p class="citation-quote">"@_citation.Snippet"</p>
|
||||||
|
</div>
|
||||||
|
<div class="popup-footer">
|
||||||
|
<span class="id-badge">ID: @SourceId.Substring(0, Math.Min(8, SourceId.Length))</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
[EditorRequired]
|
||||||
|
public string SourceId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public List<CitationDto>? Citations { get; set; }
|
||||||
|
|
||||||
|
private bool _isHovered;
|
||||||
|
private CitationDto? _citation;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
_citation = Citations?.FirstOrDefault(c => c.CitationId.Equals(SourceId, System.StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
// If not found in the thread citations, provide a clean fallback so the UI never displays an empty error
|
||||||
|
if (_citation == null)
|
||||||
|
{
|
||||||
|
_citation = new CitationDto
|
||||||
|
{
|
||||||
|
CitationId = SourceId,
|
||||||
|
SourceBook = "Grounded Document Chunk",
|
||||||
|
Snippet = "Context snippet retrieved from vector search node."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowPopup()
|
||||||
|
{
|
||||||
|
_isHovered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HidePopup()
|
||||||
|
{
|
||||||
|
_isHovered = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
.nexus-citation-container {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nexus-citation-trigger {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #06b6d4; /* Glowing Cyan */
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
position: relative;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nexus-citation-trigger:hover {
|
||||||
|
color: #00ff99; /* Neon Green on hover */
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-radar-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
filter: drop-shadow(0 0 4px currentColor);
|
||||||
|
animation: radar-spin 8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-ring {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: radar-ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nexus-citation-popup {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 10px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(5px);
|
||||||
|
width: 320px;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(10, 16, 26, 0.9); /* Premium dark background */
|
||||||
|
border: 1px solid rgba(6, 182, 212, 0.25); /* Cyan border */
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5), 0 0 12px rgba(6, 182, 212, 0.15);
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
animation: popup-fade-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
transform-origin: bottom center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #00ff99; /* Emerald/Neon Green micro-header */
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-author, .page-number {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-body {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-quote {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.id-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes radar-spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes radar-ping {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(2.2);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes popup-fade-in {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(8px) scale(0.95);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
<line x1="8" y1="2" x2="8" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<line x1="8" y1="2" x2="8" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
<line x1="16" y1="6" x2="16" y2="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<line x1="16" y1="6" x2="16" y2="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
break;
|
break;
|
||||||
|
case "share":
|
||||||
case "share-2":
|
case "share-2":
|
||||||
<circle cx="18" cy="5" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<circle cx="18" cy="5" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
<circle cx="6" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<circle cx="6" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
@@ -32,10 +33,11 @@
|
|||||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||||
break;
|
break;
|
||||||
case "search":
|
case "search":
|
||||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
|
<circle cx="11" cy="11" r="8" fill="none" stroke="currentColor" stroke-width="2" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
break;
|
break;
|
||||||
case "message-square":
|
case "message-square":
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
break;
|
break;
|
||||||
case "diamond":
|
case "diamond":
|
||||||
<path d="M12 3L3 12L12 21L21 12L12 3Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M12 3L3 12L12 21L21 12L12 3Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
@@ -45,6 +47,7 @@
|
|||||||
<line x1="3" y1="9" x2="21" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<line x1="3" y1="9" x2="21" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
<line x1="9" y1="21" x2="9" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<line x1="9" y1="21" x2="9" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
break;
|
break;
|
||||||
|
case "book":
|
||||||
case "book-open":
|
case "book-open":
|
||||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
@@ -54,38 +57,76 @@
|
|||||||
<circle cx="12" cy="7" r="4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<circle cx="12" cy="7" r="4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
break;
|
break;
|
||||||
case "settings":
|
case "settings":
|
||||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
|
||||||
|
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
break;
|
break;
|
||||||
case "bookmark":
|
case "bookmark":
|
||||||
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" />
|
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
break;
|
break;
|
||||||
case "target":
|
case "target":
|
||||||
<circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="6" /><circle cx="12" cy="12" r="2" />
|
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" />
|
||||||
|
<circle cx="12" cy="12" r="6" fill="none" stroke="currentColor" stroke-width="2" />
|
||||||
|
<circle cx="12" cy="12" r="2" fill="none" stroke="currentColor" stroke-width="2" />
|
||||||
break;
|
break;
|
||||||
case "trash":
|
case "trash":
|
||||||
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6" />
|
<polyline points="3 6 5 6 21 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<line x1="10" y1="11" x2="10" y2="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<line x1="14" y1="11" x2="14" y2="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
break;
|
break;
|
||||||
case "mail":
|
case "mail":
|
||||||
<rect width="20" height="16" x="2" y="4" rx="2" /><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
<rect width="20" height="16" x="2" y="4" rx="2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
break;
|
break;
|
||||||
case "lock":
|
case "lock":
|
||||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
break;
|
break;
|
||||||
case "eye":
|
case "eye":
|
||||||
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" /><circle cx="12" cy="12" r="3" />
|
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
break;
|
break;
|
||||||
case "eye-off":
|
case "eye-off":
|
||||||
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" /><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" /><path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" /><line x1="2" x2="22" y1="2" y2="22" />
|
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<line x1="2" x2="22" y1="2" y2="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
break;
|
break;
|
||||||
case "arrow-left":
|
case "arrow-left":
|
||||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
<line x1="19" y1="12" x2="5" y2="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<polyline points="12 19 5 12 12 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
break;
|
break;
|
||||||
case "arrow-right":
|
case "arrow-right":
|
||||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
<line x1="5" y1="12" x2="19" y2="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<polyline points="12 5 19 12 12 19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
break;
|
||||||
|
case "edit":
|
||||||
|
case "edit-2":
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4 9.5-9.5z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
break;
|
break;
|
||||||
case "log-out":
|
case "log-out":
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
break;
|
break;
|
||||||
|
case "chevron-left":
|
||||||
|
<polyline points="15 18 9 12 15 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
break;
|
||||||
|
case "chevron-right":
|
||||||
|
<polyline points="9 18 15 12 9 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
break;
|
||||||
|
case "x":
|
||||||
|
case "close":
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
break;
|
||||||
|
case "sun":
|
||||||
|
<circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
break;
|
||||||
|
case "moon":
|
||||||
|
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
<!-- Fallback circle -->
|
<!-- Fallback circle -->
|
||||||
<circle cx="12" cy="12" r="10" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,39 +1,347 @@
|
|||||||
@namespace NexusReader.UI.Shared.Components.Atoms
|
@namespace NexusReader.UI.Shared.Components.Atoms
|
||||||
|
@using System.Text.RegularExpressions
|
||||||
|
@using MediatR
|
||||||
|
@using NexusReader.Application.DTOs.AI
|
||||||
|
@using NexusReader.Application.Queries.Library
|
||||||
|
@inject IMediator Mediator
|
||||||
|
@inject IReaderNavigationService NavService
|
||||||
|
@inject IReaderInteractionService InteractionService
|
||||||
|
@inject NavigationManager NavManager
|
||||||
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
@inject ILogger<NexusSearchBox> Logger
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
<div class="nexus-search-container @(IsActive ? "active" : "")">
|
<div class="nexus-search-container @(IsFocused ? "focused" : "") @(HasResults ? "has-results" : "")" @onfocusin="HandleFocusIn" @onfocusout="HandleFocusOut">
|
||||||
<div class="search-wrapper">
|
<div class="search-wrapper">
|
||||||
<i class="nexus-icon @IconClass"></i>
|
<div class="search-icon-container">
|
||||||
<input type="text"
|
@if (_isLoading)
|
||||||
@bind="SearchValue"
|
|
||||||
@bind:event="oninput"
|
|
||||||
@onkeypress="HandleKeyPress"
|
|
||||||
placeholder="@Placeholder"
|
|
||||||
class="nexus-search-input" />
|
|
||||||
@if (!string.IsNullOrEmpty(SearchValue))
|
|
||||||
{
|
{
|
||||||
<button class="clear-btn" @onclick="ClearSearch">×</button>
|
<div class="neon-spinner"></div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="nexus-icon bi bi-search"></i>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<input type="text"
|
||||||
|
value="@SearchValue"
|
||||||
|
@oninput="HandleInput"
|
||||||
|
@onkeydown="HandleKeyDown"
|
||||||
|
placeholder="@Placeholder"
|
||||||
|
class="nexus-search-input" />
|
||||||
|
|
||||||
|
<div class="ai-status-indicator" title="Aktywny silnik AI biblioteki">
|
||||||
|
<span class="ai-pulse-dot"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(SearchValue))
|
||||||
|
{
|
||||||
|
<button type="button" class="clear-btn" @onclick="ClearSearch" aria-label="Wyczyść wyszukiwanie">×</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_isDropdownOpen && (!string.IsNullOrEmpty(SearchValue) || _isLoading || _results.Any() || _searchError != null))
|
||||||
|
{
|
||||||
|
<div class="search-dropdown glass-panel">
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
<div class="dropdown-state-container">
|
||||||
|
<div class="neon-spinner-large"></div>
|
||||||
|
<span class="state-text">Analizowanie biblioteki semantycznej...</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_searchError != null)
|
||||||
|
{
|
||||||
|
<div class="dropdown-state-container error">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill error-icon"></i>
|
||||||
|
<span class="state-text">@_searchError</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_results.Any())
|
||||||
|
{
|
||||||
|
<div class="dropdown-results-list">
|
||||||
|
@foreach (var result in _results)
|
||||||
|
{
|
||||||
|
<div class="result-card" @onclick="() => HandleResultClick(result)">
|
||||||
|
<div class="result-header">
|
||||||
|
<span class="relevance-badge">@(Math.Round(result.RelevanceScore * 100))% Trafności</span>
|
||||||
|
@if (!string.IsNullOrEmpty(result.SourceBookTitle))
|
||||||
|
{
|
||||||
|
<span class="source-title" title="@result.SourceBookTitle">w <strong>@result.SourceBookTitle</strong></span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="result-snippet">
|
||||||
|
@((MarkupString)HighlightQueryWords(result.Snippet, SearchValue))
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(SearchValue))
|
||||||
|
{
|
||||||
|
<div class="dropdown-state-container empty">
|
||||||
|
<i class="bi bi-search empty-icon"></i>
|
||||||
|
<span class="state-text">Brak wyników dla zapytania.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public string Placeholder { get; set; } = "Search your library...";
|
[Parameter] public string Placeholder { get; set; } = "Zapytaj swoją bibliotekę AI...";
|
||||||
[Parameter] public string IconClass { get; set; } = "bi bi-search";
|
|
||||||
[Parameter] public EventCallback<string> OnSearch { get; set; }
|
[Parameter] public EventCallback<string> OnSearch { get; set; }
|
||||||
|
[Parameter] public int Limit { get; set; } = 5;
|
||||||
|
|
||||||
private string SearchValue { get; set; } = string.Empty;
|
private string SearchValue { get; set; } = string.Empty;
|
||||||
private bool IsActive => !string.IsNullOrEmpty(SearchValue);
|
private bool IsFocused { get; set; }
|
||||||
|
private bool HasResults => _results.Any() && _isDropdownOpen;
|
||||||
|
|
||||||
private async Task HandleKeyPress(KeyboardEventArgs e)
|
private List<SemanticSearchResultDto> _results = new();
|
||||||
|
private bool _isLoading;
|
||||||
|
private string? _searchError;
|
||||||
|
private bool _isDropdownOpen;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
private CancellationTokenSource? _searchCts;
|
||||||
|
|
||||||
|
private async Task HandleInput(ChangeEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.Key == "Enter")
|
SearchValue = e.Value?.ToString() ?? string.Empty;
|
||||||
|
_searchError = null;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(SearchValue))
|
||||||
{
|
{
|
||||||
|
_results.Clear();
|
||||||
|
_isDropdownOpen = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isDropdownOpen = true;
|
||||||
|
|
||||||
|
// Cancel previous search in-flight
|
||||||
|
_searchCts?.Cancel();
|
||||||
|
_searchCts?.Dispose();
|
||||||
|
_searchCts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
var token = _searchCts.Token;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Debounce for 300ms
|
||||||
|
await Task.Delay(300, token);
|
||||||
|
await PerformSearchAsync(token);
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Typing continued, search cancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PerformSearchAsync(CancellationToken token)
|
||||||
|
{
|
||||||
|
_isLoading = true;
|
||||||
|
_searchError = null;
|
||||||
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global";
|
||||||
|
|
||||||
|
var result = await Mediator.Send(new SearchLibrarySemanticallyQuery(SearchValue, tenantId, Limit), token);
|
||||||
|
if (token.IsCancellationRequested || _disposed) return;
|
||||||
|
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
_results = result.Value ?? new List<SemanticSearchResultDto>();
|
||||||
|
_searchError = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_results.Clear();
|
||||||
|
_searchError = result.Errors.FirstOrDefault()?.Message ?? "Nie udało się wykonać wyszukiwania.";
|
||||||
|
Logger.LogWarning("Semantic search returned errors: {Errors}", string.Join(", ", result.Errors.Select(e => e.Message)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!token.IsCancellationRequested && !_disposed)
|
||||||
|
{
|
||||||
|
_results.Clear();
|
||||||
|
_searchError = "Wystąpił nieoczekiwany błąd podczas wyszukiwania.";
|
||||||
|
Logger.LogError(ex, "Unexpected error during semantic search for query: {Query}", SearchValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!token.IsCancellationRequested && !_disposed)
|
||||||
|
{
|
||||||
|
_isLoading = false;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleResultClick(SemanticSearchResultDto result)
|
||||||
|
{
|
||||||
|
_isDropdownOpen = false;
|
||||||
|
|
||||||
|
// 1. Resolve Ebook ID
|
||||||
|
Guid? ebookId = null;
|
||||||
|
if (result.Metadata != null)
|
||||||
|
{
|
||||||
|
foreach (var key in new[] { "ebookId", "ebook_id", "EbookId", "Ebook_Id" })
|
||||||
|
{
|
||||||
|
if (result.Metadata.TryGetValue(key, out var val) && val != null)
|
||||||
|
{
|
||||||
|
if (Guid.TryParse(val.ToString(), out var g))
|
||||||
|
{
|
||||||
|
ebookId = g;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ebookId == null || ebookId == Guid.Empty)
|
||||||
|
{
|
||||||
|
ebookId = NavService.CurrentEbookId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ebookId == null || ebookId == Guid.Empty)
|
||||||
|
{
|
||||||
|
Logger.LogWarning("Could not resolve ebook ID from search result metadata.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Resolve Chapter Index
|
||||||
|
int chapterIndex = 0;
|
||||||
|
if (result.Metadata != null)
|
||||||
|
{
|
||||||
|
foreach (var key in new[] { "chapterIndex", "chapter_index", "ChapterIndex", "chapter" })
|
||||||
|
{
|
||||||
|
if (result.Metadata.TryGetValue(key, out var val) && val != null)
|
||||||
|
{
|
||||||
|
if (int.TryParse(val.ToString(), out var parsedInt))
|
||||||
|
{
|
||||||
|
chapterIndex = parsedInt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Resolve Block ID
|
||||||
|
string? blockId = null;
|
||||||
|
if (result.Metadata != null)
|
||||||
|
{
|
||||||
|
foreach (var key in new[] { "blockId", "block_id", "BlockId", "nodeId", "node_id", "NodeId", "id" })
|
||||||
|
{
|
||||||
|
if (result.Metadata.TryGetValue(key, out var val) && val != null)
|
||||||
|
{
|
||||||
|
blockId = val.ToString();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(blockId))
|
||||||
|
{
|
||||||
|
blockId = result.ContentHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Set pending scroll and navigate
|
||||||
|
NavService.PendingScrollBlockId = blockId;
|
||||||
|
|
||||||
|
if (NavService.CurrentEbookId == ebookId.Value && NavService.CurrentChapterIndex == chapterIndex)
|
||||||
|
{
|
||||||
|
// Same chapter - scroll and highlight immediately
|
||||||
|
if (!string.IsNullOrEmpty(blockId))
|
||||||
|
{
|
||||||
|
await InteractionService.RequestScrollToBlock(blockId);
|
||||||
|
await InteractionService.RequestHighlightBlock(blockId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Different chapter or book - perform routing
|
||||||
|
NavService.SetBook(ebookId.Value, chapterIndex);
|
||||||
|
NavManager.NavigateTo($"/reader/{ebookId.Value}?chapter={chapterIndex}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoke the optional callback for parent components
|
||||||
await OnSearch.InvokeAsync(SearchValue);
|
await OnSearch.InvokeAsync(SearchValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void HandleKeyDown(KeyboardEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == "Escape")
|
||||||
|
{
|
||||||
|
_isDropdownOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleFocusIn()
|
||||||
|
{
|
||||||
|
IsFocused = true;
|
||||||
|
_isDropdownOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleFocusOut()
|
||||||
|
{
|
||||||
|
IsFocused = false;
|
||||||
|
// Delay slightly to allow click handlers on result cards to execute
|
||||||
|
await Task.Delay(200);
|
||||||
|
if (_disposed) return;
|
||||||
|
_isDropdownOpen = false;
|
||||||
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ClearSearch()
|
private void ClearSearch()
|
||||||
{
|
{
|
||||||
SearchValue = string.Empty;
|
SearchValue = string.Empty;
|
||||||
|
_results.Clear();
|
||||||
|
_searchError = null;
|
||||||
|
_isDropdownOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string HighlightQueryWords(string text, string query)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var escapedText = System.Net.WebUtility.HtmlEncode(text);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
return escapedText;
|
||||||
|
|
||||||
|
var words = query.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Where(w => w.Length > 2)
|
||||||
|
.Select(Regex.Escape);
|
||||||
|
|
||||||
|
if (!words.Any())
|
||||||
|
return escapedText;
|
||||||
|
|
||||||
|
var pattern = "(" + string.Join("|", words) + ")";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Regex.Replace(escapedText, pattern, "<mark class=\"search-highlight\">$1</mark>", RegexOptions.IgnoreCase);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return escapedText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_disposed = true;
|
||||||
|
_searchCts?.Cancel();
|
||||||
|
_searchCts?.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user