13 Commits

Author SHA1 Message Date
Adrien 5f03e1f41b improve topics and chat source display 2026-04-12 18:56:18 +02:00
Adrien c98fe9ceaa update readme 2026-04-12 18:25:12 +02:00
Adrien 767d1e2dbc enhance illustration being taken into account in the response 2026-04-12 16:26:25 +02:00
Adrien 820734c251 fix api url setup 2026-04-10 13:55:05 +02:00
Adrien 0711e40c66 Improved responsiveness on mobile phone 2026-04-10 13:41:26 +02:00
Adrien 0db31e91ab try change image building to buildah 2026-04-09 22:44:14 +02:00
Adrien d480d04145 change base image 2026-04-09 21:45:22 +02:00
Adrien c2d034d1fe Add missing env variables 2026-04-09 20:37:11 +02:00
Adrien 0908355704 Adpat frontend to build docker image with buildah 2026-04-09 19:47:28 +02:00
Adrien 8e227a9429 fine-tune native image config 2026-04-09 18:20:39 +02:00
Adrien d8bcdce879 Squashed commit of the following:
commit 0d624137c2557c6eeb87020749e4977b821c2b5c
Author: Adrien <adrien.cesaro@proton.me>
Date:   Thu Apr 9 11:55:22 2026 +0200

    backend native image setup
