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

58
README.md Normal file
View File

@@ -0,0 +1,58 @@
# AI Teacher — Neurosurgeon RAG Learning Platform
A web application for neurosurgeons to upload medical textbooks (PDF), have them
embedded into a pgvector store, then select from a predefined topic list to receive
AI-generated cross-book summaries, and engage in grounded RAG chat.
## Architecture
```mermaid
graph TD
User["Neurosurgeon (Browser)"]
FE["Frontend\nVue.js 3 / Vite\n:5173"]
BE["Backend\nSpring Boot 4 / Spring AI\n:8080"]
DB["PostgreSQL + pgvector\n(provided)"]
LLM["LLM Provider\n(OpenAI / configurable)"]
User -->|HTTP| FE
FE -->|REST /api/v1/...| BE
BE -->|JDBC / pgvector| DB
BE -->|Embedding + Chat API| LLM
```
## Stack
- **Backend**: Spring Boot 4.0.5 + Spring AI 2.0.0-M4, Java 21, Maven
- **Frontend**: Vue.js 3 + Vite + TypeScript + Pinia + Axios
- **Database**: PostgreSQL 16 + pgvector extension
- **Auth**: HTTP Basic (single shared in-memory user)
## Quick Start
See [specs/001-neuro-rag-learning/quickstart.md](specs/001-neuro-rag-learning/quickstart.md) for full instructions.
### Local Dev
```bash
# Start the database
docker compose up -d
# Backend
cd backend
mvn spring-boot:run
# Frontend
cd frontend
npm install
npm run dev
```
### Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `OPENAI_API_KEY` | Yes | OpenAI API key for embeddings and chat |
| `APP_PASSWORD` | Yes | Shared password for HTTP Basic auth |
| `DB_URL` | Yes | JDBC URL, e.g. `jdbc:postgresql://localhost:5432/aiteacher` |
| `DB_USERNAME` | Yes | Database username |
| `DB_PASSWORD` | Yes | Database password |

View File

@@ -4,5 +4,7 @@
"path": "."
}
],
"settings": {}
"settings": {
"java.configuration.updateBuildConfiguration": "interactive"
}
}

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

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
version: '3.9'
services:
postgres:
image: pgvector/pgvector:pg16
container_name: aiteacher-postgres
environment:
POSTGRES_DB: aiteacher
POSTGRES_USER: aiteacher
POSTGRES_PASSWORD: aiteacher
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U aiteacher -d aiteacher"]
interval: 10s
timeout: 5s
retries: 5
volumes:
pgdata:

7
frontend/.env.example Normal file
View File

@@ -0,0 +1,7 @@
# Base URL for the backend API.
# In development with the Vite proxy this can be left as the default (/api/v1).
# In production point it directly at the backend, e.g. https://api.example.com/api/v1
VITE_API_URL=/api/v1
# Shared password for HTTP Basic auth (must match APP_PASSWORD on the backend).
VITE_APP_PASSWORD=changeme

14
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
# ---- Build stage ----
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- Runtime stage (nginx) ----
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

File diff suppressed because one or more lines are too long

35
frontend/dist/assets/index-Cyrl5U0Q.js vendored Normal file

File diff suppressed because one or more lines are too long

14
frontend/dist/index.html vendored Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Teacher — Neurosurgeon Learning Platform</title>
<script type="module" crossorigin src="/assets/index-Cyrl5U0Q.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BFTghWYO.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Teacher — Neurosurgeon Learning Platform</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

22
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,22 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Vue Router — serve index.html for all non-asset routes
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets aggressively (content-hashed filenames)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Gzip
gzip on;
gzip_types text/plain text/css application/javascript application/json;
gzip_min_length 1024;
}

1
frontend/node_modules/.bin/esbuild generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../esbuild/bin/esbuild

1
frontend/node_modules/.bin/he generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../he/bin/he

1
frontend/node_modules/.bin/nanoid generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../nanoid/bin/nanoid.cjs

