From 93540bc7a9c8df761458668db91cde6a402606a3 Mon Sep 17 00:00:00 2001 From: CreadorLanda Date: Wed, 4 Feb 2026 17:48:18 +0100 Subject: [PATCH 01/48] feat(chat): add Groq AI chatbot integration with Swagger UI --- .gitignore | 13 +- Dockerfile | 4 +- docker-compose.yml | 35 +++++ infra/docker/ocr.Dockerfile | 2 +- services/backend-api/.env.example | 5 +- services/backend-api/pom.xml | 6 + .../creativemode/kixi/config/GroqConfig.java | 25 ++++ .../kixi/controller/ChatController.java | 21 +++ .../creativemode/kixi/dto/ChatMessageDto.java | 15 +++ .../creativemode/kixi/dto/ChatRequestDto.java | 17 +++ .../kixi/dto/ChatResponseDto.java | 16 +++ .../kixi/service/ChatService.java | 81 ++++++++++++ .../src/main/resources/application.yml | 41 ++++++ services/ocr-service/app/__init__.py | 1 + services/ocr-service/app/api/__init__.py | 1 + services/ocr-service/app/api/routes.py | 76 +++++++++++ services/ocr-service/app/config/__init__.py | 1 + services/ocr-service/app/config/settings.py | 26 ++++ services/ocr-service/app/main.py | 34 +++++ services/ocr-service/app/ocr/__init__.py | 1 + services/ocr-service/app/ocr/engine.py | 93 +++++++++++++ services/ocr-service/app/ocr/preprocessing.py | 124 ++++++++++++++++++ services/ocr-service/requirements.txt | 1 + 23 files changed, 628 insertions(+), 11 deletions(-) create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/config/GroqConfig.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/controller/ChatController.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/dto/ChatMessageDto.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/dto/ChatRequestDto.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/dto/ChatResponseDto.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/service/ChatService.java create mode 100644 services/backend-api/src/main/resources/application.yml create mode 100644 services/ocr-service/app/__init__.py create mode 100644 services/ocr-service/app/api/__init__.py create mode 100644 services/ocr-service/app/config/__init__.py create mode 100644 services/ocr-service/app/ocr/__init__.py diff --git a/.gitignore b/.gitignore index 88758ae..83e76bb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,8 @@ HELP.md -Dockerfile target/ .mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ -aplication.properties ### STS ### @@ -35,9 +33,10 @@ build/ ### VS Code ### .vscode/ -application.properties - +### Environment ### .env -Kixi -demo.iml -docker-compose.yml \ No newline at end of file +.env.local +.env.*.local + +### Application ### +application.properties diff --git a/Dockerfile b/Dockerfile index de142e4..5227e06 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,12 @@ # Etapa 1: Build -FROM maven:3.9.6-eclipse-temurin-21-alpine AS build +FROM maven:3.9.6-eclipse-temurin-17-alpine AS build WORKDIR /app COPY services/backend-api/pom.xml . COPY services/backend-api/src ./src RUN mvn clean package -DskipTests # Etapa 2: Runtime -FROM eclipse-temurin:21-jre-alpine +FROM eclipse-temurin:17-jre-alpine WORKDIR /app COPY --from=build /app/target/*.jar app.jar EXPOSE 8080 diff --git a/docker-compose.yml b/docker-compose.yml index c15a1bd..0272db1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,41 @@ services: - ./services/backend-api/docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql networks: - kixi_network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d kixi_db"] + interval: 10s + timeout: 5s + retries: 5 + + kixi_backend: + build: + context: . + dockerfile: Dockerfile + container_name: kixi_backend + environment: + DB_HOST: kixi_postgres + DB_PORT: 5432 + DB_NAME: kixi_db + DB_USERNAME: postgres + DB_PASSWORD: postgres + GROQ_API_KEY: ${GROQ_API_KEY} + GROQ_MODEL: ${GROQ_MODEL:-llama-3.3-70b-versatile} + ports: + - "8080:8080" + networks: + - kixi_network + restart: unless-stopped + + kixi_ocr: + build: + context: ./services/ocr-service + dockerfile: ../../infra/docker/ocr.Dockerfile + container_name: kixi_ocr + ports: + - "8000:8000" + networks: + - kixi_network + restart: unless-stopped volumes: kixi_postgres_data: diff --git a/infra/docker/ocr.Dockerfile b/infra/docker/ocr.Dockerfile index 66956c5..79d1641 100644 --- a/infra/docker/ocr.Dockerfile +++ b/infra/docker/ocr.Dockerfile @@ -6,7 +6,7 @@ WORKDIR /app RUN apt-get update && apt-get install -y \ tesseract-ocr \ tesseract-ocr-por \ - libgl1-mesa-glx \ + libgl1 \ libglib2.0-0 \ && rm -rf /var/lib/apt/lists/* diff --git a/services/backend-api/.env.example b/services/backend-api/.env.example index c83bd44..c749a33 100644 --- a/services/backend-api/.env.example +++ b/services/backend-api/.env.example @@ -3,4 +3,7 @@ DB_USER=postgres DB_PASSWORD=postgres DB_HOST=postgres DB_PORT=5432 -SPRING_PROFILES_ACTIVE=docker \ No newline at end of file +SPRING_PROFILES_ACTIVE=docker +GROQ_API_KEY=your_groq_api_key_here +GROQ_API_URL=https://api.groq.com/openai/v1 +GROQ_MODEL=openai/gpt-oss-120b diff --git a/services/backend-api/pom.xml b/services/backend-api/pom.xml index 9f7c68e..0b99129 100644 --- a/services/backend-api/pom.xml +++ b/services/backend-api/pom.xml @@ -30,6 +30,12 @@ spring-boot-starter-webflux + + org.springdoc + springdoc-openapi-starter-webflux-ui + 2.3.0 + + org.springframework.boot spring-boot-starter-data-r2dbc diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/config/GroqConfig.java b/services/backend-api/src/main/java/ao/creativemode/kixi/config/GroqConfig.java new file mode 100644 index 0000000..3665ff5 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/config/GroqConfig.java @@ -0,0 +1,25 @@ +package ao.creativemode.kixi.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class GroqConfig { + + @Value("${groq.api.key:}") + private String apiKey; + + @Value("${groq.api.url:https://api.groq.com/openai/v1}") + private String apiUrl; + + @Bean + public WebClient groqWebClient() { + return WebClient.builder() + .baseUrl(apiUrl) + .defaultHeader("Authorization", "Bearer " + apiKey) + .defaultHeader("Content-Type", "application/json") + .build(); + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/ChatController.java b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/ChatController.java new file mode 100644 index 0000000..91f0073 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/ChatController.java @@ -0,0 +1,21 @@ +package ao.creativemode.kixi.controller; + +import ao.creativemode.kixi.dto.ChatRequestDto; +import ao.creativemode.kixi.dto.ChatResponseDto; +import ao.creativemode.kixi.service.ChatService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/api/chat") +@RequiredArgsConstructor +public class ChatController { + + private final ChatService chatService; + + @PostMapping + public Mono chat(@RequestBody ChatRequestDto request) { + return chatService.chat(request); + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ChatMessageDto.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ChatMessageDto.java new file mode 100644 index 0000000..6201dab --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ChatMessageDto.java @@ -0,0 +1,15 @@ +package ao.creativemode.kixi.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChatMessageDto { + private String role; + private String content; +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ChatRequestDto.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ChatRequestDto.java new file mode 100644 index 0000000..f781507 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ChatRequestDto.java @@ -0,0 +1,17 @@ +package ao.creativemode.kixi.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChatRequestDto { + private String message; + private List history; +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ChatResponseDto.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ChatResponseDto.java new file mode 100644 index 0000000..c492c2b --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ChatResponseDto.java @@ -0,0 +1,16 @@ +package ao.creativemode.kixi.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChatResponseDto { + private String message; + private String model; + private Integer tokensUsed; +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/ChatService.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/ChatService.java new file mode 100644 index 0000000..eb2d679 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/ChatService.java @@ -0,0 +1,81 @@ +package ao.creativemode.kixi.service; + +import ao.creativemode.kixi.dto.ChatMessageDto; +import ao.creativemode.kixi.dto.ChatRequestDto; +import ao.creativemode.kixi.dto.ChatResponseDto; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class ChatService { + + private final WebClient groqWebClient; + + @Value("${groq.model:llama-3.3-70b-versatile}") + private String model; + + public Mono chat(ChatRequestDto request) { + List> messages = new ArrayList<>(); + + messages.add(Map.of( + "role", "system", + "content", "Você é um assistente educacional chamado Kixi. Ajude os usuários com questões sobre provas, exames e conteúdos educacionais." + )); + + if (request.getHistory() != null) { + for (ChatMessageDto msg : request.getHistory()) { + messages.add(Map.of( + "role", msg.getRole(), + "content", msg.getContent() + )); + } + } + + messages.add(Map.of( + "role", "user", + "content", request.getMessage() + )); + + Map body = new HashMap<>(); + body.put("model", model); + body.put("messages", messages); + body.put("temperature", 0.7); + body.put("max_tokens", 1024); + + return groqWebClient.post() + .uri("/chat/completions") + .bodyValue(body) + .retrieve() + .bodyToMono(Map.class) + .map(this::parseResponse); + } + + @SuppressWarnings("unchecked") + private ChatResponseDto parseResponse(Map response) { + List> choices = (List>) response.get("choices"); + Map usage = (Map) response.get("usage"); + + String content = ""; + if (choices != null && !choices.isEmpty()) { + Map message = (Map) choices.get(0).get("message"); + content = (String) message.get("content"); + } + + Integer totalTokens = usage != null ? (Integer) usage.get("total_tokens") : null; + + return ChatResponseDto.builder() + .message(content) + .model((String) response.get("model")) + .tokensUsed(totalTokens) + .build(); + } +} diff --git a/services/backend-api/src/main/resources/application.yml b/services/backend-api/src/main/resources/application.yml new file mode 100644 index 0000000..9f1af3e --- /dev/null +++ b/services/backend-api/src/main/resources/application.yml @@ -0,0 +1,41 @@ +spring: + application: + name: kixi-backend-api + + r2dbc: + url: r2dbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:kixi_db} + username: ${DB_USERNAME:postgres} + password: ${DB_PASSWORD:postgres} + + flyway: + enabled: false + +server: + port: 8080 + +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + show-details: always + +groq: + api: + key: ${GROQ_API_KEY:} + url: ${GROQ_API_URL:https://api.groq.com/openai/v1} + model: ${GROQ_MODEL:llama-3.3-70b-versatile} + +springdoc: + api-docs: + path: /api-docs + swagger-ui: + path: /swagger-ui.html + +logging: + level: + root: INFO + ao.creativemode.kixi: DEBUG + org.springframework.r2dbc: DEBUG diff --git a/services/ocr-service/app/__init__.py b/services/ocr-service/app/__init__.py new file mode 100644 index 0000000..fc1078b --- /dev/null +++ b/services/ocr-service/app/__init__.py @@ -0,0 +1 @@ +# OCR Service App diff --git a/services/ocr-service/app/api/__init__.py b/services/ocr-service/app/api/__init__.py new file mode 100644 index 0000000..9c7f58e --- /dev/null +++ b/services/ocr-service/app/api/__init__.py @@ -0,0 +1 @@ +# API module diff --git a/services/ocr-service/app/api/routes.py b/services/ocr-service/app/api/routes.py index 610bfcc..01ea69b 100644 --- a/services/ocr-service/app/api/routes.py +++ b/services/ocr-service/app/api/routes.py @@ -1 +1,77 @@ # FastAPI endpoints for OCR service +from fastapi import APIRouter, UploadFile, File, HTTPException +from fastapi.responses import JSONResponse +from PIL import Image +import io +import pytesseract + +from app.ocr.engine import OCREngine +from app.ocr.preprocessing import preprocess_image + +router = APIRouter(tags=["OCR"]) +ocr_engine = OCREngine() + + +@router.post("/ocr") +async def extract_text(file: UploadFile = File(...)): + """ + Extract text from an uploaded image using OCR. + + Supported formats: PNG, JPG, JPEG, TIFF, BMP + """ + # Validate file type + allowed_types = ["image/png", "image/jpeg", "image/jpg", "image/tiff", "image/bmp"] + if file.content_type not in allowed_types: + raise HTTPException( + status_code=400, + detail=f"Invalid file type. Allowed types: {', '.join(allowed_types)}" + ) + + try: + # Read image + contents = await file.read() + image = Image.open(io.BytesIO(contents)) + + # Preprocess and extract text + processed_image = preprocess_image(image) + result = ocr_engine.extract_text(processed_image) + + return JSONResponse(content={ + "success": True, + "filename": file.filename, + "text": result["text"], + "confidence": result.get("confidence", None) + }) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"OCR processing failed: {str(e)}") + + +@router.post("/ocr/batch") +async def extract_text_batch(files: list[UploadFile] = File(...)): + """ + Extract text from multiple images. + """ + results = [] + + for file in files: + try: + contents = await file.read() + image = Image.open(io.BytesIO(contents)) + processed_image = preprocess_image(image) + result = ocr_engine.extract_text(processed_image) + + results.append({ + "filename": file.filename, + "success": True, + "text": result["text"], + "confidence": result.get("confidence", None) + }) + except Exception as e: + results.append({ + "filename": file.filename, + "success": False, + "error": str(e) + }) + + return JSONResponse(content={"results": results}) diff --git a/services/ocr-service/app/config/__init__.py b/services/ocr-service/app/config/__init__.py new file mode 100644 index 0000000..a38cc87 --- /dev/null +++ b/services/ocr-service/app/config/__init__.py @@ -0,0 +1 @@ +# Config module diff --git a/services/ocr-service/app/config/settings.py b/services/ocr-service/app/config/settings.py index 74e0e55..6ee1e2a 100644 --- a/services/ocr-service/app/config/settings.py +++ b/services/ocr-service/app/config/settings.py @@ -1 +1,27 @@ # Settings and configuration for OCR service +import os +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + # App settings + app_name: str = "Kixi OCR Service" + app_version: str = "1.0.0" + debug: bool = os.getenv("DEBUG", "false").lower() == "true" + + # OCR settings + ocr_language: str = os.getenv("OCR_LANGUAGE", "por+eng") + max_file_size_mb: int = int(os.getenv("MAX_FILE_SIZE_MB", "10")) + + # Server settings + host: str = os.getenv("HOST", "0.0.0.0") + port: int = int(os.getenv("PORT", "8000")) + + class Config: + env_file = ".env" + case_sensitive = False + + +settings = Settings() diff --git a/services/ocr-service/app/main.py b/services/ocr-service/app/main.py index f2bcd9f..f1eb2e9 100644 --- a/services/ocr-service/app/main.py +++ b/services/ocr-service/app/main.py @@ -1 +1,35 @@ # Entry point for OCR service +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.routes import router + +app = FastAPI( + title="Kixi OCR Service", + description="Serviço de OCR para extração de texto de imagens", + version="1.0.0" +) + +# CORS configuration +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routes +app.include_router(router, prefix="/api/v1") + + +@app.get("/health") +async def health_check(): + """Health check endpoint""" + return {"status": "healthy", "service": "kixi-ocr"} + + +@app.get("/") +async def root(): + """Root endpoint""" + return {"message": "Kixi OCR Service", "version": "1.0.0"} diff --git a/services/ocr-service/app/ocr/__init__.py b/services/ocr-service/app/ocr/__init__.py new file mode 100644 index 0000000..531b055 --- /dev/null +++ b/services/ocr-service/app/ocr/__init__.py @@ -0,0 +1 @@ +# OCR module diff --git a/services/ocr-service/app/ocr/engine.py b/services/ocr-service/app/ocr/engine.py index 8f2d042..981ffcb 100644 --- a/services/ocr-service/app/ocr/engine.py +++ b/services/ocr-service/app/ocr/engine.py @@ -1 +1,94 @@ # PaddleOCR-VL engine implementation +import pytesseract +from PIL import Image +from typing import Optional + + +class OCREngine: + """ + OCR Engine using Tesseract for text extraction. + """ + + def __init__(self, lang: str = "por+eng"): + """ + Initialize OCR Engine. + + Args: + lang: Language(s) to use for OCR. Default is Portuguese + English. + """ + self.lang = lang + self.config = "--oem 3 --psm 6" # LSTM engine, uniform text block + + def extract_text(self, image: Image.Image) -> dict: + """ + Extract text from a PIL Image. + + Args: + image: PIL Image object + + Returns: + Dictionary with extracted text and confidence + """ + try: + # Get text with confidence data + data = pytesseract.image_to_data( + image, + lang=self.lang, + config=self.config, + output_type=pytesseract.Output.DICT + ) + + # Calculate average confidence + confidences = [int(c) for c in data['conf'] if int(c) > 0] + avg_confidence = sum(confidences) / len(confidences) if confidences else 0 + + # Get full text + text = pytesseract.image_to_string( + image, + lang=self.lang, + config=self.config + ) + + return { + "text": text.strip(), + "confidence": round(avg_confidence, 2), + "word_count": len([w for w in data['text'] if w.strip()]) + } + + except Exception as e: + raise RuntimeError(f"OCR extraction failed: {str(e)}") + + def extract_text_with_boxes(self, image: Image.Image) -> dict: + """ + Extract text with bounding box coordinates. + + Args: + image: PIL Image object + + Returns: + Dictionary with text blocks and their coordinates + """ + try: + data = pytesseract.image_to_data( + image, + lang=self.lang, + config=self.config, + output_type=pytesseract.Output.DICT + ) + + blocks = [] + for i, text in enumerate(data['text']): + if text.strip(): + blocks.append({ + "text": text, + "x": data['left'][i], + "y": data['top'][i], + "width": data['width'][i], + "height": data['height'][i], + "confidence": data['conf'][i] + }) + + return {"blocks": blocks} + + except Exception as e: + raise RuntimeError(f"OCR extraction with boxes failed: {str(e)}") diff --git a/services/ocr-service/app/ocr/preprocessing.py b/services/ocr-service/app/ocr/preprocessing.py index ac5e6be..740d647 100644 --- a/services/ocr-service/app/ocr/preprocessing.py +++ b/services/ocr-service/app/ocr/preprocessing.py @@ -1 +1,125 @@ # Image preprocessing for better OCR +import cv2 +import numpy as np +from PIL import Image + + +def preprocess_image(image: Image.Image) -> Image.Image: + """ + Preprocess image for better OCR results. + + Steps: + 1. Convert to grayscale + 2. Apply adaptive thresholding + 3. Denoise + 4. Deskew if needed + + Args: + image: PIL Image object + + Returns: + Preprocessed PIL Image + """ + # Convert PIL to OpenCV format + img_array = np.array(image) + + # Convert to grayscale if needed + if len(img_array.shape) == 3: + gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY) + else: + gray = img_array + + # Apply denoising + denoised = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21) + + # Apply adaptive thresholding + thresh = cv2.adaptiveThreshold( + denoised, + 255, + cv2.ADAPTIVE_THRESH_GAUSSIAN_C, + cv2.THRESH_BINARY, + 11, + 2 + ) + + # Convert back to PIL + return Image.fromarray(thresh) + + +def deskew_image(image: Image.Image) -> Image.Image: + """ + Correct image skew/rotation. + + Args: + image: PIL Image object + + Returns: + Deskewed PIL Image + """ + img_array = np.array(image) + + if len(img_array.shape) == 3: + gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY) + else: + gray = img_array + + # Find edges + edges = cv2.Canny(gray, 50, 150, apertureSize=3) + + # Find lines using Hough transform + lines = cv2.HoughLinesP( + edges, 1, np.pi / 180, 100, + minLineLength=100, maxLineGap=10 + ) + + if lines is None: + return image + + # Calculate average angle + angles = [] + for line in lines: + x1, y1, x2, y2 = line[0] + angle = np.arctan2(y2 - y1, x2 - x1) * 180 / np.pi + if abs(angle) < 45: # Filter out vertical lines + angles.append(angle) + + if not angles: + return image + + avg_angle = np.median(angles) + + # Rotate image + (h, w) = img_array.shape[:2] + center = (w // 2, h // 2) + M = cv2.getRotationMatrix2D(center, avg_angle, 1.0) + rotated = cv2.warpAffine( + img_array, M, (w, h), + flags=cv2.INTER_CUBIC, + borderMode=cv2.BORDER_REPLICATE + ) + + return Image.fromarray(rotated) + + +def enhance_contrast(image: Image.Image) -> Image.Image: + """ + Enhance image contrast using CLAHE. + + Args: + image: PIL Image object + + Returns: + Contrast-enhanced PIL Image + """ + img_array = np.array(image) + + if len(img_array.shape) == 3: + gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY) + else: + gray = img_array + + # Apply CLAHE (Contrast Limited Adaptive Histogram Equalization) + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + enhanced = clahe.apply(gray) + + return Image.fromarray(enhanced) diff --git a/services/ocr-service/requirements.txt b/services/ocr-service/requirements.txt index 0164c92..162aa5b 100644 --- a/services/ocr-service/requirements.txt +++ b/services/ocr-service/requirements.txt @@ -5,3 +5,4 @@ pytesseract>=0.3.10 Pillow>=10.0.0 opencv-python-headless>=4.8.0 numpy>=1.24.0 +pydantic-settings>=2.0.0 From fa1f583ac1afc30128bd024545f87f2c61866a19 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Wed, 4 Feb 2026 12:20:36 +0100 Subject: [PATCH 02/48] fix: fixin' the reviewed ones --- .../creativemode/kixi/controller/StatementController.java | 4 ++-- .../dto/{schoolyears => statement}/StatementRequest.java | 2 +- .../dto/{schoolyears => statement}/StatementResponse.java | 8 ++------ .../ao/creativemode/kixi/service/StatementService.java | 4 ++-- 4 files changed, 7 insertions(+), 11 deletions(-) rename services/backend-api/src/main/java/ao/creativemode/kixi/dto/{schoolyears => statement}/StatementRequest.java (98%) rename services/backend-api/src/main/java/ao/creativemode/kixi/dto/{schoolyears => statement}/StatementResponse.java (96%) diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/StatementController.java b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/StatementController.java index bfaba01..597ba88 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/StatementController.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/StatementController.java @@ -1,7 +1,7 @@ package ao.creativemode.kixi.controller; -import ao.creativemode.kixi.dto.schoolyears.StatementRequest; -import ao.creativemode.kixi.dto.schoolyears.StatementResponse; +import ao.creativemode.kixi.dto.statement.StatementRequest; +import ao.creativemode.kixi.dto.statement.StatementResponse; import ao.creativemode.kixi.service.StatementService; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/schoolyears/StatementRequest.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementRequest.java similarity index 98% rename from services/backend-api/src/main/java/ao/creativemode/kixi/dto/schoolyears/StatementRequest.java rename to services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementRequest.java index 28dcf1d..23bda06 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/schoolyears/StatementRequest.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementRequest.java @@ -1,4 +1,4 @@ -package ao.creativemode.kixi.dto.schoolyears; +package ao.creativemode.kixi.dto.statement; import jakarta.validation.constraints.*; diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/schoolyears/StatementResponse.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementResponse.java similarity index 96% rename from services/backend-api/src/main/java/ao/creativemode/kixi/dto/schoolyears/StatementResponse.java rename to services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementResponse.java index af3fa0e..30503b3 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/schoolyears/StatementResponse.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementResponse.java @@ -1,4 +1,4 @@ -package ao.creativemode.kixi.dto.schoolyears; +package ao.creativemode.kixi.dto.statement; import java.time.LocalDateTime; @@ -20,11 +20,7 @@ public class StatementResponse { private LocalDateTime createdAt; private LocalDateTime updatedAt; - - public StatementResponse() { - - } - + public StatementResponse() {} public Long getId() { return id; } public void setId(Long id) { this.id = id; } diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/StatementService.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/StatementService.java index a3f033f..a6fdb82 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/service/StatementService.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/StatementService.java @@ -1,10 +1,10 @@ package ao.creativemode.kixi.service; import ao.creativemode.kixi.common.exception.ApiException; -import ao.creativemode.kixi.dto.schoolyears.StatementRequest; +import ao.creativemode.kixi.dto.statement.StatementRequest; +import ao.creativemode.kixi.dto.statement.StatementResponse; import ao.creativemode.kixi.model.Statement; import ao.creativemode.kixi.repository.StatementRepository; -import ao.creativemode.kixi.dto.schoolyears.StatementResponse; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; From 0274547fea5c3d2a3205144a3c7ed176f262eb36 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Wed, 4 Feb 2026 13:30:21 +0100 Subject: [PATCH 03/48] refactor(statement): convert StatementRequest and StatementResponse to records - StatementRequest: converted from class to record with Jakarta validations - StatementResponse: converted to record with nested response objects - Follows SchoolYear DTO pattern --- .../kixi/dto/statement/StatementRequest.java | 91 +++++-------------- .../kixi/dto/statement/StatementResponse.java | 90 +++++------------- 2 files changed, 50 insertions(+), 131 deletions(-) diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementRequest.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementRequest.java index 23bda06..cfbdbbe 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementRequest.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementRequest.java @@ -2,80 +2,39 @@ import jakarta.validation.constraints.*; -public class StatementRequest { +public record StatementRequest( + @NotBlank(message = "O título é obrigatório") + @Size(min = 3, max = 255, message = "O título deve ter entre 3 e 255 caracteres") + String title, - @NotBlank(message = "O título é obrigatório") - @Size(min = 3, max = 255, message = "O título deve ter entre 3 e 255 caracteres") - private String title; + @NotBlank(message = "O tipo de exame é obrigatório") + String examType, - @NotBlank(message = "O tipo de exame é obrigatório") - private String examType; + @Positive(message = "A duração deve ser maior que zero") + Integer durationMinutes, - @Positive(message = "A duração deve ser maior que zero") - private Integer durationMinutes; + @Size(max = 50, message = "A variante deve ter no máximo 50 caracteres") + String variant, - @Size(max = 50, message = "A variante deve ter no máximo 50 caracteres") - private String variant; + @Size(max = 5000, message = "As instruções devem ter no máximo 5000 caracteres") + String instructions, - @Size(max = 5000, message = "As instruções devem ter no máximo 5000 caracteres") - private String instructions; + @PositiveOrZero(message = "A pontuação máxima não pode ser negativa") + Integer totalMaxScore, - @PositiveOrZero(message = "A pontuação máxima não pode ser negativa") - private Integer totalMaxScore; + @NotNull(message = "O ano letivo é obrigatório") + Long schoolYearId, - @NotNull(message = "O ano letivo é obrigatório") - private Long schoolYearId; + @NotNull(message = "O trimestre é obrigatório") + Long termId, - @NotNull(message = "O trimestre é obrigatório") - private Long termId; + @NotNull(message = "A disciplina é obrigatória") + Long subjectId, - @NotNull(message = "A disciplina é obrigatória") - private Long subjectId; + @NotNull(message = "A turma é obrigatória") + Long classId, - @NotNull(message = "A turma é obrigatória") - private Long classId; + Long courseId, - private Long courseId; - - private Boolean visible; - - public StatementRequest() {} - - // Getters e Setters - - public String getTitle() { return title; } - public void setTitle(String title) { this.title = title; } - - public String getExamType() { return examType; } - public void setExamType(String examType) { this.examType = examType; } - - public Integer getDurationMinutes() { return durationMinutes; } - public void setDurationMinutes(Integer durationMinutes) { this.durationMinutes = durationMinutes; } - - public String getVariant() { return variant; } - public void setVariant(String variant) { this.variant = variant; } - - public String getInstructions() { return instructions; } - public void setInstructions(String instructions) { this.instructions = instructions; } - - public Integer getTotalMaxScore() { return totalMaxScore; } - public void setTotalMaxScore(Integer totalMaxScore) { this.totalMaxScore = totalMaxScore; } - - public Long getSchoolYearId() { return schoolYearId; } - public void setSchoolYearId(Long schoolYearId) { this.schoolYearId = schoolYearId; } - - public Long getTermId() { return termId; } - public void setTermId(Long termId) { this.termId = termId; } - - public Long getSubjectId() { return subjectId; } - public void setSubjectId(Long subjectId) { this.subjectId = subjectId; } - - public Long getClassId() { return classId; } - public void setClassId(Long classId) { this.classId = classId; } - - public Long getCourseId() { return courseId; } - public void setCourseId(Long courseId) { this.courseId = courseId; } - - public Boolean getVisible() { return visible; } - public void setVisible(Boolean visible) { this.visible = visible; } -} + Boolean visible +) {} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementResponse.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementResponse.java index 30503b3..6892cec 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementResponse.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementResponse.java @@ -1,69 +1,29 @@ package ao.creativemode.kixi.dto.statement; -import java.time.LocalDateTime; - -public class StatementResponse { - - private Long id; - private String examType; - private Integer durationMinutes; - private String variant; - private String title; - private String instructions; - private Integer totalMaxScore; - private Long schoolYearId; - private Long termId; - private Long subjectId; - private Long classId; - private Long courseId; - private Boolean visible; - private LocalDateTime createdAt; - private LocalDateTime updatedAt; - - public StatementResponse() {} - - public Long getId() { return id; } - public void setId(Long id) { this.id = id; } - - public String getExamType() { return examType; } - public void setExamType(String examType) { this.examType = examType; } - - public Integer getDurationMinutes() { return durationMinutes; } - public void setDurationMinutes(Integer durationMinutes) { this.durationMinutes = durationMinutes; } - - public String getVariant() { return variant; } - public void setVariant(String variant) { this.variant = variant; } - - public String getTitle() { return title; } - public void setTitle(String title) { this.title = title; } +import ao.creativemode.kixi.dto.accounts.AccountBasicResponse; +import ao.creativemode.kixi.dto.classe.ClassResponse; +import ao.creativemode.kixi.dto.courses.CourseResponse; +import ao.creativemode.kixi.dto.schoolyears.SchoolYearResponse; +import ao.creativemode.kixi.dto.subject.SubjectResponse; +import ao.creativemode.kixi.dto.term.TermResponse; - public String getInstructions() { return instructions; } - public void setInstructions(String instructions) { this.instructions = instructions; } - - public Integer getTotalMaxScore() { return totalMaxScore; } - public void setTotalMaxScore(Integer totalMaxScore) { this.totalMaxScore = totalMaxScore; } - - public Long getSchoolYearId() { return schoolYearId; } - public void setSchoolYearId(Long schoolYearId) { this.schoolYearId = schoolYearId; } - - public Long getTermId() { return termId; } - public void setTermId(Long termId) { this.termId = termId; } - - public Long getSubjectId() { return subjectId; } - public void setSubjectId(Long subjectId) { this.subjectId = subjectId; } - - public Long getClassId() { return classId; } - public void setClassId(Long classId) { this.classId = classId; } - - public Long getCourseId() { return courseId; } - public void setCourseId(Long courseId) { this.courseId = courseId; } - - public Boolean getVisible() { return visible; } - public void setVisible(Boolean visible) { this.visible = visible; } - - public LocalDateTime getCreatedAt() { return createdAt; } - public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +import java.time.LocalDateTime; - public LocalDateTime getUpdatedAt() { return updatedAt; } - public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } -} +public record StatementResponse( + Long id, + String examType, + Integer durationMinutes, + String variant, + String title, + String instructions, + Integer totalMaxScore, + SchoolYearResponse schoolYear, + TermResponse term, + SubjectResponse subject, + ClassResponse classInfo, + CourseResponse course, + AccountBasicResponse createdBy, + Boolean visible, + LocalDateTime createdAt, + LocalDateTime updatedAt +) {} From cd9934106c18cf0ab510062840948828264f42f8 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Wed, 4 Feb 2026 13:31:55 +0100 Subject: [PATCH 04/48] refactor(statement): update StatementService for records and nested responses - Update accessor methods from getters to record accessors - Add toResponse with nested SchoolYear, Term, Subject, Class, Course, Account objects - Fix Mono.zip() compatibility with switchIfEmpty for non-null values - Add repository dependencies for related entities --- .../kixi/service/StatementService.java | 249 +++++++++++++----- 1 file changed, 177 insertions(+), 72 deletions(-) diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/StatementService.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/StatementService.java index a6fdb82..d03ce1d 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/service/StatementService.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/StatementService.java @@ -1,39 +1,66 @@ package ao.creativemode.kixi.service; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; + import ao.creativemode.kixi.common.exception.ApiException; +import ao.creativemode.kixi.dto.accounts.AccountBasicResponse; +import ao.creativemode.kixi.dto.classe.ClassResponse; +import ao.creativemode.kixi.dto.courses.CourseResponse; +import ao.creativemode.kixi.dto.schoolyears.SchoolYearResponse; import ao.creativemode.kixi.dto.statement.StatementRequest; import ao.creativemode.kixi.dto.statement.StatementResponse; -import ao.creativemode.kixi.model.Statement; -import ao.creativemode.kixi.repository.StatementRepository; -import org.springframework.stereotype.Service; +import ao.creativemode.kixi.dto.subject.SubjectResponse; +import ao.creativemode.kixi.dto.term.TermResponse; +import ao.creativemode.kixi.model.*; +import ao.creativemode.kixi.model.Class; +import ao.creativemode.kixi.repository.*; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; - @Service public class StatementService { private final StatementRepository repository; - - public StatementService(StatementRepository repository) { + private final SchoolYearRepository schoolYearRepository; + private final TermRepository termRepository; + private final SubjectRepository subjectRepository; + private final ClassRepository classRepository; + private final CourseRepository courseRepository; + private final AccountRepository accountRepository; + + public StatementService( + StatementRepository repository, + SchoolYearRepository schoolYearRepository, + TermRepository termRepository, + SubjectRepository subjectRepository, + ClassRepository classRepository, + CourseRepository courseRepository, + AccountRepository accountRepository + ) { this.repository = repository; + this.schoolYearRepository = schoolYearRepository; + this.termRepository = termRepository; + this.subjectRepository = subjectRepository; + this.classRepository = classRepository; + this.courseRepository = courseRepository; + this.accountRepository = accountRepository; } - public Mono> listAllActive() { + public Flux listAllActive() { return repository.findByDeletedAtIsNull() - .map(this::toResponse) - .collectList() - .onErrorResume(e -> Mono.error( + .flatMap(this::toResponse) + .onErrorResume(e -> Flux.error( ApiException.badRequest("Error listing statements: " + e.getMessage()) )); } - public Mono> listTrashed() { + public Flux listTrashed() { return repository.findByDeletedAtIsNotNull() - .map(this::toResponse) - .collectList() - .onErrorResume(e -> Mono.error( + .flatMap(this::toResponse) + .onErrorResume(e -> Flux.error( ApiException.badRequest("Error listing deleted statements: " + e.getMessage()) )); } @@ -47,7 +74,7 @@ public Mono getById(Long id) { .switchIfEmpty(Mono.error( ApiException.notFound("Statement with ID " + id + " not found") )) - .map(this::toResponse); + .flatMap(this::toResponse); } public Mono update(Long id, StatementRequest request) { @@ -61,22 +88,22 @@ public Mono update(Long id, StatementRequest request) { ApiException.notFound("Statement with ID " + id + " not found for update") )) .flatMap(statement -> { - statement.setTitle(request.getTitle()); - statement.setExamType(request.getExamType()); - statement.setDurationMinutes(request.getDurationMinutes()); - statement.setVariant(request.getVariant()); - statement.setInstructions(request.getInstructions()); - statement.setTotalMaxScore(request.getTotalMaxScore()); - statement.setSchoolYearId(request.getSchoolYearId()); - statement.setTermId(request.getTermId()); - statement.setSubjectId(request.getSubjectId()); - statement.setClassId(request.getClassId()); - statement.setCourseId(request.getCourseId()); - statement.setVisible(request.getVisible()); + statement.setTitle(request.title()); + statement.setExamType(request.examType()); + statement.setDurationMinutes(request.durationMinutes()); + statement.setVariant(request.variant()); + statement.setInstructions(request.instructions()); + statement.setTotalMaxScore(request.totalMaxScore()); + statement.setSchoolYearId(request.schoolYearId()); + statement.setTermId(request.termId()); + statement.setSubjectId(request.subjectId()); + statement.setClassId(request.classId()); + statement.setCourseId(request.courseId()); + statement.setVisible(request.visible()); statement.setUpdatedAt(LocalDateTime.now()); return repository.save(statement); }) - .map(this::toResponse) + .flatMap(this::toResponse) .onErrorResume(ApiException.class, Mono::error) .onErrorResume(e -> Mono.error( ApiException.badRequest("Error updating statement: " + e.getMessage()) @@ -147,23 +174,23 @@ public Mono create(StatementRequest request) { return validateRequest(request) .then(Mono.defer(() -> { Statement statement = new Statement(); - statement.setTitle(request.getTitle()); - statement.setExamType(request.getExamType()); - statement.setDurationMinutes(request.getDurationMinutes()); - statement.setVariant(request.getVariant()); - statement.setInstructions(request.getInstructions()); - statement.setTotalMaxScore(request.getTotalMaxScore()); - statement.setSchoolYearId(request.getSchoolYearId()); - statement.setTermId(request.getTermId()); - statement.setSubjectId(request.getSubjectId()); - statement.setClassId(request.getClassId()); - statement.setCourseId(request.getCourseId()); - statement.setVisible(request.getVisible() != null ? request.getVisible() : false); + statement.setTitle(request.title()); + statement.setExamType(request.examType()); + statement.setDurationMinutes(request.durationMinutes()); + statement.setVariant(request.variant()); + statement.setInstructions(request.instructions()); + statement.setTotalMaxScore(request.totalMaxScore()); + statement.setSchoolYearId(request.schoolYearId()); + statement.setTermId(request.termId()); + statement.setSubjectId(request.subjectId()); + statement.setClassId(request.classId()); + statement.setCourseId(request.courseId()); + statement.setVisible(request.visible() != null ? request.visible() : false); statement.setCreatedAt(LocalDateTime.now()); return repository.save(statement); })) - .map(this::toResponse) + .flatMap(this::toResponse) .onErrorResume(ApiException.class, Mono::error) .onErrorResume(e -> Mono.error( ApiException.badRequest("Error creating statement: " + e.getMessage()) @@ -177,39 +204,39 @@ private Mono validateRequest(StatementRequest request) { return Mono.error(ApiException.badRequest("Statement data is required")); } - if (request.getTitle() == null || request.getTitle().isBlank()) { + if (request.title() == null || request.title().isBlank()) { errors.add("Title is required"); - } else if (request.getTitle().length() < 3) { + } else if (request.title().length() < 3) { errors.add("Title must have at least 3 characters"); - } else if (request.getTitle().length() > 255) { + } else if (request.title().length() > 255) { errors.add("Title must have at most 255 characters"); } - if (request.getExamType() == null || request.getExamType().isBlank()) { + if (request.examType() == null || request.examType().isBlank()) { errors.add("Exam type is required"); } - if (request.getDurationMinutes() != null && request.getDurationMinutes() <= 0) { + if (request.durationMinutes() != null && request.durationMinutes() <= 0) { errors.add("Duration must be greater than zero"); } - if (request.getTotalMaxScore() != null && request.getTotalMaxScore() < 0) { + if (request.totalMaxScore() != null && request.totalMaxScore() < 0) { errors.add("Maximum score cannot be negative"); } - if (request.getSchoolYearId() == null) { + if (request.schoolYearId() == null) { errors.add("School year is required"); } - if (request.getTermId() == null) { + if (request.termId() == null) { errors.add("Term is required"); } - if (request.getSubjectId() == null) { + if (request.subjectId() == null) { errors.add("Subject is required"); } - if (request.getClassId() == null) { + if (request.classId() == null) { errors.add("Class is required"); } @@ -221,25 +248,103 @@ private Mono validateRequest(StatementRequest request) { return Mono.empty(); } + private Mono toResponse(Statement statement) { + Mono schoolYearMono = statement.getSchoolYearId() != null + ? schoolYearRepository.findById(statement.getSchoolYearId()) + .map(this::toSchoolYearResponse) + .switchIfEmpty(Mono.just(new SchoolYearResponse(null, null, null, null, null, null))) + : Mono.just(new SchoolYearResponse(null, null, null, null, null, null)); + + Mono termMono = statement.getTermId() != null + ? termRepository.findById(statement.getTermId()) + .map(this::toTermResponse) + .switchIfEmpty(Mono.just(new TermResponse(null, 0, null, null, null, null))) + : Mono.just(new TermResponse(null, 0, null, null, null, null)); + + Mono subjectMono = statement.getSubjectId() != null + ? subjectRepository.findById(statement.getSubjectId()) + .map(this::toSubjectResponse) + .switchIfEmpty(Mono.just(new SubjectResponse(null, null, null, null, null, null, null))) + : Mono.just(new SubjectResponse(null, null, null, null, null, null, null)); + + Mono classMono = statement.getClassId() != null + ? classRepository.findById(statement.getClassId()) + .map(this::toClassResponse) + .switchIfEmpty(Mono.just(new ClassResponse(null, null, null, null, null, null, null, null))) + : Mono.just(new ClassResponse(null, null, null, null, null, null, null, null)); + + Mono courseMono = statement.getCourseId() != null + ? courseRepository.findById(statement.getCourseId()) + .map(this::toCourseResponse) + .switchIfEmpty(Mono.just(new CourseResponse(null, null, null, null, null, null, null))) + : Mono.just(new CourseResponse(null, null, null, null, null, null, null)); + + Mono createdByMono = statement.getCreatedBy() != null + ? accountRepository.findById(statement.getCreatedBy()) + .map(this::toAccountResponse) + .switchIfEmpty(Mono.just(new AccountBasicResponse(null, null, null))) + : Mono.just(new AccountBasicResponse(null, null, null)); + + return Mono.zip(schoolYearMono, termMono, subjectMono, classMono, courseMono, createdByMono) + .map(tuple -> new StatementResponse( + statement.getId(), + statement.getExamType(), + statement.getDurationMinutes(), + statement.getVariant(), + statement.getTitle(), + statement.getInstructions(), + statement.getTotalMaxScore(), + tuple.getT1(), + tuple.getT2(), + tuple.getT3(), + tuple.getT4(), + tuple.getT5(), + tuple.getT6(), + statement.getVisible(), + statement.getCreatedAt(), + statement.getUpdatedAt() + )); + } + + private SchoolYearResponse toSchoolYearResponse(SchoolYear sy) { + return new SchoolYearResponse( + sy.getId(), sy.getStartYear(), sy.getEndYear(), + sy.getCreatedAt(), sy.getUpdatedAt(), sy.getDeletedAt() + ); + } + + private TermResponse toTermResponse(Term term) { + return new TermResponse( + term.getId(), term.getNumber(), term.getName(), + term.getCreatedAt(), term.getUpdatedAt(), term.getDeletedAt() + ); + } + + private SubjectResponse toSubjectResponse(Subject subject) { + return new SubjectResponse( + subject.getId(), subject.getCode(), subject.getName(), subject.getShortName(), + subject.getCreatedAt(), subject.getUpdatedAt(), subject.getDeletedAt() + ); + } + private ClassResponse toClassResponse(Class clazz) { + return new ClassResponse( + clazz.getId(), clazz.getCode(), clazz.getGrade(), + null, null, // course e schoolYear serão null aqui para evitar recursão + clazz.getCreatedAt(), clazz.getUpdatedAt(), clazz.getDeletedAt() + ); + } + + private CourseResponse toCourseResponse(Course course) { + return new CourseResponse( + course.getId(), course.getCode(), course.getName(), course.getDescription(), + course.getCreatedAt(), course.getUpdatedAt(), course.getDeletedAt() + ); + } - private StatementResponse toResponse(Statement statement) { - StatementResponse response = new StatementResponse(); - response.setId(statement.getId()); - response.setExamType(statement.getExamType()); - response.setDurationMinutes(statement.getDurationMinutes()); - response.setVariant(statement.getVariant()); - response.setTitle(statement.getTitle()); - response.setInstructions(statement.getInstructions()); - response.setTotalMaxScore(statement.getTotalMaxScore()); - response.setSchoolYearId(statement.getSchoolYearId()); - response.setTermId(statement.getTermId()); - response.setSubjectId(statement.getSubjectId()); - response.setClassId(statement.getClassId()); - response.setCourseId(statement.getCourseId()); - response.setVisible(statement.getVisible()); - response.setCreatedAt(statement.getCreatedAt()); - response.setUpdatedAt(statement.getUpdatedAt()); - return response; + private AccountBasicResponse toAccountResponse(Account account) { + return new AccountBasicResponse( + account.getId(), account.getUsername(), account.getEmail() + ); } } From dd454c6966a110e6b42885406b6f7718c40f5b03 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Wed, 4 Feb 2026 13:32:02 +0100 Subject: [PATCH 05/48] fix(statement): update StatementController path and return types - Fix path from /statements to /api/statements - Add .collectList() for Flux to List conversion --- .../kixi/controller/StatementController.java | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/StatementController.java b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/StatementController.java index 597ba88..598b0e7 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/StatementController.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/StatementController.java @@ -1,19 +1,28 @@ package ao.creativemode.kixi.controller; +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + import ao.creativemode.kixi.dto.statement.StatementRequest; import ao.creativemode.kixi.dto.statement.StatementResponse; import ao.creativemode.kixi.service.StatementService; import jakarta.validation.Valid; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; import reactor.core.publisher.Mono; -import java.util.List; - @RestController -@RequestMapping("/statements") +@RequestMapping("/api/statements") public class StatementController { private final StatementService service; @@ -26,12 +35,14 @@ public StatementController(StatementService service) { @GetMapping public Mono>> listAllActive() { return service.listAllActive() + .collectList() .map(ResponseEntity::ok); } @GetMapping("/trashed") public Mono>> listTrashed() { return service.listTrashed() + .collectList() .map(ResponseEntity::ok); } From c6e42c400116bf1c73378283fc33084cec028092 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Wed, 4 Feb 2026 13:32:07 +0100 Subject: [PATCH 06/48] feat(statement): add foreign key constraints to statement table - Add FK constraints for school_year, term, subject, class, course, created_by --- .../db/migration/V2__create_statement_table.sql | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/services/backend-api/src/main/resources/db/migration/V2__create_statement_table.sql b/services/backend-api/src/main/resources/db/migration/V2__create_statement_table.sql index 5b93a29..1bf2bfd 100644 --- a/services/backend-api/src/main/resources/db/migration/V2__create_statement_table.sql +++ b/services/backend-api/src/main/resources/db/migration/V2__create_statement_table.sql @@ -15,7 +15,14 @@ CREATE TABLE statement ( visible BOOLEAN DEFAULT false, create_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, update_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, - delete_at TIMESTAMP WITH TIME ZONE DEFAULT NULL + delete_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, + + CONSTRAINT fk_statement_school_year FOREIGN KEY (school_year_id) REFERENCES school_years(id), + CONSTRAINT fk_statement_term FOREIGN KEY (term_id) REFERENCES terms(id), + CONSTRAINT fk_statement_subject FOREIGN KEY (subject_id) REFERENCES subjects(id), + CONSTRAINT fk_statement_class FOREIGN KEY (class_id) REFERENCES classes(id), + CONSTRAINT fk_statement_course FOREIGN KEY (course_id) REFERENCES courses(id), + CONSTRAINT fk_statement_created_by FOREIGN KEY (create_by) REFERENCES accounts(id) ); CREATE INDEX idx_statement_active ON statement (delete_at) WHERE delete_at IS NULL; From 905b2deabc473789a723fb33c9fb10811dfb1252 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Wed, 4 Feb 2026 13:32:14 +0100 Subject: [PATCH 07/48] feat(simulation): add Simulation entity and SimulationStatus enum - Simulation model with R2DBC annotations - SimulationStatus enum: IN_PROGRESS, FINISHED, CANCELLED - Soft delete support with markAsDelete() and restore() --- .../creativemode/kixi/model/Simulation.java | 155 ++++++++++++++++++ .../kixi/model/SimulationStatus.java | 7 + 2 files changed, 162 insertions(+) create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/model/Simulation.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/model/SimulationStatus.java diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/model/Simulation.java b/services/backend-api/src/main/java/ao/creativemode/kixi/model/Simulation.java new file mode 100644 index 0000000..050f6b3 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/model/Simulation.java @@ -0,0 +1,155 @@ +package ao.creativemode.kixi.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +import java.time.LocalDateTime; + +@Table("simulation") +public class Simulation { + + @Id + private Long id; + + @Column("account_id") + private Long accountId; + + @Column("statement_id") + private Long statementId; + + @Column("school_year_id") + private Long schoolYearId; + + @Column("started_at") + private LocalDateTime startedAt; + + @Column("finished_at") + private LocalDateTime finishedAt; + + @Column("time_spent_seconds") + private Integer timeSpentSeconds; + + @Column("final_score") + private Double finalScore; + + @Column("status") + private SimulationStatus status; + + @Column("created_at") + private LocalDateTime createdAt; + + @Column("updated_at") + private LocalDateTime updatedAt; + + @Column("deleted_at") + private LocalDateTime deletedAt; + + public Simulation() { + this.createdAt = LocalDateTime.now(); + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getAccountId() { + return accountId; + } + + public void setAccountId(Long accountId) { + this.accountId = accountId; + } + + public Long getStatementId() { + return statementId; + } + + public void setStatementId(Long statementId) { + this.statementId = statementId; + } + + public Long getSchoolYearId() { + return schoolYearId; + } + + public void setSchoolYearId(Long schoolYearId) { + this.schoolYearId = schoolYearId; + } + + public LocalDateTime getStartedAt() { + return startedAt; + } + + public void setStartedAt(LocalDateTime startedAt) { + this.startedAt = startedAt; + } + + public LocalDateTime getFinishedAt() { + return finishedAt; + } + + public void setFinishedAt(LocalDateTime finishedAt) { + this.finishedAt = finishedAt; + } + + public Integer getTimeSpentSeconds() { + return timeSpentSeconds; + } + + public void setTimeSpentSeconds(Integer timeSpentSeconds) { + this.timeSpentSeconds = timeSpentSeconds; + } + + public Double getFinalScore() { + return finalScore; + } + + public void setFinalScore(Double finalScore) { + this.finalScore = finalScore; + } + + public SimulationStatus getStatus() { + return status; + } + + public void setStatus(SimulationStatus status) { + this.status = status; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public LocalDateTime getDeletedAt() { + return deletedAt; + } + + public void setDeletedAt(LocalDateTime deletedAt) { + this.deletedAt = deletedAt; + } + + public void markAsDelete() { + this.deletedAt = LocalDateTime.now(); + } + + public void restore() { + this.deletedAt = null; + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/model/SimulationStatus.java b/services/backend-api/src/main/java/ao/creativemode/kixi/model/SimulationStatus.java new file mode 100644 index 0000000..4926b1b --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/model/SimulationStatus.java @@ -0,0 +1,7 @@ +package ao.creativemode.kixi.model; + +public enum SimulationStatus { + IN_PROGRESS, + FINISHED, + CANCELLED +} From 931ba62e4a941d864cdef0ffb07bd351736e1229 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Wed, 4 Feb 2026 13:32:20 +0100 Subject: [PATCH 08/48] feat(simulation): add Simulation DTOs and StatementBasicResponse - SimulationRequest: record with accountId, statementId, schoolYearId, status - SimulationResponse: record with nested Account, Statement, SchoolYear objects - StatementBasicResponse: simplified statement info for nested use --- .../dto/simulation/SimulationRequest.java | 25 +++++++++++++++++++ .../dto/simulation/SimulationResponse.java | 24 ++++++++++++++++++ .../dto/statement/StatementBasicResponse.java | 11 ++++++++ 3 files changed, 60 insertions(+) create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulation/SimulationRequest.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulation/SimulationResponse.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementBasicResponse.java diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulation/SimulationRequest.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulation/SimulationRequest.java new file mode 100644 index 0000000..a0edc6d --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulation/SimulationRequest.java @@ -0,0 +1,25 @@ +package ao.creativemode.kixi.dto.simulation; + +import ao.creativemode.kixi.model.SimulationStatus; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDateTime; + +public record SimulationRequest( + @NotNull(message = "Account ID is required") + Long accountId, + + Long statementId, + + Long schoolYearId, + + LocalDateTime startedAt, + + LocalDateTime finishedAt, + + Integer timeSpentSeconds, + + Double finalScore, + + SimulationStatus status +) {} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulation/SimulationResponse.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulation/SimulationResponse.java new file mode 100644 index 0000000..5586c36 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulation/SimulationResponse.java @@ -0,0 +1,24 @@ +package ao.creativemode.kixi.dto.simulation; + +import ao.creativemode.kixi.dto.accounts.AccountBasicResponse; +import ao.creativemode.kixi.dto.schoolyears.SchoolYearResponse; +import ao.creativemode.kixi.dto.statement.StatementBasicResponse; +import ao.creativemode.kixi.model.SimulationStatus; + +import java.time.LocalDateTime; + +public record SimulationResponse( + Long id, + AccountBasicResponse account, + StatementBasicResponse statement, + SchoolYearResponse schoolYear, + LocalDateTime startedAt, + LocalDateTime finishedAt, + Integer timeSpentSeconds, + Double finalScore, + SimulationStatus status, + LocalDateTime createdAt, + LocalDateTime updatedAt, + LocalDateTime deletedAt +) { +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementBasicResponse.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementBasicResponse.java new file mode 100644 index 0000000..b9287ee --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementBasicResponse.java @@ -0,0 +1,11 @@ +package ao.creativemode.kixi.dto.statement; + +public record StatementBasicResponse( + Long id, + String examType, + String variant, + String title, + Integer durationMinutes, + Integer totalMaxScore +) { +} From 350bd46b3e3ccfef40cddc0cb24fb212e1be2853 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Wed, 4 Feb 2026 13:32:26 +0100 Subject: [PATCH 09/48] feat(simulation): add SimulationRepository with soft delete queries - findByDeletedAtIsNull for active records - findByDeletedAtIsNotNull for trashed records - findByIdAndDeletedAtIsNull/IsNotNull for single record queries --- .../kixi/repository/SimulationRepository.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/repository/SimulationRepository.java diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SimulationRepository.java b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SimulationRepository.java new file mode 100644 index 0000000..e846949 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SimulationRepository.java @@ -0,0 +1,16 @@ +package ao.creativemode.kixi.repository; + +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import org.springframework.stereotype.Repository; + +import ao.creativemode.kixi.model.Simulation; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Repository +public interface SimulationRepository extends ReactiveCrudRepository { + Flux findByDeletedAtIsNull(); + Flux findByDeletedAtIsNotNull(); + Mono findByIdAndDeletedAtIsNull(Long id); + Mono findByIdAndDeletedAtIsNotNull(Long id); +} From cecc37b4d37db68b238438ee71641b88bbe1ab4a Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Wed, 4 Feb 2026 13:32:31 +0100 Subject: [PATCH 10/48] feat(simulation): add SimulationService with full CRUD operations - Create simulation with account and school year validation - Update with status transitions (IN_PROGRESS -> FINISHED/CANCELLED) - Soft delete, restore, and hard delete support - Nested response mapping with Account, Statement, SchoolYear --- .../kixi/service/SimulationService.java | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/service/SimulationService.java diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/SimulationService.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/SimulationService.java new file mode 100644 index 0000000..0f5c29a --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/SimulationService.java @@ -0,0 +1,198 @@ +package ao.creativemode.kixi.service; + +import java.time.LocalDateTime; + +import org.springframework.stereotype.Service; + +import ao.creativemode.kixi.common.exception.ApiException; +import ao.creativemode.kixi.dto.accounts.AccountBasicResponse; +import ao.creativemode.kixi.dto.schoolyears.SchoolYearResponse; +import ao.creativemode.kixi.dto.simulation.SimulationRequest; +import ao.creativemode.kixi.dto.simulation.SimulationResponse; +import ao.creativemode.kixi.dto.statement.StatementBasicResponse; +import ao.creativemode.kixi.model.*; +import ao.creativemode.kixi.repository.*; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Service +public class SimulationService { + + private final SimulationRepository repository; + private final AccountRepository accountRepository; + private final SchoolYearRepository schoolYearRepository; + private final StatementRepository statementRepository; + + public SimulationService( + SimulationRepository repository, + AccountRepository accountRepository, + SchoolYearRepository schoolYearRepository, + StatementRepository statementRepository + ) { + this.repository = repository; + this.accountRepository = accountRepository; + this.schoolYearRepository = schoolYearRepository; + this.statementRepository = statementRepository; + } + + public Flux findAllActive() { + return repository.findByDeletedAtIsNull() + .flatMap(this::toResponse); + } + + public Flux findAllTrashed() { + return repository.findByDeletedAtIsNotNull() + .flatMap(this::toResponse); + } + + public Mono findById(Long id) { + return repository.findByIdAndDeletedAtIsNull(id) + .flatMap(this::toResponse); + } + + public Mono create(SimulationRequest dto) { + return accountRepository.findById(dto.accountId()) + .switchIfEmpty(Mono.error(ApiException.notFound("Account not found"))) + .then(Mono.defer(() -> { + if (dto.schoolYearId() != null) { + return schoolYearRepository.findById(dto.schoolYearId()) + .switchIfEmpty(Mono.error(ApiException.notFound("SchoolYear not found"))) + .then(Mono.just(true)); + } + return Mono.just(true); + })) + .then(Mono.defer(() -> { + Simulation simulation = new Simulation(); + simulation.setAccountId(dto.accountId()); + simulation.setSchoolYearId(dto.schoolYearId()); + simulation.setStatementId(dto.statementId()); + simulation.setStartedAt(dto.startedAt()); + simulation.setStatus(SimulationStatus.IN_PROGRESS); + return repository.save(simulation); + })) + .flatMap(this::toResponse); + } + + public Mono update(Long id, SimulationRequest dto) { + return repository.findByIdAndDeletedAtIsNull(id) + .switchIfEmpty(Mono.error(ApiException.notFound("Simulation not found!"))) + .flatMap(simulation -> { + if (!SimulationStatus.IN_PROGRESS.equals(simulation.getStatus())) { + return Mono.error(ApiException.badRequest("Simulation cannot be updated")); + } + + if (dto.status() != null && dto.status() != SimulationStatus.FINISHED + && dto.status() != SimulationStatus.CANCELLED) { + return Mono.error(ApiException.badRequest("Invalid status")); + } + + if (dto.status() == SimulationStatus.FINISHED) { + if (dto.finishedAt() == null || dto.timeSpentSeconds() == null) { + return Mono.error(ApiException.badRequest( + "finishedAt and timeSpentSeconds are required" + )); + } + simulation.setFinishedAt(dto.finishedAt()); + simulation.setTimeSpentSeconds(dto.timeSpentSeconds()); + simulation.setFinalScore(dto.finalScore()); + } + + if (dto.status() != null) { + simulation.setStatus(dto.status()); + } + simulation.setUpdatedAt(LocalDateTime.now()); + + return repository.save(simulation); + }) + .flatMap(this::toResponse); + } + + public Mono softDelete(Long id) { + return repository.findByIdAndDeletedAtIsNull(id) + .switchIfEmpty(Mono.error(ApiException.notFound("Simulation not found!"))) + .flatMap(simulation -> { + simulation.markAsDelete(); + return repository.save(simulation); + }) + .then(); + } + + public Mono restore(Long id) { + return repository.findById(id) + .switchIfEmpty(Mono.error(ApiException.notFound("Simulation not found"))) + .flatMap(simulation -> { + if (simulation.getDeletedAt() == null) { + return Mono.error(ApiException.conflict("Simulation is not deleted")); + } + simulation.restore(); + return repository.save(simulation); + }) + .then(); + } + + public Mono hardDelete(Long id) { + return repository.findByIdAndDeletedAtIsNotNull(id) + .switchIfEmpty(Mono.error(new RuntimeException("Simulation not found or not trashed"))) + .flatMap(repository::delete).then(); + } + + private Mono toResponse(Simulation simulation) { + Mono accountMono = accountRepository.findById(simulation.getAccountId()) + .map(this::toAccountResponse) + .switchIfEmpty(Mono.just(new AccountBasicResponse(simulation.getAccountId(), null, null))); + + Mono statementMono = simulation.getStatementId() != null + ? statementRepository.findById(simulation.getStatementId()) + .map(this::toStatementResponse) + .switchIfEmpty(Mono.just(new StatementBasicResponse(null, null, null, null, null, null))) + : Mono.just(new StatementBasicResponse(null, null, null, null, null, null)); + + Mono schoolYearMono = simulation.getSchoolYearId() != null + ? schoolYearRepository.findById(simulation.getSchoolYearId()) + .map(this::toSchoolYearResponse) + .switchIfEmpty(Mono.just(new SchoolYearResponse(null, null, null, null, null, null))) + : Mono.just(new SchoolYearResponse(null, null, null, null, null, null)); + + return Mono.zip(accountMono, statementMono, schoolYearMono) + .map(tuple -> new SimulationResponse( + simulation.getId(), + tuple.getT1(), + tuple.getT2(), + tuple.getT3(), + simulation.getStartedAt(), + simulation.getFinishedAt(), + simulation.getTimeSpentSeconds(), + simulation.getFinalScore(), + simulation.getStatus(), + simulation.getCreatedAt(), + simulation.getUpdatedAt(), + simulation.getDeletedAt() + )); + } + + private AccountBasicResponse toAccountResponse(Account account) { + return new AccountBasicResponse(account.getId(), account.getUsername(), account.getEmail()); + } + + private StatementBasicResponse toStatementResponse(Statement statement) { + return new StatementBasicResponse( + statement.getId(), + statement.getExamType(), + statement.getVariant(), + statement.getTitle(), + statement.getDurationMinutes(), + statement.getTotalMaxScore() + ); + } + + private SchoolYearResponse toSchoolYearResponse(SchoolYear schoolYear) { + return new SchoolYearResponse( + schoolYear.getId(), + schoolYear.getStartYear(), + schoolYear.getEndYear(), + schoolYear.getCreatedAt(), + schoolYear.getUpdatedAt(), + schoolYear.getDeletedAt() + ); + } +} From 48a5f7123c0e51d28ec4305e9476ac7c2ed1829e Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Wed, 4 Feb 2026 13:32:38 +0100 Subject: [PATCH 11/48] feat(simulation): add SimulationController REST endpoints Endpoints: - GET /api/simulations - List all active - GET /api/simulations/trash - List trashed - GET /api/simulations/{id} - Get by ID - POST /api/simulations - Create - PUT /api/simulations/{id} - Update - DELETE /api/simulations/{id} - Soft delete - PUT /api/simulations/{id}/restore - Restore - DELETE /api/simulations/{id}/permanent - Hard delete --- .../kixi/controller/SimulationController.java | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/controller/SimulationController.java diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/SimulationController.java b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/SimulationController.java new file mode 100644 index 0000000..133dc62 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/SimulationController.java @@ -0,0 +1,76 @@ +package ao.creativemode.kixi.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import ao.creativemode.kixi.dto.simulation.SimulationRequest; +import ao.creativemode.kixi.dto.simulation.SimulationResponse; +import ao.creativemode.kixi.service.SimulationService; +import reactor.core.publisher.Mono; + +import java.util.List; + +@RestController +@RequestMapping("/api/simulations") +public class SimulationController { + + private final SimulationService service; + + public SimulationController(SimulationService service) { + this.service = service; + } + + @GetMapping + public Mono>> findAll() { + return service.findAllActive() + .collectList() + .map(ResponseEntity::ok); + } + + @GetMapping("/trash") + public Mono>> findAllTrashed() { + return service.findAllTrashed() + .collectList() + .map(ResponseEntity::ok); + } + + @GetMapping("/{id}") + public Mono> findById(@PathVariable Long id) { + return service.findById(id) + .map(ResponseEntity::ok) + .defaultIfEmpty(ResponseEntity.notFound().build()); + } + + @PostMapping + public Mono> create(@RequestBody SimulationRequest dto) { + return service.create(dto) + .map(response -> ResponseEntity.status(HttpStatus.CREATED).body(response)); + } + + @PutMapping("/{id}") + public Mono> update( + @PathVariable Long id, + @RequestBody SimulationRequest dto) { + return service.update(id, dto) + .map(ResponseEntity::ok); + } + + @DeleteMapping("/{id}") + public Mono> softDelete(@PathVariable Long id) { + return service.softDelete(id) + .then(Mono.just(ResponseEntity.noContent().build())); + } + + @PutMapping("/{id}/restore") + public Mono> restore(@PathVariable Long id) { + return service.restore(id) + .then(Mono.just(ResponseEntity.noContent().build())); + } + + @DeleteMapping("/{id}/permanent") + public Mono> hardDelete(@PathVariable Long id) { + return service.hardDelete(id) + .then(Mono.just(ResponseEntity.noContent().build())); + } +} From c7a870b24b163d02a412bfff044cf5d6c2439dc1 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Wed, 4 Feb 2026 13:48:51 +0100 Subject: [PATCH 12/48] fix(simulation): add @Valid annotation to request body - Add jakarta.validation.Valid import - Add @Valid to create and update endpoints - Follows CRUD pattern for validation --- .../creativemode/kixi/controller/SimulationController.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/SimulationController.java b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/SimulationController.java index 133dc62..7988ba1 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/SimulationController.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/SimulationController.java @@ -5,6 +5,7 @@ import org.springframework.web.bind.annotation.*; import ao.creativemode.kixi.dto.simulation.SimulationRequest; +import jakarta.validation.Valid; import ao.creativemode.kixi.dto.simulation.SimulationResponse; import ao.creativemode.kixi.service.SimulationService; import reactor.core.publisher.Mono; @@ -43,7 +44,7 @@ public Mono> findById(@PathVariable Long id) } @PostMapping - public Mono> create(@RequestBody SimulationRequest dto) { + public Mono> create(@Valid @RequestBody SimulationRequest dto) { return service.create(dto) .map(response -> ResponseEntity.status(HttpStatus.CREATED).body(response)); } @@ -51,7 +52,7 @@ public Mono> create(@RequestBody SimulationRe @PutMapping("/{id}") public Mono> update( @PathVariable Long id, - @RequestBody SimulationRequest dto) { + @Valid @RequestBody SimulationRequest dto) { return service.update(id, dto) .map(ResponseEntity::ok); } From c1442891f3e712b49b7110f26253f12fb4811205 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Wed, 4 Feb 2026 13:48:58 +0100 Subject: [PATCH 13/48] fix(simulation): use ApiException and auto-set startedAt - Replace RuntimeException with ApiException in hardDelete - Set startedAt to current time if not provided in create --- .../java/ao/creativemode/kixi/service/SimulationService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/SimulationService.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/SimulationService.java index 0f5c29a..76880a9 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/service/SimulationService.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/SimulationService.java @@ -66,7 +66,7 @@ public Mono create(SimulationRequest dto) { simulation.setAccountId(dto.accountId()); simulation.setSchoolYearId(dto.schoolYearId()); simulation.setStatementId(dto.statementId()); - simulation.setStartedAt(dto.startedAt()); + simulation.setStartedAt(dto.startedAt() != null ? dto.startedAt() : LocalDateTime.now()); simulation.setStatus(SimulationStatus.IN_PROGRESS); return repository.save(simulation); })) @@ -132,7 +132,7 @@ public Mono restore(Long id) { public Mono hardDelete(Long id) { return repository.findByIdAndDeletedAtIsNotNull(id) - .switchIfEmpty(Mono.error(new RuntimeException("Simulation not found or not trashed"))) + .switchIfEmpty(Mono.error(ApiException.notFound("Simulation not found or not in trash"))) .flatMap(repository::delete).then(); } From 2bf59a956a9f29d14731246e461e5e7dfa6fa0a7 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Wed, 4 Feb 2026 13:49:04 +0100 Subject: [PATCH 14/48] feat(simulation): add database migration for simulation table - Create simulation table with all columns from ERM - Add foreign keys to accounts, statement, school_years - Add CHECK constraint for status values - Add indexes for active records and common queries --- .../migration/V8__create_simulation_table.sql | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 services/backend-api/src/main/resources/db/migration/V8__create_simulation_table.sql diff --git a/services/backend-api/src/main/resources/db/migration/V8__create_simulation_table.sql b/services/backend-api/src/main/resources/db/migration/V8__create_simulation_table.sql new file mode 100644 index 0000000..c6e011e --- /dev/null +++ b/services/backend-api/src/main/resources/db/migration/V8__create_simulation_table.sql @@ -0,0 +1,24 @@ +CREATE TABLE simulation ( + id BIGSERIAL PRIMARY KEY, + account_id BIGINT NOT NULL, + statement_id BIGINT NOT NULL, + school_year_id BIGINT, + started_at TIMESTAMP WITH TIME ZONE, + finished_at TIMESTAMP WITH TIME ZONE, + time_spent_seconds INTEGER, + final_score DECIMAL(5,2), + status VARCHAR(20) NOT NULL DEFAULT 'IN_PROGRESS', + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE, + deleted_at TIMESTAMP WITH TIME ZONE, + + CONSTRAINT fk_simulation_account FOREIGN KEY (account_id) REFERENCES accounts(id), + CONSTRAINT fk_simulation_statement FOREIGN KEY (statement_id) REFERENCES statement(id), + CONSTRAINT fk_simulation_school_year FOREIGN KEY (school_year_id) REFERENCES school_years(id), + CONSTRAINT chk_simulation_status CHECK (status IN ('IN_PROGRESS', 'FINISHED', 'CANCELLED')) +); + +CREATE INDEX idx_simulation_active ON simulation (deleted_at) WHERE deleted_at IS NULL; +CREATE INDEX idx_simulation_account ON simulation (account_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_simulation_statement ON simulation (statement_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_simulation_status ON simulation (status) WHERE deleted_at IS NULL; From a184645e23e54a604b04126b35eb6f1462d1c072 Mon Sep 17 00:00:00 2001 From: Obed jorge Date: Sat, 31 Jan 2026 23:37:51 +0100 Subject: [PATCH 15/48] Entity --- .../kixi/model/SimulationAnswer.java | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/model/SimulationAnswer.java diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/model/SimulationAnswer.java b/services/backend-api/src/main/java/ao/creativemode/kixi/model/SimulationAnswer.java new file mode 100644 index 0000000..b01190f --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/model/SimulationAnswer.java @@ -0,0 +1,45 @@ +package ao.creativemode.kixi.model; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +import java.sql.Date; +@Getter +@Setter +@Table("simulation_answer") +public class SimulationAnswer { + @Id + private Long id; + + @Column("simulation_id") + private Long simulationId; + + @Column("question_id") + private Long questionId; + + @Column("selected_option_id") + private Long selectedOptionId; + + @Column("score_obtained") + private float scoreObtained; + + @Column("is_correct") + private Boolean isCorrect; + + @Column("answered_at") + private Date answeredAt; + + @Column("created_at") + private Date createdAt; + + @Column("updated_at") + private Date updatedAt; + + @Column("deleted_at") + private Date deletedAt; + +} + From 300b61ff7b646580622771b61684bac4f0176033 Mon Sep 17 00:00:00 2001 From: Obed jorge Date: Sat, 31 Jan 2026 23:54:12 +0100 Subject: [PATCH 16/48] Repository --- .../kixi/repository/SimulationAnswerRepository.java | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/repository/SimulationAnswerRepository.java diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SimulationAnswerRepository.java b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SimulationAnswerRepository.java new file mode 100644 index 0000000..0dd38fb --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SimulationAnswerRepository.java @@ -0,0 +1,8 @@ +package ao.creativemode.kixi.repository; + +import ao.creativemode.kixi.model.SchoolYear; +import ao.creativemode.kixi.model.SimulationAnswer; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; + +public interface SimulationAnswerRepository extends ReactiveCrudRepository { +} From 956a96dd1fa7af3b8184b896dac8765eb8c08863 Mon Sep 17 00:00:00 2001 From: Obed jorge Date: Mon, 2 Feb 2026 19:55:43 +0100 Subject: [PATCH 17/48] DTOs and some alteration in model --- .../SimulationAnswerRequest.java | 12 ++++++++++++ .../SimulationAnswerResponse.java | 18 ++++++++++++++++++ .../kixi/model/SimulationAnswer.java | 10 +++++----- 3 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulationanswer/SimulationAnswerRequest.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulationanswer/SimulationAnswerResponse.java diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulationanswer/SimulationAnswerRequest.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulationanswer/SimulationAnswerRequest.java new file mode 100644 index 0000000..475bf6b --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulationanswer/SimulationAnswerRequest.java @@ -0,0 +1,12 @@ +package ao.creativemode.kixi.dto.simulationanswer; + +import java.time.LocalDateTime; + +public record SimulationAnswerRequest( + + Long simulationId, + Long questionId, + Long selectedOptionId, + String textAnswer, + LocalDateTime answeredAt +) { } diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulationanswer/SimulationAnswerResponse.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulationanswer/SimulationAnswerResponse.java new file mode 100644 index 0000000..48e602e --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulationanswer/SimulationAnswerResponse.java @@ -0,0 +1,18 @@ +package ao.creativemode.kixi.dto.simulationanswer; + +import java.time.LocalDateTime; + +public record SimulationAnswerResponse( + + Long id, + Long simulationId, + Long questionId, + Long selectedOptionId, + float scoreObtained, + Boolean isCorrect, + LocalDateTime answeredAt, + LocalDateTime createdAt, + LocalDateTime updatedAt, + LocalDateTime deletedAt + +) { } diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/model/SimulationAnswer.java b/services/backend-api/src/main/java/ao/creativemode/kixi/model/SimulationAnswer.java index b01190f..063c12d 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/model/SimulationAnswer.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/model/SimulationAnswer.java @@ -5,8 +5,8 @@ import org.springframework.data.annotation.Id; import org.springframework.data.relational.core.mapping.Column; import org.springframework.data.relational.core.mapping.Table; +import java.time.LocalDateTime; -import java.sql.Date; @Getter @Setter @Table("simulation_answer") @@ -30,16 +30,16 @@ public class SimulationAnswer { private Boolean isCorrect; @Column("answered_at") - private Date answeredAt; + private LocalDateTime answeredAt; @Column("created_at") - private Date createdAt; + private LocalDateTime createdAt; @Column("updated_at") - private Date updatedAt; + private LocalDateTime updatedAt; @Column("deleted_at") - private Date deletedAt; + private LocalDateTime deletedAt; } From f67091038f283aa1700fa4cecc88d93f41827c18 Mon Sep 17 00:00:00 2001 From: Obed jorge Date: Mon, 2 Feb 2026 22:35:48 +0100 Subject: [PATCH 18/48] service and some alteration in model and repository --- .../kixi/model/SimulationAnswer.java | 12 ++ .../SimulationAnswerRepository.java | 10 ++ .../kixi/service/SimulationAnswerService.java | 112 ++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/service/SimulationAnswerService.java diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/model/SimulationAnswer.java b/services/backend-api/src/main/java/ao/creativemode/kixi/model/SimulationAnswer.java index 063c12d..815a279 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/model/SimulationAnswer.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/model/SimulationAnswer.java @@ -41,5 +41,17 @@ public class SimulationAnswer { @Column("deleted_at") private LocalDateTime deletedAt; + public void markAsDeleted() { + this.deletedAt = LocalDateTime.now(); + } + + public void restore() { + this.deletedAt = null; + } + + public boolean isDeleted() { + return deletedAt != null; + } + } diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SimulationAnswerRepository.java b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SimulationAnswerRepository.java index 0dd38fb..799e0c5 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SimulationAnswerRepository.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SimulationAnswerRepository.java @@ -3,6 +3,16 @@ import ao.creativemode.kixi.model.SchoolYear; import ao.creativemode.kixi.model.SimulationAnswer; import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Flux; public interface SimulationAnswerRepository extends ReactiveCrudRepository { + + + Flux findAllByDeletedAtIsNull(); + Flux findAllByDeletedAtIsNotNull(); + Flux findByIdAndDeletedAtIsNull(Long id); + Flux findByIdAndDeletedAtIsNotNull(Long id); + + + } diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/SimulationAnswerService.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/SimulationAnswerService.java new file mode 100644 index 0000000..7796406 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/SimulationAnswerService.java @@ -0,0 +1,112 @@ +package ao.creativemode.kixi.service; + +import ao.creativemode.kixi.common.exception.ApiException; +import ao.creativemode.kixi.dto.simulationanswer.SimulationAnswerRequest; +import ao.creativemode.kixi.dto.simulationanswer.SimulationAnswerResponse; +import ao.creativemode.kixi.model.SimulationAnswer; +import ao.creativemode.kixi.repository.SimulationAnswerRepository; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +public class SimulationAnswerService { + + public final SimulationAnswerRepository repository; + + public SimulationAnswerService(SimulationAnswerRepository repository) { + this.repository = repository; + } + + public Flux findAllActive() { + return repository.findAllByDeletedAtIsNull() + .map(this::toResponse); + } + + public Mono> listTrashed() { + return repository.findAllByDeletedAtIsNotNull() + .map(this::toResponse) + .collectList(); + } + + public Flux findByIdActive(Long id) { + return repository.findByIdAndDeletedAtIsNull(id) + .switchIfEmpty(Mono.error(ApiException.notFound("School year not found"))) + .map(this::toResponse); + } + + public Mono create(SimulationAnswerRequest request) { + SimulationAnswer answer = new SimulationAnswer(); + answer.setSimulationId(request.simulationId()); + answer.setQuestionId(request.questionId()); + answer.setSelectedOptionId(request.selectedOptionId()); + answer.setAnsweredAt(request.answeredAt()); + return repository.save(answer).map(this::toResponse).onErrorMap(DataIntegrityViolationException.class, + e -> ApiException.conflict("A Simulation answer with this parameter already exists.")); + } + + + public Flux update(Long id, SimulationAnswerRequest request) { + + return repository.findByIdAndDeletedAtIsNull(id) + .switchIfEmpty(Mono.error( + ApiException.badRequest("SimulationAnswer not found or deleted") + )) + .flatMap(answer -> { + + answer.setSelectedOptionId(request.selectedOptionId()); + answer.setAnsweredAt(request.answeredAt()); + answer.setUpdatedAt(LocalDateTime.now()); + + return repository.save(answer); + }) + .map(this::toResponse); + } + + + public Mono hardDelete(Long id) { + return repository.findByIdAndDeletedAtIsNotNull(id) + .switchIfEmpty( + Mono.error(ApiException.badRequest("Only deleted Simulation answer can be permanently removed"))) + .flatMap(repository::delete) + .then(); + } + + public Mono restore(Long id) { + return repository.findByIdAndDeletedAtIsNotNull(id) + .switchIfEmpty(Mono.error(ApiException.badRequest("School year is not deleted"))) + .flatMap(entity -> { + entity.restore(); + return repository.save(entity); + }) + .then(); + } + + public Mono softDelete(Long id) { + return repository.findByIdAndDeletedAtIsNull(id) + .switchIfEmpty(Mono.error(ApiException.notFound("School year not found"))) + .flatMap(entity -> { + entity.markAsDeleted(); + return repository.save(entity); + }) + .then(); + } + + private SimulationAnswerResponse toResponse(SimulationAnswer entity) { + return new SimulationAnswerResponse( + entity.getId(), + entity.getSimulationId(), + entity.getQuestionId(), + entity.getSelectedOptionId(), + entity.getScoreObtained(), + entity.getIsCorrect(), + entity.getAnsweredAt(), + entity.getCreatedAt(), + entity.getUpdatedAt(), + entity.getDeletedAt()); + } +} From ea1e421f31f005bf0e9701826e762039a26d2349 Mon Sep 17 00:00:00 2001 From: Obed jorge Date: Tue, 3 Feb 2026 00:47:01 +0100 Subject: [PATCH 19/48] controller and migration --- .../SimulationAnswerController.java | 90 +++++++++++++++++++ .../SimulationAnswerRepository.java | 5 +- .../kixi/service/SimulationAnswerService.java | 11 +-- .../V1__create_simulation_answer5_table.sql | 23 +++++ 4 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/controller/SimulationAnswerController.java create mode 100644 services/backend-api/src/main/resources/db/migration/V1__create_simulation_answer5_table.sql diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/SimulationAnswerController.java b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/SimulationAnswerController.java new file mode 100644 index 0000000..6d6b727 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/SimulationAnswerController.java @@ -0,0 +1,90 @@ +package ao.creativemode.kixi.controller; + +import ao.creativemode.kixi.dto.schoolyears.SchoolYearRequest; +import ao.creativemode.kixi.dto.schoolyears.SchoolYearResponse; +import ao.creativemode.kixi.dto.simulationanswer.SimulationAnswerRequest; +import ao.creativemode.kixi.dto.simulationanswer.SimulationAnswerResponse; +import ao.creativemode.kixi.service.SimulationAnswerService; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.util.UriComponentsBuilder; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.net.URI; +import java.util.List; + +import static org.springframework.http.HttpStatus.NO_CONTENT; + +@RestController +@RequestMapping("/api/v1/simulation-answers") +public class SimulationAnswerController { + + private final SimulationAnswerService service; + + public SimulationAnswerController(SimulationAnswerService service) { + this.service = service; + } + + @GetMapping + public Mono>> listAllActive() { + return service.findAllActive() + .collectList() + .map(ResponseEntity::ok); + } + + @GetMapping("/trash") + public Mono>> listTrashed() { + return service.findAllTrashed() + .map(ResponseEntity::ok); + } + + @GetMapping("/{id}") + public Mono> getById(@PathVariable Long id) { + return service.findByIdActive(id).map(ResponseEntity::ok); + } + + @PostMapping + public Mono> create(@Valid @RequestBody SimulationAnswerRequest request, UriComponentsBuilder uriBuilder) { + + return service.create(request) + .map(created -> { + URI location = uriBuilder + .path("/api/v1/simulation-answers/{id}") + .buildAndExpand(created.id()) + .toUri(); + + return ResponseEntity.created(location).body(created); + }); + } + + + @PutMapping("/{id}") + public Mono> update(@PathVariable Long id, @Valid @RequestBody SimulationAnswerRequest request) { + + return service.update(id, request) + .map(ResponseEntity::ok); + } + + @DeleteMapping("/{id}") + public Mono> softDelete(@PathVariable Long id) { + return service.softDelete(id) + .thenReturn(ResponseEntity.status(NO_CONTENT).build()); + } + + @PostMapping("/{id}/restore") + public Mono> restore(@PathVariable Long id) { + return service.restore(id) + .thenReturn(ResponseEntity.ok().build()); + } + + @DeleteMapping("/{id}/purge") + public Mono> hardDelete(@PathVariable Long id) { + return service.hardDelete(id) + .thenReturn(ResponseEntity.status(NO_CONTENT).build()); + } + + + +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SimulationAnswerRepository.java b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SimulationAnswerRepository.java index 799e0c5..6b7ea36 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SimulationAnswerRepository.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SimulationAnswerRepository.java @@ -4,14 +4,15 @@ import ao.creativemode.kixi.model.SimulationAnswer; import org.springframework.data.repository.reactive.ReactiveCrudRepository; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; public interface SimulationAnswerRepository extends ReactiveCrudRepository { Flux findAllByDeletedAtIsNull(); Flux findAllByDeletedAtIsNotNull(); - Flux findByIdAndDeletedAtIsNull(Long id); - Flux findByIdAndDeletedAtIsNotNull(Long id); + Mono findByIdAndDeletedAtIsNull(Long id); + Mono findByIdAndDeletedAtIsNotNull(Long id); diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/SimulationAnswerService.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/SimulationAnswerService.java index 7796406..b3e68d6 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/service/SimulationAnswerService.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/SimulationAnswerService.java @@ -16,7 +16,7 @@ @Service public class SimulationAnswerService { - public final SimulationAnswerRepository repository; + private final SimulationAnswerRepository repository; public SimulationAnswerService(SimulationAnswerRepository repository) { this.repository = repository; @@ -27,18 +27,19 @@ public Flux findAllActive() { .map(this::toResponse); } - public Mono> listTrashed() { + public Mono> findAllTrashed() { return repository.findAllByDeletedAtIsNotNull() .map(this::toResponse) .collectList(); } - public Flux findByIdActive(Long id) { + public Mono findByIdActive(Long id) { return repository.findByIdAndDeletedAtIsNull(id) - .switchIfEmpty(Mono.error(ApiException.notFound("School year not found"))) + .switchIfEmpty(Mono.error(ApiException.notFound("SimulationAnswer not found"))) .map(this::toResponse); } + public Mono create(SimulationAnswerRequest request) { SimulationAnswer answer = new SimulationAnswer(); answer.setSimulationId(request.simulationId()); @@ -50,7 +51,7 @@ public Mono create(SimulationAnswerRequest request) { } - public Flux update(Long id, SimulationAnswerRequest request) { + public Mono update(Long id, SimulationAnswerRequest request) { return repository.findByIdAndDeletedAtIsNull(id) .switchIfEmpty(Mono.error( diff --git a/services/backend-api/src/main/resources/db/migration/V1__create_simulation_answer5_table.sql b/services/backend-api/src/main/resources/db/migration/V1__create_simulation_answer5_table.sql new file mode 100644 index 0000000..a75e073 --- /dev/null +++ b/services/backend-api/src/main/resources/db/migration/V1__create_simulation_answer5_table.sql @@ -0,0 +1,23 @@ +CREATE TABLE simulation_answer( + id BIGSERIAL PRIMARY KEY, + simulation_id BIGINT NOT NULL, + question_id BIGINT NOT NULL, + selected_option_id BIGINT, + answer_text TEXT, + score_obtained REAL NOT NULL DEFAULT 0, + is_correct BOOLEAN, + answered_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + deleted_at TIMESTAMP, + + CONSTRAINT fk_simulation FOREIGN KEY(simulation_id) + REFERENCES simulation(id) + ON DELETE CASCADE, + + CONSTRAINT uq_simulation_question UNIQUE(simulation_id, question_id) +); + +CREATE INDEX idx_simulation_answer_simulation_id ON simulation_answer(simulation_id); +CREATE INDEX idx_simulation_answer_question_id ON simulation_answer(question_id); +CREATE INDEX idx_simulation_answer_deleted_at ON simulation_answer(deleted_at); From bccb8af79ff919b02f4410090b5e8bbce6186d46 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Thu, 5 Feb 2026 20:59:20 +0100 Subject: [PATCH 20/48] fix(simulationanswer): align model with ERM and naming conventions - Rename table from 'simulation_answer' to 'simulation_answers' (plural) - Add missing 'answerText' field to model, request and response - Change 'float' to 'Float' for scoreObtained to allow null values - Rename 'textAnswer' to 'answerText' in request to match ERM - Add @NotNull validations for simulationId and questionId - Remove unused SchoolYear import from repository - Add migration V9 to rename table and indexes in database --- .../SimulationAnswerRequest.java | 16 +++++++----- .../SimulationAnswerResponse.java | 25 +++++++++---------- .../kixi/model/SimulationAnswer.java | 12 +++++---- .../SimulationAnswerRepository.java | 10 +++----- .../V9__rename_simulation_answer_table.sql | 7 ++++++ 5 files changed, 39 insertions(+), 31 deletions(-) create mode 100644 services/backend-api/src/main/resources/db/migration/V9__rename_simulation_answer_table.sql diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulationanswer/SimulationAnswerRequest.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulationanswer/SimulationAnswerRequest.java index 475bf6b..385c199 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulationanswer/SimulationAnswerRequest.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulationanswer/SimulationAnswerRequest.java @@ -1,12 +1,16 @@ package ao.creativemode.kixi.dto.simulationanswer; +import jakarta.validation.constraints.NotNull; import java.time.LocalDateTime; public record SimulationAnswerRequest( + @NotNull(message = "Simulation ID is required") Long simulationId, - Long simulationId, - Long questionId, - Long selectedOptionId, - String textAnswer, - LocalDateTime answeredAt -) { } + @NotNull(message = "Question ID is required") Long questionId, + + Long selectedOptionId, + + String answerText, + + LocalDateTime answeredAt +) {} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulationanswer/SimulationAnswerResponse.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulationanswer/SimulationAnswerResponse.java index 48e602e..04b88d6 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulationanswer/SimulationAnswerResponse.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulationanswer/SimulationAnswerResponse.java @@ -3,16 +3,15 @@ import java.time.LocalDateTime; public record SimulationAnswerResponse( - - Long id, - Long simulationId, - Long questionId, - Long selectedOptionId, - float scoreObtained, - Boolean isCorrect, - LocalDateTime answeredAt, - LocalDateTime createdAt, - LocalDateTime updatedAt, - LocalDateTime deletedAt - -) { } + Long id, + Long simulationId, + Long questionId, + Long selectedOptionId, + String answerText, + Float scoreObtained, + Boolean isCorrect, + LocalDateTime answeredAt, + LocalDateTime createdAt, + LocalDateTime updatedAt, + LocalDateTime deletedAt +) {} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/model/SimulationAnswer.java b/services/backend-api/src/main/java/ao/creativemode/kixi/model/SimulationAnswer.java index 815a279..ff73f67 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/model/SimulationAnswer.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/model/SimulationAnswer.java @@ -1,16 +1,17 @@ package ao.creativemode.kixi.model; +import java.time.LocalDateTime; import lombok.Getter; import lombok.Setter; import org.springframework.data.annotation.Id; import org.springframework.data.relational.core.mapping.Column; import org.springframework.data.relational.core.mapping.Table; -import java.time.LocalDateTime; @Getter @Setter -@Table("simulation_answer") +@Table("simulation_answers") public class SimulationAnswer { + @Id private Long id; @@ -23,8 +24,11 @@ public class SimulationAnswer { @Column("selected_option_id") private Long selectedOptionId; + @Column("answer_text") + private String answerText; + @Column("score_obtained") - private float scoreObtained; + private Float scoreObtained; @Column("is_correct") private Boolean isCorrect; @@ -52,6 +56,4 @@ public void restore() { public boolean isDeleted() { return deletedAt != null; } - } - diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SimulationAnswerRepository.java b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SimulationAnswerRepository.java index 6b7ea36..ff942fe 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SimulationAnswerRepository.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SimulationAnswerRepository.java @@ -1,19 +1,15 @@ package ao.creativemode.kixi.repository; -import ao.creativemode.kixi.model.SchoolYear; import ao.creativemode.kixi.model.SimulationAnswer; import org.springframework.data.repository.reactive.ReactiveCrudRepository; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -public interface SimulationAnswerRepository extends ReactiveCrudRepository { - - +public interface SimulationAnswerRepository + extends ReactiveCrudRepository +{ Flux findAllByDeletedAtIsNull(); Flux findAllByDeletedAtIsNotNull(); Mono findByIdAndDeletedAtIsNull(Long id); Mono findByIdAndDeletedAtIsNotNull(Long id); - - - } diff --git a/services/backend-api/src/main/resources/db/migration/V9__rename_simulation_answer_table.sql b/services/backend-api/src/main/resources/db/migration/V9__rename_simulation_answer_table.sql new file mode 100644 index 0000000..386cf8a --- /dev/null +++ b/services/backend-api/src/main/resources/db/migration/V9__rename_simulation_answer_table.sql @@ -0,0 +1,7 @@ +-- Rename simulation_answer table to simulation_answers (plural) to follow naming convention +ALTER TABLE simulation_answer RENAME TO simulation_answers; + +-- Rename indexes to match new table name +ALTER INDEX idx_simulation_answer_simulation_id RENAME TO idx_simulation_answers_simulation_id; +ALTER INDEX idx_simulation_answer_question_id RENAME TO idx_simulation_answers_question_id; +ALTER INDEX idx_simulation_answer_deleted_at RENAME TO idx_simulation_answers_deleted_at; From f3bacab2109ee3bf92591d8de5af2902a04e6efd Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Thu, 5 Feb 2026 20:59:30 +0100 Subject: [PATCH 21/48] refactor(simulationanswer): align service and controller with SchoolYear pattern - Rename findAllTrashed() to findAllDeleted() for consistency - Change return type from Mono> to Flux<> in service - Fix error messages from 'School year' to 'Simulation answer' - Map answerText field in create and update methods - Add onErrorMap for conflict handling in update method - Remove unused imports (SchoolYearRequest, SchoolYearResponse, Flux) - Add JavaDoc documentation to all controller methods --- .../SimulationAnswerController.java | 102 ++++++----- .../kixi/service/SimulationAnswerService.java | 159 +++++++++++------- 2 files changed, 157 insertions(+), 104 deletions(-) diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/SimulationAnswerController.java b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/SimulationAnswerController.java index 6d6b727..650b3fe 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/SimulationAnswerController.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/SimulationAnswerController.java @@ -1,22 +1,18 @@ package ao.creativemode.kixi.controller; -import ao.creativemode.kixi.dto.schoolyears.SchoolYearRequest; -import ao.creativemode.kixi.dto.schoolyears.SchoolYearResponse; +import static org.springframework.http.HttpStatus.NO_CONTENT; + import ao.creativemode.kixi.dto.simulationanswer.SimulationAnswerRequest; import ao.creativemode.kixi.dto.simulationanswer.SimulationAnswerResponse; import ao.creativemode.kixi.service.SimulationAnswerService; import jakarta.validation.Valid; +import java.net.URI; +import java.util.List; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.util.UriComponentsBuilder; -import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import java.net.URI; -import java.util.List; - -import static org.springframework.http.HttpStatus.NO_CONTENT; - @RestController @RequestMapping("/api/v1/simulation-answers") public class SimulationAnswerController { @@ -27,64 +23,90 @@ public SimulationAnswerController(SimulationAnswerService service) { this.service = service; } + /** + * Retrieves all active (non-deleted) simulation answers. + */ @GetMapping - public Mono>> listAllActive() { - return service.findAllActive() - .collectList() - .map(ResponseEntity::ok); + public Mono< + ResponseEntity> + > listAllActive() { + return service.findAllActive().collectList().map(ResponseEntity::ok); } + /** + * Retrieves all soft-deleted (trashed) simulation answers. + */ @GetMapping("/trash") public Mono>> listTrashed() { - return service.findAllTrashed() - .map(ResponseEntity::ok); + return service.findAllDeleted().collectList().map(ResponseEntity::ok); } + /** + * Retrieves a single active simulation answer by ID. + */ @GetMapping("/{id}") - public Mono> getById(@PathVariable Long id) { + public Mono> getById( + @PathVariable Long id + ) { return service.findByIdActive(id).map(ResponseEntity::ok); } + /** + * Creates a new simulation answer. + */ @PostMapping - public Mono> create(@Valid @RequestBody SimulationAnswerRequest request, UriComponentsBuilder uriBuilder) { - - return service.create(request) - .map(created -> { - URI location = uriBuilder - .path("/api/v1/simulation-answers/{id}") - .buildAndExpand(created.id()) - .toUri(); - - return ResponseEntity.created(location).body(created); - }); + public Mono> create( + @Valid @RequestBody SimulationAnswerRequest request, + UriComponentsBuilder uriBuilder + ) { + return service + .create(request) + .map(created -> { + URI location = uriBuilder + .path("/api/v1/simulation-answers/{id}") + .buildAndExpand(created.id()) + .toUri(); + + return ResponseEntity.created(location).body(created); + }); } - + /** + * Updates an existing active simulation answer. + */ @PutMapping("/{id}") - public Mono> update(@PathVariable Long id, @Valid @RequestBody SimulationAnswerRequest request) { - - return service.update(id, request) - .map(ResponseEntity::ok); + public Mono> update( + @PathVariable Long id, + @Valid @RequestBody SimulationAnswerRequest request + ) { + return service.update(id, request).map(ResponseEntity::ok); } + /** + * Soft-deletes a simulation answer (moves it to trash). + */ @DeleteMapping("/{id}") public Mono> softDelete(@PathVariable Long id) { - return service.softDelete(id) - .thenReturn(ResponseEntity.status(NO_CONTENT).build()); + return service + .softDelete(id) + .thenReturn(ResponseEntity.status(NO_CONTENT).build()); } + /** + * Restores a soft-deleted simulation answer from trash. + */ @PostMapping("/{id}/restore") public Mono> restore(@PathVariable Long id) { - return service.restore(id) - .thenReturn(ResponseEntity.ok().build()); + return service.restore(id).thenReturn(ResponseEntity.ok().build()); } + /** + * Permanently deletes a simulation answer (only if already soft-deleted). + */ @DeleteMapping("/{id}/purge") public Mono> hardDelete(@PathVariable Long id) { - return service.hardDelete(id) - .thenReturn(ResponseEntity.status(NO_CONTENT).build()); + return service + .hardDelete(id) + .thenReturn(ResponseEntity.status(NO_CONTENT).build()); } - - - } diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/SimulationAnswerService.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/SimulationAnswerService.java index b3e68d6..bdf26c1 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/service/SimulationAnswerService.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/SimulationAnswerService.java @@ -5,14 +5,12 @@ import ao.creativemode.kixi.dto.simulationanswer.SimulationAnswerResponse; import ao.creativemode.kixi.model.SimulationAnswer; import ao.creativemode.kixi.repository.SimulationAnswerRepository; +import java.time.LocalDateTime; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import java.time.LocalDateTime; -import java.util.List; - @Service public class SimulationAnswerService { @@ -23,91 +21,124 @@ public SimulationAnswerService(SimulationAnswerRepository repository) { } public Flux findAllActive() { - return repository.findAllByDeletedAtIsNull() - .map(this::toResponse); + return repository.findAllByDeletedAtIsNull().map(this::toResponse); } - public Mono> findAllTrashed() { - return repository.findAllByDeletedAtIsNotNull() - .map(this::toResponse) - .collectList(); + public Flux findAllDeleted() { + return repository.findAllByDeletedAtIsNotNull().map(this::toResponse); } public Mono findByIdActive(Long id) { - return repository.findByIdAndDeletedAtIsNull(id) - .switchIfEmpty(Mono.error(ApiException.notFound("SimulationAnswer not found"))) - .map(this::toResponse); + return repository + .findByIdAndDeletedAtIsNull(id) + .switchIfEmpty( + Mono.error(ApiException.notFound("Simulation answer not found")) + ) + .map(this::toResponse); } - - public Mono create(SimulationAnswerRequest request) { + public Mono create( + SimulationAnswerRequest request + ) { SimulationAnswer answer = new SimulationAnswer(); answer.setSimulationId(request.simulationId()); answer.setQuestionId(request.questionId()); answer.setSelectedOptionId(request.selectedOptionId()); + answer.setAnswerText(request.answerText()); answer.setAnsweredAt(request.answeredAt()); - return repository.save(answer).map(this::toResponse).onErrorMap(DataIntegrityViolationException.class, - e -> ApiException.conflict("A Simulation answer with this parameter already exists.")); - } - - - public Mono update(Long id, SimulationAnswerRequest request) { - return repository.findByIdAndDeletedAtIsNull(id) - .switchIfEmpty(Mono.error( - ApiException.badRequest("SimulationAnswer not found or deleted") - )) - .flatMap(answer -> { - - answer.setSelectedOptionId(request.selectedOptionId()); - answer.setAnsweredAt(request.answeredAt()); - answer.setUpdatedAt(LocalDateTime.now()); - - return repository.save(answer); - }) - .map(this::toResponse); + return repository + .save(answer) + .map(this::toResponse) + .onErrorMap(DataIntegrityViolationException.class, e -> + ApiException.conflict( + "A simulation answer with this parameter already exists." + ) + ); } + public Mono update( + Long id, + SimulationAnswerRequest request + ) { + return repository + .findByIdAndDeletedAtIsNull(id) + .switchIfEmpty( + Mono.error(ApiException.notFound("Simulation answer not found")) + ) + .flatMap(answer -> { + answer.setSimulationId(request.simulationId()); + answer.setQuestionId(request.questionId()); + answer.setSelectedOptionId(request.selectedOptionId()); + answer.setAnswerText(request.answerText()); + answer.setAnsweredAt(request.answeredAt()); + answer.setUpdatedAt(LocalDateTime.now()); + + return repository.save(answer); + }) + .map(this::toResponse) + .onErrorMap(DataIntegrityViolationException.class, e -> + ApiException.conflict( + "A simulation answer with this parameter already exists." + ) + ); + } - public Mono hardDelete(Long id) { - return repository.findByIdAndDeletedAtIsNotNull(id) - .switchIfEmpty( - Mono.error(ApiException.badRequest("Only deleted Simulation answer can be permanently removed"))) - .flatMap(repository::delete) - .then(); + public Mono softDelete(Long id) { + return repository + .findByIdAndDeletedAtIsNull(id) + .switchIfEmpty( + Mono.error(ApiException.notFound("Simulation answer not found")) + ) + .flatMap(entity -> { + entity.markAsDeleted(); + return repository.save(entity); + }) + .then(); } public Mono restore(Long id) { - return repository.findByIdAndDeletedAtIsNotNull(id) - .switchIfEmpty(Mono.error(ApiException.badRequest("School year is not deleted"))) - .flatMap(entity -> { - entity.restore(); - return repository.save(entity); - }) - .then(); + return repository + .findByIdAndDeletedAtIsNotNull(id) + .switchIfEmpty( + Mono.error( + ApiException.badRequest("Simulation answer is not deleted") + ) + ) + .flatMap(entity -> { + entity.restore(); + return repository.save(entity); + }) + .then(); } - public Mono softDelete(Long id) { - return repository.findByIdAndDeletedAtIsNull(id) - .switchIfEmpty(Mono.error(ApiException.notFound("School year not found"))) - .flatMap(entity -> { - entity.markAsDeleted(); - return repository.save(entity); - }) - .then(); + public Mono hardDelete(Long id) { + return repository + .findByIdAndDeletedAtIsNotNull(id) + .switchIfEmpty( + Mono.error( + ApiException.badRequest( + "Only deleted simulation answers can be permanently removed" + ) + ) + ) + .flatMap(repository::delete) + .then(); } private SimulationAnswerResponse toResponse(SimulationAnswer entity) { return new SimulationAnswerResponse( - entity.getId(), - entity.getSimulationId(), - entity.getQuestionId(), - entity.getSelectedOptionId(), - entity.getScoreObtained(), - entity.getIsCorrect(), - entity.getAnsweredAt(), - entity.getCreatedAt(), - entity.getUpdatedAt(), - entity.getDeletedAt()); + entity.getId(), + entity.getSimulationId(), + entity.getQuestionId(), + entity.getSelectedOptionId(), + entity.getAnswerText(), + entity.getScoreObtained(), + entity.getIsCorrect(), + entity.getAnsweredAt(), + entity.getCreatedAt(), + entity.getUpdatedAt(), + entity.getDeletedAt() + ); } } From 598b3bd334ec0f0af5e910ebde3c0672b8e69d48 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Thu, 5 Feb 2026 20:59:37 +0100 Subject: [PATCH 22/48] fix(exception): return correct HTTP status codes from ApiException - Use ApiException.getStatus() instead of hardcoded 500 - Return proper status codes: 404 for notFound, 400 for badRequest, 409 for conflict - Use status reason phrase as title instead of generic 'API Error' - This fix affects all controllers using ApiException globally --- .../exception/GlobalExceptionHandler.java | 120 +++++++++++------- 1 file changed, 74 insertions(+), 46 deletions(-) diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/common/exception/GlobalExceptionHandler.java b/services/backend-api/src/main/java/ao/creativemode/kixi/common/exception/GlobalExceptionHandler.java index d6d1e6f..b0b9a8f 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/common/exception/GlobalExceptionHandler.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/common/exception/GlobalExceptionHandler.java @@ -1,8 +1,12 @@ package ao.creativemode.kixi.common.exception; import ao.creativemode.kixi.common.dto.ProblemDetail; +import java.net.URI; +import java.util.Map; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -10,10 +14,6 @@ import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; -import java.net.URI; -import java.util.Map; -import java.util.stream.Collectors; - /** * Global exception handler for the API. * Returns RFC 9457 Problem Details in case of errors. @@ -21,45 +21,67 @@ @ControllerAdvice public class GlobalExceptionHandler { - private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + private static final Logger log = LoggerFactory.getLogger( + GlobalExceptionHandler.class + ); - private static final URI DEFAULT_TYPE = URI.create("https://api.kixi.com/errors"); + private static final URI DEFAULT_TYPE = URI.create( + "https://api.kixi.com/errors" + ); @ExceptionHandler(ApiException.class) public Mono> handleApiException( - ApiException ex, - ServerWebExchange exchange) { + ApiException ex, + ServerWebExchange exchange + ) { + HttpStatus status = + ex.getStatus() != null + ? ex.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + int statusCode = status.value(); ProblemDetail problem = ProblemDetail.forStatusAndDetail( - 500, - ex.getMessage() != null ? ex.getMessage() : "API Error occurred").withTitle("API Error"); + statusCode, + ex.getMessage() != null ? ex.getMessage() : "API Error occurred" + ).withTitle(status.getReasonPhrase()); problem = addInstance(exchange, problem); - return Mono.just(ResponseEntity.status(500).body(problem)); + + return Mono.just(ResponseEntity.status(statusCode).body(problem)); } @ExceptionHandler(WebExchangeBindException.class) public Mono> handleValidationErrors( - WebExchangeBindException ex, - ServerWebExchange exchange) { - - Map fieldErrors = ex.getFieldErrors().stream() - .collect(Collectors.toMap( - fieldError -> fieldError.getField(), - fieldError -> { - String msg = fieldError.getDefaultMessage() != null - ? fieldError.getDefaultMessage() - : "Invalid value"; - if (fieldError.getRejectedValue() != null) { - return Map.of( - "message", msg, - "rejectedValue", fieldError.getRejectedValue()); - } - return msg; - })); + WebExchangeBindException ex, + ServerWebExchange exchange + ) { + Map fieldErrors = ex + .getFieldErrors() + .stream() + .collect( + Collectors.toMap( + fieldError -> fieldError.getField(), + fieldError -> { + String msg = + fieldError.getDefaultMessage() != null + ? fieldError.getDefaultMessage() + : "Invalid value"; + if (fieldError.getRejectedValue() != null) { + return Map.of( + "message", + msg, + "rejectedValue", + fieldError.getRejectedValue() + ); + } + return msg; + } + ) + ); ProblemDetail problem = ProblemDetail.validationError( - "Validation failed for one or more fields", - fieldErrors); + "Validation failed for one or more fields", + fieldErrors + ); problem = addInstance(exchange, problem); @@ -68,34 +90,40 @@ public Mono> handleValidationErrors( @ExceptionHandler(Exception.class) public Mono> handleGenericException( - Exception ex, - ServerWebExchange exchange) { - + Exception ex, + ServerWebExchange exchange + ) { log.error("Unhandled exception occurred", ex); ProblemDetail problem = ProblemDetail.forStatusAndDetail( - 500, - "An unexpected error occurred on the server. Please try again later.") - .withTitle("Internal Server Error"); + 500, + "An unexpected error occurred on the server. Please try again later." + ).withTitle("Internal Server Error"); problem = addInstance(exchange, problem); return Mono.just(ResponseEntity.internalServerError().body(problem)); } /** - * * Adds the 'instance' field with the URI of the current request (RFC 9457 * recommended) */ - private ProblemDetail addInstance(ServerWebExchange exchange, ProblemDetail problem) { + private ProblemDetail addInstance( + ServerWebExchange exchange, + ProblemDetail problem + ) { String requestUri = exchange.getRequest().getURI().toString(); - Map currentProps = problem.properties() != null ? problem.properties() : Map.of(); - Map updatedProps = new java.util.HashMap<>(currentProps); + Map currentProps = + problem.properties() != null ? problem.properties() : Map.of(); + Map updatedProps = new java.util.HashMap<>( + currentProps + ); updatedProps.put("instance", requestUri); return new ProblemDetail( - problem.type(), - problem.title(), - problem.status(), - problem.detail(), - updatedProps); + problem.type(), + problem.title(), + problem.status(), + problem.detail(), + updatedProps + ); } -} \ No newline at end of file +} From 1bd6f9dc973033238f82eae6e051c8292a4f9392 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Wed, 4 Feb 2026 16:21:05 +0100 Subject: [PATCH 23/48] feature: firt step paddle-ocr implementation --- .../kixi/dto/simulation/SimulationRequest.java | 4 ++-- .../kixi/dto/statement/StatementResponse.java | 4 ++-- .../ao/creativemode/kixi/model/Simulation.java | 4 ++-- .../kixi/service/StatementService.java | 15 +++++++++++++-- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulation/SimulationRequest.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulation/SimulationRequest.java index a0edc6d..c14431a 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulation/SimulationRequest.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/simulation/SimulationRequest.java @@ -1,10 +1,10 @@ package ao.creativemode.kixi.dto.simulation; +import java.time.LocalDateTime; + import ao.creativemode.kixi.model.SimulationStatus; import jakarta.validation.constraints.NotNull; -import java.time.LocalDateTime; - public record SimulationRequest( @NotNull(message = "Account ID is required") Long accountId, diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementResponse.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementResponse.java index 6892cec..8002c11 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementResponse.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementResponse.java @@ -1,5 +1,7 @@ package ao.creativemode.kixi.dto.statement; +import java.time.LocalDateTime; + import ao.creativemode.kixi.dto.accounts.AccountBasicResponse; import ao.creativemode.kixi.dto.classe.ClassResponse; import ao.creativemode.kixi.dto.courses.CourseResponse; @@ -7,8 +9,6 @@ import ao.creativemode.kixi.dto.subject.SubjectResponse; import ao.creativemode.kixi.dto.term.TermResponse; -import java.time.LocalDateTime; - public record StatementResponse( Long id, String examType, diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/model/Simulation.java b/services/backend-api/src/main/java/ao/creativemode/kixi/model/Simulation.java index 050f6b3..59c974c 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/model/Simulation.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/model/Simulation.java @@ -1,11 +1,11 @@ package ao.creativemode.kixi.model; +import java.time.LocalDateTime; + import org.springframework.data.annotation.Id; import org.springframework.data.relational.core.mapping.Column; import org.springframework.data.relational.core.mapping.Table; -import java.time.LocalDateTime; - @Table("simulation") public class Simulation { diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/StatementService.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/StatementService.java index d03ce1d..51217fe 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/service/StatementService.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/StatementService.java @@ -15,9 +15,20 @@ import ao.creativemode.kixi.dto.statement.StatementResponse; import ao.creativemode.kixi.dto.subject.SubjectResponse; import ao.creativemode.kixi.dto.term.TermResponse; -import ao.creativemode.kixi.model.*; +import ao.creativemode.kixi.model.Account; import ao.creativemode.kixi.model.Class; -import ao.creativemode.kixi.repository.*; +import ao.creativemode.kixi.model.Course; +import ao.creativemode.kixi.model.SchoolYear; +import ao.creativemode.kixi.model.Statement; +import ao.creativemode.kixi.model.Subject; +import ao.creativemode.kixi.model.Term; +import ao.creativemode.kixi.repository.AccountRepository; +import ao.creativemode.kixi.repository.ClassRepository; +import ao.creativemode.kixi.repository.CourseRepository; +import ao.creativemode.kixi.repository.SchoolYearRepository; +import ao.creativemode.kixi.repository.StatementRepository; +import ao.creativemode.kixi.repository.SubjectRepository; +import ao.creativemode.kixi.repository.TermRepository; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; From 83fd94d0d74211f46e546b460aa44baf049205ca Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Wed, 4 Feb 2026 09:03:10 +0100 Subject: [PATCH 24/48] feature: firt step paddle-ocr implementation --- docker-compose.yml | 272 ++++++- infra/docker/backend.Dockerfile | 84 ++- infra/docker/ocr.Dockerfile | 140 +++- services/backend-api/docker/postgres/init.sql | 372 ++++++++- .../kixi/client/OcrServiceClient.java | 294 ++++++++ .../kixi/common/exception/ApiException.java | 275 ++++++- .../exception/GlobalExceptionHandler.java | 115 +++ .../kixi/controller/OcrController.java | 224 ++++++ .../kixi/controller/StatementController.java | 512 +++++++++++-- .../kixi/controller/SubjectController.java | 37 +- .../kixi/dto/ocr/OcrResponse.java | 232 ++++++ .../ao/creativemode/kixi/model/Question.java | 299 ++++++++ .../kixi/model/QuestionOption.java | 234 ++++++ .../ao/creativemode/kixi/model/Statement.java | 344 ++++++++- .../repository/QuestionOptionRepository.java | 158 ++++ .../kixi/repository/QuestionRepository.java | 172 +++++ .../kixi/repository/StatementRepository.java | 167 +++- .../kixi/service/StatementService.java | 714 ++++++++++-------- services/ocr-service/Dockerfile | 124 +++ services/ocr-service/README.md | 327 +++++++- services/ocr-service/app/api/pdf_handler.py | 289 +++++++ .../ocr-service/app/ocr/postprocessing.py | 703 ++++++++++++++++- services/ocr-service/env.example | 96 +++ services/ocr-service/pytest.ini | 43 ++ services/ocr-service/tests/__init__.py | 8 + services/ocr-service/tests/conftest.py | 400 ++++++++++ services/ocr-service/tests/test_engine.py | 656 +++++++++++++++- services/ocr-service/tests/test_routes.py | 395 ++++++++++ 28 files changed, 7269 insertions(+), 417 deletions(-) create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/client/OcrServiceClient.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/controller/OcrController.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/dto/ocr/OcrResponse.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/model/Question.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/model/QuestionOption.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionOptionRepository.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionRepository.java create mode 100644 services/ocr-service/Dockerfile create mode 100644 services/ocr-service/app/api/pdf_handler.py create mode 100644 services/ocr-service/env.example create mode 100644 services/ocr-service/pytest.ini create mode 100644 services/ocr-service/tests/__init__.py create mode 100644 services/ocr-service/tests/conftest.py create mode 100644 services/ocr-service/tests/test_routes.py diff --git a/docker-compose.yml b/docker-compose.yml index 0272db1..6728cf7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,56 @@ +# ============================================================================= +# Docker Compose Configuration for Kixi Platform +# ============================================================================= +# +# This file defines all services for the Kixi platform: +# - postgres: PostgreSQL 16 database +# - backend-api: Spring Boot WebFlux API (Java 17) +# - ocr-service: FastAPI OCR service with PaddleOCR-VL (Python 3.11) +# +# Usage: +# docker-compose up --build # Start all services +# docker-compose up -d postgres # Start only database +# docker-compose up ocr-service # Start OCR service with dependencies +# docker-compose logs -f backend-api # Follow backend logs +# docker-compose down -v # Stop and remove volumes +# +# Profiles: +# docker-compose --profile gpu up # Start with GPU-enabled OCR +# docker-compose --profile cache up # Start with Redis cache +# +# Environment Variables: +# Copy .env.example to .env and customize as needed +# +# ============================================================================= + services: - kixi_postgres: - image: postgres:15.15-alpine - container_name: kixi_postgres + # =========================================================================== + # PostgreSQL Database + # =========================================================================== + postgres: + image: postgres:16-alpine + container_name: kixi-postgres + restart: unless-stopped environment: - POSTGRES_DB: kixi_db - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres + POSTGRES_DB: ${POSTGRES_DB:-kixi} + POSTGRES_USER: ${POSTGRES_USER:-kixi} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kixi_secret} + PGDATA: /var/lib/postgresql/data/pgdata ports: - - "5433:5432" + - "${POSTGRES_PORT:-5432}:5432" volumes: - - kixi_postgres_data:/var/lib/postgresql/data - - ./services/backend-api/docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql + - postgres_data:/var/lib/postgresql/data + - ./services/backend-api/docker/postgres/init.sql:/docker-entrypoint-initdb.d/01-init.sql:z + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${POSTGRES_USER:-kixi} -d ${POSTGRES_DB:-kixi}", + ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s networks: - kixi_network healthcheck: @@ -49,9 +89,217 @@ services: - kixi_network restart: unless-stopped -volumes: - kixi_postgres_data: + # =========================================================================== + # Backend API (Spring Boot WebFlux) + # =========================================================================== + # Build uses Dockerfile from infra/docker/ with project root as context + # This allows access to all project files during build + backend-api: + build: + context: . + dockerfile: infra/docker/backend.Dockerfile + container_name: kixi-backend-api + restart: unless-stopped + environment: + # Database Configuration + SPRING_R2DBC_URL: r2dbc:postgresql://postgres:5432/${POSTGRES_DB:-kixi} + SPRING_R2DBC_USERNAME: ${POSTGRES_USER:-kixi} + SPRING_R2DBC_PASSWORD: ${POSTGRES_PASSWORD:-kixi_secret} + # OCR Service Configuration + OCR_SERVICE_URL: http://ocr-service:8000 + OCR_SERVICE_TIMEOUT_MS: ${OCR_SERVICE_TIMEOUT_MS:-120000} + # Application Configuration + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILE:-docker} + SERVER_PORT: 8080 + # Logging Configuration + LOGGING_LEVEL_ROOT: ${LOG_LEVEL:-INFO} + LOGGING_LEVEL_AO_CREATIVEMODE: ${APP_LOG_LEVEL:-DEBUG} + # JWT Configuration (optional) + JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + JWT_EXPIRATION_MS: ${JWT_EXPIRATION_MS:-86400000} + ports: + - "${BACKEND_PORT:-8080}:8080" + depends_on: + postgres: + condition: service_healthy + ocr-service: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + networks: + - kixi-network + + # =========================================================================== + # OCR Service (FastAPI + PaddleOCR-VL) + # =========================================================================== + # Build uses Dockerfile from infra/docker/ with project root as context + # For local development, you can use the Dockerfile in services/ocr-service/ + ocr-service: + build: + context: . + dockerfile: infra/docker/ocr.Dockerfile + # Alternative: Use local Dockerfile for development + # context: ./services/ocr-service + # dockerfile: Dockerfile + container_name: kixi-ocr-service + restart: unless-stopped + environment: + # Service Configuration + SERVICE_NAME: ocr-service + SERVICE_VERSION: 1.0.0 + ENVIRONMENT: ${ENVIRONMENT:-docker} + DEBUG: ${OCR_DEBUG:-false} + # Server Configuration + HOST: 0.0.0.0 + PORT: 8000 + WORKERS: ${OCR_WORKERS:-1} + # OCR Configuration + OCR_LANG: ${OCR_LANG:-pt} + OCR_USE_GPU: ${OCR_USE_GPU:-false} + OCR_USE_ANGLE_CLS: ${OCR_USE_ANGLE_CLS:-true} + OCR_SHOW_LOG: ${OCR_SHOW_LOG:-false} + # Processing Configuration + MAX_IMAGE_SIZE_MB: ${MAX_IMAGE_SIZE_MB:-20.0} + MAX_IMAGES_PER_REQUEST: ${MAX_IMAGES_PER_REQUEST:-10} + PROCESSING_TIMEOUT_SECONDS: ${PROCESSING_TIMEOUT_SECONDS:-120} + MIN_CONFIDENCE_THRESHOLD: ${MIN_CONFIDENCE_THRESHOLD:-0.5} + # Preprocessing Configuration + ENABLE_DESKEW: ${ENABLE_DESKEW:-true} + ENABLE_DENOISE: ${ENABLE_DENOISE:-true} + TARGET_DPI: ${TARGET_DPI:-300} + # Logging Configuration + LOG_LEVEL: ${OCR_LOG_LEVEL:-INFO} + LOG_FORMAT: ${OCR_LOG_FORMAT:-json} + # Metrics Configuration + ENABLE_METRICS: ${ENABLE_METRICS:-true} + ports: + - "${OCR_PORT:-8000}:8000" + volumes: + # Persist PaddleOCR models between container restarts + - ocr_models:/home/ocr/.paddleocr + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + deploy: + resources: + limits: + memory: 4G + reservations: + memory: 2G + networks: + - kixi-network + # =========================================================================== + # OCR Service with GPU Support (Optional Profile) + # =========================================================================== + # Start with: docker-compose --profile gpu up + # Requires NVIDIA Docker runtime and CUDA-compatible GPU + ocr-service-gpu: + build: + context: . + dockerfile: infra/docker/ocr.Dockerfile + container_name: kixi-ocr-service-gpu + profiles: + - gpu + restart: unless-stopped + environment: + SERVICE_NAME: ocr-service-gpu + SERVICE_VERSION: 1.0.0 + ENVIRONMENT: ${ENVIRONMENT:-docker} + DEBUG: ${OCR_DEBUG:-false} + HOST: 0.0.0.0 + PORT: 8000 + WORKERS: ${OCR_WORKERS:-1} + # Enable GPU acceleration + OCR_LANG: ${OCR_LANG:-pt} + OCR_USE_GPU: "true" + OCR_USE_ANGLE_CLS: "true" + OCR_SHOW_LOG: "false" + MAX_IMAGE_SIZE_MB: "20.0" + MAX_IMAGES_PER_REQUEST: "10" + PROCESSING_TIMEOUT_SECONDS: "120" + MIN_CONFIDENCE_THRESHOLD: "0.5" + ENABLE_DESKEW: "true" + ENABLE_DENOISE: "true" + TARGET_DPI: "300" + LOG_LEVEL: INFO + LOG_FORMAT: json + ENABLE_METRICS: "true" + ports: + - "${OCR_GPU_PORT:-8001}:8000" + volumes: + - ocr_models_gpu:/home/ocr/.paddleocr + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 120s + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] + networks: + - kixi-network + + # =========================================================================== + # Redis Cache (Optional Profile) + # =========================================================================== + # Start with: docker-compose --profile cache up + # Used for caching OCR results and session management + redis: + image: redis:7-alpine + container_name: kixi-redis + profiles: + - cache + restart: unless-stopped + command: redis-server --appendonly yes + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 3 + networks: + - kixi-network + +# ============================================================================= +# Networks +# ============================================================================= networks: - kixi_network: + kixi-network: driver: bridge + name: kixi-network + +# ============================================================================= +# Volumes +# ============================================================================= +volumes: + # PostgreSQL data persistence + postgres_data: + name: kixi-postgres-data + + # PaddleOCR models cache (CPU version) + ocr_models: + name: kixi-ocr-models + + # PaddleOCR models cache (GPU version) + ocr_models_gpu: + name: kixi-ocr-models-gpu + + # Redis data persistence + redis_data: + name: kixi-redis-data diff --git a/infra/docker/backend.Dockerfile b/infra/docker/backend.Dockerfile index b51ab4c..7c986cc 100644 --- a/infra/docker/backend.Dockerfile +++ b/infra/docker/backend.Dockerfile @@ -1,14 +1,86 @@ +# Backend API Dockerfile +# +# Multi-stage build for the Kixi Backend API (Spring Boot WebFlux) +# Optimized for production deployments with minimal image size +# +# Build from project root: +# docker build -f infra/docker/backend.Dockerfile -t kixi-backend-api . +# +# Or using docker-compose (recommended): +# docker-compose up --build backend-api + +# ============================================================================= +# Stage 1: Builder +# ============================================================================= FROM eclipse-temurin:17-jdk-jammy AS build + WORKDIR /app -COPY mvnw . -COPY .mvn .mvn -COPY pom.xml . + +# Copy Maven wrapper and configuration from services/backend-api +COPY services/backend-api/mvnw . +COPY services/backend-api/.mvn .mvn +COPY services/backend-api/pom.xml . + +# Make Maven wrapper executable RUN chmod +x mvnw + +# Download dependencies (cached layer) RUN ./mvnw dependency:go-offline -B -COPY src ./src + +# Copy source code from services/backend-api +COPY services/backend-api/src ./src + +# Build the application (skip tests for faster builds) RUN ./mvnw clean package -DskipTests -FROM eclipse-temurin:17-jre-jammy + +# ============================================================================= +# Stage 2: Runtime +# ============================================================================= +FROM eclipse-temurin:17-jre-jammy AS runtime + +# Labels +LABEL maintainer="Kixi Team " \ + org.opencontainers.image.title="Kixi Backend API" \ + org.opencontainers.image.description="Spring Boot WebFlux Backend API for the Kixi platform" \ + org.opencontainers.image.version="0.0.1-SNAPSHOT" \ + org.opencontainers.image.vendor="Creative Mode" \ + org.opencontainers.image.source="https://github.com/creative-mode/kixi" + +# Set environment variables +ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0" \ + SPRING_PROFILES_ACTIVE=docker \ + SERVER_PORT=8080 \ + TZ=Africa/Luanda + +# Install useful tools +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + tzdata \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user for security +RUN groupadd --gid 1000 spring && \ + useradd --uid 1000 --gid spring --shell /bin/bash --create-home spring + +# Set working directory WORKDIR /app + +# Copy the built JAR from builder stage COPY --from=build /app/target/demo-0.0.1-SNAPSHOT.jar app.jar + +# Change ownership to non-root user +RUN chown -R spring:spring /app + +# Switch to non-root user +USER spring + +# Expose port EXPOSE 8080 -ENTRYPOINT ["java","-jar","app.jar"] \ No newline at end of file + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:8080/actuator/health || exit 1 + +# Run the application +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] diff --git a/infra/docker/ocr.Dockerfile b/infra/docker/ocr.Dockerfile index 79d1641..2447409 100644 --- a/infra/docker/ocr.Dockerfile +++ b/infra/docker/ocr.Dockerfile @@ -1,5 +1,124 @@ -FROM python:3.11-slim +# OCR Service Dockerfile +# +# Multi-stage build for the Kixi OCR Service using PaddleOCR-VL +# Optimized for production deployments with minimal image size +# +# Build from project root: +# docker build -f infra/docker/ocr.Dockerfile -t kixi-ocr-service . +# +# Or using docker-compose (recommended): +# docker-compose up --build ocr-service +# ============================================================================= +# Stage 1: Builder +# ============================================================================= +FROM python:3.11-slim AS builder + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + gcc \ + g++ \ + libffi-dev \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +# Create virtual environment +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +# Copy requirements and install dependencies +WORKDIR /app +COPY services/ocr-service/requirements.txt . + +# Install Python dependencies +RUN pip install --upgrade pip setuptools wheel && \ + pip install -r requirements.txt + +# ============================================================================= +# Stage 2: Runtime +# ============================================================================= +FROM python:3.11-slim AS runtime + +# Labels +LABEL maintainer="Kixi Team " \ + org.opencontainers.image.title="Kixi OCR Service" \ + org.opencontainers.image.description="OCR Service using PaddleOCR-VL for text extraction from exam images" \ + org.opencontainers.image.version="1.0.0" \ + org.opencontainers.image.vendor="Creative Mode" \ + org.opencontainers.image.source="https://github.com/creative-mode/kixi" + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONFAULTHANDLER=1 \ + PATH="/opt/venv/bin:$PATH" \ + # Application settings + SERVICE_NAME=ocr-service \ + SERVICE_VERSION=1.0.0 \ + ENVIRONMENT=production \ + DEBUG=false \ + HOST=0.0.0.0 \ + PORT=8000 \ + WORKERS=1 \ + # OCR settings + OCR_LANG=pt \ + OCR_USE_GPU=false \ + OCR_USE_ANGLE_CLS=true \ + OCR_SHOW_LOG=false \ + # Processing settings + MAX_IMAGE_SIZE_MB=20.0 \ + MAX_IMAGES_PER_REQUEST=10 \ + PROCESSING_TIMEOUT_SECONDS=120 \ + MIN_CONFIDENCE_THRESHOLD=0.5 \ + ENABLE_DESKEW=true \ + ENABLE_DENOISE=true \ + TARGET_DPI=300 \ + # Logging + LOG_LEVEL=INFO \ + LOG_FORMAT=json \ + # Metrics + ENABLE_METRICS=true \ + # Timezone + TZ=Africa/Luanda + +# Install runtime dependencies +# Note: libgl1-mesa-glx was renamed to libgl1 in Debian Trixie +RUN apt-get update && apt-get install -y --no-install-recommends \ + # OpenCV dependencies (Debian Trixie compatible) + libgl1 \ + libglib2.0-0t64 \ + libsm6 \ + libxext6 \ + libxrender1 \ + libgomp1 \ + # PDF processing dependencies + poppler-utils \ + # Fonts for proper text rendering + fonts-liberation \ + fonts-dejavu-core \ + fonts-freefont-ttf \ + # Curl for health checks + curl \ + # Timezone data + tzdata \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +# Create non-root user for security +RUN groupadd --gid 1000 ocr && \ + useradd --uid 1000 --gid ocr --shell /bin/bash --create-home ocr + +# Copy virtual environment from builder +COPY --from=builder /opt/venv /opt/venv + +# Set working directory WORKDIR /app # Instalar dependências do sistema para OCR (Tesseract) @@ -10,11 +129,22 @@ RUN apt-get update && apt-get install -y \ libglib2.0-0 \ && rm -rf /var/lib/apt/lists/* -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +# Create directories for models and cache +RUN mkdir -p /home/ocr/.paddleocr && \ + chown -R ocr:ocr /home/ocr/.paddleocr && \ + # Create tmp directory for processing + mkdir -p /tmp/ocr && \ + chown -R ocr:ocr /tmp/ocr -COPY app/ ./app/ +# Switch to non-root user +USER ocr +# Expose port EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# Default command - run with uvicorn +CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/services/backend-api/docker/postgres/init.sql b/services/backend-api/docker/postgres/init.sql index 6889a47..33f11b6 100644 --- a/services/backend-api/docker/postgres/init.sql +++ b/services/backend-api/docker/postgres/init.sql @@ -1,9 +1,369 @@ -CREATE DATABASE creativemode; +-- ============================================================================= +-- Kixi Database Initialization Script +-- ============================================================================= +-- This script creates all required tables for the Kixi platform. +-- It should be idempotent (safe to run multiple times). +-- ============================================================================= -CREATE TABLE anos_letivos ( +-- Create database if not exists (handled by Docker) +-- CREATE DATABASE kixi; + +-- ============================================================================= +-- School Years Table +-- ============================================================================= +CREATE TABLE IF NOT EXISTS school_years ( + id BIGSERIAL PRIMARY KEY, + start_year INTEGER NOT NULL, + end_year INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + CONSTRAINT uk_school_years UNIQUE (start_year, end_year), + CONSTRAINT ck_school_years_interval CHECK (end_year > start_year) +); + +-- ============================================================================= +-- Terms Table (Trimesters/Periods) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS terms ( + id BIGSERIAL PRIMARY KEY, + number INTEGER NOT NULL, + name VARCHAR(100) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + CONSTRAINT uk_terms_number UNIQUE (number) +); + +-- ============================================================================= +-- Subjects Table +-- ============================================================================= +CREATE TABLE IF NOT EXISTS subjects ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL, + name VARCHAR(200) NOT NULL, + short_name VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + CONSTRAINT uk_subjects_code UNIQUE (code) +); + +-- ============================================================================= +-- Courses Table +-- ============================================================================= +CREATE TABLE IF NOT EXISTS courses ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL, + name VARCHAR(200) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + CONSTRAINT uk_courses_code UNIQUE (code) +); + +-- ============================================================================= +-- Classes Table +-- ============================================================================= +CREATE TABLE IF NOT EXISTS classes ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50), + grade INTEGER NOT NULL, + course_id BIGINT REFERENCES courses(id), + school_year_id BIGINT REFERENCES school_years(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_classes_course ON classes(course_id); +CREATE INDEX IF NOT EXISTS idx_classes_school_year ON classes(school_year_id); + +-- ============================================================================= +-- Accounts Table +-- ============================================================================= +CREATE TABLE IF NOT EXISTS accounts ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(100) NOT NULL, + email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + email_verified BOOLEAN DEFAULT FALSE, + last_login TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + CONSTRAINT uk_accounts_username UNIQUE (username), + CONSTRAINT uk_accounts_email UNIQUE (email) +); + +-- ============================================================================= +-- Users Table (Profile information) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + account_id BIGINT NOT NULL REFERENCES accounts(id), + first_name VARCHAR(100), + last_name VARCHAR(100), + photo VARCHAR(500), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + CONSTRAINT uk_users_account UNIQUE (account_id) +); + +-- ============================================================================= +-- Roles Table +-- ============================================================================= +CREATE TABLE IF NOT EXISTS roles ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(50) NOT NULL, + description VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + CONSTRAINT uk_roles_name UNIQUE (name) +); + +-- ============================================================================= +-- Account Roles Table (Many-to-Many) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS account_roles ( + account_id BIGINT NOT NULL REFERENCES accounts(id), + role_id BIGINT NOT NULL REFERENCES roles(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + PRIMARY KEY (account_id, role_id) +); + +-- ============================================================================= +-- Sessions Table +-- ============================================================================= +CREATE TABLE IF NOT EXISTS sessions ( + id BIGSERIAL PRIMARY KEY, + account_id BIGINT NOT NULL REFERENCES accounts(id), + token VARCHAR(500) NOT NULL, + ip_address VARCHAR(50), + expires_at TIMESTAMP NOT NULL, + last_used TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_sessions_account ON sessions(account_id); +CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); + +-- ============================================================================= +-- Statements Table (Exam Papers) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS statements ( + id BIGSERIAL PRIMARY KEY, + exam_type VARCHAR(100), + duration_minutes INTEGER, + variant VARCHAR(10), + title VARCHAR(500), + instructions TEXT, + total_max_score DECIMAL(10, 2), + school_year_id BIGINT REFERENCES school_years(id), + term_id BIGINT REFERENCES terms(id), + subject_id BIGINT REFERENCES subjects(id), + class_id BIGINT REFERENCES classes(id), + course_id BIGINT REFERENCES courses(id), + created_by BIGINT REFERENCES accounts(id), + visible BOOLEAN DEFAULT FALSE, + needs_review BOOLEAN DEFAULT FALSE, + ocr_confidence DECIMAL(5, 4), + ocr_request_id VARCHAR(100), + source VARCHAR(50) DEFAULT 'manual', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_statements_school_year ON statements(school_year_id); +CREATE INDEX IF NOT EXISTS idx_statements_term ON statements(term_id); +CREATE INDEX IF NOT EXISTS idx_statements_subject ON statements(subject_id); +CREATE INDEX IF NOT EXISTS idx_statements_class ON statements(class_id); +CREATE INDEX IF NOT EXISTS idx_statements_created_by ON statements(created_by); +CREATE INDEX IF NOT EXISTS idx_statements_visible ON statements(visible); +CREATE INDEX IF NOT EXISTS idx_statements_needs_review ON statements(needs_review); +CREATE INDEX IF NOT EXISTS idx_statements_source ON statements(source); +CREATE INDEX IF NOT EXISTS idx_statements_deleted_at ON statements(deleted_at); + +-- ============================================================================= +-- Questions Table +-- ============================================================================= +CREATE TABLE IF NOT EXISTS questions ( + id BIGSERIAL PRIMARY KEY, + statement_id BIGINT NOT NULL REFERENCES statements(id) ON DELETE CASCADE, + number INTEGER NOT NULL, + text TEXT NOT NULL, + question_type VARCHAR(50) NOT NULL DEFAULT 'unknown', + max_score DECIMAL(10, 2), + order_index INTEGER, + ocr_confidence DECIMAL(5, 4), + page_index INTEGER DEFAULT 0, + needs_review BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + CONSTRAINT uk_questions_statement_number UNIQUE (statement_id, number) +); + +CREATE INDEX IF NOT EXISTS idx_questions_statement ON questions(statement_id); +CREATE INDEX IF NOT EXISTS idx_questions_type ON questions(question_type); +CREATE INDEX IF NOT EXISTS idx_questions_deleted_at ON questions(deleted_at); + +-- ============================================================================= +-- Question Images Table +-- ============================================================================= +CREATE TABLE IF NOT EXISTS question_images ( + id BIGSERIAL PRIMARY KEY, + question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + image_url VARCHAR(1000) NOT NULL, + caption VARCHAR(500), + order_index INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_question_images_question ON question_images(question_id); + +-- ============================================================================= +-- Question Options Table (Multiple Choice Answers) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS question_options ( id BIGSERIAL PRIMARY KEY, - ano_inicio INT NOT NULL, - ano_fim INT NOT NULL, - CONSTRAINT uk_anos_letivos UNIQUE (ano_inicio, ano_fim), - CONSTRAINT ck_anos_letivos_intervalo CHECK (ano_fim > ano_inicio) + question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + option_label VARCHAR(10) NOT NULL, + option_text TEXT NOT NULL, + is_correct BOOLEAN DEFAULT FALSE, + order_index INTEGER DEFAULT 0, + ocr_confidence DECIMAL(5, 4), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + CONSTRAINT uk_question_options_label UNIQUE (question_id, option_label) ); + +CREATE INDEX IF NOT EXISTS idx_question_options_question ON question_options(question_id); +CREATE INDEX IF NOT EXISTS idx_question_options_deleted_at ON question_options(deleted_at); + +-- ============================================================================= +-- Simulations Table (Student Exam Attempts) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS simulations ( + id BIGSERIAL PRIMARY KEY, + account_id BIGINT NOT NULL REFERENCES accounts(id), + statement_id BIGINT NOT NULL REFERENCES statements(id), + school_year_id BIGINT REFERENCES school_years(id), + started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at TIMESTAMP, + time_spent_seconds INTEGER, + final_score DECIMAL(10, 2), + status VARCHAR(50) DEFAULT 'in_progress', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_simulations_account ON simulations(account_id); +CREATE INDEX IF NOT EXISTS idx_simulations_statement ON simulations(statement_id); +CREATE INDEX IF NOT EXISTS idx_simulations_status ON simulations(status); + +-- ============================================================================= +-- Simulation Answers Table +-- ============================================================================= +CREATE TABLE IF NOT EXISTS simulation_answers ( + id BIGSERIAL PRIMARY KEY, + simulation_id BIGINT NOT NULL REFERENCES simulations(id) ON DELETE CASCADE, + question_id BIGINT NOT NULL REFERENCES questions(id), + selected_option_id BIGINT REFERENCES question_options(id), + answer_text TEXT, + score_obtained DECIMAL(10, 2), + is_correct BOOLEAN, + answered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_simulation_answers_simulation ON simulation_answers(simulation_id); +CREATE INDEX IF NOT EXISTS idx_simulation_answers_question ON simulation_answers(question_id); + +-- ============================================================================= +-- Default Data +-- ============================================================================= + +-- Insert default roles +INSERT INTO roles (name, description) VALUES + ('ADMIN', 'System administrator with full access'), + ('TEACHER', 'Teacher with access to create and manage statements'), + ('STUDENT', 'Student with access to view statements and take simulations') +ON CONFLICT (name) DO NOTHING; + +-- Insert default terms +INSERT INTO terms (number, name) VALUES + (1, '1º Trimestre'), + (2, '2º Trimestre'), + (3, '3º Trimestre') +ON CONFLICT (number) DO NOTHING; + +-- Insert sample subjects +INSERT INTO subjects (code, name, short_name) VALUES + ('MAT', 'Matemática', 'Mat'), + ('PORT', 'Língua Portuguesa', 'Port'), + ('FIS', 'Física', 'Fís'), + ('QUIM', 'Química', 'Quím'), + ('BIO', 'Biologia', 'Bio'), + ('HIST', 'História', 'Hist'), + ('GEO', 'Geografia', 'Geo'), + ('ING', 'Inglês', 'Ing'), + ('FIL', 'Filosofia', 'Fil') +ON CONFLICT (code) DO NOTHING; + +-- Insert sample school year +INSERT INTO school_years (start_year, end_year) VALUES + (2024, 2025) +ON CONFLICT (start_year, end_year) DO NOTHING; + +-- ============================================================================= +-- Functions and Triggers +-- ============================================================================= + +-- Function to update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Apply trigger to all tables with updated_at column +DO $$ +DECLARE + t text; +BEGIN + FOR t IN + SELECT table_name FROM information_schema.columns + WHERE column_name = 'updated_at' + AND table_schema = 'public' + LOOP + EXECUTE format(' + DROP TRIGGER IF EXISTS update_%I_updated_at ON %I; + CREATE TRIGGER update_%I_updated_at + BEFORE UPDATE ON %I + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + ', t, t, t, t); + END LOOP; +END; +$$ language 'plpgsql'; + +-- ============================================================================= +-- Grant Permissions (if needed for specific users) +-- ============================================================================= +-- GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO kixi; +-- GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO kixi; diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/client/OcrServiceClient.java b/services/backend-api/src/main/java/ao/creativemode/kixi/client/OcrServiceClient.java new file mode 100644 index 0000000..14eee48 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/client/OcrServiceClient.java @@ -0,0 +1,294 @@ +package ao.creativemode.kixi.client; + +import ao.creativemode.kixi.dto.ocr.OcrResponse; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.http.codec.multipart.FilePart; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientRequestException; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; + +import java.time.Duration; +import java.util.List; + +/** + * WebClient-based client for the OCR microservice. + * + * This client handles all communication with the OCR service for + * extracting text from images and PDFs. + * + * Features: + * - Reactive non-blocking HTTP calls + * - Automatic retry with exponential backoff + * - Timeout configuration + * - Error handling and mapping + */ +@Component +public class OcrServiceClient { + + private static final Logger log = LoggerFactory.getLogger(OcrServiceClient.class); + + private final WebClient webClient; + private final Duration timeout; + private final int maxRetries; + + /** + * Construct the OCR service client. + * + * @param ocrServiceUrl Base URL of the OCR service (e.g., http://ocr-service:8000) + * @param timeoutMs Request timeout in milliseconds + * @param maxRetries Maximum number of retry attempts + */ + public OcrServiceClient( + @Value("${ocr.service.url:http://localhost:8000}") String ocrServiceUrl, + @Value("${ocr.service.timeout-ms:120000}") long timeoutMs, + @Value("${ocr.service.max-retries:2}") int maxRetries) { + + this.timeout = Duration.ofMillis(timeoutMs); + this.maxRetries = maxRetries; + + this.webClient = WebClient.builder() + .baseUrl(ocrServiceUrl) + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .codecs(configurer -> configurer + .defaultCodecs() + .maxInMemorySize(50 * 1024 * 1024)) // 50MB for large images + .build(); + + log.info("OCR Service Client initialized: url={}, timeout={}ms, maxRetries={}", + ocrServiceUrl, timeoutMs, maxRetries); + } + + /** + * Extract text from uploaded file parts. + * + * @param files List of uploaded file parts (images or PDFs) + * @return Mono containing the OCR response + */ + public Mono extractText(List files) { + if (files == null || files.isEmpty()) { + return Mono.error(new IllegalArgumentException("At least one file is required")); + } + + log.info("Sending OCR request: {} file(s)", files.size()); + + MultipartBodyBuilder builder = new MultipartBodyBuilder(); + + for (FilePart file : files) { + builder.asyncPart("images", file.content(), DataBuffer.class) + .filename(file.filename()) + .contentType(getContentType(file.filename())); + } + + return webClient.post() + .uri("/ocr/v1/extract") + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromMultipartData(builder.build())) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, response -> + response.bodyToMono(String.class) + .flatMap(body -> Mono.error(new OcrClientException( + "OCR request failed: " + body, + response.statusCode().value())))) + .onStatus(HttpStatusCode::is5xxServerError, response -> + response.bodyToMono(String.class) + .flatMap(body -> Mono.error(new OcrServerException( + "OCR service error: " + body, + response.statusCode().value())))) + .bodyToMono(OcrResponse.class) + .timeout(timeout) + .retryWhen(Retry.backoff(maxRetries, Duration.ofSeconds(1)) + .filter(this::isRetryable) + .doBeforeRetry(signal -> log.warn( + "Retrying OCR request, attempt {}: {}", + signal.totalRetries() + 1, + signal.failure().getMessage()))) + .doOnSuccess(response -> log.info( + "OCR request successful: requestId={}, status={}, confidence={}", + response.requestId(), + response.status(), + response.overallConfidence())) + .doOnError(error -> log.error("OCR request failed", error)); + } + + /** + * Extract text from raw image bytes. + * + * @param imageBytes Raw image bytes + * @param filename Original filename for content type detection + * @return Mono containing the OCR response + */ + public Mono extractTextFromBytes(byte[] imageBytes, String filename) { + if (imageBytes == null || imageBytes.length == 0) { + return Mono.error(new IllegalArgumentException("Image bytes cannot be empty")); + } + + log.info("Sending OCR request for single image: {} ({} bytes)", filename, imageBytes.length); + + MultipartBodyBuilder builder = new MultipartBodyBuilder(); + builder.part("images", imageBytes) + .filename(filename) + .contentType(getContentType(filename)); + + return webClient.post() + .uri("/ocr/v1/extract") + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromMultipartData(builder.build())) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, response -> + response.bodyToMono(String.class) + .flatMap(body -> Mono.error(new OcrClientException( + "OCR request failed: " + body, + response.statusCode().value())))) + .onStatus(HttpStatusCode::is5xxServerError, response -> + response.bodyToMono(String.class) + .flatMap(body -> Mono.error(new OcrServerException( + "OCR service error: " + body, + response.statusCode().value())))) + .bodyToMono(OcrResponse.class) + .timeout(timeout) + .retryWhen(Retry.backoff(maxRetries, Duration.ofSeconds(1)) + .filter(this::isRetryable)) + .doOnSuccess(response -> log.info( + "OCR request successful: requestId={}, status={}", + response.requestId(), + response.status())) + .doOnError(error -> log.error("OCR request failed", error)); + } + + /** + * Simple health check for the OCR service. + * + * @return Mono containing true if the service is healthy + */ + public Mono healthCheck() { + return webClient.get() + .uri("/health") + .retrieve() + .bodyToMono(String.class) + .map(response -> true) + .timeout(Duration.ofSeconds(10)) + .onErrorReturn(false) + .doOnNext(healthy -> log.debug("OCR service health check: {}", healthy ? "OK" : "FAILED")); + } + + /** + * Get supported languages from the OCR service. + * + * @return Mono containing the languages response + */ + public Mono getSupportedLanguages() { + return webClient.get() + .uri("/ocr/v1/supported-languages") + .retrieve() + .bodyToMono(SupportedLanguagesResponse.class) + .timeout(Duration.ofSeconds(10)); + } + + /** + * Determine if an exception is retryable. + */ + private boolean isRetryable(Throwable throwable) { + // Retry on network errors and 5xx server errors + if (throwable instanceof WebClientRequestException) { + return true; + } + if (throwable instanceof OcrServerException) { + return true; + } + if (throwable instanceof WebClientResponseException ex) { + return ex.getStatusCode().is5xxServerError(); + } + return false; + } + + /** + * Get content type based on file extension. + */ + private MediaType getContentType(String filename) { + if (filename == null) { + return MediaType.APPLICATION_OCTET_STREAM; + } + + String lowerFilename = filename.toLowerCase(); + + if (lowerFilename.endsWith(".jpg") || lowerFilename.endsWith(".jpeg")) { + return MediaType.IMAGE_JPEG; + } else if (lowerFilename.endsWith(".png")) { + return MediaType.IMAGE_PNG; + } else if (lowerFilename.endsWith(".pdf")) { + return MediaType.APPLICATION_PDF; + } else if (lowerFilename.endsWith(".webp")) { + return MediaType.parseMediaType("image/webp"); + } else if (lowerFilename.endsWith(".gif")) { + return MediaType.IMAGE_GIF; + } else if (lowerFilename.endsWith(".bmp")) { + return MediaType.parseMediaType("image/bmp"); + } else if (lowerFilename.endsWith(".tiff") || lowerFilename.endsWith(".tif")) { + return MediaType.parseMediaType("image/tiff"); + } + + return MediaType.APPLICATION_OCTET_STREAM; + } + + /** + * Response for supported languages endpoint. + */ + public record SupportedLanguagesResponse( + List languages, + String defaultLanguage + ) { + public record LanguageInfo( + String code, + String name, + boolean primary + ) {} + } + + /** + * Exception for client-side (4xx) errors from the OCR service. + */ + public static class OcrClientException extends RuntimeException { + private final int statusCode; + + public OcrClientException(String message, int statusCode) { + super(message); + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } + } + + /** + * Exception for server-side (5xx) errors from the OCR service. + */ + public static class OcrServerException extends RuntimeException { + private final int statusCode; + + public OcrServerException(String message, int statusCode) { + super(message); + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/common/exception/ApiException.java b/services/backend-api/src/main/java/ao/creativemode/kixi/common/exception/ApiException.java index 0339cff..5f81cf1 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/common/exception/ApiException.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/common/exception/ApiException.java @@ -2,18 +2,38 @@ import org.springframework.http.HttpStatus; +/** + * Custom API exception with HTTP status support. + * + * Provides factory methods for common HTTP error statuses + * and allows the status to be retrieved for proper response handling. + */ public class ApiException extends RuntimeException { + private final HttpStatus status; private final String title; private final String code; + /** + * Create an API exception with status and message. + * + * @param status HTTP status code + * @param message Error message + */ public ApiException(HttpStatus status, String message) { super(message); this.status = status; - this.title = null; + this.title = status.getReasonPhrase(); this.code = null; } + /** + * Create an API exception with status, title, and message. + * + * @param status HTTP status code + * @param title Error title + * @param message Error message + */ public ApiException(HttpStatus status, String title, String message) { super(message); this.status = status; @@ -21,19 +41,266 @@ public ApiException(HttpStatus status, String title, String message) { this.code = null; } + /** + * Create an API exception with status, title, message, and code. + * + * @param status HTTP status code + * @param title Error title + * @param message Error message + * @param code Error code for programmatic handling + */ + public ApiException( + HttpStatus status, + String title, + String message, + String code + ) { + super(message); + this.status = status; + this.title = title; + this.code = code; + } + + /** + * Get the HTTP status code. + * + * @return HTTP status + */ public HttpStatus getStatus() { return status; } - public static ApiException notFound(String message) { - return new ApiException(HttpStatus.NOT_FOUND, "Not Found", message); + /** + * Get the HTTP status code as integer. + * + * @return HTTP status code value + */ + public int getStatusCode() { + return status.value(); } + /** + * Get the error title. + * + * @return Error title + */ + public String getTitle() { + return title; + } + + /** + * Get the error code. + * + * @return Error code or null if not set + */ + public String getCode() { + return code; + } + + // ========================================================================= + // Factory methods for common HTTP errors + // ========================================================================= + + /** + * Create a 400 Bad Request exception. + * + * @param message Error message + * @return ApiException with BAD_REQUEST status + */ public static ApiException badRequest(String message) { return new ApiException(HttpStatus.BAD_REQUEST, "Bad Request", message); } + /** + * Create a 400 Bad Request exception with custom title. + * + * @param title Error title + * @param message Error message + * @return ApiException with BAD_REQUEST status + */ + public static ApiException badRequest(String title, String message) { + return new ApiException(HttpStatus.BAD_REQUEST, title, message); + } + + /** + * Create a 401 Unauthorized exception. + * + * @param message Error message + * @return ApiException with UNAUTHORIZED status + */ + public static ApiException unauthorized(String message) { + return new ApiException( + HttpStatus.UNAUTHORIZED, + "Unauthorized", + message + ); + } + + /** + * Create a 403 Forbidden exception. + * + * @param message Error message + * @return ApiException with FORBIDDEN status + */ + public static ApiException forbidden(String message) { + return new ApiException(HttpStatus.FORBIDDEN, "Forbidden", message); + } + + /** + * Create a 404 Not Found exception. + * + * @param message Error message + * @return ApiException with NOT_FOUND status + */ + public static ApiException notFound(String message) { + return new ApiException(HttpStatus.NOT_FOUND, "Not Found", message); + } + + /** + * Create a 404 Not Found exception for a specific resource. + * + * @param resourceName Name of the resource (e.g., "Statement", "Question") + * @param resourceId ID of the resource + * @return ApiException with NOT_FOUND status + */ + public static ApiException notFound( + String resourceName, + Object resourceId + ) { + return new ApiException( + HttpStatus.NOT_FOUND, + "Not Found", + String.format("%s with ID %s not found", resourceName, resourceId) + ); + } + + /** + * Create a 409 Conflict exception. + * + * @param message Error message + * @return ApiException with CONFLICT status + */ public static ApiException conflict(String message) { return new ApiException(HttpStatus.CONFLICT, "Conflict", message); } -} \ No newline at end of file + + /** + * Create a 409 Conflict exception for duplicate resource. + * + * @param resourceName Name of the resource + * @param field Field that caused the conflict + * @param value Value that already exists + * @return ApiException with CONFLICT status + */ + public static ApiException duplicate( + String resourceName, + String field, + Object value + ) { + return new ApiException( + HttpStatus.CONFLICT, + "Duplicate Resource", + String.format( + "%s with %s '%s' already exists", + resourceName, + field, + value + ) + ); + } + + /** + * Create a 422 Unprocessable Entity exception. + * + * @param message Error message + * @return ApiException with UNPROCESSABLE_ENTITY status + */ + public static ApiException unprocessableEntity(String message) { + return new ApiException( + HttpStatus.UNPROCESSABLE_ENTITY, + "Unprocessable Entity", + message + ); + } + + /** + * Create a 500 Internal Server Error exception. + * + * @param message Error message + * @return ApiException with INTERNAL_SERVER_ERROR status + */ + public static ApiException internalError(String message) { + return new ApiException( + HttpStatus.INTERNAL_SERVER_ERROR, + "Internal Server Error", + message + ); + } + + /** + * Create a 500 Internal Server Error exception with cause. + * + * @param message Error message + * @param cause Original exception + * @return ApiException with INTERNAL_SERVER_ERROR status + */ + public static ApiException internalError(String message, Throwable cause) { + ApiException exception = new ApiException( + HttpStatus.INTERNAL_SERVER_ERROR, + "Internal Server Error", + message + ); + exception.initCause(cause); + return exception; + } + + /** + * Create a 502 Bad Gateway exception. + * + * @param message Error message + * @return ApiException with BAD_GATEWAY status + */ + public static ApiException badGateway(String message) { + return new ApiException(HttpStatus.BAD_GATEWAY, "Bad Gateway", message); + } + + /** + * Create a 503 Service Unavailable exception. + * + * @param message Error message + * @return ApiException with SERVICE_UNAVAILABLE status + */ + public static ApiException serviceUnavailable(String message) { + return new ApiException( + HttpStatus.SERVICE_UNAVAILABLE, + "Service Unavailable", + message + ); + } + + /** + * Create a 504 Gateway Timeout exception. + * + * @param message Error message + * @return ApiException with GATEWAY_TIMEOUT status + */ + public static ApiException gatewayTimeout(String message) { + return new ApiException( + HttpStatus.GATEWAY_TIMEOUT, + "Gateway Timeout", + message + ); + } + + @Override + public String toString() { + return String.format( + "ApiException{status=%d %s, title='%s', message='%s', code='%s'}", + status.value(), + status.getReasonPhrase(), + title, + getMessage(), + code + ); + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/common/exception/GlobalExceptionHandler.java b/services/backend-api/src/main/java/ao/creativemode/kixi/common/exception/GlobalExceptionHandler.java index b0b9a8f..c208121 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/common/exception/GlobalExceptionHandler.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/common/exception/GlobalExceptionHandler.java @@ -1,5 +1,7 @@ package ao.creativemode.kixi.common.exception; +import ao.creativemode.kixi.client.OcrServiceClient.OcrClientException; +import ao.creativemode.kixi.client.OcrServiceClient.OcrServerException; import ao.creativemode.kixi.common.dto.ProblemDetail; import java.net.URI; import java.util.Map; @@ -29,6 +31,9 @@ public class GlobalExceptionHandler { "https://api.kixi.com/errors" ); + /** + * Handle custom API exceptions with proper status codes. + */ @ExceptionHandler(ApiException.class) public Mono> handleApiException( ApiException ex, @@ -49,6 +54,9 @@ public Mono> handleApiException( return Mono.just(ResponseEntity.status(statusCode).body(problem)); } + /** + * Handle validation errors from request body binding. + */ @ExceptionHandler(WebExchangeBindException.class) public Mono> handleValidationErrors( WebExchangeBindException ex, @@ -88,6 +96,112 @@ public Mono> handleValidationErrors( return Mono.just(ResponseEntity.badRequest().body(problem)); } + /** + * Handle OCR client exceptions (4xx errors from OCR service). + */ + @ExceptionHandler(OcrClientException.class) + public Mono> handleOcrClientException( + OcrClientException ex, + ServerWebExchange exchange + ) { + log.warn( + "OCR client error: status={}, message={}", + ex.getStatusCode(), + ex.getMessage() + ); + + ProblemDetail problem = new ProblemDetail( + OCR_ERROR_TYPE, + "OCR Processing Error", + ex.getStatusCode(), + ex.getMessage(), + Map.of("service", "ocr-service", "errorType", "client_error") + ); + + problem = addInstance(exchange, problem); + + return Mono.just( + ResponseEntity.status(ex.getStatusCode()).body(problem) + ); + } + + /** + * Handle OCR server exceptions (5xx errors from OCR service). + */ + @ExceptionHandler(OcrServerException.class) + public Mono> handleOcrServerException( + OcrServerException ex, + ServerWebExchange exchange + ) { + log.error( + "OCR server error: status={}, message={}", + ex.getStatusCode(), + ex.getMessage() + ); + + ProblemDetail problem = new ProblemDetail( + OCR_ERROR_TYPE, + "OCR Service Unavailable", + HttpStatus.SERVICE_UNAVAILABLE.value(), + "The OCR service is temporarily unavailable. Please try again later.", + Map.of("service", "ocr-service", "errorType", "server_error") + ); + + problem = addInstance(exchange, problem); + + return Mono.just( + ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(problem) + ); + } + + /** + * Handle timeout exceptions from OCR service calls. + */ + @ExceptionHandler(TimeoutException.class) + public Mono> handleTimeoutException( + TimeoutException ex, + ServerWebExchange exchange + ) { + log.error("Request timeout: {}", ex.getMessage()); + + ProblemDetail problem = new ProblemDetail( + URI.create("https://api.kixi.ao/errors/timeout"), + "Request Timeout", + HttpStatus.GATEWAY_TIMEOUT.value(), + "The request took too long to process. Please try again with a smaller file or fewer images.", + Map.of("errorType", "timeout") + ); + + problem = addInstance(exchange, problem); + + return Mono.just( + ResponseEntity.status(HttpStatus.GATEWAY_TIMEOUT).body(problem) + ); + } + + /** + * Handle illegal argument exceptions (bad requests). + */ + @ExceptionHandler(IllegalArgumentException.class) + public Mono> handleIllegalArgumentException( + IllegalArgumentException ex, + ServerWebExchange exchange + ) { + log.warn("Illegal argument: {}", ex.getMessage()); + + ProblemDetail problem = ProblemDetail.forStatusAndDetail( + HttpStatus.BAD_REQUEST.value(), + ex.getMessage() != null ? ex.getMessage() : "Invalid request" + ).withTitle("Bad Request"); + + problem = addInstance(exchange, problem); + + return Mono.just(ResponseEntity.badRequest().body(problem)); + } + + /** + * Handle all other uncaught exceptions. + */ @ExceptionHandler(Exception.class) public Mono> handleGenericException( Exception ex, @@ -100,6 +214,7 @@ public Mono> handleGenericException( "An unexpected error occurred on the server. Please try again later." ).withTitle("Internal Server Error"); problem = addInstance(exchange, problem); + return Mono.just(ResponseEntity.internalServerError().body(problem)); } diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/OcrController.java b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/OcrController.java new file mode 100644 index 0000000..7db7c53 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/OcrController.java @@ -0,0 +1,224 @@ +package ao.creativemode.kixi.controller; + +import ao.creativemode.kixi.client.OcrServiceClient; +import ao.creativemode.kixi.dto.ocr.OcrResponse; +import ao.creativemode.kixi.common.exception.ApiException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.codec.multipart.FilePart; +import org.springframework.web.bind.annotation.*; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * REST Controller for OCR operations. + * + * Provides endpoints for extracting text from images and PDFs + * using the OCR microservice. + * + * Endpoints: + * - POST /api/v1/ocr/extract - Extract text from uploaded images + * - GET /api/v1/ocr/health - Check OCR service health + * - GET /api/v1/ocr/languages - Get supported OCR languages + */ +@RestController +@RequestMapping("/api/v1/ocr") +public class OcrController { + + private static final Logger log = LoggerFactory.getLogger(OcrController.class); + + private static final Set ALLOWED_EXTENSIONS = Set.of( + ".jpg", ".jpeg", ".png", ".pdf", ".webp", ".bmp", ".tiff", ".tif" + ); + + private static final long MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB + private static final int MAX_FILES = 10; + + private final OcrServiceClient ocrServiceClient; + + public OcrController(OcrServiceClient ocrServiceClient) { + this.ocrServiceClient = ocrServiceClient; + } + + /** + * Extract text from uploaded images/PDFs. + * + * @param files List of uploaded file parts (images or PDFs) + * @return OCR extraction result with structured data + */ + @PostMapping(value = "/extract", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public Mono> extractText( + @RequestPart("files") Flux files) { + + log.info("OCR extraction request received"); + + return files + .collectList() + .flatMap(fileList -> { + // Validate file count + if (fileList.isEmpty()) { + return Mono.error(ApiException.badRequest("At least one file is required")); + } + if (fileList.size() > MAX_FILES) { + return Mono.error(ApiException.badRequest( + "Maximum " + MAX_FILES + " files allowed per request")); + } + + // Validate file types + for (FilePart file : fileList) { + if (!isAllowedFileType(file.filename())) { + return Mono.error(ApiException.badRequest( + "Invalid file type: " + file.filename() + + ". Allowed: " + String.join(", ", ALLOWED_EXTENSIONS))); + } + } + + log.info("Processing {} file(s) for OCR extraction", fileList.size()); + + // Call OCR service + return ocrServiceClient.extractText(fileList); + }) + .map(ocrResponse -> { + // Return appropriate status based on OCR result + if (ocrResponse.isSuccess()) { + return ResponseEntity.ok(ocrResponse); + } else if (ocrResponse.isPartial()) { + return ResponseEntity.status(HttpStatus.MULTI_STATUS).body(ocrResponse); + } else { + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(ocrResponse); + } + }) + .doOnSuccess(response -> log.info( + "OCR extraction completed: status={}", + response.getStatusCode())) + .doOnError(error -> log.error("OCR extraction failed", error)); + } + + /** + * Extract text from a single uploaded image/PDF. + * + * Simplified endpoint for single-file extraction. + * + * @param file Single uploaded file part + * @return OCR extraction result with structured data + */ + @PostMapping(value = "/extract/single", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public Mono> extractTextSingle( + @RequestPart("file") FilePart file) { + + log.info("Single-file OCR extraction request received: {}", file.filename()); + + // Validate file type + if (!isAllowedFileType(file.filename())) { + return Mono.error(ApiException.badRequest( + "Invalid file type: " + file.filename() + + ". Allowed: " + String.join(", ", ALLOWED_EXTENSIONS))); + } + + return ocrServiceClient.extractText(List.of(file)) + .map(ocrResponse -> { + if (ocrResponse.isSuccess()) { + return ResponseEntity.ok(ocrResponse); + } else if (ocrResponse.isPartial()) { + return ResponseEntity.status(HttpStatus.MULTI_STATUS).body(ocrResponse); + } else { + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(ocrResponse); + } + }) + .doOnSuccess(response -> log.info( + "Single-file OCR extraction completed: status={}", + response.getStatusCode())) + .doOnError(error -> log.error("Single-file OCR extraction failed", error)); + } + + /** + * Check OCR service health. + * + * @return Health status of the OCR service + */ + @GetMapping("/health") + public Mono>> checkHealth() { + return ocrServiceClient.healthCheck() + .map(healthy -> { + Map response = Map.of( + "service", "ocr-service", + "status", healthy ? "healthy" : "unhealthy", + "available", healthy + ); + return healthy + ? ResponseEntity.ok(response) + : ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response); + }) + .onErrorResume(error -> { + log.error("OCR health check failed", error); + Map response = Map.of( + "service", "ocr-service", + "status", "unavailable", + "available", false, + "error", error.getMessage() + ); + return Mono.just(ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response)); + }); + } + + /** + * Get supported OCR languages. + * + * @return List of supported languages + */ + @GetMapping("/languages") + public Mono> getSupportedLanguages() { + return ocrServiceClient.getSupportedLanguages() + .map(ResponseEntity::ok) + .onErrorResume(error -> { + log.error("Failed to get supported languages", error); + return Mono.error(ApiException.badRequest("Failed to retrieve supported languages")); + }); + } + + /** + * Get OCR service information. + * + * @return Information about the OCR service capabilities + */ + @GetMapping("/info") + public Mono>> getInfo() { + return Mono.just(ResponseEntity.ok(Map.of( + "service", "OCR Service", + "version", "1.0.0", + "supportedFormats", List.of("JPEG", "PNG", "PDF", "WebP", "BMP", "TIFF"), + "maxFileSize", MAX_FILE_SIZE, + "maxFiles", MAX_FILES, + "features", List.of( + "Text extraction from images", + "PDF multi-page support", + "Question detection and segmentation", + "Multiple choice option extraction", + "Metadata extraction (school year, subject, etc.)", + "Confidence scores for all extracted data", + "Portuguese language optimization" + ) + ))); + } + + /** + * Validate file extension. + */ + private boolean isAllowedFileType(String filename) { + if (filename == null || filename.isBlank()) { + return false; + } + + String lowerFilename = filename.toLowerCase(); + return ALLOWED_EXTENSIONS.stream().anyMatch(lowerFilename::endsWith); + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/StatementController.java b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/StatementController.java index 598b0e7..d3d382a 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/StatementController.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/StatementController.java @@ -1,88 +1,510 @@ package ao.creativemode.kixi.controller; -import java.util.List; +import ao.creativemode.kixi.model.Statement; +import ao.creativemode.kixi.model.Question; +import ao.creativemode.kixi.model.QuestionOption; +import ao.creativemode.kixi.service.StatementService; +import ao.creativemode.kixi.service.StatementService.StatementWithQuestions; +import ao.creativemode.kixi.common.exception.ApiException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import ao.creativemode.kixi.dto.statement.StatementRequest; -import ao.creativemode.kixi.dto.statement.StatementResponse; -import ao.creativemode.kixi.service.StatementService; -import jakarta.validation.Valid; +import org.springframework.http.codec.multipart.FilePart; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.util.UriComponentsBuilder; + +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Set; +/** + * REST Controller for Statement (exam paper) management. + * + * Provides endpoints for: + * - CRUD operations on statements + * - OCR-based statement creation from images + * - Managing statement visibility and review status + * - Retrieving statements with their questions and options + * + * Base path: /api/v1/statements + */ @RestController -@RequestMapping("/api/statements") +@RequestMapping("/api/v1/statements") public class StatementController { - private final StatementService service; + private static final Logger log = LoggerFactory.getLogger(StatementController.class); + + private static final Set ALLOWED_EXTENSIONS = Set.of( + ".jpg", ".jpeg", ".png", ".pdf", ".webp", ".bmp", ".tiff", ".tif" + ); + + private static final int MAX_FILES = 10; + + private final StatementService statementService; + + public StatementController(StatementService statementService) { + this.statementService = statementService; + } + + // ========================================================================= + // OCR Endpoints + // ========================================================================= + + /** + * Create a statement from uploaded images using OCR. + * + * This endpoint receives image files, sends them to the OCR service, + * and creates a Statement with Questions based on the extracted data. + * + * @param files Uploaded image files (multipart/form-data) + * @param uriBuilder URI builder for location header + * @return Created statement with questions and options + */ + @PostMapping(value = "/ocr/extract", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public Mono> createFromOcr( + @RequestPart("files") Flux files, + UriComponentsBuilder uriBuilder) { + + log.info("OCR statement creation request received"); + + return files + .collectList() + .flatMap(fileList -> { + // Validate file count + if (fileList.isEmpty()) { + return Mono.error(ApiException.badRequest("At least one file is required")); + } + if (fileList.size() > MAX_FILES) { + return Mono.error(ApiException.badRequest( + "Maximum " + MAX_FILES + " files allowed per request")); + } + + // Validate file types + for (FilePart file : fileList) { + if (!isAllowedFileType(file.filename())) { + return Mono.error(ApiException.badRequest( + "Invalid file type: " + file.filename() + + ". Allowed: " + String.join(", ", ALLOWED_EXTENSIONS))); + } + } + + log.info("Processing {} file(s) for OCR-based statement creation", fileList.size()); + + // TODO: Get actual user ID from authentication context + Long createdBy = 1L; // Placeholder - public StatementController(StatementService service) { - this.service = service; + return statementService.createFromOcr(fileList, createdBy); + }) + .map(result -> { + URI location = uriBuilder + .path("/api/v1/statements/{id}") + .buildAndExpand(result.statement().getId()) + .toUri(); + + StatementOcrResponse response = StatementOcrResponse.from(result); + + return ResponseEntity.created(location).body(response); + }) + .doOnSuccess(response -> log.info( + "Statement created from OCR: id={}", + response.getBody() != null ? response.getBody().id() : null)) + .doOnError(error -> log.error("OCR statement creation failed", error)); + } + + /** + * Create a statement from a single uploaded image using OCR. + * + * Simplified endpoint for single-file uploads. + * + * @param file Single uploaded image file + * @param uriBuilder URI builder for location header + * @return Created statement with questions and options + */ + @PostMapping(value = "/ocr/extract/single", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public Mono> createFromOcrSingle( + @RequestPart("file") FilePart file, + UriComponentsBuilder uriBuilder) { + + log.info("Single-file OCR statement creation request received: {}", file.filename()); + + // Validate file type + if (!isAllowedFileType(file.filename())) { + return Mono.error(ApiException.badRequest( + "Invalid file type: " + file.filename() + + ". Allowed: " + String.join(", ", ALLOWED_EXTENSIONS))); + } + + // TODO: Get actual user ID from authentication context + Long createdBy = 1L; // Placeholder + + return statementService.createFromOcr(List.of(file), createdBy) + .map(result -> { + URI location = uriBuilder + .path("/api/v1/statements/{id}") + .buildAndExpand(result.statement().getId()) + .toUri(); + + StatementOcrResponse response = StatementOcrResponse.from(result); + + return ResponseEntity.created(location).body(response); + }) + .doOnSuccess(response -> log.info( + "Statement created from single-file OCR: id={}", + response.getBody() != null ? response.getBody().id() : null)) + .doOnError(error -> log.error("Single-file OCR statement creation failed", error)); } + // ========================================================================= + // Standard CRUD Endpoints + // ========================================================================= + /** + * Get all active statements. + */ @GetMapping - public Mono>> listAllActive() { - return service.listAllActive() + public Mono>> listAllActive() { + return statementService.findAllActive() + .map(StatementSummary::from) + .collectList() + .map(ResponseEntity::ok); + } + + /** + * Get all statements that need review. + */ + @GetMapping("/review") + public Mono>> listNeedingReview() { + return statementService.findNeedingReview() + .map(StatementSummary::from) + .collectList() + .map(ResponseEntity::ok); + } + + /** + * Get all statements created via OCR. + */ + @GetMapping("/from-ocr") + public Mono>> listFromOcr() { + return statementService.findFromOcr() + .map(StatementSummary::from) .collectList() .map(ResponseEntity::ok); } - @GetMapping("/trashed") - public Mono>> listTrashed() { - return service.listTrashed() + /** + * Get all deleted (trashed) statements. + */ + @GetMapping("/trash") + public Mono>> listTrashed() { + return statementService.findAllDeleted() + .map(StatementSummary::from) .collectList() .map(ResponseEntity::ok); } - + /** + * Get a statement by ID. + */ @GetMapping("/{id}") - public Mono> getById(@PathVariable Long id) { - return service.getById(id) + public Mono> getById(@PathVariable Long id) { + return statementService.findById(id) + .map(StatementSummary::from) .map(ResponseEntity::ok); } + /** + * Get a statement with all its questions and options. + */ + @GetMapping("/{id}/full") + public Mono> getByIdWithQuestions(@PathVariable Long id) { + return statementService.findByIdWithQuestions(id) + .map(StatementOcrResponse::from) + .map(ResponseEntity::ok); + } - @PostMapping - public Mono> create(@Valid @RequestBody StatementRequest request) { - return service.create(request) - .map(response -> ResponseEntity.status(HttpStatus.CREATED).body(response)); + /** + * Search statements by title. + */ + @GetMapping("/search") + public Mono>> searchByTitle( + @RequestParam String query) { + return statementService.searchByTitle(query) + .map(StatementSummary::from) + .collectList() + .map(ResponseEntity::ok); } - @PutMapping("/{id}") - public Mono> update( - @PathVariable Long id, - @Valid @RequestBody StatementRequest request) { - return service.update(id, request) + /** + * Get statements by school year. + */ + @GetMapping("/by-school-year/{schoolYearId}") + public Mono>> getBySchoolYear( + @PathVariable Long schoolYearId) { + return statementService.findBySchoolYear(schoolYearId) + .map(StatementSummary::from) + .collectList() + .map(ResponseEntity::ok); + } + + /** + * Get statements by subject. + */ + @GetMapping("/by-subject/{subjectId}") + public Mono>> getBySubject( + @PathVariable Long subjectId) { + return statementService.findBySubject(subjectId) + .map(StatementSummary::from) + .collectList() .map(ResponseEntity::ok); } + /** + * Soft delete a statement. + */ @DeleteMapping("/{id}") public Mono> softDelete(@PathVariable Long id) { - return service.softDelete(id) - .thenReturn(ResponseEntity.noContent().build()); + return statementService.softDelete(id) + .thenReturn(ResponseEntity.noContent().build()); } - @PatchMapping("/{id}/restore") + /** + * Restore a soft-deleted statement. + */ + @PostMapping("/{id}/restore") public Mono> restore(@PathVariable Long id) { - return service.restore(id) - .thenReturn(ResponseEntity.noContent().build()); + return statementService.restore(id) + .thenReturn(ResponseEntity.ok().build()); } - @DeleteMapping("/{id}/hard") + /** + * Permanently delete a statement (only if already soft-deleted). + */ + @DeleteMapping("/{id}/purge") public Mono> hardDelete(@PathVariable Long id) { - return service.hardDelete(id) - .thenReturn(ResponseEntity.noContent().build()); + return statementService.hardDelete(id) + .thenReturn(ResponseEntity.noContent().build()); + } + + /** + * Approve review and make statement visible. + */ + @PostMapping("/{id}/approve") + public Mono> approveReview(@PathVariable Long id) { + return statementService.approveReview(id) + .map(StatementSummary::from) + .map(ResponseEntity::ok); + } + + /** + * Set statement visibility. + */ + @PatchMapping("/{id}/visibility") + public Mono> setVisibility( + @PathVariable Long id, + @RequestParam boolean visible) { + return statementService.setVisible(id, visible) + .map(StatementSummary::from) + .map(ResponseEntity::ok); + } + + // ========================================================================= + // Statistics Endpoints + // ========================================================================= + + /** + * Get statement statistics. + */ + @GetMapping("/stats") + public Mono>> getStatistics() { + return Mono.zip( + statementService.countActive(), + statementService.countNeedingReview(), + statementService.countBySource("ocr"), + statementService.countBySource("manual") + ).map(tuple -> Map.of( + "totalActive", tuple.getT1(), + "needingReview", tuple.getT2(), + "fromOcr", tuple.getT3(), + "manual", tuple.getT4() + )).map(ResponseEntity::ok); + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + /** + * Validate file extension. + */ + private boolean isAllowedFileType(String filename) { + if (filename == null || filename.isBlank()) { + return false; + } + + String lowerFilename = filename.toLowerCase(); + return ALLOWED_EXTENSIONS.stream().anyMatch(lowerFilename::endsWith); + } + + // ========================================================================= + // Response DTOs + // ========================================================================= + + /** + * Summary response for statement listing. + */ + public record StatementSummary( + Long id, + String title, + String examType, + Integer durationMinutes, + String variant, + Double totalMaxScore, + Boolean visible, + Boolean needsReview, + String source, + Double ocrConfidence, + Long schoolYearId, + Long termId, + Long subjectId, + Long classId + ) { + public static StatementSummary from(Statement statement) { + return new StatementSummary( + statement.getId(), + statement.getTitle(), + statement.getExamType(), + statement.getDurationMinutes(), + statement.getVariant(), + statement.getTotalMaxScore(), + statement.getVisible(), + statement.getNeedsReview(), + statement.getSource(), + statement.getOcrConfidence(), + statement.getSchoolYearId(), + statement.getTermId(), + statement.getSubjectId(), + statement.getClassId() + ); + } + } + + /** + * Full response including questions and options. + */ + public record StatementOcrResponse( + Long id, + String title, + String examType, + Integer durationMinutes, + String variant, + String instructions, + Double totalMaxScore, + Boolean visible, + Boolean needsReview, + String source, + Double ocrConfidence, + String ocrRequestId, + Long schoolYearId, + Long termId, + Long subjectId, + Long classId, + List questions + ) { + public static StatementOcrResponse from(StatementWithQuestions result) { + Statement s = result.statement(); + List questions = result.questions(); + List allOptions = result.options(); + + List questionResponses = questions.stream() + .map(q -> { + List options = allOptions.stream() + .filter(opt -> opt.getQuestionId().equals(q.getId())) + .map(OptionResponse::from) + .toList(); + return QuestionResponse.from(q, options); + }) + .toList(); + + return new StatementOcrResponse( + s.getId(), + s.getTitle(), + s.getExamType(), + s.getDurationMinutes(), + s.getVariant(), + s.getInstructions(), + s.getTotalMaxScore(), + s.getVisible(), + s.getNeedsReview(), + s.getSource(), + s.getOcrConfidence(), + s.getOcrRequestId(), + s.getSchoolYearId(), + s.getTermId(), + s.getSubjectId(), + s.getClassId(), + questionResponses + ); + } + } + + /** + * Question response DTO. + */ + public record QuestionResponse( + Long id, + Integer number, + String text, + String questionType, + Double maxScore, + Integer orderIndex, + Double ocrConfidence, + Integer pageIndex, + Boolean needsReview, + List options + ) { + public static QuestionResponse from(Question q, List options) { + return new QuestionResponse( + q.getId(), + q.getNumber(), + q.getText(), + q.getQuestionType(), + q.getMaxScore(), + q.getOrderIndex(), + q.getOcrConfidence(), + q.getPageIndex(), + q.getNeedsReview(), + options + ); + } + } + + /** + * Option response DTO. + */ + public record OptionResponse( + Long id, + String optionLabel, + String optionText, + Boolean isCorrect, + Integer orderIndex, + Double ocrConfidence + ) { + public static OptionResponse from(QuestionOption o) { + return new OptionResponse( + o.getId(), + o.getOptionLabel(), + o.getOptionText(), + o.getIsCorrect(), + o.getOrderIndex(), + o.getOcrConfidence() + ); + } } -} \ No newline at end of file +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/SubjectController.java b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/SubjectController.java index 3f979eb..d72ee20 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/SubjectController.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/SubjectController.java @@ -29,9 +29,9 @@ public Mono>> listAllActive(){ .map(ResponseEntity::ok); } - @GetMapping("/{id}") - public Mono> getByCode(@PathVariable Long id){ - return service.findByCodeActive(id) + @GetMapping("/{code}") + public Mono> getByCode(@PathVariable String code){ + return service.findByCodeActive(code) .map(ResponseEntity::ok); } @@ -42,6 +42,9 @@ public Mono>> listTrashed(){ .map(ResponseEntity::ok); } + + + @PostMapping public Mono> create( @Valid @RequestBody SubjectRequest request, @@ -50,38 +53,38 @@ public Mono> create( return service.create(request) .map(subject->{ URI uriLocal = uriBuilder - .path("/api/v1/subjects/{id}") - .buildAndExpand(subject.id()) + .path("/api/v1/subjects/{code}") + .buildAndExpand(subject.code()) .toUri(); return ResponseEntity.created(uriLocal).body(subject); }); } - @PutMapping("/{id}") + @PutMapping("/{code}") public Mono> update( - @PathVariable Long id, + @PathVariable String code, @Valid @RequestBody SubjectRequest data ){ - return service.update(id,data) + return service.update(code,data) .map(ResponseEntity::ok); } - @DeleteMapping("/{id}") - public Mono> softDelete(@PathVariable Long id){ - return service.softDelete(id) + @DeleteMapping("/{code}") + public Mono> softDelete(@PathVariable String code){ + return service.softDelete(code) .map(v->ResponseEntity.noContent().build()); } - @PostMapping("/{id}/restore") - public Mono> restore(@PathVariable Long id){ - return service.restore(id) + @PostMapping("/{code}/restore") + public Mono> restore(@PathVariable String code){ + return service.restore(code) .map(v->ResponseEntity.noContent().build()); } - @DeleteMapping("/{id}/purge") - public Mono> hardDelete(@PathVariable Long id){ - return service.hardDelete(id) + @DeleteMapping("/{code}/purge") + public Mono> hardDelete(@PathVariable String code){ + return service.hardDelete(code) .map(v->ResponseEntity.noContent().build()); } } diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ocr/OcrResponse.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ocr/OcrResponse.java new file mode 100644 index 0000000..fc74063 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ocr/OcrResponse.java @@ -0,0 +1,232 @@ +package ao.creativemode.kixi.dto.ocr; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; + +/** + * OCR Service Response DTO + * + * Maps the JSON response from the OCR microservice to Java objects. + * This is the main response wrapper containing all extracted data. + */ +public record OcrResponse( + String status, + + @JsonProperty("requestId") + String requestId, + + @JsonProperty("processingTimeMs") + Integer processingTimeMs, + + @JsonProperty("overallConfidence") + Double overallConfidence, + + DocumentInfo document, + + OcrMetadata metadata, + + List questions, + + @JsonProperty("unmappedContent") + List unmappedContent, + + List warnings, + + @JsonProperty("errorMessage") + String errorMessage +) { + /** + * Check if the OCR processing was successful. + */ + public boolean isSuccess() { + return "success".equals(status); + } + + /** + * Check if the OCR processing was partial (some data extracted with low confidence). + */ + public boolean isPartial() { + return "partial".equals(status); + } + + /** + * Check if the OCR processing failed. + */ + public boolean isError() { + return "error".equals(status); + } + + /** + * Check if the result needs human review (low confidence or warnings). + */ + public boolean needsReview() { + return isPartial() || + (overallConfidence != null && overallConfidence < 0.8) || + (warnings != null && !warnings.isEmpty()); + } + + /** + * Document information from OCR. + */ + public record DocumentInfo( + @JsonProperty("pageCount") + Integer pageCount, + + @JsonProperty("mainLanguage") + String mainLanguage, + + @JsonProperty("hasTables") + Boolean hasTables + ) {} + + /** + * Extracted metadata with confidence scores. + */ + public record OcrMetadata( + @JsonProperty("schoolYear") + ConfidenceField schoolYear, + + @JsonProperty("term") + ConfidenceField term, + + @JsonProperty("subject") + ConfidenceField subject, + + @JsonProperty("course") + ConfidenceField course, + + @JsonProperty("class") + ConfidenceField classInfo, + + @JsonProperty("examType") + ConfidenceField examType, + + @JsonProperty("durationMinutes") + ConfidenceField durationMinutes, + + @JsonProperty("variant") + ConfidenceField variant, + + @JsonProperty("title") + ConfidenceField title, + + @JsonProperty("instructions") + ConfidenceField instructions + ) {} + + /** + * Generic confidence field for any value type. + */ + public record ConfidenceField( + T value, + Double confidence + ) { + /** + * Check if the field has a value with sufficient confidence. + */ + public boolean isConfident(double threshold) { + return value != null && confidence != null && confidence >= threshold; + } + + /** + * Check if the field has low confidence (needs review). + */ + public boolean isLowConfidence(double threshold) { + return value != null && confidence != null && confidence < threshold; + } + } + + /** + * Extracted question with all components. + */ + public record ExtractedQuestion( + Integer number, + + Double confidence, + + ConfidenceField text, + + @JsonProperty("questionType") + ConfidenceField questionType, + + @JsonProperty("maxScore") + ConfidenceField maxScore, + + List options, + + @JsonProperty("pageIndex") + Integer pageIndex, + + @JsonProperty("startY") + Integer startY, + + @JsonProperty("endY") + Integer endY + ) { + /** + * Check if this is a multiple choice question. + */ + public boolean isMultipleChoice() { + return options != null && !options.isEmpty(); + } + + /** + * Get the question type value, defaulting to "unknown". + */ + public String getQuestionTypeValue() { + return questionType != null && questionType.value() != null + ? questionType.value() + : "unknown"; + } + } + + /** + * Extracted question option. + */ + public record ExtractedOption( + @JsonProperty("optionLabel") + String optionLabel, + + @JsonProperty("optionText") + String optionText, + + Double confidence + ) {} + + /** + * Unmapped content that couldn't be categorized. + */ + public record UnmappedContent( + @JsonProperty("pageIndex") + Integer pageIndex, + + String text, + + Double confidence + ) {} + + /** + * Processing warning from OCR service. + */ + public record OcrWarning( + String code, + String field, + Double confidence, + String message + ) { + /** + * Check if this is a low confidence warning. + */ + public boolean isLowConfidence() { + return "LOW_CONFIDENCE".equals(code); + } + + /** + * Check if this is an unknown question type warning. + */ + public boolean isUnknownQuestionType() { + return "UNKNOWN_QUESTION_TYPE".equals(code); + } + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/model/Question.java b/services/backend-api/src/main/java/ao/creativemode/kixi/model/Question.java new file mode 100644 index 0000000..5869e95 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/model/Question.java @@ -0,0 +1,299 @@ +package ao.creativemode.kixi.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +import java.time.LocalDateTime; + +/** + * Question entity representing a single question within an exam statement. + * + * Each question belongs to a Statement and can have multiple QuestionOptions + * if it's a multiple choice question. + * + * Question types: + * - multiple_choice: Has options with one or more correct answers + * - short_answer: Expects a brief text/numeric answer + * - development: Requires an extended written response + * - true_false: Binary true/false answer + */ +@Table("questions") +public class Question { + + @Id + private Long id; + + /** + * Reference to the parent statement + */ + @Column("statement_id") + private Long statementId; + + /** + * Question number within the exam (e.g., 1, 2, 3...) + */ + @Column("number") + private Integer number; + + /** + * The actual question text + */ + @Column("text") + private String text; + + /** + * Type of question: multiple_choice, short_answer, development, true_false + */ + @Column("question_type") + private String questionType; + + /** + * Maximum score/points for this question + */ + @Column("max_score") + private Double maxScore; + + /** + * Order index for display (allows custom ordering independent of question number) + */ + @Column("order_index") + private Integer orderIndex; + + /** + * OCR confidence score for this question (0.0 - 1.0) + */ + @Column("ocr_confidence") + private Double ocrConfidence; + + /** + * Page index where this question was found (for multi-page documents) + */ + @Column("page_index") + private Integer pageIndex; + + /** + * Flag indicating if this question needs human review + */ + @Column("needs_review") + private Boolean needsReview; + + @CreatedDate + @Column("created_at") + private LocalDateTime createdAt; + + @LastModifiedDate + @Column("updated_at") + private LocalDateTime updatedAt; + + @Column("deleted_at") + private LocalDateTime deletedAt; + + // Constructors + + public Question() { + this.needsReview = false; + } + + public Question(Long statementId, Integer number, String text, String questionType) { + this(); + this.statementId = statementId; + this.number = number; + this.text = text; + this.questionType = questionType; + this.orderIndex = number; + } + + // Getters and Setters + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getStatementId() { + return statementId; + } + + public void setStatementId(Long statementId) { + this.statementId = statementId; + } + + public Integer getNumber() { + return number; + } + + public void setNumber(Integer number) { + this.number = number; + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public String getQuestionType() { + return questionType; + } + + public void setQuestionType(String questionType) { + this.questionType = questionType; + } + + public Double getMaxScore() { + return maxScore; + } + + public void setMaxScore(Double maxScore) { + this.maxScore = maxScore; + } + + public Integer getOrderIndex() { + return orderIndex; + } + + public void setOrderIndex(Integer orderIndex) { + this.orderIndex = orderIndex; + } + + public Double getOcrConfidence() { + return ocrConfidence; + } + + public void setOcrConfidence(Double ocrConfidence) { + this.ocrConfidence = ocrConfidence; + } + + public Integer getPageIndex() { + return pageIndex; + } + + public void setPageIndex(Integer pageIndex) { + this.pageIndex = pageIndex; + } + + public Boolean getNeedsReview() { + return needsReview; + } + + public void setNeedsReview(Boolean needsReview) { + this.needsReview = needsReview; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public LocalDateTime getDeletedAt() { + return deletedAt; + } + + public void setDeletedAt(LocalDateTime deletedAt) { + this.deletedAt = deletedAt; + } + + // Utility methods + + /** + * Mark this question as deleted (soft delete) + */ + public void markAsDeleted() { + this.deletedAt = LocalDateTime.now(); + } + + /** + * Restore a soft-deleted question + */ + public void restore() { + this.deletedAt = null; + } + + /** + * Check if this question is deleted + */ + public boolean isDeleted() { + return deletedAt != null; + } + + /** + * Check if this is a multiple choice question + */ + public boolean isMultipleChoice() { + return "multiple_choice".equals(questionType); + } + + /** + * Check if this is a short answer question + */ + public boolean isShortAnswer() { + return "short_answer".equals(questionType); + } + + /** + * Check if this is a development/essay question + */ + public boolean isDevelopment() { + return "development".equals(questionType); + } + + /** + * Check if this is a true/false question + */ + public boolean isTrueFalse() { + return "true_false".equals(questionType); + } + + /** + * Mark this question for human review + */ + public void markForReview() { + this.needsReview = true; + } + + /** + * Mark this question as reviewed and approved + */ + public void approveReview() { + this.needsReview = false; + } + + /** + * Check if this question has low OCR confidence + */ + public boolean hasLowConfidence(double threshold) { + return ocrConfidence != null && ocrConfidence < threshold; + } + + @Override + public String toString() { + return "Question{" + + "id=" + id + + ", statementId=" + statementId + + ", number=" + number + + ", questionType='" + questionType + '\'' + + ", maxScore=" + maxScore + + ", ocrConfidence=" + ocrConfidence + + ", needsReview=" + needsReview + + '}'; + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/model/QuestionOption.java b/services/backend-api/src/main/java/ao/creativemode/kixi/model/QuestionOption.java new file mode 100644 index 0000000..b0ec11d --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/model/QuestionOption.java @@ -0,0 +1,234 @@ +package ao.creativemode.kixi.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +import java.time.LocalDateTime; + +/** + * QuestionOption entity representing an option for a multiple choice question. + * + * Each option belongs to a Question and contains the option label (e.g., A, B, C, D), + * the option text, and whether it is the correct answer. + */ +@Table("question_options") +public class QuestionOption { + + @Id + private Long id; + + /** + * Reference to the parent question + */ + @Column("question_id") + private Long questionId; + + /** + * Option label (e.g., "A", "B", "C", "D", or "1", "2", "3", "4") + */ + @Column("option_label") + private String optionLabel; + + /** + * The text content of this option + */ + @Column("option_text") + private String optionText; + + /** + * Whether this option is the correct answer + */ + @Column("is_correct") + private Boolean isCorrect; + + /** + * Order index for display (allows custom ordering) + */ + @Column("order_index") + private Integer orderIndex; + + /** + * OCR confidence score for this option (0.0 - 1.0) + */ + @Column("ocr_confidence") + private Double ocrConfidence; + + @CreatedDate + @Column("created_at") + private LocalDateTime createdAt; + + @LastModifiedDate + @Column("updated_at") + private LocalDateTime updatedAt; + + @Column("deleted_at") + private LocalDateTime deletedAt; + + // Constructors + + public QuestionOption() { + this.isCorrect = false; + } + + public QuestionOption(Long questionId, String optionLabel, String optionText) { + this(); + this.questionId = questionId; + this.optionLabel = optionLabel; + this.optionText = optionText; + } + + public QuestionOption(Long questionId, String optionLabel, String optionText, Boolean isCorrect) { + this(questionId, optionLabel, optionText); + this.isCorrect = isCorrect; + } + + // Getters and Setters + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getQuestionId() { + return questionId; + } + + public void setQuestionId(Long questionId) { + this.questionId = questionId; + } + + public String getOptionLabel() { + return optionLabel; + } + + public void setOptionLabel(String optionLabel) { + this.optionLabel = optionLabel; + } + + public String getOptionText() { + return optionText; + } + + public void setOptionText(String optionText) { + this.optionText = optionText; + } + + public Boolean getIsCorrect() { + return isCorrect; + } + + public void setIsCorrect(Boolean isCorrect) { + this.isCorrect = isCorrect; + } + + public Integer getOrderIndex() { + return orderIndex; + } + + public void setOrderIndex(Integer orderIndex) { + this.orderIndex = orderIndex; + } + + public Double getOcrConfidence() { + return ocrConfidence; + } + + public void setOcrConfidence(Double ocrConfidence) { + this.ocrConfidence = ocrConfidence; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public LocalDateTime getDeletedAt() { + return deletedAt; + } + + public void setDeletedAt(LocalDateTime deletedAt) { + this.deletedAt = deletedAt; + } + + // Utility methods + + /** + * Mark this option as deleted (soft delete) + */ + public void markAsDeleted() { + this.deletedAt = LocalDateTime.now(); + } + + /** + * Restore a soft-deleted option + */ + public void restore() { + this.deletedAt = null; + } + + /** + * Check if this option is deleted + */ + public boolean isDeleted() { + return deletedAt != null; + } + + /** + * Mark this option as the correct answer + */ + public void markAsCorrect() { + this.isCorrect = true; + } + + /** + * Mark this option as incorrect + */ + public void markAsIncorrect() { + this.isCorrect = false; + } + + /** + * Check if this option has low OCR confidence + */ + public boolean hasLowConfidence(double threshold) { + return ocrConfidence != null && ocrConfidence < threshold; + } + + /** + * Get a normalized option label (uppercase, trimmed) + */ + public String getNormalizedLabel() { + return optionLabel != null ? optionLabel.toUpperCase().trim() : null; + } + + @Override + public String toString() { + return "QuestionOption{" + + "id=" + id + + ", questionId=" + questionId + + ", optionLabel='" + optionLabel + '\'' + + ", optionText='" + (optionText != null && optionText.length() > 50 + ? optionText.substring(0, 50) + "..." + : optionText) + '\'' + + ", isCorrect=" + isCorrect + + ", ocrConfidence=" + ocrConfidence + + '}'; + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/model/Statement.java b/services/backend-api/src/main/java/ao/creativemode/kixi/model/Statement.java index eb7356e..9f0caae 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/model/Statement.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/model/Statement.java @@ -1,66 +1,392 @@ package ao.creativemode.kixi.model; -import lombok.Data; -import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.relational.core.mapping.Column; import org.springframework.data.relational.core.mapping.Table; + import java.time.LocalDateTime; -@Data -@Table("statement") +/** + * Statement entity representing an exam paper/test in the system. + * + * This entity stores metadata about exam papers including their type, + * duration, variant, and associated references to school year, term, + * subject, and class. + * + * The actual questions are stored in a separate Question entity with + * a foreign key reference to this statement. + */ +@Table("statements") public class Statement { + @Id private Long id; + /** + * Type of examination (e.g., "Avaliação Periódica", "Exame Final", "Teste Sumativo") + */ @Column("exam_type") private String examType; + /** + * Duration of the exam in minutes + */ @Column("duration_minutes") private Integer durationMinutes; + /** + * Exam variant (e.g., "A", "B", "C") + */ @Column("variant") private String variant; + /** + * Title of the exam/statement + */ @Column("title") private String title; + /** + * Instructions for the exam + */ @Column("instructions") private String instructions; + /** + * Total maximum score for the entire exam + */ @Column("total_max_score") - private Integer totalMaxScore; + private Double totalMaxScore; + /** + * Reference to the school year + */ @Column("school_year_id") private Long schoolYearId; + /** + * Reference to the term/trimester + */ @Column("term_id") private Long termId; + /** + * Reference to the subject + */ @Column("subject_id") private Long subjectId; + /** + * Reference to the class + */ @Column("class_id") private Long classId; + /** + * Reference to the course (optional) + */ @Column("course_id") private Long courseId; - @Column("create_by") + /** + * Reference to the user who created this statement + */ + @Column("created_by") private Long createdBy; + /** + * Visibility flag (true if the statement is visible to students) + */ @Column("visible") private Boolean visible; + /** + * Flag indicating if this statement needs human review (e.g., low OCR confidence) + */ + @Column("needs_review") + private Boolean needsReview; + + /** + * OCR confidence score (0.0 - 1.0) + */ + @Column("ocr_confidence") + private Double ocrConfidence; + + /** + * Original OCR request ID for tracking + */ + @Column("ocr_request_id") + private String ocrRequestId; + + /** + * Source of the statement (e.g., "manual", "ocr", "import") + */ + @Column("source") + private String source; + @CreatedDate - @Column("create_at") + @Column("created_at") private LocalDateTime createdAt; @LastModifiedDate - @Column("update_at") + @Column("updated_at") private LocalDateTime updatedAt; - @Column("delete_at") + @Column("deleted_at") private LocalDateTime deletedAt; + + // Constructors + + public Statement() { + this.visible = false; + this.needsReview = false; + this.source = "manual"; + } + + public Statement(String examType, String title) { + this(); + this.examType = examType; + this.title = title; + } + + // Getters and Setters + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getExamType() { + return examType; + } + + public void setExamType(String examType) { + this.examType = examType; + } + + public Integer getDurationMinutes() { + return durationMinutes; + } + + public void setDurationMinutes(Integer durationMinutes) { + this.durationMinutes = durationMinutes; + } + + public String getVariant() { + return variant; + } + + public void setVariant(String variant) { + this.variant = variant; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getInstructions() { + return instructions; + } + + public void setInstructions(String instructions) { + this.instructions = instructions; + } + + public Double getTotalMaxScore() { + return totalMaxScore; + } + + public void setTotalMaxScore(Double totalMaxScore) { + this.totalMaxScore = totalMaxScore; + } + + public Long getSchoolYearId() { + return schoolYearId; + } + + public void setSchoolYearId(Long schoolYearId) { + this.schoolYearId = schoolYearId; + } + + public Long getTermId() { + return termId; + } + + public void setTermId(Long termId) { + this.termId = termId; + } + + public Long getSubjectId() { + return subjectId; + } + + public void setSubjectId(Long subjectId) { + this.subjectId = subjectId; + } + + public Long getClassId() { + return classId; + } + + public void setClassId(Long classId) { + this.classId = classId; + } + + public Long getCourseId() { + return courseId; + } + + public void setCourseId(Long courseId) { + this.courseId = courseId; + } + + public Long getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(Long createdBy) { + this.createdBy = createdBy; + } + + public Boolean getVisible() { + return visible; + } + + public void setVisible(Boolean visible) { + this.visible = visible; + } + + public Boolean getNeedsReview() { + return needsReview; + } + + public void setNeedsReview(Boolean needsReview) { + this.needsReview = needsReview; + } + + public Double getOcrConfidence() { + return ocrConfidence; + } + + public void setOcrConfidence(Double ocrConfidence) { + this.ocrConfidence = ocrConfidence; + } + + public String getOcrRequestId() { + return ocrRequestId; + } + + public void setOcrRequestId(String ocrRequestId) { + this.ocrRequestId = ocrRequestId; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public LocalDateTime getDeletedAt() { + return deletedAt; + } + + public void setDeletedAt(LocalDateTime deletedAt) { + this.deletedAt = deletedAt; + } + + // Utility methods + + /** + * Mark this statement as deleted (soft delete) + */ + public void markAsDeleted() { + this.deletedAt = LocalDateTime.now(); + } + + /** + * Restore a soft-deleted statement + */ + public void restore() { + this.deletedAt = null; + } + + /** + * Check if this statement is deleted + */ + public boolean isDeleted() { + return deletedAt != null; + } + + /** + * Check if this statement was created via OCR + */ + public boolean isFromOcr() { + return "ocr".equals(source); + } + + /** + * Mark this statement as needing review (e.g., low OCR confidence) + */ + public void markForReview() { + this.needsReview = true; + } + + /** + * Mark this statement as reviewed and approved + */ + public void approveReview() { + this.needsReview = false; + } + + /** + * Set OCR-related metadata + */ + public void setOcrMetadata(String requestId, Double confidence, boolean needsReview) { + this.ocrRequestId = requestId; + this.ocrConfidence = confidence; + this.needsReview = needsReview; + this.source = "ocr"; + } + + @Override + public String toString() { + return "Statement{" + + "id=" + id + + ", examType='" + examType + '\'' + + ", title='" + title + '\'' + + ", variant='" + variant + '\'' + + ", schoolYearId=" + schoolYearId + + ", subjectId=" + subjectId + + ", visible=" + visible + + ", needsReview=" + needsReview + + ", source='" + source + '\'' + + '}'; + } } diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionOptionRepository.java b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionOptionRepository.java new file mode 100644 index 0000000..465fa72 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionOptionRepository.java @@ -0,0 +1,158 @@ +package ao.creativemode.kixi.repository; + +import ao.creativemode.kixi.model.QuestionOption; + +import org.springframework.data.r2dbc.repository.Query; +import org.springframework.data.r2dbc.repository.R2dbcRepository; +import org.springframework.stereotype.Repository; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Repository for QuestionOption entity database operations. + * + * Provides reactive CRUD operations and custom queries for + * managing question options (multiple choice answers) in the database. + */ +@Repository +public interface QuestionOptionRepository extends R2dbcRepository { + + /** + * Find all active (non-deleted) options + */ + Flux findAllByDeletedAtIsNull(); + + /** + * Find all options for a specific question + */ + Flux findAllByQuestionIdAndDeletedAtIsNull(Long questionId); + + /** + * Find all options for a question, ordered by label + */ + @Query("SELECT * FROM question_options WHERE question_id = :questionId AND deleted_at IS NULL ORDER BY option_label ASC") + Flux findAllByQuestionIdOrderedByLabel(Long questionId); + + /** + * Find all options for a question, ordered by order_index + */ + @Query("SELECT * FROM question_options WHERE question_id = :questionId AND deleted_at IS NULL ORDER BY order_index ASC") + Flux findAllByQuestionIdOrderedByOrderIndex(Long questionId); + + /** + * Find an active option by ID + */ + Mono findByIdAndDeletedAtIsNull(Long id); + + /** + * Find a deleted option by ID + */ + Mono findByIdAndDeletedAtIsNotNull(Long id); + + /** + * Find an option by question ID and label + */ + Mono findByQuestionIdAndOptionLabelAndDeletedAtIsNull(Long questionId, String optionLabel); + + /** + * Find the correct option(s) for a question + */ + Flux findAllByQuestionIdAndIsCorrectTrueAndDeletedAtIsNull(Long questionId); + + /** + * Find the single correct option for a question (for single-answer questions) + */ + @Query("SELECT * FROM question_options WHERE question_id = :questionId AND is_correct = true AND deleted_at IS NULL LIMIT 1") + Mono findCorrectOptionByQuestionId(Long questionId); + + /** + * Find incorrect options for a question + */ + Flux findAllByQuestionIdAndIsCorrectFalseAndDeletedAtIsNull(Long questionId); + + /** + * Find options with OCR confidence below threshold + */ + @Query("SELECT * FROM question_options WHERE ocr_confidence < :threshold AND deleted_at IS NULL ORDER BY ocr_confidence ASC") + Flux findAllWithLowOcrConfidence(Double threshold); + + /** + * Find options with low OCR confidence for a specific question + */ + @Query("SELECT * FROM question_options WHERE question_id = :questionId AND ocr_confidence < :threshold AND deleted_at IS NULL ORDER BY option_label ASC") + Flux findByQuestionIdWithLowOcrConfidence(Long questionId, Double threshold); + + /** + * Count options for a question + */ + Mono countByQuestionIdAndDeletedAtIsNull(Long questionId); + + /** + * Count correct options for a question + */ + Mono countByQuestionIdAndIsCorrectTrueAndDeletedAtIsNull(Long questionId); + + /** + * Check if an option exists by ID and is active + */ + Mono existsByIdAndDeletedAtIsNull(Long id); + + /** + * Check if an option with the same label exists for a question + */ + Mono existsByQuestionIdAndOptionLabelAndDeletedAtIsNull(Long questionId, String optionLabel); + + /** + * Check if a question has a correct option defined + */ + Mono existsByQuestionIdAndIsCorrectTrueAndDeletedAtIsNull(Long questionId); + + /** + * Delete all options for a question (soft delete) + */ + @Query("UPDATE question_options SET deleted_at = CURRENT_TIMESTAMP WHERE question_id = :questionId AND deleted_at IS NULL") + Mono softDeleteAllByQuestionId(Long questionId); + + /** + * Find the next order index for a question + */ + @Query("SELECT COALESCE(MAX(order_index), 0) + 1 FROM question_options WHERE question_id = :questionId AND deleted_at IS NULL") + Mono findNextOrderIndex(Long questionId); + + /** + * Mark all options as incorrect for a question + */ + @Query("UPDATE question_options SET is_correct = false, updated_at = CURRENT_TIMESTAMP WHERE question_id = :questionId AND deleted_at IS NULL") + Mono markAllAsIncorrect(Long questionId); + + /** + * Mark a specific option as correct (and others as incorrect) + */ + @Query("UPDATE question_options SET is_correct = (id = :correctOptionId), updated_at = CURRENT_TIMESTAMP WHERE question_id = :questionId AND deleted_at IS NULL") + Mono setCorrectOption(Long questionId, Long correctOptionId); + + /** + * Search options by text content (case-insensitive partial match) + */ + @Query("SELECT * FROM question_options WHERE LOWER(option_text) LIKE LOWER(CONCAT('%', :searchTerm, '%')) AND deleted_at IS NULL ORDER BY question_id, option_label") + Flux searchByText(String searchTerm); + + /** + * Get average OCR confidence for options of a question + */ + @Query("SELECT AVG(ocr_confidence) FROM question_options WHERE question_id = :questionId AND ocr_confidence IS NOT NULL AND deleted_at IS NULL") + Mono getAverageOcrConfidence(Long questionId); + + /** + * Find all options for multiple questions + */ + @Query("SELECT * FROM question_options WHERE question_id IN (:questionIds) AND deleted_at IS NULL ORDER BY question_id, order_index") + Flux findAllByQuestionIds(Iterable questionIds); + + /** + * Bulk delete options for multiple questions + */ + @Query("UPDATE question_options SET deleted_at = CURRENT_TIMESTAMP WHERE question_id IN (:questionIds) AND deleted_at IS NULL") + Mono softDeleteAllByQuestionIds(Iterable questionIds); +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionRepository.java b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionRepository.java new file mode 100644 index 0000000..d5369d3 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionRepository.java @@ -0,0 +1,172 @@ +package ao.creativemode.kixi.repository; + +import ao.creativemode.kixi.model.Question; + +import org.springframework.data.r2dbc.repository.Query; +import org.springframework.data.r2dbc.repository.R2dbcRepository; +import org.springframework.stereotype.Repository; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Repository for Question entity database operations. + * + * Provides reactive CRUD operations and custom queries for + * managing exam questions in the database. + */ +@Repository +public interface QuestionRepository extends R2dbcRepository { + + /** + * Find all active (non-deleted) questions + */ + Flux findAllByDeletedAtIsNull(); + + /** + * Find all questions for a specific statement + */ + Flux findAllByStatementIdAndDeletedAtIsNull(Long statementId); + + /** + * Find all questions for a statement, ordered by question number + */ + @Query("SELECT * FROM questions WHERE statement_id = :statementId AND deleted_at IS NULL ORDER BY number ASC") + Flux findAllByStatementIdOrderedByNumber(Long statementId); + + /** + * Find all questions for a statement, ordered by order_index + */ + @Query("SELECT * FROM questions WHERE statement_id = :statementId AND deleted_at IS NULL ORDER BY order_index ASC") + Flux findAllByStatementIdOrderedByOrderIndex(Long statementId); + + /** + * Find an active question by ID + */ + Mono findByIdAndDeletedAtIsNull(Long id); + + /** + * Find a deleted question by ID + */ + Mono findByIdAndDeletedAtIsNotNull(Long id); + + /** + * Find a question by statement ID and question number + */ + Mono findByStatementIdAndNumberAndDeletedAtIsNull(Long statementId, Integer number); + + /** + * Find all questions that need review + */ + Flux findAllByNeedsReviewTrueAndDeletedAtIsNull(); + + /** + * Find all questions for a statement that need review + */ + Flux findAllByStatementIdAndNeedsReviewTrueAndDeletedAtIsNull(Long statementId); + + /** + * Find questions by type + */ + Flux findAllByQuestionTypeAndDeletedAtIsNull(String questionType); + + /** + * Find questions by type for a specific statement + */ + Flux findAllByStatementIdAndQuestionTypeAndDeletedAtIsNull(Long statementId, String questionType); + + /** + * Find multiple choice questions for a statement + */ + @Query("SELECT * FROM questions WHERE statement_id = :statementId AND question_type = 'multiple_choice' AND deleted_at IS NULL ORDER BY number ASC") + Flux findMultipleChoiceByStatementId(Long statementId); + + /** + * Find questions with OCR confidence below threshold + */ + @Query("SELECT * FROM questions WHERE ocr_confidence < :threshold AND deleted_at IS NULL ORDER BY ocr_confidence ASC") + Flux findAllWithLowOcrConfidence(Double threshold); + + /** + * Find questions with low OCR confidence for a specific statement + */ + @Query("SELECT * FROM questions WHERE statement_id = :statementId AND ocr_confidence < :threshold AND deleted_at IS NULL ORDER BY number ASC") + Flux findByStatementIdWithLowOcrConfidence(Long statementId, Double threshold); + + /** + * Find questions on a specific page + */ + Flux findAllByPageIndexAndDeletedAtIsNull(Integer pageIndex); + + /** + * Find questions on a specific page for a statement + */ + Flux findAllByStatementIdAndPageIndexAndDeletedAtIsNull(Long statementId, Integer pageIndex); + + /** + * Count questions for a statement + */ + Mono countByStatementIdAndDeletedAtIsNull(Long statementId); + + /** + * Count questions needing review + */ + Mono countByNeedsReviewTrueAndDeletedAtIsNull(); + + /** + * Count questions by type for a statement + */ + Mono countByStatementIdAndQuestionTypeAndDeletedAtIsNull(Long statementId, String questionType); + + /** + * Check if a question exists by ID and is active + */ + Mono existsByIdAndDeletedAtIsNull(Long id); + + /** + * Check if a question with the same number exists in a statement + */ + Mono existsByStatementIdAndNumberAndDeletedAtIsNull(Long statementId, Integer number); + + /** + * Delete all questions for a statement (soft delete) + */ + @Query("UPDATE questions SET deleted_at = CURRENT_TIMESTAMP WHERE statement_id = :statementId AND deleted_at IS NULL") + Mono softDeleteAllByStatementId(Long statementId); + + /** + * Calculate total max score for a statement + */ + @Query("SELECT COALESCE(SUM(max_score), 0) FROM questions WHERE statement_id = :statementId AND deleted_at IS NULL") + Mono calculateTotalMaxScore(Long statementId); + + /** + * Find the next order index for a statement + */ + @Query("SELECT COALESCE(MAX(order_index), 0) + 1 FROM questions WHERE statement_id = :statementId AND deleted_at IS NULL") + Mono findNextOrderIndex(Long statementId); + + /** + * Find the next question number for a statement + */ + @Query("SELECT COALESCE(MAX(number), 0) + 1 FROM questions WHERE statement_id = :statementId AND deleted_at IS NULL") + Mono findNextQuestionNumber(Long statementId); + + /** + * Search questions by text content (case-insensitive partial match) + */ + @Query("SELECT * FROM questions WHERE LOWER(text) LIKE LOWER(CONCAT('%', :searchTerm, '%')) AND deleted_at IS NULL ORDER BY statement_id, number") + Flux searchByText(String searchTerm); + + /** + * Find questions with specific score range + */ + @Query("SELECT * FROM questions WHERE max_score >= :minScore AND max_score <= :maxScore AND deleted_at IS NULL ORDER BY max_score DESC") + Flux findByScoreRange(Double minScore, Double maxScore); + + /** + * Get average OCR confidence for a statement + */ + @Query("SELECT AVG(ocr_confidence) FROM questions WHERE statement_id = :statementId AND ocr_confidence IS NOT NULL AND deleted_at IS NULL") + Mono getAverageOcrConfidence(Long statementId); +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/StatementRepository.java b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/StatementRepository.java index 0f59b82..cc24dcb 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/StatementRepository.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/StatementRepository.java @@ -1,23 +1,170 @@ package ao.creativemode.kixi.repository; import ao.creativemode.kixi.model.Statement; + import org.springframework.data.r2dbc.repository.Query; -import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import org.springframework.data.r2dbc.repository.R2dbcRepository; import org.springframework.stereotype.Repository; + import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +/** + * Repository for Statement entity database operations. + * + * Provides reactive CRUD operations and custom queries for + * managing exam statements in the database. + */ @Repository -public interface StatementRepository extends ReactiveCrudRepository { +public interface StatementRepository extends R2dbcRepository { + + /** + * Find all active (non-deleted) statements + */ + Flux findAllByDeletedAtIsNull(); + + /** + * Find all soft-deleted statements + */ + Flux findAllByDeletedAtIsNotNull(); - Flux findByDeletedAtIsNull(); - Flux findByDeletedAtIsNotNull(); + /** + * Find an active statement by ID + */ Mono findByIdAndDeletedAtIsNull(Long id); - Mono existsByTitleAndDeletedAtIsNull(String title); - Flux findBySchoolYearIdAndDeletedAtIsNull(Long schoolYearId); - Flux findBySubjectIdAndDeletedAtIsNull(Long subjectId); - Flux findByClassIdAndDeletedAtIsNull(Long classId); - @Query("SELECT COUNT(*) FROM statement WHERE delete_at IS NULL") - Mono countActive(); + /** + * Find a deleted statement by ID + */ + Mono findByIdAndDeletedAtIsNotNull(Long id); + + /** + * Find all visible statements + */ + Flux findAllByVisibleTrueAndDeletedAtIsNull(); + + /** + * Find all statements that need review + */ + Flux findAllByNeedsReviewTrueAndDeletedAtIsNull(); + + /** + * Find statements by school year + */ + Flux findAllBySchoolYearIdAndDeletedAtIsNull(Long schoolYearId); + + /** + * Find statements by subject + */ + Flux findAllBySubjectIdAndDeletedAtIsNull(Long subjectId); + + /** + * Find statements by term + */ + Flux findAllByTermIdAndDeletedAtIsNull(Long termId); + + /** + * Find statements by class + */ + Flux findAllByClassIdAndDeletedAtIsNull(Long classId); + + /** + * Find statements by school year and subject + */ + Flux findAllBySchoolYearIdAndSubjectIdAndDeletedAtIsNull(Long schoolYearId, Long subjectId); + + /** + * Find statements by school year, term, and subject + */ + Flux findAllBySchoolYearIdAndTermIdAndSubjectIdAndDeletedAtIsNull( + Long schoolYearId, Long termId, Long subjectId); + + /** + * Find statements created by a specific user + */ + Flux findAllByCreatedByAndDeletedAtIsNull(Long createdBy); + + /** + * Find statements by source (manual, ocr, import) + */ + Flux findAllBySourceAndDeletedAtIsNull(String source); + + /** + * Find statements created via OCR + */ + @Query("SELECT * FROM statements WHERE source = 'ocr' AND deleted_at IS NULL ORDER BY created_at DESC") + Flux findAllFromOcr(); + + /** + * Find statements by OCR request ID + */ + Mono findByOcrRequestIdAndDeletedAtIsNull(String ocrRequestId); + + /** + * Find statements with OCR confidence below threshold + */ + @Query("SELECT * FROM statements WHERE ocr_confidence < :threshold AND deleted_at IS NULL ORDER BY ocr_confidence ASC") + Flux findAllWithLowOcrConfidence(Double threshold); + + /** + * Find statements by exam type + */ + Flux findAllByExamTypeAndDeletedAtIsNull(String examType); + + /** + * Find statements by variant + */ + Flux findAllByVariantAndDeletedAtIsNull(String variant); + + /** + * Count active statements + */ + Mono countByDeletedAtIsNull(); + + /** + * Count statements needing review + */ + Mono countByNeedsReviewTrueAndDeletedAtIsNull(); + + /** + * Count statements by source + */ + Mono countBySourceAndDeletedAtIsNull(String source); + + /** + * Check if a statement exists by ID and is active + */ + Mono existsByIdAndDeletedAtIsNull(Long id); + + /** + * Search statements by title (case-insensitive partial match) + */ + @Query("SELECT * FROM statements WHERE LOWER(title) LIKE LOWER(CONCAT('%', :searchTerm, '%')) AND deleted_at IS NULL ORDER BY created_at DESC") + Flux searchByTitle(String searchTerm); + + /** + * Find recent statements with pagination + */ + @Query("SELECT * FROM statements WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT :limit OFFSET :offset") + Flux findRecentStatements(int limit, int offset); + + /** + * Find statements by multiple filters + */ + @Query(""" + SELECT * FROM statements + WHERE deleted_at IS NULL + AND (:schoolYearId IS NULL OR school_year_id = :schoolYearId) + AND (:termId IS NULL OR term_id = :termId) + AND (:subjectId IS NULL OR subject_id = :subjectId) + AND (:classId IS NULL OR class_id = :classId) + AND (:examType IS NULL OR exam_type = :examType) + ORDER BY created_at DESC + """) + Flux findByFilters( + Long schoolYearId, + Long termId, + Long subjectId, + Long classId, + String examType); } diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/StatementService.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/StatementService.java index 51217fe..69b82c6 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/service/StatementService.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/StatementService.java @@ -1,361 +1,477 @@ package ao.creativemode.kixi.service; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; +import ao.creativemode.kixi.client.OcrServiceClient; +import ao.creativemode.kixi.dto.ocr.OcrResponse; +import ao.creativemode.kixi.dto.ocr.OcrResponse.ExtractedQuestion; +import ao.creativemode.kixi.dto.ocr.OcrResponse.ExtractedOption; +import ao.creativemode.kixi.dto.ocr.OcrResponse.OcrMetadata; +import ao.creativemode.kixi.model.Statement; +import ao.creativemode.kixi.model.Question; +import ao.creativemode.kixi.model.QuestionOption; +import ao.creativemode.kixi.repository.StatementRepository; +import ao.creativemode.kixi.repository.QuestionRepository; +import ao.creativemode.kixi.repository.QuestionOptionRepository; +import ao.creativemode.kixi.common.exception.ApiException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.codec.multipart.FilePart; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; -import ao.creativemode.kixi.common.exception.ApiException; -import ao.creativemode.kixi.dto.accounts.AccountBasicResponse; -import ao.creativemode.kixi.dto.classe.ClassResponse; -import ao.creativemode.kixi.dto.courses.CourseResponse; -import ao.creativemode.kixi.dto.schoolyears.SchoolYearResponse; -import ao.creativemode.kixi.dto.statement.StatementRequest; -import ao.creativemode.kixi.dto.statement.StatementResponse; -import ao.creativemode.kixi.dto.subject.SubjectResponse; -import ao.creativemode.kixi.dto.term.TermResponse; -import ao.creativemode.kixi.model.Account; -import ao.creativemode.kixi.model.Class; -import ao.creativemode.kixi.model.Course; -import ao.creativemode.kixi.model.SchoolYear; -import ao.creativemode.kixi.model.Statement; -import ao.creativemode.kixi.model.Subject; -import ao.creativemode.kixi.model.Term; -import ao.creativemode.kixi.repository.AccountRepository; -import ao.creativemode.kixi.repository.ClassRepository; -import ao.creativemode.kixi.repository.CourseRepository; -import ao.creativemode.kixi.repository.SchoolYearRepository; -import ao.creativemode.kixi.repository.StatementRepository; -import ao.creativemode.kixi.repository.SubjectRepository; -import ao.creativemode.kixi.repository.TermRepository; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.time.LocalDateTime; +import java.util.List; + +/** + * Service for managing Statement entities and OCR integration. + * + * Provides business logic for: + * - CRUD operations on statements + * - OCR-based statement creation from images + * - Mapping OCR results to domain entities + * - Managing questions and options + */ @Service public class StatementService { - private final StatementRepository repository; - private final SchoolYearRepository schoolYearRepository; - private final TermRepository termRepository; - private final SubjectRepository subjectRepository; - private final ClassRepository classRepository; - private final CourseRepository courseRepository; - private final AccountRepository accountRepository; - public StatementService( - StatementRepository repository, - SchoolYearRepository schoolYearRepository, - TermRepository termRepository, - SubjectRepository subjectRepository, - ClassRepository classRepository, - CourseRepository courseRepository, - AccountRepository accountRepository - ) { - this.repository = repository; - this.schoolYearRepository = schoolYearRepository; - this.termRepository = termRepository; - this.subjectRepository = subjectRepository; - this.classRepository = classRepository; - this.courseRepository = courseRepository; - this.accountRepository = accountRepository; - } + private static final Logger log = LoggerFactory.getLogger(StatementService.class); + + private static final double LOW_CONFIDENCE_THRESHOLD = 0.8; + private static final double MIN_CONFIDENCE_THRESHOLD = 0.5; - public Flux listAllActive() { - return repository.findByDeletedAtIsNull() - .flatMap(this::toResponse) - .onErrorResume(e -> Flux.error( - ApiException.badRequest("Error listing statements: " + e.getMessage()) - )); + private final StatementRepository statementRepository; + private final QuestionRepository questionRepository; + private final QuestionOptionRepository optionRepository; + private final OcrServiceClient ocrServiceClient; + + public StatementService( + StatementRepository statementRepository, + QuestionRepository questionRepository, + QuestionOptionRepository optionRepository, + OcrServiceClient ocrServiceClient) { + this.statementRepository = statementRepository; + this.questionRepository = questionRepository; + this.optionRepository = optionRepository; + this.ocrServiceClient = ocrServiceClient; } - public Flux listTrashed() { - return repository.findByDeletedAtIsNotNull() - .flatMap(this::toResponse) - .onErrorResume(e -> Flux.error( - ApiException.badRequest("Error listing deleted statements: " + e.getMessage()) - )); + // ========================================================================= + // OCR Integration + // ========================================================================= + + /** + * Create a statement from uploaded images using OCR. + * + * @param files List of uploaded image files + * @param createdBy ID of the user creating the statement + * @return Mono containing the created statement with questions + */ + @Transactional + public Mono createFromOcr(List files, Long createdBy) { + log.info("Creating statement from OCR: {} file(s), createdBy={}", files.size(), createdBy); + + return ocrServiceClient.extractText(files) + .flatMap(ocrResponse -> { + if (ocrResponse.isError()) { + log.error("OCR extraction failed: {}", ocrResponse.errorMessage()); + return Mono.error(ApiException.badRequest( + "OCR extraction failed: " + ocrResponse.errorMessage())); + } + + log.info("OCR extraction successful: requestId={}, confidence={}, questions={}", + ocrResponse.requestId(), + ocrResponse.overallConfidence(), + ocrResponse.questions() != null ? ocrResponse.questions().size() : 0); + + return createStatementFromOcrResponse(ocrResponse, createdBy); + }) + .doOnSuccess(result -> log.info( + "Statement created from OCR: statementId={}, questions={}", + result.statement().getId(), + result.questions().size())) + .doOnError(error -> log.error("Failed to create statement from OCR", error)); } - public Mono getById(Long id) { - if (id == null || id <= 0) { - return Mono.error(ApiException.badRequest("Statement ID is required and must be greater than zero")); + /** + * Create a statement from an OCR response. + * + * @param ocrResponse The OCR response containing extracted data + * @param createdBy ID of the user creating the statement + * @return Mono containing the created statement with questions + */ + @Transactional + public Mono createStatementFromOcrResponse( + OcrResponse ocrResponse, + Long createdBy) { + + // Create and populate statement from metadata + Statement statement = mapMetadataToStatement(ocrResponse.metadata(), ocrResponse); + statement.setCreatedBy(createdBy); + statement.setOcrMetadata( + ocrResponse.requestId(), + ocrResponse.overallConfidence(), + ocrResponse.needsReview()); + statement.setSource("ocr"); + + // Calculate total max score from questions + if (ocrResponse.questions() != null) { + double totalScore = ocrResponse.questions().stream() + .filter(q -> q.maxScore() != null && q.maxScore().value() != null) + .mapToDouble(q -> q.maxScore().value()) + .sum(); + statement.setTotalMaxScore(totalScore); } - return repository.findByIdAndDeletedAtIsNull(id) - .switchIfEmpty(Mono.error( - ApiException.notFound("Statement with ID " + id + " not found") - )) - .flatMap(this::toResponse); + // Save statement first + return statementRepository.save(statement) + .flatMap(savedStatement -> { + if (ocrResponse.questions() == null || ocrResponse.questions().isEmpty()) { + return Mono.just(new StatementWithQuestions( + savedStatement, List.of(), List.of())); + } + + // Create and save questions + return createQuestionsFromOcr(savedStatement.getId(), ocrResponse.questions()) + .collectList() + .flatMap(savedQuestions -> { + // Collect all question IDs + List questionIds = savedQuestions.stream() + .map(Question::getId) + .toList(); + + // Load all options for these questions + return optionRepository.findAllByQuestionIds(questionIds) + .collectList() + .map(options -> new StatementWithQuestions( + savedStatement, savedQuestions, options)); + }); + }); } - public Mono update(Long id, StatementRequest request) { - if (id == null || id <= 0) { - return Mono.error(ApiException.badRequest("Statement ID is required and must be greater than zero")); + /** + * Map OCR metadata to Statement entity. + */ + private Statement mapMetadataToStatement(OcrMetadata metadata, OcrResponse ocrResponse) { + Statement statement = new Statement(); + + if (metadata != null) { + // Title + if (metadata.title() != null && metadata.title().value() != null) { + statement.setTitle(metadata.title().value()); + } else { + statement.setTitle("Imported Statement - " + LocalDateTime.now()); + } + + // Exam type + if (metadata.examType() != null && metadata.examType().value() != null) { + statement.setExamType(metadata.examType().value()); + } + + // Duration + if (metadata.durationMinutes() != null && metadata.durationMinutes().value() != null) { + statement.setDurationMinutes(metadata.durationMinutes().value()); + } + + // Variant + if (metadata.variant() != null && metadata.variant().value() != null) { + statement.setVariant(metadata.variant().value()); + } + + // Instructions + if (metadata.instructions() != null && metadata.instructions().value() != null) { + statement.setInstructions(metadata.instructions().value()); + } + + // Note: schoolYearId, termId, subjectId, classId, courseId need to be + // resolved from the text values (e.g., "2024/2025" -> ID lookup) + // This would require additional repositories and lookup logic + // For now, these are left null and can be set manually or via a separate endpoint } - return validateRequest(request) - .then(repository.findByIdAndDeletedAtIsNull(id)) - .switchIfEmpty(Mono.error( - ApiException.notFound("Statement with ID " + id + " not found for update") - )) - .flatMap(statement -> { - statement.setTitle(request.title()); - statement.setExamType(request.examType()); - statement.setDurationMinutes(request.durationMinutes()); - statement.setVariant(request.variant()); - statement.setInstructions(request.instructions()); - statement.setTotalMaxScore(request.totalMaxScore()); - statement.setSchoolYearId(request.schoolYearId()); - statement.setTermId(request.termId()); - statement.setSubjectId(request.subjectId()); - statement.setClassId(request.classId()); - statement.setCourseId(request.courseId()); - statement.setVisible(request.visible()); - statement.setUpdatedAt(LocalDateTime.now()); - return repository.save(statement); - }) - .flatMap(this::toResponse) - .onErrorResume(ApiException.class, Mono::error) - .onErrorResume(e -> Mono.error( - ApiException.badRequest("Error updating statement: " + e.getMessage()) - )); - } + // Set OCR-specific fields + statement.setVisible(false); // Require manual review before publishing + statement.setNeedsReview(ocrResponse.needsReview() || + (ocrResponse.overallConfidence() != null && + ocrResponse.overallConfidence() < LOW_CONFIDENCE_THRESHOLD)); - public Mono softDelete(Long id) { - if (id == null || id <= 0) { - return Mono.error(ApiException.badRequest("Statement ID is required and must be greater than zero")); - } + return statement; + } - return repository.findByIdAndDeletedAtIsNull(id) - .switchIfEmpty(Mono.error( - ApiException.notFound("Statement with ID " + id + " not found for deletion") - )) - .flatMap(statement -> { - statement.setDeletedAt(LocalDateTime.now()); - return repository.save(statement); - }) - .then() - .onErrorResume(ApiException.class, Mono::error) - .onErrorResume(e -> Mono.error( - ApiException.badRequest("Error deleting statement: " + e.getMessage()) - )); + /** + * Create questions from OCR extracted questions. + */ + private Flux createQuestionsFromOcr(Long statementId, List extractedQuestions) { + return Flux.fromIterable(extractedQuestions) + .flatMap(extracted -> { + Question question = mapExtractedToQuestion(statementId, extracted); + return questionRepository.save(question) + .flatMap(savedQuestion -> { + // Create options if this is a multiple choice question + if (extracted.options() != null && !extracted.options().isEmpty()) { + return createOptionsFromOcr(savedQuestion.getId(), extracted.options()) + .then(Mono.just(savedQuestion)); + } + return Mono.just(savedQuestion); + }); + }); } - public Mono restore(Long id) { - if (id == null || id <= 0) { - return Mono.error(ApiException.badRequest("Statement ID is required and must be greater than zero")); + /** + * Map extracted question to Question entity. + */ + private Question mapExtractedToQuestion(Long statementId, ExtractedQuestion extracted) { + Question question = new Question(); + question.setStatementId(statementId); + question.setNumber(extracted.number()); + question.setOrderIndex(extracted.number()); + + // Text + if (extracted.text() != null && extracted.text().value() != null) { + question.setText(extracted.text().value()); } - return repository.findById(id) - .switchIfEmpty(Mono.error( - ApiException.notFound("Statement with ID " + id + " not found") - )) - .filter(statement -> statement.getDeletedAt() != null) - .switchIfEmpty(Mono.error( - ApiException.badRequest("Statement with ID " + id + " is not deleted and cannot be restored") - )) - .flatMap(statement -> { - statement.setDeletedAt(null); - return repository.save(statement); - }) - .then() - .onErrorResume(ApiException.class, Mono::error) - .onErrorResume(e -> Mono.error( - ApiException.badRequest("Error restoring statement: " + e.getMessage()) - )); - } + // Question type + question.setQuestionType(extracted.getQuestionTypeValue()); - public Mono hardDelete(Long id) { - if (id == null || id <= 0) { - return Mono.error(ApiException.badRequest("Statement ID is required and must be greater than zero")); + // Max score + if (extracted.maxScore() != null && extracted.maxScore().value() != null) { + question.setMaxScore(extracted.maxScore().value()); } - return repository.findById(id) - .switchIfEmpty(Mono.error( - ApiException.notFound("Statement with ID " + id + " not found for permanent deletion") - )) - .flatMap(statement -> repository.deleteById(id)) - .onErrorResume(ApiException.class, Mono::error) - .onErrorResume(e -> Mono.error( - ApiException.badRequest("Error permanently deleting statement: " + e.getMessage()) - )); + // OCR metadata + question.setOcrConfidence(extracted.confidence()); + question.setPageIndex(extracted.pageIndex()); + + // Mark for review if low confidence + question.setNeedsReview(extracted.confidence() != null && + extracted.confidence() < LOW_CONFIDENCE_THRESHOLD); + + return question; } - public Mono create(StatementRequest request) { - return validateRequest(request) - .then(Mono.defer(() -> { - Statement statement = new Statement(); - statement.setTitle(request.title()); - statement.setExamType(request.examType()); - statement.setDurationMinutes(request.durationMinutes()); - statement.setVariant(request.variant()); - statement.setInstructions(request.instructions()); - statement.setTotalMaxScore(request.totalMaxScore()); - statement.setSchoolYearId(request.schoolYearId()); - statement.setTermId(request.termId()); - statement.setSubjectId(request.subjectId()); - statement.setClassId(request.classId()); - statement.setCourseId(request.courseId()); - statement.setVisible(request.visible() != null ? request.visible() : false); - statement.setCreatedAt(LocalDateTime.now()); - - return repository.save(statement); - })) - .flatMap(this::toResponse) - .onErrorResume(ApiException.class, Mono::error) - .onErrorResume(e -> Mono.error( - ApiException.badRequest("Error creating statement: " + e.getMessage()) - )); + /** + * Create options from OCR extracted options. + */ + private Flux createOptionsFromOcr(Long questionId, List extractedOptions) { + return Flux.fromIterable(extractedOptions) + .index() + .flatMap(indexed -> { + ExtractedOption extracted = indexed.getT2(); + int index = indexed.getT1().intValue(); + + QuestionOption option = new QuestionOption(); + option.setQuestionId(questionId); + option.setOptionLabel(extracted.optionLabel()); + option.setOptionText(extracted.optionText()); + option.setIsCorrect(false); // OCR doesn't know the correct answer + option.setOrderIndex(index); + option.setOcrConfidence(extracted.confidence()); + + return optionRepository.save(option); + }); } - private Mono validateRequest(StatementRequest request) { - List errors = new ArrayList<>(); + // ========================================================================= + // Standard CRUD Operations + // ========================================================================= - if (request == null) { - return Mono.error(ApiException.badRequest("Statement data is required")); - } + /** + * Find all active statements. + */ + public Flux findAllActive() { + return statementRepository.findAllByDeletedAtIsNull(); + } - if (request.title() == null || request.title().isBlank()) { - errors.add("Title is required"); - } else if (request.title().length() < 3) { - errors.add("Title must have at least 3 characters"); - } else if (request.title().length() > 255) { - errors.add("Title must have at most 255 characters"); - } + /** + * Find all deleted statements. + */ + public Flux findAllDeleted() { + return statementRepository.findAllByDeletedAtIsNotNull(); + } - if (request.examType() == null || request.examType().isBlank()) { - errors.add("Exam type is required"); - } + /** + * Find a statement by ID. + */ + public Mono findById(Long id) { + return statementRepository.findByIdAndDeletedAtIsNull(id) + .switchIfEmpty(Mono.error(ApiException.notFound("Statement not found"))); + } - if (request.durationMinutes() != null && request.durationMinutes() <= 0) { - errors.add("Duration must be greater than zero"); - } + /** + * Find a statement with all its questions. + */ + public Mono findByIdWithQuestions(Long id) { + return findById(id) + .flatMap(statement -> + questionRepository.findAllByStatementIdOrderedByNumber(id) + .collectList() + .flatMap(questions -> { + List questionIds = questions.stream() + .map(Question::getId) + .toList(); + + if (questionIds.isEmpty()) { + return Mono.just(new StatementWithQuestions( + statement, questions, List.of())); + } + + return optionRepository.findAllByQuestionIds(questionIds) + .collectList() + .map(options -> new StatementWithQuestions( + statement, questions, options)); + }) + ); + } - if (request.totalMaxScore() != null && request.totalMaxScore() < 0) { - errors.add("Maximum score cannot be negative"); - } + /** + * Find statements that need review. + */ + public Flux findNeedingReview() { + return statementRepository.findAllByNeedsReviewTrueAndDeletedAtIsNull(); + } - if (request.schoolYearId() == null) { - errors.add("School year is required"); - } + /** + * Find statements created via OCR. + */ + public Flux findFromOcr() { + return statementRepository.findAllFromOcr(); + } - if (request.termId() == null) { - errors.add("Term is required"); - } + /** + * Find statements by school year. + */ + public Flux findBySchoolYear(Long schoolYearId) { + return statementRepository.findAllBySchoolYearIdAndDeletedAtIsNull(schoolYearId); + } - if (request.subjectId() == null) { - errors.add("Subject is required"); - } + /** + * Find statements by subject. + */ + public Flux findBySubject(Long subjectId) { + return statementRepository.findAllBySubjectIdAndDeletedAtIsNull(subjectId); + } - if (request.classId() == null) { - errors.add("Class is required"); - } + /** + * Search statements by title. + */ + public Flux searchByTitle(String searchTerm) { + return statementRepository.searchByTitle(searchTerm); + } - if (!errors.isEmpty()) { - String errorMessage = String.join("; ", errors); - return Mono.error(ApiException.badRequest("Validation errors: " + errorMessage)); - } + /** + * Save a statement. + */ + public Mono save(Statement statement) { + return statementRepository.save(statement); + } - return Mono.empty(); + /** + * Soft delete a statement. + */ + @Transactional + public Mono softDelete(Long id) { + return findById(id) + .flatMap(statement -> { + statement.markAsDeleted(); + return statementRepository.save(statement); + }) + .then(); } - private Mono toResponse(Statement statement) { - Mono schoolYearMono = statement.getSchoolYearId() != null - ? schoolYearRepository.findById(statement.getSchoolYearId()) - .map(this::toSchoolYearResponse) - .switchIfEmpty(Mono.just(new SchoolYearResponse(null, null, null, null, null, null))) - : Mono.just(new SchoolYearResponse(null, null, null, null, null, null)); - - Mono termMono = statement.getTermId() != null - ? termRepository.findById(statement.getTermId()) - .map(this::toTermResponse) - .switchIfEmpty(Mono.just(new TermResponse(null, 0, null, null, null, null))) - : Mono.just(new TermResponse(null, 0, null, null, null, null)); - - Mono subjectMono = statement.getSubjectId() != null - ? subjectRepository.findById(statement.getSubjectId()) - .map(this::toSubjectResponse) - .switchIfEmpty(Mono.just(new SubjectResponse(null, null, null, null, null, null, null))) - : Mono.just(new SubjectResponse(null, null, null, null, null, null, null)); - - Mono classMono = statement.getClassId() != null - ? classRepository.findById(statement.getClassId()) - .map(this::toClassResponse) - .switchIfEmpty(Mono.just(new ClassResponse(null, null, null, null, null, null, null, null))) - : Mono.just(new ClassResponse(null, null, null, null, null, null, null, null)); - - Mono courseMono = statement.getCourseId() != null - ? courseRepository.findById(statement.getCourseId()) - .map(this::toCourseResponse) - .switchIfEmpty(Mono.just(new CourseResponse(null, null, null, null, null, null, null))) - : Mono.just(new CourseResponse(null, null, null, null, null, null, null)); - - Mono createdByMono = statement.getCreatedBy() != null - ? accountRepository.findById(statement.getCreatedBy()) - .map(this::toAccountResponse) - .switchIfEmpty(Mono.just(new AccountBasicResponse(null, null, null))) - : Mono.just(new AccountBasicResponse(null, null, null)); - - return Mono.zip(schoolYearMono, termMono, subjectMono, classMono, courseMono, createdByMono) - .map(tuple -> new StatementResponse( - statement.getId(), - statement.getExamType(), - statement.getDurationMinutes(), - statement.getVariant(), - statement.getTitle(), - statement.getInstructions(), - statement.getTotalMaxScore(), - tuple.getT1(), - tuple.getT2(), - tuple.getT3(), - tuple.getT4(), - tuple.getT5(), - tuple.getT6(), - statement.getVisible(), - statement.getCreatedAt(), - statement.getUpdatedAt() - )); + /** + * Restore a soft-deleted statement. + */ + @Transactional + public Mono restore(Long id) { + return statementRepository.findByIdAndDeletedAtIsNotNull(id) + .switchIfEmpty(Mono.error(ApiException.badRequest("Statement is not deleted"))) + .flatMap(statement -> { + statement.restore(); + return statementRepository.save(statement); + }) + .then(); } - private SchoolYearResponse toSchoolYearResponse(SchoolYear sy) { - return new SchoolYearResponse( - sy.getId(), sy.getStartYear(), sy.getEndYear(), - sy.getCreatedAt(), sy.getUpdatedAt(), sy.getDeletedAt() - ); + /** + * Hard delete a statement (only if already soft-deleted). + */ + @Transactional + public Mono hardDelete(Long id) { + return statementRepository.findByIdAndDeletedAtIsNotNull(id) + .switchIfEmpty(Mono.error(ApiException.badRequest( + "Only deleted statements can be permanently removed"))) + .flatMap(statement -> + // First delete all questions and options + questionRepository.findAllByStatementIdAndDeletedAtIsNull(id) + .flatMap(question -> + optionRepository.softDeleteAllByQuestionId(question.getId()) + .then(questionRepository.delete(question))) + .then(statementRepository.delete(statement))) + .then(); } - private TermResponse toTermResponse(Term term) { - return new TermResponse( - term.getId(), term.getNumber(), term.getName(), - term.getCreatedAt(), term.getUpdatedAt(), term.getDeletedAt() - ); + /** + * Approve review for a statement (mark as reviewed). + */ + @Transactional + public Mono approveReview(Long id) { + return findById(id) + .flatMap(statement -> { + statement.approveReview(); + statement.setVisible(true); + return statementRepository.save(statement); + }); } - private SubjectResponse toSubjectResponse(Subject subject) { - return new SubjectResponse( - subject.getId(), subject.getCode(), subject.getName(), subject.getShortName(), - subject.getCreatedAt(), subject.getUpdatedAt(), subject.getDeletedAt() - ); + /** + * Mark a statement as visible. + */ + public Mono setVisible(Long id, boolean visible) { + return findById(id) + .flatMap(statement -> { + statement.setVisible(visible); + return statementRepository.save(statement); + }); } - private ClassResponse toClassResponse(Class clazz) { - return new ClassResponse( - clazz.getId(), clazz.getCode(), clazz.getGrade(), - null, null, // course e schoolYear serão null aqui para evitar recursão - clazz.getCreatedAt(), clazz.getUpdatedAt(), clazz.getDeletedAt() - ); + // ========================================================================= + // Statistics + // ========================================================================= + + /** + * Count all active statements. + */ + public Mono countActive() { + return statementRepository.countByDeletedAtIsNull(); } - private CourseResponse toCourseResponse(Course course) { - return new CourseResponse( - course.getId(), course.getCode(), course.getName(), course.getDescription(), - course.getCreatedAt(), course.getUpdatedAt(), course.getDeletedAt() - ); + /** + * Count statements needing review. + */ + public Mono countNeedingReview() { + return statementRepository.countByNeedsReviewTrueAndDeletedAtIsNull(); } - private AccountBasicResponse toAccountResponse(Account account) { - return new AccountBasicResponse( - account.getId(), account.getUsername(), account.getEmail() - ); + /** + * Count statements by source. + */ + public Mono countBySource(String source) { + return statementRepository.countBySourceAndDeletedAtIsNull(source); } + + // ========================================================================= + // DTOs + // ========================================================================= + + /** + * Record representing a statement with its questions and options. + */ + public record StatementWithQuestions( + Statement statement, + List questions, + List options + ) {} } diff --git a/services/ocr-service/Dockerfile b/services/ocr-service/Dockerfile new file mode 100644 index 0000000..5d00d92 --- /dev/null +++ b/services/ocr-service/Dockerfile @@ -0,0 +1,124 @@ +# OCR Service Dockerfile +# Multi-stage build for optimized image size + +# ============================================================================= +# Stage 1: Builder +# ============================================================================= +FROM python:3.11-slim as builder + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + gcc \ + g++ \ + libffi-dev \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +# Create virtual environment +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +# Copy requirements and install dependencies +WORKDIR /app +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --upgrade pip setuptools wheel && \ + pip install -r requirements.txt + +# ============================================================================= +# Stage 2: Runtime +# ============================================================================= +FROM python:3.11-slim as runtime + +# Labels +LABEL maintainer="Kixi Team" \ + service="ocr-service" \ + version="1.0.0" \ + description="OCR Service using PaddleOCR-VL for text extraction" + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONFAULTHANDLER=1 \ + PATH="/opt/venv/bin:$PATH" \ + # Application settings + SERVICE_NAME=ocr-service \ + SERVICE_VERSION=1.0.0 \ + ENVIRONMENT=production \ + DEBUG=false \ + HOST=0.0.0.0 \ + PORT=8000 \ + WORKERS=1 \ + # OCR settings + OCR_LANG=pt \ + OCR_USE_GPU=false \ + OCR_USE_ANGLE_CLS=true \ + OCR_SHOW_LOG=false \ + # Processing settings + MAX_IMAGE_SIZE_MB=20.0 \ + MAX_IMAGES_PER_REQUEST=10 \ + MIN_CONFIDENCE_THRESHOLD=0.5 \ + ENABLE_DESKEW=true \ + ENABLE_DENOISE=true \ + TARGET_DPI=300 \ + # Logging + LOG_LEVEL=INFO \ + LOG_FORMAT=json + +# Install runtime dependencies +# Note: libgl1-mesa-glx was renamed to libgl1 in Debian Trixie +RUN apt-get update && apt-get install -y --no-install-recommends \ + # OpenCV dependencies (Debian Trixie compatible) + libgl1 \ + libglib2.0-0t64 \ + libsm6 \ + libxext6 \ + libxrender1 \ + libgomp1 \ + # PDF processing dependencies (for pdf2image fallback) + poppler-utils \ + # Fonts for rendering + fonts-liberation \ + fonts-dejavu-core \ + # Curl for health checks + curl \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +# Create non-root user for security +RUN groupadd --gid 1000 ocr && \ + useradd --uid 1000 --gid ocr --shell /bin/bash --create-home ocr + +# Copy virtual environment from builder +COPY --from=builder /opt/venv /opt/venv + +# Set working directory +WORKDIR /app + +# Copy application code +COPY --chown=ocr:ocr . . + +# Create directories for models and cache +RUN mkdir -p /home/ocr/.paddleocr && \ + chown -R ocr:ocr /home/ocr/.paddleocr + +# Switch to non-root user +USER ocr + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# Run the application +CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/services/ocr-service/README.md b/services/ocr-service/README.md index 40174fa..6e42b2b 100644 --- a/services/ocr-service/README.md +++ b/services/ocr-service/README.md @@ -1,3 +1,326 @@ -# OCR Service +# Kixi OCR Service -Documentação e instruções para o serviço de OCR. +A high-performance OCR (Optical Character Recognition) microservice built with FastAPI and PaddleOCR-VL for extracting structured text from exam images and PDFs. + +## Overview + +This service is part of the Kixi platform and is responsible for: + +- Extracting text from exam paper images +- Detecting and structuring questions, options, and metadata +- Processing multi-page PDF documents +- Providing confidence scores for all extracted data +- Supporting Portuguese and other languages + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ OCR Service │ +├─────────────────────────────────────────────────────────────┤ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ FastAPI │──│ OCR Engine │──│ PaddleOCR-VL │ │ +│ │ Routes │ │ (engine.py)│ │ (PP-OCRv4 models) │ │ +│ └─────────────┘ └─────────────┘ └─────────────────────┘ │ +│ │ │ │ +│ │ ┌──────┴──────┐ │ +│ │ │ │ │ +│ ┌──────┴──────┐ │ ┌──────────┴───────┐ │ +│ │ PDF Handler │ │ │ Image Preprocessor│ │ +│ │ │ │ │ (OpenCV) │ │ +│ └─────────────┘ │ └──────────────────┘ │ +│ │ │ +│ ┌──────┴──────┐ │ +│ │Postprocessor│ │ +│ │ (Regex + ML)│ │ +│ └─────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Features + +- **PaddleOCR-VL Integration**: Uses PP-OCRv4 models for high accuracy +- **Multi-language Support**: Optimized for Portuguese, with support for 80+ languages +- **PDF Processing**: Extract text from multi-page PDF documents +- **Image Preprocessing**: Automatic deskewing, noise reduction, and contrast enhancement +- **Structured Output**: Extracts metadata, questions, options with confidence scores +- **Question Type Detection**: Identifies multiple choice, short answer, development, and true/false questions +- **RESTful API**: Clean FastAPI-based HTTP interface +- **Health Checks**: Built-in health check endpoints for container orchestration +- **Prometheus Metrics**: Optional metrics endpoint for monitoring + +## API Endpoints + +### Core Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/ocr/v1/extract` | Extract text from one or more images/PDFs | +| POST | `/ocr/v1/extract/simple` | Simple single-image extraction | +| GET | `/ocr/health` | Detailed health check | +| GET | `/health` | Simple health check | +| GET | `/ocr/v1/supported-languages` | List supported OCR languages | + +### Request Format + +**POST /ocr/v1/extract** + +```bash +curl -X POST http://localhost:8000/ocr/v1/extract \ + -H "Content-Type: multipart/form-data" \ + -F "images[]=@exam_page1.jpg" \ + -F "images[]=@exam_page2.jpg" \ + -F 'context={"languageHint": "pt"}' +``` + +### Response Format + +```json +{ + "status": "success", + "requestId": "req-abc123def456", + "processingTimeMs": 4870, + "overallConfidence": 0.892, + "document": { + "pageCount": 2, + "mainLanguage": "pt", + "hasTables": true + }, + "metadata": { + "schoolYear": { "value": "2024/2025", "confidence": 0.97 }, + "term": { "value": "2º Trimestre", "confidence": 0.94 }, + "subject": { "value": "Matemática", "confidence": 0.93 }, + "examType": { "value": "Avaliação Periódica", "confidence": 0.91 }, + "durationMinutes": { "value": 120, "confidence": 0.88 }, + "variant": { "value": "A", "confidence": 0.96 } + }, + "questions": [ + { + "number": 1, + "confidence": 0.935, + "text": { "value": "Resolva a equação: 3x - 7 = 14", "confidence": 0.96 }, + "questionType": { "value": "short_answer", "confidence": 0.89 }, + "maxScore": { "value": 5, "confidence": 0.92 }, + "options": [], + "pageIndex": 0 + }, + { + "number": 2, + "confidence": 0.918, + "text": { "value": "Qual das opções representa a raiz quadrada de 64?", "confidence": 0.95 }, + "questionType": { "value": "multiple_choice", "confidence": 0.94 }, + "options": [ + { "optionLabel": "A", "optionText": "6", "confidence": 0.97 }, + { "optionLabel": "B", "optionText": "8", "confidence": 0.96 }, + { "optionLabel": "C", "optionText": "7", "confidence": 0.94 }, + { "optionLabel": "D", "optionText": "9", "confidence": 0.95 } + ], + "pageIndex": 0 + } + ], + "warnings": [ + { "code": "LOW_CONFIDENCE", "field": "class", "confidence": 0.76 } + ] +} +``` + +## Quick Start + +### Prerequisites + +- Python 3.11+ +- Docker (optional, recommended) + +### Local Development + +1. **Create virtual environment:** + +```bash +cd services/ocr-service +python -m venv venv +source venv/bin/activate # Linux/macOS +# or: venv\Scripts\activate # Windows +``` + +2. **Install dependencies:** + +```bash +pip install -r requirements.txt +``` + +3. **Configure environment:** + +```bash +cp env.example .env +# Edit .env as needed +``` + +4. **Run the service:** + +```bash +python -m app.main +# or +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +5. **Access API documentation:** + +Open http://localhost:8000/docs (available in debug mode) + +### Docker + +**Build and run:** + +```bash +# Build image +docker build -t kixi-ocr-service . + +# Run container +docker run -d \ + --name ocr-service \ + -p 8000:8000 \ + -e DEBUG=true \ + -e OCR_LANG=pt \ + kixi-ocr-service +``` + +**Using docker-compose (from project root):** + +```bash +docker-compose up --build ocr-service +``` + +## Configuration + +All configuration is done through environment variables. See `env.example` for the complete list. + +### Key Configuration Options + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | 8000 | Server port | +| `OCR_LANG` | pt | Primary OCR language | +| `OCR_USE_GPU` | false | Enable GPU acceleration | +| `OCR_USE_ANGLE_CLS` | true | Detect rotated text | +| `MAX_IMAGE_SIZE_MB` | 20.0 | Maximum upload size | +| `MAX_IMAGES_PER_REQUEST` | 10 | Maximum images per request | +| `MIN_CONFIDENCE_THRESHOLD` | 0.5 | Minimum confidence threshold | +| `ENABLE_DESKEW` | true | Auto-correct image rotation | +| `ENABLE_DENOISE` | true | Apply noise reduction | +| `DEBUG` | false | Enable debug mode | + +## Project Structure + +``` +ocr-service/ +├── app/ +│ ├── __init__.py +│ ├── main.py # FastAPI application entry point +│ ├── api/ +│ │ ├── __init__.py +│ │ ├── routes.py # API endpoints +│ │ └── pdf_handler.py # PDF processing utilities +│ ├── config/ +│ │ ├── __init__.py +│ │ └── settings.py # Configuration management +│ └── ocr/ +│ ├── __init__.py +│ ├── engine.py # PaddleOCR engine wrapper +│ ├── preprocessing.py # Image preprocessing +│ └── postprocessing.py# OCR result parsing +├── tests/ +│ ├── fixtures/ # Test images +│ └── test_engine.py # Unit tests +├── Dockerfile +├── requirements.txt +├── env.example +└── README.md +``` + +## Testing + +### Run Tests + +```bash +# Run all tests +pytest + +# Run with coverage +pytest --cov=app --cov-report=html + +# Run specific test file +pytest tests/test_engine.py -v +``` + +### Manual Testing + +```bash +# Test with a sample image +curl -X POST http://localhost:8000/ocr/v1/extract/simple \ + -F "image=@tests/fixtures/sample_exam.jpg" +``` + +## Performance Considerations + +### Optimization Tips + +1. **GPU Acceleration**: Set `OCR_USE_GPU=true` for 5-10x faster processing (requires CUDA) +2. **Pre-warming**: The service pre-loads models on startup in production mode +3. **Image Size**: Optimal input resolution is 300 DPI +4. **Batch Processing**: Use the multi-image endpoint for multi-page documents + +### Resource Requirements + +| Mode | RAM | CPU | GPU (optional) | +|------|-----|-----|----------------| +| Minimum | 2GB | 2 cores | - | +| Recommended | 4GB | 4 cores | NVIDIA (CUDA 11+) | +| Production | 8GB+ | 4+ cores | NVIDIA T4 or better | + +## Integration with Backend API + +The OCR service is called by the Spring Boot backend API: + +```java +// Example WebClient call from backend-api +WebClient webClient = WebClient.create("http://ocr-service:8000"); + +Mono response = webClient.post() + .uri("/ocr/v1/extract") + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromMultipartData("images", imageResource)) + .retrieve() + .bodyToMono(OCRResponse.class); +``` + +## Troubleshooting + +### Common Issues + +**Model Download Slow/Failing:** +```bash +# Pre-download models manually +python -c "from paddleocr import PaddleOCR; PaddleOCR(lang='pt')" +``` + +**Out of Memory:** +- Reduce `MAX_IMAGE_SIZE_MB` +- Process fewer images per request +- Consider GPU acceleration + +**Low Accuracy:** +- Ensure input images are at least 300 DPI +- Enable preprocessing (`ENABLE_DESKEW=true`, `ENABLE_DENOISE=true`) +- Check if the correct language is configured + +**PDF Processing Fails:** +- Install poppler-utils: `apt-get install poppler-utils` +- Ensure PyMuPDF is installed: `pip install PyMuPDF` + +## Contributing + +See the main project's [CONTRIBUTING.md](../../CONTRIBUTING.md) for guidelines. + +## License + +This service is part of the Kixi platform and is licensed under Apache License 2.0 with Commons Clause. See [LICENSE](../../LICENSE) for details. \ No newline at end of file diff --git a/services/ocr-service/app/api/pdf_handler.py b/services/ocr-service/app/api/pdf_handler.py new file mode 100644 index 0000000..0dc1339 --- /dev/null +++ b/services/ocr-service/app/api/pdf_handler.py @@ -0,0 +1,289 @@ +""" +PDF Handler Module + +Utilities for extracting images from PDF files for OCR processing. +Supports multi-page PDFs and various PDF rendering qualities. +""" + +import io +from typing import List, Optional + +import numpy as np +from PIL import Image + +import structlog + +logger = structlog.get_logger(__name__) + + +def is_pdf(content: bytes) -> bool: + """ + Check if content is a PDF file based on magic bytes. + + Args: + content: File content as bytes + + Returns: + True if content is a PDF file + """ + return content[:4] == b'%PDF' + + +def extract_images_from_pdf( + pdf_content: bytes, + dpi: int = 300, + first_page: Optional[int] = None, + last_page: Optional[int] = None, +) -> List[np.ndarray]: + """ + Extract images from PDF pages. + + Uses PyMuPDF (fitz) for PDF rendering. Falls back to pdf2image + if PyMuPDF is not available. + + Args: + pdf_content: PDF file content as bytes + dpi: Resolution for rendering (default 300 DPI) + first_page: First page to extract (1-indexed, optional) + last_page: Last page to extract (1-indexed, optional) + + Returns: + List of images as numpy arrays (BGR format for OpenCV) + + Raises: + ValueError: If the PDF cannot be processed + """ + images = [] + + try: + # Try PyMuPDF first (faster and more reliable) + images = _extract_with_pymupdf(pdf_content, dpi, first_page, last_page) + except ImportError: + logger.warning("PyMuPDF not available, falling back to pdf2image") + try: + images = _extract_with_pdf2image(pdf_content, dpi, first_page, last_page) + except ImportError: + raise ValueError( + "No PDF processing library available. " + "Install either PyMuPDF (fitz) or pdf2image with poppler." + ) + except Exception as e: + logger.error("PDF extraction failed", error=str(e)) + raise ValueError(f"Failed to extract images from PDF: {e}") + + if not images: + raise ValueError("No pages could be extracted from PDF") + + logger.info( + "PDF extraction complete", + num_pages=len(images), + dpi=dpi, + ) + + return images + + +def _extract_with_pymupdf( + pdf_content: bytes, + dpi: int = 300, + first_page: Optional[int] = None, + last_page: Optional[int] = None, +) -> List[np.ndarray]: + """ + Extract images using PyMuPDF (fitz). + + Args: + pdf_content: PDF file content as bytes + dpi: Resolution for rendering + first_page: First page to extract (1-indexed) + last_page: Last page to extract (1-indexed) + + Returns: + List of images as numpy arrays (BGR format) + """ + import fitz # PyMuPDF + + images = [] + + # Open PDF from bytes + pdf_document = fitz.open(stream=pdf_content, filetype="pdf") + + try: + # Calculate page range + total_pages = len(pdf_document) + start_page = (first_page - 1) if first_page else 0 + end_page = last_page if last_page else total_pages + + # Ensure valid range + start_page = max(0, min(start_page, total_pages - 1)) + end_page = max(1, min(end_page, total_pages)) + + # Calculate zoom factor for desired DPI + # Default PDF resolution is 72 DPI + zoom = dpi / 72.0 + matrix = fitz.Matrix(zoom, zoom) + + for page_num in range(start_page, end_page): + page = pdf_document[page_num] + + # Render page to pixmap + pixmap = page.get_pixmap(matrix=matrix, alpha=False) + + # Convert to PIL Image + img_data = pixmap.tobytes("ppm") + pil_image = Image.open(io.BytesIO(img_data)) + + # Convert to numpy array (RGB) + img_array = np.array(pil_image) + + # Convert RGB to BGR for OpenCV compatibility + if len(img_array.shape) == 3 and img_array.shape[2] == 3: + img_array = img_array[:, :, ::-1].copy() + + images.append(img_array) + + logger.debug( + "Extracted PDF page", + page_num=page_num + 1, + size=f"{pixmap.width}x{pixmap.height}", + ) + + finally: + pdf_document.close() + + return images + + +def _extract_with_pdf2image( + pdf_content: bytes, + dpi: int = 300, + first_page: Optional[int] = None, + last_page: Optional[int] = None, +) -> List[np.ndarray]: + """ + Extract images using pdf2image (requires poppler). + + Args: + pdf_content: PDF file content as bytes + dpi: Resolution for rendering + first_page: First page to extract (1-indexed) + last_page: Last page to extract (1-indexed) + + Returns: + List of images as numpy arrays (BGR format) + """ + from pdf2image import convert_from_bytes + + images = [] + + # Convert PDF to images + pil_images = convert_from_bytes( + pdf_content, + dpi=dpi, + first_page=first_page, + last_page=last_page, + fmt="RGB", + ) + + for idx, pil_image in enumerate(pil_images): + # Convert to numpy array (RGB) + img_array = np.array(pil_image) + + # Convert RGB to BGR for OpenCV compatibility + if len(img_array.shape) == 3 and img_array.shape[2] == 3: + img_array = img_array[:, :, ::-1].copy() + + images.append(img_array) + + logger.debug( + "Extracted PDF page", + page_num=idx + 1, + size=f"{pil_image.width}x{pil_image.height}", + ) + + return images + + +def get_pdf_info(pdf_content: bytes) -> dict: + """ + Get information about a PDF file. + + Args: + pdf_content: PDF file content as bytes + + Returns: + Dictionary with PDF metadata + """ + info = { + "page_count": 0, + "title": None, + "author": None, + "subject": None, + "creator": None, + "encrypted": False, + } + + try: + import fitz + + pdf_document = fitz.open(stream=pdf_content, filetype="pdf") + + try: + info["page_count"] = len(pdf_document) + info["encrypted"] = pdf_document.is_encrypted + + # Get metadata + metadata = pdf_document.metadata + if metadata: + info["title"] = metadata.get("title") + info["author"] = metadata.get("author") + info["subject"] = metadata.get("subject") + info["creator"] = metadata.get("creator") + + finally: + pdf_document.close() + + except ImportError: + # Fallback: just check if it's a valid PDF + if is_pdf(pdf_content): + info["page_count"] = -1 # Unknown + else: + raise ValueError("Invalid PDF file") + + except Exception as e: + logger.error("Failed to get PDF info", error=str(e)) + raise ValueError(f"Failed to read PDF: {e}") + + return info + + +def validate_pdf(pdf_content: bytes, max_pages: int = 50) -> None: + """ + Validate a PDF file for OCR processing. + + Args: + pdf_content: PDF file content as bytes + max_pages: Maximum allowed pages + + Raises: + ValueError: If the PDF is invalid or exceeds limits + """ + if not is_pdf(pdf_content): + raise ValueError("File is not a valid PDF") + + try: + info = get_pdf_info(pdf_content) + + if info["encrypted"]: + raise ValueError("Encrypted PDFs are not supported") + + if info["page_count"] > max_pages: + raise ValueError( + f"PDF has {info['page_count']} pages, " + f"maximum allowed is {max_pages}" + ) + + except ValueError: + raise + except Exception as e: + raise ValueError(f"Failed to validate PDF: {e}") diff --git a/services/ocr-service/app/ocr/postprocessing.py b/services/ocr-service/app/ocr/postprocessing.py index 12133d5..3b9b54e 100644 --- a/services/ocr-service/app/ocr/postprocessing.py +++ b/services/ocr-service/app/ocr/postprocessing.py @@ -1 +1,702 @@ -# Basic normalization post-OCR +""" +OCR Postprocessing Module + +Provides utilities for processing and structuring raw OCR output: +- Text normalization and cleaning +- Question detection and segmentation +- Metadata extraction (school year, term, subject, etc.) +- Question type inference (multiple_choice, short_answer, development, true_false) +- Option extraction for multiple choice questions +- Confidence score aggregation +""" + +import re +from dataclasses import dataclass, field +from typing import Optional, List, Dict, Any, Tuple +from enum import Enum + + +class QuestionType(str, Enum): + """Supported question types.""" + MULTIPLE_CHOICE = "multiple_choice" + SHORT_ANSWER = "short_answer" + DEVELOPMENT = "development" + TRUE_FALSE = "true_false" + UNKNOWN = "unknown" + + +@dataclass +class TextBlock: + """Represents a block of text with position and confidence.""" + text: str + confidence: float + bbox: Tuple[int, int, int, int] # x1, y1, x2, y2 + page_index: int = 0 + line_index: int = 0 + + +@dataclass +class ExtractedOption: + """Represents an extracted question option.""" + option_label: str + option_text: str + confidence: float + + +@dataclass +class ExtractedQuestion: + """Represents an extracted question with all its components.""" + number: int + text: str + text_confidence: float + question_type: QuestionType + question_type_confidence: float + max_score: Optional[float] = None + max_score_confidence: float = 0.0 + options: List[ExtractedOption] = field(default_factory=list) + page_index: int = 0 + start_y: int = 0 + end_y: int = 0 + + @property + def confidence(self) -> float: + """Calculate overall question confidence.""" + confidences = [self.text_confidence, self.question_type_confidence] + if self.options: + confidences.extend(opt.confidence for opt in self.options) + if self.max_score is not None: + confidences.append(self.max_score_confidence) + return sum(confidences) / len(confidences) if confidences else 0.0 + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return { + "number": self.number, + "confidence": round(self.confidence, 3), + "text": {"value": self.text, "confidence": round(self.text_confidence, 3)}, + "questionType": {"value": self.question_type.value, "confidence": round(self.question_type_confidence, 3)}, + "maxScore": {"value": self.max_score, "confidence": round(self.max_score_confidence, 3)} if self.max_score else {"value": None, "confidence": 0.0}, + "options": [ + { + "optionLabel": opt.option_label, + "optionText": opt.option_text, + "confidence": round(opt.confidence, 3), + } + for opt in self.options + ], + "pageIndex": self.page_index, + "startY": self.start_y, + "endY": self.end_y, + } + + +@dataclass +class MetadataField: + """Represents an extracted metadata field with confidence.""" + value: Optional[Any] + confidence: float + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return {"value": self.value, "confidence": round(self.confidence, 3)} + + +@dataclass +class ExtractedMetadata: + """Represents extracted document metadata.""" + school_year: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) + term: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) + subject: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) + course: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) + class_info: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) + exam_type: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) + duration_minutes: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) + variant: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) + title: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) + instructions: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return { + "schoolYear": self.school_year.to_dict(), + "term": self.term.to_dict(), + "subject": self.subject.to_dict(), + "course": self.course.to_dict(), + "class": self.class_info.to_dict(), + "examType": self.exam_type.to_dict(), + "durationMinutes": self.duration_minutes.to_dict(), + "variant": self.variant.to_dict(), + "title": self.title.to_dict(), + "instructions": self.instructions.to_dict(), + } + + +@dataclass +class UnmappedContent: + """Represents content that couldn't be mapped to questions or metadata.""" + page_index: int + text: str + confidence: float + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return { + "pageIndex": self.page_index, + "text": self.text, + "confidence": round(self.confidence, 3), + } + + +@dataclass +class Warning: + """Represents a processing warning.""" + code: str + field: str + confidence: float + message: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + result = { + "code": self.code, + "field": self.field, + "confidence": round(self.confidence, 3), + } + if self.message: + result["message"] = self.message + return result + + +class OCRPostprocessor: + """ + Postprocessor for OCR results. + + Transforms raw OCR text blocks into structured data including: + - Document metadata (school year, subject, exam type, etc.) + - Questions with their types and options + - Confidence scores for all extracted values + """ + + # Regex patterns for metadata extraction + PATTERNS = { + # School year patterns (e.g., "2024/2025", "Ano Letivo 2024-2025", "2024 - 2025") + "school_year": [ + r"(?:ano\s*let[ií]vo|school\s*year)?\s*(\d{4})\s*[/-]\s*(\d{4})", + r"(\d{4})\s*/\s*(\d{2,4})", + ], + # Term patterns (e.g., "1º Trimestre", "2nd Term", "III Trimestre") + "term": [ + r"(\d)[ºª°]?\s*(?:trimestre|term|período|bimestre)", + r"(?:trimestre|term|período|bimestre)\s*(\d)", + r"(I{1,3}|IV)\s*(?:trimestre|term|período)", + r"(primeiro|segundo|terceiro|quarto|first|second|third|fourth)\s*(?:trimestre|term|período)", + ], + # Subject patterns + "subject": [ + r"(?:disciplina|subject|matéria|cadeira)[:\s]+([A-Za-záàâãéèêíïóôõöúçñÁÀÂÃÉÈÊÍÏÓÔÕÖÚÇÑ\s\-]+)", + r"(?:prova\s+de|exame\s+de|avaliação\s+de)[:\s]+([A-Za-záàâãéèêíïóôõöúçñÁÀÂÃÉÈÊÍÏÓÔÕÖÚÇÑ\s\-]+)", + ], + # Duration patterns (e.g., "120 minutos", "2 horas", "Duration: 90 min") + "duration": [ + r"(?:duração|duration|tempo)[:\s]*(\d+)\s*(?:min(?:utos)?|minutes?)", + r"(\d+)\s*(?:min(?:utos)?|minutes?)", + r"(\d+)\s*(?:horas?|hours?)\s*(?:e\s*(\d+)\s*min(?:utos)?)?", + ], + # Variant patterns (e.g., "Versão A", "Variant B", "Prova A") + "variant": [ + r"(?:versão|variant|prova|versao)\s*([A-Za-z])", + r"(?:grupo|group)\s*([A-Za-z])", + ], + # Class patterns (e.g., "12ª Classe", "10º Ano", "Class 9A") + "class": [ + r"(\d{1,2})[ºª°]?\s*(?:classe|class|ano|grade|série)", + r"(?:classe|class|turma)[:\s]*(\d{1,2})\s*([A-Za-z])?", + r"(\d{1,2})[ºª°]?\s*(?:ano)?\s*-?\s*(?:turma)?\s*([A-Za-z])?", + ], + # Exam type patterns + "exam_type": [ + r"(avaliação\s*(?:periódica|sumativa|formativa|diagnóstica|final|contínua))", + r"(prova\s*(?:escrita|oral|prática|final|parcial))", + r"(exame\s*(?:final|nacional|regional|de\s*época))", + r"(teste\s*(?:escrito|sumativo|formativo))", + r"(examination|assessment|test|exam|quiz)", + ], + # Question number patterns + "question_number": [ + r"^(?:questão|pergunta|question|exercício|problema|item)\s*n?[ºª°]?\s*(\d+)", + r"^(\d+)\s*[.)\-:]\s*", + r"^([IVX]+)\s*[.)\-:]\s*", + ], + # Option patterns (e.g., "A)", "a.", "(A)", "1.") + "option": [ + r"^\(?([A-Da-d])\)?[.):]\s*(.+)", + r"^\(?(\d)\)?[.):]\s*(.+)", + r"^([A-Da-d])\s*[-–—]\s*(.+)", + ], + # Score patterns (e.g., "(5 pontos)", "[10 pts]", "5 valores") + "score": [ + r"[(\[]\s*(\d+(?:[.,]\d+)?)\s*(?:pontos?|pts?|valores?|marks?|points?)\s*[)\]]", + r"(\d+(?:[.,]\d+)?)\s*(?:pontos?|pts?|valores?|marks?|points?)", + ], + # True/False patterns + "true_false": [ + r"(?:verdadeiro|falso|true|false|v\/f|t\/f)", + r"(?:certo|errado|correto|incorreto)", + ], + } + + # Keywords for question type inference + QUESTION_TYPE_KEYWORDS = { + QuestionType.MULTIPLE_CHOICE: [ + "escolha", "assinale", "marque", "alternativa", "opção", + "select", "choose", "mark", "circle", "option", + ], + QuestionType.DEVELOPMENT: [ + "justifique", "explique", "desenvolva", "comente", "discuta", + "argumente", "analise", "compare", "descreva", "fundamente", + "justify", "explain", "discuss", "describe", "analyze", "compare", + ], + QuestionType.SHORT_ANSWER: [ + "calcule", "determine", "resolva", "encontre", "simplifique", + "calculate", "solve", "find", "determine", "simplify", "compute", + ], + QuestionType.TRUE_FALSE: [ + "verdadeiro", "falso", "v/f", "certo", "errado", + "true", "false", "t/f", "correct", "incorrect", + ], + } + + def __init__( + self, + min_confidence_threshold: float = 0.5, + low_confidence_threshold: float = 0.8, + ): + """ + Initialize the postprocessor. + + Args: + min_confidence_threshold: Minimum confidence to include results + low_confidence_threshold: Threshold below which to add warnings + """ + self.min_confidence_threshold = min_confidence_threshold + self.low_confidence_threshold = low_confidence_threshold + + def process( + self, + text_blocks: List[TextBlock], + page_count: int = 1, + ) -> Tuple[ExtractedMetadata, List[ExtractedQuestion], List[UnmappedContent], List[Warning]]: + """ + Process OCR text blocks into structured data. + + Args: + text_blocks: List of text blocks from OCR + page_count: Number of pages in the document + + Returns: + Tuple of (metadata, questions, unmapped_content, warnings) + """ + warnings = [] + unmapped = [] + + # Sort blocks by page and position + sorted_blocks = sorted(text_blocks, key=lambda b: (b.page_index, b.bbox[1], b.bbox[0])) + + # Extract metadata from header blocks (first ~20% of first page) + metadata = self._extract_metadata(sorted_blocks) + warnings.extend(self._generate_metadata_warnings(metadata)) + + # Segment and extract questions + questions = self._extract_questions(sorted_blocks) + warnings.extend(self._generate_question_warnings(questions)) + + # Collect unmapped content + unmapped = self._collect_unmapped(sorted_blocks, metadata, questions) + + return metadata, questions, unmapped, warnings + + def _extract_metadata(self, blocks: List[TextBlock]) -> ExtractedMetadata: + """Extract document metadata from text blocks.""" + metadata = ExtractedMetadata() + + # Combine all text for pattern matching + full_text = " ".join(b.text for b in blocks[:30]) # Use first ~30 blocks + full_text_lower = full_text.lower() + + # Extract school year + for pattern in self.PATTERNS["school_year"]: + match = re.search(pattern, full_text, re.IGNORECASE) + if match: + start_year = match.group(1) + end_year = match.group(2) + if len(end_year) == 2: + end_year = start_year[:2] + end_year + value = f"{start_year}/{end_year}" + confidence = self._estimate_confidence_from_match(match, full_text, blocks) + metadata.school_year = MetadataField(value, confidence) + break + + # Extract term + for pattern in self.PATTERNS["term"]: + match = re.search(pattern, full_text, re.IGNORECASE) + if match: + term_value = match.group(1) + # Normalize term value + term_map = { + "primeiro": "1", "first": "1", "i": "1", + "segundo": "2", "second": "2", "ii": "2", + "terceiro": "3", "third": "3", "iii": "3", + "quarto": "4", "fourth": "4", "iv": "4", + } + normalized = term_map.get(term_value.lower(), term_value) + confidence = self._estimate_confidence_from_match(match, full_text, blocks) + metadata.term = MetadataField(f"{normalized}º Trimestre", confidence) + break + + # Extract subject + for pattern in self.PATTERNS["subject"]: + match = re.search(pattern, full_text, re.IGNORECASE) + if match: + subject = match.group(1).strip() + confidence = self._estimate_confidence_from_match(match, full_text, blocks) + metadata.subject = MetadataField(subject, confidence) + break + + # Extract duration + for pattern in self.PATTERNS["duration"]: + match = re.search(pattern, full_text, re.IGNORECASE) + if match: + minutes = int(match.group(1)) + if match.lastindex >= 2 and match.group(2): + minutes = minutes * 60 + int(match.group(2)) + elif "hora" in match.group(0).lower() or "hour" in match.group(0).lower(): + minutes = minutes * 60 + confidence = self._estimate_confidence_from_match(match, full_text, blocks) + metadata.duration_minutes = MetadataField(minutes, confidence) + break + + # Extract variant + for pattern in self.PATTERNS["variant"]: + match = re.search(pattern, full_text, re.IGNORECASE) + if match: + variant = match.group(1).upper() + confidence = self._estimate_confidence_from_match(match, full_text, blocks) + metadata.variant = MetadataField(variant, confidence) + break + + # Extract class info + for pattern in self.PATTERNS["class"]: + match = re.search(pattern, full_text, re.IGNORECASE) + if match: + class_num = match.group(1) + class_letter = match.group(2).upper() if match.lastindex >= 2 and match.group(2) else "" + value = f"{class_num}ª Classe" + (f" - Turma {class_letter}" if class_letter else "") + confidence = self._estimate_confidence_from_match(match, full_text, blocks) + metadata.class_info = MetadataField(value, confidence) + break + + # Extract exam type + for pattern in self.PATTERNS["exam_type"]: + match = re.search(pattern, full_text, re.IGNORECASE) + if match: + exam_type = match.group(1).strip().title() + confidence = self._estimate_confidence_from_match(match, full_text, blocks) + metadata.exam_type = MetadataField(exam_type, confidence) + break + + # Extract title (usually the first prominent text) + if blocks: + title_candidates = [b for b in blocks[:10] if len(b.text) > 10 and b.confidence > 0.8] + if title_candidates: + title_block = max(title_candidates, key=lambda b: b.confidence) + metadata.title = MetadataField(title_block.text, title_block.confidence) + + # Extract instructions (look for instruction keywords) + instruction_keywords = ["leia", "responda", "atenção", "instruções", "read", "answer", "attention", "instructions"] + for block in blocks[:20]: + if any(kw in block.text.lower() for kw in instruction_keywords): + metadata.instructions = MetadataField(block.text, block.confidence) + break + + return metadata + + def _extract_questions(self, blocks: List[TextBlock]) -> List[ExtractedQuestion]: + """Extract and structure questions from text blocks.""" + questions = [] + current_question = None + question_text_parts = [] + + for block in blocks: + # Check if this block starts a new question + question_number = self._detect_question_number(block.text) + + if question_number is not None: + # Save previous question if exists + if current_question is not None: + current_question.text = self._clean_text(" ".join(question_text_parts)) + questions.append(current_question) + + # Start new question + current_question = ExtractedQuestion( + number=question_number, + text="", + text_confidence=block.confidence, + question_type=QuestionType.UNKNOWN, + question_type_confidence=0.0, + page_index=block.page_index, + start_y=block.bbox[1], + end_y=block.bbox[3], + ) + question_text_parts = [self._remove_question_prefix(block.text)] + + elif current_question is not None: + # Check if this is an option + option = self._detect_option(block.text) + if option: + current_question.options.append(ExtractedOption( + option_label=option[0], + option_text=option[1], + confidence=block.confidence, + )) + else: + # Add to question text + question_text_parts.append(block.text) + + # Update end position + current_question.end_y = max(current_question.end_y, block.bbox[3]) + + # Extract score if present + score = self._detect_score(block.text) + if score is not None: + current_question.max_score = score + current_question.max_score_confidence = block.confidence + + # Save last question + if current_question is not None: + current_question.text = self._clean_text(" ".join(question_text_parts)) + questions.append(current_question) + + # Infer question types + for question in questions: + question.question_type, question.question_type_confidence = self._infer_question_type(question) + + return questions + + def _detect_question_number(self, text: str) -> Optional[int]: + """Detect if text starts with a question number.""" + text = text.strip() + + for pattern in self.PATTERNS["question_number"]: + match = re.match(pattern, text, re.IGNORECASE) + if match: + num_str = match.group(1) + # Convert Roman numerals + roman_map = {"I": 1, "II": 2, "III": 3, "IV": 4, "V": 5, "VI": 6, "VII": 7, "VIII": 8, "IX": 9, "X": 10} + if num_str.upper() in roman_map: + return roman_map[num_str.upper()] + return int(num_str) + + return None + + def _remove_question_prefix(self, text: str) -> str: + """Remove question number prefix from text.""" + for pattern in self.PATTERNS["question_number"]: + text = re.sub(pattern, "", text, flags=re.IGNORECASE).strip() + return text + + def _detect_option(self, text: str) -> Optional[Tuple[str, str]]: + """Detect if text is a question option.""" + text = text.strip() + + for pattern in self.PATTERNS["option"]: + match = re.match(pattern, text, re.IGNORECASE) + if match: + label = match.group(1).upper() + option_text = match.group(2).strip() + return (label, option_text) + + return None + + def _detect_score(self, text: str) -> Optional[float]: + """Detect score/points in text.""" + for pattern in self.PATTERNS["score"]: + match = re.search(pattern, text, re.IGNORECASE) + if match: + score_str = match.group(1).replace(",", ".") + return float(score_str) + return None + + def _infer_question_type(self, question: ExtractedQuestion) -> Tuple[QuestionType, float]: + """Infer question type based on text and options.""" + text_lower = question.text.lower() + + # If has options, likely multiple choice + if question.options: + return QuestionType.MULTIPLE_CHOICE, 0.95 + + # Check for true/false patterns + for pattern in self.PATTERNS["true_false"]: + if re.search(pattern, text_lower, re.IGNORECASE): + return QuestionType.TRUE_FALSE, 0.90 + + # Check keywords + max_confidence = 0.0 + detected_type = QuestionType.UNKNOWN + + for q_type, keywords in self.QUESTION_TYPE_KEYWORDS.items(): + matches = sum(1 for kw in keywords if kw in text_lower) + if matches > 0: + confidence = min(0.7 + (matches * 0.1), 0.95) + if confidence > max_confidence: + max_confidence = confidence + detected_type = q_type + + if detected_type != QuestionType.UNKNOWN: + return detected_type, max_confidence + + # Default to short answer if we can't determine + return QuestionType.SHORT_ANSWER, 0.5 + + def _estimate_confidence_from_match( + self, + match: re.Match, + full_text: str, + blocks: List[TextBlock], + ) -> float: + """Estimate confidence for a regex match based on surrounding text blocks.""" + # Find blocks that contain the match + match_start = match.start() + match_text = match.group(0) + + base_confidence = 0.85 + + # Adjust based on block confidence + for block in blocks: + if match_text in block.text: + base_confidence = (base_confidence + block.confidence) / 2 + break + + return base_confidence + + def _clean_text(self, text: str) -> str: + """Clean and normalize extracted text.""" + # Remove extra whitespace + text = re.sub(r"\s+", " ", text).strip() + + # Remove score annotations + for pattern in self.PATTERNS["score"]: + text = re.sub(pattern, "", text) + + return text.strip() + + def _collect_unmapped( + self, + blocks: List[TextBlock], + metadata: ExtractedMetadata, + questions: List[ExtractedQuestion], + ) -> List[UnmappedContent]: + """Collect text blocks that weren't mapped to metadata or questions.""" + # For simplicity, return empty list - full implementation would track used blocks + return [] + + def _generate_metadata_warnings(self, metadata: ExtractedMetadata) -> List[Warning]: + """Generate warnings for low-confidence metadata fields.""" + warnings = [] + + fields = [ + ("schoolYear", metadata.school_year), + ("term", metadata.term), + ("subject", metadata.subject), + ("class", metadata.class_info), + ("examType", metadata.exam_type), + ] + + for field_name, field_value in fields: + if field_value.value is not None and field_value.confidence < self.low_confidence_threshold: + warnings.append(Warning( + code="LOW_CONFIDENCE", + field=field_name, + confidence=field_value.confidence, + )) + + return warnings + + def _generate_question_warnings(self, questions: List[ExtractedQuestion]) -> List[Warning]: + """Generate warnings for low-confidence questions.""" + warnings = [] + + for question in questions: + if question.confidence < self.low_confidence_threshold: + warnings.append(Warning( + code="LOW_CONFIDENCE", + field=f"question_{question.number}", + confidence=question.confidence, + )) + + if question.question_type == QuestionType.UNKNOWN: + warnings.append(Warning( + code="UNKNOWN_QUESTION_TYPE", + field=f"question_{question.number}", + confidence=question.question_type_confidence, + )) + + return warnings + + +def normalize_text(text: str) -> str: + """ + Normalize text for consistent processing. + + - Removes extra whitespace + - Normalizes quotes and dashes + - Fixes common OCR errors + """ + # Normalize whitespace + text = re.sub(r"\s+", " ", text).strip() + + # Normalize quotes + text = re.sub(r"[""„‟]", '"', text) + text = re.sub(r"[''‚‛]", "'", text) + + # Normalize dashes + text = re.sub(r"[–—−]", "-", text) + + # Common OCR fixes + ocr_fixes = { + r"\bl\b": "I", # lowercase L to uppercase I + r"0(?=[A-Za-z])": "O", # zero before letter to O + r"(?<=[A-Za-z])0": "O", # zero after letter to O + } + + for pattern, replacement in ocr_fixes.items(): + text = re.sub(pattern, replacement, text) + + return text + + +def detect_language(text: str) -> str: + """ + Detect the primary language of the text. + + Returns ISO 639-1 language code (pt, en, etc.) + """ + # Simple keyword-based detection + pt_keywords = ["de", "da", "do", "em", "para", "com", "uma", "não", "que", "se", "os", "as"] + en_keywords = ["the", "and", "is", "are", "for", "with", "you", "that", "this", "have"] + + text_lower = text.lower() + words = text_lower.split() + + pt_count = sum(1 for w in words if w in pt_keywords) + en_count = sum(1 for w in words if w in en_keywords) + + if pt_count > en_count: + return "pt" + elif en_count > pt_count: + return "en" + else: + return "pt" # Default to Portuguese + + +# Default postprocessor instance +default_postprocessor = OCRPostprocessor() diff --git a/services/ocr-service/env.example b/services/ocr-service/env.example new file mode 100644 index 0000000..b0e1468 --- /dev/null +++ b/services/ocr-service/env.example @@ -0,0 +1,96 @@ +# OCR Service Environment Configuration +# Copy this file to .env and adjust values as needed + +# ============================================================================= +# SERVICE CONFIGURATION +# ============================================================================= +SERVICE_NAME=ocr-service +SERVICE_VERSION=1.0.0 +ENVIRONMENT=development +DEBUG=true + +# ============================================================================= +# SERVER CONFIGURATION +# ============================================================================= +HOST=0.0.0.0 +PORT=8000 +WORKERS=1 + +# ============================================================================= +# OCR CONFIGURATION +# ============================================================================= +# Primary language for OCR (pt, en, es, fr, de, etc.) +OCR_LANG=pt + +# Use GPU acceleration (requires CUDA and paddlepaddle-gpu) +OCR_USE_GPU=false + +# Use angle classification for rotated text detection +OCR_USE_ANGLE_CLS=true + +# Show PaddleOCR internal logs +OCR_SHOW_LOG=false + +# Custom model directories (optional - leave empty for default models) +# OCR_DET_MODEL_DIR=/path/to/detection/model +# OCR_REC_MODEL_DIR=/path/to/recognition/model +# OCR_CLS_MODEL_DIR=/path/to/classification/model + +# ============================================================================= +# PROCESSING CONFIGURATION +# ============================================================================= +# Maximum image size in MB +MAX_IMAGE_SIZE_MB=20.0 + +# Maximum number of images per request +MAX_IMAGES_PER_REQUEST=10 + +# Timeout for OCR processing in seconds +PROCESSING_TIMEOUT_SECONDS=120 + +# Minimum confidence threshold for text extraction (0.0 - 1.0) +MIN_CONFIDENCE_THRESHOLD=0.5 + +# ============================================================================= +# IMAGE PREPROCESSING +# ============================================================================= +# Enable automatic deskewing (rotation correction) +ENABLE_DESKEW=true + +# Enable noise reduction +ENABLE_DENOISE=true + +# Target DPI for image processing +TARGET_DPI=300 + +# ============================================================================= +# CACHING (Optional) +# ============================================================================= +ENABLE_CACHE=false +CACHE_TTL_SECONDS=3600 +# REDIS_URL=redis://localhost:6379/0 + +# ============================================================================= +# SECURITY (Optional) +# ============================================================================= +# Enable authentication +ENABLE_AUTH=false + +# JWT configuration (if authentication is enabled) +# JWT_SECRET_KEY=your-secret-key-here +# JWT_ALGORITHM=HS256 + +# Simple API key authentication (alternative to JWT) +# API_KEY=your-api-key-here + +# ============================================================================= +# LOGGING +# ============================================================================= +LOG_LEVEL=INFO +LOG_FORMAT=json + +# ============================================================================= +# METRICS (Optional) +# ============================================================================= +ENABLE_METRICS=true +METRICS_PORT=9090 diff --git a/services/ocr-service/pytest.ini b/services/ocr-service/pytest.ini new file mode 100644 index 0000000..a7ca7b6 --- /dev/null +++ b/services/ocr-service/pytest.ini @@ -0,0 +1,43 @@ +[pytest] +# Test discovery +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +# Markers +markers = + slow: marks tests as slow (deselect with '-m "not slow"') + integration: marks tests as integration tests (deselect with '-m "not integration"') + gpu: marks tests that require GPU + +# Async mode +asyncio_mode = auto + +# Logging +log_cli = true +log_cli_level = INFO +log_cli_format = %(asctime)s [%(levelname)8s] %(name)s: %(message)s +log_cli_date_format = %Y-%m-%d %H:%M:%S + +# Coverage +addopts = + -v + --tb=short + --strict-markers + -ra + +# Ignore warnings +filterwarnings = + ignore::DeprecationWarning + ignore::PendingDeprecationWarning + ignore::UserWarning + +# Timeout for tests (in seconds) +timeout = 120 + +# Parallel execution (requires pytest-xdist) +# addopts = -n auto + +# Minimum version +minversion = 8.0 diff --git a/services/ocr-service/tests/__init__.py b/services/ocr-service/tests/__init__.py new file mode 100644 index 0000000..bd4bff8 --- /dev/null +++ b/services/ocr-service/tests/__init__.py @@ -0,0 +1,8 @@ +""" +OCR Service Test Suite + +This package contains tests for the OCR service components: +- Unit tests for preprocessing, postprocessing, and engine modules +- Integration tests for the complete OCR pipeline +- API endpoint tests +""" diff --git a/services/ocr-service/tests/conftest.py b/services/ocr-service/tests/conftest.py new file mode 100644 index 0000000..28920e0 --- /dev/null +++ b/services/ocr-service/tests/conftest.py @@ -0,0 +1,400 @@ +""" +Pytest Configuration and Fixtures + +Shared fixtures for OCR service tests. +""" + +import io +import pytest +import numpy as np +from PIL import Image +from unittest.mock import MagicMock, patch + + +# ============================================================================= +# Image Fixtures +# ============================================================================= + +@pytest.fixture +def sample_image(): + """Create a sample test image (BGR format for OpenCV).""" + # Create a white image with some dark regions simulating text + img = np.ones((480, 640, 3), dtype=np.uint8) * 255 + + # Add dark regions to simulate text lines + img[50:80, 50:300] = 0 # Header line + img[100:130, 50:350] = 0 # Title line + img[150:175, 50:250] = 0 # Metadata line 1 + img[180:205, 50:200] = 0 # Metadata line 2 + + # Question 1 + img[240:265, 50:400] = 0 # Question text + img[280:300, 70:150] = 0 # Option A + img[310:330, 70:160] = 0 # Option B + img[340:360, 70:155] = 0 # Option C + img[370:390, 70:165] = 0 # Option D + + return img + + +@pytest.fixture +def sample_grayscale_image(): + """Create a sample grayscale test image.""" + img = np.ones((480, 640), dtype=np.uint8) * 255 + img[50:100, 50:300] = 0 + img[150:200, 50:350] = 0 + return img + + +@pytest.fixture +def sample_pil_image(): + """Create a sample PIL Image.""" + img = Image.new("RGB", (640, 480), color="white") + return img + + +@pytest.fixture +def sample_image_bytes(sample_pil_image): + """Create sample image as PNG bytes.""" + buffer = io.BytesIO() + sample_pil_image.save(buffer, format="PNG") + buffer.seek(0) + return buffer.getvalue() + + +@pytest.fixture +def sample_jpeg_bytes(sample_pil_image): + """Create sample image as JPEG bytes.""" + buffer = io.BytesIO() + sample_pil_image.save(buffer, format="JPEG") + buffer.seek(0) + return buffer.getvalue() + + +@pytest.fixture +def large_image(): + """Create a large test image for resize testing.""" + return np.ones((5000, 5000, 3), dtype=np.uint8) * 255 + + +@pytest.fixture +def small_image(): + """Create a small test image.""" + return np.ones((100, 100, 3), dtype=np.uint8) * 255 + + +# ============================================================================= +# Text Block Fixtures +# ============================================================================= + +@pytest.fixture +def sample_text_blocks(): + """Sample text blocks for postprocessing tests.""" + from app.ocr.postprocessing import TextBlock + + return [ + TextBlock( + text="PROVA DE MATEMÁTICA", + confidence=0.95, + bbox=(50, 20, 300, 50), + page_index=0, + line_index=0 + ), + TextBlock( + text="Ano Letivo 2024/2025", + confidence=0.92, + bbox=(50, 60, 250, 90), + page_index=0, + line_index=1 + ), + TextBlock( + text="1º Trimestre", + confidence=0.90, + bbox=(50, 100, 200, 130), + page_index=0, + line_index=2 + ), + TextBlock( + text="Duração: 120 minutos", + confidence=0.88, + bbox=(50, 140, 250, 170), + page_index=0, + line_index=3 + ), + TextBlock( + text="Versão A", + confidence=0.94, + bbox=(450, 100, 520, 130), + page_index=0, + line_index=4 + ), + TextBlock( + text="1. Calcule o valor de x na equação 2x + 5 = 15:", + confidence=0.94, + bbox=(50, 200, 450, 230), + page_index=0, + line_index=5 + ), + TextBlock( + text="A) 5", + confidence=0.91, + bbox=(70, 240, 120, 270), + page_index=0, + line_index=6 + ), + TextBlock( + text="B) 10", + confidence=0.92, + bbox=(70, 280, 130, 310), + page_index=0, + line_index=7 + ), + TextBlock( + text="C) 15", + confidence=0.93, + bbox=(70, 320, 130, 350), + page_index=0, + line_index=8 + ), + TextBlock( + text="D) 20", + confidence=0.90, + bbox=(70, 360, 130, 390), + page_index=0, + line_index=9 + ), + TextBlock( + text="(5 pontos)", + confidence=0.87, + bbox=(460, 200, 540, 230), + page_index=0, + line_index=10 + ), + TextBlock( + text="2. Justifique por que o triângulo ABC é isósceles:", + confidence=0.89, + bbox=(50, 420, 400, 450), + page_index=0, + line_index=11 + ), + ] + + +@pytest.fixture +def english_text_blocks(): + """Sample English text blocks.""" + from app.ocr.postprocessing import TextBlock + + return [ + TextBlock( + text="MATHEMATICS EXAM", + confidence=0.95, + bbox=(50, 20, 300, 50), + page_index=0, + line_index=0 + ), + TextBlock( + text="School Year 2024/2025", + confidence=0.92, + bbox=(50, 60, 250, 90), + page_index=0, + line_index=1 + ), + TextBlock( + text="1. Calculate the value of x:", + confidence=0.94, + bbox=(50, 200, 350, 230), + page_index=0, + line_index=2 + ), + ] + + +# ============================================================================= +# Mock Fixtures +# ============================================================================= + +@pytest.fixture +def mock_paddle_ocr(): + """Mock PaddleOCR class.""" + with patch("app.ocr.engine.PaddleOCR") as mock_cls: + mock_instance = MagicMock() + mock_cls.return_value = mock_instance + + # Mock OCR result format + mock_instance.ocr.return_value = [ + [ + [[[10, 10], [200, 10], [200, 40], [10, 40]], ("PROVA DE MATEMÁTICA", 0.95)], + [[[10, 50], [250, 50], [250, 80], [10, 80]], ("Ano Letivo 2024/2025", 0.92)], + [[[10, 100], [200, 100], [200, 130], [10, 130]], ("1º Trimestre", 0.90)], + [[[10, 150], [300, 150], [300, 180], [10, 180]], ("1. Calcule o valor de x:", 0.95)], + [[[30, 200], [100, 200], [100, 230], [30, 230]], ("A) 5", 0.92)], + [[[30, 240], [110, 240], [110, 270], [30, 270]], ("B) 10", 0.91)], + [[[30, 280], [110, 280], [110, 310], [30, 310]], ("C) 15", 0.93)], + [[[30, 320], [110, 320], [110, 350], [30, 350]], ("D) 20", 0.90)], + ] + ] + + yield mock_cls + + +@pytest.fixture +def mock_paddle_ocr_empty(): + """Mock PaddleOCR with empty results.""" + with patch("app.ocr.engine.PaddleOCR") as mock_cls: + mock_instance = MagicMock() + mock_cls.return_value = mock_instance + mock_instance.ocr.return_value = [[]] + yield mock_cls + + +@pytest.fixture +def mock_paddle_ocr_error(): + """Mock PaddleOCR that raises an error.""" + with patch("app.ocr.engine.PaddleOCR") as mock_cls: + mock_instance = MagicMock() + mock_cls.return_value = mock_instance + mock_instance.ocr.side_effect = Exception("OCR processing failed") + yield mock_cls + + +# ============================================================================= +# PDF Fixtures +# ============================================================================= + +@pytest.fixture +def sample_pdf_content(): + """Create minimal valid PDF content for testing.""" + # This is a minimal valid PDF structure + pdf_content = b"""%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >> +endobj +xref +0 4 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +trailer +<< /Size 4 /Root 1 0 R >> +startxref +196 +%%EOF""" + return pdf_content + + +@pytest.fixture +def invalid_pdf_content(): + """Create invalid PDF content for testing.""" + return b"This is not a valid PDF file" + + +# ============================================================================= +# Configuration Fixtures +# ============================================================================= + +@pytest.fixture +def mock_settings(): + """Mock settings for testing.""" + with patch("app.config.settings") as mock: + mock.service_name = "ocr-service" + mock.service_version = "1.0.0" + mock.environment = "test" + mock.debug = True + mock.host = "0.0.0.0" + mock.port = 8000 + mock.workers = 1 + mock.ocr_lang = "pt" + mock.ocr_use_gpu = False + mock.ocr_use_angle_cls = True + mock.ocr_show_log = False + mock.max_image_size_mb = 20.0 + mock.max_images_per_request = 10 + mock.min_confidence_threshold = 0.5 + mock.enable_deskew = True + mock.enable_denoise = True + mock.target_dpi = 300 + mock.log_level = "INFO" + mock.log_format = "json" + mock.enable_metrics = False + yield mock + + +# ============================================================================= +# API Client Fixtures +# ============================================================================= + +@pytest.fixture +def test_client(): + """Create FastAPI test client.""" + from fastapi.testclient import TestClient + from app.main import app + + with TestClient(app) as client: + yield client + + +@pytest.fixture +def async_client(): + """Create async test client.""" + import httpx + from app.main import app + + return httpx.AsyncClient(app=app, base_url="http://test") + + +# ============================================================================= +# Pytest Configuration +# ============================================================================= + +def pytest_configure(config): + """Configure pytest markers.""" + config.addinivalue_line( + "markers", "slow: marks tests as slow (deselect with '-m \"not slow\"')" + ) + config.addinivalue_line( + "markers", "integration: marks tests as integration tests" + ) + config.addinivalue_line( + "markers", "gpu: marks tests that require GPU" + ) + + +def pytest_collection_modifyitems(config, items): + """Modify test collection based on markers.""" + # Skip slow tests unless explicitly requested + if not config.getoption("--runslow", default=False): + skip_slow = pytest.mark.skip(reason="need --runslow option to run") + for item in items: + if "slow" in item.keywords: + item.add_marker(skip_slow) + + # Skip integration tests unless explicitly requested + if not config.getoption("--runintegration", default=False): + skip_integration = pytest.mark.skip(reason="need --runintegration option to run") + for item in items: + if "integration" in item.keywords: + item.add_marker(skip_integration) + + +def pytest_addoption(parser): + """Add custom command line options.""" + parser.addoption( + "--runslow", + action="store_true", + default=False, + help="run slow tests" + ) + parser.addoption( + "--runintegration", + action="store_true", + default=False, + help="run integration tests" + ) diff --git a/services/ocr-service/tests/test_engine.py b/services/ocr-service/tests/test_engine.py index 9f6f0b9..45ef52f 100644 --- a/services/ocr-service/tests/test_engine.py +++ b/services/ocr-service/tests/test_engine.py @@ -1 +1,655 @@ -# Tests for OCR engine +""" +OCR Engine Test Suite + +Comprehensive tests for the OCR processing engine. +""" + +import pytest +import numpy as np +from unittest.mock import Mock, patch, MagicMock +from PIL import Image +import io + +from app.ocr.engine import ( + OCREngine, + OCRResult, + DocumentInfo, + get_engine, + initialize_engine, + shutdown_engine, +) +from app.ocr.preprocessing import ( + ImagePreprocessor, + PreprocessingResult, + load_image_from_bytes, + load_image_from_pil, + image_to_bytes, +) +from app.ocr.postprocessing import ( + OCRPostprocessor, + TextBlock, + ExtractedMetadata, + ExtractedQuestion, + ExtractedOption, + QuestionType, + MetadataField, + normalize_text, + detect_language, +) + + +# ============================================================================= +# Fixtures +# ============================================================================= + +@pytest.fixture +def sample_image(): + """Create a sample test image.""" + # Create a simple white image with some text-like patterns + img = np.ones((480, 640, 3), dtype=np.uint8) * 255 + # Add some dark regions to simulate text + img[50:100, 50:200] = 0 + img[150:180, 50:300] = 0 + img[200:230, 50:250] = 0 + return img + + +@pytest.fixture +def sample_pil_image(): + """Create a sample PIL Image.""" + img = Image.new("RGB", (640, 480), color="white") + return img + + +@pytest.fixture +def sample_image_bytes(sample_pil_image): + """Create sample image as bytes.""" + buffer = io.BytesIO() + sample_pil_image.save(buffer, format="PNG") + buffer.seek(0) + return buffer.getvalue() + + +@pytest.fixture +def mock_paddle_ocr(): + """Mock PaddleOCR class.""" + with patch("app.ocr.engine.PaddleOCR") as mock_cls: + mock_instance = MagicMock() + mock_cls.return_value = mock_instance + + # Mock OCR result format + mock_instance.ocr.return_value = [ + [ + [[[10, 10], [200, 10], [200, 40], [10, 40]], ("1. Calcule o valor de x:", 0.95)], + [[[10, 50], [300, 50], [300, 80], [10, 80]], ("A) 5", 0.92)], + [[[10, 90], [300, 90], [300, 120], [10, 120]], ("B) 10", 0.91)], + [[[10, 130], [300, 130], [300, 160], [10, 160]], ("C) 15", 0.93)], + [[[10, 170], [300, 170], [300, 200], [10, 200]], ("D) 20", 0.90)], + ] + ] + + yield mock_cls + + +@pytest.fixture +def text_blocks(): + """Sample text blocks for postprocessing tests.""" + return [ + TextBlock(text="PROVA DE MATEMÁTICA", confidence=0.95, bbox=(50, 20, 300, 50), page_index=0, line_index=0), + TextBlock(text="Ano Letivo 2024/2025", confidence=0.92, bbox=(50, 60, 250, 90), page_index=0, line_index=1), + TextBlock(text="1º Trimestre", confidence=0.90, bbox=(50, 100, 200, 130), page_index=0, line_index=2), + TextBlock(text="Duração: 120 minutos", confidence=0.88, bbox=(50, 140, 250, 170), page_index=0, line_index=3), + TextBlock(text="1. Calcule o valor de x na equação:", confidence=0.94, bbox=(50, 200, 400, 230), page_index=0, line_index=4), + TextBlock(text="A) 5", confidence=0.91, bbox=(70, 240, 120, 270), page_index=0, line_index=5), + TextBlock(text="B) 10", confidence=0.92, bbox=(70, 280, 130, 310), page_index=0, line_index=6), + TextBlock(text="C) 15", confidence=0.93, bbox=(70, 320, 130, 350), page_index=0, line_index=7), + TextBlock(text="D) 20", confidence=0.90, bbox=(70, 360, 130, 390), page_index=0, line_index=8), + TextBlock(text="2. Justifique sua resposta:", confidence=0.89, bbox=(50, 420, 350, 450), page_index=0, line_index=9), + ] + + +# ============================================================================= +# Preprocessing Tests +# ============================================================================= + +class TestImagePreprocessor: + """Tests for ImagePreprocessor class.""" + + def test_init(self): + """Test preprocessor initialization.""" + preprocessor = ImagePreprocessor( + enable_deskew=True, + enable_denoise=True, + target_dpi=300, + ) + assert preprocessor.enable_deskew is True + assert preprocessor.enable_denoise is True + assert preprocessor.target_dpi == 300 + + def test_preprocess_grayscale_conversion(self, sample_image): + """Test that preprocessing converts to grayscale and back.""" + preprocessor = ImagePreprocessor(enable_deskew=False, enable_denoise=False) + result = preprocessor.preprocess(sample_image) + + assert isinstance(result, PreprocessingResult) + assert result.image.shape[2] == 3 # Should be BGR + assert "grayscale_conversion" in result.preprocessing_applied + + def test_preprocess_with_denoise(self, sample_image): + """Test preprocessing with noise reduction enabled.""" + preprocessor = ImagePreprocessor(enable_deskew=False, enable_denoise=True) + result = preprocessor.preprocess(sample_image) + + assert "denoise" in result.preprocessing_applied + + def test_preprocess_with_deskew(self, sample_image): + """Test preprocessing with deskewing enabled.""" + preprocessor = ImagePreprocessor(enable_deskew=True, enable_denoise=False) + result = preprocessor.preprocess(sample_image) + + # Deskew might not always find lines to correct + assert isinstance(result.rotation_angle, float) + + def test_preprocess_calculates_hash(self, sample_image): + """Test that preprocessing calculates image hash.""" + preprocessor = ImagePreprocessor() + result = preprocessor.preprocess(sample_image) + + assert result.image_hash is not None + assert len(result.image_hash) == 64 # SHA-256 hex + + def test_resize_if_needed_large_image(self): + """Test resizing large images.""" + large_image = np.zeros((5000, 5000, 3), dtype=np.uint8) + resized, scale = ImagePreprocessor.resize_if_needed(large_image, max_dimension=4096) + + assert max(resized.shape[:2]) <= 4096 + assert scale < 1.0 + + def test_resize_if_needed_small_image(self): + """Test that small images are not resized unnecessarily.""" + small_image = np.zeros((500, 500, 3), dtype=np.uint8) + resized, scale = ImagePreprocessor.resize_if_needed(small_image) + + assert scale == 1.0 + assert resized.shape == small_image.shape + + +class TestLoadImageFunctions: + """Tests for image loading functions.""" + + def test_load_image_from_bytes(self, sample_image_bytes): + """Test loading image from bytes.""" + image = load_image_from_bytes(sample_image_bytes) + + assert isinstance(image, np.ndarray) + assert len(image.shape) == 3 + assert image.shape[2] == 3 # BGR + + def test_load_image_from_bytes_invalid(self): + """Test loading invalid bytes raises ValueError.""" + with pytest.raises(ValueError, match="Failed to decode"): + load_image_from_bytes(b"invalid image data") + + def test_load_image_from_pil(self, sample_pil_image): + """Test loading image from PIL Image.""" + image = load_image_from_pil(sample_pil_image) + + assert isinstance(image, np.ndarray) + assert image.shape == (480, 640, 3) + + def test_image_to_bytes(self, sample_image): + """Test converting image to bytes.""" + img_bytes = image_to_bytes(sample_image, format="PNG") + + assert isinstance(img_bytes, bytes) + assert len(img_bytes) > 0 + # Verify it's a valid PNG + assert img_bytes[:8] == b'\x89PNG\r\n\x1a\n' + + +# ============================================================================= +# Postprocessing Tests +# ============================================================================= + +class TestOCRPostprocessor: + """Tests for OCRPostprocessor class.""" + + def test_init(self): + """Test postprocessor initialization.""" + postprocessor = OCRPostprocessor( + min_confidence_threshold=0.5, + low_confidence_threshold=0.8, + ) + assert postprocessor.min_confidence_threshold == 0.5 + assert postprocessor.low_confidence_threshold == 0.8 + + def test_process_extracts_metadata(self, text_blocks): + """Test that postprocessing extracts metadata.""" + postprocessor = OCRPostprocessor() + metadata, questions, unmapped, warnings = postprocessor.process(text_blocks) + + assert isinstance(metadata, ExtractedMetadata) + assert metadata.school_year.value == "2024/2025" + assert metadata.term.value is not None + + def test_process_extracts_questions(self, text_blocks): + """Test that postprocessing extracts questions.""" + postprocessor = OCRPostprocessor() + metadata, questions, unmapped, warnings = postprocessor.process(text_blocks) + + assert len(questions) >= 1 + assert isinstance(questions[0], ExtractedQuestion) + assert questions[0].number == 1 + + def test_process_detects_multiple_choice(self, text_blocks): + """Test detection of multiple choice questions.""" + postprocessor = OCRPostprocessor() + metadata, questions, unmapped, warnings = postprocessor.process(text_blocks) + + # First question should be multiple choice (has options A, B, C, D) + if len(questions) > 0: + first_question = questions[0] + if first_question.options: + assert first_question.question_type == QuestionType.MULTIPLE_CHOICE + + def test_process_generates_warnings(self, text_blocks): + """Test that warnings are generated for low confidence fields.""" + postprocessor = OCRPostprocessor(low_confidence_threshold=0.95) + metadata, questions, unmapped, warnings = postprocessor.process(text_blocks) + + # With threshold of 0.95, most fields should trigger warnings + assert isinstance(warnings, list) + + +class TestTextNormalization: + """Tests for text normalization functions.""" + + def test_normalize_text_removes_extra_whitespace(self): + """Test whitespace normalization.""" + text = "Hello world test" + normalized = normalize_text(text) + assert normalized == "Hello world test" + + def test_normalize_text_normalizes_quotes(self): + """Test quote normalization.""" + text = '"Hello" and 'world'" + normalized = normalize_text(text) + assert '"' in normalized + assert "'" in normalized + + def test_normalize_text_normalizes_dashes(self): + """Test dash normalization.""" + text = "option–one—two−three" + normalized = normalize_text(text) + assert "–" not in normalized + assert "—" not in normalized + + +class TestLanguageDetection: + """Tests for language detection.""" + + def test_detect_portuguese(self): + """Test detection of Portuguese text.""" + text = "O aluno deve resolver as questões de matemática com atenção" + lang = detect_language(text) + assert lang == "pt" + + def test_detect_english(self): + """Test detection of English text.""" + text = "The student should solve the math questions carefully" + lang = detect_language(text) + assert lang == "en" + + def test_detect_empty_text(self): + """Test detection with empty text defaults to Portuguese.""" + lang = detect_language("") + assert lang == "pt" + + +class TestQuestionType: + """Tests for question type enum and inference.""" + + def test_question_type_values(self): + """Test QuestionType enum values.""" + assert QuestionType.MULTIPLE_CHOICE.value == "multiple_choice" + assert QuestionType.SHORT_ANSWER.value == "short_answer" + assert QuestionType.DEVELOPMENT.value == "development" + assert QuestionType.TRUE_FALSE.value == "true_false" + assert QuestionType.UNKNOWN.value == "unknown" + + +class TestExtractedQuestion: + """Tests for ExtractedQuestion dataclass.""" + + def test_question_confidence_calculation(self): + """Test overall confidence calculation.""" + question = ExtractedQuestion( + number=1, + text="Test question", + text_confidence=0.9, + question_type=QuestionType.SHORT_ANSWER, + question_type_confidence=0.85, + ) + + # Confidence should be average of text and type confidence + expected = (0.9 + 0.85) / 2 + assert abs(question.confidence - expected) < 0.01 + + def test_question_with_options_confidence(self): + """Test confidence with options included.""" + question = ExtractedQuestion( + number=1, + text="Test question", + text_confidence=0.9, + question_type=QuestionType.MULTIPLE_CHOICE, + question_type_confidence=0.95, + options=[ + ExtractedOption("A", "Option 1", 0.88), + ExtractedOption("B", "Option 2", 0.92), + ], + ) + + # Should include option confidences + assert question.confidence > 0 + + def test_question_to_dict(self): + """Test conversion to dictionary.""" + question = ExtractedQuestion( + number=1, + text="What is 2+2?", + text_confidence=0.95, + question_type=QuestionType.SHORT_ANSWER, + question_type_confidence=0.9, + max_score=5.0, + max_score_confidence=0.85, + ) + + result = question.to_dict() + + assert result["number"] == 1 + assert result["text"]["value"] == "What is 2+2?" + assert result["questionType"]["value"] == "short_answer" + assert result["maxScore"]["value"] == 5.0 + + +# ============================================================================= +# OCR Engine Tests +# ============================================================================= + +class TestOCREngine: + """Tests for OCREngine class.""" + + def test_init(self): + """Test engine initialization.""" + engine = OCREngine( + lang="pt", + use_gpu=False, + use_angle_cls=True, + ) + + assert engine.lang == "pt" + assert engine.use_gpu is False + assert engine.use_angle_cls is True + assert engine._initialized is False + + def test_health_check_not_initialized(self): + """Test health check when not initialized.""" + engine = OCREngine() + health = engine.health_check() + + assert health["initialized"] is False + assert health["status"] == "not_initialized" + + @patch("app.ocr.engine.PaddleOCR") + def test_initialize(self, mock_paddle): + """Test engine initialization.""" + mock_paddle.return_value = MagicMock() + + engine = OCREngine() + engine.initialize() + + assert engine._initialized is True + mock_paddle.assert_called_once() + + @patch("app.ocr.engine.PaddleOCR") + def test_process_image(self, mock_paddle, sample_image): + """Test processing a single image.""" + # Setup mock + mock_instance = MagicMock() + mock_paddle.return_value = mock_instance + mock_instance.ocr.return_value = [ + [ + [[[10, 10], [200, 10], [200, 40], [10, 40]], ("Test text", 0.95)], + ] + ] + + engine = OCREngine() + result = engine.process_image(sample_image) + + assert isinstance(result, OCRResult) + assert result.status in ["success", "partial", "error"] + assert result.processing_time_ms >= 0 + + @patch("app.ocr.engine.PaddleOCR") + def test_process_image_with_error(self, mock_paddle, sample_image): + """Test error handling during processing.""" + mock_instance = MagicMock() + mock_paddle.return_value = mock_instance + mock_instance.ocr.side_effect = Exception("OCR failed") + + engine = OCREngine() + result = engine.process_image(sample_image) + + assert result.status == "error" + assert result.error_message is not None + + @patch("app.ocr.engine.PaddleOCR") + def test_process_bytes(self, mock_paddle, sample_image_bytes): + """Test processing image from bytes.""" + mock_instance = MagicMock() + mock_paddle.return_value = mock_instance + mock_instance.ocr.return_value = [[]] + + engine = OCREngine() + result = engine.process_bytes(sample_image_bytes) + + assert isinstance(result, OCRResult) + + def test_process_bytes_invalid(self): + """Test processing invalid bytes.""" + engine = OCREngine() + result = engine.process_bytes(b"invalid") + + assert result.status == "error" + assert "Failed to load image" in result.error_message + + @patch("app.ocr.engine.PaddleOCR") + def test_process_multiple_images(self, mock_paddle, sample_image): + """Test processing multiple images.""" + mock_instance = MagicMock() + mock_paddle.return_value = mock_instance + mock_instance.ocr.return_value = [ + [ + [[[10, 10], [200, 10], [200, 40], [10, 40]], ("Page content", 0.9)], + ] + ] + + engine = OCREngine() + images = [sample_image, sample_image] + result = engine.process_images(images) + + assert isinstance(result, OCRResult) + assert result.document.page_count == 2 + + @patch("app.ocr.engine.PaddleOCR") + def test_process_empty_images_list(self, mock_paddle): + """Test processing empty images list.""" + engine = OCREngine() + result = engine.process_images([]) + + assert result.status == "error" + assert "No images provided" in result.error_message + + def test_shutdown(self): + """Test engine shutdown.""" + engine = OCREngine() + engine.shutdown() + + assert engine._ocr is None + assert engine._initialized is False + + +class TestOCRResult: + """Tests for OCRResult dataclass.""" + + def test_to_dict(self): + """Test OCRResult to dictionary conversion.""" + result = OCRResult( + status="success", + request_id="req-test-123", + processing_time_ms=1500, + overall_confidence=0.85, + document=DocumentInfo( + page_count=2, + main_language="pt", + has_tables=False, + ), + metadata=ExtractedMetadata(), + questions=[], + unmapped_content=[], + warnings=[], + ) + + result_dict = result.to_dict() + + assert result_dict["status"] == "success" + assert result_dict["requestId"] == "req-test-123" + assert result_dict["processingTimeMs"] == 1500 + assert result_dict["overallConfidence"] == 0.85 + assert result_dict["document"]["pageCount"] == 2 + assert result_dict["document"]["mainLanguage"] == "pt" + + def test_to_dict_with_error(self): + """Test OCRResult with error message.""" + result = OCRResult( + status="error", + request_id="req-error-123", + processing_time_ms=100, + overall_confidence=0.0, + document=DocumentInfo(page_count=0, main_language="pt", has_tables=False), + metadata=ExtractedMetadata(), + questions=[], + unmapped_content=[], + warnings=[], + error_message="Processing failed", + ) + + result_dict = result.to_dict() + + assert result_dict["status"] == "error" + assert result_dict["errorMessage"] == "Processing failed" + + +# ============================================================================= +# Module-level Functions Tests +# ============================================================================= + +class TestModuleFunctions: + """Tests for module-level functions.""" + + @patch("app.ocr.engine._default_engine", None) + def test_get_engine_creates_instance(self): + """Test that get_engine creates a new instance.""" + engine = get_engine() + + assert isinstance(engine, OCREngine) + + @patch("app.ocr.engine._default_engine", None) + def test_get_engine_returns_same_instance(self): + """Test that get_engine returns the same instance.""" + engine1 = get_engine() + engine2 = get_engine() + + assert engine1 is engine2 + + @patch("app.ocr.engine.PaddleOCR") + @patch("app.ocr.engine._default_engine", None) + def test_initialize_engine(self, mock_paddle): + """Test initialize_engine function.""" + mock_paddle.return_value = MagicMock() + + initialize_engine() + + engine = get_engine() + assert engine._initialized is True + + @patch("app.ocr.engine._default_engine") + def test_shutdown_engine(self, mock_engine): + """Test shutdown_engine function.""" + mock_engine.shutdown = MagicMock() + + shutdown_engine() + + # Should call shutdown on the engine + # Note: actual behavior depends on global state + + +# ============================================================================= +# Integration Tests (marked for optional execution) +# ============================================================================= + +@pytest.mark.integration +class TestOCRIntegration: + """Integration tests that require actual PaddleOCR.""" + + @pytest.mark.slow + def test_real_ocr_processing(self, sample_image): + """Test with actual PaddleOCR (slow, requires models).""" + try: + from paddleocr import PaddleOCR + except ImportError: + pytest.skip("PaddleOCR not installed") + + engine = OCREngine(lang="en", use_gpu=False) + result = engine.process_image(sample_image) + + assert isinstance(result, OCRResult) + assert result.status in ["success", "partial", "error"] + + +# ============================================================================= +# Async Tests +# ============================================================================= + +@pytest.mark.asyncio +class TestAsyncOCR: + """Async tests for OCR engine.""" + + @patch("app.ocr.engine.PaddleOCR") + async def test_process_image_async(self, mock_paddle, sample_image): + """Test async image processing.""" + mock_instance = MagicMock() + mock_paddle.return_value = mock_instance + mock_instance.ocr.return_value = [[]] + + engine = OCREngine() + result = await engine.process_image_async(sample_image) + + assert isinstance(result, OCRResult) + + @patch("app.ocr.engine.PaddleOCR") + async def test_process_bytes_async(self, mock_paddle, sample_image_bytes): + """Test async bytes processing.""" + mock_instance = MagicMock() + mock_paddle.return_value = mock_instance + mock_instance.ocr.return_value = [[]] + + engine = OCREngine() + result = await engine.process_bytes_async(sample_image_bytes) + + assert isinstance(result, OCRResult) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/services/ocr-service/tests/test_routes.py b/services/ocr-service/tests/test_routes.py new file mode 100644 index 0000000..f8c5a2d --- /dev/null +++ b/services/ocr-service/tests/test_routes.py @@ -0,0 +1,395 @@ +""" +OCR Service API Routes Test Suite + +Tests for the FastAPI endpoints of the OCR service. +""" + +import pytest +from fastapi.testclient import TestClient +from unittest.mock import patch, MagicMock, AsyncMock +import io +from PIL import Image + +from app.main import app +from app.ocr.engine import OCRResult, DocumentInfo +from app.ocr.postprocessing import ExtractedMetadata, ExtractedQuestion, QuestionType + + +# ============================================================================= +# Fixtures +# ============================================================================= + +@pytest.fixture +def client(): + """Create a test client for the FastAPI app.""" + return TestClient(app) + + +@pytest.fixture +def sample_image_bytes(): + """Create a sample PNG image as bytes.""" + img = Image.new("RGB", (100, 100), color="white") + buffer = io.BytesIO() + img.save(buffer, format="PNG") + buffer.seek(0) + return buffer.getvalue() + + +@pytest.fixture +def sample_jpeg_bytes(): + """Create a sample JPEG image as bytes.""" + img = Image.new("RGB", (100, 100), color="white") + buffer = io.BytesIO() + img.save(buffer, format="JPEG") + buffer.seek(0) + return buffer.getvalue() + + +@pytest.fixture +def mock_ocr_result(): + """Create a mock OCR result.""" + return OCRResult( + status="success", + request_id="req-test-123", + processing_time_ms=1500, + overall_confidence=0.85, + document=DocumentInfo( + page_count=1, + main_language="pt", + has_tables=False, + ), + metadata=ExtractedMetadata(), + questions=[ + ExtractedQuestion( + number=1, + text="What is 2+2?", + text_confidence=0.95, + question_type=QuestionType.SHORT_ANSWER, + question_type_confidence=0.9, + ) + ], + unmapped_content=[], + warnings=[], + ) + + +# ============================================================================= +# Root and Health Endpoint Tests +# ============================================================================= + +class TestRootEndpoints: + """Tests for root and health endpoints.""" + + def test_root_endpoint(self, client): + """Test the root endpoint returns service info.""" + response = client.get("/") + + assert response.status_code == 200 + data = response.json() + assert "service" in data + assert "version" in data + assert data["status"] == "running" + + def test_health_endpoint(self, client): + """Test the health endpoint.""" + response = client.get("/health") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + assert "service" in data + assert "version" in data + + def test_ocr_health_endpoint(self, client): + """Test the OCR-specific health endpoint.""" + with patch("app.api.routes.get_ocr_engine") as mock_engine: + mock_instance = MagicMock() + mock_instance.health_check.return_value = { + "initialized": True, + "status": "healthy", + "message": "OCR engine is operational", + } + mock_engine.return_value = mock_instance + + response = client.get("/ocr/health") + + assert response.status_code == 200 + data = response.json() + assert data["initialized"] is True + + +# ============================================================================= +# OCR Extract Endpoint Tests +# ============================================================================= + +class TestExtractEndpoint: + """Tests for the OCR extract endpoints.""" + + @patch("app.api.routes.get_ocr_engine") + def test_extract_single_image(self, mock_get_engine, client, sample_image_bytes, mock_ocr_result): + """Test extracting text from a single image.""" + # Setup mock + mock_engine = MagicMock() + mock_engine.process_image_async = AsyncMock(return_value=mock_ocr_result) + mock_get_engine.return_value = mock_engine + + # Make request + files = {"images": ("test.png", sample_image_bytes, "image/png")} + response = client.post("/ocr/v1/extract", files=[("images", ("test.png", sample_image_bytes, "image/png"))]) + + assert response.status_code in [200, 207] + data = response.json() + assert "status" in data + assert "requestId" in data + + @patch("app.api.routes.get_ocr_engine") + def test_extract_jpeg_image(self, mock_get_engine, client, sample_jpeg_bytes, mock_ocr_result): + """Test extracting text from a JPEG image.""" + mock_engine = MagicMock() + mock_engine.process_image_async = AsyncMock(return_value=mock_ocr_result) + mock_get_engine.return_value = mock_engine + + response = client.post( + "/ocr/v1/extract", + files=[("images", ("test.jpg", sample_jpeg_bytes, "image/jpeg"))] + ) + + assert response.status_code in [200, 207] + + def test_extract_no_files(self, client): + """Test that extraction fails without files.""" + response = client.post("/ocr/v1/extract", files=[]) + + # Should fail with 422 (validation error) or 400 + assert response.status_code in [400, 422] + + def test_extract_invalid_file_type(self, client): + """Test that extraction rejects invalid file types.""" + invalid_content = b"This is not an image" + + response = client.post( + "/ocr/v1/extract", + files=[("images", ("test.txt", invalid_content, "text/plain"))] + ) + + assert response.status_code == 400 + data = response.json() + assert "Invalid file type" in data.get("detail", str(data)) + + +class TestSimpleExtractEndpoint: + """Tests for the simplified single-image extract endpoint.""" + + @patch("app.api.routes.get_ocr_engine") + def test_simple_extract(self, mock_get_engine, client, sample_image_bytes, mock_ocr_result): + """Test simple extraction with a single image.""" + mock_engine = MagicMock() + mock_engine.process_image_async = AsyncMock(return_value=mock_ocr_result) + mock_get_engine.return_value = mock_engine + + response = client.post( + "/ocr/v1/extract/simple", + files={"image": ("test.png", sample_image_bytes, "image/png")} + ) + + assert response.status_code in [200, 207] + data = response.json() + assert "status" in data + + def test_simple_extract_pdf_rejected(self, client): + """Test that PDF files are rejected in simple mode.""" + pdf_content = b"%PDF-1.4 fake pdf content" + + response = client.post( + "/ocr/v1/extract/simple", + files={"image": ("test.pdf", pdf_content, "application/pdf")} + ) + + assert response.status_code == 400 + + +# ============================================================================= +# Supported Languages Endpoint Tests +# ============================================================================= + +class TestSupportedLanguagesEndpoint: + """Tests for the supported languages endpoint.""" + + def test_get_supported_languages(self, client): + """Test retrieving supported OCR languages.""" + response = client.get("/ocr/v1/supported-languages") + + assert response.status_code == 200 + data = response.json() + assert "languages" in data + assert "default" in data + assert isinstance(data["languages"], list) + assert len(data["languages"]) > 0 + + # Check that Portuguese is included + pt_lang = next((l for l in data["languages"] if l["code"] == "pt"), None) + assert pt_lang is not None + assert pt_lang["primary"] is True + + +# ============================================================================= +# Status Endpoint Tests +# ============================================================================= + +class TestStatusEndpoint: + """Tests for the request status endpoint.""" + + def test_get_status_not_implemented(self, client): + """Test that status endpoint returns 501 (not implemented).""" + response = client.get("/ocr/v1/status/req-test-123") + + assert response.status_code == 501 + data = response.json() + assert "requestId" in data + assert data["status"] == "unknown" + + +# ============================================================================= +# File Validation Tests +# ============================================================================= + +class TestFileValidation: + """Tests for file validation logic.""" + + def test_valid_extensions(self, client, sample_image_bytes): + """Test that valid extensions are accepted.""" + valid_extensions = [ + ("test.jpg", "image/jpeg"), + ("test.jpeg", "image/jpeg"), + ("test.png", "image/png"), + ("test.webp", "image/webp"), + ("test.bmp", "image/bmp"), + ] + + with patch("app.api.routes.get_ocr_engine") as mock_get_engine: + mock_engine = MagicMock() + mock_engine.process_image_async = AsyncMock(return_value=MagicMock( + status="success", + to_dict=lambda: {"status": "success", "requestId": "test"} + )) + mock_get_engine.return_value = mock_engine + + for filename, content_type in valid_extensions: + response = client.post( + "/ocr/v1/extract", + files=[("images", (filename, sample_image_bytes, content_type))] + ) + # Should not return 400 for invalid file type + assert response.status_code != 400 or "Invalid file type" not in response.text + + def test_invalid_extensions(self, client): + """Test that invalid extensions are rejected.""" + invalid_files = [ + ("test.txt", b"text content", "text/plain"), + ("test.doc", b"doc content", "application/msword"), + ("test.exe", b"exe content", "application/octet-stream"), + ("test.html", b"", "text/html"), + ] + + for filename, content, content_type in invalid_files: + response = client.post( + "/ocr/v1/extract", + files=[("images", (filename, content, content_type))] + ) + assert response.status_code == 400 + + +# ============================================================================= +# Error Handling Tests +# ============================================================================= + +class TestErrorHandling: + """Tests for error handling in API routes.""" + + @patch("app.api.routes.get_ocr_engine") + def test_ocr_processing_error(self, mock_get_engine, client, sample_image_bytes): + """Test handling of OCR processing errors.""" + mock_engine = MagicMock() + mock_engine.process_image_async = AsyncMock(side_effect=Exception("OCR failed")) + mock_get_engine.return_value = mock_engine + + response = client.post( + "/ocr/v1/extract", + files=[("images", ("test.png", sample_image_bytes, "image/png"))] + ) + + assert response.status_code == 500 + + @patch("app.api.routes.get_ocr_engine") + def test_invalid_image_content(self, mock_get_engine, client): + """Test handling of invalid image content.""" + mock_engine = MagicMock() + mock_engine.process_image_async = AsyncMock( + side_effect=ValueError("Failed to decode image") + ) + mock_get_engine.return_value = mock_engine + + # Send garbage data with valid extension + response = client.post( + "/ocr/v1/extract", + files=[("images", ("test.png", b"not an image", "image/png"))] + ) + + # Should return error status + assert response.status_code in [400, 500] + + +# ============================================================================= +# Response Format Tests +# ============================================================================= + +class TestResponseFormat: + """Tests for response format correctness.""" + + @patch("app.api.routes.get_ocr_engine") + def test_success_response_format(self, mock_get_engine, client, sample_image_bytes, mock_ocr_result): + """Test that success response has correct format.""" + mock_engine = MagicMock() + mock_engine.process_image_async = AsyncMock(return_value=mock_ocr_result) + mock_get_engine.return_value = mock_engine + + response = client.post( + "/ocr/v1/extract", + files=[("images", ("test.png", sample_image_bytes, "image/png"))] + ) + + assert response.status_code in [200, 207] + data = response.json() + + # Check required fields + assert "status" in data + assert "requestId" in data + assert "processingTimeMs" in data + assert "overallConfidence" in data + assert "document" in data + assert "metadata" in data + assert "questions" in data + assert "warnings" in data + + @patch("app.api.routes.get_ocr_engine") + def test_document_info_format(self, mock_get_engine, client, sample_image_bytes, mock_ocr_result): + """Test that document info has correct format.""" + mock_engine = MagicMock() + mock_engine.process_image_async = AsyncMock(return_value=mock_ocr_result) + mock_get_engine.return_value = mock_engine + + response = client.post( + "/ocr/v1/extract", + files=[("images", ("test.png", sample_image_bytes, "image/png"))] + ) + + data = response.json() + document = data.get("document", {}) + + assert "pageCount" in document + assert "mainLanguage" in document + assert "hasTables" in document + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From 4d09efbd4e6b46b9418a91af0d5734d329315e28 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Thu, 5 Feb 2026 13:44:11 +0100 Subject: [PATCH 25/48] fix(class): corrigir tipo do campo grade de String para Integer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ClassRequest: alterado grade de String para Integer - ClassResponse: alterado grade de String para Integer - OcrController.ClassInfo: alterado grade de String para Integer - Removida anotação @NotBlank (específica para String) do grade --- .../kixi/controller/OcrController.java | 609 ++++++++++++++---- .../kixi/dto/classe/ClassRequest.java | 30 +- .../kixi/dto/classe/ClassResponse.java | 33 +- 3 files changed, 523 insertions(+), 149 deletions(-) diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/OcrController.java b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/OcrController.java index 7db7c53..a2e6c85 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/OcrController.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/OcrController.java @@ -1,9 +1,14 @@ package ao.creativemode.kixi.controller; import ao.creativemode.kixi.client.OcrServiceClient; -import ao.creativemode.kixi.dto.ocr.OcrResponse; import ao.creativemode.kixi.common.exception.ApiException; - +import ao.creativemode.kixi.dto.ocr.ExamExtractionResponse; +import ao.creativemode.kixi.dto.ocr.OcrResponse; +import ao.creativemode.kixi.service.OcrPersistenceService; +import ao.creativemode.kixi.service.OcrPersistenceService.StatementWithRelations; +import java.util.List; +import java.util.Map; +import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; @@ -11,22 +16,18 @@ import org.springframework.http.ResponseEntity; import org.springframework.http.codec.multipart.FilePart; import org.springframework.web.bind.annotation.*; - import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import java.util.List; -import java.util.Map; -import java.util.Set; - /** * REST Controller for OCR operations. * * Provides endpoints for extracting text from images and PDFs - * using the OCR microservice. + * using the OCR microservice, and persisting the results. * * Endpoints: - * - POST /api/v1/ocr/extract - Extract text from uploaded images + * - POST /api/v1/ocr/extract - Extract text from uploaded images (OCR only) + * - POST /api/v1/ocr/extract-and-persist - Extract and persist to database * - GET /api/v1/ocr/health - Check OCR service health * - GET /api/v1/ocr/languages - Get supported OCR languages */ @@ -34,110 +35,402 @@ @RequestMapping("/api/v1/ocr") public class OcrController { - private static final Logger log = LoggerFactory.getLogger(OcrController.class); + private static final Logger log = LoggerFactory.getLogger( + OcrController.class + ); private static final Set ALLOWED_EXTENSIONS = Set.of( - ".jpg", ".jpeg", ".png", ".pdf", ".webp", ".bmp", ".tiff", ".tif" + ".jpg", + ".jpeg", + ".png", + ".pdf", + ".webp", + ".bmp", + ".tiff", + ".tif" ); private static final long MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB private static final int MAX_FILES = 10; private final OcrServiceClient ocrServiceClient; + private final OcrPersistenceService ocrPersistenceService; - public OcrController(OcrServiceClient ocrServiceClient) { + public OcrController( + OcrServiceClient ocrServiceClient, + OcrPersistenceService ocrPersistenceService + ) { this.ocrServiceClient = ocrServiceClient; + this.ocrPersistenceService = ocrPersistenceService; } /** - * Extract text from uploaded images/PDFs. + * Extract text from uploaded images/PDFs (OCR only, no persistence). * * @param files List of uploaded file parts (images or PDFs) * @return OCR extraction result with structured data */ - @PostMapping(value = "/extract", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PostMapping( + value = "/extract", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE + ) public Mono> extractText( - @RequestPart("files") Flux files) { - + @RequestPart("files") Flux files + ) { log.info("OCR extraction request received"); return files - .collectList() - .flatMap(fileList -> { - // Validate file count - if (fileList.isEmpty()) { - return Mono.error(ApiException.badRequest("At least one file is required")); - } - if (fileList.size() > MAX_FILES) { - return Mono.error(ApiException.badRequest( - "Maximum " + MAX_FILES + " files allowed per request")); - } + .collectList() + .flatMap(fileList -> { + // Validate file count + if (fileList.isEmpty()) { + return Mono.error( + ApiException.badRequest("At least one file is required") + ); + } + if (fileList.size() > MAX_FILES) { + return Mono.error( + ApiException.badRequest( + "Maximum " + + MAX_FILES + + " files allowed per request" + ) + ); + } - // Validate file types - for (FilePart file : fileList) { - if (!isAllowedFileType(file.filename())) { - return Mono.error(ApiException.badRequest( - "Invalid file type: " + file.filename() + - ". Allowed: " + String.join(", ", ALLOWED_EXTENSIONS))); - } + // Validate file types + for (FilePart file : fileList) { + if (!isAllowedFileType(file.filename())) { + return Mono.error( + ApiException.badRequest( + "Invalid file type: " + + file.filename() + + ". Allowed: " + + String.join(", ", ALLOWED_EXTENSIONS) + ) + ); } + } - log.info("Processing {} file(s) for OCR extraction", fileList.size()); - - // Call OCR service - return ocrServiceClient.extractText(fileList); - }) - .map(ocrResponse -> { - // Return appropriate status based on OCR result - if (ocrResponse.isSuccess()) { - return ResponseEntity.ok(ocrResponse); - } else if (ocrResponse.isPartial()) { - return ResponseEntity.status(HttpStatus.MULTI_STATUS).body(ocrResponse); - } else { - return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(ocrResponse); - } - }) - .doOnSuccess(response -> log.info( - "OCR extraction completed: status={}", - response.getStatusCode())) - .doOnError(error -> log.error("OCR extraction failed", error)); + log.info( + "Processing {} file(s) for OCR extraction", + fileList.size() + ); + + // Call OCR service + return ocrServiceClient.extractText(fileList); + }) + .map(ocrResponse -> { + // Return appropriate status based on OCR result + if (ocrResponse.isSuccess()) { + return ResponseEntity.ok(ocrResponse); + } else if (ocrResponse.isPartial()) { + return ResponseEntity.status(HttpStatus.MULTI_STATUS).body( + ocrResponse + ); + } else { + return ResponseEntity.status( + HttpStatus.UNPROCESSABLE_ENTITY + ).body(ocrResponse); + } + }) + .doOnSuccess(response -> + log.info( + "OCR extraction completed: status={}", + response.getStatusCode() + ) + ) + .doOnError(error -> log.error("OCR extraction failed", error)); } /** - * Extract text from a single uploaded image/PDF. - * - * Simplified endpoint for single-file extraction. + * Extract text from a single uploaded image/PDF (OCR only). * * @param file Single uploaded file part * @return OCR extraction result with structured data */ - @PostMapping(value = "/extract/single", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PostMapping( + value = "/extract/single", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE + ) public Mono> extractTextSingle( - @RequestPart("file") FilePart file) { - - log.info("Single-file OCR extraction request received: {}", file.filename()); + @RequestPart("file") FilePart file + ) { + log.info( + "Single-file OCR extraction request received: {}", + file.filename() + ); // Validate file type if (!isAllowedFileType(file.filename())) { - return Mono.error(ApiException.badRequest( - "Invalid file type: " + file.filename() + - ". Allowed: " + String.join(", ", ALLOWED_EXTENSIONS))); + return Mono.error( + ApiException.badRequest( + "Invalid file type: " + + file.filename() + + ". Allowed: " + + String.join(", ", ALLOWED_EXTENSIONS) + ) + ); } - return ocrServiceClient.extractText(List.of(file)) - .map(ocrResponse -> { - if (ocrResponse.isSuccess()) { - return ResponseEntity.ok(ocrResponse); - } else if (ocrResponse.isPartial()) { - return ResponseEntity.status(HttpStatus.MULTI_STATUS).body(ocrResponse); - } else { - return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(ocrResponse); + return ocrServiceClient + .extractText(List.of(file)) + .map(ocrResponse -> { + if (ocrResponse.isSuccess()) { + return ResponseEntity.ok(ocrResponse); + } else if (ocrResponse.isPartial()) { + return ResponseEntity.status(HttpStatus.MULTI_STATUS).body( + ocrResponse + ); + } else { + return ResponseEntity.status( + HttpStatus.UNPROCESSABLE_ENTITY + ).body(ocrResponse); + } + }) + .doOnSuccess(response -> + log.info( + "Single-file OCR extraction completed: status={}", + response.getStatusCode() + ) + ) + .doOnError(error -> + log.error("Single-file OCR extraction failed", error) + ); + } + + /** + * Extract exam data in structured Angolan format. + * + * Returns the extraction result in the exact JSON structure required + * for Angolan exam papers (12ª classe), including: + * - exam_type, duration_minutes, variant, title, instructions + * - school_year_start, school_year_end, class_grade, course_name, subject_name + * - total_max_score + * - questions with number, subitems, text, type, cotacao, options, has_image + * - images_to_upload with suggested_filename, description, region + * + * @param files List of uploaded file parts (images or PDFs) + * @return Structured exam extraction result + */ + @PostMapping( + value = "/extract/exam", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE + ) + public Mono> extractExam( + @RequestPart("files") Flux files + ) { + log.info("Angolan exam extraction request received"); + + return files + .collectList() + .flatMap(fileList -> { + // Validate file count + if (fileList.isEmpty()) { + return Mono.error( + ApiException.badRequest("At least one file is required") + ); + } + if (fileList.size() > MAX_FILES) { + return Mono.error( + ApiException.badRequest( + "Maximum " + + MAX_FILES + + " files allowed per request" + ) + ); + } + + // Validate file types + for (FilePart file : fileList) { + if (!isAllowedFileType(file.filename())) { + return Mono.error( + ApiException.badRequest( + "Invalid file type: " + + file.filename() + + ". Allowed: " + + String.join(", ", ALLOWED_EXTENSIONS) + ) + ); + } + } + + log.info( + "Processing {} file(s) for Angolan exam extraction", + fileList.size() + ); + + // Call OCR service + return ocrServiceClient.extractText(fileList); + }) + .map(ocrResponse -> { + // Convert to structured exam format + ExamExtractionResponse examResponse = + ExamExtractionResponse.fromOcrResponse(ocrResponse); + + if (ocrResponse.isSuccess()) { + return ResponseEntity.ok(examResponse); + } else if (ocrResponse.isPartial()) { + return ResponseEntity.status(HttpStatus.MULTI_STATUS).body( + examResponse + ); + } else { + return ResponseEntity.status( + HttpStatus.UNPROCESSABLE_ENTITY + ).body(examResponse); + } + }) + .doOnSuccess(response -> + log.info( + "Angolan exam extraction completed: status={}", + response.getStatusCode() + ) + ) + .doOnError(error -> + log.error("Angolan exam extraction failed", error) + ); + } + + /** + * Extract text from uploaded images/PDFs and persist to database. + * + * This endpoint performs full OCR extraction and creates/updates: + * - SchoolYear (lookup or create by start_year/end_year) + * - Course (lookup or create by name) + * - Subject (lookup or create by name) + * - Class (lookup or create by grade/course/school_year) + * - Statement with Questions and Options + * + * @param files List of uploaded file parts (images or PDFs) + * @param createdBy Optional user ID who is creating the statement + * @return Created statement with all related entities + */ + @PostMapping( + value = "/extract-and-persist", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE + ) + public Mono< + ResponseEntity + > extractAndPersist( + @RequestPart("files") Flux files, + @RequestParam(value = "createdBy", required = false) Long createdBy + ) { + log.info( + "OCR extraction and persistence request received, createdBy={}", + createdBy + ); + + return files + .collectList() + .flatMap(fileList -> { + // Validate file count + if (fileList.isEmpty()) { + return Mono.error( + ApiException.badRequest("At least one file is required") + ); + } + if (fileList.size() > MAX_FILES) { + return Mono.error( + ApiException.badRequest( + "Maximum " + + MAX_FILES + + " files allowed per request" + ) + ); + } + + // Validate file types + for (FilePart file : fileList) { + if (!isAllowedFileType(file.filename())) { + return Mono.error( + ApiException.badRequest( + "Invalid file type: " + + file.filename() + + ". Allowed: " + + String.join(", ", ALLOWED_EXTENSIONS) + ) + ); } - }) - .doOnSuccess(response -> log.info( - "Single-file OCR extraction completed: status={}", - response.getStatusCode())) - .doOnError(error -> log.error("Single-file OCR extraction failed", error)); + } + + log.info( + "Processing {} file(s) for OCR extraction and persistence", + fileList.size() + ); + + // Process and persist + return ocrPersistenceService.processAndPersist( + fileList, + createdBy + ); + }) + .map(result -> { + StatementWithRelationsResponse response = + new StatementWithRelationsResponse( + result.statement().getId(), + result.statement().getTitle(), + result.statement().getExamType(), + result.statement().getVariant(), + result.statement().getDurationMinutes(), + result.statement().getTotalMaxScore(), + result.statement().getOcrConfidence(), + result.statement().getNeedsReview(), + result.schoolYear() != null + ? new SchoolYearInfo( + result.schoolYear().getId(), + result.schoolYear().getStartYear(), + result.schoolYear().getEndYear() + ) + : null, + result.course() != null + ? new EntityInfo( + result.course().getId(), + result.course().getName() + ) + : null, + result.subject() != null + ? new EntityInfo( + result.subject().getId(), + result.subject().getName() + ) + : null, + result.classEntity() != null + ? new ClassInfo( + result.classEntity().getId(), + result.classEntity().getGrade(), + result.classEntity().getCode() + ) + : null, + result.questions() != null + ? result.questions().size() + : 0, + result.imagesToUpload() != null + ? result + .imagesToUpload() + .stream() + .map(img -> + new ImageToUploadInfo( + img.suggestedFilename(), + img.description(), + img.region() + ) + ) + .toList() + : List.of() + ); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + }) + .doOnSuccess(response -> + log.info( + "OCR extraction and persistence completed: statementId={}", + response.getBody() != null + ? response.getBody().statementId() + : "null" + ) + ) + .doOnError(error -> + log.error("OCR extraction and persistence failed", error) + ); } /** @@ -147,27 +440,41 @@ public Mono> extractTextSingle( */ @GetMapping("/health") public Mono>> checkHealth() { - return ocrServiceClient.healthCheck() - .map(healthy -> { - Map response = Map.of( - "service", "ocr-service", - "status", healthy ? "healthy" : "unhealthy", - "available", healthy - ); - return healthy - ? ResponseEntity.ok(response) - : ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response); - }) - .onErrorResume(error -> { - log.error("OCR health check failed", error); - Map response = Map.of( - "service", "ocr-service", - "status", "unavailable", - "available", false, - "error", error.getMessage() - ); - return Mono.just(ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response)); - }); + return ocrServiceClient + .healthCheck() + .map(healthy -> { + Map response = Map.of( + "service", + "ocr-service", + "status", + healthy ? "healthy" : "unhealthy", + "available", + healthy + ); + return healthy + ? ResponseEntity.ok(response) + : ResponseEntity.status( + HttpStatus.SERVICE_UNAVAILABLE + ).body(response); + }) + .onErrorResume(error -> { + log.error("OCR health check failed", error); + Map response = Map.of( + "service", + "ocr-service", + "status", + "unavailable", + "available", + false, + "error", + error.getMessage() + ); + return Mono.just( + ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body( + response + ) + ); + }); } /** @@ -176,13 +483,20 @@ public Mono>> checkHealth() { * @return List of supported languages */ @GetMapping("/languages") - public Mono> getSupportedLanguages() { - return ocrServiceClient.getSupportedLanguages() - .map(ResponseEntity::ok) - .onErrorResume(error -> { - log.error("Failed to get supported languages", error); - return Mono.error(ApiException.badRequest("Failed to retrieve supported languages")); - }); + public Mono< + ResponseEntity + > getSupportedLanguages() { + return ocrServiceClient + .getSupportedLanguages() + .map(ResponseEntity::ok) + .onErrorResume(error -> { + log.error("Failed to get supported languages", error); + return Mono.error( + ApiException.badRequest( + "Failed to retrieve supported languages" + ) + ); + }); } /** @@ -192,22 +506,45 @@ public Mono> getSupp */ @GetMapping("/info") public Mono>> getInfo() { - return Mono.just(ResponseEntity.ok(Map.of( - "service", "OCR Service", - "version", "1.0.0", - "supportedFormats", List.of("JPEG", "PNG", "PDF", "WebP", "BMP", "TIFF"), - "maxFileSize", MAX_FILE_SIZE, - "maxFiles", MAX_FILES, - "features", List.of( + return Mono.just( + ResponseEntity.ok( + Map.of( + "service", + "OCR Service", + "version", + "1.0.0", + "supportedFormats", + List.of("JPEG", "PNG", "PDF", "WebP", "BMP", "TIFF"), + "maxFileSize", + MAX_FILE_SIZE, + "maxFiles", + MAX_FILES, + "features", + List.of( "Text extraction from images", "PDF multi-page support", "Question detection and segmentation", "Multiple choice option extraction", "Metadata extraction (school year, subject, etc.)", "Confidence scores for all extracted data", - "Portuguese language optimization" + "Portuguese language optimization", + "Angolan exam format support (12ª classe)", + "Structured exam extraction (exam_type, cotacao, subitems)", + "Automatic entity creation (SchoolYear, Course, Subject, Class)", + "Image region detection (cabecalho, questoes, rodape/coordenacao)" + ), + "endpoints", + Map.of( + "extract", + "POST /api/v1/ocr/extract - Raw OCR extraction", + "extractExam", + "POST /api/v1/ocr/extract/exam - Structured Angolan exam format", + "extractAndPersist", + "POST /api/v1/ocr/extract-and-persist - Extract and save to database" + ) ) - ))); + ) + ); } /** @@ -221,4 +558,52 @@ private boolean isAllowedFileType(String filename) { String lowerFilename = filename.toLowerCase(); return ALLOWED_EXTENSIONS.stream().anyMatch(lowerFilename::endsWith); } + + // ========================================================================= + // Response DTOs + // ========================================================================= + + /** + * Response DTO for extract-and-persist endpoint. + */ + public record StatementWithRelationsResponse( + Long statementId, + String title, + String examType, + String variant, + Integer durationMinutes, + Double totalMaxScore, + Double ocrConfidence, + Boolean needsReview, + SchoolYearInfo schoolYear, + EntityInfo course, + EntityInfo subject, + ClassInfo classInfo, + Integer questionCount, + List imagesToUpload + ) {} + + /** + * School year info DTO. + */ + public record SchoolYearInfo(Long id, Integer startYear, Integer endYear) {} + + /** + * Generic entity info DTO. + */ + public record EntityInfo(Long id, String name) {} + + /** + * Class info DTO. + */ + public record ClassInfo(Long id, Integer grade, String code) {} + + /** + * Image to upload info DTO. + */ + public record ImageToUploadInfo( + String suggestedFilename, + String description, + String region + ) {} } diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/classe/ClassRequest.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/classe/ClassRequest.java index e70dd23..b731de8 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/classe/ClassRequest.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/classe/ClassRequest.java @@ -1,20 +1,10 @@ -package ao.creativemode.kixi.dto.classe; - - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - - -public record ClassRequest( - @NotBlank(message = "code required") - @NotNull(message = "Code cannot be null") - String code, - @NotBlank(message = "Grade is required") - @NotNull(message = "Grade cannot be null") - String grade, - @NotNull(message="course id cannot be null") - Long courseId, - @NotNull(message="school year id cannot be null") - Long schoolYearId -) { -} +package ao.creativemode.kixi.dto.classe; + +import jakarta.validation.constraints.NotNull; + +public record ClassRequest( + @NotNull(message = "Code cannot be null") String code, + @NotNull(message = "Grade is required") Integer grade, + @NotNull(message = "course id cannot be null") Long courseId, + @NotNull(message = "school year id cannot be null") Long schoolYearId +) {} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/classe/ClassResponse.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/classe/ClassResponse.java index 555528f..6f50cb1 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/classe/ClassResponse.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/classe/ClassResponse.java @@ -1,17 +1,16 @@ -package ao.creativemode.kixi.dto.classe; - -import ao.creativemode.kixi.model.Course; -import ao.creativemode.kixi.model.SchoolYear; - -import java.time.LocalDateTime; - -public record ClassResponse( - Long id, - String code, - String grade, - Course course, - SchoolYear schoolYear, - LocalDateTime createdAt, - LocalDateTime updatedAt, - LocalDateTime deletedAt -) { } +package ao.creativemode.kixi.dto.classe; + +import ao.creativemode.kixi.model.Course; +import ao.creativemode.kixi.model.SchoolYear; +import java.time.LocalDateTime; + +public record ClassResponse( + Long id, + String code, + Integer grade, + Course course, + SchoolYear schoolYear, + LocalDateTime createdAt, + LocalDateTime updatedAt, + LocalDateTime deletedAt +) {} From f97038cefa7bb2191e7b0d8942f0e0b94d0a9dea Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Thu, 5 Feb 2026 13:44:27 +0100 Subject: [PATCH 26/48] =?UTF-8?q?feat(ocr):=20melhorar=20extra=C3=A7=C3=A3?= =?UTF-8?q?o=20de=20metadados=20e=20cota=C3=A7=C3=A3o=20para=20provas=20an?= =?UTF-8?q?golanas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Melhorias na extração de cotação: - Suporte a formatos: '1-a) 3 valores', '(3V)', 'COTACAO:1) a - 4 V' - Extração de cotação inline no texto das questões - Soma automática de cotação de subitems Melhorias na extração de metadados: - Padrões mais robustos para ano letivo (Ano Lectivo/Letivo) - Melhor detecção de classe/grade com fallbacks - Limpeza de campos nulos para evitar persistir dados vazios - Filtro de lixo do rodapé (COORDENAÇÃO, minttics.gov.ao, etc.) Novo campo subitemsContent: - Armazena conteúdo completo de cada alínea (a, b, c) - Inclui texto e cotação individual por subitem Correções: - Evita confundir '12ª Classe' com número de questão - Padrão de curso mais restritivo (requer 'CURSO:') - Normalização de nomes de disciplinas --- .../ocr-service/app/ocr/postprocessing.py | 1326 ++++++++++++++--- 1 file changed, 1113 insertions(+), 213 deletions(-) diff --git a/services/ocr-service/app/ocr/postprocessing.py b/services/ocr-service/app/ocr/postprocessing.py index 3b9b54e..e75a867 100644 --- a/services/ocr-service/app/ocr/postprocessing.py +++ b/services/ocr-service/app/ocr/postprocessing.py @@ -5,9 +5,12 @@ - Text normalization and cleaning - Question detection and segmentation - Metadata extraction (school year, term, subject, etc.) -- Question type inference (multiple_choice, short_answer, development, true_false) +- Question type inference (dissertativa, multipla_escolha) - Option extraction for multiple choice questions +- Image detection and region identification - Confidence score aggregation + +Optimized for Angolan exam papers (12ª classe). """ import re @@ -18,10 +21,8 @@ class QuestionType(str, Enum): """Supported question types.""" - MULTIPLE_CHOICE = "multiple_choice" - SHORT_ANSWER = "short_answer" - DEVELOPMENT = "development" - TRUE_FALSE = "true_false" + DISSERTATIVA = "dissertativa" + MULTIPLA_ESCOLHA = "multipla_escolha" UNKNOWN = "unknown" @@ -43,17 +44,46 @@ class ExtractedOption: confidence: float +@dataclass +class ImageToUpload: + """Represents an image region to be uploaded.""" + suggested_filename: str + description: str + region: str # questao_1, cabecalho, rodape, etc. + bbox: Optional[Tuple[int, int, int, int]] = None + page_index: int = 0 + + +@dataclass +class SubitemContent: + """Represents a subitem (alínea) with its label and content.""" + label: str # "a)", "b)", etc. + text: str # The content of the subitem + cotacao: Optional[float] = None + + def to_dict(self) -> Dict[str, Any]: + return { + "label": self.label, + "text": self.text, + "cotacao": self.cotacao, + } + + @dataclass class ExtractedQuestion: """Represents an extracted question with all its components.""" - number: int + number: str # Can be "1", "2a", "3-b)", etc. text: str text_confidence: float question_type: QuestionType question_type_confidence: float - max_score: Optional[float] = None - max_score_confidence: float = 0.0 - options: List[ExtractedOption] = field(default_factory=list) + subitems: List[str] = field(default_factory=list) # ["a)", "b)", "c)"] - labels only + subitems_content: List[SubitemContent] = field(default_factory=list) # Full subitem with content + cotacao: Optional[float] = None + cotacao_confidence: float = 0.0 + options: Optional[List[ExtractedOption]] = None + has_image: bool = False + image_description: Optional[str] = None page_index: int = 0 start_y: int = 0 end_y: int = 0 @@ -64,30 +94,42 @@ def confidence(self) -> float: confidences = [self.text_confidence, self.question_type_confidence] if self.options: confidences.extend(opt.confidence for opt in self.options) - if self.max_score is not None: - confidences.append(self.max_score_confidence) + if self.cotacao is not None: + confidences.append(self.cotacao_confidence) return sum(confidences) / len(confidences) if confidences else 0.0 def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for JSON serialization.""" - return { + result = { "number": self.number, "confidence": round(self.confidence, 3), + "subitems": self.subitems, + "subitemsContent": [s.to_dict() for s in self.subitems_content] if self.subitems_content else [], "text": {"value": self.text, "confidence": round(self.text_confidence, 3)}, - "questionType": {"value": self.question_type.value, "confidence": round(self.question_type_confidence, 3)}, - "maxScore": {"value": self.max_score, "confidence": round(self.max_score_confidence, 3)} if self.max_score else {"value": None, "confidence": 0.0}, - "options": [ + "type": self.question_type.value, + "cotacao": self.cotacao, + "hasImage": self.has_image, + "pageIndex": self.page_index, + "startY": self.start_y, + "endY": self.end_y, + } + + if self.image_description: + result["imageDescription"] = self.image_description + + if self.options: + result["options"] = [ { "optionLabel": opt.option_label, "optionText": opt.option_text, "confidence": round(opt.confidence, 3), } for opt in self.options - ], - "pageIndex": self.page_index, - "startY": self.start_y, - "endY": self.end_y, - } + ] + else: + result["options"] = None + + return result @dataclass @@ -103,31 +145,46 @@ def to_dict(self) -> Dict[str, Any]: @dataclass class ExtractedMetadata: - """Represents extracted document metadata.""" - school_year: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) - term: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) - subject: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) - course: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) - class_info: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) + """Represents extracted document metadata - Angolan exam format.""" exam_type: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) duration_minutes: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) variant: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) title: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) instructions: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) + school_year_start: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) + school_year_end: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) + class_grade: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) + course_name: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) + subject_name: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) + total_max_score: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) + # Legacy fields for compatibility + school_year: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) + term: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) + subject: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) + course: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) + class_info: MetadataField = field(default_factory=lambda: MetadataField(None, 0.0)) def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for JSON serialization.""" return { - "schoolYear": self.school_year.to_dict(), - "term": self.term.to_dict(), - "subject": self.subject.to_dict(), - "course": self.course.to_dict(), - "class": self.class_info.to_dict(), + # New structured fields "examType": self.exam_type.to_dict(), "durationMinutes": self.duration_minutes.to_dict(), "variant": self.variant.to_dict(), "title": self.title.to_dict(), "instructions": self.instructions.to_dict(), + "schoolYearStart": self.school_year_start.to_dict(), + "schoolYearEnd": self.school_year_end.to_dict(), + "classGrade": self.class_grade.to_dict(), + "courseName": self.course_name.to_dict(), + "subjectName": self.subject_name.to_dict(), + "totalMaxScore": self.total_max_score.to_dict(), + # Legacy fields + "schoolYear": self.school_year.to_dict(), + "term": self.term.to_dict(), + "subject": self.subject.to_dict(), + "course": self.course.to_dict(), + "class": self.class_info.to_dict(), } @@ -169,103 +226,210 @@ def to_dict(self) -> Dict[str, Any]: class OCRPostprocessor: """ - Postprocessor for OCR results. + Postprocessor for OCR results - Optimized for Angolan exams. Transforms raw OCR text blocks into structured data including: - Document metadata (school year, subject, exam type, etc.) - Questions with their types and options + - Image regions for upload - Confidence scores for all extracted values """ # Regex patterns for metadata extraction PATTERNS = { - # School year patterns (e.g., "2024/2025", "Ano Letivo 2024-2025", "2024 - 2025") + # School year patterns (e.g., "2024/2025", "Ano Letivo 2024-2025", "Ano Lectivo: 2024/2025") "school_year": [ - r"(?:ano\s*let[ií]vo|school\s*year)?\s*(\d{4})\s*[/-]\s*(\d{4})", + # Explicit "Ano Letivo" patterns + r"ano\s*let[ií]vo\s*[:\s]*(\d{4})\s*[/-]\s*(\d{2,4})", + r"ano\s*lect[ií]vo\s*[:\s]*(\d{4})\s*[/-]\s*(\d{2,4})", + r"ano\s*lectivo\s*[:\s]*(\d{4})\s*[/-]\s*(\d{2,4})", + # Inline patterns like "12ª Classe Ano Lectivo: 2024/2025" + r"classe\s*ano\s*lect[ií]vo\s*[:\s]*(\d{4})\s*[/-]\s*(\d{2,4})", + # Standalone year patterns (less specific, use last) + r"(\d{4})\s*[/-]\s*(\d{4})", r"(\d{4})\s*/\s*(\d{2,4})", + # Pattern with colon: "2024/2025" + r"[:\s](\d{4})\s*/\s*(\d{2,4})", ], - # Term patterns (e.g., "1º Trimestre", "2nd Term", "III Trimestre") + # Term patterns (e.g., "1º Trimestre") "term": [ r"(\d)[ºª°]?\s*(?:trimestre|term|período|bimestre)", r"(?:trimestre|term|período|bimestre)\s*(\d)", r"(I{1,3}|IV)\s*(?:trimestre|term|período)", - r"(primeiro|segundo|terceiro|quarto|first|second|third|fourth)\s*(?:trimestre|term|período)", ], - # Subject patterns + # Subject patterns - Angolan format (improved for "PROVA DE EXAME DE MATEMÁTICA") "subject": [ + # Most specific: "PROVA DE EXAME DE MATEMÁTICA" + r"prova\s+de\s+exame\s+de\s+([A-Za-záàâãéèêíïóôõöúçñÁÀÂÃÉÈÊÍÏÓÔÕÖÚÇÑ]+)", + # "PROVA DE RECURSO DE MATEMÁTICA" + r"prova\s+de\s+recurso\s+de\s+([A-Za-záàâãéèêíïóôõöúçñÁÀÂÃÉÈÊÍÏÓÔÕÖÚÇÑ]+)", + # "EXAME DE MATEMÁTICA" + r"exame\s+de\s+([A-Za-záàâãéèêíïóôõöúçñÁÀÂÃÉÈÊÍÏÓÔÕÖÚÇÑ]+)", + # "PROVA DE MATEMÁTICA" + r"prova\s+de\s+([A-Za-záàâãéèêíïóôõöúçñÁÀÂÃÉÈÊÍÏÓÔÕÖÚÇÑ]+)", + # Generic patterns + r"(?:recurso\s+de\s+)([A-Za-záàâãéèêíïóôõöúçñÁÀÂÃÉÈÊÍÏÓÔÕÖÚÇÑ]+)", r"(?:disciplina|subject|matéria|cadeira)[:\s]+([A-Za-záàâãéèêíïóôõöúçñÁÀÂÃÉÈÊÍÏÓÔÕÖÚÇÑ\s\-]+)", - r"(?:prova\s+de|exame\s+de|avaliação\s+de)[:\s]+([A-Za-záàâãéèêíïóôõöúçñÁÀÂÃÉÈÊÍÏÓÔÕÖÚÇÑ\s\-]+)", ], - # Duration patterns (e.g., "120 minutos", "2 horas", "Duration: 90 min") + # Duration patterns (e.g., "90 Min", "Duração: 90 minutos") "duration": [ - r"(?:duração|duration|tempo)[:\s]*(\d+)\s*(?:min(?:utos)?|minutes?)", - r"(\d+)\s*(?:min(?:utos)?|minutes?)", - r"(\d+)\s*(?:horas?|hours?)\s*(?:e\s*(\d+)\s*min(?:utos)?)?", + r"(?:duração|duration|tempo)[:\s]*(\d+)\s*(?:min(?:utos?)?|minutes?)", + r"(\d+)\s*(?:min(?:utos?)?)\b", ], - # Variant patterns (e.g., "Versão A", "Variant B", "Prova A") + # Variant/Series patterns (e.g., "Série: B", "Variante A", "Serie: B") "variant": [ - r"(?:versão|variant|prova|versao)\s*([A-Za-z])", - r"(?:grupo|group)\s*([A-Za-z])", + r"s[ée]rie\s*[:\s]*([A-Za-z])\b", + r"(?:versão|variant|variante)\s*[:\s]*([A-Za-z])\b", + r"(?:série|serie)\s*([A-Za-z])\b", ], - # Class patterns (e.g., "12ª Classe", "10º Ano", "Class 9A") + # Class/Grade patterns (e.g., "12ª Classe", "10º Ano", "12a Classe") + # More flexible patterns to catch various OCR outputs "class": [ - r"(\d{1,2})[ºª°]?\s*(?:classe|class|ano|grade|série)", - r"(?:classe|class|turma)[:\s]*(\d{1,2})\s*([A-Za-z])?", - r"(\d{1,2})[ºª°]?\s*(?:ano)?\s*-?\s*(?:turma)?\s*([A-Za-z])?", + r"(\d{1,2})\s*[ºª°aᵃ]?\s*classe", + r"(\d{1,2})\s*[ºª°oᵒ]?\s*ano", + r"(\d{1,2})\s*classe", + r"classe\s*[:\s]*(\d{1,2})", + r"(\d{1,2})\s*[ºª]\s*cl", + # Pattern for inline text like "12a Classe Ano Lectivo" + r"(\d{1,2})[aª]\s*classe", ], - # Exam type patterns + # Course patterns (e.g., "CURSO: TODOS", "Curso: Ciências") + # More restrictive to avoid capturing garbage - must have CURSO: prefix + "course": [ + r"curso\s*:\s*([A-Z]+)(?:\s|$|[^A-Za-z])", + r"curso\s*:\s+([A-Za-z]+)(?:\s|$)", + ], + # Footer/garbage patterns to filter out + "footer_garbage": [ + r"COORDENA[CÇ][AÃ]O", + r"minttics\.gov\.ao", + r"LUANDA[\s-]*ANGOLA", + r"ANGOLA", + r"gov\.ao", + r"\d+/E\d+/\d+", + r"kaixa", + r"klvs", + r"GOIK", + ], + # Inline cotação patterns (e.g., "(3V)", "(2,5V)", "(4 valores)") + "inline_cotacao": [ + r"\((\d+(?:[.,]\d+)?)\s*[Vv]\)", + r"\((\d+(?:[.,]\d+)?)\s*(?:valores?|pontos?|pts?)\)", + r"\[(\d+(?:[.,]\d+)?)\s*[Vv]\]", + ], + # Exam type patterns (improved for recurso, época, etc.) "exam_type": [ + # Most specific patterns first + r"(prova\s+de\s+exame\s+de\s+\w+)", + r"(prova\s+de\s+recurso\s+de\s+\w+)", + r"(prova\s+de\s+recurso)", + r"(prova\s+de\s+exame)", + r"(exame\s+de\s+recurso)", + r"(exame\s+de\s+época)", r"(avaliação\s*(?:periódica|sumativa|formativa|diagnóstica|final|contínua))", - r"(prova\s*(?:escrita|oral|prática|final|parcial))", - r"(exame\s*(?:final|nacional|regional|de\s*época))", - r"(teste\s*(?:escrito|sumativo|formativo))", - r"(examination|assessment|test|exam|quiz)", + r"(prova\s*(?:escrita|oral|prática|final|parcial|de\s+recurso))", + r"(exame\s*(?:final|nacional|regional|provincial|de\s+época)?)", + r"(teste\s*(?:escrito|sumativo|formativo)?)", + r"(recurso)", ], - # Question number patterns + # Question number patterns - More flexible for Angolan format "question_number": [ + r"^(\d+)\s*[-.):]", r"^(?:questão|pergunta|question|exercício|problema|item)\s*n?[ºª°]?\s*(\d+)", - r"^(\d+)\s*[.)\-:]\s*", - r"^([IVX]+)\s*[.)\-:]\s*", ], - # Option patterns (e.g., "A)", "a.", "(A)", "1.") + # Subitem patterns (e.g., "a)", "b.", "(a)") + "subitem": [ + r"^\(?([a-z])\)?[.):]\s*", + ], + # Option patterns for multiple choice "option": [ r"^\(?([A-Da-d])\)?[.):]\s*(.+)", - r"^\(?(\d)\)?[.):]\s*(.+)", r"^([A-Da-d])\s*[-–—]\s*(.+)", ], - # Score patterns (e.g., "(5 pontos)", "[10 pts]", "5 valores") - "score": [ - r"[(\[]\s*(\d+(?:[.,]\d+)?)\s*(?:pontos?|pts?|valores?|marks?|points?)\s*[)\]]", - r"(\d+(?:[.,]\d+)?)\s*(?:pontos?|pts?|valores?|marks?|points?)", + # Score/Cotação patterns - Angolan format + "cotacao": [ + r"(\d+(?:[.,]\d+)?)\s*(?:valores?|pontos?|pts?|marks?|points?)", + r"[(\[]\s*(\d+(?:[.,]\d+)?)\s*(?:valores?|pontos?|pts?|marks?|points?)?\s*[)\]]", + ], + # Cotação block pattern (e.g., "Cotação 1-a) 3 valores 2-) 4 valores") + # Updated to be more flexible and capture multi-line cotação blocks + "cotacao_block": [ + r"(?:cotação|cotacao|pontuação|pontuacao)[:\s]*(.+?)(?:\.|$|(?=\n\n))", + r"(?:cotação|cotacao|pontuação|pontuacao)[:\s]*(.+)", + ], + # Individual cotação item patterns for parsing (more comprehensive) + "cotacao_item": [ + # Pattern: "1-a) 3 valores" or "1-a) 3,5 valores" or "1a) 3 valores" + r"(\d+)\s*[-\s]?\s*([a-z])\s*\)?\s*(\d+(?:[.,]\d+)?)\s*(?:valores?|pontos?|pts?)", + # Pattern: "1-) 3 valores" or "1) 3 valores" (no subitem) + r"(\d+)\s*[-\s]?\s*\)?\s*(\d+(?:[.,]\d+)?)\s*(?:valores?|pontos?|pts?)", + # Pattern: "2-) 4 valores" with explicit dash + r"(\d+)\s*-\s*\)\s*(\d+(?:[.,]\d+)?)\s*(?:valores?|pontos?|pts?)", + # Pattern: "5-a) 2,5 valores 5-b) 2,5 valores" - captures with subitem + r"(\d+)\s*-\s*([a-z])\s*\)\s*(\d+(?:[.,]\d+)?)\s*(?:valores?|pontos?|pts?)", ], - # True/False patterns - "true_false": [ - r"(?:verdadeiro|falso|true|false|v\/f|t\/f)", - r"(?:certo|errado|correto|incorreto)", + # Image indicators + "image_indicator": [ + r"(?:figura|gráfico|tabela|diagrama|imagem|graph|table|figure|image)", + r"(?:veja|observe|analise|considere)\s+(?:a|o)\s+(?:figura|gráfico|tabela)", + r"(?:na\s+)?(?:figura|gráfico|tabela)\s+(?:abaixo|seguinte|acima)", + ], + # Coordination signature patterns + "coordination": [ + r"(?:a\s+)?coordena[çc][ãa]o", + r"coordenador(?:a)?", + r"assinatura", + r"(?:fim\s+da?\s+prova)", ], } # Keywords for question type inference QUESTION_TYPE_KEYWORDS = { - QuestionType.MULTIPLE_CHOICE: [ + QuestionType.MULTIPLA_ESCOLHA: [ "escolha", "assinale", "marque", "alternativa", "opção", "select", "choose", "mark", "circle", "option", ], - QuestionType.DEVELOPMENT: [ + QuestionType.DISSERTATIVA: [ + "resolva", "resolve", "calcule", "calcular", "determine", "determina", "justifique", "explique", "desenvolva", "comente", "discuta", - "argumente", "analise", "compare", "descreva", "fundamente", - "justify", "explain", "discuss", "describe", "analyze", "compare", - ], - QuestionType.SHORT_ANSWER: [ - "calcule", "determine", "resolva", "encontre", "simplifique", - "calculate", "solve", "find", "determine", "simplify", "compute", - ], - QuestionType.TRUE_FALSE: [ - "verdadeiro", "falso", "v/f", "certo", "errado", - "true", "false", "t/f", "correct", "incorrect", + "demonstre", "prove", "mostre", "encontre", "simplifique", + "analise", "compare", "descreva", "fundamente", ], } + # Subject name corrections for OCR errors + SUBJECT_CORRECTIONS = { + "matematica": "Matemática", + "matemática": "Matemática", + "fisica": "Física", + "física": "Física", + "fisíca": "Física", + "fìsica": "Física", + "quimica": "Química", + "química": "Química", + "quìmica": "Química", + "biologia": "Biologia", + "portugues": "Português", + "português": "Português", + "ingles": "Inglês", + "inglês": "Inglês", + "frances": "Francês", + "francês": "Francês", + "historia": "História", + "história": "História", + "geografia": "Geografia", + "filosofia": "Filosofia", + "educacao": "Educação", + "educação": "Educação", + "desenho": "Desenho", + "geometria": "Geometria", + "informatica": "Informática", + "informática": "Informática", + "economia": "Economia", + "sociologia": "Sociologia", + "psicologia": "Psicologia", + } + def __init__( self, min_confidence_threshold: float = 0.5, @@ -285,7 +449,7 @@ def process( self, text_blocks: List[TextBlock], page_count: int = 1, - ) -> Tuple[ExtractedMetadata, List[ExtractedQuestion], List[UnmappedContent], List[Warning]]: + ) -> Tuple[ExtractedMetadata, List[ExtractedQuestion], List[ImageToUpload], List[UnmappedContent], List[Warning]]: """ Process OCR text blocks into structured data. @@ -294,88 +458,148 @@ def process( page_count: Number of pages in the document Returns: - Tuple of (metadata, questions, unmapped_content, warnings) + Tuple of (metadata, questions, images_to_upload, unmapped_content, warnings) """ warnings = [] unmapped = [] + images_to_upload = [] # Sort blocks by page and position sorted_blocks = sorted(text_blocks, key=lambda b: (b.page_index, b.bbox[1], b.bbox[0])) - # Extract metadata from header blocks (first ~20% of first page) - metadata = self._extract_metadata(sorted_blocks) + # Combine all text for pattern matching + full_text = " ".join(b.text for b in sorted_blocks) + + # Extract metadata from header blocks + metadata = self._extract_metadata(sorted_blocks, full_text) + + # Clean up null/empty fields to avoid persisting empty data + metadata = self._clean_null_fields(metadata) + warnings.extend(self._generate_metadata_warnings(metadata)) + # Parse cotação block if present + cotacao_map = self._parse_cotacao_block(full_text) + # Segment and extract questions - questions = self._extract_questions(sorted_blocks) + questions = self._extract_questions(sorted_blocks, cotacao_map) warnings.extend(self._generate_question_warnings(questions)) + # Calculate total max score from questions or cotação map + total_score = sum(q.cotacao for q in questions if q.cotacao is not None) + if total_score > 0: + metadata.total_max_score = MetadataField(round(total_score, 1), 0.9) + elif cotacao_map: + # If questions don't have cotação yet, sum from cotação map + total_from_map = sum(cotacao_map.values()) + if total_from_map > 0: + metadata.total_max_score = MetadataField(round(total_from_map, 1), 0.85) + + # Detect images to upload + images_to_upload = self._detect_images_to_upload(metadata, questions, sorted_blocks, full_text) + # Collect unmapped content unmapped = self._collect_unmapped(sorted_blocks, metadata, questions) - return metadata, questions, unmapped, warnings + return metadata, questions, images_to_upload, unmapped, warnings - def _extract_metadata(self, blocks: List[TextBlock]) -> ExtractedMetadata: + def _extract_metadata(self, blocks: List[TextBlock], full_text: str) -> ExtractedMetadata: """Extract document metadata from text blocks.""" metadata = ExtractedMetadata() - - # Combine all text for pattern matching - full_text = " ".join(b.text for b in blocks[:30]) # Use first ~30 blocks full_text_lower = full_text.lower() - # Extract school year + # Extract school year - try multiple approaches + school_year_found = False for pattern in self.PATTERNS["school_year"]: match = re.search(pattern, full_text, re.IGNORECASE) if match: - start_year = match.group(1) - end_year = match.group(2) - if len(end_year) == 2: - end_year = start_year[:2] + end_year - value = f"{start_year}/{end_year}" - confidence = self._estimate_confidence_from_match(match, full_text, blocks) - metadata.school_year = MetadataField(value, confidence) - break + start_year = int(match.group(1)) + end_year_str = match.group(2) + if len(end_year_str) == 2: + end_year = int(str(start_year)[:2] + end_year_str) + else: + end_year = int(end_year_str) + + # Validate years are reasonable (2000-2100) + if 2000 <= start_year <= 2100 and 2000 <= end_year <= 2100: + confidence = self._estimate_confidence_from_match(match, full_text, blocks) + metadata.school_year_start = MetadataField(start_year, confidence) + metadata.school_year_end = MetadataField(end_year, confidence) + metadata.school_year = MetadataField(f"{start_year}/{end_year}", confidence) + school_year_found = True + break + + # Fallback: look in header blocks specifically + if not school_year_found: + header_text = " ".join(b.text for b in blocks[:20]) + year_patterns = [ + r"(\d{4})\s*[/-]\s*(\d{4})", + r"(\d{4})\s*/\s*(\d{2,4})", + ] + for pattern in year_patterns: + match = re.search(pattern, header_text) + if match: + start_year = int(match.group(1)) + end_year_str = match.group(2) + if len(end_year_str) == 2: + end_year = int(str(start_year)[:2] + end_year_str) + else: + end_year = int(end_year_str) + + if 2000 <= start_year <= 2100 and 2000 <= end_year <= 2100: + metadata.school_year_start = MetadataField(start_year, 0.8) + metadata.school_year_end = MetadataField(end_year, 0.8) + metadata.school_year = MetadataField(f"{start_year}/{end_year}", 0.8) + break # Extract term for pattern in self.PATTERNS["term"]: match = re.search(pattern, full_text, re.IGNORECASE) if match: term_value = match.group(1) - # Normalize term value term_map = { - "primeiro": "1", "first": "1", "i": "1", - "segundo": "2", "second": "2", "ii": "2", - "terceiro": "3", "third": "3", "iii": "3", - "quarto": "4", "fourth": "4", "iv": "4", + "i": "1", "ii": "2", "iii": "3", "iv": "4", } normalized = term_map.get(term_value.lower(), term_value) confidence = self._estimate_confidence_from_match(match, full_text, blocks) metadata.term = MetadataField(f"{normalized}º Trimestre", confidence) break - # Extract subject + # Extract subject (try multiple approaches) + subject_found = False for pattern in self.PATTERNS["subject"]: match = re.search(pattern, full_text, re.IGNORECASE) if match: subject = match.group(1).strip() + # Skip if it's just "Recurso" - we want the actual subject + if subject.lower() == "recurso": + continue + # Clean and normalize subject name + subject = self._normalize_subject_name(subject) confidence = self._estimate_confidence_from_match(match, full_text, blocks) + metadata.subject_name = MetadataField(subject, confidence) metadata.subject = MetadataField(subject, confidence) + subject_found = True break + # If not found, try to find subject keywords in text + if not subject_found: + for subject_key, subject_value in self.SUBJECT_CORRECTIONS.items(): + if subject_key in full_text_lower: + metadata.subject_name = MetadataField(subject_value, 0.75) + metadata.subject = MetadataField(subject_value, 0.75) + break + # Extract duration for pattern in self.PATTERNS["duration"]: match = re.search(pattern, full_text, re.IGNORECASE) if match: minutes = int(match.group(1)) - if match.lastindex >= 2 and match.group(2): - minutes = minutes * 60 + int(match.group(2)) - elif "hora" in match.group(0).lower() or "hour" in match.group(0).lower(): - minutes = minutes * 60 confidence = self._estimate_confidence_from_match(match, full_text, blocks) metadata.duration_minutes = MetadataField(minutes, confidence) break - # Extract variant + # Extract variant/series for pattern in self.PATTERNS["variant"]: match = re.search(pattern, full_text, re.IGNORECASE) if match: @@ -384,61 +608,352 @@ def _extract_metadata(self, blocks: List[TextBlock]) -> ExtractedMetadata: metadata.variant = MetadataField(variant, confidence) break - # Extract class info + # Extract class/grade - try multiple approaches + grade_found = False + + # First, try patterns on full text for pattern in self.PATTERNS["class"]: match = re.search(pattern, full_text, re.IGNORECASE) if match: - class_num = match.group(1) - class_letter = match.group(2).upper() if match.lastindex >= 2 and match.group(2) else "" - value = f"{class_num}ª Classe" + (f" - Turma {class_letter}" if class_letter else "") - confidence = self._estimate_confidence_from_match(match, full_text, blocks) - metadata.class_info = MetadataField(value, confidence) - break + grade = match.group(1) + # Validate grade is reasonable (1-13 for Angola) + try: + grade_int = int(grade) + if 1 <= grade_int <= 13: + confidence = self._estimate_confidence_from_match(match, full_text, blocks) + metadata.class_grade = MetadataField(str(grade_int), confidence) + metadata.class_info = MetadataField(f"{grade_int}ª Classe", confidence) + grade_found = True + break + except ValueError: + continue + + # Fallback 1: look for common class patterns in first blocks (header area) + if not grade_found: + header_text = " ".join(b.text for b in blocks[:20]) + # Multiple patterns for class extraction + class_patterns = [ + r"(\d{1,2})\s*[ºª°aᵃ]?\s*classe", + r"(\d{1,2})\s*[ºª°aᵃ]\s*cl\b", + r"(\d{1,2})a\s+classe", + r"(\d{1,2})ª\s+classe", + # Pattern like "12a Classe Ano Lectivo" + r"(\d{1,2})[aª]\s*classe\s*ano", + ] + for pattern in class_patterns: + class_match = re.search(pattern, header_text, re.IGNORECASE) + if class_match: + try: + grade_int = int(class_match.group(1)) + if 1 <= grade_int <= 13: + metadata.class_grade = MetadataField(str(grade_int), 0.8) + metadata.class_info = MetadataField(f"{grade_int}ª Classe", 0.8) + grade_found = True + break + except ValueError: + continue + + # Fallback 2: look for class in instructions text (common in Angolan exams) + if not grade_found and metadata.instructions.value: + instr_text = metadata.instructions.value + for pattern in class_patterns: + class_match = re.search(pattern, instr_text, re.IGNORECASE) + if class_match: + try: + grade_int = int(class_match.group(1)) + if 1 <= grade_int <= 13: + metadata.class_grade = MetadataField(str(grade_int), 0.75) + metadata.class_info = MetadataField(f"{grade_int}ª Classe", 0.75) + grade_found = True + break + except ValueError: + continue + + # Fallback 3: search for standalone pattern like "12 Classe" anywhere + if not grade_found: + standalone_pattern = r"\b(\d{1,2})\s*classe\b" + standalone_match = re.search(standalone_pattern, full_text, re.IGNORECASE) + if standalone_match: + try: + grade_int = int(standalone_match.group(1)) + if 1 <= grade_int <= 13: + metadata.class_grade = MetadataField(str(grade_int), 0.7) + metadata.class_info = MetadataField(f"{grade_int}ª Classe", 0.7) + except ValueError: + pass + + # Extract course - be more careful to get clean value + # First try explicit "CURSO:" pattern + course_match = re.search(r"curso\s*:\s*([A-Za-záàâãéèêíïóôõöúçñÁÀÂÃÉÈÊÍÏÓÔÕÖÚÇÑ\s]+?)(?:\s{2,}|$|\n|SEGIEM|N[°º])", full_text, re.IGNORECASE) + if course_match: + course = course_match.group(1).strip().upper() + # Clean course name - remove trailing garbage + course = re.sub(r'\s+', ' ', course) + # Only take first meaningful word(s) + course_words = course.split() + if len(course_words) > 0: + # Common course names + valid_courses = ['TODOS', 'CIENCIAS', 'HUMANIDADES', 'LETRAS', 'ARTES', + 'ECONOMIA', 'INFORMATICA', 'PEDAGOGIA', 'AGRONOMIA', 'GERAL'] + # Check if first word is a valid course + if course_words[0] in valid_courses: + course = course_words[0] + # Check if "TODOS" appears anywhere + elif 'TODOS' in course_words: + course = 'TODOS' + elif len(course_words) <= 2: + course = ' '.join(course_words) + else: + # Take only first meaningful word, skip garbage + for word in course_words: + if word in valid_courses or len(word) > 3: + course = word + break + confidence = 0.85 + metadata.course_name = MetadataField(course, confidence) + metadata.course = MetadataField(course, confidence) # Extract exam type for pattern in self.PATTERNS["exam_type"]: match = re.search(pattern, full_text, re.IGNORECASE) if match: exam_type = match.group(1).strip().title() + # Normalize exam types - more specific normalization + exam_type_lower = exam_type.lower() + if "prova de exame de" in exam_type_lower: + # Extract subject from "prova de exame de matemática" + exam_type = "Prova de Exame" + elif "prova de recurso" in exam_type_lower: + exam_type = "Prova de Recurso" + elif "exame de recurso" in exam_type_lower: + exam_type = "Exame de Recurso" + elif "exame de época" in exam_type_lower or "época" in exam_type_lower: + exam_type = "Exame de Época" + elif "prova de exame" in exam_type_lower or "exame" in exam_type_lower: + exam_type = "Prova de Exame" + elif "avaliação" in exam_type_lower: + exam_type = exam_type # Keep as is (Avaliação Periódica, etc.) + elif "teste" in exam_type_lower: + exam_type = exam_type # Keep as is confidence = self._estimate_confidence_from_match(match, full_text, blocks) metadata.exam_type = MetadataField(exam_type, confidence) break - # Extract title (usually the first prominent text) - if blocks: - title_candidates = [b for b in blocks[:10] if len(b.text) > 10 and b.confidence > 0.8] - if title_candidates: - title_block = max(title_candidates, key=lambda b: b.confidence) - metadata.title = MetadataField(title_block.text, title_block.confidence) + # Build title from extracted components + title = self._build_title(metadata) + if title: + metadata.title = MetadataField(title, 0.85) # Extract instructions (look for instruction keywords) - instruction_keywords = ["leia", "responda", "atenção", "instruções", "read", "answer", "attention", "instructions"] - for block in blocks[:20]: + instruction_keywords = ["leia", "responda", "atenção", "instruções", "coloque", "forma clara"] + for block in blocks[:25]: if any(kw in block.text.lower() for kw in instruction_keywords): - metadata.instructions = MetadataField(block.text, block.confidence) - break + # Check if this looks like an instruction block + if len(block.text) > 30 and not self._detect_question_number(block.text): + metadata.instructions = MetadataField(block.text.strip(), block.confidence) + break return metadata - def _extract_questions(self, blocks: List[TextBlock]) -> List[ExtractedQuestion]: + def _normalize_subject_name(self, subject: str) -> str: + """Normalize and correct subject name.""" + if not subject: + return subject + + subject_lower = subject.lower().strip() + + # Remove common OCR noise + subject_lower = re.sub(r'\s+', ' ', subject_lower) + subject_lower = subject_lower.replace('í', 'i').replace('ì', 'i') + + # Check for exact matches in corrections + if subject_lower in self.SUBJECT_CORRECTIONS: + return self.SUBJECT_CORRECTIONS[subject_lower] + + # Check for partial matches (more strict - at word boundaries) + for key, value in self.SUBJECT_CORRECTIONS.items(): + if re.search(rf'\b{re.escape(key)}\b', subject_lower): + return value + + # Return with proper capitalization + return subject.strip().title() + + def _build_title(self, metadata: ExtractedMetadata) -> Optional[str]: + """Build a title from extracted metadata components.""" + parts = [] + + if metadata.exam_type.value: + parts.append(metadata.exam_type.value) + + if metadata.subject_name.value: + if not parts or "de" not in parts[-1].lower(): + parts.append(f"de {metadata.subject_name.value}") + else: + parts.append(metadata.subject_name.value) + + if metadata.class_grade.value: + parts.append(f"{metadata.class_grade.value}ª Classe") + + if metadata.variant.value: + parts.append(f"- Série {metadata.variant.value}") + + if metadata.school_year_start.value and metadata.school_year_end.value: + parts.append(f"- {metadata.school_year_start.value}/{metadata.school_year_end.value}") + + return " ".join(parts) if parts else None + + def _parse_cotacao_block(self, full_text: str) -> Dict[str, float]: + """ + Parse cotação/scoring block to map question numbers to scores. + + Handles Angolan exam format like: + "Cotação 1-a) 3 valores 2-) 4 valores 3-a) 2,5 valores 3-b) 2,5 valores 4-) 3 valores 5-a) 2,5 valores 5-b) 2,5 valores" + + Also handles inline format like: + "COTACAO:1) a - 4 V,b - 4 V/2) 5 V/ 3) 4 V/4) 3 V" + """ + cotacao_map = {} + + # First, try to find cotação block + cotacao_text = None + for pattern in self.PATTERNS["cotacao_block"]: + match = re.search(pattern, full_text, re.IGNORECASE | re.DOTALL) + if match: + cotacao_text = match.group(1) + break + + if not cotacao_text: + # Try to find cotação anywhere in text + cotacao_match = re.search(r"cotaç[aã]o\s+(.+?)(?:\.|A\s+COORDENA|$)", full_text, re.IGNORECASE | re.DOTALL) + if cotacao_match: + cotacao_text = cotacao_match.group(1) + + # Also try inline format: "COTACAO:1) a - 4 V,b - 4 V/2) 5 V..." + if not cotacao_text: + inline_match = re.search(r"COTACAO\s*:\s*(.+?)(?:\s*$|\n\n)", full_text, re.IGNORECASE) + if inline_match: + cotacao_text = inline_match.group(1) + + if not cotacao_text: + return cotacao_map + + # Clean up the cotação text + cotacao_text = cotacao_text.replace('\n', ' ').replace('\r', ' ') + cotacao_text = re.sub(r'\s+', ' ', cotacao_text) + + # Parse using multiple patterns for flexibility + # Pattern 1: "1-a) 3 valores" - question with subitem + pattern1 = r"(\d+)\s*-?\s*([a-z])\s*\)?\s*(\d+(?:[.,]\d+)?)\s*(?:valores?|pontos?|pts?)" + for match in re.finditer(pattern1, cotacao_text, re.IGNORECASE): + q_num = match.group(1) + subitem = match.group(2).lower() + score = float(match.group(3).replace(",", ".")) + key = f"{q_num}{subitem}" + cotacao_map[key] = score + + # Pattern 2: "2-) 4 valores" - question without subitem (just dash and parenthesis) + pattern2 = r"(\d+)\s*-\s*\)\s*(\d+(?:[.,]\d+)?)\s*(?:valores?|pontos?|pts?)" + for match in re.finditer(pattern2, cotacao_text, re.IGNORECASE): + q_num = match.group(1) + score = float(match.group(2).replace(",", ".")) + # Only add if not already mapped with subitem + if q_num not in cotacao_map: + cotacao_map[q_num] = score + + # Pattern 3: "4-) 3 valores" or "4) 3 valores" - standalone questions + pattern3 = r"(? List[ExtractedQuestion]: """Extract and structure questions from text blocks.""" questions = [] current_question = None question_text_parts = [] + current_subitems = [] + current_subitems_content = [] + current_subitem_label = None + current_subitem_text_parts = [] for block in blocks: + text = block.text.strip() + + # Skip empty blocks + if not text: + continue + + # Skip cotação blocks + if re.match(r"^\s*cotaç[aã]o\s", text, re.IGNORECASE): + continue + + # Skip coordination/signature blocks + if self._is_coordination_block(text): + continue + # Check if this block starts a new question - question_number = self._detect_question_number(block.text) + question_info = self._detect_question_number(text) + + if question_info is not None: + # Save current subitem if exists + if current_subitem_label and current_subitem_text_parts: + subitem_text = self._clean_text(" ".join(current_subitem_text_parts)) + subitem_cotacao = self._extract_inline_cotacao(subitem_text) + if subitem_cotacao: + subitem_text = self._remove_inline_cotacao(subitem_text) + current_subitems_content.append(SubitemContent( + label=current_subitem_label, + text=self._clean_footer_garbage(subitem_text), + cotacao=subitem_cotacao, + )) - if question_number is not None: # Save previous question if exists if current_question is not None: current_question.text = self._clean_text(" ".join(question_text_parts)) + current_question.subitems = current_subitems + current_question.subitems_content = current_subitems_content + self._finalize_question(current_question, cotacao_map) questions.append(current_question) + q_num, remainder = question_info + # Start new question current_question = ExtractedQuestion( - number=question_number, + number=str(q_num), text="", text_confidence=block.confidence, question_type=QuestionType.UNKNOWN, @@ -447,63 +962,319 @@ def _extract_questions(self, blocks: List[TextBlock]) -> List[ExtractedQuestion] start_y=block.bbox[1], end_y=block.bbox[3], ) - question_text_parts = [self._remove_question_prefix(block.text)] + question_text_parts = [remainder] if remainder else [] + current_subitems = [] + current_subitems_content = [] + current_subitem_label = None + current_subitem_text_parts = [] + + # Check for image indicators + has_image, image_desc = self._detect_image_reference(text) + if has_image: + current_question.has_image = True + current_question.image_description = image_desc elif current_question is not None: - # Check if this is an option - option = self._detect_option(block.text) - if option: - current_question.options.append(ExtractedOption( - option_label=option[0], - option_text=option[1], - confidence=block.confidence, - )) + # Check if this is a subitem start + subitem_info = self._detect_subitem_with_text(text) + if subitem_info: + subitem_label, subitem_remainder = subitem_info + + # Save previous subitem if exists + if current_subitem_label and current_subitem_text_parts: + subitem_text = self._clean_text(" ".join(current_subitem_text_parts)) + subitem_cotacao = self._extract_inline_cotacao(subitem_text) + if subitem_cotacao: + subitem_text = self._remove_inline_cotacao(subitem_text) + current_subitems_content.append(SubitemContent( + label=current_subitem_label, + text=self._clean_footer_garbage(subitem_text), + cotacao=subitem_cotacao, + )) + + # Start new subitem + if subitem_label not in current_subitems: + current_subitems.append(subitem_label) + current_subitem_label = subitem_label + current_subitem_text_parts = [subitem_remainder] if subitem_remainder else [] + + # Still add full text to question + question_text_parts.append(text) + + # Check if this is an option (for multiple choice) + elif self._is_option_block(text): + option = self._detect_option(text) + if option: + if current_question.options is None: + current_question.options = [] + current_question.options.append(ExtractedOption( + option_label=option[0], + option_text=option[1], + confidence=block.confidence, + )) else: # Add to question text - question_text_parts.append(block.text) + question_text_parts.append(text) + # Also add to current subitem if we're in one + if current_subitem_label: + current_subitem_text_parts.append(text) # Update end position current_question.end_y = max(current_question.end_y, block.bbox[3]) - # Extract score if present - score = self._detect_score(block.text) - if score is not None: - current_question.max_score = score - current_question.max_score_confidence = block.confidence + # Check for image references in this block + has_image, image_desc = self._detect_image_reference(text) + if has_image and not current_question.has_image: + current_question.has_image = True + current_question.image_description = image_desc + + # Save last subitem + if current_subitem_label and current_subitem_text_parts: + subitem_text = self._clean_text(" ".join(current_subitem_text_parts)) + subitem_cotacao = self._extract_inline_cotacao(subitem_text) + if subitem_cotacao: + subitem_text = self._remove_inline_cotacao(subitem_text) + current_subitems_content.append(SubitemContent( + label=current_subitem_label, + text=self._clean_footer_garbage(subitem_text), + cotacao=subitem_cotacao, + )) # Save last question if current_question is not None: current_question.text = self._clean_text(" ".join(question_text_parts)) + current_question.subitems = current_subitems + current_question.subitems_content = current_subitems_content + self._finalize_question(current_question, cotacao_map) questions.append(current_question) - # Infer question types - for question in questions: - question.question_type, question.question_type_confidence = self._infer_question_type(question) - return questions - def _detect_question_number(self, text: str) -> Optional[int]: - """Detect if text starts with a question number.""" + def _finalize_question(self, question: ExtractedQuestion, cotacao_map: Dict[str, float]): + """Finalize question with type inference and cotação lookup.""" + # Infer question type + question.question_type, question.question_type_confidence = self._infer_question_type(question) + + # Look up cotação - try multiple strategies + cotacao_found = False + + # Strategy 1: Direct question number lookup from cotação block + if question.number in cotacao_map: + question.cotacao = cotacao_map[question.number] + question.cotacao_confidence = 0.9 + cotacao_found = True + + # Strategy 2: Sum subitems cotação from cotação block + if not cotacao_found: + total_cotacao = 0.0 + found_subitems = False + + # Try with detected subitems + for subitem in question.subitems: + # Clean subitem: "a)" -> "a" + clean_subitem = subitem.replace(')', '').replace('(', '').lower().strip() + key = f"{question.number}{clean_subitem}" + if key in cotacao_map: + total_cotacao += cotacao_map[key] + found_subitems = True + + # If no subitems detected, try common letters + if not found_subitems: + for letter in ['a', 'b', 'c', 'd', 'e']: + key = f"{question.number}{letter}" + if key in cotacao_map: + total_cotacao += cotacao_map[key] + found_subitems = True + + if found_subitems: + question.cotacao = total_cotacao + question.cotacao_confidence = 0.85 + cotacao_found = True + + # Strategy 3: Sum cotação from subitems_content (extracted inline) + if not cotacao_found and question.subitems_content: + total_cotacao = 0.0 + found_any = False + for subitem in question.subitems_content: + if subitem.cotacao is not None: + total_cotacao += subitem.cotacao + found_any = True + if found_any: + question.cotacao = total_cotacao + question.cotacao_confidence = 0.85 + cotacao_found = True + + # Strategy 4: Look for inline cotação in question text (e.g., "(3V)", "(4 valores)") + if not cotacao_found and question.text: + inline_cotacao = self._extract_inline_cotacao(question.text) + if inline_cotacao: + question.cotacao = inline_cotacao + question.cotacao_confidence = 0.8 + # Remove the cotação from question text + question.text = self._remove_inline_cotacao(question.text) + cotacao_found = True + + # Clean question text of any remaining inline cotação + if question.text: + question.text = self._remove_inline_cotacao(question.text) + + def _detect_question_number(self, text: str) -> Optional[Tuple[int, str]]: + """Detect if text starts with a question number. Returns (number, remainder) or None.""" text = text.strip() + # Skip blocks that look like class/grade indicators (e.g., "12ª Classe", "12a Classe") + class_pattern = r"^\d{1,2}\s*[ºª°a]?\s*(?:classe|class|ano|grade)" + if re.match(class_pattern, text, re.IGNORECASE): + return None + + # Skip blocks that look like header metadata (contain "Ano Lectivo", "Duração", etc.) + header_keywords = [ + r"ano\s*let[ií]vo", r"ano\s*lect[ií]vo", r"duração", r"duracao", + r"curso", r"proc\.", r"nome:", r"n[°º]?\s*proc" + ] + text_lower = text.lower() + for kw in header_keywords: + if re.search(kw, text_lower): + return None + for pattern in self.PATTERNS["question_number"]: match = re.match(pattern, text, re.IGNORECASE) if match: num_str = match.group(1) - # Convert Roman numerals - roman_map = {"I": 1, "II": 2, "III": 3, "IV": 4, "V": 5, "VI": 6, "VII": 7, "VIII": 8, "IX": 9, "X": 10} - if num_str.upper() in roman_map: - return roman_map[num_str.upper()] - return int(num_str) + try: + num = int(num_str) + remainder = text[match.end():].strip() + + # Additional validation: question numbers should be reasonable (1-50) + if num < 1 or num > 50: + continue + + # Skip if the remainder looks like metadata (class, year, etc.) + if remainder: + remainder_lower = remainder.lower() + if any(kw in remainder_lower for kw in ['classe', 'class', 'ano lectivo', 'ano letivo', 'duração', 'duracao']): + continue + + return (num, remainder) + except ValueError: + continue return None - def _remove_question_prefix(self, text: str) -> str: - """Remove question number prefix from text.""" - for pattern in self.PATTERNS["question_number"]: - text = re.sub(pattern, "", text, flags=re.IGNORECASE).strip() + def _detect_subitem(self, text: str) -> Optional[str]: + """Detect if text starts with a subitem (a), b), etc.)""" + text = text.strip() + + for pattern in self.PATTERNS["subitem"]: + match = re.match(pattern, text, re.IGNORECASE) + if match: + return f"{match.group(1)})" + + return None + + def _detect_subitem_with_text(self, text: str) -> Optional[Tuple[str, str]]: + """Detect subitem and return (label, remainder text).""" + text = text.strip() + + # Patterns for subitems with their content + patterns = [ + r"^\(?([a-z])\)?[.):]\s*(.*)$", # a) text, a. text, (a) text + r"^([a-z])\s*[-–—]\s*(.*)$", # a - text, a – text + ] + + for pattern in patterns: + match = re.match(pattern, text, re.IGNORECASE) + if match: + label = f"{match.group(1).lower()})" + remainder = match.group(2).strip() + return (label, remainder) + + return None + + def _extract_inline_cotacao(self, text: str) -> Optional[float]: + """Extract inline cotação like (3V), (2,5V), (4 valores) from text.""" + if not text: + return None + + for pattern in self.PATTERNS.get("inline_cotacao", []): + match = re.search(pattern, text, re.IGNORECASE) + if match: + value_str = match.group(1).replace(",", ".") + try: + return float(value_str) + except ValueError: + continue + + # Fallback patterns + fallback_patterns = [ + r"\((\d+(?:[.,]\d+)?)\s*[Vv]\)", + r"\((\d+(?:[.,]\d+)?)\s*valores?\)", + r"\((\d+(?:[.,]\d+)?)\s*pontos?\)", + r";(\d+(?:[.,]\d+)?)\s*[Vv]\)", # Pattern like ";2V)" + ] + for pattern in fallback_patterns: + match = re.search(pattern, text, re.IGNORECASE) + if match: + value_str = match.group(1).replace(",", ".") + try: + return float(value_str) + except ValueError: + continue + + return None + + def _remove_inline_cotacao(self, text: str) -> str: + """Remove inline cotação from text.""" + if not text: + return text + + patterns = [ + r"\s*\(\d+(?:[.,]\d+)?\s*[Vv]\)", + r"\s*\(\d+(?:[.,]\d+)?\s*valores?\)", + r"\s*\(\d+(?:[.,]\d+)?\s*pontos?\)", + r"\s*\[\d+(?:[.,]\d+)?\s*[Vv]\]", + r";\s*\d+(?:[.,]\d+)?\s*[Vv]\)", # Pattern like ";2V)" + ] + for pattern in patterns: + text = re.sub(pattern, "", text, flags=re.IGNORECASE) + + return text.strip() + + def _clean_footer_garbage(self, text: str) -> str: + """Remove footer garbage like coordination signatures, website URLs, etc.""" + if not text: + return text + + # Remove common footer patterns + garbage_patterns = [ + r"A?\s*COORDENA[CÇ][AÃ]O.*$", + r"minttics\.gov\.ao.*$", + r"LUANDA[\s-]*ANGOLA.*$", + r"gov\.ao.*$", + r"\d+/E\d+/\d+.*$", + r"kaixa\d*.*$", + r"klvs.*$", + r"GOIK.*$", + r"b\s*=\s*N\.m\.I.*$", + r"A7IUANDA.*$", + r"\.1\.10\s+kaixa.*$", + ] + for pattern in garbage_patterns: + text = re.sub(pattern, "", text, flags=re.IGNORECASE) + + # Clean up extra whitespace + text = re.sub(r'\s+', ' ', text).strip() + return text + def _is_option_block(self, text: str) -> bool: + """Check if text looks like a multiple choice option.""" + text = text.strip() + for pattern in self.PATTERNS["option"]: + if re.match(pattern, text, re.IGNORECASE): + return True + return False + def _detect_option(self, text: str) -> Optional[Tuple[str, str]]: """Detect if text is a question option.""" text = text.strip() @@ -517,45 +1288,131 @@ def _detect_option(self, text: str) -> Optional[Tuple[str, str]]: return None - def _detect_score(self, text: str) -> Optional[float]: - """Detect score/points in text.""" - for pattern in self.PATTERNS["score"]: + def _detect_image_reference(self, text: str) -> Tuple[bool, Optional[str]]: + """Detect if text contains image/figure references.""" + text_lower = text.lower() + + for pattern in self.PATTERNS["image_indicator"]: + match = re.search(pattern, text_lower) + if match: + # Try to extract a description + desc = self._extract_image_description(text) + return True, desc + + # Also check for mathematical expressions that might be images + math_indicators = [ + r"\\int", r"\\frac", r"\\sqrt", r"\\sum", r"\\lim", + r"\^{", r"_{", r"→", r"∫", r"√", + ] + for indicator in math_indicators: + if indicator in text: + return True, "Expressão matemática complexa" + + return False, None + + def _extract_image_description(self, text: str) -> str: + """Extract a description for an image reference.""" + # Look for descriptive text near the image indicator + patterns = [ + r"(?:figura|gráfico|tabela)\s+(?:que\s+)?(?:mostra|representa|ilustra)\s+(.+?)(?:\.|$)", + r"(?:observe|analise|considere)\s+(.+?)(?:\.|$)", + ] + + for pattern in patterns: match = re.search(pattern, text, re.IGNORECASE) if match: - score_str = match.group(1).replace(",", ".") - return float(score_str) - return None + return match.group(1).strip() + + # Default description + if "gráfico" in text.lower(): + return "Gráfico" + elif "tabela" in text.lower(): + return "Tabela" + elif "figura" in text.lower(): + return "Figura" + + return "Imagem ou expressão visual" + + def _is_coordination_block(self, text: str) -> bool: + """Check if text is a coordination/signature block.""" + text_lower = text.lower() + + for pattern in self.PATTERNS["coordination"]: + if re.search(pattern, text_lower): + return True + + return False + + def _detect_images_to_upload( + self, + metadata: ExtractedMetadata, + questions: List[ExtractedQuestion], + blocks: List[TextBlock], + full_text: str + ) -> List[ImageToUpload]: + """Detect regions that should be uploaded as images.""" + images = [] + + # Build base filename + base_name = "prova" + if metadata.subject_name.value: + base_name = f"prova-{metadata.subject_name.value.lower().replace(' ', '-')}" + if metadata.school_year_start.value and metadata.school_year_end.value: + base_name = f"{base_name}-{metadata.school_year_start.value}-{metadata.school_year_end.value}" + if metadata.variant.value: + base_name = f"{base_name}-serie-{metadata.variant.value.lower()}" + + # Check for header/logo region + if blocks and len(blocks) > 5: + header_text = " ".join(b.text for b in blocks[:5]) + if any(kw in header_text.lower() for kw in ["república", "angola", "ministério", "governo", "gabinete"]): + images.append(ImageToUpload( + suggested_filename=f"{base_name}-cabecalho.png", + description="Cabeçalho oficial com brasão/logo institucional", + region="cabecalho", + page_index=0, + )) + + # Add images for questions with visual content + for question in questions: + if question.has_image: + images.append(ImageToUpload( + suggested_filename=f"{base_name}-questao-{question.number}.png", + description=question.image_description or f"Imagem da questão {question.number}", + region=f"questao_{question.number}", + page_index=question.page_index, + )) + + # Check for coordination/signature at footer + for pattern in self.PATTERNS["coordination"]: + if re.search(pattern, full_text.lower()): + images.append(ImageToUpload( + suggested_filename=f"{base_name}-assinatura-coordenacao.png", + description="Assinatura da coordenação no rodapé da prova com texto A COORDENAÇÃO", + region="rodape", + page_index=len(set(b.page_index for b in blocks)) - 1 if blocks else 0, + )) + break + + return images def _infer_question_type(self, question: ExtractedQuestion) -> Tuple[QuestionType, float]: """Infer question type based on text and options.""" text_lower = question.text.lower() - # If has options, likely multiple choice - if question.options: - return QuestionType.MULTIPLE_CHOICE, 0.95 - - # Check for true/false patterns - for pattern in self.PATTERNS["true_false"]: - if re.search(pattern, text_lower, re.IGNORECASE): - return QuestionType.TRUE_FALSE, 0.90 - - # Check keywords - max_confidence = 0.0 - detected_type = QuestionType.UNKNOWN + # If has options, it's multiple choice + if question.options and len(question.options) > 0: + return QuestionType.MULTIPLA_ESCOLHA, 0.95 + # Check keywords for question type for q_type, keywords in self.QUESTION_TYPE_KEYWORDS.items(): matches = sum(1 for kw in keywords if kw in text_lower) if matches > 0: confidence = min(0.7 + (matches * 0.1), 0.95) - if confidence > max_confidence: - max_confidence = confidence - detected_type = q_type - - if detected_type != QuestionType.UNKNOWN: - return detected_type, max_confidence + return q_type, confidence - # Default to short answer if we can't determine - return QuestionType.SHORT_ANSWER, 0.5 + # Default to dissertativa for math/science exams + return QuestionType.DISSERTATIVA, 0.7 def _estimate_confidence_from_match( self, @@ -564,10 +1421,7 @@ def _estimate_confidence_from_match( blocks: List[TextBlock], ) -> float: """Estimate confidence for a regex match based on surrounding text blocks.""" - # Find blocks that contain the match - match_start = match.start() match_text = match.group(0) - base_confidence = 0.85 # Adjust based on block confidence @@ -584,8 +1438,12 @@ def _clean_text(self, text: str) -> str: text = re.sub(r"\s+", " ", text).strip() # Remove score annotations - for pattern in self.PATTERNS["score"]: - text = re.sub(pattern, "", text) + for pattern in self.PATTERNS["cotacao"]: + text = re.sub(pattern, "", text, flags=re.IGNORECASE) + + # Remove inline cotação blocks (e.g., "COTACAO:1) a - 4 V,b - 4 V/2) 5 V...") + text = re.sub(r"COTACAO\s*:\s*[^.]*(?:\.|$)", "", text, flags=re.IGNORECASE) + text = re.sub(r"COTAÇÃO\s*:\s*[^.]*(?:\.|$)", "", text, flags=re.IGNORECASE) return text.strip() @@ -604,10 +1462,10 @@ def _generate_metadata_warnings(self, metadata: ExtractedMetadata) -> List[Warni warnings = [] fields = [ - ("schoolYear", metadata.school_year), - ("term", metadata.term), - ("subject", metadata.subject), - ("class", metadata.class_info), + ("schoolYearStart", metadata.school_year_start), + ("schoolYearEnd", metadata.school_year_end), + ("subjectName", metadata.subject_name), + ("classGrade", metadata.class_grade), ("examType", metadata.exam_type), ] @@ -643,6 +1501,47 @@ def _generate_question_warnings(self, questions: List[ExtractedQuestion]) -> Lis return warnings + def _clean_null_fields(self, metadata: ExtractedMetadata) -> ExtractedMetadata: + """ + Clean up metadata fields that have no value or low confidence. + This prevents persisting empty/null data to the database. + """ + # Fields that should not be persisted if null/empty + # We keep the structure but ensure confidence is 0 for null values + + # For fields with None value, ensure confidence is 0 + if metadata.school_year_start.value is None: + metadata.school_year_start = MetadataField(None, 0.0) + if metadata.school_year_end.value is None: + metadata.school_year_end = MetadataField(None, 0.0) + if metadata.class_grade.value is None: + metadata.class_grade = MetadataField(None, 0.0) + if metadata.class_info.value is None: + metadata.class_info = MetadataField(None, 0.0) + if metadata.course_name.value is None: + metadata.course_name = MetadataField(None, 0.0) + if metadata.subject_name.value is None: + metadata.subject_name = MetadataField(None, 0.0) + if metadata.exam_type.value is None: + metadata.exam_type = MetadataField(None, 0.0) + if metadata.duration_minutes.value is None: + metadata.duration_minutes = MetadataField(None, 0.0) + if metadata.variant.value is None: + metadata.variant = MetadataField(None, 0.0) + if metadata.total_max_score.value is None: + metadata.total_max_score = MetadataField(None, 0.0) + + # Clean course name if it contains garbage + if metadata.course_name.value: + course = metadata.course_name.value + # If course is just "DE" or similar garbage, set to None + if course in ['DE', 'DA', 'DO', 'DAS', 'DOS', 'E', 'A', 'O']: + metadata.course_name = MetadataField(None, 0.0) + metadata.course = MetadataField(None, 0.0) + + return metadata + + def normalize_text(text: str) -> str: """ Normalize text for consistent processing. @@ -661,15 +1560,18 @@ def normalize_text(text: str) -> str: # Normalize dashes text = re.sub(r"[–—−]", "-", text) - # Common OCR fixes + # Common OCR fixes for Portuguese ocr_fixes = { - r"\bl\b": "I", # lowercase L to uppercase I - r"0(?=[A-Za-z])": "O", # zero before letter to O - r"(?<=[A-Za-z])0": "O", # zero after letter to O + r"12ª": "12ª", + r"12a": "12ª", + r"12°": "12ª", + r"Séria": "Série", + r"Sèrie": "Série", + r"Matematic[ao]": "Matemática", } for pattern, replacement in ocr_fixes.items(): - text = re.sub(pattern, replacement, text) + text = re.sub(pattern, replacement, text, flags=re.IGNORECASE) return text @@ -678,25 +1580,23 @@ def detect_language(text: str) -> str: """ Detect the primary language of the text. - Returns ISO 639-1 language code (pt, en, etc.) + Returns language code (pt, en, etc.) """ - # Simple keyword-based detection - pt_keywords = ["de", "da", "do", "em", "para", "com", "uma", "não", "que", "se", "os", "as"] - en_keywords = ["the", "and", "is", "are", "for", "with", "you", "that", "this", "have"] + # Portuguese indicators + pt_words = ["prova", "exame", "classe", "ano", "duração", "valores", "questão", + "calcule", "determine", "resolva", "trimestre", "letivo"] - text_lower = text.lower() - words = text_lower.split() + # English indicators + en_words = ["exam", "class", "year", "duration", "points", "question", + "calculate", "determine", "solve", "term"] - pt_count = sum(1 for w in words if w in pt_keywords) - en_count = sum(1 for w in words if w in en_keywords) + text_lower = text.lower() + pt_count = sum(1 for word in pt_words if word in text_lower) + en_count = sum(1 for word in en_words if word in text_lower) if pt_count > en_count: return "pt" elif en_count > pt_count: return "en" else: - return "pt" # Default to Portuguese - - -# Default postprocessor instance -default_postprocessor = OCRPostprocessor() + return "pt" # Default to Portuguese for Angolan context From 5b9a9d4e9bebe10579ea8a1aeb85ed3984cf6c68 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Thu, 5 Feb 2026 13:44:39 +0100 Subject: [PATCH 27/48] feat(ocr): adicionar SubitemContent ao OcrResponse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Novo record SubitemContent com label, text e cotacao - Campo subitemsContent no ExtractedQuestion para mapear alíneas - Permite persistir conteúdo individual de cada alínea (a, b, c) --- .../kixi/dto/ocr/OcrResponse.java | 269 +++++++++++++----- 1 file changed, 191 insertions(+), 78 deletions(-) diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ocr/OcrResponse.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ocr/OcrResponse.java index fc74063..db79497 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ocr/OcrResponse.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ocr/OcrResponse.java @@ -2,25 +2,21 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; -import java.util.Map; /** * OCR Service Response DTO * * Maps the JSON response from the OCR microservice to Java objects. - * This is the main response wrapper containing all extracted data. + * Optimized for Angolan exam papers (12ª classe). */ public record OcrResponse( String status, - @JsonProperty("requestId") - String requestId, + @JsonProperty("requestId") String requestId, - @JsonProperty("processingTimeMs") - Integer processingTimeMs, + @JsonProperty("processingTimeMs") Integer processingTimeMs, - @JsonProperty("overallConfidence") - Double overallConfidence, + @JsonProperty("overallConfidence") Double overallConfidence, DocumentInfo document, @@ -28,13 +24,13 @@ public record OcrResponse( List questions, - @JsonProperty("unmappedContent") - List unmappedContent, + @JsonProperty("imagesToUpload") List imagesToUpload, + + @JsonProperty("unmappedContent") List unmappedContent, List warnings, - @JsonProperty("errorMessage") - String errorMessage + @JsonProperty("errorMessage") String errorMessage ) { /** * Check if the OCR processing was successful. @@ -61,108 +57,157 @@ public boolean isError() { * Check if the result needs human review (low confidence or warnings). */ public boolean needsReview() { - return isPartial() || - (overallConfidence != null && overallConfidence < 0.8) || - (warnings != null && !warnings.isEmpty()); + return ( + isPartial() || + (overallConfidence != null && overallConfidence < 0.8) || + (warnings != null && !warnings.isEmpty()) + ); } /** * Document information from OCR. */ public record DocumentInfo( - @JsonProperty("pageCount") - Integer pageCount, - - @JsonProperty("mainLanguage") - String mainLanguage, - - @JsonProperty("hasTables") - Boolean hasTables + @JsonProperty("pageCount") Integer pageCount, + @JsonProperty("mainLanguage") String mainLanguage, + @JsonProperty("hasTables") Boolean hasTables ) {} /** - * Extracted metadata with confidence scores. + * Extracted metadata with confidence scores - Angolan exam format. */ public record OcrMetadata( - @JsonProperty("schoolYear") - ConfidenceField schoolYear, + // New structured fields + @JsonProperty("examType") ConfidenceField examType, - @JsonProperty("term") - ConfidenceField term, + @JsonProperty("durationMinutes") + ConfidenceField durationMinutes, - @JsonProperty("subject") - ConfidenceField subject, + @JsonProperty("variant") ConfidenceField variant, - @JsonProperty("course") - ConfidenceField course, + @JsonProperty("title") ConfidenceField title, - @JsonProperty("class") - ConfidenceField classInfo, + @JsonProperty("instructions") ConfidenceField instructions, - @JsonProperty("examType") - ConfidenceField examType, + @JsonProperty("schoolYearStart") + ConfidenceField schoolYearStart, - @JsonProperty("durationMinutes") - ConfidenceField durationMinutes, + @JsonProperty("schoolYearEnd") ConfidenceField schoolYearEnd, - @JsonProperty("variant") - ConfidenceField variant, + @JsonProperty("classGrade") ConfidenceField classGrade, - @JsonProperty("title") - ConfidenceField title, + @JsonProperty("courseName") ConfidenceField courseName, - @JsonProperty("instructions") - ConfidenceField instructions - ) {} + @JsonProperty("subjectName") ConfidenceField subjectName, + + @JsonProperty("totalMaxScore") ConfidenceField totalMaxScore, + + // Legacy fields for compatibility + @JsonProperty("schoolYear") ConfidenceField schoolYear, + + @JsonProperty("term") ConfidenceField term, + + @JsonProperty("subject") ConfidenceField subject, + + @JsonProperty("course") ConfidenceField course, + + @JsonProperty("class") ConfidenceField classInfo + ) { + /** + * Get the school year start value, returning null if not present. + */ + public Integer getSchoolYearStartValue() { + return schoolYearStart != null ? schoolYearStart.value() : null; + } + + /** + * Get the school year end value, returning null if not present. + */ + public Integer getSchoolYearEndValue() { + return schoolYearEnd != null ? schoolYearEnd.value() : null; + } + + /** + * Get the class grade value, returning null if not present. + */ + public String getClassGradeValue() { + return classGrade != null ? classGrade.value() : null; + } + + /** + * Get the course name value, returning null if not present. + */ + public String getCourseNameValue() { + return courseName != null ? courseName.value() : null; + } + + /** + * Get the subject name value, returning null if not present. + */ + public String getSubjectNameValue() { + return subjectName != null ? subjectName.value() : null; + } + + /** + * Get the total max score value, returning null if not present. + */ + public Double getTotalMaxScoreValue() { + return totalMaxScore != null ? totalMaxScore.value() : null; + } + } /** * Generic confidence field for any value type. */ - public record ConfidenceField( - T value, - Double confidence - ) { + public record ConfidenceField(T value, Double confidence) { /** * Check if the field has a value with sufficient confidence. */ public boolean isConfident(double threshold) { - return value != null && confidence != null && confidence >= threshold; + return ( + value != null && confidence != null && confidence >= threshold + ); } /** * Check if the field has low confidence (needs review). */ public boolean isLowConfidence(double threshold) { - return value != null && confidence != null && confidence < threshold; + return ( + value != null && confidence != null && confidence < threshold + ); } } /** - * Extracted question with all components. + * Extracted question with all components - Angolan exam format. */ public record ExtractedQuestion( - Integer number, + @JsonProperty("number") String number, Double confidence, + @JsonProperty("subitems") List subitems, + + @JsonProperty("subitemsContent") List subitemsContent, + ConfidenceField text, - @JsonProperty("questionType") - ConfidenceField questionType, + @JsonProperty("type") String type, - @JsonProperty("maxScore") - ConfidenceField maxScore, + @JsonProperty("cotacao") Double cotacao, List options, - @JsonProperty("pageIndex") - Integer pageIndex, + @JsonProperty("hasImage") Boolean hasImage, + + @JsonProperty("imageDescription") String imageDescription, - @JsonProperty("startY") - Integer startY, + @JsonProperty("pageIndex") Integer pageIndex, - @JsonProperty("endY") - Integer endY + @JsonProperty("startY") Integer startY, + + @JsonProperty("endY") Integer endY ) { /** * Check if this is a multiple choice question. @@ -172,37 +217,105 @@ public boolean isMultipleChoice() { } /** - * Get the question type value, defaulting to "unknown". + * Check if this is a dissertativa (essay/development) question. */ - public String getQuestionTypeValue() { - return questionType != null && questionType.value() != null - ? questionType.value() - : "unknown"; + public boolean isDissertativa() { + return "dissertativa".equals(type); + } + + /** + * Get the question type value. + */ + public String getTypeValue() { + return type != null ? type : "unknown"; + } + + /** + * Get the text value, returning empty string if not present. + */ + public String getTextValue() { + return text != null && text.value() != null ? text.value() : ""; + } + + /** + * Get the cotação (score) as a Double, returning null if not present. + */ + public Double getCotacaoValue() { + return cotacao; + } + + /** + * Check if the question has visual content. + */ + public boolean hasVisualContent() { + return hasImage != null && hasImage; } } + /** + * Subitem content with label, text and optional cotação. + */ + public record SubitemContent(String label, String text, Double cotacao) {} + /** * Extracted question option. */ public record ExtractedOption( - @JsonProperty("optionLabel") - String optionLabel, - - @JsonProperty("optionText") - String optionText, - + @JsonProperty("optionLabel") String optionLabel, + @JsonProperty("optionText") String optionText, Double confidence ) {} + /** + * Image region to be uploaded. + */ + public record ImageToUpload( + @JsonProperty("suggestedFilename") String suggestedFilename, + + String description, + + String region, + + @JsonProperty("pageIndex") Integer pageIndex + ) { + /** + * Check if this is a header/logo image. + */ + public boolean isHeader() { + return "cabecalho".equals(region); + } + + /** + * Check if this is a footer/coordination signature image. + */ + public boolean isFooter() { + return "rodape".equals(region); + } + + /** + * Check if this is a question-related image. + */ + public boolean isQuestionImage() { + return region != null && region.startsWith("questao_"); + } + + /** + * Get the question number if this is a question image. + */ + public String getQuestionNumber() { + if (isQuestionImage() && region.length() > 8) { + return region.substring(8); + } + return null; + } + } + /** * Unmapped content that couldn't be categorized. */ public record UnmappedContent( - @JsonProperty("pageIndex") - Integer pageIndex, - + @JsonProperty("pageIndex") Integer pageIndex, String text, - Double confidence ) {} From cfc6aa8177c90b65f4a73ba63a6079bddbbc76c7 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Thu, 5 Feb 2026 13:44:49 +0100 Subject: [PATCH 28/48] feat(ocr): adicionar ExamExtractionResponse e OcrPersistenceService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExamExtractionResponse: - DTO estruturado para extração de exames angolanos - Conversão de OcrResponse para formato de persistência - QuestionData, OptionData, ImageToUploadData, WarningData OcrPersistenceService: - Serviço para processar OCR e persistir no banco - Criação automática de SchoolYear, Course, Subject, Class - Mapeamento de questões e opções - Suporte a lookup ou criação de entidades relacionadas --- .../kixi/dto/ocr/ExamExtractionResponse.java | 279 +++++++ .../kixi/service/OcrPersistenceService.java | 778 ++++++++++++++++++ 2 files changed, 1057 insertions(+) create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/dto/ocr/ExamExtractionResponse.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/service/OcrPersistenceService.java diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ocr/ExamExtractionResponse.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ocr/ExamExtractionResponse.java new file mode 100644 index 0000000..906f247 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ocr/ExamExtractionResponse.java @@ -0,0 +1,279 @@ +package ao.creativemode.kixi.dto.ocr; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * Response DTO for structured exam extraction from OCR. + * + * Represents the complete extracted exam data in Angolan format, + * matching the exact structure required for persistence. + */ +public record ExamExtractionResponse( + @JsonProperty("exam_type") String examType, + + @JsonProperty("duration_minutes") Integer durationMinutes, + + String variant, + + String title, + + String instructions, + + @JsonProperty("school_year_start") Integer schoolYearStart, + + @JsonProperty("school_year_end") Integer schoolYearEnd, + + @JsonProperty("class_grade") String classGrade, + + @JsonProperty("course_name") String courseName, + + @JsonProperty("subject_name") String subjectName, + + @JsonProperty("total_max_score") Double totalMaxScore, + + List questions, + + @JsonProperty("images_to_upload") List imagesToUpload, + + // Processing metadata + @JsonProperty("request_id") String requestId, + + @JsonProperty("processing_time_ms") Integer processingTimeMs, + + @JsonProperty("overall_confidence") Double overallConfidence, + + @JsonProperty("needs_review") Boolean needsReview, + + List warnings +) { + /** + * Create from OcrResponse. + */ + public static ExamExtractionResponse fromOcrResponse(OcrResponse response) { + if (response == null) { + return null; + } + + OcrResponse.OcrMetadata metadata = response.metadata(); + + return new ExamExtractionResponse( + metadata != null && metadata.examType() != null + ? metadata.examType().value() : null, + metadata != null && metadata.durationMinutes() != null + ? metadata.durationMinutes().value() : null, + metadata != null && metadata.variant() != null + ? metadata.variant().value() : null, + metadata != null && metadata.title() != null + ? metadata.title().value() : null, + metadata != null && metadata.instructions() != null + ? metadata.instructions().value() : null, + metadata != null ? metadata.getSchoolYearStartValue() : null, + metadata != null ? metadata.getSchoolYearEndValue() : null, + metadata != null ? metadata.getClassGradeValue() : null, + metadata != null ? metadata.getCourseNameValue() : null, + metadata != null ? metadata.getSubjectNameValue() : null, + metadata != null ? metadata.getTotalMaxScoreValue() : null, + response.questions() != null + ? response.questions().stream() + .map(QuestionData::fromExtractedQuestion) + .toList() + : List.of(), + response.imagesToUpload() != null + ? response.imagesToUpload().stream() + .map(ImageToUploadData::fromImageToUpload) + .toList() + : List.of(), + response.requestId(), + response.processingTimeMs(), + response.overallConfidence(), + response.needsReview(), + response.warnings() != null + ? response.warnings().stream() + .map(WarningData::fromOcrWarning) + .toList() + : List.of() + ); + } + + /** + * Check if extraction was successful. + */ + public boolean isValid() { + return subjectName != null && !subjectName.isBlank(); + } + + /** + * Question data for the response. + */ + public record QuestionData( + String number, + + List subitems, + + String text, + + String type, + + Double cotacao, + + List options, + + @JsonProperty("has_image") Boolean hasImage, + + @JsonProperty("image_description") String imageDescription, + + Double confidence + ) { + /** + * Create from ExtractedQuestion. + */ + public static QuestionData fromExtractedQuestion(OcrResponse.ExtractedQuestion q) { + if (q == null) return null; + + return new QuestionData( + q.number(), + q.subitems() != null ? q.subitems() : List.of(), + q.getTextValue(), + q.getTypeValue(), + q.getCotacaoValue(), + q.options() != null + ? q.options().stream() + .map(OptionData::fromExtractedOption) + .toList() + : null, + q.hasVisualContent(), + q.imageDescription(), + q.confidence() + ); + } + + /** + * Check if this is a multiple choice question. + */ + public boolean isMultipleChoice() { + return "multipla_escolha".equals(type) || + (options != null && !options.isEmpty()); + } + + /** + * Check if this is a dissertative question. + */ + public boolean isDissertativa() { + return "dissertativa".equals(type); + } + } + + /** + * Option data for multiple choice questions. + */ + public record OptionData( + @JsonProperty("option_label") String optionLabel, + + @JsonProperty("option_text") String optionText, + + Double confidence + ) { + /** + * Create from ExtractedOption. + */ + public static OptionData fromExtractedOption(OcrResponse.ExtractedOption opt) { + if (opt == null) return null; + + return new OptionData( + opt.optionLabel(), + opt.optionText(), + opt.confidence() + ); + } + } + + /** + * Image to upload data. + */ + public record ImageToUploadData( + @JsonProperty("suggested_filename") String suggestedFilename, + + String description, + + String region + ) { + /** + * Create from ImageToUpload. + */ + public static ImageToUploadData fromImageToUpload(OcrResponse.ImageToUpload img) { + if (img == null) return null; + + return new ImageToUploadData( + img.suggestedFilename(), + img.description(), + img.region() + ); + } + + /** + * Check if this is a header image. + */ + public boolean isHeader() { + return "cabecalho".equals(region); + } + + /** + * Check if this is a footer/coordination signature. + */ + public boolean isFooter() { + return "rodape".equals(region); + } + + /** + * Check if this is a question image. + */ + public boolean isQuestionImage() { + return region != null && region.startsWith("questao_"); + } + + /** + * Get the question number if this is a question image. + */ + public String getQuestionNumber() { + if (isQuestionImage() && region.length() > 8) { + return region.substring(8); + } + return null; + } + } + + /** + * Warning data. + */ + public record WarningData( + String code, + + String field, + + Double confidence, + + String message + ) { + /** + * Create from OcrWarning. + */ + public static WarningData fromOcrWarning(OcrResponse.OcrWarning w) { + if (w == null) return null; + + return new WarningData( + w.code(), + w.field(), + w.confidence(), + w.message() + ); + } + + /** + * Check if this is a low confidence warning. + */ + public boolean isLowConfidence() { + return "LOW_CONFIDENCE".equals(code); + } + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/OcrPersistenceService.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/OcrPersistenceService.java new file mode 100644 index 0000000..75d73e8 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/OcrPersistenceService.java @@ -0,0 +1,778 @@ +package ao.creativemode.kixi.service; + +import ao.creativemode.kixi.client.OcrServiceClient; +import ao.creativemode.kixi.common.exception.ApiException; +import ao.creativemode.kixi.dto.ocr.OcrResponse; +import ao.creativemode.kixi.dto.ocr.OcrResponse.ExtractedOption; +import ao.creativemode.kixi.dto.ocr.OcrResponse.ExtractedQuestion; +import ao.creativemode.kixi.dto.ocr.OcrResponse.OcrMetadata; +import ao.creativemode.kixi.model.Class; +import ao.creativemode.kixi.model.Course; +import ao.creativemode.kixi.model.Question; +import ao.creativemode.kixi.model.QuestionOption; +import ao.creativemode.kixi.model.SchoolYear; +import ao.creativemode.kixi.model.Statement; +import ao.creativemode.kixi.model.Subject; +import ao.creativemode.kixi.repository.ClassRepository; +import ao.creativemode.kixi.repository.CourseRepository; +import ao.creativemode.kixi.repository.QuestionOptionRepository; +import ao.creativemode.kixi.repository.QuestionRepository; +import ao.creativemode.kixi.repository.SchoolYearRepository; +import ao.creativemode.kixi.repository.StatementRepository; +import ao.creativemode.kixi.repository.SubjectRepository; +import java.time.LocalDateTime; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.codec.multipart.FilePart; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Service for persisting OCR extraction results. + * + * Handles the complete workflow of: + * - Extracting data via OCR + * - Looking up or creating related entities (SchoolYear, Course, Subject, Class) + * - Creating Statement with Questions and Options + * + * Implements the uniqueness constraints: + * - school_years: unique by (start_year, end_year) + * - courses: unique by name (normalized) + * - subjects: unique by name + * - classes: unique by (grade, course_id, school_year_id) + * - statement: unique by (title + variant + school_year_id + subject_id + class_id) + */ +@Service +public class OcrPersistenceService { + + private static final Logger log = LoggerFactory.getLogger( + OcrPersistenceService.class + ); + + private static final double LOW_CONFIDENCE_THRESHOLD = 0.8; + private static final double MIN_CONFIDENCE_THRESHOLD = 0.5; + + private final OcrServiceClient ocrServiceClient; + private final StatementRepository statementRepository; + private final QuestionRepository questionRepository; + private final QuestionOptionRepository optionRepository; + private final SchoolYearRepository schoolYearRepository; + private final CourseRepository courseRepository; + private final SubjectRepository subjectRepository; + private final ClassRepository classRepository; + + public OcrPersistenceService( + OcrServiceClient ocrServiceClient, + StatementRepository statementRepository, + QuestionRepository questionRepository, + QuestionOptionRepository optionRepository, + SchoolYearRepository schoolYearRepository, + CourseRepository courseRepository, + SubjectRepository subjectRepository, + ClassRepository classRepository + ) { + this.ocrServiceClient = ocrServiceClient; + this.statementRepository = statementRepository; + this.questionRepository = questionRepository; + this.optionRepository = optionRepository; + this.schoolYearRepository = schoolYearRepository; + this.courseRepository = courseRepository; + this.subjectRepository = subjectRepository; + this.classRepository = classRepository; + } + + // ========================================================================= + // Main OCR Processing Methods + // ========================================================================= + + /** + * Process uploaded files via OCR and persist the results. + * + * @param files List of uploaded image files + * @param createdBy ID of the user creating the statement + * @return Mono containing the created statement with all related data + */ + @Transactional + public Mono processAndPersist( + List files, + Long createdBy + ) { + log.info( + "Processing OCR and persisting: {} file(s), createdBy={}", + files.size(), + createdBy + ); + + return ocrServiceClient + .extractText(files) + .flatMap(ocrResponse -> { + if (ocrResponse.isError()) { + log.error( + "OCR extraction failed: {}", + ocrResponse.errorMessage() + ); + return Mono.error( + ApiException.badRequest( + "OCR extraction failed: " + + ocrResponse.errorMessage() + ) + ); + } + + log.info( + "OCR extraction successful: requestId={}, confidence={}, questions={}", + ocrResponse.requestId(), + ocrResponse.overallConfidence(), + ocrResponse.questions() != null + ? ocrResponse.questions().size() + : 0 + ); + + return persistOcrResponse(ocrResponse, createdBy); + }) + .doOnSuccess(result -> + log.info( + "Statement created from OCR: statementId={}", + result.statement().getId() + ) + ) + .doOnError(error -> + log.error("Failed to process and persist OCR", error) + ); + } + + /** + * Persist an OCR response to the database. + * + * @param ocrResponse The OCR response containing extracted data + * @param createdBy ID of the user creating the statement + * @return Mono containing the created statement with all related data + */ + @Transactional + public Mono persistOcrResponse( + OcrResponse ocrResponse, + Long createdBy + ) { + OcrMetadata metadata = ocrResponse.metadata(); + + // Step 1: Find or create SchoolYear + Mono schoolYearMono = findOrCreateSchoolYear(metadata); + + // Step 2: Find or create Course + Mono courseMono = findOrCreateCourse(metadata); + + // Step 3: Find or create Subject + Mono subjectMono = findOrCreateSubject(metadata); + + // Combine the lookups and then create Class and Statement + return Mono.zip(schoolYearMono, courseMono, subjectMono).flatMap( + tuple -> { + SchoolYear schoolYear = tuple.getT1(); + Course course = tuple.getT2(); + Subject subject = tuple.getT3(); + + // Step 4: Find or create Class + return findOrCreateClass(metadata, course, schoolYear).flatMap( + classEntity -> { + // Step 5: Create Statement + return createStatement( + ocrResponse, + metadata, + createdBy, + schoolYear, + course, + subject, + classEntity + ).flatMap(statement -> { + // Step 6: Create Questions + return createQuestions( + statement.getId(), + ocrResponse.questions() + ) + .collectList() + .flatMap(questions -> { + // Step 7: Load all options + List questionIds = questions + .stream() + .map(Question::getId) + .toList(); + + return optionRepository + .findAllByQuestionIds(questionIds) + .collectList() + .map(options -> + new StatementWithRelations( + statement, + schoolYear, + course, + subject, + classEntity, + questions, + options, + ocrResponse.imagesToUpload() + ) + ); + }); + }); + } + ); + } + ); + } + + // ========================================================================= + // Entity Lookup/Create Methods + // ========================================================================= + + /** + * Find or create a SchoolYear based on OCR metadata. + */ + private Mono findOrCreateSchoolYear(OcrMetadata metadata) { + Integer startYear = metadata.getSchoolYearStartValue(); + Integer endYear = metadata.getSchoolYearEndValue(); + + if (startYear == null || endYear == null) { + // Default to current academic year + int currentYear = LocalDateTime.now().getYear(); + int currentMonth = LocalDateTime.now().getMonthValue(); + // Academic year in Angola typically starts in September + if (currentMonth >= 9) { + startYear = currentYear; + endYear = currentYear + 1; + } else { + startYear = currentYear - 1; + endYear = currentYear; + } + log.warn( + "School year not extracted, using default: {}/{}", + startYear, + endYear + ); + } + + final Integer finalStartYear = startYear; + final Integer finalEndYear = endYear; + + return schoolYearRepository + .findByStartYearAndEndYearAndDeletedAtIsNull(startYear, endYear) + .switchIfEmpty( + Mono.defer(() -> { + log.info( + "Creating new school year: {}/{}", + finalStartYear, + finalEndYear + ); + SchoolYear newSchoolYear = new SchoolYear(); + newSchoolYear.setStartYear(finalStartYear); + newSchoolYear.setEndYear(finalEndYear); + return schoolYearRepository.save(newSchoolYear); + }) + ); + } + + /** + * Find or create a Course based on OCR metadata. + */ + private Mono findOrCreateCourse(OcrMetadata metadata) { + String courseName = metadata.getCourseNameValue(); + + if (courseName == null || courseName.isBlank()) { + courseName = "TODOS"; // Default course for general exams + } + + // Normalize course name + String normalizedName = normalizeCourseName(courseName); + final String finalCourseName = normalizedName; + + return courseRepository + .findByNameIgnoreCaseAndDeletedAtIsNull(normalizedName) + .switchIfEmpty( + Mono.defer(() -> { + log.info("Creating new course: {}", finalCourseName); + Course newCourse = new Course(); + newCourse.setName(finalCourseName); + newCourse.setCode(generateCourseCode(finalCourseName)); + return courseRepository.save(newCourse); + }) + ); + } + + /** + * Find or create a Subject based on OCR metadata. + */ + private Mono findOrCreateSubject(OcrMetadata metadata) { + String subjectName = metadata.getSubjectNameValue(); + + if (subjectName == null || subjectName.isBlank()) { + return Mono.error( + ApiException.badRequest( + "Subject name is required but not extracted from OCR" + ) + ); + } + + // Normalize subject name + String normalizedName = normalizeSubjectName(subjectName); + final String finalSubjectName = normalizedName; + + return subjectRepository + .findByNameIgnoreCaseAndDeletedAtIsNull(normalizedName) + .switchIfEmpty( + Mono.defer(() -> { + log.info("Creating new subject: {}", finalSubjectName); + Subject newSubject = new Subject(); + newSubject.setName(finalSubjectName); + newSubject.setCode(generateSubjectCode(finalSubjectName)); + newSubject.setShortName( + generateShortName(finalSubjectName) + ); + return subjectRepository.save(newSubject); + }) + ); + } + + /** + * Find or create a Class based on OCR metadata. + */ + private Mono findOrCreateClass( + OcrMetadata metadata, + Course course, + SchoolYear schoolYear + ) { + String gradeStr = metadata.getClassGradeValue(); + + if (gradeStr == null || gradeStr.isBlank()) { + gradeStr = "12"; // Default to 12th grade for exams + log.warn("Class grade not extracted, using default: {}", gradeStr); + } + + // Parse grade to Integer + Integer grade; + try { + grade = Integer.parseInt(gradeStr.replaceAll("[^0-9]", "")); + } catch (NumberFormatException e) { + grade = 12; // Default to 12th grade + log.warn("Could not parse grade '{}', using default: 12", gradeStr); + } + + final Integer finalGrade = grade; + final String gradeCode = String.valueOf(grade); + + // Try to find with course first + if (course != null && course.getId() != null) { + return classRepository + .findByGradeAndCourseIdAndSchoolYearIdAndDeletedAtIsNull( + grade, + course.getId(), + schoolYear.getId() + ) + .switchIfEmpty( + Mono.defer(() -> { + log.info( + "Creating new class: grade={}, courseId={}, schoolYearId={}", + finalGrade, + course.getId(), + schoolYear.getId() + ); + Class newClass = new Class(); + newClass.setGrade(finalGrade); + newClass.setCourseId(course.getId()); + newClass.setSchoolYearId(schoolYear.getId()); + newClass.setCode( + generateClassCode(gradeCode, course.getCode()) + ); + return classRepository.save(newClass); + }) + ); + } + + // Find without course + return classRepository + .findByGradeAndSchoolYearIdAndDeletedAtIsNull( + grade, + schoolYear.getId() + ) + .switchIfEmpty( + Mono.defer(() -> { + log.info( + "Creating new class: grade={}, schoolYearId={}", + finalGrade, + schoolYear.getId() + ); + Class newClass = new Class(); + newClass.setGrade(finalGrade); + newClass.setSchoolYearId(schoolYear.getId()); + newClass.setCode(generateClassCode(gradeCode, null)); + return classRepository.save(newClass); + }) + ); + } + + // ========================================================================= + // Statement and Questions Creation + // ========================================================================= + + /** + * Create a Statement from OCR data. + */ + private Mono createStatement( + OcrResponse ocrResponse, + OcrMetadata metadata, + Long createdBy, + SchoolYear schoolYear, + Course course, + Subject subject, + Class classEntity + ) { + Statement statement = new Statement(); + + // Title + if (metadata.title() != null && metadata.title().value() != null) { + statement.setTitle(metadata.title().value()); + } else { + statement.setTitle( + buildDefaultTitle(metadata, subject, classEntity, schoolYear) + ); + } + + // Exam type + if ( + metadata.examType() != null && metadata.examType().value() != null + ) { + statement.setExamType(metadata.examType().value()); + } else { + statement.setExamType("Prova de Exame"); + } + + // Duration + if ( + metadata.durationMinutes() != null && + metadata.durationMinutes().value() != null + ) { + statement.setDurationMinutes(metadata.durationMinutes().value()); + } + + // Variant + if (metadata.variant() != null && metadata.variant().value() != null) { + statement.setVariant(metadata.variant().value()); + } + + // Instructions + if ( + metadata.instructions() != null && + metadata.instructions().value() != null + ) { + statement.setInstructions(metadata.instructions().value()); + } + + // Total max score + if (metadata.getTotalMaxScoreValue() != null) { + statement.setTotalMaxScore(metadata.getTotalMaxScoreValue()); + } else { + // Calculate from questions + double totalScore = + ocrResponse.questions() != null + ? ocrResponse + .questions() + .stream() + .filter(q -> q.getCotacaoValue() != null) + .mapToDouble(ExtractedQuestion::getCotacaoValue) + .sum() + : 0.0; + if (totalScore > 0) { + statement.setTotalMaxScore(totalScore); + } + } + + // Set foreign keys + statement.setSchoolYearId(schoolYear.getId()); + statement.setCourseId(course != null ? course.getId() : null); + statement.setSubjectId(subject.getId()); + statement.setClassId(classEntity.getId()); + statement.setCreatedBy(createdBy); + + // OCR metadata + statement.setOcrMetadata( + ocrResponse.requestId(), + ocrResponse.overallConfidence(), + ocrResponse.needsReview() + ); + statement.setSource("ocr"); + statement.setVisible(false); // Require manual review before publishing + + return statementRepository.save(statement); + } + + /** + * Create questions from OCR extracted data. + */ + private Flux createQuestions( + Long statementId, + List extractedQuestions + ) { + if (extractedQuestions == null || extractedQuestions.isEmpty()) { + return Flux.empty(); + } + + return Flux.fromIterable(extractedQuestions) + .index() + .flatMap(tuple -> { + long index = tuple.getT1(); + ExtractedQuestion extracted = tuple.getT2(); + + Question question = mapExtractedToQuestion( + statementId, + extracted, + (int) index + ); + + return questionRepository + .save(question) + .flatMap(savedQuestion -> { + // Create options if this is a multiple choice question + if ( + extracted.options() != null && + !extracted.options().isEmpty() + ) { + return createOptions( + savedQuestion.getId(), + extracted.options() + ).then(Mono.just(savedQuestion)); + } + return Mono.just(savedQuestion); + }); + }); + } + + /** + * Map extracted question to Question entity. + */ + private Question mapExtractedToQuestion( + Long statementId, + ExtractedQuestion extracted, + int orderIndex + ) { + Question question = new Question(); + question.setStatementId(statementId); + + // Parse number (might be string like "1", "2a", etc.) + try { + question.setNumber( + Integer.parseInt(extracted.number().replaceAll("[^0-9]", "")) + ); + } catch (NumberFormatException e) { + question.setNumber(orderIndex + 1); + } + + question.setOrderIndex(orderIndex); + question.setText(extracted.getTextValue()); + question.setQuestionType(mapQuestionType(extracted.getTypeValue())); + + // Cotação (score) + if (extracted.getCotacaoValue() != null) { + question.setMaxScore(extracted.getCotacaoValue()); + } + + // OCR metadata + question.setOcrConfidence(extracted.confidence()); + question.setPageIndex(extracted.pageIndex()); + + // Mark for review if low confidence + question.setNeedsReview( + extracted.confidence() != null && + extracted.confidence() < LOW_CONFIDENCE_THRESHOLD + ); + + return question; + } + + /** + * Map question type from Portuguese to database format. + */ + private String mapQuestionType(String type) { + if (type == null) { + return "unknown"; + } + return switch (type.toLowerCase()) { + case "dissertativa" -> "development"; + case "multipla_escolha" -> "multiple_choice"; + default -> type; + }; + } + + /** + * Create options for a multiple choice question. + */ + private Flux createOptions( + Long questionId, + List extractedOptions + ) { + return Flux.fromIterable(extractedOptions) + .index() + .flatMap(tuple -> { + int index = tuple.getT1().intValue(); + ExtractedOption extracted = tuple.getT2(); + + QuestionOption option = new QuestionOption(); + option.setQuestionId(questionId); + option.setOptionLabel(extracted.optionLabel()); + option.setOptionText(extracted.optionText()); + option.setOrderIndex(index); + option.setOcrConfidence(extracted.confidence()); + option.setIsCorrect(false); // OCR cannot determine correct answer + + return optionRepository.save(option); + }); + } + + // ========================================================================= + // Utility Methods + // ========================================================================= + + /** + * Normalize course name for consistent storage. + */ + private String normalizeCourseName(String name) { + if (name == null) return null; + return name.trim().toUpperCase(); + } + + /** + * Normalize subject name with proper Portuguese capitalization. + */ + private String normalizeSubjectName(String name) { + if (name == null) return null; + + // Subject name corrections + String normalized = name.trim(); + return switch (normalized.toLowerCase()) { + case "matematica", "matemática" -> "Matemática"; + case "fisica", "física" -> "Física"; + case "quimica", "química" -> "Química"; + case "biologia" -> "Biologia"; + case "portugues", "português" -> "Português"; + case "ingles", "inglês" -> "Inglês"; + case "frances", "francês" -> "Francês"; + case "historia", "história" -> "História"; + case "geografia" -> "Geografia"; + case "filosofia" -> "Filosofia"; + default -> toTitleCase(normalized); + }; + } + + /** + * Convert string to title case. + */ + private String toTitleCase(String text) { + if (text == null || text.isEmpty()) return text; + String[] words = text.split("\\s+"); + StringBuilder result = new StringBuilder(); + for (String word : words) { + if (!result.isEmpty()) result.append(" "); + if (!word.isEmpty()) { + result.append(Character.toUpperCase(word.charAt(0))); + if (word.length() > 1) { + result.append(word.substring(1).toLowerCase()); + } + } + } + return result.toString(); + } + + /** + * Generate course code from name. + */ + private String generateCourseCode(String name) { + if (name == null) return "GEN"; + return name.length() > 3 + ? name.substring(0, 3).toUpperCase() + : name.toUpperCase(); + } + + /** + * Generate subject code from name. + */ + private String generateSubjectCode(String name) { + if (name == null) return "GEN"; + String code = name + .replaceAll("[aeiouáàâãéèêíïóôõöúç\\s]", "") + .toUpperCase(); + return code.length() > 4 + ? code.substring(0, 4) + : (code.isEmpty() + ? name.substring(0, Math.min(3, name.length())).toUpperCase() + : code); + } + + /** + * Generate short name for subject. + */ + private String generateShortName(String name) { + if (name == null) return null; + if (name.length() <= 5) return name; + return name.substring(0, 5) + "."; + } + + /** + * Generate class code. + */ + private String generateClassCode(String grade, String courseCode) { + if (courseCode != null && !courseCode.isBlank()) { + return grade + "-" + courseCode; + } + return grade + "-GEN"; + } + + /** + * Build a default title from metadata. + */ + private String buildDefaultTitle( + OcrMetadata metadata, + Subject subject, + Class classEntity, + SchoolYear schoolYear + ) { + StringBuilder title = new StringBuilder("Prova de Exame"); + + if (subject != null) { + title.append(" de ").append(subject.getName()); + } + + if (classEntity != null) { + title.append(" ").append(classEntity.getGrade()).append("ª Classe"); + } + + if (metadata.variant() != null && metadata.variant().value() != null) { + title.append(" - Série ").append(metadata.variant().value()); + } + + title + .append(" - ") + .append(schoolYear.getStartYear()) + .append("/") + .append(schoolYear.getEndYear()); + + return title.toString(); + } + + // ========================================================================= + // Result Records + // ========================================================================= + + /** + * Complete result with statement and all related entities. + */ + public record StatementWithRelations( + Statement statement, + SchoolYear schoolYear, + Course course, + Subject subject, + Class classEntity, + List questions, + List options, + List imagesToUpload + ) {} +} From 23079cba43e73d454035ae0c060144a278fec4d3 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Thu, 5 Feb 2026 13:45:07 +0100 Subject: [PATCH 29/48] =?UTF-8?q?docs:=20adicionar=20documenta=C3=A7=C3=A3?= =?UTF-8?q?o=20de=20mapeamento=20OCR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OCR_MAPPING.md com guia completo de mapeamento - Estrutura de campos OCR → Entidades do banco - Exemplos de extração de cotação angolana - Endpoints de teste e exemplos de uso - Troubleshooting e limites do sistema --- docs/OCR_MAPPING.md | 324 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 docs/OCR_MAPPING.md diff --git a/docs/OCR_MAPPING.md b/docs/OCR_MAPPING.md new file mode 100644 index 0000000..f0743fa --- /dev/null +++ b/docs/OCR_MAPPING.md @@ -0,0 +1,324 @@ +# Mapeamento OCR para Entidades do Banco de Dados + +Este documento descreve como os dados extraídos via OCR são mapeados para as entidades do banco de dados do sistema Kixi - Banco de Enunciados. + +## Visão Geral do Fluxo + +``` +Imagem/PDF → OCR Service (Python) → Backend API (Spring) → Banco de Dados (PostgreSQL) +``` + +## Estrutura da Prova Angolana (12ª Classe) + +### Exemplo de Cabeçalho + +``` +REPÚBLICA DE ANGOLA +GOVERNO DA PROVÍNCIA DE LUANDA +GABINETE PROVINCIAL DE EDUCAÇÃO +DEPARTAMENTO DE EDUCAÇÃO E ENSINO + +PROVA DE EXAME DE MATEMÁTICA +12ª Classe Ano Lectivo: 2024/2025 Duração: 90 Min. Série: B +CURSO: TODOS +``` + +### Exemplo de Cotação (Rodapé) + +``` +Cotação 1-a) 3 valores 2-) 4 valores 3-a) 2,5 valores 3-b) 2,5 valores 4-) 3 valores 5-a) 2,5 valores 5-b) 2,5 valores. +``` + +--- + +## Mapeamento: OCR → Entidades + +### 1. Statement (Enunciado) + +| Campo OCR | Campo Entidade | Tipo | Exemplo | +|-----------|----------------|------|---------| +| `examType` | `exam_type` | String | "Prova de Exame" | +| `durationMinutes` | `duration_minutes` | Integer | 90 | +| `variant` | `variant` | String | "B" | +| `title` | `title` | String | "Prova de Exame de Matemática 12ª Classe - Série B - 2024/2025" | +| `instructions` | `instructions` | String | "Leia com atenção, coloque na folha de prova..." | +| `totalMaxScore` | `total_max_score` | Double | 20.0 | +| `overallConfidence` | `ocr_confidence` | Double | 0.85 | +| `requestId` | `ocr_request_id` | String | "req-abc123xyz" | +| - | `source` | String | "ocr" (fixo) | +| - | `needs_review` | Boolean | true/false (baseado na confiança) | + +### 2. SchoolYear (Ano Letivo) + +| Campo OCR | Campo Entidade | Tipo | Exemplo | +|-----------|----------------|------|---------| +| `schoolYearStart` | `start_year` | Integer | 2024 | +| `schoolYearEnd` | `end_year` | Integer | 2025 | + +**Padrões de Extração:** +- `Ano Letivo: 2024/2025` +- `Ano Lectivo: 2024/2025` +- `2024/2025` +- `2024-2025` + +### 3. Subject (Disciplina) + +| Campo OCR | Campo Entidade | Tipo | Exemplo | +|-----------|----------------|------|---------| +| `subjectName` | `name` | String | "Matemática" | +| - | `code` | String | "MAT" (gerado) | +| - | `short_name` | String | "Matemática" (gerado) | + +**Padrões de Extração:** +- `PROVA DE EXAME DE MATEMÁTICA` +- `PROVA DE RECURSO DE FÍSICA` +- `EXAME DE QUÍMICA` + +**Normalização de Nomes:** +- "matematica" → "Matemática" +- "fisica" → "Física" +- "quimica" → "Química" +- "portugues" → "Português" +- (etc.) + +### 4. Course (Curso) + +| Campo OCR | Campo Entidade | Tipo | Exemplo | +|-----------|----------------|------|---------| +| `courseName` | `name` | String | "TODOS" | +| - | `code` | String | "TOD" (gerado) | + +**Padrões de Extração:** +- `CURSO: TODOS` +- `Curso: Ciências` + +### 5. Class (Turma) + +| Campo OCR | Campo Entidade | Tipo | Exemplo | +|-----------|----------------|------|---------| +| `classGrade` | `grade` | Integer | 12 | +| - | `code` | String | "12A-TOD" (gerado) | + +**Padrões de Extração:** +- `12ª Classe` +- `12º Ano` +- `10ª classe` + +### 6. Question (Questão) + +| Campo OCR | Campo Entidade | Tipo | Exemplo | +|-----------|----------------|------|---------| +| `number` | `number` | Integer | 1 | +| `text.value` | `text` | String | "Resolve a seguinte equação exponencial..." | +| `type` | `question_type` | String | "development" / "multiple_choice" | +| `cotacao` | `max_score` | Double | 3.0 | +| `confidence` | `ocr_confidence` | Double | 0.9 | +| `pageIndex` | `page_index` | Integer | 0 | +| - | `order_index` | Integer | 0 (sequencial) | +| - | `needs_review` | Boolean | true/false (baseado na confiança) | + +**Mapeamento de Tipos:** +| Tipo OCR | Tipo BD | +|----------|---------| +| `dissertativa` | `development` | +| `multipla_escolha` | `multiple_choice` | +| `unknown` | `unknown` | + +--- + +## Extração de Cotação + +### Formato Angolano + +A cotação geralmente aparece no final da prova no formato: + +``` +Cotação 1-a) 3 valores 2-) 4 valores 3-a) 2,5 valores 3-b) 2,5 valores 4-) 3 valores 5-a) 2,5 valores 5-b) 2,5 valores. +``` + +### Padrões Suportados + +1. **Questão com subitem:** `1-a) 3 valores` +2. **Questão sem subitem:** `2-) 4 valores` +3. **Valores decimais:** `3-b) 2,5 valores` +4. **Formato alternativo:** `(3 valores)` no final da questão + +### Mapeamento Interno + +O sistema cria um mapa de cotação: + +```json +{ + "1a": 3.0, + "2": 4.0, + "3a": 2.5, + "3b": 2.5, + "4": 3.0, + "5a": 2.5, + "5b": 2.5 +} +``` + +E depois associa a cada questão: +- Se a questão tem subitems, soma as cotações dos subitems +- Se não tem subitems, usa a cotação direta + +--- + +## Endpoints para Teste + +### Python OCR Service (porta 8000) + +```bash +# Health check +curl http://localhost:8000/ocr/health + +# Extração simples +curl -X POST http://localhost:8000/ocr/v1/extract/simple \ + -F "image=@prova.jpg" + +# Extração completa (múltiplas imagens/PDF) +curl -X POST http://localhost:8000/ocr/v1/extract \ + -F "images=@prova.pdf" + +# Idiomas suportados +curl http://localhost:8000/ocr/v1/supported-languages +``` + +### Spring Backend API (porta 8080) + +```bash +# Health check +curl http://localhost:8080/api/v1/ocr/health + +# Extração simples +curl -X POST http://localhost:8080/api/v1/ocr/extract/single \ + -F "file=@prova.jpg" + +# Extração de exame estruturado +curl -X POST http://localhost:8080/api/v1/ocr/extract/exam \ + -F "files=@prova.pdf" + +# Extração e persistência no banco +curl -X POST "http://localhost:8080/api/v1/ocr/extract-and-persist?createdBy=1" \ + -F "files=@prova.pdf" +``` + +--- + +## Resposta JSON de Exemplo + +### Extração de Exame (`/api/v1/ocr/extract/exam`) + +```json +{ + "exam_type": "Prova de Exame", + "duration_minutes": 90, + "variant": "B", + "title": "Prova de Exame de Matemática 12ª Classe - Série B - 2024/2025", + "instructions": "Leia a prova com atenção...", + "school_year_start": 2024, + "school_year_end": 2025, + "class_grade": "12", + "course_name": "TODOS", + "subject_name": "Matemática", + "total_max_score": 20.0, + "questions": [ + { + "number": "1", + "subitems": ["a)"], + "text": "Resolve a seguinte equação exponencial...", + "type": "dissertativa", + "cotacao": 3.0, + "options": null, + "has_image": true, + "image_description": "Expressão matemática complexa", + "confidence": 0.85 + }, + { + "number": "2", + "subitems": [], + "text": "Em um meio de cultura especial, a quantidade de bactérias...", + "type": "dissertativa", + "cotacao": 4.0, + "options": null, + "has_image": false, + "confidence": 0.9 + } + ], + "images_to_upload": [ + { + "suggested_filename": "prova-matematica-2024-2025-serie-b-cabecalho.png", + "description": "Cabeçalho oficial com brasão/logo institucional", + "region": "cabecalho" + }, + { + "suggested_filename": "prova-matematica-2024-2025-serie-b-questao-1.png", + "description": "Expressão matemática complexa", + "region": "questao_1" + } + ], + "request_id": "req-abc123xyz", + "processing_time_ms": 2500, + "overall_confidence": 0.87, + "needs_review": false, + "warnings": [] +} +``` + +--- + +## Script de Teste + +Use o script `test-ocr.sh` na raiz do projeto: + +```bash +# Ver ajuda +./test-ocr.sh --help + +# Verificar saúde dos serviços +./test-ocr.sh --check + +# Testar extração de exame +./test-ocr.sh -e prova.jpg + +# Testar todos os endpoints +./test-ocr.sh prova.pdf +``` + +--- + +## Troubleshooting + +### Cotação não extraída + +1. Verifique se a cotação está no formato esperado +2. A cotação deve estar no final da prova +3. Formatos suportados: `X valores`, `X pontos`, `X pts` + +### Disciplina não reconhecida + +1. Verifique se o nome está na lista de normalização +2. O padrão `PROVA DE [EXAME|RECURSO] DE ` é prioritário + +### Baixa confiança + +1. Melhore a qualidade da imagem (resolução mínima: 150 DPI) +2. Evite imagens com muito ruído ou anotações manuscritas +3. PDFs digitais têm melhor resultado que fotos + +--- + +## Formatos Suportados + +| Formato | Extensões | Notas | +|---------|-----------|-------| +| JPEG | .jpg, .jpeg | Fotos de provas | +| PNG | .png | Scans de alta qualidade | +| PDF | .pdf | Multipáginas suportado | +| WebP | .webp | Compressão moderna | +| BMP | .bmp | Sem compressão | +| TIFF | .tiff, .tif | Scans profissionais | + +**Limite de tamanho:** 20MB por arquivo +**Máximo de arquivos:** 10 por requisição \ No newline at end of file From 608728854ab17555880be3b78a75fb2ea7edd273 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Thu, 5 Feb 2026 13:45:15 +0100 Subject: [PATCH 30/48] feat: adicionar script de teste OCR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Script interativo para testar endpoints OCR - Suporte a Python OCR Service e Spring Backend - Modos: health check, python, spring, exam, all - Formatação colorida e resumo de resultados --- test-ocr.sh | 307 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100755 test-ocr.sh diff --git a/test-ocr.sh b/test-ocr.sh new file mode 100755 index 0000000..fd13024 --- /dev/null +++ b/test-ocr.sh @@ -0,0 +1,307 @@ +#!/bin/bash + +# ============================================================================= +# Script de Teste OCR - Banco de Enunciados +# ============================================================================= +# Uso: ./test-ocr.sh +# +# Exemplos: +# ./test-ocr.sh prova.jpg +# ./test-ocr.sh prova.pdf +# ./test-ocr.sh ~/Downloads/exame_matematica.png +# ============================================================================= + +set -e + +# Cores para output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# URLs dos serviços +PYTHON_OCR_URL="http://localhost:8000" +SPRING_API_URL="http://localhost:8080" + +# Função para imprimir cabeçalho +print_header() { + echo "" + echo -e "${BLUE}============================================${NC}" + echo -e "${BLUE} $1${NC}" + echo -e "${BLUE}============================================${NC}" + echo "" +} + +# Função para verificar se jq está instalado +check_jq() { + if ! command -v jq &> /dev/null; then + echo -e "${YELLOW}Aviso: jq não está instalado. Saída será em JSON bruto.${NC}" + return 1 + fi + return 0 +} + +# Função para verificar saúde dos serviços +check_health() { + print_header "Verificando Saúde dos Serviços" + + echo -e "${YELLOW}1. Python OCR Service (porta 8000)...${NC}" + if curl -s "$PYTHON_OCR_URL/ocr/health" | jq -e '.status == "healthy"' > /dev/null 2>&1; then + echo -e "${GREEN} ✓ OCR Service está saudável${NC}" + else + echo -e "${RED} ✗ OCR Service não está respondendo${NC}" + echo -e "${RED} Execute: docker-compose up -d ocr-service${NC}" + exit 1 + fi + + echo -e "${YELLOW}2. Spring Backend API (porta 8080)...${NC}" + if curl -s "$SPRING_API_URL/api/v1/ocr/health" | jq -e '.status == "healthy"' > /dev/null 2>&1; then + echo -e "${GREEN} ✓ Backend API está saudável${NC}" + else + echo -e "${RED} ✗ Backend API não está respondendo${NC}" + echo -e "${RED} Execute: docker-compose up -d backend-api${NC}" + exit 1 + fi + + echo "" +} + +# Função para testar extração via Python OCR +test_python_ocr() { + local file_path="$1" + print_header "Teste via Python OCR Service" + + echo -e "${YELLOW}Endpoint: POST $PYTHON_OCR_URL/ocr/v1/extract${NC}" + echo -e "${YELLOW}Arquivo: $file_path${NC}" + echo "" + + local response + response=$(curl -s -X POST "$PYTHON_OCR_URL/ocr/v1/extract" \ + -F "images=@$file_path" \ + -H "Accept: application/json") + + if check_jq; then + echo -e "${GREEN}=== Metadados Extraídos ===${NC}" + echo "$response" | jq '.document // empty' + + echo "" + echo -e "${GREEN}=== Questões Extraídas ===${NC}" + echo "$response" | jq '.questions // empty' + + echo "" + echo -e "${GREEN}=== Resumo ===${NC}" + local num_questions + num_questions=$(echo "$response" | jq '.questions | length // 0') + local confidence + confidence=$(echo "$response" | jq '.overallConfidence // 0') + local status + status=$(echo "$response" | jq -r '.status // "unknown"') + + echo -e " Status: ${status}" + echo -e " Número de questões: ${num_questions}" + echo -e " Confiança geral: ${confidence}" + + echo "" + echo -e "${GREEN}=== Avisos ===${NC}" + echo "$response" | jq '.warnings // []' + else + echo "$response" + fi +} + +# Função para testar extração via Spring Backend +test_spring_backend() { + local file_path="$1" + print_header "Teste via Spring Backend API" + + echo -e "${YELLOW}Endpoint: POST $SPRING_API_URL/api/v1/ocr/extract/single${NC}" + echo -e "${YELLOW}Arquivo: $file_path${NC}" + echo "" + + local response + response=$(curl -s -X POST "$SPRING_API_URL/api/v1/ocr/extract/single" \ + -F "file=@$file_path" \ + -H "Accept: application/json") + + if check_jq; then + echo -e "${GREEN}=== Metadados Extraídos ===${NC}" + echo "$response" | jq '.document // empty' + + echo "" + echo -e "${GREEN}=== Questões (primeiras 3) ===${NC}" + echo "$response" | jq '.questions[:3] // empty' + + echo "" + echo -e "${GREEN}=== Resumo ===${NC}" + local num_questions + num_questions=$(echo "$response" | jq '.questions | length // 0') + echo -e " Número de questões: ${num_questions}" + else + echo "$response" + fi +} + +# Função para testar extração de exame estruturado +test_exam_extraction() { + local file_path="$1" + print_header "Teste de Extração de Exame (Formato Angolano)" + + echo -e "${YELLOW}Endpoint: POST $SPRING_API_URL/api/v1/ocr/extract/exam${NC}" + echo -e "${YELLOW}Arquivo: $file_path${NC}" + echo "" + + local response + response=$(curl -s -X POST "$SPRING_API_URL/api/v1/ocr/extract/exam" \ + -F "files=@$file_path" \ + -H "Accept: application/json") + + if check_jq; then + echo -e "${GREEN}=== Dados do Exame ===${NC}" + echo "$response" | jq '{ + examType: .examType, + subject: .subjectName, + classGrade: .classGrade, + course: .courseName, + schoolYear: "\(.schoolYearStart)/\(.schoolYearEnd)", + duration: .durationMinutes, + variant: .variant, + title: .title, + totalMaxScore: .totalMaxScore + } // empty' + + echo "" + echo -e "${GREEN}=== Questões com Cotação ===${NC}" + echo "$response" | jq '[.questions[]? | { + numero: .number, + subitems: .subitems, + tipo: .type, + cotacao: .cotacao, + temImagem: .hasImage, + texto: (.text.value | if . then (.[0:100] + (if (. | length) > 100 then "..." else "" end)) else null end) + }]' + + echo "" + echo -e "${GREEN}=== Resumo da Cotação ===${NC}" + local total_cotacao + total_cotacao=$(echo "$response" | jq '[.questions[]?.cotacao // 0] | add // 0') + local num_questions + num_questions=$(echo "$response" | jq '.questions | length // 0') + + echo -e " Total de questões: ${num_questions}" + echo -e " Soma da cotação: ${total_cotacao} valores" + + echo "" + echo -e "${GREEN}=== Imagens para Upload ===${NC}" + echo "$response" | jq '.imagesToUpload // []' + else + echo "$response" + fi +} + +# Função para mostrar ajuda +show_help() { + echo "Uso: $0 [opção] " + echo "" + echo "Opções:" + echo " -h, --help Mostra esta ajuda" + echo " -c, --check Verifica apenas a saúde dos serviços" + echo " -p, --python Testa apenas via Python OCR Service" + echo " -s, --spring Testa apenas via Spring Backend" + echo " -e, --exam Testa extração de exame estruturado" + echo " -a, --all Testa todos os endpoints (padrão)" + echo "" + echo "Exemplos:" + echo " $0 prova.jpg # Testa todos os endpoints" + echo " $0 -e prova.pdf # Testa extração de exame" + echo " $0 -p ~/Downloads/exame.png # Testa apenas Python OCR" + echo " $0 -c # Verifica saúde dos serviços" + echo "" + echo "Formatos suportados: jpg, jpeg, png, pdf, webp, bmp, tiff" +} + +# ============================================================================= +# MAIN +# ============================================================================= + +# Parse argumentos +MODE="all" +FILE_PATH="" + +while [[ $# -gt 0 ]]; do + case $1 in + -h|--help) + show_help + exit 0 + ;; + -c|--check) + MODE="check" + shift + ;; + -p|--python) + MODE="python" + shift + ;; + -s|--spring) + MODE="spring" + shift + ;; + -e|--exam) + MODE="exam" + shift + ;; + -a|--all) + MODE="all" + shift + ;; + *) + FILE_PATH="$1" + shift + ;; + esac +done + +# Verificar saúde dos serviços +check_health + +# Se modo é apenas verificar, sair +if [[ "$MODE" == "check" ]]; then + echo -e "${GREEN}Todos os serviços estão funcionando!${NC}" + exit 0 +fi + +# Verificar se arquivo foi fornecido +if [[ -z "$FILE_PATH" ]]; then + echo -e "${RED}Erro: Nenhum arquivo especificado.${NC}" + echo "" + show_help + exit 1 +fi + +# Verificar se arquivo existe +if [[ ! -f "$FILE_PATH" ]]; then + echo -e "${RED}Erro: Arquivo não encontrado: $FILE_PATH${NC}" + exit 1 +fi + +# Executar testes baseado no modo +case $MODE in + python) + test_python_ocr "$FILE_PATH" + ;; + spring) + test_spring_backend "$FILE_PATH" + ;; + exam) + test_exam_extraction "$FILE_PATH" + ;; + all) + test_python_ocr "$FILE_PATH" + test_spring_backend "$FILE_PATH" + test_exam_extraction "$FILE_PATH" + ;; +esac + +print_header "Teste Concluído" +echo -e "${GREEN}✓ Todos os testes foram executados com sucesso!${NC}" +echo "" From 5a6cfaf34cfa83439809bdefd9e2994dbd247b74 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Thu, 5 Feb 2026 13:45:25 +0100 Subject: [PATCH 31/48] =?UTF-8?q?feat(repository):=20adicionar=20m=C3=A9to?= =?UTF-8?q?dos=20de=20busca=20para=20OCR=20persistence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SchoolYearRepository: findByStartYearAndEndYear - CourseRepository: findByNameIgnoreCase - SubjectRepository: findByNameIgnoreCase - ClassRepository: findByGradeAndCourseIdAndSchoolYearId - QuestionRepository: métodos para busca por statement --- .../kixi/repository/ClassRepository.java | 63 +++++++++---- .../kixi/repository/CourseRepository.java | 11 ++- .../kixi/repository/QuestionRepository.java | 90 ++++++++++++++----- .../kixi/repository/SchoolYearRepository.java | 21 ++++- .../kixi/repository/SubjectRepository.java | 41 ++++++--- 5 files changed, 170 insertions(+), 56 deletions(-) diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/ClassRepository.java b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/ClassRepository.java index 060419e..092d40d 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/ClassRepository.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/ClassRepository.java @@ -1,15 +1,48 @@ -package ao.creativemode.kixi.repository; - - -import org.springframework.data.repository.reactive.ReactiveCrudRepository; -import ao.creativemode.kixi.model.Class; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -public interface ClassRepository extends ReactiveCrudRepository { - - Flux findAllByDeletedAtIsNull(); - Flux findAllByDeletedAtIsNotNull(); - Mono findByIdAndDeletedAtIsNull(Long id); - Mono findByIdAndDeletedAtIsNotNull(Long id); -} +package ao.creativemode.kixi.repository; + +import ao.creativemode.kixi.model.Class; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface ClassRepository extends ReactiveCrudRepository { + Flux findAllByDeletedAtIsNull(); + + Flux findAllByDeletedAtIsNotNull(); + + Mono findByIdAndDeletedAtIsNull(Long id); + + Mono findByIdAndDeletedAtIsNotNull(Long id); + + /** + * Find a class by grade, course and school year + */ + Mono findByGradeAndCourseIdAndSchoolYearIdAndDeletedAtIsNull( + Integer grade, + Long courseId, + Long schoolYearId + ); + + /** + * Find a class by grade and school year (without course) + */ + Mono findByGradeAndSchoolYearIdAndDeletedAtIsNull( + Integer grade, + Long schoolYearId + ); + + /** + * Find classes by grade + */ + Flux findByGradeAndDeletedAtIsNull(Integer grade); + + /** + * Find classes by course + */ + Flux findByCourseIdAndDeletedAtIsNull(Long courseId); + + /** + * Find classes by school year + */ + Flux findBySchoolYearIdAndDeletedAtIsNull(Long schoolYearId); +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/CourseRepository.java b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/CourseRepository.java index 5b4fc11..0d70b86 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/CourseRepository.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/CourseRepository.java @@ -6,7 +6,6 @@ import reactor.core.publisher.Mono; public interface CourseRepository extends ReactiveCrudRepository { - Mono findByIdAndDeletedAtIsNull(Long id); Flux findAllByDeletedAtIsNull(); @@ -18,4 +17,14 @@ public interface CourseRepository extends ReactiveCrudRepository { Mono findByCodeAndDeletedAtIsNull(String code); Mono findByCodeAndIdNotAndDeletedAtIsNull(String code, Long id); + + /** + * Find a course by name (case-insensitive) + */ + Mono findByNameIgnoreCaseAndDeletedAtIsNull(String name); + + /** + * Find courses by name containing (case-insensitive) + */ + Flux findByNameContainingIgnoreCaseAndDeletedAtIsNull(String name); } diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionRepository.java b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionRepository.java index d5369d3..fce559d 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionRepository.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionRepository.java @@ -1,11 +1,9 @@ package ao.creativemode.kixi.repository; import ao.creativemode.kixi.model.Question; - import org.springframework.data.r2dbc.repository.Query; import org.springframework.data.r2dbc.repository.R2dbcRepository; import org.springframework.stereotype.Repository; - import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -17,7 +15,6 @@ */ @Repository public interface QuestionRepository extends R2dbcRepository { - /** * Find all active (non-deleted) questions */ @@ -31,15 +28,24 @@ public interface QuestionRepository extends R2dbcRepository { /** * Find all questions for a statement, ordered by question number */ - @Query("SELECT * FROM questions WHERE statement_id = :statementId AND deleted_at IS NULL ORDER BY number ASC") + @Query( + "SELECT * FROM questions WHERE statement_id = :statementId AND deleted_at IS NULL ORDER BY number ASC" + ) Flux findAllByStatementIdOrderedByNumber(Long statementId); /** * Find all questions for a statement, ordered by order_index */ - @Query("SELECT * FROM questions WHERE statement_id = :statementId AND deleted_at IS NULL ORDER BY order_index ASC") + @Query( + "SELECT * FROM questions WHERE statement_id = :statementId AND deleted_at IS NULL ORDER BY order_index ASC" + ) Flux findAllByStatementIdOrderedByOrderIndex(Long statementId); + /** + * Find all questions for a statement, ordered by order_index (Spring Data naming convention) + */ + Flux findAllByStatementIdOrderByOrderIndex(Long statementId); + /** * Find an active question by ID */ @@ -53,7 +59,10 @@ public interface QuestionRepository extends R2dbcRepository { /** * Find a question by statement ID and question number */ - Mono findByStatementIdAndNumberAndDeletedAtIsNull(Long statementId, Integer number); + Mono findByStatementIdAndNumberAndDeletedAtIsNull( + Long statementId, + Integer number + ); /** * Find all questions that need review @@ -63,7 +72,9 @@ public interface QuestionRepository extends R2dbcRepository { /** * Find all questions for a statement that need review */ - Flux findAllByStatementIdAndNeedsReviewTrueAndDeletedAtIsNull(Long statementId); + Flux findAllByStatementIdAndNeedsReviewTrueAndDeletedAtIsNull( + Long statementId + ); /** * Find questions by type @@ -73,25 +84,37 @@ public interface QuestionRepository extends R2dbcRepository { /** * Find questions by type for a specific statement */ - Flux findAllByStatementIdAndQuestionTypeAndDeletedAtIsNull(Long statementId, String questionType); + Flux findAllByStatementIdAndQuestionTypeAndDeletedAtIsNull( + Long statementId, + String questionType + ); /** * Find multiple choice questions for a statement */ - @Query("SELECT * FROM questions WHERE statement_id = :statementId AND question_type = 'multiple_choice' AND deleted_at IS NULL ORDER BY number ASC") + @Query( + "SELECT * FROM questions WHERE statement_id = :statementId AND question_type = 'multiple_choice' AND deleted_at IS NULL ORDER BY number ASC" + ) Flux findMultipleChoiceByStatementId(Long statementId); /** * Find questions with OCR confidence below threshold */ - @Query("SELECT * FROM questions WHERE ocr_confidence < :threshold AND deleted_at IS NULL ORDER BY ocr_confidence ASC") + @Query( + "SELECT * FROM questions WHERE ocr_confidence < :threshold AND deleted_at IS NULL ORDER BY ocr_confidence ASC" + ) Flux findAllWithLowOcrConfidence(Double threshold); /** * Find questions with low OCR confidence for a specific statement */ - @Query("SELECT * FROM questions WHERE statement_id = :statementId AND ocr_confidence < :threshold AND deleted_at IS NULL ORDER BY number ASC") - Flux findByStatementIdWithLowOcrConfidence(Long statementId, Double threshold); + @Query( + "SELECT * FROM questions WHERE statement_id = :statementId AND ocr_confidence < :threshold AND deleted_at IS NULL ORDER BY number ASC" + ) + Flux findByStatementIdWithLowOcrConfidence( + Long statementId, + Double threshold + ); /** * Find questions on a specific page @@ -101,7 +124,10 @@ public interface QuestionRepository extends R2dbcRepository { /** * Find questions on a specific page for a statement */ - Flux findAllByStatementIdAndPageIndexAndDeletedAtIsNull(Long statementId, Integer pageIndex); + Flux findAllByStatementIdAndPageIndexAndDeletedAtIsNull( + Long statementId, + Integer pageIndex + ); /** * Count questions for a statement @@ -116,7 +142,10 @@ public interface QuestionRepository extends R2dbcRepository { /** * Count questions by type for a statement */ - Mono countByStatementIdAndQuestionTypeAndDeletedAtIsNull(Long statementId, String questionType); + Mono countByStatementIdAndQuestionTypeAndDeletedAtIsNull( + Long statementId, + String questionType + ); /** * Check if a question exists by ID and is active @@ -126,47 +155,64 @@ public interface QuestionRepository extends R2dbcRepository { /** * Check if a question with the same number exists in a statement */ - Mono existsByStatementIdAndNumberAndDeletedAtIsNull(Long statementId, Integer number); + Mono existsByStatementIdAndNumberAndDeletedAtIsNull( + Long statementId, + Integer number + ); /** * Delete all questions for a statement (soft delete) */ - @Query("UPDATE questions SET deleted_at = CURRENT_TIMESTAMP WHERE statement_id = :statementId AND deleted_at IS NULL") + @Query( + "UPDATE questions SET deleted_at = CURRENT_TIMESTAMP WHERE statement_id = :statementId AND deleted_at IS NULL" + ) Mono softDeleteAllByStatementId(Long statementId); /** * Calculate total max score for a statement */ - @Query("SELECT COALESCE(SUM(max_score), 0) FROM questions WHERE statement_id = :statementId AND deleted_at IS NULL") + @Query( + "SELECT COALESCE(SUM(max_score), 0) FROM questions WHERE statement_id = :statementId AND deleted_at IS NULL" + ) Mono calculateTotalMaxScore(Long statementId); /** * Find the next order index for a statement */ - @Query("SELECT COALESCE(MAX(order_index), 0) + 1 FROM questions WHERE statement_id = :statementId AND deleted_at IS NULL") + @Query( + "SELECT COALESCE(MAX(order_index), 0) + 1 FROM questions WHERE statement_id = :statementId AND deleted_at IS NULL" + ) Mono findNextOrderIndex(Long statementId); /** * Find the next question number for a statement */ - @Query("SELECT COALESCE(MAX(number), 0) + 1 FROM questions WHERE statement_id = :statementId AND deleted_at IS NULL") + @Query( + "SELECT COALESCE(MAX(number), 0) + 1 FROM questions WHERE statement_id = :statementId AND deleted_at IS NULL" + ) Mono findNextQuestionNumber(Long statementId); /** * Search questions by text content (case-insensitive partial match) */ - @Query("SELECT * FROM questions WHERE LOWER(text) LIKE LOWER(CONCAT('%', :searchTerm, '%')) AND deleted_at IS NULL ORDER BY statement_id, number") + @Query( + "SELECT * FROM questions WHERE LOWER(text) LIKE LOWER(CONCAT('%', :searchTerm, '%')) AND deleted_at IS NULL ORDER BY statement_id, number" + ) Flux searchByText(String searchTerm); /** * Find questions with specific score range */ - @Query("SELECT * FROM questions WHERE max_score >= :minScore AND max_score <= :maxScore AND deleted_at IS NULL ORDER BY max_score DESC") + @Query( + "SELECT * FROM questions WHERE max_score >= :minScore AND max_score <= :maxScore AND deleted_at IS NULL ORDER BY max_score DESC" + ) Flux findByScoreRange(Double minScore, Double maxScore); /** * Get average OCR confidence for a statement */ - @Query("SELECT AVG(ocr_confidence) FROM questions WHERE statement_id = :statementId AND ocr_confidence IS NOT NULL AND deleted_at IS NULL") + @Query( + "SELECT AVG(ocr_confidence) FROM questions WHERE statement_id = :statementId AND ocr_confidence IS NOT NULL AND deleted_at IS NULL" + ) Mono getAverageOcrConfidence(Long statementId); } diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SchoolYearRepository.java b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SchoolYearRepository.java index 97d9c43..b84e7cc 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SchoolYearRepository.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SchoolYearRepository.java @@ -5,11 +5,24 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -public interface SchoolYearRepository extends ReactiveCrudRepository { - +public interface SchoolYearRepository + extends ReactiveCrudRepository +{ Flux findAllByDeletedAtIsNull(); Flux findAllByDeletedAtIsNotNull(); Mono findByIdAndDeletedAtIsNull(Long id); Mono findByIdAndDeletedAtIsNotNull(Long id); - Mono findByStartYearAndEndYearAndIdNot(Integer startYear, Integer endYear, Long id); -} \ No newline at end of file + Mono findByStartYearAndEndYearAndIdNot( + Integer startYear, + Integer endYear, + Long id + ); + + /** + * Find a school year by start and end year + */ + Mono findByStartYearAndEndYearAndDeletedAtIsNull( + Integer startYear, + Integer endYear + ); +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SubjectRepository.java b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SubjectRepository.java index b839cb2..a03aa7c 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SubjectRepository.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/SubjectRepository.java @@ -1,14 +1,27 @@ -package ao.creativemode.kixi.repository; - -import ao.creativemode.kixi.model.Subject; -import org.springframework.data.repository.reactive.ReactiveCrudRepository; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -public interface SubjectRepository extends ReactiveCrudRepository { - - Flux findAllByDeletedAtIsNull(); - Flux findAllByDeletedAtIsNotNull(); - Mono findByIdAndDeletedAtIsNull(Long id); - Mono findByIdAndDeletedAtIsNotNull(Long id); -} +package ao.creativemode.kixi.repository; + +import ao.creativemode.kixi.model.Subject; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface SubjectRepository + extends ReactiveCrudRepository +{ + Flux findAllByDeletedAtIsNull(); + Flux findAllByDeletedAtIsNotNull(); + Mono findByIdAndDeletedAtIsNull(Long id); + Mono findByIdAndDeletedAtIsNotNull(Long id); + Mono findByCodeAndDeletedAtIsNull(String code); + Mono findByCodeAndDeletedAtIsNotNull(String code); + + /** + * Find a subject by name (case-insensitive) + */ + Mono findByNameIgnoreCaseAndDeletedAtIsNull(String name); + + /** + * Find subjects by name containing (case-insensitive) + */ + Flux findByNameContainingIgnoreCaseAndDeletedAtIsNull(String name); +} From 083e1f567bbfa147cdeabf18e046684299fc6fa3 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Thu, 5 Feb 2026 13:45:33 +0100 Subject: [PATCH 32/48] feat(model): adicionar campos faltantes em Class e Course MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Class: ajustes para suporte a OCR - Course: campos adicionais para persistência --- .../ao/creativemode/kixi/model/Class.java | 104 +++++++++--------- .../ao/creativemode/kixi/model/Course.java | 3 +- 2 files changed, 53 insertions(+), 54 deletions(-) diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/model/Class.java b/services/backend-api/src/main/java/ao/creativemode/kixi/model/Class.java index da58a0d..d1940f4 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/model/Class.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/model/Class.java @@ -1,52 +1,52 @@ -package ao.creativemode.kixi.model; - -import lombok.Data; -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.annotation.Id; -import org.springframework.data.annotation.LastModifiedDate; -import org.springframework.data.relational.core.mapping.Column; -import org.springframework.data.relational.core.mapping.Table; - -import java.time.LocalDateTime; - -@Data -@Table("classes") -public class Class { - @Id - private Long id; - @Column("code") - private String code; - - @Column("grade") - private String grade; - - @Column("course_id") - private Long courseId; - - @Column("school_year_id") - private Long schoolYearId; - - @CreatedDate - @Column("created_at") - private LocalDateTime createdAt; - - @LastModifiedDate - @Column("updated_at") - private LocalDateTime updatedAt; - - @Column("deleted_at") - private LocalDateTime deletedAt; - - public void markAsDeleted() { - this.deletedAt = LocalDateTime.now(); - } - - public void restore() { - this.deletedAt = null; - } - - public boolean isDeleted() { - return deletedAt != null; - } - -} +package ao.creativemode.kixi.model; + +import java.time.LocalDateTime; +import lombok.Data; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +@Data +@Table("classes") +public class Class { + + @Id + private Long id; + + @Column("code") + private String code; + + @Column("grade") + private Integer grade; + + @Column("course_id") + private Long courseId; + + @Column("school_year_id") + private Long schoolYearId; + + @CreatedDate + @Column("created_at") + private LocalDateTime createdAt; + + @LastModifiedDate + @Column("updated_at") + private LocalDateTime updatedAt; + + @Column("deleted_at") + private LocalDateTime deletedAt; + + public void markAsDeleted() { + this.deletedAt = LocalDateTime.now(); + } + + public void restore() { + this.deletedAt = null; + } + + public boolean isDeleted() { + return deletedAt != null; + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/model/Course.java b/services/backend-api/src/main/java/ao/creativemode/kixi/model/Course.java index 2ab9b0c..9e3e4e0 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/model/Course.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/model/Course.java @@ -1,5 +1,6 @@ package ao.creativemode.kixi.model; +import java.time.LocalDateTime; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -9,8 +10,6 @@ import org.springframework.data.relational.core.mapping.Column; import org.springframework.data.relational.core.mapping.Table; -import java.time.LocalDateTime; - @Data @NoArgsConstructor @AllArgsConstructor From dcf0fed7d64b01265d1b34eb12610c2302f5694c Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Thu, 5 Feb 2026 13:45:43 +0100 Subject: [PATCH 33/48] refactor(statement): ajustes em Statement service e controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StatementService: melhorias na busca e criação - SubjectService: ajustes para integração com OCR - StatementController: endpoints atualizados - StatementBasicResponse: campos adicionais --- .../kixi/controller/StatementController.java | 576 ++++++++++-------- .../dto/statement/StatementBasicResponse.java | 15 +- .../kixi/service/StatementService.java | 553 +++++++++++------ .../kixi/service/SubjectService.java | 238 ++++---- 4 files changed, 823 insertions(+), 559 deletions(-) diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/StatementController.java b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/StatementController.java index d3d382a..1302fb4 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/StatementController.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/StatementController.java @@ -1,12 +1,16 @@ package ao.creativemode.kixi.controller; -import ao.creativemode.kixi.model.Statement; +import ao.creativemode.kixi.common.exception.ApiException; import ao.creativemode.kixi.model.Question; import ao.creativemode.kixi.model.QuestionOption; +import ao.creativemode.kixi.model.Statement; import ao.creativemode.kixi.service.StatementService; import ao.creativemode.kixi.service.StatementService.StatementWithQuestions; -import ao.creativemode.kixi.common.exception.ApiException; - +import java.net.URI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; @@ -15,15 +19,9 @@ import org.springframework.http.codec.multipart.FilePart; import org.springframework.web.bind.annotation.*; import org.springframework.web.util.UriComponentsBuilder; - import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import java.net.URI; -import java.util.List; -import java.util.Map; -import java.util.Set; - /** * REST Controller for Statement (exam paper) management. * @@ -39,10 +37,19 @@ @RequestMapping("/api/v1/statements") public class StatementController { - private static final Logger log = LoggerFactory.getLogger(StatementController.class); + private static final Logger log = LoggerFactory.getLogger( + StatementController.class + ); private static final Set ALLOWED_EXTENSIONS = Set.of( - ".jpg", ".jpeg", ".png", ".pdf", ".webp", ".bmp", ".tiff", ".tif" + ".jpg", + ".jpeg", + ".png", + ".pdf", + ".webp", + ".bmp", + ".tiff", + ".tif" ); private static final int MAX_FILES = 10; @@ -67,55 +74,80 @@ public StatementController(StatementService statementService) { * @param uriBuilder URI builder for location header * @return Created statement with questions and options */ - @PostMapping(value = "/ocr/extract", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PostMapping( + value = "/ocr/extract", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE + ) public Mono> createFromOcr( - @RequestPart("files") Flux files, - UriComponentsBuilder uriBuilder) { - + @RequestPart("files") Flux files, + UriComponentsBuilder uriBuilder + ) { log.info("OCR statement creation request received"); return files - .collectList() - .flatMap(fileList -> { - // Validate file count - if (fileList.isEmpty()) { - return Mono.error(ApiException.badRequest("At least one file is required")); + .collectList() + .flatMap(fileList -> { + // Validate file count + if (fileList.isEmpty()) { + return Mono.error( + ApiException.badRequest("At least one file is required") + ); + } + if (fileList.size() > MAX_FILES) { + return Mono.error( + ApiException.badRequest( + "Maximum " + + MAX_FILES + + " files allowed per request" + ) + ); + } + + // Validate file types + for (FilePart file : fileList) { + if (!isAllowedFileType(file.filename())) { + return Mono.error( + ApiException.badRequest( + "Invalid file type: " + + file.filename() + + ". Allowed: " + + String.join(", ", ALLOWED_EXTENSIONS) + ) + ); } - if (fileList.size() > MAX_FILES) { - return Mono.error(ApiException.badRequest( - "Maximum " + MAX_FILES + " files allowed per request")); - } - - // Validate file types - for (FilePart file : fileList) { - if (!isAllowedFileType(file.filename())) { - return Mono.error(ApiException.badRequest( - "Invalid file type: " + file.filename() + - ". Allowed: " + String.join(", ", ALLOWED_EXTENSIONS))); - } - } - - log.info("Processing {} file(s) for OCR-based statement creation", fileList.size()); - - // TODO: Get actual user ID from authentication context - Long createdBy = 1L; // Placeholder - - return statementService.createFromOcr(fileList, createdBy); - }) - .map(result -> { - URI location = uriBuilder - .path("/api/v1/statements/{id}") - .buildAndExpand(result.statement().getId()) - .toUri(); - - StatementOcrResponse response = StatementOcrResponse.from(result); - - return ResponseEntity.created(location).body(response); - }) - .doOnSuccess(response -> log.info( - "Statement created from OCR: id={}", - response.getBody() != null ? response.getBody().id() : null)) - .doOnError(error -> log.error("OCR statement creation failed", error)); + } + + log.info( + "Processing {} file(s) for OCR-based statement creation", + fileList.size() + ); + + // TODO: Get actual user ID from authentication context + Long createdBy = 1L; // Placeholder + + return statementService.createFromOcr(fileList, createdBy); + }) + .map(result -> { + URI location = uriBuilder + .path("/api/v1/statements/{id}") + .buildAndExpand(result.statement().getId()) + .toUri(); + + StatementOcrResponse response = StatementOcrResponse.from( + result + ); + + return ResponseEntity.created(location).body(response); + }) + .doOnSuccess(response -> + log.info( + "Statement created from OCR: id={}", + response.getBody() != null ? response.getBody().id() : null + ) + ) + .doOnError(error -> + log.error("OCR statement creation failed", error) + ); } /** @@ -127,38 +159,57 @@ public Mono> createFromOcr( * @param uriBuilder URI builder for location header * @return Created statement with questions and options */ - @PostMapping(value = "/ocr/extract/single", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PostMapping( + value = "/ocr/extract/single", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE + ) public Mono> createFromOcrSingle( - @RequestPart("file") FilePart file, - UriComponentsBuilder uriBuilder) { - - log.info("Single-file OCR statement creation request received: {}", file.filename()); + @RequestPart("file") FilePart file, + UriComponentsBuilder uriBuilder + ) { + log.info( + "Single-file OCR statement creation request received: {}", + file.filename() + ); // Validate file type if (!isAllowedFileType(file.filename())) { - return Mono.error(ApiException.badRequest( - "Invalid file type: " + file.filename() + - ". Allowed: " + String.join(", ", ALLOWED_EXTENSIONS))); + return Mono.error( + ApiException.badRequest( + "Invalid file type: " + + file.filename() + + ". Allowed: " + + String.join(", ", ALLOWED_EXTENSIONS) + ) + ); } // TODO: Get actual user ID from authentication context Long createdBy = 1L; // Placeholder - return statementService.createFromOcr(List.of(file), createdBy) - .map(result -> { - URI location = uriBuilder - .path("/api/v1/statements/{id}") - .buildAndExpand(result.statement().getId()) - .toUri(); - - StatementOcrResponse response = StatementOcrResponse.from(result); - - return ResponseEntity.created(location).body(response); - }) - .doOnSuccess(response -> log.info( - "Statement created from single-file OCR: id={}", - response.getBody() != null ? response.getBody().id() : null)) - .doOnError(error -> log.error("Single-file OCR statement creation failed", error)); + return statementService + .createFromOcr(List.of(file), createdBy) + .map(result -> { + URI location = uriBuilder + .path("/api/v1/statements/{id}") + .buildAndExpand(result.statement().getId()) + .toUri(); + + StatementOcrResponse response = StatementOcrResponse.from( + result + ); + + return ResponseEntity.created(location).body(response); + }) + .doOnSuccess(response -> + log.info( + "Statement created from single-file OCR: id={}", + response.getBody() != null ? response.getBody().id() : null + ) + ) + .doOnError(error -> + log.error("Single-file OCR statement creation failed", error) + ); } // ========================================================================= @@ -170,10 +221,11 @@ public Mono> createFromOcrSingle( */ @GetMapping public Mono>> listAllActive() { - return statementService.findAllActive() - .map(StatementSummary::from) - .collectList() - .map(ResponseEntity::ok); + return statementService + .findAllActive() + .map(StatementSummary::from) + .collectList() + .map(ResponseEntity::ok); } /** @@ -181,10 +233,11 @@ public Mono>> listAllActive() { */ @GetMapping("/review") public Mono>> listNeedingReview() { - return statementService.findNeedingReview() - .map(StatementSummary::from) - .collectList() - .map(ResponseEntity::ok); + return statementService + .findNeedingReview() + .map(StatementSummary::from) + .collectList() + .map(ResponseEntity::ok); } /** @@ -192,10 +245,11 @@ public Mono>> listNeedingReview() { */ @GetMapping("/from-ocr") public Mono>> listFromOcr() { - return statementService.findFromOcr() - .map(StatementSummary::from) - .collectList() - .map(ResponseEntity::ok); + return statementService + .findFromOcr() + .map(StatementSummary::from) + .collectList() + .map(ResponseEntity::ok); } /** @@ -203,30 +257,37 @@ public Mono>> listFromOcr() { */ @GetMapping("/trash") public Mono>> listTrashed() { - return statementService.findAllDeleted() - .map(StatementSummary::from) - .collectList() - .map(ResponseEntity::ok); + return statementService + .findAllDeleted() + .map(StatementSummary::from) + .collectList() + .map(ResponseEntity::ok); } /** * Get a statement by ID. */ @GetMapping("/{id}") - public Mono> getById(@PathVariable Long id) { - return statementService.findById(id) - .map(StatementSummary::from) - .map(ResponseEntity::ok); + public Mono> getById( + @PathVariable Long id + ) { + return statementService + .findById(id) + .map(StatementSummary::from) + .map(ResponseEntity::ok); } /** * Get a statement with all its questions and options. */ @GetMapping("/{id}/full") - public Mono> getByIdWithQuestions(@PathVariable Long id) { - return statementService.findByIdWithQuestions(id) - .map(StatementOcrResponse::from) - .map(ResponseEntity::ok); + public Mono> getByIdWithQuestions( + @PathVariable Long id + ) { + return statementService + .findByIdWithQuestions(id) + .map(StatementOcrResponse::from) + .map(ResponseEntity::ok); } /** @@ -234,11 +295,13 @@ public Mono> getByIdWithQuestions(@PathVari */ @GetMapping("/search") public Mono>> searchByTitle( - @RequestParam String query) { - return statementService.searchByTitle(query) - .map(StatementSummary::from) - .collectList() - .map(ResponseEntity::ok); + @RequestParam String query + ) { + return statementService + .searchByTitle(query) + .map(StatementSummary::from) + .collectList() + .map(ResponseEntity::ok); } /** @@ -246,11 +309,13 @@ public Mono>> searchByTitle( */ @GetMapping("/by-school-year/{schoolYearId}") public Mono>> getBySchoolYear( - @PathVariable Long schoolYearId) { - return statementService.findBySchoolYear(schoolYearId) - .map(StatementSummary::from) - .collectList() - .map(ResponseEntity::ok); + @PathVariable Long schoolYearId + ) { + return statementService + .findBySchoolYear(schoolYearId) + .map(StatementSummary::from) + .collectList() + .map(ResponseEntity::ok); } /** @@ -258,11 +323,13 @@ public Mono>> getBySchoolYear( */ @GetMapping("/by-subject/{subjectId}") public Mono>> getBySubject( - @PathVariable Long subjectId) { - return statementService.findBySubject(subjectId) - .map(StatementSummary::from) - .collectList() - .map(ResponseEntity::ok); + @PathVariable Long subjectId + ) { + return statementService + .findBySubject(subjectId) + .map(StatementSummary::from) + .collectList() + .map(ResponseEntity::ok); } /** @@ -270,8 +337,9 @@ public Mono>> getBySubject( */ @DeleteMapping("/{id}") public Mono> softDelete(@PathVariable Long id) { - return statementService.softDelete(id) - .thenReturn(ResponseEntity.noContent().build()); + return statementService + .softDelete(id) + .thenReturn(ResponseEntity.noContent().build()); } /** @@ -279,8 +347,9 @@ public Mono> softDelete(@PathVariable Long id) { */ @PostMapping("/{id}/restore") public Mono> restore(@PathVariable Long id) { - return statementService.restore(id) - .thenReturn(ResponseEntity.ok().build()); + return statementService + .restore(id) + .thenReturn(ResponseEntity.ok().build()); } /** @@ -288,18 +357,22 @@ public Mono> restore(@PathVariable Long id) { */ @DeleteMapping("/{id}/purge") public Mono> hardDelete(@PathVariable Long id) { - return statementService.hardDelete(id) - .thenReturn(ResponseEntity.noContent().build()); + return statementService + .hardDelete(id) + .thenReturn(ResponseEntity.noContent().build()); } /** * Approve review and make statement visible. */ @PostMapping("/{id}/approve") - public Mono> approveReview(@PathVariable Long id) { - return statementService.approveReview(id) - .map(StatementSummary::from) - .map(ResponseEntity::ok); + public Mono> approveReview( + @PathVariable Long id + ) { + return statementService + .approveReview(id) + .map(StatementSummary::from) + .map(ResponseEntity::ok); } /** @@ -307,11 +380,13 @@ public Mono> approveReview(@PathVariable Long i */ @PatchMapping("/{id}/visibility") public Mono> setVisibility( - @PathVariable Long id, - @RequestParam boolean visible) { - return statementService.setVisible(id, visible) - .map(StatementSummary::from) - .map(ResponseEntity::ok); + @PathVariable Long id, + @RequestParam boolean visible + ) { + return statementService + .setVisible(id, visible) + .map(StatementSummary::from) + .map(ResponseEntity::ok); } // ========================================================================= @@ -324,16 +399,20 @@ public Mono> setVisibility( @GetMapping("/stats") public Mono>> getStatistics() { return Mono.zip( - statementService.countActive(), - statementService.countNeedingReview(), - statementService.countBySource("ocr"), - statementService.countBySource("manual") - ).map(tuple -> Map.of( - "totalActive", tuple.getT1(), - "needingReview", tuple.getT2(), - "fromOcr", tuple.getT3(), - "manual", tuple.getT4() - )).map(ResponseEntity::ok); + statementService.countActive(), + statementService.countNeedingReview(), + statementService.countBySource("ocr"), + statementService.countBySource("manual") + ) + .map(tuple -> { + Map stats = new HashMap<>(); + stats.put("totalActive", tuple.getT1()); + stats.put("needingReview", tuple.getT2()); + stats.put("fromOcr", tuple.getT3()); + stats.put("manual", tuple.getT4()); + return stats; + }) + .map(ResponseEntity::ok); } // ========================================================================= @@ -360,37 +439,37 @@ private boolean isAllowedFileType(String filename) { * Summary response for statement listing. */ public record StatementSummary( - Long id, - String title, - String examType, - Integer durationMinutes, - String variant, - Double totalMaxScore, - Boolean visible, - Boolean needsReview, - String source, - Double ocrConfidence, - Long schoolYearId, - Long termId, - Long subjectId, - Long classId + Long id, + String title, + String examType, + Integer durationMinutes, + String variant, + Double totalMaxScore, + Boolean visible, + Boolean needsReview, + String source, + Double ocrConfidence, + Long schoolYearId, + Long termId, + Long subjectId, + Long classId ) { public static StatementSummary from(Statement statement) { return new StatementSummary( - statement.getId(), - statement.getTitle(), - statement.getExamType(), - statement.getDurationMinutes(), - statement.getVariant(), - statement.getTotalMaxScore(), - statement.getVisible(), - statement.getNeedsReview(), - statement.getSource(), - statement.getOcrConfidence(), - statement.getSchoolYearId(), - statement.getTermId(), - statement.getSubjectId(), - statement.getClassId() + statement.getId(), + statement.getTitle(), + statement.getExamType(), + statement.getDurationMinutes(), + statement.getVariant(), + statement.getTotalMaxScore(), + statement.getVisible(), + statement.getNeedsReview(), + statement.getSource(), + statement.getOcrConfidence(), + statement.getSchoolYearId(), + statement.getTermId(), + statement.getSubjectId(), + statement.getClassId() ); } } @@ -399,57 +478,59 @@ public static StatementSummary from(Statement statement) { * Full response including questions and options. */ public record StatementOcrResponse( - Long id, - String title, - String examType, - Integer durationMinutes, - String variant, - String instructions, - Double totalMaxScore, - Boolean visible, - Boolean needsReview, - String source, - Double ocrConfidence, - String ocrRequestId, - Long schoolYearId, - Long termId, - Long subjectId, - Long classId, - List questions + Long id, + String title, + String examType, + Integer durationMinutes, + String variant, + String instructions, + Double totalMaxScore, + Boolean visible, + Boolean needsReview, + String source, + Double ocrConfidence, + String ocrRequestId, + Long schoolYearId, + Long termId, + Long subjectId, + Long classId, + List questions ) { public static StatementOcrResponse from(StatementWithQuestions result) { Statement s = result.statement(); List questions = result.questions(); List allOptions = result.options(); - List questionResponses = questions.stream() - .map(q -> { - List options = allOptions.stream() - .filter(opt -> opt.getQuestionId().equals(q.getId())) - .map(OptionResponse::from) - .toList(); - return QuestionResponse.from(q, options); - }) - .toList(); + List questionResponses = questions + .stream() + .map(q -> { + List options = allOptions + .stream() + .filter(opt -> opt.getQuestionId().equals(q.getId())) + .map(OptionResponse::from) + .toList(); + return QuestionResponse.from(q, options); + }) + .toList(); return new StatementOcrResponse( - s.getId(), - s.getTitle(), - s.getExamType(), - s.getDurationMinutes(), - s.getVariant(), - s.getInstructions(), - s.getTotalMaxScore(), - s.getVisible(), - s.getNeedsReview(), - s.getSource(), - s.getOcrConfidence(), - s.getOcrRequestId(), - s.getSchoolYearId(), - s.getTermId(), - s.getSubjectId(), - s.getClassId(), - questionResponses + s.getId(), + s.getTitle(), + s.getExamType(), + s.getDurationMinutes(), + s.getVariant(), + s.getInstructions(), + s.getTotalMaxScore(), + s.getVisible(), + s.getNeedsReview(), + s.getSource(), + s.getOcrConfidence(), + s.getOcrRequestId(), + s.getSchoolYearId(), + s.getTermId(), + s.getSubjectId(), + s.getClassId(), + questionResponses ); } } @@ -458,29 +539,32 @@ public static StatementOcrResponse from(StatementWithQuestions result) { * Question response DTO. */ public record QuestionResponse( - Long id, - Integer number, - String text, - String questionType, - Double maxScore, - Integer orderIndex, - Double ocrConfidence, - Integer pageIndex, - Boolean needsReview, - List options + Long id, + Integer number, + String text, + String questionType, + Double maxScore, + Integer orderIndex, + Double ocrConfidence, + Integer pageIndex, + Boolean needsReview, + List options ) { - public static QuestionResponse from(Question q, List options) { + public static QuestionResponse from( + Question q, + List options + ) { return new QuestionResponse( - q.getId(), - q.getNumber(), - q.getText(), - q.getQuestionType(), - q.getMaxScore(), - q.getOrderIndex(), - q.getOcrConfidence(), - q.getPageIndex(), - q.getNeedsReview(), - options + q.getId(), + q.getNumber(), + q.getText(), + q.getQuestionType(), + q.getMaxScore(), + q.getOrderIndex(), + q.getOcrConfidence(), + q.getPageIndex(), + q.getNeedsReview(), + options ); } } @@ -489,21 +573,21 @@ public static QuestionResponse from(Question q, List options) { * Option response DTO. */ public record OptionResponse( - Long id, - String optionLabel, - String optionText, - Boolean isCorrect, - Integer orderIndex, - Double ocrConfidence + Long id, + String optionLabel, + String optionText, + Boolean isCorrect, + Integer orderIndex, + Double ocrConfidence ) { public static OptionResponse from(QuestionOption o) { return new OptionResponse( - o.getId(), - o.getOptionLabel(), - o.getOptionText(), - o.getIsCorrect(), - o.getOrderIndex(), - o.getOcrConfidence() + o.getId(), + o.getOptionLabel(), + o.getOptionText(), + o.getIsCorrect(), + o.getOrderIndex(), + o.getOcrConfidence() ); } } diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementBasicResponse.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementBasicResponse.java index b9287ee..4e80607 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementBasicResponse.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/statement/StatementBasicResponse.java @@ -1,11 +1,10 @@ package ao.creativemode.kixi.dto.statement; public record StatementBasicResponse( - Long id, - String examType, - String variant, - String title, - Integer durationMinutes, - Integer totalMaxScore -) { -} + Long id, + String examType, + String variant, + String title, + Integer durationMinutes, + Double totalMaxScore +) {} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/StatementService.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/StatementService.java index 69b82c6..2b53487 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/service/StatementService.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/StatementService.java @@ -1,30 +1,27 @@ package ao.creativemode.kixi.service; import ao.creativemode.kixi.client.OcrServiceClient; +import ao.creativemode.kixi.common.exception.ApiException; import ao.creativemode.kixi.dto.ocr.OcrResponse; -import ao.creativemode.kixi.dto.ocr.OcrResponse.ExtractedQuestion; import ao.creativemode.kixi.dto.ocr.OcrResponse.ExtractedOption; +import ao.creativemode.kixi.dto.ocr.OcrResponse.ExtractedQuestion; import ao.creativemode.kixi.dto.ocr.OcrResponse.OcrMetadata; -import ao.creativemode.kixi.model.Statement; import ao.creativemode.kixi.model.Question; import ao.creativemode.kixi.model.QuestionOption; -import ao.creativemode.kixi.repository.StatementRepository; -import ao.creativemode.kixi.repository.QuestionRepository; +import ao.creativemode.kixi.model.Statement; import ao.creativemode.kixi.repository.QuestionOptionRepository; -import ao.creativemode.kixi.common.exception.ApiException; - +import ao.creativemode.kixi.repository.QuestionRepository; +import ao.creativemode.kixi.repository.StatementRepository; +import java.time.LocalDateTime; +import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.codec.multipart.FilePart; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; - import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import java.time.LocalDateTime; -import java.util.List; - /** * Service for managing Statement entities and OCR integration. * @@ -33,11 +30,15 @@ * - OCR-based statement creation from images * - Mapping OCR results to domain entities * - Managing questions and options + * + * For full OCR processing with entity lookup/creation, use OcrPersistenceService. */ @Service public class StatementService { - private static final Logger log = LoggerFactory.getLogger(StatementService.class); + private static final Logger log = LoggerFactory.getLogger( + StatementService.class + ); private static final double LOW_CONFIDENCE_THRESHOLD = 0.8; private static final double MIN_CONFIDENCE_THRESHOLD = 0.5; @@ -48,10 +49,11 @@ public class StatementService { private final OcrServiceClient ocrServiceClient; public StatementService( - StatementRepository statementRepository, - QuestionRepository questionRepository, - QuestionOptionRepository optionRepository, - OcrServiceClient ocrServiceClient) { + StatementRepository statementRepository, + QuestionRepository questionRepository, + QuestionOptionRepository optionRepository, + OcrServiceClient ocrServiceClient + ) { this.statementRepository = statementRepository; this.questionRepository = questionRepository; this.optionRepository = optionRepository; @@ -59,40 +61,67 @@ public StatementService( } // ========================================================================= - // OCR Integration + // OCR Integration (Legacy - for simple cases without entity lookup) // ========================================================================= /** * Create a statement from uploaded images using OCR. * + * Note: For full entity lookup/creation (SchoolYear, Course, Subject, Class), + * use OcrPersistenceService.processAndPersist() instead. + * * @param files List of uploaded image files * @param createdBy ID of the user creating the statement * @return Mono containing the created statement with questions */ @Transactional - public Mono createFromOcr(List files, Long createdBy) { - log.info("Creating statement from OCR: {} file(s), createdBy={}", files.size(), createdBy); - - return ocrServiceClient.extractText(files) - .flatMap(ocrResponse -> { - if (ocrResponse.isError()) { - log.error("OCR extraction failed: {}", ocrResponse.errorMessage()); - return Mono.error(ApiException.badRequest( - "OCR extraction failed: " + ocrResponse.errorMessage())); - } - - log.info("OCR extraction successful: requestId={}, confidence={}, questions={}", - ocrResponse.requestId(), - ocrResponse.overallConfidence(), - ocrResponse.questions() != null ? ocrResponse.questions().size() : 0); + public Mono createFromOcr( + List files, + Long createdBy + ) { + log.info( + "Creating statement from OCR: {} file(s), createdBy={}", + files.size(), + createdBy + ); + + return ocrServiceClient + .extractText(files) + .flatMap(ocrResponse -> { + if (ocrResponse.isError()) { + log.error( + "OCR extraction failed: {}", + ocrResponse.errorMessage() + ); + return Mono.error( + ApiException.badRequest( + "OCR extraction failed: " + + ocrResponse.errorMessage() + ) + ); + } + + log.info( + "OCR extraction successful: requestId={}, confidence={}, questions={}", + ocrResponse.requestId(), + ocrResponse.overallConfidence(), + ocrResponse.questions() != null + ? ocrResponse.questions().size() + : 0 + ); - return createStatementFromOcrResponse(ocrResponse, createdBy); - }) - .doOnSuccess(result -> log.info( - "Statement created from OCR: statementId={}, questions={}", - result.statement().getId(), - result.questions().size())) - .doOnError(error -> log.error("Failed to create statement from OCR", error)); + return createStatementFromOcrResponse(ocrResponse, createdBy); + }) + .doOnSuccess(result -> + log.info( + "Statement created from OCR: statementId={}, questions={}", + result.statement().getId(), + result.questions().size() + ) + ) + .doOnError(error -> + log.error("Failed to create statement from OCR", error) + ); } /** @@ -104,57 +133,87 @@ public Mono createFromOcr(List files, Long cre */ @Transactional public Mono createStatementFromOcrResponse( - OcrResponse ocrResponse, - Long createdBy) { - + OcrResponse ocrResponse, + Long createdBy + ) { // Create and populate statement from metadata - Statement statement = mapMetadataToStatement(ocrResponse.metadata(), ocrResponse); + Statement statement = mapMetadataToStatement( + ocrResponse.metadata(), + ocrResponse + ); statement.setCreatedBy(createdBy); statement.setOcrMetadata( - ocrResponse.requestId(), - ocrResponse.overallConfidence(), - ocrResponse.needsReview()); + ocrResponse.requestId(), + ocrResponse.overallConfidence(), + ocrResponse.needsReview() + ); statement.setSource("ocr"); // Calculate total max score from questions if (ocrResponse.questions() != null) { - double totalScore = ocrResponse.questions().stream() - .filter(q -> q.maxScore() != null && q.maxScore().value() != null) - .mapToDouble(q -> q.maxScore().value()) - .sum(); - statement.setTotalMaxScore(totalScore); + double totalScore = ocrResponse + .questions() + .stream() + .filter(q -> q.getCotacaoValue() != null) + .mapToDouble(ExtractedQuestion::getCotacaoValue) + .sum(); + if (totalScore > 0) { + statement.setTotalMaxScore(totalScore); + } } // Save statement first - return statementRepository.save(statement) - .flatMap(savedStatement -> { - if (ocrResponse.questions() == null || ocrResponse.questions().isEmpty()) { - return Mono.just(new StatementWithQuestions( - savedStatement, List.of(), List.of())); - } - - // Create and save questions - return createQuestionsFromOcr(savedStatement.getId(), ocrResponse.questions()) + return statementRepository + .save(statement) + .flatMap(savedStatement -> { + if ( + ocrResponse.questions() == null || + ocrResponse.questions().isEmpty() + ) { + return Mono.just( + new StatementWithQuestions( + savedStatement, + List.of(), + List.of() + ) + ); + } + + // Create and save questions + return createQuestionsFromOcr( + savedStatement.getId(), + ocrResponse.questions() + ) + .collectList() + .flatMap(savedQuestions -> { + // Collect all question IDs + List questionIds = savedQuestions + .stream() + .map(Question::getId) + .toList(); + + // Load all options for these questions + return optionRepository + .findAllByQuestionIds(questionIds) .collectList() - .flatMap(savedQuestions -> { - // Collect all question IDs - List questionIds = savedQuestions.stream() - .map(Question::getId) - .toList(); - - // Load all options for these questions - return optionRepository.findAllByQuestionIds(questionIds) - .collectList() - .map(options -> new StatementWithQuestions( - savedStatement, savedQuestions, options)); - }); - }); + .map(options -> + new StatementWithQuestions( + savedStatement, + savedQuestions, + options + ) + ); + }); + }); } /** * Map OCR metadata to Statement entity. */ - private Statement mapMetadataToStatement(OcrMetadata metadata, OcrResponse ocrResponse) { + private Statement mapMetadataToStatement( + OcrMetadata metadata, + OcrResponse ocrResponse + ) { Statement statement = new Statement(); if (metadata != null) { @@ -162,40 +221,62 @@ private Statement mapMetadataToStatement(OcrMetadata metadata, OcrResponse ocrRe if (metadata.title() != null && metadata.title().value() != null) { statement.setTitle(metadata.title().value()); } else { - statement.setTitle("Imported Statement - " + LocalDateTime.now()); + statement.setTitle( + "Imported Statement - " + LocalDateTime.now() + ); } // Exam type - if (metadata.examType() != null && metadata.examType().value() != null) { + if ( + metadata.examType() != null && + metadata.examType().value() != null + ) { statement.setExamType(metadata.examType().value()); + } else { + statement.setExamType("Prova de Exame"); } // Duration - if (metadata.durationMinutes() != null && metadata.durationMinutes().value() != null) { - statement.setDurationMinutes(metadata.durationMinutes().value()); + if ( + metadata.durationMinutes() != null && + metadata.durationMinutes().value() != null + ) { + statement.setDurationMinutes( + metadata.durationMinutes().value() + ); } // Variant - if (metadata.variant() != null && metadata.variant().value() != null) { + if ( + metadata.variant() != null && metadata.variant().value() != null + ) { statement.setVariant(metadata.variant().value()); } // Instructions - if (metadata.instructions() != null && metadata.instructions().value() != null) { + if ( + metadata.instructions() != null && + metadata.instructions().value() != null + ) { statement.setInstructions(metadata.instructions().value()); } - // Note: schoolYearId, termId, subjectId, classId, courseId need to be - // resolved from the text values (e.g., "2024/2025" -> ID lookup) - // This would require additional repositories and lookup logic - // For now, these are left null and can be set manually or via a separate endpoint + // Total max score from metadata + if (metadata.getTotalMaxScoreValue() != null) { + statement.setTotalMaxScore(metadata.getTotalMaxScoreValue()); + } + + // Note: schoolYearId, termId, subjectId, classId, courseId + // are resolved in OcrPersistenceService which does the full lookup } // Set OCR-specific fields statement.setVisible(false); // Require manual review before publishing - statement.setNeedsReview(ocrResponse.needsReview() || + statement.setNeedsReview( + ocrResponse.needsReview() || (ocrResponse.overallConfidence() != null && - ocrResponse.overallConfidence() < LOW_CONFIDENCE_THRESHOLD)); + ocrResponse.overallConfidence() < LOW_CONFIDENCE_THRESHOLD) + ); return statement; } @@ -203,42 +284,76 @@ private Statement mapMetadataToStatement(OcrMetadata metadata, OcrResponse ocrRe /** * Create questions from OCR extracted questions. */ - private Flux createQuestionsFromOcr(Long statementId, List extractedQuestions) { + private Flux createQuestionsFromOcr( + Long statementId, + List extractedQuestions + ) { return Flux.fromIterable(extractedQuestions) - .flatMap(extracted -> { - Question question = mapExtractedToQuestion(statementId, extracted); - return questionRepository.save(question) - .flatMap(savedQuestion -> { - // Create options if this is a multiple choice question - if (extracted.options() != null && !extracted.options().isEmpty()) { - return createOptionsFromOcr(savedQuestion.getId(), extracted.options()) - .then(Mono.just(savedQuestion)); - } - return Mono.just(savedQuestion); - }); - }); + .index() + .flatMap(tuple -> { + int index = tuple.getT1().intValue(); + ExtractedQuestion extracted = tuple.getT2(); + + Question question = mapExtractedToQuestion( + statementId, + extracted, + index + ); + + return questionRepository + .save(question) + .flatMap(savedQuestion -> { + // Create options if this is a multiple choice question + if ( + extracted.options() != null && + !extracted.options().isEmpty() + ) { + return createOptionsFromOcr( + savedQuestion.getId(), + extracted.options() + ).then(Mono.just(savedQuestion)); + } + return Mono.just(savedQuestion); + }); + }); } /** * Map extracted question to Question entity. */ - private Question mapExtractedToQuestion(Long statementId, ExtractedQuestion extracted) { + private Question mapExtractedToQuestion( + Long statementId, + ExtractedQuestion extracted, + int orderIndex + ) { Question question = new Question(); question.setStatementId(statementId); - question.setNumber(extracted.number()); - question.setOrderIndex(extracted.number()); - // Text - if (extracted.text() != null && extracted.text().value() != null) { - question.setText(extracted.text().value()); + // Parse number (might be string like "1", "2a", etc.) + try { + String numStr = + extracted.number() != null + ? extracted.number().replaceAll("[^0-9]", "") + : ""; + question.setNumber( + numStr.isEmpty() ? orderIndex + 1 : Integer.parseInt(numStr) + ); + } catch (NumberFormatException e) { + question.setNumber(orderIndex + 1); } - // Question type - question.setQuestionType(extracted.getQuestionTypeValue()); + question.setOrderIndex(orderIndex); + + // Text + question.setText(extracted.getTextValue()); + + // Question type - map from Portuguese to database format + String type = extracted.getTypeValue(); + question.setQuestionType(mapQuestionType(type)); - // Max score - if (extracted.maxScore() != null && extracted.maxScore().value() != null) { - question.setMaxScore(extracted.maxScore().value()); + // Cotação (max score) + if (extracted.getCotacaoValue() != null) { + question.setMaxScore(extracted.getCotacaoValue()); } // OCR metadata @@ -246,47 +361,66 @@ private Question mapExtractedToQuestion(Long statementId, ExtractedQuestion extr question.setPageIndex(extracted.pageIndex()); // Mark for review if low confidence - question.setNeedsReview(extracted.confidence() != null && - extracted.confidence() < LOW_CONFIDENCE_THRESHOLD); + question.setNeedsReview( + extracted.confidence() != null && + extracted.confidence() < LOW_CONFIDENCE_THRESHOLD + ); return question; } + /** + * Map question type from Portuguese to database format. + */ + private String mapQuestionType(String type) { + if (type == null) { + return "unknown"; + } + return switch (type.toLowerCase()) { + case "dissertativa" -> "development"; + case "multipla_escolha" -> "multiple_choice"; + default -> type; + }; + } + /** * Create options from OCR extracted options. */ - private Flux createOptionsFromOcr(Long questionId, List extractedOptions) { + private Flux createOptionsFromOcr( + Long questionId, + List extractedOptions + ) { return Flux.fromIterable(extractedOptions) - .index() - .flatMap(indexed -> { - ExtractedOption extracted = indexed.getT2(); - int index = indexed.getT1().intValue(); - - QuestionOption option = new QuestionOption(); - option.setQuestionId(questionId); - option.setOptionLabel(extracted.optionLabel()); - option.setOptionText(extracted.optionText()); - option.setIsCorrect(false); // OCR doesn't know the correct answer - option.setOrderIndex(index); - option.setOcrConfidence(extracted.confidence()); - - return optionRepository.save(option); - }); + .index() + .flatMap(tuple -> { + int index = tuple.getT1().intValue(); + ExtractedOption extracted = tuple.getT2(); + + QuestionOption option = new QuestionOption(); + option.setQuestionId(questionId); + option.setOptionLabel(extracted.optionLabel()); + option.setOptionText(extracted.optionText()); + option.setOrderIndex(index); + option.setOcrConfidence(extracted.confidence()); + option.setIsCorrect(false); // OCR cannot determine correct answer + + return optionRepository.save(option); + }); } // ========================================================================= - // Standard CRUD Operations + // CRUD Operations // ========================================================================= /** - * Find all active statements. + * Find all active (non-deleted) statements. */ public Flux findAllActive() { return statementRepository.findAllByDeletedAtIsNull(); } /** - * Find all deleted statements. + * Find all soft-deleted statements. */ public Flux findAllDeleted() { return statementRepository.findAllByDeletedAtIsNotNull(); @@ -296,38 +430,53 @@ public Flux findAllDeleted() { * Find a statement by ID. */ public Mono findById(Long id) { - return statementRepository.findByIdAndDeletedAtIsNull(id) - .switchIfEmpty(Mono.error(ApiException.notFound("Statement not found"))); + return statementRepository + .findByIdAndDeletedAtIsNull(id) + .switchIfEmpty( + Mono.error(ApiException.notFound("Statement not found: " + id)) + ); } /** - * Find a statement with all its questions. + * Find a statement with its questions. */ public Mono findByIdWithQuestions(Long id) { - return findById(id) - .flatMap(statement -> - questionRepository.findAllByStatementIdOrderedByNumber(id) - .collectList() - .flatMap(questions -> { - List questionIds = questions.stream() - .map(Question::getId) - .toList(); - - if (questionIds.isEmpty()) { - return Mono.just(new StatementWithQuestions( - statement, questions, List.of())); - } - - return optionRepository.findAllByQuestionIds(questionIds) - .collectList() - .map(options -> new StatementWithQuestions( - statement, questions, options)); - }) - ); + return findById(id).flatMap(statement -> + questionRepository + .findAllByStatementIdOrderByOrderIndex(statement.getId()) + .collectList() + .flatMap(questions -> { + if (questions.isEmpty()) { + return Mono.just( + new StatementWithQuestions( + statement, + List.of(), + List.of() + ) + ); + } + + List questionIds = questions + .stream() + .map(Question::getId) + .toList(); + + return optionRepository + .findAllByQuestionIds(questionIds) + .collectList() + .map(options -> + new StatementWithQuestions( + statement, + questions, + options + ) + ); + }) + ); } /** - * Find statements that need review. + * Find statements needing review. */ public Flux findNeedingReview() { return statementRepository.findAllByNeedsReviewTrueAndDeletedAtIsNull(); @@ -344,14 +493,18 @@ public Flux findFromOcr() { * Find statements by school year. */ public Flux findBySchoolYear(Long schoolYearId) { - return statementRepository.findAllBySchoolYearIdAndDeletedAtIsNull(schoolYearId); + return statementRepository.findAllBySchoolYearIdAndDeletedAtIsNull( + schoolYearId + ); } /** * Find statements by subject. */ public Flux findBySubject(Long subjectId) { - return statementRepository.findAllBySubjectIdAndDeletedAtIsNull(subjectId); + return statementRepository.findAllBySubjectIdAndDeletedAtIsNull( + subjectId + ); } /** @@ -374,11 +527,11 @@ public Mono save(Statement statement) { @Transactional public Mono softDelete(Long id) { return findById(id) - .flatMap(statement -> { - statement.markAsDeleted(); - return statementRepository.save(statement); - }) - .then(); + .flatMap(statement -> { + statement.markAsDeleted(); + return statementRepository.save(statement); + }) + .then(); } /** @@ -386,55 +539,57 @@ public Mono softDelete(Long id) { */ @Transactional public Mono restore(Long id) { - return statementRepository.findByIdAndDeletedAtIsNotNull(id) - .switchIfEmpty(Mono.error(ApiException.badRequest("Statement is not deleted"))) - .flatMap(statement -> { - statement.restore(); - return statementRepository.save(statement); - }) - .then(); + return statementRepository + .findByIdAndDeletedAtIsNotNull(id) + .switchIfEmpty( + Mono.error( + ApiException.notFound("Deleted statement not found: " + id) + ) + ) + .flatMap(statement -> { + statement.restore(); + return statementRepository.save(statement); + }) + .then(); } /** - * Hard delete a statement (only if already soft-deleted). + * Hard delete a statement and its questions/options. */ @Transactional public Mono hardDelete(Long id) { - return statementRepository.findByIdAndDeletedAtIsNotNull(id) - .switchIfEmpty(Mono.error(ApiException.badRequest( - "Only deleted statements can be permanently removed"))) - .flatMap(statement -> - // First delete all questions and options - questionRepository.findAllByStatementIdAndDeletedAtIsNull(id) - .flatMap(question -> - optionRepository.softDeleteAllByQuestionId(question.getId()) - .then(questionRepository.delete(question))) - .then(statementRepository.delete(statement))) - .then(); + return findById(id).flatMap(statement -> + questionRepository + .findAllByStatementIdOrderByOrderIndex(statement.getId()) + .flatMap(question -> + optionRepository + .softDeleteAllByQuestionId(question.getId()) + .then(questionRepository.delete(question)) + ) + .then(statementRepository.delete(statement)) + ); } /** - * Approve review for a statement (mark as reviewed). + * Approve a statement review. */ @Transactional public Mono approveReview(Long id) { - return findById(id) - .flatMap(statement -> { - statement.approveReview(); - statement.setVisible(true); - return statementRepository.save(statement); - }); + return findById(id).flatMap(statement -> { + statement.approveReview(); + return statementRepository.save(statement); + }); } /** - * Mark a statement as visible. + * Set statement visibility. */ + @Transactional public Mono setVisible(Long id, boolean visible) { - return findById(id) - .flatMap(statement -> { - statement.setVisible(visible); - return statementRepository.save(statement); - }); + return findById(id).flatMap(statement -> { + statement.setVisible(visible); + return statementRepository.save(statement); + }); } // ========================================================================= @@ -442,7 +597,7 @@ public Mono setVisible(Long id, boolean visible) { // ========================================================================= /** - * Count all active statements. + * Count active statements. */ public Mono countActive() { return statementRepository.countByDeletedAtIsNull(); @@ -463,15 +618,15 @@ public Mono countBySource(String source) { } // ========================================================================= - // DTOs + // Result Records // ========================================================================= /** - * Record representing a statement with its questions and options. + * Statement with its questions and options. */ public record StatementWithQuestions( - Statement statement, - List questions, - List options + Statement statement, + List questions, + List options ) {} } diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/SubjectService.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/SubjectService.java index 6992959..39f964a 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/service/SubjectService.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/SubjectService.java @@ -1,106 +1,132 @@ -package ao.creativemode.kixi.service; - -import ao.creativemode.kixi.dto.subject.SubjectRequest; -import ao.creativemode.kixi.dto.subject.SubjectResponse; -import ao.creativemode.kixi.model.Subject; -import ao.creativemode.kixi.common.exception.ApiException; -import ao.creativemode.kixi.repository.SubjectRepository; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.stereotype.Service; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - - - -@Service -public class SubjectService { - - private final SubjectRepository repository; - - public SubjectService(SubjectRepository repository){ - this.repository = repository; - } - - public Flux findAllActive(){ - return repository.findAllByDeletedAtIsNull().map(this::toResponse); - } - - public Flux findAllDeleted(){ - return repository.findAllByDeletedAtIsNotNull().map(this::toResponse); - } - - public Mono findByCodeActive(Long id){ - return repository.findByIdAndDeletedAtIsNull(id) - .switchIfEmpty(Mono.error(ApiException.notFound("Subject not found"))) - .map(this::toResponse); - } - - public Mono create(SubjectRequest data){ - Subject newSubject = new Subject(); - newSubject.setCode(data.code()); - newSubject.setName(data.name()); - newSubject.setShortName(data.shortName()); - newSubject.setDeletedAt(null); - - return repository.save(newSubject) - .map(this::toResponse) - .onErrorMap(DataIntegrityViolationException.class, - e-> ApiException.conflict("Subject with code " + data.code() + " already exists.")); - } - - public Mono update(Long id, SubjectRequest data){ - return repository.findByIdAndDeletedAtIsNull(id) - .switchIfEmpty(Mono.error(ApiException.notFound("Subject not found"))) - .flatMap(subject -> { - String newCode = data.code(); - String newName = data.name(); - String newShortName = data.shortName(); - - - subject.setCode(newCode); - subject.setName(newName); - subject.setShortName(newShortName); - return repository.save(subject) - .onErrorMap(DataIntegrityViolationException.class, - e -> ApiException.conflict("Another subject with this name already exists, please choose a different name.")); - }).map(this::toResponse); - } - - public Mono softDelete(Long id){ - return repository.findByIdAndDeletedAtIsNull(id) - .switchIfEmpty(Mono.error(ApiException.notFound("Subject not found"))) - .flatMap(subject -> { - subject.markAsDeleted(); - return repository.save(subject); - }).then(); - } - - public Mono restore(Long id){ - return repository.findByIdAndDeletedAtIsNotNull(id) - .switchIfEmpty(Mono.error(ApiException.notFound("Subject not found"))) - .flatMap(subject -> { - subject.restore(); - return repository.save(subject); - }).then(); - } - - public Mono hardDelete(Long id){ - return repository.findByIdAndDeletedAtIsNotNull(id) - .switchIfEmpty(Mono.error(ApiException.notFound("Only deleted subject can be permanently removed"))) - .flatMap(repository::delete) - .then(); - } - - - - private SubjectResponse toResponse(Subject entity) { - return new SubjectResponse( - entity.getId(), - entity.getCode(), - entity.getName(), - entity.getShortName(), - entity.getCreatedAt(), - entity.getUpdatedAt(), - entity.getDeletedAt()); - } -} +package ao.creativemode.kixi.service; + +import ao.creativemode.kixi.common.exception.ApiException; +import ao.creativemode.kixi.dto.subject.SubjectRequest; +import ao.creativemode.kixi.dto.subject.SubjectResponse; +import ao.creativemode.kixi.model.Subject; +import ao.creativemode.kixi.repository.SubjectRepository; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Service +public class SubjectService { + + private final SubjectRepository repository; + + public SubjectService(SubjectRepository repository) { + this.repository = repository; + } + + public Flux findAllActive() { + return repository.findAllByDeletedAtIsNull().map(this::toResponse); + } + + public Flux findAllDeleted() { + return repository.findAllByDeletedAtIsNotNull().map(this::toResponse); + } + + public Mono findByCodeActive(String code) { + return repository + .findByCodeAndDeletedAtIsNull(code) + .switchIfEmpty( + Mono.error(ApiException.notFound("Subject not found")) + ) + .map(this::toResponse); + } + + public Mono create(SubjectRequest data) { + Subject newSubject = new Subject(); + newSubject.setCode(data.code()); + newSubject.setName(data.name()); + newSubject.setShortName(data.shortName()); + newSubject.setDeletedAt(null); + + return repository + .save(newSubject) + .map(this::toResponse) + .onErrorMap(DataIntegrityViolationException.class, e -> + ApiException.conflict( + "Subject with code " + data.code() + " already exists." + ) + ); + } + + public Mono update(String code, SubjectRequest data) { + return repository + .findByCodeAndDeletedAtIsNull(code) + .switchIfEmpty( + Mono.error(ApiException.notFound("Subject not found")) + ) + .flatMap(subject -> { + String newCode = data.code(); + String newName = data.name(); + String newShortName = data.shortName(); + + subject.setCode(newCode); + subject.setName(newName); + subject.setShortName(newShortName); + return repository + .save(subject) + .onErrorMap(DataIntegrityViolationException.class, e -> + ApiException.conflict( + "Another subject with this code already exists, please choose a different code." + ) + ); + }) + .map(this::toResponse); + } + + public Mono softDelete(String code) { + return repository + .findByCodeAndDeletedAtIsNull(code) + .switchIfEmpty( + Mono.error(ApiException.notFound("Subject not found")) + ) + .flatMap(subject -> { + subject.markAsDeleted(); + return repository.save(subject); + }) + .then(); + } + + public Mono restore(String code) { + return repository + .findByCodeAndDeletedAtIsNotNull(code) + .switchIfEmpty( + Mono.error(ApiException.notFound("Subject not found")) + ) + .flatMap(subject -> { + subject.restore(); + return repository.save(subject); + }) + .then(); + } + + public Mono hardDelete(String code) { + return repository + .findByCodeAndDeletedAtIsNotNull(code) + .switchIfEmpty( + Mono.error( + ApiException.notFound( + "Only deleted subject can be permanently removed" + ) + ) + ) + .flatMap(repository::delete) + .then(); + } + + private SubjectResponse toResponse(Subject entity) { + return new SubjectResponse( + entity.getId(), + entity.getCode(), + entity.getName(), + entity.getShortName(), + entity.getCreatedAt(), + entity.getUpdatedAt(), + entity.getDeletedAt() + ); + } +} From bcafcca112b6c73f75367d7b1f86044687bd40ca Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Thu, 5 Feb 2026 13:45:51 +0100 Subject: [PATCH 34/48] =?UTF-8?q?chore:=20atualizar=20configura=C3=A7?= =?UTF-8?q?=C3=B5es=20de=20build=20e=20docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker-compose.yml: configurações de serviços - pom.xml: dependências atualizadas - crud-flux.md: guia de implementação --- .../implementation-guides/crud-flux.md | 4 ---- docker-compose.yml | 2 +- services/backend-api/pom.xml | 24 ++++++++++--------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/conceptual/architecture/implementation-guides/crud-flux.md b/conceptual/architecture/implementation-guides/crud-flux.md index f7598f6..b9f200a 100644 --- a/conceptual/architecture/implementation-guides/crud-flux.md +++ b/conceptual/architecture/implementation-guides/crud-flux.md @@ -1,7 +1,3 @@ -Here’s a full English version of your CRUD implementation guide, adapted for the entities you listed: - ---- - # Reactive CRUD Implementation Guide for the Project This guide details the standard pattern for implementing CRUD operations in the backend located at `services/backend-api`, using the `schoolYears` CRUD as a reference. To implement any new CRUD, replace `[EntityName]` with the name of your entity and refer to the `schoolYears` CRUD files for practical examples. diff --git a/docker-compose.yml b/docker-compose.yml index 6728cf7..f0f1063 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,7 +37,7 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kixi_secret} PGDATA: /var/lib/postgresql/data/pgdata ports: - - "${POSTGRES_PORT:-5432}:5432" + - "${POSTGRES_PORT:-5433}:5432" volumes: - postgres_data:/var/lib/postgresql/data - ./services/backend-api/docker/postgres/init.sql:/docker-entrypoint-initdb.d/01-init.sql:z diff --git a/services/backend-api/pom.xml b/services/backend-api/pom.xml index 0b99129..e234015 100644 --- a/services/backend-api/pom.xml +++ b/services/backend-api/pom.xml @@ -1,8 +1,10 @@ - - + + 4.0.0 @@ -10,7 +12,7 @@ org.springframework.boot spring-boot-starter-parent 3.2.1 - + com.example @@ -105,19 +107,19 @@ spring-boot-starter-test test - + - io.r2dbc + org.postgresql r2dbc-postgresql - 0.8.13.RELEASE + runtime io.projectreactor reactor-test test - - + + From 8aa821506cd997a5942e87d0bcf66b1e12bd5cc0 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Thu, 5 Feb 2026 14:16:02 +0100 Subject: [PATCH 35/48] modify: rm .sh lifie --- test-ocr.sh | 307 ---------------------------------------------------- 1 file changed, 307 deletions(-) delete mode 100755 test-ocr.sh diff --git a/test-ocr.sh b/test-ocr.sh deleted file mode 100755 index fd13024..0000000 --- a/test-ocr.sh +++ /dev/null @@ -1,307 +0,0 @@ -#!/bin/bash - -# ============================================================================= -# Script de Teste OCR - Banco de Enunciados -# ============================================================================= -# Uso: ./test-ocr.sh -# -# Exemplos: -# ./test-ocr.sh prova.jpg -# ./test-ocr.sh prova.pdf -# ./test-ocr.sh ~/Downloads/exame_matematica.png -# ============================================================================= - -set -e - -# Cores para output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# URLs dos serviços -PYTHON_OCR_URL="http://localhost:8000" -SPRING_API_URL="http://localhost:8080" - -# Função para imprimir cabeçalho -print_header() { - echo "" - echo -e "${BLUE}============================================${NC}" - echo -e "${BLUE} $1${NC}" - echo -e "${BLUE}============================================${NC}" - echo "" -} - -# Função para verificar se jq está instalado -check_jq() { - if ! command -v jq &> /dev/null; then - echo -e "${YELLOW}Aviso: jq não está instalado. Saída será em JSON bruto.${NC}" - return 1 - fi - return 0 -} - -# Função para verificar saúde dos serviços -check_health() { - print_header "Verificando Saúde dos Serviços" - - echo -e "${YELLOW}1. Python OCR Service (porta 8000)...${NC}" - if curl -s "$PYTHON_OCR_URL/ocr/health" | jq -e '.status == "healthy"' > /dev/null 2>&1; then - echo -e "${GREEN} ✓ OCR Service está saudável${NC}" - else - echo -e "${RED} ✗ OCR Service não está respondendo${NC}" - echo -e "${RED} Execute: docker-compose up -d ocr-service${NC}" - exit 1 - fi - - echo -e "${YELLOW}2. Spring Backend API (porta 8080)...${NC}" - if curl -s "$SPRING_API_URL/api/v1/ocr/health" | jq -e '.status == "healthy"' > /dev/null 2>&1; then - echo -e "${GREEN} ✓ Backend API está saudável${NC}" - else - echo -e "${RED} ✗ Backend API não está respondendo${NC}" - echo -e "${RED} Execute: docker-compose up -d backend-api${NC}" - exit 1 - fi - - echo "" -} - -# Função para testar extração via Python OCR -test_python_ocr() { - local file_path="$1" - print_header "Teste via Python OCR Service" - - echo -e "${YELLOW}Endpoint: POST $PYTHON_OCR_URL/ocr/v1/extract${NC}" - echo -e "${YELLOW}Arquivo: $file_path${NC}" - echo "" - - local response - response=$(curl -s -X POST "$PYTHON_OCR_URL/ocr/v1/extract" \ - -F "images=@$file_path" \ - -H "Accept: application/json") - - if check_jq; then - echo -e "${GREEN}=== Metadados Extraídos ===${NC}" - echo "$response" | jq '.document // empty' - - echo "" - echo -e "${GREEN}=== Questões Extraídas ===${NC}" - echo "$response" | jq '.questions // empty' - - echo "" - echo -e "${GREEN}=== Resumo ===${NC}" - local num_questions - num_questions=$(echo "$response" | jq '.questions | length // 0') - local confidence - confidence=$(echo "$response" | jq '.overallConfidence // 0') - local status - status=$(echo "$response" | jq -r '.status // "unknown"') - - echo -e " Status: ${status}" - echo -e " Número de questões: ${num_questions}" - echo -e " Confiança geral: ${confidence}" - - echo "" - echo -e "${GREEN}=== Avisos ===${NC}" - echo "$response" | jq '.warnings // []' - else - echo "$response" - fi -} - -# Função para testar extração via Spring Backend -test_spring_backend() { - local file_path="$1" - print_header "Teste via Spring Backend API" - - echo -e "${YELLOW}Endpoint: POST $SPRING_API_URL/api/v1/ocr/extract/single${NC}" - echo -e "${YELLOW}Arquivo: $file_path${NC}" - echo "" - - local response - response=$(curl -s -X POST "$SPRING_API_URL/api/v1/ocr/extract/single" \ - -F "file=@$file_path" \ - -H "Accept: application/json") - - if check_jq; then - echo -e "${GREEN}=== Metadados Extraídos ===${NC}" - echo "$response" | jq '.document // empty' - - echo "" - echo -e "${GREEN}=== Questões (primeiras 3) ===${NC}" - echo "$response" | jq '.questions[:3] // empty' - - echo "" - echo -e "${GREEN}=== Resumo ===${NC}" - local num_questions - num_questions=$(echo "$response" | jq '.questions | length // 0') - echo -e " Número de questões: ${num_questions}" - else - echo "$response" - fi -} - -# Função para testar extração de exame estruturado -test_exam_extraction() { - local file_path="$1" - print_header "Teste de Extração de Exame (Formato Angolano)" - - echo -e "${YELLOW}Endpoint: POST $SPRING_API_URL/api/v1/ocr/extract/exam${NC}" - echo -e "${YELLOW}Arquivo: $file_path${NC}" - echo "" - - local response - response=$(curl -s -X POST "$SPRING_API_URL/api/v1/ocr/extract/exam" \ - -F "files=@$file_path" \ - -H "Accept: application/json") - - if check_jq; then - echo -e "${GREEN}=== Dados do Exame ===${NC}" - echo "$response" | jq '{ - examType: .examType, - subject: .subjectName, - classGrade: .classGrade, - course: .courseName, - schoolYear: "\(.schoolYearStart)/\(.schoolYearEnd)", - duration: .durationMinutes, - variant: .variant, - title: .title, - totalMaxScore: .totalMaxScore - } // empty' - - echo "" - echo -e "${GREEN}=== Questões com Cotação ===${NC}" - echo "$response" | jq '[.questions[]? | { - numero: .number, - subitems: .subitems, - tipo: .type, - cotacao: .cotacao, - temImagem: .hasImage, - texto: (.text.value | if . then (.[0:100] + (if (. | length) > 100 then "..." else "" end)) else null end) - }]' - - echo "" - echo -e "${GREEN}=== Resumo da Cotação ===${NC}" - local total_cotacao - total_cotacao=$(echo "$response" | jq '[.questions[]?.cotacao // 0] | add // 0') - local num_questions - num_questions=$(echo "$response" | jq '.questions | length // 0') - - echo -e " Total de questões: ${num_questions}" - echo -e " Soma da cotação: ${total_cotacao} valores" - - echo "" - echo -e "${GREEN}=== Imagens para Upload ===${NC}" - echo "$response" | jq '.imagesToUpload // []' - else - echo "$response" - fi -} - -# Função para mostrar ajuda -show_help() { - echo "Uso: $0 [opção] " - echo "" - echo "Opções:" - echo " -h, --help Mostra esta ajuda" - echo " -c, --check Verifica apenas a saúde dos serviços" - echo " -p, --python Testa apenas via Python OCR Service" - echo " -s, --spring Testa apenas via Spring Backend" - echo " -e, --exam Testa extração de exame estruturado" - echo " -a, --all Testa todos os endpoints (padrão)" - echo "" - echo "Exemplos:" - echo " $0 prova.jpg # Testa todos os endpoints" - echo " $0 -e prova.pdf # Testa extração de exame" - echo " $0 -p ~/Downloads/exame.png # Testa apenas Python OCR" - echo " $0 -c # Verifica saúde dos serviços" - echo "" - echo "Formatos suportados: jpg, jpeg, png, pdf, webp, bmp, tiff" -} - -# ============================================================================= -# MAIN -# ============================================================================= - -# Parse argumentos -MODE="all" -FILE_PATH="" - -while [[ $# -gt 0 ]]; do - case $1 in - -h|--help) - show_help - exit 0 - ;; - -c|--check) - MODE="check" - shift - ;; - -p|--python) - MODE="python" - shift - ;; - -s|--spring) - MODE="spring" - shift - ;; - -e|--exam) - MODE="exam" - shift - ;; - -a|--all) - MODE="all" - shift - ;; - *) - FILE_PATH="$1" - shift - ;; - esac -done - -# Verificar saúde dos serviços -check_health - -# Se modo é apenas verificar, sair -if [[ "$MODE" == "check" ]]; then - echo -e "${GREEN}Todos os serviços estão funcionando!${NC}" - exit 0 -fi - -# Verificar se arquivo foi fornecido -if [[ -z "$FILE_PATH" ]]; then - echo -e "${RED}Erro: Nenhum arquivo especificado.${NC}" - echo "" - show_help - exit 1 -fi - -# Verificar se arquivo existe -if [[ ! -f "$FILE_PATH" ]]; then - echo -e "${RED}Erro: Arquivo não encontrado: $FILE_PATH${NC}" - exit 1 -fi - -# Executar testes baseado no modo -case $MODE in - python) - test_python_ocr "$FILE_PATH" - ;; - spring) - test_spring_backend "$FILE_PATH" - ;; - exam) - test_exam_extraction "$FILE_PATH" - ;; - all) - test_python_ocr "$FILE_PATH" - test_spring_backend "$FILE_PATH" - test_exam_extraction "$FILE_PATH" - ;; -esac - -print_header "Teste Concluído" -echo -e "${GREEN}✓ Todos os testes foram executados com sucesso!${NC}" -echo "" From 0e99167cbc8f0173fea51bc6d731a5dbdfa3b291 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Sat, 7 Feb 2026 11:14:26 +0100 Subject: [PATCH 36/48] feature: add @ocr.md --- docs/OCR_MAPPING.md => conceptual/architecture/flows/@ocr.md | 0 .../main/resources/db/migration/V8__create_simulation_table.sql | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename docs/OCR_MAPPING.md => conceptual/architecture/flows/@ocr.md (100%) diff --git a/docs/OCR_MAPPING.md b/conceptual/architecture/flows/@ocr.md similarity index 100% rename from docs/OCR_MAPPING.md rename to conceptual/architecture/flows/@ocr.md diff --git a/services/backend-api/src/main/resources/db/migration/V8__create_simulation_table.sql b/services/backend-api/src/main/resources/db/migration/V8__create_simulation_table.sql index c6e011e..2d7cd15 100644 --- a/services/backend-api/src/main/resources/db/migration/V8__create_simulation_table.sql +++ b/services/backend-api/src/main/resources/db/migration/V8__create_simulation_table.sql @@ -21,4 +21,4 @@ CREATE TABLE simulation ( CREATE INDEX idx_simulation_active ON simulation (deleted_at) WHERE deleted_at IS NULL; CREATE INDEX idx_simulation_account ON simulation (account_id) WHERE deleted_at IS NULL; CREATE INDEX idx_simulation_statement ON simulation (statement_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_simulation_status ON simulation (status) WHERE deleted_at IS NULL; +CREATE INDEX idx_simulation_status ON simulation (status) WHERE deleted_at IS NULL; \ No newline at end of file From fc54aa45c2e8c80ecb9c25a4dfef418d92f9983d Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Sat, 7 Feb 2026 11:15:46 +0100 Subject: [PATCH 37/48] feature: add @ocr.md --- conceptual/architecture/flows/@ocr.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/conceptual/architecture/flows/@ocr.md b/conceptual/architecture/flows/@ocr.md index f0743fa..2013aed 100644 --- a/conceptual/architecture/flows/@ocr.md +++ b/conceptual/architecture/flows/@ocr.md @@ -8,9 +8,7 @@ Este documento descreve como os dados extraídos via OCR são mapeados para as e Imagem/PDF → OCR Service (Python) → Backend API (Spring) → Banco de Dados (PostgreSQL) ``` -## Estrutura da Prova Angolana (12ª Classe) - -### Exemplo de Cabeçalho +### Exemplo de estrutura de prova ``` REPÚBLICA DE ANGOLA From b89ad879205ff1e21e4537d1d7d77eff6ea1efa1 Mon Sep 17 00:00:00 2001 From: Erasmo-Veloso Date: Wed, 4 Feb 2026 14:43:47 +0100 Subject: [PATCH 38/48] feat: implement question crud --- ...9cd-23cb-442b-9ce9-2ae6ad3f4e0a-lopito.png | Bin 0 -> 73865 bytes ...d2b5-46b3-4c96-8077-e1015fe4e77e-teste.jpg | 1 + ...ba92-4579-454d-b9d6-b10d64e446d4-teste.jpg | 1 + ...5a8-c83a-479a-b9be-8b4d99f27373-lopito.png | Bin 0 -> 73865 bytes ...1022-972b-4b60-a8d4-f7f883d1bf1b-teste.jpg | 1 + ...421e-f0b5-4c06-b4f1-dbf91a780934-teste.jpg | 1 + ...f11a-1a55-4b22-b0ee-fc60882401a3-teste.jpg | 1 + .../ao/creativemode/kixi/MainApplication.java | 17 +++++++++++++- ...m_table.sql => V10__create_term_table.sql} | 2 +- .../migration/V4__create_accounts_table.sql | 19 ---------------- ...s_table.sql => V4__create_roles_table.sql} | 0 .../migration/V8__create_questions_table.sql | 21 ++++++++++++++++++ 12 files changed, 43 insertions(+), 21 deletions(-) create mode 100644 services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/027699cd-23cb-442b-9ce9-2ae6ad3f4e0a-lopito.png create mode 100644 services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/4d22d2b5-46b3-4c96-8077-e1015fe4e77e-teste.jpg create mode 100644 services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/747eba92-4579-454d-b9d6-b10d64e446d4-teste.jpg create mode 100644 services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/78e7b5a8-c83a-479a-b9be-8b4d99f27373-lopito.png create mode 100644 services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/79511022-972b-4b60-a8d4-f7f883d1bf1b-teste.jpg create mode 100644 services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/a6bf421e-f0b5-4c06-b4f1-dbf91a780934-teste.jpg create mode 100644 services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/c8c8f11a-1a55-4b22-b0ee-fc60882401a3-teste.jpg rename services/backend-api/src/main/resources/db/migration/{V1_create_term_table.sql => V10__create_term_table.sql} (88%) delete mode 100644 services/backend-api/src/main/resources/db/migration/V4__create_accounts_table.sql rename services/backend-api/src/main/resources/db/migration/{V2__create_roles_table.sql => V4__create_roles_table.sql} (100%) create mode 100644 services/backend-api/src/main/resources/db/migration/V8__create_questions_table.sql diff --git a/services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/027699cd-23cb-442b-9ce9-2ae6ad3f4e0a-lopito.png b/services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/027699cd-23cb-442b-9ce9-2ae6ad3f4e0a-lopito.png new file mode 100644 index 0000000000000000000000000000000000000000..ca9e06b39398a4d11e54c41c92ac8f3fd52aea70 GIT binary patch literal 73865 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
N$xEiEH-m(jjd(cO^YDkyZBiu_7IFZ&kYgwi1*s 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 roEA@!Lwaj=lp3Se9Rses1!;1Vl#~ECT=5B6S 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 literal 0 HcmV?d00001 diff --git a/services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/4d22d2b5-46b3-4c96-8077-e1015fe4e77e-teste.jpg b/services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/4d22d2b5-46b3-4c96-8077-e1015fe4e77e-teste.jpg new file mode 100644 index 0000000..06def72 --- /dev/null +++ b/services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/4d22d2b5-46b3-4c96-8077-e1015fe4e77e-teste.jpg @@ -0,0 +1 @@ +arquivo de teste diff --git a/services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/747eba92-4579-454d-b9d6-b10d64e446d4-teste.jpg b/services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/747eba92-4579-454d-b9d6-b10d64e446d4-teste.jpg new file mode 100644 index 0000000..06def72 --- /dev/null +++ b/services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/747eba92-4579-454d-b9d6-b10d64e446d4-teste.jpg @@ -0,0 +1 @@ +arquivo de teste diff --git a/services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/78e7b5a8-c83a-479a-b9be-8b4d99f27373-lopito.png b/services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/78e7b5a8-c83a-479a-b9be-8b4d99f27373-lopito.png new file mode 100644 index 0000000000000000000000000000000000000000..ca9e06b39398a4d11e54c41c92ac8f3fd52aea70 GIT binary patch literal 73865 zcmXt9Wl&v9x7@h9B{&4v;O-vW-QC^YEjYoQ;O_1o++9L|;1Jw-hx_XLK|!4=&Ysz` zq*r%Oq=K9T5c=hLpr z!z|akHS4Zu(ja&&n$D!v*;Nea1{m2}uUnNW({d$dRqNHC$6CE z+uRtyOAOoB{n%3h{*~!{y9)>dfWZ?~*Z{hKv&Y=~VElklAi!4GrpTZIR!=pm#%i+a zo1-;FVYA$2XJZrX78~a-kz6ReF}@nUc4!xp-Z!k3uNMlkOMM2}us#9R%KhB36T}Xv z0aAswC9-a?m*@fXfEVl$m?{d}T-4zz{x@F6)CfY`FSIJ3q-bKR`U#^2nhhut_~3UG z!QliIC9?MN)s8010OKscgWmkF* z?Jo;r6EI>VV4G*nP1S<~G6%ybIN;>R5}s1h31P|)+R54G3Sp>^hs+jNm%X75T(U+Q zz!6hIq%p6tubS{gsFSQf1H@0jfzpLo515kjASSW9o)}yFyQZrh=N;NyKm@qzIo)Np zDTMO1KSkUyqdKHET$?(Ann*tVW}=T{BT{+oy%K2a;>k}>UKdLcst~0aRCAC!X&cUd z3uAzXS^%LK|CgXDvgW8m^`=YJB9jV3BWq}{^`E}b&30OCmKJ2+Gv;|qtVS{ymH7ze z*w{*F`cz1Y6RrHwWXPGYw9KM}2_WZ(CDYV^7ZK4l!OH5B+*ZG{wWifs_VWHM5zzaf z;$e}~e9()d>`0EM0Fwi+ou*1lfV#97^padGY`Te;44xAax&K-IIvsPbfSZ|#q<`T& zFUstSx&&7Gbo-)0Q#T1;&^MScKXaJE6;-**Xg(*B&<79P!I`ga@u&lzNJ^9 zK{-$a6r=g&bwgj~5)%gPz`?(9?M(8&Z0KhDez6!xE9~yxdLDi|_NDM7!`fmDY6D^+ zcZh^oSbJlHH>}KfThy|2LY+QBMP~p9fl#KDg?J9pfCNxIAZGyxF?JLK;%H~%Xw8UD zq6A*Pr7v$5rV;R+1N1qCV1!kXToa+;afE@j#n6|;Vrt)hw9~tqKTDhH)%5ELf4qiB z%V-hhigHxE{Or|C;-rwuaOxB2@|&FJ8aMLF1`(D2LFBDO1Yb8pEi-L-|_Qd1Ej zv0tf>AaNojAp-7ySaMYxj6P|LbO(Kh}4wE#K%t z6ZA!xgUc%#lgHhr3NNgpa!y=q+mM$i=oOM%o12Lqt@U5PzY;10BRPOyF zk{Kb={_ZYNlc-0f(R%L^#IseZ{b^zzkGhkpdl3 z?{8XcIl>7u0jUJWwgx^8r%%G#t=*}i-0VDqRQW5BG9^$dZ`ZnXY* zjlP1=XR?Tk_Pa7otSuu&$-IS+IHW#gZ7zZ`Kf={r6ibCcYPOsl0>2#bPJi;c&|8Z5 z-SK$`cdnyhH~gRYDC2bmxIy7t)W@g*BB%Uw8y;c~>)*24Yl&xA=4$z$B$-h&IrKAM zP*Ba=h~Ki%b{U~PZx2+@P})q)LLEV&btd*u_*)rx#18l)oh=QChxsVbW%iz{uk&hA z!-K6>3QSnqWG$#~x?@MkgTbcq7jB@G(<;-S-M1U+ht|o1z5oi6^mk~04uz<3q;ePX z=r@Zu(!~^y1@dbel@{joJ4w}Dbz|0qCc9GOnN;pBQh$r>{-|e)?P>EpU9pz zOXJWwP=z=PoY2CxnJHih9o#fBrc;W5`f9LnGJzzk z6Uo@-$uF`ydr$X=G}CREk2C?6V)CNZ`AgfUcCv1*RtBMpg}WPS?QEx$9O0rM^K7&ADbXegsx|5JX0k%JBtkh57y!mz3FZJ%9gJ2^?N z;ph+WoCJGbtNRJlhyZk>F47+6C9CY?lrX#>OBI0D8~>cXc9LF)TkjF>o8+M>00V1? z{t3MxDn7i_u|v(`B+{LiTTkEZCE;>5?~S2%KH9~B5#mM8Ywr7d0;p>;r)lJWSHWNB zG*)B15kk^Qx^OtfcZy;6RFq#I4%g+dgDk75xwqq!)GcQ1Aq~N-B9ot8SP*Vo>b2g+ zr8U&hCwnmzd(k3Jq6@n#+Kr5q`#XIG`|lI+#Tpv?Yz?Fym>X(DCPnvV19yU3`>=+= z(wwJrW9fzPWj-mvMiTB5E#JS`CFV5se3{Z&_Pijzj{tbBoB4*cp`NV#WjQ`A)yh)9 zvy-Q(z4%F_nZUv(?;upzL)t2iR7KbW@Y_nJw)QO{=%}d}#*Xe60 z8sK(>w<0T%Y+wvPnWdusY^k!^S#GRcPCkqH8%A&=BTkL;H!Kv^{Yd}))c3GBx}_KhfT`YbU|YO4TM;*2OwW4Z(@TYtL6L<{U%uwK4c zN>oi7K|(lD5p{z0&hBLN;QtQ3xif(o=jiV1+f_KdZVSsNYjfy2S zdJ&TV<0FmBJdEH%qp37bF0W|7y7~K9*&j*s$xWH-f)Gy8^q2Tzj%1OPJ87QZF&9dr zTVM21eRwuATAL7yV`x-WoiK>QBWm&GY$-7WC)VBa+R=NLf3?a9iGGb)q7(){G|!&W z&Hk4~g*ksWp4fXW<9bmC*erTJ7Uq|L-Jm*2s2Sd-sE#Eo478fQUyXJvZ_SFcKDxVAw=JK|QkhUO<$OB#pbTXj!pdr!+=Kc+; zW~$lY(&YZQgA?sXcui2KVE^|szUNBpU>S=?9du0UtbQirWpFT%50s+a1_ARWoi=Kw zjZCVLM4`JBK=a%#TfRYQrubQ)*aEoappr95`Qa_+@1UVGqSq;-QwHqB%4ld7%yC^+Mq(nVp(OY9Y;VvT)Q4-oLLLcJJd~EtYKv2q zO%=fQ0*p%N6FU=-U1dI>Z(BD0lQ`77Q>haQDb{xUnm#rj=qWb(Z>DUDXC+7v- z7D4wH$Y9l6$M)gVLq{0c8=neC;eT^qd#t$RM8m@N3iGP2*};!T+l#K_YHe!~KB@A; z1{AG@0vJ1svBC-9IabDhdGJ4@QF}}s98KmJjMxFMm~9oXc;d{g#U*6aa5+Z#KWVE@eIxAH68K>CI012qN|10@gT z3FX}o`WV5p(s;tDC6NMaxMd5cBiu=#mV~=9;?@_XU+yyo)zJKi8krc$#w#D;w1?Xv zv5?n1x>~t4i#~*XU zir}0_+iD^Sxeug$OYOy83CF(E3GC9Jwd6M;jB zI_KQvT!~4BD^0;|D(5N7LiGGFvoViTwXDTrrzmvlAi3%%CRpxPr&rgX{wvK{PXuvm zn8r5SfrFf-J)=FZUalkLI#KFBL~j-|V|@Dgq99ZOnFazNf8s)G$Yi!@l8D2l#wX7;DHsHbz%`jsr1;1wyzFzyJdb+1eVuM;@w`Y>obe6!GS8|NOj ziMZn0y2_=_jlaH%iYomZya;862@>FJy%7v>fBcfaP1L#fX{0>|D@)nY#*_jTVbR+? zEpCtK(2asbEl;YHjUzCq%GUc6Osbco?k2 zsiEkhC8<#rJekkbdDeg9H7q!L*tr{wv=F^m*pI}5;j%ks`t)ojg?YPySob_xU!C(x z)$dsrY;ue7=YlqG_u>_Y9UbI#zkt7j4uxmYqR8ss>~ADQ-c{w+@7zFBHR4`qv^~il zE=*``4XmdXP0ovqdgtpTbLIoXi|lpKo5iIZ?kV`5GC-LJ8!k>PErczYm|^}?JLye@ z_wmH&DRjRM5evOCsCRhkJlmzkgELn^zS@F@Ri3sGmBX82lneNoMs^(qAp?wNhD z7RFx$a_}9o%v4pd)+ro>*D~}BIuxvt1Li=}->V={f(PcPROl?ve)8HIfdj7pZ9h6p z?p8uXilzyH{np!I8?t9=jaHFUbRA1 z?iy1XTqqtVtxqnbCFELaE0wwx;?*Y)cK+wqD3hMg`Zj#M9ld(RMK?iI$2<%>1;6#s z)5nMT1bg5Ai3Po1iVgfWI$-?aB3&<=o?{GD)_WsC5tZF8xWu|}jctM8bX$Y~t`0>p z8Q4S}6FvYP()o2W!^Z1_GXrW?aVe2SZMMUL$e9#dZr47 z4ugCebk{4$w3(zYRn^qA9XW89kxxXe@U{vu@Jr!g=p-8s;H12H2UIX9&;dS-IuWs3%{k70;|~ zTpHfjFUR9C``d}|!6H_iY*hjuPdekUC^`n?HM*`JBXz0}!0vXFw?tuG1JMML_|2Vu z{@bxRYG^t}*VmZw%ZqZy*_j~3YXd3cgOHsr8lVqRE031=4GOIvpSerl!C4LfVoD$z zU3hU>kGCWz-MG61-4O;;jF&MQhR9|)cW07 zu;?77pfww>c`H6xK3Fr&DOp6JKgc|H=TTB@%W4VT27-|ELzTbT`6}4?x%N$2M0e*l z#Fx>9_oB6}o|YceLr`$RFzzitX$kp}eCd_{V0)8n^{x`tc&QiX{Q4|9RqZrycdWr* zma5A`zd*8KUJJLlvtjZ|;TbXrqfo_-D0!W1eyTxhY?^2JF0(44vmZZ?p1>l=lZ|Gw zp5pN=kYL@bIT{Ay@7`VkLEMr6BVS~i8I`H|mjvtIEI91#k-@}63EYyJN2jL~E-hr; zsV5!=9pqDRqabzyAx5KqE=O{x4oiv{otNNS6_i0sN-@Swh?iOu&)M1ujn-HkMJfiN8$$V#-)NVPw1p;}Ld4eat;m7W7fFLM8ck)a2d zq~m^d8y>9`(<945Is9DY1d|~-hYpB@XgWcts2>ejIu->KzNLs%y1zpv-z@T4u-S34 z*1|R_+0Ti+iMIB)KDtS4F{UUC9%^voe= zLxHEvU8vmjHN|P&YzPGWG4r)GTJzd+2i+`mAQ(%8_DJhevGpO!?c37bx;$^6QiV?9 z@xY*BBK|ZDRYF&Zh(SV2Q)$-P$Lh)S$u!uyf$ONnV;`K1NPX+Xd`+mU&#U7$1dU;Ho3vFSjN8dIpl}6MUTrR<@0_ zFFLpJx9qL3SGrSK2b+a?E)**9t)x2`a0qJLfu2(Wc|6u78vTQ$e5tXPyY&^$^ycE@Qq27acU#~{Ac@z(?gjPG zoO!1-BVN~VLM-)|mDGl%$XKiJh#H~fLRPQCIw*u``j^Cvnp7qcT-5Rpf5}e5xi`-? ze3R8Lt=gG!L`uay=|*K5A(eRpMcb6*zP1^+Zud(nw2{Bo;q*1zAe8;Kii3o0Gxmp~ zY-x`uf0j6a&sTX@c^#Qm4N9)MWh$i#P{GgSGs~*K>;knH|CUX~R0}3mRfnJTgs-zZ z%q5onq^-OqbyQWjI?1su(`S~l8{OJ<`*5Aeq;7<*Q9*5Xgnr%?ofTKvt@PnmmBVwv zKIu*O7WdWKKbsX1ITaSt+ssKBI)6+3u4?yhpUvxsxnbp%cX0h#J?C08g;zt}8+P zP)~S>TOb+{KP!ns9Jjbxt%29gD8W_|VJ72x9cma5=$wx4R zNOokM5ic^x+MmpYf_V;VR0


N$xEiEH-m(jjd(cO^YDkyZBiu_7IFZ&kYgwi1*s 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 roEA@!Lwaj=lp3Se9Rses1!;1Vl#~ECT=5B6S zg&_@VwdwmKUeblZCA@JTIXLe+?;6ykW0ggE1yX>Dw+nk^!bZ){waZHhkZ>rF*ky(o zeL$TStJ(w&^N!xC$)nRegB={mr`P_=1E)Nv-LVpX$Vj_2VSjb@RuXcXSJyiVA_li0 zxTlx07UUqQpl$yXom<7Be%+=lzw42){QlkaX+jBKFCX3VZ%6Yj@lK$-z8aoDg$$Le zRDi;R&7sMh&mko2R(Zp#4d6GY0LAsIEnvgU#9d2gednXJLE;#!EC}<8I%_H$UO)%h zeHK1@YQ6IXU`-{MFD&ZHI)}4h!kP1dK~O0|q>Dwc=9J436_?U-UH1kQs2^r#>Qa)C zkwdXUIGTW_mS$m#k7h^rxV%R-K&S62k>~5KW*#I-(fJ&OOqU-Bl*^LRuU7DaeoqC{ z`uUe&6ewI_VLeH?$8@l)w6bbJzl*Yr3Xff2!8OQtu+MK=zs#Hnj`gt@HJR9teiG>U zLFZ>$_R^~K+RJaH#v8h~9_FzY)KQI#=%YZ0 zxowZ+Y|6;5q z(lU}YM;UC~reQ1dz7-N6UECV{s5K|IjF1O@fP&y-dxdx5LMC@G08WkalbUg+i20xS z^I1Hf#Ae}mp-dtkH%O2FDabf4J|~|SzypSOtQs7Ti%!ILfGT$borNmVRXq^M${uRamCrWexnchVG((#7-VcmGG~D>8@YoAt0}|H+{)s8>Bm@&It$ zk;`1|#x+Ze;%}C}g;}K?(s>(y&7V%274(GQaR0GlnbJ5OChpL{vUl4J6`JP^bHe8g zSYZY}wf+_O-6L9dW}LZyWCJ$asx1j|5E%v>-bKSS7G!4?HwOtQ0 z#<_aK>2DmXjy?#E6fM3t=$JP#u>UW;9G{wx75Z(EWP0j={>FP_ykT*?S9L=*Kaa=s|E!T?@M$8j36^1=tt(4nea(q>X1!mG;>bFh$k2}cy_vnR3idHg9PQs=8 zz(7blr;yqW95OzAgBmln50mbcRTJS@St@xj@jWX?V_ueU447&dF4{f3yu|5o{bnn0 zVV9tU|H58s_I>2<5M0Bs zUdOQIv74NY#o{>SIXF1pMuJvoqwPjC;{^A}Kjk%9rb7DE1+CMrZP6ahY(5gy=;1qD z+SCv%fTDutze6%eaEqk55`!k`pBImhq%}TBs_BIC* znFkfLe&HUr)NPP4{N{f(mT=~y6h`}#;bkpgadYB_Jtq;B=t(^y9P5G*vG}sx1)P}? zFj46=@Q6pW>QeQb04i|fX6`xpY`Pq53bop}qKzH+7lieMXR9k6RA@m1fseXY=#OU| z@6b}FPeONLG_LIt8H79PzMfd1BvSLyfh<5h3J{8`$64;Rnp^b))RgdC7JM;K8j=C6 zTo_oLV&&cevmKzb=+h@m=W?(e+*4?-`v;1WM+R@6AxNw=JssbXgX(+r)}{|1p`z1h zBf##x@O7tGcPxE6XXV|eu&qhh_1VfXk+Jv})U{FJLlU3)!ICEPZ;E@8EkY_sg4TB5 z2@2>2xPLKf`C$AAKJjTbNNc}Sm)7c?_;)i(yzf6rMg2%K%7>Mw_1XILv+=|R+o_#? z%l4Ru&;&!MmO!K0O6nU!k3}@eyn^q%EgVqB;HRmYbJxaxrRX!|tQS+Z;2G7HWQyiv z3F7-M_2o_(_Br|^8{Bstt~qsNb7*orTco#*X+<0xAJH=N@V`n@>`?+t*WyQ04)4;H z-wY1KWCOeABV|LH%r3fTmqNEk5&K2a-bIbr-qHHgQF_vOjxHlp%v$tZKO&N^~# zhpdrqbr{5#ZRLY}0bf{gQ;VM1H&Vx$cmSjbdweL@aplu}HAT}OI@pKDDGjNCDxSf_ z07CPedbNMobw&uhLpL}#ZauQ(jp9Ze*o$H4HY|vs_Fv@rbugGLwat8&tc2G1c zwLkvO)YjBAdsxk4p@K5v?PChXC#~M49y+O$A>W4qC9p20Z_sI_n@lLE5Q68mnnHlv zaKaQR*mm>-c<9FVFnx5&%-IlSlRJf!jIgG2?q+Z28%w}PvY(1bzKUUxBkF#4PJJRp zC*Gf-=ckU#I}$4HX_JPgHW7SLBJpKY_Zyf6*S{`7qt{E0fV?GCqK}Ku*2d&*JS?lX z>ZjF!r0p#%dG-dCtP3|7VZlZJ81cMuv;5X02lRS~J86n%i>|_zLaJJE3g?F>EhfEi zmj}am9kjIx+(SD&HNh|KllKe?gC~a0Njvqp7LQN(p_)xVtXdwpm6VCH-u0r?sfJ5w zGXsYpSX4P6{Q0sv30Ov02=Y7|IdYF+P|dp_)6Vdo?YAa;=AjMMk0#$JwD8_Q$sK5g zGai_Ihu237ZiDfQ7pLI8){7IrMU2&^7>UnxF~9Q%cn};LDn>;&d1k5%Q^_tJdIt8k zm_+1=08Oe@|M-Sb4e5*d``g>r+)9JlaedgCAg_z1qN1Qw)XT+Oq!LcXMIEzvKiM8; zQ7muTS5)F^cr=H_12!n}l+^Toz66g6$&V+(RW-_THQGS70_rd@$)NCf`x;mx9^|KbyC~41zz+Kia8N-E6==X5j3lj}`dzVtuCALGML~P%DHbQ`XLEs9P0n)5 zCp#LmCI)gXR=0LKZ6l6CETL>NgV+Alvs@B&-^HA`TgA(?=ma0DBm5S$RZjYk2@p=3 zFK57wH|L%eXC`M#yLzu)QtGH$KS7KzAP70*CnUABT$>-Rbs+&M6vwn_1@1`Mi1Lt> zD%^1xH?mr`iXvRh?QgA57_}SVpp-?#7?Gx2WpeDu!Pa>RPYRG>t;cE$l-L`PEq}g! z8t4`VB0I=BD|jG-M!UG7{5Vv`Xmw{3f{`psvWEbdh9`GSbqs!E_p&j$M}3 z8G`&B%mDGP({IbIRO1H8^(~~KkA`pEvopv&u~<2U?@{WQLqQNv6q6Rd72onJ7S(J8 zk)-rg{on6OgU}}^^2&d>fKB6TOkkvDt9OnP4vHxE)#h#wH(J7XH$4ucb8HLhhzU3^ zzHP)ju2CeacqfBxJHxZ3*^E6X@K*!xe?~Cig*pe{uNilEkikkTwwtqdi$TOLWOf;; z6#%Inuym??W~TNm1zm|~-6qh5maGG3QSUcN%1N$&>pn(Ek-gcOR*%(~Rcja}*}yEb z@l>HH5N=RTG7}H_jvJE%$2MsI_sv)OTNjhBVnLH+e@TE1tcg*ZgNKQlhITjYSiU1; z(-Mc}eT|Zv=@)KEe@Jkt9S~y^_{I3BhxSM(CFs*NZH%Zek5u=6q7L$6B`^q$xA&*7Wt z6*0MJ9Vjg@N<7d>vZDtZUUyehO_Yr+lS8~UXF#e#*qNn!H{{Ir|A_@urQ!;fHn|{2 z?-uR7VI zIoKD{%X?T0IYUekW0oQIA4U(x0DHJ=>_7tP$$$>V83&vZ@&RvRf@c^>J_0)sboA+A zJu&ukWwH%9s6uljAgPB+e=_Z(~RRWSCKNCi>5f`9lm>FR_1`T_947Pl zv2*hk&%ju->la=CzLIALjv=2sGJ&3?y>lM_w87P_p)8So#Lv{#7UIqRkCJWQ~F2KD&_@9*jm@O@Tnp|?Wsl; z`&1W6{VUBY8)ZG6uQ61LMh;43<=rv5P4jIYwf9l-ec!_J2)Jw{BMQ|{76-;b? zlRoI(R|VkWU>oRE&?mT2(1pyzu8gM_V zi0uA11Ck+s;Y&}7uKKfmH|YKd(sD0EM?D6~yZ*X$z}hwbg@PZkza7&dJ*pd(lXdVX7=m_e3g_aN7-wE{8k(GJbA95=Fvs5$t)tjc@V{}EQ~ql`&YbyFQ&;sPR5%xR>&Eo3yxpWsE^9a&&^D=S*YB`V5FdR0!e z+ETJ}r9+$|8~L8Def9TLjm@4OyYO9RcDAW#INlc4som1(RMQ} zj`H!EOQe|<`-J~vRmH%1Nj*-8V!fB|v<*l+>APIGsm#b>;+GswStAWd4ghuY)a_KM zxdV(Si_$oVo}M!D^92#O428Z4sGZN13vL#B`iC(ImZ*CYh^0N(GrR;nnOaZN)MiP|1989D;Jb32YUlJq-7)&#U zk9;VH+=dp1FK;ly-UH(gP&MPjo3KtQlb47fknX%m8ft^IizhAN_C$jvc3_{4a3MI_ z-p&qQCDl?wXaNVX>sasUaiT>EWfkq9-PdI;zq`-4xw4CEb8Gc52QxOoNZry}W% z9b}Q4!OiUoIT82(TkbEE!wUmC-TKBxF}T?_OnFMq$G2U4&Kkq`RLS?@S1UyEmok$E zYSJ*YI%?NcT?6E+$mGqwJS~A@Xji%?{S-I3U#=NQRmeti_aL*+Zcu-O-)pJ`Uk&GP zp+>)SXHg$ob@2oHh2_lu-T)kgRSuBHaz8Unej5u$=iS1nE=oM7^jK=PK(7!sc=6lL zEqEV-=NyiWzg(3*&Q?Z?gKdR1|J2jjeZ)Hz#dlpmn&x zgy_N7_FlemBAS(;tcJrIzD$voh(S*B%?r9W8;K2a{1WO4Fk>@!)tdcF2OQY(LkbNi zD=VxLJ7yl`l{kvYNY9f3an3qTIaCcgJEC=w!g`Vr$zcPt^eusiidsj}domE>{HF6Tl?K6dr^9n_#UA<>iC) zL{lAya+s??1D!xhSx0BlGH^YPy#}MsiL<~O&~V;RTVcCP5fWvvV+;Mxk^69fg4LTm z@nrMS{04(LBgmoiHgE4Y&Fgm<=@pQ61j``{Z5SlYvYz1A-;YC*RIMr>HPN{h8S=Y? z%~4p{>em5t+%)_vYkKKQSYmQNJfJwVy^VVJZ7GiKfo{(AIWPt%)15HbcjZ?hr8>m(C`a{(W>6UbkcO4>NkxXkQc_!$YG6}M94^vX8=FMQuV|xUu(xvT*u~0_zxkFN;nU0 zC+RmIbS1+?3bT<8`u!=&^1P%W_PrwU#zy>m&8-5%#To$c6EdfEE1VTN$_wA2Dl9&X zjK7L5^l!EDqBB?PHZ%x&>#)%QvdllSrhP5tp4jGJzK9>d1VYUB?}Ya+iSGA5jjtZM z5$A|q78R{H_NL|n^RT93g1!l{p-@x>{AXZm3)tQ5YwbDyP?b$XDXz@yr@|E zV8jcHDQ=7SOrLs3#1~5hu=gYpj@r(eB zkQw>G7rpZrIyP^QPqpah1AHLP;_6X(e>1$Em#AXxMO9$~!WSw^Zm5wyccSTNdmH32 zpv(k#@Jg5sVtiRSf#p%MYAw6mHGuCP21OFlQfyMa$ z(R7W$l{MY^#C9f_*iI(4ZQHhO+t!2=+jcUsZDV3v_q<=#{l9D1uCCtQ>(N>ot{eEq z{JgzS@BP17ORh&tj;gbP6%AC@g7-9-wy5n9qfrJwfP;j-hW94DpNJSEp={ptj-tN2 zt<+HAX%#0*&SHWtPO{udr1s^cNBCel-2 zT0!IU>Wu2FNu}|oyJ-?6D*(4I%4*~|=A!NHn=GXhMvMj@Y=y^I)bF(QQeeHW-6*N9!vk3uXU9X?WTxX#W0I5U8(v!uS^!7tl zF=&s((q(q<-5EA;VFu7HkCFPK*yOmfVCA@1lYxzKar@W#FjMZ%1o>P=-ZkBmZ<>(r zkcgG1-%q0(k#s**84j(h7f02HdoDYO^JtXvl@}d9hK_3Zo3(VBI-jNiG)_qaXiZ#v zrD1;QUX+Ph69hweKU_E1LBxR(M!{{~8*>bm@o|Ou+01vlKk0|N9^XBi^&Kaae^W}*isukkW*Z*neRJkpcz z01?TAk~Rt8>=~;cuM*B1q4w~UGSn=hqHj-ViVf*REH32|)(3O)2!Fi)Z0=E?&z0DO zdNBKUO6L9dPJ{mdBt0MdpX>rzj-6!z-Vs2t{=)G*?$HM) z0oY4kdbb1E6@spuoLHq|XV6>frg-{zmcY%Y;J+gM%i>{&&=0&X5o+52S{1)pUNiwm z71&Hbt$tPhzBwa6>vVvQAk7MisQhNp^PJapTRbHnW!EKOquCQ>vxZ+8-m{)$HL=xO zth0*uOh4Pz4w3Z9MKCYQhdus>qHq@0l3vOo`-Y5S&>72r$&`VnTZeMVu+b4DO^_#m zC9I-IKVPJr4?lnbAdc{>RrscM4Q`p^D9naHS{G^I0}_`SqzQ^&-zbwE*jIj!Rep;VVIYhcnzna6sSx&L1xQ zHXr3}5J&-vNk~{cZZP%fa0!Q}tWFwP&qttx1A2v?E>VfSVo$|zl|A=s#NRw9*8W5O ziQHFW>MSWZlw9)Vzvr3@Kgod7DSuz>v8!P}JB?NMvujI}sZXs$16FV=txf(I8W(}p zR?S}TzQ}iqQ{!Rn>pUX*X!5IuIU#yi`RkVk^!TEhD{Vlyb2qYRH%V55LbOyi>rZ(3 z9RE-hB#U6*a$On`=lZ?3zY|E!J>zd)3yr3O-k>p{fYI6V5Zn;=WC%9l7b>uXq6!l9 zROQa7y^#4*8$&7lhX())eT5eIK3n49TW+YpkcM(yb7W{-xmr;(y?qX9Mxo-RopwT%s|Rg@2|7JF^D)yqLczL=anQ&@X;(b9{VN*RR{_=grKJkiQU$RW{5- zLa9O0Ow2IrKiNmY6}4fsDsBC>I@co$Ox__g_lKUNo6gsqXl7U5Go07&pJuX`>i{fGC3#^d)o<73 zZRm`z;$qs+ajvmpnc&>*q;u(G*gC?c&!JF>VNh*p*}-T4GZ8V*-ysnG_-B)-v&uoK zp7LhTf#uoQ61eb{Pvi{eGKu`fc zlO0RG%LlJ3N=k-yo1oS*Kn_wQ@1-armXE)sP?e2e!ko*nTy;@TZuOujad5*@#N%1r1}KeBG-Q{aY$_qVlvhdlOZF>Br|7(q`%6@M9!V;;b~ES zf~*iHZAt)h)4!QlyOuZx($!d4FDsa5Zz+`VGc1zHKJH|wuB2`b*@Ch%!vc z3A1iCo@MoB#1F|D^8yGwfsi1}XrBlT59ik`JiryqXP@HU zPwT!imS$W8BWj`rkPu~p(+gb_e>Fu@ahQxHtHMLAt9q)rcQYFz3Dd`>lshOu!BC-6 zA_}z4)B~2+!bK&p2{N3K>Z&~IJ)AJkP2?`6yMOvnZ4z_Q3vy!7MD^b&I&bwz+@%!) zb~1!d5ub0avCtPfR)lN&3eD&DwDmXp)|WGBiyKNMUBq3G0EXayjj&|9Ul7*P2V;{m zTx!YmQYnXtzRWH7O^-?@Ts<-63UHE$DOC$-ws%WAbSfj^+Q-t6;BVX$8#1e?O63_m zBdSuwniU_n^IUHvIZ99u1yOytRcOLIB0YxM$HJs?3kQ!l*;dP2>lgR_e@ur$j4#OL zzz?~g(}e3_5>T=<_Ydh%Z*ytplEoViPA$hRM`kj3CRDS=7)3O904!zIDQldfwXAamzpB5@$^)QN{m&BW|m2z1CS* zq`vr4Vj~pMS*r~mVxl5O_yjA+YC)*{m|=VWSz}rAm{&hyH%<819(FdG<`e^-B;G(u z(;y5bmyf{C*?pBxRHkn9;kQuHysUO68J?*Zg==T(l%>ehpjJTML{JCbhk9~hl#Vr= ziGB;pK`a(oj|aYDG1}Xd6z*Ml&P~J9Dpd5mJ@G5M{R9`_k3Wf}DSC9_z)L!080_Ud zc0z~4-@~2^hCD~D?Oc=))<-TNe(28sa+qj>BIV>osr-o~@}%J-=VYU%ZzrUbb&AQH z1ehkXDEIivC;UpJJZah{w)bBpm|R~ojCp7~B`+x&CTh=`VzkWHZ|GCOoxgMzufeBtt(pL>;Ujg?GAqk@ zK_Jt&7KD5YXNRk@L2#&P%<Cnrg*p9R8ZSSCoA$B&` z|318BXYKZ3@vNfPxUkov5mhZ=oFoC;&`dI_FA)?M##Cqbp+i3?KbQP__CZ8naLog` zVX!cU7QvrlY~7c(xKW9Yn9k;*`zCSWTn}QmDOdeSM@{WZD2=WW-OSA+`XtiC4JIi8 z6M5F$gvGE3v*-eIxP}-A4&oXxDsi6j2?*WeaWgAbH)fz?Afb_GyYav~+*lxZ-;gfe z(gSip>h4`p1h~=wqd|RUpYBR(*uG72j`F}e)uqzj*Vfr~@b8peuXM<=H&)Q^S-;Kr zCfT2`UAb97PS5fsxqiQkY14j;cRUas1y(x`r{!d5HkjlvfCi`xsIW z;tMw}4vxOZ#$W)ibwww!gwR4&f=1pea1SWap981hsH^&fdPH`o(BOJBWj7DKFn^b8 zUF*HZ2>{?7WJ|t7(FLU3d>7b-ve!=_zP2priiWOn$ASFdv!(xXcT?$DA14e?;6dYy zt-1i9$a?(cy#8u#5UhkIG1;jKq;}Wcg2f;B%Jt>Tu&?m%ERGHuWs!H(ihF133-i!C2d^o?Z(nRfWb3C(lcr$A5u%Pv#HJ7o ze{O%}5?LC+>TL2ff_VFhjglWkelNV-@137l^%C24>8Yt}O4K2niSIl%onYO`=nW7- zC{Gs8@NzIxC<5)f?B4kCf(fHuvz2yBN8V&-;SO+N7mwzui#Fz z1(#|F_cgiaDz7e*7{c;{^9GGrs7q2%=loFWfeprajvkcyIbQ{m%_Z#6baJ4S0k&a9 zh)>qW(`c#_SZU&PM>qdqtn@c{_lU_tG7!36q3jY9G14;MZt5_k;Swz+klep%3^JIe zES*m>omlsgg$UNamVuF>gFGU%z1^fs@?t(95^1WEm~h>w`duH%=6$wC3Cy0Q^hKh| zQEJrX0OKOhV*Uox7EiDK=<0dnZ^FevTt^ivsPH_i{E|d8yFGnj(?ORVe7#^~-ToAk zWgztbzDm};^tB)}jwt8EO}*f|R}uD+Jl#~8>SQusNffH|-WqvE2?#|Hzd;1k%#j2HH{uspKz zq;5pOvbbv396<$3)f&fdZg|jQ8ZG5V)+bwjW$kbSmx(Xv*5?oQVpxkg%td1cYzgUU z#|%JE!A!O4kLfABkw3*ZT`ts2f$$`;NA)NQ4&?VU7Ci7~Y?A*eHD*!iS+E?L`oYnC zMUcpY2`m0Hi)8(7osasaBb&(QA|O7fRbz<>BPHEOmByD3BA(^_<6FW6SJKeH$bcPo z)0pHCE%L)s>UD|>19m(*4y{uW!GMf?v(A+%Fua)xH2}lN^0sOG>xKdCY5Uy<8O9?(jzo=)r*y~spMT)q*&aMRD|Z39 zIU&g%GvGUHS5n~hSU;6h6`}fwFnS9SOYGMbcJ`mmdv4Sn%*1Ua6=QHLu&DRs;Mn=X z!ou1|M7z-K0Uc!m2S)hcF!Q2;Tj1C6?(mZ2`=4rr1RElCTlG?3u6zm=?u zYF;S{9A5^X!zzbXq1}Iei7sFoT54)$!uT_1?&F`@>D5y`#YgZ8y1JGC9xmA}Us#87 z%KNW+yw`I%jf?@CV?pH0z15VLG-9~%trO(7QR+Wh0ShhhjCP|Nf3%UNzOH8~lx{bAS(l0J(1!xtgkCF&Ca!#4Pj! zI2$YvU^c1s9v#9};yO%7(z9eMVQ;E6K0s)5T!8ToJ5Y4;gy2{=%@+wh1lOeAD{OCY zW4{9l&H*IC@KbLSY+zg4KRu_;9KC~E-f(`KdR(!x{TJ@nHzWa)i=m0|E8m&OlRW- zHr)9a)ULR+odd%kH6#%1`}=W*NxEF{e9o|zJZq5~ozbD@~?@6nbt*? zu8b!TGpek&Ar*RI*t%ipto!*?XjFo<=Y)9SIP~BFAa;@dYwTU|XST5UW4Pp&1HMtg z;yn?18XE66pr3sJ3E>i1lY8=m@*p!4B-J}%J~6K#^`9*p>P9sit!&P#FI(|arBZYnFD1tV8G0dY7V>2dN%m`j=)@`V#50a8@#yxu2 zjmgIOYT8)9RqPrQLMQR5-;}Y4OH}DZPqBNa3~ZX_JCUJMmomgb;{(WeEdIG!^g}xE zx2(4PH^06G++$C9%UvyypeSj9uk?iSirMbuJB(y}@XBJ^WOtTEowC56?m_{5q0!ys zubBSlIiP0rfdjfpDx{BmSzepUeEY}uE40Ahq`z0Nu>OcQZ4PYw`ap6w!t_Gq3)y zP{DHsqu_B0s24G-b3q~)_co^Y$O2vb7dsVBZk9!TT@7d5e@7$}9kurps6RqLRyc+? zB4bEnsDrQu{OE%U#-?=X{Dhu7kUn`gOskSVTe;{4vH@_G_*%f(DY0yu zt>^}9*-XnW)%ItK#$RISP(w@clnaAfqD9I{0PlO~YrPvXqkw!6x5FmPl+Nv0l?icw ztS%y=9V)u&JyO{Y|JdtTlg>R66-f%yM5j@eS&z7}ba1^95O=1oR0lkJd zl6MS}>m!>wbb5@%p$e7;_WaQ(6}6YQ<%FS{|~m6yg^8;NjiCX?Ea{`%uSLLAP zZYhxg1Eu{fW1Prb(~>wCXEV7*>|=2>{)vrkvURy9+GIErbDqy4G}J%UxxsGn3LCaW z6R}%>0Wo&7HKoHt&0J&4e^4SEf356)(FQSO&q~8~hf>%+*y29ifm>Sts4p``Lq~kd z%G{r%`Pcx_X?=LhL<)Md2gpf0WJQTdN{l#MiU!i(^nsi8Ku!Xb%K!UzG(Di zFBDv7&{BFe04L|A(Dh*B{HoAS#o-t)39jusoSyQ$%;Tit>7-mG>NDK3jS$Y!o3RJ? z!Ypuuesn9Y4>j7@%w4#>;->jYvcgl0L<7^?Qa!QU;tmsM2lk6zhbGX3I@AXMl`f0J z$iwiB81z|f(eXkMfCRHv?yZwUQ8(iUWp0J50kNANeCe<5pzhqmC?hU2j-1K2YHgM! zR}7Kc=2CwG3VkJWs0Eq^s*%<;hoCr0haukDQX^HXqRb}Tzyz^po{jQhBSCgzaVL#o zIK^_0t!|(^6anI|Hx3OTLB5kX-LgN?Yez@9_z;650%wtBX3zhE-QD*XPE=t*Y+N+c#hV`+WR~YIm0Y{Gijn6p7Cpl+4B=!Z zaKm0F&Z7b5EKxb~=cenw8`3B>N5sdnZeOp`GY;-j&*)Qb@kN>SS|SOIUO*eg<4T67z3e$VJ!&vV4v zRS*tCXy8giW8GiX5N#WMPy1&2pYqw(E7hwVR+j-ncO?{gTk|^S--sd|tkWsG4kJ}b z`)y(ZT9*ZQbCwt!574G@yqTk{(6}nT3%B0gc13h$#E@(tx$@T*4Vc|~mrjW{G@_9L zqrFMKQz}aT=qAY=Jcb~(^y0qH?)U_H0kwe*WTY)9>YzCh7ZLurV?^lD#0z65UT z0AC=k#L>{Wtb_#hD{25`K2wD$X$U$ZMgfx9&V}nMB{LipB-&;~FAyO?jfN8krZ4fd zkBvt>{t6K8{#n!H)!?o`1AL|B_#QgGG3DoCSSo_A$}hGm8w)qDtql+tJz@c)l~z;xne1-I** zk09l!Cuy-QjK3I1!ox%zGmtT>-+k&Jq(v@rdl#vyqM8Q>>5N!v!LH`gvTW8rN{|BI z?4nW}!&DT&0LTkfh}P8yS@oB1E-##eL1K-Kn@0>c9r&C!c0L>64K|tWbI$|fYr7%^ zp^D?gvr#ojX-ex%byTLh#)*il1^i+8?~(oRKYfQuFESOAoNfZ{ZHH64>GW7oR+>62 zA3Mk^N#PKNTHVtjLY~&PrEZvtFFD(>=PDo8y_OVKI4*XazA#F6X+2hj|?v_vGt{7uqK930?571Y~Fn%=m5kR7Lg`=bZdKagD1R^6d~ot);L0^yWyR(DQpJC55R<_w33hzKzQE2_hPV3!z= z0F;!LsroMuJEt)AJUp}wIFp%CrtIqFSvUYuVrq%AsDVg$%uEewkYS`+X~Dc$w#7v2 ziUAv%)>zSD{>sq)4^3UDNy&EXDvwl(3?{nWzb|&j!i96L>in+N^9L__buIU7T~uCX zHke%{kgwX-jxfMX3%&mPPTPo}rnRHTOPXQlylF;;3pBaiBK2?G?lKnZGXmy7hy11F z0PoM3%buM{EPCSf;u@&2C~wuOi%rH44VohYq64)>iXw`-M8_yfb4)4Pq2tJET`TJD zl{S)I$HbWH`T}O}d&iJNF%b^P-9JLAV59aS7gP|)4_I8NpU*5UO4;gUOo$+Pja)V5 zX5>zvGQ_CldxkggXlF5FJofZ-7nY@w^Wjik-w7Md)AFL-1x3*n4oz zIokYtpjfwyqsVS4#Y`zsU`}y;5Cp`Cyb=(gm=7v!~}G!DJz8l z8u{xkJzusf8b%-GGy#&Z3O`TV0JXiJDf~L9N-9B7x!bNOg}-(*uX>!5R(y{RHAiwi zRyPaM+c#T`hZb{Dn*;0Aoz&b(M7zwIMavPU0XJGo;ym7 zZbs zPo*1+CBetd(NFiPnIT>`!aFi)qC|uE2Mu=vYx+BO&X1a$h(nWX)A2{3xWX{(ocpKC zBnfmHJ(L8(OGP@im#GE>%VYUWPyoFf@P)h5%-r|2;p9i1K$7IE3tB@J_Wlm|O1ZE$ zXC=M_0}h_-b{>A3HJYJzy-^fXkT))-Ct{~ zk)Bt)ilW$K$V(|lmbvumue020aTZ|>Xgt7{cB=oabNNsAdZlq>`BenZu{=>g4WdOO zlc&@}OEB~#eHwPg7qRxlQ=pf8u~p~+0{yqbzsI3eh}8#=PqF$6Y1H2wJq;|Dh)d-r zW5hF_#7?>uHS$ik+i735p>VYURfT}lTyygJGm%$J7#~6(*zJR8qADQ? z-OkaP``G~q56VpQLVs_?xDCPT6D^@ftZ0}e6IOBgH?%>(;@Zg%YRmA4&pKJ?IGHXn zhlqm9PE7M)Qhf&ckBdfm^4couij2lz&Lx?gd6^VZQM9+|5fdG{mC39{=j))tpY5hc z9i<&{n7@^p2ri$L0(0pNKnRt=u}T9RLE#-J6bqSae7Q~S<^%&AEs73;dJv&ML9?5q znzm|szzMBsiGVRV8||=bKEWnF!2l8%AH~4rP$OiK^u*jBS>r$a4UDTF6j@H)aXWal z!8U~1Q{+_H+Mxm{=Qb-}W;k z|E`-b(}&${l0gulJW6{PRd9r2L(pCIwcQ`o4Q$~X(at0RMzmzqr#qgT^{N}oo0U|AXQcD zu3*=FX{Ga<_qyUGO(P7I&D5vNHE*P%+p48y|M)=6>LwQ*29F|`QyL>RE!X#Gy^R?O zO>lG#HV+Zr6N3vaxiUtqHN6pu&pb$f{E6Y9u1NZ_ZL zjW3@|5}L{MMh#O|M5^%pPlkz~7d(Ebe& zlV(t8D$~q=E1FlCa`}WtVEdWisSn;tpRA}#xNA*ZI6*)2fkqs0=sz zKw<9_v#E|jo&hCUr>Hi=Ss{4G#*tM9sH1voTYd6dRzLE?8u_Y&frhVv-M$OKShSlM z{25Aj%@nDq)G2r*7#xCm_z9@7V%{(}^^!R? zT6-khA@Yzb8A16`4^3Yl!o)8X+?Gxx%tM^wEpn)@;zQDJZ3@wpTW^c_I_Zj56ZLKi zNGwVy;T|6kYhGNsl(Wf*6daA-l3NdAL&kyblDku-+pNgHPMv->W2QVP)<;MU-2Fp- z!R|&Cqg+l5h#z9czzK@a{$&^KqSN;Z@cS{-oGkcQsFdD<-HeQzQO@`v9+Ah;+0)c> zSJ51A>P1^#R6e`|z1t-dPm)8ctK$S8v196m%a}yfZ&Deq3lftWsnfM~qW^pJb#3@% ze4d9J*>jj2%V3+^0bAZel^|_mvnA#|78{xmHu0V6%t-)mZU3HN;XggOyyRgp}r zt8@>}!Q}g%{Y~#hBH+10A30K6%<4~VJ&b_y5G1j3&QWemiWCnJqc;iQLp4y8h2|?< z>3RioA28XzOW;4NPVr#Q`0DvK~grsXVkBWR5!EW#R6w&T9T)B`T_o3F`{zpA26O_c5iUF+rD!4 z2|cjD5`v_Zr)!aI0XVWv;9;`IhKX0rpu*iVhX1T7{OXJsA#Ao?+~If_>Rp=7At<%Y ztG>tYYoyKkm#>6GF)!Y43-)wv{|hzW!p z>J=_if+wWKsGit7%LEp;9%WU>*ZNXyp93kUDA8N{6#UMgF3TR>v)+GUb41tCNJsqm zn(<4i2(YN#|CXfA@bT{!Vuz`!Aa+O*xv$<<5ipUuaa!Q(VM^lL1oK;xWqX~0vo3(qDKDKP|_ zn4>ML7u}<2)_)Hy_K@_71=Kz#hpv-rS%GLwfi{s}S?V>j5gjN3eu1@EP4q-G9xN5d z5QxcHjWI#{*sm2PWdpy-cW8S4T)G!-v^o=CoESPIE!<@!Q~VmqAXgMk*=dpgpvSNM z-TJd@>kDpoB&&0(W^E>J3MuG?YSsNZr_Uite+uEkt8Al)6XzF}si$^$tg7Ko zKp)26I3Dh&HS%dUyiDsWEwaP0PefacUGD1274?XuooIu6g5=RoWO}>DYWXOp(KL$X zF1TIEXmXMuV$15wIkR0NfaM03JbK`V%jqN8in$l=d&KnoUoy;~yY$r|zXnV(CuT`E zes8B96f*6m7EjMlC`Ww)C&;4ne!cBR8qUhKlW$>b+0Q(-TAx=mfWu3)JtX7I-FFPn z+}^Ve@-;_NtOj??;J-;2Y=PB23@P?ljWRmT2FWV)69EsK>lNrO^4&E$zHeh0Ix|&$xR}~yeIpHnN0-iDdXdmQ?qsI0iiIJ&u#fft$>iabl zQX8PLeXUn3;v$BwIUp!_*eBLx^RU#5ccmbbZ5v=6c=O{992R(Y*Z*hu8DU;ENu+tJ zM>M4kaiP(C|6y~&_L_TcOwqV^YpxFpiE&Go4P)y$!G3b-g}IA4z=k{tH1jxLl%zgd zZWNInxuJ+Da*eWioX(5pqMY4~Ur%MXP7XZU5uBOOXQ2ChP1RyzT?cgE4CV83~HGhXrH!t9s` zC+CgtL3DYUH*`)|*OFEQ_F zodPZigJZZQInhTKyuC83>ova1g{lmp2Uk>f z8;8!-=`z&R*n*0VmV=QMuYS}M4fq46$pO9GJ7waU4)%RiD zT0E*)>>!k^Q(w$E52DQE=~a#xdw`@T9%t2A&F4vS0$r)+Tt=@ z&;?#>=Ax%FxJNbiKmy<2+`bjWX=?Zf0*Y<_d}x@fO@W5?y9Oj%wrp+7d@N~VYT%US zRj&9M*`a{xH*y}p2QkPB1pl1s@u<*GO+o46h7q@l8vAN-(; zNFY22`J5(o7k`*~=TtTv9M$#XAfF|Xof*fx37!1ZYeE4Vf}1H-x1^v%YW08NBMdVi z1}#i=v@fKTD_R_o{oHzxKA^O+0r*<0h=s0| z^ahRF9TAp{9SXvQwOhn#t*O-GS*Q@RAW$spIemeV#!Tfwyt|@>wXy|R5w(em(1-0p zkzxW~vH=LUK4GGD7YmGm_N829^$qjL6_vCjpkIv3`Nw6zUul{e-yzRctCD-Zh|JK#$ z_w0Fb0P2TZ@(h7$k!l(pGq5D*a(SBy6zE^P+oy{3z@l1s`NI~(A4^&+yem=K`|{i6 z>+@K@`6?E;^=rryX8DYk2`QIn&(rCsKQD7Pb$hw4OBpW59}>b~-8U6Y2%4w9&|qsI zfAK6Fz}$lN;i&SdO?T4X#eV-sgW6qVxdnkvh53`p35_<-*%nFHS)H;o&$xWCza zu$1xH(9{Xr|Y9*~(ANKNmC88K@`-c&v4zx=|)SQU|ZSc8t(GwWB-t5^U<~ zsmdH?Py}bpsti1xtND*vj)LxzCPF&27gZzq%#|#bY@s$p5!Wo>4@jff$# zg2Qz>MxEIM?2Bt>nr`Bd)oq~{zm4-C;#j3II%z1+R!&t~;|Ij^KRmubzvn%IW`d*5 zY!pSO0X9BunewzH z&?4RHT2b0?&|sk&Dr08Tq+HkB(?m$L7qM`fCH23(1$^Nxj4DSgk4rvn&XmBi_F|+J#5s>Se=CO=$#rk=bZF312 zG;CF>366G;pl|L@AJ>j`N3;ZmIDWgiiW^?~Dkhf06o2%izHkyg5`tf{{-$gD6KRv2#A5DavfwaDh% zV@tiQW2CrFL%)PaU;;5KL?z}kvXiRYxzp}jc7TjH(Qkqjy|6vx6JT+3`GxG zOddJ0@XPLE8rIsN`;d&hcMX~Iuhn6tPjnT(furCns4E!OxAak_q6JjU>i8;Pi2b3n z+DN z3{c*ek)`c&Ofsqn5ll8r%sL>fl0a*xd1R!sL@q$0=%|Yj9I3g=>cxzSt}{tcuziFk zB`X2}GLad@*}&Or>jVsA{BcNkiuDW5xI+Ur^N_AbL-4bQTLGpe=}uliZ>~@VH53$g zOfHkqJVV+7+Rm~jHdm4pt7MXnixQb7D^PD4BwgY7k4N8O7hbu(-4s7@F8mEy;hFUjur^9k9U$tiI>)UGFBIh?HRELk>BaANDD zV*H(C83H?^u2(-jk6}&8IHv8>6r_bjSeS$ySyA%Sf2g6{A_XW3wj4LjtTu7EF^G$6 zuKh~gdGbAVG0j-B_tBPF@>5CgbSt5Hz&Ot0xB-jCw%7La{vxVfUb>7q@?wNy15n;{ z5d08nsmZS^EN|hn8|*S+^qiiwESxF^JMQ z31U?KPxh{=Bn6zB@I zfxI#S)M-t*G^G^QY#{FaZf%NWdWggY+uv~uni+~Cd^(F;_O^%Kop@m?00H6I;xaGq z2Cn3Ide02dE|u>iohRu)+X!*(J|5ss@FKvW6k0D8*I4xYuK63m9SH1L)B04=bAd(}P)oGplcpLQnKEFfu+%W) zlVdRr?Z5V8^&6$#?LJd+B5yybX?0cZjGo9j9|UfA1O2cE)XQJEM0+8!eJ3cOntIbE z%=A@%ETOa{3CkK32BAldCK8q=z9dOZPNNG4p1rEqM8B4EZ%}a*Tkx;Ou6WG^4a~0Y z+c{8B8z^dA(ZeX^5@!%l5BZ6rRI(hlXvjv7fw=&P1DLETrW*6YRwEPi z9YzryEH0l!!(}F^xXwoz?NICCt%UYW#}rIqlXi$^kM<-$7es&M^s&QrU6tk^ZkMDY z$ZP5jZX0s{yg-RUCZy3hbq;1@AWRJU3GU%jXEYbAH&19%9)Ob90sW!R1{BAzxvh`; zU&U^$VC68KeA>LD6!LiLH(lp}#*q+oy8k8AXx-Jdc?bc0NU-C)(%Dw$D;iC&Is&`F zM1Dx>1++ucGDCF)xelcWx9+?ge8#(tM1KNM5X?i*Cy=Gfc>;X$$I@S(Tg%EuYY^4` zqCRnEghM;fvrIdA@t#X)7Hr&oquMRW1T?PRPq-=GITRX8!xCd^sH&qfS!fOln*TZ7 z8uSHJSp{@KI7gGiKXqj8D_k=g12Y2G_Xv$BeSd{w;uq5fe1f}3u|Izv9s#Pry;Bvw zjwxxPZD_tZ1(CLR|M(6?02oTwSDAJHm$vw@PBD6B``J&EJg0ALY)n||>j4u$L!|-t zD@F;~8H7_1yp_p*MH)!iJ}1jvKhTm&hOamyj4gO&O{{CA|56@1)U3HBtim<=GT%)W zTtP$Pste5pulk*VfcIV92|X8$0vL^g z(SH1ox{_te8u&s-w3Z)FC&?xvF^{}DxDO=en|{Db5>-Idz|!T(g3DH8A*Yi(Uh3$` znd1B2FVXsznd5-#8zhd%H=P&+`H+AJI>*&pnV4RyQHR!qmcR!<&p~#+2{#OKqbmI*1a~Z5e-A-ex`)RlI_kT|yQ#GKW#h2qmX4AEAgAfa@BrWC zHi637zK`Z>leil)#1<*2D;!I@`Nyf4KmV2KsKS>&<#oHJ%l@9|tt0c>l^AcCs3?XOyw{ z@?G!RP}A7o&t}dVB0Q{J|LCJtdN;q07jcroZ1^rNEkP9A(gD=4UEQd8{?l@m3_lW% z`*Gs=0RomKBlYpZ0{z;-s+}Hnwfsw%Ign*fg5h zPGj4)o20RA8;z|y{qDVg=Q%UaIcN6TYrR-&e_7}l`9SwutyqC`4D^;_wk(gx4K#uA zwa&GykC{a54+P6`!3~$f_R8zn0lw?wmE{YCjf^+*7S(QGC%v!j{gV>R-I?OCm`6Ds zpA0|^ghQi0b#^^D+Q6&MHK$?Lh+OBV5NORh)oEN1+)zDzB!W zNFTvfLZWh|K^`TmYLTtG71+H6Dq()d0MonaVuN zdTR`MW3**!pexUN&CjTm8mS$HZtLBFp-tQTk~F)9PG2uHz{kfdwnjIoUT+K@qNk5@ zHV~}pNc_TJzkp<3X)9b?0WULK)@K4Ep7Wk)mEXGln=m!q60?vH8PJtBU~Jt;K^^EGv7vba|FCz+zrY9F!+?Kp!)*O;sq4Rq4Iw^}t;FAFHe8H#L%yxcgt|$Ve4%8)+tN_%e!1#9z}^qB zNl@TE5dfd#KITe%`AoU_9JuxcJ-s$ex&izOU)kdX z2y_5$#ba-_2@*+0*VUOMSbE0ClfVQ1Rl348{Id0s@}FZOGdalOXGJwtJ4@!Er$*Yp zeaUu~FJVa~3c&i;o)=0JBfh-p9~%R{p?r#8{gK{U@2-HhfUe*+HdR6`u;)oH&HLQ} z!I5a%O0OR^&cX83Cp}guW@azM_H?Bh)>n0YZ2NcQbl8%X2AHu;+o;(`_GDW^K{}5}q(e-*Cl;8FM-ZGY_ z30fOml@8yj+Y#b(TW+T{C5C0UOm60HJ^^?Ye?y(t{pWGyNqNu0J@ z64q5zEkj_U!{Z%jGMgob9PT&=F?IsO?y{hVPl}y!{Z+oe1?rzwTxKL!_^5>~m{R;{ zis=O8?pgl)wC$MaIRWEg*Wd=_+hT3;b~Ak9etnk53PJnaLCvZf>fFGZ?Fud(^p5T# z>j)BN9w3esjdLx%(X5rF&1l z2~JUf?f%F?)gNK->s$0p?>Yr-#taA16TjY3-^T6PBBKss0ODDt7))Tng?zl4sZ~&$ zwAsI7GVc!sI2iO2Xm}EkrDs^mAE}6$U@TNqC|AWO6A*g1m!mcT!*W(!k5_*vrIbQK zNAq-|V?XFis0F&5t-1M9n=j|1|Fz!%j6LSkhXi**e&FDt&^Rrh{&tN*WYEt@RwsOb zUh+4h`oR+mgGj=_XZH0MC)k`L^?55FkT*`gxF2KtB2)K*!ehJXQ_(&+2K>K`i>0Qp5=V8V=ud=fc z{z*zjCGKrEAgYKud>n`41rNPSO&Z6jg8FTSi13wTNUe9wk%L%?PR$_Kt_6*hD#Q@w zTb!J(4gPyZ#dmqIK3R>#d2bkkf;bJtmwUD`u*8?EVrq@-XV5`kO9RZQEz!q5xi{JamF3 z4q;iMio^E(^dHaPIh|ieF>E#zG&d8hk`;?)+PR)jUMh^aC+JfP+)htL#;}adQ`Gq2 z_(_7l8{F*L;7dEHdR(zVDG49mzT8cJNt5E>%IsbnL#4OR;?3m6 zyimcC3Gq^5_1>da!jUSUM|8QmjUZM)*G@#y2oDQdWur@ti^?&-@Ou_X=MeVmzp*hJ z>Zxzwu`voOGeE|zD(Qr#a9!6qP8o>gXha2n(1bE!ayG9$TXghkBqDFQH^Bxc{^U=E)6Mq3LT_`N!h_2f^dEzQ?@G2X{lRHQ-*g#nj4#|6}2Hn4bQ4?UO6gp6v-$<_P;{AmWz+B1d43MQ6_Vl z{xI07*&ykeH{kTgNnhKTojKN%M)Ipyjm^J4INT@f_PWnQI_~gk^Ef`BrNc6mXhF7; zjQb+vx*yZtAh4JYxV#>1gaLbx6X4$5KrdDUgIT>wxeWFUux1F4=$Wz@8_dhfEAcibYCrwnR1@(h{AVI{*WPkiSvXl}!9@GOOMCOj0G zg3ktBtswnO|I)_B%FeEiHs0d8{5+n!U>FBIWx@>>!W8TG%1>o=_iYqP_ipNJt|#*6 zbJlW}rCS4HP$@9R8qe$Ug#NCy+QxEL;9<~A{u?i-siIlPN%f)41y30QmKQQu5_snJ z2qH+NP9ALmx}|-*LH(@;_@TMLSINO4-(NffMA&v0)i?ZHJA8H-j3>d!aiL$^oG9R4 z$+Y}eh%k@|Wu<#n$*F}(0;cNlMvG?GHsn0Zy)g4j4BUrzmZ%fFthQJ6>;|WZNf)QXkPir4iH`d#J-tr_#kmtBgV@Vhs)ct&pA#>a$BUJ~RoEfS>>EDQ0}srBy)O4joD` z*sJhCU(GP7)QoamaL+s0Nj%FtxdDCU!%VHlT3^-oMU{C33IdCsJHHs7RKVn9E>-uu zAy|(S7V!aUD_olD0(Uck+vEhvJH1lRP%mq<&icp(C{wsQLfjIWC&Io*%O~1u||*h~%q_J<6sSw^hM^dHZI$*~RUm zdQfvtY>qyzx6$qNzH`C){cY?xY`=K}o_IQagVFB%oTs)29stq&fD3Xa;|%Xu793+{ z^rAKGOH$AhDX8boBA#T*(#D!*^!fLR-?c+*eH082CA)Wuq4|M)C5I9pQcwGEC6D#& zKyJO$_h+sQwJMtyf*v*Stg2+Y^O5h*E=prbUsl7FmDa*Y#A2Q7DJ8$va(h1aGNcVz zfGUpuY$H;9eO#tlbnpZ+_-<8l4rETe04P3fMZ>Zo) zGsG7@5Uzz^RgZ%l(lg9#ObNTcMPI0Oud-=`yvw06Le-cz&L^{ZAoA{qlM9^r%&8md z?$w*d)%IYg+-=8@%HUfWa{Szh&Dl(pG4$WX4cV>iYE;`z177*T}j6tGJC(3*Jg zmbhdBG(;=pyTDvTHQg*eS*qSH@(D#;65s5Ngnr`rNM9}`d$D{UOkb)}fQk+8pH#uy zt9+xwIzaP+Al-P|lN8GPjz(lA0}=|I0eqk|KIdN=kR=n^BwGOf;+M=o&u(C$x9C zU+8il=d?yQb&8oXiG zx?4`wE;LB!bURm%n3V@#qKD0)l2;B?%;ufR{NCJ$%y(hk-Tw>2HK%&AWTUi@c&Gzt zCuc+zEN@76H~CspMHFAQqI%U|2ANnT1RxIP71BDE zwYSeY^d?;wEQ*-g%%H;t`1OAA-!l`bj5MyDTK;$sh}n^+Xzl zI#XMx>Qo=#I5~w~^>zBwZg&_$*NdW{SM_|o&=EEPEiLW!dvp8MC&Coz-@j3D;e*^j zS$;V)xp|k!pgPPkE!uTsBc1Fl`rquWg0%FwQs;A5|Swo(-zV z!vocJ8U!dvt*Drv9LiPb-hNKZL{7Mv^VdLthOOHZNy8RaUCB`4tL(Jip#=kbP61nwUv_*Cv@vm*yhUE zf~rwS8PgHkKOYtrcpP+OoPoh5hoREFZYV$=j;-rAHag@&kVoE~^fXU;>;z4Xl5PWo zS$FCe9TbkTCNJ^#fm}A|&}q(K5x;ky->#q(fLmB4!s8t(?aLvq@-A1V%aY;+PBg2phyu+4Fyy1HOpn5^?#DV7BrGDt1XGQ0`F^GdBt(JG; zrz^|a@|htkV&=08X-vQo-jj;<;@-XdSm4FKotzV^X-tuY>=V#qB=qBra}m1CIm&lK zcOMulJAKhqx07g%WPN(arOd3Mg|Z!+BqdU?WUICDby`|o2QH%m@xi9S zU~FF;tiag`%N64?sJFE&V1=@=K{`Mx$42;W{uPpwZaQXij#n$B?S2%Xij)<_{;k_K zt)5*kKh|e*)IE}6p2HdtJIxyYyFc!4%9+NW@Rjm-^XG01U^QI@A4fWq!$H`BbME#} zRFhCuv4>dH-zgQKO94VaaLU7maG(>fpP{puo8&9d%);U)@=VpV{1|NJqnCx(# zVMEnQts4pU7D+yGwfQquIax~I1&{z=@H<;C^IY!V5C-=e6tIy(*cUzRqc^mi>Rg4q z5s9MNJnM9ETG=w+@arY6pvq-%FLfT!jy0tP*VPYssM38bC_KnB+yb^w9Dluj##CLo z7|N6D>{T}q5GQdu5gQVo0@Y)Hsc7Y(=lD=X2;9HA^lVn&z{}N?gh@{2!hsy#J*@91 ztQGU=X59Nwb(KNgYoc{lzC?%uND5G(ry-)1_E+(S=Y@jY+DW@wQ~5>}tT~znZ|=7e ztBF+h+m{AzK}fXzKC5{X(4{Fj=D0{6?~}|140W1wsT_M_&A(BG+y$*^!ki#Sbx^4& zL)PSWuBY5_O6d50_2zINUPP7o+yce_$Gn=C($nyc0NXYLLHbAM64%^bg@&{DhwM+= z^o-!4i54APPS_KEl=un-mFWPWzTLIv1fB*P=O3;e5yj5j-<~H8!JD*y5@tLM(K=XN ze?Fg9JmbG8l`R!6*{4cvMPp_gF#t0%hWDdpXp&TTub@c&5MKj$HXBUt%01<|+4H1z zEGOo`s6%7w2MqNn=_KhBlp><2k@i_lQ3K`1VoGMyE{&5DDSr6c0*)xE_cbS@UhoEF z4v!yv=`B@$_h%kxw9??*QN;J37oOkG`TpfiWKvVv*xrgerEg_79S8r_)AEl{-Ge8 zG_Ihs=r#OfG-c{L=`(pv(4NI(NT4KeR=F=$$YQ)SOH^zzR1QwzlQhkprdDvLFnqdw z>=%u$pOk9@E;A;47T0p;)eg5|)cBz>YRs@80ZjGGW&6(NF%`{Nnr;}p(N+8^KuhK9 zNn7mU$H@xC=RkhgKS!Sqe_)3O^YQ)afcCQ6KdW3$ zvOr!Hv>o^D+pFPE8s5ToW&hS0VN_f#~5ERz3L$Yv;G(BAWb%cxYXzaVa_A| zAz}>GJMFVDo-ay{fM1vhXpjzEQPmx3%6?6|4swMsZtwLdB|5EsSf`7{0bF5LOrD-3 zwSUx_Js_i314}?#89VSvgKT(f*ohgh_Pe<>gT{1a<4q z;P~(@BOU}?17hQK`zZlR@C_{BbbYCdtv|+72EGa$r!i?eFQ^1*J0{>GD5JV3oGptQ z;moG@?z`ZEW&vJAU74|_;r#Y)+Dy?NYrqTDWz}0}VImCKmdy`@PlVof1B4e4{~)!p z7lH5?Cof^ciR_)V@WjtHUv|rob4p=*X;aSei|dUfiey!Pas{^t9AOI4{84O)*4EOA zMUFdleYILE=eL=U0p6~TCVnF$A@e;e!j}gjx3i6N=#`3z8`m7jlWHF=u>t%#x1)|u z1Y?&GShO!{$v5>Z>uk!7LVMIpEj|W?lojd-`QqG|BuGOFd84!j+w}|gy$=&vS!sB$ z!u@**5A~S*SE;Lt*#o~@$D@g` zBVI?Y92<@4Jd44spQ8PPYGHcUMv>EN>SRwl6Pqx;oi4{SDfJo@3AFbXQ;v@#pQt)- z6QiTyzfdS%B>UnZ*_4K27Y%WG6DA)d&a={kig@ zy2>v)KIB*&)Vvm|DKed8fVtx>a0t*{)UZ-cOEQ+{MdmCGb!)T3^hFuL$0)>Bmw$OT&$9oGL243aqgY(5My_Oo2_Iw|wWHm`_q z;obg3o3MC|47!3Jb1B>#`gP+aS9hcQEyv!#!B{YZkEdlG$7>ma>^quUQTQL=80`@ zg%~n4+PZy}ICPbV3>!*tmZli?#H-Y$_DD^ZV1W9w9F^VANL~5#3P#abph~Sf<-qhnzV{$tmth*L)qt_e5mq#5RGYs8AkT> z&^jucq{U~1TW9MAu`PZu;DAfyij2h;c}^T1zP9WK^F;pXoEzu**?Z&RD=a?CsXrpm zEdNS9*^C8z*FuI<6J-oxqIJ_b+7~e}k4KsOfM;+Ct4hjd7KFV#whSguj|80=p=E6b zl7P}lyHorPh16Cf^-h-k!IiUU@D>4ZPdSd-avDaH+XJNR`prbJgqojYQQ|IET>B8G zD_KS|lLT}FtKCoGVnvndoR{vWbB*?!ISu0~t(T`haQGn{tBHu7kwWo-*qaHhoI(83 zn>{PK0oJ=G&JeA*J@x{;*pIqBMlS|g(5W}f&M8#dih(-;#PZv#@LmdAJNkNRkRVnm z9lu~SBmlCU?J#`n?FU6M5Fe0ozF?Kmm`&_xl|biXslqrrePD?U(CbGYal%r!s>rzT z5Zb8C&7rJ@SI!+eOsC(dug7ARjjmoo^9bgHYGL00nWq>UlUptX4VHU))|3iA;B zf~Fyk6u(}}N+;UcH){WINS&81J=2DkRpo6y1~k7ZtD5mBVmiQ1;nxOXk)HU2+DUR##C%Q1;CCOsaWx1riF1U`w?(U^uWuXy z&jkn18VFJ1p2_7jHycia16b#48k_g@jm1hFR%ODawNqrbP8T={+71*hR?a+{?E0%9 z;+PVP6qVRjBh+?b07lrMX_p>Jo45bq`odq!7XO}4fX=w3Yk*Vw=6-rQ!~@JQn(R0F z!7!2aw1QSg{uTJO;u`y(jCVwGIRDYrQ*-so*YUpgyL8^?O6(TVeeUzI4=f-7j1+ir zg{yY_1M}&|#G&DyVCC)md{9RAP;k$w0;7Yj#vB{84ECb7AbE2cwQ>zQp3i&(@w>F$ z7<|aZe3Dhhd?kVKWzbA2GqGjbPM8KPwSjL*h^vE%V=byXmtKc6?BW&-OooWXy}BaW zHOhL?s+tvq_($jF`lCeJIzBnH=nlrOYRHVl{!52Q9$BgQ-!#P43h(JrqX=+CPwN}Wvu7K`0y$#Or@7*sP=c%AsC3py zB(Z|T2VjCvX{t)xh%HcVkzhX$?RhQasq`_ltmc=@*Ijzjk#^6Sz-WGxT*KqqLWKD) znWth%FH03ox9ECKPq6}?b{ggWa6p8v0+#2R-Q0I2Fk8`j6g&CHm58&gwN9$44;Y_# z#~G31A!Ml{R}~_EM~FMJfa1Ux!~oY!`rBY(b{folO&j{$av6(t!t#k zV1T0*uLTjmCqn;WKdoEt^knDzCB!bwTHrSm03PE>tqRJ%8gqzusfLfwY0O&Ye}7+e-q6d;U4?E#mpA7nR%=!{mV{JM+WO)#(9t?- z#k3MD$psrG_D{H?d?r`Tq>ROA+NYqHW5@asS8k_SOh(=T=d4W_R5=>+@z4u)XMU4i z{o>ta7f46?i5%?Qbn|hqb5nEobkieEjno!mp^U zF#X5Yu}-VNEd>D6;o<%`-Y2JH3-Cktf@Psy;tYfxbAVX$RU$A)k(z7ox=o(-pTwiR zR7WH{_8v=4i!q;fKuh5^S}QuCbUi}~lK;y@!TUy8L!!?7s1>T8*nj3(IE+Sj_|i2J zuP2a~PiaR4Qg2f(#+>8zAFu-u=D2)J!=Jp_S9|NdD&iDk(2a6rYN7I1CUZDW+rlVI znI=u=FdQk_aI}i2?p_+WepZ?)RV)e>khvBvYw-WEku&^8cjBF0wC&|BD{cI{MzCtB zH9Gt0v{UDNptdq4b5~N#UoO8$vt8_c+32v9-6qsdgjMYiB3vO)Ysn^f#MhDbgvVsFXf?5#WY zNxqf(#mfsG{?`F-u(9?8`sG#Ok_oxh z|G8ZdMcaG-jb^&k4C>hsA)FaWqK}g>A>z5?>PX8EiO`_*m#SO>!ZkwH4Oo6k<{11b z?dsZC>mfbLj;aPn^eGe~_v)S2|3)t=Xtpb>p84-l{+yvl8ORs)`m)4;w8zrT+$flc zbwKRRHA2hcHu&f8Mgnbka6@^0PzQmqb(+$L5UPXdWmZ-BpP~hlY5YT0=9U4Ke6yzKlHDQ>Y7{3jKT6M{l=RAGbELb`{w=-v_1V8(`owr4SlxHMAJx zNA>CYh1prm=)bpiy*lqfjtp!W0o@O3wg?_g^E{`ihu1(fhz-{MBM@|)=P9rqU*(% zzc^?te0D@Gb0cBK7}pKqJsFRs^M9gg+x;U)G<51r+Z$z7CSZOz;VtgOPfHpXBD1TJ z1IjD`;|1*aNV?8V80z=g>a}vpY?u+q7$8$>>^fVav4eB_o_-L-OO$<3hcJ%o03p|#D%(RQ`O>{w0Y!Dc$m}s7X{@ATpt99x^P9d0bXjn zUm=-PDtjld_OJta8rLrw@=NEg4$!O(mxw?JhTmwZL!i<(V{tt!#Tf}x@4 zm($ne${wtUM$|F?ov~?#AVg>HsF}T*R=DF40CtWD?p|HHh4JkUvfeQ=vCN97dSJ<&srKFvHRYk&mun*zVRDg?ys37$k|Lu_lB98r7 zmV0MDTWw%ZtBOOGiX&F8l8w`oq3~Czm(WoCrA9r+=FRgrvyS|6w^IY-rG~;wp&WjW zpU%@!t*-#YW7RSIC&Ym$v-94=iryPVeZ!c2XD5}coMkpPxf`>Cj|z7Hy44gRl6ff;DE5WGaVSDl=8=YNA^ zs0n!;{29{xcD2SM3>a&&o!2J)r%p&_6vic}Z6;M~mE@R0gb`DUM-j8NrW|(>^}^eoRCbi!<1$PAjS2h{+OyCY_? z;@O`QZ~>{G7eTN2q`#AW{?t^Rd4fctcxuh7jaqWUkJUM8-Yg&yOpyTZaMdorMb4kS z{)|H6gVl7br(h>&Jl~=8o@}}J0#)C~i`2s)gA~^O=6aRP@Qe>hTk{>}*RUQB#uih2 z3IcR7S8FGe<#3~+So~FDgM#9z1GSS_v#F~Hp9{}~(B^~vEsLl8=VrY)k{0f|9`&xT zUlv&19-m2|EB87@IdJt5J0Rj({hkMgTdR&T5%RwcVjom4()RcCmCEsm5{{#Uw`{(T zQ^sbCCM!W*J3!z0brm{V`_dSN4t{=73287H9VfOCX17_hjX)M$Ux@Vc1@9k$1|t6e z{E4h5j&QPMxiTZt+|zqT*vk;5W&8f8*h$LiMQA8CNY2gb=iOUpei(IQvEsHjm~(~T zjR}ltWR>pQe=uygF;e-b=sx#)$veze?1UZZEAiHS{}?6*AH$@`56PvX95_bgmQLTz zZ)O50y!OzwdV`dN>NwA*ctn$XowrySA{sNw7T5tk)Woo-JwBM2?ghb85vqp>A6Oy) zaW*I;+M#Zm0j0DuyjRF$2xx!>hen7ECKq+)rj+vI17-6e*S1?B^68|jsbE1laeLI9 zl1-V77svFIg5szerd!l);16=;!#6t^dkUMBjHY2s51!+nY{xbnP~|VqFy{0y`o@qT z@96sIvB^PnzzYS7UH)YOW%J-KibYn@us%(y*ed(AK>oG8Yu9u&uK6WOwPB%NDJsl4 zj@C|$_RLvpPhs&kaU}_J#h&x@jMnxcg@1)*YDKGdAy(EBod zx*8?e+5*dG0h^86Dh6kDZuZYf0E7H!?LEYOtc1=HG)JzEObMLX;AI~B2H}C=agMKe z1$s7NVA=$N$5eMOG5wWG8q3qrAX#G@<1@Kg5@F&>r4!F<_1 z0c?rRuswmfJ(3z5bCKkk6yrklxoun^^Zz`;@2lF$3QG0~I3?-l@ogXW;U*?>TbWKGm*C&njt&p*cw1oz(hE)0Hl&QLxY=vZOde42klHwj(So;Hm9kC+msCB5xLkJWJsArMHt+I z1eRTJp8i7Ap;w{=v7|r_>UjWU-RgNS26dODf)xme4Z_r8st2%>vAj2iYf-y8VCVMe zf*V8uq^De>^bg3n#Y5n;auJ7O1KM9iMy|O8Gq2J;z|Qjl+TIBBQH2?A+r45ez|UeP z9J9m=-{P$VDJ?;3g`;p46^~(Oq2ox$z~1T|)wbf40Xzg~Td%N_la9`w)x3A*KWO5} zUMiur3WF{IRnpPNLsg%$^N{_$i1|xMZUbAmNlVb^uDSNsGv4_K%yUfYvH{Y&pPZk3zj7qwBAP>Tg3l?yMa$;Ccc?#|D)YJ;yGL_B&;1sC z%P(xr@s~Yc1^Ww3>bJ5>jw+{O3lxLBc(_uQk?STC1}*5d4Xem4ZPfK|>%3@}zL*=k z?jEE*XO!)CT7c+q_N3$_Cfx>-^#{(Op1dEX^APIK34nZGBO79EcyV$k)ShF@_CgzM z;i3pej}B=6OQ;wDi52^(F3p5hcNU4NkgfDUMYMG2A5Mo30dHkV<^O&HwFBBX#=Hrr zziOm6bsKz62?kjStDZt6T!qGMA))t8twpSw=_wiF;{8I^ET#=jI*ty31(@Iz^x#gL zQsL;aAZBIGOo|g~j2+sZJH4@YuaDoFPQK5zea9l|wIDe5!;{u0iQ6_AYjrS-y#>43 z+Z6TfFLmW?OzzE&W`0xm<~c*a%p|6?+Mpp4YK$UQZalac@CcY9WAlZ#Iu2V%K$QUB zKCE~){hke~502|t3;;XUNwQ~Opt(C#Am2zEu=?V!mt#-)H^#I`;6ArV!&O6n!s zwKzyqf?Is%l5rGwV>Y~pDED;;3jV=Hw3Bhz2FXpb=AUH zOxowrkh!pQ7#lshF!AXC0*A2F{cfVuQ1FOmR_F??T9v?;qGPdpgrNNV4F3oI}*YTw`C z&zbsyH77j-_I;`FqL0DZepUNs*KreNU-HO+N%~XU?+2rfG97&7@H$Ykn6y4h_6tGf z9SN8k@PT81G4=gi^QR`ufDxhwV;lA^@UdlsJM3vZ3SXsccITiv}mkSBer?{mrnS`{gKAps_#Y@@m-{q=Z; z-SC&h#kKW3{vplIui5YzM37wu6vw6m#HZUxib zw6frobGd&AuuM6Vu#vId<@O*Kg|f{jZ2bUr$8g~62ZLSuDyr7W*Vr*tO}2O9*u#We ze?zp5mp^Mm*1xp_{c)L4e@{b0I|ahIAJbz|0ETVa!O5m$$=14qlpi;M6x|TEl1`=o zAu-W{Ex9q3QgcIUd)5HA4LFN!(Lxrq>C)W`UIkxzs8qcyg)lyIKI^CcJ?9Ez25%0s zL}8L(=L67wlLz(52H`+5b}}VnVY9$^mFY8Ue~Z;4wd`Umi=pOHS+7Deumoo;LuG*l4esYoKy-#jH11Y@A~u0vtd( zPfxG%;`Wf{qSPg>W?Trq4)?mGK3cpKr10X$Gx^gdVCCxtn=ks&5Mtfd{`RtO$9-250t5DZ;)&beg?q8c1z}y37O6xo zR|y|vJ^IVQ2ck8}h>NIy`DE)2MY7u~D1cwB-v>6gbPj1>;$jk^4`C7)#$mf|jiI+! z(y@f8$JvzjqZLxAM@P6ia{s|7M`q?>P?F7>mfADG>+Ci-<^micqyhi-gf@1(!^c(1xD+wIF;907J5MgjE`{Aq}Cq7aqC_ z01En+7+et1#SUEuXny8iKrLyC5!*ziGM9n5H9bO zPu#dfxUC2Zsm_fuw5p#R0+Bsmc4@q8N${S$@HB#&A2xO!epzV6@~&gR0_ah?Ylhkb z49GEpZ30)RBDL$+9dJd(rcD0$>G~UUSw^c60e%dTpixgM5}d*u26#aC*lJ0q2CvB) zXC=lh1z3A?&gq(l2pT2PE|tRW1QNcTOJ`uWYGA*tZ`cz?PEQZbq4o>mE_{jZj|tS@ z%|7&2BCT(QF_!|p14_HNs5wMLh%j<2&h3M_R##Tjp}eyEM@34(iaP-mppCH_;*%s% zOnZ7rwBXXkwf@xJHUBcWDuU9_8Wq1y&SzvVx)IW9(QE+HilPdKao>XtJ4BO{#CMXy zP4}1>%YKcG!C%|Qbx+xaH((z5x<~m;B!m`3*ABT)rK89;i1U2iS_8WEgQyGh!8KKpN)V;UO zBJWacH^vlfI6#ef%9iFkWmGi;W8#sM%@Jl4vm?PYrhJNC+SzjF8e`#MP^0$Z2{Ii* zD=;+LsmWN~yY7soz+qE7y}VB#`MiARQqVxVZIpKo=GK``Wt`^4UYyE3f)RUy9+qyr1F^9^8!QYtl7eU+Q;z8-}md5N6x2X?#+~g z+;m^~9A`Vkf9{z!&fA?!G-8Q1CHMo};{49Hqe{FGdSXYp?+W`x2hRgq(=5>TYo9v| z{Hz>Y6ZF2u`r6)np@)TscDwrwdc_>$0=-J8WgbS+w=6I%+y9bMEd(Q&6o_y3v}v{^ z%i^ue4t5#S+5PGmDmw(8HB~l?=#Fr$k1iCU7GL+jzjFy}y76g6?aRhC2bkPM#$toy zNUq6-1IYCc*m3`!gJHv_oIOj{wG)2@phj zO*s6aeAy)^zK-@k0dN_Q=9tyc9P8K;yY)34*iR87As8v4zg0uP>KmWvMakr_K0$0< z*9vpGt3RHpx_Sr;TUG$rr*k(U7d5$jaZ5I$F*~hyLGXvJ4a92d`o9Ir26ivN7vjc1 zUHqhm(i-bSy26Fwg4%J=MDXg+;hWA(rgGDt7*#WNL>{#lKy+K%j>9KPlZh+m`@YJs zYyF5wD~{~k3f zVv5pn8-e#DGE5oy0PE}I5tF^@ED+a42Q@-|5Rpxh+b{wOx0KXaV`i#x29?=ieTceY zjzCTYuz$zekdfqG04jvI5Uu(mv%`8xtaAuBoWwx_>>*qKR#sDE)j0`ogcuWRvv6zi zbVz8L5t3>;c}T3|x=tIFeW+7C1#C^{hhSf!a>fd32u)Z1&OFh2ZoKoatoL35;6@16 zByi(Piuz-~?LB261+`*FEi9y`a5J<>?0sc)18Z?Ak9V63QQAB~282Esp{C9Ylsl?+d4+ z+rk|tr>$F|F^O1fc?!CAcDyz}rjriaiE%RMC=#Ix5~@23-+=IJSClv_4lZ$8t})v! z&zYUpM)Stzi0RGdc@3Wn&=}{&GyZ$=z^YyyN>LW{36v6x1RB=G2amM@Mfini#$AIY@j4lSg_$N1E{^ zCZ}yBMea-C#b5{okD2Y(quPfXF8pfdk^!TO7^w4o1K>!Lz3LcJKL_AZG{}Aad7jyw zHXYMvF9LY6bn+3UdYj>1kdwBarFSE?{U=rI(jGKi2I6NFdpy zYV7-o=d@*VAvXo@M92f zLi8Vn+#BQsigflNE27A?W9&yQT<(6iYpq0d{s0woX`ssFu$}?nx#%6LHP0zPX8;od z8>DjtO70qNOJHf#=L5_R>n#xMC&Yn-Dg{^&5_|*%>@J8+yaz#;tQ9ptUJ{Kj2dcbt zOvGsyLo##OTei3SElLEw<`5S|aO!lhcj60WQ0`7*l{VwlWt;XO%{(0V* zV#@%qa`yWxLG^L?HlgZqU^CgPira&RWdNo@;1u9DB4{Xa#-1XKhk%Z#P4k-;bPtYe zUh}RM!Fyqws40nDYt?aHR1(Cy8@Fs6?{|5w^1FSv1YF+VDuoFIKMOl*_cpfvG5j>5 z!KV}URxfK zOrsg|9)%!m?N|&Dd>p9v%$Dh*`(2)!MR)^%@X}J8j2j-n;Dc^_-)sT8&k4_C=tEZTvYy8H=54 zXj7OS&puG!c!?712T_wj{E_6_o%=+LCxzD@3Gx2Q`JVSpgyGDro zK8O+Tn`^9xreHr{5Xwd(Opj7}raaOi;}y3XfDtMcr5Hdr91>nLB*5x5E>GOdJQ0x< zF3%djyJ3#M&U<&0%kyOCJ_`4WGLL(n6|NPnO6ZR~xz-h!@{O%uhEL(?zz9KDz!e@< z>ab4f{J46^W*dO6?gpaf^`b7u@^LL=@w*q-TYjXQ;Pj-#|&S;12Ko+(%2yk_Z z&siaGr+@h9<5$jK{9P7Y9MB8~wlp9j5^#C%Br!Q+`XvDb4uXQmyfDKNNbQ?9aJzcC6Ai7cEKY6e3YmmfhC2QYR|&kivuyC7lw{$d&rmQiGN5 z4}So+CTx(hSG#U_kXwUgzD~Jt>$&iA0G-o%-{u($<^hpH)*^<8z#A-Rn-#hK#eHMU%^W&0x2S^srq#P_so-op+wn4Hf{YssaolaEHlfogY1wGf-Jk-|#_G zo#!`gM*U!eW_+}#$4f!$9}$(^2jB+^&j{6OrEm_Yp^+O@YDqw`i7M=l)JBYkg2=|K zG<1~*h=@R!u#|u9QfLV7C6ukBHl4>5J1XrRpAP6c7FfP~_M!~}Mg}%S2Hw@PKlo47 zIvaJ}5Lf-BqM{g^_K_)UnrWc(^_-%$*MZ!W45k#h)nP=KqE$MPn(XHg1=1EGg~^jmSSo1F?H+qsC+la$az zWgZG55@>`BA3ZWC$|ArmT~nOI5CwG+LL<8H!tBpOyCZw*F)A}MbV)?6vw>X!vb?%_ znAvHmHQTNCnw&P*y4{Q`NVp8pFOE3JrlHS*2y}e>Qoxx5rh2*;6x*#Qp8^|jMi!tM~aN)ur zfTw$Z2K}cfS-!ZxgE5oG58t?{JwM*s6RH0M2|JfaJjMLbZerK)D@PY)6Y@ip{XT&@ z-#2EbWe$WFbr>Dnp@v`9X{U&f5enKHBkqh6QOg$Egij-HUk))jEPn%V0E>74gu8

5Wma&kW%^)J(!vVi1Reaa%Vx3#z5mYXh)4v~m^zD5Xcg5Oo`|8UMe%dylTF&JH;K-RImq2r^TNBM2h7iE(rU88sx~VW$x^ zFCOh!)3v&)wmLpqr>%CJWv#C=wQK3LSf{Pknbzgh*0#cOW>i9=Fyezi5_C{RbO!Q3 z(GkZW$Ri~8-rw#Yxq+Jy!cB5-lAG+$Ur9J;-``5OYu|IuZ}07UYhtfb1#$%WQ$c070Ilaer^<8TlzQ3z>%TwbuspVD_F4Z*u9W?%=Ca}Zd=2=opE=z1lwrkCn z?b@DIB=At2yCwn70$rV3QS`#OrG+z+yu@*5#=+CpDI6OND{w*Xq1F?+Vw`{FCDRlx z2Yw6He9(m|y_ym02_IT6I@xBR;FSf7MUL-cRM;<}^d7ZJg7j>wCEj#^_g~uhu?H_4 zKdwjT`}dD^(D&%wOYn@|MbW=nv-v&Hoqeq_W+8hOeMQm7KyOM5MDl<2V&iN5o-6O9IPx_jdFkVN216bdF6%YIgiZ}(a%Nv=^9A;rqH9G;(kBk zKM@-%lpgrcKqzzvFqy7|Gbgvc@HU`Fe?t+GVAXL<9Yr+b^pENv{xKAG}Sg%CvkZuhK;QmX89GDd?li3|4vtF zzyL^H1a8{Bwthz(2b)tUC@4tVccxbj5_cyUQT?~3n#Nmu|2((6XcfZM-QO#+S5#N} zvhSaFuHD&j&hmxR0-c=?A@YYYcS$1Z_;yQe!{f2B&Z{i^dRXPnnB(?`oS)Y{0x7S& z=y4Tpjy(q_cQn`5uLPJ{I%i^*pV0!x`$YQcN_N(4c_sFgyo%!69QqBx+o9@S%l=K| z{UB zP5-7ZD>)-0eB?=>@)ScJ!zlkd^Jv!f(TyuT%v^F=uID;m>UDdN=rgecC{smxeZp6& zTWTAZYOgoo`O6nh>Ij8e( zi{FdgG3i9U-CAA$=m38`Ci(%W?M(l#1Xch8=@!(kB6qb^*Z&}i%QGio&)7yt^V)`| zP*>1LO;&%>6z+^Ld0TEp;nFycH~skN`3;}7)HE&wy+z=qW1BO&3Hw#qAjr+lH4WGH zmDB)suX(-M`Fb-#bI;S6F7UMzk{Z0)If3hXCpCcgpWAp)Aa)0)_`V+N9@4b#jSoO$ zi`1K9lNwzcPW}h+?)AmHQ6z0{$PdR~dSZ?VRu(Vccc#BX;K2bVwFdEJjwQ8`V^?e= ztfZtQ(3;hLtEfIOz^gYDUKW+xZA^S(%&3|_A>%;!;*jr63#j|NBP4Id(_*Sy-ZzWR zqn!s47HOXoxn++E&jz&Z&i<~rgXKkaBFqOmP-z2wq`9WyAG-hjtBdAO52b~E*F$%R z=eF-$`-{PMPBACQSu*be-|0$4%i}maR=dKA*3)MGqjeA&S(eyFiJa1+py%@#P&&X< z9m><53*2En=0=Cy^0{AC@s=Y@MVYMdg^2DVJ@iz|Pq*$r)%iJ<#aDSwLU`WWllOLf zY|p-i$JvXEvePrM9hJ_`zQ6{&%=xT&VqqDLGV!y(J z0rov&&)Dqe65A-2TTy%^qK~5l z2XXqVIPPt(ZFoJ7V~rVCf)$0|5afH%_f&%ZC^UIaZ)vJ+T%VlPnW3`8Hp)$3R*;n$ z_U~7?EzX{kJ&CM$PVZ~2Y1kac(Z-Bv=T^*J;P6%eiw1FehpIjto)q}I4_9q!kK=eV zLM*YRh`eP5=V-`(0O6WJ1rS+JByx9i?Uu%bFV2`zE?7CQNYvc}EE!~oH4#t$a`&3X z<~WWuBg_(8ipg10T;e%>cmP8TdlFeM=v__K4Fjp4GbSbFRnDKSMD9iT{Xsmh2H2|T z?Ja8?UroaDOp;h)OF>dqz1UOl zeuQO%OlY5=+|yFr_@wn{O+A*_QrOheITI%aGQX?Joj~T`qDURMhgxd3tm&&0hB2um zue^AniryJVo3xYZMC5T_xz}bQrY=itV?cgIL0(t_-xt;28|0fAg?ByVVUM<_tq#tl zlKhJNjEGFWMwHtJwQawP0?$S~z0;nfr9Mk+V@Lv?CjfjZ!eaqH@~_sB+>B;O^g#!*=gwn}CAKjrx&v&*Alh@qI)ccL#p4lc6J$n{yrsqYE~2*!TnA(g zHjW@Q>LPz@JB7y{OKf9UPI=KhPxzZ5Y;mS*>bGnLp6UpM|NEoo-#C)MB^nc#{EGaH zh)!OHa4k?i@LrCwq)t`tX{p)#N*o896l95Q92jCzYOMXBt5kT*c2k<+(A8<*U8ivU zAo{2FB6S{m->$U{FDGh!W^}T|Hjd<#7cK(1d!SQ*PhvC56In;Ip4q-(!=VJkGX|o= zRy)I?KS22wP&Cwd3NQQc_m?GpCYYR(PTFW{xQ-DG9Vr zIDC~L-vQ<(EU?fb%4+ds)vh)5Zze3RNf~*SMFk2~C|3hH2@JhO(Um#v6QA9%VM929 zaZL)d#Af=GUsiab^W++Z8-d9Q480fSCEx|k>|AG`uE(aSrE?}``5BjYm6}Kya83fl ze*yY`qTYWs)zoiHU|cgsSYk8%3YL^)O6HNJcybe}i-yqAq%RTRErIo@tZ6Q8*klc7 zQ&)aj;e}yOmWaq=RDXL=^8@?$CdiMDMB1PJOQDxc+WPWj0{;J-npCSUJ&uT z1)vLn`M|kJdbzM5>kyupF!9g}+gEMxOw#L^G0GB~8Il=g^CzcyktKB1#FZwXF0MZZ z5or*#j)-nNn(j7Pi?8IEzN{cCGZeVQd0Z;!0+h>v>?FRV1KF&?YH-iAR5yN-#Md%o zoFz7sFuBW%r%0!}N=2%G%K$IQFBt;fR@mk#+d;R9OMAy|VGd00^5QAd;btQ;+X=G- z<^UHZ`9lgoM3mQ@$0|P@_=)}9Orls~Gf8pw;-c*IbSk4)a%DhZ7+?N#kR9OORq=Ls zjyDgx_O0W6!@;boS?L{LWaURZxm4h53Ks&G0%r~5GLBuDX(YU=wf2o)594c_al#Ut zNxE~cTsSQd2rp5gR8$uOUmAgBIN&3MR+JV&Tb%rC2X#5B5=00_YsaPy!&P)&4uGylq}D|u)t1amvRPs?!zx+?z5u0EM9P4#C%A-Q zu)@F|&{mM$C~XLBAfJi4&z#Wad)gKbYg?LkVBc^}8PBiC&*%tG$xcgi+0JRUM`W@? zz9K?4N;X0^=uD6t;Ot?(GCmmC>LBft_xX?|XGka~rl=alIKWEU}qUt?T(*=cR%yP-G$K+$40gF#U@N{8G`4Aa%ae zH+R-HzB|&3GUj9~v6+<8HH0!;tWM{P=p~5EM#&i^&*{bj1zJ$vQITJG>gpyOo!DT* zWKAlu#AZ@nens(@LaG-ETqNjhL}mlC6EtIPEc+bThU#|2+vZf?$xKh%{{C|t503Q0 zj2Q|`Y-Y?lx3q9Z*wv#MsLANnFQI}niUh?8tj$yV{s06pV){TSJZWTKi5 zOpKm7DOyYB0bh>(EEAXz{kk1E8vXh}v~WBeO;7-b5$QzrDA0Dio=$%wN?U9_-DzJs g+GY)cjXBZ(1M-5z@|txkQvd(}07*qoM6N<$f^+X6ssI20 literal 0 HcmV?d00001 diff --git a/services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/79511022-972b-4b60-a8d4-f7f883d1bf1b-teste.jpg b/services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/79511022-972b-4b60-a8d4-f7f883d1bf1b-teste.jpg new file mode 100644 index 0000000..06def72 --- /dev/null +++ b/services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/79511022-972b-4b60-a8d4-f7f883d1bf1b-teste.jpg @@ -0,0 +1 @@ +arquivo de teste diff --git a/services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/a6bf421e-f0b5-4c06-b4f1-dbf91a780934-teste.jpg b/services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/a6bf421e-f0b5-4c06-b4f1-dbf91a780934-teste.jpg new file mode 100644 index 0000000..06def72 --- /dev/null +++ b/services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/a6bf421e-f0b5-4c06-b4f1-dbf91a780934-teste.jpg @@ -0,0 +1 @@ +arquivo de teste diff --git a/services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/c8c8f11a-1a55-4b22-b0ee-fc60882401a3-teste.jpg b/services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/c8c8f11a-1a55-4b22-b0ee-fc60882401a3-teste.jpg new file mode 100644 index 0000000..06def72 --- /dev/null +++ b/services/backend-api/services/backend-api/src/main/resources/static/uploads/questions/c8c8f11a-1a55-4b22-b0ee-fc60882401a3-teste.jpg @@ -0,0 +1 @@ +arquivo de teste diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/MainApplication.java b/services/backend-api/src/main/java/ao/creativemode/kixi/MainApplication.java index 2eb8700..609304e 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/MainApplication.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/MainApplication.java @@ -1,13 +1,28 @@ package ao.creativemode.kixi; +import io.github.cdimascio.dotenv.Dotenv; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import java.util.HashMap; +import java.util.Map; + @SpringBootApplication public class MainApplication { public static void main(String[] args) { - SpringApplication.run(MainApplication.class, args); + Dotenv dotenv = Dotenv.configure() + .directory("./services/backend-api") + .ignoreIfMissing() + .load(); + + SpringApplication app = new SpringApplication(MainApplication.class); + + Map properties = new HashMap<>(); + dotenv.entries().forEach(entry -> properties.put(entry.getKey(), entry.getValue())); + + app.setDefaultProperties(properties); + app.run(args); } } diff --git a/services/backend-api/src/main/resources/db/migration/V1_create_term_table.sql b/services/backend-api/src/main/resources/db/migration/V10__create_term_table.sql similarity index 88% rename from services/backend-api/src/main/resources/db/migration/V1_create_term_table.sql rename to services/backend-api/src/main/resources/db/migration/V10__create_term_table.sql index 9fe94ca..733af90 100644 --- a/services/backend-api/src/main/resources/db/migration/V1_create_term_table.sql +++ b/services/backend-api/src/main/resources/db/migration/V10__create_term_table.sql @@ -4,7 +4,7 @@ CREATE TABLE IF NOT EXISTS terms ( name VARCHAR(255) NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, - deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, + deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL ); CREATE INDEX idx_terms_deleted_at ON terms(deleted_at); diff --git a/services/backend-api/src/main/resources/db/migration/V4__create_accounts_table.sql b/services/backend-api/src/main/resources/db/migration/V4__create_accounts_table.sql deleted file mode 100644 index 6e2d792..0000000 --- a/services/backend-api/src/main/resources/db/migration/V4__create_accounts_table.sql +++ /dev/null @@ -1,19 +0,0 @@ -CREATE TABLE accounts ( - id BIGSERIAL PRIMARY KEY, - username VARCHAR(100) NOT NULL UNIQUE, - email VARCHAR(255) NOT NULL UNIQUE, - password_hash VARCHAR(255) NOT NULL, - email_verified BOOLEAN DEFAULT FALSE, - active BOOLEAN DEFAULT TRUE, - last_login TIMESTAMP, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP, - CONSTRAINT uc_accounts_username UNIQUE (username), - CONSTRAINT uc_accounts_email UNIQUE (email) -); - -CREATE INDEX idx_accounts_username ON accounts(username); -CREATE INDEX idx_accounts_email ON accounts(email); -CREATE INDEX idx_accounts_deleted_at ON accounts(deleted_at); -CREATE INDEX idx_accounts_active ON accounts(active); diff --git a/services/backend-api/src/main/resources/db/migration/V2__create_roles_table.sql b/services/backend-api/src/main/resources/db/migration/V4__create_roles_table.sql similarity index 100% rename from services/backend-api/src/main/resources/db/migration/V2__create_roles_table.sql rename to services/backend-api/src/main/resources/db/migration/V4__create_roles_table.sql diff --git a/services/backend-api/src/main/resources/db/migration/V8__create_questions_table.sql b/services/backend-api/src/main/resources/db/migration/V8__create_questions_table.sql new file mode 100644 index 0000000..aa813ce --- /dev/null +++ b/services/backend-api/src/main/resources/db/migration/V8__create_questions_table.sql @@ -0,0 +1,21 @@ +CREATE TABLE questions ( + id BIGSERIAL PRIMARY KEY, + statement_id BIGINT NOT NULL, + number INTEGER NOT NULL, + text TEXT NOT NULL, + question_type VARCHAR(50) NOT NULL, + max_score DECIMAL(10, 2) NOT NULL, + order_index INTEGER, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + deleted_at TIMESTAMP + + -- Ensures a unique sequence of question numbers within a specific statement + -- CONSTRAINT uk_questions_statement_number UNIQUE (statement_id, number) +); + +-- Optimization for foreign key lookups +-- CREATE INDEX idx_questions_statement_id ON questions(statement_id); + +-- Partial index for Soft Delete performance (optimizes retrieval of active records) +CREATE INDEX idx_questions_deleted_at ON questions(deleted_at) WHERE deleted_at IS NULL; \ No newline at end of file From 68a89e9d0a7cbf8540e1b92aa745eb9bd8c2caf2 Mon Sep 17 00:00:00 2001 From: Erasmo-Veloso Date: Fri, 6 Feb 2026 09:10:53 +0100 Subject: [PATCH 39/48] feat: implementation questiom image crud --- .../controller/QuestionImageController.java | 127 ++++++++++++++ .../questionimage/QuestionImageRequest.java | 16 ++ .../questionimage/QuestionImageResponse.java | 14 ++ .../kixi/model/QuestionImage.java | 55 ++++++ .../repository/QuestionImageRepository.java | 22 +++ .../kixi/service/QuestionImageService.java | 166 ++++++++++++++++++ .../V14__create_question_images_table.sql | 20 +++ 7 files changed, 420 insertions(+) create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/controller/QuestionImageController.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/dto/questionimage/QuestionImageRequest.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/dto/questionimage/QuestionImageResponse.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/model/QuestionImage.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionImageRepository.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/service/QuestionImageService.java create mode 100644 services/backend-api/src/main/resources/db/migration/V14__create_question_images_table.sql diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/QuestionImageController.java b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/QuestionImageController.java new file mode 100644 index 0000000..fc23af5 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/QuestionImageController.java @@ -0,0 +1,127 @@ +package ao.creativemode.kixi.controller; + +import ao.creativemode.kixi.dto.questionimage.QuestionImageRequest; +import ao.creativemode.kixi.dto.questionimage.QuestionImageResponse; +import ao.creativemode.kixi.service.QuestionImageService; +import jakarta.validation.Valid; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.codec.multipart.FilePart; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.util.UriComponentsBuilder; +import reactor.core.publisher.Mono; +import java.net.URI; +import java.util.List; +import java.util.UUID; + +import static org.springframework.http.HttpStatus.NO_CONTENT; + +@RestController +@RequestMapping("/api/v1/question-images") +public class QuestionImageController { + + private final QuestionImageService service; + + public QuestionImageController(QuestionImageService service) { + this.service = service; + } + + /** + * Retrieves all active question images. + */ + @GetMapping + public Mono>> listAllActive() { + return service.findAllActive() + .collectList() + .map(ResponseEntity::ok); + } + + /** + * Retrieves images associated with a specific question. + */ + @GetMapping("/question/{questionId}") + public Mono>> listByQuestion(@PathVariable UUID questionId) { + return service.findByQuestionId(questionId) + .collectList() + .map(ResponseEntity::ok); + } + + /** + * Retrieves all soft-deleted images. + */ + @GetMapping("/trash") + public Mono>> listTrashed() { + return service.findAllDeleted() + .collectList() + .map(ResponseEntity::ok); + } + + /** + * Retrieves a single active image by ID. + */ + @GetMapping("/{id}") + public Mono> getById(@PathVariable Long id) { + return service.findByIdActive(id) + .map(ResponseEntity::ok); + } + + /** + * Creates a new question image entry by uploading a file. + * Consumes multipart/form-data to receive both metadata and the image file. + */ + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public Mono> create( + @RequestPart("data") @Valid QuestionImageRequest request, + @RequestPart("file") Mono filePartMono, + UriComponentsBuilder uriBuilder) { + + return service.createWithFile(request, filePartMono) + .map(created -> { + URI location = uriBuilder + .path("/api/v1/question-images/{id}") + .buildAndExpand(created.id()) + .toUri(); + + return ResponseEntity.created(location).body(created); + }); + } + + /** + * Updates an existing active image's metadata. + */ + @PutMapping("/{id}") + public Mono> update( + @PathVariable Long id, + @Valid @RequestBody QuestionImageRequest request) { + + return service.update(id, request) + .map(ResponseEntity::ok); + } + + /** + * Soft-deletes an image. + */ + @DeleteMapping("/{id}") + public Mono> softDelete(@PathVariable Long id) { + return service.softDelete(id) + .thenReturn(ResponseEntity.status(NO_CONTENT).build()); + } + + /** + * Restores a soft-deleted image. + */ + @PostMapping("/{id}/restore") + public Mono> restore(@PathVariable Long id) { + return service.restore(id) + .thenReturn(ResponseEntity.ok().build()); + } + + /** + * Permanently deletes an image from the database and storage. + */ + @DeleteMapping("/{id}/purge") + public Mono> hardDelete(@PathVariable Long id) { + return service.hardDelete(id) + .thenReturn(ResponseEntity.status(NO_CONTENT).build()); + } +} \ No newline at end of file diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/questionimage/QuestionImageRequest.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/questionimage/QuestionImageRequest.java new file mode 100644 index 0000000..6082418 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/questionimage/QuestionImageRequest.java @@ -0,0 +1,16 @@ +package ao.creativemode.kixi.dto.questionimage; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record QuestionImageRequest( + @NotNull(message = "Question ID is required") + Long questionId, + + @NotBlank(message = "Image URL is required") + String imageUrl, + + String caption, + + Integer orderIndex +) {} \ No newline at end of file diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/questionimage/QuestionImageResponse.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/questionimage/QuestionImageResponse.java new file mode 100644 index 0000000..527f5e0 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/questionimage/QuestionImageResponse.java @@ -0,0 +1,14 @@ +package ao.creativemode.kixi.dto.questionimage; + +import java.time.LocalDateTime; + +public record QuestionImageResponse( + Long id, + Long questionId, + String imageUrl, + String caption, + Integer orderIndex, + LocalDateTime createdAt, + LocalDateTime updatedAt, + LocalDateTime deletedAt +) {} \ No newline at end of file diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/model/QuestionImage.java b/services/backend-api/src/main/java/ao/creativemode/kixi/model/QuestionImage.java new file mode 100644 index 0000000..c73a61a --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/model/QuestionImage.java @@ -0,0 +1,55 @@ +package ao.creativemode.kixi.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@Table("question_images") +public class QuestionImage { + + @Id + private Long id; + + @Column("question_id") + private Long questionId; + + @Column("image_url") + private String imageUrl; + + @Column("caption") + private String caption; + + @Column("order_index") + private Integer orderIndex; + + @CreatedDate + @Column("created_at") + private LocalDateTime createdAt; + + @LastModifiedDate + @Column("updated_at") + private LocalDateTime updatedAt; + + @Column("deleted_at") + private LocalDateTime deletedAt; + + public QuestionImage() { + } + + public void markAsDeleted() { + this.deletedAt = LocalDateTime.now(); + } + + public void restore() { + this.deletedAt = null; + } + + public boolean isDeleted() { + return deletedAt != null; + } +} \ No newline at end of file diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionImageRepository.java b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionImageRepository.java new file mode 100644 index 0000000..8de7d45 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionImageRepository.java @@ -0,0 +1,22 @@ +package ao.creativemode.kixi.repository; + +import ao.creativemode.kixi.model.QuestionImage; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface QuestionImageRepository extends ReactiveCrudRepository { + + Flux findAllByDeletedAtIsNull(); + + Flux findAllByDeletedAtIsNotNull(); + + Mono findByIdAndDeletedAtIsNull(Long id); + + Mono findByIdAndDeletedAtIsNotNull(Long id); + + /** + * Busca todas as imagens associadas a uma questão específica que não foram deletadas. + */ + Flux findByQuestionIdAndDeletedAtIsNullOrderByOrderIndexAsc(UUID questionId); +} \ No newline at end of file diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/QuestionImageService.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/QuestionImageService.java new file mode 100644 index 0000000..8c4a25b --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/QuestionImageService.java @@ -0,0 +1,166 @@ +package ao.creativemode.kixi.service; + +import ao.creativemode.kixi.common.exception.ApiException; +import ao.creativemode.kixi.dto.questionimage.QuestionImageRequest; +import ao.creativemode.kixi.dto.questionimage.QuestionImageResponse; +import ao.creativemode.kixi.model.QuestionImage; +import ao.creativemode.kixi.repository.QuestionImageRepository; +import org.springframework.http.codec.multipart.FilePart; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.UUID; + +@Service +public class QuestionImageService { + + private final QuestionImageRepository repository; + + /** + * Physical path pointing to the static resources folder for Maven projects + */ + private final Path root = Paths.get("services/backend-api/src/main/resources/static/uploads/questions"); + + public QuestionImageService(QuestionImageRepository repository) { + this.repository = repository; + try { + // Ensure the physical directory exists on service startup + Files.createDirectories(root); + } catch (IOException e) { + throw new RuntimeException("Could not initialize folder for upload!"); + } + } + + /** + * Retrieves all active (non-deleted) question images + */ + public Flux findAllActive() { + return repository.findAllByDeletedAtIsNull() + .map(this::toResponse); + } + + /** + * Retrieves all soft-deleted question images + */ + public Flux findAllDeleted() { + return repository.findAllByDeletedAtIsNotNull() + .map(this::toResponse); + } + + /** + * Finds all images associated with a specific question that have not been deleted + */ + public Flux findByQuestionId(UUID questionId) { + return repository.findByQuestionIdAndDeletedAtIsNullOrderByOrderIndexAsc(questionId) + .map(this::toResponse); + } + + /** + * Retrieves a single active question image by its ID + */ + public Mono findByIdActive(Long id) { + return repository.findByIdAndDeletedAtIsNull(id) + .switchIfEmpty(Mono.error(ApiException.notFound("Question image not found"))) + .map(this::toResponse); + } + + /** + * Creates a new QuestionImage by saving the physical file to the resources folder + * and generating its public URL + */ + public Mono createWithFile(QuestionImageRequest dto, Mono filePartMono) { + return filePartMono.flatMap(filePart -> { + // Generate a unique filename to prevent overwriting + String filename = UUID.randomUUID() + "-" + filePart.filename(); + Path targetPath = this.root.resolve(filename); + + // Transfer the incoming file bytes to the physical target path + return filePart.transferTo(targetPath) + .then(Mono.defer(() -> { + QuestionImage entity = new QuestionImage(); + entity.setQuestionId(dto.questionId()); + + // Set the public URL path (mapped via WebFlux static resources) + entity.setImageUrl("/uploads/questions/" + filename); + entity.setCaption(dto.caption()); + entity.setOrderIndex(dto.orderIndex() != null ? dto.orderIndex() : 0); + entity.setDeletedAt(null); + + return repository.save(entity); + })); + }).map(this::toResponse); + } + + /** + * Updates metadata (caption, order) for an existing active question image + */ + public Mono update(Long id, QuestionImageRequest dto) { + return repository.findByIdAndDeletedAtIsNull(id) + .switchIfEmpty(Mono.error(ApiException.notFound("Question image not found"))) + .flatMap(entity -> { + entity.setCaption(dto.caption() != null ? dto.caption() : entity.getCaption()); + entity.setOrderIndex(dto.orderIndex() != null ? dto.orderIndex() : entity.getOrderIndex()); + entity.setUpdatedAt(LocalDateTime.now()); + + return repository.save(entity); + }) + .map(this::toResponse); + } + + /** + * Marks a question image as deleted (Soft Delete) + */ + public Mono softDelete(Long id) { + return repository.findByIdAndDeletedAtIsNull(id) + .switchIfEmpty(Mono.error(ApiException.notFound("Question image not found"))) + .flatMap(entity -> { + entity.markAsDeleted(); + return repository.save(entity); + }) + .then(); + } + + /** + * Restores a previously soft-deleted question image + */ + public Mono restore(Long id) { + return repository.findByIdAndDeletedAtIsNotNull(id) + .switchIfEmpty(Mono.error(ApiException.badRequest("Question image is not deleted"))) + .flatMap(entity -> { + entity.restore(); + return repository.save(entity); + }) + .then(); + } + + /** + * Permanently removes a question image from the database + */ + public Mono hardDelete(Long id) { + return repository.findByIdAndDeletedAtIsNotNull(id) + .switchIfEmpty(Mono.error(ApiException.badRequest("Only deleted images can be permanently removed"))) + .flatMap(repository::delete) + .then(); + } + + /** + * Converts the internal Entity to a Response DTO + */ + private QuestionImageResponse toResponse(QuestionImage entity) { + return new QuestionImageResponse( + entity.getId(), + entity.getQuestionId(), + entity.getImageUrl(), + entity.getCaption(), + entity.getOrderIndex(), + entity.getCreatedAt(), + entity.getUpdatedAt(), + entity.getDeletedAt()); + } +} \ No newline at end of file diff --git a/services/backend-api/src/main/resources/db/migration/V14__create_question_images_table.sql b/services/backend-api/src/main/resources/db/migration/V14__create_question_images_table.sql new file mode 100644 index 0000000..eef74ee --- /dev/null +++ b/services/backend-api/src/main/resources/db/migration/V14__create_question_images_table.sql @@ -0,0 +1,20 @@ +-- Create table for storing question-related images +CREATE TABLE question_images ( + id BIGSERIAL PRIMARY KEY, + question_id BIGINT NOT NULL, + image_url TEXT NOT NULL, + caption TEXT, + order_index INTEGER DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + + -- Foreign key constraint linking to the questions table + CONSTRAINT fk_question_images_question + FOREIGN KEY (question_id) + REFERENCES questions (id) + ON DELETE CASCADE +); + +-- Index to optimize lookups and filtering by question_id +CREATE INDEX idx_question_images_question_id ON question_images(question_id); \ No newline at end of file From 5cb2bef5bd0c5adc83d2be65baf773b6390f1730 Mon Sep 17 00:00:00 2001 From: Erasmo-Veloso Date: Fri, 6 Feb 2026 09:12:42 +0100 Subject: [PATCH 40/48] fix: main file --- .../ao/creativemode/kixi/MainApplication.java | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/MainApplication.java b/services/backend-api/src/main/java/ao/creativemode/kixi/MainApplication.java index 609304e..9543c8f 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/MainApplication.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/MainApplication.java @@ -1,28 +1,13 @@ package ao.creativemode.kixi; -import io.github.cdimascio.dotenv.Dotenv; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import java.util.HashMap; -import java.util.Map; - @SpringBootApplication public class MainApplication { public static void main(String[] args) { - Dotenv dotenv = Dotenv.configure() - .directory("./services/backend-api") - .ignoreIfMissing() - .load(); - - SpringApplication app = new SpringApplication(MainApplication.class); - - Map properties = new HashMap<>(); - dotenv.entries().forEach(entry -> properties.put(entry.getKey(), entry.getValue())); - - app.setDefaultProperties(properties); - app.run(args); + SpringApplication.run(MainApplication.class, args); } -} +} \ No newline at end of file From d78f86b9d2471175db87bf7e368e16e82a0e49cb Mon Sep 17 00:00:00 2001 From: Erasmo-Veloso Date: Fri, 6 Feb 2026 11:15:06 +0100 Subject: [PATCH 41/48] fix: question image request file --- .../kixi/dto/questionimage/QuestionImageRequest.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/questionimage/QuestionImageRequest.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/questionimage/QuestionImageRequest.java index 6082418..93c2fe5 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/questionimage/QuestionImageRequest.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/questionimage/QuestionImageRequest.java @@ -1,14 +1,15 @@ package ao.creativemode.kixi.dto.questionimage; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import java.util.UUID; +/** + * DTO for creating or updating QuestionImage metadata. + * Note: imageUrl is excluded from the request as it is generated by the server during upload. + */ public record QuestionImageRequest( @NotNull(message = "Question ID is required") - Long questionId, - - @NotBlank(message = "Image URL is required") - String imageUrl, + UUID questionId, String caption, From c67eacee6ba60b9cad4c1d93c7d375e865fcc165 Mon Sep 17 00:00:00 2001 From: Erasmo-Veloso Date: Wed, 4 Feb 2026 14:43:47 +0100 Subject: [PATCH 42/48] feat: implement question crud --- .../ao/creativemode/kixi/MainApplication.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/MainApplication.java b/services/backend-api/src/main/java/ao/creativemode/kixi/MainApplication.java index 9543c8f..de3db92 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/MainApplication.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/MainApplication.java @@ -1,13 +1,28 @@ package ao.creativemode.kixi; +import io.github.cdimascio.dotenv.Dotenv; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import java.util.HashMap; +import java.util.Map; + @SpringBootApplication public class MainApplication { public static void main(String[] args) { - SpringApplication.run(MainApplication.class, args); + Dotenv dotenv = Dotenv.configure() + .directory("./services/backend-api") + .ignoreIfMissing() + .load(); + + SpringApplication app = new SpringApplication(MainApplication.class); + + Map properties = new HashMap<>(); + dotenv.entries().forEach(entry -> properties.put(entry.getKey(), entry.getValue())); + + app.setDefaultProperties(properties); + app.run(args); } } \ No newline at end of file From 0d0c5f5a64d4a8c43a01b20fc3752b2a08d6e3e8 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Sat, 7 Feb 2026 11:31:09 +0100 Subject: [PATCH 43/48] feature: rb --- .../kixi/common/exception/GlobalExceptionHandler.java | 4 ++++ .../kixi/repository/QuestionImageRepository.java | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/common/exception/GlobalExceptionHandler.java b/services/backend-api/src/main/java/ao/creativemode/kixi/common/exception/GlobalExceptionHandler.java index c208121..f75b345 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/common/exception/GlobalExceptionHandler.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/common/exception/GlobalExceptionHandler.java @@ -3,9 +3,12 @@ import ao.creativemode.kixi.client.OcrServiceClient.OcrClientException; import ao.creativemode.kixi.client.OcrServiceClient.OcrServerException; import ao.creativemode.kixi.common.dto.ProblemDetail; + import java.net.URI; import java.util.Map; +import java.util.concurrent.TimeoutException; import java.util.stream.Collectors; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; @@ -14,6 +17,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.support.WebExchangeBindException; import org.springframework.web.server.ServerWebExchange; + import reactor.core.publisher.Mono; /** diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionImageRepository.java b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionImageRepository.java index 8de7d45..8be7386 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionImageRepository.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionImageRepository.java @@ -1,7 +1,10 @@ package ao.creativemode.kixi.repository; -import ao.creativemode.kixi.model.QuestionImage; +import java.util.UUID; + import org.springframework.data.repository.reactive.ReactiveCrudRepository; + +import ao.creativemode.kixi.model.QuestionImage; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; From 853f5a2ee60293f21da2802ef5577d176ca3e4fd Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Sat, 7 Feb 2026 12:17:02 +0100 Subject: [PATCH 44/48] fix: refactor->code review with +code --- .../kixi/common/exception/GlobalExceptionHandler.java | 9 +++++---- .../kixi/controller/QuestionImageController.java | 3 +-- .../kixi/dto/questionimage/QuestionImageRequest.java | 3 +-- .../kixi/repository/QuestionImageRepository.java | 5 ++--- .../creativemode/kixi/service/QuestionImageService.java | 2 +- .../db/migration/V14__create_question_images_table.sql | 2 +- 6 files changed, 11 insertions(+), 13 deletions(-) diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/common/exception/GlobalExceptionHandler.java b/services/backend-api/src/main/java/ao/creativemode/kixi/common/exception/GlobalExceptionHandler.java index f75b345..0603479 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/common/exception/GlobalExceptionHandler.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/common/exception/GlobalExceptionHandler.java @@ -1,9 +1,5 @@ package ao.creativemode.kixi.common.exception; -import ao.creativemode.kixi.client.OcrServiceClient.OcrClientException; -import ao.creativemode.kixi.client.OcrServiceClient.OcrServerException; -import ao.creativemode.kixi.common.dto.ProblemDetail; - import java.net.URI; import java.util.Map; import java.util.concurrent.TimeoutException; @@ -18,6 +14,9 @@ import org.springframework.web.bind.support.WebExchangeBindException; import org.springframework.web.server.ServerWebExchange; +import ao.creativemode.kixi.client.OcrServiceClient.OcrClientException; +import ao.creativemode.kixi.client.OcrServiceClient.OcrServerException; +import ao.creativemode.kixi.common.dto.ProblemDetail; import reactor.core.publisher.Mono; /** @@ -34,6 +33,8 @@ public class GlobalExceptionHandler { private static final URI DEFAULT_TYPE = URI.create( "https://api.kixi.com/errors" ); + // Adiciona URI para erros OCR + private static final URI OCR_ERROR_TYPE = URI.create("https://api.kixi.ao/errors/ocr-error"); /** * Handle custom API exceptions with proper status codes. diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/QuestionImageController.java b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/QuestionImageController.java index fc23af5..d48a740 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/QuestionImageController.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/QuestionImageController.java @@ -12,7 +12,6 @@ import reactor.core.publisher.Mono; import java.net.URI; import java.util.List; -import java.util.UUID; import static org.springframework.http.HttpStatus.NO_CONTENT; @@ -40,7 +39,7 @@ public Mono>> listAllActive() { * Retrieves images associated with a specific question. */ @GetMapping("/question/{questionId}") - public Mono>> listByQuestion(@PathVariable UUID questionId) { + public Mono>> listByQuestion(@PathVariable Long questionId) { return service.findByQuestionId(questionId) .collectList() .map(ResponseEntity::ok); diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/questionimage/QuestionImageRequest.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/questionimage/QuestionImageRequest.java index 93c2fe5..db0bd93 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/questionimage/QuestionImageRequest.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/questionimage/QuestionImageRequest.java @@ -1,7 +1,6 @@ package ao.creativemode.kixi.dto.questionimage; import jakarta.validation.constraints.NotNull; -import java.util.UUID; /** * DTO for creating or updating QuestionImage metadata. @@ -9,7 +8,7 @@ */ public record QuestionImageRequest( @NotNull(message = "Question ID is required") - UUID questionId, + Long questionId, String caption, diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionImageRepository.java b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionImageRepository.java index 8be7386..9046b10 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionImageRepository.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/QuestionImageRepository.java @@ -1,6 +1,5 @@ package ao.creativemode.kixi.repository; -import java.util.UUID; import org.springframework.data.repository.reactive.ReactiveCrudRepository; @@ -8,7 +7,7 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -public interface QuestionImageRepository extends ReactiveCrudRepository { +public interface QuestionImageRepository extends ReactiveCrudRepository { Flux findAllByDeletedAtIsNull(); @@ -21,5 +20,5 @@ public interface QuestionImageRepository extends ReactiveCrudRepository findByQuestionIdAndDeletedAtIsNullOrderByOrderIndexAsc(UUID questionId); + Flux findByQuestionIdAndDeletedAtIsNullOrderByOrderIndexAsc(Long questionId); } \ No newline at end of file diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/QuestionImageService.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/QuestionImageService.java index 8c4a25b..e98cd55 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/service/QuestionImageService.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/QuestionImageService.java @@ -56,7 +56,7 @@ public Flux findAllDeleted() { /** * Finds all images associated with a specific question that have not been deleted */ - public Flux findByQuestionId(UUID questionId) { + public Flux findByQuestionId(Long questionId) { return repository.findByQuestionIdAndDeletedAtIsNullOrderByOrderIndexAsc(questionId) .map(this::toResponse); } diff --git a/services/backend-api/src/main/resources/db/migration/V14__create_question_images_table.sql b/services/backend-api/src/main/resources/db/migration/V14__create_question_images_table.sql index eef74ee..f754e6f 100644 --- a/services/backend-api/src/main/resources/db/migration/V14__create_question_images_table.sql +++ b/services/backend-api/src/main/resources/db/migration/V14__create_question_images_table.sql @@ -1,7 +1,7 @@ -- Create table for storing question-related images CREATE TABLE question_images ( id BIGSERIAL PRIMARY KEY, - question_id BIGINT NOT NULL, + question_id BIGSERIAL NOT NULL, image_url TEXT NOT NULL, caption TEXT, order_index INTEGER DEFAULT 0, From ccbc76b80c4fb43c38ee9caffc1d20c3a650fe78 Mon Sep 17 00:00:00 2001 From: abnerLouren Date: Sat, 7 Feb 2026 12:26:03 +0100 Subject: [PATCH 45/48] feature: reafctor migrations --- .../db/migration/V1__create_simulation_answer5_table.sql | 8 ++++---- .../db/migration/V9__rename_simulation_answer_table.sql | 7 ------- 2 files changed, 4 insertions(+), 11 deletions(-) delete mode 100644 services/backend-api/src/main/resources/db/migration/V9__rename_simulation_answer_table.sql diff --git a/services/backend-api/src/main/resources/db/migration/V1__create_simulation_answer5_table.sql b/services/backend-api/src/main/resources/db/migration/V1__create_simulation_answer5_table.sql index a75e073..5189660 100644 --- a/services/backend-api/src/main/resources/db/migration/V1__create_simulation_answer5_table.sql +++ b/services/backend-api/src/main/resources/db/migration/V1__create_simulation_answer5_table.sql @@ -1,4 +1,4 @@ -CREATE TABLE simulation_answer( +CREATE TABLE simulation_answers( id BIGSERIAL PRIMARY KEY, simulation_id BIGINT NOT NULL, question_id BIGINT NOT NULL, @@ -18,6 +18,6 @@ CREATE TABLE simulation_answer( CONSTRAINT uq_simulation_question UNIQUE(simulation_id, question_id) ); -CREATE INDEX idx_simulation_answer_simulation_id ON simulation_answer(simulation_id); -CREATE INDEX idx_simulation_answer_question_id ON simulation_answer(question_id); -CREATE INDEX idx_simulation_answer_deleted_at ON simulation_answer(deleted_at); +CREATE INDEX idx_simulation_answers_simulation_id ON simulation_answers(simulation_id); +CREATE INDEX idx_simulation_answers_question_id ON simulation_answers(question_id); +CREATE INDEX idx_simulation_answers_deleted_at ON simulation_answers(deleted_at); diff --git a/services/backend-api/src/main/resources/db/migration/V9__rename_simulation_answer_table.sql b/services/backend-api/src/main/resources/db/migration/V9__rename_simulation_answer_table.sql deleted file mode 100644 index 386cf8a..0000000 --- a/services/backend-api/src/main/resources/db/migration/V9__rename_simulation_answer_table.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Rename simulation_answer table to simulation_answers (plural) to follow naming convention -ALTER TABLE simulation_answer RENAME TO simulation_answers; - --- Rename indexes to match new table name -ALTER INDEX idx_simulation_answer_simulation_id RENAME TO idx_simulation_answers_simulation_id; -ALTER INDEX idx_simulation_answer_question_id RENAME TO idx_simulation_answers_question_id; -ALTER INDEX idx_simulation_answer_deleted_at RENAME TO idx_simulation_answers_deleted_at; From fafc2ff24bb3cc9482480e986d936e574ea7d5b3 Mon Sep 17 00:00:00 2001 From: Dizono Vundu Date: Sun, 1 Feb 2026 17:27:52 +0100 Subject: [PATCH 46/48] feat: OAuth JWT Auth and RBAC --- services/backend-api/pom.xml | 11 +- .../kixi/config/GoogleOAuth2Properties.java | 81 ++++++++++ .../kixi/config/JwtProperties.java | 28 ++++ .../kixi/config/SecurityConfig.java | 55 +++++++ .../kixi/controller/AuthController.java | 63 ++++++++ .../kixi/dto/auth/LoginRequest.java | 11 ++ .../kixi/dto/auth/LoginResponse.java | 14 ++ .../kixi/repository/AccountRepository.java | 2 + .../security/JwtAuthenticationFilter.java | 55 +++++++ .../kixi/service/AccountService.java | 8 +- .../kixi/service/AuthService.java | 146 ++++++++++++++++++ .../kixi/service/GoogleOAuth2Client.java | 62 ++++++++ .../creativemode/kixi/service/JwtService.java | 63 ++++++++ 13 files changed, 592 insertions(+), 7 deletions(-) create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/config/GoogleOAuth2Properties.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/config/JwtProperties.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/config/SecurityConfig.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/controller/AuthController.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/dto/auth/LoginRequest.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/dto/auth/LoginResponse.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/security/JwtAuthenticationFilter.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/service/AuthService.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/service/GoogleOAuth2Client.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/service/JwtService.java diff --git a/services/backend-api/pom.xml b/services/backend-api/pom.xml index e234015..ed65686 100644 --- a/services/backend-api/pom.xml +++ b/services/backend-api/pom.xml @@ -54,6 +54,11 @@ spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-security + + org.projectlombok lombok @@ -75,13 +80,13 @@ io.jsonwebtoken jjwt-api - 0.11.5 + 0.12.5 io.jsonwebtoken jjwt-impl - 0.11.5 + 0.12.5 runtime @@ -94,7 +99,7 @@ io.jsonwebtoken jjwt-jackson - 0.11.5 + 0.12.5 runtime diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/config/GoogleOAuth2Properties.java b/services/backend-api/src/main/java/ao/creativemode/kixi/config/GoogleOAuth2Properties.java new file mode 100644 index 0000000..ab89784 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/config/GoogleOAuth2Properties.java @@ -0,0 +1,81 @@ +package ao.creativemode.kixi.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "app.auth.google") +public class GoogleOAuth2Properties { + + private String clientId; + private String clientSecret; + private String redirectUri = "http://localhost:8080/api/v1/auth/google/callback"; + private String authorizationUri = "https://accounts.google.com/o/oauth2/v2/auth"; + private String tokenUri = "https://oauth2.googleapis.com/token"; + private String userInfoUri = "https://www.googleapis.com/oauth2/v2/userinfo"; + private String scope = "openid email profile"; + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public void setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + } + + public String getRedirectUri() { + return redirectUri; + } + + public void setRedirectUri(String redirectUri) { + this.redirectUri = redirectUri; + } + + public String getAuthorizationUri() { + return authorizationUri; + } + + public void setAuthorizationUri(String authorizationUri) { + this.authorizationUri = authorizationUri; + } + + public String getTokenUri() { + return tokenUri; + } + + public void setTokenUri(String tokenUri) { + this.tokenUri = tokenUri; + } + + public String getUserInfoUri() { + return userInfoUri; + } + + public void setUserInfoUri(String userInfoUri) { + this.userInfoUri = userInfoUri; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public String buildAuthorizationUrl(String state) { + return authorizationUri + "?client_id=" + clientId + + "&redirect_uri=" + java.net.URLEncoder.encode(redirectUri, java.nio.charset.StandardCharsets.UTF_8) + + "&response_type=code" + + "&scope=" + java.net.URLEncoder.encode(scope, java.nio.charset.StandardCharsets.UTF_8) + + (state != null ? "&state=" + state : ""); + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/config/JwtProperties.java b/services/backend-api/src/main/java/ao/creativemode/kixi/config/JwtProperties.java new file mode 100644 index 0000000..fa1c1fe --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/config/JwtProperties.java @@ -0,0 +1,28 @@ +package ao.creativemode.kixi.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "app.jwt") +public class JwtProperties { + + private String secret = "default-secret-change-in-production-min-256-bits"; + private long expirationMs = 86400000L; // 24 hours + + public String getSecret() { + return secret; + } + + public void setSecret(String secret) { + this.secret = secret; + } + + public long getExpirationMs() { + return expirationMs; + } + + public void setExpirationMs(long expirationMs) { + this.expirationMs = expirationMs; + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/config/SecurityConfig.java b/services/backend-api/src/main/java/ao/creativemode/kixi/config/SecurityConfig.java new file mode 100644 index 0000000..756d769 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/config/SecurityConfig.java @@ -0,0 +1,55 @@ +package ao.creativemode.kixi.config; + +import ao.creativemode.kixi.security.JwtAuthenticationFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.security.web.server.context.NoOpServerSecurityContextRepository; +import org.springframework.security.config.web.server.SecurityWebFiltersOrder; + +@Configuration +@EnableWebFluxSecurity +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + } + + @Bean + public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { + return http + .csrf(ServerHttpSecurity.CsrfSpec::disable) + .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable) + .formLogin(ServerHttpSecurity.FormLoginSpec::disable) + .securityContextRepository(NoOpServerSecurityContextRepository.getInstance()) + .authorizeExchange(exchange -> exchange + .pathMatchers("/api/v1/auth/**").permitAll() + .pathMatchers("/actuator/health").permitAll() + .anyExchange().authenticated() + ) + .exceptionHandling(handling -> handling + .authenticationEntryPoint((exchange, ex) -> { + exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); + return exchange.getResponse().setComplete(); + }) + .accessDeniedHandler((exchange, denied) -> { + exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN); + return exchange.getResponse().setComplete(); + }) + ) + .addFilterAt(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION) + .build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/AuthController.java b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/AuthController.java new file mode 100644 index 0000000..a8be181 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/AuthController.java @@ -0,0 +1,63 @@ +package ao.creativemode.kixi.controller; + +import ao.creativemode.kixi.config.GoogleOAuth2Properties; +import ao.creativemode.kixi.dto.auth.LoginRequest; +import ao.creativemode.kixi.dto.auth.LoginResponse; +import ao.creativemode.kixi.service.AuthService; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.net.URI; + +/** + * Autenticação: login tradicional (username/email + password) e login Google OAuth2. + */ +@RestController +@RequestMapping("/api/v1/auth") +public class AuthController { + + private final AuthService authService; + private final GoogleOAuth2Properties googleProperties; + + public AuthController(AuthService authService, GoogleOAuth2Properties googleProperties) { + this.authService = authService; + this.googleProperties = googleProperties; + } + + /** + * Login tradicional: username ou email + password. + * Devolve JWT e roles para uso em Authorization: Bearer <token>. + */ + @PostMapping("/login") + public Mono> login(@Valid @RequestBody LoginRequest request) { + return authService.login(request.usernameOrEmail(), request.password()) + .map(ResponseEntity::ok); + } + + /** + * Redireciona o utilizador para o consentimento Google OAuth2. + * Só funciona se app.auth.google.client-id estiver configurado. + */ + @GetMapping("/google") + public Mono> googleRedirect(ServerWebExchange exchange) { + if (googleProperties.getClientId() == null || googleProperties.getClientId().isBlank()) { + return Mono.just(ResponseEntity.badRequest().build()); + } + String state = java.util.UUID.randomUUID().toString(); + String url = googleProperties.buildAuthorizationUrl(state); + return Mono.just(ResponseEntity.status(302).location(URI.create(url)).build()); + } + + /** + * Callback do Google: troca o code por token, obtém userinfo, encontra/cria account, emite JWT. + * Resposta JSON com accessToken e roles (para uso em Authorization: Bearer <token>). + */ + @GetMapping("/google/callback") + public Mono> googleCallback(@RequestParam String code) { + return authService.loginWithGoogle(code) + .map(ResponseEntity::ok); + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/auth/LoginRequest.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/auth/LoginRequest.java new file mode 100644 index 0000000..c3232ce --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/auth/LoginRequest.java @@ -0,0 +1,11 @@ +package ao.creativemode.kixi.dto.auth; + +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank(message = "Username or email is required") + String usernameOrEmail, + + @NotBlank(message = "Password is required") + String password +) {} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/auth/LoginResponse.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/auth/LoginResponse.java new file mode 100644 index 0000000..4692b9d --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/auth/LoginResponse.java @@ -0,0 +1,14 @@ +package ao.creativemode.kixi.dto.auth; + +import java.time.Instant; +import java.util.List; + +public record LoginResponse( + String accessToken, + String tokenType, + Instant expiresAt, + Long accountId, + List roles +) { + public static final String TOKEN_TYPE = "Bearer"; +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/AccountRepository.java b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/AccountRepository.java index a979ec0..049eaeb 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/repository/AccountRepository.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/repository/AccountRepository.java @@ -19,5 +19,7 @@ public interface AccountRepository extends ReactiveCrudRepository Mono findByUsernameAndDeletedAtIsNull(String username); + Mono findByEmailAndDeletedAtIsNull(String email); + Mono findByUsernameAndIdNotAndDeletedAtIsNull(String username, Long id); } diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/security/JwtAuthenticationFilter.java b/services/backend-api/src/main/java/ao/creativemode/kixi/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..b01cdaa --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/security/JwtAuthenticationFilter.java @@ -0,0 +1,55 @@ +package ao.creativemode.kixi.security; + +import ao.creativemode.kixi.service.JwtService; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.stream.Collectors; + +@Component +public class JwtAuthenticationFilter implements WebFilter { + + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtService jwtService; + + public JwtAuthenticationFilter(JwtService jwtService) { + this.jwtService = jwtService; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + String authHeader = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION); + if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) { + return chain.filter(exchange); + } + String token = authHeader.substring(BEARER_PREFIX.length()).trim(); + if (token.isEmpty()) { + return chain.filter(exchange); + } + try { + Claims claims = jwtService.parseToken(token); + Long accountId = jwtService.getAccountId(claims); + List roles = jwtService.getRoles(claims); + List authorities = roles.stream() + .map(r -> new SimpleGrantedAuthority("ROLE_" + r)) + .collect(Collectors.toList()); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(accountId.toString(), null, authorities); + return chain.filter(exchange) + .contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication)); + } catch (JwtException e) { + return chain.filter(exchange); + } + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/AccountService.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/AccountService.java index 4cfe05f..8849e4e 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/service/AccountService.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/AccountService.java @@ -6,7 +6,7 @@ import ao.creativemode.kixi.model.Account; import ao.creativemode.kixi.repository.AccountRepository; import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -17,11 +17,11 @@ public class AccountService { private final AccountRepository repository; - private final BCryptPasswordEncoder passwordEncoder; + private final PasswordEncoder passwordEncoder; - public AccountService(AccountRepository repository) { + public AccountService(AccountRepository repository, PasswordEncoder passwordEncoder) { this.repository = repository; - this.passwordEncoder = new BCryptPasswordEncoder(); + this.passwordEncoder = passwordEncoder; } public Flux findAllActive() { diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/AuthService.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/AuthService.java new file mode 100644 index 0000000..fead86c --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/AuthService.java @@ -0,0 +1,146 @@ +package ao.creativemode.kixi.service; + +import ao.creativemode.kixi.common.exception.ApiException; +import ao.creativemode.kixi.dto.auth.LoginResponse; +import ao.creativemode.kixi.model.Account; +import ao.creativemode.kixi.model.AccountRole; +import ao.creativemode.kixi.model.Role; +import ao.creativemode.kixi.model.User; +import ao.creativemode.kixi.repository.AccountRepository; +import ao.creativemode.kixi.repository.AccountRoleRepository; +import ao.creativemode.kixi.repository.RoleRepository; +import ao.creativemode.kixi.repository.UserRepository; +import ao.creativemode.kixi.service.GoogleOAuth2Client.GoogleUserInfo; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +public class AuthService { + + private static final String DEFAULT_ROLE_NAME = "USER"; + + private final AccountRepository accountRepository; + private final AccountRoleRepository accountRoleRepository; + private final RoleRepository roleRepository; + private final UserRepository userRepository; + private final JwtService jwtService; + private final PasswordEncoder passwordEncoder; + private final GoogleOAuth2Client googleOAuth2Client; + + public AuthService(AccountRepository accountRepository, + AccountRoleRepository accountRoleRepository, + RoleRepository roleRepository, + UserRepository userRepository, + JwtService jwtService, + PasswordEncoder passwordEncoder, + GoogleOAuth2Client googleOAuth2Client) { + this.accountRepository = accountRepository; + this.accountRoleRepository = accountRoleRepository; + this.roleRepository = roleRepository; + this.userRepository = userRepository; + this.jwtService = jwtService; + this.passwordEncoder = passwordEncoder; + this.googleOAuth2Client = googleOAuth2Client; + } + + public Mono login(String usernameOrEmail, String password) { + return findAccountByUsernameOrEmail(usernameOrEmail.trim()) + .switchIfEmpty(Mono.error(ApiException.badRequest("Invalid username or password"))) + .filter(Account::getActive) + .switchIfEmpty(Mono.error(ApiException.badRequest("Account is inactive"))) + .filter(account -> passwordEncoder.matches(password, account.getPasswordHash())) + .switchIfEmpty(Mono.error(ApiException.badRequest("Invalid username or password"))) + .flatMap(account -> recordLogin(account) + .flatMap(updated -> loadRoleNames(updated.getId()) + .map(roles -> buildLoginResponse(updated.getId(), roles)))); + } + + private Mono findAccountByUsernameOrEmail(String input) { + return accountRepository.findByUsernameAndDeletedAtIsNull(input) + .switchIfEmpty(accountRepository.findByEmailAndDeletedAtIsNull(input)); + } + + private Mono recordLogin(Account account) { + account.setLastLogin(java.time.LocalDateTime.now()); + return accountRepository.save(account); + } + + private Mono> loadRoleNames(Long accountId) { + return accountRoleRepository.findByAccountIdAndDeletedAtIsNull(accountId) + .flatMap(ar -> roleRepository.findById(ar.getRoleId())) + .filter(role -> role.getDeletedAt() == null) + .map(Role::getName) + .collect(Collectors.toList()); + } + + private LoginResponse buildLoginResponse(Long accountId, List roles) { + String token = jwtService.generateToken(accountId, roles); + Instant expiresAt = Instant.now().plusMillis(jwtService.getExpirationMs()); + return new LoginResponse(token, LoginResponse.TOKEN_TYPE, expiresAt, accountId, roles); + } + + /** + * Login via Google OAuth2: troca o code por token, obtém userinfo, encontra ou cria Account/User, atribui role padrão, emite JWT. + */ + public Mono loginWithGoogle(String code) { + return googleOAuth2Client.exchangeCodeForAccessToken(code) + .flatMap(googleOAuth2Client::getUserInfo) + .flatMap(this::findOrCreateAccountFromGoogle) + .flatMap(account -> recordLogin(account) + .flatMap(updated -> loadRoleNames(updated.getId()) + .map(roles -> buildLoginResponse(updated.getId(), roles)))); + } + + private Mono findOrCreateAccountFromGoogle(GoogleUserInfo info) { + if (info.email() == null || info.email().isBlank()) { + return Mono.error(ApiException.badRequest("Google did not provide email")); + } + String email = info.email().trim().toLowerCase(); + return accountRepository.findByEmailAndDeletedAtIsNull(email) + .switchIfEmpty(createAccountAndUserFromGoogle(email, info)); + } + + private Mono createAccountAndUserFromGoogle(String email, GoogleUserInfo info) { + String username = email.split("@")[0]; + String passwordHash = passwordEncoder.encode(UUID.randomUUID().toString()); + Account account = new Account(); + account.setUsername(username); + account.setEmail(email); + account.setPasswordHash(passwordHash); + account.setEmailVerified(true); + account.setActive(true); + account.setDeletedAt(null); + + return accountRepository.save(account) + .flatMap(savedAccount -> Mono.when( + roleRepository.findByNameAndDeletedAtIsNull(DEFAULT_ROLE_NAME) + .flatMap(role -> { + AccountRole ar = new AccountRole(savedAccount.getId(), role.getId()); + return accountRoleRepository.save(ar); + }), + createUserFromGoogle(savedAccount.getId(), info) + ).thenReturn(savedAccount)); + } + + private Mono createUserFromGoogle(Long accountId, GoogleUserInfo info) { + String[] names = info.name() != null && !info.name().isBlank() + ? info.name().trim().split("\\s+", 2) + : new String[]{"", ""}; + String firstName = names[0]; + String lastName = names.length > 1 ? names[1] : ""; + User user = new User(); + user.setAccountId(accountId); + user.setFirstName(firstName); + user.setLastName(lastName); + user.setPhoto(info.picture()); + user.setDeletedAt(null); + return userRepository.save(user); + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/GoogleOAuth2Client.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/GoogleOAuth2Client.java new file mode 100644 index 0000000..f1050c4 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/GoogleOAuth2Client.java @@ -0,0 +1,62 @@ +package ao.creativemode.kixi.service; + +import ao.creativemode.kixi.config.GoogleOAuth2Properties; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.Map; + +/** + * Cliente para trocar code por token e obter userinfo do Google OAuth2. + */ +@Component +public class GoogleOAuth2Client { + + private final GoogleOAuth2Properties properties; + private final WebClient webClient = WebClient.builder().build(); + + public GoogleOAuth2Client(GoogleOAuth2Properties properties) { + this.properties = properties; + } + + public Mono exchangeCodeForAccessToken(String code) { + if (properties.getClientId() == null || properties.getClientSecret() == null) { + return Mono.error(new IllegalStateException("Google OAuth2 not configured (clientId/clientSecret missing)")); + } + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("code", code); + form.add("client_id", properties.getClientId()); + form.add("client_secret", properties.getClientSecret()); + form.add("redirect_uri", properties.getRedirectUri()); + form.add("grant_type", "authorization_code"); + + return webClient.post() + .uri(properties.getTokenUri()) + .contentType(org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData(form)) + .retrieve() + .bodyToMono(Map.class) + .map(m -> (String) m.get("access_token")) + .filter(t -> t != null && !t.isBlank()) + .switchIfEmpty(Mono.error(new IllegalArgumentException("Invalid or expired code"))); + } + + public Mono getUserInfo(String accessToken) { + return webClient.get() + .uri(properties.getUserInfoUri()) + .headers(h -> h.setBearerAuth(accessToken)) + .retrieve() + .bodyToMono(Map.class) + .map(m -> new GoogleUserInfo( + (String) m.get("email"), + (String) m.get("name"), + (String) m.get("picture") + )); + } + + public record GoogleUserInfo(String email, String name, String picture) {} +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/JwtService.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/JwtService.java new file mode 100644 index 0000000..41d0e0b --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/JwtService.java @@ -0,0 +1,63 @@ +package ao.creativemode.kixi.service; + +import ao.creativemode.kixi.config.JwtProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class JwtService { + + private final JwtProperties properties; + private final SecretKey key; + + public JwtService(JwtProperties properties) { + this.properties = properties; + this.key = Keys.hmacShaKeyFor(properties.getSecret().getBytes(StandardCharsets.UTF_8)); + } + + public String generateToken(Long accountId, List roles) { + Instant now = Instant.now(); + Instant expiry = now.plusMillis(properties.getExpirationMs()); + return Jwts.builder() + .subject(accountId.toString()) + .claim("roles", roles) + .issuedAt(Date.from(now)) + .expiration(Date.from(expiry)) + .signWith(key) + .compact(); + } + + public Claims parseToken(String token) { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + @SuppressWarnings("unchecked") + public List getRoles(Claims claims) { + List list = claims.get("roles", List.class); + if (list == null) return List.of(); + return list.stream() + .map(Object::toString) + .collect(Collectors.toList()); + } + + public Long getAccountId(Claims claims) { + return Long.parseLong(claims.getSubject()); + } + + public long getExpirationMs() { + return properties.getExpirationMs(); + } +} From 9020364c3ae6cce1bad161129a9b60fd1547b82b Mon Sep 17 00:00:00 2001 From: Dizono Vundu Date: Sun, 1 Feb 2026 17:34:27 +0100 Subject: [PATCH 47/48] docs: adding Auth and RBAC implementation guide --- .../auth-jwt-rbac-oauth2.md | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 conceptual/architecture/implementation-guides/auth-jwt-rbac-oauth2.md diff --git a/conceptual/architecture/implementation-guides/auth-jwt-rbac-oauth2.md b/conceptual/architecture/implementation-guides/auth-jwt-rbac-oauth2.md new file mode 100644 index 0000000..1b80459 --- /dev/null +++ b/conceptual/architecture/implementation-guides/auth-jwt-rbac-oauth2.md @@ -0,0 +1,91 @@ +# Implementation Guide: Authentication and Authorization (JWT + RBAC + Google OAuth2) + +**Status:** Proposed for implementation + +Sistema de autenticação e autorização seguro com JWT, RBAC (Role e AccountRole) e login via Google OAuth2. + +--- + +## 1. Visão geral + +- **Login tradicional:** `POST /api/v1/auth/login` (username ou email + password) → valida contra `Account` (BCrypt), carrega roles via `AccountRole`, emite JWT. +- **Login Google:** utilizador é redirecionado para o Google → callback com `code` → backend troca por access token, obtém email/nome, encontra ou cria `Account` e `User`, atribui role padrão se novo → emite o nosso JWT. +- **Rotas:** públicas = `/api/v1/auth/**`; privadas = resto da API, com filtro JWT e opcionalmente RBAC por endpoint. + +--- + +## 2. Fluxos + +### 2.1 Login tradicional + +1. Cliente envia `POST /api/v1/auth/login` com `{ "usernameOrEmail": "...", "password": "..." }`. +2. Backend resolve account por username ou email, verifica password com BCrypt, verifica conta ativa. +3. Carrega roles do account via `AccountRole` + `Role`. +4. Gera JWT com claims: `sub` (accountId), `roles` (lista de nomes), `exp` (expiração). +5. Resposta: `{ "accessToken": "...", "tokenType": "Bearer", "expiresAt": "...", "accountId": 1, "roles": ["ADMIN"] }`. + +### 2.2 Login Google OAuth2 + +1. Cliente acede a `GET /api/v1/auth/google` → backend redireciona para Google (URL de autorização com `client_id`, `redirect_uri`, `scope=openid email profile`). +2. Utilizador autentica-se no Google; Google redireciona para `GET /api/v1/auth/google/callback?code=...`. +3. Backend troca `code` por access_token (POST ao token endpoint do Google com client_id, client_secret, code, redirect_uri). +4. Backend chama userinfo do Google (email, name) e procura `Account` por email. +5. Se não existir: cria `Account` (email, username derivado do email, sem password ou password aleatório), cria `User` (firstName/lastName a partir do name), atribui role padrão (ex.: USER) via `AccountRole`. +6. Se existir: garante que tem pelo menos um role. +7. Emite JWT (mesmo formato do login tradicional) e devolve (ex.: redirect para frontend com token em query ou cookie, ou JSON no body). + +--- + +## 3. Componentes + +| Componente | Responsabilidade | +|------------|------------------| +| **JwtService** | Gerar JWT (accountId, roles, exp), validar token, extrair claims. | +| **AuthController** | Endpoints: login, redirect Google, callback Google. | +| **AuthService** | Validar credenciais (username/email + password), carregar roles, orquestrar login e callback Google. | +| **SecurityWebFilterChain** | Permitir `/api/v1/auth/**`, exigir autenticação no resto; filtro que lê `Authorization: Bearer `, valida JWT e preenche SecurityContext (principal = accountId, authorities = roles). | +| **RBAC** | Em endpoints protegidos: ler roles do SecurityContext; opcionalmente `@PreAuthorize("hasRole('ADMIN')")` ou verificação manual. | + +--- + +## 4. Entidades envolvidas + +- **Account:** username, email, passwordHash, emailVerified, active, lastLogin (já existente). +- **User:** accountId, firstName, lastName, photo (já existente; perfil ligado ao Account). +- **Role / AccountRole:** N:N já implementado; roles do account usados no JWT e na autorização. + +--- + +## 5. Configuração + +- **application.properties / env:** + - `jwt.secret` (base64 ou string), `jwt.expiration-ms` + - `google.client-id`, `google.client-secret`, `app.auth.google.redirect-uri` (ex.: `http://localhost:8080/api/v1/auth/google/callback`) +- **Google Cloud Console:** criar credenciais OAuth 2.0 (tipo “Web application”), definir redirect URI igual ao configurado. + +--- + +## 6. Rotas públicas vs privadas + +- **Públicas (sem JWT):** + - `POST /api/v1/auth/login` + - `GET /api/v1/auth/google` (redirect) + - `GET /api/v1/auth/google/callback` + - Opcional: health, actuator, docs. +- **Privadas:** todas as outras sob `/api/v1/**`; filtro JWT obrigatório; RBAC por endpoint conforme necessário. + +--- + +## 7. Erros + +- **401 Unauthorized:** token em falta, inválido ou expirado. +- **403 Forbidden:** token válido mas sem permissão (role insuficiente). +- Mensagens claras em JSON (ex.: ProblemDetail) para o cliente. + +--- + +## 8. Testes + +- Unitários: JwtService (gerar/validar), AuthService (validação de password, carga de roles). +- Integração: login tradicional, callback Google (mock do token endpoint e userinfo), acesso a rota protegida com/sem token e com/sem role. +- Cenários: token expirado (401), role insuficiente (403). From 6b3408753f9f80f74b5712220df803baea84b2f3 Mon Sep 17 00:00:00 2001 From: CreadorLanda Date: Wed, 4 Feb 2026 17:48:18 +0100 Subject: [PATCH 48/48] feat(chat): add Groq AI chatbot integration with Swagger UI Conflicts: --- docker-compose.yml | 278 +++++------------------------------- infra/docker/ocr.Dockerfile | 140 +----------------- 2 files changed, 39 insertions(+), 379 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index f0f1063..bcc9e55 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,52 +1,20 @@ -# ============================================================================= -# Docker Compose Configuration for Kixi Platform -# ============================================================================= -# -# This file defines all services for the Kixi platform: -# - postgres: PostgreSQL 16 database -# - backend-api: Spring Boot WebFlux API (Java 17) -# - ocr-service: FastAPI OCR service with PaddleOCR-VL (Python 3.11) -# -# Usage: -# docker-compose up --build # Start all services -# docker-compose up -d postgres # Start only database -# docker-compose up ocr-service # Start OCR service with dependencies -# docker-compose logs -f backend-api # Follow backend logs -# docker-compose down -v # Stop and remove volumes -# -# Profiles: -# docker-compose --profile gpu up # Start with GPU-enabled OCR -# docker-compose --profile cache up # Start with Redis cache -# -# Environment Variables: -# Copy .env.example to .env and customize as needed -# -# ============================================================================= - services: - # =========================================================================== - # PostgreSQL Database - # =========================================================================== - postgres: - image: postgres:16-alpine - container_name: kixi-postgres - restart: unless-stopped + kixi_postgres: + image: postgres:15.15-alpine + container_name: kixi_postgres environment: - POSTGRES_DB: ${POSTGRES_DB:-kixi} - POSTGRES_USER: ${POSTGRES_USER:-kixi} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kixi_secret} - PGDATA: /var/lib/postgresql/data/pgdata + POSTGRES_DB: kixi_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres ports: - - "${POSTGRES_PORT:-5433}:5432" + - "5433:5432" volumes: - - postgres_data:/var/lib/postgresql/data - - ./services/backend-api/docker/postgres/init.sql:/docker-entrypoint-initdb.d/01-init.sql:z + - kixi_postgres_data:/var/lib/postgresql/data + - ./services/backend-api/docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql + networks: + - kixi_network healthcheck: - test: - [ - "CMD-SHELL", - "pg_isready -U ${POSTGRES_USER:-kixi} -d ${POSTGRES_DB:-kixi}", - ] + test: ["CMD-SHELL", "pg_isready -U postgres -d kixi_db"] interval: 10s timeout: 5s retries: 5 @@ -89,217 +57,39 @@ services: - kixi_network restart: unless-stopped - # =========================================================================== - # Backend API (Spring Boot WebFlux) - # =========================================================================== - # Build uses Dockerfile from infra/docker/ with project root as context - # This allows access to all project files during build - backend-api: + kixi_backend: build: context: . - dockerfile: infra/docker/backend.Dockerfile - container_name: kixi-backend-api - restart: unless-stopped + dockerfile: Dockerfile + container_name: kixi_backend environment: - # Database Configuration - SPRING_R2DBC_URL: r2dbc:postgresql://postgres:5432/${POSTGRES_DB:-kixi} - SPRING_R2DBC_USERNAME: ${POSTGRES_USER:-kixi} - SPRING_R2DBC_PASSWORD: ${POSTGRES_PASSWORD:-kixi_secret} - # OCR Service Configuration - OCR_SERVICE_URL: http://ocr-service:8000 - OCR_SERVICE_TIMEOUT_MS: ${OCR_SERVICE_TIMEOUT_MS:-120000} - # Application Configuration - SPRING_PROFILES_ACTIVE: ${SPRING_PROFILE:-docker} - SERVER_PORT: 8080 - # Logging Configuration - LOGGING_LEVEL_ROOT: ${LOG_LEVEL:-INFO} - LOGGING_LEVEL_AO_CREATIVEMODE: ${APP_LOG_LEVEL:-DEBUG} - # JWT Configuration (optional) - JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} - JWT_EXPIRATION_MS: ${JWT_EXPIRATION_MS:-86400000} + DB_HOST: kixi_postgres + DB_PORT: 5432 + DB_NAME: kixi_db + DB_USERNAME: postgres + DB_PASSWORD: postgres + GROQ_API_KEY: ${GROQ_API_KEY} + GROQ_MODEL: ${GROQ_MODEL:-llama-3.3-70b-versatile} ports: - - "${BACKEND_PORT:-8080}:8080" - depends_on: - postgres: - condition: service_healthy - ocr-service: - condition: service_healthy - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 60s + - "8080:8080" networks: - - kixi-network - - # =========================================================================== - # OCR Service (FastAPI + PaddleOCR-VL) - # =========================================================================== - # Build uses Dockerfile from infra/docker/ with project root as context - # For local development, you can use the Dockerfile in services/ocr-service/ - ocr-service: - build: - context: . - dockerfile: infra/docker/ocr.Dockerfile - # Alternative: Use local Dockerfile for development - # context: ./services/ocr-service - # dockerfile: Dockerfile - container_name: kixi-ocr-service + - kixi_network restart: unless-stopped - environment: - # Service Configuration - SERVICE_NAME: ocr-service - SERVICE_VERSION: 1.0.0 - ENVIRONMENT: ${ENVIRONMENT:-docker} - DEBUG: ${OCR_DEBUG:-false} - # Server Configuration - HOST: 0.0.0.0 - PORT: 8000 - WORKERS: ${OCR_WORKERS:-1} - # OCR Configuration - OCR_LANG: ${OCR_LANG:-pt} - OCR_USE_GPU: ${OCR_USE_GPU:-false} - OCR_USE_ANGLE_CLS: ${OCR_USE_ANGLE_CLS:-true} - OCR_SHOW_LOG: ${OCR_SHOW_LOG:-false} - # Processing Configuration - MAX_IMAGE_SIZE_MB: ${MAX_IMAGE_SIZE_MB:-20.0} - MAX_IMAGES_PER_REQUEST: ${MAX_IMAGES_PER_REQUEST:-10} - PROCESSING_TIMEOUT_SECONDS: ${PROCESSING_TIMEOUT_SECONDS:-120} - MIN_CONFIDENCE_THRESHOLD: ${MIN_CONFIDENCE_THRESHOLD:-0.5} - # Preprocessing Configuration - ENABLE_DESKEW: ${ENABLE_DESKEW:-true} - ENABLE_DENOISE: ${ENABLE_DENOISE:-true} - TARGET_DPI: ${TARGET_DPI:-300} - # Logging Configuration - LOG_LEVEL: ${OCR_LOG_LEVEL:-INFO} - LOG_FORMAT: ${OCR_LOG_FORMAT:-json} - # Metrics Configuration - ENABLE_METRICS: ${ENABLE_METRICS:-true} - ports: - - "${OCR_PORT:-8000}:8000" - volumes: - # Persist PaddleOCR models between container restarts - - ocr_models:/home/ocr/.paddleocr - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 60s - deploy: - resources: - limits: - memory: 4G - reservations: - memory: 2G - networks: - - kixi-network - # =========================================================================== - # OCR Service with GPU Support (Optional Profile) - # =========================================================================== - # Start with: docker-compose --profile gpu up - # Requires NVIDIA Docker runtime and CUDA-compatible GPU - ocr-service-gpu: + kixi_ocr: build: - context: . - dockerfile: infra/docker/ocr.Dockerfile - container_name: kixi-ocr-service-gpu - profiles: - - gpu - restart: unless-stopped - environment: - SERVICE_NAME: ocr-service-gpu - SERVICE_VERSION: 1.0.0 - ENVIRONMENT: ${ENVIRONMENT:-docker} - DEBUG: ${OCR_DEBUG:-false} - HOST: 0.0.0.0 - PORT: 8000 - WORKERS: ${OCR_WORKERS:-1} - # Enable GPU acceleration - OCR_LANG: ${OCR_LANG:-pt} - OCR_USE_GPU: "true" - OCR_USE_ANGLE_CLS: "true" - OCR_SHOW_LOG: "false" - MAX_IMAGE_SIZE_MB: "20.0" - MAX_IMAGES_PER_REQUEST: "10" - PROCESSING_TIMEOUT_SECONDS: "120" - MIN_CONFIDENCE_THRESHOLD: "0.5" - ENABLE_DESKEW: "true" - ENABLE_DENOISE: "true" - TARGET_DPI: "300" - LOG_LEVEL: INFO - LOG_FORMAT: json - ENABLE_METRICS: "true" + context: ./services/ocr-service + dockerfile: ../../infra/docker/ocr.Dockerfile + container_name: kixi_ocr ports: - - "${OCR_GPU_PORT:-8001}:8000" - volumes: - - ocr_models_gpu:/home/ocr/.paddleocr - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 120s - deploy: - resources: - reservations: - devices: - - driver: nvidia - count: 1 - capabilities: [gpu] + - "8000:8000" networks: - - kixi-network - - # =========================================================================== - # Redis Cache (Optional Profile) - # =========================================================================== - # Start with: docker-compose --profile cache up - # Used for caching OCR results and session management - redis: - image: redis:7-alpine - container_name: kixi-redis - profiles: - - cache + - kixi_network restart: unless-stopped - command: redis-server --appendonly yes - ports: - - "${REDIS_PORT:-6379}:6379" - volumes: - - redis_data:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 3 - networks: - - kixi-network -# ============================================================================= -# Networks -# ============================================================================= -networks: - kixi-network: - driver: bridge - name: kixi-network - -# ============================================================================= -# Volumes -# ============================================================================= volumes: - # PostgreSQL data persistence - postgres_data: - name: kixi-postgres-data - - # PaddleOCR models cache (CPU version) - ocr_models: - name: kixi-ocr-models + kixi_postgres_data: - # PaddleOCR models cache (GPU version) - ocr_models_gpu: - name: kixi-ocr-models-gpu - - # Redis data persistence - redis_data: - name: kixi-redis-data +networks: + kixi_network: + driver: bridge diff --git a/infra/docker/ocr.Dockerfile b/infra/docker/ocr.Dockerfile index 2447409..79d1641 100644 --- a/infra/docker/ocr.Dockerfile +++ b/infra/docker/ocr.Dockerfile @@ -1,124 +1,5 @@ -# OCR Service Dockerfile -# -# Multi-stage build for the Kixi OCR Service using PaddleOCR-VL -# Optimized for production deployments with minimal image size -# -# Build from project root: -# docker build -f infra/docker/ocr.Dockerfile -t kixi-ocr-service . -# -# Or using docker-compose (recommended): -# docker-compose up --build ocr-service +FROM python:3.11-slim -# ============================================================================= -# Stage 1: Builder -# ============================================================================= -FROM python:3.11-slim AS builder - -# Set environment variables -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=1 \ - PIP_DISABLE_PIP_VERSION_CHECK=1 - -# Install build dependencies -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - gcc \ - g++ \ - libffi-dev \ - libssl-dev \ - && rm -rf /var/lib/apt/lists/* - -# Create virtual environment -RUN python -m venv /opt/venv -ENV PATH="/opt/venv/bin:$PATH" - -# Copy requirements and install dependencies -WORKDIR /app -COPY services/ocr-service/requirements.txt . - -# Install Python dependencies -RUN pip install --upgrade pip setuptools wheel && \ - pip install -r requirements.txt - -# ============================================================================= -# Stage 2: Runtime -# ============================================================================= -FROM python:3.11-slim AS runtime - -# Labels -LABEL maintainer="Kixi Team " \ - org.opencontainers.image.title="Kixi OCR Service" \ - org.opencontainers.image.description="OCR Service using PaddleOCR-VL for text extraction from exam images" \ - org.opencontainers.image.version="1.0.0" \ - org.opencontainers.image.vendor="Creative Mode" \ - org.opencontainers.image.source="https://github.com/creative-mode/kixi" - -# Set environment variables -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PYTHONFAULTHANDLER=1 \ - PATH="/opt/venv/bin:$PATH" \ - # Application settings - SERVICE_NAME=ocr-service \ - SERVICE_VERSION=1.0.0 \ - ENVIRONMENT=production \ - DEBUG=false \ - HOST=0.0.0.0 \ - PORT=8000 \ - WORKERS=1 \ - # OCR settings - OCR_LANG=pt \ - OCR_USE_GPU=false \ - OCR_USE_ANGLE_CLS=true \ - OCR_SHOW_LOG=false \ - # Processing settings - MAX_IMAGE_SIZE_MB=20.0 \ - MAX_IMAGES_PER_REQUEST=10 \ - PROCESSING_TIMEOUT_SECONDS=120 \ - MIN_CONFIDENCE_THRESHOLD=0.5 \ - ENABLE_DESKEW=true \ - ENABLE_DENOISE=true \ - TARGET_DPI=300 \ - # Logging - LOG_LEVEL=INFO \ - LOG_FORMAT=json \ - # Metrics - ENABLE_METRICS=true \ - # Timezone - TZ=Africa/Luanda - -# Install runtime dependencies -# Note: libgl1-mesa-glx was renamed to libgl1 in Debian Trixie -RUN apt-get update && apt-get install -y --no-install-recommends \ - # OpenCV dependencies (Debian Trixie compatible) - libgl1 \ - libglib2.0-0t64 \ - libsm6 \ - libxext6 \ - libxrender1 \ - libgomp1 \ - # PDF processing dependencies - poppler-utils \ - # Fonts for proper text rendering - fonts-liberation \ - fonts-dejavu-core \ - fonts-freefont-ttf \ - # Curl for health checks - curl \ - # Timezone data - tzdata \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* - -# Create non-root user for security -RUN groupadd --gid 1000 ocr && \ - useradd --uid 1000 --gid ocr --shell /bin/bash --create-home ocr - -# Copy virtual environment from builder -COPY --from=builder /opt/venv /opt/venv - -# Set working directory WORKDIR /app # Instalar dependências do sistema para OCR (Tesseract) @@ -129,22 +10,11 @@ RUN apt-get update && apt-get install -y \ libglib2.0-0 \ && rm -rf /var/lib/apt/lists/* -# Create directories for models and cache -RUN mkdir -p /home/ocr/.paddleocr && \ - chown -R ocr:ocr /home/ocr/.paddleocr && \ - # Create tmp directory for processing - mkdir -p /tmp/ocr && \ - chown -R ocr:ocr /tmp/ocr +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt -# Switch to non-root user -USER ocr +COPY app/ ./app/ -# Expose port EXPOSE 8000 -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \ - CMD curl -f http://localhost:8000/health || exit 1 - -# Default command - run with uvicorn -CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]