first implementation

This commit is contained in:
Adrien
2026-03-31 20:58:47 +02:00
parent dc0bcab36e
commit 618e28b354
1878 changed files with 1381732 additions and 5 deletions
@@ -0,0 +1,14 @@
package com.aiteacher;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@EnableAsync
public class AiTeacherApplication {
public static void main(String[] args) {
SpringApplication.run(AiTeacherApplication.class, args);
}
}
@@ -0,0 +1,122 @@
package com.aiteacher.book;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "book")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "title", nullable = false, length = 500)
private String title;
@Column(name = "file_name", nullable = false, length = 500)
private String fileName;
@Column(name = "file_size_bytes", nullable = false)
private long fileSizeBytes;
@Column(name = "page_count")
private Integer pageCount;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private BookStatus status;
@Column(name = "error_message", columnDefinition = "TEXT")
private String errorMessage;
@Column(name = "uploaded_at", nullable = false)
private Instant uploadedAt;
@Column(name = "processed_at")
private Instant processedAt;
// Constructors
public Book() {
}
public Book(String title, String fileName, long fileSizeBytes) {
this.title = title;
this.fileName = fileName;
this.fileSizeBytes = fileSizeBytes;
this.status = BookStatus.PENDING;
this.uploadedAt = Instant.now();
}
// Getters & Setters
public UUID getId() {
return id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getFileName() {
return fileName;
}
public void setFileName(String fileName) {
this.fileName = fileName;
}
public long getFileSizeBytes() {
return fileSizeBytes;
}
public void setFileSizeBytes(long fileSizeBytes) {
this.fileSizeBytes = fileSizeBytes;
}
public Integer getPageCount() {
return pageCount;
}
public void setPageCount(Integer pageCount) {
this.pageCount = pageCount;
}
public BookStatus getStatus() {
return status;
}
public void setStatus(BookStatus status) {
this.status = status;
}
public String getErrorMessage() {
return errorMessage;
}
public void setErrorMessage(String errorMessage) {
this.errorMessage = errorMessage;
}
public Instant getUploadedAt() {
return uploadedAt;
}
public void setUploadedAt(Instant uploadedAt) {
this.uploadedAt = uploadedAt;
}
public Instant getProcessedAt() {
return processedAt;
}
public void setProcessedAt(Instant processedAt) {
this.processedAt = processedAt;
}
}
@@ -0,0 +1,75 @@
package com.aiteacher.book;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/books")
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@PostMapping(consumes = "multipart/form-data")
public ResponseEntity<?> upload(@RequestParam("file") MultipartFile file) throws IOException {
Book book = bookService.upload(file);
return ResponseEntity.status(HttpStatus.ACCEPTED).body(toSummaryResponse(book));
}
@GetMapping
public ResponseEntity<List<Map<String, Object>>> list() {
List<Map<String, Object>> books = bookService.listAll().stream()
.map(this::toFullResponse)
.toList();
return ResponseEntity.ok(books);
}
@GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> get(@PathVariable UUID id) {
Book book = bookService.getById(id);
return ResponseEntity.ok(toFullResponse(book));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable UUID id) {
bookService.delete(id);
return ResponseEntity.noContent().build();
}
private Map<String, Object> toSummaryResponse(Book book) {
return Map.of(
"id", book.getId(),
"title", book.getTitle(),
"fileName", book.getFileName(),
"status", book.getStatus().name(),
"uploadedAt", book.getUploadedAt()
);
}
private Map<String, Object> toFullResponse(Book book) {
var map = new java.util.LinkedHashMap<String, Object>();
map.put("id", book.getId());
map.put("title", book.getTitle());
map.put("fileName", book.getFileName());
map.put("fileSizeBytes", book.getFileSizeBytes());
map.put("pageCount", book.getPageCount());
map.put("status", book.getStatus().name());
map.put("uploadedAt", book.getUploadedAt());
map.put("processedAt", book.getProcessedAt());
if (book.getErrorMessage() != null) {
map.put("errorMessage", book.getErrorMessage());
}
return map;
}
}
@@ -0,0 +1,118 @@
package com.aiteacher.book;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
import org.springframework.core.io.FileSystemResource;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.nio.file.Path;
import java.util.List;
import java.util.UUID;
import java.util.regex.Pattern;
@Service
public class BookEmbeddingService {
private static final Logger log = LoggerFactory.getLogger(BookEmbeddingService.class);
// Pattern to detect diagram/figure captions
private static final Pattern CAPTION_PATTERN =
Pattern.compile("^(Figure|Fig\\.|Table|Diagram)\\s+[\\d.]+", Pattern.CASE_INSENSITIVE);
private final VectorStore vectorStore;
private final BookRepository bookRepository;
public BookEmbeddingService(VectorStore vectorStore, BookRepository bookRepository) {
this.vectorStore = vectorStore;
this.bookRepository = bookRepository;
}
@Async
public void embedBook(UUID bookId, String bookTitle, Path pdfPath) {
log.info("Starting embedding for book {} ({})", bookId, bookTitle);
Book book = bookRepository.findById(bookId).orElse(null);
if (book == null) {
log.warn("Book {} not found, skipping embedding", bookId);
return;
}
try {
book.setStatus(BookStatus.PROCESSING);
bookRepository.save(book);
PagePdfDocumentReader reader = new PagePdfDocumentReader(
new FileSystemResource(pdfPath.toFile()),
PdfDocumentReaderConfig.builder()
.withPagesPerDocument(1)
.build()
);
List<Document> pages = reader.get();
int pageCount = pages.size();
// Enrich metadata and tag diagram captions
List<Document> enriched = pages.stream()
.map(doc -> enrichDocument(doc, bookId.toString(), bookTitle))
.toList();
vectorStore.add(enriched);
book.setStatus(BookStatus.READY);
book.setPageCount(pageCount);
book.setProcessedAt(java.time.Instant.now());
bookRepository.save(book);
log.info("Finished embedding book {} — {} pages", bookId, pageCount);
} catch (Exception ex) {
log.error("Failed to embed book {}", bookId, ex);
book.setStatus(BookStatus.FAILED);
book.setErrorMessage(truncate(ex.getMessage(), 1000));
bookRepository.save(book);
}
}
private Document enrichDocument(Document doc, String bookId, String bookTitle) {
String content = doc.getText();
String chunkType = detectChunkType(content);
doc.getMetadata().put("book_id", bookId);
doc.getMetadata().put("book_title", bookTitle);
doc.getMetadata().put("chunk_type", chunkType);
return doc;
}
private String detectChunkType(String content) {
if (content != null) {
for (String line : content.split("\\r?\\n")) {
if (CAPTION_PATTERN.matcher(line.trim()).find()) {
return "diagram";
}
}
}
return "text";
}
public void deleteBookChunks(UUID bookId) {
log.info("Deleting vector chunks for book {}", bookId);
try {
FilterExpressionBuilder b = new FilterExpressionBuilder();
vectorStore.delete(b.eq("book_id", bookId.toString()).build());
} catch (Exception ex) {
log.warn("Could not delete vector chunks for book {}: {}", bookId, ex.getMessage());
}
}
private String truncate(String message, int maxLength) {
if (message == null) return null;
return message.length() <= maxLength ? message : message.substring(0, maxLength);
}
}
@@ -0,0 +1,15 @@
package com.aiteacher.book;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.UUID;
@Repository
public interface BookRepository extends JpaRepository<Book, UUID> {
List<Book> findByStatus(BookStatus status);
boolean existsByStatus(BookStatus status);
}
@@ -0,0 +1,79 @@
package com.aiteacher.book;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.UUID;
@Service
public class BookService {
private final BookRepository bookRepository;
private final BookEmbeddingService bookEmbeddingService;
public BookService(BookRepository bookRepository, BookEmbeddingService bookEmbeddingService) {
this.bookRepository = bookRepository;
this.bookEmbeddingService = bookEmbeddingService;
}
public Book upload(MultipartFile file) throws IOException {
String originalFilename = file.getOriginalFilename();
if (originalFilename == null || !originalFilename.toLowerCase().endsWith(".pdf")) {
throw new IllegalArgumentException("Only PDF files are accepted.");
}
String title = deriveTitle(originalFilename);
Book book = new Book(title, originalFilename, file.getSize());
book = bookRepository.save(book);
// Write to a temp file so the async task can read it
Path tempFile = Files.createTempFile("aiteacher-", "-" + book.getId() + ".pdf");
file.transferTo(tempFile.toFile());
UUID bookId = book.getId();
Path pdfPath = tempFile;
String bookTitle = title;
bookEmbeddingService.embedBook(bookId, bookTitle, pdfPath);
return book;
}
public List<Book> listAll() {
return bookRepository.findAll();
}
public Book getById(UUID id) {
return bookRepository.findById(id)
.orElseThrow(() -> new NoSuchElementException("Book not found."));
}
public void delete(UUID id) {
Book book = bookRepository.findById(id)
.orElseThrow(() -> new NoSuchElementException("Book not found."));
if (book.getStatus() == BookStatus.PROCESSING) {
throw new IllegalStateException("Cannot delete a book that is currently being processed.");
}
bookEmbeddingService.deleteBookChunks(id);
bookRepository.deleteById(id);
}
private String deriveTitle(String filename) {
// Strip .pdf extension and replace separators with spaces
String name = filename.replaceAll("(?i)\\.pdf$", "");
name = name.replaceAll("[-_]", " ");
// Capitalise first letter
if (!name.isEmpty()) {
name = Character.toUpperCase(name.charAt(0)) + name.substring(1);
}
return name;
}
}
@@ -0,0 +1,8 @@
package com.aiteacher.book;
public enum BookStatus {
PENDING,
PROCESSING,
READY,
FAILED
}
@@ -0,0 +1,8 @@
package com.aiteacher.book;
public class NoKnowledgeSourceException extends RuntimeException {
public NoKnowledgeSourceException(String message) {
super(message);
}
}
@@ -0,0 +1,75 @@
package com.aiteacher.chat;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/chat")
public class ChatController {
private final ChatService chatService;
public ChatController(ChatService chatService) {
this.chatService = chatService;
}
@PostMapping("/sessions")
public ResponseEntity<Map<String, Object>> createSession(
@RequestBody(required = false) Map<String, String> body) {
String topicId = body != null ? body.get("topicId") : null;
ChatSession session = chatService.createSession(topicId);
Map<String, Object> response = new LinkedHashMap<>();
response.put("sessionId", session.getId());
response.put("topicId", session.getTopicId());
response.put("createdAt", session.getCreatedAt());
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@GetMapping("/sessions/{sessionId}/messages")
public ResponseEntity<List<Map<String, Object>>> getMessages(@PathVariable UUID sessionId) {
List<Message> messages = chatService.getMessages(sessionId);
List<Map<String, Object>> response = messages.stream()
.map(this::toMessageResponse)
.toList();
return ResponseEntity.ok(response);
}
@PostMapping("/sessions/{sessionId}/messages")
public ResponseEntity<Map<String, Object>> sendMessage(
@PathVariable UUID sessionId,
@RequestBody Map<String, String> body) {
String content = body.get("content");
if (content == null || content.isBlank()) {
throw new IllegalArgumentException("Message content must not be empty.");
}
Message message = chatService.sendMessage(sessionId, content);
return ResponseEntity.ok(toMessageResponse(message));
}
@DeleteMapping("/sessions/{sessionId}")
public ResponseEntity<Void> deleteSession(@PathVariable UUID sessionId) {
chatService.deleteSession(sessionId);
return ResponseEntity.noContent().build();
}
private Map<String, Object> toMessageResponse(Message message) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", message.getId());
map.put("role", message.getRole().name());
map.put("content", message.getContent());
if (message.getSources() != null) {
map.put("sources", message.getSources());
} else {
map.put("sources", List.of());
}
map.put("createdAt", message.getCreatedAt());
return map;
}
}
@@ -0,0 +1,166 @@
package com.aiteacher.chat;
import com.aiteacher.book.BookRepository;
import com.aiteacher.book.BookStatus;
import com.aiteacher.book.NoKnowledgeSourceException;
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.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.UUID;
@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. Your role is to answer
questions based ONLY on the content from uploaded medical textbooks that has been
retrieved for you as context.
Rules:
- Answer only from the provided context chunks
- If the context does not contain enough information, explicitly state:
"I could not find relevant information about this topic in the uploaded books."
- Cite sources when possible (book title and page number from the context metadata)
- Maintain continuity with the conversation history
- Never fabricate clinical information
""";
private final ChatClient chatClient;
private final VectorStore vectorStore;
private final BookRepository bookRepository;
private final ChatSessionRepository sessionRepository;
private final MessageRepository messageRepository;
public ChatService(ChatClient chatClient, VectorStore vectorStore,
BookRepository bookRepository,
ChatSessionRepository sessionRepository,
MessageRepository messageRepository) {
this.chatClient = chatClient;
this.vectorStore = vectorStore;
this.bookRepository = bookRepository;
this.sessionRepository = sessionRepository;
this.messageRepository = messageRepository;
}
public ChatSession createSession(String topicId) {
ChatSession session = new ChatSession(topicId);
return sessionRepository.save(session);
}
public List<Message> getMessages(UUID sessionId) {
if (!sessionRepository.existsById(sessionId)) {
throw new NoSuchElementException("Session not found.");
}
return messageRepository.findBySessionIdOrderByCreatedAtAsc(sessionId);
}
public Message sendMessage(UUID sessionId, String userContent) {
ChatSession session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new NoSuchElementException("Session not found."));
if (!bookRepository.existsByStatus(BookStatus.READY)) {
throw new NoKnowledgeSourceException("No books are available as knowledge sources.");
}
// Persist user message
Message userMessage = new Message(sessionId, MessageRole.USER, userContent);
messageRepository.save(userMessage);
// Build conversation history for context
List<Message> history = messageRepository.findBySessionIdOrderByCreatedAtAsc(sessionId);
// Build the prompt with full conversation history as context
String fullQuestion = buildQuestionWithHistory(history, userContent, session.getTopicId());
var qaAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder().similarityThreshold(0.8d).topK(6).build())
.build();
ChatResponse response = chatClient.prompt()
.advisors(qaAdvisor)
.system(SYSTEM_PROMPT)
.user(fullQuestion)
.call()
.chatResponse();
String assistantContent = response.getResult().getOutput().getText();
List<Map<String, Object>> sources = extractSources(response);
// Persist assistant message
Message assistantMessage = new Message(sessionId, MessageRole.ASSISTANT, assistantContent);
assistantMessage.setSources(sources);
return messageRepository.save(assistantMessage);
}
public void deleteSession(UUID sessionId) {
if (!sessionRepository.existsById(sessionId)) {
throw new NoSuchElementException("Session not found.");
}
sessionRepository.deleteById(sessionId);
}
private String buildQuestionWithHistory(List<Message> history, String currentQuestion,
String topicId) {
if (history.size() <= 1) {
// Only the current user message is in history; just ask the question
return topicId != null
? String.format("[Context: This is a question about the neurosurgery topic '%s']\n%s",
topicId, currentQuestion)
: currentQuestion;
}
StringBuilder sb = new StringBuilder();
if (topicId != null) {
sb.append(String.format("[Context: This conversation is about the neurosurgery topic '%s']\n\n",
topicId));
}
sb.append("Previous conversation:\n");
// Include all messages except the last (which is the current user message just saved)
for (int i = 0; i < history.size() - 1; i++) {
Message msg = history.get(i);
sb.append(msg.getRole().name()).append(": ").append(msg.getContent()).append("\n");
}
sb.append("\nCurrent question: ").append(currentQuestion);
return sb.toString();
}
private List<Map<String, Object>> extractSources(ChatResponse response) {
List<Map<String, Object>> sources = new ArrayList<>();
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) {
Map<String, Object> source = new HashMap<>();
source.put("bookTitle", bookTitle);
source.put("page", page);
sources.add(source);
}
}
}
}
}
return sources;
}
}
@@ -0,0 +1,48 @@
package com.aiteacher.chat;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "chat_session")
public class ChatSession {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "topic_id", length = 100)
private String topicId;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
public ChatSession() {
}
public ChatSession(String topicId) {
this.topicId = topicId;
this.createdAt = Instant.now();
}
public UUID getId() {
return id;
}
public String getTopicId() {
return topicId;
}
public void setTopicId(String topicId) {
this.topicId = topicId;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
}
@@ -0,0 +1,10 @@
package com.aiteacher.chat;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.UUID;
@Repository
public interface ChatSessionRepository extends JpaRepository<ChatSession, UUID> {
}
@@ -0,0 +1,90 @@
package com.aiteacher.chat;
import jakarta.persistence.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Entity
@Table(name = "message")
public class Message {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "session_id", nullable = false)
private UUID sessionId;
@Enumerated(EnumType.STRING)
@Column(name = "role", nullable = false, length = 10)
private MessageRole role;
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
private String content;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "sources", columnDefinition = "jsonb")
private List<Map<String, Object>> sources;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
public Message() {
}
public Message(UUID sessionId, MessageRole role, String content) {
this.sessionId = sessionId;
this.role = role;
this.content = content;
this.createdAt = Instant.now();
}
public UUID getId() {
return id;
}
public UUID getSessionId() {
return sessionId;
}
public void setSessionId(UUID sessionId) {
this.sessionId = sessionId;
}
public MessageRole getRole() {
return role;
}
public void setRole(MessageRole role) {
this.role = role;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public List<Map<String, Object>> getSources() {
return sources;
}
public void setSources(List<Map<String, Object>> sources) {
this.sources = sources;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
}
@@ -0,0 +1,13 @@
package com.aiteacher.chat;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.UUID;
@Repository
public interface MessageRepository extends JpaRepository<Message, UUID> {
List<Message> findBySessionIdOrderByCreatedAtAsc(UUID sessionId);
}
@@ -0,0 +1,6 @@
package com.aiteacher.chat;
public enum MessageRole {
USER,
ASSISTANT
}
@@ -0,0 +1,21 @@
package com.aiteacher.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AiConfig {
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper();
}
@Bean
public ChatClient chatClient(ChatModel chatModel) {
return ChatClient.builder(chatModel).build();
}
}
@@ -0,0 +1,56 @@
package com.aiteacher.config;
import com.aiteacher.book.NoKnowledgeSourceException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import java.util.Map;
import java.util.NoSuchElementException;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(NoKnowledgeSourceException.class)
public ResponseEntity<Map<String, String>> handleNoKnowledgeSource(NoKnowledgeSourceException ex) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(Map.of("error", ex.getMessage()));
}
@ExceptionHandler(NoSuchElementException.class)
public ResponseEntity<Map<String, String>> handleNotFound(NoSuchElementException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", ex.getMessage()));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> handleBadRequest(IllegalArgumentException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", ex.getMessage()));
}
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<Map<String, String>> handleConflict(IllegalStateException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", ex.getMessage()));
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<Map<String, String>> handleMaxUploadSize(MaxUploadSizeExceededException ex) {
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body(Map.of("error", "File exceeds maximum size of 100 MB."));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, String>> handleGeneric(Exception ex) {
log.error("Unhandled exception", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "An unexpected error occurred."));
}
}
@@ -0,0 +1,39 @@
package com.aiteacher.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf(AbstractHttpConfigurer::disable);
return http.build();
}
@Bean
public UserDetailsService userDetailsService(
@Value("${app.auth.password}") String password) {
UserDetails user = User.builder()
.username("neurosurgeon")
.password("{noop}" + password)
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
}
@@ -0,0 +1,9 @@
package com.aiteacher.topic;
public record Topic(
String id,
String name,
String description,
String category
) {
}
@@ -0,0 +1,46 @@
package com.aiteacher.topic;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Optional;
@Component
public class TopicConfigLoader {
private static final Logger log = LoggerFactory.getLogger(TopicConfigLoader.class);
private final ObjectMapper objectMapper;
private List<Topic> topics;
public TopicConfigLoader(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@PostConstruct
public void load() throws IOException {
ClassPathResource resource = new ClassPathResource("topics.json");
try (InputStream is = resource.getInputStream()) {
topics = objectMapper.readValue(is, new TypeReference<List<Topic>>() {});
}
log.info("Loaded {} neurosurgery topics from topics.json", topics.size());
}
public List<Topic> getAll() {
return topics;
}
public Optional<Topic> findById(String id) {
return topics.stream()
.filter(t -> t.id().equals(id))
.findFirst();
}
}
@@ -0,0 +1,35 @@
package com.aiteacher.topic;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.NoSuchElementException;
@RestController
@RequestMapping("/api/v1/topics")
public class TopicController {
private final TopicConfigLoader topicConfigLoader;
private final TopicSummaryService topicSummaryService;
public TopicController(TopicConfigLoader topicConfigLoader,
TopicSummaryService topicSummaryService) {
this.topicConfigLoader = topicConfigLoader;
this.topicSummaryService = topicSummaryService;
}
@GetMapping
public ResponseEntity<List<Topic>> list() {
return ResponseEntity.ok(topicConfigLoader.getAll());
}
@PostMapping("/{id}/summary")
public ResponseEntity<TopicSummaryResponse> generateSummary(@PathVariable String id) {
Topic topic = topicConfigLoader.findById(id)
.orElseThrow(() -> new NoSuchElementException("Topic not found."));
TopicSummaryResponse response = topicSummaryService.generateSummary(topic);
return ResponseEntity.ok(response);
}
}
@@ -0,0 +1,18 @@
package com.aiteacher.topic;
import java.time.Instant;
import java.util.List;
public record TopicSummaryResponse(
String topicId,
String topicName,
String summary,
List<SourceReference> sources,
Instant generatedAt
) {
public record SourceReference(
String bookTitle,
Integer page
) {
}
}
@@ -0,0 +1,108 @@
package com.aiteacher.topic;
import com.aiteacher.book.BookRepository;
import com.aiteacher.book.BookStatus;
import com.aiteacher.book.NoKnowledgeSourceException;
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;
@Service
public class TopicSummaryService {
private static final Logger log = LoggerFactory.getLogger(TopicSummaryService.class);
private static final String SYSTEM_PROMPT = """
You are an expert neurosurgery educator. Your role is to provide accurate,
clinically relevant summaries based ONLY on the content retrieved from the
uploaded medical textbooks. Do not use any knowledge outside the provided context.
When answering:
- Structure your response clearly with key points
- If the context mentions specific book titles and page numbers, reference them
- 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;
public TopicSummaryService(ChatClient chatClient, VectorStore vectorStore,
BookRepository bookRepository) {
this.chatClient = chatClient;
this.vectorStore = vectorStore;
this.bookRepository = bookRepository;
}
public TopicSummaryResponse generateSummary(Topic topic) {
if (!bookRepository.existsByStatus(BookStatus.READY)) {
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();
String summary = response.getResult().getOutput().getText();
List<TopicSummaryResponse.SourceReference> sources = extractSources(response);
return new TopicSummaryResponse(
topic.id(),
topic.name(),
summary,
sources,
Instant.now()
);
}
private String buildQuestion(Topic topic) {
return String.format(
"Please 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.",
topic.name(), topic.description()
);
}
private List<TopicSummaryResponse.SourceReference> extractSources(ChatResponse response) {
List<TopicSummaryResponse.SourceReference> sources = new ArrayList<>();
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));
}
}
}
}
}
// Deduplicate by bookTitle + page
return sources.stream().distinct().toList();
}
}