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

13
backend/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
# ---- Build stage ----
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN apk add --no-cache maven && mvn -q -DskipTests package
# ---- Runtime stage ----
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

134
backend/pom.xml Normal file
View File

@@ -0,0 +1,134 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.5</version>
<relativePath/>
</parent>
<groupId>com.aiteacher</groupId>
<artifactId>ai-teacher-backend</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>ai-teacher-backend</name>
<description>Neurosurgeon RAG Learning Platform — Backend</description>
<properties>
<java.version>25</java.version>
<spring-ai.version>2.0.0-M4</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Security (HTTP Basic) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Flyway -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<!-- PostgreSQL JDBC driver -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Spring AI — pgvector vector store -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
</dependency>
<!-- Spring AI — OpenAI starter (embedding + chat model autoconfigure) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<!-- Spring AI — ChatClient fluent API -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-client-chat</artifactId>
</dependency>
<!-- Spring AI — QuestionAnswerAdvisor (RAG) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>
<!-- Spring AI — PDF document reader -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pdf-document-reader</artifactId>
</dependency>
<!-- Jackson (JSON) -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,8 @@
package com.aiteacher.book;
public enum BookStatus {
PENDING,
PROCESSING,
READY,
FAILED
}

View File

@@ -0,0 +1,8 @@
package com.aiteacher.book;
public class NoKnowledgeSourceException extends RuntimeException {
public NoKnowledgeSourceException(String message) {
super(message);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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> {
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -0,0 +1,6 @@
package com.aiteacher.chat;
public enum MessageRole {
USER,
ASSISTANT
}

View File

@@ -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();
}
}

View File

@@ -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."));
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,9 @@
package com.aiteacher.topic;
public record Topic(
String id,
String name,
String description,
String category
) {
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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
) {
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,52 @@
spring:
datasource:
url: ${DB_URL:jdbc:postgresql://master:30432/aiteacher}
username: ${DB_USERNAME:user}
password: ${DB_PASSWORD:password}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: false
properties:
hibernate:
format_sql: false
dialect: org.hibernate.dialect.PostgreSQLDialect
open-in-view: false
flyway:
enabled: true
locations: classpath:db/migration
ai:
vectorstore:
pgvector:
dimensions: 1536
distance-type: COSINE_DISTANCE
index-type: HNSW
initialize-schema: false
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o
embedding:
options:
model: "text-embedding-3-small"
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
task:
execution:
pool:
core-size: 4
max-size: 8
queue-capacity: 50
app:
auth:
password: ${APP_PASSWORD:changeme}

View File

@@ -0,0 +1,33 @@
-- ============================================================
-- V1: Initial schema
-- Spring AI manages the vector_store table separately.
-- ============================================================
CREATE TABLE IF NOT EXISTS book (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(500) NOT NULL,
file_name VARCHAR(500) NOT NULL,
file_size_bytes BIGINT NOT NULL,
page_count INT,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
error_message TEXT,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS chat_session (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
topic_id VARCHAR(100),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS message (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES chat_session(id) ON DELETE CASCADE,
role VARCHAR(10) NOT NULL,
content TEXT NOT NULL,
sources JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_message_session ON message(session_id, created_at);

View File

@@ -0,0 +1,17 @@
-- ============================================================
-- V2: pgvector extension + Spring AI vector_store table
-- Replaces Spring AI's initialize-schema=true (which requires
-- CREATE SCHEMA privilege and fails on restricted users).
-- ============================================================
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS vector_store (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
content text,
metadata json,
embedding vector(1536)
);
CREATE INDEX IF NOT EXISTS spring_ai_vector_index
ON vector_store USING hnsw (embedding vector_cosine_ops);

View File

@@ -0,0 +1,74 @@
[
{
"id": "cerebral-aneurysm",
"name": "Cerebral Aneurysm Management",
"description": "Diagnosis, grading, and surgical/endovascular treatment of cerebral aneurysms.",
"category": "Vascular"
},
{
"id": "subarachnoid-hemorrhage",
"name": "Subarachnoid Hemorrhage",
"description": "Pathophysiology, clinical presentation, and management of subarachnoid hemorrhage.",
"category": "Vascular"
},
{
"id": "arteriovenous-malformation",
"name": "Arteriovenous Malformation (AVM)",
"description": "Classification, natural history, and treatment options for cerebral AVMs.",
"category": "Vascular"
},
{
"id": "carotid-stenosis",
"name": "Carotid Artery Stenosis",
"description": "Evaluation and surgical or endovascular management of carotid artery stenosis.",
"category": "Vascular"
},
{
"id": "glioblastoma",
"name": "Glioblastoma (GBM)",
"description": "Pathophysiology, surgical resection strategies, and adjuvant therapy for GBM.",
"category": "Oncology"
},
{
"id": "meningioma",
"name": "Meningioma",
"description": "Classification, surgical approaches, and recurrence management for meningiomas.",
"category": "Oncology"
},
{
"id": "pituitary-adenoma",
"name": "Pituitary Adenoma",
"description": "Hormonal assessment, transsphenoidal surgery, and medical management of pituitary adenomas.",
"category": "Oncology"
},
{
"id": "lumbar-disc-herniation",
"name": "Lumbar Disc Herniation",
"description": "Anatomy, clinical presentation, conservative and surgical treatment of lumbar disc herniation.",
"category": "Spine"
},
{
"id": "cervical-myelopathy",
"name": "Cervical Spondylotic Myelopathy",
"description": "Pathomechanics, clinical grading, and decompressive surgery for cervical myelopathy.",
"category": "Spine"
},
{
"id": "spinal-cord-injury",
"name": "Spinal Cord Injury",
"description": "ASIA classification, acute management protocols, and rehabilitation strategies for SCI.",
"category": "Spine"
},
{
"id": "traumatic-brain-injury",
"name": "Traumatic Brain Injury (TBI)",
"description": "GCS scoring, intracranial pressure monitoring, and surgical management of TBI.",
"category": "Trauma"
},
{
"id": "epidural-hematoma",
"name": "Epidural Hematoma",
"description": "Mechanism, radiological features, and emergency surgical evacuation of epidural hematomas.",
"category": "Trauma"
}
]

Binary file not shown.

View File

@@ -0,0 +1,52 @@
spring:
datasource:
url: ${DB_URL:jdbc:postgresql://master:30432/aiteacher}
username: ${DB_USERNAME:user}
password: ${DB_PASSWORD:password}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: false
properties:
hibernate:
format_sql: false
dialect: org.hibernate.dialect.PostgreSQLDialect
open-in-view: false
flyway:
enabled: true
locations: classpath:db/migration
ai:
vectorstore:
pgvector:
dimensions: 1536
distance-type: COSINE_DISTANCE
index-type: HNSW
initialize-schema: false
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o
embedding:
options:
model: "text-embedding-3-small"
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
task:
execution:
pool:
core-size: 4
max-size: 8
queue-capacity: 50
app:
auth:
password: ${APP_PASSWORD:changeme}

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,33 @@
-- ============================================================
-- V1: Initial schema
-- Spring AI manages the vector_store table separately.
-- ============================================================
CREATE TABLE IF NOT EXISTS book (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(500) NOT NULL,
file_name VARCHAR(500) NOT NULL,
file_size_bytes BIGINT NOT NULL,
page_count INT,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
error_message TEXT,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS chat_session (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
topic_id VARCHAR(100),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS message (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES chat_session(id) ON DELETE CASCADE,
role VARCHAR(10) NOT NULL,
content TEXT NOT NULL,
sources JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_message_session ON message(session_id, created_at);

View File

@@ -0,0 +1,17 @@
-- ============================================================
-- V2: pgvector extension + Spring AI vector_store table
-- Replaces Spring AI's initialize-schema=true (which requires
-- CREATE SCHEMA privilege and fails on restricted users).
-- ============================================================
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS vector_store (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
content text,
metadata json,
embedding vector(1536)
);
CREATE INDEX IF NOT EXISTS spring_ai_vector_index
ON vector_store USING hnsw (embedding vector_cosine_ops);

View File

@@ -0,0 +1,74 @@
[
{
"id": "cerebral-aneurysm",
"name": "Cerebral Aneurysm Management",
"description": "Diagnosis, grading, and surgical/endovascular treatment of cerebral aneurysms.",
"category": "Vascular"
},
{
"id": "subarachnoid-hemorrhage",
"name": "Subarachnoid Hemorrhage",
"description": "Pathophysiology, clinical presentation, and management of subarachnoid hemorrhage.",
"category": "Vascular"
},
{
"id": "arteriovenous-malformation",
"name": "Arteriovenous Malformation (AVM)",
"description": "Classification, natural history, and treatment options for cerebral AVMs.",
"category": "Vascular"
},
{
"id": "carotid-stenosis",
"name": "Carotid Artery Stenosis",
"description": "Evaluation and surgical or endovascular management of carotid artery stenosis.",
"category": "Vascular"
},
{
"id": "glioblastoma",
"name": "Glioblastoma (GBM)",
"description": "Pathophysiology, surgical resection strategies, and adjuvant therapy for GBM.",
"category": "Oncology"
},
{
"id": "meningioma",
"name": "Meningioma",
"description": "Classification, surgical approaches, and recurrence management for meningiomas.",
"category": "Oncology"
},
{
"id": "pituitary-adenoma",
"name": "Pituitary Adenoma",
"description": "Hormonal assessment, transsphenoidal surgery, and medical management of pituitary adenomas.",
"category": "Oncology"
},
{
"id": "lumbar-disc-herniation",
"name": "Lumbar Disc Herniation",
"description": "Anatomy, clinical presentation, conservative and surgical treatment of lumbar disc herniation.",
"category": "Spine"
},
{
"id": "cervical-myelopathy",
"name": "Cervical Spondylotic Myelopathy",
"description": "Pathomechanics, clinical grading, and decompressive surgery for cervical myelopathy.",
"category": "Spine"
},
{
"id": "spinal-cord-injury",
"name": "Spinal Cord Injury",
"description": "ASIA classification, acute management protocols, and rehabilitation strategies for SCI.",
"category": "Spine"
},
{
"id": "traumatic-brain-injury",
"name": "Traumatic Brain Injury (TBI)",
"description": "GCS scoring, intracranial pressure monitoring, and surgical management of TBI.",
"category": "Trauma"
},
{
"id": "epidural-hematoma",
"name": "Epidural Hematoma",
"description": "Mechanism, radiological features, and emergency surgical evacuation of epidural hematomas.",
"category": "Trauma"
}
]

View File

@@ -0,0 +1,3 @@
artifactId=ai-teacher-backend
groupId=com.aiteacher
version=0.0.1-SNAPSHOT

View File

@@ -0,0 +1,25 @@
com/aiteacher/AiTeacherApplication.class
com/aiteacher/book/BookEmbeddingService.class
com/aiteacher/topic/TopicController.class
com/aiteacher/topic/TopicSummaryService.class
com/aiteacher/book/BookRepository.class
com/aiteacher/chat/ChatSession.class
com/aiteacher/config/GlobalExceptionHandler.class
com/aiteacher/topic/Topic.class
com/aiteacher/topic/TopicSummaryResponse$SourceReference.class
com/aiteacher/chat/ChatService.class
com/aiteacher/chat/MessageRole.class
com/aiteacher/chat/ChatController.class
com/aiteacher/chat/Message.class
com/aiteacher/book/NoKnowledgeSourceException.class
com/aiteacher/config/AiConfig.class
com/aiteacher/book/BookService.class
com/aiteacher/book/BookController.class
com/aiteacher/topic/TopicConfigLoader.class
com/aiteacher/config/SecurityConfig.class
com/aiteacher/topic/TopicSummaryResponse.class
com/aiteacher/chat/ChatSessionRepository.class
com/aiteacher/book/BookStatus.class
com/aiteacher/book/Book.class
com/aiteacher/topic/TopicConfigLoader$1.class
com/aiteacher/chat/MessageRepository.class

View File

@@ -0,0 +1,23 @@
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/AiTeacherApplication.java
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/book/Book.java
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/book/BookController.java
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/book/BookEmbeddingService.java
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/book/BookRepository.java
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/book/BookService.java
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/book/BookStatus.java
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/book/NoKnowledgeSourceException.java
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/chat/ChatController.java
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/chat/ChatService.java
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/chat/ChatSession.java
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/chat/ChatSessionRepository.java
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/chat/Message.java
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/chat/MessageRepository.java
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/chat/MessageRole.java
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/config/AiConfig.java
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/config/GlobalExceptionHandler.java
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/config/SecurityConfig.java
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/topic/Topic.java
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/topic/TopicConfigLoader.java
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/topic/TopicController.java
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/topic/TopicSummaryResponse.java
/home/adrien/project/java/ai-teacher/backend/src/main/java/com/aiteacher/topic/TopicSummaryService.java