1
frontend/node_modules/.bin/parser generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../@babel/parser/bin/babel-parser.js

1
frontend/node_modules/.bin/rollup generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../rollup/dist/bin/rollup

1
frontend/node_modules/.bin/tsc generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../typescript/bin/tsc

1
frontend/node_modules/.bin/tsserver generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../typescript/bin/tsserver

1
frontend/node_modules/.bin/vite generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../vite/bin/vite.js

1
frontend/node_modules/.bin/vue-demi-fix generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../vue-demi/bin/vue-demi-fix.js

1
frontend/node_modules/.bin/vue-demi-switch generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../vue-demi/bin/vue-demi-switch.js

1
frontend/node_modules/.bin/vue-tsc generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../vue-tsc/bin/vue-tsc.js

963
frontend/node_modules/.package-lock.json generated vendored Normal file
View File

@@ -0,0 +1,963 @@
{
"name": "ai-teacher-frontend",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"dependencies": {
"@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
"integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
"integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@tsconfig/node20": {
"version": "20.1.9",
"resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.9.tgz",
"integrity": "sha512-IjlTv1RsvnPtUcjTqtVsZExKVq+KQx4g5pCP5tI7rAs6Xesl2qFwSz/tPDBC4JajkL/MlezBu3gPUwqRHl+RIg==",
"dev": true
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true
},
"node_modules/@types/node": {
"version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"dev": true,
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
"integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
"dev": true,
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0",
"vue": "^3.2.25"
}
},
"node_modules/@volar/language-core": {
"version": "2.4.15",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz",
"integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==",
"dev": true,
"dependencies": {
"@volar/source-map": "2.4.15"
}
},
"node_modules/@volar/source-map": {
"version": "2.4.15",
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz",
"integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==",
"dev": true
},
"node_modules/@volar/typescript": {
"version": "2.4.15",
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz",
"integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==",
"dev": true,
"dependencies": {
"@volar/language-core": "2.4.15",
"path-browserify": "^1.0.1",
"vscode-uri": "^3.0.8"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.31",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.31.tgz",
"integrity": "sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==",
"dependencies": {
"@babel/parser": "^7.29.2",
"@vue/shared": "3.5.31",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.31",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.31.tgz",
"integrity": "sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw==",
"dependencies": {
"@vue/compiler-core": "3.5.31",
"@vue/shared": "3.5.31"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.31",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.31.tgz",
"integrity": "sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q==",
"dependencies": {
"@babel/parser": "^7.29.2",
"@vue/compiler-core": "3.5.31",
"@vue/compiler-dom": "3.5.31",
"@vue/compiler-ssr": "3.5.31",
"@vue/shared": "3.5.31",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.8",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.31",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.31.tgz",
"integrity": "sha512-h0xIMxrt/LHOvJKMri+vdYT92BrK3HFLtDqq9Pr/lVVfE4IyKZKvWf0vJFW10Yr6nX02OR4MkJwI0c1HDa1hog==",
"dependencies": {
"@vue/compiler-dom": "3.5.31",
"@vue/shared": "3.5.31"
}
},
"node_modules/@vue/compiler-vue2": {
"version": "2.7.16",
"resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
"integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
"dev": true,
"dependencies": {
"de-indent": "^1.0.2",
"he": "^1.2.0"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
},
"node_modules/@vue/language-core": {
"version": "2.2.12",
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz",
"integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==",
"dev": true,
"dependencies": {
"@volar/language-core": "2.4.15",
"@vue/compiler-dom": "^3.5.0",
"@vue/compiler-vue2": "^2.7.16",
"@vue/shared": "^3.5.0",
"alien-signals": "^1.0.3",
"minimatch": "^9.0.3",
"muggle-string": "^0.4.1",
"path-browserify": "^1.0.1"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.31",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.31.tgz",
"integrity": "sha512-DtKXxk9E/KuVvt8VxWu+6Luc9I9ETNcqR1T1oW1gf02nXaZ1kuAx58oVu7uX9XxJR0iJCro6fqBLw9oSBELo5g==",
"dependencies": {
"@vue/shared": "3.5.31"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.31",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.31.tgz",
"integrity": "sha512-AZPmIHXEAyhpkmN7aWlqjSfYynmkWlluDNPHMCZKFHH+lLtxP/30UJmoVhXmbDoP1Ng0jG0fyY2zCj1PnSSA6Q==",
"dependencies": {
"@vue/reactivity": "3.5.31",
"@vue/shared": "3.5.31"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.31",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.31.tgz",
"integrity": "sha512-xQJsNRmGPeDCJq/u813tyonNgWBFjzfVkBwDREdEWndBnGdHLHgkwNBQxLtg4zDrzKTEcnikUy1UUNecb3lJ6g==",
"dependencies": {
"@vue/reactivity": "3.5.31",
"@vue/runtime-core": "3.5.31",
"@vue/shared": "3.5.31",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.31",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.31.tgz",
"integrity": "sha512-GJuwRvMcdZX/CriUnyIIOGkx3rMV3H6sOu0JhdKbduaeCji6zb60iOGMY7tFoN24NfsUYoFBhshZtGxGpxO4iA==",
"dependencies": {
"@vue/compiler-ssr": "3.5.31",
"@vue/shared": "3.5.31"
},
"peerDependencies": {
"vue": "3.5.31"
}
},
"node_modules/@vue/shared": {
"version": "3.5.31",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.31.tgz",
"integrity": "sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw=="
},
"node_modules/@vue/tsconfig": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.5.1.tgz",
"integrity": "sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==",
"dev": true
},
"node_modules/alien-signals": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
"integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==",
"dev": true
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"node_modules/brace-expansion": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
},
"node_modules/de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
"integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
"dev": true
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"dev": true,
"bin": {
"he": "bin/he"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
"dev": true
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"dev": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"node_modules/pinia": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz",
"integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==",
"dependencies": {
"@vue/devtools-api": "^6.6.3",
"vue-demi": "^0.14.10"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.4.4",
"vue": "^2.7.0 || ^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"engines": {
"node": ">=10"
}
},
"node_modules/rollup": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true,
"dependencies": {
"@types/estree": "1.0.8"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.60.1",
"@rollup/rollup-android-arm64": "4.60.1",
"@rollup/rollup-darwin-arm64": "4.60.1",
"@rollup/rollup-darwin-x64": "4.60.1",
"@rollup/rollup-freebsd-arm64": "4.60.1",
"@rollup/rollup-freebsd-x64": "4.60.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
"@rollup/rollup-linux-arm-musleabihf": "4.60.1",
"@rollup/rollup-linux-arm64-gnu": "4.60.1",
"@rollup/rollup-linux-arm64-musl": "4.60.1",
"@rollup/rollup-linux-loong64-gnu": "4.60.1",
"@rollup/rollup-linux-loong64-musl": "4.60.1",
"@rollup/rollup-linux-ppc64-gnu": "4.60.1",
"@rollup/rollup-linux-ppc64-musl": "4.60.1",
"@rollup/rollup-linux-riscv64-gnu": "4.60.1",
"@rollup/rollup-linux-riscv64-musl": "4.60.1",
"@rollup/rollup-linux-s390x-gnu": "4.60.1",
"@rollup/rollup-linux-x64-gnu": "4.60.1",
"@rollup/rollup-linux-x64-musl": "4.60.1",
"@rollup/rollup-openbsd-x64": "4.60.1",
"@rollup/rollup-openharmony-arm64": "4.60.1",
"@rollup/rollup-win32-arm64-msvc": "4.60.1",
"@rollup/rollup-win32-ia32-msvc": "4.60.1",
"@rollup/rollup-win32-x64-gnu": "4.60.1",
"@rollup/rollup-win32-x64-msvc": "4.60.1",
"fsevents": "~2.3.2"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/typescript": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"devOptional": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
"rollup": "^4.20.0"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || >=20.0.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
}
}
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"dev": true
},
"node_modules/vue": {
"version": "3.5.31",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.31.tgz",
"integrity": "sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==",
"dependencies": {
"@vue/compiler-dom": "3.5.31",
"@vue/compiler-sfc": "3.5.31",
"@vue/runtime-dom": "3.5.31",
"@vue/server-renderer": "3.5.31",
"@vue/shared": "3.5.31"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/vue-tsc": {
"version": "2.2.12",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz",
"integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==",
"dev": true,
"dependencies": {
"@volar/typescript": "2.4.15",
"@vue/language-core": "2.2.12"
},
"bin": {
"vue-tsc": "bin/vue-tsc.js"
},
"peerDependencies": {
"typescript": ">=5.0.0"
}
}
}
}

43
frontend/node_modules/.vite/deps/_metadata.json generated vendored Normal file
View File

@@ -0,0 +1,43 @@
{
"hash": "29abdcf6",
"configHash": "1fc6cf50",
"lockfileHash": "a08e29ca",
"browserHash": "8b56a248",
"optimized": {
"axios": {
"src": "../../axios/index.js",
"file": "axios.js",
"fileHash": "4b12b8e3",
"needsInterop": false
},
"pinia": {
"src": "../../pinia/dist/pinia.mjs",
"file": "pinia.js",
"fileHash": "b2eb75d2",
"needsInterop": false
},
"vue": {
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "2a5e5f78",
"needsInterop": false
},
"vue-router": {
"src": "../../vue-router/dist/vue-router.mjs",
"file": "vue-router.js",
"fileHash": "657375ef",
"needsInterop": false
}
},
"chunks": {
"chunk-YFT6OQ5R": {
"file": "chunk-YFT6OQ5R.js"
},
"chunk-4VAHRDA3": {
"file": "chunk-4VAHRDA3.js"
},
"chunk-PZ5AY32C": {
"file": "chunk-PZ5AY32C.js"
}
}
}

2753
frontend/node_modules/.vite/deps/axios.js generated vendored Normal file

File diff suppressed because it is too large Load Diff

7
frontend/node_modules/.vite/deps/axios.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

12984
frontend/node_modules/.vite/deps/chunk-4VAHRDA3.js generated vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

10
frontend/node_modules/.vite/deps/chunk-PZ5AY32C.js generated vendored Normal file
View File

@@ -0,0 +1,10 @@
var __defProp = Object.defineProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
export {
__export
};
//# sourceMappingURL=chunk-PZ5AY32C.js.map

View File

@@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

162
frontend/node_modules/.vite/deps/chunk-YFT6OQ5R.js generated vendored Normal file
View File

@@ -0,0 +1,162 @@
// node_modules/@vue/devtools-api/lib/esm/env.js
function getDevtoolsGlobalHook() {
return getTarget().__VUE_DEVTOOLS_GLOBAL_HOOK__;
}
function getTarget() {
return typeof navigator !== "undefined" && typeof window !== "undefined" ? window : typeof globalThis !== "undefined" ? globalThis : {};
}
var isProxyAvailable = typeof Proxy === "function";
// node_modules/@vue/devtools-api/lib/esm/const.js
var HOOK_SETUP = "devtools-plugin:setup";
var HOOK_PLUGIN_SETTINGS_SET = "plugin:settings:set";
// node_modules/@vue/devtools-api/lib/esm/time.js
var supported;
var perf;
function isPerformanceSupported() {
var _a;
if (supported !== void 0) {
return supported;
}
if (typeof window !== "undefined" && window.performance) {
supported = true;
perf = window.performance;
} else if (typeof globalThis !== "undefined" && ((_a = globalThis.perf_hooks) === null || _a === void 0 ? void 0 : _a.performance)) {
supported = true;
perf = globalThis.perf_hooks.performance;
} else {
supported = false;
}
return supported;
}
function now() {
return isPerformanceSupported() ? perf.now() : Date.now();
}
// node_modules/@vue/devtools-api/lib/esm/proxy.js
var ApiProxy = class {
constructor(plugin, hook) {
this.target = null;
this.targetQueue = [];
this.onQueue = [];
this.plugin = plugin;
this.hook = hook;
const defaultSettings = {};
if (plugin.settings) {
for (const id in plugin.settings) {
const item = plugin.settings[id];
defaultSettings[id] = item.defaultValue;
}
}
const localSettingsSaveId = `__vue-devtools-plugin-settings__${plugin.id}`;
let currentSettings = Object.assign({}, defaultSettings);
try {
const raw = localStorage.getItem(localSettingsSaveId);
const data = JSON.parse(raw);
Object.assign(currentSettings, data);
} catch (e) {
}
this.fallbacks = {
getSettings() {
return currentSettings;
},
setSettings(value) {
try {
localStorage.setItem(localSettingsSaveId, JSON.stringify(value));
} catch (e) {
}
currentSettings = value;
},
now() {
return now();
}
};
if (hook) {
hook.on(HOOK_PLUGIN_SETTINGS_SET, (pluginId, value) => {
if (pluginId === this.plugin.id) {
this.fallbacks.setSettings(value);
}
});
}
this.proxiedOn = new Proxy({}, {
get: (_target, prop) => {
if (this.target) {
return this.target.on[prop];
} else {
return (...args) => {
this.onQueue.push({
method: prop,
args
});
};
}
}
});
this.proxiedTarget = new Proxy({}, {
get: (_target, prop) => {
if (this.target) {
return this.target[prop];
} else if (prop === "on") {
return this.proxiedOn;
} else if (Object.keys(this.fallbacks).includes(prop)) {
return (...args) => {
this.targetQueue.push({
method: prop,
args,
resolve: () => {
}
});
return this.fallbacks[prop](...args);
};
} else {
return (...args) => {
return new Promise((resolve) => {
this.targetQueue.push({
method: prop,
args,
resolve
});
});
};
}
}
});
}
async setRealTarget(target) {
this.target = target;
for (const item of this.onQueue) {
this.target.on[item.method](...item.args);
}
for (const item of this.targetQueue) {
item.resolve(await this.target[item.method](...item.args));
}
}
};
// node_modules/@vue/devtools-api/lib/esm/index.js
function setupDevtoolsPlugin(pluginDescriptor, setupFn) {
const descriptor = pluginDescriptor;
const target = getTarget();
const hook = getDevtoolsGlobalHook();
const enableProxy = isProxyAvailable && descriptor.enableEarlyProxy;
if (hook && (target.__VUE_DEVTOOLS_PLUGIN_API_AVAILABLE__ || !enableProxy)) {
hook.emit(HOOK_SETUP, pluginDescriptor, setupFn);
} else {
const proxy = enableProxy ? new ApiProxy(descriptor, hook) : null;
const list = target.__VUE_DEVTOOLS_PLUGINS__ = target.__VUE_DEVTOOLS_PLUGINS__ || [];
list.push({
pluginDescriptor: descriptor,
setupFn,
proxy
});
if (proxy) {
setupFn(proxy.proxiedTarget);
}
}
}
export {
setupDevtoolsPlugin
};
//# sourceMappingURL=chunk-YFT6OQ5R.js.map

File diff suppressed because one or more lines are too long

3
frontend/node_modules/.vite/deps/package.json generated vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

1561
frontend/node_modules/.vite/deps/pinia.js generated vendored Normal file

File diff suppressed because it is too large Load Diff

7
frontend/node_modules/.vite/deps/pinia.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

2247
frontend/node_modules/.vite/deps/vue-router.js generated vendored Normal file

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More