Compare commits
13 Commits
0210611edf
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3faecbb639 | |||
| ef82effeac | |||
| 311eaa8b04 | |||
| e21c24b66d | |||
| eac0e9057e | |||
| afdfc31d1a | |||
| 1f187b5125 | |||
| 94ecc7a404 | |||
| e5611758f1 | |||
| 0ed89ef5a4 | |||
| 0cc25bb412 | |||
| 93d8dfde7e | |||
| 47bffd629f |
@@ -24,6 +24,10 @@ description: Standards for cross-platform compatibility (Web & MAUI Hybrid)
|
|||||||
- Use `IPlatformService.GetDeviceContext()` to determine `DeviceType` (Phone, Tablet, Desktop).
|
- Use `IPlatformService.GetDeviceContext()` to determine `DeviceType` (Phone, Tablet, Desktop).
|
||||||
- Adapt UI layout dynamically based on the context (e.g., sidebars on Tablet/Desktop, bottom navigation on Phone).
|
- Adapt UI layout dynamically based on the context (e.g., sidebars on Tablet/Desktop, bottom navigation on Phone).
|
||||||
|
|
||||||
|
- **Real-time & Events (SignalR / UI):**
|
||||||
|
- **Debouncing**: Implement trailing-edge debouncing using `CancellationTokenSource` and `Task.Delay` for high-frequency UI events (like scrolling). Do not just drop events inside a time window, as the final state might be lost.
|
||||||
|
- **Dependency Isolation**: Blazor WebAssembly (`Web.Client`) cannot reference projects that require `Microsoft.AspNetCore.App` (like SignalR Hubs). Keep SignalR abstractions in `UI.Shared` and the Hub implementation strictly on the server (`Infrastructure` or `Web.New`).
|
||||||
|
|
||||||
- **Dependency Injection:**
|
- **Dependency Injection:**
|
||||||
- Register implementations in `MauiProgram.cs` for mobile and `Program.cs` for web.
|
- Register implementations in `MauiProgram.cs` for mobile and `Program.cs` for web.
|
||||||
- Components in `NexusReader.UI.Shared` must only depend on the interfaces.
|
- Components in `NexusReader.UI.Shared` must only depend on the interfaces.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: km-rag-methodology
|
||||||
|
description: Expertise in implementing Knowledge-Map RAG (KM-RAG), focusing on structured Knowledge Units, Graph relationships, and multi-stage retrieval in .NET.
|
||||||
|
tags: [RAG, KnowledgeMap, GraphRAG, AI, .NET, CleanArchitecture]
|
||||||
|
version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# KM-RAG Methodology
|
||||||
|
|
||||||
|
This skill provides a comprehensive framework for transitioning from basic chunk-based RAG to a structured **Knowledge-Map RAG (KM-RAG)** approach.
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
- **Knowledge Units (KU)**: Granular pieces of information with stable IDs and types (Section, Table, Definition, Rule).
|
||||||
|
- **Knowledge Map (Graph)**: Explicit links between units (`Next`, `Defines`, `Contains`) enabling contextual expansion.
|
||||||
|
- **Multi-Stage Retrieval**: A pipeline starting with semantic candidate generation followed by graph expansion and optional reranking.
|
||||||
|
- **Provenance & Governance**: Full traceability of AI answers back to their source units.
|
||||||
|
|
||||||
|
## Key Artifacts
|
||||||
|
- [Core Concepts](artifacts/core_concepts.md): Deep dive into the methodology.
|
||||||
|
- [Implementation Patterns (.NET)](artifacts/implementation_patterns.md): C# code for units, links, and retrieval.
|
||||||
|
- [Quality Checklist](artifacts/evaluation_checklist.md): Metrics and safety procedures.
|
||||||
|
- [Deep Research Report](artifacts/deep-research-report-rag.md): Original research on the KM-RAG approach.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
Use this skill when:
|
||||||
|
- Designing or refactoring RAG systems for high precision.
|
||||||
|
- Implementing multi-tenant knowledge bases.
|
||||||
|
- Enhancing AI answers with structural context from a graph.
|
||||||
|
- Building evaluation pipelines for hallucination detection.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Core Concepts of KM-RAG (Knowledge-Map RAG)
|
||||||
|
|
||||||
|
Knowledge-Map RAG (KM-RAG) shifts the paradigm from "mechanical chunking" to "structured knowledge engineering".
|
||||||
|
|
||||||
|
## 1. From Chunks to Knowledge Units (KU)
|
||||||
|
Instead of random character-based splits, knowledge is partitioned into **Knowledge Units** that preserve structural meaning:
|
||||||
|
- **Unit Types**: `Section`, `Table`, `Definition`, `ProcedureStep`, `PolicyRule`.
|
||||||
|
- **Properties**: Stable ID, Version, Canonical Text, Rendered Context, Provenance (source, page, path).
|
||||||
|
|
||||||
|
## 2. The Knowledge Map (Graph)
|
||||||
|
Relationships between Knowledge Units are explicitly modeled to enhance retrieval and context assembly:
|
||||||
|
- `HAS_UNIT`: Document contains Unit.
|
||||||
|
- `NEXT` / `PREVIOUS`: Sequential flow between units.
|
||||||
|
- `DEFINES`: Unit defines a specific entity or term.
|
||||||
|
- `REFERENCES`: Unit refers to another unit.
|
||||||
|
- `EXCEPTION_OF`: Unit describes an exception to a rule in another unit.
|
||||||
|
|
||||||
|
## 3. Retrieval Strategy: "Plan over Similarity"
|
||||||
|
Retrieval is not just `top-k` similarity but a multi-stage process:
|
||||||
|
1. **Candidate Generation**: Hybrid search (Vector + Keyword) to find potential matches.
|
||||||
|
2. **Graph Expansion**: Pulling related units (e.g., "Get the section this table belongs to" or "Get the definition of term X used here").
|
||||||
|
3. **Reranking**: Using a Cross-Encoder to precisely score the expanded candidates.
|
||||||
|
4. **Context Assembly**: Building a grounded context with explicit citations.
|
||||||
|
|
||||||
|
## 4. Governance and Provenance
|
||||||
|
- **Audit Trail**: Every answer must be traceable back to specific Knowledge Units with valid provenance.
|
||||||
|
- **Permission-Aware**: Retrieval filters must enforce ACLs at the unit/graph level before the LLM sees the data.
|
||||||
|
- **Continuous Evaluation**: Monitoring "Faithfulness" (groundedness) and "Answer Relevance" using tools like RAGAS or TruLens.
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
# Mapa Wiedzy i kontrola w RAG: jak wdrożyć „nowe podejście” w sposób inżynieryjny
|
||||||
|
|
||||||
|
## Executive summary
|
||||||
|
|
||||||
|
Autor posta (entity["people","Vladimir Alekseichenko","dataworkshop ceo"], entity["organization","DataWorkshop","ml/ai training poland"]) kontrastuje „klasyczny” RAG oparty o mechaniczne chunkowanie i wektoryzację z podejściem, w którym buduje się **Mapę Wiedzy**: „graf z metadanymi, powiązaniami i odniesieniami do źródeł” (w kontekście praktyki na danych z entity["organization","Giełda Papierów Wartościowych w Warszawie","warsaw stock exchange"]). citeturn2view0turn2view1
|
||||||
|
|
||||||
|
W tym raporcie formalizuję tę ideę jako **Knowledge‑Map RAG (KM‑RAG)**: RAG, w którym warstwa „R” nie jest tylko wyszukiwaniem semantycznym po losowych fragmentach, ale **kontrolowanym wyborem jednostek wiedzy** (sekcja, tabela, rekord, definicja, reguła) powiązanych grafowo, z pełną **proweniencją (skąd to jest), politykami dostępu, wersjonowaniem i testowalnością**. To jest spójne z tezą autora, że „R w RAG” to przede wszystkim **ryzyko**: jeśli retrieval jest błędny, model będzie „pewnie” odpowiadał na podstawie złego kontekstu. citeturn2view0turn6view0
|
||||||
|
|
||||||
|
Ponieważ nie podałeś ograniczeń (skala, budżet, SLA/latencja), przyjmuję **brak specyficznych constraintów** i podaję warianty: od małych wdrożeń (Postgres/pgvector) po architektury wielotenancy (Qdrant/Pinecone/Weaviate) oraz hybrydy graf + wektory. citeturn12search2turn14search1turn14search16turn14search0turn14search2
|
||||||
|
|
||||||
|
Najważniejsze rekomendacje wdrożeniowe:
|
||||||
|
|
||||||
|
Po pierwsze, zastąp „losowe chunki” **jednostkami sensu**: segmentacją strukturalną (nagłówki/sekcje/tabele) i/lub semantyczną, z metadanymi i relacjami (poprzedni/następny, należy do sekcji, cytuje, definiuje). citeturn6view0turn11search1turn11search29
|
||||||
|
|
||||||
|
Po drugie, zbuduj **Mapę Wiedzy jako graf** (property graph) + indeksy (wektorowy i leksykalny/hybrydowy). Praktycznie: graf przechowuje relacje i proweniencję, a wektory dają tani „candidate generation”; dopiero potem używasz grafu do „dociągnięcia” brakujących kontekstów i do audytu. To jest zgodne z rodziną podejść GraphRAG (np. publikacja entity["company","Microsoft","tech company"] o GraphRAG: graf encji + „community summaries” dla lepszych odpowiedzi na pytania globalne). citeturn0search1turn3search4turn3search20
|
||||||
|
|
||||||
|
Po trzecie, „kontrola zamiast nadziei” oznacza: (a) **mierniki retrieval i generation**, (b) automatyczne testy regresji i audyt ścieżki źródeł, (c) monitoring i alerty driftu oraz incydentów bezpieczeństwa (prompt injection, data leakage). W praktyce: RAGAS/TruLens + OWASP LLM Top 10 jako checklisty, plus logowanie „trace” (kontekst → odpowiedź → cytowania). citeturn4search1turn4search2turn4search6turn4search13turn4search7
|
||||||
|
|
||||||
|
## Definicja podejścia „Mapa Wiedzy zamiast losowych chunków”
|
||||||
|
|
||||||
|
W poście autor opisuje Mapę Wiedzy jako artefakt, który budujesz **w 3 dni**: „graf z metadanymi, powiązaniami i odniesieniami do źródeł” (wspomina też kontekst narzędziowy: repozytorium na entity["company","GitHub","code hosting platform"] i notatki w entity["company","Obsidian","note-taking app company"]). citeturn2view1
|
||||||
|
|
||||||
|
Jednocześnie w dłuższym materiale autor rozwija intuicję, dlaczego „chunking + vector DB” bywa drogą donikąd: mechaniczne cięcie rozrywa jednostki sensu (akapit, tabela), a model językowy zwykle **nie weryfikuje kontekstu** – odpowiada w oparciu o to, co mu dostarczysz, nawet jeśli kontekst jest sprzeczny (stąd losowość i halucynacje). citeturn6view0turn7view1
|
||||||
|
|
||||||
|
### Precyzyjna definicja operacyjna (KM‑RAG)
|
||||||
|
|
||||||
|
**Knowledge‑Map RAG (KM‑RAG)** to architektura RAG, w której warstwa „R” jest realizowana przez:
|
||||||
|
|
||||||
|
**Reprezentację wiedzy**: dokumenty są przekształcane do zbioru **jednostek wiedzy** (Knowledge Units) o stabilnej proweniencji (ID, wersja, lokalizacja w źródle) i spójnej semantyce (sekcja definicji, tabela, rozdział, procedura), a nie losowych wycinków znaków. citeturn6view0turn11search9turn16search0
|
||||||
|
|
||||||
|
**Mapę (graf) zależności**: jednostki są węzłami grafu (np. DOCUMENT → SECTION → UNIT; ENTITY ↔ UNIT; UNIT ↔ UNIT przez „refers_to/next/derives_from”), a krawędzie niosą informację ułatwiającą retrieval i audyt (np. „to jest definicja terminu X”, „to jest wyjątek od reguły”). citeturn2view1turn10search3turn3search4
|
||||||
|
|
||||||
|
**Polityki retrieval**: zapytanie jest mapowane na intencję i encje, a retrieval wykonuje plan: generuje kandydatów (wektory/keyword/hybrid), następnie rozszerza kontekst grafowo (np. sekcja nadrzędna, definicje encji, powiązane tabele), na końcu dokonuje selekcji (rerank/pruning) i buduje kontekst z cytowaniami. citeturn12search3turn12search11turn10search6turn10search31
|
||||||
|
|
||||||
|
**Kontrolę i audytowalność**: system jest projektowany tak, aby można było odpowiedzieć na pytania: „Dlaczego ten fragment?”, „Czy użytkownik miał uprawnienia?”, „Jaka wersja źródła?”, „Czy odpowiedź jest ugruntowana (grounded) w kontekście?”. Autor wprost wiąże „mapę wiedzy” z uszczelnianiem rozwiązań, wymaganiami prawnymi/bezpieczeństwa oraz audytowalnością. citeturn7view1turn14search2
|
||||||
|
|
||||||
|
### Dlaczego „losowe chunki” są słabą abstrakcją inżynieryjną
|
||||||
|
|
||||||
|
Mechaniczne chunkowanie jest często liczone w znakach/tokenach; nawet z overlapem rozrywa strukturę i wymusza „magiczne” heurystyki (większy chunk_size, więcej chunków w kontekście), które łatwo psują wcześniej działające przypadki i utrudniają stabilną ewaluację. citeturn6view0
|
||||||
|
|
||||||
|
Z perspektywy governance kluczowy problem jest też bezpieczeństwo: w jednym dokumencie mogą być fragmenty o różnych poziomach dostępu, więc „wrzucanie wszystkiego do jednego kontekstu” łamie zasady separacji i komplikuje zgodność (ten motyw pojawia się u autora wprost). citeturn7view1turn14search2
|
||||||
|
|
||||||
|
## Architektura referencyjna i komponenty
|
||||||
|
|
||||||
|
Poniżej przedstawiam architekturę komponentową KM‑RAG, obejmującą: ingestion, mapę wiedzy, strategie segmentacji, embeddingi i wektory, retrievery i rerankery, prompt engineering i grounding, oraz kontrolę halucynacji i ewaluację.
|
||||||
|
|
||||||
|
### Diagram architektury
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph Ingestion
|
||||||
|
A[Źródła: PDF/HTML/DOCX/DB] --> B[Parsing + normalizacja]
|
||||||
|
B --> C[Jednostki wiedzy: sekcje, tabele, rekordy]
|
||||||
|
C --> D[Metadane: źródło, wersja, ACL, lokalizacja]
|
||||||
|
C --> E[Ekstrakcja encji/relacji]
|
||||||
|
E --> G[(Graf / Mapa Wiedzy)]
|
||||||
|
C --> F[Embedding + indeks]
|
||||||
|
F --> V[(Vector DB)]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph QueryTime
|
||||||
|
Q[Zapytanie użytkownika] --> R[Routing/intencja/encje]
|
||||||
|
R --> V1[Candidate gen: vector/keyword/hybrid]
|
||||||
|
V1 --> V
|
||||||
|
V --> K[Top-K kandydatów]
|
||||||
|
K --> G1[Graph expansion\n(definicje, zależności, sekcje)]
|
||||||
|
G1 --> G
|
||||||
|
G --> S[Context assembly + dedup + cytowania]
|
||||||
|
S --> L[LLM generacja\n(z zasadą "answer from sources")]
|
||||||
|
L --> O[Odpowiedź + cytowania + confidence]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Control
|
||||||
|
O --> M[Logi/trace]
|
||||||
|
M --> EV[Ewaluacja offline/online]
|
||||||
|
M --> MON[Monitoring KPI + alerty]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Model ten jest kompatybilny zarówno z „klasycznym RAG” w sensie pracy na wektorach (RAG w ujęciu Lewis et al. zakłada połączenie pamięci parametrycznej i nieparametrycznej poprzez retrieval z indeksu wektorowego), jak i z odmianami grafowymi (GraphRAG: budowa grafu encji i „community summaries” jako warstwa indeksu). citeturn0search2turn0search5turn0search1turn3search4
|
||||||
|
|
||||||
|
image_group{"layout":"carousel","aspect_ratio":"16:9","query":["GraphRAG architecture diagram","knowledge graph retrieval augmented generation diagram","vector database similarity search diagram","Neo4j graph visualization example"],"num_per_query":1}
|
||||||
|
|
||||||
|
### Ingestion: parsowanie, normalizacja i jednostki wiedzy
|
||||||
|
|
||||||
|
W KM‑RAG ingestion nie kończy się na „wyciągnij tekst z PDF”. Kluczowe jest zachowanie/rekonstrukcja struktury: tytuły, listy, tabele, numer stron, sekcje. Biblioteka entity["company","Unstructured","document processing company"] wprost opisuje „partitioning” jako ekstrakcję ustrukturyzowanych elementów (Title/NarrativeText/ListItem itd.), aby móc decydować, co zachować. citeturn16search0turn16search8turn16search4
|
||||||
|
|
||||||
|
Jeśli pracujesz na bardzo różnych formatach lub potrzebujesz także metadanych i obsługi np. zaszyfrowanych PDF, narzędzia z ekosystemu entity["organization","Apache Software Foundation","open source foundation"] (Apache Tika) podkreślają możliwość parsowania PDF, w tym obsługi dokumentów szyfrowanych przy podaniu hasła. citeturn16search1turn16search30
|
||||||
|
|
||||||
|
Wniosek projektowy: „Jednostka wiedzy” w KM‑RAG to obiekt typu np.:
|
||||||
|
|
||||||
|
- `unit_type`: `section`, `definition`, `table`, `row`, `procedure_step`, `policy_rule`
|
||||||
|
- `canonical_text` (tekst do embeddingu i rerankingu)
|
||||||
|
- `rendered_context` (tekst/fragment do wklejenia do prompta)
|
||||||
|
- `provenance`: `source_id`, `page`, `section_path`, `span_offsets`
|
||||||
|
- `governance`: `acl_tags`, `pii_class`, `retention_class`
|
||||||
|
- `links`: `prev/next`, `references`, `same_topic`
|
||||||
|
|
||||||
|
Taki model danych bezpośrednio adresuje problem autora: model nie „weźmie odpowiedzialności” za konfliktujący kontekst, więc to system ma pilnować jakości kontekstu i jego zaufania. citeturn6view0turn7view1
|
||||||
|
|
||||||
|
### Strategie segmentacji: od „chunków” do „węzłów” (Nodes)
|
||||||
|
|
||||||
|
Jeżeli musisz działać na tekście, i tak będziesz coś „dzielił” – różnica polega na tym, czy są to losowe fragmenty znaków, czy **węzły semantyczne**.
|
||||||
|
|
||||||
|
- W ekosystemie entity["company","LangChain","llm app framework company"] często proponuje się `RecursiveCharacterTextSplitter` jako „solidny default” dla wielu przypadków, ale to nadal jest heurystyka bazująca na znakach i separatorach. citeturn11search8turn11search0
|
||||||
|
- entity["company","LlamaIndex","llm data framework company"] oferuje semantyczne parsowanie węzłów: `SemanticSplitterNodeParser` dzieli tekst na grupy zdań powiązane semantycznie (z użyciem embeddingów), a dokumentacja podkreśla, że to alternatywa dla stałego rozmiaru chunków. citeturn11search1turn11search9turn11search29
|
||||||
|
|
||||||
|
KM‑RAG traktuje segmentację jako element modelowania danych: węzły mają typ, hierarchię i relacje.
|
||||||
|
|
||||||
|
### Embeddingi i Vector DB: candidate generation + filtrowanie po metadanych
|
||||||
|
|
||||||
|
Embeddingi są nadal bardzo użyteczne, ale w KM‑RAG pełnią rolę „szybkiego generatora kandydatów”, a nie „wyroczni”.
|
||||||
|
|
||||||
|
Otwartoźródłowo, entity["company","Hugging Face","ml model hub company"] utrzymuje Sentence Transformers, które dostarcza zarówno modele embeddingowe (bi-encoders), jak i rerankery (cross-encoders). citeturn12search38turn12search3
|
||||||
|
|
||||||
|
Warstwa metadanych jest w KM‑RAG krytyczna: np. do ograniczania domeny, wersji dokumentu, języka, daty wejścia w życie, uprawnień.
|
||||||
|
|
||||||
|
- entity["company","Qdrant","vector database company"] opisuje payload/metadata i filtrowanie oraz zaleca indeksowanie pól payload dla efektywności filtrowania. citeturn11search2turn11search6turn11search37
|
||||||
|
- entity["company","Pinecone","vector database company"] opisuje filtrowanie po metadanych oraz pokazuje wzorzec multitenancy przez namespaces. citeturn11search7turn14search16turn14search12
|
||||||
|
- entity["company","Weaviate","vector database company"] opisuje hybrydę BM25F + wektory (fuzja wyników i wagi są konfigurowalne) oraz posiada natywną wielodzierżawność (tenant per request). citeturn12search0turn14search0
|
||||||
|
- entity["company","Milvus","vector database project"] dokumentuje hybrydę sparse+dense i wskazuje scenariusze, w których połączenie poprawia wyniki (semantyka + dopasowanie słów kluczowych). citeturn12search1turn12search5
|
||||||
|
|
||||||
|
W KM‑RAG niemal zawsze warto rozważyć hybrid retrieval (dense + sparse), bo ogranicza „semantic drift” i poprawia precyzję przy terminach domenowych (np. numery, nazwy własne). Jest to wspólny wątek w dokumentacji Weaviate i Pinecone, opisującej fuzję wyników i podejścia do hybrydy. citeturn12search0turn11search3turn11search19
|
||||||
|
|
||||||
|
### Retrievery, rerankery i kontrola halucynacji
|
||||||
|
|
||||||
|
KM‑RAG rozdziela retrieval na etapy:
|
||||||
|
|
||||||
|
**Candidate generation (tani):** dense retriever (np. dual-encoder) i/lub sparse (BM25). Klasyczna praca o dense retrieval (DPR) pokazuje dual-encoder jako praktyczny mechanizm retrieval i porównuje go do BM25 w QA. citeturn8search0turn8search4
|
||||||
|
|
||||||
|
**Reranking (droższy):** cross-encoder reranker znacząco poprawia ranking, ale jest kosztowny, bo ocenia pary (query, doc) wspólnie w modelu. Sentence Transformers opisuje retrieve‑&‑rerank pipeline oraz rolę CrossEncodera. citeturn12search11turn12search19
|
||||||
|
|
||||||
|
**Graph expansion (precyzja i kompletność):** graf dostarcza „brakujących mostów” (definicje, zależności, wyjątki, kontekst sekcji) oraz daje audyt – to jest sedno „Mapy Wiedzy”. W wariantach GraphRAG (Microsoft) graf jest budowany z encji i relacji, a następnie grupowany w społeczności i streszczany, co poprawia odpowiedzi na pytania „globalne” (np. „jakie są główne tematy w korpusie?”), gdzie naiwny RAG zawodzi. citeturn0search1turn0search13turn3search4turn3search20
|
||||||
|
|
||||||
|
**Halucynacje i „kontrola”:** literatura proponuje pętle weryfikacji (np. Chain‑of‑Verification: draft → pytania weryfikacyjne → niezależne odpowiedzi → final) i mechanizmy samorefleksji (Self‑RAG) oraz korekty retrieval (CRAG). Są to techniki „kontroli” na poziomie architektury, a nie tylko promptu. citeturn8search3turn9search1turn9search2
|
||||||
|
|
||||||
|
## Opcje projektowe i trade‑offy
|
||||||
|
|
||||||
|
### Porównanie: klasyczny RAG vs KM‑RAG
|
||||||
|
|
||||||
|
| Wymiar | Klasyczny „chunk + vector DB” | KM‑RAG (Mapa Wiedzy) | Konsekwencja praktyczna |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Jednostka indeksowania | fragment znaków/tokenów | jednostka sensu: sekcja/tabela/rekord + typ | mniej „urwanych” kontekstów, mniej przypadkowości |
|
||||||
|
| Reprezentacja | embedding + (czasem) metadata | embedding + metadata + graf relacji + proweniencja | lepsza ścieżka audytu i „dlaczego to” |
|
||||||
|
| Retrieval | top‑k similarity | plan retrieval: hybrid + graf expansion + rerank | wyższa precyzja i odporność na trudne pytania |
|
||||||
|
| Zmiany w danych | częsty re‑index, ryzyko regresji | wersjonowanie, testy regresji per typ jednostki | stabilniejsze wdrożenia i migracje |
|
||||||
|
| Bezpieczeństwo/ACL | łatwo mieszać fragmenty o różnych uprawnieniach | ACL na poziomie jednostki i ścieżki grafu | mniejsze ryzyko wycieku kontekstu |
|
||||||
|
| Debuggowanie | „dlaczego takie chunki?” | „jaki węzeł, z jakiego źródła, jaka relacja?” | szybsze RCA i audyt |
|
||||||
|
|
||||||
|
Uzasadnienie co do problemów chunkingu i „model ufa kontekstowi” pochodzi z materiału autora; definicja Mapy Wiedzy jako grafu z metadanymi i odniesieniami jest wprost w poście. citeturn6view0turn2view1turn7view1
|
||||||
|
|
||||||
|
### Wybory technologiczne: wektory, graf, hybryda
|
||||||
|
|
||||||
|
Poniżej pokazuję typowe opcje i kompromisy (bez narzuconych constraintów – dobór zależy od QPS, wolumenu i wymagań bezpieczeństwa).
|
||||||
|
|
||||||
|
**Vector store**
|
||||||
|
|
||||||
|
- Qdrant: mocne filtrowanie payload + mechanizmy multitenancy (w tym „tiered multitenancy”). citeturn11search6turn14search1turn14search18
|
||||||
|
- Pinecone: proste multitenancy przez namespaces; dobrze opisane podejścia do hybrid search (single hybrid index vs osobne indeksy, z plusami i minusami). citeturn14search16turn11search3
|
||||||
|
- Weaviate: wbudowany hybrid BM25F + wektor, oraz multi‑tenancy z tenantem w operacjach. citeturn12search0turn14search0
|
||||||
|
- Milvus: rozbudowane podejścia do sparse+dense i multi‑vector, z dokumentacją dla hybrydy. citeturn12search1turn12search5turn12search33
|
||||||
|
- pgvector: dobre, gdy chcesz „mniej systemów” i akceptujesz kompromisy wydajności; repo dokumentuje różnice IVFFlat vs HNSW (build time/memory vs speed‑recall). citeturn12search2turn12search14
|
||||||
|
- Elasticsearch: istotny, gdy potrzebujesz „enterprise security” (RBAC, field/document‑level security) i hybrydowego wyszukiwania w jednej platformie. citeturn14search2turn14search15
|
||||||
|
|
||||||
|
**Graph / Knowledge Map store**
|
||||||
|
|
||||||
|
- Neo4j: bogate wzorce GraphRAG (graph traversal, full‑text, vector, text2cypher). Neo4j publikuje GraphRAG field guide i pakiet GraphRAG dla Pythona. citeturn10search18turn10search14turn10search31turn10search2
|
||||||
|
- Microsoft GraphRAG: gotowy pipeline budowy grafowego indeksu (encje → społeczności → streszczenia), open‑source na GitHubie + dokumentacja „Getting started”. citeturn3search0turn3search31turn3search20turn0search1
|
||||||
|
- LlamaIndex KnowledgeGraphIndex: praktyczna automatyzacja budowy grafu z tekstu i query po encjach. citeturn10search3turn10search11
|
||||||
|
|
||||||
|
**Kompromisy**
|
||||||
|
|
||||||
|
- Skalowalność: graf może zmniejszać liczbę „strzałów” w LLM (np. przez pre‑streszczenia społeczności w GraphRAG) kosztem cięższego ingestion i większej złożoności danych. citeturn0search1turn3search4
|
||||||
|
- Latencja: rerankery cross‑encoder podnoszą jakość, ale zwiększają czas (N par do oceny); dlatego standardem jest retrieval → rerank top‑N, nie rerank całego korpusu. citeturn12search11turn12search19
|
||||||
|
- Koszt: hybryda i graf często zwiększają koszt ingest (LLM do ekstrakcji encji/relacji), ale zmniejszają koszt „ratowania” jakości w runtime przez kolejne heurystyki. To jest w duchu argumentu autora o „dokładaniu mini‑klocków” versus poprawa fundamentu. citeturn6view0turn7view1
|
||||||
|
- Maintainability: mniej „magicznych” parametrów chunk_size; więcej jawnych typów jednostek i testów per typ. citeturn7view1turn13search3
|
||||||
|
- Security/data governance: najlepiej wspierać **permission‑aware retrieval** już w retrieverze (prefilter), bo wtedy model nie ma czego „wyciec”. Dokumentacja Elastic i wektor DB pokazuje mechanizmy RBAC/DLS, namespaces/tenants i filtrowanie po metadanych. citeturn14search2turn14search16turn14search0turn11search6
|
||||||
|
|
||||||
|
## Migracja z klasycznego RAG do KM‑RAG
|
||||||
|
|
||||||
|
Migracja jest łatwiejsza, jeśli potraktujesz ją jak refactoring warstwy danych i retrieval, a nie „przepisanie wszystkiego od zera”.
|
||||||
|
|
||||||
|
### Ścieżka migracji krok po kroku
|
||||||
|
|
||||||
|
**Krok pierwszy: ustal bazową prawdę (baseline) i testy.**
|
||||||
|
Bez ewaluacji będziesz „liczyć na cud” – wprost przeciwieństwo postulatu „kontrola zamiast nadziei”. Zacznij od małego zestawu pytań i oczekiwań (golden set) oraz logowania kontekstu i odpowiedzi. W praktyce możesz użyć RAGAS (metryki retrieval i faithfulness bez konieczności pełnych anotacji) oraz TruLens (RAG triad: context relevance, groundedness, answer relevance). citeturn4search1turn4search2turn4search6
|
||||||
|
|
||||||
|
**Krok drugi: dołóż metadane i proweniencję zanim dołożysz graf.**
|
||||||
|
W klasycznym RAG często brakuje stabilnych ID i lokalizacji w źródle; tymczasem autor wiąże mapę wiedzy z odniesieniami do źródeł. Minimalny zestaw to: `source_id`, `version`, `page/section`, `timestamp`, `acl_tags`. Mechanizmy filtrowania po metadanych są standardem m.in. w Pinecone i Qdrant. citeturn2view1turn11search7turn11search6
|
||||||
|
|
||||||
|
**Krok trzeci: zamień chunki na węzły o typach i relacjach.**
|
||||||
|
Zamiast „1000 znaków”, twórz: `SectionNode`, `TableNode`, `DefinitionNode`, `PolicyNode`. Jeśli nie możesz od razu, przejdź etapowo przez semantyczne node parsers (LlamaIndex) lub segmentację po strukturze dokumentu (partitioning). citeturn11search9turn16search0turn11search1
|
||||||
|
|
||||||
|
**Krok czwarty: zbuduj Mapę Wiedzy (graf) i zacznij od najtańszego użycia w runtime.**
|
||||||
|
Nie musisz od razu robić pełnego „GraphRAG global”. Najpierw używaj grafu do: (a) definicji i wyjątków, (b) dołączania kontekstu „nadrzędna sekcja” / „poprzedni‑następny”, (c) audytu ścieżki cytowań. Dopiero potem dokładaj stricte grafowe retrievery. citeturn10search6turn10search31turn3search4
|
||||||
|
|
||||||
|
**Krok piąty: wprowadź gating i rollout.**
|
||||||
|
Zgodnie z najlepszymi praktykami ewaluacji: iteruj, porównuj wersje, ustaw continuous evaluation i progi akceptacji. citeturn13search3turn13search35
|
||||||
|
|
||||||
|
### Proponowana sekwencja wdrożenia
|
||||||
|
|
||||||
|
| Faza | Co dostarczasz | Typowy czas (brak constraintów) | Kryterium „done” |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| Audit RAG | logi + golden set + baseline metryk | 1–2 tyg. | masz mierzalne recall/faithfulness + top failure modes |
|
||||||
|
| Metadata-first | proweniencja + filtry + ACL | 1–2 tyg. | brak „orphan” chunków bez źródła; prefiltrowanie działa |
|
||||||
|
| Nodes & map | węzły typowane + relacje | 2–4 tyg. | stable IDs, relacje prev/next/contains/refers_to |
|
||||||
|
| Hybrid + rerank | dense+sparse + rerank top‑N | 1–3 tyg. | poprawa metryk retrieval bez wzrostu halucynacji |
|
||||||
|
| Graph expansion | dołączanie kontekstu grafem | 2–4 tyg. | poprawa trudnych pytań „łączących fakty” |
|
||||||
|
| Produkcja | monitoring KPI + procedury incydentów | ciągłe | CE + alerty + playbook audytu |
|
||||||
|
|
||||||
|
Metryki i praktykę continuous evaluation wspiera dokumentacja OpenAI (zalecenia dot. progów context recall/precision i pipeline’u ewaluacji), co jest spójne z „kontrolą” jako procesem, nie jednorazową konfiguracją. citeturn13search3turn13search27
|
||||||
|
|
||||||
|
## Implementacje przykładowe
|
||||||
|
|
||||||
|
Poniższe implementacje są „szkieletami” (reference implementations). W obu wariantach zakładam brak narzuconych wymagań co do skali, więc pokazuję rozwiązania, które da się skalować horyzontalnie (wektor DB) i/lub uprościć (pgvector zamiast osobnej bazy).
|
||||||
|
|
||||||
|
### Stack A: open‑source embeddings + open‑source Vector DB (Sentence Transformers + Qdrant) + graf w Neo4j
|
||||||
|
|
||||||
|
**Kiedy wybrać:** gdy chcesz uniezależnić embeddingi od dostawcy, mieć pełną kontrolę nad danymi i implementować multitenancy/filtry wprost w wektor DB. Payload/filtry i multitenancy są natywnie wspierane w Qdrant. citeturn11search6turn14search1turn14search7
|
||||||
|
|
||||||
|
**Zależności (przykład):** `sentence-transformers`, `qdrant-client`, `neo4j`, parser dokumentów (`unstructured` lub Tika).
|
||||||
|
|
||||||
|
```python
|
||||||
|
# --- Ingestion: parse -> units -> embeddings -> Qdrant + graph ---
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterable, Optional
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
|
||||||
|
from sentence_transformers import SentenceTransformer, CrossEncoder
|
||||||
|
from qdrant_client import QdrantClient, models as qmodels
|
||||||
|
from neo4j import GraphDatabase
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class KnowledgeUnit:
|
||||||
|
unit_id: str
|
||||||
|
source_id: str
|
||||||
|
version: str
|
||||||
|
unit_type: str # e.g. "section", "definition", "table"
|
||||||
|
text: str # canonical text for embedding
|
||||||
|
page: Optional[int] = None
|
||||||
|
section_path: Optional[str] = None
|
||||||
|
acl: str = "public" # e.g. role/tenant tag
|
||||||
|
|
||||||
|
def stable_id(source_id: str, version: str, unit_type: str, page: str, text: str) -> str:
|
||||||
|
raw = f"{source_id}|{version}|{unit_type}|{page}|{text}".encode("utf-8")
|
||||||
|
return hashlib.sha256(raw).hexdigest()[:24]
|
||||||
|
|
||||||
|
# 1) Embeddings (bi-encoder) + reranker (cross-encoder)
|
||||||
|
embed_model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2") # example
|
||||||
|
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2") # example
|
||||||
|
|
||||||
|
# 2) Vector DB: Qdrant
|
||||||
|
qdrant = QdrantClient(url="http://localhost:6333", timeout=30)
|
||||||
|
COLLECTION = "kmrag_units"
|
||||||
|
|
||||||
|
DIM = embed_model.get_sentence_embedding_dimension()
|
||||||
|
qdrant.recreate_collection(
|
||||||
|
collection_name=COLLECTION,
|
||||||
|
vectors_config=qmodels.VectorParams(size=DIM, distance=qmodels.Distance.COSINE),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Index payload fields frequently used in filters (performance)
|
||||||
|
qdrant.create_payload_index(
|
||||||
|
collection_name=COLLECTION,
|
||||||
|
field_name="acl",
|
||||||
|
field_schema=qmodels.PayloadSchemaType.KEYWORD,
|
||||||
|
)
|
||||||
|
qdrant.create_payload_index(
|
||||||
|
collection_name=COLLECTION,
|
||||||
|
field_name="source_id",
|
||||||
|
field_schema=qmodels.PayloadSchemaType.KEYWORD,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3) Graph DB: Neo4j (property graph)
|
||||||
|
neo4j_driver = GraphDatabase.driver(
|
||||||
|
"neo4j://localhost:7687", auth=("neo4j", "password")
|
||||||
|
)
|
||||||
|
|
||||||
|
def upsert_units(units: Iterable[KnowledgeUnit]) -> None:
|
||||||
|
batch = list(units)
|
||||||
|
# embeddings
|
||||||
|
vectors = embed_model.encode([u.text for u in batch], normalize_embeddings=True)
|
||||||
|
|
||||||
|
# upsert into Qdrant with payload metadata (provenance + ACL)
|
||||||
|
qdrant.upsert(
|
||||||
|
collection_name=COLLECTION,
|
||||||
|
points=[
|
||||||
|
qmodels.PointStruct(
|
||||||
|
id=u.unit_id,
|
||||||
|
vector=vectors[i].tolist(),
|
||||||
|
payload={
|
||||||
|
"source_id": u.source_id,
|
||||||
|
"version": u.version,
|
||||||
|
"unit_type": u.unit_type,
|
||||||
|
"page": u.page,
|
||||||
|
"section_path": u.section_path,
|
||||||
|
"acl": u.acl,
|
||||||
|
"ingested_at": int(time.time()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for i, u in enumerate(batch)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# build/update graph nodes + relationships
|
||||||
|
cypher = """
|
||||||
|
UNWIND $rows AS r
|
||||||
|
MERGE (d:Document {source_id: r.source_id, version: r.version})
|
||||||
|
MERGE (u:Unit {unit_id: r.unit_id})
|
||||||
|
SET u.unit_type = r.unit_type,
|
||||||
|
u.page = r.page,
|
||||||
|
u.section_path = r.section_path
|
||||||
|
MERGE (d)-[:HAS_UNIT]->(u)
|
||||||
|
"""
|
||||||
|
with neo4j_driver.session() as s:
|
||||||
|
s.run(cypher, rows=[u.__dict__ for u in batch])
|
||||||
|
|
||||||
|
# --- Query-time retrieval: vector -> graph expansion -> rerank -> context ---
|
||||||
|
def retrieve(query: str, acl: str, top_k: int = 30, rerank_k: int = 8):
|
||||||
|
qvec = embed_model.encode([query], normalize_embeddings=True)[0].tolist()
|
||||||
|
|
||||||
|
# 1) Candidate generation with metadata filter (permission-aware)
|
||||||
|
hits = qdrant.search(
|
||||||
|
collection_name=COLLECTION,
|
||||||
|
query_vector=qvec,
|
||||||
|
limit=top_k,
|
||||||
|
query_filter=qmodels.Filter(
|
||||||
|
must=[qmodels.FieldCondition(key="acl", match=qmodels.MatchValue(value=acl))]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
candidate_ids = [h.id for h in hits]
|
||||||
|
|
||||||
|
# 2) Graph expansion: pull neighbor units from same document/section (simple example)
|
||||||
|
expand_cypher = """
|
||||||
|
MATCH (u:Unit) WHERE u.unit_id IN $ids
|
||||||
|
OPTIONAL MATCH (d:Document)-[:HAS_UNIT]->(u)
|
||||||
|
OPTIONAL MATCH (d)-[:HAS_UNIT]->(u2:Unit)
|
||||||
|
WHERE u2.section_path = u.section_path
|
||||||
|
RETURN DISTINCT u2.unit_id AS unit_id
|
||||||
|
LIMIT 200
|
||||||
|
"""
|
||||||
|
with neo4j_driver.session() as s:
|
||||||
|
rows = s.run(expand_cypher, ids=candidate_ids).data()
|
||||||
|
expanded_ids = list({r["unit_id"] for r in rows}) or candidate_ids
|
||||||
|
|
||||||
|
# 3) Fetch texts for reranking (here: from Qdrant payload 'text' not stored; you'd load from your storage)
|
||||||
|
# In production: keep canonical text in your doc store; Qdrant payload keeps provenance only.
|
||||||
|
# For demo: assume we can map id->text elsewhere:
|
||||||
|
id_to_text = load_texts(expanded_ids) # implement in your system
|
||||||
|
|
||||||
|
pairs = [(query, id_to_text[i]) for i in expanded_ids]
|
||||||
|
scores = reranker.predict(pairs)
|
||||||
|
ranked = sorted(zip(expanded_ids, scores), key=lambda x: x[1], reverse=True)[:rerank_k]
|
||||||
|
|
||||||
|
return ranked # list of (unit_id, score) + you can also return provenance from payload
|
||||||
|
|
||||||
|
def load_texts(unit_ids):
|
||||||
|
# Placeholder: pull canonical text from your document store / data lake
|
||||||
|
raise NotImplementedError
|
||||||
|
```
|
||||||
|
|
||||||
|
Co w tym szkielecie jest „Mapą Wiedzy”: Neo4j przechowuje relacje (Document→Unit, a dalej możesz dodać Entity↔Unit, REFERENCES, NEXT), a Qdrant przechowuje wektory + payload do filtrowania; filtrowanie i indeksowanie payload jest sformalizowane w dokumentacji Qdrant. citeturn11search6turn11search2turn14search7
|
||||||
|
|
||||||
|
Rerank to klasyczny krok „retrieve‑then‑rerank” opisywany przez Sentence Transformers, gdzie CrossEncoder podnosi jakość finalnych wyników kosztem obliczeń. citeturn12search11turn12search19
|
||||||
|
|
||||||
|
### Stack B: managed LLM + Vector DB (OpenAI + Pinecone) + graf (Neo4j GraphRAG / Text2Cypher)
|
||||||
|
|
||||||
|
**Kiedy wybrać:** gdy zależy Ci na szybkości iteracji, jakości modeli oraz gotowych mechanizmach „structured output”, a retrieval chcesz oprzeć o managed vector DB z namespaces i hybrid search. citeturn13search1turn14search16turn11search3
|
||||||
|
|
||||||
|
W wariancie managed sensownie jest też wykorzystać Structured Outputs do wymuszenia formatu odpowiedzi (np. `answer` + `citations[]`), co jest elementem „kontroli” i audytu. OpenAI opisuje Structured Outputs jako mechanizm gwarantujący zgodność odpowiedzi z JSON Schema. citeturn13search1turn13search8
|
||||||
|
|
||||||
|
```python
|
||||||
|
# --- Managed stack: OpenAI embeddings + Pinecone + structured outputs + graph retrieval ---
|
||||||
|
from openai import OpenAI
|
||||||
|
from pinecone import Pinecone
|
||||||
|
from neo4j_graphrag import GraphRAG # example usage; adjust to actual package API
|
||||||
|
|
||||||
|
OPENAI_MODEL_EMB = "text-embedding-3-large"
|
||||||
|
OPENAI_MODEL_GEN = "gpt-5.4-mini" # example; choose by latency/cost needs
|
||||||
|
|
||||||
|
client = OpenAI()
|
||||||
|
pc = Pinecone(api_key="PINECONE_API_KEY")
|
||||||
|
index = pc.Index("kmrag")
|
||||||
|
|
||||||
|
def embed(texts):
|
||||||
|
resp = client.embeddings.create(model=OPENAI_MODEL_EMB, input=texts)
|
||||||
|
return [d.embedding for d in resp.data]
|
||||||
|
|
||||||
|
def upsert_to_pinecone(units, namespace):
|
||||||
|
vecs = embed([u["text"] for u in units])
|
||||||
|
index.upsert(
|
||||||
|
vectors=[
|
||||||
|
(u["unit_id"], vecs[i], {
|
||||||
|
"source_id": u["source_id"],
|
||||||
|
"version": u["version"],
|
||||||
|
"unit_type": u["unit_type"],
|
||||||
|
"page": u.get("page"),
|
||||||
|
"section_path": u.get("section_path"),
|
||||||
|
"acl": u.get("acl"),
|
||||||
|
})
|
||||||
|
for i, u in enumerate(units)
|
||||||
|
],
|
||||||
|
namespace=namespace, # multitenancy / workspace isolation
|
||||||
|
)
|
||||||
|
|
||||||
|
def retrieve_candidates(query, namespace, acl, top_k=30):
|
||||||
|
qvec = embed([query])[0]
|
||||||
|
res = index.query(
|
||||||
|
vector=qvec,
|
||||||
|
top_k=top_k,
|
||||||
|
include_metadata=True,
|
||||||
|
namespace=namespace,
|
||||||
|
filter={"acl": {"$eq": acl}},
|
||||||
|
)
|
||||||
|
return res["matches"]
|
||||||
|
|
||||||
|
# Optional: graph retrieval pattern via Text2Cypher (Neo4j GraphRAG package)
|
||||||
|
# The idea: use graph schema + question -> generated Cypher -> execute -> return records as extra grounded context.
|
||||||
|
gr = GraphRAG(neo4j_uri="neo4j+s://...", user="neo4j", password="...")
|
||||||
|
|
||||||
|
ANSWER_SCHEMA = {
|
||||||
|
"name": "kmrag_answer",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"answer": {"type": "string"},
|
||||||
|
"citations": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"unit_id": {"type": "string"},
|
||||||
|
"source_id": {"type": "string"},
|
||||||
|
"quote": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["unit_id", "source_id"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
|
||||||
|
},
|
||||||
|
"required": ["answer", "citations", "confidence"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def answer(query, namespace, acl):
|
||||||
|
hits = retrieve_candidates(query, namespace, acl)
|
||||||
|
text_context = "\n\n".join(
|
||||||
|
f"[{m['id']}] ({m['metadata'].get('source_id')}) {load_unit_text(m['id'])}"
|
||||||
|
for m in hits[:8]
|
||||||
|
)
|
||||||
|
|
||||||
|
graph_context = gr.text2cypher_retrieve(query) # e.g. definitions, relationships
|
||||||
|
|
||||||
|
system = (
|
||||||
|
"Odpowiadasz wyłącznie na podstawie kontekstu i grafu.\n"
|
||||||
|
"Jeśli brakuje danych, powiedz wprost, czego nie wiesz.\n"
|
||||||
|
"Zwróć cytowania do unit_id/source_id."
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.responses.create(
|
||||||
|
model=OPENAI_MODEL_GEN,
|
||||||
|
input=[
|
||||||
|
{"role": "system", "content": system},
|
||||||
|
{"role": "user", "content": f"Pytanie: {query}\n\nKontekst:\n{text_context}\n\nGraf:\n{graph_context}"}
|
||||||
|
],
|
||||||
|
text={
|
||||||
|
"format": {
|
||||||
|
"type": "json_schema",
|
||||||
|
"json_schema": {**ANSWER_SCHEMA, "strict": True}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return resp.output_text
|
||||||
|
|
||||||
|
def load_unit_text(unit_id):
|
||||||
|
# fetch canonical unit text from your storage
|
||||||
|
raise NotImplementedError
|
||||||
|
```
|
||||||
|
|
||||||
|
Źródła dla tego stosu: OpenAI opisuje nowe modele embeddingowe (`text-embedding-3-small/large`) i guide embeddings, a także Structured Outputs i evaluation best practices. citeturn13search2turn13search1turn13search3turn13search9
|
||||||
|
Pinecone opisuje hybrydę oraz filtrowanie po metadanych i multitenancy przez namespaces. citeturn11search3turn11search7turn14search16
|
||||||
|
Wzorzec Text2Cypher – tłumaczenie pytania + schematu grafu na Cypher i wykonanie query – jest opisany w materiałach Neo4j. citeturn10search2turn10search6turn10search10
|
||||||
|
|
||||||
|
## Kontrola jakości, audyt i monitoring
|
||||||
|
|
||||||
|
„Kontrola zamiast nadziei” warto potraktować jako trzy warstwy: (A) kontrola danych i retrieval, (B) kontrola generacji, (C) kontrola procesu (ewaluacja i monitoring).
|
||||||
|
|
||||||
|
### Metryki i ewaluacja
|
||||||
|
|
||||||
|
**Ewaluacja retrieval** (czyt. „czy przynosimy właściwy kontekst”)
|
||||||
|
|
||||||
|
- Recall@K / Precision@K / MRR / NDCG: standardowe metryki IR; w pracach o retrieval z grafami i/lub KG są one explicite używane do oceny retrieval (np. praca o RAG+KG dla customer service raportuje MRR/Recall@K/NDCG@K). citeturn10search1turn10search5
|
||||||
|
- Offline test set buduj iteracyjnie na podstawie prawdziwych porażek (failure traces) – to jest zgodne z podejściem „evaluation flywheel” i continuous evaluation. citeturn13search35turn13search3
|
||||||
|
|
||||||
|
**Ewaluacja generation** (czyt. „czy odpowiedź jest ugruntowana w źródłach”)
|
||||||
|
|
||||||
|
- RAGAS: framework do „reference‑free evaluation” RAG, mierzący różne wymiary retrieval i generation. citeturn4search1turn4search5
|
||||||
|
- TruLens: RAG triad – context relevance, groundedness, answer relevance – jako praktyczny zestaw ocen dla halucynacji. citeturn4search2turn4search6
|
||||||
|
|
||||||
|
**Progi jakości (przykład)**
|
||||||
|
OpenAI w evaluation best practices podaje przykładowe targety (np. context recall ≥ 0.85, context precision > 0.7) jako część praktyk ewaluacji i porównywania wersji. Traktuj to jako punkt startowy, nie prawo natury. citeturn13search3
|
||||||
|
|
||||||
|
### Checklist audytu KM‑RAG
|
||||||
|
|
||||||
|
**Dane i ingestion**
|
||||||
|
|
||||||
|
- Czy parser zachowuje strukturę (sekcje/tabele/numery stron) i czy masz testy parsera na „trudnych dokumentach” (tabele, wielokolumnowe layouty)? citeturn16search0turn16search10turn6view0
|
||||||
|
- Czy każda jednostka wiedzy ma stabilne `source_id`, `version`, lokalizację i politykę retencji/PII? citeturn7view1turn14search2
|
||||||
|
|
||||||
|
**Mapa Wiedzy**
|
||||||
|
|
||||||
|
- Czy graf ma jasno zdefiniowane typy węzłów i relacje (HAS_UNIT, DEFINES, EXCEPTION_OF, REFERENCES, NEXT), oraz czy masz reguły walidacji (np. brak cykli w „NEXT”, spójność sekcji)? citeturn2view1turn10search31
|
||||||
|
- Czy ekstrakcja encji/relacji jest mierzalna (precision/recall) i odporna na duplikaty/rozbieżności nazw? (w praktyce: canonicalization + entity resolution). Koncepcja grafu encji jako indeksu jest centralna w GraphRAG. citeturn0search1turn0search13
|
||||||
|
|
||||||
|
**Retrieval**
|
||||||
|
|
||||||
|
- Czy stosujesz prefilter po ACL/tenant (permission-aware retrieval), zanim cokolwiek trafi do prompta? (mechanizmy namespaces/tenants i DLS/RBAC istnieją w narzędziach retrieval). citeturn14search16turn14search0turn14search2
|
||||||
|
- Czy masz hybrydę dense+sparse tam, gdzie słowa kluczowe są krytyczne (regulacje, numery, tickery)? Pinecone i Weaviate opisują hybrydę jako fuzję wyników. citeturn11search3turn12search0
|
||||||
|
- Czy reranking działa na top‑N, a nie na setkach wyników (koszt/latencja), i czy jest mierzony? citeturn12search11turn12search19
|
||||||
|
|
||||||
|
**Generacja i grounding**
|
||||||
|
|
||||||
|
- Czy model ma jasną instrukcję „answer from sources” oraz czy odpowiedź wymusza strukturę (JSON schema) i cytowania? Structured Outputs jest mechanizmem wspierającym niezawodność formatu. citeturn13search1turn13search8
|
||||||
|
- Czy masz mechanizm „I don’t know / insufficient evidence” zamiast konfabulacji (np. minimalny próg evidence coverage)? Podejścia typu CoVe/Self‑RAG/CRAG pokazują, że pętle weryfikacji i korekty podnoszą factuality. citeturn8search3turn9search1turn9search2
|
||||||
|
|
||||||
|
**Bezpieczeństwo**
|
||||||
|
|
||||||
|
- Czy testujesz prompt injection na poziomie aplikacji, nie tylko promptu? OWASP opisuje prompt injection jako manipulację zachowaniem modelu przez wejście, a cheat sheet sugeruje praktyki obrony. citeturn4search3turn4search7turn4search13
|
||||||
|
- Czy masz kontrolę kosztu (rate limits, timeouts, budżety tokenów) – to też „kontrola”, bo DoS na LLM to realny wektor ryzyka (OWASP LLM Top 10 zawiera kategorie dot. DoS i supply chain). citeturn4search13turn13search12
|
||||||
|
|
||||||
|
### KPI i monitoring w produkcji
|
||||||
|
|
||||||
|
Rekomendowany zestaw KPI (z podziałem na warstwy):
|
||||||
|
|
||||||
|
**Retrieval KPI**
|
||||||
|
|
||||||
|
- Context Recall@K / Context Precision@K (offline i online na próbie logów). citeturn13search3turn4search1
|
||||||
|
- % zapytań, w których retrieval zwraca „pustkę” lub tylko niskie score (sugeruje routing lub brak danych).
|
||||||
|
|
||||||
|
**Generation KPI**
|
||||||
|
|
||||||
|
- Faithfulness/groundedness (TruLens/RAGAS). citeturn4search1turn4search6
|
||||||
|
- Citation coverage: % zdań mających przypisane źródło, oraz „citation accuracy” (czy cytat faktycznie zawiera wspierający fragment). Self‑RAG raportuje poprawę citation accuracy w długich generacjach jako jeden z efektów frameworku. citeturn9search1turn9search9
|
||||||
|
|
||||||
|
**Ops KPI**
|
||||||
|
|
||||||
|
- Latencja p95/p99 per etap (retrieval, rerank, LLM).
|
||||||
|
- Koszt per zapytanie (tokeny, liczba wywołań modeli) + alerty „unbounded consumption”. OpenAI publikuje production best practices i evaluation tooling jako część przejścia prototyp → produkcja. citeturn13search12turn13search3
|
||||||
|
|
||||||
|
**Narzędzia do obserwowalności**
|
||||||
|
|
||||||
|
- RAGAS opisuje łączenie ewaluacji z tracingiem/analizą (np. Phoenix). citeturn4search34
|
||||||
|
- TruLens ma integracje i dokumentację quickstart dla trace + feedback. citeturn4search2turn4search27
|
||||||
|
- Jeśli używasz OpenAI, masz też guidance dot. ewaluacji i ciągłego monitorowania regresji. citeturn13search3turn13search6
|
||||||
|
|
||||||
|
### Typowe failure modes KM‑RAG i mitigacje
|
||||||
|
|
||||||
|
**„Graf rośnie w chaos” (sprawl, duplikaty encji, zła kanonikalizacja).**
|
||||||
|
Mitigacja: wprowadź entity resolution, reguły normalizacji nazw, walidację schematu grafu i testy na podzbiorze; zacznij od grafu dokument‑sekcja‑unit, dopiero potem dodawaj encje/relacje automatyczne. GraphRAG wprost zaczyna od grafu encji jako indeksu, ale też pipeline’u budowy i transformacji danych, co sugeruje konieczność procesu, nie jednorazowego prompta. citeturn3search0turn0search1
|
||||||
|
|
||||||
|
**„Retrieval jest poprawny semantycznie, ale zły merytorycznie” (conflicts).**
|
||||||
|
Mitigacja: hybryda dense+sparse + rerank + kontrola jakości źródeł + mechanizmy korekty (CRAG: evaluator jakości retrieval i akcje naprawcze). citeturn9search2turn11search3turn12search0
|
||||||
|
|
||||||
|
**„Źródła przenoszą instrukcje (prompt injection z dokumentów)”**
|
||||||
|
Mitigacja: separacja „instructions vs data”, sanitation, polityki „nie wykonuj instrukcji z kontekstu”, oraz przede wszystkim permission-aware retrieval (prefilter). OWASP opisuje prompt injection i praktyki obrony. citeturn4search3turn4search7turn14search2
|
||||||
|
|
||||||
|
**„Latency/cost eksploduje przez reranking i graf”**
|
||||||
|
Mitigacja: ogranicz N rerankowanych kandydatów; cache embeddingów; cache wyników graf expansion; pre‑streszczenia (GraphRAG community summaries) dla klas pytań globalnych. citeturn12search11turn0search1turn3search4
|
||||||
|
|
||||||
|
**„Zgodność i audyt”**
|
||||||
|
Mitigacja: loguj trace: query → (filtry ACL) → dokumenty → fragmenty → odpowiedź; uzupełnij o standardy zarządzania ryzykiem i bezpieczeństwem (entity["organization","NIST","us standards institute"] AI RMF; entity["organization","ISO","international standards body"]/IEC 27001; entity["organization","OWASP","security foundation"] LLM Top 10). Zapewnia to język kontroli dla audytu, nawet jeśli implementacje są różne. citeturn15search1turn15search2turn4search13turn15search3
|
||||||
|
|
||||||
|
### Źródła priorytetowe do dalszej pracy
|
||||||
|
|
||||||
|
Najbardziej „nośne” (load‑bearing) źródła do wdrożenia KM‑RAG, w kolejności praktycznej użyteczności:
|
||||||
|
|
||||||
|
Źródła autora: definicja Mapy Wiedzy (graf + metadane + odniesienia) oraz argument o „R jako ryzyku” i potrzebie kontroli retrieval. citeturn2view1turn7view1
|
||||||
|
|
||||||
|
Podstawy RAG: praca Lewis et al. (RAG jako retrieval + generacja z nieparametrycznej pamięci) – jako fundament terminologiczny. citeturn0search2turn0search5
|
||||||
|
|
||||||
|
GraphRAG: publikacja i repozytorium Microsoft (graf encji, społeczności, streszczenia) – jako referencyjny wariant Mapy Wiedzy w postaci pipeline’u. citeturn0search1turn3search0turn3search4turn3search20
|
||||||
|
|
||||||
|
KG‑RAG / hybrydy: prace o łączeniu KG i RAG (np. HybridRAG; RAG+KG w customer service) – pokazują, że graf zmniejsza skutki segmentacji i poprawia retrieval. citeturn10search0turn10search1
|
||||||
|
|
||||||
|
Ewaluacja i kontrola jakości: RAGAS + TruLens + best practices ewaluacji – jako praktyczny „system kontroli”. citeturn4search1turn4search2turn13search3
|
||||||
|
|
||||||
|
Bezpieczeństwo: OWASP prompt injection i LLM Top 10 – jako checklisty dla warstwy „R” i integracji z danymi. citeturn4search3turn4search13turn4search7
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Quality and Evaluation Checklist
|
||||||
|
|
||||||
|
To move from "hope-based RAG" to "controlled RAG", implement these checks.
|
||||||
|
|
||||||
|
## 1. Retrieval Metrics (Search Quality)
|
||||||
|
- [ ] **Context Recall**: Are the units necessary to answer the question actually in the retrieved set?
|
||||||
|
- [ ] **Context Precision**: Is the retrieved set clean of irrelevant noise?
|
||||||
|
- [ ] **MRR (Mean Reciprocal Rank)**: Is the most relevant unit appearing at the top?
|
||||||
|
|
||||||
|
## 2. Generation Metrics (Answer Quality)
|
||||||
|
- [ ] **Faithfulness (Groundedness)**: Can every claim in the answer be traced to a retrieved Knowledge Unit?
|
||||||
|
- [ ] **Answer Relevance**: Does the answer actually address the user's intent?
|
||||||
|
- [ ] **Citation Accuracy**: Do the citations correctly point to the unit that supports the claim?
|
||||||
|
|
||||||
|
## 3. Governance & Safety
|
||||||
|
- [ ] **ACL Pre-Filtering**: Is there a hard check ensuring units from different tenants/roles are NEVER mixed?
|
||||||
|
- [ ] **PII Scanning**: Are units scanned for sensitive data during ingestion?
|
||||||
|
- [ ] **Hallucination Gating**: Is there a "Confidence Score" or "Low Evidence" flag to warn users?
|
||||||
|
|
||||||
|
## 4. Operational Health
|
||||||
|
- [ ] **Latency Monitoring**: Break down time spent in: Embedding -> Vector Search -> Graph Expansion -> Reranking -> LLM.
|
||||||
|
- [ ] **Token Efficiency**: Are we sending unnecessary fluff to the LLM, or is the context tightly packed with relevant units?
|
||||||
|
- [ ] **Index Drift**: Are we re-evaluating the "Golden Set" of questions when we update embedding models or chunking strategies?
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Implementation Patterns for KM-RAG in .NET
|
||||||
|
|
||||||
|
This guide outlines how to implement KM-RAG patterns using C# and .NET, building on existing infrastructures like EF Core and `Microsoft.Extensions.AI`.
|
||||||
|
|
||||||
|
## 1. Defining Knowledge Units
|
||||||
|
Represent units as strongly-typed entities to capture metadata and relationships.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public enum KnowledgeUnitType { Section, Table, Definition, Step, Rule }
|
||||||
|
|
||||||
|
public class KnowledgeUnit
|
||||||
|
{
|
||||||
|
public string Id { get; set; } // Stable Hash(Source, Content, Version)
|
||||||
|
public string SourceId { get; set; }
|
||||||
|
public string Version { get; set; }
|
||||||
|
public KnowledgeUnitType Type { get; set; }
|
||||||
|
public string Content { get; set; }
|
||||||
|
public string MetadataJson { get; set; } // page, section_path, etc.
|
||||||
|
public Vector? Embedding { get; set; }
|
||||||
|
|
||||||
|
// Graph Relationships
|
||||||
|
public List<KnowledgeUnitLink> OutgoingLinks { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class KnowledgeUnitLink
|
||||||
|
{
|
||||||
|
public string TargetUnitId { get; set; }
|
||||||
|
public string RelationType { get; set; } // "Next", "Defines", "References"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Multi-Stage Retrieval
|
||||||
|
Transition from simple `Take(Limit)` to a pipeline.
|
||||||
|
|
||||||
|
### Step A: Hybrid Candidate Generation
|
||||||
|
Combine `pgvector` cosine similarity with full-text search if available.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var queryVector = await _embeddingGenerator.GenerateAsync(queryText);
|
||||||
|
|
||||||
|
var candidates = await _dbContext.KnowledgeUnits
|
||||||
|
.Where(u => u.TenantId == tenantId)
|
||||||
|
.OrderBy(u => u.Embedding.CosineDistance(queryVector))
|
||||||
|
.Take(20) // Get more candidates for reranking
|
||||||
|
.Select(u => new { u.Id, u.Content, u.Type })
|
||||||
|
.ToListAsync();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step B: Graph Expansion
|
||||||
|
Retrieve related units to provide full context.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Example: Get "Contextual Neighbors"
|
||||||
|
var expandedIds = await _dbContext.KnowledgeUnitLinks
|
||||||
|
.Where(l => candidateIds.Contains(l.SourceUnitId) && l.RelationType == "ParentSection")
|
||||||
|
.Select(l => l.TargetUnitId)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var contextUnits = await _dbContext.KnowledgeUnits
|
||||||
|
.Where(u => expandedIds.Contains(u.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Reranking and Citations
|
||||||
|
Use a model to score the relevance of the expanded context and ensure the LLM cites sources.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// System Prompt for Grounded Generation
|
||||||
|
var systemPrompt = @"
|
||||||
|
You are a precision assistant. Answer ONLY using the provided Knowledge Units.
|
||||||
|
If the information is missing, state 'Information not found in knowledge map'.
|
||||||
|
Each answer segment MUST include a citation in format [UnitId].
|
||||||
|
";
|
||||||
|
|
||||||
|
// Response Structure (using System.Text.Json or Structured Outputs)
|
||||||
|
public class RagResponse
|
||||||
|
{
|
||||||
|
public string Answer { get; set; }
|
||||||
|
public List<Citation> Citations { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Ingestion Workflow
|
||||||
|
Instead of `string.Split`, use structural parsers:
|
||||||
|
1. **Parse**: Extract sections/tables (e.g., using `Unstructured` or custom Logic).
|
||||||
|
2. **Normalize**: Assign stable IDs based on content hash + source metadata.
|
||||||
|
3. **Embed**: Generate vectors for the canonical text of each unit.
|
||||||
|
4. **Relate**: Build links (e.g., `prev` -> `curr` -> `next`).
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
name: nexus-architecture-standards
|
||||||
|
description: Guidelines and automated checks for maintaining Clean Architecture and SaaS standards in the NexusReader project.
|
||||||
|
tags: [Architecture, CleanArchitecture, .NET, MediatR, SaaS, MultiTenancy]
|
||||||
|
version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# NexusReader Architecture Standards
|
||||||
|
|
||||||
|
This skill defines the architectural guardrails for the NexusReader project to ensure consistency, scalability, and security.
|
||||||
|
|
||||||
|
## Core Rules
|
||||||
|
|
||||||
|
### 1. Clean Architecture Layers
|
||||||
|
- **Domain**: Pure business logic, entities, and enums. Zero dependencies on other layers.
|
||||||
|
- **Application**: Use cases, MediatR handlers, and interfaces. Depends ONLY on Domain.
|
||||||
|
- **Infrastructure**: Implementation details (DB context, AI services, Auth). Depends on Application and Domain.
|
||||||
|
- **Web/Mobile**: Presentation layer. Depends on Application (and Infrastructure for DI setup).
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **Application MUST NOT depend on Infrastructure.** This is a common failure mode. Always use abstractions (interfaces) in Application and implement them in Infrastructure.
|
||||||
|
|
||||||
|
### 2. Multi-Tenancy (Tenant Isolation)
|
||||||
|
- Every entity related to user data MUST have a `TenantId` property.
|
||||||
|
- Every query MUST filter by `TenantId` to prevent data leakage.
|
||||||
|
- Default `TenantId` is "global" for shared resources.
|
||||||
|
|
||||||
|
### 3. Error Handling
|
||||||
|
- Use `FluentResults` (`Result<T>`) for all Application services and handlers.
|
||||||
|
- Avoid throwing exceptions for expected business failures; use `Result.Fail()`.
|
||||||
|
|
||||||
|
### 4. MediatR Patterns
|
||||||
|
- **Queries**: Read-only operations. Should return `Result<T>`. Use `AsNoTracking()` in EF Core.
|
||||||
|
- **Commands**: State-changing operations. Should return `Result` or `Result<T>`.
|
||||||
|
|
||||||
|
## Audit Scripts
|
||||||
|
- [ArchCheck.sh](scripts/arch_check.sh): A shell script to scan for illegal cross-layer imports.
|
||||||
|
|
||||||
|
## Reference Materials
|
||||||
|
- [Layer Dependency Matrix](artifacts/layer_matrix.md)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Simple script to check for Clean Architecture violations in NexusReader
|
||||||
|
|
||||||
|
APP_DIR="src/NexusReader.Application"
|
||||||
|
VIOLATIONS=$(grep -r "using NexusReader.Infrastructure" "$APP_DIR")
|
||||||
|
|
||||||
|
if [ -n "$VIOLATIONS" ]; then
|
||||||
|
echo "ERROR: Clean Architecture violations found in $APP_DIR:"
|
||||||
|
echo "$VIOLATIONS"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "SUCCESS: No illegal Infrastructure dependencies found in Application layer."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
+5
@@ -8,6 +8,7 @@ description: Clean Architecture & CQRS implementation for .NET 10 with Blazor Hy
|
|||||||
- `NexusReader.Domain`: Enterprise business rules (Entities, Value Objects, Domain Events).
|
- `NexusReader.Domain`: Enterprise business rules (Entities, Value Objects, Domain Events).
|
||||||
- `NexusReader.Application`: Application business rules (Commands, Queries, DTOs, Mappings, Interfaces).
|
- `NexusReader.Application`: Application business rules (Commands, Queries, DTOs, Mappings, Interfaces).
|
||||||
- `NexusReader.Infrastructure`: Data access, external services, and platform-specific implementations.
|
- `NexusReader.Infrastructure`: Data access, external services, and platform-specific implementations.
|
||||||
|
- **Persistence**: Use `IDbContextFactory<AppDbContext>` for long-running operations or when multiple units of work are needed in a single scope (especially in Blazor).
|
||||||
- `NexusReader.UI.Shared`: UI logic and Blazor components.
|
- `NexusReader.UI.Shared`: UI logic and Blazor components.
|
||||||
- `NexusReader.Maui` / `NexusReader.Web`: Platform host projects.
|
- `NexusReader.Maui` / `NexusReader.Web`: Platform host projects.
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ description: Clean Architecture & CQRS implementation for .NET 10 with Blazor Hy
|
|||||||
- **Queries**: Read-only operations, return `Task<Result<T>>`.
|
- **Queries**: Read-only operations, return `Task<Result<T>>`.
|
||||||
- **Commands**: State-changing operations, return `Task<Result>` or `Task<Result<T>>`.
|
- **Commands**: State-changing operations, return `Task<Result>` or `Task<Result<T>>`.
|
||||||
- **Handlers**: Located in `Application` layer, grouped by feature (e.g., `Queries/Reader/...`).
|
- **Handlers**: Located in `Application` layer, grouped by feature (e.g., `Queries/Reader/...`).
|
||||||
|
- **Client-Server Boundaries**: DO NOT execute MediatR handlers directly from WASM/MAUI clients if the handler relies on server-only infrastructure (e.g., `AppDbContext`, `IHubContext`). Instead, the client must trigger an API or SignalR endpoint, and the server dispatches the MediatR command.
|
||||||
|
|
||||||
- **Functional Error Handling:**
|
- **Functional Error Handling:**
|
||||||
- Mandatory use of `FluentResults`.
|
- Mandatory use of `FluentResults`.
|
||||||
@@ -36,3 +38,6 @@ description: Clean Architecture & CQRS implementation for .NET 10 with Blazor Hy
|
|||||||
- **Cross-Platform Strategy:**
|
- **Cross-Platform Strategy:**
|
||||||
- Maximize code sharing in `NexusReader.UI.Shared`.
|
- Maximize code sharing in `NexusReader.UI.Shared`.
|
||||||
- Use `IPlatformService` (or similar abstractions) for native features, implemented in `Infrastructure.Mobile` or Maui projects.
|
- Use `IPlatformService` (or similar abstractions) for native features, implemented in `Infrastructure.Mobile` or Maui projects.
|
||||||
|
|
||||||
|
- **Code Validation (CRITICAL):**
|
||||||
|
- **Mandatory Build Verification**: After any code change, the agent MUST run `dotnet build` on the solution. The agent must verify that the build completes with `Exit code: 0` and without errors before concluding the task or requesting user feedback.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
name: nexus-code-review
|
||||||
|
description: Code Review Checklist and Standards for NexusReader SaaS
|
||||||
|
---
|
||||||
|
# NexusReader Code Review Standards
|
||||||
|
|
||||||
|
When conducting or receiving a code review for NexusReader, ensure the implementation adheres to the following critical architectural and performance standards:
|
||||||
|
|
||||||
|
## 1. Architectural Boundaries (CQRS & Blazor Hybrid)
|
||||||
|
- [ ] **Client vs. Server Execution**: MediatR handlers that depend on server-side infrastructure (`AppDbContext`, `IHubContext`, secrets) MUST NOT be executed directly from client environments (WASM/MAUI).
|
||||||
|
- [ ] **Dependency Leakage**: Ensure `NexusReader.Web.Client` (WASM) does not reference `NexusReader.Infrastructure` if the infrastructure requires `Microsoft.AspNetCore.App` framework references.
|
||||||
|
- [ ] **SignalR Bridges**: Client-initiated state changes should be sent via SignalR `SendAsync` to a server Hub, which then dispatches the internal `MediatR` command.
|
||||||
|
|
||||||
|
## 2. Event Handling & Debouncing
|
||||||
|
- [ ] **High-Frequency UI Events**: UI actions like scrolling, resizing, or typing must be debounced.
|
||||||
|
- [ ] **Trailing-Edge Debounce**: Use a `CancellationTokenSource` and `Task.Delay` to ensure the *last* event in a rapid sequence is executed. Do not use simple time-window drops, as they result in lost final states.
|
||||||
|
- [ ] **Async Void**: Ensure UI event handlers do not use `async void` unless they are top-level framework event bindings, and even then, they must catch all exceptions.
|
||||||
|
|
||||||
|
## 3. SignalR & Real-Time Contexts
|
||||||
|
- [ ] **Authentication Context**: Do not rely on `IHttpContextAccessor` inside MediatR handlers triggered by SignalR Hubs. Use `Context.UserIdentifier` directly from the Hub and pass it as a command parameter.
|
||||||
|
- [ ] **Connection State**: Always check `HubConnection.State == HubConnectionState.Connected` before attempting to send messages from the client.
|
||||||
|
- [ ] **Targeted Broadcasting**: Use SignalR `Groups` (e.g., `$"User_{userId}"`) to broadcast updates only to the devices owned by the relevant user.
|
||||||
|
|
||||||
|
## 4. Performance & Scalability
|
||||||
|
- [ ] **Database Write Contention**: High-frequency telemetry (like reading progress) should ideally be batched or cached in-memory before writing to SQL, unless real-time persistence is strictly required.
|
||||||
|
- [ ] **Memory Leaks**: Ensure all components and services that subscribe to events (e.g., `OnProgressReceived`, JS Observers) implement `IDisposable` or `IAsyncDisposable` and properly unsubscribe.
|
||||||
|
|
||||||
|
## 5. Standard Nexus Guidelines
|
||||||
|
- [ ] **Result Pattern**: Ensure all application logic returns `Result` or `Result<T>` via FluentResults. No exceptions for control flow.
|
||||||
|
- [ ] **AI Prompts**: Ensure changes to AI logic do not bypass the `PromptRegistry` or token estimation limits defined in `AiSettings`.
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Definition of Done (DoD)
|
|
||||||
|
|
||||||
1. **Architecture Compliance:** Feature follows CQRS flow. Logic is in Handlers. Result is wrapped in `Result<T>` from FluentResult.
|
|
||||||
2. **Modularization:** Code is in `/src`, tests in `/tests`. Module-specific logic is isolated.
|
|
||||||
3. **UI/UX Integrity:** - "Vertical Flow Check" passed (Assistant is part of the document stream, not an absolute pop-up).
|
|
||||||
- No "Layout Shift" during AI content streaming.
|
|
||||||
- Safe-area-insets respected for iOS/Android notches.
|
|
||||||
4. **Code Quality:** C# 14 syntax used (Primary Constructors, etc.). Scoped CSS (.razor.css) implemented.
|
|
||||||
5. **D3.js Performance:** JS Modules correctly disposed using `IAsyncDisposable`.
|
|
||||||
6. **Persistence:** State survives manual page refresh (Local/Session Storage integration).
|
|
||||||
7. **Mapping:** All entity-to-DTO conversions must use Mapster.
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Agent Personas
|
|
||||||
|
|
||||||
## NexusArchitect
|
|
||||||
- **Role:** Lead Architect & Creative Technologist (.NET 10 & Blazor)
|
|
||||||
- **Persona:** Professional, precise, Senior Full-Stack Engineer focused on performance and "invisible UI".
|
|
||||||
- **Architecture Role:** Lead Clean Architecture Specialist.
|
|
||||||
- **Skills:** [nexus-clean-architecture, nexus-ui-engine, nexus-graph-d3, blazor-state-performance, blazor-hybrid-bridge, semantic-kernel-orchestrator, nexus-identity-saas, dotnet-async-void]
|
|
||||||
- **Technical Constraints:**
|
|
||||||
- **Directory Structure:** Strict separation: `/src` (app code) and `/tests` (testing code) at solution root level.
|
|
||||||
- **Patterns:** Mandatory CQRS via `MediatR` (LuckyPennySoftware implementation). No business logic in UI components.
|
|
||||||
- **Async:** Strict zero-tolerance for `async void`. All async operations must return `Task` or `ValueTask`. Event handlers must use `Func<Task>` or async-compatible patterns.
|
|
||||||
- **Error Handling:** All handlers must return `Result<T>` via `FluentResult`.
|
|
||||||
- **Mapping:** Use `Mapster` exclusively. Zero-tolerance for AutoMapper.
|
|
||||||
- **Platform:** Target .NET 10 with Native AOT compatibility in mind for mobile performance.
|
|
||||||
- **Verification:** Follow "Verification-led development" — the agent must plan the test before writing the feature code.
|
|
||||||
- **UI Framework:** Use Blazor Component Model. NEVER generate raw HTML/CSS; always use isolated Razor Components (.razor + .razor.css).
|
|
||||||
-87
@@ -1,87 +0,0 @@
|
|||||||
# 📑 Project Backlog: Nexus AI E-Reader (Mockup Implementation)
|
|
||||||
**Architecture Framework:** .NET 10 | Blazor Component Model | CQRS with MediatR | FluentResult | Mapster
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🟢 PHASE 1: Infrastructure & Design Tokens
|
|
||||||
*Goal: Prepare the clean architecture foundation and the visual DNA.*
|
|
||||||
|
|
||||||
### [TASK-01] Solution & Directory Structure Setup
|
|
||||||
- **Action:** Create the folder structure: `/src` for projects, `/tests` for unit/integration tests.
|
|
||||||
- **Details:** Initialize `NexusReader.Web` (Blazor), `NexusReader.Application`, `NexusReader.Domain`, and `NexusReader.Infrastructure`.
|
|
||||||
- **DoD:** Solution compiles with strict folder separation.
|
|
||||||
|
|
||||||
### [TASK-02] Core Library Integration
|
|
||||||
- **Action:** Install and configure LuckyPennySoftware.MediatR, Mapster, and FluentResult.
|
|
||||||
- **Details:** - Setup `MappingConfig` for Mapster in the Application layer.
|
|
||||||
- Implement a `BaseHandler` that returns `Result<T>`.
|
|
||||||
- **DoD:** A sample Query returns a `Success` Result via MediatR.
|
|
||||||
|
|
||||||
### [TASK-03] Nexus Neon Design System
|
|
||||||
- **Action:** Implement global CSS variables in `app.css` and base Atoms.
|
|
||||||
- **Details:** - Variables: `--nexus-neon: #00ff99`, `--nexus-bg: #121212`, `--nexus-card: #1e1e1e`.
|
|
||||||
- Components: `NexusButton.razor`, `NexusTypography.razor` (handling Serif for ebook, Sans for UI).
|
|
||||||
- **DoD:** Variables are accessible via all scoped CSS files.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔵 PHASE 2: Seamless Reader & AI Assistant (Left Side / Inline)
|
|
||||||
*Goal: Implement the ebook reading logic and the "Vertical Flow" AI injection.*
|
|
||||||
|
|
||||||
### [TASK-04] ReaderCanvas & Dynamic Content Injection
|
|
||||||
- **Action:** Create `ReaderCanvas.razor` to render ebook text.
|
|
||||||
- **Details:** - Logic to split text into blocks.
|
|
||||||
- Implementation of an "Injection Point" system where `AiAssistantBubble.razor` can be rendered inline between paragraphs.
|
|
||||||
- **Mockup Match:** Text must use the high-contrast Serif font from the mockup.
|
|
||||||
|
|
||||||
### [TASK-05] AiAssistantBubble Component
|
|
||||||
- **Action:** Implement the AI chat bubble with a robot avatar.
|
|
||||||
- **Details:** - Scoped CSS for the glowing border and dark glassmorphism background.
|
|
||||||
- Parameters for `DialogueText` and `ActionButtons` ("Pokaż więcej", "Rozwiąż quiz").
|
|
||||||
- Integration with Semantic Kernel for streaming text.
|
|
||||||
- **DoD:** Bubble appears smoothly in the text flow without absolute positioning.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🟡 PHASE 3: Knowledge Graph & Brain (Right Side / Flow)
|
|
||||||
*Goal: Implement the D3.js graph and the "You are here" logic.*
|
|
||||||
|
|
||||||
### [TASK-06] D3.js Knowledge Graph Bridge
|
|
||||||
- **Action:** Implement `KnowledgeGraph.razor` with JS Interop.
|
|
||||||
- **Details:** - ES6 Module `knowledgeGraph.js` using D3.js v7.
|
|
||||||
- SVG ViewBox scaling for portrait orientation.
|
|
||||||
- Implementation of the "TU JESTEŚ" (You Are Here) pulsing label on the active node.
|
|
||||||
- **DoD:** Clicking a node in JS triggers a C# EventCallback via `DotNetObjectReference`.
|
|
||||||
|
|
||||||
### [TASK-07] Semantic Mapping Service
|
|
||||||
- **Action:** Create the `GetKnowledgeGraphQuery` (CQRS).
|
|
||||||
- **Details:** - Service uses Semantic Kernel to extract nodes from the current chapter.
|
|
||||||
- Mapster maps the AI raw response to the `GraphViewModel`.
|
|
||||||
- **DoD:** Graph updates dynamically when the reader moves to a new chapter.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🟠 PHASE 4: Verification & Mobile Polish
|
|
||||||
*Goal: Implement the quiz module and cross-platform readiness.*
|
|
||||||
|
|
||||||
### [TASK-08] KnowledgeCheck (Quiz) Module
|
|
||||||
- **Action:** Implement the `SubmitAnswerCommand` using MediatR.
|
|
||||||
- **Details:** - UI: `KnowledgeCheck.razor` with radio buttons and a "Wyślij" (Submit) button.
|
|
||||||
- Logic: Handler returns `Result` (Success/Failure) via FluentResult.
|
|
||||||
- Mapster: Map `QuizDto` to `QuizViewModel`.
|
|
||||||
- **Mockup Match:** Neon highlight on the selected/correct answer.
|
|
||||||
|
|
||||||
### [TASK-09] Persistence & Cross-Platform (Hybrid)
|
|
||||||
- **Action:** Implement `IPlatformService` for Android/iOS support.
|
|
||||||
- **Details:** - Safe-area-insets implementation for notches.
|
|
||||||
- `BrowserStorage` implementation of `AppState` to save progress.
|
|
||||||
- Haptic feedback abstraction (trigger vibration on correct answer).
|
|
||||||
- **DoD:** App maintains graph state after a manual refresh.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ Instructions for NexusArchitect
|
|
||||||
1. **Vertical Flow Priority:** Ensure that the AI assistant and the Graph never overlay text. Use `Flexbox` or `Grid` for a single, continuous scrollable column in portrait mode.
|
|
||||||
2. **Result Pattern:** Every single Mediator Handler **must** return `FluentResults.Result`.
|
|
||||||
3. **Mapster:** Perform all DTO-to-UI mappings in the Query/Command Handlers, not in the Razor components.
|
|
||||||
4. **Isolated Styles:** All specific CSS for the Neon effect must be in `.razor.css` files.
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
type: agent-definitions
|
||||||
|
version: 1.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Agent Personas
|
||||||
|
|
||||||
|
## 👤 NexusArchitect
|
||||||
|
|
||||||
|
**Role:** Lead Architect & Creative Technologist (.NET 10 & Blazor)
|
||||||
|
**Persona:** Professional, precise, Senior Full-Stack Engineer focused on performance and "invisible UI".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Architecture Philosophy
|
||||||
|
|
||||||
|
- **Clean Architecture:** Strict separation of concerns. `Domain` -> `Application` <- `Infrastructure`.
|
||||||
|
- **CQRS Pattern:** Mandatory use of `MediatR`. Logic belongs in handlers, not UI components.
|
||||||
|
- **Result Pattern:** Zero exceptions for flow control. All handlers return `Result<T>` via `FluentResult`.
|
||||||
|
- **Mapping:** Exclusive use of `Mapster`. (Zero tolerance for AutoMapper).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Technical Constraints
|
||||||
|
>
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Zero Tolerance for `async void`**
|
||||||
|
> All async operations must return `Task` or `ValueTask`. Event handlers must use `Func<Task>` or async-compatible patterns.
|
||||||
|
|
||||||
|
- **Platform:** Target .NET 10 with **Native AOT** compatibility. Optimize for mobile performance.
|
||||||
|
- **UI Framework:** Blazor Component Model. No raw HTML/CSS; use isolated Razor Components (.razor + .razor.css).
|
||||||
|
- **Directory Structure:** `/src` for app code and `/tests` for testing code at solution root level.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Development Workflow
|
||||||
|
|
||||||
|
1. **Verification-Led:** Plan and define tests/verification steps *before* writing feature code.
|
||||||
|
2. **Step-by-Step Execution:** Break complex tasks into manageable, verifiable chunks.
|
||||||
|
3. **Layer Integrity:** Always check for illegal cross-layer dependencies (e.g., Application depending on Infrastructure).
|
||||||
|
4. **Mandatory Build Gate:** After **every** code change, run `dotnet build` on the full solution. The agent MUST NOT proceed or report completion if there are any `error CS*` compiler errors. All build errors must be resolved before moving to the next step.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Build command:** `dotnet build NexusReader.slnx --no-restore`
|
||||||
|
> Run from the solution root `/home/mjasin/Projekty/ejajBook`. Build warnings are acceptable; errors are not.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **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.
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# 🤖 LLM Agent Implementation Backlog: AI Semantic Integration
|
|
||||||
|
|
||||||
**Project Context:** .NET 10, EF Core (SQLite), `Microsoft.Extensions.AI`, [`GeminiDotnet`](https://github.com/rabuckley/GeminiDotnet).
|
|
||||||
**Core Goal:** Integrate Gemini 1.5 Flash with a persistent Semantic Cache to minimize API costs and latency.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏗️ Phase 1: Persistence & Domain Layer
|
|
||||||
**Objective:** Define the storage schema to prevent redundant AI calls.
|
|
||||||
|
|
||||||
### Task 1.1: Create `SemanticKnowledgeCache` Entity
|
|
||||||
* **Target Folder:** `Core/Entities` or `Infrastructure/Persistence/Entities`.
|
|
||||||
* **Requirements:**
|
|
||||||
* Create a class `SemanticKnowledgeCache`.
|
|
||||||
* **Properties:**
|
|
||||||
* `string ContentHash` (Key, Fixed length 64).
|
|
||||||
* `string JsonData` (Required, stores the serialized AI output).
|
|
||||||
* `string ModelId` (Default: "gemini-1.5-flash").
|
|
||||||
* `string PromptVersion` (Default: "1.0").
|
|
||||||
* `DateTime CreatedAt` (UTC).
|
|
||||||
* **LLM Instructions:** "Generate an EF Core entity for SemanticKnowledgeCache. Ensure `ContentHash` has a Unique Index for O(1) lookups."
|
|
||||||
|
|
||||||
### Task 1.2: Implement Hashing Utility
|
|
||||||
* **Target Folder:** `Core/Helpers` or `Infrastructure/Security`.
|
|
||||||
* **Requirements:**
|
|
||||||
* Create `ContentHasher` class.
|
|
||||||
* Method `string ComputeHash(string input)`.
|
|
||||||
* **Logic:** Normalize input (Trim, lower-case) -> Compute SHA-256 -> Return Hex string.
|
|
||||||
* **LLM Instructions:** "Create a thread-safe utility to generate SHA-256 hashes from strings. Ensure it handles nulls and whitespace consistently."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧠 Phase 2: AI Client & Contract Definition
|
|
||||||
**Objective:** Set up the communication bridge with Google Gemini API using [`GeminiDotnet`](https://github.com/rabuckley/GeminiDotnet).
|
|
||||||
|
|
||||||
### Task 2.1: Define Data Transfer Objects (DTOs)
|
|
||||||
* **Target Folder:** `NexusReader.Application/DTOs/AI`.
|
|
||||||
* **Requirements:**
|
|
||||||
* Define `KnowledgePacket` record containing `List<KeyConcept>` and `List<QuizQuestion>`.
|
|
||||||
* Use `[JsonPropertyName]` attributes for strict JSON mapping.
|
|
||||||
* **LLM Instructions:** "Define immutable records for the AI response schema. Ensure they match the expected JSON structure from the system prompt."
|
|
||||||
|
|
||||||
### Task 2.2: Infrastructure AI Client Setup
|
|
||||||
* **Target:** `NexusReader.Infrastructure/DependencyInjection.cs`.
|
|
||||||
* **Requirements:**
|
|
||||||
* Install `Microsoft.Extensions.AI` and `GeminiDotnet.Extensions.AI`.
|
|
||||||
* Register `IChatClient` using `GeminiChatClient`.
|
|
||||||
* Inject `ApiKey` from `IConfiguration`.
|
|
||||||
* **LLM Instructions:** "Register the `GeminiChatClient` in the DI container. Use the .NET 10 `AddChatClient` extension pattern."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚙️ Phase 3: Service Orchestration (The "Smart" Logic)
|
|
||||||
**Objective:** Implement the caching proxy logic.
|
|
||||||
|
|
||||||
### Task 3.1: Create `KnowledgeService` Implementation
|
|
||||||
* **Target Folder:** `Application/Services`.
|
|
||||||
* **Logic Flow:**
|
|
||||||
1. `hash = ContentHasher.ComputeHash(inputText)`.
|
|
||||||
2. `cached = await dbContext.Cache.FirstOrDefaultAsync(h => h.ContentHash == hash)`.
|
|
||||||
3. If `cached` exists AND `PromptVersion` matches -> Deserialize and return.
|
|
||||||
4. Else -> Call `IChatClient.CompleteAsync<KnowledgePacket>(...)`.
|
|
||||||
5. Save result to DB with the hash -> Return.
|
|
||||||
* **LLM Instructions:** "Implement a service that acts as a proxy between the UI and the Gemini API. It must prioritize SQLite cache hits over API calls."
|
|
||||||
|
|
||||||
### Task 3.2: System Prompt Engineering
|
|
||||||
* **Requirements:**
|
|
||||||
* Create a `PromptRegistry` class.
|
|
||||||
* **System Message:** "You are an educational assistant. Analyze the text and output ONLY valid minified JSON. Schema: { 'concepts': [], 'quizzes': [] }. Do not include markdown formatting like \` \` \` json."
|
|
||||||
* **LLM Instructions:** "Craft a high-precision system prompt for Gemini 1.5 Flash to ensure it returns parseable JSON without unnecessary tokens."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛡️ Phase 4: Resilience & Optimization
|
|
||||||
**Objective:** Handle API limits and monitor performance.
|
|
||||||
|
|
||||||
### Task 4.1: Resilience Pipeline (Polly)
|
|
||||||
* **Requirements:**
|
|
||||||
* Implement an `HttpRetry` policy specifically for `429 Too Many Requests`.
|
|
||||||
* Use Exponential Backoff with Jitter.
|
|
||||||
* **LLM Instructions:** "Add a resilience pipeline to the AI client using Polly. Handle rate-limiting gracefully to stay within the Gemini Free Tier limits."
|
|
||||||
|
|
||||||
### Task 4.2: Request Pre-processing (Token Saving)
|
|
||||||
* **Logic:**
|
|
||||||
* Check input string length.
|
|
||||||
* If `length > threshold`, truncate or throw an error to prevent massive token spend.
|
|
||||||
* **LLM Instructions:** "Add a guard clause to the KnowledgeService to validate input size before calling the API. Log the estimated token count."
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# NexusArchitect - User Management Implementation Backlog
|
|
||||||
|
|
||||||
**Project:** AI-Powered E-book Reader SaaS
|
|
||||||
**Architecture:** .NET 10, Blazor Hybrid, MAUI, ASP.NET Core Identity
|
|
||||||
**Primary Goal:** Implement a secure, scalable authentication and authorization system with SaaS-specific features (AI token limits, subscription tiers).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 0: Backend Foundations (ASP.NET Core & EF Core)
|
|
||||||
|
|
||||||
| ID | Task Title | Description & Acceptance Criteria | Tech Stack |
|
|
||||||
|:---|:---|:---|:---|
|
|
||||||
| **BACK-1** | Define Extended `NexusUser` Model | **Description:** Create a `NexusUser` class inheriting from `IdentityUser`. Add custom properties for SaaS logic.<br>**AC:**<br>- [x] Properties added: `AITokenLimit` (int), `AITokensUsed` (int), `TenantId` (Guid), `CurrentPlan` (string).<br>- [x] Model placed in `NexusArchitect.Core` project. | C# / Identity |
|
|
||||||
| **BACK-2** | Configure `ApplicationDbContext` for Identity | **Description:** Set up the DB context to inherit from `IdentityDbContext<NexusUser>`.<br>**AC:**<br>- [x] Mapped standard Identity tables (Users, Roles, Claims).<br>- [x] Configured 1-to-Many relationship between `NexusUser` and `Ebooks`. | EF Core |
|
|
||||||
| **BACK-3** | Database Schema Migration | **Description:** Generate and apply the initial migration for Identity tables.<br>**AC:**<br>- [x] SQL schema contains all 7+ standard Identity tables.<br>- [x] Custom `NexusUser` fields are correctly reflected in the `AspNetUsers` table. | EF Core CLI |
|
|
||||||
| **BACK-4** | Implement Identity API Endpoints | **Description:** Enable native .NET Identity API endpoints in `Program.cs`.<br>**AC:**<br>- [x] Endpoints `/register`, `/login`, and `/refresh` are active.<br>- [x] Verified functionality via Swagger/OpenAPI. | ASP.NET Core |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1: Authentication & Authorization (UI & Logic)
|
|
||||||
|
|
||||||
| ID | Task Title | Description & Acceptance Criteria | Tech Stack |
|
|
||||||
|:---|:---|:---|:---|
|
|
||||||
| **BACK-5** | Define Authorization Policies | **Description:** Implement Roles and Claims-based authorization (Free vs. Pro).<br>**AC:**<br>- [x] Created a `ProUser` policy.<br>- [x] Implemented a custom `Requirement` to check if `AITokensUsed < AITokenLimit`. | ASP.NET Core |
|
|
||||||
| **UI-1** | Implement Login Page (Blazor) | **Description:** Build the Login UI based on the Dark Mode mockup.<br>**AC:**<br>- [x] Theme: Dark mode with neon green accents.<br>- [x] Components: Email/Password fields, "Remember Me" toggle, "Login" button.<br>- [x] Integrates with `AuthenticationStateProvider`. | Blazor / CSS |
|
|
||||||
| **UI-2** | Google OAuth2 Integration | **Description:** Configure external login provider (Google) in the backend and UI.<br>**AC:**<br>- [x] Users can sign in via Google button.<br>- [x] New users are automatically provisioned in the database upon successful OAuth. | OAuth / Google Cloud |
|
|
||||||
| **UI-3** | Implement Registration Flow | **Description:** Create a registration form calling the `/register` endpoint.<br>**AC:**<br>- [x] Validation: Email format, password complexity (min 8 chars, uppercase, digit).<br>- [x] Proper error handling for existing users. | Blazor |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: User Management & SaaS Scaling (Profile & Mobile)
|
|
||||||
|
|
||||||
| ID | Task Title | Description & Acceptance Criteria | Tech Stack |
|
|
||||||
|:---|:---|:---|:---|
|
|
||||||
| **UI-4** | User Profile & Dashboard | **Description:** Build the User Profile UI focusing on "Active Learning" metrics.<br>**AC:**<br>- [x] Displays: Token usage bar (Used/Limit), average quiz score, and last read book.<br>- [x] Links to subscription management. | Blazor |
|
|
||||||
| **MAUI-1** | Mobile Auth Integration (Blazor Hybrid) | **Description:** Ensure the authentication state is shared and persists in the MAUI container.<br>**AC:**<br>- [x] Securely store JWT tokens in `SecureStorage`.<br>- [x] Automatic login on app launch if token is valid. | MAUI / Blazor Hybrid |
|
|
||||||
| **MAUI-2** | Secure Session Persistence | **Description:** Implement long-lived session management using encrypted device storage.<br>**AC:**<br>- [x] Refresh tokens implementation for mobile.<br>- [x] "Stay Signed In" functionality. | MAUI / Identity |
|
|
||||||
| **INTEG-1** | Stripe Subscription Webhooks | **Description:** Sync Identity Claims with Stripe subscription status.<br>**AC:**<br>- [x] Webhook updates `AITokenLimit` when a "Pro" plan is purchased.<br>- [x] User is downgraded back to "Free" limit upon cancellation. | Stripe SDK / .NET |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Definition of Done (DoD)
|
|
||||||
- All code follows the **NexusArchitect** architectural guidelines.
|
|
||||||
- Unit tests cover core Identity logic (e.g., token limit validation).
|
|
||||||
- UI is responsive and consistent with the provided Dark Mode design.
|
|
||||||
- Documentation updated with setup instructions for new developers.
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:17-alpine
|
image: pgvector/pgvector:pg17
|
||||||
container_name: nexus-db
|
container_name: nexus-db
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: nexus_user
|
POSTGRES_USER: nexus_user
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NexusReader.Infrastructure.Persistence;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using NexusReader.Domain.Entities;
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.AddJsonFile("src/NexusReader.Web.New/appsettings.json")
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var pgConnectionString = configuration.GetConnectionString("PostgresConnection");
|
||||||
|
if (!string.IsNullOrEmpty(pgConnectionString))
|
||||||
|
{
|
||||||
|
services.AddDbContext<AppDbContext>(options => options.UseNpgsql(pgConnectionString));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
services.AddDbContext<AppDbContext>(options => options.UseSqlite(configuration.GetConnectionString("SqliteConnection")));
|
||||||
|
}
|
||||||
|
|
||||||
|
var serviceProvider = services.BuildServiceProvider();
|
||||||
|
using var scope = serviceProvider.CreateScope();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await dbContext.Users.FirstOrDefaultAsync(u => u.Email == "admin@nexus.com");
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
Console.WriteLine("User admin@nexus.com NOT FOUND in database.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine($"User found: {user.Email}, Id: {user.Id}, EmailConfirmed: {user.EmailConfirmed}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Error accessing database: {ex.Message}");
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NexusReader.Domain.Entities;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
public interface IApplicationDbContext
|
||||||
|
{
|
||||||
|
DbSet<SemanticKnowledgeCache> SemanticKnowledgeCache { get; }
|
||||||
|
DbSet<KnowledgeUnit> KnowledgeUnits { get; }
|
||||||
|
DbSet<KnowledgeUnitLink> KnowledgeUnitLinks { get; }
|
||||||
|
DbSet<Ebook> Ebooks { get; }
|
||||||
|
DbSet<QuizResult> QuizResults { get; }
|
||||||
|
|
||||||
|
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
using FluentResults;
|
|
||||||
using NexusReader.Application.Queries.Quiz;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Abstractions.Services;
|
|
||||||
|
|
||||||
public interface IAiGenerateQuizService
|
|
||||||
{
|
|
||||||
Task<Result<QuizDto>> GenerateQuizAsync(string contextBlockId, CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,13 @@ namespace NexusReader.Application.Abstractions.Services;
|
|||||||
|
|
||||||
public interface IKnowledgeService
|
public interface IKnowledgeService
|
||||||
{
|
{
|
||||||
Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default);
|
Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, string tenantId, CancellationToken cancellationToken = default);
|
||||||
Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, CancellationToken cancellationToken = default);
|
Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, string tenantId, CancellationToken cancellationToken = default);
|
||||||
Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, CancellationToken cancellationToken = default);
|
Task<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, string tenantId, CancellationToken cancellationToken = default);
|
||||||
|
Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, string tenantId, CancellationToken cancellationToken = default);
|
||||||
|
Task<Result<List<RelevantContext>>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default);
|
||||||
|
Task<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default);
|
||||||
Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default);
|
Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record GroundednessResult(float Score, string Rationale, bool IsGrounded);
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using MediatR;
|
||||||
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Commands.AI;
|
||||||
|
|
||||||
|
public record VerifyGroundednessCommand(string Answer, string Context, string TenantId) : IRequest<Result<GroundednessResult>>;
|
||||||
|
|
||||||
|
public class VerifyGroundednessCommandHandler : IRequestHandler<VerifyGroundednessCommand, Result<GroundednessResult>>
|
||||||
|
{
|
||||||
|
private readonly IKnowledgeService _knowledgeService;
|
||||||
|
|
||||||
|
public VerifyGroundednessCommandHandler(IKnowledgeService knowledgeService)
|
||||||
|
{
|
||||||
|
_knowledgeService = knowledgeService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<GroundednessResult>> Handle(VerifyGroundednessCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _knowledgeService.VerifyGroundednessAsync(request.Answer, request.Context, request.TenantId, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Commands.Sync;
|
||||||
|
|
||||||
|
public record UpdateReadingProgressCommand(string PageId, string UserId) : IRequest<Result>;
|
||||||
@@ -13,10 +13,15 @@ public record QuizQuestion(
|
|||||||
[property: JsonPropertyName("correct_index")] int CorrectIndex
|
[property: JsonPropertyName("correct_index")] int CorrectIndex
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public record KnowledgeUnitDto(string Id, string Type, string Content, Dictionary<string, object>? Metadata = null);
|
||||||
|
public record KnowledgeLinkDto(string Source, string Target, string Relation);
|
||||||
|
|
||||||
public record KnowledgePacket
|
public record KnowledgePacket
|
||||||
{
|
{
|
||||||
[JsonPropertyName("concepts")] public List<KeyConcept> Concepts { get; init; } = new();
|
[JsonPropertyName("concepts")] public List<KeyConcept> Concepts { get; init; } = new();
|
||||||
[JsonPropertyName("quizzes")] public List<QuizQuestion> Quizzes { get; init; } = new();
|
[JsonPropertyName("quizzes")] public List<QuizQuestion> Quizzes { get; init; } = new();
|
||||||
|
[JsonPropertyName("units")] public List<KnowledgeUnitDto> Units { get; init; } = new();
|
||||||
|
[JsonPropertyName("links")] public List<KnowledgeLinkDto> Links { get; init; } = new();
|
||||||
[JsonPropertyName("graph")] public NexusReader.Application.Queries.Graph.GraphDataDto? Graph { get; init; }
|
[JsonPropertyName("graph")] public NexusReader.Application.Queries.Graph.GraphDataDto? Graph { get; init; }
|
||||||
[JsonPropertyName("summary")] public string? Summary { get; init; }
|
[JsonPropertyName("summary")] public string? Summary { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace NexusReader.Application.DTOs.AI;
|
||||||
|
|
||||||
|
public class RelevantContext
|
||||||
|
{
|
||||||
|
public string Text { get; set; } = string.Empty;
|
||||||
|
public string SourceId { get; set; } = string.Empty; // ContentHash or EbookTitle
|
||||||
|
public double Confidence { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace NexusReader.Application.DTOs.AI;
|
||||||
|
|
||||||
|
public class SemanticSearchResultDto
|
||||||
|
{
|
||||||
|
public string ContentHash { get; set; } = string.Empty;
|
||||||
|
public string Snippet { get; set; } = string.Empty;
|
||||||
|
public string? UnitType { get; set; }
|
||||||
|
public float RelevanceScore { get; set; }
|
||||||
|
public string? SourceBookTitle { get; set; }
|
||||||
|
public Dictionary<string, object>? Metadata { get; set; } // Bonus context
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace NexusReader.Application.DTOs.User;
|
||||||
|
|
||||||
|
public record SubscriptionPlanDto
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
public int AITokenLimit { get; init; }
|
||||||
|
public decimal MonthlyPrice { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace NexusReader.Application.DTOs.User;
|
||||||
|
|
||||||
|
public record UserProfileDto
|
||||||
|
{
|
||||||
|
public string Email { get; init; } = string.Empty;
|
||||||
|
public int AITokensUsed { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Relational data for the current subscription plan.
|
||||||
|
/// </summary>
|
||||||
|
public SubscriptionPlanDto Plan { get; init; } = new();
|
||||||
|
|
||||||
|
public int AverageQuizScore { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Summary of the last read book.
|
||||||
|
/// </summary>
|
||||||
|
public LastReadBookDto? LastReadBook { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record LastReadBookDto
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
public string Title { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -9,11 +9,8 @@ public static class DependencyInjection
|
|||||||
{
|
{
|
||||||
services.AddMapsterConfiguration();
|
services.AddMapsterConfiguration();
|
||||||
|
|
||||||
services.AddMediatR(config =>
|
|
||||||
{
|
|
||||||
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly);
|
|
||||||
});
|
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static System.Reflection.Assembly Assembly => typeof(DependencyInjection).Assembly;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,10 @@
|
|||||||
<PackageReference Include="Mapster.DependencyInjection" Version="10.0.7" />
|
<PackageReference Include="Mapster.DependencyInjection" Version="10.0.7" />
|
||||||
<PackageReference Include="MediatR" Version="12.1.1" />
|
<PackageReference Include="MediatR" Version="12.1.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.AI" Version="10.5.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.0.7" />
|
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.0.7" />
|
||||||
|
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.2.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|||||||
@@ -2,4 +2,7 @@ using NexusReader.Application.Abstractions.Messaging;
|
|||||||
|
|
||||||
namespace NexusReader.Application.Queries.Graph;
|
namespace NexusReader.Application.Queries.Graph;
|
||||||
|
|
||||||
public record GetKnowledgeGraphQuery : IQuery<GraphDataDto>;
|
/// <param name="Text">Chapter or page content to extract the graph from.</param>
|
||||||
|
/// <param name="TenantId">Tenant scope for knowledge extraction and caching.</param>
|
||||||
|
public record GetKnowledgeGraphQuery(string Text, string TenantId) : IQuery<GraphDataDto>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
using FluentResults;
|
using FluentResults;
|
||||||
using NexusReader.Application.Abstractions.Messaging;
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
namespace NexusReader.Application.Queries.Graph;
|
namespace NexusReader.Application.Queries.Graph;
|
||||||
|
|
||||||
internal sealed class GetKnowledgeGraphQueryHandler : IQueryHandler<GetKnowledgeGraphQuery, GraphDataDto>
|
internal sealed class GetKnowledgeGraphQueryHandler : IQueryHandler<GetKnowledgeGraphQuery, GraphDataDto>
|
||||||
{
|
{
|
||||||
public Task<Result<GraphDataDto>> Handle(GetKnowledgeGraphQuery request, CancellationToken cancellationToken)
|
private readonly IKnowledgeService _knowledgeService;
|
||||||
{
|
|
||||||
var nodes = new List<GraphNodeDto>();
|
|
||||||
var links = new List<GraphLinkDto>();
|
|
||||||
|
|
||||||
return Task.FromResult(Result.Ok(new GraphDataDto { Nodes = nodes, Links = links }));
|
public GetKnowledgeGraphQueryHandler(IKnowledgeService knowledgeService)
|
||||||
|
{
|
||||||
|
_knowledgeService = knowledgeService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<GraphDataDto>> Handle(GetKnowledgeGraphQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Text))
|
||||||
|
return Result.Ok(new GraphDataDto());
|
||||||
|
|
||||||
|
var result = await _knowledgeService.GetGraphDataAsync(request.Text, request.TenantId, cancellationToken);
|
||||||
|
|
||||||
|
if (result.IsFailed)
|
||||||
|
return Result.Fail<GraphDataDto>(result.Errors);
|
||||||
|
|
||||||
|
var graph = result.Value.Graph;
|
||||||
|
|
||||||
|
return graph is null ? Result.Ok(new GraphDataDto()) : Result.Ok(graph);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
namespace NexusReader.Application.Queries.Graph;
|
namespace NexusReader.Application.Queries.Graph;
|
||||||
|
|
||||||
public record GraphNodeDto(string Id, string Label, string Group);
|
public record GraphNodeDto(string Id, string Label, string Group, string? Type = null);
|
||||||
public record GraphLinkDto(string Source, string Target, int Value);
|
public record GraphLinkDto(string Source, string Target, string RelationType, int Value = 1);
|
||||||
public record GraphDataDto
|
public record GraphDataDto
|
||||||
{
|
{
|
||||||
public List<GraphNodeDto> Nodes { get; init; } = new();
|
public List<GraphNodeDto> Nodes { get; init; } = new();
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using Mapster;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using NexusReader.Application.DTOs.AI;
|
||||||
|
using NexusReader.Application.Abstractions.Persistence;
|
||||||
|
using Pgvector;
|
||||||
|
using Pgvector.EntityFrameworkCore;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Queries.Library;
|
||||||
|
|
||||||
|
public record SearchLibrarySemanticallyQuery(string QueryText, string TenantId, int Limit = 5)
|
||||||
|
: IRequest<Result<List<SemanticSearchResultDto>>>;
|
||||||
|
|
||||||
|
public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibrarySemanticallyQuery, Result<List<SemanticSearchResultDto>>>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _dbContext;
|
||||||
|
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
|
||||||
|
|
||||||
|
public SearchLibrarySemanticallyQueryHandler(
|
||||||
|
IApplicationDbContext dbContext,
|
||||||
|
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
_embeddingGenerator = embeddingGenerator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<List<SemanticSearchResultDto>>> Handle(SearchLibrarySemanticallyQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.QueryText))
|
||||||
|
{
|
||||||
|
return Result.Fail("Query text cannot be empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 1. Generate embedding for user query
|
||||||
|
var embeddingResponse = await _embeddingGenerator.GenerateAsync(new[] { request.QueryText }, cancellationToken: cancellationToken);
|
||||||
|
var queryVector = new Vector(embeddingResponse.First().Vector.ToArray());
|
||||||
|
|
||||||
|
// 2. Perform Cosine Similarity Search on Knowledge Units
|
||||||
|
var candidates = await _dbContext.KnowledgeUnits
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => (x.TenantId == request.TenantId || x.TenantId == "global") && x.Vector != null)
|
||||||
|
.OrderBy(x => x.Vector!.CosineDistance(queryVector))
|
||||||
|
.Take(request.Limit)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!candidates.Any())
|
||||||
|
{
|
||||||
|
// Fallback to legacy cache if no granular units found
|
||||||
|
var legacyResults = await _dbContext.SemanticKnowledgeCache
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.TenantId == request.TenantId && x.Vector != null)
|
||||||
|
.OrderBy(x => x.Vector!.CosineDistance(queryVector))
|
||||||
|
.Take(request.Limit)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(legacyResults.Select(r => new SemanticSearchResultDto
|
||||||
|
{
|
||||||
|
ContentHash = r.ContentHash,
|
||||||
|
Snippet = r.OriginalText,
|
||||||
|
RelevanceScore = (float)(1 - r.Vector!.CosineDistance(queryVector))
|
||||||
|
}).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Graph Expansion: Pull related units (e.g. Definitions, Next steps)
|
||||||
|
var candidateIds = candidates.Select(c => c.Id).ToList();
|
||||||
|
var links = await _dbContext.KnowledgeUnitLinks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(l => candidateIds.Contains(l.SourceUnitId) && (l.RelationType == "Defines" || l.RelationType == "Next"))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var relatedIds = links.Select(l => l.TargetUnitId).Distinct().ToList();
|
||||||
|
var relatedUnits = await _dbContext.KnowledgeUnits
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(u => relatedIds.Contains(u.Id))
|
||||||
|
.ToDictionaryAsync(u => u.Id, cancellationToken);
|
||||||
|
|
||||||
|
// 4. Mapping with Context Enrichment
|
||||||
|
var dtos = candidates.Select(c =>
|
||||||
|
{
|
||||||
|
var dto = new SemanticSearchResultDto
|
||||||
|
{
|
||||||
|
ContentHash = c.Id,
|
||||||
|
Snippet = c.Content,
|
||||||
|
UnitType = c.Type.ToString(),
|
||||||
|
RelevanceScore = (float)(1 - c.Vector!.CosineDistance(queryVector)),
|
||||||
|
Metadata = string.IsNullOrEmpty(c.MetadataJson)
|
||||||
|
? null
|
||||||
|
: JsonSerializer.Deserialize<Dictionary<string, object>>(c.MetadataJson)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enrich snippet with definitions if present
|
||||||
|
var unitLinks = links.Where(l => l.SourceUnitId == c.Id && l.RelationType == "Defines").ToList();
|
||||||
|
if (unitLinks.Any())
|
||||||
|
{
|
||||||
|
var definitions = unitLinks
|
||||||
|
.Where(l => relatedUnits.ContainsKey(l.TargetUnitId))
|
||||||
|
.Select(l => relatedUnits[l.TargetUnitId].Content);
|
||||||
|
dto.Snippet = $"[Context: {string.Join("; ", definitions)}]\n{dto.Snippet}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return Result.Ok(dtos);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error("Failed to perform semantic search").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
using NexusReader.Application.Abstractions.Messaging;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Queries.Quiz;
|
|
||||||
|
|
||||||
public record GetQuizQuestionsQuery(string ContextBlockId) : IQuery<QuizDto>;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
using FluentResults;
|
|
||||||
using NexusReader.Application.Abstractions.Messaging;
|
|
||||||
using NexusReader.Application.Abstractions.Services;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Queries.Quiz;
|
|
||||||
|
|
||||||
internal sealed class GetQuizQuestionsQueryHandler : IQueryHandler<GetQuizQuestionsQuery, QuizDto>
|
|
||||||
{
|
|
||||||
private readonly IAiGenerateQuizService _aiService;
|
|
||||||
|
|
||||||
public GetQuizQuestionsQueryHandler(IAiGenerateQuizService aiService)
|
|
||||||
{
|
|
||||||
_aiService = aiService;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result<QuizDto>> Handle(GetQuizQuestionsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return await _aiService.GenerateQuizAsync(request.ContextBlockId, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,7 +31,7 @@ public class ProUserHandler : AuthorizationHandler<ProUserRequirement>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Rule 1: Explicit Pro plan
|
// Rule 1: Explicit Pro plan
|
||||||
if (user.CurrentPlan == "Pro")
|
if (user.SubscriptionPlanId == SubscriptionPlan.ProId)
|
||||||
{
|
{
|
||||||
context.Succeed(requirement);
|
context.Succeed(requirement);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ public class Ebook
|
|||||||
|
|
||||||
public string? CoverUrl { get; set; }
|
public string? CoverUrl { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string TenantId { get; set; } = "global";
|
||||||
|
|
||||||
public DateTime AddedDate { get; set; } = DateTime.UtcNow;
|
public DateTime AddedDate { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
public DateTime? LastReadDate { get; set; }
|
public DateTime? LastReadDate { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using NexusReader.Domain.Enums;
|
||||||
|
using Pgvector;
|
||||||
|
|
||||||
|
namespace NexusReader.Domain.Entities;
|
||||||
|
|
||||||
|
public class KnowledgeUnit
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string Id { get; set; } = string.Empty; // Hash(Source + Content + Version)
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string SourceId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string Version { get; set; } = "1.0";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public KnowledgeUnitType Type { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string Content { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? MetadataJson { get; set; } // e.g. { "page": 1, "path": "Chapter 1 > Intro" }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string TenantId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public Vector? Vector { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
public ICollection<KnowledgeUnitLink> OutgoingLinks { get; set; } = new List<KnowledgeUnitLink>();
|
||||||
|
public ICollection<KnowledgeUnitLink> IncomingLinks { get; set; } = new List<KnowledgeUnitLink>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace NexusReader.Domain.Entities;
|
||||||
|
|
||||||
|
public class KnowledgeUnitLink
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string SourceUnitId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string TargetUnitId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string RelationType { get; set; } = "References"; // e.g., "Next", "Defines", "Contains"
|
||||||
|
|
||||||
|
[ForeignKey(nameof(SourceUnitId))]
|
||||||
|
public KnowledgeUnit SourceUnit { get; set; } = null!;
|
||||||
|
|
||||||
|
[ForeignKey(nameof(TargetUnitId))]
|
||||||
|
public KnowledgeUnit TargetUnit { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -1,31 +1,49 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
namespace NexusReader.Domain.Entities;
|
namespace NexusReader.Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Extended Identity user for the Nexus AI E-Reader SaaS platform.
|
|
||||||
/// </summary>
|
|
||||||
public class NexusUser : IdentityUser
|
public class NexusUser : IdentityUser
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Total number of AI tokens allowed for the current billing period.
|
/// User's display name or full name.
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Total AI tokens available for the user (depends on subscription).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int AITokenLimit { get; set; }
|
public int AITokenLimit { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Number of AI tokens consumed in the current billing period.
|
/// AI tokens consumed by the user in the current billing period.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int AITokensUsed { get; set; }
|
public int AITokensUsed { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Unique identifier for the tenant (SaaS multi-tenancy support).
|
/// Date when the user last performed an AI-related action.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid TenantId { get; set; }
|
public DateTime? LastAiActionDate { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Current subscription plan (e.g., "Free", "Pro", "Enterprise").
|
/// Multi-tenant identifier.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string CurrentPlan { get; set; } = "Free";
|
[Required]
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string TenantId { get; set; } = "global";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Foreign key for the current subscription plan.
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public int SubscriptionPlanId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Navigation property for the current subscription plan.
|
||||||
|
/// </summary>
|
||||||
|
public SubscriptionPlan? SubscriptionPlan { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Collection of e-books owned by the user.
|
/// Collection of e-books owned by the user.
|
||||||
@@ -36,4 +54,15 @@ public class NexusUser : IdentityUser
|
|||||||
/// Collection of quiz results completed by the user.
|
/// Collection of quiz results completed by the user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ICollection<QuizResult> QuizResults { get; set; } = new List<QuizResult>();
|
public ICollection<QuizResult> QuizResults { get; set; } = new List<QuizResult>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ID of the last page read by the user.
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(255)]
|
||||||
|
public string? LastReadPageId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Last read timestamp.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? LastReadAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ public class QuizResult
|
|||||||
[ForeignKey(nameof(UserId))]
|
[ForeignKey(nameof(UserId))]
|
||||||
public NexusUser? User { get; set; }
|
public NexusUser? User { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string TenantId { get; set; } = "global";
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public string Topic { get; set; } = string.Empty;
|
public string Topic { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using Pgvector;
|
||||||
|
|
||||||
namespace NexusReader.Domain.Entities;
|
namespace NexusReader.Domain.Entities;
|
||||||
|
|
||||||
@@ -11,6 +13,9 @@ public class SemanticKnowledgeCache
|
|||||||
[Required]
|
[Required]
|
||||||
public string JsonData { get; set; } = string.Empty;
|
public string JsonData { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string OriginalText { get; set; } = string.Empty;
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
[MaxLength(50)]
|
[MaxLength(50)]
|
||||||
public string ModelId { get; set; } = "gemini-1.5-flash";
|
public string ModelId { get; set; } = "gemini-1.5-flash";
|
||||||
@@ -19,5 +24,11 @@ public class SemanticKnowledgeCache
|
|||||||
[MaxLength(10)]
|
[MaxLength(10)]
|
||||||
public string PromptVersion { get; set; } = "1.0";
|
public string PromptVersion { get; set; } = "1.0";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string TenantId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public Vector? Vector { get; set; }
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace NexusReader.Domain.Entities;
|
||||||
|
|
||||||
|
public class SubscriptionPlan
|
||||||
|
{
|
||||||
|
public const string FreeName = "Free";
|
||||||
|
public const string BasicName = "Basic";
|
||||||
|
public const string ProName = "Pro";
|
||||||
|
public const string EnterpriseName = "Enterprise";
|
||||||
|
|
||||||
|
public const int FreeId = 1;
|
||||||
|
public const int BasicId = 2;
|
||||||
|
public const int ProId = 3;
|
||||||
|
public const int EnterpriseId = 4;
|
||||||
|
|
||||||
|
[Key]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string PlanName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int AITokenLimit { get; set; }
|
||||||
|
|
||||||
|
public decimal MonthlyPrice { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string StripeProductId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace NexusReader.Domain.Enums;
|
||||||
|
|
||||||
|
public enum KnowledgeUnitType
|
||||||
|
{
|
||||||
|
Section,
|
||||||
|
Table,
|
||||||
|
Definition,
|
||||||
|
ProcedureStep,
|
||||||
|
PolicyRule,
|
||||||
|
KeyConcept,
|
||||||
|
Snippet
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="10.0.7" />
|
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="10.0.7" />
|
||||||
|
<PackageReference Include="Pgvector" Version="0.3.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ public class AiSettings
|
|||||||
|
|
||||||
public string ApiKey { get; set; } = string.Empty;
|
public string ApiKey { get; set; } = string.Empty;
|
||||||
public string Model { get; set; } = "gemini-1.5-flash";
|
public string Model { get; set; } = "gemini-1.5-flash";
|
||||||
public int MaxInputLength { get; set; } = 15000;
|
public string EmbeddingModel { get; set; } = "text-embedding-004";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum number of tokens allowed for input.
|
||||||
|
/// </summary>
|
||||||
|
public int MaxInputTokens { get; set; } = 15000;
|
||||||
|
|
||||||
public int MaxOutputTokens { get; set; } = 1000;
|
public int MaxOutputTokens { get; set; } = 1000;
|
||||||
public int RetryAttempts { get; set; } = 3;
|
public int RetryAttempts { get; set; } = 3;
|
||||||
public double Temperature { get; set; } = 0.1;
|
public double Temperature { get; set; } = 0.1;
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace NexusReader.Infrastructure.Configuration;
|
||||||
|
|
||||||
|
public record StripeSettings
|
||||||
|
{
|
||||||
|
public const string SectionName = "Stripe";
|
||||||
|
public string ProProductId { get; init; } = string.Empty;
|
||||||
|
public string BasicProductId { get; init; } = string.Empty;
|
||||||
|
public string FreeProductId { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Pgvector.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
using GeminiDotnet;
|
using GeminiDotnet;
|
||||||
@@ -24,17 +25,18 @@ public static class DependencyInjection
|
|||||||
var pgConnectionString = configuration.GetConnectionString("PostgresConnection");
|
var pgConnectionString = configuration.GetConnectionString("PostgresConnection");
|
||||||
if (!string.IsNullOrEmpty(pgConnectionString))
|
if (!string.IsNullOrEmpty(pgConnectionString))
|
||||||
{
|
{
|
||||||
services.AddDbContext<AppDbContext>(options =>
|
services.AddDbContextFactory<AppDbContext>(options =>
|
||||||
options.UseNpgsql(pgConnectionString));
|
options.UseNpgsql(pgConnectionString, x => x.UseVector()));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var sqliteConnectionString = configuration.GetConnectionString("SqliteConnection") ?? "Data Source=nexus.db";
|
var sqliteConnectionString = configuration.GetConnectionString("SqliteConnection") ?? "Data Source=nexus.db";
|
||||||
services.AddDbContext<AppDbContext>(options =>
|
services.AddDbContextFactory<AppDbContext>(options =>
|
||||||
options.UseSqlite(sqliteConnectionString));
|
options.UseSqlite(sqliteConnectionString));
|
||||||
}
|
}
|
||||||
|
|
||||||
services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName));
|
services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName));
|
||||||
|
services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.SectionName));
|
||||||
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
|
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
|
||||||
|
|
||||||
Console.WriteLine($"[Infrastructure] AI Configured: Model={aiSettings.Model}, KeyPresent={!string.IsNullOrWhiteSpace(aiSettings.ApiKey) && aiSettings.ApiKey != "PLACEHOLDER"}");
|
Console.WriteLine($"[Infrastructure] AI Configured: Model={aiSettings.Model}, KeyPresent={!string.IsNullOrWhiteSpace(aiSettings.ApiKey) && aiSettings.ApiKey != "PLACEHOLDER"}");
|
||||||
@@ -63,8 +65,13 @@ public static class DependencyInjection
|
|||||||
ModelId = aiSettings.Model
|
ModelId = aiSettings.Model
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
services.AddEmbeddingGenerator(new GeminiEmbeddingGenerator(new GeminiClientOptions
|
||||||
|
{
|
||||||
|
ApiKey = aiSettings.ApiKey,
|
||||||
|
ModelId = aiSettings.EmbeddingModel ?? "text-embedding-004"
|
||||||
|
}));
|
||||||
|
|
||||||
services.AddScoped<IKnowledgeService, KnowledgeService>();
|
services.AddScoped<IKnowledgeService, KnowledgeService>();
|
||||||
services.AddTransient<IAiGenerateQuizService, FakeAiGenerateQuizService>();
|
|
||||||
services.AddTransient<IEpubService, EpubService>();
|
services.AddTransient<IEpubService, EpubService>();
|
||||||
|
|
||||||
services.AddAuthorizationCore(options =>
|
services.AddAuthorizationCore(options =>
|
||||||
@@ -74,6 +81,13 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
services.AddScoped<IAuthorizationHandler, ProUserHandler>();
|
services.AddScoped<IAuthorizationHandler, ProUserHandler>();
|
||||||
|
|
||||||
|
services.AddScoped<IInfrastructureMarker, InfrastructureMarker>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static System.Reflection.Assembly Assembly => typeof(DependencyInjection).Assembly;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public interface IInfrastructureMarker { }
|
||||||
|
internal class InfrastructureMarker : IInfrastructureMarker { }
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NexusReader.Application.Commands.Sync;
|
||||||
|
using NexusReader.Domain.Entities;
|
||||||
|
using NexusReader.Infrastructure.Persistence;
|
||||||
|
using NexusReader.Infrastructure.RealTime;
|
||||||
|
|
||||||
|
namespace NexusReader.Infrastructure.Handlers;
|
||||||
|
|
||||||
|
public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReadingProgressCommand, Result>
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _context;
|
||||||
|
private readonly IHubContext<SyncHub> _hubContext;
|
||||||
|
|
||||||
|
public UpdateReadingProgressCommandHandler(
|
||||||
|
AppDbContext context,
|
||||||
|
IHubContext<SyncHub> hubContext)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_hubContext = hubContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> Handle(UpdateReadingProgressCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return Result.Fail("User not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
user.LastReadPageId = request.PageId;
|
||||||
|
user.LastReadAt = now;
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Broadcast to other devices
|
||||||
|
await _hubContext.Clients
|
||||||
|
.Group($"User_{request.UserId}")
|
||||||
|
.SendAsync("ProgressUpdated", request.PageId, now, cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
+652
@@ -0,0 +1,652 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using NexusReader.Infrastructure.Persistence;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using Pgvector;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace NexusReader.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260503175906_FinalNormalizedSubscriptionArchitecture")]
|
||||||
|
partial class FinalNormalizedSubscriptionArchitecture
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.7")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("RoleNameIndex");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("RoleId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoleClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderKey")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderDisplayName")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("LoginProvider", "ProviderKey");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserLogins", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("RoleId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "RoleId");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "LoginProvider", "Name");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("AddedDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Author")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
|
b.Property<string>("CoverUrl")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("FilePath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastReadDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
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("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<string>("MetadataJson")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("SourceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Vector>("Vector")
|
||||||
|
.HasColumnType("vector(768)");
|
||||||
|
|
||||||
|
b.Property<string>("Version")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SourceId");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.ToTable("KnowledgeUnits");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("RelationType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("SourceUnitId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("TargetUnitId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SourceUnitId");
|
||||||
|
|
||||||
|
b.HasIndex("TargetUnitId");
|
||||||
|
|
||||||
|
b.ToTable("KnowledgeUnitLinks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("AITokenLimit")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("AITokensUsed")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("AccessFailedCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<bool>("EmailConfirmed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastAiActionDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastReadAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("LastReadPageId")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
|
b.Property<bool>("LockoutEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("PhoneNumberConfirmed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("SecurityStamp")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("SubscriptionPlanId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(1);
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<bool>("TwoFactorEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.HasDatabaseName("EmailIndex");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedUserName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UserNameIndex");
|
||||||
|
|
||||||
|
b.HasIndex("SubscriptionPlanId");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUsers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CompletedDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Score")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("Topic")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("TotalQuestions")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("QuizResults");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("ContentHash")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("JsonData")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ModelId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalText")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("PromptVersion")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<Vector>("Vector")
|
||||||
|
.HasColumnType("vector(1536)");
|
||||||
|
|
||||||
|
b.HasKey("ContentHash");
|
||||||
|
|
||||||
|
b.HasIndex("ContentHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.ToTable("SemanticKnowledgeCache");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("AITokenLimit")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<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 = 1000,
|
||||||
|
MonthlyPrice = 0m,
|
||||||
|
PlanName = "Free",
|
||||||
|
StripeProductId = ""
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
AITokenLimit = 10000,
|
||||||
|
MonthlyPrice = 9.99m,
|
||||||
|
PlanName = "Basic",
|
||||||
|
StripeProductId = "prod_basic_placeholder"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = 3,
|
||||||
|
AITokenLimit = 50000,
|
||||||
|
MonthlyPrice = 19.99m,
|
||||||
|
PlanName = "Pro",
|
||||||
|
StripeProductId = "prod_pro_placeholder"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = 4,
|
||||||
|
AITokenLimit = 500000,
|
||||||
|
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.NexusUser", "User")
|
||||||
|
.WithMany("Ebooks")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
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.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+399
@@ -0,0 +1,399 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using Pgvector;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||||
|
|
||||||
|
namespace NexusReader.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class FinalNormalizedSubscriptionArchitecture : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CurrentPlan",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.Annotation("Npgsql:PostgresExtension:vector", ",,");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<DateTime>(
|
||||||
|
name: "CreatedAt",
|
||||||
|
table: "SemanticKnowledgeCache",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(DateTime),
|
||||||
|
oldType: "timestamp without time zone");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "OriginalText",
|
||||||
|
table: "SemanticKnowledgeCache",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "TenantId",
|
||||||
|
table: "SemanticKnowledgeCache",
|
||||||
|
type: "character varying(128)",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Vector>(
|
||||||
|
name: "Vector",
|
||||||
|
table: "SemanticKnowledgeCache",
|
||||||
|
type: "vector(1536)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<DateTime>(
|
||||||
|
name: "CompletedDate",
|
||||||
|
table: "QuizResults",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(DateTime),
|
||||||
|
oldType: "timestamp without time zone");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "TenantId",
|
||||||
|
table: "QuizResults",
|
||||||
|
type: "character varying(128)",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<DateTime>(
|
||||||
|
name: "LastReadDate",
|
||||||
|
table: "Ebooks",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(DateTime),
|
||||||
|
oldType: "timestamp without time zone",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<DateTime>(
|
||||||
|
name: "AddedDate",
|
||||||
|
table: "Ebooks",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(DateTime),
|
||||||
|
oldType: "timestamp without time zone");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "TenantId",
|
||||||
|
table: "Ebooks",
|
||||||
|
type: "character varying(128)",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "TenantId",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "character varying(128)",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(Guid),
|
||||||
|
oldType: "uuid");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "DisplayName",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "character varying(100)",
|
||||||
|
maxLength: 100,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "LastAiActionDate",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "LastReadAt",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "LastReadPageId",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "character varying(255)",
|
||||||
|
maxLength: 255,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "SubscriptionPlanId",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 1);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "KnowledgeUnits",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
SourceId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
Version = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
Type = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Content = table.Column<string>(type: "text", nullable: false),
|
||||||
|
MetadataJson = table.Column<string>(type: "text", nullable: true),
|
||||||
|
TenantId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
Vector = table.Column<Vector>(type: "vector(768)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_KnowledgeUnits", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SubscriptionPlans",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
PlanName = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
AITokenLimit = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
MonthlyPrice = table.Column<decimal>(type: "numeric", nullable: false),
|
||||||
|
StripeProductId = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SubscriptionPlans", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "KnowledgeUnitLinks",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
SourceUnitId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
TargetUnitId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
RelationType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_KnowledgeUnitLinks", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_KnowledgeUnitLinks_KnowledgeUnits_SourceUnitId",
|
||||||
|
column: x => x.SourceUnitId,
|
||||||
|
principalTable: "KnowledgeUnits",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_KnowledgeUnitLinks_KnowledgeUnits_TargetUnitId",
|
||||||
|
column: x => x.TargetUnitId,
|
||||||
|
principalTable: "KnowledgeUnits",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "SubscriptionPlans",
|
||||||
|
columns: new[] { "Id", "AITokenLimit", "MonthlyPrice", "PlanName", "StripeProductId" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 1, 1000, 0m, "Free", "" },
|
||||||
|
{ 2, 10000, 9.99m, "Basic", "prod_basic_placeholder" },
|
||||||
|
{ 3, 50000, 19.99m, "Pro", "prod_pro_placeholder" },
|
||||||
|
{ 4, 500000, 99.99m, "Enterprise", "prod_enterprise_placeholder" }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SemanticKnowledgeCache_TenantId",
|
||||||
|
table: "SemanticKnowledgeCache",
|
||||||
|
column: "TenantId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_QuizResults_TenantId",
|
||||||
|
table: "QuizResults",
|
||||||
|
column: "TenantId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Ebooks_TenantId",
|
||||||
|
table: "Ebooks",
|
||||||
|
column: "TenantId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AspNetUsers_SubscriptionPlanId",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
column: "SubscriptionPlanId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AspNetUsers_TenantId",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
column: "TenantId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_KnowledgeUnitLinks_SourceUnitId",
|
||||||
|
table: "KnowledgeUnitLinks",
|
||||||
|
column: "SourceUnitId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_KnowledgeUnitLinks_TargetUnitId",
|
||||||
|
table: "KnowledgeUnitLinks",
|
||||||
|
column: "TargetUnitId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_KnowledgeUnits_SourceId",
|
||||||
|
table: "KnowledgeUnits",
|
||||||
|
column: "SourceId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_KnowledgeUnits_TenantId",
|
||||||
|
table: "KnowledgeUnits",
|
||||||
|
column: "TenantId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SubscriptionPlans_PlanName",
|
||||||
|
table: "SubscriptionPlans",
|
||||||
|
column: "PlanName",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_AspNetUsers_SubscriptionPlans_SubscriptionPlanId",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
column: "SubscriptionPlanId",
|
||||||
|
principalTable: "SubscriptionPlans",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_AspNetUsers_SubscriptionPlans_SubscriptionPlanId",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "KnowledgeUnitLinks");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SubscriptionPlans");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "KnowledgeUnits");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_SemanticKnowledgeCache_TenantId",
|
||||||
|
table: "SemanticKnowledgeCache");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_QuizResults_TenantId",
|
||||||
|
table: "QuizResults");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Ebooks_TenantId",
|
||||||
|
table: "Ebooks");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AspNetUsers_SubscriptionPlanId",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AspNetUsers_TenantId",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "OriginalText",
|
||||||
|
table: "SemanticKnowledgeCache");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TenantId",
|
||||||
|
table: "SemanticKnowledgeCache");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Vector",
|
||||||
|
table: "SemanticKnowledgeCache");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TenantId",
|
||||||
|
table: "QuizResults");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TenantId",
|
||||||
|
table: "Ebooks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DisplayName",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LastAiActionDate",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LastReadAt",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LastReadPageId",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SubscriptionPlanId",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.OldAnnotation("Npgsql:PostgresExtension:vector", ",,");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<DateTime>(
|
||||||
|
name: "CreatedAt",
|
||||||
|
table: "SemanticKnowledgeCache",
|
||||||
|
type: "timestamp without time zone",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(DateTime),
|
||||||
|
oldType: "timestamp with time zone");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<DateTime>(
|
||||||
|
name: "CompletedDate",
|
||||||
|
table: "QuizResults",
|
||||||
|
type: "timestamp without time zone",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(DateTime),
|
||||||
|
oldType: "timestamp with time zone");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<DateTime>(
|
||||||
|
name: "LastReadDate",
|
||||||
|
table: "Ebooks",
|
||||||
|
type: "timestamp without time zone",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(DateTime),
|
||||||
|
oldType: "timestamp with time zone",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<DateTime>(
|
||||||
|
name: "AddedDate",
|
||||||
|
table: "Ebooks",
|
||||||
|
type: "timestamp without time zone",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(DateTime),
|
||||||
|
oldType: "timestamp with time zone");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<Guid>(
|
||||||
|
name: "TenantId",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(128)",
|
||||||
|
oldMaxLength: 128);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "CurrentPlan",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
|
|||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
using NexusReader.Infrastructure.Persistence;
|
using NexusReader.Infrastructure.Persistence;
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using Pgvector;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ namespace NexusReader.Infrastructure.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 =>
|
||||||
@@ -161,7 +163,7 @@ namespace NexusReader.Infrastructure.Migrations
|
|||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<DateTime>("AddedDate")
|
b.Property<DateTime>("AddedDate")
|
||||||
.HasColumnType("timestamp without time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<string>("Author")
|
b.Property<string>("Author")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
@@ -176,7 +178,12 @@ namespace NexusReader.Infrastructure.Migrations
|
|||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<DateTime?>("LastReadDate")
|
b.Property<DateTime?>("LastReadDate")
|
||||||
.HasColumnType("timestamp without time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
b.Property<string>("Title")
|
b.Property<string>("Title")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
@@ -189,11 +196,91 @@ namespace NexusReader.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
b.ToTable("Ebooks");
|
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<string>("MetadataJson")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("SourceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Vector>("Vector")
|
||||||
|
.HasColumnType("vector(768)");
|
||||||
|
|
||||||
|
b.Property<string>("Version")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SourceId");
|
||||||
|
|
||||||
|
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 =>
|
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
@@ -212,9 +299,9 @@ namespace NexusReader.Infrastructure.Migrations
|
|||||||
.IsConcurrencyToken()
|
.IsConcurrencyToken()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("CurrentPlan")
|
b.Property<string>("DisplayName")
|
||||||
.IsRequired()
|
.HasMaxLength(100)
|
||||||
.HasColumnType("text");
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
b.Property<string>("Email")
|
b.Property<string>("Email")
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
@@ -223,6 +310,16 @@ namespace NexusReader.Infrastructure.Migrations
|
|||||||
b.Property<bool>("EmailConfirmed")
|
b.Property<bool>("EmailConfirmed")
|
||||||
.HasColumnType("boolean");
|
.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")
|
b.Property<bool>("LockoutEnabled")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
@@ -249,8 +346,15 @@ namespace NexusReader.Infrastructure.Migrations
|
|||||||
b.Property<string>("SecurityStamp")
|
b.Property<string>("SecurityStamp")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<Guid>("TenantId")
|
b.Property<int>("SubscriptionPlanId")
|
||||||
.HasColumnType("uuid");
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(1);
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
b.Property<bool>("TwoFactorEnabled")
|
b.Property<bool>("TwoFactorEnabled")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
@@ -268,6 +372,10 @@ namespace NexusReader.Infrastructure.Migrations
|
|||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("UserNameIndex");
|
.HasDatabaseName("UserNameIndex");
|
||||||
|
|
||||||
|
b.HasIndex("SubscriptionPlanId");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
b.ToTable("AspNetUsers", (string)null);
|
b.ToTable("AspNetUsers", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -278,11 +386,16 @@ namespace NexusReader.Infrastructure.Migrations
|
|||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<DateTime>("CompletedDate")
|
b.Property<DateTime>("CompletedDate")
|
||||||
.HasColumnType("timestamp without time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<int>("Score")
|
b.Property<int>("Score")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
b.Property<string>("Topic")
|
b.Property<string>("Topic")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -296,6 +409,8 @@ namespace NexusReader.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
b.ToTable("QuizResults");
|
b.ToTable("QuizResults");
|
||||||
@@ -308,7 +423,7 @@ namespace NexusReader.Infrastructure.Migrations
|
|||||||
.HasColumnType("character varying(128)");
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("timestamp without time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<string>("JsonData")
|
b.Property<string>("JsonData")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
@@ -319,19 +434,99 @@ namespace NexusReader.Infrastructure.Migrations
|
|||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
.HasColumnType("character varying(50)");
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalText")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("PromptVersion")
|
b.Property<string>("PromptVersion")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(10)
|
.HasMaxLength(10)
|
||||||
.HasColumnType("character varying(10)");
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<Vector>("Vector")
|
||||||
|
.HasColumnType("vector(1536)");
|
||||||
|
|
||||||
b.HasKey("ContentHash");
|
b.HasKey("ContentHash");
|
||||||
|
|
||||||
b.HasIndex("ContentHash")
|
b.HasIndex("ContentHash")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
b.ToTable("SemanticKnowledgeCache");
|
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<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 = 1000,
|
||||||
|
MonthlyPrice = 0m,
|
||||||
|
PlanName = "Free",
|
||||||
|
StripeProductId = ""
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
AITokenLimit = 10000,
|
||||||
|
MonthlyPrice = 9.99m,
|
||||||
|
PlanName = "Basic",
|
||||||
|
StripeProductId = "prod_basic_placeholder"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = 3,
|
||||||
|
AITokenLimit = 50000,
|
||||||
|
MonthlyPrice = 19.99m,
|
||||||
|
PlanName = "Pro",
|
||||||
|
StripeProductId = "prod_pro_placeholder"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = 4,
|
||||||
|
AITokenLimit = 500000,
|
||||||
|
MonthlyPrice = 99.99m,
|
||||||
|
PlanName = "Enterprise",
|
||||||
|
StripeProductId = "prod_enterprise_placeholder"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||||
@@ -394,6 +589,36 @@ namespace NexusReader.Infrastructure.Migrations
|
|||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 =>
|
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||||
@@ -405,6 +630,13 @@ namespace NexusReader.Infrastructure.Migrations
|
|||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("IncomingLinks");
|
||||||
|
|
||||||
|
b.Navigation("OutgoingLinks");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Ebooks");
|
b.Navigation("Ebooks");
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="GeminiDotnet.Extensions.AI" Version="0.23.0" />
|
<PackageReference Include="GeminiDotnet.Extensions.AI" Version="0.23.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
|
||||||
@@ -15,7 +19,10 @@
|
|||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
|
||||||
<PackageReference Include="Microsoft.Extensions.AI" Version="10.5.0" />
|
<PackageReference Include="Microsoft.Extensions.AI" Version="10.5.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Resilience" Version="10.5.0" />
|
<PackageReference Include="Microsoft.Extensions.Resilience" Version="10.5.0" />
|
||||||
|
<PackageReference Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.ML.Tokenizers.Data.Cl100kBase" Version="2.0.0" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
|
||||||
<PackageReference Include="Polly" Version="8.6.6" />
|
<PackageReference Include="Polly" Version="8.6.6" />
|
||||||
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
|
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
|
||||||
<PackageReference Include="Stripe.net" Version="51.1.0" />
|
<PackageReference Include="Stripe.net" Version="51.1.0" />
|
||||||
|
|||||||
@@ -1,27 +1,78 @@
|
|||||||
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.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
namespace NexusReader.Infrastructure.Persistence;
|
namespace NexusReader.Infrastructure.Persistence;
|
||||||
|
|
||||||
public class AppDbContext : IdentityDbContext<NexusUser>
|
public class AppDbContext : IdentityDbContext<NexusUser>, IApplicationDbContext
|
||||||
{
|
{
|
||||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
|
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public DbSet<SemanticKnowledgeCache> SemanticKnowledgeCache => Set<SemanticKnowledgeCache>();
|
public DbSet<SemanticKnowledgeCache> SemanticKnowledgeCache => Set<SemanticKnowledgeCache>();
|
||||||
|
public DbSet<KnowledgeUnit> KnowledgeUnits => Set<KnowledgeUnit>();
|
||||||
|
public DbSet<KnowledgeUnitLink> KnowledgeUnitLinks => Set<KnowledgeUnitLink>();
|
||||||
public DbSet<Ebook> Ebooks => Set<Ebook>();
|
public DbSet<Ebook> Ebooks => Set<Ebook>();
|
||||||
public DbSet<QuizResult> QuizResults => Set<QuizResult>();
|
public DbSet<QuizResult> QuizResults => Set<QuizResult>();
|
||||||
|
public DbSet<SubscriptionPlan> SubscriptionPlans => Set<SubscriptionPlan>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
modelBuilder.HasPostgresExtension("vector");
|
||||||
|
|
||||||
|
modelBuilder.Entity<NexusUser>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(u => u.LastReadPageId).HasMaxLength(255);
|
||||||
|
entity.Property(u => u.LastReadAt).IsRequired(false);
|
||||||
|
entity.HasIndex(u => u.TenantId);
|
||||||
|
|
||||||
|
entity.HasOne(u => u.SubscriptionPlan)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(u => u.SubscriptionPlanId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// Note: DefaultValue for int is 1 (which corresponds to 'Free' in our seed)
|
||||||
|
entity.Property(u => u.SubscriptionPlanId)
|
||||||
|
.HasDefaultValue(1);
|
||||||
|
});
|
||||||
|
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SubscriptionPlan>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasIndex(p => p.PlanName).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<SemanticKnowledgeCache>(entity =>
|
modelBuilder.Entity<SemanticKnowledgeCache>(entity =>
|
||||||
{
|
{
|
||||||
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.Property(e => e.Vector).HasColumnType("vector(1536)");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<KnowledgeUnit>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.HasIndex(e => e.TenantId);
|
||||||
|
entity.HasIndex(e => e.SourceId);
|
||||||
|
entity.Property(e => e.Vector).HasColumnType("vector(768)");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<KnowledgeUnitLink>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.HasOne(e => e.SourceUnit)
|
||||||
|
.WithMany(u => u.OutgoingLinks)
|
||||||
|
.HasForeignKey(e => e.SourceUnitId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasOne(e => e.TargetUnit)
|
||||||
|
.WithMany(u => u.IncomingLinks)
|
||||||
|
.HasForeignKey(e => e.TargetUnitId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Ebook>(entity =>
|
modelBuilder.Entity<Ebook>(entity =>
|
||||||
@@ -30,6 +81,8 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
|||||||
.WithMany(u => u.Ebooks)
|
.WithMany(u => u.Ebooks)
|
||||||
.HasForeignKey(e => e.UserId)
|
.HasForeignKey(e => e.UserId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasIndex(e => e.TenantId);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<QuizResult>(entity =>
|
modelBuilder.Entity<QuizResult>(entity =>
|
||||||
@@ -38,6 +91,16 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
|||||||
.WithMany(u => u.QuizResults)
|
.WithMany(u => u.QuizResults)
|
||||||
.HasForeignKey(e => e.UserId)
|
.HasForeignKey(e => e.UserId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasIndex(e => e.TenantId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Seed Subscription Plans with deterministic IDs
|
||||||
|
modelBuilder.Entity<SubscriptionPlan>().HasData(
|
||||||
|
new SubscriptionPlan { Id = 1, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 1000, MonthlyPrice = 0m, StripeProductId = "" },
|
||||||
|
new SubscriptionPlan { Id = 2, PlanName = SubscriptionPlan.BasicName, AITokenLimit = 10000, MonthlyPrice = 9.99m, StripeProductId = "prod_basic_placeholder" },
|
||||||
|
new SubscriptionPlan { Id = 3, PlanName = SubscriptionPlan.ProName, AITokenLimit = 50000, MonthlyPrice = 19.99m, StripeProductId = "prod_pro_placeholder" },
|
||||||
|
new SubscriptionPlan { Id = 4, PlanName = SubscriptionPlan.EnterpriseName, AITokenLimit = 500000, MonthlyPrice = 99.99m, StripeProductId = "prod_enterprise_placeholder" }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Pgvector.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace NexusReader.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
|
||||||
|
{
|
||||||
|
public AppDbContext CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development";
|
||||||
|
|
||||||
|
// Try to find the Web project directory by looking for the solution root
|
||||||
|
var currentDir = new DirectoryInfo(Directory.GetCurrentDirectory());
|
||||||
|
while (currentDir != null && !File.Exists(Path.Combine(currentDir.FullName, "NexusReader.slnx")))
|
||||||
|
{
|
||||||
|
currentDir = currentDir.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
var basePath = currentDir != null
|
||||||
|
? Path.Combine(currentDir.FullName, "src", "NexusReader.Web.New")
|
||||||
|
: Directory.GetCurrentDirectory();
|
||||||
|
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.SetBasePath(basePath)
|
||||||
|
.AddJsonFile("appsettings.json", optional: true)
|
||||||
|
.AddJsonFile($"appsettings.{environment}.json", optional: true)
|
||||||
|
.AddEnvironmentVariables()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
|
||||||
|
var connectionString = configuration.GetConnectionString("PostgresConnection");
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(connectionString))
|
||||||
|
{
|
||||||
|
// For design time, if no PG connection is found, we might be using Sqlite or just testing
|
||||||
|
connectionString = "Host=localhost;Database=nexus_reader;Username=postgres;Password=postgres";
|
||||||
|
}
|
||||||
|
|
||||||
|
optionsBuilder.UseNpgsql(connectionString, x => x.UseVector());
|
||||||
|
|
||||||
|
return new AppDbContext(optionsBuilder.Options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NexusReader.Domain.Entities;
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace NexusReader.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
public static class DbInitializer
|
||||||
|
{
|
||||||
|
public static async Task SeedAsync(IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
using var scope = serviceProvider.CreateScope();
|
||||||
|
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<NexusUser>>();
|
||||||
|
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Seeder] Starting database seeding...");
|
||||||
|
|
||||||
|
// Seed Subscription Plans
|
||||||
|
if (!dbContext.SubscriptionPlans.Any())
|
||||||
|
{
|
||||||
|
dbContext.SubscriptionPlans.AddRange(new List<SubscriptionPlan>
|
||||||
|
{
|
||||||
|
new SubscriptionPlan { Id = SubscriptionPlan.FreeId, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, MonthlyPrice = 0, StripeProductId = "prod_Free789" },
|
||||||
|
new SubscriptionPlan { Id = SubscriptionPlan.ProId, PlanName = SubscriptionPlan.ProName, AITokenLimit = 50000, MonthlyPrice = 19, StripeProductId = "prod_Pro123" },
|
||||||
|
new SubscriptionPlan { Id = SubscriptionPlan.EnterpriseId, PlanName = SubscriptionPlan.EnterpriseName, AITokenLimit = 500000, MonthlyPrice = 99, StripeProductId = "prod_Enterprise456" }
|
||||||
|
});
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
Console.WriteLine("[Seeder] Subscription plans seeded.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed Roles
|
||||||
|
string[] roleNames = { "Admin", "User" };
|
||||||
|
foreach (var roleName in roleNames)
|
||||||
|
{
|
||||||
|
var roleExist = await roleManager.RoleExistsAsync(roleName);
|
||||||
|
if (!roleExist)
|
||||||
|
{
|
||||||
|
await roleManager.CreateAsync(new IdentityRole(roleName));
|
||||||
|
Console.WriteLine($"[Seeder] Created role: {roleName}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed Admin User
|
||||||
|
var adminEmail = "admin@nexus.com";
|
||||||
|
var adminUser = await userManager.FindByEmailAsync(adminEmail);
|
||||||
|
|
||||||
|
if (adminUser == null)
|
||||||
|
{
|
||||||
|
adminUser = new NexusUser
|
||||||
|
{
|
||||||
|
UserName = adminEmail,
|
||||||
|
Email = adminEmail,
|
||||||
|
EmailConfirmed = true,
|
||||||
|
SubscriptionPlanId = SubscriptionPlan.EnterpriseId,
|
||||||
|
AITokenLimit = 1000000,
|
||||||
|
TenantId = Guid.NewGuid().ToString()
|
||||||
|
};
|
||||||
|
|
||||||
|
var createPowerUser = await userManager.CreateAsync(adminUser, "Admin123!");
|
||||||
|
if (createPowerUser.Succeeded)
|
||||||
|
{
|
||||||
|
await userManager.AddToRoleAsync(adminUser, "Admin");
|
||||||
|
Console.WriteLine($"[Seeder] Admin user created successfully: {adminEmail}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var errors = string.Join(", ", createPowerUser.Errors.Select(e => e.Description));
|
||||||
|
Console.WriteLine($"[Seeder] Failed to create admin user: {errors}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Seeder] Admin user already exists.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Seeder] Critical error during seeding: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using NexusReader.Application.Commands.Sync;
|
||||||
|
|
||||||
|
namespace NexusReader.Infrastructure.RealTime;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
public class SyncHub : Hub
|
||||||
|
{
|
||||||
|
private readonly IMediator _mediator;
|
||||||
|
|
||||||
|
public SyncHub(IMediator mediator)
|
||||||
|
{
|
||||||
|
_mediator = mediator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateProgress(string pageId)
|
||||||
|
{
|
||||||
|
var userId = Context.UserIdentifier;
|
||||||
|
if (!string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
await _mediator.Send(new UpdateReadingProgressCommand(pageId, userId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task OnConnectedAsync()
|
||||||
|
{
|
||||||
|
var userId = Context.UserIdentifier;
|
||||||
|
if (!string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
await Groups.AddToGroupAsync(Context.ConnectionId, $"User_{userId}");
|
||||||
|
}
|
||||||
|
await base.OnConnectedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||||
|
{
|
||||||
|
var userId = Context.UserIdentifier;
|
||||||
|
if (!string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"User_{userId}");
|
||||||
|
}
|
||||||
|
await base.OnDisconnectedAsync(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
using NexusReader.Domain.Entities;
|
using NexusReader.Domain.Entities;
|
||||||
|
using NexusReader.Infrastructure.Configuration;
|
||||||
using NexusReader.Infrastructure.Persistence;
|
using NexusReader.Infrastructure.Persistence;
|
||||||
|
|
||||||
namespace NexusReader.Infrastructure.Services;
|
namespace NexusReader.Infrastructure.Services;
|
||||||
@@ -10,44 +13,90 @@ public class BillingService : IBillingService
|
|||||||
{
|
{
|
||||||
private readonly AppDbContext _dbContext;
|
private readonly AppDbContext _dbContext;
|
||||||
private readonly UserManager<NexusUser> _userManager;
|
private readonly UserManager<NexusUser> _userManager;
|
||||||
|
private readonly StripeSettings _stripeSettings;
|
||||||
|
private readonly ILogger<BillingService> _logger;
|
||||||
|
|
||||||
public BillingService(AppDbContext dbContext, UserManager<NexusUser> userManager)
|
public BillingService(
|
||||||
|
AppDbContext dbContext,
|
||||||
|
UserManager<NexusUser> userManager,
|
||||||
|
IOptions<StripeSettings> stripeSettings,
|
||||||
|
ILogger<BillingService> logger)
|
||||||
{
|
{
|
||||||
_dbContext = dbContext;
|
_dbContext = dbContext;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
|
_stripeSettings = stripeSettings.Value;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId)
|
public async Task<bool> HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId)
|
||||||
{
|
{
|
||||||
var user = await _userManager.FindByEmailAsync(customerEmail);
|
var user = await _userManager.FindByEmailAsync(customerEmail);
|
||||||
if (user == null) return false;
|
if (user == null)
|
||||||
|
|
||||||
// Map Stripe Product IDs to Nexus Plans
|
|
||||||
// These IDs would typically come from configuration
|
|
||||||
if (stripeProductId.Contains("pro"))
|
|
||||||
{
|
{
|
||||||
user.CurrentPlan = "Pro";
|
_logger.LogWarning("Attempted to update subscription for non-existent user: {Email}", customerEmail);
|
||||||
user.AITokenLimit = 50000;
|
return false;
|
||||||
}
|
}
|
||||||
else if (stripeProductId.Contains("basic"))
|
|
||||||
{
|
string targetPlanName = SubscriptionPlan.FreeName;
|
||||||
user.CurrentPlan = "Basic";
|
int tokenLimit = 1000;
|
||||||
user.AITokenLimit = 10000;
|
|
||||||
|
if (stripeProductId == _stripeSettings.ProProductId)
|
||||||
|
{
|
||||||
|
targetPlanName = SubscriptionPlan.ProName;
|
||||||
|
tokenLimit = 50000;
|
||||||
|
}
|
||||||
|
else if (stripeProductId == _stripeSettings.BasicProductId)
|
||||||
|
{
|
||||||
|
targetPlanName = SubscriptionPlan.BasicName;
|
||||||
|
tokenLimit = 10000;
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(stripeProductId) && stripeProductId != _stripeSettings.FreeProductId)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Unrecognized Stripe Product ID: {ProductId} for user {Email}. Falling back to Free tier.", stripeProductId, customerEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
var plan = await _dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == targetPlanName);
|
||||||
|
if (plan != null)
|
||||||
|
{
|
||||||
|
user.SubscriptionPlanId = plan.Id;
|
||||||
|
user.AITokenLimit = tokenLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _userManager.UpdateAsync(user);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
_logger.LogError("Failed to update user {Email} after subscription change: {Errors}",
|
||||||
|
customerEmail, string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _userManager.UpdateAsync(user);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> HandleSubscriptionDeletedAsync(string customerEmail)
|
public async Task<bool> HandleSubscriptionDeletedAsync(string customerEmail)
|
||||||
{
|
{
|
||||||
var user = await _userManager.FindByEmailAsync(customerEmail);
|
var user = await _userManager.FindByEmailAsync(customerEmail);
|
||||||
if (user == null) return false;
|
if (user == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Attempted to delete subscription for non-existent user: {Email}", customerEmail);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
user.CurrentPlan = "Free";
|
var freePlan = await _dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == SubscriptionPlan.FreeName);
|
||||||
user.AITokenLimit = 1000; // Reset to free limit
|
if (freePlan != null)
|
||||||
|
{
|
||||||
|
user.SubscriptionPlanId = freePlan.Id;
|
||||||
|
user.AITokenLimit = freePlan.AITokenLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _userManager.UpdateAsync(user);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
_logger.LogError("Failed to reset user {Email} to Free tier after subscription deletion: {Errors}",
|
||||||
|
customerEmail, string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
await _userManager.UpdateAsync(user);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,20 @@ public class EpubService : IEpubService
|
|||||||
return Result.Fail($"EPUB file not found. Checked {searchPaths.Count} locations, including: {string.Join(", ", searchPaths.Take(3))}");
|
return Result.Fail($"EPUB file not found. Checked {searchPaths.Count} locations, including: {string.Join(", ", searchPaths.Take(3))}");
|
||||||
}
|
}
|
||||||
|
|
||||||
EpubBook book = await EpubReader.ReadBookAsync(fullPath);
|
if (!File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
return Result.Fail($"EPUB file at '{fullPath}' is not accessible or does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
EpubBook book;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
book = await EpubReader.ReadBookAsync(fullPath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error($"Failed to parse EPUB file. It might be corrupted or in use. Path: {fullPath}").CausedBy(ex));
|
||||||
|
}
|
||||||
var blocks = new List<ContentBlock>();
|
var blocks = new List<ContentBlock>();
|
||||||
int totalWordCount = 0;
|
int totalWordCount = 0;
|
||||||
int blockCounter = 0;
|
int blockCounter = 0;
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
using FluentResults;
|
|
||||||
using NexusReader.Application.Abstractions.Services;
|
|
||||||
using NexusReader.Application.Queries.Quiz;
|
|
||||||
|
|
||||||
namespace NexusReader.Infrastructure.Services;
|
|
||||||
|
|
||||||
public sealed class FakeAiGenerateQuizService : IAiGenerateQuizService
|
|
||||||
{
|
|
||||||
public async Task<Result<QuizDto>> GenerateQuizAsync(string contextBlockId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// 2000ms delay to highlight Skeleton loader visually
|
|
||||||
await Task.Delay(2000, cancellationToken);
|
|
||||||
|
|
||||||
var fakeQuiz = new List<QuizQuestionDto>
|
|
||||||
{
|
|
||||||
new("Co było głównym centrum włoskiego Renesansu?", new List<string> { "Wenecja", "Rzym", "Florencja", "Mediolan" }, 2),
|
|
||||||
new("Kto stanowił wpływowy ród mecenasów sztuki?", new List<string> { "Habsburgowie", "Medyceusze", "Borgiowie", "Sforzowie" }, 1),
|
|
||||||
new("Jaką koncepcją filozoficzną charakteryzował się renesans?", new List<string> { "Teocentryzmem", "Nihilizmem", "Humanizmem", "Egzystencjalizmem" }, 2)
|
|
||||||
};
|
|
||||||
|
|
||||||
return Result.Ok(new QuizDto(fakeQuiz));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using FluentResults;
|
using FluentResults;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
|
using Microsoft.ML.Tokenizers;
|
||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
using NexusReader.Application.DTOs.AI;
|
using NexusReader.Application.DTOs.AI;
|
||||||
using NexusReader.Domain.Entities;
|
using NexusReader.Domain.Entities;
|
||||||
@@ -11,77 +12,82 @@ using Polly;
|
|||||||
using Polly.Registry;
|
using Polly.Registry;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using NexusReader.Infrastructure.Configuration;
|
using NexusReader.Infrastructure.Configuration;
|
||||||
|
using Pgvector;
|
||||||
|
using Pgvector.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace NexusReader.Infrastructure.Services;
|
namespace NexusReader.Infrastructure.Services;
|
||||||
|
|
||||||
public class KnowledgeService : IKnowledgeService
|
public class KnowledgeService : IKnowledgeService
|
||||||
{
|
{
|
||||||
private readonly IChatClient _chatClient;
|
private readonly IChatClient _chatClient;
|
||||||
private readonly AppDbContext _dbContext;
|
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||||
private readonly ResiliencePipeline _retryPipeline;
|
private readonly ResiliencePipeline _retryPipeline;
|
||||||
private readonly AiSettings _settings;
|
private readonly AiSettings _settings;
|
||||||
|
private readonly Tokenizer _tokenizer;
|
||||||
private const string PromptVersion = "1.0";
|
private const string PromptVersion = "1.0";
|
||||||
|
|
||||||
public KnowledgeService(
|
public KnowledgeService(
|
||||||
IChatClient chatClient,
|
IChatClient chatClient,
|
||||||
AppDbContext dbContext,
|
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator,
|
||||||
|
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||||
ResiliencePipelineProvider<string> pipelineProvider,
|
ResiliencePipelineProvider<string> pipelineProvider,
|
||||||
IOptions<AiSettings> settings)
|
IOptions<AiSettings> settings)
|
||||||
{
|
{
|
||||||
_chatClient = chatClient;
|
_chatClient = chatClient;
|
||||||
_dbContext = dbContext;
|
_embeddingGenerator = embeddingGenerator;
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
|
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
|
// Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides
|
||||||
|
// a very reliable estimation for token usage in Gemini-based workloads.
|
||||||
|
_tokenizer = TiktokenTokenizer.CreateForModel("gpt-4");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default)
|
public async Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, string tenantId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await GetKnowledgeInternalAsync(text, PromptRegistry.KnowledgeExtractionSystemPrompt, "full", cancellationToken);
|
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.KnowledgeExtractionSystemPrompt, "full", cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, CancellationToken cancellationToken = default)
|
public async Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, string tenantId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await GetKnowledgeInternalAsync(text, PromptRegistry.GraphExtractionPrompt, "graph", cancellationToken);
|
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.GraphExtractionPrompt, "graph", cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, CancellationToken cancellationToken = default)
|
public async Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, string tenantId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await GetKnowledgeInternalAsync(text, PromptRegistry.SummaryAndQuizPrompt, "summary_quiz", cancellationToken);
|
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.SummaryAndQuizPrompt, "summary_quiz", cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Result<KnowledgePacket>> GetKnowledgeInternalAsync(string text, string systemPrompt, string cacheSuffix, CancellationToken cancellationToken)
|
public async Task<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, string tenantId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.KM_ExtractionPrompt, "km_map", cancellationToken);
|
||||||
{
|
|
||||||
return Result.Fail("Input text is empty.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($"[KnowledgeService] Starting extraction ({cacheSuffix}) for text sample: {text.Substring(0, Math.Min(text.Length, 50))}...");
|
private async Task<Result<KnowledgePacket>> GetKnowledgeInternalAsync(string text, string tenantId, string systemPrompt, string traceType, CancellationToken cancellationToken)
|
||||||
|
|
||||||
var normalizedText = ContentHasher.Normalize(text);
|
|
||||||
if (normalizedText.Length > _settings.MaxInputLength)
|
|
||||||
{
|
{
|
||||||
normalizedText = normalizedText.Substring(0, _settings.MaxInputLength);
|
if (string.IsNullOrWhiteSpace(text)) return Result.Fail("Input text is empty.");
|
||||||
Console.WriteLine($"[KnowledgeService] WARNING: Input text truncated to {_settings.MaxInputLength} chars.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var hash = ContentHasher.ComputeHash(normalizedText) + "_" + cacheSuffix;
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
var normalizedText = text.Trim();
|
||||||
|
var hash = ContentHasher.ComputeHash(normalizedText);
|
||||||
|
|
||||||
// 1. Check Cache
|
// 1. Check Cache
|
||||||
var cached = await _dbContext.SemanticKnowledgeCache
|
var cached = await dbContext.SemanticKnowledgeCache
|
||||||
.FirstOrDefaultAsync(c => c.ContentHash == hash && c.PromptVersion == PromptVersion, cancellationToken);
|
.FirstOrDefaultAsync(c => c.ContentHash == hash && c.TenantId == tenantId, cancellationToken);
|
||||||
|
|
||||||
if (cached != null)
|
if (cached != null && cached.PromptVersion == PromptVersion)
|
||||||
{
|
{
|
||||||
|
Console.WriteLine($"[KnowledgeService] Cache Hit for {traceType} ({hash})");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var packet = JsonSerializer.Deserialize<KnowledgePacket>(cached.JsonData, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
var packet = JsonSerializer.Deserialize<KnowledgePacket>(cached.JsonData, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
if (packet != null) return Result.Ok(packet);
|
if (packet != null) return Result.Ok(packet);
|
||||||
}
|
}
|
||||||
catch { }
|
catch { /* fallback to regen */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Call AI Client
|
Console.WriteLine($"[KnowledgeService] Cache Miss for {traceType} ({hash}). Requesting AI...");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var options = new ChatOptions
|
var options = new ChatOptions
|
||||||
@@ -109,20 +115,46 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
var knowledgePacket = JsonSerializer.Deserialize<KnowledgePacket>(jsonResponse, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
var knowledgePacket = JsonSerializer.Deserialize<KnowledgePacket>(jsonResponse, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
if (knowledgePacket == null) return Result.Fail("Failed to deserialize AI response.");
|
if (knowledgePacket == null) return Result.Fail("Failed to deserialize AI response.");
|
||||||
|
|
||||||
// 3. Save to Cache
|
// 3. Generate Embedding if not present
|
||||||
|
float[]? vector = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
|
||||||
|
await _embeddingGenerator.GenerateAsync(new[] { normalizedText }, cancellationToken: ct), cancellationToken);
|
||||||
|
vector = embeddingResponse.First().Vector.ToArray();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[KnowledgeService] Embedding Error: {ex.Message}");
|
||||||
|
// We continue even if embedding fails, as the primary goal was knowledge extraction
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Save to Cache
|
||||||
var cacheEntry = new SemanticKnowledgeCache
|
var cacheEntry = new SemanticKnowledgeCache
|
||||||
{
|
{
|
||||||
ContentHash = hash,
|
ContentHash = hash,
|
||||||
JsonData = jsonResponse,
|
JsonData = jsonResponse,
|
||||||
|
OriginalText = normalizedText,
|
||||||
ModelId = _settings.Model,
|
ModelId = _settings.Model,
|
||||||
PromptVersion = PromptVersion,
|
PromptVersion = PromptVersion,
|
||||||
|
TenantId = tenantId,
|
||||||
|
Vector = vector != null ? new Vector(vector) : null,
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
if (cached == null) _dbContext.SemanticKnowledgeCache.Add(cacheEntry);
|
if (cached == null) dbContext.SemanticKnowledgeCache.Add(cacheEntry);
|
||||||
else { cached.JsonData = jsonResponse; cached.CreatedAt = DateTime.UtcNow; }
|
else
|
||||||
|
{
|
||||||
|
cached.JsonData = jsonResponse;
|
||||||
|
cached.OriginalText = normalizedText;
|
||||||
|
cached.Vector = vector != null ? new Vector(vector) : null;
|
||||||
|
cached.CreatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
// 5. Process structured KnowledgeUnits (Graph Expansion)
|
||||||
|
await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, dbContext, cancellationToken);
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
return Result.Ok(knowledgePacket);
|
return Result.Ok(knowledgePacket);
|
||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch (JsonException ex)
|
||||||
@@ -137,18 +169,154 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default)
|
private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, AppDbContext dbContext, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var unitIds = packet.Units.Select(u => u.Id).ToList();
|
||||||
|
var linkSourceIds = packet.Links.Select(l => l.Source).ToList();
|
||||||
|
var linkTargetIds = packet.Links.Select(l => l.Target).ToList();
|
||||||
|
|
||||||
|
var allCandidateIds = unitIds.Concat(linkSourceIds).Concat(linkTargetIds).Distinct().ToList();
|
||||||
|
|
||||||
|
// Single batch query to find existing units
|
||||||
|
var existingUnits = await dbContext.KnowledgeUnits
|
||||||
|
.Where(u => allCandidateIds.Contains(u.Id))
|
||||||
|
.ToDictionaryAsync(u => u.Id, cancellationToken);
|
||||||
|
|
||||||
|
var processedUnitIds = new HashSet<string>();
|
||||||
|
|
||||||
|
foreach (var unitDto in packet.Units)
|
||||||
|
{
|
||||||
|
var unitId = unitDto.Id;
|
||||||
|
existingUnits.TryGetValue(unitId, out var unit);
|
||||||
|
|
||||||
|
if (unit == null)
|
||||||
|
{
|
||||||
|
unit = new KnowledgeUnit { Id = unitId, TenantId = tenantId };
|
||||||
|
dbContext.KnowledgeUnits.Add(unit);
|
||||||
|
existingUnits[unitId] = unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
unit.Type = Enum.TryParse<NexusReader.Domain.Enums.KnowledgeUnitType>(unitDto.Type, true, out var type) ? type : NexusReader.Domain.Enums.KnowledgeUnitType.Snippet;
|
||||||
|
unit.Content = unitDto.Content;
|
||||||
|
unit.SourceId = "extracted";
|
||||||
|
unit.MetadataJson = JsonSerializer.Serialize(unitDto.Metadata);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Console.WriteLine("[KnowledgeService] Clearing SemanticKnowledgeCache...");
|
var emb = await _retryPipeline.ExecuteAsync(async ct =>
|
||||||
_dbContext.SemanticKnowledgeCache.RemoveRange(_dbContext.SemanticKnowledgeCache);
|
await _embeddingGenerator.GenerateAsync(new[] { unit.Content }, cancellationToken: ct), cancellationToken);
|
||||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
unit.Vector = new Vector(emb.First().Vector.ToArray());
|
||||||
|
}
|
||||||
|
catch { /* Ignore embedding errors for now */ }
|
||||||
|
|
||||||
|
processedUnitIds.Add(unit.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var linkDto in packet.Links)
|
||||||
|
{
|
||||||
|
var sourceExists = processedUnitIds.Contains(linkDto.Source) || existingUnits.ContainsKey(linkDto.Source);
|
||||||
|
var targetExists = processedUnitIds.Contains(linkDto.Target) || existingUnits.ContainsKey(linkDto.Target);
|
||||||
|
|
||||||
|
if (sourceExists && targetExists)
|
||||||
|
{
|
||||||
|
// Check if link already exists to avoid duplicates if necessary
|
||||||
|
// For now, assume we can add them or they are new in this session
|
||||||
|
var link = new KnowledgeUnitLink
|
||||||
|
{
|
||||||
|
SourceUnitId = linkDto.Source,
|
||||||
|
TargetUnitId = linkDto.Target,
|
||||||
|
RelationType = linkDto.Relation
|
||||||
|
};
|
||||||
|
dbContext.KnowledgeUnitLinks.Add(link);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[KnowledgeService] WARNING: Skipping invalid link {linkDto.Source} -> {linkDto.Target} (Missing units).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var systemPrompt = @"
|
||||||
|
You are a Fact-Checking AI. Evaluate if the 'Answer' is supported by the 'Context'.
|
||||||
|
Rate the groundedness from 0.0 to 1.0.
|
||||||
|
Return ONLY a JSON object: { ""score"": 0.9, ""rationale"": ""string"", ""isGrounded"": true }
|
||||||
|
";
|
||||||
|
|
||||||
|
var userPrompt = $"Context: {context}\n\nAnswer: {answer}";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var options = new ChatOptions
|
||||||
|
{
|
||||||
|
Temperature = 0.0f, // Low temperature for factual checks
|
||||||
|
MaxOutputTokens = 500
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await _retryPipeline.ExecuteAsync(async ct =>
|
||||||
|
await _chatClient.GetResponseAsync(new List<ChatMessage>
|
||||||
|
{
|
||||||
|
new ChatMessage(ChatRole.System, systemPrompt),
|
||||||
|
new ChatMessage(ChatRole.User, userPrompt)
|
||||||
|
}, options, cancellationToken: ct), cancellationToken);
|
||||||
|
|
||||||
|
var rawJson = response.Text?.Trim() ?? "{}";
|
||||||
|
rawJson = rawJson.Replace("```json", "").Replace("```", "").Trim();
|
||||||
|
|
||||||
|
var result = JsonSerializer.Deserialize<GroundednessResult>(rawJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
|
||||||
|
return result != null ? Result.Ok(result) : Result.Fail("Failed to parse groundedness result");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error("Failed to verify groundedness").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<List<RelevantContext>>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var queryEmbedding = await _retryPipeline.ExecuteAsync(async ct =>
|
||||||
|
await _embeddingGenerator.GenerateAsync(new[] { query }, cancellationToken: ct), cancellationToken);
|
||||||
|
var queryVector = new Vector(queryEmbedding.First().Vector.ToArray());
|
||||||
|
|
||||||
|
var relevantUnits = await dbContext.KnowledgeUnits
|
||||||
|
.Where(u => u.TenantId == tenantId)
|
||||||
|
.OrderBy(u => u.Vector!.L2Distance(queryVector))
|
||||||
|
.Take(5)
|
||||||
|
.Select(u => new RelevantContext { Text = u.Content, Confidence = 1.0 })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(relevantUnits);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error("Failed to retrieve relevant context").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await dbContext.SemanticKnowledgeCache.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
await dbContext.KnowledgeUnits.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
await dbContext.KnowledgeUnitLinks.ExecuteDeleteAsync(cancellationToken);
|
||||||
return Result.Ok();
|
return Result.Ok();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Result.Fail($"Failed to clear cache: {ex.Message}");
|
return Result.Fail(new Error("Failed to clear knowledge cache").CausedBy(ex));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int EstimateTokenCount(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return 0;
|
||||||
|
return _tokenizer.CountTokens(text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,4 +21,13 @@ public static class PromptRegistry
|
|||||||
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). " +
|
||||||
"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 =
|
||||||
|
"You are an expert at Knowledge Engineering. Segment the provided text into discrete Knowledge Units. " +
|
||||||
|
"Identify 'units' (sections, tables, definitions, rules) and 'links' (how they relate). " +
|
||||||
|
"CRITICAL: Units must be granular. " +
|
||||||
|
"Schema: { " +
|
||||||
|
"\"units\": [ { \"id\": \"string\", \"type\": \"Section|Table|Definition|Rule\", \"content\": \"string\", \"metadata\": { \"page\": 0 } } ], " +
|
||||||
|
"\"links\": [ { \"source\": \"string\", \"target\": \"string\", \"relation\": \"Next|Defines|Contains|References\" } ] " +
|
||||||
|
"}.";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
|
|
||||||
<Router AppAssembly="@typeof(NexusReader.UI.Shared._Imports).Assembly">
|
<Router AppAssembly="@typeof(NexusReader.UI.Shared._Imports).Assembly">
|
||||||
<Found Context="routeData">
|
<Found Context="routeData">
|
||||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(NexusReader.UI.Shared.Layout.MainLayout)" />
|
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(NexusReader.UI.Shared.Layout.MainLayout)">
|
||||||
|
<NotAuthorized>
|
||||||
|
<RedirectToLogin />
|
||||||
|
</NotAuthorized>
|
||||||
|
</AuthorizeRouteView>
|
||||||
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
||||||
</Found>
|
</Found>
|
||||||
<NotFound>
|
<NotFound>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ using Microsoft.Extensions.Logging;
|
|||||||
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 MediatR;
|
||||||
|
|
||||||
namespace NexusReader.Maui;
|
namespace NexusReader.Maui;
|
||||||
|
|
||||||
@@ -39,6 +41,18 @@ public static class MauiProgram
|
|||||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
builder.Services.AddScoped<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<IKnowledgeGraphService, KnowledgeGraphService>();
|
||||||
|
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
||||||
|
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||||
|
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||||
|
builder.Services.AddScoped<IIdentityService, IdentityService>();
|
||||||
|
|
||||||
|
builder.Services.AddApplication();
|
||||||
|
|
||||||
|
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
|
||||||
|
NexusReader.Application.DependencyInjection.Assembly
|
||||||
|
));
|
||||||
|
|
||||||
return builder.Build();
|
return builder.Build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,12 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div id="app">Loading...</div>
|
<div id="app">
|
||||||
|
<div id="app-preloader">
|
||||||
|
<div class="preloader-spinner"></div>
|
||||||
|
<div class="preloader-text">Nexus Reader</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="blazor-error-ui">
|
<div id="blazor-error-ui">
|
||||||
An unhandled error has occurred.
|
An unhandled error has occurred.
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
@namespace NexusReader.UI.Shared.Components.Atoms
|
||||||
|
|
||||||
|
<div class="nexus-search-container @(IsActive ? "active" : "")">
|
||||||
|
<div class="search-wrapper">
|
||||||
|
<i class="nexus-icon @IconClass"></i>
|
||||||
|
<input type="text"
|
||||||
|
@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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string Placeholder { get; set; } = "Search your library...";
|
||||||
|
[Parameter] public string IconClass { get; set; } = "bi bi-search";
|
||||||
|
[Parameter] public EventCallback<string> OnSearch { get; set; }
|
||||||
|
|
||||||
|
private string SearchValue { get; set; } = string.Empty;
|
||||||
|
private bool IsActive => !string.IsNullOrEmpty(SearchValue);
|
||||||
|
|
||||||
|
private async Task HandleKeyPress(KeyboardEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == "Enter")
|
||||||
|
{
|
||||||
|
await OnSearch.InvokeAsync(SearchValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearSearch()
|
||||||
|
{
|
||||||
|
SearchValue = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
.nexus-search-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 1rem auto;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--nexus-card, #141414);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nexus-search-container.active .search-wrapper,
|
||||||
|
.search-wrapper:focus-within {
|
||||||
|
border-color: var(--nexus-neon, #00ff99);
|
||||||
|
box-shadow: 0 0 15px rgba(0, 255, 153, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nexus-icon {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nexus-search-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nexus-search-input::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn:hover {
|
||||||
|
color: var(--nexus-neon, #00ff99);
|
||||||
|
}
|
||||||
@@ -1,36 +1,126 @@
|
|||||||
@using NexusReader.UI.Shared.Services
|
@using NexusReader.UI.Shared.Services
|
||||||
|
@using NexusReader.Application.DTOs.AI
|
||||||
@inject IQuizStateService QuizState
|
@inject IQuizStateService QuizState
|
||||||
|
@inject KnowledgeCoordinator Coordinator
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
<div class="ai-bubble-container">
|
<div class="ai-bubble-container">
|
||||||
<div class="ai-bubble">
|
<div class="ai-bubble">
|
||||||
<div class="ai-avatar">
|
<div class="ai-avatar">
|
||||||
<div class="avatar-ring"></div>
|
<div class="avatar-ring"></div>
|
||||||
<NexusIcon Name="robot" Size="48" Class="neon-pulse" />
|
<NexusIcon Name="robot" Size="48" Class="@(_isStreaming ? "neon-pulse" : "neon-glow")" />
|
||||||
<div class="avatar-label">
|
<div class="avatar-label">
|
||||||
<span class="name">E-Czytnik</span>
|
<span class="name">E-Czytnik</span>
|
||||||
<span class="role">Asystent AI</span>
|
<span class="role">Asystent AI</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-content">
|
<div class="ai-content">
|
||||||
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">@Dialogue</NexusTypography>
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="shimmer">Analizuję fragment...</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">
|
||||||
|
@_displayedText@(_isStreaming ? "▍" : "")
|
||||||
|
</NexusTypography>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="ai-actions">
|
<div class="ai-actions">
|
||||||
<button class="action-btn ghost" @onclick='() => HandleActionClick("more")'>Pokaż więcej informacji</button>
|
<button class="action-btn ghost" @onclick='() => HandleActionClick("more")'>Pokaż więcej informacji</button>
|
||||||
<button class="action-btn neon-border" @onclick='() => HandleActionClick("quiz")'>Rozwiąż quiz</button>
|
<button class="action-btn neon-border" @onclick='() => HandleActionClick("quiz")' disabled="@(_isLoading)">Rozwiąż quiz</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="bubble-pointer"></div>
|
<div class="bubble-pointer"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public string ContextBlockId { get; set; } = string.Empty;
|
[Parameter] public string ContextBlockId { get; set; } = string.Empty;
|
||||||
|
/// <summary>Fallback static dialogue shown when no live AI content is available.</summary>
|
||||||
[Parameter] public string Dialogue { get; set; } = string.Empty;
|
[Parameter] public string Dialogue { get; set; } = string.Empty;
|
||||||
[Parameter] public List<string> Actions { get; set; } = new();
|
[Parameter] public List<string> Actions { get; set; } = new();
|
||||||
[Parameter] public EventCallback<string> OnActionTriggered { get; set; }
|
[Parameter] public EventCallback<string> OnActionTriggered { get; set; }
|
||||||
|
|
||||||
|
private string _displayedText = string.Empty;
|
||||||
|
private bool _isLoading = false;
|
||||||
|
private bool _isStreaming = false;
|
||||||
|
private string _lastFetchedBlockId = string.Empty;
|
||||||
|
private KnowledgePacket? _packet;
|
||||||
|
private CancellationTokenSource? _streamCts;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
// Only re-fetch when the block context actually changes
|
||||||
|
if (string.IsNullOrEmpty(ContextBlockId) || ContextBlockId == _lastFetchedBlockId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_lastFetchedBlockId = ContextBlockId;
|
||||||
|
await FetchAndStreamAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FetchAndStreamAsync()
|
||||||
|
{
|
||||||
|
// Cancel any in-progress stream
|
||||||
|
_streamCts?.Cancel();
|
||||||
|
_streamCts = new CancellationTokenSource();
|
||||||
|
var token = _streamCts.Token;
|
||||||
|
|
||||||
|
_isLoading = true;
|
||||||
|
_isStreaming = false;
|
||||||
|
_displayedText = string.Empty;
|
||||||
|
_packet = null;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_packet = await Coordinator.RequestSummaryAndQuizAsync(
|
||||||
|
$"[ID: {ContextBlockId}]\n{Dialogue}");
|
||||||
|
|
||||||
|
var summary = _packet?.Summary;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(summary))
|
||||||
|
{
|
||||||
|
// Fall back to the static Dialogue parameter
|
||||||
|
_displayedText = string.IsNullOrEmpty(Dialogue)
|
||||||
|
? "Brak danych do analizy."
|
||||||
|
: Dialogue;
|
||||||
|
_isLoading = false;
|
||||||
|
StateHasChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
_isStreaming = true;
|
||||||
|
|
||||||
|
// Word-by-word reveal (streaming simulation)
|
||||||
|
var words = summary.Split(' ');
|
||||||
|
foreach (var word in words)
|
||||||
|
{
|
||||||
|
if (token.IsCancellationRequested) break;
|
||||||
|
_displayedText += (string.IsNullOrEmpty(_displayedText) ? "" : " ") + word;
|
||||||
|
StateHasChanged();
|
||||||
|
await Task.Delay(40, token); // ~25 words/sec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Superseded by a newer block — silently drop
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_displayedText = string.IsNullOrEmpty(Dialogue) ? "Błąd analizy." : Dialogue;
|
||||||
|
Console.WriteLine($"[AiAssistantBubble] Error fetching summary: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isStreaming = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task HandleActionClick(string action)
|
private async Task HandleActionClick(string action)
|
||||||
{
|
{
|
||||||
if (action.Contains("quiz", StringComparison.OrdinalIgnoreCase))
|
if (action.Contains("quiz", StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -43,4 +133,10 @@
|
|||||||
await OnActionTriggered.InvokeAsync(action);
|
await OnActionTriggered.InvokeAsync(action);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_streamCts?.Cancel();
|
||||||
|
_streamCts?.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
@using MediatR
|
||||||
|
@using NexusReader.Application.Commands.AI
|
||||||
|
@using NexusReader.Application.Abstractions.Services
|
||||||
|
@using NexusReader.UI.Shared.Components.Atoms
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@inject IMediator Mediator
|
||||||
|
@inject AuthenticationStateProvider AuthProvider
|
||||||
|
|
||||||
|
<div class="groundedness-badge @GetStatusClass()" title="@_result?.Rationale">
|
||||||
|
@if (_isChecking)
|
||||||
|
{
|
||||||
|
<span class="shimmer">Weryfikacja...</span>
|
||||||
|
}
|
||||||
|
else if (_result != null)
|
||||||
|
{
|
||||||
|
<NexusIcon Name="@(GetIcon())" Size="14" />
|
||||||
|
<span>@((_result.Score * 100).ToString("0"))% Grounded</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.groundedness-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groundedness-badge.status-high {
|
||||||
|
color: var(--nexus-neon);
|
||||||
|
border-color: var(--nexus-neon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groundedness-badge.status-medium {
|
||||||
|
color: #ffaa00;
|
||||||
|
border-color: #ffaa00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groundedness-badge.status-low {
|
||||||
|
color: #ff4444;
|
||||||
|
border-color: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string Answer { get; set; } = string.Empty;
|
||||||
|
[Parameter] public string Context { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
private GroundednessResult? _result;
|
||||||
|
private bool _isChecking;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(Answer) && !string.IsNullOrEmpty(Context) && _result == null)
|
||||||
|
{
|
||||||
|
await RunCheck();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunCheck()
|
||||||
|
{
|
||||||
|
_isChecking = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
var authState = await AuthProvider.GetAuthenticationStateAsync();
|
||||||
|
var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global";
|
||||||
|
|
||||||
|
var res = await Mediator.Send(new VerifyGroundednessCommand(Answer, Context, tenantId));
|
||||||
|
if (res.IsSuccess)
|
||||||
|
{
|
||||||
|
_result = res.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isChecking = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetStatusClass()
|
||||||
|
{
|
||||||
|
if (_result == null) return "";
|
||||||
|
if (_result.Score >= 0.8) return "status-high";
|
||||||
|
if (_result.Score >= 0.5) return "status-medium";
|
||||||
|
return "status-low";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetIcon()
|
||||||
|
{
|
||||||
|
if (_result == null) return "help";
|
||||||
|
if (_result.Score >= 0.8) return "check-circle";
|
||||||
|
if (_result.Score >= 0.5) return "info-circle";
|
||||||
|
return "alert-triangle";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
@using NexusReader.Application.DTOs.AI
|
||||||
|
@using NexusReader.UI.Shared.Components.Atoms
|
||||||
|
@namespace NexusReader.UI.Shared.Components.Organisms
|
||||||
|
|
||||||
|
<div class="global-intelligence-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3><i class="bi bi-cpu"></i> Global Intelligence</h3>
|
||||||
|
<p>Semantic search across your library</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NexusSearchBox Placeholder="Ask a question about your books..." OnSearch="HandleSearch" />
|
||||||
|
|
||||||
|
<div class="results-container">
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="nexus-spinner"></div>
|
||||||
|
<span>Analyzing your library...</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (Results != null && Results.Any())
|
||||||
|
{
|
||||||
|
@foreach (var result in Results)
|
||||||
|
{
|
||||||
|
<div class="search-result-item">
|
||||||
|
<div class="result-meta">
|
||||||
|
<span class="relevance">@(Math.Round(result.RelevanceScore * 100))% Relevant</span>
|
||||||
|
@if (!string.IsNullOrEmpty(result.SourceBookTitle))
|
||||||
|
{
|
||||||
|
<span class="source">in <strong>@result.SourceBookTitle</strong></span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="result-snippet">
|
||||||
|
@result.Snippet
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (HasSearched)
|
||||||
|
{
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
<p>No semantic matches found.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public List<SemanticSearchResultDto>? Results { get; set; }
|
||||||
|
[Parameter] public bool IsLoading { get; set; }
|
||||||
|
[Parameter] public EventCallback<string> OnPerformSearch { get; set; }
|
||||||
|
|
||||||
|
private bool HasSearched { get; set; }
|
||||||
|
|
||||||
|
private async Task HandleSearch(string query)
|
||||||
|
{
|
||||||
|
HasSearched = true;
|
||||||
|
await OnPerformSearch.InvokeAsync(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
.global-intelligence-panel {
|
||||||
|
background: var(--nexus-bg, #0a0a0a);
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--nexus-neon, #00ff99);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header p {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0.25rem 0 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item {
|
||||||
|
background: var(--nexus-card, #141414);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item:hover {
|
||||||
|
transform: translateX(4px);
|
||||||
|
border-color: rgba(0, 255, 153, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relevance {
|
||||||
|
color: var(--nexus-neon, #00ff99);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-snippet {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state, .empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 200px;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nexus-spinner {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 2px solid rgba(0, 255, 153, 0.1);
|
||||||
|
border-top-color: var(--nexus-neon, #00ff99);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
@inject IReaderNavigationService NavigationService
|
@inject IReaderNavigationService NavigationService
|
||||||
@inject KnowledgeCoordinator Coordinator
|
@inject KnowledgeCoordinator Coordinator
|
||||||
@inject IReaderInteractionService InteractionService
|
@inject IReaderInteractionService InteractionService
|
||||||
|
@inject ISyncService SyncService
|
||||||
|
|
||||||
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
|
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
|
||||||
@if (ViewModel == null)
|
@if (ViewModel == null)
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
|
Coordinator.Clear();
|
||||||
ThemeService.OnThemeChanged += StateHasChanged;
|
ThemeService.OnThemeChanged += StateHasChanged;
|
||||||
NavigationService.OnNavigationChanged += OnNavigationChanged;
|
NavigationService.OnNavigationChanged += OnNavigationChanged;
|
||||||
|
|
||||||
@@ -77,6 +79,11 @@
|
|||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
|
if (firstRender)
|
||||||
|
{
|
||||||
|
await SyncService.InitializeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
if (ViewModel != null && !_isJsInitialized)
|
if (ViewModel != null && !_isJsInitialized)
|
||||||
{
|
{
|
||||||
_isJsInitialized = true;
|
_isJsInitialized = true;
|
||||||
@@ -109,6 +116,23 @@
|
|||||||
public void HandleBlockReached(string blockId, string content)
|
public void HandleBlockReached(string blockId, string content)
|
||||||
{
|
{
|
||||||
Coordinator.OnBlockReached(blockId, content);
|
Coordinator.OnBlockReached(blockId, content);
|
||||||
|
|
||||||
|
// Debounce sync update (simple version: every 5 seconds or on a timer)
|
||||||
|
_ = SyncService.UpdateProgressAsync(blockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleSyncProgressReceived(string blockId, DateTime timestamp)
|
||||||
|
{
|
||||||
|
// For now, let's just scroll to the node if it's in the current view,
|
||||||
|
// or just log it. Usually, we should prompt the user.
|
||||||
|
Console.WriteLine($"[Sync] Received progress from another device: {blockId} at {timestamp}");
|
||||||
|
|
||||||
|
// Simple auto-scroll if it's newer than what we have (we don't track our own timestamp yet,
|
||||||
|
// but we can assume incoming syncs are from other active devices)
|
||||||
|
_ = InvokeAsync(async () => {
|
||||||
|
await ScrollToNodeAsync(blockId);
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[JSInvokable]
|
[JSInvokable]
|
||||||
@@ -196,5 +220,6 @@
|
|||||||
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
|
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
|
||||||
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
|
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
|
||||||
InteractionService.OnTextSelected -= HandleTextSelected;
|
InteractionService.OnTextSelected -= HandleTextSelected;
|
||||||
|
SyncService.OnProgressReceived -= HandleSyncProgressReceived;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,18 @@
|
|||||||
@using NexusReader.UI.Shared.Services
|
@using NexusReader.UI.Shared.Services
|
||||||
@using NexusReader.UI.Shared.Components.Molecules
|
@using NexusReader.UI.Shared.Components.Molecules
|
||||||
@using NexusReader.UI.Shared.Components.Organisms
|
@using NexusReader.UI.Shared.Components.Organisms
|
||||||
|
@using Microsoft.Extensions.Logging
|
||||||
@inject IPlatformService PlatformService
|
@inject IPlatformService PlatformService
|
||||||
@inject IFocusModeService FocusMode
|
@inject IFocusModeService FocusMode
|
||||||
@inject IQuizStateService QuizService
|
@inject IQuizStateService QuizService
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
@inject IIdentityService IdentityService
|
@inject IIdentityService IdentityService
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
|
@inject Microsoft.Extensions.Logging.ILogger<MainLayout> Logger
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
|
<AuthorizeView>
|
||||||
|
<Authorized>
|
||||||
<div class="app-container @_platformClass @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "")">
|
<div class="app-container @_platformClass @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "")">
|
||||||
<div class="reader-pane">
|
<div class="reader-pane">
|
||||||
<main>
|
<main>
|
||||||
@@ -21,14 +25,13 @@
|
|||||||
|
|
||||||
<div class="resizer" id="sidebar-resizer"></div>
|
<div class="resizer" id="sidebar-resizer"></div>
|
||||||
|
|
||||||
<AuthorizeView>
|
|
||||||
<Authorized>
|
|
||||||
<div class="intelligence-sidebar">
|
<div class="intelligence-sidebar">
|
||||||
<IntelligenceToolbar />
|
<IntelligenceToolbar />
|
||||||
<div class="intelligence-content">
|
<div class="intelligence-content">
|
||||||
<div class="intelligence-header">
|
<div class="intelligence-header">
|
||||||
<div class="ai-title">
|
<div class="ai-title">
|
||||||
<NexusIcon Name="robot" Size="20" Class="@($"neon-glow {(QuizService.HasNewQuiz ? "quiz-available" : "")}")" />
|
<NexusIcon Name="robot" Size="20"
|
||||||
|
Class="@($"neon-glow {(QuizService.HasNewQuiz ? "quiz-available" : "")}")" />
|
||||||
<span>Asystent AI</span>
|
<span>Asystent AI</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -41,14 +44,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="intelligence-scroll-area">
|
<div class="intelligence-scroll-area">
|
||||||
|
@if (!_isMobile)
|
||||||
|
{
|
||||||
<KnowledgeGraph />
|
<KnowledgeGraph />
|
||||||
|
}
|
||||||
<KnowledgeCheck />
|
<KnowledgeCheck />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
|
||||||
</AuthorizeView>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Authorized>
|
||||||
|
<Authorizing>
|
||||||
|
<div class="app-preloader">
|
||||||
|
<div class="preloader-spinner"></div>
|
||||||
|
<div class="preloader-text">Weryfikacja...</div>
|
||||||
|
</div>
|
||||||
|
</Authorizing>
|
||||||
|
<NotAuthorized>
|
||||||
|
@Body
|
||||||
|
</NotAuthorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
|
||||||
<div id="blazor-error-ui" data-nosnippet>
|
<div id="blazor-error-ui" data-nosnippet>
|
||||||
An unhandled error has occurred.
|
An unhandled error has occurred.
|
||||||
@@ -58,6 +73,7 @@
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
private string _platformClass = "platform-desktop";
|
private string _platformClass = "platform-desktop";
|
||||||
|
private bool _isMobile = false;
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
@@ -67,11 +83,13 @@
|
|||||||
var context = PlatformService.GetDeviceContext();
|
var context = PlatformService.GetDeviceContext();
|
||||||
if (context.IsSuccess)
|
if (context.IsSuccess)
|
||||||
{
|
{
|
||||||
_platformClass = context.Value.DeviceType switch
|
_isMobile = context.Value.DeviceType switch
|
||||||
{
|
{
|
||||||
DeviceType.Phone or DeviceType.Tablet => "platform-mobile",
|
DeviceType.Phone or DeviceType.Tablet => true,
|
||||||
_ => "platform-desktop"
|
_ => false
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_platformClass = _isMobile ? "platform-mobile" : "platform-desktop";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +108,10 @@
|
|||||||
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/layoutResizer.js");
|
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/layoutResizer.js");
|
||||||
await module.InvokeVoidAsync("initResizer", ".app-container", "#sidebar-resizer", "--sidebar-width");
|
await module.InvokeVoidAsync("initResizer", ".app-container", "#sidebar-resizer", "--sidebar-width");
|
||||||
}
|
}
|
||||||
catch { }
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Failed to initialize layout resizer JS module.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,4 +121,3 @@
|
|||||||
QuizService.OnQuizUpdated -= StateHasChanged;
|
QuizService.OnQuizUpdated -= StateHasChanged;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.7" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.7" />
|
||||||
<PackageReference Include="MediatR" Version="12.1.1" />
|
<PackageReference Include="MediatR" Version="12.1.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.7" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using FluentResults;
|
||||||
|
|
||||||
|
namespace NexusReader.UI.Shared.Services;
|
||||||
|
|
||||||
|
public interface ISyncService
|
||||||
|
{
|
||||||
|
Task<Result> InitializeAsync();
|
||||||
|
Task<Result> UpdateProgressAsync(string pageId);
|
||||||
|
event Action<string, DateTime> OnProgressReceived;
|
||||||
|
Task DisposeAsync();
|
||||||
|
}
|
||||||
@@ -3,16 +3,18 @@ using NexusReader.Application.Queries.Graph;
|
|||||||
using NexusReader.Application.Queries.Quiz;
|
using NexusReader.Application.Queries.Quiz;
|
||||||
using NexusReader.UI.Shared.Services;
|
using NexusReader.UI.Shared.Services;
|
||||||
using NexusReader.Application.DTOs.AI;
|
using NexusReader.Application.DTOs.AI;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace NexusReader.UI.Shared.Services;
|
namespace NexusReader.UI.Shared.Services;
|
||||||
|
|
||||||
public sealed class KnowledgeCoordinator : IDisposable
|
public sealed partial class KnowledgeCoordinator : IDisposable
|
||||||
{
|
{
|
||||||
private readonly IKnowledgeService _knowledgeService;
|
private readonly IKnowledgeService _knowledgeService;
|
||||||
private readonly IKnowledgeGraphService _graphService;
|
private readonly IKnowledgeGraphService _graphService;
|
||||||
private readonly IQuizStateService _quizService;
|
private readonly IQuizStateService _quizService;
|
||||||
private readonly IPlatformService _platformService;
|
private readonly IPlatformService _platformService;
|
||||||
private readonly IReaderInteractionService _interactionService;
|
private readonly IReaderInteractionService _interactionService;
|
||||||
|
private readonly ILogger<KnowledgeCoordinator> _logger;
|
||||||
|
|
||||||
public event Action<GraphDataDto>? OnGraphUpdated;
|
public event Action<GraphDataDto>? OnGraphUpdated;
|
||||||
|
|
||||||
@@ -21,13 +23,15 @@ public sealed class KnowledgeCoordinator : IDisposable
|
|||||||
IKnowledgeGraphService graphService,
|
IKnowledgeGraphService graphService,
|
||||||
IQuizStateService quizService,
|
IQuizStateService quizService,
|
||||||
IPlatformService platformService,
|
IPlatformService platformService,
|
||||||
IReaderInteractionService interactionService)
|
IReaderInteractionService interactionService,
|
||||||
|
ILogger<KnowledgeCoordinator> logger)
|
||||||
{
|
{
|
||||||
_knowledgeService = knowledgeService;
|
_knowledgeService = knowledgeService;
|
||||||
_graphService = graphService;
|
_graphService = graphService;
|
||||||
_quizService = quizService;
|
_quizService = quizService;
|
||||||
_platformService = platformService;
|
_platformService = platformService;
|
||||||
_interactionService = interactionService;
|
_interactionService = interactionService;
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
_interactionService.OnNodeSelected += HandleNodeSelected;
|
_interactionService.OnNodeSelected += HandleNodeSelected;
|
||||||
}
|
}
|
||||||
@@ -38,18 +42,18 @@ public sealed class KnowledgeCoordinator : IDisposable
|
|||||||
_interactionService.RequestHighlightBlock(nodeId);
|
_interactionService.RequestHighlightBlock(nodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ProcessFullPageAsync(string fullContent)
|
public async Task ProcessFullPageAsync(string fullContent, string tenantId = "global")
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(fullContent)) return;
|
if (string.IsNullOrWhiteSpace(fullContent)) return;
|
||||||
|
|
||||||
Console.WriteLine("[KnowledgeCoordinator] Generating full page graph...");
|
LogGeneratingGraph(tenantId);
|
||||||
|
|
||||||
_graphService.Clear();
|
_graphService.Clear();
|
||||||
_graphService.SetLoading(true);
|
_graphService.SetLoading(true);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await _knowledgeService.GetGraphDataAsync(fullContent);
|
var result = await _knowledgeService.GetGraphDataAsync(fullContent, tenantId);
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
var packet = result.Value;
|
var packet = result.Value;
|
||||||
@@ -63,7 +67,7 @@ public sealed class KnowledgeCoordinator : IDisposable
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[KnowledgeCoordinator] Error generating graph: {ex.Message}");
|
LogGraphError(ex, tenantId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,12 +77,13 @@ public sealed class KnowledgeCoordinator : IDisposable
|
|||||||
_graphService.SetActiveNode(blockId);
|
_graphService.SetActiveNode(blockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<KnowledgePacket?> RequestSummaryAndQuizAsync(string content)
|
public async Task<KnowledgePacket?> RequestSummaryAndQuizAsync(string content, string tenantId = "global")
|
||||||
{
|
{
|
||||||
_quizService.SetHydrating(true);
|
_quizService.SetHydrating(true);
|
||||||
|
LogRequestingSummary(tenantId);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await _knowledgeService.GetSummaryAndQuizAsync(content);
|
var result = await _knowledgeService.GetSummaryAndQuizAsync(content, tenantId);
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
var packet = result.Value;
|
var packet = result.Value;
|
||||||
@@ -90,6 +95,12 @@ public sealed class KnowledgeCoordinator : IDisposable
|
|||||||
await _platformService.VibrateSuccessAsync();
|
await _platformService.VibrateSuccessAsync();
|
||||||
return packet;
|
return packet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LogSummaryWarning(tenantId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogSummaryError(ex, tenantId);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -98,8 +109,29 @@ public sealed class KnowledgeCoordinator : IDisposable
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
_graphService.Clear();
|
||||||
|
_quizService.SetQuiz(null, null);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_interactionService.OnNodeSelected -= HandleNodeSelected;
|
_interactionService.OnNodeSelected -= HandleNodeSelected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Information, Message = "[KnowledgeCoordinator] Generating full page graph for tenant: {TenantId}")]
|
||||||
|
private partial void LogGeneratingGraph(string tenantId);
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Error, Message = "[KnowledgeCoordinator] Error generating graph for tenant: {TenantId}")]
|
||||||
|
private partial void LogGraphError(Exception ex, string tenantId);
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Information, Message = "[KnowledgeCoordinator] Requesting summary and quiz for tenant: {TenantId}")]
|
||||||
|
private partial void LogRequestingSummary(string tenantId);
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Warning, Message = "[KnowledgeCoordinator] Failed to get summary and quiz for tenant: {TenantId}")]
|
||||||
|
private partial void LogSummaryWarning(string tenantId);
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Error, Message = "[KnowledgeCoordinator] Error requesting summary and quiz for tenant: {TenantId}")]
|
||||||
|
private partial void LogSummaryError(Exception ex, string tenantId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
using System.Net.Http;
|
||||||
|
|
||||||
|
namespace NexusReader.UI.Shared.Services;
|
||||||
|
|
||||||
|
public class SyncService : ISyncService, IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly INativeStorageService _storageService;
|
||||||
|
private readonly IPlatformService _platformService;
|
||||||
|
private HubConnection? _hubConnection;
|
||||||
|
private bool _isInitialized;
|
||||||
|
private CancellationTokenSource? _debounceCts;
|
||||||
|
|
||||||
|
public event Action<string, DateTime>? OnProgressReceived;
|
||||||
|
|
||||||
|
public SyncService(
|
||||||
|
HttpClient httpClient,
|
||||||
|
INativeStorageService storageService,
|
||||||
|
IPlatformService platformService)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_storageService = storageService;
|
||||||
|
_platformService = platformService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> InitializeAsync()
|
||||||
|
{
|
||||||
|
if (_isInitialized) return Result.Ok();
|
||||||
|
|
||||||
|
var tokenResult = await _storageService.GetSecureString("nexus_auth_token");
|
||||||
|
if (tokenResult.IsFailed) return Result.Fail("Not authenticated");
|
||||||
|
|
||||||
|
var baseUrl = _httpClient.BaseAddress?.ToString() ?? "http://localhost:5000/";
|
||||||
|
var hubUrl = new Uri(new Uri(baseUrl), "synchub").ToString();
|
||||||
|
|
||||||
|
_hubConnection = new HubConnectionBuilder()
|
||||||
|
.WithUrl(hubUrl, options =>
|
||||||
|
{
|
||||||
|
options.AccessTokenProvider = () => Task.FromResult<string?>(tokenResult.Value);
|
||||||
|
})
|
||||||
|
.WithAutomaticReconnect()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
_hubConnection.On<string, DateTime>("ProgressUpdated", (pageId, timestamp) =>
|
||||||
|
{
|
||||||
|
OnProgressReceived?.Invoke(pageId, timestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _hubConnection.StartAsync();
|
||||||
|
_isInitialized = true;
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? _lastSentPageId;
|
||||||
|
|
||||||
|
public async Task<Result> UpdateProgressAsync(string pageId)
|
||||||
|
{
|
||||||
|
if (pageId == _lastSentPageId) return Result.Ok();
|
||||||
|
|
||||||
|
// Proper trailing-edge debounce
|
||||||
|
_debounceCts?.Cancel();
|
||||||
|
_debounceCts = new CancellationTokenSource();
|
||||||
|
var token = _debounceCts.Token;
|
||||||
|
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(2000, token);
|
||||||
|
|
||||||
|
if (!_isInitialized) await InitializeAsync();
|
||||||
|
|
||||||
|
if (_hubConnection?.State == HubConnectionState.Connected)
|
||||||
|
{
|
||||||
|
await _hubConnection.SendAsync("UpdateProgress", pageId, token);
|
||||||
|
_lastSentPageId = pageId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException) { /* Ignored, user kept scrolling */ }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[SyncService] Error sending progress: {ex.Message}");
|
||||||
|
}
|
||||||
|
}, token);
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
_debounceCts?.Cancel();
|
||||||
|
if (_hubConnection != null)
|
||||||
|
{
|
||||||
|
await _hubConnection.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async ValueTask IAsyncDisposable.DisposeAsync()
|
||||||
|
{
|
||||||
|
await DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,3 +15,4 @@
|
|||||||
@using NexusReader.UI.Shared.Components.Molecules
|
@using NexusReader.UI.Shared.Components.Molecules
|
||||||
@using NexusReader.UI.Shared.Components.Organisms
|
@using NexusReader.UI.Shared.Components.Organisms
|
||||||
@using NexusReader.UI.Shared.Services
|
@using NexusReader.UI.Shared.Services
|
||||||
|
@using Microsoft.Extensions.Logging
|
||||||
|
|||||||
@@ -100,3 +100,56 @@ h1:focus {
|
|||||||
margin: 1rem;
|
margin: 1rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Preloader Styles */
|
||||||
|
#app-preloader, .app-preloader {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: radial-gradient(circle at center, #1a1a1a 0%, var(--nexus-bg) 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 9999;
|
||||||
|
transition: opacity 0.8s ease, visibility 0.8s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app-preloader.loaded {
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preloader-spinner {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border: 3px solid rgba(0, 255, 153, 0.1);
|
||||||
|
border-top: 3px solid var(--nexus-neon);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||||
|
filter: drop-shadow(0 0 10px var(--nexus-neon));
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preloader-text {
|
||||||
|
color: var(--nexus-neon);
|
||||||
|
font-family: var(--nexus-font-sans);
|
||||||
|
letter-spacing: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.5; transform: scale(0.95); }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -122,13 +122,19 @@ export function updateData(data) {
|
|||||||
// Update Links
|
// Update Links
|
||||||
link = rootGroup.select(".links-layer")
|
link = rootGroup.select(".links-layer")
|
||||||
.selectAll("path")
|
.selectAll("path")
|
||||||
.data(data.links, d => d.source + "-" + d.target)
|
.data(data.links, d => d.source + "-" + d.target + "-" + d.relationType)
|
||||||
.join(
|
.join(
|
||||||
enter => enter.append("path")
|
enter => enter.append("path")
|
||||||
.attr("stroke", "rgba(255,255,255,0.05)")
|
.attr("stroke", d => {
|
||||||
|
if (d.relationType === 'Defines') return 'var(--nexus-accent)';
|
||||||
|
if (d.relationType === 'Next') return 'rgba(255,255,255,0.2)';
|
||||||
|
if (d.relationType === 'Contains') return 'var(--nexus-neon)';
|
||||||
|
return 'rgba(255,255,255,0.1)';
|
||||||
|
})
|
||||||
.attr("fill", "none")
|
.attr("fill", "none")
|
||||||
.attr("stroke-width", 1.5)
|
.attr("stroke-width", d => d.relationType === 'Defines' ? 2 : 1)
|
||||||
.call(e => e.transition().duration(500).attr("stroke", "rgba(255,255,255,0.1)")),
|
.attr("stroke-dasharray", d => d.relationType === 'References' ? "5,5" : "0")
|
||||||
|
.call(e => e.transition().duration(500).attr("opacity", 1)),
|
||||||
update => update,
|
update => update,
|
||||||
exit => exit.remove()
|
exit => exit.remove()
|
||||||
);
|
);
|
||||||
@@ -150,7 +156,12 @@ export function updateData(data) {
|
|||||||
|
|
||||||
g.append("circle")
|
g.append("circle")
|
||||||
.attr("r", 30)
|
.attr("r", 30)
|
||||||
.attr("fill", "url(#nebulaGlow)")
|
.attr("fill", d => {
|
||||||
|
if (d.type === 'Definition') return 'var(--nexus-accent)';
|
||||||
|
if (d.type === 'Table') return 'var(--nexus-neon)';
|
||||||
|
if (d.type === 'Rule') return '#ff4444';
|
||||||
|
return "url(#nebulaGlow)";
|
||||||
|
})
|
||||||
.attr("opacity", 0)
|
.attr("opacity", 0)
|
||||||
.transition().duration(1000).attr("opacity", d => d.group === 'current' ? 0.6 : 0.2);
|
.transition().duration(1000).attr("opacity", d => d.group === 'current' ? 0.6 : 0.2);
|
||||||
|
|
||||||
@@ -162,14 +173,18 @@ export function updateData(data) {
|
|||||||
.attr("height", 24)
|
.attr("height", 24)
|
||||||
.attr("rx", 12)
|
.attr("rx", 12)
|
||||||
.attr("fill", "rgba(20, 20, 20, 0.9)")
|
.attr("fill", "rgba(20, 20, 20, 0.9)")
|
||||||
.attr("stroke", "rgba(255, 255, 255, 0.1)")
|
.attr("stroke", d => {
|
||||||
|
if (d.type === 'Definition') return 'var(--nexus-accent)';
|
||||||
|
if (d.type === 'Rule') return '#ff4444';
|
||||||
|
return "rgba(255, 255, 255, 0.1)";
|
||||||
|
})
|
||||||
.attr("stroke-width", 1);
|
.attr("stroke-width", 1);
|
||||||
|
|
||||||
g.append("text")
|
g.append("text")
|
||||||
.text(d => d.label)
|
.text(d => d.label)
|
||||||
.attr("text-anchor", "middle")
|
.attr("text-anchor", "middle")
|
||||||
.attr("y", 4)
|
.attr("y", 4)
|
||||||
.attr("fill", "#ccc")
|
.attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc')
|
||||||
.attr("font-size", "0.8rem");
|
.attr("font-size", "0.8rem");
|
||||||
|
|
||||||
return g;
|
return g;
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
||||||
<ProjectReference Include="..\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
|
|
||||||
<ProjectReference Include="..\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" />
|
<ProjectReference Include="..\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ using NexusReader.Application.Abstractions.Services;
|
|||||||
using NexusReader.Web.Client.Services;
|
using NexusReader.Web.Client.Services;
|
||||||
using NexusReader.UI.Shared.Services;
|
using NexusReader.UI.Shared.Services;
|
||||||
using NexusReader.Application;
|
using NexusReader.Application;
|
||||||
using NexusReader.Infrastructure;
|
|
||||||
using NexusReader.Infrastructure.Services;
|
|
||||||
|
|
||||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||||
|
|
||||||
@@ -19,6 +18,7 @@ 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<KnowledgeCoordinator>();
|
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||||
|
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||||
|
|
||||||
// Identity & Auth Services
|
// Identity & Auth Services
|
||||||
builder.Services.AddOptions();
|
builder.Services.AddOptions();
|
||||||
@@ -34,6 +34,5 @@ builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.
|
|||||||
|
|
||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
builder.Services.AddScoped<IEpubService, WasmEpubService>();
|
builder.Services.AddScoped<IEpubService, WasmEpubService>();
|
||||||
builder.Services.AddTransient<IAiGenerateQuizService, FakeAiGenerateQuizService>();
|
|
||||||
|
|
||||||
await builder.Build().RunAsync();
|
await builder.Build().RunAsync();
|
||||||
|
|||||||
@@ -14,21 +14,65 @@ public class WasmKnowledgeService : IKnowledgeService
|
|||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default)
|
public async Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, string tenantId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await CallKnowledgeApiAsync("/api/knowledge", text, cancellationToken);
|
return await CallKnowledgeApiAsync("/api/knowledge", text, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, CancellationToken cancellationToken = default)
|
public async Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, string tenantId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await CallKnowledgeApiAsync("/api/knowledge/graph", text, cancellationToken);
|
return await CallKnowledgeApiAsync("/api/knowledge/graph", text, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, CancellationToken cancellationToken = default)
|
public async Task<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, string tenantId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await CallKnowledgeApiAsync("/api/knowledge/map", text, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, string tenantId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await CallKnowledgeApiAsync("/api/knowledge/summary", text, cancellationToken);
|
return await CallKnowledgeApiAsync("/api/knowledge/summary", text, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Result<List<RelevantContext>>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _httpClient.PostAsJsonAsync("/api/knowledge/relevant", new { query, tenantId }, cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var context = await response.Content.ReadFromJsonAsync<List<RelevantContext>>(cancellationToken: cancellationToken);
|
||||||
|
return context != null ? Result.Ok(context) : Result.Fail("Failed to deserialize relevant context.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
return Result.Fail($"Server error ({response.StatusCode}): {errorBody}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error($"Network error: {ex.Message}").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public async Task<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _httpClient.PostAsJsonAsync("/api/knowledge/verify-groundedness", new { answer, context, tenantId }, cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<GroundednessResult>(cancellationToken: cancellationToken);
|
||||||
|
return result != null ? Result.Ok(result) : Result.Fail("Failed to deserialize groundedness result.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
return Result.Fail($"Server error ({response.StatusCode}): {errorBody}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error($"Network error: {ex.Message}").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<Result<KnowledgePacket>> CallKnowledgeApiAsync(string endpoint, string text, CancellationToken cancellationToken)
|
private async Task<Result<KnowledgePacket>> CallKnowledgeApiAsync(string endpoint, string text, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<ResourcePreloader />
|
|
||||||
<link rel="stylesheet" href="_content/NexusReader.UI.Shared/app.css" />
|
<link rel="stylesheet" href="_content/NexusReader.UI.Shared/app.css" />
|
||||||
<link rel="stylesheet" href="NexusReader.Web.styles.css" />
|
<link rel="stylesheet" href="NexusReader.Web.styles.css" />
|
||||||
<ImportMap />
|
<ImportMap />
|
||||||
@@ -14,9 +13,35 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<div id="app-preloader">
|
||||||
|
<div class="preloader-spinner"></div>
|
||||||
|
<div class="preloader-text">Nexus Reader</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<NexusReader.UI.Shared.Routes @rendermode="InteractiveAuto" />
|
<NexusReader.UI.Shared.Routes @rendermode="InteractiveAuto" />
|
||||||
<ReconnectModal />
|
<ReconnectModal />
|
||||||
<script src="_framework/blazor.web.js"></script>
|
<script src="_framework/blazor.web.js"></script>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
function hidePreloader() {
|
||||||
|
const preloader = document.getElementById('app-preloader');
|
||||||
|
if (preloader) {
|
||||||
|
preloader.classList.add('loaded');
|
||||||
|
// Completely remove from DOM after transition for better accessibility
|
||||||
|
setTimeout(() => preloader.style.display = 'none', 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'complete') {
|
||||||
|
hidePreloader();
|
||||||
|
} else {
|
||||||
|
window.addEventListener('load', hidePreloader);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: If for some reason 'load' doesn't fire (e.g. big assets), hide after 3s anyway
|
||||||
|
setTimeout(hidePreloader, 3000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using NexusReader.Domain.Entities;
|
|
||||||
using Stripe;
|
|
||||||
|
|
||||||
namespace NexusReader.Web.New.Controllers;
|
|
||||||
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[ApiController]
|
|
||||||
public class StripeWebhookController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly UserManager<NexusUser> _userManager;
|
|
||||||
private readonly IConfiguration _configuration;
|
|
||||||
private readonly string _webhookSecret;
|
|
||||||
|
|
||||||
public StripeWebhookController(UserManager<NexusUser> userManager, IConfiguration configuration)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
_configuration = configuration;
|
|
||||||
_webhookSecret = _configuration["Stripe:WebhookSecret"] ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost]
|
|
||||||
public async Task<IActionResult> Index()
|
|
||||||
{
|
|
||||||
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var stripeEvent = EventUtility.ConstructEvent(
|
|
||||||
json,
|
|
||||||
Request.Headers["Stripe-Signature"],
|
|
||||||
_webhookSecret
|
|
||||||
);
|
|
||||||
|
|
||||||
switch (stripeEvent.Type)
|
|
||||||
{
|
|
||||||
case EventTypes.CheckoutSessionCompleted:
|
|
||||||
var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
|
|
||||||
await HandleSubscriptionSuccess(session?.CustomerEmail, session?.Metadata);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case EventTypes.CustomerSubscriptionUpdated:
|
|
||||||
var subscription = stripeEvent.Data.Object as Stripe.Subscription;
|
|
||||||
// Subscription update might not have email directly, would need to fetch customer
|
|
||||||
// For now, assuming email is in metadata if we set it during checkout
|
|
||||||
await HandleSubscriptionSuccess(subscription?.Metadata["CustomerEmail"], subscription?.Metadata);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case EventTypes.CustomerSubscriptionDeleted:
|
|
||||||
var deletedSubscription = stripeEvent.Data.Object as Stripe.Subscription;
|
|
||||||
await HandleSubscriptionCancellation(deletedSubscription?.Metadata["CustomerEmail"]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
catch (StripeException e)
|
|
||||||
{
|
|
||||||
return BadRequest(e.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleSubscriptionSuccess(string? email, Dictionary<string, string>? metadata)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(email)) return;
|
|
||||||
|
|
||||||
var user = await _userManager.FindByEmailAsync(email);
|
|
||||||
if (user != null)
|
|
||||||
{
|
|
||||||
var plan = metadata != null && metadata.ContainsKey("Plan") ? metadata["Plan"] : "Pro";
|
|
||||||
|
|
||||||
user.CurrentPlan = plan;
|
|
||||||
user.AITokenLimit = plan.ToLower() switch
|
|
||||||
{
|
|
||||||
"pro" => 50000,
|
|
||||||
"enterprise" => 500000,
|
|
||||||
_ => 10000 // default for unknown or free
|
|
||||||
};
|
|
||||||
|
|
||||||
await _userManager.UpdateAsync(user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleSubscriptionCancellation(string? email)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(email)) return;
|
|
||||||
|
|
||||||
var user = await _userManager.FindByEmailAsync(email);
|
|
||||||
if (user != null)
|
|
||||||
{
|
|
||||||
user.CurrentPlan = "Free";
|
|
||||||
user.AITokenLimit = 5000; // Free tier limit
|
|
||||||
await _userManager.UpdateAsync(user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ using NexusReader.Web.Components;
|
|||||||
using NexusReader.Application;
|
using NexusReader.Application;
|
||||||
using NexusReader.Infrastructure;
|
using NexusReader.Infrastructure;
|
||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
using NexusReader.Application.DTOs.User;
|
||||||
using NexusReader.Web.Client.Services;
|
using NexusReader.Web.Client.Services;
|
||||||
using NexusReader.UI.Shared.Services;
|
using NexusReader.UI.Shared.Services;
|
||||||
using NexusReader.Domain.Entities;
|
using NexusReader.Domain.Entities;
|
||||||
@@ -14,6 +15,7 @@ using NexusReader.Infrastructure.Identity;
|
|||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using NexusReader.Infrastructure.Services;
|
using NexusReader.Infrastructure.Services;
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
|
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
|
||||||
|
|
||||||
@@ -24,14 +26,14 @@ builder.Services.AddRazorComponents()
|
|||||||
.AddInteractiveServerComponents()
|
.AddInteractiveServerComponents()
|
||||||
.AddInteractiveWebAssemblyComponents();
|
.AddInteractiveWebAssemblyComponents();
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
|
||||||
|
|
||||||
// Enable detailed circuit errors for Server‑Side Blazor components
|
// Enable detailed circuit errors for Server‑Side Blazor components
|
||||||
builder.Services.AddServerSideBlazor()
|
builder.Services.AddServerSideBlazor()
|
||||||
.AddCircuitOptions(options =>
|
.AddCircuitOptions(options =>
|
||||||
{
|
{
|
||||||
options.DetailedErrors = true;
|
options.DetailedErrors = true;
|
||||||
});
|
});
|
||||||
|
builder.Services.AddSignalR();
|
||||||
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
|
||||||
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
||||||
builder.Services.AddScoped<INativeStorageService, NexusReader.UI.Shared.Services.WebStorageService>();
|
builder.Services.AddScoped<INativeStorageService, NexusReader.UI.Shared.Services.WebStorageService>();
|
||||||
@@ -42,6 +44,7 @@ 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<KnowledgeCoordinator>();
|
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||||
|
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||||
|
|
||||||
builder.Services.AddHttpClient("NexusAPI", client =>
|
builder.Services.AddHttpClient("NexusAPI", client =>
|
||||||
{
|
{
|
||||||
@@ -49,7 +52,7 @@ builder.Services.AddHttpClient("NexusAPI", client =>
|
|||||||
});
|
});
|
||||||
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
|
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
|
||||||
|
|
||||||
builder.Services.AddScoped<IIdentityService, IdentityService>();
|
builder.Services.AddScoped<IIdentityService, NexusReader.UI.Shared.Services.IdentityService>();
|
||||||
builder.Services.AddScoped<NexusAuthenticationStateProvider>();
|
builder.Services.AddScoped<NexusAuthenticationStateProvider>();
|
||||||
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<NexusAuthenticationStateProvider>());
|
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<NexusAuthenticationStateProvider>());
|
||||||
builder.Services.AddCascadingAuthenticationState();
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
@@ -57,16 +60,19 @@ builder.Services.AddCascadingAuthenticationState();
|
|||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
builder.Services.AddInfrastructure(builder.Configuration);
|
builder.Services.AddInfrastructure(builder.Configuration);
|
||||||
|
|
||||||
|
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
|
||||||
|
NexusReader.Application.DependencyInjection.Assembly,
|
||||||
|
NexusReader.Infrastructure.DependencyInjection.Assembly
|
||||||
|
));
|
||||||
|
|
||||||
// Authorization Policies
|
// Authorization Policies
|
||||||
builder.Services.AddScoped<IAuthorizationHandler, TokenLimitHandler>();
|
builder.Services.AddScoped<IAuthorizationHandler, TokenLimitHandler>();
|
||||||
builder.Services.AddAuthorization(options =>
|
builder.Services.AddAuthorizationBuilder()
|
||||||
{
|
.AddPolicy("ProUser", policy => policy.RequireClaim("Plan", SubscriptionPlan.ProName, SubscriptionPlan.EnterpriseName))
|
||||||
options.AddPolicy("ProUser", policy => policy.RequireClaim("Plan", "Pro", "Enterprise"));
|
.AddPolicy("HasAvailableTokens", policy => policy.AddRequirements(new TokenLimitRequirement()));
|
||||||
options.AddPolicy("HasAvailableTokens", policy => policy.AddRequirements(new TokenLimitRequirement()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Billing & Stripe
|
// Billing & Stripe
|
||||||
builder.Services.AddScoped<IBillingService, BillingService>();
|
builder.Services.AddScoped<IBillingService, NexusReader.Infrastructure.Services.BillingService>();
|
||||||
|
|
||||||
// Authentication
|
// Authentication
|
||||||
builder.Services.AddAuthentication(options =>
|
builder.Services.AddAuthentication(options =>
|
||||||
@@ -81,6 +87,7 @@ builder.Services.AddAuthentication(options =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddIdentityApiEndpoints<NexusUser>()
|
builder.Services.AddIdentityApiEndpoints<NexusUser>()
|
||||||
|
.AddRoles<IdentityRole>()
|
||||||
.AddEntityFrameworkStores<AppDbContext>();
|
.AddEntityFrameworkStores<AppDbContext>();
|
||||||
|
|
||||||
builder.Services.ConfigureApplicationCookie(options =>
|
builder.Services.ConfigureApplicationCookie(options =>
|
||||||
@@ -113,11 +120,63 @@ builder.Services.Configure<IdentityOptions>(options =>
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Ensure Database is initialized
|
// Startup Validation
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<NexusReader.Infrastructure.Persistence.AppDbContext>();
|
var marker = scope.ServiceProvider.GetService<IInfrastructureMarker>();
|
||||||
|
if (marker == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("CRITICAL: Infrastructure layer was not registered. Ensure AddInfrastructure() is called in Program.cs.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Database is initialized and seeded
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var services = scope.ServiceProvider;
|
||||||
|
var logger = services.GetRequiredService<ILogger<Program>>();
|
||||||
|
var dbContext = services.GetRequiredService<NexusReader.Infrastructure.Persistence.AppDbContext>();
|
||||||
|
|
||||||
|
int maxRetries = 5;
|
||||||
|
int delayMs = 2000;
|
||||||
|
|
||||||
|
for (int i = 0; i < maxRetries; i++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (logger.IsEnabled(LogLevel.Information))
|
||||||
|
{
|
||||||
|
logger.LogInformation("Próba połączenia z bazą danych (próba {Attempt}/{MaxRetries})...", i + 1, maxRetries);
|
||||||
|
}
|
||||||
|
|
||||||
await dbContext.Database.MigrateAsync();
|
await dbContext.Database.MigrateAsync();
|
||||||
|
await DbInitializer.SeedAsync(services);
|
||||||
|
|
||||||
|
if (logger.IsEnabled(LogLevel.Information))
|
||||||
|
{
|
||||||
|
logger.LogInformation("Baza danych zainicjowana pomyślnie.");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Npgsql.NpgsqlException ex) when (i < maxRetries - 1)
|
||||||
|
{
|
||||||
|
if (logger.IsEnabled(LogLevel.Warning))
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Błąd połączenia z bazą danych. Ponowna próba za {Delay}ms...", delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(delayMs);
|
||||||
|
delayMs *= 2; // Exponential backoff
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (logger.IsEnabled(LogLevel.Critical))
|
||||||
|
{
|
||||||
|
logger.LogCritical(ex, "Krytyczny błąd podczas inicjalizacji bazy danych.");
|
||||||
|
}
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
@@ -141,7 +200,7 @@ app.UseAntiforgery();
|
|||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.MapStaticAssets();
|
app.MapStaticAssets();
|
||||||
app.MapControllers();
|
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
|
||||||
|
|
||||||
// API endpoint for WASM client to fetch EPUB content
|
// API endpoint for WASM client to fetch EPUB content
|
||||||
app.MapGet("/api/epub/{index}", async (int index, IEpubService epubService) =>
|
app.MapGet("/api/epub/{index}", async (int index, IEpubService epubService) =>
|
||||||
@@ -149,40 +208,137 @@ app.MapGet("/api/epub/{index}", async (int index, IEpubService epubService) =>
|
|||||||
var result = await epubService.GetEpubContentAsync(index);
|
var result = await epubService.GetEpubContentAsync(index);
|
||||||
if (result.IsSuccess) return Results.Ok(result.Value);
|
if (result.IsSuccess) return Results.Ok(result.Value);
|
||||||
|
|
||||||
var errorMsg = result.Errors.FirstOrDefault()?.Message ?? "Unknown server error";
|
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
|
||||||
return Results.BadRequest(errorMsg);
|
return Results.BadRequest(errorMsg);
|
||||||
});
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapPost("/api/knowledge", async (KnowledgeRequest request, IKnowledgeService knowledgeService) =>
|
var knowledgeApi = app.MapGroup("/api/knowledge").RequireAuthorization("HasAvailableTokens");
|
||||||
|
|
||||||
|
knowledgeApi.MapPost("/", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) =>
|
||||||
{
|
{
|
||||||
var result = await knowledgeService.GetKnowledgeAsync(request.Text);
|
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
||||||
|
var result = await knowledgeService.GetKnowledgeAsync(request.Text, tenantId);
|
||||||
if (result.IsSuccess) return Results.Ok(result.Value);
|
if (result.IsSuccess) return Results.Ok(result.Value);
|
||||||
return Results.BadRequest(result.Errors.FirstOrDefault()?.Message ?? "Unknown server error");
|
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.MapPost("/api/knowledge/graph", async (KnowledgeRequest request, IKnowledgeService knowledgeService) =>
|
knowledgeApi.MapPost("/graph", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) =>
|
||||||
{
|
{
|
||||||
var result = await knowledgeService.GetGraphDataAsync(request.Text);
|
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
||||||
|
var result = await knowledgeService.GetGraphDataAsync(request.Text, tenantId);
|
||||||
if (result.IsSuccess) return Results.Ok(result.Value);
|
if (result.IsSuccess) return Results.Ok(result.Value);
|
||||||
return Results.BadRequest(result.Errors.FirstOrDefault()?.Message ?? "Unknown server error");
|
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.MapPost("/api/knowledge/summary", async (KnowledgeRequest request, IKnowledgeService knowledgeService) =>
|
knowledgeApi.MapPost("/summary", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) =>
|
||||||
{
|
{
|
||||||
var result = await knowledgeService.GetSummaryAndQuizAsync(request.Text);
|
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
||||||
|
var result = await knowledgeService.GetSummaryAndQuizAsync(request.Text, tenantId);
|
||||||
if (result.IsSuccess) return Results.Ok(result.Value);
|
if (result.IsSuccess) return Results.Ok(result.Value);
|
||||||
return Results.BadRequest(result.Errors.FirstOrDefault()?.Message ?? "Unknown server error");
|
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.MapDelete("/api/knowledge", async (IKnowledgeService knowledgeService) =>
|
knowledgeApi.MapPost("/verify-groundedness", async (GroundednessRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) =>
|
||||||
|
{
|
||||||
|
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
||||||
|
var result = await knowledgeService.VerifyGroundednessAsync(request.Answer, request.Context, tenantId);
|
||||||
|
if (result.IsSuccess) return Results.Ok(result.Value);
|
||||||
|
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error");
|
||||||
|
});
|
||||||
|
|
||||||
|
knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) =>
|
||||||
{
|
{
|
||||||
var result = await knowledgeService.ClearCacheAsync();
|
var result = await knowledgeService.ClearCacheAsync();
|
||||||
if (result.IsSuccess) return Results.Ok();
|
if (result.IsSuccess) return Results.Ok();
|
||||||
|
|
||||||
var errorMsg = result.Errors.FirstOrDefault()?.Message ?? "Unknown server error";
|
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
|
||||||
return Results.BadRequest(errorMsg);
|
return Results.BadRequest(errorMsg);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/StripeWebhook", async (
|
||||||
|
HttpContext context,
|
||||||
|
UserManager<NexusUser> userManager,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IDbContextFactory<AppDbContext> dbContextFactory) =>
|
||||||
|
{
|
||||||
|
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||||
|
var json = await new StreamReader(context.Request.Body).ReadToEndAsync();
|
||||||
|
var webhookSecret = configuration["Stripe:WebhookSecret"] ?? "";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var stripeEvent = EventUtility.ConstructEvent(
|
||||||
|
json,
|
||||||
|
context.Request.Headers["Stripe-Signature"],
|
||||||
|
webhookSecret
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (stripeEvent.Type)
|
||||||
|
{
|
||||||
|
case EventTypes.CheckoutSessionCompleted:
|
||||||
|
var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
|
||||||
|
await HandleSubscriptionSuccess(session?.CustomerEmail, session?.Metadata, userManager, dbContext);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EventTypes.CustomerSubscriptionUpdated:
|
||||||
|
var subscription = stripeEvent.Data.Object as Stripe.Subscription;
|
||||||
|
await HandleSubscriptionSuccess(subscription?.Metadata["CustomerEmail"], subscription?.Metadata, userManager, dbContext);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EventTypes.CustomerSubscriptionDeleted:
|
||||||
|
var deletedSubscription = stripeEvent.Data.Object as Stripe.Subscription;
|
||||||
|
await HandleSubscriptionCancellation(deletedSubscription?.Metadata["CustomerEmail"], userManager, dbContext);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return Results.Ok();
|
||||||
|
}
|
||||||
|
catch (StripeException e)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(e.Message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async Task HandleSubscriptionSuccess(
|
||||||
|
string? email,
|
||||||
|
Dictionary<string, string>? metadata,
|
||||||
|
UserManager<NexusUser> userManager,
|
||||||
|
AppDbContext dbContext)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(email)) return;
|
||||||
|
|
||||||
|
var user = await userManager.FindByEmailAsync(email);
|
||||||
|
if (user != null)
|
||||||
|
{
|
||||||
|
var planName = metadata?.GetValueOrDefault("Plan") ?? SubscriptionPlan.ProName;
|
||||||
|
var plan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == planName);
|
||||||
|
|
||||||
|
if (plan != null)
|
||||||
|
{
|
||||||
|
user.SubscriptionPlanId = plan.Id;
|
||||||
|
user.AITokenLimit = plan.AITokenLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
await userManager.UpdateAsync(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task HandleSubscriptionCancellation(
|
||||||
|
string? email,
|
||||||
|
UserManager<NexusUser> userManager,
|
||||||
|
AppDbContext dbContext)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(email)) return;
|
||||||
|
|
||||||
|
var user = await userManager.FindByEmailAsync(email);
|
||||||
|
if (user != null)
|
||||||
|
{
|
||||||
|
var freePlan = await dbContext.SubscriptionPlans.FindAsync(SubscriptionPlan.FreeId);
|
||||||
|
user.SubscriptionPlanId = SubscriptionPlan.FreeId;
|
||||||
|
user.AITokenLimit = freePlan?.AITokenLimit ?? 5000;
|
||||||
|
await userManager.UpdateAsync(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
app.MapGroup("/identity").MapIdentityApi<NexusUser>();
|
app.MapGroup("/identity").MapIdentityApi<NexusUser>();
|
||||||
|
|
||||||
app.MapGet("/identity/login/google", (string? returnUrl) =>
|
app.MapGet("/identity/login/google", (string? returnUrl) =>
|
||||||
@@ -226,36 +382,38 @@ app.MapGet("/identity/callback/google", async (
|
|||||||
return Results.Redirect("/account/login?error=ProvisioningFailed");
|
return Results.Redirect("/account/login?error=ProvisioningFailed");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager<NexusUser> userManager, AppDbContext dbContext) =>
|
app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager<NexusUser> userManager, IDbContextFactory<AppDbContext> dbContextFactory) =>
|
||||||
{
|
{
|
||||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
if (userId == null) return Results.Unauthorized();
|
if (userId == null) return Results.Unauthorized();
|
||||||
|
|
||||||
var nexusUser = await dbContext.Users
|
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||||
.Include(u => u.Ebooks)
|
|
||||||
.Include(u => u.QuizResults)
|
|
||||||
.FirstOrDefaultAsync(u => u.Id == userId);
|
|
||||||
|
|
||||||
if (nexusUser == null) return Results.NotFound();
|
var profile = await dbContext.Users
|
||||||
|
.Where(u => u.Id == userId)
|
||||||
var avgScore = nexusUser.QuizResults.Any()
|
.Select(u => new UserProfileDto
|
||||||
? (int)nexusUser.QuizResults.Average(q => q.Percentage)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
var lastReadBook = nexusUser.Ebooks
|
|
||||||
.OrderByDescending(e => e.LastReadDate)
|
|
||||||
.FirstOrDefault()?.Title ?? "None";
|
|
||||||
|
|
||||||
return Results.Ok(new
|
|
||||||
{
|
{
|
||||||
nexusUser.Email,
|
Email = u.Email ?? string.Empty,
|
||||||
nexusUser.AITokenLimit,
|
AITokensUsed = u.AITokensUsed,
|
||||||
nexusUser.AITokensUsed,
|
Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto
|
||||||
nexusUser.CurrentPlan,
|
{
|
||||||
nexusUser.TenantId,
|
Id = u.SubscriptionPlan.Id,
|
||||||
AverageQuizScore = avgScore,
|
Name = u.SubscriptionPlan.PlanName,
|
||||||
LastReadBookTitle = lastReadBook
|
AITokenLimit = u.SubscriptionPlan.AITokenLimit,
|
||||||
});
|
MonthlyPrice = u.SubscriptionPlan.MonthlyPrice
|
||||||
|
} : new SubscriptionPlanDto(),
|
||||||
|
AverageQuizScore = u.QuizResults.Any() ? (int)u.QuizResults.Average(q => q.Percentage) : 0,
|
||||||
|
LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto
|
||||||
|
{
|
||||||
|
Id = e.Id,
|
||||||
|
Title = e.Title
|
||||||
|
}).FirstOrDefault()
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (profile == null) return Results.NotFound();
|
||||||
|
|
||||||
|
return Results.Ok(profile);
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapRazorComponents<App>()
|
app.MapRazorComponents<App>()
|
||||||
@@ -266,3 +424,4 @@ app.MapRazorComponents<App>()
|
|||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
public record KnowledgeRequest(string Text);
|
public record KnowledgeRequest(string Text);
|
||||||
|
public record GroundednessRequest(string Answer, string Context);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
{
|
{
|
||||||
"Stripe": {
|
"Stripe": {
|
||||||
"ApiKey": "sk_test_placeholder",
|
"ApiKey": "sk_test_placeholder",
|
||||||
"WebhookSecret": "whsec_placeholder"
|
"WebhookSecret": "whsec_placeholder",
|
||||||
|
"ProProductId": "prod_Pro123",
|
||||||
|
"BasicProductId": "prod_Basic456",
|
||||||
|
"FreeProductId": "prod_Free789"
|
||||||
},
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
@@ -24,7 +27,9 @@
|
|||||||
"Google": {
|
"Google": {
|
||||||
"ApiKey": "PLACEHOLDER",
|
"ApiKey": "PLACEHOLDER",
|
||||||
"Model": "gemini-2.5-flash-lite",
|
"Model": "gemini-2.5-flash-lite",
|
||||||
|
"MaxInputTokens": 15000,
|
||||||
"MaxOutputTokens": 8192
|
"MaxOutputTokens": 8192
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"ApiBaseUrl": "http://localhost:5000"
|
||||||
}
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user