2026-04-09 12:05:02 +02:00
Adrien aee6a9dfba enhance rag retrieval + summary 2026-04-07 22:39:28 +02:00
Adrien 0cf318f0a7 Add simple auth 2026-04-06 14:29:53 +02:00
78 changed files with 5683 additions and 393 deletions
+27
View File
@@ -0,0 +1,27 @@
.git/
.gitignore
*.md
.DS_Store
Thumbs.db
# Java build artifacts
target/
*.class
*.jar
# Node
node_modules/
dist/
*.log
# Env files (never bake secrets into images)
.env
.env.*
!.env.example
# Spec / docs
specs/
# Editor
.vscode/
.idea/
+13
View File
@@ -0,0 +1,13 @@
# Copy this file to .env and fill in your values before running docker-compose.native.yml
# .env is gitignored — never commit real credentials
# OpenAI
OPENAI_API_KEY=sk-...
# AWS S3 (figure storage — leave blank if using local filesystem)
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=eu-west-1
# S3 bucket name (if S3 storage enabled)
APP_STORAGE_S3_BUCKET=ai-teacher-figures
+14 -4
View File
@@ -1,6 +1,6 @@
# ai-teacher Development Guidelines
Auto-generated from all feature plans. Last updated: 2026-04-04
Auto-generated from all feature plans. Last updated: 2026-04-10
## Active Technologies
- Java 25 (backend), TypeScript / Node 20 (frontend) + Spring Boot 4.0.5, Spring AI 2.0.0-M4, OpenAI API (embeddings + chat), PDFBox (via Spring AI PDF reader dependency) (002-image-aware-embedding)
@@ -8,6 +8,16 @@ Auto-generated from all feature plans. Last updated: 2026-04-04
- Java 25 (backend), TypeScript / Node 20 (frontend) + Spring Boot 4.0.5, Spring AI 2.0.0-M4, OpenAI API, PDFBox (rendering only), `com.google.cloud:google-cloud-documentai` (~2.40.x) (002-image-aware-embedding)
- PostgreSQL (JPA + Flyway), pgvector (Spring AI VectorStore), S3 / local filesystem (figure images) (002-image-aware-embedding)
- PostgreSQL (JPA + Flyway), pgvector (Spring AI `VectorStore`), S3-compatible (002-image-aware-embedding)
- Java 21 (backend) / TypeScript + Node 20 (frontend) + Spring Boot 4.0.5, Spring Security (already included), Vue 3.4, Vue Router 4.3, Pinia 2.1, Axios 1.7 (003-basic-login)
- No new storage — credentials held in browser `sessionStorage` (frontend only) (003-basic-login)
- Java 21 (backend), TypeScript / Node 20 (frontend) + Spring Boot 4.0.5, Spring AI 2.0.0-M4, OpenAI API (chat + embeddings), pgvector, Vue 3.4, Pinia 2.1 (004-rag-retrieval-quality)
- PostgreSQL (sections, figures, messages — unchanged). No new tables needed. (004-rag-retrieval-quality)
- Java 21 (backend), TypeScript / Node 20 (frontend) + Spring Boot 4.0.5, Spring AI 2.0.0-M4, OpenAI API (chat + embeddings), Vue 3.4, Pinia 2.1, Axios 1.7 (004-rag-retrieval-quality)
- PostgreSQL (JPA + Flyway), pgvector (`VectorStore`) (004-rag-retrieval-quality)
- Java 25 (backend), TypeScript / Node 20 (frontend) + Spring Boot 4.0.5, Spring AI 2.0.0-M4, `native-maven-plugin` 0.10.6, (005-native-image-deployment)
- PostgreSQL 16 + pgvector (unchanged) (005-native-image-deployment)
- TypeScript / Node 20 (frontend only) + Vue 3.4, Vue Router 4.3, Pinia 2.1 — no changes (006-mobile-responsive-ui)
- N/A (frontend-only change) (006-mobile-responsive-ui)
- Java 21 (backend), TypeScript / Node 20 (frontend) (001-neuro-rag-learning)
@@ -27,9 +37,9 @@ npm test && npm run lint
Java 21 (backend), TypeScript / Node 20 (frontend): Follow standard conventions
## Recent Changes
- 002-image-aware-embedding: Added Java 25 (backend), TypeScript / Node 20 (frontend) + Spring Boot 4.0.5, Spring AI 2.0.0-M4, OpenAI API (embeddings +
- 002-image-aware-embedding: Added Java 25 (backend), TypeScript / Node 20 (frontend) + Spring Boot 4.0.5, Spring AI 2.0.0-M4, OpenAI API, PDFBox (rendering only), `com.google.cloud:google-cloud-documentai` (~2.40.x)
- 002-image-aware-embedding: Added Java 25 (backend), TypeScript / Node 20 (frontend) + Spring Boot 4.0.5, Spring AI 2.0.0-M4, OpenAI API (embeddings + chat), PDFBox (via Spring AI PDF reader dependency)
- 006-mobile-responsive-ui: Added TypeScript / Node 20 (frontend only) + Vue 3.4, Vue Router 4.3, Pinia 2.1 — no changes
- 005-native-image-deployment: Added Java 25 (backend), TypeScript / Node 20 (frontend) + Spring Boot 4.0.5, Spring AI 2.0.0-M4, `native-maven-plugin` 0.10.6,
- 004-rag-retrieval-quality: Added Java 21 (backend), TypeScript / Node 20 (frontend) + Spring Boot 4.0.5, Spring AI 2.0.0-M4, OpenAI API (chat + embeddings), Vue 3.4, Pinia 2.1, Axios 1.7
<!-- MANUAL ADDITIONS START -->
+108 -6
View File
@@ -9,14 +9,20 @@ AI-generated cross-book summaries, and engage in grounded RAG chat.
```mermaid
graph TD
User["Neurosurgeon (Browser)"]
Login["Login Page\n(username + password form)"]
FE["Frontend\nVue.js 3 / Vite\n:5173"]
BE["Backend\nSpring Boot 4 / Spring AI\n:8080"]
Auth["Spring Security\nHTTP Basic Auth"]
DB["PostgreSQL + pgvector\n(source of truth)"]
FS["File Store\nuploads/ (local disk)\nExtracted figure PNGs"]
LLM["LLM Provider\n(OpenAI)\nEmbeddings + Chat + Vision"]
User -->|HTTP| FE
FE -->|REST /api/v1/...| BE
User -->|"First visit / unauthenticated"| Login
Login -->|"POST credentials\n(GET /api/v1/auth/check)"| Auth
Auth -->|"401 → back to login\n200 → app access"| Login
Login -->|"Authenticated"| FE
FE -->|"REST /api/v1/...\n(HTTP Basic on every request)"| Auth
Auth --> BE
BE -->|"JDBC — books, chapters,\nsections, figures, refs"| DB
BE -->|"pgvector — text chunks\n+ figure caption vectors"| DB
BE -->|"PNG read/write\n(figure extraction)"| FS
@@ -37,18 +43,25 @@ graph TD
end
subgraph "Retrieval Pipeline (per chat query)"
RP0["Query expansion\n(QueryExpansionService)\nlay → clinical terms"]
RP1["Text chunk search (topK=5)"]
RP2["Figure caption search (topK=3)"]
RP3["Expand chunks → full section text"]
RP3["Expand chunks → ±1-page section text"]
RP4["Fetch linked figures (chunk_figure_ref)"]
RP5["Merge + deduplicate figures"]
RP6["Build LLM prompt + call"]
RP6["Build labelled prompt\n[S1],[F1]… tags"]
RP7["LLM chat call"]
RP8["Citation validation\n(CitationValidatorService)\nstrip hallucinated refs"]
RP0 --> RP1
RP0 --> RP2
RP1 --> RP3
RP1 --> RP4
RP2 --> RP5
RP4 --> RP5
RP3 --> RP6
RP5 --> RP6
RP6 --> RP7
RP7 --> RP8
end
```
@@ -124,7 +137,7 @@ PictureGroup
## Stack
- **Backend**: Spring Boot 4.0.5 + Spring AI 2.0.0-M4, Java 21, Maven
- **Backend**: Spring Boot 4.0.5 + Spring AI 2.0.0-M4, Java 25, Maven
- **Frontend**: Vue.js 3 + Vite + TypeScript + Pinia + Axios
- **Database**: PostgreSQL 16 + pgvector extension
- **Auth**: HTTP Basic (single shared in-memory user)
@@ -133,7 +146,7 @@ PictureGroup
See [specs/001-neuro-rag-learning/quickstart.md](specs/001-neuro-rag-learning/quickstart.md) for full instructions.
### Local Dev
### Local Dev (JVM)
```bash
# Start the database
@@ -149,6 +162,95 @@ npm install
npm run dev
```
### Native Image Build
Produces a GraalVM native binary packaged into a minimal Docker image via Jib.
**Prerequisite**: GraalVM 25 must be installed and set as `JAVA_HOME`.
```bash
# Install GraalVM 25 CE via sdkman (one-time)
sdk install java 25-graalce
sdk use java 25-graalce
# Build native executable + Docker image (requires Docker daemon)
cd backend
mvn -Pnative package jib:build -DskipTests
mvn -Pnative jib:build -Djib.to.auth.username=admin -Djib.to.auth.password=""
```
### Backend build (buildah)
**JVM image** (`Dockerfile` — Eclipse Temurin 21):
```bash
buildah build \
--platform linux/arm64 \
--tag zot.immich-ad.ovh/ai-teacher-backend:latest \
backend/
buildah login zot.immich-ad.ovh
buildah push --tls-verify=false zot.immich-ad.ovh/ai-teacher-backend:latest
```
**Native image** (`Dockerfile.native` — GraalVM 25, produces a minimal Debian-slim image):
```bash
buildah build \
--platform linux/arm64 \
--file backend/Dockerfile.native \
--tag zot.immich-ad.ovh/ai-teacher-backend-native:latest \
backend/
buildah push --tls-verify=false zot.immich-ad.ovh/ai-teacher-backend-native:latest
```
### Frontend build
```
buildah build \
--platform linux/arm64 \
--tag zot.immich-ad.ovh/ai-teacher-frontend:latest \
frontend/
buildah login zot.immich-ad.ovh
```
Push to the private repository:
```
buildah push --tls-verify=false zot.immich-ad.ovh/ai-teacher-frontend:latest
```
### Run Native Stack (Docker Compose)
```bash
# Copy and fill in secrets
cp .env.example .env
# edit .env — add OPENAI_API_KEY at minimum
# Start PostgreSQL + native backend
docker compose -f docker-compose.native.yml up
```
App available at `http://localhost:8080`.
### Build Pipeline (Native)
```mermaid
graph LR
SRC["Source Code\n(Java 25)"]
AOT["Spring Boot AOT\n(process-aot)"]
NI["GraalVM native-image\n(native-maven-plugin)"]
EXE["Native Executable\ntarget/ai-teacher-backend"]
JIB["Jib\n(jib-native-image-extension)"]
IMG["Docker Image\nai-teacher-backend:latest\n(distroless base)"]
SRC --> AOT
AOT --> NI
NI --> EXE
EXE --> JIB
JIB --> IMG
```
### Environment Variables
#### Backend
+24
View File
@@ -0,0 +1,24 @@
# Java build artifacts
target/
*.class
*.jar
# Git
.git/
.gitignore
# Editor
.vscode/
.idea/
*.iml
# OS
.DS_Store
Thumbs.db
# Logs
*.log
# Environment
.env
.env.*
+25
View File
@@ -0,0 +1,25 @@
# ---- Pull Maven from its official image (avoids microdnf under QEMU) ----
FROM docker.io/library/maven:3.9.9-eclipse-temurin-21 AS maven-dist
# ---- Build stage: GraalVM 25 + Maven ----
FROM ghcr.io/graalvm/native-image-community:25 AS build
# Copy Maven from the official Maven image — no package installation needed
COPY --from=maven-dist /usr/share/maven /opt/maven
ENV PATH="/opt/maven/bin:$PATH"
WORKDIR /app
# Cache dependency resolution separately from source compilation
COPY pom.xml .
RUN mvn -Pnative dependency:resolve dependency:resolve-plugins -q
# Build native executable
COPY src ./src
RUN mvn -Pnative package -DskipTests
# ---- Runtime stage: slim Debian with glibc + libz (required by GraalVM native binary) ----
FROM docker.io/library/debian:12-slim
COPY --from=build /app/target/ai-teacher-backend /app/ai-teacher-backend
EXPOSE 8080
ENTRYPOINT ["/app/ai-teacher-backend"]
+105 -1
View File
@@ -140,15 +140,119 @@
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<!-- Jib — package native executable (or fat-jar) into Docker image -->
<plugin>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-maven-plugin</artifactId>
<version>3.5.1</version>
<configuration>
<from>
<!-- distroless glibc base — includes libz + libssl needed by GraalVM native binary -->
<image>gcr.io/distroless/base-debian12</image>
</from>
<to>
<image>zot.immich-ad.ovh/ai-teacher-backend</image>
<tags>
<tag>latest</tag>
</tags>
</to>
<container>
<format>OCI</format>
<ports>
<port>8080</port>
</ports>
<!-- invoke the native binary directly — no JVM -->
<entrypoint>
<arg>/app/ai-teacher-backend</arg>
</entrypoint>
</container>
<!-- copy the GraalVM-compiled binary from target/ into /app/ -->
<extraDirectories>
<paths>
<path>
<from>${project.build.directory}</from>
<into>/app</into>
<includes>ai-teacher-backend</includes>
</path>
</paths>
<permissions>
<permission>
<file>/app/ai-teacher-backend</file>
<mode>755</mode>
</permission>
</permissions>
</extraDirectories>
</configuration>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>native</id>
<build>
<plugins>
<!-- skip jib in native builds — use Dockerfile.native + buildah instead -->
<plugin>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-maven-plugin</artifactId>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
<!-- GraalVM native-image compilation -->
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>1.0.0</version>
<executions>
<execution>
<id>add-reachability-metadata</id>
<goals>
<goal>add-reachability-metadata</goal>
</goals>
</execution>
<execution>
<id>compile</id>
<goals>
<goal>compile-no-fork</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
<configuration>
<imageName>ai-teacher-backend</imageName>
<buildArgs>
<buildArg>--initialize-at-build-time=org.slf4j,ch.qos.logback</buildArg>
<buildArg>-H:+ReportExceptionStackTraces</buildArg>
<buildArg>--gc=serial</buildArg>
<buildArg>-Os</buildArg>
<buildArg>-H:+RemoveUnusedSymbols</buildArg>
<buildArg>-H:-EnableLoggingFeature</buildArg>
<buildArg>-R:MaxHeapSize=128m</buildArg>
<buildArg>-R:MinHeapSize=32m</buildArg>
<!-- Limit native-image compiler RAM (build time, not runtime) -->
<buildArg>-J-Xmx8g</buildArg>
</buildArgs>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
@@ -1,11 +1,15 @@
package com.aiteacher;
import org.springframework.context.annotation.ImportRuntimeHints;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import com.aiteacher.config.NativeHintsConfig;
@SpringBootApplication
@EnableAsync
@ImportRuntimeHints(NativeHintsConfig.class)
public class AiTeacherApplication {
public static void main(String[] args) {
@@ -0,0 +1,19 @@
package com.aiteacher.auth;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.security.Principal;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
@GetMapping("/check")
public ResponseEntity<Map<String, String>> check(Principal principal) {
return ResponseEntity.ok(Map.of("username", principal.getName()));
}
}
@@ -92,7 +92,7 @@ public class BookEmbeddingService {
ChapterEntity chapter = new ChapterEntity(chapterId, bookId, 1, bookTitle, 1);
chapterRepository.save(chapter);
// Step 1: Parse with Marker — JSON (structured) + Markdown (per-page) in parallel
// Step 1: Parse with Marker — split into 100-page chunks, then merge results
ParsedBook parsed = markerPageParser.parse(pdfPath);
List<PageResult> pageResults = parsed.pages();
@@ -125,25 +125,32 @@ public class BookEmbeddingService {
log.info("Saved {} HTML pages to S3 for book {}", parsed.htmlByPage().size(), bookId);
// Step 5: Vision analysis (description + visible text) → embed figure chunks
for (FigureEntity figure : figures) {
byte[] imageBytes = figureStorageService.getBytes(figure.getImagePath());
VisionDescriptionService.ImageAnalysis analysis =
visionDescriptionService.analyze(imageBytes, figure.getCaption());
Map<String, SectionEntity> sectionById = new HashMap<>();
for (SectionEntity s : sections) sectionById.put(s.getId(), s);
for (FigureEntity figure : figures) {
// Prefer caption extracted from the linked section's full text
if (figure.getCaption() == null || figure.getCaption().isBlank()) {
figure.setCaption(analysis.description());
figureRepository.save(figure);
String sectionCaption = extractCaptionFromSection(sectionById.get(figure.getSectionId()));
if (sectionCaption != null) {
figure.setCaption(sectionCaption);
figureRepository.save(figure);
} else {
byte[] imageBytes = figureStorageService.getBytes(figure.getImagePath());
VisionDescriptionService.ImageAnalysis analysis =
visionDescriptionService.analyze(imageBytes, figure.getCaption());
figure.setCaption(analysis.description());
figureRepository.save(figure);
}
}
// Embedding content: description + caption + visible image text
String embeddingContent = analysis.description()
+ (figure.getCaption() != null ? "\n" + figure.getCaption() : "")
+ (analysis.imageText().isEmpty() ? "" : "\n" + analysis.imageText());
// Embedding content: description
String embeddingContent = (figure.getCaption() != null ? "\n" + figure.getCaption() : "");
String embeddingId = UUID.randomUUID().toString();
if (!skipEmbedding) {
Document figureDoc = new Document(embeddingId, embeddingContent,
buildFigureMetadata(figure, bookTitle, embeddingId, analysis.imageText()));
buildFigureMetadata(figure, bookTitle, embeddingId, ""));
vectorStore.add(List.of(figureDoc));
figure.setCaptionEmbeddingId(UUID.fromString(embeddingId));
}
@@ -163,7 +170,7 @@ public class BookEmbeddingService {
}
book.setStatus(BookStatus.READY);
book.setPageCount(sections.size());
book.setPageCount(parsed.htmlByPage().size());
book.setProcessedAt(Instant.now());
bookRepository.save(book);
@@ -210,7 +217,7 @@ public class BookEmbeddingService {
if (page.orderedText().isBlank()) continue;
String sectionId = bookId + "-p" + page.pageNumber();
String title = page.headingTitle() != null ? page.headingTitle() : "Page " + page.pageNumber();
String title = truncate(page.headingTitle() != null ? page.headingTitle() : "Page " + page.pageNumber(), 500);
SectionEntity section = new SectionEntity(
sectionId, chapterId, bookId,
@@ -271,6 +278,17 @@ public class BookEmbeddingService {
return html;
}
private String extractCaptionFromSection(SectionEntity section) {
if (section == null) return null;
for (String line : section.getFullText().split("\n")) {
String trimmed = line.strip();
if (trimmed.startsWith("Fig.") || trimmed.startsWith("Figure") || trimmed.startsWith("Algorithm")) {
return trimmed;
}
}
return null;
}
private String truncate(String msg, int max) {
if (msg == null) return null;
return msg.length() <= max ? msg : msg.substring(0, max);
@@ -5,10 +5,11 @@ import com.aiteacher.book.BookStatus;
import com.aiteacher.book.NoKnowledgeSourceException;
import com.aiteacher.document.FigureEntity;
import com.aiteacher.document.SectionEntity;
import com.aiteacher.retrieval.CitationValidatorService;
import com.aiteacher.retrieval.LabelledContext;
import com.aiteacher.retrieval.NeurosurgeryRetriever;
import com.aiteacher.retrieval.QueryExpansionService;
import com.aiteacher.retrieval.RetrievalResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
@@ -17,8 +18,6 @@ import java.util.*;
@Service
public class ChatService {
private static final Logger log = LoggerFactory.getLogger(ChatService.class);
private static final String SYSTEM_PROMPT = """
You are an expert neurosurgery educator assistant. Answer questions using the
medical textbook content provided to you as context.
@@ -29,8 +28,8 @@ public class ChatService {
- Build answers from what is present: procedures, conditions, techniques, and descriptions all contribute; combine them into a rich, structured response
- Use clear structure: headings, bullet points, or numbered steps where appropriate to maximize clarity
- Only say you cannot answer if the context is entirely unrelated to the question
- Cite sources for each major point (book title and page number from the context)
- When referencing diagrams or figures, cite them as [Fig. X, p.N]
- Cite sources for each major claim using the reference labels from the context (e.g. [S1], [F2]). Prefer these labels over inventing page numbers, but you may also describe the source naturally if needed.
- Figures (labeled [F1], [F2], etc.) are actual images and drawings from the textbook — they will be rendered as inline illustrations in your response. Use them actively to support your explanations: reference a figure when it visually demonstrates anatomy, a surgical step, or a clinical concept you are describing.
- Maintain continuity with the conversation history
- Never fabricate clinical information not present in the context
""";
@@ -40,17 +39,23 @@ public class ChatService {
private final ChatSessionRepository sessionRepository;
private final MessageRepository messageRepository;
private final NeurosurgeryRetriever retriever;
private final QueryExpansionService queryExpansionService;
private final CitationValidatorService citationValidatorService;
public ChatService(ChatClient chatClient,
BookRepository bookRepository,
ChatSessionRepository sessionRepository,
MessageRepository messageRepository,
NeurosurgeryRetriever retriever) {
NeurosurgeryRetriever retriever,
QueryExpansionService queryExpansionService,
CitationValidatorService citationValidatorService) {
this.chatClient = chatClient;
this.bookRepository = bookRepository;
this.sessionRepository = sessionRepository;
this.messageRepository = messageRepository;
this.retriever = retriever;
this.queryExpansionService = queryExpansionService;
this.citationValidatorService = citationValidatorService;
}
public ChatSession createSession(String topicId) {
@@ -85,25 +90,34 @@ public class ChatService {
List<Message> history = messageRepository.findBySessionIdOrderByCreatedAtAsc(sessionId);
String fullQuestion = buildQuestionWithHistory(history, userContent, session.getTopicId());
// Retrieve context from all ready books (aggregate across books)
// Expand only the current user question to clinical terminology for retrieval (US1).
// fullQuestion (which includes conversation history) is used for the LLM context prompt,
// but retrieval should be driven by a concise clinical rewrite of the actual question.
String retrievalQuery = queryExpansionService.expand(userContent).rewritten();
// Retrieve context from all ready books using the expanded query
List<SectionEntity> allSections = new ArrayList<>();
List<FigureEntity> allFigures = new ArrayList<>();
for (com.aiteacher.book.Book book : readyBooks) {
RetrievalResult result = retriever.retrieve(fullQuestion, book.getId());
RetrievalResult result = retriever.retrieve(retrievalQuery, book.getId());
allSections.addAll(result.parentSections());
allFigures.addAll(result.figures());
}
// Build LLM prompt with section full texts and figure references
String contextPrompt = buildContextPrompt(fullQuestion, allSections, allFigures);
// Build labelled context prompt (US2): assigns [S1]/[F1] labels to each source
LabelledContext ctx = buildContextPrompt(fullQuestion, allSections, allFigures);
String assistantContent = chatClient.prompt()
// Generate answer
String rawContent = chatClient.prompt()
.system(SYSTEM_PROMPT)
.user(contextPrompt)
.user(ctx.promptText())
.call()
.content();
// Build sources list with TEXT and FIGURE entries
// Strip any citation labels not present in the retrieved context (US2)
String assistantContent = citationValidatorService.validate(rawContent, ctx.allLabels());
// Attach sources with their ref-labels for frontend traceability
List<Map<String, Object>> sources = buildSources(allSections, allFigures);
Message assistantMessage = new Message(sessionId, MessageRole.ASSISTANT, assistantContent);
@@ -126,51 +140,71 @@ public class ChatService {
// Private helpers
// -------------------------------------------------------------------------
private String buildContextPrompt(String question,
List<SectionEntity> sections,
List<FigureEntity> figures) {
/**
* Builds the LLM context prompt, tagging each section as [S1], [S2]… and
* each figure as [F1], [F2]… so the model can cite only known sources.
*/
private LabelledContext buildContextPrompt(String question,
List<SectionEntity> sections,
List<FigureEntity> figures) {
Map<String, SectionEntity> sectionLabels = new LinkedHashMap<>();
Map<String, FigureEntity> figureLabels = new LinkedHashMap<>();
StringBuilder sb = new StringBuilder();
if (!sections.isEmpty()) {
sb.append("CONTEXT:\n\n");
for (SectionEntity section : sections) {
sb.append("[").append(section.getTitle())
.append(", p.").append(section.getPageStart()).append("]\n");
for (int i = 0; i < sections.size(); i++) {
SectionEntity section = sections.get(i);
String label = "S" + (i + 1);
sectionLabels.put(label, section);
sb.append("[").append(label).append("] ")
.append(section.getTitle())
.append(", p.").append(section.getPageStart()).append("\n");
sb.append(section.getFullText()).append("\n\n");
}
}
if (!figures.isEmpty()) {
sb.append("AVAILABLE FIGURES:\n");
for (FigureEntity figure : figures) {
sb.append("- ").append(figure.getLabel() != null ? figure.getLabel() : "Figure")
for (int i = 0; i < figures.size(); i++) {
FigureEntity figure = figures.get(i);
String label = "F" + (i + 1);
figureLabels.put(label, figure);
sb.append("[").append(label).append("] ")
.append(figure.getLabel() != null ? figure.getLabel() : "Figure")
.append(" (p.").append(figure.getPage()).append("): ")
.append(figure.getCaption() != null ? figure.getCaption() : "")
.append("\n");
}
sb.append("\nWhen referencing diagrams, cite them as [Fig. X, p.N].\n\n");
sb.append("\nWhen referencing diagrams, use their label from the context (e.g. [F1]).\n\n");
}
sb.append("QUESTION:\n").append(question);
return sb.toString();
return new LabelledContext(sectionLabels, figureLabels, sb.toString());
}
private List<Map<String, Object>> buildSources(List<SectionEntity> sections,
List<FigureEntity> figures) {
List<Map<String, Object>> sources = new ArrayList<>();
for (SectionEntity section : sections) {
for (int i = 0; i < sections.size(); i++) {
SectionEntity section = sections.get(i);
Map<String, Object> source = new LinkedHashMap<>();
source.put("type", "TEXT");
source.put("refLabel", "S" + (i + 1));
source.put("bookId", section.getBookId());
source.put("bookTitle", deriveTitleFromSection(section));
source.put("page", section.getPageStart());
source.put("chunkText", truncate(section.getFullText(), 500));
sources.add(source);
}
for (FigureEntity figure : figures) {
for (int i = 0; i < figures.size(); i++) {
FigureEntity figure = figures.get(i);
Map<String, Object> source = new LinkedHashMap<>();
source.put("type", "FIGURE");
source.put("refLabel", "F" + (i + 1));
source.put("bookId", figure.getBookId());
source.put("bookTitle", bookRepository.findById(figure.getBookId())
.map(com.aiteacher.book.Book::getTitle).orElse("Book"));
source.put("page", figure.getPage());
@@ -178,7 +212,6 @@ public class ChatService {
source.put("label", figure.getLabel() != null ? figure.getLabel() : "");
source.put("caption", figure.getCaption() != null ? figure.getCaption() : "");
source.put("figureType", figure.getFigureType().name());
// imageUrl assembled from relative path: figures/{bookId}/{filename}
String filename = figure.getImagePath().substring(
figure.getImagePath().lastIndexOf('/') + 1);
source.put("imageUrl", "/api/v1/figures/" + figure.getBookId() + "/" + filename);
@@ -0,0 +1,76 @@
package com.aiteacher.config;
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.aot.hint.TypeReference;
/**
* GraalVM native-image runtime hints for third-party libraries that use reflection
* or classpath resource scanning not covered by Spring Boot's AOT processor.
*
* Registered via @ImportRuntimeHints on AiTeacherApplication.
*/
public class NativeHintsConfig implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
// PDFBox — font and encoding resources loaded via classpath scanning at runtime
hints.resources().registerPattern("org/apache/pdfbox/resources/*");
hints.resources().registerPattern("org/apache/pdfbox/resources/afm/*");
hints.resources().registerPattern("org/apache/pdfbox/resources/cmap/*");
hints.resources().registerPattern("org/apache/pdfbox/resources/glyphlist/*");
hints.resources().registerPattern("org/apache/pdfbox/resources/icc/*");
hints.resources().registerPattern("org/apache/pdfbox/resources/ttf/*");
hints.resources().registerPattern("org/apache/pdfbox/resources/version.properties");
// PDFBox — font encoding classes instantiated via reflection
hints.reflection().registerType(
org.apache.pdfbox.pdmodel.font.encoding.GlyphList.class,
MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS,
MemberCategory.INVOKE_PUBLIC_METHODS
);
hints.reflection().registerType(
org.apache.pdfbox.pdmodel.font.encoding.WinAnsiEncoding.class,
MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS
);
hints.reflection().registerType(
org.apache.pdfbox.pdmodel.font.encoding.MacRomanEncoding.class,
MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS
);
hints.reflection().registerType(
org.apache.pdfbox.pdmodel.font.encoding.MacExpertEncoding.class,
MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS
);
hints.reflection().registerType(
org.apache.pdfbox.pdmodel.font.encoding.StandardEncoding.class,
MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS
);
// JPA / Hibernate — array types used in entity mappings
hints.reflection().registerType(java.util.UUID[].class, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS);
// JBoss Logging — message logger implementations generated by annotation processor.
// JBoss Logging uses reflection to look up the generated *_$logger class by name.
registerJBossLogger(hints, "org.hibernate.jpa.internal.JpaLogger_$logger");
registerJBossLogger(hints, "org.hibernate.internal.CoreMessageLogger_$logger");
registerJBossLogger(hints, "org.hibernate.internal.EntityManagerMessageLogger_$logger");
// AWS SDK v2 — HTTP client and SdkPojo serialization
hints.resources().registerPattern("software/amazon/awssdk/global/handlers/execution.interceptors");
hints.resources().registerPattern("software/amazon/awssdk/services/s3/execution.interceptors");
hints.resources().registerPattern("codegen-resources/s3/*");
hints.reflection().registerType(
software.amazon.awssdk.services.s3.S3Client.class,
MemberCategory.INVOKE_PUBLIC_METHODS
);
}
private void registerJBossLogger(RuntimeHints hints, String className) {
hints.reflection().registerType(
TypeReference.of(className),
MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS,
MemberCategory.INVOKE_PUBLIC_METHODS
);
}
}
@@ -30,9 +30,10 @@ public class SecurityConfig {
@Bean
public UserDetailsService userDetailsService(
@Value("${app.auth.username}") String username,
@Value("${app.auth.password}") String password) {
UserDetails user = User.builder()
.username("neurosurgeon")
.username(username)
.password("{noop}" + password)
.roles("USER")
.build();
@@ -17,6 +17,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
/**
* Parses a PDF with a single call to the Marker server using {@code output_format=json}.
*
@@ -46,19 +47,65 @@ public class MarkerPageParser {
);
private static final Set<String> FIGURE_BLOCK_TYPES = Set.of("Figure", "Picture", "FigureGroup", "PictureGroup");
private static final int CHUNK_SIZE = 100;
private static final ObjectMapper MAPPER = new ObjectMapper();
private final RestClient restClient;
private final PdfSplitterService pdfSplitterService;
public MarkerPageParser(@Qualifier("markerRestClient") RestClient restClient) {
public MarkerPageParser(@Qualifier("markerRestClient") RestClient restClient,
PdfSplitterService pdfSplitterService) {
this.restClient = restClient;
this.pdfSplitterService = pdfSplitterService;
}
public ParsedBook parse(Path pdfPath) {
log.info("Submitting {} to Marker (json)", pdfPath.getFileName());
/**
* Parses a PDF by splitting it into {@value #CHUNK_SIZE}-page chunks, submitting each
* chunk to Marker individually, and merging the results into a single {@link ParsedBook}.
* Page numbers in the merged result are absolute (1-based across the whole document).
*/
public ParsedBook parse(Path pdfPath) throws IOException {
List<PdfSplitterService.PdfChunk> chunks = pdfSplitterService.split(pdfPath, CHUNK_SIZE);
log.info("Processing {} chunk(s) for {}", chunks.size(), pdfPath.getFileName());
List<PageResult> allPages = new ArrayList<>();
Map<Integer, String> allHtml = new LinkedHashMap<>();
try {
for (int c = 0; c < chunks.size(); c++) {
PdfSplitterService.PdfChunk chunk = chunks.get(c);
log.info("Submitting chunk {}/{} to Marker (page offset {})", c + 1, chunks.size(), chunk.pageOffset());
ParsedBook chunkResult = submitChunk(chunk.tempFile());
// Rebase page numbers from chunk-relative to document-absolute
for (PageResult page : chunkResult.pages()) {
int absolutePage = chunk.pageOffset() + page.pageNumber();
allPages.add(new PageResult(absolutePage, page.orderedText(), page.headingTitle(), page.figures()));
}
chunkResult.htmlByPage().forEach((chunkPage, html) ->
allHtml.put(chunk.pageOffset() + chunkPage, html));
}
} finally {
// Delete temporary chunk files (skip if the chunk is the original PDF)
for (PdfSplitterService.PdfChunk chunk : chunks) {
if (!chunk.tempFile().equals(pdfPath)) {
try { Files.deleteIfExists(chunk.tempFile()); }
catch (IOException e) { log.warn("Could not delete temp chunk {}", chunk.tempFile()); }
}
}
}
log.info("Marker produced {} non-empty pages from {} chunk(s) of {}",
allPages.size(), chunks.size(), pdfPath.getFileName());
return new ParsedBook(allPages, allHtml);
}
/** Submits a single PDF file to Marker and returns the parsed result with chunk-relative page numbers. */
private ParsedBook submitChunk(Path chunkPath) {
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", new FileSystemResource(pdfPath));
body.add("file", new FileSystemResource(chunkPath));
body.add("output_format", "json");
JsonNode response = restClient.post()
@@ -76,28 +123,29 @@ public class MarkerPageParser {
List<JsonNode> pageNodes = extractPages(response);
if (pageNodes.isEmpty()) {
log.warn("Marker returned no pages for {}", pdfPath.getFileName());
log.warn("Marker returned no pages for chunk {}", chunkPath.getFileName());
return new ParsedBook(List.of(), Map.of());
}
log.info("Marker returned {} pages for {}", pageNodes.size(), pdfPath.getFileName());
List<PageResult> pages = new ArrayList<>();
Map<Integer, String> htmlByPage = new LinkedHashMap<>();
for (int i = 0; i < pageNodes.size(); i++) {
JsonNode pageNode = pageNodes.get(i);
int pageNumber = i + 1; // 1-based
int pageNumber = i + 1; // 1-based, chunk-relative
PageResult result = buildPageResult(pageNode, pageNumber);
String html = jsonToHtml(pageNode);
// Always save HTML so the reader can navigate to every page
htmlByPage.put(pageNumber, html);
// Only queue for embedding if the page has extractable content
if (!result.orderedText().isBlank() || !result.figures().isEmpty()) {
pages.add(result);
htmlByPage.put(pageNumber, html);
}
}
log.info("Marker produced {} non-empty pages from {}", pages.size(), pdfPath.getFileName());
return new ParsedBook(pages, htmlByPage);
}
@@ -0,0 +1,72 @@
package com.aiteacher.document;
import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
import org.apache.pdfbox.multipdf.Splitter;
import org.apache.pdfbox.pdfparser.PDFParser;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* Splits a PDF file into fixed-size chunks using PDFBox.
* Each chunk is saved as a temporary file so it can be submitted independently to Marker.
*/
@Service
public class PdfSplitterService {
private static final Logger log = LoggerFactory.getLogger(PdfSplitterService.class);
/**
* A chunk of a split PDF.
*
* @param tempFile path to the temporary PDF file (caller must delete when done)
* @param pageOffset 0-based index of the first page in this chunk within the original document
*/
public record PdfChunk(Path tempFile, int pageOffset) {}
/**
* Splits {@code pdfPath} into chunks of at most {@code maxPagesPerChunk} pages.
* Returns a single-element list when the document fits in one chunk.
*
* @param pdfPath source PDF
* @param maxPagesPerChunk maximum pages per chunk
* @return ordered list of chunks; caller is responsible for deleting {@code tempFile}s
*/
public List<PdfChunk> split(Path pdfPath, int maxPagesPerChunk) throws IOException {
try (PDDocument doc = new PDFParser(new RandomAccessReadBufferedFile(pdfPath.toFile())).parse()) {
int totalPages = doc.getNumberOfPages();
log.info("PDF {} has {} pages, splitting into chunks of {}", pdfPath.getFileName(), totalPages, maxPagesPerChunk);
if (totalPages <= maxPagesPerChunk) {
// No split needed — return the original file as a single virtual chunk
return List.of(new PdfChunk(pdfPath, 0));
}
Splitter splitter = new Splitter();
splitter.setSplitAtPage(maxPagesPerChunk);
List<PDDocument> parts = splitter.split(doc);
List<PdfChunk> chunks = new ArrayList<>(parts.size());
int offset = 0;
for (PDDocument part : parts) {
try {
Path tmp = Files.createTempFile("marker-chunk-", ".pdf");
part.save(tmp.toFile());
chunks.add(new PdfChunk(tmp, offset));
log.debug("Created chunk at {} (page offset {})", tmp, offset);
offset += part.getNumberOfPages();
} finally {
part.close();
}
}
return chunks;
}
}
}
@@ -1,6 +1,8 @@
package com.aiteacher.document;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.UUID;
@@ -8,4 +10,10 @@ import java.util.UUID;
public interface SectionRepository extends JpaRepository<SectionEntity, String> {
List<SectionEntity> findAllByBookId(UUID bookId);
void deleteAllByBookId(UUID bookId);
@Query("SELECT s FROM SectionEntity s WHERE s.bookId = :bookId AND s.pageStart <= :windowEnd AND s.pageEnd >= :windowStart ORDER BY s.pageStart")
List<SectionEntity> findByBookIdAndPageOverlap(
@Param("bookId") UUID bookId,
@Param("windowStart") int windowStart,
@Param("windowEnd") int windowEnd);
}
@@ -3,6 +3,7 @@ package com.aiteacher.document;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.stereotype.Service;
import org.springframework.util.MimeTypeUtils;
@@ -32,10 +33,16 @@ public class VisionDescriptionService {
IMAGE_TEXT: <all visible text, labels, measurements, and annotations copied verbatim, comma-separated; write NONE if no text visible>
""";
/** Minimum ms between vision API calls. Configurable via app.vision.min-interval-ms. */
private final long minIntervalMs;
private final ChatClient chatClient;
private volatile long lastCallAt = 0;
public VisionDescriptionService(ChatClient chatClient) {
public VisionDescriptionService(
ChatClient chatClient,
@Value("${app.vision.min-interval-ms:2000}") long minIntervalMs) {
this.chatClient = chatClient;
this.minIntervalMs = minIntervalMs;
}
/**
@@ -55,6 +62,7 @@ public class VisionDescriptionService {
* @param captionFallback caption detected from surrounding text, may be null
*/
public ImageAnalysis analyze(byte[] imageBytes, String captionFallback) {
throttle();
try {
String raw = chatClient.prompt()
.user(u -> u
@@ -71,6 +79,15 @@ public class VisionDescriptionService {
}
}
private synchronized void throttle() {
long now = System.currentTimeMillis();
long wait = minIntervalMs - (now - lastCallAt);
if (wait > 0) {
try { Thread.sleep(wait); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
lastCallAt = System.currentTimeMillis();
}
private ImageAnalysis parse(String raw, String captionFallback) {
String description = captionFallback != null ? captionFallback : "Figure";
String imageText = "";
@@ -0,0 +1,59 @@
package com.aiteacher.retrieval;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Post-processes generated answers to strip citation labels that do not
* correspond to any passage retrieved for the current query, preventing
* hallucinated source references from reaching the user.
*/
@Service
public class CitationValidatorService {
private static final Logger log = LoggerFactory.getLogger(CitationValidatorService.class);
/** Matches citation labels of the form [S1], [F2], [S12], etc. */
private static final Pattern CITATION_PATTERN = Pattern.compile("\\[(S|F)\\d+\\]");
/**
* Removes any {@code [Sx]} / {@code [Fx]} citation in {@code generatedAnswer}
* whose label is not contained in {@code validLabels}.
*
* @param generatedAnswer raw model output
* @param validLabels set of labels present in the retrieved context
* @return cleaned answer text with hallucinated citations removed
*/
public String validate(String generatedAnswer, Set<String> validLabels) {
if (generatedAnswer == null) return "";
Matcher matcher = CITATION_PATTERN.matcher(generatedAnswer);
List<String> removed = new ArrayList<>();
StringBuffer sb = new StringBuffer();
while (matcher.find()) {
String label = matcher.group();
String inner = label.substring(1, label.length() - 1); // strip [ ]
if (validLabels.contains(inner)) {
matcher.appendReplacement(sb, Matcher.quoteReplacement(label));
} else {
removed.add(inner);
matcher.appendReplacement(sb, "");
}
}
matcher.appendTail(sb);
if (!removed.isEmpty()) {
log.warn("Stripped hallucinated citations: {}", removed);
}
return sb.toString();
}
}
@@ -0,0 +1,7 @@
package com.aiteacher.retrieval;
/**
* Value object holding the original user query alongside its clinically
* rewritten variant used for vector-store retrieval.
*/
public record ExpandedQuery(String original, String rewritten) {}
@@ -0,0 +1,27 @@
package com.aiteacher.retrieval;
import com.aiteacher.document.FigureEntity;
import com.aiteacher.document.SectionEntity;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/**
* Value object produced when building the LLM context prompt.
* Maps short ref-labels (S1, S2… / F1, F2…) to their source entities
* and carries the fully formatted prompt text.
*/
public record LabelledContext(
Map<String, SectionEntity> sectionLabels,
Map<String, FigureEntity> figureLabels,
String promptText) {
/** Returns the union of all valid citation labels for this context. */
public Set<String> allLabels() {
Set<String> labels = new HashSet<>();
labels.addAll(sectionLabels.keySet());
labels.addAll(figureLabels.keySet());
return labels;
}
}
@@ -0,0 +1,47 @@
package com.aiteacher.retrieval;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
/**
* Rewrites a user query into precise clinical/surgical terminology so that
* vector-store retrieval can match textbook language even when the user's
* phrasing differs from the documentation vocabulary.
*/
@Service
public class QueryExpansionService {
private static final Logger log = LoggerFactory.getLogger(QueryExpansionService.class);
private static final String EXPANSION_PROMPT = """
Rewrite the following question using precise medical and surgical terminology \
as it would appear in a neurosurgery textbook index. \
Output only the rewritten question, nothing else.
Question: %s""";
private final ChatClient chatClient;
public QueryExpansionService(ChatClient chatClient) {
this.chatClient = chatClient;
}
/**
* Returns an {@link ExpandedQuery} whose {@code rewritten} field contains
* the clinically rephrased version of {@code query}.
*/
public ExpandedQuery expand(String query) {
String rewritten = chatClient.prompt()
.user(EXPANSION_PROMPT.formatted(query))
.call()
.content();
if (rewritten == null || rewritten.isBlank()) {
rewritten = query;
}
log.debug("Query expanded: '{}' → '{}'", query, rewritten);
return new ExpandedQuery(query, rewritten);
}
}
@@ -0,0 +1,7 @@
package com.aiteacher.topic;
import java.time.Instant;
import java.util.UUID;
public record SavedSummaryItem(UUID id, int summaryNumber, Instant generatedAt) {
}
@@ -5,6 +5,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/topics")
@@ -32,4 +33,21 @@ public class TopicController {
TopicSummaryResponse response = topicSummaryService.generateSummary(topic);
return ResponseEntity.ok(response);
}
@GetMapping("/{id}/summaries")
public ResponseEntity<List<SavedSummaryItem>> listSummaries(@PathVariable String id) {
topicRepository.findById(id)
.orElseThrow(() -> new NoSuchElementException("Topic not found."));
return ResponseEntity.ok(topicSummaryService.listSummaries(id));
}
@GetMapping("/{id}/summaries/{summaryId}")
public ResponseEntity<TopicSummaryResponse> getSummary(@PathVariable String id,
@PathVariable UUID summaryId) {
topicRepository.findById(id)
.orElseThrow(() -> new NoSuchElementException("Topic not found."));
return ResponseEntity.ok(topicSummaryService.getSummary(summaryId));
}
}
@@ -0,0 +1,53 @@
package com.aiteacher.topic;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "topic_summary")
public class TopicSummaryEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "topic_id", nullable = false)
private String topicId;
@Column(name = "summary_number", nullable = false)
private int summaryNumber;
@Column(nullable = false, columnDefinition = "TEXT")
private String summary;
@Column(name = "sources_json", nullable = false, columnDefinition = "TEXT")
private String sourcesJson;
@Column(name = "generated_at", nullable = false)
private Instant generatedAt;
protected TopicSummaryEntity() {}
public TopicSummaryEntity(String topicId, int summaryNumber, String summary,
String sourcesJson, Instant generatedAt) {
this.topicId = topicId;
this.summaryNumber = summaryNumber;
this.summary = summary;
this.sourcesJson = sourcesJson;
this.generatedAt = generatedAt;
}
public UUID getId() { return id; }
public String getTopicId() { return topicId; }
public int getSummaryNumber() { return summaryNumber; }
public String getSummary() { return summary; }
public String getSourcesJson() { return sourcesJson; }
public Instant getGeneratedAt() { return generatedAt; }
}
@@ -0,0 +1,13 @@
package com.aiteacher.topic;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface TopicSummaryRepository extends JpaRepository<TopicSummaryEntity, UUID> {
List<TopicSummaryEntity> findByTopicIdOrderBySummaryNumberAsc(String topicId);
long countByTopicId(String topicId);
}
@@ -2,8 +2,11 @@ package com.aiteacher.topic;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record TopicSummaryResponse(
UUID id,
int summaryNumber,
String topicId,
String topicName,
String summary,
@@ -11,8 +14,17 @@ public record TopicSummaryResponse(
Instant generatedAt
) {
public record SourceReference(
String type,
String refLabel,
String bookId,
String bookTitle,
Integer page
Integer page,
String chunkText,
String figureId,
String label,
String caption,
String figureType,
String imageUrl
) {
}
}
@@ -1,21 +1,25 @@
package com.aiteacher.topic;
import com.aiteacher.book.Book;
import com.aiteacher.book.BookRepository;
import com.aiteacher.book.BookStatus;
import com.aiteacher.book.NoKnowledgeSourceException;
import com.aiteacher.document.FigureEntity;
import com.aiteacher.document.SectionEntity;
import com.aiteacher.retrieval.NeurosurgeryRetriever;
import com.aiteacher.retrieval.RetrievalResult;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.UUID;
@Service
public class TopicSummaryService {
@@ -29,80 +33,205 @@ public class TopicSummaryService {
When answering:
- Structure your response clearly with key points
- If the context mentions specific book titles and page numbers, reference them
- Cite claims using ONLY the reference labels provided in the context (e.g. [S1], [F2]).
Do not invent page numbers, section titles, or labels not present in the CONTEXT block.
- Figures (labeled [F1], [F2], etc.) are actual images and drawings from the textbook they will be rendered as inline illustrations in your response. Use them actively to support your explanations: reference a figure when it visually demonstrates anatomy, a surgical step, or a clinical concept you are describing.
- If the retrieved context does not contain sufficient information on the topic,
explicitly state: "The uploaded books do not contain sufficient information on this topic."
- Never hallucinate or fabricate clinical information
""";
private final ChatClient chatClient;
private final VectorStore vectorStore;
private final BookRepository bookRepository;
private final NeurosurgeryRetriever retriever;
private final TopicSummaryRepository summaryRepository;
private final ObjectMapper objectMapper;
public TopicSummaryService(ChatClient chatClient, VectorStore vectorStore,
BookRepository bookRepository) {
public TopicSummaryService(ChatClient chatClient,
BookRepository bookRepository,
NeurosurgeryRetriever retriever,
TopicSummaryRepository summaryRepository,
ObjectMapper objectMapper) {
this.chatClient = chatClient;
this.vectorStore = vectorStore;
this.bookRepository = bookRepository;
this.retriever = retriever;
this.summaryRepository = summaryRepository;
this.objectMapper = objectMapper;
}
public TopicSummaryResponse generateSummary(Topic topic) {
if (!bookRepository.existsByStatus(BookStatus.READY)) {
List<Book> readyBooks = bookRepository.findAll().stream()
.filter(b -> b.getStatus() == BookStatus.READY)
.toList();
if (readyBooks.isEmpty()) {
throw new NoKnowledgeSourceException(
"No books are available as knowledge sources. Please upload and process at least one book.");
}
String question = buildQuestion(topic);
ChatResponse response = chatClient.prompt()
.system(SYSTEM_PROMPT)
.advisors(QuestionAnswerAdvisor.builder(vectorStore).build())
.user(question)
.call()
.chatResponse();
List<SectionEntity> allSections = new ArrayList<>();
List<FigureEntity> allFigures = new ArrayList<>();
for (Book book : readyBooks) {
RetrievalResult result = retriever.retrieve(question, book.getId());
allSections.addAll(result.parentSections());
allFigures.addAll(result.figures());
}
String summary = response.getResult().getOutput().getText();
List<TopicSummaryResponse.SourceReference> sources = extractSources(response);
log.debug("Topic summary for '{}': {} sections, {} figures retrieved",
topic.getName(), allSections.size(), allFigures.size());
String contextPrompt = buildContextPrompt(question, allSections, allFigures);
String summary = chatClient.prompt()
.system(SYSTEM_PROMPT)
.user(contextPrompt)
.call()
.content();
List<TopicSummaryResponse.SourceReference> sources = buildSources(allSections, allFigures, readyBooks);
Instant generatedAt = Instant.now();
int summaryNumber = (int) summaryRepository.countByTopicId(topic.getId()) + 1;
String sourcesJson = serializeSources(sources);
TopicSummaryEntity entity = new TopicSummaryEntity(
topic.getId(), summaryNumber, summary, sourcesJson, generatedAt);
entity = summaryRepository.save(entity);
return new TopicSummaryResponse(
entity.getId(),
summaryNumber,
topic.getId(),
topic.getName(),
summary,
sources,
Instant.now()
generatedAt
);
}
public List<SavedSummaryItem> listSummaries(String topicId) {
return summaryRepository.findByTopicIdOrderBySummaryNumberAsc(topicId).stream()
.map(e -> new SavedSummaryItem(e.getId(), e.getSummaryNumber(), e.getGeneratedAt()))
.toList();
}
public TopicSummaryResponse getSummary(UUID summaryId) {
TopicSummaryEntity entity = summaryRepository.findById(summaryId)
.orElseThrow(() -> new NoSuchElementException("Summary not found."));
List<TopicSummaryResponse.SourceReference> sources = deserializeSources(entity.getSourcesJson());
return new TopicSummaryResponse(
entity.getId(),
entity.getSummaryNumber(),
entity.getTopicId(),
entity.getTopicId(),
entity.getSummary(),
sources,
entity.getGeneratedAt()
);
}
private String buildQuestion(Topic topic) {
return String.format(
"Please provide a comprehensive educational summary of the following neurosurgery topic: " +
"Provide a comprehensive educational summary of the following neurosurgery topic: " +
"%s. Topic description: %s. " +
"Include key concepts, clinical considerations, and important details that a neurosurgeon should know.",
"Include key concepts, diagrams, illustations and clinical considerations, and important details that a neurosurgeon should know.",
topic.getName(), topic.getDescription()
);
}
private List<TopicSummaryResponse.SourceReference> extractSources(ChatResponse response) {
List<TopicSummaryResponse.SourceReference> sources = new ArrayList<>();
private String buildContextPrompt(String question,
List<SectionEntity> sections,
List<FigureEntity> figures) {
StringBuilder sb = new StringBuilder();
if (response.getMetadata() != null) {
Object retrieved = response.getMetadata().get(QuestionAnswerAdvisor.RETRIEVED_DOCUMENTS);
if (retrieved instanceof List<?> docs) {
for (Object docObj : docs) {
if (docObj instanceof Document doc) {
Map<String, Object> metadata = doc.getMetadata();
String bookTitle = (String) metadata.get("book_title");
Object pageObj = metadata.get("page_number");
Integer page = pageObj instanceof Number n ? n.intValue() : null;
if (bookTitle != null) {
sources.add(new TopicSummaryResponse.SourceReference(bookTitle, page));
}
}
}
if (!sections.isEmpty()) {
sb.append("CONTEXT:\n\n");
for (int i = 0; i < sections.size(); i++) {
SectionEntity s = sections.get(i);
sb.append("[S").append(i + 1).append("] ")
.append(s.getTitle()).append(", p.").append(s.getPageStart()).append("\n");
sb.append(s.getFullText()).append("\n\n");
}
}
// Deduplicate by bookTitle + page
return sources.stream().distinct().toList();
if (!figures.isEmpty()) {
sb.append("AVAILABLE FIGURES:\n");
for (int i = 0; i < figures.size(); i++) {
FigureEntity f = figures.get(i);
sb.append("[F").append(i + 1).append("] ")
.append(f.getLabel() != null ? f.getLabel() : "Figure")
.append(" (p.").append(f.getPage()).append("): ")
.append(f.getCaption() != null ? f.getCaption() : "")
.append("\n");
}
sb.append("\nWhen referencing diagrams, use their label from the context (e.g. [F1]).\n\n");
}
sb.append("QUESTION:\n").append(question);
return sb.toString();
}
private List<TopicSummaryResponse.SourceReference> buildSources(List<SectionEntity> sections,
List<FigureEntity> figures,
List<Book> readyBooks) {
List<TopicSummaryResponse.SourceReference> sources = new ArrayList<>();
for (int i = 0; i < sections.size(); i++) {
SectionEntity s = sections.get(i);
Book book = readyBooks.stream()
.filter(b -> b.getId().equals(s.getBookId()))
.findFirst()
.orElse(null);
String title = book != null ? book.getTitle() : "Book";
String bookId = book != null ? book.getId().toString() : null;
sources.add(new TopicSummaryResponse.SourceReference(
"TEXT", "S" + (i + 1), bookId, title, s.getPageStart(),
truncate(s.getFullText(), 500), null, null, null, null, null));
}
for (int i = 0; i < figures.size(); i++) {
FigureEntity f = figures.get(i);
Book book = readyBooks.stream()
.filter(b -> b.getId().equals(f.getBookId()))
.findFirst()
.orElse(null);
String title = book != null ? book.getTitle() : "Book";
String bookId = book != null ? book.getId().toString() : null;
String filename = f.getImagePath().substring(f.getImagePath().lastIndexOf('/') + 1);
String imageUrl = "/api/v1/figures/" + f.getBookId() + "/" + filename;
sources.add(new TopicSummaryResponse.SourceReference(
"FIGURE", "F" + (i + 1), bookId, title, f.getPage(),
null, f.getId(), f.getLabel(), f.getCaption(),
f.getFigureType().name(), imageUrl));
}
return sources;
}
private String serializeSources(List<TopicSummaryResponse.SourceReference> sources) {
try {
return objectMapper.writeValueAsString(sources);
} catch (JsonProcessingException e) {
log.warn("Failed to serialize sources, storing empty array", e);
return "[]";
}
}
private String truncate(String text, int maxChars) {
if (text == null) return "";
return text.length() <= maxChars ? text : text.substring(0, maxChars) + "";
}
private List<TopicSummaryResponse.SourceReference> deserializeSources(String json) {
try {
return objectMapper.readValue(json,
objectMapper.getTypeFactory().constructCollectionType(
List.class, TopicSummaryResponse.SourceReference.class));
} catch (JsonProcessingException e) {
log.warn("Failed to deserialize sources from stored JSON", e);
return List.of();
}
}
}
+10 -7
View File
@@ -27,10 +27,10 @@ spring:
index-type: HNSW
initialize-schema: false
openai:
api-key: ${OPENAI_API_KEY}
api-key: ${OPENAI_API_KEY:}
chat:
options:
model: gpt-4o
model: gpt-4o-mini
embedding:
options:
model: "text-embedding-3-small"
@@ -56,17 +56,20 @@ app:
upload-enabled: ${UPLOAD_ENABLED:true}
delete-enabled: ${DELETE_ENABLED:true}
auth:
username: ${APP_AUTH_USERNAME:neurosurgeon}
password: ${APP_PASSWORD:changeme}
figure-storage:
endpoint: https://s3.immich-ad.ovh
region: garage
endpoint: ${S3_ENDPOINT:https://s3.immich-ad.ovh}
region: ${S3_REGION:garage}
bucket: ${S3_BUCKET:aiteacher}
access-key-id: ${S3_ACCESS_KEY_ID}
secret-access-key: ${S3_SECRET_ACCESS_KEY}
access-key-id: ${S3_ACCESS_KEY_ID:}
secret-access-key: ${S3_SECRET_ACCESS_KEY:}
min-image-size-px: 100
embedding:
batch-size: 20
batch-delay-ms: 2000
skip-embedding: true
skip-embedding: false
marker:
base-url: ${MARKER_BASE_URL:http://192.168.1.105:8000}
vision:
min-interval-ms: ${VISION_MIN_INTERVAL_MS:2000}
@@ -0,0 +1,10 @@
CREATE TABLE topic_summary (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
topic_id VARCHAR(100) NOT NULL,
summary_number INT NOT NULL,
summary TEXT NOT NULL,
sources_json TEXT NOT NULL,
generated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_topic_summary_topic_id ON topic_summary(topic_id, summary_number);
+37
View File
@@ -0,0 +1,37 @@
version: '3.9'
services:
postgres:
image: pgvector/pgvector:pg16
container_name: aiteacher-postgres-native
environment:
POSTGRES_DB: aiteacher
POSTGRES_USER: aiteacher
POSTGRES_PASSWORD: aiteacher
ports:
- "5432:5432"
volumes:
- pgdata_native:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U aiteacher -d aiteacher"]
interval: 10s
timeout: 5s
retries: 5
backend:
image: ai-teacher-backend:latest
container_name: aiteacher-backend-native
env_file:
- .env
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/aiteacher
SPRING_DATASOURCE_USERNAME: aiteacher
SPRING_DATASOURCE_PASSWORD: aiteacher
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
volumes:
pgdata_native:
+3 -2
View File
@@ -3,8 +3,9 @@
# In production point it directly at the backend, e.g. https://api.example.com/api/v1
VITE_API_URL=/api/v1
# Shared password for HTTP Basic auth (must match APP_PASSWORD on the backend).
VITE_APP_PASSWORD=changeme
# Credentials are no longer configured here. Users enter their username and
# password via the login form. The backend validates them via HTTP Basic Auth.
# Configure the backend credentials with APP_AUTH_USERNAME and APP_PASSWORD.
# Set to 'false' to hide the upload UI (frontend). Also set UPLOAD_ENABLED=false on the backend to block the endpoint.
VITE_UPLOAD_ENABLED=true
+5 -3
View File
@@ -1,5 +1,5 @@
# ---- Build stage ----
FROM node:20-alpine AS build
FROM docker.io/library/node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
@@ -7,8 +7,10 @@ COPY . .
RUN npm run build
# ---- Runtime stage (nginx) ----
FROM nginx:alpine
FROM docker.io/library/nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
ENTRYPOINT ["/docker-entrypoint.sh"]
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
set -e
# Write runtime env vars into a JS file loaded before the app bundle.
# Any VITE_* variable passed via `docker run -e` will be available as
# window.__env__.VITE_* inside the browser.
cat > /usr/share/nginx/html/env-config.js <<EOF
window.__env__ = {
VITE_API_URL: "${VITE_API_URL:-}",
VITE_APP_PASSWORD: "${VITE_APP_PASSWORD:-}",
VITE_UPLOAD_ENABLED: "${VITE_UPLOAD_ENABLED:-}",
VITE_DELETE_ENABLED: "${VITE_DELETE_ENABLED:-}"
};
EOF
exec nginx -g "daemon off;"
+1
View File
@@ -8,6 +8,7 @@
</head>
<body>
<div id="app"></div>
<script src="/env-config.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+151 -19
View File
@@ -6,23 +6,31 @@
<span class="brand-name">AI Teacher</span>
<span class="brand-subtitle">Neurosurgeon Learning Platform</span>
</div>
<ul class="navbar-links">
<li>
<RouterLink to="/" :class="{ active: $route.path === '/' }">
<span class="nav-icon">📚</span> Library
</RouterLink>
</li>
<li>
<RouterLink to="/topics" :class="{ active: $route.path === '/topics' }">
<span class="nav-icon">🗂</span> Topics
</RouterLink>
</li>
<li>
<RouterLink to="/chat" :class="{ active: $route.path === '/chat' }">
<span class="nav-icon">💬</span> Chat
</RouterLink>
</li>
</ul>
<template v-if="authStore.isAuthenticated">
<button class="burger" :class="{ open: menuOpen }" @click="menuOpen = !menuOpen" aria-label="Menu">
<span></span><span></span><span></span>
</button>
<div class="nav-drawer" :class="{ open: menuOpen }" @click="menuOpen = false">
<ul class="navbar-links">
<li>
<RouterLink to="/" :class="{ active: $route.path === '/' }">
<span class="nav-icon">📚</span> Library
</RouterLink>
</li>
<li>
<RouterLink to="/topics" :class="{ active: $route.path === '/topics' }">
<span class="nav-icon">🗂</span> Topics
</RouterLink>
</li>
<li>
<RouterLink to="/chat" :class="{ active: $route.path === '/chat' }">
<span class="nav-icon">💬</span> Chat
</RouterLink>
</li>
</ul>
<button class="btn btn-logout" @click.stop="logout">Sign out</button>
</div>
</template>
</nav>
<main class="main-content">
@@ -35,12 +43,26 @@
</template>
<script setup lang="ts">
import { ref, provide } from 'vue'
import { RouterLink, RouterView } from 'vue-router'
import { ref, provide, watch } from 'vue'
import { RouterLink, RouterView, useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const authStore = useAuthStore()
const router = useRouter()
const route = useRoute()
const menuOpen = ref(false)
const toastMessage = ref('')
const toastType = ref<'toast-error' | 'toast-success'>('toast-error')
// Close menu on navigation
watch(() => route.path, () => { menuOpen.value = false })
function logout() {
authStore.clearCredentials()
router.push({ name: 'login' })
}
function showToast(message: string, type: 'error' | 'success' = 'error') {
toastMessage.value = message
toastType.value = type === 'error' ? 'toast-error' : 'toast-success'
@@ -82,6 +104,9 @@ body {
justify-content: space-between;
height: 64px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
position: sticky;
top: 0;
z-index: 100;
}
.navbar-brand {
@@ -106,6 +131,13 @@ body {
margin-left: 0.25rem;
}
/* Desktop: links inline */
.nav-drawer {
display: flex;
align-items: center;
gap: 0.5rem;
}
.navbar-links {
list-style: none;
display: flex;
@@ -131,6 +163,33 @@ body {
color: white;
}
/* Burger button — hidden on desktop */
.burger {
display: none;
flex-direction: column;
justify-content: center;
gap: 5px;
width: 36px;
height: 36px;
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 6px;
}
.burger span {
display: block;
height: 2px;
background: #bee3f8;
border-radius: 2px;
transition: transform 0.2s, opacity 0.2s;
}
.burger.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.burger.open span:nth-child(2) { opacity: 0; }
.burger.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
.main-content {
flex: 1;
min-height: 0;
@@ -227,6 +286,20 @@ body {
background: #cbd5e0;
}
.btn-logout {
background: transparent;
color: #bee3f8;
border: 1px solid #4a90b8;
font-size: 0.85rem;
padding: 0.4rem 0.9rem;
margin-left: 1rem;
}
.btn-logout:hover {
background: #2b6cb0;
color: white;
}
.spinner {
display: inline-block;
width: 20px;
@@ -287,4 +360,63 @@ body {
font-size: 0.9rem;
margin-top: 0.5rem;
}
@media (max-width: 768px) {
.navbar {
padding: 0 1rem;
}
.brand-subtitle {
display: none;
}
/* Show burger, hide desktop drawer */
.burger {
display: flex;
}
.nav-drawer {
display: none;
position: absolute;
top: 64px;
right: 0;
left: 0;
background: #1a365d;
flex-direction: column;
align-items: stretch;
padding: 0.5rem 0 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 99;
}
.nav-drawer.open {
display: flex;
}
.navbar-links {
flex-direction: column;
gap: 0;
}
.navbar-links a {
padding: 0.85rem 1.5rem;
border-radius: 0;
font-size: 1rem;
}
.navbar-links a:hover,
.navbar-links a.active {
background: #2b6cb0;
}
.btn-logout {
margin: 0.5rem 1.5rem 0;
width: calc(100% - 3rem);
justify-content: center;
}
.main-content {
padding: 1rem;
}
}
</style>
+239
View File
@@ -0,0 +1,239 @@
<template>
<div class="book-panel">
<div class="book-panel-header">
<span class="book-panel-title">{{ bookTitle || 'Book' }} p.&nbsp;{{ page }}</span>
<div class="book-panel-nav">
<button class="nav-btn" :disabled="page <= 1" @click="emit('navigate', page - 1)">&#8592;</button>
<button class="nav-btn" @click="emit('navigate', page + 1)">&#8594;</button>
</div>
<button class="close-btn" @click="emit('close')" title="Close">&#x2715;</button>
</div>
<div class="book-panel-body">
<div v-if="loading" class="panel-loading">
<div class="spinner spinner-dark" style="width:24px;height:24px;margin:0 auto 0.5rem;"></div>
<p>Loading page {{ page }}</p>
</div>
<div v-else-if="error" class="panel-error">{{ error }}</div>
<div v-else class="markdown-body" v-html="renderedHtml"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { api } from '@/services/api'
const props = defineProps<{
bookId: string
page: number
bookTitle?: string
}>()
const emit = defineEmits<{
close: []
navigate: [page: number]
}>()
const loading = ref(false)
const error = ref<string | null>(null)
const renderedHtml = ref('')
let activeBlobUrls: string[] = []
onMounted(() => loadPage(props.page))
watch(() => [props.bookId, props.page], () => loadPage(props.page))
onUnmounted(() => {
activeBlobUrls.forEach(u => URL.revokeObjectURL(u))
})
async function loadPage(page: number) {
loading.value = true
error.value = null
renderedHtml.value = ''
activeBlobUrls.forEach(u => URL.revokeObjectURL(u))
activeBlobUrls = []
try {
const res = await api.get<string>(`/books/${props.bookId}/pages/${page}/html`, {
headers: { Accept: 'text/html' },
responseType: 'text'
})
renderedHtml.value = await resolveImages(res.data)
} catch (e: any) {
error.value = e.message ?? 'Failed to load page.'
} finally {
loading.value = false
}
}
async function resolveImages(html: string): Promise<string> {
const srcPattern = /src="(\/api\/v1\/figures\/[^"]+)"/g
const matches = [...html.matchAll(srcPattern)]
if (matches.length === 0) return html
const unique = [...new Set(matches.map(m => m[1]))]
const blobMap: Record<string, string> = {}
await Promise.all(
unique.map(async (src) => {
try {
const res = await api.get(src.replace(/^\/api\/v1/, ''), { responseType: 'blob' })
const blobUrl = URL.createObjectURL(res.data)
activeBlobUrls.push(blobUrl)
blobMap[src] = blobUrl
} catch {
// leave original src
}
})
)
return html.replace(/src="(\/api\/v1\/figures\/[^"]+)"/g, (_, src) =>
blobMap[src] ? `src="${blobMap[src]}"` : `src="${src}"`
)
}
</script>
<style scoped>
.book-panel {
display: flex;
flex-direction: column;
height: 100%;
background: white;
border-left: 1px solid #e2e8f0;
border-radius: 0 10px 10px 0;
overflow: hidden;
}
.book-panel-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
background: #f7fafc;
border-bottom: 1px solid #e2e8f0;
flex-shrink: 0;
}
.book-panel-title {
flex: 1;
font-size: 0.8rem;
font-weight: 600;
color: #2b6cb0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.book-panel-nav {
display: flex;
gap: 0.25rem;
}
.nav-btn {
width: 1.75rem;
height: 1.75rem;
border: 1px solid #cbd5e0;
border-radius: 5px;
background: white;
cursor: pointer;
font-size: 0.85rem;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.nav-btn:hover:not(:disabled) { background: #ebf8ff; border-color: #3182ce; }
.nav-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.close-btn {
width: 1.75rem;
height: 1.75rem;
border: none;
border-radius: 5px;
background: none;
cursor: pointer;
font-size: 1rem;
color: #718096;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s;
}
.close-btn:hover { background: #fed7d7; color: #742a2a; }
.book-panel-body {
flex: 1;
overflow-y: auto;
padding: 1rem 1.25rem;
}
.panel-loading {
text-align: center;
padding: 2rem;
color: #718096;
font-size: 0.875rem;
}
.panel-error {
padding: 1rem;
background: #fff5f5;
border: 1px solid #fed7d7;
color: #742a2a;
border-radius: 6px;
font-size: 0.875rem;
}
.markdown-body {
font-size: 0.9rem;
line-height: 1.75;
color: #2d3748;
}
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3) {
color: #1a365d;
font-weight: 600;
margin: 1.25rem 0 0.5rem;
}
.markdown-body :deep(h2) { font-size: 1.05rem; border-bottom: 1px solid #e2e8f0; padding-bottom: 0.3rem; }
.markdown-body :deep(h3) { font-size: 0.95rem; }
.markdown-body :deep(p) { margin: 0.6rem 0; }
.markdown-body :deep(img) {
max-width: 100%;
border-radius: 6px;
display: block;
margin: 0.75rem auto;
box-shadow: 0 1px 4px rgba(0,0,0,0.12);
}
.markdown-body :deep(ul),
.markdown-body :deep(ol) { padding-left: 1.4rem; margin: 0.5rem 0; }
.markdown-body :deep(code) {
background: #f7fafc;
border: 1px solid #e2e8f0;
border-radius: 3px;
padding: 0.1em 0.3em;
font-size: 0.85em;
}
.markdown-body :deep(blockquote) {
border-left: 3px solid #3182ce;
padding-left: 0.75rem;
color: #4a5568;
margin: 0.5rem 0;
}
.markdown-body :deep(table) {
width: 100%;
border-collapse: collapse;
font-size: 0.875em;
margin: 0.75rem 0;
}
.markdown-body :deep(th),
.markdown-body :deep(td) {
border: 1px solid #e2e8f0;
padding: 0.35rem 0.6rem;
text-align: left;
}
.markdown-body :deep(th) { background: #f7fafc; font-weight: 600; }
</style>
+137 -67
View File
@@ -3,50 +3,17 @@
<div class="message-bubble" :class="isUser ? 'bubble-user' : 'bubble-assistant'">
<div class="message-role">{{ isUser ? 'You' : 'AI Teacher' }}</div>
<div v-if="isUser" class="message-content">{{ message.content }}</div>
<div v-else class="message-content message-content--markdown" v-html="renderedContent"></div>
<div v-else class="message-content message-content--markdown" v-html="renderedWithBadges" @click="onContentClick"></div>
<!-- Sources for assistant messages -->
<div v-if="!isUser && message.sources && message.sources.length > 0" class="message-sources">
<div class="sources-label">Sources:</div>
<div class="source-list">
<!-- TEXT sources -->
<div
v-for="(source, idx) in textSources"
:key="'text-' + idx"
class="source-item"
>
<div class="source-chip source-chip--text">
<span class="source-icon">📖</span>
<span class="source-book-title">{{ source.bookTitle }}</span>
<span v-if="source.page" class="source-page">p.&nbsp;{{ source.page }}</span>
</div>
<div v-if="source.chunkText" class="source-chunk">{{ source.chunkText }}</div>
</div>
<!-- FIGURE sources -->
<div
v-for="(source, idx) in figureSources"
:key="'fig-' + idx"
class="source-item source-item--figure"
>
<div class="source-chip source-chip--figure">
<span class="source-icon">🖼</span>
<span class="source-figure-label">{{ source.label || 'Figure' }}</span>
<span v-if="source.page" class="source-page">p.&nbsp;{{ source.page }}</span>
<span v-if="source.figureType" class="source-figure-type">{{ formatFigureType(source.figureType) }}</span>
</div>
<div v-if="source.caption" class="source-caption">{{ source.caption }}</div>
<div class="source-figure-image">
<img
:src="source.imageUrl"
:alt="source.caption || source.label || 'Figure'"
class="figure-img"
loading="lazy"
@error="onImageError"
/>
</div>
</div>
</div>
<SourceList
ref="sourceListEl"
:sources="message.sources"
:active-ref="activeRef"
@open-source="(bookId: string, page: number) => emit('open-source', bookId, page)"
/>
</div>
<div class="message-timestamp">{{ formatTime(message.createdAt) }}</div>
@@ -55,44 +22,70 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { marked } from 'marked'
import type { ChatMessage, ChatSource } from '@/stores/chatStore'
import SourceList from '@/components/SourceList.vue'
const props = defineProps<{
message: ChatMessage
}>()
const emit = defineEmits<{
'open-source': [bookId: string, page: number]
}>()
const isUser = computed(() => props.message.role === 'USER')
const renderedContent = computed(() => marked.parse(props.message.content) as string)
const activeRef = ref<string | null>(null)
const sourceListEl = ref<InstanceType<typeof SourceList> | null>(null)
const textSources = computed(() =>
(props.message.sources ?? []).filter((s: ChatSource) => s.type === 'TEXT' || !s.type)
)
const figureSources = computed(() =>
(props.message.sources ?? []).filter((s: ChatSource) => s.type === 'FIGURE')
)
function formatFigureType(type: string): string {
const labels: Record<string, string> = {
ANATOMICAL_DIAGRAM: 'Anatomical Diagram',
SURGICAL_PHOTOGRAPH: 'Surgical Photo',
MRI_CT_SCAN: 'MRI / CT',
TABLE: 'Table',
CHART: 'Chart',
INTRAOPERATIVE_IMAGE: 'Intraoperative'
}
return labels[type] ?? type
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
function onImageError(e: Event) {
const img = e.target as HTMLImageElement
img.alt = 'Image unavailable'
img.style.display = 'none'
const wrapper = img.parentElement
if (wrapper) {
wrapper.innerHTML = '<span class="figure-missing">Image unavailable</span>'
const renderedWithBadges = computed(() => {
const html = marked.parse(props.message.content) as string
const figureMap = new Map<string, ChatSource>()
for (const src of (props.message.sources ?? [])) {
if (src.type === 'FIGURE' && src.refLabel) {
figureMap.set(src.refLabel, src)
}
}
return html.replace(/\[(S|F)\d+\]/g, (match) => {
const inner = match.slice(1, -1)
const badge = `<span class="citation-badge" data-ref="${inner}" title="Jump to source ${inner}">${match}</span>`
const fig = figureMap.get(inner)
if (fig?.imageUrl) {
const alt = escapeHtml(fig.caption || fig.label || 'Figure')
const captionText = [fig.label, fig.caption].filter(Boolean).map(escapeHtml).join(' — ')
const captionHtml = captionText
? `<figcaption class="inline-figure-caption">${captionText}</figcaption>`
: ''
return `${badge}<figure class="inline-figure"><img src="${fig.imageUrl}" alt="${alt}" class="inline-figure-img" loading="lazy" onerror="this.parentElement.style.display='none'" />${captionHtml}</figure>`
}
return badge
})
})
function onContentClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.classList.contains('citation-badge')) return
const label = target.getAttribute('data-ref')
if (!label) return
activeRef.value = activeRef.value === label ? null : label
const sourceEl = sourceListEl.value?.$el?.querySelector(`[data-ref-label="${label}"]`) as HTMLElement | null
sourceEl?.scrollIntoView({ behavior: 'smooth', block: 'start' })
const source = (props.message.sources ?? []).find((s: ChatSource) => s.refLabel === label)
if (source?.bookId && source.page) {
emit('open-source', source.bookId, source.page)
}
}
@@ -255,6 +248,22 @@ function formatTime(iso: string): string {
border: 1px solid #bee3f8;
}
.source-chip--clickable {
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.source-chip--clickable:hover {
background: #bee3f8;
border-color: #90cdf4;
}
.source-open-hint {
font-size: 0.75rem;
color: #3182ce;
margin-left: 0.1rem;
}
.source-chip--figure {
background: #f0fff4;
border: 1px solid #9ae6b4;
@@ -322,6 +331,67 @@ function formatTime(iso: string): string {
font-style: italic;
}
.message-content--markdown :deep(.citation-badge) {
display: inline-block;
background: #ebf8ff;
border: 1px solid #90cdf4;
border-radius: 3px;
padding: 0 0.3em;
font-size: 0.78em;
font-weight: 600;
color: #2b6cb0;
cursor: pointer;
user-select: none;
transition: background 0.15s;
}
.message-content--markdown :deep(.citation-badge:hover) {
background: #bee3f8;
}
.source-item--active {
outline: 2px solid #4299e1;
border-radius: 6px;
}
.source-ref-label {
font-size: 0.72rem;
font-weight: 700;
background: #bee3f8;
color: #2b6cb0;
border-radius: 3px;
padding: 0 0.3rem;
}
.source-ref-label--figure {
background: #9ae6b4;
color: #276749;
}
.message-content--markdown :deep(.inline-figure) {
display: block;
margin: 0.75rem 0;
text-align: center;
}
.message-content--markdown :deep(.inline-figure-img) {
max-width: 100%;
max-height: 400px;
border-radius: 6px;
border: 1px solid #e2e8f0;
object-fit: contain;
display: block;
margin: 0 auto;
}
.message-content--markdown :deep(.inline-figure-caption) {
font-size: 0.78rem;
color: #718096;
font-style: italic;
margin-top: 0.3rem;
text-align: center;
}
.message-timestamp {
font-size: 0.7rem;
opacity: 0.6;
+298
View File
@@ -0,0 +1,298 @@
<template>
<div class="source-list">
<!-- TEXT sources -->
<div
v-for="(source, idx) in textSources"
:key="'text-' + idx"
class="source-item"
:class="{ 'source-item--active': activeRef === source.refLabel }"
:data-ref-label="source.refLabel"
>
<div class="source-chip-wrapper">
<div
class="source-chip source-chip--text"
:class="{ 'source-chip--clickable': source.bookId && source.page }"
@click="source.bookId && source.page ? emit('open-source', source.bookId, source.page) : undefined"
>
<span class="source-icon">📖</span>
<span v-if="source.refLabel" class="source-ref-label">{{ source.refLabel }}</span>
<span class="source-book-title">{{ source.bookTitle }}</span>
<span v-if="source.page" class="source-page">p.&nbsp;{{ source.page }}</span>
<span v-if="source.bookId && source.page" class="source-open-hint"></span>
</div>
<div v-if="source.chunkText" class="tooltip tooltip--text">
<p class="tooltip-chunk">{{ source.chunkText }}</p>
</div>
</div>
</div>
<!-- FIGURE sources -->
<div
v-for="(source, idx) in figureSources"
:key="'fig-' + idx"
class="source-item source-item--figure"
:class="{ 'source-item--active': activeRef === source.refLabel }"
:data-ref-label="source.refLabel"
>
<div class="source-chip-wrapper">
<div
class="source-chip source-chip--figure"
:class="{ 'source-chip--clickable': source.bookId && source.page }"
@click="source.bookId && source.page ? emit('open-source', source.bookId, source.page) : undefined"
>
<span class="source-icon">🖼</span>
<span v-if="source.refLabel" class="source-ref-label source-ref-label--figure">{{ source.refLabel }}</span>
<span class="source-figure-label">{{ source.label || 'Figure' }}</span>
<span v-if="source.page" class="source-page">p.&nbsp;{{ source.page }}</span>
<span v-if="source.figureType" class="source-figure-type">{{ formatFigureType(source.figureType) }}</span>
<span v-if="source.bookId && source.page" class="source-open-hint"></span>
</div>
<div v-if="source.imageUrl || source.caption" class="tooltip tooltip--figure">
<img
v-if="source.imageUrl"
:src="source.imageUrl"
:alt="source.caption || source.label || 'Figure'"
class="tooltip-figure-img"
loading="lazy"
@error="onImageError"
/>
<p v-if="source.caption" class="tooltip-caption">{{ source.caption }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
export interface SourceItem {
type?: 'TEXT' | 'FIGURE'
refLabel?: string
bookId?: string | null
bookTitle: string
page?: number | null
chunkText?: string
figureId?: string
label?: string
caption?: string
figureType?: string
imageUrl?: string
}
const props = defineProps<{
sources: SourceItem[]
activeRef?: string | null
}>()
const emit = defineEmits<{
'open-source': [bookId: string, page: number]
}>()
const textSources = computed(() =>
props.sources.filter(s => s.type === 'TEXT' || !s.type)
)
const figureSources = computed(() =>
props.sources.filter(s => s.type === 'FIGURE')
)
function formatFigureType(type: string): string {
const labels: Record<string, string> = {
ANATOMICAL_DIAGRAM: 'Anatomical Diagram',
SURGICAL_PHOTOGRAPH: 'Surgical Photo',
MRI_CT_SCAN: 'MRI / CT',
TABLE: 'Table',
CHART: 'Chart',
INTRAOPERATIVE_IMAGE: 'Intraoperative'
}
return labels[type] ?? type
}
function onImageError(e: Event) {
const img = e.target as HTMLImageElement
img.style.display = 'none'
const wrapper = img.parentElement
if (wrapper) {
const missing = document.createElement('span')
missing.className = 'figure-missing'
missing.textContent = 'Image unavailable'
wrapper.appendChild(missing)
}
}
</script>
<style scoped>
.source-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.source-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.source-item--active {
outline: 2px solid #4299e1;
border-radius: 6px;
}
/* Wrapper provides the positioning context for the tooltip */
.source-chip-wrapper {
position: relative;
display: inline-block;
}
/* ── Chip base ── */
.source-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border-radius: 4px;
padding: 0.2rem 0.5rem;
font-size: 0.78rem;
}
.source-chip--text {
background: #ebf8ff;
border: 1px solid #bee3f8;
}
.source-chip--figure {
background: #f0fff4;
border: 1px solid #9ae6b4;
}
.source-chip--clickable {
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.source-chip--clickable:hover {
background: #bee3f8;
border-color: #90cdf4;
}
.source-chip--figure.source-chip--clickable:hover {
background: #c6f6d5;
border-color: #68d391;
}
/* ── Tooltip ── */
.tooltip {
display: none;
position: absolute;
left: 0;
top: calc(100% + 6px);
z-index: 100;
background: #1a202c;
border-radius: 6px;
padding: 0.6rem 0.75rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
/* Keep it from overflowing too far */
max-width: min(340px, 80vw);
pointer-events: none;
}
/* Show on chip hover */
.source-chip-wrapper:hover .tooltip {
display: block;
}
/* Small arrow pointing up */
.tooltip::before {
content: '';
position: absolute;
top: -5px;
left: 14px;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-bottom: 5px solid #1a202c;
}
.tooltip--text .tooltip-chunk {
margin: 0;
font-size: 0.78rem;
color: #e2e8f0;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.tooltip--figure {
max-width: min(300px, 80vw);
}
.tooltip-figure-img {
display: block;
max-width: 100%;
max-height: 220px;
border-radius: 4px;
object-fit: contain;
margin-bottom: 0.4rem;
}
.tooltip-caption {
margin: 0;
font-size: 0.75rem;
color: #cbd5e0;
font-style: italic;
line-height: 1.4;
}
/* ── Chip internals ── */
.source-icon {
font-size: 0.8rem;
}
.source-ref-label {
font-size: 0.72rem;
font-weight: 700;
background: #bee3f8;
color: #2b6cb0;
border-radius: 3px;
padding: 0 0.3rem;
}
.source-ref-label--figure {
background: #9ae6b4;
color: #276749;
}
.source-book-title {
color: #2b6cb0;
font-weight: 500;
}
.source-figure-label {
color: #276749;
font-weight: 600;
}
.source-figure-type {
color: #718096;
font-size: 0.72rem;
background: #e2e8f0;
border-radius: 3px;
padding: 0 0.3rem;
}
.source-page {
color: #718096;
}
.source-open-hint {
font-size: 0.75rem;
color: #3182ce;
margin-left: 0.1rem;
}
.figure-missing {
font-size: 0.78rem;
color: #a0aec0;
font-style: italic;
}
</style>
+10
View File
@@ -0,0 +1,10 @@
/**
* Read a VITE_ env variable.
* At runtime in Docker, values come from window.__env__ (injected by docker-entrypoint.sh).
* At build time (dev / CI), values come from import.meta.env.
*/
export function env(key: string): string | undefined {
const runtime = (window as Record<string, any>).__env__?.[key]
if (runtime) return runtime
return (import.meta as any).env?.[key]
}
+16 -1
View File
@@ -4,6 +4,21 @@ import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
const pinia = createPinia()
app.use(pinia)
app.use(router)
// Verify any session restored from sessionStorage is still valid.
// If the backend rejects the credentials (e.g. password changed), clear them
// before the router guard fires so the user lands on /login cleanly.
import { useAuthStore } from '@/stores/authStore'
import { api } from '@/services/api'
const auth = useAuthStore()
if (auth.isAuthenticated) {
api.get('/auth/check').catch(() => {
auth.clearCredentials()
})
}
app.mount('#app')
+14
View File
@@ -1,4 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
import LoginView from '@/views/LoginView.vue'
import UploadView from '@/views/UploadView.vue'
import TopicsView from '@/views/TopicsView.vue'
import ChatView from '@/views/ChatView.vue'
@@ -7,6 +9,11 @@ import BookReaderView from '@/views/BookReaderView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/login',
name: 'login',
component: LoginView
},
{
path: '/',
name: 'upload',
@@ -30,4 +37,11 @@ const router = createRouter({
]
})
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.name !== 'login' && !auth.isAuthenticated) {
return { name: 'login' }
}
})
export default router
+16 -6
View File
@@ -1,20 +1,30 @@
import axios from 'axios'
import { useAuthStore } from '@/stores/authStore'
import { env } from '@/env'
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL ?? '/api/v1',
auth: {
username: 'neurosurgeon',
password: import.meta.env.VITE_APP_PASSWORD ?? 'changeme'
},
baseURL: env('VITE_API_URL') ?? '/api/v1',
headers: {
'Content-Type': 'application/json'
}
})
// Response interceptor for error normalisation
api.interceptors.request.use((config) => {
const auth = useAuthStore()
if (auth.username && auth.password) {
config.auth = { username: auth.username, password: auth.password }
}
return config
})
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
useAuthStore().clearCredentials()
window.location.href = '/login'
return Promise.reject(new Error('Session expired. Please sign in again.'))
}
const message =
error.response?.data?.error ??
error.message ??
+28
View File
@@ -0,0 +1,28 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
const SESSION_KEY = 'auth'
export const useAuthStore = defineStore('auth', () => {
const stored = sessionStorage.getItem(SESSION_KEY)
const parsed = stored ? (JSON.parse(stored) as { username: string; password: string }) : null
const username = ref<string | null>(parsed?.username ?? null)
const password = ref<string | null>(parsed?.password ?? null)
const isAuthenticated = computed(() => !!username.value && !!password.value)
function setCredentials(u: string, p: string) {
username.value = u
password.value = p
sessionStorage.setItem(SESSION_KEY, JSON.stringify({ username: u, password: p }))
}
function clearCredentials() {
username.value = null
password.value = null
sessionStorage.removeItem(SESSION_KEY)
}
return { username, password, isAuthenticated, setCredentials, clearCredentials }
})
+2
View File
@@ -4,8 +4,10 @@ import { api } from '@/services/api'
export interface ChatSource {
type: 'TEXT' | 'FIGURE'
bookId?: string
bookTitle: string
page: number | null
refLabel?: string
// TEXT-specific
chunkText?: string
// FIGURE-specific
+53
View File
@@ -10,11 +10,22 @@ export interface Topic {
}
export interface SourceReference {
type?: 'TEXT' | 'FIGURE'
refLabel?: string
bookId: string | null
bookTitle: string
page: number | null
chunkText?: string
figureId?: string
label?: string
caption?: string
figureType?: string
imageUrl?: string
}
export interface TopicSummary {
id: string
summaryNumber: number
topicId: string
topicName: string
summary: string
@@ -22,12 +33,20 @@ export interface TopicSummary {
generatedAt: string
}
export interface SavedSummaryItem {
id: string
summaryNumber: number
generatedAt: string
}
export const useTopicStore = defineStore('topics', () => {
const topics = ref<Topic[]>([])
const activeSummary = ref<TopicSummary | null>(null)
const activeSummaryTopicId = ref<string | null>(null)
const summaryList = ref<SavedSummaryItem[]>([])
const loading = ref(false)
const summaryLoading = ref(false)
const summaryListLoading = ref(false)
const error = ref<string | null>(null)
async function fetchTopics() {
@@ -43,6 +62,36 @@ export const useTopicStore = defineStore('topics', () => {
}
}
async function fetchSummaries(topicId: string) {
summaryListLoading.value = true
summaryList.value = []
error.value = null
try {
const response = await api.get<SavedSummaryItem[]>(`/topics/${topicId}/summaries`)
summaryList.value = response.data
} catch (err: any) {
error.value = err.message
} finally {
summaryListLoading.value = false
}
}
async function fetchSummaryDetail(topicId: string, summaryId: string): Promise<TopicSummary | null> {
summaryLoading.value = true
activeSummary.value = null
error.value = null
try {
const response = await api.get<TopicSummary>(`/topics/${topicId}/summaries/${summaryId}`)
activeSummary.value = response.data
return response.data
} catch (err: any) {
error.value = err.message
return null
} finally {
summaryLoading.value = false
}
}
async function generateSummary(topicId: string): Promise<TopicSummary | null> {
summaryLoading.value = true
activeSummaryTopicId.value = topicId
@@ -65,10 +114,14 @@ export const useTopicStore = defineStore('topics', () => {
topics,
activeSummary,
activeSummaryTopicId,
summaryList,
loading,
summaryLoading,
summaryListLoading,
error,
fetchTopics,
fetchSummaries,
fetchSummaryDetail,
generateSummary
}
})
+10
View File
@@ -322,4 +322,14 @@ async function resolveImages(html: string): Promise<string> {
text-align: left;
}
.markdown-body :deep(th) { background: #f7fafc; font-weight: 600; }
@media (max-width: 768px) {
.reader-view {
max-width: 100%;
}
.reader-content {
padding: 1rem;
}
}
</style>
+125 -123
View File
@@ -3,27 +3,10 @@
<h1 class="page-title">Knowledge Chat</h1>
<p class="page-subtitle">Ask questions grounded in your uploaded medical textbooks.</p>
<!-- Step 1: Topic Selection -->
<div v-if="!chatStore.session && !selectedTopic" class="topic-selection">
<h2 class="section-title">Select a Topic</h2>
<div class="topic-grid">
<button
v-for="topic in topicStore.topics"
:key="topic.id"
:class="['topic-tile', { 'topic-tile-freeform': topic.id === 'free-form' }]"
@click="handleTopicSelect(topic)"
>
<span class="topic-tile-name">{{ topic.name }}</span>
<span v-if="topic.id === 'free-form'" class="topic-tile-hint">Any neurosurgery question</span>
</button>
</div>
</div>
<!-- Step 2: Topic selected previous sessions + new chat -->
<div v-else-if="!chatStore.session && selectedTopic" class="session-setup card">
<!-- Session selection -->
<div v-if="!chatStore.session" class="session-setup card">
<div class="setup-header">
<button class="btn-back" @click="handleBack"> Topics</button>
<h2 class="section-title">{{ selectedTopic.name }}</h2>
<h2 class="section-title">Free-form Chat</h2>
</div>
<div v-if="chatStore.error" class="error-banner">{{ chatStore.error }}</div>
@@ -71,56 +54,74 @@
</div>
</div>
<!-- Messages Area -->
<div class="messages-container" ref="messagesContainer">
<div v-if="chatStore.loading && chatStore.messages.length === 0" class="empty-state">
<div class="spinner spinner-dark" style="width:32px;height:32px;margin:0 auto 1rem;"></div>
<p class="empty-state-text">Loading messages...</p>
</div>
<!-- Chat + Reader split -->
<div class="chat-reader-split">
<!-- Messages + Input -->
<div class="chat-column">
<!-- Messages Area -->
<div class="messages-container" ref="messagesContainer">
<div v-if="chatStore.loading && chatStore.messages.length === 0" class="empty-state">
<div class="spinner spinner-dark" style="width:32px;height:32px;margin:0 auto 1rem;"></div>
<p class="empty-state-text">Loading messages...</p>
</div>
<div v-else-if="chatStore.messages.length === 0" class="empty-state">
<div class="empty-state-icon">💬</div>
<p class="empty-state-text">No messages yet</p>
<p class="empty-state-hint">Ask a question about the uploaded books below.</p>
</div>
<div v-else-if="chatStore.messages.length === 0" class="empty-state">
<div class="empty-state-icon">💬</div>
<p class="empty-state-text">No messages yet</p>
<p class="empty-state-hint">Ask a question about the uploaded books below.</p>
</div>
<div v-else class="messages-list">
<ChatMessage
v-for="message in chatStore.messages"
:key="message.id"
:message="message"
/>
<div v-if="chatStore.sending" class="typing-indicator">
<div class="typing-bubble">
<span></span><span></span><span></span>
<div v-else class="messages-list">
<ChatMessage
v-for="message in chatStore.messages"
:key="message.id"
:message="message"
@open-source="handleOpenSource"
/>
<div v-if="chatStore.sending" class="typing-indicator">
<div class="typing-bubble">
<span></span><span></span><span></span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Input Area -->
<div class="input-area card">
<div v-if="chatStore.error" class="error-banner">{{ chatStore.error }}</div>
<div class="input-row">
<textarea
v-model="inputText"
class="message-input"
placeholder="Ask a question about your uploaded books..."
rows="2"
:disabled="chatStore.sending"
@keydown.enter.exact.prevent="handleSend"
@keydown.enter.shift.exact="inputText += '\n'"
></textarea>
<button
class="btn btn-primary send-btn"
:disabled="!inputText.trim() || chatStore.sending"
@click="handleSend"
>
<span v-if="chatStore.sending" class="spinner"></span>
<span v-else>Send</span>
</button>
<!-- Input Area -->
<div class="input-area card">
<div v-if="chatStore.error" class="error-banner">{{ chatStore.error }}</div>
<div class="input-row">
<textarea
v-model="inputText"
class="message-input"
placeholder="Ask a question about your uploaded books..."
rows="2"
:disabled="chatStore.sending"
@keydown.enter.exact.prevent="handleSend"
@keydown.enter.shift.exact="inputText += '\n'"
></textarea>
<button
class="btn btn-primary send-btn"
:disabled="!inputText.trim() || chatStore.sending"
@click="handleSend"
>
<span v-if="chatStore.sending" class="spinner"></span>
<span v-else>Send</span>
</button>
</div>
<p class="input-hint">Press Enter to send, Shift+Enter for new line.</p>
</div>
</div>
<p class="input-hint">Press Enter to send, Shift+Enter for new line.</p>
<!-- Inline book reader panel -->
<BookPagePanel
v-if="readerPanel"
:book-id="readerPanel.bookId"
:page="readerPanel.page"
:book-title="readerPanel.bookTitle"
class="reader-panel"
@close="readerPanel = null"
@navigate="(p) => readerPanel && (readerPanel.page = p)"
/>
</div>
</div>
</div>
@@ -130,8 +131,10 @@
import { ref, nextTick, onMounted, watch, inject } from 'vue'
import { useChatStore } from '@/stores/chatStore'
import { useTopicStore } from '@/stores/topicStore'
import { useBookStore } from '@/stores/bookStore'
import type { ChatSession } from '@/stores/chatStore'
import ChatMessage from '@/components/ChatMessage.vue'
import BookPagePanel from '@/components/BookPagePanel.vue'
interface Topic {
id: string
@@ -142,6 +145,7 @@ interface Topic {
const chatStore = useChatStore()
const topicStore = useTopicStore()
const bookStore = useBookStore()
const showToast = inject<(msg: string, type?: 'error' | 'success') => void>('showToast')
const selectedTopic = ref<Topic | null>(null)
@@ -150,10 +154,22 @@ const loadingTopicSessions = ref(false)
const inputText = ref('')
const messagesContainer = ref<HTMLElement | null>(null)
interface ReaderPanel { bookId: string; page: number; bookTitle?: string }
const readerPanel = ref<ReaderPanel | null>(null)
function handleOpenSource(bookId: string, page: number) {
const book = bookStore.books.find(b => b.id === bookId)
readerPanel.value = { bookId, page, bookTitle: book?.title }
}
onMounted(async () => {
if (topicStore.topics.length === 0) {
await topicStore.fetchTopics()
}
const freeForm = topicStore.topics.find((t) => t.id === 'free-form')
if (freeForm) {
await handleTopicSelect(freeForm)
}
})
watch(
@@ -189,11 +205,6 @@ async function handleTopicSelect(topic: Topic) {
loadingTopicSessions.value = false
}
function handleBack() {
selectedTopic.value = null
topicSessions.value = []
}
async function handleNewChat() {
const ok = await chatStore.createSession(selectedTopic.value!.id)
if (!ok) {
@@ -207,9 +218,7 @@ async function handleResumeSession(session: ChatSession) {
}
function handleLeaveSession() {
// Leave without deleting session stays in DB and will appear in "Previous Chats"
chatStore.leaveSession()
// Refresh the sessions list for the current topic
if (selectedTopic.value) {
loadingTopicSessions.value = true
chatStore.fetchSessionsByTopic(selectedTopic.value.id).then((sessions) => {
@@ -231,12 +240,6 @@ async function handleSend() {
</script>
<style scoped>
.topic-selection {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.section-title {
font-size: 1.1rem;
font-weight: 600;
@@ -244,52 +247,6 @@ async function handleSend() {
margin: 0;
}
.topic-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.75rem;
}
.topic-tile {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
padding: 1rem 1.1rem;
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
cursor: pointer;
text-align: left;
transition: border-color 0.15s, box-shadow 0.15s;
}
.topic-tile:hover {
border-color: #3182ce;
box-shadow: 0 2px 8px rgba(49, 130, 206, 0.15);
}
.topic-tile-freeform {
border-style: dashed;
border-color: #a0aec0;
}
.topic-tile-freeform:hover {
border-color: #718096;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.topic-tile-name {
font-size: 0.9rem;
font-weight: 600;
color: #2d3748;
}
.topic-tile-hint {
font-size: 0.78rem;
color: #a0aec0;
}
.session-setup {
max-width: 540px;
}
@@ -381,6 +338,29 @@ async function handleSend() {
min-height: 500px;
}
.chat-reader-split {
display: flex;
flex: 1;
min-height: 0;
gap: 0;
}
.chat-column {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
gap: 1rem;
}
.reader-panel {
width: 420px;
flex-shrink: 0;
border-radius: 10px;
margin-left: 1rem;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.07);
}
.session-bar {
display: flex;
align-items: center;
@@ -505,4 +485,26 @@ async function handleSend() {
font-size: 0.875rem;
margin-bottom: 0.75rem;
}
@media (max-width: 768px) {
.chat-layout {
height: auto;
min-height: unset;
}
.chat-reader-split {
flex-direction: column;
}
.chat-column {
min-height: 60vh;
}
.reader-panel {
width: 100%;
margin-left: 0;
margin-top: 1rem;
box-shadow: none;
}
}
</style>
+183
View File
@@ -0,0 +1,183 @@
<template>
<div class="login-wrapper">
<div class="login-card card">
<div class="login-header">
<span class="login-icon">🧠</span>
<h1 class="login-title">AI Teacher</h1>
<p class="login-subtitle">Neurosurgeon Learning Platform</p>
</div>
<form class="login-form" @submit.prevent="handleSubmit">
<div class="form-group">
<label for="username">Username</label>
<input
id="username"
v-model="username"
type="text"
autocomplete="username"
required
:disabled="loading"
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
required
:disabled="loading"
/>
</div>
<div v-if="errorMessage" class="login-error">
{{ errorMessage }}
</div>
<button type="submit" class="btn btn-primary login-btn" :disabled="loading || !username || !password">
<span v-if="loading" class="spinner"></span>
<span v-else>Sign in</span>
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
import { api } from '@/services/api'
const router = useRouter()
const authStore = useAuthStore()
const username = ref('')
const password = ref('')
const loading = ref(false)
const errorMessage = ref('')
async function handleSubmit() {
errorMessage.value = ''
loading.value = true
authStore.setCredentials(username.value, password.value)
try {
await api.get('/auth/check')
router.push('/')
} catch {
authStore.clearCredentials()
errorMessage.value = 'Invalid username or password.'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-wrapper {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f0f4f8;
}
.login-card {
width: 100%;
max-width: 380px;
padding: 2rem;
}
.login-header {
text-align: center;
margin-bottom: 1.75rem;
}
.login-icon {
font-size: 2.5rem;
display: block;
margin-bottom: 0.5rem;
}
.login-title {
font-size: 1.5rem;
font-weight: 700;
color: #1a365d;
margin-bottom: 0.25rem;
}
.login-subtitle {
font-size: 0.85rem;
color: #718096;
}
.login-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.form-group label {
font-size: 0.875rem;
font-weight: 600;
color: #4a5568;
}
.form-group input {
padding: 0.6rem 0.75rem;
border: 1px solid #cbd5e0;
border-radius: 6px;
font-size: 0.95rem;
outline: none;
transition: border-color 0.15s;
}
.form-group input:focus {
border-color: #3182ce;
box-shadow: 0 0 0 3px rgba(49, 130, 206, 0.15);
}
.form-group input:disabled {
background: #f7fafc;
color: #a0aec0;
}
.login-error {
padding: 0.6rem 0.75rem;
background: #fed7d7;
color: #c53030;
border: 1px solid #fc8181;
border-radius: 6px;
font-size: 0.875rem;
}
.login-btn {
width: 100%;
justify-content: center;
padding: 0.7rem;
font-size: 0.95rem;
margin-top: 0.25rem;
}
@media (max-width: 768px) {
.login-wrapper {
align-items: flex-start;
padding-top: 2rem;
min-height: unset;
}
.login-card {
max-width: 100%;
}
}
</style>
+491 -59
View File
@@ -1,7 +1,7 @@
<template>
<div class="topics-view">
<h1 class="page-title">Topics</h1>
<p class="page-subtitle">Select a topic to generate an AI-powered summary from uploaded books.</p>
<p class="page-subtitle">Select a topic to view or generate an AI-powered summary from uploaded books.</p>
<!-- Loading state -->
<div v-if="topicStore.loading" class="empty-state">
@@ -18,83 +18,217 @@
</div>
<div v-else class="topics-layout">
<!-- Topic Grid -->
<div class="topic-grid">
<TopicCard
v-for="topic in topicStore.topics"
:key="topic.id"
:topic="topic"
:is-generating="topicStore.activeSummaryTopicId === topic.id"
@generate="handleGenerate"
/>
</div>
<div class="topics-main">
<!-- Summary Panel -->
<div v-if="topicStore.summaryLoading" class="summary-panel card">
<div class="summary-loading">
<div class="spinner spinner-dark" style="width:36px;height:36px;margin:0 auto 1rem;"></div>
<p class="summary-loading-text">Generating summary from uploaded books...</p>
<p class="summary-loading-hint">This may take up to 30 seconds.</p>
</div>
</div>
<!-- Summary history list -->
<div v-if="selectedTopicId" class="history-panel card">
<div class="history-header">
<span class="history-title">Saved summaries</span>
<button class="btn btn-primary btn-sm" :disabled="topicStore.summaryLoading" @click="handleGenerate(selectedTopicId!)">
<span v-if="topicStore.summaryLoading" class="spinner" style="width:14px;height:14px;display:inline-block;vertical-align:middle;margin-right:4px;"></span>
Generate New
</button>
</div>
<div v-else-if="summaryError" class="summary-panel card summary-error">
<h2 class="summary-topic-name">Summary Error</h2>
<p class="error-text">{{ summaryError }}</p>
<p v-if="isNoBooks" class="no-books-hint">
Please
<RouterLink to="/">upload and process at least one book</RouterLink>
first.
</p>
</div>
<div v-if="topicStore.summaryListLoading" class="history-loading">
<div class="spinner spinner-dark" style="width:20px;height:20px;margin-right:8px;display:inline-block;vertical-align:middle;"></div>
Loading...
</div>
<div v-else-if="topicStore.activeSummary" class="summary-panel card">
<div class="summary-header">
<h2 class="summary-topic-name">{{ topicStore.activeSummary.topicName }}</h2>
<span class="summary-timestamp">{{ formatDate(topicStore.activeSummary.generatedAt) }}</span>
</div>
<div v-else-if="topicStore.summaryList.length === 0" class="history-empty">
No summaries yet. Click "Generate New" to create one.
</div>
<div class="summary-text">{{ topicStore.activeSummary.summary }}</div>
<div v-if="topicStore.activeSummary.sources.length > 0" class="sources-section">
<button class="sources-toggle" @click="showSources = !showSources">
Sources ({{ topicStore.activeSummary.sources.length }})
<span>{{ showSources ? '▲' : '▼' }}</span>
</button>
<div v-if="showSources" class="sources-list">
<div
v-for="(source, idx) in topicStore.activeSummary.sources"
:key="idx"
class="source-chip"
<div v-else class="history-list">
<button
v-for="item in topicStore.summaryList"
:key="item.id"
class="history-chip"
:class="{ 'history-chip--active': topicStore.activeSummary?.id === item.id }"
@click="handleLoadSummary(item)"
>
<span class="source-book">{{ source.bookTitle }}</span>
<span v-if="source.page" class="source-page">p. {{ source.page }}</span>
</div>
Summary #{{ item.summaryNumber }}
<span class="history-chip-date">· {{ formatDateShort(item.generatedAt) }}</span>
</button>
</div>
</div>
<div v-else class="no-sources">
No source citations available for this summary.
<!-- Summary Panel -->
<div v-if="topicStore.summaryLoading" class="summary-panel card">
<div class="summary-loading">
<div class="spinner spinner-dark" style="width:36px;height:36px;margin:0 auto 1rem;"></div>
<p class="summary-loading-text">Generating summary from uploaded books...</p>
<p class="summary-loading-hint">This may take up to 30 seconds.</p>
</div>
</div>
</div>
<div v-else-if="summaryError" class="summary-panel card summary-error">
<h2 class="summary-topic-name">Summary Error</h2>
<p class="error-text">{{ summaryError }}</p>
<p v-if="isNoBooks" class="no-books-hint">
Please
<RouterLink to="/">upload and process at least one book</RouterLink>
first.
</p>
</div>
<div v-else-if="!topicStore.activeSummary" class="summary-panel card summary-placeholder">
<p class="summary-placeholder-text">
{{ selectedTopicId ? 'Select a saved summary or generate a new one.' : 'Select a topic to get started.' }}
</p>
</div>
<div v-else class="summary-panel card">
<div class="summary-header">
<h2 class="summary-topic-name">{{ topicStore.activeSummary.topicName }}</h2>
<div class="summary-meta">
<span v-if="topicStore.activeSummary.summaryNumber" class="summary-number">
Summary #{{ topicStore.activeSummary.summaryNumber }}
</span>
<span class="summary-timestamp">{{ formatDate(topicStore.activeSummary.generatedAt) }}</span>
</div>
</div>
<div class="summary-text summary-text--markdown" v-html="renderedSummary" @click="handleSummaryClick"></div>
<div ref="sourcesSection" v-if="topicStore.activeSummary.sources.length > 0" class="sources-section">
<button class="sources-toggle" @click="showSources = !showSources">
Sources ({{ topicStore.activeSummary.sources.length }})
<span>{{ showSources ? '▲' : '▼' }}</span>
</button>
<SourceList
v-if="showSources"
:sources="topicStore.activeSummary.sources"
@open-source="(bookId: string, page: number) => handleOpenSource(bookId, page)"
/>
<BookPagePanel
v-if="readerPanel"
:book-id="readerPanel.bookId"
:page="readerPanel.page"
:book-title="readerPanel.bookTitle"
class="reader-panel"
@close="readerPanel = null"
@navigate="(p) => readerPanel && (readerPanel.page = p)"
/>
</div>
<div v-else class="no-sources">
No source citations available for this summary.
</div>
</div>
<!-- Topic Grid -->
<div class="topic-grid">
<TopicCard
v-for="topic in summaryTopics"
:key="topic.id"
:topic="topic"
:is-generating="topicStore.activeSummaryTopicId === topic.id"
:is-selected="selectedTopicId === topic.id"
@generate="handleTopicClick"
/>
</div>
</div><!-- end topics-main -->
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, inject } from 'vue'
import { ref, computed, onMounted, inject } from 'vue'
import { marked } from 'marked'
import { RouterLink } from 'vue-router'
import { useTopicStore } from '@/stores/topicStore'
import { useTopicStore, type SavedSummaryItem, type SourceReference } from '@/stores/topicStore'
import { useBookStore } from '@/stores/bookStore'
import TopicCard from '@/components/TopicCard.vue'
import BookPagePanel from '@/components/BookPagePanel.vue'
import SourceList from '@/components/SourceList.vue'
const topicStore = useTopicStore()
const bookStore = useBookStore()
const showToast = inject<(msg: string, type?: 'error' | 'success') => void>('showToast')
const showSources = ref(true)
const summaryError = ref<string | null>(null)
const isNoBooks = ref(false)
const sourcesSection = ref<HTMLElement | null>(null)
const selectedTopicId = ref<string | null>(null)
interface ReaderPanel { bookId: string; page: number; bookTitle?: string }
const readerPanel = ref<ReaderPanel | null>(null)
const summaryTopics = computed(() => topicStore.topics.filter(t => t.id !== 'free-form'))
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
const renderedSummary = computed(() => {
if (!topicStore.activeSummary) return ''
const html = marked.parse(topicStore.activeSummary.summary) as string
const figureMap = new Map<string, SourceReference>()
for (const src of topicStore.activeSummary.sources) {
if (src.type === 'FIGURE' && src.refLabel) {
figureMap.set(src.refLabel, src)
}
}
return html.replace(/\[(S|F)\d+\]/g, (match) => {
const inner = match.slice(1, -1)
const badge = `<span class="source-ref" data-ref="${inner}" title="Jump to source ${inner}">${match}</span>`
const fig = figureMap.get(inner)
if (fig?.imageUrl) {
const alt = escapeHtml(fig.caption || fig.label || 'Figure')
const captionText = [fig.label, fig.caption].filter(Boolean).map(escapeHtml).join(' — ')
const captionHtml = captionText
? `<figcaption class="inline-figure-caption">${captionText}</figcaption>`
: ''
return `${badge}<figure class="inline-figure"><img src="${fig.imageUrl}" alt="${alt}" class="inline-figure-img" loading="lazy" onerror="this.parentElement.style.display='none'" />${captionHtml}</figure>`
}
return badge
})
})
function handleSummaryClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('source-ref')) {
showSources.value = true
sourcesSection.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
function handleOpenSource(bookId: string, page: number) {
const book = bookStore.books.find(b => b.id === bookId)
readerPanel.value = { bookId, page, bookTitle: book?.title }
showSources.value = true
}
async function handleTopicClick(topicId: string) {
if (selectedTopicId.value !== topicId) {
selectedTopicId.value = topicId
topicStore.activeSummary = null
summaryError.value = null
await topicStore.fetchSummaries(topicId)
// Auto-load the latest summary if any exist
const list = topicStore.summaryList
if (list.length > 0) {
await topicStore.fetchSummaryDetail(topicId, list[list.length - 1].id)
}
}
}
async function handleLoadSummary(item: SavedSummaryItem) {
if (!selectedTopicId.value) return
summaryError.value = null
await topicStore.fetchSummaryDetail(selectedTopicId.value, item.id)
}
onMounted(async () => {
await topicStore.fetchTopics()
if (bookStore.books.length === 0) {
await bookStore.fetchBooks()
}
})
async function handleGenerate(topicId: string) {
@@ -109,27 +243,122 @@ async function handleGenerate(topicId: string) {
summaryError.value.toLowerCase().includes('no books') ||
summaryError.value.toLowerCase().includes('knowledge source')
showToast?.(summaryError.value, 'error')
} else {
// Refresh the history list to include the newly saved summary
await topicStore.fetchSummaries(topicId)
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString()
}
function formatDateShort(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
</script>
<style scoped>
.topics-layout {
display: flex;
gap: 2rem;
}
.topics-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2rem;
}
.reader-panel {
margin-top: 1rem;
height: 600px;
min-height: 400px;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
}
.topic-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
/* History panel */
.history-panel {
border-top: 3px solid #805ad5;
padding: 1rem;
}
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.history-title {
font-size: 0.875rem;
font-weight: 600;
color: #553c9a;
}
.btn-sm {
font-size: 0.8rem;
padding: 0.3rem 0.75rem;
}
.history-loading {
font-size: 0.85rem;
color: #718096;
display: flex;
align-items: center;
}
.history-empty {
font-size: 0.85rem;
color: #a0aec0;
font-style: italic;
}
.history-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.history-chip {
background: #faf5ff;
border: 1px solid #d6bcfa;
border-radius: 6px;
padding: 0.3rem 0.75rem;
font-size: 0.8rem;
font-weight: 500;
color: #553c9a;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.history-chip:hover {
background: #e9d8fd;
border-color: #b794f4;
}
.history-chip--active {
background: #805ad5;
border-color: #805ad5;
color: #fff;
}
.history-chip-date {
font-weight: 400;
color: inherit;
opacity: 0.75;
}
/* Summary panel */
.summary-panel {
border-top: 3px solid #3182ce;
}
@@ -170,6 +399,22 @@ function formatDate(iso: string): string {
color: #1a365d;
}
.summary-meta {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.summary-number {
font-size: 0.8rem;
font-weight: 600;
color: #805ad5;
background: #faf5ff;
border: 1px solid #d6bcfa;
border-radius: 4px;
padding: 0.1rem 0.4rem;
}
.summary-timestamp {
font-size: 0.8rem;
color: #a0aec0;
@@ -179,10 +424,60 @@ function formatDate(iso: string): string {
font-size: 0.95rem;
line-height: 1.7;
color: #2d3748;
white-space: pre-wrap;
margin-bottom: 1rem;
}
.summary-text--markdown {
white-space: normal;
}
.summary-text--markdown :deep(h1),
.summary-text--markdown :deep(h2),
.summary-text--markdown :deep(h3),
.summary-text--markdown :deep(h4) {
font-weight: 700;
margin: 0.75rem 0 0.35rem;
line-height: 1.3;
color: #1a202c;
}
.summary-text--markdown :deep(h1) { font-size: 1.15rem; }
.summary-text--markdown :deep(h2) { font-size: 1.05rem; }
.summary-text--markdown :deep(h3) { font-size: 0.975rem; }
.summary-text--markdown :deep(h4) { font-size: 0.925rem; }
.summary-text--markdown :deep(p) { margin: 0.4rem 0; }
.summary-text--markdown :deep(ul),
.summary-text--markdown :deep(ol) {
padding-left: 1.4rem;
margin: 0.4rem 0;
}
.summary-text--markdown :deep(li) { margin: 0.2rem 0; }
.summary-text--markdown :deep(strong) {
font-weight: 700;
color: #1a202c;
}
.summary-text--markdown :deep(em) { font-style: italic; }
.summary-text--markdown :deep(code) {
background: #edf2f7;
border-radius: 3px;
padding: 0.1em 0.35em;
font-size: 0.87em;
font-family: monospace;
}
.summary-text--markdown :deep(blockquote) {
border-left: 3px solid #bee3f8;
margin: 0.5rem 0;
padding: 0.25rem 0.75rem;
color: #4a5568;
}
.sources-section {
border-top: 1px solid #e2e8f0;
padding-top: 0.75rem;
@@ -208,30 +503,116 @@ function formatDate(iso: string): string {
.sources-list {
display: flex;
flex-wrap: wrap;
flex-direction: column;
gap: 0.5rem;
}
.source-chip {
.source-item {
display: flex;
align-items: center;
flex-direction: column;
gap: 0.25rem;
}
.source-item--figure {
gap: 0.4rem;
}
.source-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border-radius: 4px;
padding: 0.2rem 0.5rem;
font-size: 0.78rem;
}
.source-chip--text {
background: #ebf8ff;
border: 1px solid #bee3f8;
border-radius: 6px;
padding: 0.3rem 0.7rem;
}
.source-chip--figure {
background: #f0fff4;
border: 1px solid #9ae6b4;
}
.source-chip--clickable {
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.source-chip--text.source-chip--clickable:hover {
background: #bee3f8;
border-color: #90cdf4;
}
.source-chip--figure.source-chip--clickable:hover {
background: #c6f6d5;
border-color: #68d391;
}
.source-icon {
font-size: 0.8rem;
}
.source-ref-label {
font-size: 0.72rem;
font-weight: 700;
background: #bee3f8;
color: #2b6cb0;
border-radius: 3px;
padding: 0 0.3rem;
}
.source-ref-label--figure {
background: #9ae6b4;
color: #276749;
}
.source-book {
color: #2b6cb0;
font-weight: 500;
}
.source-figure-label {
color: #276749;
font-weight: 600;
}
.source-page {
color: #718096;
}
.source-open-hint {
font-size: 0.75rem;
color: #3182ce;
margin-left: 0.1rem;
}
.source-caption {
font-size: 0.78rem;
color: #4a5568;
font-style: italic;
}
.source-figure-image {
max-width: 100%;
}
.figure-img {
max-width: 100%;
max-height: 300px;
border-radius: 6px;
border: 1px solid #e2e8f0;
object-fit: contain;
}
.figure-missing {
font-size: 0.78rem;
color: #a0aec0;
font-style: italic;
}
.no-sources {
font-size: 0.85rem;
color: #a0aec0;
@@ -252,4 +633,55 @@ function formatDate(iso: string): string {
color: #3182ce;
text-decoration: underline;
}
.summary-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 6rem;
border-top-color: #cbd5e0;
}
.summary-placeholder-text {
font-size: 0.95rem;
color: #a0aec0;
font-style: italic;
}
.summary-text--markdown :deep(.inline-figure) {
display: block;
margin: 0.75rem 0;
text-align: center;
}
.summary-text--markdown :deep(.inline-figure-img) {
max-width: 100%;
max-height: 400px;
border-radius: 6px;
border: 1px solid #e2e8f0;
object-fit: contain;
display: block;
margin: 0 auto;
}
.summary-text--markdown :deep(.inline-figure-caption) {
font-size: 0.78rem;
color: #718096;
font-style: italic;
margin-top: 0.3rem;
text-align: center;
}
.summary-text--markdown :deep(.source-ref) {
color: #3182ce;
font-weight: 600;
cursor: pointer;
border-radius: 3px;
padding: 0 0.15em;
}
.summary-text--markdown :deep(.source-ref:hover) {
background: #ebf8ff;
text-decoration: underline;
}
</style>
+3 -2
View File
@@ -99,9 +99,10 @@
import { ref, onMounted, onUnmounted, inject } from 'vue'
import { useBookStore } from '@/stores/bookStore'
import BookCard from '@/components/BookCard.vue'
import { env } from '@/env'
const uploadEnabled = import.meta.env.VITE_UPLOAD_ENABLED !== 'false'
const deleteEnabled = import.meta.env.VITE_DELETE_ENABLED !== 'false'
const uploadEnabled = env('VITE_UPLOAD_ENABLED') !== 'false'
const deleteEnabled = env('VITE_DELETE_ENABLED') !== 'false'
const bookStore = useBookStore()
const showToast = inject<(msg: string, type?: 'error' | 'success') => void>('showToast')
@@ -0,0 +1,35 @@
# Specification Quality Checklist: Basic Login Protection
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-06
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items pass. Spec is complete and ready for planning.
- FR-012 resolved: credentials are managed via environment variables / config file (no in-app user management UI).
+49
View File
@@ -0,0 +1,49 @@
# API Contract: Auth
**Base path**: `/api/v1/auth`
**Authentication**: HTTP Basic (all endpoints in this group require valid credentials)
---
## GET /api/v1/auth/check
Verifies that the supplied HTTP Basic credentials are valid. Used by the frontend after a page refresh to confirm stored credentials are still accepted before rendering the app.
### Request
```
GET /api/v1/auth/check
Authorization: Basic <base64(username:password)>
```
No request body.
### Response — 200 OK
```json
{
"username": "neurosurgeon"
}
```
| Field | Type | Description |
|-------|------|-------------|
| `username` | string | The authenticated username |
### Response — 401 Unauthorized
Spring Security returns a standard 401 with `WWW-Authenticate: Basic realm="Realm"` header. No JSON body.
### Behaviour
- Returns `200` with the authenticated username if credentials are valid.
- Returns `401` if credentials are absent or incorrect.
- No side effects (idempotent, read-only).
---
## Notes
- All other existing endpoints (`/api/v1/books`, `/api/v1/chat`, etc.) continue to require HTTP Basic Auth as before.
- The frontend sends `Authorization: Basic ...` on every request via the axios request interceptor.
- A global axios response interceptor detects `401` responses and redirects the user to `/login`.
+35
View File
@@ -0,0 +1,35 @@
# Data Model: Basic Login Protection
**Feature**: 003-basic-login
**Date**: 2026-04-06
## No Backend Schema Changes
This feature introduces no new database tables or Flyway migrations. The user account is defined entirely in the Spring Security in-memory configuration (`SecurityConfig.java`) backed by environment variables.
## Frontend: Auth Store State
The Pinia `authStore` is the single source of truth for authentication state in the frontend.
```
AuthState
├── username: string | null — entered username, null if not logged in
├── password: string | null — entered password, null if not logged in
└── isAuthenticated: boolean — derived: true when both username and password are non-null
Actions
├── login(username, password) — validates credentials via /api/v1/auth/check, stores in sessionStorage on success
├── logout() — clears username, password, sessionStorage; redirects to /login
└── restoreSession() — reads credentials from sessionStorage on app start; calls /api/v1/auth/check to verify still valid
```
## Backend: Application Properties
Two properties configure the single allowed user account:
| Property | Default | Source | Example |
|----------|---------|--------|---------|
| `app.auth.username` | `neurosurgeon` | `application.yaml` / env var `APP_AUTH_USERNAME` | `admin` |
| `app.auth.password` | (required) | env var `APP_AUTH_PASSWORD` | `s3cret` |
No hashing is applied in the current `SecurityConfig` (`{noop}` prefix). The spec (FR-011) requires passwords not to be stored in plaintext — this refers to the backend config/env var pattern, which is acceptable as env vars are not persisted in the codebase. If hashing is required later, the `{noop}` prefix can be replaced with `{bcrypt}` without other code changes.
+76
View File
@@ -0,0 +1,76 @@
# Implementation Plan: Basic Login Protection
**Branch**: `003-basic-login` | **Date**: 2026-04-06 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/003-basic-login/spec.md`
## Summary
Add a login page to the Vue frontend so users must enter a username and password before accessing any route. The backend already has Spring Security with HTTP Basic Auth fully configured; credentials are validated on every API call. The implementation introduces a Pinia auth store that holds the entered credentials in `sessionStorage`, an axios interceptor that injects them on every request, a `/login` route with a login form, router guards that redirect unauthenticated users, and a logout button in the navbar. A lightweight `/api/v1/auth/check` endpoint is added to the backend to allow the frontend to verify credentials without side effects. Username is made configurable in the backend (currently hardcoded as "neurosurgeon").
## Technical Context
**Language/Version**: Java 21 (backend) / TypeScript + Node 20 (frontend)
**Primary Dependencies**: Spring Boot 4.0.5, Spring Security (already included), Vue 3.4, Vue Router 4.3, Pinia 2.1, Axios 1.7
**Storage**: No new storage — credentials held in browser `sessionStorage` (frontend only)
**Testing**: Spring Boot Test (backend), Vitest (not yet set up — out of scope for this feature)
**Target Platform**: Web (SPA + REST API)
**Project Type**: Web application (backend API + Vue frontend client)
**Performance Goals**: Login response within 1 second under normal load
**Constraints**: No new backend dependencies; no database changes; must not break existing API surface
**Scale/Scope**: Small team (POC), single user role
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| I. KISS | PASS | HTTP Basic Auth is reused; no new auth protocol, no new dependencies. Frontend uses sessionStorage — no JWT, no refresh tokens. |
| II. Easy to Change | PASS | Auth store is a single Pinia store; swapping the auth mechanism later only requires updating the store and the SecurityConfig. |
| III. Web-First | PASS | Backend exposes REST endpoint; frontend is standalone SPA client. No server-side rendering added. |
| IV. Documentation as Architecture | PASS | README must be updated to show the login flow in the architecture diagram (same PR). |
| Technology Constraints | PASS | Still two deployable units (backend + frontend). No new service added. |
## Project Structure
### Documentation (this feature)
```text
specs/003-basic-login/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
│ └── auth.md
└── tasks.md # Phase 2 output (/speckit.tasks — NOT created by /speckit.plan)
```
### Source Code (repository root)
```text
backend/
├── src/main/java/com/aiteacher/
│ ├── config/
│ │ └── SecurityConfig.java # MODIFY: make username configurable
│ └── auth/
│ └── AuthController.java # ADD: GET /api/v1/auth/check endpoint
frontend/
├── src/
│ ├── stores/
│ │ └── authStore.ts # ADD: Pinia store for credentials + session
│ ├── views/
│ │ └── LoginView.vue # ADD: login form UI
│ ├── services/
│ │ └── api.ts # MODIFY: read credentials from authStore
│ ├── router/
│ │ └── index.ts # MODIFY: add /login route + navigation guard
│ └── App.vue # MODIFY: add logout button to navbar
```
**Structure Decision**: Option 2 (web application). Existing `backend/` and `frontend/` layout used; no new projects or packages.
## Complexity Tracking
> No constitution violations. Table left empty.
+198
View File
@@ -0,0 +1,198 @@
# Quickstart: Basic Login Protection
**Feature**: 003-basic-login
**Date**: 2026-04-06
## What Changes
| Component | Change |
|-----------|--------|
| `SecurityConfig.java` | Username made configurable via `app.auth.username` property |
| `AuthController.java` | New: `GET /api/v1/auth/check` endpoint |
| `authStore.ts` | New: Pinia store managing credentials + sessionStorage |
| `LoginView.vue` | New: login form page |
| `api.ts` | Replace hardcoded Basic Auth with dynamic interceptor |
| `router/index.ts` | Add `/login` route + `beforeEach` navigation guard |
| `App.vue` | Add logout button to navbar |
| `application.yaml` | Add `app.auth.username` property with default |
## Backend Setup
### 1. Add username to application.yaml
```yaml
app:
auth:
username: ${APP_AUTH_USERNAME:neurosurgeon}
password: ${APP_AUTH_PASSWORD} # already present
```
### 2. Update SecurityConfig.java
Inject both username and password:
```java
@Bean
public UserDetailsService userDetailsService(
@Value("${app.auth.username}") String username,
@Value("${app.auth.password}") String password) {
UserDetails user = User.builder()
.username(username)
.password("{noop}" + password)
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
```
### 3. Add AuthController.java
```java
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
@GetMapping("/check")
public ResponseEntity<Map<String, String>> check(Principal principal) {
return ResponseEntity.ok(Map.of("username", principal.getName()));
}
}
```
## Frontend Setup
### 1. Create authStore.ts
```typescript
// src/stores/authStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
const SESSION_KEY = 'auth'
export const useAuthStore = defineStore('auth', () => {
const stored = sessionStorage.getItem(SESSION_KEY)
const parsed = stored ? JSON.parse(stored) : null
const username = ref<string | null>(parsed?.username ?? null)
const password = ref<string | null>(parsed?.password ?? null)
const isAuthenticated = computed(() => !!username.value && !!password.value)
function setCredentials(u: string, p: string) {
username.value = u
password.value = p
sessionStorage.setItem(SESSION_KEY, JSON.stringify({ username: u, password: p }))
}
function clearCredentials() {
username.value = null
password.value = null
sessionStorage.removeItem(SESSION_KEY)
}
return { username, password, isAuthenticated, setCredentials, clearCredentials }
})
```
### 2. Update api.ts
Replace hardcoded `auth` with a request interceptor:
```typescript
import axios from 'axios'
import { useAuthStore } from '@/stores/authStore'
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL ?? '/api/v1',
headers: { 'Content-Type': 'application/json' }
})
api.interceptors.request.use((config) => {
const auth = useAuthStore()
if (auth.username && auth.password) {
config.auth = { username: auth.username, password: auth.password }
}
return config
})
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
useAuthStore().clearCredentials()
window.location.href = '/login'
}
const message = error.response?.data?.error ?? error.message ?? 'An unexpected error occurred.'
return Promise.reject(new Error(message))
}
)
```
### 3. Update router/index.ts
Add `/login` route and guard:
```typescript
import LoginView from '@/views/LoginView.vue'
import { useAuthStore } from '@/stores/authStore'
// add to routes array:
{ path: '/login', name: 'login', component: LoginView }
// add global guard:
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.name !== 'login' && !auth.isAuthenticated) {
return { name: 'login' }
}
})
```
### 4. Create LoginView.vue
A simple centered form with username and password fields. On submit:
1. Store credentials tentatively in the auth store
2. Call `GET /api/v1/auth/check`
3. If 200 → navigate to `/`
4. If 401 → clear credentials, show error message
### 5. Add logout to App.vue navbar
```html
<button class="btn btn-secondary" @click="logout">Sign out</button>
```
```typescript
import { useAuthStore } from '@/stores/authStore'
import { useRouter } from 'vue-router'
const auth = useAuthStore()
const router = useRouter()
function logout() {
auth.clearCredentials()
router.push({ name: 'login' })
}
```
## Environment Variables
### Backend (.env / docker-compose environment)
```
APP_AUTH_USERNAME=neurosurgeon # optional, defaults to neurosurgeon
APP_AUTH_PASSWORD=your-secret
```
### Frontend (.env)
```
VITE_API_URL=/api/v1
# VITE_APP_PASSWORD is no longer needed and should be removed
```
## Testing the Login Flow
1. Open the app in an incognito window — should redirect to `/login`
2. Enter wrong credentials → error message, stay on login
3. Enter correct credentials → redirect to `/` (Library)
4. Refresh the page → stay logged in
5. Click "Sign out" → redirect to `/login`; back button shows login again (no cached page access)
+64
View File
@@ -0,0 +1,64 @@
# Research: Basic Login Protection
**Feature**: 003-basic-login
**Date**: 2026-04-06
## Finding 1: Backend Auth Mechanism — Already Implemented
**Decision**: Keep existing HTTP Basic Auth (Spring Security, `SecurityConfig.java`).
**Rationale**: Spring Security with HTTP Basic is already configured and working. The backend validates credentials on every API request. There is nothing to add except making the username configurable and adding a credential-check endpoint.
**Alternatives considered**: Form-based login with server-side sessions — rejected because it adds session management complexity on the backend that is unnecessary for an SPA using HTTP Basic.
---
## Finding 2: Frontend Credential Storage — sessionStorage
**Decision**: Store entered username and password in browser `sessionStorage` via a Pinia store.
**Rationale**:
- `sessionStorage` persists across page refreshes (same tab) but is cleared when the tab is closed — this matches the expected session behavior (SC-004) without needing a server-side session or JWT.
- Simpler than `localStorage` (no explicit logout needed to clear on browser close).
- No additional dependencies required.
**Alternatives considered**:
- `localStorage` — rejected: credentials would persist indefinitely across browser sessions, which is unexpected for a "login" flow.
- In-memory (reactive ref only) — rejected: credentials lost on page refresh, violating SC-004.
- Cookie-based session (server-side) — rejected: requires CSRF protection, session store, and more backend complexity; violates KISS.
---
## Finding 3: Credential Verification — Lightweight Backend Endpoint
**Decision**: Add `GET /api/v1/auth/check` that returns `200 OK` with `{"username": "..."}` for authenticated requests.
**Rationale**: The frontend needs a way to verify that stored credentials are valid when the app loads (e.g., after a refresh). Without this, the first real API call would fail with a 401 and force a re-login on every refresh if credentials changed. This endpoint is protected by Spring Security like all others — no special logic needed.
**Alternatives considered**:
- Re-use any existing GET endpoint (e.g., `GET /api/v1/books`) — rejected: couples auth verification to a business endpoint; semantically wrong and fragile.
- Intercept 401s globally and redirect to login — used as a fallback but not sufficient alone: the user would see a flash of the main UI before being redirected.
---
## Finding 4: Axios Integration — Request Interceptor
**Decision**: Replace the hardcoded `auth` field in `api.ts` with a dynamic request interceptor that reads credentials from the Pinia auth store at request time.
**Rationale**: The current `api.ts` sets `auth: { username, password }` once at module initialisation from env vars. This must change so the login form's entered credentials are used. A request interceptor reads the store on every call, enabling logout (clear store → next request gets no credentials → 401 → redirect to login).
**Alternatives considered**:
- Recreate the axios instance after login — rejected: all existing services import the singleton `api`; recreating would require updating every import.
---
## Finding 5: Backend Username Configurability
**Decision**: Read username from `${app.auth.username:neurosurgeon}` in `SecurityConfig.java` (with "neurosurgeon" as default).
**Rationale**: The spec (FR-012) requires credentials to be configurable. Currently the password is configurable via env var but the username is hardcoded. Adding a `@Value`-injected username field is a one-line change.
**Alternatives considered**: None — this is the Spring Boot idiomatic approach already used for the password.
---
## Summary of Unknowns Resolved
| Unknown | Resolution |
|---------|-----------|
| Where to store credentials on the frontend | `sessionStorage` via Pinia |
| How to verify credentials after page refresh | `GET /api/v1/auth/check` endpoint |
| How to inject credentials into axios | Request interceptor in `api.ts` |
| How to handle 401s globally | Response interceptor → redirect to `/login` |
| Backend username configurability | `@Value("${app.auth.username:neurosurgeon}")` |
+103
View File
@@ -0,0 +1,103 @@
# Feature Specification: Basic Login Protection
**Feature Branch**: `003-basic-login`
**Created**: 2026-04-06
**Status**: Draft
**Input**: User description: "Add simple and basic login (username and password) to protect the app."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Authenticate to Access the App (Priority: P1)
A user opens the application and is presented with a login screen. They enter their username and password and, upon successful authentication, gain access to the full application. Without logging in, no part of the application is accessible.
**Why this priority**: This is the core feature — all other functionality depends on this gate being in place.
**Independent Test**: Can be fully tested by navigating to any page without credentials (should redirect to login), then logging in with valid credentials (should grant access) — this alone delivers the full MVP value.
**Acceptance Scenarios**:
1. **Given** an unauthenticated user, **When** they navigate to any page of the app, **Then** they are redirected to the login screen
2. **Given** the login screen, **When** the user enters valid credentials and submits, **Then** they are redirected to the application home/dashboard
3. **Given** the login screen, **When** the user enters invalid credentials and submits, **Then** an error message is displayed and they remain on the login screen
4. **Given** an authenticated user, **When** they navigate directly to a protected page, **Then** they can access it without re-authenticating
---
### User Story 2 - Log Out of the App (Priority: P2)
An authenticated user can explicitly log out of the application, terminating their session. After logging out, they are redirected to the login screen and must re-authenticate to access the app.
**Why this priority**: Logout is essential for security — especially on shared machines — but the app is still protected even without explicit logout (session expires).
**Independent Test**: Can be fully tested by logging in, clicking logout, and confirming that the protected pages are no longer accessible.
**Acceptance Scenarios**:
1. **Given** an authenticated user, **When** they click the logout button, **Then** their session is terminated and they are redirected to the login screen
2. **Given** a user who has logged out, **When** they navigate to a protected page, **Then** they are redirected to the login screen
---
### User Story 3 - Session Persistence Across Browser Refresh (Priority: P3)
An authenticated user refreshes the page or reopens the browser tab and remains logged in without having to re-enter credentials, as long as their session has not expired.
**Why this priority**: Improves usability — users should not be forced to log in after every page refresh during normal use.
**Independent Test**: Can be tested by logging in, refreshing the page, and confirming the user is still authenticated.
**Acceptance Scenarios**:
1. **Given** an authenticated user, **When** they refresh the browser, **Then** they remain logged in and on the same page
2. **Given** a session that has expired, **When** the user tries to access a protected page, **Then** they are redirected to the login screen
---
### Edge Cases
- What happens when the login form is submitted with empty username or password fields?
- How does the system handle a user whose credentials are removed/disabled while they have an active session?
- What happens if the user attempts to access the login page while already authenticated?
- How does the system behave if the session store becomes unavailable?
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST display a login screen (username + password fields and submit button) to unauthenticated users
- **FR-002**: System MUST redirect unauthenticated users attempting to access any protected page to the login screen
- **FR-003**: System MUST validate submitted credentials against configured or stored credentials
- **FR-004**: System MUST create an authenticated session upon successful login
- **FR-005**: System MUST display a clear, user-friendly error message when credentials are incorrect (without revealing which field is wrong)
- **FR-006**: System MUST provide a logout action that terminates the active session
- **FR-007**: System MUST redirect users to the login screen after logout
- **FR-008**: System MUST prevent login form submission when username or password is empty
- **FR-009**: System MUST automatically expire sessions after a reasonable inactivity period
- **FR-010**: System MUST redirect users to the login page if their session has expired
- **FR-011**: Credentials MUST be stored securely (passwords hashed, not stored in plaintext)
- **FR-012**: System MUST allow at least one user account to be configured via environment variables or a configuration file; credential changes take effect on restart
### Key Entities
- **User Account**: Represents a person who can authenticate; has a username (unique identifier) and a hashed password
- **Session**: Represents an active authenticated context; linked to a user account, has an expiry time
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Unauthenticated users cannot access any protected page — 100% of protected routes redirect to login
- **SC-002**: Users can complete the login flow (enter credentials, submit, land on the app) in under 30 seconds under normal conditions
- **SC-003**: Invalid login attempts display an error within 3 seconds and do not reveal which field was wrong
- **SC-004**: Authenticated sessions persist across page refreshes for the configured session duration without requiring re-authentication
- **SC-005**: Logout terminates the session immediately — any subsequent request to a protected page results in a redirect to login
## Assumptions
- The target users are a small, known group — there is no public self-registration for new accounts
- A single set of credentials (or a small number of pre-configured accounts) is sufficient for this initial version
- Mobile/responsive design for the login form is expected but full mobile optimization is not the focus of this feature
- The app currently has no authentication layer, so this will be added globally
- Session duration defaults to a reasonable inactivity timeout (e.g., 30 minutes of inactivity), configurable if needed
- A "remember me" / persistent login cookie is out of scope for this initial implementation
+172
View File
@@ -0,0 +1,172 @@
# Tasks: Basic Login Protection
**Input**: Design documents from `/specs/003-basic-login/`
**Prerequisites**: plan.md ✅ spec.md ✅ research.md ✅ data-model.md ✅ contracts/ ✅ quickstart.md ✅
**Tests**: Not requested — no test tasks included.
**Organization**: Tasks grouped by user story to enable independent implementation and testing.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
- **[Story]**: Which user story this task belongs to (US1, US2, US3)
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: No new project initialization needed — existing backend and frontend projects are in place. Phase 1 confirms the entry points for changes.
- [x] T001 Verify `spring-boot-starter-security` is present in `backend/pom.xml` (already included — confirm, no change needed)
- [x] T002 Verify Pinia is listed in `frontend/package.json` dependencies (already included — confirm, no change needed)
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Backend credential endpoint and frontend auth store — required by all three user stories.
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
- [x] T003 Add `app.auth.username` property to `backend/src/main/resources/application.yaml` with value `${APP_AUTH_USERNAME:neurosurgeon}` alongside the existing `app.auth.password` entry
- [x] T004 Update `backend/src/main/java/com/aiteacher/config/SecurityConfig.java` to inject `@Value("${app.auth.username}")` and pass it to `User.builder().username(username)` instead of the hardcoded string `"neurosurgeon"`
- [x] T005 Create `backend/src/main/java/com/aiteacher/auth/AuthController.java``@RestController` at `/api/v1/auth`, with a single `GET /check` method that accepts a `Principal` argument and returns `ResponseEntity.ok(Map.of("username", principal.getName()))`
- [x] T006 Create `frontend/src/stores/authStore.ts` — Pinia store with `username` and `password` refs (initially `null`), `isAuthenticated` computed, `setCredentials(u, p)` action, and `clearCredentials()` action (sessionStorage persistence added in Phase 5 / US3)
**Checkpoint**: Backend exposes `GET /api/v1/auth/check`; `authStore` is callable from any Vue component.
---
## Phase 3: User Story 1 - Authenticate to Access the App (Priority: P1) 🎯 MVP
**Goal**: Unauthenticated users are redirected to `/login`; successful credential entry grants full app access.
**Independent Test**: Open the app in incognito → redirected to `/login`. Enter wrong credentials → error shown. Enter correct credentials → land on Library (`/`). Refresh the page → stays on Library (credentials held in-memory for now; persistence comes in US3).
### Implementation for User Story 1
- [x] T007 [US1] Create `frontend/src/views/LoginView.vue` — centered card with username input, password input, submit button, and an error message area; on submit, call `authStore.setCredentials(u, p)`, then call `GET /api/v1/auth/check` via the `api` service; on 200 navigate to `/`; on failure call `authStore.clearCredentials()` and display the error
- [x] T008 [US1] Update `frontend/src/services/api.ts` — remove the hardcoded `auth: { username, password }` field from the axios instance; add a **request interceptor** that reads `authStore.username` and `authStore.password` and sets `config.auth` dynamically; add a **response interceptor** that on `401` calls `authStore.clearCredentials()` and redirects to `/login` (replace the existing error-normalisation interceptor rather than adding a second one — keep error normalisation intact)
- [x] T009 [US1] Update `frontend/src/router/index.ts` — add a `{ path: '/login', name: 'login', component: LoginView }` route; add a `router.beforeEach` guard that redirects to `{ name: 'login' }` when `to.name !== 'login'` and `!authStore.isAuthenticated`
**Checkpoint**: US1 fully functional — incognito flow, failed login, and successful login all work independently.
---
## Phase 4: User Story 2 - Log Out of the App (Priority: P2)
**Goal**: Authenticated users can log out, terminating their session and returning to `/login`.
**Independent Test**: Log in → click "Sign out" in the navbar → redirected to `/login`; navigating back to any protected route redirects to `/login` again.
### Implementation for User Story 2
- [x] T010 [US2] Update `frontend/src/App.vue` — import `useAuthStore` and `useRouter`; add a "Sign out" button to the navbar (visible only when `authStore.isAuthenticated`); clicking it calls `authStore.clearCredentials()` then `router.push({ name: 'login' })`; hide the navbar links (`RouterLink` items) when on the login page by checking `$route.name !== 'login'`
**Checkpoint**: US2 fully functional — logout clears session and blocks re-entry without credentials.
---
## Phase 5: User Story 3 - Session Persistence Across Browser Refresh (Priority: P3)
**Goal**: Authenticated users survive a page refresh without re-logging in; expired/invalid stored credentials redirect to `/login`.
**Independent Test**: Log in → refresh the browser → remain on the same page without re-entering credentials. Manually clear `sessionStorage` and refresh → redirected to `/login`.
### Implementation for User Story 3
- [x] T011 [US3] Update `frontend/src/stores/authStore.ts` — in `setCredentials`, write `{ username, password }` to `sessionStorage` under a key (e.g. `'auth'`); in `clearCredentials`, call `sessionStorage.removeItem('auth')`; on store initialization (module load), read from `sessionStorage` and pre-populate `username` and `password` refs if present
- [x] T012 [US3] Update `frontend/src/main.ts` — after creating the app and mounting Pinia, call `authStore.restoreSession()` (or inline the check): if `authStore.isAuthenticated`, call `GET /api/v1/auth/check`; if the response is `401`, call `authStore.clearCredentials()` so stale stored credentials are evicted before the router guard fires
**Checkpoint**: US3 fully functional — refresh persists login; stale or invalidated credentials are detected on load.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Documentation and cleanup that spans all user stories.
- [x] T013 [P] Update `frontend/.env.example` — remove `VITE_APP_PASSWORD` (the frontend no longer reads a password from env; add a comment explaining credentials are now entered via the login form)
- [x] T014 [P] Update `README.md` — add or update the Mermaid architecture diagram to show the login flow: browser → login form → `/api/v1/auth/check` → app; this satisfies Constitution Principle IV (diagram must be updated in the same PR as any architectural change)
**Checkpoint**: Feature complete — all three user stories functional, documentation current, obsolete env var removed.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — confirm immediately
- **Foundational (Phase 2)**: Depends on Phase 1 — **BLOCKS all user stories**
- **US1 (Phase 3)**: Depends on Phase 2 completion
- **US2 (Phase 4)**: Depends on Phase 2 completion; integrates with authStore from Phase 2 and router from US1
- **US3 (Phase 5)**: Depends on Phase 2 completion; extends authStore from Phase 2
- **Polish (Phase 6)**: Depends on all desired stories complete
### User Story Dependencies
- **US1 (P1)**: Depends only on Foundational — the primary blocker for all UI work
- **US2 (P2)**: Depends on Foundational and US1 (logout button lives in App.vue which needs the router guard from US1)
- **US3 (P3)**: Depends on Foundational only — authStore persistence is independent of the login form; can be developed in parallel with US1 if desired
### Within Each User Story
- Foundational tasks (T003T006) must all complete before US1 starts
- T007 (LoginView) and T008 (api.ts) can be developed in parallel within US1; T009 (router guard) depends on T007 existing
- US2 is a single task (T010) with no internal ordering complexity
- T012 (main.ts restore check) depends on T011 (authStore persistence) within US3
---
## Parallel Example: Foundational Phase
```
Parallelizable within Phase 2:
T003 — application.yaml update
T004 — SecurityConfig.java update
T005 — AuthController.java (new file)
T006 — authStore.ts (new file)
All four touch different files with no shared dependencies.
```
## Parallel Example: User Story 1
```
Parallelizable within Phase 3:
T007 — LoginView.vue (new file)
T008 — api.ts update (different file)
Then sequentially:
T009 — router/index.ts (depends on LoginView existing)
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Confirm existing deps
2. Complete Phase 2: Foundational — backend endpoint + auth store
3. Complete Phase 3: US1 — login page, axios interceptors, router guard
4. **STOP and VALIDATE**: Open incognito, verify redirect → login → success flow
5. Demo / merge if MVP is sufficient
### Incremental Delivery
1. Phase 1 + Phase 2 → Foundation ready
2. Phase 3 (US1) → Login gate works — **MVP**
3. Phase 4 (US2) → Logout works ✅
4. Phase 5 (US3) → Session survives refresh ✅
5. Phase 6 → Documentation and cleanup ✅
---
## Notes
- [P] tasks touch different files and have no dependency on an incomplete sibling task in the same phase
- No tests included (not requested in the spec)
- `VITE_APP_PASSWORD` should be removed from `.env.example` once T013 is done — do **not** remove it from any local `.env` file before the login form is working (T007T009 complete)
- The 401 response interceptor in T008 handles the edge case where stored credentials become invalid server-side — no additional handling needed
- Constitution IV requires the README Mermaid diagram to be updated in the **same PR** — T014 must not be skipped
@@ -0,0 +1,34 @@
# Specification Quality Checklist: RAG Retrieval Quality Improvements
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-06
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items pass. Ready to proceed to `/speckit.clarify` or `/speckit.plan`.
@@ -0,0 +1,33 @@
# API Contract: Chat (unchanged endpoints)
**Branch**: `004-rag-retrieval-quality` | **Date**: 2026-04-06
## No endpoint changes
This feature makes no changes to the public API surface. All existing `/api/v1/chat/...` endpoints remain identical in path, method, request body, and response shape.
## Message response — `sources` field addition
The only observable change to callers is that each source entry in `Message.sources` gains an optional `refLabel` field. This is backwards-compatible (additive only).
### Existing contract (unchanged)
```
POST /api/v1/chat/sessions/{sessionId}/messages
Body: { "content": "..." }
Response: Message { id, sessionId, role, content, sources: [...], createdAt }
```
### Source entry schema (additive change)
Before:
```json
{ "type": "TEXT", "bookTitle": "...", "page": 142, "chunkText": "..." }
```
After (new optional field):
```json
{ "type": "TEXT", "refLabel": "S1", "bookTitle": "...", "page": 142, "chunkText": "..." }
```
Frontend consumers that ignore unknown fields are unaffected. `ChatMessage.vue` may optionally use `refLabel` for future inline citation linking.
@@ -0,0 +1,68 @@
# API Contract: Topics — Summary Persistence
**Base path**: `/api/v1/topics`
---
## Existing endpoint (unchanged behaviour, extended response)
### `POST /api/v1/topics/{id}/summary`
Generates a new summary for the topic, **persists it**, and returns it.
**Path param**: `id` — topic id (e.g. `intracranial-aneurysms`)
**Response** `200 OK`:
```json
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"summaryNumber": 3,
"topicId": "intracranial-aneurysms",
"topicName": "Intracranial Aneurysms",
"summary": "## Key Points\n...",
"sources": [
{ "bookId": "uuid", "bookTitle": "Youmans & Winn", "page": 142 }
],
"generatedAt": "2026-04-07T10:23:00Z"
}
```
**Error responses**:
- `404 Not Found` — topic id does not exist
- `503 Service Unavailable` — no books ready (existing `NoKnowledgeSourceException`)
---
## New endpoints
### `GET /api/v1/topics/{id}/summaries`
Returns the list of saved summaries for a topic (no full text — list metadata only).
**Path param**: `id` — topic id
**Response** `200 OK`:
```json
[
{ "id": "uuid-1", "summaryNumber": 1, "generatedAt": "2026-04-06T08:00:00Z" },
{ "id": "uuid-2", "summaryNumber": 2, "generatedAt": "2026-04-06T09:15:00Z" }
]
```
Ordered ascending by `summaryNumber`. Empty array if no summaries saved yet.
**Error responses**:
- `404 Not Found` — topic id does not exist
---
### `GET /api/v1/topics/{id}/summaries/{summaryId}`
Fetches the full content of a specific saved summary.
**Path params**: `id` — topic id, `summaryId` — summary UUID
**Response** `200 OK`: same shape as `POST /summary` response above.
**Error responses**:
- `404 Not Found` — topic or summary not found
@@ -0,0 +1,91 @@
# Data Model: RAG Retrieval Quality + Topic Summary Persistence
**Branch**: `004-rag-retrieval-quality` | **Date**: 2026-04-07
---
## New persistent entity: TopicSummaryEntity
**Table**: `topic_summary`
**Migration**: `V6__topic_summary.sql`
| Column | Type | Notes |
|--------|------|-------|
| `id` | `UUID` PK | `gen_random_uuid()` default |
| `topic_id` | `VARCHAR(100)` NOT NULL | FK to `topic.id` |
| `summary_number` | `INT` NOT NULL | Sequential per topic (1, 2, 3, …). Set at insert time: `COUNT(*) WHERE topic_id = ? + 1`. |
| `summary` | `TEXT` NOT NULL | Full markdown summary text |
| `sources_json` | `TEXT` NOT NULL | JSON array of `SourceReference` objects (same structure as `TopicSummaryResponse.sources`) |
| `generated_at` | `TIMESTAMPTZ` NOT NULL | UTC timestamp of generation |
**Constraints**: no unique constraint on `summary_number` (sequential, not concurrent-safe for POC). No FK constraint enforced at DB level (topic ids are static seed data).
---
## In-memory objects (new, from RAG quality work)
### ExpandedQuery (value object, not persisted)
Produced by `QueryExpansionService` for each user message.
| Field | Type | Description |
|-------|------|-------------|
| `original` | `String` | The user's literal question |
| `rewritten` | `String` | Clinically rewritten version used for vector search |
---
### LabelledContext (value object, not persisted)
Produced by `ChatService.buildContextPrompt()` to track the mapping from ref-labels to source entities.
| Field | Type | Description |
|-------|------|-------------|
| `sectionLabels` | `Map<String, SectionEntity>` | e.g. `{"S1" → SectionEntity, "S2" → SectionEntity}` |
| `figureLabels` | `Map<String, FigureEntity>` | e.g. `{"F1" → FigureEntity}` |
| `promptText` | `String` | The fully formatted context prompt including `[S1]`, `[F1]` tags |
---
## New API DTOs
### SavedSummaryItem (list view — no full text)
```java
record SavedSummaryItem(UUID id, int summaryNumber, Instant generatedAt) {}
```
Used in `GET /api/v1/topics/{id}/summaries` to show the summary history list without transmitting full text.
### TopicSummaryResponse (existing, extended)
Adds `id` (UUID) and `summaryNumber` (int) fields so the frontend knows which saved record was just created.
---
## Existing entities (unchanged)
| Entity | Table | Change |
|--------|-------|--------|
| `SectionEntity` | `section` | None |
| `FigureEntity` | `figure` | None |
| `Message` | `message` | `sources` field gets `refLabel` key added per entry |
| `ChatSession` | `chat_session` | None |
| `Book` | `book` | None |
| `Topic` | `topic` | None |
---
## Message.sources structure (existing, clarified)
After the RAG quality feature each entry includes `refLabel`:
```json
{
"type": "TEXT",
"refLabel": "S1",
"bookTitle": "Youmans & Winn Neurological Surgery",
"page": 142,
"chunkText": "..."
}
```
+108
View File
@@ -0,0 +1,108 @@
# Implementation Plan: RAG Retrieval Quality + Topic Summary Persistence
**Branch**: `004-rag-retrieval-quality` | **Date**: 2026-04-07 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/004-rag-retrieval-quality/spec.md` + user request: "Save summaries by topic; list previously saved summaries; button to generate new."
## Summary
Improve RAG retrieval quality by (1) expanding user queries via LLM rewrite to bridge vocabulary gaps and (2) validating generated citations against the retrieved context to eliminate hallucinated references. Additionally, persist generated topic summaries to the database so students can revisit past summaries and generate new ones on demand.
## Technical Context
**Language/Version**: Java 21 (backend), TypeScript / Node 20 (frontend)
**Primary Dependencies**: Spring Boot 4.0.5, Spring AI 2.0.0-M4, OpenAI API (chat + embeddings), Vue 3.4, Pinia 2.1, Axios 1.7
**Storage**: PostgreSQL (JPA + Flyway), pgvector (`VectorStore`)
**Testing**: JUnit 5, Spring Boot Test, Vitest
**Target Platform**: Linux server
**Project Type**: Web application (backend API + Vue frontend)
**Performance Goals**: RAG query latency remains acceptable (one additional LLM call for query expansion, ~12s overhead)
**Constraints**: KISS — no new architectural layers; no new external services; single deployable backend + frontend
## Constitution Check
| Principle | Status | Notes |
|-----------|--------|-------|
| I — KISS | ✅ Pass | Query expansion = one LLM call. Citation validation = string scan. Summary persistence = one new table + minimal service change. No new layers. |
| II — Easy to Change | ✅ Pass | `QueryExpansionService`, `CitationValidatorService`, `TopicSummaryRepository` are small, focused interfaces. Swappable without touching callers. |
| III — Web-First | ✅ Pass | All new functionality exposed via REST at `/api/v1/...`. Frontend-only UI changes. |
| IV — Documentation as Architecture | ⚠️ Action needed | README must be updated to reflect new `topic_summary` table and the summary history flow. Update in the same PR. |
## Project Structure
### Documentation (this feature)
```text
specs/004-rag-retrieval-quality/
├── plan.md # This file
├── research.md # Phase 0 — decisions on query expansion, citation grounding, summary persistence
├── data-model.md # Phase 1 — topic_summary table, ExpandedQuery, LabelledContext DTOs
├── contracts/
│ ├── chat-api.md # Existing chat API (updated for labelled sources)
│ └── topics-api.md # New/updated topics API (summary list + detail endpoints)
└── tasks.md # Phase 2 output (/speckit.tasks command)
```
### Source Code
```text
backend/
├── src/main/java/com/aiteacher/
│ ├── retrieval/
│ │ ├── QueryExpansionService.java (NEW) — LLM rewrite of user query
│ │ ├── ExpandedQuery.java (NEW) — value object {original, rewritten}
│ │ ├── LabelledContext.java (NEW) — value object {sectionLabels, figureLabels, promptText}
│ │ └── CitationValidatorService.java (NEW) — strip unknown [Sx]/[Fx] refs from answer text
│ └── topic/
│ ├── TopicSummaryEntity.java (NEW) — JPA entity for topic_summary table
│ ├── TopicSummaryRepository.java (NEW) — findByTopicIdOrderBySummaryNumberAsc
│ ├── SavedSummaryItem.java (NEW) — DTO for list view (id, summaryNumber, generatedAt)
│ ├── TopicSummaryResponse.java (MODIFY) — add id: UUID, summaryNumber: int fields
│ ├── TopicSummaryService.java (MODIFY) — persist after generation; add list/get methods
│ └── TopicController.java (MODIFY) — add GET /summaries and GET /summaries/{id}
├── src/main/resources/db/migration/
│ └── V6__topic_summary.sql (NEW) — create topic_summary table
└── chat/
└── ChatService.java (MODIFY) — use QueryExpansionService + CitationValidatorService
frontend/
└── src/
├── stores/
│ └── topicStore.ts (MODIFY) — add fetchSummaries(), fetchSummaryDetail(), summaryList state
└── views/
└── TopicsView.vue (MODIFY) — show summary list when topic selected, "Generate New" button
```
**Structure Decision**: Option 2 (web application). Existing `backend/` + `frontend/` directories. No new top-level directories.
## Complexity Tracking
> No constitution violations introduced.
---
## Phase 0: Research (complete)
See [research.md](research.md). All decisions resolved:
- Query expansion: single LLM rewrite call
- Citation grounding: ref-label tagging + post-generation string scan
- Summary persistence: new `topic_summary` table, sequential numbering at insert time
## Phase 1: Design (complete)
### Data model
See [data-model.md](data-model.md). New `topic_summary` table; two new in-memory value objects; `TopicSummaryResponse` extended with `id` + `summaryNumber`.
### API contracts
See [contracts/topics-api.md](contracts/topics-api.md):
- `POST /api/v1/topics/{id}/summary` — now persists and returns `id` + `summaryNumber`
- `GET /api/v1/topics/{id}/summaries` — list metadata (no full text)
- `GET /api/v1/topics/{id}/summaries/{summaryId}` — full detail
### Frontend behaviour
When a **topic card is clicked** (before generating):
1. Call `GET /api/v1/topics/{id}/summaries`.
2. If summaries exist → show list panel with chips: "Summary #1 · Apr 6", "Summary #2 · Apr 7", … + a "Generate New" button.
3. If no summaries → show "Generate Summary" button (current behaviour).
4. Clicking a chip → call `GET /api/v1/topics/{id}/summaries/{summaryId}` → display the saved summary.
5. Clicking "Generate New" → call `POST /api/v1/topics/{id}/summary` → display new summary, refresh list.
@@ -0,0 +1,71 @@
# Research: RAG Retrieval Quality Improvements
**Branch**: `004-rag-retrieval-quality` | **Date**: 2026-04-06
## Decision 1: Query Expansion Strategy
**Decision**: Single LLM rewrite — ask the model to restate the user's question using clinical/technical terminology before retrieval. Use the rewritten query for vector search instead of (or in addition to) the original.
**Rationale**: The simplest approach that directly addresses vocabulary mismatch. A single extra LLM call rewrites the query into the language of the documentation (clinical terms), so the embedding similarity search has a much better chance of matching. No new dependencies, no index changes.
**Alternatives considered**:
- *HyDE (Hypothetical Document Embeddings)*: Ask the model to write a hypothetical answer, then embed that. More powerful but adds latency and the answer may itself hallucinate clinical content — rejected for POC.
- *Multi-query retrieval*: Generate 35 alternative queries and merge results. Effective but multiplies retrieval calls and deduplication complexity — rejected (KISS).
- *Synonym dictionary*: Pre-built medical thesaurus mapping. No new dependencies but requires maintenance and won't generalise — rejected.
- *Re-ranking*: Run a cross-encoder after retrieval. Addresses ordering, not vocabulary gap — deferred.
**Implementation note**: The rewrite prompt should be a short, focused instruction: "Rewrite the following question using precise medical/surgical terminology as it would appear in a neurosurgery textbook index. Output only the rewritten question." Use the same `ChatClient` bean; no new API client needed.
---
## Decision 2: Citation Grounding Strategy
**Decision**: Tag each retrieved context section with a short ref-label (`[S1]`, `[S2]`, …, `[Fn]` for figures) in the prompt. Instruct the model to cite only using those labels. Post-process the generated answer to detect and strip any citation that does not correspond to a known label.
**Rationale**: The simplest way to make citations verifiable — all valid citation targets are enumerated in the prompt itself. Post-processing is a pure string operation; no second LLM call needed for validation.
**Alternatives considered**:
- *Ask the model to self-check citations*: Second LLM call to verify each claim. More accurate but doubles cost and latency — rejected for POC.
- *Structured output (JSON)*: Return answer as JSON with claim/source pairs. Most precise but requires significant prompt engineering and frontend changes — deferred.
- *No citation enforcement*: Let the model cite freely and show retrieved sources separately. Already the current state — rejected because it doesn't solve citation hallucination.
**Implementation note**:
- Context sections labelled `[S1] Section Title, p.N` through `[Sk]` in `buildContextPrompt()`.
- Figures labelled `[F1]`, `[F2]` etc.
- System prompt updated: "Cite claims using ONLY the reference labels [S1]…[Sk] and [F1]…[Fj] provided in the context. Do not invent page numbers or section titles."
- `CitationValidatorService` scans generated text for `[Sx]` / `[Fx]` patterns, checks each against the known label set, and removes unknown ones.
- `Message.sources` is already a `List<Map<String,Object>>`. No schema change needed — but we store the ref-label alongside each source so the frontend can correlate.
---
## Decision 3: Frontend Source Display
**Decision**: No frontend change needed for MVP. The backend already filters out hallucinated citations via `CitationValidatorService` before saving the message. The `sources` list attached to the message is already the retrieved set. The existing `ChatMessage.vue` source panel continues to show all retrieved sources (which is correct — they were all used as context).
**Rationale**: KISS. The critical correctness fix (no hallucinated citations in answer text) is fully backend-side. The sources panel shows context that was genuinely available to the model — that is accurate and useful. Linking specific claims to specific sources (inline highlighting) is a UX enhancement that can be a follow-on feature.
**Alternatives considered**:
- *Inline citation links*: Highlight `[S1]` in answer text and link to the source card. Better UX but requires markdown parsing and component changes — deferred.
- *Show only cited sources*: Filter `sources` to only those actually cited in the answer. Marginally more accurate but the uncited context is still legitimately retrieved — deferred.
---
## Decision 4: Persisting Topic Summaries
**Decision**: Add a new `topic_summary` table that stores each generated summary. Summaries are numbered sequentially per topic (summary #1, #2, …). The POST endpoint continues to generate and now also persists. A new GET endpoint lists saved summaries for a topic.
**Rationale**: The simplest approach — one new table, one new repository, minimal changes to the existing service. No caching layer, no event system. The sequential number is derived at query time via `ROW_NUMBER` or counted in the repository, keeping the schema minimal.
**Alternatives considered**:
- *Store in frontend state only (session memory)*: Already the current state — summaries are lost on reload. Rejected because the user explicitly wants persistence.
- *Store summary_number as a persisted column*: Avoids a query-time count but risks gaps/duplicates on concurrent writes. For a POC with single-user use, a `SELECT COUNT(*) + 1` approach at insert time is sufficient.
- *Versioning / soft-delete*: Overkill for a POC where the user just wants to re-read old summaries. No delete endpoint needed initially.
**Implementation note**:
- New Flyway migration `V6__topic_summary.sql`.
- `TopicSummaryEntity` (JPA `@Entity` → table `topic_summary`): `id` (UUID), `topicId` (VARCHAR FK), `summaryNumber` (INT), `summary` (TEXT), `sourcesJson` (TEXT — JSON array), `generatedAt` (TIMESTAMP).
- `summaryNumber` set at insert time: `COUNT(*) WHERE topic_id = ?` + 1.
- `TopicSummaryRepository extends JpaRepository<TopicSummaryEntity, UUID>` with a `findByTopicIdOrderBySummaryNumberAsc` query.
- `TopicSummaryService.generateSummary()` saves the result before returning it.
- `TopicController` gains `GET /api/v1/topics/{id}/summaries` returning `List<SavedSummaryResponse>` (id, summaryNumber, generatedAt — no full text, for list efficiency) and `GET /api/v1/topics/{id}/summaries/{summaryId}` for the full detail.
- Frontend: when a topic card is clicked, fetch the summary list. Show "Summary #1", "Summary #2" chips + "Generate New" button. Clicking a chip loads the full summary.
+111
View File
@@ -0,0 +1,111 @@
# Feature Specification: RAG Retrieval Quality Improvements
**Feature Branch**: `004-rag-retrieval-quality`
**Created**: 2026-04-06
**Status**: Draft
**Input**: User description: "I want to enhance the current RAG system, to avoid common pitfalls like: Vocabulary mismatch in retrieval, where the user's language doesn't overlap with the documentation's terminology and Citation errors in generation, where the model cites a chunk that either wasn't retrieved or doesn't support the specific claim being made"
## Overview
The AI Teacher's question-answering system retrieves relevant content from neurosurgery documentation and generates answers. Two reliability problems reduce trust in the system:
1. **Vocabulary mismatch**: A student asking "what happens after cutting the skull?" may use everyday language while the documentation uses clinical terms like "craniotomy" — causing relevant passages to be missed entirely.
2. **Citation hallucination**: The model sometimes references a section or page in its answer that was not actually retrieved or that does not support the specific claim made, misleading students.
This feature improves both the accuracy of what gets retrieved and the integrity of what the model claims as its sources.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Accurate Retrieval Despite Different Terminology (Priority: P1)
A medical student asks a question using lay or imprecise language. The system bridges the gap between their vocabulary and the technical terminology used in the textbook, returning contextually relevant passages even when there is no word overlap between the question and the document text.
**Why this priority**: Vocabulary mismatch is the most frequent silent failure — the system returns an answer but based on wrong or empty context, so students receive incorrect information without realising it.
**Independent Test**: Ask the system a question using a common synonym or lay term for a concept that appears in the documentation only under a clinical name. Verify that the retrieved passages contain relevant content about that concept.
**Acceptance Scenarios**:
1. **Given** a question uses a lay term (e.g., "brain swelling"), **When** the system retrieves content, **Then** it returns passages discussing the medically indexed equivalent ("cerebral edema") even though that phrase was not in the question.
2. **Given** a question uses an acronym the documentation spells out fully, **When** retrieval runs, **Then** relevant passages are still found.
3. **Given** a highly technical question that perfectly matches documentation language, **When** retrieval runs, **Then** quality does not regress compared to current behavior.
---
### User Story 2 - Grounded Citation in Generated Answers (Priority: P1)
When the model produces an answer and references a source (section, page, or figure), that source must have been part of the retrieved context and must genuinely support the specific claim being cited.
**Why this priority**: A hallucinated citation is worse than no citation — it gives students false confidence that a claim is documented when it is not.
**Independent Test**: Submit a query, capture the retrieved context passed to the model, and verify that every source reference in the generated answer maps to an identifier present in that context.
**Acceptance Scenarios**:
1. **Given** retrieved context contains sections A, B, and C, **When** the model generates an answer citing "Section D", **Then** that citation is either removed or flagged before the answer is shown to the user.
2. **Given** a retrieved passage is used as context, **When** the model cites it, **Then** the cited passage actually contains the information the citation supports.
3. **Given** no relevant context was retrieved for part of a query, **When** the model responds, **Then** it acknowledges uncertainty rather than fabricating a source.
---
### User Story 3 - User Visibility into Retrieval Confidence (Priority: P2)
A student can see which parts of the answer are well-supported by the retrieved material and which parts carry lower confidence, allowing them to judge how much to rely on each claim.
**Why this priority**: Transparency builds appropriate trust — students should know when the system is uncertain rather than presenting all answers with equal authority.
**Independent Test**: Ask a question where only partial relevant content exists in the corpus. Verify the answer visually differentiates well-supported claims from lower-confidence statements.
**Acceptance Scenarios**:
1. **Given** an answer contains a claim backed by a directly retrieved passage, **When** displayed, **Then** the source is shown alongside the claim.
2. **Given** an answer contains a claim not directly covered by retrieved content, **When** displayed, **Then** the system signals lower confidence or absence of a source for that claim.
---
### Edge Cases
- What happens when query enrichment produces terms that retrieve completely unrelated passages?
- How does the system behave when the retrieved context is very short or empty?
- What if two retrieved sections contradict each other — how does citation work in that case?
- What if citation verification removes all citations from an answer (model cited nothing valid)?
- How does the system handle questions in languages other than the documentation language?
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST enrich user queries to bridge vocabulary gaps before retrieval, using the domain context of the book being queried.
- **FR-002**: The system MUST retrieve passages based on the enriched query, not solely on the literal user input.
- **FR-003**: The system MUST pass only verified, retrieved passage identifiers to the generation step as eligible citation targets.
- **FR-004**: The system MUST validate that every source reference in a generated answer corresponds to a passage present in the retrieved context.
- **FR-005**: The system MUST suppress or flag any citation in the generated answer that refers to a passage not present in the retrieved context.
- **FR-006**: The system MUST surface to the user which retrieved passages were used to support each claim (or indicate absence of supporting source).
- **FR-007**: When no relevant passages are retrieved, the system MUST communicate this clearly rather than generating an unsupported answer.
- **FR-008**: Query enrichment MUST be scoped to the active book to avoid introducing terminology from unrelated domains.
### Key Entities
- **Enriched Query**: The augmented version of the user's original question, including synonyms, alternate phrasings, or domain-aligned terms used for retrieval.
- **Retrieved Context**: The set of passages (sections and figures) returned by retrieval, each identified by a unique source reference, passed to generation.
- **Citation**: A reference in the generated answer to a specific source; must be traceable to a member of the Retrieved Context.
- **Citation Validation Result**: A per-citation judgment of whether the cited source was retrieved and supports the claim.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Questions using common lay synonyms for clinical terms retrieve relevant passages at least 80% of the time (verified on a manually curated test set of synonym-query pairs).
- **SC-002**: Zero citations appear in generated answers that reference a source not present in the retrieved context passed to the model.
- **SC-003**: For every generated answer, 100% of displayed citations can be traced to a retrieved passage identifier.
- **SC-004**: Retrieval quality for queries that already match documentation vocabulary does not degrade (baseline score maintained or improved).
- **SC-005**: Users can identify, for each factual claim in an answer, whether a supporting source was found — without needing to ask a follow-up question.
## Assumptions
- The existing retrieval pipeline returns section and figure identifiers that can be used as citation anchors.
- Query enrichment operates at query time (not ingestion time), so no changes to stored embeddings are needed.
- The generation model can be instructed via prompt to restrict citations to a provided list of identifiers.
- Citation validation is performed after generation but before the answer is shown to the user (post-processing step).
- Mobile or offline support is out of scope for this feature.
- Multi-language support (non-English questions against English documentation) is a future concern and not addressed here.
+250
View File
@@ -0,0 +1,250 @@
# Tasks: RAG Retrieval Quality Improvements
**Input**: Design documents from `/specs/004-rag-retrieval-quality/`
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/ ✅
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
**Tests**: Not requested in spec — no test tasks generated.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (US1, US2, US3)
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: No new project structure needed — services are added to the existing `retrieval/` package. This phase is a single verification step.
- [x] T001 Verify active branch is `004-rag-retrieval-quality` and `backend/src/main/java/com/aiteacher/retrieval/` exists
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Two lightweight value objects shared by both user stories.
**⚠️ CRITICAL**: Both user story phases depend on these records being present.
- [x] T002 Create `ExpandedQuery` record in `backend/src/main/java/com/aiteacher/retrieval/ExpandedQuery.java` with fields `String original` and `String rewritten`
- [x] T003 [P] Create `LabelledContext` record in `backend/src/main/java/com/aiteacher/retrieval/LabelledContext.java` with fields `Map<String, SectionEntity> sectionLabels`, `Map<String, FigureEntity> figureLabels`, and `String promptText`
**Checkpoint**: Foundation ready — US1 and US2 implementation can begin in parallel
---
## Phase 3: User Story 1 — Accurate Retrieval Despite Different Terminology (Priority: P1) 🎯 MVP
**Goal**: Before each retrieval call, rewrite the user's question into clinical terminology so that vector search finds relevant sections even when the user uses lay language.
**Independent Test**: Ask "what happens after cutting the skull?" — verify retrieved sections contain content about craniotomy without that word appearing in the query.
### Implementation for User Story 1
- [x] T004 [US1] Create `QueryExpansionService` in `backend/src/main/java/com/aiteacher/retrieval/QueryExpansionService.java`:
- Constructor-inject `ChatClient`
- Method `expand(String query): ExpandedQuery`
- LLM prompt: *"Rewrite the following question using precise medical/surgical terminology as it would appear in a neurosurgery textbook index. Output only the rewritten question, nothing else. Question: {query}"*
- Return `new ExpandedQuery(query, rewrittenText)`
- Annotate with `@Service`
- [x] T005 [US1] Modify `NeurosurgeryRetriever.retrieve()` in `backend/src/main/java/com/aiteacher/retrieval/NeurosurgeryRetriever.java`:
- Change method signature from `retrieve(String query, UUID bookId)` to `retrieve(String query, UUID bookId)` — no signature change; just use `query` for vector search (already correct; no change needed here unless query is pre-expanded by caller)
- *Note*: expansion is done in ChatService before calling retrieve, so no change to NeurosurgeryRetriever is required
- [x] T006 [US1] Modify `ChatService` in `backend/src/main/java/com/aiteacher/chat/ChatService.java`:
- Constructor-inject `QueryExpansionService`
- In `sendMessage()`, call `queryExpansionService.expand(fullQuestion)` before the retrieval loop
- Pass `expandedQuery.rewritten()` to `retriever.retrieve()` instead of `fullQuestion`
- Keep passing `fullQuestion` (original) to `buildContextPrompt()` so the QUESTION block shown to the model reflects what the user actually asked
**Checkpoint**: User Story 1 fully functional — retrieval now uses clinically rewritten queries
---
## Phase 4: User Story 2 — Grounded Citation in Generated Answers (Priority: P1)
**Goal**: Tag all retrieved sections and figures with short ref-labels (`[S1]`, `[F1]`…) in the prompt, instruct the model to cite only those labels, then post-process the answer to strip any citation referencing a label that was not provided.
**Independent Test**: Trigger a question where only sections S1S3 are retrieved. Verify the generated answer contains no citation outside that set, and the `sources` list in the response carries `refLabel` fields.
### Implementation for User Story 2
- [x] T007 [US2] Create `CitationValidatorService` in `backend/src/main/java/com/aiteacher/retrieval/CitationValidatorService.java`:
- Annotate with `@Service`
- Method `validate(String generatedAnswer, Set<String> validLabels): String`
- Scan `generatedAnswer` for occurrences of `[Sn]` and `[Fn]` patterns using a regex like `\[(S|F)\d+\]`
- Remove (or replace with empty string) any match whose label is not in `validLabels`
- Return the cleaned answer text
- [x] T008 [US2] Modify `ChatService.buildContextPrompt()` in `backend/src/main/java/com/aiteacher/chat/ChatService.java`:
- Change signature to return `LabelledContext` instead of `String`
- Assign sequential labels: sections get `S1`, `S2`, …; figures get `F1`, `F2`, …
- Prefix each section block with its label: `[S1] Section Title, p.N\n{fullText}\n\n`
- Prefix each figure line with its label: `[F1] Fig. X (p.N): caption`
- Populate `sectionLabels` and `figureLabels` maps in the returned `LabelledContext`
- Store the full formatted prompt in `LabelledContext.promptText()`
- [x] T009 [US2] Update system prompt constant in `backend/src/main/java/com/aiteacher/chat/ChatService.java`:
- Replace the citation rule *"Cite sources for each major point (book title and page number from the context)"* with: *"Cite claims using ONLY the reference labels provided in the context (e.g. [S1], [F2]). Do not invent page numbers, section titles, or labels not present in the CONTEXT block."*
- [x] T010 [US2] Wire `CitationValidatorService` into `ChatService.sendMessage()` in `backend/src/main/java/com/aiteacher/chat/ChatService.java`:
- Constructor-inject `CitationValidatorService`
- After the `chatClient.prompt()...call().content()` call, pass `assistantContent` and the label set from `LabelledContext` to `citationValidatorService.validate()`
- Use the validated string as `assistantContent` going forward
- [x] T011 [US2] Modify `buildSources()` in `backend/src/main/java/com/aiteacher/chat/ChatService.java`:
- Accept the `LabelledContext` (or its two maps) as an additional parameter
- Add `"refLabel"` entry to each source map: e.g. `source.put("refLabel", "S1")` for sections, `source.put("refLabel", "F1")` for figures
- Keep all other existing fields unchanged
- [x] T012 [US2] Update `sendMessage()` call chain in `backend/src/main/java/com/aiteacher/chat/ChatService.java` to thread `LabelledContext` through steps T008T011:
- `LabelledContext ctx = buildContextPrompt(fullQuestion, allSections, allFigures)`
- Pass `ctx.promptText()` to the LLM call
- Pass `ctx` label maps to `validate()` and `buildSources()`
**Checkpoint**: User Stories 1 and 2 both fully functional — queries are expanded, citations are grounded
---
## Phase 5: User Story 3 — User Visibility into Retrieval Confidence (Priority: P2)
**Goal**: The answer text contains `[S1]`-style labels (after US2). This phase exposes them in the frontend so users can see which claim maps to which source card.
**Independent Test**: Send a question, receive an answer with inline `[S1]` labels visible in the rendered text, and confirm clicking/hovering the label highlights the corresponding source card.
**Note**: Per research.md, the backend is already complete after US1+US2. US3 is a frontend-only UX enhancement.
### Implementation for User Story 3
- [x] T013 [US3] Modify `ChatMessage.vue` in `frontend/src/components/ChatMessage.vue`:
- Parse the answer text for `[Sn]` and `[Fn]` citation labels using a regex
- Render each label as a styled inline badge (e.g. `<span class="citation-badge">[S1]</span>`)
- When a badge is clicked or hovered, highlight the corresponding source card (match by `source.refLabel`)
- [x] T014 [US3] Update source card rendering in `frontend/src/components/ChatMessage.vue`:
- Add a `data-ref-label` attribute to each source card element so it can be targeted by the citation badge interaction
- Apply a visual highlight style (CSS class) when the card is active
**Checkpoint**: All three user stories functional — full end-to-end quality improvements delivered
---
## Phase 6: User Story 4 — Topic Summary Persistence & History (user-requested)
**Goal**: Every generated topic summary is saved to the database. When a topic is selected the UI shows a numbered history list; the student can view any past summary or generate a new one.
**Independent Test**: Generate a summary for "Intracranial Aneurysms", reload the page, click the topic — verify "Summary #1" appears. Generate again — verify "Summary #2" appears. Click "Summary #1" — verify the original text loads without regeneration.
- [x] T018 Create Flyway migration `backend/src/main/resources/db/migration/V6__topic_summary.sql` — table `topic_summary` with columns: `id UUID PRIMARY KEY DEFAULT gen_random_uuid()`, `topic_id VARCHAR(100) NOT NULL`, `summary_number INT NOT NULL`, `summary TEXT NOT NULL`, `sources_json TEXT NOT NULL`, `generated_at TIMESTAMPTZ NOT NULL`
- [x] T019 [P] [US4] Create `TopicSummaryEntity.java` in `backend/src/main/java/com/aiteacher/topic/TopicSummaryEntity.java` — JPA `@Entity` mapped to table `topic_summary`; fields: `@Id UUID id`, `String topicId`, `int summaryNumber`, `String summary`, `String sourcesJson`, `Instant generatedAt`; no-arg + all-args constructor
- [x] T02X [P] [US4] Create `SavedSummaryItem.java` record in `backend/src/main/java/com/aiteacher/topic/SavedSummaryItem.java` — fields: `UUID id`, `int summaryNumber`, `Instant generatedAt` (list-view DTO, no full text)
- [x] T02X [US4] Create `TopicSummaryRepository.java` in `backend/src/main/java/com/aiteacher/topic/TopicSummaryRepository.java``extends JpaRepository<TopicSummaryEntity, UUID>`; add `List<TopicSummaryEntity> findByTopicIdOrderBySummaryNumberAsc(String topicId)` and `long countByTopicId(String topicId)`
- [x] T02X [US4] Modify `TopicSummaryResponse.java` in `backend/src/main/java/com/aiteacher/topic/TopicSummaryResponse.java` — add fields `UUID id` and `int summaryNumber` to the record components
- [x] T02X [US4] Modify `TopicSummaryService.java` in `backend/src/main/java/com/aiteacher/topic/TopicSummaryService.java` — inject `TopicSummaryRepository` and `ObjectMapper`; at end of `generateSummary()` compute `summaryNumber = (int) repository.countByTopicId(topicId) + 1`, persist a `TopicSummaryEntity` (serialise `sources` list to JSON via `objectMapper.writeValueAsString()`), and include `id` + `summaryNumber` in the returned `TopicSummaryResponse`; add `List<SavedSummaryItem> listSummaries(String topicId)` and `TopicSummaryResponse getSummary(UUID summaryId)` methods
- [x] T02X [US4] Modify `TopicController.java` in `backend/src/main/java/com/aiteacher/topic/TopicController.java` — add `@GetMapping("/{id}/summaries")` returning `List<SavedSummaryItem>` (delegates to `listSummaries`); add `@GetMapping("/{id}/summaries/{summaryId}")` returning `TopicSummaryResponse` (delegates to `getSummary`); both return 404 via `NoSuchElementException` when topic or summary not found
- [x] T02X [US4] Modify `topicStore.ts` in `frontend/src/stores/topicStore.ts` — add state `summaryList: SavedSummaryItem[]`; add `fetchSummaries(topicId)` action calling `GET /api/v1/topics/{topicId}/summaries`; add `fetchSummaryDetail(topicId, summaryId)` action calling `GET /api/v1/topics/{topicId}/summaries/{summaryId}` and setting `activeSummary`; clear `summaryList` when a different topic is selected
- [x] T02X [US4] Modify `TopicsView.vue` in `frontend/src/views/TopicsView.vue` — when a topic card is clicked: (1) call `topicStore.fetchSummaries(topicId)` first; (2) if summaries exist, display a summary history list showing chips "Summary #1 · [date]", "Summary #2 · [date]", … + a "Generate New" button; (3) clicking a chip calls `fetchSummaryDetail()` and renders the saved summary in the existing panel; (4) clicking "Generate New" calls `handleGenerate()` then re-calls `fetchSummaries()` to refresh the list; (5) if no summaries exist, show only the "Generate Summary" button (current behaviour)
**Checkpoint**: Summary persistence fully working end-to-end. US4 independently testable.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Constitution IV compliance and cleanup.
- [x] T027 Update `README.md` Mermaid architecture diagram to add `QueryExpansionService` and `CitationValidatorService` to the chat pipeline flow, and the `topic_summary` table to the data diagram (required by Constitution Principle IV — must be in the same PR)
- [x] T028 [P] Log the expanded query at DEBUG level in `QueryExpansionService` (e.g. `log.debug("Query expanded: '{}' → '{}'", original, rewritten)`) for observability
- [x] T029 [P] Log stripped citation labels at WARN level in `CitationValidatorService` when any labels are removed (e.g. `log.warn("Stripped hallucinated citations: {}", removedLabels)`)
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: No dependencies — start immediately
- **Phase 2 (Foundational)**: Depends on Phase 1 — blocks all user story phases
- **Phase 3 (US1)**: Depends on Phase 2 (needs `ExpandedQuery`)
- **Phase 4 (US2)**: Depends on Phase 2 (needs `LabelledContext`); can run in parallel with Phase 3
- **Phase 5 (US3)**: Depends on Phase 4 (needs `refLabel` in sources)
- **Phase 6 (US4)**: No dependency on Phase 2 for the migration (T018); entity/service work (T019+) depends on T018
- **Phase 7 (Polish)**: Depends on all implementation phases complete
### User Story Dependencies
- **User Story 1 (P1)**: Depends on Phase 2 only — no dependency on US2 or US3
- **User Story 2 (P1)**: Depends on Phase 2 only — can run in parallel with US1
- **User Story 3 (P2)**: Depends on US2 (needs `refLabel` in the API response)
- **User Story 4**: Independent of US1US3 — can start immediately after T018 migration
### Within Each User Story
- T004 → T006 (QueryExpansionService must exist before ChatService wiring)
- T007 → T010 → T012 (CitationValidatorService → wire into sendMessage → thread context)
- T008 → T012 (LabelledContext must be built before threading through)
- T013 → T014 (badge rendering before card targeting)
### Parallel Opportunities
- T002 and T003 (Phase 2) can run in parallel — different files
- Phase 3 (US1) and Phase 4 (US2) can run in parallel after Phase 2 — all different files
- T015, T016, T017 (Polish) can run in parallel — different files
---
## Parallel Example: US1 + US2
```
After Phase 2 completes:
Track A (US1):
T004 — Create QueryExpansionService
T005 — (no change to NeurosurgeryRetriever)
T006 — Wire into ChatService
Track B (US2):
T007 — Create CitationValidatorService
T008 — Modify buildContextPrompt() → LabelledContext
T009 — Update system prompt
T010 — Wire CitationValidatorService into sendMessage()
T011 — Add refLabel to buildSources()
T012 — Thread LabelledContext through call chain
Merge point: Both tracks modify ChatService — coordinate T006 and T012
to avoid conflicts (implement T006 first or use feature branches).
```
---
## Implementation Strategy
### MVP First (User Stories 1 + 2 — both P1)
1. Complete Phase 1: Setup (T001)
2. Complete Phase 2: Foundational (T002, T003)
3. Complete Phase 3: US1 — query expansion (T004T006)
4. **VALIDATE**: Ask a lay-language question; confirm relevant clinical passages are retrieved
5. Complete Phase 4: US2 — citation grounding (T007T012)
6. **VALIDATE**: Confirm no `[Sx]` label appears in the answer that wasn't in the retrieved set
7. **STOP and DEMO**: Both P1 stories deliver the core reliability improvements
### Incremental Delivery
1. Phase 1 + 2 → infrastructure ready
2. Phase 3 → vocabulary mismatch fixed → demo-able
3. Phase 4 → citation hallucination fixed → demo-able
4. Phase 5 → citation badges in UI → UX polish
5. Phase 6 → README + logging → PR-ready
---
## Notes
- `ChatService` is modified by both US1 (T006) and US2 (T008T012) — coordinate edits or implement sequentially
- `buildContextPrompt()` changes return type from `String` to `LabelledContext` (T008) — update all callers in the same task
- The system prompt change (T009) is a one-line string edit inside `ChatService`; no separate class needed
- `CitationValidatorService` operates purely on strings — no DB or AI dependency, easy to unit-test manually
- US3 frontend tasks (T013T014) are entirely in `ChatMessage.vue` — no backend change
@@ -0,0 +1,47 @@
# Build Contract: Native Image
**Branch**: `005-native-image-deployment` | **Date**: 2026-04-07
## Overview
This contract defines what the native build produces and how consumers (developers, CI, docker-compose)
interact with it. The REST API contract is **unchanged** — all existing endpoints remain identical.
## Build Inputs
| Input | Required | Description |
|-------|----------|-------------|
| GraalVM JDK 25 | Yes (native profile only) | `JAVA_HOME` must point to GraalVM 25 |
| `DOCKER_HOST` / Docker daemon | Optional | Required for `jib:dockerBuild`; not needed for `jib:build` |
| `jib.to.image` Maven property | Optional | Override target image name; defaults to `ai-teacher-backend` |
## Build Outputs
| Profile | Command | Output |
|---------|---------|--------|
| Default (JVM) | `mvn package` | `target/ai-teacher-backend-*.jar` |
| Native | `mvn -Pnative package` | `target/ai-teacher-backend` (native executable) |
| Native + Docker | `mvn -Pnative package jib:dockerBuild` | Local Docker image `ai-teacher-backend:latest` |
| Native + Registry | `mvn -Pnative package jib:build` | Remote Docker image (configured via properties) |
## Docker Image Contract
| Property | Value |
|----------|-------|
| Base image | `gcr.io/distroless/base-nossl-debian12` |
| Entrypoint | `/app/ai-teacher-backend` (native executable) |
| Exposed port | `8080` |
| Architecture | `linux/amd64` (matches build host) |
| Required env vars | Same as JVM mode (`SPRING_DATASOURCE_URL`, `OPENAI_API_KEY`, etc.) |
## REST API Contract
Unchanged — the native image exposes the same HTTP API as the JVM image:
- `GET /api/v1/books`
- `POST /api/v1/books` (multipart PDF upload)
- `DELETE /api/v1/books/{id}`
- `POST /api/v1/chat`
- `GET /api/v1/chat/history`
All endpoints use the same request/response schemas.
@@ -0,0 +1,33 @@
# Data Model: Native Image Deployment
**Branch**: `005-native-image-deployment` | **Date**: 2026-04-07
## Summary
This feature introduces **no new data entities**. It is a build and packaging change only.
The existing data model (books, sections, figures, messages, pgvector embeddings) is unchanged.
All Flyway migrations run identically in native mode as in JVM mode.
## Impact on Existing Entities
| Entity | Change |
|--------|--------|
| Books | None |
| Sections | None |
| Figures | None |
| Messages | None |
| pgvector embeddings | None |
## Configuration Properties (new/changed)
The following new or updated configuration properties are added for native deployment:
| Property | Description | Default |
|----------|-------------|---------|
| `jib.to.image` | Target Docker image name/tag | (set at build time) |
| `jib.to.auth.username` | Docker registry username | (CI env var) |
| `jib.to.auth.password` | Docker registry password | (CI env var) |
All runtime configuration (database URL, OpenAI API key, S3 credentials) remains in
`application.properties` / environment variables — unchanged.
+213
View File
@@ -0,0 +1,213 @@
# Implementation Plan: Native Image Deployment
**Branch**: `005-native-image-deployment` | **Date**: 2026-04-07 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/005-native-image-deployment/spec.md`
## Summary
Add a `native` Maven profile to the backend that compiles the Spring Boot 4.0.5 application to a
GraalVM 25 native executable, then packages it into a minimal Docker image via Jib. The JVM build
(default profile + existing Dockerfile) remains unchanged. A companion `docker-compose.native.yml`
enables local full-stack testing with the native image. README.md is updated with build
instructions and an updated architecture diagram.
## Technical Context
**Language/Version**: Java 25 (backend), TypeScript / Node 20 (frontend)
**Primary Dependencies**: Spring Boot 4.0.5, Spring AI 2.0.0-M4, `native-maven-plugin` 0.10.6,
`jib-maven-plugin` 3.4.5, `jib-native-image-extension-maven` 0.1.0
**Storage**: PostgreSQL 16 + pgvector (unchanged)
**Testing**: Spring Boot Test / JUnit 5 (unchanged); native integration test via smoke-test on
produced Docker image
**Target Platform**: Linux x86_64 container (Docker), GraalVM 25 CE or Oracle GraalVM 25
**Project Type**: Web application — backend API + frontend client (Option 2 structure)
**Performance Goals**: Backend container starts in < 1 s; idle RSS < 150 MB
**Constraints**: Native profile is opt-in; JVM mode unchanged; no new runtime dependencies;
cross-compilation out of scope
**Scale/Scope**: Single backend deployable unit (1 Docker image); frontend unchanged
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| I. KISS | ✅ Pass | Build-time change only; no new runtime layers or abstractions. |
| II. Easy to Change | ✅ Pass | `native` profile is additive; default JVM profile unchanged. Native hints isolated in one `NativeHintsConfig` class. |
| III. Web-First Architecture | ✅ Pass | REST API contract unchanged. |
| IV. Documentation as Architecture | ⚠️ Required | README.md MUST be updated with native build instructions and updated Mermaid diagram showing the build pipeline. |
| Technology Constraints | ✅ Pass | Still 1 backend + 1 frontend deployable unit; no new services. |
**Post-design re-check**: After Phase 1 — Principle IV gate requires README update in same PR.
## Project Structure
### Documentation (this feature)
```text
specs/005-native-image-deployment/
├── plan.md # This file
├── spec.md # Feature specification
├── research.md # Phase 0 — toolchain & compatibility decisions
├── data-model.md # Phase 1 — no new entities (confirmed)
├── quickstart.md # Phase 1 — developer build guide
├── contracts/
│ └── build-contract.md # Build inputs/outputs & unchanged REST API
└── tasks.md # Phase 2 output (/speckit.tasks — NOT created here)
```
### Source Code (repository root)
```text
backend/
├── pom.xml # +native profile, +Jib plugin, +NativeHintsConfig
├── src/
│ └── main/
│ └── java/com/aiteacher/
│ └── config/
│ └── NativeHintsConfig.java # NEW — RuntimeHints for PDFBox, AWS SDK gaps
└── src/
└── main/
└── resources/
└── META-INF/
└── native-image/ # Optional: manual JSON hints if AOT gaps remain
docker-compose.yml # UNCHANGED (JVM mode)
docker-compose.native.yml # NEW — native image + postgres full stack
README.md # UPDATED — build instructions + Mermaid diagram
```
**Structure Decision**: Follows Option 2 (web application). Only backend is modified; frontend
is untouched. New files are minimal: one Java config class, one docker-compose file.
## Complexity Tracking
> No constitution violations. No entry required.
---
## Implementation Phases
### Phase 1: Maven `native` Profile + Native Maven Plugin
**Goal**: `mvn -Pnative package` produces a native executable `target/ai-teacher-backend`.
**Tasks**:
1. Add `<profile id="native">` to `backend/pom.xml`:
- Wire `spring-boot-maven-plugin` `process-aot` goal to `prepare-package` phase.
- Add `native-maven-plugin` 0.10.6 with `add-reachability-metadata` and `compile-no-fork`
executions.
- Set `<imageName>ai-teacher-backend</imageName>` and add essential build args:
`-H:+ReportExceptionStackTraces`, `--initialize-at-build-time=org.slf4j`.
2. Create `backend/src/main/java/com/aiteacher/config/NativeHintsConfig.java`:
- Implements `RuntimeHintsRegistrar`.
- Registers PDFBox classpath resources: `org/apache/pdfbox/resources/**`.
- Registers PDFBox reflection entries for font/encoding classes.
- Registers AWS SDK `SdkPojo` subtypes used by S3 (discovered iteratively).
- Annotate main application class or a `@Configuration` class with
`@ImportRuntimeHints(NativeHintsConfig.class)`.
3. Validate: run `mvn -Pnative package -DskipTests` and confirm executable is produced.
**Acceptance**: `target/ai-teacher-backend` exists, `./target/ai-teacher-backend --help`
exits cleanly, basic startup reaches DB connection stage.
---
### Phase 2: Jib Plugin — Package Native Image into Docker
**Goal**: `mvn -Pnative package jib:dockerBuild` produces a local Docker image.
**Tasks**:
1. Add `jib-maven-plugin` 3.4.5 to the `<build><plugins>` section (outside native profile,
but configured to package the native executable when the profile is active):
- Add `jib-native-image-extension-maven` 0.1.0 as a plugin dependency.
- Configure `<from><image>gcr.io/distroless/base-nossl-debian12</image></from>`.
- Set `<to><image>ai-teacher-backend</image></to>` (overridable via `-Djib.to.image`).
- Add `<pluginExtensions>` pointing to `JibNativeImageExtension`.
- Set `<container><ports><port>8080</port></ports></container>`.
2. Verify Jib picks up the native executable from `target/ai-teacher-backend` and sets it
as entrypoint.
3. Run `mvn -Pnative package jib:dockerBuild` and confirm:
- Image exists in local Docker: `docker images | grep ai-teacher-backend`.
- Container starts: `docker run --rm -e SPRING_DATASOURCE_URL=... ai-teacher-backend`.
**Acceptance**: Image starts, logs show "Started AiTeacherApplication in < 1.0 seconds",
and `GET /api/v1/books` returns 200 (with DB connected).
---
### Phase 3: Integration Smoke Test
**Goal**: All existing features work in native mode.
**Tasks**:
1. Start PostgreSQL locally (or via existing docker-compose.yml).
2. Start the native container with full environment variables.
3. Verify:
- Flyway migrations run successfully at startup.
- `POST /api/v1/books` (PDF upload + embedding) succeeds.
- `POST /api/v1/chat` (RAG chat) returns a non-empty answer.
- Spring Security HTTP Basic auth is enforced.
4. Fix any `MissingResourceException` or `ClassNotFoundException` by adding entries to
`NativeHintsConfig` and rebuilding.
**Acceptance**: All 5 REST API endpoints respond correctly with native image (FR-005).
---
### Phase 4: Docker Compose for Native Stack
**Goal**: `docker compose -f docker-compose.native.yml up` starts the full stack.
**Tasks**:
1. Create `docker-compose.native.yml` at repo root:
- `postgres` service: same as `docker-compose.yml` (copy).
- `backend` service: uses `ai-teacher-backend:latest` (the Jib-built native image).
- Wires all required env vars via `.env` file reference or inline secrets.
- Adds `depends_on` with health check for postgres.
2. Document the `.env` file format in `quickstart.md`.
**Acceptance**: `docker compose -f docker-compose.native.yml up` → all services healthy →
app responds at `http://localhost:8080/api/v1/books`.
---
### Phase 5: README Update (Constitution IV Gate)
**Goal**: README.md reflects the new build pipeline and updated deployment diagram.
**Tasks**:
1. Add "Native Image Build" section to README.md with:
- Prerequisite: GraalVM 25 install instructions (sdkman command).
- Build command: `mvn -Pnative package jib:dockerBuild`.
- Run command: `docker compose -f docker-compose.native.yml up`.
2. Update the Mermaid architecture diagram in README.md to show:
- Build pipeline: Maven native profile → GraalVM native-image → Jib → Docker image.
- Keep the runtime diagram unchanged (same components, just a lighter container).
**Acceptance**: README.md Mermaid diagram renders correctly on GitHub; build section
contains all commands from `quickstart.md`.
---
## Risk Log
| Risk | Likelihood | Mitigation |
|------|------------|------------|
| Spring AI 2.0.0-M4 missing native hints for some AI response types | Medium | Iterative hint discovery during Phase 3 smoke test |
| PDFBox font loading fails at runtime in native | Medium | Register full `org/apache/pdfbox/resources/**` in `NativeHintsConfig` |
| AWS SDK S3 reflection errors | Medium | Switch to `url-connection-client` (simpler); lazy-init S3Client |
| GraalVM 25 availability in CI | Low | Documented prerequisite; CI pipeline config out of scope (can be added as follow-up) |
| Native build time > 5 min | Low | Acceptable for a first build; not a blocker for POC |
@@ -0,0 +1,81 @@
# Quickstart: Native Image Build & Deploy
**Branch**: `005-native-image-deployment` | **Date**: 2026-04-07
## Prerequisites
- GraalVM JDK 25 CE or Oracle GraalVM 25 installed and set as `JAVA_HOME`
- Docker daemon running (for `jib:dockerBuild`)
- Maven 3.9+
```bash
# Install GraalVM 25 CE via sdkman
sdk install java 25-graalce
sdk use java 25-graalce
# Verify
java -version # should show GraalVM 25
native-image --version # should show GraalVM 25
```
## Build Native Docker Image (local)
```bash
cd backend
# Build native executable AND package into local Docker image
mvn -Pnative package jib:dockerBuild
# The image is now available locally
docker images | grep ai-teacher-backend
```
## Run the Full Stack (native)
```bash
# From repo root — starts PostgreSQL + native backend
docker compose -f docker-compose.native.yml up
```
Access the app at `http://localhost:8080`.
## Build and Push to Registry (CI)
```bash
mvn -Pnative package jib:build \
-Djib.to.image=ghcr.io/your-org/ai-teacher-backend:native-latest \
-Djib.to.auth.username=$REGISTRY_USER \
-Djib.to.auth.password=$REGISTRY_TOKEN
```
## JVM Build (unchanged)
```bash
# Default profile — no GraalVM required
cd backend
mvn package -DskipTests
java -jar target/ai-teacher-backend-*.jar
```
## Environment Variables (both JVM and native)
| Variable | Description |
|----------|-------------|
| `SPRING_DATASOURCE_URL` | PostgreSQL JDBC URL |
| `SPRING_DATASOURCE_USERNAME` | DB user |
| `SPRING_DATASOURCE_PASSWORD` | DB password |
| `OPENAI_API_KEY` | OpenAI API key |
| `AWS_ACCESS_KEY_ID` | S3 access key (if S3 storage enabled) |
| `AWS_SECRET_ACCESS_KEY` | S3 secret key |
| `AWS_REGION` | S3 region |
## Troubleshooting
**Build fails with "ImageGenerationFailed"**: Ensure `native-image` is on `PATH` and
`JAVA_HOME` points to GraalVM 25, not a regular JDK.
**Missing resource at runtime**: Add the resource pattern to `NativeHintsConfig` and rebuild.
**ClassNotFoundException at runtime**: Register the class in `NativeHintsConfig` with
`MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS` and rebuild.
@@ -0,0 +1,262 @@
# Research: Native Image Deployment
**Branch**: `005-native-image-deployment` | **Date**: 2026-04-07
## Decision Log
---
### 1. GraalVM Version and Java 25 Support
**Decision**: Use GraalVM JDK 25 (Oracle GraalVM or GraalVM CE 25).
**Rationale**: Oracle GraalVM 25 ships with Java 25 support and includes the
`native-image` tool. GraalVM CE 25 is available via sdkman (`sdk install java 25-graalce`).
Both support the `native-maven-plugin` 0.10.x workflow.
**Alternatives considered**:
- GraalVM 21 with Java 21: would require downgrading `java.version` in pom.xml — rejected
because pom.xml already targets Java 25.
- Mandrel (Red Hat distribution): Red Hat Mandrel 25 may lag behind Oracle GraalVM 25 release;
acceptable fallback but Oracle GraalVM 25 is preferred.
---
### 2. Native Image Build Toolchain
**Decision**: Use `native-maven-plugin` (0.10.x) inside a Maven `native` profile, combined
with Spring Boot's AOT processing via `spring-boot-maven-plugin` `process-aot` execution.
**Rationale**: This is the canonical Spring Boot 4.x native image approach:
1. `mvn -Pnative package` runs AOT → generates source under `target/spring-aot/`
2. `native:compile` compiles the AOT-generated sources + app into a native executable.
The `native-maven-plugin` coordinates with GraalVM `native-image` CLI automatically.
**Alternatives considered**:
- Paketo Buildpacks (`spring-boot:build-image -Pnative`): does not use Jib; rejected because
the user explicitly wants Jib.
- Manual `native-image` CLI: fragile, no Maven lifecycle integration — rejected.
---
### 3. Jib + Native Image Integration
**Decision**: Use `jib-maven-plugin` (3.4.x) with the `jib-native-image-extension-maven`
extension (0.1.0).
**Rationale**: The Jib native image extension packages a pre-built native executable into a
Docker image without requiring a Docker daemon. The workflow is:
```
mvn -Pnative native:compile # step 1: build native executable
mvn -Pnative jib:dockerBuild # step 2: package into Docker image
```
Or combined (with `process-aot` wired in `native` profile):
```
mvn -Pnative package jib:dockerBuild
```
Jib uses the extension to locate the native executable under `target/` and set it as the
container entrypoint.
**Key configuration**:
```xml
<plugin>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-maven-plugin</artifactId>
<version>3.4.5</version>
<configuration>
<from>
<!-- distroless for security; or cgr.dev/chainguard/static for even smaller -->
<image>gcr.io/distroless/base-nossl-debian12</image>
</from>
<pluginExtensions>
<pluginExtension>
<implementation>
com.google.cloud.tools.jib.maven.extension.nativeimage.JibNativeImageExtension
</implementation>
</pluginExtension>
</pluginExtensions>
</configuration>
<dependencies>
<dependency>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-native-image-extension-maven</artifactId>
<version>0.1.0</version>
</dependency>
</dependencies>
</plugin>
```
**Alternatives considered**:
- Custom Dockerfile for native image: more control but manual, diverges from Jib workflow —
rejected in favor of Jib as specified.
- `jib:build` (push to registry) vs `jib:dockerBuild` (local daemon): default to
`jib:dockerBuild` for dev; `jib:build` for CI with registry configured.
---
### 4. Docker Base Image for Native Executable
**Decision**: `gcr.io/distroless/base-nossl-debian12` (Debian 12 glibc variant).
**Rationale**: GraalVM native images compiled on Linux link against glibc by default.
Distroless provides glibc without a shell, minimizing attack surface.
Important: the native executable compiled on the host must target the same glibc ABI as the
base image. Since the build is on Linux x86_64 this matches Debian 12's glibc 2.36.
**Alternatives considered**:
- `alpine` (musl libc): requires `--static` or `--libc=musl` native-image flag; more complex —
rejected for simplicity (KISS).
- `gcr.io/distroless/java-base`: includes JVM libs we don't need — rejected (pure native).
- `scratch`: too minimal; requires fully static binary with all libs included — rejected.
---
### 5. Spring AI 2.0.0-M4 Native Image Compatibility
**Decision**: Rely on Spring AI's built-in AOT hints; add manual `RuntimeHints` only for gaps
found during build.
**Rationale**: Spring AI 2.0.0-M4 includes `@NativeHint` registrations for the OpenAI
integration (HTTP client, response DTOs) and pgvector store. Spring Boot's AOT processor
picks these up automatically. M4 milestone has known native image improvements.
**Known gaps** (require manual RuntimeHints):
- `spring-ai-pdf-document-reader`: PDFBox font/resource loading uses `Class.forName()` and
classpath resource scanning. Need to register font resources and PDFBox parser classes.
- `spring-ai-advisors-vector-store`: generally AOT-compatible via Spring AI hints.
**Approach**: Add a `NativeHintsConfig` class annotated with `@ImportRuntimeHints` that
registers PDFBox fonts and any other gaps found during a `-Ob` (quick) native build.
---
### 6. PDFBox Reflection Hints
**Decision**: Register PDFBox font resources and parser classes via a `RuntimeHintsRegistrar`.
**Rationale**: PDFBox 3.x loads fonts from classpath resources (`/org/apache/pdfbox/resources/`)
and uses reflection for CMap/encoding classes. Without hints the native build fails at runtime
with `ClassNotFoundException` or missing resource errors.
**Minimum hints needed**:
```java
hints.resources().registerPattern("org/apache/pdfbox/resources/**");
hints.reflection().registerType(org.apache.pdfbox.pdmodel.font.encoding.GlyphList.class,
MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS);
// Additional classes as discovered during smoke tests
```
---
### 7. AWS SDK v2 Native Image Support
**Decision**: Add `software.amazon.awssdk:aws-crt-client` is NOT needed; instead add
`software.amazon.awssdk:netty-nio-client` or use URL connection client.
Add AWS SDK v2 native image support via `aws-sdk-java-v2-native-bridge` if available,
otherwise register reflection manually.
**Rationale**: AWS SDK v2 2.30.x provides native image support via:
- `software.amazon.awssdk:apache-client` or `url-connection-client` (simpler, fewer reflection
needs than netty)
- The SDK's `SdkPojo` interfaces generate reflection entries; Spring Boot AOT may not cover all.
**Minimum approach**: Switch S3 HTTP client from default (netty) to `url-connection-client`
for simplicity in native mode, register `SdkPojo` subtypes via `RuntimeHints`.
**Alternative**: If S3 is used infrequently and only for figure uploads, mark S3Client as a
conditional bean initialized lazily — reflection issues then surface only on first use (not
startup), making native hints easier to discover iteratively.
---
### 8. Flyway in Native Mode
**Decision**: No special configuration needed; Flyway 10.x has native image support.
**Rationale**: Spring Boot 4.x AOT includes Flyway native hints. PostgreSQL JDBC driver
and Flyway 10 have been tested with GraalVM native. No manual hints required.
---
### 9. Spring Security in Native Mode
**Decision**: No special configuration needed.
**Rationale**: Spring Security 7.x (bundled with Spring Boot 4.x) includes AOT-compatible
configuration. HTTP Basic auth does not require dynamic proxies at runtime.
---
### 10. Build Command Summary
```bash
# Install GraalVM 25
sdk install java 25-graalce
# Build native executable + Docker image (local daemon)
cd backend
mvn -Pnative package jib:dockerBuild
# Or push to registry (CI)
mvn -Pnative package jib:build \
-Djib.to.image=ghcr.io/your-org/ai-teacher-backend:native-latest \
-Djib.to.auth.username=$CI_USER \
-Djib.to.auth.password=$CI_TOKEN
```
---
### 11. Maven Profile Structure
```xml
<profiles>
<profile>
<id>native</id>
<build>
<plugins>
<!-- AOT processing -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<id>process-aot</id>
<goals><goal>process-aot</goal></goals>
</execution>
</executions>
</plugin>
<!-- Native compile -->
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>0.10.6</version>
<executions>
<execution>
<id>add-reachability-metadata</id>
<goals><goal>add-reachability-metadata</goal></goals>
</execution>
<execution>
<id>compile</id>
<goals><goal>compile-no-fork</goal></goals>
<phase>package</phase>
</execution>
</executions>
<configuration>
<imageName>ai-teacher-backend</imageName>
<buildArgs>
<buildArg>--initialize-at-build-time=org.slf4j</buildArg>
<buildArg>-H:+ReportExceptionStackTraces</buildArg>
</buildArgs>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
```
+138
View File
@@ -0,0 +1,138 @@
# Feature Specification: Native Image Deployment
**Feature Branch**: `005-native-image-deployment`
**Created**: 2026-04-07
**Status**: Draft
**Input**: Prepare the application for deployment using GraalVM native image for the backend, built into a Docker image via Jib.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Build Native Docker Image (Priority: P1)
A developer or CI pipeline runs a single Maven command to produce a Docker image containing the
native-compiled backend binary. The image starts in under one second and uses significantly less
memory than the JVM fat-jar image.
**Why this priority**: This is the core deliverable — without a working native Docker image the
entire feature has no value.
**Independent Test**: Run `mvn -Pnative jib:dockerBuild` (or equivalent), start the resulting
container, hit `GET /api/v1/books`, and receive a valid response. Startup log must show the server
is ready in under 1 second.
**Acceptance Scenarios**:
1. **Given** a clean checkout with GraalVM installed, **When** the developer runs the native build
command, **Then** a Docker image is produced without manual steps.
2. **Given** the produced Docker image, **When** the container is started with the required
environment variables, **Then** the backend starts, connects to PostgreSQL, and serves HTTP
requests correctly.
3. **Given** the produced Docker image, **When** a RAG chat request is made, **Then** it responds
correctly (OpenAI call, pgvector retrieval, and Flyway migrations all work in native mode).
---
### User Story 2 - JVM Mode Preserved (Priority: P2)
A developer who has not installed GraalVM can still build and run the backend the same way as
today (fat-jar / existing Dockerfile), without any change to their workflow.
**Why this priority**: Preserving the developer experience for non-native builds ensures the team
can keep iterating without a GraalVM prerequisite.
**Independent Test**: Run `mvn package -DskipTests` and start the resulting jar with `java -jar`
the application starts and serves requests identically to before this feature.
**Acceptance Scenarios**:
1. **Given** a standard JDK (no GraalVM), **When** `mvn package` is run, **Then** a fat-jar is
produced and runs normally.
2. **Given** the existing Dockerfile, **When** `docker build` is run, **Then** a JVM-mode image
is produced and works exactly as before.
---
### User Story 3 - Docker Compose Full Stack (Priority: P3)
A developer can bring up the complete stack (PostgreSQL + native backend) with a single
`docker compose up` command for local integration testing.
**Why this priority**: Makes it easy to validate the native image against the real database before
pushing to any environment.
**Independent Test**: Run `docker compose up` (with native image tag), wait for healthy status,
make a RAG request — all services communicate correctly.
**Acceptance Scenarios**:
1. **Given** the docker-compose.yml, **When** `docker compose up` is run with the native backend
image, **Then** all services start, health checks pass, and the application is reachable.
---
### Edge Cases
- What happens when a reflection-heavy library (PDFBox, AWS SDK) is called at runtime without
proper native hints? → Must be caught at build time via native-image test or detected early
via integration smoke test.
- What if GraalVM is not installed in CI? → Build must fail with a clear error message, not
silently produce a JVM image.
- What if an environment variable required at runtime is missing? → Container must fail fast with
a meaningful error (not a NullPointerException from missing reflection).
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The project MUST provide a `native` Maven profile that compiles the backend to a
standalone native executable using GraalVM.
- **FR-002**: The native profile MUST include Spring Boot AOT processing to generate the
reflection, serialization, and proxy hints required at compile time.
- **FR-003**: The Jib Maven plugin MUST be configured to package the native executable into a
Docker image without requiring Docker daemon access during the build.
- **FR-004**: The resulting Docker image MUST start the backend in under 1 second on modern
hardware (2+ CPU cores, 2 GB RAM).
- **FR-005**: All existing REST endpoints MUST function correctly in native mode (book upload,
RAG chat, Flyway migrations, pgvector retrieval, Spring Security).
- **FR-006**: The JVM fat-jar build (default profile) MUST continue to work unchanged.
- **FR-007**: The README.md MUST be updated with native build instructions and updated
architecture/deployment diagram.
- **FR-008**: The `docker-compose.yml` MUST be updated (or a companion file added) to support
running the native Docker image alongside PostgreSQL.
### Key Entities
- **Native Profile**: Maven build profile (`native`) that activates AOT processing and
GraalVM native compilation.
- **Docker Image**: OCI image produced by Jib, containing the native executable and a minimal
base OS layer.
- **AOT Hints**: Compile-time metadata (reflection, serialization, proxy) that replace runtime
reflection for third-party libraries.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: The native Docker image starts and serves its first HTTP request in under 1 second
on a machine with 2+ CPU cores and 2 GB RAM.
- **SC-002**: The native container's idle memory footprint is at least 40% lower than the
equivalent JVM container running the same application.
- **SC-003**: All existing API endpoints return correct responses in native mode (0 regressions).
- **SC-004**: A developer with GraalVM installed can build the native Docker image with a single
command documented in README.md.
- **SC-005**: The default JVM build and Dockerfile continue to work without any changes to the
developer workflow.
## Assumptions
- GraalVM 25 (CE or Oracle) will be available in CI and on developer machines that want to build
native images.
- The native build is performed on Linux x86_64; cross-compilation for other architectures is out
of scope for this feature.
- Spring Boot 4.0.5 and Spring AI 2.0.0-M4 have sufficient native image support for the features
used (OpenAI chat/embedding, pgvector, Flyway, Spring Security, Spring Data JPA).
- The Docker registry target and image name/tag will be configured via Maven properties or
environment variables, not hardcoded.
- Frontend deployment is out of scope for this feature; only the backend native image is addressed.
- The existing Dockerfile is kept as a fallback JVM image build; the Jib-built native image is
the new primary artifact.
+189
View File
@@ -0,0 +1,189 @@
# Tasks: Native Image Deployment
**Input**: Design documents from `/specs/005-native-image-deployment/`
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/ ✅
**Tests**: No test tasks — this feature has no unit/integration test requirements in the spec.
Smoke testing is part of US1 implementation (T010, T011).
**Organization**: Tasks grouped by user story for independent implementation and verification.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no incomplete-task dependencies)
- **[Story]**: Which user story this task belongs to (US1, US2, US3)
- Paths use `backend/` and repo root (web app structure per constitution)
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Verify prerequisites and baseline before touching any build files.
- [ ] T001 Verify GraalVM 25 CE or Oracle GraalVM 25 is installed: run `native-image --version` and confirm output shows GraalVM 25; document install command (`sdk install java 25-graalce`) if missing
- [ ] T002 [P] Verify baseline JVM build passes: run `mvn package -DskipTests` inside `backend/` and confirm `target/ai-teacher-backend-*.jar` is produced
**Checkpoint**: GraalVM available, existing JVM build green — safe to modify build files
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Add AOT processing and native compilation support to the Maven build. These changes
are prerequisites for all user stories.
**⚠️ CRITICAL**: US1 cannot start until T007 confirms the native executable is produced.
- [ ] T003 Add `<profile id="native">` skeleton to `backend/pom.xml` and wire `spring-boot-maven-plugin` `process-aot` goal to the `prepare-package` phase inside that profile
- [ ] T004 Add `native-maven-plugin` 0.10.6 to the `native` profile in `backend/pom.xml` with executions `add-reachability-metadata` and `compile-no-fork` (phase `package`), image name `ai-teacher-backend`, and build args `--initialize-at-build-time=org.slf4j` and `-H:+ReportExceptionStackTraces`
- [ ] T005 [P] Create `backend/src/main/java/com/aiteacher/config/NativeHintsConfig.java` implementing `RuntimeHintsRegistrar`: register resource pattern `org/apache/pdfbox/resources/**` and reflection for `org.apache.pdfbox.pdmodel.font.encoding.GlyphList` with `MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS`
- [ ] T006 Add `@ImportRuntimeHints(NativeHintsConfig.class)` annotation to the main `@SpringBootApplication` class in `backend/src/main/java/com/aiteacher/`
- [ ] T007 Run `mvn -Pnative package -DskipTests` inside `backend/` and confirm `target/ai-teacher-backend` native executable is produced; fix any AOT compilation errors before proceeding
**Checkpoint**: `target/ai-teacher-backend` exists and exits cleanly — US1 implementation can begin
---
## Phase 3: User Story 1 — Build Native Docker Image (Priority: P1) 🎯 MVP
**Goal**: One command (`mvn -Pnative package jib:dockerBuild`) produces a local Docker image
containing the native backend. Container starts in < 1 s and all REST endpoints work.
**Independent Test**: Start native container with DB env vars → `GET /api/v1/books` returns 200 →
startup log shows ready in under 1 second.
### Implementation for User Story 1
- [ ] T008 [US1] Add `jib-maven-plugin` 3.4.5 to `backend/pom.xml` `<build><plugins>` section (default build, not inside `native` profile): configure `<from><image>gcr.io/distroless/base-nossl-debian12</image></from>`, `<to><image>ai-teacher-backend</image></to>`, exposed port `8080`, and `<pluginExtensions>` referencing `com.google.cloud.tools.jib.maven.extension.nativeimage.JibNativeImageExtension`; add `jib-native-image-extension-maven` 0.1.0 as plugin `<dependency>`
- [ ] T009 [US1] Run `mvn -Pnative package jib:dockerBuild -DskipTests` inside `backend/` and confirm `docker images | grep ai-teacher-backend` shows the produced image
- [ ] T010 [US1] Start the native container with required env vars (`SPRING_DATASOURCE_URL`, `SPRING_DATASOURCE_USERNAME`, `SPRING_DATASOURCE_PASSWORD`, `OPENAI_API_KEY`) pointing to a local PostgreSQL; verify startup log shows "Started … in < 1.0 seconds" and `GET /api/v1/books` returns HTTP 200
- [ ] T011 [US1] Run full smoke test against the running native container: `POST /api/v1/books` (PDF upload), `POST /api/v1/chat` (RAG query), `GET /api/v1/chat/history`, and HTTP Basic auth enforcement; for each `MissingResourceException` or `ClassNotFoundException` found, add the corresponding entry to `backend/src/main/java/com/aiteacher/config/NativeHintsConfig.java` and rebuild (T009 → T010 loop) until all pass
**Checkpoint**: Native Docker image starts < 1 s, all 5 endpoints work — US1 complete ✅
---
## Phase 4: User Story 2 — JVM Mode Preserved (Priority: P2)
**Goal**: Default Maven build and existing Dockerfile continue to work with no changes required
from developers who do not have GraalVM installed.
**Independent Test**: `mvn package -DskipTests` produces a fat-jar; `docker build` using
`backend/Dockerfile` produces a working JVM container.
### Implementation for User Story 2
- [ ] T012 [P] [US2] Verify `mvn package -DskipTests` (no `-Pnative`) inside `backend/` still produces `target/ai-teacher-backend-*.jar`; confirm the jar starts correctly with `java -jar`
- [ ] T013 [P] [US2] Verify `docker build -t ai-teacher-backend-jvm backend/` using the existing `backend/Dockerfile` succeeds and the resulting JVM container starts and serves `GET /api/v1/books` correctly
**Checkpoint**: JVM path fully unchanged — US2 complete ✅
---
## Phase 5: User Story 3 — Docker Compose Full Stack (Priority: P3)
**Goal**: `docker compose -f docker-compose.native.yml up` starts PostgreSQL + native backend
together so developers can run the complete native stack locally with a single command.
**Independent Test**: `docker compose -f docker-compose.native.yml up` → all services healthy →
`GET http://localhost:8080/api/v1/books` returns 200.
### Implementation for User Story 3
- [ ] T014 [US3] Create `docker-compose.native.yml` at repo root: include `postgres` service (same image and config as `docker-compose.yml`), add `backend` service using image `ai-teacher-backend:latest` with all required env vars sourced from `.env`, `depends_on` postgres with health-check condition, and port mapping `8080:8080`
- [ ] T015 [P] [US3] Create `.env.example` at repo root listing all env vars needed by the native stack (`SPRING_DATASOURCE_URL`, `SPRING_DATASOURCE_USERNAME`, `SPRING_DATASOURCE_PASSWORD`, `OPENAI_API_KEY`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`) with placeholder values and inline comments
**Checkpoint**: Full native stack starts via docker compose — US3 complete ✅
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Constitution IV gate (README must be updated in the same PR as architectural change)
and final documentation alignment.
- [ ] T016 Update `README.md` at repo root: add a "Native Image Build" section with GraalVM install command (`sdk install java 25-graalce`), build command (`mvn -Pnative package jib:dockerBuild`), and run command (`docker compose -f docker-compose.native.yml up`); update the Mermaid architecture diagram to show the native build pipeline (Maven native profile → GraalVM native-image → Jib → Docker image)
- [ ] T017 [P] Update `specs/005-native-image-deployment/quickstart.md` with any corrections or additions discovered during implementation (env var names, exact commands, troubleshooting entries)
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — start immediately
- **Foundational (Phase 2)**: Requires Phase 1 completion — **BLOCKS all user stories**
- **US1 (Phase 3)**: Requires Phase 2 (T007 must pass) — primary deliverable
- **US2 (Phase 4)**: Requires Phase 2 (T003T006 must not break JVM path) — can run in parallel with US1 after Phase 2
- **US3 (Phase 5)**: Requires US1 completion (T009 must produce the Docker image)
- **Polish (Phase 6)**: Requires US1, US2, US3 all complete
### User Story Dependencies
- **US1 (P1)**: Unblocked after Foundational — critical path
- **US2 (P2)**: Unblocked after Foundational — can run in parallel with US1 (different verification commands, no file conflicts)
- **US3 (P3)**: Depends on US1 (needs the Docker image to exist for docker-compose)
### Within US1
T008 (Jib config) → T009 (build image) → T010 (verify startup) → T011 (smoke test + hint fixes, may loop back to T009)
### Parallel Opportunities
- T001 and T002 (Phase 1): both can run in parallel
- T003/T004 (pom.xml edits) are sequential (same file); T005 (new Java file) can run in parallel with T003/T004
- T012 and T013 (US2 verification): parallel, different commands
- T014 and T015 (US3 docker-compose + .env.example): parallel, different files
- T016 and T017 (Polish): parallel, different files
- US1 (Phase 3) and US2 (Phase 4) can run in parallel after Foundational completes
---
## Parallel Example: Foundational Phase
```bash
# In parallel — different files, no dependencies between them:
Task T005: "Create NativeHintsConfig.java in backend/src/main/java/com/aiteacher/config/"
# Sequential — same file (pom.xml):
Task T003: "Add native profile skeleton with spring-boot-maven-plugin process-aot"
Task T004: "Add native-maven-plugin 0.10.6 to native profile" # after T003
```
## Parallel Example: After Foundational Completes
```bash
# US1 and US2 can start simultaneously:
Developer A → Phase 3 (US1): T008 → T009 → T010 → T011
Developer B → Phase 4 (US2): T012, T013 (parallel)
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup (T001, T002)
2. Complete Phase 2: Foundational (T003T007) — **CRITICAL, do not skip T007**
3. Complete Phase 3: US1 (T008T011)
4. **STOP and VALIDATE**: Native image starts < 1 s, all endpoints work
5. This alone satisfies FR-001 through FR-006 and SC-001 through SC-003
### Incremental Delivery
1. Phase 1 + Phase 2 → build toolchain ready
2. Phase 3 (US1) → native Docker image working (**MVP**)
3. Phase 4 (US2) → JVM fallback confirmed
4. Phase 5 (US3) → full native stack via docker compose
5. Phase 6 (Polish) → README updated (constitution IV gate — **required before merge**)
---
## Notes
- [P] tasks operate on different files or independent commands — safe to run concurrently
- US2 (T012, T013) can be verified at any time after Phase 2; they are quick sanity checks
- The hint-fix loop in T011 is the most likely source of iteration — budget extra time
- Constitution IV gate (README update, T016) is **mandatory** — PR cannot merge without it
- `.env.example` (T015) should be committed; actual `.env` must be in `.gitignore`
+75
View File
@@ -0,0 +1,75 @@
# Implementation Plan: Mobile-Responsive UI
**Branch**: `006-mobile-responsive-ui` | **Date**: 2026-04-10 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/006-mobile-responsive-ui/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow.
## Summary
Add CSS media queries to make the frontend usable on small screens (≤ 768px). Three targeted fixes:
1. Make the navbar sticky and its link bar horizontally scrollable
2. Prevent horizontal overflow in the book reader view
3. Align the login card toward the top of the viewport instead of vertically centered
No new dependencies, no backend changes, no new components. Pure CSS additions scoped to existing Vue SFCs.
## Technical Context
**Language/Version**: TypeScript / Node 20 (frontend only)
**Primary Dependencies**: Vue 3.4, Vue Router 4.3, Pinia 2.1 — no changes
**Storage**: N/A (frontend-only change)
**Testing**: Manual browser testing at 375px viewport; `npm run lint` for TypeScript
**Target Platform**: Modern mobile browsers (iOS Safari, Chrome Android); desktop unchanged
**Project Type**: Web application (frontend client only for this feature)
**Performance Goals**: No regression — changes are CSS-only, zero runtime cost
**Constraints**: No new dependencies; no changes to desktop layout; no hamburger menu
**Scale/Scope**: 5 files touched (App.vue, LoginView.vue, BookReaderView.vue, possibly UploadView.vue, BookCard.vue)
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| I. KISS | ✅ PASS | Pure CSS media queries — simplest viable solution. No new abstractions. |
| II. Easy to Change | ✅ PASS | Changes are scoped CSS within existing SFCs; easy to revert or extend. |
| III. Web-First Architecture | ✅ PASS | Frontend-only change; no API contract impact. |
| IV. Documentation as Architecture | ✅ PASS | No architectural change → README diagram unchanged. |
| Technology Constraints | ✅ PASS | Remains `backend/` + `frontend/`; no new deployable unit. |
**Verdict**: No violations. Complexity Tracking table not required.
## Project Structure
### Documentation (this feature)
```text
specs/006-mobile-responsive-ui/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # N/A (no data model changes)
├── quickstart.md # Phase 1 output
├── contracts/ # N/A (no API contract changes)
└── tasks.md # Phase 2 output (/speckit.tasks command)
```
### Source Code (repository root)
```text
frontend/
├── src/
│ ├── App.vue # Navbar sticky + link bar scroll
│ ├── views/
│ │ ├── LoginView.vue # Login card top-aligned on mobile
│ │ ├── BookReaderView.vue # Reader fits screen width on mobile
│ │ └── UploadView.vue # Book grid min-width reduction (if needed)
│ └── components/
│ └── BookCard.vue # Card min-width reduction (if needed)
```
**Structure Decision**: Option 2 (Web application) per constitution. This feature touches only `frontend/`; `backend/` is untouched.
## Complexity Tracking
> No violations — table not required for this feature.
@@ -0,0 +1,28 @@
# Quickstart: Mobile-Responsive UI
**Feature**: 006-mobile-responsive-ui
## What this feature does
Adds CSS media queries to 23 Vue SFCs so the app is usable on phone screens (≤ 768px):
- Navbar sticks to top; link bar scrolls horizontally
- Book reader stacks vertically (no horizontal overflow)
- Login card appears near the top of the screen
## Files to change
| File | Change |
|------|--------|
| `frontend/src/App.vue` | Add `position: sticky; top: 0; z-index: 100` to `.navbar`. Add `@media (max-width: 768px)` block: make `.navbar-links` horizontally scrollable, reduce `.navbar` padding |
| `frontend/src/views/LoginView.vue` | Add `@media (max-width: 768px)`: change `.login-wrapper` to `align-items: flex-start; padding-top: 2rem` |
| `frontend/src/views/BookReaderView.vue` | Add `@media (max-width: 768px)`: set `.chat-reader-split` to `flex-direction: column`, remove fixed width on `.reader-panel`, set `.reader-panel` to `width: 100%` |
## How to test
1. Open browser DevTools → toggle device toolbar → select "iPhone SE" (375 × 667)
2. Navigate to each page:
- **Any page**: navbar should be visible and sticky; links should be scrollable horizontally within the bar
- **Login** (`/`): login card should appear near the top, not vertically centered
- **Book reader** (`/books/:id`): content should fill the width, no horizontal page scrollbar
## No backend changes. No new dependencies. No new files.
@@ -0,0 +1,36 @@
# Research: Mobile-Responsive UI
**Feature**: 006-mobile-responsive-ui
**Date**: 2026-04-10
## Decision 1: Navbar Sticky vs Fixed
- **Decision**: Use `position: sticky; top: 0` on `.navbar` (currently unstyled for position)
- **Rationale**: `sticky` keeps the element in normal flow until it hits the scroll threshold, avoiding the need to add `padding-top` to `<main>` to compensate for a `fixed` element that's removed from flow. Simpler with fewer side effects.
- **Alternatives considered**: `position: fixed` — works but requires matching `padding-top` on `.main-content` to prevent content from hiding under the navbar. More coupled.
## Decision 2: Navbar Links — Horizontal Scroll vs Hamburger Menu
- **Decision**: Horizontal scroll on `.navbar-links` using `overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch`
- **Rationale**: User explicitly requested horizontal scroll ("button on the right need horizontal scroll to be accessed"). This is the simplest implementation — one CSS rule. A hamburger menu would require a toggle button, JavaScript state, and an open/close animation — unjustified complexity for a POC.
- **Alternatives considered**: Hamburger/drawer menu — rejected per user request and KISS principle.
## Decision 3: Book Reader Layout on Mobile
- **Decision**: Stack reader and chat panels vertically on mobile (≤ 768px) using `flex-direction: column` on `.chat-reader-split`, remove fixed width on `.reader-panel`
- **Rationale**: The current layout in BookReaderView.vue uses a flex row with `.reader-panel` at a fixed 420px. On a 375px screen this immediately overflows. Stacking vertically is the simplest fix — one media query, two CSS rules.
- **Alternatives considered**: Tabs (reader/chat toggle) — more complex, requires JS state; rejected per KISS.
## Decision 4: Login Card Top Alignment on Mobile
- **Decision**: On ≤ 768px, change `.login-wrapper` from `align-items: center` to `align-items: flex-start` and add `padding-top: 2rem`
- **Rationale**: The wrapper is a full-height flex column (`min-height: 100vh`). Centering vertically on mobile pushes the form to ~50% of viewport height. Switching to `flex-start` with a small top padding keeps the form near the top without changing desktop behavior.
- **Alternatives considered**: Removing `min-height: 100vh` — would break the background fill; rejected.
## Decision 5: Breakpoint
- **Decision**: Single breakpoint at `max-width: 768px`
- **Rationale**: Covers all common phone widths (320px430px) while leaving tablet/desktop untouched. Consistent with common mobile-first conventions. Adding a second breakpoint (e.g., 480px) would be premature for a POC.
- **Alternatives considered**: 480px only — too narrow, misses larger phones; 1024px — too wide, affects tablets unnecessarily.
## No NEEDS CLARIFICATION items remain.
+90
View File
@@ -0,0 +1,90 @@
# Feature Specification: Mobile-Responsive UI
**Feature Branch**: `006-mobile-responsive-ui`
**Created**: 2026-04-10
**Status**: Draft
**Input**: User description: "I want the frontend to be usable in phone (small screen): Fix the nav bar on top, button on the right need horizontal scroll to be accessed. When read the books (from library section) the book section should fit the screen (width). The login section should be closer to the top of the screen"
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Sticky Navbar with Scrollable Links (Priority: P1)
On a phone, the user opens the app. The navbar stays visible at the top as they scroll. All navigation links are reachable by horizontally scrolling the link bar — no links are clipped or hidden.
**Why this priority**: Without a usable nav, the user cannot navigate between sections at all. This is the most fundamental usability blocker on mobile.
**Independent Test**: Can be tested by opening the app on a ~375px-wide viewport (iPhone SE) and verifying the navbar is fixed/sticky, all nav links are scrollable horizontally, and the page content below scrolls independently.
**Acceptance Scenarios**:
1. **Given** a 375px-wide viewport, **When** the page loads, **Then** the navbar is visible and stuck to the top of the screen
2. **Given** a 375px-wide viewport, **When** there are multiple nav links that overflow horizontally, **Then** the links are accessible by swiping/scrolling the link bar without triggering page scroll
3. **Given** a 375px-wide viewport, **When** the user scrolls the page content down, **Then** the navbar remains fixed at the top
---
### User Story 2 - Book Reader Fits Screen Width (Priority: P2)
A user opens a book from the library on their phone. The book content area fits the full screen width — no horizontal page scroll is required to read the text.
**Why this priority**: The book reader is the primary value-delivery surface of the app. If it overflows the screen, the app is unusable for its main purpose.
**Independent Test**: Can be tested by navigating to the BookReaderView on a 375px viewport and verifying no horizontal scrollbar appears and text is readable without zooming.
**Acceptance Scenarios**:
1. **Given** a 375px-wide viewport, **When** the user opens a book, **Then** the book content panel fills the screen width without overflow
2. **Given** a 375px-wide viewport, **When** the reader and chat panels are side-by-side on desktop, **Then** on mobile they stack vertically (reader on top, chat below) or the layout adapts to prevent overflow
---
### User Story 3 - Login Form Near Top of Screen (Priority: P3)
A user lands on the login page on their phone. The login form appears near the top of the visible screen rather than perfectly centered vertically, so they do not need to scroll or zoom to reach the form.
**Why this priority**: Vertical centering that places a form at the mid-screen on desktop pushes it below the keyboard fold on mobile. Fixing this improves first-contact UX.
**Independent Test**: Can be tested by opening the login page on a 375px-wide, 667px-tall viewport (iPhone SE) and verifying the login card starts within the top 40% of the viewport.
**Acceptance Scenarios**:
1. **Given** a 375px-wide viewport, **When** the login page loads, **Then** the login card is positioned toward the top of the screen (not perfectly vertically centered)
2. **Given** a phone with virtual keyboard open, **When** the user taps an input field, **Then** the form remains accessible without scrolling behind the keyboard
---
### Edge Cases
- What happens when a very long book title wraps in the navbar or book card?
- How does the nav handle exactly the boundary between "fits" and "needs scroll" (e.g., 3 vs 4 links)?
- What if the user rotates to landscape on a phone (wider but shorter viewport)?
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The navbar MUST be sticky/fixed at the top of the viewport on all screen sizes
- **FR-002**: The navbar link area MUST be horizontally scrollable on small screens to access all navigation links
- **FR-003**: Horizontal page-level scroll MUST NOT occur due to navbar overflow
- **FR-004**: The book reader content area MUST NOT exceed the viewport width on screens ≤ 768px
- **FR-005**: The login card MUST be positioned toward the top of the viewport (not vertically centered) on screens ≤ 768px
### Key Entities *(include if feature involves data)*
- N/A — this is a pure frontend CSS/layout change with no data model impact
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: On a 375px-wide viewport, the navbar is visible and fixed; all links are reachable by horizontal scroll within the navbar
- **SC-002**: On a 375px-wide viewport, the BookReaderView shows content without a horizontal scrollbar at the page level
- **SC-003**: On a 375px-wide viewport, the login card top edge is within the top 150px of the viewport
## Assumptions
- Target breakpoint for "small screen / phone" is ≤ 768px width
- No hamburger menu is required — horizontal scroll on the link bar is acceptable per user request
- The existing Vue 3 + plain CSS stack is retained (no CSS framework added)
- Desktop layout is unchanged
- No backend changes required
+58
View File
@@ -0,0 +1,58 @@
# Tasks: Mobile-Responsive UI
**Input**: Design documents from `/specs/006-mobile-responsive-ui/`
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, quickstart.md ✅
No foundational phase needed — all changes are isolated CSS additions within existing SFCs.
---
## Phase 1: User Story 1 - Sticky Navbar with Scrollable Links (Priority: P1) 🎯 MVP
**Goal**: Navbar stays fixed at top; all links are reachable by horizontal scroll on mobile.
**Independent Test**: Open DevTools → iPhone SE (375px) → verify navbar is sticky and links scroll horizontally.
- [x] T001 [US1] Add `position: sticky; top: 0; z-index: 100` to `.navbar` in `frontend/src/App.vue`
- [x] T002 [US1] Add `@media (max-width: 768px)` block to `frontend/src/App.vue`: reduce navbar padding, make `.navbar-links` horizontally scrollable (`overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; flex-shrink: 0`)
**Checkpoint**: Navbar is sticky and links are horizontally scrollable on 375px viewport.
---
## Phase 2: User Story 2 - Book Reader Fits Screen Width (Priority: P2)
**Goal**: Book reader content fills phone screen width without page-level horizontal overflow.
**Independent Test**: Open DevTools → iPhone SE → navigate to a book → verify no horizontal scrollbar, content fills width.
- [x] T003 [US2] Add `@media (max-width: 768px)` block to `frontend/src/views/BookReaderView.vue`: set `.chat-reader-split` to `flex-direction: column`, set `.reader-panel` to `width: 100%; min-width: unset; max-width: 100%`
**Checkpoint**: BookReaderView stacks vertically with no horizontal overflow on 375px viewport.
---
## Phase 3: User Story 3 - Login Form Near Top of Screen (Priority: P3)
**Goal**: Login card appears near top of viewport on mobile instead of vertically centered.
**Independent Test**: Open DevTools → iPhone SE → navigate to login → verify card top is within top 150px of viewport.
- [x] T004 [US3] Add `@media (max-width: 768px)` block to `frontend/src/views/LoginView.vue`: change `.login-wrapper` to `align-items: flex-start; padding-top: 2rem`
**Checkpoint**: Login card appears near top of screen on 375px viewport.
---
## Phase 4: Polish
- [x] T005 [P] Run `npm run lint` in `frontend/` and fix any lint errors (ESLint not configured — pre-existing, unrelated to this feature)
- [x] T006 [P] Verify `.main-content` padding is reduced on mobile in `frontend/src/App.vue` (covered in T002 media query block)
---
## Dependencies & Execution Order
- T001 → T002 (same file, sequential)
- T003, T004 independent of each other and of T001/T002 (different files)
- T005, T006 after all implementation tasks