first implementation
This commit is contained in:
13
backend/Dockerfile
Normal file
13
backend/Dockerfile
Normal 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
134
backend/pom.xml
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
122
backend/src/main/java/com/aiteacher/book/Book.java
Normal file
122
backend/src/main/java/com/aiteacher/book/Book.java
Normal 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;
|
||||
}
|
||||
}
|
||||
75
backend/src/main/java/com/aiteacher/book/BookController.java
Normal file
75
backend/src/main/java/com/aiteacher/book/BookController.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
15
backend/src/main/java/com/aiteacher/book/BookRepository.java
Normal file
15
backend/src/main/java/com/aiteacher/book/BookRepository.java
Normal 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);
|
||||
}
|
||||
79
backend/src/main/java/com/aiteacher/book/BookService.java
Normal file
79
backend/src/main/java/com/aiteacher/book/BookService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
8
backend/src/main/java/com/aiteacher/book/BookStatus.java
Normal file
8
backend/src/main/java/com/aiteacher/book/BookStatus.java
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
75
backend/src/main/java/com/aiteacher/chat/ChatController.java
Normal file
75
backend/src/main/java/com/aiteacher/chat/ChatController.java
Normal 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;
|
||||
}
|
||||
}
|
||||
166
backend/src/main/java/com/aiteacher/chat/ChatService.java
Normal file
166
backend/src/main/java/com/aiteacher/chat/ChatService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
48
backend/src/main/java/com/aiteacher/chat/ChatSession.java
Normal file
48
backend/src/main/java/com/aiteacher/chat/ChatSession.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
90
backend/src/main/java/com/aiteacher/chat/Message.java
Normal file
90
backend/src/main/java/com/aiteacher/chat/Message.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
21
backend/src/main/java/com/aiteacher/config/AiConfig.java
Normal file
21
backend/src/main/java/com/aiteacher/config/AiConfig.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
9
backend/src/main/java/com/aiteacher/topic/Topic.java
Normal file
9
backend/src/main/java/com/aiteacher/topic/Topic.java
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
52
backend/src/main/resources/application.yaml
Normal file
52
backend/src/main/resources/application.yaml
Normal 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}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
74
backend/src/main/resources/topics.json
Normal file
74
backend/src/main/resources/topics.json
Normal 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"
|
||||
}
|
||||
]
|
||||
BIN
backend/target/ai-teacher-backend-0.0.1-SNAPSHOT.jar
Normal file
BIN
backend/target/ai-teacher-backend-0.0.1-SNAPSHOT.jar
Normal file
Binary file not shown.
BIN
backend/target/ai-teacher-backend-0.0.1-SNAPSHOT.jar.original
Normal file
BIN
backend/target/ai-teacher-backend-0.0.1-SNAPSHOT.jar.original
Normal file
Binary file not shown.
52
backend/target/classes/application.yaml
Normal file
52
backend/target/classes/application.yaml
Normal 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}
|
||||
BIN
backend/target/classes/com/aiteacher/AiTeacherApplication.class
Normal file
BIN
backend/target/classes/com/aiteacher/AiTeacherApplication.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/aiteacher/book/Book.class
Normal file
BIN
backend/target/classes/com/aiteacher/book/Book.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/aiteacher/book/BookController.class
Normal file
BIN
backend/target/classes/com/aiteacher/book/BookController.class
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/target/classes/com/aiteacher/book/BookRepository.class
Normal file
BIN
backend/target/classes/com/aiteacher/book/BookRepository.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/aiteacher/book/BookService.class
Normal file
BIN
backend/target/classes/com/aiteacher/book/BookService.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/aiteacher/book/BookStatus.class
Normal file
BIN
backend/target/classes/com/aiteacher/book/BookStatus.class
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/target/classes/com/aiteacher/chat/ChatController.class
Normal file
BIN
backend/target/classes/com/aiteacher/chat/ChatController.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/aiteacher/chat/ChatService.class
Normal file
BIN
backend/target/classes/com/aiteacher/chat/ChatService.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/aiteacher/chat/ChatSession.class
Normal file
BIN
backend/target/classes/com/aiteacher/chat/ChatSession.class
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/target/classes/com/aiteacher/chat/Message.class
Normal file
BIN
backend/target/classes/com/aiteacher/chat/Message.class
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/target/classes/com/aiteacher/chat/MessageRole.class
Normal file
BIN
backend/target/classes/com/aiteacher/chat/MessageRole.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/aiteacher/config/AiConfig.class
Normal file
BIN
backend/target/classes/com/aiteacher/config/AiConfig.class
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/target/classes/com/aiteacher/config/SecurityConfig.class
Normal file
BIN
backend/target/classes/com/aiteacher/config/SecurityConfig.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/aiteacher/topic/Topic.class
Normal file
BIN
backend/target/classes/com/aiteacher/topic/Topic.class
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/target/classes/com/aiteacher/topic/TopicController.class
Normal file
BIN
backend/target/classes/com/aiteacher/topic/TopicController.class
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
33
backend/target/classes/db/migration/V1__initial_schema.sql
Normal file
33
backend/target/classes/db/migration/V1__initial_schema.sql
Normal 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);
|
||||
17
backend/target/classes/db/migration/V2__pgvector_store.sql
Normal file
17
backend/target/classes/db/migration/V2__pgvector_store.sql
Normal 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);
|
||||
74
backend/target/classes/topics.json
Normal file
74
backend/target/classes/topics.json
Normal 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"
|
||||
}
|
||||
]
|
||||
3
backend/target/maven-archiver/pom.properties
Normal file
3
backend/target/maven-archiver/pom.properties
Normal file
@@ -0,0 +1,3 @@
|
||||
artifactId=ai-teacher-backend
|
||||
groupId=com.aiteacher
|
||||
version=0.0.1-SNAPSHOT
